@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.
- package/.claude/skills/developing-bod-db.md +1 -1
- package/.claude/skills/using-bod-db.md +19 -1
- package/CLAUDE.md +1 -1
- package/admin/ui.html +7 -6
- package/docs/para-chat-integration.md +7 -6
- package/package.json +1 -1
- package/src/client/BodClient.ts +9 -5
- package/src/server/BodDB.ts +6 -1
- package/src/server/ReplicationEngine.ts +80 -62
- package/src/server/Transport.ts +7 -2
- package/src/shared/logger.ts +40 -0
- package/src/shared/protocol.ts +1 -1
- package/tests/optimization.test.ts +4 -2
- package/tests/repl-load.test.ts +372 -0
- package/tests/repl-stream-bloat.test.ts +65 -35
- package/tests/replication-topology.test.ts +49 -7
- package/tests/replication.test.ts +16 -7
|
@@ -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
|
|
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:
|
|
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' })
|
|
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;
|
|
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
|
|
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="
|
|
137
|
-
<div
|
|
138
|
-
<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' },
|
|
72
|
-
{ path: '_auth', mode: 'replica' },
|
|
73
|
-
{ path: '
|
|
74
|
-
{ path: '
|
|
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`** —
|
|
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
package/src/client/BodClient.ts
CHANGED
|
@@ -203,9 +203,11 @@ export class BodClient {
|
|
|
203
203
|
this.scheduleReconnect();
|
|
204
204
|
}
|
|
205
205
|
|
|
206
|
-
|
|
207
|
-
this.connectPromise
|
|
208
|
-
|
|
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
|
-
|
|
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> {
|
package/src/server/BodDB.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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)
|
|
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
|
-
|
|
331
|
-
|
|
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')
|
|
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
|
|
399
|
-
|
|
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
|
-
|
|
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:
|
|
453
|
-
const
|
|
454
|
-
this.log.info(`Stream bootstrap: ${
|
|
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
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
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 (
|
|
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);
|
package/src/server/Transport.ts
CHANGED
|
@@ -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
|
-
|
|
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)) {
|
package/src/shared/logger.ts
CHANGED
|
@@ -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) {
|
package/src/shared/protocol.ts
CHANGED
|
@@ -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
|
|