Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 10 additions & 7 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ jobs:

- uses: actions/setup-node@v6
with:
node-version: 22
node-version: 24
cache: pnpm

- run: pnpm install --frozen-lockfile
Expand All @@ -63,7 +63,7 @@ jobs:

- uses: actions/setup-node@v6
with:
node-version: 22
node-version: 24
cache: pnpm

- run: pnpm install --frozen-lockfile
Expand All @@ -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
1 change: 1 addition & 0 deletions .node-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
24
2 changes: 1 addition & 1 deletion .nvmrc
Original file line number Diff line number Diff line change
@@ -1 +1 @@
22
24
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"version": "0.9.89",
"private": true,
"engines": {
"node": ">=22"
"node": ">=24"
},
"scripts": {
"build": "pnpm -r run build",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
// © 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";
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
Expand All @@ -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 = <Args>(config: {
readonly cases: readonly ConformanceCase<Args>[];
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);
});
}
};
Original file line number Diff line number Diff line change
@@ -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));
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -8,27 +9,28 @@ 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,
complete: todo.complete,
order: index,
dragPosition: null,
assignees: [],
});
});
}),
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
});
}
});
Original file line number Diff line number Diff line change
Expand Up @@ -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 } })) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
});
});
8 changes: 8 additions & 0 deletions packages/data/src/ecs/entity/persistence-sharing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
10 changes: 10 additions & 0 deletions packages/data/src/ecs/entity/persistence-sharing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
2 changes: 1 addition & 1 deletion packages/data/src/ecs/entity/public.ts
Original file line number Diff line number Diff line change
@@ -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";