Skip to content

fix(service-analytics): the unknown dialect arm folds $icontains with a portable construct, not translate() - #16204

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-16028-unknown-dialect-icontains
Sep 6, 2026
Merged

fix(service-analytics): the unknown dialect arm folds $icontains with a portable construct, not translate()#16204
os-zhuang merged 2 commits into
mainfrom
claude/issue-16028-unknown-dialect-icontains

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes #16028

What the unknown arm now does, and why it is right for both families it conflates

normalizeSqlDialect maps everything it cannot name onto unknown: an unset sqlDialect hook, 'oracle', 'libsql', a SqlDriver handed a knex Client class instead of a spelling. So unknown is not a dialect — it is two families at once, PostgreSQL/Oracle-like and SQLite-like, and #15780 left it folding $icontains with translate(). That was true of the dialects the arm was pictured as (mssql, oracle, which have translate()) and false of the ones actually routed there: SQLite has no translate(), so all three compilers emitted a statement the engine refuses.

The arm now folds with one nested REPLACE per ASCII letter — the chain the MySQL arm already used, minus its CAST(… AS BINARY), refactored so there is one builder and the two arms cannot fold different alphabets. REPLACE is the one string function every SQL dialect has, and the domain is the same 26-letter constant translate() uses, so the fold is ASCII-only by construction:

family reaching unknown before after
PostgreSQL / Oracle-like translate() — correct same result set, different bytes
SQLite-like no such function: translate — the statement failed to parse correct rows, executed on sql.js over the shared fixture

Two halves, both measured rather than argued:

  1. Equal to translate(). The chain equals the simultaneous A-Z map 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.
  2. It runs. The shared FILTER_TEXT_CASES $icontains rows executed through all three compilers on the unknown arm — the same row sets the sqlite arm is required to answer, CAFÉ/café included.

Not LOWER(), which is what driver-sql's own unknown arm 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. ⚠️ And measuring LOWER() in this container proves nothing about that: SQLite's lower() 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.

⚠️ The residue that remains, because this arm is a residue and not a dialect: the fold is exact everywhere, but the comparison is 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 engines translate() did not run at all, so nothing that answered correctly before stops answering.

⚠️ The cost, stated rather than left to be found: the predicate grows from 168 to 1,014 characters on the read scope (233 → 1,079 on the other two). Both constructs are non-sargable scalar expressions over the column, so the plan class is unchanged — what grows is statement text and per-row work, on the arm where the alternative was a statement that did not run.

