@bod.ee/db 0.12.2 → 0.12.6

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.
@@ -86,7 +86,7 @@ Push paths are append-only logs. `StreamEngine` adds consumer group offsets (`_s
86
86
  `MQEngine` owns all MQ SQL via `storage.db.prepare()` — same pattern as StreamEngine. Columns: `mq_status` (pending/inflight), `mq_inflight_until` (Unix ms), `mq_delivery_count`. `fetch()` uses SQLite transaction with TOCTOU guard (`changes > 0`). Ack = DELETE. Sweep reclaims expired inflight; exhausted messages move to DLQ at `<queue>/_dlq/<key>`. Per-queue options via longest prefix match on `queues` config.
87
87
 
88
88
  ### Replication
89
- `ReplicationEngine` — primary/replica + multi-source feed subscriptions via `_repl` stream. Primary: `onWrite` hooks emit events to `_repl` stream (updates flattened to per-path sets). Replica: bootstraps via `streamMaterialize`, subscribes for ongoing events, proxies writes to primary. Guards: `_replaying` prevents re-emission, `_emitting` prevents recursion from `db.push('_repl')`. Sweep deletes are replicated. Transport checks `isReplica` and forwards write ops.
89
+ `ReplicationEngine` — primary/replica + multi-source feed subscriptions via `_repl` stream. Primary: `onWrite` hooks emit events to `_repl` stream (updates flattened to per-path sets). Auto-compact on write threshold (`autoCompactThreshold`, default 500) + on startup keeps `_repl` bounded. Replica: bootstraps via cursor-based `streamMaterialize` pagination (`batchSize: 200`), subscribes for ongoing events, proxies writes to primary. `bootstrapFromStream()` helper handles all 3 bootstrap sites (replica, router-based, sources). Guards: `_replaying` prevents re-emission, `_emitting` prevents recursion from `db.push('_repl')`. Sweep deletes are replicated. Transport checks `isReplica` and forwards write ops.
90
90
 
91
91
  **Sources** (`ReplicationSource[]`): independent of role. Each source creates a `BodClient`, bootstraps filtered `_repl` snapshot, subscribes for ongoing events. `matchesSourcePaths()` filters by path prefix. `remapPath()` prepends `localPrefix`. Events applied with `_replaying=true`. Sources connect via `Promise.allSettled` — individual failures logged, others continue. Deterministic `groupId` default: `source_${url}_${paths.join('+')}`.
92
92
 
@@ -27,6 +27,12 @@ import { BodDB, increment, serverTimestamp, arrayUnion, arrayRemove, ref } from
27
27
  const db = new BodDB({
28
28
  path: './data.db', // SQLite file (default: ':memory:')
29
29
  port: 4400, // optional — only needed if calling db.serve()
30
+ log: { // optional structured logging
31
+ enabled: true,
32
+ level: 'info', // 'debug' | 'info' | 'warn' | 'error'
33
+ components: '*', // '*' or ['storage', 'transport', 'subs', 'replication', 'keyauth']
34
+ logDir: './logs', // writes bod-YYYYMMDD-HHmmss.log per run; omit for console-only
35
+ },
30
36
  rules: { // inline rules, or path to .json/.ts file
31
37
  'users/$uid': {
32
38
  read: true,
@@ -301,6 +307,11 @@ ws.send(JSON.stringify({ id: '20', op: 'batch-sub', subscriptions: [
301
307
  // Stream extended ops
302
308
  ws.send(JSON.stringify({ id: '21', op: 'stream-snapshot', path: 'events/orders' }));
303
309
  ws.send(JSON.stringify({ id: '21', op: 'stream-materialize', path: 'events/orders', keepKey: 'orderId' }));
310
+ // Cursor-based pagination (for large streams):
311
+ ws.send(JSON.stringify({ id: '21b', op: 'stream-materialize', path: 'events/orders', keepKey: 'orderId', batchSize: 200 }));
312
+ // → { id: '21b', ok: true, data: { data: {...}, nextCursor: 'abc123' } }
313
+ // Follow-up page:
314
+ ws.send(JSON.stringify({ id: '21c', op: 'stream-materialize', path: 'events/orders', keepKey: 'orderId', batchSize: 200, cursor: 'abc123' }));
304
315
  ws.send(JSON.stringify({ id: '22', op: 'stream-compact', path: 'events/orders', maxAge: 86400 }));
305
316
  ws.send(JSON.stringify({ id: '23', op: 'stream-reset', path: 'events/orders' }));
306
317
  ```
@@ -450,6 +461,13 @@ const similar = await client.vectorSearch({ query: [0.1, 0.2, 0.3], path: 'docs'
450
461
  // Stream snapshot, materialize, compact, reset
451
462
  const snap = await client.streamSnapshot('events/orders');
452
463
  const view = await client.streamMaterialize('events/orders', { keepKey: 'orderId' });
464
+ // Cursor-based materialize for large streams (avoids huge single response):
465
+ let cursor: string | undefined;
466
+ do {
467
+ const page = await client.streamMaterialize('events/orders', { keepKey: 'orderId', batchSize: 200, cursor });
468
+ // page.data contains this batch, page.nextCursor is undefined when done
469
+ cursor = page.nextCursor;
470
+ } while (cursor);
453
471
  await client.streamCompact('events/orders', { maxAge: 86400 });
454
472
  await client.streamReset('events/orders');
455
473
  ```
@@ -485,7 +503,7 @@ await replica.replication!.start();
485
503
  - Reads served locally on replica, writes forwarded to primary
486
504
  - Replicas bootstrap full state on first connect, then consume live events
487
505
  - Push keys preserved across replicas (deterministic)
488
- - Auto-compaction on `_repl` stream (keepKey: 'path', maxCount: 10000)
506
+ - Auto-compaction on `_repl` stream (keepKey: 'path', maxCount: 500, auto-compact every 500 writes)
489
507
  - Excluded prefixes: `_repl`, `_streams`, `_mq`, `_auth` (internal data not replicated)
490
508
 
491
509
  ### Per-Path Topology
package/CLAUDE.md CHANGED
@@ -71,7 +71,7 @@ config.ts — demo instance config (open rules, indexes, fts, v
71
71
  - **BodClientCached**: two-tier cache wrapper around BodClient. Memory (Map, LRU eviction) + IndexedDB persistence. Stale-while-revalidate: subscribed paths always fresh, unsubscribed return stale + background refetch. Writes (`set/update/delete`) invalidate path + ancestors. `init()` opens IDB + sweeps expired. `warmup(paths[])` bulk-loads from IDB. Passthrough for `push/batch/query/search/mq/stream/vfs` via `cachedClient.client`.
72
72
  - **MCP**: `MCPAdapter` wraps a `BodClient` as a JSON-RPC MCP server (stdio + HTTP). Connects to a running BodDB instance over WebSocket — no embedded DB. Entry point: `mcp.ts`. Tools: CRUD (6), FTS (2), vectors (2), streams (4), MQ (7) = 21 tools. Use `--stdio` for Claude Code/Desktop, `--http` for remote agents.
73
73
  - **VFS (Virtual File System)**: `VFSEngine` — files stored outside SQLite via pluggable `VFSBackend` interface. `LocalBackend` stores at `<storageRoot>/<fileId>` using `Bun.file`/`Bun.write`. Metadata at `_vfs/<virtualPath>/` (size, mime, mtime, fileId, isDir) — gets subs/rules/replication for free. `fileId = pushId` so move/rename is metadata-only. REST: `POST/GET/DELETE /files/<path>`, `?stat=1`, `?list=1`, `?mkdir=1`, `PUT ?move=<dst>`. WS chunked fallback: base64-encoded `vfs-upload-init/chunk/done`, `vfs-download-init` → `vfs-download-chunk` push messages. Client: `VFSClient` via `client.vfs()` — `upload/download` (REST) + `uploadWS/downloadWS` (WS) + `stat/list/mkdir/delete/move`.
74
- - **Replication**: `ReplicationEngine` — single primary + N read replicas + multi-source feed subscriptions. Star topology. Primary emits write events to `_repl` stream via `onWrite` hooks. Replicas bootstrap via `streamMaterialize('_repl', { keepKey: 'path' })`, then subscribe for ongoing events. Write proxy: replica forwards writes to primary via BodClient, primary applies + emits, replica consumes. `_replaying` flag prevents re-emission loops. `_emitting` guard prevents recursion from `db.push('_repl')`. Updates flattened to per-path set events for correct compaction keying. Sweep delete events replicated. Excluded prefixes: `_repl`, `_streams`, `_mq`, `_auth`. **Sources**: `ReplicationSource[]` — subscribe to specific paths from multiple remote DBs. Each source is an independent BodClient that filters `_repl` events by path prefix, with optional `localPrefix` remapping (e.g. remote `users/u1` → local `db-a/users/u1`). Sources connect in parallel; individual failures don't block others. Sources are independent of role — a DB can be primary AND consume sources. **Per-path topology**: `PathTopologyRouter` — when `paths` config is set, each path prefix gets an independent mode: `primary` (local authoritative, emits), `replica` (remote authoritative, proxies writes), `sync` (bidirectional, both emit+apply), `readonly` (pull-only, rejects writes), `writeonly` (push-only, ignores remote). Longest-prefix match resolves mode. `writeProxy: 'proxy'|'reject'` overrides replica write behavior. Bootstrap skips sync paths (ongoing stream only). Auth/rules checked before proxy in all handlers. `shouldProxyPath(path)`/`shouldRejectPath(path)` replace `isReplica` checks. `emitsToRepl`/`pullsFromPrimary` getters for compact/bootstrap decisions. Stable `replicaId` from config hash. Falls back to `role` when `paths` absent (backward compat).
74
+ - **Replication**: `ReplicationEngine` — single primary + N read replicas + multi-source feed subscriptions. Star topology. Primary emits write events to `_repl` stream via `onWrite` hooks. Replicas bootstrap via cursor-based `streamMaterialize('_repl', { keepKey: 'path', batchSize: 200 })` pagination (avoids huge single WS frame), then subscribe for ongoing events. Auto-compact on write threshold (`autoCompactThreshold`, default 500) + on startup keeps `_repl` bounded. Write proxy: replica forwards writes to primary via BodClient, primary applies + emits, replica consumes. `_replaying` flag prevents re-emission loops. `_emitting` guard prevents recursion from `db.push('_repl')`. Updates flattened to per-path set events for correct compaction keying. Sweep delete events replicated. Excluded prefixes: `_repl`, `_streams`, `_mq`, `_admin`, `_auth`. **Sources**: `ReplicationSource[]` — subscribe to specific paths from multiple remote DBs. Each source is an independent BodClient that filters `_repl` events by path prefix, with optional `localPrefix` remapping (e.g. remote `users/u1` → local `db-a/users/u1`). Sources connect in parallel; individual failures don't block others. Sources are independent of role — a DB can be primary AND consume sources. **Per-path topology**: `PathTopologyRouter` — when `paths` config is set, each path prefix gets an independent mode: `primary` (local authoritative, emits), `replica` (remote authoritative, proxies writes), `sync` (bidirectional, both emit+apply), `readonly` (pull-only, rejects writes), `writeonly` (push-only, ignores remote). Longest-prefix match resolves mode. `writeProxy: 'proxy'|'reject'` overrides replica write behavior. Bootstrap skips sync paths (ongoing stream only). Auth/rules checked before proxy in all handlers. `shouldProxyPath(path)`/`shouldRejectPath(path)` replace `isReplica` checks. `emitsToRepl`/`pullsFromPrimary` getters for compact/bootstrap decisions. Stable `replicaId` from config hash. Falls back to `role` when `paths` absent (backward compat).
75
75
  - **KeyAuth integration guide**: `docs/keyauth-integration.md` — flows for signup, signin, new device, autoAuth, IAM roles, common mistakes.
76
76
  - **Para-chat integration guide**: `docs/para-chat-integration.md` — how para-chat uses BodDB: per-path topology, VFS, KeyAuth, caching, file sync.
77
77
  - **KeyAuth**: `KeyAuthEngine` — portable Ed25519 identity & IAM. Identity hierarchy: Root (server-level, key on filesystem), Account (portable, password-encrypted private key in DB or device-generated), Device (delegate, linked via password unlock). Challenge-response auth: server sends nonce → client signs with Ed25519 → server verifies + creates session. Self-signed tokens (no JWT lib): `base64url(payload).base64url(Ed25519_sign)`. Data model at `_auth/` prefix (protected from external writes). Device reverse-index at `_auth/deviceIndex/{dfp}` for O(1) lookup. Password change is atomic (single `db.update()`). IAM: roles with path-based permissions, account role assignment. `_auth/` excluded from replication. Transport guards: `auth-link-device` and `auth-change-password` require authenticated session; non-root users can only change own password. **Device registration**: `registerDevice(publicKey)` — client-generated keypair, no password, idempotent; `allowOpenRegistration: false` requires authenticated session. **Browser crypto**: `keyAuth.browser.ts` uses `@noble/ed25519` with DER↔raw key bridge for server compatibility. **BodClient autoAuth**: `autoAuth: true` auto-generates keypair (localStorage), registers, authenticates — zero-config device identity. `client.auth.*` convenience methods for all auth ops. **IAM transport ops**: `auth-create-role`, `auth-delete-role`, `auth-update-roles` (root only), `auth-list-accounts`, `auth-list-roles`. Device accounts (no encrypted key) safely reject `linkDevice`/`changePassword`.
package/admin/ui.html CHANGED
@@ -8,7 +8,7 @@
8
8
  body { font-family: monospace; font-size: 13px; background: #0d0d0d; color: #d4d4d4; display: flex; flex-direction: column; height: 100vh; overflow: hidden; }
9
9
 
10
10
  /* Metrics bar */
11
- #metrics-bar { display: flex; background: #0a0a0a; border-bottom: 1px solid #2a2a2a; flex-shrink: 0; overflow-x: auto; align-items: stretch; }
11
+ #metrics-bar { display: flex; background: #0a0a0a; border-bottom: 1px solid #2a2a2a; flex-shrink: 0; align-items: stretch; width: 100%; }
12
12
  .metric-card { display: flex; flex-direction: column; padding: 5px 10px 4px; border-right: 1px solid #181818; min-width: 140px; flex-shrink: 0; gap: 1px; overflow: hidden; }
13
13
  .metric-card:last-child { border-right: none; width: auto; }
14
14
  .metric-right { margin-left: auto; }
@@ -127,15 +127,15 @@
127
127
  <div class="metric-top"><span class="metric-label">Ping</span><span class="metric-value" id="s-ping">—</span></div>
128
128
  <canvas class="metric-canvas" id="g-ping" width="100" height="28"></canvas>
129
129
  </div>
130
- <div class="metric-card metric-right" id="repl-card" style="border-left:1px solid #282828;display:none;width:180px">
130
+ <div class="metric-card" id="repl-card" style="border-left:1px solid #282828;display:none;width:180px">
131
131
  <div class="metric-top"><span class="metric-label">Replication</span><span class="metric-value dim" id="s-repl-role">—</span></div>
132
132
  <div style="margin-top:4px;font-size:10px" id="s-repl-sources"></div>
133
133
  </div>
134
- <div class="metric-card" style="border-left:1px solid #282828">
134
+ <div class="metric-card metric-right" style="border-left:1px solid #282828;justify-content:space-between">
135
135
  <div class="metric-top"><span class="metric-label">Uptime</span><span class="metric-value dim" id="s-uptime">—</span></div>
136
- <div style="margin-top:4px;font-size:10px;color:#555" id="s-ts">—</div>
137
- <div style="margin-top:4px"><span class="metric-label">WS<span id="ws-dot"></span></span> <span style="font-size:10px;color:#555"><span id="s-clients">0</span> clients · <span id="s-subs">0</span> subs</span></div>
138
- <div style="margin-top:6px"><button id="stats-toggle" class="sm" onclick="toggleStats()" title="Toggle server stats collection">Stats: ON</button></div>
136
+ <div style="font-size:10px;color:#555;display:flex;justify-content:space-between"><span id="s-ts">—</span><span>v<span id="s-version">—</span></span></div>
137
+ <div><span class="metric-label">WS<span id="ws-dot"></span></span> <span style="font-size:10px;color:#555"><span id="s-clients">0</span> clients · <span id="s-subs">0</span> subs</span></div>
138
+ <div><button id="stats-toggle" class="sm" onclick="toggleStats()" title="Toggle server stats collection">Stats: ON</button></div>
139
139
  </div>
140
140
  </div>
141
141
 
@@ -1257,6 +1257,7 @@ db.on('_admin/stats', (snap) => {
1257
1257
  document.getElementById('s-subs').textContent = s.subs ?? 0;
1258
1258
  document.getElementById('s-uptime').textContent = fmtUptime(s.process.uptimeSec);
1259
1259
  document.getElementById('s-ts').textContent = new Date(s.ts).toLocaleTimeString();
1260
+ if (s.version) document.getElementById('s-version').textContent = s.version;
1260
1261
 
1261
1262
  // Replication stats
1262
1263
  if (s.repl) {
@@ -68,12 +68,13 @@ const db = new BodDB({
68
68
  role: 'primary', // fallback for unconfigured paths
69
69
  primaryUrl: repl.remoteUrl, // wss://bod.ee/db
70
70
  paths: [
71
- { path: '_vfs', mode: 'primary' }, // local files are authoritative
72
- { path: '_auth', mode: 'replica' }, // bod.ee is auth authority
73
- { path: 'config', mode: 'sync' }, // bidirectional app config
74
- { path: 'storage', mode: 'primary' }, // local collections (notifications, etc.)
71
+ { path: '_vfs', mode: 'primary' }, // local files are authoritative
72
+ { path: '_auth', mode: 'replica' }, // bod.ee is auth authority
73
+ { path: '_auth/sessions', mode: 'primary' }, // sessions are local (longest-prefix wins)
74
+ { path: '_auth/server', mode: 'primary' }, // server keypair is local
75
+ { path: 'config', mode: 'sync' }, // bidirectional app config
76
+ { path: 'storage', mode: 'primary' }, // local collections (notifications, etc.)
75
77
  ],
76
- excludePrefixes: ['_repl', '_streams', '_mq'],
77
78
  },
78
79
  });
79
80
  ```
@@ -93,7 +94,7 @@ const db = new BodDB({
93
94
  1. **`_auth` writes are proxied** — `createAccount`, `linkDevice` go through bod.ee automatically. No separate HTTP call needed.
94
95
  2. **`_vfs` emits to remote** — file metadata changes push to bod.ee via `_repl` stream. No manual upload sync.
95
96
  3. **Bootstrap is selective** — only `_auth` (replica) pulls from remote on connect. `_vfs` (primary) and `storage` (primary) keep local state.
96
- 4. **`_auth/sessions` and `_auth/server`** — automatically excluded from replication (`_auth` prefix excluded by default; replica mode pulls from remote but these internal paths are local-only).
97
+ 4. **`_auth/sessions` and `_auth/server`** — kept local via explicit sub-path overrides (`mode: 'primary'`). Longest-prefix match ensures `_auth/sessions/x` resolves to `primary` even though `_auth` is `replica`.
97
98
 
98
99
  ---
99
100
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bod.ee/db",
3
- "version": "0.12.2",
3
+ "version": "0.12.6",
4
4
  "module": "index.ts",
5
5
  "type": "module",
6
6
  "exports": {
@@ -203,9 +203,11 @@ export class BodClient {
203
203
  this.scheduleReconnect();
204
204
  }
205
205
 
206
- if (!this.connectPromise) return;
207
- this.connectPromise = null;
208
- // Only reject if this was the initial connect
206
+ // Reject initial connect promise so callers don't hang forever
207
+ if (this.connectPromise) {
208
+ this.connectPromise = null;
209
+ reject(new Error(`WebSocket connection failed (${this.options.url})`));
210
+ }
209
211
  };
210
212
 
211
213
  ws.onerror = () => {
@@ -456,8 +458,10 @@ export class BodClient {
456
458
  return this.send('stream-snapshot', { path });
457
459
  }
458
460
 
459
- async streamMaterialize(path: string, opts?: { keepKey?: string }): Promise<Record<string, unknown>> {
460
- return this.send('stream-materialize', { path, keepKey: opts?.keepKey }) as Promise<Record<string, unknown>>;
461
+ async streamMaterialize(path: string, opts?: { keepKey?: string }): Promise<Record<string, unknown>>;
462
+ async streamMaterialize(path: string, opts: { keepKey?: string; batchSize: number; cursor?: string }): Promise<{ data: Record<string, unknown>; nextCursor?: string }>;
463
+ async streamMaterialize(path: string, opts?: { keepKey?: string; batchSize?: number; cursor?: string }): Promise<Record<string, unknown> | { data: Record<string, unknown>; nextCursor?: string }> {
464
+ return this.send('stream-materialize', { path, ...opts }) as any;
461
465
  }
462
466
 
463
467
  async streamCompact(path: string, opts?: { maxAge?: number; maxCount?: number; keepKey?: string }): Promise<unknown> {
@@ -12,6 +12,8 @@ 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
14
  import { Logger, type LogConfig } from '../shared/logger.ts';
15
+ import pkg from '../../package.json' with { type: 'json' };
16
+ const PKG_VERSION: string = pkg.version ?? 'unknown';
15
17
 
16
18
  export interface TransactionProxy {
17
19
  get(path: string): unknown;
@@ -79,7 +81,8 @@ export class BodDB {
79
81
  this.options = { ...new BodDBOptions(), ...options };
80
82
  this.log = new Logger(this.options.log);
81
83
  const _log = this.log.forComponent('db');
82
- _log.info(`Initializing BodDB (path: ${this.options.path})`);
84
+ console.log(`[BodDB] v${PKG_VERSION} (path: ${this.options.path})`);
85
+ _log.info(`Initializing BodDB v${PKG_VERSION} (path: ${this.options.path})`);
83
86
  this.storage = new StorageEngine({ path: this.options.path });
84
87
  this.subs = new SubscriptionEngine();
85
88
  this.stream = new StreamEngine(this.storage, this.subs, { compact: this.options.compact });
@@ -409,6 +412,7 @@ export class BodDB {
409
412
 
410
413
  // Reuse a single stats object to minimize allocations
411
414
  const statsData: Record<string, unknown> = {
415
+ version: PKG_VERSION,
412
416
  process: {}, db: {}, system: {},
413
417
  subs: 0, clients: 0, repl: null, ts: 0,
414
418
  };
@@ -537,6 +541,7 @@ export class BodDB {
537
541
  this.stop();
538
542
  this.subs.clear();
539
543
  this.storage.close();
544
+ this.log.close();
540
545
  }
541
546
 
542
547
  private snapshotExisting(path: string): Set<string> {
@@ -128,11 +128,15 @@ export class ReplicationOptions {
128
128
  /** Bootstrap replica from primary's full state before applying _repl stream */
129
129
  fullBootstrap: boolean = true;
130
130
  compact?: CompactOptions;
131
+ /** Auto-compact _repl after this many emitted writes (0 = disabled, default 500) */
132
+ autoCompactThreshold: number = 500;
131
133
  sources?: ReplicationSource[];
132
134
  /** Per-path topology: strings default to 'sync', objects specify mode. When absent, role governs all paths. */
133
135
  paths?: Array<string | PathTopology>;
134
136
  }
135
137
 
138
+ const BOOTSTRAP_BATCH_SIZE = 200;
139
+
136
140
  type ProxyableMessage = Extract<ClientMessage, { op: 'set' | 'delete' | 'update' | 'push' | 'batch' }>;
137
141
 
138
142
  export class ReplicationEngine {
@@ -145,6 +149,7 @@ export class ReplicationEngine {
145
149
  private _started = false;
146
150
  private _seq = 0;
147
151
  private _emitting = false;
152
+ private _emitCount = 0;
148
153
  private _pendingReplEvents: WriteEvent[] | null = null;
149
154
  private log: ComponentLogger;
150
155
 
@@ -251,7 +256,7 @@ export class ReplicationEngine {
251
256
  /** Stop replication */
252
257
  stop(): void {
253
258
  this._started = false;
254
- if (this._compactTimer) { clearInterval(this._compactTimer); this._compactTimer = null; }
259
+ this._emitCount = 0;
255
260
  this.unsubWrite?.();
256
261
  this.unsubWrite = null;
257
262
  this.unsubStream?.();
@@ -263,6 +268,7 @@ export class ReplicationEngine {
263
268
  sc.client.disconnect();
264
269
  }
265
270
  this.sourceConns = [];
271
+ this._pendingReplEvents = null;
266
272
  }
267
273
 
268
274
  /** Proxy a write operation to the primary (replica mode) */
@@ -289,22 +295,18 @@ export class ReplicationEngine {
289
295
 
290
296
  // --- Primary mode ---
291
297
 
292
- private _compactTimer: ReturnType<typeof setInterval> | null = null;
293
-
294
298
  private startPrimary(): void {
295
299
  this.unsubWrite = this.db.onWrite((ev: WriteEvent) => {
296
- this.emit(ev);
300
+ // Defer emit — onWrite fires inside the SQLite write transaction,
301
+ // and db.push('_repl') inside that transaction gets silently dropped.
302
+ const evCopy = { ...ev };
303
+ setTimeout(() => { if (this._started) this.emit(evCopy); }, 0);
297
304
  });
298
305
 
299
- // Auto-compact _repl stream to prevent unbounded growth
306
+ // Compact on startup
300
307
  const compact = this.options.compact ?? { maxCount: 500, keepKey: 'path' };
301
308
  if (compact.maxCount || compact.maxAge) {
302
- // Compact on startup
303
309
  try { this.db.stream.compact('_repl', compact); } catch {}
304
- // Then periodically (every 5 minutes)
305
- this._compactTimer = setInterval(() => {
306
- try { this.db.stream.compact('_repl', compact); } catch {}
307
- }, 5 * 60_000);
308
310
  }
309
311
  }
310
312
 
@@ -322,22 +324,29 @@ export class ReplicationEngine {
322
324
 
323
325
  /** Buffer replication events during transactions, emit immediately otherwise */
324
326
  private emit(ev: WriteEvent): void {
325
- if (this._emitting) return;
327
+ if (this._emitting) {
328
+ this.log.debug('emit: skipped (re-entrant)', { path: ev.path });
329
+ return;
330
+ }
326
331
  if (this.router) {
327
- // Single resolve: check exclusion override + shouldEmit together
328
332
  const resolved = this.router.resolve(ev.path);
329
333
  const isConfigured = resolved.path !== '';
330
- // If in excludePrefixes but not explicitly configured, skip
331
- if (!isConfigured && this.options.excludePrefixes.some(p => ev.path.startsWith(p))) return;
334
+ if (!isConfigured && this.options.excludePrefixes.some(p => ev.path.startsWith(p))) {
335
+ this.log.debug('emit: skipped (excluded, not configured)', { path: ev.path });
336
+ return;
337
+ }
332
338
  const mode = resolved.mode;
333
- if (mode !== 'primary' && mode !== 'sync' && mode !== 'writeonly') return;
339
+ if (mode !== 'primary' && mode !== 'sync' && mode !== 'writeonly') {
340
+ this.log.debug('emit: skipped (mode)', { path: ev.path, mode });
341
+ return;
342
+ }
334
343
  } else {
335
344
  if (this.isExcluded(ev.path)) return;
336
345
  }
337
346
 
338
- // If buffering (transaction in progress), collect events
339
347
  if (this._pendingReplEvents) {
340
348
  this._pendingReplEvents.push(ev);
349
+ this.log.debug('emit: buffered (batch)', { path: ev.path });
341
350
  return;
342
351
  }
343
352
 
@@ -351,9 +360,20 @@ export class ReplicationEngine {
351
360
  const seq = this._seq++;
352
361
  const idempotencyKey = `${replEvent.ts}:${seq}:${ev.path}`;
353
362
  this.db.push('_repl', replEvent, { idempotencyKey });
363
+ this.log.info('_repl emit', { seq, op: ev.op, path: ev.path });
364
+ } catch (e: any) {
365
+ this.log.error('_repl emit failed', { path: ev.path, error: e.message });
354
366
  } finally {
355
367
  this._emitting = false;
356
368
  }
369
+
370
+ this._emitCount++;
371
+ const threshold = this.options.autoCompactThreshold;
372
+ if (threshold > 0 && this._emitCount >= threshold) {
373
+ this._emitCount = 0;
374
+ const compact = this.options.compact ?? { maxCount: 500, keepKey: 'path' };
375
+ try { this.db.stream.compact('_repl', compact); } catch {}
376
+ }
357
377
  }
358
378
 
359
379
  /** Start buffering replication events (call before transaction) */
@@ -394,30 +414,22 @@ export class ReplicationEngine {
394
414
  await this.bootstrapFullState(bootstrapPaths);
395
415
  }
396
416
 
397
- // Stream bootstrap filtered
398
- const snapshot = await this.client!.streamMaterialize('_repl', { keepKey: 'path' });
399
- if (snapshot) {
400
- this.db.setReplaying(true);
401
- try {
402
- for (const [, event] of Object.entries(snapshot)) {
403
- const ev = event as ReplEvent;
404
- if (this.matchesPathPrefixes(ev.path, pathPrefixes)) {
405
- this.applyEvent(ev);
406
- }
407
- }
408
- } finally {
409
- this.db.setReplaying(false);
410
- }
411
- }
417
+ // Stream bootstrap filtered (cursor-based to avoid huge single response)
418
+ const applied = await this.bootstrapFromStream(this.client!, { filter: ev => this.matchesPathPrefixes(ev.path, pathPrefixes) });
419
+ this.log.info(`Stream bootstrap (paths): ${applied} events applied`);
412
420
 
413
421
  // Subscribe to ongoing events, filter by paths
414
422
  const groupId = this.options.replicaId!;
423
+ this.log.info('Subscribing to _repl stream', { groupId, pathPrefixes });
415
424
  this.unsubStream = this.client.stream('_repl', groupId).on((events) => {
425
+ this.log.info('_repl events received', { count: events.length });
416
426
  this.db.setReplaying(true);
417
427
  try {
418
428
  for (const e of events) {
419
429
  const ev = e.val() as ReplEvent;
420
- if (this.matchesPathPrefixes(ev.path, pathPrefixes)) {
430
+ const matched = this.matchesPathPrefixes(ev.path, pathPrefixes);
431
+ this.log.info('_repl event', { op: ev.op, path: ev.path, matched });
432
+ if (matched) {
421
433
  this.applyEvent(ev);
422
434
  }
423
435
  this.client!.stream('_repl', groupId).ack(e.key).catch(() => {});
@@ -449,20 +461,9 @@ export class ReplicationEngine {
449
461
  await this.bootstrapFullState();
450
462
  }
451
463
 
452
- // Stream bootstrap: apply _repl events on top (catches recent writes, deduped by idempotent set)
453
- const snapshot = await this.client!.streamMaterialize('_repl', { keepKey: 'path' });
454
- this.log.info(`Stream bootstrap: ${snapshot ? Object.keys(snapshot).length + ' events' : 'no events'}`);
455
- if (snapshot) {
456
- this.db.setReplaying(true);
457
- try {
458
- for (const [, event] of Object.entries(snapshot)) {
459
- const ev = event as ReplEvent;
460
- this.applyEvent(ev);
461
- }
462
- } finally {
463
- this.db.setReplaying(false);
464
- }
465
- }
464
+ // Stream bootstrap: cursor-based to avoid huge single response
465
+ const applied = await this.bootstrapFromStream(this.client!);
466
+ this.log.info(`Stream bootstrap: ${applied} events applied`);
466
467
 
467
468
  // Subscribe to ongoing events
468
469
  const groupId = this.options.replicaId!;
@@ -526,21 +527,11 @@ export class ReplicationEngine {
526
527
  const client = new BodClient({ url: source.url, auth: source.auth });
527
528
  await client.connect();
528
529
 
529
- // Bootstrap: materialize _repl, filter by source paths
530
- const snapshot = await client.streamMaterialize('_repl', { keepKey: 'path' });
531
- if (snapshot) {
532
- this.db.setReplaying(true);
533
- try {
534
- for (const [, event] of Object.entries(snapshot)) {
535
- const ev = event as ReplEvent;
536
- if (this.matchesSourcePaths(ev.path, source)) {
537
- this.applyEvent(ev, source);
538
- }
539
- }
540
- } finally {
541
- this.db.setReplaying(false);
542
- }
543
- }
530
+ // Bootstrap: cursor-based materialize _repl, filter by source paths
531
+ await this.bootstrapFromStream(client, {
532
+ filter: ev => this.matchesSourcePaths(ev.path, source),
533
+ source,
534
+ });
544
535
 
545
536
  // Subscribe to ongoing events
546
537
  const groupId = source.id || `source_${source.url}_${source.paths.sort().join('+')}`;
@@ -577,10 +568,37 @@ export class ReplicationEngine {
577
568
  return source.localPrefix ? `${source.localPrefix}/${path}` : path;
578
569
  }
579
570
 
571
+ /** Cursor-based stream bootstrap: pages through _repl materialize to avoid huge single responses */
572
+ private async bootstrapFromStream(client: BodClient, opts?: { filter?: (ev: ReplEvent) => boolean; source?: ReplicationSource }): Promise<number> {
573
+ let cursor: string | undefined;
574
+ let applied = 0;
575
+ const filter = opts?.filter;
576
+ const source = opts?.source;
577
+ this.db.setReplaying(true);
578
+ try {
579
+ do {
580
+ const page = await client.streamMaterialize('_repl', { keepKey: 'path', batchSize: BOOTSTRAP_BATCH_SIZE, cursor });
581
+ if (page.data) {
582
+ for (const [, event] of Object.entries(page.data)) {
583
+ const ev = event as ReplEvent;
584
+ if (!filter || filter(ev)) {
585
+ this.applyEvent(ev, source);
586
+ applied++;
587
+ }
588
+ }
589
+ }
590
+ cursor = page.nextCursor;
591
+ } while (cursor);
592
+ } finally {
593
+ this.db.setReplaying(false);
594
+ }
595
+ return applied;
596
+ }
597
+
580
598
  private applyEvent(ev: ReplEvent, source?: ReplicationSource): void {
581
599
  const path = source ? this.remapPath(ev.path, source) : ev.path;
582
600
  // Defense-in-depth: skip events for paths we shouldn't apply (primary/writeonly)
583
- if (!source && this.router && !this.router.shouldApply(path)) return;
601
+ if (this.router && !this.router.shouldApply(path)) return;
584
602
  switch (ev.op) {
585
603
  case 'set':
586
604
  this.db.set(path, ev.value, ev.ttl ? { ttl: ev.ttl } : undefined);
@@ -515,10 +515,11 @@ export class Transport {
515
515
  }
516
516
 
517
517
  case 'batch': {
518
- // Upfront rules check before proxy (defense-in-depth)
518
+ // Upfront auth prefix + rules check before proxy (defense-in-depth)
519
519
  for (const batchOp of msg.operations) {
520
520
  const opPaths = batchOp.op === 'update' ? Object.keys(batchOp.updates) : [batchOp.path];
521
521
  for (const p of opPaths) {
522
+ if (guardAuthPrefix(p)) return;
522
523
  if (self.rules && !self.rules.check('write', p, ws.data.auth)) {
523
524
  return error(`Permission denied for ${p}`, Errors.PERMISSION_DENIED);
524
525
  }
@@ -706,7 +707,11 @@ export class Transport {
706
707
  if (self.rules && !self.rules.check('read', msg.path, ws.data.auth)) {
707
708
  return error('Permission denied', Errors.PERMISSION_DENIED);
708
709
  }
709
- return reply(self.db.stream.materialize(msg.path, msg.keepKey ? { keepKey: msg.keepKey } : undefined));
710
+ const matOpts: { keepKey?: string; batchSize?: number; cursor?: string } = {};
711
+ if (msg.keepKey) matOpts.keepKey = msg.keepKey;
712
+ if (msg.batchSize) matOpts.batchSize = msg.batchSize;
713
+ if (msg.cursor) matOpts.cursor = msg.cursor;
714
+ return reply(self.db.stream.materialize(msg.path, Object.keys(matOpts).length ? matOpts : undefined));
710
715
  }
711
716
  case 'stream-compact': {
712
717
  if (self.rules && !self.rules.check('write', msg.path, ws.data.auth)) {
@@ -3,6 +3,9 @@
3
3
  * Disabled by default. Enable via `log` option in BodDBOptions.
4
4
  */
5
5
 
6
+ import { createWriteStream, mkdirSync } from 'fs';
7
+ import type { WriteStream } from 'fs';
8
+
6
9
  export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
7
10
 
8
11
  export interface LogConfig {
@@ -12,6 +15,12 @@ export interface LogConfig {
12
15
  level?: LogLevel;
13
16
  /** Enable specific components: ['storage', 'transport', 'subs', 'replication', 'stats', 'keyauth'] or '*' for all */
14
17
  components?: string[] | '*';
18
+ /**
19
+ * Directory to write log files into. Each run creates a new file named
20
+ * `bod-YYYYMMDD-HHmmss.log`. If omitted, logs go to console only.
21
+ * Only active when `enabled: true`.
22
+ */
23
+ logDir?: string;
15
24
  }
16
25
 
17
26
  const LEVELS: Record<LogLevel, number> = { debug: 0, info: 1, warn: 2, error: 3 };
@@ -21,11 +30,31 @@ export class Logger {
21
30
  private minLevel: number;
22
31
  private components: Set<string> | '*';
23
32
  private _cache = new Map<string, ComponentLogger>();
33
+ private _stream: WriteStream | null = null;
34
+ private _streamDead = false;
24
35
 
25
36
  constructor(config?: LogConfig) {
26
37
  this.enabled = config?.enabled ?? false;
27
38
  this.minLevel = LEVELS[config?.level ?? 'info'];
28
39
  this.components = config?.components === '*' ? '*' : new Set(config?.components ?? []);
40
+ if (this.enabled && config?.logDir) {
41
+ try {
42
+ mkdirSync(config.logDir, { recursive: true });
43
+ const d = new Date();
44
+ const ts = `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
45
+ const file = `${config.logDir}/bod-${ts}.log`;
46
+ this._stream = createWriteStream(file, { flags: 'a' });
47
+ this._stream.on('error', (err) => {
48
+ if (!this._streamDead) {
49
+ console.error(`[BodDB logger] File write error, disabling disk logging: ${err.message}`);
50
+ this._streamDead = true;
51
+ this._stream = null;
52
+ }
53
+ });
54
+ } catch (err: any) {
55
+ console.error(`[BodDB logger] Failed to open log file in "${config.logDir}": ${err.message}`);
56
+ }
57
+ }
29
58
  }
30
59
 
31
60
  forComponent(name: string): ComponentLogger {
@@ -50,9 +79,20 @@ export class Logger {
50
79
  } else {
51
80
  console[level === 'debug' ? 'log' : level](prefix, msg);
52
81
  }
82
+ if (this._stream && !this._streamDead) {
83
+ const dataPart = data !== undefined ? ' ' + JSON.stringify(data) : '';
84
+ this._stream.write(`${prefix} ${msg}${dataPart}\n`);
85
+ }
86
+ }
87
+
88
+ close(): void {
89
+ this._stream?.end();
90
+ this._stream = null;
53
91
  }
54
92
  }
55
93
 
94
+ function pad(n: number): string { return n.toString().padStart(2, '0'); }
95
+
56
96
  export class ComponentLogger {
57
97
  readonly isDebug: boolean;
58
98
  constructor(private logger: Logger, private component: string) {
@@ -26,7 +26,7 @@ export type ClientMessage =
26
26
  | { id: string; op: 'vector-search'; query: number[]; path?: string; limit?: number; threshold?: number }
27
27
  | { id: string; op: 'vector-store'; path: string; embedding: number[] }
28
28
  | { id: string; op: 'stream-snapshot'; path: string }
29
- | { id: string; op: 'stream-materialize'; path: string; keepKey?: string }
29
+ | { id: string; op: 'stream-materialize'; path: string; keepKey?: string; batchSize?: number; cursor?: string }
30
30
  | { id: string; op: 'stream-compact'; path: string; maxAge?: number; maxCount?: number; keepKey?: string }
31
31
  | { id: string; op: 'stream-reset'; path: string }
32
32
  // VFS ops
@@ -325,7 +325,7 @@ describe('B5: Cursor-Based Stream Materialize', () => {
325
325
  });
326
326
 
327
327
  describe('B6: Batched Replication Events', () => {
328
- test('transaction batches repl events', () => {
328
+ test('transaction batches repl events', async () => {
329
329
  const db = new BodDB({ path: ':memory:', replication: { role: 'primary' } });
330
330
  db.replication!['_started'] = true;
331
331
  db.replication!['startPrimary']();
@@ -336,6 +336,7 @@ describe('B6: Batched Replication Events', () => {
336
336
  tx.set('b', 2);
337
337
  tx.set('c', 3);
338
338
  });
339
+ await new Promise(r => setTimeout(r, 50));
339
340
 
340
341
  // All 3 should have been emitted to _repl
341
342
  const replEvents = db.storage.query('_repl');
@@ -344,12 +345,13 @@ describe('B6: Batched Replication Events', () => {
344
345
  db.close();
345
346
  });
346
347
 
347
- test('non-transactional writes emit immediately', () => {
348
+ test('non-transactional writes emit immediately', async () => {
348
349
  const db = new BodDB({ path: ':memory:', replication: { role: 'primary' } });
349
350
  db.replication!['_started'] = true;
350
351
  db.replication!['startPrimary']();
351
352
 
352
353
  db.set('x', 'val');
354
+ await new Promise(r => setTimeout(r, 50));
353
355
  const replEvents = db.storage.query('_repl');
354
356
  expect(replEvents.length).toBe(1);
355
357