fix(spec,service-analytics): the matrix's token axis + its sixth consumer, and the dropped $between it found (ADR-0053 D-A3.1) - #4109
Conversation
…atetime backends (ADR-0053 D-A3) (#4081) The temporal seam broke four times (#3650 window dropped entirely, #3773 epoch-as-Julian-day NULL buckets, #3777 bare-day upper bound losing the final day, #4047 type-bracket comparison returning nothing on memory/mongo), and each break was found by accident: every fix left a suite proving its own issue against its own fixture, with no shared standard to hold the six evaluation surfaces together. This adds the standard — the temporal twin of filter-logic-conformance (#3774), and the one ADR-0053 decision (D-A3) that had never been built: - `packages/spec/src/data/temporal-conformance.ts` exports `TEMPORAL_CONFORMANCE_ROWS` (one fixture spanning exact-midnight, intra-day, next-midnight, month-end last-millisecond, leap-day and pre-epoch instants, each tagged with a writer form for the D-E4 mixed-writer axis) and `TEMPORAL_CONFORMANCE_CASES` ({ name, fieldType, operator, filter, tokenFilter?, dateRange?, expected, note }) — field-type × operator × bound-semantics (D-D2) × relative-token cells, asserting row-id sets, never emitted SQL. Each note names the incident the case guards. - Six thin consumers: driver-sql (canonical + un-backfilled legacy storage; live PG/MySQL via the existing CI temporal job), driver-sqlite-wasm (inheritance guard), driver-memory and driver-mongodb (mixed-writer seeds), formula's matchesFilterCondition (RLS write-side check), and the analytics preview evaluator (where + timeDimensions.dateRange, the #3650 surface). - The token axis runs end-to-end: consumers that can reach @objectstack/core resolve each case's tokenFilter/dateRange against the pinned TEMPORAL_CONFORMANCE_NOW and must land on the same rows as the literal spelling, so resolver drift and evaluator drift are distinguishable at a glance. Deliberate scope, same discipline as the filter-logic table: a case belongs here only if every backend must agree on it. The schema-aware-only cells (bare-day $eq/$in on datetime, $gt at an exact stored midnight, Field.time) stay pinned in the per-driver suites and are documented as exclusions. Closes #4081. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqqZmPS5a4gJGBoCTwipFr
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
…n top Main landed its own D-A3 matrix (#4098: TEMPORAL_ROWS/TEMPORAL_CASES + the preview $between fix) while this branch was in flight. Resolution keeps main's landed table, naming and consumers as the base, and re-applies this branch's increments — exactly the coverage #4098's ADR addendum recorded as still open: - the relative-token axis: TemporalCase.tokenFilter/dateRange + TEMPORAL_NOW, resolved via @objectstack/core resolveFilterTokens in the five core-reachable consumers (formula stays literal-only by dependency stance) - TemporalRow.writerForm: mixed writer-population seeding (D-E4) in the sql/wasm/memory/mongo consumers - a pre-epoch fixture row, and the date equality/$in cells (#1874's shape) - a driver-sqlite-wasm consumer pinning the inherited SqlDriver seam - a legacy-storage sweep in the driver-sql consumer (un-backfilled mixed epoch/naive column through the read-repair path) The duplicate preview consumer from this branch is dropped in favour of main's preview-temporal-conformance.test.ts, extended with the token and timeDimensions.dateRange sweeps; this branch's changeset is rewritten as the increment (.changeset/temporal-conformance-token-axis.md) alongside #4098's. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqqZmPS5a4gJGBoCTwipFr
📓 Docs Drift CheckThis PR changes 2 package(s): 108 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
…$between it found (ADR-0053 D-A3.1) NativeSQLStrategy is the surface #3650 was actually about, and it was listed in the conformance matrix's own backend table with no consumer — because every existing suite for it asserts the emitted SQL string, and a DROPPED predicate is invisible to a string assertion: the SQL stays valid, just wider. Executing the shared cases against a real engine (sql.js, the same pure-WASM engine driver-sql falls back to) and asserting row ids is what D-A3 asked for, and it immediately showed `$between` returning the entire table. The cause sat one layer below the strategy, in the shared filter-normalizer: `$between` was absent from MONGO_TO_CUBE_OP, and an unmapped operator is `continue`d, so the predicate vanished from the compiled WHERE clause. Both strategies read that normalizer, so the ObjectQL aggregate path was affected too. User-visible symptom: a widget with a range filter charts the whole dataset, with nothing in the SQL to suggest a filter was ever requested. `$between [min, max]` now LOWERS to its two bounds (gte + lte) rather than gaining an operator of its own, so a range's max inherits the calendar-day whole-day rule (#3777) from each strategy's existing upper-bound handling — NativeSQLStrategy compiles a bare-day upper bound half-open itself, the ObjectQL path gets the same rule from the driver — instead of needing a second implementation to keep in step. That is how #4098 closed the same defect on the preview evaluator. A malformed `$between` throws rather than being dropped, the stance driver-memory took for the same shape in #3948. The consumer runs the literal, relative-token and timeDimensions.dateRange spellings of every shared case. Rows are seeded canonical, where the driver's temporal hooks are identities, so the context omits them and exercises the same "absent = identity" path Postgres takes (D-A2); the un-backfilled mixed column stays covered where the driver truth lives. The cause itself is NOT fully closed: `$startsWith`, `$endsWith`, `$null` and `$regex` are still silently dropped by the same `continue`. Filed as #4128 rather than expanded into this change, with the #3948 precedent for turning the fallback into a throw. service-analytics 391 green (was 360; +31 from the new consumer). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqqZmPS5a4gJGBoCTwipFr
The module doc described the shape it produces but never what it actually maps — and in a normalizer, that omission is load-bearing: an unmapped operator is `continue`d, which does not "not support" the predicate, it DELETES it, widening the query to rows the author excluded. That is how the $between defect survived (see #4128 for the four operators still in that state, and the #3948 precedent for making the fallback throw). Mirrors the scope comment the preview evaluator carries for the same reason. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqqZmPS5a4gJGBoCTwipFr
…query (#4128) Closes the CAUSE behind the $between defect, not just that instance. `normalizeAnalyticsFilters` skipped any operator missing from its map, and a skipped predicate does not narrow a query — it widens it: the SQL stays valid and returns rows the author excluded. Four operators from the spec's authorable vocabulary sat in that state, and a fifth was mapped wrongly: - $startsWith / $endsWith were dropped. Both strategies now compile them — anchored LIKE on the raw-SQL path, the canonical operators (which every driver implements directly) on the ObjectQL path, so an anchored match does not depend on regex dialect. - $null was dropped. It is what the console emits for an "is empty" filter, so such a widget showed every row. - $exists was mapped value-INDEPENDENTLY to `set`, so {$exists: false} compiled to IS NOT NULL — the inverse of what it asks. It and $null are now resolved explicitly: a key→name map cannot express an operator whose meaning flips with its value, which is exactly how that inversion got in. - $notContains reached ObjectQLStrategy, which had no arm for it and fell to a `default` returning a bare value — compiling "does not contain x" as "equals x". - Unknown operators now THROW on both surfaces rather than being dropped (normalizer) or reinterpreted as an equality (ObjectQL). #3948's call for the same shape. $or / $not remain skipped — expressing them needs a recursive WHERE builder rather than the flat array the strategies consume. That gap is declared in the module doc rather than silent. filter-operator-coverage.test.ts runs the whole vocabulary against a real SQLite engine and asserts ROW IDS; six of its cases fail without this change. A dropped predicate is invisible to the SQL-string assertions the strategies' other suites use, which is how these survived. service-analytics 413 green. Closes #4128. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqqZmPS5a4gJGBoCTwipFr
|
CI 说明:Lint & Type Check 的红不是本 PR 引入的,在 base 分支上同样失败。 失败的其实不是 ESLint 步骤,而是同一 job 里的 在纯净的 成因是两个 PR 的竞态:#4112 删掉了那个挂载,#4116 在守卫账本里声明了它,先后合入未互相 rebase。已单独立项 #4140(含复现步骤与建议的销账式修法)。 本 PR 的其余检查照常,待 main 恢复后我会重跑并推进合并。 Generated by Claude Code |
…rature-consistency-akyja5
The temporal conformance matrix (#4109) gives every case a `tokenFilter` spelling, and the driver + analytics suites resolve it before asserting. This suite runs only the literal `filter`, with nothing saying why — so the absence reads as an omission, and the next person either "fixes" a non-gap or assumes coverage that cannot exist. It is architecture: an RLS `check` is a CEL expression (`check` is a string, compiled by rlsCompiler.compileFilter) where a relative date is the function today() evaluated at compile time. A {token} string never reaches this evaluator — resolveFilterTokens runs on the ObjectQL read path and the write-side check does not go through it; verified that plugin-security never calls the resolver. Comment only; the suite is unchanged and still 13/13. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EkL3RGJrzjLEGURsLxfS2f
#4081(ADR-0053 D-A3)的补完。矩阵本体已由 #4098 先行落地;本 PR 与其合流,补上 #4098 的 ADR 附录里明确记为仍然开放的两处覆盖,并修掉补覆盖时矩阵当场抓到的缺陷——以及顺着成因挖出的一整类。
一、relative-token 轴(#4098 附录记为开放)
四次事故都发生在 token 解析之下,但作者实际书写的就是
{today}/{90_days_ago}/{current_month_end},而没有任何测试证明解析后的比较值在各后端落到同一批行。TemporalCase新增tokenFilter?与dateRange?,加上钉死的参考瞬间TEMPORAL_NOW。@objectstack/core的消费者把 token 拼写解析后必须落在与字面量拼写相同的行集——resolver 漂移与 evaluator 漂移由此可分。formula依赖刻意止于 spec,只跑字面量。TemporalRow.writerForm(D-E4 混合写入者播种)、负 epoch 行、date的相等与$in([P2] Provide a declarative time-relative trigger (avoid fragile date-equality on record-change) #1874 起源形态)、driver-sqlite-wasm继承护栏、driver-sql未回填 legacy 双扫。二、第六个消费者:
NativeSQLStrategy它正是 #3650 事发的那个面,也是唯一手工拼 SQL、不走驱动编译器的后端,却在矩阵的后端表里没有消费者。原因很说明问题:它现有的套件断言的都是 SQL 字符串,而谓词被丢掉对字符串断言完全隐形——SQL 依然合法,只是更宽。新消费者用真实引擎(
sql.js)执行并断言行 id。三、它当场抓到的缺陷:
$between谓词凭空消失首跑即红:
$between返回全表。成因在共享的filter-normalizer——MONGO_TO_CUBE_OP里没有$between,未映射的算子被continue掉,谓词直接从 WHERE 消失。两条策略都吃这个归一化器,所以都中招。用户侧症状就是 #3650:带区间过滤的图表画出整个数据集。修法:
$between下降为它的两个界(gte+lte),上界自动继承各策略已有的整日日历规则,不需要第二份实现——与 #4098 修 preview 的做法一致。四、收口成因本身(Closes #4128)
只修
$between等于只修实例。按同一把尺子把整个可授权词汇过了一遍,又挖出两处比「丢弃」更糟的——语义被悄悄写反:$startsWith/$endsWithLIKE 'x%'/LIKE '%x';ObjectQL 侧透传规范算子,锚点不依赖 regex 方言$nullIS NULL/IS NOT NULL$existsset,{$exists: false}编译成IS NOT NULL——与语义完全相反$null一起显式解析:键→名的映射表表达不了「含义随取值翻转」的算子,这正是反转的成因$notContainsObjectQLStrategy没有 case,落到default: return v0返回裸值——「不包含 x」被编译成「等于 x」$notContainscontinue丢弃;ObjectQL 层当成相等$or/$not仍跳过(需要递归 WHERE 构建器),但这一条现在写进模块文档作为已声明的缺口,不再是隐性的。验证(本地全绿)
两个新套件都用
git stash实测过修复前的行为:$between4 例红、算子覆盖 6 例红。mongodb 套件本沙箱无二进制而 skip,CI 真实执行。spec 八个生成物 gate 全 PASS。Refs #4081,Closes #4128。
🤖 Generated with Claude Code
https://claude.ai/code/session_01TqqZmPS5a4gJGBoCTwipFr