@adobe/ecs-persistence 0.0.1 → 0.1.0

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.
Files changed (44) hide show
  1. package/README.md +45 -27
  2. package/dist/index.js +318 -80
  3. package/dist/index.js.map +1 -1
  4. package/dist/types/decode.d.ts +7 -4
  5. package/dist/types/encode.d.ts +2 -2
  6. package/dist/types/format/format.d.ts +4 -4
  7. package/dist/types/guide/01-saving.d.ts +40 -0
  8. package/dist/types/guide/02-copying.d.ts +23 -0
  9. package/dist/types/guide/03-chunks.d.ts +23 -0
  10. package/dist/types/guide/04-settings.d.ts +45 -0
  11. package/dist/types/index.d.ts +2 -3
  12. package/dist/types/match/keys.d.ts +36 -0
  13. package/dist/types/match/match.d.ts +13 -0
  14. package/dist/types/program/contents.d.ts +5 -3
  15. package/dist/types/program/program.d.ts +12 -5
  16. package/dist/types/selection/selection.d.ts +10 -3
  17. package/dist/types/snapshot.d.ts +15 -5
  18. package/dist/types/spawn.d.ts +22 -13
  19. package/package.json +10 -3
  20. package/src/decode.ts +6 -5
  21. package/src/encode.ts +2 -2
  22. package/src/format/format.ts +8 -8
  23. package/src/guide/01-saving.md +18 -0
  24. package/src/guide/01-saving.out +4 -0
  25. package/src/guide/01-saving.ts +55 -0
  26. package/src/guide/02-copying.md +15 -0
  27. package/src/guide/02-copying.out +4 -0
  28. package/src/guide/02-copying.ts +70 -0
  29. package/src/guide/03-chunks.md +15 -0
  30. package/src/guide/03-chunks.out +3 -0
  31. package/src/guide/03-chunks.ts +75 -0
  32. package/src/guide/04-settings.md +15 -0
  33. package/src/guide/04-settings.out +4 -0
  34. package/src/guide/04-settings.ts +75 -0
  35. package/src/guide/README.md +10 -0
  36. package/src/index.ts +2 -3
  37. package/src/match/keys.ts +105 -0
  38. package/src/match/match.ts +165 -0
  39. package/src/program/contents.ts +5 -3
  40. package/src/program/program.ts +26 -15
  41. package/src/selection/selection.ts +22 -8
  42. package/src/snapshot.ts +16 -8
  43. package/src/spawn.ts +36 -17
  44. package/src/persist.ts +0 -15
