Files
CherryHQ-cherry-studio/docs/references/data/data-ordering-guide.md

391 lines
24 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Ordering Guide
Canonical spec for any sortable resource in the DataApi system. Uses a single fractional-indexing design ([fractional-indexing](https://www.npmjs.com/package/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 /mini-apps` 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.
```typescript
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: `src/shared/data/api/schemas/_endpointHelpers.ts`. Intersect the resource's schema type with `OrderEndpoints<'/res'>` to inject the two PATCH endpoints into `ApiSchemas`.
```typescript
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.
```typescript
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](./v2-migration-guide.md#order-key-stamping-in-migrators).
### 4. Renderer — `useReorder` hook
File: `src/renderer/data/hooks/useReorder.ts`. One hook on top of `useMutation`; drop its `applyReorderedList` straight into a drag-and-drop callback.
```tsx
import { useQuery } from '@data/hooks/useDataApi'
import { useReorder } from '@data/hooks/useReorder'
// Paginated collection — items live under `.items`.
function McpServerList() {
const { data } = useQuery('/mcp-servers')
const { applyReorderedList, isPending } = useReorder('/mcp-servers')
return <DraggableList items={data?.items ?? []} onReorder={applyReorderedList} />
}
// Flat-array collection — the response *is* the list.
function PinList() {
const { data } = useQuery('/pins')
const { applyReorderedList } = useReorder('/pins')
return <DraggableList items={data ?? []} 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. `useReorder` reads the items list from the cache by auto-detecting flat arrays and `{ items }`-shaped objects; see §4.3 for nested shapes.
---
The sections below spell out the normative spec for each layer.
## 1. API Shape
### `PATCH /{resource}/:id/order` — primary
Request body — exactly one of:
```jsonc
{ "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)
```jsonc
{ "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`:
```typescript
useMutation('POST', '/providers/order:reset', { refresh: ['/providers'] })
```
### `PATCH /{resource}/order:batch` — auxiliary (used internally by `useReorder`)
```jsonc
{
"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 to `orderKey`.
- **Index**: required. Use `orderKeyIndex(tableName)(t)` for whole-table or `scopedOrderKeyIndex(tableName, scopeColumn)(t)` for partitioned tables.
- **Known partition dimensions** in the codebase:
- Live (active consumers): `group.entityType`, `pin.entityType`, `user_model.providerId`, `miniapp.status`.
- Planned / hypothetical: none currently.
- **No secondary order axes**. Each sortable table exposes exactly one `order_key`. Orthogonal user intents — e.g. "in a group" vs "pinned" — are modelled as separate tables, not as overloaded scope values on a shared column. Resource-specific design (polymorphic shape, purge contracts, concurrency semantics) lives in each schema / service's JSDoc, not here — this guide scopes to the ordering mechanism only.
---
## 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`. |
| `applyScopedMoves(tx, table, moves, { pkColumn, scopeColumn })` | Reorder path for tables partitioned by a discriminator column. Infers scope from the target row, enforces single-scope batches, and delegates to `applyMoves`. See §3.1. |
| `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:
- **`pkColumn` is required.** Primary-key column names vary (`miniapp.appId`, `mcpServer.id`, `topic.id`, ...); helpers make zero assumptions.
- **Must run inside an outer transaction.** Helpers take `tx` and never open their own.
- **`scope` applies symmetrically** to target, anchor, and neighbour lookups — anchoring across scopes throws.
- **`insertManyWithOrderKey` preserves input order under `ORDER BY orderKey ASC`.** For `position: 'last'` the batch lands after existing rows; for `'first'` before; within the batch, relative order mirrors `valuesList`.
Scoped usage:
```typescript
// user_model: scope by providerId
await applyMoves(tx, userModelTable, moves, {
pkColumn: userModelTable.id,
scope: eq(userModelTable.providerId, providerId),
})
```
---
## 3.1 Scoped Reorder Pattern
Scope inference is a **service-layer** responsibility. The HTTP client sends `{ before: X }` / `{ after: X }` / `{ position: 'first' | 'last' }` — it never names the scope. The handler validates the body with `OrderRequestSchema` and forwards the id and anchor verbatim; it does not read the row or resolve the scope. The service SELECTs the target row, reads its scope column, and applies `eq(scopeColumn, value)` to `applyMoves`.
`applyScopedMoves` (`src/main/data/services/utils/orderKey.ts`) is the infra-level helper that encodes this pattern. `GroupService` and `PinService` are its first two consumers; any future table scoped by a discriminator column should prefer it over hand-rolling `SELECT → applyMoves` boilerplate.
**Contract**:
- A batch that spans more than one distinct scope value is rejected with `VALIDATION_ERROR`. Scoped reorders must stay within one partition; cross-scope moves are a row update (`PATCH /:id`), not a reorder.
- A target id missing from the table is reported as `NOT_FOUND`. The missing-id check runs before the multi-scope check.
- Empty `moves` is a no-op (no DB access).
```ts
await applyScopedMoves(tx, pinTable, moves, {
pkColumn: pinTable.id,
scopeColumn: pinTable.entityType
})
```
---
## 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
```tsx
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 Supported cache shapes
`useReorder` inspects the cached value at `collectionUrl` to locate the items list. Three shapes are recognized out of the box:
| Shape | Example endpoints | How items are extracted |
|---|---|---|
| **Flat array** `T[]` | `GET /pins`, `GET /groups`, `GET /tags`, `GET /providers` | The cache value *is* the array. |
| **Wrapped pagination** `{ items, total, page }` / `{ items, nextCursor }` | `GET /mini-apps`, `GET /mcp-servers`, `GET /assistants`, `GET /knowledges` | Reads `cache.items`; preserves `total` / `page` / `nextCursor` on optimistic writes. |
| **Naked items wrapper** `{ items: T[] }` | `GET /knowledges/:id/items` | Reads `cache.items`. |
No caller configuration is required for any of the three. Both pagination shapes (`OffsetPaginationResponse` and `CursorPaginationResponse`) fall under the same `{ items }` branch — metadata fields are passed through unchanged. For the pagination model itself, see the [Pagination Guide](./data-pagination-guide.md).
### 4.4 Using accessors for nested shapes
For responses the defaults cannot reach — grouped views, GraphQL-style connections, or envelopes with a different field name — pass `selectItems` and `updateItems` together. Passing one without the other throws at hook construction.
```tsx
// Envelope with a different field name: cache = { data: T[], meta }
useReorder('/custom', {
selectItems: (cache) => (cache as Envelope).data,
updateItems: (cache, items) => ({ ...(cache as Envelope), data: items })
})
// Grouped view: cache = { groups: [{ id, items }], version }
useReorder('/grouped-view', {
selectItems: (cache) => (cache as GroupedView).groups[0].items,
updateItems: (cache, items) => {
const c = cache as GroupedView
return { ...c, groups: [{ ...c.groups[0], items }, ...c.groups.slice(1)] }
}
})
// GraphQL-ish connection: cache = { edges: [{ node }], pageInfo }
useReorder('/connection', {
selectItems: (cache) => (cache as Conn).edges.map((e) => e.node),
updateItems: (cache, items) => {
const c = cache as Conn
return { ...c, edges: items.map((node, i) => ({ ...c.edges[i], node })) }
}
})
```
`updateItems` must be the inverse of `selectItems`: a round trip through the pair must yield the same items list.
### 4.5 Degradation: not-loaded vs. unrecognized cache
The hook distinguishes two failure modes so calls remain safe even when preconditions aren't met.
| Precondition | `move` / `applyBatch` | `applyReorderedList` |
|---|---|---|
| **Cache not yet loaded** (`readCache` returns `undefined`) | no-op, warn on each call | no-op, warn on each call |
| **Cache loaded, shape unrecognized** | skip optimistic overlay, **PATCH still fires**, warn (de-duplicated per hook) | no-op, warn (de-duplicated per hook) |
Rationale:
- "Not loaded" is a UX timing bug — the user interacted before data arrived. Every occurrence is worth logging; each is an independent event.
- "Unrecognized shape" is a caller contract issue (missing accessors for a nested cache). `move`'s `id` / `anchor` arguments are self-contained and the server can honor them without a client-side diff, so the PATCH is allowed through. `applyReorderedList`, by contrast, needs a current baseline to compute minimal moves — without one, the new list would have to be replayed blindly, which is unsafe. The warning is deduplicated because a misconfigured accessor would otherwise log on every drag.
### 4.6 Anti-pattern: don't shadow SWR with local state
```tsx
// 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](./v2-migration-guide.md#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](./api-design-guidelines.md#non-crud-operations)).
- **Body enum**: `position: 'first' | 'last'` — distinct from the `/order` URL segment.
- **DB column**: `order_key` (SQL) / `orderKey` (TS), always `TEXT NOT NULL`. No nullable variants.
- **Type names**: every order-related export prefixed with `Order` (`OrderRequest`, `OrderRequestSchema`, `OrderBatchRequest`, `OrderBatchRequestSchema`, `OrderEndpoints`). No `Sort*` / `Position*` / `Rank*` aliases — the `Order` prefix is what keeps `_endpointHelpers.ts` classifiable 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:
1. **Schema**: `...orderKeyColumns` + `orderKeyIndex(tableName)(t)` or `scopedOrderKeyIndex(tableName, scopeColumn)(t)`.
2. **Endpoints**: `& OrderEndpoints<'/{res}'>` on the resource's schema type. Add `POST /{res}/order:reset` inline if needed. Handlers validate bodies with `OrderRequestSchema` / `OrderBatchRequestSchema`.
3. **Service**: `insertWithOrderKey` for create, `applyMoves` (or `applyScopedMoves` for discriminator-partitioned tables) for reorder, `resetOrder` for reset. For partitioned tables, the relevant scope predicate is:
- `group`: `eq(groupTable.entityType, entityType)` — live (`GroupService.reorder` / `reorderBatch` via `applyScopedMoves`).
- `pin`: `eq(pinTable.entityType, entityType)` — live (`PinService.reorder` / `reorderBatch` via `applyScopedMoves`).
- `user_model`: `eq(userModelTable.providerId, providerId)`.
- `miniapp`: `eq(miniappTable.status, status)`.
- `topic` / `user_provider` / `mcp_server`: whole-table (`scope: undefined`), except topic service may narrow to non-deleted rows.
New scoped consumers should prefer `applyScopedMoves` (which handles scope lookup and rejects cross-scope batches) over composing `applyMoves` with a manually assembled `eq(...)` scope.
4. **Migrator**: replace legacy `sortOrder = index` with `assignOrderKeysByScope` (or `assignOrderKeysInSequence` for whole-table). Drop `index` / `sortOrder` parameters from `transform*` functions.
5. **Renderer**: `useReorder(collectionUrl)`, or `useReorder(collectionUrl, { idKey: 'appId' })` for non-`id` pk. If the `GET` response is neither a flat array nor `{ items }`-shaped (e.g. a grouped or connection-style envelope), also pass `selectItems` / `updateItems` — see §4.4.
6. **Drizzle custom migration** (runs when the consuming resource's PR lands, not part of the base-infrastructure PR): add `order_key` nullable → backfill bucket-by-bucket via `generateOrderKeySequence` imported from `@data/services/utils/orderKey` (never from `fractional-indexing` directly) → promote to `NOT NULL` → drop the old `sort_order` column → create the index. Until this step runs, the production schema keeps the legacy `sort_order INT` column — 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 could call `generateKeyBetween` and produce **identical** new keys. `order_key` is not `UNIQUE`, so both rows would 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. It is extremely rare in practice: the single synchronous better-sqlite3 connection runs each write transaction to completion in one JS turn, so same-anchor inserts serialize by construction and cannot each read the old neighborhood and insert against it (the serialization comes from the synchronous single connection, not `BEGIN IMMEDIATE`). Cursor pagination now applies a deterministic `(order_key, id)` tiebreaker through the shared `keysetOrdering` (`services/utils/keysetCursor.ts`), so keyset page-walking stays deterministic under an `order_key` tie **by construction**, rather than relying on `order_key` being unique — this is pagination determinism, not a fix for an observed skip/dup. Read/display paths that sort by `order_key` **alone** (no id tiebreaker) can still briefly alternate two tied rows until the next drag. The composite `(order_key, pk)` index from the original "future fix" is still **not** added (negligible at single-user scale; avoids schema↔migration drift) — only the deterministic tiebreaker landed, applied at the query layer.
**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. ~150300 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
`group` table — `src/main/data/db/schemas/group.ts`. Partition column: `entityType`. Each entityType owns an independent `orderKey` sequence. `GroupService.reorder` / `reorderBatch` delegate to `applyScopedMoves` with `scopeColumn: groupTable.entityType`; see §3.1.
Resource design (API shape, consumer-side `groupId` linkage) is documented on `GroupService` and consumer migrations — not here.
---
## 10. Pin Ordering
`pin` table — `src/main/data/db/schemas/pin.ts`. Partition column: `entityType`. Pin order is scoped per entityType via `scopedOrderKeyIndex('pin', 'entityType')`. `PinService.reorder` / `reorderBatch` delegate to `applyScopedMoves` with `scopeColumn: pinTable.entityType`; see §3.1.
Resource design (polymorphic `(entityType, entityId)` shape, idempotent concurrent-safe `pin()`, `purgeForEntityTx` delete contract, hard-delete-on-unpin) is documented on `pin.ts` schema and `PinService` — not here.