-
Notifications
You must be signed in to change notification settings - Fork 10
Expand file tree
/
Copy pathanalytics.zod.ts
More file actions
494 lines (459 loc) · 22.3 KB
/
Copy pathanalytics.zod.ts
File metadata and controls
494 lines (459 loc) · 22.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
import { z } from 'zod';
import { FilterConditionSchema } from './filter.zod';
import { DATE_RANGE_PRESETS } from './date-range-presets';
/**
* Analytics/Semantic Layer Protocol
*
* Defines the "Business Logic" for data analysis.
* Inspired by Cube.dev, LookML, and dbt MetricFlow.
*
* This layer decouples the "Physical Data" (Tables/Columns) from the
* "Business Data" (Metrics/Dimensions).
*/
/**
* Aggregation Metric Type
* The mathematical operation to perform on a metric.
*/
import { lazySchema } from '../shared/lazy-schema';
import { strictObject } from '../shared/strict-object';
import { MetadataProtectionFields } from '../kernel/metadata-protection.zod';
export const AggregationMetricType = z.enum([
'count',
'sum',
'avg',
'min',
'max',
'count_distinct',
'number', // Custom SQL expression returning a number
'string', // Custom SQL expression returning a string
'boolean' // Custom SQL expression returning a boolean
]);
export type AggregationMetricType = z.input<typeof AggregationMetricType>;
/**
* Dimension Type
* The nature of the grouping field.
*/
export const DimensionType = z.enum([
'string',
'number',
'boolean',
'time',
'geo'
]);
export type DimensionType = z.input<typeof DimensionType>;
/**
* Time Interval for Time Dimensions
*/
export const TimeUpdateInterval = z.enum([
'second', 'minute', 'hour', 'day', 'week', 'month', 'quarter', 'year'
]);
export type TimeUpdateInterval = z.input<typeof TimeUpdateInterval>;
/**
* Metric Schema
* A quantitative measurement (e.g., "Total Revenue", "Average Order Value").
*
* Strict as of #4001 batch D: the cube family is a real authoring surface —
* `defineCube()` parses an author literal and `defineStack({ analyticsCubes })`
* carries every cube through `StackSchema.parse` (BFS from the 26 metadata-type
* roots + `ObjectStackSchema` resolves the whole family reachable, with
* `ObjectSchema` as positive control and a fresh uncarried shape as negative
* control in the same run).
*/
export const MetricSchema = lazySchema(() => strictObject(
{
surface: 'this metric',
history: 'Until this shape was closed, an undeclared metric key was silently dropped — the cube '
+ 'registered and the metric computed as if the key had never been written.',
// `title` is CORRECT one level up (`CubeSchema.title`); a metric spells it `label`.
aliases: { title: 'label' },
guidance: {
// REMOVED (#10414, ADR-0049 enforce-or-remove): `filters` was a declared
// per-metric raw-SQL filter (`filters: [{ sql }]`) with ZERO consumers —
// both SQL strategies aggregate `sql` and never read it, so a
// hand-authored condition parsed, registered, and silently returned the
// UNFILTERED aggregate under the author's metric name (the #10298 shape,
// one level up). What actually filters: the query's `where`, the
// condition folded into the metric's own `sql` expression, or an
// ADR-0021 dataset measure's structured `filter` (#10411). The nested
// `strictObject` the key carried (closed by #4001 batch D) is gone with
// it — strictness on a shape nothing reads was fake compliance either way.
filters:
'`measures.<metric>.filters` was removed in @objectstack/spec 17 (ADR-0049) — '
+ 'it never had an effect: no strategy read it (NativeSQLStrategy and ObjectQLStrategy '
+ 'both aggregate the metric\'s `sql` and ignore `filters`), so an authored '
+ '`filters: [{ sql: … }]` parsed clean and the query returned the UNFILTERED aggregate. '
+ 'Delete the key. To filter what a metric measures: filter at query time with `where` '
+ '(canonical Query DSL FilterCondition), fold the condition into the metric\'s own `sql` '
+ 'expression, or use an ADR-0021 dataset measure\'s structured `filter`. '
+ 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.',
},
},
{
name: z.string().regex(/^[a-z_][a-z0-9_]*$/).describe('Unique metric ID'),
label: z.string().describe('Human readable label'),
description: z.string().optional(),
type: AggregationMetricType,
/** Source Calculation */
sql: z.string().describe('SQL expression or field reference'),
// `filters` was REMOVED here (#10414) — see the `guidance` entry above for
// the full story and the replacement channels. The raw-SQL fragment shape
// (`[{ sql: string }]`) also ran against the platform's structured
// `FilterCondition` direction: a raw fragment cannot be parameterized,
// re-targeted per driver dialect, or walked by the lint rules
// (`packages/lint/src/filter-walk.ts` deliberately never enumerated it).
/** Format for display (e.g. "currency", "percent") */
format: z.string().optional(),
},
));
/**
* Dimension Schema
* A categorical attribute to group by (e.g., "Product Category", "Order Date").
*
* Strict as of #4001 batch D — same doors as {@link MetricSchema}.
*/
export const DimensionSchema = lazySchema(() => strictObject(
{
surface: 'this dimension',
history: 'Until this shape was closed, an undeclared dimension key was silently dropped.',
aliases: {
// `title` is CORRECT one level up (`CubeSchema.title`); a dimension spells it `label`.
title: 'label',
// The singular names ONE granularity; this key declares the SUPPORTED list.
granularity: 'granularities',
},
},
{
name: z.string().regex(/^[a-z_][a-z0-9_]*$/).describe('Unique dimension ID'),
label: z.string().describe('Human readable label'),
description: z.string().optional(),
type: DimensionType,
/** Source Column */
sql: z.string().describe('SQL expression or column reference'),
/** For Time Dimensions: Supported Granularities */
granularities: z.array(TimeUpdateInterval).optional(),
},
));
/**
* Join Schema
* Defines how this cube relates to others.
*
* Strict as of #4001 batch D — same doors as {@link MetricSchema}. Before the
* close, a join authored with `relationshipp:` (or any near-miss) parsed clean
* and fell back to the `many_to_one` default — a different join shape than the
* author declared, under a successful parse.
*/
export const CubeJoinSchema = lazySchema(() => strictObject(
{
surface: 'this cube join',
history: 'Until this shape was closed, an undeclared join key was silently dropped — a typo\'d '
+ '`relationship` fell back to the `many_to_one` default.',
// The join condition is spelled `sql` here (its doc says "ON clause").
aliases: { on: 'sql' },
},
{
name: z.string().describe('Target cube name'),
relationship: z.enum(['one_to_one', 'one_to_many', 'many_to_one']).default('many_to_one'),
sql: z.string().describe('Join condition (ON clause)'),
},
));
/**
* Cube Schema
* A logical data model representing a business entity or process for analysis.
* Maps physical tables to business metrics and dimensions.
*
* Strict as of #4001 batch D. Doors, measured: `defineCube()` (the factory the
* showcase example authors through) and `defineStack({ analyticsCubes })` /
* artifact ingest, both of which parse `StackSchema` → `analyticsCubes[]`.
*
* [#10194] This docblock used to say the ADR-0010 protection envelope is
* deliberately NOT declared here, on the premise that `analytics_cube`
* resolves no `getMetadataTypeSchema` entry (so `saveMetaItem` never 422s
* it). #10194 retired that premise: `analytics_cube` is now bound in
* `UNREGISTERED_KIND_SCHEMAS`, so `PUT /meta/analytics_cube/:name` parses a
* body through THIS schema — and the `getMetaItemLayered` → `saveMetaItem`
* round-trip carries the `applyProtection` stamp. The shape is `.strict()`,
* so without the envelope spread below the new 422 would fire at the
* runtime's own stamp instead of at malformed author input. The other two
* observations stand: `CubeRegistry.register` takes typed objects without a
* parse, and artifact ingest parses the compiled definition BEFORE
* `applyProtection` stamps `_packageId`/`_provenance` at registration.
*/
export const CubeSchema = lazySchema(() => strictObject(
{
surface: 'this cube',
history: 'Until this shape was closed, an undeclared cube key was silently dropped — the cube '
+ 'registered without it and the analytics service served whatever remained.',
aliases: {
// `label` is the metric/dimension spelling; the cube itself uses `title`.
label: 'title',
// `sql` doubles as the base table name ("Base SQL statement or Table Name").
table: 'sql',
sqlTable: 'sql',
},
},
{
name: z.string().regex(/^[a-z_][a-z0-9_]*$/).describe('Cube name (snake_case)'),
title: z.string().optional(),
description: z.string().optional(),
/** Physical Data Source */
sql: z.string().describe('Base SQL statement or Table Name'),
/** Semantic Definitions */
measures: z.record(z.string(), MetricSchema).describe('Quantitative metrics'),
dimensions: z.record(z.string(), DimensionSchema).describe('Qualitative attributes'),
/** Relationships */
joins: z.record(z.string(), CubeJoinSchema).optional(),
/** Pre-aggregations / Caching */
refreshKey: strictObject(
{
surface: 'this cube refreshKey block',
history: 'Until this shape was closed, an undeclared refreshKey key was silently dropped — '
+ 'a typo\'d `sql` probe left the cube refreshing on nothing.',
},
{
every: z.string().optional().describe('Refresh interval (e.g. "1 hour")'),
sql: z.string().optional().describe('SQL to check for data changes'),
},
).optional(),
/** Access Control */
public: z.boolean().default(false),
// ADR-0010 — runtime protection envelope (internal — set by loader).
// [#10194] See the docblock above for why this spread became load-bearing
// the day the `/meta` write door started parsing bodies with this schema.
...MetadataProtectionFields,
},
));
/**
* 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 same names as {@link DateRangePreset} — declared through the schema so the alias cannot drift from it. */
export type AnalyticsDateRangePreset = z.input<typeof AnalyticsDateRangePresetSchema>;
/**
* 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
* <!-- os:check -->
* ```ts
* 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
* 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<typeof AnalyticsDateRangeSchema>;
/**
* 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<PropertyKey> },
): 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.
*
* Strict as of #4001 batch D. The TOP level was already gated at the one
* production door — `api/analytics.zod.ts`'s `AnalyticsQueryRequestSchema` is
* `.extend(…).strict()` since #3878, so an undeclared top-level key answered
* 400 at `/analytics/query` before this change. What was NOT gated is the
* level this file owns: closing the base makes the posture hold at every
* door (a future bare `AnalyticsQuerySchema.parse` included) instead of only
* at the wrapper that happened to re-apply it, and the nested
* `timeDimensions[]` item below carries the real behaviour change.
*/
export const AnalyticsQuerySchema = lazySchema(() => strictObject(
{
surface: 'this analytics query',
history: 'Until this shape was closed, an undeclared key here was silently dropped at every door '
+ 'except the strict `/analytics/query` wrapper.',
// The sibling record dialect (`data/query.zod.ts` `BaseQuerySchema`) spells
// sorting `orderBy`; the analytics dialect spells it `order`.
aliases: { orderBy: 'order' },
guidance: {
// The second sentence used to point at the cube metric's own `filters` —
// a key #10414 removed (never suggest a key the schema cannot accept;
// the `triggerPhrase` lesson in strict-object.ts).
filters: '`filters` is not an AnalyticsQuery field — use `where` (canonical Query DSL '
+ 'FilterCondition, the same shape find() takes). There is no per-metric filter key '
+ 'either: fold the condition into the metric\'s own `sql` expression, or use '
+ 'an ADR-0021 dataset measure\'s structured `filter`.',
},
// No `extraKeys`: the one extension (`AnalyticsQueryRequestSchema`) adds
// only the #3878 `retiredKey` tombstones, and a tombstone must never be
// suggested (the `triggerPhrase` lesson in strict-object.ts).
},
{
cube: z.string().optional().describe('Target cube name (optional when provided externally, e.g. in API request wrapper)'),
measures: z.array(z.string()).describe('List of metrics to calculate'),
dimensions: z.array(z.string()).optional().describe('List of dimensions to group by'),
/**
* WHERE clause — canonical filter shape per the unified Query DSL
* (see {@link FilterConditionSchema} in `data/filter.zod.ts` and
* {@link QuerySchema} in `data/query.zod.ts`). This is the same
* MongoDB-style filter used by `find()`, dashboard widget `filter`,
* RLS conditions, etc.
*
* @example
* ```ts
* { where: { is_active: true, stage: { $nin: ['lost'] } } }
* ```
*/
where: FilterConditionSchema.optional().describe(
'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`).'
),
/**
* Time-bucketed dimensions. Strict as of #4001 batch D — and this item is
* the batch's live behaviour change at the REST door: the `.strict()` on
* `AnalyticsQueryRequestSchema` guards only the TOP level, so before this
* close `{ dimension, granuarity: 'day' }` rode through the strict wrapper
* with the typo'd granularity silently stripped — the query bucketed the
* whole range as one group under an ordinary 200 (measured on `main`).
*/
timeDimensions: z.array(strictObject(
{
surface: 'this time dimension',
history: 'Until this shape was closed, an undeclared key here was silently stripped even at the '
+ 'strict `/analytics/query` door — top-level strictness does not recurse.',
// The plural is the cube DIMENSION's declaration key; a query's time
// dimension takes exactly one `granularity`.
aliases: { granularities: 'granularity' },
},
{
dimension: z.string(),
granularity: TimeUpdateInterval.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().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(),
limit: z.number().optional(),
offset: z.number().optional(),
/**
* Reference timezone (IANA name) for date bucketing. OPTIONAL WITH NO
* DEFAULT, deliberately (#4538): an ABSENT timezone is a meaningful state —
* the engine resolves it (`selection.timezone ?? context.timezone ?? 'UTC'`,
* ADR-0053 Phase 2), and the `/analytics` entry forwards bodies
* validation-only precisely so a schema default cannot silently override
* the org-timezone resolution chain (#1982/#2018). The `.default('UTC')`
* this field used to carry declared a boundary the runtime refused to
* enforce.
*/
timezone: z.string().optional(),
},
));
export type Metric = z.input<typeof MetricSchema>;
export type Dimension = z.input<typeof DimensionSchema>;
export type CubeJoin = z.input<typeof CubeJoinSchema>;
/** Post-parse shape of {@link CubeJoin} — defaults applied, transforms run (ADR-0122). */
export type CubeJoinParsed = z.infer<typeof CubeJoinSchema>;
export type Cube = z.input<typeof CubeSchema>;
/** Post-parse shape of {@link Cube} — defaults applied, transforms run (ADR-0122). */
export type CubeParsed = z.infer<typeof CubeSchema>;
/**
* Type-safe factory for an analytics semantic-layer cube. Validates at authoring time via
* `.parse()` and accepts input-shape config (optional defaults, CEL
* shorthand) — preferred over a bare `: Cube` literal.
*/
export function defineCube(config: z.input<typeof CubeSchema>): CubeParsed {
return CubeSchema.parse(config);
}
export type AnalyticsQuery = z.input<typeof AnalyticsQuerySchema>;