Skip to content

Commit 9d34fbe

Browse files
authored
refactor(utils): consolidate duplicated helpers onto @sim/utils (#5509)
* refactor(utils): consolidate duplicated helpers onto @sim/utils Replaces ~90 hand-rolled reimplementations of error-message extraction, postgres error-code checks, sleep, Math.random, retry/backoff, object filtering/omission, noop, string truncation, date/time formatting, email normalization, and plain-object type guards with the shared @sim/utils exports. Wires check:utils into CI (test-build.yml) so these patterns don't regress. * fix(test): mock @sim/utils/random instead of Math.random in schedule-execute tests The schedules/execute route previously used Math.random() for jitter delay; this consolidation PR switched it to randomInt() from @sim/utils/random, which is backed by crypto.getRandomValues() rather than Math.random(). The route.test.ts spies on Math.random() no longer had any effect, so jitter became real random delay instead of the deterministic 0ms the tests expect, causing intermittent 10s timeouts in CI. * fix(retry): preserve uncapped Retry-After comparison in tools/index.ts parseRetryAfter() caps its return value at 30s by default. tools/index.ts compares the parsed Retry-After against a caller-configured maxDelayMs to decide whether to skip a retry entirely -- capping before that comparison silently defeats the skip check whenever maxDelayMs is configured above 30s, since a Retry-After between 30s and maxDelayMs would incorrectly look "within limits" and get retried instead of skipped (caught by Cursor Bugbot). Added an optional maxMs param (default unchanged) so tools/index.ts can request the raw, uncapped value for its own comparison while backoffWithJitter still clamps the actual sleep duration to maxDelayMs. Added a regression test covering maxDelayMs > 30s. * fix(utils): fall back to Intl-resolved abbreviation for unmapped timezones getTimezoneAbbreviation only covered 9 hardcoded IANA zones and returned the raw IANA string for everything else, degrading schedule descriptions for zones like Europe/Berlin or America/Toronto (caught by Greptile). The deleted local implementation in schedules/utils.ts resolved any valid IANA timezone generically via Intl.DateTimeFormat's short timeZoneName. Restore that as a fallback so only genuinely invalid timezone strings return themselves unchanged.
1 parent 099f525 commit 9d34fbe

98 files changed

Lines changed: 310 additions & 327 deletions

File tree

Some content is hidden

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

.github/workflows/test-build.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,9 @@ jobs:
116116
- name: API contract boundary audit
117117
run: bun run check:api-validation:strict
118118

119+
- name: Shared utils enforcement audit
120+
run: bun run check:utils
121+
119122
- name: Zustand v5 selector audit
120123
run: bun run check:zustand-v5
121124

apps/sim/app/(auth)/login/login-form.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
} from '@sim/emcn'
1212
import { createLogger } from '@sim/logger'
1313
import { getErrorMessage } from '@sim/utils/errors'
14+
import { normalizeEmail } from '@sim/utils/string'
1415
import { useRouter, useSearchParams } from 'next/navigation'
1516
import { requestJson } from '@/lib/api/client/request'
1617
import { forgetPasswordContract } from '@/lib/api/contracts'
@@ -45,7 +46,7 @@ const validateEmailField = (emailValue: string): string[] => {
4546
return errors
4647
}
4748

