@objectstack/plugin-webhooks 5.1.0 → 5.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/.turbo/turbo-build.log +35 -13
  2. package/CHANGELOG.md +9 -37
  3. package/dist/chunk-JN76ZRWN.js +164 -0
  4. package/dist/chunk-JN76ZRWN.js.map +1 -0
  5. package/dist/chunk-M4M5FWIH.cjs +15 -0
  6. package/dist/chunk-M4M5FWIH.cjs.map +1 -0
  7. package/dist/chunk-NYSUNT6X.js +15 -0
  8. package/dist/chunk-NYSUNT6X.js.map +1 -0
  9. package/dist/chunk-OW7ESXOK.cjs +164 -0
  10. package/dist/chunk-OW7ESXOK.cjs.map +1 -0
  11. package/dist/index.cjs +747 -0
  12. package/dist/index.cjs.map +1 -0
  13. package/dist/index.d.cts +455 -0
  14. package/dist/index.d.ts +425 -74
  15. package/dist/index.js +712 -218
  16. package/dist/index.js.map +1 -1
  17. package/dist/outbox-bPQmKYPN.d.cts +128 -0
  18. package/dist/outbox-bPQmKYPN.d.ts +128 -0
  19. package/dist/schema.cjs +9 -0
  20. package/dist/schema.cjs.map +1 -0
  21. package/dist/schema.d.cts +4772 -0
  22. package/dist/schema.d.ts +4772 -0
  23. package/dist/schema.js +9 -0
  24. package/dist/schema.js.map +1 -0
  25. package/dist/sql-outbox.cjs +184 -0
  26. package/dist/sql-outbox.cjs.map +1 -0
  27. package/dist/sql-outbox.d.cts +54 -0
  28. package/dist/sql-outbox.d.ts +54 -0
  29. package/dist/sql-outbox.js +184 -0
  30. package/dist/sql-outbox.js.map +1 -0
  31. package/package.json +30 -10
  32. package/src/auto-enqueuer.test.ts +391 -0
  33. package/src/auto-enqueuer.ts +335 -0
  34. package/src/dispatcher.test.ts +324 -0
  35. package/src/dispatcher.ts +218 -0
  36. package/src/http-sender.ts +187 -0
  37. package/src/index.ts +48 -12
  38. package/src/memory-outbox.ts +127 -0
  39. package/src/outbox.ts +141 -0
  40. package/src/partition.ts +19 -0
  41. package/src/retention.test.ts +116 -0
  42. package/src/retention.ts +144 -0
  43. package/src/schema.ts +22 -0
  44. package/src/sql-outbox.test.ts +410 -0
  45. package/src/sql-outbox.ts +282 -0
  46. package/src/sys-webhook-delivery.object.ts +202 -0
  47. package/src/webhook-outbox-plugin.ts +280 -0
  48. package/tsconfig.json +5 -13
  49. package/tsup.config.ts +14 -0
  50. package/dist/index.d.mts +0 -104
  51. package/dist/index.mjs +0 -216
  52. package/dist/index.mjs.map +0 -1
  53. package/src/webhooks-plugin.test.ts +0 -218
  54. package/src/webhooks-plugin.ts +0 -294
