@script-it/json-views-core 0.1.0-beta.1
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/LICENSE +21 -0
- package/README.md +174 -0
- package/THIRD_PARTY_NOTICES.txt +4917 -0
- package/dist/annotation-capabilities.d.ts +17 -0
- package/dist/annotation-capabilities.js +21 -0
- package/dist/atomic-value.d.ts +3 -0
- package/dist/atomic-value.js +6 -0
- package/dist/date-type.d.ts +48 -0
- package/dist/date-type.js +244 -0
- package/dist/diagnostics.d.ts +8 -0
- package/dist/diagnostics.js +252 -0
- package/dist/document-session.d.ts +52 -0
- package/dist/document-session.js +164 -0
- package/dist/formats/conversion.d.ts +17 -0
- package/dist/formats/conversion.js +75 -0
- package/dist/formats/csv-format.d.ts +31 -0
- package/dist/formats/csv-format.js +269 -0
- package/dist/formats/document-format.d.ts +35 -0
- package/dist/formats/document-format.js +1 -0
- package/dist/formats/json-format.d.ts +12 -0
- package/dist/formats/json-format.js +109 -0
- package/dist/html-view-bindings.d.ts +4 -0
- package/dist/html-view-bindings.js +32 -0
- package/dist/html-view-limits.d.ts +1 -0
- package/dist/html-view-limits.js +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +19 -0
- package/dist/inference-fields.d.ts +5 -0
- package/dist/inference-fields.js +23 -0
- package/dist/inference.d.ts +26 -0
- package/dist/inference.js +304 -0
- package/dist/json-limits.d.ts +8 -0
- package/dist/json-limits.js +25 -0
- package/dist/json-path.d.ts +14 -0
- package/dist/json-path.js +84 -0
- package/dist/json-source-patcher.d.ts +97 -0
- package/dist/json-source-patcher.js +778 -0
- package/dist/metadata.d.ts +137 -0
- package/dist/metadata.js +952 -0
- package/dist/safe-pattern.d.ts +5 -0
- package/dist/safe-pattern.js +20 -0
- package/dist/schema-validation.d.ts +3 -0
- package/dist/schema-validation.js +4 -0
- package/dist/source-default.d.ts +2 -0
- package/dist/source-default.js +39 -0
- package/dist/table-suitability.d.ts +5 -0
- package/dist/table-suitability.js +55 -0
- package/dist/type-registry.d.ts +57 -0
- package/dist/type-registry.js +238 -0
- package/dist/value-comparison.d.ts +18 -0
- package/dist/value-comparison.js +133 -0
- package/dist/view-model.d.ts +18 -0
- package/dist/view-model.js +134 -0
- package/package.json +52 -0
- package/schema/jsonviews.schema.json +102 -0
package/dist/metadata.js
ADDED
|
@@ -0,0 +1,952 @@
|
|
|
1
|
+
import { MAX_HTML_VIEW_BYTES } from './html-view-limits.js';
|
|
2
|
+
import { assertJsonDepth } from './json-limits.js';
|
|
3
|
+
import { getValueAtPath, VALUE_PATH_MISSING } from './json-path.js';
|
|
4
|
+
import { compileSafePattern } from './safe-pattern.js';
|
|
5
|
+
import { defaultTypeRegistry, JSON_VIEW_OPTION_COLORS } from './type-registry.js';
|
|
6
|
+
import { compareDateValues, parseDateValue } from './date-type.js';
|
|
7
|
+
import { inferJsonViewMetadata } from './inference.js';
|
|
8
|
+
import { ANNOTATION_PROPERTIES, COMMON_FILTER_OPERATORS, VIEW_PROPERTIES } from './annotation-capabilities.js';
|
|
9
|
+
import { explainMetadataDiagnostics } from './diagnostics.js';
|
|
10
|
+
export const JSON_VIEW_PATH_MISSING = Symbol('jsonView-path-missing');
|
|
11
|
+
const FILTER_OPERATORS = new Set(COMMON_FILTER_OPERATORS);
|
|
12
|
+
const PRESENCE_OPERATORS = new Set(['isEmpty', 'isNotEmpty']);
|
|
13
|
+
function isRecord(value) {
|
|
14
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
15
|
+
}
|
|
16
|
+
function own(object, key) {
|
|
17
|
+
return Object.prototype.hasOwnProperty.call(object, key);
|
|
18
|
+
}
|
|
19
|
+
function syntaxError(source, index, detail) {
|
|
20
|
+
return new SyntaxError(`Invalid JSONPath ${JSON.stringify(source)} at character ${index}: ${detail}`);
|
|
21
|
+
}
|
|
22
|
+
function parseQuotedProperty(source, start) {
|
|
23
|
+
let index = start + 2;
|
|
24
|
+
let key = '';
|
|
25
|
+
while (index < source.length) {
|
|
26
|
+
const character = source[index];
|
|
27
|
+
if (character === "'") {
|
|
28
|
+
if (source[index + 1] !== ']')
|
|
29
|
+
throw syntaxError(source, index + 1, 'expected ] after quoted property');
|
|
30
|
+
return { key, next: index + 2 };
|
|
31
|
+
}
|
|
32
|
+
if (character !== '\\') {
|
|
33
|
+
key += character;
|
|
34
|
+
index += 1;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
const escaped = source[index + 1];
|
|
38
|
+
if (escaped === undefined)
|
|
39
|
+
throw syntaxError(source, index, 'unterminated escape');
|
|
40
|
+
const simpleEscapes = {
|
|
41
|
+
"'": "'", '\\': '\\', '/': '/', b: '\b', f: '\f', n: '\n', r: '\r', t: '\t',
|
|
42
|
+
};
|
|
43
|
+
if (own(simpleEscapes, escaped)) {
|
|
44
|
+
key += simpleEscapes[escaped];
|
|
45
|
+
index += 2;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (escaped === 'u') {
|
|
49
|
+
const hex = source.slice(index + 2, index + 6);
|
|
50
|
+
if (!/^[0-9a-fA-F]{4}$/.test(hex))
|
|
51
|
+
throw syntaxError(source, index, 'invalid Unicode escape');
|
|
52
|
+
key += String.fromCharCode(Number.parseInt(hex, 16));
|
|
53
|
+
index += 6;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
throw syntaxError(source, index, `unsupported escape \\${escaped}`);
|
|
57
|
+
}
|
|
58
|
+
throw syntaxError(source, start, 'unterminated quoted property');
|
|
59
|
+
}
|
|
60
|
+
export function parseJsonViewPath(source, options = {}) {
|
|
61
|
+
if (typeof source !== 'string' || source.length === 0)
|
|
62
|
+
throw new SyntaxError('JSONPath must be a non-empty string');
|
|
63
|
+
const root = source[0];
|
|
64
|
+
if (root !== '$')
|
|
65
|
+
throw syntaxError(source, 0, 'path must start with $');
|
|
66
|
+
if (options.root && root !== options.root)
|
|
67
|
+
throw syntaxError(source, 0, `path must start with ${options.root}`);
|
|
68
|
+
const segments = [];
|
|
69
|
+
let index = 1;
|
|
70
|
+
while (index < source.length) {
|
|
71
|
+
if (source[index] === '.') {
|
|
72
|
+
const start = index + 1;
|
|
73
|
+
index = start;
|
|
74
|
+
while (index < source.length && source[index] !== '.' && source[index] !== '[')
|
|
75
|
+
index += 1;
|
|
76
|
+
const key = source.slice(start, index);
|
|
77
|
+
if (!key || /\s/.test(key) || key === '*')
|
|
78
|
+
throw syntaxError(source, start, 'invalid dot property');
|
|
79
|
+
segments.push({ kind: 'property', key });
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (source[index] !== '[')
|
|
83
|
+
throw syntaxError(source, index, 'expected . or [');
|
|
84
|
+
if (source[index + 1] === "'") {
|
|
85
|
+
const parsed = parseQuotedProperty(source, index);
|
|
86
|
+
segments.push({ kind: 'property', key: parsed.key });
|
|
87
|
+
index = parsed.next;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
const close = source.indexOf(']', index + 1);
|
|
91
|
+
if (close < 0)
|
|
92
|
+
throw syntaxError(source, index, 'unterminated index');
|
|
93
|
+
const token = source.slice(index + 1, close);
|
|
94
|
+
if (token === '*') {
|
|
95
|
+
if (!options.allowWildcard)
|
|
96
|
+
throw syntaxError(source, index + 1, 'wildcards are not allowed here');
|
|
97
|
+
segments.push({ kind: 'wildcard' });
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
if (!/^(0|[1-9]\d*)$/.test(token))
|
|
101
|
+
throw syntaxError(source, index + 1, 'index must be a non-negative integer');
|
|
102
|
+
const parsedIndex = Number(token);
|
|
103
|
+
if (!Number.isSafeInteger(parsedIndex))
|
|
104
|
+
throw syntaxError(source, index + 1, 'index must be a safe integer');
|
|
105
|
+
segments.push({ kind: 'index', index: parsedIndex });
|
|
106
|
+
}
|
|
107
|
+
index = close + 1;
|
|
108
|
+
}
|
|
109
|
+
return { source, root, segments };
|
|
110
|
+
}
|
|
111
|
+
function pathSegmentsToValuePath(path) {
|
|
112
|
+
return path.segments.map((segment) => {
|
|
113
|
+
if (segment.kind === 'property')
|
|
114
|
+
return segment.key;
|
|
115
|
+
if (segment.kind === 'index')
|
|
116
|
+
return segment.index;
|
|
117
|
+
throw new TypeError(`JSONPath ${JSON.stringify(path.source)} is not concrete`);
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
export function resolveJsonViewPath(root, path) {
|
|
121
|
+
const sourcePath = pathSegmentsToValuePath(path);
|
|
122
|
+
let current = root;
|
|
123
|
+
for (const segment of path.segments) {
|
|
124
|
+
if (segment.kind === 'property') {
|
|
125
|
+
if (!isRecord(current) || !own(current, segment.key))
|
|
126
|
+
return { value: JSON_VIEW_PATH_MISSING, sourcePath };
|
|
127
|
+
current = current[segment.key];
|
|
128
|
+
}
|
|
129
|
+
else if (segment.kind === 'index') {
|
|
130
|
+
if (!Array.isArray(current) || !own(current, String(segment.index))) {
|
|
131
|
+
return { value: JSON_VIEW_PATH_MISSING, sourcePath };
|
|
132
|
+
}
|
|
133
|
+
current = current[segment.index];
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
throw new TypeError(`JSONPath ${JSON.stringify(path.source)} is not concrete`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return { value: current, sourcePath };
|
|
140
|
+
}
|
|
141
|
+
/** Without a root, reads the compiled snapshot; supplying a root resolves the plan against it. */
|
|
142
|
+
export function getJsonViewViewRows(view, root) {
|
|
143
|
+
if (arguments.length < 2)
|
|
144
|
+
return rowsForRecordCollection(view.value, view.sourcePath);
|
|
145
|
+
const resolved = resolveJsonViewPath(root, view.path);
|
|
146
|
+
return rowsForRecordCollection(resolved.value, resolved.sourcePath);
|
|
147
|
+
}
|
|
148
|
+
function rowsForRecordCollection(value, sourcePath) {
|
|
149
|
+
if (Array.isArray(value)) {
|
|
150
|
+
return value.flatMap((item, index) => isRecord(item)
|
|
151
|
+
? [{ key: index, value: item, sourcePath: [...sourcePath, index] }]
|
|
152
|
+
: []);
|
|
153
|
+
}
|
|
154
|
+
if (!isRecord(value))
|
|
155
|
+
return [];
|
|
156
|
+
return Object.entries(value).flatMap(([key, item]) => isRecord(item)
|
|
157
|
+
? [{ key, value: item, sourcePath: [...sourcePath, key] }]
|
|
158
|
+
: []);
|
|
159
|
+
}
|
|
160
|
+
export function resolveJsonViewRowPath(root, row, path) {
|
|
161
|
+
const prefix = path.segments.slice(0, row.sourcePath.length);
|
|
162
|
+
const aligned = prefix.length === row.sourcePath.length && prefix.every((segment, index) => {
|
|
163
|
+
const sourceSegment = row.sourcePath[index];
|
|
164
|
+
if (segment.kind === 'wildcard')
|
|
165
|
+
return true;
|
|
166
|
+
if (segment.kind === 'property')
|
|
167
|
+
return segment.key === sourceSegment;
|
|
168
|
+
return segment.index === sourceSegment;
|
|
169
|
+
});
|
|
170
|
+
if (!aligned)
|
|
171
|
+
return { value: JSON_VIEW_PATH_MISSING, sourcePath: row.sourcePath };
|
|
172
|
+
const remainder = path.segments.slice(row.sourcePath.length);
|
|
173
|
+
if (remainder.some((segment) => segment.kind === 'wildcard')) {
|
|
174
|
+
throw new TypeError(`JSONPath ${JSON.stringify(path.source)} selects more than one value per record`);
|
|
175
|
+
}
|
|
176
|
+
const currentRow = getValueAtPath(root, row.sourcePath);
|
|
177
|
+
if (currentRow === VALUE_PATH_MISSING)
|
|
178
|
+
return { value: JSON_VIEW_PATH_MISSING, sourcePath: row.sourcePath };
|
|
179
|
+
const resolved = resolveJsonViewPath(currentRow, { ...path, segments: remainder });
|
|
180
|
+
return { value: resolved.value, sourcePath: [...row.sourcePath, ...resolved.sourcePath] };
|
|
181
|
+
}
|
|
182
|
+
function diagnostic(diagnostics, scope, code, message, metadataPath, details = {}) {
|
|
183
|
+
diagnostics.push({ scope, code, message, metadataPath, ...details });
|
|
184
|
+
}
|
|
185
|
+
function diagnoseUnknownProperties(value, allowed, metadataPath, diagnostics, scope, code, details = {}) {
|
|
186
|
+
for (const key of Object.keys(value)) {
|
|
187
|
+
if (allowed.includes(key))
|
|
188
|
+
continue;
|
|
189
|
+
const suggestion = allowed.find((candidate) => candidate.toLowerCase() === key.toLowerCase());
|
|
190
|
+
diagnostic(diagnostics, scope, code, `Unrecognized property ${JSON.stringify(key)}${suggestion ? `; use ${JSON.stringify(suggestion)} (case-sensitive)` : ''}; the built-in compiler does not use it`, [...metadataPath, key], { ...details, severity: 'warning' });
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
function normalizeDescriptor(value, metadataPath, diagnostics, typeRegistry) {
|
|
194
|
+
if (!isRecord(value)) {
|
|
195
|
+
diagnostic(diagnostics, 'schema', 'invalid-schema-descriptor', 'Schema descriptor must be an object', metadataPath);
|
|
196
|
+
return undefined;
|
|
197
|
+
}
|
|
198
|
+
if (typeof value.type !== 'string' || !value.type.trim()) {
|
|
199
|
+
diagnostic(diagnostics, 'schema', 'invalid-schema-type', 'Schema descriptor must name a type', [...metadataPath, 'type']);
|
|
200
|
+
return undefined;
|
|
201
|
+
}
|
|
202
|
+
if (!typeRegistry.has(value.type)) {
|
|
203
|
+
diagnostic(diagnostics, 'schema', 'unknown-schema-type', `No type named ${JSON.stringify(value.type)} is registered`, [...metadataPath, 'type']);
|
|
204
|
+
return undefined;
|
|
205
|
+
}
|
|
206
|
+
const supported = typeRegistry.get(value.type)?.descriptorProperties;
|
|
207
|
+
if (supported) {
|
|
208
|
+
diagnoseUnknownProperties(value, [...supported, 'nullable'], metadataPath, diagnostics, 'schema', 'unknown-schema-property');
|
|
209
|
+
if (value.pattern !== undefined && ['number', 'checkbox', 'multi-select'].includes(value.type)) {
|
|
210
|
+
diagnostic(diagnostics, 'schema', 'inapplicable-schema-property', `pattern only checks strings; it does not constrain ${value.type} values`, [...metadataPath, 'pattern'], { severity: 'warning' });
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
const knownProperties = new Set([
|
|
214
|
+
'type', 'title', 'description', 'nullable', 'required', 'minimum', 'maximum',
|
|
215
|
+
'defaultIncludeTime', 'pattern', 'options', 'optionColors', 'placeholder', 'multiline', 'step',
|
|
216
|
+
]);
|
|
217
|
+
const normalized = {
|
|
218
|
+
...Object.fromEntries(Object.entries(value).filter(([key]) => !knownProperties.has(key))),
|
|
219
|
+
type: value.type,
|
|
220
|
+
};
|
|
221
|
+
for (const key of ['title', 'description']) {
|
|
222
|
+
if (value[key] === undefined)
|
|
223
|
+
continue;
|
|
224
|
+
if (typeof value[key] === 'string')
|
|
225
|
+
normalized[key] = value[key];
|
|
226
|
+
else
|
|
227
|
+
diagnostic(diagnostics, 'schema', `invalid-${key}`, `${key} must be a string`, [...metadataPath, key]);
|
|
228
|
+
}
|
|
229
|
+
if (value.nullable !== undefined) {
|
|
230
|
+
diagnostic(diagnostics, 'schema', 'unsupported-schema-property', 'Optional fields already allow null; use required to reject empty values', [...metadataPath, 'nullable']);
|
|
231
|
+
}
|
|
232
|
+
for (const key of ['required']) {
|
|
233
|
+
if (value[key] === undefined)
|
|
234
|
+
continue;
|
|
235
|
+
if (typeof value[key] === 'boolean')
|
|
236
|
+
normalized[key] = value[key];
|
|
237
|
+
else
|
|
238
|
+
diagnostic(diagnostics, 'schema', `invalid-${key}`, `${key} must be a boolean`, [...metadataPath, key]);
|
|
239
|
+
}
|
|
240
|
+
delete normalized.minimum;
|
|
241
|
+
delete normalized.maximum;
|
|
242
|
+
for (const key of ['minimum', 'maximum']) {
|
|
243
|
+
if (value[key] === undefined)
|
|
244
|
+
continue;
|
|
245
|
+
if (value.type === 'date') {
|
|
246
|
+
if (value[key] === null)
|
|
247
|
+
continue;
|
|
248
|
+
const parsed = parseDateValue(value[key]);
|
|
249
|
+
if (typeof value[key] === 'string' && (parsed.kind === 'date' || parsed.kind === 'datetime' || parsed.kind === 'floating-datetime'))
|
|
250
|
+
normalized[key] = value[key];
|
|
251
|
+
else
|
|
252
|
+
diagnostic(diagnostics, 'schema', `invalid-${key}`, `${key} must be a date or date and time`, [...metadataPath, key]);
|
|
253
|
+
}
|
|
254
|
+
else if (typeof value[key] === 'number' && Number.isFinite(value[key]))
|
|
255
|
+
normalized[key] = value[key];
|
|
256
|
+
else
|
|
257
|
+
diagnostic(diagnostics, 'schema', `invalid-${key}`, `${key} must be a finite number`, [...metadataPath, key]);
|
|
258
|
+
}
|
|
259
|
+
if (value.type === 'date' && typeof normalized.minimum === 'string' && typeof normalized.maximum === 'string'
|
|
260
|
+
&& (parseDateValue(normalized.minimum).kind === 'date') === (parseDateValue(normalized.maximum).kind === 'date')
|
|
261
|
+
&& compareDateValues(normalized.minimum, normalized.maximum) > 0) {
|
|
262
|
+
diagnostic(diagnostics, 'schema', 'invalid-date-bounds', 'minimum must not be after maximum', metadataPath);
|
|
263
|
+
delete normalized.minimum;
|
|
264
|
+
delete normalized.maximum;
|
|
265
|
+
}
|
|
266
|
+
delete normalized.defaultIncludeTime;
|
|
267
|
+
if (value.defaultIncludeTime !== undefined) {
|
|
268
|
+
if (typeof value.defaultIncludeTime === 'boolean')
|
|
269
|
+
normalized.defaultIncludeTime = value.defaultIncludeTime;
|
|
270
|
+
else
|
|
271
|
+
diagnostic(diagnostics, 'schema', 'invalid-defaultIncludeTime', 'defaultIncludeTime must be a boolean', [...metadataPath, 'defaultIncludeTime']);
|
|
272
|
+
}
|
|
273
|
+
if (value.pattern !== undefined) {
|
|
274
|
+
if (typeof value.pattern !== 'string') {
|
|
275
|
+
diagnostic(diagnostics, 'schema', 'invalid-pattern', 'pattern must be a string', [...metadataPath, 'pattern']);
|
|
276
|
+
}
|
|
277
|
+
else {
|
|
278
|
+
try {
|
|
279
|
+
compileSafePattern(value.pattern);
|
|
280
|
+
normalized.pattern = value.pattern;
|
|
281
|
+
}
|
|
282
|
+
catch (error) {
|
|
283
|
+
diagnostic(diagnostics, 'schema', 'invalid-pattern', `pattern must use supported RE2 syntax: ${error instanceof Error ? error.message : String(error)}`, [...metadataPath, 'pattern']);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
if (value.options !== undefined) {
|
|
288
|
+
if (Array.isArray(value.options) && value.options.every((option) => typeof option === 'string')) {
|
|
289
|
+
normalized.options = [...value.options];
|
|
290
|
+
}
|
|
291
|
+
else {
|
|
292
|
+
diagnostic(diagnostics, 'schema', 'invalid-options', 'options must be an array of strings', [...metadataPath, 'options']);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
if (value.optionColors !== undefined) {
|
|
296
|
+
if (value.type !== 'select' && value.type !== 'multi-select') {
|
|
297
|
+
diagnostic(diagnostics, 'schema', 'invalid-optionColors', 'optionColors is only supported by select and multi-select descriptors', [...metadataPath, 'optionColors']);
|
|
298
|
+
}
|
|
299
|
+
else if (!isRecord(value.optionColors)) {
|
|
300
|
+
diagnostic(diagnostics, 'schema', 'invalid-optionColors', 'optionColors must map option values to built-in palette colors', [...metadataPath, 'optionColors']);
|
|
301
|
+
}
|
|
302
|
+
else {
|
|
303
|
+
const optionColors = Object.create(null);
|
|
304
|
+
for (const [option, color] of Object.entries(value.optionColors)) {
|
|
305
|
+
if (typeof color === 'string' && JSON_VIEW_OPTION_COLORS.includes(color)) {
|
|
306
|
+
optionColors[option] = color;
|
|
307
|
+
}
|
|
308
|
+
else {
|
|
309
|
+
diagnostic(diagnostics, 'schema', 'invalid-option-color', `Color for ${JSON.stringify(option)} must be one of ${JSON_VIEW_OPTION_COLORS.join(', ')}`, [...metadataPath, 'optionColors', option]);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
normalized.optionColors = optionColors;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
if (value.placeholder !== undefined) {
|
|
316
|
+
if (typeof value.placeholder === 'string')
|
|
317
|
+
normalized.placeholder = value.placeholder;
|
|
318
|
+
else
|
|
319
|
+
diagnostic(diagnostics, 'schema', 'invalid-placeholder', 'placeholder must be a string', [...metadataPath, 'placeholder']);
|
|
320
|
+
}
|
|
321
|
+
if (value.multiline !== undefined) {
|
|
322
|
+
if (typeof value.multiline === 'boolean')
|
|
323
|
+
normalized.multiline = value.multiline;
|
|
324
|
+
else
|
|
325
|
+
diagnostic(diagnostics, 'schema', 'invalid-multiline', 'multiline must be a boolean', [...metadataPath, 'multiline']);
|
|
326
|
+
}
|
|
327
|
+
if (value.step !== undefined) {
|
|
328
|
+
if (typeof value.step === 'number' && Number.isFinite(value.step) && value.step > 0)
|
|
329
|
+
normalized.step = value.step;
|
|
330
|
+
else
|
|
331
|
+
diagnostic(diagnostics, 'schema', 'invalid-step', 'step must be a positive finite number', [...metadataPath, 'step']);
|
|
332
|
+
}
|
|
333
|
+
if (typeof normalized.minimum === 'number' && typeof normalized.maximum === 'number' && normalized.minimum > normalized.maximum) {
|
|
334
|
+
diagnostic(diagnostics, 'schema', 'invalid-number-bounds', 'minimum must not exceed maximum', metadataPath);
|
|
335
|
+
delete normalized.minimum;
|
|
336
|
+
delete normalized.maximum;
|
|
337
|
+
}
|
|
338
|
+
const descriptorIssue = typeRegistry.validateDescriptor(normalized);
|
|
339
|
+
if (descriptorIssue) {
|
|
340
|
+
diagnostic(diagnostics, 'schema', 'invalid-type-descriptor', descriptorIssue, metadataPath);
|
|
341
|
+
return undefined;
|
|
342
|
+
}
|
|
343
|
+
return normalized;
|
|
344
|
+
}
|
|
345
|
+
function schemaSpecificity(path) {
|
|
346
|
+
return [path.segments.filter((segment) => segment.kind !== 'wildcard').length, path.segments.length];
|
|
347
|
+
}
|
|
348
|
+
function pathsOverlap(left, right) {
|
|
349
|
+
if (left.root !== right.root || left.segments.length !== right.segments.length)
|
|
350
|
+
return false;
|
|
351
|
+
return left.segments.every((segment, index) => {
|
|
352
|
+
const other = right.segments[index];
|
|
353
|
+
if (segment.kind === 'wildcard')
|
|
354
|
+
return true;
|
|
355
|
+
if (other.kind === 'wildcard')
|
|
356
|
+
return true;
|
|
357
|
+
if (segment.kind !== other.kind)
|
|
358
|
+
return false;
|
|
359
|
+
return segment.kind === 'property'
|
|
360
|
+
? segment.key === other.key
|
|
361
|
+
: segment.index === other.index;
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
function canonicalValue(value) {
|
|
365
|
+
if (Array.isArray(value))
|
|
366
|
+
return `[${value.map(canonicalValue).join(',')}]`;
|
|
367
|
+
if (isRecord(value))
|
|
368
|
+
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalValue(value[key])}`).join(',')}}`;
|
|
369
|
+
return JSON.stringify(value);
|
|
370
|
+
}
|
|
371
|
+
export function matchesJsonViewPath(path, sourcePath) {
|
|
372
|
+
if (path.root !== '$' || path.segments.length !== sourcePath.length)
|
|
373
|
+
return false;
|
|
374
|
+
return path.segments.every((segment, index) => {
|
|
375
|
+
const sourceSegment = sourcePath[index];
|
|
376
|
+
if (segment.kind === 'wildcard')
|
|
377
|
+
return true;
|
|
378
|
+
if (segment.kind === 'property')
|
|
379
|
+
return segment.key === sourceSegment;
|
|
380
|
+
return segment.index === sourceSegment;
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
export function schemaForJsonViewPath(schema, sourcePath) {
|
|
384
|
+
return schema
|
|
385
|
+
.filter((entry) => matchesJsonViewPath(entry.path, sourcePath))
|
|
386
|
+
.sort((left, right) => (right.specificity[0] - left.specificity[0]
|
|
387
|
+
|| right.specificity[1] - left.specificity[1]
|
|
388
|
+
|| left.declarationIndex - right.declarationIndex))[0];
|
|
389
|
+
}
|
|
390
|
+
function parseDeclaredPath(value, expectedRoot, allowWildcard, metadataPath, diagnostics, scope, code, details = {}) {
|
|
391
|
+
if (typeof value !== 'string') {
|
|
392
|
+
diagnostic(diagnostics, scope, code, 'Path must be a string', metadataPath, details);
|
|
393
|
+
return undefined;
|
|
394
|
+
}
|
|
395
|
+
try {
|
|
396
|
+
return parseJsonViewPath(value, { root: expectedRoot, allowWildcard });
|
|
397
|
+
}
|
|
398
|
+
catch (error) {
|
|
399
|
+
diagnostic(diagnostics, scope, code, error instanceof Error ? error.message : 'Invalid JSONPath', metadataPath, details);
|
|
400
|
+
return undefined;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
function compileSchema(value, diagnostics, typeRegistry) {
|
|
404
|
+
const schemaPath = ['$jsonviews', 'schema'];
|
|
405
|
+
if (value === undefined)
|
|
406
|
+
return [];
|
|
407
|
+
if (!isRecord(value)) {
|
|
408
|
+
diagnostic(diagnostics, 'metadata', 'invalid-schema-map', '$jsonviews.schema must be an object', schemaPath);
|
|
409
|
+
return [];
|
|
410
|
+
}
|
|
411
|
+
const compiled = [];
|
|
412
|
+
Object.entries(value).forEach(([declaration, descriptorValue], declarationIndex) => {
|
|
413
|
+
const metadataPath = [...schemaPath, declaration];
|
|
414
|
+
const path = parseDeclaredPath(declaration, '$', true, metadataPath, diagnostics, 'schema', 'invalid-schema-path', { declaration });
|
|
415
|
+
const descriptor = normalizeDescriptor(descriptorValue, metadataPath, diagnostics, typeRegistry);
|
|
416
|
+
if (!path || !descriptor)
|
|
417
|
+
return;
|
|
418
|
+
compiled.push({
|
|
419
|
+
declaration,
|
|
420
|
+
path,
|
|
421
|
+
descriptor,
|
|
422
|
+
declarationIndex,
|
|
423
|
+
specificity: schemaSpecificity(path),
|
|
424
|
+
metadataPath,
|
|
425
|
+
});
|
|
426
|
+
});
|
|
427
|
+
compiled.forEach((entry, index) => {
|
|
428
|
+
for (let previousIndex = 0; previousIndex < index; previousIndex += 1) {
|
|
429
|
+
const previous = compiled[previousIndex];
|
|
430
|
+
if (entry.specificity[0] !== previous.specificity[0]
|
|
431
|
+
|| entry.specificity[1] !== previous.specificity[1]
|
|
432
|
+
|| !pathsOverlap(entry.path, previous.path)
|
|
433
|
+
|| canonicalValue(entry.descriptor) === canonicalValue(previous.descriptor))
|
|
434
|
+
continue;
|
|
435
|
+
diagnostic(diagnostics, 'schema', 'schema-specificity-conflict', `Conflicts with equally specific declaration ${JSON.stringify(previous.declaration)}; the earlier declaration wins`, entry.metadataPath, { declaration: entry.declaration });
|
|
436
|
+
break;
|
|
437
|
+
}
|
|
438
|
+
});
|
|
439
|
+
// Legacy body annotations retain rich-text rendering without a singular record role.
|
|
440
|
+
compiled.forEach((entry) => {
|
|
441
|
+
if (entry.descriptor.type === 'body')
|
|
442
|
+
entry.descriptor = { ...entry.descriptor, type: 'markdown' };
|
|
443
|
+
});
|
|
444
|
+
return compiled;
|
|
445
|
+
}
|
|
446
|
+
function inferSchemaOptions(root, schema) {
|
|
447
|
+
for (const entry of schema) {
|
|
448
|
+
if (entry.descriptor.type !== 'select' && entry.descriptor.type !== 'multi-select')
|
|
449
|
+
continue;
|
|
450
|
+
const options = new Set(entry.descriptor.options ?? []);
|
|
451
|
+
for (const location of expandSchemaLocations(root, entry)) {
|
|
452
|
+
if (entry.descriptor.type === 'select' && typeof location.value === 'string' && location.value !== '') {
|
|
453
|
+
options.add(location.value);
|
|
454
|
+
}
|
|
455
|
+
if (entry.descriptor.type === 'multi-select' && Array.isArray(location.value)) {
|
|
456
|
+
location.value.forEach((value) => {
|
|
457
|
+
if (typeof value === 'string' && value !== '')
|
|
458
|
+
options.add(value);
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
entry.descriptor = { ...entry.descriptor, options: Array.from(options) };
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
function normalizeColumns(value, metadataPath, diagnostics, viewId) {
|
|
466
|
+
if (value === undefined)
|
|
467
|
+
return undefined;
|
|
468
|
+
if (!Array.isArray(value)) {
|
|
469
|
+
diagnostic(diagnostics, 'view', 'invalid-columns', 'columns must be an array', metadataPath, { viewId });
|
|
470
|
+
return undefined;
|
|
471
|
+
}
|
|
472
|
+
if (value.length === 0)
|
|
473
|
+
return undefined;
|
|
474
|
+
const columns = value.flatMap((raw, index) => {
|
|
475
|
+
const itemPath = [...metadataPath, index];
|
|
476
|
+
if (isRecord(raw))
|
|
477
|
+
diagnoseUnknownProperties(raw, ['label', 'path'], itemPath, diagnostics, 'view', 'unknown-view-property', { viewId });
|
|
478
|
+
const path = isRecord(raw) ? parseDeclaredPath(raw.path, '$', true, [...itemPath, 'path'], diagnostics, 'view', 'invalid-column-path', { viewId }) : undefined;
|
|
479
|
+
if (!isRecord(raw) || typeof raw.label !== 'string' || raw.label.trim() === '') {
|
|
480
|
+
diagnostic(diagnostics, 'view', 'invalid-column', 'Column requires a non-empty label', isRecord(raw) ? [...itemPath, 'label'] : itemPath, { viewId });
|
|
481
|
+
return [];
|
|
482
|
+
}
|
|
483
|
+
return path ? [{ label: raw.label, path, metadataPath: itemPath }] : [];
|
|
484
|
+
});
|
|
485
|
+
return columns.length > 0 ? columns : undefined;
|
|
486
|
+
}
|
|
487
|
+
function normalizeFilter(value, metadataPath, diagnostics, viewId) {
|
|
488
|
+
if (value === undefined)
|
|
489
|
+
return undefined;
|
|
490
|
+
if (isRecord(value))
|
|
491
|
+
diagnoseUnknownProperties(value, ['match', 'rules'], metadataPath, diagnostics, 'view', 'unknown-view-property', { viewId });
|
|
492
|
+
if (!isRecord(value)) {
|
|
493
|
+
diagnostic(diagnostics, 'view', 'invalid-filter', 'filter requires an object with a rules array', metadataPath, { viewId });
|
|
494
|
+
return undefined;
|
|
495
|
+
}
|
|
496
|
+
if (value.match !== undefined && value.match !== 'all' && value.match !== 'any') {
|
|
497
|
+
diagnostic(diagnostics, 'view', 'invalid-filter', 'filter.match must be "all" or "any"', [...metadataPath, 'match'], { viewId });
|
|
498
|
+
}
|
|
499
|
+
if (!Array.isArray(value.rules)) {
|
|
500
|
+
diagnostic(diagnostics, 'view', 'invalid-filter', 'filter.rules must be an array', [...metadataPath, 'rules'], { viewId });
|
|
501
|
+
return undefined;
|
|
502
|
+
}
|
|
503
|
+
const match = value.match === 'any' ? 'any' : 'all';
|
|
504
|
+
const rules = value.rules.flatMap((raw, index) => {
|
|
505
|
+
const itemPath = [...metadataPath, 'rules', index];
|
|
506
|
+
if (isRecord(raw))
|
|
507
|
+
diagnoseUnknownProperties(raw, ['path', 'operator', 'value'], itemPath, diagnostics, 'view', 'unknown-view-property', { viewId });
|
|
508
|
+
const path = isRecord(raw) ? parseDeclaredPath(raw.path, '$', true, [...itemPath, 'path'], diagnostics, 'view', 'invalid-filter-path', { viewId }) : undefined;
|
|
509
|
+
if (!isRecord(raw) || typeof raw.operator !== 'string' || !raw.operator.trim()) {
|
|
510
|
+
diagnostic(diagnostics, 'view', 'invalid-filter-rule', 'Filter rule requires a non-empty operator', isRecord(raw) ? [...itemPath, 'operator'] : itemPath, { viewId });
|
|
511
|
+
return [];
|
|
512
|
+
}
|
|
513
|
+
const operator = raw.operator;
|
|
514
|
+
if (!PRESENCE_OPERATORS.has(operator) && !own(raw, 'value')) {
|
|
515
|
+
diagnostic(diagnostics, 'view', 'invalid-filter-value', `${operator} requires a comparison value`, [...itemPath, 'value'], { viewId });
|
|
516
|
+
return [];
|
|
517
|
+
}
|
|
518
|
+
if ((operator === 'in' || operator === 'notIn') && !Array.isArray(raw.value)) {
|
|
519
|
+
diagnostic(diagnostics, 'view', 'invalid-filter-value', `${operator} requires an array value`, [...itemPath, 'value'], { viewId });
|
|
520
|
+
return [];
|
|
521
|
+
}
|
|
522
|
+
if (PRESENCE_OPERATORS.has(operator) && own(raw, 'value')) {
|
|
523
|
+
diagnostic(diagnostics, 'view', 'unused-filter-value', `${operator} ignores value`, [...itemPath, 'value'], { viewId, severity: 'warning' });
|
|
524
|
+
}
|
|
525
|
+
if (!path)
|
|
526
|
+
return [];
|
|
527
|
+
return [{
|
|
528
|
+
path,
|
|
529
|
+
operator,
|
|
530
|
+
...(PRESENCE_OPERATORS.has(operator) ? {} : { value: raw.value }),
|
|
531
|
+
metadataPath: itemPath,
|
|
532
|
+
}];
|
|
533
|
+
});
|
|
534
|
+
return { match, rules };
|
|
535
|
+
}
|
|
536
|
+
function normalizeSort(value, metadataPath, diagnostics, viewId) {
|
|
537
|
+
if (value === undefined)
|
|
538
|
+
return [];
|
|
539
|
+
if (!Array.isArray(value)) {
|
|
540
|
+
diagnostic(diagnostics, 'view', 'invalid-sort', 'sort must be an array', metadataPath, { viewId });
|
|
541
|
+
return [];
|
|
542
|
+
}
|
|
543
|
+
return value.flatMap((raw, index) => {
|
|
544
|
+
const itemPath = [...metadataPath, index];
|
|
545
|
+
if (isRecord(raw))
|
|
546
|
+
diagnoseUnknownProperties(raw, ['path', 'direction'], itemPath, diagnostics, 'view', 'unknown-view-property', { viewId });
|
|
547
|
+
const path = isRecord(raw) ? parseDeclaredPath(raw.path, '$', true, [...itemPath, 'path'], diagnostics, 'view', 'invalid-sort-path', { viewId }) : undefined;
|
|
548
|
+
if (!isRecord(raw) || (raw.direction !== 'asc' && raw.direction !== 'desc')) {
|
|
549
|
+
diagnostic(diagnostics, 'view', 'invalid-sort-entry', 'Sort entry requires direction "asc" or "desc"', isRecord(raw) ? [...itemPath, 'direction'] : itemPath, { viewId });
|
|
550
|
+
return [];
|
|
551
|
+
}
|
|
552
|
+
return path ? [{ path, direction: raw.direction, metadataPath: itemPath }] : [];
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
function normalizeGroupOrder(value, metadataPath, diagnostics, viewId) {
|
|
556
|
+
if (value === undefined)
|
|
557
|
+
return undefined;
|
|
558
|
+
if (!Array.isArray(value)) {
|
|
559
|
+
diagnostic(diagnostics, 'view', 'invalid-group-order', 'groupOrder must be an array of scalar group values', metadataPath, { viewId });
|
|
560
|
+
return undefined;
|
|
561
|
+
}
|
|
562
|
+
const groups = [];
|
|
563
|
+
const seen = new Set();
|
|
564
|
+
value.forEach((group, index) => {
|
|
565
|
+
const valid = group === null || typeof group === 'string' || typeof group === 'boolean'
|
|
566
|
+
|| (typeof group === 'number' && Number.isFinite(group));
|
|
567
|
+
if (!valid) {
|
|
568
|
+
diagnostic(diagnostics, 'view', 'invalid-group-order', 'Each groupOrder entry must be a string, finite number, boolean, or null', [...metadataPath, index], { viewId });
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
const key = group === null || group === '' ? 'empty' : `${typeof group}:${JSON.stringify(group)}`;
|
|
572
|
+
if (seen.has(key)) {
|
|
573
|
+
diagnostic(diagnostics, 'view', 'invalid-group-order', 'groupOrder values must be unique', [...metadataPath, index], { viewId });
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
seen.add(key);
|
|
577
|
+
groups.push(group);
|
|
578
|
+
});
|
|
579
|
+
return groups;
|
|
580
|
+
}
|
|
581
|
+
function supportsRecordCollection(value) {
|
|
582
|
+
if (Array.isArray(value))
|
|
583
|
+
return value.every(isRecord);
|
|
584
|
+
return isRecord(value) && Object.values(value).every(isRecord);
|
|
585
|
+
}
|
|
586
|
+
function samePathSegment(left, right) {
|
|
587
|
+
if (left.kind !== right.kind)
|
|
588
|
+
return false;
|
|
589
|
+
if (left.kind === 'property')
|
|
590
|
+
return left.key === right.key;
|
|
591
|
+
if (left.kind === 'index')
|
|
592
|
+
return left.index === right.index;
|
|
593
|
+
return true;
|
|
594
|
+
}
|
|
595
|
+
function validatesAsViewField(viewPath, fieldPath) {
|
|
596
|
+
if (fieldPath.segments.length <= viewPath.segments.length + 1)
|
|
597
|
+
return false;
|
|
598
|
+
if (!viewPath.segments.every((segment, index) => samePathSegment(segment, fieldPath.segments[index])))
|
|
599
|
+
return false;
|
|
600
|
+
if (fieldPath.segments[viewPath.segments.length]?.kind !== 'wildcard')
|
|
601
|
+
return false;
|
|
602
|
+
return !fieldPath.segments.slice(viewPath.segments.length + 1).some((segment) => segment.kind === 'wildcard');
|
|
603
|
+
}
|
|
604
|
+
function validateViewField(root, rows, viewPath, fieldPath, metadataPath, diagnostics, viewId, code) {
|
|
605
|
+
const fieldHelp = () => {
|
|
606
|
+
const keys = [...new Set(rows.slice(0, 20).flatMap((row) => Object.keys(row.value)))].slice(0, 12);
|
|
607
|
+
const examples = keys.filter((key) => /^[A-Za-z_$][\w$]*$/.test(key)).map((key) => `${viewPath.source}[*].${key}`);
|
|
608
|
+
return {
|
|
609
|
+
fix: `Set this path to an existing field under ${viewPath.source}[*]. Use a concrete nested suffix for nested fields.`,
|
|
610
|
+
expected: `${viewPath.source}, followed by one [*], then a nonempty concrete field path`,
|
|
611
|
+
...(examples.length ? { examples } : {}),
|
|
612
|
+
capabilities: [
|
|
613
|
+
'Use absolute $ paths, not @ relative paths. No additional wildcard may appear after the record selector.',
|
|
614
|
+
'The path must resolve on at least one current record; an empty collection has no fields to check.',
|
|
615
|
+
...(keys.length ? [`Record keys sampled from up to 20 rows (not exhaustive): ${keys.map((key) => JSON.stringify(key)).join(', ')}.`] : []),
|
|
616
|
+
],
|
|
617
|
+
};
|
|
618
|
+
};
|
|
619
|
+
if (!validatesAsViewField(viewPath, fieldPath)) {
|
|
620
|
+
diagnostic(diagnostics, 'view', code, `Path ${JSON.stringify(fieldPath.source)} must select one field from each record under ${JSON.stringify(viewPath.source)}, for example ${viewPath.source}[*].field`, metadataPath, { viewId, help: fieldHelp() });
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
if (rows.length === 0)
|
|
624
|
+
return;
|
|
625
|
+
const resolved = rows.map((row) => resolveJsonViewRowPath(root, row, fieldPath));
|
|
626
|
+
if (resolved.every((item) => item.value === JSON_VIEW_PATH_MISSING)) {
|
|
627
|
+
diagnostic(diagnostics, 'view', code, `Path ${JSON.stringify(fieldPath.source)} does not resolve on any record selected by ${JSON.stringify(viewPath.source)}`, metadataPath, { viewId, sourcePath: resolved[0]?.sourcePath, help: fieldHelp() });
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
function reserveViewId(raw, declarationIndex, usedIds) {
|
|
631
|
+
const requested = typeof raw.id === 'string' && raw.id.trim() ? raw.id.trim() : `view-${declarationIndex + 1}`;
|
|
632
|
+
let id = requested;
|
|
633
|
+
let suffix = 2;
|
|
634
|
+
while (usedIds.has(id)) {
|
|
635
|
+
id = `${requested}-${suffix}`;
|
|
636
|
+
suffix += 1;
|
|
637
|
+
}
|
|
638
|
+
usedIds.add(id);
|
|
639
|
+
return id;
|
|
640
|
+
}
|
|
641
|
+
function compileViews(root, value, diagnostics, schema, typeRegistry) {
|
|
642
|
+
const viewsPath = ['$jsonviews', 'views'];
|
|
643
|
+
if (value === undefined)
|
|
644
|
+
return [];
|
|
645
|
+
if (!Array.isArray(value)) {
|
|
646
|
+
diagnostic(diagnostics, 'metadata', 'invalid-views', '$jsonviews.views must be an array', viewsPath);
|
|
647
|
+
return [];
|
|
648
|
+
}
|
|
649
|
+
const usedIds = new Set();
|
|
650
|
+
return value.flatMap((raw, declarationIndex) => {
|
|
651
|
+
const metadataPath = [...viewsPath, declarationIndex];
|
|
652
|
+
if (!isRecord(raw)) {
|
|
653
|
+
diagnostic(diagnostics, 'view', 'invalid-view', 'View declaration must be an object', metadataPath);
|
|
654
|
+
return [];
|
|
655
|
+
}
|
|
656
|
+
const viewDiagnostics = [];
|
|
657
|
+
if (Object.hasOwn(raw, 'title'))
|
|
658
|
+
diagnostic(viewDiagnostics, 'view', 'removed-view-title', 'View title is not supported; put the identifying field first in columns', [...metadataPath, 'title']);
|
|
659
|
+
const requestedId = typeof raw.id === 'string' ? raw.id.trim() : '';
|
|
660
|
+
const duplicateId = requestedId !== '' && usedIds.has(requestedId);
|
|
661
|
+
const id = reserveViewId(raw, declarationIndex, usedIds);
|
|
662
|
+
diagnoseUnknownProperties(raw, VIEW_PROPERTIES, metadataPath, viewDiagnostics, 'view', 'unknown-view-property', { viewId: id });
|
|
663
|
+
if (raw.id !== undefined && !requestedId) {
|
|
664
|
+
diagnostic(viewDiagnostics, 'view', 'invalid-view-id', 'View id must be a non-empty string when supplied', [...metadataPath, 'id'], { viewId: id, severity: 'warning' });
|
|
665
|
+
}
|
|
666
|
+
else if (duplicateId) {
|
|
667
|
+
diagnostic(viewDiagnostics, 'view', 'duplicate-view-id', `Duplicate view id ${JSON.stringify(requestedId)} was assigned ${JSON.stringify(id)}`, [...metadataPath, 'id'], { viewId: id, severity: 'warning' });
|
|
668
|
+
}
|
|
669
|
+
const name = typeof raw.name === 'string' ? raw.name.trim() : '';
|
|
670
|
+
if (!name)
|
|
671
|
+
diagnostic(viewDiagnostics, 'view', 'invalid-view', 'View requires a non-empty name', [...metadataPath, 'name'], { viewId: id });
|
|
672
|
+
const path = parseDeclaredPath(raw.path, '$', false, [...metadataPath, 'path'], viewDiagnostics, 'view', 'invalid-view-path', { viewId: id });
|
|
673
|
+
const groupBy = raw.groupBy === undefined ? undefined : parseDeclaredPath(raw.groupBy, '$', true, [...metadataPath, 'groupBy'], viewDiagnostics, 'view', 'invalid-group-path', { viewId: id });
|
|
674
|
+
const groupOrder = normalizeGroupOrder(raw.groupOrder, [...metadataPath, 'groupOrder'], viewDiagnostics, id);
|
|
675
|
+
const orderPath = raw.orderPath === undefined ? undefined : parseDeclaredPath(raw.orderPath, '$', true, [...metadataPath, 'orderPath'], viewDiagnostics, 'view', 'invalid-order-path', { viewId: id });
|
|
676
|
+
const columns = normalizeColumns(raw.columns, [...metadataPath, 'columns'], viewDiagnostics, id);
|
|
677
|
+
let filter = normalizeFilter(raw.filter, [...metadataPath, 'filter'], viewDiagnostics, id);
|
|
678
|
+
const sort = normalizeSort(raw.sort, [...metadataPath, 'sort'], viewDiagnostics, id);
|
|
679
|
+
if (raw.display !== undefined && raw.display !== 'kanban' && raw.display !== 'html') {
|
|
680
|
+
diagnostic(viewDiagnostics, 'view', 'invalid-display', 'Supported display overrides are "kanban" and "html"', [...metadataPath, 'display'], { viewId: id });
|
|
681
|
+
}
|
|
682
|
+
if (raw.display === 'html') {
|
|
683
|
+
if (typeof raw.html !== 'string' || !raw.html.trim())
|
|
684
|
+
diagnostic(viewDiagnostics, 'view', 'invalid-html', 'HTML views require a non-empty html string', [...metadataPath, 'html'], { viewId: id });
|
|
685
|
+
if (raw.css !== undefined && typeof raw.css !== 'string')
|
|
686
|
+
diagnostic(viewDiagnostics, 'view', 'invalid-html-css', 'css must be a string', [...metadataPath, 'css'], { viewId: id });
|
|
687
|
+
for (const property of ['html', 'css'])
|
|
688
|
+
if (typeof raw[property] === 'string' && new TextEncoder().encode(raw[property]).length > MAX_HTML_VIEW_BYTES)
|
|
689
|
+
diagnostic(viewDiagnostics, 'view', 'html-size-limit', 'HTML and CSS are limited to 256 KiB each', [...metadataPath, property], { viewId: id });
|
|
690
|
+
for (const property of ['columns', 'filter', 'sort', 'groupBy', 'groupOrder', 'orderPath'])
|
|
691
|
+
if (raw[property] !== undefined)
|
|
692
|
+
diagnostic(viewDiagnostics, 'view', 'incompatible-html', `${property} does not apply to HTML views`, [...metadataPath, property], { viewId: id });
|
|
693
|
+
}
|
|
694
|
+
else if (raw.html !== undefined || raw.css !== undefined)
|
|
695
|
+
diagnostic(viewDiagnostics, 'view', 'incompatible-html', 'html and css require display: "html"', metadataPath, { viewId: id });
|
|
696
|
+
// Report independent syntax errors together; defer data-dependent checks until the root resolves.
|
|
697
|
+
const resolved = path ? resolveJsonViewPath(root, path) : undefined;
|
|
698
|
+
if (path && resolved?.value === JSON_VIEW_PATH_MISSING) {
|
|
699
|
+
diagnostic(viewDiagnostics, 'view', 'unresolved-view-path', `View path ${JSON.stringify(path.source)} does not exist`, [...metadataPath, 'path'], { viewId: id, sourcePath: resolved.sourcePath });
|
|
700
|
+
}
|
|
701
|
+
if (!path || !resolved || resolved.value === JSON_VIEW_PATH_MISSING) {
|
|
702
|
+
diagnostics.push(...viewDiagnostics);
|
|
703
|
+
return [];
|
|
704
|
+
}
|
|
705
|
+
const recordCollection = supportsRecordCollection(resolved.value);
|
|
706
|
+
const rows = recordCollection ? rowsForRecordCollection(resolved.value, resolved.sourcePath) : [];
|
|
707
|
+
if (filter) {
|
|
708
|
+
const rules = filter.rules.filter((rule) => {
|
|
709
|
+
const descriptors = rows.length > 0 && validatesAsViewField(path, rule.path)
|
|
710
|
+
? rows.map((row) => schemaForJsonViewPath(schema, resolveJsonViewRowPath(root, row, rule.path).sourcePath)?.descriptor)
|
|
711
|
+
: [[...schema].filter((entry) => pathsOverlap(entry.path, rule.path))
|
|
712
|
+
.sort((left, right) => right.specificity[0] - left.specificity[0] || left.declarationIndex - right.declarationIndex)[0]?.descriptor];
|
|
713
|
+
const unsupported = descriptors.findIndex((descriptor) => {
|
|
714
|
+
const operators = descriptor ? typeRegistry.filterOperators(descriptor) : undefined;
|
|
715
|
+
return !(operators ? operators.includes(rule.operator) : FILTER_OPERATORS.has(rule.operator));
|
|
716
|
+
});
|
|
717
|
+
if (unsupported < 0) {
|
|
718
|
+
if (!PRESENCE_OPERATORS.has(rule.operator) && descriptors.some((descriptor) => descriptor?.type === 'date')
|
|
719
|
+
&& !['date', 'datetime', 'floating-datetime'].includes(parseDateValue(rule.value).kind)) {
|
|
720
|
+
diagnostic(viewDiagnostics, 'view', 'invalid-filter-value', 'Date comparison requires a valid date or timestamp string', [...rule.metadataPath, 'value'], { viewId: id, help: {
|
|
721
|
+
fix: 'Set value to a valid calendar date or timestamp with seconds. To check for missing dates use isEmpty/isNotEmpty without value.',
|
|
722
|
+
expected: 'YYYY-MM-DD or YYYY-MM-DDTHH:mm:ss with optional fractional seconds and timezone offset',
|
|
723
|
+
examples: ['2026-01-01', '2026-01-01T09:30:00Z'],
|
|
724
|
+
} });
|
|
725
|
+
return false;
|
|
726
|
+
}
|
|
727
|
+
return true;
|
|
728
|
+
}
|
|
729
|
+
diagnostic(viewDiagnostics, 'view', 'invalid-filter-operator', `${rule.operator} is not supported for ${descriptors[unsupported]?.type ?? 'untyped'} values`, [...rule.metadataPath, 'operator'], { viewId: id, help: {
|
|
730
|
+
fix: 'Choose an operator supported by every applicable field type.',
|
|
731
|
+
expected: `operator supported by ${descriptors[unsupported]?.type ?? 'untyped'} values`,
|
|
732
|
+
allowedValues: [...new Set(descriptors.flatMap((descriptor) => descriptor ? [...(typeRegistry.filterOperators(descriptor) ?? COMMON_FILTER_OPERATORS)] : [...COMMON_FILTER_OPERATORS]))]
|
|
733
|
+
.filter((operator) => descriptors.every((descriptor) => (descriptor ? typeRegistry.filterOperators(descriptor) ?? COMMON_FILTER_OPERATORS : COMMON_FILTER_OPERATORS).includes(operator))),
|
|
734
|
+
} });
|
|
735
|
+
return false;
|
|
736
|
+
});
|
|
737
|
+
filter = { ...filter, rules };
|
|
738
|
+
}
|
|
739
|
+
const hasRecordConfiguration = columns !== undefined || filter !== undefined || sort.length > 0
|
|
740
|
+
|| groupBy !== undefined || groupOrder !== undefined || orderPath !== undefined;
|
|
741
|
+
if (orderPath && groupBy && canonicalValue(orderPath.segments) === canonicalValue(groupBy.segments)) {
|
|
742
|
+
diagnostic(viewDiagnostics, 'view', 'conflicting-kanban-paths', 'orderPath must be different from groupBy', [...metadataPath, 'orderPath'], { viewId: id });
|
|
743
|
+
}
|
|
744
|
+
if (hasRecordConfiguration && !recordCollection) {
|
|
745
|
+
diagnostic(viewDiagnostics, 'view', 'incompatible-view', 'Configured fields require an array of objects or dictionary of object records', metadataPath, { viewId: id, sourcePath: resolved.sourcePath });
|
|
746
|
+
}
|
|
747
|
+
if (recordCollection) {
|
|
748
|
+
columns?.forEach((column) => validateViewField(root, rows, path, column.path, [...column.metadataPath, 'path'], viewDiagnostics, id, 'invalid-column-path'));
|
|
749
|
+
filter?.rules.forEach((rule) => validateViewField(root, rows, path, rule.path, [...rule.metadataPath, 'path'], viewDiagnostics, id, 'invalid-filter-path'));
|
|
750
|
+
sort.forEach((entry) => validateViewField(root, rows, path, entry.path, [...entry.metadataPath, 'path'], viewDiagnostics, id, 'invalid-sort-path'));
|
|
751
|
+
if (groupBy)
|
|
752
|
+
validateViewField(root, rows, path, groupBy, [...metadataPath, 'groupBy'], viewDiagnostics, id, 'invalid-group-path');
|
|
753
|
+
if (orderPath)
|
|
754
|
+
validateViewField(root, rows, path, orderPath, [...metadataPath, 'orderPath'], viewDiagnostics, id, 'invalid-order-path');
|
|
755
|
+
}
|
|
756
|
+
if (orderPath && recordCollection && validatesAsViewField(path, orderPath)) {
|
|
757
|
+
for (const row of rows) {
|
|
758
|
+
const order = resolveJsonViewRowPath(root, row, orderPath);
|
|
759
|
+
if (order.value === JSON_VIEW_PATH_MISSING || typeof order.value !== 'number' || !Number.isFinite(order.value)) {
|
|
760
|
+
diagnostic(viewDiagnostics, 'view', 'invalid-order-value', 'orderPath must resolve to a finite number on every record', [...metadataPath, 'orderPath'], { viewId: id, sourcePath: order.sourcePath });
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
let display = 'adaptive';
|
|
765
|
+
if (raw.display === 'html') {
|
|
766
|
+
display = 'html';
|
|
767
|
+
}
|
|
768
|
+
else if (raw.display === 'kanban') {
|
|
769
|
+
if (!groupBy || !recordCollection) {
|
|
770
|
+
diagnostic(viewDiagnostics, 'view', 'incompatible-kanban', 'Kanban requires groupBy and a record collection', [...metadataPath, 'display'], { viewId: id, sourcePath: resolved.sourcePath });
|
|
771
|
+
}
|
|
772
|
+
else {
|
|
773
|
+
display = 'kanban';
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
else if (groupBy || groupOrder || orderPath) {
|
|
777
|
+
diagnostic(viewDiagnostics, 'view', 'incompatible-kanban', 'groupBy, groupOrder, and orderPath are only valid for a Kanban view', metadataPath, { viewId: id });
|
|
778
|
+
}
|
|
779
|
+
diagnostics.push(...viewDiagnostics);
|
|
780
|
+
if (viewDiagnostics.some((item) => item.severity !== 'warning'))
|
|
781
|
+
return [];
|
|
782
|
+
return [{
|
|
783
|
+
id,
|
|
784
|
+
name,
|
|
785
|
+
path,
|
|
786
|
+
sourcePath: resolved.sourcePath,
|
|
787
|
+
value: resolved.value,
|
|
788
|
+
display,
|
|
789
|
+
...(display === 'html' ? { html: raw.html, css: raw.css ?? '' } : {}),
|
|
790
|
+
...(groupBy ? { groupBy } : {}),
|
|
791
|
+
...(groupOrder !== undefined ? { groupOrder } : {}),
|
|
792
|
+
...(orderPath ? { orderPath } : {}),
|
|
793
|
+
...(columns !== undefined ? { columns } : {}),
|
|
794
|
+
...(filter ? { filter } : {}),
|
|
795
|
+
sort,
|
|
796
|
+
declarationIndex,
|
|
797
|
+
metadataPath,
|
|
798
|
+
}];
|
|
799
|
+
});
|
|
800
|
+
}
|
|
801
|
+
function expandSchemaLocations(root, entry) {
|
|
802
|
+
const results = [];
|
|
803
|
+
const visit = (value, segmentIndex, sourcePath) => {
|
|
804
|
+
if (segmentIndex === entry.path.segments.length) {
|
|
805
|
+
results.push({ value, sourcePath });
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
const segment = entry.path.segments[segmentIndex];
|
|
809
|
+
const missing = () => {
|
|
810
|
+
const remainder = entry.path.segments.slice(segmentIndex);
|
|
811
|
+
const concrete = [...sourcePath];
|
|
812
|
+
for (const part of remainder) {
|
|
813
|
+
if (part.kind === 'wildcard')
|
|
814
|
+
break;
|
|
815
|
+
concrete.push(part.kind === 'property' ? part.key : part.index);
|
|
816
|
+
}
|
|
817
|
+
results.push({ value: JSON_VIEW_PATH_MISSING, sourcePath: concrete });
|
|
818
|
+
};
|
|
819
|
+
if (segment.kind === 'property') {
|
|
820
|
+
if (!isRecord(value)) {
|
|
821
|
+
missing();
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
if (own(value, segment.key))
|
|
825
|
+
visit(value[segment.key], segmentIndex + 1, [...sourcePath, segment.key]);
|
|
826
|
+
else
|
|
827
|
+
missing();
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
if (segment.kind === 'index') {
|
|
831
|
+
if (!Array.isArray(value)) {
|
|
832
|
+
missing();
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
835
|
+
if (own(value, String(segment.index)))
|
|
836
|
+
visit(value[segment.index], segmentIndex + 1, [...sourcePath, segment.index]);
|
|
837
|
+
else
|
|
838
|
+
missing();
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
if (Array.isArray(value)) {
|
|
842
|
+
value.forEach((child, index) => visit(child, segmentIndex + 1, [...sourcePath, index]));
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
if (isRecord(value)) {
|
|
846
|
+
Object.entries(value).forEach(([key, child]) => visit(child, segmentIndex + 1, [...sourcePath, key]));
|
|
847
|
+
}
|
|
848
|
+
else
|
|
849
|
+
missing();
|
|
850
|
+
};
|
|
851
|
+
visit(root, 0, []);
|
|
852
|
+
return results;
|
|
853
|
+
}
|
|
854
|
+
function validateResolvedSchemaValue(value, descriptor, entry, sourcePath, diagnostics, typeRegistry, suppressRequiredPaths) {
|
|
855
|
+
const details = { declaration: entry.declaration, sourcePath };
|
|
856
|
+
if (value === JSON_VIEW_PATH_MISSING) {
|
|
857
|
+
if (descriptor.required && !suppressRequiredPaths.some((path) => path.length <= sourcePath.length && path.every((part, index) => part === sourcePath[index]))) {
|
|
858
|
+
diagnostic(diagnostics, 'value', 'required-value-missing', 'Required value is missing', entry.metadataPath, details);
|
|
859
|
+
}
|
|
860
|
+
return;
|
|
861
|
+
}
|
|
862
|
+
const issue = typeRegistry.validate(value, descriptor);
|
|
863
|
+
const suppressRequired = suppressRequiredPaths.some((path) => path.length <= sourcePath.length && path.every((part, index) => part === sourcePath[index]));
|
|
864
|
+
if (issue && !(suppressRequired && issue === 'A value is required')) {
|
|
865
|
+
diagnostic(diagnostics, 'value', 'invalid-typed-value', issue, entry.metadataPath, details);
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
for (const warning of typeRegistry.warnings(value, descriptor)) {
|
|
869
|
+
diagnostic(diagnostics, 'value', 'typed-value-warning', warning, entry.metadataPath, { ...details, severity: 'warning' });
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
function validateSchemaValues(root, schema, diagnostics, typeRegistry, suppressRequiredPaths) {
|
|
873
|
+
const visited = new Set();
|
|
874
|
+
for (const entry of schema) {
|
|
875
|
+
for (const location of expandSchemaLocations(root, entry)) {
|
|
876
|
+
const winner = schemaForJsonViewPath(schema, location.sourcePath);
|
|
877
|
+
if (winner && winner !== entry)
|
|
878
|
+
continue;
|
|
879
|
+
const visitKey = JSON.stringify(location.sourcePath);
|
|
880
|
+
if (visited.has(visitKey))
|
|
881
|
+
continue;
|
|
882
|
+
visited.add(visitKey);
|
|
883
|
+
validateResolvedSchemaValue(location.value, entry.descriptor, entry, location.sourcePath, diagnostics, typeRegistry, suppressRequiredPaths);
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
function compileMetadataValue(root, metadata, typeRegistry, recognized, inference, metadataSource = 'embedded', suppressRequiredPaths = []) {
|
|
888
|
+
const diagnostics = [];
|
|
889
|
+
diagnoseUnknownProperties(metadata, ANNOTATION_PROPERTIES, ['$jsonviews'], diagnostics, 'metadata', 'unknown-annotation-property');
|
|
890
|
+
const schema = compileSchema(metadata.schema, diagnostics, typeRegistry);
|
|
891
|
+
inferSchemaOptions(root, schema);
|
|
892
|
+
const views = compileViews(root, metadata.views, diagnostics, schema, typeRegistry);
|
|
893
|
+
if (metadataSource === 'embedded') {
|
|
894
|
+
for (const view of views)
|
|
895
|
+
for (const rule of view.filter?.rules ?? []) {
|
|
896
|
+
rule.sourceValuePath = [...rule.metadataPath, 'value'];
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
// Inference is a presentation hint, not a user-authored validation contract.
|
|
900
|
+
// A sampled collection can contain later values that do not match the guessed
|
|
901
|
+
// type, and those values must not become upload-time errors.
|
|
902
|
+
if (metadataSource !== 'inferred') {
|
|
903
|
+
validateSchemaValues(root, schema, diagnostics, typeRegistry, suppressRequiredPaths);
|
|
904
|
+
}
|
|
905
|
+
return {
|
|
906
|
+
root,
|
|
907
|
+
metadata,
|
|
908
|
+
metadataSource,
|
|
909
|
+
status: 'ready',
|
|
910
|
+
recognized,
|
|
911
|
+
active: schema.length > 0 || views.length > 0 || diagnostics.length > 0,
|
|
912
|
+
schema,
|
|
913
|
+
views,
|
|
914
|
+
diagnostics: explainMetadataDiagnostics(diagnostics, root, metadata, typeRegistry, metadataSource, schema),
|
|
915
|
+
...(inference ? { inference } : {}),
|
|
916
|
+
};
|
|
917
|
+
}
|
|
918
|
+
export function compileJsonViewMetadata(root, typeRegistry = defaultTypeRegistry, options = {}) {
|
|
919
|
+
assertJsonDepth(root);
|
|
920
|
+
assertJsonDepth(options.metadata);
|
|
921
|
+
const inactive = () => ({
|
|
922
|
+
root, status: 'none', recognized: false, active: false, schema: [], views: [], diagnostics: [],
|
|
923
|
+
});
|
|
924
|
+
const supplied = (metadata, metadataSource) => {
|
|
925
|
+
if (isRecord(metadata) && metadata.version === 1) {
|
|
926
|
+
return compileMetadataValue(root, metadata, typeRegistry, true, undefined, metadataSource, options.suppressRequiredPaths);
|
|
927
|
+
}
|
|
928
|
+
const unsupported = isRecord(metadata) && Number.isInteger(metadata.version) && Number(metadata.version) > 0;
|
|
929
|
+
return {
|
|
930
|
+
...inactive(),
|
|
931
|
+
metadata,
|
|
932
|
+
metadataSource,
|
|
933
|
+
status: unsupported ? 'unsupported-version' : 'invalid',
|
|
934
|
+
diagnostics: explainMetadataDiagnostics([{
|
|
935
|
+
scope: 'metadata',
|
|
936
|
+
code: unsupported ? 'unsupported-metadata-version' : 'invalid-metadata',
|
|
937
|
+
message: unsupported ? `Unsupported JSON Views annotation version: ${String(metadata.version)}` : 'Annotations must be an object with version: 1',
|
|
938
|
+
metadataPath: unsupported ? ['$jsonviews', 'version'] : ['$jsonviews'],
|
|
939
|
+
}], root, metadata, typeRegistry, metadataSource),
|
|
940
|
+
};
|
|
941
|
+
};
|
|
942
|
+
if (Object.prototype.hasOwnProperty.call(options, 'metadata'))
|
|
943
|
+
return supplied(options.metadata, 'external');
|
|
944
|
+
if (isRecord(root)) {
|
|
945
|
+
if (own(root, '$jsonviews'))
|
|
946
|
+
return supplied(root.$jsonviews, 'embedded');
|
|
947
|
+
}
|
|
948
|
+
const inference = inferJsonViewMetadata(root);
|
|
949
|
+
if (Object.keys(inference.schema).length === 0 && inference.views.length === 0)
|
|
950
|
+
return inactive();
|
|
951
|
+
return compileMetadataValue(root, inference, typeRegistry, false, inference, 'inferred', options.suppressRequiredPaths);
|
|
952
|
+
}
|