Skip to content

Commit 7c2888a

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-21918-federated-injected-anchors
2 parents bd18cf3 + faf8dce commit 7c2888a

10 files changed

Lines changed: 771 additions & 57 deletions
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/runtime': patch
3+
'@objectstack/service-datasource': patch
4+
---
5+
6+
An import over a code-defined datasource is held to the namespace of the package that declares it (ADR-0028), and the draft door answers the prefixed name (#21889).
7+
8+
Clause-②: no
9+
10+
- **Before.** `POST /api/v1/datasources/:name/external/tables/:remote/import` with an explicit `name` that carried no namespace prefix answered `201` and saved an unprefixed federated object, and `POST …/external/tables/:remote/draft` answered the bare remote table name with a `TODO(namespace)` note. Measured on the showcase's `showcase_external` under `objectstack dev` and `objectstack start`.
11+
- **`@objectstack/runtime`.** `AppPlugin` registers each code-defined datasource through `applyProtection` with the id and version of the package body that declares it, so the item carries `_packageId`, `_packageVersion` and `_provenance: 'package'`. On an ADR-0130 `packages[]` artifact each datasource takes its own body's id, never the artifact's top-level manifest id. A top-level datasource that no body declares keeps its registration under the artifact's own id, and a warning names it.
12+
- **`@objectstack/service-datasource`.** The federation service reads the datasource's package record from the engine registry (`registry.getPackage` on the `objectql` service), the store the runtime publish gate reads for the same check. It used to ask the `metadata` service, which holds no package records in any composition, so no datasource resolved a namespace. The package id still comes only from the stamped `_packageId`.
13+
- **What a caller sees now.** On a datasource whose package declares `manifest.namespace`, an unprefixed import `name` answers `400 EXTERNAL_IMPORT_ERROR` with ADR-0028's message, which names the prefixed name to use. An import with no `name` override saves the prefixed name the draft derives (for example `showcase_customers` instead of `customers`). `GET /api/v1/meta/datasource` lists the three provenance keys on a code-defined datasource; all three are declared on `DatasourceSchema`. The datasource admin list (`GET /api/v1/datasources`) is unchanged, and the admin door still refuses to edit or remove a code-defined datasource.
14+
- **Unchanged.** A datasource that carries no `_packageId` (the host `default` is one) and a package that declares no namespace resolve no namespace, so their imports and drafts answer as before.
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [#21889, ADR-0028] An import over a CODE-DEFINED datasource is held to the
4+
// namespace of the package that declares the datasource, over the real
5+
// showcase composition.
6+
//
7+
// ## What was broken
8+
//
9+
// The showcase declares `showcase_external` in its own package, whose
10+
// `manifest.namespace` is `showcase`. `POST
11+
// /api/v1/datasources/showcase_external/external/tables/orders/import` with an
12+
// explicit `name` that carried no prefix answered `201` and persisted an
13+
// unprefixed federated object, and the draft door answered the bare remote
14+
// table name with its `TODO(namespace)` note. Two links were missing:
15+
// `AppPlugin` registered the code-defined datasource with no `_packageId`, and
16+
// the federation service then looked the package record up on the `metadata`
17+
// service, which holds none in any composition. The datasource is now stamped
18+
// with the package body that declares it, and the namespace is read from the
19+
// engine registry, the store the publish gate reads.
20+
//
21+
// ## What each case pins
22+
//
23+
// The ruling's two pins: an unprefixed import name is refused with ADR-0028's
24+
// message and saves nothing, and the draft door answers the prefixed name with
25+
// no `TODO(namespace)` note. Then the controls that show the stamp moved
26+
// nothing else: a prefixed import is saved and serves the remote rows, the
27+
// host `default` datasource carries no package, the admin service keeps
28+
// refusing edits to a code-defined datasource and lists it as before, and
29+
// validate and the existing federated objects answer as before.
30+
//
31+
// The verify harness does not mount the federation service, so this file
32+
// mounts `ExternalDatasourceServicePlugin` itself, as `objectstack dev` and
33+
// `serve` do. The working directory is a temporary one because the showcase's
34+
// external datasource and its fixture both name a cwd-relative SQLite file.
35+
36+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
37+
import showcaseStack, { onEnable } from '@objectstack/example-showcase';
38+
import { ExternalDatasourceServicePlugin } from '@objectstack/service-datasource';
39+
import { bootStack, type VerifyStack } from '@objectstack/verify';
40+
import { mkdtempSync, rmSync } from 'node:fs';
41+
import { tmpdir } from 'node:os';
42+
import { join } from 'node:path';
43+
44+
/** The showcase's code-defined external datasource (`showcase-external.datasource.ts`). */
45+
const DATASOURCE = 'showcase_external';
46+
/** The package that declares it, and that package's ADR-0028 namespace. */
47+
const PACKAGE_ID = 'com.example.showcase';
48+
const NAMESPACE = 'showcase';
49+
/** An explicit import name with no namespace prefix: the defect's shape. */
50+
const UNPREFIXED = 'dogfood_ext_order_21889';
51+
const PREFIXED = `${NAMESPACE}_${UNPREFIXED}`;
52+
53+
const ext = `/datasources/${DATASOURCE}/external`;
54+
55+
interface Envelope {
56+
success?: boolean;
57+
error?: { code?: string; message?: string };
58+
item?: Record<string, unknown>;
59+
data?: unknown;
60+
records?: Array<Record<string, unknown>>;
61+
}
62+
63+
describe('an import over a code-defined datasource is held to its package\'s namespace (showcase)', () => {
64+
let stack: VerifyStack;
65+
let token: string;
66+
let prevCwd: string;
67+
let dir: string;
68+
69+
const call = async (method: string, path: string, body?: unknown) => {
70+
const res = await stack.apiAs(token, method, path, body);
71+
const json = (await res.json().catch(() => ({}))) as Envelope;
72+
return { status: res.status, json };
73+
};
74+
75+
beforeAll(async () => {
76+
prevCwd = process.cwd();
77+
dir = mkdtempSync(join(tmpdir(), 'dogfood-21889-'));
78+
process.chdir(dir);
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 bootStack(showcaseStack, {
84+
databaseFile: join(dir, 'showcase.db'),
85+
extraPlugins: [new ExternalDatasourceServicePlugin()],
86+
});
87+
token = await stack.signIn();
88+
}, 180_000);
89+
90+
afterAll(async () => {
91+
await stack?.stop();
92+
if (prevCwd) process.chdir(prevCwd);
93+
if (dir) rmSync(dir, { recursive: true, force: true });
94+
});
95+
96+
it('premise: the code-defined datasource carries the provenance of the package that declares it', async () => {
97+
const version = (showcaseStack as unknown as { manifest?: { version?: string } }).manifest?.version;
98+
expect(version).toMatch(/^\d+\.\d+\.\d+/);
99+
100+
const read = await call('GET', `/meta/datasource/${DATASOURCE}`);
101+
expect(read.status, JSON.stringify(read.json)).toBe(200);
102+
expect(read.json.item).toMatchObject({
103+
origin: 'code',
104+
_packageId: PACKAGE_ID,
105+
_packageVersion: version,
106+
_provenance: 'package',
107+
});
108+
});
109+
110+
it('control: validate and the code-defined federated objects answer as before, ahead of any import', async () => {
111+
const validated = await call('POST', `${ext}/validate`);
112+
expect(validated.status, JSON.stringify(validated.json)).toBe(200);
113+
const report = validated.json.data as { ok?: boolean; results?: Array<{ object: string }> };
114+
expect(report.ok).toBe(true);
115+
expect(report.results?.map((r) => r.object).sort()).toEqual(['showcase_ext_customer', 'showcase_ext_order']);
116+
117+
const rows = await call('GET', '/data/showcase_ext_order');
118+
expect(rows.status, JSON.stringify(rows.json)).toBe(200);
119+
expect(rows.json.records).toHaveLength(4);
120+
});
121+
122+
it('an explicit import name without the prefix is refused with ADR-0028\'s message, and nothing is saved', async () => {
123+
const refused = await call('POST', `${ext}/tables/orders/import`, { name: UNPREFIXED });
124+
125+
expect(refused.status, JSON.stringify(refused.json)).toBe(400);
126+
expect(refused.json.error?.code).toBe('EXTERNAL_IMPORT_ERROR');
127+
expect(refused.json.error?.message).toContain('missing the package namespace prefix');
128+
expect(refused.json.error?.message).toContain(`'${PREFIXED}'`);
129+
130+
const stored = await call('GET', `/meta/object/${UNPREFIXED}`);
131+
expect(stored.status, JSON.stringify(stored.json)).toBe(404);
132+
});
133+
134+
it('the draft door answers the prefixed name, with no TODO(namespace) note', async () => {
135+
const drafted = await call('POST', `${ext}/tables/orders/draft`, {});
136+
137+
expect(drafted.status, JSON.stringify(drafted.json)).toBe(200);
138+
const draft = (drafted.json.data as { draft?: { name?: string; source?: string } } | undefined)?.draft;
139+
expect(draft?.name).toBe(`${NAMESPACE}_orders`);
140+
expect(draft?.source).not.toContain('TODO(namespace)');
141+
});
142+
143+
it('control: a prefixed import name is saved and serves the remote rows', async () => {
144+
const imported = await call('POST', `${ext}/tables/orders/import`, { name: PREFIXED });
145+
expect(imported.status, JSON.stringify(imported.json)).toBe(201);
146+
147+
const rows = await call('GET', `/data/${PREFIXED}`);
148+
expect(rows.status, JSON.stringify(rows.json)).toBe(200);
149+
expect(rows.json.records).toHaveLength(4);
150+
});
151+
152+
it('control: the host `default` datasource carries no package, so no namespace is demanded on it', async () => {
153+
const read = await call('GET', '/meta/datasource/default');
154+
expect(read.status, JSON.stringify(read.json)).toBe(200);
155+
expect(read.json.item).not.toHaveProperty('_packageId');
156+
expect(read.json.item).not.toHaveProperty('_provenance');
157+
});
158+
159+
it('control: the admin service still refuses to edit or remove a code-defined datasource, and lists no envelope key', async () => {
160+
// The harness mounts no `/api/v1/datasources` admin routes (the CLI's
161+
// `serve` does), so this reads the service those routes relay: a refusal
162+
// here is the door's `400 DATASOURCE_ADMIN_ERROR`. The refusals key on
163+
// `origin`, which the stamp leaves as it was.
164+
const admin = stack.kernel.getService<{
165+
updateDatasource(name: string, patch: Record<string, unknown>): Promise<unknown>;
166+
removeDatasource(name: string): Promise<void>;
167+
listDatasources(): Promise<Array<Record<string, unknown>>>;
168+
}>('datasource-admin');
169+
170+
await expect(admin.updateDatasource(DATASOURCE, { label: 'Renamed 21889' })).rejects.toBeInstanceOf(Error);
171+
await expect(admin.removeDatasource(DATASOURCE)).rejects.toBeInstanceOf(Error);
172+
173+
const entry = (await admin.listDatasources()).find((d) => d.name === DATASOURCE);
174+
expect(entry).toMatchObject({ origin: 'code', label: 'External Analytics (SQLite)' });
175+
expect(entry).not.toHaveProperty('_packageId');
176+
});
177+
});

‎packages/qa/dogfood/test/external-import-destructive-remedy.dogfood.test.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@
3030
// mounts `ExternalDatasourceServicePlugin` itself, as `objectstack dev` and
3131
// `serve` do. The working directory is a temporary one because the showcase's
3232
// external datasource and its fixture both name a cwd-relative SQLite file.
33+
//
34+
// [#21889] Both imported names carry the showcase's ADR-0028 prefix:
35+
// `showcase_external` is declared by the showcase package (namespace
36+
// `showcase`), so an import over it is held to that namespace.
3337

3438
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
3539
import showcaseStack, { onEnable } from '@objectstack/example-showcase';
@@ -43,9 +47,9 @@ import { join } from 'node:path';
4347
const DATASOURCE = 'showcase_external';
4448
const REMOTE = 'customers';
4549
/** The object the first import creates and the re-import would shrink. */
46-
const NAME = 'dogfood_ext_cust_21841';
50+
const NAME = 'showcase_dogfood_ext_cust_21841';
4751
/** The remedy's "new `name`". */
48-
const NEW_NAME = 'dogfood_ext_cust_21841_v2';
52+
const NEW_NAME = 'showcase_dogfood_ext_cust_21841_v2';
4953
/** The column the re-import leaves out, so the stored object would lose its field. */
5054
const DROPPED = 'region';
5155
/** The re-import's options: the same table, one column fewer. */

‎packages/qa/dogfood/test/external-import-saves-like-meta.dogfood.test.ts‎

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,13 @@
3131
// `serve` do. The working directory is a temporary one because the showcase's
3232
// external datasource and its fixture both name a cwd-relative SQLite file:
3333
// this file's remote database is its own, not one a parallel file writes.
34+
//
35+
// [#21889] Both imported names carry the showcase's ADR-0028 prefix:
36+
// `showcase_external` is declared by the showcase package (namespace
37+
// `showcase`), so an import over it is held to that namespace. That retires the
38+
// shape this file's control first pinned, an object named exactly as its
39+
// remote table (`orders`), which the namespace now refuses; the control imports
40+
// with no `name` override instead, under the prefixed name the draft derives.
3441

3542
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
3643
import showcaseStack, { onEnable } from '@objectstack/example-showcase';
@@ -43,9 +50,9 @@ import { join } from 'node:path';
4350
/** The showcase's external datasource and its two remote tables (`external-fixture.ts`). */
4451
const DATASOURCE = 'showcase_external';
4552
/** 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';
53+
const RENAMED = 'showcase_dogfood_ext_cust_21788';
54+
/** Imported with no `name` override: the draft's name, the remote table's prefixed with the namespace. */
55+
const DERIVED = 'showcase_orders';
4956

5057
const importPath = (remote: string) => `/datasources/${DATASOURCE}/external/tables/${remote}/import`;
5158

@@ -101,11 +108,12 @@ describe('Import as Object persists like the metadata door (showcase, cold boot)
101108
);
102109
});
103110

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 });
111+
it('control: an object imported with no name override is saved under the prefixed name and serves the remote rows', async () => {
112+
const imported = await call('POST', importPath('orders'), {});
106113
expect(imported.status, JSON.stringify(imported.json)).toBe(201);
114+
expect((imported.json as { data?: { object?: { name?: string } } }).data?.object?.name).toBe(DERIVED);
107115

108-
const read = await call('GET', `/data/${SAME_NAME}`);
116+
const read = await call('GET', `/data/${DERIVED}`);
109117
expect(read.status, JSON.stringify(read.json)).toBe(200);
110118
expect(recordsOf(read.json)).toHaveLength(4);
111119
});
@@ -120,8 +128,8 @@ describe('Import as Object persists like the metadata door (showcase, cold boot)
120128
expect.soft(renamed.status, JSON.stringify(renamed.json)).toBe(200);
121129
expect.soft(recordsOf(renamed.json)).toHaveLength(3);
122130

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);
131+
const derived = await call('GET', `/data/${DERIVED}`);
132+
expect.soft(derived.status, JSON.stringify(derived.json)).toBe(200);
133+
expect.soft(recordsOf(derived.json)).toHaveLength(4);
126134
}, 180_000);
127135
});

‎packages/qa/dogfood/test/external-validate-sees-runtime-save.dogfood.test.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,8 @@ const SAVED = 'dogfood_ext_cust_21842';
4949
/** A column the saved object declares and the remote `customers` table does not have. */
5050
const MISSING = 'loyalty_tier';
5151
/** Imported at runtime from the remote `orders` table. */
52-
const IMPORTED = 'dogfood_ext_ord_21842';
52+
// [#21889] Prefixed: an import over `showcase_external` is held to the showcase package's namespace.
53+
const IMPORTED = 'showcase_dogfood_ext_ord_21842';
5354

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

0 commit comments

Comments
 (0)