fix(service-analytics): the ObjectQL face echoes a date bucket in the driver's own expression, so SQLite runs it - #21587
Conversation
… driver's own expression
ObjectQLStrategy.generateSql printed every date-bucketed dimension as
date_trunc('<granularity>', col). SQLite refuses that, and PostgreSQL
answers timestamps where the face answers 2026-01. The echo now reads
the bucket from a dateBucketSql strategy-context hook, which the plugin
fills from SqlDriver.dateBucketSql: the rendered, unchanged
buildDateBucketExpr. Where nothing answers (no hook, a non-SQL driver,
a granularity the driver buckets in memory, a non-UTC timezone) the
echo keeps date_trunc.
Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
…e as inherited REMOTE_FACE_ANSWERS names every public SqlDriver member. The new dateBucketSql renders the SQLite bucket expression through Knex's compiler, which needs no connection, so the inherited answer is true on the remote face. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
…SQLite and live PostgreSQL Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
…dateBucketSql Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
…d patch REMOTE_FACE_ANSWERS is not exported from the package index and tsup drops it from dist, so the new inherited row publishes nothing. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 3 package(s): 9 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 4 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 20 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 43e9e59227385382035ee73a2eba57ffbf4d5e51 && git checkout 43e9e59227385382035ee73a2eba57ffbf4d5e51
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 901e7cf13abd61afb4990ebc1e3b9cd36cf9b33d 2b8b2fe7f5e83b42474f452d00de38a01a91f186 && git checkout -B drift-repro 901e7cf13abd61afb4990ebc1e3b9cd36cf9b33d && git merge --no-ff 2b8b2fe7f5e83b42474f452d00de38a01a91f186
node scripts/docs-audit/affected-docs.mjs --json 901e7cf13abd61afb4990ebc1e3b9cd36cf9b33d
|
…ql surfaces SqlDriver gains the public dateBucketSql member and AnalyticsServiceConfig gains the optional dateBucketSql key; both widen an exported surface. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
…e envelope caller census objectql-echo-date-bucket.test.ts calls analytics.query on the real AnalyticsService that AnalyticsServicePlugin registers, not on the SDK. The census enumerates it as a service-receiver site, so the ledger gains its NOT_SDK row and the service-receiver and NOT_SDK counts move 8 to 9. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
…ent census suite The census names the pin in its LEDGER, so a change to the pin moves the census verdict. The cross-package roster and turbo.json's @objectstack/client#test inputs now carry it by name, as they carry the rest and driver-memory siblings. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
Fixes #21441
Clause-②: yes (widening)
What changes
The ObjectQL face's echoed
sqland thePOST /api/v1/analytics/sqlbody now print a date-bucketed dimension in the bucket expression the driver itself groups by for its dialect. Before,generateSqlprinteddate_trunc('GRANULARITY', col)on every dialect. SQLite refuses that, and PostgreSQL answers timestamps where the face answers2026-01.The route is the one the seat answered for Q1 (A), with Q2 = A from triage: the mechanism governs on every dialect.
driver-sql: one public member,SqlDriver.dateBucketSql(objectName, field, granularity). It returnsknex.raw(sql, bindings).toQuery()over the unchangedbuildDateBucketExpr(field, granularity, objectName), ornullwhere that returnsnull. No change to whatbuildDateBucketExprreturns, and no spec member.service-analytics:strategies/types.ts: one optional context member,dateBucketSql, besidesqlDialect.analytics-service.ts: one optionalAnalyticsServiceConfig.dateBucketSqland onebaseCtxpass-through line.plugin.ts: wires the hook fromgetDriverForObject, assqlDialectis wired (structural read,typeofguard,undefinedon every tier that cannot answer).objectql-strategy.ts:dimExprprints the hook's answer, and keepsdate_truncwhere nothing answers. The "REPRESENTATIVE" docstring sentence is narrowed to exactly those cases. The false comment ("the SQL shape the driver's own bucketing implements") is corrected.driver-turso: the comment that saidSqlDriveremitsdate_truncis corrected (comment only).REMOTE_FACE_ANSWERSgains one row,dateBucketSql: 'inherited'. Itssatisfiespin over every key ofSqlDriverfails the package's build until every publicSqlDrivermember is classified, so the ruled driver member forces this row. The row is not on the package's public surface: it is not exported from the index, and tsup drops it fromdist/.There is no second bucketing table and no dialect branch in
service-analytics.Where the echo keeps
date_truncThe hook answers nothing, and the bucket stays representative, in four cases:
weekon SQLite:buildDateBucketExprreturnsnull).timezone.Case 4 is the strategy's own gate (
zone && zone !== 'UTC'). It mirrors objectql'stzRequiresInMemory(ADR-0053 Phase 2, D2). A non-UTC zone makes the engine bucket in memory on that zone's calendar, which the driver's UTC expression does not describe. Measured: withtimezone: 'Asia/Shanghai'the face answers2026-01: 20, 2026-02: 8, while the driver's UTC expression would answer27, 1.Not gated: a measure
filter. The engine also buckets in memory when a measure carries afilter(hasAggregationFilter). It does so on the same UTC calendar the driver's expression is held to ("Must matchbucketDateValue()exactly"), so there the driver expression answers the face's keys, and the echo uses it. Measured on both engines (pinned below).Measured
Premise, at
main0bddffd55b. Measured through the realcreateDispatcherPluginmount (POST /api/v1/analytics/queryand/sql), default composition, withSqlDriveron better-sqlite3 and on a live PostgreSQL 16.14 (private cluster, stopped and removed afterwards). Each query ran 0 raw statements and 1 engine aggregate, so these are ObjectQL-face answers./sqlbody)strftime('%Y-%m', ...)/(strftime('%Y', ...) || '-Q' || ...)date_trunc('month' / 'quarter', col)no such function: date_truncselect *(in-memory bucketing)date_trunc('week', col)no such function: date_truncto_char((col)::timestamptz AT TIME ZONE 'UTC', 'YYYY-MM' / 'YYYY"-Q"Q' / 'IYYY"-W"IW')date_trunc(...)2026-01-01T00:00:00.000Zwhere the face answers2026-01timezone: 'Asia/Shanghai'select *(in-memory bucketing)date_trunc('month', col)After, at
a61c6d79a9(same harness, same mount):strftime(...)on both column types, and run with the face's row count.to_char(...)and run.date_trunc.The harness was a scratch copy of
runtime/src/analytics-query-window-validity.test.ts, deleted after the run.The MySQL arm is by code read only. No MySQL server was available. The member renders the driver's own
date_format(convert_tz(??, @@session.time_zone, '+00:00'), ...)arm through the sametoQuery.Turso remote face, measured with a scratch harness (deleted). A
TursoDriverin remote mode over a libSQLfile:client answersdateBucketSqlbyte-identically to the local face, with no connection. libSQL runs it (2026-01,2026-Q1). Week answersnullon both faces. Remote mode advertises an emptyqueryDateGranularity, so the engine buckets there in memory, on the UTC keys this expression answers.Pins
packages/services/service-analytics/src/__tests__/objectql-echo-date-bucket.test.ts, default plugin composition. SQLite runs every time; live PostgreSQL runs behindOS_TEST_POSTGRES_URL, which no CI step sets for this package (a named skip there). Run at the final head5032b8f3e9with live PG 16.14: 16 passed (16), 8 of them live PG.dateand adatetimecolumn:generateSql(the/sqlbody), with no params;date_truncand that the driver's own aggregate statement contains;filter(the engine aggregates in memory): the echo carries the driver expression, and run with its params it answers the face's rows.date_trunc('week', closed_on).timezone(both engines): the face answers the zone's calendar, and the echo keepsdate_trunc('month', closed_at).ObjectQLStrategywhose context names no hook echoesdate_trunc('month', closed_on).The dispatch's pin "on SQLite, week bucketed echoes run" does not hold under the ruled fallback. The SQLite driver buckets week in memory (
dateGranularityCapabilities.weekis false), so the hook answers nothing there and the echo keepsdate_trunc, which SQLite refuses. It is pinned as a fallback instead.Ablations
Each was predicted first, run through
scripts/ablation-replace.mjsin wrap mode, and its restore was proven by the tool: blob equals the HEAD blob, andgit diff HEADis empty. The subject issrc, imported relatively by the pin, so nodistleg applies.dateBucketSql,config line replaced by a comment).expected 'date_trunc(...)' not to contain 'date_trunc'. The 4 fallback pins stayed green.e1378f313e49equals HEAD's.strftime(...)andto_char(...)where they expectdate_trunc.3769d2062c91equals HEAD's.Verification
At final head
5032b8f3e9unless noted:pnpm --filter @objectstack/service-analytics test(no PG, as CI runs it): 176 files, 4160 passed, 261 skipped, exit 0 (at1e3cc1fc72; the only later change is the changeset file).The same suite with live PG 16.14 set: 4418 passed, 1 failed. The failure is
read-scope-temporal-coercion.test.ts's premise check, "the server is not on UTC": this private server ranEtc/UTC. That is environmental, not this change.pnpm --filter @objectstack/service-analytics typecheck: exit 0.tsc --listFilesreaches the new test file.pnpm --filter @objectstack/driver-sql test: 216 files, 3611 passed, 204 skipped, exit 0.driver-sql typecheck: exit 0.pnpm --filter @objectstack/driver-turso test: 88 files, 2366 passed, 33 skipped, exit 0.driver-turso typecheck: exit 0.Gates:
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsderives 67 commands. All 67 ran at5032b8f3e9and exited 0.--ranreconciliation: "67 derived, 67 run, 0 NOT-MEASURED, 0 UNRUN".check:dual-build-cjs-loadsfirst answeredPREREQUISITE NOT MET(nodist) and was re-run after a root build.Lint, a declared narrowing:
.tsfiles are linted, and the changeset.mdis ignored ("no matching configuration").--format json: 7 files linted, 0 errors, 0 warnings, at5032b8f3e9.eslint.config.mjsnever enables type-aware linting (noparserOptions.project), so this diff cannot move an untouched file's verdict.The full
pnpm lintis CI's.Acceptance notes
objectqlengine.tscomment abovetzRequiresInMemorystill calls native driver bucketingdate_trunc. Open PR fix(objectql): an in-process engine verb refuses an object name the registry does not resolve (#21516) #21545 holds that file. Carrier: whoever next edits that comment.skills/objectstack-ui/rules/dashboards.md("Engine support") says Postgres buckets withdate_trunc.SqlDriveremitsto_char(... AT TIME ZONE 'UTC', ...).skills/**is Tier H, so it is not touched here. Carrier: none.date_trunc('week', ...), by the ruled fallback. Closing it needs the driver to bucket week in SQL on SQLite (adriver-sqlcapability decision), which this card does not make.datecolumn shifts by the server's timezone (::timestamptz AT TIME ZONE 'UTC'), so with a non-UTC server a calendar day lands in the previous bucket #21485 (the PG and MySQLdate-column shift under a non-UTC server) is not addressed here. Because the echo now renders the driver's expression, its fix reaches the echo with no second edit.origin/mainonce (cc645f2385, docs-only, disjoint). A latermaincommit (a7ab047cf6,packages/restonly) is not merged; the queue rebuilds on it.Generated by Claude Code