@bod.ee/db 0.10.1 → 0.11.1
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 +5 -1
- package/.claude/skills/config-file.md +3 -1
- package/.claude/skills/developing-bod-db.md +10 -1
- package/.claude/skills/using-bod-db.md +18 -1
- package/CLAUDE.md +11 -3
- package/admin/admin.ts +9 -0
- package/admin/demo.config.ts +1 -0
- package/admin/ui.html +67 -37
- package/index.ts +2 -0
- package/package.json +1 -1
- package/src/client/BodClient.ts +31 -6
- package/src/server/BodDB.ts +113 -42
- package/src/server/KeyAuthEngine.ts +32 -7
- package/src/server/ReplicationEngine.ts +31 -1
- package/src/server/StorageEngine.ts +117 -6
- package/src/server/StreamEngine.ts +20 -2
- package/src/server/SubscriptionEngine.ts +122 -35
- package/src/server/Transport.ts +131 -19
- package/src/shared/keyAuth.ts +4 -4
- package/src/shared/logger.ts +68 -0
- package/src/shared/protocol.ts +3 -1
- package/tests/keyauth.test.ts +28 -1
- package/tests/optimization.test.ts +392 -0
package/src/server/BodDB.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { ReplicationEngine, type ReplicationOptions, type WriteEvent } from './R
|
|
|
11
11
|
import { VFSEngine, type VFSEngineOptions } from './VFSEngine.ts';
|
|
12
12
|
import { KeyAuthEngine, type KeyAuthEngineOptions } from './KeyAuthEngine.ts';
|
|
13
13
|
import { validatePath } from '../shared/pathUtils.ts';
|
|
14
|
+
import { Logger, type LogConfig } from '../shared/logger.ts';
|
|
14
15
|
|
|
15
16
|
export interface TransactionProxy {
|
|
16
17
|
get(path: string): unknown;
|
|
@@ -46,6 +47,8 @@ export class BodDBOptions {
|
|
|
46
47
|
port?: number;
|
|
47
48
|
auth?: TransportOptions['auth'];
|
|
48
49
|
transport?: Partial<TransportOptions>;
|
|
50
|
+
/** Logging config — disabled by default */
|
|
51
|
+
log?: LogConfig;
|
|
49
52
|
}
|
|
50
53
|
|
|
51
54
|
export class BodDB {
|
|
@@ -59,6 +62,7 @@ export class BodDB {
|
|
|
59
62
|
readonly vfs: VFSEngine | null = null;
|
|
60
63
|
readonly keyAuth: KeyAuthEngine | null = null;
|
|
61
64
|
readonly options: BodDBOptions;
|
|
65
|
+
readonly log: Logger;
|
|
62
66
|
replication: ReplicationEngine | null = null;
|
|
63
67
|
private _replaying = false;
|
|
64
68
|
private _onWriteHooks: Array<(ev: WriteEvent) => void> = [];
|
|
@@ -66,11 +70,16 @@ export class BodDB {
|
|
|
66
70
|
get transport(): Transport | null { return this._transport; }
|
|
67
71
|
private sweepTimer: ReturnType<typeof setInterval> | null = null;
|
|
68
72
|
private _statsInterval: ReturnType<typeof setInterval> | null = null;
|
|
73
|
+
private _statsStmtCount: any = null;
|
|
69
74
|
private _lastCpuUsage = process.cpuUsage();
|
|
70
75
|
private _lastCpuTime = performance.now();
|
|
76
|
+
private _lastStats: Record<string, unknown> | null = null;
|
|
71
77
|
|
|
72
78
|
constructor(options?: Partial<BodDBOptions>) {
|
|
73
79
|
this.options = { ...new BodDBOptions(), ...options };
|
|
80
|
+
this.log = new Logger(this.options.log);
|
|
81
|
+
const _log = this.log.forComponent('db');
|
|
82
|
+
_log.info(`Initializing BodDB (path: ${this.options.path})`);
|
|
74
83
|
this.storage = new StorageEngine({ path: this.options.path });
|
|
75
84
|
this.subs = new SubscriptionEngine();
|
|
76
85
|
this.stream = new StreamEngine(this.storage, this.subs, { compact: this.options.compact });
|
|
@@ -88,6 +97,7 @@ export class BodDB {
|
|
|
88
97
|
? BodDB.loadRulesFileSync(rulesConfig)
|
|
89
98
|
: (rulesConfig ?? {});
|
|
90
99
|
this.rules = new RulesEngine({ rules: resolvedRules, defaultDeny: this.options.defaultDeny });
|
|
100
|
+
_log.debug(`Rules loaded (${Object.keys(resolvedRules).length} paths, defaultDeny: ${!!this.options.defaultDeny})`);
|
|
91
101
|
|
|
92
102
|
// Auto-create indexes from config
|
|
93
103
|
if (this.options.indexes) {
|
|
@@ -96,21 +106,25 @@ export class BodDB {
|
|
|
96
106
|
this.storage.createIndex(basePath, field);
|
|
97
107
|
}
|
|
98
108
|
}
|
|
109
|
+
_log.debug(`Indexes created: ${JSON.stringify(this.options.indexes)}`);
|
|
99
110
|
}
|
|
100
111
|
|
|
101
112
|
// Initialize FTS if configured
|
|
102
113
|
if (this.options.fts) {
|
|
103
114
|
this.fts = new FTSEngine(this.storage.db, this.options.fts);
|
|
115
|
+
_log.info('FTS5 engine enabled');
|
|
104
116
|
}
|
|
105
117
|
|
|
106
118
|
// Initialize vectors if configured
|
|
107
119
|
if (this.options.vectors) {
|
|
108
120
|
this.vectors = new VectorEngine(this.storage.db, this.options.vectors);
|
|
121
|
+
_log.info(`Vector engine enabled (dimensions: ${this.options.vectors.dimensions})`);
|
|
109
122
|
}
|
|
110
123
|
|
|
111
124
|
// Initialize VFS if configured
|
|
112
125
|
if (this.options.vfs) {
|
|
113
126
|
(this as { vfs: VFSEngine }).vfs = new VFSEngine(this, this.options.vfs);
|
|
127
|
+
_log.info(`VFS enabled (root: ${this.options.vfs.storageRoot})`);
|
|
114
128
|
}
|
|
115
129
|
|
|
116
130
|
// Initialize KeyAuth if configured
|
|
@@ -119,11 +133,13 @@ export class BodDB {
|
|
|
119
133
|
if (this.options.keyAuth.rootPublicKey) {
|
|
120
134
|
this.keyAuth!.initRoot(this.options.keyAuth.rootPublicKey);
|
|
121
135
|
}
|
|
136
|
+
_log.info('KeyAuth engine enabled');
|
|
122
137
|
}
|
|
123
138
|
|
|
124
139
|
// Start TTL sweep
|
|
125
140
|
if (this.options.sweepInterval > 0) {
|
|
126
141
|
this.sweepTimer = setInterval(() => this.sweep(), this.options.sweepInterval);
|
|
142
|
+
_log.debug(`TTL sweep interval: ${this.options.sweepInterval}ms`);
|
|
127
143
|
}
|
|
128
144
|
|
|
129
145
|
// Init replication
|
|
@@ -134,7 +150,10 @@ export class BodDB {
|
|
|
134
150
|
const compactOpts = this.options.replication.compact ?? { keepKey: 'path', maxCount: 10000 };
|
|
135
151
|
this.stream.options.compact = { ...this.stream.options.compact, _repl: compactOpts };
|
|
136
152
|
}
|
|
153
|
+
_log.info(`Replication enabled (role: ${this.replication.isPrimary ? 'primary' : 'replica'})`);
|
|
137
154
|
}
|
|
155
|
+
|
|
156
|
+
_log.info('BodDB ready');
|
|
138
157
|
}
|
|
139
158
|
|
|
140
159
|
/** Load rules from a JSON or TS file synchronously */
|
|
@@ -195,6 +214,7 @@ export class BodDB {
|
|
|
195
214
|
}
|
|
196
215
|
|
|
197
216
|
get(path: string): unknown {
|
|
217
|
+
if (path === '_admin/stats' && this._lastStats) return this._lastStats;
|
|
198
218
|
return this.storage.get(path);
|
|
199
219
|
}
|
|
200
220
|
|
|
@@ -204,7 +224,7 @@ export class BodDB {
|
|
|
204
224
|
|
|
205
225
|
set(path: string, value: unknown, options?: { ttl?: number }): void {
|
|
206
226
|
const p = validatePath(path);
|
|
207
|
-
const existedBefore = this.subs.hasChildSubscriptions ? this.snapshotExisting(p) :
|
|
227
|
+
const existedBefore = this.subs.hasChildSubscriptions ? this.snapshotExisting(p) : undefined;
|
|
208
228
|
const changed = this.storage.set(path, value);
|
|
209
229
|
if (options?.ttl) {
|
|
210
230
|
this.storage.setExpiry(path, options.ttl);
|
|
@@ -218,12 +238,15 @@ export class BodDB {
|
|
|
218
238
|
/** Manually trigger TTL sweep + stream auto-compact, returns expired paths */
|
|
219
239
|
sweep(): string[] {
|
|
220
240
|
const expired = this.storage.sweep();
|
|
221
|
-
if (expired.length > 0
|
|
222
|
-
this.subs.
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
241
|
+
if (expired.length > 0) {
|
|
242
|
+
if (this.subs.hasSubscriptions) {
|
|
243
|
+
this.subs.notify(expired, (p) => this.storage.get(p));
|
|
244
|
+
}
|
|
245
|
+
// Fire delete events for expired paths (replication)
|
|
246
|
+
for (const p of expired) {
|
|
247
|
+
this._fireWrite({ op: 'delete', path: p });
|
|
248
|
+
}
|
|
249
|
+
this.log.forComponent('storage').debug(`Sweep: ${expired.length} expired paths removed`);
|
|
227
250
|
}
|
|
228
251
|
this.stream.autoCompact();
|
|
229
252
|
this.mq.sweep();
|
|
@@ -250,7 +273,7 @@ export class BodDB {
|
|
|
250
273
|
|
|
251
274
|
delete(path: string): void {
|
|
252
275
|
const p = validatePath(path);
|
|
253
|
-
const existedBefore = this.subs.hasChildSubscriptions ? this.snapshotExisting(p) :
|
|
276
|
+
const existedBefore = this.subs.hasChildSubscriptions ? this.snapshotExisting(p) : undefined;
|
|
254
277
|
this.storage.delete(path);
|
|
255
278
|
if (this.subs.hasSubscriptions) {
|
|
256
279
|
this.subs.notify([p], (pp) => this.storage.get(pp), existedBefore);
|
|
@@ -261,7 +284,7 @@ export class BodDB {
|
|
|
261
284
|
/** Push a value with auto-generated time-sortable key (stored as single JSON row, not flattened) */
|
|
262
285
|
push(path: string, value: unknown, opts?: { idempotencyKey?: string }): string {
|
|
263
286
|
const p = validatePath(path);
|
|
264
|
-
const existedBefore = this.subs.hasChildSubscriptions ? this.snapshotExisting(p) :
|
|
287
|
+
const existedBefore = this.subs.hasChildSubscriptions ? this.snapshotExisting(p) : undefined;
|
|
265
288
|
const { key, changedPaths, duplicate } = this.storage.push(path, value, opts);
|
|
266
289
|
if (!duplicate && this.subs.hasSubscriptions) {
|
|
267
290
|
this.subs.notify(changedPaths, (pp) => this.storage.get(pp), existedBefore);
|
|
@@ -350,13 +373,21 @@ export class BodDB {
|
|
|
350
373
|
},
|
|
351
374
|
};
|
|
352
375
|
|
|
376
|
+
this.replication?.beginBatch();
|
|
353
377
|
const sqliteTx = this.storage.db.transaction(() => fn(proxy));
|
|
354
|
-
|
|
378
|
+
let result: T;
|
|
379
|
+
try {
|
|
380
|
+
result = sqliteTx();
|
|
381
|
+
} catch (e) {
|
|
382
|
+
this.replication?.discardBatch();
|
|
383
|
+
throw e;
|
|
384
|
+
}
|
|
355
385
|
|
|
356
386
|
if (this.subs.hasSubscriptions && allChanged.length > 0) {
|
|
357
387
|
this.subs.notify(allChanged, (p) => this.storage.get(p), allExistedBefore);
|
|
358
388
|
}
|
|
359
389
|
for (const ev of txWriteEvents) this._fireWrite(ev);
|
|
390
|
+
this.replication?.flushBatch();
|
|
360
391
|
return result;
|
|
361
392
|
}
|
|
362
393
|
|
|
@@ -366,53 +397,95 @@ export class BodDB {
|
|
|
366
397
|
const { statSync } = require('fs');
|
|
367
398
|
const { cpus, totalmem } = require('os');
|
|
368
399
|
let lastOsCpus = cpus();
|
|
400
|
+
// Clean up old flattened _admin/stats/* leaf rows (migrated to single JSON row)
|
|
401
|
+
this.storage.db.prepare("DELETE FROM nodes WHERE path LIKE '_admin/stats/%'").run();
|
|
402
|
+
|
|
403
|
+
// Reuse prepared statement across start/stop cycles
|
|
404
|
+
if (!this._statsStmtCount) {
|
|
405
|
+
this._statsStmtCount = this.storage.db.prepare('SELECT COUNT(*) as n FROM nodes WHERE mq_status IS NULL');
|
|
406
|
+
}
|
|
407
|
+
const stmtCount = this._statsStmtCount;
|
|
408
|
+
const statsPath = '_admin/stats';
|
|
409
|
+
|
|
410
|
+
// Reuse a single stats object to minimize allocations
|
|
411
|
+
const statsData: Record<string, unknown> = {
|
|
412
|
+
process: {}, db: {}, system: {},
|
|
413
|
+
subs: 0, clients: 0, repl: null, ts: 0,
|
|
414
|
+
};
|
|
415
|
+
const proc = statsData.process as Record<string, number>;
|
|
416
|
+
const dbStats = statsData.db as Record<string, number>;
|
|
417
|
+
const sys = statsData.system as Record<string, number>;
|
|
418
|
+
|
|
419
|
+
// Heavy calls (cpus, statSync, stmtCount) run every 5s; lightweight calls every 1s
|
|
420
|
+
let tick = 0;
|
|
421
|
+
const MB = 1 / (1024 * 1024);
|
|
422
|
+
sys.cpuCores = cpus().length;
|
|
423
|
+
sys.totalMemMb = Math.round(totalmem() * MB);
|
|
369
424
|
|
|
370
425
|
this._statsInterval = setInterval(() => {
|
|
371
|
-
if (!this.
|
|
426
|
+
if (!this._transport) return;
|
|
427
|
+
tick++;
|
|
372
428
|
|
|
373
429
|
const now = performance.now();
|
|
374
430
|
const cpu = process.cpuUsage();
|
|
375
431
|
const elapsedUs = (now - this._lastCpuTime) * 1000;
|
|
376
|
-
|
|
432
|
+
proc.cpuPercent = Math.round((cpu.user - this._lastCpuUsage.user + cpu.system - this._lastCpuUsage.system) / elapsedUs * 1000) / 10;
|
|
377
433
|
this._lastCpuUsage = cpu;
|
|
378
434
|
this._lastCpuTime = now;
|
|
379
435
|
|
|
380
436
|
const mem = process.memoryUsage();
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
437
|
+
proc.heapUsedMb = Math.round(mem.heapUsed * MB * 100) / 100;
|
|
438
|
+
proc.rssMb = Math.round(mem.rss * MB * 100) / 100;
|
|
439
|
+
proc.uptimeSec = Math.floor(process.uptime());
|
|
440
|
+
|
|
441
|
+
// Expensive calls: only every 5 ticks
|
|
442
|
+
if (tick % 5 === 0) {
|
|
443
|
+
try { dbStats.nodeCount = (stmtCount.get() as any).n; } catch {}
|
|
444
|
+
try { dbStats.sizeMb = Math.round(statSync(this.options.path).size * MB * 100) / 100; } catch {}
|
|
445
|
+
const cur = cpus();
|
|
446
|
+
let idleDelta = 0, totalDelta = 0;
|
|
447
|
+
for (let i = 0; i < cur.length; i++) {
|
|
448
|
+
const prev = lastOsCpus[i]?.times ?? cur[i].times;
|
|
449
|
+
const c = cur[i].times;
|
|
450
|
+
idleDelta += c.idle - prev.idle;
|
|
451
|
+
totalDelta += (c.user + c.nice + c.sys + c.irq + c.idle) - (prev.user + prev.nice + prev.sys + prev.irq + prev.idle);
|
|
452
|
+
}
|
|
453
|
+
lastOsCpus = cur;
|
|
454
|
+
sys.cpuPercent = totalDelta > 0 ? Math.round((1 - idleDelta / totalDelta) * 1000) / 10 : 0;
|
|
394
455
|
}
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
this.
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
ts: Date.now(),
|
|
406
|
-
});
|
|
456
|
+
|
|
457
|
+
statsData.subs = this.subs.subscriberCount();
|
|
458
|
+
statsData.clients = this._transport?.clientCount ?? 0;
|
|
459
|
+
statsData.repl = this.replication?.stats() ?? null;
|
|
460
|
+
statsData.ts = Date.now();
|
|
461
|
+
|
|
462
|
+
// Push directly to WS clients — bypass SubscriptionEngine entirely
|
|
463
|
+
this._lastStats = statsData;
|
|
464
|
+
if (this._transport) this._transport.broadcastStats(statsData, Date.now());
|
|
465
|
+
|
|
407
466
|
}, 1000);
|
|
467
|
+
this.log.forComponent('stats').info('Stats publisher started');
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** Stop the stats publisher. */
|
|
471
|
+
stopStatsPublisher(): void {
|
|
472
|
+
if (!this._statsInterval) return;
|
|
473
|
+
clearInterval(this._statsInterval);
|
|
474
|
+
this._statsInterval = null;
|
|
475
|
+
this._lastStats = null;
|
|
476
|
+
this.subs.pushValue('_admin/stats', null);
|
|
477
|
+
this.log.forComponent('stats').info('Stats publisher stopped');
|
|
408
478
|
}
|
|
409
479
|
|
|
480
|
+
get statsEnabled(): boolean { return this._statsInterval !== null; }
|
|
481
|
+
|
|
410
482
|
/** Start standalone WebSocket + REST server on its own port */
|
|
411
483
|
serve(options?: Partial<TransportOptions>) {
|
|
412
484
|
const port = options?.port ?? this.options.port;
|
|
413
485
|
const auth = options?.auth ?? this.options.auth ?? (this.keyAuth ? this.keyAuth.authCallback() : undefined);
|
|
414
486
|
this._transport = new Transport(this, this.rules, { ...this.options.transport, ...options, port, auth, keyAuth: this.keyAuth ?? undefined });
|
|
415
487
|
this.startStatsPublisher();
|
|
488
|
+
this.log.forComponent('transport').info(`Server starting on port ${port}`);
|
|
416
489
|
return this._transport.start();
|
|
417
490
|
}
|
|
418
491
|
|
|
@@ -467,13 +540,11 @@ export class BodDB {
|
|
|
467
540
|
}
|
|
468
541
|
|
|
469
542
|
private snapshotExisting(path: string): Set<string> {
|
|
470
|
-
const
|
|
471
|
-
if (this.storage.exists(path)) existing.add(path);
|
|
543
|
+
const paths = [path];
|
|
472
544
|
const parts = path.split('/');
|
|
473
545
|
for (let i = 1; i < parts.length; i++) {
|
|
474
|
-
|
|
475
|
-
if (this.storage.exists(childPath)) existing.add(childPath);
|
|
546
|
+
paths.push(parts.slice(0, i + 1).join('/'));
|
|
476
547
|
}
|
|
477
|
-
return
|
|
548
|
+
return this.storage.existsMany(paths);
|
|
478
549
|
}
|
|
479
550
|
}
|
|
@@ -19,6 +19,12 @@ export class KeyAuthEngineOptions {
|
|
|
19
19
|
clockSkew: number = 30;
|
|
20
20
|
/** Allow unauthenticated device registration (default true) */
|
|
21
21
|
allowOpenRegistration: boolean = true;
|
|
22
|
+
/** PBKDF2 iterations for password-based key derivation (default 100000) */
|
|
23
|
+
pbkdf2Iterations: number = 100_000;
|
|
24
|
+
/** Max failed auth attempts before lockout (0 = disabled) */
|
|
25
|
+
maxAuthFailures: number = 0;
|
|
26
|
+
/** Lockout duration in seconds after max failures */
|
|
27
|
+
authLockoutSeconds: number = 60;
|
|
22
28
|
}
|
|
23
29
|
|
|
24
30
|
/** Reserved prefix — _auth/ writes blocked for external clients */
|
|
@@ -80,7 +86,7 @@ export class KeyAuthEngine {
|
|
|
80
86
|
* If this is the first account ever created, it is auto-elevated to root. */
|
|
81
87
|
createAccount(password: string, roles: string[] = [], displayName?: string, devicePublicKey?: string): { publicKey: string; fingerprint: string; isRoot: boolean; deviceFingerprint?: string } {
|
|
82
88
|
const kp = generateEd25519KeyPair();
|
|
83
|
-
const enc = encryptPrivateKey(kp.privateKey, password);
|
|
89
|
+
const enc = encryptPrivateKey(kp.privateKey, password, this.options.pbkdf2Iterations);
|
|
84
90
|
const fp = fingerprint(kp.publicKeyBase64);
|
|
85
91
|
|
|
86
92
|
// First account becomes root automatically
|
|
@@ -145,6 +151,14 @@ export class KeyAuthEngine {
|
|
|
145
151
|
|
|
146
152
|
/** Verify a signed nonce. Returns token + expiry or null. */
|
|
147
153
|
verify(publicKeyBase64: string, signatureBase64: string, nonce: string): { token: string; expiresAt: number } | null {
|
|
154
|
+
const fp = fingerprint(publicKeyBase64);
|
|
155
|
+
|
|
156
|
+
// Rate limit check
|
|
157
|
+
if (this.options.maxAuthFailures > 0) {
|
|
158
|
+
const rlData = this.db.get(`_auth/rateLimit/${fp}`) as { count: number } | null;
|
|
159
|
+
if (rlData && rlData.count >= this.options.maxAuthFailures) return null;
|
|
160
|
+
}
|
|
161
|
+
|
|
148
162
|
// Check nonce exists (one-time use)
|
|
149
163
|
const nonceData = this.db.get(`_auth/nonces/${nonce}`);
|
|
150
164
|
if (!nonceData) return null;
|
|
@@ -154,9 +168,10 @@ export class KeyAuthEngine {
|
|
|
154
168
|
// Verify signature
|
|
155
169
|
const pubKeyDer = Buffer.from(publicKeyBase64, 'base64');
|
|
156
170
|
const sig = Buffer.from(signatureBase64, 'base64');
|
|
157
|
-
if (!verifySignature(Buffer.from(nonce), sig, new Uint8Array(pubKeyDer)))
|
|
158
|
-
|
|
159
|
-
|
|
171
|
+
if (!verifySignature(Buffer.from(nonce), sig, new Uint8Array(pubKeyDer))) {
|
|
172
|
+
this._recordAuthFailure(fp);
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
160
175
|
|
|
161
176
|
// Check if root
|
|
162
177
|
const root = this.db.get('_auth/root') as { fingerprint: string } | null;
|
|
@@ -200,6 +215,8 @@ export class KeyAuthEngine {
|
|
|
200
215
|
sid, fp, accountFp, roles, root: isRoot, exp: expiresAt,
|
|
201
216
|
};
|
|
202
217
|
const token = encodeToken(payload, this.serverPrivateKey);
|
|
218
|
+
// Clear rate limit on success
|
|
219
|
+
if (this.options.maxAuthFailures > 0) this.db.delete(`_auth/rateLimit/${fp}`);
|
|
203
220
|
return { token, expiresAt };
|
|
204
221
|
}
|
|
205
222
|
|
|
@@ -213,7 +230,7 @@ export class KeyAuthEngine {
|
|
|
213
230
|
let accountPrivateKey: Uint8Array;
|
|
214
231
|
try {
|
|
215
232
|
accountPrivateKey = decryptPrivateKey(
|
|
216
|
-
account.encryptedPrivateKey, account.salt, account.iv, account.authTag, password,
|
|
233
|
+
account.encryptedPrivateKey, account.salt, account.iv, account.authTag, password, this.options.pbkdf2Iterations,
|
|
217
234
|
);
|
|
218
235
|
} catch {
|
|
219
236
|
return null; // Wrong password
|
|
@@ -269,12 +286,12 @@ export class KeyAuthEngine {
|
|
|
269
286
|
|
|
270
287
|
let privateKey: Uint8Array;
|
|
271
288
|
try {
|
|
272
|
-
privateKey = decryptPrivateKey(account.encryptedPrivateKey, account.salt, account.iv, account.authTag, oldPassword);
|
|
289
|
+
privateKey = decryptPrivateKey(account.encryptedPrivateKey, account.salt, account.iv, account.authTag, oldPassword, this.options.pbkdf2Iterations);
|
|
273
290
|
} catch {
|
|
274
291
|
return false;
|
|
275
292
|
}
|
|
276
293
|
|
|
277
|
-
const enc = encryptPrivateKey(privateKey, newPassword);
|
|
294
|
+
const enc = encryptPrivateKey(privateKey, newPassword, this.options.pbkdf2Iterations);
|
|
278
295
|
privateKey.fill(0); // Wipe
|
|
279
296
|
|
|
280
297
|
// Atomic: single update call writes all fields together
|
|
@@ -460,6 +477,14 @@ export class KeyAuthEngine {
|
|
|
460
477
|
return { accountFp, roles: account.roles ?? [] };
|
|
461
478
|
}
|
|
462
479
|
|
|
480
|
+
/** Record a failed auth attempt for rate limiting */
|
|
481
|
+
private _recordAuthFailure(fp: string): void {
|
|
482
|
+
if (this.options.maxAuthFailures <= 0) return;
|
|
483
|
+
const rlData = this.db.get(`_auth/rateLimit/${fp}`) as { count: number } | null;
|
|
484
|
+
const count = (rlData?.count ?? 0) + 1;
|
|
485
|
+
this.db.set(`_auth/rateLimit/${fp}`, { count }, { ttl: this.options.authLockoutSeconds });
|
|
486
|
+
}
|
|
487
|
+
|
|
463
488
|
/** Invalidate all sessions for a given device fingerprint */
|
|
464
489
|
private _invalidateDeviceSessions(deviceFp: string): void {
|
|
465
490
|
const sessions = this.db.getShallow('_auth/sessions');
|
|
@@ -52,6 +52,7 @@ export class ReplicationEngine {
|
|
|
52
52
|
private _started = false;
|
|
53
53
|
private _seq = 0;
|
|
54
54
|
private _emitting = false;
|
|
55
|
+
private _pendingReplEvents: WriteEvent[] | null = null;
|
|
55
56
|
|
|
56
57
|
get isReplica(): boolean { return this.options.role === 'replica'; }
|
|
57
58
|
get isPrimary(): boolean { return this.options.role === 'primary'; }
|
|
@@ -154,11 +155,21 @@ export class ReplicationEngine {
|
|
|
154
155
|
return this.options.excludePrefixes.some(p => path.startsWith(p));
|
|
155
156
|
}
|
|
156
157
|
|
|
158
|
+
/** Buffer replication events during transactions, emit immediately otherwise */
|
|
157
159
|
private emit(ev: WriteEvent): void {
|
|
158
|
-
// Guard against recursion (db.push('_repl') triggers _fireWrite → emit)
|
|
159
160
|
if (this._emitting) return;
|
|
160
161
|
if (this.isExcluded(ev.path)) return;
|
|
161
162
|
|
|
163
|
+
// If buffering (transaction in progress), collect events
|
|
164
|
+
if (this._pendingReplEvents) {
|
|
165
|
+
this._pendingReplEvents.push(ev);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
this._emitNow(ev);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private _emitNow(ev: WriteEvent): void {
|
|
162
173
|
this._emitting = true;
|
|
163
174
|
try {
|
|
164
175
|
const replEvent: ReplEvent = { ...ev, ts: Date.now() };
|
|
@@ -170,6 +181,25 @@ export class ReplicationEngine {
|
|
|
170
181
|
}
|
|
171
182
|
}
|
|
172
183
|
|
|
184
|
+
/** Start buffering replication events (call before transaction) */
|
|
185
|
+
beginBatch(): void {
|
|
186
|
+
this._pendingReplEvents = [];
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Flush buffered replication events (call after transaction commit) */
|
|
190
|
+
flushBatch(): void {
|
|
191
|
+
const events = this._pendingReplEvents;
|
|
192
|
+
this._pendingReplEvents = null;
|
|
193
|
+
if (events) {
|
|
194
|
+
for (const ev of events) this._emitNow(ev);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Discard buffered events (call on transaction rollback) */
|
|
199
|
+
discardBatch(): void {
|
|
200
|
+
this._pendingReplEvents = null;
|
|
201
|
+
}
|
|
202
|
+
|
|
173
203
|
// --- Replica mode ---
|
|
174
204
|
|
|
175
205
|
private async startReplica(): Promise<void> {
|
|
@@ -305,11 +305,97 @@ export class StorageEngine {
|
|
|
305
305
|
path = validatePath(path);
|
|
306
306
|
const prefix = path + '/';
|
|
307
307
|
|
|
308
|
-
//
|
|
308
|
+
// Try SQL push-down for push rows (single JSON values at depth 1)
|
|
309
|
+
if (this._canSqlPushDown(prefix, filters)) {
|
|
310
|
+
return this._sqlPushDownQuery(path, prefix, filters, order, limit, offset);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Fallback: full JS-based query
|
|
314
|
+
return this._jsQuery(path, prefix, filters, order, limit, offset);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Check if all filters target top-level JSON fields AND data is push-row format */
|
|
318
|
+
private _canSqlPushDown(prefix: string, filters?: QueryFilter[]): boolean {
|
|
319
|
+
if (!filters?.length) return false;
|
|
320
|
+
// All filter fields must be simple identifiers (no nested paths)
|
|
321
|
+
if (!filters.every(f => /^[a-zA-Z_]\w*$/.test(f.field))) return false;
|
|
322
|
+
// Check if direct children are single JSON rows (push-row format, not flattened)
|
|
323
|
+
// Sample the first row: if its path relative to prefix has no '/', it's a push row
|
|
324
|
+
const sample = this.db.prepare('SELECT path FROM nodes WHERE path >= ? AND path < ? AND mq_status IS NULL LIMIT 1')
|
|
325
|
+
.get(prefix, prefixEnd(prefix)) as { path: string } | null;
|
|
326
|
+
if (!sample) return false;
|
|
327
|
+
const rel = sample.path.slice(prefix.length);
|
|
328
|
+
return !rel.includes('/'); // push row = no nesting
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** SQL push-down query for push rows using json_extract */
|
|
332
|
+
private _sqlPushDownQuery(
|
|
333
|
+
path: string, prefix: string,
|
|
334
|
+
filters?: QueryFilter[], order?: OrderClause,
|
|
335
|
+
limit?: number, offset?: number,
|
|
336
|
+
): Array<{ _path: string; _key: string; [k: string]: unknown }> {
|
|
337
|
+
const params: unknown[] = [prefix, prefixEnd(prefix)];
|
|
338
|
+
const whereClauses = ['path >= ?', 'path < ?', 'mq_status IS NULL'];
|
|
339
|
+
|
|
340
|
+
// Only match direct children (no '/' in relative path)
|
|
341
|
+
// We filter this in JS since SQLite doesn't have a good way to check depth
|
|
342
|
+
|
|
343
|
+
const VALID_OPS: Record<string, string> = { '==': '=', '!=': '!=', '<': '<', '<=': '<=', '>': '>', '>=': '>=' };
|
|
344
|
+
if (filters?.length) {
|
|
345
|
+
for (const f of filters) {
|
|
346
|
+
const sqlOp = VALID_OPS[f.op];
|
|
347
|
+
if (!sqlOp) continue; // skip unknown operators
|
|
348
|
+
const jsonPath = `$.${f.field}`; // f.field already validated by _canSqlPushDown regex
|
|
349
|
+
whereClauses.push(`json_extract(value, '${jsonPath}') ${sqlOp} ?`);
|
|
350
|
+
params.push(f.value);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
let sql = `SELECT path, value FROM nodes WHERE ${whereClauses.join(' AND ')}`;
|
|
355
|
+
|
|
356
|
+
if (order && /^[a-zA-Z_]\w*$/.test(order.field)) {
|
|
357
|
+
sql += ` ORDER BY json_extract(value, '$.${order.field}') ${order.dir === 'desc' ? 'DESC' : 'ASC'}`;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
if (limit != null) {
|
|
361
|
+
sql += ` LIMIT ?`;
|
|
362
|
+
params.push(limit);
|
|
363
|
+
if (offset != null) {
|
|
364
|
+
sql += ` OFFSET ?`;
|
|
365
|
+
params.push(offset);
|
|
366
|
+
}
|
|
367
|
+
} else if (offset != null) {
|
|
368
|
+
sql += ` LIMIT -1 OFFSET ?`;
|
|
369
|
+
params.push(offset);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const rows = this.db.prepare(sql).all(...params) as Array<{ path: string; value: string }>;
|
|
373
|
+
|
|
374
|
+
// Filter to direct children only and reconstruct
|
|
375
|
+
const results: Array<{ _path: string; _key: string; [k: string]: unknown }> = [];
|
|
376
|
+
for (const row of rows) {
|
|
377
|
+
const rel = row.path.slice(prefix.length);
|
|
378
|
+
if (rel.includes('/')) continue; // skip nested rows
|
|
379
|
+
const key = rel;
|
|
380
|
+
const val = JSON.parse(row.value);
|
|
381
|
+
if (val && typeof val === 'object' && !Array.isArray(val)) {
|
|
382
|
+
results.push({ _path: row.path, _key: key, ...(val as Record<string, unknown>) });
|
|
383
|
+
} else {
|
|
384
|
+
results.push({ _path: row.path, _key: key, _value: val });
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
return results;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** Full JS-based query (original implementation) */
|
|
391
|
+
private _jsQuery(
|
|
392
|
+
path: string, prefix: string,
|
|
393
|
+
filters?: QueryFilter[], order?: OrderClause,
|
|
394
|
+
limit?: number, offset?: number,
|
|
395
|
+
): Array<{ _path: string; _key: string; [k: string]: unknown }> {
|
|
309
396
|
const rows = this.stmtPrefix.all(prefix, prefixEnd(prefix)) as Array<{ path: string; value: string }>;
|
|
310
397
|
if (rows.length === 0) return [];
|
|
311
398
|
|
|
312
|
-
// Group rows by direct child key
|
|
313
399
|
const children = new Map<string, Array<{ path: string; value: string }>>();
|
|
314
400
|
for (const row of rows) {
|
|
315
401
|
const rel = row.path.slice(prefix.length);
|
|
@@ -318,7 +404,6 @@ export class StorageEngine {
|
|
|
318
404
|
children.get(childKey)!.push(row);
|
|
319
405
|
}
|
|
320
406
|
|
|
321
|
-
// Reconstruct each child
|
|
322
407
|
let results: Array<{ _path: string; _key: string; [k: string]: unknown }> = [];
|
|
323
408
|
for (const [key, childRows] of children) {
|
|
324
409
|
const childPath = `${path}/${key}`;
|
|
@@ -330,7 +415,6 @@ export class StorageEngine {
|
|
|
330
415
|
}
|
|
331
416
|
}
|
|
332
417
|
|
|
333
|
-
// Apply filters
|
|
334
418
|
if (filters?.length) {
|
|
335
419
|
for (const f of filters) {
|
|
336
420
|
results = results.filter(item => {
|
|
@@ -348,7 +432,6 @@ export class StorageEngine {
|
|
|
348
432
|
}
|
|
349
433
|
}
|
|
350
434
|
|
|
351
|
-
// Apply ordering
|
|
352
435
|
if (order) {
|
|
353
436
|
const dir = order.dir === 'desc' ? -1 : 1;
|
|
354
437
|
results.sort((a, b) => {
|
|
@@ -361,7 +444,6 @@ export class StorageEngine {
|
|
|
361
444
|
});
|
|
362
445
|
}
|
|
363
446
|
|
|
364
|
-
// Apply offset + limit
|
|
365
447
|
if (offset) results = results.slice(offset);
|
|
366
448
|
if (limit) results = results.slice(0, limit);
|
|
367
449
|
|
|
@@ -438,6 +520,35 @@ export class StorageEngine {
|
|
|
438
520
|
}));
|
|
439
521
|
}
|
|
440
522
|
|
|
523
|
+
/** Check which paths from a list have data (exact or subtree). Returns the set of existing paths. */
|
|
524
|
+
existsMany(paths: string[]): Set<string> {
|
|
525
|
+
if (paths.length === 0) return new Set();
|
|
526
|
+
const result = new Set<string>();
|
|
527
|
+
const validated = paths.map(p => validatePath(p));
|
|
528
|
+
const params: string[] = [];
|
|
529
|
+
const conditions: string[] = [];
|
|
530
|
+
for (const vp of validated) {
|
|
531
|
+
const prefix = vp + '/';
|
|
532
|
+
conditions.push('(path = ? OR (path >= ? AND path < ?))');
|
|
533
|
+
params.push(vp, prefix, prefixEnd(prefix));
|
|
534
|
+
}
|
|
535
|
+
const sql = `SELECT DISTINCT path FROM nodes WHERE (${conditions.join(' OR ')}) AND mq_status IS NULL`;
|
|
536
|
+
const rows = this.db.prepare(sql).all(...params) as Array<{ path: string }>;
|
|
537
|
+
// Build a lookup set for O(1) matching
|
|
538
|
+
const vpSet = new Set(validated);
|
|
539
|
+
for (const row of rows) {
|
|
540
|
+
// Check exact match
|
|
541
|
+
if (vpSet.has(row.path)) { result.add(row.path); continue; }
|
|
542
|
+
// Check if row is a descendant of any requested path
|
|
543
|
+
const parts = row.path.split('/');
|
|
544
|
+
for (let i = parts.length - 1; i >= 1; i--) {
|
|
545
|
+
const ancestor = parts.slice(0, i).join('/');
|
|
546
|
+
if (vpSet.has(ancestor)) { result.add(ancestor); break; }
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
return result;
|
|
550
|
+
}
|
|
551
|
+
|
|
441
552
|
close() {
|
|
442
553
|
this.db.close();
|
|
443
554
|
}
|
|
@@ -61,12 +61,30 @@ export class StreamEngine {
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
/** Get materialized view: snapshot merged with events on top */
|
|
64
|
-
materialize(topic: string, opts?: { keepKey?: string }): Record<string, unknown
|
|
64
|
+
materialize(topic: string, opts?: { keepKey?: string }): Record<string, unknown>;
|
|
65
|
+
materialize(topic: string, opts: { keepKey?: string; batchSize: number; cursor?: string }): { data: Record<string, unknown>; nextCursor?: string };
|
|
66
|
+
materialize(topic: string, opts?: { keepKey?: string; batchSize?: number; cursor?: string }): Record<string, unknown> | { data: Record<string, unknown>; nextCursor?: string } {
|
|
65
67
|
const snap = this.snapshot(topic);
|
|
66
68
|
const result: Record<string, unknown> = snap ? { ...snap.data } : {};
|
|
67
69
|
const keepKey = opts?.keepKey;
|
|
70
|
+
const batchSize = opts?.batchSize;
|
|
71
|
+
|
|
72
|
+
if (batchSize != null && batchSize < Infinity) {
|
|
73
|
+
// Cursor-based: return one batch at a time
|
|
74
|
+
const afterKey = opts?.cursor ?? snap?.key ?? null;
|
|
75
|
+
const events = this.storage.queryAfterKey(topic, afterKey, batchSize);
|
|
76
|
+
for (const ev of events) {
|
|
77
|
+
if (keepKey && ev.value && typeof ev.value === 'object' && keepKey in (ev.value as any)) {
|
|
78
|
+
result[String((ev.value as any)[keepKey])] = ev.value;
|
|
79
|
+
} else if (ev.value && typeof ev.value === 'object' && !Array.isArray(ev.value)) {
|
|
80
|
+
Object.assign(result, ev.value);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const nextCursor = events.length === batchSize ? events[events.length - 1].key : undefined;
|
|
84
|
+
return { data: result, nextCursor };
|
|
85
|
+
}
|
|
68
86
|
|
|
69
|
-
//
|
|
87
|
+
// Full materialize (backward compatible)
|
|
70
88
|
const afterKey = snap?.key ?? null;
|
|
71
89
|
const events = this.storage.queryAfterKey(topic, afterKey, Number.MAX_SAFE_INTEGER);
|
|
72
90
|
for (const ev of events) {
|