Skip to content

Commit 6d5f280

Browse files
authored
improvement(utils): add toArray and the scalar payload coercions (#8055)
* improvement(utils): add toArray and the scalar coercions, replacing 44 copies Ten files declared `Array.isArray(v) ? v : []` and 34 declared the `typeof v === 'x' ? v : null` one-liner under eleven different names. * docs: document toArray and the scalar payload coercions
1 parent 83fd36a commit 6d5f280

67 files changed

Lines changed: 1387 additions & 1398 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.claude/rules/global.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ Use shared helpers from `@sim/utils` instead of writing inline implementations:
5252
- `omit(obj, keys)` from `@sim/utils/object` — remove keys from object
5353
- `filterUndefined(obj)` from `@sim/utils/object` — strip undefined-valued keys. Never write `Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined))`
5454
- `isRecordLike(value)` from `@sim/utils/object` — indexable-object guard. Never redeclare `typeof value === 'object' && value !== null && !Array.isArray(value)`
55+
- `toRecord(value)` / `toRecordOrNull(value)` / `toArray(value)` from `@sim/utils/object` — coerce an untyped payload value to a record or array. Never inline `isRecordLike(v) ? v : {}` or `Array.isArray(v) ? v : []`. Where the source is already typed, keep the inline `Array.isArray` check: it narrows, while `toArray` asserts
56+
- `toStringOrNull(value)` / `toNumberOrNull(value)` / `toBooleanOrNull(value)` from `@sim/utils/coerce` — read one scalar out of an untyped payload. Never declare a local one-liner that is byte-identical to one of these (`asString`, `getString`, `nullableString`, …). Keep a local helper that differs: one returning `undefined` rather than `null` changes the wire shape, and one adding `Number.isFinite` or a string parse is a stricter check these deliberately omit
5557
- `truncate(str, maxLength, suffix?)` from `@sim/utils/string` — safe string truncation with ellipsis
5658
- `escapeRegExp(value)` from `@sim/utils/string` — escape regex metacharacters. Never inline `replace(/[.*+?^${}()|[\]\\]/g, '\\$&')`
5759
- `compareStrings(left, right)` from `@sim/utils/string` — code-unit string comparator for hashes, fingerprints, and values compared across processes. Never `localeCompare` there

.cursor/rules/global.mdc

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ Use shared helpers from `@sim/utils` instead of writing inline implementations:
5555
- `omit(obj, keys)` from `@sim/utils/object` — remove keys from object
5656
- `filterUndefined(obj)` from `@sim/utils/object` — strip undefined-valued keys. Never write `Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined))`
5757
- `isRecordLike(value)` from `@sim/utils/object` — indexable-object guard. Never redeclare `typeof value === 'object' && value !== null && !Array.isArray(value)`
58+
- `toRecord(value)` / `toRecordOrNull(value)` / `toArray(value)` from `@sim/utils/object` — coerce an untyped payload value to a record or array. Never inline `isRecordLike(v) ? v : {}` or `Array.isArray(v) ? v : []`. Where the source is already typed, keep the inline `Array.isArray` check: it narrows, while `toArray` asserts
59+
- `toStringOrNull(value)` / `toNumberOrNull(value)` / `toBooleanOrNull(value)` from `@sim/utils/coerce` — read one scalar out of an untyped payload. Never declare a local one-liner that is byte-identical to one of these (`asString`, `getString`, `nullableString`, …). Keep a local helper that differs: one returning `undefined` rather than `null` changes the wire shape, and one adding `Number.isFinite` or a string parse is a stricter check these deliberately omit
5860
- `truncate(str, maxLength, suffix?)` from `@sim/utils/string` — safe string truncation with ellipsis
5961
- `escapeRegExp(value)` from `@sim/utils/string` — escape regex metacharacters. Never inline `replace(/[.*+?^${}()|[\]\\]/g, '\\$&')`
6062
- `compareStrings(left, right)` from `@sim/utils/string` — code-unit string comparator for hashes, fingerprints, and values compared across processes. Never `localeCompare` there

CLAUDE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ You are a professional software engineer. All code must follow best practices: a
1717
- `structuredClone(value)` — built-in deep clone; never `JSON.parse(JSON.stringify(...))`
1818
- `omit(obj, keys)` / `filterUndefined(obj)` from `@sim/utils/object` — object trimming; never `Object.fromEntries(Object.entries(...).filter(...))`
1919
- `isRecordLike(value)` from `@sim/utils/object` — never redeclare `typeof value === 'object' && value !== null && !Array.isArray(value)`
20+
- `toRecord(value)` / `toRecordOrNull(value)` / `toArray(value)` from `@sim/utils/object` — coerce an untyped payload value to a record or array; never inline `isRecordLike(v) ? v : {}` or `Array.isArray(v) ? v : []`. Where the source is already typed, keep the inline `Array.isArray` check: it narrows, while `toArray` asserts
21+
- `toStringOrNull(value)` / `toNumberOrNull(value)` / `toBooleanOrNull(value)` from `@sim/utils/coerce` — read one scalar out of an untyped payload; never declare a local one-liner byte-identical to one of these. Keep a local helper that differs: `undefined` instead of `null` changes the wire shape, and a `Number.isFinite` or string-parse variant is a stricter check these omit
2022
- `truncate(str, maxLength, suffix?)` from `@sim/utils/string` — never inline slice + ellipsis
2123
- `escapeRegExp(value)` from `@sim/utils/string` — never inline `replace(/[.*+?^${}()|[\]\\]/g, '\\$&')`
2224
- `compareStrings(left, right)` from `@sim/utils/string` — code-unit ordering for hashes, fingerprints, and cross-process comparisons; never `localeCompare` there

apps/sim/app/api/v2/tables/utils.ts

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { toStringOrNull } from '@sim/utils/coerce'
12
import type {
23
V2ApiTable,
34
V2EnrichmentProviderOutcome,
@@ -238,11 +239,6 @@ function storedNumber(value: unknown): number {
238239
return typeof value === 'number' && Number.isFinite(value) ? value : 0
239240
}
240241

241-
/** Reads a stored field that the published shape declares as a nullable string. */
242-
function storedNullableString(value: unknown): string | null {
243-
return typeof value === 'string' ? value : null
244-
}
245-
246242
/**
247243
* Reads a stored timestamp, keeping only a value the published `date-time`
248244
* format will accept. A Postgres literal or a half-written blob becomes `null`
@@ -257,13 +253,13 @@ function storedTimestamp(value: unknown): string | null {
257253
function toApiEnrichmentProvider(value: unknown): V2EnrichmentProviderOutcome {
258254
const provider = (value ?? {}) as Record<string, unknown>
259255
return {
260-
id: storedNullableString(provider.id) ?? '',
261-
label: storedNullableString(provider.label) ?? '',
262-
toolId: storedNullableString(provider.toolId) ?? '',
263-
status: storedNullableString(provider.status) ?? 'not_run',
256+
id: toStringOrNull(provider.id) ?? '',
257+
label: toStringOrNull(provider.label) ?? '',
258+
toolId: toStringOrNull(provider.toolId) ?? '',
259+
status: toStringOrNull(provider.status) ?? 'not_run',
264260
cost: storedNumber(provider.cost),
265261
durationMs: storedNumber(provider.durationMs),
266-
error: storedNullableString(provider.error),
262+
error: toStringOrNull(provider.error),
267263
}
268264
}
269265

@@ -287,7 +283,7 @@ export function toApiEnrichmentDetail(
287283
completedAt: storedTimestamp(stored.completedAt),
288284
durationMs: storedNumber(stored.durationMs),
289285
totalCost: storedNumber(stored.totalCost),
290-
matchedProvider: storedNullableString(stored.matchedProvider),
286+
matchedProvider: toStringOrNull(stored.matchedProvider),
291287
aborted: stored.aborted === true,
292288
providers: Array.isArray(stored.providers) ? stored.providers.map(toApiEnrichmentProvider) : [],
293289
}

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { toArray } from '@sim/utils/object'
12
/**
23
* Extracts the raw value from a preview context entry.
34
*
@@ -41,5 +42,5 @@ export function parseJsonArrayValue<T>(value: unknown): T[] {
4142
return []
4243
}
4344
}
44-
return Array.isArray(parsed) ? (parsed as T[]) : []
45+
return toArray<T>(parsed)
4546
}

apps/sim/connectors/grain/grain.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { createLogger } from '@sim/logger'
22
import { getErrorMessage } from '@sim/utils/errors'
3+
import { toArray } from '@sim/utils/object'
34
import { fetchWithRetry } from '@/lib/knowledge/documents/secure-fetch.server'
45
import { VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils'
56
import { grainConnectorMeta } from '@/connectors/grain/meta'
@@ -328,7 +329,7 @@ async function fetchTranscript(
328329
}
329330

330331
const data = await response.json()
331-
return Array.isArray(data) ? (data as GrainTranscriptSegment[]) : []
332+
return toArray<GrainTranscriptSegment>(data)
332333
}
333334

334335
export const grainConnector: ConnectorConfig = {

apps/sim/lib/internal/asana/client.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { toArray } from '@sim/utils/object'
12
import { readResponseTextWithLimit } from '@/lib/core/utils/stream-limits'
23
import { AsanaOperationError } from '@/lib/internal/asana/errors'
34

@@ -13,7 +14,7 @@ export function asObject(value: unknown): AsanaJsonObject {
1314
}
1415

1516
export function asArray(value: unknown): unknown[] {
16-
return Array.isArray(value) ? value : []
17+
return toArray(value)
1718
}
1819

1920
function providerErrorMessage(response: Response, text: string): string {

apps/sim/lib/internal/cbinsights/operations/chat.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1+
import { toStringOrNull } from '@sim/utils/coerce'
12
import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types'
23
import type { CbInsightsChatParams } from '@/tools/cbinsights/chat'
34
import {
45
asArray,
5-
asString,
66
asStringArray,
77
cbInsightsRequest,
88
compactBody,
@@ -29,9 +29,9 @@ export const executeCbinsightsChatOperation: InternalToolOperationImplementation
2929
body: compactBody({ message, chatID: parseOptionalStringParam(params.chatId, 'chatId') }),
3030
},
3131
(data) => ({
32-
chatId: asString(data.chatID),
33-
title: asString(data.title),
34-
message: asString(data.message),
32+
chatId: toStringOrNull(data.chatID),
33+
title: toStringOrNull(data.title),
34+
message: toStringOrNull(data.message),
3535
sources: asArray(data.sources),
3636
relatedContent: asArray(data.relatedContent),
3737
suggestions: asStringArray(data.suggestions),

apps/sim/lib/internal/cbinsights/operations/get-exit-probability-history.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1+
import { toStringOrNull } from '@sim/utils/coerce'
12
import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types'
23
import type { CbInsightsExitProbabilityHistoryParams } from '@/tools/cbinsights/get_exit_probability_history'
34
import {
45
asArray,
5-
asString,
66
cbInsightsRequest,
77
compactBody,
88
parseOptionalStringParam,
@@ -25,7 +25,7 @@ export const executeCbinsightsGetExitProbabilityHistoryOperation: InternalToolOp
2525
(data) => ({
2626
ipo: asArray(data.ipo),
2727
mna: asArray(data.mna),
28-
incompleteRoundType: asString(data.incompleteRoundType),
28+
incompleteRoundType: toStringOrNull(data.incompleteRoundType),
2929
}),
3030
signal
3131
)

apps/sim/lib/internal/cbinsights/operations/get-org-funding-window.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1+
import { toNumberOrNull, toStringOrNull } from '@sim/utils/coerce'
12
import { toRecordOrNull } from '@sim/utils/object'
23
import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types'
34
import type { CbInsightsOrgParams } from '@/tools/cbinsights/types'
4-
import { asNumber, asString, cbInsightsRequest, requireOrgId } from '@/tools/cbinsights/utils'
5+
import { cbInsightsRequest, requireOrgId } from '@/tools/cbinsights/utils'
56

67
export const executeCbinsightsGetOrgFundingWindowOperation: InternalToolOperationImplementation<
78
CbInsightsOrgParams
@@ -17,9 +18,9 @@ export const executeCbinsightsGetOrgFundingWindowOperation: InternalToolOperatio
1718
params,
1819
{ path: `/v2/organizations/${orgId}/fundingwindow` },
1920
(data) => ({
20-
windowStart: asString(data.windowStart),
21-
windowEnd: asString(data.windowEnd),
22-
cohortNextRoundRate: asNumber(data.cohortNextRoundRate),
21+
windowStart: toStringOrNull(data.windowStart),
22+
windowEnd: toStringOrNull(data.windowEnd),
23+
cohortNextRoundRate: toNumberOrNull(data.cohortNextRoundRate),
2324
cohortCriteria: toRecordOrNull(data.cohortCriteria),
2425
latestFunding: toRecordOrNull(data.latestFunding),
2526
}),

0 commit comments

Comments
 (0)