17 KiB
Ordering Guide
Canonical spec for any sortable resource in the DataApi system. Uses a single fractional-indexing design (fractional-indexing, Rocicorp, ~2 KB gzip) — PATCH /{resource}/:id/order with an anchor body. Scales from tens to thousands of rows without background rebalancing; applies uniformly whether the view is paginated or not. Replaces the two incompatible predecessors (PATCH /miniapps absolute sortOrder integers and PATCH /mcp-servers full orderedIds list).
Every sortable resource stores its position as a string order_key column. A reorder is always relative against an anchor (another row's id, or a first / last sentinel), never an absolute index. The server computes a new key between neighbours in one transaction; the renderer optimistically reorders its local cache and revalidates on completion.
Quickstart — The Four Layers
A sortable resource touches four layers. The toolkit provides one import per layer — this section shows the end-to-end picture at a glance before the specification dives into specifics.
1. Database schema — orderKeyColumns + index helpers
File: src/main/data/db/schemas/_columnHelpers.ts. Spread ...orderKeyColumns into the table definition (the field name is locked to orderKey at the type level) and attach the right index helper.
import { sqliteTable, text } from 'drizzle-orm/sqlite-core'
import { orderKeyColumns, orderKeyIndex, scopedOrderKeyIndex } from './_columnHelpers'
// Whole-table ordering
export const mcpServer = sqliteTable(
'mcp_server',
{
id: text().primaryKey(),
...orderKeyColumns, // TEXT NOT NULL `order_key`
},
(t) => [orderKeyIndex('mcp_server')(t)] // CREATE INDEX mcp_server_order_key_idx
)
// Partitioned ordering (scope = providerId)
export const userModel = sqliteTable(
'user_model',
{
id: text().primaryKey(),
providerId: text('provider_id').notNull(),
...orderKeyColumns,
},
(t) => [scopedOrderKeyIndex('user_model', 'providerId')(t)] // (provider_id, order_key)
)
2. API schema — OrderEndpoints<TRes>
File: packages/shared/data/api/schemas/_endpointHelpers.ts. Intersect the resource's schema type with OrderEndpoints<'/res'> to inject the two PATCH endpoints into ApiSchemas.
import type { OrderEndpoints } from './_endpointHelpers'
export type McpServerSchemas = {
'/mcp-servers': { GET: { ... }; POST: { ... } }
'/mcp-servers/:id': { GET: { ... }; PATCH: { ... }; DELETE: { ... } }
} & OrderEndpoints<'/mcp-servers'>
// Adds '/mcp-servers/:id/order' (PATCH) and '/mcp-servers/order:batch' (PATCH)
// with correct params / body (OrderRequest, OrderBatchRequest) / response.
Handlers validate with the matching Zod schemas (OrderRequestSchema, OrderBatchRequestSchema) exported from the same file.
3. Server-side service — insertWithOrderKey / applyMoves / resetOrder
File: src/main/data/services/utils/orderKey.ts. Use these helpers on any POST create path, reorder path, and /order:reset path — never hand-write tx.insert(table).values({ orderKey }) or touch fractional-indexing directly.
import { insertWithOrderKey, insertManyWithOrderKey, applyMoves, resetOrder } from './utils/orderKey'
await insertWithOrderKey(tx, mcpServerTable, values, { pkColumn: mcpServerTable.id })
await insertManyWithOrderKey(tx, mcpServerTable, valuesList, { pkColumn: mcpServerTable.id })
await applyMoves(tx, mcpServerTable, moves, { pkColumn: mcpServerTable.id })
await resetOrder(tx, mcpServerTable, orderedRows, { pkColumn: mcpServerTable.id })
// Scoped variants pass a `scope` SQL expression, e.g.:
// scope: eq(userModelTable.providerId, providerId)
Migrators (Redux/Dexie → SQLite) use the pure-function counterparts assignOrderKeysInSequence / assignOrderKeysByScope from src/main/data/migration/v2/utils/orderKey.ts — see v2 Migration Guide — Order-Key Stamping in Migrators.
4. Renderer — useReorder hook
File: src/renderer/src/data/hooks/useReorder.ts. One hook on top of useMutation; drop its applyReorderedList straight into a drag-and-drop callback.
import { useQuery } from '@data/hooks/useDataApi'
import { useReorder } from '@data/hooks/useReorder'
function McpServerList() {
const { data } = useQuery('/mcp-servers')
const { applyReorderedList, isPending } = useReorder('/mcp-servers')
return <DraggableList items={data?.items ?? []} onReorder={applyReorderedList} />
}
// Non-`id` primary key (e.g. miniapp.appId):
useReorder('/mini-apps', { idKey: 'appId' })
Optimistic writes / server revalidation / failure rollback are all handled internally through the DataApi cache hooks (useReadCache / useWriteCache / useInvalidateCache) — the component never tracks the list in local state and never calls SWR directly.
The sections below spell out the normative spec for each layer.
1. API Shape
PATCH /{resource}/:id/order — primary
Request body — exactly one of:
{ "before": "row-abc" } // move :id before row-abc
{ "after": "row-xyz" } // move :id after row-xyz
{ "position": "first" } // (or "last") move :id to the head/tail
Response: 204 No Content.
| Code | HTTP | When |
|---|---|---|
VALIDATION_ERROR |
422 | Body does not match the three-way union |
NOT_FOUND |
404 | :id or before/after anchor id does not exist |
POST /{resource}/order:reset — auxiliary (opt-in per resource)
{ "preset": "alphabetical" }
Each resource declares its own preset enum inline. Server sorts the rows per preset, then rewrites every order_key in a single transaction. Not funneled through useReorder — call with a plain useMutation:
useMutation('POST', '/providers/order:reset', { refresh: ['/providers'] })
PATCH /{resource}/order:batch — auxiliary (used internally by useReorder)
{
"moves": [
{ "id": "a", "anchor": { "after": "b" } },
{ "id": "c", "anchor": { "position": "first" } }
]
}
Moves apply sequentially in one transaction; each anchor resolves against the state produced by preceding moves. Duplicate ids fold (last wins, warn logged); no-op moves (newKey === currentKey) are skipped. useReorder.applyReorderedList auto-dispatches: zero changes → no-op, one change → primary endpoint, two+ → this endpoint. Same error codes as the primary.
2. Database Schema Rules
(Code examples are in Quickstart §1.)
- Column:
order_key TEXT NOT NULL. Always injected via...orderKeyColumns; the spread locks the TS field name toorderKey. - Index: required. Use
orderKeyIndex(tableName)(t)for whole-table orscopedOrderKeyIndex(tableName, scopeColumn)(t)for partitioned tables. - Known partition dimensions in the codebase:
topic.groupId,group.entityType,user_model.providerId,miniapp.status,user_provider.isEnabled. - No secondary order axes. Secondary semantics like topic pinned are modelled as scope values (e.g. a reserved
groupId = '__pinned__'bucket), not a second column. One axis, one API.
3. Server-Side Service Helpers
All runtime order_key reads and writes go through src/main/data/services/utils/orderKey.ts — the single place in the codebase allowed to import fractional-indexing.
| Helper | Use for |
|---|---|
insertWithOrderKey(tx, table, values, { pkColumn, position?, scope? }) |
Single-row POST create on a sortable table. |
insertManyWithOrderKey(tx, table, valuesList, { pkColumn, position?, scope? }) |
Batch/seed create (≥2 rows). One boundary lookup + one bulk insert; insertWithOrderKey delegates to it internally. |
applyMoves(tx, table, moves, { pkColumn, scope? }) |
Reorder path for both PATCH /:id/order (wrapped as a single move) and PATCH /order:batch. |
resetOrder(tx, table, orderedRows, { pkColumn }) |
POST /order:reset — caller sorts by preset, helper rewrites every key. |
computeNewOrderKey(...) |
Internal to applyMoves; exported only for tests. |
generateOrderKeySequence / generateOrderKeyBetween / generateOrderKeySequenceBetween |
The ONLY sanctioned wrappers around fractional-indexing. Services, migrators, and custom-migration scripts all re-import from here. |
Binding semantics:
pkColumnis required. Primary-key column names vary (miniapp.appId,mcpServer.id,topic.id, ...); helpers make zero assumptions.- Must run inside an outer transaction. Helpers take
txand never open their own. scopeapplies symmetrically to target, anchor, and neighbour lookups — anchoring across scopes throws.insertManyWithOrderKeypreserves input order underORDER BY orderKey ASC. Forposition: 'last'the batch lands after existing rows; for'first'before; within the batch, relative order mirrorsvaluesList.
Scoped usage:
// Topic: groupId is nullable, both NULL and non-NULL are real partitions
await insertWithOrderKey(tx, topicTable, values, {
pkColumn: topicTable.id,
scope: values.groupId ? eq(topicTable.groupId, values.groupId) : isNull(topicTable.groupId),
})
// user_model: scope by providerId
await applyMoves(tx, userModelTable, moves, {
pkColumn: userModelTable.id,
scope: eq(userModelTable.providerId, providerId),
})
4. Renderer Integration
4.1 Sequence
User Component SWR cache Main SQLite
| | | | |
| drop→ | applyReorderedList |
| |--- useWriteCache(url, next) ---> overwrite |
| [UI instantly updates from optimistic value] |
| |--- PATCH /:res/:id/order --> IPC --> UPDATE --> |
| |<---------- 204 ----------------- |
| | useMutation.refresh → auto GET → overwrite |
| [UI settles with server truth] |
| on error: useInvalidateCache(url) → GET → overwrite |
Three observable steps: optimistic write → PATCH → revalidate (or invalidate on error). The optimistic value is derived by a pure reorderLocally(items, id, anchor, idKey) from the current cache — the hook never constructs order_key client-side. All cache side-effects go through the DataApi hooks; useReorder holds zero direct SWR imports.
4.2 Non-id primary keys — the idKey option
useReorder('/mini-apps', { idKey: 'appId' })
Flows into both the optimistic reducer and the new-list diff. The server-facing contract is unchanged — move(id, anchor) still takes a plain string id, PATCH body shape is untouched. idKey only affects how the client extracts ids from cached items.
Single field only — composite keys like ${providerId}:${modelName} are out of scope; pre-project a synthetic id field before passing items to the drag library.
4.3 Anti-pattern: don't shadow SWR with local state
// WRONG — fights SWR cache, flickers, goes stale
const [list, setList] = useState<Item[]>([])
useEffect(() => { setList(data?.items ?? []) }, [data])
// RIGHT — render straight from SWR; optimistic updates go to the cache
const { data } = useQuery('/mcp-servers')
<DraggableList items={data?.items ?? []} onReorder={applyReorderedList} />
The cached list is the source of truth. Every subscriber of the same key stays in sync automatically.
5. v2 Migrator Usage
Pure-function helpers assignOrderKeysInSequence / assignOrderKeysByScope stamp pre-flattened arrays; no DB access; delegate to generateOrderKeySequence from the service-layer wrapper.
→ See v2 Migration Guide — Order-Key Stamping in Migrators.
6. URL and Naming Conventions
- Sub-resource name:
/order. Never/sort,/rank,/position. - Custom methods: colon notation —
/{resource}/order:reset,/{resource}/order:batch(Google AIP-136; see API Design Guidelines — Non-CRUD Operations). - Body enum:
position: 'first' | 'last'— distinct from the/orderURL segment. - DB column:
order_key(SQL) /orderKey(TS), alwaysTEXT NOT NULL. No nullable variants. - Type names: every order-related export prefixed with
Order(OrderRequest,OrderRequestSchema,OrderBatchRequest,OrderBatchRequestSchema,OrderEndpoints). NoSort*/Position*/Rank*aliases — theOrderprefix is what keeps_endpointHelpers.tsclassifiable as it grows.
Disallowed: POST /{res}:reorder, POST /{res}/reorder, PUT /{res}/order (rejected full-list design), collection-level PATCH /{res} for reordering, nested URLs like /groups/:gid/topics/:id/order (group dimension travels in the body, not the path).
7. Migration Checklist — New Sortable Resource
Complete in one PR:
- Schema:
...orderKeyColumns+orderKeyIndex(tableName)(t)orscopedOrderKeyIndex(tableName, scopeColumn)(t). - Endpoints:
& OrderEndpoints<'/{res}'>on the resource's schema type. AddPOST /{res}/order:resetinline if needed. Handlers validate bodies withOrderRequestSchema/OrderBatchRequestSchema. - Service:
insertWithOrderKeyfor create,applyMovesfor reorder,resetOrderfor reset. For partitioned tables, pass the rightscope:topic:topic.groupId ? eq(topicTable.groupId, groupId) : isNull(topicTable.groupId)group:eq(groupTable.entityType, entityType)user_model:eq(userModelTable.providerId, providerId)miniapp:eq(miniappTable.status, status)user_provider/mcp_server: whole-table (scope: undefined)
- Migrator: replace legacy
sortOrder = indexwithassignOrderKeysByScope(orassignOrderKeysInSequencefor whole-table). Dropindex/sortOrderparameters fromtransform*functions. - Renderer:
useReorder(collectionUrl), oruseReorder(collectionUrl, { idKey: 'appId' })for non-idpk. - Drizzle custom migration (runs when the consuming resource's PR lands, not part of the base-infrastructure PR): add
order_keynullable → backfill bucket-by-bucket viagenerateOrderKeySequenceimported from@data/services/utils/orderKey(never fromfractional-indexingdirectly) → promote toNOT NULL→ drop the oldsort_ordercolumn → create the index. Until this step runs, the production schema keeps the legacysort_order INTcolumn — the base infrastructure never touches existing tables.
8. FAQ
Paginated lists — what if the drag anchor is off-page? The server resolves before/after by id against the database, not against the client's loaded window. Any visible row's id is a valid anchor. That's what makes the scheme work for topics/messages that never fit on screen.
Is order:reset safe under concurrent calls? Yes. Reset is deterministic (same preset + row data → same keys via generateOrderKeySequence). SQLite's write lock serializes concurrent resets; the second overwrites the first and the end state is consistent.
Known boundary — fractional-indexing collisions. Two transactions reading the same anchor pair simultaneously both call generateKeyBetween and produce identical new keys. order_key is not UNIQUE, so both rows succeed — the effect is a tie in ORDER BY order_key (two rows alternate in the UI). No data loss; the next drag self-repairs. Single-user SQLite makes it extremely rare. Future fix: composite (order_key, pk) index + deterministic tiebreaker. Don't implement now.
Known boundary — multi-window drag flicker. Window A mid-drag receives window B's cache invalidation → revalidates with server state (not yet including A's in-flight reorder) → optimistic value overwritten → UI snaps back → A's PATCH returns → another revalidate brings new order in → UI jumps forward. ~150–300 ms, visual-only, no data loss. Future fix: suspend external revalidations while an in-flight PATCH holds the key.
Why no dual-mode sort (default-by-createdAt / switch-to-custom)? order_key is always present and maintained regardless of which sort mode the UI shows. Mode switching belongs at the query layer — a business-level Preference picks ORDER BY lastAccessedAt DESC or ORDER BY order_key ASC at read time. Keeping order_key unconditional gives a uniform write path and avoids "when do we first materialize keys" complexity.
9. Group Ordering (Future Extension)
Placeholder. When the first resource that needs group-level ordering adopts the spec (most likely topic), this section will cover:
- Extending
OrderRequestSchemawith an optionalgroupIdfield. - Cross-group move semantics — a
groupIdchange is a row update (regularPATCH /:id), not a reorder. The/orderendpoint only moves rows within their current scope. scopeinteraction when a row changes groups in adjacent operations.
No speculative groupId field, helper variants, or UI code is added before a real consumer drives the design.