Skip to content

Commit d292ee0

Browse files
committed
fix(coda): declare nullable outputs and harden review edge cases
1 parent 998e8f3 commit d292ee0

25 files changed

Lines changed: 301 additions & 204 deletions

‎apps/docs/content/docs/integrations/coda.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -667,7 +667,7 @@ Get a single row from a Coda table, including all of its cell values
667667

668668
### Coda Get Sharing Metadata
669669

670-
Check whether the connected user can share or copy a Coda doc, and share it with the workspace or organization
670+
Check whether the connected user can share or copy a Coda doc, and whether they can share it with the workspace or organization
671671

672672
#### Input
673673

‎apps/sim/lib/credentials/token-service-accounts/validators/coda.test.ts‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -88,10 +88,10 @@ describe('validateCodaServiceAccount', () => {
8888
})
8989

9090
it('rejects a success body without a login id', async () => {
91-
mockFetch.mockResolvedValue(jsonResponse(200, { name: 'Jane' }))
92-
93-
const error = await validateCodaServiceAccount({ apiToken: 'coda-token' }).catch((e) => e)
94-
95-
expect(error.code).toBe('provider_unavailable')
91+
for (const body of [{ name: 'Jane' }, null, { loginId: ' ' }]) {
92+
mockFetch.mockResolvedValueOnce(jsonResponse(200, body))
93+
const error = await validateCodaServiceAccount({ apiToken: 'coda-token' }).catch((e) => e)
94+
expect(error.code).toBe('provider_unavailable')
95+
}
9696
})
9797
})

‎apps/sim/lib/credentials/token-service-accounts/validators/coda.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,8 @@ export async function validateCodaServiceAccount(
3838
)
3939
await throwForProviderResponse(res, 'whoami')
4040

