mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-22 10:33:27 +00:00
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:
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user