diff --git a/package.json b/package.json index fa2b9af..1dc02f5 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "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: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:sqlite": "tsx scripts/dev/db-smoke.ts --db-type sqlite", "smoke:db:mysql": "tsx scripts/dev/db-smoke.ts --db-type mysql", diff --git a/scripts/dev/generate-upgrade-fixture.ts b/scripts/dev/generate-upgrade-fixture.ts new file mode 100644 index 0000000..8dabcbf --- /dev/null +++ b/scripts/dev/generate-upgrade-fixture.ts @@ -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>(); + + 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(); + 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(); + 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}`); diff --git a/src/server/db/generated/fixtures/2026-03-14-baseline.schemaContract.json b/src/server/db/generated/fixtures/2026-03-14-baseline.schemaContract.json new file mode 100644 index 0000000..648bf4b --- /dev/null +++ b/src/server/db/generated/fixtures/2026-03-14-baseline.schemaContract.json @@ -0,0 +1,1120 @@ +{ + "tables": { + "account_tokens": { + "columns": { + "id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": true + }, + "account_id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "name": { + "logicalType": "text", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "token": { + "logicalType": "text", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "source": { + "logicalType": "text", + "notNull": false, + "defaultValue": "'manual'", + "primaryKey": false + }, + "enabled": { + "logicalType": "boolean", + "notNull": false, + "defaultValue": "true", + "primaryKey": false + }, + "is_default": { + "logicalType": "boolean", + "notNull": false, + "defaultValue": "false", + "primaryKey": false + }, + "created_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": "datetime('now')", + "primaryKey": false + }, + "updated_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": "datetime('now')", + "primaryKey": false + }, + "token_group": { + "logicalType": "text", + "notNull": false, + "defaultValue": null, + "primaryKey": false + } + } + }, + "accounts": { + "columns": { + "id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": true + }, + "site_id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "username": { + "logicalType": "text", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "access_token": { + "logicalType": "text", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "api_token": { + "logicalType": "text", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "balance": { + "logicalType": "real", + "notNull": false, + "defaultValue": "0", + "primaryKey": false + }, + "balance_used": { + "logicalType": "real", + "notNull": false, + "defaultValue": "0", + "primaryKey": false + }, + "quota": { + "logicalType": "real", + "notNull": false, + "defaultValue": "0", + "primaryKey": false + }, + "unit_cost": { + "logicalType": "real", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "value_score": { + "logicalType": "real", + "notNull": false, + "defaultValue": "0", + "primaryKey": false + }, + "status": { + "logicalType": "text", + "notNull": false, + "defaultValue": "'active'", + "primaryKey": false + }, + "checkin_enabled": { + "logicalType": "boolean", + "notNull": false, + "defaultValue": "true", + "primaryKey": false + }, + "last_checkin_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "last_balance_refresh": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "extra_config": { + "logicalType": "json", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "created_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": "datetime('now')", + "primaryKey": false + }, + "updated_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": "datetime('now')", + "primaryKey": false + }, + "is_pinned": { + "logicalType": "boolean", + "notNull": false, + "defaultValue": "false", + "primaryKey": false + }, + "sort_order": { + "logicalType": "integer", + "notNull": false, + "defaultValue": "0", + "primaryKey": false + } + } + }, + "checkin_logs": { + "columns": { + "id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": true + }, + "account_id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "status": { + "logicalType": "text", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "message": { + "logicalType": "text", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "reward": { + "logicalType": "text", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "created_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": "datetime('now')", + "primaryKey": false + } + } + }, + "events": { + "columns": { + "id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": true + }, + "type": { + "logicalType": "text", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "title": { + "logicalType": "text", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "message": { + "logicalType": "text", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "level": { + "logicalType": "text", + "notNull": true, + "defaultValue": "'info'", + "primaryKey": false + }, + "read": { + "logicalType": "boolean", + "notNull": false, + "defaultValue": "false", + "primaryKey": false + }, + "related_id": { + "logicalType": "integer", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "related_type": { + "logicalType": "text", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "created_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": "datetime('now')", + "primaryKey": false + } + } + }, + "model_availability": { + "columns": { + "id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": true + }, + "account_id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "model_name": { + "logicalType": "text", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "available": { + "logicalType": "boolean", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "latency_ms": { + "logicalType": "integer", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "checked_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": "datetime('now')", + "primaryKey": false + } + } + }, + "proxy_logs": { + "columns": { + "id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": true + }, + "route_id": { + "logicalType": "integer", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "channel_id": { + "logicalType": "integer", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "account_id": { + "logicalType": "integer", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "model_requested": { + "logicalType": "text", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "model_actual": { + "logicalType": "text", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "status": { + "logicalType": "text", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "http_status": { + "logicalType": "integer", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "latency_ms": { + "logicalType": "integer", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "prompt_tokens": { + "logicalType": "integer", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "completion_tokens": { + "logicalType": "integer", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "total_tokens": { + "logicalType": "integer", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "estimated_cost": { + "logicalType": "real", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "error_message": { + "logicalType": "text", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "retry_count": { + "logicalType": "integer", + "notNull": false, + "defaultValue": "0", + "primaryKey": false + }, + "created_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": "datetime('now')", + "primaryKey": false + }, + "billing_details": { + "logicalType": "json", + "notNull": false, + "defaultValue": null, + "primaryKey": false + } + } + }, + "route_channels": { + "columns": { + "id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": true + }, + "route_id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "account_id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "token_id": { + "logicalType": "integer", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "priority": { + "logicalType": "integer", + "notNull": false, + "defaultValue": "0", + "primaryKey": false + }, + "weight": { + "logicalType": "integer", + "notNull": false, + "defaultValue": "10", + "primaryKey": false + }, + "enabled": { + "logicalType": "boolean", + "notNull": false, + "defaultValue": "true", + "primaryKey": false + }, + "manual_override": { + "logicalType": "boolean", + "notNull": false, + "defaultValue": "false", + "primaryKey": false + }, + "success_count": { + "logicalType": "integer", + "notNull": false, + "defaultValue": "0", + "primaryKey": false + }, + "fail_count": { + "logicalType": "integer", + "notNull": false, + "defaultValue": "0", + "primaryKey": false + }, + "total_latency_ms": { + "logicalType": "integer", + "notNull": false, + "defaultValue": "0", + "primaryKey": false + }, + "total_cost": { + "logicalType": "real", + "notNull": false, + "defaultValue": "0", + "primaryKey": false + }, + "last_used_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "last_fail_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "cooldown_until": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": null, + "primaryKey": false + } + } + }, + "settings": { + "columns": { + "key": { + "logicalType": "text", + "notNull": true, + "defaultValue": null, + "primaryKey": true + }, + "value": { + "logicalType": "text", + "notNull": false, + "defaultValue": null, + "primaryKey": false + } + } + }, + "site_disabled_models": { + "columns": { + "id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": true + }, + "site_id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "model_name": { + "logicalType": "text", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "created_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": "datetime('now')", + "primaryKey": false + } + } + }, + "sites": { + "columns": { + "id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": true + }, + "name": { + "logicalType": "text", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "url": { + "logicalType": "text", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "platform": { + "logicalType": "text", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "status": { + "logicalType": "text", + "notNull": true, + "defaultValue": "'active'", + "primaryKey": false + }, + "api_key": { + "logicalType": "text", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "created_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": "datetime('now')", + "primaryKey": false + }, + "updated_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": "datetime('now')", + "primaryKey": false + }, + "is_pinned": { + "logicalType": "boolean", + "notNull": false, + "defaultValue": "false", + "primaryKey": false + }, + "sort_order": { + "logicalType": "integer", + "notNull": false, + "defaultValue": "0", + "primaryKey": false + } + } + }, + "token_model_availability": { + "columns": { + "id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": true + }, + "token_id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "model_name": { + "logicalType": "text", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "available": { + "logicalType": "boolean", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "latency_ms": { + "logicalType": "integer", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "checked_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": "datetime('now')", + "primaryKey": false + } + } + }, + "token_routes": { + "columns": { + "id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": true + }, + "model_pattern": { + "logicalType": "text", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "model_mapping": { + "logicalType": "json", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "enabled": { + "logicalType": "boolean", + "notNull": false, + "defaultValue": "true", + "primaryKey": false + }, + "created_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": "datetime('now')", + "primaryKey": false + }, + "updated_at": { + "logicalType": "datetime", + "notNull": false, + "defaultValue": "datetime('now')", + "primaryKey": false + } + } + } + }, + "indexes": [ + { + "name": "account_tokens_account_enabled_idx", + "table": "account_tokens", + "columns": [ + "account_id", + "enabled" + ], + "unique": false + }, + { + "name": "account_tokens_account_id_idx", + "table": "account_tokens", + "columns": [ + "account_id" + ], + "unique": false + }, + { + "name": "account_tokens_enabled_idx", + "table": "account_tokens", + "columns": [ + "enabled" + ], + "unique": false + }, + { + "name": "accounts_site_id_idx", + "table": "accounts", + "columns": [ + "site_id" + ], + "unique": false + }, + { + "name": "accounts_site_status_idx", + "table": "accounts", + "columns": [ + "site_id", + "status" + ], + "unique": false + }, + { + "name": "accounts_status_idx", + "table": "accounts", + "columns": [ + "status" + ], + "unique": false + }, + { + "name": "checkin_logs_account_created_at_idx", + "table": "checkin_logs", + "columns": [ + "account_id", + "created_at" + ], + "unique": false + }, + { + "name": "checkin_logs_created_at_idx", + "table": "checkin_logs", + "columns": [ + "created_at" + ], + "unique": false + }, + { + "name": "checkin_logs_status_idx", + "table": "checkin_logs", + "columns": [ + "status" + ], + "unique": false + }, + { + "name": "events_created_at_idx", + "table": "events", + "columns": [ + "created_at" + ], + "unique": false + }, + { + "name": "events_read_created_at_idx", + "table": "events", + "columns": [ + "read", + "created_at" + ], + "unique": false + }, + { + "name": "events_type_created_at_idx", + "table": "events", + "columns": [ + "type", + "created_at" + ], + "unique": false + }, + { + "name": "model_availability_account_available_idx", + "table": "model_availability", + "columns": [ + "account_id", + "available" + ], + "unique": false + }, + { + "name": "model_availability_account_model_unique", + "table": "model_availability", + "columns": [ + "account_id", + "model_name" + ], + "unique": true + }, + { + "name": "model_availability_model_name_idx", + "table": "model_availability", + "columns": [ + "model_name" + ], + "unique": false + }, + { + "name": "proxy_logs_account_created_at_idx", + "table": "proxy_logs", + "columns": [ + "account_id", + "created_at" + ], + "unique": false + }, + { + "name": "proxy_logs_created_at_idx", + "table": "proxy_logs", + "columns": [ + "created_at" + ], + "unique": false + }, + { + "name": "proxy_logs_model_actual_created_at_idx", + "table": "proxy_logs", + "columns": [ + "model_actual", + "created_at" + ], + "unique": false + }, + { + "name": "proxy_logs_status_created_at_idx", + "table": "proxy_logs", + "columns": [ + "status", + "created_at" + ], + "unique": false + }, + { + "name": "route_channels_account_id_idx", + "table": "route_channels", + "columns": [ + "account_id" + ], + "unique": false + }, + { + "name": "route_channels_route_enabled_idx", + "table": "route_channels", + "columns": [ + "route_id", + "enabled" + ], + "unique": false + }, + { + "name": "route_channels_route_id_idx", + "table": "route_channels", + "columns": [ + "route_id" + ], + "unique": false + }, + { + "name": "route_channels_route_token_idx", + "table": "route_channels", + "columns": [ + "route_id", + "token_id" + ], + "unique": false + }, + { + "name": "route_channels_token_id_idx", + "table": "route_channels", + "columns": [ + "token_id" + ], + "unique": false + }, + { + "name": "site_disabled_models_site_id_idx", + "table": "site_disabled_models", + "columns": [ + "site_id" + ], + "unique": false + }, + { + "name": "site_disabled_models_site_model_unique", + "table": "site_disabled_models", + "columns": [ + "site_id", + "model_name" + ], + "unique": true + }, + { + "name": "sites_status_idx", + "table": "sites", + "columns": [ + "status" + ], + "unique": false + }, + { + "name": "token_model_availability_available_idx", + "table": "token_model_availability", + "columns": [ + "available" + ], + "unique": false + }, + { + "name": "token_model_availability_model_name_idx", + "table": "token_model_availability", + "columns": [ + "model_name" + ], + "unique": false + }, + { + "name": "token_model_availability_token_available_idx", + "table": "token_model_availability", + "columns": [ + "token_id", + "available" + ], + "unique": false + }, + { + "name": "token_model_availability_token_model_unique", + "table": "token_model_availability", + "columns": [ + "token_id", + "model_name" + ], + "unique": true + }, + { + "name": "token_routes_enabled_idx", + "table": "token_routes", + "columns": [ + "enabled" + ], + "unique": false + }, + { + "name": "token_routes_model_pattern_idx", + "table": "token_routes", + "columns": [ + "model_pattern" + ], + "unique": false + } + ], + "uniques": [ + { + "name": "model_availability_account_model_unique", + "table": "model_availability", + "columns": [ + "account_id", + "model_name" + ] + }, + { + "name": "site_disabled_models_site_model_unique", + "table": "site_disabled_models", + "columns": [ + "site_id", + "model_name" + ] + }, + { + "name": "token_model_availability_token_model_unique", + "table": "token_model_availability", + "columns": [ + "token_id", + "model_name" + ] + } + ], + "foreignKeys": [ + { + "table": "account_tokens", + "columns": [ + "account_id" + ], + "referencedTable": "accounts", + "referencedColumns": [ + "id" + ], + "onDelete": "CASCADE" + }, + { + "table": "accounts", + "columns": [ + "site_id" + ], + "referencedTable": "sites", + "referencedColumns": [ + "id" + ], + "onDelete": "CASCADE" + }, + { + "table": "checkin_logs", + "columns": [ + "account_id" + ], + "referencedTable": "accounts", + "referencedColumns": [ + "id" + ], + "onDelete": "CASCADE" + }, + { + "table": "model_availability", + "columns": [ + "account_id" + ], + "referencedTable": "accounts", + "referencedColumns": [ + "id" + ], + "onDelete": "CASCADE" + }, + { + "table": "route_channels", + "columns": [ + "account_id" + ], + "referencedTable": "accounts", + "referencedColumns": [ + "id" + ], + "onDelete": "CASCADE" + }, + { + "table": "route_channels", + "columns": [ + "route_id" + ], + "referencedTable": "token_routes", + "referencedColumns": [ + "id" + ], + "onDelete": "CASCADE" + }, + { + "table": "route_channels", + "columns": [ + "token_id" + ], + "referencedTable": "account_tokens", + "referencedColumns": [ + "id" + ], + "onDelete": "SET NULL" + }, + { + "table": "site_disabled_models", + "columns": [ + "site_id" + ], + "referencedTable": "sites", + "referencedColumns": [ + "id" + ], + "onDelete": "CASCADE" + }, + { + "table": "token_model_availability", + "columns": [ + "token_id" + ], + "referencedTable": "account_tokens", + "referencedColumns": [ + "id" + ], + "onDelete": "CASCADE" + } + ] +} diff --git a/src/server/db/schemaArtifactGenerator.test.ts b/src/server/db/schemaArtifactGenerator.test.ts index 1afccad..8ecff80 100644 --- a/src/server/db/schemaArtifactGenerator.test.ts +++ b/src/server/db/schemaArtifactGenerator.test.ts @@ -106,4 +106,13 @@ describe('schema artifact generator', () => { expect(artifacts.mysqlBootstrap).toContain('`created_at` DATETIME 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); + }); }); diff --git a/src/server/db/schemaArtifactGenerator.ts b/src/server/db/schemaArtifactGenerator.ts index 7cd8d03..fa4ba10 100644 --- a/src/server/db/schemaArtifactGenerator.ts +++ b/src/server/db/schemaArtifactGenerator.ts @@ -18,6 +18,7 @@ export interface GeneratedDialectArtifacts { } type Dialect = 'mysql' | 'postgres'; +type SqlDialect = 'sqlite' | Dialect; function resolveDbDir(): string { return dirname(fileURLToPath(import.meta.url)); @@ -27,7 +28,7 @@ export function resolveGeneratedArtifactPath(filename: string): string { return resolve(resolveDbDir(), 'generated', filename); } -function quoteIdentifier(dialect: Dialect, identifier: string): string { +function quoteIdentifier(dialect: SqlDialect, identifier: string): string { return dialect === 'mysql' ? `\`${identifier}\`` : `"${identifier}"`; } @@ -35,7 +36,22 @@ function escapeMysqlTextPrefix(columnType: LogicalColumnType): string { 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') { switch (column.logicalType) { 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) { return ''; } if (column.logicalType === 'datetime') { + if (dialect === 'sqlite') { + return " DEFAULT (datetime('now'))"; + } return ' DEFAULT CURRENT_TIMESTAMP'; } @@ -89,18 +108,18 @@ function formatDefaultValue(column: SchemaContractColumn): string { } function buildColumnDefinition( - dialect: Dialect, + dialect: SqlDialect, columnName: string, column: SchemaContractColumn, ): string { const sqlType = mapColumnType(dialect, columnName, column); const notNull = column.notNull ? ' NOT NULL' : ''; - const defaultValue = formatDefaultValue(column); + const defaultValue = formatDefaultValue(dialect, column); const primaryKey = column.primaryKey ? ' PRIMARY KEY' : ''; 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 targetColumns = foreignKey.referencedColumns.map((column) => quoteIdentifier(dialect, column)).join(', '); const onDelete = foreignKey.onDelete ? ` ON DELETE ${foreignKey.onDelete.toUpperCase()}` : ''; @@ -144,7 +163,7 @@ function sortTableNamesForCreation(contract: SchemaContract): string[] { } function buildCreateTableStatement( - dialect: Dialect, + dialect: SqlDialect, tableName: string, contract: SchemaContract, ): string { @@ -158,7 +177,7 @@ function buildCreateTableStatement( 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 .map((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})`; } -function buildIndexStatement(dialect: Dialect, index: SchemaContractIndex, contract: SchemaContract): string { +function buildIndexStatement(dialect: SqlDialect, index: SchemaContractIndex, contract: SchemaContract): string { const columns = index.columns .map((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})`; } -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) .map((tableName) => buildCreateTableStatement(dialect, tableName, contract)); @@ -200,7 +305,7 @@ function buildBootstrapSql(dialect: Dialect, contract: SchemaContract): string { } function buildAddColumnStatement( - dialect: Dialect, + dialect: SqlDialect, tableName: string, columnName: string, column: SchemaContractColumn, @@ -208,8 +313,8 @@ function buildAddColumnStatement( return `ALTER TABLE ${quoteIdentifier(dialect, tableName)} ADD COLUMN ${buildColumnDefinition(dialect, columnName, column)}`; } -function buildUpgradeSql( - dialect: Dialect, +export function generateUpgradeSql( + dialect: SqlDialect, currentContract: SchemaContract, previousContract?: SchemaContract | null, ): string { @@ -217,6 +322,8 @@ function buildUpgradeSql( return `-- no previous schema contract available for ${dialect} additive upgrade generation\n`; } + assertAdditiveSchemaDiff(currentContract, previousContract); + const previousTableNames = new Set(Object.keys(previousContract.tables)); const currentTableNames = Object.keys(currentContract.tables).sort((left, right) => left.localeCompare(right, 'en')); const addedTableNames = currentTableNames.filter((tableName) => !previousTableNames.has(tableName)); @@ -274,10 +381,10 @@ export function generateDialectArtifacts( previousContract?: SchemaContract | null, ): GeneratedDialectArtifacts { return { - mysqlBootstrap: buildBootstrapSql('mysql', contract), - postgresBootstrap: buildBootstrapSql('postgres', contract), - mysqlUpgrade: buildUpgradeSql('mysql', contract, previousContract), - postgresUpgrade: buildUpgradeSql('postgres', contract, previousContract), + mysqlBootstrap: generateBootstrapSql('mysql', contract), + postgresBootstrap: generateBootstrapSql('postgres', contract), + mysqlUpgrade: generateUpgradeSql('mysql', contract, previousContract), + postgresUpgrade: generateUpgradeSql('postgres', contract, previousContract), }; } diff --git a/src/server/db/schemaIntrospection.ts b/src/server/db/schemaIntrospection.ts index 185d9a3..a3a97e3 100644 --- a/src/server/db/schemaIntrospection.ts +++ b/src/server/db/schemaIntrospection.ts @@ -5,7 +5,11 @@ import { tmpdir } from 'node:os'; import pg from 'pg'; import { join, resolve } from 'node:path'; import { randomUUID } from 'node:crypto'; -import { resolveGeneratedArtifactPath } from './schemaArtifactGenerator.js'; +import { + generateBootstrapSql, + generateUpgradeSql, + resolveGeneratedArtifactPath, +} from './schemaArtifactGenerator.js'; import type { LogicalColumnType, SchemaContract, @@ -30,6 +34,8 @@ export interface MaterializeFreshSchemaOptions { ssl?: boolean; } +export interface ApplyContractFixtureThenUpgradeOptions extends MaterializeFreshSchemaOptions {} + type SqliteTableInfoRow = { name: string; type: string; @@ -124,7 +130,12 @@ function splitMigrationStatements(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) .map((statement) => statement.trim()) .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 { + 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 { + 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( dialect: SchemaIntrospectionDialect, options: MaterializeFreshSchemaOptions = {}, ): Promise { if (dialect === 'sqlite') { - const tempDir = mkdtempSync(join(tmpdir(), 'metapi-schema-parity-')); - const sqlitePath = resolve(tempDir, `${randomUUID()}.db`); + const sqlitePath = createTemporarySqlitePath(); const sqlite = new Database(sqlitePath); sqlite.pragma('foreign_keys = ON'); try { @@ -785,35 +859,47 @@ export async function materializeFreshSchema( const bootstrapStatements = splitSqlStatements(readBootstrapSql(dialect)); if (dialect === 'mysql') { - const connectionOptions: mysql.ConnectionOptions = { uri: options.connectionString }; - if (options.ssl) { - connectionOptions.ssl = { rejectUnauthorized: false }; + await applyMySqlStatements(options.connectionString, options.ssl, bootstrapStatements, true); + return options.connectionString; + } + + await applyPostgresStatements(options.connectionString, options.ssl, bootstrapStatements, true); + return options.connectionString; +} + +export async function applyContractFixtureThenUpgrade( + dialect: SchemaIntrospectionDialect, + baselineContract: SchemaContract, + currentContract: SchemaContract, + options: ApplyContractFixtureThenUpgradeOptions = {}, +): Promise { + 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); - try { - await resetMySqlSchema(connection); - for (const statement of bootstrapStatements) { - await connection.query(statement); - } - } finally { - await connection.end(); + return sqlitePath; + } + + if (!options.connectionString) { + throw new Error(`connectionString is required to upgrade ${dialect} parity schema`); + } + + 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; } - const clientOptions: pg.ClientConfig = { connectionString: options.connectionString }; - if (options.ssl) { - clientOptions.ssl = { rejectUnauthorized: 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(); + await applyPostgresStatements(options.connectionString, options.ssl, bootstrapStatements, true); + if (upgradeStatements.length > 0) { + await applyPostgresStatements(options.connectionString, options.ssl, upgradeStatements, false); } return options.connectionString; } diff --git a/src/server/db/schemaUpgrade.live.test.ts b/src/server/db/schemaUpgrade.live.test.ts new file mode 100644 index 0000000..f58378a --- /dev/null +++ b/src/server/db/schemaUpgrade.live.test.ts @@ -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); + }); +});