@objectstack/plugin-webhooks 5.2.0 → 6.0.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 (43) hide show
  1. package/.turbo/turbo-build.log +28 -28
  2. package/CHANGELOG.md +12 -0
  3. package/dist/{chunk-JN76ZRWN.js → chunk-33LYZT7O.js} +21 -1
  4. package/dist/chunk-33LYZT7O.js.map +1 -0
  5. package/dist/chunk-BS2QTZH3.js +256 -0
  6. package/dist/chunk-BS2QTZH3.js.map +1 -0
  7. package/dist/chunk-FA66GQEO.cjs +256 -0
  8. package/dist/chunk-FA66GQEO.cjs.map +1 -0
  9. package/dist/{chunk-OW7ESXOK.cjs → chunk-MJZGD37S.cjs} +21 -1
  10. package/dist/chunk-MJZGD37S.cjs.map +1 -0
  11. package/dist/index.cjs +175 -14
  12. package/dist/index.cjs.map +1 -1
  13. package/dist/index.d.cts +16 -2
  14. package/dist/index.d.ts +16 -2
  15. package/dist/index.js +167 -6
  16. package/dist/index.js.map +1 -1
  17. package/dist/{outbox-bPQmKYPN.d.cts → outbox-CIn7LSyB.d.cts} +28 -1
  18. package/dist/{outbox-bPQmKYPN.d.ts → outbox-CIn7LSyB.d.ts} +28 -1
  19. package/dist/schema.cjs +2 -2
  20. package/dist/schema.d.cts +16 -1
  21. package/dist/schema.d.ts +16 -1
  22. package/dist/schema.js +1 -1
  23. package/dist/sql-outbox.cjs +4 -180
  24. package/dist/sql-outbox.cjs.map +1 -1
  25. package/dist/sql-outbox.d.cts +2 -1
  26. package/dist/sql-outbox.d.ts +2 -1
  27. package/dist/sql-outbox.js +3 -179
  28. package/dist/sql-outbox.js.map +1 -1
  29. package/package.json +5 -5
  30. package/src/index.ts +1 -0
  31. package/src/memory-outbox.test.ts +86 -0
  32. package/src/memory-outbox.ts +28 -0
  33. package/src/outbox.ts +34 -0
  34. package/src/sql-outbox.test.ts +80 -0
  35. package/src/sql-outbox.ts +61 -0
  36. package/src/sys-webhook-delivery.object.ts +22 -0
  37. package/src/webhook-outbox-plugin.ts +167 -5
  38. package/dist/chunk-JN76ZRWN.js.map +0 -1
  39. package/dist/chunk-M4M5FWIH.cjs +0 -15
  40. package/dist/chunk-M4M5FWIH.cjs.map +0 -1
  41. package/dist/chunk-NYSUNT6X.js +0 -15
  42. package/dist/chunk-NYSUNT6X.js.map +0 -1
  43. package/dist/chunk-OW7ESXOK.cjs.map +0 -1
@@ -99,6 +99,16 @@ interface AckFailure {
99
99
  dead?: boolean;
100
100
  }
101
101
  type AckResult = AckSuccess | AckFailure;
102
+ /**
103
+ * Error raised by `IWebhookOutbox.redeliver` when the requested row is
104
+ * either missing or in a non-terminal state. The dispatcher / admin UI
105
+ * surfaces this verbatim to the caller — never throw it for transient
106
+ * conditions (transport errors should bubble as native `Error`).
107
+ */
108
+ declare class RedeliverError extends Error {
109
+ readonly code: 'not_found' | 'not_eligible';
110
+ constructor(message: string, code: 'not_found' | 'not_eligible');
111
+ }
102
112
  /**
103
113
  * Pluggable storage backend for delivery rows. Implementations MUST make
104
114
  * `claim()` atomic across concurrent callers — that property is the
@@ -123,6 +133,23 @@ interface IWebhookOutbox {
123
133
  list(filter?: {
124
134
  status?: DeliveryStatus;
125
135
  }): Promise<WebhookDelivery[]>;
136
+ /**
137
+ * Reset a terminal row back to `pending` so the dispatcher will pick
138
+ * it up again on its next tick.
139
+ *
140
+ * - Eligible source states: `success`, `failed`, `dead`.
141
+ * - Rejects `pending` / `in_flight` rows — replaying those would
142
+ * double-deliver because they're either already queued or actively
143
+ * being sent.
144
+ * - Resets `attempts=0` so the retry budget restarts.
145
+ * - Clears `claimed_by`, `claimed_at`, `next_retry_at`, `error`,
146
+ * `response_code`, `response_body`. URL / payload / secret are NOT
147
+ * touched — replay reproduces the original POST byte-for-byte.
148
+ *
149
+ * Throws `RedeliverError` with code `not_found` or `not_eligible`.
150
+ * Returns the post-reset row.
151
+ */
152
+ redeliver(id: string): Promise<WebhookDelivery>;
126
153
  }
127
154
 
128
- export type { AckFailure as A, ClaimOptions as C, DeliveryStatus as D, EnqueueInput as E, IWebhookOutbox as I, WebhookDelivery as W, AckResult as a, AckSuccess as b };
155
+ export { type AckFailure as A, type ClaimOptions as C, type DeliveryStatus as D, type EnqueueInput as E, type IWebhookOutbox as I, RedeliverError as R, type WebhookDelivery as W, type AckResult as a, type AckSuccess as b };
@@ -99,6 +99,16 @@ interface AckFailure {
99
99
  dead?: boolean;
100
100
  }
101
101
  type AckResult = AckSuccess | AckFailure;
