Skip to content

Commit d636d5e

Browse files
committed
fix(forking): preserve edits during fork sync
1 parent b4bc9bf commit d636d5e

4 files changed

Lines changed: 109 additions & 16 deletions

File tree

apps/sim/ee/workspace-forking/components/fork-sync/use-fork-sync.test.tsx

Lines changed: 92 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,23 @@ const UNTOUCHED_FIELD = dependent({
123123

124124
const DEPENDENTS = [PARENT_FIELD, CHILD_FIELD, CLEARED_FIELD, UNTOUCHED_FIELD]
125125

126+
const SUCCESSFUL_PROMOTE_RESULT = {
127+
promoteRunId: 'run-1',
128+
blockers: [],
129+
unmappedRequired: [],
130+
droppedReferences: [],
131+
triggerUrlChanges: [],
132+
deployFailed: 0,
133+
}
134+
135+
function createDeferred<T>() {
136+
let resolve!: (value: T) => void
137+
const promise = new Promise<T>((resolvePromise) => {
138+
resolve = resolvePromise
139+
})
140+
return { promise, resolve }
141+
}
142+
126143
const mappedRepickContext = (previousValue: string) => ({
127144
previousValue,
128145
baselineValueFor: (field: ForkDependentReconfig) => field.currentValue,
@@ -217,14 +234,7 @@ beforeEach(() => {
217234
isPlaceholderData: false,
218235
})
219236
mockUpdateMutateAsync.mockResolvedValue({ success: true, updated: 1 })
220-
mockPromote.mockResolvedValue({
221-
promoteRunId: 'run-1',
222-
blockers: [],
223-
unmappedRequired: [],
224-
droppedReferences: [],
225-
triggerUrlChanges: [],
226-
deployFailed: 0,
227-
})
237+
mockPromote.mockResolvedValue(SUCCESSFUL_PROMOTE_RESULT)
228238
})
229239

230240
afterEach(() => {
@@ -471,4 +481,78 @@ describe('useForkSync post-sync reset', () => {
471481

472482
expect(get().reconfig[dependentKey(PARENT_FIELD)]).toBe('sheet-2')
473483
})
484+
485+
it('keeps a newer target mapping selected while Sync was in flight', async () => {
486+
const pendingPromote = createDeferred<typeof SUCCESSFUL_PROMOTE_RESULT>()
487+
mockPromote.mockReturnValue(pendingPromote.promise)
488+
const { get } = renderForkSync()
489+
let syncPromise!: Promise<void>
490+
491+
act(() => {
492+
syncPromise = get().sync()
493+
})
494+
await act(async () => Promise.resolve())
495+
expect(mockPromote).toHaveBeenCalledTimes(1)
496+
497+
act(() => get().setTarget(CREDENTIAL_ENTRY, 'cred-newer'))
498+
pendingPromote.resolve(SUCCESSFUL_PROMOTE_RESULT)
499+
await act(async () => syncPromise)
500+
501+
expect(get().targetFor(CREDENTIAL_ENTRY)).toBe('cred-newer')
502+
expect(get().dirty).toBe(true)
503+
})
504+
505+
it('keeps a newer dependent re-pick made while Sync was in flight', async () => {
506+
const pendingPromote = createDeferred<typeof SUCCESSFUL_PROMOTE_RESULT>()
507+
mockPromote.mockReturnValue(pendingPromote.promise)
508+
const { get } = renderForkSync()
509+
let syncPromise!: Promise<void>
510+
511+
act(() => {
512+
syncPromise = get().sync()
513+
})
514+
await act(async () => Promise.resolve())
515+
expect(mockPromote).toHaveBeenCalledTimes(1)
516+
517+
act(() => {
518+
get().setReconfig((current) => ({
519+
...current,
520+
[dependentKey(PARENT_FIELD)]: 'sheet-newer',
521+
}))
522+
})
523+
pendingPromote.resolve(SUCCESSFUL_PROMOTE_RESULT)
524+
await act(async () => syncPromise)
525+
526+
expect(get().reconfig[dependentKey(PARENT_FIELD)]).toBe('sheet-newer')
527+
expect(get().dirty).toBe(true)
528+
})
529+
530+
it('keeps newer mapping edits made while Save was in flight', () => {
531+
let finishSave: (() => void) | undefined
532+
mockUpdateMutate.mockImplementation((_variables, options) => {
533+
finishSave = options.onSuccess
534+
})
535+
const { get } = renderForkSync()
536+
537+
act(() => {
538+
get().setReconfig((current) => ({
539+
...current,
540+
[dependentKey(PARENT_FIELD)]: 'sheet-submitted',
541+
}))
542+
})
543+
act(() => get().save())
544+
545+
act(() => {
546+
get().setTarget(CREDENTIAL_ENTRY, 'cred-newer')
547+
get().setReconfig((current) => ({
548+
...current,
549+
[dependentKey(PARENT_FIELD)]: 'sheet-newer',
550+
}))
551+
})
552+
act(() => finishSave?.())
553+
554+
expect(get().targetFor(CREDENTIAL_ENTRY)).toBe('cred-newer')
555+
expect(get().reconfig[dependentKey(PARENT_FIELD)]).toBe('sheet-newer')
556+
expect(get().dirty).toBe(true)
557+
})
474558
})

apps/sim/ee/workspace-forking/components/fork-sync/use-fork-sync.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -744,6 +744,8 @@ export function useForkSync(params: {
744744

745745
const save = () => {
746746
if (!otherWorkspaceId || !dirty || updateMapping.isPending) return
747+
const submittedTargets = targets
748+
const submittedReconfig = reconfig
747749
updateMapping.mutate(
748750
{
749751
workspaceId,
@@ -759,8 +761,8 @@ export function useForkSync(params: {
759761
},
760762
{
761763
onSuccess: () => {
762-
setTargets({})
763-
setReconfig({})
764+
setTargets((current) => (current === submittedTargets ? {} : current))
765+
setReconfig((current) => (current === submittedReconfig ? {} : current))
764766
toast.success('Mapping saved')
765767
},
766768
onError: (error) => toast.error(getErrorMessage(error, 'Failed to save mapping')),
@@ -836,6 +838,8 @@ export function useForkSync(params: {
836838
const sync = async () => {
837839
if (!otherWorkspaceId) return
838840
setSubmitting(true)
841+
const submittedTargets = targets
842+
const submittedReconfig = reconfig
839843
// Capture every payload from the state at confirm time, before any await - the page's
840844
// controls stay mounted during the run (unlike the old modal, which blocked its UI), so a
841845
// mid-flight edit must not leak into the promote body.
@@ -931,10 +935,10 @@ export function useForkSync(params: {
931935
}
932936

933937
// The run committed the in-session choices: the mapping entries and dependent values are
934-
// stored, so keeping the overrides would leave every touched field's card on screen against
935-
// a freshly refetched diff. Drop them exactly as a successful Save does.
936-
setTargets({})
937-
setReconfig({})
938+
// stored. Drop only the exact snapshots it submitted; edits made while the request was in
939+
// flight were not committed by this run and must remain available for the next Save/Sync.
940+
setTargets((current) => (current === submittedTargets ? {} : current))
941+
setReconfig((current) => (current === submittedReconfig ? {} : current))
938942

939943
const target = otherWorkspaceName || 'the workspace'
940944
const label = direction === 'pull' ? `Pulled from "${target}"` : `Pushed to "${target}"`

apps/sim/ee/workspace-forking/lib/copy/copy-files.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,7 @@ describe('executeForkFileBlobCopies target name collisions', () => {
382382
targetKey: 'workspace/child-ws/target-budget.xlsx',
383383
contentType: 'application/vnd.ms-excel',
384384
targetFolderId: 'target-reports',
385+
displayName: 'budget.xlsx',
385386
})
386387

387388
beforeEach(() => {
@@ -427,6 +428,7 @@ describe('executeForkFileBlobCopies target name collisions', () => {
427428
workspaceId: 'child-ws',
428429
folderId: 'target-reports',
429430
originalName: 'budget (1).xlsx',
431+
displayName: 'budget (1).xlsx',
430432
deletedAt: null,
431433
})
432434
// The blob backing the surviving row must never be swept.
@@ -463,6 +465,7 @@ describe('executeForkFileBlobCopies target name collisions', () => {
463465
expect(fileRows.find((row) => row.id === 'target-file-1')).toMatchObject({
464466
folderId: 'target-reports',
465467
originalName: 'budget.xlsx',
468+
displayName: 'budget.xlsx',
466469
})
467470
expect(storageServiceMockFns.mockDeleteFile).not.toHaveBeenCalled()
468471
})

apps/sim/ee/workspace-forking/lib/copy/copy-files.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,8 @@ export async function executeForkFileBlobCopies(
325325

326326
const billingContext = await resolveStorageBillingContext(task.workspaceId)
327327
const targetOriginalName = await resolveTargetOriginalName(task)
328+
const targetDisplayName =
329+
targetOriginalName === task.fileName ? task.displayName : targetOriginalName
328330
await db.transaction(async (tx) => {
329331
const [inserted] = await tx
330332
.insert(workspaceFiles)
@@ -337,7 +339,7 @@ export async function executeForkFileBlobCopies(
337339
context: task.context,
338340
chatId: null,
339341
originalName: targetOriginalName,
340-
displayName: task.displayName,
342+
displayName: targetDisplayName,
341343
contentType: task.contentType,
342344
size: task.size,
343345
deletedAt: null,
@@ -385,7 +387,7 @@ export async function executeForkFileBlobCopies(
385387
context: task.context,
386388
chatId: null,
387389
originalName: targetOriginalName,
388-
displayName: task.displayName,
390+
displayName: targetDisplayName,
389391
contentType: task.contentType,
390392
size: task.size,
391393
deletedAt: null,

0 commit comments

Comments
 (0)