@objectstack/plugin-webhooks 17.0.0-rc.0 → 17.0.0-rc.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/CHANGELOG.md +379 -0
- package/dist/index.cjs +31 -12
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +10 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +31 -12
- package/dist/index.js.map +1 -1
- package/dist/schema.d.cts +190 -175
- package/dist/schema.d.ts +190 -175
- package/package.json +11 -5
- package/.turbo/turbo-build.log +0 -36
- package/scripts/i18n-extract.config.ts +0 -34
- package/src/auto-enqueuer.test.ts +0 -431
- package/src/auto-enqueuer.ts +0 -383
- package/src/bootstrap-declared-webhooks.test.ts +0 -283
- package/src/bootstrap-declared-webhooks.ts +0 -205
- package/src/index.ts +0 -29
- package/src/schema.ts +0 -21
- package/src/sys-webhook.object.ts +0 -231
- package/src/translations/bundle-ownership.test.ts +0 -41
- package/src/translations/en.objects.generated.ts +0 -101
- package/src/translations/es-ES.objects.generated.ts +0 -101
- package/src/translations/index.ts +0 -23
- package/src/translations/ja-JP.objects.generated.ts +0 -101
- package/src/translations/zh-CN.objects.generated.ts +0 -101
- package/src/webhook-outbox-plugin.ts +0 -305
- package/src/webhook-provenance.ts +0 -86
- package/tsconfig.json +0 -10
- package/tsup.config.ts +0 -14
package/src/auto-enqueuer.ts
DELETED
|
@@ -1,383 +0,0 @@
|
|
|
1
|
-
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
-
|
|
3
|
-
import type { IDataEngine, IRealtimeService, RealtimeEventPayload } from '@objectstack/spec/contracts';
|
|
4
|
-
import type { EnqueueHttpInput } from '@objectstack/service-messaging';
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Enqueue callback into the shared `service-messaging` HTTP outbox (ADR-0018 M3).
|
|
8
|
-
* The plugin supplies one bound to `messaging.enqueueHttp(...)`; webhooks no
|
|
9
|
-
* longer own a delivery outbox/dispatcher — they share the generic substrate.
|
|
10
|
-
*/
|
|
11
|
-
export type HttpEnqueueFn = (input: EnqueueHttpInput) => Promise<string>;
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* Optional logger interface (subset of console / kernel logger).
|
|
15
|
-
*/
|
|
16
|
-
interface OptionalLogger {
|
|
17
|
-
info?(msg: string, meta?: unknown): void;
|
|
18
|
-
warn?(msg: string, meta?: unknown): void;
|
|
19
|
-
debug?(msg: string, meta?: unknown): void;
|
|
20
|
-
error?(msg: string, err?: unknown, meta?: unknown): void;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Per-row subscription cached in memory. Mirrors a subset of the
|
|
25
|
-
* `sys_webhook` object — only what the auto-enqueuer needs to match an
|
|
26
|
-
* event and build an `EnqueueInput`.
|
|
27
|
-
*/
|
|
28
|
-
interface CachedSubscription {
|
|
29
|
-
id: string;
|
|
30
|
-
name: string;
|
|
31
|
-
objectName: string | undefined; // empty = matches all objects
|
|
32
|
-
triggers: Set<'create' | 'update' | 'delete'>;
|
|
33
|
-
url: string;
|
|
34
|
-
method?: string;
|
|
35
|
-
headers?: Record<string, string>;
|
|
36
|
-
secret?: string;
|
|
37
|
-
timeoutMs?: number;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export interface AutoEnqueuerOptions {
|
|
41
|
-
/**
|
|
42
|
-
* Object name holding webhook subscriptions. Defaults to `sys_webhook`,
|
|
43
|
-
* the platform-objects schema authored in apps.
|
|
44
|
-
*/
|
|
45
|
-
subscriptionsObject?: string;
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* Periodic full-cache refresh interval (ms). Belt-and-braces in case
|
|
49
|
-
* the subscription-change event is missed. Default 60s.
|
|
50
|
-
*/
|
|
51
|
-
refreshIntervalMs?: number;
|
|
52
|
-
|
|
53
|
-
logger?: OptionalLogger;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* Bridge between `IRealtimeService` (`data.record.*` events emitted by
|
|
58
|
-
* the engine) and `IWebhookOutbox` (durable delivery rows the dispatcher
|
|
59
|
-
* picks up).
|
|
60
|
-
*
|
|
61
|
-
* ## Why a separate class
|
|
62
|
-
* Keeps `WebhookOutboxPlugin` lean: the plugin wires services, this
|
|
63
|
-
* class owns the runtime fan-out logic + subscription cache.
|
|
64
|
-
*
|
|
65
|
-
* ## Hot path
|
|
66
|
-
* Every `engine.insert/update/delete` fires a `data.record.*` event.
|
|
67
|
-
* The handler:
|
|
68
|
-
* 1. Looks up matching subscriptions in an in-memory `Map<object, sub[]>`
|
|
69
|
-
* — O(1) per event, no DB hit on the write path.
|
|
70
|
-
* 2. Calls `outbox.enqueue()` fire-and-forget for each match. The
|
|
71
|
-
* enqueue itself is a single INSERT, which runs *after* the user's
|
|
72
|
-
* request has already returned.
|
|
73
|
-
*
|
|
74
|
-
* Net cost on the write path: one synchronous Map lookup (~microseconds).
|
|
75
|
-
*
|
|
76
|
-
* ## Cache freshness
|
|
77
|
-
* The cache is rebuilt:
|
|
78
|
-
* 1. Once on `start()`.
|
|
79
|
-
* 2. On every `data.record.{created,updated,deleted}` event whose
|
|
80
|
-
* object is `sys_webhook` (self-healing — when a user toggles a
|
|
81
|
-
* webhook, the handler refreshes the cache before returning).
|
|
82
|
-
* 3. Periodically (default 60s) as belt-and-braces.
|
|
83
|
-
*
|
|
84
|
-
* For multi-node clusters this is *eventually consistent* — node B may
|
|
85
|
-
* not see node A's edit for up to one cycle. That's acceptable for
|
|
86
|
-
* webhook configuration changes (humans don't expect millisecond
|
|
87
|
-
* propagation) and matches Hasura's behaviour.
|
|
88
|
-
*
|
|
89
|
-
* ## Determinism
|
|
90
|
-
* `eventId` is computed from `${object}:${recordId}:${type}:${timestamp}`
|
|
91
|
-
* so the outbox dedup index catches duplicates that could arise from
|
|
92
|
-
* upstream replay or buggy producers — and is stable across nodes.
|
|
93
|
-
*/
|
|
94
|
-
export class AutoEnqueuer {
|
|
95
|
-
private readonly subscriptions = new Map<string, CachedSubscription[]>();
|
|
96
|
-
private readonly subscriptionsObject: string;
|
|
97
|
-
private readonly refreshIntervalMs: number;
|
|
98
|
-
private readonly logger: OptionalLogger;
|
|
99
|
-
private subId: string | undefined;
|
|
100
|
-
private subIdSelfHeal: string | undefined;
|
|
101
|
-
private refreshTimer: ReturnType<typeof setInterval> | undefined;
|
|
102
|
-
private running = false;
|
|
103
|
-
private refreshing: Promise<void> | undefined;
|
|
104
|
-
|
|
105
|
-
constructor(
|
|
106
|
-
private readonly engine: IDataEngine,
|
|
107
|
-
private readonly realtime: IRealtimeService,
|
|
108
|
-
private readonly enqueue: HttpEnqueueFn,
|
|
109
|
-
opts: AutoEnqueuerOptions = {},
|
|
110
|
-
) {
|
|
111
|
-
this.subscriptionsObject = opts.subscriptionsObject ?? 'sys_webhook';
|
|
112
|
-
this.refreshIntervalMs = opts.refreshIntervalMs ?? 60_000;
|
|
113
|
-
this.logger = opts.logger ?? {};
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/**
|
|
117
|
-
* Load the subscription cache and start listening for events.
|
|
118
|
-
*/
|
|
119
|
-
async start(): Promise<void> {
|
|
120
|
-
if (this.running) return;
|
|
121
|
-
this.running = true;
|
|
122
|
-
|
|
123
|
-
await this.refresh();
|
|
124
|
-
|
|
125
|
-
// Main subscription: every data event → match → enqueue.
|
|
126
|
-
this.subId = await this.realtime.subscribe(
|
|
127
|
-
'webhook-auto-enqueuer',
|
|
128
|
-
(event) => this.handleEvent(event),
|
|
129
|
-
);
|
|
130
|
-
|
|
131
|
-
// Self-healing: any change to sys_webhook refreshes the cache.
|
|
132
|
-
this.subIdSelfHeal = await this.realtime.subscribe(
|
|
133
|
-
'webhook-auto-enqueuer-self-heal',
|
|
134
|
-
(event) => this.handleSelfHealEvent(event),
|
|
135
|
-
{ object: this.subscriptionsObject },
|
|
136
|
-
);
|
|
137
|
-
|
|
138
|
-
if (this.refreshIntervalMs > 0) {
|
|
139
|
-
this.refreshTimer = setInterval(() => {
|
|
140
|
-
this.refresh().catch((err) =>
|
|
141
|
-
this.logger.warn?.('[webhook-auto-enqueuer] periodic refresh failed', err),
|
|
142
|
-
);
|
|
143
|
-
}, this.refreshIntervalMs);
|
|
144
|
-
// Don't keep the process alive solely for this timer.
|
|
145
|
-
this.refreshTimer.unref?.();
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
async stop(): Promise<void> {
|
|
150
|
-
if (!this.running) return;
|
|
151
|
-
this.running = false;
|
|
152
|
-
if (this.subId) await this.realtime.unsubscribe(this.subId);
|
|
153
|
-
if (this.subIdSelfHeal) await this.realtime.unsubscribe(this.subIdSelfHeal);
|
|
154
|
-
if (this.refreshTimer) clearInterval(this.refreshTimer);
|
|
155
|
-
this.subId = undefined;
|
|
156
|
-
this.subIdSelfHeal = undefined;
|
|
157
|
-
this.refreshTimer = undefined;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
/**
|
|
161
|
-
* Force-refresh the subscription cache from storage. Concurrent
|
|
162
|
-
* callers share a single in-flight refresh.
|
|
163
|
-
*/
|
|
164
|
-
async refresh(): Promise<void> {
|
|
165
|
-
if (this.refreshing) return this.refreshing;
|
|
166
|
-
this.refreshing = this.doRefresh().finally(() => {
|
|
167
|
-
this.refreshing = undefined;
|
|
168
|
-
});
|
|
169
|
-
return this.refreshing;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
private async doRefresh(): Promise<void> {
|
|
173
|
-
let rows: any[];
|
|
174
|
-
try {
|
|
175
|
-
rows = await this.engine.find(this.subscriptionsObject, {
|
|
176
|
-
where: { active: true },
|
|
177
|
-
});
|
|
178
|
-
} catch (err) {
|
|
179
|
-
this.logger.warn?.(
|
|
180
|
-
`[webhook-auto-enqueuer] failed to load ${this.subscriptionsObject}`,
|
|
181
|
-
err,
|
|
182
|
-
);
|
|
183
|
-
return;
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
const next = new Map<string, CachedSubscription[]>();
|
|
187
|
-
for (const row of rows) {
|
|
188
|
-
const sub = this.parseRow(row);
|
|
189
|
-
if (!sub) continue;
|
|
190
|
-
// Empty objectName == "any object" → indexed under '*'.
|
|
191
|
-
const key = sub.objectName ?? '*';
|
|
192
|
-
const arr = next.get(key) ?? [];
|
|
193
|
-
arr.push(sub);
|
|
194
|
-
next.set(key, arr);
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
this.subscriptions.clear();
|
|
198
|
-
for (const [k, v] of next) this.subscriptions.set(k, v);
|
|
199
|
-
|
|
200
|
-
this.logger.debug?.('[webhook-auto-enqueuer] cache refreshed', {
|
|
201
|
-
objects: this.subscriptions.size,
|
|
202
|
-
rows: rows.length,
|
|
203
|
-
});
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
private parseRow(row: any): CachedSubscription | null {
|
|
207
|
-
if (!row?.id || !row?.url) return null;
|
|
208
|
-
// `triggers` is now authored as a multi-select (stored as an array), but
|
|
209
|
-
// legacy rows stored a comma-separated string (and some drivers hand a
|
|
210
|
-
// JSON-encoded array back as a string). Accept all three shapes so a
|
|
211
|
-
// schema change never silently drops a subscription's events.
|
|
212
|
-
const rawTriggers = row.triggers;
|
|
213
|
-
let triggerList: string[];
|
|
214
|
-
if (Array.isArray(rawTriggers)) {
|
|
215
|
-
triggerList = rawTriggers.map((t) => String(t));
|
|
216
|
-
} else {
|
|
217
|
-
const s = String(rawTriggers ?? '').trim();
|
|
218
|
-
if (s.startsWith('[')) {
|
|
219
|
-
try {
|
|
220
|
-
const parsed = JSON.parse(s);
|
|
221
|
-
triggerList = Array.isArray(parsed) ? parsed.map((t) => String(t)) : [s];
|
|
222
|
-
} catch {
|
|
223
|
-
triggerList = s.split(',');
|
|
224
|
-
}
|
|
225
|
-
} else {
|
|
226
|
-
triggerList = s.split(',');
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
const normalized = triggerList.map((t) => t.trim().toLowerCase()).filter(Boolean);
|
|
230
|
-
// [#3196] Drop (and warn about) any trigger the enqueuer can't map to an
|
|
231
|
-
// emitted record event — e.g. a legacy `sys_webhook` row authored with
|
|
232
|
-
// the now-removed `undelete`/`api` values, which would otherwise sit in
|
|
233
|
-
// the cache matching nothing. A loud drift-guard so a dead trigger can't
|
|
234
|
-
// silently no-op again.
|
|
235
|
-
const unknown = normalized.filter((t) => !DISPATCHABLE_WEBHOOK_TRIGGERS.has(t));
|
|
236
|
-
if (unknown.length > 0) {
|
|
237
|
-
this.logger.warn?.(
|
|
238
|
-
`[webhook-auto-enqueuer] webhook '${(row.name as string) ?? row.id}' declares trigger(s) the engine never emits: ` +
|
|
239
|
-
`${unknown.join(', ')} — ignored. Dispatchable triggers: create, update, delete.`,
|
|
240
|
-
{ id: row.id, unknown },
|
|
241
|
-
);
|
|
242
|
-
}
|
|
243
|
-
const triggers = new Set(
|
|
244
|
-
normalized.filter((t) => DISPATCHABLE_WEBHOOK_TRIGGERS.has(t)) as Array<'create' | 'update' | 'delete'>,
|
|
245
|
-
);
|
|
246
|
-
if (triggers.size === 0) {
|
|
247
|
-
// No dispatchable triggers (or a manual-only webhook with none) —
|
|
248
|
-
// skip auto-enqueue.
|
|
249
|
-
return null;
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
// The "definition_json" field carries advanced config (headers,
|
|
253
|
-
// secret, timeout); attempt a best-effort parse. Fall back to
|
|
254
|
-
// top-level fields where present.
|
|
255
|
-
let defn: Record<string, any> = {};
|
|
256
|
-
if (typeof row.definition_json === 'string' && row.definition_json.length > 0) {
|
|
257
|
-
try {
|
|
258
|
-
defn = JSON.parse(row.definition_json) ?? {};
|
|
259
|
-
} catch {
|
|
260
|
-
defn = {};
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
return {
|
|
265
|
-
id: row.id as string,
|
|
266
|
-
name: (row.name as string) ?? row.id,
|
|
267
|
-
objectName: row.object_name ? String(row.object_name) : undefined,
|
|
268
|
-
triggers,
|
|
269
|
-
url: String(row.url),
|
|
270
|
-
// Method is authored via a select whose option values are lowercased
|
|
271
|
-
// (get/post/…); upper-case here so delivery uses a canonical HTTP
|
|
272
|
-
// method regardless of whether the row was authored before or after
|
|
273
|
-
// the select change (legacy rows stored 'POST').
|
|
274
|
-
method: String(row.method ?? defn.method ?? 'POST').toUpperCase(),
|
|
275
|
-
headers: defn.headers,
|
|
276
|
-
secret: defn.secret,
|
|
277
|
-
timeoutMs: defn.timeoutMs,
|
|
278
|
-
};
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
/**
|
|
282
|
-
* Handler for the firehose subscription.
|
|
283
|
-
*
|
|
284
|
-
* NOTE: we intentionally `void` the inner enqueue() so the realtime
|
|
285
|
-
* publisher (and therefore the user's request) is never blocked on
|
|
286
|
-
* webhook persistence.
|
|
287
|
-
*/
|
|
288
|
-
private handleEvent(event: RealtimeEventPayload): void {
|
|
289
|
-
if (!event.type?.startsWith('data.record.')) return;
|
|
290
|
-
if (!event.object) return;
|
|
291
|
-
if (event.object === this.subscriptionsObject) return; // self-heal handles its own
|
|
292
|
-
|
|
293
|
-
const action = event.type.slice('data.record.'.length) as
|
|
294
|
-
| 'created' | 'updated' | 'deleted' | string;
|
|
295
|
-
const trigger = mapActionToTrigger(action);
|
|
296
|
-
if (!trigger) return;
|
|
297
|
-
|
|
298
|
-
const subs = [
|
|
299
|
-
...(this.subscriptions.get(event.object) ?? []),
|
|
300
|
-
...(this.subscriptions.get('*') ?? []),
|
|
301
|
-
];
|
|
302
|
-
if (subs.length === 0) return;
|
|
303
|
-
|
|
304
|
-
const payload = event.payload ?? {};
|
|
305
|
-
const recordId =
|
|
306
|
-
(payload as any).recordId ??
|
|
307
|
-
(payload as any).id ??
|
|
308
|
-
(payload as any).after?.id ??
|
|
309
|
-
(payload as any).before?.id ??
|
|
310
|
-
'unknown';
|
|
311
|
-
|
|
312
|
-
// Deterministic eventId — same input on any node → same id.
|
|
313
|
-
// Includes timestamp so two distinct updates to the same record
|
|
314
|
-
// don't accidentally dedup.
|
|
315
|
-
const eventId = `${event.object}:${recordId}:${action}:${event.timestamp}`;
|
|
316
|
-
|
|
317
|
-
for (const sub of subs) {
|
|
318
|
-
if (!sub.triggers.has(trigger)) continue;
|
|
319
|
-
|
|
320
|
-
// Fire-and-forget — never await on the hot path. Map the webhook
|
|
321
|
-
// delivery onto the generic HTTP-outbox shape (ADR-0018 M3):
|
|
322
|
-
// - source 'webhook' + dedupKey '<webhookId>:<eventId>' preserves
|
|
323
|
-
// the old (event_id, webhook_id) at-most-once enqueue;
|
|
324
|
-
// - refId = webhookId keeps per-webhook partition affinity / ordering;
|
|
325
|
-
// - label = event type → X-Objectstack-Event header.
|
|
326
|
-
void this.enqueue({
|
|
327
|
-
source: 'webhook',
|
|
328
|
-
refId: sub.id,
|
|
329
|
-
dedupKey: `${sub.id}:${eventId}`,
|
|
330
|
-
label: event.type,
|
|
331
|
-
url: sub.url,
|
|
332
|
-
method: sub.method,
|
|
333
|
-
headers: sub.headers,
|
|
334
|
-
signingSecret: sub.secret,
|
|
335
|
-
timeoutMs: sub.timeoutMs,
|
|
336
|
-
payload: {
|
|
337
|
-
object: event.object,
|
|
338
|
-
recordId,
|
|
339
|
-
action,
|
|
340
|
-
timestamp: event.timestamp,
|
|
341
|
-
...payload,
|
|
342
|
-
},
|
|
343
|
-
}).catch((err) =>
|
|
344
|
-
this.logger.warn?.('[webhook-auto-enqueuer] enqueue failed', {
|
|
345
|
-
webhook: sub.name,
|
|
346
|
-
eventId,
|
|
347
|
-
err: (err as Error)?.message ?? err,
|
|
348
|
-
}),
|
|
349
|
-
);
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
private handleSelfHealEvent(event: RealtimeEventPayload): void {
|
|
354
|
-
if (event.object !== this.subscriptionsObject) return;
|
|
355
|
-
if (!event.type?.startsWith('data.record.')) return;
|
|
356
|
-
this.refresh().catch((err) =>
|
|
357
|
-
this.logger.warn?.('[webhook-auto-enqueuer] self-heal refresh failed', err),
|
|
358
|
-
);
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
/** Test / admin accessor. */
|
|
362
|
-
snapshot(): ReadonlyMap<string, ReadonlyArray<CachedSubscription>> {
|
|
363
|
-
return this.subscriptions;
|
|
364
|
-
}
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
function mapActionToTrigger(
|
|
368
|
-
action: string,
|
|
369
|
-
): 'create' | 'update' | 'delete' | null {
|
|
370
|
-
switch (action) {
|
|
371
|
-
case 'created':
|
|
372
|
-
return 'create';
|
|
373
|
-
case 'updated':
|
|
374
|
-
return 'update';
|
|
375
|
-
case 'deleted':
|
|
376
|
-
return 'delete';
|
|
377
|
-
default:
|
|
378
|
-
return null;
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
/** The trigger values the enqueuer can actually map from an emitted record event. */
|
|
383
|
-
const DISPATCHABLE_WEBHOOK_TRIGGERS: ReadonlySet<string> = new Set(['create', 'update', 'delete']);
|
|
@@ -1,283 +0,0 @@
|
|
|
1
|
-
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* bootstrapDeclaredWebhooks — the ingestion bridge that closes #3461.
|
|
5
|
-
*
|
|
6
|
-
* Verifies that stack/connector-declared `webhook` metadata (spec shape:
|
|
7
|
-
* `object` / `isActive`) is materialized into `sys_webhook` data rows
|
|
8
|
-
* (`object_name` / `active` / `definition_json`), idempotently and without
|
|
9
|
-
* clobbering admin edits — and that the dispatcher then sees those rows.
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import { describe, expect, it, vi } from 'vitest';
|
|
13
|
-
import { AutoEnqueuer, type HttpEnqueueFn } from './auto-enqueuer.js';
|
|
14
|
-
import { bootstrapDeclaredWebhooks } from './bootstrap-declared-webhooks.js';
|
|
15
|
-
import { bindWebhookProvenanceStamp } from './webhook-provenance.js';
|
|
16
|
-
|
|
17
|
-
// ---------------------------------------------------------------------------
|
|
18
|
-
// Fakes
|
|
19
|
-
// ---------------------------------------------------------------------------
|
|
20
|
-
|
|
21
|
-
interface HookEntry {
|
|
22
|
-
event: string;
|
|
23
|
-
handler: (ctx: any) => any;
|
|
24
|
-
object?: string;
|
|
25
|
-
packageId?: string;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* A small engine fake that mirrors the real ObjectQL surface the bridge and
|
|
30
|
-
* provenance hook touch: `find({ filter | where })`, `insert`, update-by-id (the
|
|
31
|
-
* patch carries `id`, no `where`), a `_registry.listItems(type)` for declared
|
|
32
|
-
* metadata, and `beforeUpdate` hooks that run inside `update()`.
|
|
33
|
-
*/
|
|
34
|
-
class FakeEngine {
|
|
35
|
-
rows: Record<string, any[]> = {};
|
|
36
|
-
private hooks: HookEntry[] = [];
|
|
37
|
-
private declared: Record<string, any[]> = {};
|
|
38
|
-
|
|
39
|
-
constructor(seed?: { rows?: Record<string, any[]>; declared?: Record<string, any[]> }) {
|
|
40
|
-
if (seed?.rows) this.rows = JSON.parse(JSON.stringify(seed.rows));
|
|
41
|
-
if (seed?.declared) this.declared = JSON.parse(JSON.stringify(seed.declared));
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
// Declared-metadata registry (where manifest decomposition parks stack.webhooks).
|
|
45
|
-
get _registry() {
|
|
46
|
-
return {
|
|
47
|
-
listItems: (type: string) => (this.declared[type] ?? []).map((content) => ({ content })),
|
|
48
|
-
};
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
private matches(row: any, cond?: Record<string, any>): boolean {
|
|
52
|
-
if (!cond) return true;
|
|
53
|
-
return Object.entries(cond).every(([k, v]) => row[k] === v);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
async find(name: string, q?: any): Promise<any[]> {
|
|
57
|
-
const all = this.rows[name] ?? [];
|
|
58
|
-
const cond = q?.filter ?? q?.where;
|
|
59
|
-
const out = all.filter((r) => this.matches(r, cond));
|
|
60
|
-
return typeof q?.limit === 'number' ? out.slice(0, q.limit) : out;
|
|
61
|
-
}
|
|
62
|
-
async findOne(name: string, q?: any): Promise<any> {
|
|
63
|
-
return (await this.find(name, q))[0] ?? null;
|
|
64
|
-
}
|
|
65
|
-
async insert(name: string, data: any): Promise<any> {
|
|
66
|
-
const arr = (this.rows[name] = this.rows[name] ?? []);
|
|
67
|
-
arr.push({ ...data });
|
|
68
|
-
return data;
|
|
69
|
-
}
|
|
70
|
-
async update(name: string, data: any, opts?: any): Promise<any> {
|
|
71
|
-
// Run beforeUpdate hooks (the provenance stamp lives here).
|
|
72
|
-
const id = data?.id ?? opts?.where?.id;
|
|
73
|
-
const ctx = { input: { id, data }, session: opts?.context };
|
|
74
|
-
for (const h of this.hooks) {
|
|
75
|
-
if (h.event === 'beforeUpdate' && (!h.object || h.object === name)) {
|
|
76
|
-
await h.handler(ctx);
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
const arr = this.rows[name] ?? [];
|
|
80
|
-
const cond = opts?.where ?? (id ? { id } : undefined);
|
|
81
|
-
for (const r of arr) {
|
|
82
|
-
if (this.matches(r, cond)) Object.assign(r, data);
|
|
83
|
-
}
|
|
84
|
-
return { affected: 0 };
|
|
85
|
-
}
|
|
86
|
-
async delete(): Promise<any> {
|
|
87
|
-
return { affected: 0 };
|
|
88
|
-
}
|
|
89
|
-
async count(name: string): Promise<number> {
|
|
90
|
-
return (this.rows[name] ?? []).length;
|
|
91
|
-
}
|
|
92
|
-
async aggregate(): Promise<any[]> {
|
|
93
|
-
return [];
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
registerHook(event: string, handler: (ctx: any) => any, options?: Record<string, any>): void {
|
|
97
|
-
this.hooks.push({ event, handler, object: options?.object, packageId: options?.packageId });
|
|
98
|
-
}
|
|
99
|
-
unregisterHooksByPackage(packageId: string): number {
|
|
100
|
-
const before = this.hooks.length;
|
|
101
|
-
this.hooks = this.hooks.filter((h) => h.packageId !== packageId);
|
|
102
|
-
return before - this.hooks.length;
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
class FakeRealtime {
|
|
107
|
-
private subs = new Map<string, { handler: any; opts?: any }>();
|
|
108
|
-
private n = 0;
|
|
109
|
-
async publish(event: any): Promise<void> {
|
|
110
|
-
for (const sub of this.subs.values()) {
|
|
111
|
-
const o = sub.opts ?? {};
|
|
112
|
-
if (o.object && event.object !== o.object) continue;
|
|
113
|
-
await sub.handler(event);
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
async subscribe(_channel: string, handler: any, opts?: any): Promise<string> {
|
|
117
|
-
const id = `s-${++this.n}`;
|
|
118
|
-
this.subs.set(id, { handler, opts });
|
|
119
|
-
return id;
|
|
120
|
-
}
|
|
121
|
-
async unsubscribe(id: string): Promise<void> {
|
|
122
|
-
this.subs.delete(id);
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
const ADMIN_CTX = { isSystem: false, positions: [], permissions: [] };
|
|
127
|
-
|
|
128
|
-
function declaredWebhook(over: Record<string, any> = {}): any {
|
|
129
|
-
return {
|
|
130
|
-
name: 'task_changed',
|
|
131
|
-
label: 'Task Changed',
|
|
132
|
-
object: 'showcase_task',
|
|
133
|
-
triggers: ['create', 'update'],
|
|
134
|
-
url: 'https://hooks.example/task',
|
|
135
|
-
method: 'POST',
|
|
136
|
-
isActive: true,
|
|
137
|
-
...over,
|
|
138
|
-
};
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
async function flush() {
|
|
142
|
-
await new Promise((r) => setTimeout(r, 0));
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
// ---------------------------------------------------------------------------
|
|
146
|
-
// Tests
|
|
147
|
-
// ---------------------------------------------------------------------------
|
|
148
|
-
|
|
149
|
-
describe('bootstrapDeclaredWebhooks', () => {
|
|
150
|
-
it('materializes a declared webhook into a sys_webhook row (object→object_name, isActive→active)', async () => {
|
|
151
|
-
const engine = new FakeEngine({ declared: { webhook: [declaredWebhook()] } });
|
|
152
|
-
const res = await bootstrapDeclaredWebhooks(engine as any, null);
|
|
153
|
-
|
|
154
|
-
expect(res).toEqual({ seeded: 1, skipped: 0 });
|
|
155
|
-
const rows = engine.rows['sys_webhook'];
|
|
156
|
-
expect(rows).toHaveLength(1);
|
|
157
|
-
const row = rows[0];
|
|
158
|
-
expect(row.name).toBe('task_changed');
|
|
159
|
-
expect(row.object_name).toBe('showcase_task'); // object → object_name
|
|
160
|
-
expect(row.active).toBe(true); // isActive → active
|
|
161
|
-
expect(row.method).toBe('post'); // lowercased to match the select options
|
|
162
|
-
expect(row.managed_by).toBe('package');
|
|
163
|
-
expect(row.customized).toBe(false);
|
|
164
|
-
// Full validated envelope stashed for the enqueuer's advanced-config read.
|
|
165
|
-
const defn = JSON.parse(row.definition_json);
|
|
166
|
-
expect(defn.object).toBe('showcase_task');
|
|
167
|
-
expect(defn.timeoutMs).toBe(30000); // default filled by WebhookSchema.parse
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
it('maps isActive:false → active:false so a placeholder webhook ships inactive', async () => {
|
|
171
|
-
const engine = new FakeEngine({ declared: { webhook: [declaredWebhook({ isActive: false })] } });
|
|
172
|
-
await bootstrapDeclaredWebhooks(engine as any, null);
|
|
173
|
-
expect(engine.rows['sys_webhook'][0].active).toBe(false);
|
|
174
|
-
});
|
|
175
|
-
|
|
176
|
-
it('is idempotent — a second boot updates in place, never duplicates', async () => {
|
|
177
|
-
const engine = new FakeEngine({ declared: { webhook: [declaredWebhook()] } });
|
|
178
|
-
await bootstrapDeclaredWebhooks(engine as any, null);
|
|
179
|
-
await bootstrapDeclaredWebhooks(engine as any, null);
|
|
180
|
-
expect(engine.rows['sys_webhook']).toHaveLength(1);
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
it('propagates a declared change to a pristine (non-customized) row', async () => {
|
|
184
|
-
const engine = new FakeEngine({ declared: { webhook: [declaredWebhook()] } });
|
|
185
|
-
await bootstrapDeclaredWebhooks(engine as any, null);
|
|
186
|
-
|
|
187
|
-
engine['declared'].webhook = [declaredWebhook({ url: 'https://hooks.example/task-v2' })];
|
|
188
|
-
await bootstrapDeclaredWebhooks(engine as any, null);
|
|
189
|
-
|
|
190
|
-
expect(engine.rows['sys_webhook']).toHaveLength(1);
|
|
191
|
-
expect(engine.rows['sys_webhook'][0].url).toBe('https://hooks.example/task-v2');
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
it('seed-not-clobber: an admin edit (customized) survives the next boot', async () => {
|
|
195
|
-
const engine = new FakeEngine({ declared: { webhook: [declaredWebhook()] } });
|
|
196
|
-
bindWebhookProvenanceStamp(engine as any);
|
|
197
|
-
await bootstrapDeclaredWebhooks(engine as any, null);
|
|
198
|
-
|
|
199
|
-
// Admin deactivates the noisy webhook through the CRUD door (non-system).
|
|
200
|
-
const id = engine.rows['sys_webhook'][0].id;
|
|
201
|
-
await engine.update('sys_webhook', { id, active: false }, { context: ADMIN_CTX });
|
|
202
|
-
expect(engine.rows['sys_webhook'][0].customized).toBe(true); // hook stamped it
|
|
203
|
-
|
|
204
|
-
// Redeploy re-runs the seeder — the declared row is still active:true, but
|
|
205
|
-
// the admin's active:false must win.
|
|
206
|
-
await bootstrapDeclaredWebhooks(engine as any, null);
|
|
207
|
-
expect(engine.rows['sys_webhook'][0].active).toBe(false);
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
it('never overwrites an admin-authored row that collides by name', async () => {
|
|
211
|
-
const engine = new FakeEngine({
|
|
212
|
-
rows: {
|
|
213
|
-
sys_webhook: [
|
|
214
|
-
{ id: 'admin-1', name: 'task_changed', url: 'https://admin.example', active: true, managed_by: 'admin', customized: false },
|
|
215
|
-
],
|
|
216
|
-
},
|
|
217
|
-
declared: { webhook: [declaredWebhook()] },
|
|
218
|
-
});
|
|
219
|
-
const res = await bootstrapDeclaredWebhooks(engine as any, null);
|
|
220
|
-
expect(res).toEqual({ seeded: 0, skipped: 1 });
|
|
221
|
-
expect(engine.rows['sys_webhook']).toHaveLength(1);
|
|
222
|
-
expect(engine.rows['sys_webhook'][0].url).toBe('https://admin.example'); // untouched
|
|
223
|
-
});
|
|
224
|
-
|
|
225
|
-
it('skips an invalid declared webhook (bad URL) with a warning, without crashing boot', async () => {
|
|
226
|
-
const warn = vi.fn();
|
|
227
|
-
const engine = new FakeEngine({
|
|
228
|
-
declared: { webhook: [declaredWebhook({ name: 'good' }), declaredWebhook({ name: 'bad', url: 'not-a-url' })] },
|
|
229
|
-
});
|
|
230
|
-
const res = await bootstrapDeclaredWebhooks(engine as any, null, { warn });
|
|
231
|
-
|
|
232
|
-
expect(res.seeded).toBe(1); // the good one still lands
|
|
233
|
-
expect(res.skipped).toBe(1);
|
|
234
|
-
expect(engine.rows['sys_webhook'].map((r) => r.name)).toEqual(['good']);
|
|
235
|
-
expect(warn).toHaveBeenCalledWith(expect.stringContaining('failed validation'), expect.objectContaining({ name: 'bad' }));
|
|
236
|
-
});
|
|
237
|
-
|
|
238
|
-
it('is a no-op when nothing is declared', async () => {
|
|
239
|
-
const engine = new FakeEngine();
|
|
240
|
-
const res = await bootstrapDeclaredWebhooks(engine as any, null);
|
|
241
|
-
expect(res).toEqual({ seeded: 0, skipped: 0 });
|
|
242
|
-
expect(engine.rows['sys_webhook']).toBeUndefined();
|
|
243
|
-
});
|
|
244
|
-
|
|
245
|
-
it('end-to-end: a declared webhook, once materialized, dispatches on a matching data event', async () => {
|
|
246
|
-
const engine = new FakeEngine({
|
|
247
|
-
declared: {
|
|
248
|
-
webhook: [
|
|
249
|
-
declaredWebhook({
|
|
250
|
-
triggers: ['create'],
|
|
251
|
-
secret: 'shh',
|
|
252
|
-
headers: { 'X-Env': 'prod' },
|
|
253
|
-
}),
|
|
254
|
-
],
|
|
255
|
-
},
|
|
256
|
-
});
|
|
257
|
-
await bootstrapDeclaredWebhooks(engine as any, null);
|
|
258
|
-
|
|
259
|
-
const realtime = new FakeRealtime();
|
|
260
|
-
const calls: any[] = [];
|
|
261
|
-
const enqueue: HttpEnqueueFn = async (input) => {
|
|
262
|
-
calls.push(input);
|
|
263
|
-
return 'id';
|
|
264
|
-
};
|
|
265
|
-
const ae = new AutoEnqueuer(engine as any, realtime as any, enqueue, { refreshIntervalMs: 0 });
|
|
266
|
-
await ae.start();
|
|
267
|
-
|
|
268
|
-
await realtime.publish({
|
|
269
|
-
type: 'data.record.created',
|
|
270
|
-
object: 'showcase_task',
|
|
271
|
-
payload: { recordId: 't-1' },
|
|
272
|
-
timestamp: '2026-05-24T00:00:00.000Z',
|
|
273
|
-
});
|
|
274
|
-
await flush();
|
|
275
|
-
|
|
276
|
-
expect(calls).toHaveLength(1);
|
|
277
|
-
expect(calls[0].url).toBe('https://hooks.example/task');
|
|
278
|
-
// headers + secret came from the definition_json envelope the bridge wrote.
|
|
279
|
-
expect(calls[0].signingSecret).toBe('shh');
|
|
280
|
-
expect(calls[0].headers).toEqual({ 'X-Env': 'prod' });
|
|
281
|
-
await ae.stop();
|
|
282
|
-
});
|
|
283
|
-
});
|