@bod.ee/db 0.7.0 → 0.9.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.
package/cli.ts CHANGED
@@ -59,6 +59,15 @@ const db = await BodDB.create(options as any);
59
59
  const server = db.serve();
60
60
  const port = server?.port ?? options.port ?? 4400;
61
61
 
62
+ // Start replication if configured
63
+ if (db.replication) {
64
+ db.replication.start().then(() => {
65
+ console.log(` Replication: ${db.replication!.isPrimary ? 'primary' : `replica → ${(options as any).replication?.primaryUrl}`}`);
66
+ }).catch((e: Error) => {
67
+ console.error(` Replication failed: ${e.message}`);
68
+ });
69
+ }
70
+
62
71
  const line = (s: string) => ` │ ${s.padEnd(36)}│`;
63
72
  console.log([
64
73
  '',
@@ -110,6 +119,7 @@ if (options.transport?.staticRoutes) {
110
119
  system: { cpuCores: cpus().length, totalMemMb: +(totalmem() / 1024 / 1024).toFixed(0), cpuPercent: systemCpuPercent() },
111
120
  subs: db.subs.subscriberCount(),
112
121
  clients: db.transport?.clientCount ?? 0,
122
+ repl: db.replication?.stats() ?? null,
113
123
  ts: Date.now(),
114
124
  });
115
125
  }, 1000);