102
+ /**
103
+ * Error raised by `IWebhookOutbox.redeliver` when the requested row is
104
+ * either missing or in a non-terminal state. The dispatcher / admin UI
105
+ * surfaces this verbatim to the caller — never throw it for transient
106
+ * conditions (transport errors should bubble as native `Error`).
107
+ */
108
+ declare class RedeliverError extends Error {
109
+ readonly code: 'not_found' | 'not_eligible';
110
+ constructor(message: string, code: 'not_found' | 'not_eligible');
111
+ }
102
112
  /**
103
113
  * Pluggable storage backend for delivery rows. Implementations MUST make
104
114
  * `claim()` atomic across concurrent callers — that property is the
@@ -123,6 +133,23 @@ interface IWebhookOutbox {
123
133
  list(filter?: {
124
134
  status?: DeliveryStatus;
125
135
  }): Promise<WebhookDelivery[]>;
136
+ /**
137
+ * Reset a terminal row back to `pending` so the dispatcher will pick
138
+ * it up again on its next tick.
139
+ *
140
+ * - Eligible source states: `success`, `failed`, `dead`.
141
+ * - Rejects `pending` / `in_flight` rows — replaying those would
142
+ * double-deliver because they're either already queued or actively
143
+ * being sent.
144
+ * - Resets `attempts=0` so the retry budget restarts.
145
+ * - Clears `claimed_by`, `claimed_at`, `next_retry_at`, `error`,
146
+ * `response_code`, `response_body`. URL / payload / secret are NOT
147
+ * touched — replay reproduces the original POST byte-for-byte.
148
+ *
149
+ * Throws `RedeliverError` with code `not_found` or `not_eligible`.
150
+ * Returns the post-reset row.
151
+ */
152
+ redeliver(id: string): Promise<WebhookDelivery>;
126
153
  }
127
154
 
128
- export type { AckFailure as A, ClaimOptions as C, DeliveryStatus as D, EnqueueInput as E, IWebhookOutbox as I, WebhookDelivery as W, AckResult as a, AckSuccess as b };
155
+ export { type AckFailure as A, type ClaimOptions as C, type DeliveryStatus as D, type EnqueueInput as E, type IWebhookOutbox as I, RedeliverError as R, type WebhookDelivery as W, type AckResult as a, type AckSuccess as b };
package/dist/schema.cjs CHANGED
@@ -1,9 +1,9 @@
1
1
  "use strict";Object.defineProperty(exports, "__esModule", {value: true});
2
2
 
3
3
 
4
- var _chunkOW7ESXOKcjs = require('./chunk-OW7ESXOK.cjs');
4
+ var _chunkMJZGD37Scjs = require('./chunk-MJZGD37S.cjs');
5
5
 
6
6
 
7
7
 
8
- exports.SYS_WEBHOOK_DELIVERY = _chunkOW7ESXOKcjs.SYS_WEBHOOK_DELIVERY; exports.SysWebhookDelivery = _chunkOW7ESXOKcjs.SysWebhookDelivery;
8
+ exports.SYS_WEBHOOK_DELIVERY = _chunkMJZGD37Scjs.SYS_WEBHOOK_DELIVERY; exports.SysWebhookDelivery = _chunkMJZGD37Scjs.SysWebhookDelivery;
9
9
  //# sourceMappingURL=schema.cjs.map
