Skip to content

Commit 07e933b

Browse files
fix(service-datasource)!: Import as Object saves through the metadata door's save (#21837)
Fixes #21788 Clause-②: no (narrowing) ## What this changes `ExternalDatasourceServicePlugin` wired the import's `persistObject` to `metadata.register('object', name, definition)`. That held the generated object in the metadata service's memory and did nothing else: no `sys_metadata` row, no storage sync, and the SQL driver was never told the object's `external.remoteName`. `persistObject` now calls `saveMetaItem` on the `protocol` service, the save `PUT /api/v1/meta/object/:name` makes, with the request that door sends for an `object` (`{ type: 'object', name, item }`: no organization because `object` is not org-overridable, and no package, mode or force because the import route takes none). That save persists the row, writes it through to the engine registry and runs `syncObjectSchema`, which maps a federated object onto its remote table. There is no second registration path beside it. The save door is looked up when an import runs (a getter on the service config), not at `init()`. The `protocol` service registers in another plugin's `init()`, and a verdict drawn at this plugin's `init()` would be kept for the life of the process. With no save door registered, the service's existing "requires a writable metadata store" refusal still fires before any remote introspection. The import route's request and response shapes are unchanged. No `packages/spec` edit, and no new export. ## Measured on the real composition (`pnpm dev`, showcase, at this branch) | step | before (base `3237b4a2`) | after (`2beb1441`) | |:--|:--|:--| | `POST …/tables/customers/import {"name":"ext_cust"}` | 201 | 201 | | `GET /data/ext_cust` | **500 DATABASE_ERROR**, log `no such table: ext_cust` | 200, the 3 remote rows | | `POST …/tables/orders/import {"name":"orders"}`, then `GET /data/orders` | 201, 200 | 201, 200 | | `sys_metadata` rows for `ext_cust` / `orders` | none | both, env-wide, `active` | | restart on the same DB, `GET /data/ext_cust` and `/data/orders` | **404 OBJECT_NOT_FOUND** both | 200 both, with rows | | control: `PUT /meta/object/ext_cust_meta` (same binding) | 200, rows, survives restart | unchanged | ## Tests - New `packages/services/service-datasource/src/__tests__/external-import-saves-through-metadata-door.test.ts` (4 cases, relative import, measures `src/`): the import reaches `saveMetaItem` with the door's request and never calls `metadata.register`; a `protocol` registered after `init()` still receives the save; a refused save refuses the import with the door's own error (`code` and `status` asserted); with no save door, the import is refused before introspection. - `@objectstack/service-datasource`: 36 files, 713 tests pass; `tsc --noEmit` passes, and its program includes the new test file (`--listFiles`). - Gates at `9f5279c8` (this branch after merging `origin/main` `088428fb`): all 63 commands `dispatch-gates --commands` derives exit 0, and `--ran` reconciles 63 of 63 with 0 NOT-MEASURED. Narrowed eslint (`--no-inline-config`) on the two touched `.ts` files: 2 files, 0 findings. The config enables no type-aware linting (no `parserOptions.project`), so this diff cannot move the verdict on any untouched file. - Ablation, committed fix first, through `scripts/ablation-replace.mjs` with `persistObject` put back to register-only: the unit file went 3 failed and 1 passed (the no-save-door case is untouched by that mutation, as expected). The booted-stack pin described below went 3 failed and 1 passed, with `404 OBJECT_NOT_FOUND` on both reads and after the restart. The restore leg proved the blob equal to HEAD, rebuilt `dist/`, and `ablation-dist-preflight --absent` was clean on the whole tree. Both files were 4 of 4 green again. ## Not in this PR, and why - **The card's dogfood pin is written and measured, but not committed.** It imports under a different name, reads the rows, restarts on the same DB file, and reads again, with a same-name import as the control. The verify harness does not mount `ExternalDatasourceServicePlugin`, so the pin has to import it. Committing that needs `@objectstack/service-datasource` declared in `packages/qa/dogfood/package.json`, plus a source alias in `packages/qa/dogfood/vitest.config.ts`, because `check:test-source-alias` refuses a new unaliased dogfood import and its ledger only shrinks. Both are existing `packages/qa` files outside this dispatch's file surface, so the pin waits for the seat. It was run locally through an untracked `node_modules` link: green on the fix, and red under the ablation above. - **A durable federated object can abort the next boot.** This is measured, and it already happens through `PUT /meta/object`, whose code this PR does not touch. After a restart, Gate 2 (`ExternalValidationPlugin`) reads a stored federated object with the platform-injected anchors (`organization_id`, `created_by`, `updated_by`, `owner_id`, `owning_business_unit_id`) and reports each one as a `missing_column` on the remote table. On a datasource with the default `external.validation.onMismatch: 'fail'`, the boot then aborts with "Object 'NAME' does not match its remote table". This PR makes imports durable, so an import on such a datasource reaches that same abort at the next restart, where it used to vanish. A measured remedy, kept out of this PR's diff: have `validateObject` skip `unprovisionedInjectedColumns(obj)` (`@objectstack/spec/data`). With that, the same database booted and both datasources validated `ok: true`. The landing order goes back to the seat. - **A re-import that drops a column is refused with a remedy this route cannot take.** The door's destructive-change check now applies: `409 DESTRUCTIVE_CHANGE`, answered as `400 EXTERNAL_IMPORT_ERROR`, says "re-submit with ?force=true to proceed", and the import route reads no `force`. The text comes from `destructiveChangeRemedy` in `@objectstack/metadata-protocol`, which has no face for this door. ## Acceptance notes - The history row of an imported object records `recorded_by: null`. The door records the caller's user id. The import route does not pass its caller to `IExternalDatasourceService.importObject`, and that contract has no actor parameter. - Until a restart, `POST …/external/validate` does not list an object saved through the door, and that now includes an import. The federation service reads objects from the metadata service, which picks up `sys_metadata` objects only at boot. Before this change, an import sat in that service's memory, so it was presumably listed until the restart and then disappeared. That half was not measured on the base. - Under the verify harness, `ExternalDatasourceServicePlugin` mounted as an extra plugin reads the `metadata` service at `init()` and finds none. So `persistCatalog` (`external_catalog`) is never wired there. That decision is recorded at `init()`, the same shape this PR removes from `persistObject`. `persistCatalog` is untouched here. ## Patch round 1 (head 8d640fa) _Appended by the seat (`domain:services#1`, `session_011K3zqE8Pv1Evw5hc8tZCnN`) from the dev's patch-round report `5991725015`, after seat verdict `5990234116`. Line 2 was corrected in the same act (the claim amendment is in that verdict)._ `#21841` (the import route's `?force` prescription) and `#21842` (the validate door) are not addressed here. **Q1: the card's dogfood pin is committed.** `packages/qa/dogfood/test/external-import-saves-like-meta.dogfood.test.ts` has three cases: - an import under a name that differs from its remote table serves the remote rows; - the same-name control serves its rows; - after a cold boot on the same database file, both still serve their rows. To make that possible: - `@objectstack/service-datasource` is declared in `packages/qa/dogfood/package.json`; - its lockfile importer entry was regenerated by `pnpm install`. The same run also wrote `deprecated:` registry metadata onto twelve `@yuku-analyzer/binding-*` entries; none of it was hand-edited; - `packages/qa/dogfood/vitest.config.ts` aliases the package to source, in the shape `check:test-source-alias` reads. No other `packages/qa` file is edited. This is declared on #6024 (`5990241427`). **Q2: validation skips the platform's unprovisioned injected anchors.** - `validateObjectUsing` (`external-datasource-service.ts`) skips the columns `unprovisionedInjectedColumns(obj)` names. It reuses the spec's own provenance predicate and builds no second list. - This also closes the boot abort for federated objects saved through `PUT /api/v1/meta/object/:name`, which `main` has today. - A field an author declares under an anchor's name is still compared. - **Negative control:** a declared column the remote lacks is still `missing_column` at `error`. On `pnpm dev` a default-policy datasource still aborts the boot on that real drift (measured, not a committed booted pin). **Q3: declaration.** - `Clause-②: no (narrowing)`, BREAKING: some re-imports that answered `201` now answer `400`. - The changeset is `minor`, with the `!` title, the banner and one measured handling line. - ADR-0087: `not-required (no-migration-prescription)`, accepted by the gate. No authorable key, export, config field or stored shape moves, so there is nothing for `migrate meta` to rewrite. **Measured at `8d640fa2`:** - `service-datasource`: 37 files, 717 tests pass; `tsc` exit 0. - The new validation pins pass, 4 of 4, including the negative control. The dogfood pin passes, 3 of 3. `@objectstack/dogfood` typecheck exit 0. - **Ablation 1** (`persistObject` back to register-only): the dogfood pin went 3 of 3 red (`404 OBJECT_NOT_FOUND`). The restore was proven. - **Ablation 2** (the anchor skip removed): 3 of 4 validation pins red. The restore was proven. - `pnpm dev`, a default-policy datasource with one imported and one door-saved object: the restart boots, and validate answers `ok: true`. - `dispatch-gates`: 78 derived, all exit 0. `--ran`: 78 of 78, 0 NOT-MEASURED. **Acceptance notes (added by this round):** - `persistCatalog` (the `external_catalog` snapshot) still goes through `metadata.register` only, decided at `init()`. Its durability on `pnpm dev` is NOT MEASURED. - An imported object's history row records no actor (`recorded_by` null), measured. The import route does not pass its caller to `IExternalDatasourceService.importObject`, and that contract has no actor parameter. - An import named after a packaged object whose fields do not conflict would save an env-wide overlay of it, exactly as `PUT /meta/object` with the same body does. It was not measured on a compatible body; the same door semantics apply. --- _Generated by [Claude Code](https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0fe0a59 commit 07e933b

