Skip to content

Commit df1c75c

Browse files
Elon Muskclaude
andauthored
fix(driver-sql): hash-shadow UNIQUE indexes carry the NULL-safe organization key part (ADR-0120 D3) (#13016)
* wip: pass nullSafeColumns into the hash shadow (#12998) * wip: pins + typecheck fixes (#12998) * changeset (#12998) --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7ef0268 commit df1c75c

3 files changed

Lines changed: 360 additions & 19 deletions

File tree

.changeset/shadow-null-safe-key.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@objectstack/driver-sql': patch
3+
---
4+
5+
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.
6+
7+
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.
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #12998 — the hash shadow must carry the DECLARED key: NULL-safe organization
5+
* key parts (ADR-0120 D3) ride into the generation expression.
6+
*
7+
* ## The defect
8+
*
9+
* When MySQL refuses a declared UNIQUE index directly, the #11627 shadow route
10+
* received the bare column list — `norm.nullSafeColumns` was not passed — so
11+
* the generation expression hashed the RAW columns. `CONCAT` returns NULL when
12+
* any argument is NULL, so every NULL-organization row hashed to NULL and was
13+
* constrained by NOTHING, on exactly the rows (single-tenant stacks,
14+
* admin-global defaults) the `COALESCE(organization_id, '__global__')` bucket
15+
* exists to constrain. That is #5030's zero-constraint shape, silently
16+
* reintroduced by the fallback while the boot log reported the constraint as
17+
* carried.
18+
*
19+
* ## The two directions, and which is the control
20+
*
21+
* - An ORG-SCOPED unique must now COLLIDE two NULL-organization rows — the
22+
* declared ADR-0120 D3 semantics, the positive half of this fix.
23+
* - A PLAIN composite must keep any-NULL tuples NON-conflicting — MySQL's own
24+
* composite-UNIQUE semantics, pinned as deliberate in
25+
* `sql-driver-11627-hash-shadow-key.test.ts` ("hashes a composite tuple,
26+
* keeps any-NULL tuples non-conflicting"). That pin is this change's
27+
* CONTROL: a fix that coalesced every key part would pass the first
28+
* direction and break a landed, deliberate behaviour.
29+
*
30+
* Physical claims are read from `information_schema` in separate queries,
31+
* never from the DDL this driver emitted (same discipline as the #11627 file).
32+
*
33+
* Opt-in, like every live cell in this package:
34+
*
35+
* OS_TEST_MYSQL_URL=mysql://root:root@127.0.0.1:3306/conformance \
36+
* pnpm --filter @objectstack/driver-sql test
37+
*/
38+
39+
import { describe, it, expect, afterEach } from 'vitest';
40+
import { SqlDriver } from '../src/index.js';
41+
import { isHashShadowColumn } from './schema-drift.js';
42+
import { MYSQL_CELL, declareDialectCell } from './live-dialect-matrix.testkit.js';
43+
44+
/**
45+
* An object with a tenant column and one long text field carrying an
46+
* ORG-SCOPED unique. `maxLength: 1024` exceeds the 768-char keyable ceiling, so
47+
* MySQL refuses the direct (functional-key-part) index and the sync takes the
48+
* shadow route — the same route the live members
49+
* (`sys_notification_preference` / `sys_notification_subscription`) take.
50+
*/
51+
const orgUniqueOn = (name: string) => ({
52+
name,
53+
fields: {
54+
organization_id: { type: 'string' },
55+
v: { type: 'text', maxLength: 1024 },
56+
},
57+
indexes: [{ fields: ['v'], unique: 'organization' as const, name: `uniq_${name}_org_v` }],
58+
});
59+
60+
declareDialectCell(MYSQL_CELL, 'hash-shadow NULL-safe key (#12998)', (cell) => {
61+
describe('hash-shadow NULL-safe organization key on live MySQL (#12998)', () => {
62+
let driver: SqlDriver;
63+
afterEach(async () => {
64+
await driver?.disconnect().catch(() => {});
65+
});
66+
67+
/** Physical truth, read back from the catalog rather than from our DDL. */
68+
const catalog = async (table: string) => {
69+
const knex = (driver as any).knex;
70+
const cols = await knex
71+
.select('COLUMN_NAME', 'DATA_TYPE', 'GENERATION_EXPRESSION')
72+
.from('information_schema.COLUMNS')
73+
.where({ TABLE_SCHEMA: knex.client.database(), TABLE_NAME: table });
74+
const idx = await knex
75+
.select('INDEX_NAME', 'NON_UNIQUE', 'COLUMN_NAME', 'SUB_PART')
76+
.from('information_schema.STATISTICS')
77+
.where({ TABLE_SCHEMA: knex.client.database(), TABLE_NAME: table });
78+
return { cols, idx };
79+
};
80+
81+
/**
82+
* The positive direction: the generation expression embeds the NULL-safe
83+
* key part, so NULL-organization rows fold into the global bucket and
84+
* collide — while a different organization, or a different payload, still
85+
* inserts. The '__global__' literal row colliding with a NULL row is the
86+
* equivalence pin: the shadow enforces the SAME key the direct
87+
* `COALESCE(organization_id, '__global__')` index would have.
88+
*/
89+
it('collides two NULL-organization rows under an org-scoped shadow unique', async () => {
90+
driver = new SqlDriver(cell.config());
91+
await driver.initObjects([orgUniqueOn('os12998_org')]);
92+
93+
const { cols, idx } = await catalog('os12998_org');
94+
const shadow = cols.find((c: any) => isHashShadowColumn(c.COLUMN_NAME));
95+
expect(shadow, 'a shadow column must exist').toBeTruthy();
96+
// The generation expression carries the DECLARED key: the organization
97+
// part in its COALESCE form, folding NULL into the global bucket.
98+
const expr = String(shadow.GENERATION_EXPRESSION).toLowerCase();
99+
expect(expr).toContain('coalesce');
100+
expect(expr).toContain('organization_id');
101+
expect(expr).toContain('__global__');
102+
const carried = idx.filter((i: any) => isHashShadowColumn(i.COLUMN_NAME));
103+
expect(carried.length).toBe(1);
104+
expect(Number(carried[0].NON_UNIQUE)).toBe(0);
105+
expect(carried[0].SUB_PART).toBeNull();
106+
107+
const knex = (driver as any).knex;
108+
const V = 'x'.repeat(900);
109+
await knex('os12998_org').insert({ id: 'a', v: V, organization_id: null });
110+
// The defect's exact shape: a second NULL-organization row with the same
111+
// payload used to insert (CONCAT → NULL → no constraint). It must now be
112+
// refused.
113+
await expect(
114+
knex('os12998_org').insert({ id: 'b', v: V, organization_id: null }),
115+
).rejects.toThrow(/duplicate/i);
116+
// Scoping is still real: another organization holds the same payload.
117+
await knex('os12998_org').insert({ id: 'c', v: V, organization_id: 'org_b' });
118+
// …and a different payload in the global bucket is no conflict.
119+
await knex('os12998_org').insert({ id: 'd', v: 'y'.repeat(900), organization_id: null });
120+
// Equivalence with the direct index's key: NULL and the '__global__'
121+
// literal are ONE bucket.
122+
const V2 = 'z'.repeat(900);
123+
await knex('os12998_org').insert({ id: 'e', v: V2, organization_id: '__global__' });
124+
await expect(
125+
knex('os12998_org').insert({ id: 'f', v: V2, organization_id: null }),
126+
).rejects.toThrow(/duplicate/i);
127+
});
128+
129+
/**
130+
* ⛔ The control, colocated: a PLAIN composite's expression must NOT gain a
131+
* COALESCE — any-NULL tuples keep conflicting with nothing (the deliberate
132+
* #11627 semantics its own file pins behaviourally). A fix that coalesced
133+
* every part would fail exactly here.
134+
*/
135+
it('leaves plain composite key parts un-coalesced', async () => {
136+
driver = new SqlDriver(cell.config());
137+
const plain = {
138+
name: 'os12998_plain',
139+
fields: { a: { type: 'text', maxLength: 1024 }, b: { type: 'text', maxLength: 1024 } },
140+
indexes: [{ fields: ['a', 'b'], unique: true, name: 'uniq_os12998_plain_ab' }],
141+
};
142+
await driver.initObjects([plain]);
143+
const { cols } = await catalog('os12998_plain');
144+
const shadow = cols.find((c: any) => isHashShadowColumn(c.COLUMN_NAME));
145+
expect(shadow, 'a shadow column must exist').toBeTruthy();
146+
expect(String(shadow.GENERATION_EXPRESSION).toLowerCase()).not.toContain('coalesce');
147+
// And behaviourally: two any-NULL tuples coexist.
148+
const knex = (driver as any).knex;
149+
await knex('os12998_plain').insert([
150+
{ id: 'n1', a: 'x'.repeat(900), b: null },
151+
{ id: 'n2', a: 'x'.repeat(900), b: null },
152+
]);
153+
expect((await knex('os12998_plain').whereNull('b')).length).toBe(2);
154+
});
155+
156+
/**
157+
* Turning the constraint ON is data-dependent (ADR-0120 D4's exact shape):
158+
* a database that accumulated duplicate NULL-organization rows while the
159+
* shadow enforced nothing fails the shadow ALTER with ER_DUP_ENTRY. That
160+
* must be a DIAGNOSED degradation — boot survives, the log names the
161+
* conflicting groups and the operator action — never an unexplained
162+
* boot-time failure, and never a silent success.
163+
*/
164+
it('diagnoses existing NULL-org duplicates instead of failing the boot unexplained', async () => {
165+
driver = new SqlDriver(cell.config());
166+
const logs: string[] = [];
167+
(driver as any).logger = {
168+
warn: (msg: string) => logs.push(String(msg)),
169+
error: (msg: string) => logs.push(String(msg)),
170+
};
171+
// Boot once WITHOUT the unique index, and accumulate the duplicates the
172+
// void constraint admitted.
173+
const bare = orgUniqueOn('os12998_dirty');
174+
const withoutIndex = { ...bare, indexes: [] };
175+
await driver.initObjects([withoutIndex]);
176+
const knex = (driver as any).knex;
177+
const V = 'd'.repeat(900);
178+
await knex('os12998_dirty').insert([
179+
{ id: 'a', v: V, organization_id: null },
180+
{ id: 'b', v: V, organization_id: null },
181+
]);
182+
183+
// Re-register WITH the org-scoped unique: direct index refused (TEXT key),
184+
// shadow ALTER hits ER_DUP_ENTRY on the existing rows.
185+
await expect(driver.initObjects([bare])).resolves.not.toThrow();
186+
187+
const diagnosis = logs.find((l) => l.includes('cannot create hash-shadow unique index'));
188+
expect(diagnosis, 'the degradation must be logged').toBeTruthy();
189+
// It names the constraint in its declared (COALESCE) form, the
190+
// conflicting group, and what the operator must do.
191+
expect(diagnosis).toContain("COALESCE(organization_id, '__global__')");
192+
expect(diagnosis).toMatch(/Conflicting group\(s\):/);
193+
expect(diagnosis).toMatch(/os migrate plan/);
194+
// And the constraint is honestly ABSENT — no index, and the atomic ALTER
195+
// left no orphaned shadow column behind.
196+
const { cols, idx } = await catalog('os12998_dirty');
197+
expect(idx.some((i: any) => i.INDEX_NAME === 'uniq_os12998_dirty_org_v')).toBe(false);
198+
expect(cols.filter((c: any) => isHashShadowColumn(c.COLUMN_NAME))).toEqual([]);
199+
});
200+
201+
/**
202+
* The write-path half of ruling #11627 clause-②, now for the NULL-safe
203+
* key: a genuine NULL-organization duplicate must be named in DECLARED
204+
* terms — not left as MySQL's binary digest, and above all not misreported
205+
* as a HASH COLLISION. The re-select must compare through the same
206+
* COALESCE fold the enforced key applies (a bare `= NULL` matches nothing
207+
* and would flip the verdict to the collision branch).
208+
*/
209+
it('names a NULL-organization duplicate in declared terms, never as a collision', async () => {
210+
driver = new SqlDriver(cell.config());
211+
await driver.initObjects([orgUniqueOn('os12998_msg')]);
212+
const V = 'm'.repeat(900);
213+
await driver.create('os12998_msg', { v: V });
214+
const err: unknown = await driver.create('os12998_msg', { v: V }).then(
215+
() => null,
216+
(e) => e,
217+
);
218+
expect(err, 'the NULL-organization duplicate must be refused').toBeTruthy();
219+
const msg = String((err as Error)?.message ?? err);
220+
expect(msg).toMatch(/duplicate value for the UNIQUE constraint 'uniq_os12998_msg_org_v'/);
221+
expect(msg).toContain("COALESCE(organization_id, '__global__')");
222+
expect(msg).not.toContain('HASH COLLISION');
223+
});
224+
});
225+
});

0 commit comments

Comments
 (0)