Skip to content

Commit 7d00346

Browse files
committed
fix(cli): os dev's in-place upgrade line names no origin for a refused database
The line os dev prints before it upgrades a development database the organization-ownership boot guard refused said the database was "still in the v17 organization-ownership shape" for every refusal. It now names no origin and points at the guard's refusal printed above it; the copy's path stays on the verdict line. Claude-Session: https://claude.ai/code/session_01LmjU2x1faLZ39DVTFyVca6 Co-authored-by: Claude <noreply@anthropic.com>
1 parent b42c621 commit 7d00346

2 files changed

Lines changed: 161 additions & 25 deletions

File tree

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import { stripVTControlCharacters } from 'node:util';
5+
import { upgradeRefusedDevelopmentDatabase } from './dev.js';
6+
import type { DevelopmentUpgradeResult } from '../utils/dev-organization-ownership-upgrade.js';
7+
8+
/**
9+
* #24833 — what `os dev` prints when the organization-ownership boot guard
10+
* refused its `serve` child and it upgrades the development database in place.
11+
*
12+
* The coordinator's `recover` in `dev.ts` calls `upgradeRefusedDevelopmentDatabase`
13+
* with `console.log` and the real upgrade. Here the upgrade is faked: what it
14+
* does is `utils/dev-organization-ownership-upgrade.test.ts`'s subject, and the
15+
* line it prints is this file's.
16+
*
17+
* ## The defect
18+
*
19+
* The first line used to say the database was "still in the v17
20+
* organization-ownership shape" for every refusal. The guard refuses a database
21+
* whose marker is absent, unverified, unreadable or from an older shape
22+
* generation, or whose shape or birth marker it could not read or write, and
23+
* only the absent marker means "17.x or an 18.0 pre-release created it". So the
24+
* line names no origin at all: the guard's own refusal, printed just above it
25+
* by the failed boot, does.
26+
*
27+
* ⛔ The sentence is not pinned verbatim. The pins are what it must not say (an
28+
* origin) and the things it must name: the refusal above, the in-place
29+
* upgrade, the copy. The copy's path is the verdict line's, from the result.
30+
*/
31+
32+
const COPY = '.objectstack/data/backups/organization-ownership-2026-10-20T15-33-45-773Z';
33+
34+
/** Drive the seam with a fake upgrade, recording every printed line (colour stripped) and when the upgrade ran. */
35+
async function drive(result: DevelopmentUpgradeResult): Promise<{ lines: string[]; printedBeforeUpgrade: number; ok: boolean }> {
36+
const lines: string[] = [];
37+
let printedBeforeUpgrade = -1;
38+
const ok = await upgradeRefusedDevelopmentDatabase({
39+
print: (line) => lines.push(stripVTControlCharacters(line)),
40+
upgrade: async () => {
41+
printedBeforeUpgrade = lines.length;
42+
return result;
43+
},
44+
});
45+
return { lines, printedBeforeUpgrade, ok };
46+
}
47+
48+
const upgraded: DevelopmentUpgradeResult = {
49+
ok: true,
50+
backupDir: COPY,
51+
warnings: ['8 shipped permission set row(s) …'],
52+
lines: [`Upgraded the development database to the v18 organization ownership. The database as it was before the upgrade is copied to ${COPY}, beside the ceremony's log. The plan and the report are there too.`],
53+
};
54+
55+
describe('#24833 os dev — the line before the in-place upgrade of a refused development database', () => {
56+
it('names no origin for the database: no "v17", no 17.x, no 18.0 pre-release', async () => {
57+
const { lines } = await drive(upgraded);
58+
const notice = lines[0]!;
59+
expect(notice, notice).not.toMatch(/v17/i);
60+
expect(notice, notice).not.toMatch(/\b17\.x\b|\b18\.0\b|pre-?release|next\.\*|created/i);
61+
});
62+
63+
it('points at the refusal above, and says os dev upgrades the database in place keeping a copy first', async () => {
64+
const { lines, printedBeforeUpgrade } = await drive(upgraded);
65+
const notice = lines[0]!;
66+
expect(notice, notice).toMatch(/refus/i);
67+
expect(notice, notice).toMatch(/above/i);
68+
expect(notice, notice).toMatch(/os dev/);
69+
expect(notice, notice).toMatch(/in place/);
70+
expect(notice, notice).toMatch(/copy first/);
71+
// Printed BEFORE the copy and the ceremony run, which take seconds.
72+
expect(printedBeforeUpgrade).toBe(1);
73+
});
74+
75+
it('the copy\'s path stays on the verdict line, after the warnings, and the server boots again', async () => {
76+
const { lines, ok } = await drive(upgraded);
77+
expect(ok).toBe(true);
78+
expect(lines.slice(1)).toEqual([
79+
' ⚠ 8 shipped permission set row(s) …',
80+
` ✓ ${upgraded.lines[0]}`,
81+
]);
82+
expect(lines[2]).toContain(COPY);
83+
});
84+
85+
it('an upgrade that did not finish prints its refusal lines and boots nothing', async () => {
86+
const { lines, ok } = await drive({
87+
ok: false,
88+
backupDir: COPY,
89+
warnings: [],
90+
lines: ['Refused (sys_mystery): …', `The upgrade plan was refused, and nothing was changed. The copy is at ${COPY}.`],
91+
});
92+
expect(ok).toBe(false);
93+
expect(lines.slice(1).every((l) => l.startsWith(' ✗ '))).toBe(true);
94+
expect(lines).toHaveLength(3);
95+
});
96+
});

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

