Repository navigation
fix(service-analytics): on SQLite, the text operators compare the whole stored value and comparand, not only up to their first U+0000 (#20025) - #20067
Conversation
…ery face (red at base) Records the #20025 measurement on the branch before the fix: the five faces that call `textMatchPredicateSql` with `dialect: 'sqlite'` are run on better-sqlite3 and sql.js (through each driver's own `execute()`) over a grid of stored values and comparands with and without U+0000, `''` and NULL, bare and under `$not`. At this commit every face answers 88 of 128 cells differently from the JavaScript rows, while driver-sql answers all 128 alike. The fix follows in the next commit. Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude <noreply@anthropic.com>
…-operator table Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check3 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 9 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 2d3d274488dc58e2ddb7d9a87a513216ff61de26 && git checkout 2d3d274488dc58e2ddb7d9a87a513216ff61de26
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin b76aad5f6fac71cbbcd4ea6bfdf21a59e2fd4d56 d000746b1d4ac930a621ace8581c518ede1137c2 && git checkout -B drift-repro b76aad5f6fac71cbbcd4ea6bfdf21a59e2fd4d56 && git merge --no-ff d000746b1d4ac930a621ace8581c518ede1137c2
node scripts/docs-audit/affected-docs.mjs --json b76aad5f6fac71cbbcd4ea6bfdf21a59e2fd4d56 |
Fixes #20025
Clause-②: no
What this changes
service-analyticscompiles its own SQL for the text operators, intext-match-sql.tstextMatchPredicateSql, and three faces call it withdialect: 'sqlite':compileScopedFilterToSql(read-scope-sql.ts), the read scope, reached directly and through both strategies' scope merge;NativeSQLStrategy.buildFilterClause, the executedwhere;ObjectQLStrategy.buildFilterClauseSql, the/analytics/sqlecho, which its caller runs on the same database.Its
sqlitearm emittedGLOBfor every shape. SQLite'sglob()reads its pattern AND the stored value as C strings, so each is cut at its first U+0000.driver-sqlleft that construct in #19999 (the comparand's cut) and #20024 (the stored value's cut); this package re-emits the driver's construct table rather than importing it, and its copy had keptGLOB.The
sqlitearm now mirrorsdriver-sql'stextMatchPredicate/sqliteLengthAwareTextMatchonmain, cell for cell:contains($contains,$notContains,$icontains)instr(col, ?) > 0ends($endsWith), non-empty comparandcoalesce(substr(CAST(col AS BLOB), -length(CAST(? AS BLOB))), CAST(col AS BLOB)) = CAST(? AS BLOB)ends, empty comparandinstr(col, ?) > 0starts($startsWith), comparand without U+0000col GLOB ?, unchanged byte for bytestarts, comparand holding U+0000instr(col, ?) = 1glob()also cuts the stored value, so everycontains/endscomparand moves, not only a comparand holding U+0000. Only$startsWithwithout U+0000 stays onGLOB, because neither cut can change that answer and it is the one shape an index serves.lower()on both sides, ASCII-only ($regexon driver-sql is not a regex — it compiles to a substring LIKE, so it both over-matches and silently matches nothing #4706 Q1 = A). The four case-exact operators stay case-sensitive (Q2 = A);instr()and the BLOB comparison are byte-wise.NOT (…), which is NULL for a NULL value, asNOT GLOBwas. So the NULL-safe$notContainswrapper and the$nottotalisation compose unchanged (非否定路径上的$ne/$nin/$notContains:driver-sql 排除 NULL 行,driver-memory / formula 返回它们(#5146 只裁定了$not) #5298).TextMatchBind. TheLIKEarms already bind two values, so none of the three call sites changes.instr()and the BLOB suffix have no pattern language, so*,?and[bind as written.text-match-sql.ts's header now describes thesqlitearm as it is, and names the new parity suite besidetext-operator-case-exactness.test.ts.Measured before the fix: the face table
Executed, not read, at
980bc05e5b. That ismainafter PR #20046 landed, and it moved nothing intext-match-sql.tssince the dispatch base2274894cc4. It ran on better-sqlite3 (SQLite 3.53.4) and sql.js (3.49.1), each through its driver's ownexecute(). Every cell was compared with@objectstack/formula'smatchesFilterCondition.The grid: stored values with U+0000 at the start, middle and end, without it,
''and NULL; comparands with and without U+0000; each leaf bare and under$not. All five faces answered alike, and so did both engines: the direct read scope, the read scope throughNativeSQLStrategyand through the echo, the nativewhere, and the echowhere.$contains$notContains$startsWithGLOB)$endsWith$icontains$nottext-match-sqlite-nul.test.ts, 128 cells), every face on both engines answered 88 cells differently from JS at base, and answers 0 at head.driver-sql, which is already fixed onmain. It is the control, and it answered all 128 like JS at base and at head.unknownarm on SQLite emitsLIKE, and SQLite'sLIKEcuts at U+0000 too. That was measured on both engines:'a'+ U+0000 +'b'is notLIKE '%b%', and'plain'isLIKE '%'+ U+0000 +'%'. The arm is untouched. It is the residue for dialects nothing answered for, PostgreSQL-like ones among them, and PostgreSQL has noinstr(). Choosing a construct there would be a dialect guess. It is reported to the seat as a finding and noted in the header.$like/$ilikeare refused on every analytics face, before this arm:READ_SCOPE_COMPILE_FAILEDon the read scope,INVALID_FILTER/ 400 on thewhere. This package compiles no raw-pattern operator, so there is nothing to swap.Controllability (triage's point 1)
Comparand side: no caller-controlled producer measured. The RLS compile path (
RLSCompiler.compileFilter→compileCelToFilter) resolves a string method's argument from one of two places:current_user(id, organization id, email).The ids are system-generated. The email is refused at every auth entry measured when it holds U+0000, and it is read-only on the user object. Membership sets are arrays, which the string methods refuse.
plugin-sharingemits id / owner membership scopes, not text operators. Sharing-rule criteria are admin-authored and evaluated throughengine.find, the driver face that is already fixed.Stored-value side: a caller-controlled producer exists. It is any persona that holds create or edit on an object with a text field. The engine's write path stores U+0000 unchanged under a non-system context (executed). Reaching a read scope also needs a deployment-authored
usingpredicate with a string method over such a field. No shipped example authors one.Tests (at
d000746b1d)New
src/__tests__/text-match-sqlite-nul.test.ts, 29 tests. It covers:execute(). That is the transportplugin.tsuses, and a bare sql.js bind would cut a comparand holding U+0000 before any construct saw it;driver-sql's own rows on the same engine. That is the parity pin that keeps the two construct tables from drifting;$startsWithwithout U+0000 emittingGLOBand its escaped pattern byte for byte on all three compilers.It was committed first, red at base (
ced01cbad7): 14 failed and 15 passed; each face on each engine had 88 of 128 cells differ;driver-sqlhad 0.Respelled pins, never loosened. Four existing files asserted
GLOBfor shapes that no longer compile to it:text-operator-case-exactness.test.ts,icontains-dialect-sql.test.ts,sql-dialect-vocabulary.test.tsandtext-operator-non-text-column.test.ts.LIKE", where they used one/GLOB/.$endsWith/$startsWithcompiled-text rows gained SQL assertions they did not have.pnpm --filter @objectstack/service-analytics test: 121 files, 2718 tests passed.typecheck(tsc --noEmit, whose program lists the touched test files) exit 0.Ablations: each negative pin, one shape at a time, through
scripts/ablation-replace.mjsThe subject is
src/text-match-sql.ts, imported relatively by every face, so vitest reads source and nodist/is involved. Every leg landed on disk (the anchor count went from 1 to 0 and the blob changed) and was restored with its blob equal to HEAD (e913134711cb) andgit diff HEADempty.containsback onGLOBendsback onGLOBstartsholding U+0000 back onGLOBstartswithout U+0000 moved offGLOBendssent to the suffix construct''bare and under$not)coalesce()removed from the suffix''under$not)The first A6 attempt was a no-op. Its replacement text already occurred inside its own anchor, so the tool refused the leg and ran nothing. It was re-run with a non-overlapping anchor (the row above).
EXPLAIN QUERY PLANover an indexed TEXT column, on both engines:$contains/$endsWithscanned underGLOBand still scan, and$startsWithwithout U+0000 keeps itsSEARCH … USING INDEX.Gates (at
d000746b1d)node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, run from the real diff. It gave 60 families, identical to the dispatch-time list.check:dual-build-cjs-loadsandcheck:type-check-debtboth exited 3 with PREREQUISITE NOT MET, because they read the whole repo's built closure and this worktree built only the package's own. CI builds it first.--ranreconciliation: 60 derived, 58 run, 2 NOT MEASURED, 0 unrun.GITHUB_TOKEN=… node scripts/check-issue-citations.mjs: every citation this change adds resolves.--no-inline-config --format json): 6 files, 0 errors, 0 warnings.packages/**/*.{ts,…}block; none of the six is inNEVER_LINTED.--print-configshowsparserOptionswithoutproject. So this diff cannot move a verdict on an untouched file.pnpm lintis CI's.Acceptance notes
text-operator-case-exactness.test.tsand new test files. Three more existing test files carriedGLOBpins for the moved shapes and are respelled here (listed above). No call site, refusal door, driver, spec or formula file is touched.GLOBfor every shape:like-pattern.ts(the$icontainsnote),read-scope-sql.ts(thetextMatchand$icontainsdocblocks),strategies/native-sql-strategy.ts,strategies/objectql-strategy.tsandstrategies/types.ts. They ride the next PR that touches those files. Carrier: none.$icontains: ''. It is not a U+0000 cell, and this change does not move it.AnalyticsService.queryin-process, answer every non-NULL row for it, as they did underGLOB '**'.FILTER_TEXT_CASESdeclares it refused (INVALID_FILTER), anddriver-sqlrefuses it.mainmoved during this run only by3557f85fa5(adriver-tursoREADME), which touches nothing here. No merge was needed.Generated by Claude Code