Files
pad/internal/store/migrations/076_item_version_seq.sql
T
xarmian 37ec77d110 fix(store): monotonic tie-breaker for same-second item version ordering (BUG-2270)
Adds a per-item monotonic `version_seq` column (dual migrations: SQLite 076 / Postgres 054, backfilled via ROW_NUMBER) so version-history RECONSTRUCTION resolves same-second versions deterministically instead of by the random-UUID PK. Reconstruction paths (shouldCreateItemVersion, ListItemVersions/Resolved, export) order by version_seq; the timeline keyset path (ListItemVersionsBeforeTime) keeps its id-consistent cursor.

Confirming Codex (high effort): found + fixed one keyset-pagination P2 (order/cursor key mismatch). make test-pg green (migration verified against Postgres). Go CI job red only on the pre-existing govulncheck advisory tracked in BUG-2278.

https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
2026-07-21 16:16:15 -04:00

36 lines
1.8 KiB
SQL

-- Add version_seq: a per-item monotonic version counter that gives
-- item_versions a deterministic tie-breaker (BUG-2270).
--
-- item_versions.created_at is second-precision RFC3339. Two versions
-- minted in the same wall-clock second (a version RESTORE plus rapid
-- edits both do this) tie on created_at, and the id PK is a random
-- UUIDv4 — useless as an ordering tie-breaker. SQLite has rowid but
-- Postgres does not, so rowid can't be the cross-dialect fix. Result:
-- the newest→oldest reconstruction walk (ListItemVersions /
-- ListItemVersionsResolved) resolved same-second versions in arbitrary
-- order, corrupting reconstructed history and "latest".
--
-- version_seq is assigned COALESCE(MAX(version_seq),0)+1 per item in the
-- SAME transaction as each version INSERT. Version creation is serialized
-- per item under the item lock, so MAX+1 is race-safe. The ORDER BYs
-- become `created_at DESC, version_seq DESC` (newest-first) and
-- `created_at, version_seq` (export, oldest-first).
--
-- NOTE: no `IF NOT EXISTS` — SQLite's ALTER TABLE ADD COLUMN rejects it.
ALTER TABLE item_versions ADD COLUMN version_seq INTEGER NOT NULL DEFAULT 0;
-- Backfill existing rows deterministically per item. ROW_NUMBER over
-- (created_at, rowid) reproduces insertion order: rowid is monotonic with
-- insertion for SQLite's implicit rowid, so same-second rows keep the
-- order they were written. Window functions work on SQLite >= 3.25;
-- modernc.org/sqlite bundles a far newer engine.
WITH ranked AS (
SELECT id AS vid,
ROW_NUMBER() OVER (PARTITION BY item_id ORDER BY created_at, rowid) AS rn
FROM item_versions
)
UPDATE item_versions
SET version_seq = (SELECT rn FROM ranked WHERE ranked.vid = item_versions.id);
CREATE INDEX IF NOT EXISTS idx_item_versions_item_seq ON item_versions(item_id, version_seq);