Skip to content

Commit 383f0f8

Browse files
fix(cli): support unified upload sessions
1 parent 19708bf commit 383f0f8

10 files changed

Lines changed: 503 additions & 218 deletions

File tree

‎packages/sim-cli/README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,8 @@ sim tables upsert <tableId> --data <json>
130130
sim tables rows batch-delete <tableId> (--row <id>… | --filter <json>) --yes
131131

132132
sim files list
133+
sim files create --name <name> [--content <value>] [--encoding utf-8|base64]
134+
sim files upload <path> [--name <name>] [--folder-id <id>]
133135
sim files download <fileId> [-o <path>]
134136
sim files delete <fileId>
135137

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
2+
import { tmpdir } from 'node:os'
3+
import { join } from 'node:path'
4+
import { Command } from 'commander'
5+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
6+
import { buildGeneratedCommands } from '../../runtime/build.js'
7+
import { attachProtocolCommands } from './index.js'
8+
9+
const { mockRequest } = vi.hoisted(() => ({
10+
mockRequest: vi.fn(),
11+
}))
12+
13+
vi.mock('../../context.js', () => ({
14+
clientFrom: () => ({
15+
client: { request: mockRequest, requireWorkspace: () => 'ws_local' },
16+
profile: {
17+
workspaceId: 'ws_local',
18+
output: 'json',
19+
name: 'default',
20+
apiKey: 'k',
21+
endpoint: 'https://sim.example',
22+
},
23+
}),
24+
}))
25+
26+
let dir: string
27+
28+
beforeEach(() => {
29+
dir = mkdtempSync(join(tmpdir(), 'sim-file-upload-'))
30+
mockRequest.mockReset()
31+
})
32+
33+
afterEach(() => {
34+
vi.restoreAllMocks()
35+
vi.unstubAllGlobals()
36+
rmSync(dir, { recursive: true, force: true })
37+
})
38+
39+
function program(): Command {
40+
const root = new Command('sim').exitOverride()
41+
for (const group of buildGeneratedCommands()) root.addCommand(group)
42+
attachProtocolCommands(root)
43+
return root
44+
}
45+
46+
describe('files upload', () => {
47+
it('uses a signed PUT transfer and completes with an empty body', async () => {
48+
const path = join(dir, 'notes.txt')
49+
writeFileSync(path, 'hello')
50+
mockRequest
51+
.mockResolvedValueOnce({
52+
data: {
53+
session: {
54+
id: 'upload_1',
55+
status: 'uploading',
56+
name: 'notes.txt',
57+
contentType: 'text/plain',
58+
size: 5,
59+
expiresAt: '2026-08-04T20:00:00.000Z',
60+
error: null,
61+
file: null,
62+
},
63+
uploadToken: 'secret-token',
64+
transfer: {
65+
method: 'put',
66+
url: 'https://storage.example/file',
67+
headers: { 'content-type': 'text/plain' },
68+
},
69+
},
70+
})
71+
.mockResolvedValueOnce({
72+
data: {
73+
id: 'upload_1',
74+
status: 'completed',
75+
name: 'notes.txt',
76+
contentType: 'text/plain',
77+
size: 5,
78+
expiresAt: '2026-08-04T20:00:00.000Z',
79+
error: null,
80+
file: {
81+
id: 'file_1',
82+
name: 'notes.txt',
83+
size: 5,
84+
type: 'text/plain',
85+
key: 'workspace/ws_local/notes.txt',
86+
folderId: null,
87+
folderPath: null,
88+
uploadedBy: 'user_1',
89+
uploadedAt: '2026-08-04T19:00:00.000Z',
90+
updatedAt: '2026-08-04T19:00:00.000Z',
91+
},
92+
},
93+
})
94+
const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 200 }))
95+
vi.stubGlobal('fetch', fetchMock)
96+
const logged: string[] = []
97+
vi.spyOn(console, 'log').mockImplementation((line: string) => logged.push(line))
98+
99+
await program().parseAsync(['node', 'sim', 'file', 'upload', path])
100+
101+
expect(fetchMock).toHaveBeenCalledWith(
102+
'https://storage.example/file',
103+
expect.objectContaining({
104+
method: 'PUT',
105+
headers: { 'content-type': 'text/plain' },
106+
body: expect.any(Blob),
107+
})
108+
)
109+
expect(mockRequest.mock.calls[1]).toEqual([
110+
'/api/v2/files/uploads/upload_1/complete',
111+
{
112+
method: 'POST',
113+
query: { workspaceId: 'ws_local' },
114+
headers: { 'upload-token': 'secret-token' },
115+
body: {},
116+
},
117+
])
118+
expect(JSON.parse(logged[0])).toEqual({
119+
id: 'file_1',
120+
name: 'notes.txt',
121+
size: 5,
122+
status: 'uploaded',
123+
})
124+
expect(logged[0]).not.toContain('secret-token')
125+
})
126+
})

‎packages/sim-cli/src/commands/protocol/files-upload.ts‎

