Skip to content

Commit 8f2ecb3

Browse files
os-muskClaudeclaude
authored
fix(driver-sql): render the hash-shadow NULL-safe duplicate report through formatDuplicateGroups (#16579)
* fix(driver-sql): render the hash-shadow NULL-safe duplicate report through formatDuplicateGroups 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg * chore(changeset): patch @objectstack/driver-sql for the shared duplicate-group tail Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg --------- Co-authored-by: Claude <elon@objectstack.ai> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent ffca0df commit 8f2ecb3

3 files changed

Lines changed: 70 additions & 7 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@objectstack/driver-sql": patch
3+
---
4+
5+
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)
6+
7+
`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.
8+
9+
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".
10+
11+
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.

packages/drivers/driver-sql/src/sql-driver-12998-shadow-null-safe-key.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,5 +232,63 @@ declareDialectCell(MYSQL_CELL, 'hash-shadow NULL-safe key (#12998)', (cell) => {
232232
expect(msg).toContain("COALESCE(organization_id, '__global__')");
233233
expect(msg).not.toContain('HASH COLLISION');
234234
}, 60_000);
235+
236+
/**
237+
* The OVERFLOW TAIL — the half of that message no test in this repo could
238+
* see. The report is assembled by the module-local `formatDuplicateGroups`
239+
* (#14902), shared with the drift entry and both plain-unique logs, and it
240+
* renders at most FIVE groups before counting the rest. Every duplicate
241+
* fixture in this package conflicts a single group, so the tail had never
242+
* been rendered by a test at all, and the `Conflicting group(s):`
243+
* assertion above is a PREFIX — green whatever follows it.
244+
*
245+
* #16289 is that hole cashing in: this arm hand-rolled the same
246+
* five-then-count shape the helper exists to own, and its tail read
247+
* `…and N more` where the helper's reads `…and N more group(s)`. Two
248+
* durability logs about the same failure class, emitted from the same
249+
* `catch`, disagreed on how they say "there are more" — the exact drift
250+
* the helper's docblock names, recurring on the one arm left behind.
251+
*
252+
* SIX groups is the smallest fixture in which the tail renders at all,
253+
* and the five-shown count is what makes this a pin on the SHARED
254+
* renderer rather than on one arm's private spelling of it.
255+
*/
256+
it('counts the sixth conflicting group in the shared "more group(s)" tail', async () => {
257+
driver = new SqlDriver(cell.config());
258+
const logs: string[] = [];
259+
(driver as any).logger = {
260+
warn: (msg: string) => logs.push(String(msg)),
261+
error: (msg: string) => logs.push(String(msg)),
262+
};
263+
const bare = orgUniqueOn('os12998_tail');
264+
// Bound to a variable, like the fixture above: `initObjects`' parameter
265+
// type does not declare `indexes`, and an inline literal would be
266+
// rejected by tsc for a key the driver reads regardless (#16570).
267+
const withoutIndex = { ...bare, indexes: [] };
268+
await driver.initObjects([withoutIndex]);
269+
const knex = (driver as any).knex;
270+
// Six DISTINCT values, each doubled under a NULL organization: six
271+
// conflicting groups once the declared key folds NULL into the global
272+
// bucket. Long, like every fixture here, because the over-the-ceiling
273+
// TEXT column is what makes MySQL refuse the direct index and take the
274+
// shadow route into this arm.
275+
const rows = ['a', 'b', 'c', 'd', 'e', 'f'].flatMap((ch) => {
276+
const v = ch.repeat(900);
277+
return [
278+
{ id: `${ch}1`, v, organization_id: null },
279+
{ id: `${ch}2`, v, organization_id: null },
280+
];
281+
});
282+
await knex('os12998_tail').insert(rows);
283+
284+
await expect(driver.initObjects([bare])).resolves.not.toThrow();
285+
286+
const diagnosis = logs.find((l) => l.includes('cannot create hash-shadow unique index'));
287+
expect(diagnosis, 'the degradation must be logged').toBeTruthy();
288+
// Five groups rendered in full …
289+
expect(String(diagnosis).match(/\u00d7 2 rows/g) ?? []).toHaveLength(5);
290+
// … and the sixth counted in the helper's wording, not a second one.
291+
expect(diagnosis).toMatch(/; \u2026and 1 more group\(s\)\./);
292+
}, 60_000);
235293
});
236294
});

packages/drivers/driver-sql/src/sql-driver.ts

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11938,13 +11938,7 @@ export class SqlDriver implements IDataDriver {
1193811938
...nullSafe,
1193911939
]);
1194011940
if (duplicates.length > 0) {
11941-
const shown = duplicates
11942-
.slice(0, 5)
11943-
.map((g) => `(${g.key}) × ${g.rows} rows`)
11944-
.join('; ');
11945-
report = ` Conflicting group(s): ${shown}${
11946-
duplicates.length > 5 ? `; …and ${duplicates.length - 5} more` : ''
11947-
}.`;
11941+
report = ` Conflicting group(s): ${formatDuplicateGroups(duplicates)}.`;
1194811942
}
1194911943
} catch {
1195011944
// The probe is a diagnostic; the refusal below stands without it.

0 commit comments

Comments
 (0)