diff --git a/.changeset/migrate-project-id-guard-doubled-sentence.md b/.changeset/migrate-project-id-guard-doubled-sentence.md new file mode 100644 index 0000000000..8f7e45df18 --- /dev/null +++ b/.changeset/migrate-project-id-guard-doubled-sentence.md @@ -0,0 +1,24 @@ +--- +"@objectstack/metadata": patch +--- + +fix(metadata): `migrateProjectIdToEnvironmentId`'s raw-driver guard stated its instruction sentence twice (#13219) + +An operator who called `migrateProjectIdToEnvironmentId` with a driver that has +no `raw()` was refused correctly, but read the same remedy twice in one message: + +``` +migrateProjectIdToEnvironmentId: driver must expose a .raw(sql, bindings?) method. migrateProjectIdToEnvironmentId: driver must expose a .raw(sql, bindings?) method. SqlDriver (better-sqlite3/knex) supports this; cloud-side TursoDriver also conforms. +``` + +The sentence was concatenated twice, a copy-paste artifact — the sibling +`migrateEnvIdToProjectId` carries the correct single-sentence form of the +identical guard. Cosmetic and operator-facing only: the guard fires on exactly +the same condition, the remedy it names is unchanged, and nothing parses the +message. The duplicate line is deleted; the surviving sentence keeps the +trailing space that separates it from the one naming the conforming drivers. + +The refusal case in the package's tests now pins the properties — the +instruction appears exactly once, no sentence runs into the next, and the +supporting sentence is still present — rather than substring-matching the +message, which could not see a second copy and so passed either way. diff --git a/packages/metadata/src/migrations/migrate-project-id-to-environment-id.test.ts b/packages/metadata/src/migrations/migrate-project-id-to-environment-id.test.ts index 977cea41e6..69b51145fa 100644 --- a/packages/metadata/src/migrations/migrate-project-id-to-environment-id.test.ts +++ b/packages/metadata/src/migrations/migrate-project-id-to-environment-id.test.ts @@ -191,9 +191,37 @@ describe('migrateProjectIdToEnvironmentId — behaviour against a physically-sta expect(statements.filter((s) => s.startsWith('ALTER TABLE'))).toEqual([]); }); - it('still refuses a driver without .raw()', async () => { - await expect(migrateProjectIdToEnvironmentId({} as any)).rejects.toThrow( - /must expose a \.raw\(sql, bindings\?\) method/, + it('still refuses a driver without .raw(), stating the remedy exactly once', async () => { + // #13219 — the guard concatenated its instruction sentence TWICE, so an + // operator with a raw-less driver read the same remedy twice in one + // message. The assertion that used to stand here + // (`rejects.toThrow(/must expose a \.raw\(sql, bindings\?\) method/)`) + // passed either way: a substring match cannot see a second copy. So + // these pin the PROPERTIES of the assembled message, not a full-string + // copy of today's wording. + const outcome: unknown = await migrateProjectIdToEnvironmentId({} as any).then( + (value) => value, + (error: unknown) => error, ); + expect(outcome, 'a driver with no .raw() must be refused').toBeInstanceOf(Error); + const message = (outcome as Error).message; + + // 1. The remedy is stated exactly ONCE. Counted, not compared, so a + // later rewording of the sentence still leaves this asserting. + const instruction = /driver must expose a \.raw\(sql, bindings\?\) method\./g; + expect(message.match(instruction) ?? []).toHaveLength(1); + + // 2. ...and the sentences stay SEPARATED. Deleting the duplicate by + // trimming the surviving line's trailing space would satisfy (1) + // while gluing `method.SqlDriver` — this defect inverted, so it is + // pinned in the same case. A run-together sentence boundary is a + // lowercase letter, a period, then a capital; the `.raw(` in the + // text is lowercase-after-period and so is correctly not one. + expect(message).not.toMatch(/[a-z]\.[A-Z]/); + + // 3. Non-vacuity: deleting the SUPPORTING sentence instead would also + // satisfy (1) and (2). It names the drivers that do conform, which + // is the half of the message an operator acts on. + expect(message).toMatch(/SqlDriver/); }); }); diff --git a/packages/metadata/src/migrations/migrate-project-id-to-environment-id.ts b/packages/metadata/src/migrations/migrate-project-id-to-environment-id.ts index 21013e4410..0105db21f0 100644 --- a/packages/metadata/src/migrations/migrate-project-id-to-environment-id.ts +++ b/packages/metadata/src/migrations/migrate-project-id-to-environment-id.ts @@ -118,7 +118,6 @@ export async function migrateProjectIdToEnvironmentId( if (typeof driverAny.raw !== 'function') { throw new Error( - 'migrateProjectIdToEnvironmentId: driver must expose a .raw(sql, bindings?) method. ' + 'migrateProjectIdToEnvironmentId: driver must expose a .raw(sql, bindings?) method. ' + 'SqlDriver (better-sqlite3/knex) supports this; cloud-side TursoDriver also conforms.' );