Skip to content

Commit d5d8d50

Browse files
os-warrenclaude
andauthored
docs(spec,drivers,service-analytics): CAST(col AS BLOB) LIKE returning nothing is a compile-option behaviour, not a SQLite fact (#16113)
* docs(drivers,service-analytics): CAST(col AS BLOB) LIKE returning nothing is a compile-option behaviour, not a SQLite fact Both headers rejected `CAST(col AS BLOB) LIKE ?` as a portable case-exact construct on the strength of one universal claim: that it "was measured to return NOTHING at all" on SQLite. That claim is false as stated. Whether LIKE is false for a BLOB operand is fixed when SQLite is COMPILED, by SQLITE_LIKE_DOESNT_MATCH_BLOBS, and the two SQLite builds this repo ships disagree about it. Measured here over the shared FILTER_TEXT_ROWS fixture, with { name: { $contains: 'acme' } } compiled to that construct: better-sqlite3 13.0.3 (SQLite 3.53.4, flag compiled in) -> [] sql.js 1.14.1 (SQLite 3.49.1, flag absent) -> ['1','2'] Neither file's conclusion changes: both still reject the construct, and the rejection is now stronger rather than merely hedged. A construct that means two different things on the two builds this repo ships is disqualifying for a read scope on its own, without needing any particular return value at all -- one build silently answers nothing, the other silently answers exactly the ASCII over-fold the change was made to end. GLOB, which both files chose instead, answers ['2'] on both builds. Comment text only: no behaviour change, no exported surface, and no emitted JS or .d.ts difference (the driver-side block documents a module-private function; the analytics one is a file header). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y * docs(drivers,service-analytics): CAST(col AS BLOB) LIKE returning nothing is a compile-option behaviour, not a SQLite fact Both headers rejected `CAST(col AS BLOB) LIKE ?` as a portable case-exact construct on the strength of one universal claim: that it "was measured to return NOTHING at all" on SQLite. That claim is false as stated. Whether LIKE is false for a BLOB operand is fixed when SQLite is COMPILED, by SQLITE_LIKE_DOESNT_MATCH_BLOBS, and the two SQLite builds this repo ships disagree about it. Measured here over the shared FILTER_TEXT_ROWS fixture, with { name: { $contains: 'acme' } } compiled to that construct: better-sqlite3 13.0.3 (SQLite 3.53.4, flag compiled in) -> [] sql.js 1.14.1 (SQLite 3.49.1, flag absent) -> ['1','2'] Neither file's conclusion changes: both still reject the construct, and the rejection is now stronger rather than merely hedged. A construct that means two different things on the two builds this repo ships is disqualifying for a read scope on its own, without needing any particular return value at all -- one build silently answers nothing, the other silently answers exactly the ASCII over-fold the change was made to end. GLOB, which both files chose instead, answers ['2'] on both builds. Comment text only: no behaviour change, no exported surface, and no emitted JS or .d.ts difference (the driver-side block documents a module-private function; the analytics one is a file header). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y * docs(spec,driver-turso): correct the same compile-option claim in the two remaining carriers The false sentence had FOUR carriers in the tree, not the two originally identified. `git log -S` dates them to one origin: 3172831 2026-08-08 planted it in sql-driver.ts AND remote-transport.ts in the same commit d063a96 2026-08-11 copied it into packages/spec/src/data/filter.zod.ts 54bb2f1 2026-09-05 copied it into service-analytics/text-match-sql.ts One origin, four carriers, four weeks. Correcting only some of them is worse than correcting none: a reader trusts the uncorrected copy precisely because of where it sits, and packages/spec is the contract layer -- it outranks the driver layer as an authority. Both remaining headers now name SQLITE_LIKE_DOESNT_MATCH_BLOBS, carry the two shipped builds' divergent readings, and let the divergence carry the rejection rather than any claim about return values. Neither conclusion is weakened: both still reject the construct and choose GLOB. The spec header is where this bites hardest, and it already said so without knowing it: three of the five backends it lists are SQLite underneath, and those are NOT the same build -- driver-sql runs better-sqlite3 (flag compiled in) and driver-sqlite-wasm runs sql.js (flag absent). The two measured rows ARE two of the three it enumerates. Two readings added to all four headers so the remedy's stability is measured rather than asserted: typeof CAST(name AS BLOB) is 'blob' on BOTH builds -- the CAST is not the part that differs; LIKE's blob rule is the compile-time half GLOB answers ['2'] on BOTH builds -- stable across exactly the axis that disqualifies the rejected construct Comment text only; no behaviour, no exported surface. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y * docs(changeset): the four-carrier CAST AS BLOB correction ships in published typings, so it is not skip-changeset Measured on the built tree rather than assumed: the corrected text reaches packages/spec/dist/filter.zod-*.d.ts and .d.mts, and driver-turso's dist/index.d.ts, index.d.mts, index.js and index.mjs. @objectstack/spec also publishes the edited source file itself through its src/**/*.zod.ts files entry. Two packages therefore ship changed declarations, which is a release, so the earlier skip-changeset judgement -- correct while the change was confined to a module-private function and a file header -- does not survive the widened set. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7d7ca6c commit d5d8d50

5 files changed

Lines changed: 86 additions & 12 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
"@objectstack/spec": patch
3+
"@objectstack/driver-turso": patch
4+
"@objectstack/driver-sql": patch
5+
"@objectstack/service-analytics": patch
6+
---
7+
8+
Correct the documented reason for rejecting `CAST(col AS BLOB) LIKE ?` as a portable case-exact construct.
9+
10+
Four headers stated, as a universal fact about SQLite, that the construct "was measured to return NOTHING". That is not a property of SQLite: whether `LIKE` is false for a BLOB operand is fixed when SQLite is compiled, by `SQLITE_LIKE_DOESNT_MATCH_BLOBS`, and the two SQLite builds this project ships disagree about it. Measured over the shared `FILTER_TEXT_ROWS` fixture, `{ name: { $contains: 'acme' } }` compiled to that construct returns `[]` on better-sqlite3 13.0.3 (SQLite 3.53.4, flag compiled in) and `['1','2']` on sql.js 1.14.1 (SQLite 3.49.1, flag absent) — the latter being exactly the ASCII case-folding defect the construct was being considered to avoid.
11+
12+
No behaviour changes and no conclusion changes: all four sites still reject the construct and still choose `GLOB`. The rejection is now stated in a form that does not depend on any particular return value — a construct whose meaning is decided by an upstream compile flag cannot carry a read scope, because it means two different things on the two builds shipped here. Two supporting readings are recorded alongside it: `typeof CAST(name AS BLOB)` is `'blob'` on both builds, so the CAST is not the part that differs, and `GLOB` answers identically on both.
13+
14+
Documentation only. `@objectstack/spec` and `@objectstack/driver-turso` ship the corrected text in their published type declarations (and `spec` also publishes the corrected source file directly, via its `src/**/*.zod.ts` entry); for `@objectstack/driver-sql` and `@objectstack/service-analytics` the change reaches published output only through sourcemaps.

packages/drivers/driver-sql/src/sql-driver.ts

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2864,10 +2864,32 @@ function mysqlAsciiLowerBinary(expr: string): string {
28642864
* - **SQLite → `GLOB`.** `LIKE`'s ASCII fold cannot be turned off per-statement;
28652865
* `PRAGMA case_sensitive_like` is a CONNECTION-global switch, so one query
28662866
* would change every other query's meaning. Of the operand-level tricks,
2867-
* `CAST(col AS BLOB) LIKE ?` was measured to return NOTHING at all (SQLite's
2868-
* LIKE is false for a BLOB operand), so the operator has to change. `GLOB` is
2869-
* case-exact by definition and carries its own escape mechanism
2870-
* ({@link escapeGlobComparand}). `lower()` in front of it is still the
2867+
* `CAST(col AS BLOB) LIKE ?` is disqualified by something worse than
2868+
* failing: it means TWO DIFFERENT THINGS on the two SQLite builds this repo
2869+
* ships. Whether `LIKE` is false for a BLOB operand is not a property of
2870+
* SQLite the language — it is set when SQLite is COMPILED, by
2871+
* `SQLITE_LIKE_DOESNT_MATCH_BLOBS`. Measured over the shared
2872+
* `FILTER_TEXT_ROWS` fixture, `{name: {$contains: 'acme'}}` compiled to that
2873+
* construct:
2874+
*
2875+
* | build | `SQLITE_LIKE_DOESNT_MATCH_BLOBS` | rows |
2876+
* |---|---|---|
2877+
* | better-sqlite3 13.0.3 (SQLite 3.53.4) | compiled in | `[]` |
2878+
* | sql.js 1.14.1 (SQLite 3.49.1) | absent | `['1','2']` — `ACME Corp` AND `acme corp` |
2879+
*
2880+
* So one build silently answers nothing and the other silently answers
2881+
* exactly the ASCII over-fold this whole function exists to end, and which
2882+
* one a caller gets is decided by a flag upstream of us. That divergence is
2883+
* the rejection on its own: a construct whose meaning depends on how the
2884+
* driver's SQLite was BUILT cannot carry a read scope (#3948) whatever value
2885+
* it happens to return in any one container — the `[]` above is a build's
2886+
* answer, not SQLite's. The CAST itself is not the part that differs:
2887+
* `typeof CAST(name AS BLOB)` is `'blob'` on BOTH builds, so what diverges is
2888+
* purely `LIKE`'s rule for a blob operand, which is the compile-time half. So
2889+
* the operator has to change. `GLOB` is case-exact by definition, answers
2890+
* `['2']` on BOTH builds above, and carries its own
2891+
* escape mechanism ({@link escapeGlobComparand}). `lower()` in front of it is
2892+
* still the
28712893
* `$icontains` fold, and still ASCII-only: measured, `lower('CAFÉ')` is
28722894
* `'cafÉ'`, so `lower(name) GLOB '*café*'` answers row 4 and `'*cafÉ*'`
28732895
* answers row 3 — the Q1 = A boundary, executed rather than argued.

packages/drivers/driver-turso/src/remote-transport.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3036,7 +3036,20 @@ export class RemoteTransport {
30363036
* over-matching rather than a near miss. The fold cannot be switched off per
30373037
* statement (`PRAGMA case_sensitive_like` is connection-global, so one query
30383038
* would silently redefine every other query on the same connection), and
3039-
* `CAST(col AS BLOB) LIKE ?` was measured to match NOTHING at all. `GLOB` is
3039+
* `CAST(col AS BLOB) LIKE ?` cannot replace it either: whether `LIKE` is false
3040+
* for a BLOB operand is fixed when SQLite is COMPILED, by
3041+
* `SQLITE_LIKE_DOESNT_MATCH_BLOBS`, so the construct means two DIFFERENT
3042+
* things on the two SQLite builds this repo ships. Measured over the shared
3043+
* `FILTER_TEXT_ROWS` fixture, `{name: {$contains: 'acme'}}` compiled to it:
3044+
* better-sqlite3 13.0.3 (SQLite 3.53.4, flag compiled in) answers `[]`, and
3045+
* sql.js 1.14.1 (SQLite 3.49.1, flag absent) answers `['1','2']` — the very
3046+
* over-match described above. `typeof CAST(name AS BLOB)` is `'blob'` on BOTH,
3047+
* so the CAST is not the part that differs; `LIKE`'s blob rule is. That
3048+
* divergence disqualifies it here without any claim about return values, and
3049+
* it bites hardest on THIS face: a remote transport cannot pin the build its
3050+
* libSQL server was compiled from, so the flag is not merely upstream, it is
3051+
* across the wire. `GLOB` carries no such dependency — measured, it answers
3052+
* `['2']` on both builds. It is
30403053
* SQLite's case-exact pattern operator and is what both SQLite faces now
30413054
* emit — `SqlDriver.applyLike`'s `textMatchPredicate` reaches the identical
30423055
* decision for the local transport, and `turso-local-remote-*` parity suites

packages/services/service-analytics/src/text-match-sql.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,16 @@
3131
* Postgres and MySQL.
3232
* - `CAST(… AS BINARY)` is byte-wise on MySQL, is not a type on Postgres, and
3333
* takes NUMERIC affinity on SQLite (it would compare a number).
34-
* - `CAST(col AS BLOB) LIKE ?` was measured on the driver side to return
35-
* NOTHING at all — SQLite's LIKE is false for a BLOB operand.
34+
* - `CAST(col AS BLOB) LIKE ?` means two DIFFERENT things on the two SQLite
35+
* builds this repo ships, which disqualifies it more firmly than any single
36+
* wrong answer would. Whether `LIKE` is false for a BLOB operand is fixed
37+
* when SQLite is COMPILED, by `SQLITE_LIKE_DOESNT_MATCH_BLOBS`, so it is
38+
* not a portable property at all: measured on this same fixture,
39+
* `{ name: { $contains: 'acme' } }` answered `[]` on better-sqlite3 13.0.3
40+
* (SQLite 3.53.4, flag compiled in) and `['1','2']` on sql.js 1.14.1
41+
* (SQLite 3.49.1, flag absent) — the latter being precisely the over-fold
42+
* above. A construct that a read scope's correctness rests on cannot be one
43+
* whose meaning an upstream build flag decides.
3644
* - The portable primitives that ARE case-sensitive everywhere (`replace()`)
3745
* express "occurs somewhere" but not "occurs at the start / at the end"
3846
* without character-length arithmetic that is spelled differently on every

packages/spec/src/data/filter.zod.ts

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1082,11 +1082,28 @@ export function matchesLikePattern(value: string, pattern: string, foldAscii = f
10821082
* #6518 measured every way out of that and landed on `GLOB`, which is
10831083
* case-exact by definition: `PRAGMA case_sensitive_like` is CONNECTION-global,
10841084
* so one query would redefine every other query on the connection, and
1085-
* `CAST(col AS BLOB) LIKE ?` was measured to match NOTHING. Three of the five
1086-
* backends are SQLite underneath (driver-sql on better-sqlite3,
1087-
* driver-sqlite-wasm, driver-turso on both transports), so without this
1088-
* translation `$like` would mean one thing on Postgres and another on SQLite —
1089-
* the divergence #6518 closed for `$contains`, re-opened one operator over.
1085+
* `CAST(col AS BLOB) LIKE ?` is not portable enough to be the answer — whether
1086+
* `LIKE` is false for a BLOB operand is fixed when SQLite is COMPILED, by
1087+
* `SQLITE_LIKE_DOESNT_MATCH_BLOBS`, so that construct means two DIFFERENT
1088+
* things on the two SQLite builds this repo ships. Measured over the shared
1089+
* `FILTER_TEXT_ROWS` fixture, `{name: {$contains: 'acme'}}` compiled to it:
1090+
*
1091+
* | build | `SQLITE_LIKE_DOESNT_MATCH_BLOBS` | rows |
1092+
* |---|---|---|
1093+
* | better-sqlite3 13.0.3 (SQLite 3.53.4) | compiled in | `[]` |
1094+
* | sql.js 1.14.1 (SQLite 3.49.1) | absent | `['1','2']` — `ACME Corp` AND `acme corp` |
1095+
*
1096+
* That divergence is disqualifying on its own, without any claim about what the
1097+
* construct returns: the flag is upstream of us, so the meaning is a property of
1098+
* how a backend's SQLite was BUILT. And this file is exactly where that bites —
1099+
* three of the five backends are SQLite underneath (driver-sql on
1100+
* better-sqlite3, driver-sqlite-wasm, driver-turso on both transports), and
1101+
* those are NOT the same build: the two rows above ARE two of the three. `GLOB`
1102+
* has no such dependency — measured, it answers `['2']` on BOTH builds — and
1103+
* `typeof CAST(name AS BLOB)` is `'blob'` on both, so the CAST is not the part
1104+
* that differs; `LIKE`'s blob rule is. Without this translation `$like` would
1105+
* mean one thing on Postgres and another on SQLite — the divergence #6518
1106+
* closed for `$contains`, re-opened one operator over.
10901107
*
10911108
* `GLOB` has a DIFFERENT pattern language, which is the whole reason this is a
10921109
* translation and not an escape:

0 commit comments

Comments
 (0)