@rohal12/spindle 0.52.8 → 0.53.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.
package/src/structural.ts CHANGED
@@ -2,12 +2,21 @@
2
2
  // and merging the differences between two versions of a value. Mutation
3
3
  // commits, history and save hooks all go through these, so they agree on
4
4
  // what a change is.
5
+ //
6
+ // They handle the value types a save holds (see serialize() in
7
+ // class-registry.ts): plain and prototype-less objects, arrays (holes kept),
8
+ // registered class instances, Date, RegExp, Map, Set, typed arrays,
9
+ // ArrayBuffer, DataView, URL, URLSearchParams, boxed primitives, errors and
10
+ // Temporal values, with shared references and cycles.
5
11
 
6
12
  import { registeredClassName } from './class-registry';
7
13
  import { hasOwn, setOwn } from './utils/namespace';
8
14
  import { deleteByPath, getByPath, setByPath } from './utils/object-path';
15
+ import { isAtomic, isBoxed, isTemporal } from './utils/value-kinds';
9
16
 
10
- // --- Deep Clone ---
17
+ export { isAtomic };
18
+
19
+ // --- Value kinds ---
11
20
 
12
21
  function isPlainObject(value: unknown): value is Record<string, unknown> {
13
22
  if (typeof value !== 'object' || value === null) return false;
@@ -15,6 +24,11 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
15
24
  return proto === Object.prototype || proto === null;
16
25
  }
17
26
 
