@rohal12/spindle 0.52.4 → 0.52.6

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 (47) hide show
  1. package/dist/pkg/format.js +1 -1
  2. package/dist/pkg/headless.js +1502 -1521
  3. package/dist/pkg/macro-registry.json +255 -0
  4. package/package.json +1 -1
  5. package/src/class-registry.ts +6 -182
  6. package/src/components/macros/Button.tsx +2 -4
  7. package/src/components/macros/Checkbox.tsx +6 -13
  8. package/src/components/macros/Computed.tsx +38 -55
  9. package/src/components/macros/Cycle.tsx +1 -0
  10. package/src/components/macros/Dialog.tsx +7 -8
  11. package/src/components/macros/Do.tsx +2 -1
  12. package/src/components/macros/For.tsx +27 -78
  13. package/src/components/macros/Goto.tsx +6 -9
  14. package/src/components/macros/Include.tsx +10 -57
  15. package/src/components/macros/Listbox.tsx +1 -0
  16. package/src/components/macros/MacroError.tsx +37 -0
  17. package/src/components/macros/MacroLink.tsx +9 -24
  18. package/src/components/macros/Meter.tsx +19 -37
  19. package/src/components/macros/Numberbox.tsx +5 -32
  20. package/src/components/macros/PassageDisplay.tsx +37 -47
  21. package/src/components/macros/Print.tsx +1 -0
  22. package/src/components/macros/Radiobutton.tsx +8 -38
  23. package/src/components/macros/Repeat.tsx +3 -3
  24. package/src/components/macros/Set.tsx +3 -29
  25. package/src/components/macros/Switch.tsx +36 -37
  26. package/src/components/macros/Textarea.tsx +2 -28
  27. package/src/components/macros/Textbox.tsx +2 -29
  28. package/src/components/macros/Type.tsx +3 -3
  29. package/src/components/macros/Unset.tsx +17 -40
  30. package/src/components/macros/Watch.tsx +23 -91
  31. package/src/components/macros/WidgetInvocation.tsx +9 -71
  32. package/src/components/macros/detached-body.tsx +2 -2
  33. package/src/components/macros/input-macro.ts +48 -0
  34. package/src/components/macros/locals-scope.tsx +60 -0
  35. package/src/components/macros/macro-args.ts +280 -0
  36. package/src/components/macros/option-utils.ts +7 -12
  37. package/src/define-macro.ts +47 -30
  38. package/src/execute-mutation.ts +17 -97
  39. package/src/hooks/use-render-options.ts +26 -0
  40. package/src/markup/render.tsx +2 -4
  41. package/src/registry.ts +48 -0
  42. package/src/saves/save-manager.ts +190 -259
  43. package/src/saves/storage.ts +229 -402
  44. package/src/store.ts +244 -310
  45. package/src/structural.ts +388 -0
  46. package/types/index.d.ts +69 -6
  47. package/types/tooling.d.ts +33 -1
@@ -7,7 +7,7 @@ import type {
7
7
  SaveExport,
8
8
  StorageInfo,
9
9
  } from './types';
10
- import { isSaveExport, isSavePayload } from './types';
10
+ import { estimatePayloadBytes, isSaveExport, isSavePayload } from './types';
11
11
  import { getBackend, resetBackend } from './storage';
12
12
  import { deepClone, serialize, deserialize } from '../class-registry';
13
13
  import { emit } from '../event-emitter';
@@ -94,6 +94,13 @@ function inOrder<T>(op: () => Promise<T>): Promise<T> {
94
94
  return run;
95
95
  }
96
96
 
97
+ /** `op` (a `...Now` helper) as an operation that queues itself. */
98
+ function queued<A extends unknown[], R>(
99
+ op: (...args: A) => Promise<R>,
100
+ ): (...args: A) => Promise<R> {
101
+ return (...args) => inOrder(() => op(...args));
102
+ }
103
+
97
104
  // --- Playthroughs ---
98
105
 
99
106
  /**
@@ -101,16 +108,11 @@ function inOrder<T>(op: () => Promise<T>): Promise<T> {
101
108
  * the caller has already switched to the playthrough (restart() does, so
102
109
  * saves issued before this resolves are tagged with it).
103
110
  */
104
- export function startNewPlaythrough(
105
- ifid: string,
106
- id: string = crypto.randomUUID(),
107
- ): Promise<string> {
108
- return inOrder(() => startNewPlaythroughNow(ifid, id));
109
- }
111
+ export const startNewPlaythrough = queued(startNewPlaythroughNow);
110
112
 
