Skip to content

feat(data): ordered version-upgrade system for persisted schemas - #184

Merged
krisnye merged 20 commits into
mainfrom
krisnye/version-upgrader-design
Aug 21, 2026
Merged

feat(data): ordered version-upgrade system for persisted schemas#184
krisnye merged 20 commits into
mainfrom
krisnye/version-upgrader-design

Conversation

@krisnye

@krisnye krisnye commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

Summary

A pluggable schema version-upgrade system for persisted databases, built on the coerce foundation (#183). Additive & minor schema changes need no code — they're converted automatically on load; only a genuinely breaking change carries a handler. One co-located guard test fails (with a fix recipe) if the schema drifts from the recorded history or a handler lacks a test. The schema version is save-format metadata (db.version), never an ECS resource, and each persisted quadrant (document + settings) carries its own stamp so the two can drift and upgrade independently.

What an author writes

The whole authoring surface is an ordered versions array — one entry per schema change, each a JSON merge patch:

const versions: VersionEntry[] = [
  { version: 0, changes: { components: { hp: F32, score: F32 }, resources: { turn: { type: "integer", default: 0 } } } }, // initial
  { version: 1, changes: { components: { mana: F32 } } },                                    // additive — no code
  { version: 2, changes: { components: { score: { maximum: 100 } } } },                      // minor (clamp) — no code
  { version: 3, changes: { resources: { difficulty: { type: "string", default: "normal" } } } }, // additive resource — no code
  { version: 4, changes: { components: { hp: { type: "object", properties: HealthProps, precision: undefined, default: undefined } } }, // MAJOR
    handler: (store) => Store.remapComponent(store, "hp", Health, (old) => ({ current: old, max: old })) },
];
  • entries[i] IS version i; entries[0] is the initial schema; currentVersion = entries.length - 1.
  • changes is a JSON Merge Patch applied to the folded-so-far schema — record ONLY what changes: name: { …fields… } adds or deep-merges; a key set to undefined deletes it; null is a preserved value. A shape change deletes the fields it drops (number → object ⇒ precision: undefined, default: undefined). Empty changes: {} is rejected.
  • handler is present iff the change is not auto-convertible (createCoerceFunction decides — the author never classifies). It runs against the store staged to the previous version and mutates it in place; for an isolated single-component change Store.remapComponent suffices (not required) — a migration spanning several components is hand-written.

Wire it up: Database.create(plugin, { versioning: createVersionUpgrader(versions) }).

The one guard test: a single assertVersioning(...) runs BOTH the schema-fold check and the per-handler test coverage:

it("versioning is consistent", () => assertVersioning({
  database: Database.create(plugin, { versioning: createVersionUpgrader(versions) }),
  entries: versions,
  handlers: { /* a case per version that adds a handler */ },
}));

On drift it throws an actionable recipe: classifies each change (auto → record a merge-patch, no handler; breaking → record it AND add a handler), prints the exact minimal merge patch (with undefined deletes) to append, flags removals as ⚠ DROPS DATA. It also enforces the invariants: only real, typed persisted schemas may be versioned; non-persistent state must not appear; each handler touches a single quadrant. (assertVersionsMatchSchema and testUpgradeHandlers remain exported for bespoke use.)

Version as save-format metadata (not a resource)

The schema version is not an ECS component/resource — it rides the serialized envelope:

  • db.version is a first-class integer on the Database type (entries.length - 1, or 0 when unversioned).
  • db.toData() stamps it into the save metadata per persisted quadrant: schemaVersions: { document, settings } (the db writes its single version into both).
  • db.fromData(blob) reads schemaVersions from the metadata (absent ⇒ 0, a legacy blob) and hands the per-quadrant stamps to the upgrade handler.

This keeps the version out of the content data (it describes the blob's format), lets a cloud client stamp db.version into every message envelope, and — being per-quadrant — is forward-compatible with saving the document and settings blobs separately (each carries its own quadrant's stamp).

Per-quadrant upgrade

The two persisted quadrants — document (shared+persistent) and settings (nonShared+persistent) — can be saved to different backends and drift to different versions. Whether loaded as one combined blob (unscoped) or from separate per-quadrant blobs (scoped — see below), the upgrader reads each quadrant's own saved version from the metadata and replays only that quadrant's handlers, from its own version up to current, with staging scoped to the handler's quadrant so a quadrant already ahead is never down-converted. Any quadrant newer than the app ⇒ reject ({ loaded: false }, live db untouched). One-quadrant-per-handler (guard-enforced) is what makes this sound.

Split persistence — two locations, one blob per quadrant

The document and settings quadrants can be persisted to (and loaded from) two separate, independently pluggable locations. A scoped db.fromData(blob, scope) now version-upgrades only its quadrant — from that blob's own metadata stamp — and commits scoped, leaving the other quadrant intact (previously scoped loads bypassed versioning). This is sound precisely because every handler is single-quadrant, so a quadrant upgrades on its own; it's what lets a document blob and a settings blob be saved and loaded independently, in either order, non-clobbering.

The pluggable seam is the existing createStoragePersistenceService({ database, scope, storage, … }) — instantiated once per quadrant, each with its own scope and storage backend (any Storage-shaped port: localStorage, sessionStorage, a cloud/OPFS adapter). The data-lit-todo sample demonstrates it: the todos (document) and displayCompleted (settings) each auto-load/auto-save to their own localStorage key.

How load works (model A)

db.fromData(oldBlob) → reconstruct into a bare document store → the upgrader replays each quadrant's majors (quadrant-scoped staging) → the database commit path auto-normalizes to the current schema (coerce auto-convertibles; materialize additive resources; throw on a non-auto remnant — a broken migration). The final normalize targets current (the top), so it never down-converts. Reject is data, not a throw: db.fromData resolves { loaded: true } or { loaded: false, currentVersion, schemaVersions: { document, settings } } — both quadrant stamps are reported so a caller can tell WHICH quadrant is too new. A corrupt/non-numeric saved stamp reads as +Infinity and rejects, rather than silently replaying every handler.

New / changed public surface

  • db.version, createVersionUpgrader(versions), DatabaseVersioning { currentVersion, handle }
  • scoped db.fromData(blob, scope) version-upgrades its quadrant + commits scoped (enables split persistence); FromDataResult reject carries per-quadrant schemaVersions
  • VersionEntry, assertVersioning (combined guard), assertVersionsMatchSchema, testUpgradeHandlers, foldSchemas, createStoreAtVersion, runUpgradeStep, conformStoreToSchemas
  • Store.remapComponent / Store.coerceComponent — in-place schema-change helpers, namespaced on Store per the helper-placement convention (were free remapStoreComponent/coerceStoreComponent)
  • storeSchemas(store), quadrantOf, isTransientSchema
  • mergePatch(target, patch, deleteSentinel?) / Patch — JSON Merge Patch; delete sentinel defaults to undefined (so null is a value), pass null for strict RFC 7396

Tests

version-upgrader.test.ts (authoring showcase: guard pass + every failure mode, v0→v4 end-to-end, reject-newer as data, assertVersioning), version-upgrader.replay.test.ts (multi-major + staged-additive + mid-history + removal-with-preservation), version-upgrader.per-quadrant.test.ts (two quadrants at different metadata stamps upgrading independently, reject, no-op, full load path), database.versioning.test.ts (seam: accept/reject/upgrade/legacy/prune/heal/backstop), database.migration-replication.test.ts (upgrade→capture delta→peer replays), plus a node-level split-persistence test (database.versioning.test.ts: one source → a document blob + a settings blob → a fresh app loads each from its own location, either order, each upgraded independently and non-clobbering) and per-quadrant reject/corrupt-stamp coverage. Full data suite passing (3241); @adobe/data build 0 errors; sample data-lit-todo passing (107); monorepo typecheck + lint clean.

Note: a sample-level unit test of the split isn't included — the split mechanism is proven by the node composition test above and every operation verified in isolation on the real MainService plugin; a fully-assembled sample vitest file hit a non-reproducible test-runner stall (not a logic defect).

🤖 Generated with Claude Code

krisnye and others added 20 commits August 19, 2026 16:57
A pluggable, merge-patch-driven schema version history that upgrades old
documents on load and guards against un-versioned schema drift.

- VersionEntry { changes: { components?, resources? }, handler? }: an ordered
  history where entries[i] takes version i → i+1, changes are JSON merge-patches
  (RFC 7396; null = remove), and a handler is present iff the change is not
  auto-convertible. foldSchemas reconstructs the schema at any version.
- createVersionUpgrader(entries, { resource }): a DatabaseVersioning that walks
  a document from its version to current — staging the store to each major's
  input schema, running the handler, then auto-normalizing every additive/minor
  change to current via the coerce foundation. Rejects newer documents.
- assertVersionsMatchSchema: the co-located guard — folds the history and
  compares to the injected current schema, throwing an actionable recipe (record
  a merge-patch for auto-convertible changes, add a handler for breaking ones)
  so humans just edit schemas and agents can auto-fix + bump.
- createStoreAtVersion / runUpgradeStep: per-major isolated testing.

Normalization lives in the upgrader (targeting fold(entries)), not the seam, so
the merged commit path is unchanged and its buffer-compat check stays a backstop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…erialization

Close the design holes into the real intended shape:

- Model A: the database COMMIT path now auto-normalizes a versioned load to the
  current schema (coerce every auto-convertible diff, throw only on a
  non-auto-convertible remnant), replacing the old throw-on-any-mismatch. So
  additive/minor/reorder/clamp changes just work; the buffer-compat check is
  gone, its role now a backstop inside conform.
- Changes are schema REPLACEMENTS, not deep merge-patches: `{ name: schema | null }`
  replaces/removes a whole schema, so a shape change (number → object) leaves no
  stale keys. Fixes an incorrect fold.
- remapStoreComponent / remapArchetypeColumn: the manual sibling of coerce for a
  non-auto change (type change, enum remap) — what a major handler reaches for.
  Factored replaceArchetypeColumn out of coerce so both share the swap+rebuild.
- conformStoreToSchemas materializes absent components/resources via store.extend,
  so a handler (and the commit) sees additively-introduced resources at their
  defaults — closes the staging gap.
- storeSchemas(store): split a store's declared schemas into components/resources
  (minus built-ins) so the guard is `assertVersionsMatchSchema({ entries,
  ...storeSchemas(db), versionResource, currentVersion })`.
- createCoerceFunction: opaque/untyped `{ default: x }` schemas (array-buffer
  backed) pass through as identity.
- Guard recipe now flags removals as ⚠ DROPS DATA and points breaking changes at
  remapStoreComponent.

The version handler no longer normalizes (the commit path does); it only replays
majors, each staged to its version's folded schema.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Each entry carries version = index + 1 (last entry's version = currentVersion),
so a long history is scannable. Redundant with position; the guard verifies each
entry's version matches its index.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Per review: each entry's version equals its index, so version 0 is the initial
schema and currentVersion = entries.length - 1. Fold is now inclusive-through-a-
version, the upgrader applies entries[d+1 … current] staging each to fold(i-1),
and the guard verifies version === index.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A single co-located call walks the history and, for every entry with a handler,
REQUIRES a cases[version] entry (throws if missing), then builds the store at the
handler's input version, runs setup, applies the handler, and runs expect. Adding
a handler without a test now fails the suite; a case for a handler-less version
also fails. This is the handler-coverage counterpart to assertVersionsMatchSchema.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…data-ai versioning rule

