feat(web): typed client wrapper for /items-index (TASK-1345) (#487)

* 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<Item, 'content'>` 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<Item, 'content'>`
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.
This commit is contained in:
xarmian
2026-05-11 09:57:17 -04:00
committed by GitHub
parent e83e7ef413
commit f13e299d44
2 changed files with 71 additions and 0 deletions
+51
View File
@@ -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<Item, 'content'>` 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<ItemIndexResponse> => {
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<Item>(`/workspaces/${ws}/collections/${coll}/items`, {
method: 'POST',
+20
View File
@@ -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<Item, 'content'>;
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;