import { execFileSync } from 'node:child_process'; import { createHash, randomUUID } from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; export const CONTROL_SCHEMA = 'mindspace_control'; export function assertUserId(value) { const userId = String(value ?? '').trim().toLowerCase(); if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(userId)) { throw new Error('user id 必须是标准 UUID'); } return userId; } export function quotePgIdentifier(value) { return `"${String(value).replaceAll('"', '""')}"`; } export function deriveUserSpaceNames(value) { const userId = assertUserId(value); const compact = userId.replaceAll('-', ''); const short = compact.slice(0, 12); return { userId, schemaName: `u_${compact}`, ownerRole: `ms_u_${short}_owner`, agentRole: `ms_u_${short}_agent`, }; } export function buildControlSchemaSql() { return ` CREATE SCHEMA IF NOT EXISTS ${quotePgIdentifier(CONTROL_SCHEMA)}; REVOKE ALL ON SCHEMA ${quotePgIdentifier(CONTROL_SCHEMA)} FROM PUBLIC; CREATE TABLE IF NOT EXISTS ${quotePgIdentifier(CONTROL_SCHEMA)}.user_spaces ( user_id uuid PRIMARY KEY, schema_name name NOT NULL UNIQUE, owner_role name NOT NULL UNIQUE, agent_role name NOT NULL UNIQUE, backend_type text NOT NULL DEFAULT 'postgres' CHECK (backend_type IN ('sqlite', 'postgres')), source_sqlite_path text, migration_state text NOT NULL DEFAULT 'pending' CHECK ( migration_state IN ('pending', 'snapshot_created', 'schema_converted', 'data_imported', 'verified', 'shadow', 'cutover', 'rolled_back', 'failed') ), schema_version integer NOT NULL DEFAULT 1, quota_bytes bigint NOT NULL DEFAULT 104857600 CHECK (quota_bytes > 0), created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, cutover_at timestamptz, rollback_until timestamptz ); CREATE TABLE IF NOT EXISTS ${quotePgIdentifier(CONTROL_SCHEMA)}.migration_runs ( id uuid PRIMARY KEY, user_id uuid NOT NULL REFERENCES ${quotePgIdentifier(CONTROL_SCHEMA)}.user_spaces(user_id) ON DELETE CASCADE, source_path text NOT NULL, source_size_bytes bigint NOT NULL DEFAULT 0, source_sha256 text, status text NOT NULL, table_count integer NOT NULL DEFAULT 0, source_row_count bigint NOT NULL DEFAULT 0, target_row_count bigint NOT NULL DEFAULT 0, detail_json jsonb NOT NULL DEFAULT '{}'::jsonb, started_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, finished_at timestamptz ); CREATE TABLE IF NOT EXISTS ${quotePgIdentifier(CONTROL_SCHEMA)}.dataset_catalog ( user_id uuid NOT NULL REFERENCES ${quotePgIdentifier(CONTROL_SCHEMA)}.user_spaces(user_id) ON DELETE CASCADE, dataset_name text NOT NULL, physical_table name NOT NULL, config_json jsonb NOT NULL, sensitivity text NOT NULL DEFAULT 'normal' CHECK (sensitivity IN ('normal', 'personal', 'credential', 'health')), created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (user_id, dataset_name) ); CREATE TABLE IF NOT EXISTS ${quotePgIdentifier(CONTROL_SCHEMA)}.audit_events ( id bigserial PRIMARY KEY, user_id uuid, actor_type text NOT NULL, actor_id text, action text NOT NULL, object_type text, object_name text, statement_hash text, affected_rows bigint, duration_ms integer, result text NOT NULL, detail_json jsonb NOT NULL DEFAULT '{}'::jsonb, created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP ); REVOKE ALL ON ALL TABLES IN SCHEMA ${quotePgIdentifier(CONTROL_SCHEMA)} FROM PUBLIC; `.trim(); } export function buildProvisionUserSql(userId, { sourceSqlitePath = null, quotaBytes = 104857600 } = {}) { const names = deriveUserSpaceNames(userId); const schema = quotePgIdentifier(names.schemaName); const owner = quotePgIdentifier(names.ownerRole); const agent = quotePgIdentifier(names.agentRole); return { ...names, statements: [ `DO $$ BEGIN CREATE ROLE ${owner} NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$`, `DO $$ BEGIN CREATE ROLE ${agent} NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION; EXCEPTION WHEN duplicate_object THEN NULL; END $$`, `DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM pg_auth_members am JOIN pg_roles r ON r.oid = am.roleid JOIN pg_roles m ON m.oid = am.member WHERE r.rolname = '${names.ownerRole}' AND m.rolname = CURRENT_USER AND am.set_option ) THEN EXECUTE format('GRANT %I TO %I', '${names.ownerRole}', CURRENT_USER); END IF; END $$`, `CREATE SCHEMA IF NOT EXISTS ${schema} AUTHORIZATION ${owner}`, `REVOKE ALL ON SCHEMA ${schema} FROM PUBLIC`, `GRANT USAGE, CREATE ON SCHEMA ${schema} TO ${agent}`, `ALTER ROLE ${agent} SET search_path = ${schema}, pg_catalog`, `ALTER ROLE ${agent} SET statement_timeout = '30s'`, `ALTER ROLE ${agent} SET lock_timeout = '5s'`, `ALTER ROLE ${agent} SET idle_in_transaction_session_timeout = '60s'`, `ALTER ROLE ${agent} SET work_mem = '4MB'`, `ALTER DEFAULT PRIVILEGES FOR ROLE ${owner} IN SCHEMA ${schema} GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO ${agent}`, `ALTER DEFAULT PRIVILEGES FOR ROLE ${owner} IN SCHEMA ${schema} GRANT USAGE, SELECT, UPDATE ON SEQUENCES TO ${agent}`, `INSERT INTO ${quotePgIdentifier(CONTROL_SCHEMA)}.user_spaces (user_id, schema_name, owner_role, agent_role, source_sqlite_path, quota_bytes, updated_at) VALUES ($1::uuid, $2::name, $3::name, $4::name, $5, $6, CURRENT_TIMESTAMP) ON CONFLICT (user_id) DO UPDATE SET source_sqlite_path = EXCLUDED.source_sqlite_path, quota_bytes = EXCLUDED.quota_bytes, updated_at = CURRENT_TIMESTAMP`, ], params: [names.userId, names.schemaName, names.ownerRole, names.agentRole, sourceSqlitePath, quotaBytes], }; } export function mapSqliteTypeToPostgres(column) { const declared = String(column.type ?? '').trim().toUpperCase(); if (Number(column.pk) === 1 && declared.includes('INT')) return 'bigint GENERATED BY DEFAULT AS IDENTITY'; if (declared.includes('TEXT') && isSqliteTimestampDefault(column.dflt_value)) return 'timestamptz'; if (declared.includes('INT')) return 'bigint'; if (declared.includes('REAL') || declared.includes('FLOA') || declared.includes('DOUB')) return 'double precision'; if (declared.includes('BLOB')) return 'bytea'; if (declared.includes('NUM') || declared.includes('DEC')) return 'numeric'; if (declared.includes('BOOL')) return 'boolean'; return 'text'; } export function isSqliteTimestampDefault(value) { return /^\(?datetime\(\s*'now'\s*(?:,\s*'(?:\+8 hours|localtime)'\s*)?\)\)?$/i.test(String(value ?? '').trim()); } export function translateSqliteDefault(value) { if (value == null) return null; const input = String(value).trim(); if (/^NULL$/i.test(input)) return 'NULL'; if (/^[+-]?\d+(?:\.\d+)?$/.test(input)) return input; if (/^CURRENT_TIMESTAMP$/i.test(input)) return 'CURRENT_TIMESTAMP'; if (/^\(?datetime\(\s*'now'\s*(?:,\s*'(?:\+8 hours|localtime)'\s*)?\)\)?$/i.test(input)) return 'CURRENT_TIMESTAMP'; if (/^".*"$/.test(input)) return `'${input.slice(1, -1).replaceAll("'", "''")}'`; if (/^'.*'$/.test(input)) return input; return null; } export function buildPostgresTableSql(schemaName, tableName, columns) { if (!Array.isArray(columns) || columns.length === 0) throw new Error(`表 ${tableName} 没有字段`); const primaryKeys = columns.filter((column) => Number(column.pk) > 0).sort((a, b) => Number(a.pk) - Number(b.pk)); const definitions = columns.map((column) => { const parts = [quotePgIdentifier(column.name), mapSqliteTypeToPostgres(column)]; if (Number(column.notnull) === 1) parts.push('NOT NULL'); const translatedDefault = translateSqliteDefault(column.dflt_value); if (translatedDefault != null) parts.push(`DEFAULT ${translatedDefault}`); if (primaryKeys.length === 1 && primaryKeys[0].name === column.name) parts.push('PRIMARY KEY'); return parts.join(' '); }); if (primaryKeys.length > 1) { definitions.push(`PRIMARY KEY (${primaryKeys.map((column) => quotePgIdentifier(column.name)).join(', ')})`); } return `CREATE TABLE ${quotePgIdentifier(schemaName)}.${quotePgIdentifier(tableName)} (\n ${definitions.join(',\n ')}\n)`; } function stableObjectName(prefix, ...parts) { const readable = `${prefix}_${parts.join('_')}`.replace(/[^a-zA-Z0-9_]+/g, '_').toLowerCase(); const hash = createHash('sha256').update(readable).digest('hex').slice(0, 8); return `${readable.slice(0, 50)}_${hash}`; } export function extractSqliteChecks(createSql) { const sql = String(createSql ?? ''); const checks = []; const pattern = /\bCHECK\s*\(/ig; let match; while ((match = pattern.exec(sql))) { const start = pattern.lastIndex; let depth = 1; let quote = ''; let index = start; for (; index < sql.length && depth > 0; index += 1) { const char = sql[index]; if (quote) { if (char === quote && sql[index + 1] === quote) index += 1; else if (char === quote) quote = ''; continue; } if (char === "'" || char === '"') quote = char; else if (char === '(') depth += 1; else if (char === ')') depth -= 1; } if (depth === 0) checks.push(sql.slice(start, index - 1).trim()); pattern.lastIndex = index; } return checks; } export function buildPostgresIndexSql(schemaName, tableName, index) { if (!index.columns?.length) return null; if (index.partial) return null; const prefix = index.unique ? 'ux' : 'ix'; const name = stableObjectName(prefix, tableName, ...index.columns); return `CREATE ${index.unique ? 'UNIQUE ' : ''}INDEX ${quotePgIdentifier(name)} ON ${quotePgIdentifier(schemaName)}.${quotePgIdentifier(tableName)} (${index.columns.map(quotePgIdentifier).join(', ')})`; } export function buildPostgresForeignKeySql(schemaName, tableName, foreignKey) { const name = stableObjectName('fk', tableName, foreignKey.id, foreignKey.table); const onUpdate = String(foreignKey.onUpdate ?? 'NO ACTION').toUpperCase(); const onDelete = String(foreignKey.onDelete ?? 'NO ACTION').toUpperCase(); const allowedActions = new Set(['NO ACTION', 'RESTRICT', 'CASCADE', 'SET NULL', 'SET DEFAULT']); if (!allowedActions.has(onUpdate) || !allowedActions.has(onDelete)) throw new Error('SQLite 外键动作不受支持'); return `ALTER TABLE ${quotePgIdentifier(schemaName)}.${quotePgIdentifier(tableName)} ADD CONSTRAINT ${quotePgIdentifier(name)} FOREIGN KEY (${foreignKey.from.map(quotePgIdentifier).join(', ')}) REFERENCES ${quotePgIdentifier(schemaName)}.${quotePgIdentifier(foreignKey.table)} (${foreignKey.to.map(quotePgIdentifier).join(', ')}) ON UPDATE ${onUpdate} ON DELETE ${onDelete}`; } export function buildPostgresCheckSql(schemaName, tableName, expression, index) { if (/;|--|\/\*/.test(expression)) throw new Error(`CHECK 表达式包含不安全内容:${tableName}`); const name = stableObjectName('ck', tableName, index); return `ALTER TABLE ${quotePgIdentifier(schemaName)}.${quotePgIdentifier(tableName)} ADD CONSTRAINT ${quotePgIdentifier(name)} CHECK (${expression})`; } export function convertSqliteValueForPostgres(value, column) { if (value == null) return null; if (isSqliteTimestampDefault(column.dflt_value) && typeof value === 'string') { const trimmed = value.trim(); if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?$/.test(trimmed)) { return `${trimmed.replace(' ', 'T')}+08:00`; } } return value; } function sqliteJson(sqliteBin, databasePath, sql) { const output = execFileSync(sqliteBin, ['-readonly', '-json', databasePath, sql], { encoding: 'utf8', maxBuffer: 32 * 1024 * 1024, }).trim(); return output ? JSON.parse(output) : []; } function quoteSqliteIdentifier(value) { return `"${String(value).replaceAll('"', '""')}"`; } export function inspectSqliteDatabase(databasePath, { sqliteBin = 'sqlite3' } = {}) { const resolved = path.resolve(databasePath); if (!fs.statSync(resolved).isFile()) throw new Error(`SQLite 文件不存在:${resolved}`); const integrity = sqliteJson(sqliteBin, resolved, 'PRAGMA integrity_check;'); if (integrity[0]?.integrity_check !== 'ok') throw new Error(`SQLite integrity_check 未通过:${resolved}`); const tables = sqliteJson( sqliteBin, resolved, "SELECT name, sql FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name;", ).map((table) => { const columns = sqliteJson(sqliteBin, resolved, `PRAGMA table_info(${quoteSqliteIdentifier(table.name)});`); const rows = sqliteJson(sqliteBin, resolved, `SELECT * FROM ${quoteSqliteIdentifier(table.name)};`); const indexes = sqliteJson(sqliteBin, resolved, `PRAGMA index_list(${quoteSqliteIdentifier(table.name)});`).map((index) => ({ name: index.name, unique: Boolean(index.unique), origin: index.origin, partial: Boolean(index.partial), columns: sqliteJson(sqliteBin, resolved, `PRAGMA index_info(${quoteSqliteIdentifier(index.name)});`).map((item) => item.name), })).filter((index) => index.origin !== 'pk'); const foreignKeyRows = sqliteJson(sqliteBin, resolved, `PRAGMA foreign_key_list(${quoteSqliteIdentifier(table.name)});`); const foreignKeys = [...new Set(foreignKeyRows.map((item) => item.id))].map((id) => { const items = foreignKeyRows.filter((item) => item.id === id).sort((a, b) => a.seq - b.seq); return { id, table: items[0].table, from: items.map((item) => item.from), to: items.map((item) => item.to), onUpdate: items[0].on_update, onDelete: items[0].on_delete, }; }); return { ...table, columns, rows, indexes, foreignKeys, checks: extractSqliteChecks(table.sql) }; }); return { path: resolved, sizeBytes: fs.statSync(resolved).size, tables }; } export function createSqliteSnapshot(sourcePath, { sqliteBin = 'sqlite3' } = {}) { const target = path.join(os.tmpdir(), `mindspace-userdata-${Date.now()}-${process.pid}.sqlite`); if (/\s/.test(target)) throw new Error('SQLite snapshot 临时路径不能包含空格'); execFileSync(sqliteBin, [path.resolve(sourcePath), `.backup ${target}`], { stdio: 'pipe' }); // A standalone snapshot must not depend on the source WAL/SHM files. execFileSync(sqliteBin, [target, 'PRAGMA journal_mode=DELETE;'], { stdio: 'pipe' }); return target; } export async function provisionUserSpace(client, userId, options = {}) { const provision = buildProvisionUserSql(userId, options); await client.query('BEGIN'); try { for (let index = 0; index < provision.statements.length; index += 1) { const params = index === provision.statements.length - 1 ? provision.params : []; await client.query(provision.statements[index], params); } await client.query('COMMIT'); return provision; } catch (error) { await client.query('ROLLBACK'); throw error; } } export async function migrateSqliteSnapshot(client, snapshot, userId, { replaceShadow = false, sourcePath = snapshot.path } = {}) { const names = deriveUserSpaceNames(userId); const owner = quotePgIdentifier(names.ownerRole); const agent = quotePgIdentifier(names.agentRole); const migrationId = randomUUID(); let sourceRows = 0; let targetRows = 0; await client.query('BEGIN'); try { const stateResult = await client.query( `SELECT migration_state FROM ${quotePgIdentifier(CONTROL_SCHEMA)}.user_spaces WHERE user_id = $1::uuid FOR UPDATE`, [names.userId], ); const migrationState = stateResult.rows[0]?.migration_state; if (migrationState === 'cutover') throw new Error('用户空间已经 cutover,禁止迁移器覆盖'); const existingResult = await client.query( `SELECT count(*)::integer AS count FROM pg_catalog.pg_tables WHERE schemaname = $1`, [names.schemaName], ); if (Number(existingResult.rows[0]?.count ?? 0) > 0 && !replaceShadow) { throw new Error('目标影子空间已有表;如确认重建,显式使用 --replace-shadow'); } await client.query( `INSERT INTO ${quotePgIdentifier(CONTROL_SCHEMA)}.migration_runs (id, user_id, source_path, source_size_bytes, status, started_at) VALUES ($1::uuid, $2::uuid, $3, $4, 'importing', CURRENT_TIMESTAMP)`, [migrationId, names.userId, sourcePath, snapshot.sizeBytes], ); for (const table of snapshot.tables) { await client.query(`DROP TABLE IF EXISTS ${quotePgIdentifier(names.schemaName)}.${quotePgIdentifier(table.name)} CASCADE`); await client.query(buildPostgresTableSql(names.schemaName, table.name, table.columns)); await client.query(`ALTER TABLE ${quotePgIdentifier(names.schemaName)}.${quotePgIdentifier(table.name)} OWNER TO ${owner}`); await client.query( `GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE ${quotePgIdentifier(names.schemaName)}.${quotePgIdentifier(table.name)} TO ${agent}`, ); for (const row of table.rows) { const columns = Object.keys(row); const params = columns.map((_, index) => `$${index + 1}`); await client.query( `INSERT INTO ${quotePgIdentifier(names.schemaName)}.${quotePgIdentifier(table.name)} (${columns.map(quotePgIdentifier).join(', ')}) VALUES (${params.join(', ')})`, columns.map((column) => convertSqliteValueForPostgres( row[column], table.columns.find((item) => item.name === column) ?? {}, )), ); } for (const column of table.columns.filter( (item) => Number(item.pk) === 1 && String(item.type ?? '').toUpperCase().includes('INT'), )) { const qualifiedTable = `${names.schemaName}.${table.name}`; await client.query( `SELECT setval( pg_get_serial_sequence($1, $2), GREATEST(COALESCE(MAX(${quotePgIdentifier(column.name)}), 0), 1), COALESCE(MAX(${quotePgIdentifier(column.name)}), 0) > 0 ) FROM ${quotePgIdentifier(names.schemaName)}.${quotePgIdentifier(table.name)}`, [qualifiedTable, column.name], ); } sourceRows += table.rows.length; const count = await client.query( `SELECT count(*)::bigint AS count FROM ${quotePgIdentifier(names.schemaName)}.${quotePgIdentifier(table.name)}`, ); targetRows += Number(count.rows[0].count); } for (const table of snapshot.tables) { for (const index of table.indexes ?? []) { const sql = buildPostgresIndexSql(names.schemaName, table.name, index); if (sql) await client.query(sql); } for (const foreignKey of table.foreignKeys ?? []) { await client.query(buildPostgresForeignKeySql(names.schemaName, table.name, foreignKey)); } for (let index = 0; index < (table.checks ?? []).length; index += 1) { await client.query(buildPostgresCheckSql(names.schemaName, table.name, table.checks[index], index)); } } await client.query(`GRANT USAGE, SELECT, UPDATE ON ALL SEQUENCES IN SCHEMA ${quotePgIdentifier(names.schemaName)} TO ${agent}`); const registry = snapshot.tables.find((table) => table.name === '__page_data_datasets'); for (const row of registry?.rows ?? []) { await client.query( `INSERT INTO ${quotePgIdentifier(CONTROL_SCHEMA)}.dataset_catalog (user_id, dataset_name, physical_table, config_json, updated_at) VALUES ($1::uuid, $2, $3::name, $4::jsonb, CURRENT_TIMESTAMP) ON CONFLICT (user_id, dataset_name) DO UPDATE SET physical_table = EXCLUDED.physical_table, config_json = EXCLUDED.config_json, updated_at = CURRENT_TIMESTAMP`, [names.userId, row.name, row.table_name, row.config_json], ); } await client.query( `UPDATE ${quotePgIdentifier(CONTROL_SCHEMA)}.user_spaces SET migration_state = 'shadow', updated_at = CURRENT_TIMESTAMP WHERE user_id = $1::uuid`, [names.userId], ); await client.query( `UPDATE ${quotePgIdentifier(CONTROL_SCHEMA)}.migration_runs SET status = 'verified', table_count = $2, source_row_count = $3, target_row_count = $4, finished_at = CURRENT_TIMESTAMP WHERE id = $1::uuid`, [migrationId, snapshot.tables.length, sourceRows, targetRows], ); await client.query('COMMIT'); return { migrationId, ...names, tableCount: snapshot.tables.length, sourceRows, targetRows }; } catch (error) { await client.query('ROLLBACK'); throw error; } }