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/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
+ }
@@ -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
+ }
@@ -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
+ }