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
65 changes: 65 additions & 0 deletions .changeset/slot-lookup-sweep-rest-composition-root.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
---
"@objectstack/rest": patch
---

fix(rest): sweep the REST composition root's slot lookups — 16 sites typed (#4251 B4)

Batch B4 of the #4251 sweep: every service-lookup erasure in the REST
composition root. `rest-api-plugin.ts` (15) and `external-datasource-routes.ts`
(1) now pass the slot's contract type instead of annotating the result `any`;
the ratchet baseline drops **159 → 143 sites, 34 → 32 files**, and both files
leave the grandfather list. No behaviour change.

**Every contract named here is evidenced by an `implements`.** `email`,
`sharing`, `sharingRules`, `reports`, `approvals` and `external-datasource` had
a written `packages/spec` contract all along, and the class each provider
registers into the slot declares `implements` on it (`EmailService implements
IEmailService`, `ExternalDatasourceService implements IExternalDatasourceService`,
…). So the compiler verifies the shape on the producer side on every build and
this file only has to name it — the #4404 discipline that replaced seven
unchecked local stand-ins with one checked claim. `auth`, `objectql`, `i18n`,
`analytics`, `security` and `metadata` come from the `ServiceSlotContracts`
ledger; `objectql` is `IObjectQLEngine`, not `IDataEngine`, because the consumer
reaches the full engine (the `transaction` probe behind the batch routes).

**The wrapper return annotations went with them.** Ten of these lookups sit
inside `async (environmentId?) => Promise<any | undefined>` providers, and
typing only the lookup would have re-erased the contract one line later — the
KNOWN RESIDUAL shape the rule documents and cannot see. Each provider now
returns its slot's contract.