- storeSchemas now excludes nonPersistent components/resources — versioning
  tracks only the persisted schema.
- data-lit-todo: a version history (versions.ts, version 0 = frozen schema copy),
  a databaseVersion document resource, createVersionUpgrader wired into
  Database.create, and the two guard tests (schema-match + handler-coverage).
- data-ai: a tightly glob-matched versioning.md rule (matches versioning/**,
  versions.ts) documenting the frozen-history rules and the "change schema → run
  tests → follow the error's recipe" workflow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…t, replay staging, coverage tests

- MEDIUM-1: createCoerceFunction no longer blindly passes ALL untyped→untyped as
  identity. Two opaque schemas coerce only when their defaults share a runtime
  type, so a shape change ({default:"x"}→{default:{…}}) forces a handler again;
  a default tweak stays auto. Untyped-schema caveat documented on SchemaChanges.
- MEDIUM-2: createVersionUpgrader takes onDocumentTooNew — reject-newer is now
  observable (fromData still resolves non-destructively). Doc: you MUST add the
  guard test.
- MEDIUM-3 / staging fix: stagingSchemas = fold(i-1) PLUS version-i's new
  additions, so a handler can BOTH read old shapes and write components/resources
  introduced at its own version (e.g. preserve data before a removal). Used by the
  upgrader and runUpgradeStep. New replay tests: multi-major with an additive
  staged between majors, mid-history partial replay, and removal-with-preservation.
- testUpgradeHandlers runs its coverage check SYNCHRONOUSLY, so a non-awaited call
  still fails when a handler lacks a case.
- Doc fixes: removal happens at commit (not mid-replay); handler input is staged to
  version i-1; remap index is a row index, not an entity id.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…urns a result

- Session quadrant (nonPersistent AND nonShared) is never versioned. storeSchemas
  now excludes only session (not all nonPersistent — shared-but-transient still
  versions); isSessionSchema is the shared predicate. The guard REJECTS a session
  schema recorded in a history (red/green tested), and conform skips session
  defensively, so session state can't enter the version-capture process.
- Reject is now data, not a callback: Database.fromData returns FromDataResult —
  { loaded: true } or { loaded: false, documentVersion, currentVersion } when a
  handler refuses (e.g. document newer than app). Removed createVersionUpgrader's
  onDocumentTooNew callback. Breaking API change to fromData's return type.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…per handler

Close the soft spot by DISALLOWING untyped versioned schemas rather than guessing
shape from a default:

- The guard rejects a versioned schema with no declared type/enum/const (only
  session values — nonPersistent AND nonShared, e.g. GPU buffers — may be untyped,
  and those aren't versioned). And a schema's default must be fully described by
  the schema: a type mismatch or extra structure (keys the schema doesn't declare)
  fails, so the default can't smuggle in undeclared shape.
- The guard enforces one quadrant per handler: a handler whose changed schemas span
  more than one quadrant (document / settings / shared-transient) fails, with a
  message to split into one version per quadrant — since split persistence loads a
  quadrant without the others present.

Red/green tests for all three; typed the showcase's turn/difficulty resources;
data-ai versioning rule updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ument + settings

Persisted quadrants (document = shared+persistent, settings = nonShared+persistent)
can be saved to different backends and drift to different versions. createVersionUpgrader
now takes { document?, settings? } version resources, reads each quadrant's own stamp
off the merged store, and replays only that quadrant's handlers from its own version —
staging scoped to the handler's quadrant so an ahead quadrant is never down-converted.
Reject (null) if any quadrant is newer than the app. One-quadrant-per-handler (guard)
is what makes the independent replay sound.

- extract read/writeVersionResource into versioning/version-resource.ts (shared by seam + upgrader)
- quadrantOf + Quadrant to store-schemas; changedQuadrants to fold-schemas (guard + upgrader share)
- guard versionResource accepts string | string[] for per-quadrant stamps
- new version-upgrader.per-quadrant.test.ts: mixed-version merge, independent upgrade, reject, no-op, full load path

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ate handler quadrants are configured

Addresses review findings on per-quadrant versioning:
- A data-only handler (empty change set) previously staged the WHOLE store, which
  could down-convert (corrupt or throw) a quadrant already ahead of that version.
  Staging is now always scoped to the handler's quadrant — its single changed
  quadrant, or the primary quadrant for a data-only handler.
- createVersionUpgrader now throws at construction if a handler's quadrant has no
  configured version resource, instead of silently mis-gating it to the primary
  stamp and never stamping that quadrant.
- add red/green tests: empty-changes handler leaves an ahead quadrant intact;
  a settings handler with document-only config throws.
- fix JSDoc: stamped default is entries.length - 1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…s); reject empty {} entries

- add mergePatchU/PatchU: the RFC-7396 merge patch with the delete sentinel moved
  from null to undefined, so null survives as a value (e.g. default: null). Not
  serialized, so undefined is fine. mergePatch (RFC null=delete) is unchanged.
- version `changes` are now merge patches applied to the folded-so-far schema:
  record ONLY what changes; a key set to undefined deletes it; number → object
  must delete the number-only fields (precision/default) it drops.
- foldSchemas applies via mergePatchU; the guard prints the MINIMAL merge patch
  (with undefined deletes) on drift; removals are `undefined`, not `null`.
- reject a no-op entry with empty changes ({}) in the guard, and throw at
  createVersionUpgrader construction for a handler that changes no schema — a
  handler always accompanies its change, so its quadrant is always derivable
  (removes the primaryQuadrant fallback).
- update all version histories + the data-ai rule to minimal merge patches.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… two typed wrappers

mergePatch (null sentinel) and mergePatchU (undefined sentinel) now both delegate
to a private mergePatchWith(target, patch, sentinel); the algorithm is no longer
duplicated. The two public functions keep their distinct Patch/PatchU types (they
express deletion differently) but share one implementation. Behavior is unchanged
except a null value in an object patch applied onto a non-object target now deletes
the key (RFC 7396-correct) rather than being retained — previously untested.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…eleteSentinel param

mergePatch(target, patch, deleteSentinel = undefined). The delete sentinel now
defaults to undefined (so null is an ordinary value); pass null for strict RFC 7396.
Removes the duplicate mergePatchU/PatchU pair — one function, one Patch<T> type
(a deep-partial; deletion is a runtime concern, not encoded in the type). Verified
no consumer in adobe/data or the Firefly repos relies on null-for-delete, so
flipping the default is safe. Versioning fold + SchemaChanges use the single fn/type.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…mited to remapStoreComponent; rename documentVersion

- add assertVersioning({ database, entries, versionResource, handlers }) — one
  higher-order guard that runs BOTH the schema-fold check and the handler-coverage
  check; the two primitives stay exported. Sample + rule use the single call.
- soften rule #4, the guard's breaking-change recipe, and docs: remapStoreComponent
  is a convenience for an ISOLATED single-component change, NOT required — a migration
  spanning several components must be hand-written to the upgrade algorithm.
- rename the sample's version resource databaseVersion -> documentVersion (clearer;
  it stamps the document quadrant).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…aced per helper-placement rule)

The two in-place schema-change helpers were free functions with a type-name prefix,
violating the codified helper-placement rule (helpers live on the nearest type's
namespace; function files aren't type-prefixed). Moved onto the Store namespace as a
matched pair, mirroring Store.create. The free names are no longer in the public
barrel — Store.remapComponent / Store.coerceComponent is the single public surface.
No external consumer used them, so no shim. Behavior unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… an ECS resource

Per design decision: the version leaves ECS resources entirely. A single db.version
integer (entries.length-1, or 0 when unversioned) is exposed on the Database type and
stamped into every toData's save metadata, per persisted quadrant:
  schemaVersions: { document: number, settings: number }
On load, fromData reads schemaVersions from the blob metadata (absent ⇒ 0, legacy) and
the upgrader replays each quadrant from its own saved version up to db.version. The two
quadrant stamps are conceptually distinct (blobs can be saved separately and drift);
the database only ever writes its single version into both.

- DatabaseVersioning: drop `resource`; add `currentVersion`; handle ctx is now
  { documentStore, schemaVersions }.
- createVersionUpgrader(versions) — no resource config; reads per-quadrant stamps from
  metadata; replays quadrant-scoped as before.
- remove version-resource.ts (read/writeVersionResource) and the guard's versionResource
  exclusion; assertVersioning/assertVersionsMatchSchema take currentVersion = db.version.
- sample: drop the documentVersion resource; guard + main use the metadata path.
- rewrite the versioning/seam/replication tests for the metadata contract; update the rule.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…n stamp rejects

Addresses the review findings on the version-metadata load path:
- FromDataResult reject is now { loaded:false, currentVersion, schemaVersions:{document,settings} }
  so a caller can tell WHICH quadrant was too new — the old single documentVersion
  couldn't express a settings-triggered reject (and per-quadrant stamping will make
  that a live case).
- readSchemaVersions maps a present-but-non-numeric stamp to +Infinity, so a corrupt
  blob is rejected as "newer than any app" instead of silently replaying every handler.
- tests: settings-triggered reject reports both stamps; a corrupt stamp rejects.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…plit persistence

A scoped db.fromData(blob, scope) now version-upgrades ONLY the in-scope quadrant
(from that blob's metadata stamp) and commits scoped, leaving the other quadrant
intact. Previously scoped loads bypassed versioning entirely. Sound because every
handler is single-quadrant (guard-enforced), so a quadrant upgrades independently —
which is exactly what lets a document blob and a settings blob be saved to, and
loaded from, two separate locations and each upgraded on its own.

- reconstruct the throwaway store UNSCOPED (a scoped load into an empty store won't
  rebuild archetype structure); scope governs stamp-selection + the scoped commit.
- out-of-scope quadrants are treated as current so their handlers never run.
- conform only the in-scope quadrant's schemas (scopedSchemas helper).
- tests: scoped load upgrades only its quadrant; a full split (document entities +
  settings resource) saved/loaded from two blobs, each upgraded, non-clobbering,
  order-independent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…two locations

Wire two createStoragePersistenceService instances into the todo sample: the
document quadrant (the todos, shared) and the settings quadrant (displayCompleted,
per-device) each auto-load on start and auto-save to their OWN localStorage key —
two separate, independently pluggable blob locations (swap `storage` for any
Storage-shaped backend). Seeds example todos only on a first run. Each quadrant
carries and upgrades its own version via the blob metadata.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@krisnye
krisnye merged commit c7999f9 into main Aug 21, 2026
3 checks passed
@krisnye
krisnye deleted the krisnye/version-upgrader-design branch August 21, 2026 23:07
krisnye added a commit that referenced this pull request Aug 26, 2026
…rrupt stamps (#188)

Follow-up to the version-upgrader PR (#184). Four small fixes flagged in review:

- The drift recipe (assert-versions-match-schema) still told users to "set the
  version resource default to N" — the resource #184 removed. An agent obeying it
  would re-add a databaseVersion resource, which then becomes an untracked
  component and re-drifts the guard with no exclusion param left. Drop that clause;
  db.version follows the history automatically.
- The data-lit-todo versioning template carried the same dead databaseVersion
  reference; it's the file every versioned app copies from.
- readSchemaVersions treated a present-but-null/missing quadrant stamp as version 0
  (Number(null) === 0) and silently replayed from 0. Only a WHOLLY absent
  schemaVersions block is legacy now; a present block requires a finite number per
  quadrant or the load rejects (live db untouched). +2 tests.
- version-entry's handler doc claimed the handler "sees a known shape for every
  component" — false under a scoped/split load, where only the handler's own
  quadrant is staged. Document the single-quadrant contract: read/write only your
  own quadrant; reading another is a silent stale-data bug.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant