Skip to content

Commit f4c5ef7

Browse files
committed
fix(migrations): keep a ServiceNow write body off the read projection
Review findings from the first round. - A legacy ServiceNow block can hold a Create/Update Record JSON body under `fields` while its stored operation is Read Records: the id served both value spaces before the rename, and a subblock value is not cleared when the operation changes. The scoped migration moved that body onto `readFields`, where it would reach the wire as sysparm_fields. Migration entries can now carry a `whenValue` predicate for the case where the stored operation alone cannot separate two value spaces, and the ServiceNow entry uses it to move only a plausible comma-separated projection. - Type the fork copy test harness instead of using `any`, without weakening it: every predicate shape it does not model still throws rather than matching. - Correct the dependent-omission comments. Omitting a parent-invalidated field preserves the target's stored value on Save and across an undo, where the parent nets out unchanged; on a Sync the written state is source-derived, so what it prevents there is an explicit blank reaching the fields the remap's clearing pass does not cover, nested tool params in particular. Okta's migration scope is left as-is: `okta_remove_user_from_app` and the sendEmail split shipped in the same release, so no saved block can hold legacy state for it, and widening the scope would promote an activation-era value onto the deactivation switch. Tests document the boundary.
1 parent 198a370 commit f4c5ef7

5 files changed

Lines changed: 266 additions & 38 deletions