package/client.ts CHANGED
@@ -1,2 +1,3 @@
1
- export { BodClient, BodClientOptions, ValueSnapshot, ClientQueryBuilder, StreamReader, MQReader, MQMessageSnapshot } from './src/client/BodClient.ts';
2
- export type { ChildEvent, StreamEventSnapshot } from './src/client/BodClient.ts';
1
+ export { BodClient, BodClientOptions, ValueSnapshot, ClientQueryBuilder, StreamReader, MQReader, MQMessageSnapshot, VFSClient } from './src/client/BodClient.ts';
2
+ export type { ChildEvent, StreamEventSnapshot, FileStat } from './src/client/BodClient.ts';
3
+ export { CachedClient, CachedClientOptions } from './src/client/CachedClient.ts';
package/config.ts CHANGED
@@ -14,6 +14,7 @@ export default {
14
14
  fts: {},
15
15
  vectors: { dimensions: 384 },
16
16
  mq: { visibilityTimeout: 30, maxDeliveries: 5 },
17
+ vfs: { storageRoot: './.tmp/vfs' },
17
18
  compact: {
18
19
  'events/logs': { maxAge: 86400 },
19
20
  },
@@ -0,0 +1,14 @@
1
+ droplet:
2
+ host: 151.145.81.254
3
+ ssh:
4
+ privateKey: ~/.ssh/oracle_cloud
5
+ app:
6
+ name: boddb-il
7
+ dir: /opt/boddb-il
8
+ runtime:
9
+ port: 4400
10
+ service:
11
+ name: boddb-il
12
+ execStart: /home/ubuntu/.bun/bin/bun run cli.ts deploy/prod-il.config.ts
13
+ https:
14
+ domain: db-il.bod.ee
@@ -0,0 +1,19 @@
1
+ export default {
2
+ path: './data/bod.db',
3
+ port: 4400,
4
+ sweepInterval: 60000,
5
+ rules: { '': { read: true, write: true } },
6
+ indexes: {},
7
+ fts: {},
8
+ replication: {
9
+ role: 'replica' as const,
10
+ primaryUrl: 'wss://db-main.bod.ee',
11
+ replicaId: 'il-replica-1',
12
+ },
13
+ transport: {
14
+ staticRoutes: {
15
+ '/admin': './admin/ui.html',
16
+ '/': './admin/ui.html',
17
+ },
18
+ },
19
+ }
@@ -6,6 +6,7 @@ export default {
6
6
  indexes: {},
7
7
  fts: {},
8
8
  mq: { visibilityTimeout: 30, maxDeliveries: 5 },
9
+ replication: { role: 'primary' as const },
9
10
  transport: {
10
11
  staticRoutes: {
11
12
  '/admin': './admin/ui.html',
package/index.ts CHANGED
@@ -6,6 +6,7 @@ export { QueryEngine } from './src/server/QueryEngine.ts';
6
6
  export { RulesEngine, RulesEngineOptions } from './src/server/RulesEngine.ts';
7
7
  export type { RuleContext, PathRule } from './src/server/RulesEngine.ts';
8
8
  export { Transport, TransportOptions } from './src/server/Transport.ts';
9
+ export type { WsData } from './src/server/Transport.ts';
9
10
  export { FTSEngine, FTSEngineOptions } from './src/server/FTSEngine.ts';
10
11
  export { VectorEngine, VectorEngineOptions } from './src/server/VectorEngine.ts';
11
12
  export { StreamEngine, StreamEngineOptions } from './src/server/StreamEngine.ts';
@@ -13,6 +14,8 @@ export { MQEngine, MQEngineOptions } from './src/server/MQEngine.ts';
13
14
  export type { MQMessage } from './src/server/MQEngine.ts';
14
15
  export type { StreamEvent, CompactOptions, CompactResult, StreamSnapshot } from './src/server/StreamEngine.ts';
15
16
  export { FileAdapter, FileAdapterOptions } from './src/server/FileAdapter.ts';
17
+ export { ReplicationEngine, ReplicationOptions, ReplicationSource } from './src/server/ReplicationEngine.ts';
18
+ export type { ReplEvent, WriteEvent } from './src/server/ReplicationEngine.ts';
16
19
  export { compileRule } from './src/server/ExpressionRules.ts';
17
20
  export { increment, serverTimestamp, arrayUnion, arrayRemove, ref } from './src/shared/transforms.ts';
18
21
  export * from './src/shared/protocol.ts';
package/package.json CHANGED
@@ -1,8 +1,12 @@
1
1
  {
2
2
  "name": "@bod.ee/db",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "module": "index.ts",
5
5
  "type": "module",
6
+ "exports": {
7
+ ".": "./index.ts",
8
+ "./client": "./client.ts"
9
+ },
6
10
  "bin": {
7
11
  "bod-db": "cli.ts",
8
12
  "bod-db-mcp": "mcp.ts"
@@ -13,6 +17,7 @@
13
17
  },
14
18
  "scripts": {
15
19
  "admin": "bun --watch run admin/server.ts",
20
+ "admin:remote": "bun run admin/proxy.ts",
16
21
  "serve": "bun run cli.ts",
17
22
  "start": "bun run cli.ts config.ts",
18
23
  "publish-lib": "bun publish --access public",
@@ -21,7 +26,11 @@
21
26
  "deploy-logs-db": "bun run deploy/deploy.ts boddb-logs ",
22
27
  "deploy:bootstrap": "bun run deploy/deploy.ts boddb bootstrap",
23
28
  "deploy:logs": "bun run deploy/deploy.ts boddb logs",
24
- "deploy:ssh": "bun run deploy/deploy.ts boddb ssh"
29
+ "deploy:ssh": "bun run deploy/deploy.ts boddb ssh",
30
+ "deploy-il": "bun run deploy/deploy.ts boddb-il deploy",
31
+ "deploy-il:bootstrap": "bun run deploy/deploy.ts boddb-il bootstrap",
32
+ "deploy-il:logs": "bun run deploy/deploy.ts boddb-il logs",
33
+ "deploy-il:ssh": "bun run deploy/deploy.ts boddb-il ssh"
25
34
  },
26
35
  "peerDependencies": {
27
36
  "typescript": "^5"
@@ -1,4 +1,4 @@
1
- import type { QueryFilter, OrderClause, BatchOp } from '../shared/protocol.ts';
1
+ import type { QueryFilter, OrderClause, BatchOp, FileStat } from '../shared/protocol.ts';
2
2
  import { pathKey } from '../shared/pathUtils.ts';
3
3
 
4
4
  export class BodClientOptions {
@@ -24,6 +24,7 @@ export class BodClient {
24
24
  private activeSubs = new Set<string>(); // tracks 'value:path' and 'child:path' keys
25
25
  private streamCbs = new Map<string, Set<(events: Array<{ key: string; data: unknown }>) => void>>();
26
26
  private activeStreamSubs = new Set<string>(); // tracks 'path:groupId' keys
27
+ private vfsDownloadCbs = new Map<string, Set<(chunk: { data: string; seq: number; done: boolean }) => void>>();
27
28
  private closed = false;
28
29
  private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
29
30
  private reconnectDelay: number;
@@ -78,7 +79,7 @@ export class BodClient {
78
79
  if (msg.type === 'value') {
79
80
  const cbs = this.valueCbs.get(msg.path);
80
81
  if (cbs) {
81
- const snap = new ValueSnapshot(msg.path, msg.data);
82
+ const snap = new ValueSnapshot(msg.path, msg.data, msg.updatedAt);
82
83
  for (const cb of cbs) cb(snap);
83
84
  }
84
85
  return;
@@ -104,12 +105,17 @@ export class BodClient {
104
105
  }
105
106
  return;
106
107
  }
108
+ if (msg.type === 'vfs-download-chunk') {
109
+ const cbs = this.vfsDownloadCbs.get(msg.transferId);
110
+ if (cbs) for (const cb of cbs) cb(msg);
111
+ return;
112
+ }
107
113
 
108
114
  // Response message
109
115
  const p = this.pending.get(msg.id);
110
116
  if (p) {
111
117
  this.pending.delete(msg.id);
112
- if (msg.ok) p.resolve(msg.data ?? null);
118
+ if (msg.ok) p.resolve(msg);
113
119
  else p.reject(new Error(msg.error || 'Unknown error'));
114
120
  }
115
121
  };
@@ -165,23 +171,33 @@ export class BodClient {
165
171
  return String(++this.msgId);
166
172
  }
167
173
 
168
- private send(op: string, params: Record<string, unknown> = {}): Promise<unknown> {
174
+ private sendRaw(op: string, params: Record<string, unknown> = {}): Promise<Record<string, unknown>> {
169
175
  return new Promise((resolve, reject) => {
170
176
  if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
171
177
  return reject(new Error('Not connected'));
172
178
  }
173
179
  const id = this.nextId();
174
- this.pending.set(id, { resolve, reject });
180
+ this.pending.set(id, { resolve: resolve as (v: unknown) => void, reject });
175
181
  this.ws.send(JSON.stringify({ id, op, ...params }));
176
182
  });
177
183
  }
178
184
 
185
+ private async send(op: string, params: Record<string, unknown> = {}): Promise<unknown> {
186
+ const msg = await this.sendRaw(op, params);
187
+ return msg.data ?? null;
188
+ }
189
+
179
190
  // CRUD
180
191
 
181
192
  async get(path: string): Promise<unknown> {
182
193
  return this.send('get', { path });
183
194
  }
184
195
 
196
+ async getSnapshot(path: string): Promise<ValueSnapshot> {
197
+ const msg = await this.sendRaw('get', { path });
198
+ return new ValueSnapshot(path, msg.data ?? null, msg.updatedAt as number | undefined);
199
+ }
200
+
185
201
  async getShallow(path?: string): Promise<Array<{ key: string; isLeaf: boolean; value?: unknown }>> {
186
202
  return this.send('get', { path: path ?? '', shallow: true }) as Promise<Array<{ key: string; isLeaf: boolean; value?: unknown }>>;
187
203
  }
@@ -373,13 +389,43 @@ export class BodClient {
373
389
  get connected(): boolean {
374
390
  return this.ws?.readyState === WebSocket.OPEN;
375
391
  }
392
+
393
+ /** Get the HTTP base URL derived from the WS URL */
394
+ get httpUrl(): string {
395
+ return this.options.url.replace(/^ws(s?):\/\//, 'http$1://');
396
+ }
397
+
398
+ /** VFS client facade */
399
+ vfs(): VFSClient {
400
+ return new VFSClient(this);
401
+ }
402
+
403
+ /** @internal */
404
+ _send(op: string, params: Record<string, unknown> = {}): Promise<unknown> {
405
+ return this.send(op, params);
406
+ }
407
+
408
+ /** @internal — listen for download chunks */
409
+ _onDownloadChunk(transferId: string, cb: (chunk: { data: string; seq: number; done: boolean }) => void): () => void {
410
+ if (!this.vfsDownloadCbs.has(transferId)) this.vfsDownloadCbs.set(transferId, new Set());
411
+ this.vfsDownloadCbs.get(transferId)!.add(cb);
412
+ return () => {
413
+ this.vfsDownloadCbs.get(transferId)?.delete(cb);
414
+ if (this.vfsDownloadCbs.get(transferId)?.size === 0) this.vfsDownloadCbs.delete(transferId);
415
+ };
416
+ }
376
417
  }
377
418
 
378
419
  export class ValueSnapshot {
420
+ readonly updatedAt?: number;
421
+
379
422
  constructor(
380
423
  readonly path: string,
381
424
  private data: unknown,
382
- ) {}
425
+ updatedAt?: number,
426
+ ) {
427
+ this.updatedAt = updatedAt;
428
+ }
383
429
 
384
430
  val(): unknown { return this.data; }
385
431
  get key(): string { return pathKey(this.path); }
@@ -476,6 +522,83 @@ export class MQMessageSnapshot {
476
522
  exists(): boolean { return this.data !== null && this.data !== undefined; }
477
523
  }
478
524
 
525
+ export type { FileStat };
526
+
527
+ export class VFSClient {
528
+ constructor(private client: BodClient) {}
529
+
530
+ /** Upload via REST (preferred — binary streaming) */
531
+ async upload(path: string, data: Uint8Array, mime?: string): Promise<FileStat> {
532
+ const url = `${this.client.httpUrl}/files/${path}`;
533
+ const res = await fetch(url, {
534
+ method: 'POST',
535
+ body: data,
536
+ headers: mime ? { 'Content-Type': mime } : {},
537
+ });
538
+ const json = await res.json() as { ok: boolean; data: FileStat; error?: string };
539
+ if (!json.ok) throw new Error(json.error || 'Upload failed');
540
+ return json.data;
541
+ }
542
+
543
+ /** Download via REST (preferred — binary streaming) */
544
+ async download(path: string): Promise<Uint8Array> {
545
+ const url = `${this.client.httpUrl}/files/${path}`;
546
+ const res = await fetch(url);
547
+ if (!res.ok) throw new Error('Download failed');
548
+ return new Uint8Array(await res.arrayBuffer());
549
+ }
550
+
551
+ /** Upload via WS (chunked, base64-encoded fallback) */
552
+ async uploadWS(path: string, data: Uint8Array, mime?: string): Promise<FileStat> {
553
+ const transferId = await this.client._send('vfs-upload-init', { path, size: data.byteLength, mime }) as string;
554
+ const b64 = Buffer.from(data).toString('base64');
555
+ const CHUNK_SIZE = 48000;
556
+ const totalChunks = Math.ceil(b64.length / CHUNK_SIZE) || 1;
557
+ for (let i = 0; i < totalChunks; i++) {
558
+ const chunk = b64.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE);
559
+ await this.client._send('vfs-upload-chunk', { transferId, data: chunk, seq: i });
560
+ }
561
+ return await this.client._send('vfs-upload-done', { transferId }) as FileStat;
562
+ }
563
+
564
+ /** Download via WS (chunked, base64-encoded fallback) */
565
+ async downloadWS(path: string): Promise<Uint8Array> {
566
+ const result = await this.client._send('vfs-download-init', { path }) as { transferId: string; totalChunks: number };
567
+ const chunks: string[] = [];
568
+ return new Promise<Uint8Array>((resolve, reject) => {
569
+ const timeout = setTimeout(() => { off(); reject(new Error('Download timeout')); }, 30000);
570
+ const off = this.client._onDownloadChunk(result.transferId, (chunk) => {
571
+ chunks[chunk.seq] = chunk.data;
572
+ if (chunk.done) {
573
+ clearTimeout(timeout);
574
+ off();
575
+ resolve(new Uint8Array(Buffer.from(chunks.join(''), 'base64')));
576
+ }
577
+ });
578
+ });
579
+ }
580
+
581
+ async stat(path: string): Promise<FileStat | null> {
582
+ return await this.client._send('vfs-stat', { path }) as FileStat | null;
583
+ }
584
+
585
+ async list(path: string): Promise<FileStat[]> {
586
+ return await this.client._send('vfs-list', { path }) as FileStat[];
587
+ }
588
+
589
+ async delete(path: string): Promise<void> {
590
+ await this.client._send('vfs-delete', { path });
591
+ }
592
+
593
+ async mkdir(path: string): Promise<FileStat> {
594
+ return await this.client._send('vfs-mkdir', { path }) as FileStat;
595
+ }
596
+
597
+ async move(src: string, dst: string): Promise<FileStat> {
598
+ return await this.client._send('vfs-move', { path: src, dst }) as FileStat;
599
+ }
600
+ }
601
+
479
602
  export class MQReader {
480
603
  constructor(
481
604
  private client: BodClient,
@@ -0,0 +1,228 @@
1
+ import { BodClient, ValueSnapshot } from './BodClient.ts';
2
+ import type { ChildEvent } from './BodClient.ts';
3
+ import { ancestors } from '../shared/pathUtils.ts';
4
+
5
+ export class CachedClientOptions {
6
+ maxAge: number = 7 * 24 * 60 * 60 * 1000; // 7 days in ms
7
+ maxMemoryEntries: number = 500;
8
+ dbName: string = 'boddb-cache';
9
+ enabled: boolean = true;
10
+ }
11
+
12
+ interface CacheEntry {
13
+ path: string;
14
+ data: unknown;
15
+ updatedAt: number;
16
+ cachedAt: number;
17
+ }
18
+
19
+ export class CachedClient {
20
+ readonly options: CachedClientOptions;
21
+ readonly client: BodClient;
22
+ private memory = new Map<string, CacheEntry>();
23
+ private subscribedPaths = new Set<string>();
24
+ private idb: IDBDatabase | null = null;
25
+
26
+ constructor(client: BodClient, options?: Partial<CachedClientOptions>) {
27
+ this.options = { ...new CachedClientOptions(), ...options };
28
+ this.client = client;
29
+ }
30
+
31
+ async init(): Promise<void> {
32
+ if (!this.options.enabled) return;
33
+ this.idb = await this.openIDB();
34
+ await this.sweepExpired();
35
+ }
36
+
37
+ private openIDB(): Promise<IDBDatabase> {
38
+ return new Promise((resolve, reject) => {
39
+ const req = indexedDB.open(this.options.dbName, 1);
40
+ req.onupgradeneeded = () => {
41
+ const db = req.result;
42
+ if (!db.objectStoreNames.contains('entries')) {
43
+ db.createObjectStore('entries', { keyPath: 'path' });
44
+ }
45
+ };
46
+ req.onsuccess = () => resolve(req.result);
47
+ req.onerror = () => reject(req.error);
48
+ });
49
+ }
50
+
51
+ private async sweepExpired(): Promise<void> {
52
+ if (!this.idb) return;
53
+ const cutoff = Date.now() - this.options.maxAge;
54
+ const tx = this.idb.transaction('entries', 'readwrite');
55
+ const store = tx.objectStore('entries');
56
+ const req = store.openCursor();
57
+ return new Promise((resolve) => {
58
+ req.onsuccess = () => {
59
+ const cursor = req.result;
60
+ if (!cursor) { resolve(); return; }
61
+ const entry = cursor.value as CacheEntry;
62
+ if (entry.cachedAt < cutoff) cursor.delete();
63
+ cursor.continue();
64
+ };
65
+ req.onerror = () => resolve();
66
+ });
67
+ }
68
+
69
+ // --- get: stale-while-revalidate ---
70
+
71
+ async get(path: string): Promise<unknown> {
72
+ if (!this.options.enabled) return this.client.get(path);
73
+
74
+ // Memory hit + subscribed = fresh
75
+ const mem = this.memory.get(path);
76
+ if (mem && this.subscribedPaths.has(path)) return mem.data;
77
+
78
+ // Memory hit + not subscribed = return stale, revalidate
79
+ if (mem) {
80
+ this.revalidate(path);
81
+ return mem.data;
82
+ }
83
+
84
+ // IDB check
85
+ const idbEntry = await this.idbGet(path);
86
+ if (idbEntry && (Date.now() - idbEntry.cachedAt) < this.options.maxAge) {
87
+ this.memSet(path, idbEntry);
88
+ this.revalidate(path);
89
+ return idbEntry.data;
90
+ }
91
+
92
+ // Network fetch
93
+ const snap = await this.client.getSnapshot(path);
94
+ const entry: CacheEntry = { path, data: snap.val(), updatedAt: snap.updatedAt ?? Date.now(), cachedAt: Date.now() };
95
+ this.memSet(path, entry);
96
+ this.idbSet(entry);
97
+ return entry.data;
98
+ }
99
+
100
+ private revalidate(path: string): void {
101
+ this.client.getSnapshot(path).then((snap) => {
102
+ const entry: CacheEntry = { path, data: snap.val(), updatedAt: snap.updatedAt ?? Date.now(), cachedAt: Date.now() };
103
+ this.memSet(path, entry);
104
+ this.idbSet(entry);
105
+ }).catch(() => {}); // silent background refetch
106
+ }
107
+
108
+ // --- subscriptions ---
109
+
110
+ on(path: string, cb: (snap: ValueSnapshot) => void): () => void {
111
+ this.subscribedPaths.add(path);
112
+ const off = this.client.on(path, (snap) => {
113
+ const entry: CacheEntry = { path, data: snap.val(), updatedAt: snap.updatedAt ?? Date.now(), cachedAt: Date.now() };
114
+ this.memSet(path, entry);
115
+ this.idbSet(entry);
116
+ cb(snap);
117
+ });
118
+ return () => {
119
+ off();
120
+ this.subscribedPaths.delete(path);
121
+ };
122
+ }
123
+
124
+ onChild(path: string, cb: (event: ChildEvent) => void): () => void {
125
+ const off = this.client.onChild(path, (event) => {
126
+ // Invalidate parent + ancestors on any child event
127
+ this.invalidatePath(path);
128
+ cb(event);
129
+ });
130
+ return off;
131
+ }
132
+
133
+ // --- writes: passthrough + invalidate ---
134
+
135
+ async set(path: string, value: unknown, opts?: { ttl?: number }): Promise<void> {
136
+ await this.client.set(path, value, opts);
137
+ this.invalidatePath(path);
138
+ }
139
+
140
+ async update(updates: Record<string, unknown>): Promise<void> {
141
+ await this.client.update(updates);
142
+ for (const path of Object.keys(updates)) this.invalidatePath(path);
143
+ }
144
+
145
+ async delete(path: string): Promise<void> {
146
+ await this.client.delete(path);
147
+ this.invalidatePath(path);
148
+ }
149
+
150
+ private invalidatePath(path: string): void {
151
+ this.invalidate(path);
152
+ for (const ancestor of ancestors(path)) this.invalidate(ancestor);
153
+ }
154
+
155
+ private invalidate(path: string): void {
156
+ this.memory.delete(path);
157
+ this.idbDelete(path);
158
+ }
159
+
160
+ // --- warmup ---
161
+
162
+ async warmup(paths: string[]): Promise<void> {
163
+ if (!this.idb) return;
164
+ const cutoff = Date.now() - this.options.maxAge;
165
+ // Fire all gets in a single transaction synchronously, then collect results
166
+ const tx = this.idb.transaction('entries', 'readonly');
167
+ const store = tx.objectStore('entries');
168
+ const promises = paths.map(path => new Promise<void>((resolve) => {
169
+ const req = store.get(path);
170
+ req.onsuccess = () => {
171
+ const entry = req.result as CacheEntry | undefined;
172
+ if (entry && entry.cachedAt >= cutoff) this.memSet(path, entry);
173
+ resolve();
174
+ };
175
+ req.onerror = () => resolve();
176
+ }));
177
+ await Promise.all(promises);
178
+ }
179
+
180
+ close(): void {
181
+ this.memory.clear();
182
+ this.subscribedPaths.clear();
183
+ this.idb?.close();
184
+ this.idb = null;
185
+ }
186
+
187
+ // --- memory LRU ---
188
+
189
+ private memSet(path: string, entry: CacheEntry): void {
190
+ // Delete first to re-insert at end (insertion order)
191
+ this.memory.delete(path);
192
+ this.memory.set(path, entry);
193
+ // Evict oldest if over limit
194
+ while (this.memory.size > this.options.maxMemoryEntries) {
195
+ const first = this.memory.keys().next().value;
196
+ if (first !== undefined) this.memory.delete(first);
197
+ else break;
198
+ }
199
+ }
200
+
201
+ // --- IDB helpers ---
202
+
203
+ private idbGet(path: string): Promise<CacheEntry | null> {
204
+ if (!this.idb) return Promise.resolve(null);
205
+ const tx = this.idb.transaction('entries', 'readonly');
206
+ const req = tx.objectStore('entries').get(path);
207
+ return new Promise((resolve) => {
208
+ req.onsuccess = () => resolve(req.result ?? null);
209
+ req.onerror = () => resolve(null);
210
+ });
211
+ }
212
+
213
+ private idbSet(entry: CacheEntry): void {
214
+ if (!this.idb) return;
215
+ try {
216
+ const tx = this.idb.transaction('entries', 'readwrite');
217
+ tx.objectStore('entries').put(entry);
218
+ } catch {}
219
+ }
220
+
221
+ private idbDelete(path: string): void {
222
+ if (!this.idb) return;
223
+ try {
224
+ const tx = this.idb.transaction('entries', 'readwrite');
225
+ tx.objectStore('entries').delete(path);
226
+ } catch {}
227
+ }
228
+ }