Lines changed: 65 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,11 @@ import {
1616
assessArtifactStaleness,
1717
formatMtimeGap,
1818
} from '../utils/dev-restart.js';
19-
import { isOrganizationOwnershipRequiredMessage, upgradeDevelopmentDatabase } from '../utils/dev-organization-ownership-upgrade.js';
19+
import {
20+
isOrganizationOwnershipRequiredMessage,
21+
upgradeDevelopmentDatabase,
22+
type DevelopmentUpgradeResult,
23+
} from '../utils/dev-organization-ownership-upgrade.js';
2024
import { childEnvWithResolvedArtifact } from '../utils/internal-artifact-channel.js';
2125
import { makeDirectorySync } from '../utils/make-directory.js';
2226
// THE artifact precedence, written once (#21501) — shared with `start`, and
@@ -247,6 +251,42 @@ export function devWatchActive(opts: { watch: boolean; artifact?: string; config
247251
return opts.watch && !opts.artifact && opts.configExists;
248252
}
249253

254+
/**
255+
* What `os dev` prints when the organization-ownership boot guard refused its
256+
* `serve` child, and the upgrade it then runs (ADR-0131 D10, development
257+
* exception; `utils/dev-organization-ownership-upgrade.ts`). The coordinator's
258+
* `recover` calls this once per session; `upgrade` copies the development
259+
* database, runs the ceremony on it and answers its verdict.
260+
*
261+
* ⛔ The first line names no origin for the database. Every refusal the guard
262+
* throws reaches this path — a marker absent, unverified, unreadable or from an
263+
* older shape generation, a shape read that failed, a birth write that failed —
264+
* and only the absent marker means "17.x or an 18.0 pre-release created it". The
265+
* guard's own refusal, printed just above this line by the failed boot, already
266+
* names what it found, so this line points at it rather than carrying a second
267+
* copy of that wording, which would be wrong for the other five.
268+
*
269+
* The copy's path is printed by the verdict lines below, from the upgrade's own
270+
* result.
271+
*
272+
* @returns whether the upgrade finished and the server may boot again.
273+
*/
274+
export async function upgradeRefusedDevelopmentDatabase(io: {
275+
/** Writes one line for the developer. */
276+
print: (line: string) => void;
277+
/** Copies the development database, then plans and applies the ceremony on it. */
278+
upgrade: () => Promise<DevelopmentUpgradeResult>;
279+
}): Promise<boolean> {
280+
io.print(chalk.yellow(
281+
' ⟳ The organization-ownership boot guard refused this development database (the refusal above), so '
282+
+ 'os dev upgrades it in place, keeping a copy first…',
283+
));
284+
const result = await io.upgrade();
285+
for (const warning of result.warnings) io.print(chalk.yellow(` ⚠ ${warning}`));
286+
for (const line of result.lines) io.print(result.ok ? chalk.green(` ✓ ${line}`) : chalk.red(` ✗ ${line}`));
287+
return result.ok;
288+
}
289+
250290
export default class Dev extends Command {
251291
static override description =
252292
'Start development mode — watch sources, rebuild the artifact, and restart the server on change';
@@ -725,11 +765,12 @@ export default class Dev extends Command {
725765
// load is the one path that applies the whole artifact, so the
726766
// coordinator restarts the serve child after each successful rebuild
727767
// (nodemon-style, default-on; opt out with --no-restart).
728-
// [ADR-0131 D10, development exception] A serve child refused for the
729-
// v17 organization-ownership shape says so before it exits; the
768+
// [ADR-0131 D10, development exception] A serve child the
769+
// organization-ownership boot guard refused says so before it exits; the
730770
// coordinator then asks `recover` below, which copies the development
731771
// database, runs the ceremony on it and boots the server again — once
732-
// per session (`utils/dev-organization-ownership-upgrade.ts`).
772+
// per session (`utils/dev-organization-ownership-upgrade.ts`,
773+
// `upgradeRefusedDevelopmentDatabase` above for what it prints).
733774
let ownershipUpgrade: 'none' | 'pending' | 'attempted' = 'none';
734775
let upgradedBoot = false;
735776
const spawnServeChild = (info: { restartIndex: number }) => {
@@ -818,28 +859,27 @@ export default class Dev extends Command {
818859
recover: async () => {
819860
if (ownershipUpgrade !== 'pending') return false;
820861
ownershipUpgrade = 'attempted';
821-
console.log(chalk.yellow(
822-
' ⟳ This development database is still in the v17 organization-ownership shape — upgrading it in place '
823-
+ '(a copy is kept first)…',
824-
));
825-
const { resolveStandaloneDatabase } = await import('@objectstack/runtime');
826-
let sqliteFile: string | null = null;
827-
try {
828-
sqliteFile = resolveStandaloneDatabase({ projectRoot: process.cwd(), databaseUrl: effectiveDb }).sqliteFile;
829-
} catch {
830-
sqliteFile = null;
831-
}
832-
const result = upgradeDevelopmentDatabase({
833-
binPath,
834-
databaseUrl: effectiveDb,
835-
sqliteFile,
836-
env: localEnv,
837-
cwd: process.cwd(),
862+
const ok = await upgradeRefusedDevelopmentDatabase({
863+
print: (line) => console.log(line),
864+
upgrade: async () => {
865+
const { resolveStandaloneDatabase } = await import('@objectstack/runtime');
866+
let sqliteFile: string | null = null;
867+
try {
868+
sqliteFile = resolveStandaloneDatabase({ projectRoot: process.cwd(), databaseUrl: effectiveDb }).sqliteFile;
869+
} catch {
870+
sqliteFile = null;
871+
}
872+
return upgradeDevelopmentDatabase({
873+
binPath,
874+
databaseUrl: effectiveDb,
875+
sqliteFile,
876+
env: localEnv,
877+
cwd: process.cwd(),
878+
});
879+
},
838880
});
839-
for (const warning of result.warnings) console.log(chalk.yellow(` ⚠ ${warning}`));
840-
for (const line of result.lines) console.log(result.ok ? chalk.green(` ✓ ${line}`) : chalk.red(` ✗ ${line}`));
841-
if (result.ok) upgradedBoot = true;
842-
return result.ok;
881+
if (ok) upgradedBoot = true;
882+
return ok;
843883
},
844884
log: (line) => {
845885
const trimmed = line.trimStart();

0 commit comments

Comments
 (0)