wicked-bus 2.1.1 → 2.2.1

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/README.md CHANGED
@@ -146,7 +146,7 @@ Agent ecosystems have a communication problem. Tools that should work together
146
146
 
147
147
  ## Requirements
148
148
 
149
- - Node.js >= 18.0.0
149
+ - Node.js >= 20.0.0
150
150
  - `better-sqlite3` >= 9.0.0 (peer dependency)
151
151
  - macOS, Linux, or Windows
152
152
 
package/commands/cli.js CHANGED
@@ -4,8 +4,22 @@
4
4
  * wicked-bus CLI entry point.
5
5
  */
6
6
 
7
+ import { readFileSync } from 'node:fs';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { dirname, join } from 'node:path';
7
10
  import { WBError, EXIT_CODES } from '../lib/errors.js';
8
11
 
12
+ // Resolve the package version from package.json (single source of truth).
13
+ function readVersion() {
14
+ try {
15
+ const here = dirname(fileURLToPath(import.meta.url));
16
+ const pkg = JSON.parse(readFileSync(join(here, '..', 'package.json'), 'utf8'));
17
+ return pkg.version || '0.0.0';
18
+ } catch (_e) {
19
+ return '0.0.0';
20
+ }
21
+ }
22
+
9
23
  // Argument parser. Returns flags + positional args (anything that isn't --flag
10
24
  // or its value). Positional args are needed for subcommands like `dlq list`.
