Skip to content

Commit bc2cef7

Browse files
authored
feat: expose save metadata via Story API (#97) (#99)
## Summary - **`save(slot?, custom?)`**: Extended to accept custom metadata (e.g. `{ day: 3, phase: 'morning' }`) that is stored alongside the save and merged on overwrite - **`getSaveInfo(slot?)`**: New async method returning `SaveInfo` (slot, title, passage, createdAt, updatedAt, custom) for a specific slot - **`listSaves()`**: New async method returning `SaveInfo[]` for all known saves (default + named) - **`deleteSave(slot?)`**: New method to delete a save and update `knownSaves` cache - **`SaveInfo`** type added to `types/index.d.ts` and `pkg/types/index.d.ts` Games building custom save/load UIs can now display slot metadata like "Day 12 — Work Phase — saved 3:42 PM" without maintaining a parallel persistence layer. ## Test plan - [x] All 891 tests pass (12 new, no regressions) - [x] New tests: getSlotSaveInfo (null/default/named/custom), listSlotSaves (empty/all), deleteSlotSave (named/default/populate-cleanup/no-op), quickSave custom metadata (new/merge) - [x] TypeScript compiles cleanly (`tsc --noEmit`) Closes #97 release-npm 🤖 Generated with [Claude Code](https://claude.com/claude-code)
2 parents b3ff6d7 + c1569af commit bc2cef7

8 files changed

Lines changed: 387 additions & 17 deletions

File tree

docs/story-api.md

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -174,13 +174,14 @@ Set a one-shot transition for the next navigation only. Consumed automatically w
174174
{/do}
175175
```
176176

177-
### `Story.save(slot?)`
177+
### `Story.save(slot?, custom?)`
178178

179-
Perform a quick save. When `slot` is provided, saves to a named slot instead of the default autosave slot.
179+
Perform a save. When `slot` is provided, saves to a named slot instead of the default autosave slot. Pass `custom` to attach metadata that can be retrieved later via `getSaveInfo()`.
180180

181181
```javascript
182-
Story.save(); // save to default slot
183-
Story.save('my-slot'); // save to named slot
182+
Story.save(); // default slot
183+
Story.save('my-slot'); // named slot
184+
Story.save('day-3', { day: 3, phase: 'morning' }); // with custom metadata
184185
```
185186

186187
### `Story.load(slot?)`
@@ -201,6 +202,42 @@ Story.hasSave(); // check default slot
201202
Story.hasSave('my-slot'); // check named slot
202203
```
203204

205+
### `Story.getSaveInfo(slot?)`
206+
207+
Returns a `Promise<SaveInfo | null>` with metadata for the given save slot.
208+
209+
```javascript
210+
const info = await Story.getSaveInfo('my-slot');
211+
if (info) {
212+
console.log(info.title); // "Room - 3:42 PM"
213+
console.log(info.passage); // "Room"
214+
console.log(info.updatedAt); // "2026-03-22T15:42:00.000Z"
215+
console.log(info.custom); // { day: 3, phase: "morning" }
216+
}
217+
```
218+
219+
The `SaveInfo` object contains: `slot`, `title`, `passage`, `createdAt`, `updatedAt`, `custom`.
220+
221+
### `Story.listSaves()`
222+
223+
Returns a `Promise<SaveInfo[]>` listing all known saves (default + named slots).
224+
225+
```javascript
226+
const saves = await Story.listSaves();
227+
for (const save of saves) {
228+
console.log(`${save.slot || 'autosave'}: ${save.title}`);
229+
}
230+
```
231+
232+
### `Story.deleteSave(slot?)`
233+
234+
Delete a save by slot name. Omit `slot` to delete the default autosave.
235+
236+
```javascript
237+
Story.deleteSave(); // delete default save
238+
Story.deleteSave('my-slot'); // delete named slot
239+
```
240+
204241
### `Story.defineMacro(config)`
205242

206243
Register a custom macro. See [Custom Macros](custom-macros.md) for full details.

pkg/types/index.d.ts

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,25 @@ export interface Passage {
102102
content: string;
103103
}
104104

105+
/**
106+
* Metadata about a save slot, returned by `getSaveInfo()` and `listSaves()`.
107+
* @see {@link ../../src/saves/types.ts} for the implementation.
108+
*/
109+
export interface SaveInfo {
110+
/** Slot name (empty string for the default autosave slot). */
111+
slot: string;
112+
/** Save title (generated or custom). */
113+
title: string;
114+
/** Passage name at the time of saving. */
115+
passage: string;
116+
/** ISO 8601 timestamp when the save was first created. */
117+
createdAt: string;
118+
/** ISO 8601 timestamp when the save was last updated. */
119+
updatedAt: string;
120+
/** Custom metadata passed when saving. */
121+
custom: Record<string, unknown>;
122+
}
123+
105124
/**
106125
* The main Story API available as `window.Story` at runtime.
107126
* Provides access to variables, navigation, save/load, and visit tracking.
@@ -128,15 +147,24 @@ export interface StoryAPI {
128147
/** Restart the story from the beginning. */
129148
restart(): void;
130149

131-
/** Save the current state (quick save). */
132-
save(slot?: string): void;
150+
/** Save the current state. Pass `slot` for a named save, `custom` for metadata. */
151+
save(slot?: string, custom?: Record<string, unknown>): void;
133152

134153
/** Load a saved state (quick load). */
135154
load(slot?: string): void;
136155

137156
/** Check whether a save exists. */
138157
hasSave(slot?: string): boolean;
139158

159+
/** Get metadata for a specific save slot. Returns null if no save exists. */
160+
getSaveInfo(slot?: string): Promise<SaveInfo | null>;
161+
162+
/** List metadata for all known save slots. */
163+
listSaves(): Promise<SaveInfo[]>;
164+
165+
/** Delete a save by slot name. */
166+
deleteSave(slot?: string): void;
167+
140168
/** Return the number of times a passage has been visited. */
141169
visited(name: string): number;
142170

src/saves/save-manager.ts

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type {
22
SavePayload,
33
SaveMeta,
44
SaveRecord,
5+
SaveInfo,
56
PlaythroughRecord,
67
SaveExport,
78
} from './types';
@@ -134,6 +135,7 @@ export async function createSave(
134135
export async function overwriteSave(
135136
saveId: string,
136137
payload: SavePayload,
138+
custom?: Record<string, unknown>,
137139
): Promise<SaveRecord | undefined> {
138140
const existing = await getSave(saveId);
139141
if (!existing) return undefined;
@@ -149,6 +151,9 @@ export async function overwriteSave(
149151
...existing.meta,
150152
updatedAt: new Date().toISOString(),
151153
passage: payload.passage,
154+
...(custom != null
155+
? { custom: { ...existing.meta.custom, ...custom } }
156+
: {}),
152157
},
153158
payload: serializedPayload,
154159
};
@@ -275,19 +280,21 @@ export async function quickSave(
275280
playthroughId: string,
276281
payload: SavePayload,
277282
slot?: string,
283+
custom?: Record<string, unknown>,
278284
): Promise<SaveRecord> {
279285
const metaKey = slotMetaKey(ifid, slot);
280286
const existingId = await getMeta<string>(metaKey);
281287

282288
if (existingId) {
283-
const updated = await overwriteSave(existingId, payload);
289+
const updated = await overwriteSave(existingId, payload, custom);
284290
if (updated) return updated;
285291
}
286292

287293
// Create new save
288294
const record = await createSave(ifid, playthroughId, payload, {
289295
isAutosave: !slot,
290296
...(slot != null ? { slot } : {}),
297+
...custom,
291298
});
292299
await setMeta(metaKey, record.meta.id);
293300

@@ -350,6 +357,73 @@ export async function populateKnownSaves(
350357
return result;
351358
}
352359

360+
/**
361+
* Get metadata for a specific save slot.
362+
* Returns null if no save exists for that slot.
363+
*/
364+
export async function getSlotSaveInfo(
365+
ifid: string,
366+
slot?: string,
367+
): Promise<SaveInfo | null> {
368+
const metaKey = slotMetaKey(ifid, slot);
369+
const existingId = await getMeta<string>(metaKey);
370+
if (!existingId) return null;
371+
const record = await getSave(existingId);
372+
if (!record) return null;
373+
return {
374+
slot: slot ?? '',
375+
title: record.meta.title,
376+
passage: record.meta.passage,
377+
createdAt: record.meta.createdAt,
378+
updatedAt: record.meta.updatedAt,
379+
custom: record.meta.custom ?? {},
380+
};
381+
}
382+
383+
/**
384+
* List metadata for all known save slots (default + named).
385+
*/
386+
export async function listSlotSaves(ifid: string): Promise<SaveInfo[]> {
387+
const result: SaveInfo[] = [];
388+
389+
// Check default autosave
390+
const defaultInfo = await getSlotSaveInfo(ifid);
391+
if (defaultInfo) result.push(defaultInfo);
392+
393+
// Check named slots from the index
394+
const indexKey = `${SLOT_INDEX_KEY_PREFIX}${ifid}`;
395+
const slots = (await getMeta<string[]>(indexKey)) ?? [];
396+
for (const slot of slots) {
397+
const info = await getSlotSaveInfo(ifid, slot);
398+
if (info) result.push(info);
399+
}
400+
401+
return result;
402+
}
403+
404+
/**
405+
* Delete a save by slot name. Removes from slot index if named.
406+
*/
407+
export async function deleteSlotSave(
408+
ifid: string,
409+
slot?: string,
410+
): Promise<void> {
411+
const metaKey = slotMetaKey(ifid, slot);
412+
const existingId = await getMeta<string>(metaKey);
413+
if (!existingId) return;
414+
415+
await idbDeleteSave(existingId);
416+
await setMeta(metaKey, undefined);
417+
418+
// Remove from slot index if named
419+
if (slot != null) {
420+
const indexKey = `${SLOT_INDEX_KEY_PREFIX}${ifid}`;
421+
const existing = (await getMeta<string[]>(indexKey)) ?? [];
422+
const updated = existing.filter((s) => s !== slot);
423+
await setMeta(indexKey, updated);
424+
}
425+
}
426+
353427
// --- Session Persistence (survives F5, cleared on tab close) ---
354428

355429
const SESSION_KEY_PREFIX = 'spindle.session.';

src/saves/types.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,16 @@ export interface PlaythroughRecord {
4141
label: string;
4242
}
4343

44+
/** Public-facing save metadata for the Story API. */
45+
export interface SaveInfo {
46+
slot: string;
47+
title: string;
48+
passage: string;
49+
createdAt: string;
50+
updatedAt: string;
51+
custom: Record<string, unknown>;
52+
}
53+
4454
export interface SaveExport {
4555
version: 1;
4656
ifid: string;

src/store.ts

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
} from 'immer';
99
import type { StoryData } from './parser';
1010
import type { TransitionConfig } from './transition';
11-
import type { SavePayload, SaveHistoryMoment } from './saves/types';
11+
import type { SavePayload, SaveHistoryMoment, SaveInfo } from './saves/types';
1212
import { executeStoryInit } from './story-init';
1313
import { resetTriggers } from './triggers';
1414
import {
@@ -18,6 +18,9 @@ import {
1818
quickSave,
1919
loadQuickSave,
2020
populateKnownSaves,
21+
getSlotSaveInfo,
22+
listSlotSaves,
23+
deleteSlotSave,
2124
saveSession,
2225
clearSession,
2326
} from './saves/save-manager';
@@ -222,9 +225,12 @@ export interface StoryState {
222225
deleteTemporary: (name: string) => void;
223226
trackRender: (passageName: string) => void;
224227
restart: () => void;
225-
save: (slot?: string) => void;
228+
save: (slot?: string, custom?: Record<string, unknown>) => void;
226229
load: (slot?: string) => void;
227230
hasSave: (slot?: string) => boolean;
231+
getSaveInfo: (slot?: string) => Promise<SaveInfo | null>;
232+
listSaves: () => Promise<SaveInfo[]>;
233+
deleteSave: (slot?: string) => void;
228234
getSavePayload: () => SavePayload;
229235
loadFromPayload: (payload: SavePayload) => void;
230236
getHistoryVariables: (index: number) => Record<string, unknown>;
@@ -496,7 +502,7 @@ export const useStoryStore = create<StoryState>()(
496502
);
497503
},
498504

499-
save: (slot?: string) => {
505+
save: (slot?: string, custom?: Record<string, unknown>) => {
500506
const { storyData, playthroughId } = get();
501507
if (!storyData) return;
502508

@@ -505,7 +511,7 @@ export const useStoryStore = create<StoryState>()(
505511
set((state) => {
506512
state.saveError = null;
507513
});
508-
quickSave(storyData.ifid, playthroughId, payload, slot)
514+
quickSave(storyData.ifid, playthroughId, payload, slot, custom)
509515
.then(() => {
510516
set((state) => {
511517
state.knownSaves = {
@@ -550,6 +556,35 @@ export const useStoryStore = create<StoryState>()(
550556
return (slot ?? '') in knownSaves;
551557
},
552558

559+
getSaveInfo: async (slot?: string): Promise<SaveInfo | null> => {
560+
const { storyData } = get();
561+
if (!storyData) return null;
562+
return getSlotSaveInfo(storyData.ifid, slot);
563+
},
564+
565+
listSaves: async (): Promise<SaveInfo[]> => {
566+
const { storyData } = get();
567+
if (!storyData) return [];
568+
return listSlotSaves(storyData.ifid);
569+
},
570+
571+
deleteSave: (slot?: string) => {
572+
const { storyData } = get();
573+
if (!storyData) return;
574+
575+
deleteSlotSave(storyData.ifid, slot)
576+
.then(() => {
577+
set((state) => {
578+
const key = slot ?? '';
579+
const { [key]: _, ...rest } = state.knownSaves;
580+
state.knownSaves = rest as Record<string, true>;
581+
});
582+
})
583+
.catch((err) => {
584+
console.error('spindle: failed to delete save', err);
585+
});
586+
},
587+
553588
getSavePayload: (): SavePayload => {
554589
const {
555590
currentPassage,

src/story-api.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { useStoryStore } from './store';
22
import type { Passage } from './parser';
33
import { settings } from './settings';
4-
import type { SavePayload } from './saves/types';
4+
import type { SavePayload, SaveInfo } from './saves/types';
55
import { setTitleGenerator } from './saves/save-manager';
66
import { registerClass } from './class-registry';
77
import { defineMacro } from './define-macro';
@@ -48,9 +48,12 @@ export interface StoryAPI {
4848
back(): void;
4949
forward(): void;
5050
restart(): void;
51-
save(slot?: string): void;
51+
save(slot?: string, custom?: Record<string, unknown>): void;
5252
load(slot?: string): void;
5353
hasSave(slot?: string): boolean;
54+
getSaveInfo(slot?: string): Promise<SaveInfo | null>;
55+
listSaves(): Promise<SaveInfo[]>;
56+
deleteSave(slot?: string): void;
5457
visited(name?: string): number;
5558
hasVisited(name?: string): boolean;
5659
hasVisitedAny(...names: string[]): boolean;
@@ -137,8 +140,8 @@ function createStoryAPI(): StoryAPI {
137140
useStoryStore.getState().restart();
138141
},
139142

140-
save(slot?: string): void {
141-
useStoryStore.getState().save(slot);
143+
save(slot?: string, custom?: Record<string, unknown>): void {
144+
useStoryStore.getState().save(slot, custom);
142145
},
143146

144147
load(slot?: string): void {
@@ -149,6 +152,18 @@ function createStoryAPI(): StoryAPI {
149152
return useStoryStore.getState().hasSave(slot);
150153
},
151154

155+
getSaveInfo(slot?: string): Promise<SaveInfo | null> {
156+
return useStoryStore.getState().getSaveInfo(slot);
157+
},
158+
159+
listSaves(): Promise<SaveInfo[]> {
160+
return useStoryStore.getState().listSaves();
161+
},
162+
163+
deleteSave(slot?: string): void {
164+
useStoryStore.getState().deleteSave(slot);
165+
},
166+
152167
visited(name?: string): number {
153168
const state = useStoryStore.getState();
154169
return state.visitCounts[name ?? state.currentPassage] ?? 0;

0 commit comments

Comments
 (0)