import fs from 'node:fs'; import path from 'node:path'; import { execFileSync } from 'node:child_process'; import { createPostgresUserDataSpaceService } from './postgres-user-data-space-service.mjs'; export const DATASET_REGISTRY_TABLE = '__page_data_datasets'; const DEFAULT_MAX_BYTES = 20 * 1024 * 1024; const DEFAULT_QUERY_TIMEOUT_MS = 5000; const DEFAULT_MAX_ROWS = 200; function stripSqlComments(sql) { return String(sql ?? '') .replace(/--.*$/gm, '') .replace(/\/\*[\s\S]*?\*\//g, '') .trim(); } export function assertSafeSqlIdentifier(name, label = '标识符') { const value = String(name ?? '').trim(); if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) { throw Object.assign(new Error(`${label} 格式无效`), { code: 'invalid_identifier' }); } return value; } export function rejectDangerousSql(sql, { readonly = false } = {}) { const cleaned = stripSqlComments(sql); if (!cleaned) throw new Error('SQL 不能为空'); if (cleaned.length > 20000) throw new Error('SQL 过长'); if (/^\s*\./m.test(cleaned)) { throw new Error('SQL 不允许使用 sqlite3 dot command'); } if (/\b(ATTACH|DETACH|LOAD_EXTENSION|VACUUM\s+INTO)\b/i.test(cleaned)) { throw new Error('SQL 包含用户私有数据空间不允许的操作'); } if (/\bPRAGMA\s+writable_schema\b/i.test(cleaned)) { throw new Error('SQL 包含不允许的 PRAGMA'); } if (readonly && !/^\s*(SELECT|WITH)\b/i.test(cleaned)) { throw new Error('private_data_query 只允许 SELECT/WITH'); } return cleaned; } export function normalizeDatasetConfig(raw, { fallbackName = null } = {}) { const config = typeof raw === 'string' ? JSON.parse(raw) : raw; if (!config || typeof config !== 'object') { throw Object.assign(new Error('dataset 配置无效'), { code: 'invalid_dataset_config' }); } const name = assertSafeSqlIdentifier(config.name ?? fallbackName, 'dataset 名称'); const table = assertSafeSqlIdentifier(config.table ?? config.table_name, 'dataset 表名'); const actions = Array.isArray(config.actions) ? config.actions.map((action) => String(action).trim()).filter(Boolean) : []; const columns = config.columns && typeof config.columns === 'object' ? config.columns : {}; for (const [action, fields] of Object.entries(columns)) { if (!Array.isArray(fields)) { throw Object.assign(new Error(`dataset 字段白名单无效:${action}`), { code: 'invalid_dataset_config' }); } columns[action] = fields.map((field) => assertSafeSqlIdentifier(field, `${action} 字段`)); } return { name, table, description: String(config.description ?? '').trim(), actions, columns, limits: config.limits && typeof config.limits === 'object' ? { maxRowsPerRead: Number(config.limits.maxRowsPerRead ?? DEFAULT_MAX_ROWS), maxInsertBytes: Number(config.limits.maxInsertBytes ?? 8192), } : { maxRowsPerRead: DEFAULT_MAX_ROWS, maxInsertBytes: 8192, }, }; } function sqlLiteral(value) { if (value === null || value === undefined) return 'NULL'; if (typeof value === 'number' && Number.isFinite(value)) return String(value); if (typeof value === 'boolean') return value ? '1' : '0'; return `'${String(value).replace(/'/g, "''")}'`; } export function createUserDataSpaceService(options = {}) { const workspaceRoot = path.resolve(String(options.workspaceRoot ?? '').trim()); if (!workspaceRoot) { throw new Error('workspaceRoot 不能为空'); } const userId = options.userId ? String(options.userId).trim() : null; const isNodeTest = Boolean(process.env.NODE_TEST_CONTEXT); const backend = String( options.backend ?? process.env.MINDSPACE_USERDATA_BACKEND ?? (isNodeTest ? 'sqlite' : 'postgres'), ).trim().toLowerCase(); if (backend === 'sqlite' && !isNodeTest) { throw Object.assign( new Error('用户私有数据运行时已禁用 SQLite,只允许使用 PostgreSQL'), { code: 'sqlite_runtime_disabled' }, ); } if (backend !== 'postgres' && backend !== 'sqlite') { throw Object.assign(new Error(`不支持的用户数据后端:${backend}`), { code: 'invalid_userdata_backend' }); } if (backend === 'postgres') { if (!userId) throw new Error('PostgreSQL 用户数据空间要求 userId'); return createPostgresUserDataSpaceService({ ...options, userId }); } const query = typeof options.query === 'function' ? options.query : null; const sqliteBin = options.sqliteBin?.trim() || process.env.SQLITE_BIN?.trim() || 'sqlite3'; const maxBytes = Number(options.maxBytes ?? process.env.PRIVATE_DATA_MAX_BYTES ?? DEFAULT_MAX_BYTES); const queryTimeoutMs = Number( options.queryTimeoutMs ?? process.env.PRIVATE_DATA_QUERY_TIMEOUT_MS ?? DEFAULT_QUERY_TIMEOUT_MS, ); const maxRows = Number(options.maxRows ?? process.env.PRIVATE_DATA_MAX_ROWS ?? DEFAULT_MAX_ROWS); const privateDataDir = path.join(workspaceRoot, '.mindspace'); const privateDataDb = path.join(privateDataDir, 'private-data.sqlite'); function ensurePrivateDataDb() { fs.mkdirSync(privateDataDir, { recursive: true }); if (!fs.existsSync(privateDataDb)) { runSqlite(['-batch', privateDataDb, 'PRAGMA journal_mode=WAL; PRAGMA user_version = 1;']); } return privateDataDb; } function ensureReady() { ensurePrivateDataDb(); return getInfo(); } function privateDataSize() { let total = 0; for (const file of fs.existsSync(privateDataDir) ? fs.readdirSync(privateDataDir) : []) { if (file === 'private-data.sqlite' || file.startsWith('private-data.sqlite-')) { total += fs.statSync(path.join(privateDataDir, file)).size; } } return total; } function runSqlite(args) { return execFileSync(sqliteBin, args, { encoding: 'utf8', timeout: queryTimeoutMs, maxBuffer: 1024 * 1024, }); } function sqliteScalar(sql) { return Number(runSqlite(['-batch', '-noheader', privateDataDb, sql]).trim() || 0); } function runJsonQuery(sql) { const output = runSqlite(['-json', privateDataDb, sql]); return output.trim() ? JSON.parse(output) : []; } async function getQuotaState({ forUpdate = false, conn = null } = {}) { if (!query || !userId) return null; const db = conn ?? { query }; const [rows] = await db.query( `SELECT id, quota_bytes, used_bytes, reserved_bytes, status FROM h5_user_spaces WHERE user_id = ? LIMIT 1 ${forUpdate ? 'FOR UPDATE' : ''}`, [userId], ); const row = rows[0]; if (!row) return null; const quotaBytes = Number(row.quota_bytes ?? 0); const usedBytes = Number(row.used_bytes ?? 0); const reservedBytes = Number(row.reserved_bytes ?? 0); return { id: row.id, status: row.status, quotaBytes, usedBytes, reservedBytes, availableBytes: Math.max(0, quotaBytes - usedBytes - reservedBytes), }; } async function sqliteMaxPagePragmaForQuota(currentBytes) { const quota = await getQuotaState(); if (!quota) return ''; if (quota.status !== 'active') throw new Error('用户空间不可写'); if (quota.availableBytes <= 0 && currentBytes >= maxBytes) { throw new Error('用户私有数据空间配额不足'); } const allowedSize = Math.min(maxBytes, currentBytes + quota.availableBytes); const pageSize = sqliteScalar('PRAGMA page_size;') || 4096; const currentPages = sqliteScalar('PRAGMA page_count;') || 1; const maxPages = Math.max(currentPages, Math.max(1, Math.floor(allowedSize / pageSize))); return `PRAGMA max_page_count=${maxPages};`; } async function syncPrivateDataQuota(deltaBytes) { if (!query || !userId || !deltaBytes) return null; const conn = await query.getConnection?.(); if (!conn?.beginTransaction) { return null; } try { await conn.beginTransaction(); const quota = await getQuotaState({ forUpdate: true, conn }); if (!quota) { await conn.rollback(); return null; } if (quota.status !== 'active') throw new Error('用户空间不可写'); if (deltaBytes > quota.availableBytes) { throw new Error(`用户私有数据空间配额不足:需要 ${deltaBytes} 字节,可用 ${quota.availableBytes} 字节`); } await conn.query( `UPDATE h5_user_spaces SET used_bytes = GREATEST(0, used_bytes + ?), updated_at = ? WHERE id = ? AND user_id = ?`, [deltaBytes, Date.now(), quota.id, userId], ); await conn.commit(); return { deltaBytes }; } catch (err) { await conn.rollback(); throw err; } finally { conn.release(); } } function ensureDatasetRegistryTable() { ensurePrivateDataDb(); runSqlite([ '-batch', privateDataDb, `CREATE TABLE IF NOT EXISTS ${DATASET_REGISTRY_TABLE} ( name TEXT PRIMARY KEY, table_name TEXT NOT NULL, config_json TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP );`, ]); } async function getInfo() { ensurePrivateDataDb(); const size = privateDataSize(); const quota = await getQuotaState().catch(() => null); return { name: '用户私有数据空间', database: '.mindspace/private-data.sqlite', maxBytes, sizeBytes: size, quotaSyncEnabled: Boolean(query && userId), quota: quota ? { status: quota.status, quotaBytes: quota.quotaBytes, usedBytes: quota.usedBytes, reservedBytes: quota.reservedBytes, availableBytes: quota.availableBytes, } : null, }; } async function getSchema() { ensurePrivateDataDb(); return runJsonQuery( `SELECT m.name AS table_name, p.cid, p.name AS column_name, p.type, p."notnull" AS not_null, p.pk FROM sqlite_master m JOIN pragma_table_info(m.name) p WHERE m.type = 'table' AND m.name NOT LIKE 'sqlite_%' ORDER BY m.name, p.cid`, ); } async function querySql(sql) { ensurePrivateDataDb(); const cleaned = rejectDangerousSql(sql, { readonly: true }); const limited = `SELECT * FROM (${cleaned.replace(/;\s*$/, '')}) LIMIT ${maxRows}`; return runJsonQuery(limited); } async function executeSql(sql) { ensurePrivateDataDb(); const before = privateDataSize(); if (before > maxBytes) throw new Error('用户私有数据空间已超过大小限制'); const cleaned = rejectDangerousSql(sql); const quotaPragma = await sqliteMaxPagePragmaForQuota(before); runSqlite(['-batch', privateDataDb, `${quotaPragma}\n${cleaned}`]); const after = privateDataSize(); if (after > maxBytes) { throw new Error(`用户私有数据空间超过大小限制:${after}/${maxBytes} 字节`); } const delta = after - before; const quotaSync = await syncPrivateDataQuota(delta); return { sizeBytes: after, deltaBytes: delta, quotaSynced: Boolean(quotaSync), }; } function listTableColumns(tableName) { const table = assertSafeSqlIdentifier(tableName, '表名'); return runJsonQuery(`PRAGMA table_info(${table});`).map((row) => ({ name: row.name, type: row.type, notNull: Boolean(row.notnull), pk: Boolean(row.pk), })); } function tableHasColumn(tableName, columnName) { return listTableColumns(tableName).some((column) => column.name === columnName); } function getDatasetRow(name) { ensureDatasetRegistryTable(); const datasetName = assertSafeSqlIdentifier(name, 'dataset 名称'); const rows = runJsonQuery( `SELECT name, table_name, config_json, created_at, updated_at FROM ${DATASET_REGISTRY_TABLE} WHERE name = ${sqlLiteral(datasetName)} LIMIT 1`, ); return rows[0] ?? null; } function getDataset(name) { const row = getDatasetRow(name); if (!row) return null; const config = normalizeDatasetConfig(row.config_json, { fallbackName: row.name }); return { ...config, table: config.table || row.table_name, createdAt: row.created_at, updatedAt: row.updated_at, }; } function listDatasets() { ensureDatasetRegistryTable(); return runJsonQuery( `SELECT name, table_name, config_json, created_at, updated_at FROM ${DATASET_REGISTRY_TABLE} ORDER BY name`, ).map((row) => { const config = normalizeDatasetConfig(row.config_json, { fallbackName: row.name }); return { ...config, table: config.table || row.table_name, createdAt: row.created_at, updatedAt: row.updated_at, }; }); } async function upsertDataset(configInput) { const config = normalizeDatasetConfig(configInput); ensureDatasetRegistryTable(); const columns = listTableColumns(config.table); if (!columns.length) { throw Object.assign(new Error(`dataset 对应表不存在:${config.table}`), { code: 'table_not_found' }); } const payload = JSON.stringify(config); const sql = `INSERT INTO ${DATASET_REGISTRY_TABLE} (name, table_name, config_json, updated_at) VALUES (${sqlLiteral(config.name)}, ${sqlLiteral(config.table)}, ${sqlLiteral(payload)}, CURRENT_TIMESTAMP) ON CONFLICT(name) DO UPDATE SET table_name = excluded.table_name, config_json = excluded.config_json, updated_at = CURRENT_TIMESTAMP;`; await executeSql(sql); return getDataset(config.name); } function assertDatasetAction(dataset, action) { if (!dataset.actions.includes(action)) { throw Object.assign(new Error(`dataset 未授权 ${action}`), { code: 'action_not_allowed' }); } } function resolveReadColumns(dataset) { const columns = dataset.columns?.read; if (!Array.isArray(columns) || !columns.length) { throw Object.assign(new Error('dataset 未配置可读字段'), { code: 'columns_not_allowed' }); } return columns.map((column) => assertSafeSqlIdentifier(column, '读字段')); } function resolveInsertColumns(dataset, payload) { const allowed = dataset.columns?.insert; if (!Array.isArray(allowed) || !allowed.length) { throw Object.assign(new Error('dataset 未配置可写字段'), { code: 'columns_not_allowed' }); } const allowedSet = new Set(allowed.map((column) => assertSafeSqlIdentifier(column, '写字段'))); const keys = Object.keys(payload ?? {}); if (!keys.length) { throw Object.assign(new Error('提交数据不能为空'), { code: 'invalid_payload' }); } for (const key of keys) { if (!allowedSet.has(key)) { throw Object.assign(new Error(`字段未授权:${key}`), { code: 'columns_not_allowed' }); } } return keys.map((key) => assertSafeSqlIdentifier(key, '写字段')); } function resolveUpdateColumns(dataset, payload) { const allowed = dataset.columns?.update; if (!Array.isArray(allowed) || !allowed.length) { throw Object.assign(new Error('dataset 未配置可更新字段'), { code: 'columns_not_allowed' }); } const allowedSet = new Set(allowed.map((column) => assertSafeSqlIdentifier(column, '更新字段'))); const keys = Object.keys(payload ?? {}); if (!keys.length) { throw Object.assign(new Error('更新数据不能为空'), { code: 'invalid_payload' }); } for (const key of keys) { if (!allowedSet.has(key)) { throw Object.assign(new Error(`字段未授权:${key}`), { code: 'columns_not_allowed' }); } } return keys.map((key) => assertSafeSqlIdentifier(key, '更新字段')); } function buildDatasetWhereClause(table, { includeDeleted = false, rowScope = null } = {}) { const parts = []; if (!includeDeleted && tableHasColumn(table, 'deleted_at')) { parts.push('deleted_at IS NULL'); } if (rowScope?.whereClause) { parts.push(rowScope.whereClause); } return parts.length ? `WHERE ${parts.join(' AND ')}` : ''; } function readRowByIdForDataset(dataset, rowId, { rowScope = null, includeDeleted = false } = {}) { assertDatasetAction(dataset, 'read'); const table = assertSafeSqlIdentifier(dataset.table, '表名'); const id = Number(rowId); if (!Number.isFinite(id) || id <= 0) { throw Object.assign(new Error('行 id 无效'), { code: 'invalid_row_id' }); } const columns = resolveReadColumns(dataset); const parts = [`id = ${id}`]; if (!includeDeleted && tableHasColumn(table, 'deleted_at')) { parts.push('deleted_at IS NULL'); } if (rowScope?.whereClause) { parts.push(rowScope.whereClause); } const sql = `SELECT ${columns.map((column) => `"${column}"`).join(', ')} FROM "${table}" WHERE ${parts.join(' AND ')} LIMIT 1`; const rows = runJsonQuery(sql); if (!rows.length) { throw Object.assign(new Error('无权访问该行或行不存在'), { code: 'row_not_allowed' }); } return rows[0]; } function readRowsForDataset(dataset, { limit, offset, orderBy, orderDir, includeDeleted = false, rowScope = null } = {}) { assertDatasetAction(dataset, 'read'); const columns = resolveReadColumns(dataset); const table = assertSafeSqlIdentifier(dataset.table, '表名'); const maxLimit = Math.min( Math.max(1, Number(dataset.limits?.maxRowsPerRead ?? maxRows)), maxRows, ); const safeLimit = Math.min(Math.max(1, Number(limit ?? maxLimit)), maxLimit); const safeOffset = Math.max(0, Number(offset ?? 0)); const where = buildDatasetWhereClause(table, { includeDeleted, rowScope }); let orderClause = ''; if (orderBy) { const column = assertSafeSqlIdentifier(orderBy, '排序字段'); if (!columns.includes(column)) { throw Object.assign(new Error(`排序字段未授权:${column}`), { code: 'columns_not_allowed' }); } const direction = String(orderDir ?? 'asc').toLowerCase() === 'desc' ? 'DESC' : 'ASC'; orderClause = `ORDER BY "${column}" ${direction}`; } else if (columns.includes('id')) { orderClause = 'ORDER BY "id" DESC'; } const sql = `SELECT ${columns.map((column) => `"${column}"`).join(', ')} FROM "${table}" ${where} ${orderClause} LIMIT ${safeLimit} OFFSET ${safeOffset}`; const rows = runJsonQuery(sql); return { dataset, rows, limit: safeLimit, offset: safeOffset }; } function readDatasetRows(datasetName, options = {}) { const dataset = getDataset(datasetName); if (!dataset) { throw Object.assign(new Error('dataset 不存在'), { code: 'dataset_not_found' }); } return readRowsForDataset(dataset, options); } function getStatsForDataset(dataset, { rowScope = null } = {}) { assertDatasetAction(dataset, 'read'); const table = assertSafeSqlIdentifier(dataset.table, '表名'); const where = buildDatasetWhereClause(table, { rowScope }); const total = sqliteScalar(`SELECT COUNT(*) FROM "${table}" ${where};`); return { dataset: { name: dataset.name, table: dataset.table }, total }; } function getDatasetStats(datasetName) { const dataset = getDataset(datasetName); if (!dataset) { throw Object.assign(new Error('dataset 不存在'), { code: 'dataset_not_found' }); } return getStatsForDataset(dataset); } async function insertRowForDataset(dataset, payload, meta = {}) { assertDatasetAction(dataset, 'insert'); const payloadBytes = Buffer.byteLength(JSON.stringify(payload ?? {}), 'utf8'); const maxInsertBytes = Number(dataset.limits?.maxInsertBytes ?? 8192); if (payloadBytes > maxInsertBytes) { throw Object.assign(new Error('提交数据过大'), { code: 'payload_too_large' }); } const table = assertSafeSqlIdentifier(dataset.table, '表名'); const columns = resolveInsertColumns(dataset, payload); const values = { ...payload }; if (tableHasColumn(table, 'created_at') && values.created_at == null) { values.created_at = new Date().toISOString(); } if (tableHasColumn(table, 'created_ip_hash') && values.created_ip_hash == null && meta.createdIpHash) { values.created_ip_hash = meta.createdIpHash; } if (tableHasColumn(table, 'user_agent_hash') && values.user_agent_hash == null && meta.userAgentHash) { values.user_agent_hash = meta.userAgentHash; } if (tableHasColumn(table, 'page_data_session_id') && values.page_data_session_id == null && meta.pageDataSessionId) { values.page_data_session_id = meta.pageDataSessionId; } if ( meta.rowScope?.ownerColumn && tableHasColumn(table, meta.rowScope.ownerColumn) && values[meta.rowScope.ownerColumn] == null && meta.rowScope.visitorUserId ) { values[meta.rowScope.ownerColumn] = meta.rowScope.visitorUserId; } let insertColumns = columns.filter((column) => values[column] !== undefined); if ( meta.rowScope?.ownerColumn && tableHasColumn(table, meta.rowScope.ownerColumn) && values[meta.rowScope.ownerColumn] != null && !insertColumns.includes(meta.rowScope.ownerColumn) ) { insertColumns.push(meta.rowScope.ownerColumn); } if (!insertColumns.length) { throw Object.assign(new Error('提交数据不能为空'), { code: 'invalid_payload' }); } const sql = `INSERT INTO "${table}" (${insertColumns.map((column) => `"${column}"`).join(', ')}) VALUES (${insertColumns.map((column) => sqlLiteral(values[column])).join(', ')});`; await executeSql(sql); const id = sqliteScalar('SELECT last_insert_rowid();'); let row = { id }; if (dataset.actions.includes('read')) { const readBack = readRowsForDataset(dataset, { limit: 1, orderBy: 'id', orderDir: 'desc', rowScope: meta.rowScope?.whereClause ? meta.rowScope : null, }); row = readBack.rows.find((item) => Number(item.id) === Number(id)) ?? readBack.rows[0] ?? row; } else { for (const column of insertColumns) { row[column] = values[column]; } } return { dataset, row }; } async function updateDatasetRow(datasetName, rowId, payload, meta = {}) { const dataset = getDataset(datasetName); if (!dataset) { throw Object.assign(new Error('dataset 不存在'), { code: 'dataset_not_found' }); } return updateRowForDataset(dataset, rowId, payload, meta); } async function softDeleteDatasetRow(datasetName, rowId, meta = {}) { const dataset = getDataset(datasetName); if (!dataset) { throw Object.assign(new Error('dataset 不存在'), { code: 'dataset_not_found' }); } return softDeleteRowForDataset(dataset, rowId, meta); } async function insertDatasetRow(datasetName, payload, meta = {}) { const dataset = getDataset(datasetName); if (!dataset) { throw Object.assign(new Error('dataset 不存在'), { code: 'dataset_not_found' }); } return insertRowForDataset(dataset, payload, meta); } async function updateRowForDataset(dataset, rowId, payload, meta = {}) { assertDatasetAction(dataset, 'update'); const table = assertSafeSqlIdentifier(dataset.table, '表名'); const id = Number(rowId); if (!Number.isFinite(id) || id <= 0) { throw Object.assign(new Error('行 id 无效'), { code: 'invalid_row_id' }); } const columns = resolveUpdateColumns(dataset, payload); const values = { ...payload }; if (tableHasColumn(table, 'updated_at')) { values.updated_at = new Date().toISOString(); } if (tableHasColumn(table, 'updated_by_label') && meta.updatedByLabel) { values.updated_by_label = meta.updatedByLabel; } const assignments = columns.map((column) => `"${column}" = ${sqlLiteral(values[column])}`); if (tableHasColumn(table, 'updated_at') && !columns.includes('updated_at')) { assignments.push(`"updated_at" = ${sqlLiteral(values.updated_at)}`); } if (tableHasColumn(table, 'updated_by_label') && meta.updatedByLabel && !columns.includes('updated_by_label')) { assignments.push(`"updated_by_label" = ${sqlLiteral(meta.updatedByLabel)}`); } const scopeClause = meta.rowScope?.whereClause ? ` AND ${meta.rowScope.whereClause}` : ''; const deletedFilter = tableHasColumn(table, 'deleted_at') ? ' AND deleted_at IS NULL' : ''; const sql = `UPDATE "${table}" SET ${assignments.join(', ')} WHERE id = ${id}${deletedFilter}${scopeClause};`; await executeSql(sql); const row = readRowByIdForDataset(dataset, id, { rowScope: meta.rowScope ?? null }); return { dataset, row }; } async function softDeleteRowForDataset(dataset, rowId, meta = {}) { assertDatasetAction(dataset, 'soft_delete'); const table = assertSafeSqlIdentifier(dataset.table, '表名'); const id = Number(rowId); if (!Number.isFinite(id) || id <= 0) { throw Object.assign(new Error('行 id 无效'), { code: 'invalid_row_id' }); } if (!tableHasColumn(table, 'deleted_at')) { throw Object.assign(new Error('目标表不支持软删除'), { code: 'soft_delete_unsupported' }); } const assignments = ['deleted_at = CURRENT_TIMESTAMP']; if (tableHasColumn(table, 'deleted_by')) { assignments.push(`deleted_by = ${sqlLiteral(meta.deletedBy ?? meta.updatedByLabel ?? 'public')}`); } const scopeClause = meta.rowScope?.whereClause ? ` AND ${meta.rowScope.whereClause}` : ''; const sql = `UPDATE "${table}" SET ${assignments.join(', ')} WHERE id = ${id} AND deleted_at IS NULL${scopeClause};`; await executeSql(sql); const verifyParts = [`id = ${id}`]; if (meta.rowScope?.whereClause) { verifyParts.push(meta.rowScope.whereClause); } const deletedRow = runJsonQuery( `SELECT id, deleted_at FROM "${table}" WHERE ${verifyParts.join(' AND ')} LIMIT 1`, ); if (!deletedRow.length || !deletedRow[0].deleted_at) { throw Object.assign(new Error('无权删除该行或行不存在'), { code: 'row_not_allowed' }); } return { dataset, id, deleted: true }; } async function hardDeleteRowForDataset(dataset, rowId, meta = {}) { assertDatasetAction(dataset, 'hard_delete'); const table = assertSafeSqlIdentifier(dataset.table, '表名'); const id = Number(rowId); if (!Number.isFinite(id) || id <= 0) { throw Object.assign(new Error('行 id 无效'), { code: 'invalid_row_id' }); } const scopeClause = meta.rowScope?.whereClause ? ` AND ${meta.rowScope.whereClause}` : ''; await executeSql(`DELETE FROM "${table}" WHERE id = ${id}${scopeClause};`); return { dataset, id, deleted: true, permanentlyDeleted: true }; } async function restoreSoftDeletedRowForDataset(dataset, rowId) { const table = assertSafeSqlIdentifier(dataset.table, '表名'); const id = Number(rowId); if (!Number.isFinite(id) || id <= 0) { throw Object.assign(new Error('行 id 无效'), { code: 'invalid_row_id' }); } if (!tableHasColumn(table, 'deleted_at')) { throw Object.assign(new Error('目标表不支持软删除恢复'), { code: 'restore_unsupported' }); } const assignments = ['deleted_at = NULL']; if (tableHasColumn(table, 'deleted_by')) { assignments.push('deleted_by = NULL'); } const sql = `UPDATE "${table}" SET ${assignments.join(', ')} WHERE id = ${id} AND deleted_at IS NOT NULL;`; await executeSql(sql); const readBack = readRowsForDataset(dataset, { limit: 1, orderBy: 'id', orderDir: 'desc', includeDeleted: true }); const row = readBack.rows.find((item) => Number(item.id) === id) ?? { id }; return { dataset, row, restored: true }; } async function restoreSoftDeletedRow(datasetName, rowId) { const dataset = getDataset(datasetName); if (!dataset) { throw Object.assign(new Error('dataset 不存在'), { code: 'dataset_not_found' }); } return restoreSoftDeletedRowForDataset(dataset, rowId); } function csvEscape(value) { const text = value == null ? '' : String(value); if (/[",\n\r]/.test(text)) return `"${text.replace(/"/g, '""')}"`; return text; } function exportRowsAsCsv(rows, columns) { const header = columns.join(','); const body = rows.map((row) => columns.map((column) => csvEscape(row[column])).join(',')); return `${header}\n${body.join('\n')}\n`; } function exportDatasetRows(datasetName, { format = 'json', includeDeleted = false, limit = 1000 } = {}) { const dataset = getDataset(datasetName); if (!dataset) { throw Object.assign(new Error('dataset 不存在'), { code: 'dataset_not_found' }); } assertDatasetAction(dataset, 'read'); const exportLimit = Math.min(Math.max(1, Number(limit ?? 1000)), 5000); const result = readRowsForDataset(dataset, { limit: exportLimit, offset: 0, includeDeleted: Boolean(includeDeleted), }); const normalizedFormat = String(format ?? 'json').toLowerCase(); if (normalizedFormat === 'csv') { const columns = resolveReadColumns(dataset); return { dataset: dataset.name, format: 'csv', content: exportRowsAsCsv(result.rows, columns), rowCount: result.rows.length, }; } return { dataset: dataset.name, format: 'json', rows: result.rows, rowCount: result.rows.length, }; } function getSchemaForDataset(dataset) { const tableColumns = listTableColumns(dataset.table); return { dataset: { name: dataset.name, table: dataset.table, description: dataset.description, actions: dataset.actions, columns: dataset.columns, limits: dataset.limits, }, tableColumns, }; } function getDatasetSchema(datasetName) { const dataset = getDataset(datasetName); if (!dataset) { throw Object.assign(new Error('dataset 不存在'), { code: 'dataset_not_found' }); } return getSchemaForDataset(dataset); } return { workspaceRoot, privateDataDir, privateDataDb, ensureReady, ensurePrivateDataDb, privateDataSize, getInfo, getSchema, querySql, executeSql, listTableColumns, ensureDatasetRegistryTable, getDataset, listDatasets, upsertDataset, readDatasetRows, readRowsForDataset, getDatasetStats, getStatsForDataset, insertDatasetRow, insertRowForDataset, updateDatasetRow, updateRowForDataset, softDeleteDatasetRow, softDeleteRowForDataset, hardDeleteRowForDataset, restoreSoftDeletedRowForDataset, restoreSoftDeletedRow, exportDatasetRows, getDatasetSchema, getSchemaForDataset, }; }