@texuli/spec 0.7.0 → 0.9.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/README.md +73 -10
- package/dist/build.d.ts +3 -2
- package/dist/build.js +7 -3
- package/dist/catalog.d.ts +3 -2
- package/dist/catalog.js +3 -2
- package/dist/codegen/kotlin.js +3 -0
- package/dist/conformance.js +19 -1
- package/dist/contract.d.ts +4 -1
- package/dist/contract.js +9 -0
- package/dist/generated/typescript/blueprint.d.ts +286 -3
- package/dist/generated/typescript/blueprint.js +1 -1
- package/dist/generated/typescript/catalog-schema.d.ts +5 -0
- package/dist/generated/typescript/catalog-schema.js +1 -1
- package/dist/generated/typescript/diagnostics.d.ts +1 -1
- package/dist/generated/typescript/diagnostics.js +1 -1
- package/dist/generated/typescript/manifest.js +1 -1
- package/dist/generated/typescript/vocabulary.d.ts +41 -2
- package/dist/generated/typescript/vocabulary.js +7 -2
- package/dist/generated/typescript/workflow.js +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/migrations/0.7.0-0.8.0.d.ts +8 -0
- package/dist/migrations/0.7.0-0.8.0.js +11 -0
- package/dist/migrations/0.8.0-0.9.0.d.ts +8 -0
- package/dist/migrations/0.8.0-0.9.0.js +11 -0
- package/dist/migrations/index.js +4 -0
- package/dist/source.d.ts +90 -0
- package/dist/source.js +366 -0
- package/dist/validate.d.ts +9 -0
- package/dist/validate.js +47 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +5 -1
package/dist/source.js
ADDED
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Data-source bindings (spec 0.8.0, ADR-012 and ADR-015): a sourceable prop
|
|
3
|
+
* reads rows from a source the host registered,
|
|
4
|
+
* `{"$source": name, "query": {...}, "map": {...}}`. The schema
|
|
5
|
+
* (`$defs.sourceBinding`) fixes the closed shape; this module checks a
|
|
6
|
+
* binding against the host's source descriptors: the name, every field,
|
|
7
|
+
* operator and value of the query, the page size and the map. It mirrors
|
|
8
|
+
* `checkSourceQuery` in `@texuli/core/data-source` (the spec cannot import
|
|
9
|
+
* core: both are L0), and a parity test keeps the two in agreement.
|
|
10
|
+
* With no descriptors a binding is refused: the spec fails closed.
|
|
11
|
+
*/
|
|
12
|
+
import { isRecord } from "./dialect.js";
|
|
13
|
+
/** The binding key that reads a host data source: `{ "$source": name, … }`. */
|
|
14
|
+
export const SOURCE_BINDING_KEY = '$source';
|
|
15
|
+
/** The members a binding takes besides `$source`. */
|
|
16
|
+
export const SOURCE_BINDING_FIELDS = ['query', 'map'];
|
|
17
|
+
export const SPEC_SOURCE_FIELD_TYPES = ['text', 'enum', 'number', 'date', 'boolean'];
|
|
18
|
+
export const SPEC_SOURCE_OPERATIONS = [
|
|
19
|
+
'list',
|
|
20
|
+
'get',
|
|
21
|
+
'create',
|
|
22
|
+
'update',
|
|
23
|
+
'delete',
|
|
24
|
+
'search',
|
|
25
|
+
];
|
|
26
|
+
/** The `@texuli/data` grid filter model's operators (`$defs.sourceFilter.operator`). */
|
|
27
|
+
export const SPEC_SOURCE_FILTER_OPERATORS = [
|
|
28
|
+
'eq',
|
|
29
|
+
'neq',
|
|
30
|
+
'contains',
|
|
31
|
+
'in',
|
|
32
|
+
'between',
|
|
33
|
+
'before',
|
|
34
|
+
'after',
|
|
35
|
+
'isEmpty',
|
|
36
|
+
'isNotEmpty',
|
|
37
|
+
];
|
|
38
|
+
/** Every bound of a binding; the schema writes the static ones too (`tests/source.test.ts`). */
|
|
39
|
+
export const SPEC_SOURCE_LIMITS = Object.freeze({
|
|
40
|
+
nameLength: 64,
|
|
41
|
+
projection: 32,
|
|
42
|
+
sort: 4,
|
|
43
|
+
searchLength: 200,
|
|
44
|
+
filterDepth: 6,
|
|
45
|
+
filterNodes: 64,
|
|
46
|
+
filterValues: 100,
|
|
47
|
+
valueLength: 500,
|
|
48
|
+
pageSize: 500,
|
|
49
|
+
mapEntries: 16,
|
|
50
|
+
});
|
|
51
|
+
export const SPEC_SOURCE_PATTERNS = Object.freeze({
|
|
52
|
+
sourceName: '^[a-z][a-z0-9]*([._-][a-z0-9]+)*$',
|
|
53
|
+
sourceFieldName: '^[A-Za-z][A-Za-z0-9_]*$',
|
|
54
|
+
});
|
|
55
|
+
const FIELD_RE = new RegExp(SPEC_SOURCE_PATTERNS.sourceFieldName, 'u');
|
|
56
|
+
const DATE_RE = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\d{1,9})?)?(Z|[+-]\d{2}:\d{2})?)?$/;
|
|
57
|
+
/** Whether a value is shaped like a source binding (`$source` as an own key). */
|
|
58
|
+
export function isSourceBinding(value) {
|
|
59
|
+
return isRecord(value) && Object.hasOwn(value, SOURCE_BINDING_KEY);
|
|
60
|
+
}
|
|
61
|
+
/** Whether `value` is an ISO 8601 date or date-time, or epoch milliseconds. */
|
|
62
|
+
export function isSpecSourceDate(value) {
|
|
63
|
+
if (typeof value === 'number')
|
|
64
|
+
return Number.isFinite(value);
|
|
65
|
+
return (typeof value === 'string' &&
|
|
66
|
+
value.length <= 35 &&
|
|
67
|
+
DATE_RE.test(value) &&
|
|
68
|
+
!Number.isNaN(Date.parse(value)));
|
|
69
|
+
}
|
|
70
|
+
function fits(field, value) {
|
|
71
|
+
if (value === null)
|
|
72
|
+
return true;
|
|
73
|
+
switch (field.type) {
|
|
74
|
+
case 'text':
|
|
75
|
+
return typeof value === 'string' && value.length <= SPEC_SOURCE_LIMITS.valueLength;
|
|
76
|
+
case 'enum':
|
|
77
|
+
return typeof value === 'string' && (field.options ?? []).includes(value);
|
|
78
|
+
case 'number':
|
|
79
|
+
return typeof value === 'number' && Number.isFinite(value);
|
|
80
|
+
case 'date':
|
|
81
|
+
return isSpecSourceDate(value);
|
|
82
|
+
case 'boolean':
|
|
83
|
+
return typeof value === 'boolean';
|
|
84
|
+
default:
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/** The descriptor's field by name, without touching the prototype chain. */
|
|
89
|
+
function fieldOf(source, name) {
|
|
90
|
+
if (typeof name !== 'string')
|
|
91
|
+
return undefined;
|
|
92
|
+
return source.fields.find((field) => field.name === name);
|
|
93
|
+
}
|
|
94
|
+
/** Over the filter's depth or node caps, measured without recursion. */
|
|
95
|
+
function filterOverLimits(input) {
|
|
96
|
+
const stack = [{ node: input, depth: 1 }];
|
|
97
|
+
let nodes = 0;
|
|
98
|
+
while (stack.length > 0) {
|
|
99
|
+
const { node, depth } = stack.pop();
|
|
100
|
+
nodes += 1;
|
|
101
|
+
if (depth > SPEC_SOURCE_LIMITS.filterDepth || nodes > SPEC_SOURCE_LIMITS.filterNodes) {
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
const children = isRecord(node) ? node.children : undefined;
|
|
105
|
+
if (!Array.isArray(children))
|
|
106
|
+
continue;
|
|
107
|
+
if (nodes + stack.length + children.length > SPEC_SOURCE_LIMITS.filterNodes)
|
|
108
|
+
return true;
|
|
109
|
+
for (const child of children)
|
|
110
|
+
stack.push({ node: child, depth: depth + 1 });
|
|
111
|
+
}
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
const NAME_RE = new RegExp(SPEC_SOURCE_PATTERNS.sourceName, 'u');
|
|
115
|
+
const QUERY_KEYS = ['fields', 'filter', 'sort', 'search', 'pageSize'];
|
|
116
|
+
function isFieldName(value) {
|
|
117
|
+
return typeof value === 'string' && value.length <= 64 && FIELD_RE.test(value);
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* The closed shape of `$defs.sourceBinding`, checked without the schema
|
|
121
|
+
* document (so `@texuli/catalog` can run it alone): the members, their
|
|
122
|
+
* types and the static limits. The filter's own members are judged with
|
|
123
|
+
* its meaning, below.
|
|
124
|
+
*/
|
|
125
|
+
function shapeProblem(binding) {
|
|
126
|
+
const extra = Object.keys(binding).find((key) => key !== SOURCE_BINDING_KEY && key !== 'query' && key !== 'map');
|
|
127
|
+
if (extra !== undefined)
|
|
128
|
+
return { at: `.${extra}`, message: `A data-source binding takes no "${extra}".` };
|
|
129
|
+
const name = binding[SOURCE_BINDING_KEY];
|
|
130
|
+
if (typeof name !== 'string' ||
|
|
131
|
+
name.length > SPEC_SOURCE_LIMITS.nameLength ||
|
|
132
|
+
!NAME_RE.test(name)) {
|
|
133
|
+
return {
|
|
134
|
+
at: `.${SOURCE_BINDING_KEY}`,
|
|
135
|
+
message: 'A source is named by a source name, never a URL.',
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
if (Object.hasOwn(binding, 'map')) {
|
|
139
|
+
const map = binding.map;
|
|
140
|
+
if (!isRecord(map) || Object.values(map).some((value) => !isFieldName(value))) {
|
|
141
|
+
return { at: '.map', message: 'map pairs item fields with source field names.' };
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (!Object.hasOwn(binding, 'query'))
|
|
145
|
+
return null;
|
|
146
|
+
const query = binding.query;
|
|
147
|
+
if (!isRecord(query))
|
|
148
|
+
return { at: '.query', message: 'A query is an object.' };
|
|
149
|
+
const unknown = Object.keys(query).find((key) => !QUERY_KEYS.includes(key));
|
|
150
|
+
if (unknown !== undefined)
|
|
151
|
+
return { at: `.query.${unknown}`, message: `A query takes no "${unknown}".` };
|
|
152
|
+
const { fields, sort, search, pageSize } = query;
|
|
153
|
+
if (fields !== undefined &&
|
|
154
|
+
(!Array.isArray(fields) ||
|
|
155
|
+
fields.length === 0 ||
|
|
156
|
+
fields.length > SPEC_SOURCE_LIMITS.projection ||
|
|
157
|
+
!fields.every(isFieldName))) {
|
|
158
|
+
return {
|
|
159
|
+
at: '.query.fields',
|
|
160
|
+
message: `fields lists 1 to ${SPEC_SOURCE_LIMITS.projection} field names.`,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
if (sort !== undefined) {
|
|
164
|
+
const keyOk = (entry) => isRecord(entry) &&
|
|
165
|
+
Object.keys(entry).every((key) => key === 'field' || key === 'direction') &&
|
|
166
|
+
isFieldName(entry.field) &&
|
|
167
|
+
(entry.direction === undefined || entry.direction === 'asc' || entry.direction === 'desc');
|
|
168
|
+
if (!Array.isArray(sort) ||
|
|
169
|
+
sort.length === 0 ||
|
|
170
|
+
sort.length > SPEC_SOURCE_LIMITS.sort ||
|
|
171
|
+
!sort.every(keyOk)) {
|
|
172
|
+
return { at: '.query.sort', message: `sort lists 1 to ${SPEC_SOURCE_LIMITS.sort} keys.` };
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
if (search !== undefined &&
|
|
176
|
+
(typeof search !== 'string' || search.length > SPEC_SOURCE_LIMITS.searchLength)) {
|
|
177
|
+
return {
|
|
178
|
+
at: '.query.search',
|
|
179
|
+
message: `search is text of at most ${SPEC_SOURCE_LIMITS.searchLength} characters.`,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
if (pageSize !== undefined &&
|
|
183
|
+
(typeof pageSize !== 'number' ||
|
|
184
|
+
!Number.isInteger(pageSize) ||
|
|
185
|
+
pageSize < 1 ||
|
|
186
|
+
pageSize > SPEC_SOURCE_LIMITS.pageSize)) {
|
|
187
|
+
return {
|
|
188
|
+
at: '.query.pageSize',
|
|
189
|
+
message: `pageSize is an integer from 1 to ${SPEC_SOURCE_LIMITS.pageSize}.`,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Check a binding: its closed shape, then its meaning against the host's
|
|
196
|
+
* sources (fail closed without them): the name, every field, operator and
|
|
197
|
+
* value of the query, the page size and the map. One issue per problem, up
|
|
198
|
+
* to 16. `@texuli/spec`'s validator runs the schema (`$defs.sourceBinding`)
|
|
199
|
+
* first; `@texuli/catalog` runs this alone.
|
|
200
|
+
*/
|
|
201
|
+
export function checkSourceBinding(binding, context) {
|
|
202
|
+
const issues = [];
|
|
203
|
+
const fail = (at, message, code = 'source') => {
|
|
204
|
+
if (issues.length < 16)
|
|
205
|
+
issues.push({ code, at, message });
|
|
206
|
+
};
|
|
207
|
+
const shape = shapeProblem(binding);
|
|
208
|
+
if (shape) {
|
|
209
|
+
fail(shape.at, shape.message);
|
|
210
|
+
return issues;
|
|
211
|
+
}
|
|
212
|
+
const name = binding[SOURCE_BINDING_KEY];
|
|
213
|
+
if (!context.sources) {
|
|
214
|
+
fail(`.${SOURCE_BINDING_KEY}`, 'No data sources were supplied, so a data-source binding is refused.');
|
|
215
|
+
return issues;
|
|
216
|
+
}
|
|
217
|
+
const source = typeof name === 'string' ? context.sources.find((entry) => entry.name === name) : undefined;
|
|
218
|
+
if (!source) {
|
|
219
|
+
fail(`.${SOURCE_BINDING_KEY}`, `The host registered no source "${String(name)}".`, 'unknown-source');
|
|
220
|
+
return issues;
|
|
221
|
+
}
|
|
222
|
+
const lists = source.operations.includes('list') || source.operations.includes('search');
|
|
223
|
+
if (!lists)
|
|
224
|
+
fail(`.${SOURCE_BINDING_KEY}`, `"${source.name}" cannot list rows.`);
|
|
225
|
+
const query = isRecord(binding.query) ? binding.query : {};
|
|
226
|
+
const known = (value, at) => {
|
|
227
|
+
const field = fieldOf(source, value);
|
|
228
|
+
if (!field)
|
|
229
|
+
fail(at, `"${source.name}" has no "${String(value)}" field.`);
|
|
230
|
+
return field;
|
|
231
|
+
};
|
|
232
|
+
if (Array.isArray(query.fields)) {
|
|
233
|
+
query.fields.forEach((entry, index) => {
|
|
234
|
+
known(entry, `.query.fields[${index}]`);
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
if (Array.isArray(query.sort)) {
|
|
238
|
+
const seen = new Set();
|
|
239
|
+
query.sort.forEach((entry, index) => {
|
|
240
|
+
if (!isRecord(entry))
|
|
241
|
+
return;
|
|
242
|
+
const field = known(entry.field, `.query.sort[${index}].field`);
|
|
243
|
+
if (!field)
|
|
244
|
+
return;
|
|
245
|
+
if (!field.sort)
|
|
246
|
+
fail(`.query.sort[${index}].field`, `"${field.name}" is not sortable.`);
|
|
247
|
+
else if (seen.has(field.name))
|
|
248
|
+
fail(`.query.sort[${index}].field`, `"${field.name}" is sorted twice.`);
|
|
249
|
+
seen.add(field.name);
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
if (typeof query.search === 'string' && !source.operations.includes('search')) {
|
|
253
|
+
fail('.query.search', `"${source.name}" does not search.`);
|
|
254
|
+
}
|
|
255
|
+
if (typeof query.pageSize === 'number' && query.pageSize > source.maxPageSize) {
|
|
256
|
+
fail('.query.pageSize', `"${source.name}" pages at most ${source.maxPageSize} rows.`);
|
|
257
|
+
}
|
|
258
|
+
if (Object.hasOwn(query, 'filter')) {
|
|
259
|
+
if (filterOverLimits(query.filter)) {
|
|
260
|
+
fail('.query.filter', `A filter has at most ${SPEC_SOURCE_LIMITS.filterDepth} levels and ${SPEC_SOURCE_LIMITS.filterNodes} nodes.`);
|
|
261
|
+
}
|
|
262
|
+
else {
|
|
263
|
+
checkFilter(query.filter, '.query.filter', source, fail, known);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
if (isRecord(binding.map)) {
|
|
267
|
+
const entries = Object.entries(binding.map);
|
|
268
|
+
if (entries.length > SPEC_SOURCE_LIMITS.mapEntries) {
|
|
269
|
+
fail('.map', `A map has at most ${SPEC_SOURCE_LIMITS.mapEntries} entries.`);
|
|
270
|
+
}
|
|
271
|
+
for (const [target, from] of entries) {
|
|
272
|
+
if (!FIELD_RE.test(target) || target.length > 64) {
|
|
273
|
+
fail(`.map.${target}`, `"${target}" is not an item field name.`);
|
|
274
|
+
}
|
|
275
|
+
else if (context.itemFields && !context.itemFields.includes(target)) {
|
|
276
|
+
fail(`.map.${target}`, `The rows of this prop have no "${target}" field.`);
|
|
277
|
+
}
|
|
278
|
+
known(from, `.map.${target}`);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return issues;
|
|
282
|
+
}
|
|
283
|
+
/** Recursion is safe here: the depth was capped at 6 first. */
|
|
284
|
+
function checkFilter(node, at, source, fail, known) {
|
|
285
|
+
if (!isRecord(node)) {
|
|
286
|
+
fail(at, 'A filter node is an object.');
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
const has = (key) => Object.hasOwn(node, key);
|
|
290
|
+
const unknown = Object.keys(node).find((key) => !['type', 'field', 'operator', 'value', 'op', 'children'].includes(key));
|
|
291
|
+
if (unknown !== undefined) {
|
|
292
|
+
fail(`${at}.${unknown}`, `A filter node takes no "${unknown}".`);
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
if (node.type !== 'group' && node.type !== 'condition') {
|
|
296
|
+
fail(`${at}.type`, 'A filter node is a "condition" or a "group".');
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
if (node.type === 'group') {
|
|
300
|
+
const extra = ['field', 'operator', 'value'].find(has);
|
|
301
|
+
if (extra) {
|
|
302
|
+
fail(`${at}.${extra}`, `A group takes no "${extra}".`);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
if (node.op !== 'and' && node.op !== 'or') {
|
|
306
|
+
fail(`${at}.op`, 'A group is "and" or "or".');
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
if (!Array.isArray(node.children) || node.children.length === 0) {
|
|
310
|
+
fail(`${at}.children`, 'A group needs at least one child.');
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
node.children.forEach((child, index) => {
|
|
314
|
+
checkFilter(child, `${at}.children[${index}]`, source, fail, known);
|
|
315
|
+
});
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
const extra = ['op', 'children'].find(has);
|
|
319
|
+
if (extra) {
|
|
320
|
+
fail(`${at}.${extra}`, `A condition takes no "${extra}".`);
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
const field = known(node.field, `${at}.field`);
|
|
324
|
+
const operator = node.operator;
|
|
325
|
+
if (!SPEC_SOURCE_FILTER_OPERATORS.includes(operator)) {
|
|
326
|
+
fail(`${at}.operator`, 'A condition needs a filter operator.');
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
if (!field)
|
|
330
|
+
return;
|
|
331
|
+
const op = operator;
|
|
332
|
+
if (!field.filter.includes(op)) {
|
|
333
|
+
fail(`${at}.operator`, `"${field.name}" cannot be filtered by "${op}".`);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
const value = node.value;
|
|
337
|
+
const here = `${at}.value`;
|
|
338
|
+
const scalars = (list) => list.every((item) => fits(field, item));
|
|
339
|
+
if (op === 'isEmpty' || op === 'isNotEmpty') {
|
|
340
|
+
if (has('value'))
|
|
341
|
+
fail(here, `${op} takes no value.`);
|
|
342
|
+
}
|
|
343
|
+
else if (op === 'in') {
|
|
344
|
+
if (!Array.isArray(value) ||
|
|
345
|
+
value.length === 0 ||
|
|
346
|
+
value.length > SPEC_SOURCE_LIMITS.filterValues ||
|
|
347
|
+
!scalars(value)) {
|
|
348
|
+
fail(here, `in needs 1 to ${SPEC_SOURCE_LIMITS.filterValues} values of "${field.name}".`);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
else if (op === 'between') {
|
|
352
|
+
if (!Array.isArray(value) || value.length !== 2 || value.includes(null) || !scalars(value)) {
|
|
353
|
+
fail(here, `between needs [low, high] values of "${field.name}".`);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
else if (op === 'contains' &&
|
|
357
|
+
(typeof value !== 'string' || value === '' || !fits(field, value))) {
|
|
358
|
+
fail(here, 'contains needs text.');
|
|
359
|
+
}
|
|
360
|
+
else if ((op === 'before' || op === 'after') && !isSpecSourceDate(value)) {
|
|
361
|
+
fail(here, `${op} needs a date.`);
|
|
362
|
+
}
|
|
363
|
+
else if (!has('value') || Array.isArray(value) || !fits(field, value)) {
|
|
364
|
+
fail(here, `${op} needs one value of "${field.name}" (${field.type}).`);
|
|
365
|
+
}
|
|
366
|
+
}
|
package/dist/validate.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { SpecDiagnostic, SpecDiagnosticCode, SpecValidation, SpecWarning }
|
|
|
2
2
|
import type { SpecCatalog } from './catalog.js';
|
|
3
3
|
import { type SpecStateType } from './contract.js';
|
|
4
4
|
import { type Schema } from './dialect.js';
|
|
5
|
+
import { type SpecSourceDescriptor } from './source.js';
|
|
5
6
|
export type { SpecDiagnostic, SpecDiagnosticCode, SpecValidation, SpecWarning };
|
|
6
7
|
/**
|
|
7
8
|
* Checks one workflow graph against the workflow schema:
|
|
@@ -20,6 +21,12 @@ export interface SpecDocuments {
|
|
|
20
21
|
workflowSchema?: Schema;
|
|
21
22
|
/** `validateWorkflowGraph` from `@texuli/spec/workflow`, for hosts that render workflows. */
|
|
22
23
|
validateWorkflowGraph?: WorkflowGraphValidator;
|
|
24
|
+
/**
|
|
25
|
+
* The host's data sources (spec 0.8.0), as `registry.describe()` from
|
|
26
|
+
* `@texuli/core/data-source` returns them. A `$source` binding is checked
|
|
27
|
+
* against them; without them every binding is refused (`source`).
|
|
28
|
+
*/
|
|
29
|
+
sources?: readonly SpecSourceDescriptor[];
|
|
23
30
|
}
|
|
24
31
|
/** The workflow schema and its validator, when the host supplied both. */
|
|
25
32
|
export interface WorkflowSupport {
|
|
@@ -58,6 +65,8 @@ export interface TreeOptions {
|
|
|
58
65
|
warnings?: SpecWarning[];
|
|
59
66
|
/** Checks `workflowGraph` positions; without it they fail closed. */
|
|
60
67
|
workflow?: WorkflowSupport;
|
|
68
|
+
/** The host's data sources; without them `$source` bindings fail closed. */
|
|
69
|
+
sources?: readonly SpecSourceDescriptor[];
|
|
61
70
|
}
|
|
62
71
|
/**
|
|
63
72
|
* Validate a blueprint envelope against the spec alone: the envelope schema,
|
package/dist/validate.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { ACTION_FIELDS, contractRoot, DATA_BINDING_KEY, SPEC_ACTION_OPS, SPEC_LIMITS, SPEC_REGEX, SPEC_STATE_TYPES, STATE_BINDING_KEY, STATE_FIELDS, toDialectSchema, } from "./contract.js";
|
|
2
2
|
import { isRecord, validateInstance } from "./dialect.js";
|
|
3
|
+
import { checkSourceBinding, isSourceBinding, SOURCE_BINDING_KEY, } from "./source.js";
|
|
3
4
|
import { checkStateSurface, surfaceInputs } from "./surface.js";
|
|
4
5
|
const MAX_TREE_DEPTH = 64;
|
|
5
6
|
const ACTION_KEYS = new Set(Object.values(ACTION_FIELDS).flatMap((f) => f.allowed));
|
|
@@ -53,6 +54,7 @@ export function validateBlueprint(input, documents, options = {}) {
|
|
|
53
54
|
root: contractRoot(documents.blueprintSchema, workflow?.schema),
|
|
54
55
|
workflow,
|
|
55
56
|
warnings,
|
|
57
|
+
...(documents.sources ? { sources: documents.sources } : {}),
|
|
56
58
|
}));
|
|
57
59
|
}
|
|
58
60
|
return {
|
|
@@ -301,6 +303,7 @@ export function validateTree(tree, catalog, options = {}) {
|
|
|
301
303
|
insideForm,
|
|
302
304
|
out,
|
|
303
305
|
workflow: options.workflow,
|
|
306
|
+
sources: options.sources,
|
|
304
307
|
});
|
|
305
308
|
if (!component) {
|
|
306
309
|
context.fail('unknown-id', `${path}.id`, `"${node.id}" is not in the catalog.`);
|
|
@@ -418,6 +421,10 @@ function checkProps(given, bind, component, path, context) {
|
|
|
418
421
|
fail('extension-prop', here, `"${name}" is a ${prop.extension} extension (${prop.kind}) and cannot come from data.`);
|
|
419
422
|
continue;
|
|
420
423
|
}
|
|
424
|
+
if (isSourceBinding(value)) {
|
|
425
|
+
checkPropSource(value, prop, component, here, context);
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
421
428
|
if (isBindingShaped(value)) {
|
|
422
429
|
checkPropBinding(value, prop, here, context);
|
|
423
430
|
continue;
|
|
@@ -443,6 +450,38 @@ function checkProps(given, bind, component, path, context) {
|
|
|
443
450
|
}
|
|
444
451
|
}
|
|
445
452
|
}
|
|
453
|
+
/**
|
|
454
|
+
* A whole prop read from a host data source (spec 0.8.0): a sourceable prop,
|
|
455
|
+
* the closed `$defs.sourceBinding` shape, then the binding against the
|
|
456
|
+
* host's sources (fail closed without them).
|
|
457
|
+
*/
|
|
458
|
+
function checkPropSource(value, prop, component, path, context) {
|
|
459
|
+
const fail = (code, at, message) => context.fail(code, `${path}${at}`, message);
|
|
460
|
+
if (!(component.sourceable ?? []).includes(prop.name)) {
|
|
461
|
+
fail('source', '', `"${prop.name}" of "${component.id}" cannot read a data source.`);
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
const shape = context.root.$defs?.sourceBinding;
|
|
465
|
+
if (!shape) {
|
|
466
|
+
fail('source', '', 'The blueprint schema has no data-source binding.');
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
const issues = validateInstance(shape, value, context.root);
|
|
470
|
+
const first = issues[0];
|
|
471
|
+
if (first) {
|
|
472
|
+
fail('source', first.path.slice(1), `A data-source binding is ${SHAPE_HINT}: ${first.message}`);
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
const itemSchema = prop.schema?.items;
|
|
476
|
+
const itemFields = itemSchema?.properties ? Object.keys(itemSchema.properties) : undefined;
|
|
477
|
+
for (const issue of checkSourceBinding(value, {
|
|
478
|
+
...(context.sources ? { sources: context.sources } : {}),
|
|
479
|
+
...(itemFields ? { itemFields } : {}),
|
|
480
|
+
})) {
|
|
481
|
+
fail(issue.code, issue.at, issue.message);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
const SHAPE_HINT = `{"${SOURCE_BINDING_KEY}": name, "query": {...}, "map": {...}}`;
|
|
446
485
|
/** Whether `value` is a legal value of `prop` (kind, enum and property schema). */
|
|
447
486
|
function fits(prop, value, root) {
|
|
448
487
|
if (kindProblem(prop, value) !== null)
|
|
@@ -494,6 +533,10 @@ function walkOpaque(value, path, depth, context) {
|
|
|
494
533
|
}
|
|
495
534
|
if (!isRecord(value))
|
|
496
535
|
return;
|
|
536
|
+
if (isSourceBinding(value)) {
|
|
537
|
+
context.fail('source', path, 'A data-source binding reads only a whole top-level prop.');
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
497
540
|
if (isBindingShaped(value)) {
|
|
498
541
|
nestedBinding(value, path, context, undefined);
|
|
499
542
|
return;
|
|
@@ -577,6 +620,10 @@ function checkPropSchemaValue(name, propSchema, value, path, context) {
|
|
|
577
620
|
}
|
|
578
621
|
return schemaIssues;
|
|
579
622
|
}
|
|
623
|
+
if (isSourceBinding(current)) {
|
|
624
|
+
sub.fail('source', at(relative), 'A data-source binding reads only a whole top-level prop.');
|
|
625
|
+
return [];
|
|
626
|
+
}
|
|
580
627
|
if (isBindingShaped(current)) {
|
|
581
628
|
nestedBinding(current, at(relative), sub, node);
|
|
582
629
|
return [];
|
package/dist/version.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** The semantic version of the spec documents in this package. */
|
|
2
|
-
export declare const SPEC_VERSION = "0.
|
|
2
|
+
export declare const SPEC_VERSION = "0.9.0";
|
package/dist/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** The semantic version of the spec documents in this package. */
|
|
2
|
-
export const SPEC_VERSION = '0.
|
|
2
|
+
export const SPEC_VERSION = '0.9.0';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@texuli/spec",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "The schema-first blueprint contract: JSON schemas, the catalog vocabulary, deterministic TypeScript and Kotlin projections, and the conformance harness.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"svelte",
|
|
@@ -50,6 +50,10 @@
|
|
|
50
50
|
"./stream": {
|
|
51
51
|
"types": "./dist/stream/index.d.ts",
|
|
52
52
|
"default": "./dist/stream/index.js"
|
|
53
|
+
},
|
|
54
|
+
"./source": {
|
|
55
|
+
"types": "./dist/source.d.ts",
|
|
56
|
+
"default": "./dist/source.js"
|
|
53
57
|
}
|
|
54
58
|
},
|
|
55
59
|
"files": [
|