From 6af0d5ba51ddd34cf08e937c29064a3b7a91f5c8 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 10 Jul 2026 10:28:13 -0700 Subject: [PATCH 1/6] fix(data): exclude nonPersistent resources from store serialization Store.toData() serialized every archetype unconditionally, including archetypes holding nonPersistent resources/entities (negative-ID space). Since entityLocationTableData only ever covers the persistent location table, this leaked nonPersistent resource values into snapshots even though the corresponding entities could never be restored to point at them. Filter nonPersistent archetypes out of archetypesData so nonPersistent resources correctly stay out of serialized state. Bump version to 0.9.77. Co-Authored-By: Claude Sonnet 5 --- package.json | 2 +- packages/data-ai/package.json | 2 +- packages/data-gpu-samples/package.json | 2 +- packages/data-gpu/package.json | 2 +- packages/data-lit-tictactoe/package.json | 2 +- packages/data-lit-todo/package.json | 2 +- packages/data-lit/package.json | 2 +- packages/data-p2p-tictactoe/package.json | 2 +- packages/data-persistence/package.json | 2 +- packages/data-react-hello/package.json | 2 +- packages/data-react-pixie/package.json | 2 +- packages/data-react/package.json | 2 +- packages/data-solid-dashboard/package.json | 2 +- packages/data-solid/package.json | 2 +- packages/data-sync/package.json | 2 +- packages/data/package.json | 2 +- .../data/src/ecs/store/core/create-core.ts | 9 ++++- .../src/ecs/store/public/create-store.test.ts | 33 +++++++++++++++++++ 18 files changed, 57 insertions(+), 17 deletions(-) diff --git a/package.json b/package.json index 39b4509b..8fca18c6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "data-monorepo", - "version": "0.9.76", + "version": "0.9.77", "private": true, "scripts": { "build": "pnpm -r run build", diff --git a/packages/data-ai/package.json b/packages/data-ai/package.json index c8f94a31..62ae893f 100644 --- a/packages/data-ai/package.json +++ b/packages/data-ai/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-ai", - "version": "0.9.76", + "version": "0.9.77", "description": "Cross-agent architecture skills for @adobe/data — installable as a Claude Code plugin or copied into any Agent-Skills-compatible agent (Cursor, Codex).", "type": "module", "private": false, diff --git a/packages/data-gpu-samples/package.json b/packages/data-gpu-samples/package.json index bf1775e8..0ff406f8 100644 --- a/packages/data-gpu-samples/package.json +++ b/packages/data-gpu-samples/package.json @@ -1,6 +1,6 @@ { "name": "data-gpu-samples", - "version": "0.9.76", + "version": "0.9.77", "description": "WebGPU samples built on @adobe/data-gpu", "type": "module", "private": true, diff --git a/packages/data-gpu/package.json b/packages/data-gpu/package.json index d7d11764..55970c66 100644 --- a/packages/data-gpu/package.json +++ b/packages/data-gpu/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-gpu", - "version": "0.9.76", + "version": "0.9.77", "description": "Adobe data WebGPU plugins and types for graphics and compute", "type": "module", "private": false, diff --git a/packages/data-lit-tictactoe/package.json b/packages/data-lit-tictactoe/package.json index 7e2908ce..548033b5 100644 --- a/packages/data-lit-tictactoe/package.json +++ b/packages/data-lit-tictactoe/package.json @@ -1,6 +1,6 @@ { "name": "data-lit-tictactoe", - "version": "0.9.76", + "version": "0.9.77", "description": "Tic-Tac-Toe sample - Lit web components with @adobe/data-lit and AgenticService", "type": "module", "private": true, diff --git a/packages/data-lit-todo/package.json b/packages/data-lit-todo/package.json index a2c57b4e..4e958614 100644 --- a/packages/data-lit-todo/package.json +++ b/packages/data-lit-todo/package.json @@ -1,6 +1,6 @@ { "name": "data-lit-todo", - "version": "0.9.76", + "version": "0.9.77", "description": "Todo sample app demonstrating @adobe/data with Lit", "type": "module", "private": true, diff --git a/packages/data-lit/package.json b/packages/data-lit/package.json index 0fb17e98..9d37f8f6 100644 --- a/packages/data-lit/package.json +++ b/packages/data-lit/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-lit", - "version": "0.9.76", + "version": "0.9.77", "description": "Adobe data Lit bindings - hooks, elements, decorators", "type": "module", "private": false, diff --git a/packages/data-p2p-tictactoe/package.json b/packages/data-p2p-tictactoe/package.json index 4c647e11..5f51b658 100644 --- a/packages/data-p2p-tictactoe/package.json +++ b/packages/data-p2p-tictactoe/package.json @@ -1,6 +1,6 @@ { "name": "data-p2p-tictactoe", - "version": "0.9.76", + "version": "0.9.77", "description": "Serverless P2P tic-tac-toe — WebRTC DataChannel + @adobe/data-sync", "type": "module", "private": true, diff --git a/packages/data-persistence/package.json b/packages/data-persistence/package.json index 2601befc..4d1089d1 100644 --- a/packages/data-persistence/package.json +++ b/packages/data-persistence/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-persistence", - "version": "0.9.76", + "version": "0.9.77", "description": "Worker-based incremental persistence layer for @adobe/data ECS over OPFS (browser) and node:fs (server).", "type": "module", "sideEffects": false, diff --git a/packages/data-react-hello/package.json b/packages/data-react-hello/package.json index 7fc201bb..a9e977fc 100644 --- a/packages/data-react-hello/package.json +++ b/packages/data-react-hello/package.json @@ -1,6 +1,6 @@ { "name": "data-react-hello", - "version": "0.9.76", + "version": "0.9.77", "description": "Hello World sample - click counter using @adobe/data-react", "type": "module", "private": true, diff --git a/packages/data-react-pixie/package.json b/packages/data-react-pixie/package.json index 5753c6e4..d0369aed 100644 --- a/packages/data-react-pixie/package.json +++ b/packages/data-react-pixie/package.json @@ -1,6 +1,6 @@ { "name": "data-react-pixie", - "version": "0.9.76", + "version": "0.9.77", "description": "PixiJS React sample - ECS sprites (bunny, fox) with @adobe/data-react", "type": "module", "private": true, diff --git a/packages/data-react/package.json b/packages/data-react/package.json index bd868e59..610c124a 100644 --- a/packages/data-react/package.json +++ b/packages/data-react/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-react", - "version": "0.9.76", + "version": "0.9.77", "description": "Adobe data React bindings — hooks and context for ECS database", "type": "module", "private": false, diff --git a/packages/data-solid-dashboard/package.json b/packages/data-solid-dashboard/package.json index a4ebd8ee..5b0750b8 100644 --- a/packages/data-solid-dashboard/package.json +++ b/packages/data-solid-dashboard/package.json @@ -1,6 +1,6 @@ { "name": "data-solid-dashboard", - "version": "0.9.76", + "version": "0.9.77", "description": "Mini dashboard sample — multiple components sharing one @adobe/data ECS database with SolidJS", "type": "module", "private": true, diff --git a/packages/data-solid/package.json b/packages/data-solid/package.json index 6ad88bc1..f244b72b 100644 --- a/packages/data-solid/package.json +++ b/packages/data-solid/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-solid", - "version": "0.9.76", + "version": "0.9.77", "description": "Adobe data SolidJS bindings — context and provider for ECS database", "type": "module", "private": false, diff --git a/packages/data-sync/package.json b/packages/data-sync/package.json index d8a450b7..6439e283 100644 --- a/packages/data-sync/package.json +++ b/packages/data-sync/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-sync", - "version": "0.9.76", + "version": "0.9.77", "description": "Multi-user real-time synchronisation for @adobe/data ECS — server, client, and in-process loopback.", "type": "module", "sideEffects": false, diff --git a/packages/data/package.json b/packages/data/package.json index 09b9ad99..343ab2ce 100644 --- a/packages/data/package.json +++ b/packages/data/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data", - "version": "0.9.76", + "version": "0.9.77", "description": "Adobe data oriented programming library", "type": "module", "sideEffects": false, diff --git a/packages/data/src/ecs/store/core/create-core.ts b/packages/data/src/ecs/store/core/create-core.ts index cb3ba9b8..a42495f9 100644 --- a/packages/data/src/ecs/store/core/create-core.ts +++ b/packages/data/src/ecs/store/core/create-core.ts @@ -207,7 +207,14 @@ export function createCore(newComponentSchemas: NC) toData: (copy = false) => ({ componentSchemas, entityLocationTableData: persistentLocationTable.toData(copy), - archetypesData: archetypes.map(archetype => archetype.toData(copy)) + // nonPersistent archetypes hold rows for the negative-ID entity + // space (session-only resources and entities). That space is + // never serialized (entityLocationTableData above only covers + // persistentLocationTable), so including their row data here + // would silently leak "nonPersistent" state into the snapshot. + archetypesData: archetypes + .filter(archetype => !archetype.components.has("nonPersistent")) + .map(archetype => archetype.toData(copy)) }), fromData: (data: any) => { Object.assign(componentSchemas, data.componentSchemas); diff --git a/packages/data/src/ecs/store/public/create-store.test.ts b/packages/data/src/ecs/store/public/create-store.test.ts index 44f450db..9632837e 100644 --- a/packages/data/src/ecs/store/public/create-store.test.ts +++ b/packages/data/src/ecs/store/public/create-store.test.ts @@ -619,6 +619,39 @@ describe("createStore", () => { expect(newStore.resources.config).toEqual({ debug: true, volume: 0.5 }); }); + it("should exclude nonPersistent resources from serialized data", () => { + const schema = { + components: {}, + resources: { + score: { default: 0 as number, nonPersistent: true }, + persistentScore: { default: 0 as number }, + }, + archetypes: {}, + } as const; + + const store = createStore(schema as any); + (store.resources as any).score = 999; + (store.resources as any).persistentScore = 123; + + const serializedData: any = store.toData(true); + + // The nonPersistent resource's archetype is never included in + // the snapshot: its entity lives in the negative-ID space, which + // entityLocationTableData never covers either. + const hasNonPersistentArchetype = serializedData.archetypesData.some( + (a: any) => "score" in a.columns + ); + expect(hasNonPersistentArchetype).toBe(false); + + const newStore = createStore(schema as any); + newStore.fromData(serializedData); + + // Persistent resource restored from the snapshot. + expect((newStore.resources as any).persistentScore).toBe(123); + // nonPersistent resource is never restored; the fresh store keeps its default. + expect((newStore.resources as any).score).toBe(0); + }); + it("toData(true) detaches the snapshot from later store mutation; toData() references live buffers", () => { const makePopulatedStore = () => { const store = createStore({ components: { health: healthSchema }, resources: {}, archetypes: {} }); From 0547d915fedbfede366bd6332d791004ba4df149 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 10 Jul 2026 10:40:12 -0700 Subject: [PATCH 2/6] fix(data): preserve archetype ids when excluding nonPersistent data from serialization The previous commit dropped nonPersistent archetypes from Store.toData() entirely. Archetype ids are dense array indices stored by index in the persistent location table, so omitting a nonPersistent archetype that precedes a persistent one shifted every later id on reload, leaving persistent entities pointing at the wrong (or a missing) archetype. Instead keep every archetype's slot to preserve ids, but serialize nonPersistent (and deprecated ephemeral) archetypes as a data-free stub of component names. fromData recreates the empty archetype at the same id and restores rows only for persistent archetypes. Adds a regression test covering the id-aliasing case. Co-Authored-By: Claude Opus 4.8 --- .../data/src/ecs/store/core/create-core.ts | 49 +++++++++++++---- .../src/ecs/store/public/create-store.test.ts | 52 ++++++++++++++++--- 2 files changed, 84 insertions(+), 17 deletions(-) diff --git a/packages/data/src/ecs/store/core/create-core.ts b/packages/data/src/ecs/store/core/create-core.ts index a42495f9..d928445d 100644 --- a/packages/data/src/ecs/store/core/create-core.ts +++ b/packages/data/src/ecs/store/core/create-core.ts @@ -13,6 +13,16 @@ import { ComponentSchemas } from "../../component-schemas.js"; import { OptionalComponents } from "../../optional-components.js"; import { True } from "../../../schema/true/index.js"; +// An archetype lives in the nonPersistent (negative-ID) space when its +// component set carries the `nonPersistent` marker — or the deprecated +// `ephemeral` one, which `ensureArchetype` still routes to that same space. +const isNonPersistentComponentSet = (components: Iterable): boolean => { + for (const c of components) { + if (c === "nonPersistent" || c === "ephemeral") return true; + } + return false; +}; + export function createCore(newComponentSchemas: NC): Core]: Schema.ToType }>> { type C = RequiredComponents & { [K in StringKeyof]: Schema.ToType }; @@ -207,22 +217,41 @@ export function createCore(newComponentSchemas: NC) toData: (copy = false) => ({ componentSchemas, entityLocationTableData: persistentLocationTable.toData(copy), - // nonPersistent archetypes hold rows for the negative-ID entity - // space (session-only resources and entities). That space is - // never serialized (entityLocationTableData above only covers - // persistentLocationTable), so including their row data here - // would silently leak "nonPersistent" state into the snapshot. - archetypesData: archetypes - .filter(archetype => !archetype.components.has("nonPersistent")) - .map(archetype => archetype.toData(copy)) + // Archetypes in the nonPersistent (negative-ID) space back + // session-only resources and entities. That space is never + // serialized — `entityLocationTableData` above only covers the + // persistent location table — so their row data must not leak + // into the snapshot. + // + // They cannot simply be dropped, though: an archetype's id is its + // index in this dense array and is stored (by index) in the + // persistent location table. Omitting a nonPersistent archetype + // that precedes a persistent one would shift every later id on + // reload, leaving persistent entities pointing at the wrong + // archetype. So we keep every archetype's slot to preserve ids, + // but serialize nonPersistent ones as a data-free stub carrying + // only their component names (enough for `fromData` to recreate + // the empty archetype at the same id). + archetypesData: archetypes.map(archetype => + isNonPersistentComponentSet(archetype.components) + ? { componentNames: [...archetype.components] } + : archetype.toData(copy) + ) }), fromData: (data: any) => { Object.assign(componentSchemas, data.componentSchemas); persistentLocationTable.fromData(data.entityLocationTableData); for (let i = 0; i < data.archetypesData.length; i++) { - const componentNames = Object.keys(data.archetypesData[i].columns); + const entry = data.archetypesData[i]; + // Persistent entries carry `columns`; nonPersistent stubs carry + // only `componentNames` (see `toData`). + const componentNames: string[] = entry.componentNames ?? Object.keys(entry.columns); const archetype = ensureArchetype(componentNames as any); - archetype.fromData(data.archetypesData[i]); + // Recreating the archetype above already reserves its id and + // leaves it empty; only persistent archetypes restore rows. + if (!isNonPersistentComponentSet(componentNames)) { + archetype.fromData(entry); + } } } }; diff --git a/packages/data/src/ecs/store/public/create-store.test.ts b/packages/data/src/ecs/store/public/create-store.test.ts index 9632837e..a9fe148e 100644 --- a/packages/data/src/ecs/store/public/create-store.test.ts +++ b/packages/data/src/ecs/store/public/create-store.test.ts @@ -619,7 +619,7 @@ describe("createStore", () => { expect(newStore.resources.config).toEqual({ debug: true, volume: 0.5 }); }); - it("should exclude nonPersistent resources from serialized data", () => { + it("should exclude nonPersistent resource row data from serialized data", () => { const schema = { components: {}, resources: { @@ -635,13 +635,20 @@ describe("createStore", () => { const serializedData: any = store.toData(true); - // The nonPersistent resource's archetype is never included in - // the snapshot: its entity lives in the negative-ID space, which - // entityLocationTableData never covers either. - const hasNonPersistentArchetype = serializedData.archetypesData.some( - (a: any) => "score" in a.columns + // The nonPersistent resource's value must never appear in the + // snapshot: its entity lives in the negative-ID space, which + // entityLocationTableData never covers either. Its archetype slot + // is still present (as a data-free stub, to keep archetype ids + // stable) but carries no `columns`. + const scoreEntry = serializedData.archetypesData.find( + (a: any) => a.componentNames?.includes("score") ); - expect(hasNonPersistentArchetype).toBe(false); + expect(scoreEntry).toBeDefined(); + expect(scoreEntry.columns).toBeUndefined(); + const anyColumnsHoldScore = serializedData.archetypesData.some( + (a: any) => a.columns && "score" in a.columns + ); + expect(anyColumnsHoldScore).toBe(false); const newStore = createStore(schema as any); newStore.fromData(serializedData); @@ -652,6 +659,37 @@ describe("createStore", () => { expect((newStore.resources as any).score).toBe(0); }); + it("preserves archetype ids across serialization when a nonPersistent archetype precedes a persistent one", () => { + const selectionSchema = { type: "boolean", default: false } as const satisfies Schema; + const positionScalarSchema = { type: "number", default: 0 } as const satisfies Schema; + const makeStore = () => createStore({ + components: { selection: selectionSchema, position: positionScalarSchema }, + resources: {}, + archetypes: {}, + }); + + const store = makeStore(); + + // Ad-hoc nonPersistent entity archetype created FIRST → lower id. + const selectionArchetype = store.ensureArchetype(["id", "selection", "nonPersistent"]); + selectionArchetype.insert({ selection: true, nonPersistent: true }); + + // Persistent entity archetype created AFTER → higher id, referenced + // by the persistent entity-location table by that id. + const positionArchetype = store.ensureArchetype(["id", "position"]); + const positionEntity = positionArchetype.insert({ position: 42 }); + + const serializedData = store.toData(true); + + const newStore = makeStore(); + newStore.fromData(serializedData); + + // The persistent entity must still resolve to the right archetype + // after reload — it would not if the nonPersistent archetype's slot + // were dropped and later ids shifted down. + expect(newStore.read(positionEntity)).toEqual({ id: positionEntity, position: 42 }); + }); + it("toData(true) detaches the snapshot from later store mutation; toData() references live buffers", () => { const makePopulatedStore = () => { const store = createStore({ components: { health: healthSchema }, resources: {}, archetypes: {} }); From 08ec7fade8a06b09224b0f6791b68f713e6eef6f Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 10 Jul 2026 11:40:54 -0700 Subject: [PATCH 3/6] refactor(data): redesign nonPersistent serialization; drop the ephemeral alias MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the stub-based fix with a cleaner, uniform serialization model and removes the dead `ephemeral` naming entirely (breaking change). Serialization: every archetype serializes as `{ componentNames, data? }`. `data` is present only for persistent archetypes; nonPersistent (negative-ID space) archetypes serialize identity only. This preserves the dense archetype id (referenced by value in the persistent location table) without persisting session-only rows, and collapses the read/write decisions to O(1): `components.has("nonPersistent")` on write and `if (data)` on read — no component scanning. Verified end-to-end through the serialize/deserialize codec (the real bug: a nonPersistent resource previously decoded to `undefined`). Drop `ephemeral`: removed the `ephemeral` schema flag, the `ephemeral` component alias, `Entity.isEphemeral`, and every `?? schema.ephemeral` fallback. Only `nonPersistent` remains. Renamed is-ephemeral.ts → is-non-persistent.ts and updated ECS/sync docs. Existing snapshots that relied on the old format or the ephemeral flag are not compatible. Co-Authored-By: Claude Opus 4.8 --- .../src/state/negotiation-plugin.ts | 26 +++---- packages/data-sync/README.md | 12 ++-- packages/data-sync/src/sync.test.ts | 16 ++--- packages/data/src/ecs/README.md | 58 ++++++++------- .../ecs/database/create-plugin.type-test.ts | 10 +-- .../src/ecs/database/database.reset.test.ts | 4 +- .../observed/create-observed-database.ts | 2 +- .../create-transactional-store.ts | 4 +- .../create-entity-location-table.test.ts | 2 +- .../create-entity-location-table.ts | 4 +- ...eral.test.ts => is-non-persistent.test.ts} | 7 -- .../{is-ephemeral.ts => is-non-persistent.ts} | 3 - packages/data/src/ecs/entity/public.ts | 2 +- packages/data/src/ecs/optional-components.ts | 5 -- .../src/ecs/store/core/create-core.test.ts | 42 +++++------ .../data/src/ecs/store/core/create-core.ts | 71 +++++++------------ .../src/ecs/store/public/create-store.test.ts | 43 +++++++++-- .../data/src/ecs/store/public/create-store.ts | 2 +- .../serialization/serialization.test.ts | 12 ++-- .../schema/dynamic/enumerate-patches.test.ts | 16 ++--- .../schema/dynamic/get-dynamic-schema.test.ts | 8 +-- packages/data/src/schema/schema.ts | 2 - .../create-boolean-buffer.test.ts | 18 ++--- .../register-typed-buffer-codecs.ts | 10 +-- 24 files changed, 186 insertions(+), 193 deletions(-) rename packages/data/src/ecs/entity/{is-ephemeral.test.ts => is-non-persistent.test.ts} (82%) rename packages/data/src/ecs/entity/{is-ephemeral.ts => is-non-persistent.ts} (72%) diff --git a/packages/data-p2p-tictactoe/src/state/negotiation-plugin.ts b/packages/data-p2p-tictactoe/src/state/negotiation-plugin.ts index d4db3fe7..093092fd 100644 --- a/packages/data-p2p-tictactoe/src/state/negotiation-plugin.ts +++ b/packages/data-p2p-tictactoe/src/state/negotiation-plugin.ts @@ -8,8 +8,8 @@ export type ConnectionState = "idle" | "connecting" | "connected" | "disconnecte /** * Negotiation-only ECS state — resources + transactions. All resources are - * `ephemeral: true` so they are never replicated to peers (the negotiation DB - * itself is always local-only, but the ephemeral flag is good documentation). + * `nonPersistent: true` so they are never replicated to peers (the negotiation DB + * itself is always local-only, but the flag is good documentation). * * The game role (userId) lives in the synced game DB referenced by the * `gameDb` resource. Once the WebRTC handshake completes, the negotiation @@ -21,23 +21,23 @@ export type ConnectionState = "idle" | "connecting" | "connected" | "disconnecte */ export const negotiationStatePlugin = Database.Plugin.create({ resources: { - phase: { default: "idle" as Phase, ephemeral: true }, - connection: { default: "idle" as ConnectionState, ephemeral: true }, - role: { default: null as "host" | "joiner" | null, ephemeral: true }, - sessionId: { default: null as string | null, ephemeral: true }, - offerCode: { default: "" as string, ephemeral: true }, - answerCode: { default: "" as string, ephemeral: true }, - bannerText: { default: "" as string, ephemeral: true }, - bannerError: { default: false as boolean, ephemeral: true }, + phase: { default: "idle" as Phase, nonPersistent: true }, + connection: { default: "idle" as ConnectionState, nonPersistent: true }, + role: { default: null as "host" | "joiner" | null, nonPersistent: true }, + sessionId: { default: null as string | null, nonPersistent: true }, + offerCode: { default: "" as string, nonPersistent: true }, + answerCode: { default: "" as string, nonPersistent: true }, + bannerText: { default: "" as string, nonPersistent: true }, + bannerError: { default: false as boolean, nonPersistent: true }, // Live values of the two paste textareas. Backing them with // resources keeps the textareas controlled and avoids touching // the DOM from action callbacks. - hostAnswerInput: { default: "" as string, ephemeral: true }, - joinerOfferInput: { default: "" as string, ephemeral: true }, + hostAnswerInput: { default: "" as string, nonPersistent: true }, + joinerOfferInput: { default: "" as string, nonPersistent: true }, // The synced game database, populated by the negotiation service // after the WebRTC channel opens. `unknown` so the plugin stays // game-agnostic; consumers cast at the render boundary. - gameDb: { default: null as unknown, ephemeral: true }, + gameDb: { default: null as unknown, nonPersistent: true }, }, transactions: { startHostSignaling(t) { diff --git a/packages/data-sync/README.md b/packages/data-sync/README.md index 9f44f3ac..d680c7c4 100644 --- a/packages/data-sync/README.md +++ b/packages/data-sync/README.md @@ -82,11 +82,11 @@ db.transactions.movePresence(async function* () { The transaction never commits; every yield is a transient envelope; sync forwards each to peers; peers see continuously-updated presence state. -### Ephemeral resources stay local +### nonPersistent resources stay local -Resources marked `ephemeral: true` in their schema (or entities allocated -with the built-in `ephemeral` component) produce transactions whose -`TransactionResult.ephemeral === true`. The sync service skips those +Resources marked `nonPersistent: true` in their schema (or entities allocated +with the built-in `nonPersistent` component) produce transactions whose +`TransactionResult.persistent === false`. The sync service skips those envelopes entirely — they never reach the wire. Use this for per-tab UI state (selection, panel positions, signaling intermediaries, etc.) without inventing a second database. @@ -405,7 +405,7 @@ default. ### Pattern: per-user state archetypes -Model user-specific ephemeral state (cursors, selections, presence) as its +Model user-specific nonPersistent state (cursors, selections, presence) as its own archetype keyed by `userId`. This avoids all contention: each user only ever writes to their own row. @@ -642,5 +642,5 @@ expect(db.select(["x"]).length).toBe(1); ``` See `src/sync.test.ts` for the full soundness suite, including concurrent -insert ordering, late-join convergence, ephemeral-no-replicate, and +insert ordering, late-join convergence, nonPersistent-no-replicate, and compound `(userId, id)` isolation. diff --git a/packages/data-sync/src/sync.test.ts b/packages/data-sync/src/sync.test.ts index 689e2179..8cc01c4a 100644 --- a/packages/data-sync/src/sync.test.ts +++ b/packages/data-sync/src/sync.test.ts @@ -26,9 +26,9 @@ const plugin = Database.Plugin.create({ resources: { // Synced resource — replicates to peers. score: { default: 0 as number }, - // Local-only resource — never replicates because of `ephemeral: true`. - // (Verified by the "ephemeral resource never replicates" test below.) - bannerText: { default: "" as string, ephemeral: true }, + // Local-only resource — never replicates because of `nonPersistent: true`. + // (Verified by the "nonPersistent resource never replicates" test below.) + bannerText: { default: "" as string, nonPersistent: true }, }, archetypes: { Point: ["x", "y", "label"], @@ -188,13 +188,13 @@ describe("sync soundness", () => { }); // ----------------------------------------------------------------------- - // 5. Ephemeral resource never replicates. - // Red-green: this is the semantic guarantee that ephemeral: true + // 5. NonPersistent resource never replicates. + // Red-green: this is the semantic guarantee that nonPersistent: true // resources stay local. Sync service skips envelopes whose - // TransactionResult.ephemeral === true. + // TransactionResult.nonPersistent === true. // ----------------------------------------------------------------------- - it("ephemeral resource mutations are never replicated to peers", () => { + it("nonPersistent resource mutations are never replicated to peers", () => { const server = createSyncServer(); const { client: c1t, server: s1t } = createLoopbackTransport(); const { client: c2t, server: s2t } = createLoopbackTransport(); @@ -215,7 +215,7 @@ describe("sync soundness", () => { // Peer 2 must NOT see it. expect(db2.resources.bannerText).toBe(""); - // Sanity check: a non-ephemeral mutation in the same session DOES replicate. + // Sanity check: a non-nonPersistent mutation in the same session DOES replicate. db1.transactions.bumpScore({ delta: 5 }); expect(db1.resources.score).toBe(5); expect(db2.resources.score).toBe(5); diff --git a/packages/data/src/ecs/README.md b/packages/data/src/ecs/README.md index 914a472b..1af6d156 100644 --- a/packages/data/src/ecs/README.md +++ b/packages/data/src/ecs/README.md @@ -26,7 +26,7 @@ db.transactions.addPoints(10); ## Core Concepts -**Entity** — a unique integer ID. Persistent entities have positive IDs; ephemeral entities have negative IDs. +**Entity** — a unique integer ID. Persistent entities have positive IDs; nonPersistent entities have negative IDs. **Component** — a named data column. Each component has a schema that describes its type. Numeric schemas (F32, Vec3, etc.) are stored in tightly packed typed arrays for cache-friendly performance. @@ -634,7 +634,7 @@ const data = db.toData(); db.fromData(data); ``` -Ephemeral entities and components marked `ephemeral: true` in their schema are excluded from serialization. +nonPersistent entities, and components/resources whose schema is marked `nonPersistent: true`, are excluded from serialization. ## Type Utilities @@ -648,70 +648,68 @@ type MyStore = Database.Plugin.ToStore; --- -## Reference: Transient and Ephemeral Semantics +## Reference: nonPersistent and Intermediate Semantics -The ECS uses the terms "ephemeral" and "transient" with precise, distinct meanings. Ephemeral means **not persisted**. Transient means **intermediate value**. +The ECS separates two orthogonal ideas. **nonPersistent** means *not persisted* (excluded from serialization). **Intermediate** means *not the final committed step* of a transaction sequence. -### Ephemeral Component +### nonPersistent Component -A built-in optional component that can only be set at entity creation time. It cannot be added to or removed from an existing entity. Entities created with this component are allocated negative IDs and stored in a separate entity table. +A built-in optional component that can only be set at entity creation time. It cannot be added to or removed from an existing entity. Entities created with this component are allocated negative IDs and stored in a separate entity table that is never serialized. -### Ephemeral Entities +### nonPersistent Entities -Entities created with the `ephemeral` component. They always have negative IDs and are never persisted. Use ephemeral entities for session-only or UI-local state (selections, hover states, panel positions, etc.). +Entities created with the `nonPersistent` component. They always have negative IDs and are never persisted. Use them for session-only or UI-local state (selections, hover states, panel positions, etc.). -### Ephemeral Schema +### nonPersistent Schema -A component or resource schema with `ephemeral: true`. This marks the data as not persisted, but unlike the ephemeral component, it can live on a persistent entity. Ephemeral schemas are excluded from serialization but their entities still carry positive IDs. +A component or resource schema with `nonPersistent: true`. This marks the *column's data* as not persisted; unlike the component, such a column can live on a persistent (positive-ID) entity. The column is excluded from serialization and reset to its default on load. ```ts resources: { - isHovering: { default: false as boolean, ephemeral: true }, + isHovering: { default: false as boolean, nonPersistent: true }, }, ``` -### Transient Transaction +Setting `nonPersistent: true` on a **resource** schema also places that resource's singleton entity in the negative-ID space (it gets the `nonPersistent` component), so the resource resets to its default on load. -A transaction that is part of an async sequence and is not the final committed step. Each `yield` in an async generator transaction produces a transient transaction. Reconciling database replays also produce transient transactions. Transient transactions notify observers but are not pushed to the undo stack and should not trigger persistence. +### Intermediate Transaction -### Ephemeral Transaction +A transaction that is part of an async sequence and is not the final committed step. Each `yield` in an async generator transaction produces an intermediate transaction, as do reconciling-database replays. Intermediate transactions notify observers but are not pushed to the undo stack and should not trigger persistence. Exposed as `TransactionResult.intermediate`. -A transaction whose `TransactionResult.ephemeral` property is `true`. This happens when every entity touched by the transaction is an ephemeral entity (negative ID). If even one persistent entity was modified, the transaction is not ephemeral. +### `TransactionResult` flags -### How They Interact on `TransactionResult` - -Every `TransactionResult` carries two independent boolean flags: +Every `TransactionResult` carries two independent booleans: | Flag | Source | Meaning | |---|---|---| -| `transient` | Caller-provided via `execute` options | The transaction is an intermediate step, not the final commit | -| `ephemeral` | Derived from what changed | The transaction only touched ephemeral entities | +| `intermediate` | Caller-provided via `execute` options | The transaction is a non-final step, not the final commit | +| `persistent` | Derived from what changed | At least one changed entity is persistent (id ≥ 0) | -These flags are orthogonal. All four combinations are valid: +These are orthogonal. All four combinations are valid: -| `transient` | `ephemeral` | Example | +| `intermediate` | `persistent` | Example | |---|---|---| -| `false` | `false` | Normal committed change to persistent data | -| `true` | `false` | Async generator yield that modifies persistent entities | -| `false` | `true` | Committed change to UI-only state (e.g. selection) | -| `true` | `true` | Intermediate step touching only ephemeral data | +| `false` | `true` | Normal committed change to persistent data | +| `true` | `true` | Async generator yield that modifies persistent entities | +| `false` | `false` | Committed change to UI-only state (e.g. selection) | +| `true` | `false` | Intermediate step touching only nonPersistent data | ### Consumer Guidelines -**Persistence observers** should skip both transient and ephemeral transactions — transient results are not final, and ephemeral results have nothing to persist: +**Persistence observers** should skip intermediate transactions and those with nothing persistent to save: ```ts db.observe.transactions((t) => { - if (t.transient || t.ephemeral) return; + if (t.intermediate || !t.persistent) return; save(); }); ``` -**Undo/redo** should skip transient transactions (intermediate steps shouldn't clutter the undo stack) but may still record ephemeral ones when marked undoable, since users may want to undo UI state changes like selection: +**Undo/redo** should skip intermediate transactions (non-final steps shouldn't clutter the undo stack) but may still record nonPersistent-only ones when marked undoable, since users may want to undo UI state changes like selection: ```ts db.observe.transactions((t) => { - if (t.undoable && !t.transient) { + if (t.undoable && !t.intermediate) { pushToUndoStack(t); } }); diff --git a/packages/data/src/ecs/database/create-plugin.type-test.ts b/packages/data/src/ecs/database/create-plugin.type-test.ts index 5aeb9f57..729931ae 100644 --- a/packages/data/src/ecs/database/create-plugin.type-test.ts +++ b/packages/data/src/ecs/database/create-plugin.type-test.ts @@ -35,8 +35,8 @@ function validTypeInferenceTests() { }, archetypes: { A: ["a", "b"], - ABEphemeral: ["a", "b", "ephemeral"], - Ephemeral: ["ephemeral"], + ABEphemeral: ["a", "b", "nonPersistent"], + Ephemeral: ["nonPersistent"], } }); @@ -51,7 +51,7 @@ function validTypeInferenceTests() { }, archetypes: { A: ["a", "b"], - ABEphemeral: ["a", "b", "ephemeral"], + ABEphemeral: ["a", "b", "nonPersistent"], }, transactions: { testChanges: (store) => { @@ -242,7 +242,7 @@ function validTypeInferenceTests() { }, archetypes: { Foo: ["alpha", "beta"], - FooEphemeral: ["alpha", "beta", "ephemeral"], + FooEphemeral: ["alpha", "beta", "nonPersistent"], }, transactions: { doAlpha: (store, input: { a: number, b: string }) => { }, @@ -285,7 +285,7 @@ function validTypeInferenceTests() { }, archetypes: { A: ["a", "b", "alpha", "beta"], - ABEphemeral: ["a", "b", "alpha", "beta", "ephemeral"], + ABEphemeral: ["a", "b", "alpha", "beta", "nonPersistent"], }, transactions: { testChanges: (store) => { diff --git a/packages/data/src/ecs/database/database.reset.test.ts b/packages/data/src/ecs/database/database.reset.test.ts index 83ae59e3..4be1d720 100644 --- a/packages/data/src/ecs/database/database.reset.test.ts +++ b/packages/data/src/ecs/database/database.reset.test.ts @@ -21,7 +21,7 @@ const plugin = Database.Plugin.create({ } as const, resources: { score: { default: 0 as number }, - banner: { default: "" as string, ephemeral: true }, + banner: { default: "" as string, nonPersistent: true }, }, archetypes: { Point: ["x", "label"], @@ -148,7 +148,7 @@ describe("Database.reset() — observer notification", () => { unsub(); }); - it("ephemeral resource observer fires after reset", () => { + it("nonPersistent resource observer fires after reset", () => { const db = makeFresh(); db.transactions.setBanner({ text: "hi" }); diff --git a/packages/data/src/ecs/database/observed/create-observed-database.ts b/packages/data/src/ecs/database/observed/create-observed-database.ts index c921c01d..fd035557 100644 --- a/packages/data/src/ecs/database/observed/create-observed-database.ts +++ b/packages/data/src/ecs/database/observed/create-observed-database.ts @@ -115,7 +115,7 @@ export function createObservedDatabase< const observeComponent = mapEntries(store.componentSchemas, ([component]) => addToMapSet(component, componentObservers)); const resourceArchetypeComponents = (resource: string): StringKeyof[] => { - const isNonPersistent = (store.componentSchemas as any)[resource]?.nonPersistent ?? (store.componentSchemas as any)[resource]?.ephemeral; + const isNonPersistent = (store.componentSchemas as any)[resource]?.nonPersistent; return isNonPersistent ? ["id" as StringKeyof, resource as unknown as StringKeyof, "nonPersistent" as StringKeyof] : ["id" as StringKeyof, resource as unknown as StringKeyof]; diff --git a/packages/data/src/ecs/database/transactional-store/create-transactional-store.ts b/packages/data/src/ecs/database/transactional-store/create-transactional-store.ts index dbb2b010..0cec19b3 100644 --- a/packages/data/src/ecs/database/transactional-store/create-transactional-store.ts +++ b/packages/data/src/ecs/database/transactional-store/create-transactional-store.ts @@ -154,7 +154,7 @@ export function createTransactionalStore< const resources = {} as { [K in keyof R]: R[K] }; for (const name of Object.keys(store.resources)) { const resourceId = name as keyof C; - const isNonPersistent = (store.componentSchemas as any)[name]?.nonPersistent ?? (store.componentSchemas as any)[name]?.ephemeral; + const isNonPersistent = (store.componentSchemas as any)[name]?.nonPersistent; const componentNames = isNonPersistent ? ["id", resourceId, "nonPersistent"] as StringKeyof[] : ["id", resourceId] as StringKeyof[]; @@ -262,7 +262,7 @@ export function createTransactionalStore< for (const name of Object.keys(store.resources)) { if (!Object.hasOwn(resources, name)) { const resourceId = name as keyof C; - const isNonPersistent = (store.componentSchemas as any)[name]?.nonPersistent ?? (store.componentSchemas as any)[name]?.ephemeral; + const isNonPersistent = (store.componentSchemas as any)[name]?.nonPersistent; const componentNames = isNonPersistent ? ["id", resourceId, "nonPersistent"] as StringKeyof[] : ["id", resourceId] as StringKeyof[]; diff --git a/packages/data/src/ecs/entity-location-table/create-entity-location-table.test.ts b/packages/data/src/ecs/entity-location-table/create-entity-location-table.test.ts index 70eff23d..5f605d5d 100644 --- a/packages/data/src/ecs/entity-location-table/create-entity-location-table.test.ts +++ b/packages/data/src/ecs/entity-location-table/create-entity-location-table.test.ts @@ -154,7 +154,7 @@ describe('createEntityLocationTable', () => { }); }); -describe('createEphemeralEntityLocationTable', () => { +describe('createNonPersistentEntityLocationTable', () => { it('should create entities with increasing ids starting from -1', () => { const table = createEntityLocationTable(16, true); diff --git a/packages/data/src/ecs/entity-location-table/create-entity-location-table.ts b/packages/data/src/ecs/entity-location-table/create-entity-location-table.ts index 4de8363f..c86f2426 100644 --- a/packages/data/src/ecs/entity-location-table/create-entity-location-table.ts +++ b/packages/data/src/ecs/entity-location-table/create-entity-location-table.ts @@ -5,8 +5,8 @@ import { EntityLocation } from "./entity-location.js"; import { Entity } from "../entity/entity.js"; import { createSharedArrayBuffer } from "../../internal/shared-array-buffer/create-shared-array-buffer.js"; -export const createEntityLocationTable = (initialCapacity: number = 16, ephemeral: boolean = false): EntityLocationTable => { - return ephemeral ? createNegativeEntityLocationTable(initialCapacity) : createPositiveEntityLocationTable(initialCapacity); +export const createEntityLocationTable = (initialCapacity: number = 16, nonPersistent: boolean = false): EntityLocationTable => { + return nonPersistent ? createNegativeEntityLocationTable(initialCapacity) : createPositiveEntityLocationTable(initialCapacity); } const createNegativeEntityLocationTable = (initialCapacity: number = 16): EntityLocationTable => { diff --git a/packages/data/src/ecs/entity/is-ephemeral.test.ts b/packages/data/src/ecs/entity/is-non-persistent.test.ts similarity index 82% rename from packages/data/src/ecs/entity/is-ephemeral.test.ts rename to packages/data/src/ecs/entity/is-non-persistent.test.ts index 529e7435..7deccca0 100644 --- a/packages/data/src/ecs/entity/is-ephemeral.test.ts +++ b/packages/data/src/ecs/entity/is-non-persistent.test.ts @@ -27,10 +27,3 @@ describe("Entity.isPersistent", () => { expect(Entity.isPersistent(-100)).toBe(false); }); }); - -describe("Entity.isEphemeral (deprecated)", () => { - it("should behave identically to isNonPersistent", () => { - expect(Entity.isEphemeral(-1)).toBe(true); - expect(Entity.isEphemeral(1)).toBe(false); - }); -}); diff --git a/packages/data/src/ecs/entity/is-ephemeral.ts b/packages/data/src/ecs/entity/is-non-persistent.ts similarity index 72% rename from packages/data/src/ecs/entity/is-ephemeral.ts rename to packages/data/src/ecs/entity/is-non-persistent.ts index a61f72ea..270e7fc0 100644 --- a/packages/data/src/ecs/entity/is-ephemeral.ts +++ b/packages/data/src/ecs/entity/is-non-persistent.ts @@ -3,6 +3,3 @@ import type { Entity } from "./entity.js"; export const isNonPersistent = (entity: Entity): boolean => entity < 0; export const isPersistent = (entity: Entity): boolean => entity >= 0; - -/** @deprecated Use `isNonPersistent` instead */ -export const isEphemeral = isNonPersistent; diff --git a/packages/data/src/ecs/entity/public.ts b/packages/data/src/ecs/entity/public.ts index 789359e3..8b71b9a7 100644 --- a/packages/data/src/ecs/entity/public.ts +++ b/packages/data/src/ecs/entity/public.ts @@ -1,3 +1,3 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. export { schema } from "./schema.js"; -export { isNonPersistent, isPersistent, isEphemeral } from "./is-ephemeral.js"; +export { isNonPersistent, isPersistent } from "./is-non-persistent.js"; diff --git a/packages/data/src/ecs/optional-components.ts b/packages/data/src/ecs/optional-components.ts index b4ae9dda..dd073621 100644 --- a/packages/data/src/ecs/optional-components.ts +++ b/packages/data/src/ecs/optional-components.ts @@ -1,9 +1,4 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Entity } from "./entity/entity.js"; - // © 2026 Adobe. MIT License. See /LICENSE for details. export type OptionalComponents = { nonPersistent: true; - /** @deprecated Use `"nonPersistent"` instead */ - ephemeral?: true; }; diff --git a/packages/data/src/ecs/store/core/create-core.test.ts b/packages/data/src/ecs/store/core/create-core.test.ts index b00c675c..404e94b2 100644 --- a/packages/data/src/ecs/store/core/create-core.test.ts +++ b/packages/data/src/ecs/store/core/create-core.test.ts @@ -446,14 +446,14 @@ export function createCoreTestSuite( } }); - it("should create ephemeral entities with negative ids", () => { + it("should create nonPersistent entities with negative ids", () => { const core = factory({ position: positionSchema, health: healthSchema, }); - const ephemeralPositionTable = core.ensureArchetype(["id", "position", "ephemeral"]); - const writeId = ephemeralPositionTable.insert({ position: { x: 1, y: 2, z: 3 }, ephemeral: true }); + const ephemeralPositionTable = core.ensureArchetype(["id", "position", "nonPersistent"]); + const writeId = ephemeralPositionTable.insert({ position: { x: 1, y: 2, z: 3 }, nonPersistent: true }); expect(writeId).toBe(-1); const readId = ephemeralPositionTable.columns.id.get(0); @@ -466,23 +466,23 @@ export function createCoreTestSuite( expect(readComponent).toEqual({ x: 1, y: 2, z: 3 }); }); - it("should throw when trying to update ephemeral component", () => { + it("should throw when trying to update nonPersistent component", () => { const core = factory({ position: positionSchema, health: healthSchema, }); - const ephemeralPositionTable = core.ensureArchetype(["id", "position", "ephemeral"]); - const writeId = ephemeralPositionTable.insert({ position: { x: 1, y: 2, z: 3 }, ephemeral: true }); + const ephemeralPositionTable = core.ensureArchetype(["id", "position", "nonPersistent"]); + const writeId = ephemeralPositionTable.insert({ position: { x: 1, y: 2, z: 3 }, nonPersistent: true }); expect(() => { - core.update(writeId, { ephemeral: false as true }); + core.update(writeId, { nonPersistent: false as true }); }).toThrow(); expect(() => { - core.update(writeId, { ephemeral: true }); + core.update(writeId, { nonPersistent: true }); }).toThrow(); expect(() => { - core.update(writeId, { ephemeral: undefined }); + core.update(writeId, { nonPersistent: undefined }); }).toThrow(); }); @@ -757,44 +757,44 @@ export function createCoreTestSuite( }); }); - it("should delete ephemeral entities correctly", () => { + it("should delete nonPersistent entities correctly", () => { const core = factory({ position: positionSchema, }); - const ephemeralArchetype = core.ensureArchetype(["id", "position", "ephemeral"]); + const ephemeralArchetype = core.ensureArchetype(["id", "position", "nonPersistent"]); const ephemeralEntity = ephemeralArchetype.insert({ position: { x: 1, y: 2, z: 3 }, - ephemeral: true + nonPersistent: true }); - // Verify ephemeral entity exists and has negative id + // Verify nonPersistent entity exists and has negative id expect(ephemeralEntity).toBeLessThan(0); expect(core.locate(ephemeralEntity)).not.toBeNull(); expect(core.read(ephemeralEntity)).not.toBeNull(); - // Delete ephemeral entity + // Delete nonPersistent entity core.delete(ephemeralEntity); - // Verify ephemeral entity is deleted + // Verify nonPersistent entity is deleted expect(core.locate(ephemeralEntity)).toBeNull(); expect(core.read(ephemeralEntity)).toBeNull(); }); - it("should update ephemeral entities across archetypes correctly", () => { + it("should update nonPersistent entities across archetypes correctly", () => { const core = factory({ position: positionSchema, health: healthSchema, }); - // Create an ephemeral entity with just position - const ephemeralArchetype1 = core.ensureArchetype(["id", "position", "ephemeral"]); + // Create an nonPersistent entity with just position + const ephemeralArchetype1 = core.ensureArchetype(["id", "position", "nonPersistent"]); const ephemeralEntity = ephemeralArchetype1.insert({ position: { x: 1, y: 2, z: 3 }, - ephemeral: true + nonPersistent: true }); - // Verify ephemeral entity exists and has negative id + // Verify nonPersistent entity exists and has negative id expect(ephemeralEntity).toBeLessThan(0); expect(core.locate(ephemeralEntity)).not.toBeNull(); @@ -809,7 +809,7 @@ export function createCoreTestSuite( const data = core.read(ephemeralEntity); expect(data?.position).toEqual({ x: 1, y: 2, z: 3 }); expect(data?.health).toEqual({ current: 100, max: 100 }); - expect(data?.ephemeral).toBe(true); + expect(data?.nonPersistent).toBe(true); }); }); diff --git a/packages/data/src/ecs/store/core/create-core.ts b/packages/data/src/ecs/store/core/create-core.ts index d928445d..653971d2 100644 --- a/packages/data/src/ecs/store/core/create-core.ts +++ b/packages/data/src/ecs/store/core/create-core.ts @@ -13,14 +13,17 @@ import { ComponentSchemas } from "../../component-schemas.js"; import { OptionalComponents } from "../../optional-components.js"; import { True } from "../../../schema/true/index.js"; -// An archetype lives in the nonPersistent (negative-ID) space when its -// component set carries the `nonPersistent` marker — or the deprecated -// `ephemeral` one, which `ensureArchetype` still routes to that same space. -const isNonPersistentComponentSet = (components: Iterable): boolean => { - for (const c of components) { - if (c === "nonPersistent" || c === "ephemeral") return true; - } - return false; +/** + * One archetype's entry in a serialized snapshot. Every archetype + * contributes an entry so its `id` (a dense index into `archetypes`, stored + * by value in the persistent location table) is reproduced exactly on load. + * Only persistent archetypes carry `data`: nonPersistent archetypes back the + * negative-ID entity space, whose location table is never serialized, so + * their rows are not persistent state. + */ +type SerializedArchetype = { + readonly componentNames: readonly string[]; + readonly data?: unknown; }; export function createCore(newComponentSchemas: NC): Core]: Schema.ToType }>> { @@ -29,7 +32,6 @@ export function createCore(newComponentSchemas: NC) const componentSchemas: { readonly [K in StringKeyof]: Schema } = { id: Entity.schema, nonPersistent: True.schema, - ephemeral: True.schema, ...newComponentSchemas }; const persistentLocationTable = createEntityLocationTable(16, false); @@ -74,9 +76,6 @@ export function createCore(newComponentSchemas: NC) } if (comp === "nonPersistent") { isNonPersistent = true; - } else if (comp === "ephemeral") { - console.warn('"ephemeral" component is deprecated, use "nonPersistent"'); - isNonPersistent = true; } archetypeComponentSchemas[comp] = componentSchemas[comp]; } @@ -126,7 +125,7 @@ export function createCore(newComponentSchemas: NC) if (currentLocation === null) { throw new Error(`Entity not found ${entity}`); } - if ("nonPersistent" in components || "ephemeral" in components) { + if ("nonPersistent" in components) { throw new Error("Cannot update nonPersistent component"); } const currentArchetype = archetypes[currentLocation.archetype]; @@ -217,40 +216,26 @@ export function createCore(newComponentSchemas: NC) toData: (copy = false) => ({ componentSchemas, entityLocationTableData: persistentLocationTable.toData(copy), - // Archetypes in the nonPersistent (negative-ID) space back - // session-only resources and entities. That space is never - // serialized — `entityLocationTableData` above only covers the - // persistent location table — so their row data must not leak - // into the snapshot. - // - // They cannot simply be dropped, though: an archetype's id is its - // index in this dense array and is stored (by index) in the - // persistent location table. Omitting a nonPersistent archetype - // that precedes a persistent one would shift every later id on - // reload, leaving persistent entities pointing at the wrong - // archetype. So we keep every archetype's slot to preserve ids, - // but serialize nonPersistent ones as a data-free stub carrying - // only their component names (enough for `fromData` to recreate - // the empty archetype at the same id). - archetypesData: archetypes.map(archetype => - isNonPersistentComponentSet(archetype.components) + // Every archetype contributes an entry so its id (this array + // index, stored by value in the persistent location table) is + // reproduced on load. Only persistent archetypes carry `data`; + // nonPersistent ones back the negative-ID space, whose location + // table is never serialized, so their rows aren't persistent. + archetypesData: archetypes.map((archetype): SerializedArchetype => + archetype.components.has("nonPersistent") ? { componentNames: [...archetype.components] } - : archetype.toData(copy) + : { componentNames: [...archetype.components], data: archetype.toData(copy) } ) }), - fromData: (data: any) => { + fromData: (data: { componentSchemas: object; entityLocationTableData: unknown; archetypesData: readonly SerializedArchetype[] }) => { Object.assign(componentSchemas, data.componentSchemas); persistentLocationTable.fromData(data.entityLocationTableData); - for (let i = 0; i < data.archetypesData.length; i++) { - const entry = data.archetypesData[i]; - // Persistent entries carry `columns`; nonPersistent stubs carry - // only `componentNames` (see `toData`). - const componentNames: string[] = entry.componentNames ?? Object.keys(entry.columns); - const archetype = ensureArchetype(componentNames as any); - // Recreating the archetype above already reserves its id and - // leaves it empty; only persistent archetypes restore rows. - if (!isNonPersistentComponentSet(componentNames)) { - archetype.fromData(entry); + for (const { componentNames, data: archetypeData } of data.archetypesData) { + // Recreating the archetype reserves its id and leaves it + // empty; only persistent entries carry data to restore. + const archetype = ensureArchetype(componentNames as StringKeyof[]); + if (archetypeData !== undefined) { + archetype.fromData(archetypeData); } } } @@ -261,7 +246,6 @@ export function createCore(newComponentSchemas: NC) type TestType = ReturnType> type CheckTestType = Assert>> @@ -269,7 +253,6 @@ type TestTypeComponents = TestType["componentSchemas"] type CheckComponents = Assert>; \ No newline at end of file diff --git a/packages/data/src/ecs/store/public/create-store.test.ts b/packages/data/src/ecs/store/public/create-store.test.ts index a9fe148e..4360fe10 100644 --- a/packages/data/src/ecs/store/public/create-store.test.ts +++ b/packages/data/src/ecs/store/public/create-store.test.ts @@ -1,6 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, it, expect } from "vitest"; import { createStore } from "./create-store.js"; +import { serialize, deserialize } from "../../../functions/serialization/serialize.js"; import { createCoreTestSuite, positionSchema, healthSchema, nameSchema } from "../core/create-core.test.js"; import { Schema } from "../../../schema/index.js"; import { F32 } from "../../../math/f32/index.js"; @@ -638,17 +639,17 @@ describe("createStore", () => { // The nonPersistent resource's value must never appear in the // snapshot: its entity lives in the negative-ID space, which // entityLocationTableData never covers either. Its archetype slot - // is still present (as a data-free stub, to keep archetype ids - // stable) but carries no `columns`. + // is still present (to keep archetype ids stable) but carries no + // `data` — only its component names. const scoreEntry = serializedData.archetypesData.find( - (a: any) => a.componentNames?.includes("score") + (a: any) => a.componentNames.includes("score") ); expect(scoreEntry).toBeDefined(); - expect(scoreEntry.columns).toBeUndefined(); - const anyColumnsHoldScore = serializedData.archetypesData.some( - (a: any) => a.columns && "score" in a.columns + expect(scoreEntry.data).toBeUndefined(); + const anyDataHoldsScore = serializedData.archetypesData.some( + (a: any) => a.data && "score" in a.data.columns ); - expect(anyColumnsHoldScore).toBe(false); + expect(anyDataHoldsScore).toBe(false); const newStore = createStore(schema as any); newStore.fromData(serializedData); @@ -659,6 +660,34 @@ describe("createStore", () => { expect((newStore.resources as any).score).toBe(0); }); + it("round-trips through the encoded (serialize/deserialize) form without leaking or corrupting a nonPersistent resource", () => { + const schema = { + components: {}, + resources: { + score: { default: 0 as number, nonPersistent: true }, + persistentScore: { default: 0 as number }, + }, + archetypes: {}, + } as const; + + const store = createStore(schema as any); + (store.resources as any).score = 999; + (store.resources as any).persistentScore = 123; + + const encoded = serialize(store.toData(true)); + // The nonPersistent value must not survive into the encoded bytes. + expect(encoded.json).not.toContain("999"); + expect(encoded.json).toContain("123"); + + const newStore = createStore(schema as any); + newStore.fromData(deserialize(encoded)); + + // Persistent resource restored; nonPersistent resource back at its + // default (never undefined, never the leaked 999). + expect((newStore.resources as any).persistentScore).toBe(123); + expect((newStore.resources as any).score).toBe(0); + }); + it("preserves archetype ids across serialization when a nonPersistent archetype precedes a persistent one", () => { const selectionSchema = { type: "boolean", default: false } as const satisfies Schema; const positionScalarSchema = { type: "number", default: 0 } as const satisfies Schema; diff --git a/packages/data/src/ecs/store/public/create-store.ts b/packages/data/src/ecs/store/public/create-store.ts index a7130985..1c347156 100644 --- a/packages/data/src/ecs/store/public/create-store.ts +++ b/packages/data/src/ecs/store/public/create-store.ts @@ -168,7 +168,7 @@ export function createStore< // The resource component we added above will contain the resource value const ensureResourceInitialized = (name: string, resourceSchema: Schema & { default: unknown }) => { const resourceId = name as StringKeyof; - const isNonPersistent = resourceSchema.nonPersistent ?? resourceSchema.ephemeral; + const isNonPersistent = resourceSchema.nonPersistent; const componentNames: StringKeyof[] = isNonPersistent ? ["id" as StringKeyof, resourceId, "nonPersistent" as StringKeyof] : ["id" as StringKeyof, resourceId]; diff --git a/packages/data/src/functions/serialization/serialization.test.ts b/packages/data/src/functions/serialization/serialization.test.ts index d1e2a71d..f979c067 100644 --- a/packages/data/src/functions/serialization/serialization.test.ts +++ b/packages/data/src/functions/serialization/serialization.test.ts @@ -68,11 +68,11 @@ describe('serialize/deserialize', () => { expect(equals(roundTrip, table)).toBe(true); }); - it('should NOT serialize data for ephemeral number buffers and reset to defaults on deserialize', () => { + it('should NOT serialize data for nonPersistent number buffers and reset to defaults on deserialize', () => { const numberBuffer = createTypedBuffer({ type: "number", precision: 1, - ephemeral: true, + nonPersistent: true, default: 42 }, 3); @@ -99,11 +99,11 @@ describe('serialize/deserialize', () => { expect(roundTrip.numberBuffer.get(2)).not.toBe(300); }); - it('should NOT serialize data for ephemeral array buffers and reset to defaults on deserialize', () => { + it('should NOT serialize data for nonPersistent array buffers and reset to defaults on deserialize', () => { const arrayBuffer = createTypedBuffer({ type: "array", items: { type: "string" }, - ephemeral: true, + nonPersistent: true, default: ["defaultValue1", "defaultValue2"] }, 2); @@ -127,10 +127,10 @@ describe('serialize/deserialize', () => { expect(roundTrip.arrayBuffer.get(1)).not.toEqual(["customValue3", "customValue4"]); }); - it('should NOT serialize data for ephemeral struct buffers and reset to defaults on deserialize', () => { + it('should NOT serialize data for nonPersistent struct buffers and reset to defaults on deserialize', () => { const structBuffer = createStructBuffer({ type: "object", - ephemeral: true, + nonPersistent: true, default: { x: 10, y: 20 }, properties: { x: { type: "number", precision: 1 }, diff --git a/packages/data/src/schema/dynamic/enumerate-patches.test.ts b/packages/data/src/schema/dynamic/enumerate-patches.test.ts index 04832618..40210bb6 100644 --- a/packages/data/src/schema/dynamic/enumerate-patches.test.ts +++ b/packages/data/src/schema/dynamic/enumerate-patches.test.ts @@ -18,7 +18,7 @@ export const PersonSchema = { { path: "$.properties.email", value: { - ephemeral: true, + nonPersistent: true, }, match: { exclusiveMaximum: 18, @@ -48,7 +48,7 @@ export const PersonSchemaWithOneOf = { { path: "$.properties.email", value: { - ephemeral: false, + nonPersistent: false, } } ] @@ -98,7 +98,7 @@ export const PersonSchemaRootConditions = { { path: "$.properties.email", value: { - ephemeral: true, + nonPersistent: true, }, match: { properties: { @@ -111,7 +111,7 @@ export const PersonSchemaRootConditions = { { path: "$.properties.email", value: { - ephemeral: false, + nonPersistent: false, }, match: { properties: { @@ -143,7 +143,7 @@ describe('enumeratePatches', () => { age: 10, })]; expect(patches).toEqual([ - { path: ["$", "properties", "email"], fragment: { ephemeral: true } }, + { path: ["$", "properties", "email"], fragment: { nonPersistent: true } }, ]); }); it('should match on oneOf cases for human', () => { @@ -152,7 +152,7 @@ describe('enumeratePatches', () => { species: "human", })]; expect(patches).toEqual([ - { path: ["$", "properties", "email"], fragment: { ephemeral: false } }, + { path: ["$", "properties", "email"], fragment: { nonPersistent: false } }, ]); }); it('should match on oneOf cases for robot', () => { @@ -170,7 +170,7 @@ describe('enumeratePatches', () => { species: "human", })]; expect(patches).toEqual([ - { path: ["$", "properties", "email"], fragment: { ephemeral: false } }, + { path: ["$", "properties", "email"], fragment: { nonPersistent: false } }, ]); }); it('should match on simple root conditions', () => { @@ -179,7 +179,7 @@ describe('enumeratePatches', () => { species: "human", })]; expect(patches).toEqual([ - { path: ["$", "properties", "email"], fragment: { ephemeral: true } }, + { path: ["$", "properties", "email"], fragment: { nonPersistent: true } }, ]); }); }); diff --git a/packages/data/src/schema/dynamic/get-dynamic-schema.test.ts b/packages/data/src/schema/dynamic/get-dynamic-schema.test.ts index 78ca5b67..88f79445 100644 --- a/packages/data/src/schema/dynamic/get-dynamic-schema.test.ts +++ b/packages/data/src/schema/dynamic/get-dynamic-schema.test.ts @@ -25,7 +25,7 @@ describe('getDynamicSchema', () => { expect(dynamicSchema.properties?.email).toEqual({ type: "string", - ephemeral: true, + nonPersistent: true, }); }); @@ -37,7 +37,7 @@ describe('getDynamicSchema', () => { expect(dynamicSchema.properties?.email).toEqual({ type: "string", - ephemeral: false, + nonPersistent: false, }); }); @@ -62,7 +62,7 @@ describe('getDynamicSchema', () => { expect(dynamicSchema.properties?.email).toEqual({ type: "string", - ephemeral: false, + nonPersistent: false, }); }); @@ -74,7 +74,7 @@ describe('getDynamicSchema', () => { expect(dynamicSchema.properties?.email).toEqual({ type: "string", - ephemeral: true, + nonPersistent: true, }); }); diff --git a/packages/data/src/schema/schema.ts b/packages/data/src/schema/schema.ts index d263587e..fd89887f 100644 --- a/packages/data/src/schema/schema.ts +++ b/packages/data/src/schema/schema.ts @@ -26,8 +26,6 @@ export interface Schema { description?: string; conditionals?: readonly Conditional[]; nonPersistent?: boolean; - /** @deprecated Use `nonPersistent` instead */ - ephemeral?: boolean; mutable?: boolean; // defaults to false default?: any; precision?: 1 | 2; diff --git a/packages/data/src/typed-buffer/create-boolean-buffer.test.ts b/packages/data/src/typed-buffer/create-boolean-buffer.test.ts index 451876ed..a3f07dfc 100644 --- a/packages/data/src/typed-buffer/create-boolean-buffer.test.ts +++ b/packages/data/src/typed-buffer/create-boolean-buffer.test.ts @@ -169,16 +169,16 @@ describe("createBooleanBuffer", () => { expect(roundTrip.buf.get(3)).toBe(true); }); - it("should reset ephemeral boolean buffers on deserialize", () => { - const ephemeral = createTypedBuffer({ ...Boolean.schema, ephemeral: true }, 10); - ephemeral.set(0, true); - ephemeral.set(9, true); + it("should reset nonPersistent boolean buffers on deserialize", () => { + const nonPersistent = createTypedBuffer({ ...Boolean.schema, nonPersistent: true }, 10); + nonPersistent.set(0, true); + nonPersistent.set(9, true); - const payload = serialize({ ephemeral }); - const roundTrip = deserialize<{ ephemeral: TypedBuffer }>(payload); + const payload = serialize({ nonPersistent }); + const roundTrip = deserialize<{ nonPersistent: TypedBuffer }>(payload); - expect(roundTrip.ephemeral.capacity).toBe(10); - expect(roundTrip.ephemeral.get(0)).toBe(false); - expect(roundTrip.ephemeral.get(9)).toBe(false); + expect(roundTrip.nonPersistent.capacity).toBe(10); + expect(roundTrip.nonPersistent.get(0)).toBe(false); + expect(roundTrip.nonPersistent.get(9)).toBe(false); }); }); diff --git a/packages/data/src/typed-buffer/register-typed-buffer-codecs.ts b/packages/data/src/typed-buffer/register-typed-buffer-codecs.ts index 7e35cbe4..a05cf064 100644 --- a/packages/data/src/typed-buffer/register-typed-buffer-codecs.ts +++ b/packages/data/src/typed-buffer/register-typed-buffer-codecs.ts @@ -20,7 +20,7 @@ export function registerTypedBufferCodecs() { serialize: (data: TypedBuffer) => { const { type, schema, capacity } = data; try { - if (type === "const" || (schema.nonPersistent ?? schema.ephemeral)) { + if (type === "const" || schema.nonPersistent) { return { json: { type, schema, capacity } }; } else if (type === "array") { @@ -54,7 +54,7 @@ export function registerTypedBufferCodecs() { : isBoolean ? createBooleanBuffer(schema, capacity) : createArrayBuffer(schema, capacity); - if ((schema.nonPersistent ?? schema.ephemeral)) { + if (schema.nonPersistent) { if (schema.default !== undefined && schema.default !== 0) { for (let i = 0; i < capacity; i++) { buffer.set(i, schema.default); @@ -70,7 +70,7 @@ export function registerTypedBufferCodecs() { } else if (type === "boolean") { const buffer = createBooleanBuffer(schema, capacity); - if (!(schema.nonPersistent ?? schema.ephemeral)) { + if (!schema.nonPersistent) { if (binary[0]) { copyViewBytes(binary[0], buffer.getTypedArray()); } else if (array) { @@ -83,7 +83,7 @@ export function registerTypedBufferCodecs() { } else if (type === "enum") { const buffer = createEnumBuffer(schema, capacity); - if (!(schema.nonPersistent ?? schema.ephemeral)) { + if (!schema.nonPersistent) { if (binary[0]) { copyViewBytes(binary[0], buffer.getTypedArray()); } else if (array) { @@ -96,7 +96,7 @@ export function registerTypedBufferCodecs() { } else if (type === "number" || type === "struct") { const buffer = type === "number" ? createNumberBuffer(schema, capacity) : createStructBuffer(schema, capacity); - if ((schema.nonPersistent ?? schema.ephemeral)) { + if (schema.nonPersistent) { if (schema.default !== undefined && schema.default !== 0) { for (let i = 0; i < capacity; i++) { buffer.set(i, schema.default); From 51784db0bf3b02a5225fb1e72046e7cd996ff581 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 10 Jul 2026 11:51:54 -0700 Subject: [PATCH 4/6] feat(data): version the ECS snapshot format and reject incompatible loads Adds ECS_SNAPSHOT_VERSION, stamped into every toData() snapshot and checked by fromData(), which now throws on a version mismatch instead of silently mis-reconstructing. This makes the nonPersistent/ephemeral format break explicit: legacy snapshots (no version field) and any future-version snapshot are rejected with a clear error. The version guard covers the whole db/store/core fromData chain; the one internal caller that builds a payload directly (data-persistence's finalizeEntityLocationTable) stamps the current version. The incremental journal format is otherwise unaffected. Documents the break in the README. Co-Authored-By: Claude Opus 4.8 --- README.md | 24 +++++++++++++++ .../create-incremental-persistence-service.ts | 3 +- .../data/src/ecs/store/core/create-core.ts | 30 +++++++++++++++++-- packages/data/src/ecs/store/index.ts | 1 + .../src/ecs/store/public/create-store.test.ts | 16 ++++++++++ 5 files changed, 71 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 106ad83f..3975a1ba 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,30 @@ Adobe Data Oriented Programming Library Until we reach 1.0.0, minor version changes may be API breaking. +### 0.9.77 + +**`ephemeral` fully removed — use `nonPersistent`** + +The `ephemeral` name (deprecated in 0.9.70) is gone entirely: `Schema.ephemeral`, the `"ephemeral"` component alias, and `Entity.isEphemeral`. Use `nonPersistent` / `Entity.isNonPersistent`. + +```ts +// before +{ ..., ephemeral: true } +ensureArchetype(["id", "cursor", "ephemeral"]) +// after +{ ..., nonPersistent: true } +ensureArchetype(["id", "cursor", "nonPersistent"]) +``` + +**Store snapshot format changed and is now versioned** + +`db.toData()` / `store.toData()` output is now stamped with `version: ECS_SNAPSHOT_VERSION` (currently `1`), and `fromData` throws on any snapshot whose version does not match. Two consequences: + +- nonPersistent resources/entities are correctly excluded from snapshots (previously a nonPersistent resource could deserialize to `undefined`). +- Snapshots produced by earlier versions (which carried no `version` field) can no longer be loaded — they are rejected with a clear error instead of reconstructing incorrectly. Discard and regenerate persisted snapshots. + +This only affects the `toData`/`fromData` snapshot path (e.g. `createStoragePersistenceService`). The `@adobe/data-persistence` incremental journal format is unaffected. + ### 0.9.70 **`TransactionResult.ephemeral` → `TransactionResult.persistent` (inverted)** diff --git a/packages/data-persistence/src/service/create-incremental-persistence-service.ts b/packages/data-persistence/src/service/create-incremental-persistence-service.ts index c6d1f3fd..5b33b38f 100644 --- a/packages/data-persistence/src/service/create-incremental-persistence-service.ts +++ b/packages/data-persistence/src/service/create-incremental-persistence-service.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Archetype } from "@adobe/data/ecs"; +import { ECS_SNAPSHOT_VERSION, type Archetype } from "@adobe/data/ecs"; import { createColumnEncoder } from "../encoder/create-column-encoder.js"; import { decodeJournalSnapshot, @@ -980,6 +980,7 @@ export const createIncrementalPersistenceService = async ( } store.fromData({ + version: ECS_SNAPSHOT_VERSION, componentSchemas: {}, entityLocationTableData: { entities, freeListHead, nextIndex, capacity }, archetypesData: [], diff --git a/packages/data/src/ecs/store/core/create-core.ts b/packages/data/src/ecs/store/core/create-core.ts index 653971d2..af7e12dc 100644 --- a/packages/data/src/ecs/store/core/create-core.ts +++ b/packages/data/src/ecs/store/core/create-core.ts @@ -13,6 +13,18 @@ import { ComponentSchemas } from "../../component-schemas.js"; import { OptionalComponents } from "../../optional-components.js"; import { True } from "../../../schema/true/index.js"; +/** + * Serialization format version stamped into every `toData` snapshot and + * checked by `fromData`. A mismatch is thrown rather than silently + * mis-reconstructed, so an incompatible snapshot fails loudly at load. + * + * Version 1 is the first *versioned* format. Snapshots produced before this + * field existed carry no `version` and are therefore rejected — they used an + * incompatible archetype-entry shape. Bump this whenever the snapshot shape + * changes in a way older readers cannot load. + */ +export const ECS_SNAPSHOT_VERSION = 1; + /** * One archetype's entry in a serialized snapshot. Every archetype * contributes an entry so its `id` (a dense index into `archetypes`, stored @@ -26,6 +38,13 @@ type SerializedArchetype = { readonly data?: unknown; }; +type SerializedCore = { + readonly version: number; + readonly componentSchemas: object; + readonly entityLocationTableData: unknown; + readonly archetypesData: readonly SerializedArchetype[]; +}; + export function createCore(newComponentSchemas: NC): Core]: Schema.ToType }>> { type C = RequiredComponents & { [K in StringKeyof]: Schema.ToType }; @@ -213,7 +232,8 @@ export function createCore(newComponentSchemas: NC) update: updateEntity, compact, reset: resetCore, - toData: (copy = false) => ({ + toData: (copy = false): SerializedCore => ({ + version: ECS_SNAPSHOT_VERSION, componentSchemas, entityLocationTableData: persistentLocationTable.toData(copy), // Every archetype contributes an entry so its id (this array @@ -227,7 +247,13 @@ export function createCore(newComponentSchemas: NC) : { componentNames: [...archetype.components], data: archetype.toData(copy) } ) }), - fromData: (data: { componentSchemas: object; entityLocationTableData: unknown; archetypesData: readonly SerializedArchetype[] }) => { + fromData: (data: SerializedCore) => { + if (data.version !== ECS_SNAPSHOT_VERSION) { + throw new Error( + `Incompatible ECS snapshot: expected version ${ECS_SNAPSHOT_VERSION}, got ${String(data.version)}. ` + + `The serialization format has changed; this snapshot cannot be loaded.`, + ); + } Object.assign(componentSchemas, data.componentSchemas); persistentLocationTable.fromData(data.entityLocationTableData); for (const { componentNames, data: archetypeData } of data.archetypesData) { diff --git a/packages/data/src/ecs/store/index.ts b/packages/data/src/ecs/store/index.ts index df807ccc..194192a0 100644 --- a/packages/data/src/ecs/store/index.ts +++ b/packages/data/src/ecs/store/index.ts @@ -3,5 +3,6 @@ export * from "./store.js"; export * from "./archetype-components.js"; export type { ArchetypeSchema, ArchetypeRowOf, ArchetypeHandleOf } from "./archetype-row.js"; export type { EntityReadValues, EntityUpdateValues, ArchetypeQueryOptions } from "./core/core.js"; +export { ECS_SNAPSHOT_VERSION } from "./core/create-core.js"; export type { EntitySelectOptions } from "./entity-select-options.js"; export * from "./action-functions.js"; diff --git a/packages/data/src/ecs/store/public/create-store.test.ts b/packages/data/src/ecs/store/public/create-store.test.ts index 4360fe10..70c3bb4c 100644 --- a/packages/data/src/ecs/store/public/create-store.test.ts +++ b/packages/data/src/ecs/store/public/create-store.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest"; import { createStore } from "./create-store.js"; import { serialize, deserialize } from "../../../functions/serialization/serialize.js"; +import { ECS_SNAPSHOT_VERSION } from "../core/create-core.js"; import { createCoreTestSuite, positionSchema, healthSchema, nameSchema } from "../core/create-core.test.js"; import { Schema } from "../../../schema/index.js"; import { F32 } from "../../../math/f32/index.js"; @@ -719,6 +720,21 @@ describe("createStore", () => { expect(newStore.read(positionEntity)).toEqual({ id: positionEntity, position: 42 }); }); + it("stamps a version and rejects snapshots of an incompatible (or legacy, unversioned) format", () => { + const store = createStore({ components: { position: positionSchema }, resources: {}, archetypes: {} }); + const snapshot: any = store.toData(true); + expect(snapshot.version).toBe(ECS_SNAPSHOT_VERSION); + + const target = createStore({ components: { position: positionSchema }, resources: {}, archetypes: {} }); + // A future/incompatible version is rejected. + expect(() => target.fromData({ ...snapshot, version: ECS_SNAPSHOT_VERSION + 1 })).toThrow(/version/i); + // A legacy snapshot predating the version field is rejected. + const { version: _omit, ...legacy } = snapshot; + expect(() => target.fromData(legacy)).toThrow(/version/i); + // The correctly-versioned snapshot still loads. + expect(() => target.fromData(snapshot)).not.toThrow(); + }); + it("toData(true) detaches the snapshot from later store mutation; toData() references live buffers", () => { const makePopulatedStore = () => { const store = createStore({ components: { health: healthSchema }, resources: {}, archetypes: {} }); From 67425e75d085adf31d6dc4116320ff2194b90c28 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Fri, 10 Jul 2026 11:56:59 -0700 Subject: [PATCH 5/6] fix(data): warn and skip on incompatible snapshot instead of throwing fromData now logs a console.warn and returns without loading when the snapshot version does not match (legacy/unversioned or future), keeping the freshly-constructed state. Callers such as createStoragePersistenceService treat an unloadable snapshot as "no saved data" rather than surfacing an error. Updates the regression test and README accordingly. Co-Authored-By: Claude Opus 4.8 --- README.md | 4 +-- .../data/src/ecs/store/core/create-core.ts | 10 ++++-- .../src/ecs/store/public/create-store.test.ts | 35 +++++++++++++------ 3 files changed, 34 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 3975a1ba..c9fb0120 100644 --- a/README.md +++ b/README.md @@ -30,10 +30,10 @@ ensureArchetype(["id", "cursor", "nonPersistent"]) **Store snapshot format changed and is now versioned** -`db.toData()` / `store.toData()` output is now stamped with `version: ECS_SNAPSHOT_VERSION` (currently `1`), and `fromData` throws on any snapshot whose version does not match. Two consequences: +`db.toData()` / `store.toData()` output is now stamped with `version: ECS_SNAPSHOT_VERSION` (currently `1`), and `fromData` ignores any snapshot whose version does not match. Two consequences: - nonPersistent resources/entities are correctly excluded from snapshots (previously a nonPersistent resource could deserialize to `undefined`). -- Snapshots produced by earlier versions (which carried no `version` field) can no longer be loaded — they are rejected with a clear error instead of reconstructing incorrectly. Discard and regenerate persisted snapshots. +- Snapshots produced by earlier versions (which carried no `version` field) are no longer loaded — `fromData` logs a `console.warn` and silently keeps the current (freshly-constructed) state instead of reconstructing incorrectly. Discard and regenerate persisted snapshots. This only affects the `toData`/`fromData` snapshot path (e.g. `createStoragePersistenceService`). The `@adobe/data-persistence` incremental journal format is unaffected. diff --git a/packages/data/src/ecs/store/core/create-core.ts b/packages/data/src/ecs/store/core/create-core.ts index af7e12dc..ff3d2c35 100644 --- a/packages/data/src/ecs/store/core/create-core.ts +++ b/packages/data/src/ecs/store/core/create-core.ts @@ -249,10 +249,14 @@ export function createCore(newComponentSchemas: NC) }), fromData: (data: SerializedCore) => { if (data.version !== ECS_SNAPSHOT_VERSION) { - throw new Error( - `Incompatible ECS snapshot: expected version ${ECS_SNAPSHOT_VERSION}, got ${String(data.version)}. ` + - `The serialization format has changed; this snapshot cannot be loaded.`, + // Incompatible (or legacy, unversioned) snapshot. Skip the load + // rather than throw: callers treat this as "no saved data" and + // keep the freshly-constructed defaults. + console.warn( + `Ignoring incompatible ECS snapshot: expected version ${ECS_SNAPSHOT_VERSION}, got ${String(data.version)}. ` + + `The serialization format has changed; keeping current state.`, ); + return; } Object.assign(componentSchemas, data.componentSchemas); persistentLocationTable.fromData(data.entityLocationTableData); diff --git a/packages/data/src/ecs/store/public/create-store.test.ts b/packages/data/src/ecs/store/public/create-store.test.ts index 70c3bb4c..6a70045b 100644 --- a/packages/data/src/ecs/store/public/create-store.test.ts +++ b/packages/data/src/ecs/store/public/create-store.test.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import { createStore } from "./create-store.js"; import { serialize, deserialize } from "../../../functions/serialization/serialize.js"; import { ECS_SNAPSHOT_VERSION } from "../core/create-core.js"; @@ -720,19 +720,34 @@ describe("createStore", () => { expect(newStore.read(positionEntity)).toEqual({ id: positionEntity, position: 42 }); }); - it("stamps a version and rejects snapshots of an incompatible (or legacy, unversioned) format", () => { + it("stamps a version and skips (warns, does not throw) snapshots of an incompatible or legacy format", () => { const store = createStore({ components: { position: positionSchema }, resources: {}, archetypes: {} }); + const entity = store.ensureArchetype(["id", "position"]).insert({ position: { x: 1, y: 2, z: 3 } }); const snapshot: any = store.toData(true); expect(snapshot.version).toBe(ECS_SNAPSHOT_VERSION); - const target = createStore({ components: { position: positionSchema }, resources: {}, archetypes: {} }); - // A future/incompatible version is rejected. - expect(() => target.fromData({ ...snapshot, version: ECS_SNAPSHOT_VERSION + 1 })).toThrow(/version/i); - // A legacy snapshot predating the version field is rejected. - const { version: _omit, ...legacy } = snapshot; - expect(() => target.fromData(legacy)).toThrow(/version/i); - // The correctly-versioned snapshot still loads. - expect(() => target.fromData(snapshot)).not.toThrow(); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + // A future/incompatible version is skipped with a warning — no + // throw, and nothing from the snapshot is loaded. + const future = createStore({ components: { position: positionSchema }, resources: {}, archetypes: {} }); + expect(() => future.fromData({ ...snapshot, version: ECS_SNAPSHOT_VERSION + 1 })).not.toThrow(); + expect(future.select(["position"])).toHaveLength(0); + expect(warn).toHaveBeenCalledWith(expect.stringMatching(/version/i)); + + // A legacy snapshot predating the version field is likewise skipped. + const { version: _omit, ...legacy } = snapshot; + const stale = createStore({ components: { position: positionSchema }, resources: {}, archetypes: {} }); + expect(() => stale.fromData(legacy)).not.toThrow(); + expect(stale.select(["position"])).toHaveLength(0); + + // The correctly-versioned snapshot loads normally. + const target = createStore({ components: { position: positionSchema }, resources: {}, archetypes: {} }); + target.fromData(snapshot); + expect(target.read(entity)).toEqual({ id: entity, position: { x: 1, y: 2, z: 3 } }); + } finally { + warn.mockRestore(); + } }); it("toData(true) detaches the snapshot from later store mutation; toData() references live buffers", () => { From affb4707c009f3112ce5cb7e08d982b6588a2e73 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Mon, 13 Jul 2026 11:42:34 -0700 Subject: [PATCH 6/6] fix(data): undo of a column-ADD removes the column instead of writing the delete sentinel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Undoing an update that added a column (pre-image undefined) recorded the undo op's value as the internal DELETE sentinel. On replay the sentinel string was written into the column verbatim instead of removing it — the mirror of the redo-of-column-removal bug fixed in #140 (FFP-98612), and blocking FFP-98613. Root cause: recorded ops carry DELETE to mean "remove this column", but the apply path handed op values straight to store.update, which wrote the string. Fix resolves DELETE -> undefined at the single point recorded ops re-enter a store (applyOperations), on a *copy* — the core store deletes undefined keys from the object it is given, so mutating the shared op would empty it and make a second undo/redo a no-op. Rollback now reuses applyOperations (removing the duplicate applyWriteOperations), so a failed transaction that added a column also rolls back to the no-column state. Tests: undo-of-column-ADD round-trips to undefined (incl. undo -> redo -> undo), and rollback of a failed column-ADD removes the column. Co-Authored-By: Claude Opus 4.8 --- .../transactional-store/apply-operations.ts | 27 ++++++++++++-- .../create-transactional-store.test.ts | 27 ++++++++++++++ .../create-transactional-store.ts | 36 +++---------------- .../create-undo-redo-service.test.ts | 26 ++++++++++++++ 4 files changed, 81 insertions(+), 35 deletions(-) diff --git a/packages/data/src/ecs/database/transactional-store/apply-operations.ts b/packages/data/src/ecs/database/transactional-store/apply-operations.ts index 20f03f8d..8d8905de 100644 --- a/packages/data/src/ecs/database/transactional-store/apply-operations.ts +++ b/packages/data/src/ecs/database/transactional-store/apply-operations.ts @@ -3,7 +3,17 @@ import { Store } from "../../store/index.js"; import { TransactionWriteOperation } from "./transactional-store.js"; import { StringKeyof } from "../../../types/types.js"; -// Helper function to apply write operations for rollback +/** + * Sentinel recorded in an undo/redo op's update values to mean "this column + * had no pre-image — remove it" (a column that a transaction had *added*). + * It is resolved back to `undefined` when the op is applied, so the column is + * removed rather than written literally. Lives here — the single point where + * recorded ops re-enter a store — so every replay path resolves it uniformly. + */ +export const DELETE: unknown = "_$_DELETE_$_"; + +// Applies recorded write operations to a store (undo/redo replay, rollback, +// concurrency roll-forward, reconciling replay). export const applyOperations = ( store: Store, operations: TransactionWriteOperation[] @@ -16,9 +26,20 @@ export const applyOperations = ( archetype.insert(operation.values as never); break; } - case "update": - store.update(operation.entity, operation.values); + case "update": { + // Apply to a fresh object, never the recorded op: `store.update` + // deletes `undefined` keys from the object it is handed, and a + // recorded op may be replayed more than once (undo → redo → + // undo). Resolve the DELETE sentinel to `undefined` so an + // added-then-undone column is removed, not written literally. + const values: Record = {}; + for (const key in operation.values) { + const value = (operation.values as Record)[key]; + values[key] = value === DELETE ? undefined : value; + } + store.update(operation.entity, values); break; + } case "delete": store.delete(operation.entity); break; diff --git a/packages/data/src/ecs/database/transactional-store/create-transactional-store.test.ts b/packages/data/src/ecs/database/transactional-store/create-transactional-store.test.ts index 7bfa1f4e..ffd0d397 100644 --- a/packages/data/src/ecs/database/transactional-store/create-transactional-store.test.ts +++ b/packages/data/src/ecs/database/transactional-store/create-transactional-store.test.ts @@ -113,6 +113,33 @@ describe("createTransactionalStore", () => { expect(finalData?.position).toEqual({ x: 1, y: 2, z: 3 }); }); + it("should rollback a column-ADD by removing the column, not writing the delete sentinel", () => { + const baseStore = Store.create({ + components: { position: positionSchema, health: healthSchema }, + resources: { time: { default: { delta: 0.016, elapsed: 0 } } }, + archetypes: {} + }); + const store = createTransactionalStore(baseStore); + + let entity: number = -1; + store.execute((t) => { + entity = t.ensureArchetype(["id", "position"]).insert({ position: { x: 1, y: 2, z: 3 } }); + }); + + // A transaction that ADDs the `health` column then throws must roll back + // to the no-column state — never leave the internal delete sentinel. + expect(() => { + store.execute((t) => { + t.update(entity, { health: { current: 100, max: 100 } }); + throw new Error("Transaction failed"); + }); + }).toThrow("Transaction failed"); + + const finalData = store.read(entity); + expect(finalData?.health).toBeUndefined(); + expect(finalData).toEqual({ id: entity, position: { x: 1, y: 2, z: 3 } }); + }); + it("should combine multiple updates to the same entity", () => { const baseStore = Store.create({ components: { position: positionSchema, health: healthSchema }, diff --git a/packages/data/src/ecs/database/transactional-store/create-transactional-store.ts b/packages/data/src/ecs/database/transactional-store/create-transactional-store.ts index 0cec19b3..0b15ccef 100644 --- a/packages/data/src/ecs/database/transactional-store/create-transactional-store.ts +++ b/packages/data/src/ecs/database/transactional-store/create-transactional-store.ts @@ -10,9 +10,7 @@ import { Components } from "../../store/components.js"; import { ArchetypeComponents } from "../../store/archetype-components.js"; import { patchEntityValues } from "./patch-entity-values.js"; import { coalesceWriteOperations } from "./coalesce-actions.js"; - -// Sentinel value used to indicate a component should be deleted -const DELETE: unknown = "_$_DELETE_$_"; +import { applyOperations, DELETE } from "./apply-operations.js"; interface Transaction< C extends Components = never, @@ -88,10 +86,7 @@ export function createTransactionalStore< const replacedValues: any = {}; for (const name in values) { - let newValue = (values as any)[name]; - if (newValue === DELETE) { - newValue = undefined; - } + const newValue = (values as any)[name]; let oldValue = (oldValues as any)[name]; if (newValue !== oldValue) { if (oldValue === undefined) { @@ -231,7 +226,7 @@ export function createTransactionalStore< return result; } catch (error) { // Rollback on error by applying undo operations in reverse - applyWriteOperations(store, undoOperationsInReverseOrder.reverse()); + applyOperations(store, undoOperationsInReverseOrder.reverse()); throw error; } finally { transactionStore.userId = undefined; @@ -317,27 +312,4 @@ function addUpdateOperationsMaybeCombineLast( values: replacedValues, }); } -} - -// Helper function to apply write operations for rollback -function applyWriteOperations>>( - store: Store, - operations: TransactionWriteOperation[] -): void { - for (const operation of operations) { - switch (operation.type) { - case "insert": { - const componentNames = ["id", ...Object.keys(operation.values)] as StringKeyof[]; - const archetype = store.ensureArchetype(componentNames); - archetype.insert(operation.values as any); - break; - } - case "update": - store.update(operation.entity, operation.values); - break; - case "delete": - store.delete(operation.entity); - break; - } - } -} \ No newline at end of file +} \ No newline at end of file diff --git a/packages/data/src/ecs/undo-redo-service/create-undo-redo-service.test.ts b/packages/data/src/ecs/undo-redo-service/create-undo-redo-service.test.ts index 6992977a..98409fa8 100644 --- a/packages/data/src/ecs/undo-redo-service/create-undo-redo-service.test.ts +++ b/packages/data/src/ecs/undo-redo-service/create-undo-redo-service.test.ts @@ -354,4 +354,30 @@ describe("createUndoRedoService", () => { expect(await toPromise(undoRedo.undoStackIndex)).toBe(3); }); + + it("undo of a column-ADD removes the column, restoring undefined (not the delete sentinel)", () => { + // tx1: entity exists with `position` only — no `name` column. + const entity = database.transactions.createPositionEntity({ position: { x: 1, y: 2, z: 3 } }); + + // tx2: ADD the `name` column via update (pre-image is undefined). + database.transactions.updateEntity({ entity, values: { name: "hello" } }); + expect(database.read(entity)?.name).toBe("hello"); + + // Undo tx2 must REMOVE the column (row migrates back to the position-only + // archetype), leaving name === undefined — never the internal + // delete-marker string. Mirror of the redo-of-column-removal fix (#140). + undoRedo.undo(); + + const after = database.read(entity); + expect(after?.name).toBeUndefined(); + expect(after).toEqual({ id: entity, position: { x: 1, y: 2, z: 3 } }); + + // Redo re-adds the column, and a second undo must remove it again — + // the recorded op must not be consumed/emptied by the first replay. + undoRedo.redo(); + expect(database.read(entity)?.name).toBe("hello"); + undoRedo.undo(); + expect(database.read(entity)?.name).toBeUndefined(); + expect(database.read(entity)).toEqual({ id: entity, position: { x: 1, y: 2, z: 3 } }); + }); }); \ No newline at end of file