Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/31677-package-scoped-declared-webhooks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@objectstack/plugin-webhooks': patch
---

feat: a declared webhook's name resolves by (package, name) through the shared package-scoped resolver (ADR-0150 D3/D4)

Clause-②: no
Metadata-protocol: none

Which declared webhook a name dispatches is now answered by `resolvePackageScopedName` from `@objectstack/objectql`, never by list order. The dispatcher and the by-name reads (activation door, redelivery veto, name guard) read the whole environment's declaration, so each asks as a caller with no package: the environment's own item, else the unique package holder, else a refusal that names every candidate.

Who is affected: a deployment that held one declared webhook name in two packages from before phase 1's one-holder refusal (the cold boot warns about it as a "Bare-name conflict at boot"). That webhook was dispatched from whichever package registered first; it is now not dispatched, the refusal is logged once naming both packages, and the by-name reads answer a `409 RESOURCE_CONFLICT` naming them instead of "not declared". Rename one of the two (the boot warning names the rename). First-party count of such clashes: 0. Every other name resolves exactly as before, since phase 1 still refuses a new clash at install.

`@objectstack/plugin-webhooks` now depends on `@objectstack/objectql` at runtime (it was a dev dependency).
2 changes: 1 addition & 1 deletion packages/plugins/plugin-webhooks/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
},
"dependencies": {
"@objectstack/core": "workspace:*",
"@objectstack/objectql": "workspace:*",
"@objectstack/platform-objects": "workspace:*",
"@objectstack/service-messaging": "workspace:*",
"@objectstack/spec": "workspace:*",
Expand All @@ -45,7 +46,6 @@
"devDependencies": {
"@objectstack/driver-sql": "workspace:*",
"@objectstack/metadata-core": "workspace:*",
"@objectstack/objectql": "workspace:*",
"@types/node": "^26.6.5",
"hono": "^4.13.9",
"tsx": "^4.23.15",
Expand Down
92 changes: 92 additions & 0 deletions packages/plugins/plugin-webhooks/src/declared-webhooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,12 @@
import { describe, expect, it, vi } from 'vitest';
import { AutoEnqueuer, type HttpEnqueueFn } from './auto-enqueuer.js';
import {
DeclaredWebhookAmbiguousError,
DeclaredWebhookSource,
WEBHOOK_ACTIVATION_TYPE,
declaredWebhookRefId,
} from './declared-webhooks.js';
import { SchemaRegistry } from '@objectstack/objectql';
import { createWebhookRedeliverGuard } from './redeliver-guard.js';
import { bindWebhookProvenanceStamp } from './webhook-provenance.js';
import { WebhookOutboxPlugin } from './webhook-outbox-plugin.js';
Expand Down Expand Up @@ -609,3 +611,93 @@ describe('declared webhooks — the redelivery veto', () => {
expect(await guardFor(engine)({ source: 'webhook', refId: 'whk_seeded' })).toBeUndefined();
});
});

// ---------------------------------------------------------------------------
// 4. One declaration per name, by (package, name) — ADR-0150 D3 (epic #31527 child E5)
// ---------------------------------------------------------------------------

