Skip to content

Commit 19e5338

Browse files
fix(plugin-webhooks,runtime): a declared webhook the deployment can never deliver is reported at error and refused at the activation door (#24946)
Part of #24910 — this PR delivers the ruling's parts (b), (c) and the docs. Part (a), the `os validate` refusal, is left open as a fork for the seat: its honest home is `packages/spec`, a hard stop for this dispatch (see "Part (a): the fork" below). Merging this PR must not close the card. Clause-②: no ## What changes The webhook auto-enqueuer reads record events only from the realtime service. A stack with `requires: ['webhooks']` and without `'realtime'` therefore never delivers a declared webhook, while every door said yes. - **(c) Boot report, `@objectstack/plugin-webhooks`.** The plugin decides at `kernel:ready` whether the auto-enqueuer can run, from three prerequisites: the engine, realtime and messaging. When it cannot and the stack declares at least one webhook, the boot logs **one `error`** in place of the old `warn`. The line names each declared webhook and says none of them will be delivered while record writes keep succeeding. It names the missing service and the fix ("add 'realtime' to `requires`, then restart"). A stack that declares no webhook keeps the old `warn`. `autoEnqueue: false` (the host's own choice) logs nothing, as before. - **(b) Activation door, `@objectstack/runtime`.** The declared-webhook reader (the `declaredWebhooks` service) is now bound already knowing that verdict: `dispatchUnavailable()` returns the same sentence, or `null` when the enqueuer runs. `POST /api/v1/security/_activation/webhook/NAME` asks the reader before switching ON. On a deployment that delivers no declared webhook it answers `503` with `error.code: 'SERVICE_UNAVAILABLE'` and writes nothing. That is the shape the door already uses when it cannot consult a reader. A reader that cannot say whether it dispatches is refused the same way. - Switching OFF is still written. - An undeclared name is still `404`; the declaration is confirmed first. - The authority gates still answer first. - **Docs.** `content/docs/automation/webhooks.mdx` §3.0 names the two tokens a declared webhook needs (`'webhooks'`, `'realtime'`) and what each one's absence looks like. Files: `packages/plugins/plugin-webhooks/src/{webhook-outbox-plugin,declared-webhooks}.ts`, new `auto-enqueuer-disabled-report.test.ts`; `packages/runtime/src/domains/catalog-activation.ts` and its `catalog-activation-door.test.ts`; the doc; `.changeset/24910-declared-webhook-undeliverable.md` (`patch` for both packages). **Cross-lane:** the seat's declaration on #22648, comment 6295927555, named `capability-preflight.ts` (with its `validate` / `serve` call sites) and `catalog-activation.ts`. This PR touches only `catalog-activation.ts` and its test. `capability-preflight.ts`, `validate.ts` and `serve.ts` are untouched, because of the fork below. ## Premises, measured on `origin/main` `71696121a6` - **P1 holds.** The `AutoEnqueuer`'s only event source is `IRealtimeService.subscribe` (`auto-enqueuer.ts` `start()`: three subscriptions, nothing else). `webhook-outbox-plugin.ts`'s old gate disabled it without engine, realtime or messaging. `messaging` IS on the always-on slate (`PLATFORM_ALWAYS_ON_CAPABILITIES`, `packages/spec/src/kernel/platform-capabilities.ts`), which is why the card's step 6 read `hasMessaging: true`. Under `--preset minimal` it is not mounted, and the plugin's `dependencies` on messaging then refuses the boot in the kernel's resolver; that path is not measured. - **P2 holds.** `os validate` loads the stack (`config.webhooks`, `config.requires` and each `packages[]` body) without booting, and `preflightDeclaredCapabilities` already reads `requires` from both places. - **P3, the honest home of the rule, is `packages/spec`.** See the fork. - **H4, measured.** `autoEnqueue: false` has zero production callers in this tree, only tests: `plugin-webhooks` (3 sites) and one dogfood test. It is an option of an exported class, so this PR treats it as a supported host choice: no report, no door refusal. Its redelivery still reads the switch (`redeliver-guard.ts` `judgeDeclared`). ## Part (a): the fork (needs the seat's decision) The rule "a stack that declares webhooks names `'webhooks'` and `'realtime'` in `requires`" belongs to a class that already exists: the **declared-capability refusals of `defineStack`** in `packages/spec/src/stack.zod.ts`. Its two members are `validateHierarchyScopeCapability` (`STACK_HIERARCHY_SCOPE_CAPABILITY_REQUIRED`) and `validateTriggerCapability` (`STACK_TRIGGER_CAPABILITY_REQUIRED`). The trigger rule states the same token-needs-token fact in spec text ("`triggers` installs the trigger INTO the automation service … ⛔ `triggers` never IMPLIES `automation`"), and its docblock calls itself "an ENFORCED capability class, exactly like the hierarchy scopes above". Every door that loads a stack runs those refusals: `os validate`, `os build`, `os serve` / `os dev`, `@objectstack/verify`'s `bootStack`, and cloud's runtime. So "serve reads the same rule" holds without a second call site. The ruling's hard stop says: if the honest home is `packages/spec`, report a fork. ⛔ No token is implied. | Option | Business need (measured) | Long-term | Prevents AI mistakes | Startup scope | |:---|:---|:---|:---|:---| | **A. A third `defineStack` refusal in spec**, beside the trigger rule (domain:spec) | Covers every door, including the app's own tests through `bootStack`. Fixture triage: `packages/qa/dogfood/test/fixtures/webhook-materialization-fixture.ts` declares a webhook with no `requires` and deliberately boots without realtime. Six other files in lint and spec spell `webhooks: [` (five tests and `conversions/registry.ts`; some are a connector's retired nested key, not a stack's), to scan. | One rule in the contract and one class in one home. | The earliest loud refusal: at `defineStack`, even inside a test. | No new gate; one new ADR-0112 code. `defineStack`'s accept set narrows (`Clause-②` narrowing, `minor`). Cloud's resolver for `webhooks` is **NOT MEASURED** (the trigger rule measured cloud's `resolveCapabilityDependencies`); measure it first. | | **B. A rule in the CLI preflight** (`capability-preflight.ts`, domain:cli) | Covers `os validate` / `os build`, and `os serve` after a new call site. Misses `bootStack`, so an app's tests stay green on the inert stack, and misses cloud's runtime. | A second home for one class: two members in spec, one in the CLI. The preflight's charter is provider resolution ("Not a registry rule: it reads `node_modules`, not the stack", `validate.ts` step 3b). | Loud at the CLI doors; silent in-process. | No spec lane, no error code. | | C. `plugin-webhooks` declares the realtime plugin in `dependencies` | Boot-time "Missing dependency" only, invisible to `os validate`. It refuses a `webhooks` stack with no declared webhook and an `autoEnqueue: false` host, which H4 rules out. | Rejected. | | | **Recommendation: A.** It joins the existing class in the one place every door reads, and its extra cost (one cloud measurement and one error code) is smaller than giving one class two homes. Under A and under B alike, a host that wires `WebhookOutboxPlugin` through `plugins[]` without naming the tokens is refused: the token is the declaration, as for triggers. H4 measured no production host of that shape. ## Tests (head `02d22e1eec`) - New `auto-enqueuer-disabled-report.test.ts`. A real `LiteKernel` boot runs the plugin's real `kernel:ready`. It uses a real `ObjectQL` over a real in-memory `SqlDriver`, the webhook from `defineWebhook` through the engine's real `registerApp` decomposition, and messaging's real `MessagingService`. Only realtime is a stand-in. 1. With no realtime: one `error` naming the webhook and the fix, no `disabled` warn, and `dispatchUnavailable()` names `'realtime'`. An insert plus an update enqueue **nothing**. 2. CONTROL, with realtime: no error, `null`, and the same writes enqueue two deliveries (`declared:my_app_note_changed`, created then updated). 3. With no declared webhook: the old `warn`, never the error. 4. With `autoEnqueue: false`: nothing reported, and `null`. - `catalog-activation-door.test.ts` adds 7 cases: - ON is refused 503 `SERVICE_UNAVAILABLE` naming the webhook and the reader's reason, with nothing written. An empty body (the default, ON) is refused the same way. - CONTROL: OFF is written. - An undeclared name is still 404, and a walled tenant admin is still 403 first. - A reader without `dispatchUnavailable` gets 503 on ON and is written on OFF. A reader that throws gets 503. - `pnpm --filter @objectstack/plugin-webhooks exec vitest run --maxWorkers=2`: **23 files, 231 tests passed**. `typecheck` exit 0. - `pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2`: **435 files, 7609 passed, 19 skipped**. `typecheck` exit 0. - **Ablation**, one leg per changed behaviour. Each leg went through `scripts/ablation-replace.mjs` (WRAP mode, anchor x1 to x0, blob moved), and each restore was proven by blob equality with HEAD and an empty `git diff HEAD`. The subjects resolve through relative `src` imports, so no `dist` was involved. - Leg A: the error branch is never taken. Leg 1 goes red ("expected [] to have a length of 1"). - Leg B: the reader is born without the gap. Legs 1 and 3 go red. The first attempt at B was a no-op: its replacement text already occurred inside the anchor, the tool refused and restored, and it was re-run with a distinct replacement. - Leg C: the door never asks the reader. 4 door cases go red (200 where 503 was expected). ## Local gates (head `02d22e1eec`) - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, with no paths. The change set is the 7 files since the merge base `71696121a6`, and the tool printed 100 commands. Each ran once, sequentially, with its exit captured before any pipe. - First pass: 97 exited 0, and 3 exited 3 (`PREREQUISITE NOT MET`): `check:skill-examples` (client SDK not built), `check:dual-build-cjs-loads` and `check:i18n` (dists not built). - After a full `turbo run build` under the verify lock (76/76 tasks), all three re-ran and exited 0. The working tree was still clean after the build. - `node scripts/pm/dispatch-gates.mjs --ran` answered "100 derived famil(ies) accounted for — 100 run, 0 NOT-MEASURED", exit 0. - Repo-wide scans (`pnpm lint` first among them) and the families only CI runs are left to this PR's checks. ## Acceptance notes - **Authored `sys_webhook` rows on the same composition.** A stack with `'webhooks'` and without `'realtime'` delivers no authored row either. With no declared webhook that stays a `warn`, as the ruling scoped (c). Known from code reading only; it is not measured through a public door here. - **The switch's other effect.** On a deployment without realtime, switching a declared webhook ON would also have let its earlier delivery rows be redelivered (`redeliver-guard.ts` reads the switch). The door now refuses that ON too; adding `'realtime'` is the fix. - **`Clause-②`.** This PR copies the claim's `Clause-②: no`. The activation door's change is a REST accept-set narrowing on such a deployment (`200` to `503`). By the precedent of `.changeset/24813-adopt-orphans-readonly-package.md`, that may read `Clause-②: no (narrowing)`, with a `minor` runtime bump, a BREAKING note and an ADR-0087 marker in the changeset. That is the seat's call. - **The card's own path** (a published 18.0.0 scaffold outside the workspace) is NOT MEASURED here. The defect is reproduced in-tree by test leg 1 against its control. > **Seat's append** (`domain:services` seat 1, `session_011TdZ8GjixNZFLz1xJD9zzK`): the dev's round-2 markdown from os-dev report `6297443354` on #24910, verbatim. The dev writes the body once and never PATCHes it. Where the sections above describe part (b), the activation door's refusal or `dispatchUnavailable()`, this append supersedes them. Parts (a) and (b) go to the maintainer on #24910. ## Round 2 (head `9e0ec86422`): the boot report and the docs only The seat's review ruled that the activation door's `200` to `503` change is a REST accept-set narrowing, which is protocol class. That goes to the maintainer together with part (a), so this PR now carries part (c) and the docs. **The sections above that describe (b), the activation door's refusal and `dispatchUnavailable()`, no longer describe this diff.** - **Removed:** - The activation door's refusal. `packages/runtime/src/domains/catalog-activation.ts` and `catalog-activation-door.test.ts` are back to `main`'s bytes, so the PR's `packages/runtime` diff is empty. - The declared-webhook reader's `dispatchUnavailable()` verdict, together with its `DeclaredWebhookDispatch` interface and constructor parameter. The door was its only consumer. The boot report never read it: it builds its sentence from the wiring it resolves itself. `bootWebhookTable` and the `kernel:ready` hook are byte-identical to `main` again. - **Kept, as built:** ONE `error` at boot when the auto-enqueuer cannot run and the stack declares webhooks. It names each declared webhook, the consequence and the fix. The old `warn` stays when no webhook is declared, and `autoEnqueue: false` stays silent. The only addition to `declared-webhooks.ts` is `readDeclaredWebhookNames()`, which the report needs to name the webhooks. - **Docs:** `webhooks.mdx` §3.0 names `'webhooks'` + `'realtime'`. It claims no refusal by the activation door or by `os validate`; it says only that switching a declared webhook on delivers nothing while `'realtime'` is missing. - **Changeset:** `@objectstack/plugin-webhooks` `patch` only, `Clause-②: no`. "Unchanged" names `os validate` / `os build` and the activation door. - **Merged `origin/main`** at `feb09db14a`; it touched only `packages/cli`, with no overlap. **Files now:** `.changeset/24910-declared-webhook-undeliverable.md`, `content/docs/automation/webhooks.mdx`, `packages/plugins/plugin-webhooks/src/{auto-enqueuer-disabled-report.test,declared-webhooks,webhook-outbox-plugin}.ts`. **Tests on `9e0ec86422`:** - `pnpm --filter @objectstack/plugin-webhooks exec vitest run --maxWorkers=2`: 23 files, 231 tests passed. `typecheck` exit 0. - The four-case real-kernel pin: no realtime gives 1 error and 0 enqueued; the CONTROL with realtime gives 2 deliveries; no declared webhook gives the warn only; `autoEnqueue: false` is silent. - Ablation leg A, re-run on this head: the error branch is never taken, and the run reads "1 failed | 3 passed" ("expected [] to have a length of 1"). The restore was proven by blob equality with HEAD and an empty `git diff HEAD`. **Gates on `9e0ec86422`:** `dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` printed 100 commands (5 files vs the merge base `feb09db14`). All 100 ran under the verify lock and exited 0, after a full `turbo run build` (76/76). `--ran` reported "100 run, 0 NOT-MEASURED", exit 0. Round 1's head `02d22e1eec` finished CI with 36 success and 6 skipped out of 42 check-runs, none red. --- _Generated by [Claude Code](https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8cfe1f9 commit 19e5338

5 files changed

Lines changed: 405 additions & 7 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/plugin-webhooks': patch
3+
---
4+
5+
fix(plugin-webhooks): a declared webhook that the deployment can never deliver is reported once at boot, at `error`, with the consequence and the fix
6+
7+
Clause-②: no
8+
9+
The webhook auto-enqueuer reads record events only from the realtime service. A stack with `requires: ['webhooks']` and no `'realtime'` therefore never delivers a declared webhook.
10+
11+
- **Before.**
12+
- The boot logged one `WARN [webhook-auto-enqueuer] disabled — ObjectQL, Realtime, or Messaging service not available`. The line named no webhook, gave no consequence and gave no fix.
13+
- Every record write still answered 200, the declaration still read active, and no delivery row was ever written.
14+
- **After.** When the stack declares at least one webhook, the boot logs one `error` line instead of the warning. The line names each declared webhook and says none of them will be delivered while record writes keep succeeding. It names the missing service and the fix, for example "add 'realtime' to `requires`". A stack that declares no webhook gets the same `warn` as before.
15+
- **Unchanged.**
16+
- Every deployment whose auto-enqueuer runs.
17+
- A host that constructs `WebhookOutboxPlugin` with `autoEnqueue: false` gets no new log line. It turned the enqueuer off itself.
18+
- `os validate` and `os build`: they still accept a stack that declares webhooks without these tokens.
19+
- The activation door (`POST /api/v1/security/_activation/webhook/NAME`): it answers exactly as before.
20+
- **What to do.** A stack that declares webhooks needs `requires` to include both `'webhooks'` and `'realtime'`.

‎content/docs/automation/webhooks.mdx‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,28 @@ A webhook reaches the dispatcher one of two ways:
100100
- **Authored by an organization** — a `sys_webhook` row (§3.1), switched by its
101101
own `active` field.
102102

103+
**The capabilities a declared webhook needs.** A stack that declares a webhook
104+
must name two tokens in `requires`, beside whatever else it declares:
105+
106+
```ts
107+
requires: ['automation', 'triggers', 'webhooks', 'realtime'],
108+
```
109+
110+
- `'webhooks'` mounts `plugin-webhooks`: the declared-webhook reader, the
111+
auto-enqueuer and the redeliver door. Without it nothing reads the
112+
declaration at all.
113+
- `'realtime'` mounts the realtime service, which is the auto-enqueuer's only
114+
source of record events: the engine publishes `data.record.*` through it, and
115+
nothing else feeds the enqueuer (§4.1). With `'webhooks'` and without
116+
`'realtime'` the declaration is read but never dispatched. Record writes still
117+
succeed and no delivery is ever enqueued. The boot logs one `error` that names
118+
each declared webhook and the missing token. Switching a declared webhook on
119+
through the activation door delivers nothing on such a deployment either.
120+
121+
The outbox itself comes from `messaging`, which every boot mounts unless it runs
122+
with `--preset minimal`. A scaffolded app's `requires` is
123+
`['automation', 'triggers']`, so it carries neither of the two tokens above.
124+
103125
On a database an 18.0 pre-release (`18.0.0-next.*`) created, the rows that release's boot seeder
104126
wrote for declared webhooks (`managed_by: 'package'`) are still there. Until the
105127
ownership migration removes them, a pristine one is ignored in favour of its
Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#24910] A kernel with `webhooks` and without `realtime` delivers no declared
5+
* webhook — and now says so ONCE, at `error`, with the consequence and the fix.
6+
*
7+
* ## The defect
8+
*
9+
* The auto-enqueuer's only source of record events is the realtime service: the
10+
* engine publishes `data.record.*` through it and nothing else feeds the
11+
* enqueuer. Without it the plugin disabled the enqueuer with one boot `warn`,
12+
* while every record write still answered 200, the declaration still said the
13+
* webhook was active, and the activation door answered `enabled: true` for a
14+
* switch that switched nothing. By AGENTS.md "Degradation log levels" that is
15+
* the durability class: the system looks normal and a delivery it claims never
16+
* lands.
17+
*
18+
* ## What is pinned, on a real kernel boot
19+
*
20+
* A real `LiteKernel` runs the plugin's real `kernel:ready`; the engine is a
21+
* real `ObjectQL` over a real in-memory `SqlDriver`, the stack's webhook comes
22+
* from `defineWebhook` through the engine's real manifest decomposition
23+
* (`registerApp`), and the outbox is messaging's real `MessagingService`. Only
24+
* the realtime service is a stand-in, and only on the legs that mount one.
25+
*
26+
* 1. Declared webhook, no realtime: one `error` naming the webhook, `realtime`
27+
* and the fix; no `disabled` warn; and a record write enqueues nothing (the
28+
* loss itself).
29+
* 2. CONTROL, the same stack with realtime: no error, and the same write
30+
* enqueues its deliveries — so leg 1's zero is the missing service and not a
31+
* fixture that cannot deliver.
32+
* 3. No declared webhook, no realtime: the `warn` it always was, no `error`.
33+
* 4. `autoEnqueue: false`: the host's own choice — nothing reported.
34+
*/
35+
36+
import { afterEach, describe, expect, it, vi } from 'vitest';
37+
import { LiteKernel } from '@objectstack/core';
38+
import type { Plugin, PluginContext } from '@objectstack/core';
39+
import { ObjectQL } from '@objectstack/objectql';
40+
import { SqlDriver } from '@objectstack/driver-sql';
41+
import { defineWebhook } from '@objectstack/spec/automation';
42+
import type { IRealtimeService, RealtimeEventHandler, RealtimeEventPayload } from '@objectstack/spec/contracts';
43+
import { MemoryHttpOutbox, MessagingService, type EnqueueHttpInput } from '@objectstack/service-messaging';
44+
import { DeclaredWebhookSource, declaredWebhookRefId } from './declared-webhooks.js';
45+
import { SysWebhook } from './sys-webhook.object.js';
46+
import { WebhookOutboxPlugin, type WebhookOutboxPluginOptions } from './webhook-outbox-plugin.js';
47+
48+
const OBJECT = 'my_app_note';
49+
const HOOK = 'my_app_note_changed';
50+
const silentLogger = { info() {}, warn() {}, error() {}, debug() {} } as any;
51+
52+
/** The webhook the card's reproduction declares, as `defineWebhook` authors it. */
53+
const DECLARED = defineWebhook({
54+
name: HOOK,
55+
object: OBJECT,
56+
triggers: ['create', 'update', 'delete'],
57+
url: 'http://127.0.0.1:3999/hook',
58+
method: 'POST',
59+
isActive: true,
60+
});
61+
62+
class FakeRealtime implements IRealtimeService {
63+
private subs = new Map<string, { handler: RealtimeEventHandler; opts?: any }>();
64+
private n = 0;
65+
async publish(event: RealtimeEventPayload): Promise<void> {
66+
for (const sub of this.subs.values()) {
67+
if (sub.opts?.object && event.object !== sub.opts.object) continue;
68+
await sub.handler(event);
69+
}
70+
}
71+
async subscribe(_channel: string, handler: any, opts?: any): Promise<string> {
72+
const id = `s-${++this.n}`;
73+
this.subs.set(id, { handler, opts });
74+
return id;
75+
}
76+
async unsubscribe(id: string): Promise<void> { this.subs.delete(id); }
77+
}
78+
79+
/** messaging's real outbox, with every enqueue it receives recorded. */
80+
class RecordingOutbox extends MemoryHttpOutbox {
81+
readonly enqueued: EnqueueHttpInput[] = [];
82+
override async enqueue(input: EnqueueHttpInput): Promise<string> {
83+
this.enqueued.push(input);
84+
return super.enqueue(input);
85+
}
86+
}
87+
88+
interface BootOptions {
89+
realtime?: boolean;
90+
declare?: boolean;
91+
plugin?: WebhookOutboxPluginOptions;
92+
}
93+
94+
/**
95+
* Stands in for the plugin's declared dependency
96+
* (`dependencies = ['com.objectstack.service.messaging']`) and registers what a
97+
* served boot holds for `requires: ['webhooks']`: the engine, messaging, and
98+
* realtime only where the case asks.
99+
*/
100+
class FixturePlugin implements Plugin {
101+
name = 'com.objectstack.service.messaging';
102+
type = 'standard' as const;
103+
version = '1.0.0';
104+
readonly outbox = new RecordingOutbox();
105+
readonly messaging = new MessagingService({ logger: silentLogger });
106+
107+
constructor(
108+
private readonly engine: ObjectQL,
109+
private readonly realtime: FakeRealtime | undefined,
110+
) {
111+
this.messaging.setHttpOutbox(this.outbox);
112+
}
113+
114+
init(ctx: PluginContext): void {
115+
ctx.registerService('manifest', { register: () => {}, list: () => [] });
116+
ctx.registerService('objectql', this.engine);
117+
ctx.registerService('messaging', this.messaging);
118+
if (this.realtime) ctx.registerService('realtime', this.realtime);
119+
}
120+
}
121+
122+
/** The real plugin, with what its boot logs observed. */
123+
class ObservedWebhookPlugin extends WebhookOutboxPlugin {
124+
readonly logs: Array<{ level: string; message: string; meta: unknown }> = [];
125+
126+
override async init(ctx: PluginContext): Promise<void> {
127+
for (const level of ['debug', 'info', 'warn', 'error'] as const) {
128+
vi.spyOn(ctx.logger, level).mockImplementation((message: unknown, ...rest: unknown[]) => {
129+
this.logs.push({ level, message: String(message), meta: level === 'error' ? rest[1] : rest[0] });
130+
});
131+
}
132+
await super.init(ctx);
133+
}
134+
135+
lines(level: string, fragment: string) {
136+
return this.logs.filter((l) => l.level === level && l.message.includes(fragment));
137+
}
138+
}
139+
140+
const open: Array<() => Promise<void>> = [];
141+
142+
afterEach(async () => {
143+
while (open.length) {
144+
try { await open.pop()?.(); } catch { /* already stopped */ }
145+
}
146+
vi.restoreAllMocks();
147+
});
148+
149+
async function boot(options: BootOptions) {
150+
const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true });
151+
const engine = new ObjectQL();
152+
engine.registerDriver(driver, true);
153+
await engine.init();
154+
engine.registerApp({
155+
id: 'my_app',
156+
namespace: 'my_app',
157+
version: '0.0.0',
158+
objects: [{
159+
name: OBJECT,
160+
label: 'Note',
161+
// Owner sharing is not under test here — keep RLS out of the way.
162+
sharingModel: 'public_read_write',
163+
fields: { title: { type: 'text', label: 'Title' } },
164+
}],
165+
...(options.declare === false ? {} : { webhooks: [DECLARED] }),
166+
});
167+
engine.registry.registerObject(SysWebhook as any, 'test');
168+
await engine.syncSchemas();
169+
const realtime = options.realtime ? new FakeRealtime() : undefined;
170+
if (realtime) engine.setRealtimeService(realtime);
171+
172+
const fixture = new FixturePlugin(engine, realtime);
173+
const plugin = new ObservedWebhookPlugin(options.plugin);
174+
const kernel = new LiteKernel({ logger: { level: 'silent' } as any });
175+
kernel.use(fixture);
176+
kernel.use(plugin);
177+
await kernel.bootstrap();
178+
open.push(async () => { await kernel.shutdown(); await driver.disconnect(); });
179+
180+
const reader = kernel.getService<DeclaredWebhookSource>('declaredWebhooks');
181+
const enqueuerStarted = (() => {
182+
try { return Boolean(kernel.getService('webhook.autoEnqueuer')); } catch { return false; }
183+
})();
184+
/** Insert then update one record, and give fire-and-forget enqueues a moment to land. */
185+
const writeRecord = async () => {
186+
const created: any = await engine.insert(OBJECT, { title: 'seed' });
187+
await engine.update(OBJECT, { title: 'edited' }, { where: { id: created.id } });
188+
for (let i = 0; i < 20 && fixture.outbox.enqueued.length < 2; i++) await new Promise((r) => setTimeout(r, 25));
189+
};
190+
return { plugin, reader, enqueuerStarted, writeRecord, outbox: fixture.outbox };
191+
}
192+
193+
describe('[#24910] the auto-enqueuer disabled on a stack that declares webhooks', () => {
194+
it('no realtime: ONE error naming the webhook, the missing service and the fix — and a record write enqueues nothing', async () => {
195+
const { plugin, reader, enqueuerStarted, writeRecord, outbox } = await boot({});
196+
197+
const errors = plugin.lines('error', '[webhook-auto-enqueuer]');
198+
expect(errors).toHaveLength(1);
199+
expect(errors[0].message).toContain(HOOK);
200+
expect(errors[0].message).toContain("add 'realtime' to `requires`");
201+
expect(errors[0].meta).toMatchObject({ hasEngine: true, hasRealtime: false, hasMessaging: true, declared: [HOOK] });
202+
expect(plugin.lines('warn', 'auto-enqueuer] disabled')).toEqual([]);
203+
204+
expect(enqueuerStarted).toBe(false);
205+
expect(reader).toBeInstanceOf(DeclaredWebhookSource);
206+
// The reader still answers the declaration — it is only dispatch that is missing.
207+
expect((await reader.declaredWebhookNamed(HOOK))?.active).toBe(true);
208+
209+
await writeRecord();
210+
expect(outbox.enqueued).toEqual([]);
211+
});
212+
213+
it('CONTROL — the same stack with realtime: no error, and the same write is delivered', async () => {
214+
const { plugin, enqueuerStarted, writeRecord, outbox } = await boot({ realtime: true });
215+
216+
expect(plugin.lines('error', '[webhook-auto-enqueuer]')).toEqual([]);
217+
expect(enqueuerStarted).toBe(true);
218+
219+
await writeRecord();
220+
expect(outbox.enqueued.map((d) => [d.refId, d.label])).toEqual([
221+
[declaredWebhookRefId(HOOK), 'data.record.created'],
222+
[declaredWebhookRefId(HOOK), 'data.record.updated'],
223+
]);
224+
});
225+
226+
it('no declared webhook, no realtime: the warn it always was, never the error', async () => {
227+
const { plugin } = await boot({ declare: false });
228+
229+
expect(plugin.lines('error', '[webhook-auto-enqueuer]')).toEqual([]);
230+
const warns = plugin.lines('warn', 'auto-enqueuer] disabled');
231+
expect(warns).toHaveLength(1);
232+
expect(warns[0].meta).toMatchObject({ hasEngine: true, hasRealtime: false, hasMessaging: true });
233+
});
234+
235+
it('`autoEnqueue: false` is the host\'s own composition: nothing reported', async () => {
236+
const { plugin, enqueuerStarted } = await boot({ plugin: { autoEnqueue: false } });
237+
238+
expect(plugin.lines('error', '[webhook-auto-enqueuer]')).toEqual([]);
239+
expect(plugin.lines('warn', '[webhook-auto-enqueuer]')).toEqual([]);
240+
expect(enqueuerStarted).toBe(false);
241+
});
242+
});

‎packages/plugins/plugin-webhooks/src/declared-webhooks.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,21 @@ export function readDeclaredWebhookItems(engine: unknown, metadataService: unkno
159159
}
160160
}
161161

162+
/**
163+
* [#24910] The distinct `name`s of the raw declared items — what the boot
164+
* report names when the auto-enqueuer cannot run, so nothing declared would be
165+
* delivered. Read the way {@link readDeclaredWebhookItems} reads them; never
166+
* throws.
167+
*/
168+
export function readDeclaredWebhookNames(engine: unknown, metadataService: unknown): string[] {
169+
const names = new Set<string>();
170+
for (const raw of readDeclaredWebhookItems(engine, metadataService)) {
171+
const name = (raw as { name?: unknown })?.name;
172+
if (typeof name === 'string' && name) names.add(name);
173+
}
174+
return [...names];
175+
}
176+
162177
/**
163178
* The reader the plugin binds: the declarations from
164179
* {@link readDeclaredWebhookItems}, the switch from the activation ledger.

0 commit comments

Comments
 (0)