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.
@@ -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
- const plugin = args.plugin;
20
- const filter = args.filter;
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 Error(
44
- 'Multiple active subscriptions match plugin + filter. ' +
45
- 'Provide --cursor-id to disambiguate.'
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: cursor.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.1",
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": ">=18.0.0"
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": "^11.0.0",
36
+ "better-sqlite3": "^12.0.0",
37
37
  "vitest": "^4.1.4"
38
38
  },
39
39
  "files": [