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).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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