返回 CodeWhale
storage.ts
根目录 / pet / src / ui / storage.ts
1 import { type Trace } from '../core/model.js';
2 import type { PetBucket } from '../core/pet-telemetry.js';
3 import type { PetInteraction, PetWorldCheckpoint } from '../core/pet-world.js';
4 export interface SavedHabitat {
5 petPersistenceVersion: 1; seconds: number; source: 'wild' | 'demo' | 'replay';
6 sourceName: string; still: boolean; tape: readonly PetBucket[]; interactions: readonly PetInteraction[];
7 checkpoint?: PetWorldCheckpoint; expressionVersion?: 1 | 2;
8 petReplayVersion?: 1 | 2; start?: PetWorldCheckpoint;
9 }
10 export interface PetArchive { key: number; name: string; seconds: number; startSeconds: number }
11 export interface HabitatEntry { revision: number; habitat: SavedHabitat }
12 export interface SavedReference { key: string; savedAt: string }
13 export interface SavedSummary extends SavedReference { name: string; events: number; privacy: Trace['privacy']; source: Trace['source'] }
14 interface SavedTrace extends SavedReference { trace: Trace }
15 const summary = (v: SavedTrace): SavedSummary => ({ key: v.key, savedAt: v.savedAt, name: v.trace.name, events: v.trace.events.length, privacy: v.trace.privacy, source: v.trace.source });
16 const problem = (e?: DOMException | null): Error => new Error(e?.name === 'QuotaExceededError'
17 ? 'The browser storage quota is full. Export this trace to a file or delete an older saved trace.'
18 : e?.message || 'Browser storage failed. File import and export still work.');
19 const missing = () => new Error('This saved trace was deleted in another tab. Use Save separate copy to keep the loaded recording.');
20 const changed = () => new Error('This saved trace changed in another tab. Reopen it before updating, or use Save separate copy.');
21 /** Existing library with an additive habitat store, no destructive migration. savedAt is both a display time
22 * and an optimistic version; every mutation advances it by at least 1 ms.
23 * Trace and summary are committed together. A request success is not a commit. */
24 export class TraceLibrary {
25 private db?: Promise<IDBDatabase>;
26 constructor(private factory?: IDBFactory) {}
27 private open(): Promise<IDBDatabase> {
28 if (this.db) return this.db;
29 const opened = new Promise<IDBDatabase>((resolve, reject) => {
30 let abandoned = false, request: IDBOpenDBRequest;
31 try { request = (this.factory ?? indexedDB).open('whalesong-local', 3); }
32 catch { reject(new Error('This browser origin does not permit local persistence. Run npm start, or use JSONL export.')); return; }
33 request.onupgradeneeded = () => {
34 if (abandoned) { request.transaction?.abort(); return; }
35 const db = request.result;
36 if (!db.objectStoreNames.contains('habitats')) db.createObjectStore('habitats');
37 if (!db.objectStoreNames.contains('traces')) db.createObjectStore('traces', { keyPath: 'key' });
38 if (!db.objectStoreNames.contains('summaries')) {
39 const metas = db.createObjectStore('summaries', { keyPath: 'key' });
40 const cursor = request.transaction!.objectStore('traces').openCursor();
41 cursor.onsuccess = () => { const c = cursor.result; if (c) { try { metas.put(summary(c.value)); c.continue(); } catch { request.transaction?.abort(); } } };
42 }
43 };
44 request.onsuccess = () => {
45 if (abandoned) { request.result.close(); return; }
46 const db = request.result;
47 db.onversionchange = () => { db.close(); if (this.db === opened) this.db = undefined; };
48 db.onclose = () => { if (this.db === opened) this.db = undefined; };
49 resolve(db);
50 };
51 request.onblocked = () => { abandoned = true; reject(new Error('An older Whalesong tab is blocking the library upgrade. Close it, then reopen the library.')); };
52 request.onerror = () => reject(problem(request.error));
53 });
54 this.db = opened;
55 void opened.catch(() => { if (this.db === opened) this.db = undefined; });
56 return opened;
57 }
58 private async transaction<T>(mode: IDBTransactionMode, action: (traces: IDBObjectStore, metas: IDBObjectStore, done: (value: T) => void, fail: (error: Error) => void, habitats: IDBObjectStore) => void): Promise<T> {
59 const db = await this.open();
60 return new Promise<T>((resolve, reject) => {
61 let tx: IDBTransaction;
62 try { tx = db.transaction(['traces', 'summaries', 'habitats'], mode); }
63 catch (e) { this.db = undefined; reject(problem(e as DOMException)); return; }
64 let result: T, failure: Error | undefined;
65 const fail = (error: Error) => { failure ??= error; try { tx.abort(); } catch { reject(failure); } };
66 tx.oncomplete = () => failure ? reject(failure) : resolve(result);
67 tx.onerror = () => { failure ??= problem(tx.error); };
68 tx.onabort = () => reject(failure ?? problem(tx.error));
69 try { action(tx.objectStore('traces'), tx.objectStore('summaries'), value => { result = value; }, fail, tx.objectStore('habitats')); }
70 catch (e) { fail(e as Error); }
71 });
72 }
73 async save(trace: Trace, expected?: SavedReference): Promise<SavedReference> {
74 await this.open(); // Origin error before secure-context-only randomUUID.
75 const key = expected?.key ?? crypto.randomUUID();
76 return this.transaction('readwrite', (traces, metas, done, fail) => {
77 const req = traces.get(key);
78 req.onsuccess = () => {
79 try {
80 const previous: SavedTrace | undefined = req.result;
81 if (expected && !previous) throw missing();
82 if (expected && previous!.savedAt !== expected.savedAt) throw changed();
83 if (!expected && previous) throw new Error('A saved trace identifier collided. Try saving again.');
84 const savedAt = this.nextTime(previous?.savedAt), item: SavedTrace = { key, savedAt, trace };
85 traces.put(item); metas.put(summary(item)); done({ key, savedAt });
86 } catch (e) { fail(e as Error); }
87 };
88 });
89 }
90 private nextTime(previous?: string): string {
91 const last = previous ? Date.parse(previous) : 0;
92 return new Date(Math.max(Date.now(), Number.isFinite(last) ? last + 1 : 0)).toISOString();
93 }
94 async list(): Promise<SavedSummary[]> {
95 return this.transaction('readonly', (_traces, metas, done) => {
96 const req = metas.getAll();
97 req.onsuccess = () => done((req.result as SavedSummary[]).sort((a, b) => b.savedAt.localeCompare(a.savedAt)));
98 });
99 }
100 async getEntry(key: string): Promise<SavedTrace> {
101 return this.transaction('readonly', (traces, _metas, done, fail) => {
102 const req = traces.get(key); req.onsuccess = () => req.result ? done(req.result) : fail(missing());
103 });
104 }
105 async get(key: string): Promise<Trace> { return (await this.getEntry(key)).trace; }
106 async rename(expected: SavedReference, name: string): Promise<SavedReference> {
107 const trimmed = name.trim(); if (!trimmed || trimmed.length > 200) throw new Error('Use a name between 1 and 200 characters.');
108 return this.transaction('readwrite', (traces, metas, done, fail) => {
109 const req = traces.get(expected.key);
110 req.onsuccess = () => {
111 try {
112 if (!req.result) throw missing();
113 const item: SavedTrace = req.result;
114 if (item.savedAt !== expected.savedAt) throw changed();
115 item.trace.name = trimmed; item.savedAt = this.nextTime(item.savedAt);
116 traces.put(item); metas.put(summary(item)); done({ key: item.key, savedAt: item.savedAt });
117 } catch (e) { fail(e as Error); }
118 };
119 });
120 }
121 async delete(expected: SavedReference): Promise<void> {
122 await this.transaction<void>('readwrite', (traces, metas, done, fail) => {
123 const req = traces.get(expected.key);
124 req.onsuccess = () => {
125 if (req.result && req.result.savedAt !== expected.savedAt) { fail(changed()); return; }
126 traces.delete(expected.key); metas.delete(expected.key); done(undefined);
127 };
128 });
129 }
130 async clear(): Promise<void> {
131 await this.transaction<void>('readwrite', (traces, metas, done) => { traces.clear(); metas.clear(); done(undefined); });
132 }
133 async getHabitat(): Promise<HabitatEntry | undefined> {
134 return this.transaction('readonly', (_traces, _metas, done, _fail, habitats) => {
135 const request = habitats.get('pet'); request.onsuccess = () => done(request.result);
136 });
137 }
138 async saveHabitat(habitat: SavedHabitat, expectedRevision?: number, archive?: SavedHabitat): Promise<number> {
139 // Freeze the whole recording/checkpoint together before the first IDB await.
140 // A live world's arrays may otherwise advance while the transaction opens.
141 habitat = structuredClone(habitat);
142 if (archive) archive = structuredClone(archive);
143 return this.transaction('readwrite', (_traces, _metas, done, fail, habitats) => {
144 const request = habitats.get('pet');
145 request.onsuccess = () => {
146 const previous: HabitatEntry | undefined = request.result;
147 if (previous?.revision !== expectedRevision) {
148 fail(new Error('Another pet tab saved this habitat. Save a replay file to keep this version, or reload the saved habitat.')); return;
149 }
150 const revision = (previous?.revision ?? 0) + 1;
151 if (archive) {
152 habitats.add(archive, `pet-archive-${revision}`);
153 habitats.add({ key: revision, name: archive.sourceName, seconds: archive.seconds,
154 startSeconds: (archive.start?.frame.timeMs ?? 0) / 1000 }, `pet-archive-meta-${revision}`);
155 }
156 habitats.put({ revision, habitat }, 'pet'); done(revision);
157 };
158 });
159 }
160 async petArchives(): Promise<PetArchive[]> {
161 return this.transaction('readonly', (_traces, _metas, done, _fail, habitats) => {
162 const request = habitats.getAll(IDBKeyRange.bound('pet-archive-meta-', 'pet-archive-meta-\uffff'));
163 request.onsuccess = () => done((request.result as PetArchive[]).sort((a, b) => b.key - a.key));
164 });
165 }
166 async petArchive(key: number): Promise<SavedHabitat> {
167 if (!Number.isSafeInteger(key) || key < 1) throw new Error('Invalid recording archive.');
168 return this.transaction('readonly', (_traces, _metas, done, fail, habitats) => {
169 const request = habitats.get(`pet-archive-${key}`);
170 request.onsuccess = () => request.result ? done(request.result) : fail(new Error('This recording is unavailable.'));
171 });
172 }
173 }
174
174 lines TYPESCRIPT