@jarenjs/contract 0.49.2 → 0.56.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -51,8 +51,9 @@ published as JSON Schema in
51
51
  "$contract": "0.1",
52
52
  "id": "shop",
53
53
  "$defs": {
54
- "Product": { "type": "object", "required": ["id", "name"],
55
- "properties": { "id": { "type": "integer" }, "name": { "type": "string" } } }
54
+ "Product": { "type": "object",
55
+ "properties": { "id": { "type": "integer" }, "name": { "type": "string" } },
56
+ "required": ["id", "name"] }
56
57
  },
57
58
  "operations": {
58
59
  "catalog.load": {
@@ -64,9 +65,10 @@ published as JSON Schema in
64
65
  },
65
66
  "product.save": {
66
67
  "kind": "command",
67
- "input": { "type": "object", "required": ["id", "revision", "product"], "properties": {
68
+ "input": { "type": "object", "properties": {
68
69
  "id": { "type": "integer" }, "revision": { "type": "integer" },
69
- "product": { "$ref": "#/$defs/Product" } } },
70
+ "product": { "$ref": "#/$defs/Product" } },
71
+ "required": ["id", "revision", "product"] },
70
72
  "output": { "$ref": "#/$defs/Product" },
71
73
  "errors": { "conflict": { "status": 409 }, "not-found": { "status": 404 } },
72
74
  "policy": { "idempotency": "required", "revision": "input:/revision" },
@@ -74,7 +76,7 @@ published as JSON Schema in
74
76
  },
75
77
  "image.bytes": {
76
78
  "kind": "read",
77
- "input": { "type": "object", "required": ["id"], "properties": { "id": { "type": "integer" } } },
79
+ "input": { "type": "object", "properties": { "id": { "type": "integer" } }, "required": ["id"] },
78
80
  "output": true,
79
81
  "http": { "method": "GET", "path": "/api/images/{id}", "media": "application/octet-stream" }
80
82
  }
@@ -90,6 +92,51 @@ members are never coerced. An operation without `http` is bound to the
90
92
  canonical `POST /<op-id>`. A non-JSON `media` marks an operation
91
93
  *opaque*: routed and matched, never validated as JSON.
92
94
 
95
+ ## The same document, by code
96
+
97
+ `@jarenjs/linq/contract` is the pen that writes this format. The
98
+ builders are the schema pen's, every `named()` schema is hoisted into
99
+ the contract's `$defs`, the members land in the order §12.1 fixes, and
100
+ no default is written — so the document below is byte for byte the one
101
+ above:
102
+
103
+ ```js
104
+ import * as s from '@jarenjs/linq/schema';
105
+ import { command, defineContract, error, http, read } from '@jarenjs/linq/contract';
106
+
107
+ const Product = s.named('Product', s.object({ id: s.integer(), name: s.string() }).open());
108
+
109
+ export const shop = defineContract({ id: 'shop' }, {
110
+ 'catalog.load': read({
111
+ input: s.object({ since: s.string().format('date-time').optional() }).open(),
112
+ output: s.array(Product),
113
+ policy: { task: 'switch', cache: 'revision' },
114
+ http: http({ method: 'GET', path: '/api/catalog' }),
115
+ }),
116
+ 'product.save': command({
117
+ input: s.object({ id: s.integer(), revision: s.integer(), product: Product }).open(),
118
+ output: Product,
119
+ errors: { conflict: error({ status: 409 }), 'not-found': error({ status: 404 }) },
120
+ policy: { idempotency: 'required', revision: 'input:/revision' },
121
+ http: http({ method: 'PUT', path: '/api/products/{id}/master' }),
122
+ }),
123
+ 'image.bytes': read({
124
+ input: s.object({ id: s.integer() }).open(),
125
+ output: true,
126
+ http: http({ method: 'GET', path: '/api/images/{id}', media: 'application/octet-stream' }),
127
+ }),
128
+ });
129
+
130
+ compileContract(shop.document); // the same compile, the same errors
131
+ ```
132
+
133
+ The types come with it, without the `types` projection: `ContractOf<typeof
134
+ shop>` is the operation map, and `typedClient`, `typedHandlers` and
135
+ `typedTools` carry it onto a client, a handler table and an AI toolbox.
136
+ The pen's document is
137
+ [CONTRACT-PEN.md](../linq/docs/CONTRACT-PEN.md); it imports nothing of
138
+ this package.
139
+
93
140
  ## Compile once, use everywhere
94
141
 
95
142
  ```js
@@ -492,19 +539,19 @@ benchmark-figure gate so no number here is typed by hand:
492
539
 
493
540
  - **Route match**: the compiled matcher resolves the probe mix —
494
541
  static hot paths, variables, the static-beats-variable case, a miss —
495
- at <!--bm:contract.match.vs-fmw-->172 ns per lookup vs find-my-way's 180 ns<!--/bm-->;
496
- hono's TrieRouter is <!--bm:contract.match.vs-hono-->1.8x<!--/bm--> behind, and its RegExpRouter refuses this
542
+ at <!--fact:contract.match.vs-fmw-->172 ns per lookup vs find-my-way's 180 ns<!--/fact-->;
543
+ hono's TrieRouter is <!--fact:contract.match.vs-hono-->1.8x<!--/fact--> behind, and its RegExpRouter refuses this
497
544
  route table outright (a static path registered after a param sibling).
498
545
  - **Dispatch, in-process**: the whole pipeline (route, decode, validate
499
546
  input, handler, validate output,
500
- serialize) is <!--bm:contract.dispatch.vs-fastify-->2.8–15.1x<!--/bm-->
547
+ serialize) is <!--fact:contract.dispatch.vs-fastify-->2.8–15.1x<!--/fact-->
501
548
  faster than Fastify driven through its own `inject` — a number that
502
549
  includes Fastify's mock-stream harness, which is why the next row
503
550
  exists.
504
551
  - **The honest loss**: the bare pieces Fastify composes — find-my-way +
505
552
  Ajv + fast-json-stringify, called directly with no harness and no
506
553
  response validation
507
- — are <!--bm:contract.dispatch.losses-->2.4–7.5x<!--/bm--> faster than
554
+ — are <!--fact:contract.dispatch.losses-->2.4–7.5x<!--/fact--> faster than
508
555
  this pipeline. The wide end of that band is the bare `{ok:true}`
509
556
  route, where the rival's compiled serializer answers in ~200 ns and
510
557
  there is almost no work to amortize the pipeline against; on the
@@ -514,7 +561,7 @@ benchmark-figure gate so no number here is typed by hand:
514
561
  server kept its own contract before a byte leaves. Over a real
515
562
  loopback socket the two stacks are level: the socket dominates both.
516
563
  - **Revision**: computing it
517
- costs <!--bm:contract.revision.ms-->1.4 ms<!--/bm--> for the 123-operation
564
+ costs <!--fact:contract.revision.ms-->1.4 ms<!--/fact--> for the 123-operation
518
565
  contract, once per process.
519
566
 
520
567
  ## What it is not
@@ -24,7 +24,7 @@ export type ToolDefinition = {
24
24
  execute: (args: any) => any;
25
25
  };
26
26
  export type ToolClient = {
27
- invoke: (op: string, input: any, ctx?: any) => any;
27
+ invoke(op: string, input: any, ctx?: any): any;
28
28
  };
29
29
  export type ContractToolsOptions = {
30
30
  /**
@@ -22,6 +22,17 @@
22
22
  * stylesheet — the D6 shapes as every binding carries them
23
23
  * (`OUTCOME_META_MEMBERS` / `OUTCOME_ERROR_MEMBERS` in the client module
24
24
  * are the runtime twins; a test holds the text to them).
25
+ *
26
+ * One convention rides on top of emit's reading, the suite's: a string
27
+ * with `format: "date-time"` or `format: "date"` is the `DateTime`
28
+ * brand, so a consumer's generated types agree with `@jarenjs/db`'s
29
+ * entity types (`entityEmitModel`) and `@jarenjs/linq`'s schema pen,
30
+ * which both read a date format that way. Emit itself records a format
31
+ * only as a dropped constraint, so the brand is applied HERE, by
32
+ * rewriting date-formatted string nodes to a `$ref` of one shared
33
+ * definition before emit reads the document — in every position, an
34
+ * array item as much as a member — and giving that definition the brand
35
+ * intersection.
25
36
  */
26
37
  export type Contract = import('../compile.js').Contract;
27
38
  export type CompiledOperation = import('../compile.js').CompiledOperation;
@@ -17,6 +17,15 @@ declared errors, a behavior policy and an HTTP binding. It is compiled
17
17
  path matcher, and it is the single source every artifact around it is
18
18
  projected from.
19
19
 
20
+ A contract document is written by hand, or by code: `@jarenjs/linq/
21
+ contract` is the pen that writes exactly this format — the same
22
+ operations, schemas, policies and bindings, in §12.1's member order,
23
+ with the operations' named schemas hoisted into `$defs` — and it types
24
+ the client, the handler table and the AI tools from the same builders,
25
+ without running the TypeScript projection of §12.3. The three worked
26
+ examples below are rebuilt through it, byte for byte, by its own test
27
+ suite.
28
+
20
29
  Format 0.1 covers the document, its compilation, the HTTP binding's
21
30
  *shape* (§2–§6), the HTTP **server** binding that carries it (§7–§9:
22
31
  the request pipeline and its wire errors, idempotency and the ledger
@@ -48,22 +57,22 @@ coming lines of this package and will append their sections here.
48
57
  "version": "5",
49
58
  "compat": ["4"],
50
59
  "$defs": {
60
+ "Catalog": {
61
+ "type": "object",
62
+ "properties": {
63
+ "revision": { "type": "integer" },
64
+ "products": { "type": "array", "items": { "$ref": "#/$defs/Product" } }
65
+ },
66
+ "required": ["revision", "products"]
67
+ },
51
68
  "Product": {
52
69
  "type": "object",
53
- "required": ["id", "name", "price"],
54
70
  "properties": {
55
71
  "id": { "type": "integer" },
56
72
  "name": { "type": "string", "minLength": 1 },
57
73
  "price": { "type": "number", "minimum": 0 }
58
- }
59
- },
60
- "Catalog": {
61
- "type": "object",
62
- "required": ["revision", "products"],
63
- "properties": {
64
- "revision": { "type": "integer" },
65
- "products": { "type": "array", "items": { "$ref": "#/$defs/Product" } }
66
- }
74
+ },
75
+ "required": ["id", "name", "price"]
67
76
  },
68
77
  "Conflict": { "type": "object", "properties": { "current": { "$ref": "#/$defs/Product" } } }
69
78
  },
@@ -81,12 +90,12 @@ coming lines of this package and will append their sections here.
81
90
  "kind": "command",
82
91
  "input": {
83
92
  "type": "object",
84
- "required": ["id", "revision", "product"],
85
93
  "properties": {
86
94
  "id": { "type": "integer" },
87
95
  "revision": { "type": "integer" },
88
96
  "product": { "$ref": "#/$defs/Product" }
89
- }
97
+ },
98
+ "required": ["id", "revision", "product"]
90
99
  },
91
100
  "output": { "$ref": "#/$defs/Product" },
92
101
  "errors": {
@@ -102,7 +111,7 @@ coming lines of this package and will append their sections here.
102
111
  },
103
112
  "image.bytes": {
104
113
  "kind": "read",
105
- "input": { "type": "object", "required": ["id"], "properties": { "id": { "type": "integer" } } },
114
+ "input": { "type": "object", "properties": { "id": { "type": "integer" } }, "required": ["id"] },
106
115
  "output": true,
107
116
  "http": { "method": "GET", "path": "/api/images/{id}", "media": "application/octet-stream" }
108
117
  }
@@ -427,20 +436,20 @@ The three worked examples this document is tested against, complete:
427
436
  "kind": "command",
428
437
  "input": {
429
438
  "type": "object",
430
- "required": ["id", "doc"],
431
439
  "properties": {
432
440
  "id": { "type": "string" },
433
441
  "doc": { "type": "array", "items": { "type": "object" } },
434
442
  "dry": { "type": "boolean" }
435
- }
443
+ },
444
+ "required": ["id", "doc"]
436
445
  },
437
446
  "output": true,
438
447
  "policy": { "idempotency": "optional" },
439
- "http": { "method": "PUT", "path": "/docs/:id", "body": "doc", "in": { "dry": "query" }, "status": 204 }
448
+ "http": { "method": "PUT", "path": "/docs/:id", "in": { "dry": "query" }, "body": "doc", "status": 204 }
440
449
  },
441
450
  "doc.remove": {
442
451
  "kind": "command",
443
- "input": { "type": "object", "required": ["id"], "properties": { "id": { "type": "string" } } },
452
+ "input": { "type": "object", "properties": { "id": { "type": "string" } }, "required": ["id"] },
444
453
  "output": true
445
454
  }
446
455
  }
@@ -1555,6 +1564,17 @@ a test holds the text to them; `details` is `unknown` and `status`
1555
1564
  `number | null`, never optional members. An input-less operation's
1556
1565
  `input` is `null`; an opaque operation appears only in `UrlOperations`.
1557
1566
 
1567
+ One convention rides on top of emit's reading, and it is the suite's:
1568
+ a string with `format: "date-time"` or `format: "date"` is declared as
1569
+ `DateTime` — `string & { __jarenTag: 'date-time' }` — rendered once per
1570
+ document and referenced from every position, an array item as much as a
1571
+ member. Emit itself records a format only as a dropped constraint; the
1572
+ brand is applied by this projection so a consumer's generated types
1573
+ agree with `@jarenjs/db`'s entity types (`entityEmitModel`) and with
1574
+ `@jarenjs/linq`'s schema and contract pens, which read a date format the
1575
+ same way. A contract that already declares a `$defs` entry named
1576
+ `DateTime` keeps it; the brand takes the next free name.
1577
+
1558
1578
  ### §12.4 Markdown
1559
1579
 
1560
1580
  `toMarkdown(contract, { title? })` renders one reference document: the
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@jarenjs/contract",
3
3
  "private": false,
4
- "version": "0.49.2",
4
+ "version": "0.56.0",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
7
7
  "types": "./dist/types/index.d.ts",
@@ -102,9 +102,9 @@
102
102
  "prepack": "npm run build:types"
103
103
  },
104
104
  "dependencies": {
105
- "@jarenjs/core": "^0.49.2",
106
- "@jarenjs/json": "^0.49.2",
107
- "@jarenjs/validate": "^0.49.2",
108
- "@jarenjs/emit": "^0.49.2"
105
+ "@jarenjs/core": "^0.56.0",
106
+ "@jarenjs/json": "^0.56.0",
107
+ "@jarenjs/validate": "^0.56.0",
108
+ "@jarenjs/emit": "^0.56.0"
109
109
  }
110
110
  }
@@ -35,8 +35,15 @@ import { bundleSameDocument } from '../bundle.js';
35
35
  /**
36
36
  * A client as the tools read it — any binding's client. The members are
37
37
  * `any` so a client whose `invoke` narrows its own context type (the http
38
- * client's `InvokeContext`) still assigns.
39
- * @typedef {{ invoke: (op: string, input: any, ctx?: any) => any }} ToolClient
38
+ * client's `InvokeContext`) still assigns, and `invoke` is declared as a
39
+ * METHOD rather than as a function-valued property: a method's parameters
40
+ * are bivariant, so a client whose `invoke` narrows its OPERATION type to
41
+ * a literal union — `@jarenjs/linq/contract`'s `typedClient`, whose whole
42
+ * purpose is that narrowing — assigns here too. Under
43
+ * `strictFunctionTypes` the property form rejects it, and there is
44
+ * nothing to reject: this module only ever CALLS `invoke`, with an id it
45
+ * read out of the contract the client was opened on.
46
+ * @typedef {{ invoke(op: string, input: any, ctx?: any): any }} ToolClient
40
47
  */
41
48
 
42
49
  /**
@@ -23,6 +23,17 @@
23
23
  * stylesheet — the D6 shapes as every binding carries them
24
24
  * (`OUTCOME_META_MEMBERS` / `OUTCOME_ERROR_MEMBERS` in the client module
25
25
  * are the runtime twins; a test holds the text to them).
26
+ *
27
+ * One convention rides on top of emit's reading, the suite's: a string
28
+ * with `format: "date-time"` or `format: "date"` is the `DateTime`
29
+ * brand, so a consumer's generated types agree with `@jarenjs/db`'s
30
+ * entity types (`entityEmitModel`) and `@jarenjs/linq`'s schema pen,
31
+ * which both read a date format that way. Emit itself records a format
32
+ * only as a dropped constraint, so the brand is applied HERE, by
33
+ * rewriting date-formatted string nodes to a `$ref` of one shared
34
+ * definition before emit reads the document — in every position, an
35
+ * array item as much as a member — and giving that definition the brand
36
+ * intersection.
26
37
  */
27
38
 
28
39
  import { isJsonObject, setObjectMember } from '@jarenjs/core/object';
@@ -66,6 +77,72 @@ function pascal(word) {
66
77
  return out;
67
78
  }
68
79
 
80
+ /** The two formats that carry the brand. */
81
+ const DATE_FORMATS = ['date-time', 'date'];
82
+
83
+ /** The definition name the brand takes when the contract leaves it free. */
84
+ const DATE_TIME = 'DateTime';
85
+
86
+ /**
87
+ * Rewrite every date-formatted string node to a reference to the shared
88
+ * brand definition, in every position. Nodes are rebuilt, never mutated:
89
+ * the schemas here are the compiled document's own frozen subtrees.
90
+ * @param {any} node
91
+ * @param {string} name - the brand definition's name in this document
92
+ * @returns {any}
93
+ */
94
+ function brandDates(node, name) {
95
+ if (Array.isArray(node)) return node.map((item) => brandDates(item, name));
96
+ if (node === null || typeof node !== 'object') return node;
97
+ /** @type {Record<string, any>} */
98
+ const out = {};
99
+ const keys = Object.keys(node);
100
+ for (let i = 0; i < keys.length; i++) setObjectMember(out, keys[i], brandDates(node[keys[i]], name));
101
+ if (!DATE_FORMATS.includes(/** @type {any} */ (out.format))) return out;
102
+ const { type, format: _format, ...rest } = out;
103
+ if (type === 'string') return { $ref: `#/$defs/${name}`, ...rest };
104
+ // a nullable date: the brand or null, the rest of the node kept
105
+ if (Array.isArray(type) && type.length === 2 && type.includes('string') && type.includes('null')) {
106
+ return { anyOf: [{ $ref: `#/$defs/${name}` }, { type: 'null' }], ...rest };
107
+ }
108
+ return out;
109
+ }
110
+
111
+ /** Whether a document reaches a date-formatted string anywhere. */
112
+ function usesDates(node) {
113
+ if (Array.isArray(node)) return node.some(usesDates);
114
+ if (node === null || typeof node !== 'object') return false;
115
+ if (DATE_FORMATS.includes(node.format)) return true;
116
+ return Object.values(node).some(usesDates);
117
+ }
118
+
119
+ /**
120
+ * Replace the brand definition's compiled declaration — a plain
121
+ * `string` — with the brand intersection `string & { __jarenTag:
122
+ * 'date-time' }`, structurally identical to `@jarenjs/db`'s and
123
+ * `@jarenjs/linq`'s.
124
+ * @param {any} model
125
+ * @param {string} name
126
+ */
127
+ function brandDeclaration(model, name) {
128
+ const declaration = model.declarations.find((/** @type {any} */ d) => d.name === name);
129
+ /* c8 ignore next -- the definition is added exactly when it is referenced */
130
+ if (declaration === undefined) return;
131
+ declaration.type = {
132
+ kind: 'intersection',
133
+ parts: [{ kind: 'primitive', primitive: 'string' }, {
134
+ kind: 'object',
135
+ members: [{
136
+ kind: 'member', name: '__jarenTag',
137
+ type: { kind: 'literal', value: 'date-time' }, required: true,
138
+ constraints: [], doc: [],
139
+ }],
140
+ }],
141
+ };
142
+ declaration.doc = ['An RFC 3339 string branded for the date operators;',
143
+ 'structurally identical to the @jarenjs/linq and @jarenjs/db brand.'];
144
+ }
145
+
69
146
  /**
70
147
  * The type model of a contract: the synthetic `$defs` root, the row per
71
148
  * operation the stylesheet renders, and the rendered declarations.
@@ -117,7 +194,20 @@ export function contractTypeModel(contract, ops, source) {
117
194
  }
118
195
  const names = Object.keys(contractDefs);
119
196
  for (let i = 0; i < names.length; i++) setObjectMember(defs, names[i], contractDefs[names[i]]);
120
- const model = compileEmitModel({ $defs: defs }, { name: 'Contract', source });
197
+ /** @type {Record<string, any>} */
198
+ let root = defs;
199
+ /** @type {string | null} */
200
+ let brand = null;
201
+ if (usesDates(defs)) {
202
+ // the brand takes its own name unless the contract already spells it
203
+ brand = taken.has(DATE_TIME) ? unique(DATE_TIME) : DATE_TIME;
204
+ root = { [brand]: { type: 'string' } };
205
+ const branded = brandDates(defs, brand);
206
+ const keys = Object.keys(branded);
207
+ for (let i = 0; i < keys.length; i++) setObjectMember(root, keys[i], branded[keys[i]]);
208
+ }
209
+ const model = compileEmitModel({ $defs: root }, { name: 'Contract', source });
210
+ if (brand !== null) brandDeclaration(model, brand);
121
211
  const declarations = model.declarations.filter((d) => d.name !== model.root);
122
212
  const types = renderTypeScript({ ...model, declarations }, { banner: false });
123
213
  return { types, rows, model: { ...model, declarations } };