Skip to content

Commit 0df4d58

Browse files
fix(table): reject a v2 predicate at the legacy filter compiler
Cross-version safety net. If a client speaking the predicate grammar reaches a server that predates it, the predicate arrives at the legacy `$`-compiler as `{ all: [...] }`. That was skipped as "an array on a regular field", so the filter compiled to NO WHERE CLAUSE — which on a bulk delete means every row rather than none. `update-runner` has always had an `if (!filterClause) throw`; `delete-runner` does not, so the background delete path (tables over 1000 rows) was the one that could actually wipe a table. `buildFilterClause` is the single choke point every filter path shares — `queryRows`, `update-runner`, `delete-runner`, inline and background — so one guard there covers all of them, and it names the mismatch instead of failing with a generic "filter required". Scoped to the `all`/`any` discriminators specifically: an ordinary column that happens to hold an array stays a silent skip, so no working legacy filter changes behaviour. This matters for deploy ordering. The copilot and sim deploy independently, and if the copilot ships the new grammar first it starts sending predicates to a sim that cannot parse them. With this guard that is a loud 400 on every path instead of a silent table wipe on one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011M563cbFy2S74GvSDf2C3R
1 parent 705ea6f commit 0df4d58

2 files changed

Lines changed: 59 additions & 0 deletions

File tree

apps/sim/lib/table/__tests__/sql.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -774,3 +774,50 @@ describe('buildPredicateClause (v2 grammar)', () => {
774774
expect(() => buildPredicateClause(p, TABLE, [])).toThrow('Invalid field name')
775775
})
776776
})
777+
778+
/**
779+
* Cross-version safety. If a client speaking the v2 predicate grammar reaches a
780+
* server that predates it, the predicate arrives at the LEGACY `$`-compiler as
781+
* `{ all: [...] }`. That used to be skipped as "an array on a regular field",
782+
* compiling to no WHERE clause — which on a bulk delete means every row rather
783+
* than none. The guard turns that into a loud, self-describing failure, and it
784+
* sits at the one choke point every filter path shares (`queryRows`,
785+
* `update-runner`, `delete-runner`, inline and background).
786+
*/
787+
describe('legacy compiler rejects a v2 predicate (version-mismatch fail-fast)', () => {
788+
it('throws on a top-level all/any group instead of emitting no clause', () => {
789+
for (const group of ['all', 'any'] as const) {
790+
expect(() =>
791+
buildFilterClause(
792+
{ [group]: [{ field: 'tenant_id', op: 'eq', value: 'acme' }] } as unknown as Filter,
793+
TABLE,
794+
NO_COLUMNS
795+
)
796+
).toThrow(/v2 predicate tree/)
797+
}
798+
})
799+
800+
it('catches one nested inside a legacy $or', () => {
801+
expect(() =>
802+
buildFilterClause(
803+
{
804+
$or: [{ status: 'a' }, { all: [{ field: 'tenant_id', op: 'eq', value: 'acme' }] }],
805+
} as unknown as Filter,
806+
TABLE,
807+
NO_COLUMNS
808+
)
809+
).toThrow(/v2 predicate tree/)
810+
})
811+
812+
it('leaves legitimate legacy filters alone', () => {
813+
expect(buildFilterClause({ status: 'archived' }, TABLE, NO_COLUMNS)).toBeDefined()
814+
expect(
815+
buildFilterClause({ $or: [{ status: 'a' }, { status: 'b' }] }, TABLE, NO_COLUMNS)
816+
).toBeDefined()
817+
// An ordinary column holding an array stays a silent skip — only the
818+
// predicate discriminators `all`/`any` are treated as a version mismatch.
819+
expect(() =>
820+
buildFilterClause({ status: ['a', 'b'] } as unknown as Filter, TABLE, NO_COLUMNS)
821+
).not.toThrow()
822+
})
823+
})

apps/sim/lib/table/sql.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,18 @@ function buildFilterClauseInternal(
157157
}
158158

159159
// Skip arrays for regular fields - arrays are only valid for $or and $and.
160+
// A v2 predicate tree (`{ all | any: [...] }`) that reaches this legacy
161+
// compiler is a VERSION MISMATCH — a caller speaking the newer grammar
162+
// against an older server. Skipping it as "an array on a regular field"
163+
// compiles to no WHERE clause at all, which on a bulk delete means every
164+
// row rather than none. Fail fast and name the mismatch instead.
165+
if ((field === 'all' || field === 'any') && Array.isArray(condition)) {
166+
throw new TableQueryValidationError(
167+
`Filter looks like a v2 predicate tree ("${field}" group) but reached the legacy filter compiler. ` +
168+
'This usually means a client is sending the predicate grammar to a server that predates it.'
169+
)
170+
}
171+
160172
// If we encounter an array here, it's likely malformed input (e.g., { name: [filter1, filter2] })
161173
// which doesn't have a clear semantic meaning, so we skip it.
162174
if (Array.isArray(condition)) {

0 commit comments

Comments
 (0)