fix(driver-sql): emit an aggregate alias as one identifier, not a qualified reference - #14112

Merged
os-steve merged 2 commits into
mainfrom
claude/issue-13714-timedimension-granularity-sql
Sep 1, 2026
Merged

fix(driver-sql): emit an aggregate alias as one identifier, not a qualified reference#14112
os-steve merged 2 commits into
mainfrom
claude/issue-13714-timedimension-granularity-sql

Conversation

@claude

@claudeclaudeBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Closes#13714

What was actually wrong

The filer's hypothesis — a date-truncation SQL emission problem on the granularity clause — is falsified. The bucket expression is correct on every dialect. What the database refuses is the alias.

An analytics measure is addressed on the wire as CUBE.MEASURE, and ObjectQLStrategy uses that dotted name verbatim as the driver-level aggregation alias — it is the key the caller reads its own number back under. driver-sql bound that alias through knex's ?? placeholder, which does not quote an identifier so much as parse one: wrapString splits the value on . into table.column and re-quotes each segment. So the statement reached the database as:

select strftime('%Y-%m', `due_date`) as `due_date`,
count(*) as `showcase_delivery`.`count`
from `showcase_task` group by strftime('%Y-%m', `due_date`)
-- near ".": syntax error (better-sqlite3, measured)

Not valid SQL on any dialect. It never runs, so the caller gets DATABASE_ERROR/500 from aggregate()'s terminal envelope (#11455) — a backend fault for a query that is spelled correctly.

The granularity is the ROUTER, not the fault

Measured on origin/main62a137bae, one driver, two axes crossed:

aliasgranularityresult
nday / month / quarter / year200
showcase_delivery.countnone at all500
showcase_delivery.countmonth500

NativeSQLStrategy.canHandle declines exactly on timeDimensions[].granularity, and that face hand-writes AS "the measure name" — one quoted identifier, already correct. So an un-bucketed cube query never reaches the broken door and a bucketed one always does.

That fork is why the reporter's controls were 200 while the same measure bucketed by month was a 500, and it means a repair aimed at buildDateBucketExpr would have left the defect exactly where it was.

