wicked-bus 1.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/README.md +153 -0
- package/commands/cli.js +129 -0
- package/commands/cmd-ack.js +31 -0
- package/commands/cmd-cleanup.js +44 -0
- package/commands/cmd-deregister.js +26 -0
- package/commands/cmd-emit.js +52 -0
- package/commands/cmd-init.js +30 -0
- package/commands/cmd-list.js +39 -0
- package/commands/cmd-register.js +29 -0
- package/commands/cmd-replay.js +65 -0
- package/commands/cmd-status.js +70 -0
- package/commands/cmd-subscribe.js +102 -0
- package/install.mjs +83 -0
- package/lib/config.js +87 -0
- package/lib/db.js +72 -0
- package/lib/emit.js +111 -0
- package/lib/errors.js +45 -0
- package/lib/index.cjs +20 -0
- package/lib/index.js +13 -0
- package/lib/paths.js +69 -0
- package/lib/poll.js +212 -0
- package/lib/register.js +164 -0
- package/lib/schema.sql +68 -0
- package/lib/sweep.js +71 -0
- package/lib/validate.js +156 -0
- package/package.json +56 -0
- package/scripts/postinstall.js +14 -0
- package/skills/wicked-bus/emit/SKILL.md +147 -0
- package/skills/wicked-bus/init/SKILL.md +94 -0
- package/skills/wicked-bus/naming/SKILL.md +151 -0
- package/skills/wicked-bus/query/SKILL.md +177 -0
- package/skills/wicked-bus/subscribe/SKILL.md +164 -0
package/lib/poll.js
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Event polling and acknowledgment.
|
|
3
|
+
* @module lib/poll
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { WBError } from './errors.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Match an event against a filter string.
|
|
10
|
+
* @param {string} eventType - The event_type to test
|
|
11
|
+
* @param {string} domain - The domain of the event
|
|
12
|
+
* @param {string} filterStr - Filter pattern, e.g. 'wicked.test.run.*@wicked-testing'
|
|
13
|
+
* @returns {boolean}
|
|
14
|
+
*/
|
|
15
|
+
export function matchesFilter(eventType, domain, filterStr) {
|
|
16
|
+
let typePattern, domainFilter;
|
|
17
|
+
const atIdx = filterStr.indexOf('@');
|
|
18
|
+
if (atIdx !== -1) {
|
|
19
|
+
typePattern = filterStr.slice(0, atIdx);
|
|
20
|
+
domainFilter = filterStr.slice(atIdx + 1);
|
|
21
|
+
} else {
|
|
22
|
+
typePattern = filterStr;
|
|
23
|
+
domainFilter = null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Domain check
|
|
27
|
+
if (domainFilter && domain !== domainFilter) return false;
|
|
28
|
+
|
|
29
|
+
// Catch-all (*@domain)
|
|
30
|
+
if (typePattern === '*') return true;
|
|
31
|
+
|
|
32
|
+
// Exact match
|
|
33
|
+
if (typePattern === eventType) return true;
|
|
34
|
+
|
|
35
|
+
// Single-level wildcard (prefix.*)
|
|
36
|
+
if (typePattern.endsWith('.*')) {
|
|
37
|
+
const prefix = typePattern.slice(0, -2);
|
|
38
|
+
if (eventType.startsWith(prefix + '.')) {
|
|
39
|
+
const remainder = eventType.slice(prefix.length + 1);
|
|
40
|
+
return !remainder.includes('.'); // single-level only
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Build SQL WHERE clauses from a filter string for optimized queries.
|
|
49
|
+
* @param {string} filterStr
|
|
50
|
+
* @returns {{ where: string, params: object }}
|
|
51
|
+
*/
|
|
52
|
+
function buildFilterSql(filterStr) {
|
|
53
|
+
let typePattern, domainFilter;
|
|
54
|
+
const atIdx = filterStr.indexOf('@');
|
|
55
|
+
if (atIdx !== -1) {
|
|
56
|
+
typePattern = filterStr.slice(0, atIdx);
|
|
57
|
+
domainFilter = filterStr.slice(atIdx + 1);
|
|
58
|
+
} else {
|
|
59
|
+
typePattern = filterStr;
|
|
60
|
+
domainFilter = null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const conditions = [];
|
|
64
|
+
const params = {};
|
|
65
|
+
|
|
66
|
+
// Domain filter
|
|
67
|
+
if (domainFilter) {
|
|
68
|
+
conditions.push('domain = :domain_filter');
|
|
69
|
+
params.domain_filter = domainFilter;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Type filter
|
|
73
|
+
if (typePattern === '*') {
|
|
74
|
+
// Catch-all: no type filter
|
|
75
|
+
} else if (typePattern.endsWith('.*')) {
|
|
76
|
+
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 + '.%.%';
|
|
81
|
+
} else {
|
|
82
|
+
// Exact match
|
|
83
|
+
conditions.push('event_type = :exact_type');
|
|
84
|
+
params.exact_type = typePattern;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
where: conditions.length > 0 ? conditions.join(' AND ') : '1=1',
|
|
89
|
+
params,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Poll for events from the given cursor position.
|
|
95
|
+
* @param {import('better-sqlite3').Database} db
|
|
96
|
+
* @param {string} cursorId
|
|
97
|
+
* @param {object} [options]
|
|
98
|
+
* @param {number} [options.batchSize=100]
|
|
99
|
+
* @returns {object[]} Array of event rows
|
|
100
|
+
*/
|
|
101
|
+
export function poll(db, cursorId, options = {}) {
|
|
102
|
+
const batchSize = options.batchSize || 100;
|
|
103
|
+
|
|
104
|
+
// Load cursor
|
|
105
|
+
const cursor = db.prepare(
|
|
106
|
+
'SELECT * FROM cursors WHERE cursor_id = ?'
|
|
107
|
+
).get(cursorId);
|
|
108
|
+
|
|
109
|
+
if (!cursor || cursor.deregistered_at != null) {
|
|
110
|
+
throw new WBError('WB-006', 'CURSOR_NOT_FOUND', {
|
|
111
|
+
message: 'Cursor not found or deregistered',
|
|
112
|
+
cursor_id: cursorId,
|
|
113
|
+
reason: 'cursor not found or deregistered',
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// WB-003 check: cursor behind oldest available row
|
|
118
|
+
const oldest = db.prepare('SELECT MIN(event_id) as min_id FROM events').get();
|
|
119
|
+
if (oldest && oldest.min_id != null) {
|
|
120
|
+
if (cursor.last_event_id < oldest.min_id - 1) {
|
|
121
|
+
throw new WBError('WB-003', 'CURSOR_BEHIND_TTL_WINDOW', {
|
|
122
|
+
message: 'Cursor is behind the TTL window; events have been swept',
|
|
123
|
+
cursor_last_event_id: cursor.last_event_id,
|
|
124
|
+
oldest_available_event_id: oldest.min_id,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Load subscription for filter
|
|
130
|
+
const sub = db.prepare(
|
|
131
|
+
'SELECT * FROM subscriptions WHERE subscription_id = ?'
|
|
132
|
+
).get(cursor.subscription_id);
|
|
133
|
+
|
|
134
|
+
if (!sub) {
|
|
135
|
+
throw new WBError('WB-006', 'CURSOR_NOT_FOUND', {
|
|
136
|
+
message: 'Subscription not found for cursor',
|
|
137
|
+
cursor_id: cursorId,
|
|
138
|
+
reason: 'subscription not found',
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const filter = sub.event_type_filter;
|
|
143
|
+
const { where, params } = buildFilterSql(filter);
|
|
144
|
+
const now = Date.now();
|
|
145
|
+
|
|
146
|
+
const sql = `
|
|
147
|
+
SELECT * FROM events
|
|
148
|
+
WHERE event_id > :last_event_id
|
|
149
|
+
AND expires_at > :now
|
|
150
|
+
AND ${where}
|
|
151
|
+
ORDER BY event_id ASC
|
|
152
|
+
LIMIT :batch_size
|
|
153
|
+
`;
|
|
154
|
+
|
|
155
|
+
const allParams = {
|
|
156
|
+
...params,
|
|
157
|
+
last_event_id: cursor.last_event_id,
|
|
158
|
+
now,
|
|
159
|
+
batch_size: batchSize,
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
return db.prepare(sql).all(allParams);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Acknowledge events up to the given event_id for a cursor.
|
|
167
|
+
* @param {import('better-sqlite3').Database} db
|
|
168
|
+
* @param {string} cursorId
|
|
169
|
+
* @param {number} lastEventId
|
|
170
|
+
* @returns {{ acked: boolean, cursor_id: string, last_event_id: number }}
|
|
171
|
+
*/
|
|
172
|
+
export function ack(db, cursorId, lastEventId) {
|
|
173
|
+
const now = Date.now();
|
|
174
|
+
|
|
175
|
+
// Check cursor exists and is active
|
|
176
|
+
const cursor = db.prepare(
|
|
177
|
+
'SELECT * FROM cursors WHERE cursor_id = ?'
|
|
178
|
+
).get(cursorId);
|
|
179
|
+
|
|
180
|
+
if (!cursor || cursor.deregistered_at != null) {
|
|
181
|
+
throw new WBError('WB-006', 'CURSOR_NOT_FOUND', {
|
|
182
|
+
message: 'Cursor not found or deregistered',
|
|
183
|
+
cursor_id: cursorId,
|
|
184
|
+
reason: 'cursor not found or deregistered',
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const update = db.prepare(`
|
|
189
|
+
UPDATE cursors
|
|
190
|
+
SET last_event_id = ?, acked_at = ?
|
|
191
|
+
WHERE cursor_id = ? AND deregistered_at IS NULL
|
|
192
|
+
`);
|
|
193
|
+
|
|
194
|
+
const txn = db.transaction(() => {
|
|
195
|
+
const result = update.run(lastEventId, now, cursorId);
|
|
196
|
+
if (result.changes === 0) {
|
|
197
|
+
throw new WBError('WB-006', 'CURSOR_NOT_FOUND', {
|
|
198
|
+
message: 'Cursor not found or deregistered',
|
|
199
|
+
cursor_id: cursorId,
|
|
200
|
+
reason: 'cursor not found or deregistered',
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
txn();
|
|
206
|
+
|
|
207
|
+
return {
|
|
208
|
+
acked: true,
|
|
209
|
+
cursor_id: cursorId,
|
|
210
|
+
last_event_id: lastEventId,
|
|
211
|
+
};
|
|
212
|
+
}
|
package/lib/register.js
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registration and deregistration of providers/subscribers.
|
|
3
|
+
* @module lib/register
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { mkdirSync, writeFileSync, unlinkSync } from 'node:fs';
|
|
9
|
+
import { resolveDataDir } from './paths.js';
|
|
10
|
+
import { WBError } from './errors.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Register a provider or subscriber.
|
|
14
|
+
* @param {import('better-sqlite3').Database} db
|
|
15
|
+
* @param {object} opts
|
|
16
|
+
* @param {string} opts.plugin - Plugin name
|
|
17
|
+
* @param {'provider'|'subscriber'} opts.role
|
|
18
|
+
* @param {string} opts.filter - Event type filter (for subscribers) or comma-separated event types (for providers)
|
|
19
|
+
* @param {string} [opts.schema_version] - Schema version (providers)
|
|
20
|
+
* @param {'oldest'|'latest'} [opts.cursor_init='latest'] - Cursor initialization (subscribers)
|
|
21
|
+
* @returns {object}
|
|
22
|
+
*/
|
|
23
|
+
export function register(db, opts) {
|
|
24
|
+
const subscriptionId = uuidv4();
|
|
25
|
+
const now = Date.now();
|
|
26
|
+
|
|
27
|
+
const insertSub = db.prepare(`
|
|
28
|
+
INSERT INTO subscriptions (
|
|
29
|
+
subscription_id, plugin, role, event_type_filter,
|
|
30
|
+
schema_version, registered_at
|
|
31
|
+
) VALUES (?, ?, ?, ?, ?, ?)
|
|
32
|
+
`);
|
|
33
|
+
|
|
34
|
+
insertSub.run(
|
|
35
|
+
subscriptionId,
|
|
36
|
+
opts.plugin,
|
|
37
|
+
opts.role,
|
|
38
|
+
opts.filter,
|
|
39
|
+
opts.schema_version || null,
|
|
40
|
+
now
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
const result = {
|
|
44
|
+
subscription_id: subscriptionId,
|
|
45
|
+
plugin: opts.plugin,
|
|
46
|
+
role: opts.role,
|
|
47
|
+
registered_at: now,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
if (opts.role === 'provider') {
|
|
51
|
+
// Write sidecar JSON
|
|
52
|
+
writeSidecar(opts.plugin, {
|
|
53
|
+
subscription_id: subscriptionId,
|
|
54
|
+
plugin: opts.plugin,
|
|
55
|
+
role: 'provider',
|
|
56
|
+
event_types: opts.filter.split(',').map(s => s.trim()),
|
|
57
|
+
schema_version: opts.schema_version || null,
|
|
58
|
+
registered_at: new Date(now).toISOString(),
|
|
59
|
+
registered_at_ms: now,
|
|
60
|
+
});
|
|
61
|
+
result.filter = opts.filter;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (opts.role === 'subscriber') {
|
|
65
|
+
// Create cursor
|
|
66
|
+
const cursorId = uuidv4();
|
|
67
|
+
let lastEventId = 0;
|
|
68
|
+
|
|
69
|
+
if (opts.cursor_init === 'latest') {
|
|
70
|
+
const row = db.prepare('SELECT MAX(event_id) as max_id FROM events').get();
|
|
71
|
+
lastEventId = row && row.max_id != null ? row.max_id : 0;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
db.prepare(`
|
|
75
|
+
INSERT INTO cursors (
|
|
76
|
+
cursor_id, subscription_id, last_event_id, created_at
|
|
77
|
+
) VALUES (?, ?, ?, ?)
|
|
78
|
+
`).run(cursorId, subscriptionId, lastEventId, now);
|
|
79
|
+
|
|
80
|
+
result.cursor_id = cursorId;
|
|
81
|
+
result.filter = opts.filter;
|
|
82
|
+
result.cursor_init = opts.cursor_init || 'latest';
|
|
83
|
+
result.last_event_id = lastEventId;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return result;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Deregister a subscription (soft delete).
|
|
91
|
+
* @param {import('better-sqlite3').Database} db
|
|
92
|
+
* @param {string} subscriptionId
|
|
93
|
+
* @returns {object}
|
|
94
|
+
*/
|
|
95
|
+
export function deregister(db, subscriptionId) {
|
|
96
|
+
const now = Date.now();
|
|
97
|
+
|
|
98
|
+
const sub = db.prepare(
|
|
99
|
+
'SELECT * FROM subscriptions WHERE subscription_id = ?'
|
|
100
|
+
).get(subscriptionId);
|
|
101
|
+
|
|
102
|
+
if (!sub) {
|
|
103
|
+
throw new WBError('WB-006', 'CURSOR_NOT_FOUND', {
|
|
104
|
+
message: `Subscription not found: ${subscriptionId}`,
|
|
105
|
+
subscription_id: subscriptionId,
|
|
106
|
+
reason: 'subscription not found',
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const txn = db.transaction(() => {
|
|
111
|
+
// Soft-delete the subscription
|
|
112
|
+
db.prepare(
|
|
113
|
+
'UPDATE subscriptions SET deregistered_at = ? WHERE subscription_id = ?'
|
|
114
|
+
).run(now, subscriptionId);
|
|
115
|
+
|
|
116
|
+
// Soft-delete associated cursors (for subscribers)
|
|
117
|
+
if (sub.role === 'subscriber') {
|
|
118
|
+
db.prepare(
|
|
119
|
+
'UPDATE cursors SET deregistered_at = ? WHERE subscription_id = ? AND deregistered_at IS NULL'
|
|
120
|
+
).run(now, subscriptionId);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Remove provider sidecar
|
|
124
|
+
if (sub.role === 'provider') {
|
|
125
|
+
removeSidecar(sub.plugin);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
txn();
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
deregistered: true,
|
|
133
|
+
subscription_id: subscriptionId,
|
|
134
|
+
deregistered_at: now,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Write a provider sidecar JSON file.
|
|
140
|
+
*/
|
|
141
|
+
function writeSidecar(plugin, data) {
|
|
142
|
+
try {
|
|
143
|
+
const dataDir = resolveDataDir();
|
|
144
|
+
const providersDir = join(dataDir, 'providers');
|
|
145
|
+
mkdirSync(providersDir, { recursive: true });
|
|
146
|
+
const sidecarPath = join(providersDir, `${plugin}.json`);
|
|
147
|
+
writeFileSync(sidecarPath, JSON.stringify(data, null, 2) + '\n', 'utf8');
|
|
148
|
+
} catch (_) {
|
|
149
|
+
// Sidecar write failure is non-fatal
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Remove a provider sidecar JSON file.
|
|
155
|
+
*/
|
|
156
|
+
function removeSidecar(plugin) {
|
|
157
|
+
try {
|
|
158
|
+
const dataDir = resolveDataDir();
|
|
159
|
+
const sidecarPath = join(dataDir, 'providers', `${plugin}.json`);
|
|
160
|
+
unlinkSync(sidecarPath);
|
|
161
|
+
} catch (_) {
|
|
162
|
+
// Sidecar removal failure is non-fatal
|
|
163
|
+
}
|
|
164
|
+
}
|
package/lib/schema.sql
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
PRAGMA journal_mode = WAL;
|
|
2
|
+
PRAGMA synchronous = NORMAL;
|
|
3
|
+
PRAGMA foreign_keys = ON;
|
|
4
|
+
PRAGMA busy_timeout = 5000;
|
|
5
|
+
|
|
6
|
+
-- events
|
|
7
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
8
|
+
event_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
9
|
+
event_type TEXT NOT NULL CHECK(length(event_type) <= 128),
|
|
10
|
+
domain TEXT NOT NULL CHECK(length(domain) <= 64),
|
|
11
|
+
subdomain TEXT NOT NULL DEFAULT '' CHECK(length(subdomain) <= 64),
|
|
12
|
+
payload TEXT NOT NULL,
|
|
13
|
+
schema_version TEXT NOT NULL DEFAULT '1.0.0',
|
|
14
|
+
idempotency_key TEXT NOT NULL UNIQUE,
|
|
15
|
+
emitted_at INTEGER NOT NULL,
|
|
16
|
+
expires_at INTEGER NOT NULL,
|
|
17
|
+
dedup_expires_at INTEGER NOT NULL,
|
|
18
|
+
metadata TEXT
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
CREATE INDEX IF NOT EXISTS idx_events_event_type ON events(event_type);
|
|
22
|
+
CREATE INDEX IF NOT EXISTS idx_events_domain ON events(domain);
|
|
23
|
+
CREATE INDEX IF NOT EXISTS idx_events_subdomain ON events(subdomain);
|
|
24
|
+
CREATE INDEX IF NOT EXISTS idx_events_type_domain ON events(event_type, domain);
|
|
25
|
+
CREATE INDEX IF NOT EXISTS idx_events_emitted_at ON events(emitted_at);
|
|
26
|
+
CREATE INDEX IF NOT EXISTS idx_events_expires_at ON events(expires_at);
|
|
27
|
+
CREATE INDEX IF NOT EXISTS idx_events_dedup_expires_at ON events(dedup_expires_at);
|
|
28
|
+
|
|
29
|
+
-- subscriptions
|
|
30
|
+
CREATE TABLE IF NOT EXISTS subscriptions (
|
|
31
|
+
subscription_id TEXT PRIMARY KEY,
|
|
32
|
+
plugin TEXT NOT NULL,
|
|
33
|
+
role TEXT NOT NULL CHECK(role IN ('provider','subscriber')),
|
|
34
|
+
event_type_filter TEXT NOT NULL,
|
|
35
|
+
schema_version TEXT,
|
|
36
|
+
registered_at INTEGER NOT NULL,
|
|
37
|
+
deregistered_at INTEGER,
|
|
38
|
+
health_check_interval_ms INTEGER DEFAULT 60000
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
CREATE INDEX IF NOT EXISTS idx_subscriptions_plugin ON subscriptions(plugin);
|
|
42
|
+
CREATE INDEX IF NOT EXISTS idx_subscriptions_active
|
|
43
|
+
ON subscriptions(plugin, role) WHERE deregistered_at IS NULL;
|
|
44
|
+
|
|
45
|
+
-- cursors
|
|
46
|
+
CREATE TABLE IF NOT EXISTS cursors (
|
|
47
|
+
cursor_id TEXT PRIMARY KEY,
|
|
48
|
+
subscription_id TEXT NOT NULL
|
|
49
|
+
REFERENCES subscriptions(subscription_id) ON DELETE RESTRICT,
|
|
50
|
+
last_event_id INTEGER NOT NULL DEFAULT 0,
|
|
51
|
+
acked_at INTEGER,
|
|
52
|
+
created_at INTEGER NOT NULL,
|
|
53
|
+
deregistered_at INTEGER
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
CREATE INDEX IF NOT EXISTS idx_cursors_subscription_id ON cursors(subscription_id);
|
|
57
|
+
CREATE INDEX IF NOT EXISTS idx_cursors_active
|
|
58
|
+
ON cursors(subscription_id) WHERE deregistered_at IS NULL;
|
|
59
|
+
|
|
60
|
+
-- schema_migrations
|
|
61
|
+
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
62
|
+
version INTEGER PRIMARY KEY,
|
|
63
|
+
applied_at INTEGER NOT NULL,
|
|
64
|
+
description TEXT
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
INSERT OR IGNORE INTO schema_migrations(version, applied_at, description)
|
|
68
|
+
VALUES (1, unixepoch() * 1000, 'initial schema');
|
package/lib/sweep.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TTL sweep -- deletes expired events, optionally archiving them first.
|
|
3
|
+
* @module lib/sweep
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Run a single sweep pass.
|
|
8
|
+
* @param {import('better-sqlite3').Database} db
|
|
9
|
+
* @param {object} config
|
|
10
|
+
* @returns {{ events_deleted: number }}
|
|
11
|
+
*/
|
|
12
|
+
export function runSweep(db, config) {
|
|
13
|
+
const now = Date.now();
|
|
14
|
+
|
|
15
|
+
const txn = db.transaction(() => {
|
|
16
|
+
if (config.archive_mode) {
|
|
17
|
+
// Create archive table if not exists
|
|
18
|
+
db.exec(`
|
|
19
|
+
CREATE TABLE IF NOT EXISTS events_archive (
|
|
20
|
+
event_id INTEGER PRIMARY KEY,
|
|
21
|
+
event_type TEXT NOT NULL,
|
|
22
|
+
domain TEXT NOT NULL,
|
|
23
|
+
subdomain TEXT NOT NULL DEFAULT '',
|
|
24
|
+
payload TEXT NOT NULL,
|
|
25
|
+
schema_version TEXT NOT NULL DEFAULT '1.0.0',
|
|
26
|
+
idempotency_key TEXT NOT NULL,
|
|
27
|
+
emitted_at INTEGER NOT NULL,
|
|
28
|
+
expires_at INTEGER NOT NULL,
|
|
29
|
+
dedup_expires_at INTEGER NOT NULL,
|
|
30
|
+
metadata TEXT
|
|
31
|
+
);
|
|
32
|
+
`);
|
|
33
|
+
|
|
34
|
+
// Copy to archive before deletion
|
|
35
|
+
db.prepare(`
|
|
36
|
+
INSERT OR IGNORE INTO events_archive
|
|
37
|
+
SELECT * FROM events WHERE dedup_expires_at < ?
|
|
38
|
+
`).run(now);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Delete expired events
|
|
42
|
+
const result = db.prepare(
|
|
43
|
+
'DELETE FROM events WHERE dedup_expires_at < ?'
|
|
44
|
+
).run(now);
|
|
45
|
+
|
|
46
|
+
return { events_deleted: result.changes };
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
return txn();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Start a background sweep interval.
|
|
54
|
+
* @param {import('better-sqlite3').Database} db
|
|
55
|
+
* @param {object} config
|
|
56
|
+
* @returns {NodeJS.Timeout|null} The interval handle, or null if sweep is disabled.
|
|
57
|
+
*/
|
|
58
|
+
export function startSweep(db, config) {
|
|
59
|
+
if (!config.sweep_interval_minutes || config.sweep_interval_minutes === 0) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const intervalMs = config.sweep_interval_minutes * 60_000;
|
|
64
|
+
return setInterval(() => {
|
|
65
|
+
try {
|
|
66
|
+
runSweep(db, config);
|
|
67
|
+
} catch (_) {
|
|
68
|
+
// Sweep errors are non-fatal
|
|
69
|
+
}
|
|
70
|
+
}, intervalMs);
|
|
71
|
+
}
|
package/lib/validate.js
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Event validation.
|
|
3
|
+
* @module lib/validate
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { WBError } from './errors.js';
|
|
7
|
+
|
|
8
|
+
// Event type: wicked.<segments> where segments are lowercase alphanum/underscore separated by dots
|
|
9
|
+
const EVENT_TYPE_REGEX = /^wicked\.[a-z0-9_]+(\.[a-z0-9_]+)*$/;
|
|
10
|
+
const SEMVER_REGEX = /^\d+\.\d+\.\d+$/;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Validate an event object before writing.
|
|
14
|
+
* @param {object} event - { event_type, domain, payload, schema_version?, subdomain?, metadata? }
|
|
15
|
+
* @param {object} config - Merged config with max_payload_bytes
|
|
16
|
+
* @throws {WBError} WB-001 or WB-005
|
|
17
|
+
*/
|
|
18
|
+
export function validateEvent(event, config) {
|
|
19
|
+
const missing = [];
|
|
20
|
+
const received = Object.keys(event || {});
|
|
21
|
+
|
|
22
|
+
if (!event || typeof event !== 'object') {
|
|
23
|
+
throw new WBError('WB-001', 'INVALID_EVENT_SCHEMA', {
|
|
24
|
+
message: 'Event must be an object',
|
|
25
|
+
received_fields: [],
|
|
26
|
+
missing_fields: ['event_type', 'domain', 'payload'],
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Check required fields
|
|
31
|
+
if (!event.event_type) missing.push('event_type');
|
|
32
|
+
if (!event.domain) missing.push('domain');
|
|
33
|
+
if (event.payload === undefined || event.payload === null) missing.push('payload');
|
|
34
|
+
|
|
35
|
+
if (missing.length > 0) {
|
|
36
|
+
throw new WBError('WB-001', 'INVALID_EVENT_SCHEMA', {
|
|
37
|
+
message: `Missing required fields: ${missing.join(', ')}`,
|
|
38
|
+
received_fields: received,
|
|
39
|
+
missing_fields: missing,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// event_type validation
|
|
44
|
+
if (typeof event.event_type !== 'string') {
|
|
45
|
+
throw new WBError('WB-001', 'INVALID_EVENT_SCHEMA', {
|
|
46
|
+
message: 'event_type must be a string',
|
|
47
|
+
received_fields: received,
|
|
48
|
+
violation: 'event_type must be a string',
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
if (event.event_type.length > 128) {
|
|
52
|
+
throw new WBError('WB-001', 'INVALID_EVENT_SCHEMA', {
|
|
53
|
+
message: `event_type exceeds 128 chars (got ${event.event_type.length})`,
|
|
54
|
+
received_fields: received,
|
|
55
|
+
violation: 'event_type exceeds 128 chars',
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
if (!EVENT_TYPE_REGEX.test(event.event_type)) {
|
|
59
|
+
throw new WBError('WB-001', 'INVALID_EVENT_SCHEMA', {
|
|
60
|
+
message: `event_type does not match pattern: ${EVENT_TYPE_REGEX}`,
|
|
61
|
+
received_fields: received,
|
|
62
|
+
violation: `event_type must match ${EVENT_TYPE_REGEX}`,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// domain validation
|
|
67
|
+
if (typeof event.domain !== 'string') {
|
|
68
|
+
throw new WBError('WB-001', 'INVALID_EVENT_SCHEMA', {
|
|
69
|
+
message: 'domain must be a string',
|
|
70
|
+
received_fields: received,
|
|
71
|
+
violation: 'domain must be a string',
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
if (event.domain.length > 64) {
|
|
75
|
+
throw new WBError('WB-001', 'INVALID_EVENT_SCHEMA', {
|
|
76
|
+
message: `domain exceeds 64 chars (got ${event.domain.length})`,
|
|
77
|
+
received_fields: received,
|
|
78
|
+
violation: 'domain exceeds 64 chars',
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// subdomain validation (optional, defaults to '')
|
|
83
|
+
if (event.subdomain !== undefined && event.subdomain !== null) {
|
|
84
|
+
if (typeof event.subdomain !== 'string') {
|
|
85
|
+
throw new WBError('WB-001', 'INVALID_EVENT_SCHEMA', {
|
|
86
|
+
message: 'subdomain must be a string',
|
|
87
|
+
received_fields: received,
|
|
88
|
+
violation: 'subdomain must be a string',
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
if (event.subdomain.length > 64) {
|
|
92
|
+
throw new WBError('WB-001', 'INVALID_EVENT_SCHEMA', {
|
|
93
|
+
message: `subdomain exceeds 64 chars (got ${event.subdomain.length})`,
|
|
94
|
+
received_fields: received,
|
|
95
|
+
violation: 'subdomain exceeds 64 chars',
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// payload validation
|
|
101
|
+
if (typeof event.payload !== 'object' || event.payload === null || Array.isArray(event.payload)) {
|
|
102
|
+
// If it's a string, try to parse it
|
|
103
|
+
if (typeof event.payload === 'string') {
|
|
104
|
+
try {
|
|
105
|
+
const parsed = JSON.parse(event.payload);
|
|
106
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
107
|
+
throw new Error('not an object');
|
|
108
|
+
}
|
|
109
|
+
} catch (_) {
|
|
110
|
+
throw new WBError('WB-001', 'INVALID_EVENT_SCHEMA', {
|
|
111
|
+
message: 'payload must be a valid JSON object',
|
|
112
|
+
received_fields: received,
|
|
113
|
+
violation: 'payload must be a valid JSON object',
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
} else {
|
|
117
|
+
throw new WBError('WB-001', 'INVALID_EVENT_SCHEMA', {
|
|
118
|
+
message: 'payload must be a valid JSON object',
|
|
119
|
+
received_fields: received,
|
|
120
|
+
violation: 'payload must be a valid JSON object',
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Payload size check
|
|
126
|
+
const payloadStr = typeof event.payload === 'string'
|
|
127
|
+
? event.payload
|
|
128
|
+
: JSON.stringify(event.payload);
|
|
129
|
+
const payloadBytes = Buffer.byteLength(payloadStr, 'utf8');
|
|
130
|
+
if (payloadBytes > config.max_payload_bytes) {
|
|
131
|
+
throw new WBError('WB-001', 'INVALID_EVENT_SCHEMA', {
|
|
132
|
+
message: `Payload size ${payloadBytes} bytes exceeds max_payload_bytes (${config.max_payload_bytes})`,
|
|
133
|
+
received_fields: received,
|
|
134
|
+
violation: 'payload exceeds max_payload_bytes',
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// schema_version validation (if present)
|
|
139
|
+
if (event.schema_version !== undefined && event.schema_version !== null) {
|
|
140
|
+
if (typeof event.schema_version !== 'string' || !SEMVER_REGEX.test(event.schema_version)) {
|
|
141
|
+
throw new WBError('WB-001', 'INVALID_EVENT_SCHEMA', {
|
|
142
|
+
message: 'schema_version must be a valid semver string (e.g. 1.0.0)',
|
|
143
|
+
received_fields: received,
|
|
144
|
+
violation: 'invalid schema_version format',
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
const major = parseInt(event.schema_version.split('.')[0], 10);
|
|
148
|
+
if (major > 1) {
|
|
149
|
+
throw new WBError('WB-005', 'SCHEMA_VERSION_UNSUPPORTED', {
|
|
150
|
+
message: `schema_version ${event.schema_version} is not supported (max 1.x)`,
|
|
151
|
+
declared: event.schema_version,
|
|
152
|
+
max_supported: '1.x',
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|