Skip to content

Commit 1d50208

Browse files
refactor(table): store saved views in the v2 predicate grammar
Views shipped (#5961) storing the legacy `$`-object filter and `{col: dir}` sort record — a brand-new persistent store of the grammar this branch is retiring, created days before the wire moved to predicates. The feature is still dark (`table-views` is UI-only and off), so the stored shape can change now without a data migration; once the flag flips, it cannot. `TableViewConfig` now carries `TablePredicate` + `SortSpec`. The wire contract uses `predicateSchema`/`sortSpecSchema`, which also brings the strict-object node shapes and depth/size bounds to the view routes — previously a view's filter was accepted as an arbitrary domain object. The grid still runs on the legacy pair internally; translation happens at the view boundary. Apply: `predicateToFilter` (total here — stored predicates are builder-authored). Save: `filterToRules ∘ filterRulesToPredicate`, the builder round-trip. SortSpec keeps priority order the record never could. Dev-era rows written before the switch are normalized on read: legacy filters convert through the builder round-trip and are dropped if the result's leaf fields fail the column-name pattern — the rule converters accept garbage (`{$bogus: …}` becomes a rule on a column literally named `$bogus`), so the conversion is validated rather than trusted. Also folds `sortQuery`'s single-entry record out of the save path in favour of the sort params directly, so a saved view records the same thing the URL says. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011M563cbFy2S74GvSDf2C3R
1 parent e268562 commit 1d50208

5 files changed

Lines changed: 121 additions & 22 deletions

File tree

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ import type {
2222
} from '@/lib/table'
2323
import { getColumnId } from '@/lib/table/column-keys'
2424
import { TABLE_LIMITS } from '@/lib/table/constants'
25+
import {
26+
filterRulesToPredicate,
27+
filterToRules,
28+
predicateToFilter,
29+
} from '@/lib/table/query-builder/converters'
2530
import {
2631
type BreadcrumbItem,
2732
type ColumnOption,
@@ -423,13 +428,17 @@ export function Table({
423428
config: TableViewConfig | null,
424429
keep?: { sort?: boolean; filter?: boolean; hiddenColumns?: boolean }
425430
) => {
426-
if (!keep?.filter) setFilter(config?.filter ?? null)
431+
// Stored views speak the v2 grammar; the grid's runtime state is still the
432+
// legacy Filter/Sort pair, so translate at this boundary. A stored predicate
433+
// is always builder-authored (the save path converts from builder output),
434+
// so the legacy projection is total here.
435+
if (!keep?.filter) setFilter(config?.filter ? predicateToFilter(config.filter) : null)
427436
if (!keep?.hiddenColumns) setHiddenColumns(config?.hiddenColumns ?? [])
428437
if (keep?.sort) return
429-
const sortEntry = config?.sort ? Object.entries(config.sort)[0] : undefined
438+
const sortEntry = config?.sort?.[0]
430439
setTableParams({
431-
sort: sortEntry ? sortEntry[0] : null,
432-
dir: sortEntry ? (sortEntry[1] as SortDirection) : null,
440+
sort: sortEntry ? sortEntry.field : null,
441+
dir: sortEntry ? (sortEntry.direction as SortDirection) : null,
433442
})
434443
},
435444
[setTableParams]
@@ -651,11 +660,20 @@ export function Table({
651660
const currentViewConfig = useMemo<TableViewConfig>(
652661
() => ({
653662
...(activeView?.config ?? tableData?.metadata),
654-
filter: effectiveFilter,
655-
sort: sortQuery,
663+
// Views store the v2 grammar; the grid runs on the legacy pair. The filter
664+
// is builder-authored, so the rule round-trip is lossless here.
665+
filter: effectiveFilter ? filterRulesToPredicate(filterToRules(effectiveFilter)) : null,
666+
sort: sortColumn ? [{ field: sortColumn, direction: sortDirection }] : null,
656667
hiddenColumns: effectiveHiddenColumns,
657668
}),
658-
[activeView, tableData?.metadata, effectiveFilter, sortQuery, effectiveHiddenColumns]
669+
[
670+
activeView,
671+
tableData?.metadata,
672+
effectiveFilter,
673+
sortColumn,
674+
sortDirection,
675+
effectiveHiddenColumns,
676+
]
659677
)
660678

661679
/**

apps/sim/lib/api/contracts/tables.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1717,8 +1717,11 @@ export const tableEventStreamContract = defineRouteContract({
17171717
* never invalidates a view.
17181718
*/
17191719
export const tableViewConfigSchema = tableMetadataSchema.extend({
1720-
filter: filterSchema.nullable().optional(),
1721-
sort: domainObjectSchema<Sort>().nullable().optional(),
1720+
// The v2 predicate/sort grammar — same wire as the query routes, so a saved
1721+
// view gets the same strictness and depth bounds as a live filter, and its
1722+
// config can later feed the v2 surfaces without conversion.
1723+
filter: predicateSchema.nullable().optional(),
1724+
sort: sortSpecSchema.nullable().optional(),
17221725
}) satisfies z.ZodType<TableViewConfig>
17231726

17241727
export const tableViewSchema = z.object({

apps/sim/lib/table/types.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -286,8 +286,8 @@ export interface TableMetadata {
286286
* user resizes, reorders, pins, or hides columns.
287287
*/
288288
export interface TableViewConfig extends TableMetadata {
289-
filter?: Filter | null
290-
sort?: Sort | null
289+
filter?: TablePredicate | null
290+
sort?: SortSpec | null
291291
}
292292

293293
/** Async background-job lifecycle state for a table. NULL/undefined = idle (no job). */

apps/sim/lib/table/views/service.test.ts

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
*/
44
import { describe, expect, it } from 'vitest'
55
import type { ColumnDefinition, TableViewConfig } from '@/lib/table/types'
6-
import { pruneViewConfig } from '@/lib/table/views/service'
6+
import { normalizeStoredViewConfig, pruneViewConfig } from '@/lib/table/views/service'
77

88
const columns: ColumnDefinition[] = [
99
{ id: 'col_a', name: 'Name', type: 'text' },
@@ -28,14 +28,18 @@ describe('pruneViewConfig', () => {
2828
})
2929

3030
it('drops a sort on a deleted column and collapses to null when none remain', () => {
31-
expect(pruneViewConfig({ sort: { col_gone: 'asc' } }, columns).sort).toBeNull()
32-
expect(pruneViewConfig({ sort: { col_a: 'desc' } }, columns).sort).toEqual({ col_a: 'desc' })
31+
expect(
32+
pruneViewConfig({ sort: [{ field: 'col_gone', direction: 'asc' }] }, columns).sort
33+
).toBeNull()
34+
expect(
35+
pruneViewConfig({ sort: [{ field: 'col_a', direction: 'desc' }] }, columns).sort
36+
).toEqual([{ field: 'col_a', direction: 'desc' }])
3337
})
3438

3539
it('leaves the filter untouched even when it references a deleted column', () => {
3640
// Pruning a predicate would silently widen the view's row set — surfacing a
3741
// stale condition the user can see and remove is the safer failure.
38-
const filter = { col_gone: { $eq: 'x' } }
42+
const filter = { all: [{ field: 'col_gone', op: 'eq' as const, value: 'x' }] }
3943
expect(pruneViewConfig({ filter }, columns).filter).toEqual(filter)
4044
})
4145

@@ -50,3 +54,33 @@ describe('pruneViewConfig', () => {
5054
])
5155
})
5256
})
57+
58+
/**
59+
* Reads written before the grammar switch: the feature never released, so
60+
* legacy-shaped configs exist only from pre-refactor testing — but they must
61+
* come back as v2, not render broken.
62+
*/
63+
describe('normalizeStoredViewConfig', () => {
64+
it('converts a legacy $-object filter to a predicate tree', () => {
65+
const out = normalizeStoredViewConfig({ filter: { col_a: { $eq: 'x' } } })
66+
expect(out.filter).toEqual({ all: [{ field: 'col_a', op: 'eq', value: 'x' }] })
67+
})
68+
69+
it('converts a legacy {col: dir} sort record to an ordered spec', () => {
70+
const out = normalizeStoredViewConfig({ sort: { col_a: 'desc' } })
71+
expect(out.sort).toEqual([{ field: 'col_a', direction: 'desc' }])
72+
})
73+
74+
it('passes v2-shaped configs through untouched', () => {
75+
const config = {
76+
filter: { all: [{ field: 'col_a', op: 'eq', value: 'x' }] },
77+
sort: [{ field: 'col_a', direction: 'asc' }],
78+
}
79+
expect(normalizeStoredViewConfig(config)).toEqual(config)
80+
})
81+
82+
it('drops an unconvertible legacy filter rather than surfacing it broken', () => {
83+
const out = normalizeStoredViewConfig({ filter: { $bogus: [{ nested: true }] } })
84+
expect(out.filter).toBeNull()
85+
})
86+
})

apps/sim/lib/table/views/service.ts

Lines changed: 51 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,15 @@ import { createLogger } from '@sim/logger'
1515
import { generateId } from '@sim/utils/id'
1616
import { and, asc, eq, ne, sql } from 'drizzle-orm'
1717
import { getColumnId } from '@/lib/table/column-keys'
18-
import type { ColumnDefinition, TableViewConfig } from '@/lib/table/types'
18+
import { NAME_PATTERN } from '@/lib/table/constants'
19+
import { filterRulesToPredicate, filterToRules } from '@/lib/table/query-builder/converters'
20+
import type {
21+
ColumnDefinition,
22+
Filter,
23+
Predicate,
24+
PredicateNode,
25+
TableViewConfig,
26+
} from '@/lib/table/types'
1927

2028
const logger = createLogger('TableViewsService')
2129

@@ -69,22 +77,58 @@ export function pruneViewConfig(
6977
pruned.columnWidths = widths
7078
}
7179
if (config.sort) {
72-
const sort: Record<string, 'asc' | 'desc'> = {}
73-
for (const [id, direction] of Object.entries(config.sort)) {
74-
if (live.has(id)) sort[id] = direction
75-
}
76-
pruned.sort = Object.keys(sort).length > 0 ? sort : null
80+
const sort = config.sort.filter((s) => live.has(s.field))
81+
pruned.sort = sort.length > 0 ? sort : null
7782
}
7883

7984
return pruned
8085
}
8186

87+
/**
88+
* Migrates a config stored before the grammar switch. The feature never
89+
* released, so legacy-shaped rows exist only from pre-refactor testing: a
90+
* `$`-object filter converts through the builder-rule round-trip (its exact
91+
* authoring domain), and a `{col: dir}` sort record becomes an ordered spec.
92+
* Anything unconvertible is dropped rather than surfaced broken.
93+
*/
94+
95+
/** Every leaf field in the tree is a plausible column id. */
96+
function predicateFieldsAreValid(node: PredicateNode): boolean {
97+
if ('all' in node) return node.all.every(predicateFieldsAreValid)
98+
if ('any' in node) return node.any.every(predicateFieldsAreValid)
99+
return NAME_PATTERN.test((node as Predicate).field)
100+
}
101+
102+
export function normalizeStoredViewConfig(raw: Record<string, unknown>): TableViewConfig {
103+
const config = { ...raw } as TableViewConfig
104+
const filter = raw.filter as Record<string, unknown> | null | undefined
105+
if (filter && !('all' in filter) && !('any' in filter)) {
106+
try {
107+
const converted = filterRulesToPredicate(filterToRules(filter as Filter))
108+
// The rule converters don't reject garbage — an unknown `$op` becomes a
109+
// rule on a column literally named `$op`. A converted leaf whose field
110+
// fails the column-name pattern proves the input wasn't builder-authored.
111+
config.filter = converted && predicateFieldsAreValid(converted) ? converted : null
112+
} catch {
113+
config.filter = null
114+
}
115+
}
116+
const sort = raw.sort as Record<string, 'asc' | 'desc'> | unknown[] | null | undefined
117+
if (sort && !Array.isArray(sort)) {
118+
config.sort = Object.entries(sort).map(([field, direction]) => ({ field, direction }))
119+
}
120+
return config
121+
}
122+
82123
function toTableView(row: typeof tableViews.$inferSelect, columns: ColumnDefinition[]): TableView {
83124
return {
84125
id: row.id,
85126
tableId: row.tableId,
86127
name: row.name,
87-
config: pruneViewConfig((row.config ?? {}) as TableViewConfig, columns),
128+
config: pruneViewConfig(
129+
normalizeStoredViewConfig((row.config ?? {}) as Record<string, unknown>),
130+
columns
131+
),
88132
isDefault: row.isDefault,
89133
createdBy: row.createdBy,
90134
createdAt: row.createdAt,

0 commit comments

Comments
 (0)