wicked-bus 1.1.1 → 2.0.0
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/commands/cli.js +11 -1
- package/commands/cmd-daemon.js +238 -0
- package/commands/cmd-ui.js +156 -0
- package/lib/archive.js +198 -0
- package/lib/cas.js +310 -0
- package/lib/causality.js +129 -0
- package/lib/daemon-client.js +209 -0
- package/lib/daemon-notify.js +97 -0
- package/lib/daemon-singleton.js +186 -0
- package/lib/daemon.js +444 -0
- package/lib/db.js +13 -6
- package/lib/emit.js +94 -5
- package/lib/errors.js +15 -0
- package/lib/ipc-protocol.js +158 -0
- package/lib/migrate.js +133 -0
- package/lib/query.js +189 -0
- package/lib/schema-registry.js +271 -0
- package/lib/subscribe-push-or-poll.js +263 -0
- package/lib/sweep-v2.js +420 -0
- package/lib/sweep.js +22 -12
- package/lib/ui-server.js +301 -0
- package/package.json +1 -1
package/commands/cli.js
CHANGED
|
@@ -33,7 +33,7 @@ function printUsage() {
|
|
|
33
33
|
commands: [
|
|
34
34
|
'init', 'emit', 'subscribe', 'status', 'replay',
|
|
35
35
|
'cleanup', 'register', 'deregister', 'list', 'ack',
|
|
36
|
-
'dlq',
|
|
36
|
+
'dlq', 'daemon', 'ui',
|
|
37
37
|
],
|
|
38
38
|
global_flags: ['--db-path <path>', '--json', '--log-level <level>'],
|
|
39
39
|
};
|
|
@@ -128,6 +128,16 @@ async function main() {
|
|
|
128
128
|
await cmdDlq(args, globals, args._positional || []);
|
|
129
129
|
break;
|
|
130
130
|
}
|
|
131
|
+
case 'daemon': {
|
|
132
|
+
const { cmdDaemon } = await import('./cmd-daemon.js');
|
|
133
|
+
await cmdDaemon(args, globals, args._positional || []);
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
case 'ui': {
|
|
137
|
+
const { cmdUi } = await import('./cmd-ui.js');
|
|
138
|
+
await cmdUi(args, globals);
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
131
141
|
default:
|
|
132
142
|
printUsage();
|
|
133
143
|
process.exit(command ? 1 : 0);
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `wicked-bus daemon <subcommand>` — start, stop, status.
|
|
3
|
+
*
|
|
4
|
+
* - `start [--detached]` spawn the daemon in this process or detach a child
|
|
5
|
+
* - `stop` send SIGTERM to the running daemon (clean shutdown)
|
|
6
|
+
* - `status` JSON snapshot: socket path, pid, subscribers
|
|
7
|
+
*
|
|
8
|
+
* Lifecycle uses lib/daemon-singleton.js for the PID lock and
|
|
9
|
+
* lib/daemon.js for the socket server. Subscribers connect via the
|
|
10
|
+
* library / wrapper APIs; this CLI is operator-facing only.
|
|
11
|
+
*
|
|
12
|
+
* @module commands/cmd-daemon
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import { spawn } from 'node:child_process';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
19
|
+
import { resolveDataDir, ensureDataDir } from '../lib/paths.js';
|
|
20
|
+
import {
|
|
21
|
+
startDaemon,
|
|
22
|
+
socketPath,
|
|
23
|
+
} from '../lib/daemon.js';
|
|
24
|
+
import {
|
|
25
|
+
acquireDaemonLock,
|
|
26
|
+
} from '../lib/daemon-singleton.js';
|
|
27
|
+
import { probeDaemon } from '../lib/daemon-client.js';
|
|
28
|
+
|
|
29
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
30
|
+
const __dirname = path.dirname(__filename);
|
|
31
|
+
|
|
32
|
+
export async function cmdDaemon(args, globals, positional) {
|
|
33
|
+
const sub = (positional && positional[0]) || null;
|
|
34
|
+
if (!sub) {
|
|
35
|
+
return emitJson({
|
|
36
|
+
error: 'usage',
|
|
37
|
+
message: 'daemon requires a subcommand: start | stop | status',
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
switch (sub) {
|
|
42
|
+
case 'start': return cmdDaemonStart(args, globals);
|
|
43
|
+
case 'stop': return cmdDaemonStop(args, globals);
|
|
44
|
+
case 'status': return cmdDaemonStatus(args, globals);
|
|
45
|
+
default:
|
|
46
|
+
return emitJson({
|
|
47
|
+
error: 'usage',
|
|
48
|
+
message: `unknown daemon subcommand: ${sub}`,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// start
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
async function cmdDaemonStart(args /*, globals */) {
|
|
58
|
+
const dataDir = resolveDataDir();
|
|
59
|
+
ensureDataDir();
|
|
60
|
+
|
|
61
|
+
// Detached: fork ourselves with `--no-detach` so the child runs the
|
|
62
|
+
// foreground branch. Parent waits for the socket to come up, prints
|
|
63
|
+
// status, exits 0. Stderr is captured to a log file in dataDir so the
|
|
64
|
+
// child's startup errors are diagnosable even when stdio is detached.
|
|
65
|
+
if (args.detached !== false && (args.detached === true || args['detach'] === true)) {
|
|
66
|
+
const logPath = path.join(dataDir, 'daemon.log');
|
|
67
|
+
const logFd = fs.openSync(logPath, 'a');
|
|
68
|
+
const child = spawn(
|
|
69
|
+
process.execPath,
|
|
70
|
+
[path.resolve(__dirname, 'cli.js'), 'daemon', 'start', '--no-detach'],
|
|
71
|
+
{
|
|
72
|
+
detached: true,
|
|
73
|
+
stdio: ['ignore', logFd, logFd],
|
|
74
|
+
env: process.env,
|
|
75
|
+
cwd: dataDir,
|
|
76
|
+
},
|
|
77
|
+
);
|
|
78
|
+
fs.closeSync(logFd);
|
|
79
|
+
child.unref();
|
|
80
|
+
|
|
81
|
+
const ok = await waitForSocket(dataDir, 5000);
|
|
82
|
+
if (!ok) {
|
|
83
|
+
let logTail = '';
|
|
84
|
+
try { logTail = fs.readFileSync(logPath, 'utf8').slice(-2000); }
|
|
85
|
+
catch (_e) { /* ignore */ }
|
|
86
|
+
return emitJsonExit({
|
|
87
|
+
error: 'daemon-start-timeout',
|
|
88
|
+
message: 'spawned daemon did not start listening within 5s',
|
|
89
|
+
socket: socketPath(dataDir),
|
|
90
|
+
log_tail: logTail,
|
|
91
|
+
}, 1);
|
|
92
|
+
}
|
|
93
|
+
return emitJson({
|
|
94
|
+
ok: true,
|
|
95
|
+
mode: 'detached',
|
|
96
|
+
pid: child.pid,
|
|
97
|
+
socket: socketPath(dataDir),
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Foreground: hold the lock, run the daemon, hand off to a SIGTERM/SIGINT
|
|
102
|
+
// handler for clean shutdown. This branch is what `--detached` re-enters.
|
|
103
|
+
let lock;
|
|
104
|
+
try {
|
|
105
|
+
lock = acquireDaemonLock(dataDir);
|
|
106
|
+
} catch (e) {
|
|
107
|
+
if (e.code === 'EALREADY_RUNNING') {
|
|
108
|
+
return emitJsonExit({
|
|
109
|
+
error: 'EALREADY_RUNNING',
|
|
110
|
+
message: e.message,
|
|
111
|
+
prior_pid: e.priorPid,
|
|
112
|
+
socket: socketPath(dataDir),
|
|
113
|
+
}, 1);
|
|
114
|
+
}
|
|
115
|
+
throw e;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const daemon = await startDaemon({
|
|
119
|
+
dataDir,
|
|
120
|
+
inline_payload_max_bytes: numFlag(args, 'inline-payload-max-bytes'),
|
|
121
|
+
subscriber_queue_max: numFlag(args, 'subscriber-queue-max'),
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const shutdown = async () => {
|
|
125
|
+
try { await daemon.stop(); } catch (_e) { /* ignore */ }
|
|
126
|
+
try { lock.release(); } catch (_e) { /* ignore */ }
|
|
127
|
+
process.exit(0);
|
|
128
|
+
};
|
|
129
|
+
process.on('SIGTERM', shutdown);
|
|
130
|
+
process.on('SIGINT', shutdown);
|
|
131
|
+
|
|
132
|
+
emitJson({
|
|
133
|
+
ok: true,
|
|
134
|
+
mode: 'foreground',
|
|
135
|
+
pid: process.pid,
|
|
136
|
+
socket: daemon.socketPath,
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// Stay alive until a signal fires shutdown(). The Node event loop keeps
|
|
140
|
+
// the process running because the server holds an active handle.
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
// stop
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
async function cmdDaemonStop(/* args, globals */) {
|
|
148
|
+
const dataDir = resolveDataDir();
|
|
149
|
+
const lockPath = path.join(dataDir, 'daemon.lock');
|
|
150
|
+
|
|
151
|
+
let pid;
|
|
152
|
+
try {
|
|
153
|
+
pid = parseInt(fs.readFileSync(lockPath, 'utf8'), 10) || null;
|
|
154
|
+
} catch (_e) {
|
|
155
|
+
pid = null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (!pid) {
|
|
159
|
+
return emitJson({ ok: false, reason: 'no-lock-file', socket: socketPath(dataDir) });
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
process.kill(pid, 'SIGTERM');
|
|
164
|
+
} catch (e) {
|
|
165
|
+
return emitJsonExit({
|
|
166
|
+
error: 'kill-failed',
|
|
167
|
+
pid,
|
|
168
|
+
message: e.message,
|
|
169
|
+
}, 1);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Best-effort wait for the daemon to release the lock.
|
|
173
|
+
const released = await waitForLockRelease(lockPath, 5000);
|
|
174
|
+
return emitJson({
|
|
175
|
+
ok: true,
|
|
176
|
+
pid,
|
|
177
|
+
released,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
// status
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
async function cmdDaemonStatus(/* args, globals */) {
|
|
186
|
+
const dataDir = resolveDataDir();
|
|
187
|
+
const lockPath = path.join(dataDir, 'daemon.lock');
|
|
188
|
+
const sockPath = socketPath(dataDir);
|
|
189
|
+
|
|
190
|
+
let pid = null;
|
|
191
|
+
try { pid = parseInt(fs.readFileSync(lockPath, 'utf8'), 10) || null; }
|
|
192
|
+
catch (_e) { /* no lock */ }
|
|
193
|
+
|
|
194
|
+
const reachable = await probeDaemon(dataDir, 200);
|
|
195
|
+
|
|
196
|
+
return emitJson({
|
|
197
|
+
socket: sockPath,
|
|
198
|
+
lock_path: lockPath,
|
|
199
|
+
pid,
|
|
200
|
+
reachable,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
function emitJson(obj) {
|
|
207
|
+
process.stdout.write(JSON.stringify(obj) + '\n');
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function emitJsonExit(obj, code) {
|
|
211
|
+
process.stderr.write(JSON.stringify(obj) + '\n');
|
|
212
|
+
process.exit(code);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function numFlag(args, name) {
|
|
216
|
+
const v = args[name];
|
|
217
|
+
if (v == null || v === true) return undefined;
|
|
218
|
+
const n = Number(v);
|
|
219
|
+
return Number.isFinite(n) ? n : undefined;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function waitForSocket(dataDir, timeoutMs) {
|
|
223
|
+
const deadline = Date.now() + timeoutMs;
|
|
224
|
+
while (Date.now() < deadline) {
|
|
225
|
+
if (await probeDaemon(dataDir, 100)) return true;
|
|
226
|
+
await new Promise(r => setTimeout(r, 50));
|
|
227
|
+
}
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function waitForLockRelease(lockPath, timeoutMs) {
|
|
232
|
+
const deadline = Date.now() + timeoutMs;
|
|
233
|
+
while (Date.now() < deadline) {
|
|
234
|
+
if (!fs.existsSync(lockPath)) return true;
|
|
235
|
+
await new Promise(r => setTimeout(r, 50));
|
|
236
|
+
}
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `wicked-bus ui` — operator-facing CLI for the read-only UI server.
|
|
3
|
+
*
|
|
4
|
+
* wicked-bus ui start in foreground, JSON to stdout
|
|
5
|
+
* wicked-bus ui --detached fork a child, print connection info, exit 0
|
|
6
|
+
* wicked-bus ui --rotate-token regenerate the bearer token
|
|
7
|
+
* wicked-bus ui --host 0.0.0.0 bind non-loopback (warning printed)
|
|
8
|
+
* wicked-bus ui --port 7842 bind a specific port (default 0 = ephemeral
|
|
9
|
+
* in foreground; default 7842 in detached
|
|
10
|
+
* mode so a known port is reachable)
|
|
11
|
+
*
|
|
12
|
+
* Foreground sets up SIGTERM/SIGINT handlers for clean shutdown. Detached
|
|
13
|
+
* redirects child stdio to `<dataDir>/ui.log` so startup failures are
|
|
14
|
+
* diagnosable (same lesson as the daemon CLI: stdio:'ignore' on a long-
|
|
15
|
+
* running spawn hides everything).
|
|
16
|
+
*
|
|
17
|
+
* @module commands/cmd-ui
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import fs from 'node:fs';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import { spawn } from 'node:child_process';
|
|
23
|
+
import { fileURLToPath } from 'node:url';
|
|
24
|
+
import { resolveDataDir, ensureDataDir } from '../lib/paths.js';
|
|
25
|
+
import { openDb } from '../lib/db.js';
|
|
26
|
+
import { startUiServer, DEFAULT_HOST, DEFAULT_PORT } from '../lib/ui-server.js';
|
|
27
|
+
|
|
28
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
29
|
+
const __dirname = path.dirname(__filename);
|
|
30
|
+
|
|
31
|
+
export async function cmdUi(args /*, globals */) {
|
|
32
|
+
const dataDir = resolveDataDir();
|
|
33
|
+
ensureDataDir();
|
|
34
|
+
|
|
35
|
+
// --no-detach is the inner branch; --detached is the outer fork.
|
|
36
|
+
if (args.detached === true || args['detach'] === true) {
|
|
37
|
+
return startDetached(args, dataDir);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return startForeground(args, dataDir);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
async function startDetached(args, dataDir) {
|
|
46
|
+
const logPath = path.join(dataDir, 'ui.log');
|
|
47
|
+
const logFd = fs.openSync(logPath, 'a');
|
|
48
|
+
|
|
49
|
+
// Re-invoke ourselves with --no-detach + a known port so the child can
|
|
50
|
+
// start cleanly and the parent can probe.
|
|
51
|
+
const port = args.port ? String(args.port) : String(DEFAULT_PORT);
|
|
52
|
+
const host = args.host ? String(args.host) : DEFAULT_HOST;
|
|
53
|
+
|
|
54
|
+
const childArgs = [
|
|
55
|
+
path.resolve(__dirname, 'cli.js'),
|
|
56
|
+
'ui', '--no-detach',
|
|
57
|
+
'--port', port,
|
|
58
|
+
'--host', host,
|
|
59
|
+
];
|
|
60
|
+
if (args['rotate-token'] === true || args.rotate_token === true) {
|
|
61
|
+
childArgs.push('--rotate-token');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const child = spawn(process.execPath, childArgs, {
|
|
65
|
+
detached: true,
|
|
66
|
+
stdio: ['ignore', logFd, logFd],
|
|
67
|
+
env: process.env,
|
|
68
|
+
cwd: dataDir,
|
|
69
|
+
});
|
|
70
|
+
fs.closeSync(logFd);
|
|
71
|
+
child.unref();
|
|
72
|
+
|
|
73
|
+
const reachable = await waitForUi(host, port, 5000);
|
|
74
|
+
if (!reachable) {
|
|
75
|
+
let logTail = '';
|
|
76
|
+
try { logTail = fs.readFileSync(logPath, 'utf8').slice(-2000); }
|
|
77
|
+
catch (_e) { /* ignore */ }
|
|
78
|
+
return emitJsonExit({
|
|
79
|
+
error: 'ui-start-timeout',
|
|
80
|
+
message: 'spawned UI did not start listening within 5s',
|
|
81
|
+
log_tail: logTail,
|
|
82
|
+
}, 1);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return emitJson({
|
|
86
|
+
ok: true,
|
|
87
|
+
mode: 'detached',
|
|
88
|
+
pid: child.pid,
|
|
89
|
+
host,
|
|
90
|
+
port: Number(port),
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
async function startForeground(args, dataDir) {
|
|
97
|
+
const liveDb = openDb();
|
|
98
|
+
const ui = await startUiServer({
|
|
99
|
+
dataDir,
|
|
100
|
+
liveDb,
|
|
101
|
+
host: args.host ?? DEFAULT_HOST,
|
|
102
|
+
port: args.port != null ? Number(args.port) : DEFAULT_PORT,
|
|
103
|
+
rotate_token: args['rotate-token'] === true || args.rotate_token === true,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const shutdown = async () => {
|
|
107
|
+
try { await ui.stop(); } catch (_e) { /* ignore */ }
|
|
108
|
+
try { liveDb.close(); } catch (_e) { /* ignore */ }
|
|
109
|
+
process.exit(0);
|
|
110
|
+
};
|
|
111
|
+
process.on('SIGTERM', shutdown);
|
|
112
|
+
process.on('SIGINT', shutdown);
|
|
113
|
+
|
|
114
|
+
emitJson({
|
|
115
|
+
ok: true,
|
|
116
|
+
mode: 'foreground',
|
|
117
|
+
pid: process.pid,
|
|
118
|
+
host: ui.host,
|
|
119
|
+
port: ui.port,
|
|
120
|
+
token_path: ui.token_path,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
async function waitForUi(host, port, timeoutMs) {
|
|
127
|
+
const http = await import('node:http');
|
|
128
|
+
const deadline = Date.now() + timeoutMs;
|
|
129
|
+
while (Date.now() < deadline) {
|
|
130
|
+
if (await ping(http.default, host, port)) return true;
|
|
131
|
+
await new Promise(r => setTimeout(r, 50));
|
|
132
|
+
}
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function ping(http, host, port) {
|
|
137
|
+
return new Promise((resolve) => {
|
|
138
|
+
const req = http.request({ host, port, path: '/healthz', method: 'GET', timeout: 200 }, (res) => {
|
|
139
|
+
// Drain so the connection releases.
|
|
140
|
+
res.resume();
|
|
141
|
+
resolve(res.statusCode === 200);
|
|
142
|
+
});
|
|
143
|
+
req.on('error', () => resolve(false));
|
|
144
|
+
req.on('timeout', () => { req.destroy(); resolve(false); });
|
|
145
|
+
req.end();
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function emitJson(obj) {
|
|
150
|
+
process.stdout.write(JSON.stringify(obj) + '\n');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function emitJsonExit(obj, code) {
|
|
154
|
+
process.stderr.write(JSON.stringify(obj) + '\n');
|
|
155
|
+
process.exit(code);
|
|
156
|
+
}
|
package/lib/archive.js
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Archive bucket lifecycle for the v2 tiered-storage warm tier.
|
|
3
|
+
*
|
|
4
|
+
* Each warm bucket is an independent SQLite file at
|
|
5
|
+
* `<data_dir>/archive/bus-YYYY-MM[suffix].db`. Files are independent —
|
|
6
|
+
* corrupting one bucket affects only the events in that month/split.
|
|
7
|
+
*
|
|
8
|
+
* Each bucket carries a `_meta` table (NOT a fictitious PRAGMA) holding
|
|
9
|
+
* `min_event_id`, `max_event_id`, `created_at`, and `sealed_at`. The query
|
|
10
|
+
* resolver reads these via normal SELECT to decide which buckets to ATTACH
|
|
11
|
+
* during a warm-spill (see lib/query.js and DESIGN-v2.md §5.4).
|
|
12
|
+
*
|
|
13
|
+
* Buckets without a `sealed_at` value are still being filled by sweep and
|
|
14
|
+
* are conservatively treated as covering up to MAX_SAFE_INTEGER until sealed.
|
|
15
|
+
*
|
|
16
|
+
* @module lib/archive
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import fs from 'node:fs';
|
|
21
|
+
import { createRequire } from 'node:module';
|
|
22
|
+
|
|
23
|
+
const require = createRequire(import.meta.url);
|
|
24
|
+
|
|
25
|
+
const BUCKET_FILE_RE = /^bus-\d{4}-\d{2}[a-z]?\.db$/;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Build the absolute path to the archive directory for a given data_dir.
|
|
29
|
+
* @param {string} dataDir
|
|
30
|
+
* @returns {string}
|
|
31
|
+
*/
|
|
32
|
+
export function archiveDir(dataDir) {
|
|
33
|
+
return path.join(dataDir, 'archive');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Ensure the archive directory exists (idempotent).
|
|
38
|
+
* @param {string} archDir
|
|
39
|
+
*/
|
|
40
|
+
export function ensureArchiveDir(archDir) {
|
|
41
|
+
fs.mkdirSync(archDir, { recursive: true });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Create a new archive bucket file with the v2 `events` schema mirrored
|
|
46
|
+
* from the live tier, plus the `_meta` table. Returns the open Database
|
|
47
|
+
* handle. Caller is responsible for closing it.
|
|
48
|
+
*
|
|
49
|
+
* @param {string} filepath - absolute path to the bucket .db file
|
|
50
|
+
* @param {{ minEventId?: number, maxEventId?: number }} [meta]
|
|
51
|
+
* @returns {import('better-sqlite3').Database}
|
|
52
|
+
*/
|
|
53
|
+
export function createBucket(filepath, meta = {}) {
|
|
54
|
+
ensureArchiveDir(path.dirname(filepath));
|
|
55
|
+
const Database = require('better-sqlite3');
|
|
56
|
+
const db = new Database(filepath);
|
|
57
|
+
|
|
58
|
+
db.pragma('journal_mode = WAL');
|
|
59
|
+
db.pragma('synchronous = NORMAL');
|
|
60
|
+
db.pragma('busy_timeout = 5000');
|
|
61
|
+
|
|
62
|
+
db.exec(`
|
|
63
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
64
|
+
event_id INTEGER PRIMARY KEY,
|
|
65
|
+
event_type TEXT NOT NULL,
|
|
66
|
+
domain TEXT NOT NULL,
|
|
67
|
+
subdomain TEXT NOT NULL DEFAULT '',
|
|
68
|
+
payload TEXT NOT NULL,
|
|
69
|
+
schema_version TEXT NOT NULL DEFAULT '1.0.0',
|
|
70
|
+
idempotency_key TEXT NOT NULL,
|
|
71
|
+
emitted_at INTEGER NOT NULL,
|
|
72
|
+
expires_at INTEGER NOT NULL,
|
|
73
|
+
dedup_expires_at INTEGER NOT NULL,
|
|
74
|
+
metadata TEXT,
|
|
75
|
+
parent_event_id INTEGER,
|
|
76
|
+
session_id TEXT,
|
|
77
|
+
correlation_id TEXT,
|
|
78
|
+
producer_id TEXT,
|
|
79
|
+
origin_node_id TEXT,
|
|
80
|
+
registry_schema_version INTEGER,
|
|
81
|
+
payload_cas_sha TEXT
|
|
82
|
+
);
|
|
83
|
+
CREATE INDEX IF NOT EXISTS idx_events_event_type ON events(event_type);
|
|
84
|
+
CREATE INDEX IF NOT EXISTS idx_events_domain ON events(domain);
|
|
85
|
+
CREATE INDEX IF NOT EXISTS idx_events_correlation_id ON events(correlation_id);
|
|
86
|
+
|
|
87
|
+
CREATE TABLE IF NOT EXISTS _meta (
|
|
88
|
+
k TEXT PRIMARY KEY,
|
|
89
|
+
v TEXT NOT NULL
|
|
90
|
+
);
|
|
91
|
+
`);
|
|
92
|
+
|
|
93
|
+
setMeta(db, 'created_at', String(Date.now()));
|
|
94
|
+
if (meta.minEventId != null) setMeta(db, 'min_event_id', String(meta.minEventId));
|
|
95
|
+
if (meta.maxEventId != null) setMeta(db, 'max_event_id', String(meta.maxEventId));
|
|
96
|
+
|
|
97
|
+
return db;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Read the `_meta` key/value pairs from a bucket file.
|
|
102
|
+
* Opens read-only so a sealed bucket can be inspected without taking a write lock.
|
|
103
|
+
*
|
|
104
|
+
* @param {string} filepath
|
|
105
|
+
* @returns {Record<string,string>}
|
|
106
|
+
*/
|
|
107
|
+
export function getBucketMeta(filepath) {
|
|
108
|
+
const Database = require('better-sqlite3');
|
|
109
|
+
const db = new Database(filepath, { readonly: true });
|
|
110
|
+
try {
|
|
111
|
+
const rows = db.prepare('SELECT k, v FROM _meta').all();
|
|
112
|
+
const meta = {};
|
|
113
|
+
for (const r of rows) meta[r.k] = r.v;
|
|
114
|
+
return meta;
|
|
115
|
+
} finally {
|
|
116
|
+
db.close();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Mark a bucket sealed: write `sealed_at` and (optionally) finalize `max_event_id`.
|
|
122
|
+
* Once sealed, a bucket's covered range is fixed and the resolver can use
|
|
123
|
+
* the [min, max] interval to skip it when out of range.
|
|
124
|
+
*
|
|
125
|
+
* @param {string} filepath
|
|
126
|
+
* @param {{ maxEventId?: number }} [opts]
|
|
127
|
+
*/
|
|
128
|
+
export function sealBucket(filepath, opts = {}) {
|
|
129
|
+
const Database = require('better-sqlite3');
|
|
130
|
+
const db = new Database(filepath);
|
|
131
|
+
try {
|
|
132
|
+
setMeta(db, 'sealed_at', String(Date.now()));
|
|
133
|
+
if (opts.maxEventId != null) {
|
|
134
|
+
setMeta(db, 'max_event_id', String(opts.maxEventId));
|
|
135
|
+
}
|
|
136
|
+
} finally {
|
|
137
|
+
db.close();
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function setMeta(db, k, v) {
|
|
142
|
+
db.prepare('INSERT OR REPLACE INTO _meta(k, v) VALUES (?, ?)').run(k, v);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* List all bucket files in the archive directory, sorted lexicographically.
|
|
147
|
+
* Suffix letters (`a`, `b`, …) preserve creation order within a month after
|
|
148
|
+
* auto-split, so lexical sort matches creation order.
|
|
149
|
+
*
|
|
150
|
+
* @param {string} archDir
|
|
151
|
+
* @returns {string[]} absolute paths
|
|
152
|
+
*/
|
|
153
|
+
export function listBuckets(archDir) {
|
|
154
|
+
if (!fs.existsSync(archDir)) return [];
|
|
155
|
+
return fs.readdirSync(archDir)
|
|
156
|
+
.filter(f => BUCKET_FILE_RE.test(f))
|
|
157
|
+
.sort()
|
|
158
|
+
.map(f => path.join(archDir, f));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Find buckets that cover (any part of) the event_id range [gapStart, gapEnd].
|
|
163
|
+
* Returns descriptors with absolute path + parsed range + unsealed flag.
|
|
164
|
+
* Unsealed buckets are conservatively included whenever their `min_event_id`
|
|
165
|
+
* is at or below `gapEnd`.
|
|
166
|
+
*
|
|
167
|
+
* @param {string} archDir
|
|
168
|
+
* @param {number} gapStart - inclusive
|
|
169
|
+
* @param {number} gapEnd - inclusive
|
|
170
|
+
* @returns {Array<{ filepath: string, min: number, max: number, unsealed: boolean }>}
|
|
171
|
+
*/
|
|
172
|
+
export function bucketsCoveringRange(archDir, gapStart, gapEnd) {
|
|
173
|
+
const all = listBuckets(archDir);
|
|
174
|
+
const covering = [];
|
|
175
|
+
for (const filepath of all) {
|
|
176
|
+
let meta;
|
|
177
|
+
try {
|
|
178
|
+
meta = getBucketMeta(filepath);
|
|
179
|
+
} catch (_e) {
|
|
180
|
+
// Unreadable bucket — surface to caller via spill, not enumeration.
|
|
181
|
+
// Caller's ATTACH attempt will throw WB-013 if needed.
|
|
182
|
+
covering.push({ filepath, min: -Infinity, max: Infinity, unsealed: true });
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
const min = meta.min_event_id != null ? Number(meta.min_event_id) : null;
|
|
186
|
+
const sealed = meta.sealed_at != null;
|
|
187
|
+
const max = sealed && meta.max_event_id != null
|
|
188
|
+
? Number(meta.max_event_id)
|
|
189
|
+
: Number.MAX_SAFE_INTEGER;
|
|
190
|
+
|
|
191
|
+
if (min === null) continue; // empty bucket — nothing to cover
|
|
192
|
+
|
|
193
|
+
if (max >= gapStart && min <= gapEnd) {
|
|
194
|
+
covering.push({ filepath, min, max, unsealed: !sealed });
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return covering;
|
|
198
|
+
}
|