Skip to content

Commit e1810a6

Browse files
fix(cli): make tables rows create and tables columns run usable
Both were dead on arrival, for opposite reasons. `createTableRows` takes `z.union([batch, single])`. A union has no flat field list, so the generator emitted no body slot — and slot absence reads the same as "this operation has no body", so the command offered nothing and sent nothing. The generator's own comment claimed the runtime fell back to taking the body as JSON; nothing did. Unions are now marked, the fields every branch shares are still emitted (both require `workspaceId`, which comes from the profile), and `--body <json|@file>` carries the rest, merged over them so the caller still wins on any key it sets. Dropping that merge was my first attempt and it failed on the missing workspace. `runTableColumn` takes `limit: { type, max }`. The pager claimed the *name* `limit` regardless of type, so it became `--limit <n>` with a default of 100 and sent a number the route rejected on every call, whether or not the flag was passed. The special case now applies only where `limit` is numeric; elsewhere it is an ordinary field and gets the JSON flag its type calls for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0148a65fkPhP4N8tgGPYtRTU
1 parent 0c7b64a commit e1810a6

5 files changed

Lines changed: 144 additions & 5 deletions

File tree

‎packages/sim-cli/src/generated/v2-api.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4233,6 +4233,10 @@ export const V2_OPERATIONS = {
42334233
pathParams: ['tableId'] as const,
42344234
responseMode: 'json',
42354235
summary: 'Create Rows',
4236+
body: {
4237+
workspaceId: { kind: 'string', required: true },
4238+
},
4239+
opaqueBody: true,
42364240
},
42374241
createTableView: {
42384242
method: 'POST',

‎packages/sim-cli/src/runtime/build.test.ts‎

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,3 +328,66 @@ describe('boolean flags', () => {
328328
)
329329
})
330330
})
331+
332+
describe('bodies and fields the generator cannot flatten', () => {
333+
it('sends a union body whole, with the profile workspace merged in', async () => {
334+
// `createTableRows` is `z.union([batch, single])`, so there is no field list
335+
// to build flags from. The command exposed nothing at all and sent no body,
336+
// and every call failed with "Request body must be valid JSON".
337+
const [path, options] = await run([
338+
'tables',
339+
'rows',
340+
'create',
341+
'tbl_1',
342+
'--body',
343+
'{"rows":[{"city":"Paris"}]}',
344+
])
345+
346+
expect(path).toBe('/api/v2/tables/tbl_1/rows')
347+
// Both branches require `workspaceId`, and it comes from the profile.
348+
expect(options.body).toEqual({ workspaceId: 'ws_local', rows: [{ city: 'Paris' }] })
349+
})
350+
351+
it('lets the caller override a shared field', async () => {
352+
const [, options] = await run([
353+
'tables',
354+
'rows',
355+
'create',
356+
'tbl_1',
357+
'--body',
358+
'{"workspaceId":"ws_other","rows":[]}',
359+
])
360+
expect(options.body).toMatchObject({ workspaceId: 'ws_other' })
361+
})
362+
363+
it('refuses a union body that is not an object', async () => {
364+
await expect(run(['tables', 'rows', 'create', 'tbl_1', '--body', '[1,2]'])).rejects.toThrow(
365+
/--body must be a JSON object/
366+
)
367+
})
368+
369+
it('leaves a non-numeric `limit` alone', async () => {
370+
// `runTableColumn` takes `limit: { type, max }`. The pager claimed the name
371+
// regardless of type, turning it into `--limit <n>` that defaulted to 100,
372+
// so every call failed with "expected object, received number".
373+
const [, omitted] = await run(['tables', 'columns', 'run', 'tbl_1', '--group-ids', '["g1"]'])
374+
expect(omitted.body).not.toHaveProperty('limit')
375+
376+
const [, given] = await run([
377+
'tables',
378+
'columns',
379+
'run',
380+
'tbl_1',
381+
'--group-ids',
382+
'["g1"]',
383+
'--limit',
384+
'{"type":"rows","max":5}',
385+
])
386+
expect(given.body).toMatchObject({ limit: { type: 'rows', max: 5 } })
387+
})
388+
389+
it('still gives paginated lists their numeric --limit', async () => {
390+
const [, options] = await run(['files', 'list', '--limit', '7'])
391+
expect(options.query).toMatchObject({ limit: 7 })
392+
})
393+
})

‎packages/sim-cli/src/runtime/build.ts‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,11 @@ function unwrapResource(data: unknown): unknown {
153153
return value && typeof value === 'object' && !Array.isArray(value) ? value : data
154154
}
155155

