From bcbede7e172eee04a84bc143fe2cab0c0d9c05e7 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Thu, 22 Oct 2026 17:09:58 -0700 Subject: [PATCH] fix(ecs): collision-free undo of added columns; redo insert records stored values Undo of an added column records undefined instead of the "_$_DELETE_$_" string sentinel, so a user value equal to it is no longer dropped. Redo inserts and changed entities carry the values that landed (defaults, factories) so replay restores e.g. the original guid. Refs FFP-122782. Co-Authored-By: Claude Sonnet 5.5 --- package.json | 2 +- packages/data-ai/.claude-plugin/plugin.json | 2 +- packages/data-ai/package.json | 2 +- packages/data-gpu/package.json | 2 +- packages/data-lit/package.json | 2 +- packages/data-persistence/package.json | 2 +- packages/data-react/package.json | 2 +- packages/data-rpc/package.json | 2 +- packages/data-solid/package.json | 2 +- packages/data-sync/package.json | 2 +- packages/data-testing/package.json | 2 +- packages/data/package.json | 2 +- .../src/ecs/database/default-factory.test.ts | 34 ++++++++++++++++++- .../transactional-store/apply-operations.ts | 19 ++--------- .../create-transactional-store.test.ts | 32 +++++++++++++++++ .../create-transactional-store.ts | 21 +++++++----- 16 files changed, 92 insertions(+), 38 deletions(-) diff --git a/package.json b/package.json index b66d4e213..99b34e604 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "data-monorepo", - "version": "0.10.29", + "version": "0.10.30", "private": true, "engines": { "node": ">=24" diff --git a/packages/data-ai/.claude-plugin/plugin.json b/packages/data-ai/.claude-plugin/plugin.json index 7af26a8fe..04d2d7dae 100644 --- a/packages/data-ai/.claude-plugin/plugin.json +++ b/packages/data-ai/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "adobe-data-ai", - "version": "0.10.29", + "version": "0.10.30", "description": "Architecture skills for @adobe/data — data-oriented modelling, archetype iteration, hot-path performance, and related conventions.", "author": { "name": "Adobe" diff --git a/packages/data-ai/package.json b/packages/data-ai/package.json index 685c91027..a73162839 100644 --- a/packages/data-ai/package.json +++ b/packages/data-ai/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-ai", - "version": "0.10.29", + "version": "0.10.30", "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/package.json b/packages/data-gpu/package.json index af8db8d9c..1bb14579f 100644 --- a/packages/data-gpu/package.json +++ b/packages/data-gpu/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-gpu", - "version": "0.10.29", + "version": "0.10.30", "description": "Adobe data WebGPU plugins and types for graphics and compute", "type": "module", "private": false, diff --git a/packages/data-lit/package.json b/packages/data-lit/package.json index 187eb04a9..301b37b3c 100644 --- a/packages/data-lit/package.json +++ b/packages/data-lit/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-lit", - "version": "0.10.29", + "version": "0.10.30", "description": "Adobe data Lit bindings - hooks, elements, decorators", "type": "module", "private": false, diff --git a/packages/data-persistence/package.json b/packages/data-persistence/package.json index 96135ce49..8208e899e 100644 --- a/packages/data-persistence/package.json +++ b/packages/data-persistence/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-persistence", - "version": "0.10.29", + "version": "0.10.30", "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/package.json b/packages/data-react/package.json index b460d1727..b1ef1d960 100644 --- a/packages/data-react/package.json +++ b/packages/data-react/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-react", - "version": "0.10.29", + "version": "0.10.30", "description": "Adobe data React bindings — hooks and context for ECS database", "type": "module", "private": false, diff --git a/packages/data-rpc/package.json b/packages/data-rpc/package.json index a2fe2b64b..4ad31ac0d 100644 --- a/packages/data-rpc/package.json +++ b/packages/data-rpc/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-rpc", - "version": "0.10.29", + "version": "0.10.30", "description": "Schema-driven, bidirectional projection of @adobe/data async data services across a boundary (iframe / MessagePort / Worker). Only Data crosses the wire.", "type": "module", "sideEffects": false, diff --git a/packages/data-solid/package.json b/packages/data-solid/package.json index de7cea231..c21695def 100644 --- a/packages/data-solid/package.json +++ b/packages/data-solid/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-solid", - "version": "0.10.29", + "version": "0.10.30", "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 ffcd3c6b3..3a4809204 100644 --- a/packages/data-sync/package.json +++ b/packages/data-sync/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-sync", - "version": "0.10.29", + "version": "0.10.30", "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-testing/package.json b/packages/data-testing/package.json index feab752fe..fae710ca8 100644 --- a/packages/data-testing/package.json +++ b/packages/data-testing/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-testing", - "version": "0.10.29", + "version": "0.10.30", "description": "Conformance-testing utilities (Match + Conformance runners) for @adobe/data ECS features", "type": "module", "sideEffects": false, diff --git a/packages/data/package.json b/packages/data/package.json index 928b09e04..3e640844d 100644 --- a/packages/data/package.json +++ b/packages/data/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data", - "version": "0.10.29", + "version": "0.10.30", "description": "Adobe data oriented programming library", "type": "module", "sideEffects": false, diff --git a/packages/data/src/ecs/database/default-factory.test.ts b/packages/data/src/ecs/database/default-factory.test.ts index ec3407768..ccb802b31 100644 --- a/packages/data/src/ecs/database/default-factory.test.ts +++ b/packages/data/src/ecs/database/default-factory.test.ts @@ -12,6 +12,7 @@ import { Database } from "./database.js"; import type { Schema } from "../../schema/index.js"; import type { Store } from "../store/index.js"; import { applyOperations } from "./transactional-store/apply-operations.js"; +import type { TransactionResult, TransactionWriteOperation } from "./transactional-store/transactional-store.js"; import type { Assert } from "../../types/assert.js"; import type { Equal } from "../../types/equal.js"; @@ -37,10 +38,20 @@ const nodePlugin = Database.Plugin.create({ }, }); +// Replays recorded ops through a transaction body, the way undo/redo does. +const replayPlugin = Database.Plugin.create({ + extends: nodePlugin, + transactions: { + replay(t, operations: TransactionWriteOperation[]) { + applyOperations(t, operations); + }, + }, +}); + const withRegistry = (start = 1000) => { let next = start; return { - db: Database.create(nodePlugin, { defaultFactories: { guid: () => next++ } }), + db: Database.create(replayPlugin, { defaultFactories: { guid: () => next++ } }), minted: () => next, }; }; @@ -75,6 +86,27 @@ describe("insert-time default-factory (full plugin chain)", () => { }); }); +// Undo/redo replay of an insert must restore the minted value, not mint again. +describe("redo of an insert with a default-factory component", () => { + it("records the minted value so replaying the redo insert keeps the original identity", () => { + const { db } = withRegistry(1000); + const results: TransactionResult[] = []; + db.observe.transactions(result => results.push(result)); + + const e = db.transactions.addNode({ value: 1 }); + const { undo, redo } = results[results.length - 1]; + expect(db.read(e)?.guid).toBe(1000); + + const redoInsert = redo.find(op => op.type === "insert"); + expect(redoInsert?.type === "insert" && redoInsert.values).toEqual({ value: 1, guid: 1000 }); + + db.transactions.replay(undo); + db.transactions.replay(redo); + const guids = db.select(["guid"]).map(entity => db.read(entity)?.guid); + expect(guids).toEqual([1000]); + }); +}); + // An index whose key or order reads a default-factory column must file the row // under the value the archetype MINTED, not the omitted insert payload. describe("indexes over a default-factory component", () => { 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 66317cacb..75dcaba0e 100644 --- a/packages/data/src/ecs/database/transactional-store/apply-operations.ts +++ b/packages/data/src/ecs/database/transactional-store/apply-operations.ts @@ -4,15 +4,6 @@ import { ID } from "../../required-components.js"; import { TransactionWriteOperation } from "./transactional-store.js"; import { StringKeyof } from "../../../types/types.js"; -/** - * 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 = ( @@ -31,14 +22,8 @@ export const applyOperations = ( // 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); + // undo). An `undefined` value removes its column. + store.update(operation.entity, { ...operation.values }); break; } case "delete": 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 3fec50b45..3e31cbbc1 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 @@ -842,6 +842,38 @@ describe("createTransactionalStore", () => { }); }); + describe("undo of an added column", () => { + const nameSchema = { type: "string" } as const satisfies Schema; + + it("removes the column on undo and restores a user value equal to the old removal sentinel", () => { + const baseStore = Store.create({ + components: { position: positionSchema, name: nameSchema }, + resources: {}, + archetypes: {} + }); + const store = createTransactionalStore(baseStore); + const sentinel = "_$_DELETE_$_"; + + let entity: Entity = 0 as Entity; + store.execute(t => { + entity = t.ensureArchetype(["position"]).insert({ position: { x: 0, y: 0, z: 0 } }); + }); + + // Add the column: undo must remove it. + const added = store.execute(t => { t.update(entity, { name: sentinel }); }); + expect(baseStore.read(entity)?.name).toBe(sentinel); + applyOperations(baseStore, added.undo); + expect(baseStore.read(entity)?.name).toBeUndefined(); + applyOperations(baseStore, added.redo); + expect(baseStore.read(entity)?.name).toBe(sentinel); + + // Edit a column whose value is the sentinel: undo must restore it, not drop it. + const edited = store.execute(t => { t.update(entity, { name: "renamed" }); }); + applyOperations(baseStore, edited.undo); + expect(baseStore.read(entity)?.name).toBe(sentinel); + }); + }); + describe("relocatedEntities (swap-move / migration signal)", () => { const make = () => createTransactionalStore(Store.create({ components: { value: F32.schema, tag: F32.schema }, 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 aa038840a..cf41fc1b8 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 @@ -11,7 +11,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"; -import { applyOperations, DELETE } from "./apply-operations.js"; +import { applyOperations } from "./apply-operations.js"; interface Transaction< C extends Components = never, @@ -59,14 +59,20 @@ export function createTransactionalStore< insert: >(values: T) => { const entity = archetype.insert(values as never); trackEntity(entity); + // Record the values that actually landed — defaults and default + // factories included — so replaying the redo insert restores the + // same row rather than re-running a factory (e.g. a guid). + // `store.read` excludes the identity column and the entity was + // just inserted, so it is present. + const storedValues = store.read(entity) as unknown as EntityInsertValues; redoOperations.push({ type: "insert", - values: values, + values: storedValues, }); undoOperationsInReverseOrder.push({ type: "delete", entity }); - changed.entities.set(entity, values); + changed.entities.set(entity, storedValues); changed.archetypes.add(id); - for (const key in values) { + for (const key in storedValues) { changed.components.add(key); } return entity; @@ -94,11 +100,10 @@ export function createTransactionalStore< const replacedValues: any = {}; for (const name in values) { const newValue = (values as any)[name]; - let oldValue = (oldValues as any)[name]; + const oldValue = (oldValues as any)[name]; if (newValue !== oldValue) { - if (oldValue === undefined) { - oldValue = DELETE; - } + // An added column has no pre-image: record `undefined`, which + // removes it on replay and cannot collide with a user value. replacedValues[name] = oldValue; changed.components.add(name); }