@objectstack/plugin-webhooks 17.0.0-rc.0 → 17.0.0-rc.2

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.
Files changed (43) hide show
  1. package/CHANGELOG.md +632 -0
  2. package/dist/{chunk-6EPMRZ7I.js → chunk-3QGZLM3T.js} +8 -3
  3. package/dist/chunk-3QGZLM3T.js.map +1 -0
  4. package/dist/{chunk-QUTQSOQC.cjs → chunk-JQUVS5KK.cjs} +8 -3
  5. package/dist/chunk-JQUVS5KK.cjs.map +1 -0
  6. package/dist/index.cjs +182 -63
  7. package/dist/index.cjs.map +1 -1
  8. package/dist/index.d.cts +37 -1
  9. package/dist/index.d.ts +37 -1
  10. package/dist/index.js +138 -19
  11. package/dist/index.js.map +1 -1
  12. package/dist/schema.cjs +2 -2
  13. package/dist/schema.d.cts +721 -548
  14. package/dist/schema.d.ts +721 -548
  15. package/dist/schema.js +1 -1
  16. package/dist/{translations-Y3CXWOTM.js → translations-2VGD4XRF.js} +17 -9
  17. package/dist/translations-2VGD4XRF.js.map +1 -0
  18. package/dist/{translations-CBQRUIS5.cjs → translations-BWS57U2V.cjs} +17 -9
  19. package/dist/translations-BWS57U2V.cjs.map +1 -0
  20. package/package.json +12 -6
  21. package/.turbo/turbo-build.log +0 -36
  22. package/dist/chunk-6EPMRZ7I.js.map +0 -1
  23. package/dist/chunk-QUTQSOQC.cjs.map +0 -1
  24. package/dist/translations-CBQRUIS5.cjs.map +0 -1
  25. package/dist/translations-Y3CXWOTM.js.map +0 -1
  26. package/scripts/i18n-extract.config.ts +0 -34
  27. package/src/auto-enqueuer.test.ts +0 -431
  28. package/src/auto-enqueuer.ts +0 -383
  29. package/src/bootstrap-declared-webhooks.test.ts +0 -283
  30. package/src/bootstrap-declared-webhooks.ts +0 -205
  31. package/src/index.ts +0 -29
  32. package/src/schema.ts +0 -21
  33. package/src/sys-webhook.object.ts +0 -231
  34. package/src/translations/bundle-ownership.test.ts +0 -41
  35. package/src/translations/en.objects.generated.ts +0 -101
  36. package/src/translations/es-ES.objects.generated.ts +0 -101
  37. package/src/translations/index.ts +0 -23
  38. package/src/translations/ja-JP.objects.generated.ts +0 -101
  39. package/src/translations/zh-CN.objects.generated.ts +0 -101
  40. package/src/webhook-outbox-plugin.ts +0 -305
  41. package/src/webhook-provenance.ts +0 -86
  42. package/tsconfig.json +0 -10
  43. package/tsup.config.ts +0 -14
@@ -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
- });