Repository navigation
feat(spec,drivers,objectql,analytics,formula): $icontains reaches every JS evaluation face (#6520) - #7123
Conversation
…ry JS evaluation face (#6520) #5702 implemented `$icontains` on the SQL family and correctly left the spec's `FILTER_OPERATORS` alone. This adds the operator to that array and gives every remaining evaluation face an arm, in ONE change, because those two steps cannot be separated: the array is a runtime allowlist that `driver-memory`'s shape gate derives from, so admitting the name while the matcher had no arm turns a loud refusal into a silently dropped predicate — measured on #5701's branch, and a dropped predicate WIDENS, which on an RLS read scope is a permission bypass (#3948). Faces changed, under the sanctioned one-off exception to the #5499 freeze (maintainer ruling 2026-08-08, semantic parity only): - spec: `FILTER_OPERATORS` admits `$icontains`; the vocabulary pin's diff set goes empty; new shared fold `foldAsciiCase` / `asciiCaseInsensitiveContains` / `asciiCaseInsensitiveRegexSource` - driver-memory: query path, reference matcher and analytics (cube) face - driver-mongodb: an ASCII-only `$regex`, never `$options: 'i'` - objectql `having`, `@objectstack/formula` `matchesFilterCondition` - service-analytics: the normalizer plus all three SQL compilers The fold is ASCII-only by ruling (#4706 Q1 = A) and lives in ONE definition, because both obvious per-package spellings are wrong the same way: `toLowerCase()` folds all of Unicode, and so does a `RegExp` with the `i` flag. The pattern-binding faces emit one `[Aa]` class per ASCII letter and pass no flags. Comparand rules match driver-sql's: literal, and refused when empty or non-string. The formula face keeps its silent fail-closed `false` for unknown operators — decided, not inherited, and documented on `matches-filter.ts`: it governs a write-side check where an unevaluable condition denies rather than widens, and callers rely on it being a total predicate. No operator the spec DECLARES is answered that way any more, which was the #6993 census's measured defect. Both `FILTER_TEXT_CASES` DEBT rows survive, now naming one open requirement each (the `$contains` family's Unicode fold, #6682) instead of two. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PiRUoQkTSBBmpyXBY3cVn2
…D on memory + mongodb (#6520) The status section still said those two REFUSE it, which this PR made false. It also now says why each still carries a DEBT row rather than importing the table: coverage is judged by IMPORT, and requirement 2 (the $contains family's Unicode fold, #6682) is still open on both. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PiRUoQkTSBBmpyXBY3cVn2
…ers $icontains (#6520) `memory-driver-filter-logic-conformance.test.ts` keys its probe map by the face's OWN declared vocabulary and fails when a declared operator has no probe — so adding `$icontains` to `MONGO_TO_CUBE_OPERATOR` correctly turned it red. That gate is the reason #5374 cannot recur, and it fired exactly as designed. The comparand is UPPER-case ('BET') against a lower-case fixture row ('beta'), so the probe only selects a row once the ASCII fold actually runs. A lower-case comparand would have been discriminating too and would have agreed across the two faces for free even if neither folded — the free-agreement certification that block's own header warns against. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PiRUoQkTSBBmpyXBY3cVn2
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 6 package(s): 111 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 8 release-owned page(s) also reference the affected code. These are read-only:
|
…ld exports (#6520) `check:api-surface` is a SEPARATE snapshot from `check:export-origins`, and this PR regenerated only the latter. Both track the public surface and both must be regenerated when it grows: export-origins records WHICH DECLARATION each name resolves to, api-surface records the NAME SET plus factory signatures. The delta is exactly the three additive exports on `./data` — `foldAsciiCase`, `asciiCaseInsensitiveContains`, `asciiCaseInsensitiveRegexSource` — reported by the gate as "0 breaking (removed/narrowed), 3 added". Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PiRUoQkTSBBmpyXBY3cVn2
…main (#6966) Two generated artifacts move with the merge: - `export-origins/` is new on main (#4796) and had never seen `HookDispatch`. - `api-surface/` needed a real regeneration, not just my own entry. The merge of main into this branch produced a file byte-identical to THIS branch's side, silently dropping the five exports #7123 added on main (`SEARCH_VIRTUAL_TYPES`, `foldAsciiCase`, `asciiCaseInsensitiveContains`, `asciiCaseInsensitiveRegexSource`, `isVirtualSearchField`). These paths carry `merge=os-regen` in .gitattributes precisely so a merge regenerates rather than picks a side; it did not here, and `check:api-surface` is what caught it. Regenerating yields the union — both main's five and this branch's `HookDispatch`. Read the export-origins diff as its gate asks: one line, `HookDispatch` under `src/data/hook.zod.ts`, the same origin as `HookContext`. Not a re-home, not a new dual-source. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EcLMsZRoR3daV3QzPXgwxK
…on the hook contract (objectstack-ai#6966) (objectstack-ai#7101) * fix(spec,objectql,sharing,storage): state per-row vs record dispatch on the hook contract (objectstack-ai#6966) A predicate (`multi: true`) write dispatches its lifecycle hooks once per matched row — `after*` since objectstack-ai#5038, `before*` since objectstack-ai#5574 — on a context deliberately indistinguishable from a single-id write's. That is the feature, and it erased the only signal several handlers had: before objectstack-ai#5574 a bulk `before*` fired once with `input.id` present-but-`undefined`, so "no id" meant "this call stands for N rows". Every guard written on it silently inverted rather than failing. Adds `HookContext.dispatch` — `{ mode: 'record' | 'per-row', index, scope }` — bound by the engine at every write dispatch site (insert, update, delete, both phases), at the point the dispatch ladder is decided. Optional, and an absent marker reads as "not per-row", so existing handlers keep their behaviour. `scope` is one object shared by every dispatch of one write across both phases: the seam handlers used to get by stashing on the context, which only ever worked because a single-id write reuses one context across its pair. Deliberately not the `isPredicateBulkWrite` discriminator objectstack-ai#5574 retired under ADR-0049: that one inferred "bulk" at the consumer from `input.id` and `options.multi` and ended with no producer and no reachable consumer. This one is engine-produced and has readers. Behaviour fixed: - plugin-sharing — the `before*` stash of a write's affected row set was landing on a per-row context the `after*` phase never saw, so every bulk update or delete on a ruled object revoked all of that object's rule grants and queued a full asynchronous re-grant, once per matched row, with the repeats racing each other. Access was never widened; a bounded write now takes the bounded path again, the cap still applies to the union, and the `after*` work runs once per write instead of N times (the bounded branch was quadratic in batch size). - service-storage — the `beforeDelete` id pre-resolution was dead on every path and `afterDelete` was doing one `sys_file` lookup per row where the batch fits one `$in`. The pre-resolution query is gone entirely: the engine has already matched the rows. `beforeUpdate` copy-on-claim no longer runs per row against a batch-scoped payload, removing a row-conditioned rewrite of a shared SET clause (out of contract under ADR-0058 Addendum II D3). Stale comments in both packages asserting that predicate writes never populate `input.id`, and that the engine reuses one `HookContext` across a write's before/after pair, are corrected where they sit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EcLMsZRoR3daV3QzPXgwxK * docs(service-storage): name the follow-up issue at the afterUpdate ownership note (objectstack-ai#6966) The comment said the bulk-update file-ownership hole was "filed separately"; it is objectstack-ai#7102. A pointer a reader can follow beats a promise they cannot check. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EcLMsZRoR3daV3QzPXgwxK * test(objectql): pin the marker as an invariant, not just path by path (objectstack-ai#6966) `HookContext.dispatch`'s JSDoc claims every dispatched context carries it — which is what makes `ctx.dispatch?.mode` safe to read with no "what if the engine did not bind it" branch. The per-path cases prove the four write paths; this proves the claim itself, and covers the one context a reader might worry about: update()/delete() keep a batch-scoped `hookContext` on the predicate path, and no handler ever sees it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EcLMsZRoR3daV3QzPXgwxK * docs(spec): regenerate the hook reference page for HookContext.dispatch (objectstack-ai#6966) `content/docs/references/data/hook.mdx` is generated from the Zod schema, so the new key has to land there too — `check:docs` is what caught it. The row renders all three members inline (three keys, under the renderer's four-key limit), so it carries no `…` elision and none of the `any` trap the `roles` tombstone note warns about. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EcLMsZRoR3daV3QzPXgwxK * fix(plugin-sharing): an empty per-row union reads as unbounded, not as "no rows" (objectstack-ai#6966) The accumulator only exists because a `before*` dispatch created it, so an empty one means every id it was handed was null — "we do not know", not "nothing changed". Reading it as an empty row set would silently skip the cleanup entirely, which is the direction objectstack-ai#4757 was filed for and the rule this module states for its resolve path. Adds direct unit cover for the accumulator: the union across rows without re-querying the predicate, dedup across the two subscribers that both stash on every row, the empty-union verdict, and the cap applied to the union. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EcLMsZRoR3daV3QzPXgwxK * chore(spec): record HookDispatch in the api-surface snapshot (objectstack-ai#6966) `HookDispatch` is a new public export, so the surface snapshot moves with it — 0 breaking, 1 added. Caught by CI's `check:api-surface`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EcLMsZRoR3daV3QzPXgwxK * chore(spec): regenerate api-surface and export-origins after merging main (objectstack-ai#6966) Two generated artifacts move with the merge: - `export-origins/` is new on main (objectstack-ai#4796) and had never seen `HookDispatch`. - `api-surface/` needed a real regeneration, not just my own entry. The merge of main into this branch produced a file byte-identical to THIS branch's side, silently dropping the five exports objectstack-ai#7123 added on main (`SEARCH_VIRTUAL_TYPES`, `foldAsciiCase`, `asciiCaseInsensitiveContains`, `asciiCaseInsensitiveRegexSource`, `isVirtualSearchField`). These paths carry `merge=os-regen` in .gitattributes precisely so a merge regenerates rather than picks a side; it did not here, and `check:api-surface` is what caught it. Regenerating yields the union — both main's five and this branch's `HookDispatch`. Read the export-origins diff as its gate asks: one line, `HookDispatch` under `src/data/hook.zod.ts`, the same origin as `HookContext`. Not a re-home, not a new dual-source. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EcLMsZRoR3daV3QzPXgwxK --------- Co-authored-by: Claude <noreply@anthropic.com>
Closes #6520
The other half of #5702. That change implemented
$icontainson the SQL family and correctly left the spec'sFILTER_OPERATORSalone; this one adds the operator to that array and gives every remaining evaluation face an arm — in one PR, because those two steps cannot be separated.The ruling chain
Maintainer ruling, 2026-08-08 (Option A — approved), quoted from the card:
The maintainer directive recorded on that comment is quoted verbatim and untranslated: 「你的建议全部接受」.
Census note, 2026-08-09 (#6993), quoted from the card:
Premise verification (on
origin/main@f5a9bc2f3)Every claim in the card re-measured before implementing; all held.
FILTER_OPERATORSlacks$icontainsfilter.zod.ts:1356, 15 members, no$icontains{$icontains}filter-operator-vocabulary.test.ts:60toEqual(['$icontains'])sql-driver.ts:1379-1382, Postgrestranslate(expr,'A..Z','a..z')on both sidesFILTER_OPERATORSfilter-refusal.ts:178new Set([...FILTER_OPERATORS])default:mongodb-filter.ts:590havingrefuses (CONDITION_OPERATORS)having-filter.ts:60false, a different classmatches-filter.tsevalOpdefault: return falseOne scope correction the card's estimate did not carry: item 3 says "add an arm to
compileScopedFilterToSql", but the echo-coverage test drives three compilers offFILTER_OPERATORS(compileScopedFilterToSql,NativeSQLStrategy,ObjectQLStrategy.generateSql) plusfilter-normalizer.ts'sMONGO_TO_CUBE_OP. All four are changed here; leaving any one out fails that test.Face inventory — a conclusion for every compile/evaluation face
Changed (under the sanctioned #5499 exception):
packages/spec/src/data/filter.zod.tsFILTER_OPERATORSadmits$icontains; staging docblock rewrittenfoldAsciiCase/asciiCaseInsensitiveContains/asciiCaseInsensitiveRegexSourcememory-driver.ts$regexfrom the ASCII pattern source, noiflagmemory-matcher.tsasciiCaseInsensitiveContainsmemory-analytics.tsicontainscube operator + predicate builder + SQL rowfilter-refusal.tsmongodb-filter.ts$regexwith the ASCII pattern and no$options; comparand rule on the walkhavinghaving-filter.tsasciiCaseInsensitiveContainscheck)matches-filter.tsasciiCaseInsensitiveContains+ the posture decision belowstrategies/filter-normalizer.ts$icontainsmaps to theicontainsleafread-scope-sql.tsLIKE, both sidesstrategies/native-sql-strategy.tsLIKE, both sides/analytics/sqlechostrategies/objectql-strategy.tsLIKE, both sidesscripts/check-driver-conformance.mjsAlready compliant — evidence, not assumption:
driver-sql,driver-sqlite-wasm,driver-turso(local + remote): implemented by drivers:$regex响亮拒收 +$icontains各后端实现(#4706 裁决 B 案 · 驱动半边) #5702/drivers(sql family): 文本算子的大小写折叠是「方言的」而非「契约的」——$contains在 SQLite 过折叠、$icontains在 PG/MySQL 过折叠 #6518. Unchanged here; their suites re-run green. This PR's fold is deliberately character-identical tosql-driver.ts's Postgres arm.packages/lint's RLS predicate rules: they consultisSupportedRlsExpression/compileCelToFilter, not the operator table — no operator vocabulary of their own to widen.spec/src/ui/view.zod.ts'sVIEW_FILTER_OPERATORS: a separate authoring vocabulary for view filters, not derived fromFILTER_OPERATORS. Untouched deliberately — the view authoring surface is objectui: FilterConditionField cannot author spec’s $icontains — the case-insensitive contains is unreachable from the filter UI #6337's lane, not this one.Out of scope, with reason:
$containsfamily folding the whole Unicode range on driver-memory's query path and driver-mongodb — that is drivers(memory, mongodb): the$containsfamily still folds case — the last two backends left on the wrong side of #4706 Q2 = A #6682, requirement 2 of the same case-set, and it is why both conformance DEBT rows survive this PR instead of being deleted.formula's silentfalsefor retired spellings ($regex/$options) — see the posture section.The #3948 probe — direction predicted first, then measured
PREDICTION, written before running: with the word list admitted and
memory-matcher.ts's arm reverted, the matcher'sdefault: breaktreats the operator as "no constraint", so the reference matcher should answer EVERY row — a silent widening, not a throw — and the new pins should go red on the row-set assertion rather than on a missing error.MEASURED on this branch —
git checkout origin/main -- memory-matcher.ts(nevergit stash:refs/stashis shared by every worktree of this repo, objectui#3430), then runmatch()over the conformance fixture, thengit checkout HEAD --to restore:The prediction held exactly, including the direction. The face does not refuse and does not error — it answers
truefor a row that plainly does not containacme, which is #5701's measurement reproduced on this branch rather than quoted from it. The gate accepts the operator becauseSUPPORTED_FIELD_OPERATORSisFILTER_OPERATORS, and the matcher'sdefault:armbreaks, which means "no constraint".So the loudness lives in the TESTS, not in the runtime:
memory-icontains.test.tsasserts exact row-id lists, so this state fails it on the ROW SET (9 ids where 2 were expected) — which is why those pins assert ids rather thantoThrow(). A throw-only assertion would have stayed green against a face that silently matched everything, and that is precisely the shape it would have missed.This is also the answer to "why one PR": there is no ordering in which the word list lands first and the failure is visible. It is not a refusal, not an error, and not a smaller row set — it is a permission-shaped widening that looks like a working filter.
Folding parity — same answers as SQL
Every face is driven over
FILTER_TEXT_ROWS— the conformance standard's own fixture — so the answers are comparable to the SQL family's by construction. Predictions written before the suites were run, from the contract rather than from the implementation:{ $icontains: 'acme' }1, 2ACME Corpmatches a lower-case comparand{ $icontains: 'ACME' }1, 2{ $icontains: 'café' }4onlyÉdoes not fold, soCAFÉmust NOT match (#4706 Q1 = A){ $icontains: 'CAFÉ' }3only{ $icontains: '100%' }5only%is not a wildcard{ $icontains: 'a_b' }7only_is not a wildcard{ $icontains: 'a.b' }9only.is not a regex metacharacter — the$regexdefect, restatedThe two non-ASCII rows are the load-bearing ones: a face that reached for
toLowerCase()or aRegExpwith theiflag answers3, 4to BOTH and is wrong twice. That is the only way to tell an ASCII fold from a Unicode one by row set.MEASURED on this branch, by running each face over the fixture — every prediction held, on every face:
driver-memory's query path and analytics face are held to the same row sets by
memory-icontains.test.ts(which asserts the two faces answer identically, case by case) and by the analytics face's own declared-operator probe; driver-mongodb bymongodb-icontains.test.ts, which executes the emitted pattern over the same fixture.On the SQL side the parity is structural rather than statistical — the emitted statement is character-identical in construct to
driver-sql's Postgres arm. Measured by callingcompileScopedFilterToSqldirectly on this branch:Both sides folded; the comparand still escaped and the
ESCAPEcharacter still bound, so the fold rides ON TOP of the literal-comparand rule rather than replacing it.translate()with an explicit 26-character domain is used rather thanLOWER()precisely because Postgres'LOWER()is locale-aware and would foldÉ.Additionally pinned per face, because a fold is easy to add and easy to over-apply:
$containsstays case-SENSITIVE on the faces that already had it right (the reference matcher,having,formula), and driver-mongodb's$containsfamily still emits its hardcoded$options: 'i'— pinned as the CURRENT state so that fixing #6682 and breaking$icontainscan never be confused for one another.The formula posture decision (census note)
Decision: keep the silent, fail-closed
falsefor unknown operators; documented inmatches-filter.ts's header. Reasons, in the order they carry weight:check: an unevaluable condition DENIES the write. Silence costs a diagnostic, not a boundary.plugin-security'sexplain-enginecalls it per record with nocatch, and several driver doubles use it as a list filter. Throwing turns a per-record verdict into an aborted operation on the read/explain path, which is not what a$icontainsparity PR should be deciding.FILTER_OPERATORSdeclares now has an arm, so the silent answer is reachable only for a name the protocol never declared or has retired.Left open on the record rather than folded in: a retired
$regex/$optionsstill gets the silentfalsehere while the other five faces print the prescription naming$icontains. That changes an accept/reject surface and belongs to the maintainer, not to a parity PR.filter.zod.ts's "The other JS evaluators sit on the refusing side too" sentence — which mis-described this face — is corrected.Downstream
#5814 (better-auth
Where.mode: 'insensitive') had its blocker cleared: an authentication query using$icontainsno longer 400s on the in-memory double.Verification
Gates and suites run locally on this branch (each build/test wrapped in the repo's shared
flockso parallel agents' memory peaks do not stack):check:driver-conformanceFILTER_TEXTrows retainedcheck:adr-0087-registrationcheck:nul-bytescheck:error-code-casing/check:route-envelopecheck:driver-memory-census/check:engine-double-contractcheck:wildcard-fallthroughcheck:spec-changes/check:upgrade-guidecheck:api-surfacegen:api-surface) — the gate reported0 breaking (removed/narrowed), 3 added; this is a SEPARATE snapshot fromcheck:export-originsand was initially missedcheck:exported-any/check:dual-source-exportscheck:export-originsgen:export-origins) — diff is exactly the 3 new exports, one origin each, no dual-sourcecheck:authorable-surfacecheck:docsgen:schema+gen:docs) — one line, the$icontainsdescriptionhaving-filter.ts, untouched by this diff)@objectstack/spec@objectstack/objectql@objectstack/service-analytics@objectstack/formula@objectstack/driver-memory@objectstack/driver-mongodbtypecheckon the five packages that declare oneservice-analyticsdeclares notypecheckscript)CI on this PR has fully converged: all 26 check runs green on head
cf3da3d(one skipped), including both gate jobs — ESLint (which is where this repo's family gates run, so a redcheck:*shows up there and nowhere in a localpnpm test) and TypeScript Type Check (which carriescheck:driver-conformance,check:api-surface,check:export-origins,check:docsand the workspace build/typecheck), plus Test Core 1-3, Build Core, Build Docs, Dogfood Regression Gate 1-3, Dogfood Verify CLI and Temporal Conformance (live PG + MySQL).Three gates fired during development, and all three were right
Recorded because a gate that catches you is evidence the gate works, and the first two of these are the mechanism this PR is about:
check:driver-conformance— RECONCILED. The first version of the driver-memory suite mentioned the text case-set's marker export in its docblock prose. The gate matches an import span loosely, read that as "this driver now runs the case-set", and failed because a ledger entry for a covered cell is an error. Correct: requirement 2 of that case-set is still open here (drivers(memory, mongodb): the$containsfamily still folds case — the last two backends left on the wrong side of #4706 Q2 = A #6682), so this driver must NOT claim the whole set. Fixed by drivingFILTER_TEXT_ROWSand never naming the marker; both DEBT rows survive, each now naming one open requirement instead of two.memory-driver-filter-logic-conformance.test.tskeys a probe map by the analytics face's OWN declared vocabulary and fails when a declared operator has no probe. Adding$icontainstoMONGO_TO_CUBE_OPERATORturned it red immediately — which is exactly the guarantee driver-memory analytics 面的$notContains编译成裸 mingo{$not: 'x'},该谓词不约束任何行 —— 结果被放大到全表 #5374 bought. The probe added for it uses an UPPER-case comparand ('BET') against a lower-case fixture row (beta), so it only selects a row once the fold actually runs; a lower-case comparand would have agreed across the two faces for free even if neither folded, the free-agreement certification that block's own header warns against.check:api-surface— the SECOND export snapshot. Regeneratingexport-origins/is not enough:api-surface/is a separate artifact tracking the public NAME SET (plus factory signatures), and it went red on CI with0 breaking (removed/narrowed), 3 added. Fixed withgen:api-surface; the delta is exactly the three additive./dataexports. Recorded because "I regenerated the export snapshot" is precisely the reasoning that misses it — there are two, and adding an export touches both. The rest of that family (check:exported-any,check:dual-source-exports,check:docs,check:authorable-surface,check:skill-refs,check:react-blocks) was then swept in one pass rather than one CI lap at a time, and is green.Out-of-scope finding filed
generateSql()renders the LIKE family with NO wildcards, so the echoed statement is an EQUALITY the pipeline never ran #7117 — driver-memory's analyticsgenerateSql()renders the LIKE family with no wildcards, so the echoed statement is an equality the pipeline never ran. Pre-existing, affects$contains/$notContains; theicontainsrow added here is consistent with its neighbour and inherits the same defect (it was added so the operator would not fall to the|| '='fallback, which would be strictly worse).Generated by Claude Code