Skip to content

Commit edac218

Browse files
fix(cli): a production serve with no storage says so before any plugin starts, and refuses when its auth service needs a driver (#23631)
Fixes #23573 Clause-②: no ## What this changes A production `os serve` of a host config (one whose `plugins` hold plugin instances) with no recognized `OS_DATABASE_URL`, no `--dev` and no plugin named like a driver declared no default datasource at the storage step, and said nothing. The boot then died in `AuthPlugin.start()` with `No driver available for object 'sys_organization'`, after an objectql ERROR whose fix sends the operator to "check the datasource behind sys_metadata", which nobody declared. This is the seat's option A (claim `6185741820`): a guard at the end of kernel Phase 1. - **`packages/cli/src/commands/serve.ts`, storage step (step 2).** Only records `productionResolvedNoStorage` when `resolveStorageDefinition` answers `null`. It refuses nothing. - **`packages/cli/src/commands/serve.ts`, composition tail.** One CLI plugin, `com.objectstack.cli.no-storage-boot-guard`, registered only when that flag is set, and registered LAST: after `com.objectstack.cli.seed-settlement-announcer`, immediately before `runtime.start()`. Its `init()` reads `ctx.getServices()` once, the same map ObjectQL adopts `driver.*` services from at its start, plus the engine's `getDefaultDriverName()` for a driver registered straight on the engine. - A driver is present: silent. - No driver, the `auth` service is registered, and `tenancy.posture` is `single`: the boot is refused before any `start()`, exit 1, through serve's existing refusal idiom (the artifact gate's: `restoreOutput()`, a red block, a thrown `Refusing to boot: …` that the fatal handler prints). It names three fixes: set `OS_DATABASE_URL`, mount a storage driver in the stack's `plugins` (for example `new DriverPlugin(driver)`), or run with `--dev`. - No driver otherwise: one `⚠ No storage: …` line naming the same fixes, worded as of the end of plugin init, and the boot goes on. - **`packages/cli/test/serve-no-storage-boot-guard.integration.test.ts`.** Five production boots of the built CLI, one per cause (below). - **`scripts/check-cli-test-child-env.mjs`.** The pin spawns `bin/run.js`, so it joins that gate's pinned built-entrypoint population, by name, the way the gate asks (one roster entry, "seven" becomes "eight"). - **`.changeset/23573-serve-no-storage-loud.md`.** `@objectstack/cli` patch, with the before and after. ⛔ Not touched: the `5c` blocks (PR #23469, now on `main` as `5c-ter`), `packages/objectql`, `plugin-auth`, `packages/core`, the drivers, `content/docs/**`. ## Measured shapes Production posture: `node packages/cli/bin/run.js serve objectstack.config.ts -p PORT` on the built CLI, `NODE_ENV`, `OS_DATABASE_URL`, `DATABASE_URL` and `OS_DATABASE_DRIVER` unset, `OS_CLOUD_URL=off`. Fixtures are throwaway `defineStack` host configs with one object. "Before" is base `9f81260487` unless marked (8d05456 = the measurement report `6184721596`). "After" is this branch, re-measured on the merged head `650e28a731` for the rows marked *. | Shape | Before | After | |---|---|---| | R1 `examples/app-showcase`, auth secret | exit 1, `sys_organization` (8d05456) | refused, exit 1; neither downstream line | | R2* host fixture, auth secret | exit 1, `sys_organization` | refused, exit 1; fixture plugin `init` ran, `start` never did | | R3* host fixture, no secret | exit 1 at `package-service` start (8d05456) | the line, then the same `package-service` failure | | R4 declared `default` datasource (`autoConnect`), auth secret | exit 1, `sys_organization` | refused | | R8 stack `AuthPlugin`, a stack plugin registers a driver on the engine in `start()` | exit 1 (8d05456) | refused | | R8b a stack plugin registers `driver.*` in `start()`, listed before a stack `ObjectQLPlugin` and `AuthPlugin` | exit 1, `sys_organization` | refused | | R8c the same, registering on the engine | exit 1, "database refused" (`no such table`) | refused | | R9* plugin `com.acme.store` (no driver-like name) registers `driver.*` in `init()`, auth secret | boots, health 200 | boots, silent | | R9e a plugin registers on the engine in `init()`, auth secret | boots | boots, silent | | R10* `--no-server --preset minimal`, no secret | boots DEGRADED (8d05456) | the line before any `start()`, then boots | | R11* a module `onEnable` registers a driver (`ctx.drivers.register`), no secret | boots | the line, then boots | | R20c* stack `AuthPlugin({ membershipPolicy })` + `OrganizationsPlugin`, `OS_TENANCY_POSTURE=group`, `--no-server --preset minimal`, no driver anywhere | boots, DEGRADED: job | the line, then boots | | R12* `OS_DATABASE_URL=file:ctl.db`, auth secret | boots (8d05456) | boots, silent | | R13* `--dev`, auth secret | boots (8d05456) | boots, silent | A refused boot wrote nothing to disk before the refusal: no `.objectstack/` in the fixture or in `examples/app-showcase`, and an empty `OS_HOME`. Its Phase-1 side effects are two `init()` log lines (the SDUI-manifest notice, the `StorageServicePlugin` local-driver warning). ## The ordering the guard rests on, verified - `resolvePluginOrder` (`packages/core/src/plugin-order.ts`) is a DFS over insertion order. The guard is inserted last and nothing declares it as a dependency, so its `init()` is the last `init()` and precedes every `start()`. Measured: on R2 the fixture plugin's `init` marker is on the stream and its `start` marker is not; on R9 a driver registered in another plugin's `init()` is seen. - No driver registered in a `start()` reaches `AuthPlugin.start()` in time. The flag needs declared objects, so step 3 composes `AppPlugin`, whose `optionalDependencies` are `com.objectstack.engine.objectql`, `com.objectstack.auth` and `com.objectstack.auth.identity-objects`. That hoists ObjectQL and auth ahead of every stack plugin. `ObjectQLPlugin` declares no dependencies. Measured: R8, R8b and R8c fail before this change. - R11's `onEnable` driver registers during `AppPlugin.start()`, after the guard's `init()`. No auth is composed there, so it gets the line and boots. ## Two departures from the dispatch, both measured - **The refusal predicate is `auth` registered AND `tenancy.posture === 'single'`, not `auth` registered alone.** R20c registers the `auth` service, has no driver anywhere, and boots today: `AuthPlugin.start()` reads `sys_organization` only under the `single` posture (`auth-plugin.ts`, `start()`). Refusing on `auth` alone would refuse a configuration that boots, which the claim forbids. The tenancy service's `posture` is the posture in force (a degraded walled request reads `single`), which is exactly the condition `AuthPlugin.start()` branches on. - **The datasource fix is spelled "mount a storage driver in the stack's plugins (e.g. `new DriverPlugin(driver)`)", not "declare a datasource".** R4 declares a `default` datasource with `autoConnect` and still fails: a config-declared datasource connects in `AppPlugin.start()`, after `AuthPlugin.start()`. Telling an R4 operator to declare a datasource would send them to the thing they already did. ## Tests All on the final head `650e28a731` (this branch merged with `origin/main` `7c6b7f4a35`), after a rebuild of the CLI's dependency closure (`turbo run build --filter='@objectstack/cli...'`, 62 tasks, exit 0). - **The pin and its eight neighbours** (the diff adds an integration-tier file, so that tier is run here for these files; the rest of it is CI's): `pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2` over `test/serve-no-storage-boot-guard.integration.test.ts` plus `serve-config-boot-flat-docs`, `serve-config-boot-residual`, `serve-host-config-metadata-door`, `serve-migrate-exit-schema-parity`, `serve-view-container-read` (`.integration`), `serve-package-declared-capabilities`, `serve-runtime-state-project-key` and `serve-host-config`: `Test Files 9 passed (9)`, `Tests 61 passed (61)`. The pin alone, three runs: 23.58 s, 24.37 s, 24.68 s. - **The unit layer:** `pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2`: `Test Files 290 passed (290)`, `Tests 4323 passed (4323)`. - **Typecheck:** `pnpm --filter @objectstack/cli typecheck`, exit 0 (`tsc --noEmit`, then `check:test-typecheck: OK`; the pin is in the `tsconfig.test.json` program, counted with `--listFilesOnly`). - **Ablation of the refusal** (committed fix first; `node scripts/ablation-replace.mjs` with its EXIT/INT/TERM restore). Mutation: the refusal's condition prefixed with `false &&`, anchor x1 to x0, blob `c016f7b009c1` to `139994049e2d`. `pnpm --filter @objectstack/cli build`, then `node scripts/ablation-dist-preflight.mjs @objectstack/cli` found the marker in 1 built file. The pin: `Tests 1 failed | 4 passed`; the refusal leg failed on `no refusal was printed`, and the boot it read printed `[ObjectQL] No driver available for object 'sys_organization'` and `ERROR sys_metadata could NOT be read at boot`. Restore: blob back to `c016f7b009c1` = HEAD, `git diff HEAD` empty; rebuild, preflight `--absent` (absent from all 632 built files, tree clean against HEAD); the pin `Tests 5 passed (5)`. Measured before the merge with `main`, which touches no line of this diff. ## Gates - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on `650e28a731`: 86 commands (the edit to `scripts/check-cli-test-child-env.mjs` brings 19 of them). All 86 run, each exit code recorded before any pipe: all 86 exit 0. `--ran`: `86 derived famil(ies) accounted for — 86 run, 0 NOT-MEASURED (a DERIVED zero …)`. - The edited gate's own suite: `node scripts/check-cli-test-child-env.mjs --self-test` (152 cases pass) and its audit (`all 179 spawn call(s) declare their child's env`), both exit 0; `node scripts/check-ratchet-remedy-authority.mjs` and its `--self-test`, which name that script, exit 0. - Read for this change though not derived: `pnpm check:startup-registry-verdict` exit 0 (`none recording a verdict the boot can contradict`), `pnpm check:init-service-contract` exit 0. - **Lint, narrowed:** `eslint --no-inline-config --format json` over the three changed code files, exit 0, 0 messages. The narrowing holds because (1) the root `lint` is `eslint . --no-inline-config`, the same flags; (2) `eslint.config.mjs` enables no type-aware linting for any file (its own note: no `parserOptions.project`, no typed rules), so a file's verdict depends on that file alone; (3) `--print-config` resolves active rules for each of the three files (5, 5 and 2), so none of them is ignored. The repo-wide `pnpm lint` is CI's. - CI check-runs: not yet read; they start when this PR opens. ## Acceptance notes - The `⚠ No storage:` line is worded as of the end of plugin init ("… so objects have no store unless a plugin registers one when it starts"), not as a verdict, because a `start()` can still register a driver (R11). AGENTS.md "Startup registry reads" is the rule; `pnpm check:startup-registry-verdict` is green. - The pin is named `.integration.test.ts`, not `.e2e.test.ts`: the `.e2e` name moves a file to the nightly run on `main` (`OS_TEST_TIERS`), and this pin guards a boot order any `serve.ts` edit can break. Its declared budget is 40 s (`// test-budget:` header, class "spawns the real producer once per single-cause fixture"): the slower of two local runs, 24.4 s on a shared container, x 1.25, rounded up. No CI reading exists yet; the first refresh that reads one re-measures it. - R3 (no auth secret, default preset) still dies at `package-service` start. It now prints the line with the fixes first. Refusing it was not in the ruling. - Out-of-scope findings are in the `os-dev-report` comment on #23573 for the seat to file or route; none is fixed here. --- _Generated by [Claude Code](https://claude.ai/code/session_01Q9PZ5Pzqcus4woVvTZwWNU)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b513343 commit edac218

4 files changed

Lines changed: 403 additions & 1 deletion

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
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
6+
7+
Clause-②: no
8+
9+
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.
10+
11+
- **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.
12+
- **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.
13+
- **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.
14+
- **When a plugin registers a driver during init**, under any name, nothing is printed and nothing changes.
15+
16+
`os start`, a standalone (non-host) config, `--dev` and any recognized `OS_DATABASE_URL` take other paths and are unchanged.

‎packages/cli/src/commands/serve.ts‎

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3089,6 +3089,13 @@ export default class Serve extends Command {
30893089
// discards as already-registered.
30903090
// [#15006] Same seam, same reason — see the ObjectQL gate above. The
30913091
// driver-provider duck-typing moved into it with the read it guards.
3092+
//
3093+
// [#23573] Whether this step declared NOTHING (production, no URL, no
3094+
// driver plugin by name). Only recorded here: this step cannot tell a
3095+
// boot that fails anyway from one whose own plugin registers a driver
3096+
// the name test misses, so the verdict waits for the no-storage boot
3097+
// guard at the end of Phase 1 (registered last, before runtime.start()).
3098+
let productionResolvedNoStorage = false;
30923099
if (shouldAutoRegisterStorageDriver(config, plugins)) {
30933100
const databaseUrl = process.env.OS_DATABASE_URL;
30943101
const driverType = resolveDriverType(process.env.OS_DATABASE_DRIVER, databaseUrl);
@@ -3136,6 +3143,8 @@ export default class Serve extends Command {
31363143
// every other kind AND the dev-default `:memory:` store). The ONE
31373144
// provision, shared with the standalone boot below.
31383145
await provisionTelemetry(resolution.sqliteFilePath);
3146+
} else {
3147+
productionResolvedNoStorage = true;
31393148
}
31403149
} catch (e: any) {
31413150
// "declared ≠ enforced" guard (#3276-class): a selection the CLI
@@ -5108,6 +5117,68 @@ export default class Serve extends Command {
51085117
// published banner surface on every boot to report a thing an operator
51095118
// cannot act on.
51105119

5120+
// ── No-storage boot guard (#23573) ─────────────────────────────
5121+
// Step 2 recorded that this production boot declared no storage. Whether
5122+
// that is fatal is only known once every plugin's init() has run, since a
5123+
// stack plugin the name test missed may register a driver there. This is
5124+
// the LAST plugin registered, so its init() runs after every other init()
5125+
// and before any start() (resolvePluginOrder keeps insertion order; no
5126+
// plugin names this one as a dependency). It reads the one map ObjectQL
5127+
// adopts `driver.*` services from at its start(), plus any driver the
5128+
// engine already holds.
5129+
// • a driver is there ⇒ nothing to say.
5130+
// • none, and the auth service runs under the 'single' posture ⇒ refuse
5131+
// before any start(). AuthPlugin.start() creates the Default
5132+
// Organization there, which needs a driver, and no driver can arrive
5133+
// before it: AppPlugin orders ObjectQL and auth ahead of every stack
5134+
// plugin, so a driver registered in any start() comes too late.
5135+
// Without this, the boot died there with a driver error, after an
5136+
// objectql ERROR naming a datasource nobody declared.
5137+
// • none otherwise ⇒ ONE line, and the boot goes on.
5138+
// Worded as of the end of init, because a start() (a module's
5139+
// onEnable) may still register a driver and some such stacks boot.
5140+
// ⛔ Not `trackPlugin`ed, for the announcer's reason above.
5141+
if (productionResolvedNoStorage) {
5142+
// ONE list, rendered as the refusal's bullets and as the line's sentence.
5143+
const noStorageRemedies = [
5144+
'set OS_DATABASE_URL (e.g. file:./data/app.db, or a postgres:// or mysql:// URL)',
5145+
"mount a storage driver in the stack's plugins (e.g. new DriverPlugin(driver) from @objectstack/runtime)",
5146+
'run with --dev (an in-memory SQLite store, for development)',
5147+
];
5148+
await kernel.use({
5149+
name: 'com.objectstack.cli.no-storage-boot-guard',
5150+
version: '1.0.0',
5151+
init: async (ctx: any) => {
5152+
const services: Map<string, any> = ctx.getServices();
5153+
const engine = services.get('objectql');
5154+
const hasDriver = [...services.keys()].some((name) => name.startsWith('driver.'))
5155+
|| (typeof engine?.getDefaultDriverName === 'function'
5156+
&& engine.getDefaultDriverName() !== undefined);
5157+
if (hasDriver) return;
5158+
if (services.has('auth') && services.get('tenancy')?.posture === 'single') {
5159+
restoreOutput();
5160+
console.error(chalk.red(
5161+
'\n ✖ This production serve has no storage: no recognized OS_DATABASE_URL declared a\n'
5162+
+ ' default datasource, and no plugin registered a storage driver by the end of plugin\n'
5163+
+ " init. The auth service runs under the 'single' tenancy posture, and its start()\n"
5164+
+ ' creates the Default Organization, which needs a driver.\n\n'
5165+
+ ' Fix one of:\n'
5166+
+ noStorageRemedies.map((remedy) => ` • ${remedy}\n`).join(''),
5167+
));
5168+
throw new Error(
5169+
'Refusing to boot: this production serve has no storage, and its auth service needs a '
5170+
+ 'driver when it starts. Set OS_DATABASE_URL, mount a storage driver plugin, or run with --dev.',
5171+
);
5172+
}
5173+
console.warn(chalk.yellow(
5174+
' ⚠ No storage: no recognized OS_DATABASE_URL, and no plugin registered a storage driver by '
5175+
+ 'the end of plugin init, so objects have no store unless a plugin registers one when it '
5176+
+ `starts. Fix: ${noStorageRemedies[0]}; ${noStorageRemedies[1]}; or ${noStorageRemedies[2]}.`,
5177+
));
5178+
},
5179+
} as any);
5180+
}
5181+
51115182
// Boot the runtime
51125183
await runtime.start();
51135184

0 commit comments

Comments
 (0)