diff --git a/.changeset/24651-boot-refusal-fresh-installs-only.md b/.changeset/24651-boot-refusal-fresh-installs-only.md new file mode 100644 index 00000000000..a3a38f2f744 --- /dev/null +++ b/.changeset/24651-boot-refusal-fresh-installs-only.md @@ -0,0 +1,18 @@ +--- +'@objectstack/runtime': patch +--- + +fix(runtime): the organization-ownership boot refusals say what 18.0 supports for a v17 database and name no step of `os migrate organization-ownership`, which 18.0 refuses on every database + +Clause-②: no (refusal text only: the refusal code `ORGANIZATION_OWNERSHIP_MIGRATION_REQUIRED`, the refused tables and the boot outcome do not move) + +In 18.0, `os migrate organization-ownership` refuses plan and apply on every database (`NOT_IMPLEMENTED`): 18.0 supports fresh installs only. Three branches of the v18 boot refusal still told the operator to run that command's plan and its `--apply --plan-file PLAN --backup-taken`, so following them led to a second refusal. + +Each branch now closes on the sentence `os migrate apply`'s drop gate and `os dev` already use: "18.0 supports fresh installs only and does not upgrade a v17 database: keep a v17 database on a 17.x release until the 18.x release that carries the upgrade path, and upgrade it there." + +- **A table still in the v17 shape** (an object that declares no organization column, over a table that still carries `organization_id`). This branch already said that 18.0 does not upgrade a v17 database. It no longer follows that with the command's plan and apply steps. +- **No table left in the v17 shape, and the ceremony's marker is not verified.** The refusal no longer prescribes a re-plan and a re-apply, which 18.0 cannot run. +- **No table left in the v17 shape, and the marker could not be read.** The refusal now says to fix the read and start again: a database no ceremony ran on boots once the read answers. It no longer prescribes a re-plan and a re-apply. +- **The column read itself failed.** The refusal still says to fix the read. It no longer offers the command as the alternative. + +A development boot (`OrganizationOwnershipBootGuardPlugin({ dev: true })`, as `os serve --dev` composes it) says "a v17 development database" in place of "a v17 database". Before, it named the command with `--dev`. diff --git a/docs/qa/platform-checklist/areas/platform-core.json b/docs/qa/platform-checklist/areas/platform-core.json index 1eeb440c891..ee012882385 100644 --- a/docs/qa/platform-checklist/areas/platform-core.json +++ b/docs/qa/platform-checklist/areas/platform-core.json @@ -3042,10 +3042,10 @@ }, { "id": "platform-core.v18-boot-refusals", - "title": "A v18 server refuses to boot rather than serve wrongly: against a 17.x-shaped database (a physical organization_id on a non-tenant table, no verified ADR-0131 ceremony marker) it stops before listening with ORGANIZATION_OWNERSHIP_MIGRATION_REQUIRED naming the tables and the command, and with security on but no identity objects it stops with AuthzIdentityObjectsMissingError", + "title": "A v18 server refuses to boot rather than serve wrongly: against a 17.x-shaped database (a physical organization_id on a non-tenant table, no verified ADR-0131 ceremony marker) it stops before listening with ORGANIZATION_OWNERSHIP_MIGRATION_REQUIRED, naming the tables and saying that 18.0 supports fresh installs only, and with security on but no identity objects it stops with AuthzIdentityObjectsMissingError", "since": "v18", "status": "active", - "revision": 3, + "revision": 4, "priority": "P0", "surface": "cli", "personas": [ @@ -3070,9 +3070,9 @@ "evidence": "the vitest summary line per pin file, with the package name and the subject sha" }, { - "clause": "the v18 serve against the v17 DB exits 1 before kernel:listening with the organization-ownership refusal, which names the tables and `os migrate organization-ownership`. ORGANIZATION_OWNERSHIP_MIGRATION_REQUIRED is that refusal's `code` (packages/runtime/src/organization-ownership-boot-guard.ts#OrganizationOwnershipBootRefusal) and the boot never prints it — `os serve` prints the refusal's message only — so the code is the pins' evidence (acceptance[0]), not this log's", + "clause": "the v18 serve against the v17 DB exits 1 before kernel:listening with the organization-ownership refusal, which names the tables and ends with the 18.0 sentence (\"18.0 supports fresh installs only and does not upgrade a v17 database: keep a v17 database on a 17.x release until the 18.x release that carries the upgrade path, and upgrade it there.\"), and names neither `os migrate organization-ownership` nor any step of it (no `--out PLAN`, no `--apply --plan-file`): 18.0 refuses that command on every database (NOT_IMPLEMENTED). ORGANIZATION_OWNERSHIP_MIGRATION_REQUIRED is that refusal's `code` (packages/runtime/src/organization-ownership-boot-guard.ts#OrganizationOwnershipBootRefusal) and the boot never prints it — `os serve` prints the refusal's message only — so the code is the pins' evidence (acceptance[0]), not this log's", "oracle": "log", - "verify": "boot output + exit code + no listening line: the printed refusal names each table still carrying organization_id and the `os migrate organization-ownership` command. ⛔ Do not grep the log for the code literal — it is not printed (packages/cli/src/commands/serve.ts prints the refusal's message)", + "verify": "boot output + exit code + no listening line: the printed refusal names each table still carrying organization_id and ends with \"18.0 supports fresh installs only and does not upgrade a v17 database: keep a v17 database on a 17.x release until the 18.x release that carries the upgrade path, and upgrade it there.\"; the refusal excerpt has 0 hits each for `--out PLAN`, `--plan-file` and `os migrate organization-ownership`. ⛔ Do not grep the log for the code literal — it is not printed (packages/cli/src/commands/serve.ts prints the refusal's message)", "evidence": "the refusal excerpt" }, { @@ -3123,6 +3123,12 @@ "date": "2026-10-19", "change": "steps[1], acceptance[1] (clause, verify) and acceptance[3].verify corrected — two checklist-accuracy findings of the 18.0 QA sweep (QA run #24557, platform-core.v18-boot-refusals; wave anchor #24579 §3). steps[1] booted `os dev` with `services: {auth: false}`, which is not a stack key (packages/spec/src/stack.zod.ts), so the run recorded acceptance[3] blocked(fixture); a stock boot composes the security plugin only beside AuthPlugin (packages/core/src/stack-auth.ts resolvePlatformAuthComposition), so the step now names a scratch config that mounts SecurityPlugin itself with the auth tier off, marked as not yet replayed live, and acceptance[3] names its pin as the fallback. acceptance[1] said the boot exits with ORGANIZATION_OWNERSHIP_MIGRATION_REQUIRED on a log oracle; that literal is only the refusal error's code (OrganizationOwnershipBootRefusal) and serve.ts prints the message alone, so the log oracle now reads the named tables and command and the code is left to the pins. acceptance[2] and steps[0] (#24592) are unchanged", "ref": "#24632" + }, + { + "revision": 4, + "date": "2026-10-19", + "change": "title and acceptance[1] (clause, verify) re-worded for 18.0's boot refusal text (card #24651). In 18.0 `os migrate organization-ownership` refuses plan and apply on every database (NOT_IMPLEMENTED: 18.0 supports fresh installs only), so the boot refusal no longer names that command or its `--out PLAN` / `--apply --plan-file PLAN --backup-taken` steps. Every branch now ends with \"18.0 supports fresh installs only and does not upgrade a v17 database: keep a v17 database on a 17.x release until the 18.x release that carries the upgrade path, and upgrade it there.\" (packages/runtime/src/organization-ownership-boot-guard.ts, freshInstallsOnly; `os serve --dev` says \"a v17 development database\"). The clause asserted that the refusal names the command, so a run against the new text would have recorded FAIL on a correct product. It now asserts the tables, that sentence, and the absence of the command and its steps. The ⛔ on the code literal is kept. acceptance[0], acceptance[2], acceptance[3] and both steps are unchanged", + "ref": "#24682" } ] }, diff --git a/packages/qa/dogfood/test/organization-ownership-boot-refusal-order.dogfood.test.ts b/packages/qa/dogfood/test/organization-ownership-boot-refusal-order.dogfood.test.ts index d6ce5afc1e8..3f6cd8c38bb 100644 --- a/packages/qa/dogfood/test/organization-ownership-boot-refusal-order.dogfood.test.ts +++ b/packages/qa/dogfood/test/organization-ownership-boot-refusal-order.dogfood.test.ts @@ -16,7 +16,8 @@ // that declare no organization column) still carrying a physical // `organization_id`: // -// 1. the v18 boot is refused, naming the command — and the refused boot has +// 1. the v18 boot is refused, saying what 18.0 supports for a v17 database and +// naming no step of the command 18.0 refuses — and the refused boot has // ALREADY added the column the ceremony waits for: the guard judges in its // own `start()`, after the engine's `start()`, which is where the schema // sync runs (and before every plugin that writes rows at start, so the @@ -109,7 +110,7 @@ describe('[ADR-0131 D10 item 5] a 17.x database upgrades through the boot refusa rmSync(dir, { recursive: true, force: true }); }); - it('1. a v17-shape database is refused, naming the command', async () => { + it('1. a v17-shape database is refused, saying what 18.0 supports and naming no step of the refused command', async () => { const seed = new DatabaseSync(file); seed.exec(` CREATE TABLE sys_user_permission_set (id TEXT PRIMARY KEY, user_id TEXT, permission_set_id TEXT, organization_id TEXT); @@ -121,7 +122,8 @@ describe('[ADR-0131 D10 item 5] a 17.x database upgrades through the boot refusa refusal = refusalIn(await boot(file)); expect(refusal, 'the boot was refused for the v17 shape').toBeDefined(); - expect(String((refusal as Error).message)).toContain('os migrate organization-ownership'); + expect(String((refusal as Error).message)).toContain('18.0 supports fresh installs only and does not upgrade a v17 database'); + expect(String((refusal as Error).message)).not.toMatch(/os migrate organization-ownership|--plan-file|--out PLAN|--backup-taken/); expect((refusal as { tables?: string[] }).tables).toEqual(expect.arrayContaining(['sys_metadata', 'sys_migration_journal'])); }, 240_000); diff --git a/packages/runtime/src/organization-ownership-boot-guard.kernel.test.ts b/packages/runtime/src/organization-ownership-boot-guard.kernel.test.ts index 8ddb05f43f8..51d2e1578d0 100644 --- a/packages/runtime/src/organization-ownership-boot-guard.kernel.test.ts +++ b/packages/runtime/src/organization-ownership-boot-guard.kernel.test.ts @@ -16,7 +16,8 @@ * 3. a fresh database boots; * 4. a database the ceremony ran on to the end (no column left) whose marker * is NOT verified — its post-check found rows still without their - * organization — is refused, naming the re-run; + * organization — is refused, saying what 18.0 supports (it cannot re-run + * the ceremony); * 5. ⭐ a refused boot writes nothing: the plugins that write rows in their * own `start()` — `AppPlugin` (the inline seed) and plugin-auth's * identity-objects plugin (the Default Organization, `AuthPlugin`'s twin @@ -181,7 +182,7 @@ describe('the v18 boot refusal on a real kernel (ADR-0131 D10 item 5)', () => { await expect(engine.findOne('sys_migration', { where: { id: MARKER }, context: { isSystem: true } })).resolves.toBeNull(); }); - it('⭐ a ceremony that ran every step and did not verify (no v17 column left, marker unverified) is refused, naming the re-run', async () => { + it('⭐ a ceremony that ran every step and did not verify (no v17 column left, marker unverified) is refused, saying what 18.0 supports', async () => { const file = databaseFile(); const db = new DatabaseSync(file); writeMarker(db, null, 602); @@ -191,7 +192,10 @@ describe('the v18 boot refusal on a real kernel (ADR-0131 D10 item 5)', () => { expect(refusal?.code, String(error)).toBe(ORGANIZATION_OWNERSHIP_BOOT_REFUSAL_CODE); expect(refusal?.tables).toEqual([]); expect(refusal?.message).toMatch(/^The organization-ownership ceremony has not finished on this database: its marker/); - expect(refusal?.message).toContain("os migrate organization-ownership --apply --plan-file PLAN --backup-taken"); + // 18.0 cannot re-run the ceremony (its `os migrate organization-ownership` refuses on every + // database), so the refusal names no step of it and says what 18.0 supports instead. + expect(refusal?.message).toContain('18.0 supports fresh installs only and does not upgrade a v17 database'); + expect(refusal?.message).not.toMatch(/os migrate organization-ownership|--plan-file|--out PLAN|--backup-taken/); expect(kernel.getState()).not.toBe('running'); }); diff --git a/packages/runtime/src/organization-ownership-boot-guard.test.ts b/packages/runtime/src/organization-ownership-boot-guard.test.ts index 921e49e8d1a..87755e2c693 100644 --- a/packages/runtime/src/organization-ownership-boot-guard.test.ts +++ b/packages/runtime/src/organization-ownership-boot-guard.test.ts @@ -68,6 +68,25 @@ const marker = (verifiedAt: string | null, blocking = 0): void => { ); }; +/** + * What 18.0 supports for a v17 database: the sentence every refusal closes on, + * in the words `@objectstack/cli`'s other 18.0 refusals use (the drop gate of + * `os migrate apply`; `os dev`'s way on for a development database). + */ +const FRESH_INSTALLS_ONLY = + '18.0 supports fresh installs only and does not upgrade a v17 database: keep a v17 database on a 17.x release ' + + 'until the 18.x release that carries the upgrade path, and upgrade it there.'; +const FRESH_INSTALLS_ONLY_DEV = + '18.0 supports fresh installs only and does not upgrade a v17 database: keep a v17 development database on a ' + + '17.x release until the 18.x release that carries the upgrade path, and upgrade it there.'; +/** `os migrate organization-ownership` refuses on every database in 18.0: no refusal names it or a step of it. */ +const WITHDRAWN_COMMAND = /os migrate organization-ownership|--plan-file|--out PLAN|--backup-taken/; + +/** The message closes on `tail` (compared as a string, so a failure shows the diff). */ +function expectTail(message: string, tail: string): void { + expect(message.slice(-tail.length)).toBe(tail); +} + async function refusalOf(run: () => Promise): Promise { try { await run(); @@ -85,32 +104,26 @@ afterEach(() => { }); describe('the v18 boot refusal (ADR-0131 D10 item 5)', () => { - it('⭐ a v17-shape database is refused, naming the command and every table still carrying the column', async () => { + it('⭐ a v17-shape database is refused, naming every table still carrying the column', async () => { v17Shape(); const err = await refusalOf(() => assertOrganizationOwnershipMigrated(engine())); expect(isOrganizationOwnershipBootRefusal(err)).toBe(true); expect(err.code).toBe(ORGANIZATION_OWNERSHIP_BOOT_REFUSAL_CODE); expect(err.tables).toEqual(['sys_job', 'sys_metadata']); expect(err.message).toMatch(/still in the v17 organization-ownership shape: sys_job, sys_metadata/); - // The apply the refusal names is one the ceremony accepts: a reviewed plan file and the backup acknowledged. - expect(err.message).toMatch(/os migrate organization-ownership --apply --plan-file PLAN --backup-taken/); expect(err.message).toMatch(/the ceremony has not run on it/); - // In 18.0 the command it names refuses too, so the refusal names the release answer itself, ahead of the command. - const release = err.message.indexOf('18.0 does not upgrade a v17 database'); - expect(release).toBeGreaterThan(-1); - expect(err.message.indexOf('17.x release until the 18.x release', release)).toBeGreaterThan(release); - expect(release).toBeLessThan(err.message.indexOf("Run 'os migrate organization-ownership --out PLAN'")); + // What it prescribes is pinned per branch below: what 18.0 supports, and no step of the withdrawn command. }); - it('a development boot names the ceremony with --dev, so it boots the way the refused boot did', async () => { + it('a development boot speaks of a development database, as os dev does; a served boot does not', async () => { v17Shape(); const dev = await refusalOf(() => assertOrganizationOwnershipMigrated(engine(), undefined, { dev: true })); - expect(dev.message).toContain( - "Run 'os migrate organization-ownership --dev --out PLAN' to write and review its plan, take a backup, then run " + - "'os migrate organization-ownership --dev --apply --plan-file PLAN --backup-taken'", - ); + expect(dev.message).toContain(FRESH_INSTALLS_ONLY_DEV); + expect(dev.message).not.toMatch(WITHDRAWN_COMMAND); + expect(dev.message).not.toContain('--dev'); const served = await refusalOf(() => assertOrganizationOwnershipMigrated(engine())); - expect(served.message).not.toContain('--dev'); + expect(served.message).toContain(FRESH_INSTALLS_ONLY); + expect(served.message).not.toContain('development'); }); it('a marker that is not verified (an unfinished ceremony, or one whose post-check found discrepancies) is refused', async () => { @@ -152,10 +165,6 @@ describe('the v18 boot refusal (ADR-0131 D10 item 5)', () => { expect(err.code).toBe(ORGANIZATION_OWNERSHIP_BOOT_REFUSAL_CODE); expect(err.tables).toEqual([]); expect(err.message).toMatch(/^The organization-ownership ceremony has not finished on this database: its marker \(sys_migration 'adr-0131-organization-ownership'\) is not verified/); - expect(err.message).toContain( - "Resolve what the apply report names, then run 'os migrate organization-ownership --out PLAN' again, review it, run " + - "'os migrate organization-ownership --apply --plan-file PLAN --backup-taken', and start again.", - ); // A verified_at with blocking left is not verified either (`isDataMigrationFlagVerified`). db.exec('DELETE FROM sys_migration'); marker('2026-10-14T01:00:00Z', 3); @@ -250,4 +259,75 @@ describe('the v18 boot refusal (ADR-0131 D10 item 5)', () => { process.env = saved; } }); + + // 18.0 supports fresh installs only, and its `os migrate organization-ownership` + // refuses on every database, plan and apply alike. One pin per refusal branch: + // each closes on what 18.0 supports for a v17 database and names no step of that + // command. The code and the outcome (refused) are the ones the pins above hold. + describe('18.0: each branch says what 18.0 supports for a v17 database, and names no step of the withdrawn command', () => { + const unreadableMarker = () => ({ ...engine(), findOne: async () => { throw new Error('SQLITE_BUSY: database is locked'); } }); + + it('⭐ the v17 shape (a table still carries the column), whatever the marker says', async () => { + v17Shape(); + const absent = await refusalOf(() => assertOrganizationOwnershipMigrated(engine())); + marker(null); + const unverified = await refusalOf(() => assertOrganizationOwnershipMigrated(engine())); + const unreadable = await refusalOf(() => assertOrganizationOwnershipMigrated(unreadableMarker())); + expect([absent, unverified, unreadable].map((e) => e.tables)).toEqual([ + ['sys_job', 'sys_metadata'], ['sys_job', 'sys_metadata'], ['sys_job', 'sys_metadata'], + ]); + for (const err of [absent, unverified, unreadable]) { + expect(err.code).toBe(ORGANIZATION_OWNERSHIP_BOOT_REFUSAL_CODE); + expectTail(err.message, `The server refuses to start rather than serve v17 data with v18 semantics. ${FRESH_INSTALLS_ONLY}`); + expect(err.message).not.toMatch(WITHDRAWN_COMMAND); + } + }); + + it('⭐ a ceremony that ran every step and did not verify (no column left): 18.0 cannot re-run it', async () => { + migrated(); + marker(null, 602); + const err = await refusalOf(() => assertOrganizationOwnershipMigrated(engine())); + expect(err.code).toBe(ORGANIZATION_OWNERSHIP_BOOT_REFUSAL_CODE); + expectTail(err.message, `rather than serve rows of an organization-owned object with no organization. ${FRESH_INSTALLS_ONLY}`); + expect(err.message).not.toMatch(WITHDRAWN_COMMAND); + }); + + it('⭐ a ceremony marker that could not be read (no column left): fix the read first', async () => { + migrated(); + const err = await refusalOf(() => assertOrganizationOwnershipMigrated(unreadableMarker())); + expect(err.code).toBe(ORGANIZATION_OWNERSHIP_BOOT_REFUSAL_CODE); + expect(err.message).toMatch(/its marker \(sys_migration 'adr-0131-organization-ownership'\) could not be read/); + expectTail(err.message, `Fix the read, then start again. ${FRESH_INSTALLS_ONLY}`); + expect(err.message).not.toMatch(WITHDRAWN_COMMAND); + }); + + it('⭐ a column read that failed: fix the read first', async () => { + v17Shape(); + const err = await refusalOf(() => assertOrganizationOwnershipMigrated(engine({ failRead: true }))); + expect(err.code).toBe(ORGANIZATION_OWNERSHIP_BOOT_REFUSAL_CODE); + expect(err.tables).toEqual([]); + expect(err.message).toContain('(SQLITE_BUSY: database is locked)'); + expectTail(err.message, `Fix the read, then start again. ${FRESH_INSTALLS_ONLY}`); + expect(err.message).not.toMatch(WITHDRAWN_COMMAND); + }); + + it('a development boot says the same of a development database in every branch, and names no --dev command', async () => { + const dev = { dev: true }; + v17Shape(); + const shape = await refusalOf(() => assertOrganizationOwnershipMigrated(engine(), undefined, dev)); + const columnRead = await refusalOf(() => assertOrganizationOwnershipMigrated(engine({ failRead: true }), undefined, dev)); + db.close(); + db = new DatabaseSync(':memory:'); + migrated(); + const markerRead = await refusalOf(() => assertOrganizationOwnershipMigrated(unreadableMarker(), undefined, dev)); + marker(null, 602); + const rerun = await refusalOf(() => assertOrganizationOwnershipMigrated(engine(), undefined, dev)); + for (const err of [shape, columnRead, markerRead, rerun]) { + expect(err.code).toBe(ORGANIZATION_OWNERSHIP_BOOT_REFUSAL_CODE); + expectTail(err.message, FRESH_INSTALLS_ONLY_DEV); + expect(err.message).not.toMatch(WITHDRAWN_COMMAND); + expect(err.message).not.toContain('--dev'); + } + }); + }); }); diff --git a/packages/runtime/src/organization-ownership-boot-guard.ts b/packages/runtime/src/organization-ownership-boot-guard.ts index 957e0de7c06..65f1c9e290c 100644 --- a/packages/runtime/src/organization-ownership-boot-guard.ts +++ b/packages/runtime/src/organization-ownership-boot-guard.ts @@ -17,8 +17,11 @@ import { * objects and the `sys_metadata` family no longer declare still hold their * legacy rows, and nothing reads them. The ceremony that carries those rows, * `os migrate organization-ownership --apply`, is an operator's act, never a - * boot step (D10). So the runtime refuses to start until it has run, and says - * which command to run. + * boot step (D10). So the runtime refuses to start until it has run. In 18.0 + * that command refuses on every database (18.0 supports fresh installs only, + * ruling record 6259608309 on the v18 epic, item 1), so the refusal names no + * step of it: it says what 18.0 supports for a v17 database + * ({@link freshInstallsOnly}). * * ## What is refused * @@ -38,8 +41,8 @@ import { * `verified_at` NULL — rows of an organization-owned object still without * their organization. D10 item 5 refuses a database the ceremony has not * COMPLETED, and `verified_at` is the ceremony's completion marker, so this - * database is refused too, naming the re-run. Without a marker there is - * nothing to judge: a fresh database is never refused. + * database is refused too. Without a marker there is nothing to judge: a + * fresh database is never refused. * * - A FRESH database is never refused: no table it creates carries a retired * column, and no ceremony wrote a marker on it. @@ -100,19 +103,27 @@ export const ORGANIZATION_OWNERSHIP_BOOT_GUARD_PLUGIN_NAME = 'com.objectstack.or const ORG = 'organization_id'; /** - * The ceremony command the refusal names, spelled for the boot that refused: - * a development boot (`os dev`, `os serve --dev`) composes its security - * plugin, `.env*` files and `telemetry` sibling as a development boot, and the - * ceremony has to boot the same way to see the same database, so the command - * it names carries `--dev`. + * What 18.0 supports for a v17 database, the sentence every refusal here + * closes on. 18.0 supports fresh installs only, and its `os migrate + * organization-ownership` refuses on every database, plan and apply alike + * (`ORGANIZATION_OWNERSHIP_UPGRADE_UNAVAILABLE` in `@objectstack/cli`; ruling + * record 6259608309 on the v18 epic, item 1). So no refusal here names a step + * of that command: each says this instead, in the words the command's other + * 18.0 refusals use (`os migrate apply`'s drop gate, and `os dev`'s way on for + * a development database). The 18.x release that registers the ceremony names + * its steps here again. */ -function ceremonyCommand(dev: boolean | undefined): string { - return dev ? 'os migrate organization-ownership --dev' : 'os migrate organization-ownership'; +function freshInstallsOnly(dev: boolean | undefined): string { + return ( + '18.0 supports fresh installs only and does not upgrade a v17 database: keep a v17 ' + + `${dev ? 'development ' : ''}database on a 17.x release until the 18.x release that carries the upgrade path, ` + + 'and upgrade it there.' + ); } -/** How the boot that runs the guard was started — what the refusal's command must match. */ +/** How the boot that runs the guard was started — what the refusal's words must match. */ export interface OrganizationOwnershipBootGuardOptions { - /** A development boot (`os dev` / `os serve --dev`): the named ceremony command carries `--dev`. */ + /** A development boot (`os dev` / `os serve --dev`): the refusal speaks of a development database. */ dev?: boolean; } @@ -218,19 +229,20 @@ export async function findV17ShapeTables(engine: GuardEngine): Promise } /** - * The refusal text: what was found, that the server refuses to start, the - * command, and the 17.x way out. On a v17-shaped database it says first that - * 18.0 does not upgrade it, because in 18.0 the command it names refuses too, - * so the operator does not reach that answer in two hops. With no table left in the v17 shape (`tables` - * empty), the ceremony ran every step and its marker is not verified: the - * text says so and names the re-run instead. + * The refusal text: what was found, that the server refuses to start, and what + * 18.0 supports for a v17 database ({@link freshInstallsOnly}). On a + * v17-shaped database (`tables` not empty) that is the whole way on. With no + * table left in the v17 shape (`tables` empty) a ceremony ran every step and + * its marker is not verified, or the marker could not be read: an unread + * marker says to fix the read first, since a database no ceremony ran on boots + * once the read answers. */ export function organizationOwnershipRefusalMessage( tables: readonly string[], marker: 'absent' | 'unverified' | 'unreadable', opts: OrganizationOwnershipBootGuardOptions = {}, ): string { - const ceremony = ceremonyCommand(opts.dev); + const answer = freshInstallsOnly(opts.dev); if (tables.length === 0) { const state = marker === 'unreadable' ? `its marker (sys_migration '${ORGANIZATION_OWNERSHIP_MIGRATION_ID}') could not be read` @@ -238,9 +250,8 @@ export function organizationOwnershipRefusalMessage( 'still found rows the ceremony has not carried'; return ( `The organization-ownership ceremony has not finished on this database: ${state}. The server refuses to start ` + - 'rather than serve rows of an organization-owned object with no organization. Resolve what the apply report ' + - `names, then run '${ceremony} --out PLAN' again, review it, run '${ceremony} --apply --plan-file PLAN ` + - "--backup-taken', and start again." + 'rather than serve rows of an organization-owned object with no organization. ' + + `${marker === 'unreadable' ? 'Fix the read, then start again. ' : ''}${answer}` ); } const state = marker === 'absent' @@ -251,11 +262,7 @@ export function organizationOwnershipRefusalMessage( return ( `This database is still in the v17 organization-ownership shape: ${tables.join(', ')} still carr` + `${tables.length === 1 ? 'ies' : 'y'} a physical organization_id column, and ${state}. The server refuses to ` + - 'start rather than serve v17 data with v18 semantics. 18.0 does not upgrade a v17 database: keep this one on a ' + - '17.x release until the 18.x release that carries the upgrade path, and run these steps there. ' + - `Run '${ceremony} --out PLAN' to write and review its ` + - `plan, take a backup, then run '${ceremony} --apply --plan-file PLAN --backup-taken', and start again. ` + - 'To keep 17.x semantics, run a 17.x runtime.' + `start rather than serve v17 data with v18 semantics. ${answer}` ); } @@ -331,7 +338,6 @@ export async function assertOrganizationOwnershipMigrated( logger?: { info?: (msg: string) => void }, opts: OrganizationOwnershipBootGuardOptions = {}, ): Promise { - const ceremony = ceremonyCommand(opts.dev); let tables: string[]; try { tables = await findV17ShapeTables(engine); @@ -339,8 +345,7 @@ export async function assertOrganizationOwnershipMigrated( throw new OrganizationOwnershipBootRefusal( `The server refuses to start: it could not read whether this database is still in the v17 organization-ownership ` + `shape (${error instanceof Error ? error.message : String(error)}), and an unread answer is never taken for a ` + - `migrated database. Fix the read, or run the ceremony ('${ceremony} --out PLAN', then ` + - `'${ceremony} --apply --plan-file PLAN --backup-taken'), then start again.`, + `migrated database. Fix the read, then start again. ${freshInstallsOnly(opts.dev)}`, [], ); }