@jarenjs/contract 0.73.0 → 0.75.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
@@ -739,3 +739,35 @@ Every subpath a consumer can import, derived from the manifest by
739
739
  Author JSON template catalogs and MessageSpec references with the
740
740
  [messages pen](../linq/docs/MESSAGES-PEN.md); existing locale render functions
741
741
  retain their pluralization and formatting behavior.
742
+
743
+ ## Structured query inputs and app reconnect
744
+
745
+ Object- and array-typed query members use one JSON-encoded parameter,
746
+ including empty arrays, nulls and nested values. Scalar strings stay
747
+ literal; parsed JSON is validated without coercion. The client, URL
748
+ builder, server dispatcher and OpenAPI projection share this codec.
749
+ Malformed or repeated JSON members are `JC2012`. Handwritten callers
750
+ must migrate repeated array keys (`tag=a&tag=b`) to one encoded JSON array;
751
+ deploy matching client/server versions and revise the contract's `version`
752
+ for revision negotiation. See [the wire rules](docs/CONTRACT-FORMAT.md#4-the-http-binding-and-member-locations).
753
+
754
+ Opt into HTTP subscription recovery per operation:
755
+
756
+ ```javascript
757
+ const binding = contractAppBinding(contract, {
758
+ subs: { 'board.feed': { reconnect: { max: 2 } } },
759
+ });
760
+ ```
761
+
762
+ The generated subscription forwards the option to `client.subscribe`.
763
+ The slot stays live with its id and last value while reconnecting; replay
764
+ continues from the last delivered sequence. Exhaustion surfaces `JC2097`.
765
+ Stop, reset and app destruction stop recovery. Without the option,
766
+ network loss is surfaced immediately; an explicit server end remains
767
+ terminal. Port and local channel lifecycles are unchanged.
768
+
769
+ The fixed-heap host-seam test measures the Node response's writable queue
770
+ and the producer's unwritten chunk separately from bytes already accepted
771
+ by TCP. The in-process Fetch leg can measure produced-minus-consumed
772
+ bytes directly. Complete-byte hashes, cursor pulls and cancellation
773
+ finalizers remain part of the same end-to-end test.
@@ -33,6 +33,16 @@ export type ContractAppBindingOptions = {
33
33
  * - the operations the app uses; default every operation
34
34
  */
35
35
  ops?: readonly string[];
36
+ /**
37
+ * - per
38
+ * subscribe-operation options. Reconnect is opt-in; the slot stays live
39
+ * while the HTTP client resumes, and reports error after exhaustion.
40
+ */
41
+ subs?: Record<string, {
42
+ reconnect: {
43
+ max: number;
44
+ };
45
+ }>;
36
46
  };
37
47
  export type TaskSlot = {
38
48
  id: number;
@@ -42,6 +42,9 @@ export type StreamProps = {
42
42
  snapshot: string;
43
43
  patch: string;
44
44
  error: string;
45
+ reconnect?: {
46
+ max: number;
47
+ };
45
48
  };
46
49
  /**
47
50
  * @typedef {import('../compile.js').Contract} Contract
@@ -60,7 +63,7 @@ export type StreamProps = {
60
63
  /**
61
64
  * The props of one generated subs entry: the operation, the slot id and
62
65
  * input resolved from state, and the action names to dispatch.
63
- * @typedef {{ op: string, id: number, input: unknown, snapshot: string, patch: string, error: string }} StreamProps
66
+ * @typedef {{ op: string, id: number, input: unknown, snapshot: string, patch: string, error: string, reconnect?: { max: number } }} StreamProps
64
67
  */
65
68
  /**
66
69
  * Make the `contract-stream` subscription handler of an app over a
@@ -262,7 +262,7 @@ export type ClientRoute = {
262
262
  method: string;
263
263
  segments: readonly import('../path.js').PathSegment[];
264
264
  queryMembers: readonly string[];
265
- queryRepeated: ReadonlySet<string>;
265
+ queryJson: ReadonlySet<string>;
266
266
  headerMembers: readonly string[];
267
267
  headerNames: readonly string[];
268
268
  /**
@@ -38,6 +38,11 @@ export type InputTransport = {
38
38
  header: readonly string[];
39
39
  repeated: readonly string[];
40
40
  };
41
+ /**
42
+ * - query members with a declared
43
+ * object/array type, encoded as one JSON value (including nullable unions)
44
+ */
45
+ queryJson: readonly string[];
41
46
  schemas: Readonly<Record<string, any>>;
42
47
  required: readonly string[];
43
48
  };
@@ -102,9 +102,9 @@ export type Route = {
102
102
  pathMembers: readonly string[];
103
103
  queryMembers: ReadonlySet<string>;
104
104
  /**
105
- * - array-typed query members
105
+ * - JSON-encoded query members
106
106
  */
107
- repeated: ReadonlySet<string>;
107
+ queryJson: ReadonlySet<string>;
108
108
  /**
109
109
  * - member names
110
110
  */
@@ -292,18 +292,19 @@ export declare function formatEntityTag(tag: string, strong: boolean): string;
292
292
  /**
293
293
  * Decode a query string into the declared members of an input object:
294
294
  * only declared names are set (an undeclared key is never merged, so no
295
- * request can smuggle a member); a `repeated` member collects every
296
- * occurrence into an array, every other member is last-wins; a `+` is a
295
+ * request can smuggle a member); JSON members decode exactly one value,
296
+ * scalar members are last-wins; a `+` is a
297
297
  * space and escapes decode as `application/x-www-form-urlencoded`
298
298
  * (`URLSearchParams`). Returns `false` when the query is not decodable
299
- * (a malformed percent-escape or invalid UTF-8) the `JC2012` case.
299
+ * (malformed percent-escape, UTF-8 or JSON, or a repeated JSON member)
300
+ * — the `JC2012` case.
300
301
  * @param {string} query - the part after `?`, possibly empty
301
302
  * @param {ReadonlySet<string>} declared - the query member names
302
- * @param {ReadonlySet<string>} repeated - the array-typed ones
303
303
  * @param {Record<string, unknown>} out - the input object under assembly
304
+ * @param {ReadonlySet<string>} json - schema-directed JSON members
304
305
  * @returns {boolean} false when not decodable
305
306
  */
306
- export declare function decodeQuery(query: string, declared: ReadonlySet<string>, repeated: ReadonlySet<string>, out: Record<string, unknown>): boolean;
307
+ export declare function decodeQuery(query: string, declared: ReadonlySet<string>, out: Record<string, unknown>, json: ReadonlySet<string>): boolean;
307
308
  /**
308
309
  * The verdict of a compiled validator under either contract: the
309
310
  * default `{ valid, errors }` or a host-injected boolean validator.
@@ -261,25 +261,44 @@ top-level `input.properties` member gets exactly one location:
261
261
  does not declare);
262
262
  4. otherwise the default: `query` for a `read`, `body` for a `command`.
263
263
 
264
- Path, query and header members arrive as strings and are decoded by a
265
- normalizer compiled **over those members only** with `coerceTypes`
266
- (`@jarenjs/validate/normalize`); **a body member is never coerced**. The
267
- compiled operation carries this as
268
- `input.transport = { normalize, members: { path, query, header, repeated }, schemas, required }`
269
- (`null` when nothing travels as a string) beside `input.effective`
270
- the object schema the declared `input` resolves to (itself, or the end
271
- of its `$ref` chain), whose `properties` are the operation's members;
272
- `schemas` holds each
273
- transport member's declared schema and `required` the transport members
274
- the input requires (what a URL builder validates without the body), and
275
- `repeated` lists the
276
- query and header members whose effective schema type is `array` a
277
- decoder collects repeats of those into an array (a repeated query key; a
278
- repeated header line or a comma-separated header list, RFC 9110 §5.3)
279
- before normalizing; every other query member is last-wins and every
280
- other header member is one line (§7.4). The server validates the
281
- reassembled input object with the operation's compiled validator; the
282
- client validates the same object before it splits it.
264
+ Scalar path, query and header members arrive as strings and are decoded
265
+ by a normalizer compiled over those members with `coerceTypes`
266
+ (`@jarenjs/validate/normalize`). **JSON query members and body members are
267
+ never coerced**, including nested values.
268
+
269
+ A query member whose effective declared `type` is `object` or `array`
270
+ (or a type array including either) uses **one JSON value in one query
271
+ parameter**. The client applies `JSON.stringify`, then `URLSearchParams`;
272
+ the server percent-decodes and applies `JSON.parse` before validation.
273
+ This includes nullable and scalar/structured unions: their values always
274
+ use JSON encoding. Empty arrays, nulls, arrays of objects, numeric object
275
+ keys and strings inside those unions round-trip without guessing from
276
+ text. An absent member is omitted. Plain `string` members remain literal,
277
+ even when their text looks like JSON. `$ref` chains are resolved at
278
+ compilation; unconstrained schemas and unions expressed only with
279
+ `anyOf`/`oneOf` do not imply a codec declare a top-level `type` to choose
280
+ one. Malformed JSON or multiple occurrences of a JSON member are `JC2012`;
281
+ a well-formed value of the wrong type is `JC2006`. Undeclared query keys
282
+ are ignored. Scalar query members remain last-wins.
283
+
284
+ The compiled operation carries
285
+ `input.transport = { normalize, members: { path, query, header, repeated }, queryJson, schemas, required }`
286
+ (`null` when nothing travels as a string), beside `input.effective`, the
287
+ resolved input object schema. `schemas` and `required` describe all
288
+ transport members for URL validation; `queryJson` lists the JSON members
289
+ excluded from scalar normalization. `repeated` identifies array-typed
290
+ query/header members; JSON query encoding takes precedence. Array headers
291
+ still collect repeated lines or comma-separated values (RFC 9110 §5.3)
292
+ before normalization; other headers require one line (§7.4). The server
293
+ validates the reassembled input; the client validates before splitting it.
294
+
295
+ **Wire migration:** array query members use `tag=["a","b"]` (URL-encoded),
296
+ replacing `tag=a&tag=b`. Update handwritten callers and deploy matching
297
+ client/server versions together; published contracts should change their
298
+ `version` so revision negotiation detects a mismatched deployment. The
299
+ OpenAPI projection declares these parameters with
300
+ `content: { "application/json": { schema: ... } }`, rather than an
301
+ exploded array schema.
283
302
 
284
303
  An operation bound to `GET` or `HEAD` MUST NOT carry a body-located
285
304
  member (`JC0016`) — including a `command` whose members default to the
@@ -601,12 +620,12 @@ is `JC2008`.
601
620
  stripped by the decoder); then `JSON.parse` (a failure is `JC2005`).
602
621
  7. **Assemble** the input object through a prototype-safe setter only, in
603
622
  this order: path members (raw decoded strings), query members
604
- (`URLSearchParams` semantics — `+` is a space; a member listed in
605
- `transport.members.repeated` collects every occurrence into an array,
606
- every other member is last-wins; an **undeclared query key is
607
- ignored, never merged**; an undecodable query is `JC2012`), declared
608
- header members (by their lowercased name; §7.4), then the transport
609
- normalizer over exactly those members (`coerceTypes`), then the body,
623
+ (`URLSearchParams` semantics — `+` is a space; `queryJson` members
624
+ decode one JSON value, scalar members are last-wins; an **undeclared
625
+ query key is ignored, never merged**; malformed encoding/JSON or a
626
+ repeated JSON member is `JC2012`), declared header members (by their
627
+ lowercased name; §7.4), then the scalar transport normalizer (JSON
628
+ members excluded), then the body,
610
629
  **never coerced**: `http.body` names a member → the parsed value is
611
630
  that member; otherwise the parsed value must be an object (`JC2006`
612
631
  with `path: ""` otherwise) and each of its own members is set unless
@@ -747,7 +766,7 @@ below; `HTTP_ERRORS` (`@jarenjs/contract/http`) is the same table as data,
747
766
  | `JC2009` | 409 | `contract/idempotency-conflict` | see §8 | the ledger says `in-progress` (retryable, `retry-after: 1`) or `mismatch` (not retryable, `details: [{ "kind": "mismatch" }]`) |
748
767
  | `JC2010` | 500 | `contract/invalid-output` | no | the handler value fails the output validator, cannot be serialized, a raw response is malformed, or a declared error's details fail their schema — the server broke the contract |
749
768
  | `JC2011` | 400 | `contract/malformed-path` | no | the path carries a malformed percent-escape |
750
- | `JC2012` | 400 | `contract/malformed-query` | no | the query string is not decodable (a malformed escape, invalid UTF-8) |
769
+ | `JC2012` | 400 | `contract/malformed-query` | no | the query string is not decodable (malformed escape/UTF-8/JSON, or a repeated JSON member) |
751
770
  | `JC2013` | 501 | `contract/not-implemented` | no | a `partial` server has no handler for the operation |
752
771
  | `JC2014` | 412 | `contract/precondition-failed` | no | `If-Match` does not match the armed tag (strong comparison), or `If-None-Match` matches on a non-GET/HEAD |
753
772
  | `JC2015` | 400 | `contract/invalid-header` | no | a declared scalar header member arrived repeated, or a header value is not a string |
@@ -1341,9 +1360,9 @@ is true exactly for a 304.
1341
1360
  by `policy.errors.details`; **nothing was sent**.
1342
1361
  3. **Split by location** (`http.in`): path variables → `encodeURIComponent`
1343
1362
  per segment into the canonical template; query members →
1344
- `URLSearchParams` (an array-typed member repeats the key per element;
1345
- `null`/`undefined` members are omitted; a scalar is its string, a
1346
- non-scalar its JSON); header members → the header named by the
1363
+ `URLSearchParams` (`queryJson` members use one JSON value, including
1364
+ null and empty arrays; undefined is omitted; scalar transport members
1365
+ omit null/undefined and otherwise use their string); header members → the header named by the
1347
1366
  member lowercased (an array as a `, `-joined list); the body: `http.body`
1348
1367
  names a member → `JSON.stringify` of that member's value; otherwise
1349
1368
  the object of the body-located members, stringified. `method` from
@@ -1712,6 +1731,23 @@ The generated actions:
1712
1731
  - `<ns><op>/reset` — as for tasks: `status "idle"`, `kind`/`error`
1713
1732
  cleared, everything else kept.
1714
1733
 
1734
+ Reconnect is an opt-in **binding choice**, not a new stream policy:
1735
+ `contractAppBinding(contract, { subs: { "data.live": { reconnect: { max: 2 } } } })`
1736
+ embeds the per-operation option in its generated `withQuery` props and
1737
+ `createContractSubscription` forwards it to `client.subscribe`. During
1738
+ retry/backoff the slot stays `live`, retaining its id, value and sequence;
1739
+ replay patches continue against the same cached document. Exhaustion
1740
+ surfaces `JC2097` in the slot. Explicit server end and non-network errors
1741
+ are terminal. Stop/reset/destroy cancel the active stream and prevent
1742
+ further retries. Without the option a lost stream surfaces immediately.
1743
+ Unknown/unselected/non-subscribe operations and malformed reconnect
1744
+ options are `JC1007`. Reconnection is supplied by the HTTP client; port
1745
+ and local clients retain their existing terminal channel lifecycle.
1746
+
1747
+ The subscription handler rejects stale sequence numbers **before**
1748
+ applying a patch to its cached document, and ignores callbacks after
1749
+ cleanup; the generated action guards provide the state-side check too.
1750
+
1715
1751
  The binding additionally returns `subs` — one entry per subscribe
1716
1752
  operation — and names its handler in `subscription`:
1717
1753
 
@@ -1807,7 +1843,7 @@ point). The mapping:
1807
1843
  |---|---|
1808
1844
  | — | `openapi: "3.1.0"`, `jsonSchemaDialect: "https://json-schema.org/draft/2020-12/schema"`, `info` (title/version defaulting to the contract id/version), `servers` when given |
1809
1845
  | operation | `paths[<canonical path>][<lowercased method>]`, paths sorted by path then method; `operationId` = the id; `summary` = `doc`'s first line (`description` = the whole `doc` when it has more); `tags` = the id's first dotted segment |
1810
- | `http.in` `path`/`query`/`header` members | `parameters` (name, `in`, `schema`; `required` from the effective input's `required`, a path parameter always required); an idempotent operation gains the `Idempotency-Key` header parameter (required under `"required"`) |
1846
+ | `http.in` `path`/`query`/`header` members | `parameters` (name, `in`, `schema`, or `content.application/json.schema` for JSON query members; `required` from the effective input's `required`, a path parameter always required); an idempotent operation gains the `Idempotency-Key` header parameter (required under `"required"`) |
1811
1847
  | body members | `requestBody`: the object of the body-located members (their `required` intersection, the input's `additionalProperties`); the whole input schema when every member is body-located; the member's own schema under `http.body`; `content[<http.media>]` |
1812
1848
  | `output`, `http.status` | `responses[<status>]` with the output schema; no content on `204`; opaque → `content[<media>]: { type: "string", format: "binary" }` |
1813
1849
  | declared `errors` | one response per status: the D7 wire-error schema (`code` **enum-pinned** to the codes of that status, `details` the declared schema when present) |
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@jarenjs/contract",
3
3
  "private": false,
4
- "version": "0.73.0",
4
+ "version": "0.75.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.73.0",
106
- "@jarenjs/json": "^0.73.0",
107
- "@jarenjs/validate": "^0.73.0",
108
- "@jarenjs/emit": "^0.73.0"
105
+ "@jarenjs/core": "^0.75.0",
106
+ "@jarenjs/json": "^0.75.0",
107
+ "@jarenjs/validate": "^0.75.0",
108
+ "@jarenjs/emit": "^0.75.0"
109
109
  }
110
110
  }
@@ -34,6 +34,9 @@ import { ContractHostError } from '../errors.js';
34
34
  * @property {string} [statePath] - where the slice lives in app state, as a
35
35
  * chain of identifier-safe segments; default `/contract`
36
36
  * @property {readonly string[]} [ops] - the operations the app uses; default every operation
37
+ * @property {Record<string, { reconnect: { max: number } }>} [subs] - per
38
+ * subscribe-operation options. Reconnect is opt-in; the slot stays live
39
+ * while the HTTP client resumes, and reports error after exhaustion.
37
40
  */
38
41
 
39
42
  /**
@@ -145,8 +148,9 @@ function replace(path, value) {
145
148
  * @param {Record<string, any>} actions
146
149
  * @param {any[]} subs
147
150
  * @param {Record<string, any>} properties
151
+ * @param {{ max: number } | undefined} reconnect
148
152
  */
149
- function appendSubscription(contract, op, id, namespace, slot, slotQuery, idQuery, nextId, actions, subs, properties) {
153
+ function appendSubscription(contract, op, id, namespace, slot, slotQuery, idQuery, nextId, actions, subs, properties, reconnect) {
150
154
  const idGuard = { $eq: ['$payload.id', idQuery] };
151
155
 
152
156
  setObjectMember(actions, `${namespace}${id}/start`, {
@@ -224,6 +228,7 @@ function appendSubscription(contract, op, id, namespace, slot, slotQuery, idQuer
224
228
  snapshot: `${namespace}${id}/snapshot`,
225
229
  patch: `${namespace}${id}/patch`,
226
230
  error: `${namespace}${id}/error`,
231
+ ...(reconnect === undefined ? {} : { reconnect: { $const: { max: reconnect.max } } }),
227
232
  },
228
233
  });
229
234
 
@@ -302,6 +307,21 @@ export function contractAppBinding(contract, options = {}) {
302
307
  }
303
308
  const ops = options.ops === undefined ? contract.ids : options.ops;
304
309
  if (!Array.isArray(ops)) throw host('ops must be an array of operation ids');
310
+ const streamOptions = options.subs === undefined ? {} : options.subs;
311
+ if (streamOptions === null || typeof streamOptions !== 'object' || Array.isArray(streamOptions))
312
+ throw host('subs must be an operation-options object');
313
+ for (const id of Object.keys(streamOptions)) {
314
+ if (!ops.includes(id) || !Object.hasOwn(contract.operations, id) || contract.operations[id].kind !== 'subscribe')
315
+ throw host(`subs names '${id}', which is not a selected subscribe operation`);
316
+ const entry = streamOptions[id];
317
+ const reconnect = entry?.reconnect;
318
+ if (entry === null || typeof entry !== 'object' || Array.isArray(entry)
319
+ || Object.keys(entry).some((key) => key !== 'reconnect')
320
+ || reconnect === null || typeof reconnect !== 'object' || Array.isArray(reconnect)
321
+ || Object.keys(reconnect).some((key) => key !== 'max')
322
+ || !Number.isSafeInteger(reconnect.max) || reconnect.max < 0)
323
+ throw host(`subs['${id}'] must be { reconnect: { max } } with a non-negative safe integer max`);
324
+ }
305
325
  const queryRoot = '$' + statePath.replaceAll('/', '.');
306
326
 
307
327
  /** @type {Record<string, TaskSlot | StreamSlot>} */
@@ -328,7 +348,8 @@ export function contractAppBinding(contract, options = {}) {
328
348
  const done = `${namespace}${id}/done`;
329
349
 
330
350
  if (op.kind === 'subscribe') {
331
- appendSubscription(contract, op, id, namespace, slot, slotQuery, idQuery, nextId, actions, subs, properties);
351
+ appendSubscription(contract, op, id, namespace, slot, slotQuery, idQuery, nextId, actions, subs, properties,
352
+ Object.hasOwn(streamOptions, id) ? streamOptions[id].reconnect : undefined);
332
353
  setObjectMember(slice, id, { id: 0, status: 'idle', kind: null, input: null, value: null, error: null, meta: null, seq: 0 });
333
354
  required.push(id);
334
355
  continue;
@@ -48,7 +48,7 @@ import { PORT_LOCAL_ERRORS } from '../pipeline.js';
48
48
  /**
49
49
  * The props of one generated subs entry: the operation, the slot id and
50
50
  * input resolved from state, and the action names to dispatch.
51
- * @typedef {{ op: string, id: number, input: unknown, snapshot: string, patch: string, error: string }} StreamProps
51
+ * @typedef {{ op: string, id: number, input: unknown, snapshot: string, patch: string, error: string, reconnect?: { max: number } }} StreamProps
52
52
  */
53
53
 
54
54
  /**
@@ -86,12 +86,18 @@ export function createContractSubscription(client, options = {}) {
86
86
  const { op, id } = p;
87
87
  /** @type {unknown} */
88
88
  let doc = null;
89
+ let seq = -1;
90
+ let stopped = false;
89
91
  const sub = client.subscribe(op, p.input === undefined ? null : p.input, {
92
+ ...(p.reconnect === undefined ? {} : { reconnect: p.reconnect }),
90
93
  onSnapshot: (/** @type {unknown} */ value, /** @type {{ seq: number }} */ info) => {
94
+ if (stopped) return;
91
95
  doc = value;
96
+ seq = info.seq;
92
97
  dispatch(p.snapshot, { id, value, seq: info.seq });
93
98
  },
94
99
  onPatch: (/** @type {{ patch: any[], seq: number }} */ emission) => {
100
+ if (stopped || emission.seq <= seq) return;
95
101
  try {
96
102
  doc = compileJSONPatch(/** @type {any} */ (emission.patch))(doc);
97
103
  }
@@ -107,10 +113,12 @@ export function createContractSubscription(client, options = {}) {
107
113
  });
108
114
  return;
109
115
  }
116
+ seq = emission.seq;
110
117
  dispatch(p.patch, { id, value: doc, seq: emission.seq });
111
118
  },
112
- onError: (/** @type {Outcome} */ outcome) => dispatch(p.error, { id, outcome }),
119
+ onError: (/** @type {Outcome} */ outcome) => { if (!stopped) dispatch(p.error, { id, outcome }); },
113
120
  onEnd: () => {
121
+ if (stopped) return;
114
122
  // the server ended the stream: the slot must say the channel is
115
123
  // gone, so a view can offer a reconnect (a fresh start)
116
124
  dispatch(p.error, {
@@ -121,7 +129,7 @@ export function createContractSubscription(client, options = {}) {
121
129
  });
122
130
  },
123
131
  });
124
- return () => sub.stop();
132
+ return () => { stopped = true; sub.stop(); };
125
133
  };
126
134
  }
127
135
 
@@ -289,7 +289,7 @@ function transportString(v) {
289
289
  * @property {string} method
290
290
  * @property {readonly import('../path.js').PathSegment[]} segments
291
291
  * @property {readonly string[]} queryMembers
292
- * @property {ReadonlySet<string>} queryRepeated
292
+ * @property {ReadonlySet<string>} queryJson
293
293
  * @property {readonly string[]} headerMembers
294
294
  * @property {readonly string[]} headerNames
295
295
  * @property {readonly string[]} bodyMembers - body-located members when the body is their object
@@ -315,8 +315,6 @@ function prepare(op) {
315
315
  const http = op.http;
316
316
  /** @type {string[]} */
317
317
  const queryMembers = [];
318
- /** @type {Set<string>} */
319
- const queryRepeated = new Set();
320
318
  /** @type {string[]} */
321
319
  const headerMembers = [];
322
320
  /** @type {string[]} */
@@ -324,14 +322,12 @@ function prepare(op) {
324
322
  /** @type {string[]} */
325
323
  const bodyMembers = [];
326
324
  const transport = op.input === null ? null : op.input.transport;
327
- const repeated = new Set(transport === null ? [] : transport.members.repeated);
328
325
  const members = Object.keys(http.in);
329
326
  for (let i = 0; i < members.length; i++) {
330
327
  const m = members[i];
331
328
  const loc = http.in[m];
332
329
  if (loc === 'query') {
333
330
  queryMembers.push(m);
334
- if (repeated.has(m)) queryRepeated.add(m);
335
331
  }
336
332
  else if (loc === 'header') {
337
333
  headerMembers.push(m);
@@ -345,7 +341,7 @@ function prepare(op) {
345
341
  method: http.method,
346
342
  segments: http.template.segments,
347
343
  queryMembers,
348
- queryRepeated,
344
+ queryJson: new Set(transport === null ? [] : transport.queryJson),
349
345
  headerMembers,
350
346
  headerNames,
351
347
  bodyMembers,
@@ -535,13 +531,12 @@ export function openHttpClient(contract, options = {}) {
535
531
  for (let i = 0; i < route.queryMembers.length; i++) {
536
532
  const m = route.queryMembers[i];
537
533
  const v = value[m];
538
- if (v === undefined || v === null) continue;
539
- if (route.queryRepeated.has(m) && Array.isArray(v)) {
540
- for (let j = 0; j < v.length; j++) {
541
- if (v[j] !== undefined && v[j] !== null) params.append(m, transportString(v[j]));
542
- }
534
+ if (route.queryJson.has(m)) {
535
+ if (v !== undefined) params.append(m, JSON.stringify(v));
536
+ continue;
543
537
  }
544
- else params.append(m, transportString(v));
538
+ if (v === undefined || v === null) continue;
539
+ params.append(m, transportString(v));
545
540
  }
546
541
  const query = params.toString();
547
542
  return query.length === 0 ? path : path + '?' + query;
package/src/compile.js CHANGED
@@ -370,20 +370,18 @@ function checkRefs(node, docPath, scope, isRoot) {
370
370
  */
371
371
 
372
372
  /**
373
- * The transport half of an operation's input: the members that travel
374
- * as strings (path, query, header) and the normalizer that decodes them
375
- * with `coerceTypes` scoped to exactly those members. `repeated` lists
376
- * the query and header members whose effective schema type is `array`
377
- * a decoder collects repeats of those into an array (a repeated query
378
- * key; a repeated header line or a comma-separated header list) before
379
- * normalizing; every other query member is last-wins and every other
380
- * header member is a single line. Body members are never here.
381
- * `schemas` holds each transport member's declared schema (what the
382
- * normalizer was compiled over) and `required` the transport members the
383
- * input schema requires — what a URL builder validates without the body.
373
+ * The transport half of an operation's input. Scalar path/query/header
374
+ * members use a compiled coercing normalizer; queryJson members carry
375
+ * one JSON value and bypass coercion. `repeated` marks array types (the
376
+ * header decoder collects their repeated lines or comma-separated list;
377
+ * JSON query encoding takes precedence for query members). Body members
378
+ * are never here. `schemas` and `required` describe every transport
379
+ * member for URL validation, including those excluded from normalization.
384
380
  * @typedef {Object} InputTransport
385
381
  * @property {(value: any) => any} normalize
386
382
  * @property {{ path: readonly string[], query: readonly string[], header: readonly string[], repeated: readonly string[] }} members
383
+ * @property {readonly string[]} queryJson - query members with a declared
384
+ * object/array type, encoded as one JSON value (including nullable unions)
387
385
  * @property {Readonly<Record<string, any>>} schemas
388
386
  * @property {readonly string[]} required
389
387
  */
@@ -1074,6 +1072,7 @@ export function compileContract(doc, options = {}) {
1074
1072
  const queryMembers = [];
1075
1073
  const headerMembers = [];
1076
1074
  const repeated = [];
1075
+ const queryJson = [];
1077
1076
  /** @type {Record<string, any>} */
1078
1077
  const pick = {};
1079
1078
  for (let i = 0; i < p.members.length; i++) {
@@ -1089,18 +1088,24 @@ export function compileContract(doc, options = {}) {
1089
1088
  const eff = effectiveSchema(schema, scope);
1090
1089
  const type = isJsonObject(eff) ? eff.type : undefined;
1091
1090
  if (type === 'array' || (Array.isArray(type) && type.includes('array'))) repeated.push(m);
1091
+ if (loc === 'query' && (type === 'object' || type === 'array'
1092
+ || (Array.isArray(type) && (type.includes('object') || type.includes('array'))))) queryJson.push(m);
1092
1093
  }
1093
1094
  }
1094
1095
  if (pathMembers.length + queryMembers.length + headerMembers.length > 0) {
1095
1096
  // the sub-schema is rooted on the document itself, so every
1096
1097
  // same-document `$ref` a member schema carries resolves exactly as
1097
1098
  // it does for the validator
1098
- const sub = { ...src, type: 'object', properties: pick };
1099
+ // JSON query values have the same typing discipline as a JSON
1100
+ // body: validate them verbatim, never coerce their descendants.
1101
+ const scalarPick = Object.fromEntries(Object.entries(pick).filter(([name]) => !queryJson.includes(name)));
1102
+ const sub = { ...src, type: 'object', properties: scalarPick };
1099
1103
  const normalize = compileNormalizer(sub, { coerceTypes: true });
1100
1104
  const declaredRequired = Array.isArray(p.inputEffective.required) ? p.inputEffective.required : [];
1101
1105
  transport = {
1102
1106
  normalize,
1103
1107
  members: { path: pathMembers, query: queryMembers, header: headerMembers, repeated },
1108
+ queryJson,
1104
1109
  schemas: pick,
1105
1110
  required: declaredRequired.filter((/** @type {unknown} */ r) => typeof r === 'string' && Object.hasOwn(pick, r)),
1106
1111
  };
@@ -133,7 +133,7 @@ import {
133
133
  * @property {ReadonlySet<string>} nonBody - path/query/header member names, never taken from the body
134
134
  * @property {readonly string[]} pathMembers
135
135
  * @property {ReadonlySet<string>} queryMembers
136
- * @property {ReadonlySet<string>} repeated - array-typed query members
136
+ * @property {ReadonlySet<string>} queryJson - JSON-encoded query members
137
137
  * @property {readonly string[]} headerMembers - member names
138
138
  * @property {readonly string[]} headerNames - the lowercase header of each
139
139
  * @property {readonly boolean[]} headerArray - array-typed, per header member
@@ -597,7 +597,7 @@ function afterIdentity(server, request, route, trace, hit, isHead, method, path,
597
597
  const m = route.pathMembers[i];
598
598
  setObjectMember(input, m, params[m]);
599
599
  }
600
- if (!decodeQuery(query, route.queryMembers, route.repeated, input)) {
600
+ if (!decodeQuery(query, route.queryMembers, input, route.queryJson)) {
601
601
  return refuse(server, 'JC2012', trace, {}, undefined, null, null);
602
602
  }
603
603
  /** @type {Record<string, string>} */
package/src/http/serve.js CHANGED
@@ -158,8 +158,6 @@ function prepare(op, handler, tag) {
158
158
  const pathMembers = [];
159
159
  /** @type {Set<string>} */
160
160
  const queryMembers = new Set();
161
- /** @type {Set<string>} */
162
- const repeated = new Set();
163
161
  /** @type {string[]} */
164
162
  const headerMembers = [];
165
163
  /** @type {string[]} */
@@ -178,10 +176,6 @@ function prepare(op, handler, tag) {
178
176
  headerNames.push(headerNameOf(m));
179
177
  headerArray.push(transport.members.repeated.includes(m));
180
178
  }
181
- for (let i = 0; i < transport.members.repeated.length; i++) {
182
- const m = transport.members.repeated[i];
183
- if (queryMembers.has(m)) repeated.add(m);
184
- }
185
179
  }
186
180
  const members = Object.keys(http.in);
187
181
  for (let i = 0; i < members.length; i++) {
@@ -203,7 +197,7 @@ function prepare(op, handler, tag) {
203
197
  nonBody,
204
198
  pathMembers,
205
199
  queryMembers,
206
- repeated,
200
+ queryJson: new Set(transport === null ? [] : transport.queryJson),
207
201
  headerMembers,
208
202
  headerNames,
209
203
  headerArray,
package/src/http/wire.js CHANGED
@@ -340,18 +340,19 @@ export function formatEntityTag(tag, strong) {
340
340
  /**
341
341
  * Decode a query string into the declared members of an input object:
342
342
  * only declared names are set (an undeclared key is never merged, so no
343
- * request can smuggle a member); a `repeated` member collects every
344
- * occurrence into an array, every other member is last-wins; a `+` is a
343
+ * request can smuggle a member); JSON members decode exactly one value,
344
+ * scalar members are last-wins; a `+` is a
345
345
  * space and escapes decode as `application/x-www-form-urlencoded`
346
346
  * (`URLSearchParams`). Returns `false` when the query is not decodable
347
- * (a malformed percent-escape or invalid UTF-8) the `JC2012` case.
347
+ * (malformed percent-escape, UTF-8 or JSON, or a repeated JSON member)
348
+ * — the `JC2012` case.
348
349
  * @param {string} query - the part after `?`, possibly empty
349
350
  * @param {ReadonlySet<string>} declared - the query member names
350
- * @param {ReadonlySet<string>} repeated - the array-typed ones
351
351
  * @param {Record<string, unknown>} out - the input object under assembly
352
+ * @param {ReadonlySet<string>} json - schema-directed JSON members
352
353
  * @returns {boolean} false when not decodable
353
354
  */
354
- export function decodeQuery(query, declared, repeated, out) {
355
+ export function decodeQuery(query, declared, out, json) {
355
356
  if (query.length === 0) return true;
356
357
  // URLSearchParams never throws: it keeps a malformed escape as its
357
358
  // literal text and replaces invalid UTF-8; both are "not decodable"
@@ -368,12 +369,16 @@ export function decodeQuery(query, declared, repeated, out) {
368
369
  const params = new URLSearchParams(query);
369
370
  for (const [name, value] of params) {
370
371
  if (!declared.has(name)) continue;
371
- if (repeated.has(name)) {
372
- const list = out[name];
373
- if (Array.isArray(list)) list.push(value);
374
- else setObjectMember(out, name, [value]);
372
+ if (json.has(name)) {
373
+ // One JSON value per declared structured member. Scalar strings
374
+ // are untouched; repeated JSON members are ambiguous and refused.
375
+ const values = params.getAll(name);
376
+ if (values.length !== 1) return false;
377
+ try { setObjectMember(out, name, JSON.parse(value)); }
378
+ catch { return false; }
379
+ continue;
375
380
  }
376
- else setObjectMember(out, name, value);
381
+ setObjectMember(out, name, value);
377
382
  }
378
383
  return true;
379
384
  }
@@ -324,7 +324,7 @@ function docLines(doc) {
324
324
  function operationView(op, projected, ctx) {
325
325
  const base = at('/operations', op.id);
326
326
  const http = op.http;
327
- /** @type {{ name: string, in: string, required: boolean, schema: any }[]} */
327
+ /** @type {any[]} */
328
328
  const parameters = [];
329
329
  /** @type {Record<string, any>} */
330
330
  const bodyMembers = {};
@@ -351,6 +351,8 @@ function operationView(op, projected, ctx) {
351
351
  bodyCount++;
352
352
  if (name === http.body) bodyMemberRequired = required;
353
353
  }
354
+ else if (loc === 'query' && op.input?.transport?.queryJson.includes(name))
355
+ parameters.push({ name, in: loc, required, content: { 'application/json': { schema: mapped } } });
354
356
  else parameters.push({ name, in: loc, required, schema: mapped });
355
357
  }
356
358
  if (op.policy.idempotency !== 'none') {