@objectstack/plugin-webhooks 7.5.0 → 7.7.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 (52) hide show
  1. package/.turbo/turbo-build.log +20 -32
  2. package/CHANGELOG.md +60 -0
  3. package/dist/chunk-HWFTXTTI.js +138 -0
  4. package/dist/chunk-HWFTXTTI.js.map +1 -0
  5. package/dist/chunk-KPKLAXNA.cjs +138 -0
  6. package/dist/chunk-KPKLAXNA.cjs.map +1 -0
  7. package/dist/index.cjs +62 -616
  8. package/dist/index.cjs.map +1 -1
  9. package/dist/index.d.cts +41 -325
  10. package/dist/index.d.ts +41 -325
  11. package/dist/index.js +52 -606
  12. package/dist/index.js.map +1 -1
  13. package/dist/schema.cjs +2 -6
  14. package/dist/schema.cjs.map +1 -1
  15. package/dist/schema.d.cts +5 -4764
  16. package/dist/schema.d.ts +5 -4764
  17. package/dist/schema.js +3 -7
  18. package/package.json +4 -11
  19. package/src/auto-enqueuer.test.ts +83 -116
  20. package/src/auto-enqueuer.ts +38 -27
  21. package/src/index.ts +13 -40
  22. package/src/schema.ts +11 -16
  23. package/src/webhook-outbox-plugin.ts +80 -296
  24. package/tsup.config.ts +1 -1
  25. package/dist/chunk-7HS5DLU2.js +0 -319
  26. package/dist/chunk-7HS5DLU2.js.map +0 -1
  27. package/dist/chunk-HF7CCDPB.cjs +0 -256
  28. package/dist/chunk-HF7CCDPB.cjs.map +0 -1
  29. package/dist/chunk-KNGLLSSP.js +0 -256
  30. package/dist/chunk-KNGLLSSP.js.map +0 -1
  31. package/dist/chunk-TDSI7UHY.cjs +0 -319
  32. package/dist/chunk-TDSI7UHY.cjs.map +0 -1
  33. package/dist/outbox-CIn7LSyB.d.cts +0 -155
  34. package/dist/outbox-CIn7LSyB.d.ts +0 -155
  35. package/dist/sql-outbox.cjs +0 -8
  36. package/dist/sql-outbox.cjs.map +0 -1
  37. package/dist/sql-outbox.d.cts +0 -55
  38. package/dist/sql-outbox.d.ts +0 -55
  39. package/dist/sql-outbox.js +0 -8
  40. package/dist/sql-outbox.js.map +0 -1
  41. package/src/dispatcher.test.ts +0 -324
  42. package/src/dispatcher.ts +0 -218
  43. package/src/http-sender.ts +0 -187
  44. package/src/memory-outbox.test.ts +0 -86
  45. package/src/memory-outbox.ts +0 -155
  46. package/src/outbox.ts +0 -175
  47. package/src/partition.ts +0 -19
  48. package/src/retention.test.ts +0 -116
  49. package/src/retention.ts +0 -144
  50. package/src/sql-outbox.test.ts +0 -490
  51. package/src/sql-outbox.ts +0 -343
  52. package/src/sys-webhook-delivery.object.ts +0 -224
