@jarenjs/linq 0.49.2 → 0.56.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/ARCHITECTURE.md +217 -0
- package/README.md +559 -17
- package/docs/APP-PEN.md +1143 -0
- package/docs/CONTRACT-PEN.md +1217 -0
- package/docs/DB-CLIENT.md +814 -0
- package/docs/FLOW-PEN.md +1026 -0
- package/docs/FORMS-PEN.md +940 -0
- package/docs/JSLT-PEN.md +955 -0
- package/docs/LINQ-FORMAT.md +771 -383
- package/docs/MIGRATION-PEN.md +781 -0
- package/docs/MODEL-PEN.md +1083 -0
- package/docs/QUERY-PEN.md +1636 -0
- package/docs/SCHEMA-PEN.md +1218 -0
- package/package.json +57 -4
- package/src/app/action.js +255 -0
- package/src/app/capture.js +63 -0
- package/src/app/define.js +260 -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 +329 -75
- package/src/capture-root.js +82 -0
- package/src/concurrency.js +9 -4
- package/src/contract/define.js +269 -0
- package/src/contract/http.js +247 -0
- package/src/contract/index.js +23 -0
- package/src/contract/operation.js +342 -0
- package/src/db/handle.js +86 -0
- package/src/db/include.js +316 -0
- package/src/db/index.js +19 -0
- package/src/db/live.js +43 -0
- package/src/db/membership.js +37 -0
- package/src/db/open.js +82 -0
- package/src/document.js +143 -13
- package/src/effect.js +65 -0
- package/src/errors.js +69 -6
- package/src/expression.js +437 -36
- package/src/flow/capture.js +33 -0
- package/src/flow/dag.js +302 -0
- package/src/flow/fsm.js +328 -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 +4 -2
- package/src/jslt/body.js +226 -0
- package/src/jslt/index.js +18 -0
- package/src/jslt/rules.js +207 -0
- package/src/json-boundary.js +90 -0
- package/src/migration/define.js +323 -0
- package/src/migration/index.js +15 -0
- package/src/migration/steps.js +248 -0
- package/src/model/collection.js +171 -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 +371 -0
- package/types/db.d.ts +188 -0
- package/types/flow.d.ts +285 -0
- package/types/forms.d.ts +253 -0
- package/types/index.d.ts +231 -26
- package/types/jslt.d.ts +193 -0
- package/types/migration.d.ts +201 -0
- package/types/model.d.ts +493 -0
- package/types/schema.d.ts +494 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file One capture over a value rooted at `$` with named externals —
|
|
4
|
+
* the shared piece under every pen's query-valued member: a schema
|
|
5
|
+
* `check()` rule (`root`, `path`), a model `compute()` default (no
|
|
6
|
+
* externals), a JSLT rule body (`root`, `path` and the parameters the
|
|
7
|
+
* body declares). The value proxy is rooted at `$`, the instance the
|
|
8
|
+
* query is evaluated at; the externals proxy answers exactly the named
|
|
9
|
+
* expressions and refuses any other name at build time (`JL0104`),
|
|
10
|
+
* earlier than the engine's own error and with the same meaning. One
|
|
11
|
+
* implementation: a pen adds an externals list, never a capture entry
|
|
12
|
+
* point.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { captureExpression } from './expression.js';
|
|
16
|
+
import { LinqBuildError } from './errors.js';
|
|
17
|
+
|
|
18
|
+
/** No parameters are declared for a rule: `p.x` cannot appear. */
|
|
19
|
+
const NO_PARAMS = new Set();
|
|
20
|
+
|
|
21
|
+
/** The indefinite article for a method name (`edge() select` → `an`). */
|
|
22
|
+
const article = (/** @type {string} */ what) =>
|
|
23
|
+
('aeiou'.includes(what.charAt(0).toLowerCase()) ? 'an' : 'a');
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The externals proxy: exactly the named expressions the capture handed
|
|
27
|
+
* us; anything else has nothing to bind to.
|
|
28
|
+
*
|
|
29
|
+
* The message states only what this file KNOWS — how many externals the
|
|
30
|
+
* evaluator binds and which — because that is all it can know: WHERE the
|
|
31
|
+
* query is evaluated differs per pen (a schema rule over the instance, a
|
|
32
|
+
* model default over the document being written, a flow guard over the
|
|
33
|
+
* step scope at step time), and naming one pen's answer here made the
|
|
34
|
+
* message contradict its own advice for every other pen. A caller that
|
|
35
|
+
* has a scope to name passes it as `advice`.
|
|
36
|
+
* @param {string} what - the method, for the message
|
|
37
|
+
* @param {readonly string[]} names - the externals bound
|
|
38
|
+
* @param {readonly any[]} proxies - one expression proxy per name
|
|
39
|
+
* @param {((name: string) => string) | undefined} advice - the fix, when one exists
|
|
40
|
+
* @param {string} noun - what this pen calls the callback
|
|
41
|
+
*/
|
|
42
|
+
function externalsProxy(what, names, proxies, advice, noun) {
|
|
43
|
+
return new Proxy(Object.freeze({}), {
|
|
44
|
+
get(_target, prop) {
|
|
45
|
+
if (typeof prop === 'symbol') return undefined;
|
|
46
|
+
const index = names.indexOf(/** @type {string} */ (prop));
|
|
47
|
+
if (index !== -1) return proxies[index];
|
|
48
|
+
const bound = names.length === 0
|
|
49
|
+
? 'no externals at all'
|
|
50
|
+
: `exactly ${names.length === 1 ? 'one external' : `${names.length} externals`}, `
|
|
51
|
+
+ `${names.map((n) => `'${n}'`).join(' and ')}`;
|
|
52
|
+
throw new LinqBuildError('JL0104',
|
|
53
|
+
`${article(what)} ${what} ${noun} cannot bind '${prop}' — its query evaluates with `
|
|
54
|
+
+ `${bound}; anything else has nothing to bind to`
|
|
55
|
+
+ (advice === undefined ? '' : advice(prop)));
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Capture one rule into a query document over a value rooted at `$`,
|
|
62
|
+
* with the named externals as the second argument.
|
|
63
|
+
* @param {string} what - the method, for the message
|
|
64
|
+
* @param {readonly string[]} externals - the externals the evaluator binds
|
|
65
|
+
* @param {(value: any, externals: any) => any} rule
|
|
66
|
+
* @param {{ advice?: (name: string) => string, fold?: boolean, noun?: string }} [options] -
|
|
67
|
+
* `advice`: appended to the `JL0104` message — how an unbound name could
|
|
68
|
+
* be declared where it can, or what the callback's own argument already
|
|
69
|
+
* is; `fold`: whether a pure data tree folds into one `$const` (the
|
|
70
|
+
* default) or is spelled as a constructor tree; `noun`: what this pen
|
|
71
|
+
* calls the callback (`'rule'` by default; a pen with no rules passes
|
|
72
|
+
* its own word)
|
|
73
|
+
* @returns {any} the captured query expression (plain JSON)
|
|
74
|
+
*/
|
|
75
|
+
export function captureQuery(what, externals, rule, options = {}) {
|
|
76
|
+
const noun = options.noun ?? 'rule';
|
|
77
|
+
return captureExpression(
|
|
78
|
+
(value, ...bound) => rule(value, externalsProxy(what, externals, bound, options.advice, noun)),
|
|
79
|
+
[{ doc: '$', pathable: true }, ...externals],
|
|
80
|
+
NO_PARAMS,
|
|
81
|
+
options.fold !== false);
|
|
82
|
+
}
|
package/src/concurrency.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
//@ts-check
|
|
2
2
|
/**
|
|
3
3
|
* @file `mapAsync` — the ONE explicit bounded-concurrency boundary
|
|
4
|
-
* (
|
|
4
|
+
* (QUERY-PEN.md §11). Element-wise asynchronous work (an HTTP call, a
|
|
5
5
|
* model call, a file read per row) happens here and nowhere else: there
|
|
6
6
|
* is no parallel universe of `selectAwait`-shaped operators, and a
|
|
7
7
|
* per-element async *predicate* is `mapAsync` then `where`, by design.
|
|
@@ -118,25 +118,30 @@ export async function* applyMapAsync(items, fn, opts) {
|
|
|
118
118
|
// parallel: a sliding window of `concurrency` in-flight tasks
|
|
119
119
|
const window = [];
|
|
120
120
|
let sourceDone = false;
|
|
121
|
+
// the first rejection anywhere in the window aborts every sibling
|
|
122
|
+
// and stops the pull, at once — not when it reaches the head of the
|
|
123
|
+
// ordered window: the rejection itself still surfaces in order
|
|
124
|
+
let rejected = false;
|
|
121
125
|
const pull = async () => {
|
|
122
126
|
const step = await items.next();
|
|
123
127
|
if (step.done) { sourceDone = true; return null; }
|
|
128
|
+
if (rejected) return null; // a sibling failed while this pull awaited
|
|
124
129
|
const promise = Promise.resolve(fn(step.value, signal));
|
|
125
130
|
// a rejection must wait its turn in the ordered window without
|
|
126
131
|
// firing unhandledRejection while an earlier task is in flight
|
|
127
|
-
promise.catch(() => {});
|
|
132
|
+
promise.catch(() => { rejected = true; controller.abort(); });
|
|
128
133
|
return { promise };
|
|
129
134
|
};
|
|
130
135
|
if (opts.ordered) {
|
|
131
136
|
// completion order = source order; the window buffers at most
|
|
132
137
|
// `concurrency` results (the documented buffering cost)
|
|
133
|
-
while (!sourceDone && window.length < opts.concurrency) {
|
|
138
|
+
while (!sourceDone && !rejected && window.length < opts.concurrency) {
|
|
134
139
|
const task = await pull();
|
|
135
140
|
if (task !== null) window.push(task.promise);
|
|
136
141
|
}
|
|
137
142
|
while (window.length > 0) {
|
|
138
143
|
const value = await window.shift();
|
|
139
|
-
if (!sourceDone) {
|
|
144
|
+
if (!sourceDone && !rejected) {
|
|
140
145
|
const task = await pull();
|
|
141
146
|
if (task !== null) window.push(task.promise);
|
|
142
147
|
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file `defineContract({ id?, version?, compat? }, operations)` — one
|
|
4
|
+
* `$contract` 0.1 document, deep-frozen, that `compileContract` takes
|
|
5
|
+
* unchanged.
|
|
6
|
+
*
|
|
7
|
+
* Two rules make the pen's document and its own public projection
|
|
8
|
+
* comparable member for member. First, the member ORDER is §12.1's, the
|
|
9
|
+
* normative one the revision hashes: root `$contract, id, version,
|
|
10
|
+
* compat, $defs, operations`; operation `kind, input, output, errors,
|
|
11
|
+
* policy, http, doc`; error `status, schema`; http `method, path, in,
|
|
12
|
+
* body, status, media`; `$defs` in first-reference order. Second, no
|
|
13
|
+
* DEFAULT is written: `describe()` marks a default inferred, and a pen
|
|
14
|
+
* that wrote them would turn every default into a declaration and move
|
|
15
|
+
* the revision for nothing.
|
|
16
|
+
*
|
|
17
|
+
* Every `named()` builder any operation reaches becomes one entry of the
|
|
18
|
+
* contract's own `$defs`, referenced `#/$defs/<name>` — the schema pen's
|
|
19
|
+
* hoisting walk over several roots instead of one. Nothing here imports
|
|
20
|
+
* `@jarenjs/contract`: the compiler stays the only judge of what the
|
|
21
|
+
* document means.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { cloneJson, deepFreeze, setObjectMember } from '@jarenjs/core/object';
|
|
25
|
+
|
|
26
|
+
import { LinqBuildError } from '../errors.js';
|
|
27
|
+
import { describeValue, requireJson, requireNameMap } from '../json-boundary.js';
|
|
28
|
+
import { isSchemaBuilder } from '../schema/brand.js';
|
|
29
|
+
import { createHoist, emitInto, hoistedDefs } from '../schema/emit.js';
|
|
30
|
+
import { http as httpBinding, isHttpBinding } from './http.js';
|
|
31
|
+
import { checkOperation, emitPolicy, isOperation, KINDS, readErrors } from './operation.js';
|
|
32
|
+
|
|
33
|
+
const CONTRACT_VERSION = '0.1';
|
|
34
|
+
const HEAD_MEMBERS = ['id', 'version', 'compat'];
|
|
35
|
+
|
|
36
|
+
/** `[A-Za-z_][A-Za-z0-9_-]*` — a contract id (§2.1). */
|
|
37
|
+
const ID = /^[A-Za-z_][A-Za-z0-9_-]*$/;
|
|
38
|
+
|
|
39
|
+
/** @param {any} value */
|
|
40
|
+
function isPlainObject(value) {
|
|
41
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* One schema position — `input`, `output`, an error's `schema` — as the
|
|
46
|
+
* document carries it: a builder emitted into the shared `$defs`
|
|
47
|
+
* context, or a JSON Schema written by hand, copied.
|
|
48
|
+
* @param {any} value
|
|
49
|
+
* @param {any} ctx - the hoisting context
|
|
50
|
+
* @param {string} at
|
|
51
|
+
* @returns {any}
|
|
52
|
+
*/
|
|
53
|
+
function schemaAt(value, ctx, at) {
|
|
54
|
+
if (isSchemaBuilder(value)) return emitInto(value, ctx, at);
|
|
55
|
+
const json = requireJson(value, `the schema at ${at}`);
|
|
56
|
+
if (typeof json !== 'boolean' && !isPlainObject(json)) {
|
|
57
|
+
throw new LinqBuildError('JL0101',
|
|
58
|
+
`a schema is an object, true or false — got ${describeValue(value)}`, at);
|
|
59
|
+
}
|
|
60
|
+
return cloneJson(json);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The kind and spec of one declared operation: a `read()`/`command()`/
|
|
65
|
+
* `subscribe()` declaration, or the same members written by hand with a
|
|
66
|
+
* `kind`.
|
|
67
|
+
*
|
|
68
|
+
* The hand-written form is checked HERE by the same predicate the three
|
|
69
|
+
* declaration functions run, against its own position in the document.
|
|
70
|
+
* It is a second door into one emitter, and a door with no check behind
|
|
71
|
+
* it is worse than no door: an unknown member would be dropped in
|
|
72
|
+
* silence (the pen never sees it again, and the compiler never sees it
|
|
73
|
+
* at all), and a non-string `doc` would be written into a document
|
|
74
|
+
* `jaren-contract` refuses.
|
|
75
|
+
* @param {any} declared
|
|
76
|
+
* @param {string} at
|
|
77
|
+
* @returns {{ kind: string, spec: any }}
|
|
78
|
+
*/
|
|
79
|
+
function declarationOf(declared, at) {
|
|
80
|
+
if (isOperation(declared)) return declared;
|
|
81
|
+
if (!isPlainObject(declared)) {
|
|
82
|
+
throw new LinqBuildError('JL0101',
|
|
83
|
+
`an operation is read(), command() or subscribe() — got ${describeValue(declared)}`, at);
|
|
84
|
+
}
|
|
85
|
+
if (!KINDS.includes(declared.kind)) {
|
|
86
|
+
throw new LinqBuildError('JL0102',
|
|
87
|
+
`an operation kind is one of ${KINDS.join(', ')} — got ${describeValue(declared.kind)}`,
|
|
88
|
+
`${at}/kind`);
|
|
89
|
+
}
|
|
90
|
+
const { kind, ...spec } = declared;
|
|
91
|
+
checkOperation(kind, spec, at);
|
|
92
|
+
return { kind, spec };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Emit one operation, in §12.1's member order, declared members only.
|
|
97
|
+
* @param {string} id
|
|
98
|
+
* @param {any} declared
|
|
99
|
+
* @param {any} ctx - the hoisting context
|
|
100
|
+
* @returns {any}
|
|
101
|
+
*/
|
|
102
|
+
function emitOperation(id, declared, ctx) {
|
|
103
|
+
const at = `/operations/${id}`;
|
|
104
|
+
const { kind, spec } = declarationOf(declared, at);
|
|
105
|
+
const out = { kind };
|
|
106
|
+
if (spec.input !== undefined) out.input = schemaAt(spec.input, ctx, `${at}/input`);
|
|
107
|
+
out.output = schemaAt(spec.output, ctx, `${at}/output`);
|
|
108
|
+
if (spec.errors !== undefined) {
|
|
109
|
+
const declaredErrors = readErrors(spec.errors, `${at}/errors`);
|
|
110
|
+
const errors = {};
|
|
111
|
+
for (const [code, entry] of declaredErrors) {
|
|
112
|
+
const e = {};
|
|
113
|
+
if (entry.status !== undefined) e.status = entry.status;
|
|
114
|
+
if (entry.schema !== undefined) {
|
|
115
|
+
e.schema = schemaAt(entry.schema, ctx, `${at}/errors/${code}/schema`);
|
|
116
|
+
}
|
|
117
|
+
setObjectMember(errors, code, e);
|
|
118
|
+
}
|
|
119
|
+
out.errors = errors;
|
|
120
|
+
}
|
|
121
|
+
if (spec.policy !== undefined) out.policy = emitPolicy(spec.policy, `${at}/policy`);
|
|
122
|
+
if (spec.http !== undefined) {
|
|
123
|
+
out.http = isHttpBinding(spec.http) ? { ...spec.http } : { ...httpBinding(spec.http) };
|
|
124
|
+
}
|
|
125
|
+
if (spec.doc !== undefined) out.doc = spec.doc;
|
|
126
|
+
return out;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The contract under construction: frozen on creation, its document
|
|
131
|
+
* assembled once. `document` and `toJSON()` are the same deep-frozen
|
|
132
|
+
* `$contract` 0.1 JSON; the phantom the declarations carry is what
|
|
133
|
+
* `ContractOf<>` reads.
|
|
134
|
+
*/
|
|
135
|
+
export class Contract {
|
|
136
|
+
#document;
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* @param {any} document - the assembled document
|
|
140
|
+
*/
|
|
141
|
+
constructor(document) {
|
|
142
|
+
this.#document = document;
|
|
143
|
+
Object.freeze(this);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** The deep-frozen `$contract` 0.1 document. */
|
|
147
|
+
get document() { return this.#document; }
|
|
148
|
+
|
|
149
|
+
/** The document — what `JSON.stringify` writes. @returns {any} */
|
|
150
|
+
toJSON() { return this.#document; }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Write a `$contract` 0.1 document.
|
|
155
|
+
*
|
|
156
|
+
* @param {any} meta - `{ id?, version?, compat? }` (§2.1)
|
|
157
|
+
* @param {any} operations - operation id → `read()` / `command()` / `subscribe()`
|
|
158
|
+
* @returns {Contract} the contract, its `document` deep-frozen JSON
|
|
159
|
+
* @throws {LinqBuildError} `JL0101` a value the pen cannot spell;
|
|
160
|
+
* `JL0102` a kind or path template the format reserves; `JL0103` two
|
|
161
|
+
* distinct builders under one `$defs` name, or a `ref()` nothing defines
|
|
162
|
+
* @example
|
|
163
|
+
* const shop = defineContract({ id: 'shop', version: '5' }, {
|
|
164
|
+
* 'catalog.load': read({ output: Catalog, http: http({ method: 'GET', path: '/api/catalog' }) }),
|
|
165
|
+
* });
|
|
166
|
+
* compileContract(shop.document).ids; // ['catalog.load']
|
|
167
|
+
*/
|
|
168
|
+
export function defineContract(meta, operations) {
|
|
169
|
+
if (!isPlainObject(meta)) {
|
|
170
|
+
throw new LinqBuildError('JL0101',
|
|
171
|
+
`defineContract() takes ({ id?, version?, compat? }, operations), got `
|
|
172
|
+
+ `${describeValue(meta)} as its first argument`);
|
|
173
|
+
}
|
|
174
|
+
for (const key of Object.keys(meta)) {
|
|
175
|
+
if (!HEAD_MEMBERS.includes(key)) {
|
|
176
|
+
throw new LinqBuildError('JL0101',
|
|
177
|
+
`defineContract() does not take '${key}' — the head is ${HEAD_MEMBERS.join(', ')}; `
|
|
178
|
+
+ 'everything else is an operation', `/${key}`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
if (meta.id !== undefined && (typeof meta.id !== 'string' || !ID.test(meta.id))) {
|
|
182
|
+
throw new LinqBuildError('JL0101',
|
|
183
|
+
`defineContract() id matches [A-Za-z_][A-Za-z0-9_-]*, got ${describeValue(meta.id)}`, '/id');
|
|
184
|
+
}
|
|
185
|
+
if (meta.version !== undefined && typeof meta.version !== 'string') {
|
|
186
|
+
throw new LinqBuildError('JL0101',
|
|
187
|
+
`defineContract() version is a string, got ${describeValue(meta.version)}`, '/version');
|
|
188
|
+
}
|
|
189
|
+
if (meta.compat !== undefined
|
|
190
|
+
&& (!Array.isArray(meta.compat) || meta.compat.some((v) => typeof v !== 'string'))) {
|
|
191
|
+
throw new LinqBuildError('JL0101',
|
|
192
|
+
'defineContract() compat is an array of peer version strings', '/compat');
|
|
193
|
+
}
|
|
194
|
+
if (!isPlainObject(operations)) {
|
|
195
|
+
throw new LinqBuildError('JL0101',
|
|
196
|
+
`defineContract() operations is a plain object of id → operation, got `
|
|
197
|
+
+ `${describeValue(operations)}`, '/operations');
|
|
198
|
+
}
|
|
199
|
+
requireNameMap(operations, 'defineContract() operations', '/operations');
|
|
200
|
+
const ids = Object.keys(operations);
|
|
201
|
+
if (ids.length === 0) {
|
|
202
|
+
throw new LinqBuildError('JL0101',
|
|
203
|
+
'defineContract() needs at least one operation', '/operations');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const ctx = createHoist();
|
|
207
|
+
const emitted = ids.map((id) => [id, emitOperation(id, operations[id], ctx)]);
|
|
208
|
+
const defs = hoistedDefs(ctx);
|
|
209
|
+
|
|
210
|
+
const out = { $contract: CONTRACT_VERSION };
|
|
211
|
+
if (meta.id !== undefined) out.id = meta.id;
|
|
212
|
+
if (meta.version !== undefined) out.version = meta.version;
|
|
213
|
+
if (meta.compat !== undefined) out.compat = meta.compat.slice();
|
|
214
|
+
if (defs !== null) out.$defs = defs;
|
|
215
|
+
const ops = {};
|
|
216
|
+
for (const [id, op] of emitted) setObjectMember(ops, id, op);
|
|
217
|
+
out.operations = ops;
|
|
218
|
+
return new Contract(deepFreeze(out));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Bind a contract client to the contract that types it. Identity at
|
|
223
|
+
* runtime: `invoke`, `url` and `subscribe` are narrowed by the pen's
|
|
224
|
+
* phantoms, and nothing is added to the binding.
|
|
225
|
+
* @template C
|
|
226
|
+
* @param {any} client - any contract client (local, http, port)
|
|
227
|
+
* @param {C} contract - the pen's contract; the type argument only
|
|
228
|
+
* @returns {any}
|
|
229
|
+
* @example
|
|
230
|
+
* const api = typedClient(openLocalClient(compileContract(shop.document), handlers), shop);
|
|
231
|
+
* const outcome = await api.invoke('product.save', { id: 1, revision: 4, product });
|
|
232
|
+
*/
|
|
233
|
+
export function typedClient(client, contract) {
|
|
234
|
+
void contract;
|
|
235
|
+
return client;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Bind a handler table to the contract it serves. Identity at runtime:
|
|
240
|
+
* the table is checked against the operation ids and each handler's
|
|
241
|
+
* input and output typed by the pen's phantoms.
|
|
242
|
+
* @template C
|
|
243
|
+
* @template H
|
|
244
|
+
* @param {C} contract - the pen's contract; the type argument only
|
|
245
|
+
* @param {H} handlers - operation id → handler
|
|
246
|
+
* @returns {H}
|
|
247
|
+
* @example
|
|
248
|
+
* serveHttp(compiled, typedHandlers(shop, { 'catalog.load': () => catalog }));
|
|
249
|
+
*/
|
|
250
|
+
export function typedHandlers(contract, handlers) {
|
|
251
|
+
void contract;
|
|
252
|
+
return handlers;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Bind `contractTools`' output to the contract that types it. Identity
|
|
257
|
+
* at runtime: each tool's `name` and `execute` argument are narrowed by
|
|
258
|
+
* the pen's phantoms.
|
|
259
|
+
* @template C
|
|
260
|
+
* @param {any} tools - what `contractTools(compiled, client)` answered
|
|
261
|
+
* @param {C} contract - the pen's contract; the type argument only
|
|
262
|
+
* @returns {any}
|
|
263
|
+
* @example
|
|
264
|
+
* for (const tool of typedTools(contractTools(compiled, client), shop)) toolbox.add(tool);
|
|
265
|
+
*/
|
|
266
|
+
export function typedTools(tools, contract) {
|
|
267
|
+
void contract;
|
|
268
|
+
return tools;
|
|
269
|
+
}
|
|
@@ -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 three identity wrappers — `typedClient`, `typedHandlers`,
|
|
15
|
+
* `typedTools` — carry the inferred `Operations` type onto a client, a
|
|
16
|
+
* handler table and an AI toolbox without running the TypeScript
|
|
17
|
+
* projection. The document is the deliverable; nothing here imports
|
|
18
|
+
* `@jarenjs/contract` or an engine.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export { defineContract, typedClient, typedHandlers, typedTools, Contract } from './define.js';
|
|
22
|
+
export { read, command, subscribe, error } from './operation.js';
|
|
23
|
+
export { http } from './http.js';
|