27
+ /** Own keys of an error that are not enumerable but are data. */
28
+ const ERROR_HIDDEN_KEYS = ['message', 'cause', 'errors'] as const;
29
+
30
+ // --- Deep Clone ---
31
+
18
32
  export interface DeepCloneOptions {
19
33
  /**
20
34
  * Return instances of unregistered classes (DOM nodes, promises, other
@@ -24,31 +38,85 @@ export interface DeepCloneOptions {
24
38
  keepUnregistered?: boolean;
25
39
  }
26
40
 
41
+ type TypedArrayCtor = new (
42
+ buffer: ArrayBufferLike,
43
+ byteOffset: number,
44
+ length: number,
45
+ ) => ArrayBufferView;
46
+
27
47
  export function deepClone<T>(value: T, options: DeepCloneOptions = {}): T {
28
48
  const seen = new Map<object, object>();
29
49
 
50
+ /** Record `copy` as the copy of `obj` before its contents are copied. */
51
+ const keep = <C extends object>(obj: object, copy: C): C => {
52
+ seen.set(obj, copy);
53
+ return copy;
54
+ };
55
+
56
+ /** Copy the own (enumerable) keys of `obj` into `copy`. */
57
+ function copyKeys(obj: object, copy: object): object {
58
+ for (const key of Object.keys(obj)) {
59
+ setOwn(copy, key, clone((obj as Record<string, unknown>)[key]));
60
+ }
61
+ return copy;
62
+ }
63
+
64
+ function cloneBuiltin(val: object): object | undefined {
65
+ if (val instanceof Date) return keep(val, new Date(val.getTime()));
66
+ if (val instanceof RegExp) {
67
+ return keep(val, new RegExp(val.source, val.flags));
68
+ }
69
+ if (val instanceof ArrayBuffer) return keep(val, val.slice(0));
70
+ if (ArrayBuffer.isView(val)) {
71
+ // The buffer through the copies, so views of one buffer share it
72
+ const buffer = clone(val.buffer) as ArrayBuffer;
73
+ if (val instanceof DataView) {
74
+ return keep(val, new DataView(buffer, val.byteOffset, val.byteLength));
75
+ }
76
+ const ctor = val.constructor as TypedArrayCtor;
77
+ const length = (val as unknown as { length: number }).length;
78
+ return keep(val, new ctor(buffer, val.byteOffset, length));
79
+ }
80
+ if (val instanceof URL) return keep(val, new URL(val.href));
81
+ if (val instanceof URLSearchParams) {
82
+ return keep(val, new URLSearchParams(val));
83
+ }
84
+ if (isBoxed(val)) return keep(val, Object((val as Number).valueOf()));
85
+ if (isTemporal(val)) return val;
86
+ if (val instanceof Error) {
87
+ // Keeps its class (built-in, or a registered or other subclass)
88
+ const copy = keep(val, Object.create(Object.getPrototypeOf(val)));
89
+ for (const key of [...ERROR_HIDDEN_KEYS, 'stack']) {
90
+ if (hasOwn(val, key)) {
91
+ Object.defineProperty(copy, key, {
92
+ value: clone((val as unknown as Record<string, unknown>)[key]),
93
+ writable: true,
94
+ configurable: true,
95
+ });
96
+ }
97
+ }
98
+ return copyKeys(val, copy);
99
+ }
100
+ return undefined;
101
+ }
102
+
30
103
  function clone(val: unknown): unknown {
31
104
  if (val === null || typeof val !== 'object') return val;
32
105
 
33
106
  const obj = val as object;
34
107
  if (seen.has(obj)) return seen.get(obj);
35
108
 
36
- if (val instanceof Date) return new Date(val.getTime()) as unknown;
37
- if (val instanceof RegExp)
38
- return new RegExp(val.source, val.flags) as unknown;
39
-
40
109
  if (Array.isArray(val)) {
41
- const arr: unknown[] = [];
42
- seen.set(obj, arr);
110
+ // Holes stay holes
111
+ const arr = keep(obj, new Array(val.length) as unknown[]);
43
112
  for (let i = 0; i < val.length; i++) {
44
- arr[i] = clone(val[i]);
113
+ if (i in val) arr[i] = clone(val[i]);
45
114
  }
46
115
  return arr;
47
116
  }
48
117
 
49
118
  if (val instanceof Map) {
50
- const copy = new Map();
51
- seen.set(obj, copy);
119
+ const copy = keep(obj, new Map());
52
120
  for (const [k, v] of val) {
53
121
  copy.set(clone(k), clone(v));
54
122
  }
@@ -56,30 +124,29 @@ export function deepClone<T>(value: T, options: DeepCloneOptions = {}): T {
56
124
  }
57
125
 
58
126
  if (val instanceof Set) {
59
- const copy = new Set();
60
- seen.set(obj, copy);
127
+ const copy = keep(obj, new Set());
61
128
  for (const v of val) {
62
129
  copy.add(clone(v));
63
130
  }
64
131
  return copy;
65
132
  }
66
133
 
134
+ const builtin = cloneBuiltin(obj);
135
+ if (builtin !== undefined) return builtin;
136
+
67
137
  // A registered class instance keeps its class, and a plain object its
68
138
  // prototype (which may be null). An instance of an unregistered class
69
139
  // is treated as plain, unless it is kept by reference.
70
140
  const keepsProto =
71
141
  registeredClassName(obj) !== undefined || isPlainObject(val);
72
142
  if (!keepsProto && options.keepUnregistered) return val;
73
- const copy = (
143
+ const copy = keep(
144
+ obj,
74
145
  keepsProto
75
146
  ? Object.create(Object.getPrototypeOf(obj) as object | null)
76
- : {}
77
- ) as Record<string, unknown>;
78
- seen.set(obj, copy);
79
- for (const key of Object.keys(obj)) {
80
- setOwn(copy, key, clone((obj as Record<string, unknown>)[key]));
81
- }
82
- return copy;
147
+ : {},
148
+ );
149
+ return copyKeys(obj, copy);
83
150
  }
84
151
 
85
152
  return clone(value) as T;
@@ -88,27 +155,179 @@ export function deepClone<T>(value: T, options: DeepCloneOptions = {}): T {
88
155
  // --- Deep Equal ---
89
156
 
90
157
  /**
91
- * Structural equality over the value types deepClone() supports: primitives,
92
- * arrays, plain objects, class instances, Date, RegExp, Map and Set (nested
93
- * at any depth). Map and Set entries are compared in insertion order. Arrays
94
- * are compared by length and index, so a hole equals an undefined element
95
- * (deepClone() and save/load turn holes into undefined elements).
158
+ * Structural equality over the value types deepClone() supports, nested at
159
+ * any depth and with cycles. Objects must have the same prototype. Map and
160
+ * Set entries are compared in insertion order, so reordering one is a
161
+ * change. Arrays are compared by length and index; a hole differs from an
162
+ * undefined element. Errors are compared by class, message, cause, errors
163
+ * and own keys (not their stack).
96
164
  */
97
165
  export function deepEqual(a: unknown, b: unknown): boolean {
98
- return equal(a, b, new Map());
166
+ return equal(a, b, loosePairs());
99
167
  }
100
168
 
101
169
  /**
102
- * `assumed` holds the pairs already being compared: meeting one again (a
103
- * cycle) assumes it equal. Every object may pair with several others, since
104
- * cycles of different lengths can still unfold to the same value.
170
+ * Called on meeting the pair (a, b) of objects to compare: true when the
171
+ * pair is already being compared (a cycle: assume it equal), false when it
172
+ * cannot be equal, undefined to compare it.
105
173
  */
106
- function equal(
107
- a: unknown,
108
- b: unknown,
109
- assumed: Map<object, Set<object>>,
110
- ): boolean {
111
- if (Object.is(a, b)) return true;
174
+ type Pairs = ((a: object, b: object) => boolean | undefined) & {
175
+ /** Identical objects are paired and compared too, not taken as equal. */
176
+ strict?: boolean;
177
+ };
178
+
179
+ /**
180
+ * Pairs for deepEqual(). Every object may pair with several others, since
181
+ * cycles of different lengths can still unfold to the same value, and
182
+ * shared references may equal distinct copies.
183
+ */
184
+ function loosePairs(): Pairs {
185
+ const assumed = new Map<object, Set<object>>();
186
+ return (a, b) => {
187
+ const pairs = assumed.get(a);
188
+ if (pairs?.has(b)) return true;
189
+ if (pairs) pairs.add(b);
190
+ else assumed.set(a, new Set([b]));
191
+ return undefined;
192
+ };
193
+ }
194
+
195
+ /**
196
+ * Pairs that also require the same reference structure: each object of one
197
+ * value pairs with exactly one of the other, so shared references and
198
+ * cycles are shared at the same places.
199
+ */
200
+ function strictPairs(): Pairs {
201
+ const ab = new Map<object, object>();
202
+ const ba = new Map<object, object>();
203
+ const pairs: Pairs = (a, b) => {
204
+ const known = ab.get(a);
205
+ if (known !== undefined) return known === b;
206
+ if (ba.has(b)) return false;
207
+ ab.set(a, b);
208
+ ba.set(b, a);
209
+ return undefined;
210
+ };
211
+ pairs.strict = true;
212
+ return pairs;
213
+ }
214
+
215
+ /**
216
+ * Whether `a` and `b` are equal as deepEqual() has it and also share
217
+ * references at the same places (a save of one loads as the other).
218
+ */
219
+ export function deepEqualStrict(a: unknown, b: unknown): boolean {
220
+ return equal(a, b, strictPairs());
221
+ }
222
+
223
+ /**
224
+ * `curr` with each part that equals (see deepEqualStrict) the part of
225
+ * `prev` at the same place replaced by `prev`'s: the history moments of a
226
+ * save then share what did not change between them, which a save stores
227
+ * once. Containers on the way to a change are copied; neither value is
228
+ * changed. Shared references and cycles within `curr` stay so.
229
+ */
230
+ export function shareEqual<T>(prev: unknown, curr: T): T {
231
+ const done = new Map<object, unknown>();
232
+ function share(p: unknown, c: unknown): unknown {
233
+ if (p === c || !isObjectValue(c) || !isObjectValue(p)) return c;
234
+ if (done.has(c)) return done.get(c);
235
+ if (deepEqualStrict(p, c)) {
236
+ done.set(c, p);
237
+ return p;
238
+ }
239
+ if (c instanceof Map && p instanceof Map) {
240
+ const out = new Map();
241
+ done.set(c, out);
242
+ for (const [k, v] of c) out.set(k, p.has(k) ? share(p.get(k), v) : v);
243
+ return out;
244
+ }
245
+ if (Array.isArray(c) && Array.isArray(p)) {
246
+ const out = new Array(c.length) as unknown[];
247
+ done.set(c, out);
248
+ for (let i = 0; i < c.length; i++) {
249
+ if (i in c) out[i] = i < p.length ? share(p[i], c[i]) : c[i];
250
+ }
251
+ return out;
252
+ }
253
+ if (mergesWith(p, c, false)) {
254
+ const out = Object.create(Object.getPrototypeOf(c) as object | null);
255
+ done.set(c, out);
256
+ const cr = c as Record<string, unknown>;
257
+ for (const key of Object.keys(cr)) {
258
+ setOwn(out, key, hasOwn(p, key) ? share(p[key], cr[key]) : cr[key]);
259
+ }
260
+ return out;
261
+ }
262
+ done.set(c, c);
263
+ return c;
264
+ }
265
+ return share(prev, curr) as T;
266
+ }
267
+
268
+ const isObjectValue = (v: unknown): v is object =>
269
+ typeof v === 'object' && v !== null;
270
+
271
+ function equalBytes(a: ArrayBufferView | ArrayBuffer, b: typeof a): boolean {
272
+ const x = ArrayBuffer.isView(a)
273
+ ? new Uint8Array(a.buffer, a.byteOffset, a.byteLength)
274
+ : new Uint8Array(a);
275
+ const y = ArrayBuffer.isView(b)
276
+ ? new Uint8Array(b.buffer, b.byteOffset, b.byteLength)
277
+ : new Uint8Array(b as ArrayBuffer);
278
+ if (x.length !== y.length) return false;
279
+ for (let i = 0; i < x.length; i++) if (x[i] !== y[i]) return false;
280
+ return true;
281
+ }
282
+
283
+ /** Equality of the content of atomic built-ins (see isAtomic) but Map/Set. */
284
+ function equalBuiltin(
285
+ a: object,
286
+ b: object,
287
+ assumed: Pairs,
288
+ ): boolean | undefined {
289
+ if (a instanceof Date) return Object.is(a.getTime(), (b as Date).getTime());
290
+ if (a instanceof RegExp) return String(a) === String(b);
291
+ if (ArrayBuffer.isView(a) || a instanceof ArrayBuffer) {
292
+ return equalBytes(a, b as ArrayBuffer);
293
+ }
294
+ if (a instanceof URL || a instanceof URLSearchParams) {
295
+ return String(a) === String(b);
296
+ }
297
+ if (isBoxed(a)) {
298
+ return Object.is((a as Number).valueOf(), (b as Number).valueOf());
299
+ }
300
+ if (isTemporal(a)) return String(a) === String(b);
301
+ if (a instanceof Error) {
302
+ return (
303
+ ERROR_HIDDEN_KEYS.every(
304
+ (key) =>
305
+ hasOwn(a, key) === hasOwn(b, key) &&
306
+ equal(
307
+ (a as unknown as Record<string, unknown>)[key],
308
+ (b as unknown as Record<string, unknown>)[key],
309
+ assumed,
310
+ ),
311
+ ) && equalKeys(a, b, assumed)
312
+ );
313
+ }
314
+ return undefined;
315
+ }
316
+
317
+ function equalKeys(a: object, b: object, assumed: Pairs): boolean {
318
+ const ao = a as Record<string, unknown>;
319
+ const bo = b as Record<string, unknown>;
320
+ const keys = Object.keys(ao);
321
+ if (keys.length !== Object.keys(bo).length) return false;
322
+ for (const key of keys) {
323
+ if (!hasOwn(bo, key) || !equal(ao[key], bo[key], assumed)) return false;
324
+ }
325
+ return true;
326
+ }
327
+
328
+ /** `assumed` tracks the pairs of objects met (see Pairs). */
329
+ function equal(a: unknown, b: unknown, assumed: Pairs): boolean {
330
+ if (Object.is(a, b) && !(assumed.strict && isObjectValue(a))) return true;
112
331
  if (
113
332
  a === null ||
114
333
  b === null ||
@@ -118,13 +337,9 @@ function equal(
118
337
  return false;
119
338
  }
120
339
  if (Object.getPrototypeOf(a) !== Object.getPrototypeOf(b)) return false;
121
- const pairs = assumed.get(a);
122
- if (pairs?.has(b)) return true;
123
- if (pairs) pairs.add(b);
124
- else assumed.set(a, new Set([b]));
340
+ const met = assumed(a, b);
341
+ if (met !== undefined) return met;
125
342
 
126
- if (a instanceof Date) return Object.is(a.getTime(), (b as Date).getTime());
127
- if (a instanceof RegExp) return String(a) === String(b);
128
343
  if (a instanceof Map || a instanceof Set) {
129
344
  const bc = b as Map<unknown, unknown> | Set<unknown>;
130
345
  if (a.size !== bc.size) return false;
@@ -140,42 +355,36 @@ function equal(
140
355
  return true;
141
356
  }
142
357
 
358
+ const builtin = equalBuiltin(a, b, assumed);
359
+ if (builtin !== undefined) return builtin;
360
+
143
361
  if (Array.isArray(a)) {
144
362
  const bc = b as unknown[];
145
363
  if (a.length !== bc.length) return false;
146
364
  for (let i = 0; i < a.length; i++) {
147
- if (!equal(a[i], bc[i], assumed)) return false;
365
+ if (i in a !== i in bc || !equal(a[i], bc[i], assumed)) return false;
148
366
  }
149
367
  return true;
150
368
  }
151
369
 
152
- const ao = a as Record<string, unknown>;
153
- const bo = b as Record<string, unknown>;
154
- const keys = Object.keys(ao);
155
- if (keys.length !== Object.keys(bo).length) return false;
156
- for (const key of keys) {
157
- if (!hasOwn(bo, key) || !equal(ao[key], bo[key], assumed)) return false;
158
- }
159
- return true;
370
+ return equalKeys(a, b, assumed);
160
371
  }
161
372
 
162
373
  // --- Structural diff ---
163
374
 
164
375
  /**
165
376
  * Objects merged property by property: plain objects and (registered)
166
- * class instances. Arrays, Map, Set, Date and RegExp are values that are
167
- * replaced as a whole, since their elements have no stable identity to
168
- * merge by (a shift moves every index).
377
+ * class instances. Arrays and atomic built-ins (Map, Set, Date, typed
378
+ * arrays, errors..., see isAtomic) are values that are replaced as a whole,
379
+ * since their elements have no stable identity to merge by (a shift moves
380
+ * every index) or their content is not in their keys.
169
381
  */
170
382
  export function isMergeable(value: unknown): value is Record<string, unknown> {
171
383
  return (
172
384
  typeof value === 'object' &&
173
385
  value !== null &&
174
386
  !Array.isArray(value) &&
175
- !(value instanceof Map) &&
176
- !(value instanceof Set) &&
177
- !(value instanceof Date) &&
178
- !(value instanceof RegExp)
387
+ !isAtomic(value)
179
388
  );
180
389
  }
181
390
 
package/src/styles.css CHANGED
@@ -118,6 +118,51 @@ tw-storydata {
118
118
  background: rgba(239, 83, 80, 0.1);
119
119
  }
120
120
 
121
+ /* Runtime errors shown on the page (see src/components/RuntimeErrors.tsx) */
122
+ .spindle-error-banners {
123
+ position: fixed;
124
+ top: 0.75em;
125
+ left: 50%;
126
+ z-index: 300;
127
+ display: flex;
128
+ flex-direction: column;
129
+ gap: 0.5em;
130
+ width: min(42em, calc(100% - 1.5em));
131
+ transform: translateX(-50%);
132
+ }
133
+
134
+ .spindle-error-banner {
135
+ position: relative;
136
+ padding: 0.75em 2.75em 0.75em 1em;
137
+ border: 1px solid #ef5350;
138
+ border-radius: 4px;
139
+ background: #2b1a1e;
140
+ color: #ffcdd2;
141
+ font-size: 0.9em;
142
+ line-height: 1.5;
143
+ box-shadow: 0 2px 12px rgba(0, 0, 0, 0.5);
144
+ }
145
+
146
+ .spindle-error-banner-message {
147
+ font-family: monospace;
148
+ color: #ef9a9a;
149
+ }
150
+
151
+ .spindle-error-banner-dismiss {
152
+ position: absolute;
153
+ top: 0.5em;
154
+ right: 0.5em;
155
+ background: transparent;
156
+ border: none;
157
+ color: inherit;
158
+ font-size: 1em;
159
+ cursor: pointer;
160
+ }
161
+
162
+ .spindle-error-banner-dismiss:hover {
163
+ color: #fff;
164
+ }
165
+
121
166
  .loading {
122
167
  text-align: center;
123
168
  padding: 2em;
@@ -1,5 +1,6 @@
1
- import { isDraft } from 'immer';
1
+ import { current as draftState, isDraft } from 'immer';
2
2
  import { hasOwn } from './namespace';
3
+ import { atomicName } from './value-kinds';
3
4
 
4
5
  /**
5
6
  * Traverse dot-path segments on an object and return the nested value.
@@ -29,22 +30,13 @@ const isArrayKey = (key: string): boolean =>
29
30
  * Built-ins whose content is not their properties: a property written into
30
31
  * one would be dropped by clones and saves (and by Immer, for Map and Set).
31
32
  */
32
- const builtinName = (value: object): string | undefined =>
33
- value instanceof Map
34
- ? 'a Map'
35
- : value instanceof Set
36
- ? 'a Set'
37
- : value instanceof Date
38
- ? 'a Date'
39
- : value instanceof RegExp
40
- ? 'a RegExp'
41
- : undefined;
33
+ const builtinName = atomicName;
42
34
 
43
35
  /**
44
36
  * Shallow copy that keeps the prototype, so a registered class instance
45
37
  * stays an instance of its class (with the same own keys deepClone copies).
46
38
  */
47
- function shallowCopy(value: object): Record<string, unknown> {
39
+ export function shallowCopy(value: object): Record<string, unknown> {
48
40
  const copy = Array.isArray(value)
49
41
  ? []
50
42
  : (Object.create(Object.getPrototypeOf(value) as object | null) as object);
@@ -120,6 +112,15 @@ export function deleteByPath(
120
112
  return;
121
113
  }
122
114
  const parent = walkToParent(root, segments, false);
115
+ if (Array.isArray(parent) && isDraft(parent) && segments.length > 1) {
116
+ // Immer turns `delete` of an array element into writing undefined: to
117
+ // leave a hole, as `delete` does, put in a copy of the array with one
118
+ const copy = (draftState(parent) as unknown[]).slice();
119
+ delete copy[Number(last)];
120
+ const holder = walkToParent(root, segments.slice(0, -1), false);
121
+ holder[segments[segments.length - 2]!] = copy;
122
+ return;
123
+ }
123
124
  delete parent[last];
124
125
  }
125
126
 
@@ -55,7 +55,8 @@ function keyOf(val: unknown, ancestors: object[]): string {
55
55
  try {
56
56
  if (Array.isArray(val)) {
57
57
  // Array.from reads a hole as undefined (map would skip it, giving
58
- // `[,]` the key of `[]`), matching deepClone, which fills holes.
58
+ // `[,]` the key of `[]`): as JSON.stringify, a key does not tell a
59
+ // hole from an undefined element (deepEqual does)
59
60
  return `[${Array.from(val, (v) => keyOf(v, ancestors)).join(',')}]`;
60
61
  }
61
62
  if (val instanceof Map) {
@@ -0,0 +1,45 @@
1
+ // Kinds of story values that structural operations (clone, equality, merge)
2
+ // and property paths treat as a whole, by their content.
3
+
4
+ export const toStringTag = (value: object): string =>
5
+ Object.prototype.toString.call(value).slice(8, -1);
6
+
7
+ /** Temporal values are immutable: copies may share them. */
8
+ export const isTemporal = (value: object): boolean =>
9
+ toStringTag(value).startsWith('Temporal.');
10
+
11
+ /** new Number(), new String(), new Boolean(), Object(1n). */
12
+ export const isBoxed = (value: object): boolean =>
13
+ value instanceof Number ||
14
+ value instanceof String ||
15
+ value instanceof Boolean ||
16
+ toStringTag(value) === 'BigInt';
17
+
18
+ /**
19
+ * Values compared and copied as a whole, by their content: they have no
20
+ * own keys to merge by, or their keys are not the data (a property written
21
+ * into one would be dropped by clones and saves, and by Immer for Map and
22
+ * Set).
23
+ */
24
+ export function isAtomic(value: object): boolean {
25
+ return (
26
+ value instanceof Date ||
27
+ value instanceof RegExp ||
28
+ value instanceof Map ||
29
+ value instanceof Set ||
30
+ ArrayBuffer.isView(value) ||
31
+ value instanceof ArrayBuffer ||
32
+ value instanceof URL ||
33
+ value instanceof URLSearchParams ||
34
+ value instanceof Error ||
35
+ isBoxed(value) ||
36
+ isTemporal(value)
37
+ );
38
+ }
39
+
40
+ /** "a Map", "an Error", ... for an atomic value (see isAtomic), else undefined. */
41
+ export function atomicName(value: object): string | undefined {
42
+ if (!isAtomic(value)) return undefined;
43
+ const tag = value instanceof Error ? 'Error' : toStringTag(value);
44
+ return `${/^[AEIOU]/.test(tag) ? 'an' : 'a'} ${tag}`;
45
+ }
package/types/index.d.ts CHANGED
@@ -575,7 +575,18 @@ export interface SaveMeta {
575
575
  */
576
576
  export interface SaveRecord {
577
577
  meta: SaveMeta;
578
- payload: SavePayload;
578
+ /** The payload as stored: serialized in one piece, with its format version. */
579
+ payload: EncodedPayload;
580
+ }
581
+
582
+ /**
583
+ * A save payload as stored. `data` is opaque serialized text; read it with
584
+ * the Story API (loading a save), not directly.
585
+ */
586
+ export interface EncodedPayload {
587
+ /** The save format version `data` was written in. */
588
+ formatVersion: number;
589
+ data: string;
579
590
  }
580
591
 
581
592
  /**
@@ -584,7 +595,8 @@ export interface SaveRecord {
584
595
  * @see {@link ../../src/saves/types.ts} for the implementation.
585
596
  */
586
597
  export interface SaveExport {
587
- version: 1;
598
+ /** The save format version of the export. */
599
+ formatVersion: number;
588
600
  /** IFID of the story the save belongs to. Imports into other stories are rejected. */
589
601
  ifid: string;
590
602
  /** ISO 8601 timestamp of the export. */
@@ -614,13 +626,18 @@ export interface StoryAPI {
614
626
  set(name: string, value: unknown): void;
615
627
  set(vars: Record<string, unknown>): void;
616
628
 
617
- /** Navigate to a passage by name. */
629
+ /**
630
+ * Navigate to a passage by name. Writes the session: if the variables hold
631
+ * a value a save cannot hold (a function, an instance of an unregistered
632
+ * class, a unique symbol), the navigation completes and then throws an
633
+ * error naming the variable.
634
+ */
618
635
  goto(passageName: string): void;
619
636
 
620
- /** Go back one step in history. */
637
+ /** Go back one step in history. Throws like `goto()`. */
621
638
  back(): void;
622
639
 
623
- /** Go forward one step in history. */
640
+ /** Go forward one step in history. Throws like `goto()`. */
624
641
  forward(): void;
625
642
 
626
643
  /** Restart the story from the beginning. */
@@ -629,7 +646,9 @@ export interface StoryAPI {
629
646
  /**
630
647
  * Save the current state. Pass `slot` for a named save, `custom` for metadata.
631
648
  * Resolves once the save is persisted (after `aftersave` handlers ran and
632
- * `hasSave(slot)` is true); rejects if persisting fails.
649
+ * `hasSave(slot)` is true); rejects if persisting fails, or if the state
650
+ * holds a value a save cannot hold (a function, an instance of an
651
+ * unregistered class, a unique symbol, a symbol key), naming it.
633
652
  */
634
653
  save(slot?: string, custom?: Record<string, unknown>): Promise<void>;
635
654
 
@@ -638,7 +657,8 @@ export interface StoryAPI {
638
657
  * playthrough, in call order: a save issued after the load belongs to it.
639
658
  * Resolves once the loaded state is applied (immediately if the slot is
640
659
  * empty, without loading if a restart was issued after the load); rejects
641
- * if loading fails.
660
+ * if loading fails, e.g. for a save holding an instance of a class that is
661
+ * not registered, or one from an incompatible save format version.
642
662
  */
643
663
  load(slot?: string): Promise<void>;
644
664
 
@@ -744,7 +764,11 @@ export interface StoryAPI {
744
764
  /** Check whether any dialog is currently open. */
745
765
  isDialogOpen(): boolean;
746
766
 
747
- /** Register a class constructor for use in story expressions. */
767
+ /**
768
+ * Register a class so its instances keep their class through clones,
769
+ * history, saves and loads. A save refuses instances of classes that are
770
+ * not registered.
771
+ */
748
772
  registerClass(name: string, ctor: new (...args: any[]) => any): void;
749
773
 
750
774
  /** Register a custom macro. */