package/src/sql-outbox.ts DELETED
@@ -1,343 +0,0 @@
1
- // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2
-
3
- import { randomUUID } from 'node:crypto';
4
- import type { IDataEngine } from '@objectstack/spec/contracts';
5
- import type {
6
- AckResult,
7
- ClaimOptions,
8
- DeliveryStatus,
9
- EnqueueInput,
10
- IWebhookOutbox,
11
- WebhookDelivery,
12
- } from './outbox.js';
13
- import { RedeliverError } from './outbox.js';
14
- import { hashPartition } from './partition.js';
15
- import { SYS_WEBHOOK_DELIVERY } from './schema.js';
16
-
17
- export interface SqlWebhookOutboxOptions {
18
- /**
19
- * Total partition count — MUST match the dispatcher's `partitionCount`.
20
- * Used at enqueue time to precompute `partition_key`.
21
- */
22
- partitionCount: number;
23
- /**
24
- * Object name to read/write. Defaults to `sys_webhook_delivery`. Override
25
- * only if you've registered the schema under a different name.
26
- */
27
- objectName?: string;
28
- }
29
-
30
- interface DeliveryRow {
31
- id: string;
32
- webhook_id: string;
33
- event_id: string;
34
- event_type: string;
35
- url: string;
36
- method?: string | null;
37
- headers_json?: string | null;
38
- secret?: string | null;
39
- timeout_ms?: number | null;
40
- payload_json: string;
41
- partition_key: number;
42
- status: DeliveryStatus;
43
- attempts: number;
44
- claimed_by?: string | null;
45
- claimed_at?: number | null;
46
- next_retry_at?: number | null;
47
- last_attempted_at?: number | null;
48
- response_code?: number | null;
49
- response_body?: string | null;
50
- error?: string | null;
51
- created_at: number;
52
- updated_at: number;
53
- }
54
-
55
- /**
56
- * Durable `IWebhookOutbox` backed by ObjectQL — the production storage
57
- * impl. Works against any registered driver (SQL, Turso, Mongo, in-memory)
58
- * because everything goes through the driver-agnostic `IDataEngine` API.
59
- *
60
- * **Why no `FOR UPDATE SKIP LOCKED`?** ObjectQL is driver-agnostic — that
61
- * SQL feature is Postgres-only. We get equivalent safety from two layers:
62
- *
63
- * 1. `cluster.lock` held per partition by the dispatcher (the primary
64
- * mutex). One node owns one partition at a time → no two claimers.
65
- * 2. Atomic `UPDATE WHERE status='pending'` (the backup). Even if two
66
- * claimers slip through (e.g. admin reschedule + dispatcher), only
67
- * the first UPDATE matches each row.
68
- *
69
- * **Why precompute `partition_key` on enqueue?** ObjectQL has no
70
- * cross-driver `hash()` function in WHERE clauses. Storing the partition
71
- * as a column makes the claim query a plain indexed lookup.
72
- *
73
- * **Dedup race**: SELECT-then-INSERT has a tiny window where two
74
- * concurrent producers both miss the SELECT and both INSERT. The unique
75
- * index `(event_id, webhook_id)` on the table catches it — the second
76
- * INSERT errors, the producer ignores it. Receivers MUST be idempotent
77
- * on the `X-Objectstack-Delivery` header anyway.
78
- */
79
- export class SqlWebhookOutbox implements IWebhookOutbox {
80
- private readonly objectName: string;
81
- private readonly partitionCount: number;
82
-
83
- constructor(
84
- private readonly engine: IDataEngine,
85
- opts: SqlWebhookOutboxOptions,
86
- ) {
87
- if (opts.partitionCount <= 0) {
88
- throw new Error('SqlWebhookOutbox: partitionCount must be > 0');
89
- }
90
- this.objectName = opts.objectName ?? SYS_WEBHOOK_DELIVERY;
91
- this.partitionCount = opts.partitionCount;
92
- }
93
-
94
- async enqueue(input: EnqueueInput): Promise<string> {
95
- // Cheap pre-check to absorb most duplicates without hitting the
96
- // unique-index error path. Race window with the INSERT below is
97
- // intentional and documented.
98
- const existing = await this.engine.findOne(this.objectName, {
99
- where: { event_id: input.eventId, webhook_id: input.webhookId },
100
- fields: ['id'],
101
- });
102
- if (existing?.id) return existing.id as string;
103
-
104
- const id = randomUUID();
105
- const now = Date.now();
106
- const row: Omit<DeliveryRow, 'response_body' | 'error'> = {
107
- id,
108
- webhook_id: input.webhookId,
109
- event_id: input.eventId,
110
- event_type: input.eventType,
111
- url: input.url,
112
- method: input.method ?? 'POST',
113
- headers_json: input.headers ? JSON.stringify(input.headers) : undefined,
114
- secret: input.secret,
115
- timeout_ms: input.timeoutMs,
116
- payload_json: JSON.stringify(input.payload ?? null),
117
- partition_key: hashPartition(input.webhookId, this.partitionCount),
118
- status: 'pending',
119
- attempts: 0,
120
- created_at: now,
121
- updated_at: now,
122
- };
123
- try {
124
- await this.engine.insert(this.objectName, row);
125
- return id;
126
- } catch (err) {
127
- // Unique-index collision (dedup race) → look up the winner and
128
- // return its id. Any other error propagates.
129
- const winner = await this.engine.findOne(this.objectName, {
130
- where: { event_id: input.eventId, webhook_id: input.webhookId },
131
- fields: ['id'],
132
- });
133
- if (winner?.id) return winner.id as string;
134
- throw err;
135
- }
136
- }
137
-
138
- async claim(opts: ClaimOptions): Promise<WebhookDelivery[]> {
139
- const now = opts.now ?? Date.now();
140
-
141
- // 1. Reap stale in_flight rows — visibility-timeout recovery.
142
- await this.engine.update(
143
- this.objectName,
144
- { status: 'pending', claimed_by: null, claimed_at: null, updated_at: now },
145
- {
146
- where: {
147
- status: 'in_flight',
148
- claimed_at: { $lt: now - opts.claimTtlMs },
149
- },
150
- multi: true,
151
- },
152
- );
153
-
154
- // 2. Pick candidate ids.
155
- const partitionFilter = opts.partition
156
- ? { partition_key: opts.partition.index }
157
- : {};
158
- const candidates = await this.engine.find(this.objectName, {
159
- where: {
160
- status: 'pending',
161
- ...partitionFilter,
162
- // next_retry_at <= now OR null
163
- $or: [
164
- { next_retry_at: null },
165
- { next_retry_at: { $lte: now } },
166
- ],
167
- },
168
- fields: ['id'],
169
- // No orderBy for portability — drivers handle the natural insert order.
170
- limit: opts.limit,
171
- });
172
- if (candidates.length === 0) return [];
173
-
174
- const ids = (candidates as Array<{ id: string }>).map((c) => c.id);
175
-
176
- // 3. Atomic claim. WHERE status='pending' rejects any rows another
177
- // worker swept up between steps 2 and 3.
178
- await this.engine.update(
179
- this.objectName,
180
- {
181
- status: 'in_flight',
182
- claimed_by: opts.nodeId,
183
- claimed_at: now,
184
- updated_at: now,
185
- },
186
- {
187
- where: { id: { $in: ids }, status: 'pending' },
188
- multi: true,
189
- },
190
- );
191
-
192
- // 4. Read back the rows we actually own.
193
- const claimed = (await this.engine.find(this.objectName, {
194
- where: {
195
- id: { $in: ids },
196
- claimed_by: opts.nodeId,
197
- claimed_at: now,
198
- status: 'in_flight',
199
- },
200
- })) as DeliveryRow[];
201
-
202
- return claimed.map((r) => this.toDelivery(r));
203
- }
204
-
205
- async ack(id: string, result: AckResult): Promise<void> {
206
- // ObjectQL has no atomic $inc across drivers, so read-then-write.
207
- // Safe enough: ack is single-writer per row (only the claimer acks).
208
- const current = (await this.engine.findOne(this.objectName, {
209
- where: { id },
210
- fields: ['attempts'],
211
- })) as { attempts?: number } | null;
212
- if (!current) return;
213
-
214
- const now = Date.now();
215
- let status: DeliveryStatus;
216
- let nextRetryAt: number | null;
217
- let error: string | null;
218
-
219
- if (result.success) {
220
- status = 'success';
221
- nextRetryAt = null;
222
- error = null;
223
- } else if (result.dead) {
224
- status = 'dead';
225
- nextRetryAt = null;
226
- error = result.error ?? null;
227
- } else {
228
- status = 'pending';
229
- nextRetryAt = result.nextRetryAt ?? null;
230
- error = result.error ?? null;
231
- }
232
-
233
- await this.engine.update(
234
- this.objectName,
235
- {
236
- status,
237
- attempts: (current.attempts ?? 0) + 1,
238
- last_attempted_at: now,
239
- claimed_by: null,
240
- claimed_at: null,
241
- response_code: result.httpStatus ?? null,
242
- response_body: result.responseBody ?? null,
243
- next_retry_at: nextRetryAt,
244
- error,
245
- updated_at: now,
246
- },
247
- { where: { id }, multi: false },
248
- );
249
- }
250
-
251
- async list(filter?: { status?: DeliveryStatus }): Promise<WebhookDelivery[]> {
252
- const rows = (await this.engine.find(this.objectName, {
253
- where: filter?.status ? { status: filter.status } : {},
254
- })) as DeliveryRow[];
255
- return rows.map((r) => this.toDelivery(r));
256
- }
257
-
258
- async redeliver(id: string): Promise<WebhookDelivery> {
259
- const current = (await this.engine.findOne(this.objectName, {
260
- where: { id },
261
- })) as DeliveryRow | null;
262
- if (!current) {
263
- throw new RedeliverError(
264
- `Delivery row '${id}' not found`,
265
- 'not_found',
266
- );
267
- }
268
- if (
269
- current.status !== 'success' &&
270
- current.status !== 'failed' &&
271
- current.status !== 'dead'
272
- ) {
273
- throw new RedeliverError(
274
- `Delivery row '${id}' is '${current.status}', expected one of: success, failed, dead`,
275
- 'not_eligible',
276
- );
277
- }
278
- const now = Date.now();
279
- // Guarded UPDATE — re-check status server-side so two concurrent
280
- // redeliver calls cannot both flip the row, and so a dispatcher
281
- // tick that flipped the row to in_flight between our SELECT and
282
- // UPDATE cannot be clobbered.
283
- await this.engine.update(
284
- this.objectName,
285
- {
286
- status: 'pending',
287
- attempts: 0,
288
- claimed_by: null,
289
- claimed_at: null,
290
- next_retry_at: null,
291
- last_attempted_at: null,
292
- response_code: null,
293
- response_body: null,
294
- error: null,
295
- updated_at: now,
296
- },
297
- {
298
- where: {
299
- id,
300
- status: { $in: ['success', 'failed', 'dead'] },
301
- },
302
- multi: false,
303
- },
304
- );
305
- const after = (await this.engine.findOne(this.objectName, {
306
- where: { id },
307
- })) as DeliveryRow | null;
308
- if (!after || after.status !== 'pending') {
309
- // Lost the race — another writer flipped the row.
310
- throw new RedeliverError(
311
- `Delivery row '${id}' state changed during redeliver`,
312
- 'not_eligible',
313
- );
314
- }
315
- return this.toDelivery(after);
316
- }
317
-
318
- private toDelivery(r: DeliveryRow): WebhookDelivery {
319
- return {
320
- id: r.id,
321
- webhookId: r.webhook_id,
322
- eventId: r.event_id,
323
- eventType: r.event_type,
324
- url: r.url,
325
- method: r.method ?? undefined,
326
- headers: r.headers_json ? JSON.parse(r.headers_json) : undefined,
327
- secret: r.secret ?? undefined,
328
- timeoutMs: r.timeout_ms ?? undefined,
329
- payload: JSON.parse(r.payload_json),
330
- status: r.status,
331
- attempts: r.attempts,
332
- claimedBy: r.claimed_by ?? undefined,
333
- claimedAt: r.claimed_at ?? undefined,
334
- nextRetryAt: r.next_retry_at ?? undefined,
335
- lastAttemptedAt: r.last_attempted_at ?? undefined,
336
- responseCode: r.response_code ?? undefined,
337
- responseBody: r.response_body ?? undefined,
338
- error: r.error ?? undefined,
339
- createdAt: r.created_at,
340
- updatedAt: r.updated_at,
341
- };
342
- }
343
- }
@@ -1,224 +0,0 @@
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
- actions: [
46
- {
47
- name: 'redeliver',
48
- label: 'Redeliver',
49
- icon: 'refresh-cw',
50
- variant: 'secondary',
51
- locations: ['list_item', 'record_header'],
52
- type: 'api',
53
- target: '/api/v1/webhooks/redeliver',
54
- method: 'POST',
55
- recordIdParam: 'deliveryId',
56
- confirmText:
57
- 'Replay this delivery? The receiver will get the original payload again — they must be idempotent on the X-Objectstack-Delivery header.',
58
- successMessage: 'Queued for redelivery',
59
- refreshAfter: true,
60
- // Only terminal rows are safe to replay. Pending / in_flight rows
61
- // are either already queued or actively being sent — replaying
62
- // would double-deliver.
63
- disabled: "!(status in ['success', 'failed', 'dead'])",
64
- },
65
- ],
66
-
67
- listViews: {
68
- recent: {
69
- type: 'grid',
70
- name: 'recent',
71
- label: 'Recent',
72
- data: { provider: 'object', object: 'sys_webhook_delivery' },
73
- columns: ['event_type', 'url', 'status', 'attempts', 'response_code', 'updated_at'],
74
- sort: [{ field: 'updated_at', order: 'desc' }],
75
- pagination: { pageSize: 50 },
76
- },
77
- failures: {
78
- type: 'grid',
79
- name: 'failures',
80
- label: 'Failures',
81
- data: { provider: 'object', object: 'sys_webhook_delivery' },
82
- columns: ['event_type', 'url', 'status', 'attempts', 'response_code', 'error', 'updated_at'],
83
- filter: [{ field: 'status', operator: 'in', value: ['failed', 'dead'] }],
84
- sort: [{ field: 'updated_at', order: 'desc' }],
85
- pagination: { pageSize: 50 },
86
- },
87
- in_flight: {
88
- type: 'grid',
89
- name: 'in_flight',
90
- label: 'In Flight',
91
- data: { provider: 'object', object: 'sys_webhook_delivery' },
92
- columns: ['event_type', 'url', 'attempts', 'claimed_by', 'claimed_at'],
93
- filter: [{ field: 'status', operator: 'equals', value: 'in_flight' }],
94
- sort: [{ field: 'claimed_at', order: 'desc' }],
95
- pagination: { pageSize: 50 },
96
- },
97
- pending: {
98
- type: 'grid',
99
- name: 'pending',
100
- label: 'Pending',
101
- data: { provider: 'object', object: 'sys_webhook_delivery' },
102
- columns: ['event_type', 'url', 'attempts', 'next_retry_at', 'updated_at'],
103
- filter: [{ field: 'status', operator: 'equals', value: 'pending' }],
104
- sort: [{ field: 'next_retry_at', order: 'asc' }],
105
- pagination: { pageSize: 50 },
106
- },
107
- by_status: {
108
- type: 'grid',
109
- name: 'by_status',
110
- label: 'By Status',
111
- data: { provider: 'object', object: 'sys_webhook_delivery' },
112
- columns: ['status', 'event_type', 'url', 'attempts', 'updated_at'],
113
- sort: [{ field: 'status', order: 'asc' }, { field: 'updated_at', order: 'desc' }],
114
- grouping: { fields: [{ field: 'status', order: 'asc', collapsed: false }] },
115
- pagination: { pageSize: 100 },
116
- },
117
- by_webhook: {
118
- type: 'grid',
119
- name: 'by_webhook',
120
- label: 'By Webhook',
121
- data: { provider: 'object', object: 'sys_webhook_delivery' },
122
- columns: ['webhook_id', 'event_type', 'status', 'attempts', 'updated_at'],
123
- sort: [{ field: 'webhook_id', order: 'asc' }, { field: 'updated_at', order: 'desc' }],
124
- grouping: { fields: [{ field: 'webhook_id', order: 'asc', collapsed: true }] },
125
- pagination: { pageSize: 100 },
126
- },
127
- all_deliveries: {
128
- type: 'grid',
129
- name: 'all_deliveries',
130
- label: 'All',
131
- data: { provider: 'object', object: 'sys_webhook_delivery' },
132
- columns: ['event_type', 'url', 'status', 'attempts', 'response_code', 'updated_at'],
133
- sort: [{ field: 'updated_at', order: 'desc' }],
134
- pagination: { pageSize: 100 },
135
- },
136
- },
137
-
138
- fields: {
139
- id: Field.text({
140
- label: 'Delivery ID',
141
- required: true,
142
- maxLength: 64,
143
- description: 'UUID — also doubles as the receiver-side idempotency key',
144
- }),
145
-
146
- webhook_id: Field.text({
147
- label: 'Webhook ID',
148
- required: true,
149
- maxLength: 64,
150
- description: 'FK to sys_webhook.id (loosely coupled — denormalised URL/secret on row)',
151
- }),
152
-
153
- event_id: Field.text({
154
- label: 'Event ID',
155
- required: true,
156
- maxLength: 128,
157
- description: 'Source event id; UNIQUE(event_id, webhook_id) for dedup',
158
- }),
159
-
160
- event_type: Field.text({
161
- label: 'Event Type',
162
- required: true,
163
- maxLength: 128,
164
- description: 'e.g. data.record.created',
165
- }),
166
-
167
- url: Field.text({
168
- label: 'Target URL',
169
- required: true,
170
- maxLength: 2048,
171
- description: 'Snapshotted at enqueue so config edits do not rewrite live rows',
172
- }),
173
-
174
- method: Field.text({ label: 'Method', required: false, maxLength: 10 }),
175
- headers_json: Field.textarea({ label: 'Headers JSON', required: false }),
176
- secret: Field.text({ label: 'HMAC Secret', required: false, maxLength: 256 }),
177
- timeout_ms: Field.number({ label: 'Timeout (ms)', required: false }),
178
- payload_json: Field.textarea({ label: 'Payload JSON', required: true }),
179
-
180
- partition_key: Field.number({
181
- label: 'Partition',
182
- required: true,
183
- description: 'hash(webhook_id) mod partitionCount — precomputed for cheap WHERE',
184
- }),
185
-
186
- status: Field.text({
187
- label: 'Status',
188
- required: true,
189
- defaultValue: 'pending',
190
- maxLength: 16,
191
- description: 'pending | in_flight | success | failed | dead',
192
- }),
193
-
194
- attempts: Field.number({
195
- label: 'Attempts',
196
- required: true,
197
- defaultValue: 0,
198
- description: 'Number of POST attempts made so far',
199
- }),
200
-
201
- claimed_by: Field.text({ label: 'Claimed By', required: false, maxLength: 128 }),
202
- claimed_at: Field.number({ label: 'Claimed At (ms)', required: false }),
203
- next_retry_at: Field.number({ label: 'Next Retry At (ms)', required: false }),
204
- last_attempted_at: Field.number({ label: 'Last Attempted At (ms)', required: false }),
205
- response_code: Field.number({ label: 'HTTP Status', required: false }),
206
- response_body: Field.textarea({ label: 'Response Body (capped)', required: false }),
207
- error: Field.textarea({ label: 'Error', required: false }),
208
-
209
- created_at: Field.number({ label: 'Created At (ms)', required: true }),
210
- updated_at: Field.number({ label: 'Updated At (ms)', required: true }),
211
- },
212
-
213
- indexes: [
214
- { fields: ['event_id', 'webhook_id'], unique: true },
215
- // Hot path: claim query
216
- { fields: ['status', 'partition_key', 'next_retry_at'] },
217
- // Reaper: scan stale in_flight rows by claimed_at
218
- { fields: ['status', 'claimed_at'] },
219
- { fields: ['webhook_id'] },
220
- ],
221
- });
222
-
223
- /** Canonical object name — exported so SqlWebhookOutbox callers can override if needed. */
224
- export const SYS_WEBHOOK_DELIVERY = 'sys_webhook_delivery' as const;