Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,31 @@ While pre-1.0, the public API may change between 0.x releases.

_Nothing yet._

## [0.3.3] — 2026-06-13

### Fixed

- **A filtered subscription's membership no longer depends on which path
decided it (ADR-0013).** The SQL snapshot and the JS delta/catch-up evaluators
disagreed on two operators, so two clients could see different rows for the
same `where` depending on connection timing:
- **`ne` crashed the delta path and hung the client.** The SQL floor accepted
`ne` but `@tanstack/db`'s evaluator has no `ne` (not-equal is `not(eq(...))`);
its compile error escaped `handleSub` uncaught, so no `reset` was sent. `ne`
is now off the floor and rejected with `reset` like any unsupported operator,
and a defensive guard turns any predicate-compile failure into a `reset`
rather than a hang. Use `not(eq(...))` for not-equal (unchanged for real
clients).
- **`like` was case-insensitive in the snapshot but case-sensitive in deltas.**
The DO now sets `PRAGMA case_sensitive_like = ON`, making SQLite `LIKE` match
`@tanstack/db`'s case-sensitive `like` on every path. (#17)

### Changed

- **Operator floor for server-side filtering dropped `ne`** — it is now
`eq, gt, gte, lt, lte, like, in, and, or, not`, exactly the set the SQL and JS
evaluators agree on row-for-row. `like` is now case-sensitive. (#17)

## [0.3.2] — 2026-06-13

### Added
Expand Down
94 changes: 94 additions & 0 deletions docs/adr/0013-predicate-floor-one-evaluator.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# ADR-0013: Filtered-subscription membership — one evaluator is the source of truth; the floor is the verified-agreeing set

**Status**: Accepted
**Date**: 2026-06-13
**Characterization**: `tests/predicate-parity.test.ts` (originally plan 003)

## Context

A filtered subscription's membership is decided by **two different evaluators**
depending on the path:

- The **initial snapshot** filters in SQLite: the `where` IR is lowered to SQL
by `src/server/sql-compiler.ts` (`"col" = ?`, `"col" LIKE ?`, …).
- **Live deltas and reconnect catch-up** filter in JavaScript: the same IR is
compiled by `@tanstack/db`'s `compileSingleRowExpression` + `toBooleanPredicate`
in `src/server/subscriptions.ts` (`sync-do.ts` delta/catch-up paths). The client's
eager-write preflight (`do-collection.ts`) uses that same JS evaluator, and so do
the client's own live queries.

If the two disagree on any row, a client's view of a filtered subset depends on
*when it connected* — a row excluded by the snapshot can be pushed in by a later
delta, and two clients silently diverge. Characterization found two real divergences:

1. **`ne` — crash + hang.** `sql-compiler.ts` accepted `ne` (lowered to `!=`), but
`@tanstack/db`'s evaluator has no `ne` in its registry (not-equal is expressed as
`not(eq(...))`). `compileSingleRowExpression` threw a `QueryCompilationError` from
inside `subs.add`, which runs in `handleSub` **after** the `try/catch` that wraps
`compileSubsetQuery`. The throw escaped uncaught: no `reset` was sent and the
subscriber hung until timeout.
2. **`like` — case divergence.** SQLite `LIKE` is ASCII case-*insensitive* by default;
`@tanstack/db`'s `like` is case-*sensitive* (its case-insensitive variant is
`ilike`, which is off-floor). A row `"HELLO"` matched `like "hello%"` in the SQL
snapshot but not in the JS deltas.

## Decisions

### D1: `@tanstack/db`'s evaluator is the source of truth for membership

It already decides membership on three of the four sites (delta, catch-up, client
preflight), and the client's live queries use the same library. The SQL snapshot is
an *optimization* that must reproduce exactly the rows that evaluator accepts. So when
the two disagree, **SQL conforms to `@tanstack/db`** — never the reverse.

### D2: The operator floor is the verified-agreeing set

