diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0bf74cbd..8323c2c1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,7 +36,7 @@ jobs: - uses: actions/setup-node@v6 with: - node-version: 22 + node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile @@ -63,7 +63,7 @@ jobs: - uses: actions/setup-node@v6 with: - node-version: 22 + node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile @@ -80,11 +80,14 @@ jobs: run: pnpm --filter '!@adobe/data' run build - name: Test downstream packages + # Every workspace package except the core (tested in the `data` job) and + # data-persistence (whose `test` script pulls in Playwright — run its + # node project instead). pnpm skips packages that declare no `test` + # script. This now includes the private sample apps (data-lit-todo, + # data-lit-tictactoe, …); previously they were only built, so a broken + # sample unit test (e.g. the data-lit-todo conformance suite) merged green. run: | - pnpm --filter @adobe/data-lit \ - --filter @adobe/data-gpu \ - --filter @adobe/data-react \ - --filter @adobe/data-solid \ - --filter @adobe/data-sync \ + pnpm --filter '!@adobe/data' \ + --filter '!@adobe/data-persistence' \ run test pnpm --filter @adobe/data-persistence run test:node diff --git a/.node-version b/.node-version new file mode 100644 index 00000000..a45fd52c --- /dev/null +++ b/.node-version @@ -0,0 +1 @@ +24 diff --git a/.nvmrc b/.nvmrc index 2bd5a0a9..a45fd52c 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -22 +24 diff --git a/package.json b/package.json index 3a5b43ae..f4a2e9b8 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "0.9.89", "private": true, "engines": { - "node": ">=22" + "node": ">=24" }, "scripts": { "build": "pnpm -r run build", diff --git a/packages/data-lit-todo/src/features/main/ecs/conformance/expect-conforms.ts b/packages/data-lit-todo/src/features/main/ecs/conformance/expect-conforms.ts index 854b35f0..2261c1e1 100644 --- a/packages/data-lit-todo/src/features/main/ecs/conformance/expect-conforms.ts +++ b/packages/data-lit-todo/src/features/main/ecs/conformance/expect-conforms.ts @@ -1,5 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { it } from "vitest"; +import { Entity } from "@adobe/data/ecs"; import type { State } from "../../data/state/state.js"; import type { ConformanceCase } from "../../data/state/conformance-case.js"; import { expectStateMatches } from "../../data/state/expect-state-matches.js"; @@ -7,6 +8,13 @@ import type { CoreDatabase } from "../core-database/core-database.js"; import { createStore } from "./create-store.js"; import { fromState } from "./from-state.js"; import { toState } from "./to-state.js"; +import { expectStateMatchesIgnoringIds } from "./expect-state-matches-ignoring-ids.js"; + +// Resolve a spec domain `id` to the ecs entity seeded for it. `fromState` +// returns the seeded entities in display order, so the i-th `before` todo maps +// to the i-th entity; an id no todo carries resolves to `Entity.none`, so an +// id-addressed transaction reads no such entity and is a no-op. +export type ResolveEntity = (specId: number) => Entity; // The conformance runner, bound to THIS feature's projection (`fromState` / // `toState`). For each case it proves the one conformance property @@ -19,25 +27,27 @@ import { toState } from "./to-state.js"; // 2. seed `fromState(before)` → run the caller's `apply` → `toState ≡ after` // — the ecs implementation reproduces the pure transform. // -// `apply` receives the seeded writable store and calls the raw transaction -// function directly (a transaction is `(store, args) => void`, so no `Database` -// is involved). A transaction addressed by entity id passes the spec `id` -// straight through: `fromState` seeds todos so that entity id equals spec `id`. -// Todos compare as a multiset keyed by `id`; the resource compares exactly (see -// `expectStateMatches`). +// `apply` receives the seeded writable store, the case args, and a `resolve` +// that maps a spec `id` to the seeded entity, then calls the raw transaction +// function directly (a transaction is `(store, …) => void`, so no `Database` +// is involved). Half 2 compares ignoring todo `id` (`expectStateMatchesIgnoringIds`): +// the ecs owns its entity-id space, so the projection conforms only up to a +// renaming of ids. Half 1 stays id-strict — the spec fully owns its domain ids. export const expectConforms = (config: { readonly cases: readonly ConformanceCase[]; readonly spec: (before: State, args: Args) => State; - readonly apply: (store: CoreDatabase.Store, args: Args) => void; + readonly apply: (store: CoreDatabase.Store, args: Args, resolve: ResolveEntity) => void; }): void => { for (const testCase of config.cases) { it(testCase.name, () => { expectStateMatches(config.spec(testCase.before, testCase.args), testCase.after); const store = createStore(); - fromState(store, testCase.before); - config.apply(store, testCase.args); - expectStateMatches(toState(store), testCase.after); + const entities = fromState(store, testCase.before); + const bySpecId = new Map(testCase.before.todos.map((todo, i) => [todo.id, entities[i]])); + const resolve: ResolveEntity = (specId) => bySpecId.get(specId) ?? Entity.none; + config.apply(store, testCase.args, resolve); + expectStateMatchesIgnoringIds(toState(store), testCase.after); }); } }; diff --git a/packages/data-lit-todo/src/features/main/ecs/conformance/expect-state-matches-ignoring-ids.ts b/packages/data-lit-todo/src/features/main/ecs/conformance/expect-state-matches-ignoring-ids.ts new file mode 100644 index 00000000..85473ad0 --- /dev/null +++ b/packages/data-lit-todo/src/features/main/ecs/conformance/expect-state-matches-ignoring-ids.ts @@ -0,0 +1,18 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { State } from "../../data/state/state.js"; +import { expectStateMatches } from "../../data/state/expect-state-matches.js"; + +// Like `expectStateMatches`, but compares todos without regard to their `id`. +// The ecs projects each todo's entity id — drawn from its own quadrant-encoded +// id-space — into `State.id`, which does not match the spec's domain `id`. The +// projection therefore conforms only up to a renaming of ids: canonicalise both +// sides to a single id so the comparison rests on the visible fields (`name`, +// `complete`) and the resource, never the id value. Duplicates still count +// (the multiset comparison is by value), so name-sharing round-trips hold. +const canonicalizeIds = (state: State): State => ({ + ...state, + todos: state.todos.map((todo) => ({ ...todo, id: 0 })), +}); + +export const expectStateMatchesIgnoringIds = (actual: State, expected: State): void => + expectStateMatches(canonicalizeIds(actual), canonicalizeIds(expected)); diff --git a/packages/data-lit-todo/src/features/main/ecs/conformance/from-state.ts b/packages/data-lit-todo/src/features/main/ecs/conformance/from-state.ts index 4c47de6a..948e4d6b 100644 --- a/packages/data-lit-todo/src/features/main/ecs/conformance/from-state.ts +++ b/packages/data-lit-todo/src/features/main/ecs/conformance/from-state.ts @@ -1,4 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Entity } from "@adobe/data/ecs"; import type { State } from "../../data/state/state.js"; import type { CoreDatabase } from "../core-database/core-database.js"; @@ -8,20 +9,21 @@ import type { CoreDatabase } from "../core-database/core-database.js"; // checked against the pure transform it stands for (see `expect-conforms.ts`). // // Clearing iterates tail→head so each delete is from the tail (no hole-fill -// shift). Todos are inserted in array (display) order with `order` = the index, -// and a fresh store assigns entity ids 1, 2, 3, … in that same order — so the -// entity id of the i-th todo equals its spec `id` when the cases number ids -// 1..N in display order. That identity is what lets an id-addressed transaction -// resolve its target and lets `toState` reproduce each todo's `id`. The -// implementation-only slots (`dragPosition`, `assignees`) are seeded empty. -export const fromState = (store: CoreDatabase.Store, state: State): void => { +// shift). Todos are inserted in array (display) order with `order` = the index; +// the implementation-only slots (`dragPosition`, `assignees`) are seeded empty. +// +// The ecs assigns entity ids from its own quadrant-encoded id-space, unrelated +// to the spec's domain `id`. So the seeded entities are returned in display +// order and the caller maps spec `id` → entity positionally (see +// `expect-conforms.ts`); nothing here assumes the two id-spaces coincide. +export const fromState = (store: CoreDatabase.Store, state: State): readonly Entity[] => { for (const arch of store.queryArchetypes(store.archetypes.Todo.components)) { for (let row = arch.rowCount - 1; row >= 0; row--) { store.delete(arch.columns.id.get(row)); } } store.resources.displayCompleted = state.displayCompleted; - state.todos.forEach((todo, index) => { + return state.todos.map((todo, index) => store.archetypes.Todo.insert({ todo: true, name: todo.name, @@ -29,6 +31,6 @@ export const fromState = (store: CoreDatabase.Store, state: State): void => { order: index, dragPosition: null, assignees: [], - }); - }); + }), + ); }; diff --git a/packages/data-lit-todo/src/features/main/ecs/conformance/projection.test.ts b/packages/data-lit-todo/src/features/main/ecs/conformance/projection.test.ts index 87d6a156..200d9b93 100644 --- a/packages/data-lit-todo/src/features/main/ecs/conformance/projection.test.ts +++ b/packages/data-lit-todo/src/features/main/ecs/conformance/projection.test.ts @@ -7,7 +7,7 @@ // proves the projection round-trips faithfully on its own. import { describe, it } from "vitest"; import type { State } from "../../data/state/state.js"; -import { expectStateMatches } from "../../data/state/expect-state-matches.js"; +import { expectStateMatchesIgnoringIds } from "./expect-state-matches-ignoring-ids.js"; import { createStore } from "./create-store.js"; import { fromState } from "./from-state.js"; import { toState } from "./to-state.js"; @@ -46,7 +46,7 @@ describe("ecs/conformance projection round-trips (toState ∘ fromState ≡ iden it(name, () => { const store = createStore(); fromState(store, state); - expectStateMatches(toState(store), state); + expectStateMatchesIgnoringIds(toState(store), state); }); } }); diff --git a/packages/data-lit-todo/src/features/main/ecs/conformance/to-state.ts b/packages/data-lit-todo/src/features/main/ecs/conformance/to-state.ts index b5fdf2a6..b267d9ea 100644 --- a/packages/data-lit-todo/src/features/main/ecs/conformance/to-state.ts +++ b/packages/data-lit-todo/src/features/main/ecs/conformance/to-state.ts @@ -7,7 +7,10 @@ import type { CoreDatabase } from "../core-database/core-database.js"; // are read in ascending `order` (the ecs materialisation of display order), // each through its full `Todo` archetype so the row shape never aliases; only // the spec fields (`id`, `name`, `complete`) are projected — the ecs-only -// `order` / `dragPosition` / `assignees` slots stay behind. Test-only. +// `order` / `dragPosition` / `assignees` slots stay behind. The projected `id` +// is the entity id (the ecs's own id-space, not the spec's domain id), so +// conformance comparisons ignore it — see `expect-state-matches-ignoring-ids`. +// Test-only. const readTodos = (store: CoreDatabase.Store): Todo[] => { const todos: Todo[] = []; for (const entity of store.select(store.archetypes.Todo.components, { order: { order: true } })) { diff --git a/packages/data-lit-todo/src/features/main/ecs/transaction-database/transactions/delete-todo.test.ts b/packages/data-lit-todo/src/features/main/ecs/transaction-database/transactions/delete-todo.test.ts index 197d5fab..c0af300e 100644 --- a/packages/data-lit-todo/src/features/main/ecs/transaction-database/transactions/delete-todo.test.ts +++ b/packages/data-lit-todo/src/features/main/ecs/transaction-database/transactions/delete-todo.test.ts @@ -13,6 +13,6 @@ describe("deleteTodo transaction conforms to State.deleteTodo", () => { expectConforms({ cases, spec: State.deleteTodo, - apply: (store, args) => deleteTodo(store, args.id), + apply: (store, args, resolve) => deleteTodo(store, resolve(args.id)), }); }); diff --git a/packages/data-lit-todo/src/features/main/ecs/transaction-database/transactions/drag-todo.test.ts b/packages/data-lit-todo/src/features/main/ecs/transaction-database/transactions/drag-todo.test.ts index 9ccfda09..334a9f32 100644 --- a/packages/data-lit-todo/src/features/main/ecs/transaction-database/transactions/drag-todo.test.ts +++ b/packages/data-lit-todo/src/features/main/ecs/transaction-database/transactions/drag-todo.test.ts @@ -16,7 +16,7 @@ describe("dragTodo transaction conforms to State.reorderTodo", () => { expectConforms({ cases, spec: State.reorderTodo, - apply: (store, args) => - dragTodo(store, { entity: args.id, dragPosition: 0, finalIndex: args.toIndex }), + apply: (store, args, resolve) => + dragTodo(store, { entity: resolve(args.id), dragPosition: 0, finalIndex: args.toIndex }), }); }); diff --git a/packages/data-lit-todo/src/features/main/ecs/transaction-database/transactions/toggle-complete.test.ts b/packages/data-lit-todo/src/features/main/ecs/transaction-database/transactions/toggle-complete.test.ts index 43357a84..1acb6b47 100644 --- a/packages/data-lit-todo/src/features/main/ecs/transaction-database/transactions/toggle-complete.test.ts +++ b/packages/data-lit-todo/src/features/main/ecs/transaction-database/transactions/toggle-complete.test.ts @@ -13,6 +13,6 @@ describe("toggleComplete transaction conforms to State.toggleComplete", () => { expectConforms({ cases, spec: State.toggleComplete, - apply: (store, args) => toggleComplete(store, args.id), + apply: (store, args, resolve) => toggleComplete(store, resolve(args.id)), }); }); diff --git a/packages/data/src/ecs/entity/persistence-sharing.test.ts b/packages/data/src/ecs/entity/persistence-sharing.test.ts index aa9de9fb..6100b387 100644 --- a/packages/data/src/ecs/entity/persistence-sharing.test.ts +++ b/packages/data/src/ecs/entity/persistence-sharing.test.ts @@ -50,4 +50,12 @@ describe("Entity persistence/sharing quadrant", () => { } } }); + + it("none is the last representable id and past any allocatable local index", () => { + // The maximum local index the encoding can carry; the allocator never + // reaches it, so `none` decodes to an id no live entity occupies. + const maxLocalIndex = (1 << (32 - 2)) - 1; + expect(toLocalIndex(Entity.none)).toBe(maxLocalIndex); + expect(Entity.none).toBe(-1); + }); }); diff --git a/packages/data/src/ecs/entity/persistence-sharing.ts b/packages/data/src/ecs/entity/persistence-sharing.ts index 70bfdedb..3d845f32 100644 --- a/packages/data/src/ecs/entity/persistence-sharing.ts +++ b/packages/data/src/ecs/entity/persistence-sharing.ts @@ -40,6 +40,16 @@ export const quadrantFor = (nonPersistent: boolean, nonShared: boolean): number /** Pack a per-quadrant local index + quadrant into an entity id. */ export const toEntity = (localIndex: number, quadrant: number): Entity => (localIndex << QUADRANT_BITS) | quadrant; +/** + * Sentinel for "no entity": the last representable id (the maximum local index + * in the last quadrant, which is numerically -1). The allocator issues local + * indices from 0 upward and a quadrant fills up long before 2^30 entities, so + * no live entity ever equals this — `locate`/`read` resolve it to null. Use it + * where a lookup may have no answer; it is not a live entity, so never + * insert/delete/update it. + */ +export const none: Entity = toEntity((1 << (32 - QUADRANT_BITS)) - 1, QUADRANT_MASK); + /** Recover the per-quadrant local index from an entity id (unsigned). */ export const toLocalIndex = (entity: Entity): number => entity >>> QUADRANT_BITS; diff --git a/packages/data/src/ecs/entity/public.ts b/packages/data/src/ecs/entity/public.ts index dfd941c1..cd3d2115 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, isNonShared, isShared, quadrantOf, toLocalIndex, toEntity, persistentQuadrants } from "./persistence-sharing.js"; +export { isNonPersistent, isPersistent, isNonShared, isShared, quadrantOf, toLocalIndex, toEntity, none, persistentQuadrants } from "./persistence-sharing.js";