albex 0.1.0 → 0.6.0

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 (72) hide show
  1. package/CHANGELOG.md +416 -0
  2. package/README.md +244 -112
  3. package/dist/albex-worker.d.ts +70 -0
  4. package/dist/albex-worker.d.ts.map +1 -0
  5. package/dist/albex-worker.js +153 -0
  6. package/dist/albex-worker.js.map +1 -0
  7. package/dist/albex.d.ts +508 -6
  8. package/dist/albex.d.ts.map +1 -1
  9. package/dist/albex.js +1911 -141
  10. package/dist/albex.js.map +1 -1
  11. package/dist/errors.d.ts +52 -0
  12. package/dist/errors.d.ts.map +1 -0
  13. package/dist/errors.js +66 -0
  14. package/dist/errors.js.map +1 -0
  15. package/dist/gpu/bloom-runtime.d.ts +60 -0
  16. package/dist/gpu/bloom-runtime.d.ts.map +1 -0
  17. package/dist/gpu/bloom-runtime.js +176 -0
  18. package/dist/gpu/bloom-runtime.js.map +1 -0
  19. package/dist/gpu/bloom-shader.wgsl.d.ts +19 -0
  20. package/dist/gpu/bloom-shader.wgsl.d.ts.map +1 -0
  21. package/dist/gpu/bloom-shader.wgsl.js +49 -0
  22. package/dist/gpu/bloom-shader.wgsl.js.map +1 -0
  23. package/dist/persistence.d.ts +21 -0
  24. package/dist/persistence.d.ts.map +1 -0
  25. package/dist/persistence.js +174 -0
  26. package/dist/persistence.js.map +1 -0
  27. package/dist/pool/coordinator.d.ts +98 -0
  28. package/dist/pool/coordinator.d.ts.map +1 -0
  29. package/dist/pool/coordinator.js +247 -0
  30. package/dist/pool/coordinator.js.map +1 -0
  31. package/dist/profile.d.ts +100 -0
  32. package/dist/profile.d.ts.map +1 -0
  33. package/dist/profile.js +200 -0
  34. package/dist/profile.js.map +1 -0
  35. package/dist/resource-manager.d.ts +56 -0
  36. package/dist/resource-manager.d.ts.map +1 -0
  37. package/dist/resource-manager.js +138 -0
  38. package/dist/resource-manager.js.map +1 -0
  39. package/dist/tiered-store.d.ts +98 -0
  40. package/dist/tiered-store.d.ts.map +1 -0
  41. package/dist/tiered-store.js +238 -0
  42. package/dist/tiered-store.js.map +1 -0
  43. package/dist/wasm-bindings.d.ts +180 -0
  44. package/dist/wasm-bindings.d.ts.map +1 -0
  45. package/dist/wasm-bindings.js +128 -0
  46. package/dist/wasm-bindings.js.map +1 -0
  47. package/dist/worker-protocol.d.ts +86 -0
  48. package/dist/worker-protocol.d.ts.map +1 -0
  49. package/dist/worker-protocol.js +20 -0
  50. package/dist/worker-protocol.js.map +1 -0
  51. package/dist/worker-runtime.d.ts +14 -0
  52. package/dist/worker-runtime.d.ts.map +1 -0
  53. package/dist/worker-runtime.js +109 -0
  54. package/dist/worker-runtime.js.map +1 -0
  55. package/package.json +60 -13
  56. package/src/albex-worker.ts +187 -0
  57. package/src/albex.ts +2136 -189
  58. package/src/errors.ts +76 -0
  59. package/src/gpu/bloom-runtime.ts +229 -0
  60. package/src/gpu/bloom-shader.wgsl.ts +48 -0
  61. package/src/persistence.ts +175 -0
  62. package/src/pool/coordinator.ts +324 -0
  63. package/src/profile.ts +280 -0
  64. package/src/resource-manager.ts +167 -0
  65. package/src/tiered-store.ts +259 -0
  66. package/src/wasm-bindings.ts +349 -0
  67. package/src/worker-protocol.ts +48 -0
  68. package/src/worker-runtime.ts +106 -0
  69. package/wasm/pkg/albex_pdf.wasm +0 -0
  70. package/wasm/pkg/albex_wasm.wasm +0 -0
  71. package/wasm/pkg/albex_wasm_bg.wasm +0 -0
  72. package/wasm/pkg/albex_wasm_simd.wasm +0 -0
