From ca306557bd627b15ce26b62f875f2c04adc24167 Mon Sep 17 00:00:00 2001 From: os-bill Date: Sat, 12 Sep 2026 18:50:26 +0000 Subject: [PATCH 1/4] fix(spec)!: retire TimeUpdateInterval's three sub-day intervals (#17296) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `second` / `minute` / `hour` were declared by `TimeUpdateInterval` alone. Every other layer of the same contract stops at five: `DateGranularity`, core's `BUCKET_GRANULARITIES`, `MONGODB_DATE_GRANULARITIES`, and — decisively — `supports.queryDateGranularity`, a `z.record(DateGranularity, boolean)` that raises `unrecognized_keys` on `hour`, so no driver could advertise sub-day bucketing even if it had one. Measured on the built packages: `driver-memory` 501, `driver-mongodb` 501, and the engine's in-memory aggregation 200 with one group per distinct timestamp. The enum now derives its members from `DateGranularity`. A retired spelling gets the retirement prescription and the migrate line; anything else gets the vocabulary. `driver-memory` carries the same split at its own door, and keeps its 501 arm with a pin measuring that the arm's population is now empty. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH --- .../time-update-interval-sub-day-retired.md | 73 ++++++++++++ content/docs/references/api/analytics.mdx | 4 +- content/docs/references/data/analytics.mdx | 11 +- .../driver-memory/src/filter-refusal.ts | 30 ++++- .../memory-analytics-time-granularity.test.ts | 72 ++++++++---- packages/spec/api-surface/data.json | 2 + packages/spec/export-origins/data.json | 2 + packages/spec/src/conversions/registry.ts | 108 ++++++++++++++++++ packages/spec/src/data/analytics.test.ts | 58 +++++++++- packages/spec/src/data/analytics.zod.ts | 90 ++++++++++++++- ...18.time-update-interval-sub-day-retired.ts | 42 +++++++ packages/spec/src/migrations/registry.ts | 39 +++++++ 12 files changed, 492 insertions(+), 39 deletions(-) create mode 100644 .changeset/time-update-interval-sub-day-retired.md create mode 100644 packages/spec/src/migrations/entries/semantic/18.time-update-interval-sub-day-retired.ts diff --git a/.changeset/time-update-interval-sub-day-retired.md b/.changeset/time-update-interval-sub-day-retired.md new file mode 100644 index 0000000000..0b696cfd44 --- /dev/null +++ b/.changeset/time-update-interval-sub-day-retired.md @@ -0,0 +1,73 @@ +--- +"@objectstack/spec": minor +"@objectstack/driver-memory": minor +--- + +fix(spec)!: `TimeUpdateInterval` retires its three sub-day intervals and derives its members from `DateGranularity` (#17296) + + + +**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. diff --git a/content/docs/references/api/analytics.mdx b/content/docs/references/api/analytics.mdx index b6e3689ad4..da965b721c 100644 --- a/content/docs/references/api/analytics.mdx +++ b/content/docs/references/api/analytics.mdx @@ -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>` | optional | | | **limit** | `number` | optional | | | **offset** | `number` | optional | | @@ -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`. | diff --git a/content/docs/references/data/analytics.mdx b/content/docs/references/data/analytics.mdx index 51f8309bb3..817e13c500 100644 --- a/content/docs/references/data/analytics.mdx +++ b/content/docs/references/data/analytics.mdx @@ -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>` | optional | | | **limit** | `number` | optional | | | **offset** | `number` | optional | | @@ -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`. | @@ -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]` @@ -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 | | --- @@ -240,9 +240,6 @@ Type: `string[]` ### Allowed Values -* `second` -* `minute` -* `hour` * `day` * `week` * `month` diff --git a/packages/drivers/driver-memory/src/filter-refusal.ts b/packages/drivers/driver-memory/src/filter-refusal.ts index daa595b4ad..f558a76396 100644 --- a/packages/drivers/driver-memory/src/filter-refusal.ts +++ b/packages/drivers/driver-memory/src/filter-refusal.ts @@ -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'; /** @@ -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 @@ -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 }; diff --git a/packages/drivers/driver-memory/src/memory-analytics-time-granularity.test.ts b/packages/drivers/driver-memory/src/memory-analytics-time-granularity.test.ts index 9289de6ec8..364a64a91b 100644 --- a/packages/drivers/driver-memory/src/memory-analytics-time-granularity.test.ts +++ b/packages/drivers/driver-memory/src/memory-analytics-time-granularity.test.ts @@ -21,7 +21,8 @@ import { describe, it, expect } from 'vitest'; import { InMemoryDriver } from './memory-driver.js'; import { MemoryAnalyticsService } from './memory-analytics.js'; -import { AnalyticsQuerySchema } from '@objectstack/spec/data'; +import { AnalyticsQuerySchema, TimeUpdateInterval } from '@objectstack/spec/data'; +import { BUCKET_GRANULARITIES } from '@objectstack/core'; import type { AnalyticsQuery, Cube } from '@objectstack/spec/data'; /** Every query goes through the schema, the route a real request body takes. */ @@ -281,21 +282,42 @@ describe('[#16178] bucketing and the reference timezone', () => { }); describe('[#16178] a sub-day granularity is refused, not dropped', () => { + // [#17296] These three were refused as NOT_IMPLEMENTED/501 when this file was + // written, because `TimeUpdateInterval` still DECLARED them and this backend + // could not bucket them — a capability gap, stated honestly. #17296 measured + // that no backend could bucket them and none could even advertise them + // (`supports.queryDateGranularity` is a record over the five-member + // `DateGranularity`), and retired the three from the spec. The refusal did + // not get quieter: its CLASS moved, from "this backend cannot" to "the + // contract does not declare it", which is the honest sentence once the + // declaration is gone. Both halves are pinned below. it.each(['second', 'minute', 'hour'])( - 'refuses %s with the NOT_IMPLEMENTED/501 envelope', + 'refuses %s — now as the 400 the retirement makes correct, carrying the retirement prescription', async (granularity) => { - // Asserted on `code` and `status` — the ADR-0112 envelope — never on the - // message text. A bare `toThrow()` would pass against a driver that threw - // for any unrelated reason. - await expect( - query({ - ...BASE, - timeDimensions: [{ dimension: 'events.createdAt', granularity: granularity as 'hour' }], - }), - ).rejects.toMatchObject({ code: 'NOT_IMPLEMENTED', status: 501 }); + // Asserted on `code` and `status` — the ADR-0112 envelope — plus the one + // sentence an upgrading author needs, because "is not declared" alone + // reads to them as a typo. A bare `toThrow()` would pass against a driver + // that threw for any unrelated reason. + const thrown = await query({ + ...BASE, + timeDimensions: [{ dimension: 'events.createdAt', granularity: granularity as 'day' }], + }).catch((e: Error & { code?: string; status?: number }) => e); + expect(thrown).toMatchObject({ code: 'INVALID_QUERY', status: 400 }); + expect((thrown as Error).message).toContain('retired'); + expect((thrown as Error).message).toContain('protocol 18'); }, ); + it('the 501 arm still EXISTS and its population is empty — measured, not asserted', async () => { + // The claim `filter-refusal.ts` makes in prose, as a reading. 501 answers + // "declared here, unbucketable here", so its population is exactly + // TimeUpdateInterval minus BUCKET_GRANULARITIES. The two sets are equal as + // of protocol 18, which is WHY every cell above is a 400 — and the day a + // widening of one alone breaks that equality, this goes red before any + // caller meets a 400 calling a declared value undeclared. + expect([...TimeUpdateInterval.options]).toEqual([...BUCKET_GRANULARITIES]); + }); + it('refuses on an EMPTY table too, so the refusal is the compile and not the data', async () => { // An unbucketed query over no rows answers `{rows: []}`; this one still // refuses, which places the refusal at compile time where the ruling put it. @@ -303,14 +325,14 @@ describe('[#16178] a sub-day granularity is refused, not dropped', () => { query( { ...BASE, - timeDimensions: [{ dimension: 'events.createdAt', granularity: 'hour' }], + timeDimensions: [{ dimension: 'events.createdAt', granularity: 'hour' as 'day' }], }, [], ), - ).rejects.toMatchObject({ code: 'NOT_IMPLEMENTED', status: 501 }); + ).rejects.toMatchObject({ code: 'INVALID_QUERY', status: 400 }); }); - it('answers 400, not 501, for a granularity the CONTRACT never declared', async () => { + it('answers 400 for a granularity the CONTRACT never declared, WITHOUT the retirement sentence', () => { // 501 is a claim about this BACKEND, and it is only honest about a value the // contract actually declares. `'fortnight'` is a mistake in the query, and // the 501 sentence asserting "the spec declares the value" would have been @@ -318,20 +340,24 @@ describe('[#16178] a sub-day granularity is refused, not dropped', () => { // schema door, which is where `POST /analytics/dataset/query` types // `selection.timeDimensions` without Zod-parsing them — so this query // deliberately does NOT go through `asQuery`. - await expect(unparsed('fortnight')).rejects.toMatchObject({ + return expect(unparsed('fortnight')).rejects.toMatchObject({ code: 'INVALID_QUERY', status: 400, }); }); - it('CONTROL — the same unparsed door still answers 501 for a DECLARED interval', async () => { - // Without this the cell above proves only "the unparsed door throws". The - // two answers differ on exactly one thing: whether `TimeUpdateInterval` - // declares the value. - await expect(unparsed('hour')).rejects.toMatchObject({ - code: 'NOT_IMPLEMENTED', - status: 501, - }); + it('CONTROL — the same door separates a RETIRED spelling from one that never existed', async () => { + // [#17296] Without this the cells above read as "every rejection says + // retired". Both are 400 now, so `code`/`status` no longer tell the two + // populations apart — the PRESCRIPTION does, and that is the whole reason + // the 400 arm branches at all. `fortnight` was never declared and has no + // migration; `hour` was, and does not have one either, which is itself the + // thing the author has to be told. + const neverDeclared = await unparsed('fortnight').catch((e: Error) => e); + const retired = await unparsed('hour').catch((e: Error) => e); + expect((neverDeclared as Error).message).not.toContain('retired'); + expect((retired as Error).message).toContain('retired there'); + expect((retired as Error).message).toContain('protocol 18'); }); }); diff --git a/packages/spec/api-surface/data.json b/packages/spec/api-surface/data.json index 9acb85ea9c..a3268e2003 100644 --- a/packages/spec/api-surface/data.json +++ b/packages/spec/api-surface/data.json @@ -510,6 +510,7 @@ "RECORD_SURFACE_PAGE_THRESHOLD (const)", "REFERENCE_VALUE_TYPES (const)", "RETIRED_FILTER_OPERATORS (const)", + "RETIRED_SUB_DAY_INTERVALS (const)", "RPC_QUERY_ALIAS_SLOTS (const)", "RUNTIME_OWNED_FIELD_TYPES (const)", "RangeOperatorSchema (const)", @@ -826,6 +827,7 @@ "suggestDefaultValueToken (function)", "suggestFieldTypeForSqlType (function)", "textOperatorDoorVerdict (function)", + "timeUpdateIntervalRefusalMessage (function)", "unprovisionedInjectedColumns (function)", "urlCredentialQueryParams (function)", "urlUserinfoPassword (function)", diff --git a/packages/spec/export-origins/data.json b/packages/spec/export-origins/data.json index 54556012ae..e288ac8f97 100644 --- a/packages/spec/export-origins/data.json +++ b/packages/spec/export-origins/data.json @@ -499,6 +499,7 @@ "RECORD_SURFACE_PAGE_THRESHOLD": "src/data/record-surface.ts#RECORD_SURFACE_PAGE_THRESHOLD (const)", "REFERENCE_VALUE_TYPES": "src/data/field-value.zod.ts#REFERENCE_VALUE_TYPES (const)", "RETIRED_FILTER_OPERATORS": "src/data/filter.zod.ts#RETIRED_FILTER_OPERATORS (const)", + "RETIRED_SUB_DAY_INTERVALS": "src/data/analytics.zod.ts#RETIRED_SUB_DAY_INTERVALS (const)", "RPC_QUERY_ALIAS_SLOTS": "src/data/data-engine.zod.ts#RPC_QUERY_ALIAS_SLOTS (const)", "RUNTIME_OWNED_FIELD_TYPES": "src/data/field.zod.ts#RUNTIME_OWNED_FIELD_TYPES (const)", "RangeOperatorSchema": "src/data/filter.zod.ts#RangeOperatorSchema (const)", @@ -813,6 +814,7 @@ "suggestDefaultValueToken": "src/data/default-value-shape.ts#suggestDefaultValueToken (function)", "suggestFieldTypeForSqlType": "src/data/type-compat.ts#suggestFieldTypeForSqlType (function)", "textOperatorDoorVerdict": "src/data/filter-text-operator-declared-type.ts#textOperatorDoorVerdict (function)", + "timeUpdateIntervalRefusalMessage": "src/data/analytics.zod.ts#timeUpdateIntervalRefusalMessage (function)", "unprovisionedInjectedColumns": "src/data/injected-system-column-provenance.ts#unprovisionedInjectedColumns (function)", "urlCredentialQueryParams": "src/data/driver/common.zod.ts#urlCredentialQueryParams (function)", "urlUserinfoPassword": "src/data/driver/common.zod.ts#urlUserinfoPassword (function)", diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 8146604b2c..750ead7c2e 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -27,6 +27,7 @@ import { renameKey, } from './walk.js'; import { resolveDriverId, type BuiltinDriverId } from '../data/driver/config-registry.zod.js'; +import { RETIRED_SUB_DAY_INTERVALS } from '../data/analytics.zod.js'; import { deepEqualAuthored } from '../shared/deep-equal.js'; /** @@ -7390,6 +7391,112 @@ const fieldColumnListsCanonicalized: MetadataConversion = { * cube's `measures` RECORD (keyed by name), one level below the collection * item, so the top-level-only `stripKeys` runs per metric, not per cube. */ +/** + * `dimensions..granularities` — the three sub-day names `TimeUpdateInterval` + * declared until protocol 18 (#17296, ADR-0049 enforce-or-remove). + * + * A VALUE strip rather than a key strip: the key stays, and the three retired + * members are dropped from the authored list. Lossless in the only sense that + * matters here — no backend ever bucketed them, so a cube offering `hour` + * offered a granularity that answered 501 on `driver-memory` and + * `driver-mongodb` and one group per distinct timestamp on the engine's + * in-memory path. Deleting the offer preserves every observable answer. + * + * A dimension left with an EMPTY list after the strip drops the key entirely + * rather than declaring `granularities: []`: the key's own meaning is "the + * granularities this dimension offers", and an empty list is the one value + * that reads as "none" while the absent key reads as "all", which is what the + * cube meant before it named only sub-day ones it never had. + */ +const cubeSubDayGranularitiesRemoved: MetadataConversion = { + id: 'cube-sub-day-granularities-removed', + toMajor: 18, + retiredFromLoadPath: true, + surface: 'analyticsCubes[].dimensions..granularities', + summary: + "cube dimension granularities 'second' / 'minute' / 'hour' removed (#17296, ADR-0049 — no " + + 'backend bucketed them and none could advertise them: `supports.queryDateGranularity` is a ' + + 'record over `DateGranularity`, which declares day, week, month, quarter, year. Offer the ' + + 'coarsest interval that still answers the question)', + apply(stack, emit) { + return mapCollection(stack, 'analyticsCubes', (cube, path) => { + const dimensions = cube.dimensions; + if (!isDict(dimensions)) return cube; + let touched = false; + const nextDimensions: Record = { ...dimensions }; + for (const [name, dim] of Object.entries(dimensions)) { + if (!isDict(dim)) continue; + const list = dim.granularities; + if (!Array.isArray(list)) continue; + const kept = list.filter((g) => !(RETIRED_SUB_DAY_INTERVALS as readonly string[]).includes(g as string)); + if (kept.length === list.length) continue; + const where = `${path}.dimensions.${name}.granularities`; + emit({ from: JSON.stringify(list), to: kept.length > 0 ? JSON.stringify(kept) : '(removed)', path: where }); + const nextDim: Record = { ...dim }; + if (kept.length > 0) nextDim.granularities = kept; + else delete nextDim.granularities; + nextDimensions[name] = nextDim; + touched = true; + } + if (!touched) return cube; + return { ...cube, dimensions: nextDimensions }; + }); + }, + fixture: { + before: { + analyticsCubes: [{ + name: 'events', + sql: 'events', + measures: { count: { name: 'count', label: 'Events', type: 'count', sql: 'id' } }, + dimensions: { + // Mixed list — the sub-day names go, the rest stays in its order. + created_at: { + name: 'created_at', label: 'Created At', type: 'time', sql: 'created_at', + granularities: ['hour', 'day', 'month'], + }, + // Sub-day ONLY — the key goes rather than becoming an empty list. + touched_at: { + name: 'touched_at', label: 'Touched At', type: 'time', sql: 'touched_at', + granularities: ['second', 'minute'], + }, + // Neither retired member nor the key at all: both ride through, and + // the copy-on-write contract keeps the references. + closed_at: { + name: 'closed_at', label: 'Closed At', type: 'time', sql: 'closed_at', + granularities: ['day', 'week'], + }, + stage: { name: 'stage', label: 'Stage', type: 'string', sql: 'stage' }, + }, + }], + }, + after: { + analyticsCubes: [{ + name: 'events', + sql: 'events', + measures: { count: { name: 'count', label: 'Events', type: 'count', sql: 'id' } }, + dimensions: { + created_at: { + name: 'created_at', label: 'Created At', type: 'time', sql: 'created_at', + granularities: ['day', 'month'], + }, + touched_at: { + name: 'touched_at', label: 'Touched At', type: 'time', sql: 'touched_at', + }, + closed_at: { + name: 'closed_at', label: 'Closed At', type: 'time', sql: 'closed_at', + granularities: ['day', 'week'], + }, + stage: { name: 'stage', label: 'Stage', type: 'string', sql: 'stage' }, + }, + }], + }, + // Two notices: the mixed list and the sub-day-only one. The two untouched + // dimensions are the fixture's own control — they prove the walk dispatches + // on a retired MEMBER rather than on the key's presence. + expectedNotices: 2, + }, +}; + const metricFiltersRemoved: MetadataConversion = { id: 'metric-filters-removed', toMajor: 18, @@ -9348,6 +9455,7 @@ export const CONVERSIONS_BY_MAJOR: Readonly { it('should accept all valid metric types', () => { @@ -40,7 +42,7 @@ describe('DimensionType', () => { describe('TimeUpdateInterval', () => { it('should accept all valid intervals', () => { - const intervals = ['second', 'minute', 'hour', 'day', 'week', 'month', 'quarter', 'year']; + const intervals = ['day', 'week', 'month', 'quarter', 'year']; for (const i of intervals) { expect(() => TimeUpdateInterval.parse(i)).not.toThrow(); } @@ -50,6 +52,60 @@ describe('TimeUpdateInterval', () => { expect(() => TimeUpdateInterval.parse('millisecond')).toThrow(); expect(() => TimeUpdateInterval.parse('decade')).toThrow(); }); + + // [#17296] The members are NOT restated here — they are `DateGranularity`'s, + // and this is the assertion that keeps the second copy from growing back. + // Both halves matter: same members, same ORDER, because the order is what a + // generated reference page and a refusal message both print. + it('declares exactly DateGranularity, in its order — one vocabulary, not two', () => { + expect(TimeUpdateInterval.options).toEqual(DateGranularity.options); + }); + + it('refuses each retired sub-day interval with the retirement prescription (#17296)', () => { + // COST DIRECTION. The cheap narrowing is the enum alone: delete three + // members and let zod answer its stock "invalid option". That parses + // identically and tells an upgrading author nothing, so what is pinned + // here is the PRESCRIPTION, not the rejection — the retired name, the + // replacement vocabulary, and the migrate line the ADR-0087 conversion + // makes true. Dropping the error map to simplify this enum turns every + // assertion below red while `.parse()` goes on throwing. + for (const retired of RETIRED_SUB_DAY_INTERVALS) { + const parsed = TimeUpdateInterval.safeParse(retired); + expect(parsed.success, retired).toBe(false); + if (parsed.success) continue; + const [issue] = parsed.error.issues; + expect(issue.message, retired).toContain(`'${retired}'`); + expect(issue.message, retired).toContain('retired in protocol 18'); + expect(issue.message, retired).toContain('day, week, month, quarter, year'); + expect(issue.message, retired).toContain('os migrate meta --from 17'); + } + }); + + it('CONTROL — an interval that was NEVER declared gets the vocabulary, not the retirement', () => { + // Without this cell the one above reads as "every rejection says + // retired". The two populations are a different mistake with a different + // next action, so a message that cannot tell them apart is the defect the + // split exists to avoid: `fortnight` never existed and has no migration. + const parsed = TimeUpdateInterval.safeParse('fortnight'); + expect(parsed.success).toBe(false); + if (parsed.success) return; + const [issue] = parsed.error.issues; + expect(issue.message).toContain('is not declared'); + expect(issue.message).not.toContain('os migrate meta'); + }); + + it('the retired names are gone from the CUBE side too, not just the query side', () => { + // `Dimension.granularities` is the authored, STORED half of this enum — + // the reason the retirement needed an ADR-0087 conversion at all. Pinned + // separately because the query key and the cube key are two sites and a + // narrowing that reached only one of them would still parse a cube + // offering a granularity no query may ask for. + const dim = (granularities: string[]) => ({ + name: 'created_at', label: 'Created At', type: 'time', sql: 'created_at', granularities, + }); + expect(DimensionSchema.safeParse(dim(['day', 'month'])).success).toBe(true); + expect(DimensionSchema.safeParse(dim(['hour'])).success).toBe(false); + }); }); describe('MetricSchema', () => { diff --git a/packages/spec/src/data/analytics.zod.ts b/packages/spec/src/data/analytics.zod.ts index edd22d3221..fcea384f4b 100644 --- a/packages/spec/src/data/analytics.zod.ts +++ b/packages/spec/src/data/analytics.zod.ts @@ -3,6 +3,7 @@ import { z } from 'zod'; import { FilterConditionSchema } from './filter.zod'; import { DATE_RANGE_PRESETS } from './date-range-presets'; +import { DateGranularity } from './query.zod'; /** * Analytics/Semantic Layer Protocol @@ -48,11 +49,92 @@ export const DimensionType = z.enum([ export type DimensionType = z.input; /** - * Time Interval for Time Dimensions + * The three sub-day names this enum declared until protocol 18 (#17296). + * + * They were never a capability any backend could offer, and not because the + * backends lag the contract — the rest of the contract never declared them: + * + * - `DateGranularity` (`data/query.zod.ts`), the vocabulary a `groupBy` entry + * and every driver's bucket expression are typed by, declares five; + * - `@objectstack/core`'s `BUCKET_GRANULARITIES` — the canonical bucket-KEY + * vocabulary, an OUTPUT contract a drill-down crosses — labels the same five + * and has no key shape for a sub-day bucket; + * - `DriverCapabilitiesSchema.supports.queryDateGranularity`, the mechanism a + * backend uses to say which granularities it buckets NATIVELY, is a + * `z.record(DateGranularity, boolean)`: `{ day, week, month, quarter, year }` + * parses and the same record plus `hour` raises `unrecognized_keys`. So a + * driver could not have advertised sub-day support even if it had one. + * + * That last point is what decides this as a RETIREMENT rather than a capability + * gap. A declared value one backend cannot serve is a gap, and the contract + * already has a place to say so. A declared value NO backend can even claim is + * a declaration with no counterpart anywhere in the contract that carries it. + * + * Measured on the shipped faces before the narrowing: `driver-memory`'s + * analytics face answered `NOT_IMPLEMENTED` / 501, `driver-mongodb`'s bucket + * builder answered `NOT_IMPLEMENTED` / 501, and the engine's in-memory + * aggregation — the fallback every SQL/ObjectQL analytics query with a + * granularity lands on, since `NativeSQLStrategy` declines on a granularity — + * answered 200 with one group per distinct timestamp, the raw instant echoed + * back as its own bucket label. Two honest refusals and one silently wrong + * answer, and no third behaviour. + * + * ⚠️ Retiring them does NOT retire sub-day analytics as an idea. Offering it + * means widening `DateGranularity`, the `queryDateGranularity` record, the + * canonical key vocabulary and every driver's bucket expression together — + * new capability, decided as such, rather than a name that parses here and + * resolves nowhere. */ -export const TimeUpdateInterval = z.enum([ - 'second', 'minute', 'hour', 'day', 'week', 'month', 'quarter', 'year' -]); +export const RETIRED_SUB_DAY_INTERVALS = ['second', 'minute', 'hour'] as const; + +/** + * The refusal a value outside {@link TimeUpdateInterval} is answered with. + * + * Two populations, one function, because they are not the same mistake and the + * author's next action differs — the separation `driver-memory`'s own + * `unsupportedTimeGranularityError` draws at its own door, and the one + * {@link analyticsDateRangeRefusalMessage} draws for this entry's sibling key: + * + * - a RETIRED sub-day name is a value this enum used to declare, so the + * prescription is the retirement — what replaced it and the migrate line; + * - anything else was never declared, so the prescription is the vocabulary. + */ +export function timeUpdateIntervalRefusalMessage(input: unknown): string { + const received = typeof input === 'string' ? `'${input}'` : JSON.stringify(input) ?? String(input); + const declared = DateGranularity.options.join(', '); + if (typeof input === 'string' && (RETIRED_SUB_DAY_INTERVALS as readonly string[]).includes(input)) { + return ( + `Time interval ${received} was retired in protocol 18 (#17296, ADR-0049 enforce-or-remove). ` + + 'No backend ever bucketed it and none could advertise it: the canonical bucket-key ' + + 'vocabulary and `supports.queryDateGranularity` both stop at ' + + `${declared}, so the name resolved to a refusal or to one group per distinct timestamp. ` + + `Ask for the coarsest interval that still answers your question (${declared}), or drop ` + + 'the key and group on the raw timestamp deliberately. ' + + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.' + ); + } + return ( + `Time interval ${received} is not declared — the declared intervals are ${declared}. ` + + 'They are `DateGranularity`, the one vocabulary the drivers, the engine and the canonical ' + + 'bucket keys all share; this enum no longer carries a second, wider copy of it.' + ); +} + +/** + * Time Interval for Time Dimensions. + * + * **Derived from `DateGranularity`, never restated.** The two were separate + * literal lists until #17296, and they disagreed by three members for as long + * as both existed — the drift this file's own `granularity`/`granularities` + * alias note warns about, one layer up. The members now come from the single + * source; what this enum adds is the refusal text, because the value arrives + * here from an analytics request body and an author needs the analytics + * prescription rather than a bare enum error. + */ +export const TimeUpdateInterval = z.enum( + DateGranularity.options, + { error: (issue) => timeUpdateIntervalRefusalMessage(issue.input) }, +); export type TimeUpdateInterval = z.input; /** diff --git a/packages/spec/src/migrations/entries/semantic/18.time-update-interval-sub-day-retired.ts b/packages/spec/src/migrations/entries/semantic/18.time-update-interval-sub-day-retired.ts new file mode 100644 index 0000000000..a8af9c9a76 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.time-update-interval-sub-day-retired.ts @@ -0,0 +1,42 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'time-update-interval-sub-day-retired', + surface: + '`TimeUpdateInterval` — the `/analytics/query` body\'s `timeDimensions[].granularity` and an ' + + 'analytics cube dimension\'s `granularities[]`. The three sub-day members `second`, ' + + '`minute` and `hour` are retired; `day`, `week`, `month`, `quarter` and `year` are ' + + 'unchanged and parse byte-identically', + replacement: + 'the coarsest declared interval that still answers the question — `day` is the finest bucket ' + + 'the platform labels. A caller who wants raw per-instant rows drops `granularity` entirely, ' + + 'which groups on the unbucketed timestamp deliberately rather than by accident. There is no ' + + 'mechanical replacement that preserves a sub-day bucket, because no backend ever produced ' + + 'one', + reason: + 'ADR-0049 enforce-or-remove (#17296, the card #17206\'s changeset promised). The rest of the ' + + 'contract never carried these three: `DateGranularity` (`data/query.zod.ts`) — the ' + + 'vocabulary a `groupBy` entry and every driver\'s bucket expression are typed by — declares ' + + 'five, `@objectstack/core`\'s `BUCKET_GRANULARITIES` labels the same five, and ' + + '`DriverCapabilitiesSchema.supports.queryDateGranularity` is a `z.record(DateGranularity, ' + + 'boolean)`, so a driver could not advertise sub-day bucketing even if it had one. Measured ' + + 'on the shipped faces before the narrowing: `driver-memory`\'s analytics face answered ' + + 'NOT_IMPLEMENTED/501, `driver-mongodb`\'s bucket builder answered NOT_IMPLEMENTED/501, and ' + + 'the engine\'s in-memory aggregation — the fallback every SQL/ObjectQL analytics query ' + + 'carrying a granularity lands on, since `NativeSQLStrategy` declines on a granularity — ' + + 'answered 200 with one group per distinct timestamp, echoing the raw instant back as its ' + + 'own bucket label. Two honest refusals and one silently wrong answer, and no third ' + + 'behaviour anywhere. ⚠️ This retires the NAMES, not the idea: offering sub-day analytics ' + + 'means widening `DateGranularity`, the `queryDateGranularity` record, the canonical ' + + 'bucket-key vocabulary and every driver\'s bucket expression together — new capability, ' + + 'decided as such', + acceptanceCriteria: + 'No analytics request body carries `timeDimensions[].granularity` of `second`, `minute` or ' + + '`hour`, and no cube dimension offers one in `granularities[]` (the D2 conversion ' + + '`cube-sub-day-granularities-removed` strips them from sources, dropping the key entirely ' + + 'when nothing coarser remains). ⚠️ The conversion cannot decide what a dimension that ' + + 'offered ONLY sub-day intervals should offer instead — review each site the run reports ' + + 'and state the granularities that dimension actually serves.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 036b92ece1..224f372193 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -5457,6 +5457,7 @@ const step18: MigrationStep = { 'element-form-removed', 'field-column-lists-canonicalized', 'metric-filters-removed', + 'cube-sub-day-granularities-removed', 'record-highlights-field-icon-removed', 'mapping-lookup-params-removed', 'translation-component-submit-label-removed', @@ -10394,6 +10395,44 @@ const step18: MigrationStep = { + 'fails to parse with the rename prescription naming the suffixed key; the parsed defaults ' + 'are 300 and 3600 as before.', }, + { + id: 'time-update-interval-sub-day-retired', + surface: + '`TimeUpdateInterval` — the `/analytics/query` body\'s `timeDimensions[].granularity` and an ' + + 'analytics cube dimension\'s `granularities[]`. The three sub-day members `second`, ' + + '`minute` and `hour` are retired; `day`, `week`, `month`, `quarter` and `year` are ' + + 'unchanged and parse byte-identically', + replacement: + 'the coarsest declared interval that still answers the question — `day` is the finest bucket ' + + 'the platform labels. A caller who wants raw per-instant rows drops `granularity` entirely, ' + + 'which groups on the unbucketed timestamp deliberately rather than by accident. There is no ' + + 'mechanical replacement that preserves a sub-day bucket, because no backend ever produced ' + + 'one', + reason: + 'ADR-0049 enforce-or-remove (#17296, the card #17206\'s changeset promised). The rest of the ' + + 'contract never carried these three: `DateGranularity` (`data/query.zod.ts`) — the ' + + 'vocabulary a `groupBy` entry and every driver\'s bucket expression are typed by — declares ' + + 'five, `@objectstack/core`\'s `BUCKET_GRANULARITIES` labels the same five, and ' + + '`DriverCapabilitiesSchema.supports.queryDateGranularity` is a `z.record(DateGranularity, ' + + 'boolean)`, so a driver could not advertise sub-day bucketing even if it had one. Measured ' + + 'on the shipped faces before the narrowing: `driver-memory`\'s analytics face answered ' + + 'NOT_IMPLEMENTED/501, `driver-mongodb`\'s bucket builder answered NOT_IMPLEMENTED/501, and ' + + 'the engine\'s in-memory aggregation — the fallback every SQL/ObjectQL analytics query ' + + 'carrying a granularity lands on, since `NativeSQLStrategy` declines on a granularity — ' + + 'answered 200 with one group per distinct timestamp, echoing the raw instant back as its ' + + 'own bucket label. Two honest refusals and one silently wrong answer, and no third ' + + 'behaviour anywhere. ⚠️ This retires the NAMES, not the idea: offering sub-day analytics ' + + 'means widening `DateGranularity`, the `queryDateGranularity` record, the canonical ' + + 'bucket-key vocabulary and every driver\'s bucket expression together — new capability, ' + + 'decided as such', + acceptanceCriteria: + 'No analytics request body carries `timeDimensions[].granularity` of `second`, `minute` or ' + + '`hour`, and no cube dimension offers one in `granularities[]` (the D2 conversion ' + + '`cube-sub-day-granularities-removed` strips them from sources, dropping the key entirely ' + + 'when nothing coarser remains). ⚠️ The conversion cannot decide what a dimension that ' + + 'offered ONLY sub-day intervals should offer instead — review each site the run reports ' + + 'and state the granularities that dimension actually serves.', + }, { id: 'training-deadline-keys-retired', surface: From ee247d27794a5ab9338d95435e0c07bdc2c8b213 Mon Sep 17 00:00:00 2001 From: os-bill Date: Sat, 12 Sep 2026 19:11:09 +0000 Subject: [PATCH 2/4] test(driver-memory): pin the retirement at both analytics doors (#17296) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The parsed door refuses one step earlier now — `AnalyticsQuerySchema` stops a sub-day granularity before any driver is reached — so the pin that used to drive the service through `asQuery` measures the schema, and the driver's own 400 is pinned through the unparsed door the dataset face uses. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH --- .../memory-analytics-time-granularity.test.ts | 65 ++++++++++++++----- 1 file changed, 47 insertions(+), 18 deletions(-) diff --git a/packages/drivers/driver-memory/src/memory-analytics-time-granularity.test.ts b/packages/drivers/driver-memory/src/memory-analytics-time-granularity.test.ts index 364a64a91b..38cd944b2b 100644 --- a/packages/drivers/driver-memory/src/memory-analytics-time-granularity.test.ts +++ b/packages/drivers/driver-memory/src/memory-analytics-time-granularity.test.ts @@ -292,19 +292,46 @@ describe('[#16178] a sub-day granularity is refused, not dropped', () => { // contract does not declare it", which is the honest sentence once the // declaration is gone. Both halves are pinned below. it.each(['second', 'minute', 'hour'])( - 'refuses %s — now as the 400 the retirement makes correct, carrying the retirement prescription', - async (granularity) => { - // Asserted on `code` and `status` — the ADR-0112 envelope — plus the one - // sentence an upgrading author needs, because "is not declared" alone - // reads to them as a typo. A bare `toThrow()` would pass against a driver - // that threw for any unrelated reason. - const thrown = await query({ + 'refuses %s AT THE SCHEMA now, one door earlier, with the retirement prescription', + (granularity) => { + // The refusal MOVED rather than softened, and it moved toward the caller: + // a parsed body no longer reaches the driver at all. `query()` runs + // `AnalyticsQuerySchema.parse`, the route a real request body takes, so + // this cell measures the door a caller actually meets first. + // + // Asserted on the PRESCRIPTION, not on "it threw": a bare rejection is + // what zod's stock invalid-enum error already gives, and it would tell an + // author upgrading from 17 nothing about why the name they wrote last + // week is gone or what to write instead. + const parsed = AnalyticsQuerySchema.safeParse({ ...BASE, - timeDimensions: [{ dimension: 'events.createdAt', granularity: granularity as 'day' }], - }).catch((e: Error & { code?: string; status?: number }) => e); + timeDimensions: [{ dimension: 'events.createdAt', granularity }], + }); + expect(parsed.success, granularity).toBe(false); + if (parsed.success) return; + const message = parsed.error.issues.map((i) => i.message).join(' '); + expect(message, granularity).toContain('retired in protocol 18'); + expect(message, granularity).toContain('os migrate meta --from 17'); + }, + ); + + it.each(['second', 'minute', 'hour'])( + 'refuses %s at the DRIVER too, for the door that does not parse, as a 400', + async (granularity) => { + // The other door — `POST /analytics/dataset/query` types + // `selection.timeDimensions` from `AnalyticsQuery` and never Zod-parses + // them, which is the reachability `analyticsDateRangeUnrecognizedError` + // records for its own out-of-vocabulary refusal. 501 said "your query is + // right, this backend cannot"; that sentence became false the moment the + // contract stopped declaring the value, so the class is now 400 and the + // driver carries the retirement sentence itself rather than telling an + // upgrading author their spelling never existed. + const thrown = await unparsed(granularity).catch( + (e: Error & { code?: string; status?: number }) => e, + ); expect(thrown).toMatchObject({ code: 'INVALID_QUERY', status: 400 }); - expect((thrown as Error).message).toContain('retired'); - expect((thrown as Error).message).toContain('protocol 18'); + expect((thrown as Error).message, granularity).toContain('retired there'); + expect((thrown as Error).message, granularity).toContain('protocol 18'); }, ); @@ -321,14 +348,16 @@ describe('[#16178] a sub-day granularity is refused, not dropped', () => { it('refuses on an EMPTY table too, so the refusal is the compile and not the data', async () => { // An unbucketed query over no rows answers `{rows: []}`; this one still // refuses, which places the refusal at compile time where the ruling put it. + // Driven through the unparsed door, because the parsed one now stops the + // value before any table — empty or not — is consulted at all. + const driver = new InMemoryDriver({ initialData: { events: [] } }); + await driver.connect(); + const service = new MemoryAnalyticsService({ driver, cubes }); await expect( - query( - { - ...BASE, - timeDimensions: [{ dimension: 'events.createdAt', granularity: 'hour' as 'day' }], - }, - [], - ), + service.query({ + ...BASE, + timeDimensions: [{ dimension: 'events.createdAt', granularity: 'hour' }], + } as unknown as AnalyticsQuery), ).rejects.toMatchObject({ code: 'INVALID_QUERY', status: 400 }); }); From f32f16a1bb0e480605c65ea32e3305ed0a6cd3f4 Mon Sep 17 00:00:00 2001 From: os-bill Date: Sat, 12 Sep 2026 19:25:03 +0000 Subject: [PATCH 3/4] chore(changeset): state the ADR-0087 disposition in the form the gate parses (#17296) The marker line carries only the two registered ids; the reasoning that was inside it moves to a section of the changeset body, where it is also legible to a reader rather than only to the parser. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH --- .changeset/time-update-interval-sub-day-retired.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.changeset/time-update-interval-sub-day-retired.md b/.changeset/time-update-interval-sub-day-retired.md index 0b696cfd44..6bbf1841cc 100644 --- a/.changeset/time-update-interval-sub-day-retired.md +++ b/.changeset/time-update-interval-sub-day-retired.md @@ -5,7 +5,11 @@ fix(spec)!: `TimeUpdateInterval` retires its three sub-day intervals and derives its members from `DateGranularity` (#17296) - + + +## 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..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` From e652ab8c030c6e74e00f1f405a71d703c0814a9a Mon Sep 17 00:00:00 2001 From: os-bill Date: Sat, 12 Sep 2026 20:20:17 +0000 Subject: [PATCH 4/4] =?UTF-8?q?fix(spec):=20address=20contract=20review=20?= =?UTF-8?q?=E2=80=94=20card=20id=20out=20of=20the=20refusal=20string,=20pr?= =?UTF-8?q?escription=20module-private,=20docblock=20adjacency=20(#17296)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R1: `check:doc-authoring` reds on an internal issue id inside a customer-facing string. The refusal now reads `(ADR-0049 enforce-or-remove)`, matching how driver-memory's twin already spells it. No pin asserted the id. R2: `timeUpdateIntervalRefusalMessage` had zero consumers outside its own error map, so it is module-private now — the `CRYPTO_HASH_RETIRED` shape an enum-value narrowing takes. `RETIRED_SUB_DAY_INTERVALS` stays exported; it has real cross-package readers. Both inventories regenerated. R3: the new conversion had been inserted between an existing docblock and the symbol it documents. It moves below `metricFiltersRemoved`; the `CONVERSIONS_BY_MAJOR` order is unchanged. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH --- packages/spec/api-surface/data.json | 1 - packages/spec/export-origins/data.json | 1 - packages/spec/src/conversions/registry.ts | 148 +++++++++++----------- packages/spec/src/data/analytics.zod.ts | 14 +- 4 files changed, 86 insertions(+), 78 deletions(-) diff --git a/packages/spec/api-surface/data.json b/packages/spec/api-surface/data.json index a3268e2003..4f391b3596 100644 --- a/packages/spec/api-surface/data.json +++ b/packages/spec/api-surface/data.json @@ -827,7 +827,6 @@ "suggestDefaultValueToken (function)", "suggestFieldTypeForSqlType (function)", "textOperatorDoorVerdict (function)", - "timeUpdateIntervalRefusalMessage (function)", "unprovisionedInjectedColumns (function)", "urlCredentialQueryParams (function)", "urlUserinfoPassword (function)", diff --git a/packages/spec/export-origins/data.json b/packages/spec/export-origins/data.json index e288ac8f97..e58b2edc5f 100644 --- a/packages/spec/export-origins/data.json +++ b/packages/spec/export-origins/data.json @@ -814,7 +814,6 @@ "suggestDefaultValueToken": "src/data/default-value-shape.ts#suggestDefaultValueToken (function)", "suggestFieldTypeForSqlType": "src/data/type-compat.ts#suggestFieldTypeForSqlType (function)", "textOperatorDoorVerdict": "src/data/filter-text-operator-declared-type.ts#textOperatorDoorVerdict (function)", - "timeUpdateIntervalRefusalMessage": "src/data/analytics.zod.ts#timeUpdateIntervalRefusalMessage (function)", "unprovisionedInjectedColumns": "src/data/injected-system-column-provenance.ts#unprovisionedInjectedColumns (function)", "urlCredentialQueryParams": "src/data/driver/common.zod.ts#urlCredentialQueryParams (function)", "urlUserinfoPassword": "src/data/driver/common.zod.ts#urlUserinfoPassword (function)", diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 750ead7c2e..0095849f94 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -7391,6 +7391,80 @@ const fieldColumnListsCanonicalized: MetadataConversion = { * cube's `measures` RECORD (keyed by name), one level below the collection * item, so the top-level-only `stripKeys` runs per metric, not per cube. */ +const metricFiltersRemoved: MetadataConversion = { + id: 'metric-filters-removed', + toMajor: 18, + retiredFromLoadPath: true, + surface: 'analyticsCubes[].measures..filters', + summary: + "cube metric key 'filters' removed (#10414, ADR-0049 — no strategy ever read it: the " + + 'authored raw-SQL condition was parsed and dropped, and the query returned the ' + + "unfiltered aggregate. Filter at query time with `where`, fold the condition into the " + + "metric's own `sql` expression, or use an ADR-0021 dataset measure's structured `filter`)", + apply(stack, emit) { + return mapCollection(stack, 'analyticsCubes', (cube, path) => { + const measures = cube.measures; + if (!isDict(measures)) return cube; + let touched = false; + const nextMeasures: Record = { ...measures }; + for (const [name, metric] of Object.entries(measures)) { + if (!isDict(metric)) continue; + const stripped = stripKeys(metric, ['filters'], emit, `${path}.measures.${name}`); + if (stripped === metric) continue; + nextMeasures[name] = stripped; + touched = true; + } + if (!touched) return cube; + return { ...cube, measures: nextMeasures }; + }); + }, + fixture: { + before: { + analyticsCubes: [{ + name: 'orders', + sql: 'orders', + measures: { + // The card's measured shape: parsed, registered, returned unfiltered. + closed_won_revenue: { + name: 'closed_won_revenue', + label: 'Closed-Won Revenue', + type: 'sum', + sql: 'amount', + filters: [{ sql: "stage = 'closed_won'" }], + }, + // A metric WITHOUT the key rides through untouched — the strip + // dispatches on key presence, and the copy-on-write contract keeps + // the reference. + order_count: { name: 'order_count', label: 'Orders', type: 'count', sql: 'id' }, + }, + dimensions: { + stage: { name: 'stage', label: 'Stage', type: 'string', sql: 'stage' }, + }, + }], + }, + after: { + analyticsCubes: [{ + name: 'orders', + sql: 'orders', + measures: { + closed_won_revenue: { + name: 'closed_won_revenue', + label: 'Closed-Won Revenue', + type: 'sum', + sql: 'amount', + }, + order_count: { name: 'order_count', label: 'Orders', type: 'count', sql: 'id' }, + }, + dimensions: { + stage: { name: 'stage', label: 'Stage', type: 'string', sql: 'stage' }, + }, + }], + }, + // One notice: the single metric carrying `filters`. + expectedNotices: 1, + }, +}; + /** * `dimensions..granularities` — the three sub-day names `TimeUpdateInterval` * declared until protocol 18 (#17296, ADR-0049 enforce-or-remove). @@ -7497,80 +7571,6 @@ const cubeSubDayGranularitiesRemoved: MetadataConversion = { }, }; -const metricFiltersRemoved: MetadataConversion = { - id: 'metric-filters-removed', - toMajor: 18, - retiredFromLoadPath: true, - surface: 'analyticsCubes[].measures..filters', - summary: - "cube metric key 'filters' removed (#10414, ADR-0049 — no strategy ever read it: the " - + 'authored raw-SQL condition was parsed and dropped, and the query returned the ' - + "unfiltered aggregate. Filter at query time with `where`, fold the condition into the " - + "metric's own `sql` expression, or use an ADR-0021 dataset measure's structured `filter`)", - apply(stack, emit) { - return mapCollection(stack, 'analyticsCubes', (cube, path) => { - const measures = cube.measures; - if (!isDict(measures)) return cube; - let touched = false; - const nextMeasures: Record = { ...measures }; - for (const [name, metric] of Object.entries(measures)) { - if (!isDict(metric)) continue; - const stripped = stripKeys(metric, ['filters'], emit, `${path}.measures.${name}`); - if (stripped === metric) continue; - nextMeasures[name] = stripped; - touched = true; - } - if (!touched) return cube; - return { ...cube, measures: nextMeasures }; - }); - }, - fixture: { - before: { - analyticsCubes: [{ - name: 'orders', - sql: 'orders', - measures: { - // The card's measured shape: parsed, registered, returned unfiltered. - closed_won_revenue: { - name: 'closed_won_revenue', - label: 'Closed-Won Revenue', - type: 'sum', - sql: 'amount', - filters: [{ sql: "stage = 'closed_won'" }], - }, - // A metric WITHOUT the key rides through untouched — the strip - // dispatches on key presence, and the copy-on-write contract keeps - // the reference. - order_count: { name: 'order_count', label: 'Orders', type: 'count', sql: 'id' }, - }, - dimensions: { - stage: { name: 'stage', label: 'Stage', type: 'string', sql: 'stage' }, - }, - }], - }, - after: { - analyticsCubes: [{ - name: 'orders', - sql: 'orders', - measures: { - closed_won_revenue: { - name: 'closed_won_revenue', - label: 'Closed-Won Revenue', - type: 'sum', - sql: 'amount', - }, - order_count: { name: 'order_count', label: 'Orders', type: 'count', sql: 'id' }, - }, - dimensions: { - stage: { name: 'stage', label: 'Stage', type: 'string', sql: 'stage' }, - }, - }], - }, - // One notice: the single metric carrying `filters`. - expectedNotices: 1, - }, -}; - /** * `record:highlights` highlight-field `icon` — a declared, advertised key with * zero read points (#10054, ADR-0049 enforce-or-remove; maintainer ruling diff --git a/packages/spec/src/data/analytics.zod.ts b/packages/spec/src/data/analytics.zod.ts index fcea384f4b..8c2cce906a 100644 --- a/packages/spec/src/data/analytics.zod.ts +++ b/packages/spec/src/data/analytics.zod.ts @@ -98,13 +98,23 @@ export const RETIRED_SUB_DAY_INTERVALS = ['second', 'minute', 'hour'] as const; * - a RETIRED sub-day name is a value this enum used to declare, so the * prescription is the retirement — what replaced it and the migrate line; * - anything else was never declared, so the prescription is the vocabulary. + * + * **Module-private**, which is the shape this kind of retirement takes: + * `hook-body.zod.ts`'s `CRYPTO_HASH_RETIRED` is the matching precedent — a + * prescription an enum's own error map consumes and nothing else does. The + * sibling {@link analyticsDateRangeRefusalMessage} is exported because a + * cross-file pin asserts `toBe(...)` against it, so the export IS the single + * source of that expected string; this one has no such consumer, and + * `driver-memory`'s door deliberately words its own sentence for its own + * surface rather than re-emitting this one. An export with no reader is a + * published surface the next narrowing has to keep. */ -export function timeUpdateIntervalRefusalMessage(input: unknown): string { +function timeUpdateIntervalRefusalMessage(input: unknown): string { const received = typeof input === 'string' ? `'${input}'` : JSON.stringify(input) ?? String(input); const declared = DateGranularity.options.join(', '); if (typeof input === 'string' && (RETIRED_SUB_DAY_INTERVALS as readonly string[]).includes(input)) { return ( - `Time interval ${received} was retired in protocol 18 (#17296, ADR-0049 enforce-or-remove). ` + `Time interval ${received} was retired in protocol 18 (ADR-0049 enforce-or-remove). ` + 'No backend ever bucketed it and none could advertise it: the canonical bucket-key ' + 'vocabulary and `supports.queryDateGranularity` both stop at ' + `${declared}, so the name resolved to a refusal or to one group per distinct timestamp. `