Lines changed: 12 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,13 @@
11
import type { Command } from 'commander'
22
import { clientFrom } from '../../context.js'
3+
import type {
4+
CompleteFileUploadResponse,
5+
CreateFileUploadResponse,
6+
} from '../../generated/v2-api.js'
37
import { contentTypeFor, localFile } from '../../transfer/local-file.js'
4-
import { finishTransfer } from '../../transfer/multipart.js'
8+
import { finishUploadSession } from '../../transfer/upload-session.js'
59
import { printProtocolResult } from './result.js'
610

7-
interface FileUpload {
8-
id: string
9-
uploadToken: string
10-
partSize: number
11-
partCount: number
12-
file: { id: string } | null
13-
}
14-
1511
export function attachFileUpload(files: Command): void {
1612
files
1713
.command('upload <path>')
@@ -24,7 +20,7 @@ export function attachFileUpload(files: Command): void {
2420
const workspaceId = client.requireWorkspace()
2521
const { name, size } = await localFile(path, options.name)
2622

27-
const created = await client.request<{ data: FileUpload }>('/api/v2/files/uploads', {
23+
const created = await client.request<CreateFileUploadResponse>('/api/v2/files/uploads', {
2824
method: 'POST',
2925
body: {
3026
workspaceId,
@@ -34,22 +30,21 @@ export function attachFileUpload(files: Command): void {
3430
...(options.folderId ? { folderId: options.folderId } : {}),
3531
},
3632
})
37-
const upload = created.data
38-
const completed = await finishTransfer<FileUpload>(
33+
const { session, uploadToken, transfer } = created.data
34+
const completed = await finishUploadSession<CompleteFileUploadResponse['data']>(
3935
client,
4036
workspaceId,
4137
{
42-
basePath: `/api/v2/files/uploads/${encodeURIComponent(upload.id)}`,
43-
uploadToken: upload.uploadToken,
44-
partSize: upload.partSize,
45-
partCount: upload.partCount,
38+
basePath: `/api/v2/files/uploads/${encodeURIComponent(session.id)}`,
39+
uploadToken,
40+
transfer,
4641
size,
4742
},
4843
path
4944
)
5045

5146
printProtocolResult(profile.output, {
52-
id: completed.file?.id ?? completed.id,
47+
id: completed.file?.id ?? session.id,
5348
name,
5449
size,
5550
status: 'uploaded',

‎packages/sim-cli/src/commands/protocol/knowledge-document-upload.test.ts‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,9 +56,6 @@ function uploadSession() {
5656
name: 'notes.doc',
5757
contentType: 'application/msword',
5858
size: 5,
59-
partSize: 10,
60-
partCount: 1,
61-
uploadToken: 'secret-token',
6259
expiresAt: '2026-08-04T20:00:00.000Z',
6360
error: null,
6461
document: null,
@@ -81,7 +78,13 @@ describe('knowledge documents upload', () => {
8178
writeFileSync(path, 'hello')
8279
const session = uploadSession()
8380
mockRequest
84-
.mockResolvedValueOnce({ data: session })
81+
.mockResolvedValueOnce({
82+
data: {
83+
session,
84+
uploadToken: 'secret-token',
85+
transfer: { method: 'multipart', partSize: 10, partCount: 1 },
86+
},
87+
})
8588
.mockResolvedValueOnce({
8689
data: {
8790
parts: [

‎packages/sim-cli/src/commands/protocol/knowledge-document-upload.ts‎

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
import type { Command } from 'commander'
22
import { clientFrom } from '../../context.js'
3-
import type { CreateKnowledgeDocumentUploadResponse } from '../../generated/v2-api.js'
3+
import type {
4+
CompleteKnowledgeDocumentUploadResponse,
5+
CreateKnowledgeDocumentUploadResponse,
6+
} from '../../generated/v2-api.js'
47
import { SimApiError } from '../../http/client.js'
58
import { contentTypeFor, localFile } from '../../transfer/local-file.js'
6-
import { finishTransfer } from '../../transfer/multipart.js'
9+
import { finishUploadSession } from '../../transfer/upload-session.js'
710
import { printProtocolResult } from './result.js'
811

9-
type KnowledgeDocumentUpload = CreateKnowledgeDocumentUploadResponse['data']
10-
1112
interface KnowledgeDocumentUploadOptions {
1213
name?: string
1314
tag?: string[]
@@ -65,24 +66,25 @@ export function attachKnowledgeDocumentUpload(documents: Command): void {
6566
},
6667
}
6768
)
68-
const upload = created.data
69-
const completed = await finishTransfer<KnowledgeDocumentUpload>(
69+
const { session, uploadToken, transfer } = created.data
70+
const completed = await finishUploadSession<
71+
CompleteKnowledgeDocumentUploadResponse['data']
72+
>(
7073
client,
7174
workspaceId,
7275
{
7376
basePath: `/api/v2/knowledge/${encodeURIComponent(
7477
knowledgeBaseId
75-
)}/documents/uploads/${encodeURIComponent(upload.id)}`,
76-
uploadToken: upload.uploadToken,
77-
partSize: upload.partSize,
78-
partCount: upload.partCount,
78+
)}/documents/uploads/${encodeURIComponent(session.id)}`,
79+
uploadToken,
80+
transfer,
7981
size,
8082
},
8183
path
8284
)
8385

8486
if (!completed.document) {
85-
throw new Error(`Knowledge upload ${completed.id} completed without a document`)
87+
throw new Error(`Knowledge upload ${session.id} completed without a document`)
8688
}
8789
printProtocolResult(profile.output, {
8890
id: completed.document.id,

‎packages/sim-cli/src/commands/protocol/tables-import.test.ts‎

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -86,12 +86,15 @@ describe('tables import output', () => {
8686
it('prints a normalized result without transfer secrets', async () => {
8787
mockRequest.mockResolvedValue({
8888
data: {
89-
id: 'import_1',
90-
status: 'queued',
91-
tableId: 'table_1',
92-
rowsProcessed: 0,
93-
error: null,
94-
upload: null,
89+
session: {
90+
id: 'import_1',
91+
status: 'queued',
92+
tableId: 'table_1',
93+
rowsProcessed: 0,
94+
error: null,
95+
},
96+
uploadToken: null,
97+
transfer: null,
9598
},
9699
})
97100
const logged: string[] = []

‎packages/sim-cli/src/commands/protocol/tables-import.ts‎

Lines changed: 22 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,18 @@ import { setTimeout as sleep } from 'node:timers/promises'
22
import chalk from 'chalk'
33
import { type Command, Option } from 'commander'
44
import { clientFrom } from '../../context.js'
5+
import type {
6+
CompleteTableImportResponse,
7+
CreateTableImportResponse,
8+
GetTableImportResponse,
9+
} from '../../generated/v2-api.js'
510
import { SimApiError, type SimClient } from '../../http/client.js'
6-
import { coerce } from '../../runtime/request.js'
11+
import { coerce, type FieldSpec } from '../../runtime/request.js'
712
import { contentTypeFor, localFile } from '../../transfer/local-file.js'
8-
import { finishTransfer } from '../../transfer/multipart.js'
13+
import { finishUploadSession } from '../../transfer/upload-session.js'
914
import { printProtocolResult } from './result.js'
1015

11-
interface TableImport {
12-
id: string
13-
status: 'uploading' | 'queued' | 'processing' | 'completed' | 'failed' | 'canceled' | 'expired'
14-
tableId: string | null
15-
rowsProcessed: number
16-
error: string | null
17-
upload: { uploadToken: string; partSize: number; partCount: number } | null
18-
}
16+
type TableImport = GetTableImportResponse['data']
1917

2018
interface ImportOptions {
2119
name?: string
@@ -39,8 +37,8 @@ function tableNameFrom(fileName: string): string {
3937
return (/^[0-9]/.test(cleaned) ? `_${cleaned}` : cleaned).slice(0, 128)
4038
}
4139

42-
function jsonFlag(raw: string, flagName: string): unknown {
43-
return coerce(raw, { kind: 'object' }, { json: true }, flagName)
40+
function jsonFlag(raw: string, flagName: string, kind: FieldSpec['kind']): unknown {
41+
return coerce(raw, { kind }, { json: true }, flagName)
4442
}
4543

4644
async function watchImport(
@@ -144,31 +142,33 @@ export function attachTableImport(tables: Command): void {
144142
target = { type: 'new', name, ...(options.folderId ? { folderId: options.folderId } : {}) }
145143
}
146144

147-
const started = await client.request<{ data: TableImport }>('/api/v2/tables/imports', {
145+
const started = await client.request<CreateTableImportResponse>('/api/v2/tables/imports', {
148146
method: 'POST',
149147
body: {
150148
workspaceId,
151149
source,
152150
target,
153-
...(options.mapping ? { mapping: jsonFlag(options.mapping, 'mapping') } : {}),
151+
...(options.mapping ? { mapping: jsonFlag(options.mapping, 'mapping', 'object') } : {}),
154152
...(options.createColumns
155-
? { createColumns: jsonFlag(options.createColumns, 'create-columns') }
153+
? { createColumns: jsonFlag(options.createColumns, 'create-columns', 'array') }
156154
: {}),
157155
...(options.timezone ? { timezone: options.timezone } : {}),
158156
},
159157
})
160158

161-
let job = started.data
162-
if (path && job.upload) {
163-
job = await finishTransfer<TableImport>(
159+
let job: TableImport = started.data.session
160+
if (path) {
161+
if (!local || !started.data.uploadToken || !started.data.transfer) {
162+
throw new Error('Local table import did not return an upload transfer')
163+
}
164+
job = await finishUploadSession<CompleteTableImportResponse['data']>(
164165
client,
165166
workspaceId,
166167
{
167168
basePath: `/api/v2/tables/imports/${encodeURIComponent(job.id)}`,
168-
uploadToken: job.upload.uploadToken,
169-
partSize: job.upload.partSize,
170-
partCount: job.upload.partCount,
171-
size: local?.size ?? 0,
169+
uploadToken: started.data.uploadToken,
170+
transfer: started.data.transfer,
171+
size: local.size,
172172
},
173173
path
174174
)

0 commit comments

Comments
 (0)