@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
@@ -1,126 +1,160 @@
1
1
  import type { SaveRecord, PlaythroughRecord, StorageBackend } from './types';
2
2
 
3
3
  // ---------------------------------------------------------------------------
4
- // Memory backend
4
+ // Backend operations, over the tables each backend provides
5
5
  // ---------------------------------------------------------------------------
6
6
 
7
7
  /**
8
- * In-memory storage. Like the other backends it stores copies: records and
9
- * values go in and come out cloned, so changing a record after storing it,
10
- * or one that was read, changes nothing stored.
8
+ * One kind of record a backend stores, by key: saves, playthroughs or meta
9
+ * values. Each backend implements these few operations; every operation of
10
+ * a StorageBackend is built on them once, by createBackend().
11
11
  */
12
- export function createMemoryBackend(): StorageBackend {
13
- const copy = <T>(value: T): T => structuredClone(value);
14
- const saves = new Map<string, SaveRecord>();
15
- const playthroughs = new Map<string, PlaythroughRecord>();
16
- const meta = new Map<string, unknown>();
17
-
18
- return {
19
- type: 'memory',
20
-
21
- async putSave(record: SaveRecord): Promise<void> {
22
- saves.set(record.meta.id, copy(record));
23
- },
12
+ interface Table<T> {
13
+ get(key: string): Promise<T | undefined>;
14
+ put(key: string, value: T): Promise<void>;
15
+ delete(key: string): Promise<void>;
16
+ /** The records whose indexed `field` (see makeTables) holds `value`. */
17
+ find(field: string, value: string): Promise<T[]>;
18
+ /** Every key, in the backend's order. */
19
+ keys(): Promise<string[]>;
20
+ }
24
21
 
25
- async getSave(id: string): Promise<SaveRecord | undefined> {
26
- const record = saves.get(id);
27
- return record && copy(record);
28
- },
22
+ type TableName = 'saves' | 'playthroughs' | 'meta';
29
23
 
30
- async deleteSave(id: string): Promise<void> {
31
- saves.delete(id);
32
- },
33
-
34
- async getSavesByIfid(ifid: string): Promise<SaveRecord[]> {
35
- return [...saves.values()].filter((s) => s.meta.ifid === ifid).map(copy);
36
- },
24
+ /** A table's indexed fields, and how to read them from a record. */
25
+ type Indexes<T> = Record<string, (record: T) => string>;
37
26
 
38
- async deleteSavesByIfid(ifid: string): Promise<void> {
39
- for (const id of [...saves.keys()]) {
40
- const s = saves.get(id);
41
- if (s && s.meta.ifid === ifid) saves.delete(id);
42
- }
43
- },
44
-
45
- async deleteSavesByPlaythrough(playthroughId: string): Promise<string[]> {
46
- const deleted: string[] = [];
47
- for (const id of [...saves.keys()]) {
48
- const s = saves.get(id);
49
- if (s && s.meta.playthroughId === playthroughId) {
50
- saves.delete(id);
51
- deleted.push(id);
52
- }
53
- }
54
- return deleted;
55
- },
27
+ type MakeTable = <T>(name: TableName, indexes: Indexes<T>) => Table<T>;
56
28
 
57
- async putPlaythrough(record: PlaythroughRecord): Promise<void> {
58
- playthroughs.set(record.id, copy(record));
59
- },
60
-
61
- async getPlaythroughsByIfid(ifid: string): Promise<PlaythroughRecord[]> {
62
- return [...playthroughs.values()]
63
- .filter((p) => p.ifid === ifid)
64
- .map(copy);
65
- },
66
-
67
- async deletePlaythroughsByIfid(ifid: string): Promise<void> {
68
- for (const id of [...playthroughs.keys()]) {
69
- const p = playthroughs.get(id);
70
- if (p && p.ifid === ifid) playthroughs.delete(id);
71
- }
72
- },
29
+ interface Tables {
30
+ saves: Table<SaveRecord>;
31
+ playthroughs: Table<PlaythroughRecord>;
32
+ meta: Table<unknown>;
33
+ }
73
34
 
74
- async deletePlaythroughById(id: string): Promise<void> {
75
- playthroughs.delete(id);
76
- },
35
+ /** A backend's tables, made by its `table` function. */
36
+ function makeTables(table: MakeTable): Tables {
37
+ return {
38
+ saves: table<SaveRecord>('saves', {
39
+ ifid: (r) => r.meta.ifid,
40
+ playthroughId: (r) => r.meta.playthroughId,
41
+ }),
42
+ playthroughs: table<PlaythroughRecord>('playthroughs', {
43
+ ifid: (r) => r.ifid,
44
+ }),
45
+ meta: table('meta', {}),
46
+ };
47
+ }
77
48
 
78
- async getMeta<T = unknown>(key: string): Promise<T | undefined> {
79
- return copy(meta.get(key)) as T | undefined;
80
- },
49
+ function createBackend(
50
+ type: StorageBackend['type'],
51
+ { saves, playthroughs, meta }: Tables,
52
+ destroy: () => Promise<void>,
53
+ ): StorageBackend {
54
+ /** Delete the records `field` finds, resolving to their keys. */
55
+ async function deleteFound<T>(
56
+ table: Table<T>,
57
+ keyOf: (record: T) => string,
58
+ field: string,
59
+ value: string,
60
+ ): Promise<string[]> {
61
+ const keys = (await table.find(field, value)).map(keyOf);
62
+ for (const key of keys) await table.delete(key);
63
+ return keys;
64
+ }
81
65
 
82
- async setMeta(key: string, value: unknown): Promise<void> {
83
- meta.set(key, copy(value));
84
- },
66
+ async function deleteMetaWhere(test: (key: string) => boolean) {
67
+ for (const key of await meta.keys()) {
68
+ if (test(key)) await meta.delete(key);
69
+ }
70
+ }
85
71
 
86
- async deleteMeta(key: string): Promise<void> {
87
- meta.delete(key);
88
- },
72
+ const saveId = (r: SaveRecord) => r.meta.id;
73
+ const playthroughId = (r: PlaythroughRecord) => r.id;
89
74
 
90
- async deleteMetaByPrefix(prefix: string): Promise<void> {
91
- for (const key of [...meta.keys()]) {
92
- if (key.startsWith(prefix)) meta.delete(key);
93
- }
94
- },
75
+ return {
76
+ type,
77
+ putSave: (record) => saves.put(record.meta.id, record),
78
+ getSave: (id) => saves.get(id),
79
+ deleteSave: (id) => saves.delete(id),
80
+ getSavesByIfid: (ifid) => saves.find('ifid', ifid),
81
+ deleteSavesByIfid: async (ifid) => {
82
+ await deleteFound(saves, saveId, 'ifid', ifid);
83
+ },
84
+ deleteSavesByPlaythrough: (id) =>
85
+ deleteFound(saves, saveId, 'playthroughId', id),
86
+
87
+ putPlaythrough: (record) => playthroughs.put(record.id, record),
88
+ getPlaythroughsByIfid: (ifid) => playthroughs.find('ifid', ifid),
89
+ deletePlaythroughsByIfid: async (ifid) => {
90
+ await deleteFound(playthroughs, playthroughId, 'ifid', ifid);
91
+ },
92
+ deletePlaythroughById: (id) => playthroughs.delete(id),
93
+
94
+ getMeta: <T>(key: string) => meta.get(key) as Promise<T | undefined>,
95
+ setMeta: (key, value) => meta.put(key, value),
96
+ deleteMeta: (key) => meta.delete(key),
97
+ deleteMetaByPrefix: (prefix) =>
98
+ deleteMetaWhere((key) => key.startsWith(prefix)),
99
+ deleteMetaByIfid: (ifid) => deleteMetaWhere((key) => key.includes(ifid)),
100
+ getAllMetaKeys: () => meta.keys(),
101
+
102
+ destroy,
103
+ };
104
+ }
95
105
 
96
- async deleteMetaByIfid(ifid: string): Promise<void> {
97
- for (const key of [...meta.keys()]) {
98
- if (key.includes(ifid)) meta.delete(key);
99
- }
100
- },
106
+ // ---------------------------------------------------------------------------
107
+ // Memory backend
108
+ // ---------------------------------------------------------------------------
101
109
 
102
- async getAllMetaKeys(): Promise<string[]> {
103
- return [...meta.keys()];
104
- },
110
+ /**
111
+ * In-memory storage. Like the other backends it stores copies: records and
112
+ * values go in and come out cloned, so changing a record after storing it,
113
+ * or one that was read, changes nothing stored.
114
+ */
115
+ export function createMemoryBackend(): StorageBackend {
116
+ const copy = <T>(value: T): T => structuredClone(value);
117
+ const maps: Map<string, unknown>[] = [];
118
+
119
+ function table<T>(_name: TableName, indexes: Indexes<T>): Table<T> {
120
+ const rows = new Map<string, T>();
121
+ maps.push(rows);
122
+ return {
123
+ get: async (key) => copy(rows.get(key)),
124
+ put: async (key, value) => {
125
+ rows.set(key, copy(value));
126
+ },
127
+ delete: async (key) => {
128
+ rows.delete(key);
129
+ },
130
+ find: async (field, value) =>
131
+ [...rows.values()]
132
+ .filter((r) => indexes[field]!(r) === value)
133
+ .map(copy),
134
+ keys: async () => [...rows.keys()],
135
+ };
136
+ }
105
137
 
106
- async destroy(): Promise<void> {
107
- saves.clear();
108
- playthroughs.clear();
109
- meta.clear();
110
- },
111
- };
138
+ return createBackend('memory', makeTables(table), async () => {
139
+ for (const rows of maps) rows.clear();
140
+ });
112
141
  }
113
142
 
114
143
  // ---------------------------------------------------------------------------
115
144
  // localStorage backend
116
145
  // ---------------------------------------------------------------------------
117
146
 
118
- const LS_SAVE_PREFIX = 'spindle.save.';
119
- const LS_PT_PREFIX = 'spindle.pt.';
120
- const LS_META_PREFIX = 'spindle.meta.';
121
- const LS_IDX_SAVES_PREFIX = 'spindle.idx.saves.';
122
- const LS_IDX_PT_PREFIX = 'spindle.idx.pt.';
123
- const LS_IDX_SAVES_PT_PREFIX = 'spindle.idx.saves-pt.';
147
+ /** Key prefixes: of the records, and of each index's ID lists. */
148
+ const LS_PREFIXES: Record<TableName, string> = {
149
+ saves: 'spindle.save.',
150
+ playthroughs: 'spindle.pt.',
151
+ meta: 'spindle.meta.',
152
+ };
153
+ const LS_INDEX_PREFIXES: Record<string, string> = {
154
+ 'saves.ifid': 'spindle.idx.saves.',
155
+ 'saves.playthroughId': 'spindle.idx.saves-pt.',
156
+ 'playthroughs.ifid': 'spindle.idx.pt.',
157
+ };
124
158
 
125
159
  function lsGet<T>(key: string): T | undefined {
126
160
  const raw = localStorage.getItem(key);
@@ -152,162 +186,67 @@ function lsIndexAdd(key: string, entry: string): void {
152
186
  }
153
187
  }
154
188
 
189
+ /** Remove an ID from an index; an index left empty is removed. */
155
190
  function lsIndexRemove(key: string, entry: string): void {
156
191
  const arr = lsIndex(key).filter((e) => e !== entry);
157
- lsSet(key, arr);
192
+ if (arr.length > 0) lsSet(key, arr);
193
+ else lsDel(key);
158
194
  }
159
195
 
160
- export function createLocalStorageBackend(): StorageBackend {
161
- return {
162
- type: 'localstorage',
163
-
164
- async putSave(record: SaveRecord): Promise<void> {
165
- const id = record.meta.id;
166
- // A save rewritten under another playthrough (an overwrite after a
167
- // restart) leaves its old playthrough's index
168
- const previous = lsGet<SaveRecord>(`${LS_SAVE_PREFIX}${id}`);
169
- if (previous && previous.meta.playthroughId !== record.meta.playthroughId)
170
- lsIndexRemove(
171
- `${LS_IDX_SAVES_PT_PREFIX}${previous.meta.playthroughId}`,
172
- id,
173
- );
174
- lsSet(`${LS_SAVE_PREFIX}${id}`, record);
175
- lsIndexAdd(`${LS_IDX_SAVES_PREFIX}${record.meta.ifid}`, id);
176
- lsIndexAdd(`${LS_IDX_SAVES_PT_PREFIX}${record.meta.playthroughId}`, id);
177
- },
178
-
179
- async getSave(id: string): Promise<SaveRecord | undefined> {
180
- return lsGet<SaveRecord>(`${LS_SAVE_PREFIX}${id}`);
181
- },
182
-
183
- async deleteSave(id: string): Promise<void> {
184
- const record = lsGet<SaveRecord>(`${LS_SAVE_PREFIX}${id}`);
185
- if (record) {
186
- lsIndexRemove(`${LS_IDX_SAVES_PREFIX}${record.meta.ifid}`, id);
187
- lsIndexRemove(
188
- `${LS_IDX_SAVES_PT_PREFIX}${record.meta.playthroughId}`,
189
- id,
190
- );
191
- }
192
- lsDel(`${LS_SAVE_PREFIX}${id}`);
193
- },
196
+ /** The keys of every stored item that start with `prefix`. */
197
+ const lsKeys = (prefix: string): string[] =>
198
+ Object.keys(localStorage).filter((key) => key.startsWith(prefix));
194
199
 
195
- async getSavesByIfid(ifid: string): Promise<SaveRecord[]> {
196
- const ids = lsIndex(`${LS_IDX_SAVES_PREFIX}${ifid}`);
197
- const results: SaveRecord[] = [];
198
- for (const id of ids) {
199
- const record = lsGet<SaveRecord>(`${LS_SAVE_PREFIX}${id}`);
200
- if (record) results.push(record);
201
- }
202
- return results;
203
- },
204
-
205
- async deleteSavesByIfid(ifid: string): Promise<void> {
206
- const ids = lsIndex(`${LS_IDX_SAVES_PREFIX}${ifid}`);
207
- for (const id of ids) {
208
- const record = lsGet<SaveRecord>(`${LS_SAVE_PREFIX}${id}`);
209
- if (record) {
210
- lsIndexRemove(
211
- `${LS_IDX_SAVES_PT_PREFIX}${record.meta.playthroughId}`,
212
- id,
213
- );
214
- }
215
- lsDel(`${LS_SAVE_PREFIX}${id}`);
216
- }
217
- lsDel(`${LS_IDX_SAVES_PREFIX}${ifid}`);
218
- },
219
-
220
- async deleteSavesByPlaythrough(playthroughId: string): Promise<string[]> {
221
- const ids = lsIndex(`${LS_IDX_SAVES_PT_PREFIX}${playthroughId}`);
222
- for (const id of ids) {
223
- const record = lsGet<SaveRecord>(`${LS_SAVE_PREFIX}${id}`);
224
- if (record) {
225
- lsIndexRemove(`${LS_IDX_SAVES_PREFIX}${record.meta.ifid}`, id);
226
- }
227
- lsDel(`${LS_SAVE_PREFIX}${id}`);
228
- }
229
- lsDel(`${LS_IDX_SAVES_PT_PREFIX}${playthroughId}`);
230
- return ids;
231
- },
232
-
233
- async putPlaythrough(record: PlaythroughRecord): Promise<void> {
234
- lsSet(`${LS_PT_PREFIX}${record.id}`, record);
235
- lsIndexAdd(`${LS_IDX_PT_PREFIX}${record.ifid}`, record.id);
236
- },
237
-
238
- async getPlaythroughsByIfid(ifid: string): Promise<PlaythroughRecord[]> {
239
- const ids = lsIndex(`${LS_IDX_PT_PREFIX}${ifid}`);
240
- const results: PlaythroughRecord[] = [];
241
- for (const id of ids) {
242
- const record = lsGet<PlaythroughRecord>(`${LS_PT_PREFIX}${id}`);
243
- if (record) results.push(record);
244
- }
245
- return results;
246
- },
247
-
248
- async deletePlaythroughsByIfid(ifid: string): Promise<void> {
249
- const ids = lsIndex(`${LS_IDX_PT_PREFIX}${ifid}`);
250
- for (const id of ids) {
251
- lsDel(`${LS_PT_PREFIX}${id}`);
252
- }
253
- lsDel(`${LS_IDX_PT_PREFIX}${ifid}`);
254
- },
255
-
256
- async deletePlaythroughById(id: string): Promise<void> {
257
- const record = lsGet<PlaythroughRecord>(`${LS_PT_PREFIX}${id}`);
258
- if (record) {
259
- lsIndexRemove(`${LS_IDX_PT_PREFIX}${record.ifid}`, id);
260
- }
261
- lsDel(`${LS_PT_PREFIX}${id}`);
262
- },
263
-
264
- async getMeta<T = unknown>(key: string): Promise<T | undefined> {
265
- return lsGet<T>(`${LS_META_PREFIX}${key}`);
266
- },
267
-
268
- async setMeta(key: string, value: unknown): Promise<void> {
269
- lsSet(`${LS_META_PREFIX}${key}`, value);
270
- },
271
-
272
- async deleteMeta(key: string): Promise<void> {
273
- lsDel(`${LS_META_PREFIX}${key}`);
274
- },
275
-
276
- async deleteMetaByPrefix(prefix: string): Promise<void> {
277
- const fullPrefix = `${LS_META_PREFIX}${prefix}`;
278
- for (const key of Object.keys(localStorage)) {
279
- if (key.startsWith(fullPrefix)) {
280
- localStorage.removeItem(key);
200
+ export function createLocalStorageBackend(): StorageBackend {
201
+ function table<T>(name: TableName, fields: Indexes<T>): Table<T> {
202
+ const prefix = LS_PREFIXES[name];
203
+ const indexes = Object.entries(fields).map(([field, read]) => ({
204
+ field,
205
+ read,
206
+ prefix: LS_INDEX_PREFIXES[`${name}.${field}`]!,
207
+ }));
208
+ /** The index lists `record` is in. */
209
+ const listsOf = (record: T) =>
210
+ indexes.map((index) => `${index.prefix}${index.read(record)}`);
211
+
212
+ return {
213
+ get: async (key) => lsGet<T>(`${prefix}${key}`),
214
+ async put(key, value) {
215
+ // A record rewritten under another index value (a save overwritten
216
+ // after a restart, under the new playthrough) leaves the old list
217
+ const previous = lsGet<T>(`${prefix}${key}`);
218
+ const lists = listsOf(value);
219
+ if (previous !== undefined) {
220
+ for (const list of listsOf(previous)) {
221
+ if (!lists.includes(list)) lsIndexRemove(list, key);
222
+ }
281
223
  }
282
- }
283
- },
284
-
285
- async deleteMetaByIfid(ifid: string): Promise<void> {
286
- for (const key of Object.keys(localStorage)) {
287
- if (key.startsWith(LS_META_PREFIX) && key.includes(ifid)) {
288
- localStorage.removeItem(key);
224
+ lsSet(`${prefix}${key}`, value);
225
+ for (const list of lists) lsIndexAdd(list, key);
226
+ },
227
+ async delete(key) {
228
+ const record = lsGet<T>(`${prefix}${key}`);
229
+ if (record !== undefined) {
230
+ for (const list of listsOf(record)) lsIndexRemove(list, key);
289
231
  }
290
- }
291
- },
292
-
293
- async getAllMetaKeys(): Promise<string[]> {
294
- const keys: string[] = [];
295
- for (const key of Object.keys(localStorage)) {
296
- if (key.startsWith(LS_META_PREFIX)) {
297
- keys.push(key.slice(LS_META_PREFIX.length));
232
+ lsDel(`${prefix}${key}`);
233
+ },
234
+ async find(field, value) {
235
+ const index = indexes.find((i) => i.field === field)!;
236
+ const results: T[] = [];
237
+ for (const id of lsIndex(`${index.prefix}${value}`)) {
238
+ const record = lsGet<T>(`${prefix}${id}`);
239
+ if (record !== undefined) results.push(record);
298
240
  }
299
- }
300
- return keys;
301
- },
241
+ return results;
242
+ },
243
+ keys: async () => lsKeys(prefix).map((key) => key.slice(prefix.length)),
244
+ };
245
+ }
302
246
 
303
- async destroy(): Promise<void> {
304
- for (const key of Object.keys(localStorage)) {
305
- if (key.startsWith('spindle.')) {
306
- localStorage.removeItem(key);
307
- }
308
- }
309
- },
310
- };
247
+ return createBackend('localstorage', makeTables(table), async () => {
248
+ for (const key of lsKeys('spindle.')) lsDel(key);
249
+ });
311
250
  }
312
251
 
313
252
  // ---------------------------------------------------------------------------
@@ -358,15 +297,6 @@ function createIDBBackend(): StorageBackend {
358
297
  return dbPromise;
359
298
  }
360
299
 
361
- function tx(
362
- storeName: string,
363
- mode: IDBTransactionMode,
364
- ): Promise<IDBObjectStore> {
365
- return openDB().then((db) =>
366
- db.transaction(storeName, mode).objectStore(storeName),
367
- );
368
- }
369
-
370
300
  function idbReq<T>(req: IDBRequest<T>): Promise<T> {
371
301
  return new Promise((resolve, reject) => {
372
302
  req.onsuccess = () => resolve(req.result);
@@ -374,156 +304,53 @@ function createIDBBackend(): StorageBackend {
374
304
  });
375
305
  }
376
306
 
377
- async function getAllFromIndex<T>(
378
- storeName: string,
379
- indexName: string,
380
- value: string,
381
- ): Promise<T[]> {
382
- const store = await tx(storeName, 'readonly');
383
- const index = store.index(indexName);
384
- const result = await idbReq(index.getAll(value));
385
- return (result ?? []) as T[];
307
+ /**
308
+ * An object store. Records hold their own key (keyPath); meta values are
309
+ * stored as `{ key, value }` rows.
310
+ */
311
+ function table<T>(name: TableName): Table<T> {
312
+ const rows = name === 'meta';
313
+ /** Run one request in a transaction of its own. */
314
+ const request = async <R>(
315
+ mode: IDBTransactionMode,
316
+ make: (store: IDBObjectStore) => IDBRequest<R>,
317
+ ): Promise<R> => {
318
+ const db = await openDB();
319
+ return idbReq(make(db.transaction(name, mode).objectStore(name)));
320
+ };
321
+
322
+ return {
323
+ async get(key) {
324
+ const result = await request('readonly', (s) => s.get(key));
325
+ return (rows ? result?.value : result) as T | undefined;
326
+ },
327
+ async put(key, value) {
328
+ await request('readwrite', (s) => s.put(rows ? { key, value } : value));
329
+ },
330
+ async delete(key) {
331
+ await request('readwrite', (s) => s.delete(key));
332
+ },
333
+ async find(field, value) {
334
+ const result = await request('readonly', (s) =>
335
+ s.index(field).getAll(value),
336
+ );
337
+ return (result ?? []) as T[];
338
+ },
339
+ keys: async () =>
340
+ (await request('readonly', (s) => s.getAllKeys())) as string[],
341
+ };
386
342
  }
387
343
 
388
- return {
389
- type: 'indexeddb',
390
-
391
- async putSave(record: SaveRecord): Promise<void> {
392
- const store = await tx('saves', 'readwrite');
393
- await idbReq(store.put(record));
394
- },
395
-
396
- async getSave(id: string): Promise<SaveRecord | undefined> {
397
- const store = await tx('saves', 'readonly');
398
- return (
399
- ((await idbReq(store.get(id))) as SaveRecord | undefined) ?? undefined
400
- );
401
- },
402
-
403
- async deleteSave(id: string): Promise<void> {
404
- const store = await tx('saves', 'readwrite');
405
- await idbReq(store.delete(id));
406
- },
407
-
408
- async getSavesByIfid(ifid: string): Promise<SaveRecord[]> {
409
- return getAllFromIndex<SaveRecord>('saves', 'ifid', ifid);
410
- },
411
-
412
- async deleteSavesByIfid(ifid: string): Promise<void> {
413
- const records = await getAllFromIndex<SaveRecord>('saves', 'ifid', ifid);
414
- const store = await tx('saves', 'readwrite');
415
- for (const r of records) {
416
- await idbReq(store.delete(r.meta.id));
417
- }
418
- },
419
-
420
- async deleteSavesByPlaythrough(playthroughId: string): Promise<string[]> {
421
- const records = await getAllFromIndex<SaveRecord>(
422
- 'saves',
423
- 'playthroughId',
424
- playthroughId,
425
- );
426
- const store = await tx('saves', 'readwrite');
427
- const ids: string[] = [];
428
- for (const r of records) {
429
- await idbReq(store.delete(r.meta.id));
430
- ids.push(r.meta.id);
431
- }
432
- return ids;
433
- },
434
-
435
- async putPlaythrough(record: PlaythroughRecord): Promise<void> {
436
- const store = await tx('playthroughs', 'readwrite');
437
- await idbReq(store.put(record));
438
- },
439
-
440
- async getPlaythroughsByIfid(ifid: string): Promise<PlaythroughRecord[]> {
441
- return getAllFromIndex<PlaythroughRecord>('playthroughs', 'ifid', ifid);
442
- },
443
-
444
- async deletePlaythroughsByIfid(ifid: string): Promise<void> {
445
- const records = await getAllFromIndex<PlaythroughRecord>(
446
- 'playthroughs',
447
- 'ifid',
448
- ifid,
449
- );
450
- const store = await tx('playthroughs', 'readwrite');
451
- for (const r of records) {
452
- await idbReq(store.delete(r.id));
453
- }
454
- },
455
-
456
- async deletePlaythroughById(id: string): Promise<void> {
457
- const store = await tx('playthroughs', 'readwrite');
458
- await idbReq(store.delete(id));
459
- },
460
-
461
- async getMeta<T = unknown>(key: string): Promise<T | undefined> {
462
- const store = await tx('meta', 'readonly');
463
- const row = (await idbReq(store.get(key))) as
464
- | { key: string; value: T }
465
- | undefined;
466
- return row ? row.value : undefined;
467
- },
468
-
469
- async setMeta(key: string, value: unknown): Promise<void> {
470
- const store = await tx('meta', 'readwrite');
471
- await idbReq(store.put({ key, value }));
472
- },
473
-
474
- async deleteMeta(key: string): Promise<void> {
475
- const store = await tx('meta', 'readwrite');
476
- await idbReq(store.delete(key));
477
- },
478
-
479
- async deleteMetaByPrefix(prefix: string): Promise<void> {
480
- const store = await tx('meta', 'readwrite');
481
- const allRows = (await idbReq(store.getAll())) as Array<{
482
- key: string;
483
- value: unknown;
484
- }>;
485
- for (const row of allRows) {
486
- if (row.key.startsWith(prefix)) {
487
- const delStore = await tx('meta', 'readwrite');
488
- await idbReq(delStore.delete(row.key));
489
- }
490
- }
491
- },
492
-
493
- async deleteMetaByIfid(ifid: string): Promise<void> {
494
- const store = await tx('meta', 'readwrite');
495
- const allRows = (await idbReq(store.getAll())) as Array<{
496
- key: string;
497
- value: unknown;
498
- }>;
499
- for (const row of allRows) {
500
- if (row.key.includes(ifid)) {
501
- const delStore = await tx('meta', 'readwrite');
502
- await idbReq(delStore.delete(row.key));
503
- }
504
- }
505
- },
506
-
507
- async getAllMetaKeys(): Promise<string[]> {
508
- const store = await tx('meta', 'readonly');
509
- const allRows = (await idbReq(store.getAll())) as Array<{
510
- key: string;
511
- value: unknown;
512
- }>;
513
- return allRows.map((r) => r.key);
514
- },
515
-
516
- async destroy(): Promise<void> {
517
- const db = await openDB();
518
- db.close();
519
- dbPromise = null;
520
- await new Promise<void>((resolve, reject) => {
521
- const req = indexedDB.deleteDatabase(IDB_DB_NAME);
522
- req.onsuccess = () => resolve();
523
- req.onerror = () => reject(req.error);
524
- });
525
- },
526
- };
344
+ return createBackend('indexeddb', makeTables(table), async () => {
345
+ const db = await openDB();
346
+ db.close();
347
+ dbPromise = null;
348
+ await new Promise<void>((resolve, reject) => {
349
+ const req = indexedDB.deleteDatabase(IDB_DB_NAME);
350
+ req.onsuccess = () => resolve();
351
+ req.onerror = () => reject(req.error);
352
+ });
353
+ });
527
354
  }
528
355
 
529
356
  // ---------------------------------------------------------------------------