Skip to content

Persistence×sharing quadrant + ECS relocation signal (snapshot v2, breaking) - #158

Merged
krisnye merged 9 commits into
mainfrom
krisnye/persistence-sharing-refactor
Jul 28, 2026
Merged

Persistence×sharing quadrant + ECS relocation signal (snapshot v2, breaking)#158
krisnye merged 9 commits into
mainfrom
krisnye/persistence-sharing-refactor

Conversation

@krisnye

@krisnye krisnye commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

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 where nonPersistent used negative ids and nonShared was declared but inert.

quadrant scope persistent shared
0 document
1 presence
2 settings
3 session
  • Core keeps 4 location tables selected by entity & 0x3; archetype/resource creation honor both tags; nonShared is now update-guarded like nonPersistent.
  • toData/fromData serialize the two persistent quadrants (document, settings) and reset the two non-persistent ones on load.
  • The bit layout lives only in entity/persistence-sharing.ts; consumers use Entity.isPersistent/isShared/…, never raw bits.

⚠️ Breaking — ECS_SNAPSHOT_VERSION 1 → 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-persistence delete its per-entity EntityLocationCache shadow and detectSwapRemoveAt re-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 / fromData take an optional PersistenceScope ({ shared?, nonShared? }) to read/write just one persistent quadrant — enabling e.g. settings in local storage and document via a separate service.

  • Scoped toData emits row data only for the in-scope quadrant(s); archetype structure is always emitted so ids stay aligned.
  • Scoped fromData restores only the in-scope quadrant(s), leaving every other quadrant untouched, so independent scoped loads compose.
  • Fully optional / backward compatible: omit the scope and behavior is unchanged.
  • toData now takes an options object ({ copy?, scope? }); Database narrows it to { scope? } so copy stays hidden. createStoragePersistenceService gains a scope option.

4. Component-level nonPersistent and nonShared honoring

Previously the schema flags were only honored for entities/resources; an individual component marked nonPersistent/nonShared was inert (the README even claimed otherwise). Now:

  • nonPersistent component 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 (including null, 0, false — all valid values); an undefined default (explicit, or an absent default key — the { default: undefined as unknown as GPUBuffer } type-placeholder pattern) → stripped (entity restores into the reduced archetype so a system re-adds it). Exposed via core.reconstructNonPersistentColumns for restore paths that rebuild columns out-of-band. data-gpus derived _-prefixed components were migrated to default: undefined` so their tag-and-exclude / derive-once systems re-run after load.
  • nonShared component/resource stays persisted locally but is not replicated: data-sync skips 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.
  • Fixes the inaccurate README claim.

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

  • Monorepo typecheck + build + lint clean.
  • @adobe/data 1473, @adobe/data-persistence 139 (all round-trip / journal-replay / crash-recovery / worker E2E green), data-sync 29, data-gpu 70, data-lit 5.
  • New coverage: relocation signal (swap/migration/value-only), four-quadrant partition + round-trip, nonShared update 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

krisnye and others added 9 commits July 27, 2026 11:31
…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
krisnye merged commit dbdf69d into main Jul 28, 2026
4 checks passed
@krisnye
krisnye deleted the krisnye/persistence-sharing-refactor branch July 28, 2026 19:15
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>
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