Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -40,21 +40,36 @@ 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,
fields: {
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) => {
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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([
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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 },
Expand Down
Loading