@adobe/ecs-persistence 0.0.0-stage → 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,34 @@
1
+ // © 2026 Adobe. MIT License. See /LICENSE for details.
2
+ import { Component, Resource, type Schema } from "@adobe/ecs";
3
+ import { persist } from "../persist.js";
4
+
5
+ /** A key persisted, with its name and schema. */
6
+ export type Persisted<K extends symbol = symbol> = { readonly name: string; readonly key: K; readonly schema: Schema };
7
+
8
+ /** What the program persists: its components and resources by name. */
9
+ export type Program = {
10
+ readonly components: ReadonlyMap<string, Persisted<Component.Key>>;
11
+ readonly resources: ReadonlyMap<string, Persisted<Resource.Key>>;
12
+ };
13
+
14
+ /** The keys of `declarations` with a `persist` trait by name, or why one can't persist: it isn't data, or a name is taken. */
15
+ const namedIn = <K extends symbol>(declarations: ReadonlyMap<K, Component.Declared>, kind: string): ReadonlyMap<string, Persisted<K>> | string => {
16
+ const named = new Map<string, Persisted<K>>();
17
+ for (const [key, declaration] of declarations) {
18
+ const name: unknown = declaration[persist];
19
+ if (typeof name !== "string") continue;
20
+ if (declaration.type === undefined) return `The ${kind} persisted as "${name}" isn't data: it has no schema.`;
21
+ if (named.has(name)) return `Two ${kind}s are persisted as "${name}".`;
22
+ named.set(name, { name, key, schema: declaration.type });
23
+ }
24
+ return named;
25
+ };
26
+
27
+ /** What the program persists, as its modules registered so far declare it, or why it can't. */
28
+ export const programOf = (): Program | string => {
29
+ const components = namedIn(Component.declarations(), "component");
30
+ if (typeof components === "string") return components;
31
+ const resources = namedIn(Resource.declarations(), "resource");
32
+ if (typeof resources === "string") return resources;
33
+ return { components, resources };
34
+ };
@@ -0,0 +1,96 @@
1
+ // © 2026 Adobe. MIT License. See /LICENSE for details.
2
+ import { Component, System, type Entity, type Resource, type World } from "@adobe/ecs";
3
+ import type { Failure } from "../failure.js";
4
+ import type { Contents } from "../program/contents.js";
5
+ import type { Program } from "../program/program.js";
6
+
7
+ /**
8
+ * Which entities a snapshot takes: those matching `with` and `not`, every one holding a persisted component by
9
+ * default; or `root` and every entity it owns, directly or through others, by persisted owner references.
10
+ */
11
+ export type Selection =
12
+ | { readonly with?: readonly Component.Key[]; readonly not?: readonly Component.Key[]; readonly resources?: boolean }
13
+ | { readonly root: Entity };
14
+
15
+ /** Which tables a snapshot reads: those of its persisted components, matching `with` and `not`, and its resources. */
16
+ type Reading = {
17
+ readonly components: readonly Component.Key[];
18
+ readonly with?: readonly Component.Key[];
19
+ readonly not?: readonly Component.Key[];
20
+ readonly resources?: readonly Resource.Key[];
21
+ };
22
+
23
+ /** A readonly system reading the tables `declaration` names. */
24
+ const tablesSystemOf = (declaration: Reading) =>
25
+ System.create({ readonly: true, tables: declaration }, ({ tables }) => tables);
26
+
27
+ /** The rows of `table` whose entities `keep` takes, as a table of their own. */
28
+ const rowsOf = (table: World.Table, keep: (entity: number) => boolean): World.Table | null => {
29
+ const rows: number[] = [];
30
+ for (let row = 0; row < table.count; row++) if (keep(table.entities[row])) rows.push(row);
31
+ if (rows.length === table.count) return table;
32
+ if (rows.length === 0) return null;
33
+ const columns: { [key: symbol]: World.Table.Column } = {};
34
+ for (const key of Object.getOwnPropertySymbols(table.columns)) {
35
+ const column: World.Table.Column | undefined = Reflect.get(table.columns, key);
36
+ if (column === undefined) continue;
37
+ if (column.kind === "value") columns[key] = column;
38
+ else if (column.kind === "values") columns[key] = { kind: "values", values: rows.map(row => column.values[row]) };
39
+ else {
40
+ const { stride } = column;
41
+ const bytes = new Uint8Array(rows.length * stride);
42
+ rows.forEach((row, at) => bytes.set(column.bytes.subarray(row * stride, (row + 1) * stride), at * stride));
43
+ columns[key] = { kind: "bytes", bytes, stride };
44
+ }
45
+ }
46
+ return { count: rows.length, entities: Float64Array.from(rows, row => table.entities[row]), columns };
47
+ };
48
+
49
+ /** The entities `root` owns, itself included, through the owner references among `owners` in the tables. */
50
+ const subtreeOf = (tables: readonly World.Table[], owners: readonly Component.Key[], root: Entity): ReadonlySet<number> => {
51
+ const children = new Map<number, number[]>();
52
+ for (const table of tables) {
53
+ for (const reference of owners) {
54
+ const key: symbol = reference;
55
+ const column: World.Table.Column | undefined = Reflect.get(table.columns, key);
56
+ if (column?.kind !== "bytes") continue;
57
+ const view = new DataView(column.bytes.buffer, column.bytes.byteOffset, column.bytes.byteLength);
58
+ for (let row = 0; row < table.count; row++) {
59
+ const owner = view.getFloat64(row * column.stride, true);
60
+ const list = children.get(owner);
61
+ if (list === undefined) children.set(owner, [table.entities[row]]);
62
+ else list.push(table.entities[row]);
63
+ }
64
+ }
65
+ }
66
+ const taken = new Set<number>([root]);
67
+ const waiting: number[] = [root];
68
+ for (let next = waiting.pop(); next !== undefined; next = waiting.pop()) {
69
+ for (const child of children.get(next) ?? []) {
70
+ if (taken.has(child)) continue;
71
+ taken.add(child);
72
+ waiting.push(child);
73
+ }
74
+ }
75
+ return taken;
76
+ };
77
+
78
+ /** The persisted part of the entities `selection` takes from `world`, on its thread, or why it can't be read. */
79
+ export const select = async (world: World, program: Program, selection: Selection): Promise<Contents | Failure> => {
80
+ const components = [...program.components.values()];
81
+ const keys = components.map(persisted => persisted.key);
82
+ const subtree = "root" in selection;
83
+ const resources = subtree || selection.resources === false ? [] : [...program.resources.values()];
84
+ const declaration: Reading = subtree
85
+ ? { components: keys }
86
+ : { components: keys, with: selection.with ?? [], not: selection.not ?? [], resources: resources.map(persisted => persisted.key) };
87
+ const read = await world.run(tablesSystemOf(declaration)).catch((error: unknown) => ({ failure: error instanceof Error ? error.message : String(error) }));
88
+ if ("failure" in read) return read;
89
+ let tables = read.archetypes;
90
+ if (subtree) {
91
+ const owners = keys.filter(key => Component.declarations().get(key)?.reference === "owner");
92
+ const taken = subtreeOf(tables, owners, selection.root);
93
+ tables = tables.flatMap(table => rowsOf(table, entity => taken.has(entity)) ?? []);
94
+ }
95
+ return { components, tables, resources: resources.map(persisted => ({ persisted, value: read.resources[persisted.key] })) };
96
+ };
@@ -0,0 +1,22 @@
1
+ // © 2026 Adobe. MIT License. See /LICENSE for details.
2
+ import type { World } from "@adobe/ecs";
3
+ import type { Failure } from "./failure.js";
4
+ import { contents, type Contents } from "./program/contents.js";
5
+ import { programOf } from "./program/program.js";
6
+ import { select, type Selection } from "./selection/selection.js";
7
+
8
+ /** The persisted part of some of a world's entities and its persisted resources, which `encode` and `spawn` take. */
9
+ export type Snapshot = { readonly failure?: undefined; readonly [contents]: Contents };
10
+
11
+ /**
12
+ * A snapshot of some of `world`'s entities, read on its thread: by default every entity holding a persisted
13
+ * component, and the persisted resources; with `with` and `not`, those a query matches, and the resources unless
14
+ * `resources` is false; with `root`, it and every entity it owns, through persisted owner references, without
15
+ * resources. Fails, as data, when the program's persisted keys can't be saved or the world can't be read.
16
+ */
17
+ export const snapshot = async (input: { readonly world: World } & Selection): Promise<Snapshot | Failure> => {
18
+ const program = programOf();
19
+ if (typeof program === "string") return { failure: program };
20
+ const taken = await select(input.world, program, input);
21
+ return "failure" in taken ? taken : { [contents]: taken };
22
+ };
package/src/spawn.ts ADDED
@@ -0,0 +1,60 @@
1
+ // © 2026 Adobe. MIT License. See /LICENSE for details.
2
+ import { System, type Entity, type World } from "@adobe/ecs";
3
+ import type { Failure } from "./failure.js";
4
+ import { contents } from "./program/contents.js";
5
+ import type { Snapshot } from "./snapshot.js";
6
+
7
+ /**
8
+ * What `spawn` made: how many of the snapshot's entities it didn't spawn, those owned by an entity outside it; and,
9
+ * unless asked not to, `entities`, each of the snapshot's entities to the one spawned for it, or null for one it
10
+ * didn't spawn.
11
+ */
12
+ export type Spawned<Entities extends boolean = true> = { readonly failure?: undefined; readonly dropped: number }
13
+ & (Entities extends false ? {} : { readonly entities: ReadonlyMap<Entity, Entity | null> });
14
+
15
+ const isTables = (value: unknown): value is readonly World.Table[] => Array.isArray(value) && value.every(table =>
16
+ typeof table === "object" && table !== null && typeof Reflect.get(table, "count") === "number" && Reflect.get(table, "entities") instanceof Float64Array
17
+ && typeof Reflect.get(table, "columns") === "object");
18
+
19
+ const isKeyed = (value: unknown): value is { readonly [key: symbol]: unknown } => typeof value === "object" && value !== null;
20
+
21
+ /** Spawns its input's tables and sets its resources, a snapshot's, rewriting their entities. */
22
+ const spawnTables = System.create({}, ({ commands }, input: { readonly tables: unknown; readonly resources: unknown }) => {
23
+ if (!isTables(input.tables) || !isKeyed(input.resources)) throw new Error("A snapshot holds tables and resources.");
24
+ return commands.spawnTables(input.tables, input.resources);
25
+ });
26
+
27
+ /**
28
+ * Spawns an entity in `world` for each of the snapshot's, with its persisted components, and sets the persisted
29
+ * resources, on the world's thread, as the world's references would leave them had the entities outside the snapshot
30
+ * despawned: each entity value naming one of the snapshot's names the one spawned for it; an entity owned, directly
31
+ * or through others, by an entity outside the snapshot isn't spawned, which the result counts; a weak reference to
32
+ * one is left out; and any other entity value naming one is `Entity.none`. With `entities: false`, the result leaves
33
+ * out its map of entities, which costs about 85 ms and 50 MB at a million. Fails, as data, for a column or
34
+ * resource that doesn't fit, spawning nothing.
35
+ */
36
+ export function spawn(input: { readonly world: World; readonly snapshot: Snapshot; readonly entities: false }): Promise<Spawned<false> | Failure>;
37
+ export function spawn(input: { readonly world: World; readonly snapshot: Snapshot; readonly entities?: true }): Promise<Spawned | Failure>;
38
+ export async function spawn(input: { readonly world: World; readonly snapshot: Snapshot; readonly entities?: boolean }): Promise<Spawned<boolean> | Failure> {
39
+ const { tables, resources } = input.snapshot[contents];
40
+ const values: { [key: symbol]: unknown } = {};
41
+ for (const { persisted, value } of resources) values[persisted.key] = value;
42
+ const spawned = await input.world.run(spawnTables, { tables, resources: values }).catch((error: unknown) => ({ failure: error instanceof Error ? error.message : String(error) }));
43
+ if ("failure" in spawned) return spawned;
44
+ if (input.entities === false) return { dropped: spawned.dropped };
45
+ return { dropped: spawned.dropped, entities: entitiesOf(tables, spawned.entities) };
46
+ }
47
+
48
+ /** Each of the tables' entities to the one spawned for it, or null for one that wasn't. */
49
+ const entitiesOf = (tables: readonly World.Table[], spawned: readonly Float64Array[]): ReadonlyMap<Entity, Entity | null> => {
50
+ const entities = new Map<Entity, Entity | null>();
51
+ tables.forEach((table, t) => {
52
+ const made = spawned[t];
53
+ for (let row = 0; row < table.count; row++) {
54
+ // A table's entities and `spawnTables`' are entities, `Entity.none`, 0, for a row it didn't spawn.
55
+ const entity = made[row] as Entity;
56
+ entities.set(table.entities[row] as Entity, entity === 0 ? null : entity);
57
+ }
58
+ });
59
+ return entities;
60
+ };
@@ -0,0 +1,97 @@
1
+ // © 2026 Adobe. MIT License. See /LICENSE for details.
2
+ import type { Schema } from "@adobe/ecs";
3
+
4
+ // Values of components stored as values, strings and other data that isn't a fixed layout, as JSON their schema reads
5
+ // back: a map as its entries, a set as its items, numbers JSON lacks as text, and a union's value as its member's
6
+ // index and its JSON.
7
+
8
+ /** A number JSON can't hold, as text: `NaN`, `Infinity`, `-Infinity` or `-0`. */
9
+ const numberToJson = (value: number): number | string =>
10
+ Number.isNaN(value) ? "NaN" : value === Infinity ? "Infinity" : value === -Infinity ? "-Infinity" : Object.is(value, -0) ? "-0" : value;
11
+
12
+ const numberFromJson = (json: unknown): unknown => {
13
+ if (typeof json !== "string") return json;
14
+ switch (json) {
15
+ case "NaN": return NaN;
16
+ case "Infinity": return Infinity;
17
+ case "-Infinity": return -Infinity;
18
+ case "-0": return -0;
19
+ default: return json;
20
+ }
21
+ };
22
+
23
+ /** Whether a value is of the JSON kind a schema's values are, to pick a union's member. */
24
+ const fits = (schema: Schema, value: unknown): boolean => {
25
+ if ("const" in schema) return Object.is(schema.const, value) || JSON.stringify(schema.const) === JSON.stringify(value);
26
+ if ("anyOf" in schema) return schema.anyOf.some(member => fits(member, value));
27
+ if ("allOf" in schema) return schema.allOf.every(member => fits(member, value));
28
+ if (!("type" in schema)) return true;
29
+ switch (schema.type) {
30
+ case "number": case "integer": return typeof value === "number";
31
+ case "string": return typeof value === "string";
32
+ case "boolean": return typeof value === "boolean";
33
+ case "null": return value === null;
34
+ case "array": return Array.isArray(value);
35
+ case "set": return value instanceof Set;
36
+ case "map": return value instanceof Map;
37
+ case "object": return typeof value === "object" && value !== null && !Array.isArray(value) && !(value instanceof Set) && !(value instanceof Map);
38
+ default: return true;
39
+ }
40
+ };
41
+
42
+ /** A value of `schema` as JSON. */
43
+ export const toJson = (schema: Schema, value: unknown): unknown => {
44
+ if ("anyOf" in schema) {
45
+ const index = schema.anyOf.findIndex(candidate => fits(candidate, value));
46
+ const member = schema.anyOf[index];
47
+ return member === undefined ? [-1, value] : [index, toJson(member, value)];
48
+ }
49
+ if (typeof value === "number") return numberToJson(value);
50
+ if (!("type" in schema)) return value;
51
+ switch (schema.type) {
52
+ case "array": return Array.isArray(value) ? value.map(item => toJson(schema.items, item)) : value;
53
+ case "set": return value instanceof Set ? Array.from(value, item => toJson(schema.items, item)) : value;
54
+ case "map": return value instanceof Map ? Array.from(value, ([key, item]) => [toJson(schema.keys, key), toJson(schema.values, item)]) : value;
55
+ case "object": {
56
+ if (typeof value !== "object" || value === null) return value;
57
+ const json: { [name: string]: unknown } = {};
58
+ for (const name of Object.keys(value)) {
59
+ const field = schema.properties[name];
60
+ const item: unknown = Reflect.get(value, name);
61
+ if (item !== undefined) json[name] = field === undefined ? item : toJson(field, item);
62
+ }
63
+ return json;
64
+ }
65
+ default: return value;
66
+ }
67
+ };
68
+
69
+ /** A value of `schema` read back from its JSON. */
70
+ export const fromJson = (schema: Schema, json: unknown): unknown => {
71
+ if ("anyOf" in schema) {
72
+ if (!Array.isArray(json) || typeof json[0] !== "number") return json;
73
+ const member = schema.anyOf[json[0]];
74
+ return member === undefined ? json[1] : fromJson(member, json[1]);
75
+ }
76
+ if ("const" in schema) return schema.const;
77
+ if (!("type" in schema)) return json;
78
+ switch (schema.type) {
79
+ case "number": case "integer": return numberFromJson(json);
80
+ case "array": return Array.isArray(json) ? json.map(item => fromJson(schema.items, item)) : json;
81
+ case "set": return Array.isArray(json) ? new Set(json.map(item => fromJson(schema.items, item))) : json;
82
+ case "map": return Array.isArray(json)
83
+ ? new Map(json.map(entry => Array.isArray(entry) ? [fromJson(schema.keys, entry[0]), fromJson(schema.values, entry[1])] : [entry, undefined]))
84
+ : json;
85
+ case "object": {
86
+ if (typeof json !== "object" || json === null || Array.isArray(json)) return json;
87
+ const value: { [name: string]: unknown } = {};
88
+ for (const name of Object.keys(json)) {
89
+ const field = schema.properties[name];
90
+ const item: unknown = Reflect.get(json, name);
91
+ value[name] = field === undefined ? item : fromJson(field, item);
92
+ }
93
+ return value;
94
+ }
95
+ default: return json;
96
+ }
97
+ };