Floor = `{ eq, gt, gte, lt, lte, like, in, and, or, not }`. **`ne` is removed** — it
is not in `@tanstack/db`'s evaluator, and a real client emits `not(eq(...))` (which
both paths handle identically). Anything outside the floor is rejected with
`UnsupportedPredicateError` → `reset`, as before. `tests/predicate-parity.test.ts`
pins row-for-row agreement for each floor operator across both paths; **any operator
added to `COMPARATORS` must add a parity case** (enforced by review).

### D3: `LIKE` is made case-sensitive to match

The DO sets `PRAGMA case_sensitive_like = ON` in the `SyncDurableObject` constructor.
The pragma is connection-scoped, and the constructor runs on every instantiation —
including a hibernation wake — so it is always in force before any query (the same
lifecycle the existing `setWebSocketAutoResponse` registration relies on). It is
contained: the IR→SQL compiler is the only producer of `LIKE` in the codebase.
(`case_sensitive_like` is a *setter-only* pragma — there is no getter — so it is
verified behaviorally in tests, not by readback.) Case-*insensitive* matching is
`ilike`, which stays off-floor (see deferred).

The `like` **pattern must be a string literal**: `@tanstack/db`'s `evaluateLike`
returns `false` unless both operands are strings, whereas SQLite `LIKE` would
coerce a non-string pattern (`123` → `'123'`) and match rows the JS path rejects.
The compiler rejects a non-string `like` pattern with `UnsupportedPredicateError`
(→ `reset`), keeping the floor *verified*-agreeing rather than merely usually so.

### D4: A defensive fail-loud guard at predicate compile

`compilePredicate` (`subscriptions.ts`) now wraps the `@tanstack/db` compile and
re-throws any failure as `UnsupportedPredicateError`; `handleSub` catches it around
`subs.add` and answers with `reset`. With the floors aligned this is belt-and-
suspenders, but it guarantees fail-loud (a `reset`, never a hang) for any future
operator that lands in one floor and not the other.

## Consequences

- **Behavior change (observable):** a `ne` subscription is now rejected with `reset`
(was: an indefinite hang). `like` on a filtered subscription is now case-sensitive
on every path (was: case-insensitive in the snapshot only, and divergent from
deltas) — `"HELLO"` no longer matches `like "hello%"`. This aligns snapshot, delta,
catch-up, and client-side semantics. Real `@tanstack/db` clients are unaffected by
the `ne` removal — they emit `not(eq(...))`, which is fully supported.
- **No idle timers / hibernation impact:** the pragma is set synchronously in the
constructor; no polling introduced.
- **Deferred:** `ilike` (case-insensitive `LIKE`) remains off-floor. Adding it would
lower to `lower("col") LIKE lower(?)` (or equivalent) plus a parity case — a
separate decision.
- **Test coverage:** `tests/predicate-parity.test.ts` (6 cases, both paths) pins
floor-operator agreement; `tests/subscriptions.test.ts` pins the JS-floor guard at
the unit level; `tests/sql-compiler.test.ts` pins `ne` rejection and `not(eq)`
lowering.
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,4 @@ explains the displacement.
| [0009](./0009-changelog-time-retention.md) | Changelog time-based retention; reset stale reconnects | Accepted |
| [0010](./0010-typed-mutations-collection-manifest.md) | Typed mutations via a collection-row manifest on `SyncRegistry` | Accepted |
| [0012](./0012-wire-input-hardening.md) | Wire-input hardening: frame-shape guards, inbound limits, sanitized execute errors | Accepted |
| [0013](./0013-predicate-floor-one-evaluator.md) | Filtered-subscription membership: one evaluator is the source of truth; the floor is the verified-agreeing set | Accepted |
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "tanstack-do-db-collection",
"version": "0.3.2",
"version": "0.3.3",
"description": "Sync a TanStack DB collection to a Cloudflare Durable Object over WebSockets — optimistic mutations, live queries, and single-ordered-stream write confirmation.",
"type": "module",
"license": "MIT",
Expand Down
32 changes: 26 additions & 6 deletions src/server/sql-compiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,18 @@
// `where` plus orderBy/limit/offset into a parameterised SELECT, so subset
// shaping runs in SQLite rather than scanning every row in memory.
//
// Supported operator floor (D6): eq, ne, gt, gte, lt, lte, like, in, and, or,
// not. Anything outside it — ilike, isNull, functional/nested-path predicates —
// throws UnsupportedPredicateError. The caller rejects such a subscription
// rather than silently falling back to a full scan (fail loud).
// Supported operator floor (D6): eq, gt, gte, lt, lte, like, in, and, or,
// not. Anything outside it — ne, ilike, isNull, functional/nested-path
// predicates — throws UnsupportedPredicateError. The caller rejects such a
// subscription rather than silently falling back to a full scan (fail loud).
//
// The floor is exactly the set of operators that the SQL snapshot path and
// @tanstack/db's JS evaluator (delta/catch-up path) agree on, row-for-row —
// see ADR-0013. `ne` is excluded because @tanstack/db has no `ne` (it only
// knows `not(eq(...))`); accepting it here desynced the two paths and crashed
// the delta path. `like` stays in the floor but is only correct because the DO
// sets `PRAGMA case_sensitive_like = ON` (SyncDurableObject constructor), which
// makes SQLite LIKE case-sensitive to match @tanstack/db's case-sensitive `like`.