@@ -0,0 +1,105 @@
1
+ // © 2026 Adobe. MIT License. See /LICENSE for details.
2
+
3
+ /** A table's key, a column of bytes for each part, `stride` bytes per row: a row's key is its bytes in each. */
4
+ export type KeyColumns = readonly { readonly bytes: Uint8Array; readonly stride: number }[];
5
+
6
+ /**
7
+ * Keys of rows of tables, each with a number: an open-addressing table of slots, linear probing, at most half full,
8
+ * each slot two words, a key's hash and its entry plus one, or 0 for none, so a probe reads its slots until a hash
9
+ * matches. An entry is a row of the tables it was added from, whose bytes are its key.
10
+ */
11
+ export type KeyTable = {
12
+ readonly mask: number;
13
+ readonly slots: Int32Array;
14
+ readonly tables: Int32Array;
15
+ readonly rows: Int32Array;
16
+ readonly values: Float64Array;
17
+ readonly columns: KeyColumns[];
18
+ count: number;
19
+ };
20
+
21
+ /** A table for up to `capacity` keys. */
22
+ export const createKeyTable = (capacity: number): KeyTable => {
23
+ let size = 16;
24
+ while (size < 2 * capacity) size *= 2;
25
+ return {
26
+ mask: size - 1,
27
+ slots: new Int32Array(2 * size),
28
+ tables: new Int32Array(capacity),
29
+ rows: new Int32Array(capacity),
30
+ values: new Float64Array(capacity),
31
+ columns: [],
32
+ count: 0,
33
+ };
34
+ };
35
+
36
+ /** FNV-1a over a row's bytes in each column, then mixed so its low bits, which pick a slot, depend on all of them. */
37
+ const hashOf = (columns: KeyColumns, row: number): number => {
38
+ let hash = 0x811c9dc5;
39
+ for (let c = 0; c < columns.length; c++) {
40
+ const { bytes, stride } = columns[c];
41
+ for (let at = row * stride, end = at + stride; at < end; at++) hash = Math.imul(hash ^ bytes[at], 0x01000193);
42
+ }
43
+ hash ^= hash >>> 16;
44
+ hash = Math.imul(hash, 0x85ebca6b);
45
+ hash ^= hash >>> 13;
46
+ hash = Math.imul(hash, 0xc2b2ae35);
47
+ return hash ^ (hash >>> 16);
48
+ };
49
+
50
+ const equalAt = (a: KeyColumns, rowA: number, b: KeyColumns, rowB: number): boolean => {
51
+ if (a.length !== b.length) return false;
52
+ for (let c = 0; c < a.length; c++) {
53
+ const { bytes: left, stride } = a[c];
54
+ const { bytes: right, stride: other } = b[c];
55
+ if (stride !== other) return false;
56
+ for (let k = 0, i = rowA * stride, j = rowB * stride; k < stride; k++) if (left[i + k] !== right[j + k]) return false;
57
+ }
58
+ return true;
59
+ };
60
+
61
+ /** The slot holding `row`'s key, or the empty one where it would go. */
62
+ const slotOf = (table: KeyTable, columns: KeyColumns, row: number, hash: number): number => {
63
+ const { mask, slots, tables, rows } = table;
64
+ for (let slot = hash & mask; ; slot = (slot + 1) & mask) {
65
+ const entry = slots[2 * slot + 1] - 1;
66
+ if (entry < 0) return slot;
67
+ if (slots[2 * slot] === hash && equalAt(table.columns[tables[entry]], rows[entry], columns, row)) return slot;
68
+ }
69
+ };
70
+
71
+ /**
72
+ * Begins adding the rows of a table of `columns`; `addKey` then takes rows of it. Each table's columns are kept, since
73
+ * its entries' keys are their rows' bytes.
74
+ */
75
+ export const addKeyColumns = (table: KeyTable, columns: KeyColumns): void => {
76
+ table.columns.push(columns);
77
+ };
78
+
79
+ /**
80
+ * Adds row `row` of the table `addKeyColumns` began last, with `value`, unless a key equal to its is there: whether
81
+ * it was added. Takes at most the capacity the table was created with.
82
+ */
83
+ export const addKey = (table: KeyTable, row: number, value: number): boolean => {
84
+ const at = table.columns.length - 1;
85
+ const columns = table.columns[at];
86
+ const hash = hashOf(columns, row);
87
+ const slot = slotOf(table, columns, row, hash);
88
+ if (table.slots[2 * slot + 1] !== 0) return false;
89
+ const entry = table.count++;
90
+ table.slots[2 * slot] = hash;
91
+ table.slots[2 * slot + 1] = entry + 1;
92
+ table.tables[entry] = at;
93
+ table.rows[entry] = row;
94
+ table.values[entry] = value;
95
+ return true;
96
+ };
97
+
98
+ /**
99
+ * The entry whose key is equal to row `row`'s of `columns`, or -1 for none. `guess` is an entry to compare first, such
100
+ * as the one after the entry found for the row before: rows in the order they were added are found without hashing.
101
+ */
102
+ export const findKey = (table: KeyTable, columns: KeyColumns, row: number, guess: number): number => {
103
+ if (guess >= 0 && guess < table.count && equalAt(table.columns[table.tables[guess]], table.rows[guess], columns, row)) return guess;
104
+ return table.slots[2 * slotOf(table, columns, row, hashOf(columns, row)) + 1] - 1;
105
+ };
@@ -0,0 +1,165 @@
1
+ // © 2026 Adobe. MIT License. See /LICENSE for details.
2
+ import { Index, System, type Component, type Schema, type World } from "@adobe/ecs";
3
+ import type { Failure } from "../failure.js";
4
+ import type { Contents } from "../program/contents.js";
5
+ import { toJson } from "../values/values.js";
6
+ import { addKey, addKeyColumns, createKeyTable, findKey, type KeyColumns } from "./keys.js";
7
+
8
+ /** An index whose entities are the same in two worlds where their keys are: a unique one. */
9
+ export type Matching = Index<{ readonly unique: true }>;
10
+
11
+ /** What a match reads of a world: the tables of the entities an index holds, with its key's columns. */
12
+ const keysSystemOf = (declaration: Index.Declared) => System.create({
13
+ readonly: true,
14
+ tables: { components: declaration.key, with: [...declaration.key, ...declaration.components.with ?? []], not: declaration.components.not ?? [] },
15
+ }, ({ tables }) => tables.archetypes);
16
+
17
+ /** A part of an index's key: its component, and that component's schema as the snapshot holds it. */
18
+ type Part = { readonly key: Component.Key; readonly schema: Schema };
19
+
20
+ /** Bytes as text, a character each, so equal bytes give equal text and a row's are a slice of its column's. */
21
+ const bytesDecoder = new TextDecoder("latin1");
22
+
23
+ /**
24
+ * Each row's key in a table, as text, or null for a table without a column of each part: each part's column, its bytes
25
+ * in the world's layout, which two worlds of one program share, or its value as JSON by its schema. Equal keys give
26
+ * equal text.
27
+ */
28
+ const keysOf = (table: World.Table, parts: readonly Part[]): ((row: number) => string) | null => {
29
+ const columns: ((row: number) => string)[] = [];
30
+ for (const { key, schema } of parts) {
31
+ const column = table.columns[key];
32
+ if (column === undefined) return null;
33
+ if (column.kind === "bytes") {
34
+ const text = bytesDecoder.decode(column.bytes);
35
+ const { stride } = column;
36
+ columns.push(row => text.slice(row * stride, (row + 1) * stride));
37
+ }
38
+ else if (column.kind === "values") columns.push(row => JSON.stringify(toJson(schema, column.values[row])));
39
+ else {
40
+ const text = JSON.stringify(toJson(schema, column.value));
41
+ columns.push(() => text);
42
+ }
43
+ }
44
+ const [only] = columns;
45
+ return columns.length === 1 && only !== undefined ? only : row => columns.map(part => part(row)).join("\u0000");
46
+ };
47
+
48
+ /** Whether a table holds the entities an index holds: every key part and `with` component, and none in `not`. */
49
+ const covers = (table: World.Table, declaration: Index.Declared): boolean =>
50
+ [...declaration.key, ...declaration.components.with ?? []].every(key => table.columns[key] !== undefined)
51
+ && (declaration.components.not ?? []).every(key => table.columns[key] === undefined);
52
+
53
+ /**
54
+ * Adds to `existing` each of the snapshot's entities an index holds, to the world's with its key, or fails, as data,
55
+ * for two of the snapshot's with one key; `name` is the index's.
56
+ */
57
+ type Matched = (name: string, existing: Map<number, number>) => Promise<Failure | undefined>;
58
+
59
+ const twoWithOneKey = (name: string): Failure => ({ failure: `Index "${name}" can't match: two of the snapshot's entities have one key.` });
60
+
61
+ const readWorld = (world: World, declaration: Index.Declared): Promise<readonly World.Table[] | Failure> =>
62
+ world.run(keysSystemOf(declaration)).catch((error: unknown) => ({ failure: error instanceof Error ? error.message : String(error) }));
63
+
64
+ /** Two of the world's entities with one key: the one with the lower entity index, as its unique index keeps. */
65
+ const keep = (existing: Map<number, number>, entity: number, candidate: number): void => {
66
+ const kept = existing.get(entity);
67
+ if (kept === undefined || (candidate >>> 0) < (kept >>> 0)) existing.set(entity, candidate);
68
+ };
69
+
70
+ /** A table's columns of each part, or null for one without a column of bytes for each. */
71
+ const bytesOf = (table: World.Table, parts: readonly Part[]): KeyColumns | null => {
72
+ const columns: KeyColumns[number][] = [];
73
+ for (const { key } of parts) {
74
+ const column = table.columns[key];
75
+ if (column === undefined || column.kind !== "bytes") return null;
76
+ columns.push(column);
77
+ }
78
+ return columns;
79
+ };
80
+
81
+ /**
82
+ * Matches by the bytes of keys' columns, without making text of them, when every table of the snapshot holding the
83
+ * index's entities has columns of bytes for its key; null otherwise. The world's tables, of the same program, then do too.
84
+ */
85
+ const matchBytes = (world: World, from: Contents, declaration: Index.Declared, parts: readonly Part[]): Matched | null => {
86
+ const tables = from.tables.filter(table => covers(table, declaration));
87
+ const columns = tables.map(table => bytesOf(table, parts));
88
+ if (columns.some(item => item === null)) return null;
89
+ return async (name, existing) => {
90
+ const saved = createKeyTable(tables.reduce((sum, table) => sum + table.count, 0));
91
+ for (let t = 0; t < tables.length; t++) {
92
+ const keys = columns[t];
93
+ if (keys === null) continue;
94
+ addKeyColumns(saved, keys);
95
+ const { entities, count } = tables[t];
96
+ for (let row = 0; row < count; row++) if (!addKey(saved, row, entities[row])) return twoWithOneKey(name);
97
+ }
98
+ if (saved.count === 0) return undefined;
99
+ const read = await readWorld(world, declaration);
100
+ if ("failure" in read) return read;
101
+ for (const table of read) {
102
+ const keys = bytesOf(table, parts);
103
+ if (keys === null) continue;
104
+ for (let row = 0, entry = -1; row < table.count; row++) {
105
+ entry = findKey(saved, keys, row, entry + 1);
106
+ if (entry >= 0) keep(existing, saved.values[entry], table.entities[row]);
107
+ }
108
+ }
109
+ return undefined;
110
+ };
111
+ };
112
+
113
+ /** Matches by keys as text, which any column gives. */
114
+ const matchText = (world: World, from: Contents, declaration: Index.Declared, parts: readonly Part[]): Matched => async (name, existing) => {
115
+ const saved = new Map<string, number>();
116
+ for (const table of from.tables) {
117
+ if (!covers(table, declaration)) continue;
118
+ const keyAt = keysOf(table, parts);
119
+ if (keyAt === null) continue;
120
+ for (let row = 0; row < table.count; row++) {
121
+ const key = keyAt(row);
122
+ if (saved.has(key)) return twoWithOneKey(name);
123
+ saved.set(key, table.entities[row]);
124
+ }
125
+ }
126
+ if (saved.size === 0) return undefined;
127
+ const read = await readWorld(world, declaration);
128
+ if ("failure" in read) return read;
129
+ for (const table of read) {
130
+ const keyAt = keysOf(table, parts);
131
+ if (keyAt === null) continue;
132
+ for (let row = 0; row < table.count; row++) {
133
+ const entity = saved.get(keyAt(row));
134
+ if (entity !== undefined) keep(existing, entity, table.entities[row]);
135
+ }
136
+ }
137
+ return undefined;
138
+ };
139
+
140
+ /**
141
+ * Each of the snapshot's entities that `indexes` say is the same as one of `world`'s, to that one: an index holds both,
142
+ * with equal keys. Fails, as data, for an index whose key, `with` or `not` names a component the snapshot's trait doesn't
143
+ * cover, or two of the snapshot's entities one index holds with one key.
144
+ */
145
+ export const existingFor = async (world: World, from: Contents, indexes: readonly Matching[]): Promise<ReadonlyMap<number, number> | Failure> => {
146
+ const existing = new Map<number, number>();
147
+ const persisted = new Map(from.components.map(item => [item.key, item]));
148
+ for (const index of indexes) {
149
+ const declaration = Index.declarationOf(index);
150
+ if (declaration === undefined) return { failure: `${index.name} isn't an index.` };
151
+ const parts: Part[] = [];
152
+ for (const key of declaration.key) {
153
+ const found = persisted.get(key);
154
+ if (found === undefined) return { failure: `Index "${index.name}" can't match: a part of its key isn't one the snapshot's trait covers, so the snapshot holds no key for it.` };
155
+ parts.push({ key, schema: found.schema });
156
+ }
157
+ if ([...declaration.components.with ?? [], ...declaration.components.not ?? []].some(key => !persisted.has(key))) {
158
+ return { failure: `Index "${index.name}" can't match: its with or not names a component the snapshot's trait doesn't cover, so the snapshot can't tell which of its entities the index holds.` };
159
+ }
160
+ const matched = matchBytes(world, from, declaration, parts) ?? matchText(world, from, declaration, parts);
161
+ const found = await matched(index.name, existing);
162
+ if (found !== undefined) return found;
163
+ }
164
+ return existing;
165
+ };
@@ -6,11 +6,13 @@ import type { Persisted } from "./program.js";
6
6
  export const contents: unique symbol = Symbol("snapshot contents");
