From 488774222eb3a2c7ea0218540080ef2286976112 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 12:27:00 +0000 Subject: [PATCH] fix(spec): trim dead webhook triggers undelete + api (#3196) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WebhookTriggerType declared 5 triggers but only create/update/delete ever fired. The other two had no event source: - undelete: the engine has no soft-delete/restore capability (delete is a hard delete; no deleted_at convention, no restore operation, no data.record.undeleted emit). The undeleted case in the auto-enqueuer's action mapper was dead code awaiting a producer that doesn't exist. - api ("manually triggered"): no manual/programmatic fire path exists — the only webhook HTTP surface re-queues already-failed deliveries. Same declared≠enforced pattern as #3184 (validation delete-event) and #3195 (hook events). Trim, contract-first: authoring a webhook on a removed trigger now fails loudly at os validate / registration instead of registering a webhook that silently never fires. No shipped webhook metadata used either. The auto-enqueuer now also warns when a persisted sys_webhook row carries a trigger it can't map to an emitted record event (drift-guard, mirroring #3195's registerHook warning) and drops it rather than caching a subscription that matches nothing. Updated the sys_webhook trigger options + field help (all locales), docs, and regenerated reference; added rejection + drift-guard tests. Reintroduce undelete only with a real restore subsystem, and api only with a real manual-fire endpoint. Closes #3196 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VCUSMJBsX14C3RQdWFw7N7 --- .changeset/trim-webhook-dead-triggers.md | 11 ++++++ content/docs/automation/webhooks.mdx | 6 +-- .../docs/references/automation/webhook.mdx | 36 ++++++++++++++++-- .../docs/references/integration/connector.mdx | 8 ++-- content/docs/references/integration/misc.mdx | 12 +++--- .../plugin-webhooks/src/auto-enqueuer.test.ts | 37 +++++++++++++++++++ .../plugin-webhooks/src/auto-enqueuer.ts | 34 ++++++++++++----- .../plugin-webhooks/src/sys-webhook.object.ts | 6 +-- .../src/translations/en.objects.generated.ts | 4 +- .../translations/es-ES.objects.generated.ts | 4 +- .../translations/ja-JP.objects.generated.ts | 4 +- .../translations/zh-CN.objects.generated.ts | 4 +- packages/spec/src/automation/webhook.test.ts | 14 +++++-- packages/spec/src/automation/webhook.zod.ts | 27 +++++++++++--- 14 files changed, 160 insertions(+), 47 deletions(-) create mode 100644 .changeset/trim-webhook-dead-triggers.md diff --git a/.changeset/trim-webhook-dead-triggers.md b/.changeset/trim-webhook-dead-triggers.md new file mode 100644 index 0000000000..4c898be4c7 --- /dev/null +++ b/.changeset/trim-webhook-dead-triggers.md @@ -0,0 +1,11 @@ +--- +"@objectstack/spec": patch +"@objectstack/plugin-webhooks": patch +--- + +Trim the dead `undelete` and `api` webhook triggers (#3196). `WebhookTriggerType` declared five triggers but only three ever fired: + +- `undelete` had no event source — the engine has no soft-delete/restore capability (`delete` is a hard delete; no `deleted_at` convention, no restore operation, and `data.record.undeleted` is never emitted). The `undeleted` case in the auto-enqueuer's action mapper was dead code awaiting a producer that doesn't exist. +- `api` ("manually triggered") had no fire path — the only webhook HTTP surface re-queues already-failed deliveries; nothing originates a manual fire. + +Both are removed from the enum (contract-first, matching #3184/#3195): authoring a webhook on a removed trigger now fails loudly at `os validate` / registration instead of registering a webhook that silently never fires. No shipped webhook metadata used either. The auto-enqueuer now also warns when a persisted `sys_webhook` row carries a trigger it can't map to an emitted record event (a drift-guard, so a dead trigger can't silently no-op again). Reintroduce `undelete` only alongside a real restore subsystem, and `api` only alongside a real manual-fire endpoint. Updated the `sys_webhook` trigger options, field help (all locales), docs, and reference; added rejection tests. diff --git a/content/docs/automation/webhooks.mdx b/content/docs/automation/webhooks.mdx index b3791c6b1e..d0259518ac 100644 --- a/content/docs/automation/webhooks.mdx +++ b/content/docs/automation/webhooks.mdx @@ -93,8 +93,8 @@ in `definition_json`, a serialised `Webhook` JSON (canonical schema: | `id` | text | Primary key. | | `name` | text | Unique snake_case name — referenced in logs and audit. | | `label` | text | Optional display label. | -| `object_name` | text | Short object name whose events fire this webhook (blank = manual / API-triggered). | -| `triggers` | text | Comma-separated event list: `create,update,delete,undelete,api`. | +| `object_name` | text | Short object name whose record events fire this webhook. | +| `triggers` | text | Comma-separated event list: `create,update,delete`. | | `url` | text | External endpoint that receives the POST. | | `method` | text | HTTP method. Default `POST`. | | `description` | textarea | Free-text description. | @@ -321,7 +321,7 @@ Notes: - **`object`** — short object name the event came from. - **`recordId`** — id of the affected record. -- **`action`** — `created` / `updated` / `deleted` / `undeleted`. +- **`action`** — `created` / `updated` / `deleted`. - **`timestamp`** — event timestamp, an ISO 8601 string (passed through unchanged from the originating realtime event's `timestamp` field — not an epoch-ms number). diff --git a/content/docs/references/automation/webhook.mdx b/content/docs/references/automation/webhook.mdx index 385e53cade..1a1e8a2b8d 100644 --- a/content/docs/references/automation/webhook.mdx +++ b/content/docs/references/automation/webhook.mdx @@ -9,6 +9,36 @@ Webhook Trigger Event When should this webhook fire? +These mirror the record events the engine actually emits + +(`data.record.created` / `updated` / `deleted`), which the webhook + +auto-enqueuer maps to `create` / `update` / `delete`. Only events with a real + +producer are declared here — an author can't subscribe to something that + +never fires. + +Deliberately NOT triggers (#3196): + +- `undelete` — there is no soft-delete / restore capability in the engine + +(`delete` is a hard delete; no `deleted_at` convention, no restore + +operation, no `data.record.undeleted` emit), so it had no event source. + +Reintroduce it only alongside a real restore subsystem that emits an + +undelete event. + +- `api` (manual/programmatic fire) — no manual fire path exists (the only + +webhook HTTP surface re-queues already-failed deliveries). Reintroduce it + +with a real "fire this webhook now" endpoint/service, not as a bare enum + +value that silently never fires. + **Source:** `packages/spec/src/automation/webhook.zod.ts` @@ -33,8 +63,8 @@ const result = Webhook.parse(data); | :--- | :--- | :--- | :--- | | **name** | `string` | ✅ | Webhook unique name (lowercase snake_case) | | **label** | `string` | optional | Human-readable webhook label | -| **object** | `string` | optional | Object to listen to (optional for manual webhooks) | -| **triggers** | `Enum<'create' \| 'update' \| 'delete' \| 'undelete' \| 'api'>[]` | optional | Events that trigger execution | +| **object** | `string` | optional | Object whose record events (create/update/delete) trigger this webhook | +| **triggers** | `Enum<'create' \| 'update' \| 'delete'>[]` | optional | Events that trigger execution | | **url** | `string` | ✅ | External webhook endpoint URL | | **method** | `Enum<'GET' \| 'POST' \| 'PUT' \| 'PATCH' \| 'DELETE'>` | ✅ | HTTP method | | **headers** | `Record` | optional | Custom HTTP headers | @@ -75,8 +105,6 @@ const result = Webhook.parse(data); * `create` * `update` * `delete` -* `undelete` -* `api` --- diff --git a/content/docs/references/integration/connector.mdx b/content/docs/references/integration/connector.mdx index 0603e8c249..06087c43db 100644 --- a/content/docs/references/integration/connector.mdx +++ b/content/docs/references/integration/connector.mdx @@ -184,7 +184,7 @@ Circuit breaker configuration | **triggers** | `{ key: string; label: string; description?: string; type: Enum<'polling' \| 'webhook'>; … }[]` | optional | | | **syncConfig** | `{ strategy?: Enum<'full' \| 'incremental' \| 'upsert' \| 'append_only'>; direction?: Enum<'import' \| 'export' \| 'bidirectional'>; schedule?: string \| { dialect: Enum<'cel' \| 'js' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }; realtimeSync?: boolean; … }` | optional | Data sync configuration | | **fieldMappings** | `{ source: string; target: string; transform?: { type: 'constant'; value: any } \| { type: 'cast'; targetType: Enum<'string' \| 'number' \| 'boolean' \| 'date'> } \| { type: 'lookup'; table: string; keyField: string; valueField: string } \| { type: 'javascript'; expression: string \| { dialect: Enum<'cel' \| 'js' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object } } \| { type: 'map'; mappings: Record }; defaultValue?: any; … }[]` | optional | Field mapping rules | -| **webhooks** | `{ name: string; label?: string; object?: string; triggers?: Enum<'create' \| 'update' \| 'delete' \| 'undelete' \| 'api'>[]; … }[]` | optional | Webhook configurations | +| **webhooks** | `{ name: string; label?: string; object?: string; triggers?: Enum<'create' \| 'update' \| 'delete'>[]; … }[]` | optional | Webhook configurations | | **rateLimitConfig** | `{ strategy?: Enum<'fixed_window' \| 'sliding_window' \| 'token_bucket' \| 'leaky_bucket'>; maxRequests: number; windowSeconds: number; burstCapacity?: number; … }` | optional | Rate limiting configuration | | **retryConfig** | `{ strategy?: Enum<'exponential_backoff' \| 'linear_backoff' \| 'fixed_delay' \| 'no_retry'>; maxAttempts?: number; initialDelayMs?: number; maxDelayMs?: number; … }` | optional | Retry configuration | | **connectionTimeoutMs** | `number` | optional | Connection timeout in ms | @@ -310,7 +310,7 @@ Connector type | **triggers** | `{ key: string; label: string; description?: string; type: Enum<'polling' \| 'webhook'>; … }[]` | optional | | | **syncConfig** | `{ strategy?: Enum<'full' \| 'incremental' \| 'upsert' \| 'append_only'>; direction?: Enum<'import' \| 'export' \| 'bidirectional'>; schedule?: string \| { dialect: Enum<'cel' \| 'js' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }; realtimeSync?: boolean; … }` | optional | Data sync configuration | | **fieldMappings** | `{ source: string; target: string; transform?: { type: 'constant'; value: any } \| { type: 'cast'; targetType: Enum<'string' \| 'number' \| 'boolean' \| 'date'> } \| { type: 'lookup'; table: string; keyField: string; valueField: string } \| { type: 'javascript'; expression: string \| { dialect: Enum<'cel' \| 'js' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object } } \| { type: 'map'; mappings: Record }; defaultValue?: any; … }[]` | optional | Field mapping rules | -| **webhooks** | `{ name: string; label?: string; object?: string; triggers?: Enum<'create' \| 'update' \| 'delete' \| 'undelete' \| 'api'>[]; … }[]` | optional | Webhook configurations | +| **webhooks** | `{ name: string; label?: string; object?: string; triggers?: Enum<'create' \| 'update' \| 'delete'>[]; … }[]` | optional | Webhook configurations | | **rateLimitConfig** | `{ strategy?: Enum<'fixed_window' \| 'sliding_window' \| 'token_bucket' \| 'leaky_bucket'>; maxRequests: number; windowSeconds: number; burstCapacity?: number; … }` | optional | Rate limiting configuration | | **retryConfig** | `{ strategy?: Enum<'exponential_backoff' \| 'linear_backoff' \| 'fixed_delay' \| 'no_retry'>; maxAttempts?: number; initialDelayMs?: number; maxDelayMs?: number; … }` | optional | Retry configuration | | **connectionTimeoutMs** | `number` | optional | Connection timeout in ms | @@ -465,8 +465,8 @@ Synchronization strategy | :--- | :--- | :--- | :--- | | **name** | `string` | ✅ | Webhook unique name (lowercase snake_case) | | **label** | `string` | optional | Human-readable webhook label | -| **object** | `string` | optional | Object to listen to (optional for manual webhooks) | -| **triggers** | `Enum<'create' \| 'update' \| 'delete' \| 'undelete' \| 'api'>[]` | optional | Events that trigger execution | +| **object** | `string` | optional | Object whose record events (create/update/delete) trigger this webhook | +| **triggers** | `Enum<'create' \| 'update' \| 'delete'>[]` | optional | Events that trigger execution | | **url** | `string` | ✅ | External webhook endpoint URL | | **method** | `Enum<'GET' \| 'POST' \| 'PUT' \| 'PATCH' \| 'DELETE'>` | ✅ | HTTP method | | **headers** | `Record` | optional | Custom HTTP headers | diff --git a/content/docs/references/integration/misc.mdx b/content/docs/references/integration/misc.mdx index fbd749da18..52f3a09810 100644 --- a/content/docs/references/integration/misc.mdx +++ b/content/docs/references/integration/misc.mdx @@ -100,7 +100,7 @@ Message acknowledgment mode | **triggers** | `{ key: string; label: string; description?: string; type: Enum<'polling' \| 'webhook'>; … }[]` | optional | | | **syncConfig** | `{ strategy?: Enum<'full' \| 'incremental' \| 'upsert' \| 'append_only'>; direction?: Enum<'import' \| 'export' \| 'bidirectional'>; schedule?: string \| { dialect: Enum<'cel' \| 'js' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }; realtimeSync?: boolean; … }` | optional | Data sync configuration | | **fieldMappings** | `{ source: string; target: string; transform?: { type: 'constant'; value: any } \| { type: 'cast'; targetType: Enum<'string' \| 'number' \| 'boolean' \| 'date'> } \| { type: 'lookup'; table: string; keyField: string; valueField: string } \| { type: 'javascript'; expression: string \| { dialect: Enum<'cel' \| 'js' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object } } \| { type: 'map'; mappings: Record }; defaultValue?: any; … }[]` | optional | Field mapping rules | -| **webhooks** | `{ name: string; label?: string; object?: string; triggers?: Enum<'create' \| 'update' \| 'delete' \| 'undelete' \| 'api'>[]; … }[]` | optional | Webhook configurations | +| **webhooks** | `{ name: string; label?: string; object?: string; triggers?: Enum<'create' \| 'update' \| 'delete'>[]; … }[]` | optional | Webhook configurations | | **rateLimitConfig** | `{ strategy?: Enum<'fixed_window' \| 'sliding_window' \| 'token_bucket' \| 'leaky_bucket'>; maxRequests: number; windowSeconds: number; burstCapacity?: number; … }` | optional | Rate limiting configuration | | **retryConfig** | `{ strategy?: Enum<'exponential_backoff' \| 'linear_backoff' \| 'fixed_delay' \| 'no_retry'>; maxAttempts?: number; initialDelayMs?: number; maxDelayMs?: number; … }` | optional | Retry configuration | | **connectionTimeoutMs** | `number` | optional | Connection timeout in ms | @@ -307,7 +307,7 @@ File access pattern | **triggers** | `{ key: string; label: string; description?: string; type: Enum<'polling' \| 'webhook'>; … }[]` | optional | | | **syncConfig** | `{ strategy?: Enum<'full' \| 'incremental' \| 'upsert' \| 'append_only'>; direction?: Enum<'import' \| 'export' \| 'bidirectional'>; schedule?: string \| { dialect: Enum<'cel' \| 'js' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }; realtimeSync?: boolean; … }` | optional | Data sync configuration | | **fieldMappings** | `{ source: string; target: string; transform?: { type: 'constant'; value: any } \| { type: 'cast'; targetType: Enum<'string' \| 'number' \| 'boolean' \| 'date'> } \| { type: 'lookup'; table: string; keyField: string; valueField: string } \| { type: 'javascript'; expression: string \| { dialect: Enum<'cel' \| 'js' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object } } \| { type: 'map'; mappings: Record }; defaultValue?: any; … }[]` | optional | Field mapping rules | -| **webhooks** | `{ name: string; label?: string; object?: string; triggers?: Enum<'create' \| 'update' \| 'delete' \| 'undelete' \| 'api'>[]; … }[]` | optional | Webhook configurations | +| **webhooks** | `{ name: string; label?: string; object?: string; triggers?: Enum<'create' \| 'update' \| 'delete'>[]; … }[]` | optional | Webhook configurations | | **rateLimitConfig** | `{ strategy?: Enum<'fixed_window' \| 'sliding_window' \| 'token_bucket' \| 'leaky_bucket'>; maxRequests: number; windowSeconds: number; burstCapacity?: number; … }` | optional | Rate limiting configuration | | **retryConfig** | `{ strategy?: Enum<'exponential_backoff' \| 'linear_backoff' \| 'fixed_delay' \| 'no_retry'>; maxAttempts?: number; initialDelayMs?: number; maxDelayMs?: number; … }` | optional | Retry configuration | | **connectionTimeoutMs** | `number` | optional | Connection timeout in ms | @@ -415,7 +415,7 @@ File storage provider type | **triggers** | `{ key: string; label: string; description?: string; type: Enum<'polling' \| 'webhook'>; … }[]` | optional | | | **syncConfig** | `{ strategy?: Enum<'full' \| 'incremental' \| 'upsert' \| 'append_only'>; direction?: Enum<'import' \| 'export' \| 'bidirectional'>; schedule?: string \| { dialect: Enum<'cel' \| 'js' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }; realtimeSync?: boolean; … }` | optional | Data sync configuration | | **fieldMappings** | `{ source: string; target: string; transform?: { type: 'constant'; value: any } \| { type: 'cast'; targetType: Enum<'string' \| 'number' \| 'boolean' \| 'date'> } \| { type: 'lookup'; table: string; keyField: string; valueField: string } \| { type: 'javascript'; expression: string \| { dialect: Enum<'cel' \| 'js' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object } } \| { type: 'map'; mappings: Record }; defaultValue?: any; … }[]` | optional | Field mapping rules | -| **webhooks** | `{ name: string; label?: string; object?: string; triggers?: Enum<'create' \| 'update' \| 'delete' \| 'undelete' \| 'api'>[]; … }[]` | optional | Webhook configurations | +| **webhooks** | `{ name: string; label?: string; object?: string; triggers?: Enum<'create' \| 'update' \| 'delete'>[]; … }[]` | optional | Webhook configurations | | **rateLimitConfig** | `{ strategy?: Enum<'fixed_window' \| 'sliding_window' \| 'token_bucket' \| 'leaky_bucket'>; maxRequests: number; windowSeconds: number; burstCapacity?: number; … }` | optional | Rate limiting configuration | | **retryConfig** | `{ strategy?: Enum<'exponential_backoff' \| 'linear_backoff' \| 'fixed_delay' \| 'no_retry'>; maxAttempts?: number; initialDelayMs?: number; maxDelayMs?: number; … }` | optional | Retry configuration | | **connectionTimeoutMs** | `number` | optional | Connection timeout in ms | @@ -548,7 +548,7 @@ GitHub provider type | **triggers** | `{ key: string; label: string; description?: string; type: Enum<'polling' \| 'webhook'>; … }[]` | optional | | | **syncConfig** | `{ strategy?: Enum<'full' \| 'incremental' \| 'upsert' \| 'append_only'>; direction?: Enum<'import' \| 'export' \| 'bidirectional'>; schedule?: string \| { dialect: Enum<'cel' \| 'js' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }; realtimeSync?: boolean; … }` | optional | Data sync configuration | | **fieldMappings** | `{ source: string; target: string; transform?: { type: 'constant'; value: any } \| { type: 'cast'; targetType: Enum<'string' \| 'number' \| 'boolean' \| 'date'> } \| { type: 'lookup'; table: string; keyField: string; valueField: string } \| { type: 'javascript'; expression: string \| { dialect: Enum<'cel' \| 'js' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object } } \| { type: 'map'; mappings: Record }; defaultValue?: any; … }[]` | optional | Field mapping rules | -| **webhooks** | `{ name: string; label?: string; object?: string; triggers?: Enum<'create' \| 'update' \| 'delete' \| 'undelete' \| 'api'>[]; … }[]` | optional | Webhook configurations | +| **webhooks** | `{ name: string; label?: string; object?: string; triggers?: Enum<'create' \| 'update' \| 'delete'>[]; … }[]` | optional | Webhook configurations | | **rateLimitConfig** | `{ strategy?: Enum<'fixed_window' \| 'sliding_window' \| 'token_bucket' \| 'leaky_bucket'>; maxRequests: number; windowSeconds: number; burstCapacity?: number; … }` | optional | Rate limiting configuration | | **retryConfig** | `{ strategy?: Enum<'exponential_backoff' \| 'linear_backoff' \| 'fixed_delay' \| 'no_retry'>; maxAttempts?: number; initialDelayMs?: number; maxDelayMs?: number; … }` | optional | Retry configuration | | **connectionTimeoutMs** | `number` | optional | Connection timeout in ms | @@ -609,7 +609,7 @@ GitHub provider type | **triggers** | `{ key: string; label: string; description?: string; type: Enum<'polling' \| 'webhook'>; … }[]` | optional | | | **syncConfig** | `{ strategy?: Enum<'full' \| 'incremental' \| 'upsert' \| 'append_only'>; direction?: Enum<'import' \| 'export' \| 'bidirectional'>; schedule?: string \| { dialect: Enum<'cel' \| 'js' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }; realtimeSync?: boolean; … }` | optional | Data sync configuration | | **fieldMappings** | `{ source: string; target: string; transform?: { type: 'constant'; value: any } \| { type: 'cast'; targetType: Enum<'string' \| 'number' \| 'boolean' \| 'date'> } \| { type: 'lookup'; table: string; keyField: string; valueField: string } \| { type: 'javascript'; expression: string \| { dialect: Enum<'cel' \| 'js' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object } } \| { type: 'map'; mappings: Record }; defaultValue?: any; … }[]` | optional | Field mapping rules | -| **webhooks** | `{ name: string; label?: string; object?: string; triggers?: Enum<'create' \| 'update' \| 'delete' \| 'undelete' \| 'api'>[]; … }[]` | optional | Webhook configurations | +| **webhooks** | `{ name: string; label?: string; object?: string; triggers?: Enum<'create' \| 'update' \| 'delete'>[]; … }[]` | optional | Webhook configurations | | **rateLimitConfig** | `{ strategy?: Enum<'fixed_window' \| 'sliding_window' \| 'token_bucket' \| 'leaky_bucket'>; maxRequests: number; windowSeconds: number; burstCapacity?: number; … }` | optional | Rate limiting configuration | | **retryConfig** | `{ strategy?: Enum<'exponential_backoff' \| 'linear_backoff' \| 'fixed_delay' \| 'no_retry'>; maxAttempts?: number; initialDelayMs?: number; maxDelayMs?: number; … }` | optional | Retry configuration | | **connectionTimeoutMs** | `number` | optional | Connection timeout in ms | @@ -745,7 +745,7 @@ SaaS provider type | **triggers** | `{ key: string; label: string; description?: string; type: Enum<'polling' \| 'webhook'>; … }[]` | optional | | | **syncConfig** | `{ strategy?: Enum<'full' \| 'incremental' \| 'upsert' \| 'append_only'>; direction?: Enum<'import' \| 'export' \| 'bidirectional'>; schedule?: string \| { dialect: Enum<'cel' \| 'js' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }; realtimeSync?: boolean; … }` | optional | Data sync configuration | | **fieldMappings** | `{ source: string; target: string; transform?: { type: 'constant'; value: any } \| { type: 'cast'; targetType: Enum<'string' \| 'number' \| 'boolean' \| 'date'> } \| { type: 'lookup'; table: string; keyField: string; valueField: string } \| { type: 'javascript'; expression: string \| { dialect: Enum<'cel' \| 'js' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object } } \| { type: 'map'; mappings: Record }; defaultValue?: any; … }[]` | optional | Field mapping rules | -| **webhooks** | `{ name: string; label?: string; object?: string; triggers?: Enum<'create' \| 'update' \| 'delete' \| 'undelete' \| 'api'>[]; … }[]` | optional | Webhook configurations | +| **webhooks** | `{ name: string; label?: string; object?: string; triggers?: Enum<'create' \| 'update' \| 'delete'>[]; … }[]` | optional | Webhook configurations | | **rateLimitConfig** | `{ strategy?: Enum<'fixed_window' \| 'sliding_window' \| 'token_bucket' \| 'leaky_bucket'>; maxRequests: number; windowSeconds: number; burstCapacity?: number; … }` | optional | Rate limiting configuration | | **retryConfig** | `{ strategy?: Enum<'exponential_backoff' \| 'linear_backoff' \| 'fixed_delay' \| 'no_retry'>; maxAttempts?: number; initialDelayMs?: number; maxDelayMs?: number; … }` | optional | Retry configuration | | **connectionTimeoutMs** | `number` | optional | Connection timeout in ms | diff --git a/packages/plugins/plugin-webhooks/src/auto-enqueuer.test.ts b/packages/plugins/plugin-webhooks/src/auto-enqueuer.test.ts index 2e4a7afda2..b0d051620f 100644 --- a/packages/plugins/plugin-webhooks/src/auto-enqueuer.test.ts +++ b/packages/plugins/plugin-webhooks/src/auto-enqueuer.test.ts @@ -292,6 +292,43 @@ describe('AutoEnqueuer', () => { await ae.stop(); }); + it('warns and ignores a legacy trigger the engine never emits (#3196)', async () => { + // A row authored before undelete/api were removed keeps its valid + // triggers; the dead ones are dropped with a warning (drift-guard). + const engine = new FakeEngine({ + sys_webhook: [webhook({ triggers: 'create,undelete,api' })], + }); + const realtime = new FakeRealtime(); + const { enqueue, calls } = makeRecorder(); + const warn = vi.fn(); + const ae = new AutoEnqueuer(engine, realtime, enqueue, { refreshIntervalMs: 0, logger: { warn } }); + await ae.start(); + + await realtime.publish(event('created', 'contact', { id: 'c-1' })); + await flush(); + + expect(calls).toHaveLength(1); // the valid `create` trigger still fires + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('undelete'), + expect.objectContaining({ unknown: expect.arrayContaining(['undelete', 'api']) }), + ); + await ae.stop(); + }); + + it('skips a webhook whose only triggers are removed values (#3196)', async () => { + const engine = new FakeEngine({ sys_webhook: [webhook({ triggers: 'undelete,api' })] }); + const realtime = new FakeRealtime(); + const { enqueue, calls } = makeRecorder(); + const ae = new AutoEnqueuer(engine, realtime, enqueue, { refreshIntervalMs: 0 }); + await ae.start(); + + await realtime.publish(event('created', 'contact', { id: 'c-1' })); + await flush(); + + expect(calls).toHaveLength(0); + await ae.stop(); + }); + it('self-heals the cache when sys_webhook changes', async () => { const engine = new FakeEngine({ sys_webhook: [] }); const realtime = new FakeRealtime(); diff --git a/packages/plugins/plugin-webhooks/src/auto-enqueuer.ts b/packages/plugins/plugin-webhooks/src/auto-enqueuer.ts index 6cd46fb545..33869b6a8b 100644 --- a/packages/plugins/plugin-webhooks/src/auto-enqueuer.ts +++ b/packages/plugins/plugin-webhooks/src/auto-enqueuer.ts @@ -28,8 +28,8 @@ interface OptionalLogger { interface CachedSubscription { id: string; name: string; - objectName: string | undefined; // empty = matches all objects (manual-only is filtered out earlier) - triggers: Set<'create' | 'update' | 'delete' | 'undelete'>; + objectName: string | undefined; // empty = matches all objects + triggers: Set<'create' | 'update' | 'delete'>; url: string; method?: string; headers?: Record; @@ -226,13 +226,26 @@ export class AutoEnqueuer { triggerList = s.split(','); } } + const normalized = triggerList.map((t) => t.trim().toLowerCase()).filter(Boolean); + // [#3196] Drop (and warn about) any trigger the enqueuer can't map to an + // emitted record event — e.g. a legacy `sys_webhook` row authored with + // the now-removed `undelete`/`api` values, which would otherwise sit in + // the cache matching nothing. A loud drift-guard so a dead trigger can't + // silently no-op again. + const unknown = normalized.filter((t) => !DISPATCHABLE_WEBHOOK_TRIGGERS.has(t)); + if (unknown.length > 0) { + this.logger.warn?.( + `[webhook-auto-enqueuer] webhook '${(row.name as string) ?? row.id}' declares trigger(s) the engine never emits: ` + + `${unknown.join(', ')} — ignored. Dispatchable triggers: create, update, delete.`, + { id: row.id, unknown }, + ); + } const triggers = new Set( - triggerList - .map((t) => t.trim().toLowerCase()) - .filter(Boolean) as Array<'create' | 'update' | 'delete' | 'undelete'>, + normalized.filter((t) => DISPATCHABLE_WEBHOOK_TRIGGERS.has(t)) as Array<'create' | 'update' | 'delete'>, ); if (triggers.size === 0) { - // Manual-only webhook (no triggers) — skip auto-enqueue. + // No dispatchable triggers (or a manual-only webhook with none) — + // skip auto-enqueue. return null; } @@ -278,7 +291,7 @@ export class AutoEnqueuer { if (event.object === this.subscriptionsObject) return; // self-heal handles its own const action = event.type.slice('data.record.'.length) as - | 'created' | 'updated' | 'deleted' | 'undeleted' | string; + | 'created' | 'updated' | 'deleted' | string; const trigger = mapActionToTrigger(action); if (!trigger) return; @@ -353,7 +366,7 @@ export class AutoEnqueuer { function mapActionToTrigger( action: string, -): 'create' | 'update' | 'delete' | 'undelete' | null { +): 'create' | 'update' | 'delete' | null { switch (action) { case 'created': return 'create'; @@ -361,9 +374,10 @@ function mapActionToTrigger( return 'update'; case 'deleted': return 'delete'; - case 'undeleted': - return 'undelete'; default: return null; } } + +/** The trigger values the enqueuer can actually map from an emitted record event. */ +const DISPATCHABLE_WEBHOOK_TRIGGERS: ReadonlySet = new Set(['create', 'update', 'delete']); diff --git a/packages/plugins/plugin-webhooks/src/sys-webhook.object.ts b/packages/plugins/plugin-webhooks/src/sys-webhook.object.ts index adbd35f624..cef1ec3917 100644 --- a/packages/plugins/plugin-webhooks/src/sys-webhook.object.ts +++ b/packages/plugins/plugin-webhooks/src/sys-webhook.object.ts @@ -116,12 +116,12 @@ export const SysWebhook = ObjectSchema.create({ // Object picker (same widget as sys_sharing_rule) instead of a free-text // machine name. Falls back to a text input when the widget is unavailable. widget: 'object-ref', - description: 'Short object name whose events fire this webhook (blank = manual / API-triggered)', + description: 'Short object name whose record events (create/update/delete) fire this webhook', group: 'Definition', }), triggers: Field.select( - ['create', 'update', 'delete', 'undelete', 'api'], + ['create', 'update', 'delete'], { label: 'Triggers', required: false, @@ -129,7 +129,7 @@ export const SysWebhook = ObjectSchema.create({ // an array; the auto-enqueuer parser also tolerates the legacy // comma-separated / JSON-string forms so existing rows keep working. multiple: true, - description: 'Record events that fire this webhook (empty = manual / API-triggered)', + description: 'Record events that fire this webhook', group: 'Definition', }, ), diff --git a/packages/plugins/plugin-webhooks/src/translations/en.objects.generated.ts b/packages/plugins/plugin-webhooks/src/translations/en.objects.generated.ts index 319d903db4..6152db492d 100644 --- a/packages/plugins/plugin-webhooks/src/translations/en.objects.generated.ts +++ b/packages/plugins/plugin-webhooks/src/translations/en.objects.generated.ts @@ -26,11 +26,11 @@ export const enObjects: NonNullable = { }, object_name: { label: "Object", - help: "Short object name whose events fire this webhook (blank = manual / API-triggered)" + help: "Short object name whose record events (create/update/delete) fire this webhook" }, triggers: { label: "Triggers", - help: "Comma-separated event list: create,update,delete,undelete,api" + help: "Comma-separated event list: create,update,delete" }, url: { label: "Target URL", diff --git a/packages/plugins/plugin-webhooks/src/translations/es-ES.objects.generated.ts b/packages/plugins/plugin-webhooks/src/translations/es-ES.objects.generated.ts index 9edb94a505..e3649f7345 100644 --- a/packages/plugins/plugin-webhooks/src/translations/es-ES.objects.generated.ts +++ b/packages/plugins/plugin-webhooks/src/translations/es-ES.objects.generated.ts @@ -26,11 +26,11 @@ export const esESObjects: NonNullable = { }, object_name: { label: "Objeto", - help: "Nombre corto del objeto cuyos eventos activan este Webhook (vacío = activación manual / API)." + help: "Nombre corto del objeto cuyos eventos de registro (create/update/delete) activan este Webhook." }, triggers: { label: "Desencadenantes", - help: "Lista de eventos separada por comas: create,update,delete,undelete,api." + help: "Lista de eventos separada por comas: create,update,delete." }, url: { label: "URL de destino", diff --git a/packages/plugins/plugin-webhooks/src/translations/ja-JP.objects.generated.ts b/packages/plugins/plugin-webhooks/src/translations/ja-JP.objects.generated.ts index 9e0efc89c6..e4ab1ec05e 100644 --- a/packages/plugins/plugin-webhooks/src/translations/ja-JP.objects.generated.ts +++ b/packages/plugins/plugin-webhooks/src/translations/ja-JP.objects.generated.ts @@ -26,11 +26,11 @@ export const jaJPObjects: NonNullable = { }, object_name: { label: "オブジェクト", - help: "このウェブフックを発火するイベントのオブジェクト短縮名(空白 = 手動 / API トリガー)" + help: "このウェブフックを発火するレコードイベント(create/update/delete)のオブジェクト短縮名" }, triggers: { label: "トリガー", - help: "カンマ区切りのイベントリスト: create,update,delete,undelete,api" + help: "カンマ区切りのイベントリスト: create,update,delete" }, url: { label: "ターゲット URL", diff --git a/packages/plugins/plugin-webhooks/src/translations/zh-CN.objects.generated.ts b/packages/plugins/plugin-webhooks/src/translations/zh-CN.objects.generated.ts index 6be1736c13..5e4c489aa8 100644 --- a/packages/plugins/plugin-webhooks/src/translations/zh-CN.objects.generated.ts +++ b/packages/plugins/plugin-webhooks/src/translations/zh-CN.objects.generated.ts @@ -26,11 +26,11 @@ export const zhCNObjects: NonNullable = { }, object_name: { label: "对象", - help: "触发该 Webhook 的短对象名(留空 = 手动 / API 触发)" + help: "触发该 Webhook 的记录事件(create/update/delete)的短对象名" }, triggers: { label: "触发器", - help: "以逗号分隔的事件列表:create,update,delete,undelete,api" + help: "以逗号分隔的事件列表:create,update,delete" }, url: { label: "目标 URL", diff --git a/packages/spec/src/automation/webhook.test.ts b/packages/spec/src/automation/webhook.test.ts index b473dd6fe7..c5aa34a5a9 100644 --- a/packages/spec/src/automation/webhook.test.ts +++ b/packages/spec/src/automation/webhook.test.ts @@ -9,13 +9,21 @@ import { describe('WebhookTriggerType', () => { it('should accept valid trigger types', () => { - const validTypes = ['create', 'update', 'delete', 'undelete', 'api']; + const validTypes = ['create', 'update', 'delete']; validTypes.forEach(type => { expect(() => WebhookTriggerType.parse(type)).not.toThrow(); }); }); + it('should reject undelete / api — removed, no event source (#3196)', () => { + // `undelete` had no soft-delete/restore producer; `api` had no manual fire + // path. Removed rather than left as silent no-ops — authoring one now fails + // loudly instead of registering a webhook that never fires. + expect(() => WebhookTriggerType.parse('undelete')).toThrow(); + expect(() => WebhookTriggerType.parse('api')).toThrow(); + }); + it('should reject invalid trigger types', () => { expect(() => WebhookTriggerType.parse('insert')).toThrow(); expect(() => WebhookTriggerType.parse('modify')).toThrow(); @@ -121,11 +129,11 @@ describe('WebhookSchema', () => { const webhook = WebhookSchema.parse({ name: 'multi_trigger_webhook', object: 'account', - triggers: ['create', 'update', 'delete', 'undelete'], + triggers: ['create', 'update', 'delete'], url: 'https://example.com/webhook', }); - expect(webhook.triggers).toHaveLength(4); + expect(webhook.triggers).toHaveLength(3); }); it('should accept HMAC secret for signing', () => { diff --git a/packages/spec/src/automation/webhook.zod.ts b/packages/spec/src/automation/webhook.zod.ts index 2b773180b3..bccfecf21b 100644 --- a/packages/spec/src/automation/webhook.zod.ts +++ b/packages/spec/src/automation/webhook.zod.ts @@ -6,14 +6,29 @@ import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod'; /** * Webhook Trigger Event * When should this webhook fire? + * + * These mirror the record events the engine actually emits + * (`data.record.created` / `updated` / `deleted`), which the webhook + * auto-enqueuer maps to `create` / `update` / `delete`. Only events with a real + * producer are declared here — an author can't subscribe to something that + * never fires. + * + * Deliberately NOT triggers (#3196): + * - `undelete` — there is no soft-delete / restore capability in the engine + * (`delete` is a hard delete; no `deleted_at` convention, no restore + * operation, no `data.record.undeleted` emit), so it had no event source. + * Reintroduce it only alongside a real restore subsystem that emits an + * undelete event. + * - `api` (manual/programmatic fire) — no manual fire path exists (the only + * webhook HTTP surface re-queues already-failed deliveries). Reintroduce it + * with a real "fire this webhook now" endpoint/service, not as a bare enum + * value that silently never fires. */ import { lazySchema } from '../shared/lazy-schema'; export const WebhookTriggerType = z.enum([ - 'create', - 'update', - 'delete', - 'undelete', - 'api' // Manually triggered + 'create', + 'update', + 'delete', ]); /** @@ -63,7 +78,7 @@ export const WebhookSchema = lazySchema(() => z.object({ label: z.string().optional().describe('Human-readable webhook label'), /** Scope */ - object: z.string().optional().describe('Object to listen to (optional for manual webhooks)'), + object: z.string().optional().describe('Object whose record events (create/update/delete) trigger this webhook'), triggers: z.array(WebhookTriggerType).optional().describe('Events that trigger execution'), /** Target */