Skip to content

Commit ea5cf64

Browse files
committed
fix(v2): parse a bound list filter once, so the scope matches the query
The logs list fingerprinted `workflowIds`, `triggers`, and `folderPaths` through unorderedScopePart, which trims each member, then split the same raw values itself with `.split(',').filter(Boolean)`, which does not. So `?workflowIds=A,B` and `?workflowIds=A, B` produced one fingerprint and two different result sets: the second selects on a member with a leading space that matches no row. A cursor minted under one was accepted under the other, which is the exact failure the filter binding exists to refuse. Extracts parseUnorderedList as the single parse. unorderedScopePart now derives from it, and the route passes the array to the query and the joined form to the scope, so the members fingerprinted are by construction the members filtered on. Also drops three inline splits. Reported by Greptile.
1 parent b586897 commit ea5cf64

3 files changed

Lines changed: 36 additions & 8 deletions

File tree

apps/sim/app/api/v2/logs/route.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
} from '@/lib/api/contracts/v2/logs'
77
import {
88
cursorScopeKey,
9+
parseUnorderedList,
910
UNREADABLE_CURSOR_MESSAGE,
1011
unorderedScopePart,
1112
} from '@/lib/api/cursor-binding'
@@ -76,8 +77,8 @@ export const GET = defineV2JsonRoute({
7677
return {
7778
workspaceId: query.workspaceId,
7879
filters: {
79-
workflowIds: query.workflowIds?.split(',').filter(Boolean),
80-
triggers: query.triggers?.split(',').filter(Boolean),
80+
workflowIds: parseUnorderedList(query.workflowIds),
81+
triggers: parseUnorderedList(query.triggers),
8182
level: query.level,
8283
startDate: query.startDate ? new Date(query.startDate) : undefined,
8384
endDate: query.endDate ? new Date(query.endDate) : undefined,
@@ -90,7 +91,7 @@ export const GET = defineV2JsonRoute({
9091
cursor: decodedCursor ?? undefined,
9192
order: query.order,
9293
},
93-
folderPaths: query.folderPaths?.split(',').filter(Boolean),
94+
folderPaths: parseUnorderedList(query.folderPaths),
9495
limit: query.limit,
9596
includeFullDetails:
9697
query.details === 'full' || query.includeFinalOutput || query.includeTraceSpans,

apps/sim/lib/api/cursor-binding.test.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
* @vitest-environment node
33
*/
44
import { describe, expect, it } from 'vitest'
5-
import { cursorScopeKey, unorderedScopePart } from '@/lib/api/cursor-binding'
5+
import { cursorScopeKey, parseUnorderedList, unorderedScopePart } from '@/lib/api/cursor-binding'
66
import {
77
cursorSortKey,
88
decodeOffsetCursor,
@@ -207,6 +207,18 @@ describe('unordered filter scope parts', () => {
207207
expect(unorderedScopePart('B,A,B')).toBe('A,B')
208208
})
209209

210+
/**
211+
* The scope and the query must read one parse. When the scope trimmed members
212+
* and the route split the raw value itself, `A,B` and `A, B` shared a
213+
* fingerprint while selecting different rows — a cursor accepted across a
214+
* change that moved the sequence, which is the failure the binding prevents.
215+
*/
216+
it('parses the members it fingerprints', () => {
217+
expect(parseUnorderedList('A, B')).toEqual(['A', 'B'])
218+
expect(parseUnorderedList('A,B')).toEqual(parseUnorderedList('A, B'))
219+
expect(unorderedScopePart('A, B')).toBe(parseUnorderedList('A, B')?.join(','))
220+
})
221+
210222
it('still separates genuinely different sets', () => {
211223
expect(cursorScopeKey({ workflowIds: unorderedScopePart('A,B') })).not.toBe(
212224
cursorScopeKey({ workflowIds: unorderedScopePart('A,C') })

apps/sim/lib/api/cursor-binding.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -63,20 +63,35 @@ export type CursorScopePart =
6363
* {@link canonicalJson} already sorts object keys, so this only has to normalize
6464
* the list. Members are de-duplicated as well as sorted: the filters compile to
6565
* `inArray`, which is set membership, so `A,A,B` selects exactly what `A,B` does
66-
* and must not bind to a different page. Empty members are dropped because the
67-
* parsers drop them too.
66+
* and must not bind to a different page.
67+
*
68+
* Derived from {@link parseUnorderedList} rather than parsing again, so the
69+
* members this fingerprints are exactly the members the query filters on. A
70+
* route that canonicalized here and split the raw value itself would give
71+
* `A,B` and `A, B` one fingerprint and two different result sets.
6872
*/
6973
export function unorderedScopePart(raw: string | undefined): string | undefined {
74+
const members = parseUnorderedList(raw)
75+
return members && members.length > 0 ? members.join(',') : undefined
76+
}
77+
78+
/**
79+
* The members of a comma-separated filter, trimmed, de-duplicated, and sorted.
80+
*
81+
* The one parse for both halves of a bound list filter: pass the array to the
82+
* query and {@link unorderedScopePart} to the cursor scope. Callers must not
83+
* re-split the raw value for one half — that is what lets the two drift.
84+
*/
85+
export function parseUnorderedList(raw: string | undefined): string[] | undefined {
7086
if (raw === undefined) return undefined
71-
const members = [
87+
return [
7288
...new Set(
7389
raw
7490
.split(',')
7591
.map((member) => member.trim())
7692
.filter((member) => member.length > 0)
7793
),
7894
].sort()
79-
return members.length > 0 ? members.join(',') : undefined
8095
}
8196

8297
/**

0 commit comments

Comments
 (0)