Files
pad/internal/store/migrations/053_items_seq.sql
T
xarmian 7456b5aed6 feat(store): add workspace-scoped monotonic seq column to items (TASK-1352) (#492)
* feat(store): add workspace-scoped monotonic seq column to items (TASK-1352)

Adds an `items.seq` column that bumps on every mutation
(create/update/soft-delete/restore) as the cursor mechanic for the
local-first read model's delta sync (PLAN-1343, DOC-1342 design
decision #1). Each mutation stamps `MAX(seq) + 1 WHERE workspace_id = ?`
inside the same transaction that performs the write, with a Postgres
advisory lock keyed on the workspace serializing concurrent
seq-bumping mutations. SQLite's single-writer rule covers the same
guarantee there.

Migration backfills existing rows with sequential per-workspace seqs
in (updated_at, id) order so every workspace has a non-zero MAX(seq)
floor immediately. Adds an idx_items_workspace_seq index supporting
both the `/items-index` cursor read and the future `/items-changes`
range scan.

The Seq field is now populated through every items SELECT helper
(GetItem, GetItemIncludeDeleted, ListItems, ListItemsIndex,
listItemsFTS, SearchItems, ItemsModifiedSince, GetChildItems,
ListStarredItems, ResolveItemIncludeDeleted, GetItemBySlugIncludeDeleted)
and the workspace import path stamps it via the same MAX+1 subquery
so imported rows don't all collapse to seq=0.

Parent: PLAN-1343. Foundation for TASK-1353 (wire seq into
/items-index cursor) and TASK-1354 (/items-changes delta endpoint).

* fix(store): bump items.seq on role reorder, MoveItem, and field migrations per Codex review (round 1)

Codex round 1 flagged that UpdateRoleSortOrder was rewriting
items.role_sort_order without bumping the new workspace-scoped
seq column — delta-sync clients would miss role-board reorders
until a full refresh. The same gap applied to MoveItem (collection
change) and MigrateItemFieldValues (bulk select-option rename),
which are also user-visible mutations the cursor must surface.

Each path now:
  - acquires the workspace seq advisory lock (no-op on SQLite)
  - stamps seq = MAX(seq)+1 inside the same transaction

The bulk rename gives all rows affected by a single statement the
same seq value (MAX+1 at statement start). That preserves the
"no overlap, no gap" cursor contract — a client at cursor < MAX
sees them all in one batch, at cursor >= MAX sees none.
2026-05-11 12:51:49 -04:00

43 lines
1.9 KiB
SQL

-- Migration 053: items.seq + workspace-scoped monotonic counter (TASK-1352).
--
-- Adds a per-workspace monotonically-increasing `seq` column that bumps on
-- every items mutation (create / update / soft-delete / restore). Foundation
-- for the local-first read model's delta-sync cursor (PLAN-1343,
-- DOC-1342 design decision #1).
--
-- Robust against the clock-skew / same-millisecond-write / NTP-step
-- correctness holes that an `updated_at` watermark would carry. Each
-- mutation reads `MAX(seq) + 1 WHERE workspace_id = ?` inside the same
-- transaction that performs the write; SQLite's single-writer rule
-- makes that collision-free without an extra lock.
--
-- `seq` is workspace-scoped, NOT global — each workspace has its own
-- monotonic counter so a busy workspace can't fragment another's range
-- and so cursors from one workspace can't accidentally apply to
-- another.
ALTER TABLE items ADD COLUMN seq INTEGER NOT NULL DEFAULT 0;
-- Backfill: assign every existing row a seq in (workspace_id,
-- updated_at, id) order. ROW_NUMBER() OVER PARTITION gives each
-- workspace its own 1..N sequence so the post-migration MAX(seq) per
-- workspace == the row count of that workspace. SQLite supports
-- UPDATE..FROM since 3.33, which is well below the minimum version
-- the rest of the migrations already require.
UPDATE items
SET seq = sub.rn
FROM (
SELECT id,
ROW_NUMBER() OVER (PARTITION BY workspace_id ORDER BY updated_at, id) AS rn
FROM items
) AS sub
WHERE items.id = sub.id;
-- Supports two read patterns:
-- * /items-index cursor read: MAX(seq) per workspace
-- * /items-changes?since= range scan: seq > ? ORDER BY seq ASC
-- DESC is chosen to match the MAX read; the planner can still use the
-- index for the ASC range scan because the column is sorted on the
-- workspace_id leading edge.
CREATE INDEX IF NOT EXISTS idx_items_workspace_seq ON items(workspace_id, seq DESC);