@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,1221 @@
|
|
|
1
|
+
# The Jaren contract pen
|
|
2
|
+
|
|
3
|
+
> `./contract` — `$contract` 0.1 documents: the operations, their
|
|
4
|
+
> schemas, their declared behavior and their REST binding. **Read it
|
|
5
|
+
> when** you are declaring an API and want its client, its server and
|
|
6
|
+
> its tools typed from one document
|
|
7
|
+
|
|
8
|
+
Version 0.1. The key words MUST, MUST NOT, SHOULD and MAY are to be
|
|
9
|
+
interpreted as described in RFC 2119. This document is a **guide** — read
|
|
10
|
+
it in order and you can write the format — whose one normative section is
|
|
11
|
+
[§2 The mapping table](#2-the-mapping-table); the rules every pen keeps, the shared refusal table, the
|
|
12
|
+
index of the other pens and every pen's mapping table collected in one
|
|
13
|
+
place are the normative reference,
|
|
14
|
+
[LINQ-FORMAT.md](LINQ-FORMAT.md).
|
|
15
|
+
|
|
16
|
+
## 1. What it writes
|
|
17
|
+
|
|
18
|
+
You have a service with an API, and three things need to agree about it:
|
|
19
|
+
the server that implements it, the client that calls it, and whatever
|
|
20
|
+
else reads it — a generated OpenAPI file, a tool set handed to a model, a
|
|
21
|
+
test. A contract is the one document all three read, and writing it by
|
|
22
|
+
hand means keeping JSON Schemas, HTTP verbs and error names in step by
|
|
23
|
+
eye. This pen writes that document from typed calls, and the same typed
|
|
24
|
+
calls then check the client, the handlers and the tools against it.
|
|
25
|
+
|
|
26
|
+
```js
|
|
27
|
+
import { defineContract, read, command, subscribe, http, error } from '@jarenjs/linq/contract';
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
writes `$contract` 0.1 documents
|
|
31
|
+
([CONTRACT-FORMAT](../../contract/docs/CONTRACT-FORMAT.md)): the
|
|
32
|
+
operations, their schemas, their declared behavior and their REST
|
|
33
|
+
binding, as one document `compileContract` takes unchanged. The schemas
|
|
34
|
+
are the schema pen's ([SCHEMA-PEN.md](SCHEMA-PEN.md)), and every
|
|
35
|
+
`named()` builder any operation reaches is hoisted once into the
|
|
36
|
+
CONTRACT's own `$defs` and referenced `#/$defs/<name>` — the same
|
|
37
|
+
hoisting walk [the schema pen](SCHEMA-PEN.md#27-references-and-defs)
|
|
38
|
+
runs over one root, run over every operation's `input`, `output` and
|
|
39
|
+
error schemas instead.
|
|
40
|
+
|
|
41
|
+
The pen imports nothing of `@jarenjs/contract`: the compiler stays the
|
|
42
|
+
only judge of what the document means, and a tree-shaking probe holds it
|
|
43
|
+
(§7).
|
|
44
|
+
|
|
45
|
+
**The running example.** §3 is one shop's service surface, and it is six
|
|
46
|
+
contracts rather than one on purpose: a `$contract` document is the unit
|
|
47
|
+
a service publishes, so a product with a health endpoint, a catalog, an
|
|
48
|
+
order intake, a live picking board, a document store and an internal
|
|
49
|
+
notes service publishes six of them. Read in order they build the whole
|
|
50
|
+
surface up — the smallest possible contract, then a shared definition,
|
|
51
|
+
then declared failures and the whole policy, then a stream, then the
|
|
52
|
+
binding's harder cases, and last one contract driving a client, a handler
|
|
53
|
+
map and a tool set at once. §5 reads the types back off the last of
|
|
54
|
+
them. What the pen adds over writing the JSON by hand is three things —
|
|
55
|
+
the member order the revision hashes, the `$defs` hoisting, and the
|
|
56
|
+
phantom types the three consumers of §5 read.
|
|
57
|
+
|
|
58
|
+
### 1.1 The two rules that make the document comparable
|
|
59
|
+
|
|
60
|
+
Both are gates, and together they are why a pen contract and its own
|
|
61
|
+
public projection can be compared member for member:
|
|
62
|
+
|
|
63
|
+
- **The member order is §12.1's** — the normative order the contract
|
|
64
|
+
revision hashes: root `$contract, id, version, compat, $defs,
|
|
65
|
+
operations`; operation `kind, input, output, errors, policy, http,
|
|
66
|
+
doc`; error `status, schema`; http `method, path, in, body, status,
|
|
67
|
+
media`; policy §12.1's public order with the two server-side knobs
|
|
68
|
+
(`limits`, `errors`) in their §3.1 places; `$defs` in first-reference
|
|
69
|
+
order. The pen writes members in that order whatever order they were
|
|
70
|
+
declared in — `test/linq/contract-pen.test.js` builds a deliberately
|
|
71
|
+
scrambled contract and asserts every `Object.keys` above.
|
|
72
|
+
- **No default is ever written.** §3.1's defaults are the compiler's to
|
|
73
|
+
materialize and `describe()` marks them inferred; a pen that wrote
|
|
74
|
+
them would turn every default into a declaration and move the revision
|
|
75
|
+
for nothing. The test asserts the exact difference: strip from
|
|
76
|
+
`publicProjection(compileContract(doc))` what `describe().inferred`
|
|
77
|
+
names, the locations §4.1 chose or the template forced, the policy
|
|
78
|
+
members the source never declared and an error's resolved status, undo
|
|
79
|
+
§4.2's path canonicalization — and what is left is the pen's own
|
|
80
|
+
document.
|
|
81
|
+
|
|
82
|
+
The second rule has a visible consequence a reader meets early:
|
|
83
|
+
`command({ output: true })` with no `http` emits an operation of two
|
|
84
|
+
members, and the operation the compiler describes has a task, an
|
|
85
|
+
idempotency mode, a cache mode, a media type and a `POST /<op-id>`
|
|
86
|
+
binding. None of that is missing from the document; all of it is
|
|
87
|
+
inferred, and `describe().inferred` says so member by member.
|
|
88
|
+
|
|
89
|
+
## 2. The mapping table
|
|
90
|
+
|
|
91
|
+
Nine exported functions, one exported class and the two members that
|
|
92
|
+
class carries — eleven names a caller writes, and every one of them is
|
|
93
|
+
in a table below. The class itself is §5's, because a caller never
|
|
94
|
+
constructs one.
|
|
95
|
+
|
|
96
|
+
### 2.1 The document
|
|
97
|
+
|
|
98
|
+
The two calls that make a contract: the envelope, and the identity
|
|
99
|
+
members the format compares two revisions by.
|
|
100
|
+
|
|
101
|
+
| Method | Emits | Type reading | Status |
|
|
102
|
+
|---|---|---|---|
|
|
103
|
+
| `defineContract({ id?, version?, compat? }, operations)` | `{ $contract: '0.1', id?, version?, compat?, $defs?, operations }` in §12.1's root order, deep-frozen | `Contract<Ops>`; `ContractOf<typeof c>` is the operation map §5 reads | native; a head member the pen does not know, an `id` outside `[A-Za-z_][A-Za-z0-9_-]*`, a non-string `version`, a `compat` that is not an array of strings, or no operation at all, `JL0101` |
|
|
104
|
+
| `.document` | the deep-frozen `$contract` document — the same object every time | `ContractDocument` | native |
|
|
105
|
+
| `toJSON()` | the same document, so `JSON.stringify(contract)` is the contract | `ContractDocument` | native |
|
|
106
|
+
|
|
107
|
+
`operations` is a name → value map read by its own keys (the binder's
|
|
108
|
+
§1.1 rule 5): an operation id is written with `setObjectMember`, so
|
|
109
|
+
`{ ['__proto__']: read({ … }) }` is an ordinary operation and
|
|
110
|
+
`{ __proto__: read({ … }) }` is `JL0101`. An operation id is not
|
|
111
|
+
otherwise checked here — `catalog.load` and `Not An Id` both emit —
|
|
112
|
+
because CONTRACT-FORMAT §2.2's pattern
|
|
113
|
+
(`^[a-z][a-z0-9]*(\.[a-z][a-z0-9]*)*$`) is `compileContract`'s to
|
|
114
|
+
enforce, at `JC0003`.
|
|
115
|
+
|
|
116
|
+
### 2.2 The three operation kinds
|
|
117
|
+
|
|
118
|
+
An operation declares what it is by which of these three writes it, and
|
|
119
|
+
the kind decides what the format lets it do — whether it may repeat
|
|
120
|
+
safely, whether it may change state, whether it streams.
|
|
121
|
+
|
|
122
|
+
| Method | Emits | Type reading | Status |
|
|
123
|
+
|---|---|---|---|
|
|
124
|
+
| `read({ input?, output, errors?, policy?, http?, doc? })` | `{ kind: 'read', … }` in §12.1's operation order, declared members only | `OperationDeclaration<'read', S>`; `kind` is the literal | native; a member the spec does not take, a missing `output`, or a non-string `doc`, `JL0101` |
|
|
125
|
+
| `command({ … })` | `{ kind: 'command', … }` | `OperationDeclaration<'command', S>` | native; the same three |
|
|
126
|
+
| `subscribe({ … })` | `{ kind: 'subscribe', … }` — `output` is the SNAPSHOT schema (§17) | `OperationDeclaration<'subscribe', S>`; never opaque | native; the same three |
|
|
127
|
+
|
|
128
|
+
The three are one function under three names: they differ only in the
|
|
129
|
+
`kind` they carry and in what the COMPILER then requires of them (§2.8).
|
|
130
|
+
An operation may also be written by hand as `{ kind, …members }`, which
|
|
131
|
+
`defineContract` accepts and lowers the same way. It is a second door
|
|
132
|
+
into one emitter and it runs the same checks: the closed member set, the
|
|
133
|
+
required `output` and the string `doc` are all applied, and the refusal
|
|
134
|
+
names the operation's own position (`/operations/note.get/extra`) rather
|
|
135
|
+
than the spec's. §4.4 has the pair side by side.
|
|
136
|
+
|
|
137
|
+
### 2.3 The schema positions
|
|
138
|
+
|
|
139
|
+
Three positions take a schema: an operation's `input`, its `output`, and
|
|
140
|
+
an error's `schema`.
|
|
141
|
+
|
|
142
|
+
| Written as | Emits | Type reading | Status |
|
|
143
|
+
|---|---|---|---|
|
|
144
|
+
| a schema-pen builder | the builder's schema, its `named()` definitions hoisted to the contract's `$defs` and referenced `#/$defs/<name>` | `Infer<>` of the builder; `Input<>` for the accepted shape | native |
|
|
145
|
+
| a JSON Schema object, or `true` / `false` | copied verbatim, deep-cloned | `unknown` — a literal is never inferred (the binder's §1.1 rule 2) | native; a value that is neither an object nor a boolean, or one that is not JSON, `JL0101` |
|
|
146
|
+
|
|
147
|
+
`output` is required and `input` is not: an operation with no `input`
|
|
148
|
+
takes none, and reads `input: null` on both sides of §5's agreement.
|
|
149
|
+
`true` is the honest spelling for "any value", and it is what an opaque
|
|
150
|
+
operation's `output` usually is, since the contract never decodes those
|
|
151
|
+
bytes.
|
|
152
|
+
|
|
153
|
+
The hoist is per CONTRACT, not per operation: one builder reached from
|
|
154
|
+
three operations is one `$defs` entry and three `$ref`s, and the entries
|
|
155
|
+
come out in first-reference order. Two DISTINCT builders under one name
|
|
156
|
+
is `JL0103` (§4.3), and identity is what "distinct" means — the same
|
|
157
|
+
builder reached from anywhere is one definition.
|
|
158
|
+
|
|
159
|
+
### 2.4 The errors map
|
|
160
|
+
|
|
161
|
+
The failures an operation DECLARES, as opposed to the ones any operation
|
|
162
|
+
can raise: each a name the caller matches on, with a status and an
|
|
163
|
+
optional payload schema.
|
|
164
|
+
|
|
165
|
+
| Method | Emits | Type reading | Status |
|
|
166
|
+
|---|---|---|---|
|
|
167
|
+
| `errors: { <code>: … }` | `{ <code>: { status?, schema? } }`, in declaration order | the declared codes are the operation's `errors` union — `'conflict' \| 'not-found'` | native; a map that is not a plain object, a code outside `^[a-z][a-z0-9-]*$`, or an entry that is not a plain object, `JL0101` |
|
|
168
|
+
| `error({ status?, schema? })` | `{ status?, schema? }` in that order — `error()` with nothing emits `{}` | `ErrorDeclaration<E>` | native; another member, or a status outside 100–599, `JL0101` |
|
|
169
|
+
|
|
170
|
+
`error()` is a checked declaration, and the same two members written by
|
|
171
|
+
hand are accepted and checked identically: `readErrors` runs the plain
|
|
172
|
+
object through `error()` itself, so `{ conflict: { status: 409 } }` and
|
|
173
|
+
`{ conflict: error({ status: 409 }) }` emit the same entry and refuse the
|
|
174
|
+
same way. The default status (`400`) is the compiler's, so an entry that
|
|
175
|
+
declares none emits `{}` and reads its status from `describe()`.
|
|
176
|
+
|
|
177
|
+
### 2.5 The policy
|
|
178
|
+
|
|
179
|
+
`policy` takes the nine members CONTRACT-FORMAT §3.1 declares, and emits
|
|
180
|
+
the declared ones only, in this order:
|
|
181
|
+
|
|
182
|
+
| Member | Takes | Emits |
|
|
183
|
+
|---|---|---|
|
|
184
|
+
| `task` | `switch`, `exhaust`, `concat`, `parallel` | the token |
|
|
185
|
+
| `idempotency` | `none`, `optional`, `required` | the token |
|
|
186
|
+
| `revision` | `"input:<json-pointer>"` | the string, verbatim |
|
|
187
|
+
| `cache` | `none`, `revision` | the token |
|
|
188
|
+
| `limits` | `{ maxBodyBytes }`, a positive integer | `{ maxBodyBytes }` |
|
|
189
|
+
| `errors` | `{ details }` — `none`, `paths`, `full` | `{ details }` |
|
|
190
|
+
| `retry` | `{ max, on }` — an integer ≥ 0 and an array of code strings | `{ max, on }`, the array copied |
|
|
191
|
+
| `stream` | `{ resume?, heartbeatMs?, maxPatchBytes? }` — `snapshot`/`replay`, an integer ≥ 1000, a positive integer | the declared members only |
|
|
192
|
+
| `audience` | `public`, `server` | the token |
|
|
193
|
+
|
|
194
|
+
Every one is `native`, and every one refuses a value outside its set with
|
|
195
|
+
`JL0101` naming the set (§4.1). `limits` and `errors` are the two
|
|
196
|
+
server-side knobs the public projection drops, written in their §3.1
|
|
197
|
+
places (§1.1). The pen checks each member against its own table and stops
|
|
198
|
+
there: it does not check a member against the operation's KIND, so a
|
|
199
|
+
`read` carrying `idempotency: 'required'` is a well-formed policy the pen
|
|
200
|
+
writes and the compiler refuses (`JC0014`).
|
|
201
|
+
|
|
202
|
+
### 2.6 The HTTP binding
|
|
203
|
+
|
|
204
|
+
Where an operation lands on a URL, and where each input member goes when
|
|
205
|
+
it gets there — the one part of a contract that is about transport.
|
|
206
|
+
|
|
207
|
+
| Method | Emits | Type reading | Status |
|
|
208
|
+
|---|---|---|---|
|
|
209
|
+
| `http({ method, path, in?, body?, status?, media? })` | the binding in §12.1's http order, declared members only | `HttpBinding<H>`; a non-JSON `media` makes the operation `opaque: true` | native; a member the binding does not take, a method outside the seven tokens, a non-string `body`, a status outside 200–299 or a non-string `media`, `JL0101`; a reserved path-template form or a member mapped to `path` the template does not declare, `JL0102` |
|
|
210
|
+
|
|
211
|
+
| Member | Takes | Note |
|
|
212
|
+
|---|---|---|
|
|
213
|
+
| `method` | one uppercase token of `GET HEAD POST PUT PATCH DELETE OPTIONS` | lowercase is refused; the format's table is uppercase |
|
|
214
|
+
| `path` | a path template (§4.2) | `{name}` and `:name` both accepted, and **written as declared** |
|
|
215
|
+
| `in` | input member → `path` \| `query` \| `header` \| `body` | only the members the §4.1 default does not already place |
|
|
216
|
+
| `body` | the input member whose value IS the request body | a non-empty string |
|
|
217
|
+
| `status` | 200–299 | the success status; `200` is the default and is never written |
|
|
218
|
+
| `media` | a media type | anything but `application/json` or a `+json` suffix makes the operation opaque (§4.5) |
|
|
219
|
+
|
|
220
|
+
Two things this row does not do, and both are deliberate. It does not
|
|
221
|
+
canonicalize: `path: '/docs/:id'` stays `/docs/:id` in the document, and
|
|
222
|
+
`/docs/{id}` is what `describe()` and every projection show — §4.2's
|
|
223
|
+
canonical form is the compiler's, not the pen's. And it does not require
|
|
224
|
+
a `path` variable to be an input member (`JC0009`) or check the route
|
|
225
|
+
shape against the contract's other operations (`JC0010`): both need the
|
|
226
|
+
whole document, and one binding is all `http()` can see.
|
|
227
|
+
|
|
228
|
+
The binding may also be written as a plain object in the operation's
|
|
229
|
+
`http:` position. `defineContract` runs it through `http()` — the same
|
|
230
|
+
checks, the same messages — so the two spellings differ only in when the
|
|
231
|
+
refusal arrives.
|
|
232
|
+
|
|
233
|
+
### 2.7 The three consumers
|
|
234
|
+
|
|
235
|
+
Three identity wrappers that type a client, a handler map or a tool set
|
|
236
|
+
against the contract that describes it. None of them emits anything;
|
|
237
|
+
they exist so a mismatch is a compile error rather than a 404.
|
|
238
|
+
|
|
239
|
+
| Method | Emits | Type reading | Status |
|
|
240
|
+
|---|---|---|---|
|
|
241
|
+
| `typedClient(client, contract)` | — (identity) | `TypedClient<C>`: `invoke` over the invokable operations, `subscribe` over the subscribe ones, `url` over all of them | native |
|
|
242
|
+
| `typedHttpClient(client, contract)` | — (identity) | `TypedHttpClient<C>`: `TypedClient<C>` plus `bytes` over `OpaqueOf<C>` — the opaque operations, whose success is a `ByteResponse` (a live stream) rather than the output type; for an `openHttpClient` client only, a local or port client has no `bytes` | native |
|
|
243
|
+
| `typedHandlers(contract, handlers)` | — (identity) | `TypedHandlerTable<C, Host = null, Carrier = 'http'>`: one handler per invokable operation, `(input, ctx) => output \| Failure`; `ctx` is `HandlerContext<Host, Carrier>` — the HTTP context by default, `Host` the host lifecycle's `ctx.host`, a carrier union a discriminated union to narrow on `ctx.carrier` (CONTRACT-FORMAT §7.7) | native; a missing or misspelled operation does not compile, and an HTTP-only member on a port/local context does not either |
|
|
244
|
+
| `typedTools(tools, contract)` | — (identity) | `TypedTool<C>[]`: `name` is the id with `.` → `_`, `execute` takes the operation's ACCEPTED input | native |
|
|
245
|
+
|
|
246
|
+
All of them are `void contract; return x;` at run time — they add
|
|
247
|
+
nothing, wrap nothing and cost nothing. What they do is carry the phantom `Ops`
|
|
248
|
+
onto a value the engine produced, which is what makes one authored
|
|
249
|
+
document type a client, a server's handler table and an AI toolbox with
|
|
250
|
+
no generate step. §3.6 runs all three over one contract and §5 is what
|
|
251
|
+
holds their readings true.
|
|
252
|
+
|
|
253
|
+
### 2.8 What the pen does not judge
|
|
254
|
+
|
|
255
|
+
The pen refuses its own surface — a member it does not know, a value
|
|
256
|
+
outside a declared set — and the one format rule it can see earlier and
|
|
257
|
+
exactly, the path template. Everything else is `compileContract`'s,
|
|
258
|
+
because everything else needs the document as a whole:
|
|
259
|
+
|
|
260
|
+
| The document the pen writes | The compiler's refusal |
|
|
261
|
+
|---|---|
|
|
262
|
+
| a member the document's own closed vocabulary does not carry | `JC0013` (§4.4) |
|
|
263
|
+
| an operation id outside `^[a-z][a-z0-9]*(\.[a-z][a-z0-9]*)*$` | `JC0003` |
|
|
264
|
+
| a `read` that declares `idempotency` | `JC0014` |
|
|
265
|
+
| a `GET` carrying a body-located member | `JC0016` |
|
|
266
|
+
| an opaque operation with a body-located member | `JC0017` |
|
|
267
|
+
| a `subscribe` bound to anything but `GET` | `JC0019` |
|
|
268
|
+
| a path variable that is not an input member | `JC0009` |
|
|
269
|
+
| two operations sharing a route shape | `JC0010` |
|
|
270
|
+
|
|
271
|
+
Each row is a document the pen EMITS: `test/linq/contract-pen.test.js`
|
|
272
|
+
builds several of them through the pen and asserts the compiler's code,
|
|
273
|
+
which is how the division stays a division rather than a gap. LINQ-FORMAT
|
|
274
|
+
§1.1 rule 1 is the reason — a pen refuses only what it cannot SPELL, or
|
|
275
|
+
what the engine's own rule would refuse and the pen can see earlier,
|
|
276
|
+
mirrored and never invented.
|
|
277
|
+
|
|
278
|
+
## 3. Worked examples
|
|
279
|
+
|
|
280
|
+
Every `js` fence exports exactly one contract, and the `json` fence that
|
|
281
|
+
follows is what the pen emits — executed by
|
|
282
|
+
`test/linq/pen-docs.test.js`. CONTRACT-FORMAT's own three worked examples
|
|
283
|
+
are rebuilt the same way and held BYTE-equal to that document's fences by
|
|
284
|
+
`test/linq/contract-pen.test.js`, so the format spec and this pen cannot
|
|
285
|
+
drift apart either.
|
|
286
|
+
|
|
287
|
+
### 3.1 The smallest complete contract
|
|
288
|
+
|
|
289
|
+
**The health endpoint** — the smallest complete contract the shop
|
|
290
|
+
publishes. One `read`, one binding, and a `doc` string. Nothing else is required:
|
|
291
|
+
no `version`, no `$defs`, no `policy`.
|
|
292
|
+
|
|
293
|
+
```js
|
|
294
|
+
import * as s from '@jarenjs/linq/schema';
|
|
295
|
+
import { defineContract, http, read } from '@jarenjs/linq/contract';
|
|
296
|
+
|
|
297
|
+
export const health = defineContract({ id: 'health' }, {
|
|
298
|
+
'health.check': read({
|
|
299
|
+
output: s.object({ ok: s.boolean(), uptimeMs: s.integer() }).open(),
|
|
300
|
+
http: http({ method: 'GET', path: '/api/health' }),
|
|
301
|
+
doc: 'Liveness, and how long the process has been up.',
|
|
302
|
+
}),
|
|
303
|
+
});
|
|
304
|
+
```
|
|
305
|
+
```json
|
|
306
|
+
{
|
|
307
|
+
"$contract": "0.1",
|
|
308
|
+
"id": "health",
|
|
309
|
+
"operations": {
|
|
310
|
+
"health.check": {
|
|
311
|
+
"kind": "read",
|
|
312
|
+
"output": {
|
|
313
|
+
"type": "object",
|
|
314
|
+
"properties": { "ok": { "type": "boolean" }, "uptimeMs": { "type": "integer" } },
|
|
315
|
+
"required": ["ok", "uptimeMs"]
|
|
316
|
+
},
|
|
317
|
+
"http": { "method": "GET", "path": "/api/health" },
|
|
318
|
+
"doc": "Liveness, and how long the process has been up."
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
`.open()` is a schema-pen decision, not a contract one (the binder's §1.1
|
|
325
|
+
rule 4 closes objects by default), and an open response schema is what
|
|
326
|
+
lets a server add a member without breaking a client that validates.
|
|
327
|
+
|
|
328
|
+
### 3.2 A shared definition, hoisted once
|
|
329
|
+
|
|
330
|
+
**The catalog.** `Product` is reached by four positions across two operations and is one
|
|
331
|
+
`$defs` entry with four `$ref`s. The hoist is the contract's, so a
|
|
332
|
+
definition never appears inside an operation.
|
|
333
|
+
|
|
334
|
+
```js
|
|
335
|
+
import * as s from '@jarenjs/linq/schema';
|
|
336
|
+
import { command, defineContract, http, read } from '@jarenjs/linq/contract';
|
|
337
|
+
|
|
338
|
+
const Product = s.named('Product', s.object({
|
|
339
|
+
id: s.integer(),
|
|
340
|
+
name: s.string().min(1),
|
|
341
|
+
price: s.number().min(0),
|
|
342
|
+
}).open());
|
|
343
|
+
|
|
344
|
+
export const catalog = defineContract({ id: 'catalog', version: '2', compat: ['1'] }, {
|
|
345
|
+
'product.get': read({
|
|
346
|
+
input: s.object({ id: s.integer() }).open(),
|
|
347
|
+
output: Product,
|
|
348
|
+
http: http({ method: 'GET', path: '/api/products/{id}' }),
|
|
349
|
+
}),
|
|
350
|
+
'product.save': command({
|
|
351
|
+
input: s.object({ id: s.integer(), product: Product }).open(),
|
|
352
|
+
output: Product,
|
|
353
|
+
http: http({ method: 'PUT', path: '/api/products/{id}', in: { product: 'body' } }),
|
|
354
|
+
}),
|
|
355
|
+
});
|
|
356
|
+
```
|
|
357
|
+
```json
|
|
358
|
+
{
|
|
359
|
+
"$contract": "0.1",
|
|
360
|
+
"id": "catalog",
|
|
361
|
+
"version": "2",
|
|
362
|
+
"compat": ["1"],
|
|
363
|
+
"$defs": {
|
|
364
|
+
"Product": {
|
|
365
|
+
"type": "object",
|
|
366
|
+
"properties": {
|
|
367
|
+
"id": { "type": "integer" },
|
|
368
|
+
"name": { "type": "string", "minLength": 1 },
|
|
369
|
+
"price": { "type": "number", "minimum": 0 }
|
|
370
|
+
},
|
|
371
|
+
"required": ["id", "name", "price"]
|
|
372
|
+
}
|
|
373
|
+
},
|
|
374
|
+
"operations": {
|
|
375
|
+
"product.get": {
|
|
376
|
+
"kind": "read",
|
|
377
|
+
"input": {
|
|
378
|
+
"type": "object",
|
|
379
|
+
"properties": { "id": { "type": "integer" } },
|
|
380
|
+
"required": ["id"]
|
|
381
|
+
},
|
|
382
|
+
"output": { "$ref": "#/$defs/Product" },
|
|
383
|
+
"http": { "method": "GET", "path": "/api/products/{id}" }
|
|
384
|
+
},
|
|
385
|
+
"product.save": {
|
|
386
|
+
"kind": "command",
|
|
387
|
+
"input": {
|
|
388
|
+
"type": "object",
|
|
389
|
+
"properties": { "id": { "type": "integer" }, "product": { "$ref": "#/$defs/Product" } },
|
|
390
|
+
"required": ["id", "product"]
|
|
391
|
+
},
|
|
392
|
+
"output": { "$ref": "#/$defs/Product" },
|
|
393
|
+
"http": { "method": "PUT", "path": "/api/products/{id}", "in": { "product": "body" } }
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
`id` is a path variable in both bindings and is placed by §4.1's rules,
|
|
400
|
+
so neither `in` map names it; `product` is named because a `PUT`'s
|
|
401
|
+
default would have put it in the body anyway and saying so is the
|
|
402
|
+
document being explicit. The `$defs` entry carries no `$defs` of its
|
|
403
|
+
own — the schema pen's own per-root hoist is superseded by the
|
|
404
|
+
contract's.
|
|
405
|
+
|
|
406
|
+
### 3.3 Declared failures, and the whole policy
|
|
407
|
+
|
|
408
|
+
**Order intake**, where a failure is a thing you declare rather than a
|
|
409
|
+
status you hope for. `errors` and `policy` together — every policy member the format declares,
|
|
410
|
+
including the two the public projection drops.
|
|
411
|
+
|
|
412
|
+
```js
|
|
413
|
+
import * as s from '@jarenjs/linq/schema';
|
|
414
|
+
import { command, defineContract, error, http, read } from '@jarenjs/linq/contract';
|
|
415
|
+
|
|
416
|
+
const Conflict = s.named('Conflict', s.object({ currentRevision: s.integer() }).open());
|
|
417
|
+
|
|
418
|
+
export const orders = defineContract({ id: 'orders' }, {
|
|
419
|
+
'order.place': command({
|
|
420
|
+
input: s.object({ sku: s.string(), qty: s.integer().min(1), revision: s.integer() }).open(),
|
|
421
|
+
output: s.object({ orderId: s.string() }).open(),
|
|
422
|
+
errors: {
|
|
423
|
+
conflict: error({ status: 409, schema: Conflict }),
|
|
424
|
+
'out-of-stock': error({ status: 422 }),
|
|
425
|
+
rejected: error(),
|
|
426
|
+
},
|
|
427
|
+
policy: {
|
|
428
|
+
task: 'exhaust',
|
|
429
|
+
idempotency: 'required',
|
|
430
|
+
revision: 'input:/revision',
|
|
431
|
+
limits: { maxBodyBytes: 16384 },
|
|
432
|
+
errors: { details: 'paths' },
|
|
433
|
+
retry: { max: 2, on: ['JC2002'] },
|
|
434
|
+
audience: 'public',
|
|
435
|
+
},
|
|
436
|
+
http: http({ method: 'POST', path: '/api/orders' }),
|
|
437
|
+
}),
|
|
438
|
+
'order.list': read({
|
|
439
|
+
output: s.array(s.string()),
|
|
440
|
+
policy: { cache: 'revision', task: 'switch' },
|
|
441
|
+
http: http({ method: 'GET', path: '/api/orders' }),
|
|
442
|
+
}),
|
|
443
|
+
});
|
|
444
|
+
```
|
|
445
|
+
```json
|
|
446
|
+
{
|
|
447
|
+
"$contract": "0.1",
|
|
448
|
+
"id": "orders",
|
|
449
|
+
"$defs": {
|
|
450
|
+
"Conflict": {
|
|
451
|
+
"type": "object",
|
|
452
|
+
"properties": { "currentRevision": { "type": "integer" } },
|
|
453
|
+
"required": ["currentRevision"]
|
|
454
|
+
}
|
|
455
|
+
},
|
|
456
|
+
"operations": {
|
|
457
|
+
"order.place": {
|
|
458
|
+
"kind": "command",
|
|
459
|
+
"input": {
|
|
460
|
+
"type": "object",
|
|
461
|
+
"properties": {
|
|
462
|
+
"sku": { "type": "string" },
|
|
463
|
+
"qty": { "type": "integer", "minimum": 1 },
|
|
464
|
+
"revision": { "type": "integer" }
|
|
465
|
+
},
|
|
466
|
+
"required": ["sku", "qty", "revision"]
|
|
467
|
+
},
|
|
468
|
+
"output": {
|
|
469
|
+
"type": "object",
|
|
470
|
+
"properties": { "orderId": { "type": "string" } },
|
|
471
|
+
"required": ["orderId"]
|
|
472
|
+
},
|
|
473
|
+
"errors": {
|
|
474
|
+
"conflict": { "status": 409, "schema": { "$ref": "#/$defs/Conflict" } },
|
|
475
|
+
"out-of-stock": { "status": 422 },
|
|
476
|
+
"rejected": {}
|
|
477
|
+
},
|
|
478
|
+
"policy": {
|
|
479
|
+
"task": "exhaust",
|
|
480
|
+
"idempotency": "required",
|
|
481
|
+
"revision": "input:/revision",
|
|
482
|
+
"limits": { "maxBodyBytes": 16384 },
|
|
483
|
+
"errors": { "details": "paths" },
|
|
484
|
+
"retry": { "max": 2, "on": ["JC2002"] },
|
|
485
|
+
"audience": "public"
|
|
486
|
+
},
|
|
487
|
+
"http": { "method": "POST", "path": "/api/orders" }
|
|
488
|
+
},
|
|
489
|
+
"order.list": {
|
|
490
|
+
"kind": "read",
|
|
491
|
+
"output": { "type": "array", "items": { "type": "string" } },
|
|
492
|
+
"policy": { "task": "switch", "cache": "revision" },
|
|
493
|
+
"http": { "method": "GET", "path": "/api/orders" }
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
Three things the fence shows that a sentence would not. `rejected:
|
|
500
|
+
error()` emits `{}` — a declared code with no status and no details
|
|
501
|
+
schema, which is legal and which reads its `400` from `describe()`.
|
|
502
|
+
`order.list` declared `cache` before `task` and the document carries
|
|
503
|
+
`task` first: §12.1's order is the pen's, not the author's. And
|
|
504
|
+
`retry.on` names a `JC2xxx` taxonomy code rather than one of this
|
|
505
|
+
operation's own — both spellings are accepted, because a retry policy
|
|
506
|
+
that could only name declared codes could not say "retry a timeout".
|
|
507
|
+
|
|
508
|
+
### 3.4 A subscribe operation and its stream binding
|
|
509
|
+
|
|
510
|
+
**The picking board, live.** `subscribe`'s `output` is the SNAPSHOT schema (§17); the emissions that
|
|
511
|
+
follow travel the stream wire as patches against it.
|
|
512
|
+
|
|
513
|
+
```js
|
|
514
|
+
import * as s from '@jarenjs/linq/schema';
|
|
515
|
+
import { command, defineContract, http, subscribe } from '@jarenjs/linq/contract';
|
|
516
|
+
|
|
517
|
+
const Board = s.named('Board', s.object({
|
|
518
|
+
seq: s.integer(),
|
|
519
|
+
rows: s.array(s.string()),
|
|
520
|
+
}).open());
|
|
521
|
+
|
|
522
|
+
export const board = defineContract({ id: 'board' }, {
|
|
523
|
+
'board.watch': subscribe({
|
|
524
|
+
input: s.object({ id: s.string() }).open(),
|
|
525
|
+
output: Board,
|
|
526
|
+
policy: { task: 'switch', stream: { resume: 'replay', heartbeatMs: 2000, maxPatchBytes: 65536 } },
|
|
527
|
+
http: http({ method: 'GET', path: '/board/{id}' }),
|
|
528
|
+
doc: 'The board, live: a snapshot, then patches.',
|
|
529
|
+
}),
|
|
530
|
+
'board.clear': command({ input: s.object({ id: s.string() }).open(), output: Board }),
|
|
531
|
+
});
|
|
532
|
+
```
|
|
533
|
+
```json
|
|
534
|
+
{
|
|
535
|
+
"$contract": "0.1",
|
|
536
|
+
"id": "board",
|
|
537
|
+
"$defs": {
|
|
538
|
+
"Board": {
|
|
539
|
+
"type": "object",
|
|
540
|
+
"properties": {
|
|
541
|
+
"seq": { "type": "integer" },
|
|
542
|
+
"rows": { "type": "array", "items": { "type": "string" } }
|
|
543
|
+
},
|
|
544
|
+
"required": ["seq", "rows"]
|
|
545
|
+
}
|
|
546
|
+
},
|
|
547
|
+
"operations": {
|
|
548
|
+
"board.watch": {
|
|
549
|
+
"kind": "subscribe",
|
|
550
|
+
"input": {
|
|
551
|
+
"type": "object",
|
|
552
|
+
"properties": { "id": { "type": "string" } },
|
|
553
|
+
"required": ["id"]
|
|
554
|
+
},
|
|
555
|
+
"output": { "$ref": "#/$defs/Board" },
|
|
556
|
+
"policy": {
|
|
557
|
+
"task": "switch",
|
|
558
|
+
"stream": { "resume": "replay", "heartbeatMs": 2000, "maxPatchBytes": 65536 }
|
|
559
|
+
},
|
|
560
|
+
"http": { "method": "GET", "path": "/board/{id}" },
|
|
561
|
+
"doc": "The board, live: a snapshot, then patches."
|
|
562
|
+
},
|
|
563
|
+
"board.clear": {
|
|
564
|
+
"kind": "command",
|
|
565
|
+
"input": {
|
|
566
|
+
"type": "object",
|
|
567
|
+
"properties": { "id": { "type": "string" } },
|
|
568
|
+
"required": ["id"]
|
|
569
|
+
},
|
|
570
|
+
"output": { "$ref": "#/$defs/Board" }
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
```
|
|
575
|
+
|
|
576
|
+
`board.clear` declares no `http` and takes the canonical binding —
|
|
577
|
+
`POST /board.clear`, every input member in the body, status `200`,
|
|
578
|
+
`application/json` (§4.3). The pen writes no `http` member for it,
|
|
579
|
+
because the canonical binding is a default and §1.1's second rule
|
|
580
|
+
forbids writing defaults; `describe()` marks the whole binding inferred.
|
|
581
|
+
|
|
582
|
+
The three stream knobs and the `task` are what the pen writes. The media
|
|
583
|
+
type a subscribe travels on is NOT: `text/event-stream` is forced by the
|
|
584
|
+
compiler (§17), which is also why a subscribe is never opaque no matter
|
|
585
|
+
what its media says.
|
|
586
|
+
|
|
587
|
+
### 3.5 Path templates, member locations and an opaque operation
|
|
588
|
+
|
|
589
|
+
**The document store**, which is where the HTTP binding gets interesting.
|
|
590
|
+
The two template spellings, the four locations, a whole-body member, a
|
|
591
|
+
declared success status, and a media type that makes an operation
|
|
592
|
+
opaque.
|
|
593
|
+
|
|
594
|
+
```js
|
|
595
|
+
import * as s from '@jarenjs/linq/schema';
|
|
596
|
+
import { command, defineContract, http, read } from '@jarenjs/linq/contract';
|
|
597
|
+
|
|
598
|
+
export const docs = defineContract({ id: 'docs' }, {
|
|
599
|
+
'doc.put': command({
|
|
600
|
+
input: s.object({
|
|
601
|
+
id: s.string(),
|
|
602
|
+
folder: s.string(),
|
|
603
|
+
body: s.array(s.object({}).open()),
|
|
604
|
+
dry: s.boolean().optional(),
|
|
605
|
+
}).open(),
|
|
606
|
+
output: true,
|
|
607
|
+
http: http({
|
|
608
|
+
method: 'PUT',
|
|
609
|
+
path: '/docs/{folder}/{id}',
|
|
610
|
+
in: { dry: 'query' },
|
|
611
|
+
body: 'body',
|
|
612
|
+
status: 204,
|
|
613
|
+
}),
|
|
614
|
+
}),
|
|
615
|
+
'doc.thumbnail': read({
|
|
616
|
+
input: s.object({ id: s.string() }).open(),
|
|
617
|
+
output: true,
|
|
618
|
+
http: http({ method: 'GET', path: '/docs/:id/thumb', media: 'image/png' }),
|
|
619
|
+
}),
|
|
620
|
+
});
|
|
621
|
+
```
|
|
622
|
+
```json
|
|
623
|
+
{
|
|
624
|
+
"$contract": "0.1",
|
|
625
|
+
"id": "docs",
|
|
626
|
+
"operations": {
|
|
627
|
+
"doc.put": {
|
|
628
|
+
"kind": "command",
|
|
629
|
+
"input": {
|
|
630
|
+
"type": "object",
|
|
631
|
+
"properties": {
|
|
632
|
+
"id": { "type": "string" },
|
|
633
|
+
"folder": { "type": "string" },
|
|
634
|
+
"body": { "type": "array", "items": { "type": "object" } },
|
|
635
|
+
"dry": { "type": "boolean" }
|
|
636
|
+
},
|
|
637
|
+
"required": ["id", "folder", "body"]
|
|
638
|
+
},
|
|
639
|
+
"output": true,
|
|
640
|
+
"http": {
|
|
641
|
+
"method": "PUT",
|
|
642
|
+
"path": "/docs/{folder}/{id}",
|
|
643
|
+
"in": { "dry": "query" },
|
|
644
|
+
"body": "body",
|
|
645
|
+
"status": 204
|
|
646
|
+
}
|
|
647
|
+
},
|
|
648
|
+
"doc.thumbnail": {
|
|
649
|
+
"kind": "read",
|
|
650
|
+
"input": {
|
|
651
|
+
"type": "object",
|
|
652
|
+
"properties": { "id": { "type": "string" } },
|
|
653
|
+
"required": ["id"]
|
|
654
|
+
},
|
|
655
|
+
"output": true,
|
|
656
|
+
"http": { "method": "GET", "path": "/docs/:id/thumb", "media": "image/png" }
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
```
|
|
661
|
+
|
|
662
|
+
`/docs/:id/thumb` is written as declared and canonicalized to
|
|
663
|
+
`/docs/{id}/thumb` by the compiler; a projection or a `describe()` shows
|
|
664
|
+
the canonical form, this document shows the author's. The reserved forms
|
|
665
|
+
`{+id}`, `{id*}`, `{a,b}`, `*` and `:id?` are all refused here rather
|
|
666
|
+
than at compile time, naming the same form `JC0008` would (§4.2).
|
|
667
|
+
|
|
668
|
+
Two members are placed and two are not. `id` and `folder` are path
|
|
669
|
+
variables, named by the template itself — mapping one to `path` in the
|
|
670
|
+
`in` map is redundant and mapping a member the template does NOT declare
|
|
671
|
+
is `JL0102`. `body` and `dry` are the two the author placed, and `body`
|
|
672
|
+
is the whole-body member: its value IS the request body, so no other
|
|
673
|
+
member may be body-located beside it.
|
|
674
|
+
|
|
675
|
+
`doc.thumbnail`'s `image/png` makes it **opaque** (§4.5). The
|
|
676
|
+
consequences are all on the type side and §5 pins them: it is out of
|
|
677
|
+
`invoke` and out of the tool set, and it is reachable through `url()`
|
|
678
|
+
only.
|
|
679
|
+
|
|
680
|
+
### 3.6 One document, three consumers
|
|
681
|
+
|
|
682
|
+
**The internal notes service**, and the pen's whole value in one fence: one authored contract, and a client,
|
|
683
|
+
a handler table and an AI toolbox all typed off it with no generate step.
|
|
684
|
+
The three wrappers are identity at run time, so what this fence proves is
|
|
685
|
+
that a pen contract IS a contract — it compiles, it serves, it invokes,
|
|
686
|
+
and its declared failure comes back as an outcome rather than a throw.
|
|
687
|
+
|
|
688
|
+
```js
|
|
689
|
+
import * as s from '@jarenjs/linq/schema';
|
|
690
|
+
import { compileContract } from '@jarenjs/contract';
|
|
691
|
+
import { openLocalClient } from '@jarenjs/contract/local';
|
|
692
|
+
import { contractTools } from '@jarenjs/contract/project';
|
|
693
|
+
import { command, defineContract, error, http, read, typedClient, typedHandlers, typedTools }
|
|
694
|
+
from '@jarenjs/linq/contract';
|
|
695
|
+
|
|
696
|
+
const Note = s.named('Note', s.object({ id: s.string(), text: s.string().min(1) }).open());
|
|
697
|
+
|
|
698
|
+
export const notes = defineContract({ id: 'notes', version: '1' }, {
|
|
699
|
+
'note.get': read({
|
|
700
|
+
input: s.object({ id: s.string() }).open(),
|
|
701
|
+
output: Note,
|
|
702
|
+
errors: { 'not-found': error({ status: 404 }) },
|
|
703
|
+
http: http({ method: 'GET', path: '/notes/{id}' }),
|
|
704
|
+
}),
|
|
705
|
+
'note.save': command({
|
|
706
|
+
input: s.object({ note: Note }).open(),
|
|
707
|
+
output: Note,
|
|
708
|
+
policy: { idempotency: 'required' },
|
|
709
|
+
http: http({ method: 'POST', path: '/notes' }),
|
|
710
|
+
}),
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
// the three consumers, all typed off the one document above
|
|
714
|
+
const store = new Map([['n1', { id: 'n1', text: 'first' }]]);
|
|
715
|
+
const compiled = compileContract(notes.document);
|
|
716
|
+
const handlers = typedHandlers(notes, {
|
|
717
|
+
'note.get': (input, ctx) => store.get(input.id) ?? ctx.fail('not-found'),
|
|
718
|
+
'note.save': (input) => { store.set(input.note.id, input.note); return input.note; },
|
|
719
|
+
});
|
|
720
|
+
const api = typedClient(openLocalClient(compiled, handlers), notes);
|
|
721
|
+
const got = await api.invoke('note.get', { id: 'n1' });
|
|
722
|
+
if (!got.ok || got.value.text !== 'first') throw new Error('the read did not round-trip');
|
|
723
|
+
const missing = await api.invoke('note.get', { id: 'n9' });
|
|
724
|
+
if (missing.ok || missing.error.code !== 'not-found') throw new Error('the declared failure is an outcome');
|
|
725
|
+
const tools = typedTools(contractTools(compiled, api), notes);
|
|
726
|
+
if (tools.map((t) => t.name).join(' ') !== 'note_get note_save') throw new Error('the tool names are the ids');
|
|
727
|
+
api.close();
|
|
728
|
+
```
|
|
729
|
+
```json
|
|
730
|
+
{
|
|
731
|
+
"$contract": "0.1",
|
|
732
|
+
"id": "notes",
|
|
733
|
+
"version": "1",
|
|
734
|
+
"$defs": {
|
|
735
|
+
"Note": {
|
|
736
|
+
"type": "object",
|
|
737
|
+
"properties": { "id": { "type": "string" }, "text": { "type": "string", "minLength": 1 } },
|
|
738
|
+
"required": ["id", "text"]
|
|
739
|
+
}
|
|
740
|
+
},
|
|
741
|
+
"operations": {
|
|
742
|
+
"note.get": {
|
|
743
|
+
"kind": "read",
|
|
744
|
+
"input": {
|
|
745
|
+
"type": "object",
|
|
746
|
+
"properties": { "id": { "type": "string" } },
|
|
747
|
+
"required": ["id"]
|
|
748
|
+
},
|
|
749
|
+
"output": { "$ref": "#/$defs/Note" },
|
|
750
|
+
"errors": { "not-found": { "status": 404 } },
|
|
751
|
+
"http": { "method": "GET", "path": "/notes/{id}" }
|
|
752
|
+
},
|
|
753
|
+
"note.save": {
|
|
754
|
+
"kind": "command",
|
|
755
|
+
"input": {
|
|
756
|
+
"type": "object",
|
|
757
|
+
"properties": { "note": { "$ref": "#/$defs/Note" } },
|
|
758
|
+
"required": ["note"]
|
|
759
|
+
},
|
|
760
|
+
"output": { "$ref": "#/$defs/Note" },
|
|
761
|
+
"policy": { "idempotency": "required" },
|
|
762
|
+
"http": { "method": "POST", "path": "/notes" }
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
```
|
|
767
|
+
|
|
768
|
+
The `ctx.fail('not-found')` in the handler is the only spelling that
|
|
769
|
+
compiles for a code this operation declares, and `missing.error.code`
|
|
770
|
+
is that code — a declared failure is data, never an exception, on either
|
|
771
|
+
side of the wire. The tool names are the operation ids with `.` → `_`,
|
|
772
|
+
which is `ToolName<>` in §5 and `contractTools`' own rule at run time.
|
|
773
|
+
|
|
774
|
+
## 4. Refusals
|
|
775
|
+
|
|
776
|
+
The contract pen raises these three `LinqBuildError` codes and no others
|
|
777
|
+
— `test/linq/pen-docs.test.js` holds this list equal, in both directions,
|
|
778
|
+
to the codes `packages/linq/src/contract/` names. The full condition each
|
|
779
|
+
code states across every pen is the binder's,
|
|
780
|
+
[LINQ-FORMAT.md](LINQ-FORMAT.md) §1.3.
|
|
781
|
+
|
|
782
|
+
| Code | What this pen raises it for |
|
|
783
|
+
|---|---|
|
|
784
|
+
| `JL0101` | a value this pen cannot spell, a member of its own surface it does not know, a value outside a declared set, or a name → value map it cannot read |
|
|
785
|
+
| `JL0102` | a construct the format cannot carry: a reserved path-template form, a member mapped to a `path` the template does not declare, or a hand-written operation `kind` outside the three |
|
|
786
|
+
| `JL0103` | a `$defs` name collision, a reference no definition answers, or a `lazy()` that does not return a named builder |
|
|
787
|
+
|
|
788
|
+
Every message below is the one the pen raised when the spelling beside it
|
|
789
|
+
was run, with the code prefix (`JL0101: `) removed. `docPath`, where the
|
|
790
|
+
refusal carries one, is the JSON pointer of the node being assembled and
|
|
791
|
+
is appended to the message text as well (`… at /operations/a/policy/task`).
|
|
792
|
+
|
|
793
|
+
**Where the three codes come from is not one directory.** `JL0101` and
|
|
794
|
+
`JL0102` are thrown by `packages/linq/src/contract/` — 59 sites across
|
|
795
|
+
`define.js`, `operation.js` and `http.js`, 40 of the first and 19 of the
|
|
796
|
+
second. The three tables below carry 73 rows over them, which is not a
|
|
797
|
+
contradiction: one site serves many spellings (`closedTo` is a single
|
|
798
|
+
throw reached from six functions, and one branch of `policyMember`
|
|
799
|
+
covers four policy members), so a row is a CONDITION a caller can hit
|
|
800
|
+
and a site is a line in the source. `JL0103` is thrown by the SHARED
|
|
801
|
+
hoisting walk (`packages/linq/src/schema/emit.js`), reached from
|
|
802
|
+
`defineContract` and named in `define.js`'s own `@throws`, which is why
|
|
803
|
+
the refusal gate finds it in this pen's directory and why its `docPath`
|
|
804
|
+
is rooted in the contract (`/operations/a/output/properties/p`) rather
|
|
805
|
+
than in a schema. `JL0104` — the schema pen's owned-keyword and external
|
|
806
|
+
rules — is NOT in the table, and the reason is worth a reader's time: it
|
|
807
|
+
fires when the schema builder's method is called, before the contract is
|
|
808
|
+
written, so a caller who sees it is looking at a schema-pen line.
|
|
809
|
+
|
|
810
|
+
### 4.1 `JL0101` — the value, the member and the set
|
|
811
|
+
|
|
812
|
+
Raised at the door of whichever function received it.
|
|
813
|
+
|
|
814
|
+
| The spelling that trips it | The message | The spelling that works |
|
|
815
|
+
|---|---|---|
|
|
816
|
+
| `defineContract(42, { … })` | `defineContract() takes ({ id?, version?, compat? }, operations), got 42 as its first argument` | `defineContract({}, { … })` |
|
|
817
|
+
| `defineContract({ name: 'x' }, …)` | `defineContract() does not take 'name' — the head is id, version, compat; everything else is an operation` | move it into `operations` |
|
|
818
|
+
| `defineContract({ id: '9shop' }, …)` | `defineContract() id matches [A-Za-z_][A-Za-z0-9_-]*, got a string` | `{ id: 'shop9' }` |
|
|
819
|
+
| `defineContract({ version: 5 }, …)` | `defineContract() version is a string, got 5` | `{ version: '5' }` |
|
|
820
|
+
| `defineContract({ compat: '4' }, …)` | `defineContract() compat is an array of peer version strings` | `{ compat: ['4'] }` |
|
|
821
|
+
| `defineContract({}, [])` | `defineContract() operations is a plain object of id → operation, got a Array instance` | a plain object |
|
|
822
|
+
| `defineContract({}, {})` | `defineContract() needs at least one operation` | declare one |
|
|
823
|
+
| `defineContract({}, { __proto__: read({ … }) })` | `defineContract() operations received a map whose prototype was replaced: a '__proto__:' key in an object literal sets the prototype instead of adding a member, so that member is not there to emit — spell it { ['__proto__']: … }, which is an own key` | `{ ['__proto__']: read({ … }) }` |
|
|
824
|
+
| `defineContract({}, { a: 42 })` | `an operation is read(), command() or subscribe() — got 42` | `read({ output: true })` |
|
|
825
|
+
| `read(42)` | `read() takes { input?, output, errors?, policy?, http?, doc? }, got 42` | a plain object |
|
|
826
|
+
| `read({ output: true, extra: 1 })` | `read() does not take 'extra' — it takes input, output, errors, policy, http, doc` | drop it, or put it in `policy` |
|
|
827
|
+
| `read({})` | `read() needs an output — every operation declares one (true for "any value")` | `read({ output: true })` |
|
|
828
|
+
| `read({ output: true, doc: 42 })` | `read() doc is a string, got 42` | `doc: '…'` |
|
|
829
|
+
| `read({ output: { type: 'string', default: () => 1 } })` | `the schema at /operations/a/output received a Object instance, which is not JSON — a document carries null, booleans, finite numbers (never -0), strings, arrays and plain objects, and nothing else` | a JSON value for the default |
|
|
830
|
+
| `read({ output: 42 })` | `a schema is an object, true or false — got 42` | a builder, an object, or `true` |
|
|
831
|
+
| `errors: 42` | `errors is a plain object of code → error(), got 42` | a plain object |
|
|
832
|
+
| `errors: { Bad: error({}) }` | `an error code matches ^[a-z][a-z0-9-]*$, got 'Bad'` | `{ bad: error({}) }` |
|
|
833
|
+
| `errors: { bad: 42 }` | `errors.bad is error({ status?, schema? }), got 42` | `error({ status: 400 })` |
|
|
834
|
+
| `error({ code: 'x' })` | `error() does not take 'code' — it takes status, schema` | the code is the map's key |
|
|
835
|
+
| `error({ status: 99 })` | `error() status is an integer in 100–599, got 99` | `error({ status: 409 })` |
|
|
836
|
+
| `policy: 42` | `policy is a plain object of the members CONTRACT-FORMAT §3.1 declares, got 42` | a plain object |
|
|
837
|
+
| `policy: { mode: 1 }` | `policy does not take 'mode' — it takes task, idempotency, revision, cache, limits, errors, retry, stream, audience` | the member §3.1 names |
|
|
838
|
+
| `policy: { task: 'queue' }` | `policy.task is one of switch, exhaust, concat, parallel, got a string` | `task: 'exhaust'` |
|
|
839
|
+
| `policy: { idempotency: 'maybe' }` | `policy.idempotency is one of none, optional, required, got a string` | `idempotency: 'optional'` |
|
|
840
|
+
| `policy: { cache: 'always' }` | `policy.cache is one of none, revision, got a string` | `cache: 'revision'` |
|
|
841
|
+
| `policy: { audience: 'admin' }` | `policy.audience is one of public, server, got a string` | `audience: 'server'` |
|
|
842
|
+
| `policy: { revision: '/x' }` | `policy.revision is "input:<json-pointer>" — where in the input the revision a command asserts lives, got a string` | `revision: 'input:/x'` |
|
|
843
|
+
| `policy: { limits: 1 }` | `policy.limits is { maxBodyBytes }` | `limits: { maxBodyBytes: 4096 }` |
|
|
844
|
+
| `policy: { limits: { max: 1 } }` | `policy.limits does not take 'max' — it takes maxBodyBytes` | `maxBodyBytes` |
|
|
845
|
+
| `policy: { limits: { maxBodyBytes: 0 } }` | `policy.limits.maxBodyBytes is a positive integer, got 0` | a positive integer |
|
|
846
|
+
| `policy: { errors: 1 }` | `policy.errors is { details }` | `errors: { details: 'paths' }` |
|
|
847
|
+
| `policy: { errors: { detail: 1 } }` | `policy.errors does not take 'detail' — it takes details` | `details` |
|
|
848
|
+
| `policy: { errors: { details: 'some' } }` | `policy.errors.details is one of none, paths, full, got a string` | `details: 'full'` |
|
|
849
|
+
| `policy: { retry: 1 }` | `policy.retry is { max, on }` | `retry: { max: 2, on: [] }` |
|
|
850
|
+
| `policy: { retry: { max: 1, ms: 1 } }` | `policy.retry does not take 'ms' — it takes max, on` | the backoff is the client's |
|
|
851
|
+
| `policy: { retry: { max: -1, on: [] } }` | `policy.retry.max is an integer ≥ 0, got -1` | `max: 0` or more |
|
|
852
|
+
| `policy: { retry: { max: 1, on: 1 } }` | `policy.retry.on is an array of error codes (declared codes, or JC2xxx taxonomy codes)` | `on: ['JC2002']` |
|
|
853
|
+
| `policy: { stream: 1 }` | `policy.stream is { resume?, heartbeatMs?, maxPatchBytes? }` | a plain object |
|
|
854
|
+
| `policy: { stream: { keepAlive: 1 } }` | `policy.stream does not take 'keepAlive' — it takes resume, heartbeatMs, maxPatchBytes` | `heartbeatMs` |
|
|
855
|
+
| `policy: { stream: { resume: 'always' } }` | `policy.stream.resume is snapshot or replay, got a string` | `resume: 'replay'` |
|
|
856
|
+
| `policy: { stream: { heartbeatMs: 500 } }` | `policy.stream.heartbeatMs is an integer ≥ 1000, got 500` | `heartbeatMs: 2000` |
|
|
857
|
+
| `policy: { stream: { maxPatchBytes: 0 } }` | `policy.stream.maxPatchBytes is a positive integer, got 0` | a positive integer |
|
|
858
|
+
| `http(42)` | `http() takes { method, path, in?, body?, status?, media? }, got 42` | a plain object |
|
|
859
|
+
| `http({ method: 'GET', path: '/a', headers: {} })` | `http() does not take 'headers' — the binding is method, path, in, body, status, media (CONTRACT-FORMAT §4)` | `in: { … }` for a header-located member |
|
|
860
|
+
| `http({ method: 'get', path: '/a' })` | `http() method is one uppercase token of GET HEAD POST PUT PATCH DELETE OPTIONS, got a string` | `method: 'GET'` |
|
|
861
|
+
| `http({ …, in: 42 })` | `http() in is a plain object of input member → path \| query \| header \| body` | a plain object |
|
|
862
|
+
| `http({ …, in: { x: 'cookie' } })` | `http() in.x is one of path, query, header, body, got a string` | `{ x: 'header' }` |
|
|
863
|
+
| `http({ …, body: 42 })` | `http() body names the input member whose value IS the request body, got 42` | `body: 'doc'` |
|
|
864
|
+
| `http({ …, status: 404 })` | `http() status is an integer in 200–299, got 404` | `status: 204` |
|
|
865
|
+
| `http({ …, media: 42 })` | `http() media is a media type, got 42` | `media: 'image/png'` |
|
|
866
|
+
|
|
867
|
+
Two rows to read carefully. `{ __proto__: … }` is the binder's §1.1 rule
|
|
868
|
+
5 at this pen's one map door: the literal form sets the object's
|
|
869
|
+
prototype instead of adding a member, so the operation never reaches the
|
|
870
|
+
pen at all and the prototype is the only trace left to refuse by. And
|
|
871
|
+
`http() in received a Object instance, which is not JSON` — the message
|
|
872
|
+
for `in: { x: NaN }` — is the JSON boundary, shared by every pen, and it
|
|
873
|
+
is the one refusal in this table that carries no `docPath`: its `what`
|
|
874
|
+
names the position instead.
|
|
875
|
+
|
|
876
|
+
### 4.2 `JL0102` — the reserved path template, and the kind
|
|
877
|
+
|
|
878
|
+
Raised by `http()` scanning the template, which mirrors the compiler's
|
|
879
|
+
own parser: every form §4.2 reserves is refused by name at build time,
|
|
880
|
+
before `compileContract` would answer `JC0008` with the same meaning.
|
|
881
|
+
Every message carries `docPath: '/path'`.
|
|
882
|
+
|
|
883
|
+
| The spelling that trips it | The message | The spelling that works |
|
|
884
|
+
|---|---|---|
|
|
885
|
+
| `path: 42` | `http() path is a path template string, got 42` | a string |
|
|
886
|
+
| `path: 'a/b'` | `a path template must start with "/"` | `'/a/b'` |
|
|
887
|
+
| `path: '/a/'` | `a trailing "/" declares an empty segment; the root template "/" is the only empty path` | `'/a'` |
|
|
888
|
+
| `path: '/a//b'` | `an empty segment ("//")` | `'/a/b'` |
|
|
889
|
+
| `path: '/a/{id}.json'` | `a variable must be a whole segment ("{name}"), found "{id}.json" — the format reserves a variable that is only part of a segment` | `'/a/{id}'`, with the suffix in `media` |
|
|
890
|
+
| `path: '/a/{id'` | `a variable must be a whole segment ("{name}"), found "{id" — the format reserves a variable that is only part of a segment` | close the brace |
|
|
891
|
+
| `path: '/a/x:y'` | `":" is reserved for a variable segment (":name"), found "x:y"` | `'/a/x/y'` |
|
|
892
|
+
| `path: '/a/*'` | `"*" is a reserved wildcard form; $contract 0.1 has no wildcards, found "*"` | name the segments |
|
|
893
|
+
| `path: '/a?b'` | `"?" cannot appear in a path template (the query and fragment are not part of the path)` | `in: { b: 'query' }` |
|
|
894
|
+
| `path: '/a b'` | `whitespace or a control character in segment "a b"` | `'/a%20b'` |
|
|
895
|
+
| `path: '/a%zz'` | `a malformed percent-escape in segment "a%zz"` | a well-formed escape |
|
|
896
|
+
| `path: '/a/{+id}'` | `"{+id}" uses the reserved RFC 6570 operator "+"; $contract 0.1 supports only "{name}"` | `'/a/{id}'` |
|
|
897
|
+
| `path: '/a/{id*}'` | `"{id*}" uses the reserved "*" expansion modifier; $contract 0.1 has no wildcards` | `'/a/{id}'` |
|
|
898
|
+
| `path: '/a/{a,b}'`, `path: '/a/{id:3}'` | `"{a,b}" uses a reserved RFC 6570 list or prefix form; $contract 0.1 supports only "{name}"` | one variable per segment |
|
|
899
|
+
| `path: '/a/:'` | `":name" must be a whole segment with an identifier name, found ":"` | `'/a/:id'` |
|
|
900
|
+
| `path: '/a/:id?'` | `":id?" uses a reserved "?" modifier; $contract 0.1 has no wildcards or optional segments` | two operations, or `{id}` |
|
|
901
|
+
| `path: '/a/{1x}'` | `a variable name must match [A-Za-z_][A-Za-z0-9_]*, found "1x"` | `'/a/{x1}'` |
|
|
902
|
+
| `path: '/a/{id}/b/{id}'` | `the variable "id" is declared twice` | two names |
|
|
903
|
+
| `http({ method: 'GET', path: '/a', in: { id: 'path' } })` | `http() maps 'id' to path, but the template declares no {id} — a path member is named by the template itself` | `path: '/a/{id}'`, and drop the `in` entry |
|
|
904
|
+
| `defineContract({}, { a: { kind: 'stream', output: true } })` | `an operation kind is one of read, command, subscribe — got a string` | `subscribe({ output: true })` |
|
|
905
|
+
|
|
906
|
+
The last row is the only `JL0102` that is not a template: a HAND-WRITTEN
|
|
907
|
+
operation carries its own `kind`, and a kind outside the three is a
|
|
908
|
+
construct the format cannot carry. `read()`, `command()` and
|
|
909
|
+
`subscribe()` cannot trip it — they write the kind themselves.
|
|
910
|
+
|
|
911
|
+
The eight reserved forms are refused by the pen and by the compiler with
|
|
912
|
+
the same meaning and the same names; `test/linq/contract-pen.test.js`
|
|
913
|
+
runs `/a/{id}.json` through both and asserts `JL0102` from the pen and
|
|
914
|
+
`JC0008` from `compileContract`. Which one a caller sees depends only on
|
|
915
|
+
where the template was written — through `http()`, or into a JSON
|
|
916
|
+
document by hand.
|
|
917
|
+
|
|
918
|
+
### 4.3 `JL0103` — the definition
|
|
919
|
+
|
|
920
|
+
Raised when the `$defs` block is closed and every name a `ref()` demanded
|
|
921
|
+
must be answered, or when a `lazy()` thunk is resolved. The walk is the
|
|
922
|
+
schema pen's, run over every operation's schemas at once, so a collision
|
|
923
|
+
between two operations is found here and nowhere earlier.
|
|
924
|
+
|
|
925
|
+
| The spelling that trips it | The message | The spelling that works |
|
|
926
|
+
|---|---|---|
|
|
927
|
+
| two operations, each `output: s.named('P', …)` over a DIFFERENT builder | `two distinct builders are named 'P' in one document — a $defs entry can hold one definition; rename one of them` | one shared `const P = s.named('P', …)` |
|
|
928
|
+
| `output: s.object({ p: s.ref('Nope') })` | `ref('Nope') names no definition in this document — a name is defined by named('Nope', …) somewhere the root can reach` | define it in a schema this contract reaches |
|
|
929
|
+
| `output: s.object({ n: s.lazy(() => s.string()) })` | `lazy() must return a NAMED builder — a recursion is spelled as a $ref, and a $ref needs a definition to point at: lazy(() => Node) where Node = named('Node', …)` | `s.lazy(() => Node)` |
|
|
930
|
+
|
|
931
|
+
"Distinct" is by identity, not by shape: the same builder under one name,
|
|
932
|
+
reached from any operation, is one definition. Two builders that emit the
|
|
933
|
+
same JSON are still two and still a collision. The `docPath` names the
|
|
934
|
+
operation the second reference came from, which is what tells a caller
|
|
935
|
+
which two to reconcile.
|
|
936
|
+
|
|
937
|
+
A `ref()` may reach across operations: a definition named in one
|
|
938
|
+
operation's `input` answers a `ref()` in another's `output`, because the
|
|
939
|
+
`$defs` block is the contract's. What it cannot do is reach a name
|
|
940
|
+
nothing defines — that is this row, and it is why a contract assembled
|
|
941
|
+
from schemas written in several files still has one namespace.
|
|
942
|
+
|
|
943
|
+
### 4.4 Two closed vocabularies, and which one a caller meets
|
|
944
|
+
|
|
945
|
+
There are two closed member sets over the same document, and a reader who
|
|
946
|
+
does not know that will look up the wrong code.
|
|
947
|
+
|
|
948
|
+
- **The pen's surface** is what `read()`, `command()`, `subscribe()`,
|
|
949
|
+
`error()`, `http()` and `defineContract()` take. A member outside it is
|
|
950
|
+
`JL0101`, at the call, naming the members that function does take.
|
|
951
|
+
- **The document's own vocabulary** is CONTRACT-FORMAT's, and it is
|
|
952
|
+
`compileContract`'s to enforce: `JC0013`, at compile time, naming the
|
|
953
|
+
closed set. It is what a hand-written JSON contract meets, and what a
|
|
954
|
+
pen contract meets for anything the pen does not check.
|
|
955
|
+
|
|
956
|
+
The two are deliberately not one. The pen cannot own the document's
|
|
957
|
+
vocabulary — it writes only the members it was given, so a member the
|
|
958
|
+
format adds tomorrow must not be a member the pen refuses today — and the
|
|
959
|
+
compiler cannot own the pen's, because `read({ output: true, extra: 1 })`
|
|
960
|
+
never becomes a document at all.
|
|
961
|
+
|
|
962
|
+
```js
|
|
963
|
+
read({ output: true, extra: 1 })
|
|
964
|
+
// JL0101: read() does not take 'extra' — it takes input, output, errors, policy, http, doc
|
|
965
|
+
|
|
966
|
+
compileContract({ $contract: '0.1', operations: { a: { kind: 'read', output: true, extra: 1 } } })
|
|
967
|
+
// JC0013: unknown operation member 'extra' — the operation vocabulary is closed
|
|
968
|
+
// (kind, input, output, errors, policy, http, doc) at /operations/a/extra
|
|
969
|
+
```
|
|
970
|
+
|
|
971
|
+
**The pen's surface has two doors, and both run the same check.** An
|
|
972
|
+
operation written by hand inside `defineContract` —
|
|
973
|
+
`{ kind: 'read', output: true, extra: 1 }` — meets the pen's `JL0101`,
|
|
974
|
+
not the compiler's `JC0013`, because the member never becomes a document
|
|
975
|
+
member. The only difference from the declaration door is the `docPath`,
|
|
976
|
+
which names the operation's own position because there is one:
|
|
977
|
+
|
|
978
|
+
```js
|
|
979
|
+
defineContract({}, { 'note.get': { kind: 'read', output: true, extra: 1 } })
|
|
980
|
+
// JL0101: read() does not take 'extra' — it takes input, output, errors,
|
|
981
|
+
// policy, http, doc at /operations/note.get/extra
|
|
982
|
+
```
|
|
983
|
+
|
|
984
|
+
That symmetry is load-bearing rather than tidy. A pen emits only the
|
|
985
|
+
members it was given, so an unchecked door does not pass an unknown
|
|
986
|
+
member through to the compiler — it DROPS it, silently, where neither
|
|
987
|
+
refusal can reach it; and a member the pen does write without checking
|
|
988
|
+
(a non-string `doc`) reaches the document, which then fails the
|
|
989
|
+
published grammar. Both halves are pinned by
|
|
990
|
+
`test/linq/contract-pen.test.js`, which runs the same three spellings
|
|
991
|
+
through both doors and asserts the reason and the `docPath` of each.
|
|
992
|
+
|
|
993
|
+
## 5. The types
|
|
994
|
+
|
|
995
|
+
The pen is the only inference route (the binder's §1.1 rule 2): a
|
|
996
|
+
`Contract<Ops>` carries a phantom that `ContractOf<>` reads, and every
|
|
997
|
+
consumer below narrows off that one reading. Nothing here exists at run
|
|
998
|
+
time. The `shop` contract below is a catalog like §3.2's with declared
|
|
999
|
+
failures like §3.3's on it — the two features a consumer's types show off
|
|
1000
|
+
at once.
|
|
1001
|
+
|
|
1002
|
+
```ts
|
|
1003
|
+
import { compileContract } from '@jarenjs/contract';
|
|
1004
|
+
import { openLocalClient } from '@jarenjs/contract/local';
|
|
1005
|
+
import { contractTools } from '@jarenjs/contract/project';
|
|
1006
|
+
import { typedClient, typedHandlers, typedTools } from '@jarenjs/linq/contract';
|
|
1007
|
+
import type { Contract, ContractOf, InvokableOf, Outcome } from '@jarenjs/linq/contract';
|
|
1008
|
+
|
|
1009
|
+
type Ops = ContractOf<typeof shop>;
|
|
1010
|
+
// { 'product.save': { kind: 'command'; input: …; accepts: …; output: …;
|
|
1011
|
+
// errors: 'conflict' | 'not-found'; opaque: false }, … }
|
|
1012
|
+
|
|
1013
|
+
const compiled = compileContract(shop.document);
|
|
1014
|
+
const handlers = typedHandlers(shop, {
|
|
1015
|
+
'catalog.load': () => catalog, // must answer the declared output
|
|
1016
|
+
'product.save': (input, ctx) => saved ? input.product : ctx.fail('conflict'),
|
|
1017
|
+
});
|
|
1018
|
+
const served = openLocalClient(compiled, handlers); // the binding: any contract client
|
|
1019
|
+
const api = typedClient(served, shop); // the same object, narrowed by the phantom
|
|
1020
|
+
const outcome: Outcome<Product> = await api.invoke('product.save', { id: 1, revision: 4, product });
|
|
1021
|
+
api.url('image.bytes', { id: 3 }); // an opaque operation: a URL builder here
|
|
1022
|
+
api.invoke('image.bytes', { id: 3 }); // does not compile — it carries bytes
|
|
1023
|
+
const web = typedHttpClient(openHttpClient(compiled, { baseUrl }), shop);
|
|
1024
|
+
const image = await web.bytes('image.bytes', { id: 3 }); // Outcome<ByteResponse>: { status, headers, media, body: ReadableStream }
|
|
1025
|
+
web.bytes('product.save', { id: 1, revision: 4, product }); // does not compile — a JSON operation is invoked, not streamed
|
|
1026
|
+
for (const tool of typedTools(contractTools(compiled, api), shop)) toolbox.add(tool);
|
|
1027
|
+
|
|
1028
|
+
declare const anyContract: Contract<any>; // the class: an annotation, never a `new`
|
|
1029
|
+
type Invokable = keyof InvokableOf<typeof shop>; // 'catalog.load' | 'product.save'
|
|
1030
|
+
```
|
|
1031
|
+
|
|
1032
|
+
`contractTools` takes either client — the binding (`served`) or the
|
|
1033
|
+
typed view of it (`api`). That is not free: its `ToolClient` declares
|
|
1034
|
+
`invoke` as a METHOD rather than as a function-valued property, and a
|
|
1035
|
+
method's parameters are bivariant, so a client that narrows `invoke`'s
|
|
1036
|
+
operation type to a literal union — which is exactly what `typedClient`
|
|
1037
|
+
exists to do — still satisfies it. Written as a property it would not,
|
|
1038
|
+
and the pen's whole claim, one document and three consumers, would fail
|
|
1039
|
+
at the third. `test/consumer/linq-contract.ts` pins both spellings and
|
|
1040
|
+
asserts they answer the same type.
|
|
1041
|
+
|
|
1042
|
+
### 5.1 What `ContractOf<>` reads, member by member
|
|
1043
|
+
|
|
1044
|
+
One entry per declared operation, each an `OperationType`:
|
|
1045
|
+
|
|
1046
|
+
| Member | Read from | Value |
|
|
1047
|
+
|---|---|---|
|
|
1048
|
+
| `kind` | the declaration | `'read'`, `'command'` or `'subscribe'`, as a literal |
|
|
1049
|
+
| `input` | `Infer<>` of the input schema | the OUTPUT shape — post-normalization, defaults present — or `null` when none is declared |
|
|
1050
|
+
| `accepts` | `Input<>` of the input schema | the ACCEPTED shape: a defaulted member optional, a coerced member in its transport form |
|
|
1051
|
+
| `output` | `Infer<>` of the output schema | `unknown` for a hand-written JSON Schema or a boolean |
|
|
1052
|
+
| `errors` | the keys of `errors` | a literal union, `never` when none is declared |
|
|
1053
|
+
| `opaque` | the binding's `media` | `true` unless the media is `application/json` or a `+json` suffix; always `false` for a `subscribe` |
|
|
1054
|
+
|
|
1055
|
+
`opaque` is what splits the three consumers' views. `ContractOf<>` is
|
|
1056
|
+
every operation; `InvokableOf<>` drops the opaque ones, exactly as
|
|
1057
|
+
§12.3's `Operations` does, and it is what types `invoke`, the handler
|
|
1058
|
+
table and the tool set; `SubscribableOf<>` keeps the `subscribe` ones and
|
|
1059
|
+
types `client.subscribe`. `url()` is the one member declared over
|
|
1060
|
+
`ContractOf<>` itself, which is how an opaque operation stays reachable.
|
|
1061
|
+
|
|
1062
|
+
The media is read by PATTERN, never by indexing: `S extends { http:
|
|
1063
|
+
HttpBinding<infer H> }` and then `H extends { media: infer M }`. An
|
|
1064
|
+
absent optional member of a constraint is not the same as one the author
|
|
1065
|
+
declared, and indexing `H['media']` would make the two indistinguishable
|
|
1066
|
+
— so a binding with no `media` falls through to `'application/json'` by
|
|
1067
|
+
the pattern failing rather than by a lookup returning `undefined`.
|
|
1068
|
+
|
|
1069
|
+
### 5.2 The class, and the two members it carries
|
|
1070
|
+
|
|
1071
|
+
`Contract` is the one exported class, and a caller never constructs one:
|
|
1072
|
+
its constructor is private in the declaration and `defineContract()` is
|
|
1073
|
+
the only route. A caller meets it as an annotation — `Contract<any>` for
|
|
1074
|
+
a function that takes any pen contract — and reaches its two members,
|
|
1075
|
+
`document` and `toJSON()`, both of which answer the same deep-frozen
|
|
1076
|
+
JSON. The phantom `__ops` is declared and never present at run time; it
|
|
1077
|
+
exists so `ContractOf<>` has something to infer from.
|
|
1078
|
+
|
|
1079
|
+
### 5.3 What the pins hold
|
|
1080
|
+
|
|
1081
|
+
Two files, both compiled by `npm run test:types`:
|
|
1082
|
+
|
|
1083
|
+
| File | What it proves |
|
|
1084
|
+
|---|---|
|
|
1085
|
+
| `test/consumer/linq-contract-generated.ts` | `toTypeScript`'s own declarations for the three documents `test/linq/contract-corpus.js` emits, produced by `scripts/generate-contract-pen-fixture.js`. `test/linq/contract-pen.test.js` asserts the committed file is exactly what the generator produces today, so it cannot drift |
|
|
1086
|
+
| `test/consumer/linq-contract.ts` | for all three corpus contracts and a fourth built in the file, `ContractOf<>`'s `input`, `output` and error codes EQUAL (not merely assignable) to what the projection declares; `Meta`, `WireError`, `Outcome<T>`, `InvokeContext`, `Failure` and `HandlerContext` equal to the projection's rendering of §10.1's fixed D6 shapes; a local round trip; and six negatives |
|
|
1087
|
+
|
|
1088
|
+
The six negatives are the list of what the types forbid, each of which
|
|
1089
|
+
FAILS the build the day it starts compiling:
|
|
1090
|
+
|
|
1091
|
+
```ts
|
|
1092
|
+
void api.invoke('product.saev', input); // a misspelled operation id
|
|
1093
|
+
void api.invoke('product.save', { id: 1, rev: 4, product }); // 'revision' is the member
|
|
1094
|
+
void api.invoke('image.bytes', { id: 3 }); // opaque: reach it through url()
|
|
1095
|
+
const short = typedHandlers(Shop, { 'catalog.load': () => catalog }); // a missing handler
|
|
1096
|
+
const notATool: 'image_bytes' = tools[0]!.name; // the opaque operation is not a tool
|
|
1097
|
+
void liveApi.subscribe('board.set', input, {}); // 'board.set' is a command
|
|
1098
|
+
```
|
|
1099
|
+
|
|
1100
|
+
### 5.4 Three readings a caller will otherwise meet at run time
|
|
1101
|
+
|
|
1102
|
+
- **A date-formatted string is the `DateTime` brand on both sides.** It
|
|
1103
|
+
is the suite's one reading of `format: "date-time"`, shared by
|
|
1104
|
+
`@jarenjs/db`'s generated entity types, the schema pen and
|
|
1105
|
+
`toTypeScript` — so a contract whose input carries a date types the
|
|
1106
|
+
same through the pen and through `jaren-contract types`. The branding
|
|
1107
|
+
MOVED the published projection: a consumer comparing a generated file
|
|
1108
|
+
from before it will see a diff on every date member, and that diff is
|
|
1109
|
+
not a regression. `test/consumer/linq-contract.ts` pins the equality
|
|
1110
|
+
from both directions.
|
|
1111
|
+
- **A hand-written JSON Schema is `unknown` on both sides.** `output:
|
|
1112
|
+
{ type: 'string' }` reads `unknown`, not `string`, and so does the
|
|
1113
|
+
projection's rendering of the same document. That is the binder's rule
|
|
1114
|
+
2 — a literal is never inferred — and the honest answer, since nothing
|
|
1115
|
+
proved the literal is a schema at all. `s.from<T>(json)` is the
|
|
1116
|
+
caller's assertion when they want one.
|
|
1117
|
+
- **Pattern-match an absent optional member; never index it.** A handler
|
|
1118
|
+
writing against `ContractOf<C>[K]` reaches `input` and `output` through
|
|
1119
|
+
`extends { input: infer I } ? I : never`, not through `['input']`. The
|
|
1120
|
+
declaration does the same everywhere, and the reason is §5.1's: an
|
|
1121
|
+
operation that declares no input has `input: null`, and an index would
|
|
1122
|
+
make "declared as null" and "never declared" the same type.
|
|
1123
|
+
|
|
1124
|
+
## 6. What it cannot spell
|
|
1125
|
+
|
|
1126
|
+
The contract pen's limits are narrow, because a `$contract` document is
|
|
1127
|
+
mostly a map of schemas and the schema pen carries those limits
|
|
1128
|
+
([SCHEMA-PEN.md](SCHEMA-PEN.md#6-what-it-cannot-spell) §6 is the list
|
|
1129
|
+
that matters most to a contract author). What is left is three groups,
|
|
1130
|
+
and then §6.1 — the cases where the answer is not to reach for this pen
|
|
1131
|
+
at all.
|
|
1132
|
+
|
|
1133
|
+
- **The reserved path-template forms.** RFC 6570's operators and
|
|
1134
|
+
modifiers, the `*` wildcard, the optional `:name?` segment, and a
|
|
1135
|
+
variable that is only part of a segment. `$contract` 0.1 has level-1
|
|
1136
|
+
templates and nothing else, because a template is also a MATCHER
|
|
1137
|
+
(§5) and a router that has to expand `{+path}` cannot answer
|
|
1138
|
+
`match(method, path)` in one pass. §4.2 lists every form with the
|
|
1139
|
+
spelling that works; the general answer is one variable per whole
|
|
1140
|
+
segment, and a query member for everything else.
|
|
1141
|
+
- **Anything the format decides across members.** A `read`'s
|
|
1142
|
+
idempotency, a `GET`'s body member, an opaque operation's body member,
|
|
1143
|
+
a `subscribe`'s method, a route-shape collision, a path variable that
|
|
1144
|
+
is not an input member. None of these is unspellable — the pen writes
|
|
1145
|
+
every one of them — and each is `compileContract`'s to refuse, by
|
|
1146
|
+
§2.8's table. They are in this section because a reader looking for
|
|
1147
|
+
"why can't I do X" will look here, and the answer is that they CAN
|
|
1148
|
+
write it and it will not compile.
|
|
1149
|
+
- **The wire, and the bindings.** `jaren-contract-port` frames (§16.1)
|
|
1150
|
+
are the one `@jarenjs/contract` document shape with no pen and there
|
|
1151
|
+
will not be one: they are exchanged on a wire, not authored, so a
|
|
1152
|
+
builder for them would type nothing a caller writes. The binder's
|
|
1153
|
+
decision table ([LINQ-FORMAT.md](LINQ-FORMAT.md) §1.0) carries the
|
|
1154
|
+
rule and the whole list of formats it excludes; message catalogs are on
|
|
1155
|
+
the other side of that table, an authored format whose pen is not
|
|
1156
|
+
written yet.
|
|
1157
|
+
|
|
1158
|
+
One absence is worth naming because it is not a limit at all. There is no
|
|
1159
|
+
`describe()`, no `publicProjection()` and no `toTypeScript()` here: those
|
|
1160
|
+
are the compiler's, they need the materialized defaults the pen refuses
|
|
1161
|
+
to write, and reaching them means one import of `@jarenjs/contract` over
|
|
1162
|
+
`contract.document`. §7 is what that separation buys.
|
|
1163
|
+
|
|
1164
|
+
### 6.1 When not to reach for this pen
|
|
1165
|
+
|
|
1166
|
+
- **The contract is data.** A `$contract` read from a file, fetched from
|
|
1167
|
+
a running service's `describe()`, or produced by another tool is a
|
|
1168
|
+
value; `compileContract` takes it directly.
|
|
1169
|
+
- **You are consuming a contract you do not own.** The three wrappers of
|
|
1170
|
+
§2.7 type a client, a handler map or a tool set against a contract
|
|
1171
|
+
DOCUMENT — they do not need this pen to have written it. Import the
|
|
1172
|
+
document the service publishes and wrap that; authoring a second copy
|
|
1173
|
+
of somebody else's contract is how the two drift.
|
|
1174
|
+
- **The service is not one.** A contract is a published surface with a
|
|
1175
|
+
version and a compatibility list, and its whole value is that two
|
|
1176
|
+
parties can compare two revisions. One function called over a local
|
|
1177
|
+
import is not a service, and giving it a contract buys nothing but a
|
|
1178
|
+
build step.
|
|
1179
|
+
- **The API is not request/response.** Three operation kinds is the whole
|
|
1180
|
+
vocabulary — a read, a command and a subscription. A protocol that
|
|
1181
|
+
negotiates, that is bidirectional beyond a subscription, or that is
|
|
1182
|
+
really a stream of bytes has no spelling here, and §6's third bullet
|
|
1183
|
+
says why the wire's own frames deliberately have no pen.
|
|
1184
|
+
- **You want what the compiler produces, not what the pen writes.**
|
|
1185
|
+
Defaults materialized, a public projection, an OpenAPI file, a
|
|
1186
|
+
TypeScript declaration — all of those are `@jarenjs/contract`'s over
|
|
1187
|
+
`contract.document`, and none of them needs this subpath at run time.
|
|
1188
|
+
|
|
1189
|
+
## 7. Cost
|
|
1190
|
+
|
|
1191
|
+
`@jarenjs/linq/contract` builds to **<!--fact:bundle.contract-->45,298<!--/fact--> bytes** as a minified,
|
|
1192
|
+
tree-shaken ESM bundle — the figure `scripts/check-tree-shaking.js`
|
|
1193
|
+
measures and `npm run test:tree-shaking` reports, published rounded
|
|
1194
|
+
(<!--fact:bundle.contract.kb-->45<!--/fact--> kB) beside the other nine subpath prices in
|
|
1195
|
+
[docs/CONSUMING.md](../../../docs/CONSUMING.md).
|
|
1196
|
+
|
|
1197
|
+
The probe is a gate, not a report: building a one-operation contract as a
|
|
1198
|
+
consumer would, it asserts four things and fails the build on any of
|
|
1199
|
+
them:
|
|
1200
|
+
|
|
1201
|
+
- **the schema pen is included, and that is the ceiling.** A contract's
|
|
1202
|
+
inputs and outputs are schemas, so the two are measured together and
|
|
1203
|
+
the bundle carries <!--fact:bundle.schema-->33,156<!--/fact--> of its <!--fact:bundle.contract-->45,298<!--/fact--> bytes as the schema pen's own.
|
|
1204
|
+
The contract pen's own share is the remaining ~12 kB, most of it the
|
|
1205
|
+
refusal messages §4 lists;
|
|
1206
|
+
- **no chain module** — none of `sequence.js`, `document.js`, `async.js`,
|
|
1207
|
+
`concurrency.js`, `provider.js` or `sources.js` contributes a byte, and
|
|
1208
|
+
the chain's own bundle carries no contract module either;
|
|
1209
|
+
- **no `@jarenjs/contract` byte** — not one, which is the tree-shaken
|
|
1210
|
+
proof of §1's claim that the compiler is the only judge of what the
|
|
1211
|
+
document means. Nor `@jarenjs/validate`, `@jarenjs/emit`,
|
|
1212
|
+
`@jarenjs/db`, `@jarenjs/formats`, `@jarenjs/refs` or `@jarenjs/json`;
|
|
1213
|
+
- **no other pen** — the schema pen is the only one, and the schema pen
|
|
1214
|
+
in turn carries no contract module.
|
|
1215
|
+
|
|
1216
|
+
A consumer who writes a contract and also compiles it pays both prices
|
|
1217
|
+
and they add rather than overlap. That is the shape the separation is
|
|
1218
|
+
for: a browser bundle that only needs the TYPES a contract implies —
|
|
1219
|
+
`typedClient` over an HTTP binding, say — ships the pen's <!--fact:bundle.contract.kb-->45<!--/fact--> kB and none
|
|
1220
|
+
of the compiler, while the server that serves the contract imports
|
|
1221
|
+
`@jarenjs/contract` and does not need the pen at all.
|