File tree

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

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,17 @@ function sameDependencyScope(left: ForkDependentReconfig, right: ForkDependentRe
1313
* Marker stored for a descendant whose value an in-session parent re-pick invalidated, kept
1414
* distinct from the user's own empty pick. It reads as blank everywhere it is consumed - the
1515
* selector, the in-block chain context, and the sync gate - but is never submitted: the user
16-
* has not chosen a replacement, so the target keeps its stored value instead of being blanked.
17-
* A `''` the user picked themselves IS submitted and does clear the target.
16+
* has not chosen a replacement, so no override is written for it. A `''` the user picked
17+
* themselves IS submitted and does clear the target.
18+
*
19+
* What the omission buys differs by path. On Save nothing rewrites the target draft, so its
20+
* stored value survives outright - including across an undo (re-pick away, then back), where
21+
* the parent nets out unchanged so `clearDependentsOnRemap` never fires and an explicit `''`
22+
* would land on a value nobody touched. On Sync the written state is source-derived and
23+
* `clearDependentsOnRemap` already blanks every top-level dependent of a remapped parent, so
24+
* omission preserves nothing there; what it prevents is an explicit `''` reaching the fields
25+
* that pass does not cover - nested `tools[i].param` values in particular, which
26+
* `applyNestedToolOverrides` would otherwise blank.
1827
*
1928
* The escaped NUL prefix keeps it disjoint from every real selector value (ids, names, label
2029
* paths) - no selector can produce one, so it can never collide with a genuine pick.

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

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -688,9 +688,11 @@ export function useForkSync(params: {
688688
// or the source reference; promote translates a source document id to its copied counterpart
689689
// at write time). The server persists this verbatim as the stored mapping; fields whose
690690
// parent is unresolved are omitted (they can't be configured), as are fields an in-block
691-
// parent re-pick invalidated and the user never re-picked - writing those blank would destroy
692-
// a stored target value nobody chose to clear. This is the whole "what's in the mapping goes
693-
// in" contract, shared by Save and Sync so the two persist identically.
691+
// parent re-pick invalidated and the user never re-picked - submitting those blank writes an
692+
// explicit `''` override into the target draft (see `applyDependentOverrides`), which on the
693+
// paths `clearDependentsOnRemap` does not cover would clear a value nobody chose to clear.
694+
// `DEPENDENT_CLEARED_BY_PARENT` documents which paths those are. This is the whole "what's in
695+
// the mapping goes in" contract, shared by Save and Sync so the two persist identically.
694696
const buildDependentValues = () =>
695697
dependentReconfigs.flatMap((field) => {
696698
const parent = entryForDependent(field)

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

Lines changed: 71 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,25 @@
44
import { folder as folderTable } from '@sim/db/schema'
55
import {
66
dbChainMockFns,
7+
type MockCondition,
78
resetDbChainMock,
89
storageServiceMock,
910
storageServiceMockFns,
1011
} from '@sim/testing'
1112
import { beforeEach, describe, expect, it, vi } from 'vitest'
1213

14+
/** The `workspace_files` columns {@link fileRows} enforces its unique indexes on. */
15+
interface WorkspaceFileRow {
16+
id: string
17+
key: string
18+
workspaceId: string | null
19+
folderId?: string | null
20+
context: string
21+
originalName: string
22+
deletedAt: Date | null
23+
[column: string]: unknown
24+
}
25+
1326
/**
1427
* `fileRows` is the shared stand-in for the `workspace_files` table: the mocked name
1528
* allocator reads it exactly as the real one queries the DB, and the insert simulation in
@@ -23,17 +36,7 @@ const {
2336
mockIncrementStorageUsageInTx,
2437
mockResolveStorageBillingContext,
2538
} = vi.hoisted(() => {
26-
interface FileRow {
27-
id: string
28-
key: string
29-
workspaceId: string | null
30-
folderId?: string | null
31-
context: string
32-
originalName: string
33-
deletedAt: Date | null
34-
[column: string]: unknown
35-
}
36-
const fileRows: FileRow[] = []
39+
const fileRows: WorkspaceFileRow[] = []
3740
const withCopySuffix = (name: string, n: number) => {
3841
const lastDot = name.lastIndexOf('.')
3942
return lastDot > 0 && lastDot < name.length - 1
@@ -247,39 +250,76 @@ describe('executeForkFileBlobCopies storage accounting', () => {
247250
})
248251
})
249252

253+
/** Rejects a predicate the harness does not model, rather than letting it match everything. */
254+
function unsupportedPredicate(detail: string): never {
255+
throw new Error(`Unsupported predicate in test harness: ${detail}`)
256+
}
257+
258+
/** The nested clauses of an `and`/`or` node, or a throw when the node carries none. */
259+
function predicateClauses(node: MockCondition): unknown[] {
260+
if (!Array.isArray(node.conditions))
261+
unsupportedPredicate(`${String(node.type)} without conditions`)
262+
return node.conditions
263+
}
264+
265+
/**
266+
* The row key a predicate node references. The mocked schema tables are column-name maps, so a
267+
* column reference is the column name itself; anything else is a shape this harness cannot read.
268+
*/
269+
function predicateColumn(node: MockCondition, field: 'left' | 'column'): string {
270+
const column = node[field]
271+
if (typeof column !== 'string')
272+
unsupportedPredicate(`${String(node.type)} with a non-column ${field}`)
273+
return column
274+
}
275+
250276
/**
251277
* Evaluate a mocked drizzle predicate against a row. Real predicate reading, so a chain that
252278
* ignores its `where` clause cannot pass these tests by echoing a fixture back.
253279
*/
254-
function matchesPredicate(row: Record<string, unknown>, predicate: any): boolean {
280+
function matchesPredicate(row: Record<string, unknown>, predicate: unknown): boolean {
255281
if (!predicate) return true
256-
switch (predicate.type) {
282+
if (typeof predicate !== 'object') unsupportedPredicate(typeof predicate)
283+
const node = predicate as MockCondition
284+
switch (node.type) {
257285
case 'and':
258-
return predicate.conditions.every((clause: unknown) => matchesPredicate(row, clause))
286+
return predicateClauses(node).every((clause) => matchesPredicate(row, clause))
259287
case 'or':
260-
return predicate.conditions.some((clause: unknown) => matchesPredicate(row, clause))
288+
return predicateClauses(node).some((clause) => matchesPredicate(row, clause))
261289
case 'eq':
262-
return row[predicate.left] === predicate.right
263-
case 'isNull':
264-
return row[predicate.column] === null || row[predicate.column] === undefined
265-
case 'inArray':
266-
return predicate.values.includes(row[predicate.column])
290+
return row[predicateColumn(node, 'left')] === node.right
291+
case 'isNull': {
292+
const value = row[predicateColumn(node, 'column')]
293+
return value === null || value === undefined
294+
}
295+
case 'inArray': {
296+
if (!Array.isArray(node.values)) unsupportedPredicate('inArray without values')
297+
return node.values.includes(row[predicateColumn(node, 'column')])
298+
}
267299
default:
268-
throw new Error(`Unsupported predicate in test harness: ${predicate?.type}`)
300+
return unsupportedPredicate(String(node.type))
269301
}
270302
}
271303

272304
/** Awaitable stand-in for a drizzle select result, supporting `.limit`/`.for`/`.orderBy`. */
273-
function selectResult(rows: Record<string, unknown>[]): any {
274-
const builder: any = {
275-
then: (onFulfilled?: any, onRejected?: any) =>
276-
Promise.resolve(rows).then(onFulfilled, onRejected),
277-
catch: (onRejected?: any) => Promise.resolve(rows).catch(onRejected),
278-
finally: (onFinally?: any) => Promise.resolve(rows).finally(onFinally),
305+
interface MockSelectResult extends PromiseLike<WorkspaceFileRow[]> {
306+
catch: Promise<WorkspaceFileRow[]>['catch']
307+
finally: Promise<WorkspaceFileRow[]>['finally']
308+
limit: (count: number) => MockSelectResult
309+
for: () => MockSelectResult
310+
orderBy: () => MockSelectResult
311+
}
312+
313+
function selectResult(rows: WorkspaceFileRow[]): MockSelectResult {
314+
const settled = Promise.resolve(rows)
315+
const builder: MockSelectResult = {
316+
then: (onFulfilled, onRejected) => settled.then(onFulfilled, onRejected),
317+
catch: (onRejected) => settled.catch(onRejected),
318+
finally: (onFinally) => settled.finally(onFinally),
319+
limit: (count: number) => selectResult(rows.slice(0, count)),
320+
for: () => builder,
321+
orderBy: () => builder,
279322
}
280-
builder.limit = (count: number) => selectResult(rows.slice(0, count))
281-
builder.for = () => builder
282-
builder.orderBy = () => builder
283323
return builder
284324
}
285325

@@ -294,7 +334,7 @@ function installFileTableSimulation(): void {
294334
dbChainMockFns.where.mockImplementation((predicate: unknown) =>
295335
selectResult(fileRows.filter((row) => matchesPredicate(row, predicate)))
296336
)
297-
dbChainMockFns.values.mockImplementation((row: any) => {
337+
dbChainMockFns.values.mockImplementation((row: WorkspaceFileRow) => {
298338
const attemptInsert = (conflictTarget: unknown) => {
299339
const pkConflict = fileRows.some((existing) => existing.id === row.id)
300340
const activeConflict = fileRows.some(

apps/sim/lib/workflows/migrations/subblock-migrations.test.ts

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -529,6 +529,138 @@ describe('migrateSubblockIds', () => {
529529
})
530530
})
531531

532+
describe('servicenow block', () => {
533+
it('moves a legacy Read Records projection onto readFields', () => {
534+
const input: Record<string, BlockState> = {
535+
b1: makeBlock({
536+
type: 'servicenow',
537+
subBlocks: {
538+
operation: { id: 'operation', type: 'dropdown', value: 'servicenow_read_record' },
539+
fields: {
540+
id: 'fields',
541+
type: 'short-input',
542+
value: 'number,short_description,priority',
543+
},
544+
},
545+
}),
546+
}
547+
548+
const { blocks, migrated } = migrateSubblockIds(input)
549+
550+
expect(migrated).toBe(true)
551+
expect(blocks.b1.subBlocks.readFields.value).toBe('number,short_description,priority')
552+
expect(blocks.b1.subBlocks.fields).toBeUndefined()
553+
})
554+
555+
/**
556+
* The shipped block shared `fields` between the Create/Update Record JSON
557+
* body and the Read Records projection, and a subblock value survives an
558+
* operation switch. So `operation: servicenow_read_record` holding a JSON
559+
* body under `fields` is a reachable saved state, and promoting that body
560+
* onto `readFields` would send it as `sysparm_fields`.
561+
*/
562+
it('leaves a Create Record JSON body under fields when the operation was switched to Read Records', () => {
563+
const body = '{\n "short_description": "Issue description",\n "priority": "1"\n}'
564+
const input: Record<string, BlockState> = {
565+
b1: makeBlock({
566+
type: 'servicenow',
567+
subBlocks: {
568+
operation: { id: 'operation', type: 'dropdown', value: 'servicenow_read_record' },
569+
fields: { id: 'fields', type: 'code', value: body },
570+
},
571+
}),
572+
}
573+
574+
const { blocks, migrated } = migrateSubblockIds(input)
575+
576+
expect(migrated).toBe(false)
577+
expect(blocks.b1.subBlocks.readFields).toBeUndefined()
578+
expect(blocks.b1.subBlocks.fields.value).toBe(body)
579+
})
580+
581+
it('leaves a JSON array value under fields as well', () => {
582+
const input: Record<string, BlockState> = {
583+
b1: makeBlock({
584+
type: 'servicenow',
585+
subBlocks: {
586+
operation: { id: 'operation', type: 'dropdown', value: 'servicenow_read_record' },
587+
fields: { id: 'fields', type: 'code', value: '["short_description"]' },
588+
},
589+
}),
590+
}
591+
592+
const { blocks, migrated } = migrateSubblockIds(input)
593+
594+
expect(migrated).toBe(false)
595+
expect(blocks.b1.subBlocks.readFields).toBeUndefined()
596+
expect(blocks.b1.subBlocks.fields.value).toBe('["short_description"]')
597+
})
598+
599+
it('leaves the JSON body alone on create', () => {
600+
const input: Record<string, BlockState> = {
601+
b1: makeBlock({
602+
type: 'servicenow',
603+
subBlocks: {
604+
operation: { id: 'operation', type: 'dropdown', value: 'servicenow_create_record' },
605+
fields: { id: 'fields', type: 'code', value: '{"short_description":"x"}' },
606+
},
607+
}),
608+
}
609+
610+
const { blocks, migrated } = migrateSubblockIds(input)
611+
612+
expect(migrated).toBe(false)
613+
expect(blocks.b1.subBlocks.readFields).toBeUndefined()
614+
expect(blocks.b1.subBlocks.fields.value).toBe('{"short_description":"x"}')
615+
})
616+
})
617+
618+
/**
619+
* `okta_remove_user_from_app` reached the block in #6741 (`d45dad7e8b`),
620+
* whose only release tag is v0.8.3 — the same release that split `sendEmail`
621+
* into `sendDeactivationEmail`. In v0.8.2 the operation does not exist and
622+
* `sendEmail` covers only activate/deactivate/reset/delete, so no saved state
623+
* can hold a remove-from-app preference under `sendEmail`, and widening the
624+
* scope would only let an activation-era value be promoted.
625+
*/
626+
describe('okta block', () => {
627+
it('renames the deactivation half of the shared send-email switch', () => {
628+
const input: Record<string, BlockState> = {
629+
b1: makeBlock({
630+
type: 'okta',
631+
subBlocks: {
632+
operation: { id: 'operation', type: 'dropdown', value: 'okta_deactivate_user' },
633+
sendEmail: { id: 'sendEmail', type: 'switch', value: 'true' },
634+
},
635+
}),
636+
}
637+
638+
const { blocks, migrated } = migrateSubblockIds(input)
639+
640+
expect(migrated).toBe(true)
641+
expect(blocks.b1.subBlocks.sendDeactivationEmail.value).toBe('true')
642+
expect(blocks.b1.subBlocks.sendEmail).toBeUndefined()
643+
})
644+
645+
it('leaves the activation half on sendEmail', () => {
646+
const input: Record<string, BlockState> = {
647+
b1: makeBlock({
648+
type: 'okta',
649+
subBlocks: {
650+
operation: { id: 'operation', type: 'dropdown', value: 'okta_activate_user' },
651+
sendEmail: { id: 'sendEmail', type: 'switch', value: 'false' },
652+
},
653+
}),
654+
}
655+
656+
const { blocks, migrated } = migrateSubblockIds(input)
657+
658+
expect(migrated).toBe(false)
659+
expect(blocks.b1.subBlocks.sendEmail.value).toBe('false')
660+
expect(blocks.b1.subBlocks.sendDeactivationEmail).toBeUndefined()
661+
})
662+
})
663+
532664
it('should handle blocks with empty subBlocks', () => {
533665
const input: Record<string, BlockState> = {
534666
b1: makeBlock({ type: 'knowledge', subBlocks: {} }),

0 commit comments

Comments
 (0)