7
7
 
8
8
  /**
9
- * The persisted part of some of a world's entities: of each archetype, its entities and a column of each persisted
10
- * component it holds, keyed by this program's keys; and the values of persisted resources.
9
+ * What a trait covers of some of a world's entities: of each archetype, its entities and a column of each component
10
+ * the trait covers that it holds, keyed by this program's keys; and the values of the resources it covers.
11
11
  */
12
12
  export type Contents = {
13
- /** Each persisted component a table of it may hold. */
13
+ /** The key of the trait it was taken or read by. */
14
+ readonly by: symbol;
15
+ /** Each component the trait covers that a table of it may hold. */
14
16
  readonly components: readonly Persisted<Component.Key>[];
15
17
  readonly tables: readonly World.Table[];
16
18
  readonly resources: readonly { readonly persisted: Persisted<Resource.Key>; readonly value: unknown }[];
@@ -1,34 +1,45 @@
1
1
  // © 2026 Adobe. MIT License. See /LICENSE for details.
2
- import { Component, Resource, type Schema } from "@adobe/ecs";
3
- import { persist } from "../persist.js";
2
+ import { Component, Resource, type Schema, type Traits } from "@adobe/ecs";
4
3
 
5
- /** A key persisted, with its name and schema. */
4
+ /** A key a trait covers, with the name the trait saves it under and its schema. */
6
5
  export type Persisted<K extends symbol = symbol> = { readonly name: string; readonly key: K; readonly schema: Schema };
7
6
 
8
- /** What the program persists: its components and resources by name. */
7
+ /** What a trait covers: its components and resources by name. */
9
8
  export type Program = {
9
+ readonly by: symbol;
10
10
  readonly components: ReadonlyMap<string, Persisted<Component.Key>>;
11
11
  readonly resources: ReadonlyMap<string, Persisted<Resource.Key>>;
12
12
  };
13
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 => {
14
+ type Keyed = Component.Key | Resource.Key;
15
+
16
+ /** A trait of an application's own whose value names a key: its key a symbol, its value a string. */
17
+ export type NamingTrait = { readonly [T in keyof Traits<Keyed>]: T extends symbol ? [Traits<Keyed>[T]] extends [string] ? T : never : never }[keyof Traits<Keyed>];
18
+
19
+ /**
20
+ * The keys of `declarations` with the trait `by`, by the name it gives each, or why one can't be saved: the trait's
21
+ * value isn't a string, or the key isn't data, is derived, or has a name another has.
22
+ */
23
+ const namedIn = <K extends symbol>(declarations: ReadonlyMap<K, Component.Declared>, kind: string, by: symbol): ReadonlyMap<string, Persisted<K>> | string => {
16
24
  const named = new Map<string, Persisted<K>>();
17
25
  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}".`;
26
+ const name: unknown = Reflect.get(declaration, by);
27
+ if (name === undefined) continue;
28
+ if (typeof name !== "string") return `A ${kind}'s value of the trait isn't a name: ${String(name)}.`;
29
+ if (declaration.type === undefined) return `The ${kind} the trait names "${name}" isn't data: it has no schema.`;
30
+ if (declaration.derived === true) return `The ${kind} the trait names "${name}" is derived, which its rule derives again once its entity is loaded.`;
31
+ if (named.has(name)) return `The trait names two ${kind}s "${name}".`;
22
32
  named.set(name, { name, key, schema: declaration.type });
23
33
  }
24
34
  return named;
25
35
  };
