Skip to content

fix(analytics): make organization timezone drive date-dimension bucketing (#1982) - #2018

Merged
os-zhuang merged 1 commit into
mainfrom
fix/analytics-tz-bucketing
Jun 18, 2026
Merged

fix(analytics): make organization timezone drive date-dimension bucketing (#1982)#2018
os-zhuang merged 1 commit into
mainfrom
fix/analytics-tz-bucketing

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Problem

A follow-up to ADR-0053 Phase 2 (#1982) browser verification: setting a non-UTC organization timezone (localization manifest, #2006) did not change a date-bucketed analytics chart. The unit tests for the tz bucketing engine passed, but the result never reached the API — the classic "passes build / silent at runtime" gap.

Tracing it revealed three independent broken seams, all required for an org timezone to actually drive a date bucket:

  1. @objectstack/service-analytics — wrong strategy won.NativeSQLStrategy (priority 10) handled every cube/dataset query on a SQL driver, but it groups by the raw column (GROUP BY <col>, no date_trunc) and references timezone nowhere. So date dimensions never bucketed (one row per raw timestamp) and a non-UTC zone was silently dropped. It now declines queries carrying a timeDimensions[].granularity, handing them to the lower-priority ObjectQLStrategyengine.aggregate (native bucketing when UTC-safe, uniform in-memory bucketing when non-UTC).

  2. @objectstack/objectql — count-all returned 0 in-memory. The Cube count measure and a fieldless dataset count both compile to sql: '*'. The in-memory count branch treated '*' as a column name and counted non-null of a non-existent property → 0 for every bucket. The driver's COUNT(*) masked it, but the in-memory path (non-UTC date buckets, driver-rest/driver-memory) returned zeros. '*' is now counted as all rows.

  3. @objectstack/rest — context had no timezone.resolveExecCtx never resolved the localization timezone/locale, so /analytics/dataset/query always ran with timezone: 'UTC'. It now resolves them through the settings service (honouring the 4-tier cascade incl. the OS_LOCALIZATION_TIMEZONE env override), mirroring the dispatcher path's resolveLocalization.

Verification

End-to-end against example-crm with a lead created at …T03:5x:…Z (UTC day 06-18, LA day 06-17):

ScenarioBeforeAfter
org tz = UTC (settings)06-18 cnt 506-18 cnt 5
org tz = America/Los_Angeles (settings)06-18 cnt 5 ❌06-17 cnt 5
explicit selection.timezone=America/LA06-17×… cnt 0 ❌ (uncollapsed)06-17 cnt 5

Tests

  • New regression native-sql-granularity-decline.test.ts — pins the production capability shape (nativeSql: true AND objectqlAggregate: true); a granularity query must route to executeAggregate, not raw SQL. (The pre-existing granularity test forced nativeSql:false, which masked the bug.)
  • New in-memory-aggregation cases for the '*' count-all sentinel (ungrouped + per tz bucket).
  • Full suites green: objectql 660, service-analytics 127, rest 121.

🤖 Generated with Claude Code

…ting (#1982)
Date-bucketed analytics silently ignored the reference timezone end-to-end.
Three independent seams were broken; all three are needed for an org's
localization timezone to actually change a date-bucketed chart:
1. service-analytics — NativeSQLStrategy (priority 10) won every cube/dataset
query on a SQL driver, but it groups by the raw column (no date_trunc) and
ignores `timezone`, so date dimensions never bucketed (one row per raw
timestamp) and a non-UTC zone was dropped. It now declines queries carrying
a timeDimensions[].granularity, handing them to ObjectQLStrategy →
engine.aggregate (native bucketing UTC-safe, uniform in-memory when non-UTC).
2. objectql — the in-memory `count` aggregation treated the `*` count-all
sentinel (Cube `count` measure / fieldless dataset `count`, both compiled to
sql:'*') as a column name → counted 0 for every bucket. The driver COUNT(*)
masked it; the in-memory path (non-UTC date buckets, driver-rest/-memory)
returned zeros. `*` is now counted as all rows.
3. rest — resolveExecCtx never resolved the localization timezone/locale, so
/analytics/dataset/query always ran with timezone:'UTC'. It now resolves
them through the settings service (4-tier cascade incl. the
OS_LOCALIZATION_TIMEZONE env override), mirroring the dispatcher path.
Verified end-to-end against example-crm: an org tz of America/Los_Angeles now
buckets a 03:5x UTC lead into 2026-06-17 (cnt 5) vs UTC's 2026-06-18 (cnt 5).
Regression tests added for seams 1 and 2; full suites green
(objectql 660, service-analytics 127, rest 121).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@vercel

vercelBot commented Jun 18, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated (UTC)
specReadyReadyPreview, CommentJun 18, 2026 4:08am

Request Review

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling size/m labels Jun 18, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/objectql, @objectstack/rest, packages/services.

19 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/concepts/core/services.mdx(via @objectstack/objectql)
  • content/docs/concepts/implementation-status.mdx(via @objectstack/objectql, @objectstack/rest)
  • content/docs/concepts/metadata-lifecycle.mdx(via @objectstack/objectql)
  • content/docs/concepts/packages.mdx(via @objectstack/objectql, @objectstack/rest)
  • content/docs/guides/api-reference.mdx(via @objectstack/rest)
  • content/docs/guides/authentication.mdx(via @objectstack/objectql)
  • content/docs/guides/deployment-vercel.mdx(via @objectstack/objectql)
  • content/docs/guides/formula.mdx(via packages/objectql)
  • content/docs/guides/kernel-services.mdx(via @objectstack/objectql)
  • content/docs/guides/objectql-migration.mdx(via @objectstack/objectql)
  • content/docs/guides/packages.mdx(via @objectstack/objectql, @objectstack/rest, packages/services)
  • content/docs/guides/plugins.mdx(via @objectstack/objectql, @objectstack/rest)
  • content/docs/guides/runtime-services/audit-service.mdx(via packages/services)
  • content/docs/guides/runtime-services/index.mdx(via packages/services)
  • content/docs/guides/runtime-services/settings-service.mdx(via packages/services)
  • content/docs/protocol/objectos/i18n-standard.mdx(via packages/services)
  • content/docs/protocol/objectos/index.mdx(via @objectstack/objectql)
  • content/docs/protocol/objectql/state-machine.mdx(via @objectstack/objectql)
  • content/docs/releases/v9.mdx(via @objectstack/objectql)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@os-zhuang
os-zhuang merged commit fd07027 into mainJun 18, 2026
14 checks passed
@os-zhuang
os-zhuang deleted the fix/analytics-tz-bucketing branch June 18, 2026 04:55
os-zhuang added a commit that referenced this pull request Jun 18, 2026
Static gates (build, unit tests, spec-liveness, CodeQL) verify each layer in
isolation, usually against mocks, and cannot catch a break that only appears
when the real engine + strategies + services + HTTP context run together. The
#2018 tz-bucketing regression is the canonical case: green on every static gate
(900+ unit tests included) because each of its three broken seams was
individually correct and individually mocked.
This adds `@objectstack/dogfood` (private), a harness that boots real example
apps in-process against in-memory SQLite — wired with the same service plugins
`objectstack dev` loads — and exercises them through the real HTTP surface via
Hono request-injection (no ports, no sockets, CI-stable). Tests act as a browser
client: sign in, hit /api/v1/..., assert on real responses.
- src/harness.ts — bootDogfoodStack(config) → { kernel, api, raw, signIn, apiAs, stop }
- test/analytics-timezone.dogfood.test.ts — golden regression for #1982/#2018:
org timezone (set via the real settings route) must shift a boundary-crossing
instant's date bucket (UTC 2024-03-01 → America/Los_Angeles 2024-02-29) with
the correct count. Verified to FAIL when any #2018 seam is reverted.
- CI: new "Dogfood Regression Gate" job (also runs under turbo run test).
Boot-to-assert ~2s. Private package — no changeset.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
os-zhuang added a commit that referenced this pull request Jun 18, 2026
…2024)
* docs(adr): ADR-0054 prove-it-runs gate for the authorable surface
Extends ADR-0049 (enforce-or-remove) with a third leg. The liveness ledger
(#1919) classifies every authorable property live/experimental/dead, but "live"
means only a static file:line consumer pointer — proof that something reads the
property, not that authoring it produces correct runtime behavior. #2018 (tz
bucketing: live at every layer, broken in integration) and the field-type
fidelity gaps (#2022: rating/slider/toggle read back wrong-typed) fell through
that gap — call it "unproven liveness".
For a platform whose authors are AI emitting metadata across a combinatorial
space the examples never cover, unproven liveness ships silently into
third-party apps. ADR-0054 upgrades a `live` classification to optionally carry a
`proof` — a @objectstack/dogfood test that authors the property against the real
in-process stack and asserts the runtime outcome. Required as a ratchet (not a
retrofit) for a high-risk authorable class on change, and for any property
implicated in a shipped regression (the fix carries its proof). Generative
testing is explicitly deferred (Phase 3, evidence-gated).
Proposed — for architect review.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* docs(adr): accept ADR-0054 (prove-it-runs gate)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
os-zhuang pushed a commit that referenced this pull request Jun 19, 2026
…0054)
Unlocks the analytics binding that was previously blocked because its authorable
surface wasn't governed.
- Govern the `dataset` metadata type: new liveness/dataset.json classifies all 19
authorable props with file:line evidence from the analytics service consumer
audit (26 live, 1 dead — measures.certified has no runtime consumer). `dataset`
added to GOVERNED in check-liveness.mts.
- Bind the analytics class: dataset.dimensions.dateGranularity carries the
tz-bucketing proof (#1982/#2018) — the property whose org-timezone day-bucket
behavior the proof asserts. proof-registry.mts flips analytics to bound.
- Four high-risk classes now CI-enforced: field types, RLS, flow nodes, analytics.
- README governed-types + high-risk tables updated; changeset description updated.
Verified: liveness gate green (dataset 27 classified); 17 registry tests; the
analytics proof runs green; full dogfood suite 59/59.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XVdnfUAx85amkerym26vdx
os-zhuang added a commit that referenced this pull request Jun 19, 2026
…h-risk classes (ADR-0054 #1 + Phase 2) (#2045)
* feat(spec): add prove-it-runs proof field + ratchet to liveness gate (ADR-0054 #1)
ADR-0054 follow-up (1): the liveness ledger's `live` meant only a static
consumer pointer — necessary but not sufficient, since a property can be live at
every layer yet broken end-to-end. This adds the third leg: high-risk authorable
properties must carry a `proof` (a dogfood test reference) that asserts the
runtime outcome.
- proof-registry.mts: the authoritative high-risk-class list (field types,
analytics, RLS, flow nodes, form widgets) + which classes the ratchet enforces
this phase. Field types and RLS are bound (matrix exists AND surface governed);
analytics/flow/form are listed-but-blocked with honest reasons (their surface
isn't governed yet / no proof yet — Phase 2).
- check-liveness.mts: a bound `live` entry must carry a valid `proof` of its own
class. Validation is STATIC (file exists + declares the `@proof: <id>` tag) so
the gate stays seconds-cheap; running the proof remains the dogfood gate's job.
Reverse check flags unregistered `@proof:` tags.
- Ledger: field.type → field-zoo proof; permission.rowLevelSecurity.using →
#1994 RLS proof. Dogfood proofs self-declare their `@proof:` tag.
- Gate now also triggers on packages/dogfood/** so deleting/renaming a proof
re-runs the check and the dangling reference is caught.
- 15 unit + wiring tests; README documents the contract and ratchet.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XVdnfUAx85amkerym26vdx
* chore: add changeset for the liveness prove-it-runs gate change
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XVdnfUAx85amkerym26vdx
* feat(verify,spec): bind flow-node high-risk class with a runtime proof (ADR-0054 Phase 2)
Phase 2 extends the prove-it-runs ratchet to the flow-node class.
- verify: bootStack gains an opt-in `automation` flag that registers
@objectstack/service-automation, so authored flows are pulled from the registry
and POST /automation/:name/trigger runs their nodes. Without it, flow execution
was unreachable through the harness (the dispatcher's automation routes resolved
no service). Mirrors the existing `multiTenant`/`security` opt-ins; default off.
- dogfood: a self-contained flow fixture (one object + one autolaunched flow whose
update_record node stamps a record) + the flow-node proof. It authors the flow,
triggers it over HTTP, and asserts both directions — the targeted record is
stamped (node executed) AND a bystander is untouched (the input variable wired
into the node filter, not a blanket update). Runs green end-to-end.
- spec: flow-node class is now `bound` in proof-registry.mts; flow.nodes.type
carries the proof; the liveness gate enforces it. Three classes now bound:
field types, RLS, flow nodes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XVdnfUAx85amkerym26vdx
* feat(spec): govern dataset + bind the analytics high-risk class (ADR-0054)
Unlocks the analytics binding that was previously blocked because its authorable
surface wasn't governed.
- Govern the `dataset` metadata type: new liveness/dataset.json classifies all 19
authorable props with file:line evidence from the analytics service consumer
audit (26 live, 1 dead — measures.certified has no runtime consumer). `dataset`
added to GOVERNED in check-liveness.mts.
- Bind the analytics class: dataset.dimensions.dateGranularity carries the
tz-bucketing proof (#1982/#2018) — the property whose org-timezone day-bucket
behavior the proof asserts. proof-registry.mts flips analytics to bound.
- Four high-risk classes now CI-enforced: field types, RLS, flow nodes, analytics.
- README governed-types + high-risk tables updated; changeset description updated.
Verified: liveness gate green (dataset 27 classified); 17 registry tests; the
analytics proof runs green; full dogfood suite 59/59.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XVdnfUAx85amkerym26vdx
---------
Co-authored-by: Claude <noreply@anthropic.com>
os-zhuang added a commit that referenced this pull request Jun 27, 2026
feat(console): "Edit with AI" — open the build agent pre-scoped to an app's package (#2018)
objectui@2021b5f4a89c1940b2b3aa751ce4e33fe971974a
os-zhuang added a commit that referenced this pull request Jul 30, 2026
… validate /analytics bodies at the entry (#3878) (#4010)
The spec's AnalyticsQueryRequestSchema described a {cube, query, format}
ENVELOPE — the dialect of the retired degraded shim (#3891) that the
real engine never understood: an envelope body inferred a column-less
cube and died as a driver SQL syntax error (SELECT FROM ...) instead of
a shape error, and a non-contract `filters` key was silently ignored.
That mismatch sent #3867's first investigation to a wrong conclusion.
Spec:
- AnalyticsQueryRequestSchema = bare AnalyticsQuery + required top-level
`cube`, `.strict()`. `query` and `format` are retiredKey-tombstoned
(tsc `never` + parse-time prescription); `format` was never
implemented (declared != enforced).
- Registered as two step-17 semantic migrations (an HTTP-wire change
with no stored metadata to rewrite); spec-changes.json, upgrade
guide, authorable-surface, JSON schemas, OpenAPI, reference docs
regenerated.
Runtime:
- POST /analytics/query and /analytics/sql validate the body at the
entry and raise the duck-typed VALIDATION_FAILED shape both
dispatcher error exits already map to 400 + fields[] (#3918) — the
envelope answers with the tombstone's migration text, `filters` gets
a bespoke hint at `where`. The domain throws through, matching its
existing error contract; the wire-level 400 is pinned in
dispatcher-validation-error.real.test.ts.
- A valid body is forwarded byte-identical (validation only): parsing
would inject the schema's timezone 'UTC' default and override
org-timezone resolution (#1982/#2018).
- Service-absent still answers 404 before body inspection (#3891).
Tests: 5 new entry-validation cases + wire-level 400/200 pins; 9 legacy
fixtures updated to contract-valid bodies.
Refs #3878, #3891, #3867, #3918. Part 1 of the #3878 follow-through;
route-mount conditionality is the next PR.
Claude-Session: https://claude.ai/code/session_017WZBR9XyhXoqKCU6qQVyjx
Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Aug 2, 2026
…es (objectstack-ai#4538) (objectstack-ai#4568)
Each of the 11 baseline rows judged individually against a three-repo
import-level scan (framework, cloud, objectui):
- Converged onto the domain zod declaration, re-exported from ./contracts:
NotificationChannel (system), ValidationResult / HealthStatus /
StartupOptions / PluginStartupResult (kernel), JobExecution (system,
after renaming the schema's dead `duration` to the runtime-true
`durationMs`), AnalyticsQuery (data, after the schema dropped the
`timezone` .default('UTC') the /analytics entry always refused to
apply - absence means the engine resolves org timezone, objectstack-ai#1982/objectstack-ai#2018).
IStartupOrchestrator.orchestrateStartup now takes StartupOptionsInput.
- Renamed: contracts DriverCapabilities -> AnalyticsDriverCapabilities
(two live concepts shared one name; the data domain's driver
feature-flag record keeps it). @objectstack/service-analytics re-export
renamed in lockstep.
- Removed dead domain-side declarations (zero consumers in all three
repos; last of the objectstack-ai#4411 family): system MetadataExportOptions(Schema) /
MetadataImportOptions(Schema) - the contracts IMetadataService
parameter interfaces own the names now - and the system
`JobSchedule = Schedule` back-compat alias (authoring tier keeps its
real name, Schedule). @objectstack/metadata now re-exports the two
Metadata*Options names from ./contracts, the shape its own manager
implements.
All 11 dual-source-exports.baseline.json rows deleted; baselines
(api-surface, authorable-surface, json-schema.manifest, reference docs)
regenerated via check:generated --fix; the authorable-surface lines for
the removed runtime option bags deleted by hand per the objectstack-ai#4458 precedent
(not authorable metadata - no tombstone, no D2 conversion).
Out-of-scope finding filed unassigned as objectstack-ai#4567 (defineJob cron envelope
vs croner).
Claude-Session: https://claude.ai/code/session_01M9uWvoEp9CoLzYjNExj9sL
Co-authored-by: Claude <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@os-zhuang