@@ -0,0 +1,202 @@
1
+ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ import { Field, ObjectSchema } from '@objectstack/spec/data';
4
+
5
+ /**
6
+ * sys_webhook_delivery — Durable outbox row for one HTTP attempt.
7
+ *
8
+ * Schema is owned by `@objectstack/plugin-webhooks`. Add it to your stack
9
+ * via:
10
+ *
11
+ * import { SysWebhookDelivery } from '@objectstack/plugin-webhooks/schema';
12
+ * defineStack({ objects: [SysWebhookDelivery, ...], plugins: [...] });
13
+ *
14
+ * Designed for the SqlWebhookOutbox claim algorithm:
15
+ *
16
+ * 1. Producers INSERT pending rows (dedup'd by (event_id, webhook_id)).
17
+ * 2. The dispatcher's per-partition lock-holder runs:
18
+ * SELECT id WHERE status='pending' AND partition_key=? AND (next_retry_at <= now OR null)
19
+ * UPDATE SET status='in_flight' WHERE id IN (...) AND status='pending' ← atomic claim
20
+ * POST to target URL
21
+ * UPDATE SET status=success/pending/dead, attempts=attempts+1, ...
22
+ *
23
+ * `partition_key` is precomputed on enqueue (hash(webhook_id) mod N) so the
24
+ * dispatcher can filter cheaply without DB-side hash functions.
25
+ *
26
+ * Indexes are tuned for the hot path: `(status, partition_key, next_retry_at)`
27
+ * is the claim query; `(event_id, webhook_id)` is the dedup uniqueness.
28
+ *
29
+ * @namespace sys
30
+ */
31
+ export const SysWebhookDelivery = ObjectSchema.create({
32
+ name: 'sys_webhook_delivery',
33
+ label: 'Webhook Delivery',
34
+ pluralLabel: 'Webhook Deliveries',
35
+ icon: 'package',
36
+ isSystem: true,
37
+ managedBy: 'config',
38
+ userActions: { create: false, edit: false, delete: false, import: false },
39
+ description:
40
+ 'Durable outbox row for one webhook attempt. Managed by @objectstack/plugin-webhooks; do not write directly.',
41
+ displayNameField: 'id',
42
+ titleFormat: '{event_type} → {url}',
43
+ compactLayout: ['event_type', 'url', 'status', 'attempts', 'next_retry_at'],
44
+
45
+ listViews: {
46
+ recent: {
47
+ type: 'grid',
48
+ name: 'recent',
49
+ label: 'Recent',
50
+ data: { provider: 'object', object: 'sys_webhook_delivery' },
51
+ columns: ['event_type', 'url', 'status', 'attempts', 'response_code', 'updated_at'],
52
+ sort: [{ field: 'updated_at', order: 'desc' }],
53
+ pagination: { pageSize: 50 },
54
+ },
55
+ failures: {
56
+ type: 'grid',
57
+ name: 'failures',
58
+ label: 'Failures',
59
+ data: { provider: 'object', object: 'sys_webhook_delivery' },
60
+ columns: ['event_type', 'url', 'status', 'attempts', 'response_code', 'error', 'updated_at'],
61
+ filter: [{ field: 'status', operator: 'in', value: ['failed', 'dead'] }],
62
+ sort: [{ field: 'updated_at', order: 'desc' }],
63
+ pagination: { pageSize: 50 },
64
+ },
65
+ in_flight: {
66
+ type: 'grid',
67
+ name: 'in_flight',
68
+ label: 'In Flight',
69
+ data: { provider: 'object', object: 'sys_webhook_delivery' },
70
+ columns: ['event_type', 'url', 'attempts', 'claimed_by', 'claimed_at'],
71
+ filter: [{ field: 'status', operator: 'equals', value: 'in_flight' }],
72
+ sort: [{ field: 'claimed_at', order: 'desc' }],
73
+ pagination: { pageSize: 50 },
74
+ },
75
+ pending: {
76
+ type: 'grid',
77
+ name: 'pending',
78
+ label: 'Pending',
79
+ data: { provider: 'object', object: 'sys_webhook_delivery' },
80
+ columns: ['event_type', 'url', 'attempts', 'next_retry_at', 'updated_at'],
81
+ filter: [{ field: 'status', operator: 'equals', value: 'pending' }],
82
+ sort: [{ field: 'next_retry_at', order: 'asc' }],
83
+ pagination: { pageSize: 50 },
84
+ },
85
+ by_status: {
86
+ type: 'grid',
87
+ name: 'by_status',
88
+ label: 'By Status',
89
+ data: { provider: 'object', object: 'sys_webhook_delivery' },
90
+ columns: ['status', 'event_type', 'url', 'attempts', 'updated_at'],
91
+ sort: [{ field: 'status', order: 'asc' }, { field: 'updated_at', order: 'desc' }],
92
+ grouping: { fields: [{ field: 'status', order: 'asc', collapsed: false }] },
93
+ pagination: { pageSize: 100 },
94
+ },
95
+ by_webhook: {
96
+ type: 'grid',
97
+ name: 'by_webhook',
98
+ label: 'By Webhook',
99
+ data: { provider: 'object', object: 'sys_webhook_delivery' },
100
+ columns: ['webhook_id', 'event_type', 'status', 'attempts', 'updated_at'],
101
+ sort: [{ field: 'webhook_id', order: 'asc' }, { field: 'updated_at', order: 'desc' }],
102
+ grouping: { fields: [{ field: 'webhook_id', order: 'asc', collapsed: true }] },
103
+ pagination: { pageSize: 100 },
104
+ },
105
+ all_deliveries: {
106
+ type: 'grid',
107
+ name: 'all_deliveries',
108
+ label: 'All',
109
+ data: { provider: 'object', object: 'sys_webhook_delivery' },
110
+ columns: ['event_type', 'url', 'status', 'attempts', 'response_code', 'updated_at'],
111
+ sort: [{ field: 'updated_at', order: 'desc' }],
112
+ pagination: { pageSize: 100 },
113
+ },
114
+ },
115
+
116
+ fields: {
117
+ id: Field.text({
118
+ label: 'Delivery ID',
119
+ required: true,
120
+ maxLength: 64,
121
+ description: 'UUID — also doubles as the receiver-side idempotency key',
122
+ }),
123
+
124
+ webhook_id: Field.text({
125
+ label: 'Webhook ID',
126
+ required: true,
127
+ maxLength: 64,
128
+ description: 'FK to sys_webhook.id (loosely coupled — denormalised URL/secret on row)',
129
+ }),
130
+
131
+ event_id: Field.text({
132
+ label: 'Event ID',
133
+ required: true,
134
+ maxLength: 128,
135
+ description: 'Source event id; UNIQUE(event_id, webhook_id) for dedup',
136
+ }),
137
+
138
+ event_type: Field.text({
139
+ label: 'Event Type',
140
+ required: true,
141
+ maxLength: 128,
142
+ description: 'e.g. data.record.created',
143
+ }),
144
+
145
+ url: Field.text({
146
+ label: 'Target URL',
147
+ required: true,
148
+ maxLength: 2048,
149
+ description: 'Snapshotted at enqueue so config edits do not rewrite live rows',
150
+ }),
151
+
152
+ method: Field.text({ label: 'Method', required: false, maxLength: 10 }),
153
+ headers_json: Field.textarea({ label: 'Headers JSON', required: false }),
154
+ secret: Field.text({ label: 'HMAC Secret', required: false, maxLength: 256 }),
155
+ timeout_ms: Field.number({ label: 'Timeout (ms)', required: false }),
156
+ payload_json: Field.textarea({ label: 'Payload JSON', required: true }),
157
+
158
+ partition_key: Field.number({
159
+ label: 'Partition',
160
+ required: true,
161
+ description: 'hash(webhook_id) mod partitionCount — precomputed for cheap WHERE',
162
+ }),
163
+
164
+ status: Field.text({
165
+ label: 'Status',
166
+ required: true,
167
+ defaultValue: 'pending',
168
+ maxLength: 16,
169
+ description: 'pending | in_flight | success | failed | dead',
170
+ }),
171
+
172
+ attempts: Field.number({
173
+ label: 'Attempts',
174
+ required: true,
175
+ defaultValue: 0,
176
+ description: 'Number of POST attempts made so far',
177
+ }),
178
+
179
+ claimed_by: Field.text({ label: 'Claimed By', required: false, maxLength: 128 }),
180
+ claimed_at: Field.number({ label: 'Claimed At (ms)', required: false }),
181
+ next_retry_at: Field.number({ label: 'Next Retry At (ms)', required: false }),
182
+ last_attempted_at: Field.number({ label: 'Last Attempted At (ms)', required: false }),
183
+ response_code: Field.number({ label: 'HTTP Status', required: false }),
184
+ response_body: Field.textarea({ label: 'Response Body (capped)', required: false }),
185
+ error: Field.textarea({ label: 'Error', required: false }),
186
+
187
+ created_at: Field.number({ label: 'Created At (ms)', required: true }),
188
+ updated_at: Field.number({ label: 'Updated At (ms)', required: true }),
189
+ },
190
+
191
+ indexes: [
192
+ { fields: ['event_id', 'webhook_id'], unique: true },
193
+ // Hot path: claim query
194
+ { fields: ['status', 'partition_key', 'next_retry_at'] },
195
+ // Reaper: scan stale in_flight rows by claimed_at
196
+ { fields: ['status', 'claimed_at'] },
197
+ { fields: ['webhook_id'] },
198
+ ],
199
+ });
200
+
201
+ /** Canonical object name — exported so SqlWebhookOutbox callers can override if needed. */
202
+ export const SYS_WEBHOOK_DELIVERY = 'sys_webhook_delivery' as const;
@@ -0,0 +1,280 @@
1
+ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ import type { Plugin, PluginContext } from '@objectstack/core';
4
+ import type {
5
+ IClusterService,
6
+ IDataEngine,
7
+ IRealtimeService,
8
+ } from '@objectstack/spec/contracts';
9
+ import { SysWebhook } from '@objectstack/platform-objects/integration';
10
+ import { AutoEnqueuer, type AutoEnqueuerOptions } from './auto-enqueuer.js';
11
+ import { WebhookDispatcher, type DispatcherOptions } from './dispatcher.js';
12
+ import { MemoryWebhookOutbox } from './memory-outbox.js';
13
+ import type { IWebhookOutbox } from './outbox.js';
14
+ import {
15
+ DeliveryRetentionSweeper,
16
+ type DeliveryRetentionOptions,
17
+ } from './retention.js';
18
+ import { SysWebhookDelivery } from './sys-webhook-delivery.object.js';
19
+
20
+ export interface WebhookOutboxPluginOptions
21
+ extends Partial<Omit<DispatcherOptions, 'cluster' | 'outbox' | 'nodeId'>> {
22
+ /**
23
+ * Override the outbox backend. If omitted a fresh `MemoryWebhookOutbox`
24
+ * is used — fine for local development, **not for production**: each
25
+ * node will see only its own rows.
26
+ *
27
+ * Pass a factory if you need the kernel-resolved `IDataEngine`:
28
+ *
29
+ * ```ts
30
+ * outbox: (ctx) => new SqlWebhookOutbox(
31
+ * ctx.getService('objectql'), { partitionCount: 8 },
32
+ * ),
33
+ * ```
34
+ */
35
+ outbox?: IWebhookOutbox | ((ctx: PluginContext) => IWebhookOutbox);
36
+
37
+ /**
38
+ * Stable node id. If omitted, uses `process.env.OBJECTSTACK_NODE_ID`
39
+ * or a random UUID generated at plugin init.
40
+ */
41
+ nodeId?: string;
42
+
43
+ /**
44
+ * If `false`, the plugin registers the outbox/dispatcher services but
45
+ * does NOT auto-start the loop — useful for tests that want to step
46
+ * the dispatcher manually via `dispatcher.tick()`.
47
+ *
48
+ * Default: true.
49
+ */
50
+ autoStart?: boolean;
51
+
52
+ /**
53
+ * Auto-enqueue config. When enabled (default `true` if the realtime
54
+ * + data engine services are available), the plugin subscribes to
55
+ * `data.record.*` events emitted by the engine and automatically
56
+ * enqueues a delivery row for every matching `sys_webhook` row.
57
+ *
58
+ * Set `false` to disable and only use the imperative
59
+ * `outbox.enqueue()` API.
60
+ */
61
+ autoEnqueue?: boolean | AutoEnqueuerOptions;
62
+
63
+ /**
64
+ * Retention sweep config. When enabled (default `true` if a SQL
65
+ * outbox is in use), a periodic timer prunes old `success` and
66
+ * `dead` rows from `sys_webhook_delivery`.
67
+ *
68
+ * Set `false` to disable (e.g. when using `MemoryWebhookOutbox`).
69
+ */
70
+ retention?: boolean | DeliveryRetentionOptions;
71
+ }
72
+
73
+ /**
74
+ * Wires a persistent, cluster-aware webhook outbox into the kernel.
75
+ *
76
+ * Registered services:
77
+ * - `webhook.outbox` → `IWebhookOutbox` (enqueue / claim / ack / list)
78
+ * - `webhook.dispatcher` → `WebhookDispatcher` (manual `tick()` if needed)
79
+ * - `webhook.autoEnqueuer` → `AutoEnqueuer` when auto-enqueue is on
80
+ * - `webhook.retention` → `DeliveryRetentionSweeper` when retention is on
81
+ *
82
+ * End-to-end flow once auto-enqueue is enabled:
83
+ *
84
+ * engine.insert('contact', {...})
85
+ * → engine publishes data.record.created via IRealtimeService
86
+ * → AutoEnqueuer matches active sys_webhook rows in O(1)
87
+ * → outbox.enqueue() runs fire-and-forget (not on the write path)
88
+ * → dispatcher claims and POSTs (cluster-coordinated)
89
+ *
90
+ * **Cluster requirement** — this plugin depends on the cluster service
91
+ * (`ClusterServicePlugin`). With the default `memory` driver the
92
+ * dispatcher works correctly inside a single process; with a real driver
93
+ * (`@objectstack/service-cluster-redis`) it correctly coordinates work
94
+ * across nodes.
95
+ */
96
+ export class WebhookOutboxPlugin implements Plugin {
97
+ name = 'com.objectstack.plugin-webhook-outbox';
98
+ version = '1.1.0';
99
+ type = 'standard' as const;
100
+ dependencies = ['com.objectstack.service.cluster'];
101
+
102
+ private dispatcher: WebhookDispatcher | undefined;
103
+ private autoEnqueuer: AutoEnqueuer | undefined;
104
+ private retention: DeliveryRetentionSweeper | undefined;
105
+ private outboxInstance: IWebhookOutbox | undefined;
106
+
107
+ constructor(private readonly options: WebhookOutboxPluginOptions = {}) {}
108
+
109
+ async init(ctx: PluginContext): Promise<void> {
110
+ const cluster = ctx.getService<IClusterService>('cluster');
111
+ if (!cluster) {
112
+ throw new Error(
113
+ 'WebhookOutboxPlugin: required service "cluster" not found — register ClusterServicePlugin first',
114
+ );
115
+ }
116
+
117
+ // Register the schemas this plugin owns at runtime. `sys_webhook`
118
+ // (config) lives in @objectstack/platform-objects but no other
119
+ // plugin claims it — the webhook plugin is the natural owner
120
+ // since it's the consumer of those rows. `sys_webhook_delivery`
121
+ // (telemetry) is plugin-private. Registering them here means a
122
+ // stack just needs `plugins: [new WebhookOutboxPlugin(...)]`
123
+ // and both objects auto-appear in REST/Studio/Setup nav.
124
+ const manifest = ctx.getService<{ register(m: any): void }>('manifest');
125
+ if (manifest && typeof manifest.register === 'function') {
126
+ manifest.register({
127
+ id: 'com.objectstack.plugin-webhook-outbox.schema',
128
+ namespace: 'sys',
129
+ version: this.version,
130
+ type: 'plugin',
131
+ scope: 'system',
132
+ name: 'Webhook Outbox Schemas',
133
+ description:
134
+ 'Registers sys_webhook (configuration) and sys_webhook_delivery (durable outbox telemetry).',
135
+ objects: [SysWebhook, SysWebhookDelivery],
136
+ });
137
+ } else {
138
+ ctx.logger.warn?.(
139
+ '[webhook-outbox] manifest service unavailable — sys_webhook / sys_webhook_delivery will NOT appear in REST or Studio nav. Register MetadataService before WebhookOutboxPlugin.',
140
+ );
141
+ }
142
+
143
+ const outbox = this.resolveOutbox(ctx);
144
+ this.outboxInstance = outbox;
145
+ const nodeId =
146
+ this.options.nodeId ??
147
+ process.env.OBJECTSTACK_NODE_ID ??
148
+ `node-${Math.random().toString(36).slice(2, 10)}`;
149
+
150
+ const dispatcher = new WebhookDispatcher({
151
+ nodeId,
152
+ cluster,
153
+ outbox,
154
+ partitionCount: this.options.partitionCount,
155
+ batchSize: this.options.batchSize,
156
+ intervalMs: this.options.intervalMs,
157
+ lockTtlMs: this.options.lockTtlMs,
158
+ claimTtlMs: this.options.claimTtlMs,
159
+ fetchImpl: this.options.fetchImpl,
160
+ onAttempt: this.options.onAttempt,
161
+ rng: this.options.rng,
162
+ logger: ctx.logger,
163
+ });
164
+ this.dispatcher = dispatcher;
165
+
166
+ ctx.registerService('webhook.outbox', outbox);
167
+ ctx.registerService('webhook.dispatcher', dispatcher);
168
+
169
+ if (this.options.autoStart !== false) {
170
+ dispatcher.start();
171
+ }
172
+
173
+ // Loud warning when running with the in-memory outbox in production —
174
+ // it loses data on restart and never shares rows across nodes.
175
+ const usingMemoryOutbox = outbox instanceof MemoryWebhookOutbox;
176
+ if (usingMemoryOutbox && process.env.NODE_ENV === 'production') {
177
+ ctx.logger.warn?.(
178
+ '[webhook-outbox] MemoryWebhookOutbox in production — webhook deliveries WILL be lost on process exit. Pass `outbox: (ctx) => new SqlWebhookOutbox(ctx.getService("objectql"), { partitionCount: 8 })` from `@objectstack/plugin-webhooks/sql`.',
179
+ );
180
+ }
181
+
182
+ // Auto-enqueue + retention need the kernel to be fully ready
183
+ // before ObjectQL / Realtime services are resolvable.
184
+ const autoEnqueueOpt = this.options.autoEnqueue ?? true;
185
+ const retentionOpt = this.options.retention ?? true;
186
+
187
+ const needsReadyHook = autoEnqueueOpt !== false || retentionOpt !== false;
188
+ if (needsReadyHook && typeof (ctx as any).hook === 'function') {
189
+ (ctx as any).hook('kernel:ready', async () => {
190
+ await this.bootAutoEnqueue(ctx, autoEnqueueOpt);
191
+ this.bootRetention(ctx, retentionOpt);
192
+ });
193
+ }
194
+
195
+ ctx.logger.info?.('[webhook-outbox] initialised', {
196
+ nodeId,
197
+ partitions: this.options.partitionCount ?? 8,
198
+ interval: this.options.intervalMs ?? 250,
199
+ autoEnqueue: autoEnqueueOpt !== false,
200
+ retention: retentionOpt !== false,
201
+ });
202
+ }
203
+
204
+ async dispose(): Promise<void> {
205
+ await this.autoEnqueuer?.stop();
206
+ this.retention?.stop();
207
+ await this.dispatcher?.stop();
208
+ }
209
+
210
+ private resolveOutbox(ctx: PluginContext): IWebhookOutbox {
211
+ const opt = this.options.outbox;
212
+ if (!opt) return new MemoryWebhookOutbox();
213
+ if (typeof opt === 'function') return (opt as (c: PluginContext) => IWebhookOutbox)(ctx);
214
+ return opt;
215
+ }
216
+
217
+ private async bootAutoEnqueue(
218
+ ctx: PluginContext,
219
+ opt: boolean | AutoEnqueuerOptions,
220
+ ): Promise<void> {
221
+ if (opt === false) return;
222
+ const engine = this.tryGetService<IDataEngine>(ctx, ['objectql', 'data']);
223
+ const realtime = this.tryGetService<IRealtimeService>(ctx, ['realtime']);
224
+ if (!engine || !realtime) {
225
+ ctx.logger.warn?.(
226
+ '[webhook-auto-enqueuer] disabled — ObjectQL or Realtime service not available',
227
+ { hasEngine: !!engine, hasRealtime: !!realtime },
228
+ );
229
+ return;
230
+ }
231
+ if (!this.outboxInstance) return;
232
+
233
+ const enqOpts = (typeof opt === 'object' ? opt : {}) as AutoEnqueuerOptions;
234
+ this.autoEnqueuer = new AutoEnqueuer(
235
+ engine,
236
+ realtime,
237
+ this.outboxInstance,
238
+ { ...enqOpts, logger: ctx.logger },
239
+ );
240
+ await this.autoEnqueuer.start();
241
+ ctx.registerService('webhook.autoEnqueuer', this.autoEnqueuer);
242
+ ctx.logger.info?.('[webhook-auto-enqueuer] started');
243
+ }
244
+
245
+ private bootRetention(
246
+ ctx: PluginContext,
247
+ opt: boolean | DeliveryRetentionOptions,
248
+ ): void {
249
+ if (opt === false) return;
250
+ // Only meaningful for SQL outbox — Memory has its own (process-lifetime) GC.
251
+ if (this.outboxInstance instanceof MemoryWebhookOutbox) return;
252
+ const engine = this.tryGetService<IDataEngine>(ctx, ['objectql', 'data']);
253
+ if (!engine) {
254
+ ctx.logger.warn?.(
255
+ '[webhook-retention] disabled — ObjectQL service not available',
256
+ );
257
+ return;
258
+ }
259
+ const retOpts = (typeof opt === 'object' ? opt : {}) as DeliveryRetentionOptions;
260
+ this.retention = new DeliveryRetentionSweeper(engine, {
261
+ ...retOpts,
262
+ logger: ctx.logger,
263
+ });
264
+ this.retention.start();
265
+ ctx.registerService('webhook.retention', this.retention);
266
+ ctx.logger.info?.('[webhook-retention] sweeper started');
267
+ }
268
+
269
+ private tryGetService<T>(ctx: PluginContext, names: string[]): T | undefined {
270
+ for (const n of names) {
271
+ try {
272
+ const svc = ctx.getService<T>(n);
273
+ if (svc) return svc;
274
+ } catch {
275
+ // fall through
276
+ }
277
+ }
278
+ return undefined;
279
+ }
280
+ }
package/tsconfig.json CHANGED
@@ -1,18 +1,10 @@
1
1
  {
2
2
  "extends": "../../../tsconfig.json",
3
3
  "compilerOptions": {
4
- "outDir": "./dist",
5
- "rootDir": "./src",
6
- "types": [
7
- "node"
8
- ]
4
+ "outDir": "dist",
5
+ "rootDir": "src",
6
+ "types": ["node"]
9
7
  },
10
- "include": [
11
- "src/**/*"
12
- ],
13
- "exclude": [
14
- "dist",
15
- "node_modules",
16
- "**/*.test.ts"
17
- ]
8
+ "include": ["src"],
9
+ "exclude": ["node_modules", "dist"]
18
10
  }