26
36
 
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");
37
+ /** What the trait `by` covers, as the modules registered so far declare it, or why it can't be saved. */
38
+ export const programOf = (by: unknown): Program | string => {
39
+ if (typeof by !== "symbol") return "`by` is the key of the trait to save by, a symbol.";
40
+ const components = namedIn(Component.declarations(), "component", by);
30
41
  if (typeof components === "string") return components;
31
- const resources = namedIn(Resource.declarations(), "resource");
42
+ const resources = namedIn(Resource.declarations(), "resource", by);
32
43
  if (typeof resources === "string") return resources;
33
- return { components, resources };
44
+ return { by, components, resources };
34
45
  };
@@ -4,19 +4,24 @@ import type { Failure } from "../failure.js";
4
4
  import type { Contents } from "../program/contents.js";
5
5
  import type { Program } from "../program/program.js";
6
6
 
7
+ /** Values of shared components, which archetypes hold one of each: `where` as a system's of values. */
8
+ type Where = { readonly [K in Component.With<"shared">]?: Component<K> };
9
+
7
10
  /**
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.
11
+ * Which entities a snapshot takes: those matching `with`, `not` and `where`, every one holding a component its trait
12
+ * covers by default; or `root` and every entity it owns, directly or through others, by owner references its trait
13
+ * covers.
10
14
  */
