Skip to content

Commit e4c6eaf

Browse files
committed
test(driver-sql): the three live-MySQL NULL-safe shadow suites model a pre-18 store
Since 7b-0 a table initObjects creates holds organization_id NOT NULL, and since 7b-2 a unique: 'organization' declaration keys the plain tenant column. The 12998, 13015 and 15479 live cells pin the NULL-safe hash-shadow arm, so their fixtures now declare the index in its 18.0 form (an input carrying nullSafeColumns, honoured verbatim) and relax the tenant column with asPre18TenantColumn before writing NULL-organization rows. Claude-Session: https://claude.ai/code/session_018gewcgUaNUX9hMkxeP2HE2 Co-authored-by: Claude <noreply@anthropic.com>
1 parent abc25a3 commit e4c6eaf

3 files changed

Lines changed: 67 additions & 8 deletions

File tree

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

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,21 +40,36 @@ import { describe, it, expect, afterEach } from 'vitest';
4040
import { SqlDriver } from '../src/index.js';
4141
import { isHashShadowColumn } from './schema-drift.js';
4242
import { MYSQL_CELL, declareDialectCell } from './live-dialect-matrix.testkit.js';
43+
import { asPre18TenantColumn } from './pre-18-tenant-column.testkit.js';
4344

4445
/**
4546
* 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
47+
* ORG-SCOPED unique in its NULL-safe (18.0) form. `maxLength: 1024` exceeds the
48+
* 768-char keyable ceiling, so MySQL refuses the direct (functional-key-part)
49+
* index and the sync takes the shadow route — the same route the live members
4950
* (`sys_notification_preference` / `sys_notification_subscription`) take.
51+
*
52+
* ADR-0131 D1: a `unique: 'organization'` declaration now keys the PLAIN
53+
* tenant column, so the NULL-safe key part this file pins reaches the shadow
54+
* only from an input that already carries `nullSafeColumns` (honoured
55+
* verbatim) — the shape a database built before 18.x holds. Every test that
56+
* writes NULL-organization rows also returns the table to that store's
57+
* nullable tenant column (`asPre18TenantColumn`).
5058
*/
5159
const orgUniqueOn = (name: string) => ({
5260
name,
5361
fields: {
5462
organization_id: { type: 'string' },
5563
v: { type: 'text', maxLength: 1024 },
5664
},
57-
indexes: [{ fields: ['v'], unique: 'organization' as const, name: `uniq_${name}_org_v` }],
65+
indexes: [
66+
{
67+
fields: ['organization_id', 'v'],
68+
unique: true as const,
69+
nullSafeColumns: ['organization_id'],
70+
name: `uniq_${name}_org_v`,
71+
},
72+
],
5873
});
5974