41-
const body = await parseProviderJson<CodaWhoamiResponse>(res, 'whoami')
42-
if (!body.loginId) {
41+
const body = await parseProviderJson<CodaWhoamiResponse | null>(res, 'whoami')
42+
if (typeof body?.loginId !== 'string' || !body.loginId.trim()) {
4343
throw new TokenServiceAccountValidationError('provider_unavailable', 502, {
4444
step: 'whoami',
4545
reason: 'missing loginId in response',

‎apps/sim/scripts/check-block-registry.ts‎

Lines changed: 35 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -37,20 +37,21 @@ const gitOpts = { encoding: 'utf-8' as const, cwd: gitRoot }
3737
type IdMap = Record<string, Set<string>>
3838

3939
/**
40-
* Extracts subblock IDs from the `subBlocks: [ ... ]` section of a block
41-
* definition. Only grabs the top-level `id:` of each subblock object —
42-
* ignores nested IDs inside `options`, `columns`, etc. Callers pass the source
43-
* starting at the block's own definition, because a file can declare an untyped
44-
* legacy block before the typed block derived from it. A derived `subBlocks`
45-
* expression (not an array literal) yields no IDs rather than borrowing a later
46-
* bracket.
40+
* Returns the index of the `[` opening the first `subBlocks:` array literal in
41+
* `source`, or null when that `subBlocks` value is an expression instead.
4742
*/
48-
function extractSubBlockIds(source: string): string[] {
49-
const literal = /subBlocks:(\s*)(.)/.exec(source)
50-
if (!literal || literal[2] !== '[') return []
51-
52-
const bracketStart = literal.index + literal[0].length - 1
43+
function findSubBlocksLiteral(source: string): number | null {
44+
const match = /subBlocks:\s*(\S)/.exec(source)
45+
if (!match || match[1] !== '[') return null
46+
return match.index + match[0].length - 1
47+
}
5348

49+
/**
50+
* Extracts subblock IDs from the `subBlocks: [ ... ]` array literal whose
51+
* opening bracket is at `bracketStart`. Only grabs the top-level `id:` of each
52+
* subblock object — ignores nested IDs inside `options`, `columns`, etc.
53+
*/
54+
function extractSubBlockIds(source: string, bracketStart: number): string[] {
5455
const ids: string[] = []
5556
let braceDepth = 0
5657
let bracketDepth = 0
@@ -96,22 +97,40 @@ type PreviousIdsResult =
9697
| { kind: 'noop' }
9798
| { kind: 'ok'; map: IdMap }
9899

100+
/**
101+
* Reads a block's subblock IDs from its source at the base ref. A file can
102+
* declare an untyped legacy block before the typed block, so a typed block
103+
* with a `subBlocks` array literal is read from its own definition. A typed
104+
* block that derives `subBlocks` (for example by filtering the legacy block's)
105+
* cannot be evaluated here, so it is read from the legacy literal: IDs the
106+
* derivation already dropped then look removed. That fails closed while the
107+
* file is being edited, and the block is skipped while the file is unchanged,
108+
* since this diff cannot have removed anything from it.
109+
*/
110+
function extractPreviousIds(content: string, definitionStart: number, fileChanged: boolean) {
111+
const ownLiteral = findSubBlocksLiteral(content.slice(definitionStart))
112+
if (ownLiteral !== null) return extractSubBlockIds(content, definitionStart + ownLiteral)
113+
if (!fileChanged) return []
114+
const legacyLiteral = findSubBlocksLiteral(content)
115+
return legacyLiteral === null ? [] : extractSubBlockIds(content, legacyLiteral)
116+
}
117+
99118
function getPreviousIds(): PreviousIdsResult {
100119
const registryPath = 'apps/sim/blocks/registry.ts'
101120
const blocksDir = 'apps/sim/blocks/blocks'
102121

103-
let hasChanges = false
122+
let changedPaths: Set<string>
104123
try {
105124
const diff = execSync(
106125
`git diff --name-only ${baseRef} -- ${registryPath} ${blocksDir}`,
107126
gitOpts
108127
).trim()
109-
hasChanges = diff.length > 0
128+
changedPaths = new Set(diff ? diff.split('\n') : [])
110129
} catch {
111130
return { kind: 'skip', reason: 'Could not diff against base ref' }
112131
}
113132

114-
if (!hasChanges) {
133+
if (changedPaths.size === 0) {
115134
return { kind: 'noop' }
116135
}
117136

@@ -137,7 +156,7 @@ function getPreviousIds(): PreviousIdsResult {
137156
if (!typeMatch) continue
138157
const blockType = typeMatch[1]
139158

140-
const ids = extractSubBlockIds(content.slice(typeMatch.index ?? 0))
159+
const ids = extractPreviousIds(content, typeMatch.index ?? 0, changedPaths.has(filePath))
141160
if (ids.length === 0) continue
142161

143162
map[blockType] = new Set(ids)

‎apps/sim/tools/coda/coda.live.test.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,12 @@ function schemaViolations(
5353
path: string,
5454
violations: string[]
5555
): void {
56-
if (value === null || value === undefined) {
57-
if (!schema.optional) violations.push(`${path}: required but ${value}`)
56+
if (value === null) {
57+
if (!schema.nullable) violations.push(`${path}: null but not declared nullable`)
58+
return
59+
}
60+
if (value === undefined) {
61+
if (!schema.optional) violations.push(`${path}: missing but not declared optional`)
5862
return
5963
}
6064
switch (schema.type) {

‎apps/sim/tools/coda/coda.test.ts‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,36 @@ import { codaDeleteRowsTool } from '@/tools/coda/delete_rows'
1212
import { codaListDocsTool } from '@/tools/coda/list_docs'
1313
import { codaListRowsTool } from '@/tools/coda/list_rows'
1414
import { codaPublishDocTool } from '@/tools/coda/publish_doc'
15+
import { codaResolveBrowserLinkTool } from '@/tools/coda/resolve_browser_link'
1516
import { codaUpdateAclSettingsTool } from '@/tools/coda/update_acl_settings'
1617
import { codaUpdatePageTool } from '@/tools/coda/update_page'
1718
import { codaUpdateRowTool } from '@/tools/coda/update_row'
1819
import { codaUpsertRowsTool } from '@/tools/coda/upsert_rows'
1920
import { buildCodaUrl, CODA_FIELD_UPDATE_RETRY, CODA_RETRY } from '@/tools/coda/utils'
2021
import { codaWhoamiTool } from '@/tools/coda/whoami'
2122
import { ErrorExtractorId, extractErrorMessageWithId } from '@/tools/error-extractors'
23+
import type { OutputProperty } from '@/tools/types'
2224

2325
const table = { accessToken: 'token', docId: 'AbCDeFGH', tableId: 'grid-pqRst-U' }
2426

27+
/** Lists output paths a tool returned as null whose schema does not declare `nullable`. */
28+
function findUndeclaredNulls(
29+
value: unknown,
30+
properties: Record<string, OutputProperty> | undefined,
31+
path: string
32+
): string[] {
33+
if (!properties || value === null || typeof value !== 'object') return []
34+
return Object.entries(properties).flatMap(([key, schema]) => {
35+
const child = (value as Record<string, unknown>)[key]
36+
const childPath = `${path}.${key}`
37+
if (child === null) return schema.nullable ? [] : [childPath]
38+
if (Array.isArray(child)) {
39+
return child.flatMap((item) => findUndeclaredNulls(item, schema.items?.properties, childPath))
40+
}
41+
return findUndeclaredNulls(child, schema.properties, childPath)
42+
})
43+
}
44+
2545
function resolveUrl<P>(url: string | ((params: P) => string), params: P): string {
2646
return typeof url === 'function' ? url(params) : url
2747
}
@@ -46,6 +66,12 @@ describe('Coda request URLs', () => {
4666
)
4767
})
4868

69+
it('rejects a blank browser link instead of sending no url', () => {
70+
expect(() =>
71+
resolveUrl(codaResolveBrowserLinkTool.request.url, { accessToken: 'token', url: ' ' })
72+
).toThrow('url is required')
73+
})
74+
4975
it('builds list docs URLs without a doc path', () => {
5076
expect(
5177
resolveUrl(codaListDocsTool.request.url, {
@@ -84,6 +110,26 @@ describe('Coda row bodies', () => {
84110
})
85111
})
86112

113+
it('maps a column named cells instead of reading it as the cells wrapper', () => {
114+
expect(
115+
codaUpdateRowTool.request.body!({
116+
...table,
117+
rowId: 'i-1',
118+
cells: { cells: 'x', Status: 'Done' },
119+
})
120+
).toEqual({
121+
row: {
122+
cells: [
123+
{ column: 'cells', value: 'x' },
124+
{ column: 'Status', value: 'Done' },
125+
],
126+
},
127+
})
128+
expect(
129+
codaUpdateRowTool.request.body!({ ...table, rowId: 'i-1', cells: { cells: 'x' } })
130+
).toEqual({ row: { cells: [{ column: 'cells', value: 'x' }] } })
131+
})
132+
87133
it('rejects an empty upsert', () => {
88134
expect(() => codaUpsertRowsTool.request.body!({ ...table, rows: '[]' })).toThrow(
89135
'at least one row'
@@ -339,6 +385,27 @@ describe('Coda tool registration', () => {
339385
expect(allTools).toHaveLength(60)
340386
})
341387

388+
it.each(allTools)(
389+
'%s declares every output it can return as null as nullable',
390+
async (_, tool) => {
391+
const config = tool as {
392+
outputs: Record<string, OutputProperty>
393+
transformResponse: (response: Response, params: object) => Promise<{ output: unknown }>
394+
}
395+
const sparseBody = {
396+
items: [{ doc: {}, page: {}, metrics: [{}] }],
397+
customDocDomains: [{}],
398+
resource: {},
399+
id: 'x',
400+
}
401+
const { output } = await config.transformResponse(
402+
new Response(JSON.stringify(sparseBody)),
403+
table
404+
)
405+
expect(findUndeclaredNulls(output, config.outputs, '')).toEqual([])
406+
}
407+
)
408+
342409
it.each(allTools)(
343410
'%s retries safely repeatable calls and authenticates with the Coda credential',
344411
(_, tool) => {

‎apps/sim/tools/coda/create_doc.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ export const codaCreateDocTool: ToolConfig<CodaCreateDocParams, CodaCreateDocRes
165165
requestId: {
166166
type: 'string',
167167
description: 'Coda request ID for the doc creation',
168-
optional: true,
168+
nullable: true,
169169
},
170170
},
171171
}

‎apps/sim/tools/coda/get_control.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,11 +59,12 @@ export const codaGetControlTool: ToolConfig<CodaGetControlParams, CodaControlRes
5959
type: 'string',
6060
description:
6161
'Control type (aiBlock, button, checkbox, datePicker, dateRangePicker, dateTimePicker, lookup, multiselect, select, scale, slider, reaction, textbox, timePicker)',
62-
optional: true,
62+
nullable: true,
6363
},
6464
value: {
6565
type: 'json',
6666
description: 'Current value (string, number, boolean, or array of these)',
67+
nullable: true,
6768
},
6869
},
6970
},

‎apps/sim/tools/coda/get_formula.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ export const codaGetFormulaTool: ToolConfig<CodaGetFormulaParams, CodaFormulaRes
5555
value: {
5656
type: 'json',
5757
description: 'Computed value (string, number, boolean, or array of these)',
58+
nullable: true,
5859
},
5960
},
6061
},

‎apps/sim/tools/coda/get_mutation_status.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ export const codaGetMutationStatusTool: ToolConfig<
4949
warning: {
5050
type: 'string',
5151
description: 'Warning if the change completed with caveats',
52-
optional: true,
52+
nullable: true,
5353
},
5454
},
5555
}

0 commit comments

Comments
 (0)