11
15
  export type Selection =
12
- | { readonly with?: readonly Component.Key[]; readonly not?: readonly Component.Key[]; readonly resources?: boolean }
16
+ | { readonly with?: readonly Component.Key[]; readonly not?: readonly Component.Key[]; readonly where?: Where; readonly resources?: boolean }
13
17
  | { readonly root: Entity };
14
18
 
15
- /** Which tables a snapshot reads: those of its persisted components, matching `with` and `not`, and its resources. */
19
+ /** Which tables a snapshot reads: those of the components its trait covers, matching `with`, `not` and `where`, and its resources. */
16
20
  type Reading = {
17
21
  readonly components: readonly Component.Key[];
18
22
  readonly with?: readonly Component.Key[];
19
23
  readonly not?: readonly Component.Key[];
24
+ readonly where?: Where;
20
25
  readonly resources?: readonly Resource.Key[];
21
26
  };
22
27
 
@@ -75,7 +80,7 @@ const subtreeOf = (tables: readonly World.Table[], owners: readonly Component.Ke
75
80
  return taken;
76
81
  };
77
82
 
78
- /** The persisted part of the entities `selection` takes from `world`, on its thread, or why it can't be read. */
83
+ /** What `program`'s trait covers of the entities `selection` takes from `world`, on its thread, or why it can't be read. */
79
84
  export const select = async (world: World, program: Program, selection: Selection): Promise<Contents | Failure> => {
80
85
  const components = [...program.components.values()];
81
86
  const keys = components.map(persisted => persisted.key);
@@ -83,8 +88,17 @@ export const select = async (world: World, program: Program, selection: Selectio
83
88
  const resources = subtree || selection.resources === false ? [] : [...program.resources.values()];
84
89
  const declaration: Reading = subtree
85
90
  ? { 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) }));
