mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
* feat(copilot): add rename operation to user_table tool * fix(copilot): use newName instead of name for table rename operation
922 lines
30 KiB
TypeScript
922 lines
30 KiB
TypeScript
import { createLogger } from '@sim/logger'
|
|
import type { BaseServerTool, ServerToolContext } from '@/lib/copilot/tools/server/base-tool'
|
|
import type { UserTableArgs, UserTableResult } from '@/lib/copilot/tools/shared/schemas'
|
|
import { COLUMN_TYPES } from '@/lib/table/constants'
|
|
import {
|
|
addTableColumn,
|
|
batchInsertRows,
|
|
batchUpdateRows,
|
|
createTable,
|
|
deleteColumn,
|
|
deleteColumns,
|
|
deleteRow,
|
|
deleteRowsByFilter,
|
|
deleteRowsByIds,
|
|
deleteTable,
|
|
getRowById,
|
|
getTableById,
|
|
insertRow,
|
|
queryRows,
|
|
renameColumn,
|
|
renameTable,
|
|
updateColumnConstraints,
|
|
updateColumnType,
|
|
updateRow,
|
|
updateRowsByFilter,
|
|
} from '@/lib/table/service'
|
|
import type { ColumnDefinition, RowData, TableDefinition } from '@/lib/table/types'
|
|
import {
|
|
downloadWorkspaceFile,
|
|
resolveWorkspaceFileReference,
|
|
} from '@/lib/uploads/contexts/workspace/workspace-file-manager'
|
|
|
|
const logger = createLogger('UserTableServerTool')
|
|
|
|
const MAX_BATCH_SIZE = 1000
|
|
const SCHEMA_SAMPLE_SIZE = 100
|
|
|
|
type ColumnType = 'string' | 'number' | 'boolean' | 'date' | 'json'
|
|
|
|
async function resolveWorkspaceFile(
|
|
filePath: string,
|
|
workspaceId: string
|
|
): Promise<{ buffer: Buffer; name: string; type: string }> {
|
|
const record = await resolveWorkspaceFileReference(workspaceId, filePath)
|
|
if (!record) {
|
|
throw new Error(
|
|
`File not found: "${filePath}". Use glob("files/*/meta.json") to list available files.`
|
|
)
|
|
}
|
|
const buffer = await downloadWorkspaceFile(record)
|
|
return { buffer, name: record.name, type: record.type }
|
|
}
|
|
|
|
function parseFileRows(
|
|
buffer: Buffer,
|
|
fileName: string,
|
|
contentType: string
|
|
): Promise<{ headers: string[]; rows: Record<string, unknown>[] }> {
|
|
const ext = fileName.split('.').pop()?.toLowerCase()
|
|
if (ext === 'json' || contentType === 'application/json') {
|
|
return parseJsonRows(buffer)
|
|
}
|
|
if (ext === 'csv' || ext === 'tsv' || contentType === 'text/csv') {
|
|
return parseCsvRows(buffer)
|
|
}
|
|
throw new Error(`Unsupported file format: "${ext}". Supported: csv, tsv, json`)
|
|
}
|
|
|
|
async function parseJsonRows(
|
|
buffer: Buffer
|
|
): Promise<{ headers: string[]; rows: Record<string, unknown>[] }> {
|
|
const parsed = JSON.parse(buffer.toString('utf-8'))
|
|
if (!Array.isArray(parsed)) {
|
|
throw new Error('JSON file must contain an array of objects')
|
|
}
|
|
if (parsed.length === 0) {
|
|
throw new Error('JSON file contains an empty array')
|
|
}
|
|
const headerSet = new Set<string>()
|
|
for (const row of parsed) {
|
|
if (typeof row !== 'object' || row === null || Array.isArray(row)) {
|
|
throw new Error('Each element in the JSON array must be a plain object')
|
|
}
|
|
for (const key of Object.keys(row)) headerSet.add(key)
|
|
}
|
|
return { headers: [...headerSet], rows: parsed }
|
|
}
|
|
|
|
async function parseCsvRows(
|
|
buffer: Buffer
|
|
): Promise<{ headers: string[]; rows: Record<string, unknown>[] }> {
|
|
const { parse } = await import('csv-parse/sync')
|
|
const parsed = parse(buffer.toString('utf-8'), {
|
|
columns: true,
|
|
skip_empty_lines: true,
|
|
trim: true,
|
|
relax_column_count: true,
|
|
relax_quotes: true,
|
|
skip_records_with_error: true,
|
|
cast: false,
|
|
}) as Record<string, unknown>[]
|
|
if (parsed.length === 0) {
|
|
throw new Error('CSV file has no data rows')
|
|
}
|
|
const headers = Object.keys(parsed[0])
|
|
if (headers.length === 0) {
|
|
throw new Error('CSV file has no headers')
|
|
}
|
|
return { headers, rows: parsed }
|
|
}
|
|
|
|
function inferColumnType(values: unknown[]): ColumnType {
|
|
const nonEmpty = values.filter((v) => v !== null && v !== undefined && v !== '')
|
|
if (nonEmpty.length === 0) return 'string'
|
|
|
|
const allNumber = nonEmpty.every((v) => {
|
|
const n = Number(v)
|
|
return !Number.isNaN(n) && String(v).trim() !== ''
|
|
})
|
|
if (allNumber) return 'number'
|
|
|
|
const allBoolean = nonEmpty.every((v) => {
|
|
const s = String(v).toLowerCase()
|
|
return s === 'true' || s === 'false'
|
|
})
|
|
if (allBoolean) return 'boolean'
|
|
|
|
const isoDatePattern = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2})?)?/
|
|
const allDate = nonEmpty.every((v) => {
|
|
const s = String(v)
|
|
return isoDatePattern.test(s) && !Number.isNaN(Date.parse(s))
|
|
})
|
|
if (allDate) return 'date'
|
|
|
|
return 'string'
|
|
}
|
|
|
|
function inferSchema(headers: string[], rows: Record<string, unknown>[]): ColumnDefinition[] {
|
|
const sample = rows.slice(0, SCHEMA_SAMPLE_SIZE)
|
|
return headers.map((name) => ({
|
|
name,
|
|
type: inferColumnType(sample.map((r) => r[name])),
|
|
}))
|
|
}
|
|
|
|
function coerceValue(value: unknown, colType: ColumnType): string | number | boolean | null {
|
|
if (value === null || value === undefined || value === '') return null
|
|
switch (colType) {
|
|
case 'number': {
|
|
const n = Number(value)
|
|
return Number.isNaN(n) ? null : n
|
|
}
|
|
case 'boolean': {
|
|
const s = String(value).toLowerCase()
|
|
return s === 'true'
|
|
}
|
|
case 'date':
|
|
return new Date(String(value)).toISOString()
|
|
default:
|
|
return String(value)
|
|
}
|
|
}
|
|
|
|
function coerceRows(
|
|
rows: Record<string, unknown>[],
|
|
columns: ColumnDefinition[],
|
|
columnMap: Map<string, ColumnDefinition>
|
|
): RowData[] {
|
|
return rows.map((row) => {
|
|
const coerced: RowData = {}
|
|
for (const col of columns) {
|
|
if (row[col.name] !== undefined) {
|
|
coerced[col.name] = coerceValue(row[col.name], col.type as ColumnType)
|
|
}
|
|
}
|
|
return coerced
|
|
})
|
|
}
|
|
|
|
async function batchInsertAll(
|
|
tableId: string,
|
|
rows: RowData[],
|
|
table: TableDefinition,
|
|
workspaceId: string
|
|
): Promise<number> {
|
|
let inserted = 0
|
|
for (let i = 0; i < rows.length; i += MAX_BATCH_SIZE) {
|
|
const batch = rows.slice(i, i + MAX_BATCH_SIZE)
|
|
const requestId = crypto.randomUUID().slice(0, 8)
|
|
const result = await batchInsertRows({ tableId, rows: batch, workspaceId }, table, requestId)
|
|
inserted += result.length
|
|
}
|
|
return inserted
|
|
}
|
|
|
|
export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult> = {
|
|
name: 'user_table',
|
|
async execute(params: UserTableArgs, context?: ServerToolContext): Promise<UserTableResult> {
|
|
if (!context?.userId) {
|
|
logger.error('Unauthorized attempt to access user table - no authenticated user context')
|
|
throw new Error('Authentication required')
|
|
}
|
|
|
|
const { operation, args = {} } = params
|
|
const workspaceId =
|
|
context.workspaceId || ((args as Record<string, unknown>).workspaceId as string | undefined)
|
|
|
|
try {
|
|
switch (operation) {
|
|
case 'create': {
|
|
if (!args.name) {
|
|
return { success: false, message: 'Name is required for creating a table' }
|
|
}
|
|
if (!args.schema) {
|
|
return { success: false, message: 'Schema is required for creating a table' }
|
|
}
|
|
if (!workspaceId) {
|
|
return { success: false, message: 'Workspace ID is required' }
|
|
}
|
|
|
|
const requestId = crypto.randomUUID().slice(0, 8)
|
|
const table = await createTable(
|
|
{
|
|
name: args.name,
|
|
description: args.description,
|
|
schema: args.schema,
|
|
workspaceId,
|
|
userId: context.userId,
|
|
},
|
|
requestId
|
|
)
|
|
|
|
return {
|
|
success: true,
|
|
message: `Created table "${table.name}" (${table.id})`,
|
|
data: { table },
|
|
}
|
|
}
|
|
|
|
case 'get': {
|
|
if (!args.tableId) {
|
|
return { success: false, message: 'Table ID is required' }
|
|
}
|
|
|
|
const table = await getTableById(args.tableId)
|
|
if (!table) {
|
|
return { success: false, message: `Table not found: ${args.tableId}` }
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
message: `Table "${table.name}" has ${table.rowCount} rows`,
|
|
data: { table },
|
|
}
|
|
}
|
|
|
|
case 'get_schema': {
|
|
if (!args.tableId) {
|
|
return { success: false, message: 'Table ID is required' }
|
|
}
|
|
|
|
const table = await getTableById(args.tableId)
|
|
if (!table) {
|
|
return { success: false, message: `Table not found: ${args.tableId}` }
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
message: `Schema for "${table.name}"`,
|
|
data: { name: table.name, columns: table.schema.columns },
|
|
}
|
|
}
|
|
|
|
case 'delete': {
|
|
if (!args.tableId) {
|
|
return { success: false, message: 'Table ID is required' }
|
|
}
|
|
if (!workspaceId) {
|
|
return { success: false, message: 'Workspace ID is required' }
|
|
}
|
|
|
|
const table = await getTableById(args.tableId)
|
|
if (!table) {
|
|
return { success: false, message: `Table not found: ${args.tableId}` }
|
|
}
|
|
if (table.workspaceId !== workspaceId) {
|
|
return { success: false, message: 'Table not found' }
|
|
}
|
|
|
|
const requestId = crypto.randomUUID().slice(0, 8)
|
|
await deleteTable(args.tableId, requestId)
|
|
|
|
return {
|
|
success: true,
|
|
message: `Deleted table ${args.tableId}`,
|
|
}
|
|
}
|
|
|
|
case 'insert_row': {
|
|
if (!args.tableId) {
|
|
return { success: false, message: 'Table ID is required' }
|
|
}
|
|
if (!args.data) {
|
|
return { success: false, message: 'Data is required for inserting a row' }
|
|
}
|
|
if (!workspaceId) {
|
|
return { success: false, message: 'Workspace ID is required' }
|
|
}
|
|
|
|
const table = await getTableById(args.tableId)
|
|
if (!table) {
|
|
return { success: false, message: `Table not found: ${args.tableId}` }
|
|
}
|
|
|
|
const requestId = crypto.randomUUID().slice(0, 8)
|
|
const row = await insertRow(
|
|
{ tableId: args.tableId, data: args.data, workspaceId },
|
|
table,
|
|
requestId
|
|
)
|
|
|
|
return {
|
|
success: true,
|
|
message: `Inserted row ${row.id}`,
|
|
data: { row },
|
|
}
|
|
}
|
|
|
|
case 'batch_insert_rows': {
|
|
if (!args.tableId) {
|
|
return { success: false, message: 'Table ID is required' }
|
|
}
|
|
if (!args.rows || args.rows.length === 0) {
|
|
return { success: false, message: 'Rows array is required and must not be empty' }
|
|
}
|
|
if (!workspaceId) {
|
|
return { success: false, message: 'Workspace ID is required' }
|
|
}
|
|
|
|
const table = await getTableById(args.tableId)
|
|
if (!table) {
|
|
return { success: false, message: `Table not found: ${args.tableId}` }
|
|
}
|
|
|
|
const requestId = crypto.randomUUID().slice(0, 8)
|
|
const rows = await batchInsertRows(
|
|
{ tableId: args.tableId, rows: args.rows, workspaceId },
|
|
table,
|
|
requestId
|
|
)
|
|
|
|
return {
|
|
success: true,
|
|
message: `Inserted ${rows.length} rows`,
|
|
data: { rows, insertedCount: rows.length },
|
|
}
|
|
}
|
|
|
|
case 'get_row': {
|
|
if (!args.tableId) {
|
|
return { success: false, message: 'Table ID is required' }
|
|
}
|
|
if (!args.rowId) {
|
|
return { success: false, message: 'Row ID is required' }
|
|
}
|
|
if (!workspaceId) {
|
|
return { success: false, message: 'Workspace ID is required' }
|
|
}
|
|
|
|
const row = await getRowById(args.tableId, args.rowId, workspaceId)
|
|
if (!row) {
|
|
return { success: false, message: `Row not found: ${args.rowId}` }
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
message: `Row ${row.id}`,
|
|
data: { row },
|
|
}
|
|
}
|
|
|
|
case 'query_rows': {
|
|
if (!args.tableId) {
|
|
return { success: false, message: 'Table ID is required' }
|
|
}
|
|
if (!workspaceId) {
|
|
return { success: false, message: 'Workspace ID is required' }
|
|
}
|
|
|
|
const requestId = crypto.randomUUID().slice(0, 8)
|
|
const result = await queryRows(
|
|
args.tableId,
|
|
workspaceId,
|
|
{
|
|
filter: args.filter,
|
|
sort: args.sort,
|
|
limit: args.limit,
|
|
offset: args.offset,
|
|
},
|
|
requestId
|
|
)
|
|
|
|
return {
|
|
success: true,
|
|
message: `Returned ${result.rows.length} of ${result.totalCount} rows`,
|
|
data: result,
|
|
}
|
|
}
|
|
|
|
case 'update_row': {
|
|
if (!args.tableId) {
|
|
return { success: false, message: 'Table ID is required' }
|
|
}
|
|
if (!args.rowId) {
|
|
return { success: false, message: 'Row ID is required' }
|
|
}
|
|
if (!args.data) {
|
|
return { success: false, message: 'Data is required for updating a row' }
|
|
}
|
|
if (!workspaceId) {
|
|
return { success: false, message: 'Workspace ID is required' }
|
|
}
|
|
|
|
const table = await getTableById(args.tableId)
|
|
if (!table) {
|
|
return { success: false, message: `Table not found: ${args.tableId}` }
|
|
}
|
|
|
|
const requestId = crypto.randomUUID().slice(0, 8)
|
|
const updatedRow = await updateRow(
|
|
{ tableId: args.tableId, rowId: args.rowId, data: args.data, workspaceId },
|
|
table,
|
|
requestId
|
|
)
|
|
|
|
return {
|
|
success: true,
|
|
message: `Updated row ${updatedRow.id}`,
|
|
data: { row: updatedRow },
|
|
}
|
|
}
|
|
|
|
case 'delete_row': {
|
|
if (!args.tableId) {
|
|
return { success: false, message: 'Table ID is required' }
|
|
}
|
|
if (!args.rowId) {
|
|
return { success: false, message: 'Row ID is required' }
|
|
}
|
|
if (!workspaceId) {
|
|
return { success: false, message: 'Workspace ID is required' }
|
|
}
|
|
|
|
const requestId = crypto.randomUUID().slice(0, 8)
|
|
await deleteRow(args.tableId, args.rowId, workspaceId, requestId)
|
|
|
|
return {
|
|
success: true,
|
|
message: `Deleted row ${args.rowId}`,
|
|
}
|
|
}
|
|
|
|
case 'update_rows_by_filter': {
|
|
if (!args.tableId) {
|
|
return { success: false, message: 'Table ID is required' }
|
|
}
|
|
if (!args.filter) {
|
|
return { success: false, message: 'Filter is required for bulk update' }
|
|
}
|
|
if (!args.data) {
|
|
return { success: false, message: 'Data is required for bulk update' }
|
|
}
|
|
if (!workspaceId) {
|
|
return { success: false, message: 'Workspace ID is required' }
|
|
}
|
|
|
|
const table = await getTableById(args.tableId)
|
|
if (!table) {
|
|
return { success: false, message: `Table not found: ${args.tableId}` }
|
|
}
|
|
|
|
const requestId = crypto.randomUUID().slice(0, 8)
|
|
const result = await updateRowsByFilter(
|
|
{
|
|
tableId: args.tableId,
|
|
filter: args.filter,
|
|
data: args.data,
|
|
limit: args.limit,
|
|
workspaceId,
|
|
},
|
|
table,
|
|
requestId
|
|
)
|
|
|
|
return {
|
|
success: true,
|
|
message: `Updated ${result.affectedCount} rows`,
|
|
data: { affectedCount: result.affectedCount, affectedRowIds: result.affectedRowIds },
|
|
}
|
|
}
|
|
|
|
case 'delete_rows_by_filter': {
|
|
if (!args.tableId) {
|
|
return { success: false, message: 'Table ID is required' }
|
|
}
|
|
if (!args.filter) {
|
|
return { success: false, message: 'Filter is required for bulk delete' }
|
|
}
|
|
if (!workspaceId) {
|
|
return { success: false, message: 'Workspace ID is required' }
|
|
}
|
|
|
|
const requestId = crypto.randomUUID().slice(0, 8)
|
|
const result = await deleteRowsByFilter(
|
|
{
|
|
tableId: args.tableId,
|
|
filter: args.filter,
|
|
limit: args.limit,
|
|
workspaceId,
|
|
},
|
|
requestId
|
|
)
|
|
|
|
return {
|
|
success: true,
|
|
message: `Deleted ${result.affectedCount} rows`,
|
|
data: { affectedCount: result.affectedCount, affectedRowIds: result.affectedRowIds },
|
|
}
|
|
}
|
|
|
|
case 'batch_update_rows': {
|
|
if (!args.tableId) {
|
|
return { success: false, message: 'Table ID is required' }
|
|
}
|
|
if (!workspaceId) {
|
|
return { success: false, message: 'Workspace ID is required' }
|
|
}
|
|
|
|
const rawUpdates = (args as Record<string, unknown>).updates as
|
|
| Array<{ rowId: string; data: Record<string, unknown> }>
|
|
| undefined
|
|
const columnName = (args as Record<string, unknown>).columnName as string | undefined
|
|
const valuesMap = (args as Record<string, unknown>).values as
|
|
| Record<string, unknown>
|
|
| undefined
|
|
|
|
let updates: Array<{ rowId: string; data: Record<string, unknown> }>
|
|
|
|
if (rawUpdates && rawUpdates.length > 0) {
|
|
updates = rawUpdates
|
|
} else if (columnName && valuesMap) {
|
|
updates = Object.entries(valuesMap).map(([rowId, value]) => ({
|
|
rowId,
|
|
data: { [columnName]: value },
|
|
}))
|
|
} else {
|
|
return {
|
|
success: false,
|
|
message: 'Provide either "updates" array or "columnName" + "values" map',
|
|
}
|
|
}
|
|
|
|
if (updates.length > MAX_BATCH_SIZE) {
|
|
return {
|
|
success: false,
|
|
message: `Too many updates (${updates.length}). Maximum is ${MAX_BATCH_SIZE}.`,
|
|
}
|
|
}
|
|
|
|
const table = await getTableById(args.tableId)
|
|
if (!table) {
|
|
return { success: false, message: `Table not found: ${args.tableId}` }
|
|
}
|
|
|
|
const requestId = crypto.randomUUID().slice(0, 8)
|
|
const result = await batchUpdateRows(
|
|
{
|
|
tableId: args.tableId,
|
|
updates: updates as Array<{ rowId: string; data: RowData }>,
|
|
workspaceId,
|
|
},
|
|
table,
|
|
requestId
|
|
)
|
|
|
|
return {
|
|
success: true,
|
|
message: `Updated ${result.affectedCount} rows`,
|
|
data: { affectedCount: result.affectedCount, affectedRowIds: result.affectedRowIds },
|
|
}
|
|
}
|
|
|
|
case 'batch_delete_rows': {
|
|
if (!args.tableId) {
|
|
return { success: false, message: 'Table ID is required' }
|
|
}
|
|
if (!workspaceId) {
|
|
return { success: false, message: 'Workspace ID is required' }
|
|
}
|
|
|
|
const rowIds = (args as Record<string, unknown>).rowIds as string[] | undefined
|
|
if (!rowIds || rowIds.length === 0) {
|
|
return { success: false, message: 'rowIds array is required' }
|
|
}
|
|
|
|
if (rowIds.length > MAX_BATCH_SIZE) {
|
|
return {
|
|
success: false,
|
|
message: `Too many row IDs (${rowIds.length}). Maximum is ${MAX_BATCH_SIZE}.`,
|
|
}
|
|
}
|
|
|
|
const requestId = crypto.randomUUID().slice(0, 8)
|
|
const result = await deleteRowsByIds(
|
|
{ tableId: args.tableId, rowIds, workspaceId },
|
|
requestId
|
|
)
|
|
|
|
return {
|
|
success: true,
|
|
message: `Deleted ${result.deletedCount} rows`,
|
|
data: {
|
|
deletedCount: result.deletedCount,
|
|
deletedRowIds: result.deletedRowIds,
|
|
},
|
|
}
|
|
}
|
|
|
|
case 'create_from_file': {
|
|
const filePath = (args as Record<string, unknown>).filePath as string | undefined
|
|
if (!filePath) {
|
|
return { success: false, message: 'filePath is required (e.g. "files/data.csv")' }
|
|
}
|
|
if (!workspaceId) {
|
|
return { success: false, message: 'Workspace ID is required' }
|
|
}
|
|
|
|
const file = await resolveWorkspaceFile(filePath, workspaceId)
|
|
const { headers, rows } = await parseFileRows(file.buffer, file.name, file.type)
|
|
if (rows.length === 0) {
|
|
return { success: false, message: 'File contains no data rows' }
|
|
}
|
|
|
|
const columns = inferSchema(headers, rows)
|
|
const tableName = args.name || file.name.replace(/\.[^.]+$/, '')
|
|
const requestId = crypto.randomUUID().slice(0, 8)
|
|
const table = await createTable(
|
|
{
|
|
name: tableName,
|
|
description: args.description || `Imported from ${file.name}`,
|
|
schema: { columns },
|
|
workspaceId,
|
|
userId: context.userId,
|
|
},
|
|
requestId
|
|
)
|
|
|
|
const columnMap = new Map(columns.map((c) => [c.name, c]))
|
|
const coerced = coerceRows(rows, columns, columnMap)
|
|
const inserted = await batchInsertAll(table.id, coerced, table, workspaceId)
|
|
|
|
logger.info('Table created from file', {
|
|
tableId: table.id,
|
|
fileName: file.name,
|
|
columns: columns.length,
|
|
rows: inserted,
|
|
userId: context.userId,
|
|
})
|
|
|
|
return {
|
|
success: true,
|
|
message: `Created table "${table.name}" with ${columns.length} columns and ${inserted} rows from "${file.name}"`,
|
|
data: {
|
|
tableId: table.id,
|
|
tableName: table.name,
|
|
columns: columns.map((c) => ({ name: c.name, type: c.type })),
|
|
rowCount: inserted,
|
|
sourceFile: file.name,
|
|
},
|
|
}
|
|
}
|
|
|
|
case 'import_file': {
|
|
const filePath = (args as Record<string, unknown>).filePath as string | undefined
|
|
const tableId = (args as Record<string, unknown>).tableId as string | undefined
|
|
if (!filePath) {
|
|
return { success: false, message: 'filePath is required (e.g. "files/data.csv")' }
|
|
}
|
|
if (!tableId) {
|
|
return { success: false, message: 'tableId is required for import_file' }
|
|
}
|
|
if (!workspaceId) {
|
|
return { success: false, message: 'Workspace ID is required' }
|
|
}
|
|
|
|
const table = await getTableById(tableId)
|
|
if (!table) {
|
|
return { success: false, message: `Table not found: ${tableId}` }
|
|
}
|
|
|
|
const file = await resolveWorkspaceFile(filePath, workspaceId)
|
|
const { headers, rows } = await parseFileRows(file.buffer, file.name, file.type)
|
|
if (rows.length === 0) {
|
|
return { success: false, message: 'File contains no data rows' }
|
|
}
|
|
|
|
const tableColumns = table.schema.columns as ColumnDefinition[]
|
|
const tableColNames = new Set(tableColumns.map((c) => c.name))
|
|
const mappedHeaders = headers.filter((h) => tableColNames.has(h))
|
|
if (mappedHeaders.length === 0) {
|
|
return {
|
|
success: false,
|
|
message: `No matching columns between file (${headers.join(', ')}) and table (${tableColumns.map((c) => c.name).join(', ')})`,
|
|
}
|
|
}
|
|
|
|
const requiredMissing = tableColumns
|
|
.filter((c) => c.required && !headers.includes(c.name))
|
|
.map((c) => c.name)
|
|
if (requiredMissing.length > 0) {
|
|
return {
|
|
success: false,
|
|
message: `File is missing required columns: ${requiredMissing.join(', ')}`,
|
|
}
|
|
}
|
|
|
|
const columnMap = new Map(tableColumns.map((c) => [c.name, c]))
|
|
const matchedColumns = tableColumns.filter((c) => headers.includes(c.name))
|
|
const coerced = coerceRows(rows, matchedColumns, columnMap)
|
|
const inserted = await batchInsertAll(table.id, coerced, table, workspaceId)
|
|
|
|
logger.info('Rows imported from file', {
|
|
tableId: table.id,
|
|
fileName: file.name,
|
|
matchedColumns: mappedHeaders.length,
|
|
rows: inserted,
|
|
userId: context.userId,
|
|
})
|
|
|
|
return {
|
|
success: true,
|
|
message: `Imported ${inserted} rows into "${table.name}" from "${file.name}" (${mappedHeaders.length} columns matched)`,
|
|
data: {
|
|
tableId: table.id,
|
|
tableName: table.name,
|
|
matchedColumns: mappedHeaders,
|
|
skippedColumns: headers.filter((h) => !tableColNames.has(h)),
|
|
rowCount: inserted,
|
|
sourceFile: file.name,
|
|
},
|
|
}
|
|
}
|
|
|
|
case 'add_column': {
|
|
if (!args.tableId) {
|
|
return { success: false, message: 'Table ID is required' }
|
|
}
|
|
const col = (args as Record<string, unknown>).column as
|
|
| {
|
|
name: string
|
|
type: string
|
|
unique?: boolean
|
|
position?: number
|
|
}
|
|
| undefined
|
|
if (!col?.name || !col?.type) {
|
|
return {
|
|
success: false,
|
|
message: 'column with name and type is required for add_column',
|
|
}
|
|
}
|
|
const requestId = crypto.randomUUID().slice(0, 8)
|
|
const updated = await addTableColumn(args.tableId, col, requestId)
|
|
return {
|
|
success: true,
|
|
message: `Added column "${col.name}" (${col.type}) to table`,
|
|
data: { schema: updated.schema },
|
|
}
|
|
}
|
|
|
|
case 'rename_column': {
|
|
if (!args.tableId) {
|
|
return { success: false, message: 'Table ID is required' }
|
|
}
|
|
const colName = (args as Record<string, unknown>).columnName as string | undefined
|
|
const newColName = (args as Record<string, unknown>).newName as string | undefined
|
|
if (!colName || !newColName) {
|
|
return { success: false, message: 'columnName and newName are required' }
|
|
}
|
|
const requestId = crypto.randomUUID().slice(0, 8)
|
|
const updated = await renameColumn(
|
|
{ tableId: args.tableId, oldName: colName, newName: newColName },
|
|
requestId
|
|
)
|
|
return {
|
|
success: true,
|
|
message: `Renamed column "${colName}" to "${newColName}"`,
|
|
data: { schema: updated.schema },
|
|
}
|
|
}
|
|
|
|
case 'delete_column': {
|
|
if (!args.tableId) {
|
|
return { success: false, message: 'Table ID is required' }
|
|
}
|
|
const colName = (args as Record<string, unknown>).columnName as string | undefined
|
|
const colNames = (args as Record<string, unknown>).columnNames as string[] | undefined
|
|
const names = colNames ?? (colName ? [colName] : null)
|
|
if (!names || names.length === 0) {
|
|
return { success: false, message: 'columnName or columnNames is required' }
|
|
}
|
|
const requestId = crypto.randomUUID().slice(0, 8)
|
|
if (names.length === 1) {
|
|
const updated = await deleteColumn(
|
|
{ tableId: args.tableId, columnName: names[0] },
|
|
requestId
|
|
)
|
|
return {
|
|
success: true,
|
|
message: `Deleted column "${names[0]}"`,
|
|
data: { schema: updated.schema },
|
|
}
|
|
}
|
|
const updated = await deleteColumns(
|
|
{ tableId: args.tableId, columnNames: names },
|
|
requestId
|
|
)
|
|
return {
|
|
success: true,
|
|
message: `Deleted ${names.length} columns: ${names.join(', ')}`,
|
|
data: { schema: updated.schema },
|
|
}
|
|
}
|
|
|
|
case 'update_column': {
|
|
if (!args.tableId) {
|
|
return { success: false, message: 'Table ID is required' }
|
|
}
|
|
const colName = (args as Record<string, unknown>).columnName as string | undefined
|
|
if (!colName) {
|
|
return { success: false, message: 'columnName is required' }
|
|
}
|
|
const newType = (args as Record<string, unknown>).newType as string | undefined
|
|
const uniqFlag = (args as Record<string, unknown>).unique as boolean | undefined
|
|
if (newType === undefined && uniqFlag === undefined) {
|
|
return {
|
|
success: false,
|
|
message: 'At least one of newType or unique must be provided',
|
|
}
|
|
}
|
|
const requestId = crypto.randomUUID().slice(0, 8)
|
|
let result: TableDefinition | undefined
|
|
if (newType !== undefined) {
|
|
if (!(COLUMN_TYPES as readonly string[]).includes(newType)) {
|
|
return {
|
|
success: false,
|
|
message: `Invalid column type "${newType}". Must be one of: ${COLUMN_TYPES.join(', ')}`,
|
|
}
|
|
}
|
|
result = await updateColumnType(
|
|
{
|
|
tableId: args.tableId,
|
|
columnName: colName,
|
|
newType: newType as (typeof COLUMN_TYPES)[number],
|
|
},
|
|
requestId
|
|
)
|
|
}
|
|
if (uniqFlag !== undefined) {
|
|
result = await updateColumnConstraints(
|
|
{ tableId: args.tableId, columnName: colName, unique: uniqFlag },
|
|
requestId
|
|
)
|
|
}
|
|
return {
|
|
success: true,
|
|
message: `Updated column "${colName}"`,
|
|
data: { schema: result?.schema },
|
|
}
|
|
}
|
|
|
|
case 'rename': {
|
|
if (!args.tableId) {
|
|
return { success: false, message: 'Table ID is required' }
|
|
}
|
|
const newName = (args as Record<string, unknown>).newName as string | undefined
|
|
if (!newName) {
|
|
return { success: false, message: 'newName is required for renaming a table' }
|
|
}
|
|
if (!workspaceId) {
|
|
return { success: false, message: 'Workspace ID is required' }
|
|
}
|
|
|
|
const table = await getTableById(args.tableId)
|
|
if (!table) {
|
|
return { success: false, message: `Table not found: ${args.tableId}` }
|
|
}
|
|
if (table.workspaceId !== workspaceId) {
|
|
return { success: false, message: 'Table not found' }
|
|
}
|
|
|
|
const requestId = crypto.randomUUID().slice(0, 8)
|
|
const renamed = await renameTable(args.tableId, newName, requestId)
|
|
|
|
return {
|
|
success: true,
|
|
message: `Renamed table to "${renamed.name}"`,
|
|
data: { table: { id: renamed.id, name: renamed.name } },
|
|
}
|
|
}
|
|
|
|
default:
|
|
return { success: false, message: `Unknown operation: ${operation}` }
|
|
}
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : String(error)
|
|
logger.error('Table operation failed', { operation, error: errorMessage })
|
|
return { success: false, message: `Operation failed: ${errorMessage}` }
|
|
}
|
|
},
|
|
}
|