@texuli/spec 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,11 @@
1
+ /**
2
+ * 0.7.0 → 0.8.0 is additive (PLAN_010 wave 4–6, ADR-012 and ADR-015): it
3
+ * adds vocabulary (data-source bindings and the other 0.8.0 additions) and
4
+ * removes nothing. Every 0.7.0 envelope is a valid 0.8.0 envelope, so the
5
+ * step only stamps `spec`.
6
+ */
7
+ export const migration070to080 = Object.freeze({
8
+ from: '0.7.0',
9
+ to: '0.8.0',
10
+ migrate: (envelope) => ({ ...envelope, spec: '0.8.0' }),
11
+ });
@@ -4,6 +4,8 @@ import { migration020to030 } from "./0.2.0-0.3.0.js";
4
4
  import { migration030to040 } from "./0.3.0-0.4.0.js";
5
5
  import { migration040to050 } from "./0.4.0-0.5.0.js";
6
6
  import { migration050to060 } from "./0.5.0-0.6.0.js";
7
+ import { migration060to070 } from "./0.6.0-0.7.0.js";
8
+ import { migration070to080 } from "./0.7.0-0.8.0.js";
7
9
  /** The version an envelope without `spec` was written for. */
8
10
  export const UNDECLARED_SPEC_VERSION = '0.2.0';
9
11
  /** Every step, in order; each `to` is the next step's `from`. */
@@ -12,6 +14,8 @@ export const SPEC_MIGRATIONS = Object.freeze([
12
14
  migration030to040,
13
15
  migration040to050,
14
16
  migration050to060,
17
+ migration060to070,
18
+ migration070to080,
15
19
  ]);
