From 6205f2a412eee9dc2328a432d3832b19bdafdafe Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 14:09:14 +0000 Subject: [PATCH 1/3] wip: pass nullSafeColumns into the hash shadow (#12998) --- ...-driver-12998-shadow-null-safe-key.test.ts | 225 ++++++++++++++++++ packages/drivers/driver-sql/src/sql-driver.ts | 147 ++++++++++-- 2 files changed, 353 insertions(+), 19 deletions(-) create mode 100644 packages/drivers/driver-sql/src/sql-driver-12998-shadow-null-safe-key.test.ts 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 new file mode 100644 index 0000000000..f5883d7c90 --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-12998-shadow-null-safe-key.test.ts @@ -0,0 +1,225 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #12998 — the hash shadow must carry the DECLARED key: NULL-safe organization + * key parts (ADR-0120 D3) ride into the generation expression. + * + * ## The defect + * + * When MySQL refuses a declared UNIQUE index directly, the #11627 shadow route + * received the bare column list — `norm.nullSafeColumns` was not passed — so + * the generation expression hashed the RAW columns. `CONCAT` returns NULL when + * any argument is NULL, so every NULL-organization row hashed to NULL and was + * constrained by NOTHING, on exactly the rows (single-tenant stacks, + * admin-global defaults) the `COALESCE(organization_id, '__global__')` bucket + * exists to constrain. That is #5030's zero-constraint shape, silently + * reintroduced by the fallback while the boot log reported the constraint as + * carried. + * + * ## The two directions, and which is the control + * + * - An ORG-SCOPED unique must now COLLIDE two NULL-organization rows — the + * declared ADR-0120 D3 semantics, the positive half of this fix. + * - A PLAIN composite must keep any-NULL tuples NON-conflicting — MySQL's own + * composite-UNIQUE semantics, pinned as deliberate in + * `sql-driver-11627-hash-shadow-key.test.ts` ("hashes a composite tuple, + * keeps any-NULL tuples non-conflicting"). That pin is this change's + * CONTROL: a fix that coalesced every key part would pass the first + * direction and break a landed, deliberate behaviour. + * + * Physical claims are read from `information_schema` in separate queries, + * never from the DDL this driver emitted (same discipline as the #11627 file). + * + * Opt-in, like every live cell in this package: + * + * OS_TEST_MYSQL_URL=mysql://root:root@127.0.0.1:3306/conformance \ + * pnpm --filter @objectstack/driver-sql test + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { SqlDriver } from '../src/index.js'; +import { isHashShadowColumn } from './schema-drift.js'; +import { MYSQL_CELL, declareDialectCell } from './live-dialect-matrix.testkit.js'; + +/** + * An object with a tenant column and one long text field carrying an + * ORG-SCOPED unique. `maxLength: 1024` exceeds the 768-char keyable ceiling, so + * MySQL refuses the direct (functional-key-part) index and the sync takes the + * shadow route — the same route the live members + * (`sys_notification_preference` / `sys_notification_subscription`) take. + */ +const orgUniqueOn = (name: string) => ({ + name, + fields: { + organization_id: { type: 'string' }, + v: { type: 'text', maxLength: 1024 }, + }, + indexes: [{ fields: ['v'], unique: 'organization' as const, name: `uniq_${name}_org_v` }], +}); + +declareDialectCell(MYSQL_CELL, 'hash-shadow NULL-safe key (#12998)', (cell) => { + describe('hash-shadow NULL-safe organization key on live MySQL (#12998)', () => { + let driver: SqlDriver; + afterEach(async () => { + await driver?.disconnect().catch(() => {}); + }); + + /** Physical truth, read back from the catalog rather than from our DDL. */ + const catalog = async (table: string) => { + const knex = (driver as any).knex; + const cols = await knex + .select('COLUMN_NAME', 'DATA_TYPE', 'GENERATION_EXPRESSION') + .from('information_schema.COLUMNS') + .where({ TABLE_SCHEMA: knex.client.database(), TABLE_NAME: table }); + const idx = await knex + .select('INDEX_NAME', 'NON_UNIQUE', 'COLUMN_NAME', 'SUB_PART') + .from('information_schema.STATISTICS') + .where({ TABLE_SCHEMA: knex.client.database(), TABLE_NAME: table }); + return { cols, idx }; + }; + + /** + * The positive direction: the generation expression embeds the NULL-safe + * key part, so NULL-organization rows fold into the global bucket and + * collide — while a different organization, or a different payload, still + * inserts. The '__global__' literal row colliding with a NULL row is the + * equivalence pin: the shadow enforces the SAME key the direct + * `COALESCE(organization_id, '__global__')` index would have. + */ + it('collides two NULL-organization rows under an org-scoped shadow unique', async () => { + driver = new SqlDriver(cell.config()); + await driver.initObjects([orgUniqueOn('os12998_org')]); + + const { cols, idx } = await catalog('os12998_org'); + const shadow = cols.find((c: any) => isHashShadowColumn(c.COLUMN_NAME)); + expect(shadow, 'a shadow column must exist').toBeTruthy(); + // The generation expression carries the DECLARED key: the organization + // part in its COALESCE form, folding NULL into the global bucket. + const expr = String(shadow.GENERATION_EXPRESSION).toLowerCase(); + expect(expr).toContain('coalesce'); + expect(expr).toContain('organization_id'); + expect(expr).toContain('__global__'); + const carried = idx.filter((i: any) => isHashShadowColumn(i.COLUMN_NAME)); + expect(carried.length).toBe(1); + expect(Number(carried[0].NON_UNIQUE)).toBe(0); + expect(carried[0].SUB_PART).toBeNull(); + + const knex = (driver as any).knex; + const V = 'x'.repeat(900); + await knex('os12998_org').insert({ id: 'a', v: V, organization_id: null }); + // The defect's exact shape: a second NULL-organization row with the same + // payload used to insert (CONCAT → NULL → no constraint). It must now be + // refused. + await expect( + knex('os12998_org').insert({ id: 'b', v: V, organization_id: null }), + ).rejects.toThrow(/duplicate/i); + // Scoping is still real: another organization holds the same payload. + await knex('os12998_org').insert({ id: 'c', v: V, organization_id: 'org_b' }); + // …and a different payload in the global bucket is no conflict. + await knex('os12998_org').insert({ id: 'd', v: 'y'.repeat(900), organization_id: null }); + // Equivalence with the direct index's key: NULL and the '__global__' + // literal are ONE bucket. + const V2 = 'z'.repeat(900); + await knex('os12998_org').insert({ id: 'e', v: V2, organization_id: '__global__' }); + await expect( + knex('os12998_org').insert({ id: 'f', v: V2, organization_id: null }), + ).rejects.toThrow(/duplicate/i); + }); + + /** + * ⛔ The control, colocated: a PLAIN composite's expression must NOT gain a + * COALESCE — any-NULL tuples keep conflicting with nothing (the deliberate + * #11627 semantics its own file pins behaviourally). A fix that coalesced + * every part would fail exactly here. + */ + it('leaves plain composite key parts un-coalesced', async () => { + driver = new SqlDriver(cell.config()); + await driver.initObjects([ + { + name: 'os12998_plain', + fields: { a: { type: 'text', maxLength: 1024 }, b: { type: 'text', maxLength: 1024 } }, + indexes: [{ fields: ['a', 'b'], unique: true, name: 'uniq_os12998_plain_ab' }], + }, + ]); + const { cols } = await catalog('os12998_plain'); + const shadow = cols.find((c: any) => isHashShadowColumn(c.COLUMN_NAME)); + expect(shadow, 'a shadow column must exist').toBeTruthy(); + expect(String(shadow.GENERATION_EXPRESSION).toLowerCase()).not.toContain('coalesce'); + // And behaviourally: two any-NULL tuples coexist. + const knex = (driver as any).knex; + await knex('os12998_plain').insert([ + { id: 'n1', a: 'x'.repeat(900), b: null }, + { id: 'n2', a: 'x'.repeat(900), b: null }, + ]); + expect((await knex('os12998_plain').whereNull('b')).length).toBe(2); + }); + + /** + * Turning the constraint ON is data-dependent (ADR-0120 D4's exact shape): + * a database that accumulated duplicate NULL-organization rows while the + * shadow enforced nothing fails the shadow ALTER with ER_DUP_ENTRY. That + * must be a DIAGNOSED degradation — boot survives, the log names the + * conflicting groups and the operator action — never an unexplained + * boot-time failure, and never a silent success. + */ + it('diagnoses existing NULL-org duplicates instead of failing the boot unexplained', 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)), + }; + // Boot once WITHOUT the unique index, and accumulate the duplicates the + // void constraint admitted. + const bare = orgUniqueOn('os12998_dirty'); + await driver.initObjects([{ ...bare, indexes: [] }]); + const knex = (driver as any).knex; + const V = 'd'.repeat(900); + await knex('os12998_dirty').insert([ + { id: 'a', v: V, organization_id: null }, + { id: 'b', v: V, organization_id: null }, + ]); + + // Re-register WITH the org-scoped unique: direct index refused (TEXT key), + // shadow ALTER hits ER_DUP_ENTRY on the existing 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(); + // It names the constraint in its declared (COALESCE) form, the + // conflicting group, and what the operator must do. + expect(diagnosis).toContain("COALESCE(organization_id, '__global__')"); + expect(diagnosis).toMatch(/Conflicting group\(s\):/); + expect(diagnosis).toMatch(/os migrate plan/); + // And the constraint is honestly ABSENT — no index, and the atomic ALTER + // left no orphaned shadow column behind. + const { cols, idx } = await catalog('os12998_dirty'); + expect(idx.some((i: any) => i.INDEX_NAME === 'uniq_os12998_dirty_org_v')).toBe(false); + expect(cols.filter((c: any) => isHashShadowColumn(c.COLUMN_NAME))).toEqual([]); + }); + + /** + * The write-path half of ruling #11627 clause-②, now for the NULL-safe + * key: a genuine NULL-organization duplicate must be named in DECLARED + * terms — not left as MySQL's binary digest, and above all not misreported + * as a HASH COLLISION. The re-select must compare through the same + * COALESCE fold the enforced key applies (a bare `= NULL` matches nothing + * and would flip the verdict to the collision branch). + */ + it('names a NULL-organization duplicate in declared terms, never as a collision', async () => { + driver = new SqlDriver(cell.config()); + await driver.initObjects([orgUniqueOn('os12998_msg')]); + const V = 'm'.repeat(900); + await driver.create('os12998_msg', { v: V }); + const err: unknown = await driver.create('os12998_msg', { v: V }).then( + () => null, + (e) => e, + ); + expect(err, 'the NULL-organization duplicate must be refused').toBeTruthy(); + const msg = String((err as Error)?.message ?? err); + expect(msg).toMatch(/duplicate value for the UNIQUE constraint 'uniq_os12998_msg_org_v'/); + expect(msg).toContain("COALESCE(organization_id, '__global__')"); + expect(msg).not.toContain('HASH COLLISION'); + }); + }); +}); diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts index a6cd04d66b..532e03e749 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -11435,7 +11435,14 @@ export class SqlDriver implements IDataDriver { // nothing. Those cases stay refused below, and stay tracked. if (unique) { try { - if (await this.createHashShadowUniqueIndex(tableName, name, columns)) { + // #12998: the shadow must hash the DECLARED key, so the NULL-safe + // organization key parts (ADR-0120 D3) ride along — without them + // the generation expression hashed the RAW columns, `CONCAT` + // returned NULL for every NULL-organization row, and the rows the + // COALESCE bucket exists to constrain were constrained by nothing + // (#5030's shape, reintroduced by the fallback while the boot log + // reported the constraint as carried). + if (await this.createHashShadowUniqueIndex(tableName, name, columns, nullSafe)) { existing.add(name); continue; } @@ -11445,6 +11452,43 @@ export class SqlDriver implements IDataDriver { existing.add(name); continue; } + if (nullSafe.size > 0 && isUniqueViolationError(shadowErr)) { + // #12998: the shadow ALTER computes the generated column for the + // EXISTING rows, so a database that accumulated duplicates under + // the NULL-safe key while the constraint was void fails here + // with a uniqueness violation — the same data-dependent + // #5030-made-visible case the direct route handles below. Same + // disposition: do not take the boot down, name what collided + // and what fixes it, and let the ADR-0120 D4 drift pre-flight + // keep reporting the exact rows. + let report = ''; + try { + const duplicates = await this.probeNullSafeUniqueDuplicates(tableName, columns, [ + ...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` : '' + }.`; + } + } catch { + // The probe is a diagnostic; the refusal below stands without it. + } + this.logDurabilityFailure( + `[sql-driver] cannot create hash-shadow unique index '${name}' on "${tableName}" — ` + + `existing rows violate the NULL-safe key (duplicates the previous void constraint ` + + `admitted, #5030).${report} The constraint '${columns + .map((c) => (nullSafe.has(c) ? `COALESCE(${c}, '${GLOBAL_TENANT}')` : c)) + .join(', ')}' is NOT enforced until the data is deduplicated: run "os migrate plan" ` + + `for the conflicting rows (ADR-0120 D4).`, + shadowMsg, + ); + continue; + } // Fall through to the named refusal, which is still the honest // outcome — but say that the shadow route was tried and why it // did not land, so this does not read as never having been @@ -14065,30 +14109,55 @@ export class SqlDriver implements IDataDriver { * a crash. {@link explainHashShadowDuplicate} exists so the driver can tell * the two apart by reading the source columns back, and name whichever it is. * + * ## The NULL-safe organization key parts ride along (#12998) + * + * The shadow hashes the DECLARED key, not the raw columns. For a key part + * `normalizeDeclaredIndex` marked NULL-safe (ADR-0120 D3 — the organization + * key part of an org-scoped unique), the generation expression embeds the + * same `COALESCE(col, '__global__')` the direct index would have carried, so + * NULL-organization rows fold into the global bucket and DO collide with each + * other. Without it, `CONCAT` returned NULL for every NULL-organization row + * and the shadow silently reintroduced #5030's zero-constraint on exactly the + * rows (single-tenant / admin-global defaults) the COALESCE exists for — + * while the boot log reported the constraint as carried. Plain key parts keep + * the CONCAT-NULL semantics above: an any-NULL tuple still conflicts with + * nothing, matching MySQL's own composite-UNIQUE behaviour. + * * Returns `true` when the shadow index now exists. */ protected async createHashShadowUniqueIndex( tableName: string, indexName: string, columns: string[], + nullSafeColumns?: ReadonlySet, ): Promise { if (!this.isMysql) return false; const shadow = SqlDriver.hashShadowColumnFor(indexName); const ref = (c: string) => `\`${c.replace(/`/g, '``')}\``; + // The DECLARED key part: NULL-safe parts in their COALESCE form (#12998), + // plain parts as the bare column. + const part = (c: string) => + nullSafeColumns?.has(c) ? `COALESCE(${ref(c)}, '${GLOBAL_TENANT}')` : ref(c); // ONE argument needs no separator, and CONCAT of one value would only add // a chance to get the encoding wrong. const expr = columns.length === 1 - ? ref(columns[0]!) - : `CONCAT(${columns.map((c) => ref(c)).join(', 0x1f, ')})`; + ? part(columns[0]!) + : `CONCAT(${columns.map(part).join(', 0x1f, ')})`; const sql = `ALTER TABLE ${ref(tableName)} ` + `ADD COLUMN ${ref(shadow)} VARBINARY(32) GENERATED ALWAYS AS (UNHEX(SHA2(${expr}, 256))) STORED, ` + `ADD UNIQUE KEY ${ref(indexName)} (${ref(shadow)})`; await this.knex.raw(sql); + // The boot log describes the key the shadow actually enforces — the + // NULL-safe parts in their COALESCE spelling — so "carried" can be read + // literally (#12998). + const described = columns + .map((c) => (nullSafeColumns?.has(c) ? `COALESCE(${c}, '${GLOBAL_TENANT}')` : c)) + .join(', '); this.logger.warn( `[sql-driver] UNIQUE index '${indexName}' on "${tableName}" is carried by the hash-shadow column ` + - `"${shadow}" (SHA-256 of ${columns.join(', ')}), because MySQL cannot key ${columns.length > 1 ? 'this column set' : 'a column'} ` + + `"${shadow}" (SHA-256 of ${described}), because MySQL cannot key ${columns.length > 1 ? 'this column set' : 'a column'} ` + `longer than ${SqlDriver.MAX_KEYABLE_VARCHAR_CHARS} characters directly (#11627). The declared ` + `constraint is enforced over the full value; only the physical key differs.`, { tableName, indexName, columns, shadow }, @@ -14143,29 +14212,57 @@ export class SqlDriver implements IDataDriver { const sources = await this.hashShadowSourceColumns(tableName, indexName); if (sources.length === 0) return null; // Only the source columns the failing write actually supplied; a partial - // update cannot be re-selected on columns it never mentioned. - if (!sources.every((c) => Object.prototype.hasOwnProperty.call(values, c))) return null; + // update cannot be re-selected on columns it never mentioned. A NULL-safe + // key part (#12998) is exempt: the enforced key COALESCEs an absent or + // NULL value into the '__global__' bucket, so its key part is knowable + // without the write mentioning the column. + if ( + !sources.every( + (s) => s.nullSafe || Object.prototype.hasOwnProperty.call(values, s.column), + ) + ) { + return null; + } let existing = 0; try { - const rows = await this.knex(tableName) - .where(Object.fromEntries(sources.map((c) => [c, values[c]]))) - .limit(1); + // Re-select by the key the index ENFORCES, not by raw equality (#12998): + // a NULL-safe part compares through the same COALESCE the generation + // expression carries (NULL and absent both land in the global bucket), + // and plain parts use `<=>` — MySQL's NULL-safe equality; this whole + // route is inside `isMysql`. A bare `.where({col: null})` would compare + // `= NULL`, match nothing, and misreport a genuine NULL-organization + // duplicate as a HASH COLLISION. + let q = this.knex(tableName); + for (const s of sources) { + q = s.nullSafe + ? q.whereRaw(`COALESCE(??, '${GLOBAL_TENANT}') = COALESCE(?, '${GLOBAL_TENANT}')`, [ + s.column, + (values[s.column] ?? null) as any, + ]) + : q.whereRaw('?? <=> ?', [s.column, (values[s.column] ?? null) as any]); + } + const rows = await q.limit(1); existing = rows.length; } catch { return null; } + // Describe the key parts as enforced — NULL-safe parts in their COALESCE + // spelling — so the message names the actual constraint (#12998). + const described = sources + .map((s) => (s.nullSafe ? `COALESCE(${s.column}, '${GLOBAL_TENANT}')` : s.column)) + .join(', '); if (existing > 0) { // The ordinary case: a real duplicate. Say so in the declared terms // rather than leaving MySQL's binary digest as the only explanation. return ( `[sql-driver] duplicate value for the UNIQUE constraint '${indexName}' on "${tableName}" ` + - `(${sources.join(', ')}). The constraint is physically carried by a hash-shadow column, so the ` + + `(${described}). The constraint is physically carried by a hash-shadow column, so the ` + `server's own message quotes a binary digest instead of the value (#11627).` ); } return ( `[sql-driver] HASH COLLISION on the shadow-carried UNIQUE index '${indexName}' on "${tableName}" ` + - `(${sources.join(', ')}): the write was rejected as a duplicate, but NO existing row carries these ` + + `(${described}): the write was rejected as a duplicate, but NO existing row carries these ` + `values. Uniqueness on this index is enforced over a SHA-256 of them (#11627), so two different ` + `values produced the same digest. This is expected at a rate near 10^-59 for a billion rows — if ` + `you are reading this, please report it with the values above; the write itself is legitimate and ` + @@ -14173,11 +14270,6 @@ export class SqlDriver implements IDataDriver { ); } - /** - * The declared key columns a shadow-carried index hashes, read back from the - * registered metadata so the disambiguating select above filters on the same - * columns the shadow was generated from. - */ /** * Attach {@link explainHashShadowDuplicate}'s verdict to a failing write, or * return the error untouched (#11627). @@ -14210,7 +14302,18 @@ export class SqlDriver implements IDataDriver { }); } - protected async hashShadowSourceColumns(tableName: string, indexName: string): Promise { + /** + * The declared key parts a shadow-carried index hashes, read back from the + * generation expression the server stores, so the disambiguating select + * above filters on the same key the shadow was generated from. Per part: + * the column identity, and whether the expression wraps it in the NULL-safe + * `COALESCE(col, …)` form (ADR-0120 D3 via #12998) — the read side must + * compare through the same fold the enforced key applies. + */ + protected async hashShadowSourceColumns( + tableName: string, + indexName: string, + ): Promise> { try { const rows: Array<{ GENERATION_EXPRESSION?: string; generation_expression?: string }> = await this.knex @@ -14222,8 +14325,14 @@ export class SqlDriver implements IDataDriver { COLUMN_NAME: SqlDriver.hashShadowColumnFor(indexName), }); const expr = String(rows[0]?.GENERATION_EXPRESSION ?? rows[0]?.generation_expression ?? ''); - // `unhex(sha2(`a`,256))` or `unhex(sha2(concat(`a`,0x1f,`b`),256))` - return [...expr.matchAll(/`((?:[^`]|``)+)`/g)].map((m) => m[1]!.replace(/``/g, '`')); + // `unhex(sha2(`a`,256))`, `unhex(sha2(concat(`a`,0x1f,`b`),256))`, or with + // a NULL-safe part: `…concat(coalesce(`org`,_utf8mb4'__global__'),0x1f,`b`)…` + // (#12998). The optional group marks which identifiers the expression + // wraps in COALESCE. + return [...expr.matchAll(/(coalesce\s*\(\s*)?`((?:[^`]|``)+)`/gi)].map((m) => ({ + column: m[2]!.replace(/``/g, '`'), + nullSafe: m[1] != null, + })); } catch { return []; } From 1431bc018e872b8f4a8dec6b9ad8b1b448467554 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 14:27:09 +0000 Subject: [PATCH 2/3] wip: pins + typecheck fixes (#12998) --- ...sql-driver-12998-shadow-null-safe-key.test.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 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 f5883d7c90..575ab9d1c6 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 @@ -134,13 +134,12 @@ declareDialectCell(MYSQL_CELL, 'hash-shadow NULL-safe key (#12998)', (cell) => { */ it('leaves plain composite key parts un-coalesced', async () => { driver = new SqlDriver(cell.config()); - await driver.initObjects([ - { - name: 'os12998_plain', - fields: { a: { type: 'text', maxLength: 1024 }, b: { type: 'text', maxLength: 1024 } }, - indexes: [{ fields: ['a', 'b'], unique: true, name: 'uniq_os12998_plain_ab' }], - }, - ]); + const plain = { + name: 'os12998_plain', + fields: { a: { type: 'text', maxLength: 1024 }, b: { type: 'text', maxLength: 1024 } }, + indexes: [{ fields: ['a', 'b'], unique: true, name: 'uniq_os12998_plain_ab' }], + }; + await driver.initObjects([plain]); const { cols } = await catalog('os12998_plain'); const shadow = cols.find((c: any) => isHashShadowColumn(c.COLUMN_NAME)); expect(shadow, 'a shadow column must exist').toBeTruthy(); @@ -172,7 +171,8 @@ declareDialectCell(MYSQL_CELL, 'hash-shadow NULL-safe key (#12998)', (cell) => { // Boot once WITHOUT the unique index, and accumulate the duplicates the // void constraint admitted. const bare = orgUniqueOn('os12998_dirty'); - await driver.initObjects([{ ...bare, indexes: [] }]); + const withoutIndex = { ...bare, indexes: [] }; + await driver.initObjects([withoutIndex]); const knex = (driver as any).knex; const V = 'd'.repeat(900); await knex('os12998_dirty').insert([ From 1b04af6506767ef9e2c4d818380554612755ca1e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 14:50:51 +0000 Subject: [PATCH 3/3] changeset (#12998) --- .changeset/shadow-null-safe-key.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/shadow-null-safe-key.md diff --git a/.changeset/shadow-null-safe-key.md b/.changeset/shadow-null-safe-key.md new file mode 100644 index 0000000000..5bff1b575a --- /dev/null +++ b/.changeset/shadow-null-safe-key.md @@ -0,0 +1,7 @@ +--- +'@objectstack/driver-sql': patch +--- + +MySQL hash-shadow UNIQUE indexes (#11627) now hash the DECLARED key: the NULL-safe organization key part of an org-scoped unique (ADR-0120 D3) is embedded as `COALESCE(organization_id, '__global__')` inside the generation expression, so NULL-organization rows fold into the global bucket and collide with each other — the same key the direct index would have enforced. Previously the shadow hashed the raw columns, `CONCAT` returned NULL for every NULL-organization row, and a shadow-carried org-scoped unique silently enforced nothing on exactly the rows (single-tenant stacks, admin-global defaults) the NULL-safe key exists to constrain, while the boot log reported the constraint as carried. Plain composite shadows are unchanged: any-NULL tuples still conflict with nothing, matching MySQL's own composite-UNIQUE semantics. + +Deployment note — turning this constraint on is data-dependent: a MySQL database that accumulated duplicate NULL-organization rows while the shadow enforced nothing will fail the shadow `ALTER` with `ER_DUP_ENTRY` on its next boot. That failure is now diagnosed, not fatal: the boot continues, the log names the conflicting groups (probed over the same COALESCE key) and the operator action (`os migrate plan`, deduplicate, re-run), and the constraint is honestly reported as NOT enforced until the data is deduplicated — the same disposition as the direct NULL-safe route (ADR-0120 D4). Write-path duplicate diagnosis follows the key: a genuine NULL-organization duplicate is named in declared terms instead of being misreported as a hash collision.