export class UnsupportedPredicateError extends Error {
constructor(message: string) {
Expand All @@ -17,11 +25,12 @@ export class UnsupportedPredicateError extends Error {
const IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/
const COMPARATORS: Record<string, string> = {
eq: "=",
ne: "!=",
gt: ">",
gte: ">=",
lt: "<",
lte: "<=",
// LIKE is case-sensitive only because the DO sets PRAGMA case_sensitive_like
// = ON; see the floor note above and ADR-0013.
like: "LIKE",
}

Expand Down Expand Up @@ -60,6 +69,17 @@ function compileExpr(node: unknown, params: Array<unknown>): string {

if (name in COMPARATORS) {
if (args.length !== 2) throw new UnsupportedPredicateError(`'${name}' expects 2 arguments`)
if (name === "like") {
// @tanstack/db's evaluateLike returns false unless BOTH operands are
// strings, whereas SQLite LIKE coerces a non-string pattern (e.g. 123 →
// '123') and could match rows the JS delta/catch-up path rejects. Restrict
// the pattern to a string literal so the two paths stay in lockstep — the
// floor must be verified-agreeing, not merely "usually agreeing" (ADR-0013).
const lit = args[1]
if (!isNode(lit) || lit.type !== "val" || typeof lit.value !== "string") {
throw new UnsupportedPredicateError("'like' pattern must be a string literal")
}
}
return `${column(args[0])} ${COMPARATORS[name]} ${literal(args[1], params)}`
}
if (name === "and" || name === "or") {
Expand All @@ -84,7 +104,7 @@ function compileExpr(node: unknown, params: Array<unknown>): string {
}
throw new UnsupportedPredicateError(
`operator '${name}' is not supported for server-side filtering ` +
`(floor: eq, ne, gt, gte, lt, lte, like, in, and, or, not)`,
`(floor: eq, gt, gte, lt, lte, like, in, and, or, not)`,
)
}

Expand Down
18 changes: 15 additions & 3 deletions src/server/subscriptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
// client's operator semantics exactly (no second predicate implementation).

import { compileSingleRowExpression, toBooleanPredicate } from "@tanstack/db"
import { UnsupportedPredicateError } from "./sql-compiler.ts"

export interface Sub {
subId: string
Expand All @@ -19,9 +20,20 @@ export interface Sub {

function compilePredicate(where: unknown): (row: Record<string, unknown>) => boolean {
if (where === undefined || where === null) return () => true
const evaluate = compileSingleRowExpression(where as never) as (
row: Record<string, unknown>,
) => boolean | null
let evaluate: (row: Record<string, unknown>) => boolean | null
try {
evaluate = compileSingleRowExpression(where as never) as (row: Record<string, unknown>) => boolean | null
} catch (e) {
// @tanstack/db's evaluator rejects any operator outside its registry (e.g. a
// hand-built `ne`, which it does not know — only `not(eq(...))`). The SQL
// snapshot floor (sql-compiler.ts) and this JS delta floor MUST be the same
// set, or a sub's membership depends on which path decided it. Re-throw as
// UnsupportedPredicateError so handleSub rejects the sub with a `reset`
// (fail loud) instead of letting this escape uncaught and hang the client. (ADR-0013)
throw new UnsupportedPredicateError(
`predicate not compilable by @tanstack/db evaluator: ${e instanceof Error ? e.message : String(e)}`,
)
}
// toBooleanPredicate collapses SQL 3-valued null to false, matching SQL.
return (row) => toBooleanPredicate(evaluate(row))
}
Expand Down
26 changes: 24 additions & 2 deletions src/server/sync-do.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ import { Broadcaster } from "./broadcast.ts"
import { decodeResult, encodeResult, lookupTx, recordTx, type SeenTx, sweepDedup } from "./dedup.ts"
import type { SyncRegistry } from "./registry.ts"
import { andPredicates, compileSubsetQuery, UnsupportedPredicateError } from "./sql-compiler.ts"
import { SubscriptionRegistry } from "./subscriptions.ts"
import { SubscriptionRegistry, type Sub } from "./subscriptions.ts"

export abstract class SyncDurableObject<Env = unknown, TUser = unknown> extends DurableObject<Env> {
/** Set by `registerSync` — the collections/mutations/commands this DO serves. */
Expand Down Expand Up @@ -88,6 +88,12 @@ export abstract class SyncDurableObject<Env = unknown, TUser = unknown> extends
super(ctx, env)
// Auto-pong via the runtime: survives hibernation, no per-message billing.
this.ctx.setWebSocketAutoResponse(new WebSocketRequestResponsePair("ping", "pong"))
// Make SQLite LIKE case-sensitive so the SQL snapshot path matches
// @tanstack/db's case-sensitive `like` evaluator on the delta path — the
// single source of truth for filtered-subscription membership (ADR-0013).
// Connection-scoped pragma; re-applied on every instantiation, including a
// hibernation wake (same lifecycle as the auto-response registration above).
this.sql.exec("PRAGMA case_sensitive_like = ON")
// Restore the live-socket set after a hibernation wake.
for (const ws of this.ctx.getWebSockets()) this.liveWs.add(ws)
this.broadcaster = new Broadcaster((ws, frame) => this.send(ws, frame), this.tickMs)
Expand Down Expand Up @@ -609,7 +615,23 @@ export abstract class SyncDurableObject<Env = unknown, TUser = unknown> extends
// before the tick loses the write (reconnect resumes past it).
this.broadcaster.flushOne(ws)

const sub = this.subs.add(ws, frame.subId, frame.collection, frame.where)
// Registering compiles the predicate in @tanstack/db's evaluator. If the
// predicate is outside the JS floor (e.g. an operator the SQL floor somehow
// let through), that throws UnsupportedPredicateError — reject with `reset`
// rather than letting it escape uncaught and hang the client (ADR-0013).
// With the floors aligned this is belt-and-suspenders, but it must hold for
// any future operator added to one floor and not the other.
let sub: Sub
try {
sub = this.subs.add(ws, frame.subId, frame.collection, frame.where)
} catch (e) {
if (e instanceof UnsupportedPredicateError) {
console.error(`sub '${frame.subId}' on '${frame.collection}' rejected: ${e.message}`)
this.send(ws, { t: "reset", sub: frame.subId })
return
}
throw e
}
const seq = String(currentSeq(this.sql))

// Reconnect catch-up: a `since` cursor asks for changes after that point
Expand Down
Loading