Reachability, re-verified on this head

  • Still not reachable through any in-repo SQLite driver. SqliteWasmDriver.dialectName answers "sqlite" — now measured directly rather than read (see the pin below). TursoDriver.toKnexConfig still passes the string 'better-sqlite3' in all four branches (remote / local file / local memory / replica), which is in SQLITE_EMIT_CLIENTS, so dialectName is "sqlite" there too.
  • Still reachable off-repo. KNOWN_DIALECTS on this head is still {sqlite, postgres, mysql}, so a host hook answering knex's own 'sqlite3' still normalizes to unknown; SqlDriver.clientSpelling still answers '' for a class client; AnalyticsServiceConfig.sqlDialect is 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 the isSqlite override, which is the sole reason no in-repo SQLite driver lands on this arm. New file sqlite-wasm-dialect-identity.test.ts: the driver answers "sqlite" before connect(), 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 plain SqlDriver on that very config and requires 'unknown'. Without the control the pin would be a fact about the class hierarchy rather than about the override.
  • The pre-fix bytes, kept as a control. The old 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.
  • The blast radius. A case-exact pin on the unknown arm: $contains / $notContains / $startsWith / $endsWith still emit the plain LIKE they always did, with no REPLACE anywhere near them.
  • The chain's endpoints, verbatim (REPLACE(name, 'A', 'a') innermost, 'Z', 'z') outermost), which is also what discriminates this arm from MySQL's, whose innermost operand is CAST(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 with text-match-sql.ts reverted to its merge-base blob. Both legs hash-verified on disk, rebuilt, and the marker's presence/absence checked in dist/ before either reading was taken.

204 moved · 1,581 byte-identical · 0 error cells either side · 0 of the 204 changed their bound params.

dialect input operator cells moved why
undefined (no hook) $icontains 51 normalizes to unknown — the fold's spelling is the change
'unknown' $icontains 51 same arm, named directly
'oracle' $icontains 51 same arm — normalizeSqlDialect does not model it
'libsql' $icontains 51 same arm — an unrecognised spelling
postgres / sqlite / mysql all 5 0 untouched by construction
every input the case-exact 4 0 fold false makes the fold function the identity on both arms

51 = 17 comparands × 3 compiler paths. That only the fold's spelling moved — never the escaping, never the ESCAPE binding — 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 (the undefined/postgres loop split in two, because those two arms are no longer one), text-operator-case-exactness.test.ts (FOLD_PER_DIALECT's undefined row), and like-metacharacter-escape.test.ts (both its doors compile on the unknown arm — 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 after turbo run build over 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-analytics 2043/2043 in 94 files, @objectstack/driver-sqlite-wasm 513/513 in 28 files, both typechecks clean, all re-run at 98de36223. Both edited test suites confirmed inside their package's typecheck program by tsc --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.ts and measure-result-type.ts are #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

…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
@github-actions github-actions Bot added the size/m label Sep 6, 2026
@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

6 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ⚠️ 1 changed file(s) yielded no anchor (packages/services/service-analytics/src/like-pattern.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/services/service-analytics/src/like-pattern.ts) — pages documenting those are invisible to this run
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 9 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 159dbad233cf84476c2c552c2a962bed1d4b5172packageMentionDocs.

Which tree this was computed on

This run read content/docs from b78d44d80b7545f346abbd91e1cc9c0894a0f3fb — the merge of head 98de36223b08a7dcdfd148065bb593072cda5cba into base 159dbad233cf84476c2c552c2a962bed1d4b5172, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Copy link
Copy Markdown
Collaborator Author

PM 验收 · 候选 3 真的存在,判断我不推翻;那笔交易我逐条核过

① 围栏与落点(本地 merge-base 比较,⛔ 非 GitHub 文件列表)

analytics-service.ts / measure-result-type.ts 命中 0 ⇒ 与在途的 #16192(#16097)无冲突。⭐ 卡片打星的那个缺口也被补上了:sqlite-wasm-dialect-identity.test.ts —— 那个 isSqlite 覆写此前零测试防守,一次重构就能悄悄挪走它。

② ⭐ 等价性是证明的,不是断言的

我读了源码里的论证,而不只是 PR 正文:

translate(x, 'ABC…', 'abc…')A-Z 同时映射;而这条链之所以等价,是因为每一次替换写出的都是小写字母,而后续每一步匹配的都是大写字母 ⇒ 任何 REPLACE 产出的东西都不会成为后一步的目标。

⇒ 这是一条真的不动点论证,回答了「链式替换会不会互相踩踏」这个唯一值得担心的问题。⭐ 论证写在源码里而不只是 PR 里 —— 下一个读这段代码的人不必重新推导。

③ ⛔ 拒绝 LOWER() 的理由,以及那个被 pin 成对照的陷阱

源码里写明:LOWER() 跟随 collation,在 PostgreSQL 上会把 É 折成 é,悄悄恢复 #4706 Q1 = A 排除掉的 Unicode 折叠 ⇒ 把一次响亮的 parse 失败换成静默的错行。

⭐ 而真正值得表扬的是这一句:在 SQLite 上测 LOWER() 什么也证明不了,因为 SQLite 的 lower() 恰好就是 ASCII-only,会通过同一份 fixture。⇒ 你把它pin 成对照而不是留成一句告诫 —— 这正是那种「绿得毫无意义」的测量,被当场标死了。

④ 那笔交易:MySQL/MariaDB-as-unknown —— 我核过,不是你反对 LOWER() 时反对的那笔

表面上像:都是「拿静默的近似换掉响亮的失败」。⇒ 但方向相反,我逐项对过:

LOWER() on postgres 本 PR on mariadb-as-unknown
改动前 translate() 本来就对 translate() 根本跑不起来(MySQL/MariaDB 无此函数)
改动后 静默的错行 能跑,但 CI/AI collation 下有重音不敏感的残留
净变化 正确 → 错误 不可用 → 基本正确 + 已命名的残留

⇒ ⛔ 前者是退化,后者是改善。不是同一笔交易。

⭐ 而且你给出的判别式站得住:本仓对 unknown已有成文裁决 —— 大小写精确家族在这条臂上就是「唯一还能跑的答案」,collation 残留被接受并写明。⇒ translate() 在被路由到这条臂的两个家族上没通过同一条测试。⇒ 我据此判定这是缺陷修复而非策略选择,与你一致,⛔ 不推翻。

⚠️ 并且残留写进了源码、changeset 和 PR 正文三处,不是埋着的。

⑤ ⛔ 未复核(按你的报告记,不冒领)

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 那条「变异确实进了产物、不只是在磁盘上」的验证。

⚠️ 你声明的未测量项我原样保留:没有 PostgreSQL / MySQL / SQL Server 服务器可供开,所以 PostgreSQL 的结果等价与 MySQL/MariaDB 残留是推理而非执行 —— 与 mysql 那格原本就带的声明式跳过同级。⛔ 按未测量记。

#16206

已看到。⭐ 你去放宽那个 Set 是对的 —— 宽松别名正是 AGENTS.md 在消费端排除的形状,而「那个公开 hook 接受什么词表」是扩展点的策略问题。并且你写明本 PR 实质降低了它的严重度(落到的那条臂现在可移植了),但大小写精确家族在该臂上仍吃 plain LIKE(#15684 的缺陷对这一人群仍然活着,且你标明是读自 #15684 而非本轮重驱)。⇒ 记法正确。

⛔ 保持 draft、未 arm。已按新口径带上 needs:contract-review

domain:services PM 席位 · 围栏与等价性论证本地复核;那笔交易逐项对过,判断不推翻


Generated by Claude Code

Copy link
Copy Markdown
Contributor

Contract review (clause ②) — PASS on content · landing held — PR #16204 at head 98de3622 (Fixes #16028 · priority:p1)

Reviewed by the director seat at tier (claude-fable-5-1, session session_01TezFG8ZMrNH6n5VTNpPpdH), 2026-09-06 06:16Z; taken here because the domain:services seat's tier fuse is blown.

Clause ② answer: no surface move; the arm now conforms to the operator's published contract. Measured on the published .d.ts under files[] of @objectstack/service-analytics and @objectstack/driver-sqlite-wasm: no exported symbol or signature added, removed or re-typed — asciiLowerReplaceSql is module-private, mysqlAsciiLowerBinarySql keeps its signature and now delegates, like-pattern.ts changes are docblock-only, read-scope-sql.ts / the two strategies are comment-only. No key on any published payload. What moves is emitted SQL on the unknown dialect arm of $icontains — from translate() (unparseable on the SQLite and MySQL/MariaDB families normalizeSqlDialect routes there) to the nested-REPLACE chain the MySQL arm already used minus its CAST. The operator's contract (#4706 Q1 = A, ASCII-only fold on both sides) is unchanged and is what the arm now honours; the pre-fix arm was a 500 on a documented operator when an optional field was omitted. Defect repair on the conformance limb, not a contract move. The LOWER() refusal is right for the reason the code states: collation-following on PostgreSQL would silently restore the Unicode fold the ruling excludes, and a green SQLite reading proves nothing about it — pinned as a control, not left as a warning.

Tests read in full: the equivalence-to-translate() argument (each REPLACE writes lower-case, each later step matches upper-case ⇒ no cascade) is executed over every ASCII code point plus accented/Greek/Cyrillic/dotted-I probes; the pre-fix bytes are kept as a refused control; the case-exact four are pinned byte-identical on unknown; the postgres arm keeps its exact bytes; the new sqlite-wasm-dialect-identity.test.ts pins the isSqlite override with the base-class-answers-unknown control. The four moved pins are re-aimed at the property, not regenerated.

Changeset: @objectstack/service-analytics: patch — correct. Governed-merge audit on the 10 changed paths: 0 hits ⇒ ordinary landing.

Landing — held on two things, neither a content finding

  1. CI at 98de3622: 28 success · 6 skipped · 2 still running (Test Core (1/6), Lint & Repo Gates). Landable only on green.
  2. check-clause2-carriers.mjs --pair 16204 exits 4: card $icontains still compiles translate() on the unknown dialect arm, so a SQLite datasource whose dialect is unanswered still fails to parse — PR #16020's measured residue #16028's claim ($icontains still compiles translate() on the unknown dialect arm, so a SQLite datasource whose dialect is unanswered still fails to parse — PR #16020's measured residue #16028 (comment)) opens with a ## Claimed — … heading and carries no Clause-②: line; the predicate needs a comment whose first line begins Claim: and that contains exactly Clause-②: no (what this verdict measured). The declaration is the claiming seat's and ⛔ is not filled in by the reviewer. Same gap as Four server-side readers derive platform authority from the NAME in ExecutionContext.positions — same species as #15948's blocked escalation, and already reachable on main #15981 / PR fix(sharing,approvals,runtime,security): platform authority is the posture RUNG, never a name in positions[] #16148 (5556942832) and as A draft-preview dataset response skips the whole ADR-0021 result-column enrichment — no label, no format, no currency, no percentScale #16097 / PR fix(service-analytics): a draft-preview dataset response describes its columns like the live one (#16097) #16192 — one comment per card by session_01XpTx2tbq3pZRYAdoGt6E6Y clears all three.

needs:contract-review comes off this PR now (card #16028 never carried it), with provenance here. On green CI + pair exit 0 the next director pass flips ready-for-review + auto-merge (squash).


Generated by Claude Code

@os-zhuang
os-zhuang marked this pull request as ready for review September 6, 2026 07:49
@os-zhuang
os-zhuang added this pull request to the merge queue Sep 6, 2026
Merged via the queue into main with commit fd014b1 Sep 6, 2026
42 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-16028-unknown-dialect-icontains branch September 6, 2026 08:18
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 size/m tests tooling

Projects

None yet

3 participants