@jarenjs/linq 0.49.2 → 0.66.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +227 -0
- package/README.md +650 -17
- package/docs/APP-PEN.md +1143 -0
- package/docs/CONTRACT-PEN.md +1221 -0
- package/docs/DB-CLIENT.md +882 -0
- package/docs/FLOW-PEN.md +1033 -0
- package/docs/FORMS-PEN.md +940 -0
- package/docs/JSLT-PEN.md +955 -0
- package/docs/LINQ-FORMAT.md +778 -383
- package/docs/MIGRATION-PEN.md +781 -0
- package/docs/MODEL-PEN.md +1092 -0
- package/docs/QUERY-PEN.md +1724 -0
- package/docs/SCHEMA-PEN.md +1218 -0
- package/package.json +57 -4
- package/src/app/action.js +251 -0
- package/src/app/capture.js +63 -0
- package/src/app/define.js +255 -0
- package/src/app/index.js +20 -0
- package/src/app/patch.js +277 -0
- package/src/app/sub.js +106 -0
- package/src/async.js +377 -75
- package/src/capture-root.js +82 -0
- package/src/concurrency.js +48 -11
- package/src/contract/define.js +282 -0
- package/src/contract/http.js +247 -0
- package/src/contract/index.js +23 -0
- package/src/contract/operation.js +338 -0
- package/src/db/handle.js +89 -0
- package/src/db/include.js +351 -0
- package/src/db/index.js +24 -0
- package/src/db/ledger.js +195 -0
- package/src/db/live.js +43 -0
- package/src/db/membership.js +37 -0
- package/src/db/open.js +130 -0
- package/src/document.js +143 -13
- package/src/effect.js +65 -0
- package/src/errors.js +78 -6
- package/src/expression.js +463 -36
- package/src/federate.js +531 -0
- package/src/flow/capture.js +33 -0
- package/src/flow/dag.js +316 -0
- package/src/flow/fsm.js +323 -0
- package/src/flow/index.js +22 -0
- package/src/forms/index.js +43 -0
- package/src/forms/rules.js +170 -0
- package/src/forms/submit.js +177 -0
- package/src/index.js +5 -2
- package/src/jslt/body.js +226 -0
- package/src/jslt/index.js +18 -0
- package/src/jslt/rules.js +202 -0
- package/src/json-boundary.js +90 -0
- package/src/migration/define.js +318 -0
- package/src/migration/index.js +15 -0
- package/src/migration/steps.js +244 -0
- package/src/model/collection.js +273 -0
- package/src/model/define.js +125 -0
- package/src/model/entity.js +307 -0
- package/src/model/index.js +47 -0
- package/src/model/relation.js +85 -0
- package/src/provider.js +137 -20
- package/src/schema/brand.js +31 -0
- package/src/schema/builders.js +526 -0
- package/src/schema/check.js +29 -0
- package/src/schema/emit.js +394 -0
- package/src/schema/factories.js +239 -0
- package/src/schema/index.js +37 -0
- package/src/schema-of.js +24 -0
- package/src/sequence.js +233 -103
- package/src/sources.js +10 -3
- package/types/app.d.ts +293 -0
- package/types/contract.d.ts +468 -0
- package/types/db.d.ts +359 -0
- package/types/flow.d.ts +285 -0
- package/types/forms.d.ts +253 -0
- package/types/index.d.ts +296 -26
- package/types/jslt.d.ts +193 -0
- package/types/migration.d.ts +201 -0
- package/types/model.d.ts +526 -0
- package/types/schema.d.ts +494 -0
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file `http()` — the REST binding of one operation (CONTRACT-FORMAT
|
|
4
|
+
* §4), written as the format spells it and checked as far as the pen can
|
|
5
|
+
* see. The path template scan mirrors the compiler's own parser: every
|
|
6
|
+
* form §4.2 reserves is refused by name with `JL0102`, at build time,
|
|
7
|
+
* before `compileContract` would answer `JC0008` with the same meaning.
|
|
8
|
+
* Nothing is canonicalized here — a `:name` template is written as the
|
|
9
|
+
* author declared it, and the projections are what show the `{name}`
|
|
10
|
+
* form.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { LinqBuildError } from '../errors.js';
|
|
14
|
+
import { describeValue, requireJson } from '../json-boundary.js';
|
|
15
|
+
|
|
16
|
+
/** The binding brand: how `defineContract` tells a checked binding apart. */
|
|
17
|
+
export const HTTP_BINDING = Symbol.for('@jarenjs/linq/contract-http');
|
|
18
|
+
|
|
19
|
+
/** The members `http` accepts, in the order §12.1 fixes. */
|
|
20
|
+
export const HTTP_MEMBERS = Object.freeze(['method', 'path', 'in', 'body', 'status', 'media']);
|
|
21
|
+
|
|
22
|
+
/** The uppercase tokens §4's table lists. */
|
|
23
|
+
export const HTTP_METHODS = Object.freeze(['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']);
|
|
24
|
+
|
|
25
|
+
/** The four places an input member can travel. */
|
|
26
|
+
export const LOCATIONS = Object.freeze(['path', 'query', 'header', 'body']);
|
|
27
|
+
|
|
28
|
+
/** `[A-Za-z_][A-Za-z0-9_]*` — a path variable's name. */
|
|
29
|
+
const IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
30
|
+
|
|
31
|
+
/** The RFC 6570 operators a `{…}` expression may open with; all reserved. */
|
|
32
|
+
const OPERATORS = '+#./;?&=';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* One static segment: any character but the structural ones (`/ { } : *
|
|
36
|
+
* ? #`), whitespace and controls; a `%` must open a well-formed escape.
|
|
37
|
+
* @param {string} segment
|
|
38
|
+
* @param {string} at
|
|
39
|
+
*/
|
|
40
|
+
function checkStatic(segment, at) {
|
|
41
|
+
for (let i = 0; i < segment.length; i++) {
|
|
42
|
+
const ch = segment[i];
|
|
43
|
+
if (ch === '{' || ch === '}') {
|
|
44
|
+
throw new LinqBuildError('JL0102',
|
|
45
|
+
`a variable must be a whole segment ("{name}"), found "${segment}" — the format `
|
|
46
|
+
+ 'reserves a variable that is only part of a segment', at);
|
|
47
|
+
}
|
|
48
|
+
if (ch === ':') {
|
|
49
|
+
throw new LinqBuildError('JL0102',
|
|
50
|
+
`":" is reserved for a variable segment (":name"), found "${segment}"`, at);
|
|
51
|
+
}
|
|
52
|
+
if (ch === '*') {
|
|
53
|
+
throw new LinqBuildError('JL0102',
|
|
54
|
+
`"*" is a reserved wildcard form; $contract 0.1 has no wildcards, found "${segment}"`, at);
|
|
55
|
+
}
|
|
56
|
+
if (ch === '?' || ch === '#') {
|
|
57
|
+
throw new LinqBuildError('JL0102',
|
|
58
|
+
`"${ch}" cannot appear in a path template (the query and fragment are not part of `
|
|
59
|
+
+ 'the path)', at);
|
|
60
|
+
}
|
|
61
|
+
const code = segment.charCodeAt(i);
|
|
62
|
+
if (code <= 0x20 || code === 0x7f) {
|
|
63
|
+
throw new LinqBuildError('JL0102',
|
|
64
|
+
`whitespace or a control character in segment "${segment}"`, at);
|
|
65
|
+
}
|
|
66
|
+
if (ch === '%') {
|
|
67
|
+
if (!/^[0-9A-Fa-f]{2}$/.test(segment.slice(i + 1, i + 3))) {
|
|
68
|
+
throw new LinqBuildError('JL0102',
|
|
69
|
+
`a malformed percent-escape in segment "${segment}"`, at);
|
|
70
|
+
}
|
|
71
|
+
i += 2;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The variables a template declares, refusing every reserved form by
|
|
78
|
+
* name (§4.2). Mirrors the compiler's parser; nothing is rewritten.
|
|
79
|
+
* @param {any} source
|
|
80
|
+
* @param {string} at
|
|
81
|
+
* @returns {string[]} the variable names, in order
|
|
82
|
+
*/
|
|
83
|
+
export function pathVariables(source, at) {
|
|
84
|
+
if (typeof source !== 'string') {
|
|
85
|
+
throw new LinqBuildError('JL0102',
|
|
86
|
+
`http() path is a path template string, got ${describeValue(source)}`, at);
|
|
87
|
+
}
|
|
88
|
+
if (source.length === 0 || source[0] !== '/') {
|
|
89
|
+
throw new LinqBuildError('JL0102', 'a path template must start with "/"', at);
|
|
90
|
+
}
|
|
91
|
+
/** @type {string[]} */
|
|
92
|
+
const variables = [];
|
|
93
|
+
if (source === '/') return variables;
|
|
94
|
+
const segments = source.slice(1).split('/');
|
|
95
|
+
for (let i = 0; i < segments.length; i++) {
|
|
96
|
+
const segment = segments[i];
|
|
97
|
+
if (segment.length === 0) {
|
|
98
|
+
throw new LinqBuildError('JL0102', i === segments.length - 1
|
|
99
|
+
? 'a trailing "/" declares an empty segment; the root template "/" is the only empty path'
|
|
100
|
+
: 'an empty segment ("//")', at);
|
|
101
|
+
}
|
|
102
|
+
/** @type {string | null} */
|
|
103
|
+
let name = null;
|
|
104
|
+
if (segment[0] === '{') {
|
|
105
|
+
if (segment[segment.length - 1] !== '}') {
|
|
106
|
+
throw new LinqBuildError('JL0102',
|
|
107
|
+
`a variable must be a whole segment ("{name}"), found "${segment}" — the format `
|
|
108
|
+
+ 'reserves a variable that is only part of a segment', at);
|
|
109
|
+
}
|
|
110
|
+
name = segment.slice(1, -1);
|
|
111
|
+
if (name.length > 0 && OPERATORS.includes(name[0])) {
|
|
112
|
+
throw new LinqBuildError('JL0102',
|
|
113
|
+
`"{${name}}" uses the reserved RFC 6570 operator "${name[0]}"; $contract 0.1 `
|
|
114
|
+
+ 'supports only "{name}"', at);
|
|
115
|
+
}
|
|
116
|
+
const last = name[name.length - 1];
|
|
117
|
+
if (last === '+' || last === '*') {
|
|
118
|
+
throw new LinqBuildError('JL0102',
|
|
119
|
+
`"{${name}}" uses the reserved "${last}" expansion modifier; $contract 0.1 has no `
|
|
120
|
+
+ 'wildcards', at);
|
|
121
|
+
}
|
|
122
|
+
if (name.includes(',') || name.includes(':')) {
|
|
123
|
+
throw new LinqBuildError('JL0102',
|
|
124
|
+
`"{${name}}" uses a reserved RFC 6570 list or prefix form; $contract 0.1 supports `
|
|
125
|
+
+ 'only "{name}"', at);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
else if (segment[0] === ':') {
|
|
129
|
+
name = segment.slice(1);
|
|
130
|
+
if (name.length === 0 || name.includes('{') || name.includes('}')) {
|
|
131
|
+
throw new LinqBuildError('JL0102',
|
|
132
|
+
`":name" must be a whole segment with an identifier name, found "${segment}"`, at);
|
|
133
|
+
}
|
|
134
|
+
const last = name[name.length - 1];
|
|
135
|
+
if (last === '*' || last === '+' || last === '?') {
|
|
136
|
+
throw new LinqBuildError('JL0102',
|
|
137
|
+
`":${name}" uses a reserved "${last}" modifier; $contract 0.1 has no wildcards or `
|
|
138
|
+
+ 'optional segments', at);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (name === null) {
|
|
142
|
+
checkStatic(segment, at);
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (!IDENT.test(name)) {
|
|
146
|
+
throw new LinqBuildError('JL0102',
|
|
147
|
+
`a variable name must match [A-Za-z_][A-Za-z0-9_]*, found "${name}"`, at);
|
|
148
|
+
}
|
|
149
|
+
if (variables.includes(name)) {
|
|
150
|
+
throw new LinqBuildError('JL0102', `the variable "${name}" is declared twice`, at);
|
|
151
|
+
}
|
|
152
|
+
variables.push(name);
|
|
153
|
+
}
|
|
154
|
+
return variables;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* One operation's HTTP binding, checked and branded. The members are
|
|
159
|
+
* written in §12.1's order — `method`, `path`, `in`, `body`, `status`,
|
|
160
|
+
* `media` — and only the ones declared: a default is the compiler's to
|
|
161
|
+
* materialize, never the pen's to write.
|
|
162
|
+
*
|
|
163
|
+
* @param {any} spec - `{ method, path, in?, body?, status?, media? }`
|
|
164
|
+
* @returns {any} the binding, frozen and branded
|
|
165
|
+
* @throws {LinqBuildError} `JL0101` a member that is not what it takes;
|
|
166
|
+
* `JL0102` a path template form the format reserves
|
|
167
|
+
* @example
|
|
168
|
+
* http({ method: 'PUT', path: '/api/products/{id}', in: { revision: 'body' } });
|
|
169
|
+
*/
|
|
170
|
+
export function http(spec) {
|
|
171
|
+
if (spec === null || typeof spec !== 'object' || Array.isArray(spec)) {
|
|
172
|
+
throw new LinqBuildError('JL0101',
|
|
173
|
+
`http() takes { method, path, in?, body?, status?, media? }, got ${describeValue(spec)}`);
|
|
174
|
+
}
|
|
175
|
+
for (const key of Object.keys(spec)) {
|
|
176
|
+
if (!HTTP_MEMBERS.includes(key)) {
|
|
177
|
+
throw new LinqBuildError('JL0101',
|
|
178
|
+
`http() does not take '${key}' — the binding is ${HTTP_MEMBERS.join(', ')} `
|
|
179
|
+
+ '(CONTRACT-FORMAT §4)', `/${key}`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (!HTTP_METHODS.includes(spec.method)) {
|
|
183
|
+
throw new LinqBuildError('JL0101',
|
|
184
|
+
`http() method is one uppercase token of ${HTTP_METHODS.join(' ')}, got `
|
|
185
|
+
+ `${describeValue(spec.method)}`, '/method');
|
|
186
|
+
}
|
|
187
|
+
const variables = pathVariables(spec.path, '/path');
|
|
188
|
+
|
|
189
|
+
const out = { method: spec.method, path: spec.path };
|
|
190
|
+
if (spec.in !== undefined) {
|
|
191
|
+
const locations = requireJson(spec.in, 'http() in');
|
|
192
|
+
if (locations === null || typeof locations !== 'object' || Array.isArray(locations)) {
|
|
193
|
+
throw new LinqBuildError('JL0101',
|
|
194
|
+
'http() in is a plain object of input member → path | query | header | body', '/in');
|
|
195
|
+
}
|
|
196
|
+
const placed = {};
|
|
197
|
+
for (const member of Object.keys(locations)) {
|
|
198
|
+
const where = locations[member];
|
|
199
|
+
if (!LOCATIONS.includes(where)) {
|
|
200
|
+
throw new LinqBuildError('JL0101',
|
|
201
|
+
`http() in.${member} is one of ${LOCATIONS.join(', ')}, got ${describeValue(where)}`,
|
|
202
|
+
`/in/${member}`);
|
|
203
|
+
}
|
|
204
|
+
if (where === 'path' && !variables.includes(member)) {
|
|
205
|
+
throw new LinqBuildError('JL0102',
|
|
206
|
+
`http() maps '${member}' to path, but the template declares no {${member}} — a `
|
|
207
|
+
+ 'path member is named by the template itself', `/in/${member}`);
|
|
208
|
+
}
|
|
209
|
+
placed[member] = where;
|
|
210
|
+
}
|
|
211
|
+
out.in = placed;
|
|
212
|
+
}
|
|
213
|
+
if (spec.body !== undefined) {
|
|
214
|
+
if (typeof spec.body !== 'string' || spec.body.length === 0) {
|
|
215
|
+
throw new LinqBuildError('JL0101',
|
|
216
|
+
`http() body names the input member whose value IS the request body, got `
|
|
217
|
+
+ `${describeValue(spec.body)}`, '/body');
|
|
218
|
+
}
|
|
219
|
+
out.body = spec.body;
|
|
220
|
+
}
|
|
221
|
+
if (spec.status !== undefined) {
|
|
222
|
+
if (!Number.isInteger(spec.status) || spec.status < 200 || spec.status > 299) {
|
|
223
|
+
throw new LinqBuildError('JL0101',
|
|
224
|
+
`http() status is an integer in 200–299, got ${describeValue(spec.status)}`, '/status');
|
|
225
|
+
}
|
|
226
|
+
out.status = spec.status;
|
|
227
|
+
}
|
|
228
|
+
if (spec.media !== undefined) {
|
|
229
|
+
if (typeof spec.media !== 'string' || spec.media.length === 0) {
|
|
230
|
+
throw new LinqBuildError('JL0101',
|
|
231
|
+
`http() media is a media type, got ${describeValue(spec.media)}`, '/media');
|
|
232
|
+
}
|
|
233
|
+
out.media = spec.media;
|
|
234
|
+
}
|
|
235
|
+
Object.defineProperty(out, HTTP_BINDING, { value: true, enumerable: false });
|
|
236
|
+
return Object.freeze(out);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Whether a value is an `http()` binding (as opposed to the same members
|
|
241
|
+
* written by hand, which `defineContract` accepts and checks the same way).
|
|
242
|
+
* @param {any} value
|
|
243
|
+
* @returns {boolean}
|
|
244
|
+
*/
|
|
245
|
+
export function isHttpBinding(value) {
|
|
246
|
+
return value !== null && typeof value === 'object' && value[HTTP_BINDING] === true;
|
|
247
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file `@jarenjs/linq/contract` — `$contract` 0.1 documents by code.
|
|
4
|
+
* `defineContract({ id?, version?, compat? }, operations)` writes the
|
|
5
|
+
* document `compileContract` takes, with every `named()` schema builder
|
|
6
|
+
* an operation reaches hoisted into the contract's own `$defs` and every
|
|
7
|
+
* member in the order CONTRACT-FORMAT §12.1 fixes, so a pen document and
|
|
8
|
+
* its own public projection differ by nothing but the defaults the
|
|
9
|
+
* compiler materializes. `read`/`command`/`subscribe` declare the three
|
|
10
|
+
* kinds, `http()` the REST binding (its path template checked here,
|
|
11
|
+
* earlier than the compiler's `JC0008`), `error()` one entry of an
|
|
12
|
+
* operation's `errors`.
|
|
13
|
+
*
|
|
14
|
+
* The identity wrappers — `typedClient`, `typedHttpClient`,
|
|
15
|
+
* `typedHandlers`, `typedTools` — carry the inferred `Operations` type
|
|
16
|
+
* onto a client (an HTTP one with its byte method), a handler table and
|
|
17
|
+
* an AI toolbox without running the TypeScript projection. The document is the deliverable; nothing here imports
|
|
18
|
+
* `@jarenjs/contract` or an engine.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export { defineContract, typedClient, typedHttpClient, typedHandlers, typedTools, Contract } from './define.js';
|
|
22
|
+
export { read, command, subscribe, error } from './operation.js';
|
|
23
|
+
export { http } from './http.js';
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file The operation declarations of the contract pen — `read()`,
|
|
4
|
+
* `command()`, `subscribe()` (CONTRACT-FORMAT §3's three kinds) and
|
|
5
|
+
* `error()` (an entry of an operation's `errors` map) — plus the policy
|
|
6
|
+
* vocabulary §3.1 fixes.
|
|
7
|
+
*
|
|
8
|
+
* These write no document: they carry a checked spec that
|
|
9
|
+
* `defineContract` lowers, because the schemas an operation names are
|
|
10
|
+
* hoisted into the contract's own `$defs` and only the whole document
|
|
11
|
+
* knows them. The pen refuses its own surface (a member it does not
|
|
12
|
+
* know, a policy value outside its declared set) and leaves the
|
|
13
|
+
* format's cross-member rules — a read that declares idempotency, a
|
|
14
|
+
* body-located member on a GET, two operations sharing a route shape —
|
|
15
|
+
* to `compileContract`, which is the only judge of them.
|
|
16
|
+
*
|
|
17
|
+
* No default is ever written: §3.1's defaults are materialized by the
|
|
18
|
+
* compiler and marked inferred by `describe()`. A pen that wrote them
|
|
19
|
+
* would turn every default into a declaration and move the revision for
|
|
20
|
+
* nothing.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { isJsonObject } from '@jarenjs/core/object';
|
|
24
|
+
import { LinqBuildError } from '../errors.js';
|
|
25
|
+
import { describeValue } from '../json-boundary.js';
|
|
26
|
+
|
|
27
|
+
/** The operation brand: how `defineContract` tells a declaration apart. */
|
|
28
|
+
export const OPERATION = Symbol.for('@jarenjs/linq/contract-operation');
|
|
29
|
+
|
|
30
|
+
/** The error-declaration brand. */
|
|
31
|
+
export const ERROR_DECLARATION = Symbol.for('@jarenjs/linq/contract-error');
|
|
32
|
+
|
|
33
|
+
/** The three kinds §3 declares. */
|
|
34
|
+
export const KINDS = Object.freeze(['read', 'command', 'subscribe']);
|
|
35
|
+
|
|
36
|
+
/** The members an operation spec accepts, in the order §12.1 fixes. */
|
|
37
|
+
export const OPERATION_MEMBERS = Object.freeze(['input', 'output', 'errors', 'policy', 'http', 'doc']);
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The policy members, in the order the pen writes them: §12.1's public
|
|
41
|
+
* order with the two server-side knobs (`limits`, `errors`) in their
|
|
42
|
+
* §3.1 positions — the projection drops those two, so their place is
|
|
43
|
+
* the source document's own.
|
|
44
|
+
*/
|
|
45
|
+
export const POLICY_MEMBERS = Object.freeze([
|
|
46
|
+
'task', 'idempotency', 'revision', 'cache', 'limits', 'errors', 'retry', 'stream', 'audience',
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
/** The value sets §3.1's table declares, by member. */
|
|
50
|
+
export const POLICY_VALUES = Object.freeze({
|
|
51
|
+
__proto__: null,
|
|
52
|
+
task: ['switch', 'exhaust', 'concat', 'parallel'],
|
|
53
|
+
idempotency: ['none', 'optional', 'required'],
|
|
54
|
+
cache: ['none', 'revision'],
|
|
55
|
+
audience: ['public', 'server'],
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
/** An error code: `^[a-z][a-z0-9-]*$` (§3's table). */
|
|
59
|
+
const CODE = /^[a-z][a-z0-9-]*$/;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A member set the pen knows, or `JL0101` naming the one it does not.
|
|
63
|
+
* @param {any} spec
|
|
64
|
+
* @param {readonly string[]} members
|
|
65
|
+
* @param {string} what
|
|
66
|
+
* @param {string} at
|
|
67
|
+
*/
|
|
68
|
+
function closedTo(spec, members, what, at) {
|
|
69
|
+
for (const key of Object.keys(spec)) {
|
|
70
|
+
if (!members.includes(key)) {
|
|
71
|
+
throw new LinqBuildError('JL0101',
|
|
72
|
+
`${what} does not take '${key}' — it takes ${members.join(', ')}`, `${at}/${key}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* One entry of `policy.retry`/`policy.stream`/`policy.limits`/
|
|
79
|
+
* `policy.errors`, checked against §3.1's table.
|
|
80
|
+
* @param {string} member
|
|
81
|
+
* @param {any} value
|
|
82
|
+
* @param {string} at
|
|
83
|
+
* @returns {any} the member's emitted value
|
|
84
|
+
*/
|
|
85
|
+
function policyMember(member, value, at) {
|
|
86
|
+
const set = POLICY_VALUES[member];
|
|
87
|
+
if (set !== undefined) {
|
|
88
|
+
if (!set.includes(value)) {
|
|
89
|
+
throw new LinqBuildError('JL0101',
|
|
90
|
+
`policy.${member} is one of ${set.join(', ')}, got ${describeValue(value)}`, at);
|
|
91
|
+
}
|
|
92
|
+
return value;
|
|
93
|
+
}
|
|
94
|
+
if (member === 'revision') {
|
|
95
|
+
if (typeof value !== 'string' || !value.startsWith('input:')) {
|
|
96
|
+
throw new LinqBuildError('JL0101',
|
|
97
|
+
'policy.revision is "input:<json-pointer>" — where in the input the revision a '
|
|
98
|
+
+ `command asserts lives, got ${describeValue(value)}`, at);
|
|
99
|
+
}
|
|
100
|
+
return value;
|
|
101
|
+
}
|
|
102
|
+
if (member === 'limits') {
|
|
103
|
+
if (!isJsonObject(value)) {
|
|
104
|
+
throw new LinqBuildError('JL0101', 'policy.limits is { maxBodyBytes }', at);
|
|
105
|
+
}
|
|
106
|
+
closedTo(value, ['maxBodyBytes'], 'policy.limits', at);
|
|
107
|
+
if (!Number.isInteger(value.maxBodyBytes) || value.maxBodyBytes <= 0) {
|
|
108
|
+
throw new LinqBuildError('JL0101',
|
|
109
|
+
`policy.limits.maxBodyBytes is a positive integer, got ${describeValue(value.maxBodyBytes)}`,
|
|
110
|
+
`${at}/maxBodyBytes`);
|
|
111
|
+
}
|
|
112
|
+
return { maxBodyBytes: value.maxBodyBytes };
|
|
113
|
+
}
|
|
114
|
+
if (member === 'errors') {
|
|
115
|
+
if (!isJsonObject(value)) {
|
|
116
|
+
throw new LinqBuildError('JL0101', 'policy.errors is { details }', at);
|
|
117
|
+
}
|
|
118
|
+
closedTo(value, ['details'], 'policy.errors', at);
|
|
119
|
+
if (!['none', 'paths', 'full'].includes(value.details)) {
|
|
120
|
+
throw new LinqBuildError('JL0101',
|
|
121
|
+
`policy.errors.details is one of none, paths, full, got ${describeValue(value.details)}`,
|
|
122
|
+
`${at}/details`);
|
|
123
|
+
}
|
|
124
|
+
return { details: value.details };
|
|
125
|
+
}
|
|
126
|
+
if (member === 'retry') {
|
|
127
|
+
if (!isJsonObject(value)) {
|
|
128
|
+
throw new LinqBuildError('JL0101', 'policy.retry is { max, on }', at);
|
|
129
|
+
}
|
|
130
|
+
closedTo(value, ['max', 'on'], 'policy.retry', at);
|
|
131
|
+
if (!Number.isInteger(value.max) || value.max < 0) {
|
|
132
|
+
throw new LinqBuildError('JL0101',
|
|
133
|
+
`policy.retry.max is an integer ≥ 0, got ${describeValue(value.max)}`, `${at}/max`);
|
|
134
|
+
}
|
|
135
|
+
if (!Array.isArray(value.on) || value.on.some((code) => typeof code !== 'string')) {
|
|
136
|
+
throw new LinqBuildError('JL0101',
|
|
137
|
+
'policy.retry.on is an array of error codes (declared codes, or JC2xxx taxonomy codes)',
|
|
138
|
+
`${at}/on`);
|
|
139
|
+
}
|
|
140
|
+
return { max: value.max, on: value.on.slice() };
|
|
141
|
+
}
|
|
142
|
+
// stream
|
|
143
|
+
if (!isJsonObject(value)) {
|
|
144
|
+
throw new LinqBuildError('JL0101', 'policy.stream is { resume?, heartbeatMs?, maxPatchBytes? }', at);
|
|
145
|
+
}
|
|
146
|
+
closedTo(value, ['resume', 'heartbeatMs', 'maxPatchBytes'], 'policy.stream', at);
|
|
147
|
+
const stream = {};
|
|
148
|
+
if (value.resume !== undefined) {
|
|
149
|
+
if (value.resume !== 'snapshot' && value.resume !== 'replay') {
|
|
150
|
+
throw new LinqBuildError('JL0101',
|
|
151
|
+
`policy.stream.resume is snapshot or replay, got ${describeValue(value.resume)}`,
|
|
152
|
+
`${at}/resume`);
|
|
153
|
+
}
|
|
154
|
+
stream.resume = value.resume;
|
|
155
|
+
}
|
|
156
|
+
if (value.heartbeatMs !== undefined) {
|
|
157
|
+
if (!Number.isInteger(value.heartbeatMs) || value.heartbeatMs < 1000) {
|
|
158
|
+
throw new LinqBuildError('JL0101',
|
|
159
|
+
`policy.stream.heartbeatMs is an integer ≥ 1000, got ${describeValue(value.heartbeatMs)}`,
|
|
160
|
+
`${at}/heartbeatMs`);
|
|
161
|
+
}
|
|
162
|
+
stream.heartbeatMs = value.heartbeatMs;
|
|
163
|
+
}
|
|
164
|
+
if (value.maxPatchBytes !== undefined) {
|
|
165
|
+
if (!Number.isInteger(value.maxPatchBytes) || value.maxPatchBytes <= 0) {
|
|
166
|
+
throw new LinqBuildError('JL0101',
|
|
167
|
+
'policy.stream.maxPatchBytes is a positive integer, got '
|
|
168
|
+
+ `${describeValue(value.maxPatchBytes)}`, `${at}/maxPatchBytes`);
|
|
169
|
+
}
|
|
170
|
+
stream.maxPatchBytes = value.maxPatchBytes;
|
|
171
|
+
}
|
|
172
|
+
return stream;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* One operation's `policy`, in the pen's member order, declared members
|
|
177
|
+
* only.
|
|
178
|
+
* @param {any} policy
|
|
179
|
+
* @param {string} at
|
|
180
|
+
* @returns {any}
|
|
181
|
+
*/
|
|
182
|
+
export function emitPolicy(policy, at) {
|
|
183
|
+
if (!isJsonObject(policy)) {
|
|
184
|
+
throw new LinqBuildError('JL0101',
|
|
185
|
+
`policy is a plain object of the members CONTRACT-FORMAT §3.1 declares, got `
|
|
186
|
+
+ `${describeValue(policy)}`, at);
|
|
187
|
+
}
|
|
188
|
+
closedTo(policy, POLICY_MEMBERS, 'policy', at);
|
|
189
|
+
const out = {};
|
|
190
|
+
for (const member of POLICY_MEMBERS) {
|
|
191
|
+
if (policy[member] === undefined) continue;
|
|
192
|
+
out[member] = policyMember(member, policy[member], `${at}/${member}`);
|
|
193
|
+
}
|
|
194
|
+
return out;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* One entry of an operation's `errors` map — `{ status?, schema? }`.
|
|
199
|
+
* The `schema` may be a schema-pen builder (hoisted into the contract's
|
|
200
|
+
* `$defs` like any other) or a JSON Schema written by hand.
|
|
201
|
+
*
|
|
202
|
+
* @param {any} [spec] - `{ status?, schema? }`
|
|
203
|
+
* @returns {any} the declaration, frozen and branded
|
|
204
|
+
* @throws {LinqBuildError} `JL0101` a member the declaration does not take
|
|
205
|
+
* @example
|
|
206
|
+
* error({ status: 409, schema: Conflict });
|
|
207
|
+
*/
|
|
208
|
+
export function error(spec = {}) {
|
|
209
|
+
if (!isJsonObject(spec)) {
|
|
210
|
+
throw new LinqBuildError('JL0101',
|
|
211
|
+
`error() takes { status?, schema? }, got ${describeValue(spec)}`);
|
|
212
|
+
}
|
|
213
|
+
closedTo(spec, ['status', 'schema'], 'error()', '');
|
|
214
|
+
if (spec.status !== undefined
|
|
215
|
+
&& (!Number.isInteger(spec.status) || spec.status < 100 || spec.status > 599)) {
|
|
216
|
+
throw new LinqBuildError('JL0101',
|
|
217
|
+
`error() status is an integer in 100–599, got ${describeValue(spec.status)}`, '/status');
|
|
218
|
+
}
|
|
219
|
+
const out = {};
|
|
220
|
+
if (spec.status !== undefined) out.status = spec.status;
|
|
221
|
+
if (spec.schema !== undefined) out.schema = spec.schema;
|
|
222
|
+
Object.defineProperty(out, ERROR_DECLARATION, { value: true, enumerable: false });
|
|
223
|
+
return Object.freeze(out);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* The `errors` map of an operation, checked: codes match §3's pattern,
|
|
228
|
+
* every entry is an `error()` declaration or the same members by hand.
|
|
229
|
+
* @param {any} errors
|
|
230
|
+
* @param {string} at
|
|
231
|
+
* @returns {[string, any][]} code → `{ status?, schema? }`, in declaration order
|
|
232
|
+
*/
|
|
233
|
+
export function readErrors(errors, at) {
|
|
234
|
+
if (!isJsonObject(errors)) {
|
|
235
|
+
throw new LinqBuildError('JL0101',
|
|
236
|
+
`errors is a plain object of code → error(), got ${describeValue(errors)}`, at);
|
|
237
|
+
}
|
|
238
|
+
return Object.keys(errors).map((code) => {
|
|
239
|
+
if (!CODE.test(code)) {
|
|
240
|
+
throw new LinqBuildError('JL0101',
|
|
241
|
+
`an error code matches ^[a-z][a-z0-9-]*$, got '${code}'`, `${at}/${code}`);
|
|
242
|
+
}
|
|
243
|
+
const declared = errors[code];
|
|
244
|
+
if (!isJsonObject(declared)) {
|
|
245
|
+
throw new LinqBuildError('JL0101',
|
|
246
|
+
`errors.${code} is error({ status?, schema? }), got ${describeValue(declared)}`,
|
|
247
|
+
`${at}/${code}`);
|
|
248
|
+
}
|
|
249
|
+
return [code, declared[ERROR_DECLARATION] === true ? declared : error(declared)];
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* One operation spec, checked against §3's member set. There are two
|
|
255
|
+
* doors into `defineContract`'s emitter and both run this: a
|
|
256
|
+
* `read()`/`command()`/`subscribe()` declaration, which has no position
|
|
257
|
+
* in the document yet, and an operation written by hand as `{ kind,
|
|
258
|
+
* …members }`, which does. A member this pen does not know must not
|
|
259
|
+
* reach the document whichever door it came through — the pen emits only
|
|
260
|
+
* what it was given, so an unchecked member is either dropped in silence
|
|
261
|
+
* or written into a document the grammar refuses.
|
|
262
|
+
* @param {string} kind
|
|
263
|
+
* @param {any} spec
|
|
264
|
+
* @param {string} [at] - the docPath of the operation being assembled;
|
|
265
|
+
* absent at declaration, where the operation has no id yet
|
|
266
|
+
*/
|
|
267
|
+
export function checkOperation(kind, spec, at) {
|
|
268
|
+
const base = at ?? '';
|
|
269
|
+
if (!isJsonObject(spec)) {
|
|
270
|
+
throw new LinqBuildError('JL0101',
|
|
271
|
+
`${kind}() takes { input?, output, errors?, policy?, http?, doc? }, got `
|
|
272
|
+
+ `${describeValue(spec)}`, at);
|
|
273
|
+
}
|
|
274
|
+
closedTo(spec, OPERATION_MEMBERS, `${kind}()`, base);
|
|
275
|
+
if (spec.output === undefined) {
|
|
276
|
+
throw new LinqBuildError('JL0101',
|
|
277
|
+
`${kind}() needs an output — every operation declares one (true for "any value")`,
|
|
278
|
+
`${base}/output`);
|
|
279
|
+
}
|
|
280
|
+
if (spec.doc !== undefined && typeof spec.doc !== 'string') {
|
|
281
|
+
throw new LinqBuildError('JL0101',
|
|
282
|
+
`${kind}() doc is a string, got ${describeValue(spec.doc)}`, `${base}/doc`);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* One operation declaration of `kind`.
|
|
288
|
+
* @param {string} kind
|
|
289
|
+
* @param {any} spec
|
|
290
|
+
* @returns {any}
|
|
291
|
+
*/
|
|
292
|
+
function operation(kind, spec) {
|
|
293
|
+
checkOperation(kind, spec);
|
|
294
|
+
const out = { kind, spec };
|
|
295
|
+
Object.defineProperty(out, OPERATION, { value: true, enumerable: false });
|
|
296
|
+
return Object.freeze(out);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* A `read` operation: a query whose result may be cached and whose
|
|
301
|
+
* input members default to the query string.
|
|
302
|
+
* @param {any} spec - `{ input?, output, errors?, policy?, http?, doc? }`
|
|
303
|
+
* @returns {any}
|
|
304
|
+
* @example
|
|
305
|
+
* read({ output: Catalog, http: http({ method: 'GET', path: '/api/catalog' }) });
|
|
306
|
+
*/
|
|
307
|
+
export function read(spec) { return operation('read', spec); }
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* A `command` operation: a state change whose input members default to
|
|
311
|
+
* the request body.
|
|
312
|
+
* @param {any} spec - `{ input?, output, errors?, policy?, http?, doc? }`
|
|
313
|
+
* @returns {any}
|
|
314
|
+
* @example
|
|
315
|
+
* command({ input: SaveInput, output: Product, errors: { conflict: error({ status: 409 }) } });
|
|
316
|
+
*/
|
|
317
|
+
export function command(spec) { return operation('command', spec); }
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* A `subscribe` operation (§17): its `output` is the snapshot schema
|
|
321
|
+
* and its emissions travel the stream binding. The compiler enforces
|
|
322
|
+
* the shape the wire requires (`GET`, `task: switch`, `idempotency:
|
|
323
|
+
* none`, the forced media); the pen writes what is declared.
|
|
324
|
+
* @param {any} spec - `{ input?, output, errors?, policy?, http?, doc? }`
|
|
325
|
+
* @returns {any}
|
|
326
|
+
* @example
|
|
327
|
+
* subscribe({ output: Board, policy: { stream: { resume: 'replay' } } });
|
|
328
|
+
*/
|
|
329
|
+
export function subscribe(spec) { return operation('subscribe', spec); }
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Whether a value is an operation declaration.
|
|
333
|
+
* @param {any} value
|
|
334
|
+
* @returns {boolean}
|
|
335
|
+
*/
|
|
336
|
+
export function isOperation(value) {
|
|
337
|
+
return value !== null && typeof value === 'object' && value[OPERATION] === true;
|
|
338
|
+
}
|