91
+ : { components: keys, with: selection.with ?? [], not: selection.not ?? [], where: selection.where ?? {}, resources: resources.map(persisted => persisted.key) };
92
+ const failed = (error: unknown): Failure => ({ failure: error instanceof Error ? error.message : String(error) });
93
+ let system: ReturnType<typeof tablesSystemOf>;
94
+ try {
95
+ system = tablesSystemOf(declaration);
96
+ }
97
+ catch (error) {
98
+ // A `where` naming what isn't a shared component, or giving one a value not of its type.
99
+ return failed(error);
100
+ }
101
+ const read = await world.run(system).catch(failed);
88
102
  if ("failure" in read) return read;
89
103
  let tables = read.archetypes;
90
104
  if (subtree) {
@@ -92,5 +106,5 @@ export const select = async (world: World, program: Program, selection: Selectio
92
106
  const taken = subtreeOf(tables, owners, selection.root);
93
107
  tables = tables.flatMap(table => rowsOf(table, entity => taken.has(entity)) ?? []);
94
108
  }
95
- return { components, tables, resources: resources.map(persisted => ({ persisted, value: read.resources[persisted.key] })) };
109
+ return { by: program.by, components, tables, resources: resources.map(persisted => ({ persisted, value: read.resources[persisted.key] })) };
96
110
  };
