@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,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.
|
|
@@ -54,6 +54,18 @@ export async function* applyMapAsync(items, fn, opts) {
|
|
|
54
54
|
/** The pipeline's own failure, so source cleanup cannot displace it.
|
|
55
55
|
* @type {{ reason: any } | null} */
|
|
56
56
|
let failure = null;
|
|
57
|
+
/** A callback failure may close while a pull is pending; finally awaits
|
|
58
|
+
* that same close, so cancellation reaches the producer exactly once.
|
|
59
|
+
* @type {Promise<any> | null} */
|
|
60
|
+
let closing = null;
|
|
61
|
+
const closeSource = () => {
|
|
62
|
+
if (closing === null) {
|
|
63
|
+
closing = Promise.resolve().then(() => typeof items.return === 'function'
|
|
64
|
+
? items.return(undefined) : undefined);
|
|
65
|
+
closing.catch(() => {}); // the finally block reports cleanup failures
|
|
66
|
+
}
|
|
67
|
+
return closing;
|
|
68
|
+
};
|
|
57
69
|
|
|
58
70
|
try {
|
|
59
71
|
if (opts.mode === 'concat' || (opts.mode === 'parallel' && opts.concurrency === 1)) {
|
|
@@ -118,25 +130,45 @@ export async function* applyMapAsync(items, fn, opts) {
|
|
|
118
130
|
// parallel: a sliding window of `concurrency` in-flight tasks
|
|
119
131
|
const window = [];
|
|
120
132
|
let sourceDone = false;
|
|
133
|
+
// the first rejection anywhere in the window aborts every sibling
|
|
134
|
+
// and stops the pull, at once — not when it reaches the head of the
|
|
135
|
+
// ordered window: the rejection itself still surfaces in order
|
|
136
|
+
let rejected = false;
|
|
137
|
+
let stopPull;
|
|
138
|
+
/** @type {Promise<IteratorResult<any>>} */
|
|
139
|
+
const stoppedPull = new Promise((resolve) => {
|
|
140
|
+
stopPull = () => resolve({ done: true, value: undefined });
|
|
141
|
+
});
|
|
142
|
+
const taskFailed = () => {
|
|
143
|
+
if (rejected) return;
|
|
144
|
+
rejected = true;
|
|
145
|
+
stopPull();
|
|
146
|
+
controller.abort();
|
|
147
|
+
closeSource();
|
|
148
|
+
};
|
|
149
|
+
// An idle producer must not hide a callback's failure. The race also
|
|
150
|
+
// observes a pending next() that rejects after cancellation won.
|
|
151
|
+
const next = () => Promise.race([items.next(), stoppedPull]);
|
|
121
152
|
const pull = async () => {
|
|
122
|
-
const step = await
|
|
153
|
+
const step = await next();
|
|
123
154
|
if (step.done) { sourceDone = true; return null; }
|
|
155
|
+
if (rejected) return null; // a sibling failed while this pull awaited
|
|
124
156
|
const promise = Promise.resolve(fn(step.value, signal));
|
|
125
157
|
// a rejection must wait its turn in the ordered window without
|
|
126
158
|
// firing unhandledRejection while an earlier task is in flight
|
|
127
|
-
promise.catch(
|
|
159
|
+
promise.catch(taskFailed);
|
|
128
160
|
return { promise };
|
|
129
161
|
};
|
|
130
162
|
if (opts.ordered) {
|
|
131
163
|
// completion order = source order; the window buffers at most
|
|
132
164
|
// `concurrency` results (the documented buffering cost)
|
|
133
|
-
while (!sourceDone && window.length < opts.concurrency) {
|
|
165
|
+
while (!sourceDone && !rejected && window.length < opts.concurrency) {
|
|
134
166
|
const task = await pull();
|
|
135
167
|
if (task !== null) window.push(task.promise);
|
|
136
168
|
}
|
|
137
169
|
while (window.length > 0) {
|
|
138
170
|
const value = await window.shift();
|
|
139
|
-
if (!sourceDone) {
|
|
171
|
+
if (!sourceDone && !rejected) {
|
|
140
172
|
const task = await pull();
|
|
141
173
|
if (task !== null) window.push(task.promise);
|
|
142
174
|
}
|
|
@@ -148,19 +180,24 @@ export async function* applyMapAsync(items, fn, opts) {
|
|
|
148
180
|
let nextId = 0;
|
|
149
181
|
const inflight = new Map();
|
|
150
182
|
const start = async () => {
|
|
151
|
-
const step = await
|
|
183
|
+
const step = await next();
|
|
152
184
|
if (step.done) { sourceDone = true; return; }
|
|
185
|
+
if (rejected) return; // a task failed while this source pull awaited
|
|
153
186
|
const id = nextId++;
|
|
154
|
-
|
|
187
|
+
const promise = Promise.resolve(fn(step.value, signal)).then(
|
|
155
188
|
(value) => ({ id, value }),
|
|
156
189
|
// the task's identity rides in an internal ENVELOPE, never on the
|
|
157
190
|
// rejection value. Stamping the value mutated whatever the handler
|
|
158
191
|
// threw: a frozen error became a different TypeError, a thrown
|
|
159
192
|
// string came back boxed, an ordinary error grew a private
|
|
160
193
|
// property, and a hostile proxy could break normalization outright.
|
|
161
|
-
(error) => { throw new TaskFailure(id, error); })
|
|
194
|
+
(error) => { throw new TaskFailure(id, error); });
|
|
195
|
+
// Observe failures immediately, including while the next source
|
|
196
|
+
// pull is pending or downstream has stopped consuming the window.
|
|
197
|
+
promise.catch(taskFailed);
|
|
198
|
+
inflight.set(id, promise);
|
|
162
199
|
};
|
|
163
|
-
while (!sourceDone && inflight.size < opts.concurrency) await start();
|
|
200
|
+
while (!sourceDone && !rejected && inflight.size < opts.concurrency) await start();
|
|
164
201
|
while (inflight.size > 0) {
|
|
165
202
|
let settled;
|
|
166
203
|
try {
|
|
@@ -174,7 +211,7 @@ export async function* applyMapAsync(items, fn, opts) {
|
|
|
174
211
|
throw err;
|
|
175
212
|
}
|
|
176
213
|
inflight.delete(settled.id);
|
|
177
|
-
if (!sourceDone) await start();
|
|
214
|
+
if (!sourceDone && !rejected) await start();
|
|
178
215
|
yield settled.value;
|
|
179
216
|
}
|
|
180
217
|
}
|
|
@@ -191,7 +228,7 @@ export async function* applyMapAsync(items, fn, opts) {
|
|
|
191
228
|
// itself succeeded. Composed as a rejection the `await` adopts,
|
|
192
229
|
// because a `throw` here would be the very substitution this
|
|
193
230
|
// avoids: it discards whatever completion the block was carrying.
|
|
194
|
-
await
|
|
231
|
+
await closeSource().then(undefined,
|
|
195
232
|
(cleanupError) => Promise.reject(failure === null
|
|
196
233
|
? cleanupError
|
|
197
234
|
: new AggregateError([failure.reason, cleanupError],
|
|
@@ -0,0 +1,282 @@
|
|
|
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, isJsonObject } 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
|
+
/**
|
|
40
|
+
* One schema position — `input`, `output`, an error's `schema` — as the
|
|
41
|
+
* document carries it: a builder emitted into the shared `$defs`
|
|
42
|
+
* context, or a JSON Schema written by hand, copied.
|
|
43
|
+
* @param {any} value
|
|
44
|
+
* @param {any} ctx - the hoisting context
|
|
45
|
+
* @param {string} at
|
|
46
|
+
* @returns {any}
|
|
47
|
+
*/
|
|
48
|
+
function schemaAt(value, ctx, at) {
|
|
49
|
+
if (isSchemaBuilder(value)) return emitInto(value, ctx, at);
|
|
50
|
+
const json = requireJson(value, `the schema at ${at}`);
|
|
51
|
+
if (typeof json !== 'boolean' && !isJsonObject(json)) {
|
|
52
|
+
throw new LinqBuildError('JL0101',
|
|
53
|
+
`a schema is an object, true or false — got ${describeValue(value)}`, at);
|
|
54
|
+
}
|
|
55
|
+
return cloneJson(json);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The kind and spec of one declared operation: a `read()`/`command()`/
|
|
60
|
+
* `subscribe()` declaration, or the same members written by hand with a
|
|
61
|
+
* `kind`.
|
|
62
|
+
*
|
|
63
|
+
* The hand-written form is checked HERE by the same predicate the three
|
|
64
|
+
* declaration functions run, against its own position in the document.
|
|
65
|
+
* It is a second door into one emitter, and a door with no check behind
|
|
66
|
+
* it is worse than no door: an unknown member would be dropped in
|
|
67
|
+
* silence (the pen never sees it again, and the compiler never sees it
|
|
68
|
+
* at all), and a non-string `doc` would be written into a document
|
|
69
|
+
* `jaren-contract` refuses.
|
|
70
|
+
* @param {any} declared
|
|
71
|
+
* @param {string} at
|
|
72
|
+
* @returns {{ kind: string, spec: any }}
|
|
73
|
+
*/
|
|
74
|
+
function declarationOf(declared, at) {
|
|
75
|
+
if (isOperation(declared)) return declared;
|
|
76
|
+
if (!isJsonObject(declared)) {
|
|
77
|
+
throw new LinqBuildError('JL0101',
|
|
78
|
+
`an operation is read(), command() or subscribe() — got ${describeValue(declared)}`, at);
|
|
79
|
+
}
|
|
80
|
+
if (!KINDS.includes(declared.kind)) {
|
|
81
|
+
throw new LinqBuildError('JL0102',
|
|
82
|
+
`an operation kind is one of ${KINDS.join(', ')} — got ${describeValue(declared.kind)}`,
|
|
83
|
+
`${at}/kind`);
|
|
84
|
+
}
|
|
85
|
+
const { kind, ...spec } = declared;
|
|
86
|
+
checkOperation(kind, spec, at);
|
|
87
|
+
return { kind, spec };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Emit one operation, in §12.1's member order, declared members only.
|
|
92
|
+
* @param {string} id
|
|
93
|
+
* @param {any} declared
|
|
94
|
+
* @param {any} ctx - the hoisting context
|
|
95
|
+
* @returns {any}
|
|
96
|
+
*/
|
|
97
|
+
function emitOperation(id, declared, ctx) {
|
|
98
|
+
const at = `/operations/${id}`;
|
|
99
|
+
const { kind, spec } = declarationOf(declared, at);
|
|
100
|
+
const out = { kind };
|
|
101
|
+
if (spec.input !== undefined) out.input = schemaAt(spec.input, ctx, `${at}/input`);
|
|
102
|
+
out.output = schemaAt(spec.output, ctx, `${at}/output`);
|
|
103
|
+
if (spec.errors !== undefined) {
|
|
104
|
+
const declaredErrors = readErrors(spec.errors, `${at}/errors`);
|
|
105
|
+
const errors = {};
|
|
106
|
+
for (const [code, entry] of declaredErrors) {
|
|
107
|
+
const e = {};
|
|
108
|
+
if (entry.status !== undefined) e.status = entry.status;
|
|
109
|
+
if (entry.schema !== undefined) {
|
|
110
|
+
e.schema = schemaAt(entry.schema, ctx, `${at}/errors/${code}/schema`);
|
|
111
|
+
}
|
|
112
|
+
setObjectMember(errors, code, e);
|
|
113
|
+
}
|
|
114
|
+
out.errors = errors;
|
|
115
|
+
}
|
|
116
|
+
if (spec.policy !== undefined) out.policy = emitPolicy(spec.policy, `${at}/policy`);
|
|
117
|
+
if (spec.http !== undefined) {
|
|
118
|
+
out.http = isHttpBinding(spec.http) ? { ...spec.http } : { ...httpBinding(spec.http) };
|
|
119
|
+
}
|
|
120
|
+
if (spec.doc !== undefined) out.doc = spec.doc;
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The contract under construction: frozen on creation, its document
|
|
126
|
+
* assembled once. `document` and `toJSON()` are the same deep-frozen
|
|
127
|
+
* `$contract` 0.1 JSON; the phantom the declarations carry is what
|
|
128
|
+
* `ContractOf<>` reads.
|
|
129
|
+
*/
|
|
130
|
+
export class Contract {
|
|
131
|
+
#document;
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* @param {any} document - the assembled document
|
|
135
|
+
*/
|
|
136
|
+
constructor(document) {
|
|
137
|
+
this.#document = document;
|
|
138
|
+
Object.freeze(this);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The deep-frozen `$contract` 0.1 document. */
|
|
142
|
+
get document() { return this.#document; }
|
|
143
|
+
|
|
144
|
+
/** The document — what `JSON.stringify` writes. @returns {any} */
|
|
145
|
+
toJSON() { return this.#document; }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Write a `$contract` 0.1 document.
|
|
150
|
+
*
|
|
151
|
+
* @param {any} meta - `{ id?, version?, compat? }` (§2.1)
|
|
152
|
+
* @param {any} operations - operation id → `read()` / `command()` / `subscribe()`
|
|
153
|
+
* @returns {Contract} the contract, its `document` deep-frozen JSON
|
|
154
|
+
* @throws {LinqBuildError} `JL0101` a value the pen cannot spell;
|
|
155
|
+
* `JL0102` a kind or path template the format reserves; `JL0103` two
|
|
156
|
+
* distinct builders under one `$defs` name, or a `ref()` nothing defines
|
|
157
|
+
* @example
|
|
158
|
+
* const shop = defineContract({ id: 'shop', version: '5' }, {
|
|
159
|
+
* 'catalog.load': read({ output: Catalog, http: http({ method: 'GET', path: '/api/catalog' }) }),
|
|
160
|
+
* });
|
|
161
|
+
* compileContract(shop.document).ids; // ['catalog.load']
|
|
162
|
+
*/
|
|
163
|
+
export function defineContract(meta, operations) {
|
|
164
|
+
if (!isJsonObject(meta)) {
|
|
165
|
+
throw new LinqBuildError('JL0101',
|
|
166
|
+
`defineContract() takes ({ id?, version?, compat? }, operations), got `
|
|
167
|
+
+ `${describeValue(meta)} as its first argument`);
|
|
168
|
+
}
|
|
169
|
+
for (const key of Object.keys(meta)) {
|
|
170
|
+
if (!HEAD_MEMBERS.includes(key)) {
|
|
171
|
+
throw new LinqBuildError('JL0101',
|
|
172
|
+
`defineContract() does not take '${key}' — the head is ${HEAD_MEMBERS.join(', ')}; `
|
|
173
|
+
+ 'everything else is an operation', `/${key}`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
if (meta.id !== undefined && (typeof meta.id !== 'string' || !ID.test(meta.id))) {
|
|
177
|
+
throw new LinqBuildError('JL0101',
|
|
178
|
+
`defineContract() id matches [A-Za-z_][A-Za-z0-9_-]*, got ${describeValue(meta.id)}`, '/id');
|
|
179
|
+
}
|
|
180
|
+
if (meta.version !== undefined && typeof meta.version !== 'string') {
|
|
181
|
+
throw new LinqBuildError('JL0101',
|
|
182
|
+
`defineContract() version is a string, got ${describeValue(meta.version)}`, '/version');
|
|
183
|
+
}
|
|
184
|
+
if (meta.compat !== undefined
|
|
185
|
+
&& (!Array.isArray(meta.compat) || meta.compat.some((v) => typeof v !== 'string'))) {
|
|
186
|
+
throw new LinqBuildError('JL0101',
|
|
187
|
+
'defineContract() compat is an array of peer version strings', '/compat');
|
|
188
|
+
}
|
|
189
|
+
if (!isJsonObject(operations)) {
|
|
190
|
+
throw new LinqBuildError('JL0101',
|
|
191
|
+
`defineContract() operations is a plain object of id → operation, got `
|
|
192
|
+
+ `${describeValue(operations)}`, '/operations');
|
|
193
|
+
}
|
|
194
|
+
requireNameMap(operations, 'defineContract() operations', '/operations');
|
|
195
|
+
const ids = Object.keys(operations);
|
|
196
|
+
if (ids.length === 0) {
|
|
197
|
+
throw new LinqBuildError('JL0101',
|
|
198
|
+
'defineContract() needs at least one operation', '/operations');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const ctx = createHoist();
|
|
202
|
+
const emitted = ids.map((id) => [id, emitOperation(id, operations[id], ctx)]);
|
|
203
|
+
const defs = hoistedDefs(ctx);
|
|
204
|
+
|
|
205
|
+
const out = { $contract: CONTRACT_VERSION };
|
|
206
|
+
if (meta.id !== undefined) out.id = meta.id;
|
|
207
|
+
if (meta.version !== undefined) out.version = meta.version;
|
|
208
|
+
if (meta.compat !== undefined) out.compat = meta.compat.slice();
|
|
209
|
+
if (defs !== null) out.$defs = defs;
|
|
210
|
+
const ops = {};
|
|
211
|
+
for (const [id, op] of emitted) setObjectMember(ops, id, op);
|
|
212
|
+
out.operations = ops;
|
|
213
|
+
return new Contract(deepFreeze(out));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Bind a contract client to the contract that types it. Identity at
|
|
218
|
+
* runtime: `invoke`, `url` and `subscribe` are narrowed by the pen's
|
|
219
|
+
* phantoms, and nothing is added to the binding.
|
|
220
|
+
* @template C
|
|
221
|
+
* @param {any} client - any contract client (local, http, port)
|
|
222
|
+
* @param {C} contract - the pen's contract; the type argument only
|
|
223
|
+
* @returns {any}
|
|
224
|
+
* @example
|
|
225
|
+
* const api = typedClient(openLocalClient(compileContract(shop.document), handlers), shop);
|
|
226
|
+
* const outcome = await api.invoke('product.save', { id: 1, revision: 4, product });
|
|
227
|
+
*/
|
|
228
|
+
export function typedClient(client, contract) {
|
|
229
|
+
void contract;
|
|
230
|
+
return client;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Bind an HTTP client to the contract that types it: `typedClient` plus
|
|
235
|
+
* `bytes` over the opaque operations. Identity at runtime — a local or
|
|
236
|
+
* port client carries no `bytes` and takes `typedClient`.
|
|
237
|
+
* @template C
|
|
238
|
+
* @template T
|
|
239
|
+
* @param {T} client - an `openHttpClient` client
|
|
240
|
+
* @param {C} contract - the pen's contract; the type argument only
|
|
241
|
+
* @returns {T}
|
|
242
|
+
* @example
|
|
243
|
+
* const api = typedHttpClient(openHttpClient(compileContract(shop.document), { baseUrl }), shop);
|
|
244
|
+
* const outcome = await api.bytes('image.bytes', { id: 3 }); // ok: { status, headers, media, body: ReadableStream }
|
|
245
|
+
*/
|
|
246
|
+
export function typedHttpClient(client, contract) {
|
|
247
|
+
void contract;
|
|
248
|
+
return client;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Bind a handler table to the contract it serves. Identity at runtime:
|
|
253
|
+
* the table is checked against the operation ids and each handler's
|
|
254
|
+
* input and output typed by the pen's phantoms.
|
|
255
|
+
* @template C
|
|
256
|
+
* @template H
|
|
257
|
+
* @param {C} contract - the pen's contract; the type argument only
|
|
258
|
+
* @param {H} handlers - operation id → handler
|
|
259
|
+
* @returns {H}
|
|
260
|
+
* @example
|
|
261
|
+
* serveHttp(compiled, typedHandlers(shop, { 'catalog.load': () => catalog }));
|
|
262
|
+
*/
|
|
263
|
+
export function typedHandlers(contract, handlers) {
|
|
264
|
+
void contract;
|
|
265
|
+
return handlers;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Bind `contractTools`' output to the contract that types it. Identity
|
|
270
|
+
* at runtime: each tool's `name` and `execute` argument are narrowed by
|
|
271
|
+
* the pen's phantoms.
|
|
272
|
+
* @template C
|
|
273
|
+
* @param {any} tools - what `contractTools(compiled, client)` answered
|
|
274
|
+
* @param {C} contract - the pen's contract; the type argument only
|
|
275
|
+
* @returns {any}
|
|
276
|
+
* @example
|
|
277
|
+
* for (const tool of typedTools(contractTools(compiled, client), shop)) toolbox.add(tool);
|
|
278
|
+
*/
|
|
279
|
+
export function typedTools(tools, contract) {
|
|
280
|
+
void contract;
|
|
281
|
+
return tools;
|
|
282
|
+
}
|