feat(data): ordered version-upgrade system for persisted schemas - #184
Merged
Conversation
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
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
versionsarray — one entry per schema change, each a JSON merge patch:entries[i]IS versioni;entries[0]is the initial schema;currentVersion = entries.length - 1.changesis a JSON Merge Patch applied to the folded-so-far schema — record ONLY what changes:name: { …fields… }adds or deep-merges; a key set toundefineddeletes it;nullis a preserved value. A shape change deletes the fields it drops (number → object ⇒precision: undefined, default: undefined). Emptychanges: {}is rejected.handleris present iff the change is not auto-convertible (createCoerceFunctiondecides — the author never classifies). It runs against the store staged to the previous version and mutates it in place; for an isolated single-component changeStore.remapComponentsuffices (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: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
undefineddeletes) 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. (assertVersionsMatchSchemaandtestUpgradeHandlersremain 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.versionis a first-class integer on theDatabasetype (entries.length - 1, or0when 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)readsschemaVersionsfrom 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.versioninto 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) andsettings(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 ownscopeandstoragebackend (anyStorage-shaped port: localStorage, sessionStorage, a cloud/OPFS adapter). Thedata-lit-todosample demonstrates it: the todos (document) anddisplayCompleted(settings) each auto-load/auto-save to their ownlocalStoragekey.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.fromDataresolves{ 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+Infinityand rejects, rather than silently replaying every handler.New / changed public surface
db.version,createVersionUpgrader(versions),DatabaseVersioning { currentVersion, handle }db.fromData(blob, scope)version-upgrades its quadrant + commits scoped (enables split persistence);FromDataResultreject carries per-quadrantschemaVersionsVersionEntry,assertVersioning(combined guard),assertVersionsMatchSchema,testUpgradeHandlers,foldSchemas,createStoreAtVersion,runUpgradeStep,conformStoreToSchemasStore.remapComponent/Store.coerceComponent— in-place schema-change helpers, namespaced onStoreper the helper-placement convention (were freeremapStoreComponent/coerceStoreComponent)storeSchemas(store),quadrantOf,isTransientSchemamergePatch(target, patch, deleteSentinel?)/Patch— JSON Merge Patch; delete sentinel defaults toundefined(sonullis a value), passnullfor strict RFC 7396Tests
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/databuild 0 errors; sampledata-lit-todopassing (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
MainServiceplugin; a fully-assembled sample vitest file hit a non-reproducible test-runner stall (not a logic defect).🤖 Generated with Claude Code