Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .changeset/22835-datasource-reads-environment-only.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
'@objectstack/service-datasource': minor
'@objectstack/metadata-core': minor
'@objectstack/objectql': patch
---

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)

Clause-②: no (narrowing)

<!-- 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. -->

**BREAKING (narrowing).** Graded `minor` on the v18 prerelease line, as the read narrowing it completes.

**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:

- 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;
- 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;
- 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.

**What moves for consumers.**

| From | To |
|:--|:--|
| 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 |

`@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.
5 changes: 5 additions & 0 deletions packages/metadata-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,11 @@ export * from './meta-write-capability.js';
// surface is unchanged.
export * from './record-organization.js';

// [ADR-0131 D6] The one row-value test for a `sys_metadata` row stored in a
// legacy organization's layer — read by `@objectstack/objectql`'s binders and
// `@objectstack/service-datasource`'s runtime-datasource reads.
export * from './legacy-organization-row.js';

// [#16319] The field-`type` admission vocabulary — the predicate, the shared
// sentence and the cross-package discriminator behind the ruled 「禁止加载」
// refusal. Sunk here by the same criterion as everything above: the enforcing
Expand Down
16 changes: 16 additions & 0 deletions packages/metadata-core/src/legacy-organization-row.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect } from 'vitest';
import { isLegacyOrganizationRow } from './legacy-organization-row.js';

describe('[ADR-0131 D6] isLegacyOrganizationRow — the one row-value test', () => {
it('a row whose organization_id names an organization is a legacy organization row', () => {
expect(isLegacyOrganizationRow({ organization_id: 'org_a' })).toBe(true);
});

it('an absent, null or empty organization_id is the environment\'s row', () => {
for (const row of [{}, { organization_id: undefined }, { organization_id: null }, { organization_id: '' }, null, undefined]) {
expect(isLegacyOrganizationRow(row), JSON.stringify(row)).toBe(false);
}
});
});
19 changes: 19 additions & 0 deletions packages/metadata-core/src/legacy-organization-row.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [ADR-0131 D6] A `sys_metadata` row stored in a legacy organization's layer:
* its `organization_id` names an organization (neither absent, `null` nor the
* empty string). No metadata read serves one and no boot loads one; the boot
* report names them, and nothing deletes or rewrites them.
*
* Judged off the row in hand — `sys_metadata` is a platform table, never a
* federated object. Sunk here because its readers outside the metadata door —
* `@objectstack/objectql`'s authored hook and action binders, and
* `@objectstack/service-datasource`'s runtime-datasource reads — share no other
* common home, and a second spelling would let them disagree on which rows are
* the environment's.
*/
export function isLegacyOrganizationRow(row: { organization_id?: unknown } | null | undefined): boolean {
const organizationId = row?.organization_id;
return organizationId !== null && organizationId !== undefined && organizationId !== '';
}
15 changes: 4 additions & 11 deletions packages/objectql/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ import { resolveArtifactPackageOrder, artifactPackageId, readDeploymentOrgScopin
// multi-package stack no body declares, and the id they take. See
// `unclaimedTopLevelObjects` below.
import { unclaimedTopLevel } from '@objectstack/metadata';
// [ADR-0131 D6] The one row-value test for a legacy organization's
// `sys_metadata` row: no metadata read serves one, so the authored hook and
// action binders skip it too.
import { isLegacyOrganizationRow } from '@objectstack/metadata-core';
import { applyConversionsToStoredItem } from '@objectstack/spec';
import { StorageNameMapping } from '@objectstack/spec/system';
import { applyProtection } from '@objectstack/spec/shared';
Expand Down Expand Up @@ -116,17 +120,6 @@ function unclaimedTopLevelObjects(
: undefined;
}

/**
* [ADR-0131 D6] A `sys_metadata` row stored in a legacy organization's layer:
* no metadata read serves one, so the authored hook and action binders skip it
* too. Judged off the row in hand — `sys_metadata` is a platform table, never a
* federated object.
*/
function isLegacyOrganizationRow(row: { organization_id?: unknown } | null | undefined): boolean {
const organizationId = row?.organization_id;
return organizationId !== null && organizationId !== undefined && organizationId !== '';
}

/**
* Options for ObjectQLPlugin.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// [#22835 · ADR-0131 D6] The datasource-admin plugin reads ENVIRONMENT rows
// only, over the real showcase composition across a restart. Its reads of
// stored `datasource` rows carried no organization predicate, so a row stored
// in a legacy organization's layer was restored, served by both doors and
// pooled, while the boot report named it as served by no read.
//
// Pinned: neither door serves the legacy row and no pool opens from it; the
// environment row is served; the boot report still names the legacy row, and
// it stays at rest; the by-name reads behind the plugin's writes never reach
// it. The routes are mounted the way `packages/cli/src/commands/serve.ts` does.

import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import { registerDatasourceAdminRoutes } from '@objectstack/service-datasource';
import { type VerifyStack } from '@objectstack/verify';
import { bootShowcase } from './showcase-boot.js';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

/** A fixture datasource stored in a legacy organization's layer. */
const LEGACY = 'dogfood_legacy_ds_22835';
const LEGACY_ORG = 'org_dogfood_legacy_22835';
const LEGACY_LABEL = 'Legacy organization row 22835';
/** A fixture datasource stored in the environment's layer: the control. */
const ENV = 'dogfood_env_ds_22835';

const SYS = { isSystem: true, positions: [], permissions: [] };

type Row = Record<string, unknown>;
interface Answer { status: number; json: Record<string, any> }

describe('[#22835] the datasource-admin plugin reads environment rows only (showcase, restart)', () => {
let stack: VerifyStack;
let token: string;
let dir: string;
/** Every `console.warn` line the measured boot printed. */
let bootWarnings: string[] = [];
/** The legacy row as it was stored, before the measured boot. */
let plantedLegacy: Row | undefined;

const routes = () => ({
name: 'dogfood.datasource-admin-routes',
version: '1.0.0',
optionalDependencies: ['com.objectstack.server.hono'],
init: async (ctx: { getService?: (name: string) => unknown }) => {
const httpServer = ctx.getService?.('http.server') ?? ctx.getService?.('http-server');
registerDatasourceAdminRoutes(httpServer as never, ctx as never, '/api/v1');
},
});
const boot = async () => {
stack = await bootShowcase({ databaseFile: join(dir, 'showcase.db'), extraPlugins: [routes()] });
token = await stack.signIn();
};
const ql = () => stack.kernel.getService('objectql') as {
find(object: string, query: unknown): Promise<Row[]>;
insert(object: string, row: Row, options: unknown): Promise<unknown>;
};
const storedRows = (name: string) => ql().find('sys_metadata', { where: { type: 'datasource', name }, context: SYS });
const call = async (method: string, path: string, body?: unknown): Promise<Answer> => {
const res = await stack.apiAs(token, method, path, body);
return { status: res.status, json: (await res.json().catch(() => ({}))) as Record<string, any> };
};
const adminNames = async () => {
const listed = await call('GET', '/datasources');
expect(listed.status, JSON.stringify(listed.json)).toBe(200);
return (listed.json.data?.datasources ?? []).map((d: Row) => d.name);
};
const metaNames = async () => {
const listed = await call('GET', '/meta/datasource');
expect(listed.status, JSON.stringify(listed.json)).toBe(200);
const data = listed.json.data ?? listed.json;
return ((Array.isArray(data) ? data : data?.items) ?? []).map((d: Row) => d?.name);
};
const pooled = (name: string) => {
const connection = stack.kernel.getService('datasource-connection') as {
listConnectionStates(): Array<{ name: string; status: string }>;
};
return connection.listConnectionStates().some((s) => s.name === name);
};
const legacyRows = async () => (await storedRows(LEGACY)).filter((r) => r.organization_id === LEGACY_ORG);
const environmentRows = async (name: string) => (await storedRows(name)).filter((r) => r.organization_id == null);

beforeAll(async () => {
dir = mkdtempSync(join(tmpdir(), 'dogfood-22835-'));
await boot();
// The rows an earlier release left, written with system context the way
// that release's store held them: one in a legacy organization's layer,
// one in the environment's.
const now = new Date().toISOString();
for (const [name, organizationId, label] of [
[LEGACY, LEGACY_ORG, LEGACY_LABEL],
[ENV, null, 'Environment row 22835'],
] as const) {
await ql().insert('sys_metadata', {
type: 'datasource', name, organization_id: organizationId, package_id: null, scope: 'platform',
state: 'active', version: 1, checksum: null, created_at: now, updated_at: now,
metadata: JSON.stringify({ name, label, driver: 'sqlite', config: { filename: join(dir, `${name}.db`) } }),
}, { context: SYS });
}
[plantedLegacy] = await legacyRows();
await stack.stop();

const warn = vi.spyOn(console, 'warn');
try {
await boot();
} finally {
bootWarnings = warn.mock.calls.map((args) => args.map(String).join(' '));
warn.mockRestore();
}
}, 240_000);

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

it('premise: both rows are at rest, and the boot report names the legacy one', async () => {
expect(plantedLegacy).toBeDefined();
expect(await environmentRows(ENV)).toHaveLength(1);
const report = bootWarnings.filter((line) => line.includes('[metadata_org_scoped_unserved]'));
expect(report).toHaveLength(1);
expect(report[0]).toContain(`${LEGACY}@${LEGACY_ORG}`);
});

it('neither door serves the legacy row, and no pool is opened from it; the environment row is served', async () => {
expect((await call('GET', `/datasources/${LEGACY}`)).status).toBe(404);
expect((await call('GET', `/meta/datasource/${LEGACY}`)).status).toBe(404);
expect(await adminNames()).not.toContain(LEGACY);
expect(await metaNames()).not.toContain(LEGACY);
expect(pooled(LEGACY)).toBe(false);

expect((await call('GET', `/datasources/${ENV}`)).status).toBe(200);
expect((await call('GET', `/meta/datasource/${ENV}`)).status).toBe(200);
expect(await adminNames()).toContain(ENV);
expect(await metaNames()).toContain(ENV);
expect(pooled(ENV)).toBe(true);
});

it('the writes\' by-name reads never reach the legacy row, and the row is kept as it was', async () => {
// An admin create under the legacy row's name writes an environment row of
// its own; its remove takes that row away again.
const created = await call('POST', '/datasources', {
name: LEGACY, label: 'Admin create 22835', driver: 'sqlite', config: { filename: join(dir, 'admin.db') },
});
expect(created.status, JSON.stringify(created.json)).toBe(201);
expect(await environmentRows(LEGACY)).toHaveLength(1);
expect((await call('DELETE', `/datasources/${LEGACY}`)).status).toBe(204);
expect(await environmentRows(LEGACY)).toHaveLength(0);

// A `/meta` save under the name: the admin door serves the saved body.
const saved = await call('PUT', `/meta/datasource/${LEGACY}`, {
name: LEGACY, label: 'Meta save 22835', driver: 'sqlite', config: { filename: join(dir, 'meta.db') },
});
expect(saved.status, JSON.stringify(saved.json)).toBe(200);
const served = await call('GET', `/datasources/${LEGACY}`);
expect(served.status, JSON.stringify(served.json)).toBe(200);
expect(served.json.data?.datasource?.label).toBe('Meta save 22835');

// Neither deleted nor rewritten.
expect(await legacyRows()).toEqual([plantedLegacy]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -732,4 +732,60 @@ describe('DatasourceAdminServicePlugin: runtime datasource durability', () => {
expect(row().checksum).toBe(hashSpec(JSON.parse(row().metadata as string), 'datasource'));
});
});

// [#22835 · ADR-0131 D6] Every read here of a stored `datasource` row reads
// the environment's rows only: a row stored in a legacy organization's layer
// is never restored, served, pooled, updated or deleted. A legacy row under
// the same name as an environment row is inserted FIRST, so a read that
// picked the first row it found would pick it.
describe('[#22835] a legacy organization-scoped row is read by none of the plugin\'s reads', () => {
const row = (name: string, organizationId: string | null, host: string) => ({
id: `meta_${name}_${organizationId ?? 'env'}`, name, type: 'datasource', organization_id: organizationId,
metadata: JSON.stringify({ name, label: host, origin: 'runtime', driver: 'postgres', config: { host, database: 'db' } }),
state: 'active', version: 1,
});
const host = async (rows: Array<Record<string, unknown>>) => {
const data = Object.assign(fakeSysMetadataEngine(), { drivers: [] as any[] });
(data as any).registerDriver = (d: any) => data.drivers.push(d);
(data as any).getDriverByName = (n: string) => data.drivers.find((d) => d.name === n);
data.rows.push(...rows);
const built: any[] = [];
const factory: IDatasourceDriverFactory = {
supports: (id: string) => id === 'postgres',
create: async (spec) => { built.push(spec); return { connect: async () => {}, disconnect: async () => {} }; },
};
const projectors = new Map<string, (evt: unknown) => Promise<void>>();
const protocol = { registerMutationProjector: (t: string, p: (evt: unknown) => Promise<void>) => projectors.set(t, p) };
const b = await boot({ driverFactory: factory, services: { data, protocol } });
await b.plugin.start(b.ctx);
const metaDoorWrote = (name: string) => projectors.get('datasource')!({ type: 'datasource', name, state: 'active' });
return { ...b, data, built, metaDoorWrote };
};
const LEGACY = row('dup', 'org_legacy', 'legacy-host');

it('the boot restore registers and pools environment rows only', async () => {
const b = await host([row('org_ds', 'org_legacy', 'org-host'), row('env_ds', null, 'env-host'), row('blank_ds', '', 'blank-host')]);
expect(await b.metadata.get('datasource', 'org_ds')).toBeUndefined();
expect((await b.service.listDatasources()).map((d) => d.name).sort()).toEqual(['blank_ds', 'env_ds']);
expect(b.built.map((s) => s.name).sort()).toEqual(['blank_ds', 'env_ds']);
expect(b.data.rows.find((r) => r.name === 'org_ds')).toEqual(row('org_ds', 'org_legacy', 'org-host'));
});

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 () => {
const b = await host([LEGACY, row('dup', null, 'env-host'), row('only_legacy', 'org_legacy', 'l-host')]);
await b.metaDoorWrote('dup');
await b.metaDoorWrote('only_legacy');
expect(await b.metadata.get('datasource', 'dup')).toMatchObject({ label: 'env-host' });
expect(await b.metadata.get('datasource', 'only_legacy')).toBeUndefined();
expect([...new Set(b.built.map((s) => s.config.host))]).toEqual(['env-host']);

await b.service.updateDatasource('dup', { label: 'edited' });
expect(JSON.parse(String(b.data.rows.find((r) => r.id === 'meta_dup_env')!.metadata)).label).toBe('edited');
await b.service.removeDatasource('dup');
expect(b.data.rows.some((r) => r.id === 'meta_dup_env')).toBe(false);
await b.service.createDatasource({ name: 'dup', driver: 'postgres', config: { host: 'new-host', database: 'db' } });
expect(b.data.rows.filter((r) => r.name === 'dup' && r.organization_id == null)).toHaveLength(1);
expect(b.data.rows.find((r) => r.id === LEGACY.id)).toEqual(LEGACY);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -139,8 +139,9 @@ describe('[#21913] runtime-datasource sys_metadata helpers carry the explicit sy
expect(verbs.filter((v) => v === 'insert')).toHaveLength(1);
expect(verbs.filter((v) => v === 'update')).toHaveLength(1);
expect(verbs.filter((v) => v === 'delete')).toHaveLength(1);
expect(verbs.filter((v) => v === 'find').length).toBeGreaterThanOrEqual(1);
expect(verbs.filter((v) => v === 'findOne').length).toBeGreaterThanOrEqual(4);
// [#22835] Every read is a `find` filtered to the environment's rows:
// two existence probes, the boot restore, convergence and the delete probe.
expect(verbs.filter((v) => v === 'find').length).toBeGreaterThanOrEqual(5);
expect(onMetadata).toHaveLength(data.calls.length);
expectAllSystem(onMetadata);
});
Expand Down
Loading
Loading