@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.
Files changed (41) hide show
  1. package/CHANGELOG.md +626 -0
  2. package/dist/{chunk-RBCYKJVL.js → chunk-6EPMRZ7I.js} +28 -2
  3. package/dist/chunk-6EPMRZ7I.js.map +1 -0
  4. package/dist/{chunk-PTMJ5BLL.cjs → chunk-QUTQSOQC.cjs} +28 -2
  5. package/dist/chunk-QUTQSOQC.cjs.map +1 -0
  6. package/dist/index.cjs +250 -33
  7. package/dist/index.cjs.map +1 -1
  8. package/dist/index.d.cts +25 -0
  9. package/dist/index.d.ts +25 -0
  10. package/dist/index.js +231 -14
  11. package/dist/index.js.map +1 -1
  12. package/dist/schema.cjs +2 -2
  13. package/dist/schema.d.cts +515 -304
  14. package/dist/schema.d.ts +515 -304
  15. package/dist/schema.js +1 -1
  16. package/dist/translations-CBQRUIS5.cjs +383 -0
  17. package/dist/translations-CBQRUIS5.cjs.map +1 -0
  18. package/dist/translations-Y3CXWOTM.js +383 -0
  19. package/dist/translations-Y3CXWOTM.js.map +1 -0
  20. package/package.json +11 -5
  21. package/.turbo/turbo-build.log +0 -36
  22. package/dist/chunk-PTMJ5BLL.cjs.map +0 -1
  23. package/dist/chunk-RBCYKJVL.js.map +0 -1
  24. package/dist/translations-EPJYANXJ.js +0 -727
  25. package/dist/translations-EPJYANXJ.js.map +0 -1
  26. package/dist/translations-L2JAQDJB.cjs +0 -727
  27. package/dist/translations-L2JAQDJB.cjs.map +0 -1
  28. package/scripts/i18n-extract.config.ts +0 -32
  29. package/src/auto-enqueuer.test.ts +0 -431
  30. package/src/auto-enqueuer.ts +0 -383
  31. package/src/index.ts +0 -29
  32. package/src/schema.ts +0 -21
  33. package/src/sys-webhook.object.ts +0 -193
  34. package/src/translations/en.objects.generated.ts +0 -187
  35. package/src/translations/es-ES.objects.generated.ts +0 -187
  36. package/src/translations/index.ts +0 -23
  37. package/src/translations/ja-JP.objects.generated.ts +0 -187
  38. package/src/translations/zh-CN.objects.generated.ts +0 -187
  39. package/src/webhook-outbox-plugin.ts +0 -259
  40. package/tsconfig.json +0 -10
  41. 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']);
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
- });