package/src/snapshot.ts CHANGED
@@ -2,20 +2,28 @@
2
2
  import type { World } from "@adobe/ecs";
3
3
  import type { Failure } from "./failure.js";
4
4
  import { contents, type Contents } from "./program/contents.js";
5
- import { programOf } from "./program/program.js";
5
+ import { programOf, type NamingTrait } from "./program/program.js";
6
6
  import { select, type Selection } from "./selection/selection.js";
7
7
 
8
- /** The persisted part of some of a world's entities and its persisted resources, which `encode` and `spawn` take. */
8
+ /**
9
+ * What a trait covers of some of a world's entities and of its resources, which `encode` and `spawn` take, and the
10
+ * trait it was taken or read by.
11
+ */
9
12
  export type Snapshot = { readonly failure?: undefined; readonly [contents]: Contents };
10
13
 
11
14
  /**
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.
15
+ * A snapshot of some of `world`'s entities, read on its thread, by the trait `by`: a trait of the application's own,
16
+ * whose value on a component's or resource's declaration is the name it's saved under. Of each entity it takes, it
17
+ * takes the components the trait covers. By default it takes every entity holding one of them, and the resources the
18
+ * trait covers; with `with` and `not`, those a query matches, and the resources unless `resources` is false; with
19
+ * `where`, those of the archetypes holding its values of shared components, as a system's `where` of values keeps it to
20
+ * them, read whole, so a snapshot of one value's entities costs what they hold, not the world; with `root`, it and every
21
+ * entity it owns, through owner references the trait covers, without resources. Fails, as data, when `by` isn't a
22
+ * symbol, what the trait covers can't be saved, `where` names what isn't a shared component or gives one a value not
23
+ * of its type, or the world can't be read.
16
24
  */
17
- export const snapshot = async (input: { readonly world: World } & Selection): Promise<Snapshot | Failure> => {
18
- const program = programOf();
25
+ export const snapshot = async (input: { readonly world: World; readonly by: NamingTrait } & Selection): Promise<Snapshot | Failure> => {
26
+ const program = programOf(input.by);
19
27
  if (typeof program === "string") return { failure: program };
20
28
  const taken = await select(input.world, program, input);
21
29
  return "failure" in taken ? taken : { [contents]: taken };
package/src/spawn.ts CHANGED
@@ -1,13 +1,14 @@
1
1
  // © 2026 Adobe. MIT License. See /LICENSE for details.
2
2
  import { System, type Entity, type World } from "@adobe/ecs";
3
3
  import type { Failure } from "./failure.js";
4
+ import { existingFor, type Matching } from "./match/match.js";
4
5
  import { contents } from "./program/contents.js";
5
6
  import type { Snapshot } from "./snapshot.js";
6
7
 
7
8
  /**
8
9
  * 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.
10
+ * unless asked not to, `entities`, each of the snapshot's entities to the one spawned for it, the one `match` found
11
+ * for it, or null for one it didn't spawn.
11
12
  */
12
13
  export type Spawned<Entities extends boolean = true> = { readonly failure?: undefined; readonly dropped: number }
13
14
  & (Entities extends false ? {} : { readonly entities: ReadonlyMap<Entity, Entity | null> });
@@ -18,34 +19,52 @@ const isTables = (value: unknown): value is readonly World.Table[] => Array.isAr
18
19
 
19
20
  const isKeyed = (value: unknown): value is { readonly [key: symbol]: unknown } => typeof value === "object" && value !== null;
20
21
 
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);
22
+ /** `spawnTables` takes entities by their numbers: a map of the snapshot's to the world's holds only numbers. */
23
+ const isEntities = (value: unknown): value is ReadonlyMap<Entity, Entity> => {
24
+ if (!(value instanceof Map)) return false;
25
+ for (const [saved, found] of value) if (typeof saved !== "number" || typeof found !== "number") return false;
26
+ return true;
27
+ };
28
+
29
+ /** Spawns its input's tables and sets its resources, a snapshot's, rewriting their entities, those `existing` maps standing for theirs. */
30
+ const spawnTables = System.create({}, ({ commands }, input: { readonly tables: unknown; readonly resources: unknown; readonly existing: unknown }) => {
31
+ if (!isTables(input.tables) || !isKeyed(input.resources) || !isEntities(input.existing)) throw new Error("A snapshot holds tables and resources.");
32
+ return commands.spawnTables(input.tables, { resources: input.resources, existing: input.existing });
25
33
  });
26
34
 
27
35
  /**
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
36
+ * Spawns an entity in `world` for each of the snapshot's, with the components its trait covers, and sets the resources
37
+ * it holds, on the world's thread, as the world's references would leave them had the entities outside the snapshot
30
38
  * despawned: each entity value naming one of the snapshot's names the one spawned for it; an entity owned, directly
31
39
  * 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.
40
+ * one is left out; and any other entity value naming one is `Entity.none`.
41
+ * - `match`: unique indexes saying which of the snapshot's entities are already in `world`: one an index holds there,
42
+ * with the key of one of the snapshot's it holds, stands for it, keeping its own values, and isn't spawned again. It
43
+ * fails for an index whose key, `with` or `not` names a component the snapshot's trait doesn't cover, or two of the
44
+ * snapshot's entities with one key.
45
+ * - `entities: false` leaves out the result's map of entities, which costs about 85 ms and 50 MB at a million.
46
+ *
47
+ * Fails, as data, for a column or resource that doesn't fit, spawning nothing.
35
48
  */
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];
49
+ export function spawn(input: Spawning & { readonly entities: false }): Promise<Spawned<false> | Failure>;
50
+ export function spawn(input: Spawning & { readonly entities?: true }): Promise<Spawned | Failure>;
51
+ export async function spawn(input: Spawning & { readonly entities?: boolean }): Promise<Spawned<boolean> | Failure> {
52
+ const from = input.snapshot[contents];
53
+ const { tables, resources } = from;
40
54
  const values: { [key: symbol]: unknown } = {};
41
55
  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) }));
