@bod.ee/db 0.7.0 → 0.8.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/.claude/settings.local.json +7 -1
- package/.claude/skills/config-file.md +7 -0
- package/.claude/skills/deploying-bod-db.md +34 -0
- package/.claude/skills/developing-bod-db.md +20 -2
- package/.claude/skills/using-bod-db.md +165 -0
- package/.github/workflows/test-and-publish.yml +111 -0
- package/CLAUDE.md +10 -1
- package/README.md +57 -2
- package/admin/proxy.ts +79 -0
- package/admin/rules.ts +1 -1
- package/admin/server.ts +134 -50
- package/admin/ui.html +729 -18
- package/cli.ts +10 -0
- package/client.ts +3 -2
- package/config.ts +1 -0
- package/deploy/boddb-il.yaml +14 -0
- package/deploy/prod-il.config.ts +19 -0
- package/deploy/prod.config.ts +1 -0
- package/index.ts +3 -0
- package/package.json +7 -2
- package/src/client/BodClient.ts +129 -6
- package/src/client/CachedClient.ts +228 -0
- package/src/server/BodDB.ts +145 -1
- package/src/server/ReplicationEngine.ts +332 -0
- package/src/server/StorageEngine.ts +19 -0
- package/src/server/Transport.ts +577 -360
- package/src/server/VFSEngine.ts +172 -0
- package/src/shared/protocol.ts +25 -4
- package/tests/cached-client.test.ts +143 -0
- package/tests/replication.test.ts +404 -0
- package/tests/vfs.test.ts +166 -0
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
|
@@ -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
|
+
}
|
package/deploy/prod.config.ts
CHANGED
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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bod.ee/db",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"module": "index.ts",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
},
|
|
14
14
|
"scripts": {
|
|
15
15
|
"admin": "bun --watch run admin/server.ts",
|
|
16
|
+
"admin:remote": "bun run admin/proxy.ts",
|
|
16
17
|
"serve": "bun run cli.ts",
|
|
17
18
|
"start": "bun run cli.ts config.ts",
|
|
18
19
|
"publish-lib": "bun publish --access public",
|
|
@@ -21,7 +22,11 @@
|
|
|
21
22
|
"deploy-logs-db": "bun run deploy/deploy.ts boddb-logs ",
|
|
22
23
|
"deploy:bootstrap": "bun run deploy/deploy.ts boddb bootstrap",
|
|
23
24
|
"deploy:logs": "bun run deploy/deploy.ts boddb logs",
|
|
24
|
-
"deploy:ssh": "bun run deploy/deploy.ts boddb ssh"
|
|
25
|
+
"deploy:ssh": "bun run deploy/deploy.ts boddb ssh",
|
|
26
|
+
"deploy-il": "bun run deploy/deploy.ts boddb-il deploy",
|
|
27
|
+
"deploy-il:bootstrap": "bun run deploy/deploy.ts boddb-il bootstrap",
|
|
28
|
+
"deploy-il:logs": "bun run deploy/deploy.ts boddb-il logs",
|
|
29
|
+
"deploy-il:ssh": "bun run deploy/deploy.ts boddb-il ssh"
|
|
25
30
|
},
|
|
26
31
|
"peerDependencies": {
|
|
27
32
|
"typescript": "^5"
|
package/src/client/BodClient.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
+
}
|