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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .changeset/28143-install-local-datasource-holders.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
'@objectstack/runtime': patch
'@objectstack/cloud-connection': patch
---

fix(cloud-connection): an install-local install judges who holds a datasource name with one judge that reads the engine too and fails closed, and a restart's rehydrate no longer takes over a name another holder owns

Clause-②: yes (widening)
The widening: `@objectstack/runtime` now exports `datasourceNameHolder` and the type `DatasourceNameHolding`, and `POST /api/v1/marketplace/install-local` gains a `503 SERVICE_UNAVAILABLE` answer.
Metadata-protocol: none

Three edges `POST /api/v1/marketplace/install-local`'s datasource-name check left open, each measured on `main`:

- **A holder that cannot be read.** When the metadata registry's read of a declared datasource name threw, the check skipped it and the install went ahead (`200`). It now answers `503 SERVICE_UNAVAILABLE` with `details: { datasource, reading }` and writes nothing: a check that did not run is never taken for "no holder". Retry once that store answers.
- **A datasource registered on the data engine only** (the legacy `onEnable` bridge, which leaves no metadata item) was not seen: the install answered `200` and reported that pool as the package's `already-registered`. It now holds its name, `409 RESOURCE_CONFLICT` like every other holder.
- **A package installed before the check existed.** A restart's rehydrate replayed such a package's datasources over a datasource created in Setup under the same name: the Setup item was overwritten by the package's code registration, the name joined the code-defined set (the Setup row turned read-only), and the package's uninstall closed and evicted the Setup row's pool. The rehydrate now leaves a held name alone: it is not registered, recorded or connected for the package, one `error` line names it, its holder and the remedy, and the uninstall does not touch it. An uninstall also closes only the pools the package's own connect opened, never one it found already registered.

`@objectstack/runtime` exports `datasourceNameHolder`, the one judge both the install door and the rehydrate use: the host's `default`, a metadata item not stamped with the package (a Setup row, another package's), a name in the code-datasource set the package did not record, then a driver on the data engine.

**The remedy** for a package the rehydrate now reports: rename the package's datasource and install it again, or remove the datasource holding the name and restart.
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#28143] Step 2a of `POST /api/v1/marketplace/install-local` — a package
* declaring a datasource name another holder owns is refused before any write
* — judges with the runtime's ONE judge (`datasourceNameHolder`), and so:
*
* - a holder the metadata registry cannot be read for is `503
* SERVICE_UNAVAILABLE`, never taken for "no holder": the install does not
* proceed past an outage;
* - a datasource registered on the data engine only (the legacy `onEnable`
* bridge, with no metadata item) holds its name: `409 RESOURCE_CONFLICT`,
* where the door used to install and answer that pool `already-registered`.
*
* Each refusal leaves the runtime as it found it: no ledger entry, nothing
* registered. The end-to-end refusals (the boot's datasource, the host's
* `default`) are pinned by `install-local-declared-datasources.dogfood.test.ts`.
*/

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
// The install and its 2a judge reach `@objectstack/runtime` through a lazy
// `import()`; its first load is paid here, at module top — never inside a
// clocked `it` (`scripts/check-test-source-alias.mjs`).
import '@objectstack/runtime';
import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js';
import { installerAuthService, withInstallerGrants } from './install-local-principal.fixtures.js';

type Handler = (c: any) => Promise<any>;

const ROUTE = 'POST /api/v1/marketplace/install-local';
const DS = 'qah_store';

function makeRawApp() {
const routes = new Map<string, Handler>();
return {
routes,
get: (p: string, h: Handler) => routes.set(`GET ${p}`, h),
post: (p: string, h: Handler) => routes.set(`POST ${p}`, h),
delete: (p: string, h: Handler) => routes.set(`DELETE ${p}`, h),
};
}

/** A kernel whose metadata registry and data engine the case shapes. */
function makeCtx(rawApp: any, opts: { metadataFault?: boolean; engineDrivers?: string[] }) {
const hooks = new Map<string, any>();
const registered: any[] = [];
const drivers = new Set(opts.engineDrivers ?? []);
const services: Record<string, any> = {
manifest: { register: (m: any) => registered.push({ manifest: m }) },
auth: installerAuthService(),
objectql: withInstallerGrants({
syncSchemas: async () => undefined,
registry: { getAllPackages: () => registered },
getDriverByName: (name: string) => (drivers.has(name) ? { name } : undefined),
}),
metadata: {
get: async () => {
if (opts.metadataFault) throw new Error('metadata loader unavailable');
return undefined;
},
},
};
return {
registered,
ctx: {
hook: (e: string, h: any) => hooks.set(e, h),
getService: (name: string) => {
if (name === 'http-server') return { getRawApp: () => rawApp };
const svc = services[name];
if (svc === undefined) throw new Error(`no ${name}`);
return svc;
},
registerService: (name: string, svc: unknown) => { services[name] = svc; },
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
},
fire: async () => { await hooks.get('kernel:ready')?.(); },
};
}

function makeC(body: any) {
const json = vi.fn((payload: any, status?: number) => ({ payload, status: status ?? 200 }));
return { req: { url: 'http://localhost:3000/x', raw: new Request('http://localhost:3000/x'), json: async () => body, param: () => undefined }, json };
}

const BODY = {
manifest: {
id: 'com.example.qah',
version: '1.0.0',
datasources: [{ name: DS, label: 'QA store', driver: 'sqlite', config: { filename: ':memory:' } }],
objects: [],
},
};

let dir: string;
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'mil-ds-holder-')); });
afterEach(() => { rmSync(dir, { recursive: true, force: true }); vi.restoreAllMocks(); });

