|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * #10965 — the `service-package` seam guard, on a REAL booted driver. |
| 5 | + * |
| 6 | + * The card established the conflation by reading and named the boot path as |
| 7 | + * unverified: *"Whether the DevPlugin zero-install stack (a real |
| 8 | + * `InMemoryDriver`) reaches `get()`/`list()` at boot is unverified."* This file |
| 9 | + * is that measurement, kept as a pin. |
| 10 | + * |
| 11 | + * It lives in `packages/runtime` because that is where the pieces already are: |
| 12 | + * `@objectstack/service-package` is a devDependency and this package's |
| 13 | + * `vitest.config.ts` already aliases it to `src/` for exactly this reason (the |
| 14 | + * `#5047` rehydration pin next door). `service-package` itself depends on |
| 15 | + * neither the engine nor a driver, so the same boot cannot be written inside it. |
| 16 | + * |
| 17 | + * ## Why the driver here is a LOCAL DOUBLE, not `@objectstack/driver-memory` |
| 18 | + * |
| 19 | + * The first version of this file booted the real `InMemoryDriver`. That made it |
| 20 | + * a THIRD consumer of a package whose investment is frozen (#5499), arriving |
| 21 | + * after #5704 migrated the test backends and #6664 replaced the prose census |
| 22 | + * with a ledger — and `check:driver-memory-census` refused it, correctly. |
| 23 | + * |
| 24 | + * The disposition taken was MIGRATE, not ledger. The two consumers a maintainer |
| 25 | + * ruled permanent are both kept because nothing can stand in for them: one needs |
| 26 | + * the SCHEMALESS arm of a divergence pin, the other needs a driver whose |
| 27 | + * `supports = {}` hands autonumber seeding back to the engine. Neither shape is |
| 28 | + * what this file needs. What it needs is a seam whose `execute()` RETURNS |
| 29 | + * without answering — one return value, not a capability profile — and |
| 30 | + * `packages/objectql/src/protocol-recorded-by-null.test.ts` already models |
| 31 | + * exactly that with a local `makeStubDriver` carrying `async execute() { return |
| 32 | + * null; }`. `makeStubDriver` is itself the convention #5704/#5784 established so |
| 33 | + * that grepping for the driver lands on real consumers only. |
| 34 | + * |
| 35 | + * ⚠️ **What the double does NOT model.** It is not evidence about |
| 36 | + * `@objectstack/driver-memory`'s behaviour. That the real driver logs |
| 37 | + * `Raw execution not supported in InMemory driver` and returns `null` was |
| 38 | + * measured on a real boot while triaging #10965, and it stays pinned on a real |
| 39 | + * booted driver by `packages/cli/src/commands/migrate/duplicates.null-seam.test.ts` |
| 40 | + * (#10677), which reaches it through the datasource factory rather than by |
| 41 | + * importing it. Nothing about that fact is re-asserted here, and this file would |
| 42 | + * not notice if that driver changed. What it models is the SHAPE — a seam that |
| 43 | + * accepts a statement and returns no result set — which is the only property the |
| 44 | + * guard under test keys on, and which the implementation deliberately judges by |
| 45 | + * return value rather than by driver identity. |
| 46 | + * |
| 47 | + * ## Measured on a real booted `InMemoryDriver`, before the fix (framework |
| 48 | + * `2866d5f97`) — the triage measurement this file is the regression pin for |
| 49 | + * |
| 50 | + * typeof objectql.execute -> 'function' (the shape test passes) |
| 51 | + * objectql.registry.installPackage -> 'function' (so hydration RUNS) |
| 52 | + * start() issued three statements, each returning null: |
| 53 | + * CREATE TABLE IF NOT EXISTS sys_packages … -> null |
| 54 | + * CREATE INDEX IF NOT EXISTS idx_packages_latest … -> null |
| 55 | + * SELECT * FROM sys_packages … (the hydration list) -> null |
| 56 | + * list() -> [] ⇒ "no packages are installed" |
| 57 | + * get() -> null ⇒ "this package is not installed" |
| 58 | + * |
| 59 | + * ⭐ What this pins is NOT "some named driver is refused". It is the separation |
| 60 | + * the guard keys on: **a seam that cannot ANSWER is absent, not empty.** No |
| 61 | + * driver is named by the implementation — the seam is judged by what it |
| 62 | + * returns — so the double below is judged by the same rule any real host is. |
| 63 | + * |
| 64 | + * No real driver is asserted anywhere in this file, by construction: every |
| 65 | + * driver-specific claim would be a false pin over a backend this suite does not |
| 66 | + * load. |
| 67 | + */ |
| 68 | + |
| 69 | +import { describe, it, expect } from 'vitest'; |
| 70 | +import { LiteKernel, type PluginContext } from '@objectstack/core'; |
| 71 | +import { ObjectQLPlugin } from '@objectstack/objectql'; |
| 72 | +import { |
| 73 | + PackageServicePlugin, |
| 74 | + PACKAGE_SEAM_UNREADABLE_MESSAGE, |
| 75 | +} from '@objectstack/service-package'; |
| 76 | + |
| 77 | +import { DriverPlugin } from './driver-plugin.js'; |
| 78 | + |
| 79 | +/** |
| 80 | + * The `objectql` slot, as this file reads it: the raw-SQL seam under test, plus |
| 81 | + * the registry half whose presence is what lets `start()`'s hydration loop run |
| 82 | + * at all. Spelled out rather than `any` so the slot's shape is a declaration |
| 83 | + * (#4251 / `check:slot-lookup`). |
| 84 | + */ |
| 85 | +interface ObjectQlSlot { |
| 86 | + execute: (query: { sql: string; args?: unknown[] }) => Promise<unknown>; |
| 87 | + registry?: { |
| 88 | + installPackage?: (manifest: unknown) => unknown; |
| 89 | + getPackage?: (id: string) => unknown; |
| 90 | + }; |
| 91 | +} |
| 92 | + |
| 93 | +/** The two read methods this card is about, off the registered `package` slot. */ |
| 94 | +interface PackageSlot { |
| 95 | + get: (packageId: string, version?: string) => Promise<unknown>; |
| 96 | + list: () => Promise<unknown[]>; |
| 97 | +} |
| 98 | + |
| 99 | +/** |
| 100 | + * A seam that ACCEPTS a statement and returns no result set. |
| 101 | + * |
| 102 | + * The whole double, and deliberately the smallest thing that can be one: the |
| 103 | + * engine's `execute` delegates straight to `driver.execute(...)` after checking |
| 104 | + * only that the method EXISTS (`packages/objectql/src/engine.ts:11687`), which |
| 105 | + * is the half of the defect that made the conflation invisible — the shape test |
| 106 | + * passes and the answer never arrives. |
| 107 | + * |
| 108 | + * `execute` returning `null` is the measured `InMemoryDriver` return, modelled |
| 109 | + * here the way `protocol-recorded-by-null.test.ts`'s own `makeStubDriver` models |
| 110 | + * it. The rest of the surface exists so `kernel.bootstrap()` completes; nothing |
| 111 | + * below `execute` is asserted on. |
| 112 | + */ |
| 113 | +function makeNonAnsweringDriver() { |
| 114 | + const driver = { |
| 115 | + name: 'stub-non-answering', |
| 116 | + version: '0.0.0', |
| 117 | + supports: {}, |
| 118 | + async connect() {}, |
| 119 | + async disconnect() {}, |
| 120 | + async checkHealth() { return true; }, |
| 121 | + /** ⭐ The one behaviour under test: it RETURNS, and it does not answer. */ |
| 122 | + async execute() { return null; }, |
| 123 | + async find() { return []; }, |
| 124 | + async findOne() { return null; }, |
| 125 | + async count() { return 0; }, |
| 126 | + async create(_object: string, data: Record<string, unknown>) { return data; }, |
| 127 | + async update(_object: string, _id: unknown, data: Record<string, unknown>) { return data; }, |
| 128 | + async delete() { return true; }, |
| 129 | + }; |
| 130 | + return driver; |
| 131 | +} |
| 132 | + |
| 133 | +interface BootedStack { |
| 134 | + kernel: LiteKernel; |
| 135 | + engine: ObjectQlSlot; |
| 136 | + statements: string[]; |
| 137 | + results: unknown[]; |
| 138 | + warnLogs: string[]; |
| 139 | + service: PackageSlot; |
| 140 | +} |
| 141 | + |
| 142 | +/** |
| 143 | + * The zero-install stack: REAL kernel, REAL ObjectQL engine and registry, REAL |
| 144 | + * `PackageServicePlugin.start()` — with the non-answering seam supplied by the |
| 145 | + * double above. Everything the guard is judged against is real except the one |
| 146 | + * property being modelled. |
| 147 | + */ |
| 148 | +async function bootNonAnsweringStack(): Promise<BootedStack> { |
| 149 | + const kernel = new LiteKernel({ logger: { level: 'error' } }); |
| 150 | + kernel.use(new ObjectQLPlugin({})); |
| 151 | + kernel.use(new DriverPlugin(makeNonAnsweringDriver())); |
| 152 | + await kernel.bootstrap(); |
| 153 | + |
| 154 | + const engine = kernel.getService<ObjectQlSlot>('objectql'); |
| 155 | + |
| 156 | + // Record what `start()` actually asks the seam, and what the seam answers. |
| 157 | + const statements: string[] = []; |
| 158 | + const results: unknown[] = []; |
| 159 | + const realExecute = engine.execute.bind(engine); |
| 160 | + engine.execute = async (q: { sql: string; args?: unknown[] }) => { |
| 161 | + const result = await realExecute(q); |
| 162 | + statements.push(String(q.sql).replace(/\s+/g, ' ').trim()); |
| 163 | + results.push(result); |
| 164 | + return result; |
| 165 | + }; |
| 166 | + |
| 167 | + const warnLogs: string[] = []; |
| 168 | + let service: PackageSlot | undefined; |
| 169 | + const services = new Map<string, unknown>([['objectql', engine]]); |
| 170 | + const ctx = { |
| 171 | + logger: { |
| 172 | + debug: () => {}, info: () => {}, |
| 173 | + warn: (msg: string) => warnLogs.push(String(msg)), |
| 174 | + error: () => {}, |
| 175 | + }, |
| 176 | + getService: (n: string) => services.get(n), |
| 177 | + registerService: (n: string, s: unknown) => { |
| 178 | + services.set(n, s); |
| 179 | + if (n === 'package') service = s as PackageSlot; |
| 180 | + }, |
| 181 | + } as unknown as PluginContext; |
| 182 | + |
| 183 | + await new PackageServicePlugin().start(ctx); |
| 184 | + return { kernel, engine, statements, results, warnLogs, service: service! }; |
| 185 | +} |
| 186 | + |
| 187 | +describe('#10965 service-package over a booted engine whose seam never answers', () => { |
| 188 | + it('the seam has the SHAPE of a seam and answers nothing — the two the guard separates', async () => { |
| 189 | + const stack = await bootNonAnsweringStack(); |
| 190 | + try { |
| 191 | + // The half that made the conflation invisible: `start()`'s own gate asks |
| 192 | + // whether `execute` is callable, and on this driver it is. |
| 193 | + expect(stack.engine.execute, 'the shape test still passes — that is the defect').toBeTypeOf('function'); |
| 194 | + |
| 195 | + // The half the guard now asks about: the driver accepts the statement |
| 196 | + // and hands back no result set at all. |
| 197 | + await expect(stack.engine.execute({ sql: 'select 1 as os_seam_probe' })).resolves.toBeNull(); |
| 198 | + |
| 199 | + // And the hydration gate it had to get past is genuinely open, so the |
| 200 | + // boot loop below really does run. |
| 201 | + expect(typeof stack.engine.registry?.installPackage).toBe('function'); |
| 202 | + expect(typeof stack.engine.registry?.getPackage).toBe('function'); |
| 203 | + } finally { |
| 204 | + await stack.kernel.shutdown(); |
| 205 | + } |
| 206 | + }, 120_000); |
| 207 | + |
| 208 | + it('boot REACHES the list read — the card’s unverified half, measured', async () => { |
| 209 | + const stack = await bootNonAnsweringStack(); |
| 210 | + try { |
| 211 | + const listStatement = stack.statements.find((s) => /^SELECT \* FROM sys_packages WHERE \(id, created_at\) IN/.test(s)); |
| 212 | + expect(listStatement, 'start() issues the latest-per-id SELECT that backs list()').toBeDefined(); |
| 213 | + |
| 214 | + // …and the driver answered every one of them with no result set, which |
| 215 | + // is what `normalizeRows` used to flatten to "no packages installed". |
| 216 | + expect(stack.results.length).toBeGreaterThan(0); |
| 217 | + expect(stack.results.every((r) => r === null)).toBe(true); |
| 218 | + } finally { |
| 219 | + await stack.kernel.shutdown(); |
| 220 | + } |
| 221 | + }, 120_000); |
| 222 | + |
| 223 | + it('boot does not brick, and says the durable packages could not be read', async () => { |
| 224 | + const stack = await bootNonAnsweringStack(); |
| 225 | + try { |
| 226 | + const skip = stack.warnLogs.find((l) => /hydration from sys_packages SKIPPED/i.test(l)); |
| 227 | + expect(skip, 'the silent skip is now audible').toBeDefined(); |
| 228 | + expect(skip).toMatch(/not "no packages installed"/); |
| 229 | + } finally { |
| 230 | + await stack.kernel.shutdown(); |
| 231 | + } |
| 232 | + }, 120_000); |
| 233 | + |
| 234 | + it('get() and list() REFUSE with the ADR-0112 envelope instead of answering an absence', async () => { |
| 235 | + const stack = await bootNonAnsweringStack(); |
| 236 | + try { |
| 237 | + for (const call of [ |
| 238 | + () => stack.service.get('com.acme.crm', 'latest'), |
| 239 | + () => stack.service.get('com.acme.crm', '1.0.0'), |
| 240 | + () => stack.service.list(), |
| 241 | + ]) { |
| 242 | + let thrown: (Error & { code?: string; status?: number }) | undefined; |
| 243 | + try { |
| 244 | + await call(); |
| 245 | + throw new Error('expected a refusal, but the call returned'); |
| 246 | + } catch (e) { |
| 247 | + thrown = e as Error & { code?: string; status?: number }; |
| 248 | + } |
| 249 | + // code AND status — never status alone, and never a bare toThrow(). |
| 250 | + expect(thrown!.code).toBe('SERVICE_UNAVAILABLE'); |
| 251 | + expect(thrown!.status).toBe(503); |
| 252 | + expect(thrown!.message).toBe(PACKAGE_SEAM_UNREADABLE_MESSAGE); |
| 253 | + } |
| 254 | + } finally { |
| 255 | + await stack.kernel.shutdown(); |
| 256 | + } |
| 257 | + }, 120_000); |
| 258 | +}); |
0 commit comments