Persistence×sharing quadrant + ECS relocation signal (snapshot v2, breaking) - #158
Merged
Conversation
…pshot v2) Partition entity space into four quadrants (persistence × sharing) encoded in the low 2 bits of the entity id, replacing the sign-based nonPersistent scheme. Bumps ECS_SNAPSHOT_VERSION 1->2 (old snapshots are rejected). Also surfaces swap-remove/migration relocations from the ECS as TransactionResult.relocatedEntities, letting persistence delete its per-entity location shadow cache and detectSwapRemoveAt re-derivation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Drop quadrantOf/toLocalIndex from the public Entity namespace (leaving only the isPersistent/isNonPersistent/isShared/isNonShared predicates). The load-time quadrant bucketing that needed them moves into @adobe/data behind serializedEntityLocationTables, so data-persistence hands over flat (entity, archetype, row) triples and no longer touches the bit layout. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add an optional PersistenceScope to toData/fromData so a caller can read/write
just the shared (document) or nonShared (settings) persistent quadrant instead
of the whole snapshot. A scoped toData emits row data only for the in-scope
quadrant(s) — every archetype's structure is still emitted so archetype ids
stay aligned — and a scoped fromData restores only those quadrant(s), leaving
every other quadrant untouched, so independent services can each own a quadrant.
toData now takes an options object ({ copy?, scope? }); Database narrows it to
{ scope? } so the copy flag stays hidden at that layer. createStoragePersistenceService
gains a scope option (and only auto-saves when an in-scope entity changes).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
nonPersistent: a component/resource whose schema is nonPersistent is no longer serialized (core snapshot + data-persistence journal/checkpoint). On load its value is reconstructed by the default-driven hybrid — defaulted → reset to the schema default (present); no default → stripped (entity restores into the reduced archetype, so a system re-adds it on demand). Exposed via core.reconstructNonPersistentColumns for restore paths that rebuild columns out-of-band. Previously the schema flag was inert for non-resource components. nonShared: data-sync no longer replicates a transaction with no shared effect (only non-shared entities, or only nonShared components). Peers replay whole transactions, so mixed shared/non-shared transactions still replicate in full — keep non-shared mutations in their own transactions. Fixes the README claim, which documented nonPersistent-component exclusion that was never implemented. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… slot The on-disk ELT (and its in-memory replay state) was indexed by raw entity id, which is sparse under the quadrant encoding (~2-4x gaps). Index by Entity.toPersistentSlot instead so the two persistent quadrants pack together and non-persistent ids leave no gaps. Format-only change, folded into the same unreleased v2 snapshot bump so we break the persistence format exactly once. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…dense
Replace the single interleaved entity-location.bin with one file per persistent
quadrant (entity-location-{0,2}.bin), each indexed by the entity's per-quadrant
local index. Every persistent quadrant now packs with exactly one 8-byte slot
per entity — no gaps, even for imbalanced (e.g. document-heavy) workloads. The
in-memory replay state (EltState) is likewise one-per-quadrant, routed by
Entity.quadrantOf. Format-only, folded into the unreleased v2 bump.
Re-exposes Entity.quadrantOf/toLocalIndex/toEntity/persistentQuadrants (needed by
the per-quadrant router); drops the interim toPersistentSlot/fromPersistentSlot.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…trip)
A component whose nonPersistent schema has `default: null`/`undefined` is the
type-only-placeholder pattern (e.g. `{ default: null as unknown as GPUBuffer }`)
— the key carries a type, not a usable value. On load such a component is now
stripped rather than retained as null; falsy-but-real defaults (0, false, "")
are still kept and reset. Was `'default' in schema`; now `schema.default != null`.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
null is a legitimate, serializable value, so treating it as "no default" was
wrong. A nonPersistent component is stripped on load only when its default is
undefined (explicit or absent key) — the type-only-placeholder pattern
{ default: undefined as unknown as GPUBuffer }. null (and 0, false, "") are
real values: retained and reset like any other default.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…nents Every `_`-prefixed (derived/ephemeral) nonPersistent component that spread a schema was inheriting that schema's real default (identity matrix, zero vec, `true` tag, …). Under the new rule that would RETAIN it on load — so a tag-and-exclude / derive-once system (jolt/rapier body mirroring, ragdoll build, world-matrix/bounds derivation) would see the component still present, its exclude-query would skip the entity, and it would never re-derive → broken after reload. Override `default: undefined` so these strip on load and their owning system re-derives them. Runtime is unaffected (systems always write them before reading). Leaves GPU-handle resources / wholly-non-persistent columns (where null is the runtime sentinel) as-is. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
krisnye
added a commit
that referenced
this pull request
Jul 28, 2026
…ption (#159) * ci: pin Node 24, test every package, fix data-lit-todo id-shape assumption CI let a broken data-lit-todo conformance suite merge green (#158) for two reasons, both fixed here: 1. The downstream test step only ran six packages and *built* — never tested — the private sample apps. Broadened the filter to run `test` in every workspace package except the core (covered by the `data` job) and data-persistence (Playwright — kept on its `test:node` project). This now includes data-lit-todo and the other samples. 2. `Set.prototype.isSupersetOf` (used in the ecs readEntity path) is Node 22+ and several suites need it; CI ran Node 22 but nothing pinned it. Pinned Node >=24 (engines, .nvmrc, .node-version) and bumped both CI jobs to 24. The data-lit-todo conformance harness assumed ecs entity ids equalled the spec's domain ids (1..N) — true only under the old sign-based id scheme, broken by the persistence/sharing quadrant encoding (stride-4 ids). Made the harness id-shape-agnostic instead: fromState returns the seeded entities so the runner maps spec id -> entity, and the projection halves compare ignoring the todo id. No domain-id component added; the app is unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(ecs): add Entity.none sentinel; use it in data-lit-todo conformance Replace the hand-rolled toEntity(1_000_000, 0) in the conformance harness with a first-class Entity.none: the last representable id (max local index in the last quadrant, numerically -1). The allocator issues local indices from 0 and a quadrant fills long before 2^30 entities, so it never collides with a live entity and locate/read resolve it to null. The bit layout stays owned by persistence-sharing.ts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- 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
Make the ECS's persistence × sharing model real and complete, in one PR (one persistence-format break).
1. Persistence × sharing quadrant (breaking: snapshot v2)
Entity space is partitioned into four quadrants along two orthogonal axes — durability (
nonPersistent) and sharing (nonShared) — encoded in the low 2 bits of the entity id (bit0 = nonPersistent,bit1 = nonShared). Replaces the old sign-based scheme wherenonPersistentused negative ids andnonSharedwas declared but inert.entity & 0x3; archetype/resource creation honor both tags;nonSharedis now update-guarded likenonPersistent.toData/fromDataserialize the two persistent quadrants (document, settings) and reset the two non-persistent ones on load.entity/persistence-sharing.ts; consumers useEntity.isPersistent/isShared/…, never raw bits.ECS_SNAPSHOT_VERSION1 → 2. Entity ids, the set of serialized location tables, and the on-disk entity-location file layout all changed, so snapshots written by prior versions no longer load (rejected with a warning; the store keeps its defaults — non-throwing). All persistence-format changes below are folded into this one bump.2. ECS emits relocations; persistence shadow cache deleted
Swap-remove/migration moves are surfaced as
TransactionResult.relocatedEntities(the ECS already computed the moved id for free). This lets@adobe/data-persistencedelete its per-entityEntityLocationCacheshadow anddetectSwapRemoveAtre-derivation — persistence drives full-row writes directly from the relocation signal. Closes a latent hole where a relocation inside an intermediate transaction could desync the persisted image (covered by a regression test).3. Quadrant-scoped
toData/fromData(additive, optional)toData/fromDatatake an optionalPersistenceScope({ shared?, nonShared? }) to read/write just one persistent quadrant — enabling e.g.settingsin local storage anddocumentvia a separate service.toDataemits row data only for the in-scope quadrant(s); archetype structure is always emitted so ids stay aligned.fromDatarestores only the in-scope quadrant(s), leaving every other quadrant untouched, so independent scoped loads compose.toDatanow takes an options object ({ copy?, scope? });Databasenarrows it to{ scope? }socopystays hidden.createStoragePersistenceServicegains ascopeoption.4. Component-level
nonPersistentandnonSharedhonoringPreviously the schema flags were only honored for entities/resources; an individual component marked
nonPersistent/nonSharedwas inert (the README even claimed otherwise). Now:nonPersistentcomponent is never serialized (core snapshot + data-persistence journal/checkpoint). On load its value is reconstructed by a default-driven hybrid: a real (non-undefined) default → reset to it (includingnull,0,false— all valid values); anundefineddefault (explicit, or an absentdefaultkey — the{ default: undefined as unknown as GPUBuffer }type-placeholder pattern) → stripped (entity restores into the reduced archetype so a system re-adds it). Exposed viacore.reconstructNonPersistentColumnsfor restore paths that rebuild columns out-of-band. data-gpus derived_-prefixed components were migrated todefault: undefined` so their tag-and-exclude / derive-once systems re-run after load.nonSharedcomponent/resource stays persisted locally but is not replicated:data-syncskips any transaction with no shared effect (only non-shared entities, or only nonShared components). Note: peers replay whole transactions, so a mixed shared/non-shared transaction can't be partially stripped — keep non-shared mutations in their own transactions to keep them local.5. Dense on-disk entity-location index
The entity-location index is now one file per persistent quadrant (
entity-location-{0,2}.bin), each indexed by the entity's per-quadrant local index — exactly one 8-byte slot per entity, no gaps, for any workload. This replaces the raw-entity-id indexing that was ~2–4× sparse under the quadrant encoding. Part of the same v2 format break (so it's done once).Verification
@adobe/data1473,@adobe/data-persistence139 (all round-trip / journal-replay / crash-recovery / worker E2E green), data-sync 29, data-gpu 70, data-lit 5.nonSharedupdate guard, intermediate-relocation regression, scoped single-quadrant + two-service compose, component-level nonPersistent hybrid (core + full checkpoint/reload), nonShared replication skip, and entity-location file density (exactly one slot per entity).🤖 Generated with Claude Code