Skip to content

Commit 8a399b2

Browse files
fix(service-datasource): the admin door reads a datasource's origin from provenance, and a metadata-door write reaches it in the same boot (#21977)
Fixes #21923 Clause-②: no ## What changes A runtime datasource is one record that two doors serve: the datasource admin door (`/api/v1/datasources`) and the metadata door (`/api/v1/meta/datasource`). They disagreed about it in three ways. All three land in `packages/services/service-datasource/src/datasource-admin-plugin.ts`. 1. **Origin comes from provenance, not from the record.** `listDatasourceRecords` and `getDatasourceRecord` served `origin: r.origin ?? 'code'`. A metadata-door body has no `origin`, or it asserts `origin: 'code'`. So after a restart, the boot restore registered the row and the admin door served it as code-defined and refused its `PATCH`. A new `servedOrigin(ctx, name)` returns `code` only for a name in the host's code-datasource set, and `runtime` for every other name, whatever the record says. That set is the `code-datasource-names` kernel service PR #21965 landed, which the boot restore and the metadata door's refusal already read. Boot pool rehydration filters on the served origin, so such a datasource also gets its live pool after a restart. 2. **Same-boot reach.** The metadata door persists to `sys_metadata` and the SchemaRegistry. It never registers the record in the MetadataService slot the admin door lists. At `start()`, the plugin now registers the protocol's awaited `datasource` mutation projector (ADR-0094, `registerMutationProjector`; no `metadata-protocol` edit). After each metadata-door save, publish, revert, rollback or delete, and before that door answers, the projector does three things: - it re-reads the stored row (`sys_metadata`, the same read the boot restore makes); - it registers or unregisters the MetadataService slot to match that row; - it converges the live pool through the existing `convergePool`. A name in the code set is skipped, because code wins. So the metadata door's repair `DELETE` of a stored shadow row under a code name keeps the code definition served. 3. **The reverse direction.** `persistDatasourceRow` wrote the `sys_metadata` row with no `checksum`. The metadata door's reads serve `row.checksum ?? hashSpec(body)` as the version, but its writes compare the parent against the raw column. So every metadata-door `PUT` and `DELETE` of an admin-created datasource answered `409 METADATA_CONFLICT`. The row now carries `hashSpec(record, 'datasource')`, imported from `@objectstack/metadata-core`. That is the same computation `SysMetadataRepository.put` stamps (`hashSpec(body, ref.type)`). `@objectstack/metadata-core` moves from devDependencies to dependencies; in the lockfile only the importer entry moves. `convergePool` is the receive half of the datasource cluster bridge, and the projector's pool step. It now decides "code" from the same set instead of `row.origin !== 'runtime'`. It pools every other stored row as runtime, and stamps the record `origin: 'runtime'` before the record reaches the connect context. Editing a code-defined datasource is still refused at both doors. ### Why `Clause-②: no` The `origin` docblock in `packages/spec/src/data/datasource.zod.ts` reads: "`runtime` — created via the Studio wizard, persisted in the runtime metadata store, environment-scoped, editable", and "Never accepted from client input". The published contract already says such a datasource is editable, so the admin door's refusal of a metadata-door datasource as code-defined was a false refusal. No key, export or stored shape moves. `projectionApplied` now appears on the answer to a datasource write through the metadata door. That key is already declared optional on the protocol's write answer, so this adds no new key to a published payload. ## Measured on the base (`c9761cd2fb`): the new door pin is red `datasource-meta-door-reaches-admin-door.dogfood.test.ts`, run on the unmodified base, gave 4 failed and 2 passed: - `:174` same boot, after `PUT /meta/datasource/dogfood_meta_none_21923` answered 200: `expected undefined to match object { origin: 'runtime', …(1) }`. The admin door did not list it. - `:193` an admin-created datasource, then `PUT /meta/datasource/dogfood_admin_rt_21923`: `409 METADATA_CONFLICT`, "Expected parent hmac-sha256:… but current is null." - `:206` after a restart: received `"origin": "code"`, expected `"runtime"`. ## Mechanism hypotheses, measured 1. **Does any code registration reach the MetadataService without its name in the set?** This was measured with `bootStack` on HEAD `493c13dbb3`, comparing `metadata.list('datasource')` against `code-datasource-names`: | composition | listed at boot | code set | listed but outside the set | |---|---|---|---| | showcase | `default`, `showcase_external` | `default`, `showcase_external` | none | | crm | `crm_analytics`, `crm_primary`, `default` | `crm_analytics`, `crm_primary`, `default` | none | | multi-package | `default` | `default` | none | A package installed after boot does not register datasources in the MetadataService at all: `registerApp` and the `sys_packages` rehydrate write only the engine registry. So the admin door never lists one. The three residual paths, read and not measured on a boot, are under Acceptance notes. The fix never falls back to `?? 'code'`. 2. **Same-boot reach.** Verified on main (the `:174` failure above). The seam is the awaited projector, not `onMetadataMutation`: the metadata door answers only after the admin door lists the record, and a projection failure is reported on that answer as well as logged. - **A live pool is needed.** The admin door's create calls `registerPool`, which connects. The dogfood pin asserts `connected` for a metadata-door save in the same boot and again after a restart. Before the fix there was no pool, and after a restart the served `code` kept the datasource out of pool rehydration. 3. **Checksum.** The fix lands inside `service-datasource` by importing the repository's own computation. The residual, rows already stored without a checksum, is under Acceptance notes. 4. **Carrier notes.** - `convergePool` is covered by the provenance rule (above). - The `restoreRuntimeDatasources` and `rehydratePools` warnings that go only to `options.logger` are not changed. The bounded in-place-fix condition "same defect class" does not hold for them; see Acceptance notes. ## Tests (head `493c13dbb3`) - `pnpm --filter @objectstack/service-datasource typecheck`: exit 0. The package's `tsc --listFiles` includes the edited test file. - `pnpm --filter @objectstack/service-datasource test`: 41 files and 760 tests pass. - 7 new cases in `datasource-admin-plugin.test.ts`: the served origin, the projector registration, a metadata-door save, an edit and a delete reaching the admin door with their pool, a write under a code name changing nothing, a peer signal pooling by provenance, and the checksum. - One fixture re-judged: the "artefact (code) datasource" case now carries the code set, as the runtime fills it, instead of relying on a missing `origin`. - `datasource-system-context.pin.test.ts` follows `convergePool`'s new `ctx` parameter. - `pnpm --filter @objectstack/dogfood typecheck`: exit 0. - Four dogfood files pass, 24 tests (after `pnpm --filter @objectstack/service-datasource build`, dist marker preflight present): the new pin, `datasource-restore-code-wins`, `meta-door-code-datasource`, and `external-import-code-datasource-namespace`. - The rejection pin asserts `code` plus `status`: the admin `PATCH` of `showcase_external` still answers `400 DATASOURCE_ADMIN_ERROR`, with the message's first sentence. ### Ablations (each mutation through `scripts/ablation-replace.mjs`, rebuilt, `ablation-dist-preflight` on both legs, restored to the HEAD blob `a7f28b52b967`, `git diff HEAD` empty, and the restore leg rebuilt) | put back | unit (26 in file) | dogfood | |---|---|---| | `origin ?? 'code'` in list and get | 3 red | new pin 3 red (`:174`, `:206`, `:222`) | | the record's own `origin` first (`r.origin ?? servedOrigin(…)`) | 2 red | new pin 2 red (`:175`, the body asserting `code` served as code) | | no projector (`void this.projectMetadataDoorWrites;`) | 4 red | new pin 4 red (`:174`, `:194`, `:206`, `:222`) | | `convergePool` reads `row.origin !== 'runtime'` | 3 red | new pin 2 red (`:178`, no pool) | | no checksum (`const checksum = null`) | 1 red | new pin 3 red (`:193` and `:221`, `409 METADATA_CONFLICT`) | | projector without its code-name guard | 1 red | `datasource-restore-code-wins` 1 red (`:271`, the repair `DELETE` unregisters the code definition) | Three first attempts did not run: the no-projector, `convergePool` and no-checksum mutations each failed the DTS build with an unused symbol (TS6133), so nothing was measured. Each was redone with a mutation that compiles. In every void attempt the restore leg was proven the same way. ## Gates (head `493c13dbb3`) `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` re-derived 78 families on this change. That is a superset of the 49 the dispatch listed; the additions come from the changeset, the lockfile and `package.json`. All 78 exit 0. `--ran` reconciles: 78 derived, 78 run, 0 NOT-MEASURED, each with a recorded exit code. One gate needed a rerun. `pnpm check:dual-build-cjs-loads` first exited 3 with PREREQUISITE NOT MET (8 unrelated packages had no `dist/`). After those packages were built, it exited 0: 106 require entry points across 66 packages load. Narrowed lint (`eslint --no-inline-config --format json`) over the 4 touched `.ts` files: - every one resolves a config (`--print-config`); - the JSON output counts 4 files, 0 errors and 0 warnings; - `eslint.config.mjs` enables no type-aware linting, so this diff cannot move a verdict on an untouched file. The repo-wide `pnpm lint` is left to CI. ## Acceptance notes - **Rows stored before this release.** An admin-created datasource row written before this release has no `checksum`, so the metadata door still answers it 409 until the admin door next writes it; one admin edit is the remedy. The asymmetry itself is in `metadata-protocol`'s `sys-metadata-repository.ts`: `rowToItem` serves `row.checksum ?? hashSpec(body)` while `put` and `delete` compare the raw column. It holds for any type's null-checksum row, and is reported to the seat rather than edited here. - **Code datasources the code set does not cover** (read, not measured on a boot): - Dev artifact HMR (`artifactWatch`) re-registers the artifact's datasources through the MetadataPlugin door. A datasource added to the artifact after boot is not in the set, because `AppPlugin` memoizes its owners, so the admin door serves it as runtime until a restart. - The legacy `FilesystemLoader` (a `datasource/` directory under the metadata root) lists datasources nothing adds to the set. - A host that composes the artifact door without `AppPlugin` or `DefaultDatasourcePlugin` registers no set, so nothing is code there; the boot restore already treats such a host that way. The remedy for each is on the producer side: contribute to the set. It is not a consumer default. - **Cluster.** The projector runs on the writing replica only; the protocol's cluster channel replays mutation listeners, not projectors. So a metadata-door datasource write does not converge peer replicas' MetadataService or pools until they restart. Before this change no replica converged. Cross-replica MetadataService coherence is a separate, open measurement. - **Lost warnings under `os serve`.** The existing `restoreRuntimeDatasources` and `rehydratePools` warnings still go only to `options.logger`, which `os serve` does not pass. This change widens the population that reaches `rehydratePools`, because metadata-door datasources now rehydrate. The new projector adds no log line of its own: a projection failure is reported on the metadata door's answer (`projectionApplied`) and logged by the protocol. - **Not this card.** The metadata door's own `GET` serves a stored row under a code-defined name (the overlay read); #21922 remains open for that half. ## Files - `packages/services/service-datasource/src/datasource-admin-plugin.ts` - `packages/services/service-datasource/src/__tests__/datasource-admin-plugin.test.ts` - `packages/services/service-datasource/src/__tests__/datasource-system-context.pin.test.ts` - `packages/services/service-datasource/package.json`, `pnpm-lock.yaml` (`@objectstack/metadata-core` becomes a dependency) - `packages/qa/dogfood/test/datasource-meta-door-reaches-admin-door.dogfood.test.ts` (the door pin, declared on #6024) - `.changeset/21923-datasource-origin-from-provenance.md` (`@objectstack/service-datasource` patch) --- _Generated by [Claude Code](https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 04e776b commit 8a399b2

