Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
272 lines
8.5 KiB
JavaScript
272 lines
8.5 KiB
JavaScript
/**
|
|
* Table row batch operations controller
|
|
* Handles: POST /tables/:tableId/rows/batch-update,
|
|
* POST /tables/:tableId/rows/batch-delete
|
|
*/
|
|
import express from 'express';
|
|
import { dbAll, dbGet, dbRun, sqlNow, safeJsonParse } from '../../../database/connection.js';
|
|
import { apiLogger } from '../../../utils/logger.js';
|
|
import { success, notFound, badRequest, forbidden, error } from '../../../utils/response.js';
|
|
import { resolveSelectValues } from '../../../services/SelectValueResolver.js';
|
|
import { checkTableAccess } from './helpers.js';
|
|
import { fireRowUpdateTriggers } from '../../../services/AutomationTriggerService.js';
|
|
|
|
const router = express.Router();
|
|
|
|
/**
|
|
* POST /api/v3/tables/:tableId/rows/batch-update
|
|
* Batch update multiple rows at once
|
|
* Useful for bulk replace and mass updates
|
|
*/
|
|
router.post('/tables/:tableId/rows/batch-update', async (req, res) => {
|
|
try {
|
|
const { tableId } = req.params;
|
|
const { updates } = req.body;
|
|
|
|
// Validate input
|
|
if (!Array.isArray(updates) || updates.length === 0) {
|
|
return badRequest(res, 'Updates array is required and must not be empty');
|
|
}
|
|
|
|
// Limit batch size for safety (single transactional batch)
|
|
const MAX_BATCH_SIZE = 10000;
|
|
if (updates.length > MAX_BATCH_SIZE) {
|
|
return error(
|
|
res,
|
|
'BATCH_LIMIT_EXCEEDED',
|
|
`Batch size exceeds limit of ${MAX_BATCH_SIZE}`,
|
|
400,
|
|
{ limit: MAX_BATCH_SIZE, requested: updates.length }
|
|
);
|
|
}
|
|
|
|
apiLogger.debug({ tableId, count: updates.length }, "BATCH UPDATE request");
|
|
|
|
// Check access if API key has project restriction
|
|
if (req.user?.projectId) {
|
|
const access = await checkTableAccess(tableId, req.user);
|
|
if (!access.allowed) {
|
|
return forbidden(res, access.error);
|
|
}
|
|
}
|
|
|
|
// Get table info
|
|
const table = await dbGet(`
|
|
SELECT data_source_id, source_table_name, source_id_column
|
|
FROM universal_tables
|
|
WHERE id = ?
|
|
`, [tableId]);
|
|
|
|
if (!table) {
|
|
return notFound(res, 'Table');
|
|
}
|
|
|
|
// Get columns for this table
|
|
const columns = await dbAll(`
|
|
SELECT id, column_name
|
|
FROM table_columns
|
|
WHERE table_id = ?
|
|
`, [tableId]);
|
|
|
|
// Build column ID to name map
|
|
const columnIdToName = {};
|
|
for (const col of columns) {
|
|
columnIdToName[col.id] = col.column_name;
|
|
columnIdToName[String(col.id)] = col.column_name;
|
|
}
|
|
|
|
const results = {
|
|
success: true,
|
|
updated: 0,
|
|
errors: []
|
|
};
|
|
|
|
// ADR-0025 A.2: row_update triggers are collected during the transaction
|
|
// and fired after COMMIT so they observe persisted state.
|
|
const pendingTriggers = [];
|
|
|
|
// Process updates in a transaction
|
|
await dbRun('BEGIN TRANSACTION');
|
|
|
|
try {
|
|
for (const update of updates) {
|
|
const { rowId, data } = update;
|
|
|
|
if (!rowId || !data) {
|
|
results.errors.push({ rowId: String(rowId), error: 'Missing rowId or data' });
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
// Handle local table_rows
|
|
if (!table.data_source_id || !table.source_table_name) {
|
|
// Get existing row
|
|
const existingRow = await dbGet(
|
|
'SELECT id, data FROM table_rows WHERE id = ? AND table_id = ?',
|
|
[rowId, tableId]
|
|
);
|
|
|
|
if (!existingRow) {
|
|
results.errors.push({ rowId: String(rowId), error: 'Row not found' });
|
|
continue;
|
|
}
|
|
|
|
// Map column_id keys to column_name keys
|
|
const normalizedData = {};
|
|
for (const [key, value] of Object.entries(data)) {
|
|
if (columnIdToName[key]) {
|
|
normalizedData[columnIdToName[key]] = value;
|
|
} else {
|
|
normalizedData[key] = value;
|
|
}
|
|
}
|
|
|
|
// ADR-098: Resolve select column text values -> numeric IDs before merge
|
|
const { resolvedData: validatedBatchData } = await resolveSelectValues(tableId, normalizedData);
|
|
|
|
// Merge data
|
|
const existingData = safeJsonParse(existingRow.data) || {};
|
|
const mergedData = { ...existingData, ...validatedBatchData };
|
|
|
|
await dbRun(
|
|
`UPDATE table_rows SET data = ?, updated_at = ${sqlNow()} WHERE id = ?`,
|
|
[JSON.stringify(mergedData), rowId]
|
|
);
|
|
|
|
pendingTriggers.push({ rowId: existingRow.id, oldData: existingData, newData: mergedData });
|
|
|
|
results.updated++;
|
|
} else {
|
|
// Handle external data source - save to local table_rows as virtual data
|
|
const existingRow = await dbGet(
|
|
'SELECT id, data FROM table_rows WHERE table_id = ? AND base_id = ?',
|
|
[tableId, rowId]
|
|
);
|
|
|
|
if (existingRow) {
|
|
const existingData = safeJsonParse(existingRow.data) || {};
|
|
const mergedData = { ...existingData, ...data };
|
|
|
|
await dbRun(
|
|
`UPDATE table_rows SET data = ?, updated_at = ${sqlNow()} WHERE id = ?`,
|
|
[JSON.stringify(mergedData), existingRow.id]
|
|
);
|
|
} else {
|
|
await dbRun(
|
|
`INSERT INTO table_rows (table_id, base_id, data, created_at, updated_at) VALUES (?, ?, ?, ${sqlNow()}, ${sqlNow()})`,
|
|
[tableId, rowId, JSON.stringify(data)]
|
|
);
|
|
}
|
|
|
|
results.updated++;
|
|
}
|
|
} catch (rowError) {
|
|
apiLogger.error({ err: rowError, rowId }, 'BATCH UPDATE row error');
|
|
results.errors.push({ rowId: String(rowId), error: rowError.message });
|
|
}
|
|
}
|
|
|
|
await dbRun('COMMIT');
|
|
} catch (txError) {
|
|
await dbRun('ROLLBACK');
|
|
throw txError;
|
|
}
|
|
|
|
for (const t of pendingTriggers) {
|
|
fireRowUpdateTriggers(parseInt(tableId), t.rowId, t.newData, t.oldData).catch(err => {
|
|
apiLogger.warn({ err, tableId, rowId: t.rowId }, 'Batch row update trigger failed (non-blocking)');
|
|
});
|
|
}
|
|
|
|
apiLogger.info({ updated: results.updated, errors: results.errors.length }, "BATCH UPDATE completed");
|
|
|
|
success(res, {
|
|
updated: results.updated,
|
|
errors: results.errors.length > 0 ? results.errors : undefined
|
|
});
|
|
} catch (err) {
|
|
apiLogger.error({ err }, 'POST batch-update error');
|
|
error(res, 'BATCH_UPDATE_FAILED', err.message, 500);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* POST /api/v3/tables/:tableId/rows/batch-delete
|
|
* Batch delete multiple rows at once
|
|
* Body: { rowIds: string[] }
|
|
*/
|
|
router.post('/tables/:tableId/rows/batch-delete', async (req, res) => {
|
|
try {
|
|
const { tableId } = req.params;
|
|
const { rowIds } = req.body;
|
|
|
|
// Validate input
|
|
if (!Array.isArray(rowIds) || rowIds.length === 0) {
|
|
return badRequest(res, 'rowIds array is required and must not be empty');
|
|
}
|
|
|
|
// Limit batch size for safety (single transactional batch)
|
|
const MAX_BATCH_SIZE = 10000;
|
|
if (rowIds.length > MAX_BATCH_SIZE) {
|
|
return error(
|
|
res,
|
|
'BATCH_LIMIT_EXCEEDED',
|
|
`Batch size exceeds limit of ${MAX_BATCH_SIZE}`,
|
|
400,
|
|
{ limit: MAX_BATCH_SIZE, requested: rowIds.length }
|
|
);
|
|
}
|
|
|
|
apiLogger.debug({ tableId, count: rowIds.length }, "BATCH DELETE request");
|
|
|
|
// Check access if API key has project restriction
|
|
if (req.user?.projectId) {
|
|
const access = await checkTableAccess(tableId, req.user);
|
|
if (!access.allowed) {
|
|
return forbidden(res, access.error);
|
|
}
|
|
}
|
|
|
|
// Get table info
|
|
const table = await dbGet(`
|
|
SELECT id, data_source_id, source_table_name
|
|
FROM universal_tables
|
|
WHERE id = ?
|
|
`, [tableId]);
|
|
|
|
if (!table) {
|
|
return notFound(res, 'Table');
|
|
}
|
|
|
|
// Only support local tables for batch delete
|
|
if (table.data_source_id) {
|
|
return badRequest(res, 'Batch delete is not supported for external data sources');
|
|
}
|
|
|
|
// Perform batch delete in transaction
|
|
await dbRun('BEGIN TRANSACTION');
|
|
|
|
try {
|
|
const placeholders = rowIds.map(() => '?').join(',');
|
|
|
|
const result = await dbRun(
|
|
`DELETE FROM table_rows WHERE table_id = ? AND id IN (${placeholders})`,
|
|
[tableId, ...rowIds]
|
|
);
|
|
|
|
await dbRun('COMMIT');
|
|
|
|
apiLogger.info({ deleted: result.changes }, "BATCH DELETE completed");
|
|
|
|
success(res, { deleted: result.changes });
|
|
} catch (txError) {
|
|
await dbRun('ROLLBACK');
|
|
throw txError;
|
|
}
|
|
} catch (err) {
|
|
apiLogger.error({ err }, 'POST batch-delete error');
|
|
error(res, 'BATCH_DELETE_FAILED', err.message, 500);
|
|
}
|
|
});
|
|
|
|
export default router;
|