From 366e39cb497d7da59003f9832bfb130c8634859e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 13:57:11 +0000 Subject: [PATCH 1/3] fix(service-package): refuse reads over a seam that never answered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `get()` returned `null` and `list()` returned `[]` over a storage seam that accepted the query and ran nothing. `InMemoryDriver.execute()` logs `Raw execution not supported in InMemory driver` and returns `null`; the local `normalizeRows` maps that to `[]`, which is also what a working driver returns for a SELECT that matched nothing. Both read paths reported that emptiness as a product answer callers act on, and `start()`'s own `sys_packages` rehydration skipped in silence because of it. Reads now establish the seam ANSWERED before reading emptiness as a fact: a result that is not a result set is refused with a declared ADR-0112 envelope (`SERVICE_UNAVAILABLE` / 503), and the skipped boot rehydration is logged at warn. A seam that answers with genuinely zero rows is unchanged. Third instance of one class (#10677 / PR #10788, #10789 / PR #10964): a seam that cannot answer is absent, not empty. The predicate is a local copy — `metadata-protocol` deliberately does not publish its own, and this package does not depend on it. Part of #10965 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx --- .changeset/olive-pandas-repeat.md | 9 + .../src/package-service.null-seam.test.ts | 170 +++++++++ .../services/service-package/src/index.ts | 175 +++++++++- .../service-package/src/null-seam.test.ts | 329 ++++++++++++++++++ 4 files changed, 682 insertions(+), 1 deletion(-) create mode 100644 .changeset/olive-pandas-repeat.md create mode 100644 packages/runtime/src/package-service.null-seam.test.ts create mode 100644 packages/services/service-package/src/null-seam.test.ts diff --git a/.changeset/olive-pandas-repeat.md b/.changeset/olive-pandas-repeat.md new file mode 100644 index 0000000000..b3c8fdccf6 --- /dev/null +++ b/.changeset/olive-pandas-repeat.md @@ -0,0 +1,9 @@ +--- +"@objectstack/service-package": patch +--- + +`get()` and `list()` no longer report "not installed" / "nothing installed" over a storage seam they never queried. + +A driver that cannot run raw SQL returns no result set rather than throwing (`InMemoryDriver.execute()` logs `Raw execution not supported in InMemory driver` and returns `null`), and the service's row flattener mapped that to `[]` — the same value a working driver returns when a package genuinely is not installed. Both read paths then handed that back as a product answer, and the boot-time `sys_packages` rehydration skipped silently because of it. + +Reads now establish that the seam ANSWERED before reading emptiness as a fact. A seam that returns no result set is refused with `SERVICE_UNAVAILABLE` / 503 and a message saying the answer is unknown; boot logs the skipped rehydration at `warn` instead of passing over it. A seam that answers with genuinely zero rows is unchanged: `get()` still returns `null` and `list()` still returns `[]`. diff --git a/packages/runtime/src/package-service.null-seam.test.ts b/packages/runtime/src/package-service.null-seam.test.ts new file mode 100644 index 0000000000..4b6c4f093b --- /dev/null +++ b/packages/runtime/src/package-service.null-seam.test.ts @@ -0,0 +1,170 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #10965 — the `service-package` seam guard, on a REAL booted driver. + * + * The card established the conflation by reading and named the boot path as + * unverified: *"Whether the DevPlugin zero-install stack (a real + * `InMemoryDriver`) reaches `get()`/`list()` at boot is unverified."* This file + * is that measurement, kept as a pin. + * + * It lives in `packages/runtime` because that is where the pieces already are: + * `@objectstack/driver-memory` is a dependency, `@objectstack/service-package` + * a devDependency, and this package's `vitest.config.ts` already aliases the + * latter to its `src/` for exactly this reason (the `#5047` rehydration pin + * next door). `service-package` itself depends on neither the engine nor a + * driver, so the same boot cannot be written inside it. + * + * ## Measured here, before the fix (framework `2866d5f97`) + * + * typeof objectql.execute -> 'function' (the shape test passes) + * objectql.registry.installPackage -> 'function' (so hydration RUNS) + * start() issued three statements, each returning null: + * CREATE TABLE IF NOT EXISTS sys_packages … -> null + * CREATE INDEX IF NOT EXISTS idx_packages_latest … -> null + * SELECT * FROM sys_packages … (the hydration list) -> null + * list() -> [] ⇒ "no packages are installed" + * get() -> null ⇒ "this package is not installed" + * + * ⭐ What this pins is NOT "the memory driver is refused". It is the separation + * the guard keys on: **a seam that cannot ANSWER is absent, not empty.** No + * driver is named by the implementation — the seam is judged by what it + * returns, and this file asserts the real driver falls on the "cannot answer" + * side of that line. + * + * The mongodb driver is deliberately NOT asserted anywhere here: it is not + * loaded by this suite, and an assertion about it would be a false pin. + */ + +import { describe, it, expect } from 'vitest'; +import { LiteKernel } from '@objectstack/core'; +import { ObjectQLPlugin } from '@objectstack/objectql'; +import { InMemoryDriver } from '@objectstack/driver-memory'; +import { + PackageServicePlugin, + PACKAGE_SEAM_UNREADABLE_MESSAGE, +} from '@objectstack/service-package'; + +import { DriverPlugin } from './driver-plugin.js'; + +interface BootedStack { + kernel: LiteKernel; + engine: any; + statements: string[]; + results: unknown[]; + warnLogs: string[]; + service: any; +} + +/** The zero-install stack: real engine, real InMemoryDriver, real plugin. */ +async function bootMemoryStack(): Promise { + const kernel = new LiteKernel({ logger: { level: 'error' } }); + kernel.use(new ObjectQLPlugin({})); + kernel.use(new DriverPlugin(new InMemoryDriver({ persistence: false }), 'memory')); + await kernel.bootstrap(); + + const engine = kernel.getService('objectql'); + + // Record what `start()` actually asks the seam, and what the seam answers. + const statements: string[] = []; + const results: unknown[] = []; + const realExecute = engine.execute.bind(engine); + engine.execute = async (q: any) => { + const result = await realExecute(q); + statements.push(String(q.sql).replace(/\s+/g, ' ').trim()); + results.push(result); + return result; + }; + + const warnLogs: string[] = []; + let service: any; + const services = new Map([['objectql', engine]]); + const ctx: any = { + logger: { + debug: () => {}, info: () => {}, + warn: (msg: string) => warnLogs.push(String(msg)), + error: () => {}, + }, + getService: (n: string) => services.get(n), + registerService: (n: string, s: unknown) => { + services.set(n, s); + if (n === 'package') service = s; + }, + }; + + await new PackageServicePlugin().start(ctx); + return { kernel, engine, statements, results, warnLogs, service }; +} + +describe('#10965 service-package over a real booted InMemoryDriver', () => { + it('the seam has the SHAPE of a seam and answers nothing — the two the guard separates', async () => { + const stack = await bootMemoryStack(); + try { + // The half that made the conflation invisible: `start()`'s own gate asks + // whether `execute` is callable, and on this driver it is. + expect(stack.engine.execute, 'the shape test still passes — that is the defect').toBeTypeOf('function'); + + // The half the guard now asks about: the driver accepts the statement + // and hands back no result set at all. + await expect(stack.engine.execute({ sql: 'select 1 as os_seam_probe' })).resolves.toBeNull(); + + // And the hydration gate it had to get past is genuinely open, so the + // boot loop below really does run. + expect(typeof stack.engine.registry?.installPackage).toBe('function'); + expect(typeof stack.engine.registry?.getPackage).toBe('function'); + } finally { + await stack.kernel.shutdown(); + } + }, 120_000); + + it('boot REACHES the list read — the card’s unverified half, measured', async () => { + const stack = await bootMemoryStack(); + try { + const listStatement = stack.statements.find((s) => /^SELECT \* FROM sys_packages WHERE \(id, created_at\) IN/.test(s)); + expect(listStatement, 'start() issues the latest-per-id SELECT that backs list()').toBeDefined(); + + // …and the driver answered every one of them with no result set, which + // is what `normalizeRows` used to flatten to "no packages installed". + expect(stack.results.length).toBeGreaterThan(0); + expect(stack.results.every((r) => r === null)).toBe(true); + } finally { + await stack.kernel.shutdown(); + } + }, 120_000); + + it('boot does not brick, and says the durable packages could not be read', async () => { + const stack = await bootMemoryStack(); + try { + const skip = stack.warnLogs.find((l) => /hydration from sys_packages SKIPPED/i.test(l)); + expect(skip, 'the silent skip is now audible').toBeDefined(); + expect(skip).toMatch(/not "no packages installed"/); + } finally { + await stack.kernel.shutdown(); + } + }, 120_000); + + it('get() and list() REFUSE with the ADR-0112 envelope instead of answering an absence', async () => { + const stack = await bootMemoryStack(); + try { + for (const call of [ + () => stack.service.get('com.acme.crm', 'latest'), + () => stack.service.get('com.acme.crm', '1.0.0'), + () => stack.service.list(), + ]) { + let thrown: any; + try { + await call(); + throw new Error('expected a refusal, but the call returned'); + } catch (e) { + thrown = e; + } + // code AND status — never status alone, and never a bare toThrow(). + expect(thrown.code).toBe('SERVICE_UNAVAILABLE'); + expect(thrown.status).toBe(503); + expect(thrown.message).toBe(PACKAGE_SEAM_UNREADABLE_MESSAGE); + } + } finally { + await stack.kernel.shutdown(); + } + }, 120_000); +}); diff --git a/packages/services/service-package/src/index.ts b/packages/services/service-package/src/index.ts index 676ba5bbb9..1314b679cf 100644 --- a/packages/services/service-package/src/index.ts +++ b/packages/services/service-package/src/index.ts @@ -173,6 +173,10 @@ function declaresHttpAnswer(error: unknown): boolean { * - Some drivers may return `{ rows: [...] }` wrappers in other contexts. * * This helper accepts any of those shapes and always returns an array. + * + * ⚠️ [#10965] It returns `[]` for EVERYTHING else too, and that is the whole + * defect this file's seam guard exists for — see {@link isResultSet}. Flatten + * with this only AFTER the result has been established as an answer. */ function normalizeRows(result: any): any[] { if (Array.isArray(result)) return result; @@ -180,6 +184,127 @@ function normalizeRows(result: any): any[] { return []; } +/** + * ── The seam that ACCEPTS a query but never ANSWERS one (#10965) ─────────── + * + * {@link normalizeRows} flattens the result-set shapes a raw SELECT comes back + * as. A seam can hand back one more thing, and it means something else + * entirely: `null` — "I did not run your query". `InMemoryDriver.execute()` is + * the measured case; it logs `Raw execution not supported in InMemory driver` + * and returns `null`. It neither throws nor is absent, so `start()`'s + * `objectql.execute` shape test is satisfied, and `normalizeRows(null)` is `[]` + * — which is also what a real driver returns for a SELECT that matched nothing. + * + * Both read paths in this service then reported that emptiness as a fact about + * the data, and unlike its two siblings (#10677 / PR #10788 for + * `os migrate duplicates`, #10789 / PR #10964 for `backfillSeedTenancy`) what + * they hand back is a PRODUCT ANSWER a caller acts on: + * + * - `get()` returned `null` ⇒ "this package is not installed". + * - `list()` returned `[]` ⇒ "no packages are installed". + * + * Measured on a real booted stack (LiteKernel + ObjectQLPlugin + + * `InMemoryDriver`), `start()`'s own rehydration reaches `list()`: the three + * statements it issues (CREATE TABLE, CREATE INDEX, the latest-per-id SELECT) + * each return `null`, `list()` answers `[]`, and the hydration loop iterates + * zero times — a SILENT skip, because its only log is behind `hydrated > 0`. + * Nothing downstream WRITES on that reading (the loop's only write is + * per-row; `installPackage`/`updatePackage`/`deletePackage` in + * `metadata-protocol` call `publish`/`delete` unconditionally, never gated on + * this read), so the blast radius is a silent hydration skip plus two false + * answers on the HTTP read doors — not a re-install. + * + * ⭐ **A seam that cannot ANSWER is absent, not empty.** The separation keys on + * the only thing that distinguishes them: a driver that answers returns a + * RESULT SET. Nothing here names a driver, so any host with the same no-op + * shape is covered without an allowlist to maintain. + */ + +/** + * Is `result` one of the result-set shapes a raw SELECT can come back as? + * + * The shapes {@link normalizeRows} accepts, asked as a yes/no: a bare row array + * (better-sqlite3 through knex, and the mysql2 `[rows, fields]` tuple, which is + * an array too) and `{ rows }` (pg). An empty result set in any of those + * spellings is still a result set, and still `true` — that is what keeps a + * legitimately-empty install answering "not installed" / "nothing installed", + * and it is the half of this change that stops it being a rename. + * + * This cannot lose a row {@link normalizeRows} would have found: every shape it + * rejects is one that flattener already maps to `[]`, so the only change is + * "refused as unreadable" replacing "reported as zero rows". + * + * ⛔ A LOCAL copy, deliberately. `metadata-protocol` does not publish its own + * from the package index, the CLI keeps a third for its probes, and + * `@objectstack/metadata-protocol` is not a dependency of this package at all. + * Unifying the three is its own decision, not a rider on this fix. + */ +function isResultSet(result: unknown): boolean { + if (Array.isArray(result)) return true; + if (typeof result === 'object' && result !== null) { + return Array.isArray((result as { rows?: unknown }).rows); + } + return false; +} + +/** + * [#10965] The caller-facing sentence a read over a non-answering seam gets. + * + * Like {@link PACKAGE_PUBLISH_DRIVER_FAULT_MESSAGE}, a CONSTANT that + * interpolates nothing: no driver text, no statement, no table name. It says + * the one thing a caller can act on — the answer is UNKNOWN, not "no". + * + * Exported so the doors and their pins assert the POSITIVE shape rather than + * the absence of the old empty answer (an absence assertion passes for any + * rewrite, including a worse one). + */ +export const PACKAGE_SEAM_UNREADABLE_MESSAGE = + 'The package registry could not be read: the storage seam accepted the query but returned no ' + + 'result set. Whether this package is installed is UNKNOWN — this is not an answer of "no".'; + +/** Brand for the refusal below, so only IT is re-thrown out of the read catches. */ +const SEAM_UNREADABLE = Symbol.for('objectstack.service-package.seam-unreadable'); + +/** + * [#10965] The refusal a read raises when the seam did not answer. + * + * ADR-0112 envelope: a `status` AND a `code`, both declared, so it leaves by + * the door's shared `errorFromThrown` mapping as the producer's own answer + * rather than a 500 catch-all. `SERVICE_UNAVAILABLE` / 503 is the standard + * catalog's own pairing and the spelling `metadata-protocol` already uses for + * exactly this condition (`metadataStoreUnavailableError`: the store is + * unreachable, so existence is unknown) — no new code is registered, and + * nothing in `packages/spec` is touched. + */ +function packageSeamUnreadableError(): Error { + const err = new Error(PACKAGE_SEAM_UNREADABLE_MESSAGE) as Error & { + code?: string; + status?: number; + [SEAM_UNREADABLE]?: true; + }; + err.code = 'SERVICE_UNAVAILABLE'; + err.status = 503; + err[SEAM_UNREADABLE] = true; + return err; +} + +/** + * [#10965] Is this the seam refusal above? + * + * ⛔ Deliberately NOT {@link declaresHttpAnswer}. That predicate asks the much + * broader "did this throw declare an envelope?", and widening the two READ + * catches to re-throw every such error would change how this service answers + * driver faults it has always swallowed — a behaviour change this card did not + * measure and does not need. Only the refusal this file raises escapes. + */ +function isSeamUnreadable(error: unknown): boolean { + return ( + typeof error === 'object' + && error !== null + && (error as Record)[SEAM_UNREADABLE] === true + ); +} + /** * Package Management Service Plugin * @@ -287,6 +412,15 @@ export class PackageServicePlugin implements Plugin { const args = version === 'latest' ? [packageId] : [packageId, version]; const result = await objectql.execute!({ sql, args }); + + // [#10965] Before reading emptiness as a fact, establish that there + // was an answer to read. A seam that did not run the SELECT hands + // back no result set, and `normalizeRows` maps that to `[]` — the + // same value a real driver returns when the package genuinely is not + // installed. Refusing here is what makes those two distinguishable; + // a result set with zero rows still falls through to `null` below. + if (!isResultSet(result)) throw packageSeamUnreadableError(); + const rows = normalizeRows(result); if (rows.length === 0) { @@ -304,6 +438,15 @@ export class PackageServicePlugin implements Plugin { updated_at: row.updated_at, }; } catch (error) { + // [#10965] The seam refusal is the ONE throw this catch must not + // swallow: swallowing it would restore the exact `null` the refusal + // exists to replace, and the caller would be back to reading "not + // installed" off a query that never ran. Everything else keeps the + // behaviour it has always had. + if (isSeamUnreadable(error)) { + logger.error(`Cannot answer whether package '${packageId}' is installed`, error as Error); + throw error; + } logger.error(`Failed to get package: ${packageId}`, error as Error); return null; } @@ -321,6 +464,11 @@ export class PackageServicePlugin implements Plugin { `, }); + // [#10965] Same separation as `get()`: a seam that never ran this + // SELECT must not be reported as "no packages are installed". An + // answering seam with zero rows still returns `[]` below. + if (!isResultSet(result)) throw packageSeamUnreadableError(); + return normalizeRows(result).map((row: any) => ({ id: row.id, version: row.version, @@ -331,6 +479,13 @@ export class PackageServicePlugin implements Plugin { updated_at: row.updated_at, })); } catch (error) { + // [#10965] As in `get()`: only the seam refusal escapes, because + // swallowing it would answer "nothing installed" over a driver this + // method never queried. + if (isSeamUnreadable(error)) { + logger.error('Cannot answer which packages are installed', error as Error); + throw error; + } logger.error('Failed to list packages', error as Error); return []; } @@ -433,7 +588,25 @@ export class PackageServicePlugin implements Plugin { } } } catch (error) { - logger.debug(`Package hydration from sys_packages skipped: ${(error as Error)?.message}`); + // [#10965] The measured consequence of the conflation, and the half that + // made it invisible. `list()` used to answer `[]` over a seam that never + // ran the SELECT, so this loop iterated zero times and said nothing — + // its only log sits behind `hydrated > 0`. A durable package was then + // absent from the registry for the whole process lifetime, with no line + // anywhere distinguishing that from an environment with no packages. + // + // Now `list()` refuses, and the refusal is reported at WARN naming what + // is unknown. Boot still continues: an unreadable seam must not brick the + // environment, exactly as a stale package does not (above). + if (isSeamUnreadable(error)) { + logger.warn( + 'Package hydration from sys_packages SKIPPED — the storage seam accepted the query but ' + + 'returned no result set, so durable packages could NOT be read. Any package persisted in ' + + 'sys_packages is absent from the registry for this process. This is not "no packages installed".', + ); + } else { + logger.debug(`Package hydration from sys_packages skipped: ${(error as Error)?.message}`); + } } } diff --git a/packages/services/service-package/src/null-seam.test.ts b/packages/services/service-package/src/null-seam.test.ts new file mode 100644 index 0000000000..196fab41e4 --- /dev/null +++ b/packages/services/service-package/src/null-seam.test.ts @@ -0,0 +1,329 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #10965 — `get()` / `list()` answered over a driver they never queried. + * + * ## What was measured before the fix + * + * The card established the conflation by READING, and said so; it named the + * boot path as unverified. It was booted for real before this change — a + * `LiteKernel` with the real `ObjectQLPlugin` and a real + * `InMemoryDriver({ persistence: false })`, with `PackageServicePlugin.start()` + * driven against that engine: + * + * typeof objectql.execute -> 'function' (the shape test passes) + * objectql.registry.installPackage -> 'function' (so hydration RUNS) + * start() issues 3 statements, each returning null: + * CREATE TABLE IF NOT EXISTS sys_packages … -> null + * CREATE INDEX IF NOT EXISTS idx_packages_latest … -> null + * SELECT * FROM sys_packages … (the hydration list) -> null + * list() -> [] ⇒ "no packages are installed" + * get() -> null ⇒ "this package is not installed" + * + * So the boot path DOES reach `list()` on the zero-install stack, and the + * hydration loop iterated zero times without a word — its only log sits behind + * `hydrated > 0`. What nothing did was WRITE on that reading: the loop's only + * write is per-row, and `metadata-protocol`'s `installPackage` / + * `updatePackage` / `deletePackage` call `publish` / `delete` unconditionally, + * never gated on this read. No re-install; a silent hydration skip plus two + * false answers on the HTTP read doors. + * + * ## What these tests pin — BOTH directions + * + * The separation is not "stopped returning null/[]". It is that a seam which + * cannot ANSWER is now distinguishable from one that answered NO ROWS, and the + * second case must keep working: an implementation that treated every empty + * result as a broken seam would score green on the refusal cases alone and + * break every legitimately-empty deployment. The `node:sqlite` cases below are + * that leg, on a real driver running the real statements from `index.ts`. + * + * ## What is deliberately NOT asserted here + * + * This service's LOCAL `normalizeRows` implements TWO of the three dialect + * shapes — a bare row array and `{ rows }`. It does not unwrap the mysql2 + * `[rows, fields]` tuple the way `metadata-protocol`'s copy does (that one + * tests `Array.isArray(result[0])`). So no populated-tuple result is asserted + * here: it would be a pin on behaviour this file does not have. What IS pinned + * is that a tuple-shaped result is still treated as an ANSWER, so the guard + * cannot misfire on a dialect it does not fully flatten. The gap itself is + * filed separately rather than fixed as a rider. + */ + +import { describe, it, expect } from 'vitest'; +import { DatabaseSync } from 'node:sqlite'; +import { + PackageServicePlugin, + PACKAGE_SEAM_UNREADABLE_MESSAGE, + type PackageService, +} from './index.js'; + +const MANIFEST = { id: 'com.acme.crm', name: 'CRM', version: '1.0.0', type: 'application' } as any; + +interface Booted { + svc: PackageService; + warnLogs: string[]; + errorLogs: string[]; +} + +/** Boot the real plugin over whatever `execute` the case supplies. */ +async function bootWith( + execute: (q: { sql: string; args?: unknown[] }) => Promise, + registry?: { installPackage: (m: unknown) => void; getPackage: (id: string) => unknown }, +): Promise { + const warnLogs: string[] = []; + const errorLogs: string[] = []; + const engine: any = { execute }; + if (registry) engine.registry = registry; + + let registered: PackageService | undefined; + const ctx: any = { + logger: { + debug: () => {}, + info: () => {}, + warn: (msg: string) => warnLogs.push(String(msg)), + error: (msg: string) => errorLogs.push(String(msg)), + }, + getService: (n: string) => (n === 'objectql' ? engine : undefined), + registerService: (_n: string, s: PackageService) => { registered = s; }, + }; + + const plugin = new PackageServicePlugin(); + await plugin.init(ctx); + await plugin.start(ctx); + return { svc: registered!, warnLogs, errorLogs }; +} + +/** + * A REAL SQLite database behind `objectql.execute` — an ANSWERING seam. + * + * `stmt.all()` returns a bare row array, dialect shape #1, and an empty SELECT + * returns `[]`. The `CREATE TABLE` / `CREATE INDEX` in `ensureTable` and the + * SELECTs in `get()` / `list()` are the statements from `index.ts`, run + * verbatim against a real engine. + */ +function realSqliteSeam() { + const db = new DatabaseSync(':memory:'); + return { + db, + async execute({ sql, args }: { sql: string; args?: unknown[] }) { + const stmt = db.prepare(sql); + return /^\s*select/i.test(sql) + ? stmt.all(...((args ?? []) as any[])) + : stmt.run(...((args ?? []) as any[])); + }, + }; +} + +/** The ADR-0112 envelope a seam refusal must declare: code AND status. */ +async function expectSeamRefusal(run: () => Promise): Promise { + let thrown: any; + try { + await run(); + throw new Error('expected a refusal, but the call returned'); + } catch (e) { + thrown = e; + } + // ⛔ Never `toThrow()` alone: an unfixed path throwing a bare Error would + // satisfy that and pin nothing. The envelope is the contract. + expect(thrown.code).toBe('SERVICE_UNAVAILABLE'); + expect(thrown.status).toBe(503); + expect(thrown.message).toBe(PACKAGE_SEAM_UNREADABLE_MESSAGE); + // The wording is itself the contract: it says the answer is UNKNOWN. + expect(thrown.message).toMatch(/UNKNOWN/); + expect(thrown.message).not.toMatch(/sys_packages|SELECT/i); +} + +// ─────────────────────────────────────────────────────────────────────────── +// 1. The seam that cannot answer — every spelling of "no result set" +// ─────────────────────────────────────────────────────────────────────────── + +describe('#10965 a non-answering seam is REFUSED, not reported as absence', () => { + // `null` first: the measured `InMemoryDriver.execute()` return. + const nonAnswers: Array<[string, unknown]> = [ + ['null — the measured InMemoryDriver return', null], + ['undefined', undefined], + ['a host that echoes the statement back', 'SELECT * FROM sys_packages'], + ['an object with no rows key', {}], + ['an object whose rows is not an array', { rows: 'not-an-array' }], + ['a number', 42], + ]; + + for (const [label, value] of nonAnswers) { + it(`get() refuses over ${label}`, async () => { + const { svc } = await bootWith(async () => value); + await expectSeamRefusal(() => svc.get('com.acme.crm', 'latest')); + }); + + it(`list() refuses over ${label}`, async () => { + const { svc } = await bootWith(async () => value); + await expectSeamRefusal(() => svc.list()); + }); + } + + it('get() refuses for a pinned version too, not only "latest"', async () => { + const { svc } = await bootWith(async () => null); + await expectSeamRefusal(() => svc.get('com.acme.crm', '1.0.0')); + }); + + it('the refusal is logged as an inability, not as a miss', async () => { + const { svc, errorLogs } = await bootWith(async () => null); + await svc.get('com.acme.crm', 'latest').catch(() => {}); + await svc.list().catch(() => {}); + expect(errorLogs.some((l) => /Cannot answer whether package/.test(l))).toBe(true); + expect(errorLogs.some((l) => /Cannot answer which packages/.test(l))).toBe(true); + // The old lines claimed a failed read of something that exists. + expect(errorLogs.some((l) => /^Failed to get package/.test(l))).toBe(false); + expect(errorLogs.some((l) => /^Failed to list packages/.test(l))).toBe(false); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// 2. STILL-EMPTY — the load-bearing leg, on a REAL driver +// ─────────────────────────────────────────────────────────────────────────── + +describe('#10965 a seam that ANSWERS with zero rows still means "not installed"', () => { + it('real SQLite, empty sys_packages: get() -> null and list() -> []', async () => { + const seam = realSqliteSeam(); + const { svc, warnLogs } = await bootWith(seam.execute); + + // The table exists and is genuinely empty — proven against the real db, + // so "the query never ran" cannot explain the answers below. + expect(seam.db.prepare('SELECT COUNT(*) AS n FROM sys_packages').get()).toEqual({ n: 0 }); + + await expect(svc.get('com.acme.crm', 'latest')).resolves.toBeNull(); + await expect(svc.get('com.acme.crm', '1.0.0')).resolves.toBeNull(); + await expect(svc.list()).resolves.toEqual([]); + // A legitimately-empty install says nothing about an unreadable seam. + expect(warnLogs.filter((l) => /SKIPPED/.test(l))).toEqual([]); + }); + + it('real SQLite, populated: the rows still come back', async () => { + const seam = realSqliteSeam(); + const { svc } = await bootWith(seam.execute); + + await svc.publish({ manifest: MANIFEST, metadata: { objects: [] } }); + + const got = await svc.get('com.acme.crm', 'latest'); + expect(got).toMatchObject({ id: 'com.acme.crm', version: '1.0.0' }); + expect(got!.manifest).toMatchObject({ id: 'com.acme.crm' }); + + const listed = await svc.list(); + expect(listed.map((p) => p.id)).toEqual(['com.acme.crm']); + }); + + it('a package that is genuinely absent is still `null`, beside one that is present', async () => { + const seam = realSqliteSeam(); + const { svc } = await bootWith(seam.execute); + await svc.publish({ manifest: MANIFEST, metadata: {} }); + + await expect(svc.get('com.acme.crm', 'latest')).resolves.not.toBeNull(); + await expect(svc.get('com.acme.nothing', 'latest')).resolves.toBeNull(); + await expect(svc.get('com.acme.crm', '9.9.9')).resolves.toBeNull(); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// 3. The dialect shapes this flattener implements stay answers +// ─────────────────────────────────────────────────────────────────────────── + +describe('#10965 the guard never turns a result set into a refusal', () => { + const row = { + id: 'com.acme.crm', + version: '1.0.0', + manifest: JSON.stringify(MANIFEST), + metadata: '{}', + hash: 'h', + created_at: 't', + updated_at: 't', + }; + + /** Only the SELECTs are shaped; DDL keeps the same spelling. */ + function seamReturning(select: unknown) { + return async ({ sql }: { sql: string }) => (/^\s*select/i.test(sql.trim()) ? select : []); + } + + it('bare row array (better-sqlite3 through knex) — rows come through', async () => { + const { svc } = await bootWith(seamReturning([row])); + await expect(svc.get('com.acme.crm', 'latest')).resolves.toMatchObject({ id: 'com.acme.crm' }); + await expect(svc.list()).resolves.toHaveLength(1); + }); + + it('bare EMPTY array — an answer of no rows, not a refusal', async () => { + const { svc } = await bootWith(seamReturning([])); + await expect(svc.get('com.acme.crm', 'latest')).resolves.toBeNull(); + await expect(svc.list()).resolves.toEqual([]); + }); + + it('`{ rows, rowCount }` (pg) — rows come through', async () => { + const { svc } = await bootWith(seamReturning({ rows: [row], rowCount: 1 })); + await expect(svc.get('com.acme.crm', 'latest')).resolves.toMatchObject({ id: 'com.acme.crm' }); + await expect(svc.list()).resolves.toHaveLength(1); + }); + + it('`{ rows: [], rowCount: 0 }` (pg) — an answer of no rows, not a refusal', async () => { + const { svc } = await bootWith(seamReturning({ rows: [], rowCount: 0 })); + await expect(svc.get('com.acme.crm', 'latest')).resolves.toBeNull(); + await expect(svc.list()).resolves.toEqual([]); + }); + + it('an `[rows, fields]`-shaped result is an ANSWER — the guard does not misfire', async () => { + // This local flattener does not UNWRAP the tuple (filed separately), so + // nothing is asserted about the rows it yields. What is asserted is the + // only thing this card owns: it is not mistaken for a seam that failed to + // answer, so no dialect gets a false 503. + const { svc } = await bootWith(seamReturning([[], []])); + await expect(svc.list()).resolves.toEqual([]); + await expect(svc.get('com.acme.crm', 'latest')).resolves.toBeNull(); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// 4. Boot hydration — the measured consequence, now audible +// ─────────────────────────────────────────────────────────────────────────── + +describe('#10965 boot hydration over a non-answering seam', () => { + function fakeRegistry() { + const installed: any[] = []; + return { + installed, + installPackage: (m: any) => { installed.push(m); }, + getPackage: (_id: string) => undefined, + }; + } + + it('does not brick boot, and SAYS the durable packages could not be read', async () => { + const registry = fakeRegistry(); + const { warnLogs } = await bootWith(async () => null, registry); + + // Boot completed (bootWith would have rejected otherwise) and installed + // nothing — but no longer silently. + expect(registry.installed).toEqual([]); + const skip = warnLogs.find((l) => /hydration from sys_packages SKIPPED/i.test(l)); + expect(skip).toBeDefined(); + expect(skip).toMatch(/no result set/); + expect(skip).toMatch(/not "no packages installed"/); + }); + + it('an ANSWERING seam with zero rows hydrates nothing and says nothing', async () => { + const registry = fakeRegistry(); + const seam = realSqliteSeam(); + const { warnLogs } = await bootWith(seam.execute, registry); + + expect(registry.installed).toEqual([]); + expect(warnLogs.filter((l) => /SKIPPED/.test(l))).toEqual([]); + }); + + it('an ANSWERING seam with a durable row still hydrates it', async () => { + const registry = fakeRegistry(); + const seam = realSqliteSeam(); + + // Seed the durable row through the service's own publish, then boot a + // second plugin instance over the same database — a restart. + const first = await bootWith(seam.execute); + await first.svc.publish({ manifest: MANIFEST, metadata: {} }); + + const { warnLogs } = await bootWith(seam.execute, registry); + expect(registry.installed.map((m: any) => m.id)).toEqual(['com.acme.crm']); + expect(warnLogs.filter((l) => /SKIPPED/.test(l))).toEqual([]); + }); +}); From b86504c7a4993e9afa93645e4c36ed9278668356 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 14:17:01 +0000 Subject: [PATCH 2/3] test(runtime): declare the objectql/package slot contracts instead of `any` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check:slot-lookup` flagged the new null-seam pin: `getService('objectql')` is a service-lookup erasure, and the baseline never grows (#4251). The slot's shape is now spelled out — the raw-SQL seam plus the registry half whose presence is what lets `start()`'s hydration loop run. Part of #10965 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx --- .../src/package-service.null-seam.test.ts | 50 +++++++++++++------ 1 file changed, 35 insertions(+), 15 deletions(-) diff --git a/packages/runtime/src/package-service.null-seam.test.ts b/packages/runtime/src/package-service.null-seam.test.ts index 4b6c4f093b..bda45b12f2 100644 --- a/packages/runtime/src/package-service.null-seam.test.ts +++ b/packages/runtime/src/package-service.null-seam.test.ts @@ -37,7 +37,7 @@ */ import { describe, it, expect } from 'vitest'; -import { LiteKernel } from '@objectstack/core'; +import { LiteKernel, type PluginContext } from '@objectstack/core'; import { ObjectQLPlugin } from '@objectstack/objectql'; import { InMemoryDriver } from '@objectstack/driver-memory'; import { @@ -47,13 +47,33 @@ import { import { DriverPlugin } from './driver-plugin.js'; +/** + * The `objectql` slot, as this file reads it: the raw-SQL seam under test, plus + * the registry half whose presence is what lets `start()`'s hydration loop run + * at all. Spelled out rather than `any` so the slot's shape is a declaration + * (#4251 / `check:slot-lookup`). + */ +interface ObjectQlSlot { + execute: (query: { sql: string; args?: unknown[] }) => Promise; + registry?: { + installPackage?: (manifest: unknown) => unknown; + getPackage?: (id: string) => unknown; + }; +} + +/** The two read methods this card is about, off the registered `package` slot. */ +interface PackageSlot { + get: (packageId: string, version?: string) => Promise; + list: () => Promise; +} + interface BootedStack { kernel: LiteKernel; - engine: any; + engine: ObjectQlSlot; statements: string[]; results: unknown[]; warnLogs: string[]; - service: any; + service: PackageSlot; } /** The zero-install stack: real engine, real InMemoryDriver, real plugin. */ @@ -63,13 +83,13 @@ async function bootMemoryStack(): Promise { kernel.use(new DriverPlugin(new InMemoryDriver({ persistence: false }), 'memory')); await kernel.bootstrap(); - const engine = kernel.getService('objectql'); + const engine = kernel.getService('objectql'); // Record what `start()` actually asks the seam, and what the seam answers. const statements: string[] = []; const results: unknown[] = []; const realExecute = engine.execute.bind(engine); - engine.execute = async (q: any) => { + engine.execute = async (q: { sql: string; args?: unknown[] }) => { const result = await realExecute(q); statements.push(String(q.sql).replace(/\s+/g, ' ').trim()); results.push(result); @@ -77,9 +97,9 @@ async function bootMemoryStack(): Promise { }; const warnLogs: string[] = []; - let service: any; + let service: PackageSlot | undefined; const services = new Map([['objectql', engine]]); - const ctx: any = { + const ctx = { logger: { debug: () => {}, info: () => {}, warn: (msg: string) => warnLogs.push(String(msg)), @@ -88,12 +108,12 @@ async function bootMemoryStack(): Promise { getService: (n: string) => services.get(n), registerService: (n: string, s: unknown) => { services.set(n, s); - if (n === 'package') service = s; + if (n === 'package') service = s as PackageSlot; }, - }; + } as unknown as PluginContext; await new PackageServicePlugin().start(ctx); - return { kernel, engine, statements, results, warnLogs, service }; + return { kernel, engine, statements, results, warnLogs, service: service! }; } describe('#10965 service-package over a real booted InMemoryDriver', () => { @@ -151,17 +171,17 @@ describe('#10965 service-package over a real booted InMemoryDriver', () => { () => stack.service.get('com.acme.crm', '1.0.0'), () => stack.service.list(), ]) { - let thrown: any; + let thrown: (Error & { code?: string; status?: number }) | undefined; try { await call(); throw new Error('expected a refusal, but the call returned'); } catch (e) { - thrown = e; + thrown = e as Error & { code?: string; status?: number }; } // code AND status — never status alone, and never a bare toThrow(). - expect(thrown.code).toBe('SERVICE_UNAVAILABLE'); - expect(thrown.status).toBe(503); - expect(thrown.message).toBe(PACKAGE_SEAM_UNREADABLE_MESSAGE); + expect(thrown!.code).toBe('SERVICE_UNAVAILABLE'); + expect(thrown!.status).toBe(503); + expect(thrown!.message).toBe(PACKAGE_SEAM_UNREADABLE_MESSAGE); } } finally { await stack.kernel.shutdown(); From 5820d092226f5d7dce1818f7c35ef00616fd70b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 15:44:20 +0000 Subject: [PATCH 3/3] test(runtime): migrate the null-seam pin off the frozen driver-memory package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check:driver-memory-census` refused this file as a THIRD consumer of a package whose investment is frozen (#5499), arriving after #5704 migrated the test backends and #6664 replaced the prose census with a ledger. Disposition taken: MIGRATE, not ledger. The two ruled-permanent consumers are kept because nothing can stand in for them — one needs the schemaless arm of a divergence pin, the other a driver whose `supports = {}` hands autonumber seeding back to the engine. This file needs neither: it needs a seam whose `execute()` returns without answering, which is one return value rather than a capability profile, and `protocol-recorded-by-null.test.ts` already models exactly that with a local `makeStubDriver` — the convention #5704/#5784 established so a grep for the driver lands on real consumers only. The kernel, the ObjectQL engine and registry, and PackageServicePlugin.start() all stay real; only the seam's non-answer is doubled. What the double does NOT model is stated in the file: it is not evidence about driver-memory, whose null-return stays pinned on a real boot by the CLI sibling (#10677). Ledger untouched; ruled set still 2. Part of #10965 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx --- .../src/package-service.null-seam.test.ts | 108 ++++++++++++++---- 1 file changed, 88 insertions(+), 20 deletions(-) diff --git a/packages/runtime/src/package-service.null-seam.test.ts b/packages/runtime/src/package-service.null-seam.test.ts index bda45b12f2..3eb181508f 100644 --- a/packages/runtime/src/package-service.null-seam.test.ts +++ b/packages/runtime/src/package-service.null-seam.test.ts @@ -9,13 +9,43 @@ * is that measurement, kept as a pin. * * It lives in `packages/runtime` because that is where the pieces already are: - * `@objectstack/driver-memory` is a dependency, `@objectstack/service-package` - * a devDependency, and this package's `vitest.config.ts` already aliases the - * latter to its `src/` for exactly this reason (the `#5047` rehydration pin - * next door). `service-package` itself depends on neither the engine nor a - * driver, so the same boot cannot be written inside it. + * `@objectstack/service-package` is a devDependency and this package's + * `vitest.config.ts` already aliases it to `src/` for exactly this reason (the + * `#5047` rehydration pin next door). `service-package` itself depends on + * neither the engine nor a driver, so the same boot cannot be written inside it. * - * ## Measured here, before the fix (framework `2866d5f97`) + * ## Why the driver here is a LOCAL DOUBLE, not `@objectstack/driver-memory` + * + * The first version of this file booted the real `InMemoryDriver`. That made it + * a THIRD consumer of a package whose investment is frozen (#5499), arriving + * after #5704 migrated the test backends and #6664 replaced the prose census + * with a ledger — and `check:driver-memory-census` refused it, correctly. + * + * The disposition taken was MIGRATE, not ledger. The two consumers a maintainer + * ruled permanent are both kept because nothing can stand in for them: one needs + * the SCHEMALESS arm of a divergence pin, the other needs a driver whose + * `supports = {}` hands autonumber seeding back to the engine. Neither shape is + * what this file needs. What it needs is a seam whose `execute()` RETURNS + * without answering — one return value, not a capability profile — and + * `packages/objectql/src/protocol-recorded-by-null.test.ts` already models + * exactly that with a local `makeStubDriver` carrying `async execute() { return + * null; }`. `makeStubDriver` is itself the convention #5704/#5784 established so + * that grepping for the driver lands on real consumers only. + * + * ⚠️ **What the double does NOT model.** It is not evidence about + * `@objectstack/driver-memory`'s behaviour. That the real driver logs + * `Raw execution not supported in InMemory driver` and returns `null` was + * measured on a real boot while triaging #10965, and it stays pinned on a real + * booted driver by `packages/cli/src/commands/migrate/duplicates.null-seam.test.ts` + * (#10677), which reaches it through the datasource factory rather than by + * importing it. Nothing about that fact is re-asserted here, and this file would + * not notice if that driver changed. What it models is the SHAPE — a seam that + * accepts a statement and returns no result set — which is the only property the + * guard under test keys on, and which the implementation deliberately judges by + * return value rather than by driver identity. + * + * ## Measured on a real booted `InMemoryDriver`, before the fix (framework + * `2866d5f97`) — the triage measurement this file is the regression pin for * * typeof objectql.execute -> 'function' (the shape test passes) * objectql.registry.installPackage -> 'function' (so hydration RUNS) @@ -26,20 +56,19 @@ * list() -> [] ⇒ "no packages are installed" * get() -> null ⇒ "this package is not installed" * - * ⭐ What this pins is NOT "the memory driver is refused". It is the separation + * ⭐ What this pins is NOT "some named driver is refused". It is the separation * the guard keys on: **a seam that cannot ANSWER is absent, not empty.** No * driver is named by the implementation — the seam is judged by what it - * returns, and this file asserts the real driver falls on the "cannot answer" - * side of that line. + * returns — so the double below is judged by the same rule any real host is. * - * The mongodb driver is deliberately NOT asserted anywhere here: it is not - * loaded by this suite, and an assertion about it would be a false pin. + * No real driver is asserted anywhere in this file, by construction: every + * driver-specific claim would be a false pin over a backend this suite does not + * load. */ import { describe, it, expect } from 'vitest'; import { LiteKernel, type PluginContext } from '@objectstack/core'; import { ObjectQLPlugin } from '@objectstack/objectql'; -import { InMemoryDriver } from '@objectstack/driver-memory'; import { PackageServicePlugin, PACKAGE_SEAM_UNREADABLE_MESSAGE, @@ -67,6 +96,40 @@ interface PackageSlot { list: () => Promise; } +/** + * A seam that ACCEPTS a statement and returns no result set. + * + * The whole double, and deliberately the smallest thing that can be one: the + * engine's `execute` delegates straight to `driver.execute(...)` after checking + * only that the method EXISTS (`packages/objectql/src/engine.ts:11687`), which + * is the half of the defect that made the conflation invisible — the shape test + * passes and the answer never arrives. + * + * `execute` returning `null` is the measured `InMemoryDriver` return, modelled + * here the way `protocol-recorded-by-null.test.ts`'s own `makeStubDriver` models + * it. The rest of the surface exists so `kernel.bootstrap()` completes; nothing + * below `execute` is asserted on. + */ +function makeNonAnsweringDriver() { + const driver = { + name: 'stub-non-answering', + version: '0.0.0', + supports: {}, + async connect() {}, + async disconnect() {}, + async checkHealth() { return true; }, + /** ⭐ The one behaviour under test: it RETURNS, and it does not answer. */ + async execute() { return null; }, + async find() { return []; }, + async findOne() { return null; }, + async count() { return 0; }, + async create(_object: string, data: Record) { return data; }, + async update(_object: string, _id: unknown, data: Record) { return data; }, + async delete() { return true; }, + }; + return driver; +} + interface BootedStack { kernel: LiteKernel; engine: ObjectQlSlot; @@ -76,11 +139,16 @@ interface BootedStack { service: PackageSlot; } -/** The zero-install stack: real engine, real InMemoryDriver, real plugin. */ -async function bootMemoryStack(): Promise { +/** + * The zero-install stack: REAL kernel, REAL ObjectQL engine and registry, REAL + * `PackageServicePlugin.start()` — with the non-answering seam supplied by the + * double above. Everything the guard is judged against is real except the one + * property being modelled. + */ +async function bootNonAnsweringStack(): Promise { const kernel = new LiteKernel({ logger: { level: 'error' } }); kernel.use(new ObjectQLPlugin({})); - kernel.use(new DriverPlugin(new InMemoryDriver({ persistence: false }), 'memory')); + kernel.use(new DriverPlugin(makeNonAnsweringDriver())); await kernel.bootstrap(); const engine = kernel.getService('objectql'); @@ -116,9 +184,9 @@ async function bootMemoryStack(): Promise { return { kernel, engine, statements, results, warnLogs, service: service! }; } -describe('#10965 service-package over a real booted InMemoryDriver', () => { +describe('#10965 service-package over a booted engine whose seam never answers', () => { it('the seam has the SHAPE of a seam and answers nothing — the two the guard separates', async () => { - const stack = await bootMemoryStack(); + const stack = await bootNonAnsweringStack(); try { // The half that made the conflation invisible: `start()`'s own gate asks // whether `execute` is callable, and on this driver it is. @@ -138,7 +206,7 @@ describe('#10965 service-package over a real booted InMemoryDriver', () => { }, 120_000); it('boot REACHES the list read — the card’s unverified half, measured', async () => { - const stack = await bootMemoryStack(); + const stack = await bootNonAnsweringStack(); try { const listStatement = stack.statements.find((s) => /^SELECT \* FROM sys_packages WHERE \(id, created_at\) IN/.test(s)); expect(listStatement, 'start() issues the latest-per-id SELECT that backs list()').toBeDefined(); @@ -153,7 +221,7 @@ describe('#10965 service-package over a real booted InMemoryDriver', () => { }, 120_000); it('boot does not brick, and says the durable packages could not be read', async () => { - const stack = await bootMemoryStack(); + const stack = await bootNonAnsweringStack(); try { const skip = stack.warnLogs.find((l) => /hydration from sys_packages SKIPPED/i.test(l)); expect(skip, 'the silent skip is now audible').toBeDefined(); @@ -164,7 +232,7 @@ describe('#10965 service-package over a real booted InMemoryDriver', () => { }, 120_000); it('get() and list() REFUSE with the ADR-0112 envelope instead of answering an absence', async () => { - const stack = await bootMemoryStack(); + const stack = await bootNonAnsweringStack(); try { for (const call of [ () => stack.service.get('com.acme.crm', 'latest'),