fix(service-analytics): the unknown dialect arm folds $icontains with a portable construct, not translate() - #16204
Conversation
…with a portable construct, not `translate()` `normalizeSqlDialect` routes EVERYTHING it cannot name onto `unknown` — an unset hook, `'oracle'`, `'libsql'`, a `SqlDriver` given a class client. #15780 left that arm folding with `translate()` on the reading that it "was never broken", which held only for the dialects the arm was pictured as (mssql, oracle). SQLite reaches it through four embedder compositions, and SQLite has no `translate()`, so all three of this package's compilers emitted a statement the engine refuses — a documented operator answering 500 because one OPTIONAL field was left out. The arm now folds with one nested `REPLACE` per ASCII letter: the same chain the MySQL arm already used, minus its `CAST(… AS BINARY)`, so there is one builder and the two cannot fold different alphabets. It parses on every SQL dialect and is ASCII-only BY CONSTRUCTION, so it serves both families `unknown` conflates — PostgreSQL/Oracle-like keep `translate()`'s exact result set, SQLite-like get an answer at all. - `postgres` is untouched and byte-identical; so is the case-EXACT family on every arm (`fold` false makes the fold function the identity on both). - ⛔ Not `LOWER()`, which `driver-sql`'s own `unknown` arm uses: it follows the collation and would trade this parse failure for silently wrong rows on PostgreSQL, the Unicode fold #4706 Q1 = A rules out. - The pins that moved are re-aimed at the property, not regenerated, and the pre-fix bytes are kept as a control that the engine still refuses them. - `SqliteWasmDriver`'s `isSqlite` override — 0 direct test hits, the sole reason no in-repo SQLite driver lands on this arm — gets a direct pin, with a control showing the base class answers `unknown` for that very config. Refs #16028 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
…ed and with its moved cells enumerated Refs #16028 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
📓 Docs Drift Check6 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. 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 b78d44d80b7545f346abbd91e1cc9c0894a0f3fb && git checkout b78d44d80b7545f346abbd91e1cc9c0894a0f3fb
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 159dbad233cf84476c2c552c2a962bed1d4b5172 98de36223b08a7dcdfd148065bb593072cda5cba && git checkout -B drift-repro 159dbad233cf84476c2c552c2a962bed1d4b5172 && git merge --no-ff 98de36223b08a7dcdfd148065bb593072cda5cba
node scripts/docs-audit/affected-docs.mjs --json 159dbad233cf84476c2c552c2a962bed1d4b5172 |
PM 验收 · 候选 3 真的存在,判断我不推翻;那笔交易我逐条核过① 围栏与落点(本地 merge-base 比较,⛔ 非 GitHub 文件列表)
② ⭐ 等价性是证明的,不是断言的我读了源码里的论证,而不只是 PR 正文:
⇒ 这是一条真的不动点论证,回答了「链式替换会不会互相踩踏」这个唯一值得担心的问题。⭐ 论证写在源码里而不只是 PR 里 —— 下一个读这段代码的人不必重新推导。 ③ ⛔ 拒绝
|
LOWER() on postgres |
本 PR on mariadb-as-unknown |
|
|---|---|---|
| 改动前 | translate() 本来就对 |
translate() 根本跑不起来(MySQL/MariaDB 无此函数) |
| 改动后 | 静默的错行 | 能跑,但 CI/AI collation 下有重音不敏感的残留 |
| 净变化 | 正确 → 错误 | 不可用 → 基本正确 + 已命名的残留 |
⇒ ⛔ 前者是退化,后者是改善。不是同一笔交易。
⭐ 而且你给出的判别式站得住:本仓对 unknown 臂已有成文裁决 —— 大小写精确家族在这条臂上就是「唯一还能跑的答案」,collation 残留被接受并写明。⇒ translate() 在被路由到这条臂的两个家族上没通过同一条测试。⇒ 我据此判定这是缺陷修复而非策略选择,与你一致,⛔ 不推翻。
⑤ ⛔ 未复核(按你的报告记,不冒领)
57 个门族(含两个先答 3 = PREREQUISITE NOT MET、你引用了门自己的话——「measuring now would not fail, it would silently measure a DIFFERENT WORLD」——补齐后转绿)、1,785 格的位移普查(204 移动、1,581 逐字节相同、两条腿都 0 错误格、204 格无一改变绑定参数)、sql.js 上的实际执行,以及 ablation-dist-preflight 那条「变异确实进了产物、不只是在磁盘上」的验证。
⑥ #16206
已看到。⭐ 你不去放宽那个 Set 是对的 —— 宽松别名正是 AGENTS.md 在消费端排除的形状,而「那个公开 hook 接受什么词表」是扩展点的策略问题。并且你写明本 PR 实质降低了它的严重度(落到的那条臂现在可移植了),但大小写精确家族在该臂上仍吃 plain LIKE(#15684 的缺陷对这一人群仍然活着,且你标明是读自 #15684 而非本轮重驱)。⇒ 记法正确。
⛔ 保持 draft、未 arm。已按新口径带上 needs:contract-review。
domain:services PM 席位 · 围栏与等价性论证本地复核;那笔交易逐项对过,判断不推翻
Generated by Claude Code
Contract review (clause ②) — PASS on content · landing held — PR #16204 at head
|
Fixes #16028
What the
unknownarm now does, and why it is right for both families it conflatesnormalizeSqlDialectmaps everything it cannot name ontounknown: an unsetsqlDialecthook,'oracle','libsql', aSqlDriverhanded a knex Client class instead of a spelling. Sounknownis not a dialect — it is two families at once, PostgreSQL/Oracle-like and SQLite-like, and #15780 left it folding$icontainswithtranslate(). That was true of the dialects the arm was pictured as (mssql, oracle, which havetranslate()) and false of the ones actually routed there: SQLite has notranslate(), so all three compilers emitted a statement the engine refuses.The arm now folds with one nested
REPLACEper ASCII letter — the chain the MySQL arm already used, minus itsCAST(… AS BINARY), refactored so there is one builder and the two arms cannot fold different alphabets.REPLACEis the one string function every SQL dialect has, and the domain is the same 26-letter constanttranslate()uses, so the fold is ASCII-only by construction:unknowntranslate()— correctno such function: translate— the statement failed to parseTwo halves, both measured rather than argued:
translate(). The chain equals the simultaneousA-Zmap because no step can feed a later one — every replacement writes a lower-case letter and every later step matches an upper-case one. Executed on the engine over every ASCII code point plus accented, Greek, Cyrillic and dotted-I probes, required equal to the ASCII-only map exactly.FILTER_TEXT_CASES$icontainsrows executed through all three compilers on theunknownarm — the same row sets thesqlitearm is required to answer,CAFÉ/caféincluded.⛔ Not⚠️ And measuring
LOWER(), which is whatdriver-sql's ownunknownarm folds with.LOWER()follows the collation, so adopting it would trade a loud parse failure for silently wrong rows on PostgreSQL — the Unicode fold #4706 Q1 = A rules out.LOWER()in this container proves nothing about that: SQLite'slower()is ASCII-only and passes the same fixture. That trap is pinned as a comment beside the arm and as a control in the suite, so the construct is chosen by construction and not by what happens to pass locally.LIKE, which on a case- or accent-insensitive collation (MySQL/MariaDB arriving here through the'mariadb'spelling #11756 deliberately leaves unrecognised; SQL Server) over-matches beyond ASCII. That is the same residue this arm's case-exact neighbour already carries and names — not a new one — and on those enginestranslate()did not run at all, so nothing that answered correctly before stops answering.Reachability, re-verified on this head
SqliteWasmDriver.dialectNameanswers"sqlite"— now measured directly rather than read (see the pin below).TursoDriver.toKnexConfigstill passes the string'better-sqlite3'in all four branches (remote / local file / local memory / replica), which is inSQLITE_EMIT_CLIENTS, sodialectNameis"sqlite"there too.KNOWN_DIALECTSon this head is still{sqlite, postgres, mysql}, so a host hook answering knex's own'sqlite3'still normalizes tounknown;SqlDriver.clientSpellingstill answers''for a class client;AnalyticsServiceConfig.sqlDialectis still optional (analytics-service.ts:606). All four constructions the card lists remain live — they are now served correctly instead of refused by the engine.⛔ Unchanged and still NOT MEASURED: whether any shipped application hits this path today. Nothing here asserts it.
What was pinned
SqliteWasmDriver's dialect identity — the card measured 0 direct test hits on theisSqliteoverride, which is the sole reason no in-repo SQLite driver lands on this arm. New filesqlite-wasm-dialect-identity.test.ts: the driver answers"sqlite"beforeconnect(), on memory- and file-backed configs; its knex client is asserted to be a class (so no string table could have answered); and the control constructs a plainSqlDriveron that very config and requires'unknown'. Without the control the pin would be a fact about the class hierarchy rather than about the override.translate()statement is rebuilt in the suite and handed to the engine, which still refuses it — so every green beside it is a measurement of the change, not of an engine that would have accepted anything.unknownarm:$contains/$notContains/$startsWith/$endsWithstill emit the plainLIKEthey always did, with noREPLACEanywhere near them.REPLACE(name, 'A', 'a')innermost,'Z', 'z')outermost), which is also what discriminates this arm from MySQL's, whose innermost operand isCAST(name AS BINARY). The expected text is rebuilt in the test from the ruled domain rather than imported, so a change to the emitter's loop cannot re-bless itself.Every pinned cell this moved, and why
Measured by generating the emitted SQL and bound params for
{NativeSQLStrategy, ObjectQLStrategy echo, compileScopedFilterToSql} × {undefined, 'unknown', 'oracle', 'libsql', 'postgres', 'sqlite', 'mysql'} × 5 text operators × 17 comparands= 1,785 cells, at this head and again withtext-match-sql.tsreverted to its merge-base blob. Both legs hash-verified on disk, rebuilt, and the marker's presence/absence checked indist/before either reading was taken.204 moved · 1,581 byte-identical · 0 error cells either side · 0 of the 204 changed their bound params.
undefined(no hook)$icontainsunknown— the fold's spelling is the change'unknown'$icontains'oracle'$icontainsnormalizeSqlDialectdoes not model it'libsql'$icontainspostgres/sqlite/mysqlfoldfalse makes the fold function the identity on both arms51 = 17 comparands × 3 compiler paths. That only the fold's spelling moved — never the escaping, never the
ESCAPEbinding — is what the unchanged params column says.The four suite pins that moved were re-aimed at the property, not regenerated:
icontains-dialect-sql.test.ts(theundefined/postgresloop split in two, because those two arms are no longer one),text-operator-case-exactness.test.ts(FOLD_PER_DIALECT'sundefinedrow), andlike-metacharacter-escape.test.ts(both its doors compile on theunknownarm — that file opens a real SQLite database and was pinning a fold that could not run on it).Verification
Family derived mechanically on this head —
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, Reconciliation: 57 families, harvested via--commands: 55 exit 0 on the first pass; the 2 that answered exit 3 (PREREQUISITE NOT MET — they read built output) both exit 0 afterturbo run buildover the workspace. Every exit code captured immediately after a single redirected command, never through a pipe.pnpm lint(repo-wide,eslint . --no-inline-config) exit 0 — full scope, no narrowing.@objectstack/service-analytics2043/2043 in 94 files,@objectstack/driver-sqlite-wasm513/513 in 28 files, both typechecks clean, all re-run at98de36223. Both edited test suites confirmed inside their package's typecheck program bytsc --listFiles, so "typecheck clean" covers them.⛔ No claim is made here about CI state.
Changeset:
.changeset/analytics-unknown-dialect-icontains-portable-fold.md, graded patch with the grading argued in it — no exported type, signature or option changes, and the operator's contract is unchanged; what moves is emitted SQL on one arm.Files kept out of this branch on purpose:
analytics-service.tsandmeasure-result-type.tsare #16097's and #16098's this round; the remedy did not need either.🤖 Generated with Claude Code
https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
Generated by Claude Code