Files
metapi/src/server/db/runtimeSchemaBootstrap.test.ts
T

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))',
);
});
});