16
20
  /**
17
21
  * Bring a blueprint envelope up to `target` (the current spec by default)
@@ -0,0 +1,90 @@
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
+ /** The binding key that reads a host data source: `{ "$source": name, … }`. */
13
+ export declare const SOURCE_BINDING_KEY = "$source";
14
+ /** The members a binding takes besides `$source`. */
15
+ export declare const SOURCE_BINDING_FIELDS: readonly ["query", "map"];
16
+ export declare const SPEC_SOURCE_FIELD_TYPES: readonly ["text", "enum", "number", "date", "boolean"];
17
+ export type SpecSourceFieldType = (typeof SPEC_SOURCE_FIELD_TYPES)[number];
18
+ export declare const SPEC_SOURCE_OPERATIONS: readonly ["list", "get", "create", "update", "delete", "search"];
19
+ export type SpecSourceOperation = (typeof SPEC_SOURCE_OPERATIONS)[number];
20
+ /** The `@texuli/data` grid filter model's operators (`$defs.sourceFilter.operator`). */
21
+ export declare const SPEC_SOURCE_FILTER_OPERATORS: readonly ["eq", "neq", "contains", "in", "between", "before", "after", "isEmpty", "isNotEmpty"];
22
+ export type SpecSourceFilterOperator = (typeof SPEC_SOURCE_FILTER_OPERATORS)[number];
23
+ /** Every bound of a binding; the schema writes the static ones too (`tests/source.test.ts`). */
24
+ export declare const SPEC_SOURCE_LIMITS: Readonly<{
25
+ nameLength: 64;
26
+ projection: 32;
27
+ sort: 4;
28
+ searchLength: 200;
29
+ filterDepth: 6;
30
+ filterNodes: 64;
31
+ filterValues: 100;
32
+ valueLength: 500;
33
+ pageSize: 500;
34
+ mapEntries: 16;
35
+ }>;
36
+ export declare const SPEC_SOURCE_PATTERNS: Readonly<{
37
+ sourceName: "^[a-z][a-z0-9]*([._-][a-z0-9]+)*$";
38
+ sourceFieldName: "^[A-Za-z][A-Za-z0-9_]*$";
39
+ }>;
40
+ /** One declared field, as `@texuli/core/data-source` describes it. */
41
+ export interface SpecSourceField {
42
+ readonly name: string;
43
+ readonly type: SpecSourceFieldType;
44
+ readonly options?: readonly string[];
45
+ readonly filter: readonly SpecSourceFilterOperator[];
46
+ readonly sort: boolean;
47
+ readonly editable?: boolean;
48
+ readonly required?: boolean;
49
+ }
50
+ /**
51
+ * A host source as plain data: structurally the `SourceDescriptor` of
52
+ * `@texuli/core/data-source` (`registry.describe()` can be passed as is).
53
+ */
54
+ export interface SpecSourceDescriptor {
55
+ readonly name: string;
56
+ readonly key: string;
57
+ readonly fields: readonly SpecSourceField[];
58
+ readonly operations: readonly SpecSourceOperation[];
59
+ readonly pagination?: 'cursor' | 'offset' | 'none';
60
+ readonly maxPageSize: number;
61
+ readonly defaultPageSize?: number;
62
+ }
63
+ /** One semantic problem with a binding; `at` is relative to the binding (`.query.filter`). */
64
+ export interface SourceBindingIssue {
65
+ readonly code: 'source' | 'unknown-source';
66
+ readonly at: string;
67
+ readonly message: string;
68
+ }
69
+ /** What a binding is checked against. */
70
+ export interface SourceBindingContext {
71
+ /** The host's sources. Omitted: every binding is refused (fail closed). */
72
+ readonly sources?: readonly SpecSourceDescriptor[];
73
+ /**
74
+ * The item fields the bound prop's rows may carry (its item schema's
75
+ * properties), when known: `map` keys must be among them.
76
+ */
77
+ readonly itemFields?: readonly string[];
78
+ }
79
+ /** Whether a value is shaped like a source binding (`$source` as an own key). */
80
+ export declare function isSourceBinding(value: unknown): value is Record<string, unknown>;
81
+ /** Whether `value` is an ISO 8601 date or date-time, or epoch milliseconds. */
82
+ export declare function isSpecSourceDate(value: unknown): boolean;
83
+ /**
84
+ * Check a binding: its closed shape, then its meaning against the host's
85
+ * sources (fail closed without them): the name, every field, operator and
86
+ * value of the query, the page size and the map. One issue per problem, up
87
+ * to 16. `@texuli/spec`'s validator runs the schema (`$defs.sourceBinding`)
88
+ * first; `@texuli/catalog` runs this alone.
89
+ */
90
+ export declare function checkSourceBinding(binding: Record<string, unknown>, context: SourceBindingContext): SourceBindingIssue[];
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
+ }
@@ -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 {
@@ -275,9 +277,22 @@ export function validateTree(tree, catalog, options = {}) {
275
277
  const flow = options.flow ?? null;
276
278
  const root = options.root ?? { $defs: {} };
277
279
  const warn = (warning) => options.warnings?.push(warning);
280
+ // Spec 0.7.0 (ADR-014): a node key is unique in its tree; the schema checks its pattern.
281
+ const keys = new Set();
278
282
  const visit = (node, path, depth, parent, insideForm) => {
279
283
  if (!isRecord(node) || typeof node.id !== 'string' || depth > MAX_TREE_DEPTH)
280
284
  return;
285
+ if (typeof node.key === 'string' && SPEC_REGEX.nodeKey.test(node.key)) {
286
+ if (keys.has(node.key)) {
287
+ out.push({
288
+ code: 'duplicate-key',
289
+ path: `${path}.key`,
290
+ id: node.id,
291
+ message: `The key "${node.key.slice(0, 64)}" is already used in this tree.`,
292
+ });
293
+ }
294
+ keys.add(node.key);
295
+ }
281
296
  checkSlot(node.slot, parent, node.id, path, out);
282
297
  const component = byId.get(node.id);
283
298
  const context = reportInto({
@@ -288,6 +303,7 @@ export function validateTree(tree, catalog, options = {}) {
288
303
  insideForm,
289
304
  out,
290
305
  workflow: options.workflow,
306
+ sources: options.sources,
291
307
  });
292
308
  if (!component) {
293
309
  context.fail('unknown-id', `${path}.id`, `"${node.id}" is not in the catalog.`);
@@ -405,6 +421,10 @@ function checkProps(given, bind, component, path, context) {
405
421
  fail('extension-prop', here, `"${name}" is a ${prop.extension} extension (${prop.kind}) and cannot come from data.`);
406
422
  continue;
407
423
  }
424
+ if (isSourceBinding(value)) {
425
+ checkPropSource(value, prop, component, here, context);
426
+ continue;
427
+ }
408
428
  if (isBindingShaped(value)) {
409
429
  checkPropBinding(value, prop, here, context);
410
430
  continue;
@@ -430,6 +450,38 @@ function checkProps(given, bind, component, path, context) {
430
450
  }
431
451
  }
432
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": {...}}`;
433
485
  /** Whether `value` is a legal value of `prop` (kind, enum and property schema). */
434
486
  function fits(prop, value, root) {
435
487
  if (kindProblem(prop, value) !== null)
@@ -481,6 +533,10 @@ function walkOpaque(value, path, depth, context) {
481
533
  }
482
534
  if (!isRecord(value))
483
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
+ }
484
540
  if (isBindingShaped(value)) {
485
541
  nestedBinding(value, path, context, undefined);
486
542
  return;
@@ -564,6 +620,10 @@ function checkPropSchemaValue(name, propSchema, value, path, context) {
564
620
  }
565
621
  return schemaIssues;
566
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
+ }
567
627
  if (isBindingShaped(current)) {
568
628
  nestedBinding(current, at(relative), sub, node);
569
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.6.0";
2
+ export declare const SPEC_VERSION = "0.8.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.6.0';
2
+ export const SPEC_VERSION = '0.8.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@texuli/spec",
3
- "version": "0.6.0",
3
+ "version": "0.8.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": [