305 lines
10 KiB
TypeScript
305 lines
10 KiB
TypeScript
import baselineContract from './generated/fixtures/2026-03-14-baseline.schemaContract.json' with { type: 'json' };
|
|
import currentContract from './generated/schemaContract.json' with { type: 'json' };
|
|
import { classifyLegacyCompatMutation } from './legacySchemaCompat.js';
|
|
import { generateUpgradeSql } from './schemaArtifactGenerator.js';
|
|
import type { SchemaContract, SchemaContractColumn } from './schemaContract.js';
|
|
import { describe, expect, it } from 'vitest';
|
|
import {
|
|
__runtimeSchemaBootstrapTestUtils,
|
|
ensureRuntimeDatabaseSchema,
|
|
type RuntimeSchemaClient,
|
|
type RuntimeSchemaDialect,
|
|
} from './runtimeSchemaBootstrap.js';
|
|
|
|
function createStubClient(dialect: RuntimeSchemaDialect, executedSql: string[]): RuntimeSchemaClient {
|
|
return {
|
|
dialect,
|
|
begin: async () => {},
|
|
commit: async () => {},
|
|
rollback: async () => {},
|
|
execute: async (sqlText: string) => {
|
|
if (sqlText.trim().toLowerCase().startsWith('select')) {
|
|
return [];
|
|
}
|
|
executedSql.push(sqlText);
|
|
return [];
|
|
},
|
|
queryScalar: async (sqlText: string, params: unknown[] = []) => {
|
|
if (sqlText.includes('information_schema') || sqlText.includes('sqlite_master') || sqlText.includes('pragma_table_info')) {
|
|
return 1;
|
|
}
|
|
if (params.length > 0) {
|
|
return 1;
|
|
}
|
|
return 0;
|
|
},
|
|
close: async () => {},
|
|
};
|
|
}
|
|
|
|
function makeColumn(overrides: Partial<SchemaContractColumn> = {}): SchemaContractColumn {
|
|
return {
|
|
logicalType: 'text',
|
|
notNull: false,
|
|
defaultValue: null,
|
|
primaryKey: false,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe('runtime schema bootstrap', () => {
|
|
it.each(['mysql', 'postgres'] as const)('executes live-schema upgrade statements for %s', async (dialect) => {
|
|
const executedSql: string[] = [];
|
|
const expectedUpgradeSql = __runtimeSchemaBootstrapTestUtils.splitSqlStatements(
|
|
generateUpgradeSql(dialect, currentContract, baselineContract),
|
|
);
|
|
|
|
await ensureRuntimeDatabaseSchema(createStubClient(dialect, executedSql), {
|
|
currentContract,
|
|
liveContract: baselineContract,
|
|
});
|
|
|
|
expect(executedSql.slice(0, expectedUpgradeSql.length)).toEqual(expectedUpgradeSql);
|
|
});
|
|
|
|
it('skips external schema execution when live schema already matches the current contract', async () => {
|
|
const executedSql: string[] = [];
|
|
const expectedUpgradeSql = __runtimeSchemaBootstrapTestUtils.buildExternalUpgradeStatements(
|
|
'mysql',
|
|
currentContract,
|
|
currentContract,
|
|
);
|
|
|
|
await ensureRuntimeDatabaseSchema(createStubClient('mysql', executedSql), {
|
|
currentContract,
|
|
liveContract: currentContract,
|
|
});
|
|
|
|
expect(expectedUpgradeSql).toEqual([]);
|
|
expect(executedSql.every((sqlText) => classifyLegacyCompatMutation(sqlText) === 'legacy')).toBe(true);
|
|
});
|
|
|
|
it('tolerates non-additive live-schema drift and still emits additive runtime patch statements', () => {
|
|
const driftedLiveContract = __runtimeSchemaBootstrapTestUtils.cloneContract(currentContract);
|
|
|
|
delete driftedLiveContract.tables.model_availability?.columns.is_manual;
|
|
if (driftedLiveContract.tables.sites?.columns.status) {
|
|
driftedLiveContract.tables.sites.columns.status.defaultValue = null;
|
|
}
|
|
driftedLiveContract.indexes = driftedLiveContract.indexes.filter((index) => index.name !== 'accounts_site_id_idx');
|
|
driftedLiveContract.uniques = driftedLiveContract.uniques.filter((unique) => unique.name !== 'proxy_files_public_id_unique');
|
|
|
|
const statements = __runtimeSchemaBootstrapTestUtils.buildExternalUpgradeStatements(
|
|
'mysql',
|
|
currentContract,
|
|
driftedLiveContract,
|
|
);
|
|
|
|
expect(statements.some((sqlText) => sqlText.includes('ALTER TABLE `model_availability` ADD COLUMN `is_manual`'))).toBe(true);
|
|
expect(statements.some((sqlText) => sqlText.includes('CREATE INDEX `accounts_site_id_idx`'))).toBe(true);
|
|
expect(statements.some((sqlText) => sqlText.includes('CREATE UNIQUE INDEX `proxy_files_public_id_unique`'))).toBe(true);
|
|
});
|
|
|
|
it('ignores duplicate mysql index and column errors when replaying additive schema statements', async () => {
|
|
const executedSql: string[] = [];
|
|
const duplicateColumnSql = __runtimeSchemaBootstrapTestUtils.splitSqlStatements(
|
|
generateUpgradeSql('mysql', currentContract, baselineContract),
|
|
).find((sqlText) => sqlText.includes('ALTER TABLE `model_availability` ADD COLUMN `is_manual`'));
|
|
const duplicateIndexSql = __runtimeSchemaBootstrapTestUtils.splitSqlStatements(
|
|
generateUpgradeSql('mysql', currentContract, baselineContract),
|
|
).find((sqlText) => sqlText.includes('proxy_files_public_id_unique'));
|
|
|
|
expect(duplicateColumnSql).toBeDefined();
|
|
expect(duplicateIndexSql).toBeDefined();
|
|
|
|
await ensureRuntimeDatabaseSchema({
|
|
...createStubClient('mysql', executedSql),
|
|
execute: async (sqlText: string) => {
|
|
executedSql.push(sqlText);
|
|
if (sqlText === duplicateColumnSql) {
|
|
const error = new Error("Duplicate column name 'is_manual'") as Error & { code?: string };
|
|
error.code = 'ER_DUP_FIELDNAME';
|
|
throw error;
|
|
}
|
|
if (sqlText === duplicateIndexSql) {
|
|
const error = new Error("Duplicate key name 'model_availability_account_model_unique'") as Error & { code?: string };
|
|
error.code = 'ER_DUP_KEYNAME';
|
|
throw error;
|
|
}
|
|
return [];
|
|
},
|
|
}, {
|
|
currentContract,
|
|
liveContract: baselineContract,
|
|
});
|
|
|
|
expect(executedSql).toContain(duplicateColumnSql);
|
|
expect(executedSql).toContain(duplicateIndexSql);
|
|
});
|
|
|
|
it('ignores postgres relation-already-exists errors when replaying additive schema statements', async () => {
|
|
const executedSql: string[] = [];
|
|
const targetSql = __runtimeSchemaBootstrapTestUtils.splitSqlStatements(
|
|
generateUpgradeSql('postgres', currentContract, baselineContract),
|
|
).find((sqlText) => sqlText.includes('proxy_files_public_id_unique'));
|
|
|
|
expect(targetSql).toBeDefined();
|
|
|
|
await ensureRuntimeDatabaseSchema({
|
|
...createStubClient('postgres', executedSql),
|
|
execute: async (sqlText: string) => {
|
|
executedSql.push(sqlText);
|
|
if (sqlText === targetSql) {
|
|
const error = new Error('relation "model_availability_account_model_unique" already exists') as Error & { code?: string };
|
|
error.code = '42P07';
|
|
throw error;
|
|
}
|
|
return [];
|
|
},
|
|
}, {
|
|
currentContract,
|
|
liveContract: baselineContract,
|
|
});
|
|
|
|
expect(executedSql).toContain(targetSql);
|
|
});
|
|
|
|
it('adds mysql text prefixes for new indexes when live datetime-like columns are still stored as text', async () => {
|
|
const executedSql: string[] = [];
|
|
const minimalContract: SchemaContract = {
|
|
tables: {
|
|
proxy_logs: {
|
|
columns: {
|
|
downstream_api_key_id: makeColumn({ logicalType: 'integer' }),
|
|
created_at: makeColumn({ logicalType: 'datetime', defaultValue: "datetime('now')" }),
|
|
},
|
|
},
|
|
},
|
|
indexes: [
|
|
{
|
|
name: 'proxy_logs_downstream_api_key_created_at_idx',
|
|
table: 'proxy_logs',
|
|
columns: ['downstream_api_key_id', 'created_at'],
|
|
unique: false,
|
|
},
|
|
],
|
|
uniques: [],
|
|
foreignKeys: [],
|
|
};
|
|
|
|
await ensureRuntimeDatabaseSchema({
|
|
...createStubClient('mysql', executedSql),
|
|
execute: async (sqlText: string) => {
|
|
if (sqlText.includes('FROM information_schema.columns')) {
|
|
return [[
|
|
{
|
|
table_name: 'proxy_logs',
|
|
column_name: 'downstream_api_key_id',
|
|
data_type: 'int',
|
|
column_type: 'int',
|
|
},
|
|
{
|
|
table_name: 'proxy_logs',
|
|
column_name: 'created_at',
|
|
data_type: 'text',
|
|
column_type: 'text',
|
|
},
|
|
]];
|
|
}
|
|
|
|
executedSql.push(sqlText);
|
|
return [];
|
|
},
|
|
queryScalar: async (sqlText: string, params: unknown[] = []) => {
|
|
if (sqlText.includes('information_schema.tables')) {
|
|
return params[0] === 'proxy_logs' ? 1 : 0;
|
|
}
|
|
if (sqlText.includes('information_schema.columns')) {
|
|
return 1;
|
|
}
|
|
return 0;
|
|
},
|
|
}, {
|
|
currentContract: minimalContract,
|
|
liveContract: {
|
|
...minimalContract,
|
|
indexes: [],
|
|
},
|
|
});
|
|
|
|
expect(executedSql).toContain(
|
|
'CREATE INDEX `proxy_logs_downstream_api_key_created_at_idx` ON `proxy_logs` (`downstream_api_key_id`, `created_at`(191))',
|
|
);
|
|
});
|
|
|
|
it('does not add mysql text prefixes when live indexed text columns are varchar-backed', async () => {
|
|
const executedSql: string[] = [];
|
|
const minimalContract: SchemaContract = {
|
|
tables: {
|
|
sites: {
|
|
columns: {
|
|
platform: makeColumn({ logicalType: 'text', notNull: true }),
|
|
url: makeColumn({ logicalType: 'text', notNull: true }),
|
|
},
|
|
},
|
|
},
|
|
indexes: [],
|
|
uniques: [
|
|
{
|
|
name: 'sites_platform_url_unique',
|
|
table: 'sites',
|
|
columns: ['platform', 'url'],
|
|
},
|
|
],
|
|
foreignKeys: [],
|
|
};
|
|
|
|
await ensureRuntimeDatabaseSchema({
|
|
...createStubClient('mysql', executedSql),
|
|
execute: async (sqlText: string) => {
|
|
if (sqlText.includes('FROM information_schema.columns')) {
|
|
return [[
|
|
{
|
|
table_name: 'sites',
|
|
column_name: 'platform',
|
|
data_type: 'varchar',
|
|
column_type: 'varchar(32)',
|
|
},
|
|
{
|
|
table_name: 'sites',
|
|
column_name: 'url',
|
|
data_type: 'varchar',
|
|
column_type: 'varchar(255)',
|
|
},
|
|
]];
|
|
}
|
|
|
|
executedSql.push(sqlText);
|
|
return [];
|
|
},
|
|
queryScalar: async (sqlText: string, params: unknown[] = []) => {
|
|
if (sqlText.includes('information_schema.tables')) {
|
|
return params[0] === 'sites' ? 1 : 0;
|
|
}
|
|
if (sqlText.includes('information_schema.columns')) {
|
|
return 1;
|
|
}
|
|
return 0;
|
|
},
|
|
}, {
|
|
currentContract: minimalContract,
|
|
liveContract: {
|
|
...minimalContract,
|
|
uniques: [],
|
|
},
|
|
});
|
|
|
|
expect(executedSql).toContain(
|
|
'CREATE UNIQUE INDEX `sites_platform_url_unique` ON `sites` (`platform`, `url`)',
|
|
);
|
|
expect(executedSql).not.toContain(
|
|
'CREATE UNIQUE INDEX `sites_platform_url_unique` ON `sites` (`platform`(191), `url`(191))',
|
|
);
|
|
});
|
|
});
|