Skip to content

Commit 82f9066

Browse files
committed
refactor(files): share the whitespace class and split the display-name tests
1 parent 01af6af commit 82f9066

5 files changed

Lines changed: 37 additions & 36 deletions

File tree

‎apps/sim/lib/uploads/contexts/workspace/__integration__/file-names.integration.ts‎

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ vi.mock('@/lib/uploads/core/setup.server', () => ({
2828

2929
import { fileParseBodySchema } from '@/lib/api/contracts/storage-transfer'
3030
import * as inputValidation from '@/lib/core/security/input-validation.server'
31+
import { textArrayLiteral } from '@/lib/db/arrays'
3132
import { executeFileParserOperation } from '@/lib/internal/file/parser'
3233
import {
3334
createKnowledgeAclFixtureIds,
@@ -204,29 +205,34 @@ describe('workspace file names in PostgreSQL', () => {
204205
}
205206
})
206207

208+
// Names stored before upload normalization: U+202F before AM/PM, doubled and edge whitespace,
209+
// a decomposed accent, a control character, and a BOM.
210+
const unnormalizedNames = [
211+
'Screenshot 2026-01-15 at 9.41.07\u202fAM.png',
212+
'Quarterly Report.pdf',
213+
' padded.txt\t',
214+
'Cafe\u0301 menu.png',
215+
'ring\u0007ing.png',
216+
'\ufeffbom.md',
217+
]
218+
219+
it('normalizes a display name in SQL exactly as normalizeDisplaySegment does', async () => {
220+
const rows = await db.execute<{ name: string; key: string }>(
221+
sql`SELECT name, ${displaySegmentKey(sql`name`)} AS key FROM unnest(${textArrayLiteral(unnormalizedNames)}) AS name`
222+
)
223+
expect(rows).toHaveLength(unnormalizedNames.length)
224+
for (const row of rows) expect(row.key).toBe(normalizeDisplaySegment(row.name))
225+
})
226+
207227
it('resolves a stored name that differs from its VFS path through the display-name index', async () => {
208228
const fixture = await seedWorkspace()
209229
const folder = await createWorkspaceFileFolder({
210230
workspaceId: fixture.workspaceId,
211231
userId: fixture.aliceId,
212232
name: 'Reports',
213233
})
214-
// Names stored before upload normalization: U+202F before AM/PM, doubled and edge
215-
// whitespace, a decomposed accent, a control character, and a BOM.
216-
const storedNames = [
217-
'Screenshot 2026-01-15 at 9.41.07 AM.png',
218-
'Quarterly Report.pdf',
219-
' padded.txt\t',
220-
'Café menu.png',
221-
'ring\u0007ing.png',
222-
'bom.md',
223-
]
224-
for (const name of storedNames) {
225-
const [row] = await db.execute(sql`SELECT ${displaySegmentKey(sql`${name}::text`)} AS key`)
226-
expect(row.key).toBe(normalizeDisplaySegment(name))
227-
}
228234

229-
const files = storedNames.map((name, n) => ({
235+
const files = unnormalizedNames.map((name, n) => ({
230236
name,
231237
id: `wf_legacy_${n}_${fixture.workspaceId}`,
232238
folderId: n === 0 ? folder.id : null,

‎apps/sim/lib/vfs/path.ts‎

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,7 @@
1-
import { escapeRegExp } from '@sim/utils/string'
1+
import { escapeRegExp, WHITESPACE_CHARACTER_CLASS } from '@sim/utils/string'
22

33
const CONTROL_CHARS = /[\x00-\x1f\x7f]/g
4-
/** The characters `\s` matches, spelled out so a PostgreSQL pattern can share the class. */
5-
const WHITESPACE_CLASS =
6-
'[ \\t\\n\\v\\f\\r\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000\\ufeff]'
7-
const WHITESPACE = new RegExp(`${WHITESPACE_CLASS}+`, 'g')
4+
const WHITESPACE = new RegExp(`${WHITESPACE_CHARACTER_CLASS}+`, 'g')
85

96
export class VfsPathError extends Error {
107
constructor(message: string) {
@@ -29,7 +26,7 @@ export function normalizeDisplaySegment(segment: string): string {
2926
*/
3027
export function displaySegmentPattern(name: string): string {
3128
const words = name.split(' ').map(escapeRegExp)
32-
return `^${WHITESPACE_CLASS}*${words.join(`${WHITESPACE_CLASS}+`)}${WHITESPACE_CLASS}*$`
29+
return `^${WHITESPACE_CHARACTER_CLASS}*${words.join(`${WHITESPACE_CHARACTER_CLASS}+`)}${WHITESPACE_CHARACTER_CLASS}*$`
3330
}
3431

3532
export function encodeVfsSegment(segment: string): string {

‎packages/db/migrations/0407_workspace_files_display_name_index.sql‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
1-
-- A name reference that misses the exact-name lookup read every live file in its workspace, and
2-
-- a workflow's new-file existence check always misses, so a large workspace paid a full read per
3-
-- file written. This index serves that fallback by displayed name. It is partial on live
4-
-- workspace files, a small slice of the table, so the build writes little WAL.
1+
-- Serves name references that miss the exact-name lookup (see displaySegmentKey in schema.ts).
2+
-- Partial on live workspace files, so the concurrent build is small.
53
COMMIT;--> statement-breakpoint
64
SET lock_timeout = 0;--> statement-breakpoint
75
-- migration-safe: replay replaces only this new index to recover an interrupted concurrent build; existing indexes remain available.

‎packages/db/schema.ts‎

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { WHITESPACE_CHARACTER_CLASS } from '@sim/utils/string'
12
import { type SQL, sql } from 'drizzle-orm'
23
import {
34
type AnyPgColumn,
@@ -69,10 +70,6 @@ export function foldedEmail(column: AnyPgColumn | SQL): SQL<string> {
6970
return sql<string>`lower(btrim(${column}))`
7071
}
7172

72-
/** The characters JavaScript's `trim()` and `\s` treat as whitespace, as a PostgreSQL class. */
73-
const DISPLAY_WHITESPACE =
74-
'[ \\t\\n\\v\\f\\r\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000\\ufeff]'
75-
7673
/**
7774
* A file name reduced to the VFS path segment it displays as, in SQL: NFC-composed, trimmed,
7875
* control characters removed, whitespace runs collapsed to one space. It is the exact
@@ -82,7 +79,7 @@ const DISPLAY_WHITESPACE =
8279
* are inlined with `sql.raw` because a bound parameter would not match the index expression.
8380
*/
8481
export function displaySegmentKey(column: AnyPgColumn | SQL): SQL<string> {
85-
const ws = sql.raw(DISPLAY_WHITESPACE)
82+
const ws = sql.raw(WHITESPACE_CHARACTER_CLASS)
8683
return sql<string>`regexp_replace(regexp_replace(regexp_replace(normalize(${column}, NFC), '^${ws}+|${ws}+$', '', 'g'), '[\\x01-\\x1f\\x7f]', '', 'g'), '${ws}+', ' ', 'g')`
8784
}
8885

@@ -2822,12 +2819,7 @@ export const workspaceFiles = pgTable(
28222819
.where(
28232820
sql`${table.deletedAt} IS NULL AND ${table.context} = 'workspace' AND ${table.workspaceId} IS NOT NULL`
28242821
),
2825-
/**
2826-
* Serves name references that miss the exact-name lookup (a stored name whose whitespace,
2827-
* composition, or control characters differ from its VFS path, or a bare name in a folder).
2828-
* Without it each miss reads every live file in the workspace — one per new file a workflow
2829-
* writes, since the existence check misses by definition.
2830-
*/
2822+
/** Serves name references that miss the exact-name lookup; see `displaySegmentKey`. */
28312823
workspaceDisplayNameIdx: index('workspace_files_workspace_display_name_idx')
28322824
.on(table.workspaceId, displaySegmentKey(table.originalName))
28332825
.concurrently()

‎packages/utils/src/string.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,14 @@
1414
*/
1515
const NUL_CHARACTER = '\u0000'
1616

17+
/**
18+
* The characters JavaScript's `\s` and `trim()` treat as whitespace, spelled out as a bracket
19+
* expression both JavaScript and PostgreSQL regular expressions accept, so a SQL expression can
20+
* normalize whitespace exactly as its TypeScript twin does.
21+
*/
22+
export const WHITESPACE_CHARACTER_CLASS =
23+
'[ \\t\\n\\v\\f\\r\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000\\ufeff]'
24+
1725
/** Reports whether `value` carries a `U+0000`. See {@link NUL_CHARACTER}. */
1826
export function containsNulCharacter(value: string): boolean {
1927
return value.includes(NUL_CHARACTER)

0 commit comments

Comments
 (0)