From f13e299d44a6eb7b0595634a94f4ff875e55fe8f Mon Sep 17 00:00:00 2001 From: xarmian Date: Mon, 11 May 2026 09:57:17 -0400 Subject: [PATCH] feat(web): typed client wrapper for /items-index (TASK-1345) (#487) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(web): typed client wrapper + ItemIndexResponse for /items-index (TASK-1345) Adds the TypeScript surface for the local-first read model bootstrap endpoint shipped in TASK-1344: - `ItemIndexRow` — `Omit` so adding a column to `Item` flows into the skinny row shape automatically. - `ItemIndexResponse` — `{ items, total, cursor }` wrapper. `cursor` is documented as opaque since Phase 2 swaps the placeholder for a `seq` cursor. - `api.items.listIndex(ws, { collection?, includeArchived? })` — mirrors `listByCollection`'s shape, hits `/workspaces/{ws}/items-index` (workspace-level, not `/items/index`, to avoid colliding with an item whose slug is `"index"` — see PR #486 round 1). No callers wired yet — that's TASK-1346 (ListView virtualization) and the IDB sync layer in Phase 2. Parent: PLAN-1343. * fix(web): strip empty content field in listIndex per Codex review (round 1) Codex round 1 [P2] flagged that the server still ships `content: ""` on every row of /items-index because `models.Item.Content` is tagged `json:"content"` without `omitempty`. TypeScript's `Omit` hides the field from downstream consumers, so naive spread/cache code could silently overwrite real item bodies with the empty string. Enforce the typed contract at the wrapper boundary: parse the raw response with `content` typed as optional, then destructure it out of each row before returning. The returned object has no `content` key, matching `ItemIndexRow`'s shape both at compile time AND runtime. Future cleanup option (separate task): add a Go DTO struct so the server doesn't put the empty field on the wire in the first place. The client-side strip is the minimal fix that addresses the correctness risk without touching the server contract. --- web/src/lib/api/client.ts | 51 ++++++++++++++++++++++++++++++++++++++ web/src/lib/types/index.ts | 20 +++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/web/src/lib/api/client.ts b/web/src/lib/api/client.ts index 510ca813..9b9463e2 100644 --- a/web/src/lib/api/client.ts +++ b/web/src/lib/api/client.ts @@ -7,6 +7,8 @@ import type { CollectionUpdate, Item, ItemCreate, + ItemIndexResponse, + ItemIndexRow, ItemUpdate, ItemLink, ItemLinkCreate, @@ -322,6 +324,55 @@ export const api = { `/workspaces/${ws}/collections/${coll}/items${qs(params)}` ), + /** + * Skinny-projection cross-collection listing for the local-first + * read model (PLAN-1343 / TASK-1344). Returns every item in a + * workspace MINUS the rich-text `content` body, plus a `total` + * count and a forward-looking `cursor` placeholder. + * + * Optional filters mirror the server: `collection` narrows to one + * collection slug, `include_archived` flips the soft-delete gate. + * + * Endpoint: `GET /api/v1/workspaces/{ws}/items-index`. The path is + * deliberately at workspace level (sibling to `/plans-progress`) + * rather than `/items/index` to avoid colliding with any item + * whose slug is `"index"` — see PR #486 Codex round 1. + * + * The server's `ListItemsIndex` query doesn't scan `i.content`, but + * the Go struct serializes the zero value (`content: ""`) over the + * wire because `models.Item.Content` has no `omitempty`. Strip it + * here so the returned shape matches `ItemIndexRow`'s + * `Omit` contract — preventing downstream code + * from spreading a row back into the canonical item store and + * silently blanking the rich-text body. Per Codex round 1 [P2] + * on PR #487. + */ + listIndex: async ( + ws: string, + opts?: { collection?: string; includeArchived?: boolean } + ): Promise => { + const raw = await request<{ + items: (ItemIndexRow & { content?: string })[]; + total: number; + cursor: string; + }>( + `/workspaces/${ws}/items-index${qs({ + collection: opts?.collection, + include_archived: opts?.includeArchived ? 'true' : undefined, + })}` + ); + const items: ItemIndexRow[] = raw.items.map((row) => { + // Destructure to discard the always-empty `content` key so + // the returned object truly has no `content` property — + // `delete row.content` would mutate the parsed JSON in + // place, but the explicit rest pattern survives strict + // linting and produces a new shallow copy per row. + const { content: _ignored, ...rest } = row; + return rest; + }); + return { items, total: raw.total, cursor: raw.cursor }; + }, + create: (ws: string, coll: string, data: ItemCreate) => request(`/workspaces/${ws}/collections/${coll}/items`, { method: 'POST', diff --git a/web/src/lib/types/index.ts b/web/src/lib/types/index.ts index 4ac3e68e..72290512 100644 --- a/web/src/lib/types/index.ts +++ b/web/src/lib/types/index.ts @@ -442,6 +442,26 @@ export interface Item { decision_log?: ItemDecisionLogEntry[]; } +// ─── Items index (skinny projection) ───────────────────────────────────────── +// `ItemIndexRow` is the row shape returned by `GET /workspaces/{ws}/items-index` +// (TASK-1344): every column on `Item` EXCEPT the rich-text `content` body, so +// the local-first read model (PLAN-1343) can hydrate a workspace-wide index +// without paying the body cost on bootstrap. +// +// Derived from `Item` via `Omit<…, 'content'>` so adding a new column to +// `Item` automatically flows into the index row without a second edit. +export type ItemIndexRow = Omit; + +export interface ItemIndexResponse { + items: ItemIndexRow[]; + total: number; + // `cursor` is a placeholder until Phase 2 lands the monotonic `seq` + // column — today the server returns the maximum `updated_at` across + // the result set (RFC3339Nano), or `"0"` when the workspace is empty. + // Clients should treat the value as opaque. + cursor: string; +} + export interface ItemCreate { title: string; content?: string;