**Three slots have no contract, and say so three different ways rather than one
`any`.** `env-registry` is typed as `RestEnvRegistry`, the shape `RestServer`'s
own constructor declares for that parameter, so the argument is checked rather
than waved through. `settings` gets a named local surface (`SettingsReadSurface`)
following B2's decision for this slot — `service-settings` is optional, so the
REST layer must not depend on it — carrying the one method the platform consumes
(`get`, through `resolveLocalizationContext`'s cascade) with the public
`ResolvedSettingValue` as its return type. `default-project` gets a narrow slice
declaring only the field this file reads. And the service-existence probe, whose
slot name is a runtime argument, is `unknown`: it asks whether something
occupies the slot and never touches its shape, which is exactly what `unknown`
says and `any` does not.

**No dead probe this batch — reported rather than implied.** Every earlier batch
in this line found one (#4361's `getMetaItem` on a service that never had it,
#4321's `registerInMemory`), so each probe the typed consumers make was checked
against its contract: `emailService.send`, `authService.getApi` /
`isAuthGateActive`, `svc.queryDataset`, `ql.transaction`, the six approval
verbs, the five security methods and the five federation methods all name real
members at real arities. The `external-datasource` route probes are now visibly
redundant-but-correct — the contract's methods are required, so `svc?.method` is
truthy whenever the service resolved, and the 503 path is reached only by the
service being absent, which is what it is for.

The new pin is a runtime test, deliberately. `packages/rest` excludes its test
files from `tsconfig.json` and declares no `typecheck` script, so no tsc program
compiles them and a type-level assertion there would evaluate never — the
phantom-check shape #5286 / #5449 paid for. What is checkable is the wiring, and
that is the risk this change actually carries: the providers are positional
arguments 6..19 of a twenty-argument constructor, all with the same
`(environmentId?) => Promise<unknown>` shape, so a provider resolving the wrong
slot is assignable everywhere and invisible to the compiler. The test drives
each provider and asserts it hands back the instance registered in ITS slot,
pins the exact set of slot names the boot resolves, and pins the degraded path
where every optional slot is empty.
19 changes: 14 additions & 5 deletions packages/rest/src/external-datasource-routes.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import type { PluginContext } from '@objectstack/core';
import type { IHttpServer } from '@objectstack/spec/contracts';
import type { IExternalDatasourceService, IHttpServer } from '@objectstack/spec/contracts';
// The declared envelope is written in ONE place for the whole platform (#3973).
import { sendOk, sendError } from '@objectstack/types';

Expand Down Expand Up @@ -62,9 +62,18 @@ export function registerExternalDatasourceRoutes(
): void {
const ext = `${basePath}/datasources/:name/external`;

const externalService = (): any => {
/**
* The `external-datasource` slot's occupant (ADR-0015 §4.5).
*
* [#4251 B4] `IExternalDatasourceService`, which `ExternalDatasourceService`
* declares `implements` — so the five method names and arities this module
* probes are checked against the contract rather than asserted. Returns
* `undefined` when federation is not wired into the host; every route below
* answers 503 in that case, which is why the lookup is allowed to fail.
*/
const externalService = (): IExternalDatasourceService | undefined => {
try {
return ctx.getService<any>('external-datasource');
return ctx.getService<IExternalDatasourceService>('external-datasource');
} catch {
return undefined;
}
Expand Down Expand Up @@ -153,8 +162,8 @@ export function registerExternalDatasourceRoutes(
if (!svc?.validateAll) return unavailable(res);
try {
const report = await svc.validateAll();
const results = (report.results ?? []).filter((r: any) => r.datasource === req.params.name);
sendOk(res, { ok: results.every((r: any) => r.ok), results });
const results = (report.results ?? []).filter((r) => r.datasource === req.params.name);
sendOk(res, { ok: results.every((r) => r.ok), results });
} catch (err) {
refused(res, err);
}
Expand Down
223 changes: 223 additions & 0 deletions packages/rest/src/rest-api-plugin-slot-lookups.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#4251 B4] The REST composition root's slot lookups, pinned at runtime.
*
* `rest-api-plugin.ts` resolves sixteen service slots and hands most of them to
* `RestServer` as lazily-invoked providers. B4 replaced the `any` on every one
* of those lookups with the slot's contract — a change that cannot alter
* behaviour, but CAN silently mis-wire it: the providers are positional
* arguments 6..19 of a twenty-argument constructor, all with the same shape
* (`(environmentId?) => Promise<unknown>`), so a provider that resolves the
* wrong slot name is assignable everywhere and invisible to the compiler.
*
* Why a RUNTIME pin and not a type-level one. `packages/rest/tsconfig.json`
* excludes its `.test.ts` files and the package declares no `typecheck` script
* (it is a DEBT/TEST_DEBT ledger entry), so NO tsc program compiles this file. A
* `@ts-expect-error` or an `Assert< Equal< … > >` written here would evaluate
* never and stay green if it were deleted — the phantom-check shape AGENTS.md
* bans and #5286 / #5449 paid for. What IS checkable here is the wiring, so
* that is what this pins:
*
* 1. every provider resolves the slot it is NAMED for (the mapping the B4
* types assert, verified against the registry), and
* 2. the exact set of slot names the boot asks for — so a retyped literal
* (`'sharingRules'` → `'sharing-rules'`) fails here rather than degrading
* one route to a permanent 501 in production.
*/

import { describe, it, expect, vi } from 'vitest';

const captured = vi.hoisted(() => ({ ctorArgs: [] as unknown[][] }));

// Capture RestServer's constructor arguments without registering ~hundreds of
// routes. Everything else in the module (RestEnvRegistry & co) stays real, so
// the plugin's imports resolve normally.
vi.mock('./rest-server.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('./rest-server.js')>();
return {
...actual,
RestServer: class {
constructor(...args: unknown[]) {
captured.ctorArgs.push(args);
}
registerRoutes(): void {
/* routes are not under test here */
}
},
};
});

const { createRestApiPlugin } = await import('./rest-api-plugin.js');

/**
* The provider arguments, by their position in the `RestServer` constructor.
* `slot` is the service name the provider must resolve — the claim each B4 type
* annotation makes, restated in a form the runtime can check.
*/
const PROVIDERS = [
{ index: 6, label: 'authServiceProvider', slot: 'auth' },
{ index: 7, label: 'objectQLProvider', slot: 'objectql' },
{ index: 8, label: 'emailServiceProvider', slot: 'email' },
{ index: 9, label: 'sharingServiceProvider', slot: 'sharing' },
{ index: 10, label: 'reportsServiceProvider', slot: 'reports' },
{ index: 11, label: 'approvalsServiceProvider', slot: 'approvals' },
{ index: 12, label: 'sharingRulesServiceProvider', slot: 'sharingRules' },
{ index: 13, label: 'i18nServiceProvider', slot: 'i18n' },
{ index: 14, label: 'analyticsServiceProvider', slot: 'analytics' },
{ index: 15, label: 'settingsServiceProvider', slot: 'settings' },
{ index: 17, label: 'securityServiceProvider', slot: 'security' },
{ index: 19, label: 'metadataServiceProvider', slot: 'metadata' },
] as const;

/**
* Every slot name the boot itself resolves, before any route runs.
*
* `external-datasource` is deliberately NOT here: its lookup lives inside a
* per-request closure (`external-datasource-routes.ts`), so registering the
* routes resolves nothing — the federation routes answer 503 per request when
* the service is absent rather than deciding it once at boot.
*/
const BOOT_SLOTS = [
'manifest',
'http.server',
'protocol',
'kernel-manager',
'env-registry',
'kernel-resolver',
'package',
] as const;

function mockServer() {
return {
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
delete: vi.fn(),
patch: vi.fn(),
use: vi.fn(),
listen: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
};
}

/** One distinguishable fake per slot, so "which slot did it read" is provable. */
function allServices(): Record<string, unknown> {
const services: Record<string, unknown> = {
'http.server': mockServer(),
protocol: { getDiscovery: vi.fn() },
manifest: { register: vi.fn() },
'kernel-manager': { getOrCreate: vi.fn() },
'env-registry': { resolveByHostname: vi.fn() },
'kernel-resolver': { resolveKernel: vi.fn() },
'default-project': { environmentId: 'env_only' },
package: { listPackages: vi.fn() },
'external-datasource': { validateAll: vi.fn() },
};
for (const { slot } of PROVIDERS) services[slot] = { __slot: slot };
return services;
}

function mockCtx(services: Record<string, unknown>) {
const asked: string[] = [];
return {
asked,
ctx: {
registerService: vi.fn(),
getService: vi.fn((name: string) => {
asked.push(name);
if (name in services) return services[name];
throw new Error(`Service '${name}' not found`);
}),
getServices: vi.fn(() => new Map(Object.entries(services))),
hook: vi.fn(),
trigger: vi.fn().mockResolvedValue(undefined),
logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
getKernel: vi.fn(),
},
};
}

async function boot(services: Record<string, unknown>) {
captured.ctorArgs.length = 0;
const { ctx, asked } = mockCtx(services);
const plugin = createRestApiPlugin();
await plugin.init?.(ctx as never);
await plugin.start?.(ctx as never);
expect(captured.ctorArgs).toHaveLength(1);
return { args: captured.ctorArgs[0]!, asked, ctx };
}

describe('[#4251 B4] rest-api-plugin slot lookups', () => {
it('resolves each provider from the slot it is named for', async () => {
const services = allServices();
const { args } = await boot(services);

for (const { index, label, slot } of PROVIDERS) {
const provider = args[index] as (environmentId?: string) => Promise<unknown>;
expect(typeof provider, `${label} must be wired at argument ${index}`).toBe('function');
// The provider must hand back the instance registered in ITS slot — not
// a sibling's. Same shape for all of them, so only identity proves it.
await expect(provider('env_1'), `${label} must resolve '${slot}'`).resolves.toBe(
services[slot],
);
}
});

it('passes the env-registry and default-environment seams as RestServer declares them', async () => {
const services = allServices();
const { args } = await boot(services);

// `envRegistry` is a plain instance, not a provider (constructor arg 4).
expect(args[4]).toBe(services['env-registry']);
// `defaultEnvironmentIdProvider` reads the one field this plugin declares
// on the `default-project` slot.
expect((args[5] as () => string | undefined)()).toBe('env_only');
});

it('reports service presence without touching the occupant', async () => {
const services = allServices();
const { args } = await boot(services);
const exists = args[16] as (name: string) => boolean;

expect(exists('analytics')).toBe(true);
// An empty slot throws out of `getService`; the probe answers false rather
// than propagating.
expect(exists('nope-not-registered')).toBe(false);
});

it('asks for exactly the slots it declares, and no others', async () => {
const services = allServices();
const { args, asked } = await boot(services);

// Providers are lazy, so drive every one to make its lookup observable.
for (const { index } of PROVIDERS) {
await (args[index] as (environmentId?: string) => Promise<unknown>)('env_1');
}
(args[5] as () => string | undefined)();

const expected = new Set<string>([
...BOOT_SLOTS,
'default-project',
...PROVIDERS.map((p) => p.slot),
]);
expect(new Set(asked)).toEqual(expected);
});

it('degrades without optional slots — every provider answers undefined, no throw', async () => {
// Only the two slots `start()` hard-requires; every other lookup throws.
const services: Record<string, unknown> = {
'http.server': mockServer(),
protocol: { getDiscovery: vi.fn() },
};
const { args } = await boot(services);

for (const { index, label } of PROVIDERS) {
const provider = args[index] as (environmentId?: string) => Promise<unknown>;
await expect(provider('env_1'), `${label} must degrade to undefined`).resolves.toBeUndefined();
}
expect(args[4]).toBeUndefined();
expect((args[5] as () => string | undefined)()).toBeUndefined();
expect((args[16] as (name: string) => boolean)('analytics')).toBe(false);
});
});
Loading
Loading