fix(db): enforce runtime external schema upgrades
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import currentContract from '../db/generated/schemaContract.json' with { type: 'json' };
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
__databaseMigrationServiceTestUtils,
|
||||
@@ -5,6 +6,10 @@ import {
|
||||
normalizeMigrationInput,
|
||||
} from './databaseMigrationService.js';
|
||||
|
||||
function cloneContract<T>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
}
|
||||
|
||||
describe('databaseMigrationService', () => {
|
||||
it('accepts postgres migration input with normalized url', () => {
|
||||
const normalized = normalizeMigrationInput({
|
||||
@@ -103,8 +108,14 @@ describe('databaseMigrationService', () => {
|
||||
|
||||
it.each(['postgres', 'mysql', 'sqlite'] as const)('creates or patches sites schema with use_system_proxy and custom_headers for %s', async (dialect) => {
|
||||
const executedSql: string[] = [];
|
||||
const liveContract = cloneContract(currentContract);
|
||||
delete liveContract.tables.sites.columns.use_system_proxy;
|
||||
delete liveContract.tables.sites.columns.custom_headers;
|
||||
|
||||
await __databaseMigrationServiceTestUtils.ensureSchema({
|
||||
dialect,
|
||||
connectionString: dialect === 'sqlite' ? ':memory:' : `${dialect}://example.invalid/metapi`,
|
||||
ssl: false,
|
||||
begin: async () => {},
|
||||
commit: async () => {},
|
||||
rollback: async () => {},
|
||||
@@ -112,17 +123,11 @@ describe('databaseMigrationService', () => {
|
||||
executedSql.push(sqlText);
|
||||
return [];
|
||||
},
|
||||
queryScalar: async (sqlText, params = []) => {
|
||||
if (sqlText.includes('sqlite_master') || sqlText.includes('information_schema.tables')) {
|
||||
return 1;
|
||||
}
|
||||
if (sqlText.includes('pragma_table_info') || sqlText.includes('information_schema.columns')) {
|
||||
const columnName = String(params[1] ?? sqlText.match(/name = '([^']+)'/)?.[1] ?? '');
|
||||
return columnName === 'use_system_proxy' || columnName === 'custom_headers' ? 0 : 1;
|
||||
}
|
||||
return 0;
|
||||
},
|
||||
queryScalar: async () => 1,
|
||||
close: async () => {},
|
||||
}, {
|
||||
currentContract,
|
||||
liveContract,
|
||||
});
|
||||
|
||||
const useSystemProxySql = executedSql.find((sqlText) => sqlText.includes('use_system_proxy'));
|
||||
@@ -134,8 +139,13 @@ describe('databaseMigrationService', () => {
|
||||
|
||||
it.each(['postgres', 'mysql'] as const)('patches token_routes decision snapshot columns for %s', async (dialect) => {
|
||||
const executedSql: string[] = [];
|
||||
const liveContract = cloneContract(currentContract);
|
||||
delete liveContract.tables.token_routes.columns.decision_snapshot;
|
||||
|
||||
await __databaseMigrationServiceTestUtils.ensureSchema({
|
||||
dialect,
|
||||
connectionString: `${dialect}://example.invalid/metapi`,
|
||||
ssl: false,
|
||||
begin: async () => {},
|
||||
commit: async () => {},
|
||||
rollback: async () => {},
|
||||
@@ -143,17 +153,11 @@ describe('databaseMigrationService', () => {
|
||||
executedSql.push(sqlText);
|
||||
return [];
|
||||
},
|
||||
queryScalar: async (sqlText, params = []) => {
|
||||
if (sqlText.includes('information_schema.tables')) {
|
||||
return 1;
|
||||
}
|
||||
if (sqlText.includes('information_schema.columns')) {
|
||||
const columnName = String(params[1] ?? '');
|
||||
return columnName === 'decision_snapshot' ? 0 : 1;
|
||||
}
|
||||
return 0;
|
||||
},
|
||||
queryScalar: async () => 1,
|
||||
close: async () => {},
|
||||
}, {
|
||||
currentContract,
|
||||
liveContract,
|
||||
});
|
||||
|
||||
expect(
|
||||
|
||||
@@ -54,7 +54,7 @@ describe('factoryResetService', () => {
|
||||
delete process.env.DATA_DIR;
|
||||
});
|
||||
|
||||
it('clears current active data before switching runtime back to sqlite', async () => {
|
||||
it('clears current active data while preserving external runtime connectivity', async () => {
|
||||
await db.insert(schema.sites).values({
|
||||
name: 'External Runtime Site',
|
||||
url: 'https://external.example.com',
|
||||
@@ -81,10 +81,17 @@ describe('factoryResetService', () => {
|
||||
ensureDefaultSitesSeeded,
|
||||
});
|
||||
|
||||
expect(switchRuntimeDatabase).toHaveBeenCalledWith('sqlite', '', false);
|
||||
expect(runSqliteMigrations).toHaveBeenCalledTimes(1);
|
||||
expect(switchRuntimeDatabase).toHaveBeenCalledWith('postgres', 'postgres://user:pass@127.0.0.1:5432/metapi', true);
|
||||
expect(runSqliteMigrations).not.toHaveBeenCalled();
|
||||
expect(ensureDefaultSitesSeeded).toHaveBeenCalledTimes(1);
|
||||
expect(await db.select().from(schema.sites).all()).toHaveLength(0);
|
||||
expect(await db.select().from(schema.settings).all()).toHaveLength(0);
|
||||
expect(await db.select().from(schema.settings).all()).toEqual([
|
||||
{ key: 'auth_token', value: JSON.stringify('external-reset-token') },
|
||||
{ key: 'proxy_token', value: JSON.stringify('change-me-proxy-sk-token') },
|
||||
{ key: 'system_proxy_url', value: JSON.stringify('') },
|
||||
{ key: 'db_type', value: JSON.stringify('postgres') },
|
||||
{ key: 'db_url', value: JSON.stringify('postgres://user:pass@127.0.0.1:5432/metapi') },
|
||||
{ key: 'db_ssl', value: JSON.stringify(true) },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { buildConfig, config } from '../config.js';
|
||||
import { db, schema, switchRuntimeDatabase } from '../db/index.js';
|
||||
import { upsertSetting } from '../db/upsertSetting.js';
|
||||
import { updateBalanceRefreshCron, updateCheckinCron, updateLogCleanupSettings } from './checkinScheduler.js';
|
||||
import { ensureDefaultSitesSeeded } from './defaultSiteSeedService.js';
|
||||
import { startProxyLogRetentionService } from './proxyLogRetentionService.js';
|
||||
@@ -13,6 +14,15 @@ type FactoryResetDependencies = {
|
||||
ensureDefaultSitesSeeded?: typeof ensureDefaultSitesSeeded;
|
||||
};
|
||||
|
||||
type PreservedInfrastructureState = {
|
||||
authToken: string;
|
||||
proxyToken: string;
|
||||
systemProxyUrl: string;
|
||||
dbType: 'sqlite' | 'mysql' | 'postgres';
|
||||
dbUrl: string;
|
||||
dbSsl: boolean;
|
||||
};
|
||||
|
||||
async function clearAllBusinessData() {
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.delete(schema.routeChannels).run();
|
||||
@@ -32,13 +42,32 @@ async function clearAllBusinessData() {
|
||||
});
|
||||
}
|
||||
|
||||
function resetRuntimeConfigToInitialState() {
|
||||
function captureInfrastructureState(): PreservedInfrastructureState {
|
||||
return {
|
||||
authToken: config.authToken,
|
||||
proxyToken: config.proxyToken,
|
||||
systemProxyUrl: config.systemProxyUrl,
|
||||
dbType: config.dbType,
|
||||
dbUrl: config.dbUrl,
|
||||
dbSsl: config.dbSsl,
|
||||
};
|
||||
}
|
||||
|
||||
function shouldPreserveExternalRuntime(state: PreservedInfrastructureState): boolean {
|
||||
return state.dbType !== 'sqlite' && !!state.dbUrl.trim();
|
||||
}
|
||||
|
||||
function resetRuntimeConfigToInitialState(preserved: PreservedInfrastructureState) {
|
||||
const baseline = buildConfig(process.env);
|
||||
Object.assign(config, baseline);
|
||||
config.authToken = FACTORY_RESET_ADMIN_TOKEN;
|
||||
config.dbType = 'sqlite';
|
||||
config.dbUrl = '';
|
||||
config.dbSsl = false;
|
||||
config.authToken = preserved.authToken || baseline.authToken || FACTORY_RESET_ADMIN_TOKEN;
|
||||
config.proxyToken = preserved.proxyToken || baseline.proxyToken;
|
||||
config.systemProxyUrl = preserved.systemProxyUrl || baseline.systemProxyUrl;
|
||||
if (shouldPreserveExternalRuntime(preserved)) {
|
||||
config.dbType = preserved.dbType;
|
||||
config.dbUrl = preserved.dbUrl;
|
||||
config.dbSsl = preserved.dbSsl;
|
||||
}
|
||||
config.logCleanupConfigured = false;
|
||||
config.logCleanupUsageLogsEnabled = config.proxyLogRetentionDays > 0;
|
||||
config.logCleanupProgramLogsEnabled = false;
|
||||
@@ -55,6 +84,23 @@ function resetRuntimeConfigToInitialState() {
|
||||
invalidateSiteProxyCache();
|
||||
}
|
||||
|
||||
async function restoreInfrastructureSettings(preserved: PreservedInfrastructureState): Promise<void> {
|
||||
await upsertSetting('auth_token', preserved.authToken || FACTORY_RESET_ADMIN_TOKEN);
|
||||
await upsertSetting('proxy_token', preserved.proxyToken);
|
||||
await upsertSetting('system_proxy_url', preserved.systemProxyUrl);
|
||||
|
||||
if (shouldPreserveExternalRuntime(preserved)) {
|
||||
await upsertSetting('db_type', preserved.dbType);
|
||||
await upsertSetting('db_url', preserved.dbUrl);
|
||||
await upsertSetting('db_ssl', preserved.dbSsl);
|
||||
return;
|
||||
}
|
||||
|
||||
await upsertSetting('db_type', config.dbType);
|
||||
await upsertSetting('db_url', config.dbUrl);
|
||||
await upsertSetting('db_ssl', config.dbSsl);
|
||||
}
|
||||
|
||||
async function runDefaultSqliteMigrations() {
|
||||
const migrateModule = await import('../db/migrate.js');
|
||||
migrateModule.runSqliteMigrations();
|
||||
@@ -64,11 +110,15 @@ export async function performFactoryReset(deps: FactoryResetDependencies = {}):
|
||||
const switchRuntimeDatabaseImpl = deps.switchRuntimeDatabase ?? switchRuntimeDatabase;
|
||||
const runSqliteMigrationsImpl = deps.runSqliteMigrations ?? runDefaultSqliteMigrations;
|
||||
const ensureDefaultSitesSeededImpl = deps.ensureDefaultSitesSeeded ?? ensureDefaultSitesSeeded;
|
||||
const preserved = captureInfrastructureState();
|
||||
|
||||
await clearAllBusinessData();
|
||||
resetRuntimeConfigToInitialState();
|
||||
await switchRuntimeDatabaseImpl('sqlite', '', false);
|
||||
await runSqliteMigrationsImpl();
|
||||
resetRuntimeConfigToInitialState(preserved);
|
||||
await switchRuntimeDatabaseImpl(config.dbType, config.dbUrl, config.dbSsl);
|
||||
if (config.dbType === 'sqlite') {
|
||||
await runSqliteMigrationsImpl();
|
||||
}
|
||||
await clearAllBusinessData();
|
||||
await restoreInfrastructureSettings(preserved);
|
||||
await ensureDefaultSitesSeededImpl();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user