feat(tables): column operations, row ordering, V1 API (#3488)

* feat(tables): add column operations, row ordering, V1 columns API, and OpenAPI spec

Adds column rename/delete/type change/constraint updates to the tables module,
row ordering via position column, UI metadata schema, V1 public API for column
operations with rate limiting and audit logging, and OpenAPI documentation.

Key changes:
- Service-layer column operations with validation (name pattern, type compatibility, unique/required constraints)
- Position column on user_table_rows with composite index for efficient ordering
- V1 /api/v1/tables/{tableId}/columns endpoint (POST/PATCH/DELETE) with rate limiting and audit
- Shared Zod schemas extracted to table/utils.ts using COLUMN_TYPES constant
- Targeted React Query invalidation (row vs schema mutations) with consistent onSettled usage
- OpenAPI 3.1.0 spec for columns endpoint with code samples
- Position field added to all row response mappings for consistency
- Sort fallback to position ordering when buildSortClause returns null

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(tables): use specific error prefixes instead of broad "Cannot" match

Prevents internal TypeErrors (e.g. "Cannot read properties of undefined")
from leaking as 400 responses. Now matches only domain-specific errors:
"Cannot delete the last column" and "Cannot set column".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(tables): reject Infinity and NaN in number type compatibility check

Number.isFinite rejects Infinity, -Infinity, and NaN, preventing
non-finite values from passing column type validation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(tables): invalidate table list on row create/delete for stale rowCount

Row create and delete mutations now invalidate the table list cache since
it includes a computed rowCount. Row updates (which don't change count)
continue to only invalidate row queries.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(tables): add column name length check, deduplicate name gen, reset pagination on clear

- Add MAX_COLUMN_NAME_LENGTH validation to addTableColumn (was missing,
  renameColumn already had it)
- Extract generateColumnName helper to eliminate triplicated logic across
  handleAddColumn, handleInsertColumnLeft, handleInsertColumnRight
- Reset pagination to page 0 when clearing sort/filter to prevent showing
  empty pages after narrowing filters are removed

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: hoist tableId above try block in V1 columns route, add detail invalidation to invalidateRowCount

- V1 columns route: `tableId` was declared inside `try` but referenced in
  `catch` logger.error, causing undefined in error logs. Hoisted `await params`
  above try in all three handlers (POST, PATCH, DELETE).
- invalidateRowCount: added `tableKeys.detail(tableId)` invalidation since the
  single-table GET response includes `rowCount`, which becomes stale after
  row create/delete without this.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: add position to all row mutation responses, remove dead filter code

- Add `position` field to POST (single + batch) and PATCH row responses
  across both internal and V1 routes, matching GET responses and OpenAPI spec.
- Remove unused `filterConfig`, `handleFilterToggle`, `handleFilterClear`,
  and `activeFilters` — dead code left over from merge conflict resolution.
  `handleFilterApply` (the one actually wired to JSX) is preserved.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: invalidateTableSchema now also invalidates table list cache

Column add/rename/delete/update mutations now invalidate tableKeys.list()
since the list endpoint returns schema.columns for each table. Without this,
the sidebar table list would show stale column schemas until staleTime expires.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: replace window.prompt/confirm with emcn Modal dialogs

Replace non-standard browser dialogs with proper emcn Modal components
to match the existing codebase pattern (e.g. delete table confirmation).

- Column rename: Modal with Input field + Enter key support
- Column delete: Modal with destructive confirmation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Waleed
2026-03-09 02:14:38 -07:00
committed by GitHub
parent d4eb25df91
commit 4c562c8e04
20 changed files with 14550 additions and 55 deletions

View File

@@ -1362,6 +1362,288 @@
}
}
},
"/api/v1/tables/{tableId}/columns": {
"post": {
"operationId": "addColumn",
"summary": "Add Column",
"description": "Add a new column to the table schema. Optionally specify a position to insert the column at a specific index.",
"tags": ["Tables"],
"x-codeSamples": [
{
"lang": "curl",
"source": "curl -X POST \\\n \"https://www.sim.ai/api/v1/tables/{tableId}/columns\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"column\": {\n \"name\": \"email\",\n \"type\": \"string\",\n \"required\": true,\n \"unique\": true\n }\n }'"
}
],
"parameters": [
{
"name": "tableId",
"in": "path",
"required": true,
"schema": { "type": "string" },
"description": "The table ID"
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["workspaceId", "column"],
"properties": {
"workspaceId": {
"type": "string",
"description": "The workspace ID"
},
"column": {
"type": "object",
"required": ["name", "type"],
"properties": {
"name": {
"type": "string",
"description": "Column name (alphanumeric and underscores, must start with letter or underscore)"
},
"type": {
"type": "string",
"enum": ["string", "number", "boolean", "date", "json"],
"description": "Column data type"
},
"required": {
"type": "boolean",
"description": "Whether the column requires a value"
},
"unique": {
"type": "boolean",
"description": "Whether column values must be unique"
},
"position": {
"type": "integer",
"minimum": 0,
"description": "Position index to insert the column at (0-based). Appends if omitted."
}
}
}
}
}
}
}
},
"responses": {
"200": {
"description": "Column added successfully",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": { "type": "boolean" },
"data": {
"type": "object",
"properties": {
"columns": {
"type": "array",
"items": { "$ref": "#/components/schemas/ColumnDefinition" }
}
}
}
}
}
}
}
},
"400": {
"$ref": "#/components/responses/BadRequest"
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"404": {
"$ref": "#/components/responses/NotFound"
},
"429": {
"$ref": "#/components/responses/RateLimited"
}
}
},
"patch": {
"operationId": "updateColumn",
"summary": "Update Column",
"description": "Update a column's name, type, or constraints. Multiple updates can be applied in a single request. When renaming, subsequent updates (type, constraints) use the new name.",
"tags": ["Tables"],
"x-codeSamples": [
{
"lang": "curl",
"source": "curl -X PATCH \\\n \"https://www.sim.ai/api/v1/tables/{tableId}/columns\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"columnName\": \"old_name\",\n \"updates\": {\n \"name\": \"new_name\",\n \"type\": \"number\"\n }\n }'"
}
],
"parameters": [
{
"name": "tableId",
"in": "path",
"required": true,
"schema": { "type": "string" },
"description": "The table ID"
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["workspaceId", "columnName", "updates"],
"properties": {
"workspaceId": {
"type": "string",
"description": "The workspace ID"
},
"columnName": {
"type": "string",
"description": "Current name of the column to update"
},
"updates": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "New column name"
},
"type": {
"type": "string",
"enum": ["string", "number", "boolean", "date", "json"],
"description": "New column data type"
},
"required": {
"type": "boolean",
"description": "Whether the column requires a value"
},
"unique": {
"type": "boolean",
"description": "Whether column values must be unique"
}
}
}
}
}
}
}
},
"responses": {
"200": {
"description": "Column updated successfully",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": { "type": "boolean" },
"data": {
"type": "object",
"properties": {
"columns": {
"type": "array",
"items": { "$ref": "#/components/schemas/ColumnDefinition" }
}
}
}
}
}
}
}
},
"400": {
"$ref": "#/components/responses/BadRequest"
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"404": {
"$ref": "#/components/responses/NotFound"
},
"429": {
"$ref": "#/components/responses/RateLimited"
}
}
},
"delete": {
"operationId": "deleteColumn",
"summary": "Delete Column",
"description": "Delete a column from the table schema. This removes the column definition and strips the corresponding key from all existing row data. Cannot delete the last remaining column.",
"tags": ["Tables"],
"x-codeSamples": [
{
"lang": "curl",
"source": "curl -X DELETE \\\n \"https://www.sim.ai/api/v1/tables/{tableId}/columns\" \\\n -H \"X-API-Key: YOUR_API_KEY\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"workspaceId\": \"YOUR_WORKSPACE_ID\",\n \"columnName\": \"old_column\"\n }'"
}
],
"parameters": [
{
"name": "tableId",
"in": "path",
"required": true,
"schema": { "type": "string" },
"description": "The table ID"
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["workspaceId", "columnName"],
"properties": {
"workspaceId": {
"type": "string",
"description": "The workspace ID"
},
"columnName": {
"type": "string",
"description": "Name of the column to delete"
}
}
}
}
}
},
"responses": {
"200": {
"description": "Column deleted successfully",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": { "type": "boolean" },
"data": {
"type": "object",
"properties": {
"columns": {
"type": "array",
"items": { "$ref": "#/components/schemas/ColumnDefinition" }
}
}
}
}
}
}
}
},
"400": {
"$ref": "#/components/responses/BadRequest"
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"404": {
"$ref": "#/components/responses/NotFound"
},
"429": {
"$ref": "#/components/responses/RateLimited"
}
}
}
},
"/api/v1/tables/{tableId}/rows": {
"get": {
"operationId": "listRows",
@@ -3774,6 +4056,10 @@
"additionalProperties": true,
"description": "Row data as key-value pairs matching the table schema."
},
"position": {
"type": "integer",
"description": "Row's position/order in the table."
},
"createdAt": {
"type": "string",
"format": "date-time",

View File

@@ -3,21 +3,24 @@ import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { addTableColumn } from '@/lib/table'
import { accessError, checkAccess, normalizeColumn } from '@/app/api/table/utils'
import {
addTableColumn,
deleteColumn,
renameColumn,
updateColumnConstraints,
updateColumnType,
} from '@/lib/table'
import {
accessError,
CreateColumnSchema,
checkAccess,
DeleteColumnSchema,
normalizeColumn,
UpdateColumnSchema,
} from '@/app/api/table/utils'
const logger = createLogger('TableColumnsAPI')
const CreateColumnSchema = z.object({
workspaceId: z.string().min(1, 'Workspace ID is required'),
column: z.object({
name: z.string().min(1, 'Column name is required'),
type: z.enum(['string', 'number', 'boolean', 'date', 'json']),
required: z.boolean().optional(),
unique: z.boolean().optional(),
}),
})
interface ColumnsRouteParams {
params: Promise<{ tableId: string }>
}
@@ -75,3 +78,154 @@ export async function POST(request: NextRequest, { params }: ColumnsRouteParams)
return NextResponse.json({ error: 'Failed to add column' }, { status: 500 })
}
}
/** PATCH /api/table/[tableId]/columns - Updates a column (rename, type change, constraints). */
export async function PATCH(request: NextRequest, { params }: ColumnsRouteParams) {
const requestId = generateRequestId()
const { tableId } = await params
try {
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success || !authResult.userId) {
logger.warn(`[${requestId}] Unauthorized column update attempt`)
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
const body = await request.json()
const validated = UpdateColumnSchema.parse(body)
const result = await checkAccess(tableId, authResult.userId, 'write')
if (!result.ok) return accessError(result, requestId, tableId)
const { table } = result
if (table.workspaceId !== validated.workspaceId) {
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
const { updates } = validated
let updatedTable = null
if (updates.name) {
updatedTable = await renameColumn(
{ tableId, oldName: validated.columnName, newName: updates.name },
requestId
)
}
if (updates.type) {
updatedTable = await updateColumnType(
{ tableId, columnName: updates.name ?? validated.columnName, newType: updates.type },
requestId
)
}
if (updates.required !== undefined || updates.unique !== undefined) {
updatedTable = await updateColumnConstraints(
{
tableId,
columnName: updates.name ?? validated.columnName,
...(updates.required !== undefined ? { required: updates.required } : {}),
...(updates.unique !== undefined ? { unique: updates.unique } : {}),
},
requestId
)
}
if (!updatedTable) {
return NextResponse.json({ error: 'No updates specified' }, { status: 400 })
}
return NextResponse.json({
success: true,
data: {
columns: updatedTable.schema.columns.map(normalizeColumn),
},
})
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Invalid request data', details: error.errors },
{ status: 400 }
)
}
if (error instanceof Error) {
const msg = error.message
if (msg.includes('not found') || msg.includes('Table not found')) {
return NextResponse.json({ error: msg }, { status: 404 })
}
if (
msg.includes('already exists') ||
msg.includes('Cannot delete the last column') ||
msg.includes('Cannot set column') ||
msg.includes('Invalid column') ||
msg.includes('exceeds maximum') ||
msg.includes('incompatible') ||
msg.includes('duplicate')
) {
return NextResponse.json({ error: msg }, { status: 400 })
}
}
logger.error(`[${requestId}] Error updating column in table ${tableId}:`, error)
return NextResponse.json({ error: 'Failed to update column' }, { status: 500 })
}
}
/** DELETE /api/table/[tableId]/columns - Deletes a column from the table schema. */
export async function DELETE(request: NextRequest, { params }: ColumnsRouteParams) {
const requestId = generateRequestId()
const { tableId } = await params
try {
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success || !authResult.userId) {
logger.warn(`[${requestId}] Unauthorized column deletion attempt`)
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
const body = await request.json()
const validated = DeleteColumnSchema.parse(body)
const result = await checkAccess(tableId, authResult.userId, 'write')
if (!result.ok) return accessError(result, requestId, tableId)
const { table } = result
if (table.workspaceId !== validated.workspaceId) {
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
const updatedTable = await deleteColumn(
{ tableId, columnName: validated.columnName },
requestId
)
return NextResponse.json({
success: true,
data: {
columns: updatedTable.schema.columns.map(normalizeColumn),
},
})
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Invalid request data', details: error.errors },
{ status: 400 }
)
}
if (error instanceof Error) {
if (error.message.includes('not found') || error.message === 'Table not found') {
return NextResponse.json({ error: error.message }, { status: 404 })
}
if (error.message.includes('Cannot delete') || error.message.includes('last column')) {
return NextResponse.json({ error: error.message }, { status: 400 })
}
}
logger.error(`[${requestId}] Error deleting column from table ${tableId}:`, error)
return NextResponse.json({ error: 'Failed to delete column' }, { status: 500 })
}
}

