Skip to content

Commit 2643970

Browse files
committed
Merge remote-tracking branch 'origin/staging' into realtime-rooms
# Conflicts: # apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx
2 parents a58f928 + e5ae445 commit 2643970

6 files changed

Lines changed: 227 additions & 106 deletions

File tree

apps/sim/app/api/v1/admin/workspaces/[id]/import/route.ts

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,10 @@ import {
4141
extractWorkflowsFromZip,
4242
parseWorkflowJson,
4343
} from '@/lib/workflows/operations/import-export'
44+
import { prepareWorkflowStateForPersistence } from '@/lib/workflows/persistence/prepare-state'
4445
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils'
4546
import { deduplicateWorkflowName } from '@/lib/workflows/utils'
47+
import { normalizeImportedVariables } from '@/lib/workflows/variables/parse'
4648
import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils'
4749
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
4850
import {
@@ -52,7 +54,6 @@ import {
5254
} from '@/app/api/v1/admin/responses'
5355
import type {
5456
ImportResult,
55-
WorkflowVariable,
5657
WorkspaceImportRequest,
5758
WorkspaceImportResponse,
5859
} from '@/app/api/v1/admin/types'
@@ -270,7 +271,22 @@ async function importSingleWorkflow(
270271
variables: {},
271272
})
272273

273-
const saveResult = await saveWorkflowToNormalizedTables(workflowId, workflowData)
274+
/**
275+
* Same normalization the editor, the v1 import API and the single-workflow
276+
* admin import run, via the one shared implementation. Without it this route
277+
* wrote raw parsed state, so a dangling edge tripped the `workflow_edges`
278+
* foreign key and failed the restore, and blocks missing their backfilled
279+
* columns could land unopenable.
280+
*/
281+
const { state: preparedState, warnings } = prepareWorkflowStateForPersistence(workflowData)
282+
if (warnings.length > 0) {
283+
logger.warn(`Admin API: normalized "${dedupedName}" with warnings`, { warnings })
284+
}
285+
286+
const saveResult = await saveWorkflowToNormalizedTables(workflowId, {
287+
...workflowData,
288+
...preparedState,
289+
})
274290

275291
if (!saveResult.success) {
276292
await db.delete(workflow).where(eq(workflow.id, workflowId))
@@ -282,18 +298,13 @@ async function importSingleWorkflow(
282298
}
283299
}
284300

