@speakai/shared 1.24.0 → 2.1.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.
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/interfaces/dashboard.d.ts +6 -81
- package/dist/interfaces/dashboard.d.ts.map +1 -1
- package/dist/interfaces/dashboard.js +8 -2
- package/dist/interfaces/dashboard.js.map +1 -1
- package/dist/schemas/dashboard-spec.schema.d.ts +50979 -0
- package/dist/schemas/dashboard-spec.schema.d.ts.map +1 -0
- package/dist/schemas/dashboard-spec.schema.js +649 -0
- package/dist/schemas/dashboard-spec.schema.js.map +1 -0
- package/dist/schemas/index.d.ts +2 -0
- package/dist/schemas/index.d.ts.map +1 -0
- package/dist/schemas/index.js +2 -0
- package/dist/schemas/index.js.map +1 -0
- package/dist/utils/dashboard-spec.d.ts +78 -0
- package/dist/utils/dashboard-spec.d.ts.map +1 -0
- package/dist/utils/dashboard-spec.js +91 -0
- package/dist/utils/dashboard-spec.js.map +1 -0
- package/package.json +9 -1
|
@@ -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
|
+
'today', 'yesterday', '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
|