From fdf3defef611a9bca7da92c8f79a9031c843625b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 09:42:37 +0000 Subject: [PATCH 1/3] fix(driver-sql): render the hash-shadow NULL-safe duplicate report through formatDuplicateGroups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fourth duplicate-group site — the hash-shadow arm's NULL-safe branch — still hand-rolled the `.slice(0, 5).join('; ')` shape the module-local helper was added to own, and its overflow 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 surrounding ` Conflicting group(s): ….` framing is byte-identical to the other three call sites; only the tail moves. The pin file asserted the `Conflicting group(s):` PREFIX only, so nothing in the tree could see the tail. A sixth conflicting group is the smallest fixture that renders it, and the new live cell asserts five groups shown plus the counted tail. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg --- ...-driver-12998-shadow-null-safe-key.test.ts | 54 +++++++++++++++++++ packages/drivers/driver-sql/src/sql-driver.ts | 8 +-- 2 files changed, 55 insertions(+), 7 deletions(-) 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..40ab868ffa 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,59 @@ 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'); + await driver.initObjects([{ ...bare, indexes: [] }]); + 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. From eb267ee61b59bbfb3749af3a76b4d29fa80641f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 09:53:46 +0000 Subject: [PATCH 2/3] chore(changeset): patch @objectstack/driver-sql for the shared duplicate-group tail Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg --- .../hash-shadow-null-safe-duplicate-group-tail.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .changeset/hash-shadow-null-safe-duplicate-group-tail.md 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. From a2cd9f58ee68ce7f73e31a25e2f3b253bf3634b9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 09:58:50 +0000 Subject: [PATCH 3/3] test(driver-sql): bind the index-less fixture to a variable so tsc accepts it `initObjects`' parameter type does not declare `indexes`, so an inline object literal carrying it is rejected by the excess-property check even though the driver reads the key (filed separately). Every other fixture in this file binds a variable first; the new one now does too. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg --- .../src/sql-driver-12998-shadow-null-safe-key.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 40ab868ffa..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 @@ -261,7 +261,11 @@ declareDialectCell(MYSQL_CELL, 'hash-shadow NULL-safe key (#12998)', (cell) => { error: (msg: string) => logs.push(String(msg)), }; const bare = orgUniqueOn('os12998_tail'); - await driver.initObjects([{ ...bare, indexes: [] }]); + // 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