@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.
@@ -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
- get isReplica(): boolean { return this.options.role === 'replica'; }
58
- get isPrimary(): boolean { return this.options.role === 'primary'; }
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
- this.options.replicaId = `replica_${Math.random().toString(36).slice(2, 10)}`;
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
- if (this.isPrimary) {
235
+ // Always hook writes for emit (router or primary will filter)
236
+ if (this.router || this.isPrimary) {
98
237
  this.startPrimary();
99
- } else {
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
- return this.options.excludePrefixes.some(p => path.startsWith(p));
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.isExcluded(ev.path)) return;
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
- private async startReplica(): Promise<void> {
206
- if (!this.options.primaryUrl) throw new Error('primaryUrl required for replica mode');
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
- console.log(` [REPL] Connected to primary ${this.options.primaryUrl}`);
397
+ this.log.info(`Connected to primary ${this.options.primaryUrl} (paths: ${pathPrefixes.join(', ')})`);
215
398
 
216
- // Full state bootstrap: fetch all data from primary (catches pre-replication data)
217
- if (this.options.fullBootstrap) {
218
- await this.bootstrapFullState();
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: apply _repl events on top (catches recent writes, deduped by idempotent set)
222
- const snapshot = await this.client!.streamMaterialize('_repl', { keepKey: 'path' });
223
- console.log(` [REPL] Stream bootstrap: ${snapshot ? Object.keys(snapshot).length + ' events' : 'no events'}`);
224
- if (snapshot) {
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 [, event] of Object.entries(snapshot)) {
228
- const ev = event as ReplEvent;
229
- this.applyEvent(ev);
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
- console.log(` [REPL] Subscribing to stream as '${groupId}'`);
454
+ this.log.info(`Subscribing to stream as '${groupId}'`);
239
455
  this.unsubStream = this.client.stream('_repl', groupId).on((events) => {
240
- console.log(` [REPL] Received ${events.length} events`);
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
- const keys = topLevel
474
+ let keys = topLevel
259
475
  .map(e => e.key)
260
476
  .filter(k => !this.isExcluded(k));
261
- console.log(` [REPL] Full bootstrap: ${keys.length} top-level keys [${keys.join(', ')}]`);
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
- console.error(`[REPL] source ${src.url} paths=[${src.paths}] failed:`, (results[i] as PromiseRejectedResult).reason);
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
- const snapshot = await client.streamMaterialize('_repl', { keepKey: 'path' });
297
- if (snapshot) {
298
- this.db.setReplaying(true);
299
- try {
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);
@@ -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
- if (this.db.replication?.isReplica) {
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
- if (this.db.replication?.isReplica) {
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
- if (self.db.replication?.isReplica) {
506
- try { return reply(await self.db.replication.proxyWrite(msg)); }
507
- catch (e: any) { return error(e.message, Errors.INTERNAL); }
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
- return reply(self.db.stream.materialize(msg.path, msg.keepKey ? { keepKey: msg.keepKey } : undefined));
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)) {