Skip to content

Commit 5692261

Browse files
feat(api): expand v2 tables with stateless multipart transfers (#6188)
* feat(api): expand the public v2 tables surface Adds 16 operations so a v2 caller can do what the internal surface can: rename/move/lock a table, restore it, manage saved views, run enrichment columns, look up rows, and import/export with observable job control. Extracts lib/table/orchestration/import.ts (performTableCsvImport, performCreateTableFromCsv) and lib/table/export-stream.ts from the first-party routes, then repoints those routes at them, so v1 and v2 cannot drift on what an import or export actually does. events/stream, metadata and dispatches stay internal — they are editor state, not public API. * fix(api): make v2 table PATCH all-or-nothing and name the lock in every 423 Greptile P1: PATCH applied locks, rename and move as three sequential transactions, so a folder rejected mid-request left the earlier writes persisted while the response reported failure — and the schema-changed signal was skipped, leaving open clients on stale state. Every rejectable condition now runs before the first write, and the signal fires whenever anything did land. Cursor: v2TableLockError dropped the lock kind, so async import, column run, enrichment and table mutations returned a bare LOCKED. A table has four independent locks, so the caller could not tell which to clear. * fix(api): report the lock kind on classified 423s too, not just thrown ones The previous commit named the lock only where the rejection was thrown and caught at the route boundary. Where it instead arrives as a classified `errorCode: 'locked'` outcome — delete table, delete row, update column, and the table mutations — the kind was dropped, so those 423s stayed unactionable while their neighbours improved. The orchestration results now carry `lock`, and a shared `v2TableOrchestrationError` renders both arrival paths into the same `{ code, message, details: { lock } }` body. `details` is omitted rather than sent null when the kind is unknown, so a caller branching on it sees absence instead of a phantom value. * 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. * feat(api): make v2 table PATCH state which operations landed on failure Greptile held the PR at 4/5 on the residual non-atomicity and named two acceptable resolutions: make PATCH atomic, or have the contract adopt and expose partial-success explicitly. Atomicity would mean threading one transaction through renameTable, moveTableToFolder and updateTableLocks — three shared service functions with four non-test callers including the first-party route and two copilot tools — and deferring their per-operation audits to commit time. That is a refactor of shared write paths well outside this PR. So the contract states it instead. Every rejectable condition is already pre-validated, so a failure here is a genuine fault; when one follows a successful operation the error now carries `details.applied` listing what is live. Absent when nothing applied, so its presence always means "these changes took effect despite the error". Documented on the operation. `v2ErrorForOrchestration` gained the optional `details` this needs. * fix(api): make table lock flags read-only on the public v2 surface The new PATCH /api/v2/tables/[tableId] accepted a `locks` object, gated on workspace admin plus the table-locks feature. That still lets an API key clear the guard placed there to stop it: `write` is the floor for the endpoint, and admin keys are ordinary API keys, so a lock is no longer a boundary the key cannot cross. Locks stay readable on the table resource and enforcement is unchanged (a locked verb still returns 423). Changing one is now a first-party admin action only. The v2 body is declared here rather than reusing the first-party updateTableBodySchema, which keeps its `locks` field so the UI can still toggle them. It is .strict(), so a request carrying `locks` is rejected with a 400 naming the field instead of silently succeeding without applying it. * fix(api): keep reporting applied operations when the PATCH re-read fails The composite table PATCH promises that `error.details.applied` names the operations that are live despite an error, but `applied` was scoped inside the try. A rename or move that committed and was then followed by a throw in the final re-read — or a re-read finding the table archived — returned a bare 500/404 with no details, telling the caller nothing had landed. It would then retry into a duplicate-name conflict or repeat the move. `applied` is now function-scoped so every post-write exit carries it: the 404 on a missing re-read, a thrown lock error, a classified orchestration error, and the generic 500. `v2TableLockError` gains the same `extraDetails` parameter `v2TableOrchestrationError` already had. * feat(api): add workflow group writes to the v2 tables surface v2 exposed GET /groups but none of the writes, so the public API could run an enrichment or workflow column and read its binding, but never create one. A caller could add a plain data column and trigger the machine; wiring the two together still required the UI. Adds POST/PATCH/DELETE on /api/v2/tables/[tableId]/groups. The group is the unit that fills columns — one group feeds several — so creating one creates its output columns in the same call, matching the first-party shape rather than inverting it onto the column endpoint. Four departures from the first-party body, all public-surface concerns: - group.id is optional and server-generated. The UI mints an id to render optimistically; a public caller has no such need and a client-chosen id is a collision waiting to happen. - outputColumns[].workflowGroupId is dropped from the body and stamped from the resolved group, so it cannot disagree with it. - autoRun defaults to false. First-party defaults true so a UI add fills cells immediately; here it would make one POST fan out a metered run across every existing row. - A group naming neither a workflowId (type manual) nor an enrichmentId (type enrichment) is a 400 rather than a half-specified group the route has to guess about. Also rejects an outputColumns entry no group output feeds — the two arrays are joined by column name, and the first-party client builds both from one picker so it cannot desync, but a public caller can. Workspace containment on workflowId is asserted before it is persisted, on create and on any update that re-points the group; without it a table becomes a way to invoke workflows the key cannot otherwise reach. * improvement(api): make v2 table import and export async-only Drops the three synchronous entry points: POST /tables/[tableId]/import, POST /tables/import-csv, and GET /tables/[tableId]/export. Sync import tied a write to the lifetime of an HTTP request. The body *was* the data, so it carried a 10 MB cap that Next silently truncates past — a partial import reporting success. It also had no job, so a timeout mid-write left rows in place with nothing to poll and nothing to cancel. The async path reads the file from storage instead: upload via POST /api/v2/files for a key, start with POST /import-async, watch GET /tables/[tableId] -> job, stop with POST /job/cancel. Sync export carried no such hazard, but one shape per operation beats two: with both removed the surface has exactly one way to move a table in or out, and the CLI wraps the extra calls. This also removes the last multipart handling in v2 tables. Those were the only routes bypassing parseRequest — form fields were parsed by hand against separate form schemas, outside the contract system every other v2 write goes through. Create-a-table-from-CSV is now two calls: POST /tables, then /import-async with createColumns. csvImportModeSchema is append|replace, so there is no single-call create. Route baseline 1064 -> 1061. * docs(api): correct the import-async note about upload size limits The docstring claimed there is no synchronous upload endpoint and so no request-body size cliff. Both are wrong: POST /api/v2/files is a synchronous multipart upload with a 100 MB cap, and it is the only v2 upload path (presigned is deliberately absent). What async-only actually bought: the cap went 10 MB -> 100 MB, it fails on an explicit size check and a bounded body read rather than a proxy cap that silently truncates, authorization completes before any body is buffered, and the table write is a job that can be watched and cancelled. * feat(api): unify file and table transfers * improvement(api): make multipart transfers stateless * fix(api): make table import completion retries idempotent
1 parent ca1dad8 commit 5692261

114 files changed

Lines changed: 32330 additions & 2246 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/docs/openapi-v2-files-audit.json

Lines changed: 128 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@
169169
}
170170
}
171171
},
172-
"post": {
172+
"x-removed-buffered-post": {
173173
"operationId": "uploadFile",
174174
"summary": "Upload File",
175175
"description": "Upload a file to a workspace as `multipart/form-data` with a single `file` field. The workspace — and the optional target `folderId` — are supplied as query parameters (not form fields) so authorization runs before the request body is buffered. Maximum file size is 100MB. A name already taken in the destination folder is **not** an error: the name is auto-suffixed (`data.csv` -> `data (1).csv`), matching the in-app uploader, so a `201` can come back with a `name` different from the one you sent — always read `name` from the response rather than assuming it. `409` is returned only if a unique name cannot be allocated after several attempts. Use `PATCH /api/v2/files/{fileId}` if you need a specific name to be exact-or-fail. Returns `201 Created`.\n\nPresigned upload is not part of the public API: it debits the storage quota only in a separate register step, so a caller that never registers would leave unaccounted bytes in storage. This buffered path debits inside the upload transaction.",
@@ -315,6 +315,126 @@
315315
}
316316
}
317317
},
318+
"/api/v2/files/uploads": {
319+
"post": {
320+
"operationId": "createFileUpload",
321+
"summary": "Create File Upload",
322+
"description": "Create a stateless multipart upload session and signed upload token. Every file uses this flow; a small file is a single part. The maximum file size is 5 GB.",
323+
"tags": ["Files"],
324+
"requestBody": {
325+
"required": true,
326+
"content": { "application/json": { "schema": {} } }
327+
},
328+
"responses": {
329+
"201": {
330+
"description": "The upload session.",
331+
"content": { "application/json": { "schema": {} } }
332+
},
333+
"400": { "$ref": "#/components/responses/BadRequest" },
334+
"401": { "$ref": "#/components/responses/Unauthorized" },
335+
"403": { "$ref": "#/components/responses/Forbidden" },
336+
"429": { "$ref": "#/components/responses/RateLimited" },
337+
"500": { "$ref": "#/components/responses/InternalError" }
338+
}
339+
}
340+
},
341+
"/api/v2/files/uploads/{uploadId}": {
342+
"delete": {
343+
"operationId": "abortFileUpload",
344+
"summary": "Abort File Upload",
345+
"description": "Abort an incomplete upload and discard its provider parts.",
346+
"tags": ["Files"],
347+
"parameters": [
348+
{
349+
"name": "uploadId",
350+
"in": "path",
351+
"required": true,
352+
"schema": { "type": "string" }
353+
},
354+
{ "$ref": "#/components/parameters/WorkspaceIdQuery" },
355+
{ "$ref": "#/components/parameters/UploadTokenHeader" }
356+
],
357+
"responses": {
358+
"200": {
359+
"description": "The aborted upload session.",
360+
"content": { "application/json": { "schema": {} } }
361+
},
362+
"401": { "$ref": "#/components/responses/Unauthorized" },
363+
"404": { "$ref": "#/components/responses/NotFound" },
364+
"409": { "$ref": "#/components/responses/Conflict" },
365+
"429": { "$ref": "#/components/responses/RateLimited" },
366+
"500": { "$ref": "#/components/responses/InternalError" }
367+
}
368+
}
369+
},
370+
"/api/v2/files/uploads/{uploadId}/parts": {
371+
"post": {
372+
"operationId": "createFileUploadPartUrls",
373+
"summary": "Create File Upload Part URLs",
374+
"description": "Issue short-lived signed PUT URLs for a bounded set of upload part numbers.",
375+
"tags": ["Files"],
376+
"parameters": [
377+
{
378+
"name": "uploadId",
379+
"in": "path",
380+
"required": true,
381+
"schema": { "type": "string" }
382+
},
383+
{ "$ref": "#/components/parameters/WorkspaceIdQuery" },
384+
{ "$ref": "#/components/parameters/UploadTokenHeader" }
385+
],
386+
"requestBody": {
387+
"required": true,
388+
"content": { "application/json": { "schema": {} } }
389+
},
390+
"responses": {
391+
"200": {
392+
"description": "Signed URLs for the requested parts.",
393+
"content": { "application/json": { "schema": {} } }
394+
},
395+
"400": { "$ref": "#/components/responses/BadRequest" },
396+
"401": { "$ref": "#/components/responses/Unauthorized" },
397+
"404": { "$ref": "#/components/responses/NotFound" },
398+
"409": { "$ref": "#/components/responses/Conflict" },
399+
"429": { "$ref": "#/components/responses/RateLimited" },
400+
"500": { "$ref": "#/components/responses/InternalError" }
401+
}
402+
}
403+
},
404+
"/api/v2/files/uploads/{uploadId}/complete": {
405+
"post": {
406+
"operationId": "completeFileUpload",
407+
"summary": "Complete File Upload",
408+
"description": "Verify every part, assemble the object, and atomically register the workspace file.",
409+
"tags": ["Files"],
410+
"parameters": [
411+
{
412+
"name": "uploadId",
413+
"in": "path",
414+
"required": true,
415+
"schema": { "type": "string" }
416+
},
417+
{ "$ref": "#/components/parameters/WorkspaceIdQuery" },
418+
{ "$ref": "#/components/parameters/UploadTokenHeader" }
419+
],
420+
"requestBody": {
421+
"required": true,
422+
"content": { "application/json": { "schema": {} } }
423+
},
424+
"responses": {
425+
"200": {
426+
"description": "The completed upload and registered file.",
427+
"content": { "application/json": { "schema": {} } }
428+
},
429+
"400": { "$ref": "#/components/responses/BadRequest" },
430+
"401": { "$ref": "#/components/responses/Unauthorized" },
431+
"404": { "$ref": "#/components/responses/NotFound" },
432+
"409": { "$ref": "#/components/responses/Conflict" },
433+
"429": { "$ref": "#/components/responses/RateLimited" },
434+
"500": { "$ref": "#/components/responses/InternalError" }
435+
}
436+
}
437+
},
318438
"/api/v2/files/{fileId}": {
319439
"get": {
320440
"operationId": "downloadFile",
@@ -1557,6 +1677,13 @@
15571677
"example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64"
15581678
}
15591679
},
1680+
"UploadTokenHeader": {
1681+
"name": "upload-token",
1682+
"in": "header",
1683+
"required": true,
1684+
"description": "The signed token returned when the multipart upload was created.",
1685+
"schema": { "type": "string", "minLength": 1 }
1686+
},
15601687
"FileIdPath": {
15611688
"name": "fileId",
15621689
"in": "path",

0 commit comments

Comments
 (0)