Skip to content

Commit 8532783

Browse files
fix(service-datasource,metadata-core)!: the datasource-admin plugin reads environment rows only; a legacy organization-scoped datasource row is neither restored nor served (ADR-0131 D6) (#22862)
Fixes #22835 Clause-②: no (narrowing) ## Summary `DatasourceAdminServicePlugin` now reads the environment's stored `datasource` rows only. This is the ADR-0131 D6 read narrowing that #15206's C5 applied to every metadata read, carried to `@objectstack/service-datasource`, which S5's enumeration missed. A row stored in a legacy organization's layer is no longer restored at boot, served by the datasource-admin door or the metadata read door, pooled, updated or deleted by this plugin. The boot report `[metadata_org_scoped_unserved]` keeps naming it, and it stays at rest: neither deleted nor rewritten (#22011 A). This makes the pending v18 note `.changeset/15206-reads-environment-only.md` ("served by no read and loaded by no boot") true for `datasource`. That note is not edited. #21922's "code wins on collision" restore design is unchanged. Classes, positions and function names only (security card). ## What changed - **One predicate, moved, not copied.** `isLegacyOrganizationRow` was module-private in `packages/objectql/src/plugin.ts`. It moves verbatim to `packages/metadata-core/src/legacy-organization-row.ts` and is exported from `@objectstack/metadata-core`, which both `objectql` and `service-datasource` already depend on (no new edge). `metadata-core` had no equivalent (searched for organization-row predicates). `objectql`'s three binder call sites now import it, unchanged, and the private copy is deleted. - **Every read of a stored datasource row in the plugin goes through one helper.** The new private `findEnvironmentRows` (`datasource-admin-plugin.ts`) runs a `find` on `sys_metadata` (type `datasource`, state `active`, optionally one name, system context) and drops each row `isLegacyOrganizationRow` names. Census of the plugin's reads of stored rows, each now behind it: - `loadDatasourceRows`: the boot restore (`restoreRuntimeDatasources`); - `loadDatasourceRow`: the by-name read behind cluster convergence (`convergePool`) and the `/meta` datasource projection (`applyMetadataDoorWrite`); - `persistDatasourceRow`: the existence probe behind admin create and update; - `deleteDatasourceRow`: the existence probe behind admin remove. - **By-name reads moved from `findOne` to a filtered `find`.** A single row picked by the store could be the legacy one while the environment's row exists. `findOne` leaves the plugin's private engine slice type (`DataEngineLike`), where it is now unused. - **The plugin's other reads** (`listDatasourceRecords`, `getDatasourceRecord`, `countBoundObjects*`) read the MetadataService, not `sys_metadata`. They stop serving the legacy row because the restore no longer registers it. The measurement below shows this; hypothesis 3 holds. ## Measurement: real boot, before and after Showcase via `bootShowcase` over a database file. Two `datasource` rows were stored with system context between two boots: one legacy organization-scoped, one environment. Readings come from the second boot. | Reading | Before (`d489318714`) | After (`fccd98aea0`) | |:--|:--|:--| | datasource-admin door: by name, list | 200, listed | 404, absent | | metadata read door: by name, list | 200 (the legacy body), listed | 404, absent | | live pool from the legacy row | opened | none | | environment row: both doors, pool | 200, 200, pooled | 200, 200, pooled (unchanged) | | boot report `[metadata_org_scoped_unserved]` | names the legacy row | names the legacy row | | admin create under the legacy row's name | 400 "already exists" | 201; writes an environment row, legacy row untouched | | admin remove under that name | 204, and **the legacy row was deleted** | 204; removes the environment row only, legacy row kept | The last row is the same missing predicate on a write path's existence probe (`deleteDatasourceRow`). The same fix closes it. ## Pins - `packages/qa/dogfood/test/datasource-restore-environment-rows.dogfood.test.ts` (new): a real boot, a restart and both doors. Three cases: - Premise and control: both rows are at rest, and the boot report names the legacy row. - Neither door serves the legacy row (404 by name, absent from both lists) and no pool opens from it. The environment row is served and pooled. - The writes' by-name reads never reach the legacy row: an admin create and remove under its name write and remove an environment row of their own, and a `/meta` save under the name is what the admin door then serves. The legacy row equals its stored bytes at the end. - `packages/services/service-datasource/src/__tests__/datasource-admin-plugin.test.ts`, describe `[#22835]` (two cases, on the file's existing `sys_metadata` double): - The restore registers and pools environment rows only. A row whose `organization_id` is the empty string counts as the environment's, per the one predicate. - With a legacy row inserted first under the same name as an environment row, the `/meta` projection, admin update, remove and create all reach the environment row, and a legacy-only name is neither registered nor pooled. The legacy row stays byte-equal. - `datasource-system-context.pin.test.ts`: population count moved from `findOne` to `find` (5 reads); the system-context assertion is unchanged. - `packages/metadata-core/src/legacy-organization-row.test.ts` (new): the predicate's truth table. ## Ablation (direction observed: red, as expected) - Committed state `fccd98aea0`, mutated with `scripts/ablation-replace.mjs`. In `findEnvironmentRows`, the anchor `.filter((row) => !isLegacyOrganizationRow(row));` was replaced by a filter that keeps every row, which reproduces the pre-fix reads. - The anchor count went 1 to 0 and the marker 0 to 1. Blob `ed525af25cfd` became `f7140c73d786`. - `pnpm --filter @objectstack/service-datasource build`, then `ablation-dist-preflight.mjs @objectstack/service-datasource ABLATION-22835` found the marker present in `dist/index.js` and `dist/index.cjs`. - Unit pins (`-t 22835`): **2 failed**. Readings: the legacy row was registered at restore, and the projection served the legacy body. - Dogfood pin: **2 failed, 1 passed** (the premise control). Readings: the admin door answered 200 for the legacy row, and the admin create answered 400 "already exists". - Restore: `ablation-replace --restore` then `git checkout HEAD -- PATH`. The blob equals HEAD (`ed525af25cfd`), `git diff HEAD` is empty and the tree is clean. After a rebuild, the preflight with `--absent` confirms the marker is absent from all 24 built files. ## Tests and gates Head `b02b3f8c16` is a merge of `origin/main` `eed637c09f` (four commits, none touching these packages), plus a comment-only trim of the dogfood header. - `@objectstack/service-datasource`: `vitest run`, 42 files and 765 tests passed. `typecheck` exit 0, and `tsc --listFiles` includes both edited test files. - `@objectstack/metadata-core`: `vitest run`, 17 files and 386 tests passed. `typecheck` exit 0, covering the new test through both tsconfigs. - `@objectstack/objectql`: the 16 `src/plugin*.test.ts` files plus `hook-binder-package-scope.test.ts`, 17 files and 158 tests passed. `typecheck` (including `check:test-typecheck`) exit 0. - `@objectstack/dogfood`, isolated project: this pin plus the four sibling datasource files (`datasource-restore-code-wins`, `datasource-meta-door-reaches-admin-door`, `meta-door-code-datasource`, `external-import-code-datasource-namespace`), 5 files and 28 tests passed at `6e19243562`. The pin was re-run at `b02b3f8c16`: 3 of 3 passed. Dogfood `tsc --noEmit` exit 0 and lists the new file. - Build: the dogfood dependency closure was built with turbo (63 tasks) after the merge. - Lint: `eslint --no-inline-config --format json` on the 8 edited TS files reported 8 files, 0 errors and 0 warnings. `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`), so untouched files cannot move. A repo-wide `pnpm lint` is left to CI. - Gates: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 70 commands at `b02b3f8c16`, a superset of the 46 named at dispatch. All 70 ran sequentially, with the exit code captured before any pipe. `--ran` reconciliation: 70 derived, 70 run, 0 NOT-MEASURED, 0 UNRUN. - `check:dual-build-cjs-loads` first exited 3 (PREREQUISITE NOT MET: eight unrelated packages unbuilt). After building them it re-ran and exited 0. - Verdict lines: `check-adr-0087-registration` reports 1 declared-breaking changeset, carrying its disposition. `check-empty-changeset` reports no foreign changeset modified. `check-changeset-no-major` reports no `major`. ## Changeset `.changeset/22835-datasource-reads-environment-only.md`: - Bumps: `@objectstack/service-datasource` minor (the narrowing), `@objectstack/metadata-core` minor (the new export), `@objectstack/objectql` patch (the import, no behaviour change). - Carries `Clause-②: no (narrowing)` with a FROM to TO row. - ADR-0087 disposition: not-required (already-registered `metadata-read-organization-scope-retired`). That entry already retires every read of an organization-scoped row, boot hydration included. ## Line budget +325 / -38 across 9 files against the merge base. Source is +59 / -36: `metadata-core` +24, `objectql` +4 / -11, `datasource-admin-plugin.ts` +31 / -25, mostly comments. The advisory total of 300 is exceeded by the real-boot pin, 164 lines. It boots twice, mounts the admin routes and reads both doors and the store; its sibling datasource dogfood files run 250 to 300 lines. ## Acceptance notes (observations, not filed) - `metadata-protocol`'s `reportUnhydratableOrgScopedRows` re-checks the same row value inline instead of importing the predicate. That is a second spelling in a third package, though `metadata-protocol` depends on `metadata-core`. It is outside this claim's file surface. Carrier: none. - No `objectql` test pins the hook and action binders' skip of a legacy organization row. This PR moves that predicate without changing it. Carrier: none. ## Contract review This PR owes one contract-review-tier review before the queue (`Clause-②` narrowing), to be arranged by the seat. --- _Generated by [Claude Code](https://claude.ai/code/session_01JfJfBUC3cQ6hhgm9MQK76T)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6d36017 commit 8532783

9 files changed

Lines changed: 325 additions & 38 deletions

File tree

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
'@objectstack/service-datasource': minor
3+
'@objectstack/metadata-core': minor
4+
'@objectstack/objectql': patch
5+
---
6+
7+
feat(service-datasource,metadata-core)!: the datasource-admin plugin reads the environment's stored `datasource` rows only; a legacy organization-scoped row is neither loaded at boot nor served (ADR-0131 D6)
8+
9+
Clause-②: no (narrowing)
10+
11+
<!-- adr-0087: not-required (already-registered metadata-read-organization-scope-retired) That entry retires every metadata read of a row stored organization-scoped, the boot hydration included, and states that such a row is served by no read and loaded by no boot. This changeset carries the same rule to the datasource-admin plugin's own reads of sys_metadata: no key, export or stored shape is removed, renamed or re-shaped, and nothing is rewritten. -->
12+
13+
**BREAKING (narrowing).** Graded `minor` on the v18 prerelease line, as the read narrowing it completes.
14+
15+
**What changes.** `DatasourceAdminServicePlugin` reads stored `datasource` rows in five places — the boot restore (`restoreRuntimeDatasources`), the by-name read behind cluster convergence and the `/meta` door's datasource projection, and the existence probes behind its own create, update and remove. Each now reads the environment's rows only, judged by the same row-value test the metadata reads apply. A row stored organization-scoped by an earlier release:
16+
17+
- is no longer registered at boot, so the datasource-admin door and the metadata read door no longer serve it, and no live pool is opened from it;
18+
- is never updated or deleted by an admin create, update or remove under its name — those write and remove an environment row of their own;
19+
- stays at rest, and the boot report `[metadata_org_scoped_unserved]` keeps naming it. Nothing is deleted or rewritten; the v18 migration ceremony (`os migrate`, ADR-0131 D10) carries it.
20+
21+
**What moves for consumers.**
22+
23+
| From | To |
24+
|:--|:--|
25+
| a legacy organization-scoped runtime datasource a deployment relied on | re-create it through the datasource-admin door or save it through the `/meta` door to make it environment-wide now, or wait for the v18 migration ceremony |
26+
27+
`@objectstack/metadata-core` exports `isLegacyOrganizationRow`, the one row-value test for such a row; `@objectstack/objectql`'s authored hook and action binders now read it from there, with no change in what they bind.

‎packages/metadata-core/src/index.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,11 @@ export * from './meta-write-capability.js';
114114
// surface is unchanged.
115115
export * from './record-organization.js';
116116

117+
// [ADR-0131 D6] The one row-value test for a `sys_metadata` row stored in a
118+
// legacy organization's layer — read by `@objectstack/objectql`'s binders and
119+
// `@objectstack/service-datasource`'s runtime-datasource reads.
120+
export * from './legacy-organization-row.js';
121+
117122
// [#16319] The field-`type` admission vocabulary — the predicate, the shared
118123
// sentence and the cross-package discriminator behind the ruled 「禁止加载」
119124
// refusal. Sunk here by the same criterion as everything above: the enforcing
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import { isLegacyOrganizationRow } from './legacy-organization-row.js';
5+
6+
describe('[ADR-0131 D6] isLegacyOrganizationRow — the one row-value test', () => {
7+
it('a row whose organization_id names an organization is a legacy organization row', () => {
8+
expect(isLegacyOrganizationRow({ organization_id: 'org_a' })).toBe(true);
9+
});
10+
11+
it('an absent, null or empty organization_id is the environment\'s row', () => {
12+
for (const row of [{}, { organization_id: undefined }, { organization_id: null }, { organization_id: '' }, null, undefined]) {
13+
expect(isLegacyOrganizationRow(row), JSON.stringify(row)).toBe(false);
14+
}
15+
});
16+
});
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [ADR-0131 D6] A `sys_metadata` row stored in a legacy organization's layer:
5+
* its `organization_id` names an organization (neither absent, `null` nor the
6+
* empty string). No metadata read serves one and no boot loads one; the boot
7+
* report names them, and nothing deletes or rewrites them.
8+
*
9+
* Judged off the row in hand — `sys_metadata` is a platform table, never a
10+
* federated object. Sunk here because its readers outside the metadata door —
11+
* `@objectstack/objectql`'s authored hook and action binders, and
12+
* `@objectstack/service-datasource`'s runtime-datasource reads — share no other
13+
* common home, and a second spelling would let them disagree on which rows are
14+
* the environment's.
15+
*/
16+
export function isLegacyOrganizationRow(row: { organization_id?: unknown } | null | undefined): boolean {
17+
const organizationId = row?.organization_id;
18+
return organizationId !== null && organizationId !== undefined && organizationId !== '';
19+
}

‎packages/objectql/src/plugin.ts‎

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ import { resolveArtifactPackageOrder, artifactPackageId, readDeploymentOrgScopin
99
// multi-package stack no body declares, and the id they take. See
1010
// `unclaimedTopLevelObjects` below.
1111
import { unclaimedTopLevel } from '@objectstack/metadata';
12+
// [ADR-0131 D6] The one row-value test for a legacy organization's
13+
// `sys_metadata` row: no metadata read serves one, so the authored hook and
14+
// action binders skip it too.
15+
import { isLegacyOrganizationRow } from '@objectstack/metadata-core';
1216
import { applyConversionsToStoredItem } from '@objectstack/spec';
1317
import { StorageNameMapping } from '@objectstack/spec/system';
1418
import { applyProtection } from '@objectstack/spec/shared';
@@ -116,17 +120,6 @@ function unclaimedTopLevelObjects(
116120
: undefined;
117121
}
118122

119-
/**
120-
* [ADR-0131 D6] A `sys_metadata` row stored in a legacy organization's layer:
121-
* no metadata read serves one, so the authored hook and action binders skip it
122-
* too. Judged off the row in hand — `sys_metadata` is a platform table, never a
123-
* federated object.
124-
*/
125-
function isLegacyOrganizationRow(row: { organization_id?: unknown } | null | undefined): boolean {
126-
const organizationId = row?.organization_id;
127-
return organizationId !== null && organizationId !== undefined && organizationId !== '';
128-
}
129-
130123
/**
131124
* Options for ObjectQLPlugin.
132125
*
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [#22835 · ADR-0131 D6] The datasource-admin plugin reads ENVIRONMENT rows
4+
// only, over the real showcase composition across a restart. Its reads of
5+
// stored `datasource` rows carried no organization predicate, so a row stored
6+
// in a legacy organization's layer was restored, served by both doors and
7+
// pooled, while the boot report named it as served by no read.
8+
//
9+
// Pinned: neither door serves the legacy row and no pool opens from it; the
10+
// environment row is served; the boot report still names the legacy row, and
11+
// it stays at rest; the by-name reads behind the plugin's writes never reach
12+
// it. The routes are mounted the way `packages/cli/src/commands/serve.ts` does.
13+
14+
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
15+
import { registerDatasourceAdminRoutes } from '@objectstack/service-datasource';
16+
import { type VerifyStack } from '@objectstack/verify';
17+
import { bootShowcase } from './showcase-boot.js';
18+
import { mkdtempSync, rmSync } from 'node:fs';
19+
import { tmpdir } from 'node:os';
20+
import { join } from 'node:path';
21+
22+
/** A fixture datasource stored in a legacy organization's layer. */
23+
const LEGACY = 'dogfood_legacy_ds_22835';
24+
const LEGACY_ORG = 'org_dogfood_legacy_22835';
25+
const LEGACY_LABEL = 'Legacy organization row 22835';
26+
/** A fixture datasource stored in the environment's layer: the control. */
27+
const ENV = 'dogfood_env_ds_22835';
28+
29+
const SYS = { isSystem: true, positions: [], permissions: [] };
30+
31+
type Row = Record<string, unknown>;
32+
interface Answer { status: number; json: Record<string, any> }
33+
34+
describe('[#22835] the datasource-admin plugin reads environment rows only (showcase, restart)', () => {
35+
let stack: VerifyStack;
36+
let token: string;
37+
let dir: string;
38+
/** Every `console.warn` line the measured boot printed. */
39+
let bootWarnings: string[] = [];
40+
/** The legacy row as it was stored, before the measured boot. */
41+
let plantedLegacy: Row | undefined;
42+
43+
const routes = () => ({
44+
name: 'dogfood.datasource-admin-routes',
45+
version: '1.0.0',
46+
optionalDependencies: ['com.objectstack.server.hono'],
47+
init: async (ctx: { getService?: (name: string) => unknown }) => {
48+
const httpServer = ctx.getService?.('http.server') ?? ctx.getService?.('http-server');
49+
registerDatasourceAdminRoutes(httpServer as never, ctx as never, '/api/v1');
50+
},
51+
});
52+
const boot = async () => {
53+
stack = await bootShowcase({ databaseFile: join(dir, 'showcase.db'), extraPlugins: [routes()] });
54+
token = await stack.signIn();
55+
};
56+
const ql = () => stack.kernel.getService('objectql') as {
57+
find(object: string, query: unknown): Promise<Row[]>;
58+
insert(object: string, row: Row, options: unknown): Promise<unknown>;
59+
};
60+
const storedRows = (name: string) => ql().find('sys_metadata', { where: { type: 'datasource', name }, context: SYS });
61+
const call = async (method: string, path: string, body?: unknown): Promise<Answer> => {
62+
const res = await stack.apiAs(token, method, path, body);
63+
return { status: res.status, json: (await res.json().catch(() => ({}))) as Record<string, any> };
64+
};
65+
const adminNames = async () => {
66+
const listed = await call('GET', '/datasources');
67+
expect(listed.status, JSON.stringify(listed.json)).toBe(200);
68+
return (listed.json.data?.datasources ?? []).map((d: Row) => d.name);
69+
};
70+
const metaNames = async () => {
71+
const listed = await call('GET', '/meta/datasource');
72+
expect(listed.status, JSON.stringify(listed.json)).toBe(200);
73+
const data = listed.json.data ?? listed.json;
74+
return ((Array.isArray(data) ? data : data?.items) ?? []).map((d: Row) => d?.name);
75+
};
76+
const pooled = (name: string) => {
77+
const connection = stack.kernel.getService('datasource-connection') as {
78+
listConnectionStates(): Array<{ name: string; status: string }>;
79+
};
80+
return connection.listConnectionStates().some((s) => s.name === name);
81+
};
82+
const legacyRows = async () => (await storedRows(LEGACY)).filter((r) => r.organization_id === LEGACY_ORG);
83+
const environmentRows = async (name: string) => (await storedRows(name)).filter((r) => r.organization_id == null);
84+
85+
beforeAll(async () => {
86+
dir = mkdtempSync(join(tmpdir(), 'dogfood-22835-'));
87+
await boot();
88+
// The rows an earlier release left, written with system context the way
89+
// that release's store held them: one in a legacy organization's layer,
90+
// one in the environment's.
91+
const now = new Date().toISOString();
92+
for (const [name, organizationId, label] of [
93+
[LEGACY, LEGACY_ORG, LEGACY_LABEL],
94+
[ENV, null, 'Environment row 22835'],
95+
] as const) {
96+
await ql().insert('sys_metadata', {
97+
type: 'datasource', name, organization_id: organizationId, package_id: null, scope: 'platform',
98+
state: 'active', version: 1, checksum: null, created_at: now, updated_at: now,
99+
metadata: JSON.stringify({ name, label, driver: 'sqlite', config: { filename: join(dir, `${name}.db`) } }),
100+
}, { context: SYS });
101+
}
102+
[plantedLegacy] = await legacyRows();
103+
await stack.stop();
104+
105+
const warn = vi.spyOn(console, 'warn');
106+
try {
107+
await boot();
108+
} finally {
109+
bootWarnings = warn.mock.calls.map((args) => args.map(String).join(' '));
110+
warn.mockRestore();
111+
}
112+
}, 240_000);
113+
114+
afterAll(async () => {
115+
await stack?.stop();
116+
if (dir) rmSync(dir, { recursive: true, force: true });
117+
});
118+
119+
it('premise: both rows are at rest, and the boot report names the legacy one', async () => {
120+
expect(plantedLegacy).toBeDefined();
121+
expect(await environmentRows(ENV)).toHaveLength(1);
122+
const report = bootWarnings.filter((line) => line.includes('[metadata_org_scoped_unserved]'));
123+
expect(report).toHaveLength(1);
124+
expect(report[0]).toContain(`${LEGACY}@${LEGACY_ORG}`);
125+
});
126+
127+
it('neither door serves the legacy row, and no pool is opened from it; the environment row is served', async () => {
128+
expect((await call('GET', `/datasources/${LEGACY}`)).status).toBe(404);
129+
expect((await call('GET', `/meta/datasource/${LEGACY}`)).status).toBe(404);
130+
expect(await adminNames()).not.toContain(LEGACY);
131+
expect(await metaNames()).not.toContain(LEGACY);
132+
expect(pooled(LEGACY)).toBe(false);
133+
134+
expect((await call('GET', `/datasources/${ENV}`)).status).toBe(200);
135+
expect((await call('GET', `/meta/datasource/${ENV}`)).status).toBe(200);
136+
expect(await adminNames()).toContain(ENV);
137+
expect(await metaNames()).toContain(ENV);
138+
expect(pooled(ENV)).toBe(true);
139+
});
140+
141+
it('the writes\' by-name reads never reach the legacy row, and the row is kept as it was', async () => {
142+
// An admin create under the legacy row's name writes an environment row of
143+
// its own; its remove takes that row away again.
144+
const created = await call('POST', '/datasources', {
145+
name: LEGACY, label: 'Admin create 22835', driver: 'sqlite', config: { filename: join(dir, 'admin.db') },
146+
});
147+
expect(created.status, JSON.stringify(created.json)).toBe(201);
148+
expect(await environmentRows(LEGACY)).toHaveLength(1);
149+
expect((await call('DELETE', `/datasources/${LEGACY}`)).status).toBe(204);
150+
expect(await environmentRows(LEGACY)).toHaveLength(0);
151+
152+
// A `/meta` save under the name: the admin door serves the saved body.
153+
const saved = await call('PUT', `/meta/datasource/${LEGACY}`, {
154+
name: LEGACY, label: 'Meta save 22835', driver: 'sqlite', config: { filename: join(dir, 'meta.db') },
155+
});
156+
expect(saved.status, JSON.stringify(saved.json)).toBe(200);
157+
const served = await call('GET', `/datasources/${LEGACY}`);
158+
expect(served.status, JSON.stringify(served.json)).toBe(200);
159+
expect(served.json.data?.datasource?.label).toBe('Meta save 22835');
160+
161+
// Neither deleted nor rewritten.
162+
expect(await legacyRows()).toEqual([plantedLegacy]);
163+
});
164+
});

‎packages/services/service-datasource/src/__tests__/datasource-admin-plugin.test.ts‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -732,4 +732,60 @@ describe('DatasourceAdminServicePlugin: runtime datasource durability', () => {
732732
expect(row().checksum).toBe(hashSpec(JSON.parse(row().metadata as string), 'datasource'));
733733
});
734734
});
735+
736+
// [#22835 · ADR-0131 D6] Every read here of a stored `datasource` row reads
737+
// the environment's rows only: a row stored in a legacy organization's layer
738+
// is never restored, served, pooled, updated or deleted. A legacy row under
739+
// the same name as an environment row is inserted FIRST, so a read that
740+
// picked the first row it found would pick it.
741+
describe('[#22835] a legacy organization-scoped row is read by none of the plugin\'s reads', () => {
742+
const row = (name: string, organizationId: string | null, host: string) => ({
743+
id: `meta_${name}_${organizationId ?? 'env'}`, name, type: 'datasource', organization_id: organizationId,
744+
metadata: JSON.stringify({ name, label: host, origin: 'runtime', driver: 'postgres', config: { host, database: 'db' } }),
745+
state: 'active', version: 1,
746+
});
747+
const host = async (rows: Array<Record<string, unknown>>) => {
748+
const data = Object.assign(fakeSysMetadataEngine(), { drivers: [] as any[] });
749+
(data as any).registerDriver = (d: any) => data.drivers.push(d);
750+
(data as any).getDriverByName = (n: string) => data.drivers.find((d) => d.name === n);
751+
data.rows.push(...rows);
752+
const built: any[] = [];
753+
const factory: IDatasourceDriverFactory = {
754+
supports: (id: string) => id === 'postgres',
755+
create: async (spec) => { built.push(spec); return { connect: async () => {}, disconnect: async () => {} }; },
756+
};
757+
const projectors = new Map<string, (evt: unknown) => Promise<void>>();
758+
const protocol = { registerMutationProjector: (t: string, p: (evt: unknown) => Promise<void>) => projectors.set(t, p) };
759+
const b = await boot({ driverFactory: factory, services: { data, protocol } });
760+
await b.plugin.start(b.ctx);
761+
const metaDoorWrote = (name: string) => projectors.get('datasource')!({ type: 'datasource', name, state: 'active' });
762+
return { ...b, data, built, metaDoorWrote };
763+
};
764+
const LEGACY = row('dup', 'org_legacy', 'legacy-host');
765+
766+
it('the boot restore registers and pools environment rows only', async () => {
767+
const b = await host([row('org_ds', 'org_legacy', 'org-host'), row('env_ds', null, 'env-host'), row('blank_ds', '', 'blank-host')]);
768+
expect(await b.metadata.get('datasource', 'org_ds')).toBeUndefined();
769+
expect((await b.service.listDatasources()).map((d) => d.name).sort()).toEqual(['blank_ds', 'env_ds']);
770+
expect(b.built.map((s) => s.name).sort()).toEqual(['blank_ds', 'env_ds']);
771+
expect(b.data.rows.find((r) => r.name === 'org_ds')).toEqual(row('org_ds', 'org_legacy', 'org-host'));
772+
});
773+
774+
it('the by-name reads skip a legacy row: projection and admin writes reach the environment row, and the legacy row is kept as it was', async () => {
775+
const b = await host([LEGACY, row('dup', null, 'env-host'), row('only_legacy', 'org_legacy', 'l-host')]);
776+
await b.metaDoorWrote('dup');
777+
await b.metaDoorWrote('only_legacy');
778+
expect(await b.metadata.get('datasource', 'dup')).toMatchObject({ label: 'env-host' });
779+
expect(await b.metadata.get('datasource', 'only_legacy')).toBeUndefined();
780+
expect([...new Set(b.built.map((s) => s.config.host))]).toEqual(['env-host']);
781+
782+
await b.service.updateDatasource('dup', { label: 'edited' });
783+
expect(JSON.parse(String(b.data.rows.find((r) => r.id === 'meta_dup_env')!.metadata)).label).toBe('edited');
784+
await b.service.removeDatasource('dup');
785+
expect(b.data.rows.some((r) => r.id === 'meta_dup_env')).toBe(false);
786+
await b.service.createDatasource({ name: 'dup', driver: 'postgres', config: { host: 'new-host', database: 'db' } });
787+
expect(b.data.rows.filter((r) => r.name === 'dup' && r.organization_id == null)).toHaveLength(1);
788+
expect(b.data.rows.find((r) => r.id === LEGACY.id)).toEqual(LEGACY);
789+
});
790+
});
735791
});

‎packages/services/service-datasource/src/__tests__/datasource-system-context.pin.test.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,8 +139,9 @@ describe('[#21913] runtime-datasource sys_metadata helpers carry the explicit sy
139139
expect(verbs.filter((v) => v === 'insert')).toHaveLength(1);
140140
expect(verbs.filter((v) => v === 'update')).toHaveLength(1);
141141
expect(verbs.filter((v) => v === 'delete')).toHaveLength(1);
142-
expect(verbs.filter((v) => v === 'find').length).toBeGreaterThanOrEqual(1);
143-
expect(verbs.filter((v) => v === 'findOne').length).toBeGreaterThanOrEqual(4);
142+
// [#22835] Every read is a `find` filtered to the environment's rows:
143+
// two existence probes, the boot restore, convergence and the delete probe.
144+
expect(verbs.filter((v) => v === 'find').length).toBeGreaterThanOrEqual(5);
144145
expect(onMetadata).toHaveLength(data.calls.length);
145146
expectAllSystem(onMetadata);
146147
});

0 commit comments

Comments
 (0)