Skip to content

Commit 025008a

Browse files
fix(runtime,cli): a plain os dev self-heals safe drift and provisions the telemetry sibling on the standalone stack (#21733) (#21766)
Fixes #21733 Clause-②: yes (widening) ## What this changes A plain `os dev` on a non-host config (every fresh scaffold) boots through the standalone stack, and that path now gets the two dev behaviours `content/docs/deployment/cli.mdx` already documents: 1. **Dev schema self-heal.** On restart, safe drift (`autoMigrate: 'safe'`) is applied. The card's staged non-NULL-safe unique index comes back NULL-safe, and the drift and plan lines saying "auto-applied at boot under dev autoMigrate: 'safe'" are now true. Their wording is unchanged. 2. **Telemetry sibling.** The `DB.telemetry.EXT` sibling datasource is provisioned next to a file-backed SQLite primary under the same `resolveTelemetryDbPath` rule. `OS_TELEMETRY_DB=0` opts out. Production boots and one-shot command boots do not change. ## Route (A2): one decision, in `@objectstack/runtime` - **New home.** `packages/runtime/src/dev-auto-migrate.ts` exports `devAutoMigrateConfig(driver, dev)`. It decides two things in one place: - when: an explicit dev boot; - for which kinds: those whose spec connection contract declares `autoMigrate`, i.e. sqlite, postgres and mysql. - **Three readers, none with its own condition:** - the standalone stack's `default` definition (`standalone-stack.ts`); - every arm of `resolveStorageDefinition` (`storage-driver.ts`, the host-config path); - the telemetry sibling's driver. - **Why runtime.** The dependency direction allows cli → runtime only, and `service-datasource` and `spec` are out of lane. The move adds two exports to `@objectstack/runtime`'s only entry, `devAutoMigrateConfig` and `DevAutoMigrateConfig`. That is this PR's published-surface widening, hence `Clause-②: yes (widening)` and a `minor` on runtime (see the changeset). - **Per-kind behaviour is unchanged.** The host path already gave `'safe'` to exactly sqlite, postgres, mysql and the dev-default `:memory:` sqlite, and none to sqlite-wasm, mongodb or turso. Two pins hold this: - `dev-auto-migrate.test.ts` holds the helper's set equal to the spec contract; - `dev-self-heal-host-parity.test.ts` drives both real entry points for all 6 kinds × dev/production. All 12 rows agree. ## One-shot fence (A3) - **The risk.** `bootSchemaStack` (`schema-migrate.ts`, every `os migrate *` / `os meta *` / `os secret *` boot) passes no `dev`. So `factoryDev` falls back to `NODE_ENV === 'development'`. - **The fix.** The standalone stack therefore reads the decision under `cfg.dev === true` only. The step-down keeps its `NODE_ENV` default. `os dev`, `os serve` and `os start` always pass `dev` explicitly, from `serve.ts` and through `createDefaultHostConfig`. - **Measured** by ablation leg C below: with the read keyed on `factoryDev` under `NODE_ENV=development`: - a **non-deferred** one-shot boot applied the staged safe drift (fence pin red, `expected [] to include 'safe:recreate_index'`); - `os migrate plan` stayed write-free, because its deferred DDL never reaches the reconcile (pin green). - **Pins** are in their own file, `schema-migrate.dev-self-heal-fence.integration.test.ts`: - plan lists the drift as `safe` and leaves the staged file byte-identical; - a non-deferred `bootSchemaStack` leaves the drift in place; - the serving declaration (`dev: true`) heals the same file, as a positive control. ## Telemetry on the standalone path (A4) - **Shared provision.** The inline provision in `serve.ts` became `provisionTelemetryDatasource` (`utils/telemetry-datasource.ts`). Both serving paths call it through one closure: - the host branch keys it on `resolution.sqliteFilePath`; - the standalone branches key it on `standaloneTelemetryPrimary(input)`, which is the runtime's own pre-boot `resolveStandaloneDatabase` over the same input the stack was handed. Only the native `sqlite` kind counts, as on the host path. - **Banner.** It needed nothing new. `servedSqliteFilePath` on this path is still read from the booted driver, and the plugin roster gains `TelemetryDatasource` exactly as on the host path. - **Falsifier.** No deliberate omission was found: no comment, test or ruling in `standalone-stack*`, `default-host*`, the CLI tests or ADR-0057/0062. ADR-0062 records the telemetry sibling as a pre-built `DriverPlugin`, and it stays one. ## Reproduction (A1), origin/main `8cbba54491`, built CLI (`bin/run.js`) The scaffold is one object `scaf_item` with `qa_code: { unique: true }`, `sharingModel: 'private'`, and no plugins. - **Boot 1** (`os dev --no-watch -d file:A.db`) created the NULL-safe index ``CREATE UNIQUE INDEX `uniq_scaf_item_organization_id_qa_code` ON `scaf_item` (COALESCE(`organization_id`, '__global__'), `qa_code`)``. No `A.telemetry.db` was created. - **Stage.** The index was restaged as `CREATE UNIQUE INDEX uniq_scaf_item_organization_id_qa_code ON scaf_item (organization_id, qa_code)`. `os migrate plan --database-url file:A.db` then listed `✓ scaf_item [uniq_…] [recreate_index]` under Safe, and the file was byte-identical afterwards. - **Two plain `os dev` restarts** each logged `[schema-drift] … (auto-applied at boot under dev autoMigrate: 'safe')`, and the index stayed bare both times. - **Control.** The same staged file under `OS_MODE=off` logged `[schema-drift] auto-reconciled recreate_index on scaf_item.organization_id`, the index came back NULL-safe, and `A.telemetry.db` was created. - **After the fix,** the same staged file with a plain `os dev` logs `auto-reconciled recreate_index`, the index comes back NULL-safe, and `A.telemetry.db` is created. It holds `sys_audit_log`, `sys_job_run`, `sys_notification` and 4 more tables. ## Tests Full suites and lint ran at `c20d26dd76` (round 1). Round 2 merged `origin/main` `b7a13c762f` and reran the targeted set at `3e28108544`, listed under the table. | Run | Result | |---|---| | `pnpm --filter @objectstack/runtime exec vitest run --project local --maxWorkers=2` (full runtime suite) | 324 files, 4617 passed, 19 skipped | | `pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2` (full) | 258 files, 3777 passed | | `… --project integration --maxWorkers=2 --shard=1/4 … 4/4` (full, 93 files) | 24/23/23/23 files; 169+267+238+223 passed, 2 skipped | | `pnpm --filter @objectstack/runtime typecheck` / `@objectstack/cli typecheck` (incl. test layers) | green | | `pnpm lint` (full, `eslint . --no-inline-config`) | exit 0, 99 s; rerun at `3e28108544`: exit 0, 104 s | Round 2 at `3e28108544`, after the merge and a full turbo rebuild of `./packages/*` and `./packages/*/*` (71 tasks): - `pnpm --filter @objectstack/runtime typecheck` and `@objectstack/cli typecheck`, including the test layers: green. - runtime `src/default-datasource-plugin.test.ts` and `src/dev-auto-migrate.test.ts`: 2 files, 19 passed. - the four CLI pins below, on the rebuilt packages: 4 files, 13 passed. The new pins: - `packages/runtime/src/dev-auto-migrate.test.ts`: contract equality, plus the `default` definition under dev, production, and the `NODE_ENV=development` default. - `packages/cli/src/utils/dev-self-heal-host-parity.test.ts`: both hosts, 6 kinds × dev/production. - `packages/cli/src/utils/schema-migrate.dev-self-heal-fence.integration.test.ts`: the one-shot fence, in its own file. - `packages/cli/src/utils/telemetry-datasource.provision.integration.test.ts`: the provision, the `OS_TELEMETRY_DB=0` opt-out, production opt-in only by explicit path, and `standaloneTelemetryPrimary`. - `packages/cli/test/dev-standalone-self-heal.integration.test.ts`: the card's restart pin on the **built** CLI. - fresh `os dev` with `OS_TELEMETRY_DB=0`: NULL-safe index, no sibling; - restart on the restaged bare index: NULL-safe again, `auto-reconciled`, `A.telemetry.db` present; - production `os serve` (`NODE_ENV` unset) on the restaged file: the drift is reported and left bare. ## Reverse verification (A5): `scripts/ablation-replace.mjs`, rebuilt, `ablation-dist-preflight` both legs All three legs follow the same sequence: 1. The anchor hits once, and the blob changes. 2. `dist/` carries the marker (preflight exit 0). 3. Measure. 4. Restore: the blob equals HEAD and `git diff HEAD` is empty. 5. Rebuild, then preflight `--absent`: the marker is gone from `dist/` and the tree is clean. | Leg | Mutation (`standalone-stack.ts` / `serve.ts`) | Blob | Red | Green | |---|---|---|---|---| | A: the standalone read of the decision | `config: { ...driverConfig, ...devSelfHeal }` → `...(String('ABLATED_21733_A') ? {} : devSelfHeal)` | `69295ae4dcb6` → `3a20c5f19924` → `69295ae4dcb6` | restart NULL-safe pin; fence positive control; host parity; runtime dev-definition ×2 | production pin; fresh pin; telemetry pin; both one-shot fence cases; `storage-driver.test.ts` (host path); runtime contract, production and NODE_ENV cases | | B: the standalone telemetry provision | `standaloneSqlitePrimary = await standaloneTelemetryPrimary(standaloneInput);` → `String('ABLATED_21733_B') ? undefined : …` | `d5486ac90717` → `42870a5cbc82` → `d5486ac90717` | telemetry-sibling pin | self-heal, opt-out, production pins | | C: the one-shot fence (A3) | `devAutoMigrateConfig(dbDriver, cfg.dev === true)` → `… String('ABLATED_21733_C') ? factoryDev : cfg.dev === true` | `69295ae4dcb6` → `22ebf7f09362` → `69295ae4dcb6` | non-deferred one-shot fence; runtime NODE_ENV-default case | `os migrate plan` byte-identical; serving positive control | ## Gates `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `3e28108544` (merge base `b7a13c762`, 14 paths) derives the same 86 families as round 1. All 85 runnable families were rerun on that head and passed. That includes `check:changeset-no-major --base origin/main` (no `major`) and `check:adr-0087-registration` (no declared-breaking changeset). Round-1 notes, at `c20d26dd76`: - **Rerun after a prerequisite.** `check:dual-build-cjs-loads` and `check:i18n-coverage` first answered `PREREQUISITE NOT MET` (exit 3) on unbuilt packages. They were rerun green after building those packages. - **Rerun after a fix.** `check:cli-test-child-env` went red on the new built-entry spawner and was rerun green. The fix is in the second deviation below. - **NOT MEASURED: `check:pm-dispatch-gates`.** Its self-test and its bare check each ran past the 10-minute foreground cap at 590 s on this shared box, with every printed case passing. It gates `scripts/pm/dispatch-gates.mjs`, which this diff does not touch. CI runs it. - **Carried.** `check:pm-dispatch-gates` is carried NOT MEASURED into round 2. Its inputs (`scripts/pm`, `.github`, `.claude`, the root `package.json`) are byte-unchanged between `c20d26dd76` and `3e28108544`. - **Reconciled.** `--ran` at `3e28108544` reports `86 derived famil(ies) accounted for — 85 run, 1 NOT-MEASURED`. - **Ledger blind spot.** `git grep autoMigrate` over `packages/spec/liveness/**` and `*.ledger.*` found 0 hits; the control term `sqlite` hits `datasource.json`. No symbol was renamed or removed. ## Deviations for the seat - **`@objectstack/runtime` gains two exports.** `devAutoMigrateConfig` and its `DevAutoMigrateConfig` type, on the package's only entry. This is the widening declared above. - No key is added to `StandaloneStackConfigSchema` or to any other exported type or schema, and no accept set moves. - The `dev` key's TSDoc now states its narrower self-heal meaning: an explicit `true` only. - The changeset is `minor` on `@objectstack/runtime` and `patch` on `@objectstack/cli`. - **`scripts/check-cli-test-child-env.mjs` census.** Its pinned built-entrypoint population (formerly "exactly the six files") now admits `dev-standalone-self-heal.integration.test.ts`, with a comment saying why. The production leg is only reachable through `bin/run.js` with `NODE_ENV` unset. The gate's 152 self-test cases pass. - **origin/main merged.** `b7a13c762f` came in through a merge commit (`07745648c0`, no rebase), with no conflicts. One file overlaps, `serve.ts`, where #21752 adds the AuthPlugin `appName`; its hunks are disjoint from this diff. After the merge, the branch's diff against `main` was the same 13 files with the same +1134/−68 as before; round 2's R4 TSDoc edit makes it 14 files, +1143/−69. The rebuilt `serve.js` carries both sides. ## Acceptance notes - **`DefaultDatasourcePluginOptions.dev` TSDoc** (`packages/runtime/src/default-datasource-plugin.ts`), comment only, fixed in round 2. It used to say `dev` arms the "self-heal passthroughs". It now says what `dev` arms, the sqlite step-down (#2229), and that the self-heal rides in the definition's `config.autoMigrate`, which `devAutoMigrateConfig` decides. - **Artifact-fallback branch.** The `createDefaultHostConfig` branch (`os start` with no config, `os dev -a`) gets both behaviours too. Its telemetry primary is resolved from the same input handed to `createDefaultHostConfig`. The empty-boot stub declares no datasource, so the database answer cannot differ. This is commented at the call site. --- _Generated by [Claude Code](https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 50b5e03 commit 025008a

14 files changed

Lines changed: 1143 additions & 69 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/runtime": minor
3+
"@objectstack/cli": patch
4+
---
5+
6+
fix(runtime,cli): a plain `os dev` now self-heals safe schema drift on restart and provisions the `telemetry` sibling database, as `content/docs/deployment/cli.mdx` already says (#21733)
7+
8+
Clause-②: yes (widening)
9+
10+
- **What was broken.** A config with no instantiated `plugins[]` (every fresh scaffold) boots through the standalone stack. Its `default` datasource was built without `autoMigrate: 'safe'`: only the config-load fallback that a host config or `OS_MODE=off` takes carried it. So safe drift was never applied on restart. An example is a per-organization unique index that an older release left non-NULL-safe. Meanwhile the driver's drift line and `os migrate plan` both said the change was "auto-applied at boot under dev autoMigrate: 'safe'". The same boot never provisioned the `<db>.telemetry.<ext>` sibling either.
11+
- **The fix.** The dev self-heal decision now lives in one place, `devAutoMigrateConfig` in `@objectstack/runtime`. That is the driver kinds whose connection contract declares `autoMigrate` (sqlite, postgres, mysql), on a dev boot. The standalone stack, the CLI's config-load fallback and the telemetry sibling all read it, so no kind gains or loses the self-heal relative to the host path. The telemetry provision is one helper (`provisionTelemetryDatasource`) that both serving paths call, under the same `resolveTelemetryDbPath` rule: dev default-on for a file-backed SQLite primary, `OS_TELEMETRY_DB=0` to opt out, `OS_TELEMETRY_DB=<path>` to opt in anywhere.
12+
- **Only a serving boot self-heals.** The standalone stack arms the self-heal on an explicit `dev: true`. That is what `os dev` passes. It does not arm it on the `NODE_ENV=development` default that its sqlite step-down still takes. A one-shot command (`os migrate *`, `os meta resync`, …) passes no `dev`, so it never applies drift its operator did not confirm, whatever `NODE_ENV` says. Production boots are unchanged: the definition carries no `autoMigrate`, and the SQL driver refuses it under `NODE_ENV=production` anyway.
13+
- **Why minor.** `@objectstack/runtime` gains two exports on its only entry, `devAutoMigrateConfig` and its `DevAutoMigrateConfig` type. That is the widening: the existing decision moved out of the CLI so that the CLI reads it rather than keep a second copy. No config key, schema or accept set moves. `@objectstack/cli` is a `patch`: its fix restores documented behaviour and adds no public surface.

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

Lines changed: 53 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import chalk, { chalkStderr } from 'chalk';
88
import { bundleRequire } from 'bundle-require';
99
import { loadConfig, BUNDLE_REQUIRE_EXTERNALS } from '../utils/config.js';
1010
import { mergeBootConfig } from '../utils/merge-boot-config.js';
11+
import { provisionTelemetryDatasource, standaloneTelemetryPrimary } from '../utils/telemetry-datasource.js';
1112
import { isHostConfig, shouldBootWithLibrary } from '../utils/plugin-detection.js';
1213
import { readInternalArtifactPath, readInternalConfigOutputPath } from '../utils/internal-artifact-channel.js';
1314
// The precedence's last rung — whether the cwd config takes part — decided by
@@ -2583,6 +2584,12 @@ export default class Serve extends Command {
25832584
// human and may be redacted or labelled, while this one is handed to
25842585
// `stat` — see `describeDriverSqliteFile`.
25852586
let servedSqliteFilePath: string | undefined;
2587+
// #21733 — the file-backed native SQLite database a composed STANDALONE
2588+
// stack declared as its `default` datasource, read BEFORE the kernel boots
2589+
// so the `telemetry` sibling can be provisioned next to it (step 2 below).
2590+
// `servedSqliteFilePath` cannot serve: on this path it is only learned by
2591+
// probing the booted kernel. Unset for every other boot and every other kind.
2592+
let standaloneSqlitePrimary: string | undefined;
25862593

25872594
// Resolve the kernel logger level up front. It decides more than the
25882595
// logger's own threshold: it decides whether the boot-quiet window below
@@ -2741,7 +2748,7 @@ export default class Serve extends Command {
27412748
// "missing artifact" error and assemble a bare kernel that
27422749
// can later install marketplace apps at runtime.
27432750
const { createDefaultHostConfig } = await import('@objectstack/runtime');
2744-
const bootResult = await createDefaultHostConfig({
2751+
const defaultHostInput = {
27452752
requireArtifact: !useEmptyBoot,
27462753
dev: isDev,
27472754
// #8368: the already-fetched, already-verified LOCAL copy. Passing
@@ -2755,7 +2762,15 @@ export default class Serve extends Command {
27552762
...(pinnedArtifact
27562763
? { artifactPath: pinnedArtifact.localPath }
27572764
: supervisorArtifact ? { artifactPath: supervisorArtifact } : {}),
2758-
});
2765+
};
2766+
const bootResult = await createDefaultHostConfig(defaultHostInput);
2767+
// #21733 — the `default` database this stack declared, for the
2768+
// telemetry sibling (step 2). The same input the host config was
2769+
// built from: the database resolution reads the artifact only for a
2770+
// declared default datasource, and the empty boot's synthesized stub
2771+
// declares none, so the artifact rung `createDefaultHostConfig`
2772+
// settles internally cannot answer differently.
2773+
standaloneSqlitePrimary = await standaloneTelemetryPrimary(defaultHostInput);
27592774
// [#4002] `api` merges per key — see mergeBootConfig. A shallow spread
27602775
// let the boot builder's two scoping keys wipe the author's whole `api`
27612776
// block, silently dropping `requireAuth` / `enforceProjectMembership`.
@@ -2779,6 +2794,10 @@ export default class Serve extends Command {
27792794
...(supervisorArtifact ? { artifactPath: supervisorArtifact } : {}),
27802795
};
27812796
const bootResult = await createStandaloneStack(standaloneInput);
2797+
// #21733 — the `default` database the stack just declared, resolved
2798+
// by the runtime's own pre-boot resolution over the SAME input, for
2799+
// the telemetry sibling (step 2).
2800+
standaloneSqlitePrimary = await standaloneTelemetryPrimary(standaloneInput);
27822801
// #21501 — did the standalone stack load a compiled artifact as this
27832802
// app's bundle? Its AppPlugin over the bundle is the proof (it is
27842803
// pushed only when the bundle loaded, and a non-host config carries
@@ -3190,6 +3209,26 @@ export default class Serve extends Command {
31903209
}
31913210
}
31923211

3212+
// ADR-0057 §3.6 — the `telemetry` sibling datasource, provisioned next to
3213+
// a file-backed SQLite primary by EVERY serving boot through ONE helper
3214+
// (`provisionTelemetryDatasource`, #21733): the config-load fallback in
3215+
// step 2 below and the standalone stack right after it. Dev default-on;
3216+
// `OS_TELEMETRY_DB=0` opts out, `OS_TELEMETRY_DB=<path>` opts in anywhere
3217+
// (incl. serve) — `resolveTelemetryDbPath` is the rule, never restated here.
3218+
const provisionTelemetry = async (primaryPath: string | undefined): Promise<void> => {
3219+
const telemetryPath = await provisionTelemetryDatasource({
3220+
primaryPath,
3221+
env: process.env,
3222+
dev: isDev,
3223+
use: (plugin) => kernel.use(plugin as any),
3224+
warn: (m) => console.warn(chalk.yellow(m)),
3225+
});
3226+
if (telemetryPath) {
3227+
trackPlugin('TelemetryDatasource');
3228+
printDiagnostic(chalk.dim(` telemetry datasource: ${telemetryPath} (lifecycle-classed system data; OS_TELEMETRY_DB=0 to disable)`));
3229+
}
3230+
};
3231+
31933232
// 2. Auto-register storage driver
31943233
// Priority:
31953234
// 1. OS_DATABASE_DRIVER env var (explicit override)
@@ -3234,7 +3273,7 @@ export default class Serve extends Command {
32343273
// resolveStorageDefinition. The dev sqlite step-down (#2229) and
32353274
// the loosen-only self-heal (#2186, via config.autoMigrate) now run
32363275
// inside the factory at connect.
3237-
const { DriverPlugin, DefaultDatasourcePlugin } = await import('@objectstack/runtime');
3276+
const { DefaultDatasourcePlugin } = await import('@objectstack/runtime');
32383277
const resolution = resolveStorageDefinition(driverType, { databaseUrl, isDev, authToken: databaseAuthToken });
32393278
if (resolution) {
32403279
// #5602: libSQL/Turso is the one kind the shared open-core factory
@@ -3257,52 +3296,11 @@ export default class Serve extends Command {
32573296
resolvedDatabaseUrl = resolution.displayUrl;
32583297
servedSqliteFilePath = resolution.sqliteFilePath;
32593298

3260-
// ADR-0057 §3.6 (#2834 ②): provision the dedicated `telemetry`
3261-
// datasource — a sibling SQLite file the engine routes every
3262-
// telemetry/event/audit-classed object to, so platform-generated
3263-
// growth can never again bloat the business DB. Dev default-on
3264-
// for file-backed primaries; `OS_TELEMETRY_DB=0` opts out,
3265-
// `OS_TELEMETRY_DB=<path>` opts in anywhere (incl. serve). Gated on
3266-
// an explicit SQLite primary (`sqliteFilePath`, unset for the mingo
3267-
// memory driver AND the dev-default `:memory:` store). The old
3268-
// `resolution.engine !== 'memory'` refinement is unknowable now
3269-
// that the primary connects later (#3826); the telemetry
3270-
// provision's own `telemetry.engine !== 'memory'` check below
3271-
// still guards the ABI-broken step-down case. The telemetry
3272-
// driver itself stays a pre-built DriverPlugin — the documented
3273-
// escape hatch for named auxiliary drivers.
3274-
if (resolution.sqliteFilePath) {
3275-
const { resolveTelemetryDbPath } = await import('../utils/telemetry-datasource.js');
3276-
const telemetryPath = resolveTelemetryDbPath({ primaryPath: resolution.sqliteFilePath, env: process.env, dev: isDev });
3277-
if (telemetryPath) {
3278-
try {
3279-
const { resolveSqliteDriver } = await import('@objectstack/service-datasource');
3280-
const telemetry = await resolveSqliteDriver({
3281-
filename: telemetryPath,
3282-
dev: isDev,
3283-
autoMigrate: isDev ? 'safe' : undefined,
3284-
warn: (m) => console.warn(chalk.yellow(m)),
3285-
});
3286-
if (telemetry.engine !== 'memory') {
3287-
// The engine keys datasources by driver name — the
3288-
// lifecycle router looks this exact name up. The driver
3289-
// name is the WHOLE wiring: DriverPlugin.init registers
3290-
// `driver.telemetry`, ObjectQL's discovery loop adopts
3291-
// it, and lifecycle-classed objects route to it. (An
3292-
// options bag once also asked for `datasourceName:
3293-
// 'telemetry'` metadata registration — inert since
3294-
// inception, retired in #4320.)
3295-
Object.defineProperty(telemetry.driver, 'name', { value: 'telemetry' });
3296-
await kernel.use(new DriverPlugin(telemetry.driver));
3297-
trackPlugin('TelemetryDatasource');
3298-
printDiagnostic(chalk.dim(` telemetry datasource: ${telemetryPath} (lifecycle-classed system data; OS_TELEMETRY_DB=0 to disable)`));
3299-
}
3300-
} catch {
3301-
// Best-effort: a failed telemetry provision must never block
3302-
// boot — objects simply stay on the primary datasource.
3303-
}
3304-
}
3305-
}
3299+
// ADR-0057 §3.6 (#2834 ②): the dedicated `telemetry` datasource
3300+
// next to an explicit SQLite primary (`sqliteFilePath`, unset for
3301+
// every other kind AND the dev-default `:memory:` store). The ONE
3302+
// provision, shared with the standalone boot below.
3303+
await provisionTelemetry(resolution.sqliteFilePath);
33063304
}
33073305
} catch (e: any) {
33083306
// "declared ≠ enforced" guard (#3276-class): a selection the CLI
@@ -3332,6 +3330,12 @@ export default class Serve extends Command {
33323330
if (e?.code === 'MONGODB_MULTI_TENANT_UNSUPPORTED') throw e;
33333331
// silent
33343332
}
3333+
} else if (standaloneSqlitePrimary !== undefined) {
3334+
// #21733 — the standalone stack's `default` datasource is a file-backed
3335+
// SQLite database (resolved by the runtime's own pre-boot resolution in
3336+
// the boot-mode dispatch above), so it gets the same sibling the
3337+
// config-load fallback provisions, under the same rule.
3338+
await provisionTelemetry(standaloneSqlitePrimary);
33353339
}
33363340

33373341
// 3. Auto-register AppPlugin if config contains app definitions
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #21733 — both boot hosts give the `default` datasource the SAME dev
5+
* self-heal, kind by kind.
6+
*
7+
* The self-heal (`autoMigrate: 'safe'`, #2186) used to be decided inline in
8+
* the CLI host (`resolveStorageDefinition`) and not at all in the runtime host
9+
* (`createStandaloneStack`), which every plain `os dev` composes. Both now read
10+
* `devAutoMigrateConfig` from `@objectstack/runtime`; this file drives the two
11+
* REAL entry points, not the helper, so a host that stops reading it — or
12+
* starts deciding for itself — fails here by kind. Same reason the driver
13+
* vocabulary's own cross-host pin lives in this package
14+
* (`driver-vocabulary-parity.test.ts`): it is the one that can import both.
15+
*
16+
* The host path's answer is the reference: no kind gains or loses `'safe'`
17+
* relative to it (`storage-driver.test.ts` pins that answer on its own).
18+
*/
19+
20+
import { describe, it, expect, afterEach } from 'vitest';
21+
import { mkdtempSync, rmSync } from 'node:fs';
22+
import { tmpdir } from 'node:os';
23+
import { join } from 'node:path';
24+
import { createStandaloneStack } from '@objectstack/runtime';
25+
import { resolveStorageDefinition } from './storage-driver.js';
26+
27+
// [#10126] Pay the first transform of these dist-resolved workspace deps at
28+
// MODULE LOAD: `createStandaloneStack` reaches them through dynamic
29+
// `import()`s inside clocked `it()` bodies (`scripts/check-test-source-alias.mjs`).
30+
import '@objectstack/service-datasource';
31+
import '@objectstack/objectql';
32+
import '@objectstack/metadata';
33+
34+
const ENV_KEYS = ['OS_DATABASE_URL', 'DATABASE_URL', 'TURSO_DATABASE_URL', 'OS_DATABASE_DRIVER', 'OS_HOME'] as const;
35+
const ORIGINAL_ENV: Record<string, string | undefined> = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]]));
36+
const dirs: string[] = [];
37+
38+
afterEach(() => {
39+
for (const key of ENV_KEYS) {
40+
const original = ORIGINAL_ENV[key];
41+
if (original === undefined) delete process.env[key];
42+
else process.env[key] = original;
43+
}
44+
for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true });
45+
});
46+
47+
/** Each canonical kind, by a URL both hosts select it from. */
48+
function urlsFor(dir: string): Record<string, string> {
49+
return {
50+
sqlite: `file:${join(dir, 'p.db')}`,
51+
'sqlite-wasm': `wasm-sqlite://${join(dir, 'w.db')}`,
52+
postgres: 'postgres://u:p@localhost:5432/db',
53+
mysql: 'mysql://u:p@localhost:3306/db',
54+
mongodb: 'mongodb://localhost:27017/db',
55+
turso: 'libsql://my-db.turso.io',
56+
};
57+
}
58+
59+
async function runtimeAutoMigrate(databaseUrl: string, dev: boolean, projectRoot: string): Promise<unknown> {
60+
const stack = await createStandaloneStack({ databaseUrl, dev, projectRoot });
61+
const plugin = stack.plugins.find((p: any) => p?.name === 'com.objectstack.runtime.default-datasource') as any;
62+
expect(plugin, 'the standalone stack must carry the DefaultDatasourcePlugin').toBeDefined();
63+
return plugin.def.config?.autoMigrate;
64+
}
65+
66+
const BOOT_TIMEOUT = 90_000;
67+
68+
describe('#21733 — the dev self-heal is the same on both boot hosts', () => {
69+
it('every kind: the standalone stack carries exactly the `autoMigrate` the CLI host does, dev and production', async () => {
70+
for (const key of ENV_KEYS) delete process.env[key];
71+
const dir = mkdtempSync(join(tmpdir(), 'os-21733-parity-'));
72+
dirs.push(dir);
73+
const rows: string[] = [];
74+
for (const [kind, url] of Object.entries(urlsFor(dir))) {
75+
for (const dev of [true, false]) {
76+
const cli = resolveStorageDefinition(kind, { databaseUrl: url, isDev: dev })?.config.autoMigrate;
77+
const runtime = await runtimeAutoMigrate(url, dev, dir);
78+
rows.push(`${kind} dev=${dev}: cli=${String(cli)} runtime=${String(runtime)}`);
79+
}
80+
}
81+
// One assertion over the whole table, so a divergence names every kind it touches.
82+
expect(rows).toEqual([
83+
'sqlite dev=true: cli=safe runtime=safe',
84+
'sqlite dev=false: cli=undefined runtime=undefined',
85+
'sqlite-wasm dev=true: cli=undefined runtime=undefined',
86+
'sqlite-wasm dev=false: cli=undefined runtime=undefined',
87+
'postgres dev=true: cli=safe runtime=safe',
88+
'postgres dev=false: cli=undefined runtime=undefined',
89+
'mysql dev=true: cli=safe runtime=safe',
90+
'mysql dev=false: cli=undefined runtime=undefined',
91+
'mongodb dev=true: cli=undefined runtime=undefined',
92+
'mongodb dev=false: cli=undefined runtime=undefined',
93+
'turso dev=true: cli=undefined runtime=undefined',
94+
'turso dev=false: cli=undefined runtime=undefined',
95+
]);
96+
}, BOOT_TIMEOUT);
97+
});

0 commit comments

Comments
 (0)