wicked-bus 2.1.1 → 2.2.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/README.md +1 -1
- package/lib/poll.js +52 -6
- package/lib/subscribe-push-or-poll.js +18 -0
- package/lib/subscribe.js +15 -0
- package/package.json +1 -1
- package/skills/wicked-bus/subscribe/SKILL.md +11 -5
package/README.md
CHANGED
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
|
@@ -138,16 +138,22 @@ async function checkBusEvents() {
|
|
|
138
138
|
| Pattern | Matches |
|
|
139
139
|
|---------|---------|
|
|
140
140
|
| `wicked.run.completed` | Exact match only |
|
|
141
|
-
| `wicked.run.*` |
|
|
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.
|
|
149
|
-
3
|
|
150
|
-
|
|
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
|
|