Skip to content

Commit bc7747c

Browse files
fix(service-datasource)!: on objectstack start, read the metadata service when it is used, so validate and the boot gate compare every federated object (#21887)
Fixes #21876 Clause-②: no (narrowing) ## What this changes `ExternalDatasourceServicePlugin.init()` read the `metadata` service once and kept the answer. `objectstack start` composes no metadata plugin. Its `metadata` service is the kernel's in-memory fallback, which the kernel pre-injects after every plugin's `init()`, just before the start phase. The `start` log shows the order: `Service 'external-datasource' registered`, then `Service 'metadata' registered`, then `Phase 2: Start plugins`. So on `start`, every reader of the kept value saw no service for the life of the process. The plugin now looks up the `metadata` service when each reader runs. It uses a resolver function called at each use, the same pattern `metadataSaveDoor` already follows in this `init()` (AGENTS.md, "Startup registry reads", cure 1). That covers `getDatasource`, `getObject`, `listObjects`, `getNamespace` and the catalog write. The catalog write was a conditional spread, so `init()` decided whether the `persistCatalog` slot existed at all (H2). It is now a getter, as `persistObject` already is. With no metadata service at all, each reader returns the same fallback it always did. Not changed: what validation judges, what each `onMismatch` value does, and the boot gate's code (`packages/runtime`). The import's save call (`persistObject`, landed with #21874) is not changed either. Objects are still read from the metadata service's copy. #21842's PR #21875 moves them to the engine registry. **The object reads, called out.** `getObject` and `listObjects` read the same kept value, so replacing it with a resolver also changes how those two readers are spelled: each calls the resolver instead of using the constant. They read the same members with the same fallback as before. Only the moment the service is looked up moves. Without this, the card's "Done when" cannot hold: on `start`, validate would still list no objects. These are the lines PR #21875 replaces. When #21875 merges `main`, its `getObject` and `listObjects` should replace these, and it should keep this PR's `getDatasource` line. Files: `packages/services/service-datasource/src/plugin.ts`, one new unit file beside it, one new dogfood file, and a `minor` changeset with the `!` banner. No `packages/spec` edit, no new export, and no edit to an existing `packages/qa` file. ## Measured on the real composition (showcase, `objectstack start` and `objectstack dev`) Remote fixture for the probe: `customers.lifetime_value` is TEXT on the remote, while `showcase_ext_customer` declares a currency field. That is a `type_mismatch` at severity `error`. A missing column would not survive the boot, because the showcase's own `onEnable` adds missing columns. A column type it leaves alone. The base build of `service-datasource` is `plugin.ts` at `2e780467`. The branch build is `plugin.ts` at `10dd86129b`, which is this head's `plugin.ts` minus #21874's one-line `writeFace` change from the merge. | `objectstack start` | base | branch | | --- | --- | --- | | boot gate, drift, showcase's `onMismatch: 'warn'` | `all federated objects match their remote schema {"objects":0}` | `external schema drift` (warn) on `showcase_ext_customer`: `type_mismatch` `lifetime_value`, expected `currency`, actual `text` | | boot gate, no drift | (`objects: 0` whatever the remote holds) | `all federated objects match their remote schema {"objects":2}` | | boot gate, drift, datasource set to `onMismatch: 'fail'` for the probe (restored after; blob equals HEAD) | boots, `objects: 0` | **refuses to boot**: "Object 'showcase_ext_customer' does not match its remote table on datasource 'showcase_external': type_mismatch: customers.lifetime_value (expected currency, actual text)" | | `POST /datasources/showcase_external/external/validate` | `{ ok: true, results: [] }` | 2 rows: `showcase_ext_customer` `ok: false` with the `type_mismatch`, `showcase_ext_order` `ok: true` | | `POST …/external/refresh-catalog`, then `GET /meta/external_catalog/showcase_external_catalog` | snapshot answered; the read answers `RESOURCE_NOT_FOUND` (never stored) | snapshot answered; the read returns the stored record | | `GET …/external/tables` | 2 tables | same (the showcase sets no `allowedSchemas`) | | `POST …/tables/customers/draft` | `customers`, with the `TODO(namespace)` note | same (see the namespace note below) | | import `orders` as `probe_ext_orders_21876`, then as `showcase_probe_orders_21876` | 201, 201 | 201, 201 (same note) | | validate after both imports | `results: []` | the 2 code-defined rows only; the imported objects are not listed until restart (H5, #21842's) | **`objectstack dev`, the control (H4):** base and branch gave byte-identical answers from validate, validate-after-import, tables, the catalog read and both imports, after stripping `snapshotAt`. Both boot gates log the same drift warning. `dev` answers exactly what the branch now answers on `start`. **The namespace note.** The showcase's code-defined datasource carries no `_packageId` on either composition, `dev` included, so its namespace never resolves there. The namespace half (the draft's prefix, the import's name check) is pinned in the unit file, with a datasource that carries package provenance. On `start` it was blind on every deployment, and it now answers as on `dev`. **Introspection (`data` service).** It is still read at `init()`. On `start` it is already registered by then: base `start` answered tables, draft and refresh. See Acceptance notes. ## Tests - New `packages/services/service-datasource/src/__tests__/external-metadata-read-at-use.test.ts`: 10 cases, relative import, so it measures `src/`. The `metadata` service is registered AFTER `init()`, as on `start`. Under that ordering: validate compares each federated object and reports the drifted column, and the boot gate's sweep (`validateAll`) lists every federated object. The draft takes the namespace of the datasource's package. An import's explicit name that breaks the prefix is refused, asserting `code: 'EXTERNAL_IMPORT_ERROR'` and `status: 400`, and the save door is never called. The refreshed catalog is persisted. `allowedSchemas` is honoured. The service is looked up again at each use, never remembered. Control: a service registered BEFORE `init()` (the `dev` ordering) gives the same answers. With no metadata service, every reader keeps its fallback. - New `packages/qa/dogfood/test/external-validate-start-ordering.dogfood.test.ts`: 3 cases, booted showcase. The remote's `customers.email` is renamed away after provisioning; the harness does not run `onEnable` at boot, so the drift survives. A precondition asserts that the harness's `metadata` service is the kernel's in-memory fallback, the `start` shape. Validate compares both federated objects and reports `missing_column email`. `validateAll()` lists both. - **Red at base** (`ce28b73809`: the two test files on the base `plugin.ts`): unit 7 failed, 3 passed, for example `expected [] to deeply equal [ 'wh_customer', 'wh_order' ]` and `expected "vi.fn()" to be called 1 times, but got 0 times`. Dogfood 2 failed, 1 passed (the precondition), `expected [] to deeply equal [ 'showcase_ext_customer', … ]`. - **Ablation** at `10dd86129b` (fix committed first), through `scripts/ablation-replace.mjs` in WRAP mode. The resolver line was replaced by a capture taken at `init()` and a resolver returning that capture (anchor hits 1, blob `3f9f1968` to `2dbc3ade`, marker `ABLATION-21876` count 1 on disk, anchor count 0). Unit: 7 of 10 failed, the same 7 as at base. Dogfood: 2 of 3 failed. Restore: blob after restore `3f9f1968` equals HEAD, `git diff HEAD` is empty and `git status` is clean. No `dist/` is on either path: the unit file imports `src/` relatively, and the dogfood config aliases `@objectstack/service-datasource` to `src/`. - **At this head `c6f237478c`** (`origin/main` `e864db56df` merged, which carries #21874): closure build (`@objectstack/dogfood` dependencies plus `service-datasource`) 63 of 63 tasks. `@objectstack/service-datasource`: 38 files, 728 tests passed. `tsc --noEmit` passes, and `--listFiles` includes the new unit file (count 1). Dogfood: the new file plus `external-import-saves-like-meta` and `external-import-destructive-remedy` (the other two files that mount this plugin), 3 files, 10 tests passed. Dogfood `tsc --noEmit` passes, with the new file in `--listFiles` (count 1). **The gap, named.** The dogfood file boots the verify harness, not the `objectstack start` CLI. The harness composes no metadata plugin, so its `metadata` service is the same kernel fallback, injected at the same moment as on `start`. The precondition case holds that. The harness does not mount the boot gate. Mounting it from this file would need `@objectstack/runtime` as a value import, which the dogfood package does not alias. That would mean a new pair in `check:test-source-alias`'s shrink-only ledger or an edit to the dogfood vitest config, and both are outside this dispatch. So the file pins the gate's input (`validateAll()`), and the gate's own verdict on `start` is the probe table above. ## Gates At `c6f237478c`: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derives 67 commands: the dispatch's 59 plus 8 that the changeset brings. All 67 were run and all exit 0. `--ran` reconciles 67 run, 0 NOT-MEASURED, 0 UNRUN. `check:dual-build-cjs-loads` first answered PREREQUISITE NOT MET (exit 3), because 8 unrelated packages had no `dist/`. `check:type-check-debt`'s re-measure later in the same battery built them, and the gate was re-run on the same head to exit 0. Also run: `pnpm check:startup-registry-verdict` exit 0 ("43 startup/open-registry seam(s) … none recording a verdict the boot can contradict"). Narrowed eslint (`--no-inline-config`, `--format json`) on the 3 touched `.ts` files: 3 files, 0 errors, 0 warnings. The changeset `.md` is outside eslint's population: eslint answers "File ignored because no matching configuration was supplied". `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`), so this diff cannot move the verdict on any untouched file. ## Docs No `content/docs` sentence is made false. `data-modeling/external-datasources.mdx` promises a federated datasource is "validated at boot" and that a mismatch "fails boot". That was false on `start` and is now true. ## Acceptance notes - **`engine` (the `data` service) is still read at `init()`.** It is not part of this card. On both `start` and `dev`, `ObjectQLPlugin` registers it in its own `init()`, which runs before this plugin. Base `start` introspected (tables, draft, refresh answered), so nothing was measured wrong. Carrier: none. - **The showcase's code-defined datasources carry no package provenance**, so the federation draft and import never resolve a namespace for them, on `dev` as on `start`. It is reported to the seat with its evidence, not filed from here. - **A federated object saved at runtime** is still listed by the validate door only after the next restart. That is #21842's, fixed by PR #21875 after this lands. --- _Generated by [Claude Code](https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e085a8c commit bc7747c

4 files changed

Lines changed: 543 additions & 17 deletions

File tree

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
---
2+
'@objectstack/service-datasource': minor
3+
---
4+
5+
fix(service-datasource)!: on `objectstack start`, external validation and the boot gate compare every federated object
6+
7+
**BREAKING (narrowing)** — on `objectstack start` a deployment with real schema drift on a
8+
federated object can now refuse to boot, as its `external.validation.onMismatch` setting
9+
declares.
10+
11+
The federation service (`ExternalDatasourceServicePlugin`) read the `metadata` service once,
12+
in its `init()`, and kept the answer. `objectstack start` composes no metadata plugin: its
13+
`metadata` service is the kernel's in-memory fallback, which the kernel registers after every
14+
plugin's `init()`, just before the start phase. So on `start` the federation service kept "no
15+
metadata service" for the life of the process, and every read behind it answered as if the
16+
deployment declared nothing. It now asks for the `metadata` service each time it reads it, so
17+
`start` sees what `objectstack dev` always saw.
18+
19+
| on `objectstack start` | before | now |
20+
| --- | --- | --- |
21+
| the boot gate (ADR-0015 §5.2) | logged "all federated objects match their remote schema" with `objects: 0`, having compared nothing, so no `onMismatch` policy ever applied | compares every federated object and applies each datasource's `onMismatch` to every measured mismatch |
22+
| `POST /api/v1/datasources/:name/external/validate` | `ok: true` with no rows | one row per federated object bound to the datasource, with its diffs |
23+
| `POST /api/v1/datasources/:name/external/refresh-catalog` | answered the snapshot, never stored it | also stores it as the datasource's `external_catalog` record |
24+
| `GET /api/v1/datasources/:name/external/tables` | ignored the datasource's `external.allowedSchemas` | leaves out a table whose schema is outside them |
25+
| draft and import names | never resolved a package namespace: drafts were unprefixed, and an import's explicit `name` was never held to the ADR-0028 prefix rule | resolve the namespace of the datasource's package when the datasource carries package provenance; an import whose explicit `name` lacks that prefix is refused `400 EXTERNAL_IMPORT_ERROR` |
26+
27+
**What to do if a `start` deployment now refuses to boot.** Under `onMismatch: 'fail'` (the
28+
default) the boot stops with `ExternalSchemaMismatchError`, naming the object, the datasource
29+
and each drifted column. Either fix the drift (align the object's fields, its
30+
`external.columnMap` or `external.ignoreColumns`, or the remote table) or set
31+
`external.validation.onMismatch: 'warn'` on that datasource, which boots and logs the drift
32+
instead. To see the drift before deploying, call
33+
`POST /api/v1/datasources/:name/external/validate` on `objectstack dev`, which already
34+
compared. A remote that cannot be reached still never stops the boot. An import refused for
35+
its name takes a `name` carrying the datasource package's namespace prefix.
36+
37+
**Unchanged.** `objectstack dev`, and every composition that registers a metadata plugin before
38+
the federation service, answers exactly as before: measured on the showcase, every federation
39+
door and the boot gate gave the same answers before and after. What validation judges, what
40+
each `onMismatch` value does, and the boot gate's skip for a datasource that sets
41+
`external.validation.checkOnBoot: false` are unchanged.
42+
43+
Clause-②: no (narrowing)
44+
45+
<!-- adr-0087: not-required (no-migration-prescription) No authorable key, spelling, export, config field or stored shape is removed or renamed, and no stored row is read or rewritten: on a composition with no metadata plugin the federation service now reads the metadata service it always declared it reads, so the boot gate and the federation doors judge the objects and datasources the deployment already declares. The handling above is an operator choice between two settings that already exist, not a FROM to TO mapping that objectstack migrate meta could apply. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers when a plugin resolves a service (not registered / already-registered); and the change is runtime behaviour, not a declaration (not runtime-interface-only / type-surface-only). -->
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [#21876] On a composition with no metadata plugin — the `objectstack start`
4+
// shape — the federation service compares every federated object.
5+
//
6+
// ## What was broken
7+
//
8+
// `ExternalDatasourceServicePlugin.init()` read the `metadata` service once and
9+
// kept the answer. `objectstack start` composes no metadata plugin: its
10+
// `metadata` service is the kernel's in-memory fallback, registered after every
11+
// plugin's `init()`, just before the start phase. So the federation service
12+
// kept "no metadata service" for the life of the process. Measured on the
13+
// showcase under `objectstack start`: `POST /datasources/:name/external/validate`
14+
// answered `ok: true` with no rows, and the boot gate logged "all federated
15+
// objects match their remote schema" with `objects: 0` — although two
16+
// federated objects exist, and one of them could have drifted. Under
17+
// `objectstack dev` (which composes the metadata plugin before this one) both
18+
// objects were compared. The service now resolves `metadata` when it is used.
19+
//
20+
// ## Why this composition
21+
//
22+
// The verify harness composes no metadata plugin either, so its `metadata`
23+
// service is the same kernel fallback, registered at the same moment, as on
24+
// `objectstack start`. The first case asserts that, so a harness that one day
25+
// composes a metadata plugin cannot turn this file into a silent pass. The
26+
// harness does not mount the federation service, so this file mounts it, as
27+
// `objectstack dev` and `start` do. It does not mount the boot gate either
28+
// (`createExternalValidationPlugin`, `@objectstack/runtime`): the gate's input
29+
// is the federation service's whole-farm sweep, `validateAll()`, which the
30+
// third case reads directly. The gate's own log on a real `objectstack start`
31+
// is recorded on the pull request that brought this file.
32+
//
33+
// ## The drift
34+
//
35+
// The showcase's remote is a SQLite file its `onEnable` provisions. The file
36+
// is the test's own (a temporary working directory), and after provisioning
37+
// its `customers` table loses the `email` column the code-defined
38+
// `showcase_ext_customer` declares — a real drift, so "compared" and "skipped"
39+
// give different answers.
40+
41+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
42+
import showcaseStack, { onEnable } from '@objectstack/example-showcase';
43+
import { ExternalDatasourceServicePlugin, resolveSqliteDriver } from '@objectstack/service-datasource';
44+
import { bootStack, type VerifyStack } from '@objectstack/verify';
45+
import { mkdtempSync, rmSync } from 'node:fs';
46+
import { tmpdir } from 'node:os';
47+
import { join } from 'node:path';
48+
49+
/** The showcase's external datasource (`showcase-external.datasource.ts`) and its remote file. */
50+
const DATASOURCE = 'showcase_external';
51+
const REMOTE_FILE = '.objectstack/data/showcase_external.db';
52+
/** The code-defined federated objects bound to it. */
53+
const FEDERATED = ['showcase_ext_customer', 'showcase_ext_order'];
54+
55+
interface Row {
56+
object: string;
57+
datasource: string;
58+
ok: boolean;
59+
diffs: Array<{ kind: string; column?: string; severity: string }>;
60+
}
61+
interface Report {
62+
ok: boolean;
63+
results: Row[];
64+
}
65+
66+
const objectsOf = (rows: Row[]) => rows.map((r) => r.object).sort();
67+
68+
/** Rename the remote's `customers.email` away, so the remote no longer has the column the object declares. */
69+
async function driftRemoteCustomers(): Promise<void> {
70+
const { driver } = await resolveSqliteDriver({ filename: REMOTE_FILE, warn: () => undefined });
71+
const remote = driver as unknown as {
72+
connect(): Promise<void>;
73+
disconnect(): Promise<void>;
74+
execute(sql: string): Promise<unknown>;
75+
};
76+
await remote.connect();
77+
try {
78+
await remote.execute('ALTER TABLE customers RENAME COLUMN email TO billing_contact');
79+
} finally {
80+
await remote.disconnect();
81+
}
82+
}
83+
84+
describe('federation on a composition with no metadata plugin (the objectstack start shape)', () => {
85+
let stack: VerifyStack;
86+
let token: string;
87+
let prevCwd: string;
88+
let dir: string;
89+
90+
beforeAll(async () => {
91+
prevCwd = process.cwd();
92+
dir = mkdtempSync(join(tmpdir(), 'dogfood-21876-'));
93+
process.chdir(dir);
94+
// Provision the remote tables, exactly as the showcase does at boot (the
95+
// harness imports only the stack's default export, so `onEnable` never
96+
// runs on its own), then drift one column away.
97+
await onEnable({ logger: { info() {}, warn() {} } } as never);
98+
await driftRemoteCustomers();
99+
stack = await bootStack(showcaseStack, { extraPlugins: [new ExternalDatasourceServicePlugin()] });
100+
token = await stack.signIn();
101+
}, 180_000);
102+
103+
afterAll(async () => {
104+
await stack?.stop();
105+
if (prevCwd) process.chdir(prevCwd);
106+
if (dir) rmSync(dir, { recursive: true, force: true });
107+
});
108+
109+
it('precondition: the metadata service is the kernel\'s in-memory fallback, as on objectstack start', () => {
110+
const metadata = stack.kernel.getService<{ __serviceInfo?: { status?: string; message?: string } }>('metadata');
111+
expect(metadata.__serviceInfo?.status).toBe('degraded');
112+
expect(metadata.__serviceInfo?.message).toMatch(/^In-memory metadata registry/);
113+
});
114+
115+
it('validate compares each federated object, and reports the drifted column', async () => {
116+
const res = await stack.apiAs(token, 'POST', `/datasources/${DATASOURCE}/external/validate`);
117+
const body = (await res.json()) as { data?: Report };
118+
expect(res.status, JSON.stringify(body)).toBe(200);
119+
const report = body.data as Report;
120+
121+
expect(objectsOf(report.results)).toEqual(FEDERATED);
122+
const customer = report.results.find((r) => r.object === 'showcase_ext_customer');
123+
expect(customer?.ok).toBe(false);
124+
expect(customer?.diffs).toEqual([
125+
expect.objectContaining({ kind: 'missing_column', column: 'email', severity: 'error' }),
126+
]);
127+
const order = report.results.find((r) => r.object === 'showcase_ext_order');
128+
expect(order?.ok, JSON.stringify(order)).toBe(true);
129+
expect(report.ok).toBe(false);
130+
});
131+
132+
it('the boot gate\'s sweep (validateAll) counts the real federated objects', async () => {
133+
const service = stack.kernel.getService<{ validateAll(): Promise<Report> }>('external-datasource');
134+
135+
const report = await service.validateAll();
136+
137+
const onShowcase = report.results.filter((r) => r.datasource === DATASOURCE);
138+
expect(objectsOf(onShowcase)).toEqual(FEDERATED);
139+
expect(onShowcase.find((r) => r.object === 'showcase_ext_customer')?.diffs).toEqual([
140+
expect.objectContaining({ kind: 'missing_column', column: 'email' }),
141+
]);
142+
});
143+
});

0 commit comments

Comments
 (0)