@speakai/shared 1.23.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,649 @@
1
+ /**
2
+ * Dashboard spec v2 — the validated dashboard contract.
3
+ *
4
+ * The single source of truth for what a dashboard IS, shared by the manual
5
+ * editor (speak-client), the chat generation LLM's structured output, the MCP
6
+ * tool surface, and the public viewer. Every write path parses against this.
7
+ *
8
+ * Import via the `@speakai/shared/schemas` subpath ONLY. This module has a
9
+ * runtime dependency on zod; the package's root barrel must never reach it
10
+ * (see tests/no-zod-in-root-barrel.test.ts).
11
+ *
12
+ * Every exported schema that can transitively contain a `Filter` ships
13
+ * pre-wrapped in the structural depth guard (`withDepthGuard`). The raw,
14
+ * unguarded schemas are private to this module and exist only for composition —
15
+ * there is deliberately no unguarded schema in the public surface to misuse.
16
+ */
17
+ import { z } from 'zod';
18
+ import { DASHBOARD_GRID_COLS, MAX_FILTER_DEPTH, MAX_JSON_DEPTH, SECTION_OVERVIEW_ID, exceedsJsonDepth, rectsOverlap, resolveSectionGroups, } from '../utils/dashboard-spec.js';
19
+ const NUMERIC_FIELD_TYPES = new Set(['number', 'currency']);
20
+ const TEMPORAL_FIELD_TYPES = new Set(['date', 'datetime']);
21
+ const NUMERIC_AGGS = new Set(['sum', 'avg', 'median', 'min', 'max']);
22
+ /* ── Structural depth guard ──────────────────────────────────────────────── */
23
+ /**
24
+ * Wraps a schema in the ITERATIVE structural depth guard.
25
+ *
26
+ * ⛔ DO NOT "SIMPLIFY" THIS INTO A `superRefine`. That is the obvious refactor
27
+ * and it silently reintroduces the bug. `filterSchema` is recursive, and zod
28
+ * recurses while parsing it. A 12 KB payload nesting `and` ~1200 deep makes
29
+ * `safeParse` THROW `RangeError: Maximum call stack size exceeded` — an uncaught
30
+ * throw, i.e. a 500 where a 400 belongs. `JSON.parse` survives that depth, so a
31
+ * body parser hands it straight through to us.
32
+ *
33
+ * A `superRefine` CANNOT defend against this: it runs AFTER the inner schemas
34
+ * have parsed, by which point the recursive descent has already blown the stack.
35
+ * The guard has to run BEFORE the recursion begins — hence `z.preprocess` — and
36
+ * it has to be iterative (see `exceedsJsonDepth`).
37
+ */
38
+ function withDepthGuard(schema) {
39
+ return z.preprocess((raw, ctx) => {
40
+ if (exceedsJsonDepth(raw, MAX_JSON_DEPTH)) {
41
+ ctx.addIssue({
42
+ code: 'custom',
43
+ message: `payload nesting exceeds the maximum depth of ${MAX_JSON_DEPTH}`,
44
+ });
45
+ return z.NEVER;
46
+ }
47
+ return raw;
48
+ }, schema);
49
+ }
50
+ /* ── Filter (self-recursive) ─────────────────────────────────────────────── */
51
+ export const filterOpSchema = z.enum([
52
+ 'eq', 'neq', 'in', 'gt', 'gte', 'lt', 'lte', 'exists', 'notExists',
53
+ ]);
54
+ const filterScalarSchema = z.union([z.string(), z.number(), z.boolean()]);
55
+ const filterListSchema = z.array(z.union([z.string(), z.number()])).min(1).max(100);
56
+ const filterLeafSchema = z
57
+ .strictObject({
58
+ field: z.string().min(1).max(120),
59
+ op: filterOpSchema,
60
+ value: z.union([filterScalarSchema, filterListSchema]).optional(),
61
+ })
62
+ .superRefine((f, ctx) => {
63
+ const valueless = f.op === 'exists' || f.op === 'notExists';
64
+ const isList = Array.isArray(f.value);
65
+ if (valueless && f.value !== undefined) {
66
+ ctx.addIssue({ code: 'custom', path: ['value'], message: `op "${f.op}" must not carry a value` });
67
+ return;
68
+ }
69
+ if (valueless)
70
+ return;
71
+ if (f.value === undefined) {
72
+ ctx.addIssue({ code: 'custom', path: ['value'], message: `op "${f.op}" requires a value` });
73
+ return;
74
+ }
75
+ if (f.op === 'in' && !isList) {
76
+ ctx.addIssue({ code: 'custom', path: ['value'], message: 'op "in" requires an array value' });
77
+ }
78
+ if (f.op !== 'in' && isList) {
79
+ ctx.addIssue({ code: 'custom', path: ['value'], message: `op "${f.op}" requires a scalar value` });
80
+ }
81
+ });
82
+ /**
83
+ * Recursive predicate. Zod 4's getter syntax lets `z.infer` derive the recursive
84
+ * type with no hand-written interface.
85
+ *
86
+ * PRIVATE — composition only. The public `filterSchema` below carries the depth
87
+ * guard; this raw one would throw RangeError on a deeply-nested payload.
88
+ */
89
+ const filterSchemaRaw = z.union([
90
+ filterLeafSchema,
91
+ z.strictObject({ get and() { return z.array(filterSchemaRaw).min(1).max(10); } }),
92
+ z.strictObject({ get or() { return z.array(filterSchemaRaw).min(1).max(10); } }),
93
+ ]);
94
+ /* ── Metric — STRATIFIED (see plan Decision 1) ───────────────────────────── */
95
+ export const aggSchema = z.enum([
96
+ 'sum', 'avg', 'min', 'max', 'median', 'count', 'countDistinct',
97
+ ]);
98
+ export const builtinMetricSchema = z.enum([
99
+ 'mediaCount', 'totalDuration', 'avgSentiment', 'speakerCount', 'wordCount',
100
+ ]);
101
+ /** A LEAF metric. `Expr` operands are always base metrics — never other exprs. */
102
+ const baseMetricSchemaRaw = z.discriminatedUnion('kind', [
103
+ z.strictObject({
104
+ kind: z.literal('builtin'),
105
+ name: builtinMetricSchema,
106
+ filter: filterSchemaRaw.optional(),
107
+ }),
108
+ z.strictObject({
109
+ kind: z.literal('field'),
110
+ fieldName: z.string().min(1).max(120),
111
+ agg: aggSchema,
112
+ filter: filterSchemaRaw.optional(),
113
+ }),
114
+ ]);
115
+ /**
116
+ * Derived metrics — a BOUNDED four-op grammar. Operands are `baseMetric`, so an
117
+ * expr can never nest inside an expr. That bound is what keeps LLM-generated
118
+ * specs safe to validate and cheap to compute; anything outside these four goes
119
+ * in a `narrative` widget instead. `ratio(diff(a,b), diff(c,d))` must not parse.
120
+ */
121
+ const exprSchemaRaw = z.discriminatedUnion('op', [
122
+ z.strictObject({ op: z.literal('ratio'), numerator: baseMetricSchemaRaw, denominator: baseMetricSchemaRaw }),
123
+ z.strictObject({ op: z.literal('diff'), a: baseMetricSchemaRaw, b: baseMetricSchemaRaw }),
124
+ z.strictObject({ op: z.literal('delta'), metric: baseMetricSchemaRaw, over: z.enum(['first-to-last', 'prev-period']) }),
125
+ z.strictObject({ op: z.literal('rank'), metric: baseMetricSchemaRaw, direction: z.enum(['desc', 'asc']) }),
126
+ ]);
127
+ const metricSchemaRaw = z.discriminatedUnion('kind', [
128
+ ...baseMetricSchemaRaw.options,
129
+ z.strictObject({
130
+ kind: z.literal('expr'),
131
+ expr: exprSchemaRaw,
132
+ filter: filterSchemaRaw.optional(),
133
+ }),
134
+ ]);
135
+ /* ── GroupBy ─────────────────────────────────────────────────────────────── */
136
+ export const granularitySchema = z.enum(['record', 'day', 'week', 'month', 'quarter']);
137
+ export const groupBySchema = z.discriminatedUnion('kind', [
138
+ z.strictObject({ kind: z.literal('field'), fieldName: z.string().min(1).max(120) }),
139
+ z.strictObject({ kind: z.literal('time'), fieldName: z.string().min(1).max(120), granularity: granularitySchema }),
140
+ z.strictObject({ kind: z.literal('folder') }),
141
+ z.strictObject({ kind: z.literal('speaker') }),
142
+ ]);
143
+ /* ── Threshold ───────────────────────────────────────────────────────────── */
144
+ export const thresholdStatusSchema = z.enum(['good', 'warn', 'critical', 'neutral']);
145
+ /** Discriminated on `op` so `between` is the only form that takes a tuple. */
146
+ const thresholdWhenSchema = z
147
+ .discriminatedUnion('op', [
148
+ z.strictObject({ op: z.enum(['gte', 'gt', 'lt', 'lte']), value: z.number() }),
149
+ z.strictObject({ op: z.literal('between'), value: z.tuple([z.number(), z.number()]) }),
150
+ ])
151
+ .superRefine((w, ctx) => {
152
+ // A reversed band ([high, low]) validates structurally but matches no value —
153
+ // the configured coloring silently never fires. Reject it, don't strip it.
154
+ if (w.op === 'between' && w.value[0] > w.value[1]) {
155
+ ctx.addIssue({ code: 'custom', path: ['value'], message: 'between bounds must be [low, high]' });
156
+ }
157
+ });
158
+ export const thresholdSchema = z.strictObject({
159
+ when: thresholdWhenSchema,
160
+ status: thresholdStatusSchema,
161
+ label: z.string().min(1).max(40).optional(),
162
+ });
163
+ const thresholdsSchema = z.array(thresholdSchema).max(8);
164
+ /* ── Source / DateRange / Binding ────────────────────────────────────────── */
165
+ export const sourceSchema = z.discriminatedUnion('type', [
166
+ z.strictObject({ type: z.literal('folders'), folderIds: z.array(z.string().min(1).max(64)).min(1).max(50) }),
167
+ z.strictObject({ type: z.literal('team') }),
168
+ z.strictObject({ type: z.literal('workspace') }),
169
+ ]);
170
+ export const dateRangePresetSchema = z.enum([
171
+ 'last7days', 'last30days', 'last3months', 'yearToDate', 'allTime',
172
+ ]);
173
+ export const dateRangeSchema = z.strictObject({ preset: dateRangePresetSchema });
174
+ /** Per-widget override. Omit any key to inherit the dashboard's value. */
175
+ const bindingSchemaRaw = z.strictObject({
176
+ source: sourceSchema.optional(),
177
+ dateRange: dateRangeSchema.optional(),
178
+ filter: filterSchemaRaw.optional(),
179
+ });
180
+ /* ── Column (table) ──────────────────────────────────────────────────────── */
181
+ /**
182
+ * `{header} & ({field} | {metric}) & {thresholds?}`. Both branches are strict,
183
+ * so a column carrying BOTH `field` and `metric` fails both — the intersection
184
+ * semantics come for free.
185
+ */
186
+ const columnSchemaRaw = z.union([
187
+ z.strictObject({
188
+ header: z.string().min(1).max(40),
189
+ field: z.string().min(1).max(120),
190
+ thresholds: thresholdsSchema.optional(),
191
+ }),
192
+ z.strictObject({
193
+ header: z.string().min(1).max(40),
194
+ metric: metricSchemaRaw,
195
+ thresholds: thresholdsSchema.optional(),
196
+ }),
197
+ ]);
198
+ /* ── Layout ──────────────────────────────────────────────────────────────── */
199
+ export const layoutSchema = z
200
+ .strictObject({
201
+ x: z.number().int().min(0).max(DASHBOARD_GRID_COLS - 1),
202
+ y: z.number().int().min(0).max(200),
203
+ w: z.number().int().min(1).max(DASHBOARD_GRID_COLS),
204
+ h: z.number().int().min(1).max(40),
205
+ })
206
+ .refine((l) => l.x + l.w <= DASHBOARD_GRID_COLS, {
207
+ message: `widget overflows the ${DASHBOARD_GRID_COLS}-column grid (x + w must be <= ${DASHBOARD_GRID_COLS})`,
208
+ path: ['w'],
209
+ });
210
+ /* ── Widgets ─────────────────────────────────────────────────────────────── */
211
+ const widgetBase = {
212
+ id: z.string().min(1).max(64).regex(/^[a-z0-9]+(-[a-z0-9]+)*$/, 'widget id must be kebab-case'),
213
+ title: z.string().min(1).max(40),
214
+ binding: bindingSchemaRaw.optional(),
215
+ layout: layoutSchema,
216
+ };
217
+ const tableConfigSchema = z
218
+ .strictObject({
219
+ rowsAre: z.enum(['records', 'groups']),
220
+ groupBy: groupBySchema.optional(),
221
+ columns: z.array(columnSchemaRaw).min(1).max(12),
222
+ sort: z.strictObject({ column: z.string().min(1), dir: z.enum(['asc', 'desc']) }).optional(),
223
+ limit: z.number().int().min(1).max(500).optional(),
224
+ searchable: z.boolean().optional(),
225
+ rowClick: z.enum(['openMedia', 'none']).optional(),
226
+ })
227
+ .superRefine((c, ctx) => {
228
+ const headers = c.columns.map((col) => col.header);
229
+ headers.forEach((h, i) => {
230
+ if (headers.indexOf(h) !== i) {
231
+ ctx.addIssue({ code: 'custom', path: ['columns', i, 'header'], message: `duplicate column header "${h}"` });
232
+ }
233
+ });
234
+ if (c.sort && !headers.includes(c.sort.column)) {
235
+ ctx.addIssue({ code: 'custom', path: ['sort', 'column'], message: `sort column "${c.sort.column}" is not one of the table's headers` });
236
+ }
237
+ if (c.rowsAre === 'groups' && !c.groupBy) {
238
+ ctx.addIssue({ code: 'custom', path: ['groupBy'], message: 'rowsAre:"groups" requires a groupBy' });
239
+ }
240
+ if (c.rowsAre === 'records' && c.groupBy) {
241
+ ctx.addIssue({ code: 'custom', path: ['groupBy'], message: 'rowsAre:"records" must not carry a groupBy' });
242
+ }
243
+ });
244
+ const widgetSchemaRaw = z.discriminatedUnion('type', [
245
+ // Narrative — the headline element. `focus` is what the LLM emits; the
246
+ // generated* fields are written by the server (generate-on-author) and served
247
+ // to public viewers as static content.
248
+ z.strictObject({
249
+ ...widgetBase,
250
+ type: z.literal('narrative'),
251
+ config: z.strictObject({
252
+ focus: z.string().min(1).max(400),
253
+ generatedText: z.string().max(4000).optional(),
254
+ // z.iso.datetime(), NOT z.string().datetime() — the latter is @deprecated
255
+ // in zod 4 (still functional in 4.4.3, but it will go at the next major).
256
+ generatedAt: z.iso.datetime().optional(),
257
+ generatedForDateRange: dateRangeSchema.optional(),
258
+ }),
259
+ }),
260
+ z.strictObject({
261
+ ...widgetBase,
262
+ type: z.literal('stat-cards'),
263
+ config: z.strictObject({
264
+ tiles: z.array(z.strictObject({
265
+ metric: metricSchemaRaw,
266
+ label: z.string().min(1).max(40),
267
+ caption: z.string().max(80).optional(),
268
+ thresholds: thresholdsSchema.optional(),
269
+ })).min(1).max(6),
270
+ }),
271
+ }),
272
+ // The chart workhorse. `series` (a 2nd groupBy dimension) is the single
273
+ // highest-leverage primitive the design spike found — without it, "score over
274
+ // time, one line per person" cannot be drawn.
275
+ z.strictObject({
276
+ ...widgetBase,
277
+ type: z.literal('metric-chart'),
278
+ config: z.strictObject({
279
+ mark: z.enum(['line', 'bar', 'area', 'donut', 'stacked-bar']),
280
+ metric: metricSchemaRaw,
281
+ groupBy: groupBySchema.optional(),
282
+ series: groupBySchema.optional(),
283
+ sort: z.enum(['value-desc', 'value-asc', 'label']).optional(),
284
+ limit: z.number().int().min(1).max(100).optional(),
285
+ thresholds: thresholdsSchema.optional(),
286
+ }),
287
+ }),
288
+ z.strictObject({ ...widgetBase, type: z.literal('table'), config: tableConfigSchema }),
289
+ z.strictObject({
290
+ ...widgetBase,
291
+ type: z.literal('comparison'),
292
+ config: z.strictObject({
293
+ dimension: z.enum(['folder', 'time', 'fieldValue']),
294
+ a: bindingSchemaRaw,
295
+ b: bindingSchemaRaw,
296
+ metrics: z.array(metricSchemaRaw).min(1).max(6),
297
+ }),
298
+ }),
299
+ z.strictObject({
300
+ ...widgetBase,
301
+ type: z.literal('field-distribution'),
302
+ config: z.strictObject({
303
+ fieldName: z.string().min(1).max(120), // name, not id
304
+ measure: z.enum(['count', 'percent']),
305
+ chartType: z.enum(['bar', 'donut']),
306
+ }),
307
+ }),
308
+ z.strictObject({
309
+ ...widgetBase,
310
+ type: z.literal('sentiment-trend'),
311
+ config: z.strictObject({ granularity: z.enum(['day', 'week', 'month']) }),
312
+ }),
313
+ z.strictObject({
314
+ ...widgetBase,
315
+ type: z.literal('themes'),
316
+ config: z.strictObject({ limit: z.number().int().min(1).max(50) }),
317
+ }),
318
+ z.strictObject({
319
+ ...widgetBase,
320
+ type: z.literal('people'),
321
+ config: z.strictObject({
322
+ metrics: z.array(metricSchemaRaw).min(1).max(6),
323
+ limit: z.number().int().min(1).max(100),
324
+ }),
325
+ }),
326
+ // team-activity is valid ONLY under an effective source.type === "team"
327
+ // (envelope check 4). It IS publicly shareable; the member `email` is masked
328
+ // by the server-side public serializer, not here.
329
+ z.strictObject({
330
+ ...widgetBase,
331
+ type: z.literal('team-activity'),
332
+ config: z.strictObject({
333
+ metrics: z.array(z.enum(['uploads', 'minutes', 'meetings', 'chatUsage', 'lastActive'])).min(1).max(5),
334
+ }),
335
+ }),
336
+ z.strictObject({
337
+ ...widgetBase,
338
+ type: z.literal('notes'),
339
+ config: z.strictObject({ content: z.string().min(1).max(4000) }),
340
+ }),
341
+ ]);
342
+ export const widgetTypeSchema = z.enum([
343
+ 'narrative', 'stat-cards', 'metric-chart', 'table', 'comparison',
344
+ 'field-distribution', 'sentiment-trend', 'themes', 'people',
345
+ 'team-activity', 'notes',
346
+ ]);
347
+ /* ── Section ─────────────────────────────────────────────────────────────── */
348
+ export const sectionSchema = z.strictObject({
349
+ id: z.string().min(1).max(64).regex(/^[a-z0-9]+(-[a-z0-9]+)*$/, 'section id must be kebab-case'),
350
+ title: z.string().min(1).max(24),
351
+ icon: z.string().min(1).max(40).regex(/^[a-z][a-z0-9-]*$/, 'icon must be a kebab-case lucide name'),
352
+ widgetIds: z.array(z.string().min(1).max(64)).max(24),
353
+ });
354
+ /* ── Envelope ────────────────────────────────────────────────────────────── */
355
+ /**
356
+ * Structural base. `widgets`/`sections` carry CAPS only, never minimums — a
357
+ * blank dashboard (zero widgets) is valid, and the spec's "4–16 widgets"
358
+ * guidance is a generation-prompt rule, not a persistence invariant.
359
+ */
360
+ const dashboardSpecBaseSchema = z.strictObject({
361
+ title: z.string().min(1).max(60),
362
+ description: z.string().max(280).optional(),
363
+ source: sourceSchema,
364
+ dateRange: dateRangeSchema,
365
+ sections: z.array(sectionSchema).max(12),
366
+ widgets: z.array(widgetSchemaRaw).max(24),
367
+ /** Optimistic-concurrency token. Server-owned; generators omit it. */
368
+ revision: z.number().int().nonnegative().default(0),
369
+ });
370
+ /* ── Public schemas — every filter-containing one carries the depth guard ─── */
371
+ export const filterSchema = withDepthGuard(filterSchemaRaw);
372
+ export const baseMetricSchema = withDepthGuard(baseMetricSchemaRaw);
373
+ export const exprSchema = withDepthGuard(exprSchemaRaw);
374
+ export const metricSchema = withDepthGuard(metricSchemaRaw);
375
+ export const bindingSchema = withDepthGuard(bindingSchemaRaw);
376
+ export const columnSchema = withDepthGuard(columnSchemaRaw);
377
+ export const widgetSchema = withDepthGuard(widgetSchemaRaw);
378
+ /** Records a filter tree AND every field its predicates name, recursively. */
379
+ function addFilter(acc, filter, path) {
380
+ if (!filter)
381
+ return;
382
+ acc.filters.push({ filter, path });
383
+ acc.fields.push(...fieldRefsInFilter(filter, path));
384
+ }
385
+ /** Walks a filter tree (incl. `and`/`or`) and yields every `field` it names. */
386
+ function fieldRefsInFilter(filter, path) {
387
+ if ('and' in filter) {
388
+ return filter.and.flatMap((f, i) => fieldRefsInFilter(f, [...path, 'and', i]));
389
+ }
390
+ if ('or' in filter) {
391
+ return filter.or.flatMap((f, i) => fieldRefsInFilter(f, [...path, 'or', i]));
392
+ }
393
+ return [{ use: 'mention', fieldName: filter.field, path: [...path, 'field'] }];
394
+ }
395
+ function addBaseMetric(acc, metric, path) {
396
+ if (metric.kind === 'field') {
397
+ acc.fields.push({ use: 'agg', fieldName: metric.fieldName, agg: metric.agg, path: [...path, 'fieldName'] });
398
+ }
399
+ addFilter(acc, metric.filter, [...path, 'filter']);
400
+ }
401
+ function addMetric(acc, metric, path) {
402
+ if (metric.kind !== 'expr') {
403
+ addBaseMetric(acc, metric, path);
404
+ return;
405
+ }
406
+ exprOperands(metric.expr).forEach(([key, operand]) => addBaseMetric(acc, operand, [...path, 'expr', key]));
407
+ addFilter(acc, metric.filter, [...path, 'filter']);
408
+ }
409
+ function addGroupBy(acc, groupBy, path) {
410
+ if (groupBy.kind === 'field') {
411
+ acc.fields.push({ use: 'group', fieldName: groupBy.fieldName, path: [...path, 'fieldName'] });
412
+ }
413
+ else if (groupBy.kind === 'time') {
414
+ acc.fields.push({ use: 'timeGroup', fieldName: groupBy.fieldName, path: [...path, 'fieldName'] });
415
+ }
416
+ // folder | speaker name no field.
417
+ }
418
+ function addBinding(acc, binding, path) {
419
+ addFilter(acc, binding?.filter, [...path, 'filter']);
420
+ }
421
+ /**
422
+ * EVERY field name and filter reachable from a widget — all five field sites.
423
+ *
424
+ * Metrics, groupBy/series, and field-distribution are the obvious three. The two
425
+ * that are easy to miss, and that an LLM is most likely to typo, are FILTER
426
+ * PREDICATES (metric-level and binding-level, recursively through and/or) and
427
+ * RAW TABLE COLUMNS. A hallucinated field name in either one validates clean,
428
+ * matches nothing, and renders a confident `0` — a plausible wrong number, which
429
+ * is strictly worse than an error. Do not narrow this function.
430
+ */
431
+ function collectWidgetRefs(widget) {
432
+ const acc = { fields: [], filters: [] };
433
+ // Site 5: per-widget binding filter (applies to every widget type).
434
+ addBinding(acc, widget.binding, ['binding']);
435
+ const c = ['config'];
436
+ switch (widget.type) {
437
+ case 'metric-chart':
438
+ addMetric(acc, widget.config.metric, [...c, 'metric']);
439
+ if (widget.config.groupBy)
440
+ addGroupBy(acc, widget.config.groupBy, [...c, 'groupBy']);
441
+ if (widget.config.series)
442
+ addGroupBy(acc, widget.config.series, [...c, 'series']);
443
+ break;
444
+ case 'stat-cards':
445
+ widget.config.tiles.forEach((t, i) => addMetric(acc, t.metric, [...c, 'tiles', i, 'metric']));
446
+ break;
447
+ case 'comparison':
448
+ widget.config.metrics.forEach((m, i) => addMetric(acc, m, [...c, 'metrics', i]));
449
+ // Both sides of a comparison are bindings, and each may carry a filter.
450
+ addBinding(acc, widget.config.a, [...c, 'a']);
451
+ addBinding(acc, widget.config.b, [...c, 'b']);
452
+ break;
453
+ case 'people':
454
+ widget.config.metrics.forEach((m, i) => addMetric(acc, m, [...c, 'metrics', i]));
455
+ break;
456
+ case 'table':
457
+ if (widget.config.groupBy)
458
+ addGroupBy(acc, widget.config.groupBy, [...c, 'groupBy']);
459
+ widget.config.columns.forEach((col, i) => {
460
+ if ('metric' in col) {
461
+ addMetric(acc, col.metric, [...c, 'columns', i, 'metric']);
462
+ }
463
+ else {
464
+ // A raw-field column names a field. Easy to miss; unchecked = silent 0.
465
+ acc.fields.push({ use: 'mention', fieldName: col.field, path: [...c, 'columns', i, 'field'] });
466
+ }
467
+ });
468
+ break;
469
+ case 'field-distribution':
470
+ acc.fields.push({ use: 'mention', fieldName: widget.config.fieldName, path: [...c, 'fieldName'] });
471
+ break;
472
+ // Field-free widget types, listed EXPLICITLY — no `default:` clause.
473
+ // A `default: break` would silently exempt any widget type added later, and
474
+ // an unchecked field name is a silent-wrong-number bug. With these spelled
475
+ // out, adding a widget type makes `assertNoFieldRefs` a COMPILE ERROR until
476
+ // someone decides whether it names a field.
477
+ case 'narrative':
478
+ case 'sentiment-trend':
479
+ case 'themes':
480
+ case 'team-activity':
481
+ case 'notes':
482
+ break;
483
+ default:
484
+ assertNoFieldRefs(widget);
485
+ }
486
+ return acc;
487
+ }
488
+ /** Exhaustiveness guard: a new widget type fails to compile here until handled. */
489
+ function assertNoFieldRefs(widget) {
490
+ throw new Error(`unhandled widget type in collectWidgetRefs: ${JSON.stringify(widget)}`);
491
+ }
492
+ /**
493
+ * The four bounded ops, flattened to `[documentKey, operand]` pairs. The key is
494
+ * the operand's ACTUAL name in the payload (`numerator`, `a`, `metric`, …) so an
495
+ * issue path points at a location the caller can navigate — there is no synthetic
496
+ * `operands` array in the document.
497
+ */
498
+ function exprOperands(expr) {
499
+ switch (expr.op) {
500
+ case 'ratio': return [['numerator', expr.numerator], ['denominator', expr.denominator]];
501
+ case 'diff': return [['a', expr.a], ['b', expr.b]];
502
+ case 'delta':
503
+ case 'rank': return [['metric', expr.metric]];
504
+ }
505
+ }
506
+ /** Nesting depth of a filter tree. A leaf predicate is depth 1. */
507
+ function filterDepth(filter) {
508
+ if ('and' in filter)
509
+ return 1 + Math.max(...filter.and.map(filterDepth));
510
+ if ('or' in filter)
511
+ return 1 + Math.max(...filter.or.map(filterDepth));
512
+ return 1;
513
+ }
514
+ /* ── The envelope schema ─────────────────────────────────────────────────── */
515
+ /**
516
+ * Builds the dashboard spec schema.
517
+ *
518
+ * @param fieldTypes Optional `fieldName → FieldType` map. When supplied, the
519
+ * aggregator/field-type check (#3) runs and unknown field names are rejected.
520
+ * When omitted, only the structural checks (#1, #2, #4, #5) run — the resulting
521
+ * schema is a strict superset, so anything the field-aware schema accepts,
522
+ * the structural one accepts too.
523
+ *
524
+ * Pass it on the SERVER (which already loads the company's fields). The
525
+ * CLIENT may omit it and validate structurally in `zodResolver`; the server
526
+ * is the authority.
527
+ */
528
+ export function buildDashboardSpecSchema(fieldTypes) {
529
+ const checked = dashboardSpecBaseSchema.superRefine((spec, ctx) => {
530
+ const widgetsById = new Map(spec.widgets.map((w) => [w.id, w]));
531
+ /* ── 1. Referential integrity + uniqueness ───────────────────────────── */
532
+ const seenWidgetIds = new Set();
533
+ spec.widgets.forEach((w, i) => {
534
+ if (seenWidgetIds.has(w.id)) {
535
+ ctx.addIssue({ code: 'custom', path: ['widgets', i, 'id'], message: `duplicate widget id "${w.id}"` });
536
+ }
537
+ seenWidgetIds.add(w.id);
538
+ });
539
+ const seenSectionIds = new Set();
540
+ const claimed = new Set();
541
+ spec.sections.forEach((s, si) => {
542
+ if (seenSectionIds.has(s.id)) {
543
+ ctx.addIssue({ code: 'custom', path: ['sections', si, 'id'], message: `duplicate section id "${s.id}"` });
544
+ }
545
+ seenSectionIds.add(s.id);
546
+ s.widgetIds.forEach((wid, wi) => {
547
+ if (!widgetsById.has(wid)) {
548
+ ctx.addIssue({ code: 'custom', path: ['sections', si, 'widgetIds', wi], message: `section "${s.id}" references unknown widget "${wid}"` });
549
+ return;
550
+ }
551
+ if (claimed.has(wid)) {
552
+ ctx.addIssue({ code: 'custom', path: ['sections', si, 'widgetIds', wi], message: `widget "${wid}" appears in more than one section` });
553
+ }
554
+ claimed.add(wid);
555
+ });
556
+ });
557
+ /* ── 2. Layout collision, PER RESOLVED SECTION ───────────────────────── */
558
+ // Unsectioned widgets form the implicit "Overview" group. `y` restarts at 0
559
+ // in each section, so collisions are only meaningful WITHIN a group.
560
+ for (const group of resolveSectionGroups(spec.sections, spec.widgets)) {
561
+ for (let i = 0; i < group.widgetIds.length; i++) {
562
+ for (let j = i + 1; j < group.widgetIds.length; j++) {
563
+ const a = widgetsById.get(group.widgetIds[i]);
564
+ const b = widgetsById.get(group.widgetIds[j]);
565
+ if (!a || !b || !rectsOverlap(a.layout, b.layout))
566
+ continue;
567
+ ctx.addIssue({
568
+ code: 'custom',
569
+ path: ['widgets', spec.widgets.indexOf(b), 'layout'],
570
+ message: `widget "${b.id}" overlaps "${a.id}" in section "${group.id}"`,
571
+ });
572
+ }
573
+ }
574
+ }
575
+ /* ── 4. team-activity requires an effective source.type === "team" ────── */
576
+ spec.widgets.forEach((w, i) => {
577
+ if (w.type !== 'team-activity')
578
+ return;
579
+ const effective = w.binding?.source ?? spec.source;
580
+ if (effective.type !== 'team') {
581
+ ctx.addIssue({ code: 'custom', path: ['widgets', i, 'type'], message: 'team-activity is only valid when the effective source is {type:"team"}' });
582
+ }
583
+ });
584
+ const refsByWidget = spec.widgets.map(collectWidgetRefs);
585
+ /* ── 5. Filter nesting depth (semantic; the structural guard already ran) ─ */
586
+ // Safe to recurse here ONLY because the envelope's z.preprocess has already
587
+ // rejected anything deeper than MAX_JSON_DEPTH. Without that, this check is
588
+ // unreachable — the stack overflows during the recursive filter parse, long
589
+ // before any superRefine runs.
590
+ refsByWidget.forEach((refs, wi) => {
591
+ for (const { filter, path } of refs.filters) {
592
+ if (filterDepth(filter) > MAX_FILTER_DEPTH) {
593
+ ctx.addIssue({
594
+ code: 'custom',
595
+ path: ['widgets', wi, ...path],
596
+ message: `filter nesting exceeds the maximum depth of ${MAX_FILTER_DEPTH}`,
597
+ });
598
+ }
599
+ }
600
+ });
601
+ /* ── 3. Field existence + aggregator/field-type compatibility ──────────── */
602
+ // Context-dependent: runs only when the caller supplied a field-type map.
603
+ // Walks ALL FIVE sites a field name can appear (see collectWidgetRefs) —
604
+ // including filter predicates and raw table columns, the two an LLM is most
605
+ // likely to typo and the two whose failure mode is a confident wrong number.
606
+ if (!fieldTypes)
607
+ return;
608
+ refsByWidget.forEach((refs, wi) => {
609
+ for (const ref of refs.fields) {
610
+ const path = ['widgets', wi, ...ref.path];
611
+ // Own-property lookup only: a field named after a JS prototype member
612
+ // ("toString", "constructor", "__proto__", …) must resolve to undefined,
613
+ // not to `Object.prototype.toString`, or the unknown-field guard below is
614
+ // bypassed and the widget silently renders a confident 0.
615
+ const type = Object.hasOwn(fieldTypes, ref.fieldName) ? fieldTypes[ref.fieldName] : undefined;
616
+ if (!type) {
617
+ ctx.addIssue({ code: 'custom', path, message: `unknown field "${ref.fieldName}"` });
618
+ continue;
619
+ }
620
+ if (ref.use === 'agg' && NUMERIC_AGGS.has(ref.agg) && !NUMERIC_FIELD_TYPES.has(type)) {
621
+ ctx.addIssue({
622
+ code: 'custom',
623
+ path,
624
+ message: `agg "${ref.agg}" requires a number/currency field; "${ref.fieldName}" is ${type}`,
625
+ });
626
+ }
627
+ if (ref.use === 'group' && TEMPORAL_FIELD_TYPES.has(type)) {
628
+ ctx.addIssue({
629
+ code: 'custom',
630
+ path,
631
+ message: `field "${ref.fieldName}" is ${type}; grouping it requires kind:"time" with an explicit granularity`,
632
+ });
633
+ }
634
+ if (ref.use === 'timeGroup' && !TEMPORAL_FIELD_TYPES.has(type)) {
635
+ ctx.addIssue({
636
+ code: 'custom',
637
+ path,
638
+ message: `grouping by time requires a date/datetime field; "${ref.fieldName}" is ${type}`,
639
+ });
640
+ }
641
+ }
642
+ });
643
+ });
644
+ return withDepthGuard(checked);
645
+ }
646
+ /** Structural-only schema (checks 1, 2, 4, 5). Safe where field types are unknown. */
647
+ export const dashboardSpecSchema = buildDashboardSpecSchema();
648
+ export { SECTION_OVERVIEW_ID };
649
+ //# sourceMappingURL=dashboard-spec.schema.js.map