@rohal12/spindle 0.59.3 → 0.59.5
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/dist/pkg/format.js +1 -1
- package/dist/pkg/headless.js +318 -116
- package/package.json +1 -1
- package/src/components/macros/Include.tsx +7 -2
- package/src/define-macro.ts +6 -3
- package/src/execute-mutation.ts +192 -123
- package/src/saves/save-manager.ts +6 -6
- package/src/saves/storage.ts +24 -1
- package/src/store.ts +71 -35
- package/src/story-api.ts +15 -1
- package/src/structural.ts +183 -35
- package/src/utils/object-path.ts +3 -5
|
@@ -38,6 +38,13 @@ defineMacro({
|
|
|
38
38
|
[passage],
|
|
39
39
|
);
|
|
40
40
|
|
|
41
|
+
// Count the inclusion when its content mounts or the passage changes,
|
|
42
|
+
// not on every render (an unrelated variable update re-renders it)
|
|
43
|
+
const included = !failure && !!passage;
|
|
44
|
+
ctx.hooks.useEffect(() => {
|
|
45
|
+
if (included) useStoryStore.getState().trackRender(passageName);
|
|
46
|
+
}, [included, passageName]);
|
|
47
|
+
|
|
41
48
|
if (!storyData) return null;
|
|
42
49
|
if (failure) {
|
|
43
50
|
return (
|
|
@@ -49,8 +56,6 @@ defineMacro({
|
|
|
49
56
|
}
|
|
50
57
|
if (!passage || !ast) return null;
|
|
51
58
|
|
|
52
|
-
useStoryStore.getState().trackRender(passageName);
|
|
53
|
-
|
|
54
59
|
const nobr = passage.tags.includes('nobr');
|
|
55
60
|
const content = inline
|
|
56
61
|
? ctx.renderInlineNodes(ast)
|
package/src/define-macro.ts
CHANGED
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
renderInlineNodes,
|
|
19
19
|
} from './markup/render';
|
|
20
20
|
import type { ASTNode } from './markup/ast';
|
|
21
|
-
import { executeMutation, readState } from './execute-mutation';
|
|
21
|
+
import { executeMutation, mutateState, readState } from './execute-mutation';
|
|
22
22
|
import { evaluate } from './expression';
|
|
23
23
|
import { useStoryStore } from './store';
|
|
24
24
|
import { getByPath, setByPath } from './utils/object-path';
|
|
@@ -228,8 +228,11 @@ export function defineMacro<const P extends readonly ParameterDef[] = []>(
|
|
|
228
228
|
// In program order, also when mutation code performs the input
|
|
229
229
|
ctx.getValue = () => getByPath(readState().variables, segments);
|
|
230
230
|
ctx.setValue = (value: unknown) => {
|
|
231
|
-
|
|
232
|
-
|
|
231
|
+
// The commit mutation code uses, which keeps the references among
|
|
232
|
+
// variables: a write to a draft copies only the path written, and
|
|
233
|
+
// other variables sharing the object would keep the old one.
|
|
234
|
+
mutateState((work, adopt) => {
|
|
235
|
+
setByPath(work.variables, segments, adopt(value), {
|
|
233
236
|
createMissing: true,
|
|
234
237
|
});
|
|
235
238
|
});
|
package/src/execute-mutation.ts
CHANGED
|
@@ -10,21 +10,20 @@ import { useStoryStore } from './store';
|
|
|
10
10
|
import type { StoryState, VariableNamespaces } from './store';
|
|
11
11
|
import { execute } from './expression';
|
|
12
12
|
import {
|
|
13
|
-
|
|
13
|
+
changesBetween,
|
|
14
14
|
applyChange,
|
|
15
15
|
deepClone,
|
|
16
16
|
deepEqual,
|
|
17
|
-
diffPaths,
|
|
18
17
|
existingObjects,
|
|
19
18
|
isApplied,
|
|
20
19
|
isMergeable,
|
|
21
20
|
locateObjects,
|
|
22
21
|
mergeKeys,
|
|
23
22
|
pathKey,
|
|
24
|
-
|
|
23
|
+
sharedPaths,
|
|
25
24
|
type PathChange,
|
|
26
25
|
} from './structural';
|
|
27
|
-
import { getByPath } from './utils/object-path';
|
|
26
|
+
import { getByPath, setByPath } from './utils/object-path';
|
|
28
27
|
import { asNamespace, hasOwn } from './utils/namespace';
|
|
29
28
|
|
|
30
29
|
type NamespaceName = keyof VariableNamespaces;
|
|
@@ -76,7 +75,11 @@ export function readState(): StoryState {
|
|
|
76
75
|
const cloneValue = <T>(value: T): T =>
|
|
77
76
|
deepClone(value, { keepUnregistered: true });
|
|
78
77
|
|
|
79
|
-
/**
|
|
78
|
+
/**
|
|
79
|
+
* Copies values for one target (the three variable namespaces, so an object
|
|
80
|
+
* shared between them is shared there too), and what the values share they
|
|
81
|
+
* share there too.
|
|
82
|
+
*/
|
|
80
83
|
type Cloner = <T>(value: T) => T;
|
|
81
84
|
|
|
82
85
|
/**
|
|
@@ -87,19 +90,30 @@ type Cloner = <T>(value: T) => T;
|
|
|
87
90
|
* one, not a copy: `$a.self = $a` refers to the root of `a` in the store.
|
|
88
91
|
*/
|
|
89
92
|
function clonerFor(
|
|
90
|
-
target:
|
|
93
|
+
target: object,
|
|
91
94
|
paths?: ReadonlyMap<object, string[]>,
|
|
92
95
|
): Cloner {
|
|
93
96
|
const seen = paths ? existingObjects(paths, target) : new Map();
|
|
94
|
-
|
|
97
|
+
// Immer does not finalize a draft held as a Map key, so an existing object
|
|
98
|
+
// met there is put in the draft as the object itself: its base while the
|
|
99
|
+
// code did not change it, which other references to it then agree with.
|
|
100
|
+
const mapKey = (copy: object, original: object): object => {
|
|
101
|
+
const path = paths?.get(original);
|
|
102
|
+
if (!path || !isDraft(copy)) return copy;
|
|
103
|
+
const plain = currentDraft(copy) as object;
|
|
104
|
+
setByPath(target as Record<string, unknown>, path, plain);
|
|
105
|
+
seen.set(original, plain);
|
|
106
|
+
return plain;
|
|
107
|
+
};
|
|
108
|
+
return (value) => deepClone(value, { keepUnregistered: true, seen, mapKey });
|
|
95
109
|
}
|
|
96
110
|
|
|
97
111
|
/** One cloner per target copy, made when first used. */
|
|
98
112
|
const clonersFor = (
|
|
99
|
-
paths?: (target:
|
|
113
|
+
paths?: (target: object) => ReadonlyMap<object, string[]>,
|
|
100
114
|
) => {
|
|
101
115
|
const made = new Map<object, Cloner>();
|
|
102
|
-
return (target:
|
|
116
|
+
return (target: object): Cloner => {
|
|
103
117
|
let cloner = made.get(target);
|
|
104
118
|
if (!cloner) {
|
|
105
119
|
cloner = clonerFor(target, paths?.(target));
|
|
@@ -129,14 +143,13 @@ export function frozenCopy<T>(value: T): T {
|
|
|
129
143
|
*/
|
|
130
144
|
function mirror(
|
|
131
145
|
draft: VariableNamespaces,
|
|
132
|
-
ns: NamespaceName,
|
|
133
146
|
change: PathChange,
|
|
134
147
|
scopes: readonly MutationScope[] = activeScopes,
|
|
135
|
-
cloner: (target:
|
|
148
|
+
cloner: (target: object) => Cloner = clonerOf,
|
|
136
149
|
): void {
|
|
137
|
-
const root = change.path[
|
|
150
|
+
const [ns, root] = change.path as [NamespaceName, string];
|
|
138
151
|
for (const scope of scopes) {
|
|
139
|
-
for (const copy of [scope.work
|
|
152
|
+
for (const copy of [scope.work, scope.base]) {
|
|
140
153
|
try {
|
|
141
154
|
applyChange(
|
|
142
155
|
copy,
|
|
@@ -147,11 +160,11 @@ function mirror(
|
|
|
147
160
|
} catch {
|
|
148
161
|
const stored = draft[ns][root];
|
|
149
162
|
if (hasOwn(draft[ns], root)) {
|
|
150
|
-
copy[root] = cloneValue(
|
|
163
|
+
copy[ns][root] = cloneValue(
|
|
151
164
|
isDraft(stored) ? currentDraft(stored) : stored,
|
|
152
165
|
);
|
|
153
166
|
} else {
|
|
154
|
-
delete copy[root];
|
|
167
|
+
delete copy[ns][root];
|
|
155
168
|
}
|
|
156
169
|
}
|
|
157
170
|
}
|
|
@@ -159,7 +172,7 @@ function mirror(
|
|
|
159
172
|
}
|
|
160
173
|
|
|
161
174
|
/** A cloner of a copy of its own (no references shared with others). */
|
|
162
|
-
const clonerOf = (target:
|
|
175
|
+
const clonerOf = (target: object): Cloner => clonerFor(target);
|
|
163
176
|
|
|
164
177
|
const NAMESPACE_KEYS: ReadonlySet<string> = new Set(NAMESPACES);
|
|
165
178
|
|
|
@@ -279,27 +292,25 @@ export function routeStoreUpdate<S extends VariableNamespaces>(
|
|
|
279
292
|
view,
|
|
280
293
|
recipe as (draft: Draft<S>) => void,
|
|
281
294
|
);
|
|
282
|
-
|
|
295
|
+
// Paths below the namespaces, so one cloner and one application serve all
|
|
296
|
+
const changes = NAMESPACES.flatMap((ns) => {
|
|
283
297
|
const own = patches.filter((p) => p.path[0] === ns);
|
|
284
|
-
return
|
|
285
|
-
ns,
|
|
286
|
-
|
|
287
|
-
|
|
298
|
+
return own.length
|
|
299
|
+
? writtenPaths(view[ns], next[ns], own).map(
|
|
300
|
+
(change): PathChange => ({ ...change, path: [ns, ...change.path] }),
|
|
301
|
+
)
|
|
302
|
+
: [];
|
|
288
303
|
});
|
|
289
|
-
// Each target takes its own copies, which share what the written values do
|
|
290
|
-
|
|
291
|
-
const
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
): PathChange =>
|
|
304
|
+
// Each target takes its own copies, which share what the written values do,
|
|
305
|
+
// and an object the code holds elsewhere stays that one (`Story.set('b', $a)`)
|
|
306
|
+
const held = locateObjects(namespacesOf(inner.work), changes);
|
|
307
|
+
const cloners = clonersFor(() => held);
|
|
308
|
+
const owned = (change: PathChange, target: object): PathChange =>
|
|
295
309
|
change.deleted
|
|
296
310
|
? change
|
|
297
311
|
: { ...change, value: cloners(target)(change.value) };
|
|
298
|
-
for (const
|
|
299
|
-
|
|
300
|
-
applyChange(inner.work[ns], owned(change, inner.work[ns]));
|
|
301
|
-
}
|
|
302
|
-
}
|
|
312
|
+
for (const change of changes)
|
|
313
|
+
applyChange(inner.work, owned(change, inner.work));
|
|
303
314
|
|
|
304
315
|
return (draft) => {
|
|
305
316
|
const target = draft as unknown as Record<string, unknown>;
|
|
@@ -313,28 +324,68 @@ export function routeStoreUpdate<S extends VariableNamespaces>(
|
|
|
313
324
|
}
|
|
314
325
|
}
|
|
315
326
|
const namespaces = draft as unknown as VariableNamespaces;
|
|
316
|
-
for (const
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
delete namespaces[ns][root];
|
|
326
|
-
}
|
|
327
|
+
for (const change of changes) {
|
|
328
|
+
const [ns, root] = change.path as [NamespaceName, string];
|
|
329
|
+
try {
|
|
330
|
+
applyChange(namespaces, owned(change, namespaces));
|
|
331
|
+
} catch {
|
|
332
|
+
if (hasOwn(inner.work[ns], root)) {
|
|
333
|
+
namespaces[ns][root] = cloneValue(inner.work[ns][root]);
|
|
334
|
+
} else {
|
|
335
|
+
delete namespaces[ns][root];
|
|
327
336
|
}
|
|
328
|
-
mirror(namespaces, ns, change, activeScopes, cloners);
|
|
329
337
|
}
|
|
338
|
+
mirror(namespaces, change, activeScopes, cloners);
|
|
330
339
|
}
|
|
331
340
|
};
|
|
332
341
|
}
|
|
333
342
|
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
343
|
+
/**
|
|
344
|
+
* Make the objects `work` holds at several paths one object in `draft`
|
|
345
|
+
* again where `changes` wrote below one of them: a write through one path
|
|
346
|
+
* of a draft gives that path a new object, and the other paths would go on
|
|
347
|
+
* referring to the old one (`$a.n = 2` with `$b` the same object as `$a`).
|
|
348
|
+
*/
|
|
349
|
+
function relink(
|
|
350
|
+
draft: VariableNamespaces,
|
|
351
|
+
work: VariableNamespaces,
|
|
352
|
+
changes: readonly PathChange[],
|
|
353
|
+
): void {
|
|
354
|
+
const written = changes.map((c) => pathKey(c.path).slice(0, -1));
|
|
355
|
+
for (const [first, ...others] of sharedPaths(work)) {
|
|
356
|
+
// Written at or below one of the paths: the written key follows its
|
|
357
|
+
// JSON, which opens with the path's own
|
|
358
|
+
const prefixes = [first!, ...others].map((p) => pathKey(p).slice(0, -1));
|
|
359
|
+
if (!written.some((w) => prefixes.some((p) => w.startsWith(p)))) continue;
|
|
360
|
+
try {
|
|
361
|
+
const object = getByPath(draft, first!);
|
|
362
|
+
if (object === null || typeof object !== 'object') continue;
|
|
363
|
+
for (const path of others)
|
|
364
|
+
setByPath(draft as unknown as Record<string, unknown>, path, object);
|
|
365
|
+
} catch {
|
|
366
|
+
// A path the store does not hold: the code's view is not the store's
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Copies of the namespaces that keep their references to each other: an
|
|
373
|
+
* object held in two of them (`%copy = $a`) is one object in the copies too.
|
|
374
|
+
*/
|
|
375
|
+
function cloneNamespaces(from: VariableNamespaces): VariableNamespaces {
|
|
376
|
+
const seen = new Map<object, object>();
|
|
377
|
+
return {
|
|
378
|
+
variables: deepClone(from.variables, { seen }),
|
|
379
|
+
temporary: deepClone(from.temporary, { seen }),
|
|
380
|
+
transient: deepClone(from.transient, { seen }),
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** The three variable namespaces of `state`, as an object to walk. */
|
|
385
|
+
const namespacesOf = (state: VariableNamespaces): VariableNamespaces => ({
|
|
386
|
+
variables: state.variables,
|
|
387
|
+
temporary: state.temporary,
|
|
388
|
+
transient: state.transient,
|
|
338
389
|
});
|
|
339
390
|
|
|
340
391
|
/** A mutation to commit, and whether its code goes on running after. */
|
|
@@ -362,13 +413,9 @@ function commitScopes(commits: readonly Commit[]): void {
|
|
|
362
413
|
const all = commits.map(({ scope, keepRunning }) => ({
|
|
363
414
|
scope,
|
|
364
415
|
keepRunning,
|
|
365
|
-
changes:
|
|
366
|
-
(ns) => [ns, changesOf(scope.base[ns], scope.work[ns])] as const,
|
|
367
|
-
),
|
|
416
|
+
changes: changesOf(scope.base, scope.work),
|
|
368
417
|
}));
|
|
369
|
-
const changed = all.filter(({ changes }) =>
|
|
370
|
-
changes.some(([, list]) => list.length > 0),
|
|
371
|
-
);
|
|
418
|
+
const changed = all.filter(({ changes }) => changes.length > 0);
|
|
372
419
|
if (changed.length === 0) return;
|
|
373
420
|
// Before the store update: watchers it fires may commit again (a goto)
|
|
374
421
|
for (const { scope, keepRunning } of changed) {
|
|
@@ -390,80 +437,67 @@ function commitScopes(commits: readonly Commit[]): void {
|
|
|
390
437
|
all.forEach(({ scope, changes }, i) => {
|
|
391
438
|
// The copies the store and the enclosing mutations take are their own,
|
|
392
439
|
// and keep the references of the values written (one object written
|
|
393
|
-
// at two paths is one object there) and to
|
|
394
|
-
// already (see clonerFor). The code's working
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
);
|
|
398
|
-
const own = new Map(
|
|
399
|
-
changes.map(([ns]) => [ns, clonerFor(draft[ns], paths.get(ns))]),
|
|
400
|
-
);
|
|
440
|
+
// at two paths, in one namespace or two, is one object there) and to
|
|
441
|
+
// the objects they hold already (see clonerFor). The code's working
|
|
442
|
+
// copy stays its own.
|
|
443
|
+
const paths = locateObjects(scope.work, changes);
|
|
444
|
+
const own = clonerFor(draft, paths);
|
|
401
445
|
const enclosing = all.slice(0, i).map((c) => c.scope);
|
|
402
|
-
const enclosingCloners =
|
|
403
|
-
|
|
404
|
-
)
|
|
405
|
-
|
|
406
|
-
const
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
if (
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
if (!isApplied(draft[ns], change)) applyChange(draft[ns], change);
|
|
413
|
-
continue;
|
|
414
|
-
}
|
|
415
|
-
// A changed path already holding the value keeps its reference;
|
|
416
|
-
// an alias change is one of reference only: it holds when the
|
|
417
|
-
// very object is there.
|
|
418
|
-
const value = change.alias ? own.get(ns)!(change.value) : undefined;
|
|
419
|
-
if (
|
|
420
|
-
change.alias
|
|
421
|
-
? Object.is(getByPath(draft[ns], change.path), value)
|
|
422
|
-
: isApplied(draft[ns], change)
|
|
423
|
-
) {
|
|
424
|
-
continue;
|
|
425
|
-
}
|
|
426
|
-
applyChange(draft[ns], {
|
|
427
|
-
...change,
|
|
428
|
-
value: change.alias ? value : own.get(ns)!(change.value),
|
|
429
|
-
});
|
|
430
|
-
} catch {
|
|
431
|
-
// An intermediate object the code wrote into is gone from the
|
|
432
|
-
// store: the code's view of the whole root wins.
|
|
433
|
-
draft[ns][root] = own.get(ns)!(scope.work[ns][root]);
|
|
434
|
-
replaced.add(root);
|
|
446
|
+
const enclosingCloners = clonersFor(() => paths);
|
|
447
|
+
const replaced = new Set<string>();
|
|
448
|
+
for (const change of changes) {
|
|
449
|
+
const [ns, root] = change.path as [NamespaceName, string];
|
|
450
|
+
const rootKey = pathKey([ns, root]);
|
|
451
|
+
if (replaced.has(rootKey)) continue;
|
|
452
|
+
try {
|
|
453
|
+
if (change.deleted) {
|
|
454
|
+
if (!isApplied(draft, change)) applyChange(draft, change);
|
|
455
|
+
continue;
|
|
435
456
|
}
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
457
|
+
// A changed path already holding the value keeps its reference;
|
|
458
|
+
// an alias change is one of reference only: it holds when the
|
|
459
|
+
// very object is there.
|
|
460
|
+
const value = change.alias ? own(change.value) : undefined;
|
|
461
|
+
if (
|
|
462
|
+
change.alias
|
|
463
|
+
? Object.is(getByPath(draft, change.path), value)
|
|
464
|
+
: isApplied(draft, change)
|
|
465
|
+
) {
|
|
466
|
+
continue;
|
|
467
|
+
}
|
|
468
|
+
applyChange(draft, {
|
|
469
|
+
...change,
|
|
470
|
+
value: change.alias ? value : own(change.value),
|
|
471
|
+
});
|
|
472
|
+
} catch {
|
|
473
|
+
// An intermediate object the code wrote into is gone from the
|
|
474
|
+
// store: the code's view of the whole root wins.
|
|
475
|
+
draft[ns][root] = own(scope.work[ns][root]);
|
|
476
|
+
replaced.add(rootKey);
|
|
441
477
|
}
|
|
442
478
|
}
|
|
479
|
+
relink(draft, scope.work, changes);
|
|
480
|
+
// Hand the changes to the mutations this one runs inside, before
|
|
481
|
+
// watchers fired by this update run.
|
|
482
|
+
for (const change of changes) {
|
|
483
|
+
mirror(draft, change, enclosing, enclosingCloners);
|
|
484
|
+
}
|
|
443
485
|
});
|
|
444
486
|
}
|
|
445
487
|
}
|
|
446
488
|
|
|
447
489
|
/**
|
|
448
|
-
* The changes of the code to
|
|
449
|
-
*
|
|
450
|
-
* than they did (see aliasChanges), those first.
|
|
490
|
+
* The changes of the code to the namespaces, as paths below them (`['variables',
|
|
491
|
+
* 'a']`; see changesBetween).
|
|
451
492
|
*/
|
|
452
|
-
|
|
453
|
-
base:
|
|
454
|
-
work:
|
|
455
|
-
): PathChange[]
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
// Written whole by a change at it or above it
|
|
461
|
-
if (underChange(change.path, keys)) continue;
|
|
462
|
-
keys.add(pathKey(change.path));
|
|
463
|
-
aliases.push(change);
|
|
464
|
-
}
|
|
465
|
-
return [...aliases, ...written];
|
|
466
|
-
}
|
|
493
|
+
const changesOf = (
|
|
494
|
+
base: VariableNamespaces,
|
|
495
|
+
work: VariableNamespaces,
|
|
496
|
+
): PathChange[] =>
|
|
497
|
+
changesBetween(
|
|
498
|
+
base as unknown as Record<string, unknown>,
|
|
499
|
+
work as unknown as Record<string, unknown>,
|
|
500
|
+
);
|
|
467
501
|
|
|
468
502
|
/** Every running mutation, to commit with its code going on. */
|
|
469
503
|
const running = (): Commit[] =>
|
|
@@ -503,11 +537,37 @@ export function runWithCommittedMutations<T>(action: () => T): T {
|
|
|
503
537
|
}
|
|
504
538
|
}
|
|
505
539
|
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
540
|
+
/**
|
|
541
|
+
* Run `run` on working copies of the story state and commit what it changed
|
|
542
|
+
* to the store, as mutation code does (see commitScopes): the references
|
|
543
|
+
* between values are kept, which a store update made on a draft does not do.
|
|
544
|
+
* Nothing is committed when `run` throws.
|
|
545
|
+
*/
|
|
546
|
+
export function mutateState(
|
|
547
|
+
run: (work: VariableNamespaces, adopt: Cloner) => void,
|
|
510
548
|
): void {
|
|
549
|
+
// The writes of the code this runs inside come first, and stay even if
|
|
550
|
+
// `run` throws
|
|
551
|
+
commitScopes(running());
|
|
552
|
+
// Where the objects `run` may be handed are: in the state it continues from
|
|
553
|
+
const held = locateObjects(
|
|
554
|
+
namespacesOf(getActiveMutationScope() ?? useStoryStore.getState()),
|
|
555
|
+
[],
|
|
556
|
+
);
|
|
557
|
+
const scope = startScope();
|
|
558
|
+
// Copies values into the working copy, an object it holds already staying
|
|
559
|
+
// that one, and what the values share staying shared
|
|
560
|
+
const adopt = clonerFor(scope.work, held);
|
|
561
|
+
try {
|
|
562
|
+
run(scope.work, adopt);
|
|
563
|
+
} finally {
|
|
564
|
+
activeScopes.pop();
|
|
565
|
+
}
|
|
566
|
+
commitScopes([...running(), { scope, keepRunning: false }]);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/** Start a mutation, from the pending state of the one it runs inside. */
|
|
570
|
+
function startScope(): MutationScope {
|
|
511
571
|
// A mutation started while another executes (a watcher run action fired
|
|
512
572
|
// by a Story.set in its code) continues from the enclosing code's pending
|
|
513
573
|
// state rather than the store, as a direct call at that point would.
|
|
@@ -516,6 +576,15 @@ export function executeMutation(
|
|
|
516
576
|
work: cloneNamespaces(start),
|
|
517
577
|
base: cloneNamespaces(start),
|
|
518
578
|
};
|
|
579
|
+
activeScopes.push(scope);
|
|
580
|
+
return scope;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
export function executeMutation(
|
|
584
|
+
code: string,
|
|
585
|
+
mergedLocals: Record<string, unknown>,
|
|
586
|
+
scopeUpdate: (key: string, value: unknown) => void,
|
|
587
|
+
): void {
|
|
519
588
|
// Locals are deep-cloned like the store namespaces: a loop item or widget
|
|
520
589
|
// argument taken from story state is Immer-frozen, and an unfrozen object
|
|
521
590
|
// mutated in place would keep its reference, so a nested assignment
|
|
@@ -525,7 +594,7 @@ export function executeMutation(
|
|
|
525
594
|
deepClone(mergedLocals, { keepUnregistered: true }),
|
|
526
595
|
);
|
|
527
596
|
|
|
528
|
-
|
|
597
|
+
const scope = startScope();
|
|
529
598
|
try {
|
|
530
599
|
execute(
|
|
531
600
|
code,
|
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
IncompatibleSaveError,
|
|
16
16
|
SAVE_FORMAT_VERSION,
|
|
17
17
|
} from './format';
|
|
18
|
-
import { getBackend, resetBackend } from './storage';
|
|
18
|
+
import { getBackend, resetBackend, META_PREFIXES } from './storage';
|
|
19
19
|
import { deepClone } from '../structural';
|
|
20
20
|
import { emit } from '../event-emitter';
|
|
21
21
|
import { withoutDraws } from '../prng';
|
|
@@ -152,7 +152,7 @@ async function startNewPlaythroughNow(
|
|
|
152
152
|
const PLAYTHROUGH_LABEL = /^Playthrough (\d+)$/;
|
|
153
153
|
|
|
154
154
|
function playthroughCountKey(ifid: string): string {
|
|
155
|
-
return
|
|
155
|
+
return `${META_PREFIXES.playthroughCount}${ifid}`;
|
|
156
156
|
}
|
|
157
157
|
|
|
158
158
|
/**
|
|
@@ -173,7 +173,7 @@ async function nextPlaythroughNumber(ifid: string): Promise<number> {
|
|
|
173
173
|
}
|
|
174
174
|
|
|
175
175
|
function currentPlaythroughKey(ifid: string): string {
|
|
176
|
-
return
|
|
176
|
+
return `${META_PREFIXES.currentPlaythrough}${ifid}`;
|
|
177
177
|
}
|
|
178
178
|
|
|
179
179
|
export function getCurrentPlaythroughId(
|
|
@@ -476,9 +476,9 @@ export const getSavesGrouped = queued(async function getSavesGroupedNow(
|
|
|
476
476
|
|
|
477
477
|
// --- Quick Save / Slot Save ---
|
|
478
478
|
|
|
479
|
-
const AUTOSAVE_KEY_PREFIX =
|
|
480
|
-
const SLOT_KEY_PREFIX =
|
|
481
|
-
const SLOT_INDEX_KEY_PREFIX =
|
|
479
|
+
const AUTOSAVE_KEY_PREFIX = META_PREFIXES.autosave;
|
|
480
|
+
const SLOT_KEY_PREFIX = META_PREFIXES.slot;
|
|
481
|
+
const SLOT_INDEX_KEY_PREFIX = META_PREFIXES.slotIndex;
|
|
482
482
|
|
|
483
483
|
/**
|
|
484
484
|
* Whether `slot` names a slot. The default (autosave) slot is addressed by
|
package/src/saves/storage.ts
CHANGED
|
@@ -46,6 +46,28 @@ function makeTables(table: MakeTable): Tables {
|
|
|
46
46
|
};
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
/**
|
|
50
|
+
* The meta keys a story owns, in one place: save-manager builds them,
|
|
51
|
+
* deleteMetaByIfid() matches them. A story's key is its prefix followed by
|
|
52
|
+
* its IFID, except a named slot's pointer, `slot.<name>.<IFID>`.
|
|
53
|
+
*/
|
|
54
|
+
export const META_PREFIXES = {
|
|
55
|
+
autosave: 'autosave.',
|
|
56
|
+
slot: 'slot.',
|
|
57
|
+
slotIndex: 'slotIndex.',
|
|
58
|
+
playthroughCount: 'playthroughCount.',
|
|
59
|
+
currentPlaythrough: 'currentPlaythroughId.',
|
|
60
|
+
} as const;
|
|
61
|
+
|
|
62
|
+
/** Whether the meta `key` belongs to the story `ifid` (and no other). */
|
|
63
|
+
function isMetaKeyOf(key: string, ifid: string): boolean {
|
|
64
|
+
const { slot, ...others } = META_PREFIXES;
|
|
65
|
+
return (
|
|
66
|
+
Object.values(others).some((prefix) => key === prefix + ifid) ||
|
|
67
|
+
(key.startsWith(slot) && key.endsWith(`.${ifid}`))
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
49
71
|
function createBackend(
|
|
50
72
|
type: StorageBackend['type'],
|
|
51
73
|
{ saves, playthroughs, meta }: Tables,
|
|
@@ -96,7 +118,8 @@ function createBackend(
|
|
|
96
118
|
deleteMeta: (key) => meta.delete(key),
|
|
97
119
|
deleteMetaByPrefix: (prefix) =>
|
|
98
120
|
deleteMetaWhere((key) => key.startsWith(prefix)),
|
|
99
|
-
deleteMetaByIfid: (ifid) =>
|
|
121
|
+
deleteMetaByIfid: (ifid) =>
|
|
122
|
+
deleteMetaWhere((key) => isMetaKeyOf(key, ifid)),
|
|
100
123
|
getAllMetaKeys: () => meta.keys(),
|
|
101
124
|
|
|
102
125
|
destroy,
|