Skip to content

Commit d0a53cc

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-21387-retire-role-arm
2 parents 811c170 + 025008a commit d0a53cc

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)