Skip to content

Commit 5fea5f7

Browse files
refactor(tables): make lib/table/orchestration the single implementation (#6134)
* refactor(orchestration): move the shared error contract out of lib/workflows OrchestrationErrorCode and statusForOrchestrationError are the contract every lib/[resource]/orchestration module returns against, but they lived inside the workflows module, so resource-neutral code (lib/folders) already had to import from a workflow path. Moved to lib/core/orchestration/types. Adds a 'locked' class mapping to 423. Both tables and workflows have a lock that forbids a mutation, and each caller was translating that to a status itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(tables): make lib/table/orchestration the single implementation Column update was implemented four times — the UI route, v1, v2, and the copilot table tool — each calling the same column services but owning its own guards, error mapping, and audit. The copies had drifted, and the drift was the bug: v2 was missing both guards, only the copilot copy minted stable option ids, and only v1/v2 audited. performUpdateTableColumn, performDeleteTable, and performDeleteTableRow now own that logic; all ten call sites reduce to auth, parse, call, render. The guards are asserted once in lib/table/orchestration rather than four times against four routes. Behavior this consolidates, previously true on only some paths: - The typeChanging guard. updateColumnType early-returns on an unchanged type and drops any options sent with it, so restating the current type alongside new options silently discarded them. v2 had no guard at all and, since its contract shares v1's body schema, accepted options and ignored them. - The select-unique guard. Each write is its own locked transaction, so a rename or type change paired with a constraint write that is going to fail commits first and then throws, half-applying the schema change. - Stable select-option ids. Cells reference the option id, so an edit that re-sends an option by name has to reuse it or every cell holding it is orphaned. Only the copilot path did this; normalizeSelectOptionsInput moves to lib/table/select-options and now covers every caller. It preserves a supplied id, so it is a no-op for the fully-formed options the HTTP contracts accept. - required forwarded into the type and options writes, so a conversion validates against the constraint the same request is setting. - An audit on every successful update. The UI route and the copilot tool emitted none. - Single-row delete through the row service. v2 did a raw db.delete, skipping assertRowDelete and deleteOrderedRow, so a delete-locked table returned 200 and the row-count bookkeeping never ran. - The delete actor handed to deleteTable, which audits only when a row was actually archived. v1 and v2 omitted it and audited themselves outside that check, emitting TABLE_DELETED for a no-op delete of an archived table. Failure classes come back as OrchestrationErrorCode; v2 renders them through a new v2ErrorForOrchestration, mirroring statusForOrchestrationError on the v1 and UI surfaces, so a given failure maps to the same status everywhere. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(tables): bind the column-update tests to the orchestration function The base's route tests assert which column service each payload reaches — the behavior that now lives in performUpdateTableColumn. They mocked the `@/lib/table` barrel; the orchestration module imports the service directly, so they mock that too and keep asserting the same thing through the extracted implementation. The orchestration tests move onto the base's semantics: writes address the stable column id, a rename rides inside the write it accompanies rather than running first, and the currency guards replace the non-select options guard the service now owns. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(copilot): drop the column-type import the delegation made dead Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(tables): move the audit log out of the table service `lib/table/service.ts` wrote its own audit rows, so whether an operation was audited depended on which function a caller reached for rather than on a user having performed it. That is what let v1 and v2 audit a no-op delete, and what made `deleteTable`'s optional `actingUserId` double as an audit opt-out flag. Worse, most sites fell back to `actingUserId ?? createdBy`, so an unattributed call was logged against the table's *creator*. The copilot `mv` path passed no actor at all: renaming someone else's table recorded them as the renamer. Audit now lives in the orchestration functions — performDeleteTable, performRenameTable, performMoveTableToFolder, performUpdateTableLocks — and the services just write. Internal callers (folder cascade, import rollback) keep calling the service and are silent by construction rather than by remembering to omit an argument. Two services now return what the audit needs: `deleteTable` reports whether it actually archived a row, so a repeat delete logs nothing; `updateTableLocks` returns the before/after locks, since only the locked write can observe the transition its description names. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(tables): restore audit provenance and conflict status in orchestration Moving the audits into the orchestration functions dropped three things the routes had been carrying, and added one the orchestration now owns twice. - The v1 and v2 column-update routes passed `request` to `recordAudit`, so their audit rows recorded the caller's IP and user-agent. The orchestration function had no way to receive it. Every table orchestration function now takes an optional `OrchestrationRequestContext` and every HTTP route forwards it; the copilot and VFS callers, which have no request, omit it. - `classifyTableMutation` matched `TableConflictError` on "already exists" appearing in the message and reported it as `validation`, turning the UI route's 409 on a duplicate table rename into a 400. It now matches the type, the way `performRestoreTable` already did. - `captureServerEvent` ran on every delete while the audit was gated on a row actually being archived, so a repeat delete of an archived table still reported `table_deleted`. Both now hang off the same evidence. - The copilot delete path kept its own `captureServerEvent` from when the service did not emit one, double-counting every copilot table delete. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YGzbVDZpe2dEALbu2BUU8a * fix(tables): say which type a no-op column update restated A copilot `update_column` payload whose only content was the column's current type used to return success with the live schema, while the v1, v2, and UI routes rejected the same payload with "No updates specified". Delegating to `performUpdateTableColumn` unified them onto the routes' rejection — correct, but the message tells the caller its request was empty when it named a type. The orchestration function now reports the same thing `updateColumnType` reports when it loses this race concurrently: the column is already that type, re-issue without the type change. An empty payload still reads "No updates specified". Drops the copilot's `outcome.table ?? tableForUpdate` fallback with it — the comment described the no-op that can no longer reach that line, and a success always carries a table. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YGzbVDZpe2dEALbu2BUU8a * refactor(tables): classify failures by type instead of by message text The table module decided HTTP statuses by searching error messages for phrases. `VALIDATION_MESSAGE_FRAGMENTS` and `ROW_WRITE_ERROR_PATTERNS` held 32 substrings between them, and fifteen more lists were inlined in routes — 83 matchers over 17 files, each its own copy of the guesswork and already drifted apart. It made message wording load-bearing: `TableRowLimitError`'s own doc comment noted that its text had to contain "row limit" for a route to answer 400, and adding "already exists" to a rename message silently demoted a 409 to a 400 (the bug fixed one commit ago, by adding another special case). Services now throw `OrchestrationError`, which carries the transport-neutral `OrchestrationErrorCode` the layers above already speak. Classification is one `instanceof` in `orchestrationErrorResponse` (UI + v1) and `v2CaughtOrchestrationError` (v2). Every pattern list is gone. Wording is free to change; an unclassified error still becomes a generic 500, which is what an unexpected fault should be. `asOrchestrationError` walks the `cause` chain rather than testing the caught value directly: drizzle wraps a throw raised inside a transaction callback in a `DrizzleQueryError` whose own message is the failed SQL, so a bare `instanceof` would drop every failure raised inside `withLockedTable`. That is the same reason `rootErrorMessage` had to dig for a root cause before. Three throws stay bare `Error` deliberately — `Table ID mismatch`, `Workspace ID mismatch`, and `Failed to build upsert conflict predicate` are internal invariants no consumer classified, and they keep falling through to a 500. `Insufficient capacity` was in the pattern list with no producer anywhere in the codebase. Status changes, all deliberate: - `'forbidden'` joins the code union so the table-row-limit ceiling keeps its 403; without it this refactor would have flattened it to 400. - import-async's table-limit rejection: 400 -> 403, matching the two other create routes it had drifted from. - Renaming a table to an invalid name: 500 -> 400. `validateTableName` messages don't contain "Invalid", so no matcher ever caught them. - Restoring a table that isn't archived, or into an archived workspace: 500 -> 400. - A duplicate *column* name stays `validation`/400 rather than becoming a 409 like a duplicate table name. Both v1 and the orchestration have always answered 400 for it; changing a published status is not this refactor's job. The twelve tests that changed were asserting the substring mechanism itself, constructing plain `Error`s with magic strings. They now assert the real contract, plus new cases pinning that identical wording carrying no classification stays internal and keeps its message off the wire. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YGzbVDZpe2dEALbu2BUU8a --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent fbc7b17 commit 5fea5f7

62 files changed

Lines changed: 2170 additions & 1330 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/sim/app/api/table/[tableId]/columns/route.test.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,13 @@ vi.mock('@/lib/table', () => ({
4242
updateColumnOptions: mockUpdateColumnOptions,
4343
updateColumnType: mockUpdateColumnType,
4444
}))
45+
vi.mock('@/lib/table/columns/service', () => ({
46+
renameColumn: mockRenameColumn,
47+
updateColumnConstraints: mockUpdateColumnConstraints,
48+
updateColumnCurrency: mockUpdateColumnCurrency,
49+
updateColumnOptions: mockUpdateColumnOptions,
50+
updateColumnType: mockUpdateColumnType,
51+
}))
4552
vi.mock('@/app/api/table/utils', () => ({
4653
accessError: () => new Response('denied', { status: 403 }),
4754
checkAccess: mockCheckAccess,
@@ -50,6 +57,7 @@ vi.mock('@/app/api/table/utils', () => ({
5057
tableLockErrorResponse: () => null,
5158
}))
5259

60+
import { OrchestrationError } from '@/lib/core/orchestration/types'
5361
import { PATCH } from '@/app/api/table/[tableId]/columns/route'
5462

5563
const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111'
@@ -159,7 +167,10 @@ describe('PATCH /api/table/[tableId]/columns — pre-flight guards', () => {
159167
// Stands in for the race the guards cannot close: the column stopped being
160168
// a currency between the snapshot the guards read and this write.
161169
mockUpdateColumnCurrency.mockRejectedValue(
162-
new Error('Cannot set currency on column "amount" of type "string"')
170+
new OrchestrationError(
171+
'validation',
172+
'Cannot set currency on column "amount" of type "string"'
173+
)
163174
)
164175

165176
const response = await patch({ name: 'renamed', currencyCode: 'USD' })

apps/sim/app/api/table/[tableId]/columns/route.ts

Lines changed: 15 additions & 207 deletions
Original file line numberDiff line numberDiff line change
@@ -8,20 +8,11 @@ import {
88
import { parseRequest } from '@/lib/api/server'
99
import { isZodError, validationErrorResponse } from '@/lib/api/server/validation'
1010
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
11+
import { statusForOrchestrationError } from '@/lib/core/orchestration/types'
1112
import { generateRequestId } from '@/lib/core/utils/request'
1213
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
13-
import {
14-
addTableColumn,
15-
deleteColumn,
16-
renameColumn,
17-
updateColumnConstraints,
18-
updateColumnCurrency,
19-
updateColumnOptions,
20-
updateColumnType,
21-
} from '@/lib/table'
22-
import { columnMatchesRef, getColumnId } from '@/lib/table/column-keys'
23-
import { columnTypeById } from '@/lib/table/column-types'
24-
import { isSupportedCurrencyCode } from '@/lib/table/currency'
14+
import { addTableColumn, deleteColumn } from '@/lib/table'
15+
import { performUpdateTableColumn } from '@/lib/table/orchestration'
2516
import {
2617
accessError,
2718
checkAccess,
@@ -120,215 +111,32 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu
120111
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
121112
}
122113

123-
const { updates } = validated
124-
let updatedTable = null
125-
126-
// A payload that repeats the current type must not go through
127-
// `updateColumnType` — it early-returns on an unchanged type and would drop
128-
// any `options` alongside it. Only a real type change routes there; an
129-
// unchanged type with options routes to the options-only update.
130-
const currentColumn = table.schema.columns.find((c) =>
131-
columnMatchesRef(c, validated.columnName)
132-
)
133-
// Address every write below by the stable id, not the name: a rename folded
134-
// into one of them must not break the next one's lookup.
135-
const columnRef = currentColumn ? getColumnId(currentColumn) : validated.columnName
136-
// The constraints write below is a separate, unconditional step, so it is
137-
// the last one whenever it runs — that is the write the rename rides on.
138-
const typeChanging = updates.type !== undefined && updates.type !== currentColumn?.type
139-
if (!currentColumn) {
140-
return NextResponse.json(
141-
{ error: `Column "${validated.columnName}" not found` },
142-
{ status: 404 }
143-
)
144-
}
145-
146-
// A retype applies and validates the constraints itself, so the separate
147-
// constraint write only runs when the type is unchanged. The rename rides
148-
// whichever write actually runs last.
149-
const typedWriteRuns =
150-
typeChanging ||
151-
updates.currencyCode !== undefined ||
152-
updates.options !== undefined ||
153-
updates.multiple !== undefined
154-
const constraintsWriteRuns =
155-
!typedWriteRuns && (updates.required !== undefined || updates.unique !== undefined)
156-
const renameWithTypedWrite =
157-
updates.name && !constraintsWriteRuns ? { newName: updates.name } : {}
158-
159-
// Every write below is its own locked transaction, so one that is going to
160-
// fail leaves the earlier ones committed. These guards reject the knowable
161-
// cases up front, before any write at all.
162-
// Gate on the type the column ENDS UP with, not on whether the type is
163-
// changing: an options-only update on an existing select column carries the
164-
// same hazard as a conversion does.
165-
const resultingType = updates.type ?? currentColumn?.type
166-
if (updates.currencyCode !== undefined) {
167-
if (resultingType !== 'currency') {
168-
return NextResponse.json(
169-
{
170-
error: `Cannot set currency on column "${validated.columnName}" of type "${resultingType}"`,
171-
},
172-
{ status: 400 }
173-
)
174-
}
175-
if (!isSupportedCurrencyCode(updates.currencyCode)) {
176-
return NextResponse.json(
177-
{
178-
error: `Invalid currency code "${updates.currencyCode}". Use an ISO 4217 code, e.g. USD`,
179-
},
180-
{ status: 400 }
181-
)
182-
}
183-
}
184-
// The rename runs last (see below), so a name already taken would fail after
185-
// the typed write committed. This is the only rename failure a caller can
186-
// cause; catching it here leaves just the concurrent-collision race, which
187-
// no pre-flight check can close.
188-
if (
189-
updates.name &&
190-
table.schema.columns.some(
191-
(c) =>
192-
c.name.toLowerCase() === updates.name?.toLowerCase() &&
193-
!columnMatchesRef(c, validated.columnName)
194-
)
195-
) {
196-
return NextResponse.json(
197-
{ error: `Column "${updates.name}" already exists` },
198-
{ status: 400 }
199-
)
200-
}
201-
if (
202-
currentColumn?.workflowGroupId &&
203-
(updates.required !== undefined || updates.unique !== undefined)
204-
) {
205-
return NextResponse.json(
206-
{
207-
error: `Cannot change constraints on workflow-output column "${currentColumn.name}". Constraints aren't applicable to columns whose values come from workflow execution.`,
208-
},
209-
{ status: 400 }
210-
)
211-
}
212-
if (updates.unique === true && !columnTypeById(resultingType).supportsUnique) {
114+
const outcome = await performUpdateTableColumn({
115+
table,
116+
columnName: validated.columnName,
117+
userId: authResult.userId,
118+
updates: validated.updates,
119+
requestId,
120+
request,
121+
})
122+
if (!outcome.success || !outcome.table) {
213123
return NextResponse.json(
214-
{ error: `Cannot set a ${resultingType} column as unique` },
215-
{ status: 400 }
124+
{ error: outcome.error ?? 'Failed to update column' },
125+
{ status: statusForOrchestrationError(outcome.errorCode) }
216126
)
217127
}
218128

219-
if (typeChanging) {
220-
updatedTable = await updateColumnType(
221-
{
222-
tableId,
223-
columnName: columnRef,
224-
newType: updates.type as NonNullable<typeof updates.type>,
225-
...(updates.options !== undefined ? { options: updates.options } : {}),
226-
...(updates.multiple !== undefined ? { multiple: updates.multiple } : {}),
227-
...(updates.currencyCode !== undefined ? { currencyCode: updates.currencyCode } : {}),
228-
// Forwarded so the conversion validates against the constraint this
229-
// same request is about to set, not the column's current one.
230-
...(updates.required !== undefined ? { required: updates.required } : {}),
231-
...(updates.unique !== undefined ? { unique: updates.unique } : {}),
232-
...renameWithTypedWrite,
233-
},
234-
requestId
235-
)
236-
} else if (updates.currencyCode !== undefined) {
237-
// Re-denominating an existing currency column: schema-only, no cell
238-
// rewrite. Reached only when the type is unchanged — a conversion INTO
239-
// currency carries the code through `updateColumnType` above.
240-
updatedTable = await updateColumnCurrency(
241-
{
242-
tableId,
243-
columnName: columnRef,
244-
currencyCode: updates.currencyCode,
245-
...(updates.required !== undefined ? { required: updates.required } : {}),
246-
...(updates.unique !== undefined ? { unique: updates.unique } : {}),
247-
...renameWithTypedWrite,
248-
},
249-
requestId
250-
)
251-
} else if (updates.options !== undefined || updates.multiple !== undefined) {
252-
updatedTable = await updateColumnOptions(
253-
{
254-
tableId,
255-
columnName: columnRef,
256-
options: updates.options ?? currentColumn?.options ?? [],
257-
...(updates.multiple !== undefined ? { multiple: updates.multiple } : {}),
258-
// Forwarded so the removal guard validates against the constraint this
259-
// same request is about to set, not the column's current one.
260-
...(updates.required !== undefined ? { required: updates.required } : {}),
261-
...(updates.unique !== undefined ? { unique: updates.unique } : {}),
262-
...renameWithTypedWrite,
263-
},
264-
requestId
265-
)
266-
}
267-
268-
// Skipped whenever a typed write ran: that write already applied and
269-
// validated these, in one transaction with the change they accompany.
270-
if (constraintsWriteRuns) {
271-
updatedTable = await updateColumnConstraints(
272-
{
273-
tableId,
274-
columnName: columnRef,
275-
...(updates.required !== undefined ? { required: updates.required } : {}),
276-
...(updates.unique !== undefined ? { unique: updates.unique } : {}),
277-
...(updates.name ? { newName: updates.name } : {}),
278-
},
279-
requestId
280-
)
281-
}
282-
283-
// A rename rides along with the LAST write above, inside that write's
284-
// transaction — a rename is metadata-only (rows key on the stable column
285-
// id), so nothing forces it to be its own write, and folding it in is what
286-
// stops a combined request from committing one half and then failing. Only
287-
// a rename with nothing to ride on runs standalone.
288-
if (updates.name && !updatedTable) {
289-
updatedTable = await renameColumn(
290-
{ tableId, oldName: columnRef, newName: updates.name },
291-
requestId
292-
)
293-
}
294-
295-
if (!updatedTable) {
296-
return NextResponse.json({ error: 'No updates specified' }, { status: 400 })
297-
}
298-
299129
return NextResponse.json({
300130
success: true,
301131
data: {
302-
columns: updatedTable.schema.columns.map(normalizeColumn),
132+
columns: outcome.table.schema.columns.map(normalizeColumn),
303133
},
304134
})
305135
} catch (error) {
306-
const lockError = tableLockErrorResponse(error)
307-
if (lockError) return lockError
308136
if (isZodError(error)) {
309137
return validationErrorResponse(error, 'Invalid request data')
310138
}
311139

312-
const msg = rootErrorMessage(error)
313-
if (msg.includes('not found') || msg.includes('Table not found')) {
314-
return NextResponse.json({ error: msg }, { status: 404 })
315-
}
316-
if (
317-
msg.includes('already exists') ||
318-
msg.includes('Cannot delete the last column') ||
319-
msg.includes('Cannot set column') ||
320-
msg.includes('Cannot set unique column') ||
321-
msg.includes('Invalid column') ||
322-
msg.includes('exceeds maximum') ||
323-
msg.includes('incompatible') ||
324-
msg.includes('duplicate') ||
325-
msg.includes('option') ||
326-
msg.includes('currency') ||
327-
msg.includes('is already type')
328-
) {
329-
return NextResponse.json({ error: msg }, { status: 400 })
330-
}
331-
332140
logger.error(`[${requestId}] Error updating column in table ${tableId}:`, error)
333141
return NextResponse.json({ error: 'Failed to update column' }, { status: 500 })
334142
}

apps/sim/app/api/table/[tableId]/columns/run/route.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,12 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
88
import { TableQueryValidationError } from '@/lib/table/errors'
99
import { toLegacyFilter } from '@/lib/table/query-builder/converters'
1010
import { runWorkflowColumn } from '@/lib/table/workflow-columns'
11-
import { accessError, checkAccess, tableFilterError } from '@/app/api/table/utils'
11+
import {
12+
accessError,
13+
checkAccess,
14+
orchestrationErrorResponse,
15+
tableFilterError,
16+
} from '@/app/api/table/utils'
1217

1318
const logger = createLogger('TableRunColumnAPI')
1419

@@ -66,9 +71,8 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro
6671
if (error instanceof TableQueryValidationError) {
6772
return NextResponse.json({ error: error.message }, { status: 400 })
6873
}
69-
if (error instanceof Error && error.message === 'Invalid workspace ID') {
70-
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
71-
}
74+
const classified = orchestrationErrorResponse(error)
75+
if (classified) return classified
7276
logger.error(`run-column failed:`, error)
7377
return NextResponse.json({ error: 'Failed to run columns' }, { status: 500 })
7478
}

apps/sim/app/api/table/[tableId]/import/route.test.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ vi.mock('@/lib/table/billing', () => ({
7979
limit >= 0 && current + added > limit,
8080
}))
8181

82+
import { OrchestrationError } from '@/lib/core/orchestration/types'
8283
import { TableLockedError } from '@/lib/table/mutation-locks'
8384
import { POST } from '@/app/api/table/[tableId]/import/route'
8485

@@ -372,7 +373,10 @@ describe('POST /api/table/[tableId]/import', () => {
372373

373374
it('surfaces unique violations from importAppendRows as 400', async () => {
374375
mockImportAppendRows.mockRejectedValueOnce(
375-
new Error('Row 1: Column "name" must be unique. Value "Alice" already exists in row row_xxx')
376+
new OrchestrationError(
377+
'validation',
378+
'Row 1: Column "name" must be unique. Value "Alice" already exists in row row_xxx'
379+
)
376380
)
377381
const response = await callPost(
378382
createFormData(createCsvFile('name,age\nAlice,30'), { mode: 'append' })
@@ -516,7 +520,9 @@ describe('POST /api/table/[tableId]/import', () => {
516520
})
517521

518522
it('surfaces column-creation failures from importAppendRows as 400', async () => {
519-
mockImportAppendRows.mockRejectedValueOnce(new Error('Column "email" already exists'))
523+
mockImportAppendRows.mockRejectedValueOnce(
524+
new OrchestrationError('validation', 'Column "email" already exists')
525+
)
520526
const response = await callPost(
521527
createFormData(createCsvFile('name,age,email\nAlice,30,a@x.io'), {
522528
mode: 'append',
@@ -529,7 +535,9 @@ describe('POST /api/table/[tableId]/import', () => {
529535
})
530536

531537
it('surfaces row insert failures without success when schema was mutated', async () => {
532-
mockImportAppendRows.mockRejectedValueOnce(new Error('must be unique'))
538+
mockImportAppendRows.mockRejectedValueOnce(
539+
new OrchestrationError('validation', 'must be unique')
540+
)
533541
const response = await callPost(
534542
createFormData(createCsvFile('name,age,email\nAlice,30,a@x.io'), {
535543
mode: 'append',

0 commit comments

Comments
 (0)