@objectstack/plugin-webhooks 17.0.0-rc.0 → 17.0.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@objectstack/plugin-webhooks",
3
- "version": "17.0.0-rc.0",
3
+ "version": "17.0.0-rc.1",
4
4
  "license": "Apache-2.0",
5
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
6
  "type": "module",
@@ -19,9 +19,9 @@
19
19
  }
20
20
  },
21
21
  "dependencies": {
22
- "@objectstack/core": "17.0.0-rc.0",
23
- "@objectstack/spec": "17.0.0-rc.0",
24
- "@objectstack/service-messaging": "17.0.0-rc.0"
22
+ "@objectstack/core": "17.0.0-rc.1",
23
+ "@objectstack/service-messaging": "17.0.0-rc.1",
24
+ "@objectstack/spec": "17.0.0-rc.1"
25
25
  },
26
26
  "devDependencies": {
27
27
  "@types/node": "^26.1.1",
@@ -41,8 +41,14 @@
41
41
  "url": "https://github.com/objectstack-ai/objectstack.git",
42
42
  "directory": "packages/plugins/plugin-webhooks"
43
43
  },
44
+ "files": [
45
+ "dist",
46
+ "README.md",
47
+ "CHANGELOG.md"
48
+ ],
44
49
  "scripts": {
45
50
  "build": "tsup",
46
- "test": "vitest run"
51
+ "test": "vitest run",
52
+ "typecheck": "tsc --noEmit"
47
53
  }
48
54
  }
