@adobe/ecs-persistence 0.0.0-stage → 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 (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +89 -2
  3. package/dist/index.js +754 -0
  4. package/dist/index.js.map +1 -0
  5. package/dist/types/decode.d.ts +11 -0
  6. package/dist/types/encode.d.ts +6 -0
  7. package/dist/types/failure.d.ts +4 -0
  8. package/dist/types/format/format.d.ts +10 -0
  9. package/dist/types/guide/01-saving.d.ts +40 -0
  10. package/dist/types/guide/02-copying.d.ts +23 -0
  11. package/dist/types/guide/03-chunks.d.ts +23 -0
  12. package/dist/types/guide/04-settings.d.ts +45 -0
  13. package/dist/types/index.d.ts +10 -0
  14. package/dist/types/match/keys.d.ts +36 -0
  15. package/dist/types/match/match.d.ts +13 -0
  16. package/dist/types/persist.d.ts +14 -0
  17. package/dist/types/program/contents.d.ts +19 -0
  18. package/dist/types/program/program.d.ts +21 -0
  19. package/dist/types/selection/selection.d.ts +24 -0
  20. package/dist/types/snapshot.d.ts +28 -0
  21. package/dist/types/spawn.d.ts +42 -0
  22. package/dist/types/values/values.d.ts +5 -0
  23. package/package.json +46 -3
  24. package/src/decode.ts +16 -0
  25. package/src/encode.ts +10 -0
  26. package/src/failure.ts +4 -0
  27. package/src/format/format.ts +185 -0
  28. package/src/guide/01-saving.md +18 -0
  29. package/src/guide/01-saving.out +4 -0
  30. package/src/guide/01-saving.ts +55 -0
  31. package/src/guide/02-copying.md +15 -0
  32. package/src/guide/02-copying.out +4 -0
  33. package/src/guide/02-copying.ts +70 -0
  34. package/src/guide/03-chunks.md +15 -0
  35. package/src/guide/03-chunks.out +3 -0
  36. package/src/guide/03-chunks.ts +75 -0
  37. package/src/guide/04-settings.md +15 -0
  38. package/src/guide/04-settings.out +4 -0
  39. package/src/guide/04-settings.ts +75 -0
  40. package/src/guide/README.md +10 -0
  41. package/src/index.ts +11 -0
  42. package/src/match/keys.ts +105 -0
  43. package/src/match/match.ts +165 -0
  44. package/src/program/contents.ts +19 -0
  45. package/src/program/program.ts +45 -0
  46. package/src/selection/selection.ts +110 -0
  47. package/src/snapshot.ts +30 -0
  48. package/src/spawn.ts +79 -0
  49. package/src/values/values.ts +97 -0
package/src/decode.ts ADDED
@@ -0,0 +1,16 @@
1
+ // © 2026 Adobe. MIT License. See /LICENSE for details.
2
+ import type { Failure } from "./failure.js";
3
+ import { decodeContents } from "./format/format.js";
4
+ import { contents } from "./program/contents.js";
5
+ import type { NamingTrait } from "./program/program.js";
6
+ import type { Snapshot } from "./snapshot.js";
7
+
8
+ /**
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.
12
+ */
13
+ export const decode = (bytes: Uint8Array, options: { readonly by: NamingTrait }): Snapshot | Failure => {
14
+ const decoded = decodeContents(bytes, options.by);
15
+ return "failure" in decoded ? decoded : { [contents]: decoded };
16
+ };
package/src/encode.ts ADDED
@@ -0,0 +1,10 @@
1
+ // © 2026 Adobe. MIT License. See /LICENSE for details.
2
+ import { encodeContents } from "./format/format.js";
3
+ import { contents } from "./program/contents.js";
4
+ import type { Snapshot } from "./snapshot.js";
5
+
6
+ /**
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
+ */
10
+ export const encode = (snapshot: Snapshot): Uint8Array => encodeContents(snapshot[contents]);
package/src/failure.ts ADDED
@@ -0,0 +1,4 @@
1
+ // © 2026 Adobe. MIT License. See /LICENSE for details.
2
+
3
+ /** Why a snapshot, a decoding or a spawn didn't happen. */
4
+ export type Failure = { readonly failure: string };
@@ -0,0 +1,185 @@
1
+ // © 2026 Adobe. MIT License. See /LICENSE for details.
2
+ import type { Component, Resource, Schema, World } from "@adobe/ecs";
3
+ import type { Failure } from "../failure.js";
4
+ import type { Contents } from "../program/contents.js";
5
+ import { programOf, type Persisted } from "../program/program.js";
6
+ import { fromJson, toJson } from "../values/values.js";
7
+
8
+ /**
9
+ * The binary form of a snapshot, little-endian:
10
+ * - 16 bytes: `ECSP`, the format's version as a u32, the header's length in bytes as a u32, and 4 bytes of zeros;
11
+ * - the header, JSON text padded with spaces to a multiple of 8 bytes: each component and resource by name with its
12
+ * schema, and each table's count and where its entities and columns are;
13
+ * - blocks, each starting at a multiple of 8 bytes: a table's entities as f64s, a column's bytes as its components'
14
+ * layout holds them, and a column of values as JSON text.
15
+ * A loader views the blocks of bytes where they are, in the buffer it's given.
16
+ */
17
+
18
+ const magic = [0x45, 0x43, 0x53, 0x50];
19
+ const version = 1;
20
+ const prefix = 16;
21
+
22
+ /** Where a column's data is. */
23
+ type ColumnJson =
24
+ | { readonly kind: "bytes"; readonly offset: number; readonly length: number; readonly stride: number }
25
+ | { readonly kind: "values"; readonly offset: number; readonly length: number }
26
+ | { readonly kind: "value"; readonly value: unknown };
27
+
28
+ type Header = {
29
+ readonly components: readonly { readonly name: string; readonly schema: Schema }[];
30
+ readonly resources: readonly { readonly name: string; readonly schema: Schema; readonly value: unknown }[];
31
+ readonly tables: readonly { readonly count: number; readonly entities: number; readonly columns: readonly ({ readonly component: number } & ColumnJson)[] }[];
32
+ };
33
+
34
+ const aligned = (offset: number): number => Math.ceil(offset / 8) * 8;
35
+
36
+ const encoder = new TextEncoder();
37
+ const decoder = new TextDecoder();
38
+
39
+ /** A snapshot's contents as bytes. */
40
+ export const encodeContents = (snapshot: Contents): Uint8Array => {
41
+ const blocks: Uint8Array[] = [];
42
+ let offset = 0;
43
+ /** Places a block, returning its offset from the first block. */
44
+ const place = (bytes: Uint8Array): number => {
45
+ const at = offset;
46
+ blocks.push(bytes);
47
+ offset = aligned(offset + bytes.byteLength);
48
+ return at;
49
+ };
50
+ const indexOf = new Map(snapshot.components.map((persisted, index) => [persisted.key, index]));
51
+ const tables = snapshot.tables.map(table => {
52
+ const entities = place(new Uint8Array(table.entities.buffer, table.entities.byteOffset, table.entities.byteLength));
53
+ const columns = snapshot.components.flatMap((persisted): ({ readonly component: number } & ColumnJson)[] => {
54
+ const key: symbol = persisted.key;
55
+ const column: World.Table.Column | undefined = Reflect.get(table.columns, key);
56
+ const component = indexOf.get(persisted.key) ?? -1;
57
+ if (column === undefined) return [];
58
+ if (column.kind === "bytes") return [{ component, kind: "bytes", offset: place(column.bytes), length: column.bytes.byteLength, stride: column.stride }];
59
+ if (column.kind === "value") return [{ component, kind: "value", value: toJson(persisted.schema, column.value) }];
60
+ const text = encoder.encode(JSON.stringify(column.values.map(value => toJson(persisted.schema, value))));
61
+ return [{ component, kind: "values", offset: place(text), length: text.byteLength }];
62
+ });
63
+ return { count: table.count, entities, columns };
64
+ });
65
+ const header: Header = {
66
+ components: snapshot.components.map(({ name, schema }) => ({ name, schema })),
67
+ resources: snapshot.resources.map(({ persisted, value }) => ({ name: persisted.name, schema: persisted.schema, value: toJson(persisted.schema, value) })),
68
+ tables,
69
+ };
70
+ const text = encoder.encode(JSON.stringify(header));
71
+ const headerLength = aligned(text.byteLength);
72
+ const bytes = new Uint8Array(prefix + headerLength + offset);
73
+ bytes.set(magic, 0);
74
+ const view = new DataView(bytes.buffer);
75
+ view.setUint32(4, version, true);
76
+ view.setUint32(8, headerLength, true);
77
+ bytes.set(text, prefix);
78
+ bytes.fill(0x20, prefix + text.byteLength, prefix + headerLength);
79
+ let at = prefix + headerLength;
80
+ for (const block of blocks) {
81
+ bytes.set(block, at);
82
+ at = aligned(at + block.byteLength);
83
+ }
84
+ return bytes;
85
+ };
86
+
87
+ const isObject = (value: unknown): value is object => typeof value === "object" && value !== null;
88
+
89
+ /** The header's JSON, checked enough to read it without casts, or null. */
90
+ const headerOf = (json: unknown): Header | null => {
91
+ if (!isObject(json)) return null;
92
+ const components: unknown = Reflect.get(json, "components");
93
+ const resources: unknown = Reflect.get(json, "resources");
94
+ const tables: unknown = Reflect.get(json, "tables");
95
+ if (!Array.isArray(components) || !Array.isArray(resources) || !Array.isArray(tables)) return null;
96
+ const named = (item: unknown): boolean => isObject(item) && typeof Reflect.get(item, "name") === "string" && isObject(Reflect.get(item, "schema"));
97
+ if (!components.every(named) || !resources.every(named)) return null;
98
+ const isColumn = (item: unknown): boolean => {
99
+ if (!isObject(item) || typeof Reflect.get(item, "component") !== "number") return false;
100
+ const kind: unknown = Reflect.get(item, "kind");
101
+ if (kind === "value") return true;
102
+ const numbers = kind === "bytes" ? ["offset", "length", "stride"] : kind === "values" ? ["offset", "length"] : null;
103
+ return numbers !== null && numbers.every(name => typeof Reflect.get(item, name) === "number");
104
+ };
105
+ const isTable = (item: unknown): boolean => {
106
+ if (!isObject(item) || typeof Reflect.get(item, "count") !== "number" || typeof Reflect.get(item, "entities") !== "number") return false;
107
+ const columns: unknown = Reflect.get(item, "columns");
108
+ return Array.isArray(columns) && columns.every(isColumn);
109
+ };
110
+ if (!tables.every(isTable)) return null;
111
+ // Checked above: each item has the fields the header's type names, of their types.
112
+ const header = json as Header;
113
+ return header;
114
+ };
115
+
116
+ /** Whether two schemas are the same, by their JSON in their properties' order, which places a struct's fields. */
117
+ const sameSchema = (a: Schema, b: Schema): boolean => JSON.stringify(a) === JSON.stringify(b);
118
+
119
+ /**
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
+ */
124
+ export const decodeContents = (bytes: Uint8Array, by: unknown): Contents | Failure => {
125
+ if (bytes.byteLength < prefix || magic.some((byte, at) => bytes[at] !== byte)) return { failure: "These bytes aren't a snapshot." };
126
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
127
+ const found = view.getUint32(4, true);
128
+ if (found !== version) return { failure: `This snapshot's format is version ${found}; this package reads version ${version}.` };
129
+ const headerLength = view.getUint32(8, true);
130
+ let json: unknown;
131
+ try {
132
+ json = JSON.parse(decoder.decode(bytes.subarray(prefix, prefix + headerLength)));
133
+ }
134
+ catch {
135
+ return { failure: "This snapshot's header isn't JSON." };
136
+ }
137
+ const header = headerOf(json);
138
+ if (header === null) return { failure: "This snapshot's header isn't one this package writes." };
139
+ const program = programOf(by);
140
+ if (typeof program === "string") return { failure: program };
141
+ const components: Persisted<Component.Key>[] = [];
142
+ for (const { name, schema } of header.components) {
143
+ const persisted = program.components.get(name);
144
+ if (persisted === undefined) return { failure: `This snapshot holds component "${name}", which the trait doesn't cover.` };
145
+ if (!sameSchema(persisted.schema, schema)) return { failure: `Component "${name}" has another schema here than in this snapshot.` };
146
+ components.push(persisted);
147
+ }
148
+ const resources: { persisted: Persisted<Resource.Key>; value: unknown }[] = [];
149
+ for (const { name, schema, value } of header.resources) {
150
+ const persisted = program.resources.get(name);
151
+ if (persisted === undefined) return { failure: `This snapshot holds resource "${name}", which the trait doesn't cover.` };
152
+ if (!sameSchema(persisted.schema, schema)) return { failure: `Resource "${name}" has another schema here than in this snapshot.` };
153
+ resources.push({ persisted, value: fromJson(schema, value) });
154
+ }
155
+ // Blocks are 8-byte aligned in the buffer the format was written to; one given at another offset is copied once.
156
+ const blocks = (bytes.byteOffset + prefix + headerLength) % 8 === 0 ? bytes : bytes.slice();
157
+ const base = blocks.byteOffset + prefix + headerLength;
158
+ const end = blocks.byteOffset + blocks.byteLength;
159
+ const inBounds = (offset: number, length: number): boolean => offset >= 0 && length >= 0 && base + offset + length <= end;
160
+ const tables: World.Table[] = [];
161
+ for (const table of header.tables) {
162
+ if (!inBounds(table.entities, table.count * 8)) return { failure: "This snapshot's blocks are cut short." };
163
+ const columns: { [key: symbol]: World.Table.Column } = {};
164
+ for (const column of table.columns) {
165
+ const persisted = components[column.component];
166
+ if (persisted === undefined) return { failure: "This snapshot names a component it doesn't hold." };
167
+ if (column.kind === "value") columns[persisted.key] = { kind: "value", value: fromJson(persisted.schema, column.value) };
168
+ else if (!inBounds(column.offset, column.length)) return { failure: "This snapshot's blocks are cut short." };
169
+ else if (column.kind === "bytes") columns[persisted.key] = { kind: "bytes", bytes: new Uint8Array(blocks.buffer, base + column.offset, column.length), stride: column.stride };
170
+ else {
171
+ let values: unknown;
172
+ try {
173
+ values = JSON.parse(decoder.decode(new Uint8Array(blocks.buffer, base + column.offset, column.length)));
174
+ }
175
+ catch {
176
+ return { failure: `Component "${persisted.name}"'s values in this snapshot aren't JSON.` };
177
+ }
178
+ if (!Array.isArray(values)) return { failure: `Component "${persisted.name}"'s values in this snapshot aren't a list.` };
179
+ columns[persisted.key] = { kind: "values", values: values.map(value => fromJson(persisted.schema, value)) };
180
+ }
181
+ }
182
+ tables.push({ count: table.count, entities: new Float64Array(blocks.buffer, base + table.entities, table.count), columns });
183
+ }
184
+ return { by: program.by, components, tables, resources };
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 ADDED
@@ -0,0 +1,11 @@
1
+ // © 2026 Adobe. MIT License. See /LICENSE for details.
2
+ /**
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
+ * @module @adobe/ecs-persistence
6
+ */
7
+ export { decode } from "./decode.js";
8
+ export { encode } from "./encode.js";
9
+ export type { Failure } from "./failure.js";
10
+ export { snapshot, type Snapshot } from "./snapshot.js";
11
+ export { spawn, type Spawned } from "./spawn.js";