Files
pad/internal/models/item_workspace_move.go
T
xarmian bf14c1168a feat(store): add item_workspace_moves provenance table (TASK-2356)
Phase 1 of PLAN-2357. Durable record of "this item was copied/moved from
workspace A to workspace B", backing the forward redirect (TASK-2359) and
the destination's back-pointer. Implements DR-2 / DR-2a.

Paired, dual-dialect, forward-only migrations (migrations/077 +
pgmigrations/055). archived_source distinguishes a move from a plain copy
(INTEGER on SQLite, BOOLEAN on Postgres, written through dialect.BoolToInt).
source_seq is a NULLABLE per-source move ordinal that exists solely so two
moves inside the same second are orderable — created_at is second-precision
RFC3339, so archive -> restore -> move again would otherwise resolve to an
arbitrary destination. Partial index (source_item_id, source_seq DESC) WHERE
archived_source, deliberately NOT unique: restore-then-move-again legitimately
repeats. The back direction IS uniquely indexed — a destination item is
created by exactly one copy, in the same transaction that writes its
provenance row, so a duplicate there would silently change which source the
back-pointer names.

Cascade is asymmetric on purpose, inverting item_collection_moves: the
archived source is precisely the row whose pointer must survive, so
source_item_id carries no FK at all; target_item_id cascades, because a
pointer at a vanished destination is worse than no pointer.

Store accessors: a tx-taking insert helper (no self-committing variant — the
row must land in the copy transaction), a forward lookup returning a SET
newest-first, and a back lookup. The insert rejects an archived row with no
seq and a copy row with one, so DR-2a's ordering invariant is enforced at the
write boundary rather than assumed. NULL ordering is normalized with COALESCE
because SQLite and Postgres disagree on DESC NULL placement.

Also wires workspace purge, which the two-workspace shape requires: both
workspace columns are RESTRICT references, so a purge clearing only one
direction would fail outright when the purged workspace sits on the other end.

Tests cover insert-in-tx, forward lookup with multiple destinations ordered
newest-first and scoped to one source, back lookup, rollback leaving no row,
and both DR-2a criteria. The ordering and scoping tests use fixed row IDs
whose lexical order contradicts the expected answer, so deleting the ordering
term or the WHERE clause under test fails them on every run rather than half
the time; verified by mutating the production query.

Claude-Session: https://claude.ai/code/session_01E2fRi12n8rARczvdEa2LYT
2026-07-30 12:20:38 +00:00

44 lines
1.9 KiB
Go

package models
// ItemWorkspaceMove is one durable record of an item being copied — or moved,
// which is a copy plus an archive of the source — from one workspace to
// another. Written in the SAME transaction as the copy, so the provenance can
// never disagree with the data (PLAN-2357 DR-2).
//
// It backs exactly two lookups:
//
// - forward, by SourceItemID: "where did this item go?" One source can be
// copied into several workspaces, so the forward lookup is a SET.
// - back, by TargetItemID: "where did this item come from?" A destination
// item is created by exactly one copy, so this is at most one row.
//
// ArchivedSource distinguishes a move from a plain copy, and only a move
// produces a "moved to" pointer on the archived source (DR-2a). A source
// copied three times and then moved has four rows, and only the archived one
// says where it *went*.
type ItemWorkspaceMove struct {
ID string `json:"id"`
SourceWorkspaceID string `json:"source_workspace_id"`
SourceItemID string `json:"source_item_id"`
TargetWorkspaceID string `json:"target_workspace_id"`
TargetItemID string `json:"target_item_id"`
// ArchivedSource is true when the source was archived as part of the
// operation (a move) and false for a plain copy.
ArchivedSource bool `json:"archived_source"`
// SourceSeq is the workspace-scoped `seq` the source workspace assigned
// when it archived the source. It exists only to order one source's
// moves deterministically: CreatedAt is second-precision RFC3339, so two
// moves inside the same second tie and the "moved to" lookup could
// otherwise return an arbitrary destination.
//
// nil for plain copies, which never archive. Since the "moved to" lookup
// reads only ArchivedSource rows, nil values never participate in the
// ordering.
SourceSeq *int64 `json:"source_seq,omitempty"`
CreatedBy string `json:"created_by"`
CreatedAt string `json:"created_at"`
}