805 lines
30 KiB
JavaScript
805 lines
30 KiB
JavaScript
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 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,
|
|
restoreSoftDeletedRowForDataset,
|
|
restoreSoftDeletedRow,
|
|
exportDatasetRows,
|
|
getDatasetSchema,
|
|
getSchemaForDataset,
|
|
};
|
|
}
|