11
25
  function parseArgs(argv) {
@@ -56,6 +70,15 @@ function handleError(err) {
56
70
  async function main() {
57
71
  const argv = process.argv.slice(2);
58
72
  const command = argv[0];
73
+
74
+ // `--version` / `-v` prints the package version and exits 0. Handled before
75
+ // command dispatch so health probes (e.g. wicked-loom's doctor) get a clean
76
+ // version string instead of the usage/help JSON.
77
+ if (command === '--version' || command === '-v') {
78
+ process.stdout.write(readVersion() + '\n');
79
+ process.exit(0);
80
+ }
81
+
59
82
  const flagArgv = argv.slice(1);
60
83
  const args = parseArgs(flagArgv);
61
84
 
package/lib/atomic.js ADDED
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Cross-platform atomic file placement helpers.
3
+ *
4
+ * POSIX `rename(2)` atomically replaces an existing destination. Windows
5
+ * `MoveFileEx`-backed `fs.rename` REJECTS a rename onto an existing file with
6
+ * `EPERM` / `EEXIST` / `EACCES`, and may transiently fail with `EBUSY` when the
7
+ * destination (or a directory entry) is briefly held open by AV/indexer/another
8
+ * handle. POSIX never sees these for an overwrite.
9
+ *
10
+ * This module centralizes the cross-platform fallback so every "write to a temp
11
+ * file, then rename it into final position" site behaves identically and can't
12
+ * regress independently.
13
+ *
14
+ * @module lib/atomic
15
+ */
16
+
17
+ import fs from 'node:fs';
18
+
19
+ // Number of times we retry a transient EBUSY on win32 before giving up, and the
20
+ // backoff between attempts. Kept tiny — EBUSY here is from a momentary handle
21
+ // (AV scan / indexer), not sustained contention. Synchronous spin-wait so the
22
+ // helper stays drop-in for the existing synchronous CAS/fs codepaths.
23
+ const WIN32_EBUSY_RETRIES = 5;
24
+ const WIN32_EBUSY_BACKOFF_MS = 20;
25
+
26
+ function sleepSync(ms) {
27
+ // Busy-ish wait without pulling in a dependency. Used only on the win32 EBUSY
28
+ // retry path, which is rare and short.
29
+ const end = Date.now() + ms;
30
+ while (Date.now() < end) { /* spin */ }
31
+ }
32
+
33
+ /**
34
+ * Rename `tmp` onto `target`, overwriting any existing `target` atomically.
35
+ *
36
+ * On POSIX a single `renameSync` already overwrites atomically. On win32, if the
37
+ * rename is rejected because `target` exists (EPERM/EEXIST/EACCES) we unlink the
38
+ * destination then retry the rename; a transient EBUSY is retried with a short
39
+ * backoff. On any unrecoverable failure the `tmp` file is cleaned up before the
40
+ * error is rethrown so we never leak partial temp files.
41
+ *
42
+ * @param {string} tmp path to the fully-written temp file
43
+ * @param {string} target final destination path
44
+ * @param {object} [opts]
45
+ * @param {string[]} [opts.swallowCodes] on POSIX-or-win32, rethrow is skipped
46
+ * for these error codes (e.g. ['EEXIST']
47
+ * for content-addressed no-op races). The
48
+ * tmp file is still cleaned up.
49
+ */
50
+ export function atomicRename(tmp, target, opts = {}) {
51
+ const swallow = new Set(opts.swallowCodes ?? []);
52
+ const isWin = process.platform === 'win32';
53
+
54
+ try {
55
+ fs.renameSync(tmp, target);
56
+ return;
57
+ } catch (e) {
58
+ // win32: rename-over-existing is rejected. Unlink the destination and retry.
59
+ if (isWin && (e.code === 'EPERM' || e.code === 'EEXIST' || e.code === 'EACCES' || e.code === 'EBUSY')) {
60
+ let lastErr = e;
61
+ for (let attempt = 0; attempt <= WIN32_EBUSY_RETRIES; attempt++) {
62
+ try {
63
+ // Remove the destination so the rename has a clear path. The
64
+ // destination may legitimately not exist (pure EBUSY on the source),
65
+ // so a failing unlink is non-fatal.
66
+ try { fs.unlinkSync(target); } catch (_e) { /* may not exist */ }
67
+ fs.renameSync(tmp, target);
68
+ return;
69
+ } catch (retryErr) {
70
+ lastErr = retryErr;
71
+ // Only EBUSY is worth retrying — it's the transient AV/indexer hold.
72
+ if (retryErr.code === 'EBUSY' && attempt < WIN32_EBUSY_RETRIES) {
73
+ sleepSync(WIN32_EBUSY_BACKOFF_MS);
74
+ continue;
75
+ }
76
+ break;
77
+ }
78
+ }
79
+ // Exhausted retries (or a non-EBUSY error on the fallback path).
80
+ try { fs.unlinkSync(tmp); } catch (_e) { /* avoid leaking the temp file */ }
81
+ if (swallow.has(lastErr.code)) return;
82
+ throw lastErr;
83
+ }
84
+
85
+ // POSIX (or a win32 error we don't special-case). Clean up the temp file.
86
+ try { fs.unlinkSync(tmp); } catch (_e) { /* avoid leaking the temp file */ }
87
+ if (swallow.has(e.code)) return;
88
+ throw e;
89
+ }
90
+ }
package/lib/cas.js CHANGED
@@ -23,6 +23,7 @@ import { createHash } from 'node:crypto';
23
23
  import { createRequire } from 'node:module';
24
24
  import { WBError } from './errors.js';
25
25
  import { archiveDir, listBuckets } from './archive.js';
26
+ import { atomicRename } from './atomic.js';
26
27
 
27
28
  const require = createRequire(import.meta.url);
28
29
 
@@ -102,14 +103,11 @@ export function put(dataDir, content, opts = {}) {
102
103
  }
103
104
  }
104
105
 
105
- try {
106
- fs.renameSync(tmp, target);
107
- } catch (e) {
108
- // Race: another writer just moved their copy into place. Same SHA
109
- // identical content safe to drop ours.
110
- try { fs.unlinkSync(tmp); } catch (_e) { /* ignore */ }
111
- if (e.code !== 'EEXIST') throw e;
112
- }
106
+ // Cross-platform atomic placement. POSIX rename overwrites atomically;
107
+ // Windows rejects rename-over-existing (EPERM/EEXIST/EACCES) and may hit a
108
+ // transient EBUSY — atomicRename() handles both. EEXIST is swallowed because
109
+ // a concurrent writer placing identical content (same SHA) is a safe no-op.
110
+ atomicRename(tmp, target, { swallowCodes: ['EEXIST'] });
113
111
 
114
112
  return sha;
115
113
  }
package/lib/poll.js CHANGED
@@ -7,6 +7,20 @@ import { WBError } from './errors.js';
7
7
 
8
8
  /**
9
9
  * Match an event against a filter string.
10
+ *
11
+ * Wildcard semantics (trailing only):
12
+ * - `prefix.*` — SINGLE-level: matches `prefix.<one-segment>` and nothing
13
+ * deeper. `wicked.test.*` matches `wicked.test.run` but NOT
14
+ * `wicked.test.run.completed`. Unchanged from v1; existing subscribers keep
15
+ * their exact behavior.
16
+ * - `prefix.**` — MULTI-level: matches `prefix.<one-or-more-segments>`.
17
+ * `wicked.**` matches `wicked.fact.extracted` AND `wicked.test.run.completed`.
18
+ * This is the "everything under a prefix" filter the naming convention
19
+ * implies (every event is `wicked.<noun>.<verb>`, i.e. 3+ segments, so a
20
+ * single-level `wicked.*` would match nothing). At least one trailing
21
+ * segment is required — `**` does not match the bare prefix itself.
22
+ * - `*` — CATCH-ALL (typically `*@domain`): matches every type.
23
+ *
10
24
  * @param {string} eventType - The event_type to test
11
25
  * @param {string} domain - The domain of the event
12
26
  * @param {string} filterStr - Filter pattern, e.g. 'wicked.test.run.*@wicked-testing'
@@ -32,18 +46,40 @@ export function matchesFilter(eventType, domain, filterStr) {
32
46
  // Exact match
33
47
  if (typePattern === eventType) return true;
34
48
 
49
+ // Multi-level wildcard (prefix.**) — one or more remaining segments.
50
+ // Checked before the single-level case so the `.**` suffix is not
51
+ // mis-parsed as `.*` with a trailing `*` segment.
52
+ if (typePattern.endsWith('.**')) {
53
+ const prefix = typePattern.slice(0, -3);
54
+ if (eventType.startsWith(prefix + '.')) {
55
+ const remainder = eventType.slice(prefix.length + 1);
56
+ return remainder.length > 0; // at least one trailing segment
57
+ }
58
+ return false;
59
+ }
60
+
35
61
  // Single-level wildcard (prefix.*)
36
62
  if (typePattern.endsWith('.*')) {
37
63
  const prefix = typePattern.slice(0, -2);
38
64
  if (eventType.startsWith(prefix + '.')) {
39
65
  const remainder = eventType.slice(prefix.length + 1);
40
- return !remainder.includes('.'); // single-level only
66
+ return remainder.length > 0 && !remainder.includes('.'); // single-level only
41
67
  }
42
68
  }
43
69
 
44
70
  return false;
45
71
  }
46
72
 
73
+ /**
74
+ * Escape characters that are special to SQLite's LIKE operator so a filter
75
+ * prefix is matched literally. Pairs with `ESCAPE '\\'` in the query.
76
+ * @param {string} s
77
+ * @returns {string}
78
+ */
79
+ function escapeLike(s) {
80
+ return s.replace(/[\\%_]/g, (ch) => '\\' + ch);
81
+ }
82
+
47
83
  /**
48
84
  * Build SQL WHERE clauses from a filter string for optimized queries.
49
85
  * @param {string} filterStr
@@ -69,15 +105,25 @@ function buildFilterSql(filterStr) {
69
105
  params.domain_filter = domainFilter;
70
106
  }
71
107
 
72
- // Type filter
108
+ // Type filter — must mirror matchesFilter() exactly.
109
+ // LIKE escaping note: SQLite LIKE treats `%` and `_` as wildcards. Event
110
+ // types use only `[a-z0-9._-]`, so `_` could theoretically appear and
111
+ // over-match a single char. We add an ESCAPE clause and escape `_`/`%`/`\`
112
+ // in the prefix to keep SQL matching faithful to matchesFilter().
73
113
  if (typePattern === '*') {
74
114
  // Catch-all: no type filter
115
+ } else if (typePattern.endsWith('.**')) {
116
+ // Multi-level: prefix.<one-or-more-segments>
117
+ const prefix = typePattern.slice(0, -3);
118
+ conditions.push("event_type LIKE :prefix_like ESCAPE '\\'");
119
+ params.prefix_like = escapeLike(prefix) + '.%';
75
120
  } else if (typePattern.endsWith('.*')) {
121
+ // Single-level: prefix.<exactly-one-segment>
76
122
  const prefix = typePattern.slice(0, -2);
77
- conditions.push("event_type LIKE :prefix_like");
78
- conditions.push("event_type NOT LIKE :prefix_multi");
79
- params.prefix_like = prefix + '.%';
80
- params.prefix_multi = prefix + '.%.%';
123
+ conditions.push("event_type LIKE :prefix_like ESCAPE '\\'");
124
+ conditions.push("event_type NOT LIKE :prefix_multi ESCAPE '\\'");
125
+ params.prefix_like = escapeLike(prefix) + '.%';
126
+ params.prefix_multi = escapeLike(prefix) + '.%.%';
81
127
  } else {
82
128
  // Exact match
83
129
  conditions.push('event_type = :exact_type');
@@ -35,6 +35,24 @@ export const DEFAULT_REPROBE_INTERVAL_MS = 5000;
35
35
  export const DEFAULT_PROBE_TIMEOUT_MS = 100;
36
36
 
37
37
  /**
38
+ * ── DELIVERY GUARANTEE: AT-MOST-ONCE (per consumer) ────────────────────────
39
+ * Each frame is ACK'd (the v1 cursor is durably advanced) BEFORE it is yielded
40
+ * to the caller's `for await` loop. If the loop body throws or the process
41
+ * crashes AFTER receiving an event but BEFORE finishing its work, the cursor
42
+ * has already moved past that event — it will NOT be re-delivered. The cursor
43
+ * here means "events handed off to the consumer", not "events the consumer
44
+ * finished processing".
45
+ *
46
+ * This is the OPPOSITE guarantee from {@link import('./subscribe.js').subscribe},
47
+ * the managed `subscribe()` helper, which acks AFTER the handler succeeds
48
+ * (at-least-once, with retry + DLQ). Pick this push-or-poll iterable only when
49
+ * duplicate processing is unacceptable and dropping an event on a mid-stream
50
+ * failure is tolerable. If you need at-least-once + retry + dead-lettering, use
51
+ * `subscribe()` instead.
52
+ *
53
+ * WARNING: do not assume "I received it" == "it is safe". A throw inside the
54
+ * `for await` body silently loses the in-flight event.
55
+ *
38
56
  * @param {object} opts
39
57
  * @param {import('better-sqlite3').Database} opts.db - live DB connection (poll fallback + pointer resolution)
40
58
  * @param {string} opts.cursor_id
package/lib/subscribe.js CHANGED
@@ -214,6 +214,21 @@ function computeLag(db, cursorId) {
214
214
  /**
215
215
  * Subscribe to events with a managed loop, retry, DLQ, and lifecycle.
216
216
  *
217
+ * ── DELIVERY GUARANTEE: AT-LEAST-ONCE ──────────────────────────────────────
218
+ * The handler is invoked BEFORE the cursor is acked. If the handler throws,
219
+ * the event is retried (up to `maxRetries`) and only dead-lettered + acked
220
+ * once the retry budget is exhausted. A crash between "handler succeeded" and
221
+ * "ack persisted" re-delivers the event on the next poll. Therefore the
222
+ * handler MAY see the same logical event more than once and MUST be idempotent
223
+ * (the bus already enforces emit-side idempotency via the `idempotency_key`
224
+ * UNIQUE constraint; consumers must enforce their own on the consume side).
225
+ *
226
+ * This is the OPPOSITE guarantee from {@link subscribePushOrPoll}, which acks
227
+ * BEFORE yielding (at-most-once). Choose `subscribe()` when losing an event is
228
+ * worse than processing it twice (the common case); choose
229
+ * `subscribePushOrPoll()` only when re-processing is unacceptable and dropping
230
+ * an event on a mid-stream failure is tolerable.
231
+ *
217
232
  * @param {object} opts
218
233
  * @param {import('better-sqlite3').Database} opts.db - Open DB handle (required)
219
234
  * @param {string} opts.plugin - Subscriber plugin identity
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wicked-bus",
3
- "version": "2.1.1",
3
+ "version": "2.2.1",
4
4
  "description": "Lightweight, local-first SQLite event bus for AI agents and developer tools",
5
5
  "type": "module",
6
6
  "exports": {
@@ -138,16 +138,22 @@ async function checkBusEvents() {
138
138
  | Pattern | Matches |
139
139
  |---------|---------|
140
140
  | `wicked.run.completed` | Exact match only |
141
- | `wicked.run.*` | All `wicked.run.` events (single-level wildcard) |
141
+ | `wicked.run.*` | `wicked.run.<one-segment>` single-level wildcard |
142
+ | `wicked.run.**` | `wicked.run.<one-or-more-segments>` — multi-level wildcard |
143
+ | `wicked.**` | Everything under `wicked.` (every `wicked.<noun>.<verb>` event) |
142
144
  | `*@wicked-brain` | All events from the `wicked-brain` domain |
143
- | `wicked.memory.*@wicked-brain` | Memory events from brain only |
145
+ | `wicked.memory.*@wicked-brain` | Memory events (single-level) from brain only |
144
146
 
145
147
  ### Filter rules
146
148
 
147
149
  1. `*` matches exactly one segment (single-level wildcard)
148
- 2. `@domain` suffix scopes by the `domain` column
149
- 3. Wildcards and `@domain` can combine: `wicked.run.*@my-plugin`
150
- 4. `*` alone (catch-all) is valid but noisy
150
+ 2. `**` matches one or more segments (multi-level wildcard). Event types are
151
+ `wicked.<noun>.<verb>` (3+ segments), so to subscribe to "everything under
152
+ `wicked`" use `wicked.**`, not `wicked.*` (which would match nothing).
153
+ 3. A trailing `**` requires at least one segment after the prefix.
154
+ 4. `@domain` suffix scopes by the `domain` column
155
+ 5. Wildcards and `@domain` can combine: `wicked.run.*@my-plugin` or `wicked.run.**@my-plugin`
156
+ 6. `*` alone (catch-all) is valid but noisy
151
157
 
152
158
  ## Delivery Semantics
153
159