9 files changed

Lines changed: 512 additions & 6 deletions
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/service-datasource': minor
3+
---
4+
5+
fix(service-datasource)!: "Import as Object" saves the imported federated object through the metadata door's own save, so it is durable and reads from its remote table; federated validation stops reporting the platform's injected anchors as missing remote columns (#21788)
6+
7+
**BREAKING** — the import route now answers `400` to some re-imports that used to answer `201`.
8+
9+
Clause-②: no (narrowing)
10+
11+
- **The import was neither durable nor mapped.** `POST /api/v1/datasources/:name/external/tables/:remote/import` held the generated object in the metadata service's memory only. No `sys_metadata` row was written, the object's storage was not synced, and the driver was never told the object's remote table. An object imported under a name that differs from its remote table answered `201` and then `500 DATABASE_ERROR` (`no such table: <name>`) on its first read. Every import was gone after a restart (`404 OBJECT_NOT_FOUND`).
12+
- **It now saves like `PUT /api/v1/meta/object/:name`.** The import calls `saveMetaItem` on the `protocol` service with the request that door sends for an `object`. The object becomes a `sys_metadata` row the next boot binds, it is written through to the engine registry, and it is mapped onto its `external.remoteName` table. An import under a different name and one under the remote table's own name both serve the remote rows, before and after a restart. The save door is looked up when an import runs. A deployment with no metadata save door still refuses the import with "requires a writable metadata store", before any remote introspection.
13+
- **What narrows.** The metadata door's refusals now apply to the import, and the route relays each one as `400 EXTERNAL_IMPORT_ERROR` with the door's message. A re-import that would drop a field the stored object already has, or change its type, is refused as a destructive change. It used to answer `201` with an in-memory overwrite that a restart discarded. An import whose `name` collides with an object the metadata door will not overwrite is refused the same way. The route's request and response shapes are unchanged.
14+
- **If a re-import or a name is refused:** import the table again under a new `name`. To change an object you already imported, save its new definition through `PUT /api/v1/meta/object/:name?force=true`, which accepts the destructive change on purpose. Re-submitting the import with `?force=true` does not help, because the import route reads no `force`.
15+
- **Federated validation compares only what the remote owns.** A stored federated object is read back with the anchors the platform injects without storage (`organization_id`, `created_by`, `updated_by`, `owner_id`, `owning_business_unit_id`). The boot validation gate and `POST …/external/validate` reported each of them as a `missing_column` at error severity. So once a datasource with the default `external.validation.onMismatch: 'fail'` held such an object, it refused to boot. Validation now skips the columns `unprovisionedInjectedColumns` names. This closes the boot abort for objects saved through `PUT /api/v1/meta/object/:name`, not only for imports. A declared column the remote lacks is still a `missing_column` at error severity, and `fail` still aborts on it.
16+
17+
<!-- adr-0087: not-required (no-migration-prescription) a runtime refusal on one REST route: the external-table import now relays the metadata door's own save refusals (a destructive re-import, a name that door will not overwrite) as 400 where it answered 201 with an in-memory write no restart kept. No authorable metadata key, spelling, export, config field or stored shape moves: no schema changes, no sys_metadata row is read or rewritten, and which table or name an operator meant to re-import is not something a ledger entry can rewrite. The validation half refuses less, not more. The other categories are closed on facts: @objectstack/service-datasource publishes (not unpublished); no ADR-0087 id covers this route (not already-registered); and the change is a runtime verdict, not a declaration (not runtime-interface-only or type-surface-only). -->

‎packages/qa/dogfood/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
"@objectstack/plugin-sharing": "workspace:*",
3131
"@objectstack/plugin-webhooks": "workspace:*",
3232
"@objectstack/service-analytics": "workspace:*",
33+
"@objectstack/service-datasource": "workspace:*",
3334
"@objectstack/service-messaging": "workspace:*",
3435
"@objectstack/service-storage": "workspace:*",
3536
"@objectstack/spec": "workspace:*",
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [#21788, ADR-0015 Addendum] "Import as Object" keeps what it answers `201`
4+
// for — over the real showcase composition, across a cold boot on one
5+
// database file.
6+
//
7+
// ## What was broken
8+
//
9+
// `POST /datasources/:name/external/tables/:remote/import` persisted the
10+
// generated object with `metadata.register('object', …)`, which held it in the
11+
// metadata service's memory only: no `sys_metadata` row, no engine schema
12+
// sync, no external-object registration with the driver. Measured on the
13+
// showcase before the fix: an import under a name that differs from its remote
14+
// table answered `201` and then `500 DATABASE_ERROR` on its first read (the
15+
// SQL driver resolved the table by the object's name — `no such table`), an
16+
// import whose name equals the remote table answered `200` only because the
17+
// two names coincide, and after a restart on the same database both answered
18+
// `404 OBJECT_NOT_FOUND`. `PUT /meta/object/:name` with the same binding was
19+
// durable and served the rows. The import now saves through that door's save.
20+
//
21+
// ## Why a booted stack
22+
//
23+
// The seam pins sit beside the plugin
24+
// (`packages/services/service-datasource/src/__tests__/external-import-saves-through-metadata-door.test.ts`).
25+
// What they cannot answer is whether, on the stack an operator runs, the save
26+
// maps the object onto its remote table and lands a row the next boot binds.
27+
// Only a read through the data door and a restart on the same file show that.
28+
//
29+
// The verify harness does not mount the federation service, so this file
30+
// mounts `ExternalDatasourceServicePlugin` itself, as `objectstack dev` and
31+
// `serve` do. The working directory is a temporary one because the showcase's
32+
// external datasource and its fixture both name a cwd-relative SQLite file:
33+
// this file's remote database is its own, not one a parallel file writes.
34+
35+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
36+
import showcaseStack, { onEnable } from '@objectstack/example-showcase';
37+
import { ExternalDatasourceServicePlugin } from '@objectstack/service-datasource';
38+
import { bootStack, type VerifyStack } from '@objectstack/verify';
39+
import { mkdtempSync, rmSync } from 'node:fs';
40+
import { tmpdir } from 'node:os';
41+
import { join } from 'node:path';
42+
43+
/** The showcase's external datasource and its two remote tables (`external-fixture.ts`). */
44+
const DATASOURCE = 'showcase_external';
45+
/** Imported under a name that differs from its remote table — the defect's shape. */
46+
const RENAMED = 'dogfood_ext_cust_21788';
47+
/** Imported under the remote table's own name — the shape that hid the defect. */
48+
const SAME_NAME = 'orders';
49+
50+
const importPath = (remote: string) => `/datasources/${DATASOURCE}/external/tables/${remote}/import`;
51+
52+
function recordsOf(json: unknown): Array<Record<string, unknown>> {
53+
const body = json as { records?: unknown[] } | undefined;
54+
return (body?.records ?? []) as Array<Record<string, unknown>>;
55+
}
56+
57+
async function boot(databaseFile: string): Promise<VerifyStack> {
58+
return bootStack(showcaseStack, { databaseFile, extraPlugins: [new ExternalDatasourceServicePlugin()] });
59+
}
60+
61+
describe('Import as Object persists like the metadata door (showcase, cold boot)', () => {
62+
let stack: VerifyStack;
63+
let token: string;
64+
let prevCwd: string;
65+
let dir: string;
66+
let dbFile: string;
67+
68+
const call = async (method: string, path: string, body?: unknown) => {
69+
const res = await stack.apiAs(token, method, path, body);
70+
const json: unknown = await res.json().catch(() => ({}));
71+
return { status: res.status, json };
72+
};
73+
74+
beforeAll(async () => {
75+
prevCwd = process.cwd();
76+
dir = mkdtempSync(join(tmpdir(), 'dogfood-21788-'));
77+
process.chdir(dir);
78+
dbFile = join(dir, 'showcase.db');
79+
// Provision the remote tables, exactly as `os dev` does at boot (the
80+
// harness imports only the stack's default export, so `onEnable` never
81+
// runs on its own).
82+
await onEnable({ logger: { info() {}, warn() {} } } as never);
83+
stack = await boot(dbFile);
84+
token = await stack.signIn();
85+
}, 180_000);
86+
87+
afterAll(async () => {
88+
await stack?.stop();
89+
if (prevCwd) process.chdir(prevCwd);
90+
if (dir) rmSync(dir, { recursive: true, force: true });
91+
});
92+
93+
it('an object imported under a name that differs from its remote table serves the remote rows', async () => {
94+
const imported = await call('POST', importPath('customers'), { name: RENAMED });
95+
expect(imported.status, JSON.stringify(imported.json)).toBe(201);
96+
97+
const read = await call('GET', `/data/${RENAMED}`);
98+
expect(read.status, JSON.stringify(read.json)).toBe(200);
99+
expect(recordsOf(read.json).map((r) => r.name)).toEqual(
100+
expect.arrayContaining(['Aurora Labs', 'Borealis GmbH', 'Cyan Pacific']),
101+
);
102+
});
103+
104+
it('control: an object imported under its remote table\'s own name serves the remote rows', async () => {
105+
const imported = await call('POST', importPath('orders'), { name: SAME_NAME });
106+
expect(imported.status, JSON.stringify(imported.json)).toBe(201);
107+
108+
const read = await call('GET', `/data/${SAME_NAME}`);
109+
expect(read.status, JSON.stringify(read.json)).toBe(200);
110+
expect(recordsOf(read.json)).toHaveLength(4);
111+
});
112+
113+
it('after a cold boot on the same database file, both imported objects still serve their rows', async () => {
114+
await stack.stop();
115+
stack = await boot(dbFile);
116+
token = await stack.signIn();
117+
118+
// Independent facts about one restart, so each is reported on its own.
119+
const renamed = await call('GET', `/data/${RENAMED}`);
120+
expect.soft(renamed.status, JSON.stringify(renamed.json)).toBe(200);
121+
expect.soft(recordsOf(renamed.json)).toHaveLength(3);
122+
123+
const sameName = await call('GET', `/data/${SAME_NAME}`);
124+
expect.soft(sameName.status, JSON.stringify(sameName.json)).toBe(200);
125+
expect.soft(recordsOf(sameName.json)).toHaveLength(4);
126+
}, 180_000);
127+
});

‎packages/qa/dogfood/vitest.config.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,15 @@ export default defineConfig({
264264
find: /^@objectstack\/cloud-connection$/,
265265
replacement: path.resolve(__dirname, '../../cloud-connection/src/index.ts'),
266266
},
267+
// [#21788] `external-import-saves-like-meta.dogfood.test.ts` mounts
268+
// `ExternalDatasourceServicePlugin` on a real boot (the harness
269+
// does not) and drives the import door. The plugin's save path is
270+
// the pin's subject, so the verdict is aliased to THIS checkout's
271+
// source, not to the last `pnpm build`.
272+
{
273+
find: /^@objectstack\/service-datasource$/,
274+
replacement: path.resolve(__dirname, '../../services/service-datasource/src/index.ts'),
275+
},
267276
],
268277
},
269278
test: {
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#21788] "Import as Object" saves through the metadata door's own save.
5+
*
6+
* ## The defect this pins closed
7+
*
8+
* `ExternalDatasourceServicePlugin` wired `persistObject` to
9+
* `metadata.register('object', name, definition)`. That put the definition in
10+
* the metadata service's memory and nothing else: no `sys_metadata` row, no
11+
* engine schema sync, no external-object registration with the driver. So an
12+
* object imported under a name that differs from its remote table answered
13+
* `201` and then `500 no such table: <name>` (the SQL driver resolved the
14+
* table by the object's name), and every import was gone after a restart.
15+
* `PUT /api/v1/meta/object/:name` with the same body was durable and served
16+
* the rows, because it saves through `saveMetaItem` on the `'protocol'`
17+
* service — the one save that persists the row, writes it through to the
18+
* engine registry and syncs the object's storage (`syncObjectSchema`, which
19+
* for a federated object maps the remote table).
20+
*
21+
* ## What each case pins
22+
*
23+
* - the import reaches `saveMetaItem` with the request the metadata door
24+
* sends for an `object` (env-wide: `object` is not org-overridable), and
25+
* registers through no second path;
26+
* - the save door is resolved when the import runs, not at `init()` — a
27+
* protocol registered after this plugin still receives the save;
28+
* - a save the door refuses refuses the import with the door's own error;
29+
* - with no save door at all, the import is refused before any remote
30+
* introspection and nothing is saved.
31+
*
32+
* The plugin is imported through a RELATIVE specifier, so these cases measure
33+
* `src/` and need no build. The booted-stack half (import, read rows, restart,
34+
* read again) is the dogfood pin's.
35+
*/
36+
37+
import { describe, it, expect, vi } from 'vitest';
38+
import type { PluginContext } from '@objectstack/core';
39+
import type { IntrospectedSchema, IExternalDatasourceService } from '@objectstack/spec/contracts';
40+
import { ExternalDatasourceServicePlugin } from '../plugin.js';
41+
42+
const remoteSchema = (): IntrospectedSchema =>
43+
({
44+
dialect: 'sqlite',
45+
tables: {
46+
customers: {
47+
name: 'customers',
48+
columns: [
49+
{ name: 'id', type: 'text', nullable: false, primaryKey: true },
50+
{ name: 'name', type: 'text', nullable: true, primaryKey: false },
51+
],
52+
},
53+
},
54+
}) as unknown as IntrospectedSchema;
55+
56+
interface Harness {
57+
ctx: PluginContext;
58+
services: Map<string, unknown>;
59+
introspect: ReturnType<typeof vi.fn>;
60+
register: ReturnType<typeof vi.fn>;
61+
}
62+
63+
/** A kernel context whose `getService` throws on an unregistered name, as the kernel's does. */
64+
function harness(): Harness {
65+
const services = new Map<string, unknown>();
66+
const introspect = vi.fn(async () => remoteSchema());
67+
const register = vi.fn(async () => undefined);
68+
services.set('data', { introspectDatasource: introspect });
69+
services.set('metadata', {
70+
get: async (type: string, name: string) =>
71+
type === 'datasource' ? { name, schemaMode: 'external' } : undefined,
72+
register,
73+
});
74+
const ctx = {
75+
getService: (name: string) => {
76+
if (!services.has(name)) throw new Error(`Service '${name}' not found`);
77+
return services.get(name);
78+
},
79+
registerService: (name: string, service: unknown) => {
80+
services.set(name, service);
81+
},
82+
trigger: async () => undefined,
83+
logger: { info() {}, warn() {}, error() {}, debug() {} },
84+
} as unknown as PluginContext;
85+
return { ctx, services, introspect, register };
86+
}
87+
88+
async function federation(h: Harness): Promise<IExternalDatasourceService> {
89+
await new ExternalDatasourceServicePlugin().init(h.ctx);
90+
return h.services.get('external-datasource') as IExternalDatasourceService;
91+
}
92+
93+
describe('importObject saves through the metadata door (#21788)', () => {
94+
it('reaches saveMetaItem with the metadata door\'s object request, and registers through no second path', async () => {
95+
const h = harness();
96+
const saveMetaItem = vi.fn(async () => ({ success: true }));
97+
h.services.set('protocol', { saveMetaItem });
98+
99+
const result = await (await federation(h)).importObject('warehouse', 'customers', { name: 'ext_cust' });
100+
101+
expect(saveMetaItem).toHaveBeenCalledTimes(1);
102+
expect(saveMetaItem).toHaveBeenCalledWith({ type: 'object', name: 'ext_cust', item: result.definition });
103+
expect(result.definition).toMatchObject({
104+
name: 'ext_cust',
105+
datasource: 'warehouse',
106+
external: { remoteName: 'customers' },
107+
});
108+
expect(h.register).not.toHaveBeenCalled();
109+
});
110+
111+
it('resolves the save door when the import runs, so a protocol registered after init still receives it', async () => {
112+
const h = harness();
113+
const service = await federation(h);
114+
const saveMetaItem = vi.fn(async () => ({ success: true }));
115+
h.services.set('protocol', { saveMetaItem });
116+
117+
await service.importObject('warehouse', 'customers', { name: 'ext_cust' });
118+
119+
expect(saveMetaItem).toHaveBeenCalledTimes(1);
120+
expect(h.register).not.toHaveBeenCalled();
121+
});
122+
123+
it('refuses the import with the door\'s own refusal when the save is refused', async () => {
124+
const h = harness();
125+
const refusal = Object.assign(new Error('object/ext_cust failed validation'), {
126+
code: 'INVALID_METADATA',
127+
status: 422,
128+
});
129+
h.services.set('protocol', { saveMetaItem: vi.fn(async () => { throw refusal; }) });
130+
131+
const outcome = await (await federation(h))
132+
.importObject('warehouse', 'customers', { name: 'ext_cust' })
133+
.catch((e: unknown) => e);
134+
135+
expect(outcome).toBe(refusal);
136+
expect(outcome).toMatchObject({ code: 'INVALID_METADATA', status: 422 });
137+
expect(h.register).not.toHaveBeenCalled();
138+
});
139+
140+
it('with no save door, refuses before any remote introspection and saves nothing', async () => {
141+
const h = harness();
142+
143+
const outcome = await (await federation(h))
144+
.importObject('warehouse', 'customers', { name: 'ext_cust' })
145+
.catch((e: unknown) => e);
146+
147+
expect(outcome).toBeInstanceOf(Error);
148+
expect((outcome as Error).message).toMatch(/requires a writable metadata store/);
149+
expect(h.introspect).not.toHaveBeenCalled();
150+
expect(h.register).not.toHaveBeenCalled();
151+
});
152+
});

0 commit comments

Comments
 (0)