package/dist/schema.d.cts CHANGED
@@ -72,7 +72,7 @@ declare const SysWebhookDelivery: Omit<{
72
72
  abstract: boolean;
73
73
  datasource: string;
74
74
  fields: Record<string, {
75
- type: "number" | "boolean" | "date" | "file" | "tags" | "code" | "datetime" | "signature" | "progress" | "url" | "text" | "textarea" | "email" | "phone" | "password" | "markdown" | "html" | "richtext" | "currency" | "percent" | "time" | "toggle" | "select" | "multiselect" | "radio" | "checkboxes" | "lookup" | "master_detail" | "tree" | "image" | "avatar" | "video" | "audio" | "formula" | "summary" | "autonumber" | "location" | "address" | "json" | "color" | "rating" | "slider" | "qrcode" | "vector";
75
+ type: "number" | "boolean" | "date" | "file" | "url" | "tags" | "code" | "datetime" | "signature" | "progress" | "text" | "textarea" | "email" | "phone" | "password" | "markdown" | "html" | "richtext" | "currency" | "percent" | "time" | "toggle" | "select" | "multiselect" | "radio" | "checkboxes" | "lookup" | "master_detail" | "tree" | "image" | "avatar" | "video" | "audio" | "formula" | "summary" | "autonumber" | "location" | "address" | "json" | "color" | "rating" | "slider" | "qrcode" | "vector";
76
76
  required: boolean;
77
77
  searchable: boolean;
78
78
  multiple: boolean;
@@ -747,6 +747,21 @@ declare const SysWebhookDelivery: Omit<{
747
747
  readonly displayNameField: "id";
748
748
  readonly titleFormat: "{event_type} → {url}";
749
749
  readonly compactLayout: ["event_type", "url", "status", "attempts", "next_retry_at"];
750
+ readonly actions: [{
751
+ readonly name: "redeliver";
752
+ readonly label: "Redeliver";
753
+ readonly icon: "refresh-cw";
754
+ readonly variant: "secondary";
755
+ readonly locations: ["list_item", "record_header"];
756
+ readonly type: "api";
757
+ readonly target: "/api/v1/webhooks/redeliver";
758
+ readonly method: "POST";
759
+ readonly recordIdParam: "deliveryId";
760
+ readonly confirmText: "Replay this delivery? The receiver will get the original payload again — they must be idempotent on the X-Objectstack-Delivery header.";
761
+ readonly successMessage: "Queued for redelivery";
762
+ readonly refreshAfter: true;
763
+ readonly disabled: "!(status in ['success', 'failed', 'dead'])";
764
+ }];
750
765
  readonly listViews: {
751
766
  readonly recent: {
752
767
  readonly type: "grid";
package/dist/schema.d.ts CHANGED
@@ -72,7 +72,7 @@ declare const SysWebhookDelivery: Omit<{
72
72
  abstract: boolean;
73
73
  datasource: string;
74
74
  fields: Record<string, {
75
- type: "number" | "boolean" | "date" | "file" | "tags" | "code" | "datetime" | "signature" | "progress" | "url" | "text" | "textarea" | "email" | "phone" | "password" | "markdown" | "html" | "richtext" | "currency" | "percent" | "time" | "toggle" | "select" | "multiselect" | "radio" | "checkboxes" | "lookup" | "master_detail" | "tree" | "image" | "avatar" | "video" | "audio" | "formula" | "summary" | "autonumber" | "location" | "address" | "json" | "color" | "rating" | "slider" | "qrcode" | "vector";
75
+ type: "number" | "boolean" | "date" | "file" | "url" | "tags" | "code" | "datetime" | "signature" | "progress" | "text" | "textarea" | "email" | "phone" | "password" | "markdown" | "html" | "richtext" | "currency" | "percent" | "time" | "toggle" | "select" | "multiselect" | "radio" | "checkboxes" | "lookup" | "master_detail" | "tree" | "image" | "avatar" | "video" | "audio" | "formula" | "summary" | "autonumber" | "location" | "address" | "json" | "color" | "rating" | "slider" | "qrcode" | "vector";
76
76
  required: boolean;
77
77
  searchable: boolean;
78
78
  multiple: boolean;
@@ -747,6 +747,21 @@ declare const SysWebhookDelivery: Omit<{
747
747
  readonly displayNameField: "id";
748
748
  readonly titleFormat: "{event_type} → {url}";
749
749
  readonly compactLayout: ["event_type", "url", "status", "attempts", "next_retry_at"];
750
+ readonly actions: [{
751
+ readonly name: "redeliver";
752
+ readonly label: "Redeliver";
753
+ readonly icon: "refresh-cw";
754
+ readonly variant: "secondary";
755
+ readonly locations: ["list_item", "record_header"];
756
+ readonly type: "api";
757
+ readonly target: "/api/v1/webhooks/redeliver";
758
+ readonly method: "POST";
759
+ readonly recordIdParam: "deliveryId";
760
+ readonly confirmText: "Replay this delivery? The receiver will get the original payload again — they must be idempotent on the X-Objectstack-Delivery header.";
761
+ readonly successMessage: "Queued for redelivery";
762
+ readonly refreshAfter: true;
763
+ readonly disabled: "!(status in ['success', 'failed', 'dead'])";
764
+ }];
750
765
  readonly listViews: {
751
766
  readonly recent: {
752
767
  readonly type: "grid";
package/dist/schema.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  SYS_WEBHOOK_DELIVERY,
3
3
  SysWebhookDelivery
4
- } from "./chunk-JN76ZRWN.js";
4
+ } from "./chunk-33LYZT7O.js";
5
5
  export {
6
6
  SYS_WEBHOOK_DELIVERY,
7
7
  SysWebhookDelivery
@@ -1,184 +1,8 @@
1
- "use strict";Object.defineProperty(exports, "__esModule", {value: true}); function _nullishCoalesce(lhs, rhsFn) { if (lhs != null) { return lhs; } else { return rhsFn(); } } function _optionalChain(ops) { let lastAccessLHS = undefined; let value = ops[0]; let i = 1; while (i < ops.length) { const op = ops[i]; const fn = ops[i + 1]; i += 2; if ((op === 'optionalAccess' || op === 'optionalCall') && value == null) { return undefined; } if (op === 'access' || op === 'optionalAccess') { lastAccessLHS = value; value = fn(value); } else if (op === 'call' || op === 'optionalCall') { value = fn((...args) => value.call(lastAccessLHS, ...args)); lastAccessLHS = undefined; } } return value; }
1
+ "use strict";Object.defineProperty(exports, "__esModule", {value: true});
2
2
 
3
- var _chunkM4M5FWIHcjs = require('./chunk-M4M5FWIH.cjs');
3
+ var _chunkFA66GQEOcjs = require('./chunk-FA66GQEO.cjs');
4
+ require('./chunk-MJZGD37S.cjs');
4
5
 
5
6
 
6
- var _chunkOW7ESXOKcjs = require('./chunk-OW7ESXOK.cjs');
7
-
8
- // src/sql-outbox.ts
9
- var _crypto = require('crypto');
10
- var SqlWebhookOutbox = class {
11
- constructor(engine, opts) {
12
- this.engine = engine;
13
- if (opts.partitionCount <= 0) {
14
- throw new Error("SqlWebhookOutbox: partitionCount must be > 0");
15
- }
16
- this.objectName = _nullishCoalesce(opts.objectName, () => ( _chunkOW7ESXOKcjs.SYS_WEBHOOK_DELIVERY));
17
- this.partitionCount = opts.partitionCount;
18
- }
19
- async enqueue(input) {
20
- const existing = await this.engine.findOne(this.objectName, {
21
- where: { event_id: input.eventId, webhook_id: input.webhookId },
22
- fields: ["id"]
23
- });
24
- if (_optionalChain([existing, 'optionalAccess', _ => _.id])) return existing.id;
25
- const id = _crypto.randomUUID.call(void 0, );
26
- const now = Date.now();
27
- const row = {
28
- id,
29
- webhook_id: input.webhookId,
30
- event_id: input.eventId,
31
- event_type: input.eventType,
32
- url: input.url,
33
- method: _nullishCoalesce(input.method, () => ( "POST")),
34
- headers_json: input.headers ? JSON.stringify(input.headers) : void 0,
35
- secret: input.secret,
36
- timeout_ms: input.timeoutMs,
37
- payload_json: JSON.stringify(_nullishCoalesce(input.payload, () => ( null))),
38
- partition_key: _chunkM4M5FWIHcjs.hashPartition.call(void 0, input.webhookId, this.partitionCount),
39
- status: "pending",
40
- attempts: 0,
41
- created_at: now,
42
- updated_at: now
43
- };
44
- try {
45
- await this.engine.insert(this.objectName, row);
46
- return id;
47
- } catch (err) {
48
- const winner = await this.engine.findOne(this.objectName, {
49
- where: { event_id: input.eventId, webhook_id: input.webhookId },
50
- fields: ["id"]
51
- });
52
- if (_optionalChain([winner, 'optionalAccess', _2 => _2.id])) return winner.id;
53
- throw err;
54
- }
55
- }
56
- async claim(opts) {
57
- const now = _nullishCoalesce(opts.now, () => ( Date.now()));
58
- await this.engine.update(
59
- this.objectName,
60
- { status: "pending", claimed_by: null, claimed_at: null, updated_at: now },
61
- {
62
- where: {
63
- status: "in_flight",
64
- claimed_at: { $lt: now - opts.claimTtlMs }
65
- },
66
- multi: true
67
- }
68
- );
69
- const partitionFilter = opts.partition ? { partition_key: opts.partition.index } : {};
70
- const candidates = await this.engine.find(this.objectName, {
71
- where: {
72
- status: "pending",
73
- ...partitionFilter,
74
- // next_retry_at <= now OR null
75
- $or: [
76
- { next_retry_at: null },
77
- { next_retry_at: { $lte: now } }
78
- ]
79
- },
80
- fields: ["id"],
81
- // No orderBy for portability — drivers handle the natural insert order.
82
- limit: opts.limit
83
- });
84
- if (candidates.length === 0) return [];
85
- const ids = candidates.map((c) => c.id);
86
- await this.engine.update(
87
- this.objectName,
88
- {
89
- status: "in_flight",
90
- claimed_by: opts.nodeId,
91
- claimed_at: now,
92
- updated_at: now
93
- },
94
- {
95
- where: { id: { $in: ids }, status: "pending" },
96
- multi: true
97
- }
98
- );
99
- const claimed = await this.engine.find(this.objectName, {
100
- where: {
101
- id: { $in: ids },
102
- claimed_by: opts.nodeId,
103
- claimed_at: now,
104
- status: "in_flight"
105
- }
106
- });
107
- return claimed.map((r) => this.toDelivery(r));
108
- }
109
- async ack(id, result) {
110
- const current = await this.engine.findOne(this.objectName, {
111
- where: { id },
112
- fields: ["attempts"]
113
- });
114
- if (!current) return;
115
- const now = Date.now();
116
- let status;
117
- let nextRetryAt;
118
- let error;
119
- if (result.success) {
120
- status = "success";
121
- nextRetryAt = null;
122
- error = null;
123
- } else if (result.dead) {
124
- status = "dead";
125
- nextRetryAt = null;
126
- error = _nullishCoalesce(result.error, () => ( null));
127
- } else {
128
- status = "pending";
129
- nextRetryAt = _nullishCoalesce(result.nextRetryAt, () => ( null));
130
- error = _nullishCoalesce(result.error, () => ( null));
131
- }
132
- await this.engine.update(
133
- this.objectName,
134
- {
135
- status,
136
- attempts: (_nullishCoalesce(current.attempts, () => ( 0))) + 1,
137
- last_attempted_at: now,
138
- claimed_by: null,
139
- claimed_at: null,
140
- response_code: _nullishCoalesce(result.httpStatus, () => ( null)),
141
- response_body: _nullishCoalesce(result.responseBody, () => ( null)),
142
- next_retry_at: nextRetryAt,
143
- error,
144
- updated_at: now
145
- },
146
- { where: { id }, multi: false }
147
- );
148
- }
149
- async list(filter) {
150
- const rows = await this.engine.find(this.objectName, {
151
- where: _optionalChain([filter, 'optionalAccess', _3 => _3.status]) ? { status: filter.status } : {}
152
- });
153
- return rows.map((r) => this.toDelivery(r));
154
- }
155
- toDelivery(r) {
156
- return {
157
- id: r.id,
158
- webhookId: r.webhook_id,
159
- eventId: r.event_id,
160
- eventType: r.event_type,
161
- url: r.url,
162
- method: _nullishCoalesce(r.method, () => ( void 0)),
163
- headers: r.headers_json ? JSON.parse(r.headers_json) : void 0,
164
- secret: _nullishCoalesce(r.secret, () => ( void 0)),
165
- timeoutMs: _nullishCoalesce(r.timeout_ms, () => ( void 0)),
166
- payload: JSON.parse(r.payload_json),
167
- status: r.status,
168
- attempts: r.attempts,
169
- claimedBy: _nullishCoalesce(r.claimed_by, () => ( void 0)),
170
- claimedAt: _nullishCoalesce(r.claimed_at, () => ( void 0)),
171
- nextRetryAt: _nullishCoalesce(r.next_retry_at, () => ( void 0)),
172
- lastAttemptedAt: _nullishCoalesce(r.last_attempted_at, () => ( void 0)),
173
- responseCode: _nullishCoalesce(r.response_code, () => ( void 0)),
174
- responseBody: _nullishCoalesce(r.response_body, () => ( void 0)),
175
- error: _nullishCoalesce(r.error, () => ( void 0)),
176
- createdAt: r.created_at,
177
- updatedAt: r.updated_at
178
- };
179
- }
180
- };
181
-
182
-
183
- exports.SqlWebhookOutbox = SqlWebhookOutbox;
7
+ exports.SqlWebhookOutbox = _chunkFA66GQEOcjs.SqlWebhookOutbox;
184
8
  //# sourceMappingURL=sql-outbox.cjs.map
@@ -1 +1 @@
1
- {"version":3,"sources":["/home/runner/work/framework/framework/packages/plugins/plugin-webhooks/dist/sql-outbox.cjs","../src/sql-outbox.ts"],"names":[],"mappings":"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","file":"/home/runner/work/framework/framework/packages/plugins/plugin-webhooks/dist/sql-outbox.cjs","sourcesContent":[null,"// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.\n\nimport { randomUUID } from 'node:crypto';\nimport type { IDataEngine } from '@objectstack/spec/contracts';\nimport type {\n AckResult,\n ClaimOptions,\n DeliveryStatus,\n EnqueueInput,\n IWebhookOutbox,\n WebhookDelivery,\n} from './outbox.js';\nimport { hashPartition } from './partition.js';\nimport { SYS_WEBHOOK_DELIVERY } from './schema.js';\n\nexport interface SqlWebhookOutboxOptions {\n /**\n * Total partition count — MUST match the dispatcher's `partitionCount`.\n * Used at enqueue time to precompute `partition_key`.\n */\n partitionCount: number;\n /**\n * Object name to read/write. Defaults to `sys_webhook_delivery`. Override\n * only if you've registered the schema under a different name.\n */\n objectName?: string;\n}\n\ninterface DeliveryRow {\n id: string;\n webhook_id: string;\n event_id: string;\n event_type: string;\n url: string;\n method?: string | null;\n headers_json?: string | null;\n secret?: string | null;\n timeout_ms?: number | null;\n payload_json: string;\n partition_key: number;\n status: DeliveryStatus;\n attempts: number;\n claimed_by?: string | null;\n claimed_at?: number | null;\n next_retry_at?: number | null;\n last_attempted_at?: number | null;\n response_code?: number | null;\n response_body?: string | null;\n error?: string | null;\n created_at: number;\n updated_at: number;\n}\n\n/**\n * Durable `IWebhookOutbox` backed by ObjectQL — the production storage\n * impl. Works against any registered driver (SQL, Turso, Mongo, in-memory)\n * because everything goes through the driver-agnostic `IDataEngine` API.\n *\n * **Why no `FOR UPDATE SKIP LOCKED`?** ObjectQL is driver-agnostic — that\n * SQL feature is Postgres-only. We get equivalent safety from two layers:\n *\n * 1. `cluster.lock` held per partition by the dispatcher (the primary\n * mutex). One node owns one partition at a time → no two claimers.\n * 2. Atomic `UPDATE WHERE status='pending'` (the backup). Even if two\n * claimers slip through (e.g. admin reschedule + dispatcher), only\n * the first UPDATE matches each row.\n *\n * **Why precompute `partition_key` on enqueue?** ObjectQL has no\n * cross-driver `hash()` function in WHERE clauses. Storing the partition\n * as a column makes the claim query a plain indexed lookup.\n *\n * **Dedup race**: SELECT-then-INSERT has a tiny window where two\n * concurrent producers both miss the SELECT and both INSERT. The unique\n * index `(event_id, webhook_id)` on the table catches it — the second\n * INSERT errors, the producer ignores it. Receivers MUST be idempotent\n * on the `X-Objectstack-Delivery` header anyway.\n */\nexport class SqlWebhookOutbox implements IWebhookOutbox {\n private readonly objectName: string;\n private readonly partitionCount: number;\n\n constructor(\n private readonly engine: IDataEngine,\n opts: SqlWebhookOutboxOptions,\n ) {\n if (opts.partitionCount <= 0) {\n throw new Error('SqlWebhookOutbox: partitionCount must be > 0');\n }\n this.objectName = opts.objectName ?? SYS_WEBHOOK_DELIVERY;\n this.partitionCount = opts.partitionCount;\n }\n\n async enqueue(input: EnqueueInput): Promise<string> {\n // Cheap pre-check to absorb most duplicates without hitting the\n // unique-index error path. Race window with the INSERT below is\n // intentional and documented.\n const existing = await this.engine.findOne(this.objectName, {\n where: { event_id: input.eventId, webhook_id: input.webhookId },\n fields: ['id'],\n });\n if (existing?.id) return existing.id as string;\n\n const id = randomUUID();\n const now = Date.now();\n const row: Omit<DeliveryRow, 'response_body' | 'error'> = {\n id,\n webhook_id: input.webhookId,\n event_id: input.eventId,\n event_type: input.eventType,\n url: input.url,\n method: input.method ?? 'POST',\n headers_json: input.headers ? JSON.stringify(input.headers) : undefined,\n secret: input.secret,\n timeout_ms: input.timeoutMs,\n payload_json: JSON.stringify(input.payload ?? null),\n partition_key: hashPartition(input.webhookId, this.partitionCount),\n status: 'pending',\n attempts: 0,\n created_at: now,\n updated_at: now,\n };\n try {\n await this.engine.insert(this.objectName, row);\n return id;\n } catch (err) {\n // Unique-index collision (dedup race) → look up the winner and\n // return its id. Any other error propagates.\n const winner = await this.engine.findOne(this.objectName, {\n where: { event_id: input.eventId, webhook_id: input.webhookId },\n fields: ['id'],\n });\n if (winner?.id) return winner.id as string;\n throw err;\n }\n }\n\n async claim(opts: ClaimOptions): Promise<WebhookDelivery[]> {\n const now = opts.now ?? Date.now();\n\n // 1. Reap stale in_flight rows — visibility-timeout recovery.\n await this.engine.update(\n this.objectName,\n { status: 'pending', claimed_by: null, claimed_at: null, updated_at: now },\n {\n where: {\n status: 'in_flight',\n claimed_at: { $lt: now - opts.claimTtlMs },\n },\n multi: true,\n },\n );\n\n // 2. Pick candidate ids.\n const partitionFilter = opts.partition\n ? { partition_key: opts.partition.index }\n : {};\n const candidates = await this.engine.find(this.objectName, {\n where: {\n status: 'pending',\n ...partitionFilter,\n // next_retry_at <= now OR null\n $or: [\n { next_retry_at: null },\n { next_retry_at: { $lte: now } },\n ],\n },\n fields: ['id'],\n // No orderBy for portability — drivers handle the natural insert order.\n limit: opts.limit,\n });\n if (candidates.length === 0) return [];\n\n const ids = (candidates as Array<{ id: string }>).map((c) => c.id);\n\n // 3. Atomic claim. WHERE status='pending' rejects any rows another\n // worker swept up between steps 2 and 3.\n await this.engine.update(\n this.objectName,\n {\n status: 'in_flight',\n claimed_by: opts.nodeId,\n claimed_at: now,\n updated_at: now,\n },\n {\n where: { id: { $in: ids }, status: 'pending' },\n multi: true,\n },\n );\n\n // 4. Read back the rows we actually own.\n const claimed = (await this.engine.find(this.objectName, {\n where: {\n id: { $in: ids },\n claimed_by: opts.nodeId,\n claimed_at: now,\n status: 'in_flight',\n },\n })) as DeliveryRow[];\n\n return claimed.map((r) => this.toDelivery(r));\n }\n\n async ack(id: string, result: AckResult): Promise<void> {\n // ObjectQL has no atomic $inc across drivers, so read-then-write.\n // Safe enough: ack is single-writer per row (only the claimer acks).\n const current = (await this.engine.findOne(this.objectName, {\n where: { id },\n fields: ['attempts'],\n })) as { attempts?: number } | null;\n if (!current) return;\n\n const now = Date.now();\n let status: DeliveryStatus;\n let nextRetryAt: number | null;\n let error: string | null;\n\n if (result.success) {\n status = 'success';\n nextRetryAt = null;\n error = null;\n } else if (result.dead) {\n status = 'dead';\n nextRetryAt = null;\n error = result.error ?? null;\n } else {\n status = 'pending';\n nextRetryAt = result.nextRetryAt ?? null;\n error = result.error ?? null;\n }\n\n await this.engine.update(\n this.objectName,\n {\n status,\n attempts: (current.attempts ?? 0) + 1,\n last_attempted_at: now,\n claimed_by: null,\n claimed_at: null,\n response_code: result.httpStatus ?? null,\n response_body: result.responseBody ?? null,\n next_retry_at: nextRetryAt,\n error,\n updated_at: now,\n },\n { where: { id }, multi: false },\n );\n }\n\n async list(filter?: { status?: DeliveryStatus }): Promise<WebhookDelivery[]> {\n const rows = (await this.engine.find(this.objectName, {\n where: filter?.status ? { status: filter.status } : {},\n })) as DeliveryRow[];\n return rows.map((r) => this.toDelivery(r));\n }\n\n private toDelivery(r: DeliveryRow): WebhookDelivery {\n return {\n id: r.id,\n webhookId: r.webhook_id,\n eventId: r.event_id,\n eventType: r.event_type,\n url: r.url,\n method: r.method ?? undefined,\n headers: r.headers_json ? JSON.parse(r.headers_json) : undefined,\n secret: r.secret ?? undefined,\n timeoutMs: r.timeout_ms ?? undefined,\n payload: JSON.parse(r.payload_json),\n status: r.status,\n attempts: r.attempts,\n claimedBy: r.claimed_by ?? undefined,\n claimedAt: r.claimed_at ?? undefined,\n nextRetryAt: r.next_retry_at ?? undefined,\n lastAttemptedAt: r.last_attempted_at ?? undefined,\n responseCode: r.response_code ?? undefined,\n responseBody: r.response_body ?? undefined,\n error: r.error ?? undefined,\n createdAt: r.created_at,\n updatedAt: r.updated_at,\n };\n }\n}\n"]}
1
+ {"version":3,"sources":["/home/runner/work/framework/framework/packages/plugins/plugin-webhooks/dist/sql-outbox.cjs"],"names":[],"mappings":"AAAA;AACE;AACF,wDAA6B;AAC7B,gCAA6B;AAC7B;AACE;AACF,8DAAC","file":"/home/runner/work/framework/framework/packages/plugins/plugin-webhooks/dist/sql-outbox.cjs"}
@@ -1,5 +1,5 @@
1
1
  import { IDataEngine } from '@objectstack/spec/contracts';
2
- import { I as IWebhookOutbox, E as EnqueueInput, C as ClaimOptions, W as WebhookDelivery, a as AckResult, D as DeliveryStatus } from './outbox-bPQmKYPN.cjs';
2
+ import { I as IWebhookOutbox, E as EnqueueInput, C as ClaimOptions, W as WebhookDelivery, a as AckResult, D as DeliveryStatus } from './outbox-CIn7LSyB.cjs';
3
3
 
4
4
  interface SqlWebhookOutboxOptions {
5
5
  /**
@@ -48,6 +48,7 @@ declare class SqlWebhookOutbox implements IWebhookOutbox {
48
48
  list(filter?: {
49
49
  status?: DeliveryStatus;
50
50
  }): Promise<WebhookDelivery[]>;
51
+ redeliver(id: string): Promise<WebhookDelivery>;
51
52
  private toDelivery;
52
53
  }
53
54
 
@@ -1,5 +1,5 @@
1
1
  import { IDataEngine } from '@objectstack/spec/contracts';
2
- import { I as IWebhookOutbox, E as EnqueueInput, C as ClaimOptions, W as WebhookDelivery, a as AckResult, D as DeliveryStatus } from './outbox-bPQmKYPN.js';
2
+ import { I as IWebhookOutbox, E as EnqueueInput, C as ClaimOptions, W as WebhookDelivery, a as AckResult, D as DeliveryStatus } from './outbox-CIn7LSyB.js';
3
3
 
4
4
  interface SqlWebhookOutboxOptions {
5
5
  /**
@@ -48,6 +48,7 @@ declare class SqlWebhookOutbox implements IWebhookOutbox {
48
48
  list(filter?: {
49
49
  status?: DeliveryStatus;
50
50
  }): Promise<WebhookDelivery[]>;
51
+ redeliver(id: string): Promise<WebhookDelivery>;
51
52
  private toDelivery;
52
53
  }
53
54
 
@@ -1,183 +1,7 @@
1
1
  import {
2
- hashPartition
3
- } from "./chunk-NYSUNT6X.js";
4
- import {
5
- SYS_WEBHOOK_DELIVERY
6
- } from "./chunk-JN76ZRWN.js";
7
-
8
- // src/sql-outbox.ts
9
- import { randomUUID } from "crypto";
10
- var SqlWebhookOutbox = class {
11
- constructor(engine, opts) {
12
- this.engine = engine;
13
- if (opts.partitionCount <= 0) {
14
- throw new Error("SqlWebhookOutbox: partitionCount must be > 0");
15
- }
16
- this.objectName = opts.objectName ?? SYS_WEBHOOK_DELIVERY;
17
- this.partitionCount = opts.partitionCount;
18
- }
19
- async enqueue(input) {
20
- const existing = await this.engine.findOne(this.objectName, {
21
- where: { event_id: input.eventId, webhook_id: input.webhookId },
22
- fields: ["id"]
23
- });
24
- if (existing?.id) return existing.id;
25
- const id = randomUUID();
26
- const now = Date.now();
27
- const row = {
28
- id,
29
- webhook_id: input.webhookId,
30
- event_id: input.eventId,
31
- event_type: input.eventType,
32
- url: input.url,
33
- method: input.method ?? "POST",
34
- headers_json: input.headers ? JSON.stringify(input.headers) : void 0,
35
- secret: input.secret,
36
- timeout_ms: input.timeoutMs,
37
- payload_json: JSON.stringify(input.payload ?? null),
38
- partition_key: hashPartition(input.webhookId, this.partitionCount),
39
- status: "pending",
40
- attempts: 0,
41
- created_at: now,
42
- updated_at: now
43
- };
44
- try {
45
- await this.engine.insert(this.objectName, row);
46
- return id;
47
- } catch (err) {
48
- const winner = await this.engine.findOne(this.objectName, {
49
- where: { event_id: input.eventId, webhook_id: input.webhookId },
50
- fields: ["id"]
51
- });
52
- if (winner?.id) return winner.id;
53
- throw err;
54
- }
55
- }
56
- async claim(opts) {
57
- const now = opts.now ?? Date.now();
58
- await this.engine.update(
59
- this.objectName,
60
- { status: "pending", claimed_by: null, claimed_at: null, updated_at: now },
61
- {
62
- where: {
63
- status: "in_flight",
64
- claimed_at: { $lt: now - opts.claimTtlMs }
65
- },
66
- multi: true
67
- }
68
- );
69
- const partitionFilter = opts.partition ? { partition_key: opts.partition.index } : {};
70
- const candidates = await this.engine.find(this.objectName, {
71
- where: {
72
- status: "pending",
73
- ...partitionFilter,
74
- // next_retry_at <= now OR null
75
- $or: [
76
- { next_retry_at: null },
77
- { next_retry_at: { $lte: now } }
78
- ]
79
- },
80
- fields: ["id"],
81
- // No orderBy for portability — drivers handle the natural insert order.
82
- limit: opts.limit
83
- });
84
- if (candidates.length === 0) return [];
85
- const ids = candidates.map((c) => c.id);
86
- await this.engine.update(
87
- this.objectName,
88
- {
89
- status: "in_flight",
90
- claimed_by: opts.nodeId,
91
- claimed_at: now,
92
- updated_at: now
93
- },
94
- {
95
- where: { id: { $in: ids }, status: "pending" },
96
- multi: true
97
- }
98
- );
99
- const claimed = await this.engine.find(this.objectName, {
100
- where: {
101
- id: { $in: ids },
102
- claimed_by: opts.nodeId,
103
- claimed_at: now,
104
- status: "in_flight"
105
- }
106
- });
107
- return claimed.map((r) => this.toDelivery(r));
108
- }
109
- async ack(id, result) {
110
- const current = await this.engine.findOne(this.objectName, {
111
- where: { id },
112
- fields: ["attempts"]
113
- });
114
- if (!current) return;
115
- const now = Date.now();
116
- let status;
117
- let nextRetryAt;
118
- let error;
119
- if (result.success) {
120
- status = "success";
121
- nextRetryAt = null;
122
- error = null;
123
- } else if (result.dead) {
124
- status = "dead";
125
- nextRetryAt = null;
126
- error = result.error ?? null;
127
- } else {
128
- status = "pending";
129
- nextRetryAt = result.nextRetryAt ?? null;
130
- error = result.error ?? null;
131
- }
132
- await this.engine.update(
133
- this.objectName,
134
- {
135
- status,
136
- attempts: (current.attempts ?? 0) + 1,
137
- last_attempted_at: now,
138
- claimed_by: null,
139
- claimed_at: null,
140
- response_code: result.httpStatus ?? null,
141
- response_body: result.responseBody ?? null,
142
- next_retry_at: nextRetryAt,
143
- error,
144
- updated_at: now
145
- },
146
- { where: { id }, multi: false }
147
- );
148
- }
149
- async list(filter) {
150
- const rows = await this.engine.find(this.objectName, {
151
- where: filter?.status ? { status: filter.status } : {}
152
- });
153
- return rows.map((r) => this.toDelivery(r));
154
- }
155
- toDelivery(r) {
156
- return {
157
- id: r.id,
158
- webhookId: r.webhook_id,
159
- eventId: r.event_id,
160
- eventType: r.event_type,
161
- url: r.url,
162
- method: r.method ?? void 0,
163
- headers: r.headers_json ? JSON.parse(r.headers_json) : void 0,
164
- secret: r.secret ?? void 0,
165
- timeoutMs: r.timeout_ms ?? void 0,
166
- payload: JSON.parse(r.payload_json),
167
- status: r.status,
168
- attempts: r.attempts,
169
- claimedBy: r.claimed_by ?? void 0,
170
- claimedAt: r.claimed_at ?? void 0,
171
- nextRetryAt: r.next_retry_at ?? void 0,
172
- lastAttemptedAt: r.last_attempted_at ?? void 0,
173
- responseCode: r.response_code ?? void 0,
174
- responseBody: r.response_body ?? void 0,
175
- error: r.error ?? void 0,
176
- createdAt: r.created_at,
177
- updatedAt: r.updated_at
178
- };
179
- }
180
- };
2
+ SqlWebhookOutbox
3
+ } from "./chunk-BS2QTZH3.js";
4
+ import "./chunk-33LYZT7O.js";
181
5
  export {
182
6
  SqlWebhookOutbox
183
7
  };
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/sql-outbox.ts"],"sourcesContent":["// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.\n\nimport { randomUUID } from 'node:crypto';\nimport type { IDataEngine } from '@objectstack/spec/contracts';\nimport type {\n AckResult,\n ClaimOptions,\n DeliveryStatus,\n EnqueueInput,\n IWebhookOutbox,\n WebhookDelivery,\n} from './outbox.js';\nimport { hashPartition } from './partition.js';\nimport { SYS_WEBHOOK_DELIVERY } from './schema.js';\n\nexport interface SqlWebhookOutboxOptions {\n /**\n * Total partition count — MUST match the dispatcher's `partitionCount`.\n * Used at enqueue time to precompute `partition_key`.\n */\n partitionCount: number;\n /**\n * Object name to read/write. Defaults to `sys_webhook_delivery`. Override\n * only if you've registered the schema under a different name.\n */\n objectName?: string;\n}\n\ninterface DeliveryRow {\n id: string;\n webhook_id: string;\n event_id: string;\n event_type: string;\n url: string;\n method?: string | null;\n headers_json?: string | null;\n secret?: string | null;\n timeout_ms?: number | null;\n payload_json: string;\n partition_key: number;\n status: DeliveryStatus;\n attempts: number;\n claimed_by?: string | null;\n claimed_at?: number | null;\n next_retry_at?: number | null;\n last_attempted_at?: number | null;\n response_code?: number | null;\n response_body?: string | null;\n error?: string | null;\n created_at: number;\n updated_at: number;\n}\n\n/**\n * Durable `IWebhookOutbox` backed by ObjectQL — the production storage\n * impl. Works against any registered driver (SQL, Turso, Mongo, in-memory)\n * because everything goes through the driver-agnostic `IDataEngine` API.\n *\n * **Why no `FOR UPDATE SKIP LOCKED`?** ObjectQL is driver-agnostic — that\n * SQL feature is Postgres-only. We get equivalent safety from two layers:\n *\n * 1. `cluster.lock` held per partition by the dispatcher (the primary\n * mutex). One node owns one partition at a time → no two claimers.\n * 2. Atomic `UPDATE WHERE status='pending'` (the backup). Even if two\n * claimers slip through (e.g. admin reschedule + dispatcher), only\n * the first UPDATE matches each row.\n *\n * **Why precompute `partition_key` on enqueue?** ObjectQL has no\n * cross-driver `hash()` function in WHERE clauses. Storing the partition\n * as a column makes the claim query a plain indexed lookup.\n *\n * **Dedup race**: SELECT-then-INSERT has a tiny window where two\n * concurrent producers both miss the SELECT and both INSERT. The unique\n * index `(event_id, webhook_id)` on the table catches it — the second\n * INSERT errors, the producer ignores it. Receivers MUST be idempotent\n * on the `X-Objectstack-Delivery` header anyway.\n */\nexport class SqlWebhookOutbox implements IWebhookOutbox {\n private readonly objectName: string;\n private readonly partitionCount: number;\n\n constructor(\n private readonly engine: IDataEngine,\n opts: SqlWebhookOutboxOptions,\n ) {\n if (opts.partitionCount <= 0) {\n throw new Error('SqlWebhookOutbox: partitionCount must be > 0');\n }\n this.objectName = opts.objectName ?? SYS_WEBHOOK_DELIVERY;\n this.partitionCount = opts.partitionCount;\n }\n\n async enqueue(input: EnqueueInput): Promise<string> {\n // Cheap pre-check to absorb most duplicates without hitting the\n // unique-index error path. Race window with the INSERT below is\n // intentional and documented.\n const existing = await this.engine.findOne(this.objectName, {\n where: { event_id: input.eventId, webhook_id: input.webhookId },\n fields: ['id'],\n });\n if (existing?.id) return existing.id as string;\n\n const id = randomUUID();\n const now = Date.now();\n const row: Omit<DeliveryRow, 'response_body' | 'error'> = {\n id,\n webhook_id: input.webhookId,\n event_id: input.eventId,\n event_type: input.eventType,\n url: input.url,\n method: input.method ?? 'POST',\n headers_json: input.headers ? JSON.stringify(input.headers) : undefined,\n secret: input.secret,\n timeout_ms: input.timeoutMs,\n payload_json: JSON.stringify(input.payload ?? null),\n partition_key: hashPartition(input.webhookId, this.partitionCount),\n status: 'pending',\n attempts: 0,\n created_at: now,\n updated_at: now,\n };\n try {\n await this.engine.insert(this.objectName, row);\n return id;\n } catch (err) {\n // Unique-index collision (dedup race) → look up the winner and\n // return its id. Any other error propagates.\n const winner = await this.engine.findOne(this.objectName, {\n where: { event_id: input.eventId, webhook_id: input.webhookId },\n fields: ['id'],\n });\n if (winner?.id) return winner.id as string;\n throw err;\n }\n }\n\n async claim(opts: ClaimOptions): Promise<WebhookDelivery[]> {\n const now = opts.now ?? Date.now();\n\n // 1. Reap stale in_flight rows — visibility-timeout recovery.\n await this.engine.update(\n this.objectName,\n { status: 'pending', claimed_by: null, claimed_at: null, updated_at: now },\n {\n where: {\n status: 'in_flight',\n claimed_at: { $lt: now - opts.claimTtlMs },\n },\n multi: true,\n },\n );\n\n // 2. Pick candidate ids.\n const partitionFilter = opts.partition\n ? { partition_key: opts.partition.index }\n : {};\n const candidates = await this.engine.find(this.objectName, {\n where: {\n status: 'pending',\n ...partitionFilter,\n // next_retry_at <= now OR null\n $or: [\n { next_retry_at: null },\n { next_retry_at: { $lte: now } },\n ],\n },\n fields: ['id'],\n // No orderBy for portability — drivers handle the natural insert order.\n limit: opts.limit,\n });\n if (candidates.length === 0) return [];\n\n const ids = (candidates as Array<{ id: string }>).map((c) => c.id);\n\n // 3. Atomic claim. WHERE status='pending' rejects any rows another\n // worker swept up between steps 2 and 3.\n await this.engine.update(\n this.objectName,\n {\n status: 'in_flight',\n claimed_by: opts.nodeId,\n claimed_at: now,\n updated_at: now,\n },\n {\n where: { id: { $in: ids }, status: 'pending' },\n multi: true,\n },\n );\n\n // 4. Read back the rows we actually own.\n const claimed = (await this.engine.find(this.objectName, {\n where: {\n id: { $in: ids },\n claimed_by: opts.nodeId,\n claimed_at: now,\n status: 'in_flight',\n },\n })) as DeliveryRow[];\n\n return claimed.map((r) => this.toDelivery(r));\n }\n\n async ack(id: string, result: AckResult): Promise<void> {\n // ObjectQL has no atomic $inc across drivers, so read-then-write.\n // Safe enough: ack is single-writer per row (only the claimer acks).\n const current = (await this.engine.findOne(this.objectName, {\n where: { id },\n fields: ['attempts'],\n })) as { attempts?: number } | null;\n if (!current) return;\n\n const now = Date.now();\n let status: DeliveryStatus;\n let nextRetryAt: number | null;\n let error: string | null;\n\n if (result.success) {\n status = 'success';\n nextRetryAt = null;\n error = null;\n } else if (result.dead) {\n status = 'dead';\n nextRetryAt = null;\n error = result.error ?? null;\n } else {\n status = 'pending';\n nextRetryAt = result.nextRetryAt ?? null;\n error = result.error ?? null;\n }\n\n await this.engine.update(\n this.objectName,\n {\n status,\n attempts: (current.attempts ?? 0) + 1,\n last_attempted_at: now,\n claimed_by: null,\n claimed_at: null,\n response_code: result.httpStatus ?? null,\n response_body: result.responseBody ?? null,\n next_retry_at: nextRetryAt,\n error,\n updated_at: now,\n },\n { where: { id }, multi: false },\n );\n }\n\n async list(filter?: { status?: DeliveryStatus }): Promise<WebhookDelivery[]> {\n const rows = (await this.engine.find(this.objectName, {\n where: filter?.status ? { status: filter.status } : {},\n })) as DeliveryRow[];\n return rows.map((r) => this.toDelivery(r));\n }\n\n private toDelivery(r: DeliveryRow): WebhookDelivery {\n return {\n id: r.id,\n webhookId: r.webhook_id,\n eventId: r.event_id,\n eventType: r.event_type,\n url: r.url,\n method: r.method ?? undefined,\n headers: r.headers_json ? JSON.parse(r.headers_json) : undefined,\n secret: r.secret ?? undefined,\n timeoutMs: r.timeout_ms ?? undefined,\n payload: JSON.parse(r.payload_json),\n status: r.status,\n attempts: r.attempts,\n claimedBy: r.claimed_by ?? undefined,\n claimedAt: r.claimed_at ?? undefined,\n nextRetryAt: r.next_retry_at ?? undefined,\n lastAttemptedAt: r.last_attempted_at ?? undefined,\n responseCode: r.response_code ?? undefined,\n responseBody: r.response_body ?? undefined,\n error: r.error ?? undefined,\n createdAt: r.created_at,\n updatedAt: r.updated_at,\n };\n }\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}