156+
/** Whether the operation's body is one the generator could not describe field by field. */
157+
function opaqueBody(spec: object): boolean {
158+
return (spec as { opaqueBody?: boolean }).opaqueBody === true
159+
}
160+
156161
/** The operation's one-line help, taken from the OpenAPI summary at generation time. */
157162
function summaryFor(operation: V2OperationName): string | undefined {
158163
return (V2_OPERATIONS[operation] as { summary?: string }).summary
@@ -195,7 +200,11 @@ function addFieldOption(
195200
const name = flagNameFor(operation, field)
196201
const short = flag.short ? `-${flag.short}, ` : ''
197202

198-
if (field === 'limit') {
203+
// The pager owns `--limit`, but only where `limit` means a page size. The
204+
// name is not reserved: `runTableColumn` takes `limit: { type, max }`, and
205+
// claiming it here turned that into a numeric flag that defaulted to 100 and
206+
// made every invocation fail with "expected object, received number".
207+
if (field === 'limit' && (descriptor.kind === 'number' || descriptor.kind === 'integer')) {
199208
command.option(
200209
`--limit <n>`,
201210
'Maximum items to return (0 for everything)',
@@ -286,6 +295,17 @@ function buildLeaf(operation: V2OperationName, spec: CommandSpec, leafName: stri
286295
}
287296
}
288297

298+
// A body the generator could not break into fields is offered whole. The
299+
// union behind `tables rows create` (one row, or a batch) has no field list
300+
// to build flags from, and without this the command sent no body at all and
301+
// the server rejected the request as malformed JSON.
302+
if (opaqueBody(operationSpec)) {
303+
command.requiredOption(
304+
'--body <json|@file>',
305+
'Request body as JSON (or @path / @- to read a file or stdin)'
306+
)
307+
}
308+
289309
if (spec.confirm) {
290310
command.option('-y, --yes', 'Skip the confirmation')
291311
}

‎packages/sim-cli/src/runtime/request.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,7 @@ export function buildRequest(
213213
pathParams: readonly string[]
214214
query?: Record<string, FieldSpec>
215215
body?: Record<string, FieldSpec>
216+
opaqueBody?: boolean
216217
}
217218

218219
let path = spec.path
@@ -256,6 +257,20 @@ export function buildRequest(
256257
}
257258
}
258259

260+
// A union body comes in whole through `--body`, merged over the fields the
261+
// branches share. Replacing outright dropped the profile's `workspaceId`,
262+
// which both branches require, so every insert came back as invalid input.
263+
// The caller's JSON still wins on any key it sets.
264+
if (spec.opaqueBody) {
265+
const raw = flags.body
266+
if (typeof raw !== 'string') throw new SimApiError('--body is required', 0)
267+
const parsed = coerce(raw, { kind: 'object' }, { json: true }, 'body')
268+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
269+
throw new SimApiError('--body must be a JSON object', 0)
270+
}
271+
return { path, query, body: { ...body, ...(parsed as Record<string, unknown>) } }
272+
}
273+
259274
return {
260275
path,
261276
query,

‎scripts/generate-v2-cli-api.ts‎

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -341,16 +341,46 @@ function fieldKind(schema: JsonSchema): FieldKind {
341341
* Emitted as data rather than baked into types because the CLI has to *iterate*
342342
* these at startup to construct commands — a type alone cannot be walked.
343343
*/
344+
/**
345+
* Whether the slot is a union, whose branches the CLI cannot turn into flags.
346+
*
347+
* Distinct from "the map came out empty": the shared fields of a union are
348+
* emitted as a map, so emptiness alone no longer identifies one, and the
349+
* runtime still has to know the rest of the body must come in as JSON.
350+
*/
351+
function isUnionSlot(schema: z.ZodType): boolean {
352+
const json = z.toJSONSchema(schema, { io: 'input', unrepresentable: 'any' }) as JsonSchema
353+
return Object.keys(json.properties ?? {}).length === 0 && Boolean(json.anyOf ?? json.oneOf)
354+
}
355+
344356
function renderSlotMap(schema: z.ZodType | undefined, indent: string): string | null {
345357
if (!schema) return null
346358

347359
const json = z.toJSONSchema(schema, { io: 'input', unrepresentable: 'any' }) as JsonSchema
348-
const properties: Record<string, JsonSchema> = json.properties ?? {}
349-
const required = new Set<string>(json.required ?? [])
360+
let properties: Record<string, JsonSchema> = json.properties ?? {}
361+
let required = new Set<string>(json.required ?? [])
362+
363+
// A union has no properties of its own, but the fields every branch agrees on
364+
// are still known and still have to be sent — `workspaceId` is required by
365+
// both branches of the row-insert body and comes from the profile, so
366+
// dropping it left `tables rows create` rejected as invalid input.
367+
if (Object.keys(properties).length === 0) {
368+
const branches = (json.anyOf ?? json.oneOf) as JsonSchema[] | undefined
369+
if (branches?.length) {
370+
const shared = branches.reduce<string[]>(
371+
(keys, branch) => keys.filter((key) => branch.properties?.[key] !== undefined),
372+
Object.keys(branches[0].properties ?? {})
373+
)
374+
properties = Object.fromEntries(shared.map((key) => [key, branches[0].properties[key]]))
375+
required = new Set(shared.filter((key) => branches.every((b) => b.required?.includes(key))))
376+
}
377+
}
378+
350379
const keys = Object.keys(properties)
351380

352-
// A union body (e.g. single-row vs batch insert) has no flat field list; the
353-
// runtime falls back to taking the whole body as JSON.
381+
// A union body (e.g. single-row vs batch insert) has no flat field list. The
382+
// caller marks it `opaqueBody` so the runtime can offer the whole body as one
383+
// JSON flag instead.
354384
if (keys.length === 0) return null
355385

356386
const lines = keys.map((key) => {
@@ -441,6 +471,13 @@ function render(operations: Operation[]): string {
441471
for (const slot of ['query', 'body'] as const) {
442472
const map = renderSlotMap(op.contract[slot], ' ')
443473
if (map) out.push(` ${slot}: ${map},`)
474+
// A declared slot with no flat field list still has to be sendable.
475+
// Absence alone cannot say so: it means both "no body" and "a body the
476+
// generator could not describe", and reading it as the former left
477+
// `tables rows create` unable to send anything at all.
478+
if (slot === 'body' && op.contract.body && isUnionSlot(op.contract.body)) {
479+
out.push(` opaqueBody: true,`)
480+
}
444481
}
445482
out.push(' },')
446483
}

0 commit comments

Comments
 (0)