@objectstack/plugin-webhooks 17.0.0-rc.1 → 17.0.0-rc.2
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/CHANGELOG.md +253 -0
- package/dist/{chunk-6EPMRZ7I.js → chunk-3QGZLM3T.js} +8 -3
- package/dist/chunk-3QGZLM3T.js.map +1 -0
- package/dist/{chunk-QUTQSOQC.cjs → chunk-JQUVS5KK.cjs} +8 -3
- package/dist/chunk-JQUVS5KK.cjs.map +1 -0
- package/dist/index.cjs +158 -58
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +27 -1
- package/dist/index.d.ts +27 -1
- package/dist/index.js +113 -13
- package/dist/index.js.map +1 -1
- package/dist/schema.cjs +2 -2
- package/dist/schema.d.cts +575 -417
- package/dist/schema.d.ts +575 -417
- package/dist/schema.js +1 -1
- package/dist/{translations-Y3CXWOTM.js → translations-2VGD4XRF.js} +17 -9
- package/dist/translations-2VGD4XRF.js.map +1 -0
- package/dist/{translations-CBQRUIS5.cjs → translations-BWS57U2V.cjs} +17 -9
- package/dist/translations-BWS57U2V.cjs.map +1 -0
- package/package.json +5 -5
- package/dist/chunk-6EPMRZ7I.js.map +0 -1
- package/dist/chunk-QUTQSOQC.cjs.map +0 -1
- package/dist/translations-CBQRUIS5.cjs.map +0 -1
- package/dist/translations-Y3CXWOTM.js.map +0 -1
package/dist/index.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["/home/runner/work/objectstack/objectstack/packages/plugins/plugin-webhooks/dist/index.cjs","../src/auto-enqueuer.ts","../src/bootstrap-declared-webhooks.ts","../src/webhook-provenance.ts","../src/webhook-outbox-plugin.ts"],"names":["SYSTEM_CTX"],"mappings":"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","file":"/home/runner/work/objectstack/objectstack/packages/plugins/plugin-webhooks/dist/index.cjs","sourcesContent":[null,"// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.\n\nimport type { IDataEngine, IRealtimeService, RealtimeEventPayload } from '@objectstack/spec/contracts';\nimport type { EnqueueHttpInput } from '@objectstack/service-messaging';\n\n/**\n * Enqueue callback into the shared `service-messaging` HTTP outbox (ADR-0018 M3).\n * The plugin supplies one bound to `messaging.enqueueHttp(...)`; webhooks no\n * longer own a delivery outbox/dispatcher — they share the generic substrate.\n */\nexport type HttpEnqueueFn = (input: EnqueueHttpInput) => Promise<string>;\n\n/**\n * Optional logger interface (subset of console / kernel logger).\n */\ninterface OptionalLogger {\n info?(msg: string, meta?: unknown): void;\n warn?(msg: string, meta?: unknown): void;\n debug?(msg: string, meta?: unknown): void;\n error?(msg: string, err?: unknown, meta?: unknown): void;\n}\n\n/**\n * Per-row subscription cached in memory. Mirrors a subset of the\n * `sys_webhook` object — only what the auto-enqueuer needs to match an\n * event and build an `EnqueueInput`.\n */\ninterface CachedSubscription {\n id: string;\n name: string;\n objectName: string | undefined; // empty = matches all objects\n triggers: Set<'create' | 'update' | 'delete'>;\n url: string;\n method?: string;\n headers?: Record<string, string>;\n secret?: string;\n timeoutMs?: number;\n}\n\nexport interface AutoEnqueuerOptions {\n /**\n * Object name holding webhook subscriptions. Defaults to `sys_webhook`,\n * the platform-objects schema authored in apps.\n */\n subscriptionsObject?: string;\n\n /**\n * Periodic full-cache refresh interval (ms). Belt-and-braces in case\n * the subscription-change event is missed. Default 60s.\n */\n refreshIntervalMs?: number;\n\n logger?: OptionalLogger;\n}\n\n/**\n * Bridge between `IRealtimeService` (`data.record.*` events emitted by\n * the engine) and `IWebhookOutbox` (durable delivery rows the dispatcher\n * picks up).\n *\n * ## Why a separate class\n * Keeps `WebhookOutboxPlugin` lean: the plugin wires services, this\n * class owns the runtime fan-out logic + subscription cache.\n *\n * ## Hot path\n * Every `engine.insert/update/delete` fires a `data.record.*` event.\n * The handler:\n * 1. Looks up matching subscriptions in an in-memory `Map<object, sub[]>`\n * — O(1) per event, no DB hit on the write path.\n * 2. Calls `outbox.enqueue()` fire-and-forget for each match. The\n * enqueue itself is a single INSERT, which runs *after* the user's\n * request has already returned.\n *\n * Net cost on the write path: one synchronous Map lookup (~microseconds).\n *\n * ## Cache freshness\n * The cache is rebuilt:\n * 1. Once on `start()`.\n * 2. On every `data.record.{created,updated,deleted}` event whose\n * object is `sys_webhook` (self-healing — when a user toggles a\n * webhook, the handler refreshes the cache before returning).\n * 3. Periodically (default 60s) as belt-and-braces.\n *\n * For multi-node clusters this is *eventually consistent* — node B may\n * not see node A's edit for up to one cycle. That's acceptable for\n * webhook configuration changes (humans don't expect millisecond\n * propagation) and matches Hasura's behaviour.\n *\n * ## Determinism\n * `eventId` is computed from `${object}:${recordId}:${type}:${timestamp}`\n * so the outbox dedup index catches duplicates that could arise from\n * upstream replay or buggy producers — and is stable across nodes.\n */\nexport class AutoEnqueuer {\n private readonly subscriptions = new Map<string, CachedSubscription[]>();\n private readonly subscriptionsObject: string;\n private readonly refreshIntervalMs: number;\n private readonly logger: OptionalLogger;\n private subId: string | undefined;\n private subIdSelfHeal: string | undefined;\n private refreshTimer: ReturnType<typeof setInterval> | undefined;\n private running = false;\n private refreshing: Promise<void> | undefined;\n\n constructor(\n private readonly engine: IDataEngine,\n private readonly realtime: IRealtimeService,\n private readonly enqueue: HttpEnqueueFn,\n opts: AutoEnqueuerOptions = {},\n ) {\n this.subscriptionsObject = opts.subscriptionsObject ?? 'sys_webhook';\n this.refreshIntervalMs = opts.refreshIntervalMs ?? 60_000;\n this.logger = opts.logger ?? {};\n }\n\n /**\n * Load the subscription cache and start listening for events.\n */\n async start(): Promise<void> {\n if (this.running) return;\n this.running = true;\n\n await this.refresh();\n\n // Main subscription: every data event → match → enqueue.\n this.subId = await this.realtime.subscribe(\n 'webhook-auto-enqueuer',\n (event) => this.handleEvent(event),\n );\n\n // Self-healing: any change to sys_webhook refreshes the cache.\n this.subIdSelfHeal = await this.realtime.subscribe(\n 'webhook-auto-enqueuer-self-heal',\n (event) => this.handleSelfHealEvent(event),\n { object: this.subscriptionsObject },\n );\n\n if (this.refreshIntervalMs > 0) {\n this.refreshTimer = setInterval(() => {\n this.refresh().catch((err) =>\n this.logger.warn?.('[webhook-auto-enqueuer] periodic refresh failed', err),\n );\n }, this.refreshIntervalMs);\n // Don't keep the process alive solely for this timer.\n this.refreshTimer.unref?.();\n }\n }\n\n async stop(): Promise<void> {\n if (!this.running) return;\n this.running = false;\n if (this.subId) await this.realtime.unsubscribe(this.subId);\n if (this.subIdSelfHeal) await this.realtime.unsubscribe(this.subIdSelfHeal);\n if (this.refreshTimer) clearInterval(this.refreshTimer);\n this.subId = undefined;\n this.subIdSelfHeal = undefined;\n this.refreshTimer = undefined;\n }\n\n /**\n * Force-refresh the subscription cache from storage. Concurrent\n * callers share a single in-flight refresh.\n */\n async refresh(): Promise<void> {\n if (this.refreshing) return this.refreshing;\n this.refreshing = this.doRefresh().finally(() => {\n this.refreshing = undefined;\n });\n return this.refreshing;\n }\n\n private async doRefresh(): Promise<void> {\n let rows: any[];\n try {\n rows = await this.engine.find(this.subscriptionsObject, {\n where: { active: true },\n });\n } catch (err) {\n this.logger.warn?.(\n `[webhook-auto-enqueuer] failed to load ${this.subscriptionsObject}`,\n err,\n );\n return;\n }\n\n const next = new Map<string, CachedSubscription[]>();\n for (const row of rows) {\n const sub = this.parseRow(row);\n if (!sub) continue;\n // Empty objectName == \"any object\" → indexed under '*'.\n const key = sub.objectName ?? '*';\n const arr = next.get(key) ?? [];\n arr.push(sub);\n next.set(key, arr);\n }\n\n this.subscriptions.clear();\n for (const [k, v] of next) this.subscriptions.set(k, v);\n\n this.logger.debug?.('[webhook-auto-enqueuer] cache refreshed', {\n objects: this.subscriptions.size,\n rows: rows.length,\n });\n }\n\n private parseRow(row: any): CachedSubscription | null {\n if (!row?.id || !row?.url) return null;\n // `triggers` is now authored as a multi-select (stored as an array), but\n // legacy rows stored a comma-separated string (and some drivers hand a\n // JSON-encoded array back as a string). Accept all three shapes so a\n // schema change never silently drops a subscription's events.\n const rawTriggers = row.triggers;\n let triggerList: string[];\n if (Array.isArray(rawTriggers)) {\n triggerList = rawTriggers.map((t) => String(t));\n } else {\n const s = String(rawTriggers ?? '').trim();\n if (s.startsWith('[')) {\n try {\n const parsed = JSON.parse(s);\n triggerList = Array.isArray(parsed) ? parsed.map((t) => String(t)) : [s];\n } catch {\n triggerList = s.split(',');\n }\n } else {\n triggerList = s.split(',');\n }\n }\n const normalized = triggerList.map((t) => t.trim().toLowerCase()).filter(Boolean);\n // [#3196] Drop (and warn about) any trigger the enqueuer can't map to an\n // emitted record event — e.g. a legacy `sys_webhook` row authored with\n // the now-removed `undelete`/`api` values, which would otherwise sit in\n // the cache matching nothing. A loud drift-guard so a dead trigger can't\n // silently no-op again.\n const unknown = normalized.filter((t) => !DISPATCHABLE_WEBHOOK_TRIGGERS.has(t));\n if (unknown.length > 0) {\n this.logger.warn?.(\n `[webhook-auto-enqueuer] webhook '${(row.name as string) ?? row.id}' declares trigger(s) the engine never emits: ` +\n `${unknown.join(', ')} — ignored. Dispatchable triggers: create, update, delete.`,\n { id: row.id, unknown },\n );\n }\n const triggers = new Set(\n normalized.filter((t) => DISPATCHABLE_WEBHOOK_TRIGGERS.has(t)) as Array<'create' | 'update' | 'delete'>,\n );\n if (triggers.size === 0) {\n // No dispatchable triggers (or a manual-only webhook with none) —\n // skip auto-enqueue.\n return null;\n }\n\n // The \"definition_json\" field carries advanced config (headers,\n // secret, timeout); attempt a best-effort parse. Fall back to\n // top-level fields where present.\n let defn: Record<string, any> = {};\n if (typeof row.definition_json === 'string' && row.definition_json.length > 0) {\n try {\n defn = JSON.parse(row.definition_json) ?? {};\n } catch {\n defn = {};\n }\n }\n\n return {\n id: row.id as string,\n name: (row.name as string) ?? row.id,\n objectName: row.object_name ? String(row.object_name) : undefined,\n triggers,\n url: String(row.url),\n // Method is authored via a select whose option values are lowercased\n // (get/post/…); upper-case here so delivery uses a canonical HTTP\n // method regardless of whether the row was authored before or after\n // the select change (legacy rows stored 'POST').\n method: String(row.method ?? defn.method ?? 'POST').toUpperCase(),\n headers: defn.headers,\n secret: defn.secret,\n timeoutMs: defn.timeoutMs,\n };\n }\n\n /**\n * Handler for the firehose subscription.\n *\n * NOTE: we intentionally `void` the inner enqueue() so the realtime\n * publisher (and therefore the user's request) is never blocked on\n * webhook persistence.\n */\n private handleEvent(event: RealtimeEventPayload): void {\n if (!event.type?.startsWith('data.record.')) return;\n if (!event.object) return;\n if (event.object === this.subscriptionsObject) return; // self-heal handles its own\n\n const action = event.type.slice('data.record.'.length) as\n | 'created' | 'updated' | 'deleted' | string;\n const trigger = mapActionToTrigger(action);\n if (!trigger) return;\n\n const subs = [\n ...(this.subscriptions.get(event.object) ?? []),\n ...(this.subscriptions.get('*') ?? []),\n ];\n if (subs.length === 0) return;\n\n const payload = event.payload ?? {};\n const recordId =\n (payload as any).recordId ??\n (payload as any).id ??\n (payload as any).after?.id ??\n (payload as any).before?.id ??\n 'unknown';\n\n // Deterministic eventId — same input on any node → same id.\n // Includes timestamp so two distinct updates to the same record\n // don't accidentally dedup.\n const eventId = `${event.object}:${recordId}:${action}:${event.timestamp}`;\n\n for (const sub of subs) {\n if (!sub.triggers.has(trigger)) continue;\n\n // Fire-and-forget — never await on the hot path. Map the webhook\n // delivery onto the generic HTTP-outbox shape (ADR-0018 M3):\n // - source 'webhook' + dedupKey '<webhookId>:<eventId>' preserves\n // the old (event_id, webhook_id) at-most-once enqueue;\n // - refId = webhookId keeps per-webhook partition affinity / ordering;\n // - label = event type → X-Objectstack-Event header.\n void this.enqueue({\n source: 'webhook',\n refId: sub.id,\n dedupKey: `${sub.id}:${eventId}`,\n label: event.type,\n url: sub.url,\n method: sub.method,\n headers: sub.headers,\n signingSecret: sub.secret,\n timeoutMs: sub.timeoutMs,\n // [#3946] Envelope keys are written LAST so the event payload\n // cannot rewrite them. Behaviour-neutral for the engine's own\n // publishers — `data.record.*` payloads are\n // `{ recordId, after, changes }`, with record fields nested\n // under `after`, so none of these four keys collide today. It\n // is the shape that was wrong: a publisher that flattened\n // record fields into the payload (the `payload.id` fallback\n // above suggests some do) would have silently rewritten the\n // `object` / `action` / `timestamp` a subscriber receives.\n payload: {\n ...payload,\n object: event.object,\n recordId,\n action,\n timestamp: event.timestamp,\n },\n }).catch((err) =>\n this.logger.warn?.('[webhook-auto-enqueuer] enqueue failed', {\n webhook: sub.name,\n eventId,\n err: (err as Error)?.message ?? err,\n }),\n );\n }\n }\n\n private handleSelfHealEvent(event: RealtimeEventPayload): void {\n if (event.object !== this.subscriptionsObject) return;\n if (!event.type?.startsWith('data.record.')) return;\n this.refresh().catch((err) =>\n this.logger.warn?.('[webhook-auto-enqueuer] self-heal refresh failed', err),\n );\n }\n\n /** Test / admin accessor. */\n snapshot(): ReadonlyMap<string, ReadonlyArray<CachedSubscription>> {\n return this.subscriptions;\n }\n}\n\nfunction mapActionToTrigger(\n action: string,\n): 'create' | 'update' | 'delete' | null {\n switch (action) {\n case 'created':\n return 'create';\n case 'updated':\n return 'update';\n case 'deleted':\n return 'delete';\n default:\n return null;\n }\n}\n\n/** The trigger values the enqueuer can actually map from an emitted record event. */\nconst DISPATCHABLE_WEBHOOK_TRIGGERS: ReadonlySet<string> = new Set(['create', 'update', 'delete']);\n","// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.\n\n/**\n * bootstrapDeclaredWebhooks — materialize stack/connector-declared `webhooks`\n * into `sys_webhook` rows so the dispatcher can actually see them (closes #3461).\n *\n * ## The disconnect this closes\n * The spec authoring surface (`WebhookSchema` — `defineStack({ webhooks })`,\n * `@objectstack/spec/automation/webhook`) declares `object` / `isActive`, and\n * is generically decomposed into the ObjectQL registry at boot as metadata\n * type `webhook`. But the runtime dispatcher ({@link AutoEnqueuer}) reads\n * `sys_webhook` DATA rows (`object_name` / `active`), which until now were only\n * ever written by hand through the object's CRUD UI. Nothing bridged the two —\n * so authoring `webhooks:` on a stack produced metadata artifacts that never\n * became dispatchable rows (a silent no-op; ADR-0078). This seeder is that\n * missing ingestion path.\n *\n * ## Shape translation (authoring → runtime row)\n * The spec shape diverges from the runtime column names; we map only at this\n * boundary and stash the full validated envelope in `definition_json` (whence\n * the enqueuer reads headers / secret / timeout):\n * - `object` → `object_name`\n * - `isActive` → `active`\n * - `triggers` / `url` / `method` / `label` / `description` → same-named columns\n * - the entire parsed {@link Webhook} → `definition_json` (JSON string)\n *\n * Each item is validated through `WebhookSchema.parse()` first — this gives the\n * spec schema a real consumer (defaults for `method`/`isActive`/`timeoutMs` get\n * applied) and rejects malformed authoring with a warning instead of crashing\n * boot.\n *\n * ## Seed-not-clobber (mirrors sys_sharing_rule, #2909)\n * `sys_webhook` is admin-editable (`managedBy: 'config'`). Declared webhooks\n * ship with the app/package, so they seed with `managed_by: 'package'`\n * provenance and re-seed on every boot — but a row an admin has created\n * (`managed_by: 'admin'`) or edited (`customized: true`, stamped by\n * {@link bindWebhookProvenanceStamp}) is never overwritten. Most importantly,\n * an admin's `active: false` on a noisy webhook survives redeploys.\n *\n * MUST run before {@link AutoEnqueuer.start} so the enqueuer's first cache\n * refresh already sees the declared rows.\n */\n\nimport type { IDataEngine } from '@objectstack/spec/contracts';\nimport { WebhookSchema, type Webhook } from '@objectstack/spec/automation';\n\n/** System write context — the boot seeder is not an admin authoring action. */\nconst SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const;\n\ninterface Logger {\n info?: (msg: string, meta?: unknown) => void;\n warn?: (msg: string, meta?: unknown) => void;\n}\n\n/** Random id with a stable prefix — mirrors the sharing-rule seeder. */\nfunction uid(prefix: string): string {\n const g: any = globalThis as any;\n if (g.crypto?.randomUUID) return `${prefix}_${g.crypto.randomUUID()}`;\n return `${prefix}_${Math.random().toString(36).slice(2, 10)}`;\n}\n\n/**\n * Read declared `webhook` items from the ObjectQL registry (where the manifest\n * decomposition parks `stack.webhooks`), falling back to the metadata service.\n * Items may be wrapped as `{ content }` — unwrap to the raw authoring object.\n */\nfunction readDeclared(engine: any, metadataService: any, type: string): any[] {\n try {\n const reg = engine?._registry;\n if (reg?.listItems) {\n const items = (reg.listItems(type) ?? []).map((i: any) => i?.content ?? i).filter(Boolean);\n if (items.length > 0) return items;\n }\n } catch {\n /* fall through to metadata service */\n }\n try {\n const listed = metadataService?.list?.(type);\n const arr = typeof (listed as any)?.then === 'function' ? [] : (listed ?? []);\n return Array.isArray(arr) ? arr.map((i: any) => i?.content ?? i).filter(Boolean) : [];\n } catch {\n return [];\n }\n}\n\nexport interface BootstrapDeclaredWebhooksResult {\n seeded: number;\n skipped: number;\n}\n\n/**\n * Materialize declared webhooks into `sys_webhook`. Idempotent and safe to run\n * on every boot.\n */\nexport async function bootstrapDeclaredWebhooks(\n engine: IDataEngine,\n metadataService: any,\n logger?: Logger,\n subscriptionsObject = 'sys_webhook',\n): Promise<BootstrapDeclaredWebhooksResult> {\n const declared = readDeclared(engine, metadataService, 'webhook');\n if (declared.length === 0) return { seeded: 0, skipped: 0 };\n\n const now = new Date().toISOString();\n let seeded = 0;\n let skipped = 0;\n\n for (const raw of declared) {\n // Validate + fill defaults through the canonical spec schema. A real\n // consumer at last — a malformed webhook warns and is skipped, never\n // crashing boot.\n let wh: Webhook;\n try {\n wh = WebhookSchema.parse(raw);\n } catch (err: any) {\n logger?.warn?.('[webhook] declared webhook failed validation — skipped', {\n name: (raw as any)?.name,\n error: err?.message ?? String(err),\n });\n skipped += 1;\n continue;\n }\n\n try {\n const existing = await engine.find(subscriptionsObject, {\n where: { name: wh.name },\n limit: 1,\n context: SYSTEM_CTX,\n } as any);\n const row: any = Array.isArray(existing) ? existing[0] : undefined;\n\n if (row) {\n // Admin owns a same-named row, or has edited this seeded one — never\n // clobber. `active: false` on a noisy webhook must survive redeploys.\n if (row.managed_by === 'admin') {\n logger?.warn?.('[webhook] declared name collides with an admin-authored row — seed skipped', {\n name: wh.name,\n });\n skipped += 1;\n continue;\n }\n if (row.customized === true) {\n skipped += 1;\n continue;\n }\n const patch = {\n id: row.id,\n ...mapWebhookToRow(wh),\n // Adopt pristine/legacy (pre-provenance) rows so future boots\n // recognize them as package-managed.\n managed_by: 'package',\n updated_at: now,\n };\n await engine.update(subscriptionsObject, patch, { context: SYSTEM_CTX } as any);\n seeded += 1;\n continue;\n }\n\n const newRow = {\n id: uid('whk'),\n ...mapWebhookToRow(wh),\n managed_by: 'package',\n customized: false,\n created_at: now,\n updated_at: now,\n };\n await engine.insert(subscriptionsObject, newRow, { context: SYSTEM_CTX } as any);\n seeded += 1;\n } catch (err: any) {\n logger?.warn?.('[webhook] declared webhook seed failed', {\n name: wh.name,\n error: err?.message ?? String(err),\n });\n skipped += 1;\n }\n }\n\n logger?.info?.('[webhook] declared webhooks materialized into sys_webhook', {\n seeded,\n skipped,\n total: declared.length,\n });\n return { seeded, skipped };\n}\n\n/**\n * Translate a validated {@link Webhook} into `sys_webhook` column values.\n * `object → object_name`, `isActive → active`; the full envelope is stashed in\n * `definition_json` for the enqueuer's advanced-config read (headers/secret/…).\n */\nfunction mapWebhookToRow(wh: Webhook): Record<string, unknown> {\n return {\n name: wh.name,\n label: wh.label ?? wh.name,\n object_name: wh.object ?? null,\n triggers: wh.triggers ?? [],\n url: wh.url,\n // Store lowercase to match the object's Field.select option values\n // (get/post/…); the enqueuer upper-cases before delivery either way.\n method: String(wh.method ?? 'POST').toLowerCase(),\n description: wh.description ?? null,\n active: wh.isActive !== false,\n definition_json: JSON.stringify(wh),\n };\n}\n","// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.\n\n/**\n * [#3461] Provenance stamp for `sys_webhook`.\n *\n * `sys_webhook` is RECORD-AUTHORITATIVE: a code-declared webhook is a boot seed\n * (`bootstrapDeclaredWebhooks`), and the row — including any admin tuning such\n * as flipping a noisy webhook to `active: false` — is the authority. The seeder\n * skips rows marked `customized`, so this hook is the half that DETECTS the\n * admin edit: any non-system update touching a `package`/`platform`-seeded row\n * stamps `customized: true` onto the payload.\n *\n * Why a data hook (and not a write gate or the REST layer), verbatim to the\n * sys_sharing_rule rationale (#2909 T1):\n * - admins edit webhooks through several doors (Setup UI generic data door,\n * scripts, console) — an engine hook covers them all;\n * - there is deliberately NO write gate here: webhooks are a first-class admin\n * authoring surface, so edits are allowed — they just have to be remembered;\n * - both provenance columns are `readonly`, and the engine's readonly strip\n * exempts isSystem callers while snapshotting supplied keys BEFORE hooks run\n * — so a caller can never forge/clear `customized`, while this hook's stamp\n * survives.\n *\n * Known boundary: multi-row updates (no single `input.id`) are not stamped —\n * every webhook-editing UI path updates by id.\n */\n\ninterface MinimalEngine {\n find(object: string, opts?: any): Promise<any[]>;\n registerHook(event: string, handler: (ctx: any) => any, options?: Record<string, any>): void;\n unregisterHooksByPackage(packageId: string): number;\n}\n\ninterface MinimalLogger {\n info?: (msg: string, meta?: Record<string, any>) => void;\n warn?: (msg: string, meta?: Record<string, any>) => void;\n}\n\nexport const WEBHOOK_PROVENANCE_PACKAGE = 'plugin-webhooks:provenance';\n\nconst SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const;\n\nexport function bindWebhookProvenanceStamp(engine: MinimalEngine, logger?: MinimalLogger): void {\n if (typeof engine?.registerHook !== 'function') return;\n engine.registerHook(\n 'beforeUpdate',\n async (ctx: any) => {\n // Seeder / boot reconcilers write with isSystem — the package door, not\n // an admin customization.\n if ((ctx?.session as any)?.isSystem) return;\n const id = ctx?.input?.id ?? (ctx?.input?.data as any)?.id;\n if (!id) return; // multi-row update — see boundary note above\n const data = ctx?.input?.data;\n if (!data || typeof data !== 'object') return;\n try {\n // `previous` is not resolved before beforeUpdate hooks run — read the\n // current row ourselves (system ctx: this is a provenance check, not\n // an authorization decision).\n const rows = await engine.find('sys_webhook', {\n where: { id },\n fields: ['id', 'managed_by', 'customized'],\n limit: 1,\n context: SYSTEM_CTX,\n });\n const row = Array.isArray(rows) ? rows[0] : undefined;\n if (!row) return;\n if ((row.managed_by === 'package' || row.managed_by === 'platform') && row.customized !== true) {\n (data as any).customized = true;\n }\n } catch (err: any) {\n logger?.warn?.('[webhook] provenance stamp failed (edit proceeds unstamped)', {\n id,\n error: err?.message,\n });\n }\n },\n { object: 'sys_webhook', packageId: WEBHOOK_PROVENANCE_PACKAGE, priority: 150 },\n );\n logger?.info?.('[webhook] provenance stamp hook bound');\n}\n\nexport function unbindWebhookProvenanceStamp(engine: MinimalEngine): void {\n if (typeof engine?.unregisterHooksByPackage === 'function') {\n engine.unregisterHooksByPackage(WEBHOOK_PROVENANCE_PACKAGE);\n }\n}\n","// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.\n\nimport type { Plugin, PluginContext } from '@objectstack/core';\nimport type { IDataEngine, IRealtimeService } from '@objectstack/spec/contracts';\nimport type { EnqueueHttpInput } from '@objectstack/service-messaging';\nimport { AutoEnqueuer, type AutoEnqueuerOptions } from './auto-enqueuer.js';\nimport { SysWebhook } from './sys-webhook.object.js';\nimport { bootstrapDeclaredWebhooks } from './bootstrap-declared-webhooks.js';\nimport { bindWebhookProvenanceStamp, unbindWebhookProvenanceStamp } from './webhook-provenance.js';\n\n/**\n * Structural view of `@objectstack/service-messaging`'s HTTP-outbox surface\n * (ADR-0018 M3) — declared locally so this plugin doesn't take a hard runtime\n * import on the service. Webhook deliveries are enqueued onto the shared\n * `sys_http_delivery` outbox and drained by the messaging `HttpDispatcher`.\n */\ninterface MessagingHttpSurface {\n isHttpDeliveryReady(): boolean;\n enqueueHttp(input: EnqueueHttpInput): Promise<string>;\n redeliverHttp(id: string): Promise<{ id: string; status: string }>;\n}\n\nexport interface WebhookOutboxPluginOptions {\n /**\n * Auto-enqueue config. When enabled (default `true` if the realtime + data\n * engine services are available), the plugin subscribes to `data.record.*`\n * events and enqueues a delivery onto the shared messaging HTTP outbox for\n * every matching `sys_webhook` row.\n *\n * Set `false` to disable and enqueue webhooks imperatively elsewhere.\n */\n autoEnqueue?: boolean | AutoEnqueuerOptions;\n}\n\n/**\n * Wires webhook fan-out on top of the shared outbound-HTTP delivery substrate\n * (ADR-0018 M3).\n *\n * Webhooks are no longer their own delivery engine: the durable outbox, the\n * cluster-coordinated dispatcher, the retry/backoff/dead-letter schedule, and\n * the retention sweep all live in `@objectstack/service-messaging`\n * (`sys_http_delivery` + `HttpDispatcher`). This plugin owns only the\n * webhook-specific concerns:\n * - the `sys_webhook` configuration object,\n * - the {@link AutoEnqueuer} that turns `data.record.*` events into outbox\n * rows (`source: 'webhook'`), and\n * - the redeliver admin endpoint.\n *\n * End-to-end flow:\n *\n * engine.insert('contact', {...})\n * → engine publishes data.record.created via IRealtimeService\n * → AutoEnqueuer matches active sys_webhook rows in O(1)\n * → messaging.enqueueHttp() runs fire-and-forget (off the write path)\n * → messaging HttpDispatcher claims and POSTs (cluster-coordinated, retried)\n *\n * **Requires** `MessagingServicePlugin` (`@objectstack/service-messaging`),\n * which is a foundational, always-on capability.\n */\nexport class WebhookOutboxPlugin implements Plugin {\n name = 'com.objectstack.plugin-webhook-outbox';\n version = '2.0.0';\n type = 'standard' as const;\n dependencies = ['com.objectstack.service.messaging'];\n /**\n * `init()` registers this plugin's schema through `manifest` with no\n * fallback. Until #4187 that was safe only TRANSITIVELY — messaging happens\n * to depend on ObjectQL, which provides `manifest` — so the guarantee would\n * have evaporated silently the day messaging stopped depending on the\n * engine, and the failure would have surfaced as an unrelated plugin's\n * init crash. Declaring the requirement directly makes the kernel check it\n * regardless of what messaging depends on.\n */\n requiresServices = ['manifest'];\n\n private autoEnqueuer: AutoEnqueuer | undefined;\n /** Engine the provenance hook was bound to, so `dispose()` can unbind it. */\n private boundEngine: any;\n\n constructor(private readonly options: WebhookOutboxPluginOptions = {}) {}\n\n async init(ctx: PluginContext): Promise<void> {\n // Register the webhook config object (ADR-0029 K2.a). The delivery\n // telemetry now lives in messaging's `sys_http_delivery`, so the nav's\n // \"Deliveries\" entry points there (filtered to source=webhook in views).\n const manifest = ctx.getService<{ register(m: any): void }>('manifest');\n if (manifest && typeof manifest.register === 'function') {\n manifest.register({\n id: 'com.objectstack.plugin-webhook-outbox.schema',\n namespace: 'sys',\n version: this.version,\n type: 'plugin',\n scope: 'system',\n name: 'Webhook Schemas',\n description: 'Registers sys_webhook (configuration). Deliveries use messaging\\'s sys_http_delivery outbox.',\n objects: [SysWebhook],\n navigationContributions: [\n {\n app: 'setup',\n group: 'group_integrations',\n priority: 100,\n items: [\n { id: 'nav_webhooks', type: 'object', label: 'Webhooks', objectName: 'sys_webhook', icon: 'webhook', requiresObject: 'sys_webhook' },\n { id: 'nav_http_deliveries', type: 'object', label: 'HTTP Deliveries', objectName: 'sys_http_delivery', icon: 'send', requiresObject: 'sys_http_delivery' },\n ],\n },\n ],\n });\n } else {\n ctx.logger.warn?.(\n '[webhook-outbox] manifest service unavailable — sys_webhook will NOT appear in REST or Studio nav. Register MetadataService before WebhookOutboxPlugin.',\n );\n }\n\n // ADR-0029 D8 — contribute object translations once i18n is up.\n if (typeof (ctx as any).hook === 'function') {\n (ctx as any).hook('kernel:ready', async () => {\n try {\n const i18n = ctx.getService<any>('i18n');\n if (i18n && typeof i18n.loadTranslations === 'function') {\n const { WebhooksTranslations } = await import('./translations/index.js');\n for (const [locale, data] of Object.entries(WebhooksTranslations)) {\n i18n.loadTranslations(locale, data as Record<string, unknown>);\n }\n }\n } catch { /* i18n optional */ }\n });\n }\n\n const autoEnqueueOpt = this.options.autoEnqueue ?? true;\n\n if (typeof (ctx as any).hook === 'function') {\n (ctx as any).hook('kernel:ready', async () => {\n // Materialize declared webhooks FIRST — this only needs the data\n // engine, so it must not be gated behind the auto-enqueue\n // dispatch prerequisites (realtime + messaging). Otherwise a\n // deployment without realtime would silently fail to materialize\n // declared webhooks — the very no-op this bridge closes (#3461).\n await this.bootDeclaredWebhooks(ctx);\n await this.bootAutoEnqueue(ctx, autoEnqueueOpt);\n this.registerAdminRoutes(ctx);\n });\n }\n\n ctx.logger.info?.('[webhook-outbox] initialised (delivery via shared messaging HTTP outbox)', {\n autoEnqueue: autoEnqueueOpt !== false,\n });\n }\n\n async dispose(): Promise<void> {\n await this.autoEnqueuer?.stop();\n if (this.boundEngine) {\n try { unbindWebhookProvenanceStamp(this.boundEngine); } catch { /* best effort */ }\n this.boundEngine = undefined;\n }\n }\n\n private getMessaging(ctx: PluginContext): MessagingHttpSurface | undefined {\n const svc = this.tryGetService<MessagingHttpSurface>(ctx, ['messaging']);\n return svc && typeof svc.enqueueHttp === 'function' ? svc : undefined;\n }\n\n /**\n * [#3461] Bridge the declarative authoring surface to the dispatcher:\n * materialize stack/connector-declared `webhook` metadata into `sys_webhook`\n * rows so the auto-enqueuer (and the Studio UI) can see them.\n *\n * Gated on the DATA ENGINE alone — deliberately independent of the\n * auto-enqueue dispatch prerequisites (realtime + messaging). Materializing\n * rows is a pure write; a deployment that mounts the webhook plugin without\n * realtime must still get its declared webhooks into the table (and the\n * Setup UI), even if nothing dispatches them yet. Runs before\n * {@link bootAutoEnqueue} so the enqueuer's first cache refresh sees the rows.\n */\n private async bootDeclaredWebhooks(ctx: PluginContext): Promise<void> {\n const engine = this.tryGetService<IDataEngine>(ctx, ['objectql', 'data']);\n if (!engine) {\n ctx.logger.warn?.('[webhook] declared-webhook bootstrap skipped — no data engine available');\n return;\n }\n // Bind the provenance stamp so an admin edit freezes a seeded row.\n this.boundEngine = engine;\n bindWebhookProvenanceStamp(engine as any, ctx.logger as any);\n let metadataService: any;\n try { metadataService = ctx.getService('metadata'); } catch { /* optional */ }\n try {\n await bootstrapDeclaredWebhooks(engine, metadataService, ctx.logger as any);\n } catch (err: any) {\n ctx.logger.warn?.('[webhook] declared-webhook bootstrap failed (dispatcher still serves admin rows)', {\n error: err?.message ?? String(err),\n });\n }\n }\n\n private async bootAutoEnqueue(\n ctx: PluginContext,\n opt: boolean | AutoEnqueuerOptions,\n ): Promise<void> {\n if (opt === false) return;\n const engine = this.tryGetService<IDataEngine>(ctx, ['objectql', 'data']);\n const realtime = this.tryGetService<IRealtimeService>(ctx, ['realtime']);\n const messaging = this.getMessaging(ctx);\n if (!engine || !realtime || !messaging) {\n ctx.logger.warn?.(\n '[webhook-auto-enqueuer] disabled — ObjectQL, Realtime, or Messaging service not available',\n { hasEngine: !!engine, hasRealtime: !!realtime, hasMessaging: !!messaging },\n );\n return;\n }\n if (!messaging.isHttpDeliveryReady()) {\n ctx.logger.warn?.(\n '[webhook-auto-enqueuer] messaging HTTP outbox not ready (no data engine / reliableDelivery off) — webhook deliveries will not be durable',\n );\n }\n\n const enqOpts = (typeof opt === 'object' ? opt : {}) as AutoEnqueuerOptions;\n this.autoEnqueuer = new AutoEnqueuer(\n engine,\n realtime,\n (input) => messaging.enqueueHttp(input),\n { ...enqOpts, logger: ctx.logger },\n );\n await this.autoEnqueuer.start();\n ctx.registerService('webhook.autoEnqueuer', this.autoEnqueuer);\n ctx.logger.info?.('[webhook-auto-enqueuer] started (enqueues source=webhook onto sys_http_delivery)');\n }\n\n private tryGetService<T>(ctx: PluginContext, names: string[]): T | undefined {\n for (const n of names) {\n try {\n const svc = ctx.getService<T>(n);\n if (svc) return svc;\n } catch {\n // fall through\n }\n }\n return undefined;\n }\n\n /**\n * Mount POST /api/v1/webhooks/redeliver on the host Hono app, if one is\n * available. Delegates to `messaging.redeliverHttp(deliveryId)`. Auth is the\n * better-auth session cookie — every authenticated user counts.\n */\n private registerAdminRoutes(ctx: PluginContext): void {\n const http = this.tryGetService<any>(ctx, ['http-server']);\n if (!http || typeof http.getRawApp !== 'function') {\n ctx.logger.debug?.('[webhook-outbox] HTTP server not available; redeliver endpoint not mounted');\n return;\n }\n const rawApp = http.getRawApp();\n const messaging = this.getMessaging(ctx);\n if (!rawApp || !messaging) return;\n\n rawApp.post('/api/v1/webhooks/redeliver', async (c: any) => {\n const userId = await this.resolveSessionUserId(ctx, c);\n if (!userId) {\n return c.json(\n { success: false, error: { code: 'UNAUTHENTICATED', message: 'Sign in to redeliver webhook deliveries.' } },\n 401,\n );\n }\n let body: any;\n try {\n body = await c.req.json();\n } catch {\n return c.json({ success: false, error: { code: 'INVALID_REQUEST', message: 'Request body must be JSON.' } }, 400);\n }\n const deliveryId = typeof body?.deliveryId === 'string' ? body.deliveryId.trim() : '';\n if (!deliveryId) {\n return c.json(\n { success: false, error: { code: 'MISSING_REQUIRED_FIELD', message: 'Body must include `deliveryId: string`.' } },\n 400,\n );\n }\n try {\n const row = await messaging.redeliverHttp(deliveryId);\n ctx.logger.info?.('[webhook-outbox] redelivered', { deliveryId, requestedBy: userId });\n return c.json({ success: true, data: { id: row.id, status: row.status } });\n } catch (err: any) {\n const code = err?.code;\n if (code === 'RESOURCE_NOT_FOUND') {\n return c.json({ success: false, error: { code, message: err.message } }, 404);\n }\n if (code === 'DELIVERY_NOT_ELIGIBLE') {\n return c.json({ success: false, error: { code, message: err.message } }, 409);\n }\n ctx.logger.error?.('[webhook-outbox] redeliver failed', err as Error);\n return c.json(\n { success: false, error: { code: 'INTERNAL_ERROR', message: err?.message ?? String(err) } },\n 500,\n );\n }\n });\n\n ctx.logger.info?.('[webhook-outbox] redeliver endpoint mounted at POST /api/v1/webhooks/redeliver');\n }\n\n private async resolveSessionUserId(ctx: PluginContext, c: any): Promise<string | undefined> {\n try {\n const authService: any = this.tryGetService<any>(ctx, ['auth']);\n if (!authService) return undefined;\n let api: any = authService.api;\n if (!api && typeof authService.getApi === 'function') {\n api = await authService.getApi();\n }\n if (!api?.getSession) return undefined;\n const session = await api.getSession({ headers: c.req.raw.headers });\n const uid = session?.user?.id;\n return typeof uid === 'string' && uid.length > 0 ? uid : undefined;\n } catch {\n return undefined;\n }\n }\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["/home/runner/work/objectstack/objectstack/packages/plugins/plugin-webhooks/dist/index.cjs","../src/auto-enqueuer.ts","../src/bootstrap-declared-webhooks.ts","../src/webhook-provenance.ts","../src/webhook-outbox-plugin.ts"],"names":["SYSTEM_CTX"],"mappings":"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","file":"/home/runner/work/objectstack/objectstack/packages/plugins/plugin-webhooks/dist/index.cjs","sourcesContent":[null,"// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.\n\nimport type { IDataEngine, IRealtimeService, RealtimeEventPayload } from '@objectstack/spec/contracts';\nimport type { WebhookTriggerType } from '@objectstack/spec/automation';\nimport type { EnqueueHttpInput } from '@objectstack/service-messaging';\n\n/**\n * The authored trigger vocabulary, taken from the spec rather than restated\n * here — this file both validates authored triggers and maps events onto them,\n * so a locally-spelled union would be a second contract free to drift from the\n * one authors are validated against.\n */\ntype WebhookTrigger = WebhookTriggerType;\n\n/**\n * Enqueue callback into the shared `service-messaging` HTTP outbox (ADR-0018 M3).\n * The plugin supplies one bound to `messaging.enqueueHttp(...)`; webhooks no\n * longer own a delivery outbox/dispatcher — they share the generic substrate.\n */\nexport type HttpEnqueueFn = (input: EnqueueHttpInput) => Promise<string>;\n\n/**\n * Optional logger interface (subset of console / kernel logger).\n */\ninterface OptionalLogger {\n info?(msg: string, meta?: unknown): void;\n warn?(msg: string, meta?: unknown): void;\n debug?(msg: string, meta?: unknown): void;\n error?(msg: string, err?: unknown, meta?: unknown): void;\n}\n\n/**\n * Per-row subscription cached in memory. Mirrors a subset of the\n * `sys_webhook` object — only what the auto-enqueuer needs to match an\n * event and build an `EnqueueInput`.\n */\ninterface CachedSubscription {\n id: string;\n name: string;\n objectName: string | undefined; // empty = matches all objects\n triggers: Set<WebhookTrigger>;\n url: string;\n method?: string;\n headers?: Record<string, string>;\n secret?: string;\n timeoutMs?: number;\n}\n\nexport interface AutoEnqueuerOptions {\n /**\n * Object name holding webhook subscriptions. Defaults to `sys_webhook`,\n * the platform-objects schema authored in apps.\n */\n subscriptionsObject?: string;\n\n /**\n * Periodic full-cache refresh interval (ms). Belt-and-braces in case\n * the subscription-change event is missed. Default 60s.\n */\n refreshIntervalMs?: number;\n\n logger?: OptionalLogger;\n}\n\n/**\n * Bridge between `IRealtimeService` (`data.record.*` events emitted by\n * the engine) and `IWebhookOutbox` (durable delivery rows the dispatcher\n * picks up).\n *\n * ## Why a separate class\n * Keeps `WebhookOutboxPlugin` lean: the plugin wires services, this\n * class owns the runtime fan-out logic + subscription cache.\n *\n * ## Hot path\n * Every `engine.insert/update/delete` fires a `data.record.*` event.\n * The handler:\n * 1. Looks up matching subscriptions in an in-memory `Map<object, sub[]>`\n * — O(1) per event, no DB hit on the write path.\n * 2. Calls `outbox.enqueue()` fire-and-forget for each match. The\n * enqueue itself is a single INSERT, which runs *after* the user's\n * request has already returned.\n *\n * Net cost on the write path: one synchronous Map lookup (~microseconds).\n *\n * ## Cache freshness\n * The cache is rebuilt:\n * 1. Once on `start()`.\n * 2. On every `data.record.{created,updated,deleted}` event whose\n * object is `sys_webhook` (self-healing — when a user toggles a\n * webhook, the handler refreshes the cache before returning).\n * 3. Periodically (default 60s) as belt-and-braces.\n *\n * For multi-node clusters this is *eventually consistent* — node B may\n * not see node A's edit for up to one cycle. That's acceptable for\n * webhook configuration changes (humans don't expect millisecond\n * propagation) and matches Hasura's behaviour.\n *\n * ## Determinism\n * `eventId` is computed from `${object}:${recordId}:${type}:${timestamp}`\n * so the outbox dedup index catches duplicates that could arise from\n * upstream replay or buggy producers — and is stable across nodes.\n *\n * An aggregate `data.records.*` event (#4639) has no record to key on, so it\n * dedups on the producer's event uuid instead: two predicate sweeps in the\n * same millisecond are genuinely different events and must not collapse into\n * one delivery, which a timestamp-based key would do.\n */\nexport class AutoEnqueuer {\n private readonly subscriptions = new Map<string, CachedSubscription[]>();\n private readonly subscriptionsObject: string;\n private readonly refreshIntervalMs: number;\n private readonly logger: OptionalLogger;\n private subId: string | undefined;\n private subIdSelfHeal: string | undefined;\n private refreshTimer: ReturnType<typeof setInterval> | undefined;\n private running = false;\n private refreshing: Promise<void> | undefined;\n\n constructor(\n private readonly engine: IDataEngine,\n private readonly realtime: IRealtimeService,\n private readonly enqueue: HttpEnqueueFn,\n opts: AutoEnqueuerOptions = {},\n ) {\n this.subscriptionsObject = opts.subscriptionsObject ?? 'sys_webhook';\n this.refreshIntervalMs = opts.refreshIntervalMs ?? 60_000;\n this.logger = opts.logger ?? {};\n }\n\n /**\n * Load the subscription cache and start listening for events.\n */\n async start(): Promise<void> {\n if (this.running) return;\n this.running = true;\n\n await this.refresh();\n\n // Main subscription: every data event → match → enqueue.\n this.subId = await this.realtime.subscribe(\n 'webhook-auto-enqueuer',\n (event) => this.handleEvent(event),\n );\n\n // Self-healing: any change to sys_webhook refreshes the cache.\n this.subIdSelfHeal = await this.realtime.subscribe(\n 'webhook-auto-enqueuer-self-heal',\n (event) => this.handleSelfHealEvent(event),\n { object: this.subscriptionsObject },\n );\n\n if (this.refreshIntervalMs > 0) {\n this.refreshTimer = setInterval(() => {\n this.refresh().catch((err) =>\n this.logger.warn?.('[webhook-auto-enqueuer] periodic refresh failed', err),\n );\n }, this.refreshIntervalMs);\n // Don't keep the process alive solely for this timer.\n this.refreshTimer.unref?.();\n }\n }\n\n async stop(): Promise<void> {\n if (!this.running) return;\n this.running = false;\n if (this.subId) await this.realtime.unsubscribe(this.subId);\n if (this.subIdSelfHeal) await this.realtime.unsubscribe(this.subIdSelfHeal);\n if (this.refreshTimer) clearInterval(this.refreshTimer);\n this.subId = undefined;\n this.subIdSelfHeal = undefined;\n this.refreshTimer = undefined;\n }\n\n /**\n * Force-refresh the subscription cache from storage. Concurrent\n * callers share a single in-flight refresh.\n */\n async refresh(): Promise<void> {\n if (this.refreshing) return this.refreshing;\n this.refreshing = this.doRefresh().finally(() => {\n this.refreshing = undefined;\n });\n return this.refreshing;\n }\n\n private async doRefresh(): Promise<void> {\n let rows: any[];\n try {\n rows = await this.engine.find(this.subscriptionsObject, {\n where: { active: true },\n });\n } catch (err) {\n this.logger.warn?.(\n `[webhook-auto-enqueuer] failed to load ${this.subscriptionsObject}`,\n err,\n );\n return;\n }\n\n const next = new Map<string, CachedSubscription[]>();\n for (const row of rows) {\n const sub = this.parseRow(row);\n if (!sub) continue;\n // Empty objectName == \"any object\" → indexed under '*'.\n const key = sub.objectName ?? '*';\n const arr = next.get(key) ?? [];\n arr.push(sub);\n next.set(key, arr);\n }\n\n this.subscriptions.clear();\n for (const [k, v] of next) this.subscriptions.set(k, v);\n\n this.logger.debug?.('[webhook-auto-enqueuer] cache refreshed', {\n objects: this.subscriptions.size,\n rows: rows.length,\n });\n }\n\n private parseRow(row: any): CachedSubscription | null {\n if (!row?.id || !row?.url) return null;\n // `triggers` is now authored as a multi-select (stored as an array), but\n // legacy rows stored a comma-separated string (and some drivers hand a\n // JSON-encoded array back as a string). Accept all three shapes so a\n // schema change never silently drops a subscription's events.\n const rawTriggers = row.triggers;\n let triggerList: string[];\n if (Array.isArray(rawTriggers)) {\n triggerList = rawTriggers.map((t) => String(t));\n } else {\n const s = String(rawTriggers ?? '').trim();\n if (s.startsWith('[')) {\n try {\n const parsed = JSON.parse(s);\n triggerList = Array.isArray(parsed) ? parsed.map((t) => String(t)) : [s];\n } catch {\n triggerList = s.split(',');\n }\n } else {\n triggerList = s.split(',');\n }\n }\n const normalized = triggerList.map((t) => t.trim().toLowerCase()).filter(Boolean);\n // [#3196] Drop (and warn about) any trigger the enqueuer can't map to an\n // emitted record event — e.g. a legacy `sys_webhook` row authored with\n // the now-removed `undelete`/`api` values, which would otherwise sit in\n // the cache matching nothing. A loud drift-guard so a dead trigger can't\n // silently no-op again.\n const unknown = normalized.filter((t) => !DISPATCHABLE_WEBHOOK_TRIGGERS.has(t));\n if (unknown.length > 0) {\n this.logger.warn?.(\n `[webhook-auto-enqueuer] webhook '${(row.name as string) ?? row.id}' declares trigger(s) the engine never emits: ` +\n `${unknown.join(', ')} — ignored. Dispatchable triggers: ` +\n `${[...DISPATCHABLE_WEBHOOK_TRIGGERS].join(', ')}.`,\n { id: row.id, unknown },\n );\n }\n const triggers = new Set(\n normalized.filter((t) => DISPATCHABLE_WEBHOOK_TRIGGERS.has(t)) as WebhookTrigger[],\n );\n if (triggers.size === 0) {\n // [ADR-0078 Phase 4] No dispatchable triggers — the webhook can\n // never fire on ANY path, so say so instead of skipping silently.\n // This comment used to read \"(or a manual-only webhook with\n // none)\", but that mode does not exist: the `api` trigger was\n // REMOVED (#3196, `webhook.zod.ts`) precisely because there is no\n // manual fire path — the only webhook HTTP surface re-queues\n // already-failed deliveries. So a zero-trigger row is not an off\n // switch (that is `active`), it is a dead subscription that looks\n // armed in Setup. Same rule id as the author-time gate\n // (`webhook/without-triggers`) so the boot log greps into the\n // same docs. Only active rows reach parseRow, so a deliberately\n // disabled webhook stays warning-free.\n this.logger.warn?.(\n `[webhook-auto-enqueuer] webhook '${(row.name as string) ?? row.id}' has no dispatchable ` +\n `triggers — it will NEVER fire (rule webhook/without-triggers): there is no manual fire ` +\n `path (#3196), so this row is dead while looking armed in Setup. Declare ` +\n `one of: ${[...DISPATCHABLE_WEBHOOK_TRIGGERS].join(', ')}, or set it inactive if it ` +\n `should be off.`,\n { id: row.id },\n );\n return null;\n }\n\n // The \"definition_json\" field carries advanced config (headers,\n // secret, timeout); attempt a best-effort parse. Fall back to\n // top-level fields where present.\n let defn: Record<string, any> = {};\n if (typeof row.definition_json === 'string' && row.definition_json.length > 0) {\n try {\n defn = JSON.parse(row.definition_json) ?? {};\n } catch {\n defn = {};\n }\n }\n\n return {\n id: row.id as string,\n name: (row.name as string) ?? row.id,\n objectName: row.object_name ? String(row.object_name) : undefined,\n triggers,\n url: String(row.url),\n // Method is authored via a select whose option values are lowercased\n // (get/post/…); upper-case here so delivery uses a canonical HTTP\n // method regardless of whether the row was authored before or after\n // the select change (legacy rows stored 'POST').\n method: String(row.method ?? defn.method ?? 'POST').toUpperCase(),\n headers: defn.headers,\n secret: defn.secret,\n timeoutMs: defn.timeoutMs,\n };\n }\n\n /**\n * Handler for the firehose subscription.\n *\n * NOTE: we intentionally `void` the inner enqueue() so the realtime\n * publisher (and therefore the user's request) is never blocked on\n * webhook persistence.\n */\n private handleEvent(event: RealtimeEventPayload): void {\n if (!event.object) return;\n if (event.object === this.subscriptionsObject) return; // self-heal handles its own\n\n // [#4639] A predicate write publishes the aggregate `data.records.*`\n // instead, which has no record to describe — separate path, separate\n // trigger, separate delivery shape.\n if (event.type?.startsWith('data.records.')) {\n this.handleBulkEvent(event);\n return;\n }\n if (!event.type?.startsWith('data.record.')) return;\n\n const action = event.type.slice('data.record.'.length) as\n | 'created' | 'updated' | 'deleted' | string;\n const trigger = mapActionToTrigger(action);\n if (!trigger) return;\n\n const subs = [\n ...(this.subscriptions.get(event.object) ?? []),\n ...(this.subscriptions.get('*') ?? []),\n ];\n if (subs.length === 0) return;\n\n // [#4626] The envelope's `payload` IS the spec's `DataEvent`\n // (`@objectstack/spec/api`): `recordId` is a REQUIRED top-level string\n // the ObjectQL engine validates before publishing. Read it directly.\n // The old `recordId ?? id ?? after?.id ?? before?.id ?? 'unknown'`\n // chain was consumer-side tolerance for a producer that never filled\n // the contract (AGENTS.md PD #12) — and its `'unknown'` fallback\n // silently turned an unnameable record into a delivered webhook. An\n // off-contract event is now DROPPED loudly: the producer is broken and\n // gets fixed there.\n const payload = event.payload ?? {};\n const recordId = (payload as { recordId?: unknown }).recordId;\n if (typeof recordId !== 'string' || recordId === '') {\n this.logger.warn?.(\n '[webhook-auto-enqueuer] dropping off-contract data event: payload is not a DataEvent ' +\n '(no top-level string `recordId`) — fix the producer',\n { type: event.type, object: event.object },\n );\n return;\n }\n\n // Deterministic eventId — same input on any node → same id.\n // Includes timestamp so two distinct updates to the same record\n // don't accidentally dedup.\n const eventId = `${event.object}:${recordId}:${action}:${event.timestamp}`;\n\n for (const sub of subs) {\n if (!sub.triggers.has(trigger)) continue;\n\n // Fire-and-forget — never await on the hot path. Map the webhook\n // delivery onto the generic HTTP-outbox shape (ADR-0018 M3):\n // - source 'webhook' + dedupKey '<webhookId>:<eventId>' preserves\n // the old (event_id, webhook_id) at-most-once enqueue;\n // - refId = webhookId keeps per-webhook partition affinity / ordering;\n // - label = event type → X-Objectstack-Event header.\n void this.enqueue({\n source: 'webhook',\n refId: sub.id,\n dedupKey: `${sub.id}:${eventId}`,\n label: event.type,\n url: sub.url,\n method: sub.method,\n headers: sub.headers,\n signingSecret: sub.secret,\n timeoutMs: sub.timeoutMs,\n // [#3946] Envelope keys are written LAST so the event payload\n // cannot rewrite them. Behaviour-neutral for the engine's own\n // publishers — since #4626 a `data.record.*` payload is a\n // `DataEvent` (`id`, `type`, `object`, `recordId`, `changes?`,\n // `after?`, `userId?`, `timestamp`), whose `object` /\n // `recordId` / `timestamp` carry the SAME values written here\n // and whose record fields stay nested under `after`. It is the\n // shape that was wrong: a publisher that flattened record\n // fields into the payload would have silently rewritten the\n // `object` / `action` / `timestamp` a subscriber receives.\n payload: {\n ...payload,\n object: event.object,\n recordId,\n action,\n timestamp: event.timestamp,\n },\n }).catch((err) =>\n this.logger.warn?.('[webhook-auto-enqueuer] enqueue failed', {\n webhook: sub.name,\n eventId,\n err: (err as Error)?.message ?? err,\n }),\n );\n }\n }\n\n /**\n * Handler for aggregate `data.records.*` events — a predicate write\n * (`multi: true`) that the driver reports only as an affected-row count\n * (#4639).\n *\n * Deliberately NOT folded into {@link handleEvent}'s per-record path. The\n * delivered body has no `recordId` and no record fields, so a subscriber\n * to `update` that started receiving these would get a payload missing\n * everything it reads — which is how the pre-#4626 `recordId: ''`\n * fabrication broke consumers, just arriving from the other side. A\n * webhook opts in with `bulk_update` / `bulk_delete`.\n */\n private handleBulkEvent(event: RealtimeEventPayload): void {\n const action = event.type.slice('data.records.'.length);\n const trigger = mapBulkActionToTrigger(action);\n if (!trigger) return;\n\n const subs = [\n ...(this.subscriptions.get(event.object!) ?? []),\n ...(this.subscriptions.get('*') ?? []),\n ];\n if (subs.length === 0) return;\n\n // Same contract discipline as the per-record path: the payload IS the\n // spec's `BulkDataEvent`, whose `matched` the engine validates before\n // publishing. An off-contract event is dropped loudly rather than\n // delivered with a guessed count — `matched` is the entire substance\n // of a bulk delivery, so a wrong one is worse than none.\n const payload = event.payload ?? {};\n const matched = (payload as { matched?: unknown }).matched;\n if (typeof matched !== 'number' || !Number.isInteger(matched) || matched < 0) {\n this.logger.warn?.(\n '[webhook-auto-enqueuer] dropping off-contract bulk data event: payload is not a ' +\n 'BulkDataEvent (no top-level non-negative integer `matched`) — fix the producer',\n { type: event.type, object: event.object },\n );\n return;\n }\n\n // A predicate write has no natural key to build a deterministic id\n // from — `${object}:${action}:${timestamp}` would collide between two\n // sweeps landing in the same millisecond, and silently drop the\n // second. The producer's own event uuid is generated once and travels\n // with the event, so it dedups redelivery of the SAME event without\n // ever conflating two distinct ones.\n const eventUuid = (payload as { id?: unknown }).id;\n if (typeof eventUuid !== 'string' || eventUuid === '') {\n this.logger.warn?.(\n '[webhook-auto-enqueuer] dropping off-contract bulk data event: payload has no ' +\n 'top-level string `id` to dedup on — fix the producer',\n { type: event.type, object: event.object },\n );\n return;\n }\n const eventId = `${event.object}:${event.type}:${eventUuid}`;\n\n for (const sub of subs) {\n if (!sub.triggers.has(trigger)) continue;\n\n void this.enqueue({\n source: 'webhook',\n refId: sub.id,\n dedupKey: `${sub.id}:${eventId}`,\n label: event.type,\n url: sub.url,\n method: sub.method,\n headers: sub.headers,\n signingSecret: sub.secret,\n timeoutMs: sub.timeoutMs,\n // [#3946] Envelope keys last so the payload cannot rewrite them.\n payload: {\n ...payload,\n object: event.object,\n matched,\n action,\n timestamp: event.timestamp,\n },\n }).catch((err) =>\n this.logger.warn?.('[webhook-auto-enqueuer] bulk enqueue failed', {\n webhook: sub.name,\n eventId,\n err: (err as Error)?.message ?? err,\n }),\n );\n }\n }\n\n private handleSelfHealEvent(event: RealtimeEventPayload): void {\n if (event.object !== this.subscriptionsObject) return;\n // [#4639] A predicate write over `sys_webhook` (deactivate every\n // webhook on an object, say) changes the subscription set exactly like\n // a per-record edit does, so it must refresh the cache too — matching\n // only `data.record.` would leave the enqueuer dispatching from rows\n // the admin just turned off.\n if (!event.type?.startsWith('data.record.') && !event.type?.startsWith('data.records.')) return;\n this.refresh().catch((err) =>\n this.logger.warn?.('[webhook-auto-enqueuer] self-heal refresh failed', err),\n );\n }\n\n /** Test / admin accessor. */\n snapshot(): ReadonlyMap<string, ReadonlyArray<CachedSubscription>> {\n return this.subscriptions;\n }\n}\n\nfunction mapActionToTrigger(\n action: string,\n): 'create' | 'update' | 'delete' | null {\n switch (action) {\n case 'created':\n return 'create';\n case 'updated':\n return 'update';\n case 'deleted':\n return 'delete';\n default:\n return null;\n }\n}\n\n/** [#4639] `data.records.{action}` → its opt-in bulk trigger. */\nfunction mapBulkActionToTrigger(action: string): 'bulk_update' | 'bulk_delete' | null {\n switch (action) {\n case 'updated':\n return 'bulk_update';\n case 'deleted':\n return 'bulk_delete';\n default:\n return null;\n }\n}\n\n/** The trigger values the enqueuer can actually map from an emitted record event. */\nconst DISPATCHABLE_WEBHOOK_TRIGGERS: ReadonlySet<string> = new Set([\n 'create',\n 'update',\n 'delete',\n 'bulk_update',\n 'bulk_delete',\n]);\n","// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.\n\n/**\n * bootstrapDeclaredWebhooks — materialize stack/connector-declared `webhooks`\n * into `sys_webhook` rows so the dispatcher can actually see them (closes #3461).\n *\n * ## The disconnect this closes\n * The spec authoring surface (`WebhookSchema` — `defineStack({ webhooks })`,\n * `@objectstack/spec/automation/webhook`) declares `object` / `isActive`, and\n * is generically decomposed into the ObjectQL registry at boot as metadata\n * type `webhook`. But the runtime dispatcher ({@link AutoEnqueuer}) reads\n * `sys_webhook` DATA rows (`object_name` / `active`), which until now were only\n * ever written by hand through the object's CRUD UI. Nothing bridged the two —\n * so authoring `webhooks:` on a stack produced metadata artifacts that never\n * became dispatchable rows (a silent no-op; ADR-0078). This seeder is that\n * missing ingestion path.\n *\n * ## Shape translation (authoring → runtime row)\n * The spec shape diverges from the runtime column names; we map only at this\n * boundary and stash the full validated envelope in `definition_json` (whence\n * the enqueuer reads headers / secret / timeout):\n * - `object` → `object_name`\n * - `isActive` → `active`\n * - `triggers` / `url` / `method` / `label` / `description` → same-named columns\n * - the entire parsed {@link Webhook} → `definition_json` (JSON string)\n *\n * Each item is validated through `WebhookSchema.parse()` first — this gives the\n * spec schema a real consumer (defaults for `method`/`isActive`/`timeoutMs` get\n * applied) and rejects malformed authoring with a warning instead of crashing\n * boot.\n *\n * ## Seed-not-clobber (mirrors sys_sharing_rule, #2909)\n * `sys_webhook` is admin-editable (`managedBy: 'config'`). Declared webhooks\n * ship with the app/package, so they seed with `managed_by: 'package'`\n * provenance and re-seed on every boot — but a row an admin has created\n * (`managed_by: 'admin'`) or edited (`customized: true`, stamped by\n * {@link bindWebhookProvenanceStamp}) is never overwritten. Most importantly,\n * an admin's `active: false` on a noisy webhook survives redeploys.\n *\n * MUST run before {@link AutoEnqueuer.start} so the enqueuer's first cache\n * refresh already sees the declared rows.\n */\n\nimport type { IDataEngine } from '@objectstack/spec/contracts';\nimport { WebhookSchema, type Webhook } from '@objectstack/spec/automation';\n\n/** System write context — the boot seeder is not an admin authoring action. */\nconst SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const;\n\ninterface Logger {\n info?: (msg: string, meta?: unknown) => void;\n warn?: (msg: string, meta?: unknown) => void;\n}\n\n/** Random id with a stable prefix — mirrors the sharing-rule seeder. */\nfunction uid(prefix: string): string {\n const g: any = globalThis as any;\n if (g.crypto?.randomUUID) return `${prefix}_${g.crypto.randomUUID()}`;\n return `${prefix}_${Math.random().toString(36).slice(2, 10)}`;\n}\n\n/**\n * Read declared `webhook` items from the ObjectQL registry (where the manifest\n * decomposition parks `stack.webhooks`), falling back to the metadata service.\n * Items may be wrapped as `{ content }` — unwrap to the raw authoring object.\n */\nfunction readDeclared(engine: any, metadataService: any, type: string): any[] {\n try {\n const reg = engine?._registry;\n if (reg?.listItems) {\n const items = (reg.listItems(type) ?? []).map((i: any) => i?.content ?? i).filter(Boolean);\n if (items.length > 0) return items;\n }\n } catch {\n /* fall through to metadata service */\n }\n try {\n const listed = metadataService?.list?.(type);\n const arr = typeof (listed as any)?.then === 'function' ? [] : (listed ?? []);\n return Array.isArray(arr) ? arr.map((i: any) => i?.content ?? i).filter(Boolean) : [];\n } catch {\n return [];\n }\n}\n\nexport interface BootstrapDeclaredWebhooksResult {\n seeded: number;\n skipped: number;\n}\n\n/**\n * Materialize declared webhooks into `sys_webhook`. Idempotent and safe to run\n * on every boot.\n */\nexport async function bootstrapDeclaredWebhooks(\n engine: IDataEngine,\n metadataService: any,\n logger?: Logger,\n subscriptionsObject = 'sys_webhook',\n): Promise<BootstrapDeclaredWebhooksResult> {\n const declared = readDeclared(engine, metadataService, 'webhook');\n if (declared.length === 0) return { seeded: 0, skipped: 0 };\n\n const now = new Date().toISOString();\n let seeded = 0;\n let skipped = 0;\n\n for (const raw of declared) {\n // Validate + fill defaults through the canonical spec schema. A real\n // consumer at last — a malformed webhook warns and is skipped, never\n // crashing boot.\n let wh: Webhook;\n try {\n wh = WebhookSchema.parse(raw);\n } catch (err: any) {\n logger?.warn?.('[webhook] declared webhook failed validation — skipped', {\n name: (raw as any)?.name,\n error: err?.message ?? String(err),\n });\n skipped += 1;\n continue;\n }\n\n try {\n const existing = await engine.find(subscriptionsObject, {\n where: { name: wh.name },\n limit: 1,\n context: SYSTEM_CTX,\n } as any);\n const row: any = Array.isArray(existing) ? existing[0] : undefined;\n\n if (row) {\n // Admin owns a same-named row, or has edited this seeded one — never\n // clobber. `active: false` on a noisy webhook must survive redeploys.\n if (row.managed_by === 'admin') {\n logger?.warn?.('[webhook] declared name collides with an admin-authored row — seed skipped', {\n name: wh.name,\n });\n skipped += 1;\n continue;\n }\n if (row.customized === true) {\n skipped += 1;\n continue;\n }\n const patch = {\n id: row.id,\n ...mapWebhookToRow(wh),\n // Adopt pristine/legacy (pre-provenance) rows so future boots\n // recognize them as package-managed.\n managed_by: 'package',\n updated_at: now,\n };\n await engine.update(subscriptionsObject, patch, { context: SYSTEM_CTX } as any);\n seeded += 1;\n continue;\n }\n\n const newRow = {\n id: uid('whk'),\n ...mapWebhookToRow(wh),\n managed_by: 'package',\n customized: false,\n created_at: now,\n updated_at: now,\n };\n await engine.insert(subscriptionsObject, newRow, { context: SYSTEM_CTX } as any);\n seeded += 1;\n } catch (err: any) {\n logger?.warn?.('[webhook] declared webhook seed failed', {\n name: wh.name,\n error: err?.message ?? String(err),\n });\n skipped += 1;\n }\n }\n\n logger?.info?.('[webhook] declared webhooks materialized into sys_webhook', {\n seeded,\n skipped,\n total: declared.length,\n });\n return { seeded, skipped };\n}\n\n/**\n * Translate a validated {@link Webhook} into `sys_webhook` column values.\n * `object → object_name`, `isActive → active`; the full envelope is stashed in\n * `definition_json` for the enqueuer's advanced-config read (headers/secret/…).\n */\nfunction mapWebhookToRow(wh: Webhook): Record<string, unknown> {\n return {\n name: wh.name,\n label: wh.label ?? wh.name,\n object_name: wh.object ?? null,\n triggers: wh.triggers ?? [],\n url: wh.url,\n // Store lowercase to match the object's Field.select option values\n // (get/post/…); the enqueuer upper-cases before delivery either way.\n method: String(wh.method ?? 'POST').toLowerCase(),\n description: wh.description ?? null,\n active: wh.isActive !== false,\n definition_json: JSON.stringify(wh),\n };\n}\n","// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.\n\n/**\n * [#3461] Provenance stamp for `sys_webhook`.\n *\n * `sys_webhook` is RECORD-AUTHORITATIVE: a code-declared webhook is a boot seed\n * (`bootstrapDeclaredWebhooks`), and the row — including any admin tuning such\n * as flipping a noisy webhook to `active: false` — is the authority. The seeder\n * skips rows marked `customized`, so this hook is the half that DETECTS the\n * admin edit: any non-system update touching a `package`/`platform`-seeded row\n * stamps `customized: true` onto the payload.\n *\n * Why a data hook (and not a write gate or the REST layer), verbatim to the\n * sys_sharing_rule rationale (#2909 T1):\n * - admins edit webhooks through several doors (Setup UI generic data door,\n * scripts, console) — an engine hook covers them all;\n * - there is deliberately NO write gate here: webhooks are a first-class admin\n * authoring surface, so edits are allowed — they just have to be remembered;\n * - both provenance columns are `readonly`, and the engine's readonly strip\n * exempts isSystem callers while snapshotting supplied keys BEFORE hooks run\n * — so a caller can never forge/clear `customized`, while this hook's stamp\n * survives.\n *\n * Known boundary: multi-row updates (no single `input.id`) are not stamped —\n * every webhook-editing UI path updates by id.\n */\n\ninterface MinimalEngine {\n find(object: string, opts?: any): Promise<any[]>;\n registerHook(event: string, handler: (ctx: any) => any, options?: Record<string, any>): void;\n unregisterHooksByPackage(packageId: string): number;\n}\n\ninterface MinimalLogger {\n info?: (msg: string, meta?: Record<string, any>) => void;\n warn?: (msg: string, meta?: Record<string, any>) => void;\n}\n\nexport const WEBHOOK_PROVENANCE_PACKAGE = 'plugin-webhooks:provenance';\n\nconst SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const;\n\nexport function bindWebhookProvenanceStamp(engine: MinimalEngine, logger?: MinimalLogger): void {\n if (typeof engine?.registerHook !== 'function') return;\n engine.registerHook(\n 'beforeUpdate',\n async (ctx: any) => {\n // Seeder / boot reconcilers write with isSystem — the package door, not\n // an admin customization.\n if ((ctx?.session as any)?.isSystem) return;\n const id = ctx?.input?.id ?? (ctx?.input?.data as any)?.id;\n if (!id) return; // multi-row update — see boundary note above\n const data = ctx?.input?.data;\n if (!data || typeof data !== 'object') return;\n try {\n // `previous` is not resolved before beforeUpdate hooks run — read the\n // current row ourselves (system ctx: this is a provenance check, not\n // an authorization decision).\n const rows = await engine.find('sys_webhook', {\n where: { id },\n fields: ['id', 'managed_by', 'customized'],\n limit: 1,\n context: SYSTEM_CTX,\n });\n const row = Array.isArray(rows) ? rows[0] : undefined;\n if (!row) return;\n if ((row.managed_by === 'package' || row.managed_by === 'platform') && row.customized !== true) {\n (data as any).customized = true;\n }\n } catch (err: any) {\n logger?.warn?.('[webhook] provenance stamp failed (edit proceeds unstamped)', {\n id,\n error: err?.message,\n });\n }\n },\n { object: 'sys_webhook', packageId: WEBHOOK_PROVENANCE_PACKAGE, priority: 150 },\n );\n logger?.info?.('[webhook] provenance stamp hook bound');\n}\n\nexport function unbindWebhookProvenanceStamp(engine: MinimalEngine): void {\n if (typeof engine?.unregisterHooksByPackage === 'function') {\n engine.unregisterHooksByPackage(WEBHOOK_PROVENANCE_PACKAGE);\n }\n}\n","// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.\n\nimport type { Plugin, PluginContext } from '@objectstack/core';\nimport type { IDataEngine, IRealtimeService } from '@objectstack/spec/contracts';\nimport type { EnqueueHttpInput } from '@objectstack/service-messaging';\nimport { AutoEnqueuer, type AutoEnqueuerOptions } from './auto-enqueuer.js';\nimport { SysWebhook } from './sys-webhook.object.js';\nimport { bootstrapDeclaredWebhooks } from './bootstrap-declared-webhooks.js';\nimport { bindWebhookProvenanceStamp, unbindWebhookProvenanceStamp } from './webhook-provenance.js';\n\n/**\n * Structural view of `@objectstack/service-messaging`'s HTTP-outbox surface\n * (ADR-0018 M3) — declared locally so this plugin doesn't take a hard runtime\n * import on the service. Webhook deliveries are enqueued onto the shared\n * `sys_http_delivery` outbox and drained by the messaging `HttpDispatcher`.\n */\ninterface MessagingHttpSurface {\n isHttpDeliveryReady(): boolean;\n enqueueHttp(input: EnqueueHttpInput): Promise<string>;\n redeliverHttp(id: string): Promise<{ id: string; status: string }>;\n}\n\nexport interface WebhookOutboxPluginOptions {\n /**\n * Auto-enqueue config. When enabled (default `true` if the realtime + data\n * engine services are available), the plugin subscribes to `data.record.*`\n * events and enqueues a delivery onto the shared messaging HTTP outbox for\n * every matching `sys_webhook` row.\n *\n * Set `false` to disable and enqueue webhooks imperatively elsewhere.\n */\n autoEnqueue?: boolean | AutoEnqueuerOptions;\n}\n\n/**\n * Wires webhook fan-out on top of the shared outbound-HTTP delivery substrate\n * (ADR-0018 M3).\n *\n * Webhooks are no longer their own delivery engine: the durable outbox, the\n * cluster-coordinated dispatcher, the retry/backoff/dead-letter schedule, and\n * the retention sweep all live in `@objectstack/service-messaging`\n * (`sys_http_delivery` + `HttpDispatcher`). This plugin owns only the\n * webhook-specific concerns:\n * - the `sys_webhook` configuration object,\n * - the {@link AutoEnqueuer} that turns `data.record.*` events into outbox\n * rows (`source: 'webhook'`), and\n * - the redeliver admin endpoint.\n *\n * End-to-end flow:\n *\n * engine.insert('contact', {...})\n * → engine publishes data.record.created via IRealtimeService\n * → AutoEnqueuer matches active sys_webhook rows in O(1)\n * → messaging.enqueueHttp() runs fire-and-forget (off the write path)\n * → messaging HttpDispatcher claims and POSTs (cluster-coordinated, retried)\n *\n * **Requires** `MessagingServicePlugin` (`@objectstack/service-messaging`),\n * which is a foundational, always-on capability.\n */\nexport class WebhookOutboxPlugin implements Plugin {\n name = 'com.objectstack.plugin-webhook-outbox';\n version = '2.0.0';\n type = 'standard' as const;\n dependencies = ['com.objectstack.service.messaging'];\n /**\n * `init()` registers this plugin's schema through `manifest` with no\n * fallback. Until #4187 that was safe only TRANSITIVELY — messaging happens\n * to depend on ObjectQL, which provides `manifest` — so the guarantee would\n * have evaporated silently the day messaging stopped depending on the\n * engine, and the failure would have surfaced as an unrelated plugin's\n * init crash. Declaring the requirement directly makes the kernel check it\n * regardless of what messaging depends on.\n */\n requiresServices = ['manifest'];\n\n private autoEnqueuer: AutoEnqueuer | undefined;\n /** Engine the provenance hook was bound to, so `dispose()` can unbind it. */\n private boundEngine: any;\n\n constructor(private readonly options: WebhookOutboxPluginOptions = {}) {}\n\n async init(ctx: PluginContext): Promise<void> {\n // Register the webhook config object (ADR-0029 K2.a). The delivery\n // telemetry now lives in messaging's `sys_http_delivery`, so the nav's\n // \"Deliveries\" entry points there (filtered to source=webhook in views).\n const manifest = ctx.getService<{ register(m: any): void }>('manifest');\n if (manifest && typeof manifest.register === 'function') {\n manifest.register({\n id: 'com.objectstack.plugin-webhook-outbox.schema',\n namespace: 'sys',\n version: this.version,\n type: 'plugin',\n scope: 'system',\n name: 'Webhook Schemas',\n description: 'Registers sys_webhook (configuration). Deliveries use messaging\\'s sys_http_delivery outbox.',\n objects: [SysWebhook],\n navigationContributions: [\n {\n app: 'setup',\n group: 'group_integrations',\n priority: 100,\n items: [\n { id: 'nav_webhooks', type: 'object', label: 'Webhooks', objectName: 'sys_webhook', icon: 'webhook', requiresObject: 'sys_webhook' },\n { id: 'nav_http_deliveries', type: 'object', label: 'HTTP Deliveries', objectName: 'sys_http_delivery', icon: 'send', requiresObject: 'sys_http_delivery' },\n ],\n },\n ],\n });\n } else {\n ctx.logger.warn?.(\n '[webhook-outbox] manifest service unavailable — sys_webhook will NOT appear in REST or Studio nav. Register MetadataService before WebhookOutboxPlugin.',\n );\n }\n\n // ADR-0029 D8 — contribute object translations once i18n is up.\n if (typeof (ctx as any).hook === 'function') {\n (ctx as any).hook('kernel:ready', async () => {\n try {\n const i18n = ctx.getService<any>('i18n');\n if (i18n && typeof i18n.loadTranslations === 'function') {\n const { WebhooksTranslations } = await import('./translations/index.js');\n for (const [locale, data] of Object.entries(WebhooksTranslations)) {\n i18n.loadTranslations(locale, data as Record<string, unknown>);\n }\n }\n } catch { /* i18n optional */ }\n });\n }\n\n const autoEnqueueOpt = this.options.autoEnqueue ?? true;\n\n if (typeof (ctx as any).hook === 'function') {\n (ctx as any).hook('kernel:ready', async () => {\n // Materialize declared webhooks FIRST — this only needs the data\n // engine, so it must not be gated behind the auto-enqueue\n // dispatch prerequisites (realtime + messaging). Otherwise a\n // deployment without realtime would silently fail to materialize\n // declared webhooks — the very no-op this bridge closes (#3461).\n await this.bootDeclaredWebhooks(ctx);\n await this.bootAutoEnqueue(ctx, autoEnqueueOpt);\n this.registerAdminRoutes(ctx);\n });\n }\n\n ctx.logger.info?.('[webhook-outbox] initialised (delivery via shared messaging HTTP outbox)', {\n autoEnqueue: autoEnqueueOpt !== false,\n });\n }\n\n async dispose(): Promise<void> {\n await this.autoEnqueuer?.stop();\n if (this.boundEngine) {\n try { unbindWebhookProvenanceStamp(this.boundEngine); } catch { /* best effort */ }\n this.boundEngine = undefined;\n }\n }\n\n private getMessaging(ctx: PluginContext): MessagingHttpSurface | undefined {\n const svc = this.tryGetService<MessagingHttpSurface>(ctx, ['messaging']);\n return svc && typeof svc.enqueueHttp === 'function' ? svc : undefined;\n }\n\n /**\n * [#3461] Bridge the declarative authoring surface to the dispatcher:\n * materialize stack/connector-declared `webhook` metadata into `sys_webhook`\n * rows so the auto-enqueuer (and the Studio UI) can see them.\n *\n * Gated on the DATA ENGINE alone — deliberately independent of the\n * auto-enqueue dispatch prerequisites (realtime + messaging). Materializing\n * rows is a pure write; a deployment that mounts the webhook plugin without\n * realtime must still get its declared webhooks into the table (and the\n * Setup UI), even if nothing dispatches them yet. Runs before\n * {@link bootAutoEnqueue} so the enqueuer's first cache refresh sees the rows.\n */\n private async bootDeclaredWebhooks(ctx: PluginContext): Promise<void> {\n const engine = this.tryGetService<IDataEngine>(ctx, ['objectql', 'data']);\n if (!engine) {\n ctx.logger.warn?.('[webhook] declared-webhook bootstrap skipped — no data engine available');\n return;\n }\n // Bind the provenance stamp so an admin edit freezes a seeded row.\n this.boundEngine = engine;\n bindWebhookProvenanceStamp(engine as any, ctx.logger as any);\n let metadataService: any;\n try { metadataService = ctx.getService('metadata'); } catch { /* optional */ }\n try {\n await bootstrapDeclaredWebhooks(engine, metadataService, ctx.logger as any);\n } catch (err: any) {\n ctx.logger.warn?.('[webhook] declared-webhook bootstrap failed (dispatcher still serves admin rows)', {\n error: err?.message ?? String(err),\n });\n }\n }\n\n private async bootAutoEnqueue(\n ctx: PluginContext,\n opt: boolean | AutoEnqueuerOptions,\n ): Promise<void> {\n if (opt === false) return;\n const engine = this.tryGetService<IDataEngine>(ctx, ['objectql', 'data']);\n const realtime = this.tryGetService<IRealtimeService>(ctx, ['realtime']);\n const messaging = this.getMessaging(ctx);\n if (!engine || !realtime || !messaging) {\n ctx.logger.warn?.(\n '[webhook-auto-enqueuer] disabled — ObjectQL, Realtime, or Messaging service not available',\n { hasEngine: !!engine, hasRealtime: !!realtime, hasMessaging: !!messaging },\n );\n return;\n }\n if (!messaging.isHttpDeliveryReady()) {\n ctx.logger.warn?.(\n '[webhook-auto-enqueuer] messaging HTTP outbox not ready (no data engine / reliableDelivery off) — webhook deliveries will not be durable',\n );\n }\n\n const enqOpts = (typeof opt === 'object' ? opt : {}) as AutoEnqueuerOptions;\n this.autoEnqueuer = new AutoEnqueuer(\n engine,\n realtime,\n (input) => messaging.enqueueHttp(input),\n { ...enqOpts, logger: ctx.logger },\n );\n await this.autoEnqueuer.start();\n ctx.registerService('webhook.autoEnqueuer', this.autoEnqueuer);\n ctx.logger.info?.('[webhook-auto-enqueuer] started (enqueues source=webhook onto sys_http_delivery)');\n }\n\n private tryGetService<T>(ctx: PluginContext, names: string[]): T | undefined {\n for (const n of names) {\n try {\n const svc = ctx.getService<T>(n);\n if (svc) return svc;\n } catch {\n // fall through\n }\n }\n return undefined;\n }\n\n /**\n * Mount POST /api/v1/webhooks/redeliver on the host Hono app, if one is\n * available. Delegates to `messaging.redeliverHttp(deliveryId)`. Auth is the\n * better-auth session cookie — every authenticated user counts.\n */\n private registerAdminRoutes(ctx: PluginContext): void {\n const http = this.tryGetService<any>(ctx, ['http-server']);\n if (!http || typeof http.getRawApp !== 'function') {\n ctx.logger.debug?.('[webhook-outbox] HTTP server not available; redeliver endpoint not mounted');\n return;\n }\n const rawApp = http.getRawApp();\n const messaging = this.getMessaging(ctx);\n if (!rawApp || !messaging) return;\n\n rawApp.post('/api/v1/webhooks/redeliver', async (c: any) => {\n const userId = await this.resolveSessionUserId(ctx, c);\n if (!userId) {\n return c.json(\n { success: false, error: { code: 'UNAUTHENTICATED', message: 'Sign in to redeliver webhook deliveries.' } },\n 401,\n );\n }\n let body: any;\n try {\n body = await c.req.json();\n } catch {\n return c.json({ success: false, error: { code: 'INVALID_REQUEST', message: 'Request body must be JSON.' } }, 400);\n }\n const deliveryId = typeof body?.deliveryId === 'string' ? body.deliveryId.trim() : '';\n if (!deliveryId) {\n return c.json(\n { success: false, error: { code: 'MISSING_REQUIRED_FIELD', message: 'Body must include `deliveryId: string`.' } },\n 400,\n );\n }\n try {\n const row = await messaging.redeliverHttp(deliveryId);\n ctx.logger.info?.('[webhook-outbox] redelivered', { deliveryId, requestedBy: userId });\n return c.json({ success: true, data: { id: row.id, status: row.status } });\n } catch (err: any) {\n const code = err?.code;\n if (code === 'RESOURCE_NOT_FOUND') {\n return c.json({ success: false, error: { code, message: err.message } }, 404);\n }\n if (code === 'DELIVERY_NOT_ELIGIBLE') {\n return c.json({ success: false, error: { code, message: err.message } }, 409);\n }\n ctx.logger.error?.('[webhook-outbox] redeliver failed', err as Error);\n return c.json(\n { success: false, error: { code: 'INTERNAL_ERROR', message: err?.message ?? String(err) } },\n 500,\n );\n }\n });\n\n ctx.logger.info?.('[webhook-outbox] redeliver endpoint mounted at POST /api/v1/webhooks/redeliver');\n }\n\n private async resolveSessionUserId(ctx: PluginContext, c: any): Promise<string | undefined> {\n try {\n const authService: any = this.tryGetService<any>(ctx, ['auth']);\n if (!authService) return undefined;\n let api: any = authService.api;\n if (!api && typeof authService.getApi === 'function') {\n api = await authService.getApi();\n }\n if (!api?.getSession) return undefined;\n const session = await api.getSession({ headers: c.req.raw.headers });\n const uid = session?.user?.id;\n return typeof uid === 'string' && uid.length > 0 ? uid : undefined;\n } catch {\n return undefined;\n }\n }\n}\n"]}
|
package/dist/index.d.cts
CHANGED
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
import { Plugin, PluginContext } from '@objectstack/core';
|
|
2
2
|
import { IDataEngine, IRealtimeService } from '@objectstack/spec/contracts';
|
|
3
|
+
import { WebhookTriggerType } from '@objectstack/spec/automation';
|
|
3
4
|
import { EnqueueHttpInput } from '@objectstack/service-messaging';
|
|
4
5
|
export { SysWebhook } from './schema.cjs';
|
|
5
6
|
import '@objectstack/spec/data';
|
|
6
7
|
|
|
8
|
+
/**
|
|
9
|
+
* The authored trigger vocabulary, taken from the spec rather than restated
|
|
10
|
+
* here — this file both validates authored triggers and maps events onto them,
|
|
11
|
+
* so a locally-spelled union would be a second contract free to drift from the
|
|
12
|
+
* one authors are validated against.
|
|
13
|
+
*/
|
|
14
|
+
type WebhookTrigger = WebhookTriggerType;
|
|
7
15
|
/**
|
|
8
16
|
* Enqueue callback into the shared `service-messaging` HTTP outbox (ADR-0018 M3).
|
|
9
17
|
* The plugin supplies one bound to `messaging.enqueueHttp(...)`; webhooks no
|
|
@@ -28,7 +36,7 @@ interface CachedSubscription {
|
|
|
28
36
|
id: string;
|
|
29
37
|
name: string;
|
|
30
38
|
objectName: string | undefined;
|
|
31
|
-
triggers: Set<
|
|
39
|
+
triggers: Set<WebhookTrigger>;
|
|
32
40
|
url: string;
|
|
33
41
|
method?: string;
|
|
34
42
|
headers?: Record<string, string>;
|
|
@@ -85,6 +93,11 @@ interface AutoEnqueuerOptions {
|
|
|
85
93
|
* `eventId` is computed from `${object}:${recordId}:${type}:${timestamp}`
|
|
86
94
|
* so the outbox dedup index catches duplicates that could arise from
|
|
87
95
|
* upstream replay or buggy producers — and is stable across nodes.
|
|
96
|
+
*
|
|
97
|
+
* An aggregate `data.records.*` event (#4639) has no record to key on, so it
|
|
98
|
+
* dedups on the producer's event uuid instead: two predicate sweeps in the
|
|
99
|
+
* same millisecond are genuinely different events and must not collapse into
|
|
100
|
+
* one delivery, which a timestamp-based key would do.
|
|
88
101
|
*/
|
|
89
102
|
declare class AutoEnqueuer {
|
|
90
103
|
private readonly engine;
|
|
@@ -120,6 +133,19 @@ declare class AutoEnqueuer {
|
|
|
120
133
|
* webhook persistence.
|
|
121
134
|
*/
|
|
122
135
|
private handleEvent;
|
|
136
|
+
/**
|
|
137
|
+
* Handler for aggregate `data.records.*` events — a predicate write
|
|
138
|
+
* (`multi: true`) that the driver reports only as an affected-row count
|
|
139
|
+
* (#4639).
|
|
140
|
+
*
|
|
141
|
+
* Deliberately NOT folded into {@link handleEvent}'s per-record path. The
|
|
142
|
+
* delivered body has no `recordId` and no record fields, so a subscriber
|
|
143
|
+
* to `update` that started receiving these would get a payload missing
|
|
144
|
+
* everything it reads — which is how the pre-#4626 `recordId: ''`
|
|
145
|
+
* fabrication broke consumers, just arriving from the other side. A
|
|
146
|
+
* webhook opts in with `bulk_update` / `bulk_delete`.
|
|
147
|
+
*/
|
|
148
|
+
private handleBulkEvent;
|
|
123
149
|
private handleSelfHealEvent;
|
|
124
150
|
/** Test / admin accessor. */
|
|
125
151
|
snapshot(): ReadonlyMap<string, ReadonlyArray<CachedSubscription>>;
|
package/dist/index.d.ts
CHANGED
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
import { Plugin, PluginContext } from '@objectstack/core';
|
|
2
2
|
import { IDataEngine, IRealtimeService } from '@objectstack/spec/contracts';
|
|
3
|
+
import { WebhookTriggerType } from '@objectstack/spec/automation';
|
|
3
4
|
import { EnqueueHttpInput } from '@objectstack/service-messaging';
|
|
4
5
|
export { SysWebhook } from './schema.js';
|
|
5
6
|
import '@objectstack/spec/data';
|
|
6
7
|
|
|
8
|
+
/**
|
|
9
|
+
* The authored trigger vocabulary, taken from the spec rather than restated
|
|
10
|
+
* here — this file both validates authored triggers and maps events onto them,
|
|
11
|
+
* so a locally-spelled union would be a second contract free to drift from the
|
|
12
|
+
* one authors are validated against.
|
|
13
|
+
*/
|
|
14
|
+
type WebhookTrigger = WebhookTriggerType;
|
|
7
15
|
/**
|
|
8
16
|
* Enqueue callback into the shared `service-messaging` HTTP outbox (ADR-0018 M3).
|
|
9
17
|
* The plugin supplies one bound to `messaging.enqueueHttp(...)`; webhooks no
|
|
@@ -28,7 +36,7 @@ interface CachedSubscription {
|
|
|
28
36
|
id: string;
|
|
29
37
|
name: string;
|
|
30
38
|
objectName: string | undefined;
|
|
31
|
-
triggers: Set<
|
|
39
|
+
triggers: Set<WebhookTrigger>;
|
|
32
40
|
url: string;
|
|
33
41
|
method?: string;
|
|
34
42
|
headers?: Record<string, string>;
|
|
@@ -85,6 +93,11 @@ interface AutoEnqueuerOptions {
|
|
|
85
93
|
* `eventId` is computed from `${object}:${recordId}:${type}:${timestamp}`
|
|
86
94
|
* so the outbox dedup index catches duplicates that could arise from
|
|
87
95
|
* upstream replay or buggy producers — and is stable across nodes.
|
|
96
|
+
*
|
|
97
|
+
* An aggregate `data.records.*` event (#4639) has no record to key on, so it
|
|
98
|
+
* dedups on the producer's event uuid instead: two predicate sweeps in the
|
|
99
|
+
* same millisecond are genuinely different events and must not collapse into
|
|
100
|
+
* one delivery, which a timestamp-based key would do.
|
|
88
101
|
*/
|
|
89
102
|
declare class AutoEnqueuer {
|
|
90
103
|
private readonly engine;
|
|
@@ -120,6 +133,19 @@ declare class AutoEnqueuer {
|
|
|
120
133
|
* webhook persistence.
|
|
121
134
|
*/
|
|
122
135
|
private handleEvent;
|
|
136
|
+
/**
|
|
137
|
+
* Handler for aggregate `data.records.*` events — a predicate write
|
|
138
|
+
* (`multi: true`) that the driver reports only as an affected-row count
|
|
139
|
+
* (#4639).
|
|
140
|
+
*
|
|
141
|
+
* Deliberately NOT folded into {@link handleEvent}'s per-record path. The
|
|
142
|
+
* delivered body has no `recordId` and no record fields, so a subscriber
|
|
143
|
+
* to `update` that started receiving these would get a payload missing
|
|
144
|
+
* everything it reads — which is how the pre-#4626 `recordId: ''`
|
|
145
|
+
* fabrication broke consumers, just arriving from the other side. A
|
|
146
|
+
* webhook opts in with `bulk_update` / `bulk_delete`.
|
|
147
|
+
*/
|
|
148
|
+
private handleBulkEvent;
|
|
123
149
|
private handleSelfHealEvent;
|
|
124
150
|
/** Test / admin accessor. */
|
|
125
151
|
snapshot(): ReadonlyMap<string, ReadonlyArray<CachedSubscription>>;
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
SysWebhook
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-3QGZLM3T.js";
|
|
4
4
|
|
|
5
5
|
// src/auto-enqueuer.ts
|
|
6
6
|
var AutoEnqueuer = class {
|
|
@@ -112,7 +112,7 @@ var AutoEnqueuer = class {
|
|
|
112
112
|
const unknown = normalized.filter((t) => !DISPATCHABLE_WEBHOOK_TRIGGERS.has(t));
|
|
113
113
|
if (unknown.length > 0) {
|
|
114
114
|
this.logger.warn?.(
|
|
115
|
-
`[webhook-auto-enqueuer] webhook '${row.name ?? row.id}' declares trigger(s) the engine never emits: ${unknown.join(", ")} \u2014 ignored. Dispatchable triggers:
|
|
115
|
+
`[webhook-auto-enqueuer] webhook '${row.name ?? row.id}' declares trigger(s) the engine never emits: ${unknown.join(", ")} \u2014 ignored. Dispatchable triggers: ${[...DISPATCHABLE_WEBHOOK_TRIGGERS].join(", ")}.`,
|
|
116
116
|
{ id: row.id, unknown }
|
|
117
117
|
);
|
|
118
118
|
}
|
|
@@ -120,6 +120,10 @@ var AutoEnqueuer = class {
|
|
|
120
120
|
normalized.filter((t) => DISPATCHABLE_WEBHOOK_TRIGGERS.has(t))
|
|
121
121
|
);
|
|
122
122
|
if (triggers.size === 0) {
|
|
123
|
+
this.logger.warn?.(
|
|
124
|
+
`[webhook-auto-enqueuer] webhook '${row.name ?? row.id}' has no dispatchable triggers \u2014 it will NEVER fire (rule webhook/without-triggers): there is no manual fire path (#3196), so this row is dead while looking armed in Setup. Declare one of: ${[...DISPATCHABLE_WEBHOOK_TRIGGERS].join(", ")}, or set it inactive if it should be off.`,
|
|
125
|
+
{ id: row.id }
|
|
126
|
+
);
|
|
123
127
|
return null;
|
|
124
128
|
}
|
|
125
129
|
let defn = {};
|
|
@@ -154,9 +158,13 @@ var AutoEnqueuer = class {
|
|
|
154
158
|
* webhook persistence.
|
|
155
159
|
*/
|
|
156
160
|
handleEvent(event) {
|
|
157
|
-
if (!event.type?.startsWith("data.record.")) return;
|
|
158
161
|
if (!event.object) return;
|
|
159
162
|
if (event.object === this.subscriptionsObject) return;
|
|
163
|
+
if (event.type?.startsWith("data.records.")) {
|
|
164
|
+
this.handleBulkEvent(event);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
if (!event.type?.startsWith("data.record.")) return;
|
|
160
168
|
const action = event.type.slice("data.record.".length);
|
|
161
169
|
const trigger = mapActionToTrigger(action);
|
|
162
170
|
if (!trigger) return;
|
|
@@ -166,7 +174,14 @@ var AutoEnqueuer = class {
|
|
|
166
174
|
];
|
|
167
175
|
if (subs.length === 0) return;
|
|
168
176
|
const payload = event.payload ?? {};
|
|
169
|
-
const recordId = payload.recordId
|
|
177
|
+
const recordId = payload.recordId;
|
|
178
|
+
if (typeof recordId !== "string" || recordId === "") {
|
|
179
|
+
this.logger.warn?.(
|
|
180
|
+
"[webhook-auto-enqueuer] dropping off-contract data event: payload is not a DataEvent (no top-level string `recordId`) \u2014 fix the producer",
|
|
181
|
+
{ type: event.type, object: event.object }
|
|
182
|
+
);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
170
185
|
const eventId = `${event.object}:${recordId}:${action}:${event.timestamp}`;
|
|
171
186
|
for (const sub of subs) {
|
|
172
187
|
if (!sub.triggers.has(trigger)) continue;
|
|
@@ -182,12 +197,13 @@ var AutoEnqueuer = class {
|
|
|
182
197
|
timeoutMs: sub.timeoutMs,
|
|
183
198
|
// [#3946] Envelope keys are written LAST so the event payload
|
|
184
199
|
// cannot rewrite them. Behaviour-neutral for the engine's own
|
|
185
|
-
// publishers — `data.record.*`
|
|
186
|
-
// `
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
// record fields
|
|
190
|
-
//
|
|
200
|
+
// publishers — since #4626 a `data.record.*` payload is a
|
|
201
|
+
// `DataEvent` (`id`, `type`, `object`, `recordId`, `changes?`,
|
|
202
|
+
// `after?`, `userId?`, `timestamp`), whose `object` /
|
|
203
|
+
// `recordId` / `timestamp` carry the SAME values written here
|
|
204
|
+
// and whose record fields stay nested under `after`. It is the
|
|
205
|
+
// shape that was wrong: a publisher that flattened record
|
|
206
|
+
// fields into the payload would have silently rewritten the
|
|
191
207
|
// `object` / `action` / `timestamp` a subscriber receives.
|
|
192
208
|
payload: {
|
|
193
209
|
...payload,
|
|
@@ -205,9 +221,77 @@ var AutoEnqueuer = class {
|
|
|
205
221
|
);
|
|
206
222
|
}
|
|
207
223
|
}
|
|
224
|
+
/**
|
|
225
|
+
* Handler for aggregate `data.records.*` events — a predicate write
|
|
226
|
+
* (`multi: true`) that the driver reports only as an affected-row count
|
|
227
|
+
* (#4639).
|
|
228
|
+
*
|
|
229
|
+
* Deliberately NOT folded into {@link handleEvent}'s per-record path. The
|
|
230
|
+
* delivered body has no `recordId` and no record fields, so a subscriber
|
|
231
|
+
* to `update` that started receiving these would get a payload missing
|
|
232
|
+
* everything it reads — which is how the pre-#4626 `recordId: ''`
|
|
233
|
+
* fabrication broke consumers, just arriving from the other side. A
|
|
234
|
+
* webhook opts in with `bulk_update` / `bulk_delete`.
|
|
235
|
+
*/
|
|
236
|
+
handleBulkEvent(event) {
|
|
237
|
+
const action = event.type.slice("data.records.".length);
|
|
238
|
+
const trigger = mapBulkActionToTrigger(action);
|
|
239
|
+
if (!trigger) return;
|
|
240
|
+
const subs = [
|
|
241
|
+
...this.subscriptions.get(event.object) ?? [],
|
|
242
|
+
...this.subscriptions.get("*") ?? []
|
|
243
|
+
];
|
|
244
|
+
if (subs.length === 0) return;
|
|
245
|
+
const payload = event.payload ?? {};
|
|
246
|
+
const matched = payload.matched;
|
|
247
|
+
if (typeof matched !== "number" || !Number.isInteger(matched) || matched < 0) {
|
|
248
|
+
this.logger.warn?.(
|
|
249
|
+
"[webhook-auto-enqueuer] dropping off-contract bulk data event: payload is not a BulkDataEvent (no top-level non-negative integer `matched`) \u2014 fix the producer",
|
|
250
|
+
{ type: event.type, object: event.object }
|
|
251
|
+
);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
const eventUuid = payload.id;
|
|
255
|
+
if (typeof eventUuid !== "string" || eventUuid === "") {
|
|
256
|
+
this.logger.warn?.(
|
|
257
|
+
"[webhook-auto-enqueuer] dropping off-contract bulk data event: payload has no top-level string `id` to dedup on \u2014 fix the producer",
|
|
258
|
+
{ type: event.type, object: event.object }
|
|
259
|
+
);
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
const eventId = `${event.object}:${event.type}:${eventUuid}`;
|
|
263
|
+
for (const sub of subs) {
|
|
264
|
+
if (!sub.triggers.has(trigger)) continue;
|
|
265
|
+
void this.enqueue({
|
|
266
|
+
source: "webhook",
|
|
267
|
+
refId: sub.id,
|
|
268
|
+
dedupKey: `${sub.id}:${eventId}`,
|
|
269
|
+
label: event.type,
|
|
270
|
+
url: sub.url,
|
|
271
|
+
method: sub.method,
|
|
272
|
+
headers: sub.headers,
|
|
273
|
+
signingSecret: sub.secret,
|
|
274
|
+
timeoutMs: sub.timeoutMs,
|
|
275
|
+
// [#3946] Envelope keys last so the payload cannot rewrite them.
|
|
276
|
+
payload: {
|
|
277
|
+
...payload,
|
|
278
|
+
object: event.object,
|
|
279
|
+
matched,
|
|
280
|
+
action,
|
|
281
|
+
timestamp: event.timestamp
|
|
282
|
+
}
|
|
283
|
+
}).catch(
|
|
284
|
+
(err) => this.logger.warn?.("[webhook-auto-enqueuer] bulk enqueue failed", {
|
|
285
|
+
webhook: sub.name,
|
|
286
|
+
eventId,
|
|
287
|
+
err: err?.message ?? err
|
|
288
|
+
})
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
208
292
|
handleSelfHealEvent(event) {
|
|
209
293
|
if (event.object !== this.subscriptionsObject) return;
|
|
210
|
-
if (!event.type?.startsWith("data.record.")) return;
|
|
294
|
+
if (!event.type?.startsWith("data.record.") && !event.type?.startsWith("data.records.")) return;
|
|
211
295
|
this.refresh().catch(
|
|
212
296
|
(err) => this.logger.warn?.("[webhook-auto-enqueuer] self-heal refresh failed", err)
|
|
213
297
|
);
|
|
@@ -229,7 +313,23 @@ function mapActionToTrigger(action) {
|
|
|
229
313
|
return null;
|
|
230
314
|
}
|
|
231
315
|
}
|
|
232
|
-
|
|
316
|
+
function mapBulkActionToTrigger(action) {
|
|
317
|
+
switch (action) {
|
|
318
|
+
case "updated":
|
|
319
|
+
return "bulk_update";
|
|
320
|
+
case "deleted":
|
|
321
|
+
return "bulk_delete";
|
|
322
|
+
default:
|
|
323
|
+
return null;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
var DISPATCHABLE_WEBHOOK_TRIGGERS = /* @__PURE__ */ new Set([
|
|
327
|
+
"create",
|
|
328
|
+
"update",
|
|
329
|
+
"delete",
|
|
330
|
+
"bulk_update",
|
|
331
|
+
"bulk_delete"
|
|
332
|
+
]);
|
|
233
333
|
|
|
234
334
|
// src/bootstrap-declared-webhooks.ts
|
|
235
335
|
import { WebhookSchema } from "@objectstack/spec/automation";
|
|
@@ -441,7 +541,7 @@ var WebhookOutboxPlugin = class {
|
|
|
441
541
|
try {
|
|
442
542
|
const i18n = ctx.getService("i18n");
|
|
443
543
|
if (i18n && typeof i18n.loadTranslations === "function") {
|
|
444
|
-
const { WebhooksTranslations } = await import("./translations-
|
|
544
|
+
const { WebhooksTranslations } = await import("./translations-2VGD4XRF.js");
|
|
445
545
|
for (const [locale, data] of Object.entries(WebhooksTranslations)) {
|
|
446
546
|
i18n.loadTranslations(locale, data);
|
|
447
547
|
}
|