diff --git a/.changeset/24710-ownership-refusal-text-fresh-installs-only.md b/.changeset/24710-ownership-refusal-text-fresh-installs-only.md new file mode 100644 index 0000000000..a8afad52c7 --- /dev/null +++ b/.changeset/24710-ownership-refusal-text-fresh-installs-only.md @@ -0,0 +1,14 @@ +--- +'@objectstack/metadata-core': patch +'@objectstack/metadata-protocol': patch +--- + +fix(metadata-core, metadata-protocol): two refusals say what 18.0 supports for a v17 database, not `os migrate organization-ownership`, which 18.0 refuses on every database + +Clause-②: no (text only: no refusal code, outcome, accept set or served shape moves) + +In 18.0, `os migrate organization-ownership` refuses plan and apply on every database (`NOT_IMPLEMENTED`): 18.0 supports fresh installs only. Two refusals still told the operator to run it, so following them led to a second refusal. + +- **`LEGACY_ORGANIZATION_ROWS_UNREADABLE`** (`LegacyOrganizationRowsUnreadable`, `@objectstack/metadata-core`). It used to close with "Run `os migrate organization-ownership` on this database first." This read runs on every SQL database, a fresh 18.0 one included, and several of its refusals are the driver's (a dialect it cannot name, no raw-SQL seam, a column list that is not a list or comes back empty). For those, the command was never the remedy. The message now closes: "A fresh 18.0 install holds no legacy organization rows: there, the cause is the one named above, and the fix is in the SQL driver or the caller that met it. A database that does hold them is a v17 one. 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." +- **The seed refusal for a natural key held by a row with no organization** (`SeedLoaderService`, `@objectstack/metadata-protocol`). It used to close with "Attribute the unowned rows first (`os migrate organization-ownership`, then the same command with --apply), then re-run this seed". Even before 18.0, that bare `--apply` was refused, since the command requires `--plan-file`. The message now closes: "A row with no organization predates organization stamping, so it is a v17 database's. 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." +- **Unchanged:** both refusal codes, "Nothing was listed or written.", the seed refusal's opening sentences (what was refused and why), its `errored` count, and the dropped record. diff --git a/packages/metadata-core/src/legacy-organization-row.test.ts b/packages/metadata-core/src/legacy-organization-row.test.ts index 56655f2519..ddff9cf119 100644 --- a/packages/metadata-core/src/legacy-organization-row.test.ts +++ b/packages/metadata-core/src/legacy-organization-row.test.ts @@ -115,6 +115,28 @@ describe('readLegacyOrganizationRows — fails closed', () => { await expect(readLegacyOrganizationRows({ dialectName: 'sqlite' })).rejects.toThrow(/no raw-SQL seam/); }); + // What 18.0 supports, said to both readers: a driver-shaped refusal, which a + // fresh install meets too, and one only a database still carrying the + // column reaches. The ceremony 18.0 refuses is never the remedy. + it.each([ + ['driver-shaped: no raw seam (any database, a fresh one included)', { dialectName: 'sqlite' }], + ['database-shaped: the column is on disk, the legacy select answered nothing', sqlDriver({ dialectName: 'sqlite', columns: V17_COLUMNS, rows: null })], + ])('the refusal says what 18.0 supports, never the ceremony (%s)', async (_label, driver) => { + const error = await readLegacyOrganizationRows(driver).then(() => null, (e: unknown) => e); + expect(error).toBeInstanceOf(LegacyOrganizationRowsUnreadable); + const refusal = error as LegacyOrganizationRowsUnreadable; + expect(refusal.code).toBe('LEGACY_ORGANIZATION_ROWS_UNREADABLE'); + expect(refusal.message).toContain('Nothing was listed or written.'); + expect(refusal.message).toContain( + 'A fresh 18.0 install holds no legacy organization rows: there, the cause is the one named above, ' + + 'and the fix is in the SQL driver or the caller that met it.', + ); + expect(refusal.message).toMatch( + /A database that does hold them is a v17 one\. 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\.$/, + ); + expect(refusal.message).not.toContain('os migrate organization-ownership'); + }); + it('a table outside the family is refused before anything runs', async () => { const driver = sqlDriver({ dialectName: 'sqlite', columns: V17_COLUMNS, rows: [] }); await expect(readLegacyOrganizationRows(driver, 'sys_user')).rejects.toThrow(/not a sys_metadata-family table/); diff --git a/packages/metadata-core/src/legacy-organization-row.ts b/packages/metadata-core/src/legacy-organization-row.ts index 17c876ee18..e19b19399a 100644 --- a/packages/metadata-core/src/legacy-organization-row.ts +++ b/packages/metadata-core/src/legacy-organization-row.ts @@ -75,7 +75,10 @@ export class LegacyOrganizationRowsUnreadable extends Error { constructor(readonly table: string, detail: string) { super( `Could not tell ${table}'s legacy organization rows from the environment's: ${detail}. ` + - 'Nothing was listed or written. Run `os migrate organization-ownership` on this database first.', + 'Nothing was listed or written. A fresh 18.0 install holds no legacy organization rows: there, the ' + + 'cause is the one named above, and the fix is in the SQL driver or the caller that met it. A database ' + + 'that does hold them is a v17 one. 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.', ); this.name = 'LegacyOrganizationRowsUnreadable'; } diff --git a/packages/metadata-protocol/src/seed-loader-null-organization-holder.test.ts b/packages/metadata-protocol/src/seed-loader-null-organization-holder.test.ts index e51405c1be..0278178435 100644 --- a/packages/metadata-protocol/src/seed-loader-null-organization-holder.test.ts +++ b/packages/metadata-protocol/src/seed-loader-null-organization-holder.test.ts @@ -135,6 +135,15 @@ describe('[#24301] a scoped load over a NULL-organization holder of a natural ke const [refusal] = refusalsOf(result); expect(refusal).toMatchObject({ sourceObject: 'tw_widget', targetField: 'sku', attemptedValue: 'W-HELD', recordIndex: 0 }); expect(refusal.message).toContain("'w_unowned'"); + // What 18.0 supports, never the ceremony it refuses; the first sentences + // (what was refused, and why) stay. + expect(refusal.message).toContain('which has no organization (organization_id is NULL)'); + expect(refusal.message).toContain('decided by the organization-ownership ceremony, not by a seed load.'); + expect(refusal.message).toMatch( + /A row with no organization predates organization stamping, so it is a v17 database's\. 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\.$/, + ); + expect(refusal.message).not.toContain('os migrate organization-ownership'); + expect(refusal.message).not.toContain('--apply'); }); it("the organization's own row wins: a key held both by it and by an unowned row is matched, not refused", async () => { diff --git a/packages/metadata-protocol/src/seed-loader.ts b/packages/metadata-protocol/src/seed-loader.ts index 3963a4b46a..a1382d99c4 100644 --- a/packages/metadata-protocol/src/seed-loader.ts +++ b/packages/metadata-protocol/src/seed-loader.ts @@ -1848,9 +1848,10 @@ export class SeedLoaderService implements ISeedLoaderService { `which has no organization (organization_id is NULL), and this seed load writes for organization ` + `${config.organizationId}. Inserting the record would put a twin beside that row, and updating the ` + `row would hand it to this organization: which organization an unowned row belongs to is decided by ` + - `the organization-ownership ceremony, not by a seed load. Attribute the unowned rows first ` + - `(\`os migrate organization-ownership\`, then the same command with --apply), then re-run this seed: ` + - `the row is this organization's own by then, and the upsert matches it.`, + `the organization-ownership ceremony, not by a seed load. A row with no organization predates ` + + `organization stamping, so it is a v17 database's. 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.`, }; errors.push(error); allErrors.push(error); diff --git a/packages/runtime/src/seed-loader-null-organization-holder-real-driver.integration.test.ts b/packages/runtime/src/seed-loader-null-organization-holder-real-driver.integration.test.ts index da4d020fb4..b8f361b1c7 100644 --- a/packages/runtime/src/seed-loader-null-organization-holder-real-driver.integration.test.ts +++ b/packages/runtime/src/seed-loader-null-organization-holder-real-driver.integration.test.ts @@ -209,10 +209,12 @@ describe('scoped seed load over NULL-organization holders (sqlite-file)', () => expect(kickoff.targetObject).toBe('tw_event'); expect(kickoff.targetField).toBe('subject'); expect(kickoff.attemptedValue).toBe('Kickoff'); - // The message names the key, the unowned holder and the remedy's command. + // The message names the key, the unowned holder and what 18.0 supports: + // never the ceremony 18.0 refuses. expect(kickoff.message).toContain('subject=Kickoff'); expect(kickoff.message).toContain("'ev_kickoff'"); - expect(kickoff.message).toContain('os migrate organization-ownership'); + expect(kickoff.message).toContain('18.0 supports fresh installs only and does not upgrade a v17 database'); + expect(kickoff.message).not.toContain('os migrate organization-ownership'); expect(result.success).toBe(false); // Loud: said at `error`, once per dataset. const lines = logger.error.mock.calls.map((c) => String(c[0])).filter((m) => refusals.some((e) => m.includes(e.message)));