Skip to content

Commit 83e04cb

Browse files
fix(api): make async table imports observable, not just startable
`POST /import-async` pointed callers at `GET /api/v2/tables/jobs` to track progress, but that endpoint filters to `type = 'export'` — imports are derived onto the table itself, one write job at a time, and exports get a separate list precisely because they are excluded from that derivation. The public Table shape omitted those derived fields, so an async import could be started and cancelled but never observed to completion, failure, or progress. That is the gap the import/export/job-control set was meant to close. Table now carries `job` — id, type, status, rowsProcessed, error, or null when idle — and the import-async docs point at the table rather than the export list.
1 parent 8ccf529 commit 83e04cb

6 files changed

Lines changed: 109 additions & 6 deletions

File tree

apps/docs/openapi-v2-tables.json

Lines changed: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,8 @@
103103
"insertLocked": false,
104104
"updateLocked": false,
105105
"deleteLocked": false
106-
}
106+
},
107+
"job": null
107108
}
108109
],
109110
"nextCursor": null
@@ -211,7 +212,8 @@
211212
"insertLocked": false,
212213
"updateLocked": false,
213214
"deleteLocked": false
214-
}
215+
},
216+
"job": null
215217
}
216218
}
217219
}
@@ -467,7 +469,8 @@
467469
"deleteLocked": true
468470
},
469471
"createdAt": "2026-01-15T10:30:00.000Z",
470-
"updatedAt": "2026-01-16T09:12:00.000Z"
472+
"updatedAt": "2026-01-16T09:12:00.000Z",
473+
"job": null
471474
}
472475
}
473476
}
@@ -1720,7 +1723,8 @@
17201723
"deleteLocked": true
17211724
},
17221725
"createdAt": "2026-01-15T10:30:00.000Z",
1723-
"updatedAt": "2026-01-16T09:12:00.000Z"
1726+
"updatedAt": "2026-01-16T09:12:00.000Z",
1727+
"job": null
17241728
}
17251729
}
17261730
}
@@ -2916,7 +2920,7 @@
29162920
"post": {
29172921
"operationId": "importTableCsvAsync",
29182922
"summary": "Import CSV (Background)",
2919-
"description": "Start a background import of a file already uploaded to workspace storage \u2014 the path for files too large for the synchronous import.\n\nReturns as soon as the job is queued. Track it with `GET /api/v2/tables/jobs` and stop it with `POST /job/cancel`. `fileKey` must sit under this workspace\u2019s storage prefix. The table\u2019s lock flags are checked before the job slot is claimed, so a locked table answers 423 here rather than failing inside the worker.",
2923+
"description": "Start a background import of a file already uploaded to workspace storage \u2014 the path for files too large for the synchronous import.\n\nReturns as soon as the job is queued. Track it on the table itself \u2014 `GET /api/v2/tables/{tableId}` returns a `job` object with `status`, `rowsProcessed` and `error` while the import runs \u2014 and stop it with `POST /job/cancel`. (`GET /api/v2/tables/jobs` lists exports only: those run concurrently and are not derived onto the table.) `fileKey` must sit under this workspace\u2019s storage prefix. The table\u2019s lock flags are checked before the job slot is claimed, so a locked table answers 423 here rather than failing inside the worker.",
29202924
"tags": ["Tables"],
29212925
"x-codeSamples": [
29222926
{
@@ -3711,7 +3715,8 @@
37113715
"folderId",
37123716
"locks",
37133717
"createdAt",
3714-
"updatedAt"
3718+
"updatedAt",
3719+
"job"
37153720
],
37163721
"properties": {
37173722
"id": {
@@ -3767,6 +3772,17 @@
37673772
},
37683773
"locks": {
37693774
"$ref": "#/components/schemas/TableLocks"
3775+
},
3776+
"job": {
3777+
"oneOf": [
3778+
{
3779+
"$ref": "#/components/schemas/TableJobState"
3780+
},
3781+
{
3782+
"type": "null"
3783+
}
3784+
],
3785+
"description": "In-flight background job, or null when the table is idle."
37703786
}
37713787
}
37723788
},
@@ -5422,6 +5438,33 @@
54225438
}
54235439
}
54245440
}
5441+
},
5442+
"TableJobState": {
5443+
"type": "object",
5444+
"description": "The table's in-flight background job. Import and delete jobs are derived onto the table itself (one write job per table), so the table is their status endpoint \u2014 poll `GET /api/v2/tables/{tableId}` after starting one. Exports are read-only and run concurrently, so they are listed separately by `GET /api/v2/tables/jobs` instead.",
5445+
"required": ["id", "type", "status", "rowsProcessed", "error"],
5446+
"properties": {
5447+
"id": {
5448+
"type": ["string", "null"],
5449+
"description": "Job id \u2014 pass to `POST /job/cancel` to stop it."
5450+
},
5451+
"type": {
5452+
"enum": ["import", "delete", "export", "backfill", "update", null],
5453+
"description": "Which kind of job is running."
5454+
},
5455+
"status": {
5456+
"enum": ["running", "ready", "failed", "canceled"],
5457+
"description": "`running` is in-flight; the rest are terminal."
5458+
},
5459+
"rowsProcessed": {
5460+
"type": "integer",
5461+
"description": "Rows handled so far \u2014 progress for a running job."
5462+
},
5463+
"error": {
5464+
"type": ["string", "null"],
5465+
"description": "Failure reason for a `failed` job; null otherwise."
5466+
}
5467+
}
54255468
}
54265469
},
54275470
"responses": {

apps/sim/app/api/v2/tables/[tableId]/restore/route.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ describe('POST /api/v2/tables/[tableId]/restore', () => {
109109
maxRows: 1000,
110110
folderId: null,
111111
locks: UNLOCKED,
112+
job: null,
112113
createdAt: '2026-01-01T00:00:00.000Z',
113114
updatedAt: '2026-01-02T00:00:00.000Z',
114115
},

apps/sim/app/api/v2/tables/[tableId]/route.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,7 @@ describe('PATCH /api/v2/tables/[tableId]', () => {
192192
maxRows: 1000,
193193
folderId: null,
194194
locks: UNLOCKED,
195+
job: null,
195196
createdAt: '2026-01-01T00:00:00.000Z',
196197
updatedAt: '2026-01-02T00:00:00.000Z',
197198
},
@@ -203,6 +204,31 @@ describe('PATCH /api/v2/tables/[tableId]', () => {
203204
expect(mockPerformUpdateTableLocks).not.toHaveBeenCalled()
204205
})
205206

207+
it('surfaces a running import so an async job is observable, not just startable', async () => {
208+
// `POST /import-async` and `POST /job/cancel` let a caller start and stop an
209+
// import; without this the table never reports that it is running, so there
210+
// is nothing to poll between the two.
211+
mockGetTableById.mockResolvedValue({
212+
...UPDATED_TABLE,
213+
jobStatus: 'running',
214+
jobId: 'job-1',
215+
jobType: 'import',
216+
jobRowsProcessed: 250,
217+
jobError: null,
218+
})
219+
mockPerformRenameTable.mockResolvedValue({ success: true })
220+
221+
const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed' })
222+
223+
expect((await res.json()).data.table.job).toEqual({
224+
id: 'job-1',
225+
type: 'import',
226+
status: 'running',
227+
rowsProcessed: 250,
228+
error: null,
229+
})
230+
})
231+
206232
it('moves the table only after confirming the folder belongs to the workspace', async () => {
207233
mockPerformMoveTableToFolder.mockResolvedValue({ success: true })
208234

apps/sim/app/api/v2/tables/import-csv/route.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ describe('POST /api/v2/tables/import-csv', () => {
121121
maxRows: 1000,
122122
folderId: null,
123123
locks: UNLOCKED,
124+
job: null,
124125
createdAt: '2026-01-01T00:00:00.000Z',
125126
updatedAt: '2026-01-01T00:00:00.000Z',
126127
},

apps/sim/app/api/v2/tables/utils.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,18 @@ export function toApiTable(table: TableDefinition) {
6565
maxRows: table.maxRows,
6666
folderId: table.folderId ?? null,
6767
locks: table.locks,
68+
// `jobStatus` is the presence signal — the service leaves the whole group
69+
// null when the table is idle. Without this an async import could be
70+
// started and cancelled but never observed to completion or failure.
71+
job: table.jobStatus
72+
? {
73+
id: table.jobId ?? null,
74+
type: table.jobType ?? null,
75+
status: table.jobStatus,
76+
rowsProcessed: table.jobRowsProcessed ?? 0,
77+
error: table.jobError ?? null,
78+
}
79+
: null,
6880
createdAt: toIso(table.createdAt),
6981
updatedAt: toIso(table.updatedAt),
7082
}

apps/sim/lib/api/contracts/v2/tables.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,24 @@ export const V2_MAX_ROW_LIMIT = 1000
7676
* Public table shape emitted by `toApiTable` (timestamps ISO-serialized).
7777
* Concrete so the v2 contract describes exactly what the wire carries.
7878
*/
79+
/**
80+
* The table's current background job, or `null` when idle.
81+
*
82+
* This is how an async import or delete is observed. Those jobs are derived
83+
* onto the table itself (one write job per table at a time), so the table is
84+
* their status endpoint — unlike exports, which are read-only, run concurrently,
85+
* and therefore have the dedicated `GET /api/v2/tables/jobs` list instead.
86+
*/
87+
export const v2TableJobStateSchema = z.object({
88+
id: z.string().nullable(),
89+
type: z.enum(['import', 'delete', 'export', 'backfill', 'update']).nullable(),
90+
status: z.enum(['running', 'ready', 'failed', 'canceled']),
91+
rowsProcessed: z.number(),
92+
/** Failure reason for a `failed` job; `null` otherwise. */
93+
error: z.string().nullable(),
94+
})
95+
export type V2TableJobState = z.output<typeof v2TableJobStateSchema>
96+
7997
export const v2ApiTableSchema = z.object({
8098
id: z.string(),
8199
name: z.string(),
@@ -87,6 +105,8 @@ export const v2ApiTableSchema = z.object({
87105
folderId: z.string().nullable(),
88106
/** Governance flags. Writable only by a workspace admin via `PATCH`. */
89107
locks: tableLocksSchema,
108+
/** In-flight background job, or `null` when the table is idle. */
109+
job: v2TableJobStateSchema.nullable(),
90110
createdAt: z.string(),
91111
updatedAt: z.string(),
92112
})

0 commit comments

Comments
 (0)