import fs from 'fs/promises'; import path from 'path'; import { createClient } from '@supabase/supabase-js'; import { loadEncryptedJson, readEncryptedFile } from './cryptoUtils.js'; import { POSTGRES_STORAGE_DIR, POSTGRES_STORAGE_DB_DIR, POSTGRES_STORAGE_MANIFEST, DATA_ROOT, readPostgresStorageManifest, refreshStorageMode, } from './dataPaths.js'; import { applyPostgresSchema, createRawPostgresPool, encryptBinaryPayload, encryptJsonPayload, getCreateSchemaSql, getDropSchemaSql, hasExternalPostgresConfig, getPostgresSchemaName, getSetSearchPathSql, makeLookupToken, makeOwnerLookup, } from './postgres.js'; import { POSTGRES_SCHEMA_SQL } from './postgresSchema.js'; import { SUPABASE_URL } from './config.js'; const TEMP_TTL_MS = 24 * 60 * 60 * 1000; const TEMP_INACTIVITY = 12 * 60 * 60 * 1000; const FEEDBACK_AAD = 'feedback_tickets_v1'; const VERSION_FILE = path.join(DATA_ROOT, 'version.json'); const WEB_SEARCH_USAGE_FILE = path.join(DATA_ROOT, 'web-search-usage.json'); const MEDIA_INDEX_FILE = path.join(DATA_ROOT, 'media', 'index.json'); const MEDIA_BLOBS_DIR = path.join(DATA_ROOT, 'media', 'blobs'); const TEMP_STORE_FILE = path.join(DATA_ROOT, 'temp_sessions.json'); const MEMORIES_FILE = path.join(DATA_ROOT, 'memories', 'index.json'); const DELETED_CHATS_FILE = path.join(DATA_ROOT, 'deleted_chats', 'index.json'); const SYSTEM_PROMPTS_FILE = path.join(DATA_ROOT, 'system-prompts', 'index.json'); const FEEDBACK_FILE = path.join(DATA_ROOT, 'feedback_tickets.json'); const GUEST_REQUEST_FILE = path.join(DATA_ROOT, 'guest_request_counts.json'); function nowIso() { return new Date().toISOString(); } function sessionAad(scopeType, sessionId) { return `chat-session:${scopeType}:${sessionId}`; } function guestStateLookup(tempId) { return makeLookupToken('guest-state', tempId); } function guestStateAad(tempId) { return `guest-state:${tempId}`; } function guestExpiryRecord(tempData) { const createdExpires = (tempData.created || Date.now()) + TEMP_TTL_MS; const inactiveExpires = (tempData.lastActive || Date.now()) + TEMP_INACTIVITY; return new Date(Math.min(createdExpires, inactiveExpires)).toISOString(); } function shareTokenLookup(token) { return makeLookupToken('session-share-token', token); } function shareAad(recordId) { return `session-share:${recordId}`; } function promptLookup(userId) { return makeLookupToken('system-prompt', userId); } function promptAad(userId) { return `system-prompt:${userId}`; } function mediaEntryAad(id) { return `media-entry:${id}`; } function feedbackAad(id) { return `feedback:${id}`; } function usernameLookup(username) { return makeLookupToken('username', username); } function versionLookup(publicUrl) { return makeLookupToken('app-version', publicUrl); } function versionAad(publicUrl) { return `app-version:${publicUrl}`; } function requestLookup(ip) { return makeLookupToken('guest-request', ip); } function webUsageLookup(key) { return makeLookupToken('web-search-usage', key); } function webUsageAad(key, day) { return `web-search-usage:${key}:${day}`; } function mediaBlobAad(entry) { return `media:${entry.id}:${entry.ownerType}:${entry.ownerId}`; } function getTempStorageDir() { return `${POSTGRES_STORAGE_DIR}.tmp`; } function getTempStorageDbDir() { return path.join(getTempStorageDir(), path.basename(POSTGRES_STORAGE_DB_DIR)); } function buildStorageManifest(report, backend = 'embedded') { return { formatVersion: 2, createdAt: report.completedAt, updatedAt: report.completedAt, storageMode: 'postgres', backend, dbDir: path.basename(POSTGRES_STORAGE_DB_DIR), status: report.status, pending: report.pending, }; } const SQL_TABLE_COPY_PLANS = [ { table: 'app_versions', reportKey: 'copiedAppVersions', columns: ['public_url_lookup', 'updated_at', 'payload'], }, { table: 'guest_state', reportKey: 'copiedGuestStateRows', columns: ['owner_lookup', 'expires_at', 'updated_at', 'payload'], }, { table: 'chat_sessions', reportKey: 'copiedChatSessions', columns: ['id', 'scope_type', 'owner_lookup', 'created_at', 'updated_at', 'expires_at', 'payload'], }, { table: 'session_shares', reportKey: 'copiedSessionShares', columns: ['id', 'token_lookup', 'owner_lookup', 'created_at', 'payload'], }, { table: 'deleted_chats', reportKey: 'copiedDeletedChats', columns: ['id', 'owner_lookup', 'purge_at', 'deleted_at', 'payload'], }, { table: 'memories', reportKey: 'copiedMemories', columns: ['id', 'owner_lookup', 'created_at', 'updated_at', 'payload'], }, { table: 'media_entries', reportKey: 'copiedMediaEntries', columns: [ 'id', 'owner_lookup', 'parent_id', 'entry_type', 'updated_at', 'created_at', 'trashed_at', 'purge_at', 'expires_at', 'size_bytes', 'payload', ], }, { table: 'media_blobs', reportKey: 'copiedMediaBlobs', columns: ['entry_id', 'updated_at', 'payload'], }, { table: 'system_prompts', reportKey: 'copiedSystemPrompts', columns: ['owner_lookup', 'updated_at', 'payload'], }, { table: 'feedback_tickets', reportKey: 'copiedFeedbackTickets', columns: ['id', 'status', 'submitted_at', 'payload'], }, { table: 'guest_request_counters', reportKey: 'copiedGuestRequestCounters', columns: ['key_lookup', 'expires_at', 'updated_at', 'payload'], }, { table: 'web_search_usage', reportKey: 'copiedWebSearchUsageRows', columns: ['key_lookup', 'day_key', 'updated_at', 'payload'], }, { table: 'user_settings', reportKey: 'copiedUserSettings', columns: ['owner_lookup', 'updated_at', 'payload'], }, { table: 'user_profiles', reportKey: 'copiedUserProfiles', columns: ['owner_lookup', 'username_lookup', 'updated_at', 'payload'], }, { table: 'device_sessions', reportKey: 'copiedDeviceSessions', columns: ['token_lookup', 'user_lookup', 'active', 'created_at', 'last_seen_at', 'payload'], }, ]; async function fileExists(filePath) { try { await fs.access(filePath); return true; } catch { return false; } } async function readJsonIfExists(filePath) { try { return JSON.parse(await fs.readFile(filePath, 'utf8')); } catch { return null; } } async function fetchAllSupabaseRows(client, tableName) { const pageSize = 1000; const rows = []; let from = 0; while (true) { const { data, error } = await client .from(tableName) .select('*') .range(from, from + pageSize - 1); if (error) throw error; rows.push(...(data || [])); if (!data || data.length < pageSize) break; from += pageSize; } return rows; } async function resetBootstrapSchema(pool) { const client = await pool.connect(); try { await client.query('SET statement_timeout TO 0'); await client.query(getDropSchemaSql()); await client.query(getCreateSchemaSql()); await client.query(getSetSearchPathSql()); } finally { client.release(); } } async function migrateVersions(pool, report) { const data = await readJsonIfExists(VERSION_FILE); const entries = Array.isArray(data) ? data : []; let count = 0; for (const entry of entries) { for (const [publicUrl, sha] of Object.entries(entry || {})) { await pool.query( `INSERT INTO app_versions (public_url_lookup, updated_at, payload) VALUES ($1, $2, $3::jsonb)`, [ versionLookup(publicUrl), nowIso(), JSON.stringify(encryptJsonPayload({ publicUrl, sha }, versionAad(publicUrl))), ] ); count += 1; } } report.migrated.versionEntries = count; } async function migrateTempSessions(pool, report) { const data = await loadEncryptedJson(TEMP_STORE_FILE); const records = data || {}; let ownerCount = 0; let sessionCount = 0; for (const [tempId, tempData] of Object.entries(records)) { ownerCount += 1; const owner = { type: 'guest', id: tempId }; await pool.query( `INSERT INTO guest_state (owner_lookup, expires_at, updated_at, payload) VALUES ($1, $2, $3, $4::jsonb)`, [ guestStateLookup(tempId), guestExpiryRecord(tempData), nowIso(), JSON.stringify(encryptJsonPayload({ tempId, msgCount: tempData.msgCount || 0, created: tempData.created || Date.now(), lastActive: tempData.lastActive || Date.now(), }, guestStateAad(tempId))), ] ); for (const session of Object.values(tempData.sessions || {})) { await pool.query( `INSERT INTO chat_sessions (id, scope_type, owner_lookup, created_at, updated_at, expires_at, payload) VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb)`, [ session.id, 'guest', makeOwnerLookup(owner), new Date(session.created || Date.now()).toISOString(), nowIso(), guestExpiryRecord(tempData), JSON.stringify(encryptJsonPayload(session, sessionAad('guest', session.id))), ] ); sessionCount += 1; } } report.migrated.tempOwners = ownerCount; report.migrated.tempSessions = sessionCount; } async function migrateMemories(pool, report) { const data = await loadEncryptedJson(MEMORIES_FILE); const memories = Object.values(data?.memories || {}); for (const memory of memories) { await pool.query( `INSERT INTO memories (id, owner_lookup, created_at, updated_at, payload) VALUES ($1, $2, $3, $4, $5::jsonb)`, [ memory.id, makeOwnerLookup({ type: memory.ownerType, id: memory.ownerId }), memory.createdAt, memory.updatedAt, JSON.stringify(encryptJsonPayload(memory, `memory:${memory.id}`)), ] ); } report.migrated.memories = memories.length; } async function migrateDeletedChats(pool, report) { const data = await loadEncryptedJson(DELETED_CHATS_FILE); const deletedChats = Object.values(data?.deletedChats || {}); for (const record of deletedChats) { await pool.query( `INSERT INTO deleted_chats (id, owner_lookup, purge_at, deleted_at, payload) VALUES ($1, $2, $3, $4, $5::jsonb)`, [ record.id, makeOwnerLookup({ type: record.ownerType, id: record.ownerId }), record.purgeAt || null, record.deletedAt, JSON.stringify(encryptJsonPayload(record, `deleted-chat:${record.id}`)), ] ); } report.migrated.deletedChats = deletedChats.length; } async function migrateSystemPrompts(pool, report) { const data = await loadEncryptedJson(SYSTEM_PROMPTS_FILE, 'system-prompts'); const prompts = Object.entries(data?.prompts || {}); for (const [userId, prompt] of prompts) { const record = { userId, markdown: prompt.markdown, updatedAt: prompt.updatedAt || nowIso(), }; await pool.query( `INSERT INTO system_prompts (owner_lookup, updated_at, payload) VALUES ($1, $2, $3::jsonb)`, [ promptLookup(userId), record.updatedAt, JSON.stringify(encryptJsonPayload(record, promptAad(userId))), ] ); } report.migrated.systemPrompts = prompts.length; } async function migrateFeedback(pool, report) { const data = await loadEncryptedJson(FEEDBACK_FILE, FEEDBACK_AAD); const tickets = Array.isArray(data?.tickets) ? data.tickets : []; for (const ticket of tickets) { await pool.query( `INSERT INTO feedback_tickets (id, status, submitted_at, payload) VALUES ($1, $2, $3, $4::jsonb)`, [ ticket.id, ticket.status || 'open', ticket.submittedAt, JSON.stringify(encryptJsonPayload(ticket, feedbackAad(ticket.id))), ] ); } report.migrated.feedbackTickets = tickets.length; } async function migrateGuestRequestCounters(pool, report) { const data = await loadEncryptedJson(GUEST_REQUEST_FILE); const entries = Object.entries(data || {}); for (const [ip, entry] of entries) { await pool.query( `INSERT INTO guest_request_counters (key_lookup, expires_at, updated_at, payload) VALUES ($1, $2, $3, $4::jsonb)`, [ requestLookup(ip), new Date(entry.resetAt || Date.now()).toISOString(), nowIso(), JSON.stringify(encryptJsonPayload({ ip, count: entry.count || 0, resetAt: entry.resetAt || Date.now(), }, 'guest-request-row')), ] ); } report.migrated.guestRequestCounters = entries.length; } async function migrateWebSearchUsage(pool, report) { const data = await readJsonIfExists(WEB_SEARCH_USAGE_FILE); const days = data?.days && typeof data.days === 'object' ? data.days : {}; let count = 0; for (const [dayKey, keys] of Object.entries(days)) { for (const [key, used] of Object.entries(keys || {})) { await pool.query( `INSERT INTO web_search_usage (key_lookup, day_key, updated_at, payload) VALUES ($1, $2, $3, $4::jsonb)`, [ webUsageLookup(key), dayKey, nowIso(), JSON.stringify(encryptJsonPayload({ used }, webUsageAad(key, dayKey))), ] ); count += 1; } } report.migrated.webSearchUsageRows = count; } async function migrateMedia(pool, report) { const data = await loadEncryptedJson(MEDIA_INDEX_FILE); const entries = Object.values(data?.entries || {}); let blobCount = 0; for (const entry of entries) { await pool.query( `INSERT INTO media_entries ( id, owner_lookup, parent_id, entry_type, updated_at, created_at, trashed_at, purge_at, expires_at, size_bytes, payload ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb)`, [ entry.id, makeOwnerLookup({ type: entry.ownerType, id: entry.ownerId }), entry.parentId || null, entry.type, entry.updatedAt || entry.createdAt, entry.createdAt, entry.trashedAt || null, entry.purgeAt || null, entry.expiresAt || null, entry.size || 0, JSON.stringify(encryptJsonPayload(entry, mediaEntryAad(entry.id))), ] ); if (entry.type === 'file') { const blobPath = path.join(MEDIA_BLOBS_DIR, `${entry.id}.bin`); if (await fileExists(blobPath)) { const blob = await readEncryptedFile(blobPath, mediaBlobAad(entry)); await pool.query( `INSERT INTO media_blobs (entry_id, updated_at, payload) VALUES ($1, $2, $3)`, [ entry.id, entry.updatedAt || entry.createdAt, encryptBinaryPayload(blob, mediaBlobAad(entry)), ] ); blobCount += 1; } } } report.migrated.mediaEntries = entries.length; report.migrated.mediaBlobs = blobCount; } async function migrateSupabaseData(pool, report) { const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY || ''; if (!serviceRoleKey) { if (process.env.ALLOW_PARTIAL_SQL_MIGRATION === '1') { report.pending.push( 'Supabase-backed user tables were not exported because SUPABASE_SERVICE_ROLE_KEY is not set.' ); return; } throw new Error( 'SUPABASE_SERVICE_ROLE_KEY is required to migrate existing Supabase-backed sessions, shares, settings, and profiles before enabling PostgreSQL-only mode. Set ALLOW_PARTIAL_SQL_MIGRATION=1 only if you intentionally want to skip those records.' ); } const supabase = createClient(SUPABASE_URL, serviceRoleKey, { auth: { persistSession: false }, }); const [webSessions, sharedSessions, userSettings, profiles] = await Promise.all([ fetchAllSupabaseRows(supabase, 'web_sessions'), fetchAllSupabaseRows(supabase, 'shared_sessions'), fetchAllSupabaseRows(supabase, 'user_settings'), fetchAllSupabaseRows(supabase, 'profiles'), ]); for (const row of webSessions) { const session = { id: row.id, name: row.name, created: new Date(row.created_at).getTime(), history: row.history || [], model: row.model || null, }; await pool.query( `INSERT INTO chat_sessions (id, scope_type, owner_lookup, created_at, updated_at, expires_at, payload) VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb)`, [ row.id, 'user', makeOwnerLookup({ type: 'user', id: row.user_id }), row.created_at, row.updated_at || nowIso(), null, JSON.stringify(encryptJsonPayload(session, sessionAad('user', row.id))), ] ); } for (const row of sharedSessions) { const lookup = shareTokenLookup(row.token); const id = row.id || `share_${lookup.slice(0, 24)}`; await pool.query( `INSERT INTO session_shares (id, token_lookup, owner_lookup, created_at, payload) VALUES ($1, $2, $3, $4, $5::jsonb)`, [ id, lookup, makeOwnerLookup({ type: 'user', id: row.owner_id }), row.created_at || nowIso(), JSON.stringify(encryptJsonPayload({ id, ownerId: row.owner_id, sessionSnapshot: row.session_snapshot, createdAt: row.created_at || nowIso(), }, shareAad(id))), ] ); } for (const row of userSettings) { await pool.query( `INSERT INTO user_settings (owner_lookup, updated_at, payload) VALUES ($1, $2, $3::jsonb)`, [ makeOwnerLookup({ type: 'user', id: row.user_id }), row.updated_at || nowIso(), JSON.stringify(encryptJsonPayload({ userId: row.user_id, settings: row.settings || {}, updatedAt: row.updated_at || nowIso(), }, `user-settings:${row.user_id}`)), ] ); } for (const row of profiles) { if (!row.id || !row.username) continue; await pool.query( `INSERT INTO user_profiles (owner_lookup, username_lookup, updated_at, payload) VALUES ($1, $2, $3, $4::jsonb)`, [ makeOwnerLookup({ type: 'user', id: row.id }), usernameLookup(row.username), row.updated_at || nowIso(), JSON.stringify(encryptJsonPayload({ userId: row.id, username: row.username, updatedAt: row.updated_at || nowIso(), }, `user-profile:${row.id}`)), ] ); } report.migrated.supabaseWebSessions = webSessions.length; report.migrated.supabaseSharedSessions = sharedSessions.length; report.migrated.supabaseUserSettings = userSettings.length; report.migrated.supabaseProfiles = profiles.length; } async function writeStorageFolder(targetDir, report, backend = 'embedded') { await fs.mkdir(targetDir, { recursive: true }); await fs.writeFile( path.join(targetDir, path.basename(POSTGRES_STORAGE_MANIFEST)), JSON.stringify(buildStorageManifest(report, backend), null, 2), 'utf8' ); await fs.writeFile(path.join(targetDir, 'schema.sql'), POSTGRES_SCHEMA_SQL, 'utf8'); await fs.writeFile(path.join(targetDir, 'migration-report.json'), JSON.stringify(report, null, 2), 'utf8'); await fs.writeFile( path.join(targetDir, 'README.txt'), [ 'This folder contains the encrypted PostgreSQL storage used by the backend.', `The actual embedded database files live in ./${path.basename(POSTGRES_STORAGE_DB_DIR)}/.`, 'schema.sql contains the schema used for the migrated encrypted SQL backend.', ].join('\n'), 'utf8' ); } async function prepareTempEmbeddedTarget() { const tempDir = getTempStorageDir(); await fs.rm(tempDir, { recursive: true, force: true }); await fs.mkdir(tempDir, { recursive: true }); return { tempDir, tempDbDir: getTempStorageDbDir(), }; } async function activateEmbeddedStorageFolder(tempDir) { await fs.rm(POSTGRES_STORAGE_DIR, { recursive: true, force: true }); await fs.rename(tempDir, POSTGRES_STORAGE_DIR); refreshStorageMode(); } function buildInsertValueSql(table, columns) { return columns .map((column, index) => (column === 'payload' && table !== 'media_blobs' ? `$${index + 1}::jsonb` : `$${index + 1}`)) .join(', '); } function normalizeCopiedSqlValue(table, column, value) { if (column === 'payload' && table !== 'media_blobs' && value && typeof value === 'object' && !Buffer.isBuffer(value)) { return JSON.stringify(value); } return value ?? null; } async function copySqlTable(sourcePool, targetPool, plan, report) { const selectSql = `SELECT ${plan.columns.join(', ')} FROM ${plan.table}`; const insertSql = ` INSERT INTO ${plan.table} (${plan.columns.join(', ')}) VALUES (${buildInsertValueSql(plan.table, plan.columns)}) `; const { rows } = await sourcePool.query(selectSql); for (const row of rows) { await targetPool.query( insertSql, plan.columns.map((column) => normalizeCopiedSqlValue(plan.table, column, row[column])) ); } report.migrated[plan.reportKey] = rows.length; } async function copyExistingSqlData(sourcePool, targetPool, report) { for (const plan of SQL_TABLE_COPY_PLANS) { await copySqlTable(sourcePool, targetPool, plan, report); } } async function hasMaterializedEmbeddedDatabase() { try { const entries = await fs.readdir(POSTGRES_STORAGE_DB_DIR); return entries.length > 0; } catch { return false; } } export async function migrateLegacyDataToPostgres({ skipIfFolderExists = true, replaceExistingFolder = false, sourceLabel = 'legacy_files', } = {}) { if (skipIfFolderExists && await fileExists(POSTGRES_STORAGE_DIR)) { refreshStorageMode(); return { status: 'already_active', targetFolder: POSTGRES_STORAGE_DIR, skipped: true, migrated: {}, pending: [], }; } if (!skipIfFolderExists && !replaceExistingFolder && await fileExists(POSTGRES_STORAGE_DIR)) { throw new Error(`SQL storage folder already exists at ${POSTGRES_STORAGE_DIR}. Remove it before running the migration again.`); } await fs.mkdir(DATA_ROOT, { recursive: true }); const { tempDir, tempDbDir } = await prepareTempEmbeddedTarget(); const pool = await createRawPostgresPool({ backend: 'embedded', dataDir: tempDbDir }); const report = { startedAt: nowIso(), source: sourceLabel, targetFolder: POSTGRES_STORAGE_DIR, targetDatabaseDir: POSTGRES_STORAGE_DB_DIR, targetSchema: getPostgresSchemaName(), targetBackend: 'embedded', migrated: {}, pending: [], }; try { await resetBootstrapSchema(pool); await applyPostgresSchema(pool); await migrateVersions(pool, report); await migrateTempSessions(pool, report); await migrateMemories(pool, report); await migrateDeletedChats(pool, report); await migrateSystemPrompts(pool, report); await migrateFeedback(pool, report); await migrateGuestRequestCounters(pool, report); await migrateWebSearchUsage(pool, report); await migrateMedia(pool, report); await migrateSupabaseData(pool, report); report.completedAt = nowIso(); report.status = report.pending.length ? 'completed_with_pending_items' : 'completed'; } finally { await pool.end(); } await writeStorageFolder(tempDir, report, 'embedded'); await activateEmbeddedStorageFolder(tempDir); return report; } export async function materializeEmbeddedPostgresStorage() { const manifest = readPostgresStorageManifest(); if (!manifest) { return { skipped: true, reason: 'manifest_missing', }; } if (await hasMaterializedEmbeddedDatabase()) { if (manifest.backend !== 'embedded') { const report = { startedAt: nowIso(), completedAt: nowIso(), source: 'manifest_refresh', targetFolder: POSTGRES_STORAGE_DIR, targetDatabaseDir: POSTGRES_STORAGE_DB_DIR, targetSchema: getPostgresSchemaName(), targetBackend: 'embedded', migrated: {}, pending: [], status: 'completed', }; await writeStorageFolder(POSTGRES_STORAGE_DIR, report, 'embedded'); refreshStorageMode(); return { upgraded: true, report }; } return { skipped: true, reason: 'already_embedded', }; } if (!hasExternalPostgresConfig()) { const report = await migrateLegacyDataToPostgres({ skipIfFolderExists: false, replaceExistingFolder: true, sourceLabel: 'legacy_files_upgrade', }); return { upgraded: true, report }; } const { tempDir, tempDbDir } = await prepareTempEmbeddedTarget(); const sourcePool = await createRawPostgresPool({ backend: 'external' }); const targetPool = await createRawPostgresPool({ backend: 'embedded', dataDir: tempDbDir }); const report = { startedAt: nowIso(), source: 'external_postgres', previousBackend: String(manifest.backend || 'external_marker'), targetFolder: POSTGRES_STORAGE_DIR, targetDatabaseDir: POSTGRES_STORAGE_DB_DIR, targetSchema: getPostgresSchemaName(), targetBackend: 'embedded', migrated: {}, pending: [], }; try { await resetBootstrapSchema(targetPool); await applyPostgresSchema(targetPool); await copyExistingSqlData(sourcePool, targetPool, report); report.completedAt = nowIso(); report.status = 'completed'; } finally { await sourcePool.end(); await targetPool.end(); } await writeStorageFolder(tempDir, report, 'embedded'); await activateEmbeddedStorageFolder(tempDir); return { upgraded: true, report }; } export async function runPostgresMigrationCli() { const report = await migrateLegacyDataToPostgres({ skipIfFolderExists: false }); console.log('PostgreSQL migration complete.'); console.log(JSON.stringify(report, null, 2)); }