Skip to content

fix(spec): stop folding like/ilike onto $contains at the wire (#7536) - #7593

Merged
os-zhuang merged 5 commits into
mainfrom
claude/issue-7536-like-wire-lowering
Aug 11, 2026
Merged

fix(spec): stop folding like/ilike onto $contains at the wire (#7536)#7593
os-zhuang merged 5 commits into
mainfrom
claude/issue-7536-like-wire-lowering

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Closes #7536

The defect

The wire lowering carried 'like': '$contains' in AST_OPERATOR_MAP
(packages/spec/src/data/filter.zod.ts), so every like predicate arriving
over HTTP was rewritten into a substring search before any driver saw it.
$contains LIKE-escapes its comparand and wraps it in %…%, which breaks a
like in both directions at once. Measured in QA run #7463:

filter node before now
["name","like","%Industries"] 200, 0 rows — the % bound as a literal percent sign the rows ENDING WITH Industries
["name","like","Industries"] a substring match, byte-identical to the $contains control an EXACT match
["name","ilike","…"] 400ilike had no lowering at all, so isFilterAST() refused the whole filter the case-insensitive twin

The second row is the tell: like and $contains producing the same bytes
means like was never reaching the driver as a pattern.

The file already documented the contract being violated. canonicalAstOperator,
thirty lines below the map entry, carried a hand-written exemption for
like/ilike whose comment read: "they are NOT substring matches at the
driver … Folding them onto contains would silently wrap the value in %…% and
change what the query means."
That exemption only ever shaped its own output;
the lowering the wire path takes had none. Consequence named on the card:
driver-sql's like/ilike handling has been unreachable from the wire since
#5158 — and it turned out to be worse than unreachable, see below.

What changed

Two new declared operators, $like / $ilike. The comparand IS the
pattern: % any sequence, _ exactly one character, backslash escapes either,
matched against the WHOLE value — so a wildcard-free pattern is an exact
comparison. $like is case-SENSITIVE (#4706 Q2 = A, the contract $contains
already answers); $ilike folds ASCII only (Q1 = A). like/ilike lower to
them, ilike entering the AST vocabulary for the first time.
canonicalAstOperator's exemption is retired — the generic round-trip
answers both spellings by construction now, so the special case is gone along
with the reason it existed.

The pattern language is defined once, in the spec, and shared by every face:
hasDanglingLikeEscape, likePatternToRegexSource, matchesLikePattern,
likePatternToGlobPattern. Six faces translating one pattern language
separately is the #3948 shape reached through translation instead of vocabulary.

driver-sql gained an emitter arm that did not exist. The card said the bind
arm was unreachable; measured, there was no arm to reach — the array-format
emitter went away with the array dialect in #5158, leaving only the two infix
spellings in SCALAR_COMPARAND_OPERATORS, a comparand gate for an operator
nothing could emit. So a lowering-only fix would have turned a silent wrong
answer into a 400 and the card's repro table still would not pass.

Blast-radius sweep — one row per execution path

path verdict evidence
driver-sql implementsLIKE (Postgres/MySQL), GLOB (SQLite) sql-driver-like-pattern.test.ts, 19 cases against real SQLite
driver-sqlite-wasm implements — inherits SqlDriver's compiler full suite green (308)
driver-turso local + remote implements on both transports turso-local-remote-like-parity.test.ts, 15 cases, each asserting the two transports agree BEFORE asserting the answer
driver-memory query path + reference matcher implements memory-like-pattern.test.ts, each case asserted on both faces
@objectstack/formula implements matches-filter-like.test.ts
driver-mongodb refuses loudly INVALID_FILTER/400 via its default: arm
objectql having refuses loudly INVALID_FILTER/400 (#7047 already fixed the bare Error)
service-analytics normalizer refuses loudly INVALID_FILTER/400
service-analytics read-scope-sql refuses, different envelope READ_SCOPE_COMPILE_FAILED/500 — deliberate for RLS; recorded, not changed here

$like/$ilike are deliberately not in FILTER_OPERATORS: that array is
the runtime allowlist several faces derive acceptance from, and adding a name
there before every face has an arm turns a loud refusal into a silently DROPPED
predicate — the widening measured in #5701, ruled on in #3948. driver-memory
widens its own SUPPORTED_FIELD_OPERATORS by hand instead, the precedent
driver-turso's remote transport set for $icontains in #5702.

Two faces got arms rather than refusals for reasons that do not generalise:
formula because a declared operator hitting its silent false would deny
every write while the read scope's SQL matched rows (the #6993 defect), and
driver-memory because it is the in-memory double — an app whose tests run
there and whose production runs SQL must not meet a 400 for a filter that works.
The latter is also the one driver holding the VALID_AST_OPERATORS
expressibility invariant (#3948), which a refusal would have broken; that
invariant's suite is what caught it.

Why SQLite gets GLOB. $like is case-exact and SQLite's LIKE folds ASCII
unconditionally (PRAGMA case_sensitive_like is connection-global) — #6518's
finding and the operator it landed on. GLOB speaks a different pattern language,
so the pattern is TRANSLATED, not escaped, including GLOB's own metacharacters
which are ordinary to LIKE: an unescaped * in a GLOB pattern is the same
filter bypass an unescaped % is under LIKE (#5567).

Refused rather than given a meaning: a pattern ending in a lone unpaired
backslash. No reading survives every backend (Postgres rejects it outright, GLOB
has no escape character), so it is refused at the door on every face by one
shared test.

Wire-compat findings

No shipped app metadata, seed, fixture or docs example authors a like
predicate — VIEW_FILTER_OPERATORS does not even admit the spelling, so no
stored view can carry one. The only in-repo producer was
@objectstack/client's query builder, and it was broken twice over:

.contains(), .startsWith() and .endsWith() built a like tuple by gluing
wildcards onto the caller's value. While the wire folded like onto
$contains, the glued % was escaped back into a literal, so
.contains('name','Corp') searched for the text %Corp% and matched only rows
containing percent signs. And once like reaches the driver as a real pattern,
the glue becomes the other bug — a % or _ inside the caller's own value
would silently become a wildcard. They now emit contains / starts_with /
ends_with, whose comparand is text. .like() is unchanged and finally works;
.ilike() is new.

Three test/doc sites used $like as their "an operator the dialect does not
have" exemplar; those were retargeted at $sounds_like with the reason recorded
in place, and the historical account of cloud#1030 left intact.

Reverse verification — predicted in writing, then measured

probe predicted measured
restore 'like': '$contains' the wildcard pin and the exact-match pin both red; object-spelling cases green 13 spec red + 3 driver red, object spelling green — as predicted, including canonicalAstOperator going red, which confirms the exemption and the map entry were always one fact
break the $contains wrap the control pin red 2 red — the control, and the like ≠ contains inequality, which I predicted would also go red and wanted to: it is load-bearing in both directions
delete driver-sql's $like arms every case red with INVALID_FILTER, not wrong rows 14 red, all 14 "Unsupported filter operator"
emit LIKE not GLOB on SQLite 1 red (the case-sensitivity case) 5 red — prediction MISSED. Direction right, count wrong: the fixture's ACME INDUSTRIES row is reachable by several patterns once LIKE folds case, so the fold shows up in more row sets than the case-labelled one
drop the dangling-escape gate the refusal loses its ADR-0112 envelope 1 red, expected undefined to be 'INVALID_FILTER' — the gate supplies the envelope, the translator only throws

One further prediction miss worth recording: I expected the wire ilike case to
go red under probe 1, and it stayed green. parseFilterAST's lenient $${op}
fallback mints $ilike even with no map entry — it is isFilterAST (the
protocol door) that would have refused it. Two functions, two answers, which is
exactly the split #3948 is about.

Gates

Merged origin/main (never rebased) after #7574 landed; the incoming diff is
disjoint from every file here.

Not touched

No content/docs/releases/, no docs/adr/**. #5222 ($field cross-field
push-down) is not implemented here.


Generated by Claude Code

claude added 4 commits August 11, 2026 06:23
The wire lowering carried 'like': '$contains' in AST_OPERATOR_MAP, so every
like predicate arriving over HTTP was rewritten into a substring search before
any driver saw it. $contains LIKE-escapes its comparand and wraps it in %…%,
which broke like in both directions: a caller's wildcards bound as literals
(['name','like','%Industries'] returned 0 rows) and a wildcard-free pattern
became a substring match byte-identical to the $contains control.

canonicalAstOperator already documented the contract being violated, thirty
lines below the map entry, in a hand-written exemption for like/ilike. That
exemption only shaped its own output; the lowering the wire takes had none.

- spec: new $like/$ilike operators, the pattern language defined once
  (hasDanglingLikeEscape, likePatternToRegexSource, matchesLikePattern,
  likePatternToGlobPattern), like/ilike lowered to them, the exemption retired.
- driver-sql: the emitter arm the wire could not reach since #5158 — LIKE on
  Postgres/MySQL, GLOB on SQLite (case-exactness, #6518), pattern translated
  rather than escaped.
- driver-turso: the same on the remote transport, sharing the spec translation.
- driver-memory: both faces, so the in-memory double does not 400 for a filter
  production answers; the stale infix like==contains arm is gone.
- formula: arms, so a write-side check agrees with the read-side SQL.
- client: contains/startsWith/endsWith stop gluing wildcards into a like tuple.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PhHptz16p1kRmbmuzEZkgd
#7536)

The four new pattern-language exports and the two new declared operators.
Also corrects the $like describe() and the FILTER_OPERATORS staging table,
which both said the JS faces refuse — driver-memory and formula answer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PhHptz16p1kRmbmuzEZkgd
@vercel

vercel Bot commented Aug 11, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 11, 2026 6:44am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 6 package(s): @objectstack/client, @objectstack/driver-memory, @objectstack/driver-sql, @objectstack/driver-turso, @objectstack/formula, @objectstack/spec.

114 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/agents.mdx (via @objectstack/spec)
  • content/docs/ai/skills-reference.mdx (via packages/client, @objectstack/spec)
  • content/docs/ai/skills.mdx (via @objectstack/spec)
  • content/docs/api/client-sdk.mdx (via @objectstack/client, @objectstack/spec)
  • content/docs/api/data-flow.mdx (via @objectstack/client)
  • content/docs/api/environment-routing.mdx (via @objectstack/client, @objectstack/spec)
  • content/docs/api/error-catalog.mdx (via @objectstack/client, @objectstack/spec)
  • content/docs/api/error-handling-client.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-server.mdx (via @objectstack/spec)
  • content/docs/api/index.mdx (via @objectstack/spec)
  • content/docs/automation/approvals.mdx (via @objectstack/spec)
  • content/docs/automation/connectors.mdx (via @objectstack/spec)
  • content/docs/automation/flows.mdx (via @objectstack/spec)
  • content/docs/automation/hook-bodies.mdx (via packages/spec)
  • content/docs/automation/hooks.mdx (via @objectstack/spec)
  • content/docs/automation/index.mdx (via @objectstack/spec)
  • content/docs/automation/webhooks.mdx (via @objectstack/spec)
  • content/docs/automation/workflows.mdx (via @objectstack/spec)
  • content/docs/concepts/architecture.mdx (via @objectstack/spec)
  • content/docs/concepts/design-principles.mdx (via packages/spec)
  • content/docs/concepts/index.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-driven.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-lifecycle.mdx (via packages/spec)
  • content/docs/concepts/north-star.mdx (via @objectstack/spec)
  • content/docs/data-modeling/analytics.mdx (via @objectstack/spec)
  • content/docs/data-modeling/drivers.mdx (via @objectstack/driver-memory, @objectstack/driver-sql, @objectstack/driver-turso, @objectstack/spec)
  • content/docs/data-modeling/external-datasources.mdx (via @objectstack/spec)
  • content/docs/data-modeling/field-types.mdx (via @objectstack/spec)
  • content/docs/data-modeling/fields.mdx (via @objectstack/spec)
  • content/docs/data-modeling/formulas.mdx (via @objectstack/formula, @objectstack/spec)
  • content/docs/data-modeling/index.mdx (via @objectstack/spec)
  • content/docs/data-modeling/objects.mdx (via @objectstack/spec)
  • content/docs/data-modeling/queries.mdx (via @objectstack/spec)
  • content/docs/data-modeling/schema-design.mdx (via @objectstack/spec)
  • content/docs/data-modeling/seed-data.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation-rules.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation.mdx (via @objectstack/formula, @objectstack/spec)
  • content/docs/deployment/cli.mdx (via @objectstack/driver-turso, @objectstack/spec)
  • content/docs/deployment/environment-variables.mdx (via @objectstack/driver-turso)
  • content/docs/deployment/self-hosting.mdx (via @objectstack/driver-turso)
  • content/docs/deployment/tenancy-modes.mdx (via @objectstack/spec)
  • content/docs/deployment/troubleshooting.mdx (via @objectstack/spec)
  • content/docs/deployment/validating-metadata.mdx (via @objectstack/spec)
  • content/docs/deployment/vercel.mdx (via @objectstack/driver-memory)
  • content/docs/getting-started/build-with-claude-code.mdx (via @objectstack/spec)
  • content/docs/getting-started/common-patterns.mdx (via @objectstack/spec)
  • content/docs/getting-started/examples.mdx (via @objectstack/spec)
  • content/docs/getting-started/glossary.mdx (via @objectstack/driver-memory, @objectstack/driver-sql, @objectstack/driver-turso)
  • content/docs/getting-started/quick-reference.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-start.mdx (via @objectstack/spec)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/client, @objectstack/spec)
  • content/docs/kernel/cluster.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/auth-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/cache-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/data-engine.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/index.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/metadata-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/storage-service.mdx (via @objectstack/spec)
  • content/docs/kernel/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/data-service.mdx (via @objectstack/client, @objectstack/spec)
  • content/docs/kernel/runtime-services/email-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/examples.mdx (via @objectstack/spec)
  • content/docs/kernel/runtime-services/index.mdx (via packages/client, packages/spec)
  • content/docs/kernel/runtime-services/queue-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sharing-service.mdx (via @objectstack/spec)
  • content/docs/kernel/runtime-services/sms-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/storage-service.mdx (via @objectstack/spec)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/driver-memory, @objectstack/driver-sql, @objectstack/spec)
  • content/docs/kernel/services.mdx (via @objectstack/spec)
  • content/docs/permissions/authentication.mdx (via @objectstack/client, @objectstack/driver-memory)
  • content/docs/permissions/authorization.mdx (via @objectstack/spec)
  • content/docs/permissions/permission-sets.mdx (via @objectstack/spec)
  • content/docs/permissions/permissions-matrix.mdx (via @objectstack/spec)
  • content/docs/permissions/positions.mdx (via @objectstack/spec)
  • content/docs/permissions/rls.mdx (via @objectstack/spec)
  • content/docs/permissions/sharing-rules.mdx (via @objectstack/spec)
  • content/docs/permissions/system-context.mdx (via packages/spec)
  • content/docs/plugins/adding-a-metadata-type.mdx (via @objectstack/spec)
  • content/docs/plugins/anatomy.mdx (via @objectstack/driver-sql)
  • content/docs/plugins/development.mdx (via @objectstack/spec)
  • content/docs/plugins/index.mdx (via @objectstack/driver-memory, @objectstack/spec)
  • content/docs/plugins/packages.mdx (via @objectstack/client, @objectstack/driver-memory, @objectstack/driver-sql, @objectstack/driver-turso, @objectstack/formula, @objectstack/spec)
  • content/docs/protocol/backward-compatibility.mdx (via @objectstack/spec)
  • content/docs/protocol/diagram.mdx (via packages/spec)
  • content/docs/protocol/kernel/config-resolution.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/http-protocol.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/i18n-standard.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/driver-sql, @objectstack/spec)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/driver-sql, @objectstack/spec)
  • content/docs/protocol/kernel/plugin-spec.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/realtime-protocol.mdx (via @objectstack/client)
  • content/docs/protocol/knowledge.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/query-syntax.mdx (via @objectstack/driver-memory, @objectstack/driver-sql, @objectstack/spec)
  • content/docs/protocol/objectql/schema.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/security.mdx (via packages/spec)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/actions.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/concept.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/layout-dsl.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/record-alert.mdx (via @objectstack/formula, @objectstack/spec)
  • content/docs/protocol/objectui/widget-contract.mdx (via @objectstack/spec)
  • content/docs/ui/actions.mdx (via @objectstack/spec)
  • content/docs/ui/apps.mdx (via @objectstack/spec)
  • content/docs/ui/create-vs-edit-form.mdx (via @objectstack/spec)
  • content/docs/ui/dashboards.mdx (via @objectstack/spec)
  • content/docs/ui/field-grouping-and-order.mdx (via @objectstack/spec)
  • content/docs/ui/forms.mdx (via @objectstack/spec)
  • content/docs/ui/index.mdx (via @objectstack/spec)
  • content/docs/ui/public-data-collection.mdx (via @objectstack/spec)
  • content/docs/ui/setup-app.mdx (via @objectstack/spec)
  • content/docs/ui/translations.mdx (via @objectstack/spec)
  • content/docs/ui/views.mdx (via @objectstack/spec)

8 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx (via @objectstack/client, @objectstack/driver-memory, @objectstack/driver-sql, @objectstack/spec)
  • content/docs/releases/index.mdx (via @objectstack/spec)
  • content/docs/releases/v12.mdx (via @objectstack/spec)
  • content/docs/releases/v13.mdx (via @objectstack/spec)
  • content/docs/releases/v15.mdx (via @objectstack/formula)
  • content/docs/releases/v16.mdx (via @objectstack/client, @objectstack/formula, @objectstack/spec)
  • content/docs/releases/v17.mdx (via @objectstack/client, @objectstack/spec)
  • content/docs/releases/v9.mdx (via @objectstack/spec)

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actions github-actions Bot added documentation Improvements or additions to documentation protocol:data tests tooling labels Aug 11, 2026
…ble (#7536)

Adds the two operators to the reference table and a short section on why
$like is not a spelling of $contains — text vs pattern, substring vs whole
value — plus the per-backend coverage split.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PhHptz16p1kRmbmuzEZkgd
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation protocol:data size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Filter AST: like is folded to $contains at the wire — wildcards bind as literals and driver-sql's like/ilike arm is unreachable

2 participants