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 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
+ }