package/src/errors.ts ADDED
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Albex error hierarchy.
3
+ *
4
+ * All errors thrown by Albex extend `AlbexError`, which itself extends
5
+ * `Error`. This lets callers `catch` on the base class to handle every Albex
6
+ * failure, or on a specific subclass when they want to react differently to,
7
+ * say, "wrong file type" versus "PDF parse error".
8
+ *
9
+ * The strings are intentionally short and machine-friendly so they can be
10
+ * logged or mapped to user-facing messages without parsing the `.message`.
11
+ */
12
+
13
+ export class AlbexError extends Error {
14
+ /** Discriminator for runtime branching when the prototype chain is lost
15
+ * (e.g. after structuredClone across a Worker boundary). */
16
+ readonly kind: string;
17
+ constructor(kind: string, message: string) {
18
+ super(message);
19
+ this.name = 'AlbexError';
20
+ this.kind = kind;
21
+ }
22
+ }
23
+
24
+ /** Thrown when `init()` was not called or the WASM module failed to load. */
25
+ export class AlbexInitError extends AlbexError {
26
+ constructor(message: string) {
27
+ super('init', message);
28
+ this.name = 'AlbexInitError';
29
+ }
30
+ }
31
+
32
+ /** Thrown when a file's extension is not in the supported list. */
33
+ export class AlbexUnsupportedFormatError extends AlbexError {
34
+ /** The lowercase extension that was rejected, without leading dot. */
35
+ readonly ext: string;
36
+ constructor(ext: string) {
37
+ super('unsupported_format', `Unsupported format: .${ext}`);
38
+ this.name = 'AlbexUnsupportedFormatError';
39
+ this.ext = ext;
40
+ }
41
+ }
42
+
43
+ /** Thrown when a document parser fails (corrupt ZIP, malformed PDF, etc.). */
44
+ export class AlbexParseError extends AlbexError {
45
+ /** The format that failed (`'pdf'`, `'docx'`, …). */
46
+ readonly format: string;
47
+ constructor(format: string, message: string) {
48
+ super('parse', message);
49
+ this.name = 'AlbexParseError';
50
+ this.format = format;
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Thrown when an indexing operation does not fit: either the scratchpad was
56
+ * too small for a single write, or one of the engine's pools (chunks, text,
57
+ * documents, names) ran out of room mid-document. Before 0.6.0 the latter was
58
+ * silent — the corpus was truncated with no signal.
59
+ *
60
+ * `limit` names which pool overflowed (or `'scratchpad'`), so callers can
61
+ * branch — e.g. start a fresh shard, `compact()`, or surface "library full".
62
+ * When a capacity error is raised during `indexFile`, the engine may hold a
63
+ * partially-indexed copy of the offending document; treat the index as full
64
+ * and stop adding.
65
+ */
66
+ export type AlbexCapacityLimit = 'chunks' | 'text' | 'docs' | 'names' | 'scratchpad';
67
+
68
+ export class AlbexCapacityError extends AlbexError {
69
+ /** Which pool overflowed. Undefined for older call sites that didn't set it. */
70
+ readonly limit?: AlbexCapacityLimit;
71
+ constructor(message: string, limit?: AlbexCapacityLimit) {
72
+ super('capacity', message);
73
+ this.name = 'AlbexCapacityError';
74
+ if (limit) this.limit = limit;
75
+ }
76
+ }
@@ -0,0 +1,229 @@
1
+ /**
2
+ * WebGPU Bloom pre-filter accelerator.
3
+ *
4
+ * For large corpora (>20 k chunks) the per-chunk `(bloom & pattern) == pattern`
5
+ * check dominates Albex's CPU search time even though each check is one
6
+ * AND and one compare. Running ~100 k of them in a single GPU dispatch
7
+ * cuts that cost by an order of magnitude on modern integrated and
8
+ * discrete GPUs alike.
9
+ *
10
+ * Usage flow (driven by `AlbexEngine` when `shouldUseGpu` returns true):
11
+ *
12
+ * 1. After indexing settles, call `uploadChunkBlooms(blooms)` once to
13
+ * copy the per-chunk Bloom values to a `GPUBuffer`.
14
+ * 2. Before each search, call `scan(patternBloom)` to dispatch the
15
+ * compute shader. The function returns a packed Uint32Array where
16
+ * bit `i` indicates that chunk `i` passed the Bloom test.
17
+ * 3. The CPU then runs the (much smaller) Bitap pass over only the
18
+ * candidates, in WASM as before.
19
+ *
20
+ * The class is intentionally one-shot and tolerant: if `init()` fails
21
+ * because WebGPU is unavailable or refuses a device, callers must check
22
+ * `available` and fall back to the CPU pre-filter.
23
+ */
24
+
25
+ import { BLOOM_SCAN_WGSL } from './bloom-shader.wgsl.js';
26
+
27
+ // Minimal subset of the WebGPU types we touch. The full typings would
28
+ // require `@types/webgpu`, which is heavy and changes often; declaring
29
+ // only what we use keeps the public surface lean.
30
+ type GPULike = {
31
+ requestAdapter(): Promise<GPUAdapterLike | null>;
32
+ };
33
+ type GPUAdapterLike = {
34
+ requestDevice(): Promise<GPUDeviceLike>;
35
+ };
36
+ type GPUDeviceLike = {
37
+ createBuffer(desc: unknown): GPUBufferLike;
38
+ createBindGroupLayout(desc: unknown): unknown;
39
+ createPipelineLayout(desc: unknown): unknown;
40
+ createShaderModule(desc: { code: string }): unknown;
41
+ createComputePipeline(desc: unknown): GPUComputePipelineLike;
42
+ createBindGroup(desc: unknown): unknown;
43
+ createCommandEncoder(): GPUCommandEncoderLike;
44
+ queue: { submit(commands: unknown[]): void; writeBuffer(b: GPUBufferLike, off: number, data: ArrayBufferView): void };
45
+ destroy(): void;
46
+ };
47
+ type GPUBufferLike = {
48
+ destroy(): void;
49
+ mapAsync(mode: number): Promise<void>;
50
+ getMappedRange(): ArrayBuffer;
51
+ unmap(): void;
52
+ size: number;
53
+ };
54
+ type GPUComputePipelineLike = {
55
+ getBindGroupLayout(idx: number): unknown;
56
+ };
57
+ type GPUCommandEncoderLike = {
58
+ beginComputePass(): GPUComputePassLike;
59
+ copyBufferToBuffer(src: GPUBufferLike, sOff: number, dst: GPUBufferLike, dOff: number, size: number): void;
60
+ finish(): unknown;
61
+ };
62
+ type GPUComputePassLike = {
63
+ setPipeline(p: unknown): void;
64
+ setBindGroup(idx: number, g: unknown): void;
65
+ dispatchWorkgroups(x: number, y?: number, z?: number): void;
66
+ end(): void;
67
+ };
68
+
69
+ // GPUBufferUsage / GPUMapMode constants are exposed on the global as
70
+ // numeric enums. We mirror only the values we need to avoid pulling typings.
71
+ const USAGE_STORAGE = 0x80;
72
+ const USAGE_COPY_DST = 0x08;
73
+ const USAGE_COPY_SRC = 0x04;
74
+ const USAGE_MAP_READ = 0x01;
75
+ const MAP_READ = 0x01;
76
+
77
+ export class BloomGpu {
78
+ available = false;
79
+ private _device: GPUDeviceLike | null = null;
80
+ private _pipeline: GPUComputePipelineLike | null = null;
81
+ private _chunkBuf: GPUBufferLike | null = null;
82
+ private _paramBuf: GPUBufferLike | null = null;
83
+ private _passBuf: GPUBufferLike | null = null;
84
+ private _readBuf: GPUBufferLike | null = null;
85
+ private _chunkCount = 0;
86
+
87
+ /** Try to acquire a WebGPU device. Sets `available = true` on success. */
88
+ async init(): Promise<boolean> {
89
+ try {
90
+ const gpu = (globalThis as unknown as { navigator?: { gpu?: GPULike } }).navigator?.gpu;
91
+ if (!gpu) return false;
92
+ const adapter = await gpu.requestAdapter();
93
+ if (!adapter) return false;
94
+ const device = await adapter.requestDevice();
95
+
96
+ const module = device.createShaderModule({ code: BLOOM_SCAN_WGSL });
97
+ this._pipeline = device.createComputePipeline({
98
+ layout: 'auto',
99
+ compute: { module, entryPoint: 'bloom_scan' },
100
+ } as unknown as Record<string, unknown>) as GPUComputePipelineLike;
101
+
102
+ this._device = device;
103
+ this.available = true;
104
+ return true;
105
+ } catch {
106
+ this.available = false;
107
+ return false;
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Upload the per-chunk Bloom values to a fresh GPU buffer. Resizes if
113
+ * the chunk count changed since last upload. Pass a `Uint32Array` where
114
+ * each chunk occupies two consecutive entries (low half then high half).
115
+ */
116
+ uploadChunkBlooms(blooms: Uint32Array, chunkCount: number): void {
117
+ if (!this._device) throw new Error('BloomGpu not initialised');
118
+ if (this._chunkBuf && this._chunkCount !== chunkCount) {
119
+ this._chunkBuf.destroy();
120
+ this._chunkBuf = null;
121
+ }
122
+ if (!this._chunkBuf) {
123
+ this._chunkBuf = this._device.createBuffer({
124
+ size: blooms.byteLength,
125
+ usage: USAGE_STORAGE | USAGE_COPY_DST,
126
+ } as unknown as Record<string, unknown>);
127
+ }
128
+ this._device.queue.writeBuffer(this._chunkBuf, 0, blooms);
129
+ this._chunkCount = chunkCount;
130
+ }
131
+
132
+ /**
133
+ * Dispatch the Bloom scan and read back the packed pass-bitset.
134
+ *
135
+ * Returns a `Uint32Array` of length `ceil(chunkCount / 32)`. Each set
136
+ * bit indicates a chunk that passed the Bloom pre-filter.
137
+ */
138
+ async scan(patternLo: number, patternHi: number): Promise<Uint32Array> {
139
+ if (!this._device || !this._pipeline || !this._chunkBuf) {
140
+ throw new Error('BloomGpu not ready');
141
+ }
142
+ const device = this._device;
143
+ const passWords = Math.ceil(this._chunkCount / 32);
144
+
145
+ if (!this._paramBuf) {
146
+ this._paramBuf = device.createBuffer({
147
+ size: 16,
148
+ usage: USAGE_STORAGE | USAGE_COPY_DST,
149
+ } as unknown as Record<string, unknown>);
150
+ }
151
+ const paramBytes = new Uint32Array([patternLo >>> 0, patternHi >>> 0, this._chunkCount, 0]);
152
+ device.queue.writeBuffer(this._paramBuf, 0, paramBytes);
153
+
154
+ if (!this._passBuf || this._passBuf.size < passWords * 4) {
155
+ this._passBuf?.destroy();
156
+ this._readBuf?.destroy();
157
+ this._passBuf = device.createBuffer({
158
+ size: Math.max(4, passWords * 4),
159
+ usage: USAGE_STORAGE | USAGE_COPY_SRC,
160
+ } as unknown as Record<string, unknown>);
161
+ this._readBuf = device.createBuffer({
162
+ size: Math.max(4, passWords * 4),
163
+ usage: USAGE_MAP_READ | USAGE_COPY_DST,
164
+ } as unknown as Record<string, unknown>);
165
+ }
166
+ // Zero the pass buffer between dispatches — writeBuffer with empty
167
+ // suffices since atomics set bits cumulatively.
168
+ device.queue.writeBuffer(this._passBuf, 0, new Uint32Array(passWords));
169
+
170
+ const layout = this._pipeline.getBindGroupLayout(0);
171
+ const bindGroup = device.createBindGroup({
172
+ layout,
173
+ entries: [
174
+ { binding: 0, resource: { buffer: this._chunkBuf } },
175
+ { binding: 1, resource: { buffer: this._paramBuf } },
176
+ { binding: 2, resource: { buffer: this._passBuf } },
177
+ ],
178
+ } as unknown as Record<string, unknown>);
179
+
180
+ const encoder = device.createCommandEncoder();
181
+ const pass = encoder.beginComputePass();
182
+ pass.setPipeline(this._pipeline);
183
+ pass.setBindGroup(0, bindGroup);
184
+ pass.dispatchWorkgroups(Math.ceil(this._chunkCount / 64));
185
+ pass.end();
186
+ encoder.copyBufferToBuffer(this._passBuf, 0, this._readBuf!, 0, passWords * 4);
187
+ device.queue.submit([encoder.finish()]);
188
+
189
+ await this._readBuf!.mapAsync(MAP_READ);
190
+ const out = new Uint32Array(this._readBuf!.getMappedRange().slice(0));
191
+ this._readBuf!.unmap();
192
+ return out;
193
+ }
194
+
195
+ destroy(): void {
196
+ this._chunkBuf?.destroy();
197
+ this._paramBuf?.destroy();
198
+ this._passBuf?.destroy();
199
+ this._readBuf?.destroy();
200
+ this._device?.destroy();
201
+ this._device = null;
202
+ this._chunkBuf = null;
203
+ this._paramBuf = null;
204
+ this._passBuf = null;
205
+ this._readBuf = null;
206
+ this.available = false;
207
+ }
208
+
209
+ /** TC39 explicit-resource-management alias of `destroy()`. */
210
+ [Symbol.dispose](): void { this.destroy(); }
211
+ }
212
+
213
+ /**
214
+ * Convenience: take a flat `Uint8Array` view of a WASM `chunks[]` array
215
+ * (32 bytes per Chunk) and extract just the 8-byte Bloom field at offset 0
216
+ * into a `Uint32Array` packed `[lo, hi, lo, hi, …]`.
217
+ *
218
+ * Used by the engine when uploading to the GPU buffer.
219
+ */
220
+ export function packBloomsFromChunks(chunkBytes: Uint8Array, chunkCount: number): Uint32Array {
221
+ const out = new Uint32Array(chunkCount * 2);
222
+ const view = new DataView(chunkBytes.buffer, chunkBytes.byteOffset, chunkBytes.byteLength);
223
+ for (let i = 0; i < chunkCount; i++) {
224
+ // Chunk struct: bloom: u64 at offset 0 within a 32-byte record.
225
+ out[i * 2] = view.getUint32(i * 32 + 0, true); // low
226
+ out[i * 2 + 1] = view.getUint32(i * 32 + 4, true); // high
227
+ }
228
+ return out;
229
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * WGSL compute shader for batched Bloom-filter pre-checks.
3
+ *
4
+ * Inputs (storage buffers):
5
+ * blooms : array<u32> — 2 u32 per chunk encoding the chunk's u64 Bloom.
6
+ * Index 2*i = low half, 2*i+1 = high half.
7
+ * params : { pattern_lo: u32, pattern_hi: u32, chunk_count: u32 }
8
+ *
9
+ * Outputs:
10
+ * passes : array<atomic<u32>> — bit i (in passes[i / 32]) set iff
11
+ * chunks[i] might contain the pattern.
12
+ *
13
+ * Workgroup size of 64 matches typical occupancy on Apple GPUs and
14
+ * Snapdragon; larger workgroups gain little for this memory-bound op.
15
+ *
16
+ * Kept in TS so the bundler ships it inline — no extra `.wgsl` asset fetch.
17
+ */
18
+ export const BLOOM_SCAN_WGSL = /* wgsl */ `
19
+ struct Params {
20
+ pattern_lo: u32,
21
+ pattern_hi: u32,
22
+ chunk_count: u32,
23
+ _pad: u32,
24
+ };
25
+
26
+ @group(0) @binding(0) var<storage, read> blooms : array<u32>;
27
+ @group(0) @binding(1) var<storage, read> params : Params;
28
+ @group(0) @binding(2) var<storage, read_write> passes : array<atomic<u32>>;
29
+
30
+ @compute @workgroup_size(64)
31
+ fn bloom_scan(@builtin(global_invocation_id) gid: vec3<u32>) {
32
+ let i = gid.x;
33
+ if (i >= params.chunk_count) { return; }
34
+
35
+ let bloom_lo = blooms[i * 2u];
36
+ let bloom_hi = blooms[i * 2u + 1u];
37
+
38
+ // (bloom & pattern) == pattern iff pattern is a subset of bloom.
39
+ let match_lo = (bloom_lo & params.pattern_lo) == params.pattern_lo;
40
+ let match_hi = (bloom_hi & params.pattern_hi) == params.pattern_hi;
41
+ if (!(match_lo && match_hi)) { return; }
42
+
43
+ // Mark bit i in the packed result. Each u32 holds 32 chunk results.
44
+ let word = i / 32u;
45
+ let bit = i % 32u;
46
+ atomicOr(&passes[word], 1u << bit);
47
+ }
48
+ `;
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Albex persistence layer.
3
+ *
4
+ * Backends:
5
+ * - OPFS (preferred, available in Chrome 102+, Safari 15.2+, Firefox 111+)
6
+ * - IndexedDB (universal fallback)
7
+ *
8
+ * Both store a single binary blob per snapshot name. The format is opaque to
9
+ * this layer — see the snapshot header documented in `wasm/src/lib.rs`.
10
+ *
11
+ * API surface is intentionally minimal:
12
+ * savePersisted(name, bytes) → void
13
+ * loadPersisted(name) → Uint8Array | null
14
+ * deletePersisted(name) → void
15
+ * listPersisted() → string[]
16
+ */
17
+
18
+ const DB_NAME = 'albex';
19
+ const STORE_NAME = 'snapshots';
20
+ const OPFS_DIR = 'albex-snapshots';
21
+
22
+ // ─────────────────────────────────────────────────────────────────────────────
23
+ // Backend detection
24
+ // ─────────────────────────────────────────────────────────────────────────────
25
+
26
+ function hasOpfs(): boolean {
27
+ // navigator.storage.getDirectory is the OPFS entry point.
28
+ return typeof navigator !== 'undefined'
29
+ && typeof navigator.storage?.getDirectory === 'function';
30
+ }
31
+
32
+ function hasIdb(): boolean {
33
+ return typeof indexedDB !== 'undefined';
34
+ }
35
+
36
+ // ─────────────────────────────────────────────────────────────────────────────
37
+ // OPFS backend
38
+ // ─────────────────────────────────────────────────────────────────────────────
39
+
40
+ async function opfsDir(): Promise<FileSystemDirectoryHandle> {
41
+ const root = await navigator.storage.getDirectory();
42
+ return root.getDirectoryHandle(OPFS_DIR, { create: true });
43
+ }
44
+
45
+ async function opfsSave(name: string, bytes: Uint8Array): Promise<void> {
46
+ const dir = await opfsDir();
47
+ const file = await dir.getFileHandle(name, { create: true });
48
+ const w = await file.createWritable();
49
+ // FileSystemWritableFileStream.write demands ArrayBuffer specifically (not
50
+ // ArrayBufferLike); copy through a plain Uint8Array to satisfy the type.
51
+ const plain = new Uint8Array(bytes.byteLength);
52
+ plain.set(bytes);
53
+ await w.write(plain);
54
+ await w.close();
55
+ }
56
+
57
+ async function opfsLoad(name: string): Promise<Uint8Array | null> {
58
+ try {
59
+ const dir = await opfsDir();
60
+ const file = await dir.getFileHandle(name);
61
+ const f = await file.getFile();
62
+ return new Uint8Array(await f.arrayBuffer());
63
+ } catch {
64
+ return null;
65
+ }
66
+ }
67
+
68
+ async function opfsDelete(name: string): Promise<void> {
69
+ try {
70
+ const dir = await opfsDir();
71
+ await dir.removeEntry(name);
72
+ } catch { /* not found */ }
73
+ }
74
+
75
+ async function opfsList(): Promise<string[]> {
76
+ const dir = await opfsDir();
77
+ const out: string[] = [];
78
+ // @ts-expect-error: async iterator on FileSystemDirectoryHandle (Web Spec)
79
+ for await (const [name] of dir.entries()) out.push(name as string);
80
+ return out;
81
+ }
82
+
83
+ // ─────────────────────────────────────────────────────────────────────────────
84
+ // IndexedDB backend
85
+ // ─────────────────────────────────────────────────────────────────────────────
86
+
87
+ function idbOpen(): Promise<IDBDatabase> {
88
+ return new Promise((resolve, reject) => {
89
+ const req = indexedDB.open(DB_NAME, 1);
90
+ req.onupgradeneeded = () => {
91
+ const db = req.result;
92
+ if (!db.objectStoreNames.contains(STORE_NAME)) {
93
+ db.createObjectStore(STORE_NAME);
94
+ }
95
+ };
96
+ req.onsuccess = () => resolve(req.result);
97
+ req.onerror = () => reject(req.error);
98
+ });
99
+ }
100
+
101
+ function idbReq<T>(req: IDBRequest<T>): Promise<T> {
102
+ return new Promise((resolve, reject) => {
103
+ req.onsuccess = () => resolve(req.result);
104
+ req.onerror = () => reject(req.error);
105
+ });
106
+ }
107
+
108
+ async function idbSave(name: string, bytes: Uint8Array): Promise<void> {
109
+ const db = await idbOpen();
110
+ const tx = db.transaction(STORE_NAME, 'readwrite');
111
+ // structured-clone of a TypedArray copies the buffer once. That's the price
112
+ // of the IDB fallback; OPFS path avoids it.
113
+ tx.objectStore(STORE_NAME).put(bytes, name);
114
+ await new Promise<void>((res, rej) => {
115
+ tx.oncomplete = () => res();
116
+ tx.onerror = () => rej(tx.error);
117
+ });
118
+ db.close();
119
+ }
120
+
121
+ async function idbLoad(name: string): Promise<Uint8Array | null> {
122
+ const db = await idbOpen();
123
+ const tx = db.transaction(STORE_NAME, 'readonly');
124
+ const got = await idbReq<unknown>(tx.objectStore(STORE_NAME).get(name));
125
+ db.close();
126
+ if (got instanceof Uint8Array) return got;
127
+ if (got instanceof ArrayBuffer) return new Uint8Array(got);
128
+ return null;
129
+ }
130
+
131
+ async function idbDelete(name: string): Promise<void> {
132
+ const db = await idbOpen();
133
+ const tx = db.transaction(STORE_NAME, 'readwrite');
134
+ tx.objectStore(STORE_NAME).delete(name);
135
+ await new Promise<void>((res, rej) => {
136
+ tx.oncomplete = () => res();
137
+ tx.onerror = () => rej(tx.error);
138
+ });
139
+ db.close();
140
+ }
141
+
142
+ async function idbList(): Promise<string[]> {
143
+ const db = await idbOpen();
144
+ const tx = db.transaction(STORE_NAME, 'readonly');
145
+ const keys = await idbReq<IDBValidKey[]>(tx.objectStore(STORE_NAME).getAllKeys());
146
+ db.close();
147
+ return keys.map(k => String(k));
148
+ }
149
+
150
+ // ─────────────────────────────────────────────────────────────────────────────
151
+ // Backend router
152
+ // ─────────────────────────────────────────────────────────────────────────────
153
+
154
+ export async function savePersisted(name: string, bytes: Uint8Array): Promise<void> {
155
+ if (hasOpfs()) return opfsSave(name, bytes);
156
+ if (hasIdb()) return idbSave(name, bytes);
157
+ throw new Error('No persistence backend available (OPFS or IndexedDB)');
158
+ }
159
+
160
+ export async function loadPersisted(name: string): Promise<Uint8Array | null> {
161
+ if (hasOpfs()) return opfsLoad(name);
162
+ if (hasIdb()) return idbLoad(name);
163
+ return null;
164
+ }
165
+
166
+ export async function deletePersisted(name: string): Promise<void> {
167
+ if (hasOpfs()) return opfsDelete(name);
168
+ if (hasIdb()) return idbDelete(name);
169
+ }
170
+
171
+ export async function listPersisted(): Promise<string[]> {
172
+ if (hasOpfs()) return opfsList();
173
+ if (hasIdb()) return idbList();
174
+ return [];
175
+ }