@bod.ee/db 0.12.1 → 0.12.4
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/skills/config-file.md +6 -0
- package/.claude/skills/deploying-bod-db.md +21 -0
- package/.claude/skills/developing-bod-db.md +1 -1
- package/.claude/skills/using-bod-db.md +42 -0
- package/CLAUDE.md +2 -1
- package/README.md +14 -0
- package/admin/ui.html +7 -6
- package/docs/para-chat-integration.md +198 -0
- package/docs/repl-stream-bloat-spec.md +48 -0
- package/package.json +1 -1
- package/src/client/BodClient.ts +4 -2
- package/src/server/BodDB.ts +8 -4
- package/src/server/ReplicationEngine.ts +283 -45
- package/src/server/Transport.ts +62 -30
- package/src/server/VFSEngine.ts +13 -2
- package/src/shared/protocol.ts +2 -1
- package/tests/repl-load.test.ts +370 -0
- package/tests/repl-stream-bloat.test.ts +295 -0
- package/tests/replication-topology.test.ts +835 -0
|
@@ -2,6 +2,7 @@ import type { BodDB } from './BodDB.ts';
|
|
|
2
2
|
import { BodClient } from '../client/BodClient.ts';
|
|
3
3
|
import type { ClientMessage } from '../shared/protocol.ts';
|
|
4
4
|
import type { CompactOptions } from './StreamEngine.ts';
|
|
5
|
+
import type { ComponentLogger } from '../shared/logger.ts';
|
|
5
6
|
|
|
6
7
|
export interface ReplEvent {
|
|
7
8
|
op: 'set' | 'delete' | 'push';
|
|
@@ -20,6 +21,94 @@ export interface WriteEvent {
|
|
|
20
21
|
ttl?: number;
|
|
21
22
|
}
|
|
22
23
|
|
|
24
|
+
// --- Per-path topology types ---
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Per-path replication mode:
|
|
28
|
+
* - `primary`: local-authoritative, emits to _repl, not pulled from remote
|
|
29
|
+
* - `replica`: remote-authoritative, pulled from remote, client writes proxied to primary
|
|
30
|
+
* - `sync`: emits + pulls (one-directional pull — remote must also configure a source pointing back for true bidirectional)
|
|
31
|
+
* - `readonly`: pulled from remote, client writes rejected
|
|
32
|
+
* - `writeonly`: emits only, not pulled from remote, client writes allowed locally
|
|
33
|
+
*/
|
|
34
|
+
export type PathMode = 'primary' | 'replica' | 'sync' | 'readonly' | 'writeonly';
|
|
35
|
+
|
|
36
|
+
export interface PathTopology {
|
|
37
|
+
path: string;
|
|
38
|
+
mode: PathMode;
|
|
39
|
+
/** Override write behavior: 'reject' rejects writes even on replica paths (default: proxy for replica, reject for readonly) */
|
|
40
|
+
writeProxy?: 'proxy' | 'optimistic' | 'reject';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Resolves per-path replication mode via longest-prefix match */
|
|
44
|
+
export class PathTopologyRouter {
|
|
45
|
+
private entries: PathTopology[];
|
|
46
|
+
private fallbackMode: PathMode;
|
|
47
|
+
|
|
48
|
+
constructor(paths: Array<string | PathTopology>, fallbackRole: 'primary' | 'replica') {
|
|
49
|
+
this.fallbackMode = fallbackRole === 'primary' ? 'primary' : 'replica';
|
|
50
|
+
this.entries = paths.map(p =>
|
|
51
|
+
typeof p === 'string' ? { path: p, mode: 'sync' as PathMode } : p
|
|
52
|
+
);
|
|
53
|
+
// Sort longest-first for prefix matching
|
|
54
|
+
this.entries.sort((a, b) => b.path.length - a.path.length);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Longest-prefix match, falls back to role-based mode */
|
|
58
|
+
resolve(path: string): PathTopology {
|
|
59
|
+
for (const e of this.entries) {
|
|
60
|
+
if (path === e.path || path.startsWith(e.path + '/')) {
|
|
61
|
+
return e;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return { path: '', mode: this.fallbackMode };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Should this path's writes be emitted to _repl? */
|
|
68
|
+
shouldEmit(path: string): boolean {
|
|
69
|
+
const { mode } = this.resolve(path);
|
|
70
|
+
return mode === 'primary' || mode === 'sync' || mode === 'writeonly';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Should incoming repl events for this path be applied? */
|
|
74
|
+
shouldApply(path: string): boolean {
|
|
75
|
+
const { mode } = this.resolve(path);
|
|
76
|
+
return mode === 'replica' || mode === 'sync' || mode === 'readonly';
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Should client writes to this path be proxied to primary? */
|
|
80
|
+
shouldProxy(path: string): boolean {
|
|
81
|
+
const { mode, writeProxy } = this.resolve(path);
|
|
82
|
+
if (writeProxy === 'reject') return false;
|
|
83
|
+
if (mode === 'readonly') return false; // readonly rejects, not proxies
|
|
84
|
+
return mode === 'replica';
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Should client writes to this path be rejected? */
|
|
88
|
+
shouldReject(path: string): boolean {
|
|
89
|
+
const { mode, writeProxy } = this.resolve(path);
|
|
90
|
+
if (writeProxy === 'reject') return true;
|
|
91
|
+
return mode === 'readonly';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Get all configured entries */
|
|
95
|
+
getEntries(): readonly PathTopology[] { return this.entries; }
|
|
96
|
+
|
|
97
|
+
/** Get paths that need pulling from primary (ongoing stream subscription) */
|
|
98
|
+
getReplicaPaths(): string[] {
|
|
99
|
+
return this.entries
|
|
100
|
+
.filter(e => e.mode === 'replica' || e.mode === 'readonly' || e.mode === 'sync')
|
|
101
|
+
.map(e => e.path);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Get paths that need full bootstrap from primary (excludes sync — sync paths get ongoing events only, avoiding overwrite of local state) */
|
|
105
|
+
getBootstrapPaths(): string[] {
|
|
106
|
+
return this.entries
|
|
107
|
+
.filter(e => e.mode === 'replica' || e.mode === 'readonly')
|
|
108
|
+
.map(e => e.path);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
23
112
|
export class ReplicationSource {
|
|
24
113
|
url: string = '';
|
|
25
114
|
auth?: () => string | Promise<string>;
|
|
@@ -34,17 +123,25 @@ export class ReplicationOptions {
|
|
|
34
123
|
primaryUrl?: string;
|
|
35
124
|
primaryAuth?: () => string | Promise<string>;
|
|
36
125
|
replicaId?: string;
|
|
126
|
+
/** Paths excluded from replication. Note: when `paths` explicitly configures a prefix (e.g. `_auth`), it overrides this exclusion. */
|
|
37
127
|
excludePrefixes: string[] = ['_repl', '_streams', '_mq', '_admin', '_auth'];
|
|
38
128
|
/** Bootstrap replica from primary's full state before applying _repl stream */
|
|
39
129
|
fullBootstrap: boolean = true;
|
|
40
130
|
compact?: CompactOptions;
|
|
131
|
+
/** Auto-compact _repl after this many emitted writes (0 = disabled, default 500) */
|
|
132
|
+
autoCompactThreshold: number = 500;
|
|
41
133
|
sources?: ReplicationSource[];
|
|
134
|
+
/** Per-path topology: strings default to 'sync', objects specify mode. When absent, role governs all paths. */
|
|
135
|
+
paths?: Array<string | PathTopology>;
|
|
42
136
|
}
|
|
43
137
|
|
|
138
|
+
const BOOTSTRAP_BATCH_SIZE = 200;
|
|
139
|
+
|
|
44
140
|
type ProxyableMessage = Extract<ClientMessage, { op: 'set' | 'delete' | 'update' | 'push' | 'batch' }>;
|
|
45
141
|
|
|
46
142
|
export class ReplicationEngine {
|
|
47
143
|
readonly options: ReplicationOptions;
|
|
144
|
+
readonly router: PathTopologyRouter | null = null;
|
|
48
145
|
private client: BodClient | null = null;
|
|
49
146
|
private unsubWrite: (() => void) | null = null;
|
|
50
147
|
private unsubStream: (() => void) | null = null;
|
|
@@ -52,10 +149,17 @@ export class ReplicationEngine {
|
|
|
52
149
|
private _started = false;
|
|
53
150
|
private _seq = 0;
|
|
54
151
|
private _emitting = false;
|
|
152
|
+
private _emitCount = 0;
|
|
55
153
|
private _pendingReplEvents: WriteEvent[] | null = null;
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
154
|
+
private log: ComponentLogger;
|
|
155
|
+
|
|
156
|
+
/** When no router, falls back to role-based check. With router, both return false — use emitsToRepl/pullsFromPrimary instead. */
|
|
157
|
+
get isReplica(): boolean { return !this.router && this.options.role === 'replica'; }
|
|
158
|
+
get isPrimary(): boolean { return !this.router && this.options.role === 'primary'; }
|
|
159
|
+
/** True if this node emits write events to _repl (primary, router with emitting paths, or any node with startPrimary called) */
|
|
160
|
+
get emitsToRepl(): boolean { return this.isPrimary || !!this.router; }
|
|
161
|
+
/** True if this node pulls events from a primary */
|
|
162
|
+
get pullsFromPrimary(): boolean { return this.isReplica || (!!this.router && this.router.getReplicaPaths().length > 0); }
|
|
59
163
|
get started(): boolean { return this._started; }
|
|
60
164
|
get seq(): number { return this._seq; }
|
|
61
165
|
|
|
@@ -65,6 +169,7 @@ export class ReplicationEngine {
|
|
|
65
169
|
role: this.options.role,
|
|
66
170
|
started: this._started,
|
|
67
171
|
seq: this._seq,
|
|
172
|
+
topology: this.router ? this.router.getEntries().map(e => ({ path: e.path, mode: e.mode })) : null,
|
|
68
173
|
sources: (this.options.sources ?? []).map((s, i) => {
|
|
69
174
|
const conn = this.sourceConns[i];
|
|
70
175
|
return {
|
|
@@ -84,19 +189,62 @@ export class ReplicationEngine {
|
|
|
84
189
|
options?: Partial<ReplicationOptions>,
|
|
85
190
|
) {
|
|
86
191
|
this.options = { ...new ReplicationOptions(), ...options };
|
|
192
|
+
this.log = db.log.forComponent('repl');
|
|
193
|
+
if (this.options.paths?.length) {
|
|
194
|
+
this.router = new PathTopologyRouter(this.options.paths, this.options.role);
|
|
195
|
+
}
|
|
87
196
|
if (!this.options.replicaId) {
|
|
88
|
-
|
|
197
|
+
// Derive stable ID from config when possible, fall back to random
|
|
198
|
+
if (this.options.primaryUrl) {
|
|
199
|
+
const seed = `${this.options.primaryUrl}:${(this.options.paths ?? []).map(p => typeof p === 'string' ? p : p.path).sort().join('+')}`;
|
|
200
|
+
// Simple hash for stability across restarts
|
|
201
|
+
let h = 0;
|
|
202
|
+
for (let i = 0; i < seed.length; i++) h = ((h << 5) - h + seed.charCodeAt(i)) | 0;
|
|
203
|
+
this.options.replicaId = `replica_${(h >>> 0).toString(36)}`;
|
|
204
|
+
} else {
|
|
205
|
+
this.options.replicaId = `replica_${Math.random().toString(36).slice(2, 10)}`;
|
|
206
|
+
}
|
|
89
207
|
}
|
|
90
208
|
}
|
|
91
209
|
|
|
210
|
+
/** Should client writes to this path be proxied to primary? */
|
|
211
|
+
shouldProxyPath(path: string): boolean {
|
|
212
|
+
if (this.router) {
|
|
213
|
+
// Only proxy if we have a client connection to proxy through
|
|
214
|
+
return this.router.shouldProxy(path) && !!this.client;
|
|
215
|
+
}
|
|
216
|
+
return this.options.role === 'replica';
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Should client writes to this path be rejected? */
|
|
220
|
+
shouldRejectPath(path: string): boolean {
|
|
221
|
+
if (this.router) {
|
|
222
|
+
const shouldProxy = this.router.shouldProxy(path);
|
|
223
|
+
// If path wants proxy but no client → reject instead of crashing
|
|
224
|
+
if (shouldProxy && !this.client) return true;
|
|
225
|
+
return this.router.shouldReject(path);
|
|
226
|
+
}
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
|
|
92
230
|
/** Start replication — primary listens for writes, replica connects and consumes */
|
|
93
231
|
async start(): Promise<void> {
|
|
94
232
|
if (this._started) return;
|
|
95
233
|
this._started = true;
|
|
96
234
|
|
|
97
|
-
|
|
235
|
+
// Always hook writes for emit (router or primary will filter)
|
|
236
|
+
if (this.router || this.isPrimary) {
|
|
98
237
|
this.startPrimary();
|
|
99
|
-
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Connect to primary for replica/readonly/sync paths
|
|
241
|
+
if (this.router) {
|
|
242
|
+
const pullPaths = this.router.getReplicaPaths();
|
|
243
|
+
if (pullPaths.length) {
|
|
244
|
+
if (!this.options.primaryUrl) throw new Error('primaryUrl is required when paths include replica/readonly/sync modes');
|
|
245
|
+
await this.startReplicaForPaths(pullPaths);
|
|
246
|
+
}
|
|
247
|
+
} else if (this.options.role === 'replica') {
|
|
100
248
|
await this.startReplica();
|
|
101
249
|
}
|
|
102
250
|
|
|
@@ -108,6 +256,7 @@ export class ReplicationEngine {
|
|
|
108
256
|
/** Stop replication */
|
|
109
257
|
stop(): void {
|
|
110
258
|
this._started = false;
|
|
259
|
+
this._emitCount = 0;
|
|
111
260
|
this.unsubWrite?.();
|
|
112
261
|
this.unsubWrite = null;
|
|
113
262
|
this.unsubStream?.();
|
|
@@ -123,7 +272,7 @@ export class ReplicationEngine {
|
|
|
123
272
|
|
|
124
273
|
/** Proxy a write operation to the primary (replica mode) */
|
|
125
274
|
async proxyWrite(msg: ProxyableMessage): Promise<unknown> {
|
|
126
|
-
if (!this.client) throw new Error('Replica not connected to primary');
|
|
275
|
+
if (!this.client) throw new Error('Replica not connected to primary — ensure primaryUrl is set and start() has been called');
|
|
127
276
|
switch (msg.op) {
|
|
128
277
|
case 'set':
|
|
129
278
|
await this.client.set(msg.path, msg.value, msg.ttl ? { ttl: msg.ttl } : undefined);
|
|
@@ -149,16 +298,40 @@ export class ReplicationEngine {
|
|
|
149
298
|
this.unsubWrite = this.db.onWrite((ev: WriteEvent) => {
|
|
150
299
|
this.emit(ev);
|
|
151
300
|
});
|
|
301
|
+
|
|
302
|
+
// Compact on startup
|
|
303
|
+
const compact = this.options.compact ?? { maxCount: 500, keepKey: 'path' };
|
|
304
|
+
if (compact.maxCount || compact.maxAge) {
|
|
305
|
+
try { this.db.stream.compact('_repl', compact); } catch {}
|
|
306
|
+
}
|
|
152
307
|
}
|
|
153
308
|
|
|
154
309
|
private isExcluded(path: string): boolean {
|
|
155
|
-
|
|
310
|
+
if (this.options.excludePrefixes.some(p => path.startsWith(p))) {
|
|
311
|
+
// If router explicitly configures this path, don't exclude it
|
|
312
|
+
if (this.router) {
|
|
313
|
+
const resolved = this.router.resolve(path);
|
|
314
|
+
if (resolved.path !== '') return false; // explicitly configured → not excluded
|
|
315
|
+
}
|
|
316
|
+
return true;
|
|
317
|
+
}
|
|
318
|
+
return false;
|
|
156
319
|
}
|
|
157
320
|
|
|
158
321
|
/** Buffer replication events during transactions, emit immediately otherwise */
|
|
159
322
|
private emit(ev: WriteEvent): void {
|
|
160
323
|
if (this._emitting) return;
|
|
161
|
-
if (this.
|
|
324
|
+
if (this.router) {
|
|
325
|
+
// Single resolve: check exclusion override + shouldEmit together
|
|
326
|
+
const resolved = this.router.resolve(ev.path);
|
|
327
|
+
const isConfigured = resolved.path !== '';
|
|
328
|
+
// If in excludePrefixes but not explicitly configured, skip
|
|
329
|
+
if (!isConfigured && this.options.excludePrefixes.some(p => ev.path.startsWith(p))) return;
|
|
330
|
+
const mode = resolved.mode;
|
|
331
|
+
if (mode !== 'primary' && mode !== 'sync' && mode !== 'writeonly') return;
|
|
332
|
+
} else {
|
|
333
|
+
if (this.isExcluded(ev.path)) return;
|
|
334
|
+
}
|
|
162
335
|
|
|
163
336
|
// If buffering (transaction in progress), collect events
|
|
164
337
|
if (this._pendingReplEvents) {
|
|
@@ -176,9 +349,19 @@ export class ReplicationEngine {
|
|
|
176
349
|
const seq = this._seq++;
|
|
177
350
|
const idempotencyKey = `${replEvent.ts}:${seq}:${ev.path}`;
|
|
178
351
|
this.db.push('_repl', replEvent, { idempotencyKey });
|
|
352
|
+
|
|
179
353
|
} finally {
|
|
180
354
|
this._emitting = false;
|
|
181
355
|
}
|
|
356
|
+
|
|
357
|
+
// Auto-compact on write threshold (outside _emitting guard so notifications flow normally)
|
|
358
|
+
this._emitCount++;
|
|
359
|
+
const threshold = this.options.autoCompactThreshold;
|
|
360
|
+
if (threshold > 0 && this._emitCount >= threshold) {
|
|
361
|
+
this._emitCount = 0;
|
|
362
|
+
const compact = this.options.compact ?? { maxCount: 500, keepKey: 'path' };
|
|
363
|
+
try { this.db.stream.compact('_repl', compact); } catch {}
|
|
364
|
+
}
|
|
182
365
|
}
|
|
183
366
|
|
|
184
367
|
/** Start buffering replication events (call before transaction) */
|
|
@@ -202,42 +385,75 @@ export class ReplicationEngine {
|
|
|
202
385
|
|
|
203
386
|
// --- Replica mode ---
|
|
204
387
|
|
|
205
|
-
|
|
206
|
-
|
|
388
|
+
/** Start replica for specific path prefixes (router-based) */
|
|
389
|
+
private async startReplicaForPaths(pathPrefixes: string[]): Promise<void> {
|
|
390
|
+
if (!this.options.primaryUrl) throw new Error('primaryUrl required for per-path replication');
|
|
207
391
|
|
|
208
392
|
this.client = new BodClient({
|
|
209
393
|
url: this.options.primaryUrl,
|
|
210
394
|
auth: this.options.primaryAuth,
|
|
211
395
|
});
|
|
212
|
-
|
|
213
396
|
await this.client.connect();
|
|
214
|
-
|
|
397
|
+
this.log.info(`Connected to primary ${this.options.primaryUrl} (paths: ${pathPrefixes.join(', ')})`);
|
|
215
398
|
|
|
216
|
-
//
|
|
217
|
-
|
|
218
|
-
|
|
399
|
+
// Bootstrap only replica/readonly paths (sync paths get ongoing events only — avoids overwriting local state)
|
|
400
|
+
const bootstrapPaths = this.router!.getBootstrapPaths();
|
|
401
|
+
if (this.options.fullBootstrap && bootstrapPaths.length) {
|
|
402
|
+
await this.bootstrapFullState(bootstrapPaths);
|
|
219
403
|
}
|
|
220
404
|
|
|
221
|
-
// Stream bootstrap
|
|
222
|
-
const
|
|
223
|
-
|
|
224
|
-
|
|
405
|
+
// Stream bootstrap filtered (cursor-based to avoid huge single response)
|
|
406
|
+
const applied = await this.bootstrapFromStream(this.client!, { filter: ev => this.matchesPathPrefixes(ev.path, pathPrefixes) });
|
|
407
|
+
this.log.info(`Stream bootstrap (paths): ${applied} events applied`);
|
|
408
|
+
|
|
409
|
+
// Subscribe to ongoing events, filter by paths
|
|
410
|
+
const groupId = this.options.replicaId!;
|
|
411
|
+
this.unsubStream = this.client.stream('_repl', groupId).on((events) => {
|
|
225
412
|
this.db.setReplaying(true);
|
|
226
413
|
try {
|
|
227
|
-
for (const
|
|
228
|
-
const ev =
|
|
229
|
-
this.
|
|
414
|
+
for (const e of events) {
|
|
415
|
+
const ev = e.val() as ReplEvent;
|
|
416
|
+
if (this.matchesPathPrefixes(ev.path, pathPrefixes)) {
|
|
417
|
+
this.applyEvent(ev);
|
|
418
|
+
}
|
|
419
|
+
this.client!.stream('_repl', groupId).ack(e.key).catch(() => {});
|
|
230
420
|
}
|
|
231
421
|
} finally {
|
|
232
422
|
this.db.setReplaying(false);
|
|
233
423
|
}
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** Check if path matches any of the given prefixes */
|
|
428
|
+
private matchesPathPrefixes(path: string, prefixes: string[]): boolean {
|
|
429
|
+
return prefixes.some(p => path === p || path.startsWith(p + '/'));
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
private async startReplica(): Promise<void> {
|
|
433
|
+
if (!this.options.primaryUrl) throw new Error('primaryUrl required for replica mode');
|
|
434
|
+
|
|
435
|
+
this.client = new BodClient({
|
|
436
|
+
url: this.options.primaryUrl,
|
|
437
|
+
auth: this.options.primaryAuth,
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
await this.client.connect();
|
|
441
|
+
this.log.info(`Connected to primary ${this.options.primaryUrl}`);
|
|
442
|
+
|
|
443
|
+
// Full state bootstrap: fetch all data from primary (catches pre-replication data)
|
|
444
|
+
if (this.options.fullBootstrap) {
|
|
445
|
+
await this.bootstrapFullState();
|
|
234
446
|
}
|
|
235
447
|
|
|
448
|
+
// Stream bootstrap: cursor-based to avoid huge single response
|
|
449
|
+
const applied = await this.bootstrapFromStream(this.client!);
|
|
450
|
+
this.log.info(`Stream bootstrap: ${applied} events applied`);
|
|
451
|
+
|
|
236
452
|
// Subscribe to ongoing events
|
|
237
453
|
const groupId = this.options.replicaId!;
|
|
238
|
-
|
|
454
|
+
this.log.info(`Subscribing to stream as '${groupId}'`);
|
|
239
455
|
this.unsubStream = this.client.stream('_repl', groupId).on((events) => {
|
|
240
|
-
|
|
456
|
+
this.log.debug(`Received ${events.length} events`);
|
|
241
457
|
this.db.setReplaying(true);
|
|
242
458
|
try {
|
|
243
459
|
for (const e of events) {
|
|
@@ -252,13 +468,16 @@ export class ReplicationEngine {
|
|
|
252
468
|
});
|
|
253
469
|
}
|
|
254
470
|
|
|
255
|
-
/** Fetch full DB state from primary and apply locally */
|
|
256
|
-
private async bootstrapFullState(): Promise<void> {
|
|
471
|
+
/** Fetch full DB state from primary and apply locally. Optional pathPrefixes filters which top-level keys to pull. */
|
|
472
|
+
private async bootstrapFullState(pathPrefixes?: string[]): Promise<void> {
|
|
257
473
|
const topLevel = await this.client!.getShallow();
|
|
258
|
-
|
|
474
|
+
let keys = topLevel
|
|
259
475
|
.map(e => e.key)
|
|
260
476
|
.filter(k => !this.isExcluded(k));
|
|
261
|
-
|
|
477
|
+
if (pathPrefixes) {
|
|
478
|
+
keys = keys.filter(k => this.matchesPathPrefixes(k, pathPrefixes));
|
|
479
|
+
}
|
|
480
|
+
this.log.info(`Full bootstrap: ${keys.length} top-level keys [${keys.join(', ')}]`);
|
|
262
481
|
if (keys.length === 0) return;
|
|
263
482
|
|
|
264
483
|
this.db.setReplaying(true);
|
|
@@ -283,7 +502,7 @@ export class ReplicationEngine {
|
|
|
283
502
|
for (let i = 0; i < results.length; i++) {
|
|
284
503
|
if (results[i].status === 'rejected') {
|
|
285
504
|
const src = this.options.sources![i];
|
|
286
|
-
|
|
505
|
+
this.log.error(`source ${src.url} paths=[${src.paths}] failed:`, (results[i] as PromiseRejectedResult).reason);
|
|
287
506
|
}
|
|
288
507
|
}
|
|
289
508
|
}
|
|
@@ -292,21 +511,11 @@ export class ReplicationEngine {
|
|
|
292
511
|
const client = new BodClient({ url: source.url, auth: source.auth });
|
|
293
512
|
await client.connect();
|
|
294
513
|
|
|
295
|
-
// Bootstrap: materialize _repl, filter by source paths
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
for (const [, event] of Object.entries(snapshot)) {
|
|
301
|
-
const ev = event as ReplEvent;
|
|
302
|
-
if (this.matchesSourcePaths(ev.path, source)) {
|
|
303
|
-
this.applyEvent(ev, source);
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
} finally {
|
|
307
|
-
this.db.setReplaying(false);
|
|
308
|
-
}
|
|
309
|
-
}
|
|
514
|
+
// Bootstrap: cursor-based materialize _repl, filter by source paths
|
|
515
|
+
await this.bootstrapFromStream(client, {
|
|
516
|
+
filter: ev => this.matchesSourcePaths(ev.path, source),
|
|
517
|
+
source,
|
|
518
|
+
});
|
|
310
519
|
|
|
311
520
|
// Subscribe to ongoing events
|
|
312
521
|
const groupId = source.id || `source_${source.url}_${source.paths.sort().join('+')}`;
|
|
@@ -343,8 +552,37 @@ export class ReplicationEngine {
|
|
|
343
552
|
return source.localPrefix ? `${source.localPrefix}/${path}` : path;
|
|
344
553
|
}
|
|
345
554
|
|
|
555
|
+
/** Cursor-based stream bootstrap: pages through _repl materialize to avoid huge single responses */
|
|
556
|
+
private async bootstrapFromStream(client: BodClient, opts?: { filter?: (ev: ReplEvent) => boolean; source?: ReplicationSource }): Promise<number> {
|
|
557
|
+
let cursor: string | undefined;
|
|
558
|
+
let applied = 0;
|
|
559
|
+
const filter = opts?.filter;
|
|
560
|
+
const source = opts?.source;
|
|
561
|
+
this.db.setReplaying(true);
|
|
562
|
+
try {
|
|
563
|
+
do {
|
|
564
|
+
const page = await client.streamMaterialize('_repl', { keepKey: 'path', batchSize: BOOTSTRAP_BATCH_SIZE, cursor });
|
|
565
|
+
if (page.data) {
|
|
566
|
+
for (const [, event] of Object.entries(page.data)) {
|
|
567
|
+
const ev = event as ReplEvent;
|
|
568
|
+
if (!filter || filter(ev)) {
|
|
569
|
+
this.applyEvent(ev, source);
|
|
570
|
+
applied++;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
cursor = page.nextCursor;
|
|
575
|
+
} while (cursor);
|
|
576
|
+
} finally {
|
|
577
|
+
this.db.setReplaying(false);
|
|
578
|
+
}
|
|
579
|
+
return applied;
|
|
580
|
+
}
|
|
581
|
+
|
|
346
582
|
private applyEvent(ev: ReplEvent, source?: ReplicationSource): void {
|
|
347
583
|
const path = source ? this.remapPath(ev.path, source) : ev.path;
|
|
584
|
+
// Defense-in-depth: skip events for paths we shouldn't apply (primary/writeonly)
|
|
585
|
+
if (!source && this.router && !this.router.shouldApply(path)) return;
|
|
348
586
|
switch (ev.op) {
|
|
349
587
|
case 'set':
|
|
350
588
|
this.db.set(path, ev.value, ev.ttl ? { ttl: ev.ttl } : undefined);
|
package/src/server/Transport.ts
CHANGED
|
@@ -159,7 +159,14 @@ export class Transport {
|
|
|
159
159
|
if (this.options.keyAuth && path.startsWith(AUTH_PREFIX)) {
|
|
160
160
|
return Response.json({ ok: false, error: `Write to ${AUTH_PREFIX} is reserved`, code: Errors.PERMISSION_DENIED }, { status: 403 });
|
|
161
161
|
}
|
|
162
|
-
|
|
162
|
+
const auth = await this.extractAuth(req);
|
|
163
|
+
if (this.rules && !this.rules.check('write', path, auth)) {
|
|
164
|
+
return Response.json({ ok: false, error: 'Permission denied', code: Errors.PERMISSION_DENIED }, { status: 403 });
|
|
165
|
+
}
|
|
166
|
+
if (this.db.replication?.shouldRejectPath(path)) {
|
|
167
|
+
return Response.json({ ok: false, error: 'Path is readonly', code: Errors.PERMISSION_DENIED }, { status: 403 });
|
|
168
|
+
}
|
|
169
|
+
if (this.db.replication?.shouldProxyPath(path)) {
|
|
163
170
|
try {
|
|
164
171
|
const body = await req.json();
|
|
165
172
|
await this.db.replication.proxyWrite({ op: 'set', path, value: body });
|
|
@@ -168,10 +175,6 @@ export class Transport {
|
|
|
168
175
|
return Response.json({ ok: false, error: e.message, code: Errors.INTERNAL }, { status: 500 });
|
|
169
176
|
}
|
|
170
177
|
}
|
|
171
|
-
const auth = await this.extractAuth(req);
|
|
172
|
-
if (this.rules && !this.rules.check('write', path, auth)) {
|
|
173
|
-
return Response.json({ ok: false, error: 'Permission denied', code: Errors.PERMISSION_DENIED }, { status: 403 });
|
|
174
|
-
}
|
|
175
178
|
const body = await req.json();
|
|
176
179
|
this.db.set(path, body);
|
|
177
180
|
return Response.json({ ok: true });
|
|
@@ -185,7 +188,14 @@ export class Transport {
|
|
|
185
188
|
if (this.options.keyAuth && path.startsWith(AUTH_PREFIX)) {
|
|
186
189
|
return Response.json({ ok: false, error: `Write to ${AUTH_PREFIX} is reserved`, code: Errors.PERMISSION_DENIED }, { status: 403 });
|
|
187
190
|
}
|
|
188
|
-
|
|
191
|
+
const auth = await this.extractAuth(req);
|
|
192
|
+
if (this.rules && !this.rules.check('write', path, auth)) {
|
|
193
|
+
return Response.json({ ok: false, error: 'Permission denied', code: Errors.PERMISSION_DENIED }, { status: 403 });
|
|
194
|
+
}
|
|
195
|
+
if (this.db.replication?.shouldRejectPath(path)) {
|
|
196
|
+
return Response.json({ ok: false, error: 'Path is readonly', code: Errors.PERMISSION_DENIED }, { status: 403 });
|
|
197
|
+
}
|
|
198
|
+
if (this.db.replication?.shouldProxyPath(path)) {
|
|
189
199
|
try {
|
|
190
200
|
await this.db.replication.proxyWrite({ op: 'delete', path });
|
|
191
201
|
return Response.json({ ok: true });
|
|
@@ -193,10 +203,6 @@ export class Transport {
|
|
|
193
203
|
return Response.json({ ok: false, error: e.message, code: Errors.INTERNAL }, { status: 500 });
|
|
194
204
|
}
|
|
195
205
|
}
|
|
196
|
-
const auth = await this.extractAuth(req);
|
|
197
|
-
if (this.rules && !this.rules.check('write', path, auth)) {
|
|
198
|
-
return Response.json({ ok: false, error: 'Permission denied', code: Errors.PERMISSION_DENIED }, { status: 403 });
|
|
199
|
-
}
|
|
200
206
|
this.db.delete(path);
|
|
201
207
|
return Response.json({ ok: true });
|
|
202
208
|
})();
|
|
@@ -418,13 +424,14 @@ export class Transport {
|
|
|
418
424
|
|
|
419
425
|
case 'set': {
|
|
420
426
|
if (guardAuthPrefix(msg.path)) return;
|
|
421
|
-
if (self.db.replication?.isReplica) {
|
|
422
|
-
try { return reply(await self.db.replication.proxyWrite(msg)); }
|
|
423
|
-
catch (e: any) { return error(e.message, Errors.INTERNAL); }
|
|
424
|
-
}
|
|
425
427
|
if (self.rules && !self.rules.check('write', msg.path, ws.data.auth, self.db.get(msg.path), msg.value)) {
|
|
426
428
|
return error('Permission denied', Errors.PERMISSION_DENIED);
|
|
427
429
|
}
|
|
430
|
+
if (self.db.replication?.shouldRejectPath(msg.path)) return error('Path is readonly', Errors.PERMISSION_DENIED);
|
|
431
|
+
if (self.db.replication?.shouldProxyPath(msg.path)) {
|
|
432
|
+
try { return reply(await self.db.replication.proxyWrite(msg)); }
|
|
433
|
+
catch (e: any) { return error(e.message, Errors.INTERNAL); }
|
|
434
|
+
}
|
|
428
435
|
self.db.set(msg.path, msg.value, msg.ttl ? { ttl: msg.ttl } : undefined);
|
|
429
436
|
return reply(null);
|
|
430
437
|
}
|
|
@@ -433,28 +440,34 @@ export class Transport {
|
|
|
433
440
|
for (const path of Object.keys(msg.updates)) {
|
|
434
441
|
if (guardAuthPrefix(path)) return;
|
|
435
442
|
}
|
|
436
|
-
if (self.db.replication?.isReplica) {
|
|
437
|
-
try { return reply(await self.db.replication.proxyWrite(msg)); }
|
|
438
|
-
catch (e: any) { return error(e.message, Errors.INTERNAL); }
|
|
439
|
-
}
|
|
440
443
|
for (const path of Object.keys(msg.updates)) {
|
|
441
444
|
if (self.rules && !self.rules.check('write', path, ws.data.auth)) {
|
|
442
445
|
return error(`Permission denied for ${path}`, Errors.PERMISSION_DENIED);
|
|
443
446
|
}
|
|
444
447
|
}
|
|
448
|
+
// Per-path: if ANY path needs reject, reject; if ANY needs proxy, proxy all (known limitation: greedy)
|
|
449
|
+
if (self.db.replication) {
|
|
450
|
+
const paths = Object.keys(msg.updates);
|
|
451
|
+
if (paths.some(p => self.db.replication!.shouldRejectPath(p))) return error('Path is readonly', Errors.PERMISSION_DENIED);
|
|
452
|
+
if (paths.some(p => self.db.replication!.shouldProxyPath(p))) {
|
|
453
|
+
try { return reply(await self.db.replication.proxyWrite(msg)); }
|
|
454
|
+
catch (e: any) { return error(e.message, Errors.INTERNAL); }
|
|
455
|
+
}
|
|
456
|
+
}
|
|
445
457
|
self.db.update(msg.updates);
|
|
446
458
|
return reply(null);
|
|
447
459
|
}
|
|
448
460
|
|
|
449
461
|
case 'delete': {
|
|
450
462
|
if (guardAuthPrefix(msg.path)) return;
|
|
451
|
-
if (self.db.replication?.isReplica) {
|
|
452
|
-
try { return reply(await self.db.replication.proxyWrite(msg)); }
|
|
453
|
-
catch (e: any) { return error(e.message, Errors.INTERNAL); }
|
|
454
|
-
}
|
|
455
463
|
if (self.rules && !self.rules.check('write', msg.path, ws.data.auth)) {
|
|
456
464
|
return error('Permission denied', Errors.PERMISSION_DENIED);
|
|
457
465
|
}
|
|
466
|
+
if (self.db.replication?.shouldRejectPath(msg.path)) return error('Path is readonly', Errors.PERMISSION_DENIED);
|
|
467
|
+
if (self.db.replication?.shouldProxyPath(msg.path)) {
|
|
468
|
+
try { return reply(await self.db.replication.proxyWrite(msg)); }
|
|
469
|
+
catch (e: any) { return error(e.message, Errors.INTERNAL); }
|
|
470
|
+
}
|
|
458
471
|
self.db.delete(msg.path);
|
|
459
472
|
return reply(null);
|
|
460
473
|
}
|
|
@@ -502,9 +515,23 @@ export class Transport {
|
|
|
502
515
|
}
|
|
503
516
|
|
|
504
517
|
case 'batch': {
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
518
|
+
// Upfront rules check before proxy (defense-in-depth)
|
|
519
|
+
for (const batchOp of msg.operations) {
|
|
520
|
+
const opPaths = batchOp.op === 'update' ? Object.keys(batchOp.updates) : [batchOp.path];
|
|
521
|
+
for (const p of opPaths) {
|
|
522
|
+
if (self.rules && !self.rules.check('write', p, ws.data.auth)) {
|
|
523
|
+
return error(`Permission denied for ${p}`, Errors.PERMISSION_DENIED);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
// Per-path topology: if ANY path needs reject, reject; if ANY needs proxy, proxy all (known limitation: greedy)
|
|
528
|
+
if (self.db.replication) {
|
|
529
|
+
const batchPaths = msg.operations.map((o: any) => o.path != null ? [o.path] : (o.updates ? Object.keys(o.updates) : [])).flat();
|
|
530
|
+
if (batchPaths.some((p: string) => self.db.replication!.shouldRejectPath(p))) return error('Path is readonly', Errors.PERMISSION_DENIED);
|
|
531
|
+
if (batchPaths.some((p: string) => self.db.replication!.shouldProxyPath(p))) {
|
|
532
|
+
try { return reply(await self.db.replication.proxyWrite(msg)); }
|
|
533
|
+
catch (e: any) { return error(e.message, Errors.INTERNAL); }
|
|
534
|
+
}
|
|
508
535
|
}
|
|
509
536
|
const results: unknown[] = [];
|
|
510
537
|
self.db.transaction((tx) => {
|
|
@@ -546,13 +573,14 @@ export class Transport {
|
|
|
546
573
|
|
|
547
574
|
case 'push': {
|
|
548
575
|
if (guardAuthPrefix(msg.path)) return;
|
|
549
|
-
if (self.db.replication?.isReplica) {
|
|
550
|
-
try { return reply(await self.db.replication.proxyWrite(msg)); }
|
|
551
|
-
catch (e: any) { return error(e.message, Errors.INTERNAL); }
|
|
552
|
-
}
|
|
553
576
|
if (self.rules && !self.rules.check('write', msg.path, ws.data.auth)) {
|
|
554
577
|
return error('Permission denied', Errors.PERMISSION_DENIED);
|
|
555
578
|
}
|
|
579
|
+
if (self.db.replication?.shouldRejectPath(msg.path)) return error('Path is readonly', Errors.PERMISSION_DENIED);
|
|
580
|
+
if (self.db.replication?.shouldProxyPath(msg.path)) {
|
|
581
|
+
try { return reply(await self.db.replication.proxyWrite(msg)); }
|
|
582
|
+
catch (e: any) { return error(e.message, Errors.INTERNAL); }
|
|
583
|
+
}
|
|
556
584
|
const key = self.db.push(msg.path, msg.value, msg.idempotencyKey ? { idempotencyKey: msg.idempotencyKey } : undefined);
|
|
557
585
|
return reply(key);
|
|
558
586
|
}
|
|
@@ -678,7 +706,11 @@ export class Transport {
|
|
|
678
706
|
if (self.rules && !self.rules.check('read', msg.path, ws.data.auth)) {
|
|
679
707
|
return error('Permission denied', Errors.PERMISSION_DENIED);
|
|
680
708
|
}
|
|
681
|
-
|
|
709
|
+
const matOpts: { keepKey?: string; batchSize?: number; cursor?: string } = {};
|
|
710
|
+
if (msg.keepKey) matOpts.keepKey = msg.keepKey;
|
|
711
|
+
if (msg.batchSize) matOpts.batchSize = msg.batchSize;
|
|
712
|
+
if (msg.cursor) matOpts.cursor = msg.cursor;
|
|
713
|
+
return reply(self.db.stream.materialize(msg.path, Object.keys(matOpts).length ? matOpts : undefined));
|
|
682
714
|
}
|
|
683
715
|
case 'stream-compact': {
|
|
684
716
|
if (self.rules && !self.rules.check('write', msg.path, ws.data.auth)) {
|