285-
if (workflowData.variables && Array.isArray(workflowData.variables)) {
286-
const variablesRecord: Record<string, WorkflowVariable> = {}
287-
workflowData.variables.forEach((v) => {
288-
const varId = v.id || generateId()
289-
variablesRecord[varId] = {
290-
id: varId,
291-
name: v.name,
292-
type: v.type || 'string',
293-
value: v.value,
294-
}
295-
})
296-
301+
/**
302+
* Previously guarded on `Array.isArray`, which silently dropped every
303+
* variable in the current record form — the exact shape this workspace's
304+
* own export emits — so an export/import round trip lost all of them.
305+
*/
306+
const variablesRecord = normalizeImportedVariables(workflowData.variables)
307+
if (Object.keys(variablesRecord).length > 0) {
297308
await db
298309
.update(workflow)
299310
.set({ variables: variablesRecord, updatedAt: new Date() })

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/lock-copy.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/**
2-
* Single source of truth for lock vocabulary shared by the lock settings modal,
3-
* the blocked-action toast, and the table header chip. Kept out of
2+
* Single source of truth for lock vocabulary shared by the lock settings modal
3+
* and the lock toasts (the on-open announcement and blocked actions). Kept out of
44
* `lib/table/mutation-locks.ts` — that module is server-tainted (importing it
55
* from a client component pulls `next/headers` into the browser bundle).
66
*/
@@ -76,8 +76,8 @@ export function describeLocks(locks: TableLocks): { name: string; detail: string
7676

7777
/**
7878
* Why a locked-table notice was raised. `'status'` is the informational case
79-
* (a non-admin clicking the header lock chip); the rest are actions the user
80-
* just tried and couldn't do.
79+
* (the announcement shown once when a locked table is opened); the rest are
80+
* actions the user just tried and couldn't do.
8181
*/
8282
export type BlockedTableAction = 'add-row' | 'add-column' | 'delete-column' | 'edit-cell' | 'status'
8383

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx

Lines changed: 46 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client'
22

3-
import { useCallback, useMemo, useReducer, useRef, useState } from 'react'
3+
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'
44
import { Chip, ChipConfirmModal, toast } from '@sim/emcn'
55
import { Download, Lock, Pencil, Table as TableIcon, Trash, Upload } from '@sim/emcn/icons'
66
import { createLogger } from '@sim/logger'
@@ -62,12 +62,7 @@ import {
6262
import { COLUMN_SIDEBAR_WIDTH } from './components/table-grid/constants'
6363
import { COLUMN_TYPE_ICONS } from './components/table-grid/headers'
6464
import { useTable, useTableEventStream, useTableRoom } from './hooks'
65-
import {
66-
type BlockedTableAction,
67-
describeBlockedAction,
68-
describeLocks,
69-
lockedNouns,
70-
} from './lock-copy'
65+
import { type BlockedTableAction, describeBlockedAction, lockedNouns } from './lock-copy'
7166
import {
7267
DEFAULT_TABLE_DETAIL_SORT_DIRECTION,
7368
tableDetailParsers,
@@ -636,7 +631,10 @@ export function Table({
636631
if (!tableData) return
637632
if (blockedToastIdRef.current) toast.dismiss(blockedToastIdRef.current)
638633
const { title, text } = describeBlockedAction(action, tableData.locks)
639-
blockedToastIdRef.current = toast.warning(title, {
634+
// 'status' is the on-open announcement — nothing was refused, so it reads
635+
// as information rather than a warning.
636+
const notify = action === 'status' ? toast.info : toast.warning
637+
blockedToastIdRef.current = notify(title, {
640638
description: text,
641639
...(canOpenLockSettings
642640
? {
@@ -650,23 +648,48 @@ export function Table({
650648
[tableData, canOpenLockSettings]
651649
)
652650

651+
// Announce the lock state once per table on open. Unlike the re-rendering
652+
// permission gates, this fires once and can't self-correct, so it waits for
653+
// `canAdmin` to settle instead of treating loading as permitted.
654+
const announcedLockTableIdRef = useRef<string | null>(null)
655+
useEffect(() => {
656+
if (!tableData || userPermissions.isLoading) return
657+
if (announcedLockTableIdRef.current === tableData.id) return
658+
announcedLockTableIdRef.current = tableData.id
659+
if (lockedNouns(tableData.locks).length === 0) return
660+
showBlockedToast('status')
661+
}, [tableData, userPermissions.isLoading, showBlockedToast])
662+
663+
// A notice must not outlive the table it describes — its action targets
664+
// whichever table is current. Keyed on `tableId` so an embedded swap that
665+
// changes the prop without a route change is covered too. Leaving ends the
666+
// visit, so the latch resets and coming back announces again.
667+
useEffect(
668+
() => () => {
669+
announcedLockTableIdRef.current = null
670+
if (!blockedToastIdRef.current) return
671+
toast.dismiss(blockedToastIdRef.current)
672+
blockedToastIdRef.current = null
673+
},
674+
[tableId]
675+
)
676+
677+
// A toast's action is captured when it is created, so a viewer who loses
678+
// admin access mid-toast would keep a Lock settings button that opens
679+
// nothing. Dismiss on that transition only — a viewer who never had access
680+
// has a legitimate action-less notice that must survive.
681+
const couldOpenLockSettingsRef = useRef(canOpenLockSettings)
682+
useEffect(() => {
683+
const lostAccess = couldOpenLockSettingsRef.current && !canOpenLockSettings
684+
couldOpenLockSettingsRef.current = canOpenLockSettings
685+
if (!lostAccess || !blockedToastIdRef.current) return
686+
toast.dismiss(blockedToastIdRef.current)
687+
blockedToastIdRef.current = null
688+
}, [canOpenLockSettings])
689+
653690
const headerActions = useMemo(() => {
654691
if (!tableData) return undefined
655-
// Header space is for state, not for settings: the chip appears only once
656-
// something is actually locked, and names the mode so it reads at a glance.
657-
// Reaching the panel on an unlocked table is the dropdown's job.
658-
const anyLocked = lockedNouns(tableData.locks).length > 0
659692
return [
660-
...(anyLocked
661-
? [
662-
{
663-
label: describeLocks(tableData.locks).name,
664-
icon: Lock,
665-
onClick: () =>
666-
userPermissions.canAdmin ? setShowLockSettings(true) : showBlockedToast('status'),
667-
},
668-
]
669-
: []),
670693
{
671694
label: 'Import CSV',
672695
icon: Upload,
@@ -682,14 +705,7 @@ export function Table({
682705
disabled: tableData.rowCount === 0,
683706
},
684707
]
685-
}, [
686-
tableData,
687-
userPermissions.canEdit,
688-
userPermissions.canAdmin,
689-
handleExportCsv,
690-
onRequestImportCsv,
691-
showBlockedToast,
692-
])
708+
}, [tableData, userPermissions.canEdit, handleExportCsv, onRequestImportCsv])
693709

694710
// Adding a column is a schema change. The trigger stays visible when the
695711
// table is schema-locked and explains itself instead of disappearing.

apps/sim/lib/workflows/operations/import-export.ts

Lines changed: 10 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { createLogger } from '@sim/logger'
22
import { getErrorMessage } from '@sim/utils/errors'
3-
import { generateId } from '@sim/utils/id'
43
import { isRecordLike } from '@sim/utils/object'
54
import { ApiClientError } from '@/lib/api/client/errors'
65
import { requestJson } from '@/lib/api/client/request'
@@ -18,6 +17,7 @@ import {
1817
sanitizeForExport,
1918
} from '@/lib/workflows/sanitization/json-sanitizer'
2019
import { sanitizeMalformedSubBlocks } from '@/lib/workflows/sanitization/subblocks'
20+
import { normalizeImportedVariables } from '@/lib/workflows/variables/parse'
2121
import { regenerateWorkflowIds } from '@/stores/workflows/utils'
2222
import type { Variable, WorkflowState } from '@/stores/workflows/workflow/types'
2323

@@ -724,37 +724,15 @@ export async function persistImportedWorkflow({
724724
throw new Error(`Failed to save workflow state for ${newWorkflowId}`)
725725
}
726726

727-
if (workflowData.variables) {
728-
const variablesArray = Array.isArray(workflowData.variables)
729-
? workflowData.variables
730-
: Object.values(workflowData.variables)
731-
732-
if (variablesArray.length > 0) {
733-
type WorkflowVariablesBodyInput = NonNullable<
734-
Parameters<typeof requestJson<typeof workflowVariablesContract>>[1]['body']
735-
>
736-
const variablesRecord: WorkflowVariablesBodyInput['variables'] = {}
737-
738-
for (const variable of variablesArray) {
739-
const id =
740-
typeof variable.id === 'string' && variable.id.trim() ? variable.id : generateId()
741-
742-
variablesRecord[id] = {
743-
id,
744-
name: variable.name,
745-
type: variable.type,
746-
value: variable.value,
747-
}
748-
}
749-
750-
try {
751-
await requestJson(workflowVariablesContract, {
752-
params: { id: newWorkflowId },
753-
body: { variables: variablesRecord },
754-
})
755-
} catch {
756-
throw new Error(`Failed to save variables for ${newWorkflowId}`)
757-
}
727+
const variablesRecord = normalizeImportedVariables(workflowData.variables)
728+
if (Object.keys(variablesRecord).length > 0) {
729+
try {
730+
await requestJson(workflowVariablesContract, {
731+
params: { id: newWorkflowId },
732+
body: { variables: variablesRecord },
733+
})
734+
} catch {
735+
throw new Error(`Failed to save variables for ${newWorkflowId}`)
758736
}
759737
}
760738

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
/**
2+
* @vitest-environment node
3+
*
4+
* Pins the contract between the two halves of every workflow export/import
5+
* round trip: `parseWorkflowVariables` produces what exports emit, and
6+
* `normalizeImportedVariables` consumes what imports receive. A shape the first
7+
* emits that the second drops is silent data loss on a restore, which is
8+
* exactly the bug the workspace importer shipped with.
9+
*/
10+
11+
import { describe, expect, it } from 'vitest'
12+
import { normalizeImportedVariables, parseWorkflowVariables } from '@/lib/workflows/variables/parse'
13+
14+
const VARIABLE = {
15+
id: 'var-1',
16+
name: 'apiHost',
17+
type: 'string' as const,
18+
value: 'https://example.com',
19+
}
20+
21+
describe('parseWorkflowVariables', () => {
22+
it('returns undefined for null so callers can omit the field', () => {
23+
expect(parseWorkflowVariables(null)).toBeUndefined()
24+
})
25+
26+
it('reads the current record form', () => {
27+
expect(parseWorkflowVariables({ 'var-1': VARIABLE })).toEqual({ 'var-1': VARIABLE })
28+
})
29+
30+
it('re-keys the legacy array form by variable id', () => {
31+
expect(parseWorkflowVariables([VARIABLE] as never)).toEqual({ 'var-1': VARIABLE })
32+
})
33+
34+
it('parses a JSON string column value', () => {
35+
expect(parseWorkflowVariables(JSON.stringify({ 'var-1': VARIABLE }) as never)).toEqual({
36+
'var-1': VARIABLE,
37+
})
38+
})
39+
40+
it('skips legacy array rows without a usable id instead of emitting an "undefined" key', () => {
41+
const result = parseWorkflowVariables([VARIABLE, { name: 'orphan' }, null] as never)
42+
43+
expect(Object.keys(result ?? {})).toEqual(['var-1'])
44+
})
45+
})
46+
47+
describe('normalizeImportedVariables', () => {
48+
it('accepts the record form', () => {
49+
expect(normalizeImportedVariables({ 'var-1': VARIABLE })).toEqual({ 'var-1': VARIABLE })
50+
})
51+
52+
it('accepts the legacy array form', () => {
53+
expect(normalizeImportedVariables([VARIABLE])).toEqual({ 'var-1': VARIABLE })
54+
})
55+
56+
it('returns an empty record for null, undefined and non-objects', () => {
57+
expect(normalizeImportedVariables(null)).toEqual({})
58+
expect(normalizeImportedVariables(undefined)).toEqual({})
59+
expect(normalizeImportedVariables('nope')).toEqual({})
60+
})
61+
62+
it('falls back to the map key when an entry carries no id', () => {
63+
expect(normalizeImportedVariables({ fromKey: { name: 'x', value: 1 } })).toMatchObject({
64+
fromKey: { id: 'fromKey', name: 'x' },
65+
})
66+
})
67+
68+
it('coerces an unrecognized type to string rather than persisting it', () => {
69+
const result = normalizeImportedVariables({ v: { ...VARIABLE, type: 'secret' } })
70+
71+
expect(result['var-1'].type).toBe('string')
72+
})
73+
74+
it('keeps a __proto__-keyed variable as an ordinary own property', () => {
75+
/**
76+
* Built via `JSON.parse` rather than a literal: an object literal's
77+
* `__proto__` sets the prototype, while `JSON.parse` creates a real own
78+
* key — and `JSON.parse` is how an import payload actually arrives.
79+
*/
80+
const payload = JSON.parse('{"__proto__": {"name": "sneaky", "value": 1}}')
81+
82+
const result = normalizeImportedVariables(payload)
83+
84+
expect(Object.keys(result)).toContain('__proto__')
85+
expect(Object.getPrototypeOf(result)).toBe(Object.prototype)
86+
})
87+
88+
it('trims a padded id so the key is referenceable', () => {
89+
expect(Object.keys(normalizeImportedVariables([{ ...VARIABLE, id: ' var-1 ' }]))).toEqual([
90+
'var-1',
91+
])
92+
})
93+
94+
it('skips non-object entries instead of writing junk variables', () => {
95+
expect(normalizeImportedVariables(['nope', null, VARIABLE])).toEqual({ 'var-1': VARIABLE })
96+
})
97+
})
98+
99+
describe('export -> import variable round trip', () => {
100+
/**
101+
* The regression guard. Workspace export writes whatever
102+
* `parseWorkflowVariables` returns — the record form — and the importer used
103+
* to guard on `Array.isArray`, so every variable was silently dropped on
104+
* restore.
105+
*/
106+
it('survives the record form that exports actually emit', () => {
107+
const exported = parseWorkflowVariables({ 'var-1': VARIABLE })
108+
109+
expect(exported).toBeDefined()
110+
expect(Array.isArray(exported)).toBe(false)
111+
expect(normalizeImportedVariables(exported)).toEqual({ 'var-1': VARIABLE })
112+
})
113+
114+
it('survives a legacy array-form export', () => {
115+
const exported = parseWorkflowVariables([VARIABLE] as never)
116+
117+
expect(normalizeImportedVariables(exported)).toEqual({ 'var-1': VARIABLE })
118+
})
119+
})

0 commit comments

Comments
 (0)