@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
@@ -1,18 +1,28 @@
1
1
  import type { World } from "@adobe/ecs";
2
2
  import type { Failure } from "./failure.js";
3
3
  import { contents, type Contents } from "./program/contents.js";
4
+ import { type NamingTrait } from "./program/program.js";
4
5
  import { type Selection } from "./selection/selection.js";
5
- /** The persisted part of some of a world's entities and its persisted resources, which `encode` and `spawn` take. */
6
+ /**
7
+ * What a trait covers of some of a world's entities and of its resources, which `encode` and `spawn` take, and the
8
+ * trait it was taken or read by.
9
+ */
6
10
  export type Snapshot = {
7
11
  readonly failure?: undefined;
8
12
  readonly [contents]: Contents;
9
13
  };
10
14
  /**
11
- * A snapshot of some of `world`'s entities, read on its thread: by default every entity holding a persisted
12
- * component, and the persisted resources; with `with` and `not`, those a query matches, and the resources unless
13
- * `resources` is false; with `root`, it and every entity it owns, through persisted owner references, without
14
- * 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.
15
24
  */
16
25
  export declare const snapshot: (input: {
17
26
  readonly world: World;
27
+ readonly by: NamingTrait;
18
28
  } & Selection) => Promise<Snapshot | Failure>;
@@ -1,10 +1,11 @@
1
1
  import { type Entity, type World } from "@adobe/ecs";
2
2
  import type { Failure } from "./failure.js";
3
+ import { type Matching } from "./match/match.js";
3
4
  import type { Snapshot } from "./snapshot.js";
4
5
  /**
5
6
  * What `spawn` made: how many of the snapshot's entities it didn't spawn, those owned by an entity outside it; and,
6
- * unless asked not to, `entities`, each of the snapshot's entities to the one spawned for it, or null for one it
7
- * didn't spawn.
7
+ * unless asked not to, `entities`, each of the snapshot's entities to the one spawned for it, the one `match` found
8
+ * for it, or null for one it didn't spawn.
8
9
  */
9
10
  export type Spawned<Entities extends boolean = true> = {
10
11
  readonly failure?: undefined;
@@ -13,21 +14,29 @@ export type Spawned<Entities extends boolean = true> = {
13
14
  readonly entities: ReadonlyMap<Entity, Entity | null>;
14
15
  });
15
16
  /**
16
- * Spawns an entity in `world` for each of the snapshot's, with its persisted components, and sets the persisted
17
- * resources, on the world's thread, as the world's references would leave them had the entities outside the snapshot
17
+ * Spawns an entity in `world` for each of the snapshot's, with the components its trait covers, and sets the resources
18
+ * it holds, on the world's thread, as the world's references would leave them had the entities outside the snapshot
18
19
  * despawned: each entity value naming one of the snapshot's names the one spawned for it; an entity owned, directly
19
20
  * or through others, by an entity outside the snapshot isn't spawned, which the result counts; a weak reference to
20
- * one is left out; and any other entity value naming one is `Entity.none`. With `entities: false`, the result leaves
21
- * out its map of entities, which costs about 85 ms and 50 MB at a million. Fails, as data, for a column or
22
- * resource that doesn't fit, spawning nothing.
21
+ * one is left out; and any other entity value naming one is `Entity.none`.
22
+ * - `match`: unique indexes saying which of the snapshot's entities are already in `world`: one an index holds there,
23
+ * with the key of one of the snapshot's it holds, stands for it, keeping its own values, and isn't spawned again. It
24
+ * fails for an index whose key, `with` or `not` names a component the snapshot's trait doesn't cover, or two of the
25
+ * snapshot's entities with one key.
26
+ * - `entities: false` leaves out the result's map of entities, which costs about 85 ms and 50 MB at a million.
27
+ *
28
+ * Fails, as data, for a column or resource that doesn't fit, spawning nothing.
23
29
  */
