Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions .changeset/time-update-interval-sub-day-retired.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
---
"@objectstack/spec": minor
"@objectstack/driver-memory": minor
---

fix(spec)!: `TimeUpdateInterval` retires its three sub-day intervals and derives its members from `DateGranularity` (#17296)

<!-- adr-0087: registered time-update-interval-sub-day-retired, cube-sub-day-granularities-removed -->

## ADR-0087 disposition

`second`, `minute` and `hour` leave a published closed enum that reaches TWO authored sites: an analytics request body's `timeDimensions[].granularity`, and an analytics cube dimension's `granularities[]`, which is stored metadata (`defineCube()` / `defineStack({ analyticsCubes })`). The stored half is rewritten by the D2 conversion `cube-sub-day-granularities-removed`, which strips the retired members from `analyticsCubes[].dimensions.<dim>.granularities` and drops the key entirely when nothing coarser remains (an empty list would read as "offers none", the absent key as "offers all"). The semantic entry `time-update-interval-sub-day-retired` carries the half no transform can decide: a dimension that offered ONLY sub-day intervals needs an author to say what it actually serves. `day`, `week`, `month`, `quarter` and `year` are untouched and parse byte-identically.

**BREAKING** for anyone authoring or sending `granularity: 'second'`,
`'minute'` or `'hour'`, and for anyone importing the `TimeUpdateInterval`
TYPE. Landing in the
launch window as `minor` under the lockstep convention this cluster's siblings
already use.

## What was wrong

`TimeUpdateInterval` declared **eight** intervals. The rest of the contract
never carried three of them, and this is the measurement rather than the
argument:

| layer | declares |
|:---|:---|
| `TimeUpdateInterval` (`data/analytics.zod.ts`) | **8** — the five below plus `second`, `minute`, `hour` |
| `DateGranularity` (`data/query.zod.ts`) — what a `groupBy` entry and every driver bucket expression are typed by | 5 |
| `@objectstack/core`'s `BUCKET_GRANULARITIES` — the canonical bucket-KEY output contract a drill-down crosses | 5 |
| `driver-mongodb`'s `MONGODB_DATE_GRANULARITIES` | 5 |

`DriverCapabilitiesSchema.supports.queryDateGranularity` — the one mechanism a
backend has for saying which granularities it buckets natively — is a
`z.record(DateGranularity, boolean)`. Measured: `{ day, week, month, quarter,
year }` parses; the same record plus `hour` raises `unrecognized_keys: ["hour"]`.
**No driver could advertise sub-day bucketing even if it had one.** That is what
makes this a retirement rather than a capability gap: a declared value one
backend cannot serve is a gap and the contract has a place to say so, but a
declared value *no* backend can even claim has no counterpart anywhere in the
contract that carries it.

Driven against the built packages, two rows fourteen hours apart on one UTC
calendar day, before this change:

| face | `granularity: 'hour'` | `granularity: 'day'` (control) |
|:---|:---|:---|
| `driver-memory` analytics | `NOT_IMPLEMENTED` / 501 | 1 group, `2026-09-06` |
| `driver-mongodb` bucket builder | `NOT_IMPLEMENTED` / 501 | `$dateToString` `%Y-%m-%d` |
| engine in-memory aggregation — the fallback every SQL/ObjectQL analytics query carrying a granularity lands on, since `NativeSQLStrategy` declines on a granularity | **200, 2 groups keyed on the RAW instant** | 1 group, `2026-09-06` |

Two honest refusals and one silently wrong answer. No third behaviour, and no
backend that bucketed it.

## What changed

- `TimeUpdateInterval` is now `z.enum(DateGranularity.options, …)` — the members
come from the single source instead of a second literal list that disagreed
with it by three members for as long as both existed.
- A refusal message splits two populations that are not the same mistake: a
**retired** sub-day name gets the retirement and the `os migrate meta --from
17` line; anything else gets the vocabulary. `driver-memory`'s own analytics
door carries the same split.
- `driver-memory`'s `NOT_IMPLEMENTED` / 501 answer for these three is **not
silenced** — the declaration it announced is gone, so the class moves to the
400 the retirement makes correct. The 501 arm stays, and a pin measures that
its population is now empty (`TimeUpdateInterval.options` equals
`BUCKET_GRANULARITIES`), so the day one of the two is widened alone it lights
up again instead of a freshly declared value being called undeclared.

## What this does NOT decide

Sub-day analytics bucketing as a **capability**. Offering it means widening
`DateGranularity`, the `queryDateGranularity` record, the canonical bucket-key
vocabulary and every driver's bucket expression together — new capability,
decided as such, rather than a name that parses in one enum and resolves
nowhere.
4 changes: 2 additions & 2 deletions content/docs/references/api/analytics.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ const result = AnalyticsEndpoint.parse(data);
| **measures** | `string[]` | ✅ | List of metrics to calculate |
| **dimensions** | `string[]` | optional | List of dimensions to group by |
| **where** | `any` | optional | Filtering criteria (canonical Query DSL FilterCondition). An authored `FilterArray` is lowered by `parseFilterAST` on the client before the wire; this field admits only the lowered `FilterCondition` (see `FilterArray` in `data/filter.zod.ts`). |
| **timeDimensions** | `{ dimension: string; granularity?: Enum<'second' \| 'minute' \| 'hour' \| 'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>; dateRange?: Enum<'today' \| 'yesterday' \| 'this_week' \| 'last_week' \| 'this_month' \| 'last_month' \| …> \| string[] }[]` | optional | Time-bucketed dimensions. Each entry names a dimension, an optional bucket `granularity`, and an optional `dateRange` — a preset name from the closed date-range vocabulary (e.g. `'last_7_days'`) or an explicit `[start, end]` window; an unrecognised string answers `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` instead of silently widening. |
| **timeDimensions** | `{ dimension: string; granularity?: Enum<'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>; dateRange?: Enum<'today' \| 'yesterday' \| 'this_week' \| 'last_week' \| 'this_month' \| 'last_month' \| …> \| string[] }[]` | optional | Time-bucketed dimensions. Each entry names a dimension, an optional bucket `granularity`, and an optional `dateRange` — a preset name from the closed date-range vocabulary (e.g. `'last_7_days'`) or an explicit `[start, end]` window; an unrecognised string answers `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` instead of silently widening. |
| **order** | `Record<string, Enum<'asc' \| 'desc'>>` | optional | |
| **limit** | `number` | optional | |
| **offset** | `number` | optional | |
Expand All @@ -97,7 +97,7 @@ const result = AnalyticsEndpoint.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **dimension** | `string` | ✅ | |
| **granularity** | `Enum<'second' \| 'minute' \| 'hour' \| 'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>` | optional | |
| **granularity** | `Enum<'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>` | optional | |
| **dateRange** | `Enum<'today' \| 'yesterday' \| 'this_week' \| 'last_week' \| 'this_month' \| 'last_month' \| …> \| string[]` | optional | Time window for this dimension: a date-range PRESET name from the closed vocabulary in `data/date-range-presets.ts` (today, yesterday, this_week, last_week, this_month, last_month, this_quarter, last_quarter, this_year, last_year, last_7_days, last_30_days, last_90_days — e.g. `'last_7_days'`), or an explicit `[start, end]` array of ISO dates / `{date-macro}` tokens (e.g. `["2023-01-01", "2023-01-31"]`). Any other string is refused at the schema with `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED`. |


Expand Down
11 changes: 4 additions & 7 deletions content/docs/references/data/analytics.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ Type: `string[]`
| **measures** | `string[]` | ✅ | List of metrics to calculate |
| **dimensions** | `string[]` | optional | List of dimensions to group by |
| **where** | `any` | optional | Filtering criteria (canonical Query DSL FilterCondition). An authored `FilterArray` is lowered by `parseFilterAST` on the client before the wire; this field admits only the lowered `FilterCondition` (see `FilterArray` in `data/filter.zod.ts`). |
| **timeDimensions** | `{ dimension: string; granularity?: Enum<'second' \| 'minute' \| 'hour' \| 'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>; dateRange?: Enum<'today' \| 'yesterday' \| 'this_week' \| 'last_week' \| 'this_month' \| 'last_month' \| …> \| string[] }[]` | optional | Time-bucketed dimensions. Each entry names a dimension, an optional bucket `granularity`, and an optional `dateRange` — a preset name from the closed date-range vocabulary (e.g. `'last_7_days'`) or an explicit `[start, end]` window; an unrecognised string answers `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` instead of silently widening. |
| **timeDimensions** | `{ dimension: string; granularity?: Enum<'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>; dateRange?: Enum<'today' \| 'yesterday' \| 'this_week' \| 'last_week' \| 'this_month' \| 'last_month' \| …> \| string[] }[]` | optional | Time-bucketed dimensions. Each entry names a dimension, an optional bucket `granularity`, and an optional `dateRange` — a preset name from the closed date-range vocabulary (e.g. `'last_7_days'`) or an explicit `[start, end]` window; an unrecognised string answers `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` instead of silently widening. |
| **order** | `Record<string, Enum<'asc' \| 'desc'>>` | optional | |
| **limit** | `number` | optional | |
| **offset** | `number` | optional | |
Expand All @@ -109,7 +109,7 @@ Type: `string[]`
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **dimension** | `string` | ✅ | |
| **granularity** | `Enum<'second' \| 'minute' \| 'hour' \| 'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>` | optional | |
| **granularity** | `Enum<'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>` | optional | |
| **dateRange** | `Enum<'today' \| 'yesterday' \| 'this_week' \| 'last_week' \| 'this_month' \| 'last_month' \| …> \| string[]` | optional | Time window for this dimension: a date-range PRESET name from the closed vocabulary in `data/date-range-presets.ts` (today, yesterday, this_week, last_week, this_month, last_month, this_quarter, last_quarter, this_year, last_year, last_7_days, last_30_days, last_90_days — e.g. `'last_7_days'`), or an explicit `[start, end]` array of ISO dates / `{date-macro}` tokens (e.g. `["2023-01-01", "2023-01-31"]`). Any other string is refused at the schema with `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED`. |


Expand Down Expand Up @@ -158,7 +158,7 @@ Type: `string[]`
| **description** | `string` | optional | |
| **type** | `Enum<'string' \| 'number' \| 'boolean' \| 'time' \| 'geo'>` | ✅ | |
| **sql** | `string` | ✅ | SQL expression or column reference |
| **granularities** | `Enum<'second' \| 'minute' \| 'hour' \| 'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>[]` | optional | |
| **granularities** | `Enum<'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>[]` | optional | |

### Nested Shape: `Cube.joins[string]`

Expand Down Expand Up @@ -202,7 +202,7 @@ Type: `string[]`
| **description** | `string` | optional | |
| **type** | `Enum<'string' \| 'number' \| 'boolean' \| 'time' \| 'geo'>` | ✅ | |
| **sql** | `string` | ✅ | SQL expression or column reference |
| **granularities** | `Enum<'second' \| 'minute' \| 'hour' \| 'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>[]` | optional | |
| **granularities** | `Enum<'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>[]` | optional | |


---
Expand Down Expand Up @@ -240,9 +240,6 @@ Type: `string[]`

### Allowed Values

* `second`
* `minute`
* `hour`
* `day`
* `week`
* `month`
Expand Down
30 changes: 28 additions & 2 deletions packages/drivers/driver-memory/src/filter-refusal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ import { BUCKET_GRANULARITIES } from '@objectstack/core';
// [#16178] and the DECLARED interval vocabulary, quoted the same way, so the
// refusal can tell a value the contract never declared apart from one it
// declares and this backend cannot label.
import { TimeUpdateInterval } from '@objectstack/spec/data';
import { RETIRED_SUB_DAY_INTERVALS, TimeUpdateInterval } from '@objectstack/spec/data';
import { StandardErrorCode } from '@objectstack/spec/api';

/**
Expand Down Expand Up @@ -133,6 +133,17 @@ export function refusePerAggregationFilter(alias: string): never {
* interval this backend cannot label reaches the 501 arm. Same separation the
* `dateRange` half of this face already draws (#16322 / #16041).
*
* ⚠️ **[#17296] The 501 arm's population is EMPTY as of protocol 18, and the arm
* stays anyway.** That card retired the three sub-day names from
* `TimeUpdateInterval`, so the declared set and {@link BUCKET_GRANULARITIES} are
* now the same five and no value can be declared-but-unbucketable here. The arm
* is not dead code being kept for sentiment: it is the guard that catches the
* two vocabularies diverging AGAIN, which is the state this whole card
* documents. Deleting it would mean the next widening of `TimeUpdateInterval`
* alone answers a 400 calling a freshly declared value undeclared — the same
* lie in the other direction. `memory-analytics-time-granularity.test.ts` pins
* the emptiness so the claim is measured rather than asserted.
*
* ⚠️ The 400 arm answers the GENERAL `StandardErrorCode.INVALID_QUERY` rather
* than a dedicated `ANALYTICS_GRANULARITY_UNRECOGNIZED` — the shape its
* `dateRange` sibling uses — because a dedicated code has to be registered in
Expand All @@ -143,10 +154,25 @@ export function refusePerAggregationFilter(alias: string): never {
export function unsupportedTimeGranularityError(dimension: string, granularity: string): Error {
const declaredIntervals = TimeUpdateInterval.options as readonly string[];
if (!declaredIntervals.includes(granularity)) {
// [#17296] A RETIRED name is not the same mistake as a name that never
// existed, and it reaches this arm for a different reason: the author wrote
// a spelling the spec declared until protocol 18, so the sentence they need
// is the retirement, not the vocabulary. Without this branch an author
// upgrading from 17 is told `hour` "is not declared", which is true today
// and useless — it reads as a typo and hides the fact that there is no
// sub-day bucket to migrate to at any granularity.
const retired = (RETIRED_SUB_DAY_INTERVALS as readonly string[]).includes(granularity);
const outOfVocabulary = new Error(
`Time dimension "${dimension}" asks for granularity "${granularity}", which @objectstack/spec's ` +
`TimeUpdateInterval does not declare — the declared intervals are ` +
`${declaredIntervals.join(', ')}. This is a mistake in the query rather than a gap in this ` +
`${declaredIntervals.join(', ')}. ` +
(retired
? `It was declared until protocol 18 and was retired there (ADR-0049 enforce-or-remove): no ` +
`backend ever bucketed a sub-day interval and none could advertise one, so there is no ` +
`finer bucket to migrate to. Ask for the coarsest interval that still answers your ` +
`question, or drop the key and group on the raw timestamp deliberately. `
: ``) +
`This is a mistake in the query rather than a gap in this ` +
`backend (driver-memory), so it answers a 400 rather than the 501 a declared-but-unbucketable ` +
`interval gets. Ask for one of ${BUCKET_GRANULARITIES.join(', ')}, which this backend buckets.`,
) as Error & { code?: string; status?: number };
Expand Down
Loading
Loading