From 9c72dbb741b24743df9f98e75073fb999d5bdb78 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 11:54:10 +0000 Subject: [PATCH 1/7] feat(spec): close timeDimensions[].dateRange's string arm to the date-range preset vocabulary (wip) Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01T6HeZvT9wdSJD1ZxJb5Eno --- ...alytics-daterange-refusal-envelope.test.ts | 145 +++++++++++++ packages/runtime/src/domains/analytics.ts | 26 ++- packages/spec/json-schema.manifest/data.json | 2 + packages/spec/src/api/analytics.test.ts | 2 +- .../spec/src/api/error-code-ledger.zod.ts | 22 ++ ...ytics-date-range-closed-vocabulary.test.ts | 200 ++++++++++++++++++ .../data/analytics-strictness-batchd.test.ts | 2 +- packages/spec/src/data/analytics.test.ts | 2 +- packages/spec/src/data/analytics.zod.ts | 128 ++++++++++- ...-dimension-date-range-vocabulary-closed.ts | 60 ++++++ 10 files changed, 577 insertions(+), 12 deletions(-) create mode 100644 packages/runtime/src/analytics-daterange-refusal-envelope.test.ts create mode 100644 packages/spec/src/data/analytics-date-range-closed-vocabulary.test.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.analytics-time-dimension-date-range-vocabulary-closed.ts diff --git a/packages/runtime/src/analytics-daterange-refusal-envelope.test.ts b/packages/runtime/src/analytics-daterange-refusal-envelope.test.ts new file mode 100644 index 0000000000..1538f0974c --- /dev/null +++ b/packages/runtime/src/analytics-daterange-refusal-envelope.test.ts @@ -0,0 +1,145 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#16041] `POST /analytics/query` (and `/analytics/sql`) refuse a + * `timeDimensions[].dateRange` string outside the closed date-range vocabulary + * AT THE DOOR, with the ADR-0112 envelope `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` + * — and the analytics service is never reached. + * + * The defect this pins shut is a silent widening: the schema's arm was a bare + * `z.string()`, so `"Last 7 days"` (the schema comment's own example) passed the + * door, reached driver-memory as written, fell through to a `[range, range]` + * pseudo-window and matched EVERY `Date`-typed row at HTTP 200. Maintainer + * ruling (decision batch #57, option A): the vocabulary closes at the schema and + * any other string is refused with a stable code. The schema raises one + * prescriptive issue and exports the structural predicate + * (`isAnalyticsDateRangeRefusalIssue`); this door lifts it into the registered + * code — so the assertions read the code, the status and the "service not + * called" fact, never message prose. + * + * Harness: the shape `dispatcher-validation-error.test.ts` uses to drive the + * mounted route through `dispatcher-plugin`'s thrown-error exit. + */ + +import { describe, it, expect } from 'vitest'; +import { ApiErrorSchema } from '@objectstack/spec/api'; + +import { createDispatcherPlugin } from './dispatcher-plugin.js'; + +function makeFakeServer() { + const handlers: Record any> = {}; + const rec = (verb: string) => (path: string, handler: any) => { + handlers[`${verb} ${path}`] = handler; + }; + return { + handlers, + server: { get: rec('GET'), post: rec('POST'), put: rec('PUT'), delete: rec('DELETE'), patch: rec('PATCH') }, + }; +} + +function makeCtx(fakeServer: any, calls: { query: unknown[]; sql: unknown[] }) { + const analytics = { + query: async (body: unknown) => { calls.query.push(body); return { data: [] }; }, + getMeta: async () => ({ cubes: [] }), + generateSql: async (body: unknown) => { calls.sql.push(body); return { sql: 'SELECT 1', params: [] }; }, + }; + const kernel = { + getService: (name: string) => (name === 'analytics' ? analytics : undefined), + getServiceAsync: async (name: string) => (name === 'analytics' ? analytics : undefined), + }; + return { + getKernel: () => kernel, + getService: (name: string) => (name === 'http.server' ? fakeServer : undefined), + environmentId: undefined, + logger: { info() {}, warn() {}, error() {}, debug() {} }, + hook: () => {}, + on: () => {}, + } as any; +} + +function makeRes() { + const res: any = { + statusCode: undefined as number | undefined, + body: undefined as any, + status(c: number) { res.statusCode = c; return res; }, + header() { return res; }, + json(b: any) { res.body = b; return res; }, + }; + return res; +} + +async function post(path: '/analytics/query' | '/analytics/sql', body: unknown) { + const { server, handlers } = makeFakeServer(); + const calls = { query: [] as unknown[], sql: [] as unknown[] }; + const plugin = createDispatcherPlugin({ prefix: '/api/v1', securityHeaders: false }); + await plugin.start?.(makeCtx(server, calls)); + const handler = handlers[`POST /api/v1${path}`]; + expect(handler, `POST /api/v1${path} must be mounted`).toBeTypeOf('function'); + const res = makeRes(); + await handler({ body, query: {} }, res); + return { res, calls }; +} + +const body = (dateRange: unknown, extra: Record = {}) => ({ + cube: 'orders', + measures: ['count'], + timeDimensions: [{ dimension: 'created_at', granularity: 'day', dateRange, ...extra }], +}); + +describe('#16041 — /analytics/query refuses an unrecognised dateRange string at the door', () => { + it('answers 400 ANALYTICS_DATE_RANGE_UNRECOGNIZED for "Last 7 days" and never calls the service', async () => { + const { res, calls } = await post('/analytics/query', body('Last 7 days')); + + expect(res.statusCode).toBe(400); + expect(res.body.success).toBe(false); + expect(res.body.error.httpStatus).toBe(400); + expect(res.body.error.code).toBe('ANALYTICS_DATE_RANGE_UNRECOGNIZED'); + // A registered code is served verbatim — nothing was demoted. + expect(res.body.error.declaredCode).toBeUndefined(); + // The message locates the field and carries the schema's prescription. + expect(res.body.error.message).toContain('timeDimensions.0.dateRange'); + expect(res.body.error.message).toContain('"Last 7 days"'); + expect(res.body.error.message).toContain('last_7_days'); + // The whole point: the silent widening never reaches an engine. + expect(calls.query).toEqual([]); + }); + + it('the envelope parses against ApiErrorSchema — the code is a vocabulary member, not a dialect', async () => { + const { res } = await post('/analytics/query', body('last 3 months')); + const parsed = ApiErrorSchema.safeParse(res.body.error); + expect(parsed.success, JSON.stringify(parsed.success ? null : parsed.error.issues)).toBe(true); + expect(res.body.error.code).toBe('ANALYTICS_DATE_RANGE_UNRECOGNIZED'); + }); + + it('/analytics/sql shares the body contract and refuses identically', async () => { + const { res, calls } = await post('/analytics/sql', body('2026-01-20')); + expect(res.statusCode).toBe(400); + expect(res.body.error.code).toBe('ANALYTICS_DATE_RANGE_UNRECOGNIZED'); + expect(calls.sql).toEqual([]); + }); + + it('a preset name passes the door and the ORIGINAL body reaches the service untouched', async () => { + const request = body('last_7_days'); + const { res, calls } = await post('/analytics/query', request); + expect(res.statusCode ?? 200).toBe(200); + expect(calls.query).toHaveLength(1); + // Validation-only: the door forwards the caller's body, not a parse. + expect(calls.query[0]).toBe(request); + }); + + it('the array arm is untouched by the closing', async () => { + const { res, calls } = await post('/analytics/query', body(['2026-01-01', '2026-01-31'])); + expect(res.statusCode ?? 200).toBe(200); + expect(calls.query).toHaveLength(1); + }); + + it('a body wrong in MORE than the dateRange stays the generic VALIDATION_FAILED + fields[] — the lift is all-or-nothing', async () => { + const { res, calls } = await post('/analytics/query', body('Last 7 days', { granuarity: 'day' })); + expect(res.statusCode).toBe(400); + expect(res.body.error.code).toBe('VALIDATION_FAILED'); + const fields: Array<{ field: string }> = res.body.error.details.fields; + expect(fields.map((f) => f.field)).toContain('timeDimensions.0.dateRange'); + expect(fields.some((f) => f.field.startsWith('timeDimensions.0'))).toBe(true); + expect(calls.query).toEqual([]); + }); +}); diff --git a/packages/runtime/src/domains/analytics.ts b/packages/runtime/src/domains/analytics.ts index 58d30c6351..765032dc3b 100644 --- a/packages/runtime/src/domains/analytics.ts +++ b/packages/runtime/src/domains/analytics.ts @@ -14,6 +14,7 @@ import { CoreServiceName } from '@objectstack/spec/system'; import { AnalyticsQueryRequestSchema } from '@objectstack/spec/api'; +import { isAnalyticsDateRangeRefusalIssue } from '@objectstack/spec/data'; import { isServiceServeable } from '../service-serveable.js'; import { validationFailure, fieldsFromZodIssues } from '../validation-failure.js'; import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js'; @@ -62,10 +63,27 @@ function assertAnalyticsQueryBody(body: unknown): void { const parsed = AnalyticsQueryRequestSchema.safeParse(body); if (!parsed.success) { const fields = fieldsFromZodIssues(parsed.error.issues); - throw validationFailure( - `Invalid AnalyticsQuery body: ${fields.map((f) => `${f.field}: ${f.message}`).join('; ')}`, - fields, - ); + const message = `Invalid AnalyticsQuery body: ${fields.map((f) => `${f.field}: ${f.message}`).join('; ')}`; + // [#16041] The closed-vocabulary refusal of `timeDimensions[].dateRange` + // answers its own registered code, `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` + // (maintainer ruling, decision batch #57: the string arm closes to the + // date-range preset vocabulary and any other string is refused at the + // schema with the ADR-0112 envelope carrying a stable code). The schema + // raises ONE prescriptive issue at the field's own path and exports the + // predicate that recognises it — structural, never message sniffing — + // so this door lifts the CONDITION the contract declared rather than + // classifying prose. Lifted only when every issue is that refusal: a + // body wrong in several places stays the generic `VALIDATION_FAILED` + // + `fields[]`, whose per-field carrier is the right one for a + // multi-field failure. `.status`/`.code` declared so + // `resolveThrownHttpError` serves the same envelope at both exits. + if (parsed.error.issues.every(isAnalyticsDateRangeRefusalIssue)) { + throw Object.assign(new Error(message), { + status: 400, + code: 'ANALYTICS_DATE_RANGE_UNRECOGNIZED', + }); + } + throw validationFailure(message, fields); } } diff --git a/packages/spec/json-schema.manifest/data.json b/packages/spec/json-schema.manifest/data.json index 3b7bd08323..f243440cde 100644 --- a/packages/spec/json-schema.manifest/data.json +++ b/packages/spec/json-schema.manifest/data.json @@ -9,6 +9,8 @@ "data/AggregationNode", "data/AggregationPipeline", "data/AggregationStage", + "data/AnalyticsDateRange", + "data/AnalyticsDateRangePreset", "data/AnalyticsQuery", "data/ApiMethod", "data/ApiOperation", diff --git a/packages/spec/src/api/analytics.test.ts b/packages/spec/src/api/analytics.test.ts index 7407cea8d8..9958da0196 100644 --- a/packages/spec/src/api/analytics.test.ts +++ b/packages/spec/src/api/analytics.test.ts @@ -80,7 +80,7 @@ describe('AnalyticsQueryRequestSchema — the BARE AnalyticsQuery shape (#3878)' dimensions: ['product_category'], where: { status: 'active', stage: { $nin: ['lost'] } }, timeDimensions: [ - { dimension: 'created_at', granularity: 'month', dateRange: 'Last 7 days' }, + { dimension: 'created_at', granularity: 'month', dateRange: 'last_7_days' }, ], order: { total_revenue: 'desc' }, limit: 100, diff --git a/packages/spec/src/api/error-code-ledger.zod.ts b/packages/spec/src/api/error-code-ledger.zod.ts index 57350ea45e..57e9f32b66 100644 --- a/packages/spec/src/api/error-code-ledger.zod.ts +++ b/packages/spec/src/api/error-code-ledger.zod.ts @@ -264,6 +264,28 @@ export const ERROR_CODE_LEDGER = { 'VALIDATION_FAILED', // record-level validation; carries `fields[]` (#3977) ], '@objectstack/runtime': [ + // [#16041] `POST /analytics/query` / `/analytics/sql` refused a + // `timeDimensions[].dateRange` string outside the closed date-range + // vocabulary (`AnalyticsDateRangeSchema` in `data/analytics.zod.ts`, + // derived from `DATE_RANGE_PRESETS`), or a value that is neither a + // preset name nor a `[start, end]` array. Answered 400 by the runtime + // door (`domains/analytics.ts` lifts the schema's own `invalid_union` + // issue via `isAnalyticsDateRangeRefusalIssue`). Maintainer ruling, + // decision batch #57 (option A, contract first): the arm used to be a + // bare `z.string()` and an unparseable spelling silently matched EVERY + // `Date`-typed row in driver-memory while reading as a single day on SQL. + // + // Not a VALIDATION_ERROR synonym, for the FLOW_* reason: the code names + // the CONDITION — a window spelled outside the platform's one date-range + // vocabulary — which is reported at two moments: here, at the schema + // door, and by the drivers (#16322) when a host calls + // `AnalyticsService.query` in-process past the schema. One condition, + // one code, one wording (`analyticsDateRangeRefusalMessage`, the #5240 + // convention); `VALIDATION_FAILED` would say "malformed body" at one + // moment and nothing a driver could speak at the other. Registered under + // the door that names the wire vocabulary; each driver adds its own + // provenance row when its refusal lands. + 'ANALYTICS_DATE_RANGE_UNRECOGNIZED', // [#16293] The AI-facing action doors refuse a call against an action // whose AUTHOR declared `ai.requiresConfirmation: true` when the request // does not carry the confirmation member as `true` — 428, the standard diff --git a/packages/spec/src/data/analytics-date-range-closed-vocabulary.test.ts b/packages/spec/src/data/analytics-date-range-closed-vocabulary.test.ts new file mode 100644 index 0000000000..9a67642c80 --- /dev/null +++ b/packages/spec/src/data/analytics-date-range-closed-vocabulary.test.ts @@ -0,0 +1,200 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#16041] `timeDimensions[].dateRange`'s string arm is the CLOSED date-range + * preset vocabulary, derived from `data/date-range-presets.ts`, and any other + * string is refused at the schema — maintainer ruling, decision batch #57 + * (option A, contract first). + * + * The negative control is the refusal itself: on the unfixed schema the arm + * was a bare `z.string()`, so every `refuses …` case below parsed CLEAN there + * — measured by ablation (the arm restored to `z.string()` from the base + * commit): those cases go red, the acceptance cases stay green. An + * accept-assertion alone would have proved nothing against `z.string()`. + * + * What the closing fixes is a SILENT WIDENING, not a crash: driver-memory fell + * an unparseable range through to `[range, range]`, a pseudo-window every + * `Date`-typed row satisfied (a `Date` compares above a `String` under BSON + * cross-type ordering), so `"Last 7 days"` — the schema comment's own example + * — returned all of history at HTTP 200. The assertions therefore pin the + * refusal's SHAPE (path, single issue, prescriptive text, the structural + * predicate the runtime door lifts into `ANALYTICS_DATE_RANGE_UNRECOGNIZED`), + * not merely `success === false`. + */ + +import { describe, it, expect } from 'vitest'; +import { + AnalyticsDateRangePresetSchema, + AnalyticsDateRangeSchema, + AnalyticsQuerySchema, + analyticsDateRangeRefusalMessage, + isAnalyticsDateRangeRefusalIssue, + type AnalyticsQuery, +} from './analytics.zod'; +import { AnalyticsQueryRequestSchema } from '../api/analytics.zod'; +import { DATE_RANGE_PRESETS } from './date-range-presets'; +import { ERROR_CODE_LEDGER, ErrorCode } from '../api/error-code-ledger.zod'; + +const QUERY = { measures: ['orders.count'] }; +const withRange = (dateRange: unknown) => ({ + ...QUERY, + timeDimensions: [{ dimension: 'orders.created_at', granularity: 'day', dateRange }], +}); +const RANGE_PATH = ['timeDimensions', 0, 'dateRange']; + +/** The spellings the platform used to accept and could not resolve. */ +const RETIRED_SPELLINGS = [ + 'Last 7 days', // the schema comment's own example — capital L, spaces + 'Last 30 days', + 'last 7 days', // the driver-memory dialect (case-sensitive `last N `) + 'last 3 months', + '2026-01-20', // the SQL strategies' single-day dialect + 'LAST_7_DAYS', // case — the vocabulary is snake_case, case-sensitive + 'last_60_days', // an undeclared sibling + 'This week', + 'custom', // the dashboard defaultRange-only sentinel + '{7_days_ago}', // the OTHER vocabulary: a macro token is a bound, not a window + '', +]; + +describe('AnalyticsQuerySchema.timeDimensions[].dateRange — closed vocabulary (#16041)', () => { + it('derives the string arm from date-range-presets.ts — no fourth copy of the list', () => { + // The module header records the vocabulary once existed in three drifting + // copies. The enum's options ARE the module's tuple, in its order. + expect(AnalyticsDateRangePresetSchema.options).toEqual([...DATE_RANGE_PRESETS]); + // The ruling's "presets plus `today`": `today` is the vocabulary's first + // member, so the enum needs no second source to carry it. + expect(AnalyticsDateRangePresetSchema.options[0]).toBe('today'); + }); + + it('accepts every declared preset name as a bare string, on the schema and on the /analytics/query body', () => { + for (const preset of DATE_RANGE_PRESETS) { + const parsed = AnalyticsQuerySchema.safeParse(withRange(preset)); + expect(parsed.success, `AnalyticsQuerySchema accepts ${preset}`).toBe(true); + const request = AnalyticsQueryRequestSchema.safeParse({ cube: 'orders', ...withRange(preset) }); + expect(request.success, `AnalyticsQueryRequestSchema accepts ${preset}`).toBe(true); + } + }); + + it('keeps the array arm exactly as it was — ISO dates or date-macro tokens', () => { + for (const window of [ + ['2023-01-01', '2023-01-31'], + ['{7_days_ago}', '{today}'], + ['2026-01-20', '2026-01-20'], // the replacement for the retired bare-ISO spelling + ]) { + const parsed = AnalyticsQuerySchema.safeParse(withRange(window)); + expect(parsed.success, JSON.stringify(window)).toBe(true); + } + // Absent stays absent — the field is optional. + expect(AnalyticsQuerySchema.safeParse({ ...QUERY, timeDimensions: [{ dimension: 'x' }] }).success).toBe(true); + }); + + it('REFUSES "Last 7 days" — the value the schema comment used to document — with ONE issue at the field\'s own path', () => { + // Negative control: on the base commit's bare `z.string()` this parse + // SUCCEEDS and the assertion below is red. See the file header. + const parsed = AnalyticsQuerySchema.safeParse(withRange('Last 7 days')); + expect(parsed.success).toBe(false); + if (parsed.success) return; + expect(parsed.error.issues).toHaveLength(1); + const [issue] = parsed.error.issues; + expect(issue.path).toEqual(RANGE_PATH); + expect(issue.code).toBe('invalid_union'); + // The prescription: the value, the spelling that works, the wire code. + expect(issue.message).toContain('"Last 7 days"'); + expect(issue.message).toContain('last_7_days'); + expect(issue.message).toContain('ANALYTICS_DATE_RANGE_UNRECOGNIZED'); + expect(issue.message).toContain('silently widen'); + // The structural handle the runtime door lifts into the registered code. + expect(isAnalyticsDateRangeRefusalIssue(issue)).toBe(true); + }); + + it('refuses every retired spelling — driver-memory\'s, SQL\'s single-day, case and near-miss variants', () => { + for (const spelling of RETIRED_SPELLINGS) { + const parsed = AnalyticsQuerySchema.safeParse(withRange(spelling)); + expect(parsed.success, `refuses ${JSON.stringify(spelling)}`).toBe(false); + if (parsed.success) continue; + expect(parsed.error.issues, JSON.stringify(spelling)).toHaveLength(1); + expect(parsed.error.issues[0].path).toEqual(RANGE_PATH); + expect(parsed.error.issues[0].message).toBe(analyticsDateRangeRefusalMessage(spelling)); + expect(isAnalyticsDateRangeRefusalIssue(parsed.error.issues[0])).toBe(true); + } + }); + + it('refuses the same spellings identically through the /analytics/query body schema', () => { + const parsed = AnalyticsQueryRequestSchema.safeParse({ cube: 'orders', ...withRange('Last 7 days') }); + expect(parsed.success).toBe(false); + if (parsed.success) return; + expect(parsed.error.issues).toHaveLength(1); + expect(parsed.error.issues[0].path).toEqual(RANGE_PATH); + expect(isAnalyticsDateRangeRefusalIssue(parsed.error.issues[0])).toBe(true); + }); + + it('refuses a value that is neither arm, described by type — still one issue, still the same path', () => { + for (const [value, received] of [ + [42, 'number'], + [null, 'null'], + [{ start: '2026-01-01' }, 'object'], + [['2026-01-01', 3], 'an array with a non-string bound'], + ] as const) { + const parsed = AnalyticsQuerySchema.safeParse(withRange(value)); + expect(parsed.success, JSON.stringify(value)).toBe(false); + if (parsed.success) continue; + expect(parsed.error.issues).toHaveLength(1); + expect(parsed.error.issues[0].path).toEqual(RANGE_PATH); + expect(parsed.error.issues[0].message).toContain(`received ${received}`); + expect(isAnalyticsDateRangeRefusalIssue(parsed.error.issues[0])).toBe(true); + } + }); + + it('spells the vocabulary in the refusal from the module, so the prescription cannot drift from the enum', () => { + const message = analyticsDateRangeRefusalMessage('Last 7 days'); + for (const preset of DATE_RANGE_PRESETS) expect(message).toContain(preset); + // And the standalone union reports the same wording as the nested field. + const bare = AnalyticsDateRangeSchema.safeParse('Last 7 days'); + expect(bare.success).toBe(false); + if (!bare.success) expect(bare.error.issues[0].message).toBe(message); + }); + + it('the predicate is structural and fires on nothing else', () => { + // A different refusal on the same item: an undeclared key. + const unknownKey = AnalyticsQuerySchema.safeParse({ + ...QUERY, + timeDimensions: [{ dimension: 'x', granuarity: 'day', dateRange: 'last_7_days' }], + }); + expect(unknownKey.success).toBe(false); + if (!unknownKey.success) { + expect(unknownKey.error.issues.some(isAnalyticsDateRangeRefusalIssue)).toBe(false); + } + // A different refusal on a sibling field. + const badGranularity = AnalyticsQuerySchema.safeParse({ + ...QUERY, + timeDimensions: [{ dimension: 'x', granularity: 'fortnight', dateRange: 'last_7_days' }], + }); + expect(badGranularity.success).toBe(false); + if (!badGranularity.success) { + expect(badGranularity.error.issues.some(isAnalyticsDateRangeRefusalIssue)).toBe(false); + } + // The same key name outside the declared position is not this refusal. + expect(isAnalyticsDateRangeRefusalIssue({ code: 'invalid_union', path: ['dateRange'] })).toBe(false); + expect(isAnalyticsDateRangeRefusalIssue({ code: 'invalid_union', path: ['filters', 0, 'dateRange'] })).toBe(false); + expect(isAnalyticsDateRangeRefusalIssue({ code: 'invalid_type', path: RANGE_PATH })).toBe(false); + }); + + it('the wire code is a registered ADR-0112 ledger member, owned by the runtime door that emits it', () => { + expect(ERROR_CODE_LEDGER['@objectstack/runtime']).toContain('ANALYTICS_DATE_RANGE_UNRECOGNIZED'); + expect(ErrorCode.safeParse('ANALYTICS_DATE_RANGE_UNRECOGNIZED').success).toBe(true); + }); + + it('narrows the authored TYPE too — a retired spelling no longer compiles', () => { + const accepted: NonNullable = [ + { dimension: 'created_at', dateRange: 'last_7_days' }, + { dimension: 'created_at', dateRange: ['2023-01-01', '2023-01-31'] }, + ]; + const retired: NonNullable = [ + // @ts-expect-error — the display spelling is outside the closed vocabulary + { dimension: 'created_at', dateRange: 'Last 7 days' }, + ]; + expect(accepted).toHaveLength(2); + expect(retired).toHaveLength(1); + }); +}); diff --git a/packages/spec/src/data/analytics-strictness-batchd.test.ts b/packages/spec/src/data/analytics-strictness-batchd.test.ts index 0a914c5e1e..5239dc1b4b 100644 --- a/packages/spec/src/data/analytics-strictness-batchd.test.ts +++ b/packages/spec/src/data/analytics-strictness-batchd.test.ts @@ -117,7 +117,7 @@ describe('#4001 batch D — the doors the cube family is reachable through', () ...QUERY, dimensions: ['stage'], where: { is_active: true }, - timeDimensions: [{ dimension: 'created', granularity: 'day', dateRange: 'Last 7 days' }], + timeDimensions: [{ dimension: 'created', granularity: 'day', dateRange: 'last_7_days' }], order: { stage: 'asc' }, limit: 10, offset: 0, diff --git a/packages/spec/src/data/analytics.test.ts b/packages/spec/src/data/analytics.test.ts index 7ec490b285..deb3713ae6 100644 --- a/packages/spec/src/data/analytics.test.ts +++ b/packages/spec/src/data/analytics.test.ts @@ -351,7 +351,7 @@ describe('AnalyticsQuerySchema', () => { timeDimensions: [{ dimension: 'orders.created_at', granularity: 'month', - dateRange: 'Last 7 days', + dateRange: 'last_7_days', }], order: { 'orders.count': 'desc' }, limit: 100, diff --git a/packages/spec/src/data/analytics.zod.ts b/packages/spec/src/data/analytics.zod.ts index 0dcbbf57b6..07876c9e30 100644 --- a/packages/spec/src/data/analytics.zod.ts +++ b/packages/spec/src/data/analytics.zod.ts @@ -2,6 +2,7 @@ import { z } from 'zod'; import { FilterConditionSchema } from './filter.zod'; +import { DATE_RANGE_PRESETS } from './date-range-presets'; /** * Analytics/Semantic Layer Protocol @@ -245,6 +246,109 @@ export const CubeSchema = lazySchema(() => strictObject( }, )); +/** + * The bare-string arm of `timeDimensions[].dateRange` — the dashboard + * date-range PRESET vocabulary, closed (#16041). + * + * Derived from {@link DATE_RANGE_PRESETS} rather than restated: that module's + * header records the vocabulary once existed in three drifting copies, and a + * fourth here would be the defect it was consolidated to end. `today` is the + * vocabulary's first member, so the ruling's "presets plus `today`" IS this + * enum. `analytics-date-range-closed-vocabulary.test.ts` pins the options + * equal to the module's list. + * + * Why closed (maintainer ruling on #16041, decision batch #57, option A — + * contract first): the arm was a bare `z.string()` whose only documented + * example, `"Last 7 days"`, was a value no driver could parse. An unrecognised + * spelling reached `driver-memory` as written and fell through to a + * `[range, range]` "window" that matched EVERY `Date`-typed row (a `Date` + * compares above a `String` under BSON cross-type ordering, so both garbage + * bounds were satisfied) — a dashboard asking for one week silently got all + * of history, while the SQL side read a bare string as a single ISO day. + * Same input, opposite wrong answers, neither an error. The protocol is the + * baseline, so the vocabulary is declared ONCE here and the drivers align to + * it (#16322) instead of each guessing. + */ +export const AnalyticsDateRangePresetSchema = z.enum(DATE_RANGE_PRESETS); + +/** + * The one refusal wording for a `timeDimensions[].dateRange` value outside + * the closed contract — shared by the schema door (this file) and, through + * the `ANALYTICS_DATE_RANGE_UNRECOGNIZED` envelope, by the runtime door and + * the drivers (#16322), so one condition keeps one wording (the #5240 + * convention). A bare string is judged against {@link DATE_RANGE_PRESETS}; + * anything that is neither a preset name nor an array is described by type. + */ +export function analyticsDateRangeRefusalMessage(input: unknown): string { + const window = 'an explicit window is the two-element array [start, end] of ISO dates or ' + + '{date-macro} tokens — e.g. ["2026-01-01", "2026-01-31"] or ["{7_days_ago}", "{today}"]'; + if (typeof input === 'string') { + return ( + `${JSON.stringify(input)} is not a dateRange the platform can resolve. A bare string must ` + + `be one of the declared date-range PRESET names (${DATE_RANGE_PRESETS.join(', ')}) — the ` + + `same closed vocabulary the dashboard date filter uses, case-sensitive, snake_case; ` + + `${window}. Refused at the schema (ANALYTICS_DATE_RANGE_UNRECOGNIZED / 400): an ` + + 'unrecognised spelling used to reach the driver as written and silently widen the window ' + + 'to every row instead of the one you named.' + ); + } + const received = input === null ? 'null' : Array.isArray(input) ? 'an array with a non-string bound' : typeof input; + return ( + `dateRange must be a date-range preset name (${DATE_RANGE_PRESETS.join(', ')}) or ` + + `${window}; received ${received}. Refused at the schema (ANALYTICS_DATE_RANGE_UNRECOGNIZED / 400).` + ); +} + +/** + * `timeDimensions[].dateRange` — a preset name from the closed vocabulary, or + * an explicit `[start, end]` window. + * + * @example + * ```ts + * { dimension: 'created_at', granularity: 'day', dateRange: 'last_7_days' } + * { dimension: 'created_at', granularity: 'month', dateRange: ['2023-01-01', '2023-01-31'] } + * { dimension: 'created_at', dateRange: ['{30_days_ago}', '{today}'] } + * ``` + * + * A value that is neither raises ONE issue at the field's own path with the + * prescriptive wording of {@link analyticsDateRangeRefusalMessage}; the + * runtime door recognises it through {@link isAnalyticsDateRangeRefusalIssue} + * and answers the ADR-0112 envelope `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` + * (registered in `api/error-code-ledger.zod.ts`). + */ +export const AnalyticsDateRangeSchema = z.union( + [AnalyticsDateRangePresetSchema, z.array(z.string())], + { + // Zod 4 reports a union with no matching arm as ONE `invalid_union` issue + // at the union's own path, so the prescription lands on + // `timeDimensions.N.dateRange` instead of on the two arms' generic texts. + error: (issue) => (issue.code === 'invalid_union' ? analyticsDateRangeRefusalMessage(issue.input) : undefined), + }, +); +export type AnalyticsDateRange = z.input; + +/** + * Is this Zod issue the closed-vocabulary refusal of a + * `timeDimensions[].dateRange` value? Structural — the union's own + * `invalid_union` issue at the path this schema declares — so the door that + * lifts it into `ANALYTICS_DATE_RANGE_UNRECOGNIZED` reads the contract rather + * than sniffing message prose, and moves with the schema if the field ever + * moves. Accepts any object with a Zod-issue-shaped `code` and `path` so a + * door does not need Zod's own types to ask. + */ +export function isAnalyticsDateRangeRefusalIssue( + issue: { code: string; path: ReadonlyArray }, +): boolean { + const p = issue.path; + return ( + issue.code === 'invalid_union' + && p.length >= 3 + && p[p.length - 1] === 'dateRange' + && typeof p[p.length - 2] === 'number' + && p[p.length - 3] === 'timeDimensions' + ); +} + /** * Analytics Query Schema * The request format for the Analytics API. @@ -322,12 +426,26 @@ export const AnalyticsQuerySchema = lazySchema(() => strictObject( { dimension: z.string(), granularity: TimeUpdateInterval.optional(), - dateRange: z.union([ - z.string(), // "Last 7 days" - z.array(z.string()) // ["2023-01-01", "2023-01-31"] - ]).optional(), + // The string arm is the closed preset vocabulary (`'last_7_days'`, never + // the display spelling `"Last 7 days"` this comment used to show — a + // value no driver could parse, #16041); the array arm is an explicit + // `["2023-01-01", "2023-01-31"]` window. See {@link AnalyticsDateRangeSchema}. + dateRange: AnalyticsDateRangeSchema.optional().describe( + // The vocabulary is spelled by the module, never restated here — the + // generated reference page is one of the three copies #4614 retired. + 'Time window for this dimension: a date-range PRESET name from the closed vocabulary in ' + + `\`data/date-range-presets.ts\` (${DATE_RANGE_PRESETS.join(', ')} — 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`.' + ), }, - )).optional(), + )).optional().describe( + '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: z.record(z.string(), z.enum(['asc', 'desc'])).optional(), diff --git a/packages/spec/src/migrations/entries/semantic/18.analytics-time-dimension-date-range-vocabulary-closed.ts b/packages/spec/src/migrations/entries/semantic/18.analytics-time-dimension-date-range-vocabulary-closed.ts new file mode 100644 index 0000000000..716a101896 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.analytics-time-dimension-date-range-vocabulary-closed.ts @@ -0,0 +1,60 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'analytics-time-dimension-date-range-vocabulary-closed', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span already, and a nested backtick would close it. + surface: + 'the bare-STRING arm of timeDimensions[].dateRange on an analytics query — ' + + 'AnalyticsQuerySchema / the POST /analytics/query and /analytics/sql bodies, a dataset ' + + 'selection\'s timeDimensions, and any AnalyticsQuery a host passes to ' + + 'AnalyticsService.query in-process — authored as anything other than one of the ' + + 'thirteen declared date-range preset names (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): the display spelling "Last 7 days" the schema comment used ' + + 'to show, the driver-memory dialect "last N days" / "last 3 months", or a bare ISO date ' + + 'such as "2026-01-20" (the SQL strategies\' single-day dialect)', + replacement: + 'a preset name from the closed vocabulary — `\'last_7_days\'` for "Last 7 days" / "last 7 ' + + 'days", `\'last_30_days\'`, `\'this_month\'`, and so on (`DATE_RANGE_PRESETS` in ' + + '`@objectstack/spec/data` is the list; the rejection prints it) — or, for an explicit ' + + 'window, the two-element array the array arm always accepted: `[\'2026-01-20\', ' + + '\'2026-01-20\']` for the single day a bare ISO string used to mean on SQL, ' + + '`[\'2026-01-01\', \'2026-01-31\']`, or `[\'{7_days_ago}\', \'{today}\']` in date-macro tokens', + reason: + 'Maintainer ruling on #16041 (decision batch #57, option A — contract first, 2026-09-06): ' + + 'the protocol is the baseline, so the vocabulary is declared once in the schema and the ' + + 'drivers align to it (#16322) instead of each guessing. The arm was a bare `z.string()` ' + + 'whose only documented example, `"Last 7 days"`, no driver could parse: driver-memory ' + + 'recognised exactly `today` and a case-sensitive `last N ` and fell every other ' + + 'string through to a `[range, range]` pseudo-window that — measured through mingo on ' + + '2026-09-05 — matched EVERY `Date`-typed row, 2099 included, because a `Date` compares ' + + 'above a `String` under BSON cross-type ordering; the SQL strategies read the same string ' + + 'as a single ISO day. A dashboard asking for one week silently got all of history on one ' + + 'backend and one day on the other, with no error on either. The string arm is now ' + + '`z.enum(DATE_RANGE_PRESETS)` — derived from `data/date-range-presets.ts`, the vocabulary\'s ' + + 'single source of truth since #4614, so the two cannot drift — and any other string is ' + + 'refused at parse time with one prescriptive issue at the field\'s own path; the runtime ' + + 'door answers the ADR-0112 envelope `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` ' + + '(`api/error-code-ledger.zod.ts`). ⚠️ No D2 conversion and no stored-metadata rewrite: ' + + 'this value is a QUERY-time request field, not a `sys_metadata` shape, and the two ' + + 'retired dialects meant different windows on different backends, so coercing one would ' + + 'be the platform guessing which the author meant. Measured in this repository at the ' + + 'ruling: three authored `\'Last 7 days\'`, all in spec tests, and no published dashboard ' + + 'authors the string arm at all (the shipped console lowers presets to the array arm). ' + + 'ADR-0049 / ADR-0112.', + acceptanceCriteria: + 'Grep every authored `timeDimensions[].dateRange` string — dashboard datasets, saved ' + + 'analytics queries, SDK / MCP callers, in-process `AnalyticsService.query` calls — and ' + + 'rewrite each bare string that is not one of the thirteen preset names: a relative ' + + 'phrase to its preset (`\'last_7_days\'`), an ISO day to the two-element array ' + + '`[day, day]`. `POST /analytics/query` now answers `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` ' + + 'naming the value and the vocabulary, so a sweep is mechanical; `AnalyticsQuerySchema.' + + 'safeParse` reports the same issue at `timeDimensions.N.dateRange`. Preset names and ' + + '`[start, end]` arrays parse byte-identically to before. A query that carried one of the ' + + 'retired spellings was never returning the window it named (all rows on driver-memory, ' + + 'one day on SQL), so re-check what the widget was supposed to show rather than trusting ' + + 'the old result set.', +}; From ce8266cc396f0f939ee560234e52663fd2f1e9b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 12:09:39 +0000 Subject: [PATCH 2/7] feat(spec): changeset, ADR-0087 registration and regenerated projections for the closed dateRange vocabulary (wip 2) Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01T6HeZvT9wdSJD1ZxJb5Eno --- .../analytics-daterange-closed-vocabulary.md | 69 +++++++++++++++++++ content/docs/references/api/analytics.mdx | 10 ++- content/docs/references/api/contract.mdx | 3 +- .../docs/references/api/error-code-ledger.mdx | 1 + content/docs/references/data/analytics.mdx | 56 ++++++++++++++- content/docs/references/index.mdx | 10 +-- packages/spec/api-surface/data.json | 5 ++ packages/spec/declaration-map/data.json | 3 + packages/spec/export-origins/data.json | 5 ++ packages/spec/src/data/analytics.zod.ts | 2 + packages/spec/src/migrations/registry.ts | 56 +++++++++++++++ 11 files changed, 210 insertions(+), 10 deletions(-) create mode 100644 .changeset/analytics-daterange-closed-vocabulary.md diff --git a/.changeset/analytics-daterange-closed-vocabulary.md b/.changeset/analytics-daterange-closed-vocabulary.md new file mode 100644 index 0000000000..fb541344ec --- /dev/null +++ b/.changeset/analytics-daterange-closed-vocabulary.md @@ -0,0 +1,69 @@ +--- +"@objectstack/spec": minor +"@objectstack/runtime": minor +--- + +feat(spec)!: `timeDimensions[].dateRange`'s string arm closes to the date-range preset vocabulary; any other string is refused with `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` (#16041) + + + +**BREAKING** — an accept-set narrowing on a published analytics contract. +`AnalyticsQuerySchema.timeDimensions[].dateRange` (and with it the +`POST /analytics/query` / `/analytics/sql` bodies, `AnalyticsQueryRequestSchema`, +and the `AnalyticsQuery` type every driver and `AnalyticsService.query` caller is +typed against) used to accept ANY string. It now accepts exactly the thirteen +dashboard date-range preset names, derived from `data/date-range-presets.ts` +(`z.enum(DATE_RANGE_PRESETS)` — the vocabulary's single source of truth since +#4614, so the two cannot drift), or the unchanged `[start, end]` array arm. +Shipped as `minor` under the repo's launch-window convention for breaking +changes; the hand-migration prescription is registered under protocol major 18. +Maintainer ruling on #16041 (2026-09-06, decision batch #57, option A — +contract first, 「同意」): 「本项目以协议为基准。所以开发应该对其协议,协议有问题应该立卡修改协议」. + +## What was wrong + +The arm was a bare `z.string()` whose only documented example — `"Last 7 days"`, +in the schema's own comment — was a value no driver could parse. `driver-memory` +recognised exactly `today` and a case-sensitive `last N ` and fell every +other string through to a `[range, range]` pseudo-window that (measured through +mingo, 2026-09-05) matched **every `Date`-typed row**, 2099 included, because a +`Date` compares above a `String` under BSON cross-type ordering. The SQL +strategies read the same bare string as a single ISO day. A dashboard asking for +one week silently got all of history on one backend and one day on the other, +at HTTP 200 on both. + +## What it does now + +- The string arm is `AnalyticsDateRangePresetSchema = z.enum(DATE_RANGE_PRESETS)` + (`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`); the schema example is corrected to + `'last_7_days'`. +- Any other value raises ONE prescriptive issue at `timeDimensions.N.dateRange` + (`analyticsDateRangeRefusalMessage`: the value, the vocabulary, the array + spelling for an explicit window). `@objectstack/spec/data` exports the + structural predicate `isAnalyticsDateRangeRefusalIssue` for doors. +- `POST /analytics/query` and `/analytics/sql` answer the ADR-0112 envelope + **`400 ANALYTICS_DATE_RANGE_UNRECOGNIZED`** — a new `ERROR_CODE_LEDGER` member + registered under `@objectstack/runtime` — and the analytics service is never + reached. A body wrong in more places than the `dateRange` stays the generic + `400 VALIDATION_FAILED` + `details.fields[]`. + +## FROM → TO + +| you wrote | write instead | +|:--|:--| +| `dateRange: 'Last 7 days'` / `'last 7 days'` | `dateRange: 'last_7_days'` | +| `dateRange: 'Last 30 days'` / `'last 30 days'` | `dateRange: 'last_30_days'` | +| `dateRange: 'last 3 months'` | `dateRange: 'last_90_days'`, or an explicit `['{90_days_ago}', '{today}']` | +| `dateRange: '2026-01-20'` (the SQL single-day dialect) | `dateRange: ['2026-01-20', '2026-01-20']` | +| `dateRange: 'This week'` | `dateRange: 'this_week'` | +| `dateRange: ['2026-01-01', '2026-01-31']` | unchanged | + +Measured in this repository at the ruling: three authored `'Last 7 days'`, all +in `packages/spec` tests (re-spelled here), and no published dashboard authors +the string arm at all — the shipped console lowers presets to the array arm +before querying. The drivers' own refusal of a non-conforming value that reaches +them in-process (past the schema) is the sibling card #16322, blocked by this +one; the fenced `service-analytics` fixture that authors the retired bare-ISO +spelling is that card's to re-triage. diff --git a/content/docs/references/api/analytics.mdx b/content/docs/references/api/analytics.mdx index 294a736f7e..2d21bfea2d 100644 --- a/content/docs/references/api/analytics.mdx +++ b/content/docs/references/api/analytics.mdx @@ -83,7 +83,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?: string \| string[] }[]` | optional | | +| **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. | | **order** | `Record>` | optional | | | **limit** | `number` | optional | | | **offset** | `number` | optional | | @@ -91,6 +91,14 @@ const result = AnalyticsEndpoint.parse(data); | **query** | `never` | optional | [REMOVED] `query` was removed from AnalyticsQueryRequest in @objectstack/spec 17.0.0. The `{ cube, query: {...} }` envelope was the dialect of the retired degraded analytics shim — the real engine never understood it. Move the query.* fields to the body top level: `{ cube, measures, dimensions?, where?, timeDimensions?, order?, limit?, offset?, timezone? }`. | | **format** | `never` | optional | [REMOVED] `format` was removed from AnalyticsQueryRequest in @objectstack/spec 17.0.0. It was never implemented — every response is the JSON envelope. Delete the key; for CSV/XLSX use the export surface instead. | +### Nested Shape: `AnalyticsQueryRequest.timeDimensions[number]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **dimension** | `string` | ✅ | | +| **granularity** | `Enum<'second' \| 'minute' \| 'hour' \| '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/api/contract.mdx b/content/docs/references/api/contract.mdx index d5db2646c4..14d6ec710f 100644 --- a/content/docs/references/api/contract.mdx +++ b/content/docs/references/api/contract.mdx @@ -27,7 +27,7 @@ const result = ApiErrorSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +298 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | +| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +299 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | | **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | | **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | @@ -93,6 +93,7 @@ const result = ApiErrorSchema.parse(data); * `ACTION_DISABLED` * `ALREADY_REVERTED` * `AMBIGUOUS_MATCH` +* `ANALYTICS_DATE_RANGE_UNRECOGNIZED` * `ANALYTICS_QUERY_FAILED` * `APPROVAL_ACTIONS_FAILED` * `APPROVAL_APPROVE_FAILED` diff --git a/content/docs/references/api/error-code-ledger.mdx b/content/docs/references/api/error-code-ledger.mdx index 2cd6bcc965..a75aea1473 100644 --- a/content/docs/references/api/error-code-ledger.mdx +++ b/content/docs/references/api/error-code-ledger.mdx @@ -209,6 +209,7 @@ const result = ErrorCode.parse(data); * `ACTION_DISABLED` * `ALREADY_REVERTED` * `AMBIGUOUS_MATCH` +* `ANALYTICS_DATE_RANGE_UNRECOGNIZED` * `ANALYTICS_QUERY_FAILED` * `APPROVAL_ACTIONS_FAILED` * `APPROVAL_APPROVE_FAILED` diff --git a/content/docs/references/data/analytics.mdx b/content/docs/references/data/analytics.mdx index 89abfe0f5b..d8b92d268f 100644 --- a/content/docs/references/data/analytics.mdx +++ b/content/docs/references/data/analytics.mdx @@ -20,8 +20,8 @@ This layer decouples the "Physical Data" (Tables/Columns) from the ## TypeScript Usage ```typescript -import { AggregationMetricType, AnalyticsQuerySchema, CubeSchema, CubeJoinSchema, DimensionSchema, DimensionType, MetricSchema, TimeUpdateInterval } from '@objectstack/spec/data'; -import type { AggregationMetricType, AnalyticsQuery, Cube, CubeJoin, Dimension, DimensionType, Metric, TimeUpdateInterval } from '@objectstack/spec/data'; +import { AggregationMetricType, AnalyticsDateRangeSchema, AnalyticsDateRangePresetSchema, AnalyticsQuerySchema, CubeSchema, CubeJoinSchema, DimensionSchema, DimensionType, MetricSchema, TimeUpdateInterval } from '@objectstack/spec/data'; +import type { AggregationMetricType, AnalyticsDateRange, AnalyticsQuery, Cube, CubeJoin, Dimension, DimensionType, Metric, TimeUpdateInterval } from '@objectstack/spec/data'; // Validate data const result = AggregationMetricType.parse(data); @@ -44,6 +44,48 @@ const result = AggregationMetricType.parse(data); * `boolean` +--- + +## AnalyticsDateRange + +### Union Options + +This schema accepts one of the following structures: + +#### Option 1 + +Allowed Values: `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` + +--- + +#### Option 2 + +Type: `string[]` + +--- + + +--- + +## AnalyticsDateRangePreset + +### Allowed Values + +* `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` + + --- ## AnalyticsQuery @@ -56,12 +98,20 @@ const result = AggregationMetricType.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?: string \| string[] }[]` | optional | | +| **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. | | **order** | `Record>` | optional | | | **limit** | `number` | optional | | | **offset** | `number` | optional | | | **timezone** | `string` | optional | | +### Nested Shape: `AnalyticsQuery.timeDimensions[number]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **dimension** | `string` | ✅ | | +| **granularity** | `Enum<'second' \| 'minute' \| 'hour' \| '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/index.mdx b/content/docs/references/index.mdx index 438008c672..880d9e904c 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,6 +1,6 @@ --- title: Protocol Reference -description: Every schema published by @objectstack/spec — 1574 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1576 schemas across 14 protocol modules --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -23,7 +23,7 @@ counts are sums of the rows they head. Regenerate with | [API Protocol](/docs/references/api) | 31 | 436 | REST contracts, endpoints, routing, realtime, batch, discovery. | | [Automation Protocol](/docs/references/automation) | 13 | 73 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. | | [Cloud Protocol](/docs/references/cloud) | 11 | 94 | Environments, packages and versions, marketplace, developer portal, tenancy. | -| [Data Protocol](/docs/references/data) | 29 | 166 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. | +| [Data Protocol](/docs/references/data) | 29 | 168 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. | | [Identity Protocol](/docs/references/identity) | 5 | 27 | Users and accounts, organizations, positions, SCIM provisioning. | | [Integration Protocol](/docs/references/integration) | 1 | 24 | The single connector protocol (ADR-0097) — catalog descriptors and provider-bound instances. | | [Kernel Protocol](/docs/references/kernel) | 30 | 162 | Plugin lifecycle and manifests, capabilities and security, metadata loading, service registry. | @@ -33,7 +33,7 @@ counts are sums of the rows they head. Regenerate with | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | | [System Protocol](/docs/references/system) | 33 | 272 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | | [UI Protocol](/docs/references/ui) | 16 | 153 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **198** | **1574** | 14 protocol modules | +| **Total** | **198** | **1576** | 14 protocol modules | --- @@ -149,13 +149,13 @@ Environments, packages and versions, marketplace, developer portal, tenancy. ## Data Protocol -**Source:** `packages/spec/src/data/` · **Import:** `@objectstack/spec/data` · **29 pages, 166 schemas** +**Source:** `packages/spec/src/data/` · **Import:** `@objectstack/spec/data` · **29 pages, 168 schemas** Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. | File | Schemas | | :--- | :--- | -| [`analytics.zod.ts`](/docs/references/data/analytics) | `AggregationMetricType`, `AnalyticsQuery`, `Cube`, `CubeJoin`, `Dimension`, `DimensionType`, `Metric`, `TimeUpdateInterval` | +| [`analytics.zod.ts`](/docs/references/data/analytics) | `AggregationMetricType`, `AnalyticsDateRange`, `AnalyticsDateRangePreset`, `AnalyticsQuery`, `Cube`, `CubeJoin`, `Dimension`, `DimensionType`, `Metric`, `TimeUpdateInterval` | | [`context-tokens.zod.ts`](/docs/references/data/context-tokens) | `ContextToken`, `ContextTokenPlaceholder` | | [`data-engine.zod.ts`](/docs/references/data/data-engine) | `BaseEngineOptions`, `DataEngineAggregateOptions`, `DataEngineAggregateRequest`, `DataEngineCountOptions`, `DataEngineCountRequest`, `DataEngineDeleteOptions`, `DataEngineDeleteRequest`, `DataEngineExecuteRequest`, `DataEngineFilter`, `DataEngineFindOneRequest`, `DataEngineFindRequest`, `DataEngineInsertOptions`, `DataEngineInsertRequest`, `DataEngineQueryOptions`, `DataEngineRequest`, `DataEngineSort`, `DataEngineUpdateOptions`, `DataEngineUpdateRequest`, `DataEngineVectorFindRequest`, `DroppedFieldsEvent`, `EngineAggregateOptions`, `EngineCountOptions`, `EngineDeleteOptions`, `EngineQueryOptions`, `EngineUpdateOptions` | | [`datasource.zod.ts`](/docs/references/data/datasource) | `Datasource`, `DriverDefinition`, `DriverType`, `ExternalDatasourceSettings`, `SchemaMode` | diff --git a/packages/spec/api-surface/data.json b/packages/spec/api-surface/data.json index 81bbd507da..30283f75af 100644 --- a/packages/spec/api-surface/data.json +++ b/packages/spec/api-surface/data.json @@ -30,6 +30,9 @@ "AggregationRow (interface)", "AggregationStage (type)", "AggregationStageSchema (const)", + "AnalyticsDateRange (type)", + "AnalyticsDateRangePresetSchema (const)", + "AnalyticsDateRangeSchema (const)", "AnalyticsQuery (type)", "AnalyticsQuerySchema (const)", "ApiExposureDenialReason (type)", @@ -680,6 +683,7 @@ "ValueRoundTripColumn (type)", "ValueShapeFieldDef (interface)", "ZeroLimitConformanceCase (interface)", + "analyticsDateRangeRefusalMessage (function)", "apiExposureDenialReason (function)", "asciiCaseInsensitiveContains (function)", "asciiCaseInsensitiveRegexSource (function)", @@ -733,6 +737,7 @@ "hookForm (const)", "injectedSystemColumnDefs (function)", "isAcceptedFilterComparand (function)", + "isAnalyticsDateRangeRefusalIssue (function)", "isApiOperationAllowed (function)", "isApiPrimitive (function)", "isAppResolvedDefaultToken (function)", diff --git a/packages/spec/declaration-map/data.json b/packages/spec/declaration-map/data.json index 125434a0de..633f4d3aee 100644 --- a/packages/spec/declaration-map/data.json +++ b/packages/spec/declaration-map/data.json @@ -14,6 +14,9 @@ "AggregationPipelineSchema": "data/AggregationPipeline", "AggregationStage": "data/AggregationStage", "AggregationStageSchema": "data/AggregationStage", + "AnalyticsDateRange": "data/AnalyticsDateRange", + "AnalyticsDateRangePresetSchema": "data/AnalyticsDateRangePreset", + "AnalyticsDateRangeSchema": "data/AnalyticsDateRange", "AnalyticsQuery": "data/AnalyticsQuery", "AnalyticsQuerySchema": "data/AnalyticsQuery", "ApiMethod": "data/ApiMethod", diff --git a/packages/spec/export-origins/data.json b/packages/spec/export-origins/data.json index 624ffba6fa..3ad0c0f5df 100644 --- a/packages/spec/export-origins/data.json +++ b/packages/spec/export-origins/data.json @@ -28,6 +28,9 @@ "AggregationRow": "src/data/aggregation-conformance.ts#AggregationRow (interface)", "AggregationStage": "src/data/driver-nosql.zod.ts#AggregationStage (type)", "AggregationStageSchema": "src/data/driver-nosql.zod.ts#AggregationStageSchema (const)", + "AnalyticsDateRange": "src/data/analytics.zod.ts#AnalyticsDateRange (type)", + "AnalyticsDateRangePresetSchema": "src/data/analytics.zod.ts#AnalyticsDateRangePresetSchema (const)", + "AnalyticsDateRangeSchema": "src/data/analytics.zod.ts#AnalyticsDateRangeSchema (const)", "AnalyticsQuery": "src/data/analytics.zod.ts#AnalyticsQuery (type)", "AnalyticsQuerySchema": "src/data/analytics.zod.ts#AnalyticsQuerySchema (const)", "ApiExposureDenialReason": "src/data/api-derivation.ts#ApiExposureDenialReason (type)", @@ -667,6 +670,7 @@ "ValueRoundTripColumn": "src/data/value-roundtrip-conformance.ts#ValueRoundTripColumn (type)", "ValueShapeFieldDef": "src/data/field-value.zod.ts#ValueShapeFieldDef (interface)", "ZeroLimitConformanceCase": "src/data/pagination-conformance.ts#ZeroLimitConformanceCase (interface)", + "analyticsDateRangeRefusalMessage": "src/data/analytics.zod.ts#analyticsDateRangeRefusalMessage (function)", "apiExposureDenialReason": "src/data/api-derivation.ts#apiExposureDenialReason (function)", "asciiCaseInsensitiveContains": "src/data/filter.zod.ts#asciiCaseInsensitiveContains (function)", "asciiCaseInsensitiveRegexSource": "src/data/filter.zod.ts#asciiCaseInsensitiveRegexSource (function)", @@ -720,6 +724,7 @@ "hookForm": "src/data/hook.form.ts#hookForm (const)", "injectedSystemColumnDefs": "src/data/injected-system-column-provenance.ts#injectedSystemColumnDefs (function)", "isAcceptedFilterComparand": "src/data/filter-comparand-type.ts#isAcceptedFilterComparand (function)", + "isAnalyticsDateRangeRefusalIssue": "src/data/analytics.zod.ts#isAnalyticsDateRangeRefusalIssue (function)", "isApiOperationAllowed": "src/data/api-derivation.ts#isApiOperationAllowed (function)", "isApiPrimitive": "src/data/api-derivation.ts#isApiPrimitive (function)", "isAppResolvedDefaultToken": "src/data/default-value-tokens.ts#isAppResolvedDefaultToken (function)", diff --git a/packages/spec/src/data/analytics.zod.ts b/packages/spec/src/data/analytics.zod.ts index 07876c9e30..e573905afe 100644 --- a/packages/spec/src/data/analytics.zod.ts +++ b/packages/spec/src/data/analytics.zod.ts @@ -270,6 +270,8 @@ export const CubeSchema = lazySchema(() => strictObject( * it (#16322) instead of each guessing. */ export const AnalyticsDateRangePresetSchema = z.enum(DATE_RANGE_PRESETS); +/** The same names as {@link DateRangePreset} — declared through the schema so the alias cannot drift from it. */ +export type AnalyticsDateRangePreset = z.input; /** * The one refusal wording for a `timeDimensions[].dateRange` value outside diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 9a9bfa0a71..8e1bd8b227 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -5635,6 +5635,62 @@ const step18: MigrationStep = { + 'every `/analytics/query` body\'s `timeDimensions[]` items carry only ' + '`dimension`/`granularity`/`dateRange`. Declared keys parse byte-identically to before.', }, + { + id: 'analytics-time-dimension-date-range-vocabulary-closed', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span already, and a nested backtick would close it. + surface: + 'the bare-STRING arm of timeDimensions[].dateRange on an analytics query — ' + + 'AnalyticsQuerySchema / the POST /analytics/query and /analytics/sql bodies, a dataset ' + + 'selection\'s timeDimensions, and any AnalyticsQuery a host passes to ' + + 'AnalyticsService.query in-process — authored as anything other than one of the ' + + 'thirteen declared date-range preset names (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): the display spelling "Last 7 days" the schema comment used ' + + 'to show, the driver-memory dialect "last N days" / "last 3 months", or a bare ISO date ' + + 'such as "2026-01-20" (the SQL strategies\' single-day dialect)', + replacement: + 'a preset name from the closed vocabulary — `\'last_7_days\'` for "Last 7 days" / "last 7 ' + + 'days", `\'last_30_days\'`, `\'this_month\'`, and so on (`DATE_RANGE_PRESETS` in ' + + '`@objectstack/spec/data` is the list; the rejection prints it) — or, for an explicit ' + + 'window, the two-element array the array arm always accepted: `[\'2026-01-20\', ' + + '\'2026-01-20\']` for the single day a bare ISO string used to mean on SQL, ' + + '`[\'2026-01-01\', \'2026-01-31\']`, or `[\'{7_days_ago}\', \'{today}\']` in date-macro tokens', + reason: + 'Maintainer ruling on #16041 (decision batch #57, option A — contract first, 2026-09-06): ' + + 'the protocol is the baseline, so the vocabulary is declared once in the schema and the ' + + 'drivers align to it (#16322) instead of each guessing. The arm was a bare `z.string()` ' + + 'whose only documented example, `"Last 7 days"`, no driver could parse: driver-memory ' + + 'recognised exactly `today` and a case-sensitive `last N ` and fell every other ' + + 'string through to a `[range, range]` pseudo-window that — measured through mingo on ' + + '2026-09-05 — matched EVERY `Date`-typed row, 2099 included, because a `Date` compares ' + + 'above a `String` under BSON cross-type ordering; the SQL strategies read the same string ' + + 'as a single ISO day. A dashboard asking for one week silently got all of history on one ' + + 'backend and one day on the other, with no error on either. The string arm is now ' + + '`z.enum(DATE_RANGE_PRESETS)` — derived from `data/date-range-presets.ts`, the vocabulary\'s ' + + 'single source of truth since #4614, so the two cannot drift — and any other string is ' + + 'refused at parse time with one prescriptive issue at the field\'s own path; the runtime ' + + 'door answers the ADR-0112 envelope `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` ' + + '(`api/error-code-ledger.zod.ts`). ⚠️ No D2 conversion and no stored-metadata rewrite: ' + + 'this value is a QUERY-time request field, not a `sys_metadata` shape, and the two ' + + 'retired dialects meant different windows on different backends, so coercing one would ' + + 'be the platform guessing which the author meant. Measured in this repository at the ' + + 'ruling: three authored `\'Last 7 days\'`, all in spec tests, and no published dashboard ' + + 'authors the string arm at all (the shipped console lowers presets to the array arm). ' + + 'ADR-0049 / ADR-0112.', + acceptanceCriteria: + 'Grep every authored `timeDimensions[].dateRange` string — dashboard datasets, saved ' + + 'analytics queries, SDK / MCP callers, in-process `AnalyticsService.query` calls — and ' + + 'rewrite each bare string that is not one of the thirteen preset names: a relative ' + + 'phrase to its preset (`\'last_7_days\'`), an ISO day to the two-element array ' + + '`[day, day]`. `POST /analytics/query` now answers `400 ANALYTICS_DATE_RANGE_UNRECOGNIZED` ' + + 'naming the value and the vocabulary, so a sweep is mechanical; `AnalyticsQuerySchema.' + + 'safeParse` reports the same issue at `timeDimensions.N.dateRange`. Preset names and ' + + '`[start, end]` arrays parse byte-identically to before. A query that carried one of the ' + + 'retired spellings was never returning the window it named (all rows on driver-memory, ' + + 'one day on SQL), so re-check what the widget was supposed to show rather than trusting ' + + 'the old result set.', + }, { id: 'api-error-retry-after-unit-in-key', surface: 'EnhancedApiError.retryAfter (api/errors.zod.ts) — the ADR-0112 error envelope on the wire', From 60861def1d8537b69cc87c80c8e75984530ede6c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 12:20:37 +0000 Subject: [PATCH 3/7] chore(spec): regenerate api-surface, export-origins, declaration-map and reference docs for AnalyticsDateRangePreset (wip 3) Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01T6HeZvT9wdSJD1ZxJb5Eno --- content/docs/references/data/analytics.mdx | 2 +- packages/spec/api-surface/data.json | 1 + packages/spec/declaration-map/data.json | 1 + packages/spec/export-origins/data.json | 1 + 4 files changed, 4 insertions(+), 1 deletion(-) diff --git a/content/docs/references/data/analytics.mdx b/content/docs/references/data/analytics.mdx index d8b92d268f..51f8309bb3 100644 --- a/content/docs/references/data/analytics.mdx +++ b/content/docs/references/data/analytics.mdx @@ -21,7 +21,7 @@ This layer decouples the "Physical Data" (Tables/Columns) from the ```typescript import { AggregationMetricType, AnalyticsDateRangeSchema, AnalyticsDateRangePresetSchema, AnalyticsQuerySchema, CubeSchema, CubeJoinSchema, DimensionSchema, DimensionType, MetricSchema, TimeUpdateInterval } from '@objectstack/spec/data'; -import type { AggregationMetricType, AnalyticsDateRange, AnalyticsQuery, Cube, CubeJoin, Dimension, DimensionType, Metric, TimeUpdateInterval } from '@objectstack/spec/data'; +import type { AggregationMetricType, AnalyticsDateRange, AnalyticsDateRangePreset, AnalyticsQuery, Cube, CubeJoin, Dimension, DimensionType, Metric, TimeUpdateInterval } from '@objectstack/spec/data'; // Validate data const result = AggregationMetricType.parse(data); diff --git a/packages/spec/api-surface/data.json b/packages/spec/api-surface/data.json index 30283f75af..6d5b872370 100644 --- a/packages/spec/api-surface/data.json +++ b/packages/spec/api-surface/data.json @@ -31,6 +31,7 @@ "AggregationStage (type)", "AggregationStageSchema (const)", "AnalyticsDateRange (type)", + "AnalyticsDateRangePreset (type)", "AnalyticsDateRangePresetSchema (const)", "AnalyticsDateRangeSchema (const)", "AnalyticsQuery (type)", diff --git a/packages/spec/declaration-map/data.json b/packages/spec/declaration-map/data.json index 633f4d3aee..3f29c42592 100644 --- a/packages/spec/declaration-map/data.json +++ b/packages/spec/declaration-map/data.json @@ -15,6 +15,7 @@ "AggregationStage": "data/AggregationStage", "AggregationStageSchema": "data/AggregationStage", "AnalyticsDateRange": "data/AnalyticsDateRange", + "AnalyticsDateRangePreset": "data/AnalyticsDateRangePreset", "AnalyticsDateRangePresetSchema": "data/AnalyticsDateRangePreset", "AnalyticsDateRangeSchema": "data/AnalyticsDateRange", "AnalyticsQuery": "data/AnalyticsQuery", diff --git a/packages/spec/export-origins/data.json b/packages/spec/export-origins/data.json index 3ad0c0f5df..48f3e7aa4b 100644 --- a/packages/spec/export-origins/data.json +++ b/packages/spec/export-origins/data.json @@ -29,6 +29,7 @@ "AggregationStage": "src/data/driver-nosql.zod.ts#AggregationStage (type)", "AggregationStageSchema": "src/data/driver-nosql.zod.ts#AggregationStageSchema (const)", "AnalyticsDateRange": "src/data/analytics.zod.ts#AnalyticsDateRange (type)", + "AnalyticsDateRangePreset": "src/data/analytics.zod.ts#AnalyticsDateRangePreset (type)", "AnalyticsDateRangePresetSchema": "src/data/analytics.zod.ts#AnalyticsDateRangePresetSchema (const)", "AnalyticsDateRangeSchema": "src/data/analytics.zod.ts#AnalyticsDateRangeSchema (const)", "AnalyticsQuery": "src/data/analytics.zod.ts#AnalyticsQuery (type)", From bf059cf478662593d203f8561b33641db99de394 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 12:46:47 +0000 Subject: [PATCH 4/7] test(spec): pin the two isomorphic dateRange aliases (ADR-0122) and compile the schema example (wip 4) Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01T6HeZvT9wdSJD1ZxJb5Eno --- packages/spec/src/data/analytics.zod.ts | 11 +++++++--- .../src/type-alias-convention.pin.test.ts | 20 ++++++++++++++++--- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/packages/spec/src/data/analytics.zod.ts b/packages/spec/src/data/analytics.zod.ts index e573905afe..edd22d3221 100644 --- a/packages/spec/src/data/analytics.zod.ts +++ b/packages/spec/src/data/analytics.zod.ts @@ -306,10 +306,15 @@ export function analyticsDateRangeRefusalMessage(input: unknown): string { * an explicit `[start, end]` window. * * @example + * * ```ts - * { dimension: 'created_at', granularity: 'day', dateRange: 'last_7_days' } - * { dimension: 'created_at', granularity: 'month', dateRange: ['2023-01-01', '2023-01-31'] } - * { dimension: 'created_at', dateRange: ['{30_days_ago}', '{today}'] } + * import type { AnalyticsQuery } from '@objectstack/spec/data'; + * + * const timeDimensions: AnalyticsQuery['timeDimensions'] = [ + * { dimension: 'created_at', granularity: 'day', dateRange: 'last_7_days' }, + * { dimension: 'created_at', granularity: 'month', dateRange: ['2023-01-01', '2023-01-31'] }, + * { dimension: 'created_at', dateRange: ['{30_days_ago}', '{today}'] }, + * ]; * ``` * * A value that is neither raises ONE issue at the field's own path with the diff --git a/packages/spec/src/type-alias-convention.pin.test.ts b/packages/spec/src/type-alias-convention.pin.test.ts index 93aac301f9..9a6b63e569 100644 --- a/packages/spec/src/type-alias-convention.pin.test.ts +++ b/packages/spec/src/type-alias-convention.pin.test.ts @@ -275,7 +275,7 @@ import type * as M184 from './shared/value-domain.zod.js'; import type * as M185 from './shared/epoch.zod.js'; // --------------------------------------------------------------------------- -// 813 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. +// 815 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. // // That number is machine-checked, not hand-kept. The runtime companion at the // bottom of this file recomputes the pin count from the source and asserts that @@ -1463,6 +1463,13 @@ export type Iso770 = Assert, z.infer< export type Iso771 = Assert, z.infer< typeof M55.AggregationMetricType > >>; export type Iso772 = Assert, z.infer< typeof M55.DimensionType > >>; export type Iso773 = Assert, z.infer< typeof M55.TimeUpdateInterval > >>; +// [#16041] Added after the generated corpus (numbers continue from the file's +// end, see the #4395 note above). The closed `timeDimensions[].dateRange` +// vocabulary: a bare `z.enum` derived from `DATE_RANGE_PRESETS`, and the union +// of that enum with `z.array(z.string())` — no default, no transform on either +// arm, so author and parsed states coincide. +export type Iso869 = Assert, z.infer< typeof M55.AnalyticsDateRangePresetSchema > >>; +export type Iso870 = Assert, z.infer< typeof M55.AnalyticsDateRangeSchema > >>; // data/context-tokens.zod.ts export type Iso774 = Assert, z.infer< typeof M176.ContextTokenPlaceholderSchema > >>; @@ -1679,7 +1686,7 @@ describe('ADR-0122 type-alias convention', () => { // this title and the section header above the pin list — are now asserted // against the recomputed count below, so neither can go stale without a red // test naming it. - it('still declares all 813 isomorphic pins', () => { + it('still declares all 815 isomorphic pins', () => { // The truth of each pin is proved by tsc, not here — an `Assert>` // that stops holding is a compile error with the alias named. What tsc // cannot notice is a pin that was DELETED: removing the assertion removes @@ -2158,7 +2165,14 @@ describe('ADR-0122 type-alias convention', () => { // pin (`Iso868`). The two movements are disjoint — the retirement drops // pins those three modules declared, this adds one no module had — so // the merged count is 825 - 13 + 1. - expect(pins).toHaveLength(813); + // + // 813 -> 815 is #16041's closed `timeDimensions[].dateRange` vocabulary + // (data/analytics.zod.ts, module slot M55): `AnalyticsDateRangePresetSchema` + // (a bare `z.enum` derived from `DATE_RANGE_PRESETS`) and + // `AnalyticsDateRangeSchema` (its union with `z.array(z.string())`) — no + // default, no transform on either arm, two new pins (`Iso869` / `Iso870`). + // +2 added. + expect(pins).toHaveLength(815); // The count is stated in PROSE twice as well — this case's title and the // section header above the pin list — and until #6605 nothing read either From 396e627911894a07e398a0ef91bfe74e0ddf1260 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 13:06:46 +0000 Subject: [PATCH 5/7] test(service-analytics): re-spell the single-day dateRange fixture in the array form The closed string vocabulary for timeDimensions[].dateRange retires the bare-date spelling this fixture authored, so the package's required TypeScript Type Check went red on one line (TS2322 at line 187). The fixture now writes the same single-day window as ['2026-01-20', '2026-01-20']; the strategy lowers both spellings to the identical $gte/$lte bounds, so the expected values are untouched. The test's title still names the retired dialect on purpose: a comment above it records that retiring the test belongs to the driver-alignment card, together with the strategy's degeneration. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01T6HeZvT9wdSJD1ZxJb5Eno --- .../src/__tests__/objectql-daterange.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/services/service-analytics/src/__tests__/objectql-daterange.test.ts b/packages/services/service-analytics/src/__tests__/objectql-daterange.test.ts index a8b2b8b77a..ba2d720b1c 100644 --- a/packages/services/service-analytics/src/__tests__/objectql-daterange.test.ts +++ b/packages/services/service-analytics/src/__tests__/objectql-daterange.test.ts @@ -177,6 +177,9 @@ describe('ObjectQLStrategy — timeDimensions[].dateRange (#3650)', () => { ]); }); + // The title names the bare-string dialect that #16041 closed at the schema; the + // fixture now spells the same single-day window in the array form. Retiring + // this test, together with the strategy's degeneration, belongs to #16322. it('degenerates a bare-string dateRange to a single point, like NativeSQLStrategy', async () => { const seen: AggOpts[] = []; const result = await makeService(seen).query( @@ -184,7 +187,7 @@ describe('ObjectQLStrategy — timeDimensions[].dateRange (#3650)', () => { cube: 'sales', dimensions: ['stage'], measures: ['revenue'], - timeDimensions: [{ dimension: 'close_date', dateRange: '2026-01-20' }], + timeDimensions: [{ dimension: 'close_date', dateRange: ['2026-01-20', '2026-01-20'] }], }, ctx, ); From d8a3fd2d08f6d24503dd78c1a55d93b76d187dfe Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 13:55:00 +0000 Subject: [PATCH 6/7] chore(spec): regenerate content/docs/references/index.mdx on the merged tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regeneration commit for the forward merge of origin/main (554a16037e) into this branch, kept apart from the merge commit so the two can be read separately. `gen:schema` + `gen:docs` on the merged tree changed exactly one file: the references index, whose counts are now the union of both sides — main's ResumeFailureDetails (API 436 -> 437) plus this branch's AnalyticsDateRange / AnalyticsDateRangePreset (Data 166 -> 168), total 1575 -> 1577. No other generated artifact moved. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01T6HeZvT9wdSJD1ZxJb5Eno --- content/docs/references/index.mdx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index 880d9e904c..181a484f2e 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,6 +1,6 @@ --- title: Protocol Reference -description: Every schema published by @objectstack/spec — 1576 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1577 schemas across 14 protocol modules --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -20,7 +20,7 @@ counts are sums of the rows they head. Regenerate with | Module | Pages | Schemas | Description | | :--- | ---: | ---: | :--- | | [AI Protocol](/docs/references/ai) | 11 | 66 | Agents, tools, skills, RAG and knowledge sources, model registry, conversations. | -| [API Protocol](/docs/references/api) | 31 | 436 | REST contracts, endpoints, routing, realtime, batch, discovery. | +| [API Protocol](/docs/references/api) | 31 | 437 | REST contracts, endpoints, routing, realtime, batch, discovery. | | [Automation Protocol](/docs/references/automation) | 13 | 73 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. | | [Cloud Protocol](/docs/references/cloud) | 11 | 94 | Environments, packages and versions, marketplace, developer portal, tenancy. | | [Data Protocol](/docs/references/data) | 29 | 168 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. | @@ -33,7 +33,7 @@ counts are sums of the rows they head. Regenerate with | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | | [System Protocol](/docs/references/system) | 33 | 272 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | | [UI Protocol](/docs/references/ui) | 16 | 153 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **198** | **1576** | 14 protocol modules | +| **Total** | **198** | **1577** | 14 protocol modules | --- @@ -61,7 +61,7 @@ Agents, tools, skills, RAG and knowledge sources, model registry, conversations. ## API Protocol -**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **31 pages, 436 schemas** +**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **31 pages, 437 schemas** REST contracts, endpoints, routing, realtime, batch, discovery. @@ -70,7 +70,7 @@ REST contracts, endpoints, routing, realtime, batch, discovery. | [`analytics.zod.ts`](/docs/references/api/analytics) | `AnalyticsEndpoint`, `AnalyticsMetadataResponse`, `AnalyticsQueryRequest`, `AnalyticsResultResponse`, `AnalyticsSqlResponse`, `GetAnalyticsMetaRequest` | | [`auth.zod.ts`](/docs/references/api/auth) | `AuthProvider`, `LoginRequest`, `LoginType`, `RefreshTokenRequest`, `RegisterRequest`, `Session`, `SessionResponse`, `SessionUser`, `UserProfileResponse` | | [`auth-endpoints.zod.ts`](/docs/references/api/auth-endpoints) | `AuthEndpoint`, `AuthFeaturesConfig`, `AuthProviderInfo`, `DeviceRequestResponse`, `DeviceTokenResponse`, `EmailPasswordConfigPublic`, `GetAuthConfigResponse` | -| [`automation-api.zod.ts`](/docs/references/api/automation-api) | `AutomationApiErrorCode`, `AutomationFlowPathParams`, `AutomationRunPathParams`, `CreateFlowRequest`, `CreateFlowResponse`, `DeleteFlowRequest`, `DeleteFlowResponse`, `FlowSummary`, `GetFlowRequest`, `GetFlowResponse`, `GetRunRequest`, `GetRunResponse`, `ListFlowsRequest`, `ListFlowsResponse`, `ListRunsRequest`, `ListRunsResponse`, `ToggleFlowRequest`, `ToggleFlowResponse`, `TriggerFlowRequest`, `TriggerFlowResponse`, `UpdateFlowRequest`, `UpdateFlowResponse` | +| [`automation-api.zod.ts`](/docs/references/api/automation-api) | `AutomationApiErrorCode`, `AutomationFlowPathParams`, `AutomationRunPathParams`, `CreateFlowRequest`, `CreateFlowResponse`, `DeleteFlowRequest`, `DeleteFlowResponse`, `FlowSummary`, `GetFlowRequest`, `GetFlowResponse`, `GetRunRequest`, `GetRunResponse`, `ListFlowsRequest`, `ListFlowsResponse`, `ListRunsRequest`, `ListRunsResponse`, `ResumeFailureDetails`, `ToggleFlowRequest`, `ToggleFlowResponse`, `TriggerFlowRequest`, `TriggerFlowResponse`, `UpdateFlowRequest`, `UpdateFlowResponse` | | [`batch.zod.ts`](/docs/references/api/batch) | `BatchConfig`, `BatchOperationResult`, `BatchOperationType`, `BatchOptions`, `BatchRecord`, `BatchUpdateRequest`, `BatchUpdateResponse`, `CrossObjectBatchDroppedFields`, `CrossObjectBatchOperation`, `CrossObjectBatchRequest`, `CrossObjectBatchResponse`, `DeleteManyRequest`, `UpdateManyRecord`, `UpdateManyRequest` | | [`contract.zod.ts`](/docs/references/api/contract) | `ApiError`, `BaseResponse`, `BatchLoadingStrategy`, `BulkRequest`, `BulkResponse`, `CreateRequest`, `DataLoaderConfig`, `DeleteResponse`, `ExportRequest`, `IdRequest`, `ListRecordResponse`, `ModificationResult`, `QueryOptimizationConfig`, `RecordData`, `SingleRecordResponse`, `UpdateRequest` | | [`discovery.zod.ts`](/docs/references/api/discovery) | `ApiRoutes`, `CapabilityDescriptor`, `Discovery`, `DiscoveryEnvironment`, `RouteHealthEntry`, `RouteHealthReport`, `ServiceInfo`, `ServiceSelfInfo`, `ServiceStatus`, `WellKnownCapabilities` | From 080052ce832fbe175b6fdf74bcd7fde22d8ac6cc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 14:51:37 +0000 Subject: [PATCH 7/7] test(driver-memory): retire the 15 date-range pins whose input the closed dateRange contract no longer admits Not a test disabled to get green. #16041 closed the string arm of timeDimensions[].dateRange to the date-range-presets.ts vocabulary (maintainer ruling, decision batch #57), and this retirement is the maintainer's option-B decision of 2026-09-07, chosen over routing the fixtures around the schema door because that would have kept a live pin on the silent-widening fallback #16041 exists to abolish. The three memory-analytics-date-range-{dst,timezone,utc-window} files fed the retired relative dialect ('last 3 days', 'last 7 days', 'last 1 week', 'last 2 weeks', 'last 1 month', 'last 3 months', 'last 1 year') and one garbage string through asQuery = AnalyticsQuerySchema.parse, so on this branch they read 15 failed | 37 passed (52) plus three TS2322, on input production can no longer deliver through any door. Re-spelling was measured, not assumed: driving the built dist directly, only 'today' produces a window; every other preset (last_7_days, last_week, ...) falls to parseDateRangeString's [range, range] fallback and matches every row, because the parser keys on startsWith('last '). So 0 of the 15 cases are expressible until #16322 aligns the parser. Form: it.todo per retired case, original title kept and suffixed with the reason; a site comment at each naming #16041 and #16322 and stating the coverage lost; the two surviving helpers' `range` parameter retyped from string to AnalyticsDateRange (the closed contract), which is what clears the TS2322. No surviving assertion changed. The dst file's driver harness (CUBE / asQuery / probesSelected / probesFor) had no surviving caller and is removed; its cell table and all eight controls survive. COVERAGE LOST until #16322 reinstates it in preset form: - dst: all 13 driver cells, i.e. the ENTIRE `last N UNIT` arithmetic-leg DST coverage in driver-memory goes to zero: spring-forward and fall-back, the day / week / month / year legs, both hemispheres, the two non-whole-hour zones (St_Johns -03:30, Chatham +12:45). - timezone: the one `last N` case anchoring on the SUPPLIED zone's calendar day and midnight (Asia/Shanghai, 'last 7 days'). - utc-window: the fallback fence on 'not a range at all'; its zone-independence was a property of an answer that matched every row, and #16322 owes the driver-side refusal pin in its place. What survives: the 'today' leg's DST and zone coverage, i.e. the 7 timezone cells including the 23-hour America/New_York spring-forward day and the 11 utc-window cells, plus every control on the dst cell table. Reinstatement note for #16322: last_7_days / last_30_days / last_90_days are rolling day-leg windows; last_week / last_month / last_quarter / last_year are calendar windows, not n units back, so the week / month / year cells need re-measured transition instants under preset semantics. Measured: driver-memory typecheck exit 2 (three TS2322) before, exit 0 after; the three files 15 failed | 37 passed before, 37 passed | 15 todo after, also under TZ=America/New_York; full package 1095 passed | 15 todo (1110); spec and runtime pins unmoved (235/235, 37/37); dist byte-identical under a forced rebuild, so the 16 dependents cannot observe a test-only diff. The same 15 failures are what reddened Temporal Conformance on d8a3fd2d08 (its non-SQL leg runs driver-memory under TZ=America/New_York); the InnoDB line in that job's tail is the MySQL container's echo of a passing negative test and appears in main's green run too. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01T6HeZvT9wdSJD1ZxJb5Eno --- .../memory-analytics-date-range-dst.test.ts | 127 ++++++------------ ...mory-analytics-date-range-timezone.test.ts | 49 +++---- ...ry-analytics-date-range-utc-window.test.ts | 47 +++---- 3 files changed, 79 insertions(+), 144 deletions(-) diff --git a/packages/drivers/driver-memory/src/memory-analytics-date-range-dst.test.ts b/packages/drivers/driver-memory/src/memory-analytics-date-range-dst.test.ts index dde996638d..2bd736a983 100644 --- a/packages/drivers/driver-memory/src/memory-analytics-date-range-dst.test.ts +++ b/packages/drivers/driver-memory/src/memory-analytics-date-range-dst.test.ts @@ -49,14 +49,43 @@ * with the one-calendar answer. Without it a green run would be ambiguous * between "the fix works" and "these instants are not actually in a transition * window" — the second being the failure mode that hid this bug for so long. + * + * ## ⛔ The driver cells are RETIRED — #16041 closed the dialect, #16322 reinstates + * + * Every driver-facing cell below fed the relative dialect (`'last 3 days'`, + * `'last 7 days'`, `'last 1 week'`, `'last 2 weeks'`, `'last 1 month'`, + * `'last 3 months'`, `'last 1 year'`) through `AnalyticsQuerySchema.parse`. + * #16041 (maintainer ruling, decision batch #57) closed the string arm of + * `timeDimensions[].dateRange` to the `date-range-presets.ts` vocabulary, so + * that input is refused at the schema door and no longer reaches + * `parseDateRangeString` through any door production has. Re-spelling was + * MEASURED, not assumed: the parser matches `range.startsWith('last ')`, so + * every snake_case preset (`last_7_days`, `last_week`, …) takes the + * `[range, range]` fallback and matches EVERY row — not one of the 13 cells + * can be expressed in the closed vocabulary until #16322 aligns the parser. + * The cells are `it.todo` (retirement was chosen over routing the fixture + * around the schema door, which would have kept a live pin on the + * silent-widening fallback #16041 exists to abolish). + * + * ⚠️ COVERAGE LOST until #16322 reinstates it in preset form: the driver is + * no longer measured on the `last N …` ARITHMETIC leg across a DST + * transition — spring-forward and fall-back, all four legs (day / week / + * month / year), both hemispheres, the two non-whole-hour zones. (The + * `'today'` leg across the 23-hour spring-forward day stays covered by + * `memory-analytics-date-range-timezone.test.ts`.) What survives here is the + * CELL TABLE and its controls — every cell still flips, both directions, all + * four legs, the TZ=UTC indistinguishability fence — so the reinstatement + * starts from a verified table. Note for #16322: `last_7_days` / + * `last_30_days` / `last_90_days` are the rolling day-leg presets, while + * `last_week` / `last_month` / `last_quarter` / `last_year` are CALENDAR + * windows, not `n` units back, so the week / month / year cells need + * re-measured instants under the preset semantics. The retired harness + * (`probesSelected` over `MemoryAnalyticsService.query`) is in history at + * 5f4f1f6e22 / 1cf7392728. */ import { describe, it, expect } from 'vitest'; import { vi } from 'vitest'; -import { InMemoryDriver } from './memory-driver.js'; -import { MemoryAnalyticsService } from './memory-analytics.js'; -import { AnalyticsQuerySchema } from '@objectstack/spec/data'; -import type { AnalyticsQuery, Cube } from '@objectstack/spec/data'; const REAL_TZ = process.env.TZ; @@ -105,49 +134,8 @@ function utcArithmeticStart(unit: Unit, num: number): string { return s.toISOString(); } -const CUBE: Cube = { - name: 'events', - title: 'Events', - sql: 'events', - measures: { - count: { name: 'count', label: 'Count', type: 'count', sql: 'id' }, - }, - dimensions: { - probe: { name: 'probe', label: 'Probe', type: 'string', sql: 'probe' }, - createdAt: { - name: 'created_at', - label: 'Created At', - type: 'time', - sql: 'created_at', - granularities: ['day'], - }, - }, - public: true, -}; - -const asQuery = (input: AnalyticsQuery): AnalyticsQuery => AnalyticsQuerySchema.parse(input); - -/** Ask `range` over rows planted at `instants`; answer which probes came back. */ -async function probesSelected(instants: string[], range: string): Promise { - const driver = new InMemoryDriver({ - initialData: { - events: instants.map((iso, i) => ({ - id: i + 1, - probe: iso, - created_at: new Date(iso), - })), - }, - }); - await driver.connect(); - const service = new MemoryAnalyticsService({ driver, cubes: [CUBE] }); - const result = await service.query(asQuery({ - cube: 'events', - measures: ['events.count'], - dimensions: ['events.probe'], - timeDimensions: [{ dimension: 'events.createdAt', dateRange: range }], - })); - return result.rows.map((row) => String(row['events.probe'])).sort(); -} +// The driver harness (`CUBE`, `asQuery`, `probesSelected`) left with the +// retired cells — see the header; #16322 brings it back with the preset form. interface Cell { zone: string; @@ -186,48 +174,13 @@ const DST_CELLS: Cell[] = [ const label = (c: Cell) => `${c.zone} @ ${c.instant} '${c.range}'`; -/** Probe instants straddling BOTH candidate boundaries, computed in-zone. */ -function probesFor(c: Cell): string[] { - const truth = Date.parse(utcArithmeticStart(c.unit, c.num)); - const mixed = Date.parse(localArithmeticStart(c.unit, c.num)); - return [...new Set([ - new Date(truth).toISOString(), - new Date(truth - 1).toISOString(), - new Date(mixed).toISOString(), - new Date(mixed - 1).toISOString(), - new Date(truth + 43_200_000).toISOString(), // comfortably inside, both ways - ])].sort(); -} - describe('#15825 defect 2 — the `last N ...` legs resolve on one calendar, across DST transitions', () => { for (const c of DST_CELLS) { - it(`${c.kind}: ${label(c)}`, async () => { - const { truth, mixed, probes } = await at(c.zone, c.instant, async () => ({ - truth: utcArithmeticStart(c.unit, c.num), - mixed: localArithmeticStart(c.unit, c.num), - probes: probesFor(c), - })); - - // CONTROL FIRST — if these agree, the cell is not in a transition - // window and every assertion below would be vacuous. (It is the - // whole reason a TZ=UTC-only test is worthless here.) - expect( - mixed, - `${label(c)}: the local-arithmetic spelling must DISAGREE here, otherwise this cell pins nothing`, - ).not.toBe(truth); - - const inZone = await at(c.zone, c.instant, () => probesSelected(probes, c.range)); - const atUtc = await at('UTC', c.instant, () => probesSelected(probes, c.range)); - - // THE ORACLE: at TZ=UTC the two spellings coincide, so this run is - // the reference answer. The process timezone must not move it. - expect(inZone, `${label(c)}: the process timezone changed which rows were counted`).toEqual(atUtc); - - // And the answer must actually be non-trivial — a window that - // selected everything or nothing would compare equal for free. - expect(inZone.length, `${label(c)}: probes must straddle the boundary`).toBeGreaterThan(0); - expect(inZone.length, `${label(c)}: probes must straddle the boundary`).toBeLessThan(probes.length); - }); + // ⛔ RETIRED (#16041 → #16322): `c.range` is the relative dialect the + // closed vocabulary refuses at the schema door; no preset expresses it + // until #16322 aligns the parser (measured — see the header, which also + // states exactly what is uncovered until then). + it.todo(`${c.kind}: ${label(c)} — retired by #16041 (dialect closed at the schema), reinstate under #16322 in preset form`); } it('the day and week legs also match the offset-free definition — n x 24h before the UTC day', async () => { diff --git a/packages/drivers/driver-memory/src/memory-analytics-date-range-timezone.test.ts b/packages/drivers/driver-memory/src/memory-analytics-date-range-timezone.test.ts index baa70f96e0..3c11d14129 100644 --- a/packages/drivers/driver-memory/src/memory-analytics-date-range-timezone.test.ts +++ b/packages/drivers/driver-memory/src/memory-analytics-date-range-timezone.test.ts @@ -55,7 +55,7 @@ import { describe, it, expect, vi } from 'vitest'; import { InMemoryDriver } from './memory-driver.js'; import { MemoryAnalyticsService } from './memory-analytics.js'; import { AnalyticsQuerySchema } from '@objectstack/spec/data'; -import type { AnalyticsQuery, Cube } from '@objectstack/spec/data'; +import type { AnalyticsDateRange, AnalyticsQuery, Cube } from '@objectstack/spec/data'; const REAL_TZ = process.env.TZ; @@ -98,7 +98,8 @@ const asQuery = (input: AnalyticsQuery): AnalyticsQuery => AnalyticsQuerySchema. /** Ask `range` over rows planted at `instants`; answer which probes came back. */ async function probesSelected( instants: string[], - opts: { range?: string; timezone?: string } = {}, + // `range` is the CLOSED contract (#16041): a preset name or an explicit window. + opts: { range?: AnalyticsDateRange; timezone?: string } = {}, ): Promise { const driver = new InMemoryDriver({ initialData: { @@ -304,32 +305,20 @@ describe('#16042 — the resolution is host-independent and degrades to UTC', () }); describe("#16042 — `last N …` anchors on the zone's calendar too", () => { - const c = CELLS[0]; // Asia/Shanghai: local day 2026-09-07, UTC day 2026-09-06 - - it("'last 7 days' starts 7 days before the ZONE's day, at the zone's midnight", async () => { - // 7 days before Shanghai's 2026-09-07 is 2026-08-31; that day begins at - // 2026-08-30T16:00:00.000Z. Computed independently: Shanghai is +08:00 - // year-round, so its midnight is the previous day's 16:00Z. - const tzStart = '2026-08-30T16:00:00.000Z'; - const utcStart = '2026-08-30T00:00:00.000Z'; // 7 days before UTC's 2026-09-06 - expect(tzStart).not.toBe(utcStart); - - // The upper bound of a `last N` window is the current INSTANT, so a - // probe must sit before it; both probes do. - const probes = [utcStart, tzStart, iso(ms(tzStart) - 1)]; - - await at('Asia/Tokyo', c.instant, async () => { - // AFTER — the window opens at Shanghai's midnight, so the probe one - // millisecond earlier is OUT. - await expect(probesSelected(probes, { range: 'last 7 days', timezone: c.zone })).resolves.toEqual( - [tzStart].sort(), - ); - // BEFORE — with no timezone the window opens 16 hours earlier, at - // UTC midnight, and takes all three probes. That extra row IS the - // defect, in the `last N` leg. - await expect(probesSelected(probes, { range: 'last 7 days' })).resolves.toEqual( - [...probes].sort(), - ); - }); - }); + // ⛔ RETIRED (#16041 → #16322). This case fed `dateRange: 'last 7 days'` + // through `AnalyticsQuerySchema.parse`; #16041 (maintainer ruling, decision + // batch #57) closed the string arm to the `date-range-presets.ts` + // vocabulary, so that dialect is refused at the schema door. Re-spelling + // was MEASURED, not assumed: `parseDateRangeString` matches + // `startsWith('last ')`, so the preset `'last_7_days'` takes the + // `[range, range]` fallback and matches every row — the case cannot be + // expressed until #16322 aligns the parser. + // + // COVERAGE LOST until #16322 reinstates it as `'last_7_days'`: the driver + // is no longer measured on the `last N …` leg anchoring to the SUPPLIED + // zone's calendar day and midnight — Asia/Shanghai's window opening at + // 2026-08-30T16:00:00.000Z rather than UTC's 2026-08-30T00:00:00.000Z, with + // the no-timezone control taking the extra 16 hours of rows. The `'today'` + // leg's zone anchoring — both halves — stays covered by the cells above. + it.todo("'last 7 days' starts 7 days before the ZONE's day, at the zone's midnight — retired by #16041 (dialect closed at the schema), reinstate under #16322 as 'last_7_days'"); }); diff --git a/packages/drivers/driver-memory/src/memory-analytics-date-range-utc-window.test.ts b/packages/drivers/driver-memory/src/memory-analytics-date-range-utc-window.test.ts index ab19b43c84..50ab926875 100644 --- a/packages/drivers/driver-memory/src/memory-analytics-date-range-utc-window.test.ts +++ b/packages/drivers/driver-memory/src/memory-analytics-date-range-utc-window.test.ts @@ -45,7 +45,7 @@ import { vi } from 'vitest'; import { InMemoryDriver } from './memory-driver.js'; import { MemoryAnalyticsService } from './memory-analytics.js'; import { AnalyticsQuerySchema } from '@objectstack/spec/data'; -import type { AnalyticsQuery, Cube } from '@objectstack/spec/data'; +import type { AnalyticsDateRange, AnalyticsQuery, Cube } from '@objectstack/spec/data'; const REAL_TZ = process.env.TZ; @@ -101,7 +101,8 @@ const CUBE: Cube = { const asQuery = (input: AnalyticsQuery): AnalyticsQuery => AnalyticsQuerySchema.parse(input); /** Ask `range` over rows planted at `instants`; answer which probes came back. */ -async function probesSelected(instants: string[], range = 'today'): Promise { +// `range` is the CLOSED contract (#16041): a preset name or an explicit window. +async function probesSelected(instants: string[], range: AnalyticsDateRange = 'today'): Promise { const driver = new InMemoryDriver({ initialData: { events: instants.map((iso, i) => ({ @@ -256,31 +257,23 @@ describe('#15825 defect 1 fences', () => { }); }); - it('the unrecognised-range fallback carries no calendar — same answer in every zone', async () => { - // This repair touched only the two legs that BUILD a window. The - // `return [range, range]` fallback is untouched, and this fence holds - // it that way: its answer must not depend on the process timezone. - // - // ⚠️ It is deliberately NOT asserted to be a sensible answer. Measured - // 2026-09-05: an unparseable `dateRange` reaches mingo as - // `{$gte: '', $lte: ''}` and, under BSON cross-type - // ordering, matches EVERY `Date`-typed row — so the time filter is - // silently dropped rather than refused. That is a different defect - // class from this card's (vocabulary, not calendar) and is filed - // separately; ⛔ it is not repaired here. - const probes = [ - '2020-01-01T00:00:00.000Z', - '2026-09-05T06:00:00.000Z', - '2099-01-01T00:00:00.000Z', - ]; - const answers: string[] = []; - for (const zone of ['UTC', 'Asia/Shanghai', 'America/Los_Angeles', 'Pacific/Chatham']) { - await at(zone, '2026-09-05T12:00:00Z', async () => { - answers.push(JSON.stringify(await probesSelected(probes, 'not a range at all'))); - }); - } - expect(new Set(answers).size, `the fallback answered differently per zone: ${answers.join(' | ')}`).toBe(1); - }); + // ⛔ RETIRED (#16041 → #16322). This fence fed `dateRange: 'not a range at + // all'` through `AnalyticsQuerySchema.parse` to pin that the `[range, range]` + // fallback's answer did not depend on the process zone. #16041 (maintainer + // ruling, decision batch #57) closed the string arm to the + // `date-range-presets.ts` vocabulary, so an unrecognised string is refused + // at the schema door (`400 ANALYTICS_DATE_RANGE_UNRECOGNIZED`, pinned in + // `packages/spec/src/data/analytics-date-range-closed-vocabulary.test.ts` + // and `packages/runtime/src/analytics-daterange-refusal-envelope.test.ts`) + // and never reaches the parser through any door. Retired rather than routed + // around the door: that would have kept a live pin on the silent-widening + // fallback this card exists to abolish. + // + // COVERAGE LOST until #16322: nothing a caller can reach — the fallback's + // zone-independence was a property of an answer that matched EVERY row. + // #16322 deletes the fallback and owes the DRIVER-side refusal pin in its + // place (memory and SQL refusing identically, one conformance fixture). + it.todo('the unrecognised-range fallback carries no calendar — same answer in every zone — retired by #16041 (input refused at the schema); #16322 replaces it with the driver-side refusal pin'); it('the process timezone is restored after every case', () => { expect(process.env.TZ).toBe(REAL_TZ);