describe('declared webhooks — a name resolves by (package, name) (ADR-0150 D3)', () => {
const A = 'com.acme.alpha';
const B = 'com.acme.beta';

/** A real registry with each id installed as a code package shipping `wh` in its own composite slot. */
function registryShipping(packages: Array<[string, any]>): SchemaRegistry {
const registry = new SchemaRegistry();
registry.logLevel = 'silent';
for (const [id, wh] of packages) {
if (!registry.getPackage(id)) {
registry.installPackage({ id, name: id, version: '1.0.0', type: 'app', namespace: id.split('.').pop() } as never, 'code');
}
registry.registerItem('webhook', { ...wh }, 'name' as never, id);
}
return registry;
}

/** The fake engine, its `_registry` the real one. */
function engineOver(registry: SchemaRegistry, seed?: { rows?: Record<string, any[]> }): FakeEngine {
const engine = newEngine(seed);
Object.defineProperty(engine, '_registry', { get: () => registry });
return engine;
}

it('one package holder: the declaration dispatches, carrying its package', async () => {
const engine = engineOver(registryShipping([[A, declaredWebhook()]]));
const source = new DeclaredWebhookSource(engine, undefined);
expect((await source.list()).map((d) => [d.webhook.name, d.packageId])).toEqual([['task_changed', A]]);
expect((await source.declaredWebhookNamed('task_changed'))?.packageId).toBe(A);

const { enqueuer, realtime, calls } = await startEnqueuer(engine);
await realtime.publish(taskCreated());
await flush();
expect(calls).toHaveLength(1);
await enqueuer.stop();
});

it('two package holders: neither is dispatched, and the refusal names both, once', async () => {
const engine = engineOver(registryShipping([
[A, declaredWebhook({ url: 'https://hooks.example/alpha' })],
[B, declaredWebhook({ url: 'https://hooks.example/beta' })],
]));
const warn = vi.fn();
const { enqueuer, realtime, calls, declaredWebhooks } = await startEnqueuer(engine, { warn, info: vi.fn(), error: vi.fn(), debug: vi.fn() });
await realtime.publish(taskCreated());
await flush();
expect(calls).toEqual([]);
expect(await declaredWebhooks.list()).toEqual([]);
await enqueuer.stop();

const refusals = warn.mock.calls.filter(([msg]) => String(msg).includes(`declared webhook 'task_changed' is not dispatched`));
expect(refusals).toHaveLength(1);
expect(refusals[0][0]).toContain(`"${A}"`);
expect(refusals[0][0]).toContain(`"${B}"`);
});

it('two package holders: the by-name read throws the 409 conflict naming both, never "not declared"', async () => {
const engine = engineOver(registryShipping([[A, declaredWebhook()], [B, declaredWebhook()]]));
const source = new DeclaredWebhookSource(engine, undefined);
const err = await source.declaredWebhookNamed('task_changed').then(() => undefined, (e: unknown) => e);
expect(err).toBeInstanceOf(DeclaredWebhookAmbiguousError);
expect(err).toMatchObject({ code: 'RESOURCE_CONFLICT', status: 409, webhook: 'task_changed' });
expect((err as DeclaredWebhookAmbiguousError).candidates).toEqual([
{ kind: 'package', packageId: A },
{ kind: 'package', packageId: B },
]);

// The redelivery veto propagates it: "we could not check" never reads as "allowed".
const guard = createWebhookRedeliverGuard(engine as any, 'sys_webhook', source);
await expect(guard({ source: 'webhook', refId: declaredWebhookRefId('task_changed') }))
.rejects.toBeInstanceOf(DeclaredWebhookAmbiguousError);
});

it('with no registry to resolve against, a name the metadata service lists twice is refused, never first-picked', async () => {
const engine = newEngine();
const metadata = {
list: (type: string) => (type === 'webhook'
? [{ ...declaredWebhook(), _packageId: A }, { ...declaredWebhook({ url: 'https://hooks.example/beta' }), _packageId: B }]
: []),
};
const source = new DeclaredWebhookSource(engine, metadata);
expect(await source.list()).toEqual([]);
await expect(source.declaredWebhookNamed('task_changed')).rejects.toMatchObject({ code: 'RESOURCE_CONFLICT', status: 409 });
});
});
161 changes: 153 additions & 8 deletions packages/plugins/plugin-webhooks/src/declared-webhooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,24 @@
* applied, a malformed item skipped with one warning per name per process,
* never a crashed refresh.
*
* ## One declaration per name, by `(package, name)` (ADR-0150 D3/D4)
*
* A webhook is addressed by `(package, name)`. Which declaration a name
* serves is answered by the ONE package-scoped resolver
* (`resolvePackageScopedName`, `@objectstack/objectql`), never by list order:
* every reader here (the dispatcher, the redelivery veto, the activation door,
* the name guard) is context-free, so it asks as a caller with no package —
* the environment's own item, else the unique package holder, else a refusal
* naming every candidate. A refused name is not dispatched (reported once per
* name per process), and {@link DeclaredWebhookReader.declaredWebhookNamed}
* throws {@link DeclaredWebhookAmbiguousError} for it, so no door reads "not
* declared" for a name two packages declare. While phase 1's one-holder
* refusal stands (`webhook` is one of its types) no two packages hold a name,
* so this answers every name as before. Items the registry does not hold (the
* metadata service's, on a host whose registry holds no webhook) have no
* package coordinate to resolve: one item per name serves, and a name listed
* twice is refused the same way, never first-picked.
*
* ## The switch: regime C (ADR-0131 D6)
*
* A declared webhook is read-only. It is switched on or off for the deployment
Expand Down Expand Up @@ -78,6 +96,13 @@

import { WebhookSchema, type WebhookParsed } from '@objectstack/spec/automation';
import { METADATA_ACTIVATION_TABLE } from '@objectstack/core';
import {
packageScopedRefusalMessage,
resolvePackageScopedName,
type PackageScopedHolder,
type PackageScopedRefusal,
type PackageScopedRegistry,
} from '@objectstack/objectql';

/** System read context — the dispatcher reads its own configuration. */
const SYSTEM_CTX = { isSystem: true } as const;
Expand Down Expand Up @@ -132,10 +157,94 @@ export interface DeclaredWebhook {
export interface DeclaredWebhookReader {
/** Every valid declaration, each with its switch resolved. */
list(): Promise<DeclaredWebhook[]>;
/** The declaration named `name`, switched on or off, or `null` when nothing declares it. */
/**
* The declaration named `name`, switched on or off, or `null` when nothing
* declares it. Throws {@link DeclaredWebhookAmbiguousError} when the name
* resolves to no single declaration (ADR-0150 D3).
*/
declaredWebhookNamed(name: string): Promise<DeclaredWebhook | null>;
}

/**
* [ADR-0150 D3, epic #31527 red line 2] A declared webhook name that resolves
* to no single declaration: two packages declare it, and nothing picks one.
* `code` + `status` are the conflict envelope (409 `RESOURCE_CONFLICT`, the
* catalogue's code for a 409, the status ADR-0150 D5 gives an ambiguous bare
* name); `candidates` names every holder, and so does the message.
*/
export class DeclaredWebhookAmbiguousError extends Error {
readonly code = 'RESOURCE_CONFLICT';
readonly status = 409;
/** The declared webhook name. */
readonly webhook: string;
/** Every holder of the name, in the resolver's order. */
readonly candidates: readonly PackageScopedHolder[];
constructor(refusal: PackageScopedRefusal) {
super(refusal.message);
this.name = 'DeclaredWebhookAmbiguousError';
this.webhook = refusal.name;
this.candidates = refusal.candidates;
}
}

/** The engine's `SchemaRegistry`, when it carries the slice the package-scoped resolver reads. */
function packageScopedRegistryOf(engine: unknown): PackageScopedRegistry | undefined {
let reg: unknown;
try {
reg = (engine as { _registry?: unknown } | undefined)?._registry;
} catch {
return undefined;
}
const r = reg as Partial<Record<keyof PackageScopedRegistry, unknown>> | undefined;
return typeof r?.packageScopedHoldersOf === 'function'
&& typeof r.getPackageScopedItem === 'function'
&& typeof r.getEnvironmentScopedItem === 'function'
&& typeof r.getPackage === 'function'
&& typeof r.getAllPackages === 'function'
? (reg as PackageScopedRegistry)
: undefined;
}

/**
* Which raw declaration serves `name` (ADR-0150 D3, a caller with no
* package): the resolver's answer when the registry holds the name; else the
* one listed item; else a refusal naming every listed holder.
*/
function chooseDeclaration(
name: string,
listed: readonly unknown[],
registry: PackageScopedRegistry | undefined,
): { item: unknown } | { refusal: PackageScopedRefusal } {
if (registry) {
const resolved = resolvePackageScopedName(registry, { type: 'webhook', name });
if (resolved.status === 'resolved') return { item: resolved.item };
if (resolved.status === 'refused') return { refusal: resolved };
// not-found: the listed items are not the registry's (the metadata service's).
}
if (listed.length === 1) return { item: listed[0] };
const candidates: PackageScopedHolder[] = [];
const seen = new Set<string>();
for (const item of listed) {
const pkg = (item as { _packageId?: unknown })?._packageId;
const holder: PackageScopedHolder = typeof pkg === 'string' && pkg !== '' && pkg !== 'sys_metadata'
? { kind: 'package', packageId: pkg }
: { kind: 'environment' };
const key = holder.kind === 'package' ? holder.packageId : '';
if (seen.has(key)) continue;
seen.add(key);
candidates.push(holder);
}
const refusal = {
status: 'refused' as const,
reason: 'ambiguous-in-environment' as const,
type: 'webhook' as const,
name,
callerPackageId: null,
candidates,
};
return { refusal: { ...refusal, message: packageScopedRefusalMessage(refusal) } };
}

/**
* The raw declared `webhook` items, registry first, metadata service second.
* Never throws: a reader that cannot answer yields nothing from that reader.
Expand Down Expand Up @@ -181,6 +290,10 @@ export function readDeclaredWebhookNames(engine: unknown, metadataService: unkno
export class DeclaredWebhookSource implements DeclaredWebhookReader {
/** Names already reported as failing validation — said once per process. */
private readonly invalidReported = new Set<string>();
/** Names already reported as resolving to no single declaration — said once per process. */
private readonly refusalReported = new Set<string>();
/** The names the last read refused (ADR-0150 D3), each with its refusal. */
private refusals = new Map<string, PackageScopedRefusal>();

constructor(
private readonly engine: unknown,
Expand All @@ -201,13 +314,48 @@ export class DeclaredWebhookSource implements DeclaredWebhookReader {

async declaredWebhookNamed(name: string): Promise<DeclaredWebhook | null> {
if (!name) return null;
return (await this.list()).find((d) => d.webhook.name === name) ?? null;
const declared = await this.list();
const refusal = this.refusals.get(name);
if (refusal) throw new DeclaredWebhookAmbiguousError(refusal);
return declared.find((d) => d.webhook.name === name) ?? null;
}

private parseDeclared(): Array<{ webhook: WebhookParsed; packageId?: string }> {
const out: Array<{ webhook: WebhookParsed; packageId?: string }> = [];
const seen = new Set<string>();
for (const raw of readDeclaredWebhookItems(this.engine, this.metadataService)) {
const raws = readDeclaredWebhookItems(this.engine, this.metadataService);
const listedByName = new Map<string, unknown[]>();
for (const raw of raws) {
const name = (raw as { name?: unknown })?.name;
if (typeof name !== 'string' || name === '') continue;
const listed = listedByName.get(name);
if (listed) listed.push(raw);
else listedByName.set(name, [raw]);
}
const registry = packageScopedRegistryOf(this.engine);
const refusals = new Map<string, PackageScopedRefusal>();
const answered = new Set<string>();
for (const listedRaw of raws) {
let raw = listedRaw;
const listedName = (listedRaw as { name?: unknown })?.name;
// A name serves ONE declaration (ADR-0150 D3): asked once, at its
// first listing; a nameless item goes on to fail validation below.
if (typeof listedName === 'string' && listedName !== '') {
if (answered.has(listedName)) continue;
answered.add(listedName);
const chosen = chooseDeclaration(listedName, listedByName.get(listedName) ?? [listedRaw], registry);
if ('refusal' in chosen) {
refusals.set(listedName, chosen.refusal);
if (!this.refusalReported.has(listedName)) {
this.refusalReported.add(listedName);
this.logger?.warn?.(
`[webhook] declared webhook '${listedName}' is not dispatched: ${chosen.refusal.message}`,
{ name: listedName, candidates: chosen.refusal.candidates },
);
}
continue;
}
raw = chosen.item;
}
let webhook: WebhookParsed;
try {
webhook = WebhookSchema.parse(raw);
Expand All @@ -222,13 +370,10 @@ export class DeclaredWebhookSource implements DeclaredWebhookReader {
}
continue;
}
// Two registrations of one name: the first wins, as the seeder's
// name-keyed upsert let the first one hold the row.
if (seen.has(webhook.name)) continue;
seen.add(webhook.name);
const pkg = (raw as { _packageId?: unknown })._packageId;
out.push({ webhook, ...(typeof pkg === 'string' && pkg ? { packageId: pkg } : {}) });
}
this.refusals = refusals;
return out;
}
}
Expand Down
7 changes: 7 additions & 0 deletions packages/plugins/plugin-webhooks/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,13 @@ export default defineConfig({
// Mechanism + measured costs: examples/app-showcase/vitest.config.ts.
// Enforced repo-wide by scripts/check-console-intercept-disarm.mjs.
disableConsoleIntercept: true,
// #13517: quiet the registry's per-item registration chatter — the
// engine's own `OS_REGISTRY_LOG` seam, not a change to its shipped
// default. Enforced by scripts/check-registry-log-declared.mjs, which
// reads this suite as engine-booting since `declared-webhooks.test.ts`
// resolves declared webhook names through a real `SchemaRegistry`
// (ADR-0150 D3).
env: { OS_REGISTRY_LOG: 'warn' },
},
resolve: {
alias: [
Expand Down
Loading
Loading