24
- export declare function spawn(input: {
25
- readonly world: World;
26
- readonly snapshot: Snapshot;
30
+ export declare function spawn(input: Spawning & {
27
31
  readonly entities: false;
28
32
  }): Promise<Spawned<false> | Failure>;
29
- export declare function spawn(input: {
30
- readonly world: World;
31
- readonly snapshot: Snapshot;
33
+ export declare function spawn(input: Spawning & {
32
34
  readonly entities?: true;
33
35
  }): Promise<Spawned | Failure>;
36
+ /** What `spawn` takes: the world spawned into, the snapshot, and the indexes saying which of its entities are there already. */
37
+ type Spawning = {
38
+ readonly world: World;
39
+ readonly snapshot: Snapshot;
40
+ readonly match?: readonly Matching[];
41
+ };
42
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adobe/ecs-persistence",
3
- "version": "0.0.1",
3
+ "version": "0.1.0",
4
4
  "description": "Saving, loading and copying the entities of @adobe/ecs worlds, as binary columns",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -19,13 +19,14 @@
19
19
  "LICENSE"
20
20
  ],
21
21
  "dependencies": {
22
- "@adobe/ecs": "0.2.3"
22
+ "@adobe/ecs": "0.2.4"
23
23
  },
24
24
  "devDependencies": {
25
25
  "@types/node": "^25.6.0",
26
26
  "typescript": "^5.8.3",
27
27
  "vite": "^8.3.4",
28
- "vitest": "^5.0.5"
28
+ "vitest": "^5.0.5",
29
+ "ecs-guide-checks": "0.0.0"
29
30
  },
30
31
  "exports": {
31
32
  ".": {
@@ -33,6 +34,12 @@
33
34
  "types": "./dist/types/index.d.ts"
34
35
  }
35
36
  },
37
+ "guide": {
38
+ "files": [
39
+ "src/**/*.ts",
40
+ "dist/**/*.js"
41
+ ]
42
+ },
36
43
  "scripts": {
37
44
  "build": "cp ../../LICENSE . && tsc -p tsconfig.build.json && vite build",
38
45
  "typecheck": "tsc && tsc -p tsconfig.build.json",
package/src/decode.ts CHANGED
@@ -2,14 +2,15 @@
2
2
  import type { Failure } from "./failure.js";
3
3
  import { decodeContents } from "./format/format.js";
4
4
  import { contents } from "./program/contents.js";
5
+ import type { NamingTrait } from "./program/program.js";
5
6
  import type { Snapshot } from "./snapshot.js";
6
7
 
7
8
  /**
8
- * The snapshot `bytes` holds, its columns of bytes viewed where they are in `bytes`, copied once only when `bytes`
9
- * doesn't start at a multiple of 8; or why it can't be read: it isn't one, or names a component or resource this
10
- * program doesn't persist, or persists with another schema.
9
+ * The snapshot `bytes` holds, read by the trait `by`, its columns of bytes viewed where they are in `bytes`, copied once
10
+ * only when `bytes` doesn't start at a multiple of 8; or why it can't be read: it isn't one, `by` isn't a symbol, or it
11
+ * names a component or resource the trait doesn't cover, or covers with another schema.
11
12
  */
12
- export const decode = (bytes: Uint8Array): Snapshot | Failure => {
13
- const decoded = decodeContents(bytes);
13
+ export const decode = (bytes: Uint8Array, options: { readonly by: NamingTrait }): Snapshot | Failure => {
14
+ const decoded = decodeContents(bytes, options.by);
14
15
  return "failure" in decoded ? decoded : { [contents]: decoded };
15
16
  };
package/src/encode.ts CHANGED
@@ -4,7 +4,7 @@ import { contents } from "./program/contents.js";
4
4
  import type { Snapshot } from "./snapshot.js";
5
5
 
6
6
  /**
7
- * A snapshot as bytes, little-endian: a header naming each persisted component and resource with its schema, then
8
- * 8-byte-aligned blocks of each table's entities and columns, its bytes columns copied whole.
7
+ * A snapshot as bytes, little-endian: a header naming each component and resource it holds by its trait's name for it,
8
+ * with its schema, then 8-byte-aligned blocks of each table's entities and columns, its bytes columns copied whole.
9
9
  */
10
10
  export const encode = (snapshot: Snapshot): Uint8Array => encodeContents(snapshot[contents]);
@@ -117,11 +117,11 @@ const headerOf = (json: unknown): Header | null => {
117
117
  const sameSchema = (a: Schema, b: Schema): boolean => JSON.stringify(a) === JSON.stringify(b);
118
118
 
119
119
  /**
120
- * The snapshot contents `bytes` holds, its components and resources by name as this program persists them, its blocks of
121
- * bytes viewed where they are; or why it can't be read: it isn't one, or names what this program doesn't persist, or
122
- * persists with another schema.
120
+ * The snapshot contents `bytes` holds, its components and resources by the names the trait `by` gives them, its blocks
121
+ * of bytes viewed where they are; or why it can't be read: it isn't one, or names what the trait doesn't cover, or
122
+ * covers with another schema.
123
123
  */
124
- export const decodeContents = (bytes: Uint8Array): Contents | Failure => {
124
+ export const decodeContents = (bytes: Uint8Array, by: unknown): Contents | Failure => {
125
125
  if (bytes.byteLength < prefix || magic.some((byte, at) => bytes[at] !== byte)) return { failure: "These bytes aren't a snapshot." };
126
126
  const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
127
127
  const found = view.getUint32(4, true);
@@ -136,19 +136,19 @@ export const decodeContents = (bytes: Uint8Array): Contents | Failure => {
136
136
  }
137
137
  const header = headerOf(json);
138
138
  if (header === null) return { failure: "This snapshot's header isn't one this package writes." };
139
- const program = programOf();
139
+ const program = programOf(by);
140
140
  if (typeof program === "string") return { failure: program };
141
141
  const components: Persisted<Component.Key>[] = [];
142
142
  for (const { name, schema } of header.components) {
143
143
  const persisted = program.components.get(name);
144
- if (persisted === undefined) return { failure: `This snapshot holds component "${name}", which this program doesn't persist.` };
144
+ if (persisted === undefined) return { failure: `This snapshot holds component "${name}", which the trait doesn't cover.` };
145
145
  if (!sameSchema(persisted.schema, schema)) return { failure: `Component "${name}" has another schema here than in this snapshot.` };
146
146
  components.push(persisted);
147
147
  }
148
148
  const resources: { persisted: Persisted<Resource.Key>; value: unknown }[] = [];
149
149
  for (const { name, schema, value } of header.resources) {
150
150
  const persisted = program.resources.get(name);
151
- if (persisted === undefined) return { failure: `This snapshot holds resource "${name}", which this program doesn't persist.` };
151
+ if (persisted === undefined) return { failure: `This snapshot holds resource "${name}", which the trait doesn't cover.` };
152
152
  if (!sameSchema(persisted.schema, schema)) return { failure: `Resource "${name}" has another schema here than in this snapshot.` };
153
153
  resources.push({ persisted, value: fromJson(schema, value) });
154
154
  }
@@ -181,5 +181,5 @@ export const decodeContents = (bytes: Uint8Array): Contents | Failure => {
181
181
  }
182
182
  tables.push({ count: table.count, entities: new Float64Array(blocks.buffer, base + table.entities, table.count), columns });
183
183
  }
184
- return { components, tables, resources };
184
+ return { by: program.by, components, tables, resources };
185
185
  };
@@ -0,0 +1,18 @@
1
+ # Saving and loading
2
+
3
+ Saved data names each component and resource by a name of its own, the same across versions and moves of its module.
4
+ A trait of the application's own gives it: [`file`](01-saving.ts#L5-L6), a `Symbol()`
5
+ [declared as a trait](01-saving.ts#L14-L16) whose value is a string, names a key on its declaration,
6
+ [`[file]: "position"`](01-saving.ts#L18), as it's saved in a file. Only data is saved, a value with a schema, and never
7
+ a derived component; `selected` here has no name, so it isn't saved.
8
+
9
+ - `snapshot({ world, by: file })` takes every entity holding a component the trait covers, those components alone,
10
+ and the resources it covers, as columns.
11
+ - `encode(snapshot)` writes them as bytes, and `decode(bytes, { by: file })` reads them back, viewing each column
12
+ where it is.
13
+ - `spawn({ world, snapshot })` spawns an entity for each saved one and sets the resources. Entity values name the
14
+ entities spawned for theirs: the archer [targets the tower](01-saving.ts#L29-L30) again. Its result's `entities`
15
+ maps each saved entity to its new one.
16
+
17
+ `snapshot`, `decode` and `spawn` each say why it didn't happen as `{ failure }`, rather than throwing, so
18
+ `result.failure !== undefined` tells which. `encode` takes a snapshot one of them gave and always gives its bytes.
@@ -0,0 +1,4 @@
1
+ saved bytes
2
+ tower at [ 4, 0, 2 ]
3
+ archer at [ 1, 0, 1 ] targets an entity
4
+ level 3 of 2 entities
@@ -0,0 +1,55 @@
1
+ // © 2026 Adobe. MIT License. See /LICENSE for details.
2
+ import { Entity, System, World, type Vec3 } from "@adobe/ecs";
3
+ import { decode, encode, snapshot, spawn } from "../index.js";
4
+
5
+ /** Saved in a file, under its name there. */
6
+ const file = Symbol();
7
+ const position = Symbol();
8
+ const name = Symbol();
9
+ const target = Symbol();
10
+ const selected = Symbol();
11
+ const level = Symbol();
12
+
13
+ declare module "@adobe/ecs" {
14
+ interface Traits<N> {
15
+ readonly [file]: string;
16
+ }
17
+ interface Components {
18
+ [position]: { type: Vec3; [file]: "position" };
19
+ [name]: { type: string; [file]: "name" };
20
+ [target]: { type: Entity; reference: "weak"; [file]: "target" };
21
+ [selected]: { type: true };
22
+ }
23
+ interface Resources {
24
+ [level]: { type: number; default: 1; [file]: "level" };
25
+ }
26
+ }
27
+
28
+ const build = System.create(({ commands, resources }) => {
29
+ const tower = commands.spawn([name, position, selected])({ [name]: "tower", [position]: [4, 0, 2] });
30
+ commands.spawn([name, position, target])({ [name]: "archer", [position]: [1, 0, 1], [target]: tower });
31
+ resources[level] = 3;
32
+ });
33
+
34
+ const report = System.create({ readonly: true, components: { with: [name, position], optional: [target, selected] } }, row => {
35
+ const notes = [...row[target] === undefined ? [] : ["targets an entity"], ...row[selected] === undefined ? [] : ["selected"]];
36
+ console.log(row[name], "at", row[position], ...notes);
37
+ });
38
+ const levelOf = System.create({ readonly: true }, ({ resources }) => resources[level]);
39
+
40
+ export const main = async (): Promise<void> => {
41
+ await using world = World.create();
42
+ await world.run(build);
43
+ const taken = await snapshot({ world, by: file });
44
+ if (taken.failure !== undefined) return console.log(taken.failure);
45
+ const bytes = encode(taken);
46
+ console.log("saved", bytes.length > 0 ? "bytes" : "nothing");
47
+
48
+ await using loaded = World.create();
49
+ const read = decode(bytes, { by: file });
50
+ if (read.failure !== undefined) return console.log(read.failure);
51
+ const spawned = await spawn({ world: loaded, snapshot: read });
52
+ if (spawned.failure !== undefined) return console.log(spawned.failure);
53
+ await loaded.run(report);
54
+ console.log("level", await loaded.run(levelOf), "of", spawned.entities.size, "entities");
55
+ };
@@ -0,0 +1,15 @@
1
+ # Copying between worlds
2
+
3
+ Copying entities from one world into another is a snapshot spawned there, without bytes.
4
+ `snapshot({ world, by: file, root })` takes [`root`](02-copying.ts#L51) and every entity it owns, directly or through
5
+ others, by owner references its trait covers; `with` and `not` take the entities a query matches.
6
+
7
+ - **Entities outside:** an entity value naming one that's left out does what despawning it would. A copied entity
8
+ [owned by one left behind](02-copying.ts#L65-L67) isn't spawned, nor anything it owns, and `dropped` counts them; a
9
+ weak reference to one is left out; any other value naming one is `Entity.none`.
10
+ - **The new entities:** `entities` maps each copied entity to its copy. `entities: false` leaves the map out, for a
11
+ large copy that doesn't need it.
12
+ - **Merging:** `match` takes unique indexes saying which entities are the same in both worlds: one an index holds in
13
+ the world, with the key of one copied, stands for it and keeps its own values. Copying the fleet into the harbor
14
+ again with [`match: [byLabel]`](02-copying.ts#L59) copies nothing twice. Each part of a key must be one the
15
+ snapshot's trait covers, and two copied entities with one key fail the merge.
@@ -0,0 +1,4 @@
1
+ harbor holds [ 'hull, owned', 'mast, owned', 'ship' ]
2
+ entities gives the harbor's ship for the ship: true
3
+ harbor holds, merged [ 'hull, owned', 'mast, owned', 'ship' ]
4
+ dock holds [] and dropped 2
@@ -0,0 +1,70 @@
1
+ // © 2026 Adobe. MIT License. See /LICENSE for details.
2
+ import { Entity, Index, System, World } from "@adobe/ecs";
3
+ import { snapshot, spawn } from "../index.js";
4
+
5
+ /** Saved in a file, under its name there. */
6
+ const file = Symbol();
7
+ const label = Symbol();
8
+ const parent = Symbol();
9
+
10
+ declare module "@adobe/ecs" {
11
+ interface Traits<N> {
12
+ readonly [file]: string;
13
+ }
14
+ interface Components {
15
+ [label]: { type: string; [file]: "label" };
16
+ [parent]: { type: Entity; reference: "owner"; [file]: "parent" };
17
+ }
18
+ }
19
+
20
+ /** Labels are unique here, so an entity with one is the same in every world. */
21
+ const byLabel = Index.create({ key: [label], unique: true });
22
+
23
+ let ship = Entity.none;
24
+ let hull = Entity.none;
25
+
26
+ const build = System.create(({ commands }) => {
27
+ ship = commands.spawn([label])({ [label]: "ship" });
28
+ hull = commands.spawn([label, parent])({ [label]: "hull", [parent]: ship });
29
+ commands.spawn([label, parent])({ [label]: "mast", [parent]: hull });
30
+ commands.spawn([label])({ [label]: "island" });
31
+ });
32
+
33
+ const labels: string[] = [];
34
+ let shipFound = Entity.none;
35
+ const list = System.create({ readonly: true, components: { with: [label], optional: [parent] } }, row => {
36
+ labels.push(row[parent] === undefined ? row[label] : `${row[label]}, owned`);
37
+ if (row[label] === "ship") shipFound = row.entity;
38
+ });
39
+ const listed = async (world: World): Promise<readonly string[]> => {
40
+ labels.length = 0;
41
+ await world.run(list);
42
+ return [...labels].sort();
43
+ };
44
+
45
+ export const main = async (): Promise<void> => {
46
+ await using world = World.create();
47
+ await world.run(build);
48
+
49
+ // The ship and everything it owns, through owner references the trait covers, copied into another world.
50
+ await using harbor = World.create();
51
+ const fleet = await snapshot({ world, by: file, root: ship });
52
+ if (fleet.failure !== undefined) return console.log(fleet.failure);
53
+ const copied = await spawn({ world: harbor, snapshot: fleet });
54
+ if (copied.failure !== undefined) return console.log(copied.failure);
55
+ console.log("harbor holds", await listed(harbor));
56
+ console.log("entities gives the harbor's ship for the ship:", copied.entities.get(ship) === shipFound);
57
+
58
+ // The fleet again, matched by label: the harbor's own stand for its entities, so none is copied twice.
59
+ const merged = await spawn({ world: harbor, snapshot: fleet, match: [byLabel] });
60
+ if (merged.failure !== undefined) return console.log(merged.failure);
61
+ console.log("harbor holds, merged", await listed(harbor));
62
+
63
+ // The hull's subtree, its owner left behind: copied, the hull would belong to no one, so neither it nor its mast is.
64
+ await using dock = World.create();
65
+ const hullOnly = await snapshot({ world, by: file, root: hull });
66
+ if (hullOnly.failure !== undefined) return console.log(hullOnly.failure);
67
+ const docked = await spawn({ world: dock, snapshot: hullOnly, entities: false });
68
+ if (docked.failure !== undefined) return console.log(docked.failure);
69
+ console.log("dock holds", await listed(dock), "and dropped", docked.dropped);
70
+ };
@@ -0,0 +1,15 @@
1
+ # Saving chunks
2
+
3
+ A world split into chunks by a [shared component](03-chunks.ts#L16), `shared: true`, holds each chunk's entities in archetypes of their
4
+ own. `snapshot({ world, by, where })` takes the entities of the archetypes holding `where`'s values, as a system's `where`
5
+ of values keeps it to them: [`where: { [chunk]: [2, 0] }`](03-chunks.ts#L57) reads chunk 2's archetype whole and
6
+ nothing else, so a chunk's snapshot costs what the chunk holds, not the world.
7
+
8
+ - **Values only:** `where` names shared components, each with a value of its type, which the archetype must hold.
9
+ Naming another component is a type error, and fails the snapshot as data.
10
+ - **Unloading:** [`commands.despawnWhere`](03-chunks.ts#L32-L35) with a `where` of the chunk's coordinates despawns
11
+ its entities by one command, as a snapshot's `where` takes them.
12
+ - **Loading back:** `spawn` puts each entity into the archetype of its saved shared values, so the chunk
13
+ [unloaded and loaded again](03-chunks.ts#L62-L66) is back where it was.
14
+ - **Loading what's partly there:** with [`match: [byTree]`](03-chunks.ts#L72), the trees still standing keep their entities and
15
+ only those cut down are spawned again.
@@ -0,0 +1,3 @@
1
+ unloaded 0:0 0:1 0:2 1:10 1:11 1:12 3:30 3:31 3:32
2
+ loaded 0:0 0:1 0:2 1:10 1:11 1:12 2:20 2:21 2:22 3:30 3:31 3:32
3
+ merged 0:0 0:1 0:2 1:10 1:11 1:12 2:20 2:21 2:22 3:30 3:31 3:32
@@ -0,0 +1,75 @@
1
+ // © 2026 Adobe. MIT License. See /LICENSE for details.
2
+ import { Index, System, World, type Vec2 } from "@adobe/ecs";
3
+ import { decode, encode, snapshot, spawn } from "../index.js";
4
+
5
+ /** Saved in a file, under its name there. */
6
+ const file = Symbol();
7
+ const chunk = Symbol();
8
+ const tree = Symbol();
9
+
10
+ declare module "@adobe/ecs" {
11
+ interface Traits<N> {
12
+ readonly [file]: string;
13
+ }
14
+ interface Components {
15
+ // Shared: each chunk's entities are an archetype of their own, holding its coordinates once.
16
+ [chunk]: { type: Vec2.I32; shared: true; [file]: "chunk" };
17
+ [tree]: { type: number; [file]: "tree" };
18
+ }
19
+ }
20
+
21
+ /** Trees are numbered across the map, so one with a number is the same tree in every world. */
22
+ const byTree = Index.create({ key: [tree], unique: true });
23
+
24
+ /** Three trees in each of four chunks, `x` 0 to 3. */
25
+ const grow = System.create(({ commands }) => {
26
+ for (let x = 0; x < 4; x++) {
27
+ const plant = commands.spawn([chunk, tree], { [chunk]: [x, 0] });
28
+ for (let k = 0; k < 3; k++) plant({ [tree]: 10 * x + k });
29
+ }
30
+ });
31
+
32
+ /** Unloads chunk `x`: despawns the trees of the archetype holding its coordinates, by one command. */
33
+ const unload = System.create({}, ({ commands }, input: { readonly x: number }) => {
34
+ commands.despawnWhere({ with: [tree], where: { [chunk]: [input.x, 0] } });
35
+ });
36
+
37
+ /** Cuts the trees of chunk 2 down: those from `from` on. */
38
+ const cut = System.create({ components: { with: [chunk, tree] } }, (row, { commands }, input: { readonly from: number }) => {
39
+ if (row[chunk][0] === 2 && row[tree] % 10 >= input.from) commands.despawn(row.entity);
40
+ });
41
+
42
+ const trees: string[] = [];
43
+ const survey = System.create({ readonly: true, components: { with: [chunk, tree] } }, row => {
44
+ trees.push(`${row[chunk][0]}:${row[tree]}`);
45
+ });
46
+ const surveyed = async (world: World): Promise<string> => {
47
+ trees.length = 0;
48
+ await world.run(survey);
49
+ return [...trees].sort().join(" ");
50
+ };
51
+
52
+ export const main = async (): Promise<void> => {
53
+ await using world = World.create();
54
+ await world.run(grow);
55
+
56
+ // Chunk 2 alone: only the archetype holding its coordinates is read.
57
+ const saved = await snapshot({ world, by: file, where: { [chunk]: [2, 0] }, resources: false });
58
+ if (saved.failure !== undefined) return console.log(saved.failure);
59
+ const bytes = encode(saved);
60
+
61
+ // Unloaded, then loaded back from its bytes, into the archetype of its coordinates.
62
+ await world.run(unload, { x: 2 });
63
+ console.log("unloaded", await surveyed(world));
64
+ const read = decode(bytes, { by: file });
65
+ if (read.failure !== undefined) return console.log(read.failure);
66
+ const loaded = await spawn({ world, snapshot: read });
67
+ if (loaded.failure !== undefined) return console.log(loaded.failure);
68
+ console.log("loaded ", await surveyed(world));
69
+
70
+ // One tree cut since: loaded again with match, the two standing keep their entities and only the cut one returns.
71
+ await world.run(cut, { from: 2 });
72
+ const merged = await spawn({ world, snapshot: read, match: [byTree] });
73
+ if (merged.failure !== undefined) return console.log(merged.failure);
74
+ console.log("merged ", await surveyed(world));
75
+ };
@@ -0,0 +1,15 @@
1
+ # Documents and settings
2
+
3
+ An application saves to more than one place, each with a trait of its own: [`file`](04-settings.ts#L5-L8) for what a
4
+ document holds, its voxels, their materials and its size, and `settings` for the user's own choices, the tool and
5
+ whether a panel is open, which never go into a document. A key carries the trait of each place it's saved in, under the
6
+ name it has there, and a name only has to be unique among the keys one trait covers.
7
+
8
+ - **Taking each:** [`snapshot({ world, by: file })`](04-settings.ts#L46) takes the document, and
9
+ [`snapshot({ world, by: settings })`](04-settings.ts#L48) the settings. The settings
10
+ [are resources alone](04-settings.ts#L26-L27), so their snapshot holds no entity.
11
+ - **Kept apart:** `decode` reads bytes by the trait it's given, and
12
+ [refuses a name](04-settings.ts#L53-L57) the trait doesn't cover, so settings can't be loaded as a document, nor a
13
+ document as settings.
14
+ - **Loading each:** `spawn` takes no trait: a snapshot holds the one it was taken or read by. A document opened in
15
+ another world leaves that world's settings as they are.
@@ -0,0 +1,4 @@
1
+ settings as a document: This snapshot holds resource "tool", which the trait doesn't cover.
2
+ a document as settings: This snapshot holds component "voxel", which the trait doesn't cover.
3
+ opened: 2 voxels, size 32, tool select, panel closed
4
+ restored: 0 entities, size 16, tool brush, panel open
@@ -0,0 +1,75 @@
1
+ // © 2026 Adobe. MIT License. See /LICENSE for details.
2
+ import { System, World, type Vec3 } from "@adobe/ecs";
3
+ import { decode, encode, snapshot, spawn } from "../index.js";
4
+
5
+ /** Saved in the document's file, under its name there. */
6
+ const file = Symbol();
7
+ /** Saved in the user's own settings, under its name there. */
8
+ const settings = Symbol();
9
+ const voxel = Symbol();
10
+ const material = Symbol();
11
+ const size = Symbol();
12
+ const tool = Symbol();
13
+ const panelOpen = Symbol();
14
+
15
+ declare module "@adobe/ecs" {
16
+ interface Traits<N> {
17
+ readonly [file]: string;
18
+ readonly [settings]: string;
19
+ }
20
+ interface Components {
21
+ [voxel]: { type: Vec3.I32; [file]: "voxel" };
22
+ [material]: { type: string; [file]: "material" };
23
+ }
24
+ interface Resources {
25
+ [size]: { type: number; default: 16; [file]: "size" };
26
+ [tool]: { type: string; default: "select"; [settings]: "tool" };
27
+ [panelOpen]: { type: boolean; default: false; [settings]: "panel open" };
28
+ }
29
+ }
30
+
31
+ const edit = System.create(({ commands, resources }) => {
32
+ commands.spawn([voxel, material])({ [voxel]: [0, 0, 0], [material]: "stone" });
33
+ commands.spawn([voxel, material])({ [voxel]: [0, 1, 0], [material]: "grass" });
34
+ resources[size] = 32;
35
+ resources[tool] = "brush";
36
+ resources[panelOpen] = true;
37
+ });
38
+
39
+ const report = System.create({ readonly: true }, ({ resources }) => `size ${resources[size]}, tool ${resources[tool]}, panel ${resources[panelOpen] ? "open" : "closed"}`);
40
+ let voxels = 0;
41
+ const countVoxels = System.create({ readonly: true, components: { with: [voxel] } }, () => { voxels++; });
42
+
43
+ export const main = async (): Promise<void> => {
44
+ await using world = World.create();
45
+ await world.run(edit);
46
+ const drawing = await snapshot({ world, by: file });
47
+ if (drawing.failure !== undefined) return console.log(drawing.failure);
48
+ const preferences = await snapshot({ world, by: settings });
49
+ if (preferences.failure !== undefined) return console.log(preferences.failure);
50
+ const fileBytes = encode(drawing);
51
+ const settingsBytes = encode(preferences);
52
+
53
+ // Each kind of bytes loads only as itself.
54
+ const settingsAsDocument = decode(settingsBytes, { by: file });
55
+ console.log("settings as a document:", settingsAsDocument.failure ?? "loaded");
56
+ const fileAsSettings = decode(fileBytes, { by: settings });
57
+ console.log("a document as settings:", fileAsSettings.failure ?? "loaded");
58
+
59
+ // A document opened on another machine, with that user's own settings.
60
+ await using opened = World.create();
61
+ const read = decode(fileBytes, { by: file });
62
+ if (read.failure !== undefined) return console.log(read.failure);
63
+ const spawned = await spawn({ world: opened, snapshot: read });
64
+ if (spawned.failure !== undefined) return console.log(spawned.failure);
65
+ await opened.run(countVoxels);
66
+ console.log("opened:", voxels, "voxels,", await opened.run(report));
67
+
68
+ // The settings alone, in a world of their own: resources, no entities.
69
+ await using restored = World.create();
70
+ const own = decode(settingsBytes, { by: settings });
71
+ if (own.failure !== undefined) return console.log(own.failure);
72
+ const applied = await spawn({ world: restored, snapshot: own });
73
+ if (applied.failure !== undefined) return console.log(applied.failure);
74
+ console.log("restored:", applied.entities.size, "entities,", await restored.run(report));
75
+ };
@@ -0,0 +1,10 @@
1
+ # Guide
2
+
3
+ How to save, load and copy worlds' entities with `@adobe/ecs-persistence`, in chapters, each its prose, `NN-name.md`,
4
+ and a program, `NN-name.ts`, whose output is `NN-name.out`. The [interactive guide](../../../ecs-guide) shows them after
5
+ the ECS's own chapters.
6
+
7
+ 1. [Saving and loading](01-saving.md)
8
+ 2. [Copying between worlds](02-copying.md)
9
+ 3. [Saving chunks](03-chunks.md)
10
+ 4. [Documents and settings](04-settings.md)
package/src/index.ts CHANGED
@@ -1,12 +1,11 @@
1
1
  // © 2026 Adobe. MIT License. See /LICENSE for details.
2
2
  /**
3
- * Saving, loading and copying the entities of `@adobe/ecs` worlds: snapshots of their persisted components and
4
- * resources, as binary columns.
3
+ * Saving, loading and copying the entities of `@adobe/ecs` worlds: snapshots of the components and resources a trait of
4
+ * the application's own covers, as binary columns.
5
5
  * @module @adobe/ecs-persistence
6
6
  */
7
7
  export { decode } from "./decode.js";
8
8
  export { encode } from "./encode.js";
9
9
  export type { Failure } from "./failure.js";
10
- export { persist } from "./persist.js";
11
10
  export { snapshot, type Snapshot } from "./snapshot.js";
12
11
  export { spawn, type Spawned } from "./spawn.js";