diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c63bca9..61065ea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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' diff --git a/package.json b/package.json index e0856a9..8e3080b 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/server/db/runtimeSchemaBootstrap.live.test.ts b/src/server/db/runtimeSchemaBootstrap.live.test.ts new file mode 100644 index 0000000..59fd564 --- /dev/null +++ b/src/server/db/runtimeSchemaBootstrap.live.test.ts @@ -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 { + 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>) { + 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 { + 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 { + 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 { + 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); + }); +}); diff --git a/src/server/db/runtimeSchemaBootstrap.test.ts b/src/server/db/runtimeSchemaBootstrap.test.ts index 9d4f460..d10cac9 100644 --- a/src/server/db/runtimeSchemaBootstrap.test.ts +++ b/src/server/db/runtimeSchemaBootstrap.test.ts @@ -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(); + 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); diff --git a/src/server/db/runtimeSchemaBootstrap.ts b/src/server/db/runtimeSchemaBootstrap.ts index 3121489..715ed02 100644 --- a/src/server/db/runtimeSchemaBootstrap.ts +++ b/src/server/db/runtimeSchemaBootstrap.ts @@ -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; commit(): Promise; rollback(): Promise; @@ -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): 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 { @@ -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 { 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 { +type EnsureRuntimeDatabaseSchemaOptions = { + currentContract?: SchemaContract; + liveContract?: SchemaContract; +}; + +async function resolveLiveContract(client: RuntimeSchemaClient, liveContract?: SchemaContract): Promise { + if (liveContract) { + return liveContract; + } + + return introspectLiveSchema({ + dialect: client.dialect, + connectionString: client.connectionString, + ssl: client.ssl, + }); +} + +function buildExternalUpgradeStatements( + dialect: Exclude, + currentContract: SchemaContract, + liveContract: SchemaContract, +): string[] { + return splitSqlStatements(generateUpgradeSql(dialect, currentContract, liveContract)); +} + +export async function ensureRuntimeDatabaseSchema( + client: RuntimeSchemaClient, + options: EnsureRuntimeDatabaseSchemaOptions = {}, +): Promise { + 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, }; diff --git a/src/server/routes/api/settings.factory-reset.test.ts b/src/server/routes/api/settings.factory-reset.test.ts index 27ce2ed..b3bbffd 100644 --- a/src/server/routes/api/settings.factory-reset.test.ts +++ b/src/server/routes/api/settings.factory-reset.test.ts @@ -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); diff --git a/src/server/services/databaseMigrationService.test.ts b/src/server/services/databaseMigrationService.test.ts index 9075888..f540022 100644 --- a/src/server/services/databaseMigrationService.test.ts +++ b/src/server/services/databaseMigrationService.test.ts @@ -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(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( diff --git a/src/server/services/factoryResetService.test.ts b/src/server/services/factoryResetService.test.ts index afda64a..5bfb60c 100644 --- a/src/server/services/factoryResetService.test.ts +++ b/src/server/services/factoryResetService.test.ts @@ -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) }, + ]); }); }); diff --git a/src/server/services/factoryResetService.ts b/src/server/services/factoryResetService.ts index 2061a7c..1d60285 100644 --- a/src/server/services/factoryResetService.ts +++ b/src/server/services/factoryResetService.ts @@ -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 { + 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(); }