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;