From 4d8bfea23e52189a420c894db07b9453fd397606 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 12:34:22 +0000 Subject: [PATCH 1/4] fix(cloud-connection): an install-local install connects and records the package's declared datasources The install door registered a package's objects and nothing of its datasources, so an object bound to one answered 404 OBJECT_NOT_FOUND and the name was not code-defined. AppPlugin's registration, code-set record and connectDeclared call move into one runtime helper, connectPackageDatasources, which the boot keeps calling and install-local now calls on install and on every rehydrate. In a running kernel a fail-fast verdict is reported (the datasource answers its retained verdict and a bound read answers 503 ERR_DATASOURCE_UNAVAILABLE) instead of thrown; a reinstall replaces, and the uninstall withdraws through the runtime.package-datasources cleanup. A package declaring a name another holder owns is refused 409 before any write. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_016WV4cx4xgJksGo7iVA8c9A --- .../src/marketplace-install-local-plugin.ts | 158 ++++++++ ...local-declared-datasources.dogfood.test.ts | 287 +++++++++++++++ packages/runtime/src/app-plugin.ts | 131 ++----- packages/runtime/src/code-datasource-names.ts | 36 +- .../runtime/src/declared-datasources.test.ts | 180 +++++++++ packages/runtime/src/declared-datasources.ts | 343 ++++++++++++++++++ packages/runtime/src/index.ts | 10 + 7 files changed, 1045 insertions(+), 100 deletions(-) create mode 100644 packages/qa/dogfood/test/install-local-declared-datasources.dogfood.test.ts create mode 100644 packages/runtime/src/declared-datasources.test.ts create mode 100644 packages/runtime/src/declared-datasources.ts diff --git a/packages/cloud-connection/src/marketplace-install-local-plugin.ts b/packages/cloud-connection/src/marketplace-install-local-plugin.ts index b270567b195..8944b67e52c 100644 --- a/packages/cloud-connection/src/marketplace-install-local-plugin.ts +++ b/packages/cloud-connection/src/marketplace-install-local-plugin.ts @@ -1009,6 +1009,10 @@ export class MarketplaceInstallLocalPlugin implements Plugin { const ql: any = ctx.getService('objectql'); if (ql && typeof ql.syncSchemas === 'function') await ql.syncSchemas(); } catch { /* non-fatal */ } + // [#28039] Connect the package's declared datasources — the + // boot artifact's were connected in `AppPlugin.start`, and this + // package arrives after it. + await this.connectDeclaredDatasources(ctx, converted, entry.manifestId, entry.version); // [#21321] Bind the package's script-action bodies and body // hooks — `register` above makes them declared, not runnable. await this.bindArtifactHandlers(ctx, converted, entry.manifestId); @@ -1788,6 +1792,27 @@ export class MarketplaceInstallLocalPlugin implements Plugin { }, 409); } + // 2a. [#28039] The package's datasources are registered as code and + // connected in step 4b′, so a name another holder already owns — + // the host's `default`, a datasource created in Setup or another + // package's — is refused here, before any write: connecting would + // hand the package's objects that holder's pool (the connect is + // idempotent on the name), and recording the name as code would + // make the holder's own datasource read-only to the admin door. + const heldDatasource = await this.findHeldDatasourceName(ctx, manifest, manifestId); + if (heldDatasource) { + return c.json({ + success: false, + error: { + code: 'RESOURCE_CONFLICT', + message: `Package '${manifestId}' cannot be installed: it declares the datasource '${heldDatasource.name}', ` + + `and ${heldDatasource.holder} already holds that name in this system. Rename the package's datasource, ` + + 'or remove the one holding the name first.', + details: { datasource: heldDatasource.name }, + }, + }, 409); + } + // 2b. [ADR-0120 D5e] `isolated`-posture gate on installation-wide // uniques. Runs BEFORE hot-register and before anything is written // to the ledger: a stopped install must leave the runtime exactly as @@ -1967,6 +1992,16 @@ export class MarketplaceInstallLocalPlugin implements Plugin { ctx.logger?.warn?.(`[MarketplaceInstallLocal] syncSchemas failed for ${manifestId}: ${err?.message ?? err}`); } + // 4b′. [#28039] Register, record and connect the package's declared + // datasources through the runtime's ONE path — the call + // `AppPlugin.start` makes for a boot artifact — after the schema + // sync, where the boot connects them too. Only for a package the + // running kernel holds: on the lenient path nothing of it is + // registered until the restart's rehydrate, which connects them. + const datasources = hotLoadError === undefined + ? await this.connectDeclaredDatasources(ctx, converted, manifestId, version) + : undefined; + // 4c. [#21321] Bind the package's script-action bodies and body hooks // through the runtime's ONE binder — the call `AppPlugin.start` // makes for a boot artifact. A reinstall replaces the previous @@ -2026,6 +2061,11 @@ export class MarketplaceInstallLocalPlugin implements Plugin { // [#27237] Omitted, not emptied, for a package whose connectors // name no credential, so that answer's key set is unchanged. ...(credentialRefs !== undefined ? { credentialRefs } : {}), + // [#28039] Each declared datasource's connect verdict — `connected`, + // or the connection service's named non-`ok` status (a credential + // it cannot resolve is `failed-credentials`). Omitted for a package + // that declares none, so that answer's key set is unchanged. + ...(datasources !== undefined ? { datasources } : {}), ...(unsetRefs.length > 0 ? { credentialNote: installedCredentialNote(unsetRefs, { door }) } : {}), upgradedFrom: conflict === 'marketplace' ? 'previous-marketplace-version' : null, translationsLoaded: seededSummary.translationsLoaded, @@ -3516,6 +3556,124 @@ export class MarketplaceInstallLocalPlugin implements Plugin { * Resolved lazily through `@objectstack/runtime`, like every other runtime * helper this plugin calls. Never throws. */ + /** + * [#28039] The datasources a package body declares, as `AppPlugin` reads a + * stack's: a list, or a map keyed by name. + */ + private static declaredDatasources(body: unknown): any[] { + const defs = isPlainObject(body) ? body.datasources : undefined; + return Array.isArray(defs) + ? defs + : isPlainObject(defs) + ? Object.entries(defs).map(([name, def]) => ({ name, ...(def as any) })) + : []; + } + + /** + * [#28039] Register, record and connect the datasources `manifest` + * declares, through `connectPackageDatasources` — the ONE path, which + * `AppPlugin.start` takes for the artifact the host boots: each registered + * in the metadata registry as `origin: 'code'` stamped with this package, + * its name recorded in the host's code-datasource set, and connected + * through the `datasource-connection` service (the D2 gate and the host's + * connect policy apply as on a boot). A reinstall replaces the previous + * version's datasources; the uninstall withdraws them through the + * `runtime.package-datasources` uninstall cleanup. + * + * Before this, the door registered a package's objects and nothing of its + * datasources: an object bound to one had no driver, and the admin door + * did not serve the datasource as code-defined. + * + * ⛔ Where a declared credential comes from is not decided here (#27687): + * a credential the connection service cannot resolve is its + * `failed-credentials` verdict, which the datasource answers and a read of + * a bound object names (`ERR_DATASOURCE_UNAVAILABLE`). + * + * Answers each declared datasource's verdict, or `undefined` when the + * package declares none (or the runtime predates the path). Never throws: + * the package is installed either way. + */ + private connectDeclaredDatasources = async ( + ctx: PluginContext, + manifest: unknown, + manifestId: string, + version: string | undefined, + ): Promise | undefined> => { + const declared = MarketplaceInstallLocalPlugin.declaredDatasources(manifest); + let connect: typeof import('@objectstack/runtime')['connectPackageDatasources'] | undefined; + try { + const mod: any = await import('@objectstack/runtime'); + if (typeof mod?.connectPackageDatasources === 'function') connect = mod.connectPackageDatasources; + } catch { /* reported below */ } + if (!connect) { + if (declared.length > 0) { + ctx.logger?.warn?.( + `[MarketplaceInstallLocal] this runtime has no connectPackageDatasources — the datasources of ${manifestId} are NOT connected: ` + + 'the objects bound to them have no driver. Upgrade @objectstack/runtime alongside @objectstack/cloud-connection.', + ); + } + return undefined; + } + const objects = isPlainObject(manifest) && Array.isArray(manifest.objects) ? manifest.objects : []; + try { + // Called with an empty list too: a reinstall whose new version + // declares none withdraws what the previous version registered. + const verdicts = await connect(ctx, { + appId: manifestId, + source: 'MarketplaceInstallLocal', + datasources: declared, + owned: declared.map((datasource) => ({ + datasource, + owner: { packageId: manifestId, ...(typeof version === 'string' ? { packageVersion: version } : {}) }, + })), + objects, + installed: true, + }); + return declared.length > 0 ? verdicts : undefined; + } catch (err: any) { + ctx.logger?.error?.( + `[MarketplaceInstallLocal] the datasources of ${manifestId} could not be registered or connected: ${err?.message ?? err}`, + ); + return declared.length > 0 ? [] : undefined; + } + }; + + /** + * [#28039] The first datasource `manifest` declares whose name another + * holder already owns, with that holder: the host's `default`, or a + * datasource in the metadata service not stamped with this package (one + * created in Setup, the boot artifact's, another package's). A datasource + * this package's earlier install registered is its own, so a reinstall + * passes. `undefined` when nothing collides or nothing can be read. + */ + private findHeldDatasourceName = async ( + ctx: PluginContext, + manifest: unknown, + manifestId: string, + ): Promise<{ name: string; holder: string } | undefined> => { + const names = MarketplaceInstallLocalPlugin.declaredDatasources(manifest) + .map((ds) => ds?.name) + .filter((name): name is string => typeof name === 'string' && name.length > 0); + if (names.length === 0) return undefined; + if (names.includes('default')) return { name: 'default', holder: "the host's primary datasource" }; + let metadata: { get?: (type: string, name: string) => Promise } | undefined; + try { metadata = ctx.getService('metadata'); } catch { return undefined; } + for (const name of names) { + let item: unknown; + try { item = await metadata?.get?.('datasource', name); } catch { continue; } + if (!isPlainObject(item)) continue; + const owner = item._packageId; + if (owner === manifestId) continue; + return { + name, + holder: typeof owner === 'string' && owner.length > 0 + ? `the package '${owner}'` + : item.origin === 'runtime' ? 'a datasource created in Setup' : 'a datasource', + }; + } + return undefined; + }; + private bindArtifactHandlers = async (ctx: PluginContext, manifest: unknown, manifestId: string): Promise => { let ql: IObjectQLEngine | undefined; try { ql = ctx.getService('objectql'); } catch { /* no data engine */ } diff --git a/packages/qa/dogfood/test/install-local-declared-datasources.dogfood.test.ts b/packages/qa/dogfood/test/install-local-declared-datasources.dogfood.test.ts new file mode 100644 index 00000000000..0fba44650e5 --- /dev/null +++ b/packages/qa/dogfood/test/install-local-declared-datasources.dogfood.test.ts @@ -0,0 +1,287 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// GOLDEN REGRESSION — an install-local install connects the datasources the +// package declares and records them as code-defined, the way a boot of the +// package's artifact does (#28039). +// +// ## What was measured before the fix +// +// On `origin/main` `549203d0`, this file's install (a package declaring a +// managed sqlite datasource `qads_store` and an external one `qads_vault` +// whose `credentialsRef` names no secret, each with an object bound to it) +// answered `200` with `hotLoaded: true`, and then: +// +// - `POST /data/qads_note` and `GET /data/qads_note` answered `404 +// OBJECT_NOT_FOUND` ("Object 'qads_note' is not registered"), and so did +// `GET /data/qads_secret_row`: the install registered the objects and +// nothing of their datasources, so no driver served them; +// - the connection service held no verdict for either datasource, and the +// host's code-datasource set held neither name. +// +// The boot path (`AppPlugin.start`) registers each declared datasource as +// code, records its name in the set and connects it through the connection +// service's `connectDeclared`; the install door did none of the three. +// Triage's ruling: the install connects and records through the same one +// helper, and where the credential cannot be resolved the datasource answers +// a named non-`ok` status, never a silent driver gap. ⛔ Where a code +// datasource's credential comes from is #27687's, and not decided here. +// +// ## What this file pins +// +// 1. the install answers each declared datasource's verdict: `qads_store` +// connected, `qads_vault` a named failure whose reason names the +// credential; both are registered in the metadata registry as code, +// stamped with the package, and recorded in the code-datasource set; +// 2. a write and a read of the object bound to `qads_store` reach its +// driver; +// 3. a read of the object bound to `qads_vault` answers `503 +// ERR_DATASOURCE_UNAVAILABLE` naming the datasource — not a 404; +// 4. the uninstall withdraws all of it, through its +// `runtime.package-datasources` cleanup: no verdict, no metadata +// registration, no name in the set; +// 5. a package declaring a datasource name another holder owns (the +// boot's `showcase_external`, the host's `default`) is refused `409 +// RESOURCE_CONFLICT` before any write; +// 6. on a restart, the rehydrate connects the ledger's package again, as +// code, and its bound object reads (`200`). The store is `:memory:`, so +// the pin is that the read reaches a driver, not that a row survives. +// +// Boots fixture stacks of its own (the install-local plugin mounted), so it +// stays out of `SHARED_SHOWCASE`. + +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { type VerifyStack } from '@objectstack/verify'; +import { MarketplaceInstallLocalPlugin } from '@objectstack/cloud-connection'; +import { bootShowcase } from './showcase-boot.js'; + +const ROUTE = '/marketplace/install-local'; +const PACKAGE_ID = 'com.example.qads'; +const STORE = 'qads_store'; +const VAULT = 'qads_vault'; +const NOTE = 'qads_note'; +const SECRET_ROW = 'qads_secret_row'; +const UNSET_REF = 'sys_secret:00000000-0000-0000-0000-000000000000'; + +/** A package declaring two datasources, each with an object bound to it. */ +const INSTALL_BODY = { + manifest: { + id: PACKAGE_ID, + namespace: 'qads', + name: 'QA declared datasources', + version: '1.0.0', + type: 'app', + datasources: [ + { name: STORE, label: 'QA store', driver: 'sqlite', config: { filename: ':memory:' } }, + { + name: VAULT, + label: 'QA vault', + driver: 'postgres', + schemaMode: 'external', + config: { host: '127.0.0.1', port: 1, database: 'nowhere', user: 'nobody' }, + external: { credentialsRef: UNSET_REF, validation: { onMismatch: 'warn', checkOnBoot: false } }, + }, + ], + objects: [ + { name: NOTE, label: 'QA note', datasource: STORE, fields: { title: { type: 'text', label: 'Title' } } }, + { name: SECRET_ROW, label: 'QA secret row', datasource: VAULT, external: {}, fields: { title: { type: 'text', label: 'Title' } } }, + ], + }, +}; + +/** A package whose one datasource takes a name `held` by someone else. */ +function collidingBody(id: string, held: string) { + return { + manifest: { + id, + namespace: id.split('.').pop(), + name: `QA collision ${held}`, + version: '1.0.0', + type: 'app', + datasources: [{ name: held, label: 'Collides', driver: 'sqlite', config: { filename: ':memory:' } }], + }, + }; +} + +type Answer = { status: number; body: any }; +async function json(res: Response): Promise { + return { status: res.status, body: await res.json().catch(() => null) }; +} + +interface ConnectionView { + getConnectionState(name: string): { status: string; reason?: string } | undefined; +} + +const codeNamesOf = (stack: VerifyStack): string[] => + (stack.kernel.getService('code-datasource-names') as { list(): string[] }).list(); + +describe('dogfood: an install-local install connects and records the package\'s declared datasources', () => { + let stack: VerifyStack; + let storageDir: string; + let connection: ConnectionView; + let metadata: { get(type: string, name: string): Promise }; + + let install: Answer; + const states: Record = {}; + const registered: Record = {}; + let codeNames: string[]; + let write: Answer; + let read: Answer; + let readVault: Answer; + let uninstall: Answer; + const statesAfter: Record = {}; + const registeredAfter: Record = {}; + let codeNamesAfter: string[]; + const collisions: Record = {}; + + beforeAll(async () => { + storageDir = mkdtempSync(join(tmpdir(), 'dogfood-install-local-ds-')); + stack = await bootShowcase({ + extraPlugins: [new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir })], + }); + connection = stack.kernel.getService('datasource-connection') as ConnectionView; + metadata = stack.kernel.getService('metadata') as typeof metadata; + const token = await stack.signIn(); + + // ── collisions, before anything of this package's is installed ───────── + collisions.showcase_external = await json(await stack.apiAs(token, 'POST', ROUTE, collidingBody('com.example.qadsc', 'showcase_external'))); + collisions.default = await json(await stack.apiAs(token, 'POST', ROUTE, collidingBody('com.example.qadsd', 'default'))); + + // ── install ──────────────────────────────────────────────────────────── + install = await json(await stack.apiAs(token, 'POST', ROUTE, INSTALL_BODY)); + for (const name of [STORE, VAULT]) { + states[name] = connection.getConnectionState(name); + registered[name] = await metadata.get('datasource', name); + } + codeNames = codeNamesOf(stack); + write = await json(await stack.apiAs(token, 'POST', `/data/${NOTE}`, { title: 'hello' })); + read = await json(await stack.apiAs(token, 'GET', `/data/${NOTE}`)); + readVault = await json(await stack.apiAs(token, 'GET', `/data/${SECRET_ROW}`)); + + // ── uninstall ────────────────────────────────────────────────────────── + uninstall = await json(await stack.apiAs(token, 'DELETE', `${ROUTE}/${PACKAGE_ID}`)); + for (const name of [STORE, VAULT]) { + statesAfter[name] = connection.getConnectionState(name); + registeredAfter[name] = await metadata.get('datasource', name); + } + codeNamesAfter = codeNamesOf(stack); + }, 300_000); + + afterAll(async () => { + await stack?.stop?.(); + if (storageDir) rmSync(storageDir, { recursive: true, force: true }); + }); + + it('the install answers each declared datasource\'s verdict', () => { + expect(install.status, JSON.stringify(install.body)).toBe(200); + expect(install.body?.data?.hotLoaded).toBe(true); + const verdicts = Object.fromEntries( + (install.body?.data?.datasources ?? []).map((v: { name: string; status: string }) => [v.name, v.status]), + ); + expect(verdicts[STORE]).toBe('connected'); + // The connection service's own name for a datasource it could not connect. + expect(['failed-credentials', 'failed-degraded']).toContain(verdicts[VAULT]); + }); + + it('both are registered as code, stamped with the package, and recorded in the code-datasource set', () => { + for (const name of [STORE, VAULT]) { + expect(registered[name]?.origin, name).toBe('code'); + expect(registered[name]?._packageId, name).toBe(PACKAGE_ID); + expect(registered[name]?._packageVersion, name).toBe('1.0.0'); + } + expect(codeNames).toEqual(expect.arrayContaining([STORE, VAULT])); + }); + + it('a write and a read of the object bound to the connected datasource reach its driver', () => { + expect(write.status, JSON.stringify(write.body)).toBe(201); + expect(read.status, JSON.stringify(read.body)).toBe(200); + expect((read.body?.records ?? []).map((r: { title?: string }) => r.title)).toEqual(['hello']); + }); + + it('the unresolved credential is a named non-ok verdict, and a bound read answers 503 naming the datasource', () => { + expect(states[STORE]?.status).toBe('connected'); + expect(['failed-credentials', 'failed-degraded']).toContain(states[VAULT]?.status); + expect(states[VAULT]?.reason).toContain(UNSET_REF); + expect(readVault.status, JSON.stringify(readVault.body)).toBe(503); + expect(readVault.body?.code).toBe('ERR_DATASOURCE_UNAVAILABLE'); + expect(readVault.body?.datasource).toBe(VAULT); + }); + + it('the uninstall withdraws the verdicts, the registrations and the names, as its own cleanup', () => { + expect(uninstall.status, JSON.stringify(uninstall.body)).toBe(200); + const cleanups: Array<{ name: string; success: boolean; removed: number }> = uninstall.body?.data?.cleanups ?? []; + expect(cleanups.filter((o) => o.success !== true)).toEqual([]); + expect(cleanups.find((o) => o.name === 'runtime.package-datasources')).toEqual({ + name: 'runtime.package-datasources', + success: true, + removed: 2, + }); + expect(statesAfter).toEqual({ [STORE]: undefined, [VAULT]: undefined }); + expect(registeredAfter).toEqual({ [STORE]: undefined, [VAULT]: undefined }); + expect(codeNamesAfter).not.toContain(STORE); + expect(codeNamesAfter).not.toContain(VAULT); + // CONTROL: the boot's own datasource is untouched. + expect(codeNamesAfter).toContain('showcase_external'); + }); + + it('a package declaring a datasource name another holder owns is refused 409 before any write', () => { + for (const held of ['showcase_external', 'default']) { + const answer = collisions[held]; + expect(answer.status, `${held}: ${JSON.stringify(answer.body)}`).toBe(409); + expect(answer.body?.error?.code).toBe('RESOURCE_CONFLICT'); + expect(answer.body?.error?.details).toEqual({ datasource: held }); + } + // Nothing of the refused packages reached the set or the connection service. + expect(codeNames.filter((n) => n !== STORE && n !== VAULT).sort()).toEqual(['default', 'showcase_external']); + }); +}); + +describe('dogfood: the install-local rehydrate connects a ledgered package\'s declared datasources', () => { + let storageDir: string; + let installAnswer: Answer; + let rehydrated: { state?: { status: string }; registered?: any; codeNames: string[]; read: Answer }; + + beforeAll(async () => { + storageDir = mkdtempSync(join(tmpdir(), 'dogfood-install-local-ds-rehydrate-')); + const first = await bootShowcase({ + extraPlugins: [new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir })], + }); + try { + installAnswer = await json(await first.apiAs(await first.signIn(), 'POST', ROUTE, INSTALL_BODY)); + } finally { + await first.stop(); + } + + const second = await bootShowcase({ + extraPlugins: [new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir })], + }); + try { + const connection = second.kernel.getService('datasource-connection') as ConnectionView; + const metadata = second.kernel.getService('metadata') as { get(type: string, name: string): Promise }; + rehydrated = { + state: connection.getConnectionState(STORE), + registered: await metadata.get('datasource', STORE), + codeNames: codeNamesOf(second), + read: await json(await second.apiAs(await second.signIn(), 'GET', `/data/${NOTE}`)), + }; + } finally { + await second.stop(); + } + }, 300_000); + + afterAll(() => { + if (storageDir) rmSync(storageDir, { recursive: true, force: true }); + }); + + it('after a restart the package\'s datasource is connected, registered as code and recorded, and its object reads', () => { + expect(installAnswer.status, JSON.stringify(installAnswer.body)).toBe(200); + expect(rehydrated.state?.status).toBe('connected'); + expect(rehydrated.registered?.origin).toBe('code'); + expect(rehydrated.registered?._packageId).toBe(PACKAGE_ID); + expect(rehydrated.codeNames).toEqual(expect.arrayContaining([STORE, VAULT])); + expect(rehydrated.read.status, JSON.stringify(rehydrated.read.body)).toBe(200); + }); +}); diff --git a/packages/runtime/src/app-plugin.ts b/packages/runtime/src/app-plugin.ts index 252a35b1833..94326730178 100644 --- a/packages/runtime/src/app-plugin.ts +++ b/packages/runtime/src/app-plugin.ts @@ -25,6 +25,7 @@ import { postureEnforcesWall, type TenancyPosture } from '@objectstack/spec/secu import { SeedLoaderService } from './seed-loader.js'; import { recordSeedOutcome } from './seed-summary.js'; import { contributeCodeDatasourceNames } from './code-datasource-names.js'; +import { connectPackageDatasources } from './declared-datasources.js'; import { mergeSeedDatasets, readSeedDatasets, registerSeedReplayerOnce } from './seed-datasets.js'; import { declareSeedSource, recordOrganizationReplay, type SeedLandingReport } from './seed-settlement.js'; import { loadDisabledPackageIds } from './package-state-store.js'; @@ -1314,51 +1315,13 @@ export class AppPlugin implements Plugin { ); } } - try { - const dsDefs = this.convertedCollection(ctx, 'datasources'); - const dsList = Array.isArray(dsDefs) - ? dsDefs - : dsDefs && typeof dsDefs === 'object' - ? Object.entries(dsDefs).map(([name, def]) => ({ name, ...(def as any) })) - : []; - if (dsList.length > 0) { - const metadata = ctx.getService('metadata') as - | { registerInMemory?: (t: string, n: string, d: unknown) => void } - | undefined; - if (typeof metadata?.registerInMemory === 'function') { - // [#21889] Each one stamped with the package that declares - // it (ADR-0010 `_packageId` / `_packageVersion`), through the - // one stamping helper both other load paths use. See - // `codeDefinedDatasourceOwners` for who that package is. - for (const { datasource: ds, owner } of this.codeDefinedDatasourceOwners(ctx)) { - if (!ds?.name) continue; - metadata.registerInMemory( - 'datasource', - ds.name, - applyProtection({ ...ds, origin: 'code' }, owner), - ); - } - ctx.logger.info('Registered code-defined datasources in metadata registry', { - appId, - count: dsList.length, - }); - } - } - } catch (err) { - ctx.logger.warn('[AppPlugin] failed to register code-defined datasources', { - error: (err as Error)?.message ?? String(err), - }); - } - - // [#22989] The stack's declared `apis`, by the same route — see - // `registerDeclaredApiEndpoints`. - this.registerDeclaredApiEndpoints(ctx, appId); - - // Auto-connect declared datasources (ADR-0062 D1/D2/D5). The metadata - // registration above only makes a datasource *visible*; to make its - // federated objects *queryable* with zero app boilerplate, build + open - // + register a live driver via the shared `'datasource-connection'` - // service (when present — wired by the datasource-admin plugin). The + // [#28039] The registration and the auto-connect (ADR-0062 D1/D2/D5) go + // through `connectPackageDatasources` — the ONE path, which install-local + // takes for a package installed into the running kernel. The metadata + // registration only makes a datasource *visible*; to make its federated + // objects *queryable* with zero app boilerplate, the shared + // `'datasource-connection'` service (when present — wired by the + // datasource-admin plugin) builds, opens and registers a live driver. The // service applies the D2 gate (connect only when `external`, an object // explicitly binds via `object.datasource`, a `datasourceMapping` rule // routes objects to it, or `autoConnect:true`) and the host connect @@ -1367,8 +1330,16 @@ export class AppPlugin implements Plugin { // // Runs in `start()` (before the `kernel:ready` external-validation gate) // so the kernel's init-all-then-start-all ordering guarantees the - // connection service was already registered during init. - try { + // connection service was already registered during init. The names + // joined the code-datasource set in `init()`. + // + // A fail-fast connect error propagates to brick boot as intended + // (ADR-0062 D5): the datasource has no fallback path — `external` + + // onMismatch:'fail', or objects that bind to it explicitly and would + // otherwise fail every query with "Datasource 'x' is not registered" + // (#3758). The helper logs it and re-throws so the kernel surfaces the + // real cause. + { const dsDefs = this.convertedCollection(ctx, 'datasources'); const dsList: any[] = Array.isArray(dsDefs) ? dsDefs @@ -1376,59 +1347,25 @@ export class AppPlugin implements Plugin { ? Object.entries(dsDefs).map(([name, def]) => ({ name, ...(def as any) })) : []; if (dsList.length > 0) { - // `ctx.getService` throws when a service is absent, so resolve - // defensively — a runtime without the datasource-admin plugin - // simply has no connection service, and declared datasources - // stay metadata-only (the legacy `onEnable` escape hatch still - // works). This must NOT fall into the fail-fast catch below. - let connection: - | { - connectDeclared?: (input: { - datasources: any[]; - objects?: Array<{ name?: string; datasource?: string }>; - mappedObjects?: Record; - }) => Promise>; - } - | undefined; - try { - connection = ctx.getService('datasource-connection'); - } catch { - connection = undefined; - } - if (typeof connection?.connectDeclared === 'function') { - const declaredObjects = this.convertedCollection(ctx, 'objects'); - const objects = Array.isArray(declaredObjects) ? declaredObjects : []; - const results = await connection.connectDeclared({ - datasources: dsList, - objects, - mappedObjects: this.resolveMappedObjects(ql, objects), - }); - const connected = results.filter((r) => r.status === 'connected'); - if (connected.length > 0) { - ctx.logger.info('Auto-connected declared datasources', { - appId, - connected: connected.map((r) => r.name), - }); - } - } else { - ctx.logger.debug('No datasource-connection service — declared datasources stay metadata-only', { appId }); - } + const declaredObjects = this.convertedCollection(ctx, 'objects'); + const objects = Array.isArray(declaredObjects) ? declaredObjects : []; + // [#21889] Each one stamped with the package that declares it + // (ADR-0010 `_packageId` / `_packageVersion`). See + // `codeDefinedDatasourceOwners` for who that package is. + await connectPackageDatasources(ctx, { + appId, + datasources: dsList, + owned: () => this.codeDefinedDatasourceOwners(ctx), + objects, + mappedObjects: this.resolveMappedObjects(ql, objects), + }); } - } catch (err) { - // A fail-fast connect error propagates to brick boot as intended - // (ADR-0062 D5): the datasource has no fallback path — `external` + - // onMismatch:'fail', or objects that bind to it explicitly and would - // otherwise fail every query with "Datasource 'x' is not registered" - // (#3758). Other errors are already degraded inside the connection - // service. Re-throw so the kernel surfaces the real cause. - // (Single-string message: the context logger types - // `error(message, error?)`, not a meta object.) - ctx.logger.error( - `[AppPlugin] declared-datasource auto-connect failed for app '${appId}': ${(err as Error)?.message ?? String(err)}`, - ); - throw err; } + // [#22989] The stack's declared `apis`, by the same route the datasources + // take — see `registerDeclaredApiEndpoints`. + this.registerDeclaredApiEndpoints(ctx, appId); + // [ADR-0130 D4] The residual of a multi-package stack — top-level // items no package body declares — through the metadata door's one // rule. BEFORE the security block below on purpose: that block is the diff --git a/packages/runtime/src/code-datasource-names.ts b/packages/runtime/src/code-datasource-names.ts index 8eb568aaaa0..3f15799dfc9 100644 --- a/packages/runtime/src/code-datasource-names.ts +++ b/packages/runtime/src/code-datasource-names.ts @@ -16,6 +16,13 @@ * resolves at its first read ({@link CodeDatasourceNames} says why); * - `DefaultDatasourcePlugin` — the host's own `default`. * + * …and one that arrives after boot: a package installed into the running + * kernel (install-local's install and its `kernel:ready` rehydrate), whose + * datasources `connectPackageDatasources` (`declared-datasources.ts`, #28039) + * registers as code and contributes here at that moment. The boot restore has + * run by then, so the phase argument below does not reach it; its uninstall + * withdraws the names again ({@link withdrawCodeDatasourceNames}). + * * `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, @@ -49,8 +56,9 @@ * * ## 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 + * A package installed after boot through any door other than install-local + * (the protocol's package 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. @@ -100,6 +108,12 @@ export class CodeDatasourceNames { return this.names.has(name); } + /** Take names out of the set — an installed package's uninstall (#28039). */ + delete(names: Iterable): void { + this.settle(); + for (const name of names) this.names.delete(name); + } + /** Every name in the set, sorted — for diagnostics and pins. */ list(): string[] { this.settle(); @@ -130,7 +144,8 @@ interface ServiceRegistryContext { /** * 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. + * `init()` — see the module header for why the phase matters — or, for a + * package installed into the running kernel, by `connectPackageDatasources`. * * `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 @@ -154,3 +169,18 @@ export function contributeCodeDatasourceNames( } (set as CodeDatasourceNames).add(contribution); } + +/** + * Take an uninstalled package's datasource names out of the host's set + * (#28039). A kernel with no set, or one holding something that is not this + * set, has nothing of this package's to withdraw. + */ +export function withdrawCodeDatasourceNames(ctx: ServiceRegistryContext, names: Iterable): void { + let set: unknown; + try { + set = ctx.getService(CODE_DATASOURCE_NAMES_SERVICE); + } catch { + return; + } + if (set instanceof CodeDatasourceNames) set.delete(names); +} diff --git a/packages/runtime/src/declared-datasources.test.ts b/packages/runtime/src/declared-datasources.test.ts new file mode 100644 index 00000000000..c4cc5283638 --- /dev/null +++ b/packages/runtime/src/declared-datasources.test.ts @@ -0,0 +1,180 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#28039] `connectPackageDatasources` — the ONE path that registers, records +// and connects the datasources a package declares, for the boot artifact +// (`AppPlugin.start`) and for a package installed into a running kernel +// (install-local, `installed: true`). +// +// The two callers differ in exactly three ways, each pinned here: +// - a boot leaves the names to `init()`'s contribution; an install records +// them in the code-datasource set itself; +// - a fail-fast connect verdict refuses a boot (it throws), and is reported +// by an install as the retained verdict; +// - an install's reinstall withdraws what the previous call registered, and +// its uninstall cleanup (`runtime.package-datasources`) withdraws the rest. +// The end-to-end half (a real install door, driver and read) is the dogfood +// file `install-local-declared-datasources.dogfood.test.ts`. + +import { describe, it, expect, vi } from 'vitest'; +import { connectPackageDatasources } from './declared-datasources.js'; +import { CODE_DATASOURCE_NAMES_SERVICE, CodeDatasourceNames } from './code-datasource-names.js'; + +type Verdict = { name: string; status: string; reason?: string }; + +/** A kernel's service registry with a metadata service, a connection service and a protocol. */ +function kernel(opts: { failFast?: string; withConnection?: boolean } = {}) { + const services = new Map(); + const items = new Map(); + const states = new Map(); + const cleanups = new Map Promise<{ success: boolean; removed: number; error?: string }>>(); + const connectCalls: Array<{ datasources: any[]; objects?: unknown }> = []; + const disconnected: string[] = []; + + services.set('metadata', { + registerInMemory: (type: string, name: string, data: unknown) => items.set(`${type}/${name}`, data), + get: async (type: string, name: string) => items.get(`${type}/${name}`), + unregister: async (type: string, name: string) => { items.delete(`${type}/${name}`); }, + }); + if (opts.withConnection !== false) { + services.set('datasource-connection', { + connectDeclared: async (input: { datasources: any[]; objects?: unknown }) => { + connectCalls.push(input); + const results: Verdict[] = []; + for (const ds of input.datasources) { + const v: Verdict = ds.name === opts.failFast + ? { name: ds.name, status: 'failed-credentials', reason: `credential '${ds.external?.credentialsRef}' could not be resolved` } + : { name: ds.name, status: 'connected' }; + states.set(ds.name, v); + results.push(v); + } + if (opts.failFast && states.has(opts.failFast)) throw new Error(`datasource '${opts.failFast}': connect failed`); + return results; + }, + getConnectionState: (name: string) => states.get(name), + disconnect: async (name: string) => { disconnected.push(name); states.delete(name); }, + }); + } + services.set('protocol', { + registerUninstallCleanup: (name: string, cleanup: any) => cleanups.set(name, cleanup), + }); + const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }; + const 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); + }, + logger, + }; + const codeNames = () => (services.get(CODE_DATASOURCE_NAMES_SERVICE) as CodeDatasourceNames | undefined)?.list() ?? []; + return { ctx: ctx as never, items, states, cleanups, connectCalls, disconnected, logger, codeNames }; +} + +const STORE = { name: 'pkg_store', driver: 'sqlite', config: { filename: ':memory:' } }; +const VAULT = { name: 'pkg_vault', driver: 'postgres', schemaMode: 'external', external: { credentialsRef: 'sys_secret:nope' } }; +const OBJECTS = [{ name: 'pkg_note', datasource: 'pkg_store' }, { name: 'pkg_secret', datasource: 'pkg_vault' }]; + +describe('connectPackageDatasources — an installed package', () => { + it('registers each datasource as code stamped with the package, records the names, and connects them', async () => { + const k = kernel(); + const verdicts = await connectPackageDatasources(k.ctx, { + appId: 'com.example.pkg', + source: 'MarketplaceInstallLocal', + datasources: [STORE], + owned: [{ datasource: STORE, owner: { packageId: 'com.example.pkg', packageVersion: '1.0.0' } }], + objects: OBJECTS, + installed: true, + }); + expect(verdicts).toEqual([{ name: 'pkg_store', status: 'connected' }]); + expect(k.items.get('datasource/pkg_store')).toMatchObject({ + name: 'pkg_store', + origin: 'code', + _packageId: 'com.example.pkg', + _packageVersion: '1.0.0', + }); + expect(k.codeNames()).toEqual(['pkg_store']); + expect(k.connectCalls).toEqual([{ datasources: [STORE], objects: OBJECTS, mappedObjects: {} }]); + expect([...k.cleanups.keys()]).toEqual(['runtime.package-datasources']); + }); + + it('a fail-fast verdict is reported, not thrown: each attempted datasource answers its retained verdict', async () => { + const k = kernel({ failFast: 'pkg_vault' }); + const verdicts = await connectPackageDatasources(k.ctx, { + appId: 'com.example.pkg', + datasources: [STORE, VAULT], + objects: OBJECTS, + installed: true, + }); + expect(verdicts).toEqual([ + { name: 'pkg_store', status: 'connected' }, + { name: 'pkg_vault', status: 'failed-credentials' }, + ]); + expect(k.logger.error).toHaveBeenCalledTimes(1); + // Recorded and registered all the same: the datasource answers its verdict. + expect(k.codeNames()).toEqual(['pkg_store', 'pkg_vault']); + expect(k.items.get('datasource/pkg_vault')?.origin).toBe('code'); + }); + + it('a reinstall replaces: what the previous version registered and the new one drops is withdrawn', async () => { + const k = kernel(); + await connectPackageDatasources(k.ctx, { appId: 'com.example.pkg', datasources: [STORE, VAULT], installed: true }); + await connectPackageDatasources(k.ctx, { appId: 'com.example.pkg', datasources: [STORE], installed: true }); + expect(k.disconnected).toEqual(['pkg_store', 'pkg_vault']); + expect(k.codeNames()).toEqual(['pkg_store']); + expect(k.items.has('datasource/pkg_vault')).toBe(false); + expect(k.states.get('pkg_store')?.status).toBe('connected'); + }); + + it('the uninstall cleanup withdraws the package\'s datasources, and only its own', async () => { + const k = kernel(); + await connectPackageDatasources(k.ctx, { appId: 'com.example.pkg', datasources: [STORE], installed: true }); + await connectPackageDatasources(k.ctx, { appId: 'com.example.other', datasources: [VAULT], installed: true }); + const cleanup = k.cleanups.get('runtime.package-datasources')!; + + expect(await cleanup({ packageId: 'com.example.pkg' })).toEqual({ success: true, removed: 1 }); + expect(k.disconnected).toEqual(['pkg_store']); + expect(k.items.has('datasource/pkg_store')).toBe(false); + expect(k.codeNames()).toEqual(['pkg_vault']); + + // A package that registered nothing here — a boot artifact's, another door's — is a no-op. + expect(await cleanup({ packageId: 'com.example.unknown' })).toEqual({ success: true, removed: 0 }); + expect(k.items.has('datasource/pkg_vault')).toBe(true); + }); + + it('a metadata registration stamped with another package is not removed by this one\'s withdrawal', async () => { + const k = kernel(); + await connectPackageDatasources(k.ctx, { appId: 'com.example.pkg', datasources: [STORE], installed: true }); + k.items.set('datasource/pkg_store', { name: 'pkg_store', origin: 'code', _packageId: 'com.example.later' }); + await k.cleanups.get('runtime.package-datasources')!({ packageId: 'com.example.pkg' }); + expect(k.items.get('datasource/pkg_store')?._packageId).toBe('com.example.later'); + }); +}); + +describe('connectPackageDatasources — the boot artifact', () => { + it('a fail-fast verdict refuses the boot, as it always did', async () => { + const k = kernel({ failFast: 'pkg_vault' }); + await expect( + connectPackageDatasources(k.ctx, { appId: 'com.example.app', datasources: [STORE, VAULT], objects: OBJECTS }), + ).rejects.toThrow("datasource 'pkg_vault': connect failed"); + expect(k.logger.error).toHaveBeenCalledWith( + expect.stringContaining("[AppPlugin] declared-datasource auto-connect failed for app 'com.example.app'"), + ); + }); + + it('records no name and registers no uninstall cleanup: the boot owns them', async () => { + const k = kernel(); + await connectPackageDatasources(k.ctx, { appId: 'com.example.app', datasources: [STORE] }); + expect(k.codeNames()).toEqual([]); + expect(k.cleanups.size).toBe(0); + expect(k.items.get('datasource/pkg_store')?.origin).toBe('code'); + }); + + it('with no connection service the datasources stay metadata-only, and nothing throws', async () => { + const k = kernel({ withConnection: false }); + expect(await connectPackageDatasources(k.ctx, { appId: 'com.example.app', datasources: [STORE] })).toEqual([]); + expect(k.items.get('datasource/pkg_store')?.origin).toBe('code'); + }); +}); diff --git a/packages/runtime/src/declared-datasources.ts b/packages/runtime/src/declared-datasources.ts new file mode 100644 index 00000000000..17b4535642f --- /dev/null +++ b/packages/runtime/src/declared-datasources.ts @@ -0,0 +1,343 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#28039] The ONE path that makes the datasources a package declares live + * (ADR-0015 Addendum, ADR-0062 D1/D2/D5): + * + * 1. registered in the metadata registry IN MEMORY as `origin: 'code'`, + * stamped with the package that declares each one (ADR-0010 + * `_packageId` / `_packageVersion`), so the admin list returns them and + * the admin service enforces them as read-only; + * 2. recorded in the host's code-datasource set + * ({@link CODE_DATASOURCE_NAMES_SERVICE}), so the admin door and the `/meta` + * door treat them as code-defined; + * 3. auto-connected through the `datasource-connection` service's + * `connectDeclared`, which applies the D2 gate and the host's connect + * policy and records each verdict (`getConnectionState`). + * + * Two callers, one path: + * + * - `AppPlugin.start()`, for the artifact the host boots. Its names joined + * the set in `init()` (Phase 1, before the datasource-admin plugin's + * restore — `code-datasource-names.ts` says why the phase matters), so step + * 2 is already done; a fail-fast connect verdict refuses the boot. + * - the install-local plugin (`@objectstack/cloud-connection`), for a package + * installed into a RUNNING kernel — on install and on every rehydrate + * ({@link PackageDatasourceOptions.installed}). Before this, that door + * registered the package's objects and nothing of its datasources, so an + * object bound to one had no driver and the name was not code-defined. + * Here the names join the set now, a fail-fast verdict is reported rather + * than thrown (there is no boot left to refuse: the datasource answers its + * retained non-`ok` verdict, and a read of a bound object answers + * `ERR_DATASOURCE_UNAVAILABLE` naming it), and the package's uninstall + * undoes all three steps through the `runtime.package-datasources` + * uninstall cleanup, as `runtime.package-jobs` does for its jobs. + * + * ⛔ Where a declared credential comes from is not decided here (#27687): the + * connection service resolves `external.credentialsRef` the one way it does + * for every datasource, and a credential it cannot resolve is its + * `failed-credentials` verdict. + */ + +import type { PluginContext } from '@objectstack/core'; +import { applyProtection } from '@objectstack/spec/shared'; +import { + CODE_DATASOURCE_NAMES_SERVICE, + contributeCodeDatasourceNames, + withdrawCodeDatasourceNames, +} from './code-datasource-names.js'; + +/** The package a declared datasource is stamped with (ADR-0010). */ +export interface DeclaredDatasourceOwner { + packageId?: string; + packageVersion?: string; +} + +/** What {@link connectPackageDatasources} is given. */ +export interface PackageDatasourceOptions { + /** The app / package id, for the log lines and the uninstall record. */ + appId: string; + /** The log tag — `AppPlugin`, `MarketplaceInstallLocal`. Defaults to `AppPlugin`. */ + source?: string; + /** The list `connectDeclared` receives. */ + datasources: readonly any[]; + /** + * Each declared datasource with the package that declares it — what + * registers in the metadata registry. Defaults to {@link datasources}, each + * stamped with `appId`. A function is resolved inside the registration's + * lenient catch: `AppPlugin`'s ownership resolution reads the artifact's + * package bodies, and a refusal there leaves the datasources unlisted, never + * the boot refused. + */ + owned?: OwnedDatasources | (() => OwnedDatasources); + /** The package's objects: the D2 gate's explicit bindings, and the schemas synced on connect. */ + objects?: ReadonlyArray<{ name?: string; datasource?: string }>; + /** Datasource name → the objects a `datasourceMapping` rule routes to it (D2 (d)). */ + mappedObjects?: Readonly>; + /** + * The package arrived in a running kernel (install, rehydrate). The names + * join the code-datasource set now, the datasources the previous call for + * this `appId` registered are withdrawn first (a reinstall replaces, never + * accumulates), a fail-fast connect verdict is reported instead of thrown, + * and the uninstall cleanup is registered. + */ + installed?: boolean; +} + +/** Each declared datasource with the package that declares it. */ +export type OwnedDatasources = ReadonlyArray<{ datasource: any; owner: DeclaredDatasourceOwner }>; + +/** One declared datasource's connect verdict, as the connection service retained it. */ +export interface PackageDatasourceVerdict { + name: string; + status: string; +} + +/** The `datasource-connection` surface this path drives. */ +interface ConnectionServiceLike { + connectDeclared?: (input: { + datasources: any[]; + objects?: ReadonlyArray<{ name?: string; datasource?: string }>; + mappedObjects?: Readonly>; + }) => Promise>; + getConnectionState?: (name: string) => { name: string; status: string } | undefined; + disconnect?: (name: string) => Promise; +} + +/** The uninstall cleanup's name on the protocol's registry. */ +const PACKAGE_DATASOURCES_UNINSTALL_CLEANUP = 'runtime.package-datasources'; + +/** + * Per kernel (keyed by its code-datasource set), the datasource names an + * installed package's call registered — what its reinstall and its uninstall + * withdraw. A boot artifact's are never recorded: the boot owns them. + */ +const INSTALLED_BY_APP = new WeakMap>(); + +/** The kernel's code-datasource set, which keys {@link INSTALLED_BY_APP}; `undefined` before any producer contributed. */ +function kernelKey(ctx: PluginContext): object | undefined { + try { + const set = ctx.getService(CODE_DATASOURCE_NAMES_SERVICE); + return set !== null && typeof set === 'object' ? set : undefined; + } catch { + return undefined; + } +} + +/** The protocols this process registered the datasource cleanup on — once each. */ +const DATASOURCE_CLEANUP_REGISTERED = new WeakSet(); + +/** + * Register, record and connect the datasources one package declares — see the + * module header. Returns the connect verdict of each declared datasource the + * connection service attempted (`[]` with no connection service, which leaves + * them metadata-only, as a runtime without the datasource-admin plugin always + * has). + * + * Throws on a boot (`installed` unset) exactly where the boot path always + * threw: a fail-fast connect verdict (ADR-0062 D5). A registration failure is + * logged at `warn` and the connect still runs. + */ +export async function connectPackageDatasources( + ctx: PluginContext, + options: PackageDatasourceOptions, +): Promise { + const { appId, datasources } = options; + const tag = `[${options.source ?? 'AppPlugin'}]`; + const installed = options.installed === true; + const ownedOf = (): OwnedDatasources => + typeof options.owned === 'function' + ? options.owned() + : options.owned ?? datasources.map((datasource) => ({ datasource, owner: { packageId: appId } })); + + if (installed) await withdrawPackageDatasources(ctx, appId); + if (datasources.length === 0) return []; + + if (installed) { + const names = ownedOf() + .map(({ datasource }) => datasource?.name) + .filter((name): name is string => typeof name === 'string' && name.length > 0); + contributeCodeDatasourceNames(ctx, names); + const key = kernelKey(ctx); + if (key) recordInstalled(key, appId, names); + ensureDatasourceUninstallCleanup(ctx); + } + + // 1. The metadata registration — lenient: a registry that refuses leaves + // the datasource unlisted, never the package unloaded. + try { + const metadata = ctx.getService('metadata') as + | { registerInMemory?: (t: string, n: string, d: unknown) => void } + | undefined; + if (typeof metadata?.registerInMemory === 'function') { + for (const { datasource: ds, owner } of ownedOf()) { + if (!ds?.name) continue; + metadata.registerInMemory('datasource', ds.name, applyProtection({ ...ds, origin: 'code' }, owner)); + } + ctx.logger.info('Registered code-defined datasources in metadata registry', { + appId, + count: datasources.length, + }); + } + } catch (err) { + ctx.logger.warn(`${tag} failed to register code-defined datasources`, { + error: (err as Error)?.message ?? String(err), + }); + } + + // 2. The connect. `ctx.getService` throws when a service is absent, so it is + // resolved defensively — a runtime without the datasource-admin plugin + // has no connection service, and declared datasources stay metadata-only + // (the legacy `onEnable` escape hatch still works). That must NOT fall + // into the fail-fast catch below. + let connection: ConnectionServiceLike | undefined; + try { + connection = ctx.getService('datasource-connection'); + } catch { + connection = undefined; + } + if (typeof connection?.connectDeclared !== 'function') { + ctx.logger.debug('No datasource-connection service — declared datasources stay metadata-only', { appId }); + return []; + } + try { + const results = await connection.connectDeclared({ + // By reference: the boot hands the connect the very list its one + // forward conversion produced (#23029 pins the identity). + datasources: datasources as any[], + objects: options.objects ?? [], + mappedObjects: options.mappedObjects ?? {}, + }); + const connected = results.filter((r) => r.status === 'connected'); + if (connected.length > 0) { + ctx.logger.info('Auto-connected declared datasources', { + appId, + connected: connected.map((r) => r.name), + }); + } + return results.map(({ name, status }) => ({ name, status })); + } catch (err) { + // A fail-fast verdict (ADR-0062 D5): the datasource has no fallback + // path — `external` + onMismatch:'fail', or objects that bind to it + // explicitly and would otherwise fail every query with "Datasource 'x' + // is not registered" (#3758). Other errors are already degraded inside + // the connection service. (Single-string message: the context logger + // types `error(message, error?)`, not a meta object.) + ctx.logger.error( + `${tag} declared-datasource auto-connect failed for app '${appId}': ${(err as Error)?.message ?? String(err)}`, + ); + // On a boot the kernel surfaces the real cause and the boot is refused. + if (!installed) throw err; + // In a running kernel there is no boot to refuse. `connectDeclared` + // attempted every datasource before it threw, and the connection + // service retained each verdict and marked an unusable one on the + // engine, so the datasource answers that verdict and a read of a bound + // object answers `ERR_DATASOURCE_UNAVAILABLE` naming it — never a + // silent driver gap. + const verdicts: PackageDatasourceVerdict[] = []; + for (const ds of datasources) { + const state = typeof ds?.name === 'string' ? connection.getConnectionState?.(ds.name) : undefined; + if (state) verdicts.push({ name: state.name, status: state.status }); + } + return verdicts; + } +} + +/** Record the names an installed package's call registered, for its reinstall and uninstall. */ +function recordInstalled(key: object, appId: string, names: string[]): void { + let byApp = INSTALLED_BY_APP.get(key); + if (!byApp) { + byApp = new Map(); + INSTALLED_BY_APP.set(key, byApp); + } + if (names.length > 0) byApp.set(appId, [...names]); + else byApp.delete(appId); +} + +/** + * Withdraw every datasource an installed package's earlier call registered: + * its pool disconnected (the connection service's own teardown, which evicts + * the driver and drops the retained verdict), its metadata registration + * removed — only a registration still stamped with this package's id — and its + * name taken out of the code-datasource set. Answers the names withdrawn and + * the names whose metadata registration could not be removed. + */ +async function withdrawPackageDatasources( + ctx: PluginContext, + appId: string, +): Promise<{ removed: string[]; kept: string[]; cause?: unknown }> { + const key = kernelKey(ctx); + const names = (key && INSTALLED_BY_APP.get(key)?.get(appId)) ?? []; + if (!key || names.length === 0) return { removed: [], kept: [] }; + + let metadata: { get?: (t: string, n: string) => Promise; unregister?: (t: string, n: string, o?: { notify?: boolean }) => Promise } | undefined; + try { + metadata = ctx.getService('metadata'); + } catch { + metadata = undefined; + } + let connection: ConnectionServiceLike | undefined; + try { + connection = ctx.getService('datasource-connection'); + } catch { + connection = undefined; + } + const removed: string[] = []; + const kept: string[] = []; + let cause: unknown; + for (const name of names) { + // Never throws: a failed close is logged by the service, and the driver + // is evicted either way. + if (typeof connection?.disconnect === 'function') await connection.disconnect(name); + try { + const item = await metadata?.get?.('datasource', name); + if ((item as { _packageId?: unknown } | undefined)?._packageId === appId) { + await metadata?.unregister?.('datasource', name, { notify: false }); + } + removed.push(name); + } catch (err) { + kept.push(name); + cause ??= err; + } + } + withdrawCodeDatasourceNames(ctx, names); + INSTALLED_BY_APP.get(key)?.delete(appId); + return { removed, kept, cause }; +} + +/** + * Register, once per kernel, the uninstall cleanup that withdraws an installed + * package's datasources: `runtime.package-datasources`, on the protocol's + * uninstall-cleanup registry (`registerUninstallCleanup`, #21490) — the ONE + * place an uninstall's data-plane revocation runs from, so the protocol's + * `deletePackage` and install-local's `DELETE` both withdraw them with no + * per-door copy. It acts on what {@link connectPackageDatasources} recorded for + * the package id only, so a package that installed no datasource — and a boot + * artifact, which records none — is a no-op. + * + * Never throws: a registration it could not remove is an outcome + * (`success: false`), which both doors report on their uninstall response. + */ +function ensureDatasourceUninstallCleanup(ctx: PluginContext): void { + let protocol: { registerUninstallCleanup?: (name: string, cleanup: (args: { packageId: string }) => Promise<{ success: boolean; removed: number; error?: string }>) => void } | undefined; + try { protocol = ctx.getService('protocol'); } catch { return; } + if (!protocol || typeof protocol.registerUninstallCleanup !== 'function') return; + if (DATASOURCE_CLEANUP_REGISTERED.has(protocol)) return; + DATASOURCE_CLEANUP_REGISTERED.add(protocol); + const logger = ctx.logger; + protocol.registerUninstallCleanup(PACKAGE_DATASOURCES_UNINSTALL_CLEANUP, async ({ packageId }) => { + const { removed, kept, cause } = await withdrawPackageDatasources(ctx, packageId); + if (kept.length === 0) return { success: true, removed: removed.length }; + logger?.warn?.( + `[uninstall] the metadata registration of datasource(s) ${kept.map((n) => `'${n}'`).join(', ')} of ` + + `${packageId} could not be removed (${(cause as Error)?.message ?? String(cause)}) — their pools are ` + + 'closed, but Setup → Datasources lists them until the runtime restarts.', + ); + return { + success: false, + removed: removed.length, + error: `${kept.length} datasource registration(s) of the uninstalled package stay listed until the runtime restarts: ` + + kept.map((n) => `'${n}'`).join(', '), + }; + }); +} diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index b20d09ae75a..4918413ef82 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -176,6 +176,16 @@ export type { JobWithoutBody, HookWithoutBody, } from './app-artifact-handlers.js'; +// [#28039] The ONE path that registers, records and connects the datasources a +// package declares — called by `AppPlugin.start` and by the install-local plugin +// (`@objectstack/cloud-connection`) on install and rehydrate. +export { connectPackageDatasources } from './declared-datasources.js'; +export type { + DeclaredDatasourceOwner, + OwnedDatasources, + PackageDatasourceOptions, + PackageDatasourceVerdict, +} from './declared-datasources.js'; // #14094 — what a DECLARATIVE job's handler is invoked with. A job has no graph, // so unlike a flow `script` node it is given data reach (`ql`) instead of being a // pure value-returner whose I/O the surrounding graph performs. From 475a5292d3ca575b438a8c6f1ca2f4662ea8e36f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 12:36:39 +0000 Subject: [PATCH 2/4] test(dogfood): pin the admin list's code-defined verdicts; docs and changeset for install-local datasources Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_016WV4cx4xgJksGo7iVA8c9A --- .../28039-install-local-declared-datasources.md | 17 +++++++++++++++++ .../docs/data-modeling/external-datasources.mdx | 11 +++++++++++ ...l-local-declared-datasources.dogfood.test.ts | 9 +++++++++ 3 files changed, 37 insertions(+) create mode 100644 .changeset/28039-install-local-declared-datasources.md diff --git a/.changeset/28039-install-local-declared-datasources.md b/.changeset/28039-install-local-declared-datasources.md new file mode 100644 index 00000000000..28081910911 --- /dev/null +++ b/.changeset/28039-install-local-declared-datasources.md @@ -0,0 +1,17 @@ +--- +'@objectstack/runtime': patch +'@objectstack/cloud-connection': patch +--- + +fix(cloud-connection): an install-local install connects and records the datasources the package declares, as a boot of its artifact does + +Clause-②: yes (widening) +Metadata-protocol: none + +A package installed through `POST /api/v1/marketplace/install-local` had its objects registered and nothing of its datasources: an object bound to the package's own datasource answered `404 OBJECT_NOT_FOUND` on every read and write, and the datasource was not served as code-defined. Now the install, and every restart's rehydrate of the ledger, take the path a boot of the package's artifact takes: + +- **One path.** `@objectstack/runtime` exports `connectPackageDatasources`, which `AppPlugin.start` now calls for the booted artifact and install-local calls for an installed package. Each declared datasource is registered in the metadata registry as `origin: 'code'`, stamped with the package (`_packageId`, `_packageVersion`), its name is recorded in the host's code-datasource set, and it is connected through the `datasource-connection` service's `connectDeclared` (the ADR-0062 D2 gate and the host's connect policy apply as on a boot). The boot's own behaviour is unchanged. +- **The install answer** carries `datasources`: each declared datasource's connect verdict (`connected`, or the connection service's own non-`ok` status). Omitted for a package that declares none. +- **A credential that cannot be resolved** is that datasource's named non-`ok` verdict, never a driver gap: the package is installed, Setup → Datasources reports the datasource as `error` with its reason, and a read of an object bound to it answers `503 ERR_DATASOURCE_UNAVAILABLE` naming it. Where a code datasource's credential comes from is not changed here. +- **Reinstall and uninstall.** A reinstall replaces the previous version's datasources; the uninstall closes their pools, removes their registrations and their names, reported as the `runtime.package-datasources` entry in `cleanups`. +- **A name already held** — the host's `default`, a datasource created in Setup, another package's — refuses the install `409 RESOURCE_CONFLICT` (`details.datasource`) before anything is written. diff --git a/content/docs/data-modeling/external-datasources.mdx b/content/docs/data-modeling/external-datasources.mdx index 64c20325b87..3e10becb5b3 100644 --- a/content/docs/data-modeling/external-datasources.mdx +++ b/content/docs/data-modeling/external-datasources.mdx @@ -186,6 +186,17 @@ connect, so queries against its objects keep failing until the server is restarted. Do not set it in production. + +**A package installed into a running runtime** (`os package install`, which calls +`POST /api/v1/marketplace/install-local`) gets the same treatment at install time +and at every restart: its declared datasources are registered as `origin: code`, +read-only, and auto-connected by the same gates. There is no boot left to refuse, +so a datasource that cannot be connected is installed with its failed `status` +instead, and reads of its bound objects answer `503 ERR_DATASOURCE_UNAVAILABLE`. +The install answer lists each datasource's verdict under `datasources`. A package +declaring a datasource name the runtime already holds is refused `409`. + + ### Seeing the state of a datasource Every connect attempt's verdict is retained, so a datasource that is down no diff --git a/packages/qa/dogfood/test/install-local-declared-datasources.dogfood.test.ts b/packages/qa/dogfood/test/install-local-declared-datasources.dogfood.test.ts index 0fba44650e5..84e26adba7b 100644 --- a/packages/qa/dogfood/test/install-local-declared-datasources.dogfood.test.ts +++ b/packages/qa/dogfood/test/install-local-declared-datasources.dogfood.test.ts @@ -128,6 +128,7 @@ describe('dogfood: an install-local install connects and records the package\'s const states: Record = {}; const registered: Record = {}; let codeNames: string[]; + let listed: Record; let write: Answer; let read: Answer; let readVault: Answer; @@ -157,6 +158,8 @@ describe('dogfood: an install-local install connects and records the package\'s registered[name] = await metadata.get('datasource', name); } codeNames = codeNamesOf(stack); + const admin = stack.kernel.getService('datasource-admin') as { listDatasources(): Promise> }; + listed = Object.fromEntries((await admin.listDatasources()).map((d) => [d.name, d])); write = await json(await stack.apiAs(token, 'POST', `/data/${NOTE}`, { title: 'hello' })); read = await json(await stack.apiAs(token, 'GET', `/data/${NOTE}`)); readVault = await json(await stack.apiAs(token, 'GET', `/data/${SECRET_ROW}`)); @@ -195,6 +198,12 @@ describe('dogfood: an install-local install connects and records the package\'s expect(codeNames).toEqual(expect.arrayContaining([STORE, VAULT])); }); + it('the admin list serves both as code-defined: the connected one ok, the other error naming the credential', () => { + expect(listed[STORE]).toMatchObject({ origin: 'code', status: 'ok' }); + expect(listed[VAULT]).toMatchObject({ origin: 'code', status: 'error' }); + expect(listed[VAULT]?.statusReason).toContain(UNSET_REF); + }); + it('a write and a read of the object bound to the connected datasource reach its driver', () => { expect(write.status, JSON.stringify(write.body)).toBe(201); expect(read.status, JSON.stringify(read.body)).toBe(200); From 9ae8fc1682699ce3aeef51ebd726cb4151070018 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 13:38:33 +0000 Subject: [PATCH 3/4] fix(cloud-connection)!: declare the held-name 409 as an install-door narrowing The install-local door now refuses a package whose declared datasource name the environment already holds; main installed that package 200, so the changeset declares the narrowing arm with its BREAKING banner and ADR-0087 disposition. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_016WV4cx4xgJksGo7iVA8c9A --- ...28039-install-local-declared-datasources.md | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/.changeset/28039-install-local-declared-datasources.md b/.changeset/28039-install-local-declared-datasources.md index 28081910911..2465cbd38e1 100644 --- a/.changeset/28039-install-local-declared-datasources.md +++ b/.changeset/28039-install-local-declared-datasources.md @@ -3,15 +3,27 @@ '@objectstack/cloud-connection': patch --- -fix(cloud-connection): an install-local install connects and records the datasources the package declares, as a boot of its artifact does +fix(cloud-connection)!: an install-local install connects and records the datasources the package declares, as a boot of its artifact does -Clause-②: yes (widening) +Clause-②: yes (narrowing) Metadata-protocol: none + + +**BREAKING** accept-set narrowing at `POST /api/v1/marketplace/install-local`, alongside the widening below (a new `@objectstack/runtime` export and a new field on the install answer). + +- **Before.** A package declaring a datasource whose name the environment already held (the host's `default`, a datasource created in Setup, another package's) installed `200`, and its declaration was dropped: measured on `main` with a package declaring `showcase_external`, the install answered `200` with `hotLoaded: true`. +- **Now.** The same install is refused `409 RESOURCE_CONFLICT`, with `details.datasource` naming the held name, before anything is written. Accepting it now would serve the package's objects from the holder's pool (the connect is idempotent on the name) and, by recording the name as code, make the holder's own datasource read-only. +- **Unchanged.** A package that declares no datasource, or only names nothing else holds, installs as before; a reinstall of the same package is not a collision with itself. +- **Who sees it.** Only an install-local caller (`os package install`, an AI agent, a script) installing a package whose datasource name collides with one the environment already has. + +**The remedy.** Give the package's datasource a name the environment does not already hold, and install again. + +**What else changes.** + A package installed through `POST /api/v1/marketplace/install-local` had its objects registered and nothing of its datasources: an object bound to the package's own datasource answered `404 OBJECT_NOT_FOUND` on every read and write, and the datasource was not served as code-defined. Now the install, and every restart's rehydrate of the ledger, take the path a boot of the package's artifact takes: - **One path.** `@objectstack/runtime` exports `connectPackageDatasources`, which `AppPlugin.start` now calls for the booted artifact and install-local calls for an installed package. Each declared datasource is registered in the metadata registry as `origin: 'code'`, stamped with the package (`_packageId`, `_packageVersion`), its name is recorded in the host's code-datasource set, and it is connected through the `datasource-connection` service's `connectDeclared` (the ADR-0062 D2 gate and the host's connect policy apply as on a boot). The boot's own behaviour is unchanged. - **The install answer** carries `datasources`: each declared datasource's connect verdict (`connected`, or the connection service's own non-`ok` status). Omitted for a package that declares none. - **A credential that cannot be resolved** is that datasource's named non-`ok` verdict, never a driver gap: the package is installed, Setup → Datasources reports the datasource as `error` with its reason, and a read of an object bound to it answers `503 ERR_DATASOURCE_UNAVAILABLE` naming it. Where a code datasource's credential comes from is not changed here. - **Reinstall and uninstall.** A reinstall replaces the previous version's datasources; the uninstall closes their pools, removes their registrations and their names, reported as the `runtime.package-datasources` entry in `cleanups`. -- **A name already held** — the host's `default`, a datasource created in Setup, another package's — refuses the install `409 RESOURCE_CONFLICT` (`details.datasource`) before anything is written. From dbc6f440b9aaf1b38d1d6c39442dbae91fd73683 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 14:03:17 +0000 Subject: [PATCH 4/4] fix(cloud-connection): read declared datasources through a module-scope helper, keeping the plugin's class name A static method called by the class's own name made esbuild rename the shipped class `_MarketplaceInstallLocalPlugin`, so the CLI's install-local identity no longer matched it. The two datasource methods also move above the handler binder's docblock, which they had split from its method. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_016WV4cx4xgJksGo7iVA8c9A --- .../src/marketplace-install-local-plugin.ts | 108 +++++++++--------- 1 file changed, 54 insertions(+), 54 deletions(-) diff --git a/packages/cloud-connection/src/marketplace-install-local-plugin.ts b/packages/cloud-connection/src/marketplace-install-local-plugin.ts index 8944b67e52c..3dbce3c9108 100644 --- a/packages/cloud-connection/src/marketplace-install-local-plugin.ts +++ b/packages/cloud-connection/src/marketplace-install-local-plugin.ts @@ -632,6 +632,19 @@ function manifestIdOf(p: any): string | undefined { return p?.manifest?.id ?? p?.id ?? p?.manifest?.name ?? undefined; } +/** + * [#28039] The datasources a package body declares, as `AppPlugin` reads a + * stack's: a list, or a map keyed by name. + */ +function declaredDatasources(body: unknown): any[] { + const defs = isPlainObject(body) ? body.datasources : undefined; + return Array.isArray(defs) + ? defs + : isPlainObject(defs) + ? Object.entries(defs).map(([name, def]) => ({ name, ...(def as any) })) + : []; +} + /** * The seed datasets a cached manifest bundles: its `data[]` entries that name * an object and carry records. One reading for the two doors that match seed @@ -3517,58 +3530,6 @@ export class MarketplaceInstallLocalPlugin implements Plugin { return typeof ql?.convertPackageBodyForward === 'function' ? ql.convertPackageBodyForward(manifest) : manifest; }; - /** - * [#21321] Bind an installed package's executable handlers — its - * `type: 'script'` action bodies and its body hooks — through - * `bindAppArtifactHandlers`, the runtime's ONE binder and the call - * `AppPlugin.start` makes for a boot artifact, under the same owner - * (`app:`). Called on the install route and on the - * `kernel:ready` rehydrate. - * - * [#21489] …and schedule its jobs through `scheduleAppArtifactJobs`, the - * binder's job half and the call `AppPlugin` makes on `kernel:ready`: a job - * `body` runs sandboxed on its schedule, hot and after a restart. A - * handler-only job never reaches here on an install — the install route - * refuses it ({@link unrunnableCode}) — and on a rehydrate of an entry an - * older build installed it is reported at `warn` and not run. - * - * [#21585] This door carries no runtime module, and says so to the binder - * (`withholdHooksWithoutBody`): a hook with no `body` is warned and NOT - * bound, because its function-name `handler` can never name the package's - * own code here. The install route refuses such a hook, so this fires only - * on the rehydrate of an entry an older build installed — which before this - * bound the hook by name to whatever function the engine held under it. - * - * Before this, `manifest.register` was the whole install: the package's - * actions and hooks were DECLARED and never bound, so every door refused - * its script actions ("No handler registered" over MCP, 404 over REST) - * before and after a restart, and its body hooks never fired — while an - * `os start --artifact` boot of the same file dispatched them. - * - * The binder replaces the owner's previous set, so a reinstall leaves each - * action with exactly one handler and stops whatever the new version - * dropped. ⛔ No second registration path lives here: a runtime without the - * binder (an older build, or a suite that mocks `@objectstack/runtime` - * without it) binds NOTHING and says so — the package's script actions then - * stay unrunnable, and `list_actions` does not advertise them. The same - * holds for the job half: no job is scheduled here by any other route. - * - * Resolved lazily through `@objectstack/runtime`, like every other runtime - * helper this plugin calls. Never throws. - */ - /** - * [#28039] The datasources a package body declares, as `AppPlugin` reads a - * stack's: a list, or a map keyed by name. - */ - private static declaredDatasources(body: unknown): any[] { - const defs = isPlainObject(body) ? body.datasources : undefined; - return Array.isArray(defs) - ? defs - : isPlainObject(defs) - ? Object.entries(defs).map(([name, def]) => ({ name, ...(def as any) })) - : []; - } - /** * [#28039] Register, record and connect the datasources `manifest` * declares, through `connectPackageDatasources` — the ONE path, which @@ -3599,7 +3560,7 @@ export class MarketplaceInstallLocalPlugin implements Plugin { manifestId: string, version: string | undefined, ): Promise | undefined> => { - const declared = MarketplaceInstallLocalPlugin.declaredDatasources(manifest); + const declared = declaredDatasources(manifest); let connect: typeof import('@objectstack/runtime')['connectPackageDatasources'] | undefined; try { const mod: any = await import('@objectstack/runtime'); @@ -3651,7 +3612,7 @@ export class MarketplaceInstallLocalPlugin implements Plugin { manifest: unknown, manifestId: string, ): Promise<{ name: string; holder: string } | undefined> => { - const names = MarketplaceInstallLocalPlugin.declaredDatasources(manifest) + const names = declaredDatasources(manifest) .map((ds) => ds?.name) .filter((name): name is string => typeof name === 'string' && name.length > 0); if (names.length === 0) return undefined; @@ -3674,6 +3635,45 @@ export class MarketplaceInstallLocalPlugin implements Plugin { return undefined; }; + /** + * [#21321] Bind an installed package's executable handlers — its + * `type: 'script'` action bodies and its body hooks — through + * `bindAppArtifactHandlers`, the runtime's ONE binder and the call + * `AppPlugin.start` makes for a boot artifact, under the same owner + * (`app:`). Called on the install route and on the + * `kernel:ready` rehydrate. + * + * [#21489] …and schedule its jobs through `scheduleAppArtifactJobs`, the + * binder's job half and the call `AppPlugin` makes on `kernel:ready`: a job + * `body` runs sandboxed on its schedule, hot and after a restart. A + * handler-only job never reaches here on an install — the install route + * refuses it ({@link unrunnableCode}) — and on a rehydrate of an entry an + * older build installed it is reported at `warn` and not run. + * + * [#21585] This door carries no runtime module, and says so to the binder + * (`withholdHooksWithoutBody`): a hook with no `body` is warned and NOT + * bound, because its function-name `handler` can never name the package's + * own code here. The install route refuses such a hook, so this fires only + * on the rehydrate of an entry an older build installed — which before this + * bound the hook by name to whatever function the engine held under it. + * + * Before this, `manifest.register` was the whole install: the package's + * actions and hooks were DECLARED and never bound, so every door refused + * its script actions ("No handler registered" over MCP, 404 over REST) + * before and after a restart, and its body hooks never fired — while an + * `os start --artifact` boot of the same file dispatched them. + * + * The binder replaces the owner's previous set, so a reinstall leaves each + * action with exactly one handler and stops whatever the new version + * dropped. ⛔ No second registration path lives here: a runtime without the + * binder (an older build, or a suite that mocks `@objectstack/runtime` + * without it) binds NOTHING and says so — the package's script actions then + * stay unrunnable, and `list_actions` does not advertise them. The same + * holds for the job half: no job is scheduled here by any other route. + * + * Resolved lazily through `@objectstack/runtime`, like every other runtime + * helper this plugin calls. Never throws. + */ private bindArtifactHandlers = async (ctx: PluginContext, manifest: unknown, manifestId: string): Promise => { let ql: IObjectQLEngine | undefined; try { ql = ctx.getService('objectql'); } catch { /* no data engine */ }