@texuli/spec 0.7.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.
- package/README.md +64 -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 +284 -2
- 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 +29 -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/index.js +2 -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
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
// Generated by @texuli/spec from schemas/manifest.schema.json. Do not edit: run the spec generator.
|
|
2
|
-
// Spec 0.
|
|
2
|
+
// Spec 0.8.0, digest f6b6388280d247eb9c3106a925111f35b90d1ec3b59c23652542f06a9f103daf
|
|
3
3
|
export {};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** The built catalog this vocabulary was projected from. */
|
|
2
|
-
export declare const SPEC_CATALOG_HASH = "
|
|
2
|
+
export declare const SPEC_CATALOG_HASH = "598075e391ed95dcba46e120d2fbb706e8ff67d0d5bdeb3c0741f334f439b264";
|
|
3
3
|
/** Every closed component id, sorted. */
|
|
4
|
-
export declare const SPEC_COMPONENT_IDS: readonly ["action-form", "alert", "alert-dialog", "analytics-grid", "app-nav", "badge", "banner", "blocks-code", "blocks-data", "blocks-diagram", "blocks-embed", "blocks-interactive", "blocks-map", "blocks-media", "blocks-structure", "blocks-text", "breadcrumb", "button", "button-group", "callout", "card", "charts-area", "charts-bar", "charts-bubble", "charts-bullet", "charts-canvas-renderer", "charts-cartesian", "charts-combo", "charts-donut", "charts-funnel", "charts-gauge", "charts-geo-map", "charts-geo-point-map", "charts-heatmap", "charts-line", "charts-network", "charts-pie", "charts-progress-ring", "charts-sankey", "charts-scatter", "charts-sparkline", "charts-stacked-bar", "charts-sunburst", "charts-treemap", "chat-message", "chat-thread", "checkbox", "chip", "code", "combobox", "command-palette", "content-layout", "context-menu", "context-menu-trigger", "control-choice", "control-filter", "control-slider", "control-toggle", "data-board", "data-calendar", "data-cohort-table", "data-files", "data-filter-builder", "data-grid", "data-map", "data-notification-inbox", "data-timeline", "data-tree-view", "deck-slide", "description", "detail-panel", "dialog", "document", "drawer", "dropdown-menu", "dropdown-menu-content", "dropdown-menu-item", "dropdown-menu-trigger", "empty-state", "error-state", "field", "field-error", "flow-action", "flow-view", "form", "form-field", "form-submit", "host-region", "hover-card", "hover-card-content", "icon-button", "inline-text", "input", "input-group", "kbd", "kpi", "label", "layout-auth-split", "layout-blog", "layout-card", "layout-chrome", "layout-dashboard-grid", "layout-deck", "layout-doc", "layout-docs", "layout-drawer", "layout-dual-sidebar", "layout-editorial", "layout-grid", "layout-host", "layout-landing", "layout-marketing", "layout-page", "layout-pricing", "layout-row", "layout-settings", "layout-sidebar", "layout-split", "layout-stack", "layout-tile", "layout-top-nav", "layout-wizard", "media-composition-canvas", "media-composition-svg", "media-image", "menubar", "menubar-trigger", "multi-select", "nav-icon", "navigation-menu", "pagination", "popover", "popover-content", "radio", "radio-group", "responsive-modal", "screen-outlet", "select", "select-content", "select-group", "select-item", "select-label", "select-portal", "select-separator", "select-trigger", "select-value", "select-viewport", "sheet", "sidebar", "sortable-list", "split-view", "stat", "stepper", "switch", "table", "tag", "text-counter", "textarea", "time-field", "timeline", "toaster", "tooltip", "workflow-graph"];
|
|
4
|
+
export declare const SPEC_COMPONENT_IDS: readonly ["action-form", "alert", "alert-dialog", "analytics-grid", "app-nav", "badge", "banner", "blocks-code", "blocks-data", "blocks-diagram", "blocks-embed", "blocks-interactive", "blocks-map", "blocks-media", "blocks-structure", "blocks-text", "breadcrumb", "button", "button-group", "callout", "canvas", "card", "charts-area", "charts-bar", "charts-bubble", "charts-bullet", "charts-canvas-renderer", "charts-cartesian", "charts-combo", "charts-donut", "charts-funnel", "charts-gauge", "charts-geo-map", "charts-geo-point-map", "charts-heatmap", "charts-line", "charts-network", "charts-pie", "charts-progress-ring", "charts-sankey", "charts-scatter", "charts-sparkline", "charts-stacked-bar", "charts-sunburst", "charts-treemap", "chat-message", "chat-thread", "checkbox", "chip", "code", "combobox", "command-palette", "content-layout", "context-menu", "context-menu-trigger", "control-choice", "control-filter", "control-slider", "control-toggle", "data-board", "data-calendar", "data-cohort-table", "data-dashboard", "data-dashboard-widget", "data-files", "data-filter-builder", "data-grid", "data-map", "data-notification-inbox", "data-timeline", "data-tree-view", "deck-slide", "description", "detail-panel", "dialog", "document", "drawer", "dropdown-menu", "dropdown-menu-content", "dropdown-menu-item", "dropdown-menu-trigger", "empty-state", "error-state", "field", "field-error", "flow-action", "flow-view", "form", "form-field", "form-submit", "host-region", "hover-card", "hover-card-content", "icon-button", "inline-text", "input", "input-group", "kbd", "kpi", "label", "layout-auth-split", "layout-blog", "layout-card", "layout-chrome", "layout-dashboard-grid", "layout-deck", "layout-doc", "layout-docs", "layout-drawer", "layout-dual-sidebar", "layout-editorial", "layout-grid", "layout-host", "layout-landing", "layout-marketing", "layout-page", "layout-pricing", "layout-row", "layout-settings", "layout-sidebar", "layout-split", "layout-stack", "layout-tile", "layout-top-nav", "layout-wizard", "media-composition-canvas", "media-composition-svg", "media-image", "menubar", "menubar-trigger", "multi-select", "nav-icon", "navigation-menu", "pagination", "popover", "popover-content", "radio", "radio-group", "responsive-modal", "schema-form", "screen-outlet", "select", "select-content", "select-group", "select-item", "select-label", "select-portal", "select-separator", "select-trigger", "select-value", "select-viewport", "sheet", "sidebar", "sortable-list", "split-view", "spreadsheet", "stat", "stepper", "switch", "table", "tag", "text-counter", "textarea", "time-field", "timeline", "toaster", "tooltip", "workflow-graph"];
|
|
5
5
|
export type SpecComponentId = (typeof SPEC_COMPONENT_IDS)[number];
|
|
6
6
|
/** The props a blueprint may set on each component (platform extensions left out). */
|
|
7
7
|
export interface SpecComponentProps {
|
|
@@ -139,6 +139,10 @@ export interface SpecComponentProps {
|
|
|
139
139
|
title?: string;
|
|
140
140
|
tone?: 'info' | 'success' | 'warning' | 'error' | 'brand' | 'neutral';
|
|
141
141
|
};
|
|
142
|
+
canvas: {
|
|
143
|
+
canvas: Readonly<Record<string, unknown>>;
|
|
144
|
+
label?: string;
|
|
145
|
+
};
|
|
142
146
|
card: {
|
|
143
147
|
href?: string;
|
|
144
148
|
hrefTrust?: 'developer' | 'untrusted';
|
|
@@ -493,6 +497,20 @@ export interface SpecComponentProps {
|
|
|
493
497
|
rows: readonly unknown[];
|
|
494
498
|
valueKind?: 'rate' | 'count';
|
|
495
499
|
};
|
|
500
|
+
'data-dashboard': {
|
|
501
|
+
columns?: number;
|
|
502
|
+
compaction?: 'vertical' | 'none';
|
|
503
|
+
density?: 'compact' | 'default' | 'comfortable';
|
|
504
|
+
editable?: boolean;
|
|
505
|
+
gap?: 'none' | 'small' | 'medium' | 'large';
|
|
506
|
+
label: string;
|
|
507
|
+
layout?: readonly unknown[];
|
|
508
|
+
};
|
|
509
|
+
'data-dashboard-widget': {
|
|
510
|
+
place?: Readonly<Record<string, unknown>>;
|
|
511
|
+
title: string;
|
|
512
|
+
widgetId: string;
|
|
513
|
+
};
|
|
496
514
|
'data-files': {
|
|
497
515
|
density?: 'comfortable' | 'compact';
|
|
498
516
|
entries: readonly unknown[];
|
|
@@ -985,6 +1003,11 @@ export interface SpecComponentProps {
|
|
|
985
1003
|
side?: 'top' | 'right' | 'bottom' | 'left' | 'start' | 'end' | 'center';
|
|
986
1004
|
size?: 'sm' | 'md' | 'lg' | 'xl';
|
|
987
1005
|
};
|
|
1006
|
+
'schema-form': {
|
|
1007
|
+
action: string;
|
|
1008
|
+
label?: string;
|
|
1009
|
+
schema: Readonly<Record<string, unknown>>;
|
|
1010
|
+
};
|
|
988
1011
|
'screen-outlet': Record<string, never>;
|
|
989
1012
|
select: {
|
|
990
1013
|
defaultValue?: string;
|
|
@@ -1052,6 +1075,10 @@ export interface SpecComponentProps {
|
|
|
1052
1075
|
detailLabel?: string;
|
|
1053
1076
|
listLabel?: string;
|
|
1054
1077
|
};
|
|
1078
|
+
spreadsheet: {
|
|
1079
|
+
label?: string;
|
|
1080
|
+
sheet: Readonly<Record<string, unknown>>;
|
|
1081
|
+
};
|
|
1055
1082
|
stat: {
|
|
1056
1083
|
delta?: string;
|
|
1057
1084
|
hint?: string;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Generated by @texuli/spec from catalog/components.json. Do not edit: run the spec generator.
|
|
2
|
-
// Spec 0.
|
|
2
|
+
// Spec 0.8.0, digest f6b6388280d247eb9c3106a925111f35b90d1ec3b59c23652542f06a9f103daf
|
|
3
3
|
/** The built catalog this vocabulary was projected from. */
|
|
4
|
-
export const SPEC_CATALOG_HASH = '
|
|
4
|
+
export const SPEC_CATALOG_HASH = '598075e391ed95dcba46e120d2fbb706e8ff67d0d5bdeb3c0741f334f439b264';
|
|
5
5
|
/** Every closed component id, sorted. */
|
|
6
6
|
export const SPEC_COMPONENT_IDS = [
|
|
7
7
|
'action-form',
|
|
@@ -24,6 +24,7 @@ export const SPEC_COMPONENT_IDS = [
|
|
|
24
24
|
'button',
|
|
25
25
|
'button-group',
|
|
26
26
|
'callout',
|
|
27
|
+
'canvas',
|
|
27
28
|
'card',
|
|
28
29
|
'charts-area',
|
|
29
30
|
'charts-bar',
|
|
@@ -65,6 +66,8 @@ export const SPEC_COMPONENT_IDS = [
|
|
|
65
66
|
'data-board',
|
|
66
67
|
'data-calendar',
|
|
67
68
|
'data-cohort-table',
|
|
69
|
+
'data-dashboard',
|
|
70
|
+
'data-dashboard-widget',
|
|
68
71
|
'data-files',
|
|
69
72
|
'data-filter-builder',
|
|
70
73
|
'data-grid',
|
|
@@ -140,6 +143,7 @@ export const SPEC_COMPONENT_IDS = [
|
|
|
140
143
|
'radio',
|
|
141
144
|
'radio-group',
|
|
142
145
|
'responsive-modal',
|
|
146
|
+
'schema-form',
|
|
143
147
|
'screen-outlet',
|
|
144
148
|
'select',
|
|
145
149
|
'select-content',
|
|
@@ -155,6 +159,7 @@ export const SPEC_COMPONENT_IDS = [
|
|
|
155
159
|
'sidebar',
|
|
156
160
|
'sortable-list',
|
|
157
161
|
'split-view',
|
|
162
|
+
'spreadsheet',
|
|
158
163
|
'stat',
|
|
159
164
|
'stepper',
|
|
160
165
|
'switch',
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
// Generated by @texuli/spec from schemas/workflow.schema.json. Do not edit: run the spec generator.
|
|
2
|
-
// Spec 0.
|
|
2
|
+
// Spec 0.8.0, digest f6b6388280d247eb9c3106a925111f35b90d1ec3b59c23652542f06a9f103daf
|
|
3
3
|
export {};
|
package/dist/index.d.ts
CHANGED
|
@@ -20,6 +20,7 @@ export { CAPABILITIES, type Capability, type SpecPropKind, WEB_ONLY_KINDS, WEB_O
|
|
|
20
20
|
export { ACTION_FIELDS, contractRoot, DATA_BINDING_KEY, SPEC_ACTION_OPS, SPEC_EVENT_NAMES, SPEC_LIMITS, SPEC_PATTERNS, SPEC_PROP_REFS, SPEC_STATE_TYPES, type SpecActionOp, type SpecStateType, STATE_BINDING_KEY, STATE_FIELDS, toDialectSchema, } from './contract.js';
|
|
21
21
|
export { checkSchemaDocument, DIALECT_KEYWORDS, DIALECT_TYPES, DIALECT_URI, type DialectHooks, type DialectType, type Schema, type SchemaIssue, validateInstance, } from './dialect.js';
|
|
22
22
|
export type { SpecMigration, SpecMigrationResult } from './migrations/types.js';
|
|
23
|
+
export { checkSourceBinding, isSourceBinding, isSpecSourceDate, SOURCE_BINDING_FIELDS, SOURCE_BINDING_KEY, type SourceBindingContext, type SourceBindingIssue, SPEC_SOURCE_FIELD_TYPES, SPEC_SOURCE_FILTER_OPERATORS, SPEC_SOURCE_LIMITS, SPEC_SOURCE_OPERATIONS, SPEC_SOURCE_PATTERNS, type SpecSourceDescriptor, type SpecSourceField, type SpecSourceFieldType, type SpecSourceFilterOperator, type SpecSourceOperation, } from './source.js';
|
|
23
24
|
export { checkStateSurface, LIST_TEXT_PATTERN, SPEC_CONTROL_STATE_TYPES, SURFACE_PLAYER_CODES, type SurfaceCode, type SurfaceInputs, type SurfaceIssue, surfaceInputs, } from './surface.js';
|
|
24
25
|
export { type SpecDocuments, type SpecFlowInfo, type SpecStateVariable, type TreeOptions, validateBlueprint, validateTree, type WorkflowGraphValidator, } from './validate.js';
|
|
25
26
|
export { SPEC_VERSION } from './version.js';
|
package/dist/index.js
CHANGED
|
@@ -14,6 +14,7 @@ export { canonicalJson, sha256 } from "./canonical.js";
|
|
|
14
14
|
export { CAPABILITIES, WEB_ONLY_KINDS, WEB_ONLY_PROP_NAMES, } from "./catalog.js";
|
|
15
15
|
export { ACTION_FIELDS, contractRoot, DATA_BINDING_KEY, SPEC_ACTION_OPS, SPEC_EVENT_NAMES, SPEC_LIMITS, SPEC_PATTERNS, SPEC_PROP_REFS, SPEC_STATE_TYPES, STATE_BINDING_KEY, STATE_FIELDS, toDialectSchema, } from "./contract.js";
|
|
16
16
|
export { checkSchemaDocument, DIALECT_KEYWORDS, DIALECT_TYPES, DIALECT_URI, validateInstance, } from "./dialect.js";
|
|
17
|
+
export { checkSourceBinding, isSourceBinding, isSpecSourceDate, SOURCE_BINDING_FIELDS, SOURCE_BINDING_KEY, SPEC_SOURCE_FIELD_TYPES, SPEC_SOURCE_FILTER_OPERATORS, SPEC_SOURCE_LIMITS, SPEC_SOURCE_OPERATIONS, SPEC_SOURCE_PATTERNS, } from "./source.js";
|
|
17
18
|
export { checkStateSurface, LIST_TEXT_PATTERN, SPEC_CONTROL_STATE_TYPES, SURFACE_PLAYER_CODES, surfaceInputs, } from "./surface.js";
|
|
18
19
|
export { validateBlueprint, validateTree, } from "./validate.js";
|
|
19
20
|
export { SPEC_VERSION } from "./version.js";
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { SpecMigration } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* 0.7.0 → 0.8.0 is additive (PLAN_010 wave 4–6, ADR-012 and ADR-015): it
|
|
4
|
+
* adds vocabulary (data-source bindings and the other 0.8.0 additions) and
|
|
5
|
+
* removes nothing. Every 0.7.0 envelope is a valid 0.8.0 envelope, so the
|
|
6
|
+
* step only stamps `spec`.
|
|
7
|
+
*/
|
|
8
|
+
export declare const migration070to080: SpecMigration;
|
|
@@ -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
|
+
});
|
package/dist/migrations/index.js
CHANGED
|
@@ -5,6 +5,7 @@ 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
7
|
import { migration060to070 } from "./0.6.0-0.7.0.js";
|
|
8
|
+
import { migration070to080 } from "./0.7.0-0.8.0.js";
|
|
8
9
|
/** The version an envelope without `spec` was written for. */
|
|
9
10
|
export const UNDECLARED_SPEC_VERSION = '0.2.0';
|
|
10
11
|
/** Every step, in order; each `to` is the next step's `from`. */
|
|
@@ -14,6 +15,7 @@ export const SPEC_MIGRATIONS = Object.freeze([
|
|
|
14
15
|
migration040to050,
|
|
15
16
|
migration050to060,
|
|
16
17
|
migration060to070,
|
|
18
|
+
migration070to080,
|
|
17
19
|
]);
|
|
18
20
|
/**
|
|
19
21
|
* Bring a blueprint envelope up to `target` (the current spec by default)
|
package/dist/source.d.ts
ADDED
|
@@ -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
|
+
}
|