It is also why the date-bucket parity pins (#3773, #3839) were green throughout: their reference side keys rows by the alias as a plain JS object key, where a dot is inert, and their probe aliases are bare names — the one input that breaks the SQL face is the one input they never supply. They are a declared control for this card, not evidence about it.

The fix

SqlDriver.aliasIdentifierSql() renders an output-column alias as exactly one identifier via client.wrapIdentifier — the same function knex itself calls on each segment it split out, so the dialect's quoting and quote-doubling are unchanged and a host-supplied wrapIdentifier hook is still honoured. Only the segmentation goes away.

Applied at all five alias positions on the aggregate/window builders: the bucketed groupBy projection, the plain groupBy projection, count(*) as …, func(arg) as …, and the window-function alias. The fifth is the same one-line emission defect in the same builder family, named here rather than left half-closed.

Column references are deliberately untouched.field still binds through ?? and may still be qualified — a.b in a reference position really is table.column. Only the name after as is one identifier by definition. A fenced test asserts a qualified groupBy reference still resolves.

Not fixed at the entry validator. The reporter's controls establish the entry layer is correct; turning a legitimate "count by month" into a 400 would hide the defect and break a common chart shape.

Tests

packages/drivers/driver-sql/src/sql-driver-13714-aggregate-alias-single-identifier.test.ts — the emission layer, across the live-dialect matrix (declareDialectCell): SQLite embedded, Postgres and MySQL live when provisioned and reported as a named unprovisioned cell otherwise. Sweeps DateGranularity.options (the spec's own list, so a granularity the spec grows joins without an edit), plus the un-bucketed twin, the groupBy-alias twin, an alias containing " as ", and the controls.

packages/services/service-analytics/src/__tests__/timedimension-granularity-driver-alias.test.ts — the road, on better-sqlite3, the driver the report was filed against. Closes the link that was unpinned: that ObjectQLStrategy hands the driver an aggregation whose alias is the caller's cube-qualified measure name. The route itself (NativeSQLStrategy declines on a granularity) is already pinned in both directions by native-sql-granularity-decline.test.ts and is cited as a declared control rather than duplicated.

⚠️ A dialect that declines a granularity natively (SQLite + week, capped because %V needs SQLite 3.46) is asserted as its own declared answer — the #6212NOT_IMPLEMENTED/501 capability refusal that engine.aggregate reads off supports.queryDateGranularity and serves in memory instead. The invariant spanning both answers is the card's: no shape answers DATABASE_ERROR.

Ablation — direction predicted before running

Prediction, recorded first: reverting the emission fix should redden every non-bare-alias case; week on SQLite must stay green, since it is refused at the capability check before any statement is built.

Mutation = the file restored to its 62a137bae blob. Confirmed on disk by blob hash (32687ef2…9e8a58e6…) and by both marker counts moving (aliasIdentifierSql 7 → 0; the loose as ?? count 4 → 10) — never by an editor's exit code. For the cross-package leg, driver-sql was rebuilt on each leg and the mutation confirmed in dist/ by scripts/ablation-dist-preflight.mjs … --absent. Restore proven by state: whole-tree git status --porcelain clean, blob back to the HEAD blob, markers back, and the preflight confirming the marker is in dist/ again.

suitemutatedrestored
driver-sql alias identity10 red, 3 green13 green, 2 unprovisioned
service-analytics granularity route12 red, 1 green13 green

Green in both directions, therefore ⛔ declared controls, not ablation evidence: week on SQLite (both suites), and driver-sql's two bare-alias controls (a bare alias is unchanged, an alias EQUAL to the field). The prediction held with no revision.

The ablation also corrected an overclaim in the second suite: its un-bucketed cases reddened along with the bucketed ones, because that harness declares nativeSql: false and so forces them down the same door. They are named for what they measure — the same door, without a granularity — not as a reproduction of the reporter's controls, which travel the native face.

Measured / NOT MEASURED, by dialect

dialectemissionexecution
SQLite (better-sqlite3)measuredmeasured — red before, green after, both suites
Postgrespinned, NOT executed hereneeds OS_TEST_POSTGRES_URL — CI's live-dialect matrix job
MySQLpinned, NOT executed hereneeds OS_TEST_MYSQL_URL — CI's live-dialect matrix job

The split lives in knex's shared formatter, not in a dialect, so PG and MySQL are broken by construction and repaired by construction — but that is an argument, not a reading. ⛔ This PR does not present a green on SQLite as a measurement for the other two; the live cells are declared and will report on the CI matrix job.

Verification

Union run on 7d20ebda1 (git rev-parse --short HEAD from that run):

  • pnpm --filter @objectstack/driver-sql149 files / 2273 tests pass, 9 files + 136 tests skipped; tsc --noEmit exit 0 over a 569-file program that contains both changed files (--listFiles).
  • pnpm --filter @objectstack/service-analytics86 files / 1850 tests pass; tsc --noEmit exit 0 over a 537-file program containing the new pin.
  • Gate family re-derived from the actual diff with node scripts/pm/dispatch-gates.mjs (both output sections read whole; re-derived after the diff changed, and again after git fetch origin main — the set was stable at 36 across both reads): 36 commands, 35 green.
  • check:dual-build-cjs-loads and check:type-check-debt first exited 3 = PREREQUISITE NOT MET, both naming the same missing full-workspace dist/. That closure was then built (turbo run build --filter='./packages/*' --filter='./packages/*/*', 70/70 tasks) and both re-ran green — reported as measured only after clearing the prerequisite they named, never as a pass on the prerequisite state.
  • NOT MEASURED, and neither green nor red:scripts/check-test-completeness.mjs exits 3 because it grades a saved turbo run test log, which is a CI artifact this container does not produce.

Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs


Generated by Claude Code

…lified reference
A cube query carrying a `timeDimensions[].granularity` answered 500
DATABASE_ERROR. The bucket expression was never the fault: an analytics measure
is addressed on the wire as `<cube>.<measure>` and `ObjectQLStrategy` uses that
dotted name verbatim as the driver-level aggregation `alias`, and this face bound
the alias through knex's `??` placeholder — which parses an identifier rather
than quoting one, splitting on `.` into `table.column`. The statement reached the
database as ``count(*) as `showcase_delivery`.`count` `` and was refused before it
ran.
The granularity was the router, not the fault: `NativeSQLStrategy.canHandle`
declines exactly on a granularity and that face already hand-wrote
`AS "<measure>"`, so an un-bucketed cube query never reached this door while a
bucketed one always did — which is why the reporter's controls were 200.
`aliasIdentifierSql` renders an alias through `client.wrapIdentifier`, the same
function knex calls on each segment it split out, so only the segmentation goes
away. Applied at all five alias positions on the aggregate/window builders.
Column references still bind through `??` and may still be qualified.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
…-sqlite3 driver
Replaces the dogfood HTTP pin with one at the analytics layer, driving
AnalyticsService against a real SqlDriver on better-sqlite3 — the driver the
field report was filed against. It closes the link that was unpinned: that
ObjectQLStrategy hands the driver an aggregation whose `alias` is the caller's
cube-qualified measure name, which is why nothing but analytics ever put a dot
in an alias.
The un-bucketed cases are named for what they measure — the same door, without a
granularity — rather than as a reproduction of the reporter's controls: this
harness declares `nativeSql: false` so they take the failing door too, and the
ablation reddens them alongside the bucketed cases. The reporter's controls
travel the native face, whose fork is already pinned in both directions by
native-sql-granularity-decline.test.ts.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/driver-sql, touching 3 documentable anchor(s).

8 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/drivers.mdx(via SqlDriver (symbol, a top-level class))
  • content/docs/data-modeling/index.mdx(via SqlDriver (symbol, a top-level class))
  • content/docs/data-modeling/queries.mdx(via findWithWindowFunctions (symbol, a method of class SqlDriver))
  • content/docs/permissions/tenant-audit-census.mdx(via SqlDriver (symbol, a top-level class))
  • content/docs/plugins/packages.mdx(via SqlDriver (symbol, a top-level class))
  • content/docs/protocol/kernel/index.mdx(via SqlDriver (symbol, a top-level class))
  • content/docs/protocol/kernel/lifecycle.mdx(via SqlDriver (symbol, a top-level class))
  • content/docs/protocol/objectql/query-syntax.mdx(via SqlDriver (symbol, a top-level class), findWithWindowFunctions (symbol, a method of class SqlDriver))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via SqlDriver (symbol, a top-level class), findWithWindowFunctions (symbol, a method of class SqlDriver))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 9 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 09e4b0eceda31e7fed661ed334cff6b21d97425cpackageMentionDocs.

Which tree this was computed on

This run read content/docs from d0872a721ddcfd3fe237110db21ab6e427f25674 — the merge of head 7d20ebda1b2a5e170c1b61afe9b4da0e4541b6ee into base 09e4b0eceda31e7fed661ed334cff6b21d97425c, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin d0872a721ddcfd3fe237110db21ab6e427f25674 && git checkout d0872a721ddcfd3fe237110db21ab6e427f25674
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 09e4b0eceda31e7fed661ed334cff6b21d97425c 7d20ebda1b2a5e170c1b61afe9b4da0e4541b6ee && git checkout -B drift-repro 09e4b0eceda31e7fed661ed334cff6b21d97425c && git merge --no-ff 7d20ebda1b2a5e170c1b61afe9b4da0e4541b6ee
node scripts/docs-audit/affected-docs.mjs --json 09e4b0eceda31e7fed661ed334cff6b21d97425c

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 09e4b0eceda31e7fed661ed334cff6b21d97425c → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@os-steve
os-steve marked this pull request as ready for review September 1, 2026 06:11
@os-steve
os-steve added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 9e1b2deSep 1, 2026
38 of 40 checks passed
@os-steve
os-steve deleted the claude/issue-13714-timedimension-granularity-sql branch September 1, 2026 06:31
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

analytics: cube query with a time-dimension granularity (month) on a date dimension crashes 500 DATABASE_ERROR

2 participants

@os-steve@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

fix(driver-sql): emit an aggregate alias as one identifier, not a qualified reference - #14112

Merged
os-steve merged 2 commits into
mainfrom
claude/issue-13714-timedimension-granularity-sql
Sep 1, 2026
Merged

fix(driver-sql): emit an aggregate alias as one identifier, not a qualified reference#14112
os-steve merged 2 commits into
mainfrom
claude/issue-13714-timedimension-granularity-sql

Conversation

@claude

@claudeclaudeBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Closes#13714

What was actually wrong

The filer's hypothesis — a date-truncation SQL emission problem on the granularity clause — is falsified. The bucket expression is correct on every dialect. What the database refuses is the alias.

An analytics measure is addressed on the wire as CUBE.MEASURE, and ObjectQLStrategy uses that dotted name verbatim as the driver-level aggregation alias — it is the key the caller reads its own number back under. driver-sql bound that alias through knex's ?? placeholder, which does not quote an identifier so much as parse one: wrapString splits the value on . into table.column and re-quotes each segment. So the statement reached the database as:

select strftime('%Y-%m', `due_date`) as `due_date`,
count(*) as `showcase_delivery`.`count`
from `showcase_task` group by strftime('%Y-%m', `due_date`)
-- near ".": syntax error (better-sqlite3, measured)

Not valid SQL on any dialect. It never runs, so the caller gets DATABASE_ERROR/500 from aggregate()'s terminal envelope (#11455) — a backend fault for a query that is spelled correctly.

The granularity is the ROUTER, not the fault

Measured on origin/main62a137bae, one driver, two axes crossed:

aliasgranularityresult
nday / month / quarter / year200
showcase_delivery.countnone at all500
showcase_delivery.countmonth500

NativeSQLStrategy.canHandle declines exactly on timeDimensions[].granularity, and that face hand-writes AS "the measure name" — one quoted identifier, already correct. So an un-bucketed cube query never reaches the broken door and a bucketed one always does.

That fork is why the reporter's controls were 200 while the same measure bucketed by month was a 500, and it means a repair aimed at buildDateBucketExpr would have left the defect exactly where it was.

It is also why the date-bucket parity pins (#3773, #3839) were green throughout: their reference side keys rows by the alias as a plain JS object key, where a dot is inert, and their probe aliases are bare names — the one input that breaks the SQL face is the one input they never supply. They are a declared control for this card, not evidence about it.

The fix

SqlDriver.aliasIdentifierSql() renders an output-column alias as exactly one identifier via client.wrapIdentifier — the same function knex itself calls on each segment it split out, so the dialect's quoting and quote-doubling are unchanged and a host-supplied wrapIdentifier hook is still honoured. Only the segmentation goes away.

Applied at all five alias positions on the aggregate/window builders: the bucketed groupBy projection, the plain groupBy projection, count(*) as …, func(arg) as …, and the window-function alias. The fifth is the same one-line emission defect in the same builder family, named here rather than left half-closed.

Column references are deliberately untouched.field still binds through ?? and may still be qualified — a.b in a reference position really is table.column. Only the name after as is one identifier by definition. A fenced test asserts a qualified groupBy reference still resolves.

Not fixed at the entry validator. The reporter's controls establish the entry layer is correct; turning a legitimate "count by month" into a 400 would hide the defect and break a common chart shape.

Tests

packages/drivers/driver-sql/src/sql-driver-13714-aggregate-alias-single-identifier.test.ts — the emission layer, across the live-dialect matrix (declareDialectCell): SQLite embedded, Postgres and MySQL live when provisioned and reported as a named unprovisioned cell otherwise. Sweeps DateGranularity.options (the spec's own list, so a granularity the spec grows joins without an edit), plus the un-bucketed twin, the groupBy-alias twin, an alias containing " as ", and the controls.

packages/services/service-analytics/src/__tests__/timedimension-granularity-driver-alias.test.ts — the road, on better-sqlite3, the driver the report was filed against. Closes the link that was unpinned: that ObjectQLStrategy hands the driver an aggregation whose alias is the caller's cube-qualified measure name. The route itself (NativeSQLStrategy declines on a granularity) is already pinned in both directions by native-sql-granularity-decline.test.ts and is cited as a declared control rather than duplicated.

⚠️ A dialect that declines a granularity natively (SQLite + week, capped because %V needs SQLite 3.46) is asserted as its own declared answer — the #6212NOT_IMPLEMENTED/501 capability refusal that engine.aggregate reads off supports.queryDateGranularity and serves in memory instead. The invariant spanning both answers is the card's: no shape answers DATABASE_ERROR.

Ablation — direction predicted before running

Prediction, recorded first: reverting the emission fix should redden every non-bare-alias case; week on SQLite must stay green, since it is refused at the capability check before any statement is built.

Mutation = the file restored to its 62a137bae blob. Confirmed on disk by blob hash (32687ef2…9e8a58e6…) and by both marker counts moving (aliasIdentifierSql 7 → 0; the loose as ?? count 4 → 10) — never by an editor's exit code. For the cross-package leg, driver-sql was rebuilt on each leg and the mutation confirmed in dist/ by scripts/ablation-dist-preflight.mjs … --absent. Restore proven by state: whole-tree git status --porcelain clean, blob back to the HEAD blob, markers back, and the preflight confirming the marker is in dist/ again.

suitemutatedrestored
driver-sql alias identity10 red, 3 green13 green, 2 unprovisioned
service-analytics granularity route12 red, 1 green13 green

Green in both directions, therefore ⛔ declared controls, not ablation evidence: week on SQLite (both suites), and driver-sql's two bare-alias controls (a bare alias is unchanged, an alias EQUAL to the field). The prediction held with no revision.

The ablation also corrected an overclaim in the second suite: its un-bucketed cases reddened along with the bucketed ones, because that harness declares nativeSql: false and so forces them down the same door. They are named for what they measure — the same door, without a granularity — not as a reproduction of the reporter's controls, which travel the native face.

Measured / NOT MEASURED, by dialect

dialectemissionexecution
SQLite (better-sqlite3)measuredmeasured — red before, green after, both suites
Postgrespinned, NOT executed hereneeds OS_TEST_POSTGRES_URL — CI's live-dialect matrix job
MySQLpinned, NOT executed hereneeds OS_TEST_MYSQL_URL — CI's live-dialect matrix job

The split lives in knex's shared formatter, not in a dialect, so PG and MySQL are broken by construction and repaired by construction — but that is an argument, not a reading. ⛔ This PR does not present a green on SQLite as a measurement for the other two; the live cells are declared and will report on the CI matrix job.

Verification

Union run on 7d20ebda1 (git rev-parse --short HEAD from that run):

  • pnpm --filter @objectstack/driver-sql149 files / 2273 tests pass, 9 files + 136 tests skipped; tsc --noEmit exit 0 over a 569-file program that contains both changed files (--listFiles).
  • pnpm --filter @objectstack/service-analytics86 files / 1850 tests pass; tsc --noEmit exit 0 over a 537-file program containing the new pin.
  • Gate family re-derived from the actual diff with node scripts/pm/dispatch-gates.mjs (both output sections read whole; re-derived after the diff changed, and again after git fetch origin main — the set was stable at 36 across both reads): 36 commands, 35 green.
  • check:dual-build-cjs-loads and check:type-check-debt first exited 3 = PREREQUISITE NOT MET, both naming the same missing full-workspace dist/. That closure was then built (turbo run build --filter='./packages/*' --filter='./packages/*/*', 70/70 tasks) and both re-ran green — reported as measured only after clearing the prerequisite they named, never as a pass on the prerequisite state.
  • NOT MEASURED, and neither green nor red:scripts/check-test-completeness.mjs exits 3 because it grades a saved turbo run test log, which is a CI artifact this container does not produce.

Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs


Generated by Claude Code

…lified reference
A cube query carrying a `timeDimensions[].granularity` answered 500
DATABASE_ERROR. The bucket expression was never the fault: an analytics measure
is addressed on the wire as `<cube>.<measure>` and `ObjectQLStrategy` uses that
dotted name verbatim as the driver-level aggregation `alias`, and this face bound
the alias through knex's `??` placeholder — which parses an identifier rather
than quoting one, splitting on `.` into `table.column`. The statement reached the
database as ``count(*) as `showcase_delivery`.`count` `` and was refused before it
ran.
The granularity was the router, not the fault: `NativeSQLStrategy.canHandle`
declines exactly on a granularity and that face already hand-wrote
`AS "<measure>"`, so an un-bucketed cube query never reached this door while a
bucketed one always did — which is why the reporter's controls were 200.
`aliasIdentifierSql` renders an alias through `client.wrapIdentifier`, the same
function knex calls on each segment it split out, so only the segmentation goes
away. Applied at all five alias positions on the aggregate/window builders.
Column references still bind through `??` and may still be qualified.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
…-sqlite3 driver
Replaces the dogfood HTTP pin with one at the analytics layer, driving
AnalyticsService against a real SqlDriver on better-sqlite3 — the driver the
field report was filed against. It closes the link that was unpinned: that
ObjectQLStrategy hands the driver an aggregation whose `alias` is the caller's
cube-qualified measure name, which is why nothing but analytics ever put a dot
in an alias.
The un-bucketed cases are named for what they measure — the same door, without a
granularity — rather than as a reproduction of the reporter's controls: this
harness declares `nativeSql: false` so they take the failing door too, and the
ablation reddens them alongside the bucketed cases. The reporter's controls
travel the native face, whose fork is already pinned in both directions by
native-sql-granularity-decline.test.ts.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/driver-sql, touching 3 documentable anchor(s).

8 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/drivers.mdx(via SqlDriver (symbol, a top-level class))
  • content/docs/data-modeling/index.mdx(via SqlDriver (symbol, a top-level class))
  • content/docs/data-modeling/queries.mdx(via findWithWindowFunctions (symbol, a method of class SqlDriver))
  • content/docs/permissions/tenant-audit-census.mdx(via SqlDriver (symbol, a top-level class))
  • content/docs/plugins/packages.mdx(via SqlDriver (symbol, a top-level class))
  • content/docs/protocol/kernel/index.mdx(via SqlDriver (symbol, a top-level class))
  • content/docs/protocol/kernel/lifecycle.mdx(via SqlDriver (symbol, a top-level class))
  • content/docs/protocol/objectql/query-syntax.mdx(via SqlDriver (symbol, a top-level class), findWithWindowFunctions (symbol, a method of class SqlDriver))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via SqlDriver (symbol, a top-level class), findWithWindowFunctions (symbol, a method of class SqlDriver))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 9 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 09e4b0eceda31e7fed661ed334cff6b21d97425cpackageMentionDocs.

Which tree this was computed on

This run read content/docs from d0872a721ddcfd3fe237110db21ab6e427f25674 — the merge of head 7d20ebda1b2a5e170c1b61afe9b4da0e4541b6ee into base 09e4b0eceda31e7fed661ed334cff6b21d97425c, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin d0872a721ddcfd3fe237110db21ab6e427f25674 && git checkout d0872a721ddcfd3fe237110db21ab6e427f25674
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 09e4b0eceda31e7fed661ed334cff6b21d97425c 7d20ebda1b2a5e170c1b61afe9b4da0e4541b6ee && git checkout -B drift-repro 09e4b0eceda31e7fed661ed334cff6b21d97425c && git merge --no-ff 7d20ebda1b2a5e170c1b61afe9b4da0e4541b6ee
node scripts/docs-audit/affected-docs.mjs --json 09e4b0eceda31e7fed661ed334cff6b21d97425c

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 09e4b0eceda31e7fed661ed334cff6b21d97425c → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@os-steve
os-steve marked this pull request as ready for review September 1, 2026 06:11
@os-steve
os-steve added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 9e1b2deSep 1, 2026
38 of 40 checks passed
@os-steve
os-steve deleted the claude/issue-13714-timedimension-granularity-sql branch September 1, 2026 06:31
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

analytics: cube query with a time-dimension granularity (month) on a date dimension crashes 500 DATABASE_ERROR

2 participants

@os-steve@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(driver-sql): emit an aggregate alias as one identifier, not a qualified reference - #14112

Merged
os-steve merged 2 commits into
mainfrom
claude/issue-13714-timedimension-granularity-sql
Sep 1, 2026
Merged

fix(driver-sql): emit an aggregate alias as one identifier, not a qualified reference#14112
os-steve merged 2 commits into
mainfrom
claude/issue-13714-timedimension-granularity-sql

Conversation

@claude

@claudeclaudeBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Closes#13714

What was actually wrong

The filer's hypothesis — a date-truncation SQL emission problem on the granularity clause — is falsified. The bucket expression is correct on every dialect. What the database refuses is the alias.

An analytics measure is addressed on the wire as CUBE.MEASURE, and ObjectQLStrategy uses that dotted name verbatim as the driver-level aggregation alias — it is the key the caller reads its own number back under. driver-sql bound that alias through knex's ?? placeholder, which does not quote an identifier so much as parse one: wrapString splits the value on . into table.column and re-quotes each segment. So the statement reached the database as:

select strftime('%Y-%m', `due_date`) as `due_date`,
count(*) as `showcase_delivery`.`count`
from `showcase_task` group by strftime('%Y-%m', `due_date`)
-- near ".": syntax error (better-sqlite3, measured)

Not valid SQL on any dialect. It never runs, so the caller gets DATABASE_ERROR/500 from aggregate()'s terminal envelope (#11455) — a backend fault for a query that is spelled correctly.

The granularity is the ROUTER, not the fault

Measured on origin/main62a137bae, one driver, two axes crossed:

aliasgranularityresult
nday / month / quarter / year200
showcase_delivery.countnone at all500
showcase_delivery.countmonth500

NativeSQLStrategy.canHandle declines exactly on timeDimensions[].granularity, and that face hand-writes AS "the measure name" — one quoted identifier, already correct. So an un-bucketed cube query never reaches the broken door and a bucketed one always does.

That fork is why the reporter's controls were 200 while the same measure bucketed by month was a 500, and it means a repair aimed at buildDateBucketExpr would have left the defect exactly where it was.

It is also why the date-bucket parity pins (#3773, #3839) were green throughout: their reference side keys rows by the alias as a plain JS object key, where a dot is inert, and their probe aliases are bare names — the one input that breaks the SQL face is the one input they never supply. They are a declared control for this card, not evidence about it.

The fix

SqlDriver.aliasIdentifierSql() renders an output-column alias as exactly one identifier via client.wrapIdentifier — the same function knex itself calls on each segment it split out, so the dialect's quoting and quote-doubling are unchanged and a host-supplied wrapIdentifier hook is still honoured. Only the segmentation goes away.

Applied at all five alias positions on the aggregate/window builders: the bucketed groupBy projection, the plain groupBy projection, count(*) as …, func(arg) as …, and the window-function alias. The fifth is the same one-line emission defect in the same builder family, named here rather than left half-closed.

Column references are deliberately untouched.field still binds through ?? and may still be qualified — a.b in a reference position really is table.column. Only the name after as is one identifier by definition. A fenced test asserts a qualified groupBy reference still resolves.

Not fixed at the entry validator. The reporter's controls establish the entry layer is correct; turning a legitimate "count by month" into a 400 would hide the defect and break a common chart shape.

Tests

packages/drivers/driver-sql/src/sql-driver-13714-aggregate-alias-single-identifier.test.ts — the emission layer, across the live-dialect matrix (declareDialectCell): SQLite embedded, Postgres and MySQL live when provisioned and reported as a named unprovisioned cell otherwise. Sweeps DateGranularity.options (the spec's own list, so a granularity the spec grows joins without an edit), plus the un-bucketed twin, the groupBy-alias twin, an alias containing " as ", and the controls.

packages/services/service-analytics/src/__tests__/timedimension-granularity-driver-alias.test.ts — the road, on better-sqlite3, the driver the report was filed against. Closes the link that was unpinned: that ObjectQLStrategy hands the driver an aggregation whose alias is the caller's cube-qualified measure name. The route itself (NativeSQLStrategy declines on a granularity) is already pinned in both directions by native-sql-granularity-decline.test.ts and is cited as a declared control rather than duplicated.

⚠️ A dialect that declines a granularity natively (SQLite + week, capped because %V needs SQLite 3.46) is asserted as its own declared answer — the #6212NOT_IMPLEMENTED/501 capability refusal that engine.aggregate reads off supports.queryDateGranularity and serves in memory instead. The invariant spanning both answers is the card's: no shape answers DATABASE_ERROR.

Ablation — direction predicted before running

Prediction, recorded first: reverting the emission fix should redden every non-bare-alias case; week on SQLite must stay green, since it is refused at the capability check before any statement is built.

Mutation = the file restored to its 62a137bae blob. Confirmed on disk by blob hash (32687ef2…9e8a58e6…) and by both marker counts moving (aliasIdentifierSql 7 → 0; the loose as ?? count 4 → 10) — never by an editor's exit code. For the cross-package leg, driver-sql was rebuilt on each leg and the mutation confirmed in dist/ by scripts/ablation-dist-preflight.mjs … --absent. Restore proven by state: whole-tree git status --porcelain clean, blob back to the HEAD blob, markers back, and the preflight confirming the marker is in dist/ again.

suitemutatedrestored
driver-sql alias identity10 red, 3 green13 green, 2 unprovisioned
service-analytics granularity route12 red, 1 green13 green

Green in both directions, therefore ⛔ declared controls, not ablation evidence: week on SQLite (both suites), and driver-sql's two bare-alias controls (a bare alias is unchanged, an alias EQUAL to the field). The prediction held with no revision.

The ablation also corrected an overclaim in the second suite: its un-bucketed cases reddened along with the bucketed ones, because that harness declares nativeSql: false and so forces them down the same door. They are named for what they measure — the same door, without a granularity — not as a reproduction of the reporter's controls, which travel the native face.

Measured / NOT MEASURED, by dialect

dialectemissionexecution
SQLite (better-sqlite3)measuredmeasured — red before, green after, both suites
Postgrespinned, NOT executed hereneeds OS_TEST_POSTGRES_URL — CI's live-dialect matrix job
MySQLpinned, NOT executed hereneeds OS_TEST_MYSQL_URL — CI's live-dialect matrix job

The split lives in knex's shared formatter, not in a dialect, so PG and MySQL are broken by construction and repaired by construction — but that is an argument, not a reading. ⛔ This PR does not present a green on SQLite as a measurement for the other two; the live cells are declared and will report on the CI matrix job.

Verification

Union run on 7d20ebda1 (git rev-parse --short HEAD from that run):

  • pnpm --filter @objectstack/driver-sql149 files / 2273 tests pass, 9 files + 136 tests skipped; tsc --noEmit exit 0 over a 569-file program that contains both changed files (--listFiles).
  • pnpm --filter @objectstack/service-analytics86 files / 1850 tests pass; tsc --noEmit exit 0 over a 537-file program containing the new pin.
  • Gate family re-derived from the actual diff with node scripts/pm/dispatch-gates.mjs (both output sections read whole; re-derived after the diff changed, and again after git fetch origin main — the set was stable at 36 across both reads): 36 commands, 35 green.
  • check:dual-build-cjs-loads and check:type-check-debt first exited 3 = PREREQUISITE NOT MET, both naming the same missing full-workspace dist/. That closure was then built (turbo run build --filter='./packages/*' --filter='./packages/*/*', 70/70 tasks) and both re-ran green — reported as measured only after clearing the prerequisite they named, never as a pass on the prerequisite state.
  • NOT MEASURED, and neither green nor red:scripts/check-test-completeness.mjs exits 3 because it grades a saved turbo run test log, which is a CI artifact this container does not produce.

Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs


Generated by Claude Code

…lified reference
A cube query carrying a `timeDimensions[].granularity` answered 500
DATABASE_ERROR. The bucket expression was never the fault: an analytics measure
is addressed on the wire as `<cube>.<measure>` and `ObjectQLStrategy` uses that
dotted name verbatim as the driver-level aggregation `alias`, and this face bound
the alias through knex's `??` placeholder — which parses an identifier rather
than quoting one, splitting on `.` into `table.column`. The statement reached the
database as ``count(*) as `showcase_delivery`.`count` `` and was refused before it
ran.
The granularity was the router, not the fault: `NativeSQLStrategy.canHandle`
declines exactly on a granularity and that face already hand-wrote
`AS "<measure>"`, so an un-bucketed cube query never reached this door while a
bucketed one always did — which is why the reporter's controls were 200.
`aliasIdentifierSql` renders an alias through `client.wrapIdentifier`, the same
function knex calls on each segment it split out, so only the segmentation goes
away. Applied at all five alias positions on the aggregate/window builders.
Column references still bind through `??` and may still be qualified.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
…-sqlite3 driver
Replaces the dogfood HTTP pin with one at the analytics layer, driving
AnalyticsService against a real SqlDriver on better-sqlite3 — the driver the
field report was filed against. It closes the link that was unpinned: that
ObjectQLStrategy hands the driver an aggregation whose `alias` is the caller's
cube-qualified measure name, which is why nothing but analytics ever put a dot
in an alias.
The un-bucketed cases are named for what they measure — the same door, without a
granularity — rather than as a reproduction of the reporter's controls: this
harness declares `nativeSql: false` so they take the failing door too, and the
ablation reddens them alongside the bucketed cases. The reporter's controls
travel the native face, whose fork is already pinned in both directions by
native-sql-granularity-decline.test.ts.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/driver-sql, touching 3 documentable anchor(s).

8 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/drivers.mdx(via SqlDriver (symbol, a top-level class))
  • content/docs/data-modeling/index.mdx(via SqlDriver (symbol, a top-level class))
  • content/docs/data-modeling/queries.mdx(via findWithWindowFunctions (symbol, a method of class SqlDriver))
  • content/docs/permissions/tenant-audit-census.mdx(via SqlDriver (symbol, a top-level class))
  • content/docs/plugins/packages.mdx(via SqlDriver (symbol, a top-level class))
  • content/docs/protocol/kernel/index.mdx(via SqlDriver (symbol, a top-level class))
  • content/docs/protocol/kernel/lifecycle.mdx(via SqlDriver (symbol, a top-level class))
  • content/docs/protocol/objectql/query-syntax.mdx(via SqlDriver (symbol, a top-level class), findWithWindowFunctions (symbol, a method of class SqlDriver))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via SqlDriver (symbol, a top-level class), findWithWindowFunctions (symbol, a method of class SqlDriver))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 9 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 09e4b0eceda31e7fed661ed334cff6b21d97425cpackageMentionDocs.

Which tree this was computed on

This run read content/docs from d0872a721ddcfd3fe237110db21ab6e427f25674 — the merge of head 7d20ebda1b2a5e170c1b61afe9b4da0e4541b6ee into base 09e4b0eceda31e7fed661ed334cff6b21d97425c, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin d0872a721ddcfd3fe237110db21ab6e427f25674 && git checkout d0872a721ddcfd3fe237110db21ab6e427f25674
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 09e4b0eceda31e7fed661ed334cff6b21d97425c 7d20ebda1b2a5e170c1b61afe9b4da0e4541b6ee && git checkout -B drift-repro 09e4b0eceda31e7fed661ed334cff6b21d97425c && git merge --no-ff 7d20ebda1b2a5e170c1b61afe9b4da0e4541b6ee
node scripts/docs-audit/affected-docs.mjs --json 09e4b0eceda31e7fed661ed334cff6b21d97425c

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 09e4b0eceda31e7fed661ed334cff6b21d97425c → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@os-steve
os-steve marked this pull request as ready for review September 1, 2026 06:11
@os-steve
os-steve added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 9e1b2deSep 1, 2026
38 of 40 checks passed
@os-steve
os-steve deleted the claude/issue-13714-timedimension-granularity-sql branch September 1, 2026 06:31
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

analytics: cube query with a time-dimension granularity (month) on a date dimension crashes 500 DATABASE_ERROR

2 participants

@os-steve@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(driver-sql): emit an aggregate alias as one identifier, not a qualified reference - #14112

Merged
os-steve merged 2 commits into
mainfrom
claude/issue-13714-timedimension-granularity-sql
Sep 1, 2026
Merged

fix(driver-sql): emit an aggregate alias as one identifier, not a qualified reference#14112
os-steve merged 2 commits into
mainfrom
claude/issue-13714-timedimension-granularity-sql

Conversation

@claude

@claudeclaudeBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Closes#13714

What was actually wrong

The filer's hypothesis — a date-truncation SQL emission problem on the granularity clause — is falsified. The bucket expression is correct on every dialect. What the database refuses is the alias.

An analytics measure is addressed on the wire as CUBE.MEASURE, and ObjectQLStrategy uses that dotted name verbatim as the driver-level aggregation alias — it is the key the caller reads its own number back under. driver-sql bound that alias through knex's ?? placeholder, which does not quote an identifier so much as parse one: wrapString splits the value on . into table.column and re-quotes each segment. So the statement reached the database as:

select strftime('%Y-%m', `due_date`) as `due_date`,
count(*) as `showcase_delivery`.`count`
from `showcase_task` group by strftime('%Y-%m', `due_date`)
-- near ".": syntax error (better-sqlite3, measured)

Not valid SQL on any dialect. It never runs, so the caller gets DATABASE_ERROR/500 from aggregate()'s terminal envelope (#11455) — a backend fault for a query that is spelled correctly.

The granularity is the ROUTER, not the fault

Measured on origin/main62a137bae, one driver, two axes crossed:

aliasgranularityresult
nday / month / quarter / year200
showcase_delivery.countnone at all500
showcase_delivery.countmonth500

NativeSQLStrategy.canHandle declines exactly on timeDimensions[].granularity, and that face hand-writes AS "the measure name" — one quoted identifier, already correct. So an un-bucketed cube query never reaches the broken door and a bucketed one always does.

That fork is why the reporter's controls were 200 while the same measure bucketed by month was a 500, and it means a repair aimed at buildDateBucketExpr would have left the defect exactly where it was.

It is also why the date-bucket parity pins (#3773, #3839) were green throughout: their reference side keys rows by the alias as a plain JS object key, where a dot is inert, and their probe aliases are bare names — the one input that breaks the SQL face is the one input they never supply. They are a declared control for this card, not evidence about it.

The fix

SqlDriver.aliasIdentifierSql() renders an output-column alias as exactly one identifier via client.wrapIdentifier — the same function knex itself calls on each segment it split out, so the dialect's quoting and quote-doubling are unchanged and a host-supplied wrapIdentifier hook is still honoured. Only the segmentation goes away.

Applied at all five alias positions on the aggregate/window builders: the bucketed groupBy projection, the plain groupBy projection, count(*) as …, func(arg) as …, and the window-function alias. The fifth is the same one-line emission defect in the same builder family, named here rather than left half-closed.

Column references are deliberately untouched.field still binds through ?? and may still be qualified — a.b in a reference position really is table.column. Only the name after as is one identifier by definition. A fenced test asserts a qualified groupBy reference still resolves.

Not fixed at the entry validator. The reporter's controls establish the entry layer is correct; turning a legitimate "count by month" into a 400 would hide the defect and break a common chart shape.

Tests

packages/drivers/driver-sql/src/sql-driver-13714-aggregate-alias-single-identifier.test.ts — the emission layer, across the live-dialect matrix (declareDialectCell): SQLite embedded, Postgres and MySQL live when provisioned and reported as a named unprovisioned cell otherwise. Sweeps DateGranularity.options (the spec's own list, so a granularity the spec grows joins without an edit), plus the un-bucketed twin, the groupBy-alias twin, an alias containing " as ", and the controls.

packages/services/service-analytics/src/__tests__/timedimension-granularity-driver-alias.test.ts — the road, on better-sqlite3, the driver the report was filed against. Closes the link that was unpinned: that ObjectQLStrategy hands the driver an aggregation whose alias is the caller's cube-qualified measure name. The route itself (NativeSQLStrategy declines on a granularity) is already pinned in both directions by native-sql-granularity-decline.test.ts and is cited as a declared control rather than duplicated.

⚠️ A dialect that declines a granularity natively (SQLite + week, capped because %V needs SQLite 3.46) is asserted as its own declared answer — the #6212NOT_IMPLEMENTED/501 capability refusal that engine.aggregate reads off supports.queryDateGranularity and serves in memory instead. The invariant spanning both answers is the card's: no shape answers DATABASE_ERROR.

Ablation — direction predicted before running

Prediction, recorded first: reverting the emission fix should redden every non-bare-alias case; week on SQLite must stay green, since it is refused at the capability check before any statement is built.

Mutation = the file restored to its 62a137bae blob. Confirmed on disk by blob hash (32687ef2…9e8a58e6…) and by both marker counts moving (aliasIdentifierSql 7 → 0; the loose as ?? count 4 → 10) — never by an editor's exit code. For the cross-package leg, driver-sql was rebuilt on each leg and the mutation confirmed in dist/ by scripts/ablation-dist-preflight.mjs … --absent. Restore proven by state: whole-tree git status --porcelain clean, blob back to the HEAD blob, markers back, and the preflight confirming the marker is in dist/ again.

suitemutatedrestored
driver-sql alias identity10 red, 3 green13 green, 2 unprovisioned
service-analytics granularity route12 red, 1 green13 green

Green in both directions, therefore ⛔ declared controls, not ablation evidence: week on SQLite (both suites), and driver-sql's two bare-alias controls (a bare alias is unchanged, an alias EQUAL to the field). The prediction held with no revision.

The ablation also corrected an overclaim in the second suite: its un-bucketed cases reddened along with the bucketed ones, because that harness declares nativeSql: false and so forces them down the same door. They are named for what they measure — the same door, without a granularity — not as a reproduction of the reporter's controls, which travel the native face.

Measured / NOT MEASURED, by dialect

dialectemissionexecution
SQLite (better-sqlite3)measuredmeasured — red before, green after, both suites
Postgrespinned, NOT executed hereneeds OS_TEST_POSTGRES_URL — CI's live-dialect matrix job
MySQLpinned, NOT executed hereneeds OS_TEST_MYSQL_URL — CI's live-dialect matrix job

The split lives in knex's shared formatter, not in a dialect, so PG and MySQL are broken by construction and repaired by construction — but that is an argument, not a reading. ⛔ This PR does not present a green on SQLite as a measurement for the other two; the live cells are declared and will report on the CI matrix job.

Verification

Union run on 7d20ebda1 (git rev-parse --short HEAD from that run):

  • pnpm --filter @objectstack/driver-sql149 files / 2273 tests pass, 9 files + 136 tests skipped; tsc --noEmit exit 0 over a 569-file program that contains both changed files (--listFiles).
  • pnpm --filter @objectstack/service-analytics86 files / 1850 tests pass; tsc --noEmit exit 0 over a 537-file program containing the new pin.
  • Gate family re-derived from the actual diff with node scripts/pm/dispatch-gates.mjs (both output sections read whole; re-derived after the diff changed, and again after git fetch origin main — the set was stable at 36 across both reads): 36 commands, 35 green.
  • check:dual-build-cjs-loads and check:type-check-debt first exited 3 = PREREQUISITE NOT MET, both naming the same missing full-workspace dist/. That closure was then built (turbo run build --filter='./packages/*' --filter='./packages/*/*', 70/70 tasks) and both re-ran green — reported as measured only after clearing the prerequisite they named, never as a pass on the prerequisite state.
  • NOT MEASURED, and neither green nor red:scripts/check-test-completeness.mjs exits 3 because it grades a saved turbo run test log, which is a CI artifact this container does not produce.

Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs


Generated by Claude Code

…lified reference
A cube query carrying a `timeDimensions[].granularity` answered 500
DATABASE_ERROR. The bucket expression was never the fault: an analytics measure
is addressed on the wire as `<cube>.<measure>` and `ObjectQLStrategy` uses that
dotted name verbatim as the driver-level aggregation `alias`, and this face bound
the alias through knex's `??` placeholder — which parses an identifier rather
than quoting one, splitting on `.` into `table.column`. The statement reached the
database as ``count(*) as `showcase_delivery`.`count` `` and was refused before it
ran.
The granularity was the router, not the fault: `NativeSQLStrategy.canHandle`
declines exactly on a granularity and that face already hand-wrote
`AS "<measure>"`, so an un-bucketed cube query never reached this door while a
bucketed one always did — which is why the reporter's controls were 200.
`aliasIdentifierSql` renders an alias through `client.wrapIdentifier`, the same
function knex calls on each segment it split out, so only the segmentation goes
away. Applied at all five alias positions on the aggregate/window builders.
Column references still bind through `??` and may still be qualified.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
…-sqlite3 driver
Replaces the dogfood HTTP pin with one at the analytics layer, driving
AnalyticsService against a real SqlDriver on better-sqlite3 — the driver the
field report was filed against. It closes the link that was unpinned: that
ObjectQLStrategy hands the driver an aggregation whose `alias` is the caller's
cube-qualified measure name, which is why nothing but analytics ever put a dot
in an alias.
The un-bucketed cases are named for what they measure — the same door, without a
granularity — rather than as a reproduction of the reporter's controls: this
harness declares `nativeSql: false` so they take the failing door too, and the
ablation reddens them alongside the bucketed cases. The reporter's controls
travel the native face, whose fork is already pinned in both directions by
native-sql-granularity-decline.test.ts.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/driver-sql, touching 3 documentable anchor(s).

8 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/drivers.mdx(via SqlDriver (symbol, a top-level class))
  • content/docs/data-modeling/index.mdx(via SqlDriver (symbol, a top-level class))
  • content/docs/data-modeling/queries.mdx(via findWithWindowFunctions (symbol, a method of class SqlDriver))
  • content/docs/permissions/tenant-audit-census.mdx(via SqlDriver (symbol, a top-level class))
  • content/docs/plugins/packages.mdx(via SqlDriver (symbol, a top-level class))
  • content/docs/protocol/kernel/index.mdx(via SqlDriver (symbol, a top-level class))
  • content/docs/protocol/kernel/lifecycle.mdx(via SqlDriver (symbol, a top-level class))
  • content/docs/protocol/objectql/query-syntax.mdx(via SqlDriver (symbol, a top-level class), findWithWindowFunctions (symbol, a method of class SqlDriver))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via SqlDriver (symbol, a top-level class), findWithWindowFunctions (symbol, a method of class SqlDriver))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 9 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 09e4b0eceda31e7fed661ed334cff6b21d97425cpackageMentionDocs.

Which tree this was computed on

This run read content/docs from d0872a721ddcfd3fe237110db21ab6e427f25674 — the merge of head 7d20ebda1b2a5e170c1b61afe9b4da0e4541b6ee into base 09e4b0eceda31e7fed661ed334cff6b21d97425c, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin d0872a721ddcfd3fe237110db21ab6e427f25674 && git checkout d0872a721ddcfd3fe237110db21ab6e427f25674
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 09e4b0eceda31e7fed661ed334cff6b21d97425c 7d20ebda1b2a5e170c1b61afe9b4da0e4541b6ee && git checkout -B drift-repro 09e4b0eceda31e7fed661ed334cff6b21d97425c && git merge --no-ff 7d20ebda1b2a5e170c1b61afe9b4da0e4541b6ee
node scripts/docs-audit/affected-docs.mjs --json 09e4b0eceda31e7fed661ed334cff6b21d97425c

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 09e4b0eceda31e7fed661ed334cff6b21d97425c → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@os-steve
os-steve marked this pull request as ready for review September 1, 2026 06:11
@os-steve
os-steve added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 9e1b2deSep 1, 2026
38 of 40 checks passed
@os-steve
os-steve deleted the claude/issue-13714-timedimension-granularity-sql branch September 1, 2026 06:31
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

analytics: cube query with a time-dimension granularity (month) on a date dimension crashes 500 DATABASE_ERROR

2 participants

@os-steve@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

fix(driver-sql): emit an aggregate alias as one identifier, not a qualified reference - #14112

Merged
os-steve merged 2 commits into
mainfrom
claude/issue-13714-timedimension-granularity-sql
Sep 1, 2026
Merged

fix(driver-sql): emit an aggregate alias as one identifier, not a qualified reference#14112
os-steve merged 2 commits into
mainfrom
claude/issue-13714-timedimension-granularity-sql

Conversation

@claude

@claudeclaudeBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Closes#13714

What was actually wrong

The filer's hypothesis — a date-truncation SQL emission problem on the granularity clause — is falsified. The bucket expression is correct on every dialect. What the database refuses is the alias.

An analytics measure is addressed on the wire as CUBE.MEASURE, and ObjectQLStrategy uses that dotted name verbatim as the driver-level aggregation alias — it is the key the caller reads its own number back under. driver-sql bound that alias through knex's ?? placeholder, which does not quote an identifier so much as parse one: wrapString splits the value on . into table.column and re-quotes each segment. So the statement reached the database as:

select strftime('%Y-%m', `due_date`) as `due_date`,
count(*) as `showcase_delivery`.`count`
from `showcase_task` group by strftime('%Y-%m', `due_date`)
-- near ".": syntax error (better-sqlite3, measured)

Not valid SQL on any dialect. It never runs, so the caller gets DATABASE_ERROR/500 from aggregate()'s terminal envelope (#11455) — a backend fault for a query that is spelled correctly.

The granularity is the ROUTER, not the fault

Measured on origin/main62a137bae, one driver, two axes crossed:

aliasgranularityresult
nday / month / quarter / year200
showcase_delivery.countnone at all500
showcase_delivery.countmonth500

NativeSQLStrategy.canHandle declines exactly on timeDimensions[].granularity, and that face hand-writes AS "the measure name" — one quoted identifier, already correct. So an un-bucketed cube query never reaches the broken door and a bucketed one always does.

That fork is why the reporter's controls were 200 while the same measure bucketed by month was a 500, and it means a repair aimed at buildDateBucketExpr would have left the defect exactly where it was.

It is also why the date-bucket parity pins (#3773, #3839) were green throughout: their reference side keys rows by the alias as a plain JS object key, where a dot is inert, and their probe aliases are bare names — the one input that breaks the SQL face is the one input they never supply. They are a declared control for this card, not evidence about it.

The fix

SqlDriver.aliasIdentifierSql() renders an output-column alias as exactly one identifier via client.wrapIdentifier — the same function knex itself calls on each segment it split out, so the dialect's quoting and quote-doubling are unchanged and a host-supplied wrapIdentifier hook is still honoured. Only the segmentation goes away.

Applied at all five alias positions on the aggregate/window builders: the bucketed groupBy projection, the plain groupBy projection, count(*) as …, func(arg) as …, and the window-function alias. The fifth is the same one-line emission defect in the same builder family, named here rather than left half-closed.

Column references are deliberately untouched.field still binds through ?? and may still be qualified — a.b in a reference position really is table.column. Only the name after as is one identifier by definition. A fenced test asserts a qualified groupBy reference still resolves.

Not fixed at the entry validator. The reporter's controls establish the entry layer is correct; turning a legitimate "count by month" into a 400 would hide the defect and break a common chart shape.

Tests

packages/drivers/driver-sql/src/sql-driver-13714-aggregate-alias-single-identifier.test.ts — the emission layer, across the live-dialect matrix (declareDialectCell): SQLite embedded, Postgres and MySQL live when provisioned and reported as a named unprovisioned cell otherwise. Sweeps DateGranularity.options (the spec's own list, so a granularity the spec grows joins without an edit), plus the un-bucketed twin, the groupBy-alias twin, an alias containing " as ", and the controls.

packages/services/service-analytics/src/__tests__/timedimension-granularity-driver-alias.test.ts — the road, on better-sqlite3, the driver the report was filed against. Closes the link that was unpinned: that ObjectQLStrategy hands the driver an aggregation whose alias is the caller's cube-qualified measure name. The route itself (NativeSQLStrategy declines on a granularity) is already pinned in both directions by native-sql-granularity-decline.test.ts and is cited as a declared control rather than duplicated.

⚠️ A dialect that declines a granularity natively (SQLite + week, capped because %V needs SQLite 3.46) is asserted as its own declared answer — the #6212NOT_IMPLEMENTED/501 capability refusal that engine.aggregate reads off supports.queryDateGranularity and serves in memory instead. The invariant spanning both answers is the card's: no shape answers DATABASE_ERROR.

Ablation — direction predicted before running

Prediction, recorded first: reverting the emission fix should redden every non-bare-alias case; week on SQLite must stay green, since it is refused at the capability check before any statement is built.

Mutation = the file restored to its 62a137bae blob. Confirmed on disk by blob hash (32687ef2…9e8a58e6…) and by both marker counts moving (aliasIdentifierSql 7 → 0; the loose as ?? count 4 → 10) — never by an editor's exit code. For the cross-package leg, driver-sql was rebuilt on each leg and the mutation confirmed in dist/ by scripts/ablation-dist-preflight.mjs … --absent. Restore proven by state: whole-tree git status --porcelain clean, blob back to the HEAD blob, markers back, and the preflight confirming the marker is in dist/ again.

suitemutatedrestored
driver-sql alias identity10 red, 3 green13 green, 2 unprovisioned
service-analytics granularity route12 red, 1 green13 green

Green in both directions, therefore ⛔ declared controls, not ablation evidence: week on SQLite (both suites), and driver-sql's two bare-alias controls (a bare alias is unchanged, an alias EQUAL to the field). The prediction held with no revision.

The ablation also corrected an overclaim in the second suite: its un-bucketed cases reddened along with the bucketed ones, because that harness declares nativeSql: false and so forces them down the same door. They are named for what they measure — the same door, without a granularity — not as a reproduction of the reporter's controls, which travel the native face.

Measured / NOT MEASURED, by dialect

dialectemissionexecution
SQLite (better-sqlite3)measuredmeasured — red before, green after, both suites
Postgrespinned, NOT executed hereneeds OS_TEST_POSTGRES_URL — CI's live-dialect matrix job
MySQLpinned, NOT executed hereneeds OS_TEST_MYSQL_URL — CI's live-dialect matrix job

The split lives in knex's shared formatter, not in a dialect, so PG and MySQL are broken by construction and repaired by construction — but that is an argument, not a reading. ⛔ This PR does not present a green on SQLite as a measurement for the other two; the live cells are declared and will report on the CI matrix job.

Verification

Union run on 7d20ebda1 (git rev-parse --short HEAD from that run):

  • pnpm --filter @objectstack/driver-sql149 files / 2273 tests pass, 9 files + 136 tests skipped; tsc --noEmit exit 0 over a 569-file program that contains both changed files (--listFiles).
  • pnpm --filter @objectstack/service-analytics86 files / 1850 tests pass; tsc --noEmit exit 0 over a 537-file program containing the new pin.
  • Gate family re-derived from the actual diff with node scripts/pm/dispatch-gates.mjs (both output sections read whole; re-derived after the diff changed, and again after git fetch origin main — the set was stable at 36 across both reads): 36 commands, 35 green.
  • check:dual-build-cjs-loads and check:type-check-debt first exited 3 = PREREQUISITE NOT MET, both naming the same missing full-workspace dist/. That closure was then built (turbo run build --filter='./packages/*' --filter='./packages/*/*', 70/70 tasks) and both re-ran green — reported as measured only after clearing the prerequisite they named, never as a pass on the prerequisite state.
  • NOT MEASURED, and neither green nor red:scripts/check-test-completeness.mjs exits 3 because it grades a saved turbo run test log, which is a CI artifact this container does not produce.

Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs


Generated by Claude Code

…lified reference
A cube query carrying a `timeDimensions[].granularity` answered 500
DATABASE_ERROR. The bucket expression was never the fault: an analytics measure
is addressed on the wire as `<cube>.<measure>` and `ObjectQLStrategy` uses that
dotted name verbatim as the driver-level aggregation `alias`, and this face bound
the alias through knex's `??` placeholder — which parses an identifier rather
than quoting one, splitting on `.` into `table.column`. The statement reached the
database as ``count(*) as `showcase_delivery`.`count` `` and was refused before it
ran.
The granularity was the router, not the fault: `NativeSQLStrategy.canHandle`
declines exactly on a granularity and that face already hand-wrote
`AS "<measure>"`, so an un-bucketed cube query never reached this door while a
bucketed one always did — which is why the reporter's controls were 200.
`aliasIdentifierSql` renders an alias through `client.wrapIdentifier`, the same
function knex calls on each segment it split out, so only the segmentation goes
away. Applied at all five alias positions on the aggregate/window builders.
Column references still bind through `??` and may still be qualified.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
…-sqlite3 driver
Replaces the dogfood HTTP pin with one at the analytics layer, driving
AnalyticsService against a real SqlDriver on better-sqlite3 — the driver the
field report was filed against. It closes the link that was unpinned: that
ObjectQLStrategy hands the driver an aggregation whose `alias` is the caller's
cube-qualified measure name, which is why nothing but analytics ever put a dot
in an alias.
The un-bucketed cases are named for what they measure — the same door, without a
granularity — rather than as a reproduction of the reporter's controls: this
harness declares `nativeSql: false` so they take the failing door too, and the
ablation reddens them alongside the bucketed cases. The reporter's controls
travel the native face, whose fork is already pinned in both directions by
native-sql-granularity-decline.test.ts.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/driver-sql, touching 3 documentable anchor(s).

8 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/drivers.mdx(via SqlDriver (symbol, a top-level class))
  • content/docs/data-modeling/index.mdx(via SqlDriver (symbol, a top-level class))
  • content/docs/data-modeling/queries.mdx(via findWithWindowFunctions (symbol, a method of class SqlDriver))
  • content/docs/permissions/tenant-audit-census.mdx(via SqlDriver (symbol, a top-level class))
  • content/docs/plugins/packages.mdx(via SqlDriver (symbol, a top-level class))
  • content/docs/protocol/kernel/index.mdx(via SqlDriver (symbol, a top-level class))
  • content/docs/protocol/kernel/lifecycle.mdx(via SqlDriver (symbol, a top-level class))
  • content/docs/protocol/objectql/query-syntax.mdx(via SqlDriver (symbol, a top-level class), findWithWindowFunctions (symbol, a method of class SqlDriver))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via SqlDriver (symbol, a top-level class), findWithWindowFunctions (symbol, a method of class SqlDriver))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 9 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 09e4b0eceda31e7fed661ed334cff6b21d97425cpackageMentionDocs.

Which tree this was computed on

This run read content/docs from d0872a721ddcfd3fe237110db21ab6e427f25674 — the merge of head 7d20ebda1b2a5e170c1b61afe9b4da0e4541b6ee into base 09e4b0eceda31e7fed661ed334cff6b21d97425c, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin d0872a721ddcfd3fe237110db21ab6e427f25674 && git checkout d0872a721ddcfd3fe237110db21ab6e427f25674
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 09e4b0eceda31e7fed661ed334cff6b21d97425c 7d20ebda1b2a5e170c1b61afe9b4da0e4541b6ee && git checkout -B drift-repro 09e4b0eceda31e7fed661ed334cff6b21d97425c && git merge --no-ff 7d20ebda1b2a5e170c1b61afe9b4da0e4541b6ee
node scripts/docs-audit/affected-docs.mjs --json 09e4b0eceda31e7fed661ed334cff6b21d97425c

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 09e4b0eceda31e7fed661ed334cff6b21d97425c → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@os-steve
os-steve marked this pull request as ready for review September 1, 2026 06:11
@os-steve
os-steve added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 9e1b2deSep 1, 2026
38 of 40 checks passed
@os-steve
os-steve deleted the claude/issue-13714-timedimension-granularity-sql branch September 1, 2026 06:31
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

analytics: cube query with a time-dimension granularity (month) on a date dimension crashes 500 DATABASE_ERROR

2 participants

@os-steve@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(driver-sql): emit an aggregate alias as one identifier, not a qualified reference - #14112

Merged
os-steve merged 2 commits into
mainfrom
claude/issue-13714-timedimension-granularity-sql
Sep 1, 2026
Merged

fix(driver-sql): emit an aggregate alias as one identifier, not a qualified reference#14112
os-steve merged 2 commits into
mainfrom
claude/issue-13714-timedimension-granularity-sql

Conversation

@claude

@claudeclaudeBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Closes#13714

What was actually wrong

The filer's hypothesis — a date-truncation SQL emission problem on the granularity clause — is falsified. The bucket expression is correct on every dialect. What the database refuses is the alias.

An analytics measure is addressed on the wire as CUBE.MEASURE, and ObjectQLStrategy uses that dotted name verbatim as the driver-level aggregation alias — it is the key the caller reads its own number back under. driver-sql bound that alias through knex's ?? placeholder, which does not quote an identifier so much as parse one: wrapString splits the value on . into table.column and re-quotes each segment. So the statement reached the database as:

select strftime('%Y-%m', `due_date`) as `due_date`,
count(*) as `showcase_delivery`.`count`
from `showcase_task` group by strftime('%Y-%m', `due_date`)
-- near ".": syntax error (better-sqlite3, measured)

Not valid SQL on any dialect. It never runs, so the caller gets DATABASE_ERROR/500 from aggregate()'s terminal envelope (#11455) — a backend fault for a query that is spelled correctly.

The granularity is the ROUTER, not the fault

Measured on origin/main62a137bae, one driver, two axes crossed:

aliasgranularityresult
nday / month / quarter / year200
showcase_delivery.countnone at all500
showcase_delivery.countmonth500

NativeSQLStrategy.canHandle declines exactly on timeDimensions[].granularity, and that face hand-writes AS "the measure name" — one quoted identifier, already correct. So an un-bucketed cube query never reaches the broken door and a bucketed one always does.

That fork is why the reporter's controls were 200 while the same measure bucketed by month was a 500, and it means a repair aimed at buildDateBucketExpr would have left the defect exactly where it was.

It is also why the date-bucket parity pins (#3773, #3839) were green throughout: their reference side keys rows by the alias as a plain JS object key, where a dot is inert, and their probe aliases are bare names — the one input that breaks the SQL face is the one input they never supply. They are a declared control for this card, not evidence about it.

The fix

SqlDriver.aliasIdentifierSql() renders an output-column alias as exactly one identifier via client.wrapIdentifier — the same function knex itself calls on each segment it split out, so the dialect's quoting and quote-doubling are unchanged and a host-supplied wrapIdentifier hook is still honoured. Only the segmentation goes away.

Applied at all five alias positions on the aggregate/window builders: the bucketed groupBy projection, the plain groupBy projection, count(*) as …, func(arg) as …, and the window-function alias. The fifth is the same one-line emission defect in the same builder family, named here rather than left half-closed.

Column references are deliberately untouched.field still binds through ?? and may still be qualified — a.b in a reference position really is table.column. Only the name after as is one identifier by definition. A fenced test asserts a qualified groupBy reference still resolves.

Not fixed at the entry validator. The reporter's controls establish the entry layer is correct; turning a legitimate "count by month" into a 400 would hide the defect and break a common chart shape.

Tests

packages/drivers/driver-sql/src/sql-driver-13714-aggregate-alias-single-identifier.test.ts — the emission layer, across the live-dialect matrix (declareDialectCell): SQLite embedded, Postgres and MySQL live when provisioned and reported as a named unprovisioned cell otherwise. Sweeps DateGranularity.options (the spec's own list, so a granularity the spec grows joins without an edit), plus the un-bucketed twin, the groupBy-alias twin, an alias containing " as ", and the controls.

packages/services/service-analytics/src/__tests__/timedimension-granularity-driver-alias.test.ts — the road, on better-sqlite3, the driver the report was filed against. Closes the link that was unpinned: that ObjectQLStrategy hands the driver an aggregation whose alias is the caller's cube-qualified measure name. The route itself (NativeSQLStrategy declines on a granularity) is already pinned in both directions by native-sql-granularity-decline.test.ts and is cited as a declared control rather than duplicated.

⚠️ A dialect that declines a granularity natively (SQLite + week, capped because %V needs SQLite 3.46) is asserted as its own declared answer — the #6212NOT_IMPLEMENTED/501 capability refusal that engine.aggregate reads off supports.queryDateGranularity and serves in memory instead. The invariant spanning both answers is the card's: no shape answers DATABASE_ERROR.

Ablation — direction predicted before running

Prediction, recorded first: reverting the emission fix should redden every non-bare-alias case; week on SQLite must stay green, since it is refused at the capability check before any statement is built.

Mutation = the file restored to its 62a137bae blob. Confirmed on disk by blob hash (32687ef2…9e8a58e6…) and by both marker counts moving (aliasIdentifierSql 7 → 0; the loose as ?? count 4 → 10) — never by an editor's exit code. For the cross-package leg, driver-sql was rebuilt on each leg and the mutation confirmed in dist/ by scripts/ablation-dist-preflight.mjs … --absent. Restore proven by state: whole-tree git status --porcelain clean, blob back to the HEAD blob, markers back, and the preflight confirming the marker is in dist/ again.

suitemutatedrestored
driver-sql alias identity10 red, 3 green13 green, 2 unprovisioned
service-analytics granularity route12 red, 1 green13 green

Green in both directions, therefore ⛔ declared controls, not ablation evidence: week on SQLite (both suites), and driver-sql's two bare-alias controls (a bare alias is unchanged, an alias EQUAL to the field). The prediction held with no revision.

The ablation also corrected an overclaim in the second suite: its un-bucketed cases reddened along with the bucketed ones, because that harness declares nativeSql: false and so forces them down the same door. They are named for what they measure — the same door, without a granularity — not as a reproduction of the reporter's controls, which travel the native face.

Measured / NOT MEASURED, by dialect

dialectemissionexecution
SQLite (better-sqlite3)measuredmeasured — red before, green after, both suites
Postgrespinned, NOT executed hereneeds OS_TEST_POSTGRES_URL — CI's live-dialect matrix job
MySQLpinned, NOT executed hereneeds OS_TEST_MYSQL_URL — CI's live-dialect matrix job

The split lives in knex's shared formatter, not in a dialect, so PG and MySQL are broken by construction and repaired by construction — but that is an argument, not a reading. ⛔ This PR does not present a green on SQLite as a measurement for the other two; the live cells are declared and will report on the CI matrix job.

Verification

Union run on 7d20ebda1 (git rev-parse --short HEAD from that run):

  • pnpm --filter @objectstack/driver-sql149 files / 2273 tests pass, 9 files + 136 tests skipped; tsc --noEmit exit 0 over a 569-file program that contains both changed files (--listFiles).
  • pnpm --filter @objectstack/service-analytics86 files / 1850 tests pass; tsc --noEmit exit 0 over a 537-file program containing the new pin.
  • Gate family re-derived from the actual diff with node scripts/pm/dispatch-gates.mjs (both output sections read whole; re-derived after the diff changed, and again after git fetch origin main — the set was stable at 36 across both reads): 36 commands, 35 green.
  • check:dual-build-cjs-loads and check:type-check-debt first exited 3 = PREREQUISITE NOT MET, both naming the same missing full-workspace dist/. That closure was then built (turbo run build --filter='./packages/*' --filter='./packages/*/*', 70/70 tasks) and both re-ran green — reported as measured only after clearing the prerequisite they named, never as a pass on the prerequisite state.
  • NOT MEASURED, and neither green nor red:scripts/check-test-completeness.mjs exits 3 because it grades a saved turbo run test log, which is a CI artifact this container does not produce.

Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs


Generated by Claude Code

…lified reference
A cube query carrying a `timeDimensions[].granularity` answered 500
DATABASE_ERROR. The bucket expression was never the fault: an analytics measure
is addressed on the wire as `<cube>.<measure>` and `ObjectQLStrategy` uses that
dotted name verbatim as the driver-level aggregation `alias`, and this face bound
the alias through knex's `??` placeholder — which parses an identifier rather
than quoting one, splitting on `.` into `table.column`. The statement reached the
database as ``count(*) as `showcase_delivery`.`count` `` and was refused before it
ran.
The granularity was the router, not the fault: `NativeSQLStrategy.canHandle`
declines exactly on a granularity and that face already hand-wrote
`AS "<measure>"`, so an un-bucketed cube query never reached this door while a
bucketed one always did — which is why the reporter's controls were 200.
`aliasIdentifierSql` renders an alias through `client.wrapIdentifier`, the same
function knex calls on each segment it split out, so only the segmentation goes
away. Applied at all five alias positions on the aggregate/window builders.
Column references still bind through `??` and may still be qualified.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
…-sqlite3 driver
Replaces the dogfood HTTP pin with one at the analytics layer, driving
AnalyticsService against a real SqlDriver on better-sqlite3 — the driver the
field report was filed against. It closes the link that was unpinned: that
ObjectQLStrategy hands the driver an aggregation whose `alias` is the caller's
cube-qualified measure name, which is why nothing but analytics ever put a dot
in an alias.
The un-bucketed cases are named for what they measure — the same door, without a
granularity — rather than as a reproduction of the reporter's controls: this
harness declares `nativeSql: false` so they take the failing door too, and the
ablation reddens them alongside the bucketed cases. The reporter's controls
travel the native face, whose fork is already pinned in both directions by
native-sql-granularity-decline.test.ts.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/driver-sql, touching 3 documentable anchor(s).

8 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/drivers.mdx(via SqlDriver (symbol, a top-level class))
  • content/docs/data-modeling/index.mdx(via SqlDriver (symbol, a top-level class))
  • content/docs/data-modeling/queries.mdx(via findWithWindowFunctions (symbol, a method of class SqlDriver))
  • content/docs/permissions/tenant-audit-census.mdx(via SqlDriver (symbol, a top-level class))
  • content/docs/plugins/packages.mdx(via SqlDriver (symbol, a top-level class))
  • content/docs/protocol/kernel/index.mdx(via SqlDriver (symbol, a top-level class))
  • content/docs/protocol/kernel/lifecycle.mdx(via SqlDriver (symbol, a top-level class))
  • content/docs/protocol/objectql/query-syntax.mdx(via SqlDriver (symbol, a top-level class), findWithWindowFunctions (symbol, a method of class SqlDriver))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via SqlDriver (symbol, a top-level class), findWithWindowFunctions (symbol, a method of class SqlDriver))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 9 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 09e4b0eceda31e7fed661ed334cff6b21d97425cpackageMentionDocs.

Which tree this was computed on

This run read content/docs from d0872a721ddcfd3fe237110db21ab6e427f25674 — the merge of head 7d20ebda1b2a5e170c1b61afe9b4da0e4541b6ee into base 09e4b0eceda31e7fed661ed334cff6b21d97425c, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin d0872a721ddcfd3fe237110db21ab6e427f25674 && git checkout d0872a721ddcfd3fe237110db21ab6e427f25674
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 09e4b0eceda31e7fed661ed334cff6b21d97425c 7d20ebda1b2a5e170c1b61afe9b4da0e4541b6ee && git checkout -B drift-repro 09e4b0eceda31e7fed661ed334cff6b21d97425c && git merge --no-ff 7d20ebda1b2a5e170c1b61afe9b4da0e4541b6ee
node scripts/docs-audit/affected-docs.mjs --json 09e4b0eceda31e7fed661ed334cff6b21d97425c

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 09e4b0eceda31e7fed661ed334cff6b21d97425c → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@os-steve
os-steve marked this pull request as ready for review September 1, 2026 06:11
@os-steve
os-steve added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 9e1b2deSep 1, 2026
38 of 40 checks passed
@os-steve
os-steve deleted the claude/issue-13714-timedimension-granularity-sql branch September 1, 2026 06:31
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

analytics: cube query with a time-dimension granularity (month) on a date dimension crashes 500 DATABASE_ERROR

2 participants

@os-steve@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(driver-sql): emit an aggregate alias as one identifier, not a qualified reference - #14112

Merged
os-steve merged 2 commits into
mainfrom
claude/issue-13714-timedimension-granularity-sql
Sep 1, 2026
Merged

fix(driver-sql): emit an aggregate alias as one identifier, not a qualified reference#14112
os-steve merged 2 commits into
mainfrom
claude/issue-13714-timedimension-granularity-sql

Conversation

@claude

@claudeclaudeBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Closes#13714

What was actually wrong

The filer's hypothesis — a date-truncation SQL emission problem on the granularity clause — is falsified. The bucket expression is correct on every dialect. What the database refuses is the alias.

An analytics measure is addressed on the wire as CUBE.MEASURE, and ObjectQLStrategy uses that dotted name verbatim as the driver-level aggregation alias — it is the key the caller reads its own number back under. driver-sql bound that alias through knex's ?? placeholder, which does not quote an identifier so much as parse one: wrapString splits the value on . into table.column and re-quotes each segment. So the statement reached the database as:

select strftime('%Y-%m', `due_date`) as `due_date`,
count(*) as `showcase_delivery`.`count`
from `showcase_task` group by strftime('%Y-%m', `due_date`)
-- near ".": syntax error (better-sqlite3, measured)

Not valid SQL on any dialect. It never runs, so the caller gets DATABASE_ERROR/500 from aggregate()'s terminal envelope (#11455) — a backend fault for a query that is spelled correctly.

The granularity is the ROUTER, not the fault

Measured on origin/main62a137bae, one driver, two axes crossed:

aliasgranularityresult
nday / month / quarter / year200
showcase_delivery.countnone at all500
showcase_delivery.countmonth500

NativeSQLStrategy.canHandle declines exactly on timeDimensions[].granularity, and that face hand-writes AS "the measure name" — one quoted identifier, already correct. So an un-bucketed cube query never reaches the broken door and a bucketed one always does.

That fork is why the reporter's controls were 200 while the same measure bucketed by month was a 500, and it means a repair aimed at buildDateBucketExpr would have left the defect exactly where it was.

It is also why the date-bucket parity pins (#3773, #3839) were green throughout: their reference side keys rows by the alias as a plain JS object key, where a dot is inert, and their probe aliases are bare names — the one input that breaks the SQL face is the one input they never supply. They are a declared control for this card, not evidence about it.

The fix

SqlDriver.aliasIdentifierSql() renders an output-column alias as exactly one identifier via client.wrapIdentifier — the same function knex itself calls on each segment it split out, so the dialect's quoting and quote-doubling are unchanged and a host-supplied wrapIdentifier hook is still honoured. Only the segmentation goes away.

Applied at all five alias positions on the aggregate/window builders: the bucketed groupBy projection, the plain groupBy projection, count(*) as …, func(arg) as …, and the window-function alias. The fifth is the same one-line emission defect in the same builder family, named here rather than left half-closed.

Column references are deliberately untouched.field still binds through ?? and may still be qualified — a.b in a reference position really is table.column. Only the name after as is one identifier by definition. A fenced test asserts a qualified groupBy reference still resolves.

Not fixed at the entry validator. The reporter's controls establish the entry layer is correct; turning a legitimate "count by month" into a 400 would hide the defect and break a common chart shape.

Tests

packages/drivers/driver-sql/src/sql-driver-13714-aggregate-alias-single-identifier.test.ts — the emission layer, across the live-dialect matrix (declareDialectCell): SQLite embedded, Postgres and MySQL live when provisioned and reported as a named unprovisioned cell otherwise. Sweeps DateGranularity.options (the spec's own list, so a granularity the spec grows joins without an edit), plus the un-bucketed twin, the groupBy-alias twin, an alias containing " as ", and the controls.

packages/services/service-analytics/src/__tests__/timedimension-granularity-driver-alias.test.ts — the road, on better-sqlite3, the driver the report was filed against. Closes the link that was unpinned: that ObjectQLStrategy hands the driver an aggregation whose alias is the caller's cube-qualified measure name. The route itself (NativeSQLStrategy declines on a granularity) is already pinned in both directions by native-sql-granularity-decline.test.ts and is cited as a declared control rather than duplicated.

⚠️ A dialect that declines a granularity natively (SQLite + week, capped because %V needs SQLite 3.46) is asserted as its own declared answer — the #6212NOT_IMPLEMENTED/501 capability refusal that engine.aggregate reads off supports.queryDateGranularity and serves in memory instead. The invariant spanning both answers is the card's: no shape answers DATABASE_ERROR.

Ablation — direction predicted before running

Prediction, recorded first: reverting the emission fix should redden every non-bare-alias case; week on SQLite must stay green, since it is refused at the capability check before any statement is built.

Mutation = the file restored to its 62a137bae blob. Confirmed on disk by blob hash (32687ef2…9e8a58e6…) and by both marker counts moving (aliasIdentifierSql 7 → 0; the loose as ?? count 4 → 10) — never by an editor's exit code. For the cross-package leg, driver-sql was rebuilt on each leg and the mutation confirmed in dist/ by scripts/ablation-dist-preflight.mjs … --absent. Restore proven by state: whole-tree git status --porcelain clean, blob back to the HEAD blob, markers back, and the preflight confirming the marker is in dist/ again.

suitemutatedrestored
driver-sql alias identity10 red, 3 green13 green, 2 unprovisioned
service-analytics granularity route12 red, 1 green13 green

Green in both directions, therefore ⛔ declared controls, not ablation evidence: week on SQLite (both suites), and driver-sql's two bare-alias controls (a bare alias is unchanged, an alias EQUAL to the field). The prediction held with no revision.

The ablation also corrected an overclaim in the second suite: its un-bucketed cases reddened along with the bucketed ones, because that harness declares nativeSql: false and so forces them down the same door. They are named for what they measure — the same door, without a granularity — not as a reproduction of the reporter's controls, which travel the native face.

Measured / NOT MEASURED, by dialect

dialectemissionexecution
SQLite (better-sqlite3)measuredmeasured — red before, green after, both suites
Postgrespinned, NOT executed hereneeds OS_TEST_POSTGRES_URL — CI's live-dialect matrix job
MySQLpinned, NOT executed hereneeds OS_TEST_MYSQL_URL — CI's live-dialect matrix job

The split lives in knex's shared formatter, not in a dialect, so PG and MySQL are broken by construction and repaired by construction — but that is an argument, not a reading. ⛔ This PR does not present a green on SQLite as a measurement for the other two; the live cells are declared and will report on the CI matrix job.

Verification

Union run on 7d20ebda1 (git rev-parse --short HEAD from that run):

  • pnpm --filter @objectstack/driver-sql149 files / 2273 tests pass, 9 files + 136 tests skipped; tsc --noEmit exit 0 over a 569-file program that contains both changed files (--listFiles).
  • pnpm --filter @objectstack/service-analytics86 files / 1850 tests pass; tsc --noEmit exit 0 over a 537-file program containing the new pin.
  • Gate family re-derived from the actual diff with node scripts/pm/dispatch-gates.mjs (both output sections read whole; re-derived after the diff changed, and again after git fetch origin main — the set was stable at 36 across both reads): 36 commands, 35 green.
  • check:dual-build-cjs-loads and check:type-check-debt first exited 3 = PREREQUISITE NOT MET, both naming the same missing full-workspace dist/. That closure was then built (turbo run build --filter='./packages/*' --filter='./packages/*/*', 70/70 tasks) and both re-ran green — reported as measured only after clearing the prerequisite they named, never as a pass on the prerequisite state.
  • NOT MEASURED, and neither green nor red:scripts/check-test-completeness.mjs exits 3 because it grades a saved turbo run test log, which is a CI artifact this container does not produce.

Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs


Generated by Claude Code

…lified reference
A cube query carrying a `timeDimensions[].granularity` answered 500
DATABASE_ERROR. The bucket expression was never the fault: an analytics measure
is addressed on the wire as `<cube>.<measure>` and `ObjectQLStrategy` uses that
dotted name verbatim as the driver-level aggregation `alias`, and this face bound
the alias through knex's `??` placeholder — which parses an identifier rather
than quoting one, splitting on `.` into `table.column`. The statement reached the
database as ``count(*) as `showcase_delivery`.`count` `` and was refused before it
ran.
The granularity was the router, not the fault: `NativeSQLStrategy.canHandle`
declines exactly on a granularity and that face already hand-wrote
`AS "<measure>"`, so an un-bucketed cube query never reached this door while a
bucketed one always did — which is why the reporter's controls were 200.
`aliasIdentifierSql` renders an alias through `client.wrapIdentifier`, the same
function knex calls on each segment it split out, so only the segmentation goes
away. Applied at all five alias positions on the aggregate/window builders.
Column references still bind through `??` and may still be qualified.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
…-sqlite3 driver
Replaces the dogfood HTTP pin with one at the analytics layer, driving
AnalyticsService against a real SqlDriver on better-sqlite3 — the driver the
field report was filed against. It closes the link that was unpinned: that
ObjectQLStrategy hands the driver an aggregation whose `alias` is the caller's
cube-qualified measure name, which is why nothing but analytics ever put a dot
in an alias.
The un-bucketed cases are named for what they measure — the same door, without a
granularity — rather than as a reproduction of the reporter's controls: this
harness declares `nativeSql: false` so they take the failing door too, and the
ablation reddens them alongside the bucketed cases. The reporter's controls
travel the native face, whose fork is already pinned in both directions by
native-sql-granularity-decline.test.ts.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/driver-sql, touching 3 documentable anchor(s).

8 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/drivers.mdx(via SqlDriver (symbol, a top-level class))
  • content/docs/data-modeling/index.mdx(via SqlDriver (symbol, a top-level class))
  • content/docs/data-modeling/queries.mdx(via findWithWindowFunctions (symbol, a method of class SqlDriver))
  • content/docs/permissions/tenant-audit-census.mdx(via SqlDriver (symbol, a top-level class))
  • content/docs/plugins/packages.mdx(via SqlDriver (symbol, a top-level class))
  • content/docs/protocol/kernel/index.mdx(via SqlDriver (symbol, a top-level class))
  • content/docs/protocol/kernel/lifecycle.mdx(via SqlDriver (symbol, a top-level class))
  • content/docs/protocol/objectql/query-syntax.mdx(via SqlDriver (symbol, a top-level class), findWithWindowFunctions (symbol, a method of class SqlDriver))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via SqlDriver (symbol, a top-level class), findWithWindowFunctions (symbol, a method of class SqlDriver))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 9 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 09e4b0eceda31e7fed661ed334cff6b21d97425cpackageMentionDocs.

Which tree this was computed on

This run read content/docs from d0872a721ddcfd3fe237110db21ab6e427f25674 — the merge of head 7d20ebda1b2a5e170c1b61afe9b4da0e4541b6ee into base 09e4b0eceda31e7fed661ed334cff6b21d97425c, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin d0872a721ddcfd3fe237110db21ab6e427f25674 && git checkout d0872a721ddcfd3fe237110db21ab6e427f25674
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 09e4b0eceda31e7fed661ed334cff6b21d97425c 7d20ebda1b2a5e170c1b61afe9b4da0e4541b6ee && git checkout -B drift-repro 09e4b0eceda31e7fed661ed334cff6b21d97425c && git merge --no-ff 7d20ebda1b2a5e170c1b61afe9b4da0e4541b6ee
node scripts/docs-audit/affected-docs.mjs --json 09e4b0eceda31e7fed661ed334cff6b21d97425c

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 09e4b0eceda31e7fed661ed334cff6b21d97425c → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@os-steve
os-steve marked this pull request as ready for review September 1, 2026 06:11
@os-steve
os-steve added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 9e1b2deSep 1, 2026
38 of 40 checks passed
@os-steve
os-steve deleted the claude/issue-13714-timedimension-granularity-sql branch September 1, 2026 06:31
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

analytics: cube query with a time-dimension granularity (month) on a date dimension crashes 500 DATABASE_ERROR

2 participants

@os-steve@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

fix(driver-sql): emit an aggregate alias as one identifier, not a qualified reference - #14112

Merged
os-steve merged 2 commits into
mainfrom
claude/issue-13714-timedimension-granularity-sql
Sep 1, 2026
Merged

fix(driver-sql): emit an aggregate alias as one identifier, not a qualified reference#14112
os-steve merged 2 commits into
mainfrom
claude/issue-13714-timedimension-granularity-sql

Conversation

@claude

@claudeclaudeBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Closes#13714

What was actually wrong

The filer's hypothesis — a date-truncation SQL emission problem on the granularity clause — is falsified. The bucket expression is correct on every dialect. What the database refuses is the alias.

An analytics measure is addressed on the wire as CUBE.MEASURE, and ObjectQLStrategy uses that dotted name verbatim as the driver-level aggregation alias — it is the key the caller reads its own number back under. driver-sql bound that alias through knex's ?? placeholder, which does not quote an identifier so much as parse one: wrapString splits the value on . into table.column and re-quotes each segment. So the statement reached the database as:

select strftime('%Y-%m', `due_date`) as `due_date`,
count(*) as `showcase_delivery`.`count`
from `showcase_task` group by strftime('%Y-%m', `due_date`)
-- near ".": syntax error (better-sqlite3, measured)

Not valid SQL on any dialect. It never runs, so the caller gets DATABASE_ERROR/500 from aggregate()'s terminal envelope (#11455) — a backend fault for a query that is spelled correctly.

The granularity is the ROUTER, not the fault

Measured on origin/main62a137bae, one driver, two axes crossed:

aliasgranularityresult
nday / month / quarter / year200
showcase_delivery.countnone at all500
showcase_delivery.countmonth500

NativeSQLStrategy.canHandle declines exactly on timeDimensions[].granularity, and that face hand-writes AS "the measure name" — one quoted identifier, already correct. So an un-bucketed cube query never reaches the broken door and a bucketed one always does.

That fork is why the reporter's controls were 200 while the same measure bucketed by month was a 500, and it means a repair aimed at buildDateBucketExpr would have left the defect exactly where it was.

It is also why the date-bucket parity pins (#3773, #3839) were green throughout: their reference side keys rows by the alias as a plain JS object key, where a dot is inert, and their probe aliases are bare names — the one input that breaks the SQL face is the one input they never supply. They are a declared control for this card, not evidence about it.

The fix

SqlDriver.aliasIdentifierSql() renders an output-column alias as exactly one identifier via client.wrapIdentifier — the same function knex itself calls on each segment it split out, so the dialect's quoting and quote-doubling are unchanged and a host-supplied wrapIdentifier hook is still honoured. Only the segmentation goes away.

Applied at all five alias positions on the aggregate/window builders: the bucketed groupBy projection, the plain groupBy projection, count(*) as …, func(arg) as …, and the window-function alias. The fifth is the same one-line emission defect in the same builder family, named here rather than left half-closed.

Column references are deliberately untouched.field still binds through ?? and may still be qualified — a.b in a reference position really is table.column. Only the name after as is one identifier by definition. A fenced test asserts a qualified groupBy reference still resolves.

Not fixed at the entry validator. The reporter's controls establish the entry layer is correct; turning a legitimate "count by month" into a 400 would hide the defect and break a common chart shape.

Tests

packages/drivers/driver-sql/src/sql-driver-13714-aggregate-alias-single-identifier.test.ts — the emission layer, across the live-dialect matrix (declareDialectCell): SQLite embedded, Postgres and MySQL live when provisioned and reported as a named unprovisioned cell otherwise. Sweeps DateGranularity.options (the spec's own list, so a granularity the spec grows joins without an edit), plus the un-bucketed twin, the groupBy-alias twin, an alias containing " as ", and the controls.

packages/services/service-analytics/src/__tests__/timedimension-granularity-driver-alias.test.ts — the road, on better-sqlite3, the driver the report was filed against. Closes the link that was unpinned: that ObjectQLStrategy hands the driver an aggregation whose alias is the caller's cube-qualified measure name. The route itself (NativeSQLStrategy declines on a granularity) is already pinned in both directions by native-sql-granularity-decline.test.ts and is cited as a declared control rather than duplicated.

⚠️ A dialect that declines a granularity natively (SQLite + week, capped because %V needs SQLite 3.46) is asserted as its own declared answer — the #6212NOT_IMPLEMENTED/501 capability refusal that engine.aggregate reads off supports.queryDateGranularity and serves in memory instead. The invariant spanning both answers is the card's: no shape answers DATABASE_ERROR.

Ablation — direction predicted before running

Prediction, recorded first: reverting the emission fix should redden every non-bare-alias case; week on SQLite must stay green, since it is refused at the capability check before any statement is built.

Mutation = the file restored to its 62a137bae blob. Confirmed on disk by blob hash (32687ef2…9e8a58e6…) and by both marker counts moving (aliasIdentifierSql 7 → 0; the loose as ?? count 4 → 10) — never by an editor's exit code. For the cross-package leg, driver-sql was rebuilt on each leg and the mutation confirmed in dist/ by scripts/ablation-dist-preflight.mjs … --absent. Restore proven by state: whole-tree git status --porcelain clean, blob back to the HEAD blob, markers back, and the preflight confirming the marker is in dist/ again.

suitemutatedrestored
driver-sql alias identity10 red, 3 green13 green, 2 unprovisioned
service-analytics granularity route12 red, 1 green13 green

Green in both directions, therefore ⛔ declared controls, not ablation evidence: week on SQLite (both suites), and driver-sql's two bare-alias controls (a bare alias is unchanged, an alias EQUAL to the field). The prediction held with no revision.

The ablation also corrected an overclaim in the second suite: its un-bucketed cases reddened along with the bucketed ones, because that harness declares nativeSql: false and so forces them down the same door. They are named for what they measure — the same door, without a granularity — not as a reproduction of the reporter's controls, which travel the native face.

Measured / NOT MEASURED, by dialect

dialectemissionexecution
SQLite (better-sqlite3)measuredmeasured — red before, green after, both suites
Postgrespinned, NOT executed hereneeds OS_TEST_POSTGRES_URL — CI's live-dialect matrix job
MySQLpinned, NOT executed hereneeds OS_TEST_MYSQL_URL — CI's live-dialect matrix job

The split lives in knex's shared formatter, not in a dialect, so PG and MySQL are broken by construction and repaired by construction — but that is an argument, not a reading. ⛔ This PR does not present a green on SQLite as a measurement for the other two; the live cells are declared and will report on the CI matrix job.

Verification

Union run on 7d20ebda1 (git rev-parse --short HEAD from that run):

  • pnpm --filter @objectstack/driver-sql149 files / 2273 tests pass, 9 files + 136 tests skipped; tsc --noEmit exit 0 over a 569-file program that contains both changed files (--listFiles).
  • pnpm --filter @objectstack/service-analytics86 files / 1850 tests pass; tsc --noEmit exit 0 over a 537-file program containing the new pin.
  • Gate family re-derived from the actual diff with node scripts/pm/dispatch-gates.mjs (both output sections read whole; re-derived after the diff changed, and again after git fetch origin main — the set was stable at 36 across both reads): 36 commands, 35 green.
  • check:dual-build-cjs-loads and check:type-check-debt first exited 3 = PREREQUISITE NOT MET, both naming the same missing full-workspace dist/. That closure was then built (turbo run build --filter='./packages/*' --filter='./packages/*/*', 70/70 tasks) and both re-ran green — reported as measured only after clearing the prerequisite they named, never as a pass on the prerequisite state.
  • NOT MEASURED, and neither green nor red:scripts/check-test-completeness.mjs exits 3 because it grades a saved turbo run test log, which is a CI artifact this container does not produce.

Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs


Generated by Claude Code

…lified reference
A cube query carrying a `timeDimensions[].granularity` answered 500
DATABASE_ERROR. The bucket expression was never the fault: an analytics measure
is addressed on the wire as `<cube>.<measure>` and `ObjectQLStrategy` uses that
dotted name verbatim as the driver-level aggregation `alias`, and this face bound
the alias through knex's `??` placeholder — which parses an identifier rather
than quoting one, splitting on `.` into `table.column`. The statement reached the
database as ``count(*) as `showcase_delivery`.`count` `` and was refused before it
ran.
The granularity was the router, not the fault: `NativeSQLStrategy.canHandle`
declines exactly on a granularity and that face already hand-wrote
`AS "<measure>"`, so an un-bucketed cube query never reached this door while a
bucketed one always did — which is why the reporter's controls were 200.
`aliasIdentifierSql` renders an alias through `client.wrapIdentifier`, the same
function knex calls on each segment it split out, so only the segmentation goes
away. Applied at all five alias positions on the aggregate/window builders.
Column references still bind through `??` and may still be qualified.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
…-sqlite3 driver
Replaces the dogfood HTTP pin with one at the analytics layer, driving
AnalyticsService against a real SqlDriver on better-sqlite3 — the driver the
field report was filed against. It closes the link that was unpinned: that
ObjectQLStrategy hands the driver an aggregation whose `alias` is the caller's
cube-qualified measure name, which is why nothing but analytics ever put a dot
in an alias.
The un-bucketed cases are named for what they measure — the same door, without a
granularity — rather than as a reproduction of the reporter's controls: this
harness declares `nativeSql: false` so they take the failing door too, and the
ablation reddens them alongside the bucketed cases. The reporter's controls
travel the native face, whose fork is already pinned in both directions by
native-sql-granularity-decline.test.ts.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/driver-sql, touching 3 documentable anchor(s).

8 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/drivers.mdx(via SqlDriver (symbol, a top-level class))
  • content/docs/data-modeling/index.mdx(via SqlDriver (symbol, a top-level class))
  • content/docs/data-modeling/queries.mdx(via findWithWindowFunctions (symbol, a method of class SqlDriver))
  • content/docs/permissions/tenant-audit-census.mdx(via SqlDriver (symbol, a top-level class))
  • content/docs/plugins/packages.mdx(via SqlDriver (symbol, a top-level class))
  • content/docs/protocol/kernel/index.mdx(via SqlDriver (symbol, a top-level class))
  • content/docs/protocol/kernel/lifecycle.mdx(via SqlDriver (symbol, a top-level class))
  • content/docs/protocol/objectql/query-syntax.mdx(via SqlDriver (symbol, a top-level class), findWithWindowFunctions (symbol, a method of class SqlDriver))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via SqlDriver (symbol, a top-level class), findWithWindowFunctions (symbol, a method of class SqlDriver))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 9 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 09e4b0eceda31e7fed661ed334cff6b21d97425cpackageMentionDocs.

Which tree this was computed on

This run read content/docs from d0872a721ddcfd3fe237110db21ab6e427f25674 — the merge of head 7d20ebda1b2a5e170c1b61afe9b4da0e4541b6ee into base 09e4b0eceda31e7fed661ed334cff6b21d97425c, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin d0872a721ddcfd3fe237110db21ab6e427f25674 && git checkout d0872a721ddcfd3fe237110db21ab6e427f25674
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 09e4b0eceda31e7fed661ed334cff6b21d97425c 7d20ebda1b2a5e170c1b61afe9b4da0e4541b6ee && git checkout -B drift-repro 09e4b0eceda31e7fed661ed334cff6b21d97425c && git merge --no-ff 7d20ebda1b2a5e170c1b61afe9b4da0e4541b6ee
node scripts/docs-audit/affected-docs.mjs --json 09e4b0eceda31e7fed661ed334cff6b21d97425c

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 09e4b0eceda31e7fed661ed334cff6b21d97425c → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@os-steve
os-steve marked this pull request as ready for review September 1, 2026 06:11
@os-steve
os-steve added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 9e1b2deSep 1, 2026
38 of 40 checks passed
@os-steve
os-steve deleted the claude/issue-13714-timedimension-granularity-sql branch September 1, 2026 06:31
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

analytics: cube query with a time-dimension granularity (month) on a date dimension crashes 500 DATABASE_ERROR

2 participants

@os-steve@claude