@objectstack/plugin-webhooks 5.0.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 -28
  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
package/dist/schema.js ADDED
@@ -0,0 +1,9 @@
1
+ import {
2
+ SYS_WEBHOOK_DELIVERY,
3
+ SysWebhookDelivery
4
+ } from "./chunk-JN76ZRWN.js";
5
+ export {
6
+ SYS_WEBHOOK_DELIVERY,
7
+ SysWebhookDelivery
8
+ };
9
+ //# sourceMappingURL=schema.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
@@ -0,0 +1,184 @@
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; }
2
+
3
+ var _chunkM4M5FWIHcjs = require('./chunk-M4M5FWIH.cjs');
4
+
5
+
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;
184
+ //# sourceMappingURL=sql-outbox.cjs.map
@@ -0,0 +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"]}
@@ -0,0 +1,54 @@
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';
3
+
4
+ interface SqlWebhookOutboxOptions {
5
+ /**
6
+ * Total partition count — MUST match the dispatcher's `partitionCount`.
7
+ * Used at enqueue time to precompute `partition_key`.
8
+ */
9
+ partitionCount: number;
10
+ /**
11
+ * Object name to read/write. Defaults to `sys_webhook_delivery`. Override
12
+ * only if you've registered the schema under a different name.
13
+ */
14
+ objectName?: string;
15
+ }
16
+ /**
17
+ * Durable `IWebhookOutbox` backed by ObjectQL — the production storage
18
+ * impl. Works against any registered driver (SQL, Turso, Mongo, in-memory)
19
+ * because everything goes through the driver-agnostic `IDataEngine` API.
20
+ *
21
+ * **Why no `FOR UPDATE SKIP LOCKED`?** ObjectQL is driver-agnostic — that
22
+ * SQL feature is Postgres-only. We get equivalent safety from two layers:
23
+ *
24
+ * 1. `cluster.lock` held per partition by the dispatcher (the primary
25
+ * mutex). One node owns one partition at a time → no two claimers.
26
+ * 2. Atomic `UPDATE WHERE status='pending'` (the backup). Even if two
27
+ * claimers slip through (e.g. admin reschedule + dispatcher), only
28
+ * the first UPDATE matches each row.
29
+ *
30
+ * **Why precompute `partition_key` on enqueue?** ObjectQL has no
31
+ * cross-driver `hash()` function in WHERE clauses. Storing the partition
32
+ * as a column makes the claim query a plain indexed lookup.
33
+ *
34
+ * **Dedup race**: SELECT-then-INSERT has a tiny window where two
35
+ * concurrent producers both miss the SELECT and both INSERT. The unique
36
+ * index `(event_id, webhook_id)` on the table catches it — the second
37
+ * INSERT errors, the producer ignores it. Receivers MUST be idempotent
38
+ * on the `X-Objectstack-Delivery` header anyway.
39
+ */
40
+ declare class SqlWebhookOutbox implements IWebhookOutbox {
41
+ private readonly engine;
42
+ private readonly objectName;
43
+ private readonly partitionCount;
44
+ constructor(engine: IDataEngine, opts: SqlWebhookOutboxOptions);
45
+ enqueue(input: EnqueueInput): Promise<string>;
46
+ claim(opts: ClaimOptions): Promise<WebhookDelivery[]>;
47
+ ack(id: string, result: AckResult): Promise<void>;
48
+ list(filter?: {
49
+ status?: DeliveryStatus;
50
+ }): Promise<WebhookDelivery[]>;
51
+ private toDelivery;
52
+ }
53
+
54
+ export { SqlWebhookOutbox, type SqlWebhookOutboxOptions };
@@ -0,0 +1,54 @@
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';
3
+
4
+ interface SqlWebhookOutboxOptions {
5
+ /**
6
+ * Total partition count — MUST match the dispatcher's `partitionCount`.
7
+ * Used at enqueue time to precompute `partition_key`.
8
+ */
9
+ partitionCount: number;
10
+ /**
11
+ * Object name to read/write. Defaults to `sys_webhook_delivery`. Override
12
+ * only if you've registered the schema under a different name.
13
+ */
14
+ objectName?: string;
15
+ }
16
+ /**
17
+ * Durable `IWebhookOutbox` backed by ObjectQL — the production storage
18
+ * impl. Works against any registered driver (SQL, Turso, Mongo, in-memory)
19
+ * because everything goes through the driver-agnostic `IDataEngine` API.
20
+ *
21
+ * **Why no `FOR UPDATE SKIP LOCKED`?** ObjectQL is driver-agnostic — that
22
+ * SQL feature is Postgres-only. We get equivalent safety from two layers:
23
+ *
24
+ * 1. `cluster.lock` held per partition by the dispatcher (the primary
25
+ * mutex). One node owns one partition at a time → no two claimers.
26
+ * 2. Atomic `UPDATE WHERE status='pending'` (the backup). Even if two
27
+ * claimers slip through (e.g. admin reschedule + dispatcher), only
28
+ * the first UPDATE matches each row.
29
+ *
30
+ * **Why precompute `partition_key` on enqueue?** ObjectQL has no
31
+ * cross-driver `hash()` function in WHERE clauses. Storing the partition
32
+ * as a column makes the claim query a plain indexed lookup.
33
+ *
34
+ * **Dedup race**: SELECT-then-INSERT has a tiny window where two
35
+ * concurrent producers both miss the SELECT and both INSERT. The unique
36
+ * index `(event_id, webhook_id)` on the table catches it — the second
37
+ * INSERT errors, the producer ignores it. Receivers MUST be idempotent
38
+ * on the `X-Objectstack-Delivery` header anyway.
39
+ */
40
+ declare class SqlWebhookOutbox implements IWebhookOutbox {
41
+ private readonly engine;
42
+ private readonly objectName;
43
+ private readonly partitionCount;
44
+ constructor(engine: IDataEngine, opts: SqlWebhookOutboxOptions);
45
+ enqueue(input: EnqueueInput): Promise<string>;
46
+ claim(opts: ClaimOptions): Promise<WebhookDelivery[]>;
47
+ ack(id: string, result: AckResult): Promise<void>;
48
+ list(filter?: {
49
+ status?: DeliveryStatus;
50
+ }): Promise<WebhookDelivery[]>;
51
+ private toDelivery;
52
+ }
53
+
54
+ export { SqlWebhookOutbox, type SqlWebhookOutboxOptions };
@@ -0,0 +1,184 @@
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
+ };
181
+ export {
182
+ SqlWebhookOutbox
183
+ };
184
+ //# sourceMappingURL=sql-outbox.js.map
@@ -0,0 +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":[]}
package/package.json CHANGED
@@ -1,20 +1,33 @@
1
1
  {
2
2
  "name": "@objectstack/plugin-webhooks",
3
- "version": "5.0.0",
3
+ "version": "5.2.0",
4
4
  "license": "Apache-2.0",
5
- "description": "Outbound webhook delivery plugin for ObjectStack — fan-out data.record.* events to external HTTP(S) sinks with HMAC signing and retry.",
5
+ "description": "Persistent, cluster-aware webhook dispatcher. Durable outbox + per-partition cluster.lock for exactly-once-ish delivery across nodes. See content/docs/concepts/webhook-delivery.mdx.",
6
+ "type": "module",
6
7
  "main": "dist/index.js",
7
8
  "types": "dist/index.d.ts",
8
9
  "exports": {
9
10
  ".": {
10
11
  "types": "./dist/index.d.ts",
11
- "import": "./dist/index.mjs",
12
- "require": "./dist/index.js"
12
+ "import": "./dist/index.js",
13
+ "require": "./dist/index.cjs"
14
+ },
15
+ "./sql": {
16
+ "types": "./dist/sql-outbox.d.ts",
17
+ "import": "./dist/sql-outbox.js",
18
+ "require": "./dist/sql-outbox.cjs"
19
+ },
20
+ "./schema": {
21
+ "types": "./dist/schema.d.ts",
22
+ "import": "./dist/schema.js",
23
+ "require": "./dist/schema.cjs"
13
24
  }
14
25
  },
15
26
  "dependencies": {
16
- "@objectstack/core": "5.0.0",
17
- "@objectstack/spec": "5.0.0"
27
+ "@objectstack/core": "5.2.0",
28
+ "@objectstack/platform-objects": "5.2.0",
29
+ "@objectstack/spec": "5.2.0",
30
+ "@objectstack/service-cluster": "5.1.1"
18
31
  },
19
32
  "devDependencies": {
20
33
  "@types/node": "^25.9.1",
@@ -24,11 +37,18 @@
24
37
  "keywords": [
25
38
  "objectstack",
26
39
  "plugin",
27
- "webhooks",
28
- "events"
40
+ "webhook",
41
+ "outbox",
42
+ "cluster"
29
43
  ],
44
+ "author": "ObjectStack",
45
+ "repository": {
46
+ "type": "git",
47
+ "url": "https://github.com/objectstack-ai/framework.git",
48
+ "directory": "packages/plugins/plugin-webhooks"
49
+ },
30
50
  "scripts": {
31
- "build": "tsup --config ../../../tsup.config.ts",
32
- "test": "vitest run --passWithNoTests"
51
+ "build": "tsup",
52
+ "test": "vitest run"
33
53
  }
34
54
  }