wicked-bus 2.0.1 → 2.1.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/cmd-subscribe.js +163 -6
- package/lib/index.js +38 -0
- package/lib/poll.js +13 -1
- package/package.json +3 -3
|
@@ -7,8 +7,84 @@ import { openDb } from '../lib/db.js';
|
|
|
7
7
|
import { poll, ack } from '../lib/poll.js';
|
|
8
8
|
import { register } from '../lib/register.js';
|
|
9
9
|
import { startSweep } from '../lib/sweep.js';
|
|
10
|
+
import { WBError } from '../lib/errors.js';
|
|
11
|
+
|
|
12
|
+
const SUBSCRIBE_USAGE = {
|
|
13
|
+
usage: 'wicked-bus subscribe --plugin <name> [options]',
|
|
14
|
+
description:
|
|
15
|
+
'Stream events matching a filter as newline-delimited JSON (NDJSON) to stdout. ' +
|
|
16
|
+
'Runs until SIGINT/SIGTERM.',
|
|
17
|
+
required: {
|
|
18
|
+
'--plugin <name>':
|
|
19
|
+
'Subscriber identity. The durable cursor is keyed on (plugin, filter).',
|
|
20
|
+
},
|
|
21
|
+
options: {
|
|
22
|
+
'--filter <pattern>':
|
|
23
|
+
"Event type filter, e.g. 'wicked.test.run.*' or '*@wicked-testing'. Default: all events.",
|
|
24
|
+
'--cursor-id <id>':
|
|
25
|
+
'Resume an explicit cursor instead of resolving one by (plugin, filter).',
|
|
26
|
+
'--cursor-init <oldest|latest>':
|
|
27
|
+
'Where a NEWLY registered subscription starts. Default: latest.',
|
|
28
|
+
'--poll-interval-ms <ms>': 'Poll cadence. Default: 1000.',
|
|
29
|
+
'--batch-size <n>': 'Maximum events delivered per poll. Default: 100.',
|
|
30
|
+
'--no-ack':
|
|
31
|
+
'Do not advance the cursor after delivering. Events re-deliver on the next run.',
|
|
32
|
+
'--once, --drain':
|
|
33
|
+
'Drain mode: deliver all events pending past the cursor, then exit 0 (does ' +
|
|
34
|
+
'not stream). Process completion is the wake signal for completion-driven ' +
|
|
35
|
+
'agent harnesses.',
|
|
36
|
+
'--idle-timeout <ms>':
|
|
37
|
+
'Drain mode only: after the queue empties, block up to <ms> for new events ' +
|
|
38
|
+
'(the timer resets on each delivery) before exiting 0. Without it, drain ' +
|
|
39
|
+
'exits as soon as the queue is empty.',
|
|
40
|
+
'-h, --help': 'Show this help and exit.',
|
|
41
|
+
},
|
|
42
|
+
notes: [
|
|
43
|
+
'Default (streaming) mode runs until SIGINT/SIGTERM.',
|
|
44
|
+
'Drain mode (--once/--drain) enables a clean drain -> handle -> re-arm loop.',
|
|
45
|
+
"Drain writes events to stdout as NDJSON and a one-line summary to stderr.",
|
|
46
|
+
],
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* True when the user asked for help. `--help` is captured by the arg parser as
|
|
51
|
+
* `args.help`; the short `-h` form is not a `--flag`, so it lands in positionals.
|
|
52
|
+
*/
|
|
53
|
+
function wantsHelp(args) {
|
|
54
|
+
return args.help === true || (args._positional || []).includes('-h');
|
|
55
|
+
}
|
|
10
56
|
|
|
11
57
|
export async function cmdSubscribe(args, globals) {
|
|
58
|
+
// Help and argument validation happen BEFORE any DB access so `--help`
|
|
59
|
+
// never falls through to a confusing SQLite error, and a missing required
|
|
60
|
+
// arg fails fast with a structured, non-zero-exit WBError.
|
|
61
|
+
if (wantsHelp(args)) {
|
|
62
|
+
process.stdout.write(JSON.stringify(SUBSCRIBE_USAGE, null, 2) + '\n');
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const plugin = args.plugin;
|
|
67
|
+
if (!plugin || plugin === true) {
|
|
68
|
+
throw new WBError('WB-001', 'INVALID_EVENT_SCHEMA', {
|
|
69
|
+
message: '--plugin <name> is required (run `wicked-bus subscribe --help`)',
|
|
70
|
+
reason: 'missing --plugin',
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Drain mode (--once / --drain): deliver pending events, then exit instead of
|
|
75
|
+
// streaming forever. Validated pre-DB so a bad --idle-timeout fails fast.
|
|
76
|
+
const drainMode = args.once === true || args.drain === true;
|
|
77
|
+
let idleTimeoutMs = null;
|
|
78
|
+
if (args['idle-timeout'] != null && args['idle-timeout'] !== true) {
|
|
79
|
+
idleTimeoutMs = Number(args['idle-timeout']);
|
|
80
|
+
if (!Number.isFinite(idleTimeoutMs) || idleTimeoutMs < 0) {
|
|
81
|
+
throw new WBError('WB-001', 'INVALID_EVENT_SCHEMA', {
|
|
82
|
+
message: `--idle-timeout must be a non-negative number of milliseconds, got: ${args['idle-timeout']}`,
|
|
83
|
+
reason: 'invalid --idle-timeout',
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
12
88
|
const configOverrides = {};
|
|
13
89
|
if (globals.db_path) configOverrides.db_path = globals.db_path;
|
|
14
90
|
if (globals.log_level) configOverrides.log_level = globals.log_level;
|
|
@@ -16,8 +92,9 @@ export async function cmdSubscribe(args, globals) {
|
|
|
16
92
|
const config = loadConfig(configOverrides);
|
|
17
93
|
const db = openDb(config);
|
|
18
94
|
|
|
19
|
-
|
|
20
|
-
|
|
95
|
+
// event_type_filter is NOT NULL; default an absent (or value-less) --filter
|
|
96
|
+
// to the catch-all '*' so subscribe does not fall through to a DB error.
|
|
97
|
+
const filter = typeof args.filter === 'string' ? args.filter : '*';
|
|
21
98
|
const pollIntervalMs = Number(args['poll-interval-ms']) || 1000;
|
|
22
99
|
const batchSize = Number(args['batch-size']) || 100;
|
|
23
100
|
const noAck = args['no-ack'] === true;
|
|
@@ -40,10 +117,14 @@ export async function cmdSubscribe(args, globals) {
|
|
|
40
117
|
if (existing.length === 1) {
|
|
41
118
|
cursorId = existing[0].cursor_id;
|
|
42
119
|
} else if (existing.length > 1) {
|
|
43
|
-
throw new
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
120
|
+
throw new WBError('WB-001', 'INVALID_EVENT_SCHEMA', {
|
|
121
|
+
message:
|
|
122
|
+
'Multiple active subscriptions match plugin + filter. ' +
|
|
123
|
+
'Provide --cursor-id to disambiguate.',
|
|
124
|
+
reason: 'ambiguous subscription',
|
|
125
|
+
plugin,
|
|
126
|
+
filter,
|
|
127
|
+
});
|
|
47
128
|
} else {
|
|
48
129
|
// Auto-register
|
|
49
130
|
const cursorInit = args['cursor-init'] || 'latest';
|
|
@@ -52,6 +133,14 @@ export async function cmdSubscribe(args, globals) {
|
|
|
52
133
|
}
|
|
53
134
|
}
|
|
54
135
|
|
|
136
|
+
// Drain mode: short-lived, no background sweep, exits when the queue is
|
|
137
|
+
// drained (optionally after an idle window). Process exit is the wake signal.
|
|
138
|
+
if (drainMode) {
|
|
139
|
+
await runDrain(db, cursorId, { batchSize, noAck, pollIntervalMs, idleTimeoutMs });
|
|
140
|
+
db.close();
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
55
144
|
// Start background sweep
|
|
56
145
|
const sweepHandle = startSweep(db, config);
|
|
57
146
|
|
|
@@ -100,3 +189,71 @@ export async function cmdSubscribe(args, globals) {
|
|
|
100
189
|
await new Promise(resolve => setTimeout(resolve, pollIntervalMs));
|
|
101
190
|
}
|
|
102
191
|
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Drain mode: deliver every event pending past the cursor, then return.
|
|
195
|
+
*
|
|
196
|
+
* Pages through the backlog with an in-memory floor so that `--no-ack` works
|
|
197
|
+
* correctly even when the backlog exceeds one batch: the durable cursor never
|
|
198
|
+
* moves, but the floor still advances so the next poll returns the next page
|
|
199
|
+
* rather than re-reading the same one. In ack mode both advance together.
|
|
200
|
+
*
|
|
201
|
+
* With `idleTimeoutMs`, the loop blocks for new events after the queue empties
|
|
202
|
+
* (the timer resets on each delivery) and exits once the window elapses idle.
|
|
203
|
+
* Without it, the loop exits the moment the queue is empty.
|
|
204
|
+
*
|
|
205
|
+
* stdout stays a pure NDJSON event stream; a one-line summary goes to stderr.
|
|
206
|
+
*
|
|
207
|
+
* @param {import('better-sqlite3').Database} db
|
|
208
|
+
* @param {string} cursorId
|
|
209
|
+
* @param {{ batchSize: number, noAck: boolean, pollIntervalMs: number, idleTimeoutMs: number|null }} opts
|
|
210
|
+
*/
|
|
211
|
+
async function runDrain(db, cursorId, opts) {
|
|
212
|
+
const { batchSize, noAck, pollIntervalMs, idleTimeoutMs } = opts;
|
|
213
|
+
|
|
214
|
+
const cursor = db.prepare(
|
|
215
|
+
'SELECT last_event_id FROM cursors WHERE cursor_id = ?'
|
|
216
|
+
).get(cursorId);
|
|
217
|
+
let floor = cursor ? cursor.last_event_id : 0;
|
|
218
|
+
|
|
219
|
+
let delivered = 0;
|
|
220
|
+
let stopping = false;
|
|
221
|
+
const onSignal = () => { stopping = true; };
|
|
222
|
+
process.on('SIGINT', onSignal);
|
|
223
|
+
process.on('SIGTERM', onSignal);
|
|
224
|
+
|
|
225
|
+
try {
|
|
226
|
+
let idleDeadline = idleTimeoutMs != null ? Date.now() + idleTimeoutMs : null;
|
|
227
|
+
while (!stopping) {
|
|
228
|
+
const events = poll(db, cursorId, { batchSize, afterEventId: floor });
|
|
229
|
+
|
|
230
|
+
if (events.length > 0) {
|
|
231
|
+
for (const event of events) {
|
|
232
|
+
process.stdout.write(JSON.stringify(event) + '\n');
|
|
233
|
+
floor = event.event_id;
|
|
234
|
+
if (!noAck) ack(db, cursorId, event.event_id);
|
|
235
|
+
delivered++;
|
|
236
|
+
}
|
|
237
|
+
// Activity resets the idle window.
|
|
238
|
+
if (idleTimeoutMs != null) idleDeadline = Date.now() + idleTimeoutMs;
|
|
239
|
+
// Re-poll immediately to drain a backlog larger than one batch.
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Queue empty.
|
|
244
|
+
if (idleTimeoutMs == null) break; // plain drain — done
|
|
245
|
+
if (Date.now() >= idleDeadline) break; // idle window elapsed
|
|
246
|
+
const remaining = idleDeadline - Date.now();
|
|
247
|
+
await new Promise(r => setTimeout(r, Math.min(pollIntervalMs, remaining)));
|
|
248
|
+
}
|
|
249
|
+
} finally {
|
|
250
|
+
process.removeListener('SIGINT', onSignal);
|
|
251
|
+
process.removeListener('SIGTERM', onSignal);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
process.stderr.write(JSON.stringify({
|
|
255
|
+
drained: delivered,
|
|
256
|
+
acked: !noAck,
|
|
257
|
+
last_event_id: floor,
|
|
258
|
+
}) + '\n');
|
|
259
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -13,3 +13,41 @@ export { startSweep, runSweep } from './sweep.js';
|
|
|
13
13
|
export { listDeadLetters, replayDeadLetter, dropDeadLetter } from './dlq.js';
|
|
14
14
|
export { subscribe } from './subscribe.js';
|
|
15
15
|
export { WBError, ERROR_CODES, EXIT_CODES } from './errors.js';
|
|
16
|
+
|
|
17
|
+
// ── v2 surface (#10) ────────────────────────────────────────────────────────
|
|
18
|
+
// Opt-in features that previously could only be exercised through the CLI
|
|
19
|
+
// binary. Each re-export is additive — the 1.x surface above is unchanged — and
|
|
20
|
+
// safe to import even when the underlying feature is disabled: importing does
|
|
21
|
+
// not start a daemon, open a socket, or enforce a schema. Names match the real
|
|
22
|
+
// module exports (the original proposal referenced a few that never shipped:
|
|
23
|
+
// `applyRegistryPolicy` is `applyOnEmit`; CAS is exposed as a namespace rather
|
|
24
|
+
// than flat `casRead`/`casWrite`/`gcCas` to avoid generic names at the root).
|
|
25
|
+
|
|
26
|
+
// Push-or-poll subscriber: prefers the daemon's push delivery, falls back to
|
|
27
|
+
// polling when no daemon is reachable.
|
|
28
|
+
export { subscribePushOrPoll } from './subscribe-push-or-poll.js';
|
|
29
|
+
|
|
30
|
+
// Daemon client: probe for a running daemon and connect as a push subscriber.
|
|
31
|
+
export { probeDaemon, connectAsSubscriber } from './daemon-client.js';
|
|
32
|
+
|
|
33
|
+
// Lower-level daemon integration: notify the daemon of a freshly emitted row.
|
|
34
|
+
export { notifyEmit } from './daemon-notify.js';
|
|
35
|
+
|
|
36
|
+
// Causality propagation (AsyncLocalStorage): run work within a correlation
|
|
37
|
+
// context and read the active context.
|
|
38
|
+
export { withContext, currentContext } from './causality.js';
|
|
39
|
+
|
|
40
|
+
// JSON Schema registry: look up a registered schema and apply registry policy
|
|
41
|
+
// (validate / coerce) to a payload at emit time.
|
|
42
|
+
export { getSchema, applyOnEmit } from './schema-registry.js';
|
|
43
|
+
|
|
44
|
+
// Tiered-storage sweep with monthly archive buckets.
|
|
45
|
+
export { runSweepV2 } from './sweep-v2.js';
|
|
46
|
+
|
|
47
|
+
// Cross-tier resolving poll (live + archived buckets).
|
|
48
|
+
export { pollResolve } from './query.js';
|
|
49
|
+
|
|
50
|
+
// Content-addressable store for large payloads. Namespaced because the module
|
|
51
|
+
// uses intentionally generic verbs (`put`, `get`, `gc`); reach them as
|
|
52
|
+
// `cas.put(...)`, `cas.get(...)`, `cas.gc(...)`, etc.
|
|
53
|
+
export * as cas from './cas.js';
|
package/lib/poll.js
CHANGED
|
@@ -96,6 +96,12 @@ function buildFilterSql(filterStr) {
|
|
|
96
96
|
* @param {string} cursorId
|
|
97
97
|
* @param {object} [options]
|
|
98
98
|
* @param {number} [options.batchSize=100]
|
|
99
|
+
* @param {number} [options.afterEventId] - Read-only floor override. When set,
|
|
100
|
+
* events are returned with `event_id > afterEventId` instead of the cursor's
|
|
101
|
+
* persisted `last_event_id`. The cursor is NOT mutated. Lets a caller page
|
|
102
|
+
* through events without acking (e.g. a `--drain --no-ack` consumer that
|
|
103
|
+
* advances an in-memory floor). The WB-003 staleness check still uses the
|
|
104
|
+
* persisted cursor position.
|
|
99
105
|
* @returns {object[]} Array of event rows
|
|
100
106
|
*/
|
|
101
107
|
export function poll(db, cursorId, options = {}) {
|
|
@@ -143,6 +149,12 @@ export function poll(db, cursorId, options = {}) {
|
|
|
143
149
|
const { where, params } = buildFilterSql(filter);
|
|
144
150
|
const now = Date.now();
|
|
145
151
|
|
|
152
|
+
// afterEventId is a read-only floor override (does not persist). Fall back to
|
|
153
|
+
// the cursor's durable position when not supplied.
|
|
154
|
+
const lastEventId = options.afterEventId != null
|
|
155
|
+
? options.afterEventId
|
|
156
|
+
: cursor.last_event_id;
|
|
157
|
+
|
|
146
158
|
const sql = `
|
|
147
159
|
SELECT * FROM events
|
|
148
160
|
WHERE event_id > :last_event_id
|
|
@@ -154,7 +166,7 @@ export function poll(db, cursorId, options = {}) {
|
|
|
154
166
|
|
|
155
167
|
const allParams = {
|
|
156
168
|
...params,
|
|
157
|
-
last_event_id:
|
|
169
|
+
last_event_id: lastEventId,
|
|
158
170
|
now,
|
|
159
171
|
batch_size: batchSize,
|
|
160
172
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wicked-bus",
|
|
3
|
-
"version": "2.0
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"description": "Lightweight, local-first SQLite event bus for AI agents and developer tools",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"wicked-bus-install": "./install.mjs"
|
|
18
18
|
},
|
|
19
19
|
"engines": {
|
|
20
|
-
"node": ">=
|
|
20
|
+
"node": ">=20.0.0"
|
|
21
21
|
},
|
|
22
22
|
"scripts": {
|
|
23
23
|
"postinstall": "node scripts/postinstall.js",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
},
|
|
34
34
|
"devDependencies": {
|
|
35
35
|
"@vitest/coverage-v8": "^4.1.4",
|
|
36
|
-
"better-sqlite3": "^
|
|
36
|
+
"better-sqlite3": "^12.0.0",
|
|
37
37
|
"vitest": "^4.1.4"
|
|
38
38
|
},
|
|
39
39
|
"files": [
|