feat(driver-mongodb): native dateGranularity lowering ($dateToString → engine bucket labels) + publish supports.queryDateGranularity - #7590
Conversation
…bucket labels, and publish supports.queryDateGranularity (#7580) `driver-mongodb` published no `queryDateGranularity`, so `engine.aggregate` bucketed every granularity in memory: correct answers, but the whole result set shipped to the client before the rollup. #7550 refused a bucketed node at the builder rather than implement or silently drop it, and sized the native lowering as a card of its own. This is that card. The bucket LABELS are the engine's own spellings, because the engine picks between the pushed-down and in-memory paths per query and a drill-down can cross that seam. All five declared granularities are advertised — `week` included, where driver-sql on SQLite cannot, because `$dateToString` has both halves of the ISO-8601 week date (`%G`/`%V`). No `$dateTrunc`: it answers a truncated DATE that still needs formatting, raises the server floor to MongoDB 5.0, and adds binSize/startOfWeek semantics this fleet cannot observe. `$dateToString` answers the label directly out of one operator, and fewer unobserved operators is the whole argument. All three ADR-0053 storage forms are served through one total expression: `$convert … onError/onNull: null` mirrors `bucketDateValue`'s totality, so a BSON Date, `YYYY-MM-DD` text, `HH:MM:SS` text, null, missing and unparseable junk all bucket the way the engine buckets them. The capability record and the builder's refusal read ONE constant, so an advertised granularity the builder would refuse cannot exist — the failure worse than advertising nothing, since the engine stops bucketing in memory on the strength of the bit. Parity is proven by running the SAME rows through the real `applyInMemoryAggregation` and through the emitted pipeline. driver-mongodb can depend on objectql where driver-sql cannot (objectql depends on no driver), so this is a devDependency rather than the hand-copied `bucketDateValue` the three SQL bucket suites carry, and the drift their `⚠️ Keep in sync` comments admit they cannot detect does not exist here. The strict in-process evaluator moved to `mongodb-pipeline-evaluator.testkit.ts` so both suites share one reader; only the date operators were added. It models the manual independently — its own ISO-8601 grammar, `%G`/`%V` from the standard — so the two sides CAN disagree and the comparison means something.⚠️ Nothing here has met a real mongod (#5517: proxy 403 on the binary). The bound is written into the suite header, onto the published capability, and beside the lowering. Fixes #7580 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019QkW7hhVvhTxh4V9Wmnqdy
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 5 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also reference the affected code. These are read-only:
|
Fixes #7580
Maintainer-ordered card (direct route, 2026-08-11 — verbatim in the issue's Provenance).
driver-mongodbis unfrozen per #5499 comment5249019855;driver-memorystays frozen and is untouched, and thedriver-sqlbucket suites were read as precedent only.What changes — and what deliberately does not
driver-mongodbnow lowers adateGranularity-bearinggroupByinto the aggregation pipeline and publishes the capability, soengine.aggregatepushes a bucketed aggregate down instead of fetching every matching row and bucketing in JS.The answers were already right. Measured, not assumed:
MongoDBDriver.supportspublished noqueryDateGranularity, andengine.ts's aggregate dispatch pushes a bucketed node down only for granularities a driver advertises — so the engine bucketed every granularity in memory and never reached #7550's refusal. This card moves the work server-side; it does not fix a wrong answer. What it buys is that a year-over-year rollup stops shipping the whole result set to the client first.Decisions worth reviewing
1. No
$dateTrunc, despite the card's title. The contract is not "truncate the instant", it is "produce the stringbucketDateValueproduces" — the engine's fallback emits LABELS and the two paths are chosen per query, so a drill-down crossing that seam must see the same spelling.$dateTruncanswers a truncated date that still needs formatting afterwards;$dateToStringanswers the label directly, out of one operator. It also keeps the server floor at MongoDB 3.6 instead of 5.0 and avoidsbinSize/startOfWeek— semantics this fleet cannot observe. Fewer unobserved operators is the whole argument: every operator emitted is one more documentation-derived claim (#5517), so the lowering needing the fewest wins. The route was offered as optional and this is the measurement for declining it.bucketDateValue)year'2024'%Ymonth'2024-01'%Y-%mday'2024-01-15'%Y-%m-%dweek'2025-W01'%G-W%Vquarter'2024-Q1'%Y+-Q+ a$switchover%m2. All five granularities are advertised —
weekincluded, wheredriver-sqlon SQLite cannot.$dateToStringcarries both halves of the ISO-8601 week date:%G(week-numbering YEAR) and%V(zero-padded week). Those are exactly the two quantitiesbucketDateValuecomputes off the Thursday of the week, so2024-12-30labels'2025-W01'on both sides. SQLite has neither specifier, which is why the two records legitimately differ.3.
quarteris derived with a$switchover the zero-padded month STRING, not with arithmetic. MongoDB has no quarter specifier. Deriving the digit as$ceil($divide($month, 3))would make the label depend on how$toStringformats a double —1vs1.0is the difference between'2024-Q1'and a label nothing else in the repo produces, and it is precisely the kind of claim this environment cannot check.'01' <= '03'is lexicographic and exact for fixed-width numerals, so every value in the expression stays a literal string. Null propagation then falls out of$concat, which the manual defines as returningnullif any argument is null — no$condguard, one fewer operator to model.4. Storage forms (ADR-0053): all three declared kinds are SERVED, none is refused. The capability record is per-granularity only — it has no field-kind axis — so advertising
monthmeans the engine will push a month bucket down onto adateortimecolumn too. Refusing those at the builder would turn a query the engine used to answer into a 501. Measured instead, per kind, againstbucketDateValue'snew Date(...):Date(datetime)$convertof a date is the date'2024-01-15'(date)new Date(...)= midnight UTC$dateFromStringsemantics: midnight UTC'14:30:00'(time)Invalid Date→ the empty bucketonError→ the empty bucketnew Date(ms)null/ missing / junkonNull/onError→ the empty bucket$convert … onError/onNull: nullmakes the read total, which is not a nicety:bucketDateValueis total, so a non-total expression would fail the whole aggregation on one junk row where the engine's fallback answers. AField.timecolumn is a wall clock and not an instant (#2004) — both paths agree it has no bucket rather than one of them inventing a day, which is the measurement that says it needs no separate refusal.'2024-01-15 10:00:00'with a space,'2024/01/15'), so a column holding one buckets in memory and empties here. Those are not forms this driver writes — every write goes throughcoerceTemporalValue— so the exposure is pre-#4047 legacy rows, and it is the same exposure every SQL face already carries. It is asserted as a live difference in the suite, not hidden.5. Timezones stay engine-side, and nothing needed narrowing.
engine.aggregateforces the in-memory path whenever a non-UTC reference zone meets a date bucket (ADR-0053 Phase 2 D2, pinned byengine-aggregate-timezone.test.ts), and the AST it hands a driver carries notimezonekey at all. So UTC is not a narrowing this PR chose — it is the only thing this lowering could mean. Pinned by showing the zoned label for a straddling instant is a different label, i.e. that a driver silently answering the UTC one would be answering the wrong question.6. Declared = enforced is a mechanism, not a promise. The capability record and the builder's refusal read one constant (
MONGODB_DATE_GRANULARITIES), and the suite asserts identity rather than equality — two literals that happen to agree today are exactly how a record drifts from the lowering it advertises. That drift is the failure worse than advertising nothing: the engine stops bucketing in memory on the strength of the bit, so a working query starts answering 501.7. #7550's refusal is kept and its pins were consciously rewritten. They used to assert that every declared granularity is refused — true then, false now. Rather than delete the coverage, they now assert the new substance: an advertised granularity must lower, and an unadvertised one must still refuse with
NOT_IMPLEMENTED/501 andcode+statusasserted (ADR-0112). Since the advertised set is all five, that second population is reachable only from outside the declared enum — a direct caller handing the exported builder an unknown granularity, which is the caller the refusal was always written for. Tested with one rather than deleted as unreachable, because "unreachable today" is how a refusal quietly becomes adefault:that answers. The message now names what is bucketed here,driver-sql's wording.The parity evidence, and one dependency decision
driver-sql,driver-tursoanddriver-sqlite-wasmeach hand-copybucketDateValueinto their bucket suites and say what it costs: a copy that stops tracking its original leaves the copy and the driver agreeing while both are wrong. The repo's real answer ischeckDateBucketParity(@objectstack/verify), which imports the actualapplyInMemoryAggregation— but it drives a LIVE driver, sodriver-mongodbcannot join it here.Measured rather than inherited:
objectqldepends on no driver, sodriver-mongodb → objectqlis acyclic, and as a devDependency it never enters the published package. Somongodb-date-bucket-parity.test.tsimports the realapplyInMemoryAggregationandbucketDateValue. The reference side is the engine's fallback — the drift those⚠️ Keep in synccomments admit they cannot detect does not exist for this driver. This is the one thing here outside the driver's own source; it is a devDependency line, andturbo'stest → ^buildalready orders the build.The suite runs the same rows through both paths across every advertised granularity × storage form, using
checkDateBucketParity's own instants (year/quarter/month/ISO-week boundaries, midnight in both directions) plus three rows a live fixture cannot easily carry: explicitnull, the columns MISSING entirely, and unparseable junk. It also runs the cross-column pass —atandonname the same calendar day, so a granularity that labels them differently is a storage-form leak (#3773's shape).Two guards against the failure a two-implementation comparison invites — both sides sharing one wrong idea:
'2025-W01'for 2024-12-30,'2026-W53'for 2027-01-01,'2024-Q3'for 2024-07-01);The evaluator moved to
mongodb-pipeline-evaluator.testkit.ts(thelegacy-datetime-storage.testkit.tsconvention) so both suites share one strict reader — a second, laxer copy would be the easiest way to bless a broken lowering. It moved verbatim; only the date operators were added, so nothing #7550 asserts changed meaning. Critically it models the documentation independently: its ISO-8601 grammar is written out rather than delegated tonew Date(...), and%G/%Vcome from the standard rather than transcribed frombucketDateValue— otherwise the two sides would agree by construction and the comparison would mean nothing.Reverse-verified — each deliberate break was executed and had to fail:
%G-W%V→%Y-W%V(ISO week-year dropped)$convertremoved (raw field into the date operator)What this does NOT claim
$convert/$dateToString/$concat/$switch/$lteare modelled from the manual, not observed. What is proven is that the lowering agrees with the engine under those documented semantics; what is not is that a server implements them as documented. Same bound #7550 carried for$cond/$ifNull/$addToSet, and it is written in three places rather than one: the suite header, the publishedsupportsdoc where the capability is enrolled, and beside the lowering.Gates run locally
pnpm --filter @objectstack/driver-mongodb testpnpm --filter @objectstack/driver-mongodb typecheckpnpm check:driver-conformanceeslint --no-inline-configon every changed file--filter @objectstack/driver-mongodb..., now incl. objectql)The rest of the lint farm is left to CI.
packages/objectqlsource is untouched, and no engine-side defect was found to file.Deliberately untouched
packages/drivers/driver-memory— frozen under [裁决] driver-memory / driver-mongodb 投入冻结 —— 维护者 2026-08-05 口径(跨单锚点) #5499.packages/objectql— read for the label contract and the dispatch gate; not edited.packages/qa/dogfood,packages/verify—checkDateBucketParityneeds a live driver, so enrollingdriver-mongodbthere is not available in this fleet. Recorded as the open question, not worked around.packages/spec/src/data/aggregation-conformance.ts— its scope note already excludes date bucketing by design ("a per-dialect capability … folding it in would make the table unpassable for a face that legitimately buckets nothing"), so there was nothing to update.🤖 Generated with Claude Code
https://claude.ai/code/session_019QkW7hhVvhTxh4V9Wmnqdy
Generated by Claude Code