Repository navigation
feat(spec): FILTER_TEXT_CASES declares what a text operator answers over a stored non-string value, and every face answers it (#14079) - #15686
Conversation
…ll; every face answers it Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M59rPZZFzqhfMUPFqqZTkf
…generate spec artifacts Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M59rPZZFzqhfMUPFqqZTkf
📓 Docs Drift CheckThis PR changes 6 package(s): 11 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 134 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 f1dd1846b60a56049ef7ce69ef270257c25de5f8 && git checkout f1dd1846b60a56049ef7ce69ef270257c25de5f8
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 95d5cbb31687558ac63dfdde90950d06f82820eb 29903ee1d0baf653044e0f4e8c42318df26be55e && git checkout -B drift-repro 95d5cbb31687558ac63dfdde90950d06f82820eb && git merge --no-ff 29903ee1d0baf653044e0f4e8c42318df26be55e
node scripts/docs-audit/affected-docs.mjs --json 95d5cbb31687558ac63dfdde90950d06f82820eb
|
|
CI red on
Generated by Claude Code |
…ct call without a target or context The declared-type gate applies only when both are handed in; the coverage suite's four-argument direct call keeps its LIKE instead of throwing on target.object. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M59rPZZFzqhfMUPFqqZTkf
Fixes #14079
Maintainer ruling recorded on the card (comment
5548481659, decision batch #39, option A): a stored value that is not a string never satisfies a positive text operator and satisfies$notContains— complementarity holds, on every face. This PR is that ruling landed as one contract row set that every face answers. Option C (a declared-type door) is deferred to its own decision card, which the PM files; nothing here builds it and nothing here forecloses it.CORRECTION — patch lap, head
29903ee1d(⛔ not a quiet edit of the readings below)What went wrong. The first lap's service-analytics reading (9 targeted files, 265 tests, at
e92f5a89a) was a narrowing that was not declared as one. It did not includesrc/__tests__/objectql-echo-operator-coverage.test.ts, which reaches theObjectQLStrategyoperator renderer DIRECTLY with the four-argument shape it has always had (buildFilterClauseSql('stage', operator, ['w'], []), through a local mirror of the private signature). Lap one madetargetandctxREQUIRED on that renderer, so the direct call handedundefinedand the[#14079]gate threwTypeError: Cannot read properties of undefined (reading 'object')ontarget.object. CI:Test Core (4/6)(run 33940680441, job 101237407118) andTemporal Conformance (live PG + MySQL)(job 101237406991) both red one92f5a89a, one root cause —Test Files 1 failed | 89 passed (90),Tests 1 failed | 1982 passed (1983). Reproduced locally before the fix with the WHOLE package:pnpm --filter @objectstack/service-analytics testunder the lock,VERDICT command-exit 1, the same one failure atobjectql-echo-operator-coverage.test.ts:388.The fix (producer side,
29903ee1d).targetandctxare optional onbuildFilterClauseSql; the declared-type gate applies only when both are handed in. A caller that hands no target cannot be asked the declared-type question and keeps theLIKEit always got — the "cannot answer, do not block" posturenonTextColumnResolveralready takes for a context with no hook.renderFilterNodeSqlstill always passes both, so every statement the echo prints through its public door is unchanged from lap one. The coverage suite was NOT edited: its contract (a four-argument direct call, every emittable operator renders a predicate) is genuinely unchanged. No other direct caller ofbuildFilterClauseSql/renderFilterNodeSql/buildFilterClauseexists in src or tests (git grep over the package; the remaining mentions are CHANGELOG prose and comments). The posture is pinned intext-operator-non-text-column.test.ts: the renderer called without a target keeps itsLIKEand binds the escaped pattern.Readings now, at
29903ee1d. Whole package:pnpm --filter @objectstack/service-analytics testunder the lock —VERDICT command-exit 0 · held the lock 70s,Test Files 90 passed (90),Tests 1984 passed (1984)(1983 + the new pin).typecheck(service-analytics) exit 0. Targetedeslint --no-inline-configover the two patched files: 0 errors, 0 warnings. Gates re-derived withnode scripts/pm/dispatch-gates.mjs(no paths) at29903ee1d: 41 named (the first lap's 37 pluscheck:driver-memory-census,check:refd-timer-probe,check:watch-hint-literal,check:nul-bytes); every one exit 0 —check:doc-formula-expressionsfirst answered exit 3 (Nothing was measured:@objectstack/linthad no dist in the re-created worktree), re-run green after building that package's closure — exceptcheck:dual-build-cjs-loads, stillPREREQUISITE NOT MET(no package scope; 50 packages unbuilt locally) and therefore NOT MEASURED here, declared to CI as on lap one.What landed
@objectstack/spec—FilterTextRowgains its first non-string column,score: number(a0among the nine values, so a truthiness guard is caught too). Five new evaluatedFILTER_TEXT_CASESrows over it:$contains/$startsWith/$endsWith/$icontainsanswer[],$notContainsanswers all nine.NON_TEXT_STORED_VALUE_TYPES(field-value.zod.ts) — the numeric and boolean value classes — is the one list the SQL faces classify a column by, since they cannot read the value at compile time. The spec's own reference evaluator reads the filter's field and type-gates; a coercing evaluator is proved to fail exactly the five new rows.@objectstack/driver-memory— the reference matcher's$notContainsarm answers the predicate (typeof value === 'string' && value.includes(target)fails the row) instead of the type test that failed both polarities. The no-value cells keep their driver-memory's reference matcher still answers $notContains / $nin the pre-ruling way on a no-value row — the #5499 freeze that excused it dissolved 2026-08-11, so the divergence is now unexcused and untracked #13166 answer.@objectstack/driver-sql—SqlDriver.isNonTextColumnreads thenumericFields/booleanFieldsregistriesinitObjectsandregisterExternalObjectalready fill;applyFilterConditionroutes the seven text operators over such a column toapplyTextOperatorOverNonTextColumn, which emits1 = 0for a positive operator and1 = 1for$notContains— after every comparand refusal, before either emitter, on every dialect.driver-sqlite-wasmand turso local inherit it.@objectstack/driver-turso— the remote transport keeps no schema, soTursoDriverhands it the same rule (setNonTextColumnResolver, the shape of the existing temporalsetFilterColumnSql), answered from the registriesregisterRemoteFieldMetadatafills at schema sync; each text arm asks it after its own comparand gate.@objectstack/service-analytics—compileScopedFilterToSql(filter, alias, options?)takes an optionalnonTextColumn(field)predicate;DatasetScopedStrategyContext.declaredFieldTypeexposes the field type the service already holds (sourceFieldMeta); both strategies pass it for the RLS read scope, and (declared in-place addition, see below)NativeSQLStrategy.buildFilterClauseand theObjectQLStrategyecho apply the same test to the query's own text filters, so a query and its scope answer one cell one way and the echo prints the statement that ran. A host that wires no field metadata keeps theLIKEit always got.@objectstack/objectql—having-filter.ts:415-422: the sentence "which driver-sql's polarity table already follows for the same operator" is corrected to what is true (the polarity table covers the no-value cell only; the non-string cell is now the contract row every face answers). Comment-only; no changeset.initObjectsDDL included); generated followersapi-surface/data.jsonandexport-origins/data.jsonregenerated bycheck:generated --fix(one added export each).Compile-surface declaration — every face, one conclusion
driver-sqlapplyFilterCondition/textMatchPredicate/likePatternPredicatesql-driver-text-case-conformance.test.ts: the five rows on the sqlite cell AND the live PostgreSQL 16.13 cell; compiled-shape pins on sqlite / pg / mysql probes (where 1 = 0,where 1 = 1, no LIKE / GLOB / CAST built against the column);$notcomposition; comparand refusals still ahead of the constant; an unregistered table keeps its LIKEdriver-sqlite-wasm(inherits)sqlite-wasm-icontains-and-retired-operators.test.tsdrives the whole table on sql.js withscore REALdriver-tursolocal (inherits)turso-local-remote-text-parity.test.tsdriver-tursoRemoteTransport.buildWhereSQL(pushLike/pushLikePattern)turso-local-remote-text-parity.test.ts: the five rows on both transports, LOCAL vs REMOTE compared first;$like/$ilikepins overscoreon bothservice-analyticsread-scope-sql.tscompileScopedFilterToSqlnonTextColumnpredicate;TRUE_CLAUSEadded besideFALSE_CLAUSEtext-operator-non-text-column.test.ts: compiled text (1 = 0/1 = 1,NOT (("t"."score" IS NOT NULL AND 1 = 0))), params alignment beside a text column, no-option byte-identity, comparand refusal ahead of the constant; executed throughapplyReadScopeon sql.jsservice-analyticsfilter-normalizer.tslowerAnalyticsWhereFilterCondition; its SQL emission isNativeSQLStrategy.buildFilterClauseplus the echo, which are changed (in-place addition, below)NativeSQLStrategy.generateSqlon sql.js over a REAL column; the echo prints the same constantformulamatchesFilterConditiontypeof actual === 'string'guards on every positive arm,!(…)on$notContains(measured on the card,5542868802)matches-filter-icontains.test.tsnow drives the table's fivescorerows through it, plus$like/$ilikeand a booleanobjectqlhaving(applyHaving/matchesHaving)typeof value === 'string' && value.includes(target)on$notContains(#5905); docblock sentence correctedhaving-filter-text-conformance.test.tsdrives the whole table (rows spread the new column)driver-memoryreference matchercheckConditionmemory-filter-text-conformance.test.ts: whole table on the query path, the matcher, and face agreement; the$notContains-over-scorecase named as the one legitimate whole-set answer in the WIDENS pin;$like/$ilikepins on both facesdriver-memorylive mingo path / analytics facescoreasnumber, expressible subset 12 → 15driver-mongodbtranslateFieldOperators{ $regex }is string-only in MongoDB and{ $not: { $regex } }inverts itmongodb-filter-text-conformance.test.tsdrives the five rows through its strict in-process matcher with zero code change; real mongod NOT MEASUREDWhich faces ran against a real engine, and which are modelled
/usr/lib/postgresql/16,OS_TEST_POSTGRES_URLon port 54329) for the driver-sql conformance suite's live cell — 26 tests on that cell including the five non-string rows; its statement log showsselect * from "os6518_text_case" where 1 = 0and… where 1 = 1against a"score" realcolumn,"name" LIKE $1 ESCAPE $2beside them, and zero SQLSTATE 42883 — the 500 became a result; driver-memory's three faces; formula; having.mysql2probe:where 1 = 0/where 1 = 1, noCAST); a real mongod (proxy blocks the binary; the emitted document is evaluated by the suite's strict matcher, the same substitute that package states); a Turso cloud endpoint.The three hypotheses on the dispatch
driver-sql(the registries) and was falsified as stated for the other two faces, then resolved without a silent coerce:RemoteTransportkeeps no schema (syncSchemareads one and retains nothing) — the driver injects the rule, the shape the temporal seam already uses;compileScopedFilterToSql(filter, alias)had no schema and the spec'sStrategyContextcarries none — the service already holdssourceFieldMeta, now exposed as the package-localdeclaredFieldTypehook (declared onDatasetScopedStrategyContext, not on the spec contract, for the reasongetDatasetScopeis).$ne/$nin/$notContains:driver-sql 排除 NULL 行,driver-memory / formula 返回它们(#5146 只裁定了$not) #5298 null polarity ($notContainsadmits a NULL row already) and with the$notrewrite (NOT (col IS NOT NULL AND 1 = 0)is TRUE everywhere;NOT (col IS NULL OR 1 = 1)is FALSE) — pinned on driver-sql (pg probe), read-scope-sql and the native strategy (executed).having-icontains.test.tsspreads its ownncolumn over the rows (untouched);search-filter.test.ts,filter-ascii-fold.test.ts,filter-comparand-type-conformance.ts,scripts/check-driver-conformance.mjsandfilter.zod.tsreference the table by name only and were not edited (no sentence infilter.zod.tsbecame false — its docblocks describe folding and literalness).Judgment singled out for review, with its rollback
The ruling names
read-scope-sql; the analyticswhereface (NativeSQLStrategy.buildFilterClause+ theObjectQLStrategyecho) is the dispatch's compile surface (4) and was fixed in place under the bounded exemption: same defect class (aLIKEover a numeric dimension coerces on SQLite and is refused on Postgres), mechanical with the shape pinned by the ruling, no other claim on those files (PM's 01:49Z in-flight check), same gate family. Without it a query'swhereand its RLS scope would answer one cell two ways on the same request. Rollback: revert theif (shape) {gate innative-sql-strategy.ts, theif (like) {gate plus thetarget/ctxparameters andresolveStorageTargetinobjectql-strategy.ts, and the twononTextColumnResolver/textOperatorPolarityimports; the read-scope option, the hook andnon-text-column.tsstand on their own.Deviations from the letter of the ruling, stated
FILTER_TEXT_CASESrow" landed as five evaluated rows — one per operator the table can carry — because one case holds one filter and both polarities must be pinned.$like/$ilikeare NOT rows in the shared table: a driver's enrolment is the whole table (rule 2 of its header) anddriver-mongodbrefuses those two operators. They are pinned per face that answers them (driver-sqlsql-driver-like-pattern.test.ts, driver-memory, driver-turso both transports, formula).Pin sweep (repo-wide, both sentences)
Grepped every
*.test.ts/*.testkit.tsfor text operators besidenon-string/numeric/typeof/ a numeric comparand. Found and flipped:driver-memory/src/memory-matcher-no-value-negated-operators.test.ts("a present non-string value still fails $notContains on the type test",toEqual([])) — now asserts the row IS in the negation, is NOT in the positive twin, and that0/falsebehave the same. Kept verbatim (already the ruled direction):objectql/src/having-icontains.test.ts:65(positive over a number answers[]),formula/src/matches-filter-like.test.ts:69(positive$likeover a number answers false). Unchanged and green:driver-memory/src/memory-driver-filter-logic-conformance.test.ts(temporal-column text pins),service-analytics/src/__tests__/native-sql-datetime-filter-column.test.ts.Test readings, per consumer package, at
e92f5a89a(one lock hold, after the final commit)@objectstack/spec@objectstack/driver-memory@objectstack/driver-sql(sqlite + live PostgreSQL 16.13)@objectstack/driver-sqlite-wasm@objectstack/driver-turso@objectstack/driver-mongodb@objectstack/objectql@objectstack/formula@objectstack/service-analyticsAlso: the whole
@objectstack/driver-sqlpackage once on SQLite (154 files, 2377 passed, 140 skipped).typecheckgreen on spec, driver-memory, driver-sql, driver-turso, service-analytics, objectql, formula (spec'scheck:test-typecheckincluded). Targetedeslint --no-inline-configover the 23 changed.tsfiles: 0 errors, 0 warnings (a targeted run, not the repo-wide gate — CI owns that one).Gates (derived with
node scripts/pm/dispatch-gates.mjs, no paths, atf4f2d0c90; re-run ate92f5a89a)All 37 derived gates green (17
@objectstack/spec/@objectstack/lintgates includingcheck:api-surface,check:export-origins,check:generated— "All 15 generated artifacts are up to date" —check:docs,check:liveness,check:exported-any; 20 repo-level gates includingcheck:driver-conformance,check:cross-package-test-inputs,check:test-source-alias,check:merge-driver,check:changeset-gate-self-tests), pluscheck:nul-bytes. One derived gate is NOT MEASURED locally:check:dual-build-cjs-loadsanswersPREREQUISITE NOT MET(it reads every package's dist and 50 packages were not built here; it has no package scope) — CI runs it after a full build.Changesets
@objectstack/specminor ·@objectstack/driver-memorypatch ·@objectstack/driver-sqlminor ·@objectstack/driver-tursominor ·@objectstack/service-analyticsminor — each with the ruled note that a text operator over a non-text column now answers the declared row instead of a dialect accident (Postgres: a 500 becomes a result).Out of scope, filed unassigned (both remain open)
LIKEfor the case-sensitive$containsfamily, which folds ASCII case on SQLite (measured throughNativeSQLStrategyon sql.js); the read scope admits rows the contract excludes.🤖 Generated with Claude Code
Generated by Claude Code
Generated by Claude Code