async function install(opts: { metadataFault?: boolean; engineDrivers?: string[] }) {
const rawApp = makeRawApp();
const k = makeCtx(rawApp, opts);
const plugin = new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir: dir });
await plugin.start(k.ctx as any);
await k.fire();
const res = await rawApp.routes.get(ROUTE)!(makeC(BODY));
// The plugin registers its own Installed Apps UI manifest at start; only
// the package's registration is this case's.
const registered = k.registered.map((r) => r.manifest.id).filter((id) => id === 'com.example.qah');
return { res, registered, ledger: readdirSync(dir) };
}

describe('install-local 2a — who holds a declared datasource name', () => {
it('a holder that cannot be read refuses 503, and nothing is written', async () => {
const { res, registered, ledger } = await install({ metadataFault: true });
expect(res.status).toBe(503);
expect(res.payload?.error?.code).toBe('SERVICE_UNAVAILABLE');
expect(res.payload?.error?.details).toEqual({ datasource: DS, reading: 'the metadata registry' });
expect(registered).toEqual([]);
expect(ledger).toEqual([]);
});

it('a datasource registered on the engine only holds its name: 409, and nothing is written', async () => {
const { res, registered, ledger } = await install({ engineDrivers: [DS] });
expect(res.status).toBe(409);
expect(res.payload?.error?.code).toBe('RESOURCE_CONFLICT');
expect(res.payload?.error?.details).toEqual({ datasource: DS });
expect(registered).toEqual([]);
expect(ledger).toEqual([]);
});

it('CONTROL: a name nobody holds installs', async () => {
const { res, registered } = await install({ engineDrivers: ['someone_else'] });
expect(res.status, JSON.stringify(res.payload)).toBe(200);
expect(res.payload?.success).toBe(true);
expect(registered).toEqual(['com.example.qah']);
});
});
76 changes: 57 additions & 19 deletions packages/cloud-connection/src/marketplace-install-local-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1812,7 +1812,28 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
// 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.
// [#28143] Judged by the runtime's ONE judge, which reads the data
// engine as well (a driver the legacy `onEnable` bridge registered
// has no metadata item), and a holder that cannot be read refuses
// `503`: a check that did not run is never taken for "no holder".
const heldDatasource = await this.findHeldDatasourceName(ctx, manifest, manifestId);
if (heldDatasource && 'reading' in heldDatasource) {
ctx.logger?.warn?.(
`[MarketplaceInstallLocal] install of ${manifestId} refused: whether another holder owns its datasource `
+ `'${heldDatasource.name}' could not be judged — ${heldDatasource.reading} could not be read (${heldDatasource.error})`,
);
return c.json({
success: false,
error: {
code: 'SERVICE_UNAVAILABLE',
message: `Package '${manifestId}' cannot be installed now: it declares the datasource '${heldDatasource.name}', `
+ `and whether another holder already owns that name could not be checked (${heldDatasource.reading} `
+ 'could not be read). A check that did not run is never taken for "no holder". Nothing was installed '
+ 'or written. Retry once that store answers.',
details: { datasource: heldDatasource.name, reading: heldDatasource.reading },
},
}, 503);
}
if (heldDatasource) {
return c.json({
success: false,
Expand Down Expand Up @@ -3601,37 +3622,54 @@ export class MarketplaceInstallLocalPlugin implements Plugin {

/**
* [#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.
* holder already owns, with that holder — or [#28143] whose holder could
* not be read, with what could not be. `undefined` when every name is free.
*
* The judge is the runtime's `datasourceNameHolder`, the one the
* rehydrate's `connectPackageDatasources` leaves a held name alone on: the
* host's `default`, a datasource item in the metadata registry not stamped
* with this package (one created in Setup, another package's), a name in
* the host's code-datasource set this package did not record, and a driver
* registered on the data engine with no metadata item. This package's own
* earlier registration is its own, so a reinstall passes.
*
* ⛔ Never `undefined` on a read fault: a holder that cannot be read is the
* `reading` answer, which the door refuses `503`. A runtime without the
* judge is that answer too.
*/
private findHeldDatasourceName = async (
ctx: PluginContext,
manifest: unknown,
manifestId: string,
): Promise<{ name: string; holder: string } | undefined> => {
): Promise<{ name: string; holder: string } | { name: string; reading: string; error: string } | undefined> => {
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;
if (names.includes('default')) return { name: 'default', holder: "the host's primary datasource" };
let metadata: { get?: (type: string, name: string) => Promise<unknown> } | 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;
let judge: typeof import('@objectstack/runtime')['datasourceNameHolder'] | undefined;
let loadError: unknown;
try {
const mod: any = await import('@objectstack/runtime');
if (typeof mod?.datasourceNameHolder === 'function') judge = mod.datasourceNameHolder;
} catch (err) {
loadError = err;
}
if (!judge) {
return {
name,
holder: typeof owner === 'string' && owner.length > 0
? `the package '${owner}'`
: item.origin === 'runtime' ? 'a datasource created in Setup' : 'a datasource',
name: names[0],
reading: "the runtime's datasource-name judge",
error: loadError !== undefined
? String((loadError as Error)?.message ?? loadError)
: '@objectstack/runtime has no datasourceNameHolder — upgrade it alongside @objectstack/cloud-connection',
};
}
for (const name of names) {
const holding = await judge(ctx, manifestId, name);
if (holding.status === 'held') return { name, holder: holding.holder };
if (holding.status === 'unreadable') {
return { name, reading: holding.reading, error: String((holding.cause as Error)?.message ?? holding.cause) };
}
}
return undefined;
};

Expand Down
Loading
Loading