7 files changed

Lines changed: 580 additions & 37 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"@objectstack/service-datasource": patch
3+
---
4+
5+
The datasource admin door and the metadata door agree on a runtime datasource: a datasource saved through `/api/v1/meta/datasource/:name` is listed and editable through `/api/v1/datasources` in the same boot and after a restart, and one created through the admin door can be edited and removed through the metadata door (#21923)
6+
7+
Clause-②: no
8+
9+
`DatasourceSchema.origin` publishes `runtime` as "created via the Studio wizard, persisted in the runtime metadata store, environment-scoped, editable", and says `origin` is never accepted from client input. Three things broke that for a datasource the metadata door wrote, or one the admin door wrote:
10+
11+
- **The admin door read `origin` from the record.** It served `origin ?? 'code'`, so after a restart a datasource saved through the metadata door (whose body carries no `origin`, or asserts `origin: 'code'`) was served as code-defined, and `PATCH /api/v1/datasources/:name` answered `400 DATASOURCE_ADMIN_ERROR` ("… is code-defined and cannot be edited at runtime."). It now serves `code` only for a name the host registers from code (the host's code-datasource set, the one the boot restore and the metadata door's refusal read), and `runtime` for every other name, whatever the record says. Boot pool rehydration follows the served origin, so such a datasource also gets its live pool after a restart.
12+
- **A metadata-door write never reached the admin door until a restart.** The metadata door persisted the row but never registered it where the admin door lists, so in the same boot `GET /api/v1/datasources` omitted it and `PATCH` answered "not found". The datasource-admin plugin now registers the protocol's awaited `datasource` mutation projector: after a metadata-door save, publish, revert, rollback or delete, the admin door's registry follows the stored row and the live pool converges on it (opened on a create, rebuilt on a connectivity change, evicted on a delete) before the metadata door answers. A write under a name the host defines in code changes nothing here, so the metadata door's `DELETE` of a leftover row under such a name still leaves the code definition served.
13+
- **An admin-created row could not be edited or removed through the metadata door.** The admin door stored its `sys_metadata` row with no `checksum`, the column the metadata door's optimistic lock compares, so `PUT` and `DELETE /api/v1/meta/datasource/:name` answered `409 METADATA_CONFLICT` for every admin-created datasource. The admin door now stamps the checksum the metadata door's repository stamps (`hashSpec` from `@objectstack/metadata-core`, which becomes a runtime dependency of this package). A row stored before this release has no checksum until the admin door next writes it: editing that datasource through the admin door once makes it editable through the metadata door.
14+
15+
Cluster convergence (a peer replica's signal after an admin-door write) decides "code" from the same set, so a stored row under a code-defined name never opens a pool there, and every other stored row is pooled as runtime.
16+
17+
**Unchanged.** A code-defined datasource stays read-only through both doors: the admin door's `PATCH` and `DELETE` still answer `400 DATASOURCE_ADMIN_ERROR`, and the metadata door's still answer `403 NOT_OVERRIDABLE`. A host that composes no code-datasource producer (neither `AppPlugin` nor `DefaultDatasourcePlugin`) registers no set, and the admin door then serves every datasource as runtime, as its boot restore already treats every stored row.
Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [#21923] The metadata door and the datasource admin door agree on a runtime
4+
// datasource — in the same boot and after a restart — over the real showcase
5+
// composition.
6+
//
7+
// ## What was broken
8+
//
9+
// Measured on this composition before the fix:
10+
//
11+
// - `PUT /api/v1/meta/datasource/<name>` saved a datasource (200), and in the
12+
// same boot `GET /api/v1/datasources` omitted it and the admin door's
13+
// `PATCH` answered "not found": the save wrote `sys_metadata`, never the
14+
// MetadataService slot the admin door lists, and opened no pool;
15+
// - after a restart the boot restore registered the row, and the admin read
16+
// served it as `origin: 'code'` — `origin ?? 'code'` read a body with no
17+
// `origin` as a code definition, and a body asserting `origin: 'code'` as
18+
// one too — so its `PATCH` was refused as "code-defined";
19+
// - the other direction: a datasource the admin door created was stored with
20+
// no `checksum`, so the metadata door's optimistic lock never matched it,
21+
// and its `PUT` and `DELETE` answered `409 METADATA_CONFLICT`.
22+
//
23+
// ## What each case pins (triage's pins)
24+
//
25+
// - a datasource the metadata door creates, with no `origin`, is listed and
26+
// editable through the admin door in the same boot (with a live pool, as
27+
// an admin create has) and after a restart;
28+
// - a body asserting `origin: 'code'` stays runtime: the admin door's origin
29+
// comes from the host's code-datasource set, never from the record;
30+
// - a datasource the admin door creates is edited and removed through the
31+
// metadata door, and the removal leaves the admin door in the same boot;
32+
// - a code-defined datasource stays refused at the admin door.
33+
//
34+
// The verify harness composes the datasource-admin service but not its REST
35+
// routes, so this file mounts `registerDatasourceAdminRoutes` the way
36+
// `packages/cli/src/commands/serve.ts` does. The working directory is a
37+
// temporary one because the showcase's external datasource and its fixture
38+
// both name a cwd-relative SQLite file.
39+
40+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
41+
import showcaseStack, { onEnable } from '@objectstack/example-showcase';
42+
import { registerDatasourceAdminRoutes } from '@objectstack/service-datasource';
43+
import { bootStack, type VerifyStack } from '@objectstack/verify';
44+
import { mkdtempSync, rmSync } from 'node:fs';
45+
import { tmpdir } from 'node:os';
46+
import { join } from 'node:path';
47+
48+
/** The showcase's code-defined datasource. */
49+
const EXTERNAL = 'showcase_external';
50+
/** Created through the metadata door with no `origin` in the body. */
51+
const META_NONE = 'dogfood_meta_none_21923';
52+
/** Created through the metadata door with a body asserting `origin: 'code'`. */
53+
const META_CODE = 'dogfood_meta_code_21923';
54+
/** Created through the admin door, then edited and removed through the metadata door. */
55+
const ADMIN_RT = 'dogfood_admin_rt_21923';
56+
57+
const SYS = { isSystem: true, positions: [], permissions: [] };
58+
59+
interface Answer {
60+
status: number;
61+
json: {
62+
success?: boolean;
63+
reset?: boolean;
64+
error?: string | { code?: string; message?: string };
65+
code?: string;
66+
item?: Record<string, unknown>;
67+
projectionApplied?: { success: boolean; error?: string };
68+
data?: { datasource?: Record<string, unknown>; datasources?: Array<Record<string, unknown>> };
69+
};
70+
}
71+
72+
/** `{ status, code, message }` from either door's refusal envelope. */
73+
const refusal = (a: Answer) => {
74+
const { error, code } = a.json;
75+
return typeof error === 'object' && error !== null
76+
? { status: a.status, code: error.code, message: String(error.message ?? '') }
77+
: { status: a.status, code, message: String(error ?? '') };
78+
};
79+
80+
describe('[#21923] the metadata door and the admin door agree on a runtime datasource (showcase)', () => {
81+
let stack: VerifyStack;
82+
let token: string;
83+
let prevCwd: string;
84+
let dir: string;
85+
86+
const routes = () => ({
87+
name: 'dogfood.datasource-admin-routes',
88+
version: '1.0.0',
89+
optionalDependencies: ['com.objectstack.server.hono'],
90+
init: async (ctx: { getService?: (name: string) => unknown }) => {
91+
const httpServer = ctx.getService?.('http.server') ?? ctx.getService?.('http-server');
92+
registerDatasourceAdminRoutes(httpServer as never, ctx as never, '/api/v1');
93+
},
94+
});
95+
const boot = async () => {
96+
stack = await bootStack(showcaseStack, { databaseFile: join(dir, 'showcase.db'), extraPlugins: [routes()] });
97+
token = await stack.signIn();
98+
};
99+
const restart = async () => {
100+
await stack.stop();
101+
await boot();
102+
};
103+
const call = async (method: string, path: string, body?: unknown): Promise<Answer> => {
104+
const res = await stack.apiAs(token, method, path, body);
105+
return { status: res.status, json: (await res.json().catch(() => ({}))) as Answer['json'] };
106+
};
107+
const storedRows = async (name: string) => {
108+
const ql = (await stack.kernel.getServiceAsync('objectql')) as {
109+
find(object: string, query: unknown): Promise<Array<Record<string, unknown>>>;
110+
};
111+
return ql.find('sys_metadata', { where: { type: 'datasource', name }, context: SYS });
112+
};
113+
const adminEntry = async (name: string) => {
114+
const listed = await call('GET', '/datasources');
115+
expect(listed.status, JSON.stringify(listed.json)).toBe(200);
116+
return listed.json.data?.datasources?.find((d) => d.name === name);
117+
};
118+
const connectVerdict = (name: string) => {
119+
const connection = stack.kernel.getService('datasource-connection') as {
120+
listConnectionStates(): Array<{ name: string; status: string }>;
121+
};
122+
return connection.listConnectionStates().find((s) => s.name === name)?.status;
123+
};
124+
/** The `origin` the stored row's own body asserts, if any. */
125+
const storedOrigin = async (name: string) => {
126+
const [row] = await storedRows(name);
127+
const raw = row?.metadata;
128+
return (typeof raw === 'string' ? JSON.parse(raw) : (raw as Record<string, unknown> | undefined))?.origin;
129+
};
130+
/** A sqlite datasource body, as an author writes it for the metadata door. */
131+
const sqliteBody = (name: string, label: string, extra: Record<string, unknown> = {}) => ({
132+
name, label, driver: 'sqlite', config: { filename: join(dir, `${name}.db`) }, ...extra,
133+
});
134+
/** The metadata door's served item, without its `_`-prefixed envelope keys. */
135+
const servedBody = async (name: string) => {
136+
const read = await call('GET', `/meta/datasource/${name}`);
137+
expect(read.status, JSON.stringify(read.json)).toBe(200);
138+
return Object.fromEntries(Object.entries(read.json.item ?? {}).filter(([k]) => !k.startsWith('_')));
139+
};
140+
141+
beforeAll(async () => {
142+
prevCwd = process.cwd();
143+
dir = mkdtempSync(join(tmpdir(), 'dogfood-21923-'));
144+
process.chdir(dir);
145+
// Provision the remote tables, exactly as `os dev` does at boot.
146+
await onEnable({ logger: { info() {}, warn() {} } } as never);
147+
await boot();
148+
}, 180_000);
149+
150+
afterAll(async () => {
151+
await stack?.stop();
152+
if (prevCwd) process.chdir(prevCwd);
153+
if (dir) rmSync(dir, { recursive: true, force: true });
154+
});
155+
156+
it('premise: the code datasource is served as code, and none of this file\'s names exists yet', async () => {
157+
expect(await adminEntry(EXTERNAL)).toMatchObject({ origin: 'code' });
158+
for (const name of [META_NONE, META_CODE, ADMIN_RT]) {
159+
expect(await adminEntry(name)).toBeUndefined();
160+
expect(await storedRows(name)).toEqual([]);
161+
}
162+
});
163+
164+
it('a datasource the metadata door creates is listed and editable through the admin door in the same boot, with a live pool', async () => {
165+
const none = await call('PUT', `/meta/datasource/${META_NONE}`, sqliteBody(META_NONE, 'Meta None 21923'));
166+
expect(none.status, JSON.stringify(none.json)).toBe(200);
167+
const code = await call('PUT', `/meta/datasource/${META_CODE}`, sqliteBody(META_CODE, 'Meta Code 21923', { origin: 'code' }));
168+
expect(code.status, JSON.stringify(code.json)).toBe(200);
169+
expect(await storedRows(META_NONE)).toHaveLength(1);
170+
// The stored body really asserts `origin: 'code'` — the record's own claim.
171+
expect(await storedOrigin(META_CODE)).toBe('code');
172+
173+
// Listed, as runtime — the body asserting `origin: 'code'` included.
174+
expect(await adminEntry(META_NONE)).toMatchObject({ origin: 'runtime', label: 'Meta None 21923' });
175+
expect(await adminEntry(META_CODE)).toMatchObject({ origin: 'runtime', label: 'Meta Code 21923' });
176+
177+
// A live pool, as the admin door's create opens one.
178+
expect(connectVerdict(META_NONE)).toBe('connected');
179+
expect(connectVerdict(META_CODE)).toBe('connected');
180+
181+
// Editable through the admin door.
182+
const patched = await call('PATCH', `/datasources/${META_NONE}`, { label: 'Meta None 21923 (admin edit)' });
183+
expect(patched.status, JSON.stringify(patched.json)).toBe(200);
184+
expect(await adminEntry(META_NONE)).toMatchObject({ origin: 'runtime', label: 'Meta None 21923 (admin edit)' });
185+
});
186+
187+
it('a datasource the admin door creates is edited and removed through the metadata door, and the removal leaves the admin door', async () => {
188+
const created = await call('POST', '/datasources', sqliteBody(ADMIN_RT, 'Admin Runtime 21923'));
189+
expect(created.status, JSON.stringify(created.json)).toBe(201);
190+
expect(connectVerdict(ADMIN_RT)).toBe('connected');
191+
192+
const put = await call('PUT', `/meta/datasource/${ADMIN_RT}`, { ...(await servedBody(ADMIN_RT)), label: 'Admin Runtime 21923 (meta edit)' });
193+
expect(put.status, JSON.stringify(put.json)).toBe(200);
194+
expect(await adminEntry(ADMIN_RT)).toMatchObject({ origin: 'runtime', label: 'Admin Runtime 21923 (meta edit)' });
195+
196+
const del = await call('DELETE', `/meta/datasource/${ADMIN_RT}`);
197+
expect(del.status, JSON.stringify(del.json)).toBe(200);
198+
expect(await storedRows(ADMIN_RT)).toEqual([]);
199+
expect(await adminEntry(ADMIN_RT)).toBeUndefined();
200+
expect(connectVerdict(ADMIN_RT)).toBeUndefined();
201+
});
202+
203+
it('after a restart, both metadata-door datasources are runtime, pooled and editable through the admin door', async () => {
204+
await restart();
205+
206+
expect(await adminEntry(META_NONE)).toMatchObject({ origin: 'runtime', label: 'Meta None 21923 (admin edit)' });
207+
// Still the record's claim at this restart: `origin: 'code'`. Served runtime.
208+
expect(await storedOrigin(META_CODE)).toBe('code');
209+
expect(await adminEntry(META_CODE)).toMatchObject({ origin: 'runtime', label: 'Meta Code 21923' });
210+
for (const name of [META_NONE, META_CODE]) {
211+
expect(connectVerdict(name)).toBe('connected');
212+
const patched = await call('PATCH', `/datasources/${name}`, { label: `${name} (after restart)` });
213+
expect(patched.status, JSON.stringify(patched.json)).toBe(200);
214+
expect(await adminEntry(name)).toMatchObject({ origin: 'runtime', label: `${name} (after restart)` });
215+
}
216+
expect(await adminEntry(ADMIN_RT)).toBeUndefined();
217+
}, 180_000);
218+
219+
it('the metadata door edits and removes an admin-edited datasource, and the admin door follows in the same boot', async () => {
220+
const put = await call('PUT', `/meta/datasource/${META_NONE}`, { ...(await servedBody(META_NONE)), label: 'Meta None 21923 (meta edit)' });
221+
expect(put.status, JSON.stringify(put.json)).toBe(200);
222+
expect(await adminEntry(META_NONE)).toMatchObject({ origin: 'runtime', label: 'Meta None 21923 (meta edit)' });
223+
224+
const del = await call('DELETE', `/meta/datasource/${META_CODE}`);
225+
expect(del.status, JSON.stringify(del.json)).toBe(200);
226+
expect(await storedRows(META_CODE)).toEqual([]);
227+
expect(await adminEntry(META_CODE)).toBeUndefined();
228+
expect(connectVerdict(META_CODE)).toBeUndefined();
229+
});
230+
231+
it('a code-defined datasource stays refused at the admin door', async () => {
232+
const patch = refusal(await call('PATCH', `/datasources/${EXTERNAL}`, { label: 'Admin edit 21923' }));
233+
expect({ status: patch.status, code: patch.code }).toEqual({ status: 400, code: 'DATASOURCE_ADMIN_ERROR' });
234+
expect(patch.message.startsWith(`Datasource '${EXTERNAL}' is code-defined and cannot be edited at runtime`), patch.message).toBe(true);
235+
expect(await adminEntry(EXTERNAL)).toMatchObject({ origin: 'code' });
236+
});
237+
});

‎packages/services/service-datasource/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
"@objectstack/core": "workspace:*",
4040
"@objectstack/driver-memory": "workspace:*",
4141
"@objectstack/driver-sql": "workspace:*",
42+
"@objectstack/metadata-core": "workspace:*",
4243
"@objectstack/spec": "workspace:*",
4344
"@objectstack/types": "workspace:*",
4445
"pg-connection-string": "^2.14.0"
@@ -62,7 +63,6 @@
6263
"devDependencies": {
6364
"@objectstack/driver-mongodb": "workspace:*",
6465
"@objectstack/driver-sqlite-wasm": "workspace:*",
65-
"@objectstack/metadata-core": "workspace:*",
6666
"@objectstack/plugin-hono-server": "workspace:*",
6767
"@types/node": "^26.6.3",
6868
"tsup": "^8.5.1",

0 commit comments

Comments
 (0)