@objectstack/plugin-webhooks 16.1.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 +626 -0
- package/dist/{chunk-RBCYKJVL.js → chunk-6EPMRZ7I.js} +28 -2
- package/dist/chunk-6EPMRZ7I.js.map +1 -0
- package/dist/{chunk-PTMJ5BLL.cjs → chunk-QUTQSOQC.cjs} +28 -2
- package/dist/chunk-QUTQSOQC.cjs.map +1 -0
- package/dist/index.cjs +250 -33
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +25 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +231 -14
- package/dist/index.js.map +1 -1
- package/dist/schema.cjs +2 -2
- package/dist/schema.d.cts +515 -304
- package/dist/schema.d.ts +515 -304
- package/dist/schema.js +1 -1
- package/dist/translations-CBQRUIS5.cjs +383 -0
- package/dist/translations-CBQRUIS5.cjs.map +1 -0
- package/dist/translations-Y3CXWOTM.js +383 -0
- package/dist/translations-Y3CXWOTM.js.map +1 -0
- package/package.json +11 -5
- package/.turbo/turbo-build.log +0 -36
- package/dist/chunk-PTMJ5BLL.cjs.map +0 -1
- package/dist/chunk-RBCYKJVL.js.map +0 -1
- package/dist/translations-EPJYANXJ.js +0 -727
- package/dist/translations-EPJYANXJ.js.map +0 -1
- package/dist/translations-L2JAQDJB.cjs +0 -727
- package/dist/translations-L2JAQDJB.cjs.map +0 -1
- package/scripts/i18n-extract.config.ts +0 -32
- package/src/auto-enqueuer.test.ts +0 -431
- package/src/auto-enqueuer.ts +0 -383
- package/src/index.ts +0 -29
- package/src/schema.ts +0 -21
- package/src/sys-webhook.object.ts +0 -193
- package/src/translations/en.objects.generated.ts +0 -187
- package/src/translations/es-ES.objects.generated.ts +0 -187
- package/src/translations/index.ts +0 -23
- package/src/translations/ja-JP.objects.generated.ts +0 -187
- package/src/translations/zh-CN.objects.generated.ts +0 -187
- package/src/webhook-outbox-plugin.ts +0 -259
- 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']);
|
package/src/index.ts
DELETED
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* @objectstack/plugin-webhooks
|
|
5
|
-
*
|
|
6
|
-
* Webhook fan-out on top of the shared outbound-HTTP delivery substrate
|
|
7
|
-
* (ADR-0018 M3). The durable outbox, cluster-coordinated dispatcher, retry /
|
|
8
|
-
* backoff / dead-letter, and retention all live in
|
|
9
|
-
* `@objectstack/service-messaging` (`sys_http_delivery` + `HttpDispatcher`).
|
|
10
|
-
*
|
|
11
|
-
* This package ships only the webhook-specific concerns:
|
|
12
|
-
* - the `sys_webhook` configuration object,
|
|
13
|
-
* - the {@link AutoEnqueuer} that turns `data.record.*` events into outbox
|
|
14
|
-
* rows (`source: 'webhook'`),
|
|
15
|
-
* - the redeliver admin endpoint.
|
|
16
|
-
*
|
|
17
|
-
* **Requires** `MessagingServicePlugin` (a foundational, always-on capability).
|
|
18
|
-
*
|
|
19
|
-
* ## Subpath exports
|
|
20
|
-
* - `@objectstack/plugin-webhooks/schema` — `SysWebhook` object schema.
|
|
21
|
-
*/
|
|
22
|
-
|
|
23
|
-
export {
|
|
24
|
-
WebhookOutboxPlugin,
|
|
25
|
-
type WebhookOutboxPluginOptions,
|
|
26
|
-
} from './webhook-outbox-plugin.js';
|
|
27
|
-
|
|
28
|
-
export { AutoEnqueuer, type AutoEnqueuerOptions, type HttpEnqueueFn } from './auto-enqueuer.js';
|
|
29
|
-
export { SysWebhook } from './sys-webhook.object.js';
|
package/src/schema.ts
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Public schema subpath: `@objectstack/plugin-webhooks/schema`.
|
|
5
|
-
*
|
|
6
|
-
* Thin re-export barrel kept stable across refactors. The object definition
|
|
7
|
-
* lives in `sys-webhook.object.ts` (matching the `*.object.ts` convention used
|
|
8
|
-
* everywhere else in the monorepo for `sys_*` schemas).
|
|
9
|
-
*
|
|
10
|
-
* `sys_webhook` moved here from `@objectstack/platform-objects` per ADR-0029
|
|
11
|
-
* (K2.a) so this plugin owns its configuration object. Delivery telemetry is no
|
|
12
|
-
* longer a webhook-owned object: post-ADR-0018 M3 deliveries are rows in the
|
|
13
|
-
* shared `sys_http_delivery` outbox owned by `@objectstack/service-messaging`.
|
|
14
|
-
*
|
|
15
|
-
* Note: callers that just need the runtime should import from the package root
|
|
16
|
-
* (`@objectstack/plugin-webhooks`), which auto-registers `sys_webhook` via the
|
|
17
|
-
* plugin manifest. This subpath exists for read-only inspection from a
|
|
18
|
-
* different runtime.
|
|
19
|
-
*/
|
|
20
|
-
|
|
21
|
-
export { SysWebhook } from './sys-webhook.object.js';
|
|
@@ -1,193 +0,0 @@
|
|
|
1
|
-
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
-
|
|
3
|
-
import { ObjectSchema, Field } from '@objectstack/spec/data';
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* sys_webhook — Outbound HTTP integration configuration (runtime).
|
|
7
|
-
*
|
|
8
|
-
* Persists a single {@link Webhook} envelope per row so administrators
|
|
9
|
-
* can author, enable/disable, and edit webhook subscriptions from the
|
|
10
|
-
* Studio UI without code changes. The canonical Zod schema for the
|
|
11
|
-
* `definition_json` envelope lives at `@objectstack/spec/automation/webhook`.
|
|
12
|
-
*
|
|
13
|
-
* One row per `name`. The automation runtime
|
|
14
|
-
* (`@objectstack/service-automation`, built-in `http_request` node) loads
|
|
15
|
-
* active rows on boot + on `sys_webhook:changed` events, registers
|
|
16
|
-
* `afterInsert` / `afterUpdate` / `afterDelete` listeners for the
|
|
17
|
-
* targeted object, and dispatches outbound HTTP calls when matching
|
|
18
|
-
* record events fire.
|
|
19
|
-
*
|
|
20
|
-
* Ownership (ADR-0029 K2.a): this object is **owned by
|
|
21
|
-
* `@objectstack/plugin-webhooks`** — the plugin that consumes these rows —
|
|
22
|
-
* alongside its sibling `sys_webhook_delivery`. It used to live in the
|
|
23
|
-
* `@objectstack/platform-objects` monolith and be imported here; the
|
|
24
|
-
* definition now lives with its owner so the plugin ships both data and
|
|
25
|
-
* behavior as one unit.
|
|
26
|
-
*
|
|
27
|
-
* Platform-wide on purpose: every project (standalone, single-tenant,
|
|
28
|
-
* cloud) can integrate with external systems (Slack, Stripe, internal
|
|
29
|
-
* services) the same way.
|
|
30
|
-
*
|
|
31
|
-
* @namespace sys
|
|
32
|
-
*/
|
|
33
|
-
export const SysWebhook = ObjectSchema.create({
|
|
34
|
-
name: 'sys_webhook',
|
|
35
|
-
label: 'Webhook',
|
|
36
|
-
pluralLabel: 'Webhooks',
|
|
37
|
-
icon: 'webhook',
|
|
38
|
-
isSystem: true,
|
|
39
|
-
managedBy: 'config',
|
|
40
|
-
// Authoring a webhook from the UI requires a structured form for the
|
|
41
|
-
// headers / auth / retry / payload blocks — the generic JSON textarea
|
|
42
|
-
// is acceptable as a v1 until a dedicated builder lands. Re-enable
|
|
43
|
-
// create/edit/delete so admins can at least toggle `active` and edit
|
|
44
|
-
// simple URL/method fields without round-tripping through code.
|
|
45
|
-
userActions: { create: true, edit: true, delete: true, import: false },
|
|
46
|
-
description: 'Outbound HTTP webhook subscription. Authored via defineWebhook() in code or the Studio editor; executed by the HTTP connector plugin.',
|
|
47
|
-
displayNameField: 'name',
|
|
48
|
-
nameField: 'name', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField)
|
|
49
|
-
titleFormat: '{label}',
|
|
50
|
-
highlightFields: ['name', 'object_name', 'url', 'active', 'updated_at'],
|
|
51
|
-
|
|
52
|
-
listViews: {
|
|
53
|
-
active: {
|
|
54
|
-
type: 'grid',
|
|
55
|
-
name: 'active',
|
|
56
|
-
label: 'Active',
|
|
57
|
-
data: { provider: 'object', object: 'sys_webhook' },
|
|
58
|
-
columns: ['label', 'object_name', 'url', 'method', 'active', 'updated_at'],
|
|
59
|
-
filter: [{ field: 'active', operator: 'equals', value: true }],
|
|
60
|
-
sort: [{ field: 'label', order: 'asc' }],
|
|
61
|
-
pagination: { pageSize: 50 },
|
|
62
|
-
},
|
|
63
|
-
inactive: {
|
|
64
|
-
type: 'grid',
|
|
65
|
-
name: 'inactive',
|
|
66
|
-
label: 'Inactive',
|
|
67
|
-
data: { provider: 'object', object: 'sys_webhook' },
|
|
68
|
-
columns: ['label', 'object_name', 'url', 'method', 'active', 'updated_at'],
|
|
69
|
-
filter: [{ field: 'active', operator: 'equals', value: false }],
|
|
70
|
-
sort: [{ field: 'label', order: 'asc' }],
|
|
71
|
-
pagination: { pageSize: 50 },
|
|
72
|
-
},
|
|
73
|
-
by_object: {
|
|
74
|
-
type: 'grid',
|
|
75
|
-
name: 'by_object',
|
|
76
|
-
label: 'By Object',
|
|
77
|
-
data: { provider: 'object', object: 'sys_webhook' },
|
|
78
|
-
columns: ['object_name', 'label', 'url', 'active', 'updated_at'],
|
|
79
|
-
sort: [{ field: 'object_name', order: 'asc' }, { field: 'label', order: 'asc' }],
|
|
80
|
-
grouping: { fields: [{ field: 'object_name', order: 'asc', collapsed: false }] },
|
|
81
|
-
pagination: { pageSize: 100 },
|
|
82
|
-
},
|
|
83
|
-
all_webhooks: {
|
|
84
|
-
type: 'grid',
|
|
85
|
-
name: 'all_webhooks',
|
|
86
|
-
label: 'All',
|
|
87
|
-
data: { provider: 'object', object: 'sys_webhook' },
|
|
88
|
-
columns: ['label', 'object_name', 'url', 'method', 'active', 'updated_at'],
|
|
89
|
-
sort: [{ field: 'label', order: 'asc' }],
|
|
90
|
-
pagination: { pageSize: 50 },
|
|
91
|
-
},
|
|
92
|
-
},
|
|
93
|
-
|
|
94
|
-
fields: {
|
|
95
|
-
id: Field.text({ label: 'Webhook ID', required: true, readonly: true, group: 'System' }),
|
|
96
|
-
|
|
97
|
-
name: Field.text({
|
|
98
|
-
label: 'Name',
|
|
99
|
-
required: true,
|
|
100
|
-
maxLength: 100,
|
|
101
|
-
description: 'Unique snake_case name — referenced in logs and audit',
|
|
102
|
-
group: 'Definition',
|
|
103
|
-
}),
|
|
104
|
-
|
|
105
|
-
label: Field.text({
|
|
106
|
-
label: 'Display Label',
|
|
107
|
-
required: false,
|
|
108
|
-
maxLength: 200,
|
|
109
|
-
group: 'Definition',
|
|
110
|
-
}),
|
|
111
|
-
|
|
112
|
-
object_name: Field.text({
|
|
113
|
-
label: 'Object',
|
|
114
|
-
required: false,
|
|
115
|
-
maxLength: 100,
|
|
116
|
-
// Object picker (same widget as sys_sharing_rule) instead of a free-text
|
|
117
|
-
// machine name. Falls back to a text input when the widget is unavailable.
|
|
118
|
-
widget: 'object-ref',
|
|
119
|
-
description: 'Short object name whose record events (create/update/delete) fire this webhook',
|
|
120
|
-
group: 'Definition',
|
|
121
|
-
}),
|
|
122
|
-
|
|
123
|
-
triggers: Field.select(
|
|
124
|
-
['create', 'update', 'delete'],
|
|
125
|
-
{
|
|
126
|
-
label: 'Triggers',
|
|
127
|
-
required: false,
|
|
128
|
-
// Multi-select instead of a hand-typed comma-separated string. Stored as
|
|
129
|
-
// an array; the auto-enqueuer parser also tolerates the legacy
|
|
130
|
-
// comma-separated / JSON-string forms so existing rows keep working.
|
|
131
|
-
multiple: true,
|
|
132
|
-
description: 'Record events that fire this webhook',
|
|
133
|
-
group: 'Definition',
|
|
134
|
-
},
|
|
135
|
-
),
|
|
136
|
-
|
|
137
|
-
url: Field.text({
|
|
138
|
-
label: 'Target URL',
|
|
139
|
-
required: true,
|
|
140
|
-
maxLength: 2048,
|
|
141
|
-
description: 'External endpoint that receives the POST',
|
|
142
|
-
group: 'Definition',
|
|
143
|
-
}),
|
|
144
|
-
|
|
145
|
-
method: Field.select(
|
|
146
|
-
['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
|
|
147
|
-
{
|
|
148
|
-
label: 'HTTP Method',
|
|
149
|
-
required: true,
|
|
150
|
-
// Select instead of free text. Option values are lowercased by the
|
|
151
|
-
// Field.select helper (get/post/…); the auto-enqueuer upper-cases the
|
|
152
|
-
// resolved method before delivery, so existing 'POST' rows and the
|
|
153
|
-
// lowercase option values both normalise correctly.
|
|
154
|
-
defaultValue: 'post',
|
|
155
|
-
description: 'HTTP method used for the callback request',
|
|
156
|
-
group: 'Definition',
|
|
157
|
-
},
|
|
158
|
-
),
|
|
159
|
-
|
|
160
|
-
description: Field.textarea({ label: 'Description', required: false, group: 'Definition' }),
|
|
161
|
-
|
|
162
|
-
active: Field.boolean({
|
|
163
|
-
label: 'Active',
|
|
164
|
-
required: true,
|
|
165
|
-
defaultValue: true,
|
|
166
|
-
description: 'Inactive webhooks are skipped by the dispatcher',
|
|
167
|
-
group: 'Definition',
|
|
168
|
-
}),
|
|
169
|
-
|
|
170
|
-
definition_json: Field.textarea({
|
|
171
|
-
label: 'Definition',
|
|
172
|
-
required: true,
|
|
173
|
-
description: 'Serialised Webhook JSON (see @objectstack/spec/automation/webhook) — full headers/auth/retry/payload config',
|
|
174
|
-
group: 'Definition',
|
|
175
|
-
}),
|
|
176
|
-
|
|
177
|
-
created_at: Field.datetime({
|
|
178
|
-
label: 'Created At',
|
|
179
|
-
required: true,
|
|
180
|
-
defaultValue: 'NOW()',
|
|
181
|
-
readonly: true,
|
|
182
|
-
group: 'System',
|
|
183
|
-
}),
|
|
184
|
-
|
|
185
|
-
updated_at: Field.datetime({ label: 'Updated At', required: false, group: 'System' }),
|
|
186
|
-
},
|
|
187
|
-
|
|
188
|
-
indexes: [
|
|
189
|
-
{ fields: ['name'], unique: true },
|
|
190
|
-
{ fields: ['object_name'] },
|
|
191
|
-
{ fields: ['active', 'object_name'] },
|
|
192
|
-
],
|
|
193
|
-
});
|