Skip to content

restoreVersion can't clear associations — empty-set snapshots are unrepresentable and adapter-divergent #111

Description

@cuibonobo

Problem

#61's rule is "every mutation snapshots the record's prior full state." For associations it holds in one direction only.

Stack.saveVersion() (packages/core/src/stack.ts:1597-1608) writes associations onto the snapshot only when the record has them:

...(record.associations && { associations: record.associations }),

So a snapshot of a record with zero associations has no associations key — indistinguishable from a legacy snapshot that predates #61. On restore, adapters only touch associations when the key is present (record-logic.ts:290 if (target.associations !== undefined), testing.ts:265). Net effect: restore can add associations back but can't remove them.

Repro: create note (v1, no tags) → associate a tag (v2; the snapshot of v1 has no associations key) → restoreVersion(1) → content reverts but the tag survives.

Worse, it's adapter-divergent. An empty association set is materialized differently per adapter:

  • SQL adapters: rowToRecord sets associations only if (associations.length) (mappers.ts:29) → an emptied record has associations: undefined.
  • MemoryAdapter.dissociate leaves associations: [] (testing.ts:222-223) → truthy → snapshotted → restore does clear.

So the identical create→tag→dissociate→restore sequence behaves differently on the test double than on real storage — the double is actually more correct than production here, which also means the test suite can't catch the production bug.

Fix

  • Always snapshot associations (empty array included) — treat "the record has no associations" as [], not absent. Old snapshots lacking the key stay "leave as-is" (legacy), so no data migration needed.
  • Always restore associations when the (now always-present) key is there, including clearing to empty.
  • Normalize the empty representation so SQL and memory adapters agree (both undefined or both [] on read) — pick one and pin it with a shared assertion, since this is exactly the two-engine drift sqlite-shared exists to prevent.

Not a bug — confirm + document

The snapshot does not capture parentId or appId, so restore can't revert a re-parent or app reattribution. This is intended: #61's decision-of-record enumerated what RecordVersion gained (associations/permissions/typeId), and restore's contract is "content + typeId + associations, never permissions." Action here is a one-line spec clarification in §Versions that restore deliberately does not cover parentId/appId — not a code change. (Permissions are likewise deliberately never restored — no action.)

Tests

  • create (no associations) → associate → restoreVersion to the pre-associate version removes the association
  • Snapshot of a record with associations still restores them (existing behavior preserved)
  • Same sequence produces identical results on MemoryAdapter and both SQLite adapters (regression against the divergence)

Refs

#61 (one-versioning-rule / full-state snapshots), #62 (restoreVersion semantics). From docs/design-assessment-2026-07.md §B1 (PR #105).

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions