Skip to content

Commit 63e3e15

Browse files
committed
fix(files): read the root's folder stats when parentId is omitted
Zod 4's `.default()` returns its value without running the schema's transform, so an omitted `parentId` reached the use case as the literal `root`, an unknown folder id, and the endpoint answered no folders. `.prefault('root')` parses the default through the transform to `null`. The legacy-cursor integration test now ends its first page inside the millisecond tie, and spells the cursor in SQL: the driver parses a raw `timestamp` as local time, which had shifted the cursor past the tie.
1 parent 70087bf commit 63e3e15

3 files changed

Lines changed: 54 additions & 9 deletions

File tree

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { authMockFns } from '@sim/testing'
2+
import { createRouteContext } from '@sim/testing/helpers/http'
3+
import { createMockRequest } from '@sim/testing/mocks/request.mock'
4+
import {
5+
workspaceFilesListMock,
6+
workspaceFilesListMockFns,
7+
} from '@sim/testing/mocks/workspace-files-list.mock'
8+
import { beforeEach, describe, expect, it, vi } from 'vitest'
9+
10+
vi.mock('@/lib/workspace-files/application/list-workspace-files', () => workspaceFilesListMock)
11+
12+
import { GET } from '@/app/api/workspaces/[id]/files/folder-stats/route'
13+
14+
const WORKSPACE_ID = 'workspace-1'
15+
const stats = workspaceFilesListMockFns.mockGetWorkspaceFileFolderStats
16+
17+
describe('GET /api/workspaces/[id]/files/folder-stats', () => {
18+
beforeEach(() => {
19+
authMockFns.mockGetSession.mockResolvedValue({
20+
user: { id: 'user-1', name: 'Test User', email: 'test@sim.ai' },
21+
session: { id: 'session-1' },
22+
})
23+
stats.mockResolvedValue({ folders: [] })
24+
})
25+
26+
it.each([
27+
['omitted', ''],
28+
['root', '?parentId=root'],
29+
])('reads the root children when parentId is %s', async (_label, query) => {
30+
const response = await GET(
31+
createMockRequest({
32+
method: 'GET',
33+
url: `/api/workspaces/${WORKSPACE_ID}/files/folder-stats${query}`,
34+
}),
35+
createRouteContext({ id: WORKSPACE_ID })
36+
)
37+
38+
expect(response.status).toBe(200)
39+
expect(stats.mock.calls[0][0].input).toEqual({ workspaceId: WORKSPACE_ID, parentId: null })
40+
})
41+
})

‎apps/sim/lib/api/contracts/workspace-files.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -308,7 +308,7 @@ export const lookupWorkspaceFilesContract = defineRouteContract({
308308

309309
const workspaceFileFolderStatsQuerySchema = z.object({
310310
/** The folder whose child folders to report: `root` (default) or a folder id. */
311-
parentId: workspaceFileFolderFilterSchema.default('root'),
311+
parentId: workspaceFileFolderFilterSchema.prefault('root'),
312312
})
313313

314314
const workspaceFileFolderStatsResponseSchema = workspaceFileSuccessSchema.extend({

‎apps/sim/lib/workspace-files/__integration__/file-listing.integration.ts‎

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -277,17 +277,21 @@ describe('bounded workspace file reads in PostgreSQL', () => {
277277
)
278278

279279
it('finishes a walk begun under millisecond-truncated ordering without skipping or repeating', async () => {
280-
const legacy = await db.execute<{ id: string; uploaded_at: Date }>(sql`
281-
SELECT id, uploaded_at FROM ${workspaceFiles}
280+
// The millisecond cursor a legacy page minted, spelled in SQL: a raw `timestamp` read back
281+
// through the driver is parsed as local time.
282+
const legacy = await db.execute<{ id: string; cursor: string }>(sql`
283+
SELECT id, to_char(uploaded_at, 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') AS cursor
284+
FROM ${workspaceFiles}
282285
WHERE workspace_id = ${workspaceId} AND context = 'workspace' AND deleted_at IS NULL
283286
ORDER BY date_trunc('milliseconds', uploaded_at), id`)
284287
const legacyOrder = legacy.map((row) => row.id)
285-
const firstPage = legacy.slice(0, 2)
286-
const last = firstPage[1]
287-
const resumed = await walk('uploadedAt', 'asc', [
288-
new Date(last.uploaded_at).toISOString(),
289-
last.id,
290-
])
288+
// End the legacy page on wf_a, first of the four rows sharing one millisecond, so the walk
289+
// resumes inside the tie, where raw and truncated ordering disagree.
290+
const cut = legacy.findIndex((row) => row.id.startsWith('wf_a_')) + 1
291+
const firstPage = legacy.slice(0, cut)
292+
const last = firstPage[firstPage.length - 1]
293+
expect(legacy[cut].cursor).toBe(last.cursor)
294+
const resumed = await walk('uploadedAt', 'asc', [last.cursor, last.id])
291295
expect([...firstPage.map((row) => row.id), ...resumed]).toEqual(legacyOrder)
292296
})
293297
})

0 commit comments

Comments
 (0)