diff --git a/apps/sim/app/api/table/utils.ts b/apps/sim/app/api/table/utils.ts index 805d2b16206..442594b4233 100644 --- a/apps/sim/app/api/table/utils.ts +++ b/apps/sim/app/api/table/utils.ts @@ -320,7 +320,9 @@ export const CreateColumnSchema = createTableColumnBodySchema export const UpdateColumnSchema = updateTableColumnBodySchema export const DeleteColumnSchema = deleteTableColumnBodySchema -export function normalizeColumn(col: ColumnDefinition): ColumnDefinition { +export function normalizeColumn( + col: ColumnDefinition +): ColumnDefinition & { required: boolean; unique: boolean } { return { // Preserve the stable column id — it's the row-data storage key, so dropping // it makes clients fall back to `name` and miss id-keyed cell values. diff --git a/apps/sim/app/api/v2/tables/[tableId]/cancel-runs/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/cancel-runs/route.test.ts index fed9a372b17..17d013606d6 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/cancel-runs/route.test.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/cancel-runs/route.test.ts @@ -1,192 +1,149 @@ /** * @vitest-environment node - * - * Public v2 cancel-runs — stops workflow/enrichment cell runs, as opposed to - * `job/cancel`, which stops an import or delete. The predicate translates to - * storage keys before the cancel so an unknown field 400s rather than becoming - * a cancel that silently matches nothing. */ + import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockCheckRateLimit, - mockResolveWorkspaceScope, - mockCheckAccess, - mockCancelRuns, - mockPredicateToFilter, - mockSignalRowsChanged, - mockGateError, - TableQueryValidationError, -} = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceScope: vi.fn(), - mockCheckAccess: vi.fn(), - mockCancelRuns: vi.fn(), - mockPredicateToFilter: vi.fn(), - mockSignalRowsChanged: vi.fn(), - mockGateError: vi.fn(), - TableQueryValidationError: class TableQueryValidationError extends Error {}, -})) +const { mocks, MockTableRowsValidationError } = vi.hoisted(() => { + class MockTableRowsValidationError extends Error {} + return { + mocks: { + authenticate: vi.fn(), + preauthRate: vi.fn(), + operationRate: vi.fn(), + gate: vi.fn(), + cancelRuns: vi.fn(), + }, + MockTableRowsValidationError, + } +}) -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceScope: mockResolveWorkspaceScope, +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticate, + V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, })) - -vi.mock('@/app/api/table/utils', async (importOriginal) => ({ - ...(await importOriginal>()), - checkAccess: mockCheckAccess, +vi.mock('@/lib/core/rate-limiter', () => ({ + RateLimiter: class { + checkRateLimitDirect = mocks.preauthRate + checkRateLimitDirectOrThrow = mocks.operationRate + }, + getRateLimit: () => ({ maxTokens: 100, refillRate: 100, refillIntervalMs: 60_000 }), })) - -vi.mock('@/app/api/v2/tables/utils', async (importOriginal) => ({ - ...(await importOriginal>()), - v2BulkPredicateToFilter: mockPredicateToFilter, +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) +vi.mock('@/lib/table/application/rows', () => ({ + TableRowsValidationError: MockTableRowsValidationError, +})) +vi.mock('@/lib/table/application/runs', () => ({ + cancelTableRuns: { operation: { id: 'tables.runs.cancel' }, execute: mocks.cancelRuns }, })) - -vi.mock('@/lib/table/workflow-columns', () => ({ cancelWorkflowGroupRuns: mockCancelRuns })) -vi.mock('@/lib/table/events', () => ({ signalTableRowsChanged: mockSignalRowsChanged })) -vi.mock('@/lib/table/errors', () => ({ TableQueryValidationError })) -vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGateError })) import { POST } from '@/app/api/v2/tables/[tableId]/cancel-runs/route' -const TABLE = { - id: 'table-1', - workspaceId: 'ws-1', - schema: { columns: [{ id: 'col-1', name: 'status', type: 'string' }] }, +const WORKSPACE_ID = 'workspace-1' +const PRINCIPAL = { + kind: 'workspace_api_key' as const, + workspaceId: WORKSPACE_ID, + keyId: 'key-1', } - -const RATE_LIMIT_OK = { +const AUTH = { + principal: PRINCIPAL, + rolloutUserId: 'owner-1', + rateLimitSubjectIds: [`workspace:${WORKSPACE_ID}`], + rateLimitSubscription: null, + keyType: 'workspace' as const, +} +const RATE = { allowed: true, - userId: 'user-1', - keyType: 'workspace', - workspaceId: 'ws-1', - limit: 100, remaining: 99, resetAt: new Date('2026-01-01T01:00:00Z'), + retryAfterMs: 0, } -function callPost(body: unknown) { - const req = new NextRequest('http://localhost:3000/api/v2/tables/table-1/cancel-runs', { +function call(body: unknown) { + const request = new NextRequest('http://localhost/api/v2/tables/table-1/cancel-runs', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'content-type': 'application/json', 'x-api-key': 'secret' }, body: JSON.stringify(body), }) - return POST(req, { params: Promise.resolve({ tableId: 'table-1' }) }) + return { + request, + response: POST(request, { params: Promise.resolve({ tableId: 'table-1' }) }), + } } -beforeEach(() => { - vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceScope.mockResolvedValue(null) - mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) - mockCancelRuns.mockResolvedValue(4) - mockGateError.mockResolvedValue(null) -}) - describe('POST /api/v2/tables/[tableId]/cancel-runs', () => { - it('cancels every run under scope "all" and reports the count', async () => { - const res = await callPost({ workspaceId: 'ws-1', scope: 'all' }) - - expect(res.status).toBe(200) - expect((await res.json()).data).toEqual({ cancelled: 4 }) - expect(mockCancelRuns).toHaveBeenCalledWith('table-1', undefined, { - filter: undefined, - excludeRowIds: undefined, - }) - // Cancelling clears the affected cells, so open readers must refetch. - expect(mockSignalRowsChanged).toHaveBeenCalledWith('table-1') - }) - - it('scopes to a single row when asked', async () => { - const res = await callPost({ workspaceId: 'ws-1', scope: 'row', rowId: 'row-1' }) - - expect(res.status).toBe(200) - expect(mockCancelRuns).toHaveBeenCalledWith('table-1', 'row-1', expect.anything()) - }) - - it('translates a name-keyed predicate to the storage-keyed filter', async () => { - mockPredicateToFilter.mockReturnValue({ 'col-1': { $eq: 'active' } }) - const predicate = { all: [{ field: 'status', op: 'eq', value: 'active' }] } - - await callPost({ workspaceId: 'ws-1', scope: 'all', filter: predicate }) - - expect(mockPredicateToFilter).toHaveBeenCalledWith(predicate, TABLE.schema) - expect(mockCancelRuns).toHaveBeenCalledWith( - 'table-1', - undefined, - expect.objectContaining({ filter: { 'col-1': { $eq: 'active' } } }) - ) + beforeEach(() => { + vi.clearAllMocks() + mocks.authenticate.mockResolvedValue(AUTH) + mocks.preauthRate.mockResolvedValue(RATE) + mocks.operationRate.mockResolvedValue(RATE) + mocks.gate.mockResolvedValue(null) + mocks.cancelRuns.mockResolvedValue({ table: { id: 'table-1' }, cancelled: 4 }) }) - it('400s an unresolvable predicate field instead of cancelling nothing', async () => { - mockPredicateToFilter.mockImplementation(() => { - throw new TableQueryValidationError('Unknown column "nope"') - }) - - const res = await callPost({ - workspaceId: 'ws-1', + it('delegates a filtered all-scope cancellation and reports the authoritative count', async () => { + const predicate = { all: [{ field: 'status', op: 'eq', value: 'ready' }] } + const invocation = call({ + workspaceId: WORKSPACE_ID, scope: 'all', - filter: { all: [{ field: 'nope', op: 'eq', value: 1 }] }, + filter: predicate, + excludeRowIds: ['row-2'], }) - - expect(res.status).toBe(400) - expect(mockCancelRuns).not.toHaveBeenCalled() - }) - - it('400s scope "row" with no rowId', async () => { - const res = await callPost({ workspaceId: 'ws-1', scope: 'row' }) - - expect(res.status).toBe(400) - expect(mockCancelRuns).not.toHaveBeenCalled() - }) - - it('400s scope "row" combined with a filter', async () => { - const res = await callPost({ - workspaceId: 'ws-1', - scope: 'row', - rowId: 'row-1', - filter: { all: [{ field: 'status', op: 'eq', value: 'active' }] }, + const response = await invocation.response + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ data: { cancelled: 4 } }) + expect(mocks.cancelRuns).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { + scope: 'all', + tableId: 'table-1', + assertedWorkspaceId: WORKSPACE_ID, + predicate, + excludeRowIds: ['row-2'], + }, + request: invocation.request, }) - - expect(res.status).toBe(400) - expect(mockCancelRuns).not.toHaveBeenCalled() }) - it('403s a read-only member', async () => { - mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) - - const res = await callPost({ workspaceId: 'ws-1', scope: 'all' }) - - expect(res.status).toBe(403) - expect(mockCancelRuns).not.toHaveBeenCalled() + it('delegates one canonical row scope without select-all fields', async () => { + const invocation = call({ workspaceId: WORKSPACE_ID, scope: 'row', rowId: 'row-1' }) + const response = await invocation.response + + expect(response.status).toBe(200) + expect(mocks.cancelRuns).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { + scope: 'row', + tableId: 'table-1', + assertedWorkspaceId: WORKSPACE_ID, + rowId: 'row-1', + }, + request: invocation.request, + }) }) - it('404s with the gate off, before any work', async () => { - mockGateError.mockResolvedValue( - new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Not found' } }), { - status: 404, - }) - ) + it('preserves an authoritative zero-cancellation result', async () => { + mocks.cancelRuns.mockResolvedValue({ table: { id: 'table-1' }, cancelled: 0 }) - const res = await callPost({ workspaceId: 'ws-1', scope: 'all' }) + const response = await call({ workspaceId: WORKSPACE_ID, scope: 'all' }).response - expect(res.status).toBe(404) - expect(mockCheckAccess).not.toHaveBeenCalled() + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ data: { cancelled: 0 } }) }) - it('429s a throttled caller', async () => { - mockCheckRateLimit.mockResolvedValue({ - ...RATE_LIMIT_OK, - allowed: false, - remaining: 0, - retryAfterMs: 1000, - }) - - const res = await callPost({ workspaceId: 'ws-1', scope: 'all' }) + it('rejects an incomplete or contradictory row scope before delegation', async () => { + const missing = await call({ workspaceId: WORKSPACE_ID, scope: 'row' }).response + const contradictory = await call({ + workspaceId: WORKSPACE_ID, + scope: 'row', + rowId: 'row-1', + filter: { all: [{ field: 'status', op: 'eq', value: 'ready' }] }, + }).response - expect(res.status).toBe(429) - expect(mockCancelRuns).not.toHaveBeenCalled() + expect(missing.status).toBe(400) + expect(contradictory.status).toBe(400) + expect(mocks.cancelRuns).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/app/api/v2/tables/[tableId]/cancel-runs/route.ts b/apps/sim/app/api/v2/tables/[tableId]/cancel-runs/route.ts index 1ef1f1f6e09..696b81c975e 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/cancel-runs/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/cancel-runs/route.ts @@ -1,81 +1,39 @@ import { createLogger } from '@sim/logger' import { v2CancelTableRunsContract } from '@/lib/api/contracts/v2/tables' -import { isZodError } from '@/lib/api/server' -import type { Filter, TableSchema } from '@/lib/table' -import { TableQueryValidationError } from '@/lib/table/errors' -import { signalTableRowsChanged } from '@/lib/table/events' -import { cancelWorkflowGroupRuns } from '@/lib/table/workflow-columns' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { checkAccess } from '@/app/api/table/utils' -import { resolveWorkspaceScope } from '@/app/api/v1/middleware' -import { - v2Data, - v2Error, - v2ValidationError, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' -import { v2BulkPredicateToFilter, v2TableAccessError } from '@/app/api/v2/tables/utils' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2TableRowsErrorPolicy } from '@/lib/table/api/row-route-policies' +import { tableOperations } from '@/lib/table/application/operations' +import { cancelTableRuns } from '@/lib/table/application/runs' const logger = createLogger('V2TableCancelRunsAPI') export const dynamic = 'force-dynamic' export const revalidate = 0 -/** - * POST /api/v2/tables/[tableId]/cancel-runs — Stop in-flight cell runs. - * - * The counterpart to `POST /columns/run`, and distinct from - * `POST /job/cancel`, which stops an import or delete. `scope: 'all'` cancels - * every running and pending cell (optionally narrowed by `filter`); `row` - * cancels one row's cells. - */ -export const POST = withPublicApiRouteHandler({ +export const POST = defineV2JsonRoute({ contract: v2CancelTableRunsContract, - rateLimitEndpoint: 'table-enrichment', - handler: async ({ input, auth: { requestId, userId, rateLimit } }) => { - try { - const { tableId } = input.params - const { workspaceId, scope, rowId, filter, excludeRowIds } = input.body - - const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - - const access = await checkAccess(tableId, userId, 'write') - if (!access.ok) return v2TableAccessError(access) - - if (access.table.workspaceId !== workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } - - // The public predicate is column-NAME keyed; the runners compile the - // storage-keyed legacy filter. Translating up front makes an unknown field - // a 400 rather than a cancel that silently matches nothing. - let legacyFilter: Filter | undefined - if (filter) { - legacyFilter = v2BulkPredicateToFilter(filter, access.table.schema as TableSchema) - } - - const cancelled = await cancelWorkflowGroupRuns( - tableId, - scope === 'row' ? rowId : undefined, - { - filter: legacyFilter, - excludeRowIds, + operation: tableOperations.cancelRuns, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableRowsErrorPolicy, + mapInput: ({ params, body }) => + body.scope === 'row' + ? { + scope: 'row' as const, + tableId: params.tableId, + assertedWorkspaceId: body.workspaceId, + rowId: body.rowId!, } - ) - - // Cancelling clears the affected rows' exec state, so open readers must - // refetch to pick up the cleared cells. - signalTableRowsChanged(tableId) - - logger.info(`[${requestId}] Cancelled table runs`, { tableId, scope, rowId, cancelled }) - - return v2Data({ cancelled }, { rateLimit }) - } catch (error) { - if (isZodError(error)) return v2ValidationError(error) - if (error instanceof TableQueryValidationError) return v2Error('BAD_REQUEST', error.message) - - throw error - } + : { + scope: 'all' as const, + tableId: params.tableId, + assertedWorkspaceId: body.workspaceId, + predicate: body.filter, + excludeRowIds: body.excludeRowIds, + }, + useCase: cancelTableRuns, + present: ({ table, cancelled }) => { + logger.info('Cancelled table runs', { tableId: table.id, cancelled }) + return { data: { cancelled } } }, }) diff --git a/apps/sim/app/api/v2/tables/[tableId]/columns/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/columns/route.test.ts index a7d6235dca0..4ca1f73bf67 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/columns/route.test.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/columns/route.test.ts @@ -1,105 +1,136 @@ /** * @vitest-environment node - * - * v2 column update wiring: the route authenticates, scopes, delegates to the - * orchestration function, and maps its failure classes onto the v2 envelope. - * The guards themselves are covered in lib/table/orchestration/columns.test.ts. */ + import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockCheckRateLimit, mockResolveWorkspaceScope, mockCheckAccess, mockPerformUpdate } = - vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceScope: vi.fn(), - mockCheckAccess: vi.fn(), - mockPerformUpdate: vi.fn(), - })) - -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceScope: mockResolveWorkspaceScope, +const mocks = vi.hoisted(() => ({ + authenticate: vi.fn(), + preauthRate: vi.fn(), + operationRate: vi.fn(), + gate: vi.fn(), + add: vi.fn(), + update: vi.fn(), + remove: vi.fn(), })) -vi.mock('@/app/api/table/utils', () => ({ - checkAccess: mockCheckAccess, - normalizeColumn: (col: Record) => col, +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticate, + V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, })) - -vi.mock('@/lib/table', () => ({ addTableColumn: vi.fn(), deleteColumn: vi.fn() })) - -vi.mock('@/lib/table/orchestration', () => ({ - performUpdateTableColumn: mockPerformUpdate, +vi.mock('@/lib/core/rate-limiter', () => ({ + RateLimiter: class { + checkRateLimitDirect = mocks.preauthRate + checkRateLimitDirectOrThrow = mocks.operationRate + }, + getRateLimit: () => ({ maxTokens: 100, refillRate: 100, refillIntervalMs: 60_000 }), })) - -vi.mock('@/app/api/v2/lib/gate', () => ({ - v2ApiGateError: vi.fn().mockResolvedValue(null), +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) +vi.mock('@/lib/table/application/columns', () => ({ + addTableColumnUseCase: { operation: { id: 'tables.columns.add' }, execute: mocks.add }, + updateTableColumnUseCase: { operation: { id: 'tables.columns.update' }, execute: mocks.update }, + deleteTableColumnUseCase: { operation: { id: 'tables.columns.delete' }, execute: mocks.remove }, })) -import { PATCH } from '@/app/api/v2/tables/[tableId]/columns/route' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { DELETE, PATCH, POST } from '@/app/api/v2/tables/[tableId]/columns/route' -const COLUMN = { id: 'col-1', name: 'Status', type: 'text' } -const TABLE = { id: 'table-1', name: 'Tasks', workspaceId: 'ws-1', schema: { columns: [COLUMN] } } +const WORKSPACE_ID = 'workspace-1' +const principal = { + kind: 'workspace_api_key' as const, + workspaceId: WORKSPACE_ID, + keyId: 'key-1', +} +const auth = { + principal, + rolloutUserId: 'owner-1', + rateLimitSubjectIds: [`workspace:${WORKSPACE_ID}`], + rateLimitSubscription: null, + keyType: 'workspace' as const, +} +const rate = { + allowed: true, + remaining: 99, + resetAt: new Date('2026-01-01T01:00:00.000Z'), + retryAfterMs: 0, +} +const table = { + id: 'table-1', + name: 'Contacts', + schema: { + columns: [ + { id: 'col-1', name: 'Name', type: 'string' as const, required: false, unique: false }, + ], + }, +} +const context = { params: Promise.resolve({ tableId: 'table-1' }) } -function patch(updates: Record = { name: 'State' }) { - const req = new NextRequest('http://localhost:3000/api/v2/tables/table-1/columns', { - method: 'PATCH', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ workspaceId: 'ws-1', columnName: 'Status', updates }), +function request(method: 'POST' | 'PATCH' | 'DELETE', body: unknown) { + return new NextRequest('http://localhost:3000/api/v2/tables/table-1/columns', { + method, + headers: { 'content-type': 'application/json', 'x-api-key': 'secret' }, + body: JSON.stringify(body), }) - return PATCH(req, { params: Promise.resolve({ tableId: 'table-1' }) }) } -describe('PATCH /api/v2/tables/[tableId]/columns', () => { +describe('/api/v2/tables/[tableId]/columns', () => { beforeEach(() => { vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue({ - allowed: true, - userId: 'user-1', - keyType: 'workspace', - workspaceId: 'ws-1', - limit: 100, - remaining: 99, - resetAt: new Date('2026-01-01T01:00:00Z'), - }) - mockResolveWorkspaceScope.mockResolvedValue(null) - mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) - mockPerformUpdate.mockResolvedValue({ success: true, table: TABLE }) + mocks.authenticate.mockResolvedValue(auth) + mocks.preauthRate.mockResolvedValue(rate) + mocks.operationRate.mockResolvedValue(rate) + mocks.gate.mockResolvedValue(null) + mocks.add.mockResolvedValue({ table }) + mocks.update.mockResolvedValue({ table, changed: false }) + mocks.remove.mockResolvedValue({ table }) }) - it('delegates to the orchestration function with the resolved table and actor', async () => { - const res = await patch() - - expect(res.status).toBe(200) - expect((await res.json()).data).toEqual({ columns: [COLUMN] }) - expect(mockPerformUpdate).toHaveBeenCalledWith( - expect.objectContaining({ table: TABLE, columnName: 'Status', userId: 'user-1' }) - ) + it('delegates column creation with canonical path and body inputs', async () => { + const req = request('POST', { + workspaceId: WORKSPACE_ID, + column: { name: 'Name', type: 'string' }, + }) + const response = await POST(req, context) + + expect(response.status).toBe(200) + expect((await response.json()).data.columns).toEqual([ + { id: 'col-1', name: 'Name', type: 'string', required: false, unique: false }, + ]) + expect(mocks.add).toHaveBeenCalledWith({ + principal, + input: { + tableId: 'table-1', + workspaceId: WORKSPACE_ID, + column: { name: 'Name', type: 'string' }, + }, + request: req, + }) }) - it.each([ - ['validation', 400, 'BAD_REQUEST'], - ['not_found', 404, 'NOT_FOUND'], - ['locked', 423, 'LOCKED'], - ])('maps a %s failure to %i', async (errorCode, status, code) => { - mockPerformUpdate.mockResolvedValue({ success: false, errorCode, error: 'nope' }) + it('maps typed application validation failures without inspecting messages', async () => { + mocks.update.mockRejectedValueOnce(new OrchestrationError('validation', 'Invalid column')) - const res = await patch() + const response = await PATCH( + request('PATCH', { + workspaceId: WORKSPACE_ID, + columnName: 'Name', + updates: { name: 'Renamed' }, + }), + context + ) - expect(res.status).toBe(status) - expect((await res.json()).error.code).toBe(code) + expect(response.status).toBe(400) + expect((await response.json()).error.message).toBe('Invalid column') }) - it('does not leak an internal failure message', async () => { - mockPerformUpdate.mockResolvedValue({ - success: false, - errorCode: 'internal', - error: 'connection string leaked', - }) - - const res = await patch() + it('delegates deletion and returns the authoritative surviving schema', async () => { + const response = await DELETE( + request('DELETE', { workspaceId: WORKSPACE_ID, columnName: 'Other' }), + context + ) - expect(res.status).toBe(500) - expect(await res.text()).not.toContain('connection string') + expect(response.status).toBe(200) + expect(mocks.remove).toHaveBeenCalledOnce() }) }) diff --git a/apps/sim/app/api/v2/tables/[tableId]/columns/route.ts b/apps/sim/app/api/v2/tables/[tableId]/columns/route.ts index 1c6d1092e17..38127237a15 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/columns/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/columns/route.ts @@ -1,158 +1,55 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { v2AddTableColumnContract, v2DeleteTableColumnContract, v2UpdateTableColumnContract, } from '@/lib/api/contracts/v2/tables' -import { isZodError } from '@/lib/api/server' -import { addTableColumn, deleteColumn } from '@/lib/table' -import { performUpdateTableColumn } from '@/lib/table/orchestration' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { checkAccess, normalizeColumn } from '@/app/api/table/utils' -import { resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import type { TableDefinition } from '@/lib/table' +import { v2TableErrorPolicies } from '@/lib/table/api' import { - v2CaughtOrchestrationError, - v2Data, - v2Error, - v2ValidationError, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' -import { v2TableAccessError, v2TableOrchestrationError } from '@/app/api/v2/tables/utils' + addTableColumnUseCase, + deleteTableColumnUseCase, + updateTableColumnUseCase, +} from '@/lib/table/application/columns' +import { tableOperations } from '@/lib/table/application/operations' +import { normalizeColumn } from '@/app/api/table/utils' export const dynamic = 'force-dynamic' export const revalidate = 0 -/** POST /api/v2/tables/[tableId]/columns — Add a column to the table schema. */ -export const POST = withPublicApiRouteHandler({ - contract: v2AddTableColumnContract, - rateLimitEndpoint: 'table-columns', - handler: async ({ request, input, auth: { requestId, userId, rateLimit } }) => { - try { - const { tableId } = input.params - const validated = input.body - - const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - - const result = await checkAccess(tableId, userId, 'write') - if (!result.ok) return v2TableAccessError(result) - - const { table } = result - if (table.workspaceId !== validated.workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } - - 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, - }) +function presentColumns(result: { table: TableDefinition }) { + return { data: { columns: result.table.schema.columns.map(normalizeColumn) } } +} - return v2Data({ columns: updatedTable.schema.columns.map(normalizeColumn) }, { rateLimit }) - } catch (error) { - if (isZodError(error)) return v2ValidationError(error) - - const classified = v2CaughtOrchestrationError(error) - if (classified) return classified - - throw error - } - }, +export const POST = defineV2JsonRoute({ + contract: v2AddTableColumnContract, + operation: tableOperations.addColumn, + useCase: addTableColumnUseCase, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableErrorPolicies.default, + mapInput: ({ params, body }) => ({ tableId: params.tableId, ...body }), + present: presentColumns, }) -/** PATCH /api/v2/tables/[tableId]/columns — Update a column (rename, type change, constraints). */ -export const PATCH = withPublicApiRouteHandler({ +export const PATCH = defineV2JsonRoute({ contract: v2UpdateTableColumnContract, - rateLimitEndpoint: 'table-columns', - handler: async ({ request, input, auth: { requestId, userId, rateLimit } }) => { - try { - const { tableId } = input.params - const validated = input.body - - const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - - const result = await checkAccess(tableId, userId, 'write') - if (!result.ok) return v2TableAccessError(result) - - const { table } = result - if (table.workspaceId !== validated.workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } - - const outcome = await performUpdateTableColumn({ - table, - columnName: validated.columnName, - userId, - updates: validated.updates, - requestId, - request, - }) - if (!outcome.success || !outcome.table) { - return v2TableOrchestrationError(outcome, 'Failed to update column') - } - - return v2Data({ columns: outcome.table.schema.columns.map(normalizeColumn) }, { rateLimit }) - } catch (error) { - if (isZodError(error)) return v2ValidationError(error) - throw error - } - }, + operation: tableOperations.updateColumn, + useCase: updateTableColumnUseCase, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableErrorPolicies.default, + mapInput: ({ params, body }) => ({ tableId: params.tableId, ...body }), + present: presentColumns, }) -/** DELETE /api/v2/tables/[tableId]/columns — Delete a column from the table schema. */ -export const DELETE = withPublicApiRouteHandler({ +export const DELETE = defineV2JsonRoute({ contract: v2DeleteTableColumnContract, - rateLimitEndpoint: 'table-columns', - handler: async ({ request, input, auth: { requestId, userId, rateLimit } }) => { - try { - const { tableId } = input.params - const validated = input.body - - const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - - const result = await checkAccess(tableId, userId, 'write') - if (!result.ok) return v2TableAccessError(result) - - const { table } = result - if (table.workspaceId !== validated.workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } - - 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 v2Data({ columns: updatedTable.schema.columns.map(normalizeColumn) }, { rateLimit }) - } catch (error) { - if (isZodError(error)) return v2ValidationError(error) - - const classified = v2CaughtOrchestrationError(error) - if (classified) return classified - - throw error - } - }, + operation: tableOperations.deleteColumn, + useCase: deleteTableColumnUseCase, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableErrorPolicies.default, + mapInput: ({ params, body }) => ({ tableId: params.tableId, ...body }), + present: presentColumns, }) diff --git a/apps/sim/app/api/v2/tables/[tableId]/columns/run/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/columns/run/route.test.ts index e3dc1b23c0b..83b77f98923 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/columns/run/route.test.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/columns/run/route.test.ts @@ -1,195 +1,146 @@ /** * @vitest-environment node - * - * Public v2 column run. The public predicate is column-NAME keyed and the - * dispatcher compiles a storage-keyed legacy filter, so the route translates - * before dispatching — an unknown field must 400 here rather than becoming a - * run that silently matches nothing. */ + import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockCheckRateLimit, - mockResolveWorkspaceScope, - mockCheckAccess, - mockRunWorkflowColumn, - mockPredicateToFilter, - mockSignalRowsChanged, - mockGateError, - TableQueryValidationError, -} = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceScope: vi.fn(), - mockCheckAccess: vi.fn(), - mockRunWorkflowColumn: vi.fn(), - mockPredicateToFilter: vi.fn(), - mockSignalRowsChanged: vi.fn(), - mockGateError: vi.fn(), - TableQueryValidationError: class TableQueryValidationError extends Error {}, -})) +const { mocks, MockTableRowsValidationError } = vi.hoisted(() => { + class MockTableRowsValidationError extends Error {} + return { + mocks: { + authenticate: vi.fn(), + preauthRate: vi.fn(), + operationRate: vi.fn(), + gate: vi.fn(), + startRun: vi.fn(), + }, + MockTableRowsValidationError, + } +}) -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceScope: mockResolveWorkspaceScope, +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticate, + V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, })) - -vi.mock('@/app/api/table/utils', () => ({ - checkAccess: mockCheckAccess, - normalizeColumn: (col: Record) => col, - rootErrorMessage: (error: unknown) => String(error), - rowWriteErrorResponse: () => null, +vi.mock('@/lib/core/rate-limiter', () => ({ + RateLimiter: class { + checkRateLimitDirect = mocks.preauthRate + checkRateLimitDirectOrThrow = mocks.operationRate + }, + getRateLimit: () => ({ maxTokens: 100, refillRate: 100, refillIntervalMs: 60_000 }), })) - -vi.mock('@/app/api/v2/tables/utils', async (importOriginal) => ({ - ...(await importOriginal>()), - v2BulkPredicateToFilter: mockPredicateToFilter, +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) +vi.mock('@/lib/table/application/rows', () => ({ + TableRowsValidationError: MockTableRowsValidationError, +})) +vi.mock('@/lib/table/application/runs', () => ({ + startTableRun: { operation: { id: 'tables.runs.start' }, execute: mocks.startRun }, })) - -vi.mock('@/lib/table/workflow-columns', () => ({ runWorkflowColumn: mockRunWorkflowColumn })) -vi.mock('@/lib/table/events', () => ({ signalTableRowsChanged: mockSignalRowsChanged })) -vi.mock('@/lib/table/errors', () => ({ TableQueryValidationError })) -vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGateError })) import { POST } from '@/app/api/v2/tables/[tableId]/columns/run/route' -const TABLE = { - id: 'table-1', - workspaceId: 'ws-1', - schema: { columns: [{ id: 'col-1', name: 'status', type: 'string' }] }, +const WORKSPACE_ID = 'workspace-1' +const PRINCIPAL = { + kind: 'workspace_api_key' as const, + workspaceId: WORKSPACE_ID, + keyId: 'key-1', } - -const RATE_LIMIT_OK = { +const AUTH = { + principal: PRINCIPAL, + rolloutUserId: 'owner-1', + rateLimitSubjectIds: [`workspace:${WORKSPACE_ID}`], + rateLimitSubscription: null, + keyType: 'workspace' as const, +} +const RATE = { allowed: true, - userId: 'user-1', - keyType: 'workspace', - workspaceId: 'ws-1', - limit: 100, remaining: 99, resetAt: new Date('2026-01-01T01:00:00Z'), + retryAfterMs: 0, } -function callPost(body: unknown) { - const req = new NextRequest('http://localhost:3000/api/v2/tables/table-1/columns/run', { +function call(body: unknown) { + const request = new NextRequest('http://localhost/api/v2/tables/table-1/columns/run', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'content-type': 'application/json', 'x-api-key': 'secret' }, body: JSON.stringify(body), }) - return POST(req, { params: Promise.resolve({ tableId: 'table-1' }) }) + return { + request, + response: POST(request, { params: Promise.resolve({ tableId: 'table-1' }) }), + } } describe('POST /api/v2/tables/[tableId]/columns/run', () => { beforeEach(() => { vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceScope.mockResolvedValue(null) - mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) - mockRunWorkflowColumn.mockResolvedValue({ dispatchId: 'dispatch-1' }) - mockGateError.mockResolvedValue(null) + mocks.authenticate.mockResolvedValue(AUTH) + mocks.preauthRate.mockResolvedValue(RATE) + mocks.operationRate.mockResolvedValue(RATE) + mocks.gate.mockResolvedValue(null) + mocks.startRun.mockResolvedValue({ table: { id: 'table-1' }, dispatchId: 'dispatch-1' }) }) - it('dispatches the run and returns the dispatch id', async () => { - const res = await callPost({ workspaceId: 'ws-1', groupIds: ['group-1'], rowIds: ['row-1'] }) - - expect(res.status).toBe(200) - expect((await res.json()).data).toEqual({ dispatchId: 'dispatch-1' }) - expect(mockRunWorkflowColumn).toHaveBeenCalledWith( - expect.objectContaining({ + it('delegates the bounded run selection and presents the dispatch id', async () => { + const predicate = { all: [{ field: 'status', op: 'eq', value: 'ready' }] } + const invocation = call({ + workspaceId: WORKSPACE_ID, + groupIds: ['group-1'], + runMode: 'incomplete', + filter: predicate, + excludeRowIds: ['row-2'], + limit: { type: 'rows', max: 25 }, + }) + const response = await invocation.response + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ data: { dispatchId: 'dispatch-1' } }) + expect(mocks.startRun).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { + kind: 'selection', tableId: 'table-1', - workspaceId: 'ws-1', + assertedWorkspaceId: WORKSPACE_ID, groupIds: ['group-1'], - rowIds: ['row-1'], - mode: 'all', - filter: undefined, - triggeredByUserId: 'user-1', - }) - ) - // The bulk clear is a row change even when the dispatch is a no-op. - expect(mockSignalRowsChanged).toHaveBeenCalledWith('table-1') + mode: 'incomplete', + rowIds: undefined, + predicate, + excludeRowIds: ['row-2'], + limit: { type: 'rows', max: 25 }, + }, + request: invocation.request, + }) }) - it('translates a name-keyed predicate to the storage-keyed filter the dispatcher walks', async () => { - mockPredicateToFilter.mockReturnValue({ 'col-1': { $eq: 'active' } }) - const predicate = { all: [{ field: 'status', op: 'eq', value: 'active' }] } + it('preserves an authoritative null dispatch as a no-op', async () => { + mocks.startRun.mockResolvedValue({ table: { id: 'table-1' }, dispatchId: null }) - const res = await callPost({ workspaceId: 'ws-1', groupIds: ['group-1'], filter: predicate }) - - expect(res.status).toBe(200) - expect(mockPredicateToFilter).toHaveBeenCalledWith(predicate, TABLE.schema) - expect(mockRunWorkflowColumn).toHaveBeenCalledWith( - expect.objectContaining({ filter: { 'col-1': { $eq: 'active' } } }) - ) - }) - - it('400s an unresolvable predicate field instead of dispatching a no-match run', async () => { - mockPredicateToFilter.mockImplementation(() => { - throw new TableQueryValidationError('Unknown column "nope"') - }) - - const res = await callPost({ - workspaceId: 'ws-1', + const response = await call({ + workspaceId: WORKSPACE_ID, groupIds: ['group-1'], - filter: { all: [{ field: 'nope', op: 'eq', value: 1 }] }, - }) + }).response - expect(res.status).toBe(400) - expect((await res.json()).error.message).toBe('Unknown column "nope"') - expect(mockRunWorkflowColumn).not.toHaveBeenCalled() + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ data: { dispatchId: null } }) }) - it('400s rowIds and filter together', async () => { - const res = await callPost({ - workspaceId: 'ws-1', + it('rejects mutually exclusive row and filter scopes before delegation', async () => { + const response = await call({ + workspaceId: WORKSPACE_ID, groupIds: ['group-1'], rowIds: ['row-1'], - filter: { all: [{ field: 'status', op: 'eq', value: 'active' }] }, - }) - - expect(res.status).toBe(400) - expect(mockRunWorkflowColumn).not.toHaveBeenCalled() - }) - - it('400s an empty groupIds list', async () => { - const res = await callPost({ workspaceId: 'ws-1', groupIds: [] }) - - expect(res.status).toBe(400) - expect(mockRunWorkflowColumn).not.toHaveBeenCalled() - }) + filter: { all: [{ field: 'status', op: 'eq', value: 'ready' }] }, + }).response - it('403s a read-only member', async () => { - mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) - - const res = await callPost({ workspaceId: 'ws-1', groupIds: ['group-1'] }) - - expect(res.status).toBe(403) - expect(mockRunWorkflowColumn).not.toHaveBeenCalled() - }) - - it('404s with the gate off, before any work', async () => { - mockGateError.mockResolvedValue( - new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Not found' } }), { - status: 404, - }) - ) - - const res = await callPost({ workspaceId: 'ws-1', groupIds: ['group-1'] }) - - expect(res.status).toBe(404) - expect(mockCheckAccess).not.toHaveBeenCalled() - expect(mockRunWorkflowColumn).not.toHaveBeenCalled() + expect(response.status).toBe(400) + expect(mocks.startRun).not.toHaveBeenCalled() }) - it('429s a throttled caller', async () => { - mockCheckRateLimit.mockResolvedValue({ - ...RATE_LIMIT_OK, - allowed: false, - remaining: 0, - retryAfterMs: 1000, - }) - - const res = await callPost({ workspaceId: 'ws-1', groupIds: ['group-1'] }) + it('rejects an empty group selection before delegation', async () => { + const response = await call({ workspaceId: WORKSPACE_ID, groupIds: [] }).response - expect(res.status).toBe(429) - expect(mockRunWorkflowColumn).not.toHaveBeenCalled() + expect(response.status).toBe(400) + expect(mocks.startRun).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/app/api/v2/tables/[tableId]/columns/run/route.ts b/apps/sim/app/api/v2/tables/[tableId]/columns/run/route.ts index 7f2345f73be..48644f53b56 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/columns/run/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/columns/run/route.ts @@ -1,91 +1,29 @@ import { v2RunTableColumnContract } from '@/lib/api/contracts/v2/tables' -import { isZodError } from '@/lib/api/server' -import type { Filter, TableSchema } from '@/lib/table' -import { TableQueryValidationError } from '@/lib/table/errors' -import { signalTableRowsChanged } from '@/lib/table/events' -import { runWorkflowColumn } from '@/lib/table/workflow-columns' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { checkAccess } from '@/app/api/table/utils' -import { resolveWorkspaceScope } from '@/app/api/v1/middleware' -import { - v2CaughtOrchestrationError, - v2Data, - v2Error, - v2ValidationError, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' -import { - v2BulkPredicateToFilter, - v2TableAccessError, - v2TableLockError, -} from '@/app/api/v2/tables/utils' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2TableRowsErrorPolicy } from '@/lib/table/api/row-route-policies' +import { tableOperations } from '@/lib/table/application/operations' +import { startTableRun } from '@/lib/table/application/runs' export const dynamic = 'force-dynamic' export const revalidate = 0 -/** - * POST /api/v2/tables/[tableId]/columns/run — Run workflow/enrichment groups. - * - * Asynchronous: the response acknowledges the dispatch, not the results. The - * dispatcher walks the scoped rows and writes cells as runs land, so callers - * poll the row endpoints. `dispatchId` is `null` where no background runner is - * configured and cells execute inline. - */ -export const POST = withPublicApiRouteHandler({ +export const POST = defineV2JsonRoute({ contract: v2RunTableColumnContract, - rateLimitEndpoint: 'table-enrichment', - handler: async ({ input, auth: { requestId, userId, rateLimit } }) => { - try { - const { tableId } = input.params - const { workspaceId, groupIds, runMode, rowIds, filter, excludeRowIds, limit } = input.body - - const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - - const access = await checkAccess(tableId, userId, 'write') - if (!access.ok) return v2TableAccessError(access) - - if (access.table.workspaceId !== workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } - - // The public predicate is column-NAME keyed; the dispatcher compiles the - // storage-keyed legacy filter. Translating up front also makes an unknown - // field a 400 here rather than a dispatch that silently matches nothing. - let legacyFilter: Filter | undefined - if (filter) { - legacyFilter = v2BulkPredicateToFilter(filter, access.table.schema as TableSchema) - } - - const { dispatchId } = await runWorkflowColumn({ - tableId, - workspaceId, - groupIds, - mode: runMode, - rowIds, - filter: legacyFilter, - excludeRowIds, - limit, - requestId, - triggeredByUserId: userId, - }) - - // Starting a run clears the target groups' cells to pending — a row change - // open readers must pick up. - signalTableRowsChanged(tableId) - - return v2Data({ dispatchId }, { rateLimit }) - } catch (error) { - if (isZodError(error)) return v2ValidationError(error) - if (error instanceof TableQueryValidationError) return v2Error('BAD_REQUEST', error.message) - - const lockError = v2TableLockError(error) - if (lockError) return lockError - - const classified = v2CaughtOrchestrationError(error) - if (classified) return classified - - throw error - } - }, + operation: tableOperations.startRun, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableRowsErrorPolicy, + mapInput: ({ params, body }) => ({ + kind: 'selection' as const, + tableId: params.tableId, + assertedWorkspaceId: body.workspaceId, + groupIds: body.groupIds, + mode: body.runMode, + rowIds: body.rowIds, + predicate: body.filter, + excludeRowIds: body.excludeRowIds, + limit: body.limit, + }), + useCase: startTableRun, + present: ({ dispatchId }) => ({ data: { dispatchId } }), }) diff --git a/apps/sim/app/api/v2/tables/[tableId]/exports/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/exports/route.test.ts new file mode 100644 index 00000000000..d20a9074734 --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/exports/route.test.ts @@ -0,0 +1,140 @@ +/** + * @vitest-environment node + */ + +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + authenticate: vi.fn(), + preauthRate: vi.fn(), + operationRate: vi.fn(), + gate: vi.fn(), + create: vi.fn(), + read: vi.fn(), + download: vi.fn(), +})) + +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticate, + V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, +})) +vi.mock('@/lib/core/rate-limiter', () => ({ + RateLimiter: class { + checkRateLimitDirect = mocks.preauthRate + checkRateLimitDirectOrThrow = mocks.operationRate + }, + getRateLimit: () => ({ maxTokens: 100, refillRate: 100, refillIntervalMs: 60_000 }), +})) +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) +vi.mock('@/lib/table/application/exports', () => ({ + createTableExportUseCase: { operation: { id: 'tables.exports.create' }, execute: mocks.create }, + readTableExportUseCase: { operation: { id: 'tables.exports.read' }, execute: mocks.read }, + cancelTableExportUseCase: { operation: { id: 'tables.exports.cancel' }, execute: vi.fn() }, + downloadTableExportUseCase: { + operation: { id: 'tables.exports.download' }, + execute: mocks.download, + }, +})) + +import { POST } from '@/app/api/v2/tables/[tableId]/exports/route' +import { GET as DOWNLOAD } from '@/app/api/v2/tables/exports/[exportId]/download/route' +import { GET as STATUS } from '@/app/api/v2/tables/exports/[exportId]/route' + +const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' +const principal = { + kind: 'workspace_api_key' as const, + workspaceId: WORKSPACE_ID, + keyId: 'key-1', +} +const auth = { + principal, + rolloutUserId: 'owner-1', + rateLimitSubjectIds: [`workspace:${WORKSPACE_ID}`], + rateLimitSubscription: null, + keyType: 'workspace' as const, +} +const rate = { + allowed: true, + remaining: 99, + resetAt: new Date('2026-01-01T01:00:00.000Z'), + retryAfterMs: 0, +} +const tableExport = { + id: 'export-1', + tableId: 'table-1', + workspaceId: WORKSPACE_ID, + format: 'csv' as const, + status: 'completed' as const, + rowsProcessed: 2, + error: null, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:01:00.000Z', + completedAt: '2026-01-01T00:01:00.000Z', +} + +describe('v2 table exports', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.authenticate.mockResolvedValue(auth) + mocks.preauthRate.mockResolvedValue(rate) + mocks.operationRate.mockResolvedValue(rate) + mocks.gate.mockResolvedValue(null) + }) + + it('creates an export through the authorized use case', async () => { + mocks.create.mockResolvedValue({ export: tableExport }) + const request = new NextRequest('http://localhost:3000/api/v2/tables/table-1/exports', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ workspaceId: WORKSPACE_ID, format: 'csv' }), + }) + + const response = await POST(request, { params: Promise.resolve({ tableId: 'table-1' }) }) + + expect(response.status).toBe(201) + expect(await response.json()).toEqual({ data: tableExport }) + expect(mocks.create).toHaveBeenCalledWith({ + principal, + input: { tableId: 'table-1', workspaceId: WORKSPACE_ID, format: 'csv' }, + request, + }) + }) + + it('reads status and download metadata through separate semantic operations', async () => { + mocks.read.mockResolvedValue({ export: tableExport }) + mocks.download.mockResolvedValue({ + url: 'https://storage.example/export.csv', + fileName: 'Contacts.csv', + expiresAt: '2026-01-01T01:00:00.000Z', + }) + const statusRequest = new NextRequest( + `http://localhost:3000/api/v2/tables/exports/export-1?workspaceId=${WORKSPACE_ID}` + ) + const downloadRequest = new NextRequest( + `http://localhost:3000/api/v2/tables/exports/export-1/download?workspaceId=${WORKSPACE_ID}` + ) + + const status = await STATUS(statusRequest, { + params: Promise.resolve({ exportId: 'export-1' }), + }) + const download = await DOWNLOAD(downloadRequest, { + params: Promise.resolve({ exportId: 'export-1' }), + }) + + expect(status.status).toBe(200) + expect(await status.json()).toEqual({ data: tableExport }) + expect(download.status).toBe(200) + expect((await download.json()).data.fileName).toBe('Contacts.csv') + expect(mocks.read).toHaveBeenCalledWith({ + principal, + input: { exportId: 'export-1', workspaceId: WORKSPACE_ID }, + request: statusRequest, + }) + expect(mocks.download).toHaveBeenCalledWith({ + principal, + input: { exportId: 'export-1', workspaceId: WORKSPACE_ID }, + request: downloadRequest, + }) + }) +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/exports/route.ts b/apps/sim/app/api/v2/tables/[tableId]/exports/route.ts index 1582877d704..9b8115899a5 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/exports/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/exports/route.ts @@ -1,48 +1,23 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { v2CreateTableExportContract } from '@/lib/api/contracts/v2/tables' -import { - createTableExportResource, - toV2TableExport, -} from '@/lib/table/orchestration/export-resource' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { checkAccess } from '@/app/api/table/utils' -import { resolveWorkspaceScope } from '@/app/api/v1/middleware' -import { - v2CaughtOrchestrationError, - v2Data, - v2Error, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2TableErrorPolicies } from '@/lib/table/api' +import { createTableExportUseCase } from '@/lib/table/application/exports' +import { tableOperations } from '@/lib/table/application/operations' -export const POST = withPublicApiRouteHandler({ +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +export const POST = defineV2JsonRoute({ contract: v2CreateTableExportContract, - rateLimitEndpoint: 'table-export', - handler: async ({ request, input, auth: { userId, rateLimit } }) => { - try { - const { workspaceId, format } = input.body - const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - const access = await checkAccess(input.params.tableId, userId, 'read') - if (!access.ok || access.table.workspaceId !== workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } - const record = await createTableExportResource({ table: access.table, format }) - recordAudit({ - workspaceId, - actorId: userId, - action: AuditAction.TABLE_EXPORTED, - resourceType: AuditResourceType.TABLE, - resourceId: access.table.id, - resourceName: access.table.name, - description: `Exported table "${access.table.name}" as ${format.toUpperCase()}`, - metadata: { format, rowCount: access.table.rowCount }, - request, - }) - return v2Data(toV2TableExport(record, true), { rateLimit, status: 201 }) - } catch (error) { - const classified = v2CaughtOrchestrationError(error) - if (classified) return classified - throw error - } - }, + operation: tableOperations.createExport, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableErrorPolicies.concealTableAuthorization, + mapInput: ({ params, body }) => ({ + tableId: params.tableId, + workspaceId: body.workspaceId, + format: body.format, + }), + useCase: createTableExportUseCase, + present: ({ export: tableExport }) => ({ data: tableExport }), }) diff --git a/apps/sim/app/api/v2/tables/[tableId]/groups/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/groups/route.test.ts index 0a847cabcac..85cd6d77e35 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/groups/route.test.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/groups/route.test.ts @@ -1,437 +1,167 @@ /** * @vitest-environment node - * - * Public v2 workflow-group listing — a read-only projection of the table's - * schema, exposed so a caller can discover the group ids the run endpoints - * take. */ + import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockCheckRateLimit, - mockResolveWorkspaceScope, - mockCheckAccess, - mockGateError, - mockAddWorkflowGroup, - mockUpdateWorkflowGroup, - mockDeleteWorkflowGroup, - mockGetActiveWorkflowContext, - mockSignalSchemaChanged, -} = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceScope: vi.fn(), - mockCheckAccess: vi.fn(), - mockGateError: vi.fn(), - mockAddWorkflowGroup: vi.fn(), - mockUpdateWorkflowGroup: vi.fn(), - mockDeleteWorkflowGroup: vi.fn(), - mockGetActiveWorkflowContext: vi.fn(), - mockSignalSchemaChanged: vi.fn(), +const mocks = vi.hoisted(() => ({ + authenticate: vi.fn(), + preauthRate: vi.fn(), + operationRate: vi.fn(), + gate: vi.fn(), + list: vi.fn(), + create: vi.fn(), + update: vi.fn(), + remove: vi.fn(), })) -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceScope: mockResolveWorkspaceScope, +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticate, + V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, })) - -vi.mock('@/app/api/table/utils', () => ({ - checkAccess: mockCheckAccess, - normalizeColumn: (col: Record) => col, - rootErrorMessage: (error: unknown) => String(error), - rowWriteErrorResponse: () => null, +vi.mock('@/lib/core/rate-limiter', () => ({ + RateLimiter: class { + checkRateLimitDirect = mocks.preauthRate + checkRateLimitDirectOrThrow = mocks.operationRate + }, + getRateLimit: () => ({ maxTokens: 100, refillRate: 100, refillIntervalMs: 60_000 }), })) - -vi.mock('@/lib/table/workflow-groups/service', () => ({ - addWorkflowGroup: mockAddWorkflowGroup, - updateWorkflowGroup: mockUpdateWorkflowGroup, - deleteWorkflowGroup: mockDeleteWorkflowGroup, +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) +vi.mock('@/lib/table/application/groups', () => ({ + listTableGroupsUseCase: { operation: { id: 'tables.groups.list' }, execute: mocks.list }, + createTableGroupUseCase: { operation: { id: 'tables.groups.create' }, execute: mocks.create }, + updateTableGroupUseCase: { operation: { id: 'tables.groups.update' }, execute: mocks.update }, + deleteTableGroupUseCase: { operation: { id: 'tables.groups.delete' }, execute: mocks.remove }, })) -vi.mock('@sim/platform-authz/workflow', () => ({ - getActiveWorkflowContext: mockGetActiveWorkflowContext, -})) - -vi.mock('@/lib/table/events', () => ({ signalTableSchemaChanged: mockSignalSchemaChanged })) - -vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGateError })) - +import { OrchestrationError } from '@/lib/core/orchestration/types' import { DELETE, GET, PATCH, POST } from '@/app/api/v2/tables/[tableId]/groups/route' -const GROUP = { - id: 'group-1', - workflowId: 'wf-1', - name: 'Enrich', - outputs: [{ blockId: 'blk-1', path: 'content', columnName: 'summary' }], +const WORKSPACE_ID = 'workspace-1' +const principal = { + kind: 'workspace_api_key' as const, + workspaceId: WORKSPACE_ID, + keyId: 'key-1', } -const TABLE = { - id: 'table-1', - workspaceId: 'ws-1', - schema: { columns: [], workflowGroups: [GROUP] }, +const auth = { + principal, + rolloutUserId: 'owner-1', + rateLimitSubjectIds: [`workspace:${WORKSPACE_ID}`], + rateLimitSubscription: null, + keyType: 'workspace' as const, } - -const RATE_LIMIT_OK = { +const rate = { allowed: true, - userId: 'user-1', - keyType: 'workspace', - workspaceId: 'ws-1', - limit: 100, remaining: 99, - resetAt: new Date('2026-01-01T01:00:00Z'), -} - -function callGet() { - const req = new NextRequest( - 'http://localhost:3000/api/v2/tables/table-1/groups?workspaceId=ws-1', - { method: 'GET' } - ) - return GET(req, { params: Promise.resolve({ tableId: 'table-1' }) }) + resetAt: new Date('2026-01-01T01:00:00.000Z'), + retryAfterMs: 0, } - -describe('GET /api/v2/tables/[tableId]/groups', () => { - beforeEach(() => { - vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceScope.mockResolvedValue(null) - mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) - mockGateError.mockResolvedValue(null) - }) - - it('returns the schema groups as one full page', async () => { - const res = await callGet() - - expect(res.status).toBe(200) - expect(await res.json()).toEqual({ data: [GROUP], nextCursor: null }) - }) - - it('returns an empty page for a table with no groups', async () => { - mockCheckAccess.mockResolvedValue({ ok: true, table: { ...TABLE, schema: { columns: [] } } }) - - const res = await callGet() - - expect(res.status).toBe(200) - expect(await res.json()).toEqual({ data: [], nextCursor: null }) - }) - - it('masks a permission failure as 404 so table existence never leaks', async () => { - mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) - - const res = await callGet() - - expect(res.status).toBe(404) - }) - - it('400s a request with no workspaceId', async () => { - const req = new NextRequest('http://localhost:3000/api/v2/tables/table-1/groups', { - method: 'GET', - }) - const res = await GET(req, { params: Promise.resolve({ tableId: 'table-1' }) }) - - expect(res.status).toBe(400) - expect(mockCheckAccess).not.toHaveBeenCalled() - }) - - it('404s with the gate off, before any work', async () => { - mockGateError.mockResolvedValue( - new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Not found' } }), { - status: 404, - }) - ) - - const res = await callGet() - - expect(res.status).toBe(404) - expect(mockCheckAccess).not.toHaveBeenCalled() - }) - - it('429s a throttled caller', async () => { - mockCheckRateLimit.mockResolvedValue({ - ...RATE_LIMIT_OK, - allowed: false, - remaining: 0, - retryAfterMs: 1000, - }) - - const res = await callGet() - - expect(res.status).toBe(429) - expect(mockCheckAccess).not.toHaveBeenCalled() - }) -}) - -const ADD_BODY = { - workspaceId: 'ws-1', - group: { - workflowId: 'wf-1', - outputs: [{ blockId: 'blk-1', path: 'content', columnName: 'summary' }], - }, - outputColumns: [{ name: 'summary', type: 'string' }], +const group = { + id: 'group-1', + workflowId: 'workflow-1', + type: 'manual' as const, + outputs: [{ blockId: 'block-1', path: 'result', columnName: 'col-1' }], + autoRun: false, } - -const UPDATED_TABLE = { +const table = { id: 'table-1', - workspaceId: 'ws-1', - schema: { columns: [{ name: 'summary', type: 'string' }], workflowGroups: [GROUP] }, + name: 'Contacts', + schema: { + columns: [ + { + id: 'col-1', + name: 'Result', + type: 'string' as const, + required: false, + unique: false, + workflowGroupId: 'group-1', + }, + ], + }, } +const context = { params: Promise.resolve({ tableId: 'table-1' }) } -function callWrite(method: 'POST' | 'PATCH' | 'DELETE', body: unknown) { - const req = new NextRequest('http://localhost:3000/api/v2/tables/table-1/groups', { +function writeRequest(method: 'POST' | 'PATCH' | 'DELETE', body: unknown) { + return new NextRequest('http://localhost:3000/api/v2/tables/table-1/groups', { method, - headers: { 'Content-Type': 'application/json' }, + headers: { 'content-type': 'application/json', 'x-api-key': 'secret' }, body: JSON.stringify(body), }) - const handler = method === 'POST' ? POST : method === 'PATCH' ? PATCH : DELETE - return handler(req, { params: Promise.resolve({ tableId: 'table-1' }) }) } -describe('POST /api/v2/tables/[tableId]/groups', () => { +describe('/api/v2/tables/[tableId]/groups', () => { beforeEach(() => { vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceScope.mockResolvedValue(null) - mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) - mockGateError.mockResolvedValue(null) - mockGetActiveWorkflowContext.mockResolvedValue({ workspaceId: 'ws-1' }) - // Echo back the id the route generated, as the real service does. - mockAddWorkflowGroup.mockImplementation(async (data: { group: { id: string } }) => ({ - ...UPDATED_TABLE, - schema: { - ...UPDATED_TABLE.schema, - workflowGroups: [{ ...GROUP, id: data.group.id }], - }, - })) - }) - - it('creates the group and its columns, returning both', async () => { - const res = await callWrite('POST', ADD_BODY) - - expect(res.status).toBe(201) - const body = await res.json() - expect(body.data.group).toMatchObject({ workflowId: 'wf-1', name: 'Enrich' }) - expect(body.data.columns).toEqual([{ name: 'summary', type: 'string' }]) - expect(mockSignalSchemaChanged).toHaveBeenCalledWith('table-1') - }) - - it('500s rather than emitting a body without the group it claims to have written', async () => { - // Write reports success but the group is absent — an internal inconsistency - // must not surface as a 200 with `group: undefined`. - mockAddWorkflowGroup.mockResolvedValue({ - ...UPDATED_TABLE, - schema: { columns: [], workflowGroups: [] }, - }) - - const res = await callWrite('POST', ADD_BODY) - - expect(res.status).toBe(500) - expect((await res.json()).error.code).toBe('INTERNAL_ERROR') - }) - - it('server-generates the group id and stamps it onto the output columns', async () => { - await callWrite('POST', ADD_BODY) - - const call = mockAddWorkflowGroup.mock.calls[0][0] - expect(call.group.id).toEqual(expect.any(String)) - expect(call.group.id).not.toBe('') - // The caller never supplies workflowGroupId — it is derived from the group. - expect(call.outputColumns[0].workflowGroupId).toBe(call.group.id) - }) - - it('defaults autoRun to false so one POST cannot fan out a metered backfill', async () => { - await callWrite('POST', ADD_BODY) - expect(mockAddWorkflowGroup.mock.calls[0][0].autoRun).toBe(false) - }) - - it('rejects a workflow from another workspace before persisting it', async () => { - mockGetActiveWorkflowContext.mockResolvedValue({ workspaceId: 'ws-other' }) - - const res = await callWrite('POST', ADD_BODY) - - expect(res.status).toBe(400) - expect((await res.json()).error.message).toContain('Workflow not found') - expect(mockAddWorkflowGroup).not.toHaveBeenCalled() - }) - - it('rejects an output column that no group output feeds', async () => { - const res = await callWrite('POST', { - ...ADD_BODY, - outputColumns: [{ name: 'summry', type: 'string' }], - }) - - expect(res.status).toBe(400) - expect((await res.json()).error.message).toContain('summry') - expect(mockAddWorkflowGroup).not.toHaveBeenCalled() - }) - - it('400s an enrichment group with no enrichmentId', async () => { - const res = await callWrite('POST', { - ...ADD_BODY, - group: { ...ADD_BODY.group, workflowId: '', type: 'enrichment' }, - }) - - expect(res.status).toBe(400) - expect(mockAddWorkflowGroup).not.toHaveBeenCalled() - }) - - it('400s a workflow group with no workflowId', async () => { - const res = await callWrite('POST', { - ...ADD_BODY, - group: { ...ADD_BODY.group, workflowId: '' }, - }) - - expect(res.status).toBe(400) - expect(mockAddWorkflowGroup).not.toHaveBeenCalled() - }) - - it('masks a permission failure as 404', async () => { - mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) - - const res = await callWrite('POST', ADD_BODY) - - expect(res.status).toBe(404) - expect(mockAddWorkflowGroup).not.toHaveBeenCalled() - }) - - it('404s with the gate off, before any work', async () => { - mockGateError.mockResolvedValue( - new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Not found' } }), { - status: 404, - }) - ) - - const res = await callWrite('POST', ADD_BODY) - - expect(res.status).toBe(404) - expect(mockAddWorkflowGroup).not.toHaveBeenCalled() - }) - - it('429s a throttled caller', async () => { - mockCheckRateLimit.mockResolvedValue({ ...RATE_LIMIT_OK, allowed: false, retryAfterMs: 1000 }) - - const res = await callWrite('POST', ADD_BODY) - - expect(res.status).toBe(429) - expect(mockAddWorkflowGroup).not.toHaveBeenCalled() - }) - - it('surfaces a duplicate-column failure as 400, not 500', async () => { - mockAddWorkflowGroup.mockRejectedValue(new Error('Column "summary" already exists')) - - const res = await callWrite('POST', ADD_BODY) - - expect(res.status).toBe(400) - expect((await res.json()).error.message).toContain('already exists') - }) -}) - -describe('PATCH /api/v2/tables/[tableId]/groups', () => { - beforeEach(() => { - vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceScope.mockResolvedValue(null) - mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) - mockGateError.mockResolvedValue(null) - mockGetActiveWorkflowContext.mockResolvedValue({ workspaceId: 'ws-1' }) - mockUpdateWorkflowGroup.mockResolvedValue(UPDATED_TABLE) - }) - - it('updates the group and returns it with the resulting columns', async () => { - const res = await callWrite('PATCH', { - workspaceId: 'ws-1', - groupId: 'group-1', - name: 'Renamed', - }) - - expect(res.status).toBe(200) - const body = await res.json() - expect(body.data.group).toEqual(GROUP) - expect(body.data.columns).toEqual([{ name: 'summary', type: 'string' }]) - expect(mockUpdateWorkflowGroup).toHaveBeenCalledWith( - expect.objectContaining({ tableId: 'table-1', groupId: 'group-1', name: 'Renamed' }), - expect.any(String) + mocks.authenticate.mockResolvedValue(auth) + mocks.preauthRate.mockResolvedValue(rate) + mocks.operationRate.mockResolvedValue(rate) + mocks.gate.mockResolvedValue(null) + mocks.list.mockResolvedValue({ groups: [group] }) + mocks.create.mockResolvedValue({ table, group }) + mocks.update.mockResolvedValue({ table, group, changed: true, startAutoRun: false }) + mocks.remove.mockResolvedValue({ table, groupId: 'group-1' }) + }) + + it('lists the bounded group projection through the read use case', async () => { + const req = new NextRequest( + `http://localhost:3000/api/v2/tables/table-1/groups?workspaceId=${WORKSPACE_ID}` ) - }) - - it('re-checks workspace containment when the group is re-pointed', async () => { - mockGetActiveWorkflowContext.mockResolvedValue({ workspaceId: 'ws-other' }) - - const res = await callWrite('PATCH', { - workspaceId: 'ws-1', - groupId: 'group-1', - workflowId: 'wf-elsewhere', + const response = await GET(req, context) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ data: [group], nextCursor: null }) + expect(mocks.list).toHaveBeenCalledWith({ + principal, + input: { tableId: 'table-1', workspaceId: WORKSPACE_ID }, + request: req, }) - - expect(res.status).toBe(400) - expect(mockUpdateWorkflowGroup).not.toHaveBeenCalled() }) - it('stamps the group id onto any newly added output columns', async () => { - await callWrite('PATCH', { - workspaceId: 'ws-1', - groupId: 'group-1', - newOutputColumns: [{ name: 'score', type: 'number' }], + it('defaults create autoRun off and delegates all execution initiation to the application layer', async () => { + const req = writeRequest('POST', { + workspaceId: WORKSPACE_ID, + group: { + workflowId: 'workflow-1', + type: 'manual', + outputs: [{ blockId: 'block-1', path: 'result', columnName: 'Result' }], + }, + outputColumns: [{ name: 'Result', type: 'string' }], }) - - expect(mockUpdateWorkflowGroup.mock.calls[0][0].newOutputColumns[0].workflowGroupId).toBe( - 'group-1' - ) - }) - - it('masks a permission failure as 404', async () => { - mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) - - const res = await callWrite('PATCH', { workspaceId: 'ws-1', groupId: 'group-1' }) - - expect(res.status).toBe(404) - expect(mockUpdateWorkflowGroup).not.toHaveBeenCalled() - }) - - it('404s an unknown group rather than reporting a generic failure', async () => { - mockUpdateWorkflowGroup.mockRejectedValue(new Error('Workflow group not found')) - - const res = await callWrite('PATCH', { workspaceId: 'ws-1', groupId: 'nope' }) - - expect(res.status).toBe(404) - }) -}) - -describe('DELETE /api/v2/tables/[tableId]/groups', () => { - beforeEach(() => { - vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceScope.mockResolvedValue(null) - mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) - mockGateError.mockResolvedValue(null) - mockDeleteWorkflowGroup.mockResolvedValue({ - ...UPDATED_TABLE, - schema: { columns: [], workflowGroups: [] }, + const response = await POST(req, context) + + expect(response.status).toBe(201) + expect((await response.json()).data.group.id).toBe('group-1') + expect(mocks.create).toHaveBeenCalledWith({ + principal, + input: expect.objectContaining({ + tableId: 'table-1', + workspaceId: WORKSPACE_ID, + autoRun: false, + }), + request: req, }) }) - it('deletes the group and reports the surviving columns', async () => { - const res = await callWrite('DELETE', { workspaceId: 'ws-1', groupId: 'group-1' }) + it('conceals denied table access on group mutations', async () => { + mocks.update.mockRejectedValueOnce(new OrchestrationError('forbidden', 'Forbidden')) - expect(res.status).toBe(200) - // The group's columns go with it — the caller sees what is left, not a bare ack. - expect(await res.json()).toEqual({ data: { id: 'group-1', deleted: true, columns: [] } }) - expect(mockDeleteWorkflowGroup).toHaveBeenCalledWith( - { tableId: 'table-1', groupId: 'group-1' }, - expect.any(String) + const response = await PATCH( + writeRequest('PATCH', { workspaceId: WORKSPACE_ID, groupId: 'group-1', name: 'Renamed' }), + context ) - }) - - it('masks a permission failure as 404', async () => { - mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) - const res = await callWrite('DELETE', { workspaceId: 'ws-1', groupId: 'group-1' }) - - expect(res.status).toBe(404) - expect(mockDeleteWorkflowGroup).not.toHaveBeenCalled() + expect(response.status).toBe(404) + expect((await response.json()).error.message).toBe('Table not found') }) - it('400s a body with no groupId', async () => { - const res = await callWrite('DELETE', { workspaceId: 'ws-1' }) + it('returns authoritative surviving columns after deletion', async () => { + const response = await DELETE( + writeRequest('DELETE', { workspaceId: WORKSPACE_ID, groupId: 'group-1' }), + context + ) - expect(res.status).toBe(400) - expect(mockDeleteWorkflowGroup).not.toHaveBeenCalled() + expect(response.status).toBe(200) + expect((await response.json()).data).toMatchObject({ id: 'group-1', deleted: true }) }) }) diff --git a/apps/sim/app/api/v2/tables/[tableId]/groups/route.ts b/apps/sim/app/api/v2/tables/[tableId]/groups/route.ts index 66d06b07f0c..8910958f55a 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/groups/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/groups/route.ts @@ -1,296 +1,73 @@ -import { getActiveWorkflowContext } from '@sim/platform-authz/workflow' -import { generateId } from '@sim/utils/id' import { v2AddWorkflowGroupContract, v2DeleteWorkflowGroupContract, v2ListWorkflowGroupsContract, v2UpdateWorkflowGroupContract, } from '@/lib/api/contracts/v2/tables' -import type { TableDefinition, TableSchema } from '@/lib/table' -import { signalTableSchemaChanged } from '@/lib/table/events' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2TableErrorPolicies } from '@/lib/table/api' import { - addWorkflowGroup, - deleteWorkflowGroup, - updateWorkflowGroup, -} from '@/lib/table/workflow-groups/service' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { checkAccess, normalizeColumn } from '@/app/api/table/utils' -import { resolveWorkspaceScope } from '@/app/api/v1/middleware' -import { v2CursorList, v2Data, v2Error, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' -import { v2TableLockError } from '@/app/api/v2/tables/utils' + createTableGroupUseCase, + deleteTableGroupUseCase, + listTableGroupsUseCase, + updateTableGroupUseCase, +} from '@/lib/table/application/groups' +import { tableOperations } from '@/lib/table/application/operations' +import { normalizeColumn } from '@/app/api/table/utils' export const dynamic = 'force-dynamic' export const revalidate = 0 -/** - * GET /api/v2/tables/[tableId]/groups — The table's workflow/enrichment groups. - * - * Read-only: groups are authored in the workflow builder, and the public - * surface exposes them so a caller can discover the `groupIds` the run - * endpoints take. Groups live on the table's schema, so this is a projection of - * the already-loaded definition rather than a second query, and the set is - * bounded per table — one full page, `nextCursor` always `null`. - */ -export const GET = withPublicApiRouteHandler({ +export const GET = defineV2JsonRoute({ contract: v2ListWorkflowGroupsContract, - rateLimitEndpoint: 'table-groups', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const { tableId } = input.params - const { workspaceId } = input.query - - const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - - const result = await checkAccess(tableId, userId, 'read') - // Mask not-authorized and not-found alike so cross-workspace existence never leaks. - if (!result.ok || result.table.workspaceId !== workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } - - const groups = (result.table.schema as TableSchema).workflowGroups ?? [] - - return v2CursorList(groups, null, { rateLimit }) - }, + operation: tableOperations.listGroups, + useCase: listTableGroupsUseCase, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableErrorPolicies.concealTableAuthorization, + mapInput: ({ params, query }) => ({ tableId: params.tableId, workspaceId: query.workspaceId }), + present: ({ groups }) => ({ data: groups, nextCursor: null }), }) -/** - * Maps expected group-service failures into the v2 envelope. The service - * signals through thrown `Error` messages rather than classified codes, so the - * string matching mirrors the first-party mapper. Unexpected errors keep - * bubbling to the public route wrapper for centralized logging and rendering. - */ -function groupMutationError(error: unknown) { - const lockError = v2TableLockError(error) - if (lockError) return lockError - - if (error instanceof Error) { - const message = error.message - if (message === 'Table not found' || message.includes('not found')) { - return v2Error('NOT_FOUND', message) - } - if ( - message.includes('Schema validation') || - message.includes('Missing column definition') || - message.includes('already exists') || - message.includes('exceed') - ) { - return v2Error('BAD_REQUEST', message) - } - } - - throw error -} - -/** - * A group persists a `workflowId` that its runs later execute. Without this the - * table becomes a way to invoke workflows the API key cannot otherwise reach, - * so containment is asserted before the id is stored — on create and on any - * update that re-points the group. - */ -async function assertWorkflowInWorkspace(workflowId: string, workspaceId: string) { - const context = await getActiveWorkflowContext(workflowId) - if (!context || context.workspaceId !== workspaceId) { - return v2Error('BAD_REQUEST', 'Workflow not found in this workspace') - } - return null -} - -/** - * `{ group, columns }` for the group a mutation touched. - * - * Throws when the write reports success but the group is absent from the - * returned schema. The contract declares `group` as present, so emitting - * `undefined` there would ship a body no client can parse while reporting 200 — - * an internal inconsistency is worth a 500, not a malformed success. - */ -function groupResponse(table: TableDefinition, groupId: string) { - const schema = table.schema as TableSchema - const group = (schema.workflowGroups ?? []).find((candidate) => candidate.id === groupId) - if (!group) { - throw new Error(`Workflow group ${groupId} missing from the table after a successful write`) - } - return { group, columns: schema.columns.map(normalizeColumn) } -} - -/** - * POST /api/v2/tables/[tableId]/groups — Bind a workflow or enrichment to the - * table and create the columns its runs populate, in one call. - */ -export const POST = withPublicApiRouteHandler({ +export const POST = defineV2JsonRoute({ contract: v2AddWorkflowGroupContract, - rateLimitEndpoint: 'table-groups', - handler: async ({ input, auth: { requestId, userId, rateLimit } }) => { - try { - const { tableId } = input.params - const validated = input.body - - const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - - const result = await checkAccess(tableId, userId, 'write') - if (!result.ok || result.table.workspaceId !== validated.workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } - - if (validated.group.workflowId) { - const workflowError = await assertWorkflowInWorkspace( - validated.group.workflowId, - result.table.workspaceId - ) - if (workflowError) return workflowError - } - - /** - * `outputs` and `outputColumns` are two arrays joined by column name, so a - * typo in either silently creates a column nothing feeds. The first-party - * client builds both from one picker and can't desync; a public caller can, - * so the mismatch is rejected rather than persisted. - */ - const outputNames = new Set(validated.group.outputs.map((output) => output.columnName)) - const orphan = validated.outputColumns.find((column) => !outputNames.has(column.name)) - if (orphan) { - return v2Error( - 'BAD_REQUEST', - `outputColumns entry "${orphan.name}" has no matching group.outputs[].columnName` - ) - } - - const groupId = validated.group.id ?? generateId() - - const updatedTable = await addWorkflowGroup( - { - tableId, - group: { ...validated.group, id: groupId }, - // Stamped from the resolved group rather than trusted from the caller. - outputColumns: validated.outputColumns.map((column) => ({ - ...column, - workflowGroupId: groupId, - })), - autoRun: validated.autoRun, - actorUserId: userId, - }, - requestId - ) - - signalTableSchemaChanged(tableId) - - return v2Data(groupResponse(updatedTable, groupId), { rateLimit, status: 201 }) - } catch (error) { - return groupMutationError(error) - } - }, + operation: tableOperations.createGroup, + useCase: createTableGroupUseCase, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableErrorPolicies.concealTableAuthorization, + mapInput: ({ params, body }) => ({ tableId: params.tableId, ...body }), + present: ({ table, group }) => ({ + data: { group, columns: table.schema.columns.map(normalizeColumn) }, + }), }) -/** - * PATCH /api/v2/tables/[tableId]/groups — Restructure a group: re-point it, - * add or remove outputs, or change how its runs are scheduled. - * - * Removing an output **deletes that column and its values** — the same - * behavior as `DELETE /columns` on a bound column. There is no detach. - */ -export const PATCH = withPublicApiRouteHandler({ +export const PATCH = defineV2JsonRoute({ contract: v2UpdateWorkflowGroupContract, - rateLimitEndpoint: 'table-groups', - handler: async ({ input, auth: { requestId, userId, rateLimit } }) => { - try { - const { tableId } = input.params - const validated = input.body - - const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - - const result = await checkAccess(tableId, userId, 'write') - if (!result.ok || result.table.workspaceId !== validated.workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } - - if (validated.workflowId !== undefined) { - const workflowError = await assertWorkflowInWorkspace( - validated.workflowId, - result.table.workspaceId - ) - if (workflowError) return workflowError - } - - const updatedTable = await updateWorkflowGroup( - { - tableId, - groupId: validated.groupId, - actorUserId: userId, - ...(validated.workflowId !== undefined ? { workflowId: validated.workflowId } : {}), - ...(validated.name !== undefined ? { name: validated.name } : {}), - ...(validated.dependencies !== undefined ? { dependencies: validated.dependencies } : {}), - ...(validated.outputs !== undefined ? { outputs: validated.outputs } : {}), - ...(validated.newOutputColumns !== undefined - ? { - newOutputColumns: validated.newOutputColumns.map((column) => ({ - ...column, - workflowGroupId: validated.groupId, - })), - } - : {}), - ...(validated.mappingUpdates !== undefined - ? { mappingUpdates: validated.mappingUpdates } - : {}), - ...(validated.inputMappings !== undefined - ? { inputMappings: validated.inputMappings } - : {}), - ...(validated.deploymentMode !== undefined - ? { deploymentMode: validated.deploymentMode } - : {}), - ...(validated.type !== undefined ? { type: validated.type } : {}), - ...(validated.autoRun !== undefined ? { autoRun: validated.autoRun } : {}), - }, - requestId - ) - - signalTableSchemaChanged(tableId) - - return v2Data(groupResponse(updatedTable, validated.groupId), { rateLimit }) - } catch (error) { - return groupMutationError(error) - } - }, + operation: tableOperations.updateGroup, + useCase: updateTableGroupUseCase, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableErrorPolicies.concealTableAuthorization, + mapInput: ({ params, body }) => ({ tableId: params.tableId, ...body }), + present: ({ table, group }) => ({ + data: { group, columns: table.schema.columns.map(normalizeColumn) }, + }), }) -/** - * DELETE /api/v2/tables/[tableId]/groups — Remove a group **and every column it - * fed**, along with their values. The surviving column list comes back so a - * caller does not have to re-read the table to see what is left. - */ -export const DELETE = withPublicApiRouteHandler({ +export const DELETE = defineV2JsonRoute({ contract: v2DeleteWorkflowGroupContract, - rateLimitEndpoint: 'table-groups', - handler: async ({ input, auth: { requestId, userId, rateLimit } }) => { - try { - const { tableId } = input.params - const validated = input.body - - const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - - const result = await checkAccess(tableId, userId, 'write') - if (!result.ok || result.table.workspaceId !== validated.workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } - - const updatedTable = await deleteWorkflowGroup( - { tableId, groupId: validated.groupId }, - requestId - ) - - signalTableSchemaChanged(tableId) - - return v2Data( - { - id: validated.groupId, - deleted: true as const, - columns: (updatedTable.schema as TableSchema).columns.map(normalizeColumn), - }, - { rateLimit } - ) - } catch (error) { - return groupMutationError(error) - } - }, + operation: tableOperations.deleteGroup, + useCase: deleteTableGroupUseCase, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableErrorPolicies.concealTableAuthorization, + mapInput: ({ params, body }) => ({ tableId: params.tableId, ...body }), + present: ({ table, groupId }) => ({ + data: { + id: groupId, + deleted: true as const, + columns: table.schema.columns.map(normalizeColumn), + }, + }), }) diff --git a/apps/sim/app/api/v2/tables/[tableId]/query/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/query/route.test.ts index e868aabfd54..88ab015bf3c 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/query/route.test.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/query/route.test.ts @@ -1,299 +1,168 @@ /** * @vitest-environment node - * - * Public v2 query POST: typed predicate name→id translation, bounded-default vs - * explicit-unbounded limit, cursor validation, workspace scoping, and - * name-keyed row output in the `{ data, nextCursor }` envelope. */ + import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { TableDefinition } from '@/lib/table/types' - -const { - mockCheckAccess, - mockQueryRows, - mockCheckRateLimit, - mockResolveWorkspaceScope, - mockIsFeatureEnabled, - mockGetWorkspaceOrganizationId, -} = vi.hoisted(() => ({ - mockCheckAccess: vi.fn(), - mockQueryRows: vi.fn(), - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceScope: vi.fn(), - mockIsFeatureEnabled: vi.fn(), - mockGetWorkspaceOrganizationId: vi.fn(), -})) - -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceScope: mockResolveWorkspaceScope, -})) - -vi.mock('@/app/api/table/utils', () => ({ - checkAccess: mockCheckAccess, - normalizeColumn: (col: Record) => col, - rootErrorMessage: (error: unknown) => String(error), - rowWriteErrorResponse: () => null, -})) -vi.mock('@/lib/table', async () => { - const columnKeys = await import('@/lib/table/column-keys') - return { ...columnKeys } +const { mocks, MockTableRowsValidationError } = vi.hoisted(() => { + class MockTableRowsValidationError extends Error { + constructor( + message: string, + readonly details?: unknown + ) { + super(message) + } + } + return { + mocks: { + authenticate: vi.fn(), + preauthRate: vi.fn(), + operationRate: vi.fn(), + gate: vi.fn(), + queryRows: vi.fn(), + }, + MockTableRowsValidationError, + } }) -vi.mock('@/lib/table/rows/service', () => ({ queryRows: mockQueryRows })) - -vi.mock('@/lib/core/config/feature-flags', () => ({ - isFeatureEnabled: mockIsFeatureEnabled, +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticate, + V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, })) - -vi.mock('@/lib/workspaces/utils', () => ({ - getWorkspaceOrganizationId: mockGetWorkspaceOrganizationId, +vi.mock('@/lib/core/rate-limiter', () => ({ + RateLimiter: class { + checkRateLimitDirect = mocks.preauthRate + checkRateLimitDirectOrThrow = mocks.operationRate + }, + getRateLimit: () => ({ maxTokens: 100, refillRate: 100, refillIntervalMs: 60_000 }), })) - -import { encodeCursor } from '@/lib/table/rows/cursor' - -vi.mock('@/app/api/v2/lib/gate', () => ({ - v2ApiGateError: vi.fn().mockResolvedValue(null), +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) +vi.mock('@/lib/table/application/rows', () => ({ + TableRowsValidationError: MockTableRowsValidationError, + queryTableRows: { operation: { id: 'tables.rows.query' }, execute: mocks.queryRows }, })) import { POST } from '@/app/api/v2/tables/[tableId]/query/route' -const RATE_LIMIT_OK = { +const WORKSPACE_ID = 'workspace-1' +const PRINCIPAL = { + kind: 'workspace_api_key' as const, + workspaceId: WORKSPACE_ID, + keyId: 'key-1', +} +const AUTH = { + principal: PRINCIPAL, + rolloutUserId: 'owner-1', + rateLimitSubjectIds: [`workspace:${WORKSPACE_ID}`], + rateLimitSubscription: null, + keyType: 'workspace' as const, +} +const RATE = { allowed: true, - userId: 'user-1', - keyType: 'workspace', - workspaceId: 'workspace-1', - limit: 100, remaining: 99, - resetAt: new Date('2024-01-01T01:00:00Z'), + resetAt: new Date('2026-01-01T01:00:00Z'), + retryAfterMs: 0, } - -function buildTable(): TableDefinition { - return { - id: 'tbl_1', - name: 'People', - description: null, - schema: { - columns: [ - { id: 'col_name', name: 'name', type: 'string' }, - { id: 'col_wins', name: 'wins', type: 'number' }, - { id: 'col_status', name: 'status', type: 'string' }, - ], - }, - metadata: null, - rowCount: 0, - maxRows: 100, - workspaceId: 'workspace-1', - createdBy: 'user-1', - archivedAt: null, - createdAt: new Date('2024-01-01'), - updatedAt: new Date('2024-01-01'), - } +const TABLE = { + id: 'table-1', + workspaceId: WORKSPACE_ID, + schema: { columns: [{ id: 'column-name', name: 'name', type: 'string' as const }] }, } - -const EMPTY_RESULT = { - rows: [], - rowCount: 0, - totalCount: 0, - limit: 100, - offset: 0, - nextCursor: null, +const ROW = { + id: 'row-1', + data: { 'column-name': 'Ada' }, + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-02T00:00:00Z'), } -function callQuery(body: Record) { - const req = new NextRequest('http://localhost:3000/api/v2/tables/tbl_1/query', { +function call(body: unknown) { + const request = new NextRequest('http://localhost/api/v2/tables/table-1/query', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'content-type': 'application/json', 'x-api-key': 'secret' }, body: JSON.stringify(body), }) - return POST(req, { params: Promise.resolve({ tableId: 'tbl_1' }) }) + return { + request, + response: POST(request, { params: Promise.resolve({ tableId: 'table-1' }) }), + } } describe('POST /api/v2/tables/[tableId]/query', () => { beforeEach(() => { vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceScope.mockResolvedValue(null) - mockCheckAccess.mockResolvedValue({ ok: true, table: buildTable() }) - mockQueryRows.mockResolvedValue(EMPTY_RESULT) - mockIsFeatureEnabled.mockResolvedValue(true) - mockGetWorkspaceOrganizationId.mockResolvedValue('org-1') - }) - - it('returns 404 when the v2 API surface flag is off', async () => { - const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') - const { v2Error } = await import('@/app/api/v2/lib/response') - vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) - - const res = await callQuery({ workspaceId: 'workspace-1' }) - - expect(res.status).toBe(404) - expect((await res.json()).error.code).toBe('NOT_FOUND') - expect(mockQueryRows).not.toHaveBeenCalled() + mocks.authenticate.mockResolvedValue(AUTH) + mocks.preauthRate.mockResolvedValue(RATE) + mocks.operationRate.mockResolvedValue(RATE) + mocks.gate.mockResolvedValue(null) + mocks.queryRows.mockResolvedValue({ table: TABLE, rows: [ROW], nextCursor: null }) }) - it('runs the flag gate only after the access check, so it cannot leak a cohort oracle', async () => { - mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) - const res = await callQuery({ workspaceId: 'workspace-1' }) - expect(res.status).toBe(403) - expect(mockGate).not.toHaveBeenCalled() - }) + it('delegates the typed query and preserves the public row envelope', async () => { + const predicate = { all: [{ field: 'name', op: 'eq', value: 'Ada' }] } + const invocation = call({ workspaceId: WORKSPACE_ID, predicate }) + const response = await invocation.response - it('translates a name-keyed predicate to storage ids', async () => { - const res = await callQuery({ - workspaceId: 'workspace-1', - predicate: { - all: [ - { field: 'status', op: 'eq', value: 'active' }, - { field: 'wins', op: 'gte', value: 10 }, - ], - }, - }) - expect(res.status).toBe(200) - expect(mockQueryRows.mock.calls[0][1].predicate).toEqual({ - all: [ - { field: 'col_status', op: 'eq', value: 'active' }, - { field: 'col_wins', op: 'gte', value: 10 }, + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + data: [ + { + id: 'row-1', + data: { name: 'Ada' }, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-02T00:00:00.000Z', + }, ], + nextCursor: null, }) - }) - - it('accepts a root condition and executes its canonical all group', async () => { - const res = await callQuery({ - workspaceId: 'workspace-1', - predicate: { field: 'status', op: 'eq', value: 'active' }, - }) - - expect(res.status).toBe(200) - expect(mockQueryRows.mock.calls[0][1].predicate).toEqual({ - all: [{ field: 'col_status', op: 'eq', value: 'active' }], + expect(mocks.queryRows).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { + tableId: 'table-1', + assertedWorkspaceId: WORKSPACE_ID, + predicate, + sort: undefined, + cursor: undefined, + limit: 100, + includeTotal: false, + }, + request: invocation.request, }) }) - it('applies the bounded default limit when omitted', async () => { - await callQuery({ workspaceId: 'workspace-1' }) - expect(mockQueryRows.mock.calls[0][1].limit).toBe(100) - }) + it('preserves explicit limit=0 as the unbounded opt-in', async () => { + await call({ workspaceId: WORKSPACE_ID, limit: 0 }).response - it('treats limit=0 as the explicit unbounded opt-in', async () => { - await callQuery({ workspaceId: 'workspace-1', limit: 0 }) - expect(mockQueryRows.mock.calls[0][1].limit).toBeUndefined() + expect(mocks.queryRows).toHaveBeenCalledWith( + expect.objectContaining({ input: expect.objectContaining({ limit: undefined }) }) + ) }) - it('rejects a limit above the max', async () => { - const res = await callQuery({ workspaceId: 'workspace-1', limit: 5000 }) - expect(res.status).toBe(400) - expect(mockQueryRows).not.toHaveBeenCalled() - }) + it('rejects an invalid page limit after admission and before delegation', async () => { + const response = await call({ workspaceId: WORKSPACE_ID, limit: 5000 }).response - it('rejects the removed regex ops at the contract boundary', async () => { - // `match`/`imatch` are no longer in FILTER_OPS — a catastrophic-backtracking - // pattern can pin a shared-pool connection, and nothing shipped depends on them. - const res = await callQuery({ - workspaceId: 'workspace-1', - predicate: { all: [{ field: 'name', op: 'match', value: '^jo' }] }, - }) - expect(res.status).toBe(400) - expect(mockQueryRows).not.toHaveBeenCalled() + expect(response.status).toBe(400) + expect(mocks.authenticate).toHaveBeenCalledOnce() + expect(mocks.operationRate).toHaveBeenCalledOnce() + expect(mocks.queryRows).not.toHaveBeenCalled() }) - it('rejects a predicate referencing an unknown column', async () => { - const res = await callQuery({ - workspaceId: 'workspace-1', - predicate: { all: [{ field: 'nope', op: 'eq', value: 1 }] }, - }) - expect(res.status).toBe(400) - expect((await res.json()).error.message).toMatch(/Unknown filter column/) - }) + it('keeps malformed POST query cursors as a structured 400', async () => { + mocks.queryRows.mockRejectedValue( + new MockTableRowsValidationError('Invalid cursor', { code: 'INVALID_CURSOR' }) + ) - it('rejects a keyset cursor combined with a custom sort', async () => { - const cursor = encodeCursor({ - lastRow: { id: 'r1', orderKey: 'a1' }, - keysetValid: true, - nextOffset: 1, - }) - const res = await callQuery({ - workspaceId: 'workspace-1', - sort: [{ field: 'wins', direction: 'desc' }], - cursor, - }) - expect(res.status).toBe(400) - expect((await res.json()).error.details.code).toBe('CURSOR_SORT_CONFLICT') - }) + const response = await call({ workspaceId: WORKSPACE_ID, cursor: 'malformed' }).response - it('returns 400 INVALID_CURSOR for a malformed cursor', async () => { - const res = await callQuery({ - workspaceId: 'workspace-1', - cursor: Buffer.from('42').toString('base64url'), - }) - expect(res.status).toBe(400) - expect((await res.json()).error.details.code).toBe('INVALID_CURSOR') - }) - - it('surfaces a workspace-scope 403 in the v2 error envelope', async () => { - mockResolveWorkspaceScope.mockResolvedValue({ - status: 403, - code: 'FORBIDDEN', - message: 'API key is not authorized for this workspace', - }) - const res = await callQuery({ workspaceId: 'workspace-1' }) - expect(res.status).toBe(403) - expect((await res.json()).error.code).toBe('FORBIDDEN') - expect(mockQueryRows).not.toHaveBeenCalled() + expect(response.status).toBe(400) + expect((await response.json()).error.details).toEqual({ code: 'INVALID_CURSOR' }) }) - it('masks a workspace-id mismatch against the table as 404', async () => { - const res = await callQuery({ workspaceId: 'other-ws' }) - expect(res.status).toBe(404) - expect((await res.json()).error).toMatchObject({ - code: 'NOT_FOUND', - message: 'Table not found', - }) - }) + it('enforces the one MiB body cap before delegation', async () => { + const response = await call({ workspaceId: WORKSPACE_ID, cursor: 'x'.repeat(1024 * 1024) }) + .response - it('returns name-keyed row data with no storage internals and a private cache header', async () => { - mockQueryRows.mockResolvedValue({ - rows: [ - { - id: 'r1', - data: { col_status: 'active', col_wins: 12 }, - position: 3, - orderKey: 'a5', - executions: {}, - createdAt: new Date('2024-02-02'), - updatedAt: new Date('2024-02-03'), - }, - ], - rowCount: 1, - totalCount: 1, - limit: 100, - offset: 0, - nextCursor: null, - }) - const res = await callQuery({ workspaceId: 'workspace-1' }) - expect(res.headers.get('Cache-Control')).toBe('private, no-store') - const body = await res.json() - expect(body.nextCursor).toBeNull() - expect(body.data[0]).toEqual({ - id: 'r1', - data: { status: 'active', wins: 12 }, - createdAt: '2024-02-02T00:00:00.000Z', - updatedAt: '2024-02-03T00:00:00.000Z', - }) - }) - - it('returns the rate-limit response when the limiter denies the request', async () => { - mockCheckRateLimit.mockResolvedValue({ - allowed: false, - limit: 100, - remaining: 0, - resetAt: new Date('2024-01-01T01:00:00Z'), - retryAfterMs: 1000, - }) - const res = await callQuery({ workspaceId: 'workspace-1' }) - expect(res.status).toBe(429) - expect(mockCheckAccess).not.toHaveBeenCalled() + expect(response.status).toBe(413) + expect(mocks.queryRows).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/app/api/v2/tables/[tableId]/query/route.ts b/apps/sim/app/api/v2/tables/[tableId]/query/route.ts index a38f876dfbb..76805afa9b5 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/query/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/query/route.ts @@ -1,116 +1,38 @@ import { TABLE_QUERY_MAX_BODY_BYTES } from '@/lib/api/contracts/tables' import { V2_DEFAULT_ROW_LIMIT, v2QueryRowsContract } from '@/lib/api/contracts/v2/tables' -import { isZodError } from '@/lib/api/server' -import type { Sort, TablePredicate, TableSchema } from '@/lib/table' -import { buildIdByName, sortSpecNamesToIds } from '@/lib/table' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2TableRowsErrorPolicy } from '@/lib/table/api/row-route-policies' +import { tableOperations } from '@/lib/table/application/operations' +import { queryTableRows } from '@/lib/table/application/rows' import { namedRowMapper } from '@/lib/table/cell-format' -import { TableQueryValidationError } from '@/lib/table/errors' -import { validatePredicate, validateSortSpec } from '@/lib/table/query-builder/validate' -import { assertCursorSortBinding, decodeCursor } from '@/lib/table/rows/cursor' -import { queryRows } from '@/lib/table/rows/service' -import { predicateToStorage } from '@/lib/table/select-values' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { checkAccess } from '@/app/api/table/utils' -import { resolveWorkspaceScope } from '@/app/api/v1/middleware' -import { - v2CursorList, - v2Error, - v2ValidationError, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' import { toApiRow } from '@/app/api/v2/tables/utils' export const dynamic = 'force-dynamic' export const revalidate = 0 -/** - * POST /api/v2/tables/[tableId]/query — public row query. Typed `predicate`/`sort` - * objects + opaque cursor pagination. Default page {@link V2_DEFAULT_ROW_LIMIT}; - * `limit=0` = unbounded (whole result or 400). - */ -export const POST = withPublicApiRouteHandler({ +export const POST = defineV2JsonRoute({ contract: v2QueryRowsContract, - rateLimitEndpoint: 'table-rows', - parseOptions: { - maxBodyBytes: TABLE_QUERY_MAX_BODY_BYTES, - }, - handler: async ({ input, auth: { requestId, userId, rateLimit } }) => { - try { - const { tableId } = input.params - const { workspaceId, sort, cursor: cursorToken, limit } = input.body - - const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - - const accessResult = await checkAccess(tableId, userId, 'read') - // Mask not-authorized and not-found alike so cross-workspace existence never leaks. - if (!accessResult.ok) return v2Error('NOT_FOUND', 'Table not found') - - const { table } = accessResult - if (workspaceId !== table.workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } - - const schema = table.schema as TableSchema - const cursor = cursorToken ? decodeCursor(cursorToken) : undefined - - const idByName = buildIdByName(schema) - // Fuses the id→name key remap with select-cell value formatting, so a select - // cell surfaces its option NAME rather than the stored option id. - const toNamedRow = namedRowMapper(schema.columns) - let predicate: TablePredicate | undefined = input.body.predicate - if (predicate) { - validatePredicate(predicate, schema.columns) - predicate = predicateToStorage(predicate, schema) - } - let sortSpec = sort - if (sortSpec?.length) { - validateSortSpec(sortSpec, schema.columns) - sortSpec = sortSpecNamesToIds(sortSpec, idByName) - } - const sortObj: Sort | undefined = sortSpec?.length - ? Object.fromEntries(sortSpec.map((s) => [s.field, s.direction])) - : undefined - - // A cursor is only valid for the query shape it was minted under: keyset - // cursors bind to the default order, offset cursors to their sort. Runs on - // the STORAGE-keyed sort so the fingerprint matches what queryRows stamped. - if (cursor) assertCursorSortBinding(cursor, sortObj) - - // Public default is a bounded page (unlike the internal surface's unbounded - // omit). `limit=0` is the explicit unbounded opt-in. - const effectiveLimit = - limit === undefined ? V2_DEFAULT_ROW_LIMIT : limit === 0 ? undefined : limit - - const result = await queryRows( - table, - { - predicate, - sort: sortObj, - limit: effectiveLimit, - after: cursor?.after, - offset: cursor?.offset, - includeTotal: false, - withExecutions: false, - }, - requestId - ) - - return v2CursorList( - result.rows.map((r) => toApiRow(r, toNamedRow)), - result.nextCursor, - { rateLimit } - ) - } catch (error) { - if (isZodError(error)) return v2ValidationError(error) - - if (error instanceof TableQueryValidationError) { - return v2Error('BAD_REQUEST', error.message, { - details: error.code ? { code: error.code } : undefined, - }) - } - - throw error + operation: tableOperations.queryRows, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableRowsErrorPolicy, + parseOptions: { maxBodyBytes: TABLE_QUERY_MAX_BODY_BYTES }, + mapInput: ({ params, body }) => ({ + tableId: params.tableId, + assertedWorkspaceId: body.workspaceId, + predicate: body.predicate, + sort: body.sort, + cursor: body.cursor, + limit: + body.limit === undefined ? V2_DEFAULT_ROW_LIMIT : body.limit === 0 ? undefined : body.limit, + includeTotal: false, + }), + useCase: queryTableRows, + present: ({ table, rows, nextCursor }) => { + const toNamedRow = namedRowMapper(table.schema.columns) + return { + data: rows.map((row) => toApiRow(row, toNamedRow)), + nextCursor, } }, }) diff --git a/apps/sim/app/api/v2/tables/[tableId]/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/route.test.ts index 6b446248750..0aef78dbd47 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/route.test.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/route.test.ts @@ -1,476 +1,178 @@ /** * @vitest-environment node - * - * Public v2 table delete and update. Delete hands the actor to the service so - * the audit is emitted there — and only for a delete that actually archived a - * row. Update routes each field to its own orchestration call; lock flags are - * read-only on this surface and a request carrying them is refused outright. */ + import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockCheckRateLimit, - mockResolveWorkspaceScope, - mockCheckAccess, - mockPerformDeleteTable, - mockPerformRenameTable, - mockPerformUpdateTableDescription, - mockPerformMoveTableToFolder, - mockPerformUpdateTableLocks, - mockRecordAudit, - mockGetTableById, - mockLoadActiveFolderPathIndex, - mockGateError, - mockSignalSchemaChanged, -} = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceScope: vi.fn(), - mockCheckAccess: vi.fn(), - mockPerformDeleteTable: vi.fn(), - mockPerformRenameTable: vi.fn(), - mockPerformUpdateTableDescription: vi.fn(), - mockPerformMoveTableToFolder: vi.fn(), - mockPerformUpdateTableLocks: vi.fn(), - mockRecordAudit: vi.fn(), - mockGetTableById: vi.fn(), - mockLoadActiveFolderPathIndex: vi.fn(), - mockGateError: vi.fn(), - mockSignalSchemaChanged: vi.fn(), -})) - -vi.mock('@sim/audit', () => ({ - AuditAction: { TABLE_DELETED: 'table.deleted', TABLE_UPDATED: 'table.updated' }, - AuditResourceType: { TABLE: 'table' }, - recordAudit: mockRecordAudit, +const mocks = vi.hoisted(() => ({ + authenticate: vi.fn(), + preauthRate: vi.fn(), + operationRate: vi.fn(), + gate: vi.fn(), + read: vi.fn(), + update: vi.fn(), + remove: vi.fn(), + capture: vi.fn(), })) -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceScope: mockResolveWorkspaceScope, +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticate, + V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, })) - -vi.mock('@/app/api/table/utils', () => ({ - checkAccess: mockCheckAccess, - normalizeColumn: (col: Record) => col, - rootErrorMessage: (error: unknown) => String(error), - rowWriteErrorResponse: () => null, +vi.mock('@/lib/core/rate-limiter', () => ({ + RateLimiter: class { + checkRateLimitDirect = mocks.preauthRate + checkRateLimitDirectOrThrow = mocks.operationRate + }, + getRateLimit: () => ({ maxTokens: 100, refillRate: 100, refillIntervalMs: 60_000 }), })) - -vi.mock('@/lib/table', () => ({ - updateTable: vi.fn(), - getTableById: mockGetTableById, - updateRow: vi.fn(), - rowDataNameToId: vi.fn(), - buildIdByName: vi.fn(), +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.capture })) +vi.mock('@/lib/table/application/tables', () => ({ + readTableUseCase: { operation: { id: 'tables.read' }, execute: mocks.read }, + updateTableUseCase: { operation: { id: 'tables.update' }, execute: mocks.update }, + deleteTableUseCase: { operation: { id: 'tables.delete' }, execute: mocks.remove }, })) -vi.mock('@/lib/table/events', () => ({ - signalTableSchemaChanged: mockSignalSchemaChanged, -})) -vi.mock('@/lib/folders/queries', () => ({ - loadActiveFolderPathIndex: mockLoadActiveFolderPathIndex, -})) -vi.mock('@/lib/workspaces/permissions/utils', () => ({ - getWorkspaceWithOwner: vi.fn().mockResolvedValue({ organizationId: 'org-1' }), -})) +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { DELETE, GET, PATCH } from '@/app/api/v2/tables/[tableId]/route' -vi.mock('@/lib/table/orchestration', () => ({ - performDeleteTable: mockPerformDeleteTable, - performRenameTable: mockPerformRenameTable, - performUpdateTableDescription: mockPerformUpdateTableDescription, - performMoveTableToFolder: mockPerformMoveTableToFolder, - performUpdateTableLocks: mockPerformUpdateTableLocks, -})) - -vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGateError })) - -import { DELETE, PATCH } from '@/app/api/v2/tables/[tableId]/route' - -const UNLOCKED = { - schemaLocked: false, - insertLocked: false, - updateLocked: false, - deleteLocked: false, +const WORKSPACE_ID = 'workspace-1' +const principal = { + kind: 'workspace_api_key' as const, + workspaceId: WORKSPACE_ID, + keyId: 'key-1', } -const TABLE = { - id: 'table-1', - name: 'Tasks', - workspaceId: 'ws-1', - schema: { columns: [] }, - locks: UNLOCKED, +const auth = { + principal, + rolloutUserId: 'owner-1', + rateLimitSubjectIds: [`workspace:${WORKSPACE_ID}`], + rateLimitSubscription: null, + keyType: 'workspace' as const, } -const UPDATED_TABLE = { - ...TABLE, - name: 'Renamed', - description: null, - rowCount: 0, - maxRows: 1000, - folderId: null, - createdAt: new Date('2026-01-01T00:00:00Z'), - updatedAt: new Date('2026-01-02T00:00:00Z'), -} - -const RATE_LIMIT_OK = { +const rate = { allowed: true, - userId: 'user-1', - keyType: 'workspace', - workspaceId: 'ws-1', - limit: 100, remaining: 99, - resetAt: new Date('2026-01-01T01:00:00Z'), + resetAt: new Date('2026-01-01T01:00:00.000Z'), + retryAfterMs: 0, } - -function callDelete() { - const req = new NextRequest('http://localhost:3000/api/v2/tables/table-1?workspaceId=ws-1', { - method: 'DELETE', - }) - return DELETE(req, { params: Promise.resolve({ tableId: 'table-1' }) }) +const table = { + id: 'table-1', + workspaceId: WORKSPACE_ID, + userId: 'owner-1', + name: 'Contacts', + description: null, + schema: { + columns: [ + { id: 'col-1', name: 'Name', type: 'string' as const, required: false, unique: false }, + ], + }, + rowCount: 0, + maxRows: 100, + folderId: null, + metadata: null, + locks: { + schemaLocked: false, + insertLocked: false, + updateLocked: false, + deleteLocked: false, + }, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), } - -function callPatch(body: unknown) { - const req = new NextRequest('http://localhost:3000/api/v2/tables/table-1', { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }) - return PATCH(req, { params: Promise.resolve({ tableId: 'table-1' }) }) +const context = { params: Promise.resolve({ tableId: 'table-1' }) } + +function request(method: 'GET' | 'PATCH' | 'DELETE', body?: unknown) { + return new NextRequest( + `http://localhost:3000/api/v2/tables/table-1${method === 'PATCH' ? '' : `?workspaceId=${WORKSPACE_ID}`}`, + { + method, + headers: { 'x-api-key': 'secret', ...(body ? { 'content-type': 'application/json' } : {}) }, + ...(body ? { body: JSON.stringify(body) } : {}), + } + ) } -beforeEach(() => { - vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceScope.mockResolvedValue(null) - mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) - mockGetTableById.mockResolvedValue(UPDATED_TABLE) - mockLoadActiveFolderPathIndex.mockResolvedValue({ - rowById: new Map([['folder-1', { id: 'folder-1', name: 'Reports', parentId: null }]]), - pathById: new Map([['folder-1', '/Reports']]), - idByPath: new Map([['/Reports', 'folder-1']]), - }) - mockGateError.mockResolvedValue(null) -}) - -describe('DELETE /api/v2/tables/[tableId]', () => { - it('delegates to the orchestration function with the resolved table and actor', async () => { - mockPerformDeleteTable.mockResolvedValue({ success: true }) - - const res = await callDelete() - - expect(res.status).toBe(200) - expect(mockPerformDeleteTable).toHaveBeenCalledWith( - expect.objectContaining({ table: TABLE, userId: 'user-1' }) - ) - expect((await res.json()).data).toEqual({ id: 'table-1', deleted: true }) - // The route no longer audits: doing so out here fired TABLE_DELETED even - // when the delete was a no-op on an already-archived table. - expect(mockRecordAudit).not.toHaveBeenCalled() - }) - - it('returns 423 LOCKED for a delete-locked table instead of a 500', async () => { - mockPerformDeleteTable.mockResolvedValue({ - success: false, - errorCode: 'locked', - error: 'Table is locked', +describe('/api/v2/tables/[tableId]', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.authenticate.mockResolvedValue(auth) + mocks.preauthRate.mockResolvedValue(rate) + mocks.operationRate.mockResolvedValue(rate) + mocks.gate.mockResolvedValue(null) + mocks.read.mockResolvedValue({ table, folderPath: '/' }) + mocks.update.mockResolvedValue({ + table, + folderPath: '/', + applied: ['name'], + changed: [], }) - - const res = await callDelete() - - expect(res.status).toBe(423) - expect((await res.json()).error.code).toBe('LOCKED') - }) -}) - -describe('PATCH /api/v2/tables/[tableId]', () => { - it('renames through the orchestration function and returns the re-read table', async () => { - mockPerformRenameTable.mockResolvedValue({ success: true }) - - const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed' }) - - expect(res.status).toBe(200) - expect((await res.json()).data).toEqual({ - table: { - id: 'table-1', - name: 'Renamed', - description: null, - schema: { columns: [] }, - rowCount: 0, - maxRows: 1000, - folderPath: '/', - locks: UNLOCKED, - job: null, - createdAt: '2026-01-01T00:00:00.000Z', - updatedAt: '2026-01-02T00:00:00.000Z', - }, + mocks.remove.mockResolvedValue({ + id: 'table-1', + deleted: true, + archived: true, + tableName: 'Contacts', + workspaceId: WORKSPACE_ID, + attributedUserId: 'owner-1', }) - expect(mockPerformRenameTable).toHaveBeenCalledWith( - expect.objectContaining({ table: TABLE, newName: 'Renamed', userId: 'user-1' }) - ) - expect(mockPerformMoveTableToFolder).not.toHaveBeenCalled() - expect(mockPerformUpdateTableLocks).not.toHaveBeenCalled() }) - it('updates and clears the table description through orchestration', async () => { - mockPerformUpdateTableDescription.mockResolvedValue({ success: true }) - - const updateResponse = await callPatch({ workspaceId: 'ws-1', description: 'Finance data' }) + it('reads through the canonical authorized use case', async () => { + const req = request('GET') + const response = await GET(req, context) - expect(updateResponse.status).toBe(200) - expect(mockPerformUpdateTableDescription).toHaveBeenCalledWith( - expect.objectContaining({ - table: TABLE, - description: 'Finance data', - userId: 'user-1', - }) - ) - - const clearResponse = await callPatch({ workspaceId: 'ws-1', description: null }) - expect(clearResponse.status).toBe(200) - expect(mockPerformUpdateTableDescription).toHaveBeenLastCalledWith( - expect.objectContaining({ description: null }) - ) - }) - - it('surfaces a running import so an async job is observable, not just startable', async () => { - // `POST /import-async` and `POST /job/cancel` let a caller start and stop an - // import; without this the table never reports that it is running, so there - // is nothing to poll between the two. - mockGetTableById.mockResolvedValue({ - ...UPDATED_TABLE, - jobStatus: 'running', - jobId: 'job-1', - jobType: 'import', - jobRowsProcessed: 250, - jobError: null, - }) - mockPerformRenameTable.mockResolvedValue({ success: true }) - - const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed' }) - - expect((await res.json()).data.table.job).toEqual({ - id: 'job-1', - type: 'import', - status: 'running', - rowsProcessed: 250, - error: null, + expect(response.status).toBe(200) + expect((await response.json()).data.table.id).toBe('table-1') + expect(mocks.read).toHaveBeenCalledWith({ + principal, + input: { tableId: 'table-1', workspaceId: WORKSPACE_ID }, + request: req, }) }) - it('moves the table only after confirming the folder belongs to the workspace', async () => { - mockPerformMoveTableToFolder.mockResolvedValue({ success: true }) - - const res = await callPatch({ workspaceId: 'ws-1', folderPath: '/Reports' }) - - expect(res.status).toBe(200) - expect(mockLoadActiveFolderPathIndex).toHaveBeenCalledWith('ws-1', 'table', expect.any(Object)) - expect(mockPerformMoveTableToFolder).toHaveBeenCalledWith( - expect.objectContaining({ table: TABLE, folderId: 'folder-1', userId: 'user-1' }) + it('preserves a successful no-op PATCH response', async () => { + const response = await PATCH( + request('PATCH', { workspaceId: WORKSPACE_ID, name: 'Contacts' }), + context ) - }) - it('404s a folder from outside the workspace without attempting the move', async () => { - const res = await callPatch({ workspaceId: 'ws-1', folderPath: '/Elsewhere' }) - - expect(res.status).toBe(404) - expect(mockPerformMoveTableToFolder).not.toHaveBeenCalled() + expect(response.status).toBe(200) + expect((await response.json()).data.table.name).toBe('Contacts') }) - it('rejects a bad folder without applying the rename that came with it', async () => { - // The three operations are separate transactions, so validation has to run - // before the first write — otherwise a rejected PATCH still renames. - const res = await callPatch({ - workspaceId: 'ws-1', - name: 'Renamed', - folderPath: '/Elsewhere', + it('reports committed fields when a later composite PATCH step fails', async () => { + mocks.update.mockResolvedValueOnce({ + table, + folderPath: null, + applied: ['name'], + changed: ['name'], + failure: new OrchestrationError('not_found', 'Folder not found'), }) - expect(res.status).toBe(404) - expect(mockPerformRenameTable).not.toHaveBeenCalled() - expect(mockPerformUpdateTableLocks).not.toHaveBeenCalled() - expect(mockSignalSchemaChanged).not.toHaveBeenCalled() - }) - - it('reports which operations landed when a later one fails', async () => { - // The three writes commit independently, so rather than pretending - // atomicity the error states what is already live — a caller can reconcile - // instead of re-reading and diffing. - mockPerformRenameTable.mockResolvedValue({ success: true }) - mockPerformMoveTableToFolder.mockResolvedValue({ - success: false, - errorCode: 'not_found', - error: 'gone', - }) - - const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed', folderPath: '/Reports' }) - - expect(res.status).toBe(404) - expect((await res.json()).error.details).toEqual({ applied: ['name'] }) - }) - - it('omits the applied list when the very first operation fails', async () => { - // `details.applied` present must always mean "these changes are live". - mockPerformRenameTable.mockResolvedValue({ - success: false, - errorCode: 'conflict', - error: 'taken', - }) - - const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed', folderPath: '/Reports' }) - - expect(res.status).toBe(409) - expect((await res.json()).error.details).toBeUndefined() - expect(mockPerformMoveTableToFolder).not.toHaveBeenCalled() - }) - - it('still signals collaborators when a later operation fails after an earlier one landed', async () => { - // A mid-write fault can't be rolled back across three transactions, so the - // clients must at least be told to refetch what did apply. - mockPerformRenameTable.mockResolvedValue({ success: true }) - mockPerformMoveTableToFolder.mockResolvedValue({ - success: false, - errorCode: 'not_found', - error: 'gone', - }) - - const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed', folderPath: '/Reports' }) - - expect(res.status).toBe(404) - expect(mockPerformRenameTable).toHaveBeenCalled() - expect(mockSignalSchemaChanged).toHaveBeenCalledWith('table-1') - }) - - /** - * Locks are read-only on the public API. A `write`-level API key can already - * mutate the table, so letting it clear a lock would let it undo the guard - * placed there to stop it. The strict body rejects the field outright rather - * than dropping it silently, which would report success for a change that - * never happened. - */ - it('rejects a lock change instead of applying or silently ignoring it', async () => { - const res = await callPatch({ workspaceId: 'ws-1', locks: { deleteLocked: true } }) - - expect(res.status).toBe(400) - const body = await res.json() - expect(body.error.code).toBe('BAD_REQUEST') - expect(JSON.stringify(body.error)).toContain('locks') - expect(mockPerformUpdateTableLocks).not.toHaveBeenCalled() - }) - - it('rejects a lock change even when paired with an otherwise valid rename', async () => { - const res = await callPatch({ - workspaceId: 'ws-1', - name: 'Renamed', - locks: { deleteLocked: false }, - }) - - expect(res.status).toBe(400) - // The whole request is refused — the rename must not land either. - expect(mockPerformRenameTable).not.toHaveBeenCalled() - }) - - /** - * The re-read runs after the writes have committed, so a failure there must - * still name what landed. Reporting a bare 500 tells the caller nothing took - * effect and it retries into a duplicate-name conflict. - */ - it('reports the applied operations when the final re-read throws', async () => { - mockPerformRenameTable.mockResolvedValue({ success: true }) - mockGetTableById.mockRejectedValue(new Error('connection reset')) - - const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed' }) - - expect(res.status).toBe(500) - expect((await res.json()).error.details).toEqual({ applied: ['name'] }) - }) - - it('reports the applied operations when the re-read finds the table archived', async () => { - mockPerformRenameTable.mockResolvedValue({ success: true }) - mockGetTableById.mockResolvedValue(null) - - const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed' }) - - expect(res.status).toBe(404) - expect((await res.json()).error.details).toEqual({ applied: ['name'] }) - }) - - it('omits applied details when the failure happened before any write', async () => { - mockGetTableById.mockRejectedValue(new Error('connection reset')) - - mockLoadActiveFolderPathIndex.mockRejectedValue(new Error('connection reset')) - - const res = await callPatch({ workspaceId: 'ws-1', folderPath: '/Nope' }) - - // Absence is meaningful: nothing is live, so a retry is safe. - expect((await res.json()).error.details).toBeUndefined() - }) - - it('still reports the stored lock flags on the table it returns', async () => { - // The response is a re-read, so the locked state has to come from there. - mockGetTableById.mockResolvedValue({ - ...UPDATED_TABLE, - locks: { ...UNLOCKED, deleteLocked: true }, - }) - - const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed' }) - - expect(res.status).toBe(200) - expect((await res.json()).data.table.locks).toMatchObject({ deleteLocked: true }) - }) - - it('maps a duplicate-name rename to 409 CONFLICT', async () => { - mockPerformRenameTable.mockResolvedValue({ - success: false, - errorCode: 'conflict', - error: 'A table named "Renamed" already exists', - }) - - const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed' }) - - expect(res.status).toBe(409) - expect((await res.json()).error.code).toBe('CONFLICT') - }) - - it('rejects a body with nothing to change', async () => { - const res = await callPatch({ workspaceId: 'ws-1' }) - - expect(res.status).toBe(400) - expect(mockPerformRenameTable).not.toHaveBeenCalled() - }) - - it('404s a table in another workspace without writing', async () => { - mockCheckAccess.mockResolvedValue({ ok: true, table: { ...TABLE, workspaceId: 'ws-other' } }) - - const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed' }) - - expect(res.status).toBe(404) - expect(mockPerformRenameTable).not.toHaveBeenCalled() - }) - - it('404s with the gate off, before any work', async () => { - mockGateError.mockResolvedValue( - new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Not found' } }), { - status: 404, - }) + const response = await PATCH( + request('PATCH', { + workspaceId: WORKSPACE_ID, + name: 'Renamed', + folderPath: '/Missing', + }), + context ) - const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed' }) - - expect(res.status).toBe(404) - expect(mockCheckAccess).not.toHaveBeenCalled() - expect(mockPerformRenameTable).not.toHaveBeenCalled() + expect(response.status).toBe(404) + expect((await response.json()).error.details).toEqual({ applied: ['name'] }) }) - it('429s a throttled caller', async () => { - mockCheckRateLimit.mockResolvedValue({ - ...RATE_LIMIT_OK, - allowed: false, - remaining: 0, - retryAfterMs: 1000, - }) - - const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed' }) + it('keeps delete analytics surface-specific after authoritative success', async () => { + const response = await DELETE(request('DELETE'), context) - expect(res.status).toBe(429) - expect(mockPerformRenameTable).not.toHaveBeenCalled() + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ data: { id: 'table-1', deleted: true } }) + expect(mocks.capture).toHaveBeenCalledWith( + 'owner-1', + 'table_deleted', + { table_id: 'table-1', workspace_id: WORKSPACE_ID }, + { groups: { workspace: WORKSPACE_ID } } + ) }) }) diff --git a/apps/sim/app/api/v2/tables/[tableId]/route.ts b/apps/sim/app/api/v2/tables/[tableId]/route.ts index 2f41f7ff910..7b39bf8b7e3 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/route.ts @@ -1,242 +1,90 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' import { v2DeleteTableContract, v2GetTableContract, v2UpdateTableContract, } from '@/lib/api/contracts/v2/tables' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' import { asOrchestrationError } from '@/lib/core/orchestration/types' -import { loadActiveFolderPathIndex } from '@/lib/folders/queries' -import { getTableById } from '@/lib/table' -import { signalTableSchemaChanged } from '@/lib/table/events' +import { captureServerEvent } from '@/lib/posthog/server' +import { v2TableErrorPolicies } from '@/lib/table/api' +import { TableOperationError } from '@/lib/table/application/errors' +import { tableOperations } from '@/lib/table/application/operations' import { - performDeleteTable, - performMoveTableToFolder, - performRenameTable, - performUpdateTableDescription, -} from '@/lib/table/orchestration' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { checkAccess } from '@/app/api/table/utils' -import { resolveWorkspaceScope } from '@/app/api/v1/middleware' -import { folderPathForId, resolveFolderPathIdentity } from '@/app/api/v2/lib/folders' -import { v2Data, v2Error, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' -import type { OrchestrationOutcome } from '@/app/api/v2/tables/utils' -import { - toApiTable, - v2TableAccessError, - v2TableLockError, - v2TableOrchestrationError, -} from '@/app/api/v2/tables/utils' - -const logger = createLogger('V2TableDetailAPI') - -/** - * `details` payload naming the operations of a composite write that committed, - * or `undefined` when none did — so `details.applied` being present always - * means "these changes are live despite the error". - */ -function appliedDetails( - applied: readonly ('name' | 'description' | 'folderPath')[] -): { applied: readonly string[] } | undefined { - return applied.length > 0 ? { applied } : undefined -} + deleteTableUseCase, + readTableUseCase, + type UpdateTableResult, + updateTableUseCase, +} from '@/lib/table/application/tables' +import { TableLockedError } from '@/lib/table/mutation-locks' +import { toApiTable } from '@/app/api/v2/tables/utils' export const dynamic = 'force-dynamic' export const revalidate = 0 -/** GET /api/v2/tables/[tableId] — Get table details. */ -export const GET = withPublicApiRouteHandler({ - contract: v2GetTableContract, - rateLimitEndpoint: 'table-detail', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const { tableId } = input.params - const { workspaceId } = input.query - - const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - - const result = await checkAccess(tableId, userId, 'read') - // Mask not-authorized and not-found alike so cross-workspace existence never leaks. - if (!result.ok) return v2Error('NOT_FOUND', 'Table not found') - - if (result.table.workspaceId !== workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } - - const folderIndex = await loadActiveFolderPathIndex(workspaceId, 'table') - return v2Data( - { table: toApiTable(result.table, folderPathForId(folderIndex, result.table.folderId)) }, - { rateLimit } +function rethrowUpdateFailure(result: UpdateTableResult): void { + if (!result.failure) return + if (result.applied.length === 0) throw result.failure + + const details = { applied: result.applied } + if (result.failure instanceof TableOperationError) { + throw new TableOperationError( + result.failure.code, + result.failure.message, + { ...result.failure.details, ...details }, + result.failure.lock ) - }, + } + if (result.failure instanceof TableLockedError) { + throw new TableOperationError('locked', result.failure.message, details, result.failure.lock) + } + const classified = asOrchestrationError(result.failure) + if (classified) throw new TableOperationError(classified.code, classified.message, details) + throw new TableOperationError('internal', 'Internal server error', details) +} + +export const GET = defineV2JsonRoute({ + contract: v2GetTableContract, + operation: tableOperations.read, + useCase: readTableUseCase, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableErrorPolicies.concealTableAuthorization, + mapInput: ({ params, query }) => ({ tableId: params.tableId, workspaceId: query.workspaceId }), + present: ({ table, folderPath }) => ({ data: { table: toApiTable(table, folderPath) } }), }) -/** - * PATCH /api/v2/tables/[tableId] — Rename and/or move a table. - * - * Each field routes to its own orchestration call so the audit records the - * operation the caller actually performed. - * - * Lock flags are **not** settable here. They are readable on the table resource - * and enforced on every write, but an API key that can mutate a table must not - * also be able to clear the lock placed there to stop it; changing a lock stays - * a first-party admin action. The contract body is `.strict()`, so a request - * carrying `locks` is rejected rather than silently ignored. - */ -export const PATCH = withPublicApiRouteHandler({ +export const PATCH = defineV2JsonRoute({ contract: v2UpdateTableContract, - rateLimitEndpoint: 'table-detail', - handler: async ({ request, input, auth: { requestId, userId, rateLimit } }) => { - /** - * Hoisted above the `try` so every exit path can report it. Once a write has - * committed, the response must say so even when the failure came *after* the - * writes — a throw in the final re-read, or the re-read finding the table - * archived. Reporting a bare 500 there tells the caller nothing landed, and - * it retries into a duplicate-name conflict or a repeated move. - */ - const applied: ('name' | 'description' | 'folderPath')[] = [] - - try { - const { tableId } = input.params - const validated = input.body - - const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - - const result = await checkAccess(tableId, userId, 'write') - if (!result.ok) return v2TableAccessError(result) - - const { table } = result - if (table.workspaceId !== validated.workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } - - const resolution = - validated.folderPath === undefined - ? undefined - : await resolveFolderPathIdentity({ - workspaceId: table.workspaceId, - resourceType: 'table', - path: validated.folderPath, - }) - if (resolution && !resolution.found) { - return v2Error('NOT_FOUND', 'Folder not found in this workspace') - } - - let failure: { outcome: OrchestrationOutcome; fallback: string } | null = null - - if (validated.name !== undefined) { - const outcome = await performRenameTable({ - table, - newName: validated.name, - userId, - requestId, - request, - }) - if (outcome.success) applied.push('name') - else failure = { outcome, fallback: 'Failed to rename table' } - } - - if (!failure && validated.description !== undefined) { - const outcome = await performUpdateTableDescription({ - table, - description: validated.description, - userId, - requestId, - request, - }) - if (outcome.success) applied.push('description') - else failure = { outcome, fallback: 'Failed to update table description' } - } - - if (!failure && validated.folderPath !== undefined) { - const outcome = await performMoveTableToFolder({ - table, - folderId: resolution?.folderId ?? null, - userId, - requestId, - request, - }) - if (outcome.success) { - applied.push('folderPath') - } else { - failure = { - outcome: - outcome.errorCode === 'not_found' - ? { ...outcome, error: 'Table not found' } - : outcome, - fallback: 'Failed to move table', - } - } - } - - if (applied.length > 0) signalTableSchemaChanged(tableId) - if (failure) { - return v2TableOrchestrationError(failure.outcome, failure.fallback, appliedDetails(applied)) - } - - const updated = await getTableById(tableId) - if (!updated) { - return v2Error('NOT_FOUND', 'Table not found', { details: appliedDetails(applied) }) - } - - const folderIndex = await loadActiveFolderPathIndex(table.workspaceId, 'table') - return v2Data( - { table: toApiTable(updated, folderPathForId(folderIndex, updated.folderId)) }, - { rateLimit } - ) - } catch (error) { - const details = appliedDetails(applied) - - const lockError = v2TableLockError(error, details) - if (lockError) return lockError - - const classified = asOrchestrationError(error) - if (classified) { - return v2TableOrchestrationError( - { errorCode: classified.code, error: classified.message }, - 'Failed to update table', - details - ) - } - - logger.error(`[${requestId}] Error updating table`, { - error: getErrorMessage(error, 'Unknown error'), - applied, - }) - return v2Error('INTERNAL_ERROR', 'Internal server error', { details }) + operation: tableOperations.update, + useCase: updateTableUseCase, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableErrorPolicies.default, + mapInput: ({ params, body }) => ({ tableId: params.tableId, ...body }), + present: (result) => { + rethrowUpdateFailure(result) + if (!result.table || result.folderPath === null) { + throw new Error('Updated table is missing from the authoritative result') } + return { data: { table: toApiTable(result.table, result.folderPath) } } }, }) -export const DELETE = withPublicApiRouteHandler({ +export const DELETE = defineV2JsonRoute({ contract: v2DeleteTableContract, - rateLimitEndpoint: 'table-detail', - handler: async ({ request, input, auth: { requestId, userId, rateLimit } }) => { - try { - const { tableId } = input.params - const { workspaceId } = input.query - - const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - - const result = await checkAccess(tableId, userId, 'write') - if (!result.ok) return v2TableAccessError(result) - - if (result.table.workspaceId !== workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } - - const outcome = await performDeleteTable({ table: result.table, userId, requestId, request }) - if (!outcome.success) { - return v2TableOrchestrationError(outcome, 'Failed to delete table') - } - - return v2Data({ id: tableId, deleted: true }, { rateLimit }) - } catch (error) { - const lockError = v2TableLockError(error) - if (lockError) return lockError - throw error - } + operation: tableOperations.delete, + useCase: deleteTableUseCase, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableErrorPolicies.default, + mapInput: ({ params, query }) => ({ tableId: params.tableId, workspaceId: query.workspaceId }), + onSuccess: ({ result }) => { + captureServerEvent( + result.attributedUserId, + 'table_deleted', + { table_id: result.id, workspace_id: result.workspaceId }, + { groups: { workspace: result.workspaceId } } + ) }, + present: ({ id, deleted }) => ({ data: { id, deleted } }), }) diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]/route.test.ts index cc1566ce848..c0e1306fe6a 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]/route.test.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]/route.test.ts @@ -1,160 +1,134 @@ /** * @vitest-environment node - * - * Public v2 per-row enrichment run — the single-cell case of the column run. - * Naming a specific cell is an explicit re-run, so it dispatches in `all` mode - * and recomputes an already-populated cell. */ + import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockCheckRateLimit, - mockResolveWorkspaceScope, - mockCheckAccess, - mockRunWorkflowColumn, - mockSignalRowsChanged, - mockGateError, -} = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceScope: vi.fn(), - mockCheckAccess: vi.fn(), - mockRunWorkflowColumn: vi.fn(), - mockSignalRowsChanged: vi.fn(), - mockGateError: vi.fn(), -})) +const { mocks, MockTableRowsValidationError } = vi.hoisted(() => { + class MockTableRowsValidationError extends Error {} + return { + mocks: { + authenticate: vi.fn(), + preauthRate: vi.fn(), + operationRate: vi.fn(), + gate: vi.fn(), + startRun: vi.fn(), + }, + MockTableRowsValidationError, + } +}) -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceScope: mockResolveWorkspaceScope, +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticate, + V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, })) - -vi.mock('@/app/api/table/utils', () => ({ - checkAccess: mockCheckAccess, - normalizeColumn: (col: Record) => col, - rootErrorMessage: (error: unknown) => String(error), - rowWriteErrorResponse: () => null, +vi.mock('@/lib/core/rate-limiter', () => ({ + RateLimiter: class { + checkRateLimitDirect = mocks.preauthRate + checkRateLimitDirectOrThrow = mocks.operationRate + }, + getRateLimit: () => ({ maxTokens: 100, refillRate: 100, refillIntervalMs: 60_000 }), +})) +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) +vi.mock('@/lib/table/application/rows', () => ({ + TableRowsValidationError: MockTableRowsValidationError, +})) +vi.mock('@/lib/table/application/runs', () => ({ + startTableRun: { operation: { id: 'tables.runs.start' }, execute: mocks.startRun }, })) -vi.mock('@/lib/table/workflow-columns', () => ({ runWorkflowColumn: mockRunWorkflowColumn })) -vi.mock('@/lib/table/events', () => ({ signalTableRowsChanged: mockSignalRowsChanged })) -vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGateError })) - +import { OrchestrationError } from '@/lib/core/orchestration/types' import { POST } from '@/app/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]/route' -const TABLE = { id: 'table-1', workspaceId: 'ws-1', schema: { columns: [] } } - -const RATE_LIMIT_OK = { +const WORKSPACE_ID = 'workspace-1' +const PRINCIPAL = { + kind: 'workspace_api_key' as const, + workspaceId: WORKSPACE_ID, + keyId: 'key-1', +} +const AUTH = { + principal: PRINCIPAL, + rolloutUserId: 'owner-1', + rateLimitSubjectIds: [`workspace:${WORKSPACE_ID}`], + rateLimitSubscription: null, + keyType: 'workspace' as const, +} +const RATE = { allowed: true, - userId: 'user-1', - keyType: 'workspace', - workspaceId: 'ws-1', - limit: 100, remaining: 99, resetAt: new Date('2026-01-01T01:00:00Z'), + retryAfterMs: 0, } -function callPost(body: unknown) { - const req = new NextRequest( - 'http://localhost:3000/api/v2/tables/table-1/rows/row-1/enrichment/group-1', +function call(body: unknown) { + const request = new NextRequest( + 'http://localhost/api/v2/tables/table-1/rows/row-1/enrichment/group-1', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'content-type': 'application/json', 'x-api-key': 'secret' }, body: JSON.stringify(body), } ) - return POST(req, { - params: Promise.resolve({ tableId: 'table-1', rowId: 'row-1', groupId: 'group-1' }), - }) + return { + request, + response: POST(request, { + params: Promise.resolve({ tableId: 'table-1', rowId: 'row-1', groupId: 'group-1' }), + }), + } } describe('POST /api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]', () => { beforeEach(() => { vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceScope.mockResolvedValue(null) - mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) - mockRunWorkflowColumn.mockResolvedValue({ dispatchId: 'dispatch-1' }) - mockGateError.mockResolvedValue(null) + mocks.authenticate.mockResolvedValue(AUTH) + mocks.preauthRate.mockResolvedValue(RATE) + mocks.operationRate.mockResolvedValue(RATE) + mocks.gate.mockResolvedValue(null) + mocks.startRun.mockResolvedValue({ table: { id: 'table-1' }, dispatchId: 'dispatch-1' }) }) - it('scopes the dispatch to the one row and group in the path', async () => { - const res = await callPost({ workspaceId: 'ws-1' }) + it('delegates the canonical row and group path scope', async () => { + const invocation = call({ workspaceId: WORKSPACE_ID }) + const response = await invocation.response - expect(res.status).toBe(200) - expect((await res.json()).data).toEqual({ dispatchId: 'dispatch-1' }) - expect(mockRunWorkflowColumn).toHaveBeenCalledWith( - expect.objectContaining({ + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ data: { dispatchId: 'dispatch-1' } }) + expect(mocks.startRun).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { + kind: 'row_enrichment', tableId: 'table-1', - workspaceId: 'ws-1', - groupIds: ['group-1'], - rowIds: ['row-1'], - mode: 'all', - triggeredByUserId: 'user-1', - }) - ) - expect(mockSignalRowsChanged).toHaveBeenCalledWith('table-1') - }) - - it('reports a null dispatch id verbatim rather than inventing one', async () => { - mockRunWorkflowColumn.mockResolvedValue({ dispatchId: null }) - - const res = await callPost({ workspaceId: 'ws-1' }) - - expect(res.status).toBe(200) - expect((await res.json()).data).toEqual({ dispatchId: null }) - }) - - it('404s a table in another workspace without dispatching', async () => { - mockCheckAccess.mockResolvedValue({ ok: true, table: { ...TABLE, workspaceId: 'ws-other' } }) - - const res = await callPost({ workspaceId: 'ws-1' }) - - expect(res.status).toBe(404) - expect(mockRunWorkflowColumn).not.toHaveBeenCalled() - }) - - it('400s a body with no workspace', async () => { - const res = await callPost({}) - - expect(res.status).toBe(400) - expect(mockRunWorkflowColumn).not.toHaveBeenCalled() + rowId: 'row-1', + groupId: 'group-1', + assertedWorkspaceId: WORKSPACE_ID, + }, + request: invocation.request, + }) }) - it('403s a read-only member', async () => { - mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) + it('preserves a null dispatch id instead of inventing one', async () => { + mocks.startRun.mockResolvedValue({ table: { id: 'table-1' }, dispatchId: null }) - const res = await callPost({ workspaceId: 'ws-1' }) + const response = await call({ workspaceId: WORKSPACE_ID }).response - expect(res.status).toBe(403) - expect(mockRunWorkflowColumn).not.toHaveBeenCalled() + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ data: { dispatchId: null } }) }) - it('404s with the gate off, before any work', async () => { - mockGateError.mockResolvedValue( - new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Not found' } }), { - status: 404, - }) - ) - - const res = await callPost({ workspaceId: 'ws-1' }) + it('rejects a missing workspace before delegation', async () => { + const response = await call({}).response - expect(res.status).toBe(404) - expect(mockCheckAccess).not.toHaveBeenCalled() - expect(mockRunWorkflowColumn).not.toHaveBeenCalled() + expect(response.status).toBe(400) + expect(mocks.startRun).not.toHaveBeenCalled() }) - it('429s a throttled caller', async () => { - mockCheckRateLimit.mockResolvedValue({ - ...RATE_LIMIT_OK, - allowed: false, - remaining: 0, - retryAfterMs: 1000, - }) + it('conceals canonical row or group lookup failures', async () => { + mocks.startRun.mockRejectedValue(new OrchestrationError('not_found', 'Row not found')) - const res = await callPost({ workspaceId: 'ws-1' }) + const response = await call({ workspaceId: WORKSPACE_ID }).response - expect(res.status).toBe(429) - expect(mockRunWorkflowColumn).not.toHaveBeenCalled() + expect(response.status).toBe(404) + expect((await response.json()).error.code).toBe('NOT_FOUND') }) }) diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]/route.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]/route.ts index 121c88c7e42..bc229a9579b 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]/route.ts @@ -1,66 +1,25 @@ import { v2RunRowEnrichmentContract } from '@/lib/api/contracts/v2/tables' -import { signalTableRowsChanged } from '@/lib/table/events' -import { runWorkflowColumn } from '@/lib/table/workflow-columns' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { checkAccess } from '@/app/api/table/utils' -import { resolveWorkspaceScope } from '@/app/api/v1/middleware' -import { - v2CaughtOrchestrationError, - v2Data, - v2Error, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' -import { v2TableAccessError, v2TableLockError } from '@/app/api/v2/tables/utils' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2TableRowsErrorPolicy } from '@/lib/table/api/row-route-policies' +import { tableOperations } from '@/lib/table/application/operations' +import { startTableRun } from '@/lib/table/application/runs' export const dynamic = 'force-dynamic' export const revalidate = 0 -/** - * POST /api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId] - * - * The single-cell case of `POST /columns/run`: runs one group for one row. - * `mode: 'all'` because naming a specific cell is an explicit re-run request — - * an already-populated cell must recompute rather than be skipped. - */ -export const POST = withPublicApiRouteHandler({ +export const POST = defineV2JsonRoute({ contract: v2RunRowEnrichmentContract, - rateLimitEndpoint: 'table-enrichment', - handler: async ({ input, auth: { requestId, userId, rateLimit } }) => { - try { - const { tableId, rowId, groupId } = input.params - const { workspaceId } = input.body - - const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - - const access = await checkAccess(tableId, userId, 'write') - if (!access.ok) return v2TableAccessError(access) - - if (access.table.workspaceId !== workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } - - const { dispatchId } = await runWorkflowColumn({ - tableId, - workspaceId, - groupIds: [groupId], - rowIds: [rowId], - mode: 'all', - requestId, - triggeredByUserId: userId, - }) - - signalTableRowsChanged(tableId) - - return v2Data({ dispatchId }, { rateLimit }) - } catch (error) { - const lockError = v2TableLockError(error) - if (lockError) return lockError - - const classified = v2CaughtOrchestrationError(error) - if (classified) return classified - - throw error - } - }, + operation: tableOperations.startRun, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableRowsErrorPolicy, + mapInput: ({ params, body }) => ({ + kind: 'row_enrichment' as const, + tableId: params.tableId, + rowId: params.rowId, + groupId: params.groupId, + assertedWorkspaceId: body.workspaceId, + }), + useCase: startTableRun, + present: ({ dispatchId }) => ({ data: { dispatchId } }), }) diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/route.test.ts index 626dd3ba567..8e22fca852c 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/route.test.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/route.test.ts @@ -1,122 +1,164 @@ /** * @vitest-environment node - * - * Public v2 single-row delete: goes through the row service so the delete lock - * and row-count bookkeeping are enforced, and renders lock/not-found in the v2 - * error envelope. */ + import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockCheckRateLimit, mockResolveWorkspaceScope, mockCheckAccess, mockPerformDeleteRow } = - vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceScope: vi.fn(), - mockCheckAccess: vi.fn(), - mockPerformDeleteRow: vi.fn(), - })) - -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceScope: mockResolveWorkspaceScope, -})) +const { mocks, MockTableRowsValidationError } = vi.hoisted(() => { + class MockTableRowsValidationError extends Error {} + return { + mocks: { + authenticate: vi.fn(), + preauthRate: vi.fn(), + operationRate: vi.fn(), + gate: vi.fn(), + readRow: vi.fn(), + updateRow: vi.fn(), + deleteRow: vi.fn(), + }, + MockTableRowsValidationError, + } +}) -vi.mock('@/app/api/table/utils', () => ({ - checkAccess: mockCheckAccess, - normalizeColumn: (col: Record) => col, - rootErrorMessage: (error: unknown) => String(error), - rowWriteErrorResponse: () => null, +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticate, + V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, })) - -vi.mock('@/lib/table', () => ({ - updateTable: vi.fn(), - getTableById: vi.fn(), - updateRow: vi.fn(), - rowDataNameToId: vi.fn(), - buildIdByName: vi.fn(), +vi.mock('@/lib/core/rate-limiter', () => ({ + RateLimiter: class { + checkRateLimitDirect = mocks.preauthRate + checkRateLimitDirectOrThrow = mocks.operationRate + }, + getRateLimit: () => ({ maxTokens: 100, refillRate: 100, refillIntervalMs: 60_000 }), })) - -vi.mock('@/lib/table/orchestration', () => ({ performDeleteTableRow: mockPerformDeleteRow })) - -vi.mock('@/app/api/v2/lib/gate', () => ({ - v2ApiGateError: vi.fn().mockResolvedValue(null), +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) +vi.mock('@/lib/table/application/rows', () => ({ + TableRowsValidationError: MockTableRowsValidationError, + readTableRow: { operation: { id: 'tables.rows.read' }, execute: mocks.readRow }, + updateTableRow: { operation: { id: 'tables.rows.update' }, execute: mocks.updateRow }, + deleteTableRow: { operation: { id: 'tables.rows.delete' }, execute: mocks.deleteRow }, })) -import { DELETE } from '@/app/api/v2/tables/[tableId]/rows/[rowId]/route' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { DELETE, GET, PATCH } from '@/app/api/v2/tables/[tableId]/rows/[rowId]/route' -const TABLE = { id: 'table-1', workspaceId: 'ws-1', schema: { columns: [] } } - -function callDelete() { - const req = new NextRequest( - 'http://localhost:3000/api/v2/tables/table-1/rows/row-1?workspaceId=ws-1', - { method: 'DELETE' } +const WORKSPACE_ID = 'workspace-1' +const PRINCIPAL = { + kind: 'workspace_api_key' as const, + workspaceId: WORKSPACE_ID, + keyId: 'key-1', +} +const AUTH = { + principal: PRINCIPAL, + rolloutUserId: 'owner-1', + rateLimitSubjectIds: [`workspace:${WORKSPACE_ID}`], + rateLimitSubscription: null, + keyType: 'workspace' as const, +} +const RATE = { + allowed: true, + remaining: 99, + resetAt: new Date('2026-01-01T01:00:00Z'), + retryAfterMs: 0, +} +const TABLE = { + id: 'table-1', + workspaceId: WORKSPACE_ID, + schema: { columns: [{ id: 'column-name', name: 'name', type: 'string' as const }] }, +} +const ROW = { + id: 'row-1', + data: { 'column-name': 'Ada' }, + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-02T00:00:00Z'), +} +const CONTEXT = { params: Promise.resolve({ tableId: 'table-1', rowId: 'row-1' }) } + +function request(method: 'GET' | 'PATCH' | 'DELETE', body?: unknown) { + return new NextRequest( + `http://localhost/api/v2/tables/table-1/rows/row-1${method === 'PATCH' ? '' : `?workspaceId=${WORKSPACE_ID}`}`, + { + method, + headers: { + 'x-api-key': 'secret', + ...(body === undefined ? {} : { 'content-type': 'application/json' }), + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + } ) - return DELETE(req, { params: Promise.resolve({ tableId: 'table-1', rowId: 'row-1' }) }) } -describe('DELETE /api/v2/tables/[tableId]/rows/[rowId]', () => { +describe('/api/v2/tables/[tableId]/rows/[rowId]', () => { beforeEach(() => { vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue({ - allowed: true, - userId: 'user-1', - keyType: 'workspace', - workspaceId: 'ws-1', - limit: 100, - remaining: 99, - resetAt: new Date('2026-01-01T01:00:00Z'), - }) - mockResolveWorkspaceScope.mockResolvedValue(null) - mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) - }) - - it('delegates to the orchestration function rather than deleting inline', async () => { - mockPerformDeleteRow.mockResolvedValue({ success: true }) - - const res = await callDelete() - - expect(res.status).toBe(200) - expect((await res.json()).data).toEqual({ deletedCount: 1, deletedRowIds: ['row-1'] }) - // The orchestration function routes through the row service, which applies - // the delete lock and the row-count decrement; the raw delete this replaced - // skipped both. - expect(mockPerformDeleteRow).toHaveBeenCalledWith( - expect.objectContaining({ table: TABLE, rowId: 'row-1' }) - ) + mocks.authenticate.mockResolvedValue(AUTH) + mocks.preauthRate.mockResolvedValue(RATE) + mocks.operationRate.mockResolvedValue(RATE) + mocks.gate.mockResolvedValue(null) + mocks.readRow.mockResolvedValue({ table: TABLE, row: ROW }) + mocks.updateRow.mockResolvedValue({ table: TABLE, row: ROW, changed: true }) + mocks.deleteRow.mockResolvedValue({ table: TABLE, deletedRowId: ROW.id }) }) - it.each([ - ['locked', 423, 'LOCKED'], - ['not_found', 404, 'NOT_FOUND'], - ])('maps a %s failure to %i', async (errorCode, status, code) => { - mockPerformDeleteRow.mockResolvedValue({ success: false, errorCode, error: 'nope' }) + it('reads through the shared use case and strips storage internals', async () => { + const req = request('GET') + const response = await GET(req, CONTEXT) - const res = await callDelete() - - expect(res.status).toBe(status) - expect((await res.json()).error.code).toBe(code) + expect(response.status).toBe(200) + expect((await response.json()).data.row).toEqual({ + id: 'row-1', + data: { name: 'Ada' }, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-02T00:00:00.000Z', + }) + expect(mocks.readRow).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { tableId: 'table-1', rowId: 'row-1', assertedWorkspaceId: WORKSPACE_ID }, + request: req, + }) }) - it('names the lock on a 423 that arrived as a classified outcome, not a throw', async () => { - mockPerformDeleteRow.mockResolvedValue({ - success: false, - errorCode: 'locked', - error: 'Row deletes are locked for this table', - lock: 'delete', + it('updates through the shared use case with the exact patch', async () => { + const req = request('PATCH', { workspaceId: WORKSPACE_ID, data: { name: 'Ada' } }) + const response = await PATCH(req, CONTEXT) + + expect(response.status).toBe(200) + expect(mocks.updateRow).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { + tableId: 'table-1', + rowId: 'row-1', + assertedWorkspaceId: WORKSPACE_ID, + data: { name: 'Ada' }, + }, + request: req, }) + }) - const res = await callDelete() + it('returns the compatible authoritative single-delete envelope', async () => { + const req = request('DELETE') + const response = await DELETE(req, CONTEXT) - expect(res.status).toBe(423) - expect((await res.json()).error.details).toEqual({ lock: 'delete' }) + expect(response.status).toBe(200) + expect((await response.json()).data).toEqual({ + deletedCount: 1, + deletedRowIds: ['row-1'], + }) + expect(mocks.deleteRow).toHaveBeenCalledWith( + expect.objectContaining({ + principal: PRINCIPAL, + input: expect.objectContaining({ tableId: 'table-1', rowId: 'row-1' }), + }) + ) }) - it('omits details entirely when the lock kind is unknown', async () => { - // A caller branching on `details.lock` should see absence, not a null. - mockPerformDeleteRow.mockResolvedValue({ success: false, errorCode: 'locked', error: 'nope' }) + it('conceals a forbidden canonical lookup as not found', async () => { + mocks.readRow.mockRejectedValue(new OrchestrationError('forbidden', 'Forbidden')) - const res = await callDelete() + const response = await GET(request('GET'), CONTEXT) - expect((await res.json()).error.details).toBeUndefined() + expect(response.status).toBe(404) + expect((await response.json()).error.code).toBe('NOT_FOUND') }) }) diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/route.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/route.ts index 8834f457a9e..08e6d9c6a2f 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/route.ts @@ -1,173 +1,66 @@ -import { db } from '@sim/db' -import { userTableRows } from '@sim/db/schema' -import { and, eq } from 'drizzle-orm' import { v2DeleteTableRowContract, v2GetTableRowContract, v2UpdateTableRowContract, } from '@/lib/api/contracts/v2/tables' -import { isZodError } from '@/lib/api/server' -import type { RowData, TableSchema } from '@/lib/table' -import { buildIdByName, rowDataNameToId, updateRow } from '@/lib/table' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2TableRowsErrorPolicy } from '@/lib/table/api/row-route-policies' +import { tableOperations } from '@/lib/table/application/operations' +import { deleteTableRow, readTableRow, updateTableRow } from '@/lib/table/application/rows' import { namedRowMapper } from '@/lib/table/cell-format' -import { performDeleteTableRow } from '@/lib/table/orchestration' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { checkAccess } from '@/app/api/table/utils' -import { resolveWorkspaceScope } from '@/app/api/v1/middleware' -import { - v2CaughtOrchestrationError, - v2Data, - v2Error, - v2ValidationError, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' -import { - toApiRow, - v2TableAccessError, - v2TableLockError, - v2TableOrchestrationError, -} from '@/app/api/v2/tables/utils' +import { toApiRow } from '@/app/api/v2/tables/utils' export const dynamic = 'force-dynamic' export const revalidate = 0 -/** GET /api/v2/tables/[tableId]/rows/[rowId] — Get a single row. */ -export const GET = withPublicApiRouteHandler({ +export const GET = defineV2JsonRoute({ contract: v2GetTableRowContract, - rateLimitEndpoint: 'table-row-detail', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const { tableId, rowId } = input.params - const { workspaceId } = input.query - - const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - - const result = await checkAccess(tableId, userId, 'read') - // Mask not-authorized and not-found alike so cross-workspace existence never leaks. - if (!result.ok) return v2Error('NOT_FOUND', 'Table not found') - - if (result.table.workspaceId !== workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } - - const [row] = await db - .select({ - id: userTableRows.id, - data: userTableRows.data, - createdAt: userTableRows.createdAt, - updatedAt: userTableRows.updatedAt, - }) - .from(userTableRows) - .where( - and( - eq(userTableRows.id, rowId), - eq(userTableRows.tableId, tableId), - eq(userTableRows.workspaceId, workspaceId) - ) - ) - .limit(1) - - if (!row) return v2Error('NOT_FOUND', 'Row not found') - - const toNamedRow = namedRowMapper((result.table.schema as TableSchema).columns) - return v2Data( - { - row: toApiRow( - { - id: row.id, - data: row.data as RowData, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - }, - toNamedRow - ), - }, - { rateLimit } - ) - }, + operation: tableOperations.readRow, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableRowsErrorPolicy, + mapInput: ({ params, query }) => ({ + tableId: params.tableId, + rowId: params.rowId, + assertedWorkspaceId: query.workspaceId, + }), + useCase: readTableRow, + present: ({ table, row }) => ({ + data: { row: toApiRow(row, namedRowMapper(table.schema.columns)) }, + }), }) -/** PATCH /api/v2/tables/[tableId]/rows/[rowId] — Partial update a single row. */ -export const PATCH = withPublicApiRouteHandler({ +export const PATCH = defineV2JsonRoute({ contract: v2UpdateTableRowContract, - rateLimitEndpoint: 'table-row-detail', - handler: async ({ input, auth: { requestId, userId, rateLimit } }) => { - try { - const { tableId, rowId } = input.params - const validated = input.body - - const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - - const result = await checkAccess(tableId, userId, 'write') - if (!result.ok) return v2TableAccessError(result) - - const { table } = result - if (table.workspaceId !== validated.workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } - - const idByName = buildIdByName(table.schema as TableSchema) - const toNamedRow = namedRowMapper((table.schema as TableSchema).columns) - const updatedRow = await updateRow( - { - tableId, - rowId, - data: rowDataNameToId(validated.data as RowData, idByName), - workspaceId: validated.workspaceId, - actorUserId: userId, - }, - table, - requestId - ) - // No `cancellationGuard` is passed, so `updateRow` can't return null here. - // Defensive narrowing for TypeScript. - if (!updatedRow) return v2Error('NOT_FOUND', 'Row not found') - - return v2Data({ row: toApiRow(updatedRow, toNamedRow) }, { rateLimit }) - } catch (error) { - if (isZodError(error)) return v2ValidationError(error) - - const classified = v2CaughtOrchestrationError(error) - if (classified) return classified - - throw error - } - }, + operation: tableOperations.updateRow, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableRowsErrorPolicy, + mapInput: ({ params, body }) => ({ + tableId: params.tableId, + rowId: params.rowId, + assertedWorkspaceId: body.workspaceId, + data: body.data, + }), + useCase: updateTableRow, + present: ({ table, row }) => ({ + data: { row: toApiRow(row, namedRowMapper(table.schema.columns)) }, + }), }) -/** DELETE /api/v2/tables/[tableId]/rows/[rowId] — Delete a single row. */ -export const DELETE = withPublicApiRouteHandler({ +export const DELETE = defineV2JsonRoute({ contract: v2DeleteTableRowContract, - rateLimitEndpoint: 'table-row-detail', - handler: async ({ input, auth: { requestId, userId, rateLimit } }) => { - try { - const { tableId, rowId } = input.params - const { workspaceId } = input.query - - const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - - const result = await checkAccess(tableId, userId, 'write') - if (!result.ok) return v2TableAccessError(result) - - if (result.table.workspaceId !== workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } - - const outcome = await performDeleteTableRow({ table: result.table, rowId, requestId }) - if (!outcome.success) { - return v2TableOrchestrationError(outcome, 'Failed to delete row') - } - - // v2 mirrors the bulk delete shape: always returns `deletedRowIds`. - return v2Data({ deletedCount: 1, deletedRowIds: [rowId] }, { rateLimit }) - } catch (error) { - const lockError = v2TableLockError(error) - if (lockError) return lockError - const classified = v2CaughtOrchestrationError(error) - if (classified) return classified - throw error - } - }, + operation: tableOperations.deleteRow, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableRowsErrorPolicy, + mapInput: ({ params, query }) => ({ + tableId: params.tableId, + rowId: params.rowId, + assertedWorkspaceId: query.workspaceId, + }), + useCase: deleteTableRow, + present: ({ deletedRowId }) => ({ + data: { deletedCount: 1, deletedRowIds: [deletedRowId] }, + }), }) diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/find/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/find/route.test.ts index 19f38dfb59f..e86f657c272 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/rows/find/route.test.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/find/route.test.ts @@ -1,207 +1,135 @@ /** * @vitest-environment node - * - * Public v2 row lookup. The wire is column-NAME keyed both ways: the predicate - * and sort translate down to storage ids on the way in, and the matched column - * id translates back to its name on the way out. */ + import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockCheckRateLimit, - mockResolveWorkspaceScope, - mockCheckAccess, - mockFindRowMatches, - mockPredicateToFilter, - mockValidateSortSpec, - mockSortSpecNamesToIds, - mockGateError, - TableQueryValidationError, -} = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceScope: vi.fn(), - mockCheckAccess: vi.fn(), - mockFindRowMatches: vi.fn(), - mockPredicateToFilter: vi.fn(), - mockValidateSortSpec: vi.fn(), - mockSortSpecNamesToIds: vi.fn(), - mockGateError: vi.fn(), - TableQueryValidationError: class TableQueryValidationError extends Error {}, -})) +const { mocks, MockTableRowsValidationError } = vi.hoisted(() => { + class MockTableRowsValidationError extends Error {} + return { + mocks: { + authenticate: vi.fn(), + preauthRate: vi.fn(), + operationRate: vi.fn(), + gate: vi.fn(), + findRows: vi.fn(), + }, + MockTableRowsValidationError, + } +}) -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceScope: mockResolveWorkspaceScope, +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticate, + V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, })) - -vi.mock('@/app/api/table/utils', () => ({ - checkAccess: mockCheckAccess, - normalizeColumn: (col: Record) => col, - rootErrorMessage: (error: unknown) => String(error), - rowWriteErrorResponse: () => null, +vi.mock('@/lib/core/rate-limiter', () => ({ + RateLimiter: class { + checkRateLimitDirect = mocks.preauthRate + checkRateLimitDirectOrThrow = mocks.operationRate + }, + getRateLimit: () => ({ maxTokens: 100, refillRate: 100, refillIntervalMs: 60_000 }), })) - -vi.mock('@/app/api/v2/tables/utils', async (importOriginal) => ({ - ...(await importOriginal>()), - v2BulkPredicateToFilter: mockPredicateToFilter, +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) +vi.mock('@/lib/table/application/rows', () => ({ + TableRowsValidationError: MockTableRowsValidationError, + findTableRows: { operation: { id: 'tables.rows.find' }, execute: mocks.findRows }, })) -vi.mock('@/lib/table', () => ({ - buildIdByName: vi.fn().mockReturnValue({ status: 'col-1', name: 'col-2' }), - sortSpecNamesToIds: mockSortSpecNamesToIds, -})) -vi.mock('@/lib/table/rows/service', () => ({ findRowMatches: mockFindRowMatches })) -vi.mock('@/lib/table/query-builder/validate', () => ({ validateSortSpec: mockValidateSortSpec })) -vi.mock('@/lib/table/errors', () => ({ TableQueryValidationError })) -vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGateError })) - import { POST } from '@/app/api/v2/tables/[tableId]/rows/find/route' -const COLUMNS = [ - { id: 'col-1', name: 'status', type: 'string' }, - { id: 'col-2', name: 'name', type: 'string' }, -] -const TABLE = { id: 'table-1', workspaceId: 'ws-1', schema: { columns: COLUMNS } } - -const RATE_LIMIT_OK = { +const WORKSPACE_ID = 'workspace-1' +const PRINCIPAL = { + kind: 'workspace_api_key' as const, + workspaceId: WORKSPACE_ID, + keyId: 'key-1', +} +const AUTH = { + principal: PRINCIPAL, + rolloutUserId: 'owner-1', + rateLimitSubjectIds: [`workspace:${WORKSPACE_ID}`], + rateLimitSubscription: null, + keyType: 'workspace' as const, +} +const RATE = { allowed: true, - userId: 'user-1', - keyType: 'workspace', - workspaceId: 'ws-1', - limit: 100, remaining: 99, resetAt: new Date('2026-01-01T01:00:00Z'), + retryAfterMs: 0, +} +const TABLE = { + id: 'table-1', + workspaceId: WORKSPACE_ID, + schema: { columns: [{ id: 'column-name', name: 'name', type: 'string' as const }] }, } -function callPost(body: unknown) { - const req = new NextRequest('http://localhost:3000/api/v2/tables/table-1/rows/find', { +function call(body: unknown) { + const request = new NextRequest('http://localhost/api/v2/tables/table-1/rows/find', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'content-type': 'application/json', 'x-api-key': 'secret' }, body: JSON.stringify(body), }) - return POST(req, { params: Promise.resolve({ tableId: 'table-1' }) }) + return { + request, + response: POST(request, { params: Promise.resolve({ tableId: 'table-1' }) }), + } } describe('POST /api/v2/tables/[tableId]/rows/find', () => { beforeEach(() => { vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceScope.mockResolvedValue(null) - mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) - mockFindRowMatches.mockResolvedValue({ - matches: [{ ordinal: 3, rowId: 'row-1', column: 'col-2' }], - truncated: false, + mocks.authenticate.mockResolvedValue(AUTH) + mocks.preauthRate.mockResolvedValue(RATE) + mocks.operationRate.mockResolvedValue(RATE) + mocks.gate.mockResolvedValue(null) + mocks.findRows.mockResolvedValue({ + table: TABLE, + matches: [{ ordinal: 3, rowId: 'row-1', column: 'column-name' }], + truncated: true, }) - mockSortSpecNamesToIds.mockImplementation((spec: { field: string }[]) => - spec.map((s) => ({ ...s, field: s.field === 'name' ? 'col-2' : s.field })) - ) - mockGateError.mockResolvedValue(null) }) - it('reports the matched column by NAME, not its storage id', async () => { - const res = await callPost({ workspaceId: 'ws-1', q: 'acme' }) - - expect(res.status).toBe(200) - expect((await res.json()).data).toEqual({ - matches: [{ ordinal: 3, rowId: 'row-1', column: 'name' }], - truncated: false, + it('delegates the bounded lookup and presents column names', async () => { + const predicate = { all: [{ field: 'name', op: 'eq', value: 'Ada' }] } + const sort = [{ field: 'name', direction: 'asc' }] + const invocation = call({ workspaceId: WORKSPACE_ID, q: 'ada', predicate, sort }) + const response = await invocation.response + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + data: { + matches: [{ ordinal: 3, rowId: 'row-1', column: 'name' }], + truncated: true, + }, }) - expect(mockFindRowMatches).toHaveBeenCalledWith( - TABLE, - { q: 'acme', filter: undefined, sort: undefined }, - expect.any(String) - ) - }) - - it('translates the predicate and sort to storage keys before searching', async () => { - mockPredicateToFilter.mockReturnValue({ 'col-1': { $eq: 'active' } }) - const predicate = { all: [{ field: 'status', op: 'eq', value: 'active' }] } - - const res = await callPost({ - workspaceId: 'ws-1', - q: 'acme', - predicate, - sort: [{ field: 'name', direction: 'asc' }], + expect(mocks.findRows).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { + tableId: 'table-1', + assertedWorkspaceId: WORKSPACE_ID, + q: 'ada', + predicate, + sort, + }, + request: invocation.request, }) - - expect(res.status).toBe(200) - expect(mockPredicateToFilter).toHaveBeenCalledWith(predicate, TABLE.schema) - expect(mockValidateSortSpec).toHaveBeenCalledWith( - [{ field: 'name', direction: 'asc' }], - COLUMNS - ) - expect(mockFindRowMatches).toHaveBeenCalledWith( - TABLE, - { q: 'acme', filter: { 'col-1': { $eq: 'active' } }, sort: { 'col-2': 'asc' } }, - expect.any(String) - ) }) - it('surfaces truncation so a caller narrows instead of paging', async () => { - mockFindRowMatches.mockResolvedValue({ matches: [], truncated: true }) + it('rejects an empty search after admission and before delegation', async () => { + const response = await call({ workspaceId: WORKSPACE_ID, q: '' }).response - const res = await callPost({ workspaceId: 'ws-1', q: 'a' }) - - expect((await res.json()).data).toEqual({ matches: [], truncated: true }) + expect(response.status).toBe(400) + expect(mocks.authenticate).toHaveBeenCalledOnce() + expect(mocks.findRows).not.toHaveBeenCalled() }) - it('400s an unresolvable predicate field instead of returning zero matches', async () => { - mockPredicateToFilter.mockImplementation(() => { - throw new TableQueryValidationError('Unknown column "nope"') - }) - - const res = await callPost({ - workspaceId: 'ws-1', - q: 'acme', - predicate: { all: [{ field: 'nope', op: 'eq', value: 1 }] }, - }) - - expect(res.status).toBe(400) - expect(mockFindRowMatches).not.toHaveBeenCalled() - }) - - it('400s an empty search string', async () => { - const res = await callPost({ workspaceId: 'ws-1', q: '' }) - - expect(res.status).toBe(400) - expect(mockFindRowMatches).not.toHaveBeenCalled() - }) - - it('masks a permission failure as 404 so table existence never leaks', async () => { - mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) - - const res = await callPost({ workspaceId: 'ws-1', q: 'acme' }) - - expect(res.status).toBe(404) - expect(mockFindRowMatches).not.toHaveBeenCalled() - }) - - it('404s with the gate off, before any work', async () => { - mockGateError.mockResolvedValue( - new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Not found' } }), { - status: 404, - }) - ) - - const res = await callPost({ workspaceId: 'ws-1', q: 'acme' }) - - expect(res.status).toBe(404) - expect(mockCheckAccess).not.toHaveBeenCalled() - expect(mockFindRowMatches).not.toHaveBeenCalled() - }) - - it('429s a throttled caller', async () => { - mockCheckRateLimit.mockResolvedValue({ - ...RATE_LIMIT_OK, - allowed: false, - remaining: 0, - retryAfterMs: 1000, - }) + it('stops at the rollout gate before the shared use case', async () => { + const { v2Error } = await import('@/app/api/v2/lib/response') + mocks.gate.mockResolvedValue(v2Error('NOT_FOUND', 'Not found')) - const res = await callPost({ workspaceId: 'ws-1', q: 'acme' }) + const response = await call({ workspaceId: WORKSPACE_ID, q: 'ada' }).response - expect(res.status).toBe(429) - expect(mockFindRowMatches).not.toHaveBeenCalled() + expect(response.status).toBe(404) + expect(mocks.findRows).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/find/route.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/find/route.ts index 18ebe72ea1f..0dfac4b811c 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/rows/find/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/find/route.ts @@ -1,90 +1,38 @@ import { v2FindTableRowsContract } from '@/lib/api/contracts/v2/tables' -import { isZodError } from '@/lib/api/server' -import type { Filter, Sort, TableSchema } from '@/lib/table' -import { buildIdByName, sortSpecNamesToIds } from '@/lib/table' -import { TableQueryValidationError } from '@/lib/table/errors' -import { validateSortSpec } from '@/lib/table/query-builder/validate' -import { findRowMatches } from '@/lib/table/rows/service' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { checkAccess } from '@/app/api/table/utils' -import { resolveWorkspaceScope } from '@/app/api/v1/middleware' -import { - v2Data, - v2Error, - v2ValidationError, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' -import { columnNameById, v2BulkPredicateToFilter } from '@/app/api/v2/tables/utils' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2TableRowsErrorPolicy } from '@/lib/table/api/row-route-policies' +import { tableOperations } from '@/lib/table/application/operations' +import { findTableRows } from '@/lib/table/application/rows' +import { columnNameById } from '@/app/api/v2/tables/utils' export const dynamic = 'force-dynamic' export const revalidate = 0 -/** - * POST /api/v2/tables/[tableId]/rows/find — Case-insensitive substring search - * across every cell, narrowed by the same predicate/sort grammar as - * `POST /query`. - * - * Returns matching CELLS, not rows: each match carries the row's ordinal in the - * same filtered+sorted view a `POST /query` with these arguments would return, - * so a caller can jump straight to the page holding it. - */ -export const POST = withPublicApiRouteHandler({ +export const POST = defineV2JsonRoute({ contract: v2FindTableRowsContract, - rateLimitEndpoint: 'table-rows-find', - handler: async ({ input, auth: { requestId, userId, rateLimit } }) => { - try { - const { tableId } = input.params - const { workspaceId, q, predicate, sort } = input.body - - const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - - const accessResult = await checkAccess(tableId, userId, 'read') - // Mask not-authorized and not-found alike so cross-workspace existence never leaks. - if (!accessResult.ok || accessResult.table.workspaceId !== workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } - - const { table } = accessResult - const schema = table.schema as TableSchema - - // The public wire is column-NAME keyed both ways: translate the predicate - // and sort down to storage ids on the way in, and the matched column id - // back to its name on the way out. - let filter: Filter | undefined - if (predicate) filter = v2BulkPredicateToFilter(predicate, schema) - - let sortObj: Sort | undefined - if (sort?.length) { - validateSortSpec(sort, schema.columns) - const storageSort = sortSpecNamesToIds(sort, buildIdByName(schema)) - sortObj = Object.fromEntries(storageSort.map((s) => [s.field, s.direction])) - } - - const { matches, truncated } = await findRowMatches( - table, - { q, filter, sort: sortObj }, - requestId - ) - - const toColumnName = columnNameById(schema) - - return v2Data( - { - matches: matches.map((match) => ({ - ordinal: match.ordinal, - rowId: match.rowId, - column: toColumnName(match.column), - })), - truncated, - }, - { rateLimit } - ) - } catch (error) { - if (isZodError(error)) return v2ValidationError(error) - if (error instanceof TableQueryValidationError) return v2Error('BAD_REQUEST', error.message) - - throw error + operation: tableOperations.findRows, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableRowsErrorPolicy, + mapInput: ({ params, body }) => ({ + tableId: params.tableId, + assertedWorkspaceId: body.workspaceId, + q: body.q, + predicate: body.predicate, + sort: body.sort, + }), + useCase: findTableRows, + present: ({ table, matches, truncated }) => { + const toColumnName = columnNameById(table.schema) + return { + data: { + matches: matches.map((match) => ({ + ordinal: match.ordinal, + rowId: match.rowId, + column: toColumnName(match.column), + })), + truncated, + }, } }, }) diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/route.test.ts new file mode 100644 index 00000000000..11ea0842bfb --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/route.test.ts @@ -0,0 +1,190 @@ +/** + * @vitest-environment node + */ + +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mocks, MockTableRowsValidationError } = vi.hoisted(() => { + class MockTableRowsValidationError extends Error {} + return { + mocks: { + authenticate: vi.fn(), + preauthRate: vi.fn(), + operationRate: vi.fn(), + gate: vi.fn(), + listRows: vi.fn(), + createRows: vi.fn(), + updateRows: vi.fn(), + deleteRows: vi.fn(), + }, + MockTableRowsValidationError, + } +}) + +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticate, + V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, +})) +vi.mock('@/lib/core/rate-limiter', () => ({ + RateLimiter: class { + checkRateLimitDirect = mocks.preauthRate + checkRateLimitDirectOrThrow = mocks.operationRate + }, + getRateLimit: () => ({ maxTokens: 100, refillRate: 100, refillIntervalMs: 60_000 }), +})) +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) +vi.mock('@/lib/table/application/rows', () => ({ + TableRowsValidationError: MockTableRowsValidationError, + listTableRows: { operation: { id: 'tables.rows.list' }, execute: mocks.listRows }, + createTableRows: { operation: { id: 'tables.rows.create' }, execute: mocks.createRows }, + updateTableRows: { operation: { id: 'tables.rows.update_many' }, execute: mocks.updateRows }, + deleteTableRows: { operation: { id: 'tables.rows.delete_many' }, execute: mocks.deleteRows }, +})) + +import { DELETE, GET, POST, PUT } from '@/app/api/v2/tables/[tableId]/rows/route' + +const WORKSPACE_ID = 'workspace-1' +const PRINCIPAL = { + kind: 'workspace_api_key' as const, + workspaceId: WORKSPACE_ID, + keyId: 'key-1', +} +const AUTH = { + principal: PRINCIPAL, + rolloutUserId: 'owner-1', + rateLimitSubjectIds: [`workspace:${WORKSPACE_ID}`], + rateLimitSubscription: null, + keyType: 'workspace' as const, +} +const RATE = { + allowed: true, + remaining: 99, + resetAt: new Date('2026-01-01T01:00:00Z'), + retryAfterMs: 0, +} +const TABLE = { + id: 'table-1', + workspaceId: WORKSPACE_ID, + schema: { columns: [{ id: 'column-name', name: 'name', type: 'string' as const }] }, +} +const ROW = { + id: 'row-1', + data: { 'column-name': 'Ada' }, + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-02T00:00:00Z'), +} +const CONTEXT = { params: Promise.resolve({ tableId: 'table-1' }) } + +function request(method: 'GET' | 'POST' | 'PUT' | 'DELETE', body?: unknown, query = '') { + return new NextRequest(`http://localhost/api/v2/tables/table-1/rows${query}`, { + method, + headers: { + 'x-api-key': 'secret', + ...(body === undefined ? {} : { 'content-type': 'application/json' }), + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }) +} + +describe('/api/v2/tables/[tableId]/rows', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.authenticate.mockResolvedValue(AUTH) + mocks.preauthRate.mockResolvedValue(RATE) + mocks.operationRate.mockResolvedValue(RATE) + mocks.gate.mockResolvedValue(null) + mocks.listRows.mockResolvedValue({ table: TABLE, rows: [ROW], nextOffset: null }) + mocks.createRows.mockResolvedValue({ kind: 'single', table: TABLE, row: ROW }) + mocks.updateRows.mockResolvedValue({ + table: TABLE, + affectedCount: 1, + affectedRowIds: ['row-1'], + }) + mocks.deleteRows.mockResolvedValue({ + kind: 'ids', + table: TABLE, + deletedCount: 1, + deletedRowIds: ['row-1'], + requestedCount: 2, + missingRowIds: ['row-2'], + }) + }) + + it('retains malformed GET cursor fallback compatibility', async () => { + const req = request('GET', undefined, `?workspaceId=${WORKSPACE_ID}&limit=25&cursor=malformed`) + const response = await GET(req, CONTEXT) + + expect(response.status).toBe(200) + expect(mocks.listRows).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { + tableId: 'table-1', + assertedWorkspaceId: WORKSPACE_ID, + limit: 25, + offset: 0, + }, + request: req, + }) + }) + + it('delegates single and batch creation through one semantic use case', async () => { + const single = request('POST', { workspaceId: WORKSPACE_ID, data: { name: 'Ada' } }) + expect((await (await POST(single, CONTEXT)).json()).data.row.id).toBe('row-1') + expect(mocks.createRows).toHaveBeenLastCalledWith({ + principal: PRINCIPAL, + input: { + kind: 'single', + tableId: 'table-1', + assertedWorkspaceId: WORKSPACE_ID, + data: { name: 'Ada' }, + }, + request: single, + }) + + mocks.createRows.mockResolvedValue({ kind: 'batch', table: TABLE, rows: [ROW] }) + const batch = request('POST', { workspaceId: WORKSPACE_ID, rows: [{ name: 'Ada' }] }) + expect((await (await POST(batch, CONTEXT)).json()).data.insertedCount).toBe(1) + expect(mocks.createRows).toHaveBeenLastCalledWith({ + principal: PRINCIPAL, + input: { + kind: 'batch', + tableId: 'table-1', + assertedWorkspaceId: WORKSPACE_ID, + rows: [{ name: 'Ada' }], + }, + request: batch, + }) + }) + + it('preserves authoritative bulk update counts including a zero-match result', async () => { + mocks.updateRows.mockResolvedValue({ table: TABLE, affectedCount: 0, affectedRowIds: [] }) + const req = request('PUT', { + workspaceId: WORKSPACE_ID, + filter: { all: [{ field: 'name', op: 'eq', value: 'missing' }] }, + data: { name: 'Grace' }, + }) + const response = await PUT(req, CONTEXT) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ data: { updatedCount: 0, updatedRowIds: [] } }) + }) + + it('preserves id-delete requested and missing-row reporting', async () => { + const req = request('DELETE', { + workspaceId: WORKSPACE_ID, + rowIds: ['row-1', 'row-2'], + }) + const response = await DELETE(req, CONTEXT) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + data: { + deletedCount: 1, + deletedRowIds: ['row-1'], + requestedCount: 2, + missingRowIds: ['row-2'], + }, + }) + }) +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/route.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/route.ts index 0be13bffdb4..d597c5bdc8e 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/rows/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/route.ts @@ -1,342 +1,136 @@ -import type { NextResponse } from 'next/server' -import type { V1BatchInsertTableRowsBody } from '@/lib/api/contracts/v1/tables' import { v2CreateTableRowsContract, v2DeleteTableRowsContract, v2ListTableRowsContract, v2UpdateRowsByFilterContract, } from '@/lib/api/contracts/v2/tables' -import { isZodError } from '@/lib/api/server' -import type { RowData, TableSchema } from '@/lib/table' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2TableRowsErrorPolicy } from '@/lib/table/api/row-route-policies' +import { tableOperations } from '@/lib/table/application/operations' import { - batchInsertRows, - buildIdByName, - deleteRowsByFilter, - deleteRowsByIds, - insertRow, - rowDataNameToId, - updateRowsByFilter, - validateBatchRows, - validateRowData, - validateRowSize, -} from '@/lib/table' + createTableRows, + deleteTableRows, + listTableRows, + updateTableRows, +} from '@/lib/table/application/rows' import { namedRowMapper } from '@/lib/table/cell-format' -import { TableQueryValidationError } from '@/lib/table/errors' -import { queryRows } from '@/lib/table/rows/service' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { checkAccess } from '@/app/api/table/utils' -import { type RateLimitResult, resolveWorkspaceScope } from '@/app/api/v1/middleware' -import { - decodeCursor, - encodeCursor, - v2CursorList, - v2Data, - v2Error, - v2ValidationError, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' -import { - toApiRow, - v2BulkPredicateToFilter, - v2RowValidationError, - v2RowWriteError, - v2TableAccessError, -} from '@/app/api/v2/tables/utils' +import { decodeCursor, encodeCursor } from '@/app/api/v2/lib/response' +import { toApiRow } from '@/app/api/v2/tables/utils' export const dynamic = 'force-dynamic' export const revalidate = 0 -/** - * Inserts a validated batch of rows. Authorizes against the table's own - * workspace (IDOR guard) before any write, translates name-keyed row data to - * storage ids, and returns the inserted rows in the canonical v2 envelope. - */ -async function handleBatchInsert( - requestId: string, - tableId: string, - validated: V1BatchInsertTableRowsBody, - userId: string, - rateLimit: RateLimitResult -): Promise { - const accessResult = await checkAccess(tableId, userId, 'write') - if (!accessResult.ok) return v2TableAccessError(accessResult) - - const { table } = accessResult - if (validated.workspaceId !== table.workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } - - // External callers key row data by column name; storage keys by id. - const idByName = buildIdByName(table.schema as TableSchema) - const toNamedRow = namedRowMapper((table.schema as TableSchema).columns) - const rows = (validated.rows as RowData[]).map((r) => rowDataNameToId(r, idByName)) - - const validation = await validateBatchRows({ - rows, - schema: table.schema as TableSchema, - tableId, - }) - if (!validation.valid) return v2RowValidationError(validation.response) - - try { - const insertedRows = await batchInsertRows( - { tableId, rows, workspaceId: validated.workspaceId, userId }, - table, - requestId - ) - - return v2Data( - { - rows: insertedRows.map((r) => toApiRow(r, toNamedRow)), - insertedCount: insertedRows.length, - }, - { rateLimit } - ) - } catch (error) { - const response = v2RowWriteError(error) - if (response) return response - - throw error - } -} - -/** - * GET /api/v2/tables/[tableId]/rows — Plain cursor page over the default row - * order. Filtered/sorted reads go through `POST /query`. - */ -export const GET = withPublicApiRouteHandler({ +export const GET = defineV2JsonRoute({ contract: v2ListTableRowsContract, - rateLimitEndpoint: 'table-rows', - handler: async ({ input, auth: { requestId, userId, rateLimit } }) => { - try { - const { tableId } = input.params - const validated = input.query - - const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - - const accessResult = await checkAccess(tableId, userId, 'read') - // Mask not-authorized and not-found alike so cross-workspace existence never leaks. - if (!accessResult.ok) return v2Error('NOT_FOUND', 'Table not found') - - const { table } = accessResult - if (validated.workspaceId !== table.workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } - - const toNamedRow = namedRowMapper((table.schema as TableSchema).columns) - - // Cursor-uniform v2 pagination: the opaque cursor encodes the underlying - // offset (upgradeable to keyset later without an interface change). Total row - // count is intentionally omitted here — it's available as `rowCount` on the table. - const offset = validated.cursor - ? (decodeCursor<{ offset: number }>(validated.cursor)?.offset ?? 0) - : 0 - - const result = await queryRows( - table, - { - limit: validated.limit, - offset, - includeTotal: true, - withExecutions: false, - }, - requestId - ) - - const total = result.totalCount ?? 0 - const hasMore = offset + result.rowCount < total - const nextCursor = hasMore ? encodeCursor({ offset: offset + validated.limit }) : null - - return v2CursorList( - result.rows.map((r) => toApiRow(r, toNamedRow)), - nextCursor, - { rateLimit } - ) - } catch (error) { - if (isZodError(error)) return v2ValidationError(error) - if (error instanceof TableQueryValidationError) return v2Error('BAD_REQUEST', error.message) - - throw error + operation: tableOperations.listRows, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableRowsErrorPolicy, + mapInput: ({ params, query }) => ({ + tableId: params.tableId, + assertedWorkspaceId: query.workspaceId, + limit: query.limit, + offset: query.cursor ? (decodeCursor<{ offset: number }>(query.cursor)?.offset ?? 0) : 0, + }), + useCase: listTableRows, + present: ({ table, rows, nextOffset }) => { + const toNamedRow = namedRowMapper(table.schema.columns) + return { + data: rows.map((row) => toApiRow(row, toNamedRow)), + nextCursor: nextOffset === null ? null : encodeCursor({ offset: nextOffset }), } }, }) -/** POST /api/v2/tables/[tableId]/rows — Insert row(s). Supports single or batch. */ -export const POST = withPublicApiRouteHandler({ +export const POST = defineV2JsonRoute({ contract: v2CreateTableRowsContract, - rateLimitEndpoint: 'table-rows', - handler: async ({ input, auth: { requestId, userId, rateLimit } }) => { - try { - const { tableId } = input.params - - if ('rows' in input.body) { - const batchValidated = input.body - const scopeError = await resolveWorkspaceScope(rateLimit, batchValidated.workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - return handleBatchInsert(requestId, tableId, batchValidated, userId, rateLimit) - } - - const validated = input.body - const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - - const accessResult = await checkAccess(tableId, userId, 'write') - if (!accessResult.ok) return v2TableAccessError(accessResult) - - const { table } = accessResult - if (validated.workspaceId !== table.workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } - - const idByName = buildIdByName(table.schema as TableSchema) - const toNamedRow = namedRowMapper((table.schema as TableSchema).columns) - const rowData = rowDataNameToId(validated.data as RowData, idByName) - - const validation = await validateRowData({ - rowData, - schema: table.schema as TableSchema, - tableId, - }) - if (!validation.valid) return v2RowValidationError(validation.response) - - const row = await insertRow( - { tableId, data: rowData, workspaceId: validated.workspaceId, userId }, - table, - requestId - ) - - return v2Data({ row: toApiRow(row, toNamedRow) }, { rateLimit }) - } catch (error) { - if (isZodError(error)) return v2ValidationError(error) - - const response = v2RowWriteError(error) - if (response) return response - - throw error - } + operation: tableOperations.createRows, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableRowsErrorPolicy, + mapInput: ({ params, body }) => + 'rows' in body + ? { + kind: 'batch' as const, + tableId: params.tableId, + assertedWorkspaceId: body.workspaceId, + rows: body.rows, + } + : { + kind: 'single' as const, + tableId: params.tableId, + assertedWorkspaceId: body.workspaceId, + data: body.data, + }, + useCase: createTableRows, + present: (result) => { + const toNamedRow = namedRowMapper(result.table.schema.columns) + return result.kind === 'single' + ? { data: { row: toApiRow(result.row, toNamedRow) } } + : { + data: { + rows: result.rows.map((row) => toApiRow(row, toNamedRow)), + insertedCount: result.rows.length, + }, + } }, }) -/** PUT /api/v2/tables/[tableId]/rows — Bulk update rows by predicate filter. */ -export const PUT = withPublicApiRouteHandler({ +export const PUT = defineV2JsonRoute({ contract: v2UpdateRowsByFilterContract, - rateLimitEndpoint: 'table-rows', - handler: async ({ input, auth: { requestId, userId, rateLimit } }) => { - try { - const { tableId } = input.params - const validated = input.body - - const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - - const accessResult = await checkAccess(tableId, userId, 'write') - if (!accessResult.ok) return v2TableAccessError(accessResult) - - const { table } = accessResult - if (validated.workspaceId !== table.workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } - - const idByName = buildIdByName(table.schema as TableSchema) - const patchData = rowDataNameToId(validated.data as RowData, idByName) - - const sizeValidation = validateRowSize(patchData) - if (!sizeValidation.valid) { - return v2Error('BAD_REQUEST', 'Invalid row data', { details: sizeValidation.errors }) - } - - const result = await updateRowsByFilter( - table, - { - filter: v2BulkPredicateToFilter(validated.filter, table.schema as TableSchema), - data: patchData, - limit: validated.limit, - actorUserId: userId, - }, - requestId - ) - - // v2 always returns `updatedRowIds` ([] when nothing matched); v1 dropped it - // on the zero-match branch. - return v2Data( - { updatedCount: result.affectedCount, updatedRowIds: result.affectedRowIds }, - { rateLimit } - ) - } catch (error) { - if (isZodError(error)) return v2ValidationError(error) - if (error instanceof TableQueryValidationError) return v2Error('BAD_REQUEST', error.message) - - const response = v2RowWriteError(error) - if (response) return response - - throw error - } - }, + operation: tableOperations.updateRows, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableRowsErrorPolicy, + mapInput: ({ params, body }) => ({ + tableId: params.tableId, + assertedWorkspaceId: body.workspaceId, + filter: body.filter, + data: body.data, + limit: body.limit, + }), + useCase: updateTableRows, + present: ({ affectedCount, affectedRowIds }) => ({ + data: { updatedCount: affectedCount, updatedRowIds: affectedRowIds }, + }), }) -/** DELETE /api/v2/tables/[tableId]/rows — Delete rows by predicate filter or IDs. */ -export const DELETE = withPublicApiRouteHandler({ +export const DELETE = defineV2JsonRoute({ contract: v2DeleteTableRowsContract, - rateLimitEndpoint: 'table-rows', - handler: async ({ input, auth: { requestId, userId, rateLimit } }) => { - try { - const { tableId } = input.params - const validated = input.body - - const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - - const accessResult = await checkAccess(tableId, userId, 'write') - if (!accessResult.ok) return v2TableAccessError(accessResult) - - const { table } = accessResult - if (validated.workspaceId !== table.workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } - - // id-based and filter-based deletes share one envelope; `requestedCount`/ - // `missingRowIds` are populated only for the id-based delete (which has a - // requested set) and omitted for the filter-based delete. - if (validated.rowIds) { - const result = await deleteRowsByIds( - table, - { tableId, rowIds: validated.rowIds, workspaceId: validated.workspaceId }, - requestId - ) - - return v2Data( - { + operation: tableOperations.deleteRows, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableRowsErrorPolicy, + mapInput: ({ params, body }) => + body.rowIds + ? { + kind: 'ids' as const, + tableId: params.tableId, + assertedWorkspaceId: body.workspaceId, + rowIds: body.rowIds, + } + : { + kind: 'filter' as const, + tableId: params.tableId, + assertedWorkspaceId: body.workspaceId, + filter: body.filter!, + limit: body.limit, + }, + useCase: deleteTableRows, + present: (result) => + result.kind === 'ids' + ? { + data: { deletedCount: result.deletedCount, deletedRowIds: result.deletedRowIds, requestedCount: result.requestedCount, missingRowIds: result.missingRowIds, }, - { rateLimit } - ) - } - - const result = await deleteRowsByFilter( - table, - { - filter: v2BulkPredicateToFilter(validated.filter!, table.schema as TableSchema), - limit: validated.limit, + } + : { + data: { + deletedCount: result.affectedCount, + deletedRowIds: result.affectedRowIds, + }, }, - requestId - ) - - return v2Data( - { deletedCount: result.affectedCount, deletedRowIds: result.affectedRowIds }, - { rateLimit } - ) - } catch (error) { - if (isZodError(error)) return v2ValidationError(error) - if (error instanceof TableQueryValidationError) return v2Error('BAD_REQUEST', error.message) - - const response = v2RowWriteError(error) - if (response) return response - - throw error - } - }, }) diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/upsert/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/upsert/route.test.ts new file mode 100644 index 00000000000..ddcd5106649 --- /dev/null +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/upsert/route.test.ts @@ -0,0 +1,137 @@ +/** + * @vitest-environment node + */ + +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mocks, MockTableRowsValidationError } = vi.hoisted(() => { + class MockTableRowsValidationError extends Error {} + return { + mocks: { + authenticate: vi.fn(), + preauthRate: vi.fn(), + operationRate: vi.fn(), + gate: vi.fn(), + upsertRow: vi.fn(), + }, + MockTableRowsValidationError, + } +}) + +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticate, + V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, +})) +vi.mock('@/lib/core/rate-limiter', () => ({ + RateLimiter: class { + checkRateLimitDirect = mocks.preauthRate + checkRateLimitDirectOrThrow = mocks.operationRate + }, + getRateLimit: () => ({ maxTokens: 100, refillRate: 100, refillIntervalMs: 60_000 }), +})) +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) +vi.mock('@/lib/table/application/rows', () => ({ + TableRowsValidationError: MockTableRowsValidationError, + upsertTableRow: { operation: { id: 'tables.rows.upsert' }, execute: mocks.upsertRow }, +})) + +import { POST } from '@/app/api/v2/tables/[tableId]/rows/upsert/route' + +const WORKSPACE_ID = 'workspace-1' +const PRINCIPAL = { + kind: 'workspace_api_key' as const, + workspaceId: WORKSPACE_ID, + keyId: 'key-1', +} +const AUTH = { + principal: PRINCIPAL, + rolloutUserId: 'owner-1', + rateLimitSubjectIds: [`workspace:${WORKSPACE_ID}`], + rateLimitSubscription: null, + keyType: 'workspace' as const, +} +const RATE = { + allowed: true, + remaining: 99, + resetAt: new Date('2026-01-01T01:00:00Z'), + retryAfterMs: 0, +} +const TABLE = { + id: 'table-1', + workspaceId: WORKSPACE_ID, + schema: { columns: [{ id: 'column-email', name: 'email', type: 'string' as const }] }, +} +const ROW = { + id: 'row-1', + data: { 'column-email': 'ada@example.com' }, + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-02T00:00:00Z'), +} + +describe('POST /api/v2/tables/[tableId]/rows/upsert', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.authenticate.mockResolvedValue(AUTH) + mocks.preauthRate.mockResolvedValue(RATE) + mocks.operationRate.mockResolvedValue(RATE) + mocks.gate.mockResolvedValue(null) + mocks.upsertRow.mockResolvedValue({ table: TABLE, row: ROW, operation: 'update' }) + }) + + it('delegates the public conflict-target name unchanged for canonical ID resolution', async () => { + const request = new NextRequest('http://localhost/api/v2/tables/table-1/rows/upsert', { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-api-key': 'secret' }, + body: JSON.stringify({ + workspaceId: WORKSPACE_ID, + data: { email: 'ada@example.com' }, + conflictTarget: 'email', + }), + }) + const response = await POST(request, { + params: Promise.resolve({ tableId: 'table-1' }), + }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + data: { + row: { + id: 'row-1', + data: { email: 'ada@example.com' }, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-02T00:00:00.000Z', + }, + operation: 'update', + }, + }) + expect(mocks.upsertRow).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { + tableId: 'table-1', + assertedWorkspaceId: WORKSPACE_ID, + data: { email: 'ada@example.com' }, + conflictTarget: 'email', + }, + request, + }) + }) + + it('rejects an empty conflict target before delegation', async () => { + const request = new NextRequest('http://localhost/api/v2/tables/table-1/rows/upsert', { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-api-key': 'secret' }, + body: JSON.stringify({ + workspaceId: WORKSPACE_ID, + data: { email: 'ada@example.com' }, + conflictTarget: '', + }), + }) + const response = await POST(request, { + params: Promise.resolve({ tableId: 'table-1' }), + }) + + expect(response.status).toBe(400) + expect(mocks.upsertRow).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/upsert/route.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/upsert/route.ts index 346b8a74b15..6c2d84285de 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/rows/upsert/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/upsert/route.ts @@ -1,68 +1,31 @@ import { v2UpsertTableRowContract } from '@/lib/api/contracts/v2/tables' -import { isZodError } from '@/lib/api/server' -import type { RowData, TableSchema } from '@/lib/table' -import { buildIdByName, rowDataNameToId, upsertRow } from '@/lib/table' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2TableRowsErrorPolicy } from '@/lib/table/api/row-route-policies' +import { tableOperations } from '@/lib/table/application/operations' +import { upsertTableRow } from '@/lib/table/application/rows' import { namedRowMapper } from '@/lib/table/cell-format' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { checkAccess } from '@/app/api/table/utils' -import { resolveWorkspaceScope } from '@/app/api/v1/middleware' -import { - v2CaughtOrchestrationError, - v2Data, - v2Error, - v2ValidationError, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' -import { toApiRow, v2TableAccessError } from '@/app/api/v2/tables/utils' +import { toApiRow } from '@/app/api/v2/tables/utils' export const dynamic = 'force-dynamic' export const revalidate = 0 -/** POST /api/v2/tables/[tableId]/rows/upsert — Insert or update a row based on unique columns. */ -export const POST = withPublicApiRouteHandler({ +export const POST = defineV2JsonRoute({ contract: v2UpsertTableRowContract, - rateLimitEndpoint: 'table-rows', - handler: async ({ input, auth: { requestId, userId, rateLimit } }) => { - try { - const { tableId } = input.params - const validated = input.body - - const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - - const result = await checkAccess(tableId, userId, 'write') - if (!result.ok) return v2TableAccessError(result) - - const { table } = result - if (table.workspaceId !== validated.workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } - - const idByName = buildIdByName(table.schema as TableSchema) - const toNamedRow = namedRowMapper((table.schema as TableSchema).columns) - const upsertResult = await upsertRow( - { - tableId, - workspaceId: validated.workspaceId, - data: rowDataNameToId(validated.data as RowData, idByName), - userId, - conflictTarget: validated.conflictTarget, - }, - table, - requestId - ) - - return v2Data( - { row: toApiRow(upsertResult.row, toNamedRow), operation: upsertResult.operation }, - { rateLimit } - ) - } catch (error) { - if (isZodError(error)) return v2ValidationError(error) - - const classified = v2CaughtOrchestrationError(error) - if (classified) return classified - - throw error - } - }, + operation: tableOperations.upsertRow, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableRowsErrorPolicy, + mapInput: ({ params, body }) => ({ + tableId: params.tableId, + assertedWorkspaceId: body.workspaceId, + data: body.data, + conflictTarget: body.conflictTarget, + }), + useCase: upsertTableRow, + present: ({ table, row, operation }) => ({ + data: { + row: toApiRow(row, namedRowMapper(table.schema.columns)), + operation, + }, + }), }) diff --git a/apps/sim/app/api/v2/tables/[tableId]/views/[viewId]/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/views/[viewId]/route.test.ts index be8a5e6bd33..eb69a72d64d 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/views/[viewId]/route.test.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/views/[viewId]/route.test.ts @@ -1,252 +1,125 @@ /** * @vitest-environment node - * - * Public v2 saved-view detail: read, patch, delete. A view that is not on this - * table is a 404 rather than a silent no-op, so a caller can tell a wrong id - * from a successful write. */ + import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockCheckRateLimit, - mockResolveWorkspaceScope, - mockCheckAccess, - mockGetTableView, - mockUpdateTableView, - mockDeleteTableView, - mockGateError, - mockGetRequiredUserEmail, - TableViewValidationError, -} = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceScope: vi.fn(), - mockCheckAccess: vi.fn(), - mockGetTableView: vi.fn(), - mockUpdateTableView: vi.fn(), - mockDeleteTableView: vi.fn(), - mockGateError: vi.fn(), - mockGetRequiredUserEmail: vi.fn(), - TableViewValidationError: class TableViewValidationError extends Error {}, -})) - -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceScope: mockResolveWorkspaceScope, +const mocks = vi.hoisted(() => ({ + authenticate: vi.fn(), + preauthRate: vi.fn(), + operationRate: vi.fn(), + gate: vi.fn(), + read: vi.fn(), + update: vi.fn(), + remove: vi.fn(), + email: vi.fn(), })) -vi.mock('@/app/api/table/utils', () => ({ - checkAccess: mockCheckAccess, - normalizeColumn: (col: Record) => col, - rootErrorMessage: (error: unknown) => String(error), - rowWriteErrorResponse: () => null, +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticate, + V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, })) - -vi.mock('@/lib/table', () => ({ - getTableView: mockGetTableView, - updateTableView: mockUpdateTableView, - deleteTableView: mockDeleteTableView, - TableViewValidationError, +vi.mock('@/lib/core/rate-limiter', () => ({ + RateLimiter: class { + checkRateLimitDirect = mocks.preauthRate + checkRateLimitDirectOrThrow = mocks.operationRate + }, + getRateLimit: () => ({ maxTokens: 100, refillRate: 100, refillIntervalMs: 60_000 }), })) - -vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGateError })) - -vi.mock('@/lib/users/queries', () => ({ - getRequiredUserEmail: mockGetRequiredUserEmail, +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) +vi.mock('@/lib/table/application/views', () => ({ + readTableViewUseCase: { operation: { id: 'tables.views.read' }, execute: mocks.read }, + updateTableViewUseCase: { operation: { id: 'tables.views.update' }, execute: mocks.update }, + deleteTableViewUseCase: { operation: { id: 'tables.views.delete' }, execute: mocks.remove }, })) +vi.mock('@/lib/users/queries', () => ({ getRequiredUserEmail: mocks.email })) import { DELETE, GET, PATCH } from '@/app/api/v2/tables/[tableId]/views/[viewId]/route' -const COLUMNS = [{ id: 'col-1', name: 'status', type: 'string' }] -const TABLE = { id: 'table-1', workspaceId: 'ws-1', schema: { columns: COLUMNS } } -const VIEW = { - id: 'view-1', - tableId: 'table-1', - name: 'Active', - config: {}, - isDefault: false, - createdBy: 'user-1', - createdAt: new Date('2026-01-01T00:00:00Z'), - updatedAt: new Date('2026-01-02T00:00:00Z'), +const WORKSPACE_ID = 'workspace-1' +const principal = { + kind: 'workspace_api_key' as const, + workspaceId: WORKSPACE_ID, + keyId: 'key-1', } -const API_VIEW = { - id: VIEW.id, - tableId: VIEW.tableId, - name: VIEW.name, - config: VIEW.config, - isDefault: VIEW.isDefault, - createdByEmail: 'ada@example.com', - createdAt: '2026-01-01T00:00:00.000Z', - updatedAt: '2026-01-02T00:00:00.000Z', +const auth = { + principal, + rolloutUserId: 'owner-1', + rateLimitSubjectIds: [`workspace:${WORKSPACE_ID}`], + rateLimitSubscription: null, + keyType: 'workspace' as const, } - -const RATE_LIMIT_OK = { +const rate = { allowed: true, - userId: 'user-1', - keyType: 'workspace', - workspaceId: 'ws-1', - limit: 100, remaining: 99, - resetAt: new Date('2026-01-01T01:00:00Z'), -} - -const params = { params: Promise.resolve({ tableId: 'table-1', viewId: 'view-1' }) } - -function callGet() { - return GET( - new NextRequest('http://localhost:3000/api/v2/tables/table-1/views/view-1?workspaceId=ws-1', { - method: 'GET', - }), - params - ) + resetAt: new Date('2026-01-01T01:00:00.000Z'), + retryAfterMs: 0, } - -function callPatch(body: unknown) { - return PATCH( - new NextRequest('http://localhost:3000/api/v2/tables/table-1/views/view-1', { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }), - params - ) +const view = { + id: 'view-1', + tableId: 'table-1', + name: 'Active', + config: {}, + isDefault: true, + createdBy: 'user-1', + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), } - -function callDelete() { - return DELETE( - new NextRequest('http://localhost:3000/api/v2/tables/table-1/views/view-1?workspaceId=ws-1', { - method: 'DELETE', - }), - params +const context = { params: Promise.resolve({ tableId: 'table-1', viewId: 'view-1' }) } + +function request(method: 'GET' | 'PATCH' | 'DELETE', body?: unknown) { + return new NextRequest( + `http://localhost:3000/api/v2/tables/table-1/views/view-1${method === 'PATCH' ? '' : `?workspaceId=${WORKSPACE_ID}`}`, + { + method, + headers: { 'x-api-key': 'secret', ...(body ? { 'content-type': 'application/json' } : {}) }, + ...(body ? { body: JSON.stringify(body) } : {}), + } ) } -beforeEach(() => { - vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceScope.mockResolvedValue(null) - mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) - mockGateError.mockResolvedValue(null) - mockGetRequiredUserEmail.mockResolvedValue('ada@example.com') -}) - -describe('GET /api/v2/tables/[tableId]/views/[viewId]', () => { - it('returns the view scoped to its table', async () => { - mockGetTableView.mockResolvedValue(VIEW) - - const res = await callGet() - - expect(res.status).toBe(200) - expect((await res.json()).data).toEqual({ view: API_VIEW }) - expect(mockGetTableView).toHaveBeenCalledWith('view-1', 'table-1', COLUMNS) - }) - - it('404s a view id that belongs to a different table', async () => { - mockGetTableView.mockResolvedValue(null) - - const res = await callGet() - - expect(res.status).toBe(404) - expect((await res.json()).error.message).toBe('View not found') - }) - - it('429s a throttled caller', async () => { - mockCheckRateLimit.mockResolvedValue({ - ...RATE_LIMIT_OK, - allowed: false, - remaining: 0, - retryAfterMs: 1000, - }) - - const res = await callGet() - - expect(res.status).toBe(429) - expect(mockGetTableView).not.toHaveBeenCalled() +describe('/api/v2/tables/[tableId]/views/[viewId]', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.authenticate.mockResolvedValue(auth) + mocks.preauthRate.mockResolvedValue(rate) + mocks.operationRate.mockResolvedValue(rate) + mocks.gate.mockResolvedValue(null) + mocks.read.mockResolvedValue({ view }) + mocks.update.mockResolvedValue({ view, changed: false }) + mocks.remove.mockResolvedValue({ viewId: 'view-1' }) + mocks.email.mockResolvedValue('user@example.com') }) -}) -describe('PATCH /api/v2/tables/[tableId]/views/[viewId]', () => { - it('forwards the patch fields to the service', async () => { - mockUpdateTableView.mockResolvedValue({ ...VIEW, isDefault: true }) + it('reads the view through canonical table and view identities', async () => { + const req = request('GET') + const response = await GET(req, context) - const res = await callPatch({ workspaceId: 'ws-1', isDefault: true }) - - expect(res.status).toBe(200) - expect((await res.json()).data.view.isDefault).toBe(true) - expect(mockUpdateTableView).toHaveBeenCalledWith({ - viewId: 'view-1', - tableId: 'table-1', - name: undefined, - config: undefined, - configPatch: undefined, - isDefault: true, - columns: COLUMNS, + expect(response.status).toBe(200) + expect((await response.json()).data.view.id).toBe('view-1') + expect(mocks.read).toHaveBeenCalledWith({ + principal, + input: { tableId: 'table-1', viewId: 'view-1', workspaceId: WORKSPACE_ID }, + request: req, }) }) - it('400s a body that changes nothing', async () => { - const res = await callPatch({ workspaceId: 'ws-1' }) - - expect(res.status).toBe(400) - expect(mockUpdateTableView).not.toHaveBeenCalled() - }) - - it('400s config and configPatch together', async () => { - const res = await callPatch({ workspaceId: 'ws-1', config: {}, configPatch: {} }) - - expect(res.status).toBe(400) - expect(mockUpdateTableView).not.toHaveBeenCalled() - }) - - it('403s a read-only member', async () => { - mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) - - const res = await callPatch({ workspaceId: 'ws-1', isDefault: true }) - - expect(res.status).toBe(403) - expect(mockUpdateTableView).not.toHaveBeenCalled() - }) - - it('404s with the gate off, before any work', async () => { - mockGateError.mockResolvedValue( - new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Not found' } }), { - status: 404, - }) + it('preserves no-op PATCH response compatibility', async () => { + const response = await PATCH( + request('PATCH', { workspaceId: WORKSPACE_ID, name: 'Active' }), + context ) - const res = await callPatch({ workspaceId: 'ws-1', isDefault: true }) - - expect(res.status).toBe(404) - expect(mockCheckAccess).not.toHaveBeenCalled() - expect(mockUpdateTableView).not.toHaveBeenCalled() + expect(response.status).toBe(200) + expect((await response.json()).data.view.name).toBe('Active') }) -}) - -describe('DELETE /api/v2/tables/[tableId]/views/[viewId]', () => { - it('returns the deleted view id', async () => { - mockDeleteTableView.mockResolvedValue(true) - - const res = await callDelete() - - expect(res.status).toBe(200) - expect((await res.json()).data).toEqual({ id: 'view-1' }) - expect(mockDeleteTableView).toHaveBeenCalledWith('view-1', 'table-1') - }) - - it('404s when nothing was deleted rather than reporting a phantom success', async () => { - mockDeleteTableView.mockResolvedValue(false) - - const res = await callDelete() - - expect(res.status).toBe(404) - }) - - it('403s a read-only member', async () => { - mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) - const res = await callDelete() + it('deletes through the authorized view use case', async () => { + const response = await DELETE(request('DELETE'), context) - expect(res.status).toBe(403) - expect(mockDeleteTableView).not.toHaveBeenCalled() + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ data: { id: 'view-1' } }) + expect(mocks.remove).toHaveBeenCalledOnce() }) }) diff --git a/apps/sim/app/api/v2/tables/[tableId]/views/[viewId]/route.ts b/apps/sim/app/api/v2/tables/[tableId]/views/[viewId]/route.ts index 0df7c7243c9..64de962a819 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/views/[viewId]/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/views/[viewId]/route.ts @@ -3,118 +3,58 @@ import { v2GetTableViewContract, v2UpdateTableViewContract, } from '@/lib/api/contracts/v2/tables' -import type { TableSchema } from '@/lib/table' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2TableErrorPolicies } from '@/lib/table/api' +import { tableOperations } from '@/lib/table/application/operations' import { - deleteTableView, - getTableView, - TableViewValidationError, - updateTableView, -} from '@/lib/table' + deleteTableViewUseCase, + readTableViewUseCase, + updateTableViewUseCase, +} from '@/lib/table/application/views' import { getRequiredUserEmail } from '@/lib/users/queries' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { checkAccess } from '@/app/api/table/utils' -import { resolveWorkspaceScope } from '@/app/api/v1/middleware' -import { v2Data, v2Error, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' -import { toApiView, v2TableAccessError } from '@/app/api/v2/tables/utils' +import { toApiView } from '@/app/api/v2/tables/utils' export const dynamic = 'force-dynamic' export const revalidate = 0 -/** GET /api/v2/tables/[tableId]/views/[viewId] — One saved view. */ -export const GET = withPublicApiRouteHandler({ - contract: v2GetTableViewContract, - rateLimitEndpoint: 'table-view-detail', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const { tableId, viewId } = input.params - const { workspaceId } = input.query - - const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - - const result = await checkAccess(tableId, userId, 'read') - // Mask not-authorized and not-found alike so cross-workspace existence never leaks. - if (!result.ok || result.table.workspaceId !== workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } - - const view = await getTableView(viewId, tableId, (result.table.schema as TableSchema).columns) - if (!view) return v2Error('NOT_FOUND', 'View not found') +async function presentView(result: { view: Parameters[0] }) { + const { view } = result + return { + data: { + view: toApiView(view, view.createdBy ? await getRequiredUserEmail(view.createdBy) : null), + }, + } +} - return v2Data( - { view: toApiView(view, view.createdBy ? await getRequiredUserEmail(view.createdBy) : null) }, - { rateLimit } - ) - }, +export const GET = defineV2JsonRoute({ + contract: v2GetTableViewContract, + operation: tableOperations.readView, + useCase: readTableViewUseCase, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableErrorPolicies.concealTableAuthorization, + mapInput: ({ params, query }) => ({ ...params, workspaceId: query.workspaceId }), + present: presentView, }) -/** - * PATCH /api/v2/tables/[tableId]/views/[viewId] — Rename, replace or merge the - * config, or promote the view to the table's default. - */ -export const PATCH = withPublicApiRouteHandler({ +export const PATCH = defineV2JsonRoute({ contract: v2UpdateTableViewContract, - rateLimitEndpoint: 'table-view-detail', - handler: async ({ input, auth: { userId, rateLimit } }) => { - try { - const { tableId, viewId } = input.params - const { workspaceId, name, config, configPatch, isDefault } = input.body - - const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - - const result = await checkAccess(tableId, userId, 'write') - if (!result.ok) return v2TableAccessError(result) - - if (result.table.workspaceId !== workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } - - const view = await updateTableView({ - viewId, - tableId, - name, - config, - configPatch, - isDefault, - columns: (result.table.schema as TableSchema).columns, - }) - if (!view) return v2Error('NOT_FOUND', 'View not found') - - return v2Data( - { - view: toApiView(view, view.createdBy ? await getRequiredUserEmail(view.createdBy) : null), - }, - { rateLimit } - ) - } catch (error) { - if (error instanceof TableViewValidationError) return v2Error('BAD_REQUEST', error.message) - - throw error - } - }, + operation: tableOperations.updateView, + useCase: updateTableViewUseCase, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableErrorPolicies.default, + mapInput: ({ params, body }) => ({ ...params, ...body }), + present: presentView, }) -/** DELETE /api/v2/tables/[tableId]/views/[viewId] — Remove a saved view. */ -export const DELETE = withPublicApiRouteHandler({ +export const DELETE = defineV2JsonRoute({ contract: v2DeleteTableViewContract, - rateLimitEndpoint: 'table-view-detail', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const { tableId, viewId } = input.params - const { workspaceId } = input.query - - const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - - const result = await checkAccess(tableId, userId, 'write') - if (!result.ok) return v2TableAccessError(result) - - if (result.table.workspaceId !== workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } - - const deleted = await deleteTableView(viewId, tableId) - if (!deleted) return v2Error('NOT_FOUND', 'View not found') - - return v2Data({ id: viewId }, { rateLimit }) - }, + operation: tableOperations.deleteView, + useCase: deleteTableViewUseCase, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableErrorPolicies.default, + mapInput: ({ params, query }) => ({ ...params, workspaceId: query.workspaceId }), + present: ({ viewId }) => ({ data: { id: viewId } }), }) diff --git a/apps/sim/app/api/v2/tables/[tableId]/views/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/views/route.test.ts index 82ca191255e..0244927dcdf 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/views/route.test.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/views/route.test.ts @@ -1,221 +1,132 @@ /** * @vitest-environment node - * - * Public v2 saved views: list and create. A view is presentation state, so the - * read needs only `read` while saving one needs `write`. */ + import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockCheckRateLimit, - mockResolveWorkspaceScope, - mockCheckAccess, - mockListTableViews, - mockCreateTableView, - mockGateError, - mockGetUserEmailsByIds, - mockGetRequiredUserEmail, - TableViewValidationError, -} = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceScope: vi.fn(), - mockCheckAccess: vi.fn(), - mockListTableViews: vi.fn(), - mockCreateTableView: vi.fn(), - mockGateError: vi.fn(), - mockGetUserEmailsByIds: vi.fn(), - mockGetRequiredUserEmail: vi.fn(), - TableViewValidationError: class TableViewValidationError extends Error {}, +const mocks = vi.hoisted(() => ({ + authenticate: vi.fn(), + preauthRate: vi.fn(), + operationRate: vi.fn(), + gate: vi.fn(), + list: vi.fn(), + create: vi.fn(), + emails: vi.fn(), + email: vi.fn(), })) -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceScope: mockResolveWorkspaceScope, +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticate, + V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, })) - -vi.mock('@/app/api/table/utils', () => ({ - checkAccess: mockCheckAccess, - normalizeColumn: (col: Record) => col, - rootErrorMessage: (error: unknown) => String(error), - rowWriteErrorResponse: () => null, +vi.mock('@/lib/core/rate-limiter', () => ({ + RateLimiter: class { + checkRateLimitDirect = mocks.preauthRate + checkRateLimitDirectOrThrow = mocks.operationRate + }, + getRateLimit: () => ({ maxTokens: 100, refillRate: 100, refillIntervalMs: 60_000 }), })) - -vi.mock('@/lib/table', () => ({ - listTableViews: mockListTableViews, - createTableView: mockCreateTableView, - TableViewValidationError, +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) +vi.mock('@/lib/table/application/views', () => ({ + listTableViewsUseCase: { operation: { id: 'tables.views.list' }, execute: mocks.list }, + createTableViewUseCase: { operation: { id: 'tables.views.create' }, execute: mocks.create }, })) - -vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGateError })) - vi.mock('@/lib/users/queries', () => ({ - getUserEmailsByIds: mockGetUserEmailsByIds, - getRequiredUserEmail: mockGetRequiredUserEmail, - requireResolvedUserEmail: (emails: Map, userId: string) => emails.get(userId)!, + getUserEmailsByIds: mocks.emails, + getRequiredUserEmail: mocks.email, + requireResolvedUserEmail: (emails: Map, userId: string) => emails.get(userId), })) import { GET, POST } from '@/app/api/v2/tables/[tableId]/views/route' -const COLUMNS = [{ id: 'col-1', name: 'status', type: 'string' }] -const TABLE = { id: 'table-1', workspaceId: 'ws-1', schema: { columns: COLUMNS } } -const VIEW = { - id: 'view-1', - tableId: 'table-1', - name: 'Active', - config: { filter: { all: [{ field: 'col-1', op: 'eq', value: 'active' }] } }, - isDefault: true, - createdBy: 'user-1', - createdAt: new Date('2026-01-01T00:00:00Z'), - updatedAt: new Date('2026-01-02T00:00:00Z'), +const WORKSPACE_ID = 'workspace-1' +const principal = { + kind: 'workspace_api_key' as const, + workspaceId: WORKSPACE_ID, + keyId: 'key-1', } -const API_VIEW = { - id: VIEW.id, - tableId: VIEW.tableId, - name: VIEW.name, - config: VIEW.config, - isDefault: VIEW.isDefault, - createdByEmail: 'ada@example.com', - createdAt: '2026-01-01T00:00:00.000Z', - updatedAt: '2026-01-02T00:00:00.000Z', +const auth = { + principal, + rolloutUserId: 'owner-1', + rateLimitSubjectIds: [`workspace:${WORKSPACE_ID}`], + rateLimitSubscription: null, + keyType: 'workspace' as const, } - -const RATE_LIMIT_OK = { +const rate = { allowed: true, - userId: 'user-1', - keyType: 'workspace', - workspaceId: 'ws-1', - limit: 100, remaining: 99, - resetAt: new Date('2026-01-01T01:00:00Z'), + resetAt: new Date('2026-01-01T01:00:00.000Z'), + retryAfterMs: 0, } - -function callGet() { - const req = new NextRequest( - 'http://localhost:3000/api/v2/tables/table-1/views?workspaceId=ws-1', - { method: 'GET' } - ) - return GET(req, { params: Promise.resolve({ tableId: 'table-1' }) }) -} - -function callPost(body: unknown) { - const req = new NextRequest('http://localhost:3000/api/v2/tables/table-1/views', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }) - return POST(req, { params: Promise.resolve({ tableId: 'table-1' }) }) +const view = { + id: 'view-1', + tableId: 'table-1', + name: 'Active', + config: {}, + isDefault: false, + createdBy: 'user-1', + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), } - -beforeEach(() => { - vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceScope.mockResolvedValue(null) - mockCheckAccess.mockResolvedValue({ ok: true, table: TABLE }) - mockGateError.mockResolvedValue(null) - mockGetUserEmailsByIds.mockResolvedValue(new Map([['user-1', 'ada@example.com']])) - mockGetRequiredUserEmail.mockResolvedValue('ada@example.com') -}) - -describe('GET /api/v2/tables/[tableId]/views', () => { - it('returns every view as one full page with ISO timestamps', async () => { - mockListTableViews.mockResolvedValue([VIEW]) - - const res = await callGet() - - expect(res.status).toBe(200) - expect(await res.json()).toEqual({ data: [API_VIEW], nextCursor: null }) - // The columns are passed so stale references are pruned from each config. - expect(mockListTableViews).toHaveBeenCalledWith('table-1', COLUMNS) +const context = { params: Promise.resolve({ tableId: 'table-1' }) } + +describe('/api/v2/tables/[tableId]/views', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.authenticate.mockResolvedValue(auth) + mocks.preauthRate.mockResolvedValue(rate) + mocks.operationRate.mockResolvedValue(rate) + mocks.gate.mockResolvedValue(null) + mocks.list.mockResolvedValue({ views: [view] }) + mocks.create.mockResolvedValue({ view }) + mocks.emails.mockResolvedValue(new Map([['user-1', 'user@example.com']])) + mocks.email.mockResolvedValue('user@example.com') }) - it('404s a table in another workspace without listing', async () => { - mockCheckAccess.mockResolvedValue({ ok: true, table: { ...TABLE, workspaceId: 'ws-other' } }) - - const res = await callGet() - - expect(res.status).toBe(404) - expect(mockListTableViews).not.toHaveBeenCalled() - }) - - it('masks a permission failure as 404 so table existence never leaks', async () => { - mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) - - const res = await callGet() - - expect(res.status).toBe(404) - expect(mockListTableViews).not.toHaveBeenCalled() - }) - - it('429s a throttled caller', async () => { - mockCheckRateLimit.mockResolvedValue({ - ...RATE_LIMIT_OK, - allowed: false, - remaining: 0, - retryAfterMs: 1000, + it('lists bounded views and resolves creator identities in the v2 presenter', async () => { + const req = new NextRequest( + `http://localhost:3000/api/v2/tables/table-1/views?workspaceId=${WORKSPACE_ID}` + ) + const response = await GET(req, context) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + data: [ + { + id: 'view-1', + tableId: 'table-1', + name: 'Active', + config: {}, + isDefault: false, + createdByEmail: 'user@example.com', + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }, + ], + nextCursor: null, }) - - const res = await callGet() - - expect(res.status).toBe(429) - expect(mockListTableViews).not.toHaveBeenCalled() - }) -}) - -describe('POST /api/v2/tables/[tableId]/views', () => { - it('creates the view with the caller as author and answers 201', async () => { - mockCreateTableView.mockResolvedValue(VIEW) - - const res = await callPost({ workspaceId: 'ws-1', name: 'Active', config: {} }) - - expect(res.status).toBe(201) - expect((await res.json()).data).toEqual({ view: API_VIEW }) - expect(mockCreateTableView).toHaveBeenCalledWith({ - tableId: 'table-1', - workspaceId: 'ws-1', - name: 'Active', - config: {}, - userId: 'user-1', - columns: COLUMNS, + expect(mocks.list).toHaveBeenCalledWith({ + principal, + input: { tableId: 'table-1', workspaceId: WORKSPACE_ID }, + request: req, }) }) - it('400s a blank view name without touching the service', async () => { - const res = await callPost({ workspaceId: 'ws-1', name: ' ', config: {} }) - - expect(res.status).toBe(400) - expect(mockCreateTableView).not.toHaveBeenCalled() - }) - - it('403s a read-only member', async () => { - mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) - - const res = await callPost({ workspaceId: 'ws-1', name: 'Active', config: {} }) - - expect(res.status).toBe(403) - expect(mockCreateTableView).not.toHaveBeenCalled() - }) - - it('404s with the gate off, before any work', async () => { - mockGateError.mockResolvedValue( - new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Not found' } }), { - status: 404, - }) - ) - - const res = await callPost({ workspaceId: 'ws-1', name: 'Active', config: {} }) - - expect(res.status).toBe(404) - expect(mockCheckAccess).not.toHaveBeenCalled() - expect(mockCreateTableView).not.toHaveBeenCalled() - }) - - it('surfaces a service-level view validation failure as 400', async () => { - mockCreateTableView.mockRejectedValue(new TableViewValidationError('View name cannot be empty')) - - const res = await callPost({ workspaceId: 'ws-1', name: 'Active', config: {} }) - - expect(res.status).toBe(400) - expect((await res.json()).error.message).toBe('View name cannot be empty') + it('creates through the authorized view use case and preserves 201', async () => { + const req = new NextRequest('http://localhost:3000/api/v2/tables/table-1/views', { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-api-key': 'secret' }, + body: JSON.stringify({ workspaceId: WORKSPACE_ID, name: 'Active', config: {} }), + }) + const response = await POST(req, context) + + expect(response.status).toBe(201) + expect((await response.json()).data.view.createdByEmail).toBe('user@example.com') + expect(mocks.create).toHaveBeenCalledWith({ + principal, + input: { tableId: 'table-1', workspaceId: WORKSPACE_ID, name: 'Active', config: {} }, + request: req, + }) }) }) diff --git a/apps/sim/app/api/v2/tables/[tableId]/views/route.ts b/apps/sim/app/api/v2/tables/[tableId]/views/route.ts index 841a5c8947e..cf87f47068f 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/views/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/views/route.ts @@ -1,98 +1,53 @@ import { v2CreateTableViewContract, v2ListTableViewsContract } from '@/lib/api/contracts/v2/tables' -import type { TableSchema } from '@/lib/table' -import { createTableView, listTableViews, TableViewValidationError } from '@/lib/table' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2TableErrorPolicies } from '@/lib/table/api' +import { tableOperations } from '@/lib/table/application/operations' +import { createTableViewUseCase, listTableViewsUseCase } from '@/lib/table/application/views' import { getRequiredUserEmail, getUserEmailsByIds, requireResolvedUserEmail, } from '@/lib/users/queries' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { checkAccess } from '@/app/api/table/utils' -import { resolveWorkspaceScope } from '@/app/api/v1/middleware' -import { v2CursorList, v2Data, v2Error, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' -import { toApiView, v2TableAccessError } from '@/app/api/v2/tables/utils' +import { toApiView } from '@/app/api/v2/tables/utils' export const dynamic = 'force-dynamic' export const revalidate = 0 -/** - * GET /api/v2/tables/[tableId]/views — Every saved view on the table. - * - * A table carries a bounded set of views, so this is one full page and - * `nextCursor` is always `null`. - */ -export const GET = withPublicApiRouteHandler({ +export const GET = defineV2JsonRoute({ contract: v2ListTableViewsContract, - rateLimitEndpoint: 'table-views', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const { tableId } = input.params - const { workspaceId } = input.query - - const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - - const result = await checkAccess(tableId, userId, 'read') - // Mask not-authorized and not-found alike so cross-workspace existence never leaks. - if (!result.ok || result.table.workspaceId !== workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } - - const views = await listTableViews(tableId, (result.table.schema as TableSchema).columns) - + operation: tableOperations.listViews, + useCase: listTableViewsUseCase, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableErrorPolicies.concealTableAuthorization, + mapInput: ({ params, query }) => ({ tableId: params.tableId, workspaceId: query.workspaceId }), + present: async ({ views }) => { const emailByUserId = await getUserEmailsByIds( views.flatMap((view) => (view.createdBy ? [view.createdBy] : [])) ) - return v2CursorList( - views.map((view) => + return { + data: views.map((view) => toApiView( view, view.createdBy ? requireResolvedUserEmail(emailByUserId, view.createdBy) : null ) ), - null, - { rateLimit } - ) + nextCursor: null, + } }, }) -/** POST /api/v2/tables/[tableId]/views — Save a filter/sort/layout as a named view. */ -export const POST = withPublicApiRouteHandler({ +export const POST = defineV2JsonRoute({ contract: v2CreateTableViewContract, - rateLimitEndpoint: 'table-views', - handler: async ({ input, auth: { userId, rateLimit } }) => { - try { - const { tableId } = input.params - const { workspaceId, name, config } = input.body - - const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - - const result = await checkAccess(tableId, userId, 'write') - if (!result.ok) return v2TableAccessError(result) - - if (result.table.workspaceId !== workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } - - const view = await createTableView({ - tableId, - workspaceId, - name, - config, - userId, - columns: (result.table.schema as TableSchema).columns, - }) - - return v2Data( - { - view: toApiView(view, view.createdBy ? await getRequiredUserEmail(view.createdBy) : null), - }, - { rateLimit, status: 201 } - ) - } catch (error) { - if (error instanceof TableViewValidationError) return v2Error('BAD_REQUEST', error.message) - - throw error - } - }, + operation: tableOperations.createView, + useCase: createTableViewUseCase, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableErrorPolicies.default, + mapInput: ({ params, body }) => ({ tableId: params.tableId, ...body }), + present: async ({ view }) => ({ + data: { + view: toApiView(view, view.createdBy ? await getRequiredUserEmail(view.createdBy) : null), + }, + }), }) diff --git a/apps/sim/app/api/v2/tables/exports/[exportId]/download/route.ts b/apps/sim/app/api/v2/tables/exports/[exportId]/download/route.ts index d6902239d8f..88c00c6a9e1 100644 --- a/apps/sim/app/api/v2/tables/exports/[exportId]/download/route.ts +++ b/apps/sim/app/api/v2/tables/exports/[exportId]/download/route.ts @@ -1,49 +1,22 @@ import { v2TableExportDownloadContract } from '@/lib/api/contracts/v2/tables' -import { requireTableExport, tableExportResult } from '@/lib/table/orchestration/export-resource' -import { generatePresignedDownloadUrl } from '@/lib/uploads/core/storage-service' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { checkAccess } from '@/app/api/table/utils' -import { resolveWorkspaceScope } from '@/app/api/v1/middleware' -import { - v2CaughtOrchestrationError, - v2Data, - v2Error, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2TableErrorPolicies } from '@/lib/table/api' +import { downloadTableExportUseCase } from '@/lib/table/application/exports' +import { tableOperations } from '@/lib/table/application/operations' -const DOWNLOAD_TTL_SECONDS = 60 * 60 +export const dynamic = 'force-dynamic' +export const revalidate = 0 -export const GET = withPublicApiRouteHandler({ +export const GET = defineV2JsonRoute({ contract: v2TableExportDownloadContract, - rateLimitEndpoint: 'table-export', - handler: async ({ input, auth: { userId, rateLimit } }) => { - try { - const { workspaceId } = input.query - const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - const record = await requireTableExport(input.params.exportId, workspaceId) - const access = await checkAccess(record.tableId, userId, 'read') - if (!access.ok || access.table.workspaceId !== workspaceId) { - return v2Error('NOT_FOUND', 'Table export not found') - } - const result = tableExportResult(record) - const url = await generatePresignedDownloadUrl( - result.resultKey, - 'workspace', - DOWNLOAD_TTL_SECONDS - ) - return v2Data( - { - url, - fileName: result.resultKey.split('/').pop() ?? `export.${result.format}`, - expiresAt: new Date(Date.now() + DOWNLOAD_TTL_SECONDS * 1000).toISOString(), - }, - { rateLimit } - ) - } catch (error) { - const classified = v2CaughtOrchestrationError(error) - if (classified) return classified - throw error - } - }, + operation: tableOperations.downloadExport, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableErrorPolicies.concealExportAuthorization, + mapInput: ({ params, query }) => ({ + exportId: params.exportId, + workspaceId: query.workspaceId, + }), + useCase: downloadTableExportUseCase, + present: (result) => ({ data: result }), }) diff --git a/apps/sim/app/api/v2/tables/exports/[exportId]/route.ts b/apps/sim/app/api/v2/tables/exports/[exportId]/route.ts index 721652921f4..61c18650cdf 100644 --- a/apps/sim/app/api/v2/tables/exports/[exportId]/route.ts +++ b/apps/sim/app/api/v2/tables/exports/[exportId]/route.ts @@ -2,62 +2,38 @@ import { v2CancelTableExportContract, v2GetTableExportContract, } from '@/lib/api/contracts/v2/tables' -import { - cancelTableExportResource, - requireTableExport, - toV2TableExport, -} from '@/lib/table/orchestration/export-resource' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { checkAccess } from '@/app/api/table/utils' -import { resolveWorkspaceScope } from '@/app/api/v1/middleware' -import { - v2CaughtOrchestrationError, - v2Data, - v2Error, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2TableErrorPolicies } from '@/lib/table/api' +import { cancelTableExportUseCase, readTableExportUseCase } from '@/lib/table/application/exports' +import { tableOperations } from '@/lib/table/application/operations' -async function authorizeExport(exportId: string, workspaceId: string, userId: string) { - const record = await requireTableExport(exportId, workspaceId) - const access = await checkAccess(record.tableId, userId, 'read') - if (!access.ok || access.table.workspaceId !== workspaceId) return null - return record -} +export const dynamic = 'force-dynamic' +export const revalidate = 0 -export const GET = withPublicApiRouteHandler({ +export const GET = defineV2JsonRoute({ contract: v2GetTableExportContract, - rateLimitEndpoint: 'table-export', - handler: async ({ input, auth: { userId, rateLimit } }) => { - try { - const { workspaceId } = input.query - const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - const record = await authorizeExport(input.params.exportId, workspaceId, userId) - if (!record) return v2Error('NOT_FOUND', 'Table export not found') - return v2Data(toV2TableExport(record), { rateLimit }) - } catch (error) { - const classified = v2CaughtOrchestrationError(error) - if (classified) return classified - throw error - } - }, + operation: tableOperations.readExport, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableErrorPolicies.concealExportAuthorization, + mapInput: ({ params, query }) => ({ + exportId: params.exportId, + workspaceId: query.workspaceId, + }), + useCase: readTableExportUseCase, + present: ({ export: tableExport }) => ({ data: tableExport }), }) -export const DELETE = withPublicApiRouteHandler({ +export const DELETE = defineV2JsonRoute({ contract: v2CancelTableExportContract, - rateLimitEndpoint: 'table-export', - handler: async ({ input, auth: { userId, rateLimit } }) => { - try { - const { workspaceId } = input.query - const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - const record = await authorizeExport(input.params.exportId, workspaceId, userId) - if (!record) return v2Error('NOT_FOUND', 'Table export not found') - return v2Data(toV2TableExport(await cancelTableExportResource(record)), { rateLimit }) - } catch (error) { - const classified = v2CaughtOrchestrationError(error) - if (classified) return classified - throw error - } - }, + operation: tableOperations.cancelExport, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableErrorPolicies.concealExportAuthorization, + mapInput: ({ params, query }) => ({ + exportId: params.exportId, + workspaceId: query.workspaceId, + }), + useCase: cancelTableExportUseCase, + present: ({ export: tableExport }) => ({ data: tableExport }), }) diff --git a/apps/sim/app/api/v2/tables/folders/route.test.ts b/apps/sim/app/api/v2/tables/folders/route.test.ts new file mode 100644 index 00000000000..866438a058c --- /dev/null +++ b/apps/sim/app/api/v2/tables/folders/route.test.ts @@ -0,0 +1,158 @@ +/** + * @vitest-environment node + */ + +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + authenticate: vi.fn(), + preauthRate: vi.fn(), + operationRate: vi.fn(), + gate: vi.fn(), + list: vi.fn(), + create: vi.fn(), + update: vi.fn(), + remove: vi.fn(), +})) + +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticate, + V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, +})) +vi.mock('@/lib/core/rate-limiter', () => ({ + RateLimiter: class { + checkRateLimitDirect = mocks.preauthRate + checkRateLimitDirectOrThrow = mocks.operationRate + }, + getRateLimit: () => ({ maxTokens: 100, refillRate: 100, refillIntervalMs: 60_000 }), +})) +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) +vi.mock('@/lib/table/application/folders', () => ({ + listTableFoldersUseCase: { operation: { id: 'tables.folders.list' }, execute: mocks.list }, + createTableFolderUseCase: { operation: { id: 'tables.folders.create' }, execute: mocks.create }, + updateTableFolderUseCase: { operation: { id: 'tables.folders.update' }, execute: mocks.update }, + deleteTableFolderUseCase: { operation: { id: 'tables.folders.delete' }, execute: mocks.remove }, +})) + +import { DELETE, GET, PATCH, POST } from '@/app/api/v2/tables/folders/route' + +const WORKSPACE_ID = 'workspace-1' +const principal = { + kind: 'workspace_api_key' as const, + workspaceId: WORKSPACE_ID, + keyId: 'key-1', +} +const auth = { + principal, + rolloutUserId: 'owner-1', + rateLimitSubjectIds: [`workspace:${WORKSPACE_ID}`], + rateLimitSubscription: null, + keyType: 'workspace' as const, +} +const rate = { + allowed: true, + remaining: 99, + resetAt: new Date('2026-01-01T01:00:00.000Z'), + retryAfterMs: 0, +} +const folder = { + id: 'folder-1', + workspaceId: WORKSPACE_ID, + userId: 'owner-1', + resourceType: 'table' as const, + name: 'Reports', + parentId: null, + sortOrder: 0, + deletedAt: null, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), +} +const index = { + rowById: new Map([['folder-1', folder]]), + pathById: new Map([['folder-1', '/Reports']]), + idByPath: new Map([['/Reports', 'folder-1']]), +} + +function request(method: 'GET' | 'POST' | 'PATCH' | 'DELETE', url: string, body?: unknown) { + return new NextRequest(`http://localhost:3000${url}`, { + method, + headers: { 'x-api-key': 'secret', ...(body ? { 'content-type': 'application/json' } : {}) }, + ...(body ? { body: JSON.stringify(body) } : {}), + }) +} + +describe('/api/v2/tables/folders', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.authenticate.mockResolvedValue(auth) + mocks.preauthRate.mockResolvedValue(rate) + mocks.operationRate.mockResolvedValue(rate) + mocks.gate.mockResolvedValue(null) + mocks.list.mockResolvedValue({ folders: [folder], index }) + mocks.create.mockResolvedValue({ folder, index, path: '/Reports' }) + mocks.update.mockResolvedValue({ + folder, + index, + path: '/Reports', + sourcePath: '/Archive/Reports', + }) + mocks.remove.mockResolvedValue({ + path: '/Reports', + deleted: true, + deletedItems: { folders: 1, tables: 2 }, + }) + }) + + it('lists canonical paths through the folder read use case', async () => { + const req = request('GET', `/api/v2/tables/folders?workspaceId=${WORKSPACE_ID}`) + const response = await GET(req) + + expect(response.status).toBe(200) + expect((await response.json()).data[0]).toMatchObject({ path: '/Reports', parentPath: '/' }) + expect(mocks.list).toHaveBeenCalledWith({ + principal, + input: expect.objectContaining({ workspaceId: WORKSPACE_ID }), + request: req, + }) + }) + + it('delegates create and relocate without route-local authorization', async () => { + const createResponse = await POST( + request('POST', '/api/v2/tables/folders', { + workspaceId: WORKSPACE_ID, + path: '/Reports', + }) + ) + const updateResponse = await PATCH( + request('PATCH', '/api/v2/tables/folders', { + workspaceId: WORKSPACE_ID, + path: '/Archive/Reports', + destinationPath: '/Reports', + }) + ) + + expect(createResponse.status).toBe(201) + expect(updateResponse.status).toBe(200) + expect(mocks.create).toHaveBeenCalledOnce() + expect(mocks.update).toHaveBeenCalledOnce() + }) + + it('returns authoritative recursive deletion counts', async () => { + const response = await DELETE( + request( + 'DELETE', + `/api/v2/tables/folders?workspaceId=${WORKSPACE_ID}&path=%2FReports&recursive=true` + ) + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + data: { + path: '/Reports', + deleted: true, + deletedItems: { folders: 1, tables: 2 }, + }, + }) + }) +}) diff --git a/apps/sim/app/api/v2/tables/folders/route.ts b/apps/sim/app/api/v2/tables/folders/route.ts index 3e885727a91..ee00df517bb 100644 --- a/apps/sim/app/api/v2/tables/folders/route.ts +++ b/apps/sim/app/api/v2/tables/folders/route.ts @@ -4,119 +4,63 @@ import { v2ListTableFoldersContract, v2RelocateTableFolderContract, } from '@/lib/api/contracts/v2/tables' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2TableErrorPolicies } from '@/lib/table/api' import { - createFolderAtPath, - deleteFolderByPath, - relocateFolderByPath, -} from '@/lib/folders/orchestration' -import { listActiveFolderRows, loadActiveFolderPathIndex } from '@/lib/folders/queries' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { - resolveFolderPathId, - toV2PathFolder, - v2FolderPathMutationError, -} from '@/app/api/v2/lib/folders' -import { v2CursorList, v2Data, v2Error, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' + createTableFolderUseCase, + deleteTableFolderUseCase, + listTableFoldersUseCase, + updateTableFolderUseCase, +} from '@/lib/table/application/folders' +import { tableOperations } from '@/lib/table/application/operations' +import { toV2PathFolder } from '@/app/api/v2/lib/folders' export const dynamic = 'force-dynamic' export const revalidate = 0 -export const GET = withPublicApiRouteHandler({ +export const GET = defineV2JsonRoute({ contract: v2ListTableFoldersContract, - rateLimitEndpoint: 'tables', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const { workspaceId, parentPath, search, sortBy, sortOrder } = input.query - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') - if (access) return v2WorkspaceAccessError(access) - - const index = await loadActiveFolderPathIndex(workspaceId, 'table') - const parentId = parentPath === undefined ? undefined : resolveFolderPathId(index, parentPath) - if (parentPath !== undefined && parentId === undefined) { - return v2Error('NOT_FOUND', 'Folder not found') - } - const rows = await listActiveFolderRows(workspaceId, 'table', { - parentId, - search, - sortBy, - sortOrder, - }) - return v2CursorList( - rows.map((row) => toV2PathFolder(row, index, false)), - null, - { rateLimit } - ) - }, + operation: tableOperations.listFolders, + useCase: listTableFoldersUseCase, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableErrorPolicies.default, + mapInput: ({ query }) => query, + present: ({ folders, index }) => ({ + data: folders.map((folder) => toV2PathFolder(folder, index, false)), + nextCursor: null, + }), }) -export const POST = withPublicApiRouteHandler({ +export const POST = defineV2JsonRoute({ contract: v2CreateTableFolderContract, - rateLimitEndpoint: 'tables', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const { workspaceId, path } = input.body - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - const result = await createFolderAtPath({ resourceType: 'table', workspaceId, userId, path }) - if (!result.success || !result.folder) { - return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to create folder') - } - const index = await loadActiveFolderPathIndex(workspaceId, 'table') - return v2Data( - { folder: toV2PathFolder(result.folder, index, false) }, - { rateLimit, status: 201 } - ) - }, + operation: tableOperations.createFolder, + useCase: createTableFolderUseCase, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableErrorPolicies.default, + mapInput: ({ body }) => body, + present: ({ folder, index }) => ({ data: { folder: toV2PathFolder(folder, index, false) } }), }) -export const PATCH = withPublicApiRouteHandler({ +export const PATCH = defineV2JsonRoute({ contract: v2RelocateTableFolderContract, - rateLimitEndpoint: 'tables', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const { workspaceId, path, destinationPath } = input.body - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - const result = await relocateFolderByPath({ - resourceType: 'table', - workspaceId, - userId, - path, - destinationPath, - }) - if (!result.success || !result.folder) { - return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to move folder') - } - const index = await loadActiveFolderPathIndex(workspaceId, 'table') - return v2Data({ folder: toV2PathFolder(result.folder, index, false) }, { rateLimit }) - }, + operation: tableOperations.updateFolder, + useCase: updateTableFolderUseCase, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableErrorPolicies.default, + mapInput: ({ body }) => body, + present: ({ folder, index }) => ({ data: { folder: toV2PathFolder(folder, index, false) } }), }) -export const DELETE = withPublicApiRouteHandler({ +export const DELETE = defineV2JsonRoute({ contract: v2DeleteTableFolderContract, - rateLimitEndpoint: 'tables', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const { workspaceId, path, recursive } = input.query - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - const result = await deleteFolderByPath({ - resourceType: 'table', - workspaceId, - userId, - path, - recursive, - }) - if (!result.success || !result.deletedItems) { - return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to delete folder') - } - return v2Data( - { - path, - deleted: true as const, - deletedItems: { - folders: result.deletedItems.folders, - tables: result.deletedItems.tables ?? 0, - }, - }, - { rateLimit } - ) - }, + operation: tableOperations.deleteFolder, + useCase: deleteTableFolderUseCase, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableErrorPolicies.default, + mapInput: ({ query }) => query, + present: ({ path, deleted, deletedItems }) => ({ data: { path, deleted, deletedItems } }), }) diff --git a/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.test.ts b/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.test.ts index 137221ed511..de1c14d4349 100644 --- a/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.test.ts +++ b/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.test.ts @@ -1,136 +1,101 @@ /** * @vitest-environment node */ + import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockCheckRateLimit, - mockResolveWorkspaceScope, - mockGetOwnedTableImportUpload, - mockFindOwnedTableImport, - mockStartUploadedTableImport, - mockToV2TableImport, - mockCompleteUploadSession, -} = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceScope: vi.fn(), - mockGetOwnedTableImportUpload: vi.fn(), - mockFindOwnedTableImport: vi.fn(), - mockStartUploadedTableImport: vi.fn(), - mockToV2TableImport: vi.fn(), - mockCompleteUploadSession: vi.fn(), -})) - -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceScope: mockResolveWorkspaceScope, +const mocks = vi.hoisted(() => ({ + authenticate: vi.fn(), + preauthRate: vi.fn(), + operationRate: vi.fn(), + gate: vi.fn(), + complete: vi.fn(), })) -vi.mock('@/app/api/v2/lib/gate', () => ({ - v2ApiGateError: vi.fn().mockResolvedValue(null), +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticate, + V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, })) - -vi.mock('@/app/api/v2/tables/utils', () => ({ - v2TableLockError: vi.fn().mockReturnValue(null), +vi.mock('@/lib/core/rate-limiter', () => ({ + RateLimiter: class { + checkRateLimitDirect = mocks.preauthRate + checkRateLimitDirectOrThrow = mocks.operationRate + }, + getRateLimit: () => ({ maxTokens: 100, refillRate: 100, refillIntervalMs: 60_000 }), })) - -vi.mock('@/lib/table/orchestration/import-resource', () => ({ - findOwnedTableImport: mockFindOwnedTableImport, - getOwnedTableImportUpload: mockGetOwnedTableImportUpload, - startUploadedTableImport: mockStartUploadedTableImport, - toV2TableImport: mockToV2TableImport, -})) - -vi.mock('@/lib/uploads/upload-session/service', () => ({ - completeUploadSession: mockCompleteUploadSession, +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) +vi.mock('@/lib/table/application/imports', () => ({ + completeTableImportUseCase: { + operation: { id: 'tables.imports.complete' }, + execute: mocks.complete, + }, })) import { POST } from '@/app/api/v2/tables/imports/[importId]/complete/route' const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' -const RATE_LIMIT = { - allowed: true, - userId: 'user-1', - keyType: 'workspace', - limit: 100, - remaining: 99, - resetAt: new Date('2026-08-03T22:00:00.000Z'), -} -const UPLOAD = { - id: 'import-1', +const principal = { + kind: 'workspace_api_key' as const, workspaceId: WORKSPACE_ID, - userId: 'user-1', + keyId: 'key-1', } - -function request() { - return POST( - new NextRequest( - `http://localhost:3000/api/v2/tables/imports/import-1/complete?workspaceId=${WORKSPACE_ID}`, - { - method: 'POST', - headers: { - 'upload-token': 'signed-upload-token', - }, - } - ), - { params: Promise.resolve({ importId: 'import-1' }) } - ) +const auth = { + principal, + rolloutUserId: 'owner-1', + rateLimitSubjectIds: [`workspace:${WORKSPACE_ID}`], + rateLimitSubscription: null, + keyType: 'workspace' as const, +} +const rate = { + allowed: true, + remaining: 99, + resetAt: new Date('2026-01-01T01:00:00.000Z'), + retryAfterMs: 0, } describe('POST /api/v2/tables/imports/[importId]/complete', () => { beforeEach(() => { vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT) - mockResolveWorkspaceScope.mockResolvedValue(null) - mockGetOwnedTableImportUpload.mockReturnValue(UPLOAD) + mocks.authenticate.mockResolvedValue(auth) + mocks.preauthRate.mockResolvedValue(rate) + mocks.operationRate.mockResolvedValue(rate) + mocks.gate.mockResolvedValue(null) }) - it('returns the existing table job when completion is retried', async () => { - const existing = { id: 'import-1', tableId: 'table-1', status: 'ready' } - const responseBody = { id: 'import-1', tableId: 'table-1', status: 'completed' } - mockFindOwnedTableImport.mockResolvedValue(existing) - mockToV2TableImport.mockReturnValue(responseBody) - - const response = await request() - - expect(response.status).toBe(200) - expect(await response.json()).toEqual({ data: responseBody }) - expect(mockGetOwnedTableImportUpload).toHaveBeenCalledWith({ - importId: 'import-1', - workspaceId: WORKSPACE_ID, - userId: 'user-1', - uploadToken: 'signed-upload-token', - }) - expect(mockFindOwnedTableImport).toHaveBeenCalledWith({ - importId: 'import-1', + it('delegates idempotent completion to the authorized import use case', async () => { + const timestamp = '2026-01-01T00:00:00.000Z' + const tableImport = { + id: 'import-1', workspaceId: WORKSPACE_ID, - userId: 'user-1', - }) - expect(mockCompleteUploadSession).not.toHaveBeenCalled() - expect(mockStartUploadedTableImport).not.toHaveBeenCalled() - }) - - it('completes by upload id and starts the import job', async () => { - const started = { id: 'import-1', tableId: 'table-1', status: 'running' } - const responseBody = { id: 'import-1', tableId: 'table-1', status: 'processing' } - mockFindOwnedTableImport.mockResolvedValue(null) - mockCompleteUploadSession.mockResolvedValue({ - session: UPLOAD, - value: null, - alreadyCompleted: false, - }) - mockStartUploadedTableImport.mockResolvedValue(started) - mockToV2TableImport.mockReturnValue(responseBody) + status: 'completed', + source: { type: 'upload', name: 'data.csv', contentType: 'text/csv', size: 128 }, + target: { type: 'new', name: 'imported_data' }, + tableId: 'table-1', + rowsProcessed: 2, + error: null, + createdAt: timestamp, + updatedAt: timestamp, + completedAt: timestamp, + } + mocks.complete.mockResolvedValue({ import: tableImport }) + const request = new NextRequest( + `http://localhost:3000/api/v2/tables/imports/import-1/complete?workspaceId=${WORKSPACE_ID}`, + { method: 'POST', headers: { 'upload-token': 'signed-upload-token' } } + ) - const response = await request() + const response = await POST(request, { params: Promise.resolve({ importId: 'import-1' }) }) expect(response.status).toBe(200) - expect(mockCompleteUploadSession).toHaveBeenCalledWith({ - session: UPLOAD, - finalize: expect.any(Function), + expect(await response.json()).toEqual({ data: tableImport }) + expect(mocks.complete).toHaveBeenCalledWith({ + principal, + input: { + importId: 'import-1', + workspaceId: WORKSPACE_ID, + uploadToken: 'signed-upload-token', + }, + request, }) - expect(mockStartUploadedTableImport).toHaveBeenCalledWith(UPLOAD) - expect(await response.json()).toEqual({ data: responseBody }) }) }) diff --git a/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.ts b/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.ts index 626b4cc2f8b..00ac2393205 100644 --- a/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.ts +++ b/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.ts @@ -1,52 +1,23 @@ import { v2CompleteTableImportContract } from '@/lib/api/contracts/v2/tables' -import { - findOwnedTableImport, - getOwnedTableImportUpload, - startUploadedTableImport, - toV2TableImport, -} from '@/lib/table/orchestration/import-resource' -import { completeUploadSession } from '@/lib/uploads/upload-session/service' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { resolveWorkspaceScope } from '@/app/api/v1/middleware' -import { - v2CaughtOrchestrationError, - v2Data, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' -import { v2TableLockError } from '@/app/api/v2/tables/utils' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2TableErrorPolicies } from '@/lib/table/api' +import { completeTableImportUseCase } from '@/lib/table/application/imports' +import { tableOperations } from '@/lib/table/application/operations' -export const POST = withPublicApiRouteHandler({ +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +export const POST = defineV2JsonRoute({ contract: v2CompleteTableImportContract, - rateLimitEndpoint: 'table-import', - handler: async ({ input, auth: { userId, rateLimit } }) => { - try { - const { workspaceId } = input.query - const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - const upload = await getOwnedTableImportUpload({ - importId: input.params.importId, - workspaceId, - userId, - uploadToken: input.headers['upload-token'], - }) - const existing = await findOwnedTableImport({ - importId: upload.id, - workspaceId, - userId: upload.userId, - }) - if (existing) return v2Data(toV2TableImport(existing), { rateLimit }) - const completed = await completeUploadSession({ - session: upload, - finalize: async () => ({ value: null }), - }) - const started = await startUploadedTableImport(completed.session) - return v2Data(await toV2TableImport(started), { rateLimit }) - } catch (error) { - const lockError = v2TableLockError(error) - if (lockError) return lockError - const classified = v2CaughtOrchestrationError(error) - if (classified) return classified - throw error - } - }, + operation: tableOperations.completeImport, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableErrorPolicies.concealImportAuthorization, + mapInput: ({ params, query, headers }) => ({ + importId: params.importId, + workspaceId: query.workspaceId, + uploadToken: headers['upload-token'], + }), + useCase: completeTableImportUseCase, + present: ({ import: tableImport }) => ({ data: tableImport }), }) diff --git a/apps/sim/app/api/v2/tables/imports/[importId]/parts/route.ts b/apps/sim/app/api/v2/tables/imports/[importId]/parts/route.ts index 779c90acb5e..1f4ddb01d01 100644 --- a/apps/sim/app/api/v2/tables/imports/[importId]/parts/route.ts +++ b/apps/sim/app/api/v2/tables/imports/[importId]/parts/route.ts @@ -1,38 +1,24 @@ import { v2CreateTableImportPartUrlsContract } from '@/lib/api/contracts/v2/tables' -import { getOwnedTableImportUpload } from '@/lib/table/orchestration/import-resource' -import { createUploadPartUrls } from '@/lib/uploads/upload-session/service' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { resolveWorkspaceScope } from '@/app/api/v1/middleware' -import { - v2CaughtOrchestrationError, - v2Data, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2TableErrorPolicies } from '@/lib/table/api' +import { createTableImportPartsUseCase } from '@/lib/table/application/imports' +import { tableOperations } from '@/lib/table/application/operations' -export const POST = withPublicApiRouteHandler({ +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +export const POST = defineV2JsonRoute({ contract: v2CreateTableImportPartUrlsContract, - rateLimitEndpoint: 'table-import', - handler: async ({ request, input, auth: { userId, rateLimit } }) => { - try { - const { workspaceId } = input.query - const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - const session = await getOwnedTableImportUpload({ - importId: input.params.importId, - workspaceId, - userId, - uploadToken: input.headers['upload-token'], - }) - const parts = await createUploadPartUrls({ - session, - partNumbers: input.body.partNumbers, - localOrigin: request.nextUrl.origin, - }) - return v2Data({ parts }, { rateLimit }) - } catch (error) { - const classified = v2CaughtOrchestrationError(error) - if (classified) return classified - throw error - } - }, + operation: tableOperations.createImportParts, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableErrorPolicies.concealImportAuthorization, + mapInput: ({ params, query, headers, body }) => ({ + importId: params.importId, + workspaceId: query.workspaceId, + uploadToken: headers['upload-token'], + partNumbers: body.partNumbers, + }), + useCase: createTableImportPartsUseCase, + present: (result) => ({ data: result }), }) diff --git a/apps/sim/app/api/v2/tables/imports/[importId]/route.ts b/apps/sim/app/api/v2/tables/imports/[importId]/route.ts index 7b5587a3ff0..aba56dd3df1 100644 --- a/apps/sim/app/api/v2/tables/imports/[importId]/route.ts +++ b/apps/sim/app/api/v2/tables/imports/[importId]/route.ts @@ -2,68 +2,39 @@ import { v2CancelTableImportContract, v2GetTableImportContract, } from '@/lib/api/contracts/v2/tables' -import { - abortTableImportUpload, - cancelTableImportResource, - getOwnedTableImport, - toV2TableImport, -} from '@/lib/table/orchestration/import-resource' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { resolveWorkspaceScope } from '@/app/api/v1/middleware' -import { - v2CaughtOrchestrationError, - v2Data, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2TableErrorPolicies } from '@/lib/table/api' +import { cancelTableImportUseCase, readTableImportUseCase } from '@/lib/table/application/imports' +import { tableOperations } from '@/lib/table/application/operations' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 -export const GET = withPublicApiRouteHandler({ +export const GET = defineV2JsonRoute({ contract: v2GetTableImportContract, - rateLimitEndpoint: 'table-import', - handler: async ({ input, auth: { userId, rateLimit } }) => { - try { - const scopeError = await resolveWorkspaceScope(rateLimit, input.query.workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - const record = await getOwnedTableImport({ - importId: input.params.importId, - workspaceId: input.query.workspaceId, - userId, - }) - return v2Data(await toV2TableImport(record), { rateLimit }) - } catch (error) { - const classified = v2CaughtOrchestrationError(error) - if (classified) return classified - throw error - } - }, + operation: tableOperations.readImport, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableErrorPolicies.concealImportAuthorization, + mapInput: ({ params, query }) => ({ + importId: params.importId, + workspaceId: query.workspaceId, + }), + useCase: readTableImportUseCase, + present: ({ import: tableImport }) => ({ data: tableImport }), }) -export const DELETE = withPublicApiRouteHandler({ +export const DELETE = defineV2JsonRoute({ contract: v2CancelTableImportContract, - rateLimitEndpoint: 'table-import', - handler: async ({ input, auth: { userId, rateLimit } }) => { - try { - const scopeError = await resolveWorkspaceScope(rateLimit, input.query.workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - const uploadToken = input.headers['upload-token'] - const record = uploadToken - ? await abortTableImportUpload({ - importId: input.params.importId, - workspaceId: input.query.workspaceId, - userId, - uploadToken, - }) - : await cancelTableImportResource( - await getOwnedTableImport({ - importId: input.params.importId, - workspaceId: input.query.workspaceId, - userId, - }) - ) - return v2Data(toV2TableImport(record), { rateLimit }) - } catch (error) { - const classified = v2CaughtOrchestrationError(error) - if (classified) return classified - throw error - } - }, + operation: tableOperations.cancelImport, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableErrorPolicies.concealImportAuthorization, + mapInput: ({ params, query, headers }) => ({ + importId: params.importId, + workspaceId: query.workspaceId, + uploadToken: headers['upload-token'], + }), + useCase: cancelTableImportUseCase, + present: ({ import: tableImport }) => ({ data: tableImport }), }) diff --git a/apps/sim/app/api/v2/tables/imports/route.test.ts b/apps/sim/app/api/v2/tables/imports/route.test.ts index f5c6a5f7541..a0d53ee1880 100644 --- a/apps/sim/app/api/v2/tables/imports/route.test.ts +++ b/apps/sim/app/api/v2/tables/imports/route.test.ts @@ -1,67 +1,64 @@ /** * @vitest-environment node */ + import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockCheckRateLimit, - mockResolveWorkspaceScope, - mockCreateTableImportResource, - mockToV2CreateTableImport, - mockLoadActiveFolderPathIndex, -} = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceScope: vi.fn(), - mockCreateTableImportResource: vi.fn(), - mockToV2CreateTableImport: vi.fn(), - mockLoadActiveFolderPathIndex: vi.fn(), +const mocks = vi.hoisted(() => ({ + authenticate: vi.fn(), + preauthRate: vi.fn(), + operationRate: vi.fn(), + gate: vi.fn(), + create: vi.fn(), })) -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceScope: mockResolveWorkspaceScope, +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticate, + V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, })) - -vi.mock('@/app/api/v2/lib/gate', () => ({ - v2ApiGateError: vi.fn().mockResolvedValue(null), +vi.mock('@/lib/core/rate-limiter', () => ({ + RateLimiter: class { + checkRateLimitDirect = mocks.preauthRate + checkRateLimitDirectOrThrow = mocks.operationRate + }, + getRateLimit: () => ({ maxTokens: 100, refillRate: 100, refillIntervalMs: 60_000 }), })) - -vi.mock('@/app/api/v2/tables/utils', () => ({ - v2TableLockError: vi.fn().mockReturnValue(null), -})) - -vi.mock('@/lib/table/orchestration/import-resource', () => ({ - createTableImportResource: mockCreateTableImportResource, - toV2CreateTableImport: mockToV2CreateTableImport, -})) - -vi.mock('@/lib/folders/queries', () => ({ - loadActiveFolderPathIndex: mockLoadActiveFolderPathIndex, +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) +vi.mock('@/lib/table/application/imports', () => ({ + createTableImportUseCase: { operation: { id: 'tables.imports.create' }, execute: mocks.create }, })) import { POST } from '@/app/api/v2/tables/imports/route' const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' -const RATE_LIMIT = { +const principal = { + kind: 'workspace_api_key' as const, + workspaceId: WORKSPACE_ID, + keyId: 'key-1', +} +const auth = { + principal, + rolloutUserId: 'owner-1', + rateLimitSubjectIds: [`workspace:${WORKSPACE_ID}`], + rateLimitSubscription: null, + keyType: 'workspace' as const, +} +const rate = { allowed: true, - userId: 'user-1', - keyType: 'workspace', - limit: 100, remaining: 99, - resetAt: new Date('2026-08-03T22:00:00.000Z'), + resetAt: new Date('2026-01-01T01:00:00.000Z'), + retryAfterMs: 0, } +const timestamp = '2026-01-01T00:00:00.000Z' describe('POST /api/v2/tables/imports', () => { beforeEach(() => { vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT) - mockResolveWorkspaceScope.mockResolvedValue(null) - mockLoadActiveFolderPathIndex.mockResolvedValue({ - rowById: new Map(), - pathById: new Map(), - idByPath: new Map(), - }) + mocks.authenticate.mockResolvedValue(auth) + mocks.preauthRate.mockResolvedValue(rate) + mocks.operationRate.mockResolvedValue(rate) + mocks.gate.mockResolvedValue(null) }) it.each([ @@ -69,7 +66,19 @@ describe('POST /api/v2/tables/imports', () => { 'upload', { type: 'upload', name: 'data.csv', contentType: 'text/csv', size: 128 }, { - session: { id: 'import-1', source: { type: 'upload' } }, + session: { + id: 'import-1', + workspaceId: WORKSPACE_ID, + status: 'uploading', + source: { type: 'upload', name: 'data.csv', contentType: 'text/csv', size: 128 }, + target: { type: 'new', name: 'imported_data' }, + tableId: null, + rowsProcessed: 0, + error: null, + createdAt: timestamp, + updatedAt: timestamp, + completedAt: null, + }, uploadToken: 'signed-token', transfer: { method: 'put', url: 'https://storage.example/upload', headers: {} }, }, @@ -78,70 +87,58 @@ describe('POST /api/v2/tables/imports', () => { 'workspace file', { type: 'workspace_file', fileId: 'file-1' }, { - session: { id: 'import-1', source: { type: 'workspace_file', fileId: 'file-1' } }, + session: { + id: 'import-1', + workspaceId: WORKSPACE_ID, + status: 'queued', + source: { type: 'workspace_file', fileId: 'file-1' }, + target: { type: 'new', name: 'imported_data' }, + tableId: 'table-1', + rowsProcessed: 0, + error: null, + createdAt: timestamp, + updatedAt: timestamp, + completedAt: null, + }, uploadToken: null, transfer: null, }, ], - ])('returns the create envelope for a %s source', async (_label, source, responseData) => { - const requestBody = { - workspaceId: WORKSPACE_ID, - source, - target: { type: 'new', name: 'imported_data' }, - } - const created = { record: { id: 'import-1' }, upload: null } - mockCreateTableImportResource.mockResolvedValue(created) - mockToV2CreateTableImport.mockReturnValue(responseData) - - const response = await POST( - new NextRequest('http://localhost:3000/api/v2/tables/imports', { + ])( + 'delegates a %s source to the authorized import use case', + async (_label, source, tableImport) => { + const body = { + workspaceId: WORKSPACE_ID, + source, + target: { type: 'new', name: 'imported_data' }, + } + mocks.create.mockResolvedValue({ import: tableImport }) + const request = new NextRequest('http://localhost:3000/api/v2/tables/imports', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(requestBody), + headers: { 'content-type': 'application/json', 'x-api-key': 'secret' }, + body: JSON.stringify(body), }) - ) - expect(response.status).toBe(201) - expect(mockCreateTableImportResource).toHaveBeenCalledWith( - requestBody, - 'user-1', - 'http://localhost:3000', - null - ) - expect(mockToV2CreateTableImport).toHaveBeenCalledWith(created) - expect(await response.json()).toEqual({ data: responseData }) - }) + const response = await POST(request) - it('accepts native JSON mapping and createColumns values', async () => { - const requestBody = { - workspaceId: WORKSPACE_ID, - source: { type: 'upload', name: 'data.csv', contentType: 'text/csv', size: 128 }, - target: { type: 'existing', tableId: 'table-1', mode: 'append' }, - mapping: { email: 'email_address', notes: null }, - createColumns: ['phone'], + expect(response.status).toBe(201) + expect(await response.json()).toEqual({ data: tableImport }) + expect(mocks.create).toHaveBeenCalledWith({ principal, input: { body }, request }) } - const created = { record: { id: 'import-1' }, upload: null } - const responseData = { - session: { id: 'import-1', source: { type: 'upload' } }, - uploadToken: 'signed-token', - transfer: { method: 'put', url: 'https://storage.example/upload', headers: {} }, - } - mockCreateTableImportResource.mockResolvedValue(created) - mockToV2CreateTableImport.mockReturnValue(responseData) + ) + it('authenticates and rate-limits before rejecting an invalid source', async () => { const response = await POST( new NextRequest('http://localhost:3000/api/v2/tables/imports', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(requestBody), + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ workspaceId: WORKSPACE_ID, source: {}, target: {} }), }) ) - expect(response.status).toBe(201) - expect(mockCreateTableImportResource).toHaveBeenCalledWith( - requestBody, - 'user-1', - 'http://localhost:3000' - ) + expect(response.status).toBe(400) + expect(mocks.authenticate).toHaveBeenCalled() + expect(mocks.operationRate).toHaveBeenCalled() + expect(mocks.create).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/app/api/v2/tables/imports/route.ts b/apps/sim/app/api/v2/tables/imports/route.ts index 8a97f9f36ad..2fba1da2417 100644 --- a/apps/sim/app/api/v2/tables/imports/route.ts +++ b/apps/sim/app/api/v2/tables/imports/route.ts @@ -1,50 +1,19 @@ import { v2CreateTableImportContract } from '@/lib/api/contracts/v2/tables' -import { - createTableImportResource, - toV2CreateTableImport, -} from '@/lib/table/orchestration/import-resource' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { resolveWorkspaceScope } from '@/app/api/v1/middleware' -import { resolveFolderPathIdentity } from '@/app/api/v2/lib/folders' -import { - v2CaughtOrchestrationError, - v2Data, - v2Error, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' -import { v2TableLockError } from '@/app/api/v2/tables/utils' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2TableErrorPolicies } from '@/lib/table/api' +import { createTableImportUseCase } from '@/lib/table/application/imports' +import { tableOperations } from '@/lib/table/application/operations' -export const POST = withPublicApiRouteHandler({ +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +export const POST = defineV2JsonRoute({ contract: v2CreateTableImportContract, - rateLimitEndpoint: 'table-import', - handler: async ({ request, input, auth: { userId, rateLimit } }) => { - try { - const scopeError = await resolveWorkspaceScope(rateLimit, input.body.workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - let created: Awaited> - if (input.body.target.type === 'new') { - const resolution = await resolveFolderPathIdentity({ - workspaceId: input.body.workspaceId, - resourceType: 'table', - path: input.body.target.folderPath ?? '/', - }) - if (!resolution.found) return v2Error('NOT_FOUND', 'Folder not found') - created = await createTableImportResource( - input.body, - userId, - request.nextUrl.origin, - resolution.folderId - ) - } else { - created = await createTableImportResource(input.body, userId, request.nextUrl.origin) - } - return v2Data(toV2CreateTableImport(created), { rateLimit, status: 201 }) - } catch (error) { - const lockError = v2TableLockError(error) - if (lockError) return lockError - const classified = v2CaughtOrchestrationError(error) - if (classified) return classified - throw error - } - }, + operation: tableOperations.createImport, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableErrorPolicies.concealTableAuthorization, + mapInput: ({ body }) => ({ body }), + useCase: createTableImportUseCase, + present: ({ import: tableImport }) => ({ data: tableImport }), }) diff --git a/apps/sim/app/api/v2/tables/route.test.ts b/apps/sim/app/api/v2/tables/route.test.ts index b5af7c73822..77141cb842c 100644 --- a/apps/sim/app/api/v2/tables/route.test.ts +++ b/apps/sim/app/api/v2/tables/route.test.ts @@ -1,292 +1,160 @@ /** * @vitest-environment node - * - * Public v2 tables list: auth/scope gating, rollout gate ordering, typed - * summary output in the `{ data, nextCursor }` envelope, private cache header. */ + import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { TableDefinition } from '@/lib/table/types' - -const { - mockQueryTables, - mockCheckRateLimit, - mockResolveWorkspaceAccess, - mockIsFeatureEnabled, - mockGetWorkspaceOrganizationId, - mockLoadActiveFolderPathIndex, - mockResolveFolderPathIdentity, - mockCreateTable, - mockGetWorkspaceTableLimits, -} = vi.hoisted(() => ({ - mockQueryTables: vi.fn(), - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceAccess: vi.fn(), - mockIsFeatureEnabled: vi.fn(), - mockGetWorkspaceOrganizationId: vi.fn(), - mockLoadActiveFolderPathIndex: vi.fn(), - mockResolveFolderPathIdentity: vi.fn(), - mockCreateTable: vi.fn(), - mockGetWorkspaceTableLimits: vi.fn(), -})) -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceAccess: mockResolveWorkspaceAccess, +const mocks = vi.hoisted(() => ({ + authenticate: vi.fn(), + preauthRate: vi.fn(), + operationRate: vi.fn(), + gate: vi.fn(), + list: vi.fn(), + create: vi.fn(), })) -vi.mock('@/lib/table', async () => { - const actual = await import('@/lib/table/column-keys') - return { - ...actual, - queryTables: mockQueryTables, - createTable: mockCreateTable, - getWorkspaceTableLimits: mockGetWorkspaceTableLimits, - } -}) - -vi.mock('@/app/api/table/utils', () => ({ - normalizeColumn: (col: Record) => col, - rootErrorMessage: (error: unknown) => String(error), - rowWriteErrorResponse: () => null, +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticate, + V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, })) - -vi.mock('@/lib/core/config/feature-flags', () => ({ - isFeatureEnabled: mockIsFeatureEnabled, +vi.mock('@/lib/core/rate-limiter', () => ({ + RateLimiter: class { + checkRateLimitDirect = mocks.preauthRate + checkRateLimitDirectOrThrow = mocks.operationRate + }, + getRateLimit: () => ({ maxTokens: 100, refillRate: 100, refillIntervalMs: 60_000 }), })) - -vi.mock('@/lib/workspaces/utils', () => ({ - getWorkspaceOrganizationId: mockGetWorkspaceOrganizationId, -})) - -vi.mock('@/app/api/v2/lib/gate', () => ({ - v2ApiGateError: vi.fn().mockResolvedValue(null), -})) - -vi.mock('@/lib/folders/queries', () => ({ - loadActiveFolderPathIndex: mockLoadActiveFolderPathIndex, -})) - -vi.mock('@/app/api/v2/lib/folders', () => ({ - folderPathForId: (_index: unknown, folderId: string | null | undefined) => - folderId ? '/Reports' : '/', - resolveFolderPathId: ( - index: { idByPath: Map }, - path: string - ): string | null | undefined => (path === '/' ? null : index.idByPath.get(path)), - resolveFolderPathIdentity: mockResolveFolderPathIdentity, +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) +vi.mock('@/lib/table/application/tables', () => ({ + listTablesUseCase: { operation: { id: 'tables.list' }, execute: mocks.list }, + createTableUseCase: { operation: { id: 'tables.create' }, execute: mocks.create }, })) import { GET, POST } from '@/app/api/v2/tables/route' -const RATE_LIMIT_OK = { - allowed: true, - userId: 'user-1', - keyType: 'workspace', - limit: 100, - remaining: 99, - resetAt: new Date('2024-01-01T01:00:00Z'), +const WORKSPACE_ID = 'workspace-1' +const principal = { + kind: 'workspace_api_key' as const, + workspaceId: WORKSPACE_ID, + keyId: 'key-1', } - -function buildTable(): TableDefinition { - return { - id: 'tbl_1', - name: 'People', - description: 'A table', - schema: { columns: [{ id: 'col_name', name: 'name', type: 'string' }] }, - metadata: null, - rowCount: 5, - maxRows: 100, - workspaceId: 'workspace-1', - createdBy: 'user-1', - archivedAt: null, - createdAt: new Date('2024-01-01'), - updatedAt: new Date('2024-01-02'), - } +const auth = { + principal, + rolloutUserId: 'owner-1', + rateLimitSubjectIds: [`workspace:${WORKSPACE_ID}`], + rateLimitSubscription: null, + keyType: 'workspace' as const, } - -function callList(query: string) { - const req = new NextRequest(`http://localhost:3000/api/v2/tables?${query}`) - return GET(req) +const rate = { + allowed: true, + remaining: 99, + resetAt: new Date('2026-01-01T01:00:00.000Z'), + retryAfterMs: 0, } - -function callCreate(body: Record) { - return POST( - new NextRequest('http://localhost:3000/api/v2/tables', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(body), - }) - ) +const table = { + id: 'table-1', + workspaceId: WORKSPACE_ID, + userId: 'owner-1', + name: 'Contacts', + description: null, + schema: { + columns: [ + { id: 'col-1', name: 'Name', type: 'string' as const, required: false, unique: false }, + ], + }, + rowCount: 0, + maxRows: 100, + folderId: null, + metadata: null, + locks: { + schemaLocked: false, + insertLocked: false, + updateLocked: false, + deleteLocked: false, + }, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), } -describe('GET /api/v2/tables', () => { +describe('/api/v2/tables', () => { beforeEach(() => { vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockQueryTables.mockResolvedValue({ tables: [buildTable()], nextKeys: null }) - mockIsFeatureEnabled.mockResolvedValue(true) - mockGetWorkspaceOrganizationId.mockResolvedValue('org-1') - mockLoadActiveFolderPathIndex.mockResolvedValue({ - rowById: new Map(), - pathById: new Map(), - idByPath: new Map(), + mocks.authenticate.mockResolvedValue(auth) + mocks.preauthRate.mockResolvedValue(rate) + mocks.operationRate.mockResolvedValue(rate) + mocks.gate.mockResolvedValue(null) + mocks.list.mockResolvedValue({ + tables: [{ table, folderPath: '/' }], + nextKeys: undefined, + sortBy: 'name', + sortOrder: 'asc', }) + mocks.create.mockResolvedValue({ table, folderPath: '/' }) }) - it('returns 404 when the v2 API surface flag is off', async () => { - const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') - const { v2Error } = await import('@/app/api/v2/lib/response') - vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) - - const res = await callList('workspaceId=workspace-1') - - expect(res.status).toBe(404) - expect((await res.json()).error.code).toBe('NOT_FOUND') - expect(mockQueryTables).not.toHaveBeenCalled() - }) - - it('400s when workspaceId is missing', async () => { - const res = await callList('') - expect(res.status).toBe(400) - expect((await res.json()).error.code).toBe('BAD_REQUEST') - expect(mockQueryTables).not.toHaveBeenCalled() - }) + it('lists through the semantic use case and preserves the cursor envelope', async () => { + const request = new NextRequest( + `http://localhost:3000/api/v2/tables?workspaceId=${WORKSPACE_ID}&limit=25` + ) + const response = await GET(request) - it('surfaces an access-denied failure in the v2 error envelope', async () => { - mockResolveWorkspaceAccess.mockResolvedValue({ - status: 403, - code: 'FORBIDDEN', - message: 'Access denied', + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ + data: [{ id: 'table-1', folderPath: '/', description: null }], + nextCursor: null, }) - const res = await callList('workspaceId=workspace-1') - expect(res.status).toBe(403) - expect((await res.json()).error).toMatchObject({ code: 'FORBIDDEN', message: 'Access denied' }) - expect(mockQueryTables).not.toHaveBeenCalled() - }) - - it('returns the rate-limit response when denied', async () => { - mockCheckRateLimit.mockResolvedValue({ - allowed: false, - limit: 100, - remaining: 0, - resetAt: new Date('2024-01-01T01:00:00Z'), - retryAfterMs: 1000, + expect(mocks.list).toHaveBeenCalledWith({ + principal, + input: expect.objectContaining({ workspaceId: WORKSPACE_ID, limit: 25 }), + request, }) - const res = await callList('workspaceId=workspace-1') - expect(res.status).toBe(429) - expect((await res.json()).error.code).toBe('RATE_LIMITED') }) - it('400s on a sort field outside the enum instead of letting it reach the query', async () => { - const res = await callList(`workspaceId=workspace-1&sortBy=name);--`) - expect(res.status).toBe(400) - expect((await res.json()).error.code).toBe('BAD_REQUEST') - }) - - it('400s on a sort direction outside the enum', async () => { - const res = await callList(`workspaceId=workspace-1&sortOrder=sideways`) - - expect(res.status).toBe(400) - }) - - it('400s on an empty search rather than treating it as unsearched', async () => { - const res = await callList(`workspaceId=workspace-1&search=`) - - expect(res.status).toBe(400) - }) - - it('forwards search and sort into the query and still terminates pagination', async () => { - const res = await callList(`workspaceId=workspace-1&search=report&sortBy=name&sortOrder=asc`) + it('authenticates and rate-limits before rejecting invalid query input', async () => { + const response = await GET(new NextRequest('http://localhost:3000/api/v2/tables')) - expect(res.status).toBe(200) - expect((await res.json()).nextCursor).toBeNull() + expect(response.status).toBe(400) + expect(mocks.authenticate).toHaveBeenCalled() + expect(mocks.operationRate).toHaveBeenCalled() + expect(mocks.list).not.toHaveBeenCalled() }) - it('treats folderPath=/ as root-only while omission lists every folder', async () => { - await callList('workspaceId=workspace-1&folderPath=%2F') + it('maps operation rate-limit infrastructure failures to service unavailable', async () => { + mocks.operationRate.mockRejectedValueOnce(new Error('rate store unavailable')) - expect(mockQueryTables).toHaveBeenCalledWith( - 'workspace-1', - expect.objectContaining({ folderId: null }) + const response = await GET( + new NextRequest(`http://localhost:3000/api/v2/tables?workspaceId=${WORKSPACE_ID}&limit=25`) ) - }) - - it('passes limit and the decoded cursor through to the query', async () => { - mockQueryTables.mockResolvedValue({ tables: [buildTable()], nextKeys: null }) - - await callList('workspaceId=workspace-1&limit=25&sortBy=name&sortOrder=desc') - - // The slice must happen in the query, not after a full-workspace read. - expect(mockQueryTables).toHaveBeenCalledWith( - 'workspace-1', - expect.objectContaining({ limit: 25, sortBy: 'name', sortOrder: 'desc' }) - ) - }) - - it('returns a nextCursor when the query reports another page', async () => { - mockQueryTables.mockResolvedValue({ tables: [buildTable()], nextKeys: ['Alpha', 'tbl_1'] }) - const res = await callList('workspaceId=workspace-1&limit=1') - const body = await res.json() - - expect(res.status).toBe(200) - expect(body.nextCursor).toEqual(expect.any(String)) - }) - - it('rejects a cursor that does not match the requested sort', async () => { - const first = await callList('workspaceId=workspace-1&sortBy=name') - // Encoded under sortBy=name, replayed under sortBy=createdAt. - mockQueryTables.mockResolvedValue({ tables: [buildTable()], nextKeys: ['Alpha', 'tbl_1'] }) - const paged = await callList('workspaceId=workspace-1&sortBy=name&limit=1') - const cursor = (await paged.json()).nextCursor - - const res = await callList( - `?workspaceId=workspace-1&sortBy=createdAt&cursor=${encodeURIComponent(cursor)}` - ) - - expect(res.status).toBe(400) - expect(first.status).toBe(200) - }) -}) - -describe('POST /api/v2/tables', () => { - beforeEach(() => { - vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockGetWorkspaceTableLimits.mockResolvedValue({ maxTables: 100 }) - mockResolveFolderPathIdentity.mockResolvedValue({ - found: true, - folderId: 'folder-1', - index: { - rowById: new Map(), - pathById: new Map([['folder-1', '/Reports']]), - idByPath: new Map([['/Reports', 'folder-1']]), + expect(response.status).toBe(503) + expect(await response.json()).toEqual({ + error: { + code: 'SERVICE_UNAVAILABLE', + message: 'Service temporarily unavailable', }, }) - mockCreateTable.mockResolvedValue({ ...buildTable(), folderId: 'folder-1' }) + expect(mocks.list).not.toHaveBeenCalled() }) - it('resolves a slashless folder path before creating the table outside the folder lock', async () => { - const res = await callCreate({ - workspaceId: 'workspace-1', - name: 'People', - folderPath: 'Reports', - schema: { columns: [{ name: 'email', type: 'string' }] }, + it('creates through the shared use case and keeps the 201 response contract', async () => { + const request = new NextRequest('http://localhost:3000/api/v2/tables', { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-api-key': 'secret' }, + body: JSON.stringify({ + workspaceId: WORKSPACE_ID, + name: 'Contacts', + schema: { columns: [{ name: 'Name', type: 'string' }] }, + }), }) - - expect(res.status).toBe(201) - expect(mockResolveFolderPathIdentity).toHaveBeenCalledWith({ - workspaceId: 'workspace-1', - resourceType: 'table', - path: '/Reports', + const response = await POST(request) + + expect(response.status).toBe(201) + expect((await response.json()).data.table.id).toBe('table-1') + expect(mocks.create).toHaveBeenCalledWith({ + principal, + input: expect.objectContaining({ workspaceId: WORKSPACE_ID, name: 'Contacts' }), + request, }) - expect(mockCreateTable).toHaveBeenCalledWith( - expect.objectContaining({ folderId: 'folder-1' }), - expect.any(String) - ) - expect((await res.json()).data.table.folderPath).toBe('/Reports') }) }) diff --git a/apps/sim/app/api/v2/tables/route.ts b/apps/sim/app/api/v2/tables/route.ts index 7caef0960a8..a770fc09ea3 100644 --- a/apps/sim/app/api/v2/tables/route.ts +++ b/apps/sim/app/api/v2/tables/route.ts @@ -1,132 +1,58 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { v2CreateTableContract, v2ListTablesContract } from '@/lib/api/contracts/v2/tables' -import { isZodError } from '@/lib/api/server' -import { loadActiveFolderPathIndex } from '@/lib/folders/queries' -import { createTable, getWorkspaceTableLimits, queryTables, type TableSchema } from '@/lib/table' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { normalizeColumn } from '@/app/api/table/utils' -import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { - folderPathForId, - resolveFolderPathId, - resolveFolderPathIdentity, -} from '@/app/api/v2/lib/folders' -import { - cursorSortKey, - decodeSortedCursor, - encodeSortedCursor, - v2CaughtOrchestrationError, - v2CursorList, - v2CursorSortError, - v2Data, - v2Error, - v2ValidationError, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' +import { INVALID_CURSOR_MESSAGE } from '@/lib/api/list-query' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { v2TableErrorPolicies } from '@/lib/table/api' +import { tableOperations } from '@/lib/table/application/operations' +import { createTableUseCase, listTablesUseCase } from '@/lib/table/application/tables' +import { cursorSortKey, decodeSortedCursor, encodeSortedCursor } from '@/app/api/v2/lib/response' import { toApiTable } from '@/app/api/v2/tables/utils' export const dynamic = 'force-dynamic' export const revalidate = 0 -/** GET /api/v2/tables — List all tables in a workspace. */ -export const GET = withPublicApiRouteHandler({ +export const GET = defineV2JsonRoute({ contract: v2ListTablesContract, - rateLimitEndpoint: 'tables', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const { workspaceId, folderPath, search, sortBy, sortOrder, limit, cursor } = input.query - - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') - if (access) return v2WorkspaceAccessError(access) - - const folderIndex = await loadActiveFolderPathIndex(workspaceId, 'table') - const folderId = - folderPath === undefined ? undefined : resolveFolderPathId(folderIndex, folderPath) - if (folderPath !== undefined && folderId === undefined) { - return v2Error('NOT_FOUND', 'Folder not found') + operation: tableOperations.list, + useCase: listTablesUseCase, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableErrorPolicies.default, + mapInput: ({ query }) => { + const sort = cursorSortKey(query.sortBy, query.sortOrder) + const decoded = decodeSortedCursor(query.cursor, sort) + if (decoded.status === 'invalid') { + throw new OrchestrationError('validation', INVALID_CURSOR_MESSAGE) } - - const sort = cursorSortKey(sortBy, sortOrder) - const decoded = decodeSortedCursor(cursor, sort) - if (decoded.status === 'invalid') return v2CursorSortError() - - const { tables, nextKeys } = await queryTables(workspaceId, { - folderId, - search, - sortBy, - sortOrder, - limit, + return { + workspaceId: query.workspaceId, + folderPath: query.folderPath, + search: query.search, + sortBy: query.sortBy, + sortOrder: query.sortOrder, + limit: query.limit, after: decoded.status === 'ok' ? decoded.keys : undefined, - }) - - const items = tables.map((table) => - toApiTable(table, folderPathForId(folderIndex, table.folderId)) - ) - const nextCursor = nextKeys ? encodeSortedCursor(sort, nextKeys) : null - - return v2CursorList(items, nextCursor, { rateLimit }) + } }, + present: ({ tables, nextKeys, sortBy, sortOrder }) => ({ + data: tables.map(({ table, folderPath }) => toApiTable(table, folderPath)), + nextCursor: nextKeys ? encodeSortedCursor(cursorSortKey(sortBy, sortOrder), nextKeys) : null, + }), }) -/** POST /api/v2/tables — Create a new table. */ -export const POST = withPublicApiRouteHandler({ +export const POST = defineV2JsonRoute({ contract: v2CreateTableContract, - rateLimitEndpoint: 'tables', - handler: async ({ request, input, auth: { requestId, userId, rateLimit } }) => { - try { - const params = input.body - - const access = await resolveWorkspaceAccess(rateLimit, userId, params.workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - - const planLimits = await getWorkspaceTableLimits(params.workspaceId) - - const normalizedSchema: TableSchema = { - columns: params.schema.columns.map(normalizeColumn), - } - - const resolution = await resolveFolderPathIdentity({ - workspaceId: params.workspaceId, - resourceType: 'table', - path: params.folderPath ?? '/', - }) - if (!resolution.found) return v2Error('NOT_FOUND', 'Folder not found') - - const table = await createTable( - { - name: params.name, - description: params.description, - schema: normalizedSchema, - workspaceId: params.workspaceId, - userId, - maxTables: planLimits.maxTables, - folderId: resolution.folderId, - }, - requestId - ) - - recordAudit({ - workspaceId: params.workspaceId, - actorId: userId, - action: AuditAction.TABLE_CREATED, - resourceType: AuditResourceType.TABLE, - resourceId: table.id, - resourceName: table.name, - description: `Created table "${table.name}" via API`, - metadata: { columnCount: params.schema.columns.length }, - request, - }) - - return v2Data( - { table: toApiTable(table, folderPathForId(resolution.index, table.folderId)) }, - { rateLimit, status: 201 } - ) - } catch (error) { - if (isZodError(error)) return v2ValidationError(error) - - const classified = v2CaughtOrchestrationError(error) - if (classified) return classified - - throw error - } - }, + operation: tableOperations.create, + useCase: createTableUseCase, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2TableErrorPolicies.default, + mapInput: ({ body }) => ({ + workspaceId: body.workspaceId, + name: body.name, + description: body.description, + schema: body.schema, + folderPath: body.folderPath, + }), + present: ({ table, folderPath }) => ({ data: { table: toApiTable(table, folderPath) } }), }) diff --git a/apps/sim/app/api/v2/tables/utils.ts b/apps/sim/app/api/v2/tables/utils.ts index 6f49b8deb77..b173e3a6936 100644 --- a/apps/sim/app/api/v2/tables/utils.ts +++ b/apps/sim/app/api/v2/tables/utils.ts @@ -57,7 +57,7 @@ export function toApiTable(table: TableDefinition, folderPath: string) { return { id: table.id, name: table.name, - description: table.description, + description: table.description ?? null, schema: { columns: (table.schema as TableSchema).columns.map(normalizeColumn), }, diff --git a/apps/sim/lib/api/contracts/v2/tables.ts b/apps/sim/lib/api/contracts/v2/tables.ts index ff4fe06f853..6a9d7857393 100644 --- a/apps/sim/lib/api/contracts/v2/tables.ts +++ b/apps/sim/lib/api/contracts/v2/tables.ts @@ -262,6 +262,7 @@ export const v2CreateTableContract = defineRouteContract({ response: { mode: 'json', schema: v2DataResponse(v2TableDataSchema), + status: 201, }, }) @@ -343,7 +344,7 @@ export const v2CreateTableFolderContract = defineRouteContract({ method: 'POST', path: '/api/v2/tables/folders', body: v2CreateFolderBodySchema, - response: { mode: 'json', schema: v2DataResponse(v2TableFolderDataSchema) }, + response: { mode: 'json', schema: v2DataResponse(v2TableFolderDataSchema), status: 201 }, }) export const v2RelocateTableFolderContract = defineRouteContract({ @@ -648,6 +649,7 @@ export const v2CreateTableViewContract = defineRouteContract({ response: { mode: 'json', schema: v2DataResponse(v2TableViewDataSchema), + status: 201, }, }) @@ -834,6 +836,7 @@ export const v2AddWorkflowGroupContract = defineRouteContract({ response: { mode: 'json', schema: v2DataResponse(v2WorkflowGroupDataSchema), + status: 201, }, }) @@ -1131,7 +1134,11 @@ export const v2CreateTableImportContract = defineRouteContract({ method: 'POST', path: '/api/v2/tables/imports', body: v2CreateTableImportBodySchema, - response: { mode: 'json', schema: v2DataResponse(v2CreateTableImportDataSchema) }, + response: { + mode: 'json', + schema: v2DataResponse(v2CreateTableImportDataSchema), + status: 201, + }, }) export const v2GetTableImportContract = defineRouteContract({ @@ -1198,7 +1205,7 @@ export const v2CreateTableExportContract = defineRouteContract({ path: '/api/v2/tables/[tableId]/exports', params: tableIdParamsSchema, body: exportTableAsyncBodySchema, - response: { mode: 'json', schema: v2DataResponse(v2TableExportSchema) }, + response: { mode: 'json', schema: v2DataResponse(v2TableExportSchema), status: 201 }, }) export const v2GetTableExportContract = defineRouteContract({ diff --git a/apps/sim/lib/copilot/application/execute-table-use-case.ts b/apps/sim/lib/copilot/application/execute-table-use-case.ts new file mode 100644 index 00000000000..536a05f9161 --- /dev/null +++ b/apps/sim/lib/copilot/application/execute-table-use-case.ts @@ -0,0 +1,63 @@ +import { + type CopilotTableDelegationContext, + resolveCopilotTablePrincipal, +} from '@/lib/copilot/auth/table-delegation' +import type { OperationUseCase } from '@/lib/core/application' +import { defineAuthorizedTableUseCase } from '@/lib/table/application/authorized-table-use-case' +import { + resolveActiveTableContext, + resolveTableWorkspaceContext, +} from '@/lib/table/application/context' +import { type TableOperation, tableOperations } from '@/lib/table/application/operations' + +const registeredTableOperationIds = new Set( + Object.values(tableOperations).map((operation) => operation.id) +) + +interface ExecuteCopilotTableUseCaseOptions { + tableId?: string +} + +export interface AdmitCopilotTableOperationInput { + workspaceId: string + tableId?: string +} + +/** Enters a registered table application use case under trusted Copilot delegation. */ +export function executeCopilotTableUseCase( + context: CopilotTableDelegationContext | undefined, + useCase: OperationUseCase, + input: I, + options: ExecuteCopilotTableUseCaseOptions = {} +): Promise { + if (!registeredTableOperationIds.has(useCase.operation.id)) { + throw new Error(`Unregistered Copilot table operation: ${useCase.operation.id}`) + } + return useCase.execute({ + principal: resolveCopilotTablePrincipal(context, options.tableId), + input, + }) +} + +/** + * Authorizes a Copilot operation that still retains a compatibility-specific + * presenter or execution strategy before that trusted adapter invokes it. + */ +export function admitCopilotTableOperation( + context: CopilotTableDelegationContext | undefined, + operation: O, + input: AdmitCopilotTableOperationInput +): Promise { + const useCase = defineAuthorizedTableUseCase({ + operation, + resolveContext: ({ input: admitted }: { input: AdmitCopilotTableOperationInput }) => + admitted.tableId + ? resolveActiveTableContext({ + tableId: admitted.tableId, + assertedWorkspaceId: admitted.workspaceId, + }) + : resolveTableWorkspaceContext(admitted.workspaceId), + async execute() {}, + }) + return executeCopilotTableUseCase(context, useCase, input, { tableId: input.tableId }) +} diff --git a/apps/sim/lib/copilot/auth/table-delegation.test.ts b/apps/sim/lib/copilot/auth/table-delegation.test.ts new file mode 100644 index 00000000000..33dc57ea01f --- /dev/null +++ b/apps/sim/lib/copilot/auth/table-delegation.test.ts @@ -0,0 +1,44 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { resolveCopilotTablePrincipal } from '@/lib/copilot/auth/table-delegation' + +describe('Copilot table delegation', () => { + const context = { + userId: 'user-1', + workspaceId: 'workspace-1', + toolCallId: 'tool-call-1', + chatId: 'chat-1', + executionId: 'execution-1', + copilotToolExecution: true, + } as const + + it('binds the trusted workspace, subject, tool call, and table scope', () => { + expect(resolveCopilotTablePrincipal(context, 'table-1')).toMatchObject({ + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'copilot-tool:tool-call-1', + audience: 'sim:tables', + resourceScope: { + tableId: 'table-1', + chatId: 'chat-1', + executionId: 'execution-1', + }, + }) + }) + + it('rejects untrusted or incomplete contexts', () => { + expect(() => + resolveCopilotTablePrincipal({ ...context, copilotToolExecution: false }, 'table-1') + ).toThrow('trusted Copilot execution context') + expect(() => + resolveCopilotTablePrincipal({ ...context, workspaceId: undefined }, 'table-1') + ).toThrow('workspace ID') + expect(() => + resolveCopilotTablePrincipal({ ...context, toolCallId: undefined }, 'table-1') + ).toThrow('tool call ID') + }) +}) diff --git a/apps/sim/lib/copilot/auth/table-delegation.ts b/apps/sim/lib/copilot/auth/table-delegation.ts new file mode 100644 index 00000000000..219985dc404 --- /dev/null +++ b/apps/sim/lib/copilot/auth/table-delegation.ts @@ -0,0 +1,44 @@ +import type { DelegatedPrincipal } from '@sim/auth/principal' +import { asOrchestrationError } from '@/lib/core/orchestration/types' +import { createTableDelegatedPrincipal } from '@/lib/table/application/delegated-principal' + +export interface CopilotTableDelegationContext { + userId: string + workspaceId?: string + chatId?: string + executionId?: string + toolCallId?: string + copilotToolExecution?: boolean +} + +/** Normalizes trusted Copilot execution context into the shared table principal. */ +export function resolveCopilotTablePrincipal( + context: CopilotTableDelegationContext | undefined, + tableId?: string +): DelegatedPrincipal { + if (!context) throw new Error('Table delegation requires a Copilot execution context') + if (!context.copilotToolExecution) { + throw new Error('Table delegation requires a trusted Copilot execution context') + } + if (!context.toolCallId) throw new Error('Table delegation requires a tool call ID') + if (!context.workspaceId) throw new Error('Table delegation requires a workspace ID') + + return createTableDelegatedPrincipal({ + serviceId: 'copilot', + subjectUserId: context.userId, + workspaceId: context.workspaceId, + delegationId: `copilot-tool:${context.toolCallId}`, + tableId, + chatId: context.chatId, + executionId: context.executionId, + }) +} + +export function messageForCopilotTableError( + error: unknown, + fallback = 'Table operation failed' +): string { + const classified = asOrchestrationError(error) + if (classified && classified.code !== 'internal') return classified.message + return fallback +} diff --git a/apps/sim/lib/copilot/request/tools/tables.test.ts b/apps/sim/lib/copilot/request/tools/tables.test.ts index 55ad05c06e7..9d0eb39c9a3 100644 --- a/apps/sim/lib/copilot/request/tools/tables.test.ts +++ b/apps/sim/lib/copilot/request/tools/tables.test.ts @@ -6,18 +6,18 @@ import { loggerMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { TableDefinition } from '@/lib/table' -const { mockGetTableById, mockReplaceTableRows, mockSpanAddEvent } = vi.hoisted(() => ({ - mockGetTableById: vi.fn(), +const { mockReadTable, mockReplaceTableRows, mockSpanAddEvent } = vi.hoisted(() => ({ + mockReadTable: vi.fn(), mockReplaceTableRows: vi.fn(), mockSpanAddEvent: vi.fn(), })) -vi.mock('@/lib/table/service', () => ({ - getTableById: mockGetTableById, +vi.mock('@/lib/table/application/tables', () => ({ + readTableUseCase: { execute: mockReadTable }, })) -vi.mock('@/lib/table/rows/service', () => ({ - replaceTableRows: mockReplaceTableRows, +vi.mock('@/lib/table/application/rows', () => ({ + replaceTableRows: { execute: mockReplaceTableRows }, })) vi.mock('@/lib/copilot/request/otel', () => ({ @@ -76,6 +76,8 @@ function buildContext(overrides: Partial = {}): ExecutionConte workflowId: 'wf-1', workspaceId: 'workspace-1', userPermission: 'write', + copilotToolExecution: true, + toolCallId: 'tool-call-1', resolvedSecretTraceRegistry: new ResolvedSecretTraceRegistry(), ...overrides, } @@ -84,12 +86,15 @@ function buildContext(overrides: Partial = {}): ExecutionConte describe('maybeWriteOutputToTable', () => { beforeEach(() => { vi.clearAllMocks() - mockGetTableById.mockResolvedValue(buildTable()) - mockReplaceTableRows.mockResolvedValue({ deletedCount: 0, insertedCount: 2 }) + mockReadTable.mockResolvedValue({ table: buildTable(), folderPath: '/' }) + mockReplaceTableRows.mockImplementation(async ({ input }: { input: { rows: unknown[] } }) => ({ + deletedCount: 0, + insertedCount: input.rows.length, + })) }) it('rejects a table from another workspace without touching it', async () => { - mockGetTableById.mockResolvedValue(buildTable({ workspaceId: 'other-workspace' })) + mockReadTable.mockRejectedValue(new Error('Table not found')) const result = await maybeWriteOutputToTable( FunctionExecute.id, @@ -98,7 +103,10 @@ describe('maybeWriteOutputToTable', () => { buildContext() ) - expect(result).toEqual({ success: false, error: 'Table "tbl_1" not found' }) + expect(result).toEqual({ + success: false, + error: 'Failed to write to table: Table operation failed', + }) expect(mockReplaceTableRows).not.toHaveBeenCalled() }) @@ -112,7 +120,7 @@ describe('maybeWriteOutputToTable', () => { expect(result.success).toBe(false) expect(result.error).toContain('requires write access') - expect(mockGetTableById).not.toHaveBeenCalled() + expect(mockReadTable).not.toHaveBeenCalled() expect(mockReplaceTableRows).not.toHaveBeenCalled() }) @@ -134,17 +142,15 @@ describe('maybeWriteOutputToTable', () => { expect(result.success).toBe(true) expect(mockReplaceTableRows).toHaveBeenCalledTimes(1) - const [data, table] = mockReplaceTableRows.mock.calls[0] - expect(data).toMatchObject({ + const [{ input }] = mockReplaceTableRows.mock.calls[0] + expect(input).toMatchObject({ tableId: 'tbl_1', - workspaceId: 'workspace-1', - userId: 'user-1', + assertedWorkspaceId: 'workspace-1', rows: [ - { col_name: 'Alice', col_age: 30 }, - { col_name: 'Bob', col_age: 40 }, + { name: 'Alice', age: 30 }, + { name: 'Bob', age: 40 }, ], }) - expect(table.id).toBe('tbl_1') }) it('projects activated secrets before persistence without rewriting sibling literals', async () => { @@ -173,10 +179,8 @@ describe('maybeWriteOutputToTable', () => { ) expect(result.success).toBe(true) - const persistedRows = mockReplaceTableRows.mock.calls[0][0].rows - expect(persistedRows).toEqual([ - { col_name: '{{OUTPUT_SECRET}}', col_age: '123', col_status: 'true' }, - ]) + const persistedRows = mockReplaceTableRows.mock.calls[0][0].input.rows + expect(persistedRows).toEqual([{ name: '{{OUTPUT_SECRET}}', age: '123', status: 'true' }]) expect(runtimeRows).toEqual([{ name: 'secret-value', age: '123', status: 'true' }]) const modelFacing = projectToolResultForCopilot( @@ -185,7 +189,7 @@ describe('maybeWriteOutputToTable', () => { ) expect(modelFacing.output).toEqual({ data: { - rows: [{ col_name: '{{OUTPUT_SECRET}}', col_age: '123', col_status: 'true' }], + rows: [{ name: '{{OUTPUT_SECRET}}', age: '123', status: 'true' }], }, }) @@ -224,9 +228,7 @@ describe('maybeWriteOutputToTable', () => { expect(result.success).toBe(true) expect(mockReplaceTableRows).toHaveBeenCalledWith( - expect.objectContaining({ rows: [{ col_name: 'unknown' }] }), - expect.anything(), - expect.any(String) + expect.objectContaining({ input: expect.objectContaining({ rows: [{ name: 'unknown' }] }) }) ) }) @@ -267,7 +269,21 @@ describe('maybeWriteOutputToTable', () => { ) expect(result.success).toBe(false) - expect(result.error).toContain('Row 1: name is required') + expect(result.error).toContain('Table operation failed') + }) + + it('fails fast when authoritative inserted count differs from the requested rows', async () => { + mockReplaceTableRows.mockResolvedValue({ deletedCount: 1, insertedCount: 1 }) + + const result = await maybeWriteOutputToTable( + FunctionExecute.id, + { outputTable: 'tbl_1' }, + { success: true, output: { result: [{ name: 'Alice' }, { name: 'Bob' }] } }, + buildContext() + ) + + expect(result.success).toBe(false) + expect(result.error).toContain('Table operation failed') }) it('keeps raw errors for terminal projection but projects application logs and OTel events', async () => { @@ -284,10 +300,10 @@ describe('maybeWriteOutputToTable', () => { buildContext({ resolvedSecretTraceRegistry: registry }) ) - expect(result.error).toContain('secret-value') - expect(JSON.stringify(tableLogger?.warn.mock.calls)).toContain('{{SECRET}}') + expect(result.error).not.toContain('secret-value') + expect(JSON.stringify(tableLogger?.warn.mock.calls)).toContain('Table operation failed') expect(JSON.stringify(tableLogger?.warn.mock.calls)).not.toContain('secret-value') - expect(JSON.stringify(mockSpanAddEvent.mock.calls)).toContain('{{SECRET}}') + expect(JSON.stringify(mockSpanAddEvent.mock.calls)).toContain('Table operation failed') expect(JSON.stringify(mockSpanAddEvent.mock.calls)).not.toContain('secret-value') }) }) @@ -295,12 +311,15 @@ describe('maybeWriteOutputToTable', () => { describe('maybeWriteReadCsvToTable', () => { beforeEach(() => { vi.clearAllMocks() - mockGetTableById.mockResolvedValue(buildTable()) - mockReplaceTableRows.mockResolvedValue({ deletedCount: 0, insertedCount: 2 }) + mockReadTable.mockResolvedValue({ table: buildTable(), folderPath: '/' }) + mockReplaceTableRows.mockImplementation(async ({ input }: { input: { rows: unknown[] } }) => ({ + deletedCount: 0, + insertedCount: input.rows.length, + })) }) it('rejects a table from another workspace without touching it', async () => { - mockGetTableById.mockResolvedValue(buildTable({ workspaceId: 'other-workspace' })) + mockReadTable.mockRejectedValue(new Error('Table not found')) const result = await maybeWriteReadCsvToTable( ReadTool.id, @@ -309,7 +328,10 @@ describe('maybeWriteReadCsvToTable', () => { buildContext() ) - expect(result).toEqual({ success: false, error: 'Table "tbl_1" not found' }) + expect(result).toEqual({ + success: false, + error: 'Failed to import into table: Table operation failed', + }) expect(mockReplaceTableRows).not.toHaveBeenCalled() }) @@ -323,7 +345,7 @@ describe('maybeWriteReadCsvToTable', () => { expect(result.success).toBe(false) expect(result.error).toContain('requires write access') - expect(mockGetTableById).not.toHaveBeenCalled() + expect(mockReadTable).not.toHaveBeenCalled() expect(mockReplaceTableRows).not.toHaveBeenCalled() }) @@ -336,10 +358,10 @@ describe('maybeWriteReadCsvToTable', () => { ) expect(result.success).toBe(true) - const [data] = mockReplaceTableRows.mock.calls[0] - expect(data.rows).toEqual([ - { col_name: 'Alice', col_age: '30' }, - { col_name: 'Bob', col_age: '40' }, + const [{ input }] = mockReplaceTableRows.mock.calls[0] + expect(input.rows).toEqual([ + { name: 'Alice', age: '30' }, + { name: 'Bob', age: '40' }, ]) }) @@ -361,15 +383,15 @@ describe('maybeWriteReadCsvToTable', () => { expect(result.success).toBe(true) expect(mockReplaceTableRows).toHaveBeenCalledWith( expect.objectContaining({ - rows: [ - { - col_name: '{{NUMBER}}', - col_status: '{{BOOLEAN}}', - }, - ], - }), - expect.anything(), - expect.any(String) + input: expect.objectContaining({ + rows: [ + { + name: '{{NUMBER}}', + status: '{{BOOLEAN}}', + }, + ], + }), + }) ) }) @@ -427,10 +449,10 @@ describe('maybeWriteReadCsvToTable', () => { expect(result.success).toBe(true) expect(mockReplaceTableRows).toHaveBeenCalledWith( expect.objectContaining({ - rows: [{ col_name: 'legacy-value', col_age: '123', col_active: 'true' }], - }), - expect.anything(), - expect.any(String) + input: expect.objectContaining({ + rows: [{ name: 'legacy-value', age: '123', active: 'true' }], + }), + }) ) }) @@ -458,7 +480,7 @@ describe('maybeWriteReadCsvToTable', () => { ) expect(result.success).toBe(false) - expect(result.error).toContain('Row 1: name is required') + expect(result.error).toContain('Table operation failed') }) it('projects active secret literals in CSV-import log and OTel errors', async () => { @@ -475,10 +497,10 @@ describe('maybeWriteReadCsvToTable', () => { buildContext({ resolvedSecretTraceRegistry: registry }) ) - expect(result.error).toContain('secret-value') - expect(JSON.stringify(tableLogger?.warn.mock.calls)).toContain('{{SECRET}}') + expect(result.error).not.toContain('secret-value') + expect(JSON.stringify(tableLogger?.warn.mock.calls)).toContain('Table operation failed') expect(JSON.stringify(tableLogger?.warn.mock.calls)).not.toContain('secret-value') - expect(JSON.stringify(mockSpanAddEvent.mock.calls)).toContain('{{SECRET}}') + expect(JSON.stringify(mockSpanAddEvent.mock.calls)).toContain('Table operation failed') expect(JSON.stringify(mockSpanAddEvent.mock.calls)).not.toContain('secret-value') }) }) diff --git a/apps/sim/lib/copilot/request/tools/tables.ts b/apps/sim/lib/copilot/request/tools/tables.ts index b8158308986..edf6dcb33af 100644 --- a/apps/sim/lib/copilot/request/tools/tables.ts +++ b/apps/sim/lib/copilot/request/tools/tables.ts @@ -1,9 +1,11 @@ import { isDeepStrictEqual } from 'node:util' import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' -import { generateId } from '@sim/utils/id' import { isPlainRecord } from '@sim/utils/object' import { parse as csvParse } from 'csv-parse/sync' +import { + messageForCopilotTableError, + resolveCopilotTablePrincipal, +} from '@/lib/copilot/auth/table-delegation' import { FunctionExecute, Read as ReadTool } from '@/lib/copilot/generated/tool-catalog-v1' import { CopilotTableOutcome } from '@/lib/copilot/generated/trace-attribute-values-v1' import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' @@ -17,11 +19,10 @@ import { } from '@/lib/copilot/request/tools/resolved-secret-result' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' import type { RowData, TableDefinition } from '@/lib/table' -import { buildIdByName, rowDataNameToId } from '@/lib/table/column-keys' +import { replaceTableRows } from '@/lib/table/application/rows' +import { readTableUseCase } from '@/lib/table/application/tables' import { columnTypeOf } from '@/lib/table/column-types' import { createExactEmptyTableRowSecretProvenance } from '@/lib/table/rows/secret-provenance' -import { replaceTableRows } from '@/lib/table/rows/service' -import { getTableById } from '@/lib/table/service' const logger = createLogger('CopilotToolResultTables') @@ -53,46 +54,63 @@ function hasUnsupportedProjectedCell( * locking, validation, plan row limits, batching, and rowCount maintenance. */ async function replaceTableRowsFromWire( - table: TableDefinition, + tableId: string, rows: Array>, context: ExecutionContext -): Promise<{ error?: string }> { +): Promise< + | { success: false; error: string } + | { success: true; table: TableDefinition; insertedCount: number; deletedCount: number } +> { + const principal = resolveCopilotTablePrincipal(context, tableId) + const { table } = await readTableUseCase.execute({ + principal, + input: { tableId, workspaceId: principal.workspaceId }, + }) const persistenceProjection = context.resolvedSecretTraceRegistry ? projectToolOutputForPersistence(rows, context.resolvedSecretTraceRegistry) : { safe: true as const, value: rows } - if (!persistenceProjection.safe) return { error: persistenceProjection.error } + if (!persistenceProjection.safe) { + return { success: false, error: persistenceProjection.error } + } if ( !Array.isArray(persistenceProjection.value) || !persistenceProjection.value.every(isPlainRecord) ) { - return { error: 'Table rows could not be persisted safely' } + return { success: false, error: 'Table rows could not be persisted safely' } } if (hasUnsupportedProjectedCell(table, rows, persistenceProjection.value)) { - return { error: TABLE_SECRET_PROJECTION_UNSUPPORTED_ERROR } + return { success: false, error: TABLE_SECRET_PROJECTION_UNSUPPORTED_ERROR } } - const idByName = buildIdByName(table.schema) - const idKeyedRows = persistenceProjection.value.map((row) => - rowDataNameToId(row as RowData, idByName) + const projectedRows = persistenceProjection.value.map((row) => row as RowData) + const columnNames = new Set(table.schema.columns.map((column) => column.name)) + const emptyIndex = projectedRows.findIndex( + (row) => !Object.keys(row).some((name) => columnNames.has(name)) ) - const emptyIndex = idKeyedRows.findIndex((row) => Object.keys(row).length === 0) if (emptyIndex !== -1) { return { + success: false, error: `Row ${emptyIndex + 1} has no keys matching columns on table "${table.name}" (columns: ${table.schema.columns.map((c) => c.name).join(', ')})`, } } - await replaceTableRows( - { + const replacement = await replaceTableRows.execute({ + principal, + input: { tableId: table.id, - rows: idKeyedRows, - workspaceId: table.workspaceId, - userId: context.userId, - secretProvenance: idKeyedRows.map(createExactEmptyTableRowSecretProvenance), + assertedWorkspaceId: principal.workspaceId, + rows: projectedRows, + secretProvenance: projectedRows.map(createExactEmptyTableRowSecretProvenance), }, + }) + if (replacement.insertedCount !== projectedRows.length) { + throw new Error('Table row replacement inserted an unexpected row count') + } + return { + success: true, table, - generateId().slice(0, 8) - ) - return {} + insertedCount: replacement.insertedCount, + deletedCount: replacement.deletedCount, + } } export async function maybeWriteOutputToTable( @@ -103,8 +121,6 @@ export async function maybeWriteOutputToTable( ): Promise { if (toolName !== FunctionExecute.id) return result if (!result.success || !result.output) return result - if (!context.workspaceId || !context.userId) return result - const outputTable = params?.outputTable as string | undefined if (!outputTable) return result @@ -116,19 +132,10 @@ export async function maybeWriteOutputToTable( { [TraceAttr.ToolName]: toolName, [TraceAttr.CopilotTableId]: outputTable, - [TraceAttr.WorkspaceId]: context.workspaceId, + [TraceAttr.WorkspaceId]: context.workspaceId ?? '', }, async (span) => { try { - const table = await getTableById(outputTable) - if (!table || table.workspaceId !== context.workspaceId) { - span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.TableNotFound) - return { - success: false, - error: `Table "${outputTable}" not found`, - } - } - const rawOutput = result.output let rows: Array> @@ -174,8 +181,8 @@ export async function maybeWriteOutputToTable( if (context.abortSignal?.aborted) { throw new Error('Request aborted before tool mutation could be applied') } - const replaceResult = await replaceTableRowsFromWire(table, rows, context) - if (replaceResult.error) { + const replaceResult = await replaceTableRowsFromWire(outputTable, rows, context) + if (!replaceResult.success) { span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.InvalidShape) return { success: false, error: replaceResult.error } } @@ -183,21 +190,22 @@ export async function maybeWriteOutputToTable( logger.info('Tool output written to table', { toolName, tableId: outputTable, - rowCount: rows.length, + rowCount: replaceResult.insertedCount, + deletedCount: replaceResult.deletedCount, }) span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.Wrote) return { success: true, output: { - message: `Wrote ${rows.length} rows to table ${outputTable}`, + message: `Wrote ${replaceResult.insertedCount} rows to table ${outputTable}`, tableId: outputTable, - rowCount: rows.length, + rowCount: replaceResult.insertedCount, }, } } catch (err) { - const rawMessage = toError(err).message + const safeMessage = messageForCopilotTableError(err) const projectedMessage = projectToolErrorMessageForCopilot( - rawMessage, + safeMessage, context.resolvedSecretTraceRegistry ) logger.warn('Failed to write tool output to table', { @@ -211,7 +219,7 @@ export async function maybeWriteOutputToTable( }) return { success: false, - error: `Failed to write to table: ${rawMessage}`, + error: `Failed to write to table: ${projectedMessage}`, } } } @@ -226,8 +234,6 @@ export async function maybeWriteReadCsvToTable( ): Promise { if (toolName !== ReadTool.id) return result if (!result.success || !result.output) return result - if (!context.workspaceId || !context.userId) return result - const outputTable = params?.outputTable as string | undefined if (!outputTable) return result @@ -239,16 +245,10 @@ export async function maybeWriteReadCsvToTable( { [TraceAttr.ToolName]: toolName, [TraceAttr.CopilotTableId]: outputTable, - [TraceAttr.WorkspaceId]: context.workspaceId, + [TraceAttr.WorkspaceId]: context.workspaceId ?? '', }, async (span) => { try { - const table = await getTableById(outputTable) - if (!table || table.workspaceId !== context.workspaceId) { - span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.TableNotFound) - return { success: false, error: `Table "${outputTable}" not found` } - } - const output = result.output as Record const content = output.content if (typeof content !== 'string') { @@ -310,8 +310,8 @@ export async function maybeWriteReadCsvToTable( if (context.abortSignal?.aborted) { throw new Error('Request aborted before tool mutation could be applied') } - const replaceResult = await replaceTableRowsFromWire(table, rows, context) - if (replaceResult.error) { + const replaceResult = await replaceTableRowsFromWire(outputTable, rows, context) + if (!replaceResult.success) { span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.InvalidShape) return { success: false, error: replaceResult.error } } @@ -319,24 +319,25 @@ export async function maybeWriteReadCsvToTable( logger.info('Read output written to table', { toolName, tableId: outputTable, - tableName: table.name, - rowCount: rows.length, + tableName: replaceResult.table.name, + rowCount: replaceResult.insertedCount, + deletedCount: replaceResult.deletedCount, filePath, }) span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.Imported) return { success: true, output: { - message: `Imported ${rows.length} rows from "${filePath}" into table "${table.name}"`, + message: `Imported ${replaceResult.insertedCount} rows from "${filePath}" into table "${replaceResult.table.name}"`, tableId: outputTable, - tableName: table.name, - rowCount: rows.length, + tableName: replaceResult.table.name, + rowCount: replaceResult.insertedCount, }, } } catch (err) { - const rawMessage = toError(err).message + const safeMessage = messageForCopilotTableError(err) const projectedMessage = projectToolErrorMessageForCopilot( - rawMessage, + safeMessage, context.resolvedSecretTraceRegistry ) logger.warn('Failed to write read output to table', { @@ -350,7 +351,7 @@ export async function maybeWriteReadCsvToTable( }) return { success: false, - error: `Failed to import into table: ${rawMessage}`, + error: `Failed to import into table: ${projectedMessage}`, } } } diff --git a/apps/sim/lib/copilot/tools/server/router.ts b/apps/sim/lib/copilot/tools/server/router.ts index 323738741b6..2eb7b1d681a 100644 --- a/apps/sim/lib/copilot/tools/server/router.ts +++ b/apps/sim/lib/copilot/tools/server/router.ts @@ -105,6 +105,7 @@ const WRITE_ACTIONS: Record = { 'create_from_file', 'import_file', 'delete', + 'rename', 'insert_row', 'batch_insert_rows', 'update_row', @@ -117,6 +118,13 @@ const WRITE_ACTIONS: Record = { 'rename_column', 'delete_column', 'update_column', + 'add_workflow_group', + 'update_workflow_group', + 'delete_workflow_group', + 'add_workflow_group_output', + 'delete_workflow_group_output', + 'run_column', + 'cancel_table_runs', 'add_enrichment', ], [ManageCustomTool.id]: ['add', 'edit', 'delete'], diff --git a/apps/sim/lib/copilot/tools/server/table/query-user-table.test.ts b/apps/sim/lib/copilot/tools/server/table/query-user-table.test.ts new file mode 100644 index 00000000000..f6f3e35145f --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/table/query-user-table.test.ts @@ -0,0 +1,49 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const executeUserTable = vi.hoisted(() => vi.fn()) + +vi.mock('@/lib/copilot/tools/server/table/user-table', () => ({ + userTableServerTool: { execute: executeUserTable }, +})) + +import { queryUserTableServerTool } from '@/lib/copilot/tools/server/table/query-user-table' + +describe('query_user_table alias', () => { + beforeEach(() => { + vi.clearAllMocks() + executeUserTable.mockResolvedValue({ success: true, message: 'ok' }) + }) + + it('delegates read operations with the original trusted context', async () => { + const context = { + userId: 'user-1', + workspaceId: 'workspace-1', + toolCallId: 'tool-call-1', + copilotToolExecution: true, + } + const params = { operation: 'query_rows', args: { tableId: 'table-1', limit: 10 } } + + await expect(queryUserTableServerTool.execute(params, context)).resolves.toEqual({ + success: true, + message: 'ok', + }) + expect(executeUserTable).toHaveBeenCalledWith(params, context) + }) + + it('rejects mutations and outputPath without invoking user_table', async () => { + await expect( + queryUserTableServerTool.execute({ operation: 'delete', args: { tableId: 'table-1' } }) + ).resolves.toMatchObject({ success: false, message: expect.stringContaining('read-only') }) + await expect( + queryUserTableServerTool.execute({ + operation: 'query_rows', + args: { tableId: 'table-1', outputPath: 'files/result.csv' }, + }) + ).resolves.toMatchObject({ success: false, message: expect.stringContaining('outputPath') }) + expect(executeUserTable).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/copilot/tools/server/table/user-table.test.ts b/apps/sim/lib/copilot/tools/server/table/user-table.test.ts index 6395f4ddf44..5c1fbcc5a22 100644 --- a/apps/sim/lib/copilot/tools/server/table/user-table.test.ts +++ b/apps/sim/lib/copilot/tools/server/table/user-table.test.ts @@ -2,6 +2,7 @@ * @vitest-environment node */ +import { getErrorMessage } from '@sim/utils/errors' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { TableDefinition } from '@/lib/table' @@ -26,6 +27,7 @@ const { mockRunTableImport, mockRunTableDelete, mockRunTableUpdate, + mockExecuteCopilotTableUseCase, fakeEnrichment, } = vi.hoisted(() => ({ mockUpdateColumnType: vi.fn(), @@ -48,6 +50,7 @@ const { mockRunTableImport: vi.fn(), mockRunTableDelete: vi.fn(), mockRunTableUpdate: vi.fn(), + mockExecuteCopilotTableUseCase: vi.fn(), fakeEnrichment: { id: 'work-email', name: 'Work Email', @@ -92,6 +95,26 @@ vi.mock('@/lib/copilot/auth/file-delegation', () => ({ })), })) +vi.mock('@/lib/copilot/auth/table-delegation', () => ({ + messageForCopilotTableError: (error: unknown) => getErrorMessage(error, 'Table operation failed'), + resolveCopilotTablePrincipal: (_context: unknown, tableId?: string) => ({ + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'test-tool', + audience: 'sim:tables', + issuedAt: new Date(0), + expiresAt: new Date(Date.now() + 60_000), + resourceScope: tableId ? { tableId } : undefined, + }), +})) + +vi.mock('@/lib/copilot/application/execute-table-use-case', () => ({ + admitCopilotTableOperation: vi.fn(), + executeCopilotTableUseCase: mockExecuteCopilotTableUseCase, +})) + vi.mock('@/lib/uploads/contexts/workspace/workspace-file-secret-provenance', () => ({ getBoundWorkspaceFileSecretProvenance: mockGetBoundWorkspaceFileSecretProvenance, })) @@ -141,8 +164,8 @@ vi.mock('@/lib/table/rows/service', () => ({ })) vi.mock('@/lib/table/jobs/service', () => ({ - markTableJobRunning: mockMarkTableJobRunning, - releaseJobClaim: mockReleaseJobClaim, + markTableJobRunningInWorkspace: mockMarkTableJobRunning, + releaseJobClaimInWorkspace: mockReleaseJobClaim, })) vi.mock('@/lib/table/import-runner', () => ({ @@ -164,7 +187,70 @@ vi.mock('@/lib/table/billing', () => ({ })) import { userTableServerTool } from '@/lib/copilot/tools/server/table/user-table' -import { encodeCursor } from '@/lib/table/rows/cursor' +import { decodeCursor, encodeCursor } from '@/lib/table/rows/cursor' + +beforeEach(() => { + mockExecuteCopilotTableUseCase.mockImplementation( + async ( + _context: unknown, + useCase: { operation: { id: string } }, + input: Record + ) => { + const table = await mockGetTableById(input.tableId) + switch (useCase.operation.id) { + case 'tables.create': { + const limits = await mockGetWorkspaceTableLimits(input.workspaceId) + const created = await mockCreateTable({ ...input, ...limits }) + return { table: created } + } + case 'tables.rows.query': { + if (!table) throw new Error('Table not found') + if (input.cursor && Array.isArray(input.sort) && input.sort.length > 0) { + throw new Error('Cursor is not valid for a sorted query') + } + const cursor = input.cursor ? decodeCursor(String(input.cursor)) : undefined + const result = await mockQueryRows(table, { + predicate: input.predicate, + sort: input.sort, + limit: input.limit, + after: cursor?.after, + offset: cursor?.offset, + includeTotal: input.includeTotal, + withExecutions: false, + }) + return { table, ...result } + } + case 'tables.columns.update': { + if (!table) throw new Error('Table not found') + const updates = input.updates as Record + const column = table.schema.columns.find( + (candidate) => candidate.name === input.columnName + ) + const next = + updates.type !== undefined && updates.type !== column?.type + ? await mockUpdateColumnType({ + tableId: input.tableId, + columnName: input.columnName, + newType: updates.type, + }) + : await mockUpdateColumnOptions({ + tableId: input.tableId, + columnName: input.columnName, + options: Array.isArray(updates.options) + ? updates.options.map((option) => + typeof option === 'string' ? { name: option } : option + ) + : column?.options, + multiple: updates.multiple, + }) + return { table: next, changed: true } + } + default: + throw new Error(`Unexpected application operation ${useCase.operation.id}`) + } + } + ) +}) function buildTable(overrides: Partial = {}): TableDefinition { return { @@ -209,7 +295,7 @@ describe('userTableServerTool.import_file', () => { mockDownloadWorkspaceFile.mockResolvedValue(Buffer.from('name,age\nAlice,30\nBob,40')) mockGetTableById.mockResolvedValue(buildTable()) mockMarkTableJobRunning.mockResolvedValue(true) - mockReleaseJobClaim.mockResolvedValue(undefined) + mockReleaseJobClaim.mockResolvedValue(true) mockBatchInsertRows.mockImplementation(async (data: { rows: unknown[] }) => data.rows.map((_, i) => ({ id: `row_${i}` })) ) @@ -359,10 +445,16 @@ describe('userTableServerTool.import_file', () => { ) expect(result.success).toBe(true) - expect(mockMarkTableJobRunning).toHaveBeenCalledWith('tbl_1', expect.any(String), 'import') + expect(mockMarkTableJobRunning).toHaveBeenCalledWith( + 'tbl_1', + 'workspace-1', + expect.any(String), + 'import' + ) expect(mockReleaseJobClaim).toHaveBeenCalledWith( 'tbl_1', - mockMarkTableJobRunning.mock.calls[0][1] + 'workspace-1', + mockMarkTableJobRunning.mock.calls[0][2] ) }) @@ -397,7 +489,12 @@ describe('userTableServerTool.import_file', () => { expect(result.success).toBe(true) expect(result.data?.jobId).toBeDefined() expect(result.message).toMatch(/background/i) - expect(mockMarkTableJobRunning).toHaveBeenCalledWith('tbl_1', expect.any(String), 'import') + expect(mockMarkTableJobRunning).toHaveBeenCalledWith( + 'tbl_1', + 'workspace-1', + expect.any(String), + 'import' + ) expect(mockBatchInsertRows).not.toHaveBeenCalled() expect(mockReplaceTableRows).not.toHaveBeenCalled() expect(mockDownloadWorkspaceFile).not.toHaveBeenCalled() @@ -946,7 +1043,7 @@ describe('userTableServerTool.delete_rows_by_filter', () => { // target = min(limit 5000, matchCount 20000) = 5000, above the inline cap → background. expect(result.data?.doomedCount).toBe(5000) expect(mockDeleteRowsByFilter).not.toHaveBeenCalled() - const [, , type, payload] = mockMarkTableJobRunning.mock.calls[0] + const [, , , type, payload] = mockMarkTableJobRunning.mock.calls[0] expect(type).toBe('delete') // Bounded delete carries maxRows and omits doomedCount so the mask is skipped and the count // isn't double-subtracted. @@ -968,7 +1065,12 @@ describe('userTableServerTool.delete_rows_by_filter', () => { expect(result.data?.affectedCount).toBe(5) expect(mockDeleteRowsByFilter).toHaveBeenCalledTimes(1) // Inline delete still claims (and releases) the table's write-job slot. - expect(mockMarkTableJobRunning).toHaveBeenCalledWith('tbl_1', expect.any(String), 'delete') + expect(mockMarkTableJobRunning).toHaveBeenCalledWith( + 'tbl_1', + 'workspace-1', + expect.any(String), + 'delete' + ) expect(mockReleaseJobClaim).toHaveBeenCalled() }) @@ -1027,8 +1129,9 @@ describe('userTableServerTool.delete_rows_by_filter', () => { expect(result.data?.jobId).toBeDefined() expect(result.data?.doomedCount).toBe(20000) expect(mockDeleteRowsByFilter).not.toHaveBeenCalled() - const [tableId, jobId, type, payload] = mockMarkTableJobRunning.mock.calls[0] + const [tableId, workspaceId, jobId, type, payload] = mockMarkTableJobRunning.mock.calls[0] expect(tableId).toBe('tbl_1') + expect(workspaceId).toBe('workspace-1') expect(type).toBe('delete') expect(payload).toMatchObject({ doomedCount: 20000, cutoff: expect.any(String) }) // Unbounded delete masks the whole set — no maxRows cap. @@ -1120,7 +1223,7 @@ describe('userTableServerTool.update_rows_by_filter', () => { // target = min(limit 5000, matchCount 20000) = 5000, above the inline cap → background. expect(result.data?.affectedCount).toBe(5000) expect(mockUpdateRowsByFilter).not.toHaveBeenCalled() - const [, , type, payload] = mockMarkTableJobRunning.mock.calls[0] + const [, , , type, payload] = mockMarkTableJobRunning.mock.calls[0] expect(type).toBe('update') expect(payload).toMatchObject({ affectedCount: 5000, maxRows: 5000 }) expect(mockRunTableUpdate.mock.calls[0][0]).toMatchObject({ maxRows: 5000 }) @@ -1186,8 +1289,9 @@ describe('userTableServerTool.update_rows_by_filter', () => { expect(result.data?.jobId).toBeDefined() expect(result.data?.affectedCount).toBe(20000) expect(mockUpdateRowsByFilter).not.toHaveBeenCalled() - const [tableId, jobId, type, payload] = mockMarkTableJobRunning.mock.calls[0] + const [tableId, workspaceId, jobId, type, payload] = mockMarkTableJobRunning.mock.calls[0] expect(tableId).toBe('tbl_1') + expect(workspaceId).toBe('workspace-1') expect(type).toBe('update') expect(payload).toMatchObject({ affectedCount: 20000, @@ -1343,3 +1447,22 @@ describe('userTableServerTool.update_column — select routing', () => { expect(arg.options).toEqual([{ id: 'opt_open', name: 'Open' }]) }) }) + +describe('userTableServerTool.delete bounds', () => { + it('rejects an unbounded multi-table delete before invoking an application use case', async () => { + vi.clearAllMocks() + const result = await userTableServerTool.execute( + { + operation: 'delete', + args: { tableIds: Array.from({ length: 101 }, (_, index) => `table-${index}`) }, + }, + { userId: 'user-1', workspaceId: 'workspace-1' } + ) + + expect(result).toEqual({ + success: false, + message: 'Cannot delete more than 100 tables at once', + }) + expect(mockExecuteCopilotTableUseCase).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/copilot/tools/server/table/user-table.ts b/apps/sim/lib/copilot/tools/server/table/user-table.ts index 0a119295f69..fca3d5c1e97 100644 --- a/apps/sim/lib/copilot/tools/server/table/user-table.ts +++ b/apps/sim/lib/copilot/tools/server/table/user-table.ts @@ -2,7 +2,15 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' +import { + admitCopilotTableOperation, + executeCopilotTableUseCase, +} from '@/lib/copilot/application/execute-table-use-case' import { resolveCopilotFilePrincipal } from '@/lib/copilot/auth/file-delegation' +import { + messageForCopilotTableError, + resolveCopilotTablePrincipal, +} from '@/lib/copilot/auth/table-delegation' import { UserTable } from '@/lib/copilot/generated/tool-catalog-v1' import { assertServerToolNotAborted, @@ -26,30 +34,48 @@ import { TABLE_LIMITS, validateMapping, } from '@/lib/table' +import { + addTableColumnUseCase, + deleteTableColumnUseCase, + updateTableColumnUseCase, +} from '@/lib/table/application/columns' +import { + createTableGroupUseCase, + deleteTableGroupUseCase, + updateTableGroupUseCase, +} from '@/lib/table/application/groups' +import { type TableOperation, tableOperations } from '@/lib/table/application/operations' +import { + createTableRows, + deleteTableRow, + deleteTableRows, + queryTableRows, + readTableRow, + updateTableRow, +} from '@/lib/table/application/rows' +import { cancelTableRuns, startTableRun } from '@/lib/table/application/runs' +import { + createTableUseCase, + deleteTableUseCase, + readTableUseCase, + updateTableUseCase, +} from '@/lib/table/application/tables' import { namedRowMapper } from '@/lib/table/cell-format' -import { buildIdByName, rowDataNameToId, sortSpecNamesToIds } from '@/lib/table/column-keys' +import { buildIdByName, rowDataNameToId } from '@/lib/table/column-keys' import { columnTypeForLeaf, deriveOutputColumnName } from '@/lib/table/column-naming' -import { - addTableColumn, - deleteColumn, - deleteColumns, - renameColumn, -} from '@/lib/table/columns/service' +import { deleteColumns } from '@/lib/table/columns/service' import { isSupportedCurrencyCode } from '@/lib/table/currency' import { markTableDeleteFailed, runTableDelete } from '@/lib/table/delete-runner' import { signalTableRowsChanged, signalTableSchemaChanged } from '@/lib/table/events' import { runTableImport, type TableImportPayload } from '@/lib/table/import-runner' -import { markTableJobRunning, releaseJobClaim } from '@/lib/table/jobs/service' -import { assertRowDelete, assertRowUpdate, patchColumnIds } from '@/lib/table/mutation-locks' import { - performDeleteTable, - performRenameTable, - performUpdateTableColumn, -} from '@/lib/table/orchestration' + markTableJobRunningInWorkspace, + releaseJobClaimInWorkspace, +} from '@/lib/table/jobs/service' +import { assertRowDelete, assertRowUpdate, patchColumnIds } from '@/lib/table/mutation-locks' import { predicateToFilter } from '@/lib/table/query-builder/converters' import { normalizeTablePredicate } from '@/lib/table/query-builder/predicate' -import { validatePredicate, validateSortSpec } from '@/lib/table/query-builder/validate' -import { assertCursorSortBinding, decodeCursor } from '@/lib/table/rows/cursor' +import { validatePredicate } from '@/lib/table/query-builder/validate' import { createExactEmptyTableRowSecretProvenance, loadTableRowSecretProvenance, @@ -57,14 +83,9 @@ import { import { batchInsertRows, batchUpdateRows, - deleteRow, deleteRowsByFilter, - deleteRowsByIds, - getRowById, - insertRow, queryRows, replaceTableRows, - updateRow, updateRowsByFilter, } from '@/lib/table/rows/service' import { normalizeSelectOptionsInput } from '@/lib/table/select-options' @@ -77,7 +98,6 @@ import type { SortSpec, TableDefinition, TableDeleteJobPayload, - TablePredicate, TablePredicateInput, TableSchema, TableUpdateJobPayload, @@ -88,13 +108,10 @@ import type { WorkflowGroupOutput, } from '@/lib/table/types' import { markTableUpdateFailed, runTableUpdate } from '@/lib/table/update-runner' -import { cancelWorkflowGroupRuns, runWorkflowColumn } from '@/lib/table/workflow-columns' import { addWorkflowGroup, addWorkflowGroupOutput, - deleteWorkflowGroup, deleteWorkflowGroupOutput, - updateWorkflowGroup, } from '@/lib/table/workflow-groups/service' import { getBoundWorkspaceFileSecretProvenance } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' import { @@ -122,6 +139,61 @@ type UserTableResult = { const MAX_BATCH_SIZE = CSV_MAX_BATCH_SIZE const MAX_INLINE_FILE_BYTES = 50 * 1024 * 1024 +const USER_TABLE_OPERATIONS: Readonly> = { + create: tableOperations.create, + create_from_file: tableOperations.create, + import_file: tableOperations.createImport, + get: tableOperations.read, + get_schema: tableOperations.read, + delete: tableOperations.delete, + insert_row: tableOperations.createRows, + batch_insert_rows: tableOperations.createRows, + get_row: tableOperations.readRow, + query_rows: tableOperations.queryRows, + update_row: tableOperations.updateRow, + delete_row: tableOperations.deleteRow, + update_rows_by_filter: tableOperations.updateRows, + delete_rows_by_filter: tableOperations.deleteRows, + batch_update_rows: tableOperations.updateRows, + batch_delete_rows: tableOperations.deleteRows, + add_column: tableOperations.addColumn, + rename_column: tableOperations.updateColumn, + delete_column: tableOperations.deleteColumn, + update_column: tableOperations.updateColumn, + rename: tableOperations.update, + add_workflow_group: tableOperations.createGroup, + update_workflow_group: tableOperations.updateGroup, + delete_workflow_group: tableOperations.deleteGroup, + add_workflow_group_output: tableOperations.updateGroup, + delete_workflow_group_output: tableOperations.updateGroup, + run_column: tableOperations.startRun, + cancel_table_runs: tableOperations.cancelRuns, + add_enrichment: tableOperations.createGroup, +} + +const DIRECT_APPLICATION_OPERATIONS = new Set([ + 'create', + 'get', + 'get_schema', + 'delete', + 'rename', + 'insert_row', + 'batch_insert_rows', + 'get_row', + 'query_rows', + 'update_row', + 'delete_row', + 'batch_delete_rows', + 'add_column', + 'rename_column', + 'update_column', + 'add_workflow_group', + 'update_workflow_group', + 'delete_workflow_group', + 'run_column', + 'cancel_table_runs', +]) + async function resolveWorkspaceFileRecordOrThrow( fileReference: string, workspaceId: string, @@ -202,7 +274,20 @@ async function dispatchImportJob(payload: TableImportPayload): Promise { region: await resolveTriggerRegion(), }) } catch (error) { - await releaseJobClaim(payload.tableId, payload.importId).catch(() => {}) + try { + const released = await releaseJobClaimInWorkspace( + payload.tableId, + payload.workspaceId, + payload.importId + ) + if (!released) throw new Error('Table import claim was no longer active') + } catch (cleanupError) { + logger.error('Failed to release table import claim after dispatch failure', { + tableId: payload.tableId, + jobId: payload.importId, + error: getErrorMessage(cleanupError), + }) + } throw error } } else { @@ -237,7 +322,16 @@ async function dispatchDeleteJob(params: { { tags: [`tableId:${tableId}`, `jobId:${jobId}`], region: await resolveTriggerRegion() } ) } catch (error) { - await releaseJobClaim(tableId, jobId).catch(() => {}) + try { + const released = await releaseJobClaimInWorkspace(tableId, workspaceId, jobId) + if (!released) throw new Error('Table delete claim was no longer active') + } catch (cleanupError) { + logger.error('Failed to release table delete claim after dispatch failure', { + tableId, + jobId, + error: getErrorMessage(cleanupError), + }) + } throw error } } else { @@ -280,7 +374,16 @@ async function dispatchUpdateJob(params: { { tags: [`tableId:${tableId}`, `jobId:${jobId}`], region: await resolveTriggerRegion() } ) } catch (error) { - await releaseJobClaim(tableId, jobId).catch(() => {}) + try { + const released = await releaseJobClaimInWorkspace(tableId, workspaceId, jobId) + if (!released) throw new Error('Table update claim was no longer active') + } catch (cleanupError) { + logger.error('Failed to release table update claim after dispatch failure', { + tableId, + jobId, + error: getErrorMessage(cleanupError), + }) + } throw error } } else { @@ -295,6 +398,47 @@ async function dispatchUpdateJob(params: { } } +async function withReleasedTableJobClaim( + tableId: string, + workspaceId: string, + jobId: string, + run: () => Promise +): Promise { + let result: T + try { + result = await run() + } catch (error) { + try { + const released = await releaseJobClaimInWorkspace(tableId, workspaceId, jobId) + if (!released) { + logger.error('Table job claim was no longer active after operation failure', { + tableId, + workspaceId, + jobId, + }) + } + } catch (cleanupError) { + logger.error('Failed to release table job claim after operation failure', { + tableId, + workspaceId, + jobId, + error: getErrorMessage(cleanupError), + }) + } + throw error + } + const released = await releaseJobClaimInWorkspace(tableId, workspaceId, jobId) + if (!released) { + logger.error('Table job claim was no longer active after successful operation', { + tableId, + workspaceId, + jobId, + }) + throw new Error('Table job claim was no longer active') + } + return result +} + /** * Loads the live workflow state and flattens it into pickable outputs. Used * to validate `(blockId, path)` pairs the AI passes to add/update_workflow_group @@ -446,12 +590,21 @@ export const userTableServerTool: BaseServerTool } const { operation, args = {} } = params - const workspaceId = - context.workspaceId || ((args as Record).workspaceId as string | undefined) + const tableId = typeof args.tableId === 'string' ? args.tableId : undefined + const tablePrincipal = resolveCopilotTablePrincipal(context, tableId) + const workspaceId = tablePrincipal.workspaceId const assertNotAborted = () => assertServerToolNotAborted(context, 'Request aborted before table mutation could be applied.') try { + const semanticOperation = USER_TABLE_OPERATIONS[operation] + if (semanticOperation && !DIRECT_APPLICATION_OPERATIONS.has(operation)) { + await admitCopilotTableOperation( + context, + semanticOperation, + tableId ? { workspaceId, tableId } : { workspaceId } + ) + } switch (operation) { case 'create': { if (!args.name) { @@ -464,31 +617,12 @@ export const userTableServerTool: BaseServerTool return { success: false, message: 'Workspace ID is required' } } - const requestId = generateId().slice(0, 8) assertNotAborted() - const planLimits = await getWorkspaceTableLimits(workspaceId) - const table = await createTable( - { - name: args.name, - description: args.description, - // Agent authors select options by name; generate their stable ids here. - schema: normalizeSchemaSelectColumns(args.schema as TableSchema), - workspaceId, - userId: context.userId, - maxTables: planLimits.maxTables, - }, - requestId - ) - - recordAudit({ + const { table } = await executeCopilotTableUseCase(context, createTableUseCase, { + name: args.name, + description: args.description, + schema: normalizeSchemaSelectColumns(args.schema as TableSchema), workspaceId, - actorId: context.userId, - action: AuditAction.TABLE_CREATED, - resourceType: AuditResourceType.TABLE, - resourceId: table.id, - resourceName: table.name, - description: `Created table "${table.name}"`, - metadata: { source: 'tool_input' }, }) return { @@ -506,10 +640,12 @@ export const userTableServerTool: BaseServerTool return { success: false, message: 'Workspace ID is required' } } - const table = await getTableById(args.tableId) - if (!table || table.workspaceId !== workspaceId) { - return { success: false, message: `Table not found: ${args.tableId}` } - } + const { table } = await executeCopilotTableUseCase( + context, + readTableUseCase, + { tableId: args.tableId, workspaceId }, + { tableId: args.tableId } + ) return { success: true, @@ -526,10 +662,12 @@ export const userTableServerTool: BaseServerTool return { success: false, message: 'Workspace ID is required' } } - const table = await getTableById(args.tableId) - if (!table || table.workspaceId !== workspaceId) { - return { success: false, message: `Table not found: ${args.tableId}` } - } + const { table } = await executeCopilotTableUseCase( + context, + readTableUseCase, + { tableId: args.tableId, workspaceId }, + { tableId: args.tableId } + ) return { success: true, @@ -547,6 +685,12 @@ export const userTableServerTool: BaseServerTool if (tableIds.length === 0) { return { success: false, message: 'tableId or tableIds is required' } } + if (tableIds.length > TABLE_LIMITS.MAX_TABLES_PER_WORKSPACE) { + return { + success: false, + message: `Cannot delete more than ${TABLE_LIMITS.MAX_TABLES_PER_WORKSPACE} tables at once`, + } + } if (!workspaceId) { return { success: false, message: 'Workspace ID is required' } } @@ -555,23 +699,23 @@ export const userTableServerTool: BaseServerTool const failed: string[] = [] for (const tableId of tableIds) { - const table = await getTableById(tableId) - if (!table || table.workspaceId !== workspaceId) { - failed.push(tableId) - continue - } - - const requestId = generateId().slice(0, 8) - assertNotAborted() - const deleteOutcome = await performDeleteTable({ - table, - userId: context.userId, - requestId, - }) - if (!deleteOutcome.success) { - return { success: false, message: deleteOutcome.error ?? 'Failed to delete table' } + try { + assertNotAborted() + await executeCopilotTableUseCase( + context, + deleteTableUseCase, + { tableId, workspaceId }, + { tableId } + ) + deleted.push(tableId) + } catch (error) { + const classified = messageForCopilotTableError(error, '') + if (classified === 'Table not found') { + failed.push(tableId) + continue + } + throw error } - deleted.push(tableId) } return { @@ -592,30 +736,23 @@ export const userTableServerTool: BaseServerTool return { success: false, message: 'Workspace ID is required' } } - const table = await getTableById(args.tableId) - if (!table || table.workspaceId !== workspaceId) { - return { success: false, message: `Table not found: ${args.tableId}` } - } - - const requestId = generateId().slice(0, 8) assertNotAborted() - // The LLM authors row data by column name; storage keys by id. - const idByName = buildIdByName(table.schema) - const toNamedRow = namedRowMapper(table.schema.columns) - const rowData = rowDataNameToId(args.data, idByName) - const row = await insertRow( + const result = await executeCopilotTableUseCase( + context, + createTableRows, { + kind: 'single', tableId: args.tableId, - data: rowData, - workspaceId, - userId: context.userId, + assertedWorkspaceId: workspaceId, + data: args.data, position: args.position as number | undefined, - secretProvenance: createExactEmptyTableRowSecretProvenance(rowData), + secretProvenance: createExactEmptyTableRowSecretProvenance(args.data), }, - table, - requestId + { tableId: args.tableId } ) - signalTableRowsChanged(args.tableId) + if (result.kind !== 'single') throw new Error('Single row insert returned a batch') + const { table, row } = result + const toNamedRow = namedRowMapper(table.schema.columns) return { success: true, @@ -640,28 +777,23 @@ export const userTableServerTool: BaseServerTool return { success: false, message: 'Workspace ID is required' } } - const table = await getTableById(args.tableId) - if (!table || table.workspaceId !== workspaceId) { - return { success: false, message: `Table not found: ${args.tableId}` } - } - - const requestId = generateId().slice(0, 8) assertNotAborted() - const idByName = buildIdByName(table.schema) - const toNamedRow = namedRowMapper(table.schema.columns) - const rowData = args.rows.map((row: RowData) => rowDataNameToId(row, idByName)) - const rows = await batchInsertRows( + const sourceRows = args.rows as RowData[] + const result = await executeCopilotTableUseCase( + context, + createTableRows, { + kind: 'batch', tableId: args.tableId, - rows: rowData, - workspaceId, - userId: context.userId, - secretProvenance: rowData.map(createExactEmptyTableRowSecretProvenance), + assertedWorkspaceId: workspaceId, + rows: sourceRows, + secretProvenance: sourceRows.map(createExactEmptyTableRowSecretProvenance), }, - table, - requestId + { tableId: args.tableId } ) - signalTableRowsChanged(args.tableId) + if (result.kind !== 'batch') throw new Error('Batch row insert returned one row') + const { table, rows } = result + const toNamedRow = namedRowMapper(table.schema.columns) return { success: true, @@ -687,14 +819,16 @@ export const userTableServerTool: BaseServerTool return { success: false, message: 'Workspace ID is required' } } - const rowTable = await getTableById(args.tableId) - if (!rowTable || rowTable.workspaceId !== workspaceId) { - return { success: false, message: `Table not found: ${args.tableId}` } - } - const row = await getRowById(args.tableId, args.rowId, workspaceId) - if (!row) { - return { success: false, message: `Row not found: ${args.rowId}` } - } + const { table: rowTable, row } = await executeCopilotTableUseCase( + context, + readTableRow, + { + tableId: args.tableId, + assertedWorkspaceId: workspaceId, + rowId: args.rowId, + }, + { tableId: args.tableId } + ) await importRowsForModel([row], context) const toNamedRow = namedRowMapper(rowTable.schema.columns) @@ -723,66 +857,24 @@ export const userTableServerTool: BaseServerTool return { success: false, message: queryLimitError } } - const table = await getTableById(args.tableId) - if (!table || table.workspaceId !== workspaceId) { - return { success: false, message: `Table not found: ${args.tableId}` } - } - - const requestId = generateId().slice(0, 8) - const idByName = buildIdByName(table.schema) - // Typed predicate/sort objects, validated against the schema (column - // NAMES) then translated to storage ids. - let predicate: TablePredicate | undefined - if (args.filter) { - const filter = args.filter as TablePredicateInput - validatePredicate(filter, table.schema.columns) - const normalizedFilter = normalizeTablePredicate(filter) - predicate = predicateToStorage(normalizedFilter, table.schema) - } - let orderSpec = args.order as SortSpec | undefined - if (orderSpec?.length) { - validateSortSpec(orderSpec, table.schema.columns) - orderSpec = sortSpecNamesToIds(orderSpec, idByName) - } - const sort = orderSpec?.length - ? Object.fromEntries(orderSpec.map((s) => [s.field, s.direction])) - : undefined - - // Opaque cursor pagination (keyset seek on the default order; the token - // hides an internal offset only for custom-sorted views, which a keyset - // physically can't page). A keyset cursor is bound to the default order, - // so it can't be combined with a fresh sort. - const cursor = args.cursor ? decodeCursor(args.cursor) : undefined - if (cursor) { - try { - // Keyset cursors bind to the default order; offset cursors to the - // exact sort they were minted under. - assertCursorSortBinding(cursor, sort) - } catch (bindError) { - return { success: false, message: getErrorMessage(bindError, 'Invalid cursor') } - } - } - - // No limit returns the ENTIRE matching result, failing fast once the - // 5MB byte budget is exceeded (caught below → structured tool error - // the model can react to by adding a filter or a limit). An explicit - // limit pages; byte-cut pages set nextCursor and the message says to - // continue with the opaque cursor. - const toNamedRow = namedRowMapper(table.schema.columns) - const result = await queryRows( - table, + const result = await executeCopilotTableUseCase( + context, + queryTableRows, { - predicate, - sort, + tableId: args.tableId, + assertedWorkspaceId: workspaceId, + predicate: args.filter + ? normalizeTablePredicate(args.filter as TablePredicateInput) + : undefined, + sort: args.order as SortSpec | undefined, limit: args.limit, - after: cursor?.after, - offset: cursor?.offset, - // Only the first page (no inbound cursor) pays for the COUNT(*). + cursor: args.cursor, includeTotal: !args.cursor, - withExecutions: false, }, - requestId + { tableId: args.tableId } ) + const { table } = result + const toNamedRow = namedRowMapper(table.schema.columns) await importRowsForModel(result.rows, context) // nextCursor covers both cut kinds (explicit limit or the 5MB byte @@ -819,40 +911,21 @@ export const userTableServerTool: BaseServerTool return { success: false, message: 'Workspace ID is required' } } - const table = await getTableById(args.tableId) - if (!table || table.workspaceId !== workspaceId) { - return { success: false, message: `Table not found: ${args.tableId}` } - } - - const requestId = generateId().slice(0, 8) assertNotAborted() - const idByName = buildIdByName(table.schema) - const toNamedRow = namedRowMapper(table.schema.columns) - const rowData = rowDataNameToId(args.data, idByName) - const updatedRow = await updateRow( + const { table, row: updatedRow } = await executeCopilotTableUseCase( + context, + updateTableRow, { tableId: args.tableId, + assertedWorkspaceId: workspaceId, rowId: args.rowId, - data: rowData, - workspaceId, - actorUserId: context.userId, - secretProvenance: createExactEmptyTableRowSecretProvenance(rowData), + data: args.data, + secretProvenance: createExactEmptyTableRowSecretProvenance(args.data), }, - table, - requestId + { tableId: args.tableId } ) - if (!updatedRow) { - // Only the cell-task path passes a `cancellationGuard`; this caller - // doesn't, so the guard never trips here. Defensive narrowing. - return { success: false, message: 'Row update was skipped' } - } + const toNamedRow = namedRowMapper(table.schema.columns) await importRowsForModel([updatedRow], context) - signalTableRowsChanged(args.tableId) - // Auto-dispatch for user edits is handled inside `updateRow` - // (mode: 'new' for newly-cleared groups + cancel+rerun for in-flight - // downstream groups). Firing a second mode: 'incomplete' dispatch - // here would race with the internal one AND bulk-clear sibling-group - // outputs (mode: 'incomplete' wipes terminal-state cells in scope). return { success: true, @@ -877,17 +950,17 @@ export const userTableServerTool: BaseServerTool return { success: false, message: 'Workspace ID is required' } } - const requestId = generateId().slice(0, 8) assertNotAborted() - const deleteRowTable = await getTableById(args.tableId) - // The old signature passed `workspaceId` into `deleteRow`, which scoped - // the query; taking a TableDefinition instead means the ownership check - // has to happen here, as every other operation in this tool does. - if (!deleteRowTable || deleteRowTable.workspaceId !== workspaceId) { - return { success: false, message: `Table ${args.tableId} not found` } - } - await deleteRow(deleteRowTable, args.rowId, requestId) - signalTableRowsChanged(args.tableId) + await executeCopilotTableUseCase( + context, + deleteTableRow, + { + tableId: args.tableId, + assertedWorkspaceId: workspaceId, + rowId: args.rowId, + }, + { tableId: args.tableId } + ) return { success: true, @@ -962,7 +1035,13 @@ export const userTableServerTool: BaseServerTool // trusted continuation and does not re-check. assertRowUpdate(table, patchColumnIds(idData)) assertNotAborted() - const claimed = await markTableJobRunning(table.id, jobId, 'update', payload) + const claimed = await markTableJobRunningInWorkspace( + table.id, + workspaceId, + jobId, + 'update', + payload + ) if (!claimed) { return { success: false, message: 'A job is already in progress for this table' } } @@ -1062,7 +1141,13 @@ export const userTableServerTool: BaseServerTool // Gate the delete lock at enqueue — the worker is a trusted continuation. assertRowDelete(table) assertNotAborted() - const claimed = await markTableJobRunning(table.id, jobId, 'delete', payload) + const claimed = await markTableJobRunningInWorkspace( + table.id, + workspaceId, + jobId, + 'delete', + payload + ) if (!claimed) { return { success: false, message: 'A job is already in progress for this table' } } @@ -1090,36 +1175,39 @@ export const userTableServerTool: BaseServerTool // completes synchronously within this request before the slot is released. assertNotAborted() const inlineDeleteId = generateId() - const deleteClaimed = await markTableJobRunning(table.id, inlineDeleteId, 'delete') + const deleteClaimed = await markTableJobRunningInWorkspace( + table.id, + workspaceId, + inlineDeleteId, + 'delete' + ) if (!deleteClaimed) { return { success: false, message: 'A job is already in progress for this table' } } - let result: Awaited> - try { - result = await deleteRowsByFilter( - table, - { filter: idFilter, limit: args.limit }, - requestId - ) - } finally { - await releaseJobClaim(table.id, inlineDeleteId).catch(() => {}) - } + const result = await withReleasedTableJobClaim( + table.id, + workspaceId, + inlineDeleteId, + () => deleteRowsByFilter(table, { filter: idFilter, limit: args.limit }, requestId) + ) if (result.affectedCount > 0) signalTableRowsChanged(args.tableId) - recordAudit({ - workspaceId, - actorId: context.userId, - action: AuditAction.TABLE_UPDATED, - resourceType: AuditResourceType.TABLE, - resourceId: table.id, - resourceName: table.name, - description: `Deleted ${result.affectedCount} row(s) from table "${table.name}"`, - metadata: { - op: 'bulk_delete', - rowsDeleted: result.affectedCount, - source: 'tool_input', - }, - }) + if (result.affectedCount > 0) { + recordAudit({ + workspaceId, + actorId: context.userId, + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: table.id, + resourceName: table.name, + description: `Deleted ${result.affectedCount} row(s) from table "${table.name}"`, + metadata: { + op: 'bulk_delete', + rowsDeleted: result.affectedCount, + source: 'tool_input', + }, + }) + } return { success: true, @@ -1224,28 +1312,19 @@ export const userTableServerTool: BaseServerTool } } - const requestId = generateId().slice(0, 8) assertNotAborted() - const batchDeleteTable = await getTableById(args.tableId) - if (!batchDeleteTable || batchDeleteTable.workspaceId !== workspaceId) { - return { success: false, message: `Table ${args.tableId} not found` } - } - const result = await deleteRowsByIds( - batchDeleteTable, - { tableId: args.tableId, rowIds, workspaceId }, - requestId + const result = await executeCopilotTableUseCase( + context, + deleteTableRows, + { + kind: 'ids', + tableId: args.tableId, + assertedWorkspaceId: workspaceId, + rowIds, + }, + { tableId: args.tableId } ) - if (result.deletedCount > 0) signalTableRowsChanged(args.tableId) - - recordAudit({ - workspaceId, - actorId: context.userId, - action: AuditAction.TABLE_UPDATED, - resourceType: AuditResourceType.TABLE, - resourceId: args.tableId, - description: `Deleted ${result.deletedCount} row(s)`, - metadata: { op: 'bulk_delete', rowsDeleted: result.deletedCount, source: 'tool_input' }, - }) + if (result.kind !== 'ids') throw new Error('Row ID deletion returned a filter result') return { success: true, @@ -1321,8 +1400,14 @@ export const userTableServerTool: BaseServerTool deleteSourceFile: false, }) } catch (dispatchError) { - // The user never saw the placeholder — archive it back out. - await deleteTable(table.id, generateId().slice(0, 8)).catch(() => {}) + try { + await deleteTable(table.id, generateId().slice(0, 8)) + } catch (cleanupError) { + logger.error('Failed to remove placeholder table after import dispatch failure', { + tableId: table.id, + error: getErrorMessage(cleanupError), + }) + } throw dispatchError } return { @@ -1487,7 +1572,12 @@ export const userTableServerTool: BaseServerTool if (shouldImportInBackground(record)) { const importId = generateId() assertNotAborted() - const claimed = await markTableJobRunning(table.id, importId, 'import') + const claimed = await markTableJobRunningInWorkspace( + table.id, + workspaceId, + importId, + 'import' + ) if (!claimed) { return { success: false, message: 'A job is already in progress for this table' } } @@ -1516,11 +1606,16 @@ export const userTableServerTool: BaseServerTool // and contention is detected before the parse work is spent. const inlineImportId = generateId() assertNotAborted() - const inlineClaimed = await markTableJobRunning(table.id, inlineImportId, 'import') + const inlineClaimed = await markTableJobRunningInWorkspace( + table.id, + workspaceId, + inlineImportId, + 'import' + ) if (!inlineClaimed) { return { success: false, message: 'A job is already in progress for this table' } } - try { + return withReleasedTableJobClaim(table.id, workspaceId, inlineImportId, async () => { const file = { buffer: ( await readWorkspaceFileContent.execute({ @@ -1631,9 +1726,7 @@ export const userTableServerTool: BaseServerTool sourceFile: file.name, }, } - } finally { - await releaseJobClaim(table.id, inlineImportId).catch(() => {}) - } + }) } case 'add_column': { @@ -1660,11 +1753,6 @@ export const userTableServerTool: BaseServerTool message: 'column with name and type is required for add_column', } } - const tableForAdd = await getTableById(args.tableId) - if (!tableForAdd || tableForAdd.workspaceId !== workspaceId) { - return { success: false, message: `Table not found: ${args.tableId}` } - } - const requestId = generateId().slice(0, 8) assertNotAborted() if (col.currencyCode !== undefined && !isSupportedCurrencyCode(col.currencyCode)) { return { @@ -1677,8 +1765,12 @@ export const userTableServerTool: BaseServerTool col.type === 'select' ? { ...col, options: normalizeSelectOptionsInput(col.options) } : { ...col, options: undefined } - const updated = await addTableColumn(args.tableId, columnToAdd, requestId) - signalTableSchemaChanged(args.tableId) + const { table: updated } = await executeCopilotTableUseCase( + context, + addTableColumnUseCase, + { tableId: args.tableId, workspaceId, column: columnToAdd }, + { tableId: args.tableId } + ) return { success: true, message: `Added column "${col.name}" (${col.type}) to table`, @@ -1698,17 +1790,18 @@ export const userTableServerTool: BaseServerTool if (!colName || !newColName) { return { success: false, message: 'columnName and newName are required' } } - const tableForRename = await getTableById(args.tableId) - if (!tableForRename || tableForRename.workspaceId !== workspaceId) { - return { success: false, message: `Table not found: ${args.tableId}` } - } - const requestId = generateId().slice(0, 8) assertNotAborted() - const updated = await renameColumn( - { tableId: args.tableId, oldName: colName, newName: newColName }, - requestId + const { table: updated } = await executeCopilotTableUseCase( + context, + updateTableColumnUseCase, + { + tableId: args.tableId, + workspaceId, + columnName: colName, + updates: { name: newColName }, + }, + { tableId: args.tableId } ) - signalTableSchemaChanged(args.tableId) return { success: true, message: `Renamed column "${colName}" to "${newColName}"`, @@ -1729,28 +1822,31 @@ export const userTableServerTool: BaseServerTool if (!names || names.length === 0) { return { success: false, message: 'columnName or columnNames is required' } } - const tableForDelete = await getTableById(args.tableId) - if (!tableForDelete || tableForDelete.workspaceId !== workspaceId) { - return { success: false, message: `Table not found: ${args.tableId}` } - } - const requestId = generateId().slice(0, 8) if (names.length === 1) { assertNotAborted() - const updated = await deleteColumn( - { tableId: args.tableId, columnName: names[0] }, - requestId + const { table: updated } = await executeCopilotTableUseCase( + context, + deleteTableColumnUseCase, + { tableId: args.tableId, workspaceId, columnName: names[0] }, + { tableId: args.tableId } ) - signalTableSchemaChanged(args.tableId) return { success: true, message: `Deleted column "${names[0]}"`, data: { schema: updated.schema }, } } + await executeCopilotTableUseCase( + context, + readTableUseCase, + { tableId: args.tableId, workspaceId }, + { tableId: args.tableId } + ) assertNotAborted() const updated = await deleteColumns( { tableId: args.tableId, columnNames: names }, - requestId + generateId().slice(0, 8), + { expectedWorkspaceId: workspaceId } ) signalTableSchemaChanged(args.tableId) return { @@ -1801,31 +1897,30 @@ export const userTableServerTool: BaseServerTool message: `Invalid column type "${newType}". Must be one of: ${COLUMN_TYPES.join(', ')}`, } } - const tableForUpdate = await getTableById(args.tableId) - if (!tableForUpdate || tableForUpdate.workspaceId !== workspaceId) { - return { success: false, message: `Table not found: ${args.tableId}` } - } assertNotAborted() - const outcome = await performUpdateTableColumn({ - table: tableForUpdate, - columnName: colName, - userId: context.userId, - updates: { - ...(newType !== undefined ? { type: newType as (typeof COLUMN_TYPES)[number] } : {}), - ...(uniqFlag !== undefined ? { unique: uniqFlag } : {}), - ...(rawOptions !== undefined ? { options: rawOptions } : {}), - ...(multiple !== undefined ? { multiple } : {}), - ...(currencyCode !== undefined ? { currencyCode } : {}), + const { table: updated } = await executeCopilotTableUseCase( + context, + updateTableColumnUseCase, + { + tableId: args.tableId, + workspaceId, + columnName: colName, + updates: { + ...(newType !== undefined + ? { type: newType as (typeof COLUMN_TYPES)[number] } + : {}), + ...(uniqFlag !== undefined ? { unique: uniqFlag } : {}), + ...(rawOptions !== undefined ? { options: rawOptions } : {}), + ...(multiple !== undefined ? { multiple } : {}), + ...(currencyCode !== undefined ? { currencyCode } : {}), + }, }, - }) - if (!outcome.success || !outcome.table) { - return { success: false, message: outcome.error ?? 'Failed to update column' } - } - signalTableSchemaChanged(args.tableId) + { tableId: args.tableId } + ) return { success: true, message: `Updated column "${colName}"`, - data: { schema: outcome.table.schema }, + data: { schema: updated.schema }, } } case 'rename': { @@ -1840,28 +1935,21 @@ export const userTableServerTool: BaseServerTool return { success: false, message: 'Workspace ID is required' } } - const table = await getTableById(args.tableId) - if (!table || table.workspaceId !== workspaceId) { - return { success: false, message: `Table not found: ${args.tableId}` } - } - - const requestId = generateId().slice(0, 8) assertNotAborted() - const renameOutcome = await performRenameTable({ - table, - newName, - userId: context.userId, - requestId, - }) - if (!renameOutcome.success) { - return { success: false, message: renameOutcome.error ?? 'Failed to rename table' } + const result = await executeCopilotTableUseCase( + context, + updateTableUseCase, + { tableId: args.tableId, workspaceId, name: newName }, + { tableId: args.tableId } + ) + if (result.failure) { + throw result.failure } - signalTableSchemaChanged(args.tableId) return { success: true, message: `Renamed table to "${newName}"`, - data: { table: { id: args.tableId, name: newName } }, + data: { table: { id: args.tableId, name: result.table?.name ?? newName } }, } } @@ -1909,10 +1997,12 @@ export const userTableServerTool: BaseServerTool message: 'outputs array (with blockId + path entries) is required', } } - const tableForGroup = await getTableById(args.tableId) - if (!tableForGroup || tableForGroup.workspaceId !== workspaceId) { - return { success: false, message: `Table not found: ${args.tableId}` } - } + const { table: tableForGroup } = await executeCopilotTableUseCase( + context, + readTableUseCase, + { tableId: args.tableId, workspaceId }, + { tableId: args.tableId } + ) for (const o of rawOutputs) { if (!o.blockId || !o.path) { @@ -1970,17 +2060,20 @@ export const userTableServerTool: BaseServerTool ...(deploymentMode ? { deploymentMode } : {}), outputs, } - const requestId = generateId().slice(0, 8) assertNotAborted() - // Mothership stages groups silently by default — the AI may add more - // columns or update deps before the user wants rows to fire. Caller - // can opt in by passing `autoRun: true`. const autoRun = args.autoRun === true - const updated = await addWorkflowGroup( - { tableId: args.tableId, group, outputColumns, autoRun, actorUserId: context.userId }, - requestId + const { table: updated } = await executeCopilotTableUseCase( + context, + createTableGroupUseCase, + { + tableId: args.tableId, + workspaceId, + group, + outputColumns, + autoRun, + }, + { tableId: args.tableId } ) - signalTableSchemaChanged(args.tableId) return { success: true, message: `Added workflow group "${name ?? groupId}" with ${outputs.length} output column(s)`, @@ -1998,10 +2091,12 @@ export const userTableServerTool: BaseServerTool if (!groupId) { return { success: false, message: 'groupId is required for update_workflow_group' } } - const tableForUpdate = await getTableById(args.tableId) - if (!tableForUpdate || tableForUpdate.workspaceId !== workspaceId) { - return { success: false, message: `Table not found: ${args.tableId}` } - } + const { table: tableForUpdate } = await executeCopilotTableUseCase( + context, + readTableUseCase, + { tableId: args.tableId, workspaceId }, + { tableId: args.tableId } + ) const updateOutputs = args.outputs as WorkflowGroupOutput[] | undefined if (updateOutputs && updateOutputs.length > 0) { // Resolve which workflow these outputs apply to: explicit override @@ -2033,13 +2128,14 @@ export const userTableServerTool: BaseServerTool return { success: false, message: validationError } } } - const requestId = generateId().slice(0, 8) assertNotAborted() - const updated = await updateWorkflowGroup( + const { table: updated } = await executeCopilotTableUseCase( + context, + updateTableGroupUseCase, { tableId: args.tableId, + workspaceId, groupId, - actorUserId: context.userId, workflowId: args.workflowId as string | undefined, name: args.name as string | undefined, dependencies: args.dependencies as WorkflowGroupDependencies | undefined, @@ -2051,9 +2147,8 @@ export const userTableServerTool: BaseServerTool deploymentMode: parseDeploymentMode(args.deploymentMode), autoRun: typeof args.autoRun === 'boolean' ? args.autoRun : undefined, }, - requestId + { tableId: args.tableId } ) - signalTableSchemaChanged(args.tableId) return { success: true, message: `Updated workflow group ${groupId}`, @@ -2068,14 +2163,13 @@ export const userTableServerTool: BaseServerTool if (!groupId) { return { success: false, message: 'groupId is required for delete_workflow_group' } } - const tableForDelete = await getTableById(args.tableId) - if (!tableForDelete || tableForDelete.workspaceId !== workspaceId) { - return { success: false, message: `Table not found: ${args.tableId}` } - } - const requestId = generateId().slice(0, 8) assertNotAborted() - const updated = await deleteWorkflowGroup({ tableId: args.tableId, groupId }, requestId) - signalTableSchemaChanged(args.tableId) + const { table: updated } = await executeCopilotTableUseCase( + context, + deleteTableGroupUseCase, + { tableId: args.tableId, workspaceId, groupId }, + { tableId: args.tableId } + ) return { success: true, message: `Deleted workflow group ${groupId}`, @@ -2110,6 +2204,7 @@ export const userTableServerTool: BaseServerTool path, columnName, actorUserId: context.userId, + workspaceId, }, requestId ) @@ -2139,7 +2234,7 @@ export const userTableServerTool: BaseServerTool const requestId = generateId().slice(0, 8) assertNotAborted() const updated = await deleteWorkflowGroupOutput( - { tableId: args.tableId, groupId, columnName }, + { tableId: args.tableId, groupId, columnName, workspaceId }, requestId ) signalTableSchemaChanged(args.tableId) @@ -2187,17 +2282,20 @@ export const userTableServerTool: BaseServerTool } rowIds = rawRowIds as string[] } - const requestId = generateId().slice(0, 8) assertNotAborted() - const { dispatchId } = await runWorkflowColumn({ - tableId: args.tableId, - workspaceId, - groupIds, - mode: runMode, - rowIds, - requestId, - triggeredByUserId: context.userId, - }) + const { dispatchId } = await executeCopilotTableUseCase( + context, + startTableRun, + { + kind: 'selection', + tableId: args.tableId, + assertedWorkspaceId: workspaceId, + groupIds, + mode: runMode, + rowIds, + }, + { tableId: args.tableId } + ) const scopeLabel = rowIds ? `${rowIds.length} row(s) by id` : runMode return { success: true, @@ -2220,14 +2318,23 @@ export const userTableServerTool: BaseServerTool if (scope === 'row' && !rowId) { return { success: false, message: 'rowId is required when scope is "row"' } } - const tableForCancel = await getTableById(args.tableId) - if (!tableForCancel || tableForCancel.workspaceId !== workspaceId) { - return { success: false, message: `Table not found: ${args.tableId}` } - } assertNotAborted() - const cancelled = await cancelWorkflowGroupRuns( - args.tableId, - scope === 'row' ? rowId : undefined + const { cancelled } = await executeCopilotTableUseCase( + context, + cancelTableRuns, + scope === 'row' + ? { + scope: 'row', + tableId: args.tableId, + assertedWorkspaceId: workspaceId, + rowId: rowId as string, + } + : { + scope: 'all', + tableId: args.tableId, + assertedWorkspaceId: workspaceId, + }, + { tableId: args.tableId } ) return { success: true, @@ -2377,8 +2484,10 @@ export const userTableServerTool: BaseServerTool error: errorMessage, cause, }) - const displayMessage = cause ? `${errorMessage} (${cause})` : errorMessage - return { success: false, message: `Operation failed: ${displayMessage}` } + return { + success: false, + message: `Operation failed: ${messageForCopilotTableError(error)}`, + } } }, } diff --git a/apps/sim/lib/folders/orchestration.test.ts b/apps/sim/lib/folders/orchestration.test.ts index 3c5a87dfaec..013bd4c166c 100644 --- a/apps/sim/lib/folders/orchestration.test.ts +++ b/apps/sim/lib/folders/orchestration.test.ts @@ -75,6 +75,7 @@ import { createFolderAtPathTransition, deleteFolder, deleteFolderByPath, + deleteFolderByPathTransition, relocateFolderByPath, restoreFolder, updateFolder, @@ -415,6 +416,32 @@ describe('path-owned folder mutations', () => { expect(result).toMatchObject({ success: true, path: '/Reports' }) }) + it('returns authoritative folder identity without double-auditing for application projection', async () => { + const source = folderRow({ id: 'folder-1', name: 'Reports' }) + mockLoadActiveFolderPathIndex.mockResolvedValue({ + rowById: new Map([['folder-1', source]]), + pathById: new Map([['folder-1', '/Reports']]), + idByPath: new Map([['/Reports', 'folder-1']]), + }) + mockArchiveFolderCascade.mockResolvedValueOnce({ folders: 1, children: 2 }) + + const result = await deleteFolderByPathTransition({ + resourceType: 'table', + workspaceId: 'ws-1', + userId: 'user-1', + path: '/Reports', + recursive: true, + }) + + expect(result).toMatchObject({ + success: true, + folderId: 'folder-1', + folderName: 'Reports', + deletedItems: { folders: 1, tables: 2 }, + }) + expect(auditMock.recordAudit).not.toHaveBeenCalled() + }) + it('rejects relocating a folder beneath its own descendant before writing', async () => { const source = folderRow({ id: 'folder-1', name: 'Reports' }) mockLoadActiveFolderPathIndex.mockResolvedValue({ diff --git a/apps/sim/lib/table/__tests__/service-filter-threading.test.ts b/apps/sim/lib/table/__tests__/service-filter-threading.test.ts index 889e75de2d8..2730adf8431 100644 --- a/apps/sim/lib/table/__tests__/service-filter-threading.test.ts +++ b/apps/sim/lib/table/__tests__/service-filter-threading.test.ts @@ -45,7 +45,12 @@ vi.mock('@/lib/table/validation', () => ({ checkBatchUniqueConstraintsDb: vi.fn(async () => ({ valid: true, errors: [] })), })) -import { deleteRowsByFilter, queryRows, updateRowsByFilter } from '@/lib/table/rows/service' +import { + deleteRowsByFilter, + queryRows, + requireTableRowIds, + updateRowsByFilter, +} from '@/lib/table/rows/service' const COLUMNS: ColumnDefinition[] = [ { name: 'name', type: 'string' }, @@ -99,7 +104,6 @@ describe('service filter threading', () => { }) it('updateRowsByFilter forwards table.schema.columns to buildFilterClause', async () => { - dbChainMockFns.where.mockResolvedValueOnce([]) await updateRowsByFilter( TABLE, { filter: { birthDate: { $lt: '2024-06-01' } }, data: { name: 'x' } }, @@ -114,8 +118,20 @@ describe('service filter threading', () => { ) }) + it('treats an empty bulk patch as a no-op before selecting rows', async () => { + const result = await updateRowsByFilter( + TABLE, + { filter: { score: { $gt: 0 } }, data: {} }, + 'req-1' + ) + + expect(result).toEqual({ affectedCount: 0, affectedRowIds: [] }) + expect(buildFilterClause).not.toHaveBeenCalled() + expect(dbChainMockFns.select).not.toHaveBeenCalled() + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) + it('deleteRowsByFilter forwards table.schema.columns to buildFilterClause', async () => { - dbChainMockFns.where.mockResolvedValueOnce([]) await deleteRowsByFilter(TABLE, { filter: { score: { $gt: 90 } } }, 'req-1') expect(buildFilterClause).toHaveBeenCalledTimes(1) @@ -125,6 +141,28 @@ describe('service filter threading', () => { COLUMNS ) }) + + it('verifies explicit row selections in bounded canonical-scope chunks', async () => { + const rowIds = Array.from( + { length: TABLE_LIMITS.DELETE_BATCH_SIZE + 1 }, + (_, index) => `row-${index}` + ) + dbChainMockFns.where + .mockResolvedValueOnce([{ count: TABLE_LIMITS.DELETE_BATCH_SIZE }]) + .mockResolvedValueOnce([{ count: 1 }]) + + await expect(requireTableRowIds(TABLE.id, TABLE.workspaceId, rowIds)).resolves.toBeUndefined() + expect(dbChainMockFns.select).toHaveBeenCalledTimes(2) + expect(dbChainMockFns.where).toHaveBeenCalledTimes(2) + }) + + it('conceals a missing explicit row selection', async () => { + dbChainMockFns.where.mockResolvedValueOnce([{ count: 0 }]) + + await expect( + requireTableRowIds(TABLE.id, TABLE.workspaceId, ['missing-row']) + ).rejects.toMatchObject({ code: 'not_found' }) + }) }) describe('bulk update/delete limited-subset ordering', () => { @@ -143,10 +181,10 @@ describe('bulk update/delete limited-subset ordering', () => { expect(dbChainMockFns.limit).toHaveBeenCalledWith(5) }) - it('does not order an unbounded updateRowsByFilter', async () => { - dbChainMockFns.where.mockResolvedValueOnce([]) + it('orders and caps an updateRowsByFilter without an explicit limit', async () => { await updateRowsByFilter(TABLE, { filter: { score: { $gt: 0 } }, data: { name: 'x' } }, 'req-1') - expect(dbChainMockFns.orderBy).not.toHaveBeenCalled() + expect(dbChainMockFns.orderBy).toHaveBeenCalled() + expect(dbChainMockFns.limit).toHaveBeenCalledWith(TABLE_LIMITS.MAX_BULK_OPERATION_SIZE + 1) }) it('orders the match query when deleteRowsByFilter has a limit', async () => { diff --git a/apps/sim/lib/table/__tests__/update-row.test.ts b/apps/sim/lib/table/__tests__/update-row.test.ts index 461d6cecbe1..91bdef534c9 100644 --- a/apps/sim/lib/table/__tests__/update-row.test.ts +++ b/apps/sim/lib/table/__tests__/update-row.test.ts @@ -330,6 +330,7 @@ describe('mutation paths — SET LOCAL timeouts', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() + dbChainMockFns.execute.mockResolvedValue([{ count: 0 }]) }) it('insertRow sets the default 10s/3s/5s timeouts', async () => { @@ -421,6 +422,20 @@ describe('mutation paths — SET LOCAL timeouts', () => { expect(findExecutedSqlContaining('pg_advisory_xact_lock')).toBe(true) expect(findExecutedSqlContaining('hashtextextended')).toBe(true) }) + + it('replaceTableRows reports the authoritative bounded delete count', async () => { + dbChainMockFns.execute.mockResolvedValue([{ count: 7 }]) + + const result = await replaceTableRows( + { tableId: 'tbl-1', workspaceId: 'ws-1', rows: [] }, + { ...TABLE, rowCount: 7 }, + 'req-1' + ) + + expect(result).toEqual({ deletedCount: 7, insertedCount: 0 }) + expect(findExecutedSqlContaining('DELETE FROM')).toBe(true) + expect(findExecutedSqlContaining('SELECT count(*)::integer AS count FROM deleted')).toBe(true) + }) }) describe('batchUpdateRows — per-row partial merge', () => { diff --git a/apps/sim/lib/table/api/index.ts b/apps/sim/lib/table/api/index.ts new file mode 100644 index 00000000000..962b274a4f3 --- /dev/null +++ b/apps/sim/lib/table/api/index.ts @@ -0,0 +1 @@ +export { v2TableErrorPolicies } from '@/lib/table/api/route-policies' diff --git a/apps/sim/lib/table/api/route-policies.ts b/apps/sim/lib/table/api/route-policies.ts new file mode 100644 index 00000000000..a6b270166d1 --- /dev/null +++ b/apps/sim/lib/table/api/route-policies.ts @@ -0,0 +1,54 @@ +import type { V2ErrorPolicy } from '@/lib/api/server/routes' +import { TableOperationError } from '@/lib/table/application/errors' +import { TableLockedError } from '@/lib/table/mutation-locks' +import { + v2CaughtOrchestrationError, + v2Error, + v2ErrorForOrchestration, +} from '@/app/api/v2/lib/response' + +function renderTableError(error: unknown) { + if (error instanceof TableOperationError) { + return v2ErrorForOrchestration( + error.code, + error.message, + error.code === 'locked' + ? { ...(error.lock ? { lock: error.lock } : {}), ...error.details } + : error.details + ) + } + if (error instanceof TableLockedError) { + return v2Error('LOCKED', error.message, { details: { lock: error.lock } }) + } + return v2CaughtOrchestrationError(error) +} + +export const v2TableErrorPolicies = { + default: { + render: renderTableError, + } satisfies V2ErrorPolicy, + concealTableAuthorization: { + render(error) { + const response = renderTableError(error) + if (!response) return null + if (response.status === 403) return v2Error('NOT_FOUND', 'Table not found') + return response + }, + } satisfies V2ErrorPolicy, + concealImportAuthorization: { + render(error) { + const response = renderTableError(error) + if (!response) return null + if (response.status === 403) return v2Error('NOT_FOUND', 'Table import not found') + return response + }, + } satisfies V2ErrorPolicy, + concealExportAuthorization: { + render(error) { + const response = renderTableError(error) + if (!response) return null + if (response.status === 403) return v2Error('NOT_FOUND', 'Table export not found') + return response + }, + } satisfies V2ErrorPolicy, +} as const diff --git a/apps/sim/lib/table/api/row-route-policies.ts b/apps/sim/lib/table/api/row-route-policies.ts new file mode 100644 index 00000000000..08dba0ffdda --- /dev/null +++ b/apps/sim/lib/table/api/row-route-policies.ts @@ -0,0 +1,13 @@ +import type { V2ErrorPolicy } from '@/lib/api/server/routes' +import { v2TableErrorPolicies } from '@/lib/table/api/route-policies' +import { TableRowsValidationError } from '@/lib/table/application/rows' +import { v2Error } from '@/app/api/v2/lib/response' + +export const v2TableRowsErrorPolicy = { + render(error) { + if (error instanceof TableRowsValidationError) { + return v2Error('BAD_REQUEST', error.message, { details: error.details }) + } + return v2TableErrorPolicies.concealTableAuthorization.render(error) + }, +} satisfies V2ErrorPolicy diff --git a/apps/sim/lib/table/application/authorization.test.ts b/apps/sim/lib/table/application/authorization.test.ts new file mode 100644 index 00000000000..f5a2ae99f7b --- /dev/null +++ b/apps/sim/lib/table/application/authorization.test.ts @@ -0,0 +1,160 @@ +/** + * @vitest-environment node + */ + +import type { Principal } from '@sim/auth/principal' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const resolvePermission = vi.hoisted(() => vi.fn()) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: resolvePermission, +})) + +import type { OrchestrationError } from '@/lib/core/orchestration/types' +import { authorizeTableOperation } from '@/lib/table/application/authorization' +import { tableOperations } from '@/lib/table/application/operations' + +const authorizationContext = { + workspaceId: 'workspace-1', + workspaceOrganizationId: 'organization-1', + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-user-1', + tableId: 'table-1', +} + +async function expectForbidden(principal: Principal) { + await expect( + authorizeTableOperation(principal, tableOperations.updateRow, authorizationContext) + ).rejects.toMatchObject>({ code: 'forbidden' }) +} + +describe('table operation authorization', () => { + beforeEach(() => { + vi.clearAllMocks() + resolvePermission.mockResolvedValue('write') + }) + + it('reauthorizes session and personal-key subjects against current policy', async () => { + await authorizeTableOperation( + { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + tableOperations.updateRow, + authorizationContext + ) + await authorizeTableOperation( + { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' }, + tableOperations.updateRow, + authorizationContext + ) + + expect(resolvePermission).toHaveBeenCalledTimes(2) + expect(resolvePermission).toHaveBeenNthCalledWith( + 1, + 'user-1', + 'workspace-1', + 'organization-1', + undefined, + { forUpdate: undefined } + ) + }) + + it('rejects a current reader for a write operation', async () => { + resolvePermission.mockResolvedValue('read') + + await expectForbidden({ kind: 'session', userId: 'user-1', sessionId: 'session-1' }) + }) + + it('rejects disabled personal keys before permission lookup', async () => { + await expect( + authorizeTableOperation( + { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' }, + tableOperations.updateRow, + { ...authorizationContext, allowPersonalApiKeys: false } + ) + ).rejects.toMatchObject>({ code: 'forbidden' }) + expect(resolvePermission).not.toHaveBeenCalled() + }) + + it('allows workspace keys only in their credential workspace', async () => { + await authorizeTableOperation( + { kind: 'workspace_api_key', workspaceId: 'workspace-1', keyId: 'key-1' }, + tableOperations.updateRow, + authorizationContext + ) + expect(resolvePermission).not.toHaveBeenCalled() + + await expectForbidden({ + kind: 'workspace_api_key', + workspaceId: 'workspace-2', + keyId: 'key-2', + }) + }) + + it('reauthorizes a valid table-scoped delegation as its human subject', async () => { + await authorizeTableOperation( + { + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'tool-call-1', + audience: 'sim:tables', + issuedAt: new Date(Date.now() - 1_000), + expiresAt: new Date(Date.now() + 60_000), + resourceScope: { tableId: 'table-1', chatId: 'chat-1' }, + }, + tableOperations.updateRow, + authorizationContext + ) + + expect(resolvePermission).toHaveBeenCalledWith( + 'user-1', + 'workspace-1', + 'organization-1', + undefined, + { forUpdate: undefined } + ) + }) + + it('rejects wrong-audience, expired, cross-workspace, and wrong-table delegations before lookup', async () => { + const base = { + kind: 'delegated' as const, + serviceId: 'copilot' as const, + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'tool-call-1', + audience: 'sim:tables', + issuedAt: new Date(Date.now() - 10_000), + } + + await expectForbidden({ + ...base, + audience: 'sim:files', + expiresAt: new Date(Date.now() + 60_000), + resourceScope: { tableId: 'table-1' }, + }) + await expectForbidden({ + ...base, + expiresAt: new Date(Date.now() - 1), + resourceScope: { tableId: 'table-1' }, + }) + await expectForbidden({ + ...base, + workspaceId: 'workspace-2', + expiresAt: new Date(Date.now() + 60_000), + resourceScope: { tableId: 'table-1' }, + }) + await expectForbidden({ + ...base, + expiresAt: new Date(Date.now() + 60_000), + resourceScope: { tableId: 'table-2' }, + }) + expect(resolvePermission).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/table/application/authorization.ts b/apps/sim/lib/table/application/authorization.ts new file mode 100644 index 00000000000..03612e1d534 --- /dev/null +++ b/apps/sim/lib/table/application/authorization.ts @@ -0,0 +1,42 @@ +import type { Principal } from '@sim/auth/principal' +import { + authorizeWorkspaceOperation, + type WorkspaceAuthorizationContext, + type WorkspaceDelegationPolicy, +} from '@/lib/core/application' +import type { TableOperation } from '@/lib/table/application/operations' + +export const TABLE_DELEGATION_AUDIENCE = 'sim:tables' + +export interface TableAuthorizationContext extends WorkspaceAuthorizationContext { + tableId?: string + rowId?: string + viewId?: string + groupId?: string + importId?: string + exportId?: string + billedAccountUserId: string +} + +export const tableDelegationPolicy: WorkspaceDelegationPolicy = { + audience: TABLE_DELEGATION_AUDIENCE, + isWithinScope( + principal: Extract, + context: TableAuthorizationContext + ) { + return ( + principal.resourceScope?.tableId === undefined || + principal.resourceScope.tableId === context.tableId + ) + }, +} + +export function authorizeTableOperation( + principal: Principal, + operation: TableOperation, + context: TableAuthorizationContext +) { + return authorizeWorkspaceOperation(principal, operation, context, { + delegation: tableDelegationPolicy, + }) +} diff --git a/apps/sim/lib/table/application/authorized-table-use-case.ts b/apps/sim/lib/table/application/authorized-table-use-case.ts new file mode 100644 index 00000000000..d5b26fa77b0 --- /dev/null +++ b/apps/sim/lib/table/application/authorized-table-use-case.ts @@ -0,0 +1,28 @@ +import { + type AuthorizedWorkspaceUseCaseDefinition, + defineAuthorizedWorkspaceUseCase, + type WorkspaceOperation, +} from '@/lib/core/application' +import { + type TableAuthorizationContext, + tableDelegationPolicy, +} from '@/lib/table/application/authorization' + +type AuthorizedTableUseCaseDefinition< + O extends WorkspaceOperation, + I, + C extends TableAuthorizationContext, + R, +> = Omit, 'authorizationOptions'> + +export function defineAuthorizedTableUseCase< + const O extends WorkspaceOperation, + I, + C extends TableAuthorizationContext, + R, +>(definition: AuthorizedTableUseCaseDefinition) { + return defineAuthorizedWorkspaceUseCase({ + ...definition, + authorizationOptions: { delegation: tableDelegationPolicy }, + }) +} diff --git a/apps/sim/lib/table/application/columns.ts b/apps/sim/lib/table/application/columns.ts new file mode 100644 index 00000000000..eae6c59b4f7 --- /dev/null +++ b/apps/sim/lib/table/application/columns.ts @@ -0,0 +1,160 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { resolvePrincipalAttribution } from '@sim/auth/principal' +import { generateRequestId } from '@/lib/core/utils/request' +import { + addTableColumn, + type ColumnDefinition, + type ColumnType, + deleteColumn, + type SelectOption, + type TableDefinition, +} from '@/lib/table' +import { defineAuthorizedTableUseCase } from '@/lib/table/application/authorized-table-use-case' +import { resolveActiveTableContext } from '@/lib/table/application/context' +import { throwTableOperationFailure } from '@/lib/table/application/errors' +import { tableOperations } from '@/lib/table/application/operations' +import { signalTableSchemaChanged } from '@/lib/table/events' +import { performUpdateTableColumn } from '@/lib/table/orchestration' + +interface TableColumnInput { + tableId: string + workspaceId: string +} + +export interface AddTableColumnInput extends TableColumnInput { + column: { + id?: string + name: string + type: string + required?: boolean + unique?: boolean + position?: number + options?: SelectOption[] + multiple?: boolean + currencyCode?: string + } +} + +export const addTableColumnUseCase = defineAuthorizedTableUseCase({ + operation: tableOperations.addColumn, + resolveContext: ({ input }: { input: AddTableColumnInput }) => + resolveActiveTableContext({ + tableId: input.tableId, + assertedWorkspaceId: input.workspaceId, + }), + async execute({ input, context }) { + const table = await addTableColumn(context.table.id, input.column, generateRequestId(), { + expectedWorkspaceId: context.workspaceId, + }) + return { table } + }, + projectAudit({ input, context, result }) { + return { + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: result.table.id, + resourceName: result.table.name, + description: `Added column "${input.column.name}" to table "${context.table.name}"`, + metadata: { column: input.column }, + } + }, + afterSuccess({ context }) { + signalTableSchemaChanged(context.table.id) + }, +}) + +export interface UpdateTableColumnInput extends TableColumnInput { + columnName: string + updates: { + name?: string + type?: ColumnType + required?: boolean + unique?: boolean + options?: unknown + multiple?: boolean + currencyCode?: string + } +} + +export const updateTableColumnUseCase = defineAuthorizedTableUseCase({ + operation: tableOperations.updateColumn, + resolveContext: ({ input }: { input: UpdateTableColumnInput }) => + resolveActiveTableContext({ + tableId: input.tableId, + assertedWorkspaceId: input.workspaceId, + }), + async execute({ principal, input, context }) { + const attribution = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }) + const outcome = await performUpdateTableColumn({ + table: context.table, + columnName: input.columnName, + userId: attribution.attributedUserId, + updates: input.updates, + requestId: generateRequestId(), + expectedWorkspaceId: context.workspaceId, + recordAudit: false, + }) + if (!outcome.success || !outcome.table) { + throwTableOperationFailure(outcome, 'Failed to update column') + } + return { + table: outcome.table, + changed: + JSON.stringify(context.table.schema) !== JSON.stringify(outcome.table.schema) || + JSON.stringify(context.table.metadata) !== JSON.stringify(outcome.table.metadata), + } + }, + projectAudit({ input, context, result }) { + if (!result.changed) return [] + return { + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: result.table.id, + resourceName: result.table.name, + description: `Updated column "${input.columnName}" in table "${context.table.name}"`, + metadata: { columnName: input.columnName, updates: input.updates }, + } + }, + afterSuccess({ context, result }) { + if (result.changed) signalTableSchemaChanged(context.table.id) + }, +}) + +export interface DeleteTableColumnInput extends TableColumnInput { + columnName: string +} + +export const deleteTableColumnUseCase = defineAuthorizedTableUseCase({ + operation: tableOperations.deleteColumn, + resolveContext: ({ input }: { input: DeleteTableColumnInput }) => + resolveActiveTableContext({ + tableId: input.tableId, + assertedWorkspaceId: input.workspaceId, + }), + async execute({ input, context }): Promise<{ table: TableDefinition }> { + const table = await deleteColumn( + { tableId: context.table.id, columnName: input.columnName }, + generateRequestId(), + { expectedWorkspaceId: context.workspaceId } + ) + return { table } + }, + projectAudit({ input, context, result }) { + return { + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: result.table.id, + resourceName: result.table.name, + description: `Deleted column "${input.columnName}" from table "${context.table.name}"`, + metadata: { columnName: input.columnName }, + } + }, + afterSuccess({ context }) { + signalTableSchemaChanged(context.table.id) + }, +}) + +export type TableColumnApplicationResult = { table: TableDefinition } +export type TableColumnDefinition = ColumnDefinition diff --git a/apps/sim/lib/table/application/context.test.ts b/apps/sim/lib/table/application/context.test.ts new file mode 100644 index 00000000000..bc6f2b41186 --- /dev/null +++ b/apps/sim/lib/table/application/context.test.ts @@ -0,0 +1,71 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { getTableById, select } = vi.hoisted(() => ({ + getTableById: vi.fn(), + select: vi.fn(), +})) + +vi.mock('@sim/db', () => ({ db: { select } })) +vi.mock('@/lib/table', () => ({ getTableById })) + +import { resolveActiveTableContext } from '@/lib/table/application/context' + +function mockWorkspaceQuery(rows: unknown[]) { + const limit = vi.fn().mockResolvedValue(rows) + const where = vi.fn(() => ({ limit })) + const from = vi.fn(() => ({ where })) + select.mockReturnValue({ from }) + return { from, where, limit } +} + +describe('table application context', () => { + beforeEach(() => { + vi.clearAllMocks() + getTableById.mockResolvedValue({ + id: 'table-1', + workspaceId: 'workspace-1', + name: 'Contacts', + }) + }) + + it('derives workspace scope from the canonical active table', async () => { + mockWorkspaceQuery([ + { + workspaceId: 'workspace-1', + workspaceOrganizationId: 'organization-1', + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-user-1', + }, + ]) + + await expect( + resolveActiveTableContext({ tableId: 'table-1', assertedWorkspaceId: 'workspace-1' }) + ).resolves.toMatchObject({ + tableId: 'table-1', + workspaceId: 'workspace-1', + billedAccountUserId: 'billing-user-1', + }) + expect(getTableById).toHaveBeenCalledWith('table-1') + expect(select).toHaveBeenCalledTimes(1) + }) + + it('conceals an asserted cross-workspace table before workspace resolution', async () => { + await expect( + resolveActiveTableContext({ tableId: 'table-1', assertedWorkspaceId: 'workspace-2' }) + ).rejects.toMatchObject({ code: 'not_found', message: 'Table not found' }) + expect(select).not.toHaveBeenCalled() + }) + + it('fails when the canonical workspace is unavailable', async () => { + mockWorkspaceQuery([]) + + await expect(resolveActiveTableContext({ tableId: 'table-1' })).rejects.toMatchObject({ + code: 'not_found', + message: 'Workspace not found', + }) + }) +}) diff --git a/apps/sim/lib/table/application/context.ts b/apps/sim/lib/table/application/context.ts new file mode 100644 index 00000000000..2d9603aec8b --- /dev/null +++ b/apps/sim/lib/table/application/context.ts @@ -0,0 +1,46 @@ +import { db } from '@sim/db' +import { workspace } from '@sim/db/schema' +import { and, eq, isNull } from 'drizzle-orm' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { getTableById, type TableDefinition } from '@/lib/table' +import type { TableAuthorizationContext } from '@/lib/table/application/authorization' + +export type TableWorkspaceContext = TableAuthorizationContext + +export interface ActiveTableContext extends TableWorkspaceContext { + tableId: string + table: TableDefinition +} + +export async function resolveTableWorkspaceContext( + workspaceId: string +): Promise { + const [canonical] = await db + .select({ + workspaceId: workspace.id, + workspaceOrganizationId: workspace.organizationId, + allowPersonalApiKeys: workspace.allowPersonalApiKeys, + billedAccountUserId: workspace.billedAccountUserId, + }) + .from(workspace) + .where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt))) + .limit(1) + + if (!canonical) throw new OrchestrationError('not_found', 'Workspace not found') + return canonical +} + +export async function resolveActiveTableContext(input: { + tableId: string + assertedWorkspaceId?: string +}): Promise { + const table = await getTableById(input.tableId) + if ( + !table || + (input.assertedWorkspaceId !== undefined && table.workspaceId !== input.assertedWorkspaceId) + ) { + throw new OrchestrationError('not_found', 'Table not found') + } + const workspaceContext = await resolveTableWorkspaceContext(table.workspaceId) + return { ...workspaceContext, tableId: table.id, table } +} diff --git a/apps/sim/lib/table/application/delegated-principal.ts b/apps/sim/lib/table/application/delegated-principal.ts new file mode 100644 index 00000000000..db2c3365c61 --- /dev/null +++ b/apps/sim/lib/table/application/delegated-principal.ts @@ -0,0 +1,36 @@ +import type { DelegatedPrincipal } from '@sim/auth/principal' +import { TABLE_DELEGATION_AUDIENCE } from '@/lib/table/application/authorization' + +const TABLE_DELEGATION_TTL_MS = 5 * 60 * 1000 + +export interface TableDelegationInput { + serviceId: DelegatedPrincipal['serviceId'] + subjectUserId: string + workspaceId: string + delegationId: string + tableId?: string + chatId?: string + executionId?: string +} + +export function createTableDelegatedPrincipal(input: TableDelegationInput): DelegatedPrincipal { + if (!input.subjectUserId || !input.workspaceId || !input.delegationId) { + throw new Error('Table delegation requires subject, workspace, and delegation IDs') + } + const issuedAt = new Date() + return { + kind: 'delegated', + serviceId: input.serviceId, + subjectUserId: input.subjectUserId, + workspaceId: input.workspaceId, + delegationId: input.delegationId, + audience: TABLE_DELEGATION_AUDIENCE, + issuedAt, + expiresAt: new Date(issuedAt.getTime() + TABLE_DELEGATION_TTL_MS), + resourceScope: { + ...(input.tableId ? { tableId: input.tableId } : {}), + ...(input.chatId ? { chatId: input.chatId } : {}), + ...(input.executionId ? { executionId: input.executionId } : {}), + }, + } +} diff --git a/apps/sim/lib/table/application/errors.ts b/apps/sim/lib/table/application/errors.ts new file mode 100644 index 00000000000..2b49a259cc9 --- /dev/null +++ b/apps/sim/lib/table/application/errors.ts @@ -0,0 +1,31 @@ +import { OrchestrationError, type OrchestrationErrorCode } from '@/lib/core/orchestration/types' +import type { TableLockKind } from '@/lib/table' + +export class TableOperationError extends OrchestrationError { + constructor( + code: OrchestrationErrorCode, + message: string, + readonly details?: Record, + readonly lock?: TableLockKind + ) { + super(code, message) + this.name = 'TableOperationError' + } +} + +export function throwTableOperationFailure( + outcome: { + success: boolean + error?: string + errorCode?: OrchestrationErrorCode + lock?: TableLockKind + }, + fallback: string, + details?: Record +): never { + if (outcome.success) throw new Error('Cannot throw a successful table operation outcome') + if (!outcome.errorCode || outcome.errorCode === 'internal') { + throw new Error(fallback) + } + throw new TableOperationError(outcome.errorCode, outcome.error ?? fallback, details, outcome.lock) +} diff --git a/apps/sim/lib/table/application/exports.ts b/apps/sim/lib/table/application/exports.ts new file mode 100644 index 00000000000..9ff24c64d42 --- /dev/null +++ b/apps/sim/lib/table/application/exports.ts @@ -0,0 +1,137 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { createLogger } from '@sim/logger' +import type { V2TableExport } from '@/lib/api/contracts/v2/tables' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { getTableById, type TableDefinition } from '@/lib/table' +import type { TableAuthorizationContext } from '@/lib/table/application/authorization' +import { defineAuthorizedTableUseCase } from '@/lib/table/application/authorized-table-use-case' +import { + resolveActiveTableContext, + resolveTableWorkspaceContext, +} from '@/lib/table/application/context' +import { tableOperations } from '@/lib/table/application/operations' +import { + cancelTableExportResource, + createTableExportResource, + requireTableExport, + type TableExportRecord, + tableExportResult, + toV2TableExport, +} from '@/lib/table/orchestration/export-resource' +import { generatePresignedDownloadUrl } from '@/lib/uploads/core/storage-service' + +const logger = createLogger('TableExportApplication') +const DOWNLOAD_TTL_SECONDS = 60 * 60 + +export interface CreateTableExportInput { + tableId: string + workspaceId: string + format: 'csv' | 'json' +} + +export interface TableExportResourceInput { + exportId: string + workspaceId: string +} + +export interface TableExportResult { + export: V2TableExport +} + +export interface DownloadTableExportResult { + url: string + fileName: string + expiresAt: string +} + +interface TableExportContext extends TableAuthorizationContext { + exportId: string + tableId: string + table: TableDefinition + record: TableExportRecord +} + +async function resolveTableExportContext( + input: TableExportResourceInput +): Promise { + const record = await requireTableExport(input.exportId, input.workspaceId) + const table = await getTableById(record.tableId) + if (!table || table.workspaceId !== record.workspaceId) { + throw new OrchestrationError('not_found', 'Table export not found') + } + const workspace = await resolveTableWorkspaceContext(record.workspaceId) + return { + ...workspace, + exportId: record.id, + tableId: table.id, + table, + record, + } +} + +export const createTableExportUseCase = defineAuthorizedTableUseCase({ + operation: tableOperations.createExport, + resolveContext: ({ input }: { input: CreateTableExportInput }) => + resolveActiveTableContext({ + tableId: input.tableId, + assertedWorkspaceId: input.workspaceId, + }), + async execute({ principal, input, context }): Promise { + const record = await createTableExportResource({ table: context.table, format: input.format }) + logger.info('Created table export', { + exportId: record.id, + tableId: context.table.id, + workspaceId: context.workspaceId, + format: input.format, + principalKind: principal.kind, + }) + return { export: toV2TableExport(record, true) } + }, + projectAudit: ({ input, context }) => ({ + action: AuditAction.TABLE_EXPORTED, + resourceType: AuditResourceType.TABLE, + resourceId: context.table.id, + resourceName: context.table.name, + description: `Exported table "${context.table.name}" as ${input.format.toUpperCase()}`, + metadata: { format: input.format, rowCount: context.table.rowCount }, + }), +}) + +export const readTableExportUseCase = defineAuthorizedTableUseCase({ + operation: tableOperations.readExport, + resolveContext: ({ input }: { input: TableExportResourceInput }) => + resolveTableExportContext(input), + async execute({ context }): Promise { + return { export: toV2TableExport(context.record) } + }, +}) + +export const cancelTableExportUseCase = defineAuthorizedTableUseCase({ + operation: tableOperations.cancelExport, + resolveContext: ({ input }: { input: TableExportResourceInput }) => + resolveTableExportContext(input), + async execute({ principal, context }): Promise { + const record = await cancelTableExportResource(context.record) + logger.info('Canceled table export', { + exportId: record.id, + tableId: context.tableId, + workspaceId: context.workspaceId, + principalKind: principal.kind, + }) + return { export: toV2TableExport(record) } + }, +}) + +export const downloadTableExportUseCase = defineAuthorizedTableUseCase({ + operation: tableOperations.downloadExport, + resolveContext: ({ input }: { input: TableExportResourceInput }) => + resolveTableExportContext(input), + async execute({ context }): Promise { + const result = tableExportResult(context.record) + return { + url: await generatePresignedDownloadUrl(result.resultKey, 'workspace', DOWNLOAD_TTL_SECONDS), + fileName: result.resultKey.split('/').pop() ?? `export.${result.format}`, + expiresAt: new Date(Date.now() + DOWNLOAD_TTL_SECONDS * 1000).toISOString(), + } + }, +}) diff --git a/apps/sim/lib/table/application/folder-paths.ts b/apps/sim/lib/table/application/folder-paths.ts new file mode 100644 index 00000000000..62683ff3504 --- /dev/null +++ b/apps/sim/lib/table/application/folder-paths.ts @@ -0,0 +1,33 @@ +import type { folder } from '@sim/db/schema' +import { withFolderTreeLock } from '@/lib/folders/locks' +import type { FolderPathIndex } from '@/lib/folders/paths' +import { ROOT_FOLDER_PATH } from '@/lib/folders/paths' +import { loadActiveFolderPathIndex, resolveFolderPathFromIndex } from '@/lib/folders/queries' + +type FolderRow = typeof folder.$inferSelect + +export interface ResolvedTableFolderPath { + folderId: string | null + index: FolderPathIndex +} + +export async function resolveTableFolderPath( + workspaceId: string, + path: string +): Promise { + return withFolderTreeLock(workspaceId, 'table', async (tx) => { + const index = await loadActiveFolderPathIndex(workspaceId, 'table', tx) + const folderId = resolveFolderPathFromIndex(index, path) + return folderId === undefined ? null : { folderId, index } + }) +} + +export function tableFolderPathForId( + index: FolderPathIndex, + folderId: string | null | undefined +): string { + if (!folderId) return ROOT_FOLDER_PATH + const path = index.pathById.get(folderId) + if (!path) throw new Error('Table references an inactive or missing folder') + return path +} diff --git a/apps/sim/lib/table/application/folders.ts b/apps/sim/lib/table/application/folders.ts new file mode 100644 index 00000000000..4126481064e --- /dev/null +++ b/apps/sim/lib/table/application/folders.ts @@ -0,0 +1,179 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { resolvePrincipalAttribution } from '@sim/auth/principal' +import type { V2SortOrder } from '@/lib/api/contracts/v2/shared' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + createFolderAtPathTransition, + deleteFolderByPathTransition, + relocateFolderByPathTransition, +} from '@/lib/folders/orchestration' +import { + type FolderSortBy, + listActiveFolderRows, + loadActiveFolderPathIndex, + resolveFolderPathFromIndex, +} from '@/lib/folders/queries' +import { defineAuthorizedTableUseCase } from '@/lib/table/application/authorized-table-use-case' +import { resolveTableWorkspaceContext } from '@/lib/table/application/context' +import { throwTableOperationFailure } from '@/lib/table/application/errors' +import { tableOperations } from '@/lib/table/application/operations' + +export interface ListTableFoldersInput { + workspaceId: string + parentPath?: string + search?: string + sortBy?: Exclude + sortOrder?: V2SortOrder +} + +export const listTableFoldersUseCase = defineAuthorizedTableUseCase({ + operation: tableOperations.listFolders, + resolveContext: ({ input }: { input: ListTableFoldersInput }) => + resolveTableWorkspaceContext(input.workspaceId), + async execute({ input, context }) { + const index = await loadActiveFolderPathIndex(context.workspaceId, 'table') + const parentId = + input.parentPath === undefined + ? undefined + : resolveFolderPathFromIndex(index, input.parentPath) + if (input.parentPath !== undefined && parentId === undefined) { + throw new OrchestrationError('not_found', 'Folder not found') + } + const folders = await listActiveFolderRows(context.workspaceId, 'table', { + parentId, + search: input.search, + sortBy: input.sortBy, + sortOrder: input.sortOrder, + }) + return { folders, index } + }, +}) + +export interface CreateTableFolderInput { + workspaceId: string + path: string +} + +export const createTableFolderUseCase = defineAuthorizedTableUseCase({ + operation: tableOperations.createFolder, + resolveContext: ({ input }: { input: CreateTableFolderInput }) => + resolveTableWorkspaceContext(input.workspaceId), + async execute({ principal, input, context }) { + const attribution = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }) + const result = await createFolderAtPathTransition({ + resourceType: 'table', + workspaceId: context.workspaceId, + userId: attribution.attributedUserId, + path: input.path, + }) + if (!result.success || !result.folder) { + throwTableOperationFailure(result, 'Failed to create folder') + } + const index = await loadActiveFolderPathIndex(context.workspaceId, 'table') + return { folder: result.folder, index, path: input.path } + }, + projectAudit({ result }) { + return { + action: AuditAction.FOLDER_CREATED, + resourceType: AuditResourceType.FOLDER, + resourceId: result.folder.id, + resourceName: result.folder.name, + description: `Created table folder "${result.path}"`, + metadata: { path: result.path, folderResourceType: 'table' }, + } + }, +}) + +export interface UpdateTableFolderInput extends CreateTableFolderInput { + destinationPath: string +} + +export const updateTableFolderUseCase = defineAuthorizedTableUseCase({ + operation: tableOperations.updateFolder, + resolveContext: ({ input }: { input: UpdateTableFolderInput }) => + resolveTableWorkspaceContext(input.workspaceId), + async execute({ principal, input, context }) { + const attribution = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }) + const result = await relocateFolderByPathTransition({ + resourceType: 'table', + workspaceId: context.workspaceId, + userId: attribution.attributedUserId, + path: input.path, + destinationPath: input.destinationPath, + }) + if (!result.success || !result.folder) { + throwTableOperationFailure(result, 'Failed to move folder') + } + const index = await loadActiveFolderPathIndex(context.workspaceId, 'table') + return { folder: result.folder, index, path: input.destinationPath, sourcePath: input.path } + }, + projectAudit({ result }) { + return { + action: AuditAction.FOLDER_MOVED, + resourceType: AuditResourceType.FOLDER, + resourceId: result.folder.id, + resourceName: result.folder.name, + description: `Moved table folder to "${result.path}"`, + metadata: { + sourcePath: result.sourcePath, + destinationPath: result.path, + folderResourceType: 'table', + }, + } + }, +}) + +export interface DeleteTableFolderInput extends CreateTableFolderInput { + recursive: boolean +} + +export const deleteTableFolderUseCase = defineAuthorizedTableUseCase({ + operation: tableOperations.deleteFolder, + resolveContext: ({ input }: { input: DeleteTableFolderInput }) => + resolveTableWorkspaceContext(input.workspaceId), + async execute({ principal, input, context }) { + const attribution = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }) + const result = await deleteFolderByPathTransition({ + resourceType: 'table', + workspaceId: context.workspaceId, + userId: attribution.attributedUserId, + path: input.path, + recursive: input.recursive, + }) + if (!result.success || !result.deletedItems || !result.folderId || !result.folderName) { + throwTableOperationFailure(result, 'Failed to delete folder') + } + return { + path: input.path, + deleted: true as const, + deletedItems: { + folders: result.deletedItems.folders, + tables: result.deletedItems.tables ?? 0, + }, + folder: { id: result.folderId, name: result.folderName }, + } + }, + projectAudit({ result }) { + return { + action: AuditAction.FOLDER_DELETED, + resourceType: AuditResourceType.FOLDER, + resourceId: result.folder.id, + resourceName: result.folder.name, + description: `Deleted table folder "${result.path}"`, + metadata: { + folderResourceType: 'table', + path: result.path, + affected: { + tables: result.deletedItems.tables, + subfolders: Math.max(result.deletedItems.folders - 1, 0), + }, + }, + } + }, +}) diff --git a/apps/sim/lib/table/application/groups.ts b/apps/sim/lib/table/application/groups.ts new file mode 100644 index 00000000000..665e887374f --- /dev/null +++ b/apps/sim/lib/table/application/groups.ts @@ -0,0 +1,279 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { resolvePrincipalAttribution } from '@sim/auth/principal' +import { createLogger } from '@sim/logger' +import { getActiveWorkflowContext } from '@sim/platform-authz/workflow' +import { generateId } from '@sim/utils/id' +import type { V2AddWorkflowGroupBody } from '@/lib/api/contracts/v2/tables' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { runDetached } from '@/lib/core/utils/background' +import { generateRequestId } from '@/lib/core/utils/request' +import type { + DeleteWorkflowGroupData, + TableDefinition, + TableSchema, + UpdateWorkflowGroupData, + WorkflowGroup, +} from '@/lib/table' +import { defineAuthorizedTableUseCase } from '@/lib/table/application/authorized-table-use-case' +import { resolveActiveTableContext } from '@/lib/table/application/context' +import { tableOperations } from '@/lib/table/application/operations' +import { startTableRun } from '@/lib/table/application/runs' +import { signalTableSchemaChanged } from '@/lib/table/events' +import { + addWorkflowGroup, + deleteWorkflowGroup, + updateWorkflowGroup, +} from '@/lib/table/workflow-groups/service' + +const logger = createLogger('TableGroupApplication') + +interface TableGroupInput { + tableId: string + workspaceId: string +} + +function groupFromTable(table: TableDefinition, groupId: string): WorkflowGroup { + const group = (table.schema as TableSchema).workflowGroups?.find( + (candidate) => candidate.id === groupId + ) + if (!group) { + throw new Error(`Workflow group ${groupId} missing from the table after a successful write`) + } + return group +} + +async function requireWorkflowInTableWorkspace( + workflowId: string, + workspaceId: string +): Promise { + const workflow = await getActiveWorkflowContext(workflowId) + if (!workflow || workflow.workspaceId !== workspaceId) { + throw new OrchestrationError('validation', 'Workflow not found in this workspace') + } +} + +export const listTableGroupsUseCase = defineAuthorizedTableUseCase({ + operation: tableOperations.listGroups, + resolveContext: ({ input }: { input: TableGroupInput }) => + resolveActiveTableContext({ + tableId: input.tableId, + assertedWorkspaceId: input.workspaceId, + }), + async execute({ context }) { + return { groups: (context.table.schema as TableSchema).workflowGroups ?? [] } + }, +}) + +export interface CreateTableGroupInput extends TableGroupInput { + group: V2AddWorkflowGroupBody['group'] + outputColumns: V2AddWorkflowGroupBody['outputColumns'] + autoRun?: boolean +} + +export const createTableGroupUseCase = defineAuthorizedTableUseCase({ + operation: tableOperations.createGroup, + resolveContext: ({ input }: { input: CreateTableGroupInput }) => + resolveActiveTableContext({ + tableId: input.tableId, + assertedWorkspaceId: input.workspaceId, + }), + async execute({ principal, input, context }) { + if (input.group.workflowId) { + await requireWorkflowInTableWorkspace(input.group.workflowId, context.workspaceId) + } + const outputNames = new Set(input.group.outputs.map((output) => output.columnName)) + const orphan = input.outputColumns.find((column) => !outputNames.has(column.name)) + if (orphan) { + throw new OrchestrationError( + 'validation', + `outputColumns entry "${orphan.name}" has no matching group.outputs[].columnName` + ) + } + + const attribution = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }) + const groupId = input.group.id ?? generateId() + const table = await addWorkflowGroup( + { + tableId: context.table.id, + workspaceId: context.workspaceId, + group: { ...input.group, id: groupId } as WorkflowGroup, + outputColumns: input.outputColumns.map((column) => ({ + ...column, + workflowGroupId: groupId, + })), + autoRun: input.autoRun ?? false, + suppressAutoRunDispatch: true, + actorUserId: attribution.attributedUserId, + }, + generateRequestId() + ) + return { table, group: groupFromTable(table, groupId) } + }, + projectAudit({ result }) { + return { + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: result.table.id, + resourceName: result.table.name, + description: `Added workflow group "${result.group.id}" to table "${result.table.name}"`, + metadata: { op: 'add_group', groupId: result.group.id }, + } + }, + afterSuccess({ principal, input, context, result, request }) { + signalTableSchemaChanged(context.table.id) + if (input.autoRun === true) { + runDetached('table-group-create-auto-run', async () => { + await startTableRun.execute({ + principal, + input: { + kind: 'selection', + tableId: context.table.id, + assertedWorkspaceId: context.workspaceId, + groupIds: [result.group.id], + mode: 'all', + }, + request, + }) + logger.info('Started table group auto-run', { + tableId: context.table.id, + groupId: result.group.id, + }) + }) + } + }, +}) + +export interface UpdateTableGroupInput + extends TableGroupInput, + Omit< + UpdateWorkflowGroupData, + 'tableId' | 'workspaceId' | 'actorUserId' | 'suppressAutoRunDispatch' + > {} + +export const updateTableGroupUseCase = defineAuthorizedTableUseCase({ + operation: tableOperations.updateGroup, + resolveContext: ({ input }: { input: UpdateTableGroupInput }) => + resolveActiveTableContext({ + tableId: input.tableId, + assertedWorkspaceId: input.workspaceId, + }), + async execute({ principal, input, context }) { + if (input.workflowId !== undefined) { + await requireWorkflowInTableWorkspace(input.workflowId, context.workspaceId) + } + const attribution = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }) + const previousGroup = (context.table.schema.workflowGroups ?? []).find( + (group) => group.id === input.groupId + ) + const table = await updateWorkflowGroup( + { + tableId: context.table.id, + workspaceId: context.workspaceId, + groupId: input.groupId, + actorUserId: attribution.attributedUserId, + suppressAutoRunDispatch: true, + ...(input.workflowId !== undefined ? { workflowId: input.workflowId } : {}), + ...(input.name !== undefined ? { name: input.name } : {}), + ...(input.dependencies !== undefined ? { dependencies: input.dependencies } : {}), + ...(input.outputs !== undefined ? { outputs: input.outputs } : {}), + ...(input.newOutputColumns !== undefined + ? { + newOutputColumns: input.newOutputColumns.map((column) => ({ + ...column, + workflowGroupId: input.groupId, + })), + } + : {}), + ...(input.mappingUpdates !== undefined ? { mappingUpdates: input.mappingUpdates } : {}), + ...(input.inputMappings !== undefined ? { inputMappings: input.inputMappings } : {}), + ...(input.deploymentMode !== undefined ? { deploymentMode: input.deploymentMode } : {}), + ...(input.type !== undefined ? { type: input.type } : {}), + ...(input.autoRun !== undefined ? { autoRun: input.autoRun } : {}), + }, + generateRequestId() + ) + const group = groupFromTable(table, input.groupId) + return { + table, + group, + changed: + JSON.stringify(context.table.schema) !== JSON.stringify(table.schema) || + JSON.stringify(context.table.metadata) !== JSON.stringify(table.metadata), + startAutoRun: previousGroup?.autoRun !== true && input.autoRun === true, + } + }, + projectAudit({ result }) { + if (!result.changed) return [] + return { + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: result.table.id, + resourceName: result.table.name, + description: `Updated workflow group "${result.group.id}" in table "${result.table.name}"`, + metadata: { op: 'update_group', groupId: result.group.id }, + } + }, + afterSuccess({ principal, context, result, request }) { + if (result.changed) signalTableSchemaChanged(context.table.id) + if (result.startAutoRun) { + runDetached('table-group-update-auto-run', async () => { + await startTableRun.execute({ + principal, + input: { + kind: 'selection', + tableId: context.table.id, + assertedWorkspaceId: context.workspaceId, + groupIds: [result.group.id], + mode: 'all', + }, + request, + }) + logger.info('Started table group auto-run', { + tableId: context.table.id, + groupId: result.group.id, + }) + }) + } + }, +}) + +export interface DeleteTableGroupInput + extends TableGroupInput, + Omit {} + +export const deleteTableGroupUseCase = defineAuthorizedTableUseCase({ + operation: tableOperations.deleteGroup, + resolveContext: ({ input }: { input: DeleteTableGroupInput }) => + resolveActiveTableContext({ + tableId: input.tableId, + assertedWorkspaceId: input.workspaceId, + }), + async execute({ input, context }) { + const table = await deleteWorkflowGroup( + { + tableId: context.table.id, + workspaceId: context.workspaceId, + groupId: input.groupId, + }, + generateRequestId() + ) + return { table, groupId: input.groupId } + }, + projectAudit({ result }) { + return { + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: result.table.id, + resourceName: result.table.name, + description: `Deleted workflow group "${result.groupId}" from table "${result.table.name}"`, + metadata: { op: 'delete_group', groupId: result.groupId }, + } + }, + afterSuccess({ context }) { + signalTableSchemaChanged(context.table.id) + }, +}) diff --git a/apps/sim/lib/table/application/imports.ts b/apps/sim/lib/table/application/imports.ts new file mode 100644 index 00000000000..e55563ad2dc --- /dev/null +++ b/apps/sim/lib/table/application/imports.ts @@ -0,0 +1,294 @@ +import { type Principal, resolvePrincipalAttribution } from '@sim/auth/principal' +import { createLogger } from '@sim/logger' +import type { + V2CreateTableImportBody, + V2CreateTableImportData, + V2TableImport, +} from '@/lib/api/contracts/v2/tables' +import { authorizeWorkspaceOperation } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { withFolderTreeLock } from '@/lib/folders/locks' +import { ROOT_FOLDER_PATH } from '@/lib/folders/paths' +import { loadActiveFolderPathIndex, resolveFolderPathFromIndex } from '@/lib/folders/queries' +import { + type TableAuthorizationContext, + tableDelegationPolicy, +} from '@/lib/table/application/authorization' +import { defineAuthorizedTableUseCase } from '@/lib/table/application/authorized-table-use-case' +import { + resolveActiveTableContext, + resolveTableWorkspaceContext, +} from '@/lib/table/application/context' +import { tableOperations } from '@/lib/table/application/operations' +import { + abortAuthorizedTableImportUpload, + cancelTableImportResource, + createAuthorizedTableImportResource, + findTableImportResource, + getPrincipalTableImportUpload, + getTableImportResource, + startUploadedTableImport, + type TableImportResource, + tableImportBodyFromUpload, + toV2CreateTableImport, + toV2TableImport, +} from '@/lib/table/orchestration/import-resource' +import { requestOrigin } from '@/lib/uploads/upload-session/application' +import { + assertUploadSessionAuthBinding, + completeUploadSession, + createUploadPartUrls, + type UploadSessionRecord, +} from '@/lib/uploads/upload-session/service' +import { readWorkspaceFileContentRecord } from '@/lib/workspace-files/application/read-workspace-file-record' + +const logger = createLogger('TableImportApplication') + +export interface CreateTableImportInput { + body: V2CreateTableImportBody +} + +export interface TableImportResourceInput { + importId: string + workspaceId: string +} + +export interface TableImportUploadInput extends TableImportResourceInput { + uploadToken: string +} + +export interface CreateTableImportPartsInput extends TableImportUploadInput { + partNumbers: number[] +} + +export interface CancelTableImportInput extends TableImportResourceInput { + uploadToken?: string +} + +export interface CreateTableImportResult { + import: V2CreateTableImportData +} + +export interface TableImportResult { + import: V2TableImport +} + +export interface CreateTableImportPartsResult { + parts: Awaited> +} + +interface TableImportContext extends TableAuthorizationContext { + importId: string + record: TableImportResource +} + +interface TableImportUploadContext extends TableAuthorizationContext { + importId: string + upload: UploadSessionRecord +} + +async function resolveCreateTableImportContext(input: CreateTableImportInput) { + if (input.body.target.type === 'existing') { + return resolveActiveTableContext({ + tableId: input.body.target.tableId, + assertedWorkspaceId: input.body.workspaceId, + }) + } + return resolveTableWorkspaceContext(input.body.workspaceId) +} + +async function resolveTableImportContext( + input: TableImportResourceInput +): Promise { + const record = await getTableImportResource({ + importId: input.importId, + assertedWorkspaceId: input.workspaceId, + }) + const workspace = await resolveTableWorkspaceContext(record.workspaceId) + return { + ...workspace, + importId: record.id, + ...(record.tableId ? { tableId: record.tableId } : {}), + record, + } +} + +async function resolveTableImportUploadContext( + principal: Principal, + input: TableImportUploadInput +): Promise { + const upload = await getPrincipalTableImportUpload({ + importId: input.importId, + assertedWorkspaceId: input.workspaceId, + principal, + uploadToken: input.uploadToken, + }) + const body = tableImportBodyFromUpload(upload) + const workspace = await resolveTableWorkspaceContext(body.workspaceId) + return { + ...workspace, + importId: upload.id, + ...(body.target.type === 'existing' ? { tableId: body.target.tableId } : {}), + upload, + } +} + +async function resolveImportFolderId( + workspaceId: string, + body: V2CreateTableImportBody +): Promise { + if (body.target.type !== 'new') return undefined + const path = body.target.folderPath ?? ROOT_FOLDER_PATH + return withFolderTreeLock(workspaceId, 'table', async (tx) => { + const index = await loadActiveFolderPathIndex(workspaceId, 'table', tx) + const folderId = resolveFolderPathFromIndex(index, path) + if (folderId === undefined) { + throw new OrchestrationError('not_found', 'Folder not found') + } + return folderId + }) +} + +export const createTableImportUseCase = defineAuthorizedTableUseCase({ + operation: tableOperations.createImport, + resolveContext: ({ input }: { input: CreateTableImportInput }) => + resolveCreateTableImportContext(input), + async execute({ principal, input, context, request }): Promise { + if (principal.kind === 'delegated') { + throw new OrchestrationError( + 'forbidden', + input.body.source.type === 'upload' + ? 'Delegated principals cannot initiate table import uploads' + : 'Delegated principals cannot initiate workspace-file table imports' + ) + } + const attribution = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }) + const folderId = await resolveImportFolderId(context.workspaceId, input.body) + const workspaceFile = + input.body.source.type === 'workspace_file' + ? ( + await readWorkspaceFileContentRecord.execute({ + principal, + input: { + fileId: input.body.source.fileId, + assertedWorkspaceId: context.workspaceId, + }, + }) + ).file + : undefined + if (input.body.source.type === 'upload' && !request) { + throw new Error('Table import upload creation requires a request context') + } + const created = await createAuthorizedTableImportResource({ + body: input.body, + userId: attribution.attributedUserId, + principal, + localOrigin: request ? requestOrigin(request) : undefined, + resolvedFolderId: folderId, + workspaceFile, + }) + logger.info('Created table import', { + importId: created.record.id, + workspaceId: context.workspaceId, + sourceType: input.body.source.type, + targetType: input.body.target.type, + principalKind: principal.kind, + }) + return { import: toV2CreateTableImport(created) } + }, +}) + +export const readTableImportUseCase = defineAuthorizedTableUseCase({ + operation: tableOperations.readImport, + resolveContext: ({ input }: { input: TableImportResourceInput }) => + resolveTableImportContext(input), + async execute({ context }): Promise { + return { import: toV2TableImport(context.record) } + }, +}) + +export const createTableImportPartsUseCase = defineAuthorizedTableUseCase({ + operation: tableOperations.createImportParts, + resolveContext: ({ + principal, + input, + }: { + principal: Principal + input: CreateTableImportPartsInput + }) => resolveTableImportUploadContext(principal, input), + async execute({ input, context, request }): Promise { + if (!request) throw new Error('Table import part creation requires a request context') + return { + parts: await createUploadPartUrls({ + session: context.upload, + partNumbers: input.partNumbers, + localOrigin: requestOrigin(request), + }), + } + }, +}) + +export const completeTableImportUseCase = defineAuthorizedTableUseCase({ + operation: tableOperations.completeImport, + resolveContext: ({ principal, input }: { principal: Principal; input: TableImportUploadInput }) => + resolveTableImportUploadContext(principal, input), + async execute({ principal, context }): Promise { + const existing = await findTableImportResource({ + importId: context.upload.id, + assertedWorkspaceId: context.workspaceId, + }) + if (existing) return { import: toV2TableImport(existing) } + + const completed = await completeUploadSession({ + session: context.upload, + finalize: async (claimed) => { + assertUploadSessionAuthBinding(claimed, principal) + await authorizeWorkspaceOperation(principal, tableOperations.completeImport, context, { + delegation: tableDelegationPolicy, + }) + return { value: null } + }, + }) + const started = await startUploadedTableImport(completed.session) + logger.info('Completed table import upload', { + importId: started.id, + workspaceId: context.workspaceId, + tableId: started.tableId, + principalKind: principal.kind, + }) + return { import: toV2TableImport(started) } + }, +}) + +export const cancelTableImportUseCase = defineAuthorizedTableUseCase({ + operation: tableOperations.cancelImport, + async resolveContext({ + principal, + input, + }: { + principal: Principal + input: CancelTableImportInput + }) { + return input.uploadToken + ? resolveTableImportUploadContext(principal, { + ...input, + uploadToken: input.uploadToken, + }) + : resolveTableImportContext(input) + }, + async execute({ principal, context }): Promise { + const record = + 'upload' in context + ? await abortAuthorizedTableImportUpload(context.upload, principal) + : await cancelTableImportResource(context.record) + logger.info('Canceled table import', { + importId: record.id, + workspaceId: context.workspaceId, + tableId: record.tableId, + principalKind: principal.kind, + }) + return { import: toV2TableImport(record) } + }, +}) diff --git a/apps/sim/lib/table/application/operations.test.ts b/apps/sim/lib/table/application/operations.test.ts new file mode 100644 index 00000000000..e792a611fb3 --- /dev/null +++ b/apps/sim/lib/table/application/operations.test.ts @@ -0,0 +1,55 @@ +/** + * @vitest-environment node + */ + +import { permissionSatisfies } from '@sim/platform-authz/workspace' +import { describe, expect, it } from 'vitest' +import { tableOperations } from '@/lib/table/application/operations' + +describe('table operation registry', () => { + it('uses unique stable operation IDs with non-empty principal policies', () => { + const operations = Object.values(tableOperations) + const ids = operations.map((operation) => operation.id) + + expect(new Set(ids).size).toBe(ids.length) + for (const operation of operations) { + expect( + operation.principalKinds.length, + `${operation.id} has no allowed principals` + ).toBeGreaterThan(0) + expect( + new Set(operation.principalKinds).size, + `${operation.id} repeats a principal kind` + ).toBe(operation.principalKinds.length) + } + }) + + it('keeps workspace-key operations at or below the fixed write ceiling', () => { + for (const operation of Object.values(tableOperations)) { + expect( + operation.principalKinds.includes('workspace_api_key'), + `${operation.id} has inconsistent workspace API-key declarations` + ).toBe(operation.workspaceApiKey === 'allow') + + if (operation.workspaceApiKey === 'allow') { + expect( + permissionSatisfies('write', operation.minimumRole), + `${operation.id} exceeds the workspace API-key write ceiling` + ).toBe(true) + } + } + }) + + it('keeps reads and mutations on their declared semantic roles', () => { + expect(tableOperations.read.minimumRole).toBe('read') + expect(tableOperations.queryRows.minimumRole).toBe('read') + expect(tableOperations.readView.minimumRole).toBe('read') + expect(tableOperations.startRun.minimumRole).toBe('write') + expect(tableOperations.cancelRuns.minimumRole).toBe('write') + expect(tableOperations.replaceRows.minimumRole).toBe('write') + expect(tableOperations.completeImport.minimumRole).toBe('write') + + expect(tableOperations.createExport.minimumRole).toBe('read') + expect(tableOperations.cancelExport.minimumRole).toBe('read') + }) +}) diff --git a/apps/sim/lib/table/application/operations.ts b/apps/sim/lib/table/application/operations.ts new file mode 100644 index 00000000000..9a8b30915da --- /dev/null +++ b/apps/sim/lib/table/application/operations.ts @@ -0,0 +1,74 @@ +import { defineWorkspaceOperation } from '@/lib/core/application' + +const ALL_PRINCIPAL_KINDS = [ + 'session', + 'personal_api_key', + 'workspace_api_key', + 'delegated', +] as const + +function readOperation(id: Id) { + return defineWorkspaceOperation({ + id, + minimumRole: 'read', + workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, + }) +} + +function writeOperation(id: Id) { + return defineWorkspaceOperation({ + id, + minimumRole: 'write', + workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, + }) +} + +export const tableOperations = { + list: readOperation('tables.list'), + read: readOperation('tables.read'), + create: writeOperation('tables.create'), + update: writeOperation('tables.update'), + delete: writeOperation('tables.delete'), + listFolders: readOperation('tables.folders.list'), + createFolder: writeOperation('tables.folders.create'), + updateFolder: writeOperation('tables.folders.update'), + deleteFolder: writeOperation('tables.folders.delete'), + addColumn: writeOperation('tables.columns.add'), + updateColumn: writeOperation('tables.columns.update'), + deleteColumn: writeOperation('tables.columns.delete'), + listRows: readOperation('tables.rows.list'), + queryRows: readOperation('tables.rows.query'), + findRows: readOperation('tables.rows.find'), + readRow: readOperation('tables.rows.read'), + createRows: writeOperation('tables.rows.create'), + replaceRows: writeOperation('tables.rows.replace'), + updateRow: writeOperation('tables.rows.update'), + updateRows: writeOperation('tables.rows.update_many'), + deleteRow: writeOperation('tables.rows.delete'), + deleteRows: writeOperation('tables.rows.delete_many'), + upsertRow: writeOperation('tables.rows.upsert'), + listViews: readOperation('tables.views.list'), + readView: readOperation('tables.views.read'), + createView: writeOperation('tables.views.create'), + updateView: writeOperation('tables.views.update'), + deleteView: writeOperation('tables.views.delete'), + listGroups: readOperation('tables.groups.list'), + createGroup: writeOperation('tables.groups.create'), + updateGroup: writeOperation('tables.groups.update'), + deleteGroup: writeOperation('tables.groups.delete'), + startRun: writeOperation('tables.runs.start'), + cancelRuns: writeOperation('tables.runs.cancel'), + createImport: writeOperation('tables.imports.create'), + readImport: readOperation('tables.imports.read'), + createImportParts: writeOperation('tables.imports.create_parts'), + completeImport: writeOperation('tables.imports.complete'), + cancelImport: writeOperation('tables.imports.cancel'), + createExport: readOperation('tables.exports.create'), + readExport: readOperation('tables.exports.read'), + cancelExport: readOperation('tables.exports.cancel'), + downloadExport: readOperation('tables.exports.download'), +} as const + +export type TableOperation = (typeof tableOperations)[keyof typeof tableOperations] diff --git a/apps/sim/lib/table/application/rows.test.ts b/apps/sim/lib/table/application/rows.test.ts new file mode 100644 index 00000000000..55db224860d --- /dev/null +++ b/apps/sim/lib/table/application/rows.test.ts @@ -0,0 +1,313 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { TableDefinition } from '@/lib/table/types' + +const { + mockReplaceRowsPrimitive, + mockDeleteRowsByIds, + mockQueryRows, + mockRecordAudit, + mockResolveContext, + mockResolvePermission, + mockSignalRowsChanged, + mockUpsertRow, +} = vi.hoisted(() => ({ + mockReplaceRowsPrimitive: vi.fn(), + mockDeleteRowsByIds: vi.fn(), + mockQueryRows: vi.fn(), + mockRecordAudit: vi.fn(), + mockResolveContext: vi.fn(), + mockResolvePermission: vi.fn(), + mockSignalRowsChanged: vi.fn(), + mockUpsertRow: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { TABLE_UPDATED: 'table.updated' }, + AuditResourceType: { TABLE: 'table' }, + recordAudit: mockRecordAudit, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mockResolvePermission, +})) + +vi.mock('@/lib/table', () => ({ + TABLE_LIMITS: { + MAX_BATCH_INSERT_SIZE: 1000, + MAX_BULK_OPERATION_SIZE: 1000, + MAX_QUERY_LIMIT: 1000, + }, + batchInsertRows: vi.fn(), + deleteRow: vi.fn(), + deleteRowsByFilter: vi.fn(), + deleteRowsByIds: mockDeleteRowsByIds, + findRowMatches: vi.fn(), + getRowById: vi.fn(), + insertRow: vi.fn(), + queryRows: mockQueryRows, + replaceTableRows: mockReplaceRowsPrimitive, + rowDataNameToId: (data: Record, idByName: Map) => + Object.fromEntries( + Object.entries(data).flatMap(([name, value]) => { + const id = idByName.get(name) + return id ? [[id, value]] : [] + }) + ), + sortSpecNamesToIds: vi.fn(), + updateRow: vi.fn(), + updateRowsByFilter: vi.fn(), + upsertRow: mockUpsertRow, + validateBatchRows: vi.fn(), + validateRowData: vi.fn(), +})) + +vi.mock('@/lib/table/application/context', () => ({ + resolveActiveTableContext: mockResolveContext, +})) + +vi.mock('@/lib/table/events', () => ({ + signalTableRowsChanged: mockSignalRowsChanged, +})) + +import { + deleteTableRows, + queryTableRows, + replaceTableRows, + TableRowsValidationError, + tablePredicateNamesToFilter, + upsertTableRow, +} from '@/lib/table/application/rows' + +const TABLE: TableDefinition = { + id: 'table-1', + name: 'People', + description: null, + schema: { columns: [{ id: 'column-name', name: 'name', type: 'string' }] }, + metadata: null, + rowCount: 2, + maxRows: 10_000, + workspaceId: 'workspace-canonical', + createdBy: 'owner-1', + archivedAt: null, + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-01'), +} + +const PRINCIPAL = { kind: 'session' as const, userId: 'user-1', sessionId: 'session-1' } + +describe('table predicate translation', () => { + it('maps invalid run filters to the shared row validation error', () => { + expect(() => + tablePredicateNamesToFilter({ all: [{ field: 'missing', op: 'eq', value: 'ready' }] }, TABLE) + ).toThrowError( + expect.objectContaining({ + name: 'TableRowsValidationError', + details: { code: 'INVALID_FILTER' }, + }) + ) + }) +}) + +describe('replaceTableRows application use case', () => { + beforeEach(() => { + vi.clearAllMocks() + mockResolvePermission.mockResolvedValue('write') + mockResolveContext.mockResolvedValue({ + tableId: TABLE.id, + table: TABLE, + workspaceId: TABLE.workspaceId, + workspaceOrganizationId: 'organization-1', + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', + }) + mockReplaceRowsPrimitive.mockResolvedValue({ deletedCount: 2, insertedCount: 1 }) + }) + + it('uses canonical scope, stable column ids, and principal attribution', async () => { + const result = await replaceTableRows.execute({ + principal: PRINCIPAL, + input: { + tableId: TABLE.id, + assertedWorkspaceId: TABLE.workspaceId, + requestId: 'request-1', + rows: [{ name: 'Ada', unknown: 'dropped' }], + }, + }) + + expect(mockReplaceRowsPrimitive).toHaveBeenCalledWith( + { + tableId: TABLE.id, + workspaceId: TABLE.workspaceId, + rows: [{ 'column-name': 'Ada' }], + userId: PRINCIPAL.userId, + secretProvenance: undefined, + }, + TABLE, + 'request-1' + ) + expect(result).toMatchObject({ deletedCount: 2, insertedCount: 1 }) + expect(mockSignalRowsChanged).toHaveBeenCalledWith(TABLE.id) + }) + + it('rejects more than 10,000 rows before opening the atomic primitive', async () => { + await expect( + replaceTableRows.execute({ + principal: PRINCIPAL, + input: { + tableId: TABLE.id, + rows: Array.from({ length: 10_001 }, () => ({})), + }, + }) + ).rejects.toBeInstanceOf(TableRowsValidationError) + expect(mockReplaceRowsPrimitive).not.toHaveBeenCalled() + }) + + it('fails fast on misaligned provenance', async () => { + await expect( + replaceTableRows.execute({ + principal: PRINCIPAL, + input: { + tableId: TABLE.id, + rows: [{ name: 'Ada' }], + secretProvenance: [], + }, + }) + ).rejects.toThrow('Secret provenance must align one-to-one with rows') + expect(mockReplaceRowsPrimitive).not.toHaveBeenCalled() + }) + + it('does not signal for an authoritative no-op result', async () => { + mockReplaceRowsPrimitive.mockResolvedValue({ deletedCount: 0, insertedCount: 0 }) + + await replaceTableRows.execute({ + principal: PRINCIPAL, + input: { tableId: TABLE.id, rows: [] }, + }) + + expect(mockSignalRowsChanged).not.toHaveBeenCalled() + }) + + it('propagates primitive infrastructure failures', async () => { + mockReplaceRowsPrimitive.mockRejectedValue(new Error('database unavailable')) + + await expect( + replaceTableRows.execute({ + principal: PRINCIPAL, + input: { tableId: TABLE.id, rows: [] }, + }) + ).rejects.toThrow('database unavailable') + expect(mockSignalRowsChanged).not.toHaveBeenCalled() + }) +}) + +describe('row query and upsert application semantics', () => { + beforeEach(() => { + vi.clearAllMocks() + mockResolvePermission.mockResolvedValue('write') + mockResolveContext.mockResolvedValue({ + tableId: TABLE.id, + table: TABLE, + workspaceId: TABLE.workspaceId, + workspaceOrganizationId: 'organization-1', + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', + }) + }) + + it('rejects a malformed POST query cursor before querying storage', async () => { + await expect( + queryTableRows.execute({ + principal: PRINCIPAL, + input: { tableId: TABLE.id, cursor: 'malformed', limit: 100 }, + }) + ).rejects.toMatchObject({ details: { code: 'INVALID_CURSOR' } }) + expect(mockQueryRows).not.toHaveBeenCalled() + }) + + it('audits only the authoritative deleted count and suppresses no-op audit', async () => { + mockDeleteRowsByIds.mockResolvedValueOnce({ + deletedCount: 1, + deletedRowIds: ['row-1'], + requestedCount: 2, + missingRowIds: ['missing-row'], + }) + + await deleteTableRows.execute({ + principal: PRINCIPAL, + input: { + kind: 'ids', + tableId: TABLE.id, + rowIds: ['row-1', 'missing-row'], + }, + }) + + expect(mockRecordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: TABLE.workspaceId, + resourceId: TABLE.id, + metadata: expect.objectContaining({ + operation: 'tables.rows.delete_many', + rowsDeleted: 1, + }), + }) + ) + + mockRecordAudit.mockClear() + mockDeleteRowsByIds.mockResolvedValueOnce({ + deletedCount: 0, + deletedRowIds: [], + requestedCount: 1, + missingRowIds: ['missing-row'], + }) + await deleteTableRows.execute({ + principal: PRINCIPAL, + input: { kind: 'ids', tableId: TABLE.id, rowIds: ['missing-row'] }, + }) + + expect(mockRecordAudit).not.toHaveBeenCalled() + }) + + it('resolves a public upsert conflict-target name to its stable column id', async () => { + mockUpsertRow.mockResolvedValue({ + operation: 'update', + row: { + id: 'row-1', + data: { 'column-name': 'Ada' }, + createdAt: new Date(), + updatedAt: new Date(), + }, + }) + + await upsertTableRow.execute({ + principal: PRINCIPAL, + input: { + tableId: TABLE.id, + requestId: 'request-1', + data: { name: 'Ada' }, + conflictTarget: 'name', + }, + }) + + expect(mockUpsertRow).toHaveBeenCalledWith( + expect.objectContaining({ + tableId: TABLE.id, + workspaceId: TABLE.workspaceId, + data: { 'column-name': 'Ada' }, + conflictTarget: 'column-name', + userId: PRINCIPAL.userId, + }), + TABLE, + 'request-1' + ) + }) +}) diff --git a/apps/sim/lib/table/application/rows.ts b/apps/sim/lib/table/application/rows.ts new file mode 100644 index 00000000000..999b7bd133f --- /dev/null +++ b/apps/sim/lib/table/application/rows.ts @@ -0,0 +1,621 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { resolvePrincipalAttribution } from '@sim/auth/principal' +import { getRequestContext } from '@sim/logger' +import { generateId } from '@sim/utils/id' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { + BulkDeleteByIdsResult, + BulkOperationResult, + Filter, + ReplaceRowsResult, + RowData, + Sort, + SortSpec, + TableDefinition, + TablePredicate, + TableRow, + TableRowSecretProvenanceWrite, +} from '@/lib/table' +import { + batchInsertRows, + deleteRow, + deleteRowsByFilter, + deleteRowsByIds, + findRowMatches, + getRowById, + insertRow, + queryRows, + replaceTableRows as replaceTableRowsPrimitive, + rowDataNameToId, + sortSpecNamesToIds, + TABLE_LIMITS, + updateRow, + updateRowsByFilter, + upsertRow, + validateBatchRows, + validateRowData, +} from '@/lib/table' +import { defineAuthorizedTableUseCase } from '@/lib/table/application/authorized-table-use-case' +import { resolveActiveTableContext } from '@/lib/table/application/context' +import { tableOperations } from '@/lib/table/application/operations' +import { buildIdByName } from '@/lib/table/column-keys' +import { TableQueryValidationError } from '@/lib/table/errors' +import { signalTableRowsChanged } from '@/lib/table/events' +import { predicateToFilter } from '@/lib/table/query-builder/converters' +import { + validatePredicate, + validatePredicateShape, + validateSortSpec, + validateStoragePredicate, +} from '@/lib/table/query-builder/validate' +import { assertCursorSortBinding, decodeCursor } from '@/lib/table/rows/cursor' +import type { FindRowMatch } from '@/lib/table/rows/service' +import { predicateToStorage } from '@/lib/table/select-values' + +export class TableRowsValidationError extends OrchestrationError { + constructor( + message: string, + readonly details?: unknown + ) { + super('validation', message) + this.name = 'TableRowsValidationError' + } +} + +interface TableScopedInput { + tableId: string + assertedWorkspaceId?: string + requestId?: string +} + +interface TableResult { + table: TableDefinition +} + +function requestId(input: TableScopedInput): string { + return input.requestId ?? getRequestContext()?.requestId ?? generateId().slice(0, 8) +} + +function actorUserId( + principal: Parameters[0], + billedAccountUserId: string +): string { + return resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: billedAccountUserId, + }).attributedUserId +} + +function namedDataToStorage(data: RowData, table: TableDefinition): RowData { + return rowDataNameToId(data, buildIdByName(table.schema)) +} + +function requireIntegerInRange(value: number, min: number, max: number, label: string): void { + if (!Number.isSafeInteger(value) || value < min || value > max) { + throw new TableRowsValidationError(`${label} must be between ${min} and ${max}`) + } +} + +export function tablePredicateNamesToFilter( + predicate: TablePredicate, + table: TableDefinition +): Filter { + try { + validatePredicateShape(predicate) + const translated = predicateToStorage(predicate, table.schema) + validateStoragePredicate(translated, table.schema.columns) + return predicateToFilter(translated) + } catch (error) { + rethrowQueryValidation(error) + } +} + +async function throwValidationResponse( + validation: + | { valid: true } + | { valid: false; response: { clone(): { json(): Promise } } } +): Promise { + if (validation.valid) return + const body = (await validation.response.clone().json()) as { + error?: string + details?: unknown + } + throw new TableRowsValidationError(body.error ?? 'Invalid row data', body.details) +} + +function rethrowQueryValidation(error: unknown): never { + if (error instanceof TableQueryValidationError) { + throw new TableRowsValidationError(error.message, error.code ? { code: error.code } : undefined) + } + throw error +} + +export interface ListTableRowsInput extends TableScopedInput { + limit: number + offset: number +} + +export interface ListTableRowsResult extends TableResult { + rows: TableRow[] + nextOffset: number | null +} + +export const listTableRows = defineAuthorizedTableUseCase({ + operation: tableOperations.listRows, + resolveContext: ({ input }: { input: ListTableRowsInput }) => resolveActiveTableContext(input), + async execute({ input, context }): Promise { + requireIntegerInRange(input.limit, 1, TABLE_LIMITS.MAX_QUERY_LIMIT, 'Limit') + if (!Number.isSafeInteger(input.offset) || input.offset < 0) { + throw new TableRowsValidationError('Offset must be 0 or greater') + } + try { + const result = await queryRows( + context.table, + { + limit: input.limit, + offset: input.offset, + includeTotal: true, + withExecutions: false, + }, + requestId(input) + ) + const total = result.totalCount ?? 0 + return { + table: context.table, + rows: result.rows, + nextOffset: input.offset + result.rowCount < total ? input.offset + input.limit : null, + } + } catch (error) { + rethrowQueryValidation(error) + } + }, +}) + +export interface QueryTableRowsInput extends TableScopedInput { + predicate?: TablePredicate + sort?: SortSpec + limit?: number + cursor?: string + includeTotal?: boolean +} + +export interface QueryTableRowsResult extends TableResult { + rows: TableRow[] + rowCount: number + totalCount: number | null + nextCursor: string | null +} + +export const queryTableRows = defineAuthorizedTableUseCase({ + operation: tableOperations.queryRows, + resolveContext: ({ input }: { input: QueryTableRowsInput }) => resolveActiveTableContext(input), + async execute({ input, context }): Promise { + try { + if (input.limit !== undefined) { + requireIntegerInRange(input.limit, 1, TABLE_LIMITS.MAX_QUERY_LIMIT, 'Limit') + } + let predicate = input.predicate + if (predicate) { + validatePredicate(predicate, context.table.schema.columns) + predicate = predicateToStorage(predicate, context.table.schema) + } + let sortSpec = input.sort + if (sortSpec?.length) { + validateSortSpec(sortSpec, context.table.schema.columns) + sortSpec = sortSpecNamesToIds(sortSpec, buildIdByName(context.table.schema)) + } + const sort: Sort | undefined = sortSpec?.length + ? Object.fromEntries(sortSpec.map((item) => [item.field, item.direction])) + : undefined + const cursor = input.cursor ? decodeCursor(input.cursor) : undefined + if (cursor) assertCursorSortBinding(cursor, sort) + const result = await queryRows( + context.table, + { + predicate, + sort, + limit: input.limit, + after: cursor?.after, + offset: cursor?.offset, + includeTotal: input.includeTotal ?? false, + withExecutions: false, + }, + requestId(input) + ) + return { table: context.table, ...result } + } catch (error) { + rethrowQueryValidation(error) + } + }, +}) + +export interface FindTableRowsInput extends TableScopedInput { + q: string + predicate?: TablePredicate + sort?: SortSpec +} + +export interface FindTableRowsResult extends TableResult { + matches: FindRowMatch[] + truncated: boolean +} + +export const findTableRows = defineAuthorizedTableUseCase({ + operation: tableOperations.findRows, + resolveContext: ({ input }: { input: FindTableRowsInput }) => resolveActiveTableContext(input), + async execute({ input, context }): Promise { + try { + if (input.q.length === 0) { + throw new TableRowsValidationError('q must be a non-empty search string') + } + const filter = input.predicate + ? tablePredicateNamesToFilter(input.predicate, context.table) + : undefined + let sort: Sort | undefined + if (input.sort?.length) { + validateSortSpec(input.sort, context.table.schema.columns) + const translated = sortSpecNamesToIds(input.sort, buildIdByName(context.table.schema)) + sort = Object.fromEntries(translated.map((item) => [item.field, item.direction])) + } + const result = await findRowMatches( + context.table, + { q: input.q, filter, sort }, + requestId(input) + ) + return { table: context.table, ...result } + } catch (error) { + rethrowQueryValidation(error) + } + }, +}) + +export interface ReadTableRowInput extends TableScopedInput { + rowId: string +} + +export interface ReadTableRowResult extends TableResult { + row: TableRow +} + +export const readTableRow = defineAuthorizedTableUseCase({ + operation: tableOperations.readRow, + resolveContext: ({ input }: { input: ReadTableRowInput }) => resolveActiveTableContext(input), + async execute({ input, context }): Promise { + const row = await getRowById(context.tableId, input.rowId, context.workspaceId) + if (!row) throw new OrchestrationError('not_found', 'Row not found') + return { table: context.table, row } + }, +}) + +interface CreateSingleTableRowInput extends TableScopedInput { + kind: 'single' + data: RowData + position?: number + afterRowId?: string + beforeRowId?: string + secretProvenance?: TableRowSecretProvenanceWrite +} + +interface CreateBatchTableRowsInput extends TableScopedInput { + kind: 'batch' + rows: RowData[] + orderKeys?: string[] + secretProvenance?: Array +} + +export type CreateTableRowsInput = CreateSingleTableRowInput | CreateBatchTableRowsInput + +export type CreateTableRowsResult = + | (TableResult & { kind: 'single'; row: TableRow }) + | (TableResult & { kind: 'batch'; rows: TableRow[] }) + +export const createTableRows = defineAuthorizedTableUseCase({ + operation: tableOperations.createRows, + resolveContext: ({ input }: { input: CreateTableRowsInput }) => resolveActiveTableContext(input), + async execute({ principal, input, context }): Promise { + const userId = actorUserId(principal, context.billedAccountUserId) + if (input.kind === 'single') { + if (input.afterRowId && input.beforeRowId) { + throw new TableRowsValidationError('afterRowId and beforeRowId are mutually exclusive') + } + if ( + input.position !== undefined && + (!Number.isSafeInteger(input.position) || input.position < 0) + ) { + throw new TableRowsValidationError('Position must be 0 or greater') + } + const data = namedDataToStorage(input.data, context.table) + await throwValidationResponse( + await validateRowData({ + rowData: data, + schema: context.table.schema, + tableId: context.tableId, + }) + ) + const row = await insertRow( + { + tableId: context.tableId, + workspaceId: context.workspaceId, + data, + userId, + position: input.position, + afterRowId: input.afterRowId, + beforeRowId: input.beforeRowId, + secretProvenance: input.secretProvenance, + }, + context.table, + requestId(input) + ) + return { kind: 'single', table: context.table, row } + } + if (input.rows.length < 1 || input.rows.length > TABLE_LIMITS.MAX_BATCH_INSERT_SIZE) { + throw new TableRowsValidationError( + `Batch row count must be between 1 and ${TABLE_LIMITS.MAX_BATCH_INSERT_SIZE}` + ) + } + if (input.secretProvenance && input.secretProvenance.length !== input.rows.length) { + throw new TableRowsValidationError('Secret provenance must align one-to-one with rows') + } + if (input.orderKeys && input.orderKeys.length !== input.rows.length) { + throw new TableRowsValidationError('orderKeys must align one-to-one with rows') + } + const rows = input.rows.map((row) => namedDataToStorage(row, context.table)) + await throwValidationResponse( + await validateBatchRows({ + rows, + schema: context.table.schema, + tableId: context.tableId, + }) + ) + const created = await batchInsertRows( + { + tableId: context.tableId, + workspaceId: context.workspaceId, + rows, + userId, + orderKeys: input.orderKeys, + secretProvenance: input.secretProvenance, + }, + context.table, + requestId(input) + ) + return { kind: 'batch', table: context.table, rows: created } + }, + afterSuccess: ({ context, result }) => { + const affected = result.kind === 'single' ? 1 : result.rows.length + if (affected > 0) signalTableRowsChanged(context.tableId) + }, +}) + +const MAX_REPLACE_TABLE_ROWS = 10_000 + +export interface ReplaceTableRowsInput extends TableScopedInput { + rows: RowData[] + secretProvenance?: Array +} + +export interface ReplaceTableRowsResult extends TableResult, ReplaceRowsResult {} + +export const replaceTableRows = defineAuthorizedTableUseCase({ + operation: tableOperations.replaceRows, + resolveContext: ({ input }: { input: ReplaceTableRowsInput }) => resolveActiveTableContext(input), + async execute({ principal, input, context }): Promise { + if (input.rows.length > MAX_REPLACE_TABLE_ROWS) { + throw new TableRowsValidationError( + `Table row replacement limit exceeded: got ${input.rows.length}, max is ${MAX_REPLACE_TABLE_ROWS}` + ) + } + if (input.secretProvenance && input.secretProvenance.length !== input.rows.length) { + throw new TableRowsValidationError('Secret provenance must align one-to-one with rows') + } + + const result = await replaceTableRowsPrimitive( + { + tableId: context.tableId, + workspaceId: context.workspaceId, + rows: input.rows.map((row) => namedDataToStorage(row, context.table)), + userId: actorUserId(principal, context.billedAccountUserId), + secretProvenance: input.secretProvenance, + }, + context.table, + requestId(input) + ) + return { table: context.table, ...result } + }, + afterSuccess: ({ context, result }) => { + if (result.deletedCount > 0 || result.insertedCount > 0) { + signalTableRowsChanged(context.tableId) + } + }, +}) + +export interface UpdateTableRowInput extends TableScopedInput { + rowId: string + data: RowData + secretProvenance?: TableRowSecretProvenanceWrite +} + +export interface UpdateTableRowResult extends TableResult { + row: TableRow + changed: boolean +} + +export const updateTableRow = defineAuthorizedTableUseCase({ + operation: tableOperations.updateRow, + resolveContext: ({ input }: { input: UpdateTableRowInput }) => resolveActiveTableContext(input), + async execute({ principal, input, context }): Promise { + const data = namedDataToStorage(input.data, context.table) + const row = await updateRow( + { + tableId: context.tableId, + workspaceId: context.workspaceId, + rowId: input.rowId, + data, + actorUserId: actorUserId(principal, context.billedAccountUserId), + secretProvenance: input.secretProvenance, + }, + context.table, + requestId(input) + ) + if (!row) throw new Error('Unconditional table row update was rejected') + return { table: context.table, row, changed: Object.keys(data).length > 0 } + }, + afterSuccess: ({ context, result }) => { + if (result.changed) signalTableRowsChanged(context.tableId) + }, +}) + +export interface UpdateTableRowsInput extends TableScopedInput { + filter: TablePredicate + data: RowData + limit?: number + secretProvenance?: TableRowSecretProvenanceWrite +} + +export interface UpdateTableRowsResult extends TableResult, BulkOperationResult {} + +export const updateTableRows = defineAuthorizedTableUseCase({ + operation: tableOperations.updateRows, + resolveContext: ({ input }: { input: UpdateTableRowsInput }) => resolveActiveTableContext(input), + async execute({ principal, input, context }): Promise { + try { + if (input.limit !== undefined) { + requireIntegerInRange(input.limit, 1, TABLE_LIMITS.MAX_BULK_OPERATION_SIZE, 'Limit') + } + const result = await updateRowsByFilter( + context.table, + { + filter: tablePredicateNamesToFilter(input.filter, context.table), + data: namedDataToStorage(input.data, context.table), + limit: input.limit, + actorUserId: actorUserId(principal, context.billedAccountUserId), + secretProvenance: input.secretProvenance, + }, + requestId(input) + ) + return { table: context.table, ...result } + } catch (error) { + rethrowQueryValidation(error) + } + }, + afterSuccess: ({ context, result }) => { + if (result.affectedCount > 0) signalTableRowsChanged(context.tableId) + }, +}) + +export interface DeleteTableRowInput extends TableScopedInput { + rowId: string +} + +export interface DeleteTableRowResult extends TableResult { + deletedRowId: string +} + +export const deleteTableRow = defineAuthorizedTableUseCase({ + operation: tableOperations.deleteRow, + resolveContext: ({ input }: { input: DeleteTableRowInput }) => resolveActiveTableContext(input), + async execute({ input, context }): Promise { + await deleteRow(context.table, input.rowId, requestId(input)) + return { table: context.table, deletedRowId: input.rowId } + }, + afterSuccess: ({ context }) => signalTableRowsChanged(context.tableId), +}) + +export type DeleteTableRowsInput = TableScopedInput & + ({ kind: 'ids'; rowIds: string[] } | { kind: 'filter'; filter: TablePredicate; limit?: number }) + +export type DeleteTableRowsResult = TableResult & + (({ kind: 'ids' } & BulkDeleteByIdsResult) | ({ kind: 'filter' } & BulkOperationResult)) + +export const deleteTableRows = defineAuthorizedTableUseCase({ + operation: tableOperations.deleteRows, + resolveContext: ({ input }: { input: DeleteTableRowsInput }) => resolveActiveTableContext(input), + async execute({ input, context }): Promise { + try { + if (input.kind === 'ids') { + if (input.rowIds.length < 1 || input.rowIds.length > TABLE_LIMITS.MAX_BULK_OPERATION_SIZE) { + throw new TableRowsValidationError( + `Row ID count must be between 1 and ${TABLE_LIMITS.MAX_BULK_OPERATION_SIZE}` + ) + } + const result = await deleteRowsByIds( + context.table, + { + tableId: context.tableId, + workspaceId: context.workspaceId, + rowIds: input.rowIds, + }, + requestId(input) + ) + return { kind: 'ids', table: context.table, ...result } + } + if (input.limit !== undefined) { + requireIntegerInRange(input.limit, 1, TABLE_LIMITS.MAX_BULK_OPERATION_SIZE, 'Limit') + } + const result = await deleteRowsByFilter( + context.table, + { + filter: tablePredicateNamesToFilter(input.filter, context.table), + limit: input.limit, + }, + requestId(input) + ) + return { kind: 'filter', table: context.table, ...result } + } catch (error) { + rethrowQueryValidation(error) + } + }, + projectAudit: ({ result }) => { + const affected = result.kind === 'ids' ? result.deletedCount : result.affectedCount + if (affected === 0) return [] + return { + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: result.table.id, + resourceName: result.table.name, + description: `Deleted ${affected} row(s) from table "${result.table.name}"`, + metadata: { + op: 'bulk_delete', + rowsDeleted: affected, + }, + } + }, + afterSuccess: ({ context, result }) => { + const affected = result.kind === 'ids' ? result.deletedCount : result.affectedCount + if (affected > 0) signalTableRowsChanged(context.tableId) + }, +}) + +export interface UpsertTableRowInput extends TableScopedInput { + data: RowData + conflictTarget?: string + secretProvenance?: TableRowSecretProvenanceWrite +} + +export interface UpsertTableRowResult extends TableResult { + row: TableRow + operation: 'insert' | 'update' +} + +export const upsertTableRow = defineAuthorizedTableUseCase({ + operation: tableOperations.upsertRow, + resolveContext: ({ input }: { input: UpsertTableRowInput }) => resolveActiveTableContext(input), + async execute({ principal, input, context }): Promise { + const conflictTarget = input.conflictTarget + ? (buildIdByName(context.table.schema).get(input.conflictTarget) ?? input.conflictTarget) + : undefined + const result = await upsertRow( + { + tableId: context.tableId, + workspaceId: context.workspaceId, + data: namedDataToStorage(input.data, context.table), + conflictTarget, + userId: actorUserId(principal, context.billedAccountUserId), + secretProvenance: input.secretProvenance, + }, + context.table, + requestId(input) + ) + return { table: context.table, row: result.row, operation: result.operation } + }, + afterSuccess: ({ context }) => signalTableRowsChanged(context.tableId), +}) diff --git a/apps/sim/lib/table/application/runs.test.ts b/apps/sim/lib/table/application/runs.test.ts new file mode 100644 index 00000000000..909afff9c88 --- /dev/null +++ b/apps/sim/lib/table/application/runs.test.ts @@ -0,0 +1,272 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { TableDefinition } from '@/lib/table/types' + +const { + mockCancelRuns, + mockGetRowById, + mockResolveContext, + mockResolvePermission, + mockRequireTableRowIds, + mockRunWorkflowColumn, + mockSignalRowsChanged, + mockTranslatePredicate, +} = vi.hoisted(() => ({ + mockCancelRuns: vi.fn(), + mockGetRowById: vi.fn(), + mockResolveContext: vi.fn(), + mockResolvePermission: vi.fn(), + mockRequireTableRowIds: vi.fn(), + mockRunWorkflowColumn: vi.fn(), + mockSignalRowsChanged: vi.fn(), + mockTranslatePredicate: vi.fn(), +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mockResolvePermission, +})) + +vi.mock('@/lib/table', () => ({ + DEFAULT_TABLE_PLAN_LIMITS: { enterprise: { maxRowsPerTable: 2 } }, + getRowById: mockGetRowById, + requireTableRowIds: mockRequireTableRowIds, + TABLE_LIMITS: { MAX_COLUMNS_PER_TABLE: 2 }, +})) + +vi.mock('@/lib/table/application/context', () => ({ + resolveActiveTableContext: mockResolveContext, +})) + +vi.mock('@/lib/table/application/rows', () => ({ + tablePredicateNamesToFilter: mockTranslatePredicate, +})) + +vi.mock('@/lib/table/events', () => ({ + signalTableRowsChanged: mockSignalRowsChanged, +})) + +vi.mock('@/lib/table/workflow-columns', () => ({ + cancelWorkflowGroupRuns: mockCancelRuns, + runWorkflowColumn: mockRunWorkflowColumn, +})) + +import { cancelTableRuns, startTableRun } from '@/lib/table/application/runs' + +const TABLE: TableDefinition = { + id: 'table-1', + name: 'People', + description: null, + schema: { + columns: [], + workflowGroups: [ + { + id: 'group-1', + name: 'Enrich', + type: 'enrichment', + enrichmentId: 'enrichment-1', + workflowId: '', + targetColumnIds: [], + sourceColumnIds: [], + }, + ], + }, + metadata: null, + rowCount: 1, + maxRows: 10, + workspaceId: 'workspace-canonical', + createdBy: 'owner-1', + archivedAt: null, + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-01'), +} + +const PRINCIPAL = { kind: 'session' as const, userId: 'user-1', sessionId: 'session-1' } + +describe('table run application use cases', () => { + beforeEach(() => { + vi.clearAllMocks() + mockResolvePermission.mockResolvedValue('write') + mockResolveContext.mockResolvedValue({ + tableId: TABLE.id, + table: TABLE, + workspaceId: TABLE.workspaceId, + workspaceOrganizationId: 'organization-1', + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', + }) + mockGetRowById.mockResolvedValue({ id: 'row-1' }) + mockRunWorkflowColumn.mockResolvedValue({ + dispatchId: 'dispatch-1', + shouldSignalRowsChanged: true, + }) + mockRequireTableRowIds.mockResolvedValue(undefined) + mockCancelRuns.mockResolvedValue(1) + mockTranslatePredicate.mockReturnValue({ all: [] }) + }) + + it('canonically validates row and group before enrichment dispatch', async () => { + const result = await startTableRun.execute({ + principal: PRINCIPAL, + input: { + kind: 'row_enrichment', + tableId: TABLE.id, + assertedWorkspaceId: TABLE.workspaceId, + rowId: 'row-1', + groupId: 'group-1', + requestId: 'request-1', + }, + }) + + expect(mockGetRowById).toHaveBeenCalledWith(TABLE.id, 'row-1', TABLE.workspaceId) + expect(mockRunWorkflowColumn).toHaveBeenCalledWith({ + tableId: TABLE.id, + workspaceId: TABLE.workspaceId, + groupIds: ['group-1'], + rowIds: ['row-1'], + mode: 'all', + requestId: 'request-1', + triggeredByUserId: PRINCIPAL.userId, + }) + expect(result.dispatchId).toBe('dispatch-1') + expect(mockSignalRowsChanged).toHaveBeenCalledWith(TABLE.id) + }) + + it('rejects missing canonical groups and rows without dispatching', async () => { + await expect( + startTableRun.execute({ + principal: PRINCIPAL, + input: { + kind: 'row_enrichment', + tableId: TABLE.id, + rowId: 'row-1', + groupId: 'missing-group', + }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + + mockGetRowById.mockResolvedValueOnce(null) + await expect( + startTableRun.execute({ + principal: PRINCIPAL, + input: { + kind: 'row_enrichment', + tableId: TABLE.id, + rowId: 'missing-row', + groupId: 'group-1', + }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + expect(mockRunWorkflowColumn).not.toHaveBeenCalled() + }) + + it('bounds explicit row selections before dispatch', async () => { + await expect( + startTableRun.execute({ + principal: PRINCIPAL, + input: { + kind: 'selection', + tableId: TABLE.id, + groupIds: ['group-1'], + mode: 'all', + rowIds: ['row-1', 'row-2', 'row-3'], + }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + expect(mockRunWorkflowColumn).not.toHaveBeenCalled() + }) + + it('deduplicates and canonically verifies explicit row selections', async () => { + await startTableRun.execute({ + principal: PRINCIPAL, + input: { + kind: 'selection', + tableId: TABLE.id, + groupIds: ['group-1', 'group-1'], + mode: 'all', + rowIds: ['row-1', 'row-1'], + }, + }) + + expect(mockRequireTableRowIds).toHaveBeenCalledWith(TABLE.id, TABLE.workspaceId, ['row-1']) + expect(mockRunWorkflowColumn).toHaveBeenCalledWith( + expect.objectContaining({ groupIds: ['group-1'], rowIds: ['row-1'] }) + ) + }) + + it('does not signal when the dispatcher reports a no-op', async () => { + mockRunWorkflowColumn.mockResolvedValue({ + dispatchId: null, + shouldSignalRowsChanged: false, + }) + + await startTableRun.execute({ + principal: PRINCIPAL, + input: { + kind: 'selection', + tableId: TABLE.id, + groupIds: ['group-1'], + mode: 'incomplete', + }, + }) + + expect(mockSignalRowsChanged).not.toHaveBeenCalled() + }) + + it('signals a cleared row state when cancellation wins before dispatch', async () => { + mockRunWorkflowColumn.mockResolvedValue({ + dispatchId: null, + shouldSignalRowsChanged: true, + }) + + await startTableRun.execute({ + principal: PRINCIPAL, + input: { + kind: 'selection', + tableId: TABLE.id, + groupIds: ['group-1'], + mode: 'all', + }, + }) + + expect(mockSignalRowsChanged).toHaveBeenCalledWith(TABLE.id) + }) + + it('requires a canonical row for row cancellation', async () => { + mockGetRowById.mockResolvedValue(null) + + await expect( + cancelTableRuns.execute({ + principal: PRINCIPAL, + input: { scope: 'row', tableId: TABLE.id, rowId: 'missing-row' }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + expect(mockCancelRuns).not.toHaveBeenCalled() + }) + + it('signals only authoritative cancellations and propagates infrastructure failures', async () => { + mockCancelRuns.mockResolvedValueOnce(0) + await cancelTableRuns.execute({ + principal: PRINCIPAL, + input: { scope: 'all', tableId: TABLE.id }, + }) + expect(mockSignalRowsChanged).not.toHaveBeenCalled() + + mockCancelRuns.mockRejectedValueOnce(new Error('database unavailable')) + await expect( + cancelTableRuns.execute({ + principal: PRINCIPAL, + input: { scope: 'all', tableId: TABLE.id }, + }) + ).rejects.toThrow('database unavailable') + expect(mockSignalRowsChanged).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/table/application/runs.ts b/apps/sim/lib/table/application/runs.ts new file mode 100644 index 00000000000..aa39b45dd31 --- /dev/null +++ b/apps/sim/lib/table/application/runs.ts @@ -0,0 +1,210 @@ +import { resolvePrincipalAttribution } from '@sim/auth/principal' +import { getRequestContext } from '@sim/logger' +import { generateId } from '@sim/utils/id' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + DEFAULT_TABLE_PLAN_LIMITS, + getRowById, + requireTableRowIds, + TABLE_LIMITS, + type TableDefinition, + type TablePredicate, +} from '@/lib/table' +import { defineAuthorizedTableUseCase } from '@/lib/table/application/authorized-table-use-case' +import { resolveActiveTableContext } from '@/lib/table/application/context' +import { tableOperations } from '@/lib/table/application/operations' +import { tablePredicateNamesToFilter } from '@/lib/table/application/rows' +import type { DispatchLimit, DispatchMode } from '@/lib/table/dispatcher' +import { signalTableRowsChanged } from '@/lib/table/events' +import { cancelWorkflowGroupRuns, runWorkflowColumn } from '@/lib/table/workflow-columns' + +interface TableRunInput { + tableId: string + assertedWorkspaceId?: string + requestId?: string +} + +interface TableRunResult { + table: TableDefinition +} + +function requestId(input: TableRunInput): string { + return input.requestId ?? getRequestContext()?.requestId ?? generateId().slice(0, 8) +} + +function actorUserId( + principal: Parameters[0], + billedAccountUserId: string +): string { + return resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: billedAccountUserId, + }).attributedUserId +} + +interface StartSelectionRunInput extends TableRunInput { + kind: 'selection' + groupIds: string[] + mode: Extract + rowIds?: string[] + predicate?: TablePredicate + excludeRowIds?: string[] + limit?: DispatchLimit +} + +interface StartRowEnrichmentInput extends TableRunInput { + kind: 'row_enrichment' + rowId: string + groupId: string +} + +export type StartTableRunInput = StartSelectionRunInput | StartRowEnrichmentInput + +export interface StartTableRunResult extends TableRunResult { + dispatchId: string | null + shouldSignalRowsChanged: boolean +} + +function requireCanonicalGroups(table: TableDefinition, groupIds: string[]): void { + if (groupIds.length === 0) { + throw new OrchestrationError('validation', 'At least one workflow group is required') + } + if (groupIds.length > TABLE_LIMITS.MAX_COLUMNS_PER_TABLE) { + throw new OrchestrationError( + 'validation', + `Cannot run more than ${TABLE_LIMITS.MAX_COLUMNS_PER_TABLE} groups` + ) + } + const canonicalGroupIds = new Set((table.schema.workflowGroups ?? []).map((group) => group.id)) + const missing = [...new Set(groupIds)].filter((groupId) => !canonicalGroupIds.has(groupId)) + if (missing.length > 0) throw new OrchestrationError('not_found', 'Workflow group not found') +} + +export const startTableRun = defineAuthorizedTableUseCase({ + operation: tableOperations.startRun, + resolveContext: ({ input }: { input: StartTableRunInput }) => resolveActiveTableContext(input), + async execute({ principal, input, context }): Promise { + const triggeredByUserId = actorUserId(principal, context.billedAccountUserId) + if (input.kind === 'row_enrichment') { + requireCanonicalGroups(context.table, [input.groupId]) + const row = await getRowById(context.tableId, input.rowId, context.workspaceId) + if (!row) throw new OrchestrationError('not_found', 'Row not found') + const result = await runWorkflowColumn({ + tableId: context.tableId, + workspaceId: context.workspaceId, + groupIds: [input.groupId], + rowIds: [input.rowId], + mode: 'all', + requestId: requestId(input), + triggeredByUserId, + }) + return { + table: context.table, + dispatchId: result.dispatchId, + shouldSignalRowsChanged: result.shouldSignalRowsChanged, + } + } + + if (input.rowIds && input.predicate) { + throw new OrchestrationError('validation', 'Provide either predicate or rowIds, but not both') + } + if (input.rowIds && input.excludeRowIds) { + throw new OrchestrationError( + 'validation', + 'excludeRowIds only applies to select-all scope (no rowIds)' + ) + } + const groupIds = [...new Set(input.groupIds)] + requireCanonicalGroups(context.table, groupIds) + const maxTargetRows = DEFAULT_TABLE_PLAN_LIMITS.enterprise.maxRowsPerTable + if (input.rowIds?.length === 0) { + throw new OrchestrationError('validation', 'At least one row ID is required') + } + if (input.rowIds && input.rowIds.length > maxTargetRows) { + throw new OrchestrationError('validation', `Cannot target more than ${maxTargetRows} rows`) + } + const rowIds = input.rowIds ? [...new Set(input.rowIds)] : undefined + if (rowIds) await requireTableRowIds(context.tableId, context.workspaceId, rowIds) + if (input.excludeRowIds && input.excludeRowIds.length > TABLE_LIMITS.MAX_EXCLUDE_ROW_IDS) { + throw new OrchestrationError( + 'validation', + `Cannot exclude more than ${TABLE_LIMITS.MAX_EXCLUDE_ROW_IDS} rows` + ) + } + const excludeRowIds = input.excludeRowIds ? [...new Set(input.excludeRowIds)] : undefined + if ( + input.limit && + (!Number.isSafeInteger(input.limit.max) || + input.limit.max < 1 || + input.limit.max > maxTargetRows) + ) { + throw new OrchestrationError('validation', `Run limit must be between 1 and ${maxTargetRows}`) + } + const filter = input.predicate + ? tablePredicateNamesToFilter(input.predicate, context.table) + : undefined + const result = await runWorkflowColumn({ + tableId: context.tableId, + workspaceId: context.workspaceId, + groupIds, + mode: input.mode, + rowIds, + filter, + excludeRowIds, + limit: input.limit, + requestId: requestId(input), + triggeredByUserId, + }) + return { + table: context.table, + dispatchId: result.dispatchId, + shouldSignalRowsChanged: result.shouldSignalRowsChanged, + } + }, + afterSuccess: ({ context, result }) => { + if (result.shouldSignalRowsChanged) signalTableRowsChanged(context.tableId) + }, +}) + +interface CancelAllTableRunsInput extends TableRunInput { + scope: 'all' + predicate?: TablePredicate + excludeRowIds?: string[] +} + +interface CancelRowTableRunsInput extends TableRunInput { + scope: 'row' + rowId: string +} + +export type CancelTableRunsInput = CancelAllTableRunsInput | CancelRowTableRunsInput + +export interface CancelTableRunsResult extends TableRunResult { + cancelled: number +} + +export const cancelTableRuns = defineAuthorizedTableUseCase({ + operation: tableOperations.cancelRuns, + resolveContext: ({ input }: { input: CancelTableRunsInput }) => resolveActiveTableContext(input), + async execute({ input, context }): Promise { + if (input.scope === 'row') { + const row = await getRowById(context.tableId, input.rowId, context.workspaceId) + if (!row) throw new OrchestrationError('not_found', 'Row not found') + } + const filter = + input.scope === 'all' && input.predicate + ? tablePredicateNamesToFilter(input.predicate, context.table) + : undefined + const cancelled = await cancelWorkflowGroupRuns( + context.tableId, + input.scope === 'row' ? input.rowId : undefined, + { + filter, + excludeRowIds: input.scope === 'all' ? input.excludeRowIds : undefined, + } + ) + return { table: context.table, cancelled } + }, + afterSuccess: ({ context, result }) => { + if (result.cancelled > 0) signalTableRowsChanged(context.tableId) + }, +}) diff --git a/apps/sim/lib/table/application/tables.ts b/apps/sim/lib/table/application/tables.ts new file mode 100644 index 00000000000..2374fd1b379 --- /dev/null +++ b/apps/sim/lib/table/application/tables.ts @@ -0,0 +1,310 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { resolvePrincipalAttribution } from '@sim/auth/principal' +import type { V2SortOrder } from '@/lib/api/contracts/v2/shared' +import type { V2TableSortBy } from '@/lib/api/contracts/v2/tables' +import type { CursorKey } from '@/lib/api/list-query' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { generateRequestId } from '@/lib/core/utils/request' +import { loadActiveFolderPathIndex } from '@/lib/folders/queries' +import { + createTable, + deleteTable, + getTableById, + getWorkspaceTableLimits, + moveTableToFolder, + queryTables, + renameTable, + type TableDefinition, + type TableSchema, + updateTableDescription, +} from '@/lib/table' +import { defineAuthorizedTableUseCase } from '@/lib/table/application/authorized-table-use-case' +import { + resolveActiveTableContext, + resolveTableWorkspaceContext, +} from '@/lib/table/application/context' +import { resolveTableFolderPath, tableFolderPathForId } from '@/lib/table/application/folder-paths' +import { tableOperations } from '@/lib/table/application/operations' +import { signalTableSchemaChanged } from '@/lib/table/events' + +export interface ListTablesInput { + workspaceId: string + folderPath?: string + search?: string + sortBy: V2TableSortBy + sortOrder: V2SortOrder + limit: number + after?: CursorKey[] +} + +export const listTablesUseCase = defineAuthorizedTableUseCase({ + operation: tableOperations.list, + resolveContext: ({ input }: { input: ListTablesInput }) => + resolveTableWorkspaceContext(input.workspaceId), + async execute({ input, context }) { + const folderIndex = await loadActiveFolderPathIndex(context.workspaceId, 'table') + const folderId = + input.folderPath === undefined + ? undefined + : input.folderPath === '/' + ? null + : folderIndex.idByPath.get(input.folderPath) + if (input.folderPath !== undefined && folderId === undefined) { + throw new OrchestrationError('not_found', 'Folder not found') + } + + const { tables, nextKeys } = await queryTables(context.workspaceId, { + folderId, + search: input.search, + sortBy: input.sortBy, + sortOrder: input.sortOrder, + limit: input.limit, + after: input.after, + }) + + return { + tables: tables.map((table) => ({ + table, + folderPath: tableFolderPathForId(folderIndex, table.folderId), + })), + nextKeys, + sortBy: input.sortBy, + sortOrder: input.sortOrder, + } + }, +}) + +export interface CreateTableInput { + workspaceId: string + name: string + description?: string + schema: TableSchema + folderPath?: string + initialRowCount?: number +} + +export const createTableUseCase = defineAuthorizedTableUseCase({ + operation: tableOperations.create, + resolveContext: ({ input }: { input: CreateTableInput }) => + resolveTableWorkspaceContext(input.workspaceId), + async execute({ principal, input, context }) { + const attribution = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }) + const planLimits = await getWorkspaceTableLimits(context.workspaceId) + const resolution = await resolveTableFolderPath(context.workspaceId, input.folderPath ?? '/') + if (!resolution) throw new OrchestrationError('not_found', 'Folder not found') + + const table = await createTable( + { + name: input.name, + description: input.description, + schema: input.schema, + workspaceId: context.workspaceId, + userId: attribution.attributedUserId, + maxTables: planLimits.maxTables, + folderId: resolution.folderId, + initialRowCount: input.initialRowCount, + }, + generateRequestId() + ) + + return { + table, + folderPath: tableFolderPathForId(resolution.index, table.folderId), + } + }, + projectAudit({ input, result }) { + return { + action: AuditAction.TABLE_CREATED, + resourceType: AuditResourceType.TABLE, + resourceId: result.table.id, + resourceName: result.table.name, + description: `Created table "${result.table.name}"`, + metadata: { columnCount: input.schema.columns.length }, + } + }, +}) + +export interface ReadTableInput { + tableId: string + workspaceId: string +} + +export const readTableUseCase = defineAuthorizedTableUseCase({ + operation: tableOperations.read, + resolveContext: ({ input }: { input: ReadTableInput }) => + resolveActiveTableContext({ + tableId: input.tableId, + assertedWorkspaceId: input.workspaceId, + }), + async execute({ context }) { + const index = await loadActiveFolderPathIndex(context.workspaceId, 'table') + return { + table: context.table, + folderPath: tableFolderPathForId(index, context.table.folderId), + } + }, +}) + +export type AppliedTableUpdate = 'name' | 'description' | 'folderPath' + +export interface UpdateTableInput extends ReadTableInput { + name?: string + description?: string | null + folderPath?: string +} + +export interface UpdateTableResult { + table: TableDefinition | null + folderPath: string | null + applied: AppliedTableUpdate[] + changed: AppliedTableUpdate[] + failure?: unknown +} + +export const updateTableUseCase = defineAuthorizedTableUseCase({ + operation: tableOperations.update, + resolveContext: ({ input }: { input: UpdateTableInput }) => + resolveActiveTableContext({ + tableId: input.tableId, + assertedWorkspaceId: input.workspaceId, + }), + async execute({ input, context }): Promise { + const applied: AppliedTableUpdate[] = [] + const changed: AppliedTableUpdate[] = [] + const resolution = + input.folderPath === undefined + ? undefined + : await resolveTableFolderPath(context.workspaceId, input.folderPath) + if (input.folderPath !== undefined && !resolution) { + throw new OrchestrationError('not_found', 'Folder not found in this workspace') + } + + let current = context.table + try { + if (input.name !== undefined) { + if (input.name !== current.name) { + await renameTable(current.id, input.name, generateRequestId(), { + expectedWorkspaceId: context.workspaceId, + }) + current = { ...current, name: input.name } + changed.push('name') + } + applied.push('name') + } + + if (input.description !== undefined) { + if (input.description !== (current.description ?? null)) { + await updateTableDescription( + current.id, + context.workspaceId, + input.description, + generateRequestId() + ) + current = { ...current, description: input.description } + changed.push('description') + } + applied.push('description') + } + + if (input.folderPath !== undefined) { + const folderId = resolution?.folderId ?? null + if (folderId !== (current.folderId ?? null)) { + await moveTableToFolder(current.id, context.workspaceId, folderId, generateRequestId()) + current = { ...current, folderId } + changed.push('folderPath') + } + applied.push('folderPath') + } + + const table = await getTableById(current.id) + if (!table || table.workspaceId !== context.workspaceId) { + throw new OrchestrationError('not_found', 'Table not found') + } + const index = + resolution?.index ?? (await loadActiveFolderPathIndex(context.workspaceId, 'table')) + return { + table, + folderPath: tableFolderPathForId(index, table.folderId), + applied, + changed, + } + } catch (failure) { + return { table: current, folderPath: null, applied, changed, failure } + } + }, + projectAudit({ input, context, result }) { + return result.changed.map((field) => { + if (field === 'name') { + return { + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: context.table.id, + resourceName: input.name ?? context.table.name, + description: `Renamed table to "${input.name}"`, + metadata: { op: 'rename', previousName: context.table.name }, + } + } + if (field === 'description') { + return { + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: context.table.id, + resourceName: result.table?.name ?? context.table.name, + description: `Updated description for table "${result.table?.name ?? context.table.name}"`, + metadata: { op: 'description' }, + } + } + return { + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: context.table.id, + resourceName: result.table?.name ?? context.table.name, + description: + input.folderPath === '/' + ? `Moved table "${result.table?.name ?? context.table.name}" to the workspace root` + : `Moved table "${result.table?.name ?? context.table.name}" into a folder`, + metadata: { op: 'move', folderPath: input.folderPath }, + } + }) + }, + afterSuccess({ context, result }) { + if (result.changed.length > 0) signalTableSchemaChanged(context.table.id) + }, +}) + +export const deleteTableUseCase = defineAuthorizedTableUseCase({ + operation: tableOperations.delete, + resolveContext: ({ input }: { input: ReadTableInput }) => + resolveActiveTableContext({ + tableId: input.tableId, + assertedWorkspaceId: input.workspaceId, + }), + async execute({ principal, context }) { + const attribution = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }) + const { archived } = await deleteTable(context.table.id, generateRequestId(), { + expectedWorkspaceId: context.workspaceId, + }) + if (!archived) throw new OrchestrationError('not_found', 'Table not found') + return { + id: context.table.id, + deleted: true as const, + archived: true as const, + tableName: archived.name, + workspaceId: context.workspaceId, + attributedUserId: attribution.attributedUserId, + } + }, + projectAudit({ result }) { + return { + action: AuditAction.TABLE_DELETED, + resourceType: AuditResourceType.TABLE, + resourceId: result.id, + resourceName: result.tableName, + description: `Archived table "${result.tableName}"`, + } + }, +}) diff --git a/apps/sim/lib/table/application/views.ts b/apps/sim/lib/table/application/views.ts new file mode 100644 index 00000000000..1df29d7cc95 --- /dev/null +++ b/apps/sim/lib/table/application/views.ts @@ -0,0 +1,199 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { resolvePrincipalAttribution } from '@sim/auth/principal' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { TableSchema, TableViewConfig } from '@/lib/table' +import { defineAuthorizedTableUseCase } from '@/lib/table/application/authorized-table-use-case' +import { resolveActiveTableContext } from '@/lib/table/application/context' +import { tableOperations } from '@/lib/table/application/operations' +import { + createTableView, + deleteTableView, + getTableView, + listTableViews, + TableViewValidationError, + updateTableView, +} from '@/lib/table/views/service' + +interface TableViewInput { + tableId: string + workspaceId: string +} + +interface TableViewResourceInput extends TableViewInput { + viewId: string +} + +function rethrowViewError(error: unknown): never { + if (error instanceof TableViewValidationError) { + throw new OrchestrationError('validation', error.message) + } + throw error +} + +export const listTableViewsUseCase = defineAuthorizedTableUseCase({ + operation: tableOperations.listViews, + resolveContext: ({ input }: { input: TableViewInput }) => + resolveActiveTableContext({ + tableId: input.tableId, + assertedWorkspaceId: input.workspaceId, + }), + async execute({ context }) { + const views = await listTableViews( + context.table.id, + (context.table.schema as TableSchema).columns, + context.workspaceId + ) + return { views } + }, +}) + +export const readTableViewUseCase = defineAuthorizedTableUseCase({ + operation: tableOperations.readView, + resolveContext: ({ input }: { input: TableViewResourceInput }) => + resolveActiveTableContext({ + tableId: input.tableId, + assertedWorkspaceId: input.workspaceId, + }), + async execute({ input, context }) { + const view = await getTableView( + input.viewId, + context.table.id, + (context.table.schema as TableSchema).columns, + context.workspaceId + ) + if (!view) throw new OrchestrationError('not_found', 'View not found') + return { view } + }, +}) + +export interface CreateTableViewInput extends TableViewInput { + name: string + config: TableViewConfig +} + +export const createTableViewUseCase = defineAuthorizedTableUseCase({ + operation: tableOperations.createView, + resolveContext: ({ input }: { input: CreateTableViewInput }) => + resolveActiveTableContext({ + tableId: input.tableId, + assertedWorkspaceId: input.workspaceId, + }), + async execute({ principal, input, context }) { + const attribution = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }) + try { + const view = await createTableView({ + tableId: context.table.id, + workspaceId: context.workspaceId, + name: input.name, + config: input.config, + userId: attribution.attributedUserId, + columns: (context.table.schema as TableSchema).columns, + }) + return { view, table: context.table } + } catch (error) { + rethrowViewError(error) + } + }, + projectAudit({ result }) { + return { + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: result.table.id, + resourceName: result.table.name, + description: `Created view "${result.view.name}" on table "${result.table.name}"`, + metadata: { op: 'create_view', viewId: result.view.id }, + } + }, +}) + +export interface UpdateTableViewInput extends TableViewResourceInput { + name?: string + config?: TableViewConfig + configPatch?: TableViewConfig + isDefault?: boolean +} + +export const updateTableViewUseCase = defineAuthorizedTableUseCase({ + operation: tableOperations.updateView, + resolveContext: ({ input }: { input: UpdateTableViewInput }) => + resolveActiveTableContext({ + tableId: input.tableId, + assertedWorkspaceId: input.workspaceId, + }), + async execute({ input, context }) { + try { + const existing = await getTableView( + input.viewId, + context.table.id, + (context.table.schema as TableSchema).columns, + context.workspaceId + ) + if (!existing) throw new OrchestrationError('not_found', 'View not found') + const view = await updateTableView({ + viewId: input.viewId, + tableId: context.table.id, + workspaceId: context.workspaceId, + name: input.name, + config: input.config, + configPatch: input.configPatch, + isDefault: input.isDefault, + columns: (context.table.schema as TableSchema).columns, + }) + if (!view) throw new OrchestrationError('not_found', 'View not found') + return { + view, + table: context.table, + changed: + existing.name !== view.name || + existing.isDefault !== view.isDefault || + JSON.stringify(existing.config) !== JSON.stringify(view.config), + } + } catch (error) { + rethrowViewError(error) + } + }, + projectAudit({ result }) { + if (!result.changed) return [] + return { + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: result.table.id, + resourceName: result.table.name, + description: `Updated view "${result.view.name}" on table "${result.table.name}"`, + metadata: { op: 'update_view', viewId: result.view.id }, + } + }, +}) + +export const deleteTableViewUseCase = defineAuthorizedTableUseCase({ + operation: tableOperations.deleteView, + resolveContext: ({ input }: { input: TableViewResourceInput }) => + resolveActiveTableContext({ + tableId: input.tableId, + assertedWorkspaceId: input.workspaceId, + }), + async execute({ input, context }) { + const existing = await getTableView( + input.viewId, + context.table.id, + (context.table.schema as TableSchema).columns, + context.workspaceId + ) + if (!existing) throw new OrchestrationError('not_found', 'View not found') + const deleted = await deleteTableView(input.viewId, context.table.id, context.workspaceId) + if (!deleted) throw new OrchestrationError('not_found', 'View not found') + return { viewId: input.viewId, viewName: existing.name, table: context.table } + }, + projectAudit({ result }) { + return { + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: result.table.id, + resourceName: result.table.name, + description: `Deleted view "${result.viewName}" from table "${result.table.name}"`, + metadata: { op: 'delete_view', viewId: result.viewId }, + } + }, +}) diff --git a/apps/sim/lib/table/column-types/registry.server.ts b/apps/sim/lib/table/column-types/registry.server.ts index de27abc60ab..6ba27f5c616 100644 --- a/apps/sim/lib/table/column-types/registry.server.ts +++ b/apps/sim/lib/table/column-types/registry.server.ts @@ -39,6 +39,7 @@ import type { JsonValue, SelectOption } from '@/lib/table/types' async function migrateSelectCellsToNames( trx: DbTransaction, tableId: string, + workspaceId: string, columnKey: string, options: SelectOption[] ): Promise { @@ -46,6 +47,7 @@ async function migrateSelectCellsToNames( await updateTableRowsWithDerivedSecretProvenance(trx, { rowWhere: and( eq(userTableRows.tableId, tableId), + eq(userTableRows.workspaceId, workspaceId), sql`jsonb_typeof(${userTableRows.data}->${columnKey}::text) = 'string'` )!, transformation: { @@ -60,6 +62,7 @@ async function migrateSelectCellsToNames( await updateTableRowsWithDerivedSecretProvenance(trx, { rowWhere: and( eq(userTableRows.tableId, tableId), + eq(userTableRows.workspaceId, workspaceId), sql`jsonb_typeof(${userTableRows.data}->${columnKey}::text) = 'array'` )!, transformation: { @@ -95,6 +98,7 @@ const COERCED_WRITE_BACK_BATCH_SIZE = 5000 export async function writeBackCoercedCells( trx: DbTransaction, tableId: string, + workspaceId: string, columnKey: string, valueByRowId: ReadonlyMap ): Promise { @@ -108,6 +112,7 @@ export async function writeBackCoercedCells( await updateTableRowsWithDerivedSecretProvenance(trx, { rowWhere: and( eq(userTableRows.tableId, tableId), + eq(userTableRows.workspaceId, workspaceId), sql`${batch}::jsonb ? ${userTableRows.id}` )!, transformation: { @@ -148,6 +153,7 @@ export async function writeBackCoercedCells( async function migrateCellsToSelectIds( trx: DbTransaction, tableId: string, + workspaceId: string, columnKey: string, options: SelectOption[], multiple: boolean @@ -177,6 +183,7 @@ async function migrateCellsToSelectIds( await updateTableRowsWithDerivedSecretProvenance(trx, { rowWhere: and( eq(userTableRows.tableId, tableId), + eq(userTableRows.workspaceId, workspaceId), sql`jsonb_typeof(${userTableRows.data}->${columnKey}::text) IN ('string', 'number', 'boolean')` )!, transformation: { @@ -199,6 +206,7 @@ async function migrateCellsToSelectIds( await updateTableRowsWithDerivedSecretProvenance(trx, { rowWhere: and( eq(userTableRows.tableId, tableId), + eq(userTableRows.workspaceId, workspaceId), sql`jsonb_typeof(${userTableRows.data}->${columnKey}::text) = 'array'` )!, transformation: { @@ -219,6 +227,7 @@ async function migrateCellsToSelectIds( await updateTableRowsWithDerivedSecretProvenance(trx, { rowWhere: and( eq(userTableRows.tableId, tableId), + eq(userTableRows.workspaceId, workspaceId), sql`jsonb_typeof(${userTableRows.data}->${columnKey}::text) IN ('string', 'number', 'boolean')` )!, transformation: { @@ -238,6 +247,7 @@ async function migrateCellsToSelectIds( await updateTableRowsWithDerivedSecretProvenance(trx, { rowWhere: and( eq(userTableRows.tableId, tableId), + eq(userTableRows.workspaceId, workspaceId), sql`jsonb_typeof(${userTableRows.data}->${columnKey}::text) = 'array'` )!, transformation: { @@ -266,10 +276,17 @@ export const COLUMN_TYPE_SERVER_REGISTRY: Record - migrateCellsToSelectIds(trx, tableId, columnKey, target.options ?? [], !!target.multiple), - migrateCellsFrom: ({ trx, tableId, columnKey, previous }) => - migrateSelectCellsToNames(trx, tableId, columnKey, previous.options ?? []), + migrateCellsTo: ({ trx, tableId, workspaceId, columnKey, target }) => + migrateCellsToSelectIds( + trx, + tableId, + workspaceId, + columnKey, + target.options ?? [], + !!target.multiple + ), + migrateCellsFrom: ({ trx, tableId, workspaceId, columnKey, previous }) => + migrateSelectCellsToNames(trx, tableId, workspaceId, columnKey, previous.options ?? []), }, currency: COLUMN_TYPE_REGISTRY.currency, } diff --git a/apps/sim/lib/table/column-types/types.server.ts b/apps/sim/lib/table/column-types/types.server.ts index c650c31c72e..48f34f812e9 100644 --- a/apps/sim/lib/table/column-types/types.server.ts +++ b/apps/sim/lib/table/column-types/types.server.ts @@ -14,6 +14,7 @@ import type { ColumnDefinition, JsonValue } from '@/lib/table/types' export interface ColumnCellMigrationContext { trx: DbTransaction tableId: string + workspaceId: string /** JSONB storage key for the column (its stable id). */ columnKey: string /** The column definition as it was before the conversion. */ diff --git a/apps/sim/lib/table/columns/memory.test.ts b/apps/sim/lib/table/columns/memory.test.ts new file mode 100644 index 00000000000..873ff7ff927 --- /dev/null +++ b/apps/sim/lib/table/columns/memory.test.ts @@ -0,0 +1,31 @@ +/** + * @vitest-environment node + */ + +import { resetEnvMock, setEnv } from '@sim/testing' +import { afterEach, describe, expect, it } from 'vitest' +import { getColumnRetypeScanBatchSize } from '@/lib/table/columns/service' +import { TABLE_LIMITS } from '@/lib/table/constants' + +const RETYPE_SCAN_BUDGET_BYTES = 32 * 1024 * 1024 + +describe('column retype memory bounds', () => { + afterEach(resetEnvMock) + + it('derives the page cap from the maximum row size and the fixed byte budget', () => { + expect(getColumnRetypeScanBatchSize()).toBe( + Math.floor(RETYPE_SCAN_BUDGET_BYTES / TABLE_LIMITS.MAX_ROW_SIZE_BYTES) + ) + + setEnv({ TABLE_MAX_ROW_SIZE_BYTES: 16 * 1024 * 1024 }) + expect(getColumnRetypeScanBatchSize()).toBe(2) + }) + + it('always processes at least one row without exceeding the row-count cap', () => { + setEnv({ TABLE_MAX_ROW_SIZE_BYTES: RETYPE_SCAN_BUDGET_BYTES * 2 }) + expect(getColumnRetypeScanBatchSize()).toBe(1) + + setEnv({ TABLE_MAX_ROW_SIZE_BYTES: 1 }) + expect(getColumnRetypeScanBatchSize()).toBe(1000) + }) +}) diff --git a/apps/sim/lib/table/columns/service.ts b/apps/sim/lib/table/columns/service.ts index 62309b4d995..4769e627b41 100644 --- a/apps/sim/lib/table/columns/service.ts +++ b/apps/sim/lib/table/columns/service.ts @@ -19,7 +19,7 @@ import { db } from '@sim/db' import { userTableDefinitions, userTableRows } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { omit } from '@sim/utils/object' -import { and, count, eq, sql } from 'drizzle-orm' +import { and, asc, count, eq, gt, sql } from 'drizzle-orm' import { OrchestrationError } from '@/lib/core/orchestration/types' import { columnMatchesRef, generateColumnId, getColumnId } from '@/lib/table/column-keys' import { @@ -33,7 +33,7 @@ import { migrationTo, writeBackCoercedCells, } from '@/lib/table/column-types/registry.server' -import { COLUMN_TYPES, NAME_PATTERN, TABLE_LIMITS } from '@/lib/table/constants' +import { COLUMN_TYPES, getMaxRowSizeBytes, NAME_PATTERN, TABLE_LIMITS } from '@/lib/table/constants' import { resolveCurrencyCode } from '@/lib/table/currency' import { assertColumnDestructive, assertSchemaMutable } from '@/lib/table/mutation-locks' import type { DbTransaction } from '@/lib/table/planner' @@ -47,7 +47,6 @@ import type { DeleteColumnData, JsonValue, RenameColumnData, - RowData, SelectOption, TableDefinition, TableMetadata, @@ -61,6 +60,49 @@ import { validateColumnDefinition } from '@/lib/table/validation' import { assertValidSchema, stripGroupDeps } from '@/lib/table/workflow-columns' const logger = createLogger('TableColumnService') +const COLUMN_RETYPE_SCAN_MAX_BYTES = 32 * 1024 * 1024 +const COLUMN_RETYPE_SCAN_MAX_ROWS = 1000 + +export function getColumnRetypeScanBatchSize(): number { + return Math.max( + 1, + Math.min( + COLUMN_RETYPE_SCAN_MAX_ROWS, + Math.floor(COLUMN_RETYPE_SCAN_MAX_BYTES / getMaxRowSizeBytes()) + ) + ) +} + +export interface ColumnMutationOptions { + expectedWorkspaceId?: string +} + +async function readColumnRetypePage( + trx: DbTransaction, + tableId: string, + workspaceId: string, + columnKey: string, + limit: number, + afterId?: string +): Promise> { + return trx + .select({ + id: userTableRows.id, + value: sql`${userTableRows.data}->${columnKey}::text`, + }) + .from(userTableRows) + .where( + and( + eq(userTableRows.tableId, tableId), + eq(userTableRows.workspaceId, workspaceId), + afterId ? gt(userTableRows.id, afterId) : undefined, + sql`${userTableRows.data} ? ${columnKey}`, + sql`${userTableRows.data}->>${columnKey}::text IS NOT NULL` + ) + ) + .orderBy(asc(userTableRows.id)) + .limit(limit) +} /** * Adds a column to an existing table's schema. @@ -84,114 +126,128 @@ export async function addTableColumn( multiple?: boolean currencyCode?: string }, - requestId: string + requestId: string, + options?: ColumnMutationOptions ): Promise { - return withLockedTable(tableId, async (table, trx) => { - assertSchemaMutable(table) - if (!NAME_PATTERN.test(column.name)) { - throw new OrchestrationError( - 'validation', - `Invalid column name "${column.name}". Must start with a letter or underscore and contain only alphanumeric characters and underscores.` - ) - } + return withLockedTable( + tableId, + async (table, trx) => { + assertSchemaMutable(table) + if (!NAME_PATTERN.test(column.name)) { + throw new OrchestrationError( + 'validation', + `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 OrchestrationError( - 'validation', - `Column name exceeds maximum length (${TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH} characters)` - ) - } + if (column.name.length > TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH) { + throw new OrchestrationError( + 'validation', + `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 OrchestrationError( - 'validation', - `Invalid column type "${column.type}". Must be one of: ${COLUMN_TYPES.join(', ')}` - ) - } + if (!COLUMN_TYPES.includes(column.type as (typeof COLUMN_TYPES)[number])) { + throw new OrchestrationError( + 'validation', + `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 OrchestrationError('validation', `Column "${column.name}" already exists`) - } + const schema = table.schema + if (schema.columns.some((c) => c.name.toLowerCase() === column.name.toLowerCase())) { + throw new OrchestrationError('validation', `Column "${column.name}" already exists`) + } - if (schema.columns.length >= TABLE_LIMITS.MAX_COLUMNS_PER_TABLE) { - throw new OrchestrationError( - 'validation', - `Table has reached maximum column limit (${TABLE_LIMITS.MAX_COLUMNS_PER_TABLE})` - ) - } + if (schema.columns.length >= TABLE_LIMITS.MAX_COLUMNS_PER_TABLE) { + throw new OrchestrationError( + 'validation', + `Table has reached maximum column limit (${TABLE_LIMITS.MAX_COLUMNS_PER_TABLE})` + ) + } - const newColumn: TableSchema['columns'][number] = { - // Honor a caller-provided id (undo of a delete reuses the original id); - // otherwise mint a fresh one. - id: column.id ?? generateColumnId(), - name: column.name, - type: column.type as TableSchema['columns'][number]['type'], - required: column.required ?? false, - unique: column.unique ?? false, - ...(column.options ? { options: column.options } : {}), - ...(column.multiple ? { multiple: true } : {}), - ...columnTypeById(column.type).defaultMetadata?.(column as ColumnDefinition), - } + const newColumn: TableSchema['columns'][number] = { + // Honor a caller-provided id (undo of a delete reuses the original id); + // otherwise mint a fresh one. + id: column.id ?? generateColumnId(), + name: column.name, + type: column.type as TableSchema['columns'][number]['type'], + required: column.required ?? false, + unique: column.unique ?? false, + ...(column.options ? { options: column.options } : {}), + ...(column.multiple ? { multiple: true } : {}), + ...columnTypeById(column.type).defaultMetadata?.(column as ColumnDefinition), + } - const columnValidation = validateColumnDefinition(newColumn) - if (!columnValidation.valid) { - throw new OrchestrationError( - 'validation', - `Invalid column: ${columnValidation.errors.join('; ')}` - ) - } + const columnValidation = validateColumnDefinition(newColumn) + if (!columnValidation.valid) { + throw new OrchestrationError( + 'validation', + `Invalid column: ${columnValidation.errors.join('; ')}` + ) + } - const newColumnId = getColumnId(newColumn) + const newColumnId = getColumnId(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 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 = { ...schema, columns } - - // Keep `metadata.columnOrder` (a list of column ids) in sync: splicing the - // new column's id at the same index we used in `columns` keeps display - // ordering aligned with the user's intent for `position`-based inserts. - const existingOrder = table.metadata?.columnOrder - let updatedMetadata = table.metadata - if (existingOrder && existingOrder.length > 0 && !existingOrder.includes(newColumnId)) { - let insertIdx = existingOrder.length - if (column.position !== undefined && column.position >= 0) { - // Anchor on the column previously at `position` — that column shifted - // right by one in `columns`, so the new id slots in at its old spot. - const anchor = schema.columns[column.position] - if (anchor) { - const anchorIdx = existingOrder.indexOf(getColumnId(anchor)) - if (anchorIdx !== -1) insertIdx = anchorIdx + const updatedSchema: TableSchema = { ...schema, columns } + + // Keep `metadata.columnOrder` (a list of column ids) in sync: splicing the + // new column's id at the same index we used in `columns` keeps display + // ordering aligned with the user's intent for `position`-based inserts. + const existingOrder = table.metadata?.columnOrder + let updatedMetadata = table.metadata + if (existingOrder && existingOrder.length > 0 && !existingOrder.includes(newColumnId)) { + let insertIdx = existingOrder.length + if (column.position !== undefined && column.position >= 0) { + // Anchor on the column previously at `position` — that column shifted + // right by one in `columns`, so the new id slots in at its old spot. + const anchor = schema.columns[column.position] + if (anchor) { + const anchorIdx = existingOrder.indexOf(getColumnId(anchor)) + if (anchorIdx !== -1) insertIdx = anchorIdx + } } + const nextOrder = [...existingOrder] + nextOrder.splice(insertIdx, 0, newColumnId) + updatedMetadata = { ...table.metadata, columnOrder: nextOrder } } - const nextOrder = [...existingOrder] - nextOrder.splice(insertIdx, 0, newColumnId) - updatedMetadata = { ...table.metadata, columnOrder: nextOrder } - } - assertValidSchema(updatedSchema, updatedMetadata?.columnOrder) + assertValidSchema(updatedSchema, updatedMetadata?.columnOrder) - const now = new Date() + const now = new Date() - await trx - .update(userTableDefinitions) - .set({ schema: updatedSchema, metadata: updatedMetadata, updatedAt: now }) - .where(eq(userTableDefinitions.id, tableId)) + await trx + .update(userTableDefinitions) + .set({ schema: updatedSchema, metadata: updatedMetadata, updatedAt: now }) + .where( + and( + eq(userTableDefinitions.id, tableId), + eq(userTableDefinitions.workspaceId, table.workspaceId) + ) + ) - logger.info(`[${requestId}] Added column "${column.name}" to table ${tableId}`) + logger.info(`[${requestId}] Added column "${column.name}" to table ${tableId}`) - return { - ...table, - schema: updatedSchema, - metadata: updatedMetadata, - updatedAt: now, - } - }) + return { + ...table, + schema: updatedSchema, + metadata: updatedMetadata, + updatedAt: now, + } + }, + { expectedWorkspaceId: options?.expectedWorkspaceId } + ) } /** @@ -204,64 +260,74 @@ export async function addTableColumn( */ export async function renameColumn( data: RenameColumnData, - requestId: string + requestId: string, + options?: ColumnMutationOptions ): Promise { - return withLockedTable(data.tableId, async (table, trx) => { - assertSchemaMutable(table) - if (!NAME_PATTERN.test(data.newName)) { - throw new OrchestrationError( - 'validation', - `Invalid column name "${data.newName}". Column names must start with a letter or underscore, followed by alphanumeric characters or underscores.` - ) - } + return withLockedTable( + data.tableId, + async (table, trx) => { + assertSchemaMutable(table) + if (!NAME_PATTERN.test(data.newName)) { + throw new OrchestrationError( + 'validation', + `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 OrchestrationError( - 'validation', - `Column name exceeds maximum length (${TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH} characters)` - ) - } + if (data.newName.length > TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH) { + throw new OrchestrationError( + 'validation', + `Column name exceeds maximum length (${TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH} characters)` + ) + } - const schema = table.schema - const columnIndex = schema.columns.findIndex((c) => columnMatchesRef(c, data.oldName)) - if (columnIndex === -1) { - throw new OrchestrationError('not_found', `Column "${data.oldName}" not found`) - } + const schema = table.schema + const columnIndex = schema.columns.findIndex((c) => columnMatchesRef(c, data.oldName)) + if (columnIndex === -1) { + throw new OrchestrationError('not_found', `Column "${data.oldName}" not found`) + } - if ( - schema.columns.some( - (c, i) => i !== columnIndex && c.name.toLowerCase() === data.newName.toLowerCase() - ) - ) { - throw new OrchestrationError('validation', `Column "${data.newName}" already exists`) - } + if ( + schema.columns.some( + (c, i) => i !== columnIndex && c.name.toLowerCase() === data.newName.toLowerCase() + ) + ) { + throw new OrchestrationError('validation', `Column "${data.newName}" already exists`) + } - const targetColumn = schema.columns[columnIndex] - const actualOldName = targetColumn.name - - // Rename is metadata-only: stored rows, metadata, and workflow-group refs all - // key on the column's stable id, which a rename never changes — so this is a - // pure schema write, no per-row JSONB rewrite or group/metadata cascade. - // Stamp the current storage key as the id (for any not-yet-backfilled column) - // so existing rows stay reachable as the display name changes. - const columnId = targetColumn.id ?? actualOldName - const updatedColumns = schema.columns.map((c, i) => - i === columnIndex ? { ...c, id: columnId, name: data.newName } : c - ) - const updatedSchema: TableSchema = { ...schema, columns: updatedColumns } - assertValidSchema(updatedSchema, table.metadata?.columnOrder) + const targetColumn = schema.columns[columnIndex] + const actualOldName = targetColumn.name + + // Rename is metadata-only: stored rows, metadata, and workflow-group refs all + // key on the column's stable id, which a rename never changes — so this is a + // pure schema write, no per-row JSONB rewrite or group/metadata cascade. + // Stamp the current storage key as the id (for any not-yet-backfilled column) + // so existing rows stay reachable as the display name changes. + const columnId = targetColumn.id ?? actualOldName + const updatedColumns = schema.columns.map((c, i) => + i === columnIndex ? { ...c, id: columnId, name: data.newName } : c + ) + const updatedSchema: TableSchema = { ...schema, columns: updatedColumns } + assertValidSchema(updatedSchema, table.metadata?.columnOrder) - const now = new Date() - await trx - .update(userTableDefinitions) - .set({ schema: updatedSchema, updatedAt: now }) - .where(eq(userTableDefinitions.id, data.tableId)) + const now = new Date() + await trx + .update(userTableDefinitions) + .set({ schema: updatedSchema, updatedAt: now }) + .where( + and( + eq(userTableDefinitions.id, data.tableId), + eq(userTableDefinitions.workspaceId, table.workspaceId) + ) + ) - logger.info( - `[${requestId}] Renamed column "${actualOldName}" to "${data.newName}" in table ${data.tableId}` - ) - return { ...table, schema: updatedSchema, updatedAt: now } - }) + logger.info( + `[${requestId}] Renamed column "${actualOldName}" to "${data.newName}" in table ${data.tableId}` + ) + return { ...table, schema: updatedSchema, updatedAt: now } + }, + { expectedWorkspaceId: options?.expectedWorkspaceId } + ) } /** Removes the given column-id keys from a metadata blob (widths/order/pinned). */ @@ -298,6 +364,7 @@ function stripColumnIdsFromMetadata( */ function stripColumnDataInBackground( tableId: string, + workspaceId: string, columnIds: string[], rowCount: number, requestId: string @@ -312,7 +379,10 @@ function stripColumnDataInBackground( }) await setTableTxTimeouts(trx, { statementMs }) await updateTableRowsWithDerivedSecretProvenance(trx, { - rowWhere: eq(userTableRows.tableId, tableId), + rowWhere: and( + eq(userTableRows.tableId, tableId), + eq(userTableRows.workspaceId, workspaceId) + )!, transformation: { mode: 'remove-columns', columnIds }, }) }) @@ -340,76 +410,96 @@ function stripColumnDataInBackground( */ export async function deleteColumn( data: DeleteColumnData, - requestId: string + requestId: string, + options?: ColumnMutationOptions ): Promise { - const { def, stripKey } = await withLockedTable(data.tableId, async (table, trx) => { - assertColumnDestructive(table) - const schema = table.schema - const columnIndex = schema.columns.findIndex((c) => columnMatchesRef(c, data.columnName)) - if (columnIndex === -1) { - throw new OrchestrationError('not_found', `Column "${data.columnName}" not found`) - } + const { def, stripKey } = await withLockedTable( + data.tableId, + async (table, trx) => { + assertColumnDestructive(table) + const schema = table.schema + const columnIndex = schema.columns.findIndex((c) => columnMatchesRef(c, data.columnName)) + if (columnIndex === -1) { + throw new OrchestrationError('not_found', `Column "${data.columnName}" not found`) + } - if (schema.columns.length <= 1) { - throw new OrchestrationError('validation', 'Cannot delete the last column in a table') - } + if (schema.columns.length <= 1) { + throw new OrchestrationError('validation', 'Cannot delete the last column in a table') + } - const targetColumn = schema.columns[columnIndex] - const actualName = targetColumn.name - const columnId = getColumnId(targetColumn) - const ownerGroupId = targetColumn.workflowGroupId - - // Drop this column's reference (by id) from every group's outputs and - // `columns` dependency. If the column is the last output of its parent - // group, the group itself is also removed (a group with zero outputs is - // invalid). - let groupRemovedId: string | null = null - const updatedGroups = (schema.workflowGroups ?? []) - .map((group) => { - let next = group - if (ownerGroupId && group.id === ownerGroupId) { - const remaining = group.outputs.filter((o) => o.columnName !== columnId) - if (remaining.length === 0) { - groupRemovedId = group.id + const targetColumn = schema.columns[columnIndex] + const actualName = targetColumn.name + const columnId = getColumnId(targetColumn) + const ownerGroupId = targetColumn.workflowGroupId + + // Drop this column's reference (by id) from every group's outputs and + // `columns` dependency. If the column is the last output of its parent + // group, the group itself is also removed (a group with zero outputs is + // invalid). + let groupRemovedId: string | null = null + const updatedGroups = (schema.workflowGroups ?? []) + .map((group) => { + let next = group + if (ownerGroupId && group.id === ownerGroupId) { + const remaining = group.outputs.filter((o) => o.columnName !== columnId) + if (remaining.length === 0) { + groupRemovedId = group.id + } + next = { ...next, outputs: remaining } } - next = { ...next, outputs: remaining } - } - return stripGroupDeps(next, new Set([columnId])) - }) - .filter((g) => g.id !== groupRemovedId) - - const updatedSchema: TableSchema = { - ...schema, - columns: schema.columns.filter((_, i) => i !== columnIndex), - ...(updatedGroups.length > 0 ? { workflowGroups: updatedGroups } : {}), - } - const updatedMetadata = stripColumnIdsFromMetadata( - table.metadata as TableMetadata | null, - new Set([columnId]) - ) - assertValidSchema(updatedSchema, updatedMetadata?.columnOrder) + return stripGroupDeps(next, new Set([columnId])) + }) + .filter((g) => g.id !== groupRemovedId) - const now = new Date() + const updatedSchema: TableSchema = { + ...schema, + columns: schema.columns.filter((_, i) => i !== columnIndex), + ...(updatedGroups.length > 0 ? { workflowGroups: updatedGroups } : {}), + } + const updatedMetadata = stripColumnIdsFromMetadata( + table.metadata as TableMetadata | null, + new Set([columnId]) + ) + assertValidSchema(updatedSchema, updatedMetadata?.columnOrder) - // Schema/metadata update commits now; the column's row-data storage is - // reclaimed in the background (fire-and-forget) — reads never surface the - // orphaned id since the column is already gone from the schema. - await trx - .update(userTableDefinitions) - .set({ schema: updatedSchema, metadata: updatedMetadata, updatedAt: now }) - .where(eq(userTableDefinitions.id, data.tableId)) + const now = new Date() - if (groupRemovedId) await stripGroupExecutions(trx, data.tableId, [groupRemovedId]) + // Schema/metadata update commits now; the column's row-data storage is + // reclaimed in the background (fire-and-forget) — reads never surface the + // orphaned id since the column is already gone from the schema. + await trx + .update(userTableDefinitions) + .set({ schema: updatedSchema, metadata: updatedMetadata, updatedAt: now }) + .where( + and( + eq(userTableDefinitions.id, data.tableId), + eq(userTableDefinitions.workspaceId, table.workspaceId) + ) + ) - logger.info(`[${requestId}] Deleted column "${actualName}" from table ${data.tableId}`) + if (groupRemovedId) { + await stripGroupExecutions(trx, data.tableId, [groupRemovedId], { + expectedWorkspaceId: table.workspaceId, + }) + } - return { - def: { ...table, schema: updatedSchema, metadata: updatedMetadata, updatedAt: now }, - stripKey: columnId, - } - }) + logger.info(`[${requestId}] Deleted column "${actualName}" from table ${data.tableId}`) - stripColumnDataInBackground(data.tableId, [stripKey], def.rowCount ?? 0, requestId) + return { + def: { ...table, schema: updatedSchema, metadata: updatedMetadata, updatedAt: now }, + stripKey: columnId, + } + }, + { expectedWorkspaceId: options?.expectedWorkspaceId } + ) + + stripColumnDataInBackground( + data.tableId, + def.workspaceId, + [stripKey], + def.rowCount ?? 0, + requestId + ) return def } @@ -419,85 +509,103 @@ export async function deleteColumn( */ export async function deleteColumns( data: { tableId: string; columnNames: string[] }, - requestId: string + requestId: string, + options?: ColumnMutationOptions ): Promise { - const { def, stripKeys } = await withLockedTable(data.tableId, async (table, trx) => { - assertColumnDestructive(table) - const schema = table.schema - const namesToDelete = new Set() - const idsToDelete = new Set() - const notFound: string[] = [] - - for (const name of data.columnNames) { - const col = schema.columns.find((c) => columnMatchesRef(c, name)) - if (!col) { - notFound.push(name) - } else { - namesToDelete.add(col.name) - idsToDelete.add(getColumnId(col)) + const { def, stripKeys } = await withLockedTable( + data.tableId, + async (table, trx) => { + assertColumnDestructive(table) + const schema = table.schema + const namesToDelete = new Set() + const idsToDelete = new Set() + const notFound: string[] = [] + + for (const name of data.columnNames) { + const col = schema.columns.find((c) => columnMatchesRef(c, name)) + if (!col) { + notFound.push(name) + } else { + namesToDelete.add(col.name) + idsToDelete.add(getColumnId(col)) + } } - } - if (notFound.length > 0) { - throw new OrchestrationError('not_found', `Columns not found: ${notFound.join(', ')}`) - } + if (notFound.length > 0) { + throw new OrchestrationError('not_found', `Columns not found: ${notFound.join(', ')}`) + } - const remaining = schema.columns.filter((c) => !namesToDelete.has(c.name)) - if (remaining.length === 0) { - throw new OrchestrationError('validation', 'Cannot delete all columns from a table') - } + const remaining = schema.columns.filter((c) => !namesToDelete.has(c.name)) + if (remaining.length === 0) { + throw new OrchestrationError('validation', 'Cannot delete all columns from a table') + } - // For each group, drop outputs whose column (by id) is being deleted. Groups - // that end up with zero outputs are removed entirely (they'd be invalid). - // Then any remaining group's dependencies referencing a removed column are - // cleaned up. - const removedGroupIds = new Set() - let updatedGroups = (schema.workflowGroups ?? []).map((group) => { - const remainingOutputs = group.outputs.filter((o) => !idsToDelete.has(o.columnName)) - if (remainingOutputs.length === 0) { - removedGroupIds.add(group.id) + // For each group, drop outputs whose column (by id) is being deleted. Groups + // that end up with zero outputs are removed entirely (they'd be invalid). + // Then any remaining group's dependencies referencing a removed column are + // cleaned up. + const removedGroupIds = new Set() + let updatedGroups = (schema.workflowGroups ?? []).map((group) => { + const remainingOutputs = group.outputs.filter((o) => !idsToDelete.has(o.columnName)) + if (remainingOutputs.length === 0) { + removedGroupIds.add(group.id) + } + return remainingOutputs.length === group.outputs.length + ? group + : { ...group, outputs: remainingOutputs } + }) + updatedGroups = updatedGroups + .filter((g) => !removedGroupIds.has(g.id)) + .map((group) => stripGroupDeps(group, idsToDelete)) + const updatedSchema: TableSchema = { + ...schema, + columns: remaining, + ...(updatedGroups.length > 0 ? { workflowGroups: updatedGroups } : {}), } - return remainingOutputs.length === group.outputs.length - ? group - : { ...group, outputs: remainingOutputs } - }) - updatedGroups = updatedGroups - .filter((g) => !removedGroupIds.has(g.id)) - .map((group) => stripGroupDeps(group, idsToDelete)) - const updatedSchema: TableSchema = { - ...schema, - columns: remaining, - ...(updatedGroups.length > 0 ? { workflowGroups: updatedGroups } : {}), - } - const updatedMetadata = stripColumnIdsFromMetadata( - table.metadata as TableMetadata | null, - idsToDelete - ) - assertValidSchema(updatedSchema, updatedMetadata?.columnOrder) + const updatedMetadata = stripColumnIdsFromMetadata( + table.metadata as TableMetadata | null, + idsToDelete + ) + assertValidSchema(updatedSchema, updatedMetadata?.columnOrder) - const now = new Date() + const now = new Date() - // Schema/metadata commit now; row storage for the deleted columns is - // reclaimed in the background (fire-and-forget). - await trx - .update(userTableDefinitions) - .set({ schema: updatedSchema, metadata: updatedMetadata, updatedAt: now }) - .where(eq(userTableDefinitions.id, data.tableId)) + // Schema/metadata commit now; row storage for the deleted columns is + // reclaimed in the background (fire-and-forget). + await trx + .update(userTableDefinitions) + .set({ schema: updatedSchema, metadata: updatedMetadata, updatedAt: now }) + .where( + and( + eq(userTableDefinitions.id, data.tableId), + eq(userTableDefinitions.workspaceId, table.workspaceId) + ) + ) - await stripGroupExecutions(trx, data.tableId, removedGroupIds) + await stripGroupExecutions(trx, data.tableId, removedGroupIds, { + expectedWorkspaceId: table.workspaceId, + }) - logger.info( - `[${requestId}] Deleted columns [${[...namesToDelete].join(', ')}] from table ${data.tableId}` - ) + logger.info( + `[${requestId}] Deleted columns [${[...namesToDelete].join(', ')}] from table ${data.tableId}` + ) - return { - def: { ...table, schema: updatedSchema, metadata: updatedMetadata, updatedAt: now }, - stripKeys: Array.from(idsToDelete), - } - }) + return { + def: { ...table, schema: updatedSchema, metadata: updatedMetadata, updatedAt: now }, + stripKeys: Array.from(idsToDelete), + } + }, + { expectedWorkspaceId: options?.expectedWorkspaceId } + ) if (stripKeys.length > 0) { - stripColumnDataInBackground(data.tableId, stripKeys, def.rowCount ?? 0, requestId) + stripColumnDataInBackground( + data.tableId, + def.workspaceId, + stripKeys, + def.rowCount ?? 0, + requestId + ) } return def } @@ -515,6 +623,7 @@ export async function deleteColumns( async function applyConstraints( trx: DbTransaction, tableId: string, + workspaceId: string, column: ColumnDefinition, columnKey: string, data: { required?: boolean; unique?: boolean } @@ -528,7 +637,7 @@ async function applyConstraints( ) } if (data.required === true && !column.required) { - const emptyCount = await countEmptyCells(trx, tableId, columnKey) + const emptyCount = await countEmptyCells(trx, tableId, workspaceId, columnKey) if (emptyCount > 0) { throw new OrchestrationError( 'validation', @@ -543,7 +652,7 @@ async function applyConstraints( `Cannot set column "${column.name}" as unique: ${column.type} columns compare stored values that would allow only one row per value.` ) } - if (await hasDuplicateValues(trx, tableId, columnKey)) { + if (await hasDuplicateValues(trx, tableId, workspaceId, columnKey)) { throw new OrchestrationError( 'validation', `Cannot set column "${column.name}" as unique: duplicate values exist` @@ -568,7 +677,12 @@ async function persistColumns( await trx .update(userTableDefinitions) .set({ schema: updatedSchema, updatedAt: now }) - .where(eq(userTableDefinitions.id, table.id)) + .where( + and( + eq(userTableDefinitions.id, table.id), + eq(userTableDefinitions.workspaceId, table.workspaceId) + ) + ) return { ...table, schema: updatedSchema, updatedAt: now } } @@ -584,10 +698,11 @@ async function persistColumns( async function hasDuplicateValues( trx: DbTransaction, tableId: string, + workspaceId: string, columnKey: string ): Promise { const duplicates = (await trx.execute( - sql`SELECT ${userTableRows.data}->>${columnKey}::text AS val, count(*) AS cnt FROM ${userTableRows} WHERE table_id = ${tableId} AND ${userTableRows.data} ? ${columnKey} AND ${userTableRows.data}->>${columnKey}::text IS NOT NULL GROUP BY val HAVING count(*) > 1 LIMIT 1` + sql`SELECT ${userTableRows.data}->>${columnKey}::text AS val, count(*) AS cnt FROM ${userTableRows} WHERE table_id = ${tableId} AND workspace_id = ${workspaceId} AND ${userTableRows.data} ? ${columnKey} AND ${userTableRows.data}->>${columnKey}::text IS NOT NULL GROUP BY val HAVING count(*) > 1 LIMIT 1` )) as { val: string; cnt: number }[] return duplicates.length > 0 } @@ -695,240 +810,270 @@ function buildConvertedColumn( */ export async function updateColumnType( data: UpdateColumnTypeData, - requestId: string + requestId: string, + options?: ColumnMutationOptions ): Promise { - return withLockedTable(data.tableId, async (table, trx) => { - // Retype reinterprets every stored value under a new type — destructive. - assertColumnDestructive(table) - // Scale both statement and idle timeouts to row count: the compatibility - // check below iterates every row in Node between the row SELECT and the - // schema UPDATE, leaving the transaction idle for that gap. The default 5s - // `idle_in_transaction_session_timeout` would abort a valid type change on - // a large table. - const timeoutMs = scaledStatementTimeoutMs(table.rowCount ?? 0, { - baseMs: 60_000, - perRowMs: 2, - }) - await setTableTxTimeouts(trx, { statementMs: timeoutMs, idleMs: timeoutMs }) - - if (!(COLUMN_TYPES as readonly string[]).includes(data.newType)) { - throw new OrchestrationError( - 'validation', - `Invalid column type "${data.newType}". Valid types: ${COLUMN_TYPES.join(', ')}` - ) - } - - const schema = table.schema - const columnIndex = schema.columns.findIndex((c) => columnMatchesRef(c, data.columnName)) - if (columnIndex === -1) { - throw new OrchestrationError('not_found', `Column "${data.columnName}" not found`) - } + return withLockedTable( + data.tableId, + async (table, trx) => { + // Retype reinterprets every stored value under a new type — destructive. + assertColumnDestructive(table) + // Scale both statement and idle timeouts to row count: the compatibility + // check below iterates every row in Node between the row SELECT and the + // schema UPDATE, leaving the transaction idle for that gap. The default 5s + // `idle_in_transaction_session_timeout` would abort a valid type change on + // a large table. + const timeoutMs = scaledStatementTimeoutMs(table.rowCount ?? 0, { + baseMs: 60_000, + perRowMs: 2, + }) + await setTableTxTimeouts(trx, { statementMs: timeoutMs, idleMs: timeoutMs }) - const column = schema.columns[columnIndex] - if (column.type === data.newType) { - // Callers gate on the type actually changing, but they compute that from - // a schema read taken before this transaction took the lock — so a - // concurrent change can land us here with real work still to do. Only a - // rename can be honoured without a conversion; anything else would be - // silently discarded, and answering success for a change that never - // happened is the worst outcome available. - const carriesOtherWork = - data.required !== undefined || - data.unique !== undefined || - data.options !== undefined || - data.multiple !== undefined || - data.currencyCode !== undefined - if (carriesOtherWork) { + if (!(COLUMN_TYPES as readonly string[]).includes(data.newType)) { throw new OrchestrationError( 'validation', - `Column "${column.name}" is already type "${data.newType}"; re-issue the request without a type change.` + `Invalid column type "${data.newType}". Valid types: ${COLUMN_TYPES.join(', ')}` ) } - const renamed = applyPendingRename(schema.columns, columnIndex, data.newName) - if (renamed === column) return table - return persistColumns( - trx, - table, - schema.columns.map((c, i) => (i === columnIndex ? renamed : c)) - ) - } - const columnKey = getColumnId(column) - // Validate existing data is compatible with the new type - const rows = await trx - .select({ id: userTableRows.id, data: userTableRows.data }) - .from(userTableRows) - .where( - and( - eq(userTableRows.tableId, data.tableId), - sql`${userTableRows.data} ? ${columnKey}`, - sql`${userTableRows.data}->>${columnKey}::text IS NOT NULL` - ) - ) + const schema = table.schema + const columnIndex = schema.columns.findIndex((c) => columnMatchesRef(c, data.columnName)) + if (columnIndex === -1) { + throw new OrchestrationError('not_found', `Column "${data.columnName}" not found`) + } - // Options the column will carry after the change — a `select` value is only - // compatible if it resolves against this set. - const isSelectType = data.newType === 'select' - const targetOptions = data.options ?? column.options ?? [] - const targetMultiple = data.multiple ?? column.multiple - // Leaving `select` behind: stored cells hold option ids, which mean nothing - // once the column is text/number/etc. Check compatibility against the option - // NAME — that's what the cell will actually become (migrated below). - const convertingAwayFromSelect = column.type === 'select' && !isSelectType - // The constraint the column ends up with, which may be arriving in this - // same request — this write applies it, so the scan below has to judge - // against the target value rather than the current one. - const targetRequired = !!(data.required ?? column.required) - - // Rows missing the key (or holding null/`[]`) are filtered out of `rows` - // entirely, so the loop below can never see them — they have to be counted - // separately, through the same predicate `applyConstraints` uses. - if (targetRequired) { - const emptyCount = await countEmptyCells(trx, data.tableId, columnKey) - if (emptyCount > 0) { - throw new OrchestrationError( - 'validation', - `Cannot change column "${column.name}" to a required "${data.newType}": ${emptyCount} row(s) have null, missing, or empty values. Fill them first, or apply the type change without making the column required.` + const column = schema.columns[columnIndex] + if (column.type === data.newType) { + // Callers gate on the type actually changing, but they compute that from + // a schema read taken before this transaction took the lock — so a + // concurrent change can land us here with real work still to do. Only a + // rename can be honoured without a conversion; anything else would be + // silently discarded, and answering success for a change that never + // happened is the worst outcome available. + const carriesOtherWork = + data.required !== undefined || + data.unique !== undefined || + data.options !== undefined || + data.multiple !== undefined || + data.currencyCode !== undefined + if (carriesOtherWork) { + throw new OrchestrationError( + 'validation', + `Column "${column.name}" is already type "${data.newType}"; re-issue the request without a type change.` + ) + } + const renamed = applyPendingRename(schema.columns, columnIndex, data.newName) + if (renamed === column) return table + return persistColumns( + trx, + table, + schema.columns.map((c, i) => (i === columnIndex ? renamed : c)) ) } - } - - /** - * The column definition the table ends up with. Built before the scan so - * the coercion below reads the same metadata (option set, currency) the - * stored value will be validated against afterwards. - */ - const convertedColumn = buildConvertedColumn(column, data, { - isSelectType, - targetMultiple: !!targetMultiple, - }) - - let incompatibleCount = 0 - let blankCount = 0 - /** - * Row id → the value the cell must END UP holding. - * - * Collected during the compatibility scan rather than re-derived later, so - * it reads the same `effective` value the check accepted — which for a - * `select` source is the option name, not the stored id. - * - * Load-bearing: a conversion is allowed exactly when the target type's - * `coerce` accepts the value, and `coerce` frequently *transforms* it (an - * epoch number becomes an ISO date, a formatted amount becomes a number). - * Without writing the transformed value back, the cell keeps its old bytes - * under the new type — and since filters and sorts apply the type's - * `jsonbCast` to whatever is stored, an epoch left in a `date` column makes - * `::timestamptz` fail on EVERY query against it. - */ - const coercedByRowId = new Map() - for (const row of rows) { - const rowData = row.data as RowData - const value = rowData[columnKey] - if (value === null || value === undefined) continue - - const effective = convertingAwayFromSelect ? selectValueForConversion(column, value) : value - - if (!isValueCompatibleWithColumn(effective, convertedColumn)) { - // A cell the target cannot read but that is merely EMPTY is not a - // conversion failure — the write path already turns an unreadable value - // into null on an optional column, so the conversion does the same. Only - // a required target has a real problem with it, and the guard above has - // already reported those. Blocking here meant a text column with a - // single blank cell could not be converted to a number at all. - if (effective === null || effective === '') { - if (targetRequired) blankCount++ - else coercedByRowId.set(row.id, null) - } else { - incompatibleCount++ + const columnKey = getColumnId(column) + + // Options the column will carry after the change — a `select` value is only + // compatible if it resolves against this set. + const isSelectType = data.newType === 'select' + const targetOptions = data.options ?? column.options ?? [] + const targetMultiple = data.multiple ?? column.multiple + // Leaving `select` behind: stored cells hold option ids, which mean nothing + // once the column is text/number/etc. Check compatibility against the option + // NAME — that's what the cell will actually become (migrated below). + const convertingAwayFromSelect = column.type === 'select' && !isSelectType + // The constraint the column ends up with, which may be arriving in this + // same request — this write applies it, so the scan below has to judge + // against the target value rather than the current one. + const targetRequired = !!(data.required ?? column.required) + + // Rows missing the key (or holding null/`[]`) are filtered out of `rows` + // entirely, so the loop below can never see them — they have to be counted + // separately, through the same predicate `applyConstraints` uses. + if (targetRequired) { + const emptyCount = await countEmptyCells(trx, data.tableId, table.workspaceId, columnKey) + if (emptyCount > 0) { + throw new OrchestrationError( + 'validation', + `Cannot change column "${column.name}" to a required "${data.newType}": ${emptyCount} row(s) have null, missing, or empty values. Fill them first, or apply the type change without making the column required.` + ) } - continue } - // `select` keeps its own id↔name migrations; everything else writes back - // whatever `coerce` produced, when that differs from what is stored. - if (!isSelectType && effective !== null) { - const coerced = columnTypeById(data.newType).coerce(effective as JsonValue, convertedColumn) - if (coerced.ok && !Object.is(coerced.value, value)) { - coercedByRowId.set(row.id, coerced.value) + /** + * The column definition the table ends up with. Built before the scan so + * the coercion below reads the same metadata (option set, currency) the + * stored value will be validated against afterwards. + */ + const convertedColumn = buildConvertedColumn(column, data, { + isSelectType, + targetMultiple: !!targetMultiple, + }) + + let incompatibleCount = 0 + let blankCount = 0 + /** + * Row id → the value the cell must END UP holding. + * + * Collected during the compatibility scan rather than re-derived later, so + * it reads the same `effective` value the check accepted — which for a + * `select` source is the option name, not the stored id. + * + * Load-bearing: a conversion is allowed exactly when the target type's + * `coerce` accepts the value, and `coerce` frequently *transforms* it (an + * epoch number becomes an ISO date, a formatted amount becomes a number). + * Without writing the transformed value back, the cell keeps its old bytes + * under the new type — and since filters and sorts apply the type's + * `jsonbCast` to whatever is stored, an epoch left in a `date` column makes + * `::timestamptz` fail on EVERY query against it. + */ + const retypeScanBatchSize = getColumnRetypeScanBatchSize() + let validationAfterId: string | undefined + while (true) { + const rows = await readColumnRetypePage( + trx, + data.tableId, + table.workspaceId, + columnKey, + retypeScanBatchSize, + validationAfterId + ) + if (rows.length === 0) break + for (const row of rows) { + const value = row.value + if (value === null || value === undefined) continue + + const effective = convertingAwayFromSelect + ? selectValueForConversion(column, value) + : value + + if (!isValueCompatibleWithColumn(effective, convertedColumn)) { + if (effective === null || effective === '') { + if (targetRequired) blankCount++ + } else { + incompatibleCount++ + } + } } + validationAfterId = rows.at(-1)?.id + if (rows.length < retypeScanBatchSize) break } - } - - if (blankCount > 0) { - throw new OrchestrationError( - 'validation', - `Cannot change column "${column.name}" to a required "${data.newType}": ${blankCount} row(s) are empty. Fill them first, or apply the type change without making the column required.` - ) - } - if (incompatibleCount > 0) { - throw new OrchestrationError( - 'validation', - `Cannot change column "${column.name}" to type "${data.newType}": ${incompatibleCount} row(s) have incompatible values. Fix or remove the incompatible values first.` - ) - } + if (blankCount > 0) { + throw new OrchestrationError( + 'validation', + `Cannot change column "${column.name}" to a required "${data.newType}": ${blankCount} row(s) are empty. Fill them first, or apply the type change without making the column required.` + ) + } - const renamedColumns = schema.columns.map((c, i) => (i === columnIndex ? convertedColumn : c)) - const updatedColumns = renamedColumns.map((c, i) => - i === columnIndex ? applyPendingRename(renamedColumns, columnIndex, data.newName) : c - ) + if (incompatibleCount > 0) { + throw new OrchestrationError( + 'validation', + `Cannot change column "${column.name}" to type "${data.newType}": ${incompatibleCount} row(s) have incompatible values. Fix or remove the incompatible values first.` + ) + } - const columnValidation = validateColumnDefinition(updatedColumns[columnIndex]) - if (!columnValidation.valid) { - throw new OrchestrationError( - 'validation', - `Invalid column: ${columnValidation.errors.join('; ')}` + const renamedColumns = schema.columns.map((c, i) => (i === columnIndex ? convertedColumn : c)) + const updatedColumns = renamedColumns.map((c, i) => + i === columnIndex ? applyPendingRename(renamedColumns, columnIndex, data.newName) : c ) - } - - const updatedSchema: TableSchema = { ...schema, columns: updatedColumns } - const now = new Date() - - // Cell rewrites are owned by the column-type registry, keyed by direction. - // Outbound runs first: leaving `select` turns opaque option ids into names, - // which is the form the inbound migration (if any) then reads. - const migrationContext = { - trx, - tableId: data.tableId, - columnKey, - previous: column, - target: updatedColumns[columnIndex], - resolved: coercedByRowId, - } - await migrationFrom(column.type)?.(migrationContext) - if (isSelectType) { - await migrationTo(data.newType)?.(migrationContext) - } else { - await writeBackCoercedCells(trx, data.tableId, columnKey, coercedByRowId) - } - // A `unique` arriving with this retype is validated HERE, against the values - // the conversion just wrote — not by the separate constraint write that - // follows. The conversion itself manufactures duplicates that no scan of the - // pre-conversion data can see (`"5"` and `"5.0"` both coerce to `5`), and - // that write runs in its own transaction, so discovering it there would - // report an error with the retype already committed and the original text - // irrecoverably rewritten. - if (data.unique === true && !column.unique) { - if (await hasDuplicateValues(trx, data.tableId, columnKey)) { + const columnValidation = validateColumnDefinition(updatedColumns[columnIndex]) + if (!columnValidation.valid) { throw new OrchestrationError( 'validation', - `Cannot change column "${column.name}" to type "${data.newType}" and set it as unique: the converted values contain duplicates.` + `Invalid column: ${columnValidation.errors.join('; ')}` ) } - } - await trx - .update(userTableDefinitions) - .set({ schema: updatedSchema, updatedAt: now }) - .where(eq(userTableDefinitions.id, data.tableId)) + const updatedSchema: TableSchema = { ...schema, columns: updatedColumns } + const now = new Date() - logger.info( - `[${requestId}] Changed column "${column.name}" type from "${column.type}" to "${data.newType}" in table ${data.tableId}` - ) + // Cell rewrites are owned by the column-type registry, keyed by direction. + // Outbound runs first: leaving `select` turns opaque option ids into names, + // which is the form the inbound migration (if any) then reads. + const migrationContext = { + trx, + tableId: data.tableId, + workspaceId: table.workspaceId, + columnKey, + previous: column, + target: updatedColumns[columnIndex], + resolved: new Map(), + } + await migrationFrom(column.type)?.(migrationContext) + if (isSelectType) { + await migrationTo(data.newType)?.(migrationContext) + } else { + let rewriteAfterId: string | undefined + while (true) { + const rows = await readColumnRetypePage( + trx, + data.tableId, + table.workspaceId, + columnKey, + retypeScanBatchSize, + rewriteAfterId + ) + if (rows.length === 0) break + const coercedByRowId = new Map() + for (const row of rows) { + const value = row.value + if (value === null || value === undefined) continue + if (value === '') { + coercedByRowId.set(row.id, null) + continue + } + const coerced = columnTypeById(data.newType).coerce(value as JsonValue, convertedColumn) + if (coerced.ok && !Object.is(coerced.value, value)) { + coercedByRowId.set(row.id, coerced.value) + } + } + await writeBackCoercedCells( + trx, + data.tableId, + table.workspaceId, + columnKey, + coercedByRowId + ) + rewriteAfterId = rows.at(-1)?.id + if (rows.length < retypeScanBatchSize) break + } + } - return { ...table, schema: updatedSchema, updatedAt: now } - }) + // A `unique` arriving with this retype is validated HERE, against the values + // the conversion just wrote — not by the separate constraint write that + // follows. The conversion itself manufactures duplicates that no scan of the + // pre-conversion data can see (`"5"` and `"5.0"` both coerce to `5`), and + // that write runs in its own transaction, so discovering it there would + // report an error with the retype already committed and the original text + // irrecoverably rewritten. + if (data.unique === true && !column.unique) { + if (await hasDuplicateValues(trx, data.tableId, table.workspaceId, columnKey)) { + throw new OrchestrationError( + 'validation', + `Cannot change column "${column.name}" to type "${data.newType}" and set it as unique: the converted values contain duplicates.` + ) + } + } + + await trx + .update(userTableDefinitions) + .set({ schema: updatedSchema, updatedAt: now }) + .where( + and( + eq(userTableDefinitions.id, data.tableId), + eq(userTableDefinitions.workspaceId, table.workspaceId) + ) + ) + + logger.info( + `[${requestId}] Changed column "${column.name}" type from "${column.type}" to "${data.newType}" in table ${data.tableId}` + ) + + return { ...table, schema: updatedSchema, updatedAt: now } + }, + { expectedWorkspaceId: options?.expectedWorkspaceId } + ) } /** @@ -941,48 +1086,65 @@ export async function updateColumnType( */ export async function updateColumnConstraints( data: UpdateColumnConstraintsData, - requestId: string + requestId: string, + options?: ColumnMutationOptions ): Promise { - return withLockedTable(data.tableId, async (table, trx) => { - assertSchemaMutable(table) - // Scale both statement and idle timeouts to row count: the required/unique - // validation runs between separate queries inside this transaction, leaving - // it briefly idle. Match `updateColumnType` so the default 5s - // `idle_in_transaction_session_timeout` can't abort a valid change on a - // large table. - const timeoutMs = scaledStatementTimeoutMs(table.rowCount ?? 0, { - baseMs: 60_000, - perRowMs: 2, - }) - await setTableTxTimeouts(trx, { statementMs: timeoutMs, idleMs: timeoutMs }) + return withLockedTable( + data.tableId, + async (table, trx) => { + assertSchemaMutable(table) + // Scale both statement and idle timeouts to row count: the required/unique + // validation runs between separate queries inside this transaction, leaving + // it briefly idle. Match `updateColumnType` so the default 5s + // `idle_in_transaction_session_timeout` can't abort a valid change on a + // large table. + const timeoutMs = scaledStatementTimeoutMs(table.rowCount ?? 0, { + baseMs: 60_000, + perRowMs: 2, + }) + await setTableTxTimeouts(trx, { statementMs: timeoutMs, idleMs: timeoutMs }) - const schema = table.schema - const columnIndex = schema.columns.findIndex((c) => columnMatchesRef(c, data.columnName)) - if (columnIndex === -1) { - throw new OrchestrationError('not_found', `Column "${data.columnName}" not found`) - } + const schema = table.schema + const columnIndex = schema.columns.findIndex((c) => columnMatchesRef(c, data.columnName)) + if (columnIndex === -1) { + throw new OrchestrationError('not_found', `Column "${data.columnName}" not found`) + } - const column = schema.columns[columnIndex] - const columnKey = getColumnId(column) - const constrained = await applyConstraints(trx, data.tableId, column, columnKey, data) - const withConstraints = schema.columns.map((c, i) => (i === columnIndex ? constrained : c)) - const updatedColumns = withConstraints.map((c, i) => - i === columnIndex ? applyPendingRename(withConstraints, columnIndex, data.newName) : c - ) - const updatedSchema: TableSchema = { ...schema, columns: updatedColumns } - const now = new Date() + const column = schema.columns[columnIndex] + const columnKey = getColumnId(column) + const constrained = await applyConstraints( + trx, + data.tableId, + table.workspaceId, + column, + columnKey, + data + ) + const withConstraints = schema.columns.map((c, i) => (i === columnIndex ? constrained : c)) + const updatedColumns = withConstraints.map((c, i) => + i === columnIndex ? applyPendingRename(withConstraints, columnIndex, data.newName) : c + ) + const updatedSchema: TableSchema = { ...schema, columns: updatedColumns } + const now = new Date() - await trx - .update(userTableDefinitions) - .set({ schema: updatedSchema, updatedAt: now }) - .where(eq(userTableDefinitions.id, data.tableId)) + await trx + .update(userTableDefinitions) + .set({ schema: updatedSchema, updatedAt: now }) + .where( + and( + eq(userTableDefinitions.id, data.tableId), + eq(userTableDefinitions.workspaceId, table.workspaceId) + ) + ) - logger.info( - `[${requestId}] Updated constraints for column "${column.name}" in table ${data.tableId}` - ) + logger.info( + `[${requestId}] Updated constraints for column "${column.name}" in table ${data.tableId}` + ) - return { ...table, schema: updatedSchema, updatedAt: now } - }) + return { ...table, schema: updatedSchema, updatedAt: now } + }, + { expectedWorkspaceId: options?.expectedWorkspaceId } + ) } /** @@ -993,161 +1155,177 @@ export async function updateColumnConstraints( */ export async function updateColumnOptions( data: UpdateColumnOptionsData, - requestId: string + requestId: string, + options?: ColumnMutationOptions ): Promise { - return withLockedTable(data.tableId, async (table, trx) => { - const schema = table.schema - const columnIndex = schema.columns.findIndex((c) => columnMatchesRef(c, data.columnName)) - if (columnIndex === -1) { - throw new OrchestrationError('not_found', `Column "${data.columnName}" not found`) - } + return withLockedTable( + data.tableId, + async (table, trx) => { + const schema = table.schema + const columnIndex = schema.columns.findIndex((c) => columnMatchesRef(c, data.columnName)) + if (columnIndex === -1) { + throw new OrchestrationError('not_found', `Column "${data.columnName}" not found`) + } - const column = schema.columns[columnIndex] - if (column.type !== 'select') { - throw new OrchestrationError( - 'validation', - `Cannot set options on column "${column.name}" of type "${column.type}"` - ) - } + const column = schema.columns[columnIndex] + if (column.type !== 'select') { + throw new OrchestrationError( + 'validation', + `Cannot set options on column "${column.name}" of type "${column.type}"` + ) + } - const columnKey = getColumnId(column) + const columnKey = getColumnId(column) - const { multiple: _prevMultiple, ...columnRest } = column - const updatedColumn = { - ...columnRest, - options: data.options, - ...((data.multiple ?? column.multiple) ? { multiple: true } : {}), - } - const columnValidation = validateColumnDefinition(updatedColumn) - if (!columnValidation.valid) { - throw new OrchestrationError( - 'validation', - `Invalid column: ${columnValidation.errors.join('; ')}` - ) - } + const { multiple: _prevMultiple, ...columnRest } = column + const updatedColumn = { + ...columnRest, + options: data.options, + ...((data.multiple ?? column.multiple) ? { multiple: true } : {}), + } + const columnValidation = validateColumnDefinition(updatedColumn) + if (!columnValidation.valid) { + throw new OrchestrationError( + 'validation', + `Invalid column: ${columnValidation.errors.join('; ')}` + ) + } - const nextMultiple = !!(data.multiple ?? column.multiple) - const wasMultiple = !!column.multiple - const keptIds = new Set(data.options.map((o) => o.id)) - const removedAny = (column.options ?? []).some((o) => !keptIds.has(o.id)) - const togglingCardinality = nextMultiple !== wasMultiple - // The constraint the column ENDS UP with, which may be arriving in this same - // request. `applyConstraints` validates and applies it below, after the cell - // migrations; the checks in between need to read the target value. - const targetRequired = !!(data.required ?? column.required) - - if (togglingCardinality || removedAny) { - const timeoutMs = scaledStatementTimeoutMs(table.rowCount ?? 0, { - baseMs: 60_000, - perRowMs: 2, - }) - await setTableTxTimeouts(trx, { statementMs: timeoutMs, idleMs: timeoutMs }) - } + const nextMultiple = !!(data.multiple ?? column.multiple) + const wasMultiple = !!column.multiple + const keptIds = new Set(data.options.map((o) => o.id)) + const removedAny = (column.options ?? []).some((o) => !keptIds.has(o.id)) + const togglingCardinality = nextMultiple !== wasMultiple + // The constraint the column ENDS UP with, which may be arriving in this same + // request. `applyConstraints` validates and applies it below, after the cell + // migrations; the checks in between need to read the target value. + const targetRequired = !!(data.required ?? column.required) + + if (togglingCardinality || removedAny) { + const timeoutMs = scaledStatementTimeoutMs(table.rowCount ?? 0, { + baseMs: 60_000, + perRowMs: 2, + }) + await setTableTxTimeouts(trx, { statementMs: timeoutMs, idleMs: timeoutMs }) + } - // Removal runs FIRST, before the multi→single guard and the shape migration. - // Both of those read the cells: the guard would otherwise count options this - // same request is dropping, and the migration keeps a multi cell's FIRST - // element — which could be a removed id sitting ahead of a kept one, so the - // surviving option would be discarded and the dead one kept. - // - // Cells are still in their pre-toggle shape here, so this passes the CURRENT - // cardinality, not the target one. - if (removedAny) { - // On a required column, clearing is not an option: it would leave rows the - // write path rejects, and `updateColumnConstraints` refuses to CREATE that - // state, so producing it here would be inconsistent. Make the caller - // reassign those rows first. + // Removal runs FIRST, before the multi→single guard and the shape migration. + // Both of those read the cells: the guard would otherwise count options this + // same request is dropping, and the migration keeps a multi cell's FIRST + // element — which could be a removed id sitting ahead of a kept one, so the + // surviving option would be discarded and the dead one kept. // - // Gated on the constraint the column ENDS UP with, which may be arriving - // in this same request: validating against the current flag both blocks a - // removal paired with `required: false` that is about to be fine, and lets - // a removal paired with `required: true` clear cells and then fail the - // constraint write, leaving this change committed behind an error. - if (targetRequired) { - const strandedCount = await countCellsLosingTheirOptions( + // Cells are still in their pre-toggle shape here, so this passes the CURRENT + // cardinality, not the target one. + if (removedAny) { + // On a required column, clearing is not an option: it would leave rows the + // write path rejects, and `updateColumnConstraints` refuses to CREATE that + // state, so producing it here would be inconsistent. Make the caller + // reassign those rows first. + // + // Gated on the constraint the column ENDS UP with, which may be arriving + // in this same request: validating against the current flag both blocks a + // removal paired with `required: false` that is about to be fine, and lets + // a removal paired with `required: true` clear cells and then fail the + // constraint write, leaving this change committed behind an error. + if (targetRequired) { + const strandedCount = await countCellsLosingTheirOptions( + trx, + data.tableId, + table.workspaceId, + columnKey, + data.options, + wasMultiple + ) + if (strandedCount > 0) { + throw new OrchestrationError( + 'validation', + `Cannot remove options from required column "${column.name}": ${strandedCount} row(s) would be left empty. Reassign those rows to a remaining option first.` + ) + } + } + await clearRemovedSelectOptions( trx, data.tableId, + table.workspaceId, columnKey, data.options, wasMultiple ) - if (strandedCount > 0) { + } + + // Switching multiple → single drops all but the first option in any cell + // that still holds several — block it rather than silently losing data. + // Counted after the removal above, so dropping surplus options and turning + // multiselect off in one save is allowed when every cell ends up with one. + if (wasMultiple && !nextMultiple) { + const [result] = await trx + .select({ count: count() }) + .from(userTableRows) + .where( + and( + eq(userTableRows.tableId, data.tableId), + eq(userTableRows.workspaceId, table.workspaceId), + sql`CASE WHEN jsonb_typeof(${userTableRows.data}->${columnKey}::text) = 'array' + THEN jsonb_array_length(${userTableRows.data}->${columnKey}::text) > 1 + ELSE false END` + ) + ) + const multiValuedCount = result?.count ?? 0 + + if (multiValuedCount > 0) { throw new OrchestrationError( 'validation', - `Cannot remove options from required column "${column.name}": ${strandedCount} row(s) would be left empty. Reassign those rows to a remaining option first.` + `Cannot switch column "${column.name}" to single-select: ${multiValuedCount} row(s) have multiple options selected. Reduce them to one option first.` ) } } - await clearRemovedSelectOptions(trx, data.tableId, columnKey, data.options, wasMultiple) - } - - // Switching multiple → single drops all but the first option in any cell - // that still holds several — block it rather than silently losing data. - // Counted after the removal above, so dropping surplus options and turning - // multiselect off in one save is allowed when every cell ends up with one. - if (wasMultiple && !nextMultiple) { - const rows = await trx - .select({ data: userTableRows.data }) - .from(userTableRows) - .where( - and(eq(userTableRows.tableId, data.tableId), sql`${userTableRows.data} ? ${columnKey}`) - ) - - let multiValuedCount = 0 - for (const row of rows) { - const value = (row.data as RowData)[columnKey] - if (Array.isArray(value) && value.length > 1) multiValuedCount++ - } - if (multiValuedCount > 0) { - throw new OrchestrationError( - 'validation', - `Cannot switch column "${column.name}" to single-select: ${multiValuedCount} row(s) have multiple options selected. Reduce them to one option first.` - ) + // A single↔multi toggle changes the stored shape (scalar id vs array of + // ids). Multi filters compile to array containment, which never matches a + // scalar, so leaving cells un-normalized would silently drop every + // pre-toggle row out of its own column's filters. + if (togglingCardinality) { + // Same registry migration the retype path uses — `updatedColumn` already + // carries the post-toggle `options`/`multiple`, which is all it reads. + await migrationTo('select')?.({ + trx, + tableId: data.tableId, + workspaceId: table.workspaceId, + columnKey, + previous: column, + target: updatedColumn, + resolved: new Map(), + }) } - } - // A single↔multi toggle changes the stored shape (scalar id vs array of - // ids). Multi filters compile to array containment, which never matches a - // scalar, so leaving cells un-normalized would silently drop every - // pre-toggle row out of its own column's filters. - if (togglingCardinality) { - // Same registry migration the retype path uses — `updatedColumn` already - // carries the post-toggle `options`/`multiple`, which is all it reads. - await migrationTo('select')?.({ + // Constraints are validated and applied AFTER the migrations above, because + // those migrations rewrite stored values — a `unique` scan run before them + // would read the pre-migration shape and pass, and the migration could then + // produce the duplicates it was meant to prevent. + const constrainedColumn = await applyConstraints( trx, - tableId: data.tableId, + data.tableId, + table.workspaceId, + updatedColumn, columnKey, - previous: column, - target: updatedColumn, - resolved: new Map(), - }) - } - - // Constraints are validated and applied AFTER the migrations above, because - // those migrations rewrite stored values — a `unique` scan run before them - // would read the pre-migration shape and pass, and the migration could then - // produce the duplicates it was meant to prevent. - const constrainedColumn = await applyConstraints( - trx, - data.tableId, - updatedColumn, - columnKey, - data - ) - const withOptions = schema.columns.map((c, i) => (i === columnIndex ? constrainedColumn : c)) - const updatedColumns = withOptions.map((c, i) => - i === columnIndex ? applyPendingRename(withOptions, columnIndex, data.newName) : c - ) + data + ) + const withOptions = schema.columns.map((c, i) => (i === columnIndex ? constrainedColumn : c)) + const updatedColumns = withOptions.map((c, i) => + i === columnIndex ? applyPendingRename(withOptions, columnIndex, data.newName) : c + ) - const updated = await persistColumns(trx, table, updatedColumns) + const updated = await persistColumns(trx, table, updatedColumns) - logger.info( - `[${requestId}] Updated options for column "${column.name}" in table ${data.tableId}` - ) + logger.info( + `[${requestId}] Updated options for column "${column.name}" in table ${data.tableId}` + ) - return updated - }) + return updated + }, + { expectedWorkspaceId: options?.expectedWorkspaceId } + ) } /** @@ -1165,73 +1343,84 @@ export async function updateColumnOptions( */ export async function updateColumnCurrency( data: UpdateColumnCurrencyData, - requestId: string + requestId: string, + options?: ColumnMutationOptions ): Promise { - return withLockedTable(data.tableId, async (table, trx) => { - assertSchemaMutable(table) + return withLockedTable( + data.tableId, + async (table, trx) => { + assertSchemaMutable(table) + + const schema = table.schema + const columnIndex = schema.columns.findIndex((c) => columnMatchesRef(c, data.columnName)) + if (columnIndex === -1) { + throw new OrchestrationError('not_found', `Column "${data.columnName}" not found`) + } - const schema = table.schema - const columnIndex = schema.columns.findIndex((c) => columnMatchesRef(c, data.columnName)) - if (columnIndex === -1) { - throw new OrchestrationError('not_found', `Column "${data.columnName}" not found`) - } + const column = schema.columns[columnIndex] + if (column.type !== 'currency') { + throw new OrchestrationError( + 'validation', + `Cannot set currency on column "${column.name}" of type "${column.type}"` + ) + } - const column = schema.columns[columnIndex] - if (column.type !== 'currency') { - throw new OrchestrationError( - 'validation', - `Cannot set currency on column "${column.name}" of type "${column.type}"` - ) - } + const updatedColumn: ColumnDefinition = { + ...column, + currencyCode: resolveCurrencyCode(data.currencyCode), + } + const columnValidation = validateColumnDefinition(updatedColumn) + if (!columnValidation.valid) { + throw new OrchestrationError( + 'validation', + `Invalid column: ${columnValidation.errors.join('; ')}` + ) + } - const updatedColumn: ColumnDefinition = { - ...column, - currencyCode: resolveCurrencyCode(data.currencyCode), - } - const columnValidation = validateColumnDefinition(updatedColumn) - if (!columnValidation.valid) { - throw new OrchestrationError( - 'validation', - `Invalid column: ${columnValidation.errors.join('; ')}` + const constrained = await applyConstraints( + trx, + data.tableId, + table.workspaceId, + updatedColumn, + getColumnId(column), + data ) - } - - const constrained = await applyConstraints( - trx, - data.tableId, - updatedColumn, - getColumnId(column), - data - ) - // Only a no-op when nothing at all changed — currency, constraints, name. - const renamePending = data.newName !== undefined && data.newName !== column.name - if ( - constrained === updatedColumn && - updatedColumn.currencyCode === column.currencyCode && - !renamePending - ) { - return table - } + // Only a no-op when nothing at all changed — currency, constraints, name. + const renamePending = data.newName !== undefined && data.newName !== column.name + if ( + constrained === updatedColumn && + updatedColumn.currencyCode === column.currencyCode && + !renamePending + ) { + return table + } - const withCurrency = schema.columns.map((c, i) => (i === columnIndex ? constrained : c)) - const updatedColumns = withCurrency.map((c, i) => - i === columnIndex ? applyPendingRename(withCurrency, columnIndex, data.newName) : c - ) - const updatedSchema: TableSchema = { ...schema, columns: updatedColumns } - const now = new Date() + const withCurrency = schema.columns.map((c, i) => (i === columnIndex ? constrained : c)) + const updatedColumns = withCurrency.map((c, i) => + i === columnIndex ? applyPendingRename(withCurrency, columnIndex, data.newName) : c + ) + const updatedSchema: TableSchema = { ...schema, columns: updatedColumns } + const now = new Date() - await trx - .update(userTableDefinitions) - .set({ schema: updatedSchema, updatedAt: now }) - .where(eq(userTableDefinitions.id, data.tableId)) + await trx + .update(userTableDefinitions) + .set({ schema: updatedSchema, updatedAt: now }) + .where( + and( + eq(userTableDefinitions.id, data.tableId), + eq(userTableDefinitions.workspaceId, table.workspaceId) + ) + ) - logger.info( - `[${requestId}] Set currency for column "${column.name}" to "${updatedColumn.currencyCode}" in table ${data.tableId}` - ) + logger.info( + `[${requestId}] Set currency for column "${column.name}" to "${updatedColumn.currencyCode}" in table ${data.tableId}` + ) - return { ...table, schema: updatedSchema, updatedAt: now } - }) + return { ...table, schema: updatedSchema, updatedAt: now } + }, + { expectedWorkspaceId: options?.expectedWorkspaceId } + ) } /** @@ -1247,6 +1436,7 @@ export async function updateColumnCurrency( async function countEmptyCells( trx: DbTransaction, tableId: string, + workspaceId: string, columnKey: string ): Promise { const [result] = await trx @@ -1255,6 +1445,7 @@ async function countEmptyCells( .where( and( eq(userTableRows.tableId, tableId), + eq(userTableRows.workspaceId, workspaceId), sql`(NOT (${userTableRows.data} ? ${columnKey}) OR ${userTableRows.data}->>${columnKey}::text IS NULL OR ${userTableRows.data}->${columnKey}::text = '[]'::jsonb)` @@ -1271,6 +1462,7 @@ async function countEmptyCells( async function countCellsLosingTheirOptions( trx: DbTransaction, tableId: string, + workspaceId: string, columnKey: string, options: SelectOption[], multiple: boolean @@ -1284,6 +1476,7 @@ async function countCellsLosingTheirOptions( .where( and( eq(userTableRows.tableId, tableId), + eq(userTableRows.workspaceId, workspaceId), sql`jsonb_typeof(${userTableRows.data}->${columnKey}::text) = 'array'`, sql`${userTableRows.data}->${columnKey}::text <> '[]'::jsonb`, // The type guard above is not ordered against this predicate, so the @@ -1308,6 +1501,7 @@ async function countCellsLosingTheirOptions( .where( and( eq(userTableRows.tableId, tableId), + eq(userTableRows.workspaceId, workspaceId), sql`jsonb_typeof(${userTableRows.data}->${columnKey}::text) = 'string'`, sql`${userTableRows.data}->>${columnKey}::text <> ''`, sql`NOT (${keptIds}::jsonb @> jsonb_build_array(${userTableRows.data}->${columnKey}::text))` @@ -1325,6 +1519,7 @@ async function countCellsLosingTheirOptions( async function clearRemovedSelectOptions( trx: DbTransaction, tableId: string, + workspaceId: string, columnKey: string, options: SelectOption[], multiple: boolean @@ -1335,6 +1530,7 @@ async function clearRemovedSelectOptions( await updateTableRowsWithDerivedSecretProvenance(trx, { rowWhere: and( eq(userTableRows.tableId, tableId), + eq(userTableRows.workspaceId, workspaceId), sql`jsonb_typeof(${userTableRows.data}->${columnKey}::text) = 'array'`, sql`NOT (${keptIds}::jsonb @> (${userTableRows.data}->${columnKey}::text))` )!, @@ -1354,6 +1550,7 @@ async function clearRemovedSelectOptions( await updateTableRowsWithDerivedSecretProvenance(trx, { rowWhere: and( eq(userTableRows.tableId, tableId), + eq(userTableRows.workspaceId, workspaceId), sql`jsonb_typeof(${userTableRows.data}->${columnKey}::text) = 'string'`, sql`${userTableRows.data}->>${columnKey}::text <> ''`, sql`NOT (${keptIds}::jsonb @> jsonb_build_array(${userTableRows.data}->${columnKey}::text))` diff --git a/apps/sim/lib/table/dispatcher.ts b/apps/sim/lib/table/dispatcher.ts index 30bb60c4101..6b2747e0e4d 100644 --- a/apps/sim/lib/table/dispatcher.ts +++ b/apps/sim/lib/table/dispatcher.ts @@ -21,7 +21,7 @@ import { writeWorkflowGroupState } from '@/lib/table/cell-write' import { USER_TABLE_ROWS_SQL_NAME } from '@/lib/table/constants' import { isExecCancelledAfter } from '@/lib/table/deps' import { appendTableEvent } from '@/lib/table/events' -import { type DbExecutor, withSeqscanOff } from '@/lib/table/planner' +import { type DbExecutor, type DbTransaction, withSeqscanOff } from '@/lib/table/planner' import { updateTableRowsWithDerivedSecretProvenance } from '@/lib/table/rows/secret-provenance' import { buildFilterClause } from '@/lib/table/sql' import type { @@ -87,6 +87,24 @@ export interface DispatchRow { requestedAt: Date } +async function deleteExecutionRows(trx: DbTransaction, filters: SQL[]): Promise { + const countRows = await trx.execute<{ count: number | string }>(sql` + WITH deleted AS ( + DELETE FROM ${tableRowExecutions} + WHERE ${and(...filters)} + RETURNING 1 + ) + SELECT count(*)::integer AS count FROM deleted + `) + const [countRow] = Array.isArray(countRows) ? countRows : [] + if (!countRow) throw new Error('Workflow cell clearing did not return a deleted count') + const count = Number(countRow.count) + if (!Number.isSafeInteger(count) || count < 0) { + throw new Error('Workflow cell clearing returned an invalid deleted count') + } + return count +} + export type DispatcherStepResult = 'continue' | 'done' /** Eager bulk clear at click time so the user sees every targeted cell go @@ -96,17 +114,18 @@ export type DispatcherStepResult = 'continue' | 'done' * already filled, mirroring the eligibility predicate. */ export async function bulkClearWorkflowGroupCells(input: { tableId: string + workspaceId: string groups: Array<{ id: string; outputs: Array<{ columnName: string }> }> rowIds?: string[] /** Select-all scope: deselected rows whose outputs must NOT be wiped. */ excludeRowIds?: string[] mode: DispatchMode -}): Promise { - const { tableId, groups, rowIds, excludeRowIds, mode } = input - if (groups.length === 0) return +}): Promise { + const { tableId, workspaceId, groups, rowIds, excludeRowIds, mode } = input + if (groups.length === 0) return false // `'new'` mode targets only rows with no prior attempt — nothing to clear. // Pre-existing outputs on any other row must not be wiped by an auto-fire. - if (mode === 'new') return + if (mode === 'new') return false const groupIds = groups.map((g) => g.id) const rowScope = rowIds && rowIds.length > 0 ? rowIds : null @@ -119,25 +138,34 @@ export async function bulkClearWorkflowGroupCells(input: { const outputCols = Array.from( new Set(groups.flatMap((g) => g.outputs.map((o) => o.columnName))) ) - const filters: SQL[] = [eq(userTableRows.tableId, tableId)] + const filters: SQL[] = [ + eq(userTableRows.tableId, tableId), + eq(userTableRows.workspaceId, workspaceId), + ] if (rowScope) filters.push(inArray(userTableRows.id, rowScope)) if (excluded) filters.push(notInArray(userTableRows.id, excluded)) - await db.transaction(async (trx) => { + return db.transaction(async (trx) => { const rowWhere = and(...filters)! - await updateTableRowsWithDerivedSecretProvenance(trx, { + const clearedRows = await updateTableRowsWithDerivedSecretProvenance(trx, { rowWhere, transformation: { mode: 'remove-columns', columnIds: outputCols }, }) const execFilters: SQL[] = [ eq(tableRowExecutions.tableId, tableId), inArray(tableRowExecutions.groupId, groupIds), + sql`${tableRowExecutions.rowId} IN ( + SELECT ${userTableRows.id} + FROM ${userTableRows} + WHERE ${userTableRows.tableId} = ${tableId} + AND ${userTableRows.workspaceId} = ${workspaceId} + )`, ] if (rowScope) execFilters.push(inArray(tableRowExecutions.rowId, rowScope)) if (excluded) execFilters.push(notInArray(tableRowExecutions.rowId, excluded)) - await trx.delete(tableRowExecutions).where(and(...execFilters)) + const deletedExecutions = await deleteExecutionRows(trx, execFilters) + return clearedRows > 0 || deletedExecutions > 0 }) - return } // `incomplete`: clear per-group, not per-row. Only groups that are @@ -147,7 +175,8 @@ export async function bulkClearWorkflowGroupCells(input: { // because a *sibling* group on the same row is incomplete, re-running the // completed one. (`never-run` groups have no exec/output to clear — the // dispatcher runs them via eligibility.) - await db.transaction(async (trx) => { + return db.transaction(async (trx) => { + let rowsChanged = false for (const group of groups) { const reRunnable = sql`EXISTS ( SELECT 1 FROM ${tableRowExecutions} re @@ -155,12 +184,16 @@ export async function bulkClearWorkflowGroupCells(input: { AND re.group_id = ${group.id} AND re.status IN ('error', 'cancelled') )` - const filters: SQL[] = [eq(userTableRows.tableId, tableId), reRunnable] + const filters: SQL[] = [ + eq(userTableRows.tableId, tableId), + eq(userTableRows.workspaceId, workspaceId), + reRunnable, + ] if (rowScope) filters.push(inArray(userTableRows.id, rowScope)) if (excluded) filters.push(notInArray(userTableRows.id, excluded)) const rowWhere = and(...filters)! - await updateTableRowsWithDerivedSecretProvenance(trx, { + const clearedRows = await updateTableRowsWithDerivedSecretProvenance(trx, { rowWhere, transformation: { mode: 'remove-columns', @@ -172,11 +205,19 @@ export async function bulkClearWorkflowGroupCells(input: { eq(tableRowExecutions.tableId, tableId), eq(tableRowExecutions.groupId, group.id), sql`${tableRowExecutions.status} IN ('error', 'cancelled')`, + sql`${tableRowExecutions.rowId} IN ( + SELECT ${userTableRows.id} + FROM ${userTableRows} + WHERE ${userTableRows.tableId} = ${tableId} + AND ${userTableRows.workspaceId} = ${workspaceId} + )`, ] if (rowScope) execFilters.push(inArray(tableRowExecutions.rowId, rowScope)) if (excluded) execFilters.push(notInArray(tableRowExecutions.rowId, excluded)) - await trx.delete(tableRowExecutions).where(and(...execFilters)) + const deletedExecutions = await deleteExecutionRows(trx, execFilters) + rowsChanged ||= clearedRows > 0 || deletedExecutions > 0 } + return rowsChanged }) } diff --git a/apps/sim/lib/table/export-runner.test.ts b/apps/sim/lib/table/export-runner.test.ts index 959d7cba670..c026bc6cb04 100644 --- a/apps/sim/lib/table/export-runner.test.ts +++ b/apps/sim/lib/table/export-runner.test.ts @@ -30,10 +30,10 @@ vi.mock('@/lib/table/service', () => ({ })) vi.mock('@/lib/table/jobs/service', () => ({ selectExportRowPage: mockSelectExportRowPage, - updateJobProgress: mockUpdateJobProgress, - markJobReady: mockMarkJobReady, - markJobFailed: mockMarkJobFailed, - setJobResultKey: mockSetJobResultKey, + updateJobProgressInWorkspace: mockUpdateJobProgress, + markJobReadyInWorkspace: mockMarkJobReady, + markJobFailedInWorkspace: mockMarkJobFailed, + setJobResultKeyInWorkspace: mockSetJobResultKey, })) vi.mock('@/lib/table/events', () => ({ appendTableEvent: mockAppendTableEvent })) vi.mock('@/lib/uploads/core/storage-service', () => ({ @@ -84,7 +84,7 @@ describe('runTableExport', () => { mockUpdateJobProgress.mockResolvedValue(true) mockMarkJobReady.mockResolvedValue(true) mockMarkJobFailed.mockResolvedValue(undefined) - mockSetJobResultKey.mockResolvedValue(undefined) + mockSetJobResultKey.mockResolvedValue(true) mockDeleteFile.mockResolvedValue(undefined) // A handle that records every write so tests can assert the streamed bytes, and echoes the // pinned key back from `complete` like the real uploader does. @@ -126,8 +126,8 @@ describe('runTableExport', () => { expect(lastHandle?.complete).toHaveBeenCalledTimes(1) expect(lastHandle?.abort).not.toHaveBeenCalled() - expect(mockSetJobResultKey).toHaveBeenCalledWith('tbl_1', 'job_1', init.key) - expect(mockMarkJobReady).toHaveBeenCalledWith('tbl_1', 'job_1') + expect(mockSetJobResultKey).toHaveBeenCalledWith('tbl_1', 'ws_1', 'job_1', init.key) + expect(mockMarkJobReady).toHaveBeenCalledWith('tbl_1', 'ws_1', 'job_1') expect(mockAppendTableEvent).toHaveBeenCalledWith( expect.objectContaining({ kind: 'job', type: 'export', status: 'ready', progress: 1 }) ) @@ -186,7 +186,7 @@ describe('runTableExport', () => { await runTableExport(payload) expect(lastHandle?.abort).toHaveBeenCalledTimes(1) - expect(mockMarkJobFailed).toHaveBeenCalledWith('tbl_1', 'job_1', 'boom') + expect(mockMarkJobFailed).toHaveBeenCalledWith('tbl_1', 'ws_1', 'job_1', 'boom') expect(mockAppendTableEvent).toHaveBeenCalledWith( expect.objectContaining({ kind: 'job', type: 'export', status: 'failed', error: 'boom' }) ) diff --git a/apps/sim/lib/table/export-runner.ts b/apps/sim/lib/table/export-runner.ts index d9b6c190298..f85f0d86f1e 100644 --- a/apps/sim/lib/table/export-runner.ts +++ b/apps/sim/lib/table/export-runner.ts @@ -11,11 +11,11 @@ import { toCsvRow, } from '@/lib/table/export-format' import { - markJobFailed, - markJobReady, + markJobFailedInWorkspace, + markJobReadyInWorkspace, selectExportRowPage, - setJobResultKey, - updateJobProgress, + setJobResultKeyInWorkspace, + updateJobProgressInWorkspace, } from '@/lib/table/jobs/service' import { getTableById } from '@/lib/table/service' import { @@ -57,7 +57,9 @@ export async function runTableExport(payload: TableExportPayload): Promise try { const table = await getTableById(tableId, { includeArchived: true }) - if (!table) throw new Error(`Export target table ${tableId} not found`) + if (!table || table.workspaceId !== workspaceId) { + throw new Error(`Export target table ${tableId} not found in workspace ${workspaceId}`) + } const columns = table.schema.columns // Stored row data is id-keyed and select cells hold option ids; JSON keys are display @@ -90,7 +92,7 @@ export async function runTableExport(payload: TableExportPayload): Promise let after: { orderKey: string | null; id: string } | null = null while (true) { // Ownership gate before every page: a canceled job stops within one batch. - const owns = await updateJobProgress(tableId, exported, jobId) + const owns = await updateJobProgressInWorkspace(tableId, workspaceId, exported, jobId) if (!owns) throw new JobSupersededError() const page = await selectExportRowPage(table, after, EXPORT_BATCH_SIZE) @@ -117,16 +119,23 @@ export async function runTableExport(payload: TableExportPayload): Promise } if (format === 'json') await handle.write(']') - const ownsFinalize = await updateJobProgress(tableId, exported, jobId) + const ownsFinalize = await updateJobProgressInWorkspace(tableId, workspaceId, exported, jobId) if (!ownsFinalize) throw new JobSupersededError() const uploaded = await handle.complete() uploadedKey = uploaded.key - await setJobResultKey(tableId, jobId, uploaded.key) - - await updateJobProgress(tableId, exported, jobId) + const storedResult = await setJobResultKeyInWorkspace(tableId, workspaceId, jobId, uploaded.key) + if (!storedResult) throw new JobSupersededError() + + const ownsReadyTransition = await updateJobProgressInWorkspace( + tableId, + workspaceId, + exported, + jobId + ) + if (!ownsReadyTransition) throw new JobSupersededError() // Only announce success if we still won the transition (not canceled at the wire). - const becameReady = await markJobReady(tableId, jobId) + const becameReady = await markJobReadyInWorkspace(tableId, workspaceId, jobId) if (becameReady) { void appendTableEvent({ kind: 'job', @@ -140,7 +149,17 @@ export async function runTableExport(payload: TableExportPayload): Promise } else { // Canceled at the very end — the file is orphaned; remove it (janitor would otherwise // only catch it via the pruned job's resultKey). - await deleteFile({ key: uploaded.key, context: 'workspace' }).catch(() => {}) + try { + await deleteFile({ key: uploaded.key, context: 'workspace' }) + } catch (cleanupError) { + logger.warn(`[${requestId}] Failed to delete superseded export`, { + tableId, + workspaceId, + jobId, + key: uploaded.key, + error: getErrorMessage(cleanupError, 'Unknown cleanup error'), + }) + } logger.info(`[${requestId}] Export finished but no longer owns the run`, { tableId, jobId }) } } catch (err) { @@ -148,16 +167,44 @@ export async function runTableExport(payload: TableExportPayload): Promise // in-flight multipart upload (not yet completed) is aborted so no staged parts linger; a // completed-but-unannounced upload is removed by key. if (uploadedKey) { - await deleteFile({ key: uploadedKey, context: 'workspace' }).catch(() => {}) + try { + await deleteFile({ key: uploadedKey, context: 'workspace' }) + } catch (cleanupError) { + logger.warn(`[${requestId}] Failed to delete incomplete export`, { + tableId, + workspaceId, + jobId, + key: uploadedKey, + error: getErrorMessage(cleanupError, 'Unknown cleanup error'), + }) + } } else if (handle) { - await handle.abort().catch(() => {}) + try { + await handle.abort() + } catch (cleanupError) { + logger.warn(`[${requestId}] Failed to abort incomplete export`, { + tableId, + workspaceId, + jobId, + error: getErrorMessage(cleanupError, 'Unknown cleanup error'), + }) + } } if (err instanceof JobSupersededError) { logger.info(`[${requestId}] Export superseded/canceled; stopping`, { tableId, jobId }) } else { const message = getErrorMessage(err, 'Export failed') logger.error(`[${requestId}] Export failed for table ${tableId}:`, err) - await markJobFailed(tableId, jobId, message).catch(() => {}) + try { + await markJobFailedInWorkspace(tableId, workspaceId, jobId, message) + } catch (failureError) { + logger.error(`[${requestId}] Failed to mark export job failed`, { + tableId, + workspaceId, + jobId, + error: getErrorMessage(failureError, 'Unknown job transition error'), + }) + } void appendTableEvent({ kind: 'job', type: 'export', diff --git a/apps/sim/lib/table/import-data.ts b/apps/sim/lib/table/import-data.ts index 041352c0166..593e79a513d 100644 --- a/apps/sim/lib/table/import-data.ts +++ b/apps/sim/lib/table/import-data.ts @@ -8,7 +8,7 @@ import { db } from '@sim/db' import { userTableDefinitions, userTableRows } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' -import { eq } from 'drizzle-orm' +import { and, eq } from 'drizzle-orm' import { OrchestrationError } from '@/lib/core/orchestration/types' import { assertRowCapacity, notifyTableRowUsage } from '@/lib/table/billing' import { CSV_MAX_BATCH_SIZE } from '@/lib/table/import' @@ -128,9 +128,9 @@ export async function bulkInsertImportBatch( ...(data.userId ? { createdBy: data.userId } : {}), })) - await db.transaction(async (trx) => { + const inserted = await db.transaction(async (trx) => { await guardBatch(trx, data.tableId, revalidate) - await mutateTableRowsWithSecretProvenance(trx, { + return mutateTableRowsWithSecretProvenance(trx, { rows: rowsToInsert.map((row) => ({ rowId: row.id, provenance: createExactEmptyTableRowSecretProvenance(row.data), @@ -142,13 +142,16 @@ export async function bulkInsertImportBatch( .insert(userTableRows) .values(rowsToInsert) .returning({ id: userTableRows.id }) - return { value: undefined, affectedRowIds: inserted.map((row) => row.id) } + return { value: inserted.length, affectedRowIds: inserted.map((row) => row.id) } }, }) }) - logger.info(`[${requestId}] Bulk-imported ${rowsToInsert.length} rows into table ${data.tableId}`) + if (inserted !== rowsToInsert.length) { + throw new Error('Bulk table import inserted an unexpected row count') + } + logger.info(`[${requestId}] Bulk-imported ${inserted} rows into table ${data.tableId}`) return { - inserted: rowsToInsert.length, + inserted, lastOrderKey: orderKeys[orderKeys.length - 1] ?? data.afterOrderKey ?? null, } } @@ -164,7 +167,11 @@ export async function deleteAllTableRows( if (!revalidate) assertRowDelete(table) await db.transaction(async (trx) => { await guardBatch(trx, table.id, revalidate) - await trx.delete(userTableRows).where(eq(userTableRows.tableId, table.id)) + await trx + .delete(userTableRows) + .where( + and(eq(userTableRows.tableId, table.id), eq(userTableRows.workspaceId, table.workspaceId)) + ) }) } @@ -210,7 +217,12 @@ export async function setTableSchemaForImport( await trx .update(userTableDefinitions) .set({ schema, updatedAt: new Date() }) - .where(eq(userTableDefinitions.id, table.id)) + .where( + and( + eq(userTableDefinitions.id, table.id), + eq(userTableDefinitions.workspaceId, table.workspaceId) + ) + ) }) } @@ -231,9 +243,13 @@ async function refreshUnderLock( ): Promise { const fresh = await guardBatch(trx, table.id, async (tx) => { const latest = await getTableById(table.id, { tx, includeArchived: true }) - return latest ?? undefined + if (!latest || latest.workspaceId !== table.workspaceId) { + throw new OrchestrationError('not_found', 'Table not found') + } + return latest }) - return fresh ?? table + if (!fresh) throw new Error('Table refresh did not return a canonical table') + return fresh } /** diff --git a/apps/sim/lib/table/import-runner.test.ts b/apps/sim/lib/table/import-runner.test.ts index b0fae115c83..168bfba8d82 100644 --- a/apps/sim/lib/table/import-runner.test.ts +++ b/apps/sim/lib/table/import-runner.test.ts @@ -40,9 +40,9 @@ vi.mock('@/lib/table/import-data', () => ({ setTableSchemaForImport: vi.fn(), })) vi.mock('@/lib/table/jobs/service', () => ({ - markJobFailed: mockMarkJobFailed, - markJobReady: mockMarkJobReady, - updateJobProgress: mockUpdateJobProgress, + markJobFailedInWorkspace: mockMarkJobFailed, + markJobReadyInWorkspace: mockMarkJobReady, + updateJobProgressInWorkspace: mockUpdateJobProgress, })) vi.mock('@/lib/table/rows/ordering', () => ({ nextImportStartOrderKey: mockNextImportStartOrderKey, @@ -114,6 +114,7 @@ describe('runTableImport source-file cleanup', () => { expect(mockMarkJobFailed).toHaveBeenCalledWith( 'tbl_1', + 'ws_1', 'job_1', expect.stringMatching(/insert-locked/i) ) diff --git a/apps/sim/lib/table/import-runner.ts b/apps/sim/lib/table/import-runner.ts index 669fe7169ee..9afb692daf3 100644 --- a/apps/sim/lib/table/import-runner.ts +++ b/apps/sim/lib/table/import-runner.ts @@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { truncate } from '@sim/utils/string' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { captureServerEvent } from '@/lib/posthog/server' import { buildAutoMapping, @@ -28,7 +29,11 @@ import { deleteAllTableRows, setTableSchemaForImport, } from '@/lib/table/import-data' -import { markJobFailed, markJobReady, updateJobProgress } from '@/lib/table/jobs/service' +import { + markJobFailedInWorkspace, + markJobReadyInWorkspace, + updateJobProgressInWorkspace, +} from '@/lib/table/jobs/service' import { assertRowDelete, assertRowInsert, assertSchemaMutable } from '@/lib/table/mutation-locks' import type { DbTransaction } from '@/lib/table/planner' import { nextImportStartOrderKey, nextImportStartPosition } from '@/lib/table/rows/ordering' @@ -99,9 +104,13 @@ export async function runTableImport(payload: TableImportPayload): Promise let source: Readable | undefined try { - if (!(await updateJobProgress(tableId, 0, importId))) throw new ImportSupersededError() + if (!(await updateJobProgressInWorkspace(tableId, workspaceId, 0, importId))) { + throw new ImportSupersededError() + } const loaded = await getTableById(tableId, { includeArchived: true }) - if (!loaded) throw new Error(`Import target table ${tableId} not found`) + if (!loaded || loaded.workspaceId !== workspaceId) { + throw new Error(`Import target table ${tableId} not found in workspace ${workspaceId}`) + } const table = loaded // Every mode ends in row inserts, and `replace` deletes first. Assert both @@ -118,20 +127,29 @@ export async function runTableImport(payload: TableImportPayload): Promise // file through. Rows already committed stay — as with an explicit cancel. const revalidateInsert = async (trx: DbTransaction) => { const fresh = await getTableById(tableId, { tx: trx, includeArchived: true }) - if (fresh) assertRowInsert(fresh) - return fresh ?? undefined + if (!fresh || fresh.workspaceId !== workspaceId) { + throw new OrchestrationError('not_found', 'Table not found') + } + assertRowInsert(fresh) + return fresh } /** Same guard for the replace-mode wipe, which lands before the first batch. */ const revalidateDelete = async (trx: DbTransaction) => { const fresh = await getTableById(tableId, { tx: trx, includeArchived: true }) - if (fresh) assertRowDelete(fresh) - return fresh ?? undefined + if (!fresh || fresh.workspaceId !== workspaceId) { + throw new OrchestrationError('not_found', 'Table not found') + } + assertRowDelete(fresh) + return fresh } /** Same guard for the inferred-schema write and `createColumns`. */ const revalidateSchema = async (trx: DbTransaction) => { const fresh = await getTableById(tableId, { tx: trx, includeArchived: true }) - if (fresh) assertSchemaMutable(fresh) - return fresh ?? undefined + if (!fresh || fresh.workspaceId !== workspaceId) { + throw new OrchestrationError('not_found', 'Table not found') + } + assertSchemaMutable(fresh) + return fresh } // Total byte size for the progress estimate — a cheap HEAD, no download. May be null on @@ -190,7 +208,7 @@ export async function runTableImport(payload: TableImportPayload): Promise * map onto the existing schema, optionally auto-creating `createColumns` first. */ const resolveSetup = async () => { - if (!(await updateJobProgress(tableId, inserted, importId))) { + if (!(await updateJobProgressInWorkspace(tableId, workspaceId, inserted, importId))) { throw new ImportSupersededError() } const headers = csvHeaders @@ -260,7 +278,7 @@ export async function runTableImport(payload: TableImportPayload): Promise // Ownership gate before every insert: once this run loses the table (cancel/supersede), // updateJobProgress returns false and we stop before writing into a table a newer import // may own. Runs per batch (not just at the emit cadence) so we stop within one batch. - const owns = await updateJobProgress(tableId, inserted, importId) + const owns = await updateJobProgressInWorkspace(tableId, workspaceId, inserted, importId) if (!owns) throw new ImportSupersededError() const coerced = coerceRowsForTable(rows, schema, headerToColumn, { timezone: payload.timezone, @@ -359,7 +377,7 @@ export async function runTableImport(payload: TableImportPayload): Promise if (sample.length === 0) { // No data rows — fail rather than report a successful empty import (matches the sync route). const message = 'CSV file has no data rows' - await markJobFailed(tableId, importId, message) + await markJobFailedInWorkspace(tableId, workspaceId, importId, message) void appendTableEvent({ kind: 'job', type: 'import', @@ -390,10 +408,10 @@ export async function runTableImport(payload: TableImportPayload): Promise await flush(batch) } - await updateJobProgress(tableId, inserted, importId) + await updateJobProgressInWorkspace(tableId, workspaceId, inserted, importId) // Only announce success if we actually won the transition — a cancel/supersede that landed // right at the end makes this a no-op, and we must not emit a false `ready`. - const becameReady = await markJobReady(tableId, importId) + const becameReady = await markJobReadyInWorkspace(tableId, workspaceId, importId) if (becameReady) { void appendTableEvent({ kind: 'job', @@ -437,7 +455,16 @@ export async function runTableImport(payload: TableImportPayload): Promise const message = getErrorMessage(err, 'Import failed') logger.error(`[${requestId}] Import failed for table ${tableId}:`, err) // Scoped to importId — a no-op if a newer import has taken over. - await markJobFailed(tableId, importId, message).catch(() => {}) + try { + await markJobFailedInWorkspace(tableId, workspaceId, importId, message) + } catch (failureError) { + logger.error(`[${requestId}] Failed to mark import job failed`, { + tableId, + workspaceId, + importId, + error: getErrorMessage(failureError, 'Unknown job transition error'), + }) + } void appendTableEvent({ kind: 'job', type: 'import', diff --git a/apps/sim/lib/table/jobs/service.ts b/apps/sim/lib/table/jobs/service.ts index ca916e689b8..3dbc48eadcd 100644 --- a/apps/sim/lib/table/jobs/service.ts +++ b/apps/sim/lib/table/jobs/service.ts @@ -156,6 +156,37 @@ export async function markTableJobRunning( return inserted.length > 0 } +/** Claims a job only when the canonical table remains in the expected workspace. */ +export async function markTableJobRunningInWorkspace( + tableId: string, + workspaceId: string, + jobId: string, + type: TableJobType, + payload?: unknown +): Promise { + const [definition] = await db + .select({ workspaceId: userTableDefinitions.workspaceId }) + .from(userTableDefinitions) + .where( + and(eq(userTableDefinitions.id, tableId), eq(userTableDefinitions.workspaceId, workspaceId)) + ) + .limit(1) + if (!definition) return false + const inserted = await db + .insert(tableJobs) + .values({ + id: jobId, + tableId, + workspaceId: definition.workspaceId, + type, + status: 'running', + payload: payload ?? null, + }) + .onConflictDoNothing() + .returning({ id: tableJobs.id }) + return inserted.length > 0 +} + /** * Releases a claim taken by {@link markTableJobRunning} for a synchronous job — deletes the * transient claim row. Scoped to `jobId` + still-running so it only clears its own claim, never a @@ -169,6 +200,26 @@ export async function releaseJobClaim(tableId: string, jobId: string): Promise { + const released = await db + .delete(tableJobs) + .where( + and( + eq(tableJobs.id, jobId), + eq(tableJobs.tableId, tableId), + eq(tableJobs.workspaceId, workspaceId), + eq(tableJobs.status, 'running') + ) + ) + .returning({ id: tableJobs.id }) + return released.length > 0 +} + /** * Records job progress (rows processed so far) and bumps `updated_at` so the stale-job janitor * (`cleanup-stale-executions`) sees a live heartbeat. @@ -191,6 +242,21 @@ export async function updateJobProgress( return updated.length > 0 } +/** Updates transfer progress under the job's canonical workspace scope. */ +export async function updateJobProgressInWorkspace( + tableId: string, + workspaceId: string, + rowsProcessed: number, + jobId: string +): Promise { + const updated = await db + .update(tableJobs) + .set({ rowsProcessed, updatedAt: new Date() }) + .where(ownsActiveJobInWorkspace(tableId, workspaceId, jobId)) + .returning({ id: tableJobs.id }) + return updated.length > 0 +} + /** * Reads the persisted progress of an in-flight job this worker still owns (`null` when the job * was canceled/superseded). A retried run seeds its counter from this so progress stays @@ -340,6 +406,24 @@ export async function setJobResultKey( .where(ownsActiveJob(tableId, jobId)) } +/** Stamps an export result only while the canonical workspace-scoped job is active. */ +export async function setJobResultKeyInWorkspace( + tableId: string, + workspaceId: string, + jobId: string, + resultKey: string +): Promise { + const updated = await db + .update(tableJobs) + .set({ + payload: sql`coalesce(${tableJobs.payload}, '{}'::jsonb) || jsonb_build_object('resultKey', ${resultKey}::text)`, + updatedAt: new Date(), + }) + .where(ownsActiveJobInWorkspace(tableId, workspaceId, jobId)) + .returning({ id: tableJobs.id }) + return updated.length > 0 +} + /** Shared WHERE for terminal transitions: this job run, and still in-flight (write-once). */ function ownsActiveJob(tableId: string, jobId: string) { return and( @@ -349,6 +433,15 @@ function ownsActiveJob(tableId: string, jobId: string) { ) } +function ownsActiveJobInWorkspace(tableId: string, workspaceId: string, jobId: string) { + return and( + eq(tableJobs.id, jobId), + eq(tableJobs.tableId, tableId), + eq(tableJobs.workspaceId, workspaceId), + eq(tableJobs.status, 'running') + ) +} + /** * Marks a job complete. No-op unless it's still this in-flight run. Returns whether it * transitioned, so the worker only emits the `ready` event when it actually won (and not after a @@ -364,6 +457,21 @@ export async function markJobReady(tableId: string, jobId: string): Promise 0 } +/** Completes a transfer only while its canonical workspace-scoped job is active. */ +export async function markJobReadyInWorkspace( + tableId: string, + workspaceId: string, + jobId: string +): Promise { + const now = new Date() + const updated = await db + .update(tableJobs) + .set({ status: 'ready', error: null, completedAt: now, updatedAt: now }) + .where(ownsActiveJobInWorkspace(tableId, workspaceId, jobId)) + .returning({ id: tableJobs.id }) + return updated.length > 0 +} + /** * Marks a job failed, leaving any already-committed work in place. No-op unless it's still this * in-flight run (so a stale worker can't clobber a newer job or a cancel). @@ -376,6 +484,22 @@ export async function markJobFailed(tableId: string, jobId: string, error: strin .where(ownsActiveJob(tableId, jobId)) } +/** Fails a transfer only while its canonical workspace-scoped job is active. */ +export async function markJobFailedInWorkspace( + tableId: string, + workspaceId: string, + jobId: string, + error: string +): Promise { + const now = new Date() + const updated = await db + .update(tableJobs) + .set({ status: 'failed', error: error.slice(0, 2000), completedAt: now, updatedAt: now }) + .where(ownsActiveJobInWorkspace(tableId, workspaceId, jobId)) + .returning({ id: tableJobs.id }) + return updated.length > 0 +} + /** * Marks an in-flight job canceled (user-initiated). No-op unless it's still running. The * worker's next ownership check then returns `false` and it stops; committed work is left in diff --git a/apps/sim/lib/table/orchestration/columns.ts b/apps/sim/lib/table/orchestration/columns.ts index 71048def646..48f8f5ffd3d 100644 --- a/apps/sim/lib/table/orchestration/columns.ts +++ b/apps/sim/lib/table/orchestration/columns.ts @@ -9,6 +9,7 @@ import { generateRequestId } from '@/lib/core/utils/request' import { columnMatchesRef, getColumnId } from '@/lib/table/column-keys' import { columnTypeById } from '@/lib/table/column-types' import { + type ColumnMutationOptions, renameColumn, updateColumnConstraints, updateColumnCurrency, @@ -22,6 +23,12 @@ import type { ColumnType, SelectOption, TableDefinition, TableLockKind } from '@ const logger = createLogger('TableColumnOrchestration') +function workspaceMutationOptions( + expectedWorkspaceId: string | undefined +): [] | [ColumnMutationOptions] { + return expectedWorkspaceId ? [{ expectedWorkspaceId }] : [] +} + export interface PerformUpdateTableColumnParams { table: TableDefinition columnName: string @@ -37,6 +44,8 @@ export interface PerformUpdateTableColumnParams { currencyCode?: string } requestId?: string + expectedWorkspaceId?: string + recordAudit?: boolean /** Forwarded to the audit record for IP / user-agent capture. */ request?: OrchestrationRequestContext } @@ -174,7 +183,8 @@ export async function performUpdateTableColumn( ...(updates.unique !== undefined ? { unique: updates.unique } : {}), ...renameWithTypedWrite, }, - requestId + requestId, + ...workspaceMutationOptions(params.expectedWorkspaceId) ) } else if (updates.currencyCode !== undefined) { // Re-denominating an existing currency column: schema-only, no cell @@ -189,7 +199,8 @@ export async function performUpdateTableColumn( ...(updates.unique !== undefined ? { unique: updates.unique } : {}), ...renameWithTypedWrite, }, - requestId + requestId, + ...workspaceMutationOptions(params.expectedWorkspaceId) ) } else if (options !== undefined || updates.multiple !== undefined) { updated = await updateColumnOptions( @@ -202,7 +213,8 @@ export async function performUpdateTableColumn( ...(updates.unique !== undefined ? { unique: updates.unique } : {}), ...renameWithTypedWrite, }, - requestId + requestId, + ...workspaceMutationOptions(params.expectedWorkspaceId) ) } @@ -217,7 +229,8 @@ export async function performUpdateTableColumn( ...(updates.unique !== undefined ? { unique: updates.unique } : {}), ...(updates.name ? { newName: updates.name } : {}), }, - requestId + requestId, + ...workspaceMutationOptions(params.expectedWorkspaceId) ) } @@ -229,7 +242,8 @@ export async function performUpdateTableColumn( if (updates.name && !updated) { updated = await renameColumn( { tableId, oldName: columnRef, newName: updates.name }, - requestId + requestId, + ...workspaceMutationOptions(params.expectedWorkspaceId) ) } } catch (error) { @@ -252,17 +266,19 @@ export async function performUpdateTableColumn( return fail('No updates specified', 'validation') } - recordAudit({ - workspaceId: table.workspaceId, - actorId: userId, - action: AuditAction.TABLE_UPDATED, - resourceType: AuditResourceType.TABLE, - resourceId: tableId, - resourceName: table.name, - description: `Updated column "${columnName}" in table "${table.name}"`, - metadata: { columnName, updates }, - ...(request ? { request } : {}), - }) + if (params.recordAudit !== false) { + recordAudit({ + workspaceId: table.workspaceId, + actorId: userId, + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: tableId, + resourceName: table.name, + description: `Updated column "${columnName}" in table "${table.name}"`, + metadata: { columnName, updates }, + ...(request ? { request } : {}), + }) + } return { success: true, table: updated } } diff --git a/apps/sim/lib/table/orchestration/export-resource.ts b/apps/sim/lib/table/orchestration/export-resource.ts index 381ae860051..6f8410d5c1a 100644 --- a/apps/sim/lib/table/orchestration/export-resource.ts +++ b/apps/sim/lib/table/orchestration/export-resource.ts @@ -9,7 +9,7 @@ import { OrchestrationError } from '@/lib/core/orchestration/types' import { runDetached } from '@/lib/core/utils/background' import { TABLE_LIMITS } from '@/lib/table/constants' import { runTableExport, type TableExportPayload } from '@/lib/table/export-runner' -import { markJobCanceled, markJobFailed, markTableJobRunning } from '@/lib/table/jobs/service' +import { markTableJobRunningInWorkspace } from '@/lib/table/jobs/service' import type { TableDefinition, TableExportJobPayload } from '@/lib/table/types' export type TableExportRecord = typeof tableJobs.$inferSelect @@ -20,7 +20,15 @@ export async function createTableExportResource(params: { }): Promise { const exportId = generateId() const payload: TableExportJobPayload = { format: params.format } - if (!(await markTableJobRunning(params.table.id, exportId, 'export', payload))) { + if ( + !(await markTableJobRunningInWorkspace( + params.table.id, + params.table.workspaceId, + exportId, + 'export', + payload + )) + ) { throw new OrchestrationError('conflict', 'Failed to start export') } const runnerPayload: TableExportPayload = { @@ -48,11 +56,12 @@ export async function createTableExportResource(params: { runDetached('table-export', () => runTableExport(runnerPayload)) } } catch (error) { - await markJobFailed( - params.table.id, + await markExportFailed({ + tableId: params.table.id, + workspaceId: params.table.workspaceId, exportId, - getErrorMessage(error, 'Failed to dispatch table export') - ) + error: getErrorMessage(error, 'Failed to dispatch table export'), + }) throw error } } @@ -62,7 +71,7 @@ export async function createTableExportResource(params: { export async function requireTableExport( exportId: string, - workspaceId: string + assertedWorkspaceId?: string ): Promise { const [record] = await db .select() @@ -70,8 +79,10 @@ export async function requireTableExport( .where( and( eq(tableJobs.id, exportId), - eq(tableJobs.workspaceId, workspaceId), - eq(tableJobs.type, 'export') + eq(tableJobs.type, 'export'), + assertedWorkspaceId === undefined + ? undefined + : eq(tableJobs.workspaceId, assertedWorkspaceId) ) ) .limit(1) @@ -86,10 +97,57 @@ export async function cancelTableExportResource( if (record.status !== 'running') { throw new OrchestrationError('conflict', `Table export is ${publicExportStatus(record.status)}`) } - await markJobCanceled(record.tableId, record.id) + const now = new Date() + const canceled = await db + .update(tableJobs) + .set({ status: 'canceled', completedAt: now, updatedAt: now }) + .where( + and( + eq(tableJobs.id, record.id), + eq(tableJobs.tableId, record.tableId), + eq(tableJobs.workspaceId, record.workspaceId), + eq(tableJobs.type, 'export'), + eq(tableJobs.status, 'running') + ) + ) + .returning({ id: tableJobs.id }) + if (canceled.length === 0) { + const current = await requireTableExport(record.id, record.workspaceId) + if (current.status === 'canceled') return current + throw new OrchestrationError( + 'conflict', + `Table export is ${publicExportStatus(current.status)}` + ) + } return requireTableExport(record.id, record.workspaceId) } +async function markExportFailed(params: { + tableId: string + workspaceId: string + exportId: string + error: string +}): Promise { + const now = new Date() + await db + .update(tableJobs) + .set({ + status: 'failed', + error: params.error.slice(0, 2000), + completedAt: now, + updatedAt: now, + }) + .where( + and( + eq(tableJobs.id, params.exportId), + eq(tableJobs.tableId, params.tableId), + eq(tableJobs.workspaceId, params.workspaceId), + eq(tableJobs.type, 'export'), + eq(tableJobs.status, 'running') + ) + ) +} + export function toV2TableExport(record: TableExportRecord, queued = false): V2TableExport { const payload = record.payload as TableExportJobPayload | null if (!payload?.format) throw new Error(`Table export ${record.id} has no format`) diff --git a/apps/sim/lib/table/orchestration/import-resource.ts b/apps/sim/lib/table/orchestration/import-resource.ts index 8cbb8cae36c..b61cab84a64 100644 --- a/apps/sim/lib/table/orchestration/import-resource.ts +++ b/apps/sim/lib/table/orchestration/import-resource.ts @@ -1,5 +1,7 @@ +import type { Principal } from '@sim/auth/principal' import { db } from '@sim/db' import { tableJobs } from '@sim/db/schema' +import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, eq } from 'drizzle-orm' @@ -22,13 +24,14 @@ import { findActiveFolder } from '@/lib/folders/queries' import { getWorkspaceTableLimits } from '@/lib/table/billing' import { CSV_MAX_FILE_SIZE_BYTES, CSV_MAX_FILE_SIZE_MESSAGE } from '@/lib/table/import' import { runTableImport, type TableImportPayload } from '@/lib/table/import-runner' -import { markJobCanceled, markJobFailed, markTableJobRunning } from '@/lib/table/jobs/service' +import { markTableJobRunningInWorkspace } from '@/lib/table/jobs/service' import { assertRowDelete, assertRowInsert } from '@/lib/table/mutation-locks' import { createTable, getTableById } from '@/lib/table/service' import type { TableImportJobPayload } from '@/lib/table/types' import { getWorkspaceFile, type WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import { abortUploadSession, + assertUploadSessionAuthBinding, type CreatedUploadSession, createUploadSession, getOwnedUploadSession, @@ -37,9 +40,11 @@ import { import { getUserSettings } from '@/lib/users/queries' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' -type TableImportStatus = 'uploading' | 'running' | 'ready' | 'failed' | 'canceled' +const logger = createLogger('TableImportResource') -interface TableImportResource { +type TableImportStatus = 'uploading' | 'running' | 'ready' | 'failed' | 'canceled' | 'expired' + +export interface TableImportResource { id: string workspaceId: string userId: string @@ -55,18 +60,24 @@ interface TableImportResource { completedAt: Date | null } -interface CreateTableImportResult { +export interface CreateTableImportResult { record: TableImportResource upload: CreatedUploadSession | null } -export async function createTableImportResource( - body: V2CreateTableImportBody, - userId: string, - localOrigin: string, +interface AuthorizedTableImportResourceParams { + body: V2CreateTableImportBody + userId: string + principal?: Principal + localOrigin?: string resolvedFolderId?: string | null + workspaceFile?: WorkspaceFileRecord +} + +async function createTableImportResourceCore( + params: AuthorizedTableImportResourceParams ): Promise { - await assertWorkspaceWrite(userId, body.workspaceId) + const { body, userId, principal, localOrigin, resolvedFolderId, workspaceFile } = params await validateTarget(body.workspaceId, body.target, resolvedFolderId) const importId = generateId() const options = importOptions(body) @@ -80,6 +91,7 @@ export async function createTableImportResource( id: importId, workspaceId: body.workspaceId, userId, + ...(principal ? { principal } : {}), purpose: 'table_import', fileName: body.source.name, contentType: body.source.contentType, @@ -90,7 +102,7 @@ export async function createTableImportResource( return { record: resourceFromUpload(upload, body), upload } } - const file = await requireWorkspaceSource(body.workspaceId, body.source.fileId) + const file = await requireWorkspaceSource(body.workspaceId, body.source.fileId, workspaceFile) assertCsvFileName(file.name) return { record: await startTableImport({ @@ -110,15 +122,36 @@ export async function createTableImportResource( } } +export async function createAuthorizedTableImportResource( + params: AuthorizedTableImportResourceParams & { principal: Principal } +): Promise { + return createTableImportResourceCore(params) +} + +/** Legacy internal resource entry point retained until internal JWTs carry signed workspace scope. */ +export async function createTableImportResource( + body: V2CreateTableImportBody, + userId: string, + localOrigin: string, + resolvedFolderId?: string | null +): Promise { + await assertWorkspaceWrite(userId, body.workspaceId) + return createTableImportResourceCore({ + body, + userId, + localOrigin, + resolvedFolderId, + }) +} + export async function startUploadedTableImport( upload: UploadSessionRecord ): Promise { const body = tableImportBodyFromUpload(upload) const workspaceId = body.workspaceId - const existing = await findOwnedTableImport({ + const existing = await findTableImportResource({ importId: upload.id, - workspaceId, - userId: upload.userId, + assertedWorkspaceId: workspaceId, }) if (existing) return existing const storedFolderId = upload.metadata.tableImportFolderId @@ -145,6 +178,24 @@ export async function startUploadedTableImport( }) } +export async function getPrincipalTableImportUpload(params: { + importId: string + assertedWorkspaceId?: string + principal: Principal + uploadToken: string +}): Promise { + const upload = await getOwnedUploadSession({ + uploadId: params.importId, + workspaceId: params.assertedWorkspaceId, + purpose: 'table_import', + uploadToken: params.uploadToken, + principal: params.principal, + }) + tableImportBodyFromUpload(upload) + return upload +} + +/** Legacy internal lookup retained until its bearer token can bind a full Principal. */ export async function getOwnedTableImportUpload(params: { importId: string workspaceId: string @@ -162,6 +213,16 @@ export async function getOwnedTableImportUpload(params: { return upload } +export async function abortAuthorizedTableImportUpload( + upload: UploadSessionRecord, + principal: Principal +): Promise { + assertUploadSessionAuthBinding(upload, principal) + const body = tableImportBodyFromUpload(upload) + return resourceFromUpload(await abortUploadSession(upload), body) +} + +/** Legacy internal cancellation retained until its bearer token can bind a full Principal. */ export async function abortTableImportUpload(params: { importId: string workspaceId: string @@ -173,20 +234,18 @@ export async function abortTableImportUpload(params: { return resourceFromUpload(await abortUploadSession(upload), body) } -export async function getOwnedTableImport(params: { +export async function getTableImportResource(params: { importId: string - workspaceId: string - userId: string + assertedWorkspaceId?: string }): Promise { - const record = await findOwnedTableImport(params) + const record = await findTableImportResource(params) if (!record) throw new OrchestrationError('not_found', 'Table import not found') return record } -export async function findOwnedTableImport(params: { +export async function findTableImportResource(params: { importId: string - workspaceId: string - userId: string + assertedWorkspaceId?: string }): Promise { const [job] = await db .select() @@ -194,14 +253,15 @@ export async function findOwnedTableImport(params: { .where( and( eq(tableJobs.id, params.importId), - eq(tableJobs.workspaceId, params.workspaceId), - eq(tableJobs.type, 'import') + eq(tableJobs.type, 'import'), + params.assertedWorkspaceId === undefined + ? undefined + : eq(tableJobs.workspaceId, params.assertedWorkspaceId) ) ) .limit(1) if (!job) return null const payload = parseImportJobPayload(job.payload) - if (payload.userId !== params.userId) return null return { id: job.id, workspaceId: job.workspaceId, @@ -219,6 +279,28 @@ export async function findOwnedTableImport(params: { } } +export async function getOwnedTableImport(params: { + importId: string + workspaceId: string + userId: string +}): Promise { + const record = await findOwnedTableImport(params) + if (!record) throw new OrchestrationError('not_found', 'Table import not found') + return record +} + +export async function findOwnedTableImport(params: { + importId: string + workspaceId: string + userId: string +}): Promise { + const record = await findTableImportResource({ + importId: params.importId, + assertedWorkspaceId: params.workspaceId, + }) + return record?.userId === params.userId ? record : null +} + export async function cancelTableImportResource( record: TableImportResource ): Promise { @@ -226,11 +308,34 @@ export async function cancelTableImportResource( if (record.status !== 'running' || !record.tableId) { throw new OrchestrationError('conflict', `Table import is ${publicImportStatus(record.status)}`) } - await markJobCanceled(record.tableId, record.id) - return getOwnedTableImport({ + const now = new Date() + const canceled = await db + .update(tableJobs) + .set({ status: 'canceled', completedAt: now, updatedAt: now }) + .where( + and( + eq(tableJobs.id, record.id), + eq(tableJobs.tableId, record.tableId), + eq(tableJobs.workspaceId, record.workspaceId), + eq(tableJobs.type, 'import'), + eq(tableJobs.status, 'running') + ) + ) + .returning({ id: tableJobs.id }) + if (canceled.length === 0) { + const current = await getTableImportResource({ + importId: record.id, + assertedWorkspaceId: record.workspaceId, + }) + if (current.status === 'canceled') return current + throw new OrchestrationError( + 'conflict', + `Table import is ${publicImportStatus(current.status)}` + ) + } + return getTableImportResource({ importId: record.id, - workspaceId: record.workspaceId, - userId: record.userId, + assertedWorkspaceId: record.workspaceId, }) } @@ -305,7 +410,15 @@ async function startTableImport(params: StartTableImportParams): Promise runTableImport(payload)) } - return getOwnedTableImport({ + return getTableImportResource({ importId: params.id, - workspaceId: params.workspaceId, - userId: params.userId, + assertedWorkspaceId: params.workspaceId, }) } catch (error) { const message = getErrorMessage(error, 'Failed to dispatch table import') - if (tableId) await markJobFailed(tableId, params.id, message).catch(() => {}) + if (tableId) { + try { + await markImportFailed({ + tableId, + workspaceId: params.workspaceId, + importId: params.id, + error: message, + }) + } catch (cleanupError) { + logger.error('Failed to mark table import dispatch failure', { + importId: params.id, + tableId, + workspaceId: params.workspaceId, + error: getErrorMessage(cleanupError, 'Unknown cleanup error'), + }) + } + } if (params.deleteSourceFile) { const { deleteFile } = await import('@/lib/uploads/core/storage-service') - await deleteFile({ key: params.fileKey, context: params.storageContext }).catch(() => {}) + try { + await deleteFile({ key: params.fileKey, context: params.storageContext }) + } catch (cleanupError) { + logger.error('Failed to delete table import source after dispatch failure', { + importId: params.id, + tableId, + workspaceId: params.workspaceId, + storageContext: params.storageContext, + error: getErrorMessage(cleanupError, 'Unknown cleanup error'), + }) + } } throw error } @@ -367,7 +505,7 @@ function resourceFromUpload( target: body.target, options: importOptions(body), tableId: body.target.type === 'existing' ? body.target.tableId : null, - status: upload.status === 'aborted' ? 'canceled' : 'uploading', + status: uploadStatus(upload), rowsProcessed: 0, error: null, createdAt: upload.createdAt, @@ -376,7 +514,7 @@ function resourceFromUpload( } } -function tableImportBodyFromUpload(upload: UploadSessionRecord): V2CreateTableImportBody { +export function tableImportBodyFromUpload(upload: UploadSessionRecord): V2CreateTableImportBody { if (upload.purpose !== 'table_import' || upload.storageContext !== 'table-import') { throw new OrchestrationError('conflict', 'Upload is not a table import') } @@ -445,14 +583,17 @@ async function requireExistingTarget( async function requireWorkspaceSource( workspaceId: string, - fileId: string + fileId: string, + file: WorkspaceFileRecord | undefined ): Promise { - const file = await getWorkspaceFile(workspaceId, fileId, { throwOnError: true }) - if (!file) throw new OrchestrationError('not_found', 'Workspace file not found') - if (file.size > CSV_MAX_FILE_SIZE_BYTES) { + const resolved = file ?? (await getWorkspaceFile(workspaceId, fileId, { throwOnError: true })) + if (!resolved || resolved.id !== fileId || resolved.workspaceId !== workspaceId) { + throw new OrchestrationError('not_found', 'Workspace file not found') + } + if (resolved.size > CSV_MAX_FILE_SIZE_BYTES) { throw new OrchestrationError('validation', CSV_MAX_FILE_SIZE_MESSAGE) } - return file + return resolved } async function assertWorkspaceWrite(userId: string, workspaceId: string): Promise { @@ -462,6 +603,49 @@ async function assertWorkspaceWrite(userId: string, workspaceId: string): Promis } } +function uploadStatus(upload: UploadSessionRecord): TableImportStatus { + switch (upload.status) { + case 'uploading': + case 'completing': + case 'finalizing': + case 'completed': + return 'uploading' + case 'aborting': + case 'aborted': + return 'canceled' + case 'failed': + return 'failed' + case 'expired': + return 'expired' + } +} + +async function markImportFailed(params: { + tableId: string + workspaceId: string + importId: string + error: string +}): Promise { + const now = new Date() + await db + .update(tableJobs) + .set({ + status: 'failed', + error: params.error.slice(0, 2000), + completedAt: now, + updatedAt: now, + }) + .where( + and( + eq(tableJobs.id, params.importId), + eq(tableJobs.tableId, params.tableId), + eq(tableJobs.workspaceId, params.workspaceId), + eq(tableJobs.type, 'import'), + eq(tableJobs.status, 'running') + ) + ) +} + function assertCsvFileName(fileName: string): void { const normalized = fileName.toLowerCase() if (!normalized.endsWith('.csv') && !normalized.endsWith('.tsv')) { diff --git a/apps/sim/lib/table/rows/executions.ts b/apps/sim/lib/table/rows/executions.ts index 21a56cf8333..c453b504b8e 100644 --- a/apps/sim/lib/table/rows/executions.ts +++ b/apps/sim/lib/table/rows/executions.ts @@ -5,7 +5,7 @@ * directly from `@/lib/table/rows/executions`. */ -import { tableRowExecutions } from '@sim/db/schema' +import { tableRowExecutions, userTableRows } from '@sim/db/schema' import { and, eq, inArray, type SQL, sql } from 'drizzle-orm' import type { DbOrTx } from '@/lib/db/types' import { getColumnId } from '@/lib/table/column-keys' @@ -353,13 +353,22 @@ export async function writeExecutionsPatch( export async function stripGroupExecutions( trx: DbOrTx, tableId: string, - groupIds: Iterable + groupIds: Iterable, + options?: { expectedWorkspaceId?: string } ): Promise { const ids = Array.from(new Set(groupIds)) if (ids.length === 0) return - await trx - .delete(tableRowExecutions) - .where( - and(eq(tableRowExecutions.tableId, tableId), inArray(tableRowExecutions.groupId, ids)) as SQL - ) + await trx.delete(tableRowExecutions).where( + and( + eq(tableRowExecutions.tableId, tableId), + inArray(tableRowExecutions.groupId, ids), + options?.expectedWorkspaceId + ? sql`EXISTS ( + SELECT 1 FROM ${userTableRows} + WHERE ${userTableRows.id} = ${tableRowExecutions.rowId} + AND ${userTableRows.workspaceId} = ${options.expectedWorkspaceId} + )` + : undefined + ) as SQL + ) } diff --git a/apps/sim/lib/table/rows/secret-provenance.test.ts b/apps/sim/lib/table/rows/secret-provenance.test.ts index 20240b69f2a..2f7177233eb 100644 --- a/apps/sim/lib/table/rows/secret-provenance.test.ts +++ b/apps/sim/lib/table/rows/secret-provenance.test.ts @@ -464,14 +464,18 @@ describe('table row secret provenance', () => { it('binds derived rows only after their matching sidecars are written', async () => { queueTableRows(userTableRows, [{ id: 'legacy-row' }]) - await updateTableRowsWithDerivedSecretProvenance(dbChainMock.db as unknown as DbTransaction, { - rowWhere: eq(userTableRows.id, 'legacy-row'), - transformation: { - mode: 'remove-columns', - columnIds: ['deleted-column', 'deleted-column'], - }, - }) + const updatedCount = await updateTableRowsWithDerivedSecretProvenance( + dbChainMock.db as unknown as DbTransaction, + { + rowWhere: eq(userTableRows.id, 'legacy-row'), + transformation: { + mode: 'remove-columns', + columnIds: ['deleted-column', 'deleted-column'], + }, + } + ) + expect(updatedCount).toBe(1) expect(dbChainMockFns.execute).toHaveBeenCalledTimes(2) expect(boundArrayValues(dbChainMockFns.execute.mock.calls[0][0])).toEqual([]) expect(sqlText(dbChainMockFns.execute.mock.calls[0][0])).not.toMatch( diff --git a/apps/sim/lib/table/rows/secret-provenance.ts b/apps/sim/lib/table/rows/secret-provenance.ts index cb75cb6abec..82c2710b8c3 100644 --- a/apps/sim/lib/table/rows/secret-provenance.ts +++ b/apps/sim/lib/table/rows/secret-provenance.ts @@ -434,13 +434,13 @@ export async function updateTableRowsWithDerivedSecretProvenance( rowWhere: SQL transformation: DerivedTableRowTransformation } -): Promise { +): Promise { const removedColumnIds = options.transformation.mode === 'remove-columns' ? [...new Set(options.transformation.columnIds)] : [] if (options.transformation.mode === 'remove-columns') { - if (removedColumnIds.length === 0) return + if (removedColumnIds.length === 0) return 0 if ( removedColumnIds.length > MAX_PROVENANCE_COLUMNS_PER_ROW || removedColumnIds.some((columnId) => columnId.length === 0) @@ -481,6 +481,7 @@ export async function updateTableRowsWithDerivedSecretProvenance( : sql`source.provenance_entries` let afterId: string | undefined + let updatedCount = 0 for (;;) { const page = await trx .select({ id: userTableRows.id }) @@ -491,6 +492,7 @@ export async function updateTableRowsWithDerivedSecretProvenance( .for('update') if (page.length === 0) break const rowIds = page.map((row) => row.id) + updatedCount += rowIds.length await trx.execute(sql` WITH source AS MATERIALIZED ( @@ -628,6 +630,7 @@ export async function updateTableRowsWithDerivedSecretProvenance( afterId = rowIds[rowIds.length - 1] if (page.length < QUERY_CHUNK_SIZE) break } + return updatedCount } async function readTableRowsVersion(tableId: string, workspaceId: string): Promise { diff --git a/apps/sim/lib/table/rows/service.ts b/apps/sim/lib/table/rows/service.ts index d0c89850f18..192099216ef 100644 --- a/apps/sim/lib/table/rows/service.ts +++ b/apps/sim/lib/table/rows/service.ts @@ -505,10 +505,23 @@ export async function replaceTableRowsWithTx( // the union of both row sets instead of only the last caller's rows. await acquireRowOrderLock(trx, data.tableId) - const deletedRows = await trx - .delete(userTableRows) - .where(eq(userTableRows.tableId, data.tableId)) - .returning({ id: userTableRows.id }) + const deleteCountRows = await trx.execute<{ count: number | string }>(sql` + WITH deleted AS ( + DELETE FROM ${userTableRows} + WHERE ${and( + eq(userTableRows.tableId, data.tableId), + eq(userTableRows.workspaceId, data.workspaceId) + )} + RETURNING 1 + ) + SELECT count(*)::integer AS count FROM deleted + `) + const [deleteCountRow] = Array.isArray(deleteCountRows) ? deleteCountRows : [] + if (!deleteCountRow) throw new Error('Table row replacement did not return a deleted count') + const deletedCount = Number(deleteCountRow.count) + if (!Number.isSafeInteger(deletedCount) || deletedCount < 0) { + throw new Error('Table row replacement returned an invalid deleted count') + } let insertedCount = 0 if (data.rows.length > 0) { @@ -549,10 +562,10 @@ export async function replaceTableRowsWithTx( } logger.info( - `[${requestId}] Replaced rows in table ${data.tableId}: deleted ${deletedRows.length}, inserted ${insertedCount}` + `[${requestId}] Replaced rows in table ${data.tableId}: deleted ${deletedCount}, inserted ${insertedCount}` ) - return { deletedCount: deletedRows.length, insertedCount } + return { deletedCount, insertedCount } } /** @@ -730,7 +743,13 @@ export async function upsertRow( const [row] = await trx .update(userTableRows) .set({ data: data.data, updatedAt: now }) - .where(eq(userTableRows.id, matchedRowId)) + .where( + and( + eq(userTableRows.id, matchedRowId), + eq(userTableRows.tableId, data.tableId), + eq(userTableRows.workspaceId, data.workspaceId) + ) + ) .returning() if (!row) return { value: undefined, affectedRowIds: [] } return { value: row, affectedRowIds: [row.id] } @@ -1449,6 +1468,33 @@ export async function getRowById( } } +/** + * Verifies an explicit row selection against the canonical table/workspace in + * bounded database chunks without materializing the complete row set. + */ +export async function requireTableRowIds( + tableId: string, + workspaceId: string, + rowIds: string[] +): Promise { + for (let index = 0; index < rowIds.length; index += TABLE_LIMITS.DELETE_BATCH_SIZE) { + const chunk = rowIds.slice(index, index + TABLE_LIMITS.DELETE_BATCH_SIZE) + const [result] = await db + .select({ count: count() }) + .from(userTableRows) + .where( + and( + eq(userTableRows.tableId, tableId), + eq(userTableRows.workspaceId, workspaceId), + inArray(userTableRows.id, chunk) + ) + ) + if (!result || Number(result.count) !== chunk.length) { + throw new OrchestrationError('not_found', 'Row not found') + } + } +} + /** * Fetches the `data` payloads for a set of rows by id, scoped to a table and * workspace. Returns lightweight `{ id, data }` records (no executions) in the @@ -1535,6 +1581,9 @@ export async function updateRow( if (!existingRow) { throw new OrchestrationError('not_found', 'Row not found') } + if (Object.keys(data.data).length === 0 && data.executionsPatch === undefined) { + return existingRow + } // Merge partial update with existing row data so callers can pass only changed fields const mergedData = { @@ -1605,7 +1654,13 @@ export async function updateRow( const updatedRows = await trx .update(userTableRows) .set({ data: persistedData, updatedAt: now }) - .where(eq(userTableRows.id, data.rowId)) + .where( + and( + eq(userTableRows.id, data.rowId), + eq(userTableRows.tableId, data.tableId), + eq(userTableRows.workspaceId, data.workspaceId) + ) + ) .returning({ id: userTableRows.id, updatedAt: userTableRows.updatedAt }) const [updatedRow] = updatedRows if (!updatedRow) throw new Error('Table row no longer exists') @@ -1748,6 +1803,9 @@ export async function updateRowsByFilter( requestId: string ): Promise { assertRowUpdate(table, patchColumnIds(data.data)) + if (Object.keys(data.data).length === 0) { + return { affectedCount: 0, affectedRowIds: [] } + } const tableName = USER_TABLE_ROWS_SQL_NAME @@ -1771,14 +1829,18 @@ export async function updateRowsByFilter( .select({ id: userTableRows.id, data: userTableRows.data }) .from(userTableRows) .where(and(baseConditions, filterClause)) - if (data.limit) { - return base - .orderBy(buildRowOrderBySql(undefined, tableName, table.schema.columns)) - .limit(data.limit) - } return base + .orderBy(buildRowOrderBySql(undefined, tableName, table.schema.columns)) + .limit(data.limit ?? TABLE_LIMITS.MAX_BULK_OPERATION_SIZE + 1) }) + if (matchingRows.length > TABLE_LIMITS.MAX_BULK_OPERATION_SIZE) { + throw new OrchestrationError( + 'validation', + `Cannot update more than ${TABLE_LIMITS.MAX_BULK_OPERATION_SIZE} rows per operation` + ) + } + if (matchingRows.length === 0) { return { affectedCount: 0, affectedRowIds: [] } } @@ -1811,7 +1873,7 @@ export async function updateRowsByFilter( } const uniqueColumns = getUniqueColumns(table.schema) - const uniqueColumnsInUpdate = uniqueColumns.filter((col) => col.name in data.data) + const uniqueColumnsInUpdate = uniqueColumns.filter((col) => getColumnId(col) in data.data) if (uniqueColumnsInUpdate.length > 0) { if (matchingRows.length > 1) { throw new OrchestrationError( @@ -1843,9 +1905,9 @@ export async function updateRowsByFilter( const ids = matchingRows.map((r) => r.id) const patchJson = JSON.stringify(data.data) - await db.transaction(async (trx) => { + const affectedRowIds = await db.transaction(async (trx) => { await setTableTxTimeouts(trx, { statementMs: 60_000 }) - await mutateTableRowsWithSecretProvenance(trx, { + return mutateTableRowsWithSecretProvenance(trx, { rows: ids.map((rowId) => ({ rowId, provenance: data.secretProvenance })), rowState: 'existing', mode: 'merge', @@ -1859,19 +1921,27 @@ export async function updateRowsByFilter( data: sql`${userTableRows.data} || ${patchJson}::jsonb`, updatedAt: now, }) - .where(inArray(userTableRows.id, batchIds)) + .where( + and( + eq(userTableRows.tableId, table.id), + eq(userTableRows.workspaceId, table.workspaceId), + inArray(userTableRows.id, batchIds) + ) + ) .returning({ id: userTableRows.id }) affectedRowIds.push(...updated.map((row) => row.id)) } - return { value: undefined, affectedRowIds } + return { value: affectedRowIds, affectedRowIds } }, }) }) - logger.info(`[${requestId}] Updated ${matchingRows.length} rows in table ${table.id}`) + logger.info(`[${requestId}] Updated ${affectedRowIds.length} rows in table ${table.id}`) - const oldRows = new Map(matchingRows.map((r) => [r.id, r.data as RowData])) - const updatedRows: TableRow[] = matchingRows.map((r) => ({ + const affectedRowIdSet = new Set(affectedRowIds) + const affectedRows = matchingRows.filter((row) => affectedRowIdSet.has(row.id)) + const oldRows = new Map(affectedRows.map((r) => [r.id, r.data as RowData])) + const updatedRows: TableRow[] = affectedRows.map((r) => ({ id: r.id, data: { ...(r.data as RowData), ...data.data }, executions: {}, @@ -1879,28 +1949,32 @@ export async function updateRowsByFilter( createdAt: now, updatedAt: now, })) - void fireTableTrigger( - table.id, - table.name, - 'update', - updatedRows, - oldRows, - table.schema, - requestId - ) - void runWorkflowColumn({ - tableId: table.id, - workspaceId: table.workspaceId, - rowIds: updatedRows.map((r) => r.id), - mode: 'new', - isManualRun: false, - requestId, - triggeredByUserId: data.actorUserId, - }).catch((err) => logger.error(`[${requestId}] auto-dispatch (updateRowsByFilter) failed:`, err)) + if (updatedRows.length > 0) { + void fireTableTrigger( + table.id, + table.name, + 'update', + updatedRows, + oldRows, + table.schema, + requestId + ) + void runWorkflowColumn({ + tableId: table.id, + workspaceId: table.workspaceId, + rowIds: updatedRows.map((r) => r.id), + mode: 'new', + isManualRun: false, + requestId, + triggeredByUserId: data.actorUserId, + }).catch((err) => + logger.error(`[${requestId}] auto-dispatch (updateRowsByFilter) failed:`, err) + ) + } return { - affectedCount: matchingRows.length, - affectedRowIds: ids, + affectedCount: affectedRowIds.length, + affectedRowIds, } } @@ -2038,9 +2112,9 @@ export async function batchUpdateRows( const now = new Date() - await db.transaction(async (trx) => { + const affectedRowIds = await db.transaction(async (trx) => { await setTableTxTimeouts(trx, { statementMs: 60_000 }) - await mutateTableRowsWithSecretProvenance(trx, { + return mutateTableRowsWithSecretProvenance(trx, { rows: mergedUpdates.map((update) => ({ rowId: update.rowId, provenance: data.secretProvenanceByRowId?.[update.rowId], @@ -2055,7 +2129,13 @@ export async function batchUpdateRows( trx .update(userTableRows) .set({ data: jsonbMergePatch(changedColumnIds, mergedData), updatedAt: now }) - .where(eq(userTableRows.id, rowId)) + .where( + and( + eq(userTableRows.id, rowId), + eq(userTableRows.tableId, data.tableId), + eq(userTableRows.workspaceId, data.workspaceId) + ) + ) .returning({ id: userTableRows.id }) ) const updatedRows = await Promise.all(dataPromises) @@ -2064,42 +2144,47 @@ export async function batchUpdateRows( await writeExecutionsPatch(trx, data.tableId, rowId, executionsPatch) } } - return { value: undefined, affectedRowIds } + return { value: affectedRowIds, affectedRowIds } }, }) }) - logger.info(`[${requestId}] Batch updated ${mergedUpdates.length} rows in table ${data.tableId}`) + logger.info(`[${requestId}] Batch updated ${affectedRowIds.length} rows in table ${data.tableId}`) + const affectedRowIdSet = new Set(affectedRowIds) const oldRowsForTrigger = new Map( - data.updates.map((u) => [u.rowId, existingMap.get(u.rowId)!.data]) + data.updates + .filter((update) => affectedRowIdSet.has(update.rowId)) + .map((update) => [update.rowId, existingMap.get(update.rowId)!.data]) ) - const updatedRowsForTrigger: TableRow[] = mergedUpdates.map( - ({ rowId, mergedData, mergedExecutions }) => ({ + const updatedRowsForTrigger: TableRow[] = mergedUpdates + .filter((update) => affectedRowIdSet.has(update.rowId)) + .map(({ rowId, mergedData, mergedExecutions }) => ({ id: rowId, data: mergedData, executions: mergedExecutions, position: 0, createdAt: now, updatedAt: now, - }) - ) - void fireTableTrigger( - data.tableId, - table.name, - 'update', - updatedRowsForTrigger, - oldRowsForTrigger, - table.schema, - requestId - ) + })) + if (updatedRowsForTrigger.length > 0) { + void fireTableTrigger( + data.tableId, + table.name, + 'update', + updatedRowsForTrigger, + oldRowsForTrigger, + table.schema, + requestId + ) + } // Per-row cancel+rerun for in-flight downstream groups whose deps just // changed — same orchestration as single-row `updateRow`. Without this, // batch updates would leave running workflows reading stale dep values. // Each row needs its own cancel + manual-incomplete dispatch because // `cancelWorkflowGroupRuns`'s `groupIds` filter is per-row. const rowsWithInFlightDownstream = mergedUpdates.filter( - (u) => u.inFlightDownstreamGroups.length > 0 + (update) => affectedRowIdSet.has(update.rowId) && update.inFlightDownstreamGroups.length > 0 ) if (rowsWithInFlightDownstream.length > 0) { void (async () => { @@ -2127,19 +2212,21 @@ export async function batchUpdateRows( } })() } - void runWorkflowColumn({ - tableId: table.id, - workspaceId: table.workspaceId, - rowIds: updatedRowsForTrigger.map((r) => r.id), - mode: 'new', - isManualRun: false, - requestId, - triggeredByUserId: data.actorUserId, - }).catch((err) => logger.error(`[${requestId}] auto-dispatch (batchUpdateRows) failed:`, err)) + if (updatedRowsForTrigger.length > 0) { + void runWorkflowColumn({ + tableId: table.id, + workspaceId: table.workspaceId, + rowIds: updatedRowsForTrigger.map((r) => r.id), + mode: 'new', + isManualRun: false, + requestId, + triggeredByUserId: data.actorUserId, + }).catch((err) => logger.error(`[${requestId}] auto-dispatch (batchUpdateRows) failed:`, err)) + } return { - affectedCount: mergedUpdates.length, - affectedRowIds: mergedUpdates.map((u) => u.rowId), + affectedCount: affectedRowIds.length, + affectedRowIds, } } @@ -2180,32 +2267,37 @@ export async function deleteRowsByFilter( .select({ id: userTableRows.id, position: userTableRows.position }) .from(userTableRows) .where(and(baseConditions, filterClause)) - if (data.limit) { - return base - .orderBy(buildRowOrderBySql(undefined, tableName, table.schema.columns)) - .limit(data.limit) - } return base + .orderBy(buildRowOrderBySql(undefined, tableName, table.schema.columns)) + .limit(data.limit ?? TABLE_LIMITS.MAX_BULK_OPERATION_SIZE + 1) }) + if (matchingRows.length > TABLE_LIMITS.MAX_BULK_OPERATION_SIZE) { + throw new OrchestrationError( + 'validation', + `Cannot delete more than ${TABLE_LIMITS.MAX_BULK_OPERATION_SIZE} rows per operation` + ) + } + if (matchingRows.length === 0) { return { affectedCount: 0, affectedRowIds: [] } } const rowIds = matchingRows.map((r) => r.id) - await deleteOrderedRowsByIds({ + const deletedRows = await deleteOrderedRowsByIds({ tableId: table.id, workspaceId: table.workspaceId, rowIds, proof, }) + const deletedRowIds = deletedRows.map((row) => row.id) - logger.info(`[${requestId}] Deleted ${matchingRows.length} rows from table ${table.id}`) + logger.info(`[${requestId}] Deleted ${deletedRowIds.length} rows from table ${table.id}`) return { - affectedCount: matchingRows.length, - affectedRowIds: rowIds, + affectedCount: deletedRowIds.length, + affectedRowIds: deletedRowIds, } } diff --git a/apps/sim/lib/table/service.ts b/apps/sim/lib/table/service.ts index 5b98c0fda17..a596ab1244b 100644 --- a/apps/sim/lib/table/service.ts +++ b/apps/sim/lib/table/service.ts @@ -117,7 +117,7 @@ function readLocks(row: { export async function withLockedTable( tableId: string, mutate: (table: TableDefinition, trx: DbTransaction) => Promise, - opts?: { includeArchived?: boolean } + opts?: { includeArchived?: boolean; expectedWorkspaceId?: string } ): Promise { return db.transaction(async (trx) => { await setTableTxTimeouts(trx) @@ -125,7 +125,7 @@ export async function withLockedTable( sql`SELECT pg_advisory_xact_lock(hashtextextended(${`user_table_schema:${tableId}`}, 0))` ) const table = await getTableById(tableId, { tx: trx, includeArchived: opts?.includeArchived }) - if (!table) { + if (!table || (opts?.expectedWorkspaceId && table.workspaceId !== opts.expectedWorkspaceId)) { throw new OrchestrationError('not_found', 'Table not found') } return mutate(table, trx) @@ -729,7 +729,12 @@ export async function addTableColumnsWithTx( await trx .update(userTableDefinitions) .set({ schema: updatedSchema, updatedAt: now }) - .where(eq(userTableDefinitions.id, table.id)) + .where( + and( + eq(userTableDefinitions.id, table.id), + eq(userTableDefinitions.workspaceId, table.workspaceId) + ) + ) logger.info( `[${requestId}] Added ${additions.length} column(s) to table ${table.id}: ${additions.map((c) => c.name).join(', ')}` @@ -779,7 +784,8 @@ export function auditTableColumnsAdded( export async function renameTable( tableId: string, newName: string, - requestId: string + requestId: string, + options?: { expectedWorkspaceId?: string } ): Promise<{ id: string; name: string }> { const nameValidation = validateTableName(newName) if (!nameValidation.valid) { @@ -791,7 +797,15 @@ export async function renameTable( const result = await db .update(userTableDefinitions) .set({ name: newName, updatedAt: now }) - .where(eq(userTableDefinitions.id, tableId)) + .where( + and( + eq(userTableDefinitions.id, tableId), + options?.expectedWorkspaceId + ? eq(userTableDefinitions.workspaceId, options.expectedWorkspaceId) + : undefined, + isNull(userTableDefinitions.archivedAt) + ) + ) // `workspaceId` is selected for the live-list notify below, not for an audit — // the audit moved up to `performRenameTable`. .returning({ id: userTableDefinitions.id, workspaceId: userTableDefinitions.workspaceId }) @@ -1014,7 +1028,7 @@ export async function updateTableMetadata( export async function deleteTable( tableId: string, requestId: string, - options?: { archivedAt?: Date; skipNotify?: boolean } + options?: { archivedAt?: Date; skipNotify?: boolean; expectedWorkspaceId?: string } ): Promise<{ archived: { name: string; workspaceId: string | null } | null }> { const now = options?.archivedAt ?? new Date() // Archiving destroys access to every row, so it is gated on the delete lock. @@ -1026,6 +1040,9 @@ export async function deleteTable( .where( and( eq(userTableDefinitions.id, tableId), + options?.expectedWorkspaceId + ? eq(userTableDefinitions.workspaceId, options.expectedWorkspaceId) + : undefined, isNull(userTableDefinitions.archivedAt), eq(userTableDefinitions.deleteLocked, false) ) @@ -1045,7 +1062,14 @@ export async function deleteTable( workspaceId: userTableDefinitions.workspaceId, }) .from(userTableDefinitions) - .where(eq(userTableDefinitions.id, tableId)) + .where( + and( + eq(userTableDefinitions.id, tableId), + options?.expectedWorkspaceId + ? eq(userTableDefinitions.workspaceId, options.expectedWorkspaceId) + : undefined + ) + ) .limit(1) if (existing && !existing.archivedAt && existing.deleteLocked) { logger.warn('Table mutation blocked by lock', { diff --git a/apps/sim/lib/table/types.ts b/apps/sim/lib/table/types.ts index cc67764b07e..6d95f1c9f54 100644 --- a/apps/sim/lib/table/types.ts +++ b/apps/sim/lib/table/types.ts @@ -905,12 +905,16 @@ export interface DeleteColumnData { /** Payload for `addWorkflowGroup` — atomic insert of a group + its outputs. */ export interface AddWorkflowGroupData { tableId: string + /** Canonical workspace derived from the table by an authorized caller. */ + workspaceId?: string group: WorkflowGroup outputColumns: ColumnDefinition[] /** When `false`, the post-add row-scheduling pass is skipped. Defaults to * `true` (UI behavior). Mothership passes `false` so groups can be staged * without firing every dep-satisfied row. */ autoRun?: boolean + /** Persist auto-run state without dispatching through the primitive. */ + suppressAutoRunDispatch?: boolean /** The member adding the group — billed/gated for the auto-run enrichment pass. */ actorUserId?: string | null } @@ -918,6 +922,8 @@ export interface AddWorkflowGroupData { /** Payload for `updateWorkflowGroup` — diffs outputs and writes columns. */ export interface UpdateWorkflowGroupData { tableId: string + /** Canonical workspace derived from the table by an authorized caller. */ + workspaceId?: string groupId: string workflowId?: string name?: string @@ -941,11 +947,15 @@ export interface UpdateWorkflowGroupData { type?: WorkflowGroupType /** Toggle the group's auto-run flag. Omit to leave it unchanged. */ autoRun?: boolean + /** Skip primitive dispatch when an authorized caller will start the run itself. */ + suppressAutoRunDispatch?: boolean /** The member updating the group — billed/gated for any triggered re-run. */ actorUserId?: string | null } export interface DeleteWorkflowGroupData { tableId: string + /** Canonical workspace derived from the table by an authorized caller. */ + workspaceId?: string groupId: string } diff --git a/apps/sim/lib/table/views/service.test.ts b/apps/sim/lib/table/views/service.test.ts index 24dde87c3fa..29e88f403bd 100644 --- a/apps/sim/lib/table/views/service.test.ts +++ b/apps/sim/lib/table/views/service.test.ts @@ -166,6 +166,24 @@ describe('table-view mutations signal collaborators', () => { expect(mockSignalTableViewsChanged).not.toHaveBeenCalled() }) + it('updateTableView returns the canonical view without writing or signaling on a true no-op', async () => { + queueTableRows(tableViews, [viewRow]) + + const result = await updateTableView({ + viewId: 'view-1', + tableId: 'table-1', + workspaceId: 'ws-1', + name: viewRow.name, + config: viewRow.config, + isDefault: viewRow.isDefault, + columns, + }) + + expect(result).toMatchObject({ id: 'view-1', name: 'My View', isDefault: false }) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + expect(mockSignalTableViewsChanged).not.toHaveBeenCalled() + }) + it('deleteTableView signals when a row was actually deleted', async () => { dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'view-1' }]) diff --git a/apps/sim/lib/table/views/service.ts b/apps/sim/lib/table/views/service.ts index bd3fb64963e..de0c8c24118 100644 --- a/apps/sim/lib/table/views/service.ts +++ b/apps/sim/lib/table/views/service.ts @@ -140,12 +140,18 @@ function toTableView(row: typeof tableViews.$inferSelect, columns: ColumnDefinit /** Every view on a table, oldest first, with stale column references pruned. */ export async function listTableViews( tableId: string, - columns: ColumnDefinition[] + columns: ColumnDefinition[], + workspaceId?: string ): Promise { const rows = await db .select() .from(tableViews) - .where(eq(tableViews.tableId, tableId)) + .where( + and( + eq(tableViews.tableId, tableId), + workspaceId ? eq(tableViews.workspaceId, workspaceId) : undefined + ) + ) .orderBy(asc(tableViews.createdAt), asc(tableViews.id)) return rows.map((row) => toTableView(row, columns)) @@ -155,12 +161,19 @@ export async function listTableViews( export async function getTableView( viewId: string, tableId: string, - columns: ColumnDefinition[] + columns: ColumnDefinition[], + workspaceId?: string ): Promise { const [row] = await db .select() .from(tableViews) - .where(and(eq(tableViews.id, viewId), eq(tableViews.tableId, tableId))) + .where( + and( + eq(tableViews.id, viewId), + eq(tableViews.tableId, tableId), + workspaceId ? eq(tableViews.workspaceId, workspaceId) : undefined + ) + ) .limit(1) return row ? toTableView(row, columns) : null @@ -205,6 +218,7 @@ export async function createTableView(data: CreateTableViewData): Promise { + const outcome = await db.transaction(async (tx) => { // Confirm the target exists BEFORE demoting. The demotion has to run first — // the partial unique index rejects a second default — but on a PATCH naming a // missing view the target update matches nothing, so without this the demote // would still commit and silently clear the table's real default. const [existing] = await tx - .select({ id: tableViews.id }) + .select() .from(tableViews) - .where(and(eq(tableViews.id, data.viewId), eq(tableViews.tableId, data.tableId))) + .where( + and( + eq(tableViews.id, data.viewId), + eq(tableViews.tableId, data.tableId), + data.workspaceId ? eq(tableViews.workspaceId, data.workspaceId) : undefined + ) + ) .limit(1) if (!existing) return null + const nextName = data.name === undefined ? existing.name : normalizeName(data.name) + const storedConfig = (existing.config ?? {}) as TableViewConfig + const nextConfig = + data.config ?? (data.configPatch ? { ...storedConfig, ...data.configPatch } : storedConfig) + const nextIsDefault = data.isDefault ?? existing.isDefault + const changed = + nextName !== existing.name || + JSON.stringify(nextConfig) !== JSON.stringify(storedConfig) || + nextIsDefault !== existing.isDefault + if (!changed) return { row: existing, changed: false as const } + if (data.isDefault === true) { await tx .update(tableViews) @@ -251,6 +282,7 @@ export async function updateTableView(data: UpdateTableViewData): Promise { +export async function deleteTableView( + viewId: string, + tableId: string, + workspaceId?: string +): Promise { const deleted = await db .delete(tableViews) - .where(and(eq(tableViews.id, viewId), eq(tableViews.tableId, tableId))) + .where( + and( + eq(tableViews.id, viewId), + eq(tableViews.tableId, tableId), + workspaceId ? eq(tableViews.workspaceId, workspaceId) : undefined + ) + ) .returning({ id: tableViews.id }) if (deleted.length > 0) { diff --git a/apps/sim/lib/table/workflow-columns.ts b/apps/sim/lib/table/workflow-columns.ts index 0f573f96f7a..ef6aade8368 100644 --- a/apps/sim/lib/table/workflow-columns.ts +++ b/apps/sim/lib/table/workflow-columns.ts @@ -490,10 +490,7 @@ export async function cancelWorkflowGroupRuns( ) const table = await getTableById(tableId) - if (!table) { - logger.warn(`cancelWorkflowGroupRuns: table ${tableId} not found`) - return 0 - } + if (!table) throw new OrchestrationError('not_found', 'Table not found') // Per-row cancel leaves the dispatcher alone — other rows in the same // dispatch keep running. Table-wide cancel must stop it, else the cursor @@ -581,7 +578,13 @@ export async function cancelWorkflowGroupRuns( db .select({ id: userTableRowsTable.id }) .from(userTableRowsTable) - .where(and(eq(userTableRowsTable.tableId, tableId), filterClause)) + .where( + and( + eq(userTableRowsTable.tableId, tableId), + eq(userTableRowsTable.workspaceId, table.workspaceId), + filterClause + ) + ) ) ) } @@ -599,6 +602,7 @@ export async function cancelWorkflowGroupRuns( : Promise.resolve() let cursor: { rowId: string; groupId: string } | undefined let processedCount = 0 + let cancelledCount = 0 let reachedEnd = false const handledGroupIds = new Set() @@ -711,7 +715,7 @@ export async function cancelWorkflowGroupRuns( ) await mapWithConcurrency(mutations, TABLE_CANCELLATION_CONCURRENCY, async (mutation) => { - await updateRow( + const updated = await updateRow( { tableId, rowId: mutation.rowId, @@ -721,12 +725,10 @@ export async function cancelWorkflowGroupRuns( }, table, `wfgrp-cancel-${mutation.rowId}` - ).catch((error) => { - logger.error(`Failed to write cancelled state for row ${mutation.rowId}`, { - error: toError(error).message, - }) - }) + ) + if (!updated) throw new Error('Authoritative cancellation write was rejected') }) + cancelledCount += mutations.reduce((total, mutation) => total + mutation.cancelledCount, 0) if (inFlightRows.length < pageSize) { reachedEnd = true @@ -740,17 +742,28 @@ export async function cancelWorkflowGroupRuns( tableId, maxRows: TABLE_CANCELLATION_MAX_ROWS, }) - await db - .update(tableRowExecutions) - .set({ - status: 'cancelled', - jobId: null, - error: 'Cancelled', - runningBlockIds: [], - cancelledAt: now, - updatedAt: now, - }) - .where(and(...inFlightFilters)) + const rows = await db.execute<{ count: number | string }>(sql` + WITH cancelled AS ( + UPDATE ${tableRowExecutions} + SET + status = 'cancelled', + job_id = NULL, + error = 'Cancelled', + running_block_ids = ARRAY[]::text[], + cancelled_at = ${sql.param(now, tableRowExecutions.cancelledAt)}, + updated_at = ${sql.param(now, tableRowExecutions.updatedAt)} + WHERE ${and(...inFlightFilters)} + RETURNING 1 + ) + SELECT count(*)::integer AS count FROM cancelled + `) + const [countRow] = Array.isArray(rows) ? rows : [] + if (!countRow) throw new Error('Cancellation update did not return an affected count') + const remainingCancelled = Number(countRow.count) + if (!Number.isSafeInteger(remainingCancelled) || remainingCancelled < 0) { + throw new Error('Cancellation update returned an invalid affected count') + } + cancelledCount += remainingCancelled } await tagSweepPromise @@ -787,18 +800,12 @@ export async function cancelWorkflowGroupRuns( .onConflictDoNothing({ target: [tableRowExecutions.rowId, tableRowExecutions.groupId], }) - .catch((error) => { - logger.error( - `Failed to write tombstone for ${tableId}/${rowId}/${tombstone.groupId}`, - { error: toError(error).message } - ) - }) } ) } } - return processedCount + return cancelledCount } /** @@ -832,7 +839,7 @@ export async function runWorkflowColumn(opts: { * callers (row writes, CSV import) → falls back to the workspace billed * account at billing time. */ triggeredByUserId?: string | null -}): Promise<{ dispatchId: string | null }> { +}): Promise<{ dispatchId: string | null; shouldSignalRowsChanged: boolean }> { const { tableId, workspaceId, @@ -849,7 +856,9 @@ export async function runWorkflowColumn(opts: { // Empty `rowIds` array means "scope explicitly empty" — auto-fire callers // (CSV import on zero matches, etc.) end up here. Skip the dispatch entirely // rather than walk the table with a no-match filter. - if (rowIds && rowIds.length === 0) return { dispatchId: null } + if (rowIds && rowIds.length === 0) { + return { dispatchId: null, shouldSignalRowsChanged: false } + } // Lazy imports: `./service` and `./dispatcher` both close cycles back to // this module; `@trigger.dev/sdk` is heavy and only needed on this op. const { getTableById } = await import('@/lib/table/service') @@ -864,8 +873,11 @@ export async function runWorkflowColumn(opts: { // every row write would otherwise produce error-level log spam on every // PATCH/insert. Manual run-column callers always pass `groupIds` so they // can't reach here with an empty target. - if (targetGroups.length === 0) return { dispatchId: null } + if (targetGroups.length === 0) { + return { dispatchId: null, shouldSignalRowsChanged: false } + } const targetGroupIds = targetGroups.map((g) => g.id) + let shouldSignalRowsChanged = false const { bulkClearWorkflowGroupCells, @@ -928,18 +940,22 @@ export async function runWorkflowColumn(opts: { if (!rowIds || rowIds.length === 0) { // Filtered runs cancel only their own scope — a table-wide cancel here // would stop unrelated work on rows outside the filter (or on deselected rows). - await cancelWorkflowGroupRuns(tableId, undefined, { + const cancelled = await cancelWorkflowGroupRuns(tableId, undefined, { groupIds: targetGroupIds, filter, excludeRowIds, spareDispatchId: dispatchId, }) + shouldSignalRowsChanged ||= cancelled > 0 } else { // Per-row cancel — sequential so we don't fan out N parallel // markActiveDispatchesCancelled calls (it's a no-op when rowId is set, // but each call still touches the DB). for (const rowId of rowIds) { - await cancelWorkflowGroupRuns(tableId, rowId, { groupIds: targetGroupIds }) + const cancelled = await cancelWorkflowGroupRuns(tableId, rowId, { + groupIds: targetGroupIds, + }) + shouldSignalRowsChanged ||= cancelled > 0 } } } @@ -955,13 +971,15 @@ export async function runWorkflowColumn(opts: { // filtered scope has none — clearing table-wide would blank rows that don't match the filter. The // dispatcher's per-row pre-stamp still provides instant Pending feedback as it walks. if (!limit && !filter) { - await bulkClearWorkflowGroupCells({ + const clearedRows = await bulkClearWorkflowGroupCells({ tableId, + workspaceId, groups: targetGroups.map((g) => ({ id: g.id, outputs: g.outputs })), rowIds, excludeRowIds, mode, }) + shouldSignalRowsChanged ||= clearedRows } } catch (err) { // Prep failed after the dispatch row was inserted — cancel it so an @@ -991,7 +1009,7 @@ export async function runWorkflowColumn(opts: { logger.info( `[Cascade] [${requestId}] dispatch ${dispatchId} cancelled during prep — not firing` ) - return { dispatchId: null } + return { dispatchId: null, shouldSignalRowsChanged } } logger.info( @@ -1023,7 +1041,7 @@ export async function runWorkflowColumn(opts: { ) } - return { dispatchId } + return { dispatchId, shouldSignalRowsChanged: true } } // ───────────────────────────── Validation ───────────────────────────── @@ -1316,6 +1334,6 @@ export function findSplitGroups( export function assertValidSchema(schema: TableSchema, columnOrder: string[] | undefined): void { const errs = validateSchema(schema, columnOrder) if (errs.length > 0) { - throw new Error(`Schema validation failed: ${errs.join('; ')}`) + throw new OrchestrationError('validation', `Schema validation failed: ${errs.join('; ')}`) } } diff --git a/apps/sim/lib/table/workflow-groups/service.ts b/apps/sim/lib/table/workflow-groups/service.ts index f15421c4c66..394a16922f3 100644 --- a/apps/sim/lib/table/workflow-groups/service.ts +++ b/apps/sim/lib/table/workflow-groups/service.ts @@ -11,6 +11,7 @@ import { db } from '@sim/db' import { userTableDefinitions, userTableRows } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, isNull } from 'drizzle-orm' +import { OrchestrationError } from '@/lib/core/orchestration/types' import type { DbOrTx } from '@/lib/db/types' import { columnMatchesRef, @@ -111,7 +112,9 @@ export async function pruneStaleWorkflowGroupOutputs({ schema: { ...schema, workflowGroups: nextGroups }, updatedAt: new Date(), }) - .where(eq(userTableDefinitions.id, t.id)) + .where( + and(eq(userTableDefinitions.id, t.id), eq(userTableDefinitions.workspaceId, workspaceId)) + ) logger.info(`[${requestId}] Pruned stale workflow=${workflowId} block refs from table ${t.id}`) } @@ -126,86 +129,100 @@ export async function addWorkflowGroup( data: AddWorkflowGroupData, requestId: string ): Promise { - const updatedTable = await withLockedTable(data.tableId, async (table, trx) => { - assertSchemaMutable(table) - const schema = table.schema - const groups = schema.workflowGroups ?? [] - if (groups.some((g) => g.id === data.group.id)) { - throw new Error(`Workflow group "${data.group.id}" already exists`) - } - - const existingNames = new Set(schema.columns.map((c) => c.name.toLowerCase())) - for (const col of data.outputColumns) { - if (!NAME_PATTERN.test(col.name)) { - throw new Error( - `Invalid output column name "${col.name}". Must satisfy ${NAME_PATTERN.source}.` + const updatedTable = await withLockedTable( + data.tableId, + async (table, trx) => { + assertSchemaMutable(table) + const schema = table.schema + const groups = schema.workflowGroups ?? [] + if (groups.some((g) => g.id === data.group.id)) { + throw new OrchestrationError( + 'validation', + `Workflow group "${data.group.id}" already exists` ) } - if (existingNames.has(col.name.toLowerCase())) { - throw new Error(`Column "${col.name}" already exists`) + + const existingNames = new Set(schema.columns.map((c) => c.name.toLowerCase())) + for (const col of data.outputColumns) { + if (!NAME_PATTERN.test(col.name)) { + throw new OrchestrationError( + 'validation', + `Invalid output column name "${col.name}". Must satisfy ${NAME_PATTERN.source}.` + ) + } + if (existingNames.has(col.name.toLowerCase())) { + throw new OrchestrationError('validation', `Column "${col.name}" already exists`) + } + } + + if (schema.columns.length + data.outputColumns.length > TABLE_LIMITS.MAX_COLUMNS_PER_TABLE) { + throw new OrchestrationError( + 'validation', + `Adding ${data.outputColumns.length} columns would exceed the maximum (${TABLE_LIMITS.MAX_COLUMNS_PER_TABLE}).` + ) } - } - if (schema.columns.length + data.outputColumns.length > TABLE_LIMITS.MAX_COLUMNS_PER_TABLE) { - throw new Error( - `Adding ${data.outputColumns.length} columns would exceed the maximum (${TABLE_LIMITS.MAX_COLUMNS_PER_TABLE}).` + // Assign stable ids to the new output columns, then rewrite the group's + // column refs from name → id so outputs/deps/inputMappings key on ids — + // matching the row-data storage key and surviving future renames. + const outputColumns = data.outputColumns.map((col) => + col.id ? col : { ...col, id: generateColumnId() } ) - } + const updatedColumns = [...schema.columns, ...outputColumns] + const idByName = new Map(updatedColumns.map((c) => [c.name, getColumnId(c)])) + const group = remapGroupColumnRefs(data.group, idByName) - // Assign stable ids to the new output columns, then rewrite the group's - // column refs from name → id so outputs/deps/inputMappings key on ids — - // matching the row-data storage key and surviving future renames. - const outputColumns = data.outputColumns.map((col) => - col.id ? col : { ...col, id: generateColumnId() } - ) - const updatedColumns = [...schema.columns, ...outputColumns] - const idByName = new Map(updatedColumns.map((c) => [c.name, getColumnId(c)])) - const group = remapGroupColumnRefs(data.group, idByName) - - const updatedSchema: TableSchema = { - ...schema, - columns: updatedColumns, - workflowGroups: [...groups, group], - } + const updatedSchema: TableSchema = { + ...schema, + columns: updatedColumns, + workflowGroups: [...groups, group], + } - // Keep `metadata.columnOrder` (column ids) in sync — see `addTableColumn`. - // New output columns get appended in the order the caller supplied. - const existingOrder = table.metadata?.columnOrder - let updatedMetadata = table.metadata - if (existingOrder && existingOrder.length > 0) { - const known = new Set(existingOrder) - const append = outputColumns.map(getColumnId).filter((id) => !known.has(id)) - if (append.length > 0) { - updatedMetadata = { ...table.metadata, columnOrder: [...existingOrder, ...append] } + // Keep `metadata.columnOrder` (column ids) in sync — see `addTableColumn`. + // New output columns get appended in the order the caller supplied. + const existingOrder = table.metadata?.columnOrder + let updatedMetadata = table.metadata + if (existingOrder && existingOrder.length > 0) { + const known = new Set(existingOrder) + const append = outputColumns.map(getColumnId).filter((id) => !known.has(id)) + if (append.length > 0) { + updatedMetadata = { ...table.metadata, columnOrder: [...existingOrder, ...append] } + } } - } - assertValidSchema(updatedSchema, updatedMetadata?.columnOrder) + assertValidSchema(updatedSchema, updatedMetadata?.columnOrder) - const now = new Date() - await trx - .update(userTableDefinitions) - .set({ schema: updatedSchema, metadata: updatedMetadata, updatedAt: now }) - .where(eq(userTableDefinitions.id, data.tableId)) + const now = new Date() + await trx + .update(userTableDefinitions) + .set({ schema: updatedSchema, metadata: updatedMetadata, updatedAt: now }) + .where( + and( + eq(userTableDefinitions.id, data.tableId), + eq(userTableDefinitions.workspaceId, table.workspaceId) + ) + ) - logger.info( - `[${requestId}] Added workflow group "${data.group.id}" with ${data.outputColumns.length} output column(s) to table ${data.tableId}` - ) + logger.info( + `[${requestId}] Added workflow group "${data.group.id}" with ${data.outputColumns.length} output column(s) to table ${data.tableId}` + ) - return { - ...table, - schema: updatedSchema, - metadata: updatedMetadata, - updatedAt: now, - } - }) + return { + ...table, + schema: updatedSchema, + metadata: updatedMetadata, + updatedAt: now, + } + }, + { expectedWorkspaceId: data.workspaceId } + ) // Auto-fire existing rows whose deps are already met for the new group. // Fire-and-forget — the dispatcher bounds queue depth (window of 20) and // walks the table in the background. HTTP returns instantly; cells fill // in over the next minutes as the dispatcher walks. Mothership opts out // by setting `autoRun: false`. - if (data.autoRun !== false) { + if (data.autoRun !== false && data.suppressAutoRunDispatch !== true) { void runWorkflowColumn({ tableId: updatedTable.id, workspaceId: updatedTable.workspaceId, @@ -244,8 +261,11 @@ export async function updateWorkflowGroup( // the lock — a concurrent `workflowId` change would make them stale. let resolvedForWorkflowId: string | undefined if (mappingUpdates.length > 0) { + const preTable = await getTableById(data.tableId) + if (!preTable || (data.workspaceId && preTable.workspaceId !== data.workspaceId)) { + throw new OrchestrationError('not_found', 'Table not found') + } try { - const preTable = await getTableById(data.tableId) const preGroup = preTable?.schema.workflowGroups?.find((g) => g.id === data.groupId) const targetWorkflowId = data.workflowId ?? preGroup?.workflowId if (targetWorkflowId) { @@ -287,251 +307,269 @@ export async function updateWorkflowGroup( } const { updatedTable, added, remappedColumnIds, newOutputs, previousAutoRun } = - await withLockedTable(data.tableId, async (table, trx) => { - // Any group patch edits the schema; the stronger destructive assert is - // applied below, only once we know this patch actually drops or remaps - // output columns (a rename / autoRun / mapping-only edit must not need - // the delete lock clear). - assertSchemaMutable(table) - await setTableTxTimeouts(trx, { statementMs: 60_000 }) - - const schema = table.schema - const groups = schema.workflowGroups ?? [] - const groupIndex = groups.findIndex((g) => g.id === data.groupId) - if (groupIndex === -1) { - throw new Error(`Workflow group "${data.groupId}" not found`) - } - const group = groups[groupIndex] - - // Normalize every caller-supplied column reference to its stable id, so - // the diff/splice/clear logic below operates uniformly in id-space (the - // row-data storage key). New output columns get ids first; then output - // `columnName`, deps, input mappings, and mapping-update targets are - // remapped name → id. Callers that already pass ids are unaffected. - const newColDefs = (data.newOutputColumns ?? []).map((col) => - col.id ? col : { ...col, id: generateColumnId() } - ) - const idByName = new Map( - [...schema.columns, ...newColDefs].map((c) => [c.name, getColumnId(c)]) - ) - const remapRef = (ref: string) => idByName.get(ref) ?? ref - const outputsInput = data.outputs?.map((o) => ({ ...o, columnName: remapRef(o.columnName) })) - const dependenciesInput = data.dependencies - ? { columns: data.dependencies.columns?.map(remapRef) } - : undefined - const inputMappingsInput = data.inputMappings?.map((m) => ({ - ...m, - columnName: remapRef(m.columnName), - })) - const mappingUpdatesNorm = mappingUpdates.map((u) => ({ - ...u, - columnName: remapRef(u.columnName), - })) - // Re-key the out-of-lock leaf-type resolution to ids to match. - const remapLeafTypeById = new Map() - for (const [name, type] of remapLeafTypeByColumn) remapLeafTypeById.set(remapRef(name), type) - - // Apply `mappingUpdates` first: each entry repoints an existing output's - // `(blockId, path)` while preserving the column. We patch the **old** view - // of outputs so the downstream `(blockId, path)`-keyed diff doesn't see the - // swap as a remove+add. The corresponding row data is cleared after the - // schema write so stale values from the old source don't linger. - const remappedColumnIds = new Set() - // Per-column type override (keyed by id) resolved (out-of-lock) from the - // new mapping's leaf type. Only populated when a remap actually changes - // the column's type against the fresh schema. - const remappedColumnTypes = new Map() - let oldOutputs = group.outputs - if (mappingUpdatesNorm.length > 0) { - const updateById = new Map(mappingUpdatesNorm.map((u) => [u.columnName, u])) - for (const u of mappingUpdatesNorm) { - const exists = oldOutputs.some((o) => o.columnName === u.columnName) - if (!exists) { - throw new Error( - `Mapping update for unknown column "${u.columnName}" (group ${data.groupId}).` - ) - } + await withLockedTable( + data.tableId, + async (table, trx) => { + // Any group patch edits the schema; the stronger destructive assert is + // applied below, only once we know this patch actually drops or remaps + // output columns (a rename / autoRun / mapping-only edit must not need + // the delete lock clear). + assertSchemaMutable(table) + await setTableTxTimeouts(trx, { statementMs: 60_000 }) + + const schema = table.schema + const groups = schema.workflowGroups ?? [] + const groupIndex = groups.findIndex((g) => g.id === data.groupId) + if (groupIndex === -1) { + throw new OrchestrationError('not_found', `Workflow group "${data.groupId}" not found`) } - oldOutputs = oldOutputs.map((o) => { - const u = updateById.get(o.columnName) - if (!u) return o - remappedColumnIds.add(o.columnName) - return { ...o, blockId: u.blockId, path: u.path } - }) - - // Only apply the out-of-lock leaf-type resolution if the group still - // points at the workflow we resolved against. If a concurrent writer - // changed `workflowId` between phase 1 and now, those types are stale — - // leave column types unchanged (best-effort, same as a resolution - // failure) rather than stamping types from the old workflow. - const finalWorkflowId = data.workflowId ?? group.workflowId - if (remapLeafTypeById.size > 0 && resolvedForWorkflowId !== finalWorkflowId) { - logger.warn( - `[${requestId}] Workflow group "${data.groupId}" workflowId changed between leaf-type resolution and apply; leaving remapped column types unchanged.` - ) - } else { - const colById = new Map(schema.columns.map((c) => [getColumnId(c), c])) + const group = groups[groupIndex] + + // Normalize every caller-supplied column reference to its stable id, so + // the diff/splice/clear logic below operates uniformly in id-space (the + // row-data storage key). New output columns get ids first; then output + // `columnName`, deps, input mappings, and mapping-update targets are + // remapped name → id. Callers that already pass ids are unaffected. + const newColDefs = (data.newOutputColumns ?? []).map((col) => + col.id ? col : { ...col, id: generateColumnId() } + ) + const idByName = new Map( + [...schema.columns, ...newColDefs].map((c) => [c.name, getColumnId(c)]) + ) + const remapRef = (ref: string) => idByName.get(ref) ?? ref + const outputsInput = data.outputs?.map((o) => ({ + ...o, + columnName: remapRef(o.columnName), + })) + const dependenciesInput = data.dependencies + ? { columns: data.dependencies.columns?.map(remapRef) } + : undefined + const inputMappingsInput = data.inputMappings?.map((m) => ({ + ...m, + columnName: remapRef(m.columnName), + })) + const mappingUpdatesNorm = mappingUpdates.map((u) => ({ + ...u, + columnName: remapRef(u.columnName), + })) + // Re-key the out-of-lock leaf-type resolution to ids to match. + const remapLeafTypeById = new Map() + for (const [name, type] of remapLeafTypeByColumn) + remapLeafTypeById.set(remapRef(name), type) + + // Apply `mappingUpdates` first: each entry repoints an existing output's + // `(blockId, path)` while preserving the column. We patch the **old** view + // of outputs so the downstream `(blockId, path)`-keyed diff doesn't see the + // swap as a remove+add. The corresponding row data is cleared after the + // schema write so stale values from the old source don't linger. + const remappedColumnIds = new Set() + // Per-column type override (keyed by id) resolved (out-of-lock) from the + // new mapping's leaf type. Only populated when a remap actually changes + // the column's type against the fresh schema. + const remappedColumnTypes = new Map() + let oldOutputs = group.outputs + if (mappingUpdatesNorm.length > 0) { + const updateById = new Map(mappingUpdatesNorm.map((u) => [u.columnName, u])) for (const u of mappingUpdatesNorm) { - const newType = remapLeafTypeById.get(u.columnName) - if (!newType) continue - const oldType = colById.get(u.columnName)?.type - if (newType !== oldType) { - remappedColumnTypes.set(u.columnName, newType) + const exists = oldOutputs.some((o) => o.columnName === u.columnName) + if (!exists) { + throw new OrchestrationError( + 'validation', + `Mapping update for unknown column "${u.columnName}" (group ${data.groupId}).` + ) + } + } + oldOutputs = oldOutputs.map((o) => { + const u = updateById.get(o.columnName) + if (!u) return o + remappedColumnIds.add(o.columnName) + return { ...o, blockId: u.blockId, path: u.path } + }) + + // Only apply the out-of-lock leaf-type resolution if the group still + // points at the workflow we resolved against. If a concurrent writer + // changed `workflowId` between phase 1 and now, those types are stale — + // leave column types unchanged (best-effort, same as a resolution + // failure) rather than stamping types from the old workflow. + const finalWorkflowId = data.workflowId ?? group.workflowId + if (remapLeafTypeById.size > 0 && resolvedForWorkflowId !== finalWorkflowId) { + logger.warn( + `[${requestId}] Workflow group "${data.groupId}" workflowId changed between leaf-type resolution and apply; leaving remapped column types unchanged.` + ) + } else { + const colById = new Map(schema.columns.map((c) => [getColumnId(c), c])) + for (const u of mappingUpdatesNorm) { + const newType = remapLeafTypeById.get(u.columnName) + if (!newType) continue + const oldType = colById.get(u.columnName)?.type + if (newType !== oldType) { + remappedColumnTypes.set(u.columnName, newType) + } } } } - } - // If the caller passed `outputs`, that's the new full set. If only - // `mappingUpdates` was sent, the new set is the remapped old set. - const newOutputs = outputsInput ?? oldOutputs - // Enrichment outputs all share empty `blockId`/`path`, so keying on those - // alone collapses every sibling to one entry (dropping columns on diff). Key - // on the registry `outputId` when present; fall back to `blockId::path` for - // workflow outputs. - const oldKey = (o: WorkflowGroupOutput) => - o.outputId ? `out::${o.outputId}` : `${o.blockId}::${o.path}` - const oldByKey = new Map(oldOutputs.map((o) => [oldKey(o), o])) - const newByKey = new Map(newOutputs.map((o) => [oldKey(o), o])) - - const removed = oldOutputs.filter((o) => !newByKey.has(oldKey(o))) - const added = newOutputs.filter((o) => !oldByKey.has(oldKey(o))) - const newColById = new Map(newColDefs.map((c) => [getColumnId(c), c])) - - for (const out of added) { - if (!newColById.has(out.columnName)) { - throw new Error( - `Missing column definition for new output "${out.columnName}" (group ${data.groupId}).` - ) + // If the caller passed `outputs`, that's the new full set. If only + // `mappingUpdates` was sent, the new set is the remapped old set. + const newOutputs = outputsInput ?? oldOutputs + // Enrichment outputs all share empty `blockId`/`path`, so keying on those + // alone collapses every sibling to one entry (dropping columns on diff). Key + // on the registry `outputId` when present; fall back to `blockId::path` for + // workflow outputs. + const oldKey = (o: WorkflowGroupOutput) => + o.outputId ? `out::${o.outputId}` : `${o.blockId}::${o.path}` + const oldByKey = new Map(oldOutputs.map((o) => [oldKey(o), o])) + const newByKey = new Map(newOutputs.map((o) => [oldKey(o), o])) + + const removed = oldOutputs.filter((o) => !newByKey.has(oldKey(o))) + const added = newOutputs.filter((o) => !oldByKey.has(oldKey(o))) + const newColById = new Map(newColDefs.map((c) => [getColumnId(c), c])) + + for (const out of added) { + if (!newColById.has(out.columnName)) { + throw new OrchestrationError( + 'validation', + `Missing column definition for new output "${out.columnName}" (group ${data.groupId}).` + ) + } } - } - const removedColumnIds = new Set(removed.map((o) => o.columnName)) - // Both paths strip values out of every row below, so they need the delete - // lock clear as well as the schema lock — same rule as a column drop. - if (removedColumnIds.size > 0 || remappedColumnIds.size > 0) { - assertColumnDestructive(table) - } - let nextColumns = schema.columns - .filter((c) => !removedColumnIds.has(getColumnId(c))) - .map((c) => { - const newType = remappedColumnTypes.get(getColumnId(c)) - return newType ? { ...c, type: newType } : c - }) - if (newColDefs.length > 0) { - // Splice the new column defs into the group's contiguous run rather than - // appending at the end. The desired in-group order is `newOutputs` (the - // sidebar's BFS-of-the-workflow ordering); we walk it, anchor at the first - // surviving sibling's index in `nextColumns`, and emit each output's - // column def in turn. - const groupColIds = new Set(newOutputs.map((o) => o.columnName)) - const firstGroupIdx = nextColumns.findIndex((c) => groupColIds.has(getColumnId(c))) - const anchorIdx = firstGroupIdx === -1 ? nextColumns.length : firstGroupIdx - const orderedGroupCols: ColumnDefinition[] = [] - for (const out of newOutputs) { - const fresh = newColById.get(out.columnName) - if (fresh) { - orderedGroupCols.push(fresh) - } else { - const existing = nextColumns.find((c) => getColumnId(c) === out.columnName) - if (existing) orderedGroupCols.push(existing) + const removedColumnIds = new Set(removed.map((o) => o.columnName)) + // Both paths strip values out of every row below, so they need the delete + // lock clear as well as the schema lock — same rule as a column drop. + if (removedColumnIds.size > 0 || remappedColumnIds.size > 0) { + assertColumnDestructive(table) + } + let nextColumns = schema.columns + .filter((c) => !removedColumnIds.has(getColumnId(c))) + .map((c) => { + const newType = remappedColumnTypes.get(getColumnId(c)) + return newType ? { ...c, type: newType } : c + }) + if (newColDefs.length > 0) { + // Splice the new column defs into the group's contiguous run rather than + // appending at the end. The desired in-group order is `newOutputs` (the + // sidebar's BFS-of-the-workflow ordering); we walk it, anchor at the first + // surviving sibling's index in `nextColumns`, and emit each output's + // column def in turn. + const groupColIds = new Set(newOutputs.map((o) => o.columnName)) + const firstGroupIdx = nextColumns.findIndex((c) => groupColIds.has(getColumnId(c))) + const anchorIdx = firstGroupIdx === -1 ? nextColumns.length : firstGroupIdx + const orderedGroupCols: ColumnDefinition[] = [] + for (const out of newOutputs) { + const fresh = newColById.get(out.columnName) + if (fresh) { + orderedGroupCols.push(fresh) + } else { + const existing = nextColumns.find((c) => getColumnId(c) === out.columnName) + if (existing) orderedGroupCols.push(existing) + } } + const remaining = nextColumns.filter((c) => !groupColIds.has(getColumnId(c))) + nextColumns = [ + ...remaining.slice(0, anchorIdx), + ...orderedGroupCols, + ...remaining.slice(anchorIdx), + ] } - const remaining = nextColumns.filter((c) => !groupColIds.has(getColumnId(c))) - nextColumns = [ - ...remaining.slice(0, anchorIdx), - ...orderedGroupCols, - ...remaining.slice(anchorIdx), - ] - } - - const updatedGroup: WorkflowGroup = { - ...group, - workflowId: data.workflowId ?? group.workflowId, - name: data.name ?? group.name, - dependencies: dependenciesInput ?? group.dependencies, - outputs: newOutputs, - ...(inputMappingsInput !== undefined ? { inputMappings: inputMappingsInput } : {}), - ...(data.deploymentMode !== undefined ? { deploymentMode: data.deploymentMode } : {}), - ...(data.type !== undefined ? { type: data.type } : {}), - ...(data.autoRun !== undefined ? { autoRun: data.autoRun } : {}), - } - // Removed outputs may be referenced as deps by sibling groups; strip those - // refs so we don't leave dangling-column deps that fail schema validation. - const nextGroups = groups - .map((g, i) => (i === groupIndex ? updatedGroup : g)) - .map((g) => (g.id === updatedGroup.id ? g : stripGroupDeps(g, removedColumnIds))) - const updatedSchema: TableSchema = { - ...schema, - columns: nextColumns, - workflowGroups: nextGroups, - } - - // `columnOrder` (column ids) mirrors the schema layout. Drop removed - // columns, then splice the new ones in at the same anchor as `nextColumns` - // so the table renders them inside the group's contiguous run. - let updatedColumnOrder = table.metadata?.columnOrder?.filter( - (id) => !removedColumnIds.has(id) - ) - if (updatedColumnOrder && newColDefs.length > 0) { - const newColIds = new Set(newColDefs.map(getColumnId)) - const orderWithoutNew = updatedColumnOrder.filter((id) => !newColIds.has(id)) - const groupColIds = new Set(newOutputs.map((o) => o.columnName)) - const orderedGroupIds = newOutputs.map((o) => o.columnName) - const firstGroupOrderIdx = orderWithoutNew.findIndex((id) => groupColIds.has(id)) - const anchorOrderIdx = - firstGroupOrderIdx === -1 ? orderWithoutNew.length : firstGroupOrderIdx - const remainingOrder = orderWithoutNew.filter((id) => !groupColIds.has(id)) - updatedColumnOrder = [ - ...remainingOrder.slice(0, anchorOrderIdx), - ...orderedGroupIds, - ...remainingOrder.slice(anchorOrderIdx), - ] - } - assertValidSchema(updatedSchema, updatedColumnOrder) - const updatedMetadata: TableMetadata | null = - updatedColumnOrder && table.metadata - ? { ...table.metadata, columnOrder: updatedColumnOrder } - : table.metadata - ? { ...table.metadata } - : null + const updatedGroup: WorkflowGroup = { + ...group, + workflowId: data.workflowId ?? group.workflowId, + name: data.name ?? group.name, + dependencies: dependenciesInput ?? group.dependencies, + outputs: newOutputs, + ...(inputMappingsInput !== undefined ? { inputMappings: inputMappingsInput } : {}), + ...(data.deploymentMode !== undefined ? { deploymentMode: data.deploymentMode } : {}), + ...(data.type !== undefined ? { type: data.type } : {}), + ...(data.autoRun !== undefined ? { autoRun: data.autoRun } : {}), + } + // Removed outputs may be referenced as deps by sibling groups; strip those + // refs so we don't leave dangling-column deps that fail schema validation. + const nextGroups = groups + .map((g, i) => (i === groupIndex ? updatedGroup : g)) + .map((g) => (g.id === updatedGroup.id ? g : stripGroupDeps(g, removedColumnIds))) + const updatedSchema: TableSchema = { + ...schema, + columns: nextColumns, + workflowGroups: nextGroups, + } - const now = new Date() - await trx - .update(userTableDefinitions) - .set({ schema: updatedSchema, metadata: updatedMetadata, updatedAt: now }) - .where(eq(userTableDefinitions.id, data.tableId)) - // Remapped columns: clear stale values in-tx so rows the backfill can't - // repopulate (no log, no matching span output) end up empty rather than - // retaining the previous mapping's value. The backfill below then writes - // the new mapping's value into rows where it can find one. - const clearedColumnIds = [...new Set([...removedColumnIds, ...remappedColumnIds])] - if (clearedColumnIds.length > 0) { - await updateTableRowsWithDerivedSecretProvenance(trx, { - rowWhere: eq(userTableRows.tableId, data.tableId), - transformation: { mode: 'remove-columns', columnIds: clearedColumnIds }, - }) - } + // `columnOrder` (column ids) mirrors the schema layout. Drop removed + // columns, then splice the new ones in at the same anchor as `nextColumns` + // so the table renders them inside the group's contiguous run. + let updatedColumnOrder = table.metadata?.columnOrder?.filter( + (id) => !removedColumnIds.has(id) + ) + if (updatedColumnOrder && newColDefs.length > 0) { + const newColIds = new Set(newColDefs.map(getColumnId)) + const orderWithoutNew = updatedColumnOrder.filter((id) => !newColIds.has(id)) + const groupColIds = new Set(newOutputs.map((o) => o.columnName)) + const orderedGroupIds = newOutputs.map((o) => o.columnName) + const firstGroupOrderIdx = orderWithoutNew.findIndex((id) => groupColIds.has(id)) + const anchorOrderIdx = + firstGroupOrderIdx === -1 ? orderWithoutNew.length : firstGroupOrderIdx + const remainingOrder = orderWithoutNew.filter((id) => !groupColIds.has(id)) + updatedColumnOrder = [ + ...remainingOrder.slice(0, anchorOrderIdx), + ...orderedGroupIds, + ...remainingOrder.slice(anchorOrderIdx), + ] + } + assertValidSchema(updatedSchema, updatedColumnOrder) + + const updatedMetadata: TableMetadata | null = + updatedColumnOrder && table.metadata + ? { ...table.metadata, columnOrder: updatedColumnOrder } + : table.metadata + ? { ...table.metadata } + : null + + const now = new Date() + await trx + .update(userTableDefinitions) + .set({ schema: updatedSchema, metadata: updatedMetadata, updatedAt: now }) + .where( + and( + eq(userTableDefinitions.id, data.tableId), + eq(userTableDefinitions.workspaceId, table.workspaceId) + ) + ) + // Remapped columns: clear stale values in-tx so rows the backfill can't + // repopulate (no log, no matching span output) end up empty rather than + // retaining the previous mapping's value. The backfill below then writes + // the new mapping's value into rows where it can find one. + const clearedColumnIds = [...new Set([...removedColumnIds, ...remappedColumnIds])] + if (clearedColumnIds.length > 0) { + await updateTableRowsWithDerivedSecretProvenance(trx, { + rowWhere: and( + eq(userTableRows.tableId, data.tableId), + eq(userTableRows.workspaceId, table.workspaceId) + )!, + transformation: { mode: 'remove-columns', columnIds: clearedColumnIds }, + }) + } - logger.info( - `[${requestId}] Updated workflow group "${data.groupId}" in table ${data.tableId} (added=${added.length}, removed=${removed.length}, remapped=${remappedColumnIds.size})` - ) + logger.info( + `[${requestId}] Updated workflow group "${data.groupId}" in table ${data.tableId} (added=${added.length}, removed=${removed.length}, remapped=${remappedColumnIds.size})` + ) - const updatedTable: TableDefinition = { - ...table, - schema: updatedSchema, - metadata: updatedMetadata, - updatedAt: now, - } - return { - updatedTable, - added, - remappedColumnIds, - newOutputs, - previousAutoRun: group.autoRun, - } - }) + const updatedTable: TableDefinition = { + ...table, + schema: updatedSchema, + metadata: updatedMetadata, + updatedAt: now, + } + return { + updatedTable, + added, + remappedColumnIds, + newOutputs, + previousAutoRun: group.autoRun, + } + }, + { expectedWorkspaceId: data.workspaceId } + ) // Backfill from saved execution logs so already-completed group runs surface // the schema changes without re-running the workflow. Two passes: @@ -583,7 +621,7 @@ export async function updateWorkflowGroup( // autoRun toggled false → true: fire deps-satisfied rows now via the // dispatcher. Mirrors the post-add path so re-enabling auto-fire doesn't // require manual run clicks for rows that are already eligible. - if (previousAutoRun === false && data.autoRun === true) { + if (previousAutoRun === false && data.autoRun === true && data.suppressAutoRunDispatch !== true) { void runWorkflowColumn({ tableId: updatedTable.id, workspaceId: updatedTable.workspaceId, @@ -610,6 +648,8 @@ export async function updateWorkflowGroup( export async function addWorkflowGroupOutput( data: { tableId: string + /** Canonical workspace derived by the authorized caller. */ + workspaceId?: string groupId: string blockId: string path: string @@ -627,10 +667,12 @@ export async function addWorkflowGroupOutput( // time out waiting (the Mothership fan-out this fix targets). Phase 2 // re-validates that the group still maps to the same workflow under the lock. const preTable = await getTableById(data.tableId) - if (!preTable) throw new Error('Table not found') + if (!preTable || (data.workspaceId && preTable.workspaceId !== data.workspaceId)) { + throw new OrchestrationError('not_found', 'Table not found') + } const preGroup = (preTable.schema.workflowGroups ?? []).find((g) => g.id === data.groupId) if (!preGroup) { - throw new Error(`Workflow group "${data.groupId}" not found`) + throw new OrchestrationError('not_found', `Workflow group "${data.groupId}" not found`) } const workflowId = preGroup.workflowId @@ -645,7 +687,7 @@ export async function addWorkflowGroupOutput( ]) const normalized = await loadWorkflowFromNormalizedTables(workflowId) if (!normalized) { - throw new Error(`Workflow ${workflowId} not found`) + throw new OrchestrationError('not_found', `Workflow ${workflowId} not found`) } const blocks = Object.values(normalized.blocks ?? {}).map((b) => ({ id: b.id, @@ -657,7 +699,8 @@ export async function addWorkflowGroupOutput( const flattened = flattenWorkflowOutputs(blocks, normalized.edges ?? []) const match = flattened.find((f) => f.blockId === data.blockId && f.path === data.path) if (!match) { - throw new Error( + throw new OrchestrationError( + 'validation', `Output ${data.blockId}::${data.path} is not a valid pickable output on workflow ${workflowId}` ) } @@ -668,153 +711,168 @@ export async function addWorkflowGroupOutput( // Phase 2 (locked): re-read fresh, validate against the current schema, and // write. The critical section holds no I/O — just the in-memory splice + the // schema UPDATE — so concurrent adders queue behind it quickly. - const { updatedTable, newOutput } = await withLockedTable(data.tableId, async (table, trx) => { - assertSchemaMutable(table) - const schema = table.schema - const groups = schema.workflowGroups ?? [] - const groupIndex = groups.findIndex((g) => g.id === data.groupId) - if (groupIndex === -1) { - throw new Error(`Workflow group "${data.groupId}" not found`) - } - const group = groups[groupIndex] - if (group.workflowId !== workflowId) { - throw new Error( - `Workflow group "${data.groupId}" was remapped to a different workflow concurrently; retry the add.` - ) - } + const { updatedTable, newOutput } = await withLockedTable( + data.tableId, + async (table, trx) => { + assertSchemaMutable(table) + const schema = table.schema + const groups = schema.workflowGroups ?? [] + const groupIndex = groups.findIndex((g) => g.id === data.groupId) + if (groupIndex === -1) { + throw new OrchestrationError('not_found', `Workflow group "${data.groupId}" not found`) + } + const group = groups[groupIndex] + if (group.workflowId !== workflowId) { + throw new OrchestrationError( + 'conflict', + `Workflow group "${data.groupId}" was remapped to a different workflow concurrently; retry the add.` + ) + } - if (group.outputs.some((o) => o.blockId === data.blockId && o.path === data.path)) { - throw new Error( - `Workflow group "${data.groupId}" already has an output at ${data.blockId}::${data.path}` - ) - } + if (group.outputs.some((o) => o.blockId === data.blockId && o.path === data.path)) { + throw new OrchestrationError( + 'validation', + `Workflow group "${data.groupId}" already has an output at ${data.blockId}::${data.path}` + ) + } - const taken = new Set(schema.columns.map((c) => c.name)) - const columnName = data.columnName ?? deriveOutputColumnName(data.path, taken) - if (!NAME_PATTERN.test(columnName)) { - throw new Error(`Invalid column name "${columnName}". Must satisfy ${NAME_PATTERN.source}.`) - } - if (taken.has(columnName)) { - throw new Error(`Column "${columnName}" already exists`) - } - if (schema.columns.length + 1 > TABLE_LIMITS.MAX_COLUMNS_PER_TABLE) { - throw new Error( - `Adding a column would exceed the maximum (${TABLE_LIMITS.MAX_COLUMNS_PER_TABLE}).` - ) - } + const taken = new Set(schema.columns.map((c) => c.name)) + const columnName = data.columnName ?? deriveOutputColumnName(data.path, taken) + if (!NAME_PATTERN.test(columnName)) { + throw new OrchestrationError( + 'validation', + `Invalid column name "${columnName}". Must satisfy ${NAME_PATTERN.source}.` + ) + } + if (taken.has(columnName)) { + throw new OrchestrationError('validation', `Column "${columnName}" already exists`) + } + if (schema.columns.length + 1 > TABLE_LIMITS.MAX_COLUMNS_PER_TABLE) { + throw new OrchestrationError( + 'validation', + `Adding a column would exceed the maximum (${TABLE_LIMITS.MAX_COLUMNS_PER_TABLE}).` + ) + } - const newColDef: ColumnDefinition = { - id: generateColumnId(), - name: columnName, - type: newColumnType, - required: false, - unique: false, - workflowGroupId: data.groupId, - } - const newColumnId = getColumnId(newColDef) - const newOutput: WorkflowGroupOutput = { - blockId: data.blockId, - path: data.path, - columnName: newColumnId, - } + const newColDef: ColumnDefinition = { + id: generateColumnId(), + name: columnName, + type: newColumnType, + required: false, + unique: false, + workflowGroupId: data.groupId, + } + const newColumnId = getColumnId(newColDef) + const newOutput: WorkflowGroupOutput = { + blockId: data.blockId, + path: data.path, + columnName: newColumnId, + } - // Sort all of the group's outputs (existing + new) in workflow execution - // order: BFS distance from the start block ASC, with discovery order as - // tiebreak. This matches what the column-sidebar does at create time, so - // columns from the same workflow always read in the order their blocks run - // — regardless of whether they were added at create time or one-by-one. - const groupColIdsBefore = new Set(group.outputs.map((o) => o.columnName)) - const orderKey = (o: { blockId: string; path: string }) => { - const d = distances[o.blockId] - const dist = d === undefined || d < 0 ? Number.POSITIVE_INFINITY : d - const idx = flatIndex.get(`${o.blockId}::${o.path}`) ?? Number.POSITIVE_INFINITY - return [dist, idx] as const - } - const allGroupOutputs = [...group.outputs, newOutput].sort((a, b) => { - const [da, ia] = orderKey(a) - const [db, ib] = orderKey(b) - return da !== db ? da - db : ia - ib - }) - const orderedGroupColIds = allGroupOutputs.map((o) => o.columnName) - const updatedGroup: WorkflowGroup = { - ...group, - outputs: allGroupOutputs, - } - const nextGroups = groups.map((g, i) => (i === groupIndex ? updatedGroup : g)) - - // Splice the new column run into nextColumns: keep the columns outside the - // group where they were, replace the group's contiguous run with the - // BFS-ordered list. Anchor at the position of the first existing sibling - // (or append if the group was empty). - const colById = new Map(schema.columns.map((c) => [getColumnId(c), c])) - const orderedGroupCols: ColumnDefinition[] = orderedGroupColIds.map((id) => { - if (id === newColumnId) return newColDef - const existing = colById.get(id) - if (!existing) { - throw new Error(`Internal: column "${id}" missing while splicing group outputs`) + // Sort all of the group's outputs (existing + new) in workflow execution + // order: BFS distance from the start block ASC, with discovery order as + // tiebreak. This matches what the column-sidebar does at create time, so + // columns from the same workflow always read in the order their blocks run + // — regardless of whether they were added at create time or one-by-one. + const groupColIdsBefore = new Set(group.outputs.map((o) => o.columnName)) + const orderKey = (o: { blockId: string; path: string }) => { + const d = distances[o.blockId] + const dist = d === undefined || d < 0 ? Number.POSITIVE_INFINITY : d + const idx = flatIndex.get(`${o.blockId}::${o.path}`) ?? Number.POSITIVE_INFINITY + return [dist, idx] as const } - return existing - }) - const remainingCols = schema.columns.filter((c) => !groupColIdsBefore.has(getColumnId(c))) - const firstGroupIdx = schema.columns.findIndex((c) => groupColIdsBefore.has(getColumnId(c))) - const colAnchor = firstGroupIdx === -1 ? remainingCols.length : firstGroupIdx - const nextColumns = [ - ...remainingCols.slice(0, colAnchor), - ...orderedGroupCols, - ...remainingCols.slice(colAnchor), - ] - - const updatedSchema: TableSchema = { - ...schema, - columns: nextColumns, - workflowGroups: nextGroups, - } + const allGroupOutputs = [...group.outputs, newOutput].sort((a, b) => { + const [da, ia] = orderKey(a) + const [db, ib] = orderKey(b) + return da !== db ? da - db : ia - ib + }) + const orderedGroupColIds = allGroupOutputs.map((o) => o.columnName) + const updatedGroup: WorkflowGroup = { + ...group, + outputs: allGroupOutputs, + } + const nextGroups = groups.map((g, i) => (i === groupIndex ? updatedGroup : g)) + + // Splice the new column run into nextColumns: keep the columns outside the + // group where they were, replace the group's contiguous run with the + // BFS-ordered list. Anchor at the position of the first existing sibling + // (or append if the group was empty). + const colById = new Map(schema.columns.map((c) => [getColumnId(c), c])) + const orderedGroupCols: ColumnDefinition[] = orderedGroupColIds.map((id) => { + if (id === newColumnId) return newColDef + const existing = colById.get(id) + if (!existing) { + throw new Error(`Internal: column "${id}" missing while splicing group outputs`) + } + return existing + }) + const remainingCols = schema.columns.filter((c) => !groupColIdsBefore.has(getColumnId(c))) + const firstGroupIdx = schema.columns.findIndex((c) => groupColIdsBefore.has(getColumnId(c))) + const colAnchor = firstGroupIdx === -1 ? remainingCols.length : firstGroupIdx + const nextColumns = [ + ...remainingCols.slice(0, colAnchor), + ...orderedGroupCols, + ...remainingCols.slice(colAnchor), + ] - const updatedColumnOrder = table.metadata?.columnOrder - ? (() => { - const orderWithoutGroup = table.metadata!.columnOrder!.filter( - (id) => !groupColIdsBefore.has(id) - ) - const firstGroupOrderIdx = table.metadata!.columnOrder!.findIndex((id) => - groupColIdsBefore.has(id) - ) - const orderAnchor = - firstGroupOrderIdx === -1 ? orderWithoutGroup.length : firstGroupOrderIdx - return [ - ...orderWithoutGroup.slice(0, orderAnchor), - ...orderedGroupColIds, - ...orderWithoutGroup.slice(orderAnchor), - ] - })() - : undefined + const updatedSchema: TableSchema = { + ...schema, + columns: nextColumns, + workflowGroups: nextGroups, + } - assertValidSchema(updatedSchema, updatedColumnOrder) + const updatedColumnOrder = table.metadata?.columnOrder + ? (() => { + const orderWithoutGroup = table.metadata!.columnOrder!.filter( + (id) => !groupColIdsBefore.has(id) + ) + const firstGroupOrderIdx = table.metadata!.columnOrder!.findIndex((id) => + groupColIdsBefore.has(id) + ) + const orderAnchor = + firstGroupOrderIdx === -1 ? orderWithoutGroup.length : firstGroupOrderIdx + return [ + ...orderWithoutGroup.slice(0, orderAnchor), + ...orderedGroupColIds, + ...orderWithoutGroup.slice(orderAnchor), + ] + })() + : undefined - const updatedMetadata: TableMetadata | null = - updatedColumnOrder && table.metadata - ? { ...table.metadata, columnOrder: updatedColumnOrder } - : table.metadata - ? { ...table.metadata } - : null + assertValidSchema(updatedSchema, updatedColumnOrder) - const now = new Date() - await trx - .update(userTableDefinitions) - .set({ schema: updatedSchema, metadata: updatedMetadata, updatedAt: now }) - .where(eq(userTableDefinitions.id, data.tableId)) + const updatedMetadata: TableMetadata | null = + updatedColumnOrder && table.metadata + ? { ...table.metadata, columnOrder: updatedColumnOrder } + : table.metadata + ? { ...table.metadata } + : null - logger.info( - `[${requestId}] Added output "${columnName}" (${newColDef.type}) to workflow group "${data.groupId}" in table ${data.tableId}` - ) + const now = new Date() + await trx + .update(userTableDefinitions) + .set({ schema: updatedSchema, metadata: updatedMetadata, updatedAt: now }) + .where( + and( + eq(userTableDefinitions.id, data.tableId), + eq(userTableDefinitions.workspaceId, table.workspaceId) + ) + ) - const updatedTable: TableDefinition = { - ...table, - schema: updatedSchema, - metadata: updatedMetadata, - updatedAt: now, - } - return { updatedTable, newOutput } - }) + logger.info( + `[${requestId}] Added output "${columnName}" (${newColDef.type}) to workflow group "${data.groupId}" in table ${data.tableId}` + ) + + const updatedTable: TableDefinition = { + ...table, + schema: updatedSchema, + metadata: updatedMetadata, + updatedAt: now, + } + return { updatedTable, newOutput } + }, + { expectedWorkspaceId: data.workspaceId } + ) // Backfill from saved execution logs — same flow `updateWorkflowGroup` // uses for added outputs. Reads each row's saved trace spans for the @@ -852,67 +910,80 @@ export async function addWorkflowGroupOutput( * `deleteWorkflowGroup` if needed. */ export async function deleteWorkflowGroupOutput( - data: { tableId: string; groupId: string; columnName: string }, + data: { tableId: string; workspaceId?: string; groupId: string; columnName: string }, requestId: string ): Promise { - return withLockedTable(data.tableId, async (table, trx) => { - assertColumnDestructive(table) - const schema = table.schema - const groups = schema.workflowGroups ?? [] - const groupIndex = groups.findIndex((g) => g.id === data.groupId) - if (groupIndex === -1) { - throw new Error(`Workflow group "${data.groupId}" not found`) - } - const group = groups[groupIndex] - // `data.columnName` may be a column id (first-party) or display name - // (mothership/legacy); resolve to the stable id used everywhere below. - const targetColumn = schema.columns.find((c) => columnMatchesRef(c, data.columnName)) - const columnId = targetColumn ? getColumnId(targetColumn) : data.columnName - if (!group.outputs.some((o) => o.columnName === columnId)) { - throw new Error( - `Workflow group "${data.groupId}" has no output bound to column "${data.columnName}"` - ) - } + return withLockedTable( + data.tableId, + async (table, trx) => { + assertColumnDestructive(table) + const schema = table.schema + const groups = schema.workflowGroups ?? [] + const groupIndex = groups.findIndex((g) => g.id === data.groupId) + if (groupIndex === -1) { + throw new OrchestrationError('not_found', `Workflow group "${data.groupId}" not found`) + } + const group = groups[groupIndex] + // `data.columnName` may be a column id (first-party) or display name + // (mothership/legacy); resolve to the stable id used everywhere below. + const targetColumn = schema.columns.find((c) => columnMatchesRef(c, data.columnName)) + const columnId = targetColumn ? getColumnId(targetColumn) : data.columnName + if (!group.outputs.some((o) => o.columnName === columnId)) { + throw new OrchestrationError( + 'not_found', + `Workflow group "${data.groupId}" has no output bound to column "${data.columnName}"` + ) + } - const updatedGroup: WorkflowGroup = { - ...group, - outputs: group.outputs.filter((o) => o.columnName !== columnId), - } - const nextGroups = groups.map((g, i) => (i === groupIndex ? updatedGroup : g)) - const nextColumns = schema.columns.filter((c) => getColumnId(c) !== columnId) - const updatedSchema: TableSchema = { - ...schema, - columns: nextColumns, - workflowGroups: nextGroups, - } + const updatedGroup: WorkflowGroup = { + ...group, + outputs: group.outputs.filter((o) => o.columnName !== columnId), + } + const nextGroups = groups.map((g, i) => (i === groupIndex ? updatedGroup : g)) + const nextColumns = schema.columns.filter((c) => getColumnId(c) !== columnId) + const updatedSchema: TableSchema = { + ...schema, + columns: nextColumns, + workflowGroups: nextGroups, + } - const updatedColumnOrder = table.metadata?.columnOrder?.filter((id) => id !== columnId) - assertValidSchema(updatedSchema, updatedColumnOrder) + const updatedColumnOrder = table.metadata?.columnOrder?.filter((id) => id !== columnId) + assertValidSchema(updatedSchema, updatedColumnOrder) - const updatedMetadata: TableMetadata | null = - updatedColumnOrder && table.metadata - ? { ...table.metadata, columnOrder: updatedColumnOrder } - : table.metadata - ? { ...table.metadata } - : null + const updatedMetadata: TableMetadata | null = + updatedColumnOrder && table.metadata + ? { ...table.metadata, columnOrder: updatedColumnOrder } + : table.metadata + ? { ...table.metadata } + : null - const now = new Date() - await setTableTxTimeouts(trx, { statementMs: 60_000 }) - await trx - .update(userTableDefinitions) - .set({ schema: updatedSchema, metadata: updatedMetadata, updatedAt: now }) - .where(eq(userTableDefinitions.id, data.tableId)) - await updateTableRowsWithDerivedSecretProvenance(trx, { - rowWhere: eq(userTableRows.tableId, data.tableId), - transformation: { mode: 'remove-columns', columnIds: [columnId] }, - }) + const now = new Date() + await setTableTxTimeouts(trx, { statementMs: 60_000 }) + await trx + .update(userTableDefinitions) + .set({ schema: updatedSchema, metadata: updatedMetadata, updatedAt: now }) + .where( + and( + eq(userTableDefinitions.id, data.tableId), + eq(userTableDefinitions.workspaceId, table.workspaceId) + ) + ) + await updateTableRowsWithDerivedSecretProvenance(trx, { + rowWhere: and( + eq(userTableRows.tableId, data.tableId), + eq(userTableRows.workspaceId, table.workspaceId) + )!, + transformation: { mode: 'remove-columns', columnIds: [columnId] }, + }) - logger.info( - `[${requestId}] Removed output "${data.columnName}" from workflow group "${data.groupId}" in table ${data.tableId}` - ) + logger.info( + `[${requestId}] Removed output "${data.columnName}" from workflow group "${data.groupId}" in table ${data.tableId}` + ) - return { ...table, schema: updatedSchema, metadata: updatedMetadata, updatedAt: now } - }) + return { ...table, schema: updatedSchema, metadata: updatedMetadata, updatedAt: now } + }, + { expectedWorkspaceId: data.workspaceId } + ) } /** @@ -923,62 +994,76 @@ export async function deleteWorkflowGroup( data: DeleteWorkflowGroupData, requestId: string ): Promise { - return withLockedTable(data.tableId, async (table, trx) => { - assertColumnDestructive(table) - const schema = table.schema - const groups = schema.workflowGroups ?? [] - const group = groups.find((g) => g.id === data.groupId) - if (!group) { - throw new Error(`Workflow group "${data.groupId}" not found`) - } + return withLockedTable( + data.tableId, + async (table, trx) => { + assertColumnDestructive(table) + const schema = table.schema + const groups = schema.workflowGroups ?? [] + const group = groups.find((g) => g.id === data.groupId) + if (!group) { + throw new OrchestrationError('not_found', `Workflow group "${data.groupId}" not found`) + } - const removedColumnIds = new Set(group.outputs.map((o) => o.columnName)) - // Removed group's output columns may be referenced as deps by sibling groups. - // Strip those refs so we don't leave dangling-column deps behind. - const nextGroups = groups - .filter((g) => g.id !== data.groupId) - .map((g) => stripGroupDeps(g, removedColumnIds)) - const updatedSchema: TableSchema = { - ...schema, - columns: schema.columns.filter((c) => !removedColumnIds.has(getColumnId(c))), - workflowGroups: nextGroups, - } - const updatedColumnOrder = table.metadata?.columnOrder?.filter( - (id) => !removedColumnIds.has(id) - ) - assertValidSchema(updatedSchema, updatedColumnOrder) - - const updatedMetadata: TableMetadata | null = - updatedColumnOrder && table.metadata - ? { ...table.metadata, columnOrder: updatedColumnOrder } - : table.metadata - ? { ...table.metadata } - : null - - const now = new Date() - await setTableTxTimeouts(trx, { statementMs: 60_000 }) - await trx - .update(userTableDefinitions) - .set({ schema: updatedSchema, metadata: updatedMetadata, updatedAt: now }) - .where(eq(userTableDefinitions.id, data.tableId)) - const removedIds = [...removedColumnIds] - if (removedIds.length > 0) { - await updateTableRowsWithDerivedSecretProvenance(trx, { - rowWhere: eq(userTableRows.tableId, data.tableId), - transformation: { mode: 'remove-columns', columnIds: removedIds }, + const removedColumnIds = new Set(group.outputs.map((o) => o.columnName)) + // Removed group's output columns may be referenced as deps by sibling groups. + // Strip those refs so we don't leave dangling-column deps behind. + const nextGroups = groups + .filter((g) => g.id !== data.groupId) + .map((g) => stripGroupDeps(g, removedColumnIds)) + const updatedSchema: TableSchema = { + ...schema, + columns: schema.columns.filter((c) => !removedColumnIds.has(getColumnId(c))), + workflowGroups: nextGroups, + } + const updatedColumnOrder = table.metadata?.columnOrder?.filter( + (id) => !removedColumnIds.has(id) + ) + assertValidSchema(updatedSchema, updatedColumnOrder) + + const updatedMetadata: TableMetadata | null = + updatedColumnOrder && table.metadata + ? { ...table.metadata, columnOrder: updatedColumnOrder } + : table.metadata + ? { ...table.metadata } + : null + + const now = new Date() + await setTableTxTimeouts(trx, { statementMs: 60_000 }) + await trx + .update(userTableDefinitions) + .set({ schema: updatedSchema, metadata: updatedMetadata, updatedAt: now }) + .where( + and( + eq(userTableDefinitions.id, data.tableId), + eq(userTableDefinitions.workspaceId, table.workspaceId) + ) + ) + const removedIds = [...removedColumnIds] + if (removedIds.length > 0) { + await updateTableRowsWithDerivedSecretProvenance(trx, { + rowWhere: and( + eq(userTableRows.tableId, data.tableId), + eq(userTableRows.workspaceId, table.workspaceId) + )!, + transformation: { mode: 'remove-columns', columnIds: removedIds }, + }) + } + await stripGroupExecutions(trx, data.tableId, [data.groupId], { + expectedWorkspaceId: table.workspaceId, }) - } - await stripGroupExecutions(trx, data.tableId, [data.groupId]) - logger.info( - `[${requestId}] Deleted workflow group "${data.groupId}" from table ${data.tableId}` - ) + logger.info( + `[${requestId}] Deleted workflow group "${data.groupId}" from table ${data.tableId}` + ) - return { - ...table, - schema: updatedSchema, - metadata: updatedMetadata, - updatedAt: now, - } - }) + return { + ...table, + schema: updatedSchema, + metadata: updatedMetadata, + updatedAt: now, + } + }, + { expectedWorkspaceId: data.workspaceId } + ) } diff --git a/apps/sim/lib/uploads/upload-session/service.test.ts b/apps/sim/lib/uploads/upload-session/service.test.ts index 020e3f2c6b1..aa2c88c5d31 100644 --- a/apps/sim/lib/uploads/upload-session/service.test.ts +++ b/apps/sim/lib/uploads/upload-session/service.test.ts @@ -320,6 +320,55 @@ describe('upload sessions', () => { ).toThrow('Upload session not found') }) + it('requires an exact immutable credential binding for table-import control', async () => { + const bound = uploadRow({ + purpose: 'table_import', + storageContext: 'table-import', + metadata: { + authBinding: { + version: 1, + workspaceId: WORKSPACE_ID, + principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' }, + }, + }, + }) + queueTableRows(schemaMock.uploadSession, [bound]) + queueTableRows(schemaMock.uploadSession, [bound]) + + await expect( + getOwnedUploadSession({ + uploadId: bound.id, + uploadToken: 'upload-secret', + purpose: 'table_import', + principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' }, + }) + ).resolves.toMatchObject({ id: bound.id, purpose: 'table_import' }) + await expect( + getOwnedUploadSession({ + uploadId: bound.id, + uploadToken: 'upload-secret', + purpose: 'table_import', + principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-2' }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + }) + + it('fails closed for legacy table-import sessions without a binding', () => { + const legacyImport = sessionRecord({ + purpose: 'table_import', + storageContext: 'table-import', + metadata: {}, + }) + + expect(() => + assertUploadSessionAuthBinding(legacyImport, { + kind: 'session', + userId: legacyImport.userId, + sessionId: 'current-session', + }) + ).toThrow('Upload session not found') + }) + it('initiates multipart storage directly at the final key', async () => { const fileSize = UPLOAD_SESSION_PUT_MAX_BYTES + 1 const row = uploadRow({ diff --git a/apps/sim/lib/uploads/upload-session/service.ts b/apps/sim/lib/uploads/upload-session/service.ts index b6e3c7303e9..a24b99e6a4f 100644 --- a/apps/sim/lib/uploads/upload-session/service.ts +++ b/apps/sim/lib/uploads/upload-session/service.ts @@ -135,7 +135,7 @@ interface CreateUploadSessionBaseParams { export type CreateUploadSessionParams = CreateUploadSessionBaseParams & ( | { purpose: 'workspace_file'; workspaceId: string; principal: Principal } - | { purpose: 'table_import'; workspaceId: string } + | { purpose: 'table_import'; workspaceId: string; principal?: Principal } | { purpose: 'knowledge_document' workspaceId: string @@ -168,6 +168,9 @@ export async function createUploadSession( throw new Error(`${params.purpose} upload requires an authenticated principal`) } metadata.authBinding = createUploadSessionAuthBinding(params.principal, workspaceId) + } else if (params.purpose === 'table_import' && params.principal) { + if (!workspaceId) throw new Error('table_import upload is missing workspaceId') + metadata.authBinding = createUploadSessionAuthBinding(params.principal, workspaceId) } const { storageContext, finalKey } = resolveUploadStorage(params, id) const method: UploadTransferMethod = @@ -406,6 +409,7 @@ export function assertUploadSessionAuthBinding( if (!isPrincipalBoundUploadPurpose(session.purpose)) return const candidate = session.metadata.authBinding if (candidate === undefined) { + if (session.purpose === 'table_import') throw uploadNotFound() assertLegacyUploadSessionOwner(session, principal) return } @@ -1107,7 +1111,9 @@ function requiresStorageQuota(purpose: UploadSessionPurpose): boolean { } function isPrincipalBoundUploadPurpose(purpose: UploadSessionPurpose): boolean { - return purpose === 'workspace_file' || purpose === 'knowledge_document' + return ( + purpose === 'workspace_file' || purpose === 'knowledge_document' || purpose === 'table_import' + ) } function resolveUploadStorage( diff --git a/packages/auth/src/principal.ts b/packages/auth/src/principal.ts index 18a138fa9a9..62136ce8635 100644 --- a/packages/auth/src/principal.ts +++ b/packages/auth/src/principal.ts @@ -33,6 +33,7 @@ export interface DelegatedPrincipal { expiresAt: Date resourceScope?: { fileId?: string + tableId?: string chatId?: string executionId?: string }