| 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 |