111
113
  async function startNewPlaythroughNow(
112
114
  ifid: string,
113
- id: string,
115
+ id: string = crypto.randomUUID(),
114
116
  ): Promise<string> {
115
117
  const backend = await getBackend();
116
118
  const num = await nextPlaythroughNumber(ifid);
@@ -171,27 +173,34 @@ export function getCurrentPlaythroughId(
171
173
  * recorded again as "Imported", as importing the save would: it was not
172
174
  * started here, so it takes no number.
173
175
  */
174
- export function adoptPlaythrough(ifid: string, id: string): Promise<void> {
175
- return inOrder(() => adoptPlaythroughNow(ifid, id));
176
- }
176
+ export const adoptPlaythrough = queued(adoptPlaythroughNow);
177
177
 
178
178
  async function adoptPlaythroughNow(ifid: string, id: string): Promise<void> {
179
179
  const backend = await getBackend();
180
- const playthroughs = await backend.getPlaythroughsByIfid(ifid);
181
- if (!playthroughs.some((p) => p.id === id)) {
182
- await backend.putPlaythrough({
183
- id,
184
- ifid,
185
- createdAt: new Date().toISOString(),
186
- label: 'Imported',
187
- });
188
- }
180
+ await ensurePlaythrough(ifid, id, new Date().toISOString());
189
181
  const key = currentPlaythroughKey(ifid);
190
182
  if ((await backend.getMeta<string>(key)) !== id) {
191
183
  await backend.setMeta(key, id);
192
184
  }
193
185
  }
194
186
 
187
+ /**
188
+ * Record the playthrough `id` of the story as "Imported", created at
189
+ * `createdAt`, unless it has a record: a playthrough not started here takes
190
+ * no number.
191
+ */
192
+ async function ensurePlaythrough(
193
+ ifid: string,
194
+ id: string,
195
+ createdAt: string,
196
+ ): Promise<void> {
197
+ const backend = await getBackend();
198
+ const playthroughs = await backend.getPlaythroughsByIfid(ifid);
199
+ if (!playthroughs.some((p) => p.id === id)) {
200
+ await backend.putPlaythrough({ id, ifid, createdAt, label: 'Imported' });
201
+ }
202
+ }
203
+
195
204
  /**
196
205
  * Set up the save system for a story that boots: the story's current
197
206
  * playthrough (a new one if it has none) and the slots holding saves (see
@@ -204,7 +213,7 @@ export function establishPlaythrough(
204
213
  const backend = await getBackend();
205
214
  const id =
206
215
  (await backend.getMeta<string>(currentPlaythroughKey(ifid))) ??
207
- (await startNewPlaythroughNow(ifid, crypto.randomUUID()));
216
+ (await startNewPlaythroughNow(ifid));
208
217
  return { id, knownSaves: await populateKnownSavesNow(ifid) };
209
218
  });
210
219
  }
@@ -250,20 +259,40 @@ export function saveWithHooks<T>(
250
259
 
251
260
  // --- Save CRUD ---
252
261
 
253
- export function createSave(
254
- ifid: string,
255
- playthroughId: string,
262
+ /**
263
+ * `payload` with `convert` applied to its live variables and to those of
264
+ * every history moment.
265
+ */
266
+ function mapVariables(
256
267
  payload: SavePayload,
257
- custom: Record<string, unknown> = {},
258
- ): Promise<SaveRecord> {
259
- return inOrder(() => createSaveNow(ifid, playthroughId, payload, custom));
268
+ convert: (variables: Record<string, unknown>) => Record<string, unknown>,
269
+ ): SavePayload {
270
+ return {
271
+ ...payload,
272
+ variables: convert(payload.variables),
273
+ history: payload.history.map((m) => ({
274
+ ...m,
275
+ variables: convert(m.variables),
276
+ })),
277
+ };
278
+ }
279
+
280
+ /** The record to store for a save: `payload` serialized, and its size. */
281
+ function storedRecord(meta: SaveMeta, payload: SavePayload): SaveRecord {
282
+ const serialized = mapVariables(deepClone(payload), serialize);
283
+ return {
284
+ meta: { ...meta, estimatedBytes: estimatePayloadBytes(serialized) },
285
+ payload: serialized,
286
+ };
260
287
  }
261
288
 
289
+ export const createSave = queued(createSaveNow);
290
+
262
291
  async function createSaveNow(
263
292
  ifid: string,
264
293
  playthroughId: string,
265
294
  payload: SavePayload,
266
- custom: Record<string, unknown>,
295
+ custom: Record<string, unknown> = {},
267
296
  ): Promise<SaveRecord> {
268
297
  const now = new Date().toISOString();
269
298
  const meta: SaveMeta = {
@@ -277,14 +306,7 @@ async function createSaveNow(
277
306
  custom,
278
307
  };
279
308
 
280
- const serializedPayload = deepClone(payload);
281
- serializedPayload.variables = serialize(serializedPayload.variables);
282
- serializedPayload.history = serializedPayload.history.map((m) => ({
283
- ...m,
284
- variables: serialize(m.variables),
285
- }));
286
- const record: SaveRecord = { meta, payload: serializedPayload };
287
- record.meta.estimatedBytes = JSON.stringify(serializedPayload).length;
309
+ const record = storedRecord(meta, payload);
288
310
  await (await getBackend()).putSave(record);
289
311
  return record;
290
312
  }
@@ -304,16 +326,7 @@ async function createSaveNow(
304
326
  * save itself rather than describing its content. `custom` is merged, as
305
327
  * before, so slot keys and metadata not passed again are kept.
306
328
  */
307
- export function overwriteSave(
308
- saveId: string,
309
- payload: SavePayload,
310
- custom?: Record<string, unknown>,
311
- playthroughId?: string,
312
- ): Promise<SaveRecord | undefined> {
313
- return inOrder(() =>
314
- overwriteSaveNow(saveId, payload, custom, playthroughId),
315
- );
316
- }
329
+ export const overwriteSave = queued(overwriteSaveNow);
317
330
 
318
331
  async function overwriteSaveNow(
319
332
  saveId: string,
@@ -325,14 +338,8 @@ async function overwriteSaveNow(
325
338
  const existing = await backend.getSave(saveId);
326
339
  if (!existing) return undefined;
327
340
 
328
- const serializedPayload = deepClone(payload);
329
- serializedPayload.variables = serialize(serializedPayload.variables);
330
- serializedPayload.history = serializedPayload.history.map((m) => ({
331
- ...m,
332
- variables: serialize(m.variables),
333
- }));
334
- const updated: SaveRecord = {
335
- meta: {
341
+ const updated = storedRecord(
342
+ {
336
343
  ...existing.meta,
337
344
  ...(playthroughId ? { playthroughId } : {}),
338
345
  updatedAt: new Date().toISOString(),
@@ -345,9 +352,8 @@ async function overwriteSaveNow(
345
352
  ? { custom: { ...existing.meta.custom, ...custom } }
346
353
  : {}),
347
354
  },
348
- payload: serializedPayload,
349
- };
350
- updated.meta.estimatedBytes = JSON.stringify(serializedPayload).length;
355
+ payload,
356
+ );
351
357
  await backend.putSave(updated);
352
358
  return updated;
353
359
  }
@@ -358,25 +364,16 @@ async function overwriteSaveNow(
358
364
  * untouched. Payloads handed to the store's `loadFromPayload()` must be live.
359
365
  */
360
366
  export function deserializePayload(payload: SavePayload): SavePayload {
361
- return {
362
- ...payload,
363
- variables: deserialize(payload.variables),
364
- history: payload.history.map((m) => ({
365
- ...m,
366
- variables: deserialize(m.variables),
367
- })),
368
- };
367
+ return mapVariables(payload, deserialize);
369
368
  }
370
369
 
371
- export function loadSave(saveId: string): Promise<SavePayload | undefined> {
372
- return inOrder(() => loadSaveNow(saveId));
373
- }
370
+ /** The live payload of a stored save, if there is one. */
371
+ const livePayload = (record: SaveRecord | undefined) =>
372
+ record && deserializePayload(record.payload);
374
373
 
375
- async function loadSaveNow(saveId: string): Promise<SavePayload | undefined> {
376
- const record = await (await getBackend()).getSave(saveId);
377
- if (!record) return undefined;
378
- return deserializePayload(record.payload);
379
- }
374
+ export const loadSave = queued(async (saveId: string) =>
375
+ livePayload(await (await getBackend()).getSave(saveId)),
376
+ );
380
377
 
381
378
  /**
382
379
  * Delete a save record. If the default slot or a named slot holds it, that
@@ -389,14 +386,7 @@ export function deleteSaveById(saveId: string): Promise<void> {
389
386
  await backend.deleteSave(saveId);
390
387
  if (!record) return;
391
388
 
392
- const ifid = record.meta.ifid;
393
- const slots = [undefined, ...(await getIndexedSlots(ifid))];
394
- for (const slot of slots) {
395
- const metaKey = slotMetaKey(ifid, slot);
396
- if ((await backend.getMeta<string>(metaKey)) !== saveId) continue;
397
- await backend.deleteMeta(metaKey);
398
- await removeFromSlotIndex(ifid, slot);
399
- }
389
+ await emptySlotsHolding(record.meta.ifid, (id) => id === saveId);
400
390
  });
401
391
  }
402
392
 
@@ -425,11 +415,9 @@ export interface PlaythroughGroup {
425
415
  saves: SaveRecord[];
426
416
  }
427
417
 
428
- export function getSavesGrouped(ifid: string): Promise<PlaythroughGroup[]> {
429
- return inOrder(() => getSavesGroupedNow(ifid));
430
- }
431
-
432
- async function getSavesGroupedNow(ifid: string): Promise<PlaythroughGroup[]> {
418
+ export const getSavesGrouped = queued(async function getSavesGroupedNow(
419
+ ifid: string,
420
+ ): Promise<PlaythroughGroup[]> {
433
421
  const backend = await getBackend();
434
422
  const [allSaves, allPlaythroughs] = await Promise.all([
435
423
  backend.getSavesByIfid(ifid),
@@ -486,7 +474,7 @@ async function getSavesGroupedNow(ifid: string): Promise<PlaythroughGroup[]> {
486
474
  }
487
475
 
488
476
  return result;
489
- }
477
+ });
490
478
 
491
479
  // --- Quick Save / Slot Save ---
492
480
 
@@ -508,39 +496,83 @@ function slotMetaKey(ifid: string, slot?: string): string {
508
496
  : `${AUTOSAVE_KEY_PREFIX}${ifid}`;
509
497
  }
510
498
 
499
+ const slotIndexKey = (ifid: string) => `${SLOT_INDEX_KEY_PREFIX}${ifid}`;
500
+
511
501
  /** Read-modify-write the per-story slot index. */
512
502
  async function updateSlotIndex(
513
503
  ifid: string,
514
504
  update: (slots: string[]) => string[],
515
505
  ): Promise<void> {
516
506
  const backend = await getBackend();
517
- const indexKey = `${SLOT_INDEX_KEY_PREFIX}${ifid}`;
518
- const existing = (await backend.getMeta<string[]>(indexKey)) ?? [];
507
+ const existing = (await backend.getMeta<string[]>(slotIndexKey(ifid))) ?? [];
519
508
  const updated = update(existing);
520
- if (updated !== existing) await backend.setMeta(indexKey, updated);
509
+ if (updated !== existing) await backend.setMeta(slotIndexKey(ifid), updated);
521
510
  }
522
511
 
523
- /** Record a named slot in the per-story slot index (no-op for the default slot). */
524
- function addToSlotIndex(ifid: string, slot?: string): Promise<void> {
525
- if (!isNamedSlot(slot)) return Promise.resolve();
526
- return updateSlotIndex(ifid, (slots) =>
527
- slots.includes(slot) ? slots : [...slots, slot],
528
- );
512
+ /** Every slot: the default one (undefined), then those in the slot index. */
513
+ async function allSlots(ifid: string): Promise<(string | undefined)[]> {
514
+ const backend = await getBackend();
515
+ const slots = (await backend.getMeta<string[]>(slotIndexKey(ifid))) ?? [];
516
+ return [undefined, ...slots.filter(isNamedSlot)];
517
+ }
518
+
519
+ /** The ID of the save a slot holds, if any. */
520
+ async function slotSaveId(
521
+ ifid: string,
522
+ slot?: string,
523
+ ): Promise<string | undefined> {
524
+ return (await getBackend()).getMeta<string>(slotMetaKey(ifid, slot));
525
+ }
526
+
527
+ /** The record of the save a slot holds, if there is one. */
528
+ async function slotRecord(
529
+ ifid: string,
530
+ slot?: string,
531
+ ): Promise<SaveRecord | undefined> {
532
+ const id = await slotSaveId(ifid, slot);
533
+ return id ? (await getBackend()).getSave(id) : undefined;
529
534
  }
530
535
 
531
- /** Remove a named slot from the per-story slot index (no-op for the default slot). */
532
- function removeFromSlotIndex(ifid: string, slot?: string): Promise<void> {
533
- if (!isNamedSlot(slot)) return Promise.resolve();
534
- return updateSlotIndex(ifid, (slots) => slots.filter((s) => s !== slot));
536
+ /** Make a slot hold the save `saveId`: its pointer, and the slot index. */
537
+ async function fillSlot(
538
+ ifid: string,
539
+ slot: string | undefined,
540
+ saveId: string,
541
+ ): Promise<void> {
542
+ await (await getBackend()).setMeta(slotMetaKey(ifid, slot), saveId);
543
+ // A named slot is recorded in the slot index
544
+ if (isNamedSlot(slot)) {
545
+ await updateSlotIndex(ifid, (slots) =>
546
+ slots.includes(slot) ? slots : [...slots, slot],
547
+ );
548
+ }
535
549
  }
536
550
 
537
- /** Named slots in the per-story slot index. */
538
- async function getIndexedSlots(ifid: string): Promise<string[]> {
539
- const indexKey = `${SLOT_INDEX_KEY_PREFIX}${ifid}`;
540
- const slots = (await (await getBackend()).getMeta<string[]>(indexKey)) ?? [];
541
- return slots.filter(isNamedSlot);
551
+ /** Clear a slot: its pointer, and its entry in the slot index. */
552
+ async function emptySlot(ifid: string, slot?: string): Promise<void> {
553
+ await (await getBackend()).deleteMeta(slotMetaKey(ifid, slot));
554
+ if (isNamedSlot(slot)) {
555
+ await updateSlotIndex(ifid, (slots) => slots.filter((s) => s !== slot));
556
+ }
542
557
  }
543
558
 
559
+ /** Clear every slot holding a save whose ID passes `test`. */
560
+ async function emptySlotsHolding(
561
+ ifid: string,
562
+ test: (saveId: string) => boolean,
563
+ ): Promise<void> {
564
+ for (const slot of await allSlots(ifid)) {
565
+ const id = await slotSaveId(ifid, slot);
566
+ if (id && test(id)) await emptySlot(ifid, slot);
567
+ }
568
+ }
569
+
570
+ /** The `custom` keys that say which slot a save lives in. */
571
+ const slotCustom = (slot: string | undefined) => ({
572
+ isAutosave: !isNamedSlot(slot),
573
+ ...(isNamedSlot(slot) ? { slot } : {}),
574
+ });
575
+
544
576
  function toSaveInfo(record: SaveRecord, slot?: string): SaveInfo {
545
577
  return {
546
578
  slot: isNamedSlot(slot) ? slot : '',
@@ -565,11 +597,9 @@ export function quickSave(
565
597
  slot?: string,
566
598
  custom?: Record<string, unknown>,
567
599
  ): Promise<SaveRecord> {
568
- const metaKey = slotMetaKey(ifid, slot);
569
600
  return inOrder(async () => {
570
601
  const ptId = await playthroughId;
571
- const backend = await getBackend();
572
- const existingId = await backend.getMeta<string>(metaKey);
602
+ const existingId = await slotSaveId(ifid, slot);
573
603
 
574
604
  if (existingId) {
575
605
  const updated = await overwriteSaveNow(existingId, payload, custom, ptId);
@@ -578,41 +608,24 @@ export function quickSave(
578
608
 
579
609
  // Create new save
580
610
  const record = await createSaveNow(ifid, ptId, payload, {
581
- isAutosave: !isNamedSlot(slot),
582
- ...(isNamedSlot(slot) ? { slot } : {}),
611
+ ...slotCustom(slot),
583
612
  ...custom,
584
613
  });
585
- await backend.setMeta(metaKey, record.meta.id);
586
- await addToSlotIndex(ifid, slot);
614
+ await fillSlot(ifid, slot, record.meta.id);
587
615
 
588
616
  return record;
589
617
  });
590
618
  }
591
619
 
592
- export function hasQuickSave(ifid: string, slot?: string): Promise<boolean> {
593
- return inOrder(() => hasQuickSaveNow(ifid, slot));
594
- }
620
+ export const hasQuickSave = queued(hasQuickSaveNow);
595
621
 
596
622
  async function hasQuickSaveNow(ifid: string, slot?: string): Promise<boolean> {
597
- const backend = await getBackend();
598
- const metaKey = slotMetaKey(ifid, slot);
599
- const existingId = await backend.getMeta<string>(metaKey);
600
- if (!existingId) return false;
601
- const record = await backend.getSave(existingId);
602
- return record !== undefined;
623
+ return (await slotRecord(ifid, slot)) !== undefined;
603
624
  }
604
625
 
605
- export function loadQuickSave(
606
- ifid: string,
607
- slot?: string,
608
- ): Promise<SavePayload | undefined> {
609
- return inOrder(async () => {
610
- const metaKey = slotMetaKey(ifid, slot);
611
- const existingId = await (await getBackend()).getMeta<string>(metaKey);
612
- if (!existingId) return undefined;
613
- return loadSaveNow(existingId);
614
- });
615
- }
626
+ export const loadQuickSave = queued(async (ifid: string, slot?: string) =>
627
+ livePayload(await slotRecord(ifid, slot)),
628
+ );
616
629
 
617
630
  /**
618
631
  * Read the save held in a slot (the default slot when `slot` is omitted) for
@@ -625,12 +638,8 @@ export function loadSlotSave(
625
638
  ifid: string,
626
639
  slot?: string,
627
640
  ): Promise<{ payload: SavePayload; playthroughId: string } | undefined> {
628
- const metaKey = slotMetaKey(ifid, slot);
629
641
  return inOrder(async () => {
630
- const backend = await getBackend();
631
- const existingId = await backend.getMeta<string>(metaKey);
632
- if (!existingId) return undefined;
633
- const record = await backend.getSave(existingId);
642
+ const record = await slotRecord(ifid, slot);
634
643
  if (!record) return undefined;
635
644
  const payload = deserializePayload(record.payload);
636
645
  const { playthroughId } = record.meta;
@@ -643,30 +652,16 @@ export function loadSlotSave(
643
652
  * Check storage for all known saves and return a map of slot keys to true.
644
653
  * The default (autosave) slot uses empty string as key.
645
654
  */
646
- export function populateKnownSaves(
647
- ifid: string,
648
- ): Promise<Record<string, true>> {
649
- return inOrder(() => populateKnownSavesNow(ifid));
650
- }
655
+ export const populateKnownSaves = queued(populateKnownSavesNow);
651
656
 
652
657
  async function populateKnownSavesNow(
653
658
  ifid: string,
654
659
  ): Promise<Record<string, true>> {
655
660
  // No prototype, so a slot named '__proto__' is recorded as an entry
656
661
  const result = Object.create(null) as Record<string, true>;
657
-
658
- // Check default autosave
659
- if (await hasQuickSaveNow(ifid)) {
660
- result[''] = true;
662
+ for (const slot of await allSlots(ifid)) {
663
+ if (await hasQuickSaveNow(ifid, slot)) result[slot ?? ''] = true;
661
664
  }
662
-
663
- // Check named slots from the index
664
- for (const slot of await getIndexedSlots(ifid)) {
665
- if (await hasQuickSaveNow(ifid, slot)) {
666
- result[slot] = true;
667
- }
668
- }
669
-
670
665
  return result;
671
666
  }
672
667
 
@@ -674,64 +669,37 @@ async function populateKnownSavesNow(
674
669
  * Get metadata for a specific save slot.
675
670
  * Returns null if no save exists for that slot.
676
671
  */
677
- export function getSlotSaveInfo(
678
- ifid: string,
679
- slot?: string,
680
- ): Promise<SaveInfo | null> {
681
- return inOrder(() => getSlotSaveInfoNow(ifid, slot));
682
- }
672
+ export const getSlotSaveInfo = queued(getSlotSaveInfoNow);
683
673
 
684
674
  async function getSlotSaveInfoNow(
685
675
  ifid: string,
686
676
  slot?: string,
687
677
  ): Promise<SaveInfo | null> {
688
- const backend = await getBackend();
689
- const metaKey = slotMetaKey(ifid, slot);
690
- const existingId = await backend.getMeta<string>(metaKey);
691
- if (!existingId) return null;
692
- const record = await backend.getSave(existingId);
693
- if (!record) return null;
694
- return toSaveInfo(record, slot);
678
+ const record = await slotRecord(ifid, slot);
679
+ return record ? toSaveInfo(record, slot) : null;
695
680
  }
696
681
 
697
682
  /**
698
683
  * List metadata for all known save slots (default + named).
699
684
  */
700
- export function listSlotSaves(ifid: string): Promise<SaveInfo[]> {
701
- return inOrder(async () => {
702
- const result: SaveInfo[] = [];
703
-
704
- // Check default autosave
705
- const defaultInfo = await getSlotSaveInfoNow(ifid);
706
- if (defaultInfo) result.push(defaultInfo);
707
-
708
- // Check named slots from the index
709
- for (const slot of await getIndexedSlots(ifid)) {
710
- const info = await getSlotSaveInfoNow(ifid, slot);
711
- if (info) result.push(info);
712
- }
713
-
714
- return result;
715
- });
716
- }
685
+ export const listSlotSaves = queued(async (ifid: string) => {
686
+ const result: SaveInfo[] = [];
687
+ for (const slot of await allSlots(ifid)) {
688
+ const info = await getSlotSaveInfoNow(ifid, slot);
689
+ if (info) result.push(info);
690
+ }
691
+ return result;
692
+ });
717
693
 
718
694
  /**
719
695
  * Delete a save by slot name. Removes from slot index if named.
720
696
  */
721
- export function deleteSlotSave(ifid: string, slot?: string): Promise<void> {
722
- const metaKey = slotMetaKey(ifid, slot);
723
- return inOrder(async () => {
724
- const backend = await getBackend();
725
- const existingId = await backend.getMeta<string>(metaKey);
726
- if (!existingId) return;
727
-
728
- await backend.deleteSave(existingId);
729
- await backend.deleteMeta(metaKey);
730
-
731
- // Remove from slot index if named
732
- await removeFromSlotIndex(ifid, slot);
733
- });
734
- }
697
+ export const deleteSlotSave = queued(async (ifid: string, slot?: string) => {
698
+ const existingId = await slotSaveId(ifid, slot);
699
+ if (!existingId) return;
700
+ await (await getBackend()).deleteSave(existingId);
701
+ await emptySlot(ifid, slot);
702
+ });
735
703
 
736
704
  // --- Session Persistence (survives F5, cleared on tab close) ---
737
705
 
@@ -774,14 +742,9 @@ export function clearSession(ifid: string): void {
774
742
 
775
743
  // --- Export / Import ---
776
744
 
777
- export function exportSave(saveId: string): Promise<SaveExport | undefined> {
778
- return inOrder(() => exportSaveNow(saveId));
779
- }
780
-
781
- async function exportSaveNow(saveId: string): Promise<SaveExport | undefined> {
782
- const record = await (await getBackend()).getSave(saveId);
745
+ /** The export of a stored save, if there is one. */
746
+ function toExport(record: SaveRecord | undefined): SaveExport | undefined {
783
747
  if (!record) return undefined;
784
-
785
748
  return {
786
749
  version: 1,
787
750
  ifid: record.meta.ifid,
@@ -790,6 +753,10 @@ async function exportSaveNow(saveId: string): Promise<SaveExport | undefined> {
790
753
  };
791
754
  }
792
755
 
756
+ export const exportSave = queued(async (saveId: string) =>
757
+ toExport(await (await getBackend()).getSave(saveId)),
758
+ );
759
+
793
760
  /**
794
761
  * Validate an export against the running story and build the record to store:
795
762
  * a fresh save ID, and the playthrough record created if it doesn't exist yet.
@@ -813,8 +780,6 @@ async function prepareImport(
813
780
  throw new Error('Invalid save file format');
814
781
  }
815
782
 
816
- const backend = await getBackend();
817
-
818
783
  // Re-assign a new ID to avoid collisions
819
784
  const record = deepClone(data.save);
820
785
  record.meta.id = crypto.randomUUID();
@@ -823,19 +788,11 @@ async function prepareImport(
823
788
  // with it, whatever its own metadata says
824
789
  record.meta.ifid = ifid;
825
790
 
826
- // Ensure the playthrough exists
827
- const playthroughs = await backend.getPlaythroughsByIfid(ifid);
828
- const ptExists = playthroughs.some((p) => p.id === record.meta.playthroughId);
829
- if (!ptExists) {
830
- // Create an "Imported" playthrough
831
- const pt: PlaythroughRecord = {
832
- id: record.meta.playthroughId,
833
- ifid,
834
- createdAt: record.meta.createdAt,
835
- label: 'Imported',
836
- };
837
- await backend.putPlaythrough(pt);
838
- }
791
+ await ensurePlaythrough(
792
+ ifid,
793
+ record.meta.playthroughId,
794
+ record.meta.createdAt,
795
+ );
839
796
 
840
797
  return record;
841
798
  }
@@ -855,18 +812,9 @@ export function importSave(
855
812
  * Export the save held in a slot (default autosave slot when `slot` is omitted).
856
813
  * Returns undefined if the slot is empty.
857
814
  */
858
- export function exportSlotSave(
859
- ifid: string,
860
- slot?: string,
861
- ): Promise<SaveExport | undefined> {
862
- return inOrder(async () => {
863
- const saveId = await (
864
- await getBackend()
865
- ).getMeta<string>(slotMetaKey(ifid, slot));
866
- if (!saveId) return undefined;
867
- return exportSaveNow(saveId);
868
- });
869
- }
815
+ export const exportSlotSave = queued(async (ifid: string, slot?: string) =>
816
+ toExport(await slotRecord(ifid, slot)),
817
+ );
870
818
 
871
819
  /**
872
820
  * Import an exported save into a slot (default autosave slot when `slot` is
@@ -879,25 +827,19 @@ export function importSlotSave(
879
827
  ifid: string,
880
828
  slot?: string,
881
829
  ): Promise<SaveInfo> {
882
- const metaKey = slotMetaKey(ifid, slot);
883
830
  return inOrder(async () => {
884
831
  const record = await prepareImport(data, ifid);
885
832
 
886
833
  // The slot keys in `custom` describe where the save lives, so they follow
887
834
  // the target slot rather than the slot the save was exported from.
888
835
  const { slot: _exportedSlot, ...custom } = record.meta.custom ?? {};
889
- record.meta.custom = {
890
- ...custom,
891
- isAutosave: !isNamedSlot(slot),
892
- ...(isNamedSlot(slot) ? { slot } : {}),
893
- };
836
+ record.meta.custom = { ...custom, ...slotCustom(slot) };
894
837
 
895
838
  const backend = await getBackend();
896
- const previousId = await backend.getMeta<string>(metaKey);
839
+ const previousId = await slotSaveId(ifid, slot);
897
840
 
898
841
  await backend.putSave(record);
899
- await backend.setMeta(metaKey, record.meta.id);
900
- await addToSlotIndex(ifid, slot);
842
+ await fillSlot(ifid, slot, record.meta.id);
901
843
  if (previousId) await backend.deleteSave(previousId);
902
844
 
903
845
  return toSaveInfo(record, slot);
@@ -906,11 +848,9 @@ export function importSlotSave(
906
848
 
907
849
  // --- Storage Management ---
908
850
 
909
- export function getStorageInfo(ifid: string): Promise<StorageInfo> {
910
- return inOrder(() => getStorageInfoNow(ifid));
911
- }
912
-
913
- async function getStorageInfoNow(ifid: string): Promise<StorageInfo> {
851
+ export const getStorageInfo = queued(async function getStorageInfoNow(
852
+ ifid: string,
853
+ ): Promise<StorageInfo> {
914
854
  const backend = await getBackend();
915
855
  const saves = await backend.getSavesByIfid(ifid);
916
856
  const playthroughs = await backend.getPlaythroughsByIfid(ifid);
@@ -921,7 +861,7 @@ async function getStorageInfoNow(ifid: string): Promise<StorageInfo> {
921
861
  totalBytes += save.meta.estimatedBytes;
922
862
  } else {
923
863
  // Lazy backfill for saves created before estimatedBytes was added
924
- const bytes = JSON.stringify(save.payload).length;
864
+ const bytes = estimatePayloadBytes(save.payload);
925
865
  save.meta.estimatedBytes = bytes;
926
866
  await backend.putSave(save);
927
867
  totalBytes += bytes;
@@ -934,7 +874,7 @@ async function getStorageInfoNow(ifid: string): Promise<StorageInfo> {
934
874
  totalBytes,
935
875
  backend: backend.type,
936
876
  };
937
- }
877
+ });
938
878
 
939
879
  export function clearGameData(ifid: string): Promise<void> {
940
880
  return inOrder(async () => {
@@ -946,9 +886,7 @@ export function clearGameData(ifid: string): Promise<void> {
946
886
  });
947
887
  }
948
888
 
949
- export function clearAllData(): Promise<void> {
950
- return inOrder(clearAllDataNow);
951
- }
889
+ export const clearAllData = queued(clearAllDataNow);
952
890
 
953
891
  async function clearAllDataNow(): Promise<void> {
954
892
  const backend = await getBackend();
@@ -988,14 +926,7 @@ export function deletePlaythroughData(
988
926
 
989
927
  // Clear the slots that held deleted saves (pointer and slot index)
990
928
  const deletedSet = new Set(deletedSaveIds);
991
- for (const slot of [undefined, ...(await getIndexedSlots(ifid))]) {
992
- const metaKey = slotMetaKey(ifid, slot);
993
- const value = await backend.getMeta<string>(metaKey);
994
- if (value && deletedSet.has(value)) {
995
- await backend.deleteMeta(metaKey);
996
- await removeFromSlotIndex(ifid, slot);
997
- }
998
- }
929
+ await emptySlotsHolding(ifid, (id) => deletedSet.has(id));
999
930
 
1000
931
  if (replacement && (await replacement.current) === playthroughId) {
1001
932
  await startNewPlaythroughNow(ifid, replacement.id);