forked from boos/BoosAPI
test(db): verify upgrade paths against the schema contract
This commit is contained in:
@@ -45,6 +45,7 @@
|
|||||||
"schema:generate": "npm run db:generate && npm run schema:contract",
|
"schema:generate": "npm run db:generate && npm run schema:contract",
|
||||||
"test:schema:unit": "vitest run --root . src/server/db/schemaContract.test.ts src/server/db/schemaArtifactGenerator.test.ts src/server/db/schemaIntrospection.test.ts src/server/db/schemaParity.test.ts",
|
"test:schema:unit": "vitest run --root . src/server/db/schemaContract.test.ts src/server/db/schemaArtifactGenerator.test.ts src/server/db/schemaIntrospection.test.ts src/server/db/schemaParity.test.ts",
|
||||||
"test:schema:parity": "vitest run --root . src/server/db/schemaParity.live.test.ts",
|
"test:schema:parity": "vitest run --root . src/server/db/schemaParity.live.test.ts",
|
||||||
|
"test:schema:upgrade": "vitest run --root . src/server/db/schemaUpgrade.live.test.ts",
|
||||||
"smoke:db": "tsx scripts/dev/db-smoke.ts",
|
"smoke:db": "tsx scripts/dev/db-smoke.ts",
|
||||||
"smoke:db:sqlite": "tsx scripts/dev/db-smoke.ts --db-type sqlite",
|
"smoke:db:sqlite": "tsx scripts/dev/db-smoke.ts --db-type sqlite",
|
||||||
"smoke:db:mysql": "tsx scripts/dev/db-smoke.ts --db-type mysql",
|
"smoke:db:mysql": "tsx scripts/dev/db-smoke.ts --db-type mysql",
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { dirname, resolve } from 'node:path';
|
||||||
|
import {
|
||||||
|
resolveGeneratedSchemaContractPath,
|
||||||
|
type SchemaContract,
|
||||||
|
} from '../../src/server/db/schemaContract.js';
|
||||||
|
|
||||||
|
interface FixtureScriptOptions {
|
||||||
|
fromRef?: string;
|
||||||
|
outputPath: string;
|
||||||
|
dropTables: string[];
|
||||||
|
dropColumns: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(argv: string[]): FixtureScriptOptions {
|
||||||
|
const options: FixtureScriptOptions = {
|
||||||
|
outputPath: 'src/server/db/generated/fixtures/2026-03-14-baseline.schemaContract.json',
|
||||||
|
dropTables: [],
|
||||||
|
dropColumns: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let index = 0; index < argv.length; index += 1) {
|
||||||
|
const argument = argv[index];
|
||||||
|
if (argument === '--from-ref') {
|
||||||
|
options.fromRef = argv[index + 1];
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (argument === '--output') {
|
||||||
|
options.outputPath = argv[index + 1];
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (argument === '--drop-table') {
|
||||||
|
options.dropTables.push(argv[index + 1]);
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (argument === '--drop-column') {
|
||||||
|
options.dropColumns.push(argv[index + 1]);
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruneContract(contract: SchemaContract, options: FixtureScriptOptions): SchemaContract {
|
||||||
|
const droppedTables = new Set(options.dropTables);
|
||||||
|
const droppedColumns = new Map<string, Set<string>>();
|
||||||
|
|
||||||
|
for (const entry of options.dropColumns) {
|
||||||
|
const [tableName, columnName] = entry.split('.', 2);
|
||||||
|
if (!tableName || !columnName) {
|
||||||
|
throw new Error(`invalid --drop-column value: ${entry}`);
|
||||||
|
}
|
||||||
|
const tableColumns = droppedColumns.get(tableName) ?? new Set<string>();
|
||||||
|
tableColumns.add(columnName);
|
||||||
|
droppedColumns.set(tableName, tableColumns);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tables = Object.fromEntries(
|
||||||
|
Object.entries(contract.tables)
|
||||||
|
.filter(([tableName]) => !droppedTables.has(tableName))
|
||||||
|
.map(([tableName, table]) => {
|
||||||
|
const removedColumns = droppedColumns.get(tableName) ?? new Set<string>();
|
||||||
|
const columns = Object.fromEntries(
|
||||||
|
Object.entries(table.columns).filter(([columnName]) => !removedColumns.has(columnName)),
|
||||||
|
);
|
||||||
|
return [tableName, { columns }];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasColumn = (tableName: string, columnName: string): boolean =>
|
||||||
|
!!tables[tableName]?.columns[columnName];
|
||||||
|
|
||||||
|
return {
|
||||||
|
tables,
|
||||||
|
indexes: contract.indexes.filter((index) =>
|
||||||
|
!!tables[index.table] && index.columns.every((columnName) => hasColumn(index.table, columnName))),
|
||||||
|
uniques: contract.uniques.filter((unique) =>
|
||||||
|
!!tables[unique.table] && unique.columns.every((columnName) => hasColumn(unique.table, columnName))),
|
||||||
|
foreignKeys: contract.foreignKeys.filter((foreignKey) =>
|
||||||
|
!!tables[foreignKey.table]
|
||||||
|
&& !!tables[foreignKey.referencedTable]
|
||||||
|
&& foreignKey.columns.every((columnName) => hasColumn(foreignKey.table, columnName))
|
||||||
|
&& foreignKey.referencedColumns.every((columnName) => hasColumn(foreignKey.referencedTable, columnName))),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readContractFromRef(fromRef: string): SchemaContract {
|
||||||
|
const contractJson = execFileSync(
|
||||||
|
'git',
|
||||||
|
['-c', 'safe.directory=.', 'show', `${fromRef}:src/server/db/generated/schemaContract.json`],
|
||||||
|
{ encoding: 'utf8' },
|
||||||
|
);
|
||||||
|
return JSON.parse(contractJson) as SchemaContract;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCurrentContract(): SchemaContract {
|
||||||
|
return JSON.parse(readFileSync(resolveGeneratedSchemaContractPath(), 'utf8')) as SchemaContract;
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = parseArgs(process.argv.slice(2));
|
||||||
|
const sourceContract = options.fromRef ? readContractFromRef(options.fromRef) : readCurrentContract();
|
||||||
|
const contract = pruneContract(sourceContract, options);
|
||||||
|
const outputPath = resolve(process.cwd(), options.outputPath);
|
||||||
|
|
||||||
|
mkdirSync(dirname(outputPath), { recursive: true });
|
||||||
|
writeFileSync(outputPath, `${JSON.stringify(contract, null, 2)}\n`, 'utf8');
|
||||||
|
|
||||||
|
console.log(`[schema:upgrade-fixture] wrote ${outputPath}`);
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -106,4 +106,13 @@ describe('schema artifact generator', () => {
|
|||||||
expect(artifacts.mysqlBootstrap).toContain('`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP');
|
expect(artifacts.mysqlBootstrap).toContain('`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP');
|
||||||
expect(artifacts.postgresBootstrap).toContain('"created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP');
|
expect(artifacts.postgresBootstrap).toContain('"created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects destructive diffs when generating additive upgrades', () => {
|
||||||
|
const current = readSchemaContract();
|
||||||
|
const previous = structuredClone(current);
|
||||||
|
|
||||||
|
delete current.tables.sites.columns.status;
|
||||||
|
|
||||||
|
expect(() => generateDialectArtifacts(current, previous)).toThrow(/non-additive schema diff/i);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export interface GeneratedDialectArtifacts {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Dialect = 'mysql' | 'postgres';
|
type Dialect = 'mysql' | 'postgres';
|
||||||
|
type SqlDialect = 'sqlite' | Dialect;
|
||||||
|
|
||||||
function resolveDbDir(): string {
|
function resolveDbDir(): string {
|
||||||
return dirname(fileURLToPath(import.meta.url));
|
return dirname(fileURLToPath(import.meta.url));
|
||||||
@@ -27,7 +28,7 @@ export function resolveGeneratedArtifactPath(filename: string): string {
|
|||||||
return resolve(resolveDbDir(), 'generated', filename);
|
return resolve(resolveDbDir(), 'generated', filename);
|
||||||
}
|
}
|
||||||
|
|
||||||
function quoteIdentifier(dialect: Dialect, identifier: string): string {
|
function quoteIdentifier(dialect: SqlDialect, identifier: string): string {
|
||||||
return dialect === 'mysql' ? `\`${identifier}\`` : `"${identifier}"`;
|
return dialect === 'mysql' ? `\`${identifier}\`` : `"${identifier}"`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,7 +36,22 @@ function escapeMysqlTextPrefix(columnType: LogicalColumnType): string {
|
|||||||
return columnType === 'text' || columnType === 'datetime' || columnType === 'json' ? '(191)' : '';
|
return columnType === 'text' || columnType === 'datetime' || columnType === 'json' ? '(191)' : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapColumnType(dialect: Dialect, columnName: string, column: SchemaContractColumn): string {
|
function mapColumnType(dialect: SqlDialect, columnName: string, column: SchemaContractColumn): string {
|
||||||
|
if (dialect === 'sqlite') {
|
||||||
|
switch (column.logicalType) {
|
||||||
|
case 'boolean':
|
||||||
|
case 'integer':
|
||||||
|
return 'INTEGER';
|
||||||
|
case 'real':
|
||||||
|
return 'REAL';
|
||||||
|
case 'datetime':
|
||||||
|
case 'json':
|
||||||
|
case 'text':
|
||||||
|
default:
|
||||||
|
return 'TEXT';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (dialect === 'mysql') {
|
if (dialect === 'mysql') {
|
||||||
switch (column.logicalType) {
|
switch (column.logicalType) {
|
||||||
case 'boolean':
|
case 'boolean':
|
||||||
@@ -72,12 +88,15 @@ function mapColumnType(dialect: Dialect, columnName: string, column: SchemaContr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDefaultValue(column: SchemaContractColumn): string {
|
function formatDefaultValue(dialect: SqlDialect, column: SchemaContractColumn): string {
|
||||||
if (column.defaultValue == null || column.primaryKey) {
|
if (column.defaultValue == null || column.primaryKey) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (column.logicalType === 'datetime') {
|
if (column.logicalType === 'datetime') {
|
||||||
|
if (dialect === 'sqlite') {
|
||||||
|
return " DEFAULT (datetime('now'))";
|
||||||
|
}
|
||||||
return ' DEFAULT CURRENT_TIMESTAMP';
|
return ' DEFAULT CURRENT_TIMESTAMP';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,18 +108,18 @@ function formatDefaultValue(column: SchemaContractColumn): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildColumnDefinition(
|
function buildColumnDefinition(
|
||||||
dialect: Dialect,
|
dialect: SqlDialect,
|
||||||
columnName: string,
|
columnName: string,
|
||||||
column: SchemaContractColumn,
|
column: SchemaContractColumn,
|
||||||
): string {
|
): string {
|
||||||
const sqlType = mapColumnType(dialect, columnName, column);
|
const sqlType = mapColumnType(dialect, columnName, column);
|
||||||
const notNull = column.notNull ? ' NOT NULL' : '';
|
const notNull = column.notNull ? ' NOT NULL' : '';
|
||||||
const defaultValue = formatDefaultValue(column);
|
const defaultValue = formatDefaultValue(dialect, column);
|
||||||
const primaryKey = column.primaryKey ? ' PRIMARY KEY' : '';
|
const primaryKey = column.primaryKey ? ' PRIMARY KEY' : '';
|
||||||
return `${quoteIdentifier(dialect, columnName)} ${sqlType}${notNull}${defaultValue}${primaryKey}`;
|
return `${quoteIdentifier(dialect, columnName)} ${sqlType}${notNull}${defaultValue}${primaryKey}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildForeignKeyClause(dialect: Dialect, foreignKey: SchemaContractForeignKey): string {
|
function buildForeignKeyClause(dialect: SqlDialect, foreignKey: SchemaContractForeignKey): string {
|
||||||
const sourceColumns = foreignKey.columns.map((column) => quoteIdentifier(dialect, column)).join(', ');
|
const sourceColumns = foreignKey.columns.map((column) => quoteIdentifier(dialect, column)).join(', ');
|
||||||
const targetColumns = foreignKey.referencedColumns.map((column) => quoteIdentifier(dialect, column)).join(', ');
|
const targetColumns = foreignKey.referencedColumns.map((column) => quoteIdentifier(dialect, column)).join(', ');
|
||||||
const onDelete = foreignKey.onDelete ? ` ON DELETE ${foreignKey.onDelete.toUpperCase()}` : '';
|
const onDelete = foreignKey.onDelete ? ` ON DELETE ${foreignKey.onDelete.toUpperCase()}` : '';
|
||||||
@@ -144,7 +163,7 @@ function sortTableNamesForCreation(contract: SchemaContract): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildCreateTableStatement(
|
function buildCreateTableStatement(
|
||||||
dialect: Dialect,
|
dialect: SqlDialect,
|
||||||
tableName: string,
|
tableName: string,
|
||||||
contract: SchemaContract,
|
contract: SchemaContract,
|
||||||
): string {
|
): string {
|
||||||
@@ -158,7 +177,7 @@ function buildCreateTableStatement(
|
|||||||
return `CREATE TABLE IF NOT EXISTS ${quoteIdentifier(dialect, tableName)} (${parts.join(', ')})`;
|
return `CREATE TABLE IF NOT EXISTS ${quoteIdentifier(dialect, tableName)} (${parts.join(', ')})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildUniqueIndexStatement(dialect: Dialect, uniqueIndex: SchemaContractUnique, contract: SchemaContract): string {
|
function buildUniqueIndexStatement(dialect: SqlDialect, uniqueIndex: SchemaContractUnique, contract: SchemaContract): string {
|
||||||
const columns = uniqueIndex.columns
|
const columns = uniqueIndex.columns
|
||||||
.map((columnName) => {
|
.map((columnName) => {
|
||||||
const column = contract.tables[uniqueIndex.table]?.columns[columnName];
|
const column = contract.tables[uniqueIndex.table]?.columns[columnName];
|
||||||
@@ -169,7 +188,7 @@ function buildUniqueIndexStatement(dialect: Dialect, uniqueIndex: SchemaContract
|
|||||||
return `CREATE UNIQUE INDEX ${quoteIdentifier(dialect, uniqueIndex.name)} ON ${quoteIdentifier(dialect, uniqueIndex.table)} (${columns})`;
|
return `CREATE UNIQUE INDEX ${quoteIdentifier(dialect, uniqueIndex.name)} ON ${quoteIdentifier(dialect, uniqueIndex.table)} (${columns})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildIndexStatement(dialect: Dialect, index: SchemaContractIndex, contract: SchemaContract): string {
|
function buildIndexStatement(dialect: SqlDialect, index: SchemaContractIndex, contract: SchemaContract): string {
|
||||||
const columns = index.columns
|
const columns = index.columns
|
||||||
.map((columnName) => {
|
.map((columnName) => {
|
||||||
const column = contract.tables[index.table]?.columns[columnName];
|
const column = contract.tables[index.table]?.columns[columnName];
|
||||||
@@ -180,7 +199,93 @@ function buildIndexStatement(dialect: Dialect, index: SchemaContractIndex, contr
|
|||||||
return `CREATE INDEX ${quoteIdentifier(dialect, index.name)} ON ${quoteIdentifier(dialect, index.table)} (${columns})`;
|
return `CREATE INDEX ${quoteIdentifier(dialect, index.name)} ON ${quoteIdentifier(dialect, index.table)} (${columns})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildBootstrapSql(dialect: Dialect, contract: SchemaContract): string {
|
function serializeColumn(column: SchemaContractColumn): string {
|
||||||
|
return [
|
||||||
|
column.logicalType,
|
||||||
|
column.notNull ? 'not-null' : 'nullable',
|
||||||
|
column.defaultValue ?? 'default:null',
|
||||||
|
column.primaryKey ? 'pk' : 'non-pk',
|
||||||
|
].join('|');
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeIndex(index: SchemaContractIndex): string {
|
||||||
|
return [index.table, index.columns.join(','), index.unique ? 'unique' : 'non-unique'].join('|');
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeUnique(unique: SchemaContractUnique): string {
|
||||||
|
return [unique.table, unique.columns.join(',')].join('|');
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeForeignKey(foreignKey: SchemaContractForeignKey): string {
|
||||||
|
return [
|
||||||
|
foreignKey.table,
|
||||||
|
foreignKey.columns.join(','),
|
||||||
|
foreignKey.referencedTable,
|
||||||
|
foreignKey.referencedColumns.join(','),
|
||||||
|
foreignKey.onDelete ?? 'null',
|
||||||
|
].join('|');
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertAdditiveSchemaDiff(currentContract: SchemaContract, previousContract: SchemaContract): void {
|
||||||
|
const violations: string[] = [];
|
||||||
|
|
||||||
|
for (const [tableName, previousTable] of Object.entries(previousContract.tables)) {
|
||||||
|
const currentTable = currentContract.tables[tableName];
|
||||||
|
if (!currentTable) {
|
||||||
|
violations.push(`removed table ${tableName}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [columnName, previousColumn] of Object.entries(previousTable.columns)) {
|
||||||
|
const currentColumn = currentTable.columns[columnName];
|
||||||
|
if (!currentColumn) {
|
||||||
|
violations.push(`removed column ${tableName}.${columnName}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (serializeColumn(currentColumn) !== serializeColumn(previousColumn)) {
|
||||||
|
violations.push(`changed column ${tableName}.${columnName}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentIndexes = new Map(currentContract.indexes.map((index) => [index.name, index]));
|
||||||
|
for (const previousIndex of previousContract.indexes) {
|
||||||
|
const currentIndex = currentIndexes.get(previousIndex.name);
|
||||||
|
if (!currentIndex) {
|
||||||
|
violations.push(`removed index ${previousIndex.name}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (serializeIndex(currentIndex) !== serializeIndex(previousIndex)) {
|
||||||
|
violations.push(`changed index ${previousIndex.name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentUniques = new Map(currentContract.uniques.map((unique) => [unique.name, unique]));
|
||||||
|
for (const previousUnique of previousContract.uniques) {
|
||||||
|
const currentUnique = currentUniques.get(previousUnique.name);
|
||||||
|
if (!currentUnique) {
|
||||||
|
violations.push(`removed unique ${previousUnique.name}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (serializeUnique(currentUnique) !== serializeUnique(previousUnique)) {
|
||||||
|
violations.push(`changed unique ${previousUnique.name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentForeignKeys = new Set(currentContract.foreignKeys.map(serializeForeignKey));
|
||||||
|
for (const previousForeignKey of previousContract.foreignKeys) {
|
||||||
|
if (!currentForeignKeys.has(serializeForeignKey(previousForeignKey))) {
|
||||||
|
violations.push(`removed foreign key ${previousForeignKey.table}(${previousForeignKey.columns.join(',')})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (violations.length > 0) {
|
||||||
|
throw new Error(`Non-additive schema diff detected:\n- ${violations.join('\n- ')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateBootstrapSql(dialect: SqlDialect, contract: SchemaContract): string {
|
||||||
const tableStatements = sortTableNamesForCreation(contract)
|
const tableStatements = sortTableNamesForCreation(contract)
|
||||||
.map((tableName) => buildCreateTableStatement(dialect, tableName, contract));
|
.map((tableName) => buildCreateTableStatement(dialect, tableName, contract));
|
||||||
|
|
||||||
@@ -200,7 +305,7 @@ function buildBootstrapSql(dialect: Dialect, contract: SchemaContract): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildAddColumnStatement(
|
function buildAddColumnStatement(
|
||||||
dialect: Dialect,
|
dialect: SqlDialect,
|
||||||
tableName: string,
|
tableName: string,
|
||||||
columnName: string,
|
columnName: string,
|
||||||
column: SchemaContractColumn,
|
column: SchemaContractColumn,
|
||||||
@@ -208,8 +313,8 @@ function buildAddColumnStatement(
|
|||||||
return `ALTER TABLE ${quoteIdentifier(dialect, tableName)} ADD COLUMN ${buildColumnDefinition(dialect, columnName, column)}`;
|
return `ALTER TABLE ${quoteIdentifier(dialect, tableName)} ADD COLUMN ${buildColumnDefinition(dialect, columnName, column)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildUpgradeSql(
|
export function generateUpgradeSql(
|
||||||
dialect: Dialect,
|
dialect: SqlDialect,
|
||||||
currentContract: SchemaContract,
|
currentContract: SchemaContract,
|
||||||
previousContract?: SchemaContract | null,
|
previousContract?: SchemaContract | null,
|
||||||
): string {
|
): string {
|
||||||
@@ -217,6 +322,8 @@ function buildUpgradeSql(
|
|||||||
return `-- no previous schema contract available for ${dialect} additive upgrade generation\n`;
|
return `-- no previous schema contract available for ${dialect} additive upgrade generation\n`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assertAdditiveSchemaDiff(currentContract, previousContract);
|
||||||
|
|
||||||
const previousTableNames = new Set(Object.keys(previousContract.tables));
|
const previousTableNames = new Set(Object.keys(previousContract.tables));
|
||||||
const currentTableNames = Object.keys(currentContract.tables).sort((left, right) => left.localeCompare(right, 'en'));
|
const currentTableNames = Object.keys(currentContract.tables).sort((left, right) => left.localeCompare(right, 'en'));
|
||||||
const addedTableNames = currentTableNames.filter((tableName) => !previousTableNames.has(tableName));
|
const addedTableNames = currentTableNames.filter((tableName) => !previousTableNames.has(tableName));
|
||||||
@@ -274,10 +381,10 @@ export function generateDialectArtifacts(
|
|||||||
previousContract?: SchemaContract | null,
|
previousContract?: SchemaContract | null,
|
||||||
): GeneratedDialectArtifacts {
|
): GeneratedDialectArtifacts {
|
||||||
return {
|
return {
|
||||||
mysqlBootstrap: buildBootstrapSql('mysql', contract),
|
mysqlBootstrap: generateBootstrapSql('mysql', contract),
|
||||||
postgresBootstrap: buildBootstrapSql('postgres', contract),
|
postgresBootstrap: generateBootstrapSql('postgres', contract),
|
||||||
mysqlUpgrade: buildUpgradeSql('mysql', contract, previousContract),
|
mysqlUpgrade: generateUpgradeSql('mysql', contract, previousContract),
|
||||||
postgresUpgrade: buildUpgradeSql('postgres', contract, previousContract),
|
postgresUpgrade: generateUpgradeSql('postgres', contract, previousContract),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,11 @@ import { tmpdir } from 'node:os';
|
|||||||
import pg from 'pg';
|
import pg from 'pg';
|
||||||
import { join, resolve } from 'node:path';
|
import { join, resolve } from 'node:path';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { resolveGeneratedArtifactPath } from './schemaArtifactGenerator.js';
|
import {
|
||||||
|
generateBootstrapSql,
|
||||||
|
generateUpgradeSql,
|
||||||
|
resolveGeneratedArtifactPath,
|
||||||
|
} from './schemaArtifactGenerator.js';
|
||||||
import type {
|
import type {
|
||||||
LogicalColumnType,
|
LogicalColumnType,
|
||||||
SchemaContract,
|
SchemaContract,
|
||||||
@@ -30,6 +34,8 @@ export interface MaterializeFreshSchemaOptions {
|
|||||||
ssl?: boolean;
|
ssl?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ApplyContractFixtureThenUpgradeOptions extends MaterializeFreshSchemaOptions {}
|
||||||
|
|
||||||
type SqliteTableInfoRow = {
|
type SqliteTableInfoRow = {
|
||||||
name: string;
|
name: string;
|
||||||
type: string;
|
type: string;
|
||||||
@@ -124,7 +130,12 @@ function splitMigrationStatements(sqlText: string): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function splitSqlStatements(sqlText: string): string[] {
|
function splitSqlStatements(sqlText: string): string[] {
|
||||||
return sqlText
|
const withoutCommentLines = sqlText
|
||||||
|
.split(/\r?\n/g)
|
||||||
|
.filter((line) => !line.trim().startsWith('--'))
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
return withoutCommentLines
|
||||||
.split(/;\s*(?:\r?\n|$)/g)
|
.split(/;\s*(?:\r?\n|$)/g)
|
||||||
.map((statement) => statement.trim())
|
.map((statement) => statement.trim())
|
||||||
.filter((statement) => statement.length > 0);
|
.filter((statement) => statement.length > 0);
|
||||||
@@ -762,13 +773,76 @@ function applySqliteMigrations(sqlite: Database.Database): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createTemporarySqlitePath(): string {
|
||||||
|
const tempDir = mkdtempSync(join(tmpdir(), 'metapi-schema-parity-'));
|
||||||
|
return resolve(tempDir, `${randomUUID()}.db`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySqliteStatements(sqlitePath: string, statements: string[]): void {
|
||||||
|
const sqlite = new Database(sqlitePath);
|
||||||
|
sqlite.pragma('foreign_keys = ON');
|
||||||
|
try {
|
||||||
|
for (const statement of statements) {
|
||||||
|
sqlite.exec(statement);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
sqlite.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyMySqlStatements(
|
||||||
|
connectionString: string,
|
||||||
|
ssl: boolean | undefined,
|
||||||
|
statements: string[],
|
||||||
|
resetSchema = false,
|
||||||
|
): Promise<void> {
|
||||||
|
const connectionOptions: mysql.ConnectionOptions = { uri: connectionString };
|
||||||
|
if (ssl) {
|
||||||
|
connectionOptions.ssl = { rejectUnauthorized: false };
|
||||||
|
}
|
||||||
|
const connection = await mysql.createConnection(connectionOptions);
|
||||||
|
try {
|
||||||
|
if (resetSchema) {
|
||||||
|
await resetMySqlSchema(connection);
|
||||||
|
}
|
||||||
|
for (const statement of statements) {
|
||||||
|
await connection.query(statement);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await connection.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyPostgresStatements(
|
||||||
|
connectionString: string,
|
||||||
|
ssl: boolean | undefined,
|
||||||
|
statements: string[],
|
||||||
|
resetSchema = false,
|
||||||
|
): Promise<void> {
|
||||||
|
const clientOptions: pg.ClientConfig = { connectionString };
|
||||||
|
if (ssl) {
|
||||||
|
clientOptions.ssl = { rejectUnauthorized: false };
|
||||||
|
}
|
||||||
|
const client = new pg.Client(clientOptions);
|
||||||
|
await client.connect();
|
||||||
|
try {
|
||||||
|
if (resetSchema) {
|
||||||
|
await resetPostgresSchema(client);
|
||||||
|
}
|
||||||
|
for (const statement of statements) {
|
||||||
|
await client.query(statement);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await client.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function materializeFreshSchema(
|
export async function materializeFreshSchema(
|
||||||
dialect: SchemaIntrospectionDialect,
|
dialect: SchemaIntrospectionDialect,
|
||||||
options: MaterializeFreshSchemaOptions = {},
|
options: MaterializeFreshSchemaOptions = {},
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
if (dialect === 'sqlite') {
|
if (dialect === 'sqlite') {
|
||||||
const tempDir = mkdtempSync(join(tmpdir(), 'metapi-schema-parity-'));
|
const sqlitePath = createTemporarySqlitePath();
|
||||||
const sqlitePath = resolve(tempDir, `${randomUUID()}.db`);
|
|
||||||
const sqlite = new Database(sqlitePath);
|
const sqlite = new Database(sqlitePath);
|
||||||
sqlite.pragma('foreign_keys = ON');
|
sqlite.pragma('foreign_keys = ON');
|
||||||
try {
|
try {
|
||||||
@@ -785,35 +859,47 @@ export async function materializeFreshSchema(
|
|||||||
|
|
||||||
const bootstrapStatements = splitSqlStatements(readBootstrapSql(dialect));
|
const bootstrapStatements = splitSqlStatements(readBootstrapSql(dialect));
|
||||||
if (dialect === 'mysql') {
|
if (dialect === 'mysql') {
|
||||||
const connectionOptions: mysql.ConnectionOptions = { uri: options.connectionString };
|
await applyMySqlStatements(options.connectionString, options.ssl, bootstrapStatements, true);
|
||||||
if (options.ssl) {
|
return options.connectionString;
|
||||||
connectionOptions.ssl = { rejectUnauthorized: false };
|
}
|
||||||
|
|
||||||
|
await applyPostgresStatements(options.connectionString, options.ssl, bootstrapStatements, true);
|
||||||
|
return options.connectionString;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyContractFixtureThenUpgrade(
|
||||||
|
dialect: SchemaIntrospectionDialect,
|
||||||
|
baselineContract: SchemaContract,
|
||||||
|
currentContract: SchemaContract,
|
||||||
|
options: ApplyContractFixtureThenUpgradeOptions = {},
|
||||||
|
): Promise<string> {
|
||||||
|
const bootstrapStatements = splitSqlStatements(generateBootstrapSql(dialect, baselineContract));
|
||||||
|
const upgradeStatements = splitSqlStatements(generateUpgradeSql(dialect, currentContract, baselineContract));
|
||||||
|
|
||||||
|
if (dialect === 'sqlite') {
|
||||||
|
const sqlitePath = createTemporarySqlitePath();
|
||||||
|
applySqliteStatements(sqlitePath, bootstrapStatements);
|
||||||
|
if (upgradeStatements.length > 0) {
|
||||||
|
applySqliteStatements(sqlitePath, upgradeStatements);
|
||||||
}
|
}
|
||||||
const connection = await mysql.createConnection(connectionOptions);
|
return sqlitePath;
|
||||||
try {
|
}
|
||||||
await resetMySqlSchema(connection);
|
|
||||||
for (const statement of bootstrapStatements) {
|
if (!options.connectionString) {
|
||||||
await connection.query(statement);
|
throw new Error(`connectionString is required to upgrade ${dialect} parity schema`);
|
||||||
}
|
}
|
||||||
} finally {
|
|
||||||
await connection.end();
|
if (dialect === 'mysql') {
|
||||||
|
await applyMySqlStatements(options.connectionString, options.ssl, bootstrapStatements, true);
|
||||||
|
if (upgradeStatements.length > 0) {
|
||||||
|
await applyMySqlStatements(options.connectionString, options.ssl, upgradeStatements, false);
|
||||||
}
|
}
|
||||||
return options.connectionString;
|
return options.connectionString;
|
||||||
}
|
}
|
||||||
|
|
||||||
const clientOptions: pg.ClientConfig = { connectionString: options.connectionString };
|
await applyPostgresStatements(options.connectionString, options.ssl, bootstrapStatements, true);
|
||||||
if (options.ssl) {
|
if (upgradeStatements.length > 0) {
|
||||||
clientOptions.ssl = { rejectUnauthorized: false };
|
await applyPostgresStatements(options.connectionString, options.ssl, upgradeStatements, false);
|
||||||
}
|
|
||||||
const client = new pg.Client(clientOptions);
|
|
||||||
await client.connect();
|
|
||||||
try {
|
|
||||||
await resetPostgresSchema(client);
|
|
||||||
for (const statement of bootstrapStatements) {
|
|
||||||
await client.query(statement);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
await client.end();
|
|
||||||
}
|
}
|
||||||
return options.connectionString;
|
return options.connectionString;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import baselineContract from './generated/fixtures/2026-03-14-baseline.schemaContract.json' with { type: 'json' };
|
||||||
|
import currentContract from './generated/schemaContract.json' with { type: 'json' };
|
||||||
|
import { applyContractFixtureThenUpgrade, introspectLiveSchema } from './schemaIntrospection.js';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
const mysqlUpgrade = process.env.DB_PARITY_MYSQL_URL ? it : it.skip;
|
||||||
|
const postgresUpgrade = process.env.DB_PARITY_POSTGRES_URL ? it : it.skip;
|
||||||
|
|
||||||
|
describe('schema upgrade parity', () => {
|
||||||
|
it('upgrades sqlite to the current contract', async () => {
|
||||||
|
const sqliteUrl = await applyContractFixtureThenUpgrade('sqlite', baselineContract, currentContract);
|
||||||
|
const live = await introspectLiveSchema({ dialect: 'sqlite', connectionString: sqliteUrl });
|
||||||
|
expect(live).toEqual(currentContract);
|
||||||
|
});
|
||||||
|
|
||||||
|
mysqlUpgrade('upgrades mysql to the current contract', async () => {
|
||||||
|
const mysqlUrl = await applyContractFixtureThenUpgrade('mysql', baselineContract, currentContract, {
|
||||||
|
connectionString: process.env.DB_PARITY_MYSQL_URL!,
|
||||||
|
});
|
||||||
|
const live = await introspectLiveSchema({ dialect: 'mysql', connectionString: mysqlUrl });
|
||||||
|
expect(live).toEqual(currentContract);
|
||||||
|
});
|
||||||
|
|
||||||
|
postgresUpgrade('upgrades postgres to the current contract', async () => {
|
||||||
|
const postgresUrl = await applyContractFixtureThenUpgrade('postgres', baselineContract, currentContract, {
|
||||||
|
connectionString: process.env.DB_PARITY_POSTGRES_URL!,
|
||||||
|
});
|
||||||
|
const live = await introspectLiveSchema({ dialect: 'postgres', connectionString: postgresUrl });
|
||||||
|
expect(live).toEqual(currentContract);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user