6075
declareDialectCell(MYSQL_CELL, 'hash-shadow NULL-safe key (#12998)', (cell) => {
@@ -100,6 +115,7 @@ declareDialectCell(MYSQL_CELL, 'hash-shadow NULL-safe key (#12998)', (cell) => {
100115
it('collides two NULL-organization rows under an org-scoped shadow unique', async () => {
101116
driver = new SqlDriver(cell.config());
102117
await driver.initObjects([orgUniqueOn('os12998_org')]);
118+
await asPre18TenantColumn(driver, 'os12998_org');
103119

104120
const { cols, idx } = await catalog('os12998_org');
105121
const shadow = cols.find((c: any) => isHashShadowColumn(c.COLUMN_NAME));
@@ -184,6 +200,7 @@ declareDialectCell(MYSQL_CELL, 'hash-shadow NULL-safe key (#12998)', (cell) => {
184200
const bare = orgUniqueOn('os12998_dirty');
185201
const withoutIndex = { ...bare, indexes: [] };
186202
await driver.initObjects([withoutIndex]);
203+
await asPre18TenantColumn(driver, 'os12998_dirty');
187204
const knex = (driver as any).knex;
188205
const V = 'd'.repeat(900);
189206
await knex('os12998_dirty').insert([
@@ -220,6 +237,7 @@ declareDialectCell(MYSQL_CELL, 'hash-shadow NULL-safe key (#12998)', (cell) => {
220237
it('names a NULL-organization duplicate in declared terms, never as a collision', async () => {
221238
driver = new SqlDriver(cell.config());
222239
await driver.initObjects([orgUniqueOn('os12998_msg')]);
240+
await asPre18TenantColumn(driver, 'os12998_msg');
223241
const V = 'm'.repeat(900);
224242
await driver.create('os12998_msg', { v: V });
225243
const err: unknown = await driver.create('os12998_msg', { v: V }).then(
@@ -266,6 +284,7 @@ declareDialectCell(MYSQL_CELL, 'hash-shadow NULL-safe key (#12998)', (cell) => {
266284
// rejected by tsc for a key the driver reads regardless (the gap commit b72226f48 closed).
267285
const withoutIndex = { ...bare, indexes: [] };
268286
await driver.initObjects([withoutIndex]);
287+
await asPre18TenantColumn(driver, 'os12998_tail');
269288
const knex = (driver as any).knex;
270289
// Six DISTINCT values, each doubled under a NULL organization: six
271290
// conflicting groups once the declared key folds NULL into the global

‎packages/drivers/driver-sql/src/sql-driver-13015-shadow-carried-index-drift.test.ts‎

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ import {
5656
type PhysicalIndex,
5757
} from './schema-drift.js';
5858
import { MYSQL_CELL, declareDialectCell } from './live-dialect-matrix.testkit.js';
59+
import { asPre18TenantColumn } from './pre-18-tenant-column.testkit.js';
5960

6061
// ─────────────────────────────────────────────────────────────────────────
6162
// Fixtures — the shapes `introspectIndexes` produces on MySQL
@@ -230,6 +231,26 @@ const orgUniqueOn = (name: string) => ({
230231
indexes: [{ fields: ['v'], unique: 'organization' as const, name: `uniq_${name}_org_v` }],
231232
});
232233

234+
/**
235+
* The same unique in its NULL-safe (18.0) form. ADR-0131 D1: a
236+
* `unique: 'organization'` declaration now keys the PLAIN tenant column, so a
237+
* COALESCE shadow — and a raw one reported against it — exists only where an
238+
* input already carries `nullSafeColumns` (honoured verbatim): a database
239+
* built before 18.x, whose tenant column is also nullable
240+
* (`asPre18TenantColumn`).
241+
*/
242+
const pre18OrgUniqueOn = (name: string) => ({
243+
...orgUniqueOn(name),
244+
indexes: [
245+
{
246+
fields: ['organization_id', 'v'],
247+
unique: true as const,
248+
nullSafeColumns: ['organization_id'],
249+
name: `uniq_${name}_org_v`,
250+
},
251+
],
252+
});
253+
233254
declareDialectCell(MYSQL_CELL, 'shadow-carried index drift (#13015)', (cell) => {
234255
describe('shadow-carried UNIQUE against the live MySQL catalog (#13015)', () => {
235256
let driver: SqlDriver;
@@ -304,8 +325,9 @@ declareDialectCell(MYSQL_CELL, 'shadow-carried index drift (#13015)', (cell) =>
304325
*/
305326
it('survives "os migrate apply --allow-destructive" over every reported entry', async () => {
306327
driver = new SqlDriver(cell.config());
307-
const obj = orgUniqueOn('os13015_apply');
328+
const obj = pre18OrgUniqueOn('os13015_apply');
308329
await driver.initObjects([obj]);
330+
await asPre18TenantColumn(driver, 'os13015_apply');
309331
await driver.disconnect();
310332

311333
// A SECOND driver: `runtimeCreatedIndexes` starts empty, so the ledger
@@ -355,8 +377,9 @@ declareDialectCell(MYSQL_CELL, 'shadow-carried index drift (#13015)', (cell) =>
355377
*/
356378
it('reports and repairs a shadow that hashes the raw columns', async () => {
357379
driver = new SqlDriver(cell.config());
358-
const obj = orgUniqueOn('os13015_stale');
380+
const obj = pre18OrgUniqueOn('os13015_stale');
359381
await driver.initObjects([obj]);
382+
await asPre18TenantColumn(driver, 'os13015_stale');
360383
const knex = (driver as any).knex;
361384
const indexName = 'uniq_os13015_stale_org_v';
362385
const shadow = hashShadowColumnFor(indexName);

‎packages/drivers/driver-sql/src/sql-driver-15479-shadow-plain-unique-duplicates.test.ts‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import { describe, it, expect, afterEach } from 'vitest';
4545
import { SqlDriver } from '../src/index.js';
4646
import { isHashShadowColumn } from './schema-drift.js';
4747
import { MYSQL_CELL, declareDialectCell } from './live-dialect-matrix.testkit.js';
48+
import { asPre18TenantColumn } from './pre-18-tenant-column.testkit.js';
4849

4950
/** 900 chars — comfortably over the 768-char keyable ceiling, so `v` is TEXT. */
5051
const LONG = 'p'.repeat(900);
@@ -73,14 +74,27 @@ const globalUniqueOn = (name: string) => ({
7374
indexes: [{ fields: ['v'], unique: 'global' as const, name: `uniq_${name}_v` }],
7475
});
7576

76-
/** The CONTROL shape: an organization-scoped unique, i.e. the NULL-safe arm. */
77+
/**
78+
* The CONTROL shape: an organization-scoped unique in its NULL-safe (18.0)
79+
* form, i.e. the NULL-safe arm. ADR-0131 D1: a `unique: 'organization'`
80+
* declaration now keys the plain tenant column, so only an input that already
81+
* carries `nullSafeColumns` (honoured verbatim) still reaches this arm — the
82+
* shape a database built before 18.x holds, nullable tenant column included.
83+
*/
7784
const orgUniqueOn = (name: string) => ({
7885
name,
7986
fields: {
8087
organization_id: { type: 'string' },
8188
v: { type: 'text', maxLength: 1024 },
8289
},
83-
indexes: [{ fields: ['v'], unique: 'organization' as const, name: `uniq_${name}_v` }],
90+
indexes: [
91+
{
92+
fields: ['organization_id', 'v'],
93+
unique: true as const,
94+
nullSafeColumns: ['organization_id'],
95+
name: `uniq_${name}_v`,
96+
},
97+
],
8498
});
8599

86100
declareDialectCell(MYSQL_CELL, 'hash-shadow plain unique over duplicates (#15479)', (cell) => {
@@ -130,6 +144,9 @@ declareDialectCell(MYSQL_CELL, 'hash-shadow plain unique over duplicates (#15479
130144
// declares it now, so the cast is gone and these two calls are checked
131145
// like any other.
132146
await driver.initObjects([{ ...meta, indexes: [] }]);
147+
// A NULL-organization row exists only on a database built before 18.x
148+
// (ADR-0131 D1 makes a fresh tenant column NOT NULL).
149+
if (row.organization_id === null) await asPre18TenantColumn(driver, meta.name);
133150
const knex = (driver as any).knex;
134151
await knex(meta.name).insert([
135152
{ id: 'a', ...row },

0 commit comments

Comments
 (0)