Skip to content

Commit fd2768a

Browse files
fix(cli): os migrate plan/apply provision the telemetry sibling the serving boot provisions, and the boot-preparation enumeration pin (#22579) (#22720)
Fixes #22579 Clause-②: no `os migrate plan` / `apply` plan and apply every object against the database it lives in: a lifecycle-classed one in the `telemetry` sibling that a development `os serve` boot (or any boot with `OS_TELEMETRY_DB` set) keeps it in, never in the primary. The family's enumeration pin lands with it: every boot-preparation step the serving boot runs (environment files, datasource provisioning, composition, the auth-family gate) is listed once, and the migrate boot must run each one through the same function. No authorable key, entry export or error code is added. One `--json` output member is added: `telemetryDatabase`, present only when a sibling is planned. ## What changes **`packages/cli/src/utils/schema-migrate.ts` (`bootSchemaStack`).** The one-shot boot provisions the sibling through `provisionTelemetryDatasource` itself, keyed on `standaloneTelemetryPrimary` (the serving boot's key for the standalone stack), exactly when the serving boot would: - whether and where: the serving boot's `dev` reading (`isDevelopmentBoot`, i.e. `--dev` or `NODE_ENV=development`, the same reading the composition's auth gate already takes), plus `OS_TELEMETRY_DB` from the environment `loadProjectEnvFiles` loaded; - how it opens: the one-shot posture (new `oneShot` option). No dev self-heal (`devAutoMigrateConfig` asked with `false`, as its own contract says for a one-shot boot). On a read-only probe (`plan`, every no-write mode), an absent sibling opens as an empty in-memory database, as the primary does (`sqliteAbsentFile: 'empty-in-memory'`). The second driver is armed as follows: - **Deferral.** Already per driver (`DeferSchemaDdlPlugin` arms every `driver.*` service), so the sibling's create-table work is held and previewed with the primary's. - **Drift and apply.** New `SchemaStack.detectManagedDrift()` / `applyMigrationEntries()` run over the primary, then the sibling. Each entry is tied to the driver that reported it and applied there. An entry the stack did not report is refused before anything is applied. The primary is still asked on every apply, as before. - **Coverage.** `measureComposedCoverage` takes the planned drivers (`utils/schema-migration-plugins.ts`, a signature change only), so an object on the sibling is examined, not counted as "bound to a different datasource". - **Counting and teardown.** `managedTableCount` sums both drivers. `shutdown()` disconnects both. No change under `packages/drivers/**`: driver-sql's rotation-aware preview already answers `sys_activity` on the sibling (measured below). **`commands/migrate/plan.ts` / `apply.ts`.** Both use the stack-level drift. They print `Telemetry database: FILE (...)` under the database line, and add `telemetryDatabase` in `--json`. The `apply` confirmation names both databases. **`commands/migrate/unmapped-columns.ts`** (widening, three lines). Its refusal says an object "is not in the set `os migrate plan` diffs: it is bound to a different datasource". That sentence would have become false for a lifecycle-classed object on the sibling, which the plan now diffs. The command now reads such an object from the sibling and names that database. **`commands/serve.ts`** (only the `.env` load region). `os serve` loads the project's `.env*` files through `loadProjectEnvFiles`, the function the migrate boot loads them with (#22581). Before, the two boots made the same `dotenv-flow` call, spelled twice. Which files are read, and in which mode, is unchanged: same mode rule, same `dotenv-flow` call, same root. ## Readings (public doors: a real `os serve --no-server` provision-and-exit, then the commands) Fixture: an app config with `requires: ['auth', 'automation']`, served as a development boot with the sibling on by default. | step | `main` `d8830c2805` | this branch | | --- | --- | --- | | the boot's sibling `boot.telemetry.db` | 9 tables: `sys_activity`, `sys_audit_log`, `sys_automation_run`, `sys_flow_dispatch`, `sys_http_delivery`, `sys_job_run`, `sys_metadata_audit`, `sys_notification`, `sys_notification_delivery` | same | | `os migrate plan --json` | `pending`: those 9 as `create_table`; no sibling named | `pending: []`, `changes: []`, `telemetryDatabase` = the sibling, `managedTables` 78 = 69 primary objects + 9 sibling, coverage 78/78 | | `os migrate apply --yes --json` | `created`: the 9, all in `boot.db` | `created: []`; the primary's table list is unchanged | | lifecycle tables in the primary after apply | 9 | 0 | These are the card's 7 plus `sys_automation_run` and `sys_flow_dispatch`, which the `automation` provider adds. **Mechanism assumption 3 (does provisioning under `plan` write?), measured.** - Over an existing sibling, `plan` and `apply` leave both databases' schema and every row identical (the pin's `state()` reading). - Over a deployment with no sibling yet, `plan` with the sibling on lists the lifecycle tables as `create_table`, and `boot.telemetry.db` does not exist afterwards (read-only probe). - `apply` (no read-only probe) opens the sibling with SQLite's create-if-absent, as it opens its primary, and flushes only after confirmation. ## Pins - **`commands/migrate/plan.telemetry-sibling.integration.test.ts`** (integration tier: spawns `os serve`, then the commands). Two cases: - After a development boot, the plan has nothing pending and no drift, names the sibling, and examines both databases. `apply` creates nothing, and neither database moves. - The control: `OS_TELEMETRY_DB=0` plans as before, with no sibling named and nothing pending. Then the sibling on, over that deployment: it is planned against and not created. - Reverse-verified at `c989e7567`: the 7 source files restored to `d8830c2805` in the tree only, the test file at HEAD. Result: 2 failed. Case 1 failed on `pending`, the 9 `create_table` entries above. Case 2's control half passed and its sibling half failed on `pending: []`. Restore verified: each blob equal to the HEAD blob, `git diff HEAD` empty. - **`utils/boot-preparation-parity.test.ts`** (unit tier, source-reading): the enumeration pin. - `STEPS` lists the four steps with their shared functions by reference: `loadProjectEnvFiles`; `createStandaloneStack`, `standaloneTelemetryPrimary`, `provisionTelemetryDatasource`; `stackBootPlugins`, `materializeStackPlugin`, `isAppPluginLike`, `stackDeclaresMetadata`, `resolveStackCollection`, `stackDeclaredCapabilities`, `stampSearchPinyinEnabled`, `providesCapability`; `resolvePlatformAuthComposition`, `resolveStackTiers`, `resolveAuthSecret`, `isDevelopmentBoot`. - For each function, `serve.ts` and the migrate boot (`schema-migrate.ts` / `schema-migration-plugins.ts`) must both use it under a binding whose loaded export is that very function object. - Closure: every function `serve.ts` uses from a step module (`utils/schema-migrate.ts`, `telemetry-datasource.ts`, `stack-collections.ts`, `plugin-detection.ts`, `graft-runtime-hooks.ts`, `@objectstack/core`) is a listed step, or a `SERVE_ONLY` entry with its reason. There are 7 entries: `shouldAutoRegisterObjectQL`, `shouldAutoRegisterStorageDriver`, `shouldBootWithLibrary`, `bundleDeclaresTranslations`, `graftAuthoredRuntimeMembers`, `stackSuppliesAuthPlugin`, `findSqlDriverForKernel`. - Out of reach, stated in the header: a step written inline in `serve.ts`. - 35 cases green. Ablations through `scripts/ablation-replace.mjs` (anchor 1 to 0, blob changed, restore verified to the HEAD blob with `git diff HEAD` empty), each turning exactly one case red: - A, `bootSchemaStack` no longer calls `provisionTelemetryDatasource`: "datasource provisioning: the migrate boot calls provisionTelemetryDatasource" red. - B, serve's `loadProjectEnvFiles(...)` replaced with `void 0;`: "environment files: the serving boot calls loadProjectEnvFiles" red. - C, `serve.ts` imports and uses `resolveTelemetryDbPath` from a step module: the closure case red, naming `resolveTelemetryDbPath (from utils/telemetry-datasource.ts)`. - **`utils/schema-migration-plugins.test.ts`**: a coverage case for an object on the sibling. - **Fixture triage: `commands/migrate/data-commands.absent-database.integration.test.ts`.** The controls' served-shape boots are in-process stacks with no sibling, but the commands run from `bin/run-dev.js` under `NODE_ENV=development`. With this change, `audit-metadata-bodies` read `sys_audit_log` from the (absent) sibling and the control case went red with "scanned 0". The fixture now declares its own one-database topology with `OS_TELEMETRY_DB=0`. The rest of the file is unchanged, and it is green again. ## Local verification - `pnpm --filter @objectstack/cli typecheck` (`tsc --noEmit` plus `check:test-typecheck`): exit 0. - `pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2` at `ed44e35bd`: 280 files, 4162 tests passed. - Integration, run locally because the diff touches the one-shot boot path: - `vitest run --project integration` over `src/commands/migrate`, `src/utils/schema-migrate*`, `telemetry-datasource*`, `unmanaged-tables*`, `platform-migrations-arming`, `test/dev-standalone-self-heal`, `test/migrate-exit-code`, at `8004788e5`: 29 of 31 files passed. The 2 that failed: - `data-commands.absent-database`, the fixture above. - `test/dev-standalone-self-heal`, PREREQUISITE (`packages/cli` not built), so NOT MEASURED on that run. - Both re-run at `ed44e35bd` after a build: 2 files, 43 tests passed. The only commit between the two runs is that test-file fix. - The rest of the integration project is declared to CI. - Gates: the dispatch list (52) and this change's re-derivation `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `ed44e35bd` (97: those 52 plus 45 for the docs page and the changeset), all run at `ed44e35bd`, each exit code captured before any pipe: 97 of 97 exit 0. Reconciled: `✓ dispatch-gates --ran: 97 derived famil(ies) accounted for — 97 run, 0 NOT-MEASURED`. Five of them first refused with PREREQUISITE NOT MET (exit 3) on an unbuilt tree (`check:dual-build-cjs-loads`, `check:i18n`, `check:i18n-coverage`, `check:i18n-walk-parity`, spec `check:skill-examples`), and went green after a workspace build (`turbo run build`, 72 tasks). - Lint, a proven narrowing: - Population: `eslint.config.mjs`'s own `files` globs. - Count: `--format json` reports 12 files, the 12 lintable paths in this diff, none ignored. Result: 0 errors, 0 warnings, at `ed44e35bd`. - Invariance: the config enables no type-aware linting (no `parserOptions.project`), so no untouched file's verdict can move. ## Acceptance notes - **Which boot a plan mirrors.** As with the auth gate and the `.env` mode, `os migrate` reads `NODE_ENV` to decide which serving boot it describes. - A plan run without `NODE_ENV=development` after `os dev` describes a production `os serve`, which keeps these objects in the primary unless `OS_TELEMETRY_DB` names a file. - Documented in `content/docs/deployment/cli.mdx`. Raised in the report as a question; no flag is added. - **Orphans already created.** Tables an earlier `os migrate apply` created in the primary for these objects are not removed. Because the objects are declared, the unmanaged-tables sweep does not name them either. - **Still primary-only.** These read the primary only, and none is made false by this change: - the unmanaged-tables sweep (`collectUnmanagedTables`); - `os migrate multi-value-columns`' drift read; - `os serve`'s artifact boot migration gate (`findSqlDriverForKernel`). carrier: #22580's PR touches the sweep's call site; otherwise none. - **Not merged with `origin/main`.** Five commits landed since `d8830c2805`, none in this diff's files. CI builds the merge ref. --- _Generated by [Claude Code](https://claude.ai/code/session_019SvPnd2bzECRNmAU9i6E4k)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 91f46d0 commit fd2768a

14 files changed

Lines changed: 850 additions & 52 deletions
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
fix(cli): `os migrate plan` / `apply` plan each object in the database it lives in, the `telemetry` sibling included (#22579)
6+
7+
A development `os serve` boot on a file-backed SQLite database, and any boot with `OS_TELEMETRY_DB=<path>`, keeps lifecycle-classed system data (audit, telemetry and event objects such as `sys_audit_log`, `sys_activity`, `sys_metadata_audit`, `sys_job_run`, `sys_notification`) in a sibling `telemetry` database. The one-shot boot `os migrate` runs never opened that sibling, so every one of those objects resolved to the primary database: after a development boot, `os migrate plan` listed each as a table to create, and `os migrate apply` created each in the primary — empty tables beside the ones the served boot uses.
8+
9+
The migrate boot now provisions the sibling exactly when the serving boot would, through the same helper and under the same rule: `--dev` or `NODE_ENV=development` on a file-backed SQLite primary, or `OS_TELEMETRY_DB=<path>`, and never with `OS_TELEMETRY_DB=0`. `plan` and `apply` diff and apply every object against the database it lives in, and print the sibling under the database line (`Telemetry database: …`); in `--json` it is the new `telemetryDatabase` field, present only when a sibling is planned. `apply`'s confirmation names both databases. A `plan` against a sibling that does not exist yet lists its tables to create and creates no file. `os migrate unmapped-columns` reads a lifecycle-classed object from the sibling, and names it as the database.
10+
11+
A deployment with no sibling — production without `OS_TELEMETRY_DB`, or `OS_TELEMETRY_DB=0` — is unchanged. Run the migration with the `NODE_ENV` the deployment is served with: without `NODE_ENV=development` the plan describes a production `os serve`. Tables an earlier `os migrate apply` created in the primary for these objects are not removed.
12+
13+
This supersedes the "Known limit" in this release's `os migrate plan` / `apply` composition entry: the migration boot now provisions the `telemetry` database.
14+
15+
`os serve` loads the project's `.env*` files through the same function `os migrate` does; which files it reads, and in which mode, is unchanged.

‎content/docs/deployment/cli.mdx‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -887,6 +887,24 @@ Under `--json` the same answer is `database` plus `databaseSource`, whose `kind`
887887
`flag`, `process-env`, `env-file` (with its `variable` and `file`),
888888
`config-datasource` (with its `datasource`) or `default`.
889889
890+
The deployment's datasources are the serving boot's too. Where `os serve` keeps
891+
lifecycle-classed system data (audit, telemetry and event objects) in the `telemetry`
892+
sibling database — a development boot (`--dev`, or `NODE_ENV=development`) on a
893+
file-backed SQLite database, or any boot with `OS_TELEMETRY_DB=<path>` — `plan` and
894+
`apply` open that sibling too, and plan and apply those objects in it, never in the
895+
primary:
896+
897+
```text
898+
ℹ Database: data/app.db (OS_DATABASE_URL from .env)
899+
ℹ Telemetry database: data/app.telemetry.db (audit, telemetry and event objects — where os serve keeps them; OS_TELEMETRY_DB=0 turns it off)
900+
```
901+
902+
Under `--json` that is `telemetryDatabase`, present only when a sibling is planned.
903+
Run the migration with the `NODE_ENV` the deployment is served with: a plan run
904+
without `NODE_ENV=development` describes a production `os serve`, which keeps those
905+
objects in the primary unless `OS_TELEMETRY_DB` names a file. A `plan` against a
906+
sibling that does not exist yet lists its tables to create and leaves no file behind.
907+
890908
#### Nothing is applied before you confirm
891909
892910
Both commands boot your app to read its metadata. That boot writes no row and no

‎packages/cli/src/commands/migrate/apply.ts‎

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
import {
1919
bootSchemaStack,
2020
describeDatabaseSource,
21+
describeTelemetryDatabase,
2122
renderPlan,
2223
renderPendingSchemaWork,
2324
summarize,
@@ -202,7 +203,13 @@ export default class MigrateApply extends Command {
202203
// [#22581] The database this run reconciles and who named it, on every
203204
// payload below — the in-sync and refusal paths included, since "nothing
204205
// to apply" is only as good as the database it was read from.
205-
const target = { database: stack.dbLabel, databaseSource: stack.dbSource };
206+
// [#22579] …and the `telemetry` sibling lifecycle-classed objects are
207+
// reconciled in, when the serving boot keeps one.
208+
const target = {
209+
database: stack.dbLabel,
210+
databaseSource: stack.dbSource,
211+
...(stack.telemetryDatabase !== null ? { telemetryDatabase: stack.telemetryDatabase } : {}),
212+
};
206213

207214
// What the object set was composed from (#12938) — printed BEFORE the
208215
// in-sync early return below, not with the plan. "Already in sync" over a
@@ -212,11 +219,14 @@ export default class MigrateApply extends Command {
212219
// above it for the same reason.
213220
if (!flags.json) {
214221
printInfo(`Database: ${chalk.white(stack.dbLabel)} ${chalk.dim(`(${describeDatabaseSource(stack.dbSource)})`)}`);
222+
if (stack.telemetryDatabase !== null) printInfo(describeTelemetryDatabase(stack.telemetryDatabase));
215223
for (const note of stack.composition.notes) console.log(chalk.dim(` ${note}`));
216224
console.log('');
217225
}
218226

219-
const drift = await stack.driver.detectManagedDrift();
227+
// [#22579] Over every datasource this run reconciles: the primary and the
228+
// `telemetry` sibling, each entry applied where it was found.
229+
const drift = await stack.detectManagedDrift();
220230
const grouped = groupByCategory(drift);
221231
// Additive work the boot sync was held back from doing. Not drift — it
222232
// is what `initObjects` does on its own — but it IS a change to the
@@ -422,7 +432,10 @@ export default class MigrateApply extends Command {
422432
printWarning('Confirmation required. Re-run with --yes to apply, or use "os migrate plan" to preview.');
423433
return;
424434
}
425-
const ok = await confirm(chalk.bold(`\nApply ${totalIntended} change(s) to ${stack.dbLabel}? [y/N] `));
435+
const targets = stack.telemetryDatabase !== null
436+
? `${stack.dbLabel} and its telemetry database ${stack.telemetryDatabase}`
437+
: stack.dbLabel;
438+
const ok = await confirm(chalk.bold(`\nApply ${totalIntended} change(s) to ${targets}? [y/N] `));
426439
if (!ok) { printInfo('Aborted — no changes made.'); return; }
427440
}
428441

@@ -431,7 +444,7 @@ export default class MigrateApply extends Command {
431444
// just-created table matches metadata by construction, so the two sets
432445
// never overlap.
433446
const created = await stack.flushSchemaDdl();
434-
const { applied, skipped } = await stack.driver.applyMigrationEntries(drift, { allowDestructive });
447+
const { applied, skipped } = await stack.applyMigrationEntries(drift, { allowDestructive });
435448

436449
if (flags.json) {
437450
await emitJson({

‎packages/cli/src/commands/migrate/data-commands.absent-database.integration.test.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,14 @@ function runCli(argv: string[], dbFile: string): Promise<Run> {
179179
// The fixed key `serve-process.ts` hands its children, so no boot
180180
// mints and persists a crypto key into this runner's home directory.
181181
OS_SECRET_KEY: '0e2e'.repeat(16),
182+
// [#22579] The deployment the controls model keeps every object in
183+
// ONE database: their served-shape boots are in-process stacks, which
184+
// provision no `telemetry` sibling. The source entry pins
185+
// `NODE_ENV=development`, under which the commands' boot mirrors a
186+
// development `os serve` — one that keeps lifecycle-classed objects
187+
// (`sys_audit_log`, `sys_activity`) in a sibling — so the fixture
188+
// declares its own topology, as a deployment with no sibling does.
189+
OS_TELEMETRY_DB: '0',
182190
}),
183191
stdio: ['ignore', 'pipe', 'pipe'],
184192
},

‎packages/cli/src/commands/migrate/plan.boot-parity.integration.test.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,10 @@ import Database from 'better-sqlite3';
4646
* and no object declares it. A new raw-DDL table fails this pin and has to be
4747
* named here with the same reason.
4848
*
49-
* Both sides run with `OS_TELEMETRY_DB=0`: a development boot's `telemetry`
50-
* sibling (ADR-0057 §3.6) is a second database the one-shot boot does not
51-
* provision — a known, separate gap — and the pin compares object sets on one.
49+
* Both sides run with `OS_TELEMETRY_DB=0`, so the pin compares object sets on
50+
* one database. A development boot's `telemetry` sibling (ADR-0057 §3.6) — a
51+
* second database both boots provision — is
52+
* `plan.telemetry-sibling.integration.test.ts`'s (#22579).
5253
*/
5354

5455
const HERE = dirname(fileURLToPath(import.meta.url));
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect, afterAll } from 'vitest';
4+
import { spawnSync } from 'node:child_process';
5+
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
6+
import { tmpdir } from 'node:os';
7+
import { dirname, join, resolve } from 'node:path';
8+
import { fileURLToPath } from 'node:url';
9+
import Database from 'better-sqlite3';
10+
11+
/**
12+
* [#22579] `os migrate plan` / `apply` plan every object against the database
13+
* it lives in — a lifecycle-classed one in the `telemetry` sibling a serving
14+
* boot keeps it in (ADR-0057 §3.6), never in the primary.
15+
*
16+
* ## The defect, measured on `main` `d8830c2805`
17+
*
18+
* A development `os serve` boot of an app declaring `requires: ['auth',
19+
* 'automation']` keeps nine objects in `boot.telemetry.db`: `sys_activity`,
20+
* `sys_audit_log`, `sys_automation_run`, `sys_flow_dispatch`,
21+
* `sys_http_delivery`, `sys_job_run`, `sys_metadata_audit`, `sys_notification`
22+
* and `sys_notification_delivery`. The one-shot migration boot never
23+
* provisioned that sibling, so every one of them resolved to the primary:
24+
* `plan` listed all nine as `create_table`, and `apply --yes` created all nine
25+
* in `boot.db` — empty orphans beside the tables the served boot uses.
26+
*
27+
* ## What is pinned
28+
*
29+
* - after a development boot, the plan names the sibling, lists no
30+
* lifecycle-classed object for the primary, and examines both databases'
31+
* objects; `apply` creates nothing, and neither database moves;
32+
* - the CONTROL: a deployment with no sibling (`OS_TELEMETRY_DB=0`) plans as
33+
* it always did — no sibling named, nothing pending;
34+
* - a plan with the sibling on over a deployment that has none yet plans the
35+
* lifecycle-classed objects into it, and brings no file into existence (the
36+
* read-only probe, #6743, holds for the sibling as for the primary).
37+
*
38+
* Each step runs through the public door — `serve`'s provision-and-exit
39+
* (`OS_MIGRATE_AND_EXIT=1`), then the commands — from the source entry, which
40+
* pins `NODE_ENV=development`: a development boot on both sides, as `os dev`
41+
* followed by `os migrate plan` in that environment is.
42+
*/
43+
44+
const HERE = dirname(fileURLToPath(import.meta.url));
45+
const CLI_ROOT = resolve(HERE, '..', '..', '..');
46+
const RUN_DEV = resolve(CLI_ROOT, 'bin', 'run-dev.js');
47+
const TSX = resolve(CLI_ROOT, '..', '..', 'node_modules', '.bin', 'tsx');
48+
const CASE_TIMEOUT_MS = 300_000;
49+
50+
/** The one table outside the object set (`PackageServicePlugin.start()`'s raw DDL; see the boot-parity pin). */
51+
const NOT_AN_OBJECT = ['sys_packages'];
52+
53+
const CONFIG = [
54+
'export default {',
55+
" manifest: { id: 'com.example.os22579sibling', name: 'sibling', version: '0.0.0', type: 'app' },",
56+
" requires: ['auth', 'automation'],",
57+
" objects: [{ name: 'os22579_sibling_app', fields: { title: { type: 'text' } } }],",
58+
'};',
59+
'',
60+
].join('\n');
61+
62+
const dirs: string[] = [];
63+
afterAll(() => {
64+
for (const d of dirs) {
65+
try { rmSync(d, { recursive: true, force: true }); } catch { /* best-effort */ }
66+
}
67+
});
68+
69+
interface Fixture {
70+
dir: string;
71+
primary: string;
72+
sibling: string;
73+
}
74+
75+
function fixture(): Fixture {
76+
const dir = mkdtempSync(join(tmpdir(), 'os-22579-sibling-'));
77+
dirs.push(dir);
78+
mkdirSync(join(dir, 'dist'), { recursive: true });
79+
writeFileSync(join(dir, 'objectstack.config.ts'), CONFIG);
80+
// `resolveTelemetryDbPath`'s dev default: `<primary>.telemetry.<ext>`.
81+
return { dir, primary: join(dir, 'boot.db'), sibling: join(dir, 'boot.telemetry.db') };
82+
}
83+
84+
/** The child's environment: the fixture's database, nothing inherited that retargets it or the sibling. */
85+
function childEnv(f: Fixture, extra: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv {
86+
const env: NodeJS.ProcessEnv = { ...process.env };
87+
for (const key of [
88+
'OS_DATABASE_URL', 'DATABASE_URL', 'TURSO_DATABASE_URL', 'OS_DATABASE_DRIVER', 'OS_HOME',
89+
'OS_TELEMETRY_DB', 'AUTH_SECRET', 'BETTER_AUTH_SECRET',
90+
]) {
91+
delete env[key];
92+
}
93+
return {
94+
...env,
95+
OS_DATABASE_URL: `file:${f.primary}`,
96+
OS_ARTIFACT_PATH: join(f.dir, 'dist', 'objectstack.json'),
97+
OS_CLOUD_URL: 'off',
98+
OS_TELEMETRY_DISABLED: '1',
99+
OS_AUTH_SECRET: 'os22579-sibling-secret-at-least-32-characters',
100+
OS_LOG_LEVEL: 'warn',
101+
...extra,
102+
};
103+
}
104+
105+
/** `os serve`'s own provision-and-exit: the full composition schema-synced into the fixture's database(s). */
106+
function serveBoot(f: Fixture, extra: NodeJS.ProcessEnv = {}): void {
107+
const boot = spawnSync(TSX, [RUN_DEV, 'serve', '--no-server', '--no-ui', '--no-console', '--port', '0'], {
108+
cwd: f.dir, env: childEnv(f, { ...extra, OS_MIGRATE_AND_EXIT: '1' }), encoding: 'utf8', timeout: 240_000,
109+
});
110+
expect(boot.status, `the serving boot did not provision the database:\n${`${boot.stdout}\n${boot.stderr}`.slice(-800)}`)
111+
.toBe(0);
112+
}
113+
114+
function command(f: Fixture, argv: string[], extra: NodeJS.ProcessEnv = {}): { exit: number | null; payload: any } {
115+
const run = spawnSync(TSX, [RUN_DEV, 'migrate', ...argv], { cwd: f.dir, env: childEnv(f, extra), encoding: 'utf8', timeout: 240_000 });
116+
let payload: any;
117+
try { payload = JSON.parse(run.stdout); } catch { payload = { unparsed: `${run.stdout}\n${run.stderr}`.slice(-800) }; }
118+
return { exit: run.status, payload };
119+
}
120+
121+
/** A database's tables, a rotation shard read as its object. */
122+
function tables(file: string): string[] {
123+
const db = new Database(file, { readonly: true });
124+
try {
125+
const names = (db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").all() as Array<{ name: string }>)
126+
.map((t) => t.name.replace(/__r\d{6,8}$/, ''));
127+
return [...new Set(names)].sort();
128+
} finally {
129+
db.close();
130+
}
131+
}
132+
133+
/** The schema and every row of every table — what "the database did not move" is read against. */
134+
function state(file: string): unknown {
135+
const db = new Database(file, { readonly: true });
136+
try {
137+
const schema = db.prepare('SELECT type, name, sql FROM sqlite_master ORDER BY type, name').all() as Array<{ type: string; name: string }>;
138+
const rows: Record<string, unknown> = {};
139+
for (const entry of schema) {
140+
if (entry.type !== 'table' || entry.name.startsWith('sqlite_')) continue;
141+
rows[entry.name] = db.prepare(`SELECT * FROM "${entry.name}" ORDER BY rowid`).all();
142+
}
143+
return { schema, rows };
144+
} finally {
145+
db.close();
146+
}
147+
}
148+
149+
const pendingTables = (payload: any): string[] => (payload.pending ?? []).map((p: any) => `${p.kind}:${p.table}`).sort();
150+
151+
describe('os migrate plan / apply plan each object against the database it lives in (#22579)', () => {
152+
it('after a development boot: the sibling is named and planned, nothing lifecycle-classed is planned for the primary, apply creates nothing', () => {
153+
const f = fixture();
154+
serveBoot(f);
155+
156+
// The serving boot's layout — the thing the plan must describe.
157+
expect(existsSync(f.sibling), 'the development boot provisioned no telemetry sibling: nothing to plan against').toBe(true);
158+
const primaryTables = tables(f.primary);
159+
const siblingTables = tables(f.sibling);
160+
expect(siblingTables).toEqual(expect.arrayContaining(['sys_audit_log', 'sys_metadata_audit']));
161+
expect(primaryTables.filter((t) => siblingTables.includes(t)), 'one table in both databases').toEqual([]);
162+
const before = { primary: state(f.primary), sibling: state(f.sibling) };
163+
164+
const plan = command(f, ['plan', '--json']);
165+
expect(plan.exit, JSON.stringify(plan.payload).slice(0, 800)).toBe(0);
166+
// Nothing to create anywhere — above all no lifecycle-classed object for the primary.
167+
expect(pendingTables(plan.payload)).toEqual([]);
168+
expect(plan.payload.changes).toEqual([]);
169+
expect(plan.payload.telemetryDatabase).toBe(f.sibling);
170+
expect(plan.payload.composition?.coverage?.unexaminedObjects).toBe(0);
171+
// Both databases' objects examined: their tables, less the raw-DDL one.
172+
expect(plan.payload.managedTables).toBe(primaryTables.length - NOT_AN_OBJECT.length + siblingTables.length);
173+
// A plan writes nothing, to either database.
174+
expect({ primary: state(f.primary), sibling: state(f.sibling) }).toEqual(before);
175+
176+
const apply = command(f, ['apply', '--yes', '--json']);
177+
expect(apply.exit, JSON.stringify(apply.payload).slice(0, 800)).toBe(0);
178+
expect(apply.payload.created).toEqual([]);
179+
expect(apply.payload.applied).toEqual([]);
180+
expect(apply.payload.telemetryDatabase).toBe(f.sibling);
181+
// No orphan in the primary, and nothing moved in either database.
182+
expect(tables(f.primary)).toEqual(primaryTables);
183+
expect({ primary: state(f.primary), sibling: state(f.sibling) }).toEqual(before);
184+
}, CASE_TIMEOUT_MS);
185+
186+
it('the control — a deployment with no sibling plans as it did; with the sibling on, an absent one is planned and not created', () => {
187+
const f = fixture();
188+
serveBoot(f, { OS_TELEMETRY_DB: '0' });
189+
expect(existsSync(f.sibling)).toBe(false);
190+
const primaryTables = tables(f.primary);
191+
expect(primaryTables).toEqual(expect.arrayContaining(['sys_audit_log', 'sys_metadata_audit']));
192+
193+
// No sibling: no sibling named, nothing pending, every object on the primary.
194+
const control = command(f, ['plan', '--json'], { OS_TELEMETRY_DB: '0' });
195+
expect(control.exit, JSON.stringify(control.payload).slice(0, 800)).toBe(0);
196+
expect('telemetryDatabase' in control.payload).toBe(false);
197+
expect(pendingTables(control.payload)).toEqual([]);
198+
expect(control.payload.changes).toEqual([]);
199+
expect(control.payload.managedTables).toBe(primaryTables.length - NOT_AN_OBJECT.length);
200+
201+
// The sibling on, over this deployment: the serving boot would now keep
202+
// the lifecycle-classed objects in a sibling that does not exist yet, so
203+
// the plan creates them there — and, being a dry run, creates no file.
204+
const before = state(f.primary);
205+
const plan = command(f, ['plan', '--json']);
206+
expect(plan.exit, JSON.stringify(plan.payload).slice(0, 800)).toBe(0);
207+
const pending = pendingTables(plan.payload);
208+
expect(pending).toEqual(expect.arrayContaining(['create_table:sys_audit_log', 'create_table:sys_metadata_audit']));
209+
expect(pending.every((p) => p.startsWith('create_table:')), pending.join(', ')).toBe(true);
210+
expect(plan.payload.telemetryDatabase).toBe(f.sibling);
211+
expect(existsSync(f.sibling), 'a dry run brought the telemetry sibling into existence').toBe(false);
212+
expect(state(f.primary)).toEqual(before);
213+
}, CASE_TIMEOUT_MS);
214+
});

0 commit comments

Comments
 (0)