48-
const validation = quickValidateEmail(emailValue.trim().toLowerCase())
49+
const validation = quickValidateEmail(normalizeEmail(emailValue))
4950
if (!validation.isValid) {
5051
errors.push(validation.reason || 'Please enter a valid email address.')
5152
}
@@ -159,7 +160,7 @@ export default function LoginPage({
159160

160161
const formData = new FormData(e.currentTarget)
161162
const emailRaw = formData.get('email') as string
162-
const email = emailRaw.trim().toLowerCase()
163+
const email = normalizeEmail(emailRaw)
163164

164165
const emailValidationErrors = validateEmailField(email)
165166
setEmailErrors(emailValidationErrors)
@@ -277,7 +278,7 @@ export default function LoginPage({
277278
return
278279
}
279280

280-
const emailValidation = quickValidateEmail(forgotPasswordEmail.trim().toLowerCase())
281+
const emailValidation = quickValidateEmail(normalizeEmail(forgotPasswordEmail))
281282
if (!emailValidation.isValid) {
282283
setResetStatus({
283284
type: 'error',

apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
TableRow,
1919
Tooltip,
2020
} from '@sim/emcn'
21+
import { formatDateTime } from '@sim/utils/formatting'
2122
import { useQueryClient } from '@tanstack/react-query'
2223
import { RefreshCw } from 'lucide-react'
2324
import { useRouter } from 'next/navigation'
@@ -68,7 +69,7 @@ const STATUS_BADGE_VARIANT: Record<string, 'orange' | 'blue' | 'green' | 'red' |
6869
function formatDate(value: string | null): string {
6970
if (!value) return '—'
7071
try {
71-
return new Date(value).toLocaleString()
72+
return formatDateTime(new Date(value))
7273
} catch {
7374
return value
7475
}

apps/sim/app/(landing)/changelog/components/changelog-timeline/changelog-timeline.tsx

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { type ReactNode, useRef, useState } from 'react'
44
import { Streamdown } from 'streamdown'
55
import 'streamdown/styles.css'
66
import { Avatar, AvatarFallback, AvatarImage, Chip, cn } from '@sim/emcn'
7+
import { formatDate } from '@sim/utils/formatting'
78
import type { ChangelogEntry, GitHubRelease } from '@/app/(landing)/changelog/types'
89
import { mapReleases, releasesEndpoint } from '@/app/(landing)/changelog/utils'
910

@@ -53,14 +54,6 @@ function isContributorsLabel(children: ReactNode): boolean {
5354
return /^\s*contributors\s*:?\s*$/i.test(String(children))
5455
}
5556

56-
function formatDate(value: string): string {
57-
return new Date(value).toLocaleDateString('en-US', {
58-
year: 'numeric',
59-
month: 'short',
60-
day: 'numeric',
61-
})
62-
}
63-
6457
export function ChangelogTimeline({ initialEntries }: ChangelogTimelineProps) {
6558
const [entries, setEntries] = useState<ChangelogEntry[]>(initialEntries)
6659
const [loading, setLoading] = useState<boolean>(false)
@@ -133,7 +126,9 @@ export function ChangelogTimeline({ initialEntries }: ChangelogTimelineProps) {
133126
</div>
134127
) : null}
135128
</div>
136-
<span className='text-[12px] text-[var(--text-muted)]'>{formatDate(entry.date)}</span>
129+
<span className='text-[12px] text-[var(--text-muted)]'>
130+
{formatDate(new Date(entry.date))}
131+
</span>
137132
</div>
138133

139134
<div aria-hidden='true' className='mt-[9px] mb-3 h-px bg-[var(--border)]' />

apps/sim/app/api/auth/oauth/utils.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { createSign } from 'crypto'
22
import { db } from '@sim/db'
33
import { account, credential } from '@sim/db/schema'
44
import { createLogger } from '@sim/logger'
5-
import { toError } from '@sim/utils/errors'
5+
import { getPostgresErrorCode, toError } from '@sim/utils/errors'
66
import { and, desc, eq } from 'drizzle-orm'
77
import { withLeaderLock } from '@/lib/concurrency/leader-lock'
88
import { coalesceLocally } from '@/lib/concurrency/singleflight'
@@ -281,7 +281,7 @@ export async function safeAccountInsert(
281281
await db.insert(account).values(data)
282282
logger.info(`Created new ${context.provider} account for user`, { userId: data.userId })
283283
} catch (error: any) {
284-
if (error?.code === '23505') {
284+
if (getPostgresErrorCode(error) === '23505') {
285285
logger.error(`Duplicate ${context.provider} account detected, credential already exists`, {
286286
userId: data.userId,
287287
identifier: context.identifier,

apps/sim/app/api/billing/invoices/route.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
* @vitest-environment node
33
*/
44
import { createMockRequest, dbChainMock, dbChainMockFns } from '@sim/testing'
5+
import { generateShortId } from '@sim/utils/id'
56
import { beforeEach, describe, expect, it, vi } from 'vitest'
67

78
const { mockGetSession, mockGetStripeClient, mockStripeInvoicesList } = vi.hoisted(() => ({
@@ -25,7 +26,7 @@ import { GET } from '@/app/api/billing/invoices/route'
2526

2627
function makeInvoice(overrides: Record<string, unknown> = {}) {
2728
return {
28-
id: `in_${Math.random().toString(36).slice(2)}`,
29+
id: `in_${generateShortId()}`,
2930
number: 'INV-1',
3031
created: 1700000000,
3132
total: 1000,

apps/sim/app/api/cron/renew-subscriptions/route.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
redisConfigMockFns,
1313
resetDbChainMock,
1414
} from '@sim/testing'
15+
import { sleep } from '@sim/utils/helpers'
1516
import { beforeEach, describe, expect, it, vi } from 'vitest'
1617

1718
const { mockVerifyCronAuth } = vi.hoisted(() => ({
@@ -37,7 +38,7 @@ function createRequest() {
3738
)
3839
}
3940

40-
const flushMicrotasks = () => new Promise((resolve) => setTimeout(resolve, 0))
41+
const flushMicrotasks = () => sleep(0)
4142

4243
describe('Teams subscription renewal route (fire-and-forget)', () => {
4344
beforeEach(() => {

apps/sim/app/api/files/public/[token]/otp/route.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { createLogger } from '@sim/logger'
2+
import { normalizeEmail } from '@sim/utils/string'
23
import type { NextRequest } from 'next/server'
34
import { NextResponse } from 'next/server'
45
import { renderOTPEmail } from '@/components/emails'
@@ -71,7 +72,7 @@ export const POST = withRouteHandler(
7172
const { token } = parsed.data.params
7273
// Normalize once so allow-list matching, OTP storage, and the verify lookup
7374
// all key off the same value (allow-list entries are stored lowercase).
74-
const email = parsed.data.body.email.trim().toLowerCase()
75+
const email = normalizeEmail(parsed.data.body.email)
7576

7677
const resolved = await resolveActiveShareByToken(token)
7778
if (!resolved) {
@@ -133,7 +134,7 @@ export const PUT = withRouteHandler(
133134
if (!parsed.success) return parsed.response
134135
const { token } = parsed.data.params
135136
const { otp } = parsed.data.body
136-
const email = parsed.data.body.email.trim().toLowerCase()
137+
const email = normalizeEmail(parsed.data.body.email)
137138

138139
const resolved = await resolveActiveShareByToken(token)
139140
if (!resolved) {

apps/sim/app/api/files/public/[token]/sso/route.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { createLogger } from '@sim/logger'
2+
import { normalizeEmail } from '@sim/utils/string'
23
import type { NextRequest } from 'next/server'
34
import { NextResponse } from 'next/server'
45
import { publicFileSSOContract } from '@/lib/api/contracts/public-shares'
@@ -53,7 +54,7 @@ export const POST = withRouteHandler(
5354
const parsed = await parseRequest(publicFileSSOContract, request, context)
5455
if (!parsed.success) return parsed.response
5556
const { token } = parsed.data.params
56-
const email = parsed.data.body.email.trim().toLowerCase()
57+
const email = normalizeEmail(parsed.data.body.email)
5758

5859
const resolved = await resolveActiveShareByToken(token)
5960
if (!resolved) {

apps/sim/app/api/function/execute/route.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { createLogger } from '@sim/logger'
2+
import { getErrorMessage } from '@sim/utils/errors'
23
import { type NextRequest, NextResponse } from 'next/server'
34
import { functionExecuteContract } from '@/lib/api/contracts'
45
import { parseRequest } from '@/lib/api/server'
@@ -1021,7 +1022,7 @@ async function maybeExportSandboxFileToWorkspace(args: {
10211022
return NextResponse.json(
10221023
{
10231024
success: false,
1024-
error: error instanceof Error ? error.message : 'Failed to export sandbox file',
1025+
error: getErrorMessage(error, 'Failed to export sandbox file'),
10251026
output: { result: null, stdout: cleanStdout(stdout), executionTime },
10261027
},
10271028
{ status: 400 }
@@ -1165,7 +1166,7 @@ async function maybeExportSandboxFilesToWorkspace(args: {
11651166
return NextResponse.json(
11661167
{
11671168
success: false,
1168-
error: error instanceof Error ? error.message : 'Invalid sandbox output destination',
1169+
error: getErrorMessage(error, 'Invalid sandbox output destination'),
11691170
output: {
11701171
result: null,
11711172
stdout: cleanStdout(args.stdout),
@@ -1220,7 +1221,7 @@ async function maybeExportSandboxFilesToWorkspace(args: {
12201221
return NextResponse.json(
12211222
{
12221223
success: false,
1223-
error: error instanceof Error ? error.message : 'Failed to export sandbox files',
1224+
error: getErrorMessage(error, 'Failed to export sandbox files'),
12241225
output: {
12251226
result: null,
12261227
stdout: cleanStdout(args.stdout),

0 commit comments

Comments
 (0)