56
+ const existing = input.match === undefined || input.match.length === 0 ? new Map<number, number>() : await existingFor(input.world, from, input.match);
57
+ if ("failure" in existing) return existing;
58
+ const spawned = await input.world.run(spawnTables, { tables, resources: values, existing }).catch((error: unknown) => ({ failure: error instanceof Error ? error.message : String(error) }));
43
59
  if ("failure" in spawned) return spawned;
44
60
  if (input.entities === false) return { dropped: spawned.dropped };
45
61
  return { dropped: spawned.dropped, entities: entitiesOf(tables, spawned.entities) };
46
62
  }
47
63
 
48
- /** Each of the tables' entities to the one spawned for it, or null for one that wasn't. */
64
+ /** What `spawn` takes: the world spawned into, the snapshot, and the indexes saying which of its entities are there already. */
65
+ type Spawning = { readonly world: World; readonly snapshot: Snapshot; readonly match?: readonly Matching[] };
66
+
67
+ /** Each of the tables' entities to the one spawned for it, the one existing for it, or null for one that wasn't. */
49
68
  const entitiesOf = (tables: readonly World.Table[], spawned: readonly Float64Array[]): ReadonlyMap<Entity, Entity | null> => {
50
69
  const entities = new Map<Entity, Entity | null>();
51
70
  tables.forEach((table, t) => {
package/src/persist.ts DELETED
@@ -1,15 +0,0 @@
1
- // © 2026 Adobe. MIT License. See /LICENSE for details.
2
- import type { Component, Components } from "@adobe/ecs";
3
-
4
- /**
5
- * The trait marking a component or resource to persist, its value the stable name saved data names it by: unlike its
6
- * key's id, the name stays the same across versions and moves of its module. Not on a derived component, which its
7
- * rule derives again once its entity is loaded.
8
- */
9
- export const persist = Symbol();
10
-
11
- declare module "@adobe/ecs" {
12
- interface Traits<N> {
13
- readonly [persist]: N extends Component.Key ? Components[N] extends { readonly derived: true } ? never : string : string;
14
- }
15
- }