Merge pull request #112 from cita-777/fix/external-db-runtime-upgrade
fix: harden external database runtime upgrades
This commit is contained in:
@@ -204,6 +204,9 @@ jobs:
|
||||
- name: Run mysql schema upgrade
|
||||
run: npm run test:schema:upgrade
|
||||
|
||||
- name: Run mysql runtime schema bootstrap
|
||||
run: npm run test:schema:runtime
|
||||
|
||||
schema-postgres:
|
||||
name: Schema Check (Postgres)
|
||||
if: github.event_name == 'pull_request'
|
||||
@@ -246,6 +249,9 @@ jobs:
|
||||
- name: Run postgres schema upgrade
|
||||
run: npm run test:schema:upgrade
|
||||
|
||||
- name: Run postgres runtime schema bootstrap
|
||||
run: npm run test:schema:runtime
|
||||
|
||||
docs-build:
|
||||
name: Build Docs
|
||||
if: github.event_name == 'pull_request' && needs.changes.outputs.docs == 'true'
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
"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",
|
||||
"test:schema:runtime": "vitest run --root . src/server/db/runtimeSchemaBootstrap.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",
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import baselineContract from './generated/fixtures/2026-03-14-baseline.schemaContract.json' with { type: 'json' };
|
||||
import currentContract from './generated/schemaContract.json' with { type: 'json' };
|
||||
import mysql from 'mysql2/promise';
|
||||
import pg from 'pg';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { generateBootstrapSql } from './schemaArtifactGenerator.js';
|
||||
import { __schemaIntrospectionTestUtils, introspectLiveSchema } from './schemaIntrospection.js';
|
||||
import { bootstrapRuntimeDatabaseSchema } from './runtimeSchemaBootstrap.js';
|
||||
|
||||
const mysqlRuntime = process.env.DB_PARITY_MYSQL_URL ? it : it.skip;
|
||||
const postgresRuntime = process.env.DB_PARITY_POSTGRES_URL ? it : it.skip;
|
||||
|
||||
async function resetMySqlSchema(connectionString: string): Promise<void> {
|
||||
const connection = await mysql.createConnection({ uri: connectionString });
|
||||
try {
|
||||
await connection.query('SET FOREIGN_KEY_CHECKS = 0');
|
||||
const [rows] = await connection.query(`
|
||||
SELECT table_name AS table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_type = 'BASE TABLE'
|
||||
`);
|
||||
for (const row of rows as Array<Record<string, unknown>>) {
|
||||
const tableName = String(row.table_name || '');
|
||||
if (!tableName) continue;
|
||||
await connection.query(`DROP TABLE IF EXISTS \`${tableName}\``);
|
||||
}
|
||||
await connection.query('SET FOREIGN_KEY_CHECKS = 1');
|
||||
} finally {
|
||||
await connection.end();
|
||||
}
|
||||
}
|
||||
|
||||
async function applyMySqlStatements(connectionString: string, statements: string[]): Promise<void> {
|
||||
const connection = await mysql.createConnection({ uri: connectionString });
|
||||
try {
|
||||
for (const statement of statements) {
|
||||
await connection.query(statement);
|
||||
}
|
||||
} finally {
|
||||
await connection.end();
|
||||
}
|
||||
}
|
||||
|
||||
async function resetPostgresSchema(connectionString: string): Promise<void> {
|
||||
const client = new pg.Client({ connectionString });
|
||||
await client.connect();
|
||||
try {
|
||||
const result = await client.query(`
|
||||
SELECT tablename
|
||||
FROM pg_tables
|
||||
WHERE schemaname = current_schema()
|
||||
ORDER BY tablename ASC
|
||||
`);
|
||||
for (const row of result.rows as Array<{ tablename: string }>) {
|
||||
await client.query(`DROP TABLE IF EXISTS "${row.tablename}" CASCADE`);
|
||||
}
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
}
|
||||
|
||||
async function applyPostgresStatements(connectionString: string, statements: string[]): Promise<void> {
|
||||
const client = new pg.Client({ connectionString });
|
||||
await client.connect();
|
||||
try {
|
||||
for (const statement of statements) {
|
||||
await client.query(statement);
|
||||
}
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
}
|
||||
|
||||
describe('runtime schema bootstrap live upgrade path', () => {
|
||||
mysqlRuntime('upgrades mysql runtime schemas from an older live contract', async () => {
|
||||
const connectionString = process.env.DB_PARITY_MYSQL_URL!;
|
||||
const baselineStatements = __schemaIntrospectionTestUtils.splitSqlStatements(
|
||||
generateBootstrapSql('mysql', baselineContract),
|
||||
);
|
||||
|
||||
await resetMySqlSchema(connectionString);
|
||||
await applyMySqlStatements(connectionString, baselineStatements);
|
||||
|
||||
await bootstrapRuntimeDatabaseSchema({
|
||||
dialect: 'mysql',
|
||||
connectionString,
|
||||
});
|
||||
|
||||
const live = await introspectLiveSchema({ dialect: 'mysql', connectionString });
|
||||
expect(live).toEqual(currentContract);
|
||||
});
|
||||
|
||||
postgresRuntime('upgrades postgres runtime schemas from an older live contract', async () => {
|
||||
const connectionString = process.env.DB_PARITY_POSTGRES_URL!;
|
||||
const baselineStatements = __schemaIntrospectionTestUtils.splitSqlStatements(
|
||||
generateBootstrapSql('postgres', baselineContract),
|
||||
);
|
||||
|
||||
await resetPostgresSchema(connectionString);
|
||||
await applyPostgresStatements(connectionString, baselineStatements);
|
||||
|
||||
await bootstrapRuntimeDatabaseSchema({
|
||||
dialect: 'postgres',
|
||||
connectionString,
|
||||
});
|
||||
|
||||
const live = await introspectLiveSchema({ dialect: 'postgres', connectionString });
|
||||
expect(live).toEqual(currentContract);
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,7 @@
|
||||
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 { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
__runtimeSchemaBootstrapTestUtils,
|
||||
@@ -30,73 +34,81 @@ function createStubClient(dialect: RuntimeSchemaDialect, executedSql: string[]):
|
||||
}
|
||||
|
||||
describe('runtime schema bootstrap', () => {
|
||||
it.each(['mysql', 'postgres'] as const)('loads generated bootstrap statements for %s', async (dialect) => {
|
||||
it.each(['mysql', 'postgres'] as const)('executes live-schema upgrade statements for %s', async (dialect) => {
|
||||
const executedSql: string[] = [];
|
||||
const expectedBootstrapSql = __runtimeSchemaBootstrapTestUtils.readGeneratedBootstrapStatements(dialect);
|
||||
const expectedUpgradeSql = __runtimeSchemaBootstrapTestUtils.splitSqlStatements(
|
||||
generateUpgradeSql(dialect, currentContract, baselineContract),
|
||||
);
|
||||
|
||||
await ensureRuntimeDatabaseSchema(createStubClient(dialect, executedSql));
|
||||
await ensureRuntimeDatabaseSchema(createStubClient(dialect, executedSql), {
|
||||
currentContract,
|
||||
liveContract: baselineContract,
|
||||
});
|
||||
|
||||
expect(executedSql.slice(0, expectedBootstrapSql.length)).toEqual(expectedBootstrapSql);
|
||||
expect(executedSql.slice(0, expectedUpgradeSql.length)).toEqual(expectedUpgradeSql);
|
||||
});
|
||||
|
||||
it('ignores duplicate mysql index errors when replaying bootstrap statements', async () => {
|
||||
it('skips external schema execution when live schema already matches the current contract', async () => {
|
||||
const executedSql: string[] = [];
|
||||
const targetSql = 'CREATE UNIQUE INDEX `model_availability_account_model_unique` ON `model_availability` (`account_id`, `model_name`(191))';
|
||||
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('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 === targetSql) {
|
||||
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(targetSql);
|
||||
expect(executedSql).toContain(duplicateColumnSql);
|
||||
expect(executedSql).toContain(duplicateIndexSql);
|
||||
});
|
||||
|
||||
it('replays the mysql bootstrap against an already-initialized schema', async () => {
|
||||
it('ignores postgres relation-already-exists errors when replaying additive schema statements', async () => {
|
||||
const executedSql: string[] = [];
|
||||
const createdStatements = new Set<string>();
|
||||
const targetSql = __runtimeSchemaBootstrapTestUtils.splitSqlStatements(
|
||||
generateUpgradeSql('postgres', currentContract, baselineContract),
|
||||
).find((sqlText) => sqlText.includes('proxy_files_public_id_unique'));
|
||||
|
||||
const mysqlClient = createStubClient('mysql', executedSql);
|
||||
mysqlClient.execute = async (sqlText: string) => {
|
||||
executedSql.push(sqlText);
|
||||
const normalized = sqlText.trim().toLowerCase();
|
||||
const createsSchemaObject = normalized.startsWith('create table if not exists')
|
||||
|| normalized.startsWith('create index')
|
||||
|| normalized.startsWith('create unique index');
|
||||
|
||||
if (createsSchemaObject) {
|
||||
if (createdStatements.has(sqlText)) {
|
||||
const error = new Error(
|
||||
normalized.startsWith('create table')
|
||||
? 'Table already exists'
|
||||
: 'Duplicate key name during bootstrap replay',
|
||||
) as Error & { code?: string };
|
||||
error.code = normalized.startsWith('create table') ? 'ER_TABLE_EXISTS_ERROR' : 'ER_DUP_KEYNAME';
|
||||
throw error;
|
||||
}
|
||||
createdStatements.add(sqlText);
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
await ensureRuntimeDatabaseSchema(mysqlClient);
|
||||
await ensureRuntimeDatabaseSchema(mysqlClient);
|
||||
|
||||
expect(executedSql.length).toBeGreaterThan(createdStatements.size);
|
||||
expect(createdStatements.size).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('ignores postgres relation-already-exists errors when replaying bootstrap statements', async () => {
|
||||
const executedSql: string[] = [];
|
||||
const targetSql = 'CREATE UNIQUE INDEX "model_availability_account_model_unique" ON "model_availability" ("account_id", "model_name")';
|
||||
expect(targetSql).toBeDefined();
|
||||
|
||||
await ensureRuntimeDatabaseSchema({
|
||||
...createStubClient('postgres', executedSql),
|
||||
@@ -109,6 +121,9 @@ describe('runtime schema bootstrap', () => {
|
||||
}
|
||||
return [];
|
||||
},
|
||||
}, {
|
||||
currentContract,
|
||||
liveContract: baselineContract,
|
||||
});
|
||||
|
||||
expect(executedSql).toContain(targetSql);
|
||||
|
||||
@@ -7,13 +7,16 @@ import {
|
||||
ensureLegacySchemaCompatibility,
|
||||
type LegacySchemaCompatInspector,
|
||||
} from './legacySchemaCompat.js';
|
||||
import { generateBootstrapSql, resolveGeneratedArtifactPath } from './schemaArtifactGenerator.js';
|
||||
import type { SchemaContract } from './schemaContract.js';
|
||||
import { generateBootstrapSql, generateUpgradeSql } from './schemaArtifactGenerator.js';
|
||||
import { introspectLiveSchema } from './schemaIntrospection.js';
|
||||
import { resolveGeneratedSchemaContractPath, type SchemaContract } from './schemaContract.js';
|
||||
|
||||
export type RuntimeSchemaDialect = 'sqlite' | 'mysql' | 'postgres';
|
||||
|
||||
export interface RuntimeSchemaClient {
|
||||
dialect: RuntimeSchemaDialect;
|
||||
connectionString: string;
|
||||
ssl: boolean;
|
||||
begin(): Promise<void>;
|
||||
commit(): Promise<void>;
|
||||
rollback(): Promise<void>;
|
||||
@@ -42,10 +45,13 @@ function isExistingSchemaObjectError(error: unknown): boolean {
|
||||
: '';
|
||||
|
||||
return code === 'ER_DUP_KEYNAME'
|
||||
|| code === 'ER_DUP_FIELDNAME'
|
||||
|| code === 'ER_TABLE_EXISTS_ERROR'
|
||||
|| code === '42P07'
|
||||
|| code === '42701'
|
||||
|| code === '42710'
|
||||
|| lowered.includes('already exists')
|
||||
|| lowered.includes('duplicate column')
|
||||
|| lowered.includes('duplicate key name')
|
||||
|| lowered.includes('relation') && lowered.includes('already exists');
|
||||
}
|
||||
@@ -144,12 +150,7 @@ function splitSqlStatements(sqlText: string): string[] {
|
||||
}
|
||||
|
||||
function readSchemaContract(): SchemaContract {
|
||||
return JSON.parse(readFileSync(resolveGeneratedArtifactPath('schemaContract.json'), 'utf8')) as SchemaContract;
|
||||
}
|
||||
|
||||
function readGeneratedBootstrapStatements(dialect: Exclude<RuntimeSchemaDialect, 'sqlite'>): string[] {
|
||||
const filename = dialect === 'mysql' ? 'mysql.bootstrap.sql' : 'postgres.bootstrap.sql';
|
||||
return splitSqlStatements(readFileSync(resolveGeneratedArtifactPath(filename), 'utf8'));
|
||||
return JSON.parse(readFileSync(resolveGeneratedSchemaContractPath(), 'utf8')) as SchemaContract;
|
||||
}
|
||||
|
||||
async function createPostgresClient(connectionString: string, ssl: boolean): Promise<RuntimeSchemaClient> {
|
||||
@@ -162,6 +163,8 @@ async function createPostgresClient(connectionString: string, ssl: boolean): Pro
|
||||
|
||||
return {
|
||||
dialect: 'postgres',
|
||||
connectionString,
|
||||
ssl,
|
||||
begin: async () => { await client.query('BEGIN'); },
|
||||
commit: async () => { await client.query('COMMIT'); },
|
||||
rollback: async () => { await client.query('ROLLBACK'); },
|
||||
@@ -185,6 +188,8 @@ async function createMySqlClient(connectionString: string, ssl: boolean): Promis
|
||||
|
||||
return {
|
||||
dialect: 'mysql',
|
||||
connectionString,
|
||||
ssl,
|
||||
begin: async () => { await connection.beginTransaction(); },
|
||||
commit: async () => { await connection.commit(); },
|
||||
rollback: async () => { await connection.rollback(); },
|
||||
@@ -210,6 +215,8 @@ async function createSqliteClient(connectionString: string): Promise<RuntimeSche
|
||||
|
||||
return {
|
||||
dialect: 'sqlite',
|
||||
connectionString,
|
||||
ssl: false,
|
||||
begin: async () => { sqlite.exec('BEGIN'); },
|
||||
commit: async () => { sqlite.exec('COMMIT'); },
|
||||
rollback: async () => { sqlite.exec('ROLLBACK'); },
|
||||
@@ -238,10 +245,43 @@ export async function createRuntimeSchemaClient(input: RuntimeSchemaConnectionIn
|
||||
return createSqliteClient(input.connectionString);
|
||||
}
|
||||
|
||||
export async function ensureRuntimeDatabaseSchema(client: RuntimeSchemaClient): Promise<void> {
|
||||
type EnsureRuntimeDatabaseSchemaOptions = {
|
||||
currentContract?: SchemaContract;
|
||||
liveContract?: SchemaContract;
|
||||
};
|
||||
|
||||
async function resolveLiveContract(client: RuntimeSchemaClient, liveContract?: SchemaContract): Promise<SchemaContract> {
|
||||
if (liveContract) {
|
||||
return liveContract;
|
||||
}
|
||||
|
||||
return introspectLiveSchema({
|
||||
dialect: client.dialect,
|
||||
connectionString: client.connectionString,
|
||||
ssl: client.ssl,
|
||||
});
|
||||
}
|
||||
|
||||
function buildExternalUpgradeStatements(
|
||||
dialect: Exclude<RuntimeSchemaDialect, 'sqlite'>,
|
||||
currentContract: SchemaContract,
|
||||
liveContract: SchemaContract,
|
||||
): string[] {
|
||||
return splitSqlStatements(generateUpgradeSql(dialect, currentContract, liveContract));
|
||||
}
|
||||
|
||||
export async function ensureRuntimeDatabaseSchema(
|
||||
client: RuntimeSchemaClient,
|
||||
options: EnsureRuntimeDatabaseSchemaOptions = {},
|
||||
): Promise<void> {
|
||||
const currentContract = options.currentContract ?? readSchemaContract();
|
||||
const statements = client.dialect === 'sqlite'
|
||||
? splitSqlStatements(generateBootstrapSql('sqlite', readSchemaContract()))
|
||||
: readGeneratedBootstrapStatements(client.dialect);
|
||||
? splitSqlStatements(generateBootstrapSql('sqlite', currentContract))
|
||||
: buildExternalUpgradeStatements(
|
||||
client.dialect,
|
||||
currentContract,
|
||||
await resolveLiveContract(client, options.liveContract),
|
||||
);
|
||||
|
||||
for (const sqlText of statements) {
|
||||
await executeBootstrapStatement(client, sqlText);
|
||||
@@ -260,5 +300,6 @@ export async function bootstrapRuntimeDatabaseSchema(input: RuntimeSchemaConnect
|
||||
}
|
||||
|
||||
export const __runtimeSchemaBootstrapTestUtils = {
|
||||
readGeneratedBootstrapStatements,
|
||||
splitSqlStatements,
|
||||
buildExternalUpgradeStatements,
|
||||
};
|
||||
|
||||
@@ -63,7 +63,7 @@ describe('settings factory reset api', () => {
|
||||
delete process.env.DATA_DIR;
|
||||
});
|
||||
|
||||
it('clears data, resets runtime config, and returns to initial sqlite state', async () => {
|
||||
it('clears business data while preserving infrastructure settings', async () => {
|
||||
const siteInsert = await db.insert(schema.sites).values({
|
||||
name: 'Reset Me',
|
||||
url: 'https://reset.example.com',
|
||||
@@ -149,10 +149,11 @@ describe('settings factory reset api', () => {
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ success: true });
|
||||
expect(config.authToken).toBe(FACTORY_RESET_ADMIN_TOKEN);
|
||||
expect(config.authToken).toBe('before-reset-token');
|
||||
expect(config.dbType).toBe('sqlite');
|
||||
expect(config.dbUrl).toBe('');
|
||||
expect(config.dbSsl).toBe(false);
|
||||
expect(config.systemProxyUrl).toBe('http://127.0.0.1:7890');
|
||||
|
||||
const sites = await db.select().from(schema.sites).all();
|
||||
expect(sites.map((site) => site.name)).toEqual([
|
||||
@@ -166,10 +167,12 @@ describe('settings factory reset api', () => {
|
||||
const dbTypeSetting = await db.select().from(schema.settings).where(eq(schema.settings.key, 'db_type')).get();
|
||||
const dbUrlSetting = await db.select().from(schema.settings).where(eq(schema.settings.key, 'db_url')).get();
|
||||
const dbSslSetting = await db.select().from(schema.settings).where(eq(schema.settings.key, 'db_ssl')).get();
|
||||
expect(authTokenSetting).toBeUndefined();
|
||||
expect(dbTypeSetting).toBeUndefined();
|
||||
expect(dbUrlSetting).toBeUndefined();
|
||||
expect(dbSslSetting).toBeUndefined();
|
||||
const systemProxySetting = await db.select().from(schema.settings).where(eq(schema.settings.key, 'system_proxy_url')).get();
|
||||
expect(authTokenSetting?.value).toBe(JSON.stringify('before-reset-token'));
|
||||
expect(dbTypeSetting?.value).toBe(JSON.stringify('sqlite'));
|
||||
expect(dbUrlSetting?.value).toBe(JSON.stringify(''));
|
||||
expect(dbSslSetting?.value).toBe(JSON.stringify(false));
|
||||
expect(systemProxySetting?.value).toBe(JSON.stringify('http://127.0.0.1:7890'));
|
||||
|
||||
expect(await db.select().from(schema.accounts).all()).toHaveLength(0);
|
||||
expect(await db.select().from(schema.accountTokens).all()).toHaveLength(0);
|
||||
|
||||
@@ -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