diff --git a/.changeset/hash-shadow-null-safe-duplicate-group-tail.md b/.changeset/hash-shadow-null-safe-duplicate-group-tail.md new file mode 100644 index 0000000000..11c0874d87 --- /dev/null +++ b/.changeset/hash-shadow-null-safe-duplicate-group-tail.md @@ -0,0 +1,11 @@ +--- +"@objectstack/driver-sql": patch +--- + +The hash-shadow NULL-safe durability log now counts its overflow duplicate groups in the same words as the other three reports that render the same rows (#16289) + +`formatDuplicateGroups` is module-local in `sql-driver.ts` for one stated reason, quoted from its own docblock: the sites that report a blocked unique "must name the SAME rows in the SAME shape, and a second hand-rolled `.slice(0, 5).join('; ')` is exactly how the plain and the NULL-safe path drifted apart in the first place". Four sites render duplicate groups — the drift entry, the direct arm's plain-unique log, the hash-shadow arm's plain-unique log, and the hash-shadow arm's NULL-safe branch — and the fourth still hand-rolled that exact shape. + +So the drift the helper exists to prevent had already recurred, in the overflow tail: the helper writes `; …and N more group(s)`, the hand-rolled copy wrote `; …and N more`. Two durability logs about the same failure class, emitted from the same `catch`, disagreed on how they say "there are more". + +What an operator sees: when a hash-shadow NULL-safe unique index is blocked by more than five conflicting groups in one table, the boot-time durability line now ends `; …and N more group(s).` instead of `; …and N more.`. The surrounding ` Conflicting group(s): ….` framing, the five groups rendered in full, their `(key) × N rows` spelling and their order are unchanged, and so is every other line. No behaviour, no data effect, no API movement — the five-then-count rendering is now owned in one place for all four sites. diff --git a/packages/drivers/driver-sql/src/sql-driver-12998-shadow-null-safe-key.test.ts b/packages/drivers/driver-sql/src/sql-driver-12998-shadow-null-safe-key.test.ts index 6bbd6183e0..8e322631ad 100644 --- a/packages/drivers/driver-sql/src/sql-driver-12998-shadow-null-safe-key.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-12998-shadow-null-safe-key.test.ts @@ -232,5 +232,63 @@ declareDialectCell(MYSQL_CELL, 'hash-shadow NULL-safe key (#12998)', (cell) => { expect(msg).toContain("COALESCE(organization_id, '__global__')"); expect(msg).not.toContain('HASH COLLISION'); }, 60_000); + + /** + * The OVERFLOW TAIL — the half of that message no test in this repo could + * see. The report is assembled by the module-local `formatDuplicateGroups` + * (#14902), shared with the drift entry and both plain-unique logs, and it + * renders at most FIVE groups before counting the rest. Every duplicate + * fixture in this package conflicts a single group, so the tail had never + * been rendered by a test at all, and the `Conflicting group(s):` + * assertion above is a PREFIX — green whatever follows it. + * + * #16289 is that hole cashing in: this arm hand-rolled the same + * five-then-count shape the helper exists to own, and its tail read + * `…and N more` where the helper's reads `…and N more group(s)`. Two + * durability logs about the same failure class, emitted from the same + * `catch`, disagreed on how they say "there are more" — the exact drift + * the helper's docblock names, recurring on the one arm left behind. + * + * SIX groups is the smallest fixture in which the tail renders at all, + * and the five-shown count is what makes this a pin on the SHARED + * renderer rather than on one arm's private spelling of it. + */ + it('counts the sixth conflicting group in the shared "more group(s)" tail', async () => { + driver = new SqlDriver(cell.config()); + const logs: string[] = []; + (driver as any).logger = { + warn: (msg: string) => logs.push(String(msg)), + error: (msg: string) => logs.push(String(msg)), + }; + const bare = orgUniqueOn('os12998_tail'); + // Bound to a variable, like the fixture above: `initObjects`' parameter + // type does not declare `indexes`, and an inline literal would be + // rejected by tsc for a key the driver reads regardless (#16570). + const withoutIndex = { ...bare, indexes: [] }; + await driver.initObjects([withoutIndex]); + const knex = (driver as any).knex; + // Six DISTINCT values, each doubled under a NULL organization: six + // conflicting groups once the declared key folds NULL into the global + // bucket. Long, like every fixture here, because the over-the-ceiling + // TEXT column is what makes MySQL refuse the direct index and take the + // shadow route into this arm. + const rows = ['a', 'b', 'c', 'd', 'e', 'f'].flatMap((ch) => { + const v = ch.repeat(900); + return [ + { id: `${ch}1`, v, organization_id: null }, + { id: `${ch}2`, v, organization_id: null }, + ]; + }); + await knex('os12998_tail').insert(rows); + + await expect(driver.initObjects([bare])).resolves.not.toThrow(); + + const diagnosis = logs.find((l) => l.includes('cannot create hash-shadow unique index')); + expect(diagnosis, 'the degradation must be logged').toBeTruthy(); + // Five groups rendered in full … + expect(String(diagnosis).match(/\u00d7 2 rows/g) ?? []).toHaveLength(5); + // … and the sixth counted in the helper's wording, not a second one. + expect(diagnosis).toMatch(/; \u2026and 1 more group\(s\)\./); + }, 60_000); }); }); diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts index 393a83f1a5..91337b2b75 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -11938,13 +11938,7 @@ export class SqlDriver implements IDataDriver { ...nullSafe, ]); if (duplicates.length > 0) { - const shown = duplicates - .slice(0, 5) - .map((g) => `(${g.key}) × ${g.rows} rows`) - .join('; '); - report = ` Conflicting group(s): ${shown}${ - duplicates.length > 5 ? `; …and ${duplicates.length - 5} more` : '' - }.`; + report = ` Conflicting group(s): ${formatDuplicateGroups(duplicates)}.`; } } catch { // The probe is a diagnostic; the refusal below stands without it.