From 95dd29736357fb489ca20e6bdfd1f07c945bebf2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 06:36:30 +0000 Subject: [PATCH 1/5] fix(cli): refuse a production serve with no storage before any plugin starts A production host-config `serve` with no recognized OS_DATABASE_URL and no driver plugin declared nothing at the storage step and said nothing; the boot then died in AuthPlugin.start with a driver error, after an objectql ERROR naming a datasource nobody declared. The storage step now records that it resolved nothing, and one CLI plugin registered last judges at the end of Phase 1: a driver present is silent; none with the auth service under the 'single' posture refuses before any start(), naming the remedies; none otherwise prints one line and boots on. Claude-Session: https://claude.ai/code/session_01Q9PZ5Pzqcus4woVvTZwWNU Co-authored-by: Claude --- packages/cli/src/commands/serve.ts | 68 ++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index e06e62ff1b2..aa698529876 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -3089,6 +3089,13 @@ export default class Serve extends Command { // discards as already-registered. // [#15006] Same seam, same reason — see the ObjectQL gate above. The // driver-provider duck-typing moved into it with the read it guards. + // + // [#23573] Whether this step declared NOTHING (production, no URL, no + // driver plugin by name). Only recorded here: this step cannot tell a + // boot that fails anyway from one whose own plugin registers a driver + // the name test misses, so the verdict waits for the no-storage boot + // guard at the end of Phase 1 (registered last, before runtime.start()). + let productionResolvedNoStorage = false; if (shouldAutoRegisterStorageDriver(config, plugins)) { const databaseUrl = process.env.OS_DATABASE_URL; const driverType = resolveDriverType(process.env.OS_DATABASE_DRIVER, databaseUrl); @@ -3136,6 +3143,8 @@ export default class Serve extends Command { // every other kind AND the dev-default `:memory:` store). The ONE // provision, shared with the standalone boot below. await provisionTelemetry(resolution.sqliteFilePath); + } else { + productionResolvedNoStorage = true; } } catch (e: any) { // "declared ≠ enforced" guard (#3276-class): a selection the CLI @@ -5089,6 +5098,65 @@ export default class Serve extends Command { // published banner surface on every boot to report a thing an operator // cannot act on. + // ── No-storage boot guard (#23573) ───────────────────────────── + // Step 2 recorded that this production boot declared no storage. Whether + // that is fatal is only known once every plugin's init() has run, since a + // stack plugin the name test missed may register a driver there. This is + // the LAST plugin registered, so its init() runs after every other init() + // and before any start() (resolvePluginOrder keeps insertion order; no + // plugin names this one as a dependency). It reads the one map ObjectQL + // adopts `driver.*` services from at its start(), plus any driver the + // engine already holds. + // • a driver is there ⇒ nothing to say. + // • none, and the auth service runs under the 'single' posture ⇒ refuse + // before any start(). AuthPlugin.start() creates the Default + // Organization there, which needs a driver, and no driver can arrive + // before it: AppPlugin orders ObjectQL and auth ahead of every stack + // plugin, so a driver registered in any start() comes too late. + // Without this, the boot died there with a driver error, after an + // objectql ERROR naming a datasource nobody declared. + // • none otherwise ⇒ ONE line, and the boot goes on. + // Worded as of the end of init, because a start() (a module's + // onEnable) may still register a driver and some such stacks boot. + // ⛔ Not `trackPlugin`ed, for the announcer's reason above. + if (productionResolvedNoStorage) { + const noStorageRemedy = + 'Set OS_DATABASE_URL (e.g. file:./data/app.db or a postgres:// URL), mount a storage driver ' + + "in the stack's plugins (e.g. new DriverPlugin(driver) from @objectstack/runtime), " + + 'or run with --dev (an in-memory SQLite store, for development).'; + await kernel.use({ + name: 'com.objectstack.cli.no-storage-boot-guard', + version: '1.0.0', + init: async (ctx: any) => { + const services: Map = ctx.getServices(); + const engine = services.get('objectql'); + const hasDriver = [...services.keys()].some((name) => name.startsWith('driver.')) + || (typeof engine?.getDefaultDriverName === 'function' + && engine.getDefaultDriverName() !== undefined); + if (hasDriver) return; + if (services.has('auth') && services.get('tenancy')?.posture === 'single') { + restoreOutput(); + console.error(chalk.red( + '\n ✖ This production serve has no storage: no recognized OS_DATABASE_URL declared a\n' + + ' default datasource, and no plugin registered a storage driver by the end of plugin\n' + + " init. The auth service runs under the 'single' tenancy posture, and its start()\n" + + ' creates the Default Organization, which needs a driver.\n\n' + + ` ${noStorageRemedy}\n`, + )); + throw new Error( + 'Refusing to boot: no storage for a production serve whose auth service needs a driver ' + + 'when it starts. ' + noStorageRemedy, + ); + } + console.warn(chalk.yellow( + ' ⚠ No storage: no recognized OS_DATABASE_URL, and no plugin registered a storage driver by ' + + 'the end of plugin init, so objects have no store unless a plugin registers one when it ' + + `starts. ${noStorageRemedy}`, + )); + }, + } as any); + } + // Boot the runtime await runtime.start(); From dd4015030310d463c82c4830ea8c14c5457180fb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 06:41:45 +0000 Subject: [PATCH 2/5] test(cli): pin the no-storage boot guard over five production boots Claude-Session: https://claude.ai/code/session_01Q9PZ5Pzqcus4woVvTZwWNU Co-authored-by: Claude --- packages/cli/src/commands/serve.ts | 19 +- .../serve-no-storage-boot-guard.e2e.test.ts | 295 ++++++++++++++++++ 2 files changed, 306 insertions(+), 8 deletions(-) create mode 100644 packages/cli/test/serve-no-storage-boot-guard.e2e.test.ts diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index aa698529876..c016f7b009c 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -5120,10 +5120,12 @@ export default class Serve extends Command { // onEnable) may still register a driver and some such stacks boot. // ⛔ Not `trackPlugin`ed, for the announcer's reason above. if (productionResolvedNoStorage) { - const noStorageRemedy = - 'Set OS_DATABASE_URL (e.g. file:./data/app.db or a postgres:// URL), mount a storage driver ' - + "in the stack's plugins (e.g. new DriverPlugin(driver) from @objectstack/runtime), " - + 'or run with --dev (an in-memory SQLite store, for development).'; + // ONE list, rendered as the refusal's bullets and as the line's sentence. + const noStorageRemedies = [ + 'set OS_DATABASE_URL (e.g. file:./data/app.db, or a postgres:// or mysql:// URL)', + "mount a storage driver in the stack's plugins (e.g. new DriverPlugin(driver) from @objectstack/runtime)", + 'run with --dev (an in-memory SQLite store, for development)', + ]; await kernel.use({ name: 'com.objectstack.cli.no-storage-boot-guard', version: '1.0.0', @@ -5141,17 +5143,18 @@ export default class Serve extends Command { + ' default datasource, and no plugin registered a storage driver by the end of plugin\n' + " init. The auth service runs under the 'single' tenancy posture, and its start()\n" + ' creates the Default Organization, which needs a driver.\n\n' - + ` ${noStorageRemedy}\n`, + + ' Fix one of:\n' + + noStorageRemedies.map((remedy) => ` • ${remedy}\n`).join(''), )); throw new Error( - 'Refusing to boot: no storage for a production serve whose auth service needs a driver ' - + 'when it starts. ' + noStorageRemedy, + 'Refusing to boot: this production serve has no storage, and its auth service needs a ' + + 'driver when it starts. Set OS_DATABASE_URL, mount a storage driver plugin, or run with --dev.', ); } console.warn(chalk.yellow( ' ⚠ No storage: no recognized OS_DATABASE_URL, and no plugin registered a storage driver by ' + 'the end of plugin init, so objects have no store unless a plugin registers one when it ' - + `starts. ${noStorageRemedy}`, + + `starts. Fix: ${noStorageRemedies[0]}; ${noStorageRemedies[1]}; or ${noStorageRemedies[2]}.`, )); }, } as any); diff --git a/packages/cli/test/serve-no-storage-boot-guard.e2e.test.ts b/packages/cli/test/serve-no-storage-boot-guard.e2e.test.ts new file mode 100644 index 00000000000..90689af3d9b --- /dev/null +++ b/packages/cli/test/serve-no-storage-boot-guard.e2e.test.ts @@ -0,0 +1,295 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// test-budget: 50 — spawns the real producer once per single-cause fixture: five production boots of the built CLI (refusal, line, a driver-providing plugin, OS_DATABASE_URL, --dev), each one fixture differing in the one input under test; 36.0 s measured locally on a shared container, no CI reading yet + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { spawn, type ChildProcessByStdio } from 'node:child_process'; +import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import type { Readable } from 'node:stream'; +import { fileURLToPath } from 'node:url'; +import { defineStackSourceFromLiteral, linkSpec } from './helpers/define-stack-fixture.js'; +import { + childEnv, + E2E_SECRET_KEY, + portContentionError, + requireBuiltCli, + reservePort, + RUN_JS_RESOLVES_FROM_DIST, +} from './helpers/serve-process.js'; + +/** + * A production `os serve` of a HOST config that resolves no storage (#23573). + * + * ## The defect + * + * With no `OS_DATABASE_URL`, no `--dev` and no plugin named like a driver, the + * storage step declares nothing and used to say nothing. The boot went on and + * died in `AuthPlugin.start()` with "No driver available for object + * 'sys_organization'", after an objectql ERROR whose remedy names a datasource + * the operator never declared. Nothing printed named the real remedies. + * + * ## What is pinned, one boot per cause + * + * The verdict is drawn by a CLI plugin registered LAST, at the end of kernel + * Phase 1 — after every `init()`, before any `start()`: + * + * 1. no driver + the auth service under the `single` posture ⇒ REFUSED, exit 1, + * naming the remedies, before any start() (the fixture's own plugin + * initialized and never started), and neither downstream line prints; + * 2. no driver + no auth composed (`--no-server --preset minimal`) ⇒ ONE line + * before any start(), and the boot still reaches its banner; + * 3. a stack plugin whose NAME no driver test matches registers a `driver.*` + * service in init() ⇒ silent, and the boot reaches its banner (this shape + * booted before the guard and must keep booting); + * 4. `OS_DATABASE_URL` set ⇒ silent (control); + * 5. `--dev` ⇒ silent (control). + * + * ## Why `bin/run.js` with `NODE_ENV` unset + * + * The branch only exists in production posture; `bin/run-dev.js` (what + * `runServe()` spawns) pins development, where the storage step always + * resolves a SQLite default and the guard is never registered. See + * `serve-ready-degraded-boot.e2e.test.ts` for the same reasoning. + */ + +const HERE = resolve(fileURLToPath(import.meta.url), '..'); +/** `bin/run.js` — the SHIPPED entrypoint. See the header for why this one. */ +const CLI = resolve(HERE, '../bin/run.js'); + +/** The complete banner — keyed on its LAST line, so the whole block is on the stream. */ +const READY_BANNER_TAIL = /Press Ctrl\+C to stop/; + +/** What a refused boot says first, and what its exit line says. */ +const REFUSAL_HEAD = 'This production serve has no storage'; +const REFUSAL_EXIT = 'Refusing to boot: this production serve has no storage'; +/** The one line a boot with no auth gets. */ +const LINE_HEAD = '⚠ No storage:'; + +/** The two downstream lines a refused boot must never reach (both are Phase 2). */ +const DOWNSTREAM_DRIVER_ERROR = 'No driver available for object'; +const DOWNSTREAM_HYDRATION_ERROR = 'sys_metadata could NOT be read'; + +/** The fixture plugin's own markers: proof of where Phase 1 ended. */ +const MARKER_INIT = '[fx-23573] host-plugin init'; +const MARKER_START = '[fx-23573] host-plugin start'; + +const AUTH_SECRET = 'os-23573-auth-secret-not-a-real-credential'; + +const OBJECT = `{ name: 'fx_task', label: 'Task', pluralLabel: 'Tasks', fields: { title: { type: 'text', label: 'Title' } } }`; +const MANIFEST = `{ id: 'com.example.fx23573', namespace: 'fx', version: '1.0.0', type: 'app', name: 'Fx' }`; +/** + * An instantiated plugin, which is what makes this a HOST config. Its name + * matches no driver test, and it writes one marker per lifecycle phase. + */ +const HOST_PLUGIN = `{ + name: 'com.example.fx23573.host-plugin', + version: '1.0.0', + init: async () => { process.stderr.write(${JSON.stringify(`${MARKER_INIT}\n`)}); }, + start: async () => { process.stderr.write(${JSON.stringify(`${MARKER_START}\n`)}); }, +}`; + +/** What `spawn(…, { stdio: ['ignore', 'pipe', 'pipe'] })` returns — no `stdin`. */ +type BootChild = ChildProcessByStdio; + +const children: BootChild[] = []; +let root: string; +/** One object, the host plugin, nothing that names storage. */ +let plainDir: string; +/** The same, plus a plugin that registers a SQL driver as `driver.*` in init(). */ +let driverDir: string; + +/** + * Make `@objectstack/driver-sql` resolvable from `dir`: a symlink to the + * package this one already depends on, found through `node_modules` (a package + * specifier, never a walk out of this package), as `linkSpec` does for spec. + */ +function linkDriverSql(dir: string): void { + // Its `exports` does not publish `./package.json`, so climb from the entry to + // the first directory holding a manifest: the package root. + let pkgRoot = dirname(createRequire(import.meta.url).resolve('@objectstack/driver-sql')); + while (!existsSync(join(pkgRoot, 'package.json'))) { + if (dirname(pkgRoot) === pkgRoot) throw new Error('@objectstack/driver-sql resolved to no package root'); + pkgRoot = dirname(pkgRoot); + } + const scope = join(dir, 'node_modules', '@objectstack'); + mkdirSync(scope, { recursive: true }); + symlinkSync(pkgRoot, join(scope, 'driver-sql'), 'dir'); +} + +function writeFixture(dir: string, header: string, plugins: string): void { + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'fx-23573', private: true, type: 'module' })); + const source = defineStackSourceFromLiteral(`{ manifest: ${MANIFEST}, objects: [${OBJECT}], plugins: [${plugins}] }`); + writeFileSync(join(dir, 'objectstack.config.ts'), `${header}${source}`); + linkSpec(dir); +} + +interface Boot { + output: string; + exitCode: number | null; + ready: boolean; +} + +/** + * Spawn `os serve` in production posture. Resolves on the COMPLETE banner (the + * child is then stopped) or on the child's exit, whichever comes first. + * Extra `env` entries override the base; `undefined` unsets. + */ +async function boot(cwd: string, env: Record, args: string[] = []): Promise { + const port = reservePort(); + const child = spawn(process.execPath, [CLI, 'serve', 'objectstack.config.ts', '--port', String(port), ...args], { + cwd, + stdio: ['ignore', 'pipe', 'pipe'], + // `childEnv`, never a bare `...process.env` — see its header (commit 1ddda1d00). + env: childEnv({ + NO_COLOR: '1', + OS_LOG_LEVEL: '', + OS_DISABLE_CONSOLE: '1', + OS_CLOUD_URL: 'off', + OS_HOME: join(cwd, '.os-home'), + OS_SECRET_KEY: E2E_SECRET_KEY, + // ⭐ The storage inputs, all truly unset unless a leg names one. + OS_DATABASE_URL: undefined, + DATABASE_URL: undefined, + OS_DATABASE_DRIVER: undefined, + OS_AUTH_SECRET: undefined, + OS_TENANCY_POSTURE: undefined, + OS_MULTI_ORG_ENABLED: undefined, + // Truly unset: production posture, and oclif resolves the command from `dist/`. + NODE_ENV: undefined, + ...env, + }), + }) as BootChild; + children.push(child); + + let output = ''; + return new Promise((done, fail) => { + let settled = false; + const timer = setTimeout(() => { + settled = true; + child.kill('SIGKILL'); + fail(new Error(`serve neither printed its complete banner nor exited\n--- output ---\n${output}`)); + }, 150_000); + const onData = (d: unknown) => { + output += String(d); + if (!settled && READY_BANNER_TAIL.test(output)) { + settled = true; + clearTimeout(timer); + child.kill('SIGTERM'); + done({ output, exitCode: null, ready: true }); + } + }; + child.stdout.on('data', onData); + child.stderr.on('data', onData); + child.on('exit', (code) => { + if (settled) return; + settled = true; + clearTimeout(timer); + const contention = portContentionError(output, 'os serve (bin/run.js, NODE_ENV unset ⇒ production)', port); + if (contention) fail(contention); + else done({ output, exitCode: code, ready: false }); + }); + }); +} + +beforeAll(() => { + requireBuiltCli(RUN_JS_RESOLVES_FROM_DIST); + root = mkdtempSync(join(tmpdir(), 'os-serve-no-storage-')); + plainDir = join(root, 'plain'); + writeFixture(plainDir, '', HOST_PLUGIN); + driverDir = join(root, 'driver'); + writeFixture( + driverDir, + "import { SqlDriver } from '@objectstack/driver-sql';\n" + + "const drv = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true });\n", + // `com.acme.store` contains no `driver` and its constructor is `Object`: + // the storage step's name test cannot see it, so the step declares nothing. + `${HOST_PLUGIN}, { name: 'com.acme.store', version: '1.0.0', init: async (ctx) => { ctx.registerService('driver.' + drv.name, drv); }, start: async () => {} }`, + ); + linkDriverSql(driverDir); +}); + +afterAll(() => { + for (const child of children) { + try { child.kill('SIGTERM'); } catch { /* already gone */ } + } + if (root) rmSync(root, { recursive: true, force: true }); +}); + +describe('os serve — a production host config with no storage (#23573)', () => { + it( + 'refuses before any plugin starts when the auth service needs a driver, naming the remedies', + async () => { + const { output, exitCode, ready } = await boot(plainDir, { OS_AUTH_SECRET: AUTH_SECRET }); + const seen = `\n--- output ---\n${output}`; + + expect(ready, `a no-storage boot with auth reached its banner${seen}`).toBe(false); + expect(exitCode, `the refusal did not exit 1${seen}`).toBe(1); + expect(output, `no refusal was printed${seen}`).toContain(REFUSAL_HEAD); + expect(output, `the exit line is not the refusal${seen}`).toContain(REFUSAL_EXIT); + for (const remedy of ['OS_DATABASE_URL', 'new DriverPlugin(driver)', '--dev']) { + expect(output, `the refusal does not name ${remedy}${seen}`).toContain(remedy); + } + + // ⭐ Before any start(): the fixture's plugin initialized and never started. + expect(output, `the fixture plugin never initialized${seen}`).toContain(MARKER_INIT); + expect(output, `a plugin started before the refusal${seen}`).not.toContain(MARKER_START); + // ⭐ Neither downstream line, both of which belong to Phase 2. + expect(output, `the old driver error survived${seen}`).not.toContain(DOWNSTREAM_DRIVER_ERROR); + expect(output, `objectql's hydration ERROR survived${seen}`).not.toContain(DOWNSTREAM_HYDRATION_ERROR); + }, + 240_000, + ); + + it( + 'prints one line before any plugin starts and keeps booting when no auth is composed', + async () => { + const { output, ready } = await boot(plainDir, {}, ['--no-server', '--preset', 'minimal']); + const seen = `\n--- output ---\n${output}`; + + expect(ready, `a boot that booted before the guard no longer reaches its banner${seen}`).toBe(true); + expect(output, `this boot was refused${seen}`).not.toContain(REFUSAL_HEAD); + expect(output.split(LINE_HEAD).length - 1, `not exactly one no-storage line${seen}`).toBe(1); + const line = output.slice(output.indexOf(LINE_HEAD)).split('\n')[0]; + for (const remedy of ['OS_DATABASE_URL', 'new DriverPlugin(driver)', '--dev']) { + expect(line, `the line does not name ${remedy}${seen}`).toContain(remedy); + } + // ⭐ Before any start(): the fixture's plugin did start in this boot, after the line. + expect(output, `the fixture plugin never started${seen}`).toContain(MARKER_START); + expect(output.indexOf(LINE_HEAD), `the line came after a plugin started${seen}`) + .toBeLessThan(output.indexOf(MARKER_START)); + }, + 240_000, + ); + + it( + 'stays silent and boots when a plugin no driver test names registers a driver in init()', + async () => { + const { output, ready } = await boot(driverDir, { OS_AUTH_SECRET: AUTH_SECRET }); + const seen = `\n--- output ---\n${output}`; + + expect(ready, `a boot with a driver-providing plugin no longer reaches its banner${seen}`).toBe(true); + expect(output, `a boot with a driver was refused${seen}`).not.toContain(REFUSAL_HEAD); + expect(output, `a boot with a driver got the no-storage line${seen}`).not.toContain(LINE_HEAD); + }, + 240_000, + ); + + it.each([ + ['OS_DATABASE_URL set', { OS_AUTH_SECRET: AUTH_SECRET, OS_DATABASE_URL: 'file:./ctl.db' }, [] as string[]], + ['--dev', { OS_AUTH_SECRET: AUTH_SECRET }, ['--dev']], + ])( + 'control: %s boots and says nothing about storage', + async (_label, env, args) => { + const { output, ready } = await boot(plainDir, env, args); + const seen = `\n--- output ---\n${output}`; + + expect(ready, `the control did not reach its banner${seen}`).toBe(true); + expect(output, `the control was refused${seen}`).not.toContain(REFUSAL_HEAD); + expect(output, `the control got the no-storage line${seen}`).not.toContain(LINE_HEAD); + }, + 240_000, + ); +}); From 43ed9b62363c6aca03eaf0ab662580fb5ee70d8f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 06:44:16 +0000 Subject: [PATCH 3/5] test(cli): run the no-storage pin in the queue tier and declare its budget Claude-Session: https://claude.ai/code/session_01Q9PZ5Pzqcus4woVvTZwWNU Co-authored-by: Claude --- ...ts => serve-no-storage-boot-guard.integration.test.ts} | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) rename packages/cli/test/{serve-no-storage-boot-guard.e2e.test.ts => serve-no-storage-boot-guard.integration.test.ts} (96%) diff --git a/packages/cli/test/serve-no-storage-boot-guard.e2e.test.ts b/packages/cli/test/serve-no-storage-boot-guard.integration.test.ts similarity index 96% rename from packages/cli/test/serve-no-storage-boot-guard.e2e.test.ts rename to packages/cli/test/serve-no-storage-boot-guard.integration.test.ts index 90689af3d9b..518fcf3460d 100644 --- a/packages/cli/test/serve-no-storage-boot-guard.e2e.test.ts +++ b/packages/cli/test/serve-no-storage-boot-guard.integration.test.ts @@ -1,5 +1,5 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -// test-budget: 50 — spawns the real producer once per single-cause fixture: five production boots of the built CLI (refusal, line, a driver-providing plugin, OS_DATABASE_URL, --dev), each one fixture differing in the one input under test; 36.0 s measured locally on a shared container, no CI reading yet +// test-budget: 30 — spawns the real producer once per single-cause fixture: five production boots of the built CLI (refusal, line, a driver-providing plugin, OS_DATABASE_URL, --dev), each fixture differing only in the input under test; 23.6 s measured locally on a shared container x 1.25, no CI reading yet import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { spawn, type ChildProcessByStdio } from 'node:child_process'; @@ -52,6 +52,12 @@ import { * `runServe()` spawns) pins development, where the storage step always * resolves a SQLite default and the guard is never registered. See * `serve-ready-degraded-boot.e2e.test.ts` for the same reasoning. + * + * ## Why `.integration`, not `.e2e` + * + * The `.e2e` name moves a file to the nightly run on `main` (`OS_TEST_TIERS`, + * `scripts/nightly-tiers.mjs`). This pin guards a boot ORDER that any serve.ts + * edit can break, so it rides the queue run, where such an edit lands. */ const HERE = resolve(fileURLToPath(import.meta.url), '..'); From 029a2c8f8c31326bf08f97f6244e0b41d8664258 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 06:46:58 +0000 Subject: [PATCH 4/5] chore(cli): changeset for the no-storage boot guard; budget from the slower reading Claude-Session: https://claude.ai/code/session_01Q9PZ5Pzqcus4woVvTZwWNU Co-authored-by: Claude --- .changeset/23573-serve-no-storage-loud.md | 16 ++++++++++++++++ ...rve-no-storage-boot-guard.integration.test.ts | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 .changeset/23573-serve-no-storage-loud.md diff --git a/.changeset/23573-serve-no-storage-loud.md b/.changeset/23573-serve-no-storage-loud.md new file mode 100644 index 00000000000..e33724c4372 --- /dev/null +++ b/.changeset/23573-serve-no-storage-loud.md @@ -0,0 +1,16 @@ +--- +'@objectstack/cli': patch +--- + +fix(cli): a production `os serve` of a host config that resolves no storage says so before any plugin starts, and refuses to boot when its auth service needs a driver + +Clause-②: no + +A host config (one whose `plugins` hold plugin instances) served in production with no recognized `OS_DATABASE_URL`, no `--dev` and no plugin named like a driver declares no default datasource. Nothing said so. + +- **Before.** The boot went on and died when `AuthPlugin` started: `✗ Plugin com.objectstack.auth failed to start - rollback complete: [ObjectQL] No driver available for object 'sys_organization'`. Before that line, objectql logged `ERROR sys_metadata could NOT be read at boot`, whose fix read `check the datasource behind sys_metadata — connection, credentials, and that the table exists`, for a datasource the operator never declared. No line named `OS_DATABASE_URL` or `--dev`. `examples/app-showcase` under a bare `os serve` took this path. +- **After, when the auth service runs under the `single` tenancy posture (the default) and no plugin registered a driver by the end of plugin init.** The boot is refused before any plugin starts, exit 1, with `✖ This production serve has no storage: …` and three fixes: set `OS_DATABASE_URL` (for example `file:./data/app.db`, or a `postgres://` or `mysql://` URL), mount a storage driver in the stack's `plugins` (for example `new DriverPlugin(driver)` from `@objectstack/runtime`), or run with `--dev`. Neither of the two lines above prints. Every configuration refused here failed to boot before this change. +- **After, when no driver is registered and the auth service does not need one at start** (no auth composed, as under `--no-server --preset minimal`, or a walled tenancy posture): one `⚠ No storage: …` line names the same fixes, and the boot goes on as before. A plugin that registers a driver in its `start()`, such as a module's `onEnable`, still works. +- **When a plugin registers a driver during init**, under any name, nothing is printed and nothing changes. + +`os start`, a standalone (non-host) config, `--dev` and any recognized `OS_DATABASE_URL` take other paths and are unchanged. diff --git a/packages/cli/test/serve-no-storage-boot-guard.integration.test.ts b/packages/cli/test/serve-no-storage-boot-guard.integration.test.ts index 518fcf3460d..a7162fa0115 100644 --- a/packages/cli/test/serve-no-storage-boot-guard.integration.test.ts +++ b/packages/cli/test/serve-no-storage-boot-guard.integration.test.ts @@ -1,5 +1,5 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -// test-budget: 30 — spawns the real producer once per single-cause fixture: five production boots of the built CLI (refusal, line, a driver-providing plugin, OS_DATABASE_URL, --dev), each fixture differing only in the input under test; 23.6 s measured locally on a shared container x 1.25, no CI reading yet +// test-budget: 40 — spawns the real producer once per single-cause fixture: five production boots of the built CLI (refusal, line, a driver-providing plugin, OS_DATABASE_URL, --dev), each fixture differing only in the input under test; 24.4 s, the slower of two local runs on a shared container, x 1.25, no CI reading yet import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { spawn, type ChildProcessByStdio } from 'node:child_process'; From 02ed90d3de6b3798deed39261302b1ce84a547de Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 06:57:24 +0000 Subject: [PATCH 5/5] test(cli): the no-storage pin joins the built-entrypoint population by name check:cli-test-child-env pins the set of files that spawn bin/run.js, so the pin joins it in the same change, and its controls keep each child's env a literal at the spawn call the gate reads NODE_ENV from. Claude-Session: https://claude.ai/code/session_01Q9PZ5Pzqcus4woVvTZwWNU Co-authored-by: Claude --- ...-no-storage-boot-guard.integration.test.ts | 40 +++++++++++-------- scripts/check-cli-test-child-env.mjs | 8 +++- 2 files changed, 31 insertions(+), 17 deletions(-) diff --git a/packages/cli/test/serve-no-storage-boot-guard.integration.test.ts b/packages/cli/test/serve-no-storage-boot-guard.integration.test.ts index a7162fa0115..fec09a286c7 100644 --- a/packages/cli/test/serve-no-storage-boot-guard.integration.test.ts +++ b/packages/cli/test/serve-no-storage-boot-guard.integration.test.ts @@ -224,6 +224,14 @@ afterAll(() => { if (root) rmSync(root, { recursive: true, force: true }); }); +/** A boot the guard must leave alone: it reaches its banner, refused nothing, printed no line. */ +function expectSilentBoot(output: string, ready: boolean): void { + const seen = `\n--- output ---\n${output}`; + expect(ready, `the boot did not reach its banner${seen}`).toBe(true); + expect(output, `the boot was refused${seen}`).not.toContain(REFUSAL_HEAD); + expect(output, `the boot got the no-storage line${seen}`).not.toContain(LINE_HEAD); +} + describe('os serve — a production host config with no storage (#23573)', () => { it( 'refuses before any plugin starts when the auth service needs a driver, naming the remedies', @@ -274,27 +282,27 @@ describe('os serve — a production host config with no storage (#23573)', () => 'stays silent and boots when a plugin no driver test names registers a driver in init()', async () => { const { output, ready } = await boot(driverDir, { OS_AUTH_SECRET: AUTH_SECRET }); - const seen = `\n--- output ---\n${output}`; - - expect(ready, `a boot with a driver-providing plugin no longer reaches its banner${seen}`).toBe(true); - expect(output, `a boot with a driver was refused${seen}`).not.toContain(REFUSAL_HEAD); - expect(output, `a boot with a driver got the no-storage line${seen}`).not.toContain(LINE_HEAD); + expectSilentBoot(output, ready); }, 240_000, ); - it.each([ - ['OS_DATABASE_URL set', { OS_AUTH_SECRET: AUTH_SECRET, OS_DATABASE_URL: 'file:./ctl.db' }, [] as string[]], - ['--dev', { OS_AUTH_SECRET: AUTH_SECRET }, ['--dev']], - ])( - 'control: %s boots and says nothing about storage', - async (_label, env, args) => { - const { output, ready } = await boot(plainDir, env, args); - const seen = `\n--- output ---\n${output}`; + // Two `it`s, not an `it.each`: each spawn's env stays a literal at its call, + // which is what `check:cli-test-child-env` reads the child's NODE_ENV from. + it( + 'control: OS_DATABASE_URL set boots and says nothing about storage', + async () => { + const { output, ready } = await boot(plainDir, { OS_AUTH_SECRET: AUTH_SECRET, OS_DATABASE_URL: 'file:./ctl.db' }); + expectSilentBoot(output, ready); + }, + 240_000, + ); - expect(ready, `the control did not reach its banner${seen}`).toBe(true); - expect(output, `the control was refused${seen}`).not.toContain(REFUSAL_HEAD); - expect(output, `the control got the no-storage line${seen}`).not.toContain(LINE_HEAD); + it( + 'control: --dev boots and says nothing about storage', + async () => { + const { output, ready } = await boot(plainDir, { OS_AUTH_SECRET: AUTH_SECRET }, ['--dev']); + expectSilentBoot(output, ready); }, 240_000, ); diff --git a/scripts/check-cli-test-child-env.mjs b/scripts/check-cli-test-child-env.mjs index 9a3bf3c78bf..9672de77f62 100644 --- a/scripts/check-cli-test-child-env.mjs +++ b/scripts/check-cli-test-child-env.mjs @@ -2635,7 +2635,7 @@ export function selfTest() { .filter((abs) => builtEntrypointSpawns(abs, readFileSync(abs, 'utf8')).spawns > 0) .map((abs) => relative(REPO_ROOT, abs).split(sep).join('/')) .sort(); - t('the built-entrypoint population is exactly the seven files that spawn bin/run.js', + t('the built-entrypoint population is exactly the eight files that spawn bin/run.js', JSON.stringify(builtFiles) === JSON.stringify([ // [#21733] Joined the population deliberately: the card's acceptance is // a plain `os dev` restart on the BUILT packages, and its production leg @@ -2653,6 +2653,12 @@ export function selfTest() { 'packages/cli/test/published-entry-node-env-source-reroute.test.ts', 'packages/cli/test/serve-mcp-capability-collision.e2e.test.ts', 'packages/cli/test/serve-mcp-stdio-answers.e2e.test.ts', + // [#23573] Joined the population deliberately: the no-storage boot + // guard is registered only when a PRODUCTION storage step declared + // nothing, and `bin/run-dev.js` sets `NODE_ENV=development` before argv + // is parsed, where the step always declares a SQLite default. Every + // spawn hands its child `NODE_ENV: undefined`. + 'packages/cli/test/serve-no-storage-boot-guard.integration.test.ts', 'packages/cli/test/serve-node-env-production-default.e2e.test.ts', // [#16630] Joined the population deliberately: the degraded-boot the // ready line has to report is only reachable in PRODUCTION posture, and