Skip to content

Commit a0fa865

Browse files
feat(cli): pick up v2 workflow CRUD, table transfers, and list search
79 → 111 operations across three merged PRs. The generator could not read the new contracts at all: a table view's filter is a recursive predicate, so Zod lifts it into `$defs` and refers to it, and `toTypeScript` threw on the first `$ref`. Those definitions are now hoisted into named aliases — recursion TypeScript resolves without complaint — named after the type that owns them so two operations lifting their own `__schema0` cannot collide. Uploading is no longer one multipart POST. `POST /api/v2/files` is gone, replaced by a presigned handshake, so `files upload` was left calling a route that no longer exists. It now creates the upload, signs part URLs in batches of 100 (each is short-lived, so signing all of them up front would expire the last ones), PUTs each part straight to storage, and completes with the ETags — aborting the upload if any step fails, since a half-finished one holds storage. Parts are read through `Blob.slice`, so only the part in flight is in memory. Verified byte-identical on a 24MB round trip. The rest is naming. `/cancel-runs`, `/rows/find`, `/restore`, `/columns/run` and the enrichment path each put a verb where a sub-resource was expected, so each had become a group holding a lone `create`. Transfer steps keep names that say what they are, since no single command drives a table import yet. Three new DELETEs needed gates, which the existing guard test caught. Aborting an upload and cancelling an import or export stop something in flight rather than destroying something kept, so those are exempt. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0148a65fkPhP4N8tgGPYtRTU
1 parent 0f31294 commit a0fa865

6 files changed

Lines changed: 2227 additions & 75 deletions

File tree

‎packages/sim-cli/src/commands/hand-written.ts‎

Lines changed: 124 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import chalk from 'chalk'
66
import type { Command } from 'commander'
77
import { clientFrom } from '../context.js'
88
import type { QueryRowsResponse } from '../generated/v2-api.js'
9-
import { SimApiError } from '../http/client.js'
9+
import { SimApiError, type SimClient } from '../http/client.js'
1010
import { type Column, printList, sanitize, text } from '../output/render.js'
1111

