From e4c6eaf7c011328e9cd2ed745ee935e3b1542601 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 22:27:05 +0000 Subject: [PATCH] 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 --- ...-driver-12998-shadow-null-safe-key.test.ts | 27 ++++++++++++++++--- ...r-13015-shadow-carried-index-drift.test.ts | 27 +++++++++++++++++-- ...479-shadow-plain-unique-duplicates.test.ts | 21 +++++++++++++-- 3 files changed, 67 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 e11c8c6e4b0..6e51b980a13 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 @@ -40,13 +40,21 @@ 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'; +import { asPre18TenantColumn } from './pre-18-tenant-column.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 + * ORG-SCOPED unique in its NULL-safe (18.0) form. `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. + * + * ADR-0131 D1: a `unique: 'organization'` declaration now keys the PLAIN + * tenant column, so the NULL-safe key part this file pins reaches the shadow + * only from an input that already carries `nullSafeColumns` (honoured + * verbatim) — the shape a database built before 18.x holds. Every test that + * writes NULL-organization rows also returns the table to that store's + * nullable tenant column (`asPre18TenantColumn`). */ const orgUniqueOn = (name: string) => ({ name, @@ -54,7 +62,14 @@ const orgUniqueOn = (name: string) => ({ organization_id: { type: 'string' }, v: { type: 'text', maxLength: 1024 }, }, - indexes: [{ fields: ['v'], unique: 'organization' as const, name: `uniq_${name}_org_v` }], + indexes: [ + { + fields: ['organization_id', 'v'], + unique: true as const, + nullSafeColumns: ['organization_id'], + name: `uniq_${name}_org_v`, + }, + ], }); declareDialectCell(MYSQL_CELL, 'hash-shadow NULL-safe key (#12998)', (cell) => { @@ -100,6 +115,7 @@ declareDialectCell(MYSQL_CELL, 'hash-shadow NULL-safe key (#12998)', (cell) => { it('collides two NULL-organization rows under an org-scoped shadow unique', async () => { driver = new SqlDriver(cell.config()); await driver.initObjects([orgUniqueOn('os12998_org')]); + await asPre18TenantColumn(driver, 'os12998_org'); const { cols, idx } = await catalog('os12998_org'); const shadow = cols.find((c: any) => isHashShadowColumn(c.COLUMN_NAME)); @@ -184,6 +200,7 @@ declareDialectCell(MYSQL_CELL, 'hash-shadow NULL-safe key (#12998)', (cell) => { const bare = orgUniqueOn('os12998_dirty'); const withoutIndex = { ...bare, indexes: [] }; await driver.initObjects([withoutIndex]); + await asPre18TenantColumn(driver, 'os12998_dirty'); const knex = (driver as any).knex; const V = 'd'.repeat(900); await knex('os12998_dirty').insert([ @@ -220,6 +237,7 @@ declareDialectCell(MYSQL_CELL, 'hash-shadow NULL-safe key (#12998)', (cell) => { 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')]); + await asPre18TenantColumn(driver, '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( @@ -266,6 +284,7 @@ declareDialectCell(MYSQL_CELL, 'hash-shadow NULL-safe key (#12998)', (cell) => { // rejected by tsc for a key the driver reads regardless (the gap commit b72226f48 closed). const withoutIndex = { ...bare, indexes: [] }; await driver.initObjects([withoutIndex]); + await asPre18TenantColumn(driver, 'os12998_tail'); 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 diff --git a/packages/drivers/driver-sql/src/sql-driver-13015-shadow-carried-index-drift.test.ts b/packages/drivers/driver-sql/src/sql-driver-13015-shadow-carried-index-drift.test.ts index 81d2b145c9f..037dbd551b3 100644 --- a/packages/drivers/driver-sql/src/sql-driver-13015-shadow-carried-index-drift.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-13015-shadow-carried-index-drift.test.ts @@ -56,6 +56,7 @@ import { type PhysicalIndex, } from './schema-drift.js'; import { MYSQL_CELL, declareDialectCell } from './live-dialect-matrix.testkit.js'; +import { asPre18TenantColumn } from './pre-18-tenant-column.testkit.js'; // ───────────────────────────────────────────────────────────────────────── // Fixtures — the shapes `introspectIndexes` produces on MySQL @@ -230,6 +231,26 @@ const orgUniqueOn = (name: string) => ({ indexes: [{ fields: ['v'], unique: 'organization' as const, name: `uniq_${name}_org_v` }], }); +/** + * The same unique in its NULL-safe (18.0) form. ADR-0131 D1: a + * `unique: 'organization'` declaration now keys the PLAIN tenant column, so a + * COALESCE shadow — and a raw one reported against it — exists only where an + * input already carries `nullSafeColumns` (honoured verbatim): a database + * built before 18.x, whose tenant column is also nullable + * (`asPre18TenantColumn`). + */ +const pre18OrgUniqueOn = (name: string) => ({ + ...orgUniqueOn(name), + indexes: [ + { + fields: ['organization_id', 'v'], + unique: true as const, + nullSafeColumns: ['organization_id'], + name: `uniq_${name}_org_v`, + }, + ], +}); + declareDialectCell(MYSQL_CELL, 'shadow-carried index drift (#13015)', (cell) => { describe('shadow-carried UNIQUE against the live MySQL catalog (#13015)', () => { let driver: SqlDriver; @@ -304,8 +325,9 @@ declareDialectCell(MYSQL_CELL, 'shadow-carried index drift (#13015)', (cell) => */ it('survives "os migrate apply --allow-destructive" over every reported entry', async () => { driver = new SqlDriver(cell.config()); - const obj = orgUniqueOn('os13015_apply'); + const obj = pre18OrgUniqueOn('os13015_apply'); await driver.initObjects([obj]); + await asPre18TenantColumn(driver, 'os13015_apply'); await driver.disconnect(); // A SECOND driver: `runtimeCreatedIndexes` starts empty, so the ledger @@ -355,8 +377,9 @@ declareDialectCell(MYSQL_CELL, 'shadow-carried index drift (#13015)', (cell) => */ it('reports and repairs a shadow that hashes the raw columns', async () => { driver = new SqlDriver(cell.config()); - const obj = orgUniqueOn('os13015_stale'); + const obj = pre18OrgUniqueOn('os13015_stale'); await driver.initObjects([obj]); + await asPre18TenantColumn(driver, 'os13015_stale'); const knex = (driver as any).knex; const indexName = 'uniq_os13015_stale_org_v'; const shadow = hashShadowColumnFor(indexName); diff --git a/packages/drivers/driver-sql/src/sql-driver-15479-shadow-plain-unique-duplicates.test.ts b/packages/drivers/driver-sql/src/sql-driver-15479-shadow-plain-unique-duplicates.test.ts index 80e5ddd499a..695e79cfaa2 100644 --- a/packages/drivers/driver-sql/src/sql-driver-15479-shadow-plain-unique-duplicates.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-15479-shadow-plain-unique-duplicates.test.ts @@ -45,6 +45,7 @@ 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'; +import { asPre18TenantColumn } from './pre-18-tenant-column.testkit.js'; /** 900 chars — comfortably over the 768-char keyable ceiling, so `v` is TEXT. */ const LONG = 'p'.repeat(900); @@ -73,14 +74,27 @@ const globalUniqueOn = (name: string) => ({ indexes: [{ fields: ['v'], unique: 'global' as const, name: `uniq_${name}_v` }], }); -/** The CONTROL shape: an organization-scoped unique, i.e. the NULL-safe arm. */ +/** + * The CONTROL shape: an organization-scoped unique in its NULL-safe (18.0) + * form, i.e. the NULL-safe arm. ADR-0131 D1: a `unique: 'organization'` + * declaration now keys the plain tenant column, so only an input that already + * carries `nullSafeColumns` (honoured verbatim) still reaches this arm — the + * shape a database built before 18.x holds, nullable tenant column included. + */ 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}_v` }], + indexes: [ + { + fields: ['organization_id', 'v'], + unique: true as const, + nullSafeColumns: ['organization_id'], + name: `uniq_${name}_v`, + }, + ], }); 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 // declares it now, so the cast is gone and these two calls are checked // like any other. await driver.initObjects([{ ...meta, indexes: [] }]); + // A NULL-organization row exists only on a database built before 18.x + // (ADR-0131 D1 makes a fresh tenant column NOT NULL). + if (row.organization_id === null) await asPre18TenantColumn(driver, meta.name); const knex = (driver as any).knex; await knex(meta.name).insert([ { id: 'a', ...row },