package/tsup.config.ts ADDED
@@ -0,0 +1,14 @@
1
+ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ import { defineConfig } from 'tsup';
4
+
5
+ export default defineConfig({
6
+ entry: ['src/index.ts', 'src/sql-outbox.ts', 'src/schema.ts'],
7
+ splitting: true,
8
+ sourcemap: true,
9
+ clean: true,
10
+ dts: true,
11
+ format: ['esm', 'cjs'],
12
+ target: 'es2020',
13
+ external: ['vitest'],
14
+ });
package/dist/index.d.mts DELETED
@@ -1,104 +0,0 @@
1
- import { Plugin, PluginContext } from '@objectstack/core';
2
-
3
- /**
4
- * A single webhook delivery target.
5
- */
6
- interface WebhookSink {
7
- /** Unique sink id used for log correlation. */
8
- id: string;
9
- /** Target HTTPS URL. */
10
- url: string;
11
- /** Optional HMAC-SHA256 secret. When set, `X-Objectstack-Signature: sha256=…` is added. */
12
- secret?: string;
13
- /**
14
- * Restrict to specific object names (logical names, e.g. `lead`, `account`).
15
- * Omit / empty → all objects.
16
- */
17
- objects?: string[];
18
- /**
19
- * Restrict to specific event types. Omit / empty → all `data.record.*` events.
20
- */
21
- eventTypes?: string[];
22
- /** Extra headers to send (Authorization, Tenant, etc.). */
23
- headers?: Record<string, string>;
24
- /** Per-request timeout in milliseconds. Default 5000. */
25
- timeoutMs?: number;
26
- /** Retry attempts on transient failure. Default 3. Set 0 to disable retries. */
27
- retries?: number;
28
- }
29
- /**
30
- * Delivery attempt outcome surfaced to in-process listeners / tests.
31
- */
32
- type WebhookDeliveryStatus = 'ok' | 'retrying' | 'failed';
33
- interface WebhookDeliveryRecord {
34
- sinkId: string;
35
- url: string;
36
- eventType: string;
37
- object?: string;
38
- status: WebhookDeliveryStatus;
39
- httpStatus?: number;
40
- attempt: number;
41
- error?: string;
42
- }
43
- /**
44
- * Plugin configuration.
45
- *
46
- * Sinks may be supplied programmatically OR via env vars when none are
47
- * passed (suitable for 12-factor / Docker deployments):
48
- *
49
- * OBJECTSTACK_WEBHOOK_URL — single URL, or comma-separated URLs.
50
- * OBJECTSTACK_WEBHOOK_SECRET — HMAC secret applied to all env-sourced URLs.
51
- * OBJECTSTACK_WEBHOOK_OBJECTS — comma-separated object whitelist.
52
- * OBJECTSTACK_WEBHOOK_EVENTS — comma-separated event-type whitelist
53
- * (e.g. `data.record.created`).
54
- */
55
- interface WebhooksPluginOptions {
56
- /** Explicit sink list (takes precedence over env vars). */
57
- sinks?: WebhookSink[];
58
- /** Override fetch (mainly for tests). Defaults to globalThis.fetch. */
59
- fetchImpl?: typeof fetch;
60
- /** Hook invoked with each delivery outcome (mainly for tests / metrics). */
61
- onDelivery?: (record: WebhookDeliveryRecord) => void;
62
- }
63
- /**
64
- * WebhooksPlugin — fan out data.record.* events to external HTTP endpoints.
65
- *
66
- * @example
67
- * ```ts
68
- * kernel.use(new WebhooksPlugin({
69
- * sinks: [
70
- * { id: 'crm-sync', url: 'https://hooks.example.com/in',
71
- * secret: process.env.HOOK_SECRET, objects: ['lead', 'account'] },
72
- * ],
73
- * }));
74
- * ```
75
- */
76
- declare class WebhooksPlugin implements Plugin {
77
- name: string;
78
- version: string;
79
- type: string;
80
- dependencies: string[];
81
- private readonly options;
82
- private subscriptionIds;
83
- private realtime?;
84
- private sinks;
85
- private logger?;
86
- constructor(options?: WebhooksPluginOptions);
87
- init(ctx: PluginContext): Promise<void>;
88
- start(ctx: PluginContext): Promise<void>;
89
- stop(ctx: PluginContext): Promise<void>;
90
- /**
91
- * Resolve sinks from constructor options, falling back to env vars when
92
- * none provided. Exposed for testing.
93
- */
94
- private resolveSinks;
95
- /**
96
- * Dispatch a single event to a sink, with HMAC signing, timeout, and
97
- * exponential-backoff retry. Failures past the retry budget are logged
98
- * but never thrown — webhook delivery must never break the originating
99
- * mutation.
100
- */
101
- private dispatch;
102
- }
103
-
104
- export { type WebhookDeliveryRecord, type WebhookDeliveryStatus, type WebhookSink, WebhooksPlugin, type WebhooksPluginOptions };