diff --git a/.changeset/21922-code-datasource-wins-at-restore.md b/.changeset/21922-code-datasource-wins-at-restore.md new file mode 100644 index 00000000000..31aebb21fb3 --- /dev/null +++ b/.changeset/21922-code-datasource-wins-at-restore.md @@ -0,0 +1,29 @@ +--- +"@objectstack/service-datasource": minor +"@objectstack/metadata-protocol": minor +"@objectstack/runtime": minor +--- + +fix(service-datasource,runtime,metadata-protocol)!: a stored datasource row no longer displaces a code-defined datasource at boot, and the metadata door refuses edits to the host's `default` (#21922, #21944) + +Clause-②: no (narrowing) + +A code-defined datasource (one the installed artifact declares in `*.datasource.ts`, or the host's own `default`) is read-only: `DatasourceSchema.origin` publishes it as "GitOps-owned, read-only in the UI", and the datasource-admin service states "code wins on collision". The boot restore broke both. It registered every stored `datasource` row in `sys_metadata` over whatever the runtime had registered from code, so after a restart a row left under a code-defined name was served by the admin door, editable there when it carried `origin: 'runtime'`, and handed to pool rehydration. A stored `default` row opened a second live pool named `default` on the row's own connection. The metadata door also still saved edits to `default`, the one code-defined datasource no package declares. + +The runtime now keeps one in-memory set of the datasource names it registers from code, on the kernel service `code-datasource-names`: `AppPlugin` adds the datasources the artifact declares and `DefaultDatasourcePlugin` adds `default`, both in `init()`, so the set is complete before any `start()` runs. The boot restore skips a stored row under a name in that set, and the metadata door's code-datasource check reads the same set. + +**BREAKING — what moves for consumers.** + +- After a restart over a stored row under a code-defined datasource's name, `GET /api/v1/datasources` serves the code definition (`origin: code`) instead of the row, and `PATCH /api/v1/datasources/:name` answers `400 DATASOURCE_ADMIN_ERROR` ("… is code-defined and cannot be edited at runtime.") where it answered 200 for a row that carried `origin: 'runtime'`. +- No live pool is opened from such a row at boot. +- `PUT /api/v1/meta/datasource/default` answered 200 and now answers `403 NOT_OVERRIDABLE`. `DELETE /api/v1/meta/datasource/default` with no stored row answered 200 and now answers the same `403`. The refusal's remedy names the host's database configuration (the database URL the server starts with), which is what defines `default`; every other code-defined datasource's refusal still names its `*.datasource.ts` source. +- The skipped row is kept, and the boot logs one warning naming it. + +**Remedy.** + +- To change a code-defined datasource, change its code definition and redeploy: its `*.datasource.ts` source, or the host's database configuration for `default`. +- A row the boot warning names is removable, and removing it is the repair: `DELETE /api/v1/meta/datasource/:name` answers 200 and deletes it. + +**Unchanged.** A runtime datasource with no code twin restores, saves and deletes through both doors as before. A host that composes neither `AppPlugin` nor `DefaultDatasourcePlugin` registers no set, and its stored rows restore as before. While a stored row exists under a code-defined name, `GET /api/v1/meta/datasource/:name` still serves that row (the metadata door reads its stored overlay first); after the `DELETE` above it serves the code definition, in the same boot. + + diff --git a/packages/metadata-protocol/src/packaged-base-regime.ts b/packages/metadata-protocol/src/packaged-base-regime.ts index 3b234c9fddf..120c02c411d 100644 --- a/packages/metadata-protocol/src/packaged-base-regime.ts +++ b/packages/metadata-protocol/src/packaged-base-regime.ts @@ -77,6 +77,11 @@ * remedy. The two doors keep their own codes (`NOT_OVERRIDABLE` / 403 here, * `DATASOURCE_ADMIN_ERROR` / 400 there); the verdict and the remedy agree. * + * [#21944] One code-defined datasource has no source file: the host's + * `default`, defined by the database the server starts with. The row lists it + * under `hostOwned`, and its sentence names that configuration instead of a + * `*.datasource.ts` nobody can find. Every other name keeps the source remedy. + * * The row is also what {@link isOriginGatedType} answers from, for the one * removal both the protocol's delete door and the repository's delete gate * allow on such a type: deleting a STORED row under a code-defined name. The @@ -127,6 +132,13 @@ export type PackagedBaseRegimeRow = readonly source: string; /** The decision record the sentence cites. */ readonly docs: string; + /** + * [#21944] Names the HOST defines from its own configuration rather than + * from a {@link source} file, each with what defines it. For such a name + * the source-file remedy is false — no such file exists, and an artifact + * may not declare the name at all — so its sentence names this instead. + */ + readonly hostOwned?: Readonly>; }; /** The table. Keyed by the canonical (singular) metadata type. */ @@ -159,6 +171,14 @@ export const PACKAGED_BASE_REGIME: Readonly }>; seed?: Row[]; + /** [#21944] The kernel services the protocol resolves — the host's code-datasource set among them. */ + services?: Map; } = {}) { const rows = new Map(); for (const r of opts.seed ?? []) rows.set(r.id, r); @@ -175,7 +177,7 @@ function makeSession(opts: { }, }; - const protocol = new ObjectStackProtocolImplementation(engine, () => new Map(), opts.environmentId) as any; + const protocol = new ObjectStackProtocolImplementation(engine, () => opts.services ?? new Map(), opts.environmentId) as any; return { protocol, rows, historyRows }; } @@ -208,8 +210,9 @@ describe('[#21899] the resolver — a datasource an installed package declares i // The plural spelling folds before the read (#4432). expect(protocol.isArtifactBacked('datasources', CODE_DS)).toBe(true); expect(protocol.isArtifactBacked('datasource', RUNTIME_DS)).toBe(false); - // The host's `default` is declared by no package: the named gap, pinned - // as what this resolver answers so a change to it is seen. + // The host's `default` is declared by no package: with no host + // code-datasource set registered, the packages alone do not see it + // ([#21944] the set is what does — see the block at the foot of this file). expect(protocol.isArtifactBacked('datasource', 'default')).toBe(false); }); @@ -419,3 +422,111 @@ describe('[#21899] the repository delete gate lifts the origin-gated type only', expectVerdict(err.message, SAVE_VERDICT); }); }); + +/** + * [#21944] The host's `default` datasource, which no package declares. The + * runtime registers it from code (`DefaultDatasourcePlugin`) and adds it to the + * host's code-datasource set — the kernel service `'code-datasource-names'` — + * in Phase 1; the resolver reads that set beside the packages, so the door + * answers `default` as the admin door does: read-only. + */ +const CODE_NAMES_SERVICE = 'code-datasource-names'; +const DEFAULT_SAVE_VERDICT = "Datasource 'default' is code-defined and cannot be edited at runtime: it is read-only."; +const DEFAULT_DELETE_VERDICT = "Datasource 'default' is code-defined and cannot be removed at runtime: it is read-only."; +const hostServices = (names: string[]) => new Map([[CODE_NAMES_SERVICE, new Set(names)]]); +/** + * The remedy `default`'s refusal must carry: no `*.datasource.ts` declares + * `default` — the host defines it from the database the server starts with — + * so the sentence names that, and never the source-file remedy. + */ +const HOST_REMEDY = "It is defined by the host's database configuration"; +const expectHostRemedy = (message: unknown) => { + const text = String(message); + expect(text).toContain(HOST_REMEDY); + expect(text).toContain('restart the server'); + expect(text).not.toContain('.datasource.ts'); + expect(text).not.toContain('OS_METADATA_WRITABLE'); +}; + +describe('[#21944] the resolver reads the host\'s code-datasource set', () => { + it('sees a name the host registers from code, and only the `datasource` type', () => { + const { protocol } = makeSession({ packages: [SHOWCASE_PACKAGE], services: hostServices(['default']) }); + expect(protocol.isArtifactBacked('datasource', 'default')).toBe(true); + expect(protocol.isArtifactBacked('datasources', 'default')).toBe(true); + expect(protocol.isArtifactBacked('object', 'default')).toBe(false); + // The packages' answer is unchanged beside it. + expect(protocol.isArtifactBacked('datasource', CODE_DS)).toBe(true); + expect(protocol.isArtifactBacked('datasource', RUNTIME_DS)).toBe(false); + }); + + it('a service under that name with no `has` is not read as a set (nothing is guessed)', () => { + const { protocol } = makeSession({ services: new Map([[CODE_NAMES_SERVICE, ['default']]]) }); + expect(protocol.isArtifactBacked('datasource', 'default')).toBe(false); + }); +}); + +for (const { label, environmentId } of KERNELS) { + describe(`[#21944] the /meta door on the host's \`default\` datasource — ${label}`, () => { + beforeEach(resetEnvHatch); + afterEach(resetEnvHatch); + + const session = (seed?: Row[]) => makeSession({ + ...(environmentId ? { environmentId } : {}), + packages: [SHOWCASE_PACKAGE], + services: hostServices(['default']), + ...(seed ? { seed } : {}), + }); + + it('PUT is refused NOT_OVERRIDABLE / 403 with the admin door\'s verdict, and stores nothing', async () => { + const { protocol, rows, historyRows } = session(); + + const err = await refusalOf(protocol.saveMetaItem({ type: 'datasource', name: 'default', item: body('default', 'Meta Renamed default') })); + + expect({ code: err?.code, status: err?.status }).toEqual({ code: 'NOT_OVERRIDABLE', status: 403 }); + expect(String(err?.message).startsWith(`${DEFAULT_SAVE_VERDICT} `), String(err?.message)).toBe(true); + expectHostRemedy(err?.message); + expect(rows.size).toBe(0); + expect(historyRows).toEqual([]); + }); + + it('DELETE with no stored row is refused the same way, naming the removal', async () => { + const { protocol, rows } = session(); + + const err = await refusalOf(protocol.deleteMetaItem({ type: 'datasource', name: 'default' })); + + expect({ code: err?.code, status: err?.status }).toEqual({ code: 'NOT_OVERRIDABLE', status: 403 }); + expect(String(err?.message).startsWith(`${DEFAULT_DELETE_VERDICT} `), String(err?.message)).toBe(true); + expectHostRemedy(err?.message); + expect(rows.size).toBe(0); + }); + + it('DELETE of a pre-existing stored row of `default` answers 200 and removes it (repair)', async () => { + const { protocol, rows } = session([shadowRow('default')]); + + const res = await protocol.deleteMetaItem({ type: 'datasource', name: 'default' }); + + expect(res).toMatchObject({ success: true, reset: true }); + expect(Array.from(rows.values()).filter((r) => r.name === 'default')).toEqual([]); + }); + + it('side by side: `default` names the host\'s configuration, a package-declared datasource still names its source', async () => { + const { protocol } = session(); + + const host = await refusalOf(protocol.saveMetaItem({ type: 'datasource', name: 'default', item: body('default', 'x') })); + const packaged = await refusalOf(protocol.saveMetaItem({ type: 'datasource', name: CODE_DS, item: body(CODE_DS, 'x') })); + + expect({ code: host?.code, status: host?.status }).toEqual({ code: 'NOT_OVERRIDABLE', status: 403 }); + expect({ code: packaged?.code, status: packaged?.status }).toEqual({ code: 'NOT_OVERRIDABLE', status: 403 }); + expectHostRemedy(host?.message); + expectVerdict(packaged?.message, SAVE_VERDICT); + expect(String(packaged?.message)).not.toContain(HOST_REMEDY); + }); + + it('control: a runtime datasource still saves with the set registered', async () => { + const { protocol, rows } = session(); + const saved = await protocol.saveMetaItem({ type: 'datasource', name: RUNTIME_DS, item: body(RUNTIME_DS, 'Runtime') }); + expect(saved).toMatchObject({ success: true }); + expect(Array.from(rows.values()).filter((r) => r.name === RUNTIME_DS)).toHaveLength(1); + }); + }); +} diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 3bc11cba4cf..c7f815cdaa8 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -16263,28 +16263,35 @@ export class ObjectStackProtocolImplementation implements * never filters package records, and a disabled package still ships the * datasource the runtime registered for it — the artifact-only lookup * answers for a disabled package's items the same way. + * - [#21944] **The host's code-datasource set**, the kernel service + * `'code-datasource-names'`: every datasource name the host registers + * from code, filled by the runtime in Phase 1 (`AppPlugin` with what the + * artifact declares, `DefaultDatasourcePlugin` with the host's own + * `default`). It is how this predicate sees `default`, which no package + * declares — `DefaultDatasourcePlugin` registers it from the host's + * definition, and the datasource-admin service refuses to edit or remove + * it as code-defined. The same set decides the datasource-admin plugin's + * boot restore (#21922), so the two doors read one answer. Read per + * call through the services registry, by name (its producer is + * `@objectstack/runtime`'s `code-datasource-names.ts`, which this + * package does not depend on); absent on a host that composes no + * code-datasource producer, where nothing is added to the packages' + * answer. * - ⛔ **Never the MetadataService slot's `origin`**: a stored row the - * datasource-admin plugin restores at boot overwrites that slot, `origin` + * datasource-admin plugin restored at boot overwrote that slot, `origin` * and all, so the slot answers what was stored last, not what a package * ships. ⛔ **Never a request body's `origin`**: the caller sets it. * * ## What it does not see * - * - **The host's `default` datasource.** No package declares it: - * `DefaultDatasourcePlugin` registers it from the host's own definition, - * in memory, as `origin: 'code'`, and the datasource-admin service - * refuses to edit or remove it as code-defined. The host's code - * datasource set is not readable from this package — the MetadataService - * slot is the unsound source above, the connection service retains no - * origin, and the engine's datasource definitions mix both origins — so - * the `/meta` door still answers for `default` as it did. A named gap, - * not a reading this predicate makes. - * - **A name no package declares** answers false and keeps the - * `runtime-only` intent: a runtime datasource stays creatable, editable - * and removable through this door. + * - **A name neither a package declares nor the host registers from + * code** answers false and keeps the `runtime-only` intent: a runtime + * datasource stays creatable, editable and removable through this door. */ private isDeclaredCodeDatasource(type: string, name: string): boolean { if ((PLURAL_TO_SINGULAR[type] ?? type) !== 'datasource') return false; + const hostCode = this.getServicesRegistry?.().get('code-datasource-names') as { has?: unknown } | undefined; + if (typeof hostCode?.has === 'function' && (hostCode as { has(n: string): boolean }).has(name)) return true; const registry = (this.engine as any)?.registry; if (typeof registry?.getAllPackages !== 'function') return false; for (const record of registry.getAllPackages() as unknown[]) { diff --git a/packages/qa/dogfood/test/datasource-restore-code-wins.dogfood.test.ts b/packages/qa/dogfood/test/datasource-restore-code-wins.dogfood.test.ts new file mode 100644 index 00000000000..9372c5fecba --- /dev/null +++ b/packages/qa/dogfood/test/datasource-restore-code-wins.dogfood.test.ts @@ -0,0 +1,279 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#21922 / #21944] Across a restart, a stored datasource row never displaces a +// code datasource — over the real showcase composition. +// +// ## What was broken +// +// The datasource-admin plugin's boot restore registered EVERY stored +// `datasource` row in the MetadataService, last-write-wins over the code +// definition the runtime had registered. Measured on this composition before +// the fix, with a stored row asserting `origin: 'runtime'` and its own +// `config.filename` under each code name: +// +// - `showcase_external` — the admin list served it as `origin: runtime` with +// the stored label, and `PATCH /api/v1/datasources/showcase_external` +// answered 200: a code-defined datasource edited at runtime; +// - `default` — the restore handed the boot's pool rehydration a "runtime" +// record, and it opened a SECOND live pool named `default` on the stored +// row's file (`getDriverByName('default')` answered that driver; the +// connect verdict went `already-registered` → `connected`); +// - and the `/meta` door saved an edit of `default` (`PUT` 200), the one +// code datasource no package declares. +// +// ## What each case pins (triage's pins on both cards) +// +// - after a restart with a stored row under each code name, the admin door +// serves the code definition and refuses the edit, and no pool is opened +// from a stored row; +// - the `/meta` door's `DELETE` of such a row (the repair) removes it, and +// what the admin door serves does not change; +// - `PUT` and `DELETE` on `/api/v1/meta/datasource/default` are refused with +// the answer the door gives every code-defined datasource, its remedy +// naming the host's database configuration — no `*.datasource.ts` +// declares `default`; +// - a runtime datasource with no code twin still restores, and one still +// saves through the `/meta` door. +// +// ⚠ Not pinned here, and named rather than hidden: while a stored row exists, +// `GET /api/v1/meta/datasource/:name` serves THAT row — the door reads its +// stored overlay first (ADR-0005's read order), whatever the MetadataService +// holds. `meta-door-code-datasource.dogfood.test.ts` pins that read as it is. +// After the repair below it serves the code definition, in the same boot. +// +// The verify harness composes the datasource-admin service but not its REST +// routes, so this file mounts `registerDatasourceAdminRoutes` the way +// `packages/cli/src/commands/serve.ts` does. The working directory is a +// temporary one because the showcase's external datasource and its fixture +// both name a cwd-relative SQLite file. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import showcaseStack, { onEnable } from '@objectstack/example-showcase'; +import { registerDatasourceAdminRoutes } from '@objectstack/service-datasource'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +/** The showcase's code-defined datasource. */ +const EXTERNAL = 'showcase_external'; +const EXTERNAL_LABEL = 'External Analytics (SQLite)'; +/** The label and origin the stored rows assert — what an earlier runtime write could leave. */ +const SHADOW_LABEL = 'Shadow 21922'; +/** A runtime datasource created through the admin door before the restart. */ +const RUNTIME = 'dogfood_rt_21922'; +/** A runtime datasource created through the `/meta` door. */ +const META_RUNTIME = 'dogfood_meta_rt_21944'; + +const SYS = { isSystem: true, positions: [], permissions: [] }; + +interface Answer { + status: number; + json: { + success?: boolean; + reset?: boolean; + error?: string | { code?: string; message?: string }; + code?: string; + item?: Record; + records?: unknown[]; + data?: { datasource?: Record; datasources?: Array> }; + }; +} + +/** `{ status, code, message }` from either door's refusal envelope. */ +const refusal = (a: Answer) => { + const { error, code } = a.json; + return typeof error === 'object' && error !== null + ? { status: a.status, code: error.code, message: String(error.message ?? '') } + : { status: a.status, code, message: String(error ?? '') }; +}; + +describe('[#21922 / #21944] a stored datasource row never displaces a code datasource across a restart (showcase)', () => { + let stack: VerifyStack; + let token: string; + let prevCwd: string; + let dir: string; + /** What `showcase_ext_customer` answers on a fresh boot — the code fixture's rows. */ + let fixtureRows: number; + + 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 bootStack(showcaseStack, { databaseFile: join(dir, 'showcase.db'), extraPlugins: [routes()] }); + token = await stack.signIn(); + }; + const restart = async () => { + await stack.stop(); + await boot(); + }; + const call = async (method: string, path: string, body?: unknown): Promise => { + const res = await stack.apiAs(token, method, path, body); + return { status: res.status, json: (await res.json().catch(() => ({}))) as Answer['json'] }; + }; + const storedRows = async (name: string) => { + const ql = (await stack.kernel.getServiceAsync('objectql')) as { + find(object: string, query: unknown): Promise>>; + }; + return ql.find('sys_metadata', { where: { type: 'datasource', name }, context: SYS }); + }; + const adminEntry = async (name: string) => { + const listed = await call('GET', '/datasources'); + expect(listed.status, JSON.stringify(listed.json)).toBe(200); + return listed.json.data?.datasources?.find((d) => d.name === name); + }; + const customerRows = async () => { + const read = await call('GET', '/data/showcase_ext_customer'); + expect(read.status, JSON.stringify(read.json)).toBe(200); + return read.json.records?.length; + }; + const connectVerdict = (name: string) => { + const connection = stack.kernel.getService('datasource-connection') as { + listConnectionStates(): Array<{ name: string; status: string }>; + }; + return connection.listConnectionStates().find((s) => s.name === name)?.status; + }; + const engine = () => stack.kernel.getService('objectql') as { getDriverByName(name: string): unknown }; + + beforeAll(async () => { + prevCwd = process.cwd(); + dir = mkdtempSync(join(tmpdir(), 'dogfood-21922-')); + process.chdir(dir); + // Provision the remote tables, exactly as `os dev` does at boot. + await onEnable({ logger: { info() {}, warn() {} } } as never); + await boot(); + fixtureRows = (await customerRows()) ?? -1; + }, 180_000); + + afterAll(async () => { + await stack?.stop(); + if (prevCwd) process.chdir(prevCwd); + if (dir) rmSync(dir, { recursive: true, force: true }); + }); + + it('premise: both code datasources are served as code, the default pool is the host\'s own, and no row is stored', async () => { + expect(fixtureRows).toBeGreaterThan(0); + expect(await adminEntry(EXTERNAL)).toMatchObject({ origin: 'code', label: EXTERNAL_LABEL }); + expect(await adminEntry('default')).toMatchObject({ origin: 'code' }); + expect(connectVerdict('default')).toBe('already-registered'); + expect(engine().getDriverByName('default')).toBeUndefined(); + expect(await storedRows(EXTERNAL)).toEqual([]); + expect(await storedRows('default')).toEqual([]); + }); + + it('the /meta door refuses PUT and a no-row DELETE of `default`, as for every code-defined datasource', async () => { + const served = await call('GET', '/meta/datasource/default'); + expect(served.status, JSON.stringify(served.json)).toBe(200); + const body = Object.fromEntries(Object.entries(served.json.item ?? {}).filter(([k]) => !k.startsWith('_'))); + + const put = refusal(await call('PUT', '/meta/datasource/default', { ...body, label: 'Meta Renamed default 21944' })); + expect({ status: put.status, code: put.code }).toEqual({ status: 403, code: 'NOT_OVERRIDABLE' }); + expect(put.message.startsWith("Datasource 'default' is code-defined and cannot be edited at runtime: it is read-only."), put.message).toBe(true); + + const del = refusal(await call('DELETE', '/meta/datasource/default')); + expect({ status: del.status, code: del.code }).toEqual({ status: 403, code: 'NOT_OVERRIDABLE' }); + expect(del.message.startsWith("Datasource 'default' is code-defined and cannot be removed at runtime: it is read-only."), del.message).toBe(true); + + // The remedy tells the truth about `default`: no `*.datasource.ts` declares + // it — the host defines it from the database the server starts with. + for (const message of [put.message, del.message]) { + expect(message).toContain("It is defined by the host's database configuration"); + expect(message).not.toContain('.datasource.ts'); + } + + expect(await storedRows('default')).toEqual([]); + }); + + it('control: a runtime datasource still saves through the /meta door', async () => { + const saved = await call('PUT', `/meta/datasource/${META_RUNTIME}`, { + name: META_RUNTIME, label: 'Meta Runtime 21944', driver: 'sqlite', config: { filename: join(dir, 'meta-rt.db') }, + }); + expect(saved.status, JSON.stringify(saved.json)).toBe(200); + expect(await storedRows(META_RUNTIME)).toHaveLength(1); + }); + + it('after a restart over a stored row under each code name: the admin door serves code, refuses the edit, and no pool opens from a row', async () => { + // A runtime datasource with no code twin, created before the restart. + const created = await call('POST', '/datasources', { + name: RUNTIME, label: 'Runtime 21922', driver: 'sqlite', config: { filename: join(dir, 'rt.db') }, + }); + expect(created.status, JSON.stringify(created.json)).toBe(201); + + // The rows an earlier runtime write left: each asserts `origin: 'runtime'` + // and its own connection file — the fixture shape under which the admin + // door's refusal cannot come from its `origin ?? 'code'` read default. + // Written through the `/meta` door's own repository on the runtime-only + // intent, the write the door's save made before it refused these names. + const protocol = stack.kernel.getService('protocol') as { + getOverlayRepo(organizationId: string | null): { + put(ref: unknown, body: unknown, opts: unknown): Promise; + }; + }; + const external = await call('GET', `/meta/datasource/${EXTERNAL}`); + const externalBody = Object.fromEntries(Object.entries(external.json.item ?? {}).filter(([k]) => !k.startsWith('_'))); + await protocol.getOverlayRepo(null).put( + { org: 'env', type: 'datasource', name: EXTERNAL }, + { ...externalBody, label: SHADOW_LABEL, origin: 'runtime', config: { filename: join(dir, 'shadow-external.db') } }, + { parentVersion: null, actor: null, intent: 'runtime-only', source: 'dogfood.pre-fix-runtime-write' }, + ); + await protocol.getOverlayRepo(null).put( + { org: 'env', type: 'datasource', name: 'default' }, + { name: 'default', label: SHADOW_LABEL, driver: 'sqlite', origin: 'runtime', config: { filename: join(dir, 'shadow-default.db') } }, + { parentVersion: null, actor: null, intent: 'runtime-only', source: 'dogfood.pre-fix-runtime-write' }, + ); + expect(await storedRows(EXTERNAL)).toHaveLength(1); + expect(await storedRows('default')).toHaveLength(1); + + await restart(); + + // The rows are kept — they are the repair target. + expect(await storedRows(EXTERNAL)).toHaveLength(1); + expect(await storedRows('default')).toHaveLength(1); + + // The admin door serves each code definition… + expect(await adminEntry(EXTERNAL)).toMatchObject({ origin: 'code', label: EXTERNAL_LABEL }); + expect(await adminEntry('default')).toMatchObject({ origin: 'code' }); + expect((await adminEntry('default'))?.label).not.toBe(SHADOW_LABEL); + + // …and refuses to edit one at runtime. + const patch = refusal(await call('PATCH', `/datasources/${EXTERNAL}`, { label: 'Admin edit after restart' })); + expect({ status: patch.status, code: patch.code }).toEqual({ status: 400, code: 'DATASOURCE_ADMIN_ERROR' }); + expect(patch.message.startsWith(`Datasource '${EXTERNAL}' is code-defined and cannot be edited at runtime`), patch.message).toBe(true); + + // No pool was opened from a stored row: `default` is still the host's own + // driver alone, and the federated objects still read the code fixture. + expect(engine().getDriverByName('default')).toBeUndefined(); + expect(connectVerdict('default')).toBe('already-registered'); + expect(await customerRows()).toBe(fixtureRows); + + // A runtime datasource with no code twin still restores, editable. + expect(await adminEntry(RUNTIME)).toMatchObject({ origin: 'runtime', label: 'Runtime 21922' }); + const runtimePatch = await call('PATCH', `/datasources/${RUNTIME}`, { label: 'Runtime 21922 (edited)' }); + expect(runtimePatch.status, JSON.stringify(runtimePatch.json)).toBe(200); + }, 180_000); + + it('the /meta DELETE of each row (the repair) removes it, and what the admin door serves does not change', async () => { + const before = { external: await adminEntry(EXTERNAL), def: await adminEntry('default') }; + + for (const name of [EXTERNAL, 'default']) { + const repaired = await call('DELETE', `/meta/datasource/${name}`); + expect(repaired.status, JSON.stringify(repaired.json)).toBe(200); + expect(repaired.json).toMatchObject({ success: true, reset: true }); + expect(await storedRows(name)).toEqual([]); + } + + expect(await adminEntry(EXTERNAL)).toEqual(before.external); + expect(await adminEntry('default')).toEqual(before.def); + // With the row gone, the /meta read serves the code definition in this + // same boot — the slot it falls back to was never overwritten. + const read = await call('GET', `/meta/datasource/${EXTERNAL}`); + expect(read.status, JSON.stringify(read.json)).toBe(200); + expect(read.json.item).toMatchObject({ origin: 'code', label: EXTERNAL_LABEL }); + }); +}); diff --git a/packages/runtime/src/app-plugin.ts b/packages/runtime/src/app-plugin.ts index 769970175c3..cc4e1685338 100644 --- a/packages/runtime/src/app-plugin.ts +++ b/packages/runtime/src/app-plugin.ts @@ -19,6 +19,7 @@ import { resolveTenancyPosture } from '@objectstack/types'; import { postureEnforcesWall, type TenancyPosture } from '@objectstack/spec/security'; import { SeedLoaderService } from './seed-loader.js'; import { recordSeedOutcome } from './seed-summary.js'; +import { contributeCodeDatasourceNames } from './code-datasource-names.js'; import { mergeSeedDatasets, readSeedDatasets, registerSeedReplayerOnce } from './seed-datasets.js'; import { declareSeedSource } from './seed-settlement.js'; import { loadDisabledPackageIds } from './package-state-store.js'; @@ -157,6 +158,14 @@ export class AppPlugin implements Plugin { private grantEnforcer?: PluginPermissionEnforcer; /** What `grantedPermissions` bound to on this artifact — see {@link ArtifactGrantBinding}. */ private grantBindingResult?: ArtifactGrantBinding; + /** + * [#21922] {@link codeDefinedDatasourceOwners}, computed once: the host's + * code-datasource set reads the names through the contribution `init()` + * registers, and `start()` registers the same list, so the two can never + * disagree about which datasources this artifact registers from code — and + * the residual-owner warning prints once, at whichever read comes first. + */ + private codeDatasourceOwners?: Array<{ datasource: any; owner: { packageId?: string; packageVersion?: string } }>; /** When true, init/start become no-ops — env has no app payload. */ private readonly empty: boolean = false; /** @@ -424,6 +433,21 @@ export class AppPlugin implements Plugin { : this.bundle; ctx.getService<{ register(m: any): void }>('manifest').register(servicePayload); + + // [#21922] The datasources this artifact registers from code join the + // host's code-datasource set HERE, in Phase 1, so the datasource-admin + // plugin's boot restore — a `start()` — never registers a stored row + // over one of them, whatever order the plugins were composed in. The + // in-memory registration itself stays in `start()` (see there). + // + // Contributed as a FUNCTION the set resolves at its first read, never + // resolved here: the names come from `collections`, which walks + // `packages[]`, and the manifest registration above is the one thing + // in `init()` allowed to touch `packages[]` (#15292's falsifier in + // `plugin-dev` pins it). `CodeDatasourceNames` says the rest. + contributeCodeDatasourceNames(ctx, () => this.codeDefinedDatasourceOwners(ctx) + .map(({ datasource }) => datasource?.name) + .filter((name): name is string => typeof name === 'string' && name.length > 0)); } /** @@ -701,6 +725,15 @@ export class AppPlugin implements Plugin { private codeDefinedDatasourceOwners(ctx: PluginContext): Array<{ datasource: any; owner: { packageId?: string; packageVersion?: string }; + }> { + this.codeDatasourceOwners ??= this.resolveCodeDefinedDatasourceOwners(ctx); + return this.codeDatasourceOwners; + } + + /** The resolution {@link codeDefinedDatasourceOwners} memoizes. */ + private resolveCodeDefinedDatasourceOwners(ctx: PluginContext): Array<{ + datasource: any; + owner: { packageId?: string; packageVersion?: string }; }> { const listOf = (dsDefs: unknown): any[] => Array.isArray(dsDefs) diff --git a/packages/runtime/src/code-datasource-names.test.ts b/packages/runtime/src/code-datasource-names.test.ts new file mode 100644 index 00000000000..8876e6e83eb --- /dev/null +++ b/packages/runtime/src/code-datasource-names.test.ts @@ -0,0 +1,144 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#21922 / #21944] The host's code-datasource set: one set, contributed to +// from code in Phase 1, complete before ANY `start()` runs — which is when the +// datasource-admin plugin's boot restore reads it. +// +// The boot case composes its reader FIRST, ahead of every producer. Start +// order follows insertion where no plugin declares an edge, and none of +// AppPlugin, DefaultDatasourcePlugin and DatasourceAdminServicePlugin declares +// one to another — so a set contributed to in `start()` would be short here. +// Contributed to in `init()`, it is whole. (AppPlugin's contribution is a +// function the set resolves at that first read — `CodeDatasourceNames` says +// why; `plugin-dev`'s #15292 falsifier pins that `init()` itself never reads +// the artifact's `packages[]` for it.) + +import { describe, it, expect } from 'vitest'; +import type { Plugin, PluginContext } from '@objectstack/core'; +import { Runtime } from './runtime.js'; +import { DefaultDatasourcePlugin } from './default-datasource-plugin.js'; +import { AppPlugin } from './app-plugin.js'; +import { CODE_DATASOURCE_NAMES_SERVICE, CodeDatasourceNames, contributeCodeDatasourceNames } from './code-datasource-names.js'; + +// [#10126] Pay the first transform of these dist-resolved workspace deps at MODULE +// LOAD, not inside a clocked `it()` body (`scripts/check-test-source-alias.mjs`). +import '@objectstack/objectql'; +import '@objectstack/service-datasource'; + +const BOOT_TIMEOUT = 60_000; + +/** A service registry with the kernel's two refusals: a miss throws, a duplicate throws. */ +function registryCtx() { + const services = new Map(); + let registrations = 0; + return { + services, + registrations: () => registrations, + ctx: { + getService(name: string): T { + if (!services.has(name)) throw new Error(`[Kernel] Service '${name}' not found`); + return services.get(name) as T; + }, + registerService(name: string, service: unknown) { + if (services.has(name)) throw new Error(`[Kernel] Service '${name}' already registered`); + services.set(name, service); + registrations += 1; + }, + }, + }; +} + +describe('contributeCodeDatasourceNames — one set per kernel', () => { + it('the first producer registers the set; every later one adds to that same set', () => { + const { ctx, services, registrations } = registryCtx(); + + contributeCodeDatasourceNames(ctx, ['default']); + contributeCodeDatasourceNames(ctx, ['crm_wh', 'erp_wh']); + + expect(registrations()).toBe(1); + const set = services.get(CODE_DATASOURCE_NAMES_SERVICE) as CodeDatasourceNames; + expect(set.list()).toEqual(['crm_wh', 'default', 'erp_wh']); + expect(set.has('crm_wh')).toBe(true); + expect(set.has('runtime_wh')).toBe(false); + }); + + it('a contribution made as a function is resolved at the first read, not when it is made', () => { + const { ctx, services } = registryCtx(); + let calls = 0; + + contributeCodeDatasourceNames(ctx, () => { calls += 1; return ['app_wh']; }); + expect(calls).toBe(0); + + const set = services.get(CODE_DATASOURCE_NAMES_SERVICE) as CodeDatasourceNames; + expect(set.has('app_wh')).toBe(true); + expect(set.has('app_wh')).toBe(true); + expect(calls).toBe(1); + }); + + it('a contribution that throws stays pending and reaches every reader — the set never answers without it', () => { + const { ctx, services } = registryCtx(); + contributeCodeDatasourceNames(ctx, ['default']); + contributeCodeDatasourceNames(ctx, () => { throw new Error('the artifact cannot be read'); }); + + const set = services.get(CODE_DATASOURCE_NAMES_SERVICE) as CodeDatasourceNames; + expect(() => set.has('default')).toThrow(/the artifact cannot be read/); + expect(() => set.has('default')).toThrow(/the artifact cannot be read/); + }); + + it('a name the kernel holds as something other than this set is a composition fault, refused loudly', () => { + const { ctx, services } = registryCtx(); + services.set(CODE_DATASOURCE_NAMES_SERVICE, new Set(['default'])); + + expect(() => contributeCodeDatasourceNames(ctx, ['default'])).toThrow(/already registered/); + }); +}); + +describe('the host\'s code-datasource set on a booted kernel', () => { + it('holds `default` and every datasource the artifact declares before the first start() runs', async () => { + const { ObjectQLPlugin } = await import('@objectstack/objectql'); + const runtime = new Runtime({ cluster: false }); + const kernel = runtime.getKernel(); + + let seenAtFirstStart: string[] | undefined; + const reader: Plugin = { + name: 'test.code-datasource-names.reader', + version: '1.0.0', + init: async () => {}, + start: async (ctx: PluginContext) => { + seenAtFirstStart = ctx.getService(CODE_DATASOURCE_NAMES_SERVICE).list(); + }, + }; + + await kernel.use(reader); + await kernel.use(new DefaultDatasourcePlugin({ driver: 'memory' })); + await kernel.use(new ObjectQLPlugin()); + await kernel.use(new AppPlugin({ + manifest: { id: 'com.test.code-ds-names', name: 'Code DS Names', version: '1.0.0' }, + datasources: [{ name: 'app_wh', label: 'App warehouse', driver: 'memory', config: {} }], + })); + + try { + await kernel.bootstrap(); + expect(seenAtFirstStart).toEqual(['app_wh', 'default']); + // One set: the kernel service the two readers resolve by name. + expect(kernel.getService(CODE_DATASOURCE_NAMES_SERVICE).list()).toEqual(['app_wh', 'default']); + } finally { + await kernel.shutdown(); + } + }, BOOT_TIMEOUT); + + it('an artifact that declares no datasource contributes nothing: the set holds only the host\'s `default`', async () => { + const { ObjectQLPlugin } = await import('@objectstack/objectql'); + const runtime = new Runtime({ cluster: false }); + const kernel = runtime.getKernel(); + await kernel.use(new DefaultDatasourcePlugin({ driver: 'memory' })); + await kernel.use(new ObjectQLPlugin()); + await kernel.use(new AppPlugin({ manifest: { id: 'com.test.no-ds', name: 'No DS', version: '1.0.0' } })); + try { + await kernel.bootstrap(); + expect(kernel.getService(CODE_DATASOURCE_NAMES_SERVICE).list()).toEqual(['default']); + } finally { + await kernel.shutdown(); + } + }, BOOT_TIMEOUT); +}); diff --git a/packages/runtime/src/code-datasource-names.ts b/packages/runtime/src/code-datasource-names.ts new file mode 100644 index 00000000000..8eb568aaaa0 --- /dev/null +++ b/packages/runtime/src/code-datasource-names.ts @@ -0,0 +1,156 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The host's code-datasource set (#21922, #21944): the ONE in-memory set of + * datasource names this host registers from code, kept on the kernel's + * {@link CODE_DATASOURCE_NAMES_SERVICE} service as a {@link CodeDatasourceNames}. + * + * ## Who fills it, and when + * + * The two producers that register a datasource from code, each from its own + * `init()`: + * + * - `AppPlugin` — every datasource the installed artifact declares + * (`*.datasource.ts`), the same list its `start()` registers in the + * MetadataService as `origin: 'code'`, contributed as a function the set + * resolves at its first read ({@link CodeDatasourceNames} says why); + * - `DefaultDatasourcePlugin` — the host's own `default`. + * + * `init()` is Phase 1, and Phase 1 completes before ANY `start()` runs + * (`ObjectKernel.bootstrap()`). The datasource-admin plugin restores stored + * rows in its `start()`, so it reads a complete set in every composition, + * whatever order the plugins were `use()`d in. The `start()`-time in-memory + * registrations are ordered against that restore by insertion order alone — + * no plugin of the three declares an edge to another, and ADR-0116 is explicit + * that registration order proves nothing — so they cannot be what the restore + * decides from. + * + * ## Who reads it + * + * - `@objectstack/service-datasource`'s boot restore: a stored row under a + * name in this set is not registered over the code definition; + * - `@objectstack/metadata-protocol`'s `isDeclaredCodeDatasource`: a name in + * this set is a code-defined datasource for the `/meta` door. + * + * Both read the service by name and use only `has(name)`. Neither package + * depends on this one, and `metadata-protocol` depends on neither of the + * others, so the kernel's service registry — the map both already resolve — + * is the seam. That is the shape `seed-summary` (`recordSeedOutcome`, + * `seed-summary.ts`) uses for the same reason: a boot fact rides a boundary + * that is already crossed, and no package gains an export for it. + * + * ## What it is never derived from + * + * ⛔ A stored row's `origin`, the MetadataService slot's `origin`, the + * connection service's retained `ConnectResult`, or a request body's + * `origin`. The caller sets a stored row's and a body's freely, and a stored + * row the restore registers overwrites the slot's, so each would answer what + * was written last rather than what the host registers from code. + * + * ## What it does not hold + * + * A package installed after boot (an HTTP install, or the Phase 2 rehydrate + * from `sys_packages`). Nothing registers such a package's datasources in the + * MetadataService as code, so the admin door does not treat them as code + * either; the `/meta` door's resolver reads the installed package records + * live, beside this set. + */ + +/** The kernel service the host's code-datasource set is registered under. */ +export const CODE_DATASOURCE_NAMES_SERVICE = 'code-datasource-names'; + +/** + * What a producer contributes: names it knows now, or a function that names + * them when the set is first read. `AppPlugin` contributes the function — see + * {@link CodeDatasourceNames}. + */ +export type CodeDatasourceContribution = Iterable | (() => Iterable); + +/** + * The set itself. Readers use `has(name)` only, structurally. + * + * ## Why a contribution can be a function + * + * `AppPlugin` learns its datasource names from the artifact's `collections`, + * and reading `collections` walks `packages[]`, which refuses a malformed entry. + * `AppPlugin.init()`'s pinned contract is that its manifest registration is the + * ONLY thing in `init()` that touches `packages[]` (#15292's falsifier in + * `plugin-dev`): a malformed artifact is refused there, once, and DevPlugin + * degrades that refusal to an error line. So `AppPlugin` registers, in + * `init()`, a function this set calls at its first read. The CONTRIBUTION is + * made in Phase 1 — before any `start()`, which is all the boot restore needs — + * and the resolution happens where the set is used (AGENTS.md's first cure for + * a startup registry read), with the same list `AppPlugin.start()` registers. + * + * A contribution that throws stays pending and the throw reaches the reader, + * on every read: a set that answered without it would call a code datasource + * "not code" without telling anyone. + */ +export class CodeDatasourceNames { + private readonly names = new Set(); + private readonly pending: Array<() => Iterable> = []; + + add(contribution: CodeDatasourceContribution): void { + if (typeof contribution === 'function') this.pending.push(contribution); + else this.addAll(contribution); + } + + has(name: string): boolean { + this.settle(); + return this.names.has(name); + } + + /** Every name in the set, sorted — for diagnostics and pins. */ + list(): string[] { + this.settle(); + return [...this.names].sort(); + } + + private settle(): void { + while (this.pending.length > 0) { + const resolved = [...this.pending[0]()]; + this.pending.shift(); + this.addAll(resolved); + } + } + + private addAll(names: Iterable): void { + for (const name of names) { + if (typeof name === 'string' && name.length > 0) this.names.add(name); + } + } +} + +/** The slice of a plugin context the producers need: the service registry. */ +interface ServiceRegistryContext { + getService(name: string): T; + registerService(name: string, service: unknown): void; +} + +/** + * Add a contribution to the host's code-datasource set, registering the set on + * the kernel the first time any producer contributes. Called from a producer's + * `init()` only — see the module header for why the phase matters. + * + * `getService` throws while the service is unregistered; that miss is the + * one case that creates the set. A name occupied by something that is not this + * set is a composition fault, and `registerService` throws on it, loudly: + * failing open there would leave every stored row free to shadow the code + * definitions this set exists to protect. + */ +export function contributeCodeDatasourceNames( + ctx: ServiceRegistryContext, + contribution: CodeDatasourceContribution, +): void { + let set: unknown; + try { + set = ctx.getService(CODE_DATASOURCE_NAMES_SERVICE); + } catch { + set = undefined; + } + if (!(set instanceof CodeDatasourceNames)) { + set = new CodeDatasourceNames(); + ctx.registerService(CODE_DATASOURCE_NAMES_SERVICE, set); + } + (set as CodeDatasourceNames).add(contribution); +} diff --git a/packages/runtime/src/default-datasource-plugin.ts b/packages/runtime/src/default-datasource-plugin.ts index fe1f3b59cd2..414620f89b7 100644 --- a/packages/runtime/src/default-datasource-plugin.ts +++ b/packages/runtime/src/default-datasource-plugin.ts @@ -9,6 +9,7 @@ import { type IDatasourceDriverFactory, } from '@objectstack/service-datasource'; import type { SqliteAbsentFileMode } from '@objectstack/driver-sql'; +import { contributeCodeDatasourceNames } from './code-datasource-names.js'; /** * DefaultDatasourcePlugin — the `default` datasource as a DECLARATION @@ -140,6 +141,14 @@ export class DefaultDatasourcePlugin implements Plugin { } init = async (ctx: PluginContext) => { + // [#21944] `default` is a code datasource of this host: it joins the + // host's code-datasource set here, in Phase 1, before any `start()` — so + // the datasource-admin plugin's boot restore never registers a stored row + // over it (or opens a pool from one), and the `/meta` door refuses to edit + // it as the admin door does. Before the connect, which may throw: whatever + // the connect's outcome, `start()` still lists `default` as code below. + contributeCodeDatasourceNames(ctx, ['default']); + const connection = new DatasourceConnectionService({ factory: () => this.factory ?? createDefaultDatasourceDriverFactory({ dev: this.dev, diff --git a/packages/services/service-datasource/src/__tests__/datasource-admin-plugin.test.ts b/packages/services/service-datasource/src/__tests__/datasource-admin-plugin.test.ts index 780f5cba294..a35d49e3f58 100644 --- a/packages/services/service-datasource/src/__tests__/datasource-admin-plugin.test.ts +++ b/packages/services/service-datasource/src/__tests__/datasource-admin-plugin.test.ts @@ -1,12 +1,15 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; +import { HonoHttpServer } from '@objectstack/plugin-hono-server'; import type { IDatasourceAdminService, IDatasourceDriverFactory } from '../contracts/index.js'; import type { DatasourceAdminService } from '../datasource-admin-service.js'; import { DatasourceAdminServicePlugin, type DatasourceAdminServicePluginOptions, } from '../datasource-admin-plugin.js'; +import { registerDatasourceAdminRoutes } from '../admin-routes.js'; +import { ENTITLED_CREDENTIAL, createSessionAuthService, createGrantsEngine } from './entitled-caller.fixture.js'; import { assertEngineFindOnePredicate } from '@objectstack/metadata-core'; @@ -421,4 +424,135 @@ describe('DatasourceAdminServicePlugin: runtime datasource durability', () => { await b.service.removeDatasource('gone'); expect(data.rows.some((r) => r.name === 'gone')).toBe(false); }); + + // [#21922 / #21944] Code wins on collision at the boot restore. The host's + // code-datasource set (the kernel service the runtime fills in Phase 1) is + // what decides "code" — never the stored row's own `origin`, which the shadow + // rows below deliberately assert as `runtime`, the shape an earlier + // `/meta` save could leave. That fixture choice is load-bearing: were the row + // registered, the slot would read `origin: 'runtime'` and the admin door would + // let the edit through, so the refusal below cannot be green by the admin + // read's own `origin ?? 'code'` default. + describe('[#21922] a stored row never displaces a code datasource at the restore', () => { + const CODE_NAMES = 'code-datasource-names'; + const now = new Date().toISOString(); + const storedRow = (name: string, body: Record) => ({ + id: `meta_${name}`, + name, + type: 'datasource', + scope: 'platform', + metadata: JSON.stringify({ name, ...body }), + state: 'active', + version: 1, + created_at: now, + updated_at: now, + }); + /** The code twin's stored shadow: another label, another connection, `origin: 'runtime'`. */ + const SHADOW = { label: 'Shadow', driver: 'postgres', origin: 'runtime', active: true, config: { host: 'shadow-host', database: 'shadow' } }; + /** What the runtime registered from code, as `AppPlugin.start()` stamps it. */ + const CODE = { name: 'crm_wh', label: 'Code warehouse', driver: 'postgres', origin: 'code', config: { host: 'code-host', database: 'wh' } }; + + /** `sys_metadata` plus a driver registry, and a factory recording each pool it builds. */ + const harness = async (opts: { codeNames?: Set; codeSlotFirst: boolean; withOptionsLogger?: boolean }) => { + 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(storedRow('crm_wh', SHADOW)); + data.rows.push(storedRow('rt_wh', { label: 'Runtime', driver: 'postgres', origin: 'runtime', active: true, config: { host: 'rt-host', database: 'rt' } })); + const built: any[] = []; + const factory: IDatasourceDriverFactory = { + supports: (id: string) => id === 'postgres', + create: async (spec) => { + built.push(spec); + return { connect: async () => {}, disconnect: async () => {} }; + }, + }; + const optionsWarn = vi.fn(); + const b = await boot({ + driverFactory: factory, + ...(opts.withOptionsLogger ? { logger: { warn: optionsWarn, info: () => {} } } : {}), + services: { data, ...(opts.codeNames ? { [CODE_NAMES]: opts.codeNames } : {}) }, + }); + const ctxWarn = vi.fn(); + b.ctx.logger = { warn: ctxWarn, info: () => {} }; + // `AppPlugin.start()` registering the code definition before this + // plugin's start(), or after it — the kernel orders the two by + // insertion alone, so both must hold. + if (opts.codeSlotFirst) await b.metadata.register('datasource', CODE.name, CODE); + await b.plugin.start(b.ctx); + return { ...b, data, built, optionsWarn, ctxWarn }; + }; + + /** The admin door, mounted the way `os serve` mounts it, over this boot's service. */ + const adminDoor = (service: unknown) => { + const server = new HonoHttpServer(0); + const authService = createSessionAuthService(); + const grantsEngine = createGrantsEngine(); + registerDatasourceAdminRoutes(server, { + getService: (name: string) => + name === 'auth' ? authService : name === 'objectql' || name === 'data' ? grantsEngine : service, + } as any, '/api/v1'); + return (method: string, path: string, body?: unknown) => + server.getRawApp().fetch(new Request(`http://local/api/v1${path}`, { + method, + headers: { 'content-type': 'application/json', authorization: ENTITLED_CREDENTIAL }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + })); + }; + + it('keeps the code definition served, refuses the admin edit, keeps the row, names it, and opens no pool from it', async () => { + const b = await harness({ codeNames: new Set(['crm_wh']), codeSlotFirst: true }); + + expect(await b.metadata.get('datasource', 'crm_wh')).toEqual(CODE); + const listed = await b.service.listDatasources(); + expect(listed.find((d) => d.name === 'crm_wh')).toMatchObject({ origin: 'code', label: 'Code warehouse' }); + + const patch = await adminDoor(b.service)('PATCH', '/datasources/crm_wh', { label: 'Edited at runtime' }); + const answer = (await patch.json()) as { error?: { code?: string; message?: string } }; + expect({ status: patch.status, code: answer.error?.code }).toEqual({ status: 400, code: 'DATASOURCE_ADMIN_ERROR' }); + expect(String(answer.error?.message).startsWith("Datasource 'crm_wh' is code-defined"), answer.error?.message).toBe(true); + + // Kept, not dropped: it is the repair target the `/meta` door removes. + expect(b.data.rows.filter((r) => r.name === 'crm_wh')).toHaveLength(1); + expect(b.ctxWarn).toHaveBeenCalledTimes(1); + expect(String(b.ctxWarn.mock.calls[0][0])).toContain("stored row for 'crm_wh'"); + expect(String(b.ctxWarn.mock.calls[0][0])).toContain('DELETE /api/v1/meta/datasource/crm_wh'); + + // No pool was built from the shadow's connection; the runtime row's was. + expect(b.built.map((s) => s.name)).toEqual(['rt_wh']); + expect(b.data.drivers.map((d) => d.name)).toEqual(['rt_wh']); + }); + + it('holds when the code registration lands AFTER the restore: the row is still not registered and no pool opens from it', async () => { + const b = await harness({ codeNames: new Set(['crm_wh']), codeSlotFirst: false }); + + expect(await b.metadata.get('datasource', 'crm_wh')).toBeUndefined(); + expect(b.built.map((s) => s.name)).toEqual(['rt_wh']); + expect(b.data.rows.filter((r) => r.name === 'crm_wh')).toHaveLength(1); + }); + + it('a runtime datasource with no code twin still restores, with its pool', async () => { + const b = await harness({ codeNames: new Set(['crm_wh']), codeSlotFirst: true }); + + expect(await b.metadata.get('datasource', 'rt_wh')).toMatchObject({ name: 'rt_wh', origin: 'runtime', label: 'Runtime' }); + expect((await b.service.listDatasources()).find((d) => d.name === 'rt_wh')).toMatchObject({ origin: 'runtime' }); + expect(b.data.drivers.map((d) => d.name)).toContain('rt_wh'); + }); + + it('the warning goes to the host-passed logger when there is one', async () => { + const b = await harness({ codeNames: new Set(['crm_wh']), codeSlotFirst: true, withOptionsLogger: true }); + + expect(b.optionsWarn).toHaveBeenCalledWith(expect.stringContaining("stored row for 'crm_wh'")); + expect(b.ctxWarn).not.toHaveBeenCalled(); + }); + + it('a host that registers no code-datasource set restores every stored row, as before', async () => { + const b = await harness({ codeSlotFirst: false }); + + expect(await b.metadata.get('datasource', 'crm_wh')).toMatchObject({ origin: 'runtime', label: 'Shadow' }); + expect(b.ctxWarn).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/services/service-datasource/src/datasource-admin-plugin.ts b/packages/services/service-datasource/src/datasource-admin-plugin.ts index b02267b3817..3b51fcf3ff9 100644 --- a/packages/services/service-datasource/src/datasource-admin-plugin.ts +++ b/packages/services/service-datasource/src/datasource-admin-plugin.ts @@ -558,14 +558,37 @@ export class DatasourceAdminServicePlugin implements Plugin { // store back into the in-memory registry, THEN rebuild their live pools. // `register()` is in-memory only in standalone serve (no writable // `datasource:` loader), so without this a node restart drops every - // UI-created datasource. Code-defined datasources come from the artifact and - // are unaffected. + // UI-created datasource. Code-defined datasources come from the artifact, + // and a stored row under one of their names is skipped, never registered + // over it (#21922, see restoreRuntimeDatasources). await this.restoreRuntimeDatasources(ctx); await this.rehydratePools(); if (this.service) await ctx.trigger('datasource-admin:ready', this.service); } - /** Reload persisted runtime datasource rows (sys_metadata) into the registry. */ + /** + * Reload persisted runtime datasource rows (sys_metadata) into the registry. + * + * [#21922] **Code wins on collision** — the invariant this service states in + * its own header ("A runtime datasource never shadows a code one"). A stored + * row whose name the host registers from code is NOT registered over the + * code definition: `register` is last-write-wins on the MetadataService + * slot, so registering it would make both doors serve the row, let the admin + * door edit a code-defined datasource (the row's `origin` is whatever its + * writer said), and hand {@link rehydratePools} a "runtime" record to open a + * live pool from under the code datasource's name. + * + * "Code" is read from the host's code-datasource set — the kernel service + * {@link CODE_DATASOURCE_NAMES_SERVICE}, which the runtime fills from code in + * Phase 1, before this `start()` runs. ⛔ Never from the stored row's own + * `origin` (its writer set it freely) nor from the MetadataService slot + * (whether a code registration reached it yet depends on composition order). + * + * The skipped row is KEPT, and named in a warning: it is the residue an + * earlier runtime write left, and the `/meta` door's `DELETE` removes it as + * repair. A host that composes no code-datasource producer registers no set, + * and then nothing is code — every stored row restores as it always has. + */ private async restoreRuntimeDatasources(ctx: PluginContext): Promise { const engine = safeGetService(ctx, 'data'); const metadata = safeGetService(ctx, 'metadata'); @@ -577,10 +600,22 @@ export class DatasourceAdminServicePlugin implements Plugin { this.options.logger?.warn?.('datasource restore: reading sys_metadata failed', err); return; } + const codeNames = codeDatasourceNamesOf(ctx); let restored = 0; for (const rec of rows) { const name = (rec as { name?: string }).name; if (!name) continue; + if (codeNames?.has(name)) { + // Loud whatever the host passed as `options.logger` — `os serve` + // passes none, and a collision nobody sees is a shadow nobody repairs. + (this.options.logger ?? ctx.logger).warn( + `datasource restore: sys_metadata holds a stored row for '${name}', a datasource this host ` + + `defines in code. The row was NOT restored over the code definition (code wins on a name ` + + `collision) and is kept. Remove it with DELETE /api/v1/meta/datasource/${name} once nothing ` + + `needs it; edit the code definition to change the datasource.`, + ); + continue; + } try { await metadata.register('datasource', name, rec); restored += 1; @@ -764,6 +799,21 @@ function safeGetService(ctx: PluginContext, name: string): T | undefined { } } +/** + * [#21922] The kernel service the host's code-datasource set is registered + * under. Its producer is `@objectstack/runtime` (`code-datasource-names.ts`), + * which this package does not depend on, so the name is spelled here and the + * value read structurally — the way `'datasource-connection'` is read in the + * other direction. Keep the two spellings equal. + */ +const CODE_DATASOURCE_NAMES_SERVICE = 'code-datasource-names'; + +/** The host's code-datasource set, or `undefined` when no producer registered one. */ +function codeDatasourceNamesOf(ctx: PluginContext): { has(name: string): boolean } | undefined { + const names = safeGetService<{ has?: unknown }>(ctx, CODE_DATASOURCE_NAMES_SERVICE); + return typeof names?.has === 'function' ? (names as { has(name: string): boolean }) : undefined; +} + function errMsg(err: unknown): string { return err instanceof Error ? err.message : String(err); }