View File

@@ -58,6 +58,7 @@ export async function GET(request: NextRequest, { params }: RowRouteParams) {
.select({
id: userTableRows.id,
data: userTableRows.data,
position: userTableRows.position,
createdAt: userTableRows.createdAt,
updatedAt: userTableRows.updatedAt,
})
@@ -83,6 +84,7 @@ export async function GET(request: NextRequest, { params }: RowRouteParams) {
row: {
id: row.id,
data: row.data,
position: row.position,
createdAt:
row.createdAt instanceof Date ? row.createdAt.toISOString() : String(row.createdAt),
updatedAt:
@@ -170,6 +172,7 @@ export async function PATCH(request: NextRequest, { params }: RowRouteParams) {
row: {
id: updatedRow.id,
data: updatedRow.data,
position: updatedRow.position,
createdAt:
updatedRow.createdAt instanceof Date
? updatedRow.createdAt.toISOString()

View File

@@ -145,6 +145,7 @@ async function handleBatchInsert(
rows: insertedRows.map((r) => ({
id: r.id,
data: r.data,
position: r.position,
createdAt: r.createdAt instanceof Date ? r.createdAt.toISOString() : r.createdAt,
updatedAt: r.updatedAt instanceof Date ? r.updatedAt.toISOString() : r.updatedAt,
})),
@@ -245,6 +246,7 @@ export async function POST(request: NextRequest, { params }: TableRowsRouteParam
row: {
id: row.id,
data: row.data,
position: row.position,
createdAt: row.createdAt instanceof Date ? row.createdAt.toISOString() : row.createdAt,
updatedAt: row.updatedAt instanceof Date ? row.updatedAt.toISOString() : row.updatedAt,
},
@@ -344,6 +346,7 @@ export async function GET(request: NextRequest, { params }: TableRowsRouteParams
.select({
id: userTableRows.id,
data: userTableRows.data,
position: userTableRows.position,
createdAt: userTableRows.createdAt,
updatedAt: userTableRows.updatedAt,
})
@@ -355,9 +358,11 @@ export async function GET(request: NextRequest, { params }: TableRowsRouteParams
const sortClause = buildSortClause(validated.sort, USER_TABLE_ROWS_SQL_NAME, schema.columns)
if (sortClause) {
query = query.orderBy(sortClause) as typeof query
} else {
query = query.orderBy(userTableRows.position) as typeof query
}
} else {
query = query.orderBy(userTableRows.createdAt) as typeof query
query = query.orderBy(userTableRows.position) as typeof query
}
const countQuery = db
@@ -379,6 +384,7 @@ export async function GET(request: NextRequest, { params }: TableRowsRouteParams
rows: rows.map((r) => ({
id: r.id,
data: r.data,
position: r.position,
createdAt: r.createdAt instanceof Date ? r.createdAt.toISOString() : String(r.createdAt),
updatedAt: r.updatedAt instanceof Date ? r.updatedAt.toISOString() : String(r.updatedAt),
})),

View File

@@ -1,7 +1,8 @@
import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server'
import { z } from 'zod'
import type { ColumnDefinition, TableDefinition } from '@/lib/table'
import { getTableById } from '@/lib/table'
import { COLUMN_TYPES, getTableById } from '@/lib/table'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('TableUtils')
@@ -154,6 +155,35 @@ export function serverErrorResponse(message = 'Internal server error') {
return errorResponse(message, 500)
}
const columnTypeEnum = z.enum(COLUMN_TYPES as unknown as [string, ...string[]])
export const CreateColumnSchema = z.object({
workspaceId: z.string().min(1, 'Workspace ID is required'),
column: z.object({
name: z.string().min(1, 'Column name is required'),
type: columnTypeEnum,
required: z.boolean().optional(),
unique: z.boolean().optional(),
position: z.number().int().min(0).optional(),
}),
})
export const UpdateColumnSchema = z.object({
workspaceId: z.string().min(1, 'Workspace ID is required'),
columnName: z.string().min(1, 'Column name is required'),
updates: z.object({
name: z.string().min(1).optional(),
type: columnTypeEnum.optional(),
required: z.boolean().optional(),
unique: z.boolean().optional(),
}),
})
export const DeleteColumnSchema = z.object({
workspaceId: z.string().min(1, 'Workspace ID is required'),
columnName: z.string().min(1, 'Column name is required'),
})
export function normalizeColumn(col: ColumnDefinition): ColumnDefinition {
return {
name: col.name,

View File

@@ -32,6 +32,7 @@ export async function checkRateLimit(
| 'table-detail'
| 'table-rows'
| 'table-row-detail'
| 'table-columns'
| 'files'
| 'file-detail'
| 'knowledge'

View File

@@ -0,0 +1,305 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { generateRequestId } from '@/lib/core/utils/request'
import {
addTableColumn,
deleteColumn,
renameColumn,
updateColumnConstraints,
updateColumnType,
} from '@/lib/table'
import {
accessError,
CreateColumnSchema,
checkAccess,
DeleteColumnSchema,
normalizeColumn,
UpdateColumnSchema,
} from '@/app/api/table/utils'
import {
checkRateLimit,
checkWorkspaceScope,
createRateLimitResponse,
} from '@/app/api/v1/middleware'
const logger = createLogger('V1TableColumnsAPI')
export const dynamic = 'force-dynamic'
export const revalidate = 0
interface ColumnsRouteParams {
params: Promise<{ tableId: string }>
}
/** POST /api/v1/tables/[tableId]/columns — Add a column to the table schema. */
export async function POST(request: NextRequest, { params }: ColumnsRouteParams) {
const requestId = generateRequestId()
const { tableId } = await params
try {
const rateLimit = await checkRateLimit(request, 'table-columns')
if (!rateLimit.allowed) {
return createRateLimitResponse(rateLimit)
}
const userId = rateLimit.userId!
let body: unknown
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Request body must be valid JSON' }, { status: 400 })
}
const validated = CreateColumnSchema.parse(body)
const scopeError = checkWorkspaceScope(rateLimit, validated.workspaceId)
if (scopeError) return scopeError
const result = await checkAccess(tableId, userId, 'write')
if (!result.ok) return accessError(result, requestId, tableId)
const { table } = result
if (table.workspaceId !== validated.workspaceId) {
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
const updatedTable = await addTableColumn(tableId, validated.column, requestId)
recordAudit({
workspaceId: validated.workspaceId,
actorId: userId,
action: AuditAction.TABLE_UPDATED,
resourceType: AuditResourceType.TABLE,
resourceId: tableId,
resourceName: table.name,
description: `Added column "${validated.column.name}" to table "${table.name}"`,
metadata: { column: validated.column },
request,
})
return NextResponse.json({
success: true,
data: {
columns: updatedTable.schema.columns.map(normalizeColumn),
},
})
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Validation error', details: error.errors },
{ status: 400 }
)
}
if (error instanceof Error) {
if (error.message.includes('already exists') || error.message.includes('maximum column')) {
return NextResponse.json({ error: error.message }, { status: 400 })
}
if (error.message === 'Table not found') {
return NextResponse.json({ error: error.message }, { status: 404 })
}
}
logger.error(`[${requestId}] Error adding column to table ${tableId}:`, error)
return NextResponse.json({ error: 'Failed to add column' }, { status: 500 })
}
}
/** PATCH /api/v1/tables/[tableId]/columns — Update a column (rename, type change, constraints). */
export async function PATCH(request: NextRequest, { params }: ColumnsRouteParams) {
const requestId = generateRequestId()
const { tableId } = await params
try {
const rateLimit = await checkRateLimit(request, 'table-columns')
if (!rateLimit.allowed) {
return createRateLimitResponse(rateLimit)
}
const userId = rateLimit.userId!
let body: unknown
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Request body must be valid JSON' }, { status: 400 })
}
const validated = UpdateColumnSchema.parse(body)
const scopeError = checkWorkspaceScope(rateLimit, validated.workspaceId)
if (scopeError) return scopeError
const result = await checkAccess(tableId, userId, 'write')
if (!result.ok) return accessError(result, requestId, tableId)
const { table } = result
if (table.workspaceId !== validated.workspaceId) {
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
const { updates } = validated
let updatedTable = null
if (updates.name) {
updatedTable = await renameColumn(
{ tableId, oldName: validated.columnName, newName: updates.name },
requestId
)
}
if (updates.type) {
updatedTable = await updateColumnType(
{ tableId, columnName: updates.name ?? validated.columnName, newType: updates.type },
requestId
)
}
if (updates.required !== undefined || updates.unique !== undefined) {
updatedTable = await updateColumnConstraints(
{
tableId,
columnName: updates.name ?? validated.columnName,
...(updates.required !== undefined ? { required: updates.required } : {}),
...(updates.unique !== undefined ? { unique: updates.unique } : {}),
},
requestId
)
}
if (!updatedTable) {
return NextResponse.json({ error: 'No updates specified' }, { status: 400 })
}
recordAudit({
workspaceId: validated.workspaceId,
actorId: userId,
action: AuditAction.TABLE_UPDATED,
resourceType: AuditResourceType.TABLE,
resourceId: tableId,
resourceName: table.name,
description: `Updated column "${validated.columnName}" in table "${table.name}"`,
metadata: { columnName: validated.columnName, updates },
request,
})
return NextResponse.json({
success: true,
data: {
columns: updatedTable.schema.columns.map(normalizeColumn),
},
})
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Validation error', details: error.errors },
{ status: 400 }
)
}
if (error instanceof Error) {
const msg = error.message
if (msg.includes('not found') || msg.includes('Table not found')) {
return NextResponse.json({ error: msg }, { status: 404 })
}
if (
msg.includes('already exists') ||
msg.includes('Cannot delete the last column') ||
msg.includes('Cannot set column') ||
msg.includes('Invalid column') ||
msg.includes('exceeds maximum') ||
msg.includes('incompatible') ||
msg.includes('duplicate')
) {
return NextResponse.json({ error: msg }, { status: 400 })
}
}
logger.error(`[${requestId}] Error updating column in table ${tableId}:`, error)
return NextResponse.json({ error: 'Failed to update column' }, { status: 500 })
}
}
/** DELETE /api/v1/tables/[tableId]/columns — Delete a column from the table schema. */
export async function DELETE(request: NextRequest, { params }: ColumnsRouteParams) {
const requestId = generateRequestId()
const { tableId } = await params
try {
const rateLimit = await checkRateLimit(request, 'table-columns')
if (!rateLimit.allowed) {
return createRateLimitResponse(rateLimit)
}
const userId = rateLimit.userId!
let body: unknown
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Request body must be valid JSON' }, { status: 400 })
}
const validated = DeleteColumnSchema.parse(body)
const scopeError = checkWorkspaceScope(rateLimit, validated.workspaceId)
if (scopeError) return scopeError
const result = await checkAccess(tableId, userId, 'write')
if (!result.ok) return accessError(result, requestId, tableId)
const { table } = result
if (table.workspaceId !== validated.workspaceId) {
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
const updatedTable = await deleteColumn(
{ tableId, columnName: validated.columnName },
requestId
)
recordAudit({
workspaceId: validated.workspaceId,
actorId: userId,
action: AuditAction.TABLE_UPDATED,
resourceType: AuditResourceType.TABLE,
resourceId: tableId,
resourceName: table.name,
description: `Deleted column "${validated.columnName}" from table "${table.name}"`,
metadata: { columnName: validated.columnName },
request,
})
return NextResponse.json({
success: true,
data: {
columns: updatedTable.schema.columns.map(normalizeColumn),
},
})
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Validation error', details: error.errors },
{ status: 400 }
)
}
if (error instanceof Error) {
if (error.message.includes('not found') || error.message === 'Table not found') {
return NextResponse.json({ error: error.message }, { status: 404 })
}
if (error.message.includes('Cannot delete') || error.message.includes('last column')) {
return NextResponse.json({ error: error.message }, { status: 400 })
}
}
logger.error(`[${requestId}] Error deleting column from table ${tableId}:`, error)
return NextResponse.json({ error: 'Failed to delete column' }, { status: 500 })
}
}

View File

@@ -64,6 +64,7 @@ export async function GET(request: NextRequest, { params }: RowRouteParams) {
.select({
id: userTableRows.id,
data: userTableRows.data,
position: userTableRows.position,
createdAt: userTableRows.createdAt,
updatedAt: userTableRows.updatedAt,
})
@@ -87,6 +88,7 @@ export async function GET(request: NextRequest, { params }: RowRouteParams) {
row: {
id: row.id,
data: row.data,
position: row.position,
createdAt:
row.createdAt instanceof Date ? row.createdAt.toISOString() : String(row.createdAt),
updatedAt:
@@ -173,6 +175,7 @@ export async function PATCH(request: NextRequest, { params }: RowRouteParams) {
row: {
id: updatedRow.id,
data: updatedRow.data,
position: updatedRow.position,
createdAt:
updatedRow.createdAt instanceof Date
? updatedRow.createdAt.toISOString()

View File

@@ -153,6 +153,7 @@ async function handleBatchInsert(
rows: insertedRows.map((r) => ({
id: r.id,
data: r.data,
position: r.position,
createdAt: r.createdAt instanceof Date ? r.createdAt.toISOString() : r.createdAt,
updatedAt: r.updatedAt instanceof Date ? r.updatedAt.toISOString() : r.updatedAt,
})),
@@ -245,6 +246,7 @@ export async function GET(request: NextRequest, { params }: TableRowsRouteParams
.select({
id: userTableRows.id,
data: userTableRows.data,
position: userTableRows.position,
createdAt: userTableRows.createdAt,
updatedAt: userTableRows.updatedAt,
})
@@ -256,9 +258,11 @@ export async function GET(request: NextRequest, { params }: TableRowsRouteParams
const sortClause = buildSortClause(validated.sort, USER_TABLE_ROWS_SQL_NAME, schema.columns)
if (sortClause) {
query = query.orderBy(sortClause) as typeof query
} else {
query = query.orderBy(userTableRows.position) as typeof query
}
} else {
query = query.orderBy(userTableRows.createdAt) as typeof query
query = query.orderBy(userTableRows.position) as typeof query
}
const countQuery = db
@@ -278,6 +282,7 @@ export async function GET(request: NextRequest, { params }: TableRowsRouteParams
rows: rows.map((r) => ({
id: r.id,
data: r.data,
position: r.position,
createdAt: r.createdAt instanceof Date ? r.createdAt.toISOString() : String(r.createdAt),
updatedAt: r.updatedAt instanceof Date ? r.updatedAt.toISOString() : String(r.updatedAt),
})),
@@ -372,6 +377,7 @@ export async function POST(request: NextRequest, { params }: TableRowsRouteParam
row: {
id: row.id,
data: row.data,
position: row.position,
createdAt: row.createdAt instanceof Date ? row.createdAt.toISOString() : row.createdAt,
updatedAt: row.updatedAt instanceof Date ? row.updatedAt.toISOString() : row.updatedAt,
},

View File

@@ -13,6 +13,7 @@ import {
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
Input,
Modal,
ModalBody,
ModalContent,
@@ -46,7 +47,9 @@ import type {
import {
useAddTableColumn,
useCreateTableRow,
useDeleteColumn,
useDeleteTable,
useUpdateColumn,
useUpdateTableRow,
} from '@/hooks/queries/tables'
import { useContextMenu, useRowSelection, useTableData } from '../../hooks'
@@ -149,6 +152,9 @@ export function Table() {
columnName: string
} | null>(null)
const [showDeleteTableConfirm, setShowDeleteTableConfirm] = useState(false)
const [renamingColumn, setRenamingColumn] = useState<string | null>(null)
const [renameValue, setRenameValue] = useState('')
const [deletingColumn, setDeletingColumn] = useState<string | null>(null)
const isDraggingRef = useRef(false)
@@ -170,6 +176,8 @@ export function Table() {
const updateRowMutation = useUpdateTableRow({ workspaceId, tableId })
const createRowMutation = useCreateTableRow({ workspaceId, tableId })
const addColumnMutation = useAddTableColumn({ workspaceId, tableId })
const updateColumnMutation = useUpdateColumn({ workspaceId, tableId })
const deleteColumnMutation = useDeleteColumn({ workspaceId, tableId })
const columns = useMemo(
() => tableData?.schema?.columns || EMPTY_COLUMNS,
@@ -565,7 +573,7 @@ export function Table() {
setEditingEmptyCell(null)
}, [])
const handleAddColumn = useCallback(() => {
const generateColumnName = useCallback(() => {
const existing = columnsRef.current.map((c) => c.name.toLowerCase())
let name = 'untitled'
let i = 2
@@ -573,19 +581,74 @@ export function Table() {
name = `untitled_${i}`
i++
}
addColumnMutation.mutate({ name, type: 'string' })
}, [addColumnMutation])
return name
}, [])
const handleRenameColumn = useCallback((_columnName: string) => {}, [])
const handleChangeType = useCallback((_columnName: string, _newType: string) => {}, [])
const handleInsertColumnLeft = useCallback((_columnName: string) => {}, [])
const handleInsertColumnRight = useCallback((_columnName: string) => {}, [])
const handleToggleUnique = useCallback((_columnName: string) => {}, [])
const handleToggleRequired = useCallback((_columnName: string) => {}, [])
const handleDeleteColumn = useCallback((_columnName: string) => {}, [])
const handleAddColumn = useCallback(() => {
addColumnMutation.mutate({ name: generateColumnName(), type: 'string' })
}, [generateColumnName])
const handleSortChange = useCallback((_column: string, _direction: SortDirection) => {}, [])
const handleSortClear = useCallback(() => {}, [])
const handleRenameColumn = useCallback((columnName: string) => {
setRenamingColumn(columnName)
setRenameValue(columnName)
}, [])
const handleRenameSubmit = useCallback(() => {
if (!renamingColumn || !renameValue.trim() || renameValue === renamingColumn) {
setRenamingColumn(null)
return
}
updateColumnMutation.mutate({ columnName: renamingColumn, updates: { name: renameValue.trim() } })
setRenamingColumn(null)
}, [renamingColumn, renameValue])
const handleChangeType = useCallback((columnName: string, newType: string) => {
updateColumnMutation.mutate({ columnName, updates: { type: newType } })
}, [])
const handleInsertColumnLeft = useCallback((columnName: string) => {
const index = columnsRef.current.findIndex((c) => c.name === columnName)
if (index === -1) return
addColumnMutation.mutate({ name: generateColumnName(), type: 'string', position: index })
}, [generateColumnName])
const handleInsertColumnRight = useCallback((columnName: string) => {
const index = columnsRef.current.findIndex((c) => c.name === columnName)
if (index === -1) return
addColumnMutation.mutate({ name: generateColumnName(), type: 'string', position: index + 1 })
}, [generateColumnName])
const handleToggleUnique = useCallback((columnName: string) => {
const column = columnsRef.current.find((c) => c.name === columnName)
if (!column) return
updateColumnMutation.mutate({ columnName, updates: { unique: !column.unique } })
}, [])
const handleToggleRequired = useCallback((columnName: string) => {
const column = columnsRef.current.find((c) => c.name === columnName)
if (!column) return
updateColumnMutation.mutate({ columnName, updates: { required: !column.required } })
}, [])
const handleDeleteColumn = useCallback((columnName: string) => {
setDeletingColumn(columnName)
}, [])
const handleDeleteColumnConfirm = useCallback(() => {
if (!deletingColumn) return
deleteColumnMutation.mutate(deletingColumn)
setDeletingColumn(null)
}, [deletingColumn])
const handleSortChange = useCallback((column: string, direction: SortDirection) => {
setQueryOptions((prev) => ({ ...prev, sort: { [column]: direction } }))
setCurrentPage(0)
}, [])
const handleSortClear = useCallback(() => {
setQueryOptions((prev) => ({ ...prev, sort: null }))
setCurrentPage(0)
}, [])
const handleFilterApply = useCallback((filter: Filter | null) => {
setQueryOptions((prev) => ({ ...prev, filter }))
@@ -602,16 +665,25 @@ export function Table() {
[columns]
)
const activeSortState = useMemo(() => {
if (!queryOptions.sort) return null
const entries = Object.entries(queryOptions.sort)
if (entries.length === 0) return null
const [column, direction] = entries[0]
return { column, direction }
}, [queryOptions.sort])
const sortConfig = useMemo<SortConfig>(
() => ({
options: columnOptions,
active: null,
active: activeSortState,
onSort: handleSortChange,
onClear: handleSortClear,
}),
[columnOptions, handleSortChange, handleSortClear]
[columnOptions, activeSortState, handleSortChange, handleSortClear]
)
if (!isLoadingTable && !tableData) {
return (
<div className='flex h-full items-center justify-center'>
@@ -852,6 +924,67 @@ export function Table() {
</ModalFooter>
</ModalContent>
</Modal>
<Modal
open={renamingColumn !== null}
onOpenChange={(open) => {
if (!open) setRenamingColumn(null)
}}
>
<ModalContent size='sm'>
<ModalHeader>Rename Column</ModalHeader>
<ModalBody>
<Input
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleRenameSubmit()
}}
placeholder='Column name'
autoFocus
/>
</ModalBody>
<ModalFooter>
<Button variant='default' onClick={() => setRenamingColumn(null)}>
Cancel
</Button>
<Button
variant='default'
onClick={handleRenameSubmit}
disabled={!renameValue.trim() || renameValue === renamingColumn}
>
Rename
</Button>
</ModalFooter>
</ModalContent>
</Modal>
<Modal
open={deletingColumn !== null}
onOpenChange={(open) => {
if (!open) setDeletingColumn(null)
}}
>
<ModalContent size='sm'>
<ModalHeader>Delete Column</ModalHeader>
<ModalBody>
<p className='text-[13px] text-[var(--text-secondary)]'>
Are you sure you want to delete{' '}
<span className='font-medium text-[var(--text-primary)]'>{deletingColumn}</span>?
This will remove all data in this column.{' '}
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
</p>
</ModalBody>
<ModalFooter>
<Button variant='default' onClick={() => setDeletingColumn(null)}>
Cancel
</Button>
<Button variant='destructive' onClick={handleDeleteColumnConfirm}>
Delete
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</div>
)
}

View File

@@ -118,14 +118,28 @@ async function fetchTableRows({
}
}
function invalidateTableData(
function invalidateRowData(queryClient: ReturnType<typeof useQueryClient>, tableId: string) {
queryClient.invalidateQueries({ queryKey: tableKeys.rowsRoot(tableId) })
}
function invalidateRowCount(
queryClient: ReturnType<typeof useQueryClient>,
workspaceId: string,
tableId: string
) {
queryClient.invalidateQueries({ queryKey: tableKeys.rowsRoot(tableId) })
queryClient.invalidateQueries({ queryKey: tableKeys.detail(tableId) })
queryClient.invalidateQueries({ queryKey: tableKeys.list(workspaceId) })
}
function invalidateTableSchema(
queryClient: ReturnType<typeof useQueryClient>,
workspaceId: string,
tableId: string
) {
queryClient.invalidateQueries({ queryKey: tableKeys.detail(tableId) })
queryClient.invalidateQueries({ queryKey: tableKeys.rowsRoot(tableId) })
queryClient.invalidateQueries({ queryKey: tableKeys.list(workspaceId) })
}
/**
@@ -223,7 +237,7 @@ export function useCreateTable(workspaceId: string) {
return res.json()
},
onSuccess: () => {
onSettled: () => {
queryClient.invalidateQueries({ queryKey: tableKeys.list(workspaceId) })
},
})
@@ -241,6 +255,7 @@ export function useAddTableColumn({ workspaceId, tableId }: RowMutationContext)
type: string
required?: boolean
unique?: boolean
position?: number
}) => {
const res = await fetch(`/api/table/${tableId}/columns`, {
method: 'POST',
@@ -256,8 +271,7 @@ export function useAddTableColumn({ workspaceId, tableId }: RowMutationContext)
return res.json()
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: tableKeys.list(workspaceId) })
queryClient.invalidateQueries({ queryKey: tableKeys.detail(tableId) })
invalidateTableSchema(queryClient, workspaceId, tableId)
},
})
}
@@ -284,7 +298,7 @@ export function useDeleteTable(workspaceId: string) {
return res.json()
},
onSuccess: () => {
onSettled: () => {
queryClient.invalidateQueries({ queryKey: tableKeys.list(workspaceId) })
},
})
@@ -311,8 +325,8 @@ export function useCreateTableRow({ workspaceId, tableId }: RowMutationContext)
return res.json()
},
onSuccess: () => {
invalidateTableData(queryClient, workspaceId, tableId)
onSettled: () => {
invalidateRowCount(queryClient, workspaceId, tableId)
},
})
}
@@ -371,7 +385,7 @@ export function useUpdateTableRow({ workspaceId, tableId }: RowMutationContext)
}
},
onSettled: () => {
invalidateTableData(queryClient, workspaceId, tableId)
invalidateRowData(queryClient, tableId)
},
})
}
@@ -397,8 +411,8 @@ export function useDeleteTableRow({ workspaceId, tableId }: RowMutationContext)
return res.json()
},
onSuccess: () => {
invalidateTableData(queryClient, workspaceId, tableId)
onSettled: () => {
invalidateRowCount(queryClient, workspaceId, tableId)
},
})
}
@@ -445,7 +459,71 @@ export function useDeleteTableRows({ workspaceId, tableId }: RowMutationContext)
return { deletedRowIds }
},
onSettled: () => {
invalidateTableData(queryClient, workspaceId, tableId)
invalidateRowCount(queryClient, workspaceId, tableId)
},
})
}
interface UpdateColumnParams {
columnName: string
updates: {
name?: string
type?: string
required?: boolean
unique?: boolean
}
}
/**
* Update a column (rename, type change, or constraint update).
*/
export function useUpdateColumn({ workspaceId, tableId }: RowMutationContext) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ columnName, updates }: UpdateColumnParams) => {
const res = await fetch(`/api/table/${tableId}/columns`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workspaceId, columnName, updates }),
})
if (!res.ok) {
const error = await res.json().catch(() => ({}))
throw new Error(error.error || 'Failed to update column')
}
return res.json()
},
onSettled: () => {
invalidateTableSchema(queryClient, workspaceId, tableId)
},
})
}
/**
* Delete a column from a table.
*/
export function useDeleteColumn({ workspaceId, tableId }: RowMutationContext) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (columnName: string) => {
const res = await fetch(`/api/table/${tableId}/columns`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workspaceId, columnName }),
})
if (!res.ok) {
const error = await res.json().catch(() => ({}))
throw new Error(error.error || 'Failed to delete column')
}
return res.json()
},
onSettled: () => {
invalidateTableSchema(queryClient, workspaceId, tableId)
},
})
}

View File

@@ -117,6 +117,7 @@ export const AuditAction = {
// Tables
TABLE_CREATED: 'table.created',
TABLE_UPDATED: 'table.updated',
TABLE_DELETED: 'table.deleted',
// Templates

View File

@@ -11,7 +11,7 @@ import { db } from '@sim/db'
import { userTableDefinitions, userTableRows } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, count, eq, sql } from 'drizzle-orm'
import { TABLE_LIMITS, USER_TABLE_ROWS_SQL_NAME } from './constants'
import { COLUMN_TYPES, NAME_PATTERN, TABLE_LIMITS, USER_TABLE_ROWS_SQL_NAME } from './constants'
import { buildFilterClause, buildSortClause } from './sql'
import type {
BatchInsertData,
@@ -21,13 +21,18 @@ import type {
BulkOperationResult,
BulkUpdateData,
CreateTableData,
DeleteColumnData,
InsertRowData,
QueryOptions,
QueryResult,
RenameColumnData,
RowData,
TableDefinition,
TableMetadata,
TableRow,
TableSchema,
UpdateColumnConstraintsData,
UpdateColumnTypeData,
UpdateRowData,
UpsertResult,
UpsertRowData,
@@ -65,6 +70,7 @@ export async function getTableById(tableId: string): Promise<TableDefinition | n
name: table.name,
description: table.description,
schema: table.schema as TableSchema,
metadata: (table.metadata as TableMetadata) ?? null,
rowCount: table.rowCount,
maxRows: table.maxRows,
workspaceId: table.workspaceId,
@@ -95,6 +101,7 @@ export async function listTables(workspaceId: string): Promise<TableDefinition[]
name: userTableDefinitions.name,
description: userTableDefinitions.description,
schema: userTableDefinitions.schema,
metadata: userTableDefinitions.metadata,
maxRows: userTableDefinitions.maxRows,
workspaceId: userTableDefinitions.workspaceId,
createdBy: userTableDefinitions.createdBy,
@@ -113,6 +120,7 @@ export async function listTables(workspaceId: string): Promise<TableDefinition[]
name: t.name,
description: t.description,
schema: t.schema as TableSchema,
metadata: (t.metadata as TableMetadata) ?? null,
rowCount: t.rowCount,
maxRows: t.maxRows,
workspaceId: t.workspaceId,
@@ -204,6 +212,7 @@ export async function createTable(
name: newTable.name,
description: newTable.description,
schema: newTable.schema as TableSchema,
metadata: null,
rowCount: 0,
maxRows: newTable.maxRows,
workspaceId: newTable.workspaceId,
@@ -224,7 +233,7 @@ export async function createTable(
*/
export async function addTableColumn(
tableId: string,
column: { name: string; type: string; required?: boolean; unique?: boolean },
column: { name: string; type: string; required?: boolean; unique?: boolean; position?: number },
requestId: string
): Promise<TableDefinition> {
const table = await getTableById(tableId)
@@ -232,6 +241,24 @@ export async function addTableColumn(
throw new Error('Table not found')
}
if (!NAME_PATTERN.test(column.name)) {
throw new Error(
`Invalid column name "${column.name}". Must start with a letter or underscore and contain only alphanumeric characters and underscores.`
)
}
if (column.name.length > TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH) {
throw new Error(
`Column name exceeds maximum length (${TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH} characters)`
)
}
if (!COLUMN_TYPES.includes(column.type as (typeof COLUMN_TYPES)[number])) {
throw new Error(
`Invalid column type "${column.type}". Must be one of: ${COLUMN_TYPES.join(', ')}`
)
}
const schema = table.schema
if (schema.columns.some((c) => c.name.toLowerCase() === column.name.toLowerCase())) {
throw new Error(`Column "${column.name}" already exists`)
@@ -250,10 +277,15 @@ export async function addTableColumn(
unique: column.unique ?? false,
}
const updatedSchema: TableSchema = {
columns: [...schema.columns, newColumn],
const columns = [...schema.columns]
if (column.position !== undefined && column.position >= 0 && column.position < columns.length) {
columns.splice(column.position, 0, newColumn)
} else {
columns.push(newColumn)
}
const updatedSchema: TableSchema = { columns }
const now = new Date()
await db
@@ -340,6 +372,13 @@ export async function insertRow(
throw new Error(`Table has reached maximum row limit (${table.maxRows})`)
}
const [{ maxPos }] = await trx
.select({
maxPos: sql<number>`coalesce(max(${userTableRows.position}), -1)`.mapWith(Number),
})
.from(userTableRows)
.where(eq(userTableRows.tableId, data.tableId))
return trx
.insert(userTableRows)
.values({
@@ -347,6 +386,7 @@ export async function insertRow(
tableId: data.tableId,
workspaceId: data.workspaceId,
data: data.data,
position: maxPos + 1,
createdAt: now,
updatedAt: now,
...(data.userId ? { createdBy: data.userId } : {}),
@@ -359,6 +399,7 @@ export async function insertRow(
return {
id: row.id,
data: row.data as RowData,
position: row.position,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
}
@@ -406,15 +447,6 @@ export async function batchInsertRows(
}
const now = new Date()
const rowsToInsert = data.rows.map((rowData) => ({
id: `row_${crypto.randomUUID().replace(/-/g, '')}`,
tableId: data.tableId,
workspaceId: data.workspaceId,
data: rowData,
createdAt: now,
updatedAt: now,
...(data.userId ? { createdBy: data.userId } : {}),
}))
// Atomic capacity check + insert inside a transaction.
// FOR UPDATE on the table definition row serializes concurrent inserts.
@@ -435,6 +467,24 @@ export async function batchInsertRows(
)
}
const [{ maxPos }] = await trx
.select({
maxPos: sql<number>`coalesce(max(${userTableRows.position}), -1)`.mapWith(Number),
})
.from(userTableRows)
.where(eq(userTableRows.tableId, data.tableId))
const rowsToInsert = data.rows.map((rowData, i) => ({
id: `row_${crypto.randomUUID().replace(/-/g, '')}`,
tableId: data.tableId,
workspaceId: data.workspaceId,
data: rowData,
position: maxPos + 1 + i,
createdAt: now,
updatedAt: now,
...(data.userId ? { createdBy: data.userId } : {}),
}))
return trx.insert(userTableRows).values(rowsToInsert).returning()
})
@@ -443,6 +493,7 @@ export async function batchInsertRows(
return insertedRows.map((r) => ({
id: r.id,
data: r.data as RowData,
position: r.position,
createdAt: r.createdAt,
updatedAt: r.updatedAt,
}))
@@ -568,6 +619,7 @@ export async function upsertRow(
row: {
id: updatedRow.id,
data: updatedRow.data as RowData,
position: updatedRow.position,
createdAt: updatedRow.createdAt,
updatedAt: updatedRow.updatedAt,
},
@@ -585,6 +637,13 @@ export async function upsertRow(
throw new Error(`Table row limit reached (${table.maxRows} rows max)`)
}
const [{ maxPos }] = await trx
.select({
maxPos: sql<number>`coalesce(max(${userTableRows.position}), -1)`.mapWith(Number),
})
.from(userTableRows)
.where(eq(userTableRows.tableId, data.tableId))
const [insertedRow] = await trx
.insert(userTableRows)
.values({
@@ -592,6 +651,7 @@ export async function upsertRow(
tableId: data.tableId,
workspaceId: data.workspaceId,
data: data.data,
position: maxPos + 1,
createdAt: now,
updatedAt: now,
...(data.userId ? { createdBy: data.userId } : {}),
@@ -602,6 +662,7 @@ export async function upsertRow(
row: {
id: insertedRow.id,
data: insertedRow.data as RowData,
position: insertedRow.position,
createdAt: insertedRow.createdAt,
updatedAt: insertedRow.updatedAt,
},
@@ -657,7 +718,7 @@ export async function queryRows(
const totalCount = Number(countResult[0].count)
// Build ORDER BY clause
// Build ORDER BY clause (default to position ASC for stable ordering)
let orderByClause
if (sort && Object.keys(sort).length > 0) {
orderByClause = buildSortClause(sort, tableName)
@@ -671,6 +732,8 @@ export async function queryRows(
if (orderByClause) {
query = query.orderBy(orderByClause) as typeof query
} else {
query = query.orderBy(userTableRows.position) as typeof query
}
const rows = await query.limit(limit).offset(offset)
@@ -683,6 +746,7 @@ export async function queryRows(
rows: rows.map((r) => ({
id: r.id,
data: r.data as RowData,
position: r.position,
createdAt: r.createdAt,
updatedAt: r.updatedAt,
})),
@@ -724,6 +788,7 @@ export async function getRowById(
return {
id: row.id,
data: row.data as RowData,
position: row.position,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
}
@@ -787,6 +852,7 @@ export async function updateRow(
return {
id: data.rowId,
data: data.data,
position: existingRow.position,
createdAt: existingRow.createdAt,
updatedAt: now,
}
@@ -1042,3 +1108,344 @@ export async function deleteRowsByIds(
missingRowIds,
}
}
/**
* Renames a column in a table's schema and updates all row data keys.
*
* @param data - Rename column data
* @param requestId - Request ID for logging
* @returns Updated table definition
* @throws Error if table not found, column not found, or new name conflicts
*/
export async function renameColumn(
data: RenameColumnData,
requestId: string
): Promise<TableDefinition> {
const table = await getTableById(data.tableId)
if (!table) {
throw new Error('Table not found')
}
if (!NAME_PATTERN.test(data.newName)) {
throw new Error(
`Invalid column name "${data.newName}". Column names must start with a letter or underscore, followed by alphanumeric characters or underscores.`
)
}
if (data.newName.length > TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH) {
throw new Error(
`Column name exceeds maximum length (${TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH} characters)`
)
}
const schema = table.schema
const columnIndex = schema.columns.findIndex(
(c) => c.name.toLowerCase() === data.oldName.toLowerCase()
)
if (columnIndex === -1) {
throw new Error(`Column "${data.oldName}" not found`)
}
if (
schema.columns.some(
(c, i) => i !== columnIndex && c.name.toLowerCase() === data.newName.toLowerCase()
)
) {
throw new Error(`Column "${data.newName}" already exists`)
}
const actualOldName = schema.columns[columnIndex].name
const updatedColumns = schema.columns.map((c, i) =>
i === columnIndex ? { ...c, name: data.newName } : c
)
const updatedSchema: TableSchema = { columns: updatedColumns }
const metadata = table.metadata as TableMetadata | null
let updatedMetadata = metadata
if (metadata?.columnWidths && actualOldName in metadata.columnWidths) {
const { [actualOldName]: width, ...rest } = metadata.columnWidths
updatedMetadata = { ...metadata, columnWidths: { ...rest, [data.newName]: width } }
}
const now = new Date()
await db.transaction(async (trx) => {
await trx
.update(userTableDefinitions)
.set({ schema: updatedSchema, metadata: updatedMetadata, updatedAt: now })
.where(eq(userTableDefinitions.id, data.tableId))
await trx.execute(
sql`UPDATE user_table_rows SET data = data - ${actualOldName} || jsonb_build_object(${data.newName}, data->${sql.raw(`'${actualOldName.replace(/'/g, "''")}'`)}) WHERE table_id = ${data.tableId} AND data ? ${actualOldName}`
)
})
logger.info(
`[${requestId}] Renamed column "${actualOldName}" to "${data.newName}" in table ${data.tableId}`
)
return { ...table, schema: updatedSchema, metadata: updatedMetadata, updatedAt: now }
}
/**
* Deletes a column from a table's schema and removes the key from all row data.
*
* @param data - Delete column data
* @param requestId - Request ID for logging
* @returns Updated table definition
* @throws Error if table not found, column not found, or it's the last column
*/
export async function deleteColumn(
data: DeleteColumnData,
requestId: string
): Promise<TableDefinition> {
const table = await getTableById(data.tableId)
if (!table) {
throw new Error('Table not found')
}
const schema = table.schema
const columnIndex = schema.columns.findIndex(
(c) => c.name.toLowerCase() === data.columnName.toLowerCase()
)
if (columnIndex === -1) {
throw new Error(`Column "${data.columnName}" not found`)
}
if (schema.columns.length <= 1) {
throw new Error('Cannot delete the last column in a table')
}
const actualName = schema.columns[columnIndex].name
const updatedSchema: TableSchema = {
columns: schema.columns.filter((_, i) => i !== columnIndex),
}
const metadata = table.metadata as TableMetadata | null
let updatedMetadata = metadata
if (metadata?.columnWidths && actualName in metadata.columnWidths) {
const { [actualName]: _, ...rest } = metadata.columnWidths
updatedMetadata = { ...metadata, columnWidths: rest }
}
const now = new Date()
await db.transaction(async (trx) => {
await trx
.update(userTableDefinitions)
.set({ schema: updatedSchema, metadata: updatedMetadata, updatedAt: now })
.where(eq(userTableDefinitions.id, data.tableId))
await trx.execute(
sql`UPDATE user_table_rows SET data = data - ${actualName} WHERE table_id = ${data.tableId} AND data ? ${actualName}`
)
})
logger.info(`[${requestId}] Deleted column "${actualName}" from table ${data.tableId}`)
return { ...table, schema: updatedSchema, metadata: updatedMetadata, updatedAt: now }
}
/**
* Changes the type of a column. Validates that existing data is compatible.
*
* @param data - Update column type data
* @param requestId - Request ID for logging
* @returns Updated table definition
* @throws Error if table not found, column not found, or existing data is incompatible
*/
export async function updateColumnType(
data: UpdateColumnTypeData,
requestId: string
): Promise<TableDefinition> {
const table = await getTableById(data.tableId)
if (!table) {
throw new Error('Table not found')
}
if (!(COLUMN_TYPES as readonly string[]).includes(data.newType)) {
throw new Error(
`Invalid column type "${data.newType}". Valid types: ${COLUMN_TYPES.join(', ')}`
)
}
const schema = table.schema
const columnIndex = schema.columns.findIndex(
(c) => c.name.toLowerCase() === data.columnName.toLowerCase()
)
if (columnIndex === -1) {
throw new Error(`Column "${data.columnName}" not found`)
}
const column = schema.columns[columnIndex]
if (column.type === data.newType) {
return table
}
const escapedName = column.name.replace(/'/g, "''")
// Validate existing data is compatible with the new type
const rows = await db
.select({ id: userTableRows.id, data: userTableRows.data })
.from(userTableRows)
.where(
and(
eq(userTableRows.tableId, data.tableId),
sql`${userTableRows.data} ? ${column.name}`,
sql`${userTableRows.data}->>${sql.raw(`'${escapedName}'`)} IS NOT NULL`
)
)
let incompatibleCount = 0
for (const row of rows) {
const rowData = row.data as RowData
const value = rowData[column.name]
if (value === null || value === undefined) continue
if (!isValueCompatibleWithType(value, data.newType)) {
incompatibleCount++
}
}
if (incompatibleCount > 0) {
throw new Error(
`Cannot change column "${column.name}" to type "${data.newType}": ${incompatibleCount} row(s) have incompatible values. Fix or remove the incompatible values first.`
)
}
const updatedColumns = schema.columns.map((c, i) =>
i === columnIndex ? { ...c, type: data.newType } : c
)
const updatedSchema: TableSchema = { columns: updatedColumns }
const now = new Date()
await db
.update(userTableDefinitions)
.set({ schema: updatedSchema, updatedAt: now })
.where(eq(userTableDefinitions.id, data.tableId))
logger.info(
`[${requestId}] Changed column "${column.name}" type from "${column.type}" to "${data.newType}" in table ${data.tableId}`
)
return { ...table, schema: updatedSchema, updatedAt: now }
}
/**
* Updates constraints (required, unique) on a column.
*
* @param data - Update column constraints data
* @param requestId - Request ID for logging
* @returns Updated table definition
* @throws Error if table not found, column not found, or existing data violates the constraint
*/
export async function updateColumnConstraints(
data: UpdateColumnConstraintsData,
requestId: string
): Promise<TableDefinition> {
const table = await getTableById(data.tableId)
if (!table) {
throw new Error('Table not found')
}
const schema = table.schema
const columnIndex = schema.columns.findIndex(
(c) => c.name.toLowerCase() === data.columnName.toLowerCase()
)
if (columnIndex === -1) {
throw new Error(`Column "${data.columnName}" not found`)
}
const column = schema.columns[columnIndex]
const escapedName = column.name.replace(/'/g, "''")
if (data.required === true && !column.required) {
const [result] = await db
.select({ count: count() })
.from(userTableRows)
.where(
and(
eq(userTableRows.tableId, data.tableId),
sql`(NOT (${userTableRows.data} ? ${column.name}) OR ${userTableRows.data}->>${sql.raw(`'${escapedName}'`)} IS NULL)`
)
)
if (result.count > 0) {
throw new Error(
`Cannot set column "${column.name}" as required: ${result.count} row(s) have null or missing values`
)
}
}
if (data.unique === true && !column.unique) {
const duplicates = await db.execute(
sql`SELECT ${userTableRows.data}->>${sql.raw(`'${escapedName}'`)} AS val, count(*) AS cnt FROM ${userTableRows} WHERE table_id = ${data.tableId} AND ${userTableRows.data} ? ${column.name} AND ${userTableRows.data}->>${sql.raw(`'${escapedName}'`)} IS NOT NULL GROUP BY val HAVING count(*) > 1 LIMIT 1`
)
if (duplicates.rows.length > 0) {
throw new Error(`Cannot set column "${column.name}" as unique: duplicate values exist`)
}
}
const updatedColumns = schema.columns.map((c, i) =>
i === columnIndex
? {
...c,
...(data.required !== undefined ? { required: data.required } : {}),
...(data.unique !== undefined ? { unique: data.unique } : {}),
}
: c
)
const updatedSchema: TableSchema = { columns: updatedColumns }
const now = new Date()
await db
.update(userTableDefinitions)
.set({ schema: updatedSchema, updatedAt: now })
.where(eq(userTableDefinitions.id, data.tableId))
logger.info(
`[${requestId}] Updated constraints for column "${column.name}" in table ${data.tableId}`
)
return { ...table, schema: updatedSchema, updatedAt: now }
}
/**
* Checks if a value is compatible with a target column type.
*/
function isValueCompatibleWithType(
value: unknown,
targetType: (typeof COLUMN_TYPES)[number]
): boolean {
if (value === null || value === undefined) return true
switch (targetType) {
case 'string':
return true
case 'number': {
if (typeof value === 'number') return Number.isFinite(value)
if (typeof value === 'string') {
const num = Number(value)
return Number.isFinite(num) && value.trim() !== ''
}
return false
}
case 'boolean': {
if (typeof value === 'boolean') return true
if (typeof value === 'string')
return ['true', 'false', '1', '0'].includes(value.toLowerCase())
if (typeof value === 'number') return value === 0 || value === 1
return false
}
case 'date': {
if (value instanceof Date) return !Number.isNaN(value.getTime())
if (typeof value === 'string') return !Number.isNaN(Date.parse(value))
return false
}
case 'json':
return true
default:
return false
}
}

View File

@@ -32,11 +32,17 @@ export interface TableSchema {
columns: ColumnDefinition[]
}
/** UI-only metadata stored alongside the table definition. */
export interface TableMetadata {
columnWidths?: Record<string, number>
}
export interface TableDefinition {
id: string
name: string
description?: string | null
schema: TableSchema
metadata?: TableMetadata | null
rowCount: number
maxRows: number
workspaceId: string
@@ -57,6 +63,7 @@ export interface TableSummary {
export interface TableRow {
id: string
data: RowData
position: number
createdAt: Date | string
updatedAt: Date | string
}
@@ -219,3 +226,27 @@ export interface BulkDeleteByIdsResult {
requestedCount: number
missingRowIds: string[]
}
export interface RenameColumnData {
tableId: string
oldName: string
newName: string
}
export interface UpdateColumnTypeData {
tableId: string
columnName: string
newType: (typeof COLUMN_TYPES)[number]
}
export interface UpdateColumnConstraintsData {
tableId: string
columnName: string
required?: boolean
unique?: boolean
}
export interface DeleteColumnData {
tableId: string
columnName: string
}

View File

@@ -87,7 +87,7 @@
"@radix-ui/react-checkbox": "^1.1.3",
"@radix-ui/react-collapsible": "^1.1.3",
"@radix-ui/react-dialog": "^1.1.5",
"@radix-ui/react-dropdown-menu": "^2.1.4",
"@radix-ui/react-dropdown-menu": "2.1.16",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-popover": "^1.1.5",
"@radix-ui/react-progress": "^1.1.2",

View File

@@ -0,0 +1,4 @@
ALTER TABLE "user_table_definitions" ADD COLUMN "metadata" jsonb;--> statement-breakpoint
ALTER TABLE "user_table_rows" ADD COLUMN "position" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
ALTER TABLE "workflow_schedule" ADD COLUMN "job_history" jsonb;--> statement-breakpoint
CREATE INDEX "user_table_rows_table_position_idx" ON "user_table_rows" USING btree ("table_id","position");

File diff suppressed because it is too large Load Diff

View File

@@ -1156,6 +1156,13 @@
"when": 1772842945935,
"tag": "0165_short_thunderbird",
"breakpoints": true
},
{
"idx": 166,
"version": "7",
"when": 1773042085248,
"tag": "0166_windy_lockjaw",
"breakpoints": true
}
]
}

View File

@@ -2519,6 +2519,12 @@ export const userTableDefinitions = pgTable(
* Stores the table schema definition. Example: { columns: [{ name: string, type: string, required: boolean }] }
*/
schema: jsonb('schema').notNull(),
/**
* @remarks
* Stores UI-specific metadata separate from the data schema.
* Example: { columnWidths: { name: 200, age: 100 } }
*/
metadata: jsonb('metadata'),
maxRows: integer('max_rows').notNull().default(10000),
rowCount: integer('row_count').notNull().default(0),
createdBy: text('created_by')
@@ -2551,6 +2557,7 @@ export const userTableRows = pgTable(
.notNull()
.references(() => workspace.id, { onDelete: 'cascade' }),
data: jsonb('data').notNull(),
position: integer('position').notNull().default(0),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
createdBy: text('created_by').references(() => user.id, { onDelete: 'set null' }),
@@ -2562,6 +2569,7 @@ export const userTableRows = pgTable(
table.workspaceId,
table.tableId
),
tablePositionIdx: index('user_table_rows_table_position_idx').on(table.tableId, table.position),
})
)

View File

@@ -78,6 +78,7 @@ export const auditMock = {
PERMISSION_GROUP_MEMBER_REMOVED: 'permission_group_member.removed',
SCHEDULE_UPDATED: 'schedule.updated',
TABLE_CREATED: 'table.created',
TABLE_UPDATED: 'table.updated',
TABLE_DELETED: 'table.deleted',
TEMPLATE_CREATED: 'template.created',
TEMPLATE_UPDATED: 'template.updated',