1212
/**
@@ -149,25 +149,101 @@ function contentTypeFor(name: string): string {
149149
return CONTENT_TYPES[extension] ?? 'application/octet-stream'
150150
}
151151

152-
/** The route's own ceiling. Checked here so a 100 MB body is never sent to be refused. */
153-
const MAX_UPLOAD_BYTES = 100 * 1024 * 1024
152+
interface UploadPartUrl {
153+
partNumber: number
154+
url: string
155+
headers: Record<string, string>
156+
}
157+
158+
interface FileUpload {
159+
id: string
160+
size: number
161+
partSize: number
162+
partCount: number
163+
uploadToken: string
164+
file: { id: string } | null
165+
}
166+
167+
/** The parts endpoint signs at most this many URLs per request. */
168+
const PART_URL_BATCH = 100
169+
170+
/**
171+
* Sends every part of a file to the storage URLs the API signs for it, and
172+
* returns what `complete` needs to reassemble them.
173+
*
174+
* URLs are requested in batches because each one is short-lived: signing all
175+
* 640 possible parts up front would leave the last ones expired by the time a
176+
* slow connection reached them.
177+
*
178+
* Parts go out one at a time. Concurrency would be faster, but a failure
179+
* mid-flight has to abort the whole upload anyway, and a sequential loop makes
180+
* "which part failed" unambiguous.
181+
*/
182+
async function uploadParts(
183+
client: SimClient,
184+
workspaceId: string,
185+
upload: FileUpload,
186+
blob: Blob
187+
): Promise<Array<{ partNumber: number; etag?: string }>> {
188+
const completed: Array<{ partNumber: number; etag?: string }> = []
189+
190+
for (let first = 1; first <= upload.partCount; first += PART_URL_BATCH) {
191+
const partNumbers = []
192+
for (let n = first; n < first + PART_URL_BATCH && n <= upload.partCount; n++) {
193+
partNumbers.push(n)
194+
}
195+
196+
const signed = await client.request<{ data: { parts: UploadPartUrl[] } }>(
197+
`/api/v2/files/uploads/${encodeURIComponent(upload.id)}/parts`,
198+
{
199+
method: 'POST',
200+
query: { workspaceId },
201+
headers: { 'upload-token': upload.uploadToken },
202+
body: { partNumbers },
203+
}
204+
)
205+
206+
for (const part of signed.data.parts) {
207+
const start = (part.partNumber - 1) * upload.partSize
208+
// `Blob.slice` is a view over the file on disk, so only the part being
209+
// sent is ever read — the point of not buffering the upload.
210+
const chunk = blob.slice(start, Math.min(start + upload.partSize, upload.size))
211+
212+
// boundary-raw-fetch: storage-signed URL on another origin, not the API
213+
const response = await fetch(part.url, {
214+
method: 'PUT',
215+
headers: part.headers,
216+
body: chunk,
217+
})
218+
if (!response.ok) {
219+
throw new SimApiError(
220+
`Part ${part.partNumber} failed with status ${response.status}`,
221+
response.status
222+
)
223+
}
224+
225+
// S3-compatible stores identify a part by the ETag they return; the API
226+
// treats it as optional because not every backend sends one.
227+
const etag = response.headers.get('etag')?.replace(/"/g, '')
228+
completed.push(etag ? { partNumber: part.partNumber, etag } : { partNumber: part.partNumber })
229+
}
230+
}
231+
232+
return completed
233+
}
154234

155235
export function attachHandWritten(program: Command): void {
156-
// ── files upload ── multipart, which the generated flag surface cannot express ──
236+
// ── files upload ── a presigned multipart handshake, not one request ──────
157237
group(program, 'files')
158238
.command('upload <path>')
159239
.description('Upload a file to the workspace')
160240
.option('--folder-id <id>', 'Target folder (defaults to the workspace root)')
161241
.option('--name <name>', 'Store it under a different name')
162242
.action(
163243
async (path: string, options: { folderId?: string; name?: string }, command: Command) => {
164-
const { client, profile } = clientFrom(command)
244+
const { client } = clientFrom(command)
165245
const workspaceId = client.requireWorkspace()
166246

167-
if (!profile.apiKey) {
168-
throw new SimApiError(`Not logged in on profile "${profile.name}". Run: sim login`, 0)
169-
}
170-
171247
let size: number
172248
try {
173249
const stats = await stat(path)
@@ -178,43 +254,54 @@ export function attachHandWritten(program: Command): void {
178254
throw new SimApiError(`Cannot read ${path}: ${(error as Error).message}`, 0)
179255
}
180256

181-
// Fail here rather than after streaming 100 MB the server will reject.
182-
if (size > MAX_UPLOAD_BYTES) {
183-
throw new SimApiError(
184-
`${path} is ${(size / 1024 / 1024).toFixed(1)}MB; the limit is 100MB`,
185-
0
186-
)
187-
}
257+
// The server sizes its own parts, but it cannot reject an empty file any
258+
// more cheaply than we can: a zero-byte upload has no parts to send.
259+
if (size === 0) throw new SimApiError(`${path} is empty`, 0)
188260

189261
const name = options.name ?? basename(path)
190-
const url = new URL(`${profile.endpoint}/api/v2/files`)
191-
url.searchParams.set('workspaceId', workspaceId)
192-
if (options.folderId) url.searchParams.set('folderId', options.folderId)
193-
194-
// `openAsBlob` keeps the file on disk and reads it as the request is
195-
// written; building a Buffer first would hold the whole upload in memory.
196-
const body = new FormData()
197-
body.append('file', await openAsBlob(path, { type: contentTypeFor(name) }), name)
198262

199-
const response = await fetch(url, {
263+
const created = await client.request<{ data: FileUpload }>('/api/v2/files/uploads', {
200264
method: 'POST',
201-
headers: { 'x-api-key': profile.apiKey },
202-
body,
265+
body: {
266+
workspaceId,
267+
name,
268+
contentType: contentTypeFor(name),
269+
size,
270+
...(options.folderId ? { folderId: options.folderId } : {}),
271+
},
203272
})
273+
const upload = created.data
204274

205-
const payload = (await response.json().catch(() => null)) as {
206-
data?: { id?: string }
207-
error?: { message?: string }
208-
} | null
275+
// Any failure past this point leaves an upload holding storage, so the
276+
// rest runs under an abort that the server also uses to release it.
277+
try {
278+
const blob = await openAsBlob(path)
279+
const parts = await uploadParts(client, workspaceId, upload, blob)
209280

210-
if (!response.ok) {
211-
throw new SimApiError(
212-
payload?.error?.message ?? `Upload failed with status ${response.status}`,
213-
response.status
281+
const completed = await client.request<{ data: FileUpload }>(
282+
`/api/v2/files/uploads/${encodeURIComponent(upload.id)}/complete`,
283+
{
284+
method: 'POST',
285+
query: { workspaceId },
286+
headers: { 'upload-token': upload.uploadToken },
287+
body: { parts },
288+
}
289+
)
290+
console.log(
291+
chalk.green(`✓ Uploaded ${name} (${completed.data.file?.id ?? completed.data.id})`)
214292
)
293+
} catch (error) {
294+
await client
295+
.request(`/api/v2/files/uploads/${encodeURIComponent(upload.id)}`, {
296+
method: 'DELETE',
297+
query: { workspaceId },
298+
headers: { 'upload-token': upload.uploadToken },
299+
})
300+
// The original failure is what the caller needs; a failed cleanup
301+
// must not replace it with a message about the cleanup.
302+
.catch(() => undefined)
303+
throw error
215304
}
216-
217-
console.log(chalk.green(`✓ Uploaded ${name} (${payload?.data?.id ?? 'unknown id'})`))
218305
}
219306
)
220307

‎packages/sim-cli/src/contract/commands.ts‎

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,13 @@ export const CLI_CONTRACT: CliContract = {
5050
deleteCredential: {
5151
confirm: 'This deletes the credential; anything authenticating with it stops working.',
5252
},
53+
deleteWorkflow: { confirm: 'This deletes the workflow and its run history.' },
54+
deleteTableView: { confirm: 'This deletes the saved view and its filters.' },
55+
deleteWorkflowGroup: {
56+
// Not just the grouping: the documented behaviour is that every column the
57+
// group fed goes with it, values included.
58+
confirm: 'This deletes the group, every column it fed, and the values in them.',
59+
},
5360
deleteFolder: {
5461
// The route archives the folder *and cascades to its contents*, so this is
5562
// the broadest delete on the surface — the message says so rather than
@@ -243,6 +250,40 @@ export const CLI_CONTRACT: CliContract = {
243250
describe: 'Enable or disable sharing for a file',
244251
},
245252

253+
// ─── The expanded tables surface ──────────────────────────────────────────
254+
// `/cancel-runs`, `/rows/find`, `/restore`, `/columns/run` and the enrichment
255+
// path all put a verb where the deriver expects a sub-resource, so each became
256+
// a group holding a lone `create`.
257+
cancelTableRuns: { command: 'tables cancel-runs', describe: 'Stop every running column job' },
258+
findTableRows: { command: 'tables rows find', describe: 'Find rows matching a predicate' },
259+
restoreTable: { command: 'tables restore', describe: 'Restore a deleted table' },
260+
runTableColumn: { command: 'tables columns run', describe: 'Run a column’s workflow' },
261+
runRowEnrichment: {
262+
command: 'tables rows enrich',
263+
describe: 'Run one row’s enrichment group',
264+
},
265+
266+
// Transfers are a handshake: create, request part URLs, send the parts, then
267+
// complete. Unlike `files upload` there is no single command driving this yet
268+
// — the import body carries source/target/mapping choices a one-liner cannot
269+
// express — so each step stays reachable under a name that says what it is.
270+
createTableImport: { command: 'tables imports create' },
271+
createTableImportPartUrls: {
272+
command: 'tables imports parts',
273+
describe: 'Sign upload URLs for a batch of parts',
274+
},
275+
completeTableImport: {
276+
command: 'tables imports complete',
277+
describe: 'Finish an import once every part is uploaded',
278+
},
279+
cancelTableImport: { command: 'tables imports cancel' },
280+
cancelTableExport: { command: 'tables exports cancel' },
281+
tableExportDownload: {
282+
// GET, but it returns a signed URL rather than a listing.
283+
command: 'tables exports download',
284+
describe: 'Get the download URL for a finished export',
285+
},
286+
246287
// ─── Documents, not records ───────────────────────────────────────────────
247288
// The payload is the artifact: `sim workflows export <id> > wf.json` has to
248289
// produce something `sim workflows import` accepts back.
@@ -280,8 +321,18 @@ export const CLI_CONTRACT: CliContract = {
280321
},
281322

282323
// ─── Not a terminal-shaped operation ──────────────────────────────────────
283-
// Multipart upload; `sim files upload <path>` needs its own file-reading
284-
// command rather than a generated flag surface.
285-
uploadFile: { hidden: true },
324+
// Multipart upload; `sim knowledge documents upload <path>` would need its own
325+
// file-reading command rather than a generated flag surface.
286326
uploadKnowledgeDocument: { hidden: true },
327+
328+
// ─── Steps of a transfer, not commands ────────────────────────────────────
329+
// Uploading is now a presigned multipart handshake: create the upload, ask for
330+
// part URLs in batches, PUT each part to storage, then complete with the
331+
// ETags — and abort if any of it fails. Exposing the steps individually would
332+
// advertise a protocol whose halfway states leak storage, so `sim files
333+
// upload` drives the whole sequence and these stay out of the surface.
334+
createFileUpload: { hidden: true },
335+
createFileUploadPartUrls: { hidden: true },
336+
completeFileUpload: { hidden: true },
337+
abortFileUpload: { hidden: true },
287338
}

0 commit comments

Comments
 (0)