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
11 changes: 11 additions & 0 deletions .changeset/21842-validate-reads-live-registry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@objectstack/service-datasource': patch
---

fix(service-datasource): `POST /api/v1/datasources/:name/external/validate` sees a federated object saved at runtime, with no restart (#21842)

Clause-②: no

- **What was wrong.** The federation service read its objects from the `metadata` service. That service holds a copy of the engine's object registry taken once at boot. `PUT /api/v1/meta/object/:name`, and the external-table import that saves through it, write `sys_metadata` and the engine registry, but never that copy. So after a federated object was saved at runtime, validate answered the code-defined objects only, and listed the saved one after a restart. An object re-saved at runtime was judged on its definition as it stood at boot.
- **What it reads now.** `ExternalDatasourceServicePlugin` reads objects (`listObjects` and `getObject`) from the engine's object registry on the `objectql` service, which is the registry the save writes through to. The registry is looked up when validation runs, not when the plugin starts. A saved or imported object is listed and judged on what was saved, the moment the save answers.
- **What does not move.** The comparison is unchanged: the same federation predicate, the same column and type checks, and datasource definitions read from the same place. On `objectstack dev` the boot validation gate sweeps the same objects with the same verdicts as before. No route's request or response shape changes, and no export is added.
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// [#21842, ADR-0015 §6] `POST /datasources/:name/external/validate` lists a
// federated object the moment it is saved at runtime, with no restart, over
// the real showcase composition.
//
// ## What was broken
//
// The federation service read its objects from the `metadata` service, which
// holds a copy of the engine's object registry taken once at boot.
// `PUT /meta/object/:name`, and the external-table import that saves through
// it, write `sys_metadata` and the engine registry, never that copy. Measured
// on the showcase (`objectstack dev`): after a federated object was saved on
// `showcase_external`, validate still answered the two code-defined objects
// only, and listed the saved one after a restart. The service now reads the
// engine registry, resolved when validation runs.
//
// ## What this file pins
//
// Through the doors an operator uses: the code-defined objects are listed;
// an object saved through `PUT /meta/object/:name` is listed with a real
// verdict (it declares a column the remote lacks, and exactly that column is
// reported); an object imported through `…/external/tables/:remote/import` is
// listed beside them. The verify harness composes no metadata plugin, so its
// `metadata` service is the kernel's fallback registered after the
// federation service's `init()`, as on `objectstack start`; the service reads
// it when validation runs ([#21876]), so each row here is a real comparison.
// The seam pins sit beside the plugin
// (`packages/services/service-datasource/src/__tests__/external-validate-reads-live-registry.test.ts`).
//
// The working directory is a temporary one because the showcase's external
// datasource and its fixture both name a cwd-relative SQLite file: this
// file's remote database is its own, not one a parallel file writes.

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import showcaseStack, { onEnable } from '@objectstack/example-showcase';
import { ExternalDatasourceServicePlugin } from '@objectstack/service-datasource';
import { bootStack, type VerifyStack } from '@objectstack/verify';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

/** The showcase's external datasource (`showcase-external.datasource.ts`). */
const DATASOURCE = 'showcase_external';
/** The two objects the showcase binds to it in code (`data/objects/external/`). */
const CODE_DEFINED = ['showcase_ext_customer', 'showcase_ext_order'];
/** Saved at runtime through the metadata door, bound to the remote `customers` table. */
const SAVED = 'dogfood_ext_cust_21842';
/** A column the saved object declares and the remote `customers` table does not have. */
const MISSING = 'loyalty_tier';
/** Imported at runtime from the remote `orders` table. */
const IMPORTED = 'dogfood_ext_ord_21842';

const validatePath = `/datasources/${DATASOURCE}/external/validate`;

interface Row {
object: string;
ok: boolean;
diffs: Array<{ kind: string; column?: string; severity: string }>;
}

function rowsOf(json: unknown): Row[] {
const body = json as { data?: { results?: Row[] } } | undefined;
return body?.data?.results ?? [];
}

function listedObjects(json: unknown): string[] {
return rowsOf(json).map((r) => String(r.object)).sort();
}

const rowOf = (json: unknown, object: string) => rowsOf(json).find((r) => r.object === object);

describe('external validate lists a federated object saved at runtime, with no restart (showcase)', () => {
let stack: VerifyStack;
let token: string;
let prevCwd: string;
let dir: string;

const call = async (method: string, path: string, body?: unknown) => {
const res = await stack.apiAs(token, method, path, body);
const json: unknown = await res.json().catch(() => ({}));
return { status: res.status, json };
};

beforeAll(async () => {
prevCwd = process.cwd();
dir = mkdtempSync(join(tmpdir(), 'dogfood-21842-'));
process.chdir(dir);
// Provision the remote tables, exactly as `os dev` does at boot (the
// harness imports only the stack's default export, so `onEnable` never
// runs on its own).
await onEnable({ logger: { info() {}, warn() {} } } as never);
stack = await bootStack(showcaseStack, {
databaseFile: join(dir, 'showcase.db'),
extraPlugins: [new ExternalDatasourceServicePlugin()],
});
token = await stack.signIn();
}, 180_000);

afterAll(async () => {
await stack?.stop();
if (prevCwd) process.chdir(prevCwd);
if (dir) rmSync(dir, { recursive: true, force: true });
});

it('lists the code-defined objects bound to the datasource', async () => {
const validated = await call('POST', validatePath);
expect(validated.status, JSON.stringify(validated.json)).toBe(200);
expect(listedObjects(validated.json)).toEqual(CODE_DEFINED);
});

it('lists an object saved through PUT /meta/object/:name with a real verdict, and still the code-defined ones', async () => {
const saved = await call('PUT', `/meta/object/${SAVED}`, {
name: SAVED,
label: 'Dogfood External Customer',
sharingModel: 'public_read_write',
datasource: DATASOURCE,
external: { remoteName: 'customers' },
fields: {
name: { type: 'text', label: 'Name' },
email: { type: 'text', label: 'Email' },
[MISSING]: { type: 'text', label: 'Loyalty Tier' },
},
});
expect(saved.status, JSON.stringify(saved.json)).toBe(200);

const validated = await call('POST', validatePath);
expect(validated.status, JSON.stringify(validated.json)).toBe(200);
expect(listedObjects(validated.json)).toEqual([...CODE_DEFINED, SAVED].sort());
// Compared, not waved through: exactly the declared column the remote lacks.
expect(rowOf(validated.json, SAVED)).toMatchObject({
ok: false,
diffs: [{ kind: 'missing_column', remoteName: 'customers', column: MISSING, severity: 'error' }],
});
for (const name of CODE_DEFINED) expect(rowOf(validated.json, name)?.ok, name).toBe(true);
});

it('lists an object imported from a remote table, beside the saved and code-defined ones', async () => {
const imported = await call('POST', `/datasources/${DATASOURCE}/external/tables/orders/import`, { name: IMPORTED });
expect(imported.status, JSON.stringify(imported.json)).toBe(201);

const validated = await call('POST', validatePath);
expect(validated.status, JSON.stringify(validated.json)).toBe(200);
expect(listedObjects(validated.json)).toEqual([...CODE_DEFINED, IMPORTED, SAVED].sort());
expect(rowOf(validated.json, IMPORTED), JSON.stringify(rowOf(validated.json, IMPORTED))).toMatchObject({
ok: true,
diffs: [],
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -136,10 +136,24 @@ interface Harness {
services: Map<string, unknown>;
}

/** A kernel context whose `getService` throws on an unregistered name, as the kernel's does. */
function harness(): Harness {
/**
* A kernel context whose `getService` throws on an unregistered name, as the kernel's does.
*
* [#21842] The federated objects live in the engine's object registry on the
* `objectql` service, which is where the federation service reads objects
* (the registry a runtime save writes through to). The `metadata` fake still
* holds its own copy, as the kernel's fallback does after the startup bridge;
* the readers this file pins are the ones that stay on the metadata service.
*/
function harness(opts: { registry?: boolean } = {}): Harness {
const services = new Map<string, unknown>();
services.set('data', { introspectDatasource: vi.fn(async () => remoteSchema()) });
if (opts.registry !== false) {
const objects = new Map<string, unknown>([[CUSTOMER.name, CUSTOMER], [ORDER.name, ORDER]]);
services.set('objectql', {
registry: { getObject: (n: string) => objects.get(n), getAllObjects: () => [...objects.values()] },
});
}
const ctx = {
getService: (name: string) => {
if (!services.has(name)) throw new Error(`Service '${name}' not found`);
Expand Down Expand Up @@ -257,18 +271,16 @@ describe('the federation service reads a metadata service registered after init

it('asks for the service again at each use, never remembering the first answer', async () => {
const { h, service } = await startOrdering();
expect(objectsOf(await service.validateAll())).toEqual(['wh_customer', 'wh_order']);

const replacement = metadataFake();
await replacement.register('object', 'wh_extra', {
name: 'wh_extra',
datasource: 'warehouse',
external: { remoteName: 'orders' },
fields: {},
});
h.services.set('metadata', replacement.service);
const customerDiffs = async () =>
(await service.validateAll()).results.find((r) => r.object === 'wh_customer')?.diffs;
expect(await customerDiffs()).toEqual([expect.objectContaining({ kind: 'missing_column', column: 'email' })]);

// [#21842] Objects come from the engine registry, so the replacement is
// told apart by the datasource definition it answers: a `managed`
// datasource is not compared against its remote at all.
h.services.set('metadata', metadataFake({ name: 'warehouse', schemaMode: 'managed' }).service);

expect(objectsOf(await service.validateAll())).toEqual(['wh_customer', 'wh_extra', 'wh_order']);
expect(await customerDiffs()).toEqual([]);
});
});

Expand All @@ -292,7 +304,9 @@ describe('control: a metadata service registered before init (the dev ordering)

describe('with no metadata service at all, every reader keeps its fallback', () => {
it('validate and the sweep answer an empty report, the draft a bare name, the catalog an unpersisted snapshot', async () => {
const service = await federation(harness());
// [#21842] No engine registry either: that is where validate's objects
// come from, so with neither service the report has nothing to list.
const service = await federation(harness({ registry: false }));

expect(await service.validateAll()).toEqual({ ok: true, results: [] });
expect((await service.generateObjectDraft('warehouse', 'customers')).name).toBe('customers');
Expand Down
Loading
Loading