@@ -1,36 +0,0 @@
1
-
2
- > @objectstack/plugin-webhooks@17.0.0-rc.0 build /home/runner/work/objectstack/objectstack/packages/plugins/plugin-webhooks
3
- > tsup
4
-
5
- CLI Building entry: src/index.ts, src/schema.ts
6
- CLI Using tsconfig: tsconfig.json
7
- CLI tsup v8.5.1
8
- CLI Using tsup config: /home/runner/work/objectstack/objectstack/packages/plugins/plugin-webhooks/tsup.config.ts
9
- CLI Target: es2020
10
- CLI Cleaning output folder
11
- ESM Build start
12
- CJS Build start
13
- ESM dist/index.js 20.63 KB
14
- ESM dist/schema.js 111.00 B
15
- ESM dist/chunk-6EPMRZ7I.js 6.98 KB
16
- ESM dist/translations-Y3CXWOTM.js 12.89 KB
17
- ESM dist/index.js.map 53.27 KB
18
- ESM dist/schema.js.map 71.00 B
19
- ESM dist/chunk-6EPMRZ7I.js.map 12.42 KB
20
- ESM dist/translations-Y3CXWOTM.js.map 17.78 KB
21
- ESM ⚡️ Build success in 546ms
22
- CJS dist/index.cjs 26.40 KB
23
- CJS dist/schema.cjs 220.00 B
24
- CJS dist/chunk-QUTQSOQC.cjs 7.14 KB
25
- CJS dist/translations-CBQRUIS5.cjs 12.98 KB
26
- CJS dist/index.cjs.map 48.41 KB
27
- CJS dist/schema.cjs.map 286.00 B
28
- CJS dist/chunk-QUTQSOQC.cjs.map 14.45 KB
29
- CJS dist/translations-CBQRUIS5.cjs.map 20.24 KB
30
- CJS ⚡️ Build success in 695ms
31
- DTS Build start
32
- DTS ⚡️ Build success in 25488ms
33
- DTS dist/index.d.ts 7.79 KB
34
- DTS dist/schema.d.ts 118.05 KB
35
- DTS dist/index.d.cts 7.79 KB
36
- DTS dist/schema.d.cts 118.05 KB
@@ -1,34 +0,0 @@
1
- // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2
-
3
- /**
4
- * Build-time only config for `os i18n extract` (ADR-0029 D8). Not deployed.
5
- * The plugin owns the i18n extraction for the objects it owns; the
6
- * `translations` baseline is this plugin's OWN generated bundles so re-running
7
- * `--merge` preserves every hand-translated string. (Initial zh-CN/ja-JP/es-ES
8
- * strings were seeded from @objectstack/platform-objects.)
9
- *
10
- * os i18n extract packages/plugins/plugin-webhooks/scripts/i18n-extract.config.ts \
11
- * --locales=zh-CN,ja-JP,es-ES --fill=default --objects-only --no-metadata-forms \
12
- * --out=packages/plugins/plugin-webhooks/src/translations
13
- */
14
-
15
- import { defineStack } from '@objectstack/spec';
16
- import { SysWebhook } from '../src/sys-webhook.object.js';
17
- // NOTE: sys_webhook_delivery moved to @objectstack/service-messaging
18
- // (sys_http_delivery, ADR-0018 M3) — this plugin no longer owns a delivery
19
- // object, so it is not extracted here.
20
- import { enObjects } from '../src/translations/en.objects.generated.js';
21
- import { zhCNObjects } from '../src/translations/zh-CN.objects.generated.js';
22
- import { jaJPObjects } from '../src/translations/ja-JP.objects.generated.js';
23
- import { esESObjects } from '../src/translations/es-ES.objects.generated.js';
24
-
25
- export default defineStack({
26
- name: 'plugin-webhooks-i18n-extract',
27
- objects: [SysWebhook] as any,
28
- translations: [
29
- { en: { objects: enObjects } },
30
- { 'zh-CN': { objects: zhCNObjects } },
31
- { 'ja-JP': { objects: jaJPObjects } },
32
- { 'es-ES': { objects: esESObjects } },
33
- ],
34
- });
@@ -1,431 +0,0 @@
1
- // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2
-
3
- /**
4
- * AutoEnqueuer end-to-end test.
5
- *
6
- * Verifies the bridge between `IRealtimeService` (data events) and the shared
7
- * `service-messaging` HTTP outbox (ADR-0018 M3 — enqueue via `messaging.enqueueHttp`):
8
- *
9
- * - On startup, subscription rules are loaded from the engine.
10
- * - `data.record.created/updated/deleted` events fan out to matching
11
- * `sys_webhook` rows, enqueued as `source: 'webhook'`.
12
- * - The `triggers` CSV column filters which actions fire.
13
- * - The `object_name` field scopes events to a specific object.
14
- * - Edits to `sys_webhook` self-heal the cache without restart.
15
- * - Enqueue is fire-and-forget (handler never throws or blocks).
16
- * - The deterministic dedupKey (`<webhookId>:<eventId>`) collapses replays.
17
- */
18
-
19
- import { describe, expect, it, vi } from 'vitest';
20
- import type {
21
- IDataEngine,
22
- IRealtimeService,
23
- RealtimeEventHandler,
24
- RealtimeEventPayload,
25
- } from '@objectstack/spec/contracts';
26
- import type { EnqueueHttpInput } from '@objectstack/service-messaging';
27
- import { AutoEnqueuer, type HttpEnqueueFn } from './auto-enqueuer.js';
28
-
29
- // ---------------------------------------------------------------------------
30
- // Fakes
31
- // ---------------------------------------------------------------------------
32
-
33
- /**
34
- * Records `enqueueHttp` calls and dedups on `(source, dedupKey)` — mirroring the
35
- * shared outbox's UNIQUE constraint so the replay test still holds.
36
- */
37
- function makeRecorder() {
38
- const calls: EnqueueHttpInput[] = [];
39
- const seen = new Map<string, string>();
40
- const enqueue: HttpEnqueueFn = async (input) => {
41
- const key = `${input.source}::${input.dedupKey}`;
42
- const existing = seen.get(key);
43
- if (existing) return existing;
44
- seen.set(key, key);
45
- calls.push(input);
46
- return key;
47
- };
48
- return { enqueue, calls };
49
- }
50
-
51
- class FakeRealtime implements IRealtimeService {
52
- private subs = new Map<string, { handler: RealtimeEventHandler; opts?: any }>();
53
- private n = 0;
54
-
55
- async publish(event: RealtimeEventPayload): Promise<void> {
56
- for (const sub of this.subs.values()) {
57
- const o = sub.opts ?? {};
58
- if (o.object && event.object !== o.object) continue;
59
- await sub.handler(event);
60
- }
61
- }
62
- async subscribe(_channel: string, handler: any, opts?: any): Promise<string> {
63
- const id = `s-${++this.n}`;
64
- this.subs.set(id, { handler, opts });
65
- return id;
66
- }
67
- async unsubscribe(id: string): Promise<void> {
68
- this.subs.delete(id);
69
- }
70
- }
71
-
72
- class FakeEngine implements IDataEngine {
73
- rows: Record<string, any[]> = {};
74
-
75
- constructor(seed?: Record<string, any[]>) {
76
- if (seed) this.rows = JSON.parse(JSON.stringify(seed));
77
- }
78
-
79
- async find(name: string, q?: any): Promise<any[]> {
80
- const all = this.rows[name] ?? [];
81
- if (!q?.where) return all;
82
- return all.filter((r) => Object.entries(q.where).every(([k, v]) => r[k] === v));
83
- }
84
- async findOne(name: string, q?: any): Promise<any> {
85
- return (await this.find(name, q))[0] ?? null;
86
- }
87
- async insert(name: string, data: any): Promise<any> {
88
- const arr = (this.rows[name] = this.rows[name] ?? []);
89
- arr.push(data);
90
- return data;
91
- }
92
- async update(name: string, data: any, opts?: any): Promise<any> {
93
- const arr = this.rows[name] ?? [];
94
- for (const r of arr) {
95
- if (opts?.where && Object.entries(opts.where).every(([k, v]) => r[k] === v)) {
96
- Object.assign(r, data);
97
- }
98
- }
99
- return { affected: 0 };
100
- }
101
- async delete(name: string, opts?: any): Promise<any> {
102
- const arr = this.rows[name] ?? [];
103
- const before = arr.length;
104
- this.rows[name] = arr.filter(
105
- (r) => !(opts?.where && Object.entries(opts.where).every(([k, v]) => r[k] === v)),
106
- );
107
- return { affected: before - this.rows[name].length };
108
- }
109
- async count(name: string): Promise<number> {
110
- return (this.rows[name] ?? []).length;
111
- }
112
- async aggregate(): Promise<any[]> {
113
- return [];
114
- }
115
- }
116
-
117
- // ---------------------------------------------------------------------------
118
- // Helpers
119
- // ---------------------------------------------------------------------------
120
-
121
- function webhook(over: Partial<any> = {}): any {
122
- return {
123
- id: over.id ?? 'wh-1',
124
- name: over.name ?? 'default',
125
- active: over.active ?? true,
126
- object_name: over.object_name ?? 'contact',
127
- triggers: over.triggers ?? 'create,update,delete',
128
- url: over.url ?? 'https://hooks.example/wh',
129
- method: 'POST',
130
- definition_json: over.definition_json,
131
- ...over,
132
- };
133
- }
134
-
135
- function event(
136
- type: 'created' | 'updated' | 'deleted',
137
- object: string,
138
- record: any,
139
- timestamp = '2026-05-24T00:00:00.000Z',
140
- ): RealtimeEventPayload {
141
- return {
142
- type: `data.record.${type}`,
143
- object,
144
- payload: { recordId: record.id, after: record },
145
- timestamp,
146
- };
147
- }
148
-
149
- async function flush() {
150
- await new Promise((r) => setTimeout(r, 0));
151
- }
152
-
153
- // ---------------------------------------------------------------------------
154
- // Tests
155
- // ---------------------------------------------------------------------------
156
-
157
- describe('AutoEnqueuer', () => {
158
- it('enqueues a delivery when a matching data event fires', async () => {
159
- const engine = new FakeEngine({ sys_webhook: [webhook()] });
160
- const realtime = new FakeRealtime();
161
- const { enqueue, calls } = makeRecorder();
162
- const ae = new AutoEnqueuer(engine, realtime, enqueue, { refreshIntervalMs: 0 });
163
- await ae.start();
164
-
165
- await realtime.publish(event('created', 'contact', { id: 'c-1', name: 'Alice' }));
166
- await flush();
167
-
168
- expect(calls).toHaveLength(1);
169
- expect(calls[0].source).toBe('webhook');
170
- expect(calls[0].refId).toBe('wh-1');
171
- expect(calls[0].url).toBe('https://hooks.example/wh');
172
- expect(calls[0].label).toBe('data.record.created');
173
- expect((calls[0].payload as any).recordId).toBe('c-1');
174
- await ae.stop();
175
- });
176
-
177
- it('skips events for other objects', async () => {
178
- const engine = new FakeEngine({ sys_webhook: [webhook({ object_name: 'contact' })] });
179
- const realtime = new FakeRealtime();
180
- const { enqueue, calls } = makeRecorder();
181
- const ae = new AutoEnqueuer(engine, realtime, enqueue, { refreshIntervalMs: 0 });
182
- await ae.start();
183
-
184
- await realtime.publish(event('created', 'lead', { id: 'l-1' }));
185
- await flush();
186
-
187
- expect(calls).toHaveLength(0);
188
- await ae.stop();
189
- });
190
-
191
- it('respects the triggers CSV (create-only webhook ignores updates)', async () => {
192
- const engine = new FakeEngine({ sys_webhook: [webhook({ triggers: 'create' })] });
193
- const realtime = new FakeRealtime();
194
- const { enqueue, calls } = makeRecorder();
195
- const ae = new AutoEnqueuer(engine, realtime, enqueue, { refreshIntervalMs: 0 });
196
- await ae.start();
197
-
198
- await realtime.publish(event('created', 'contact', { id: 'c-1' }));
199
- await realtime.publish(event('updated', 'contact', { id: 'c-1' }, '2026-05-24T00:00:01.000Z'));
200
- await realtime.publish(event('deleted', 'contact', { id: 'c-1' }, '2026-05-24T00:00:02.000Z'));
201
- await flush();
202
-
203
- expect(calls).toHaveLength(1);
204
- expect(calls[0].label).toBe('data.record.created');
205
- await ae.stop();
206
- });
207
-
208
- it('parses triggers authored as a multi-select array', async () => {
209
- // The `triggers` field is now a multi-select stored as an array; the
210
- // parser must treat it identically to the legacy CSV form.
211
- const engine = new FakeEngine({ sys_webhook: [webhook({ triggers: ['create'] })] });
212
- const realtime = new FakeRealtime();
213
- const { enqueue, calls } = makeRecorder();
214
- const ae = new AutoEnqueuer(engine, realtime, enqueue, { refreshIntervalMs: 0 });
215
- await ae.start();
216
-
217
- await realtime.publish(event('created', 'contact', { id: 'c-1' }));
218
- await realtime.publish(event('updated', 'contact', { id: 'c-1' }, '2026-05-24T00:00:01.000Z'));
219
- await flush();
220
-
221
- expect(calls).toHaveLength(1);
222
- expect(calls[0].label).toBe('data.record.created');
223
- await ae.stop();
224
- });
225
-
226
- it('parses triggers stored as a JSON-encoded array string', async () => {
227
- // Some drivers hand a JSON array column back as a string — accept it.
228
- const engine = new FakeEngine({ sys_webhook: [webhook({ triggers: '["create","update"]' })] });
229
- const realtime = new FakeRealtime();
230
- const { enqueue, calls } = makeRecorder();
231
- const ae = new AutoEnqueuer(engine, realtime, enqueue, { refreshIntervalMs: 0 });
232
- await ae.start();
233
-
234
- await realtime.publish(event('created', 'contact', { id: 'c-1' }));
235
- await realtime.publish(event('deleted', 'contact', { id: 'c-1' }, '2026-05-24T00:00:02.000Z'));
236
- await flush();
237
-
238
- // create + update are subscribed; delete is not.
239
- expect(calls).toHaveLength(1);
240
- expect(calls[0].label).toBe('data.record.created');
241
- await ae.stop();
242
- });
243
-
244
- it('fans out to multiple matching webhooks', async () => {
245
- const engine = new FakeEngine({
246
- sys_webhook: [
247
- webhook({ id: 'wh-1', name: 'slack', url: 'https://slack.test' }),
248
- webhook({ id: 'wh-2', name: 'analytics', url: 'https://amplitude.test' }),
249
- ],
250
- });
251
- const realtime = new FakeRealtime();
252
- const { enqueue, calls } = makeRecorder();
253
- const ae = new AutoEnqueuer(engine, realtime, enqueue, { refreshIntervalMs: 0 });
254
- await ae.start();
255
-
256
- await realtime.publish(event('created', 'contact', { id: 'c-1' }));
257
- await flush();
258
-
259
- expect(calls).toHaveLength(2);
260
- expect(calls.map((r) => r.url).sort()).toEqual([
261
- 'https://amplitude.test',
262
- 'https://slack.test',
263
- ]);
264
- await ae.stop();
265
- });
266
-
267
- it('skips inactive webhooks', async () => {
268
- const engine = new FakeEngine({ sys_webhook: [webhook({ active: false })] });
269
- const realtime = new FakeRealtime();
270
- const { enqueue, calls } = makeRecorder();
271
- const ae = new AutoEnqueuer(engine, realtime, enqueue, { refreshIntervalMs: 0 });
272
- await ae.start();
273
-
274
- await realtime.publish(event('created', 'contact', { id: 'c-1' }));
275
- await flush();
276
-
277
- expect(calls).toHaveLength(0);
278
- await ae.stop();
279
- });
280
-
281
- it('skips manual-only webhooks (no triggers)', async () => {
282
- const engine = new FakeEngine({ sys_webhook: [webhook({ triggers: '' })] });
283
- const realtime = new FakeRealtime();
284
- const { enqueue, calls } = makeRecorder();
285
- const ae = new AutoEnqueuer(engine, realtime, enqueue, { refreshIntervalMs: 0 });
286
- await ae.start();
287
-
288
- await realtime.publish(event('created', 'contact', { id: 'c-1' }));
289
- await flush();
290
-
291
- expect(calls).toHaveLength(0);
292
- await ae.stop();
293
- });
294
-
295
- it('warns and ignores a legacy trigger the engine never emits (#3196)', async () => {
296
- // A row authored before undelete/api were removed keeps its valid
297
- // triggers; the dead ones are dropped with a warning (drift-guard).
298
- const engine = new FakeEngine({
299
- sys_webhook: [webhook({ triggers: 'create,undelete,api' })],
300
- });
301
- const realtime = new FakeRealtime();
302
- const { enqueue, calls } = makeRecorder();
303
- const warn = vi.fn();
304
- const ae = new AutoEnqueuer(engine, realtime, enqueue, { refreshIntervalMs: 0, logger: { warn } });
305
- await ae.start();
306
-
307
- await realtime.publish(event('created', 'contact', { id: 'c-1' }));
308
- await flush();
309
-
310
- expect(calls).toHaveLength(1); // the valid `create` trigger still fires
311
- expect(warn).toHaveBeenCalledWith(
312
- expect.stringContaining('undelete'),
313
- expect.objectContaining({ unknown: expect.arrayContaining(['undelete', 'api']) }),
314
- );
315
- await ae.stop();
316
- });
317
-
318
- it('skips a webhook whose only triggers are removed values (#3196)', async () => {
319
- const engine = new FakeEngine({ sys_webhook: [webhook({ triggers: 'undelete,api' })] });
320
- const realtime = new FakeRealtime();
321
- const { enqueue, calls } = makeRecorder();
322
- const ae = new AutoEnqueuer(engine, realtime, enqueue, { refreshIntervalMs: 0 });
323
- await ae.start();
324
-
325
- await realtime.publish(event('created', 'contact', { id: 'c-1' }));
326
- await flush();
327
-
328
- expect(calls).toHaveLength(0);
329
- await ae.stop();
330
- });
331
-
332
- it('self-heals the cache when sys_webhook changes', async () => {
333
- const engine = new FakeEngine({ sys_webhook: [] });
334
- const realtime = new FakeRealtime();
335
- const { enqueue, calls } = makeRecorder();
336
- const ae = new AutoEnqueuer(engine, realtime, enqueue, { refreshIntervalMs: 0 });
337
- await ae.start();
338
-
339
- await realtime.publish(event('created', 'contact', { id: 'c-1' }));
340
- await flush();
341
- expect(calls).toHaveLength(0);
342
-
343
- await engine.insert('sys_webhook', webhook());
344
- await realtime.publish({
345
- type: 'data.record.created',
346
- object: 'sys_webhook',
347
- payload: { recordId: 'wh-1' },
348
- timestamp: '2026-05-24T00:01:00.000Z',
349
- });
350
- await flush();
351
- await flush(); // Two ticks: the self-heal handler itself awaits refresh
352
-
353
- await realtime.publish(event('created', 'contact', { id: 'c-2' }, '2026-05-24T00:01:01.000Z'));
354
- await flush();
355
-
356
- expect(calls).toHaveLength(1);
357
- expect((calls[0].payload as any).recordId).toBe('c-2');
358
- await ae.stop();
359
- });
360
-
361
- it('uses a deterministic dedupKey so replays collapse', async () => {
362
- const engine = new FakeEngine({ sys_webhook: [webhook()] });
363
- const realtime = new FakeRealtime();
364
- const { enqueue, calls } = makeRecorder();
365
- const ae = new AutoEnqueuer(engine, realtime, enqueue, { refreshIntervalMs: 0 });
366
- await ae.start();
367
-
368
- const evt = event('created', 'contact', { id: 'c-1' });
369
- await realtime.publish(evt);
370
- await realtime.publish(evt);
371
- await flush();
372
-
373
- expect(calls).toHaveLength(1);
374
- await ae.stop();
375
- });
376
-
377
- it('handler is fire-and-forget (publish does not block on enqueue)', async () => {
378
- const engine = new FakeEngine({ sys_webhook: [webhook()] });
379
- const realtime = new FakeRealtime();
380
- let slowResolve!: () => void;
381
- const blocker = new Promise<void>((res) => {
382
- slowResolve = res;
383
- });
384
- const calls: EnqueueHttpInput[] = [];
385
- const enqueue: HttpEnqueueFn = async (input) => {
386
- await blocker;
387
- calls.push(input);
388
- return 'id';
389
- };
390
-
391
- const ae = new AutoEnqueuer(engine, realtime, enqueue, { refreshIntervalMs: 0 });
392
- await ae.start();
393
-
394
- const before = Date.now();
395
- await realtime.publish(event('created', 'contact', { id: 'c-1' }));
396
- const elapsed = Date.now() - before;
397
- expect(elapsed).toBeLessThan(20); // publish must not have awaited blocker
398
-
399
- slowResolve();
400
- await flush();
401
- expect(calls).toHaveLength(1);
402
- await ae.stop();
403
- });
404
-
405
- it('logs but swallows enqueue errors so other webhooks still fire', async () => {
406
- const engine = new FakeEngine({
407
- sys_webhook: [
408
- webhook({ id: 'wh-bad', url: 'https://bad.test' }),
409
- webhook({ id: 'wh-good', url: 'https://good.test' }),
410
- ],
411
- });
412
- const realtime = new FakeRealtime();
413
- const calls: EnqueueHttpInput[] = [];
414
- const enqueue: HttpEnqueueFn = vi.fn(async (input) => {
415
- if (input.refId === 'wh-bad') throw new Error('boom');
416
- calls.push(input);
417
- return 'id';
418
- });
419
- const warn = vi.fn();
420
- const ae = new AutoEnqueuer(engine, realtime, enqueue, { refreshIntervalMs: 0, logger: { warn } });
421
- await ae.start();
422
-
423
- await realtime.publish(event('created', 'contact', { id: 'c-1' }));
424
- await flush();
425
-
426
- expect(calls).toHaveLength(1);
427
- expect(calls[0].url).toBe('https://good.test');
428
- expect(warn).toHaveBeenCalled();
429
- await ae.stop();
430
- });
431
- });