@jarenjs/linq 0.72.3 → 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/ARCHITECTURE.md CHANGED
@@ -202,16 +202,14 @@ residual runs locally, and `explain()` reports the split.
202
202
  (`JL0005`) rather than silently materialised — except two providers
203
203
  sharing a `scope` (one store's entity sets), whose roots are two
204
204
  bindings of one multi-entity input.
205
- - **Federation is a door, not a default.** `federate({ sources,
206
- maxRows, maxBytes })` (`src/federate.js`, QUERY-PEN §12.1) hands back
207
- one provider source per name, sharing one scope, so the ordinary join
208
- is admitted and the federation executes it: each side's own packed
209
- document at its own source, the smaller side into a bounded hash
210
- table, the other probed against it, and the caller's document decided
211
- in the engine over the two reduced sets. It spells no join semantics
212
- of its own — the reduction bounds the FETCH, and a value it cannot
213
- key is kept rather than guessed at — and a budget is a refusal
214
- (`JL2008`), never a spill.
205
+ - **Federation is explicit.** `federate()` (QUERY-PEN §12.1) accepts a
206
+ connected equality graph across named providers. Source-local work runs at
207
+ its provider; estimates choose a connected fetch order, hash sets reduce
208
+ candidates, and the engine retains the original tuple semantics. Packed
209
+ joins run inside out under the same cumulative row/byte admission credits.
210
+ Per-side budgets also cap intermediates; buffered production and final
211
+ results have the explicit limits documented in §12.1. Aliases receive
212
+ independent input members, and array framing preserves array-valued rows.
215
213
  - **The engine result shape leaks nowhere.** Every surface — sync,
216
214
  async, provider — reproduces `undefined | item | items` exactly,
217
215
  which is why the window-wrapper trick exists at all.
package/README.md CHANGED
@@ -730,3 +730,10 @@ Every subpath a consumer can import, derived from the manifest by
730
730
  | `@jarenjs/linq/messages` | JavaScript | declared |
731
731
  | `@jarenjs/linq/ai` | JavaScript | declared |
732
732
  <!--/fact-->
733
+
734
+ Explicit cross-provider joins use `federate({ sources, maxRows, maxBytes,
735
+ maxTotalRows, maxTotalBytes })`. Connected joins across three or more sources,
736
+ including successive fluent `.join()` calls, preserve the engine's result
737
+ order while sharing cumulative admission credits. See
738
+ [QUERY-PEN §12.1](docs/QUERY-PEN.md#121-the-federation-boundary-federate) for
739
+ planning, buffering limits and cleanup guarantees.
package/docs/DB-CLIENT.md CHANGED
@@ -826,10 +826,10 @@ never builds one; the migration between two of them is
826
826
 
827
827
  ## 7. Cost
828
828
 
829
- `@jarenjs/linq/db` builds to **<!--fact:bundle.db-->627,769<!--/fact--> bytes** as a minified,
829
+ `@jarenjs/linq/db` builds to **<!--fact:bundle.db-->633,786<!--/fact--> bytes** as a minified,
830
830
  tree-shaken ESM bundle — the figure `scripts/check-tree-shaking.js`
831
831
  measures and `npm run test:tree-shaking` reports, published rounded
832
- (<!--fact:bundle.db.kb-->628<!--/fact--> kB) beside the other nine subpath prices in
832
+ (<!--fact:bundle.db.kb-->634<!--/fact--> kB) beside the other nine subpath prices in
833
833
  [docs/CONSUMING.md](../../../docs/CONSUMING.md).
834
834
 
835
835
  It is by far the largest of the ten, and the reason is §1.1's edge rather
@@ -37,7 +37,7 @@ is the index of those guides, and it is how a reader reaches any of them.
37
37
  | Document | Lines | What it writes, and when to open it |
38
38
  |---|---:|---|
39
39
  | [LINQ-FORMAT.md](LINQ-FORMAT.md) | 948 | this file, the binder and the family's **normative reference**: what a pen is, the rules all of them keep, the shared `JL01xx` table, and the cross-pen views derived from the guides it indexes. **Read it when** you want a rule that is true of every pen, an index of the documents, or one place to look up a method without knowing which pen owns it |
40
- | [QUERY-PEN.md](QUERY-PEN.md) | 1,725 | the chain, `.` — query documents (`jaren-query`) and the provider seam. **Read it when** you are querying data, or implementing a provider that answers a query document |
40
+ | [QUERY-PEN.md](QUERY-PEN.md) | 1,735 | the chain, `.` — query documents (`jaren-query`) and the provider seam. **Read it when** you are querying data, or implementing a provider that answers a query document |
41
41
  | [SCHEMA-PEN.md](SCHEMA-PEN.md) | 1,205 | `./schema` — JSON Schema 2020-12: the structural keywords, the constraints and the annotations, each with a method of its own, plus `$query`, `$defs`/`$ref` recursion and the normalizer's per-field predicates. **Read it when** you are describing the shape of data — for validation, for a form, or as the base of an entity |
42
42
  | [MODEL-PEN.md](MODEL-PEN.md) | 1,092 | `./model` — the `x-entity` vocabulary on JSON Schema, and the `$model` 0.1 document `openStore` accepts unchanged. **Read it when** you are declaring a store's entities, their keys and their relations |
43
43
  | [JSLT-PEN.md](JSLT-PEN.md) | 955 | `./jslt` — `$jslt` 0.1 stylesheets: the envelope and its rules, whose bodies are captured over the matched value. **Read it when** you are transforming one document into another |
@@ -205,7 +205,7 @@ and the bundle is the byte count the tree-shaking probe builds.
205
205
  | Document | Subpath | Lines | Mapping rows | Worked examples | Refusals | Bundle |
206
206
  |---|---|---:|---:|---:|---:|---:|
207
207
  | [LINQ-FORMAT.md](LINQ-FORMAT.md) | — | 948 | — | — | — | — |
208
- | [QUERY-PEN.md](QUERY-PEN.md) | `.` | 1,725 | 34 | 8 | 15 | 174,131 B |
208
+ | [QUERY-PEN.md](QUERY-PEN.md) | `.` | 1,735 | 34 | 8 | 15 | 174,241 B |
209
209
  | [SCHEMA-PEN.md](SCHEMA-PEN.md) | `./schema` | 1,205 | 82 | 10 | 4 | 36,717 B |
210
210
  | [MODEL-PEN.md](MODEL-PEN.md) | `./model` | 1,092 | 28 | 6 | 3 | 45,143 B |
211
211
  | [JSLT-PEN.md](JSLT-PEN.md) | `./jslt` | 955 | 17 | 8 | 3 | 19,856 B |
@@ -215,12 +215,12 @@ and the bundle is the byte count the tree-shaking probe builds.
215
215
  | [APP-PEN.md](APP-PEN.md) | `./app` | 1,143 | 22 | 7 | 3 | 51,006 B |
216
216
  | [FORMS-PEN.md](FORMS-PEN.md) | `./forms` | 940 | 18 | 6 | 3 | 40,873 B |
217
217
  | [AI-PEN.md](AI-PEN.md) | `./ai` | 98 | 12 | 1 | 3 | 16,904 B |
218
- | [MESSAGES-PEN.md](MESSAGES-PEN.md) | `./messages` | 105 | 9 | 2 | 1 | 18,337 B |
218
+ | [MESSAGES-PEN.md](MESSAGES-PEN.md) | `./messages` | 105 | 9 | 2 | 1 | 18,363 B |
219
219
  | [JTLT-PEN.md](JTLT-PEN.md) | `./jtlt` | 83 | 13 | 1 | 2 | 16,725 B |
220
220
  | [PROJECT-PEN.md](PROJECT-PEN.md) | `./project` | 75 | 9 | 1 | 1 | 15,132 B |
221
221
  | [CHARTS-PEN.md](CHARTS-PEN.md) | `./charts` | 94 | 21 | 1 | 1 | 17,014 B |
222
- | [DB-CLIENT.md](DB-CLIENT.md) | `./db` | 893 | 41 | 4 | 2 | 627,769 B |
223
- | **16 documents** | | **12,391** | **371** | **73** | | |
222
+ | [DB-CLIENT.md](DB-CLIENT.md) | `./db` | 893 | 41 | 4 | 2 | 633,786 B |
223
+ | **16 documents** | | **12,401** | **371** | **73** | | |
224
224
  <!--/fact-->
225
225
 
226
226
  A pen whose mapping rows are far below its worked examples is a pen
@@ -262,7 +262,7 @@ it and each document publishes it. The rounded column is what
262
262
  <!--fact:pens.cost-->
263
263
  | Subpath | Document | Bundle | Rounded |
264
264
  |---|---|---:|---:|
265
- | `@jarenjs/linq` | [QUERY-PEN.md](QUERY-PEN.md) | 174,131 B | 174 kB |
265
+ | `@jarenjs/linq` | [QUERY-PEN.md](QUERY-PEN.md) | 174,241 B | 174 kB |
266
266
  | `@jarenjs/linq/schema` | [SCHEMA-PEN.md](SCHEMA-PEN.md) | 36,717 B | 37 kB |
267
267
  | `@jarenjs/linq/model` | [MODEL-PEN.md](MODEL-PEN.md) | 45,143 B | 45 kB |
268
268
  | `@jarenjs/linq/jslt` | [JSLT-PEN.md](JSLT-PEN.md) | 19,856 B | 20 kB |
@@ -272,11 +272,11 @@ it and each document publishes it. The rounded column is what
272
272
  | `@jarenjs/linq/app` | [APP-PEN.md](APP-PEN.md) | 51,006 B | 51 kB |
273
273
  | `@jarenjs/linq/forms` | [FORMS-PEN.md](FORMS-PEN.md) | 40,873 B | 41 kB |
274
274
  | `@jarenjs/linq/ai` | [AI-PEN.md](AI-PEN.md) | 16,904 B | 17 kB |
275
- | `@jarenjs/linq/messages` | [MESSAGES-PEN.md](MESSAGES-PEN.md) | 18,337 B | 18 kB |
275
+ | `@jarenjs/linq/messages` | [MESSAGES-PEN.md](MESSAGES-PEN.md) | 18,363 B | 18 kB |
276
276
  | `@jarenjs/linq/jtlt` | [JTLT-PEN.md](JTLT-PEN.md) | 16,725 B | 17 kB |
277
277
  | `@jarenjs/linq/project` | [PROJECT-PEN.md](PROJECT-PEN.md) | 15,132 B | 15 kB |
278
278
  | `@jarenjs/linq/charts` | [CHARTS-PEN.md](CHARTS-PEN.md) | 17,014 B | 17 kB |
279
- | `@jarenjs/linq/db` | [DB-CLIENT.md](DB-CLIENT.md) | 627,769 B | 628 kB |
279
+ | `@jarenjs/linq/db` | [DB-CLIENT.md](DB-CLIENT.md) | 633,786 B | 634 kB |
280
280
  <!--/fact-->
281
281
 
282
282
  Read these as prices, not as scores. `./db` is the largest by an order of
@@ -100,6 +100,6 @@ gate compares generated files and all locale parameter sets with their sources.
100
100
 
101
101
  ## 7. Cost
102
102
 
103
- The isolated messages pen costs **<!--fact:bundle.messages-->18,337<!--/fact--> bytes**.
103
+ The isolated messages pen costs **<!--fact:bundle.messages-->18,363<!--/fact--> bytes**.
104
104
  It carries the shared template compiler and generated vocabulary, with no locale,
105
105
  validator, forms or contract engine and no query chain.
package/docs/QUERY-PEN.md CHANGED
@@ -472,8 +472,9 @@ time:
472
472
  emits `{ "$for": { "it": "$.Post[*]", "it2": "$.User[*]" }, "$where":
473
473
  { "$eq": ["$it.authorId", "$it2.id"] }, "$return": "$it" }` — the shape
474
474
  the store's translator answers in ONE statement when the result is a
475
- bare binding (MODEL-FORMAT §10.2), and the declared residual over both
476
- fetched roots when it is a projection (§10.6). `concat` stays
475
+ bare binding or a projection tree of member paths and constants
476
+ (MODEL-FORMAT §10.2). An opaque projection remains a declared residual
477
+ over the fetched roots (§10.6). `concat` stays
477
478
  same-source even within a scope: one input per document. A scope MAY
478
479
  carry `relations` — the relation tables of every root of the scope,
479
480
  keyed by root name (a store's does) — which is where a chained hop
@@ -732,46 +733,55 @@ const rows = await fromAsync(fed.source('orders'))
732
733
  spaces or other characters), and all of a federation's sources share one scope — which
733
734
  is exactly what admits the join. The federation is what executes it:
734
735
 
735
- 1. each side's own document the filters and the projection the chain
736
- already packed per side runs against ITS source, over that
737
- source's own root;
738
- 2. the smaller side (by declared `estimatedRows`, else the first named)
739
- is streamed into a hash table keyed by the join key, counting rows
740
- and serialized bytes against the budget as it fills;
741
- 3. the other side is streamed and PROBED: a row whose key no build row
742
- carries cannot pair, so it is dropped before it costs anything;
743
- 4. the caller's own document runs in the engine over the two reduced
744
- sets the **resident join**, which is what decides.
745
-
746
- Step 4 is the contract. This boundary spells no join semantics of its
747
- own: the engine's `$eq` decides which rows pair, its ordering orders
748
- them, its projection shapes them. The hash table bounds the FETCH and
749
- nothing else, so a reduction that cannot key a value (a compound join
750
- key) keeps the row rather than guessing at it.
751
-
752
- `fed.source(name).explain(document)` answers the plan without running
753
- any of it: the `strategy`, the `budget`, the `build` and `probe` sides
754
- with their estimates, their own documents and whether each streams, and
755
- the `resident` document the engine answers.
756
-
757
- **A budget is a refusal, not a spill.** A side that reaches `maxRows` or
758
- `maxBytes` stops at the row that WOULD have broken it and raises
759
- `JL2008`; every cursor the call opened is closed exactly once, whether
760
- it answered, refused, failed or was aborted. A `signal` on the call is
761
- read at the ROW boundary where a cursor can be let go without
762
- abandoning a pull the source is still inside and a declared
763
- `estimatedRows` decides only which side BUILDS, never how much is held:
764
- a source that under-reports is refused by the budget all the same. A source offering a
765
- cursor (§12) is pulled row by row, so the bound is enforced before the
766
- memory is spent; one offering only `execute` answers whole, and
767
- `explain()` says `buffered` rather than pretending otherwise.
768
-
769
- Refused, by name: more than two sides in one federated document, a
770
- binding over a root the federation does not carry, and a join with no
771
- equality between one member of each side without one the fetch is the
772
- cross product of two sources, which is what the budget exists to
773
- refuse. Non-goals: no spill, no distributed transaction, no
774
- cross-source write.
736
+ 1. Each side's own filters and projection run at its source. Cursor sources
737
+ receive that document directly; buffered sources receive an array wrapper
738
+ so array-valued projected rows retain their item boundaries.
739
+ 2. Mandatory equality predicates form a connected binding graph. The smallest
740
+ declared `estimatedRows` starts the fetch. Each subsequent side is the
741
+ smallest estimated side connected to an already fetched side. Missing or
742
+ equal estimates preserve binding declaration order. Equality inside an OR
743
+ does not establish an edge; disconnected bindings refuse `JL0005` before
744
+ any source opens.
745
+ 3. Each next side is reduced against the retained keys of its already fetched
746
+ neighbours. Singular member/index paths are supported, including quoted
747
+ names and negative array indexes. Compound key values are conservatively
748
+ retained for the engine to compare.
749
+ 4. The original query engine decides the result over those sets, in the
750
+ original binding order. Fetch order never changes tuple or result order.
751
+ Packed joins emitted by successive `.join()` calls execute inside out;
752
+ their projected results become bounded intermediate sides. Aliases of one
753
+ source retain independent fetched sets.
754
+
755
+ `explain(document)` reports `strategy`, per-side `budget`, `combinedBudget`,
756
+ `order` (the ordered side descriptions), and `resident.document`. Each side
757
+ names its source, estimate, join key, child document and `row`/`buffered`
758
+ streaming mode. Nested sides additionally expose `children`. `build` and
759
+ `probe` remain the first two sides for compatibility.
760
+
761
+ **Budgets are admission credits.** `maxRows` and `maxBytes` apply separately
762
+ at each source or intermediate. `maxTotalRows` and `maxTotalBytes` cover all
763
+ retained source and intermediate admissions in the entire call, and default
764
+ to twice their corresponding per-side budget. All four are positive safe
765
+ integers. Credits are cumulative: freeing an intermediate's inputs does not
766
+ refund them. This conservative rule makes a nested plan's total explicit.
767
+
768
+ A cursor refuses with `JL2008` before retaining the row that would exceed a
769
+ credit. Buffered sources are checked after they produce their array. Nested
770
+ resident phrases additionally cap `sequenceItems` and `resultItems` at
771
+ `maxRows` (or a stricter caller limit); exceeding those intermediate limits
772
+ raises `JL2008`. Intermediate bytes are checked after projection. These are
773
+ bounds on admitted data and intermediate item counts, **not** a promise to
774
+ bound a buffered provider's heap, the size of one constructed value, or the
775
+ final resident result. A caller can also supply query-engine limits for that
776
+ final result. Estimates affect planning only, never admission.
777
+
778
+ Every opened cursor closes exactly once on success, budget refusal, child
779
+ failure or cancellation. `signal` is checked at row boundaries and passed to
780
+ providers. Cleanup failures preserve a primary execution failure and never
781
+ strand another opened cursor.
782
+
783
+ There is no spill, distributed transaction or cross-source write. A merge
784
+ strategy remains unavailable because providers declare no ordering guarantee.
775
785
 
776
786
  ## 13. Worked examples
777
787
 
@@ -1195,7 +1205,7 @@ proves that too, so the exclusion cannot hide a chain refusal).
1195
1205
  | `JL2004` | a provider's `execute()` answered a promise on the synchronous surface |
1196
1206
  | `JL2005` | a push queue was fed after `end()` |
1197
1207
  | `JL2006` | a provider answered an element terminal with something other than one array |
1198
- | `JL2008` | a federated fetch reached one side's row or byte budget (§12.1) |
1208
+ | `JL2008` | a federated fetch or intermediate reached a per-side or combined row/byte budget (§12.1) |
1199
1209
 
1200
1210
  `JL2007` is the client door's, not the chain's: `createDbLedger`'s stale
1201
1211
  settlement ([DB-CLIENT.md §2.6](DB-CLIENT.md#26-the-ledger)); it is
@@ -1672,7 +1682,7 @@ are shorter:
1672
1682
  ## 17. Cost
1673
1683
 
1674
1684
  A consumer importing `from` from `@jarenjs/linq` and calling one
1675
- terminal bundles **<!--fact:bundle.chain-->174,131<!--/fact--> bytes** (esbuild, ESM, minified, tree-shaken,
1685
+ terminal bundles **<!--fact:bundle.chain-->174,241<!--/fact--> bytes** (esbuild, ESM, minified, tree-shaken,
1676
1686
  `platform: 'neutral'`). The figure is measured by
1677
1687
  `scripts/check-tree-shaking.js`'s chain probe and compared with this
1678
1688
  section on every `npm run test:tree-shaking`: it is derived, never typed,
@@ -1706,7 +1716,7 @@ making:
1706
1716
  `docs/CONSUMING.md` states the rounded price of all ten subpaths in one
1707
1717
  table, each figure held equal to the same measurements. Two of its rows
1708
1718
  are the ones to read together: the chain at <!--fact:bundle.chain.kb-->174<!--/fact--> kB and
1709
- `./db` at <!--fact:bundle.db.kb-->628<!--/fact--> kB.
1719
+ `./db` at <!--fact:bundle.db.kb-->634<!--/fact--> kB.
1710
1720
  The client costs what the store costs, by construction, and the chain
1711
1721
  costs what running a query costs.
1712
1722
 
@@ -1715,7 +1725,7 @@ the reason is worth knowing: a bundler counts a shared module once, and
1715
1725
  the chain and every pen share the expression capture (`expression.js`)
1716
1726
  and the coded errors under it (`errors.js`, and `@jarenjs/core`'s error
1717
1727
  and object helpers). A consumer importing the chain AND the schema pen
1718
- bundles **<!--fact:bundle.chain.withSchemaPen-->198,801<!--/fact--> bytes** — **<!--fact:bundle.chain.shared-->12,047<!--/fact--> bytes** less than the sum of the
1728
+ bundles **<!--fact:bundle.chain.withSchemaPen-->198,911<!--/fact--> bytes** — **<!--fact:bundle.chain.shared-->12,047<!--/fact--> bytes** less than the sum of the
1719
1729
  figure above and [SCHEMA-PEN.md](SCHEMA-PEN.md#7-cost) §7's, which is
1720
1730
  what those shared modules weigh. The probe measures that pair too, so
1721
1731
  the saving is derived like everything else here. What the chain does NOT
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@jarenjs/linq",
3
3
  "private": false,
4
- "version": "0.72.3",
4
+ "version": "0.75.0",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
7
7
  "types": "./types/index.d.ts",
@@ -104,13 +104,13 @@
104
104
  "prepack": "npm run build:types"
105
105
  },
106
106
  "dependencies": {
107
- "@jarenjs/core": "^0.72.3",
108
- "@jarenjs/json": "^0.72.3"
107
+ "@jarenjs/core": "^0.75.0",
108
+ "@jarenjs/json": "^0.75.0"
109
109
  },
110
110
  "peerDependencies": {
111
- "@jarenjs/db": "^0.72.3",
112
- "@jarenjs/formats": "^0.72.3",
113
- "@jarenjs/validate": "^0.72.3"
111
+ "@jarenjs/db": "^0.75.0",
112
+ "@jarenjs/formats": "^0.75.0",
113
+ "@jarenjs/validate": "^0.75.0"
114
114
  },
115
115
  "peerDependenciesMeta": {
116
116
  "@jarenjs/db": {
package/src/federate.js CHANGED
@@ -1,51 +1,26 @@
1
1
  //@ts-check
2
2
  /**
3
- * @file The federation boundary (QUERY-PEN.md §13): an EXPLICIT opt-in
4
- * to reading two different provider sources under one query document.
3
+ * @file Explicit bounded federation (QUERY-PEN §12.1). Source-local
4
+ * documents execute at their providers; a connected mandatory equality
5
+ * graph chooses the fetch order by declared estimates, with binding order
6
+ * breaking ties. Hash membership only removes impossible candidates: the
7
+ * query engine decides the resident result and its original tuple order.
5
8
  *
6
- * The rule everywhere else in this package is that a query document
7
- * reads ONE input, and a join whose sides come from two unrelated
8
- * sources is `JL0005` at build time. That refusal is not a limitation
9
- * to route around it is what keeps a chain honest about where the
10
- * work happens. A cross-source join cannot be pushed anywhere: someone
11
- * has to hold rows in memory, and a surface that did it implicitly
12
- * would turn a one-line chain into an unbounded fetch of two
13
- * databases.
9
+ * Packed joins emitted by successive LINQ joins execute inside out. Every
10
+ * source and intermediate has a per-side budget, and one shared admission
11
+ * counter covers the entire call. Intermediate phrase materialization is
12
+ * capped through the query engine's own limits. Buffered providers and
13
+ * intermediate byte sizes can only be checked after they produce an array;
14
+ * cursor providers admit each retained row before holding it.
14
15
  *
15
- * So it is spelled out instead. `federate()` takes the sources by
16
- * name, takes the budgets that make the fetch finite, and hands back
17
- * one provider-compatible source per name, all sharing one scope — so
18
- * the join the chain already knows how to build is admitted, and the
19
- * federation is what executes it:
20
- *
21
- * 1. each binding's own document — the filters and the projection the
22
- * chain already packed per side — runs against ITS source;
23
- * 2. the smaller side (declared estimate, else the first named) is
24
- * streamed into a hash table keyed by the join key, counting rows
25
- * and serialized bytes against the budget as it fills;
26
- * 3. the other side is streamed and PROBED: a row whose key no build
27
- * row carries cannot join, so it is dropped before it costs
28
- * anything;
29
- * 4. the caller's own document runs in the engine over the two
30
- * reduced sets — the resident join, which is what decides.
31
- *
32
- * Step 4 is why this file spells no join semantics of its own. The
33
- * engine's `$eq` decides which rows pair, its ordering orders them and
34
- * its projection shapes them; the hash table exists to bound the FETCH,
35
- * never to answer the query. A reduction that dropped a row the engine
36
- * would have joined would be a wrong answer, so the probe keeps
37
- * anything it cannot key (a compound key value) rather than guessing.
38
- *
39
- * Non-goals, named rather than discovered: no spill (a budget is a
40
- * refusal, not a disk), no distributed transaction, no cross-source
41
- * write, and no non-equality join — without an equality key the fetch
42
- * is the cross product, which is exactly what the budget exists to
43
- * refuse.
16
+ * No spill, distributed transaction, cross-source writes, or disconnected
17
+ * cartesian products. Ordinary unrelated-source join() still refuses JL0005.
44
18
  */
45
19
 
46
20
  import { LinqBuildError, LinqRuntimeError } from './errors.js';
47
21
  import { compileDocument, isProviderSource, providerRoot } from './provider.js';
48
22
  import { memberSegment } from './expression.js';
23
+ import { parseJSONPath } from '@jarenjs/json/path';
49
24
 
50
25
  /** The strategies this boundary knows. One, for now, and it says so. */
51
26
  const STRATEGIES = new Set(['hash']);
@@ -57,7 +32,7 @@ const STRATEGIES = new Set(['hash']);
57
32
  * @returns {number}
58
33
  */
59
34
  function budgetOf(value, what) {
60
- if (!Number.isInteger(value) || value < 1) {
35
+ if (!Number.isSafeInteger(value) || value < 1) {
61
36
  throw new LinqBuildError('JL0005',
62
37
  `federate() needs a positive integer ${what} — a federated fetch holds rows in memory, `
63
38
  + 'and a bound is what makes that finite');
@@ -69,15 +44,22 @@ function budgetOf(value, what) {
69
44
  * The one member path a `'$it.a.b'` operand names, or `null` for
70
45
  * anything else (an operator call, a literal, the binding itself).
71
46
  * @param {any} node
72
- * @returns {{ binding: string, path: string[] } | null}
47
+ * @returns {{ binding: string, path: (string | number)[] } | null}
73
48
  */
74
49
  function memberPathOf(node) {
75
50
  if (typeof node !== 'string' || !node.startsWith('$')) return null;
76
- const parts = node.slice(1).split('.');
77
- if (parts.length < 2) return null;
78
- const [binding, ...path] = parts;
79
- if (binding === '' || path.some((name) => name === '')) return null;
80
- return { binding, path };
51
+ const match = /^\$([^.[\]]+)([.[])/.exec(node);
52
+ if (match === null) return null;
53
+ try {
54
+ const { segments } = parseJSONPath(`$${node.slice(match[1].length + 1)}`);
55
+ if (!segments.every((segment) => !segment.descendant && segment.selectors.length === 1
56
+ && ['name', 'index'].includes(segment.selectors[0].kind))) return null;
57
+ return { binding: match[1], path: segments.map((segment) => {
58
+ const selector = segment.selectors[0];
59
+ return selector.kind === 'name' ? selector.name : selector.index;
60
+ }) };
61
+ }
62
+ catch { return null; }
81
63
  }
82
64
 
83
65
  /** Read one member path out of a row; `undefined` where it is absent. */
@@ -85,7 +67,14 @@ function valueAt(row, path) {
85
67
  let value = row;
86
68
  for (const name of path) {
87
69
  if (value === null || typeof value !== 'object') return undefined;
88
- value = value[name];
70
+ if (typeof name === 'number') {
71
+ if (!Array.isArray(value)) return undefined;
72
+ value = value[name < 0 ? value.length + name : name];
73
+ }
74
+ else {
75
+ if (Array.isArray(value) || !Object.hasOwn(value, name)) return undefined;
76
+ value = value[name];
77
+ }
89
78
  }
90
79
  return value;
91
80
  }
@@ -169,7 +158,7 @@ function locateFlwor(document) {
169
158
  * @param {Map<string, any>} members - federated name → member record
170
159
  * @returns {any}
171
160
  */
172
- function planFederation(document, members) {
161
+ function planFederation(document, members, nested = false) {
173
162
  const located = locateFlwor(document);
174
163
  const query = located === null ? document : located.flwor;
175
164
  const bindings = query?.$for;
@@ -178,10 +167,8 @@ function planFederation(document, members) {
178
167
  'a federated document ranges over its sources with $for — this one has no bindings');
179
168
  }
180
169
  const names = Object.keys(bindings);
181
- if (names.length !== 2) {
182
- throw new LinqBuildError('JL0005',
183
- `a federated fetch joins exactly two sources, not ${names.length} — `
184
- + 'federate one pair at a time, or load the third side yourself');
170
+ if (names.length < (nested ? 1 : 2)) {
171
+ throw new LinqBuildError('JL0005', 'a federated fetch needs at least two source bindings');
185
172
  }
186
173
 
187
174
  /** @type {any[]} */
@@ -191,15 +178,20 @@ function planFederation(document, members) {
191
178
  // the two spellings the chain builds: a bare root, or the side's
192
179
  // own packed document (its `$where`, its `$orderby`, its `$return`)
193
180
  const packed = Array.isArray(value) && value.length === 1 ? value[0] : null;
194
- // a packed side ranges over ONE root: a side that is itself a join
195
- // is a federation of a federation, which this boundary does not
196
- // plan and will not guess at
181
+ // A source-local phrase has one bare root; a packed join is planned
182
+ // recursively and becomes a bounded intermediate side.
197
183
  const inner = packed === null ? [] : Object.keys(packed.$for ?? {});
198
184
  const root = packed === null ? value
199
185
  : (inner.length === 1 ? packed.$for[inner[0]] : null);
200
186
  if (typeof root !== 'string') {
201
- throw new LinqBuildError('JL0005',
202
- `the binding '${binding}' does not range over a federated source`);
187
+ if (packed === null || inner.length === 0) {
188
+ throw new LinqBuildError('JL0005',
189
+ `the binding '${binding}' does not range over a federated source`);
190
+ }
191
+ const child = planFederation(packed, members, true);
192
+ sides.push({ binding, nested: child, packed, root: null,
193
+ member: { name: binding, estimatedRows: undefined } });
194
+ continue;
203
195
  }
204
196
  const member = members.get(root);
205
197
  if (member === undefined) {
@@ -210,52 +202,57 @@ function planFederation(document, members) {
210
202
  sides.push({ binding, member, packed, root });
211
203
  }
212
204
 
213
- const key = joinKey(query.$where, sides);
214
- // the estimate decides which side fills the table: a hash join holds
215
- // the BUILD side whole, so the smaller declared side is the one to
216
- // hold. Undeclared estimates keep the caller's own order, which is
217
- // stable and says so in `explain()`
218
- const [first, second] = sides;
219
- const build = (second.member.estimatedRows ?? Infinity)
220
- < (first.member.estimatedRows ?? Infinity) ? second : first;
221
- const probe = build === first ? second : first;
222
- return {
223
- strategy: 'hash',
224
- build: { ...build, key: key[build.binding] },
225
- probe: { ...probe, key: key[probe.binding] },
226
- // the resident document is the caller's own with each side reduced
227
- // to its root: the packed work has already run at the source, and
228
- // whatever the terminal wrapped around the query is put back
229
- resident: located.rewrap({ ...query,
230
- $for: Object.fromEntries(sides.map((side) => [side.binding, side.root])) }),
231
- };
232
- }
233
-
234
- /**
235
- * The equality key that links the two sides, or the refusal.
236
- * @param {any} where
237
- * @param {any[]} sides
238
- * @returns {Record<string, string[]>}
239
- */
240
- function joinKey(where, sides) {
241
- const conjuncts = where === undefined || where === null ? []
242
- : (Array.isArray(where?.$and) ? where.$and : [where]);
243
- const [a, b] = sides;
244
- for (const conjunct of conjuncts) {
245
- const operands = conjunct?.$eq;
246
- if (!Array.isArray(operands) || operands.length !== 2) continue;
205
+ // A mandatory equality graph supplies the fetch order. OR branches
206
+ // never prove an edge. Estimates choose among connected candidates;
207
+ // equal or absent estimates retain binding declaration order.
208
+ const edges = [];
209
+ const visit = (where) => {
210
+ if (Array.isArray(where?.$and)) { where.$and.forEach(visit); return; }
211
+ const operands = where?.$eq;
212
+ if (!Array.isArray(operands) || operands.length !== 2) return;
247
213
  const left = memberPathOf(operands[0]);
248
214
  const right = memberPathOf(operands[1]);
249
- if (left === null || right === null) continue;
250
- if (left.binding === a.binding && right.binding === b.binding)
251
- return { [a.binding]: left.path, [b.binding]: right.path };
252
- if (left.binding === b.binding && right.binding === a.binding)
253
- return { [b.binding]: left.path, [a.binding]: right.path };
215
+ if (left && right && left.binding !== right.binding
216
+ && names.includes(left.binding) && names.includes(right.binding)) edges.push({ left, right });
217
+ };
218
+ visit(query.$where);
219
+ const remaining = [...sides];
220
+ const order = [];
221
+ const selected = new Set();
222
+ while (remaining.length > 0) {
223
+ const candidates = remaining.filter((side) => selected.size === 0 || edges.some(({ left, right }) =>
224
+ (left.binding === side.binding && selected.has(right.binding))
225
+ || (right.binding === side.binding && selected.has(left.binding))));
226
+ if (candidates.length === 0) throw new LinqBuildError('JL0005',
227
+ 'a federated join needs an equality between one member of each side in a connected binding graph');
228
+ candidates.sort((a, b) => (a.member.estimatedRows ?? Infinity) - (b.member.estimatedRows ?? Infinity));
229
+ const side = candidates[0];
230
+ side.links = edges.flatMap(({ left, right }) => {
231
+ const [own, other] = left.binding === side.binding ? [left, right] : [right, left];
232
+ return own.binding === side.binding && selected.has(other.binding)
233
+ ? [{ key: own.path, binding: other.binding, otherKey: other.path }] : [];
234
+ });
235
+ const edge = edges.find(({ left, right }) => left.binding === side.binding || right.binding === side.binding);
236
+ side.key = edge === undefined ? [] : (edge.left.binding === side.binding ? edge.left.path : edge.right.path);
237
+ order.push(side); selected.add(side.binding); remaining.splice(remaining.indexOf(side), 1);
238
+ }
239
+ // Bindings may independently project or alias the same source. Give
240
+ // those sets separate input members so one cannot overwrite another.
241
+ const used = new Set(sides.filter((side) => side.root !== null).map((side) => side.member.name));
242
+ const roots = new Set();
243
+ for (const side of sides) {
244
+ side.inputName = side.member.name;
245
+ if (side.root === null || roots.has(side.root)) {
246
+ let name = `_federated${sides.indexOf(side)}`;
247
+ while (used.has(name)) name += '_';
248
+ used.add(name); side.inputName = name; side.root = `$${memberSegment(name)}[*]`;
249
+ }
250
+ roots.add(side.root);
254
251
  }
255
- throw new LinqBuildError('JL0005',
256
- 'a federated join needs an equality between one member of each side — without one the '
257
- + 'fetch is the cross product of two sources, which is what the budget exists to refuse '
258
- + '(a non-equality condition still applies, but it cannot bound the fetch)');
252
+ return { strategy: 'hash', sides: order, build: order[0], probe: order[1],
253
+ resident: located.rewrap({ ...query,
254
+ $for: Object.fromEntries(sides.map((side) => [side.binding,
255
+ side.packed === null ? side.root : [side.root]])) }) };
259
256
  }
260
257
 
261
258
  /**
@@ -274,7 +271,7 @@ function joinKey(where, sides) {
274
271
  * @param {any[]} open - cursors to close, in order
275
272
  * @returns {Promise<{ rows: any[], read: number, bytes: number, streamed: boolean }>}
276
273
  */
277
- async function fetchSide(member, document, options, budget, keep, open) {
274
+ async function fetchSide(member, document, options, budget, keep, open, combined) {
278
275
  const kept = [];
279
276
  let read = 0;
280
277
  let bytes = 0;
@@ -292,6 +289,11 @@ async function fetchSide(member, document, options, budget, keep, open) {
292
289
  `the federated fetch of '${member.name}' reached its ${budget.maxBytes}-byte budget `
293
290
  + `at row ${kept.length + 1} — narrow the sides, or raise maxBytes`);
294
291
  }
292
+ if (combined.rows + 1 > combined.maxRows || combined.bytes + size > combined.maxBytes) {
293
+ throw new LinqRuntimeError('JL2008',
294
+ 'the federated fetch reached its combined row or byte budget — narrow the sides, or raise maxTotalRows/maxTotalBytes');
295
+ }
296
+ combined.rows++; combined.bytes += size;
295
297
  bytes += size;
296
298
  kept.push(row);
297
299
  };
@@ -321,19 +323,18 @@ async function fetchSide(member, document, options, budget, keep, open) {
321
323
  return { rows: kept, read, bytes, streamed: true };
322
324
  }
323
325
  stopIfAborted();
324
- const answer = await member.provider.execute(document, options);
325
- for (const row of itemsOf(answer)) admit(row);
326
+ // Frame the sequence as one array so an array-valued row is still
327
+ // one row. Cursor sources already supply that item boundary.
328
+ const answer = await member.provider.execute([document], options);
329
+ if (!Array.isArray(answer)) throw new LinqRuntimeError('JL2008',
330
+ `the federated source '${member.name}' did not answer the requested array frame`);
331
+ for (const row of answer) { stopIfAborted(); admit(row); }
326
332
  return { rows: kept, read, bytes, streamed: false };
327
333
  }
328
334
 
329
- /** The engine's answer shape as a row list: none, one, or many. */
330
- function itemsOf(answer) {
331
- if (answer === undefined) return [];
332
- return Array.isArray(answer) ? answer : [answer];
333
- }
334
-
335
335
  /** One side's document, with the federated root rewritten to the source's own. */
336
336
  function childDocument(side) {
337
+ if (side.nested) return side.packed;
337
338
  const own = providerRoot(side.member.provider);
338
339
  if (side.packed === null) return { $for: { it: own }, $return: '$it' };
339
340
  const binding = Object.keys(side.packed.$for)[0];
@@ -344,7 +345,7 @@ function childDocument(side) {
344
345
  * An explicit federation boundary over two or more provider sources.
345
346
  *
346
347
  * @param {{ sources: Record<string, any>, maxRows: number, maxBytes: number,
347
- * strategy?: string }} spec
348
+ * maxTotalRows?: number, maxTotalBytes?: number, strategy?: string }} spec
348
349
  * @returns {{ source: (name: string) => any, names: readonly string[] }}
349
350
  * @example
350
351
  * const fed = federate({
@@ -378,6 +379,11 @@ export function federate(spec) {
378
379
  maxBytes: budgetOf(spec.maxBytes, 'maxBytes'),
379
380
  };
380
381
 
382
+ const totals = {
383
+ maxRows: budgetOf(spec.maxTotalRows ?? budget.maxRows * 2, 'maxTotalRows'),
384
+ maxBytes: budgetOf(spec.maxTotalBytes ?? budget.maxBytes * 2, 'maxTotalBytes'),
385
+ };
386
+
381
387
  /** The scope every member shares: what admits the join, and nothing else. */
382
388
  const scope = Object.freeze({ federation: true });
383
389
  /** @type {Map<string, any>} federated root → member */
@@ -415,34 +421,43 @@ export function federate(spec) {
415
421
  const plan = planFederation(document, members);
416
422
  /** @type {any[]} */
417
423
  const open = [];
418
- try {
419
- const buildDoc = childDocument(plan.build);
420
- const built = await fetchSide(plan.build.member, buildDoc, options, budget,
421
- () => true, open);
422
- // the table is the REDUCTION, never the answer: it says which
423
- // keys can pair, and the engine decides which rows do
424
- const keys = new Set();
425
- let unkeyed = false;
426
- for (const row of built.rows) {
427
- const key = hashKey(valueAt(row, plan.build.key));
428
- if (key === null) unkeyed = true;
429
- else keys.add(key);
424
+ const combined = { ...totals, rows: 0, bytes: 0 };
425
+ const run = async (current, intermediate = false) => {
426
+ const fetched = new Map();
427
+ for (const side of current.sides) {
428
+ const tables = side.links.map((link) => {
429
+ const keys = new Set();
430
+ let unkeyed = false;
431
+ for (const row of fetched.get(link.binding)) {
432
+ const key = hashKey(valueAt(row, link.otherKey));
433
+ if (key === null) unkeyed = true; else keys.add(key);
434
+ }
435
+ return { ...link, keys, unkeyed };
436
+ });
437
+ const keep = (row) => tables.every((table) => {
438
+ const key = hashKey(valueAt(row, table.key));
439
+ return table.unkeyed || key === null || table.keys.has(key);
440
+ });
441
+ const member = side.nested ? { ...side.member,
442
+ provider: { execute: () => run(side.nested, true) } } : side.member;
443
+ const found = await fetchSide(member, childDocument(side), options, budget, keep, open, combined);
444
+ fetched.set(side.binding, found.rows);
430
445
  }
431
- const probeDoc = childDocument(plan.probe);
432
- const probed = await fetchSide(plan.probe.member, probeDoc, options, budget,
433
- (row) => {
434
- if (unkeyed) return true;
435
- const key = hashKey(valueAt(row, plan.probe.key));
436
- return key === null || keys.has(key);
437
- }, open);
438
-
439
- const input = {
440
- [plan.build.member.name]: built.rows,
441
- [plan.probe.member.name]: probed.rows,
442
- };
443
- const compiled = compileDocument(plan.resident,
444
- { ...options, externals: options?.externalNames ?? [] });
445
- const answer = compiled(input, options?.externals ?? {});
446
+ const input = Object.fromEntries(current.sides.map((side) => [side.inputName, fetched.get(side.binding)]));
447
+ const limits = intermediate ? { ...options?.limits,
448
+ sequenceItems: Math.min(options?.limits?.sequenceItems ?? Infinity, budget.maxRows),
449
+ resultItems: Math.min(options?.limits?.resultItems ?? Infinity, budget.maxRows) } : options?.limits;
450
+ const compiled = compileDocument(intermediate ? [current.resident] : current.resident,
451
+ { ...options, limits, externals: options?.externalNames ?? [] });
452
+ try { return compiled(input, options?.externals ?? {}); }
453
+ catch (error) {
454
+ if (intermediate && error.code === 'JQ2009' && /sequenceItems|resultItems/.test(error.message)) throw new LinqRuntimeError('JL2008',
455
+ 'a federated intermediate join reached its row budget');
456
+ throw error;
457
+ }
458
+ };
459
+ try {
460
+ const answer = await run(plan);
446
461
  // the fetch answered, so a cursor that will not close IS this
447
462
  // call's failure rather than something to swallow
448
463
  await closeAll(open);
@@ -484,13 +499,16 @@ export function federate(spec) {
484
499
  source: side.member.name,
485
500
  root: side.root,
486
501
  estimatedRows: side.member.estimatedRows ?? null,
487
- key: `$${side.binding}.${side.key.join('.')}`,
502
+ key: `$${side.binding}${side.key.map((part) => typeof part === 'number' ? `[${part}]` : memberSegment(part)).join('')}`,
488
503
  document: childDocument(side),
489
- streaming: typeof side.member.provider.cursor === 'function' ? 'row' : 'buffered',
504
+ streaming: typeof side.member.provider?.cursor === 'function' ? 'row' : 'buffered',
505
+ ...(side.nested ? { children: side.nested.sides.map(describe) } : {}),
490
506
  });
491
507
  return {
492
508
  strategy: plan.strategy,
493
509
  budget: { ...budget },
510
+ combinedBudget: { maxTotalRows: totals.maxRows, maxTotalBytes: totals.maxBytes },
511
+ order: plan.sides.map(describe),
494
512
  build: describe(plan.build),
495
513
  probe: describe(plan.probe),
496
514
  // the join itself is the engine's, over what the two fetches
@@ -135,7 +135,8 @@ export const CATALOGS = deepFreeze({
135
135
  ],
136
136
  "x-form/assert": [],
137
137
  "form/addItem": [],
138
- "form/removeItem": []
138
+ "form/removeItem": [],
139
+ "form/jsonPlaceholder": []
139
140
  },
140
141
  "contract": {
141
142
  "contract/not-found": [],
package/types/index.d.ts CHANGED
@@ -795,15 +795,18 @@ export interface FederatedSide {
795
795
  readonly document: unknown;
796
796
  /** Whether this side is pulled row by row, or answered whole. */
797
797
  readonly streaming: 'row' | 'buffered';
798
+ readonly children?: readonly FederatedSide[];
798
799
  }
799
800
 
800
801
  /** What a federated document will do, without doing any of it (§12.1). */
801
802
  export interface FederationPlan {
802
803
  readonly strategy: 'hash';
803
804
  readonly budget: { readonly maxRows: number; readonly maxBytes: number };
805
+ readonly combinedBudget: { readonly maxTotalRows: number; readonly maxTotalBytes: number };
806
+ readonly order: readonly FederatedSide[];
804
807
  readonly build: FederatedSide;
805
808
  readonly probe: FederatedSide;
806
- /** The join itself is the engine's, over the two reduced sides. */
809
+ /** The engine evaluates the original binding order over the reduced sets. */
807
810
  readonly resident: { readonly document: unknown };
808
811
  }
809
812
 
@@ -822,13 +825,18 @@ export interface FederatedSource<T = unknown> extends AsyncProvider<T> {
822
825
  * out of that, by naming the sources and the bounds together: each
823
826
  * side's own filters and projection run at its source, the smaller side
824
827
  * fills a bounded hash table, the other is probed against it, and the
825
- * caller's own document decides over the two reduced sets. A side that
828
+ * caller's own document decides over the reduced sets. Connected N-way
829
+ * graphs and nested fluent joins share cumulative admission credits. A side that
826
830
  * reaches `maxRows` or `maxBytes` raises `JL2008` at the row that would
827
831
  * have broken the bound. */
828
832
  export function federate(spec: {
829
833
  sources: Record<string, AsyncProvider | { provider: AsyncProvider; estimatedRows?: number }>;
830
834
  maxRows: number;
831
835
  maxBytes: number;
836
+ /** Shared admission credits across source reads and intermediate sets; defaults to 2 * maxRows. */
837
+ maxTotalRows?: number;
838
+ /** Defaults to 2 * maxBytes. Buffered providers/intermediates are checked after production. */
839
+ maxTotalBytes?: number;
832
840
  strategy?: 'hash';
833
841
  }): {
834
842
  source<T = unknown>(name: string): FederatedSource<T>;
@@ -1,7 +1,7 @@
1
1
  // Derived from the English catalogs by scripts/generate-message-pen.js.
2
2
  export interface CatalogIds {
3
3
  validate: "type" | "required" | "minimum" | "maximum" | "exclusiveMinimum" | "exclusiveMaximum" | "multipleOf" | "minLength" | "maxLength" | "pattern" | "additionalProperties" | "minProperties" | "maxProperties" | "minItems" | "maxItems" | "uniqueItems" | "contains" | "items" | "allOf" | "anyOf" | "oneOf" | "not" | "format" | "if" | "then" | "else" | "false schema" | "$query" | "JQ2001" | "JQ2003";
4
- forms: "form/required" | "form/type" | "form/const" | "form/enum" | "form/minLength" | "form/maxLength" | "form/pattern" | "form/format" | "form/minimum" | "form/maximum" | "form/exclusiveMinimum" | "form/exclusiveMaximum" | "form/multipleOf" | "form/minItems" | "form/maxItems" | "form/uniqueItems" | "form/minProperties" | "form/maxProperties" | "x-form/assert" | "form/addItem" | "form/removeItem";
4
+ forms: "form/required" | "form/type" | "form/const" | "form/enum" | "form/minLength" | "form/maxLength" | "form/pattern" | "form/format" | "form/minimum" | "form/maximum" | "form/exclusiveMinimum" | "form/exclusiveMaximum" | "form/multipleOf" | "form/minItems" | "form/maxItems" | "form/uniqueItems" | "form/minProperties" | "form/maxProperties" | "x-form/assert" | "form/addItem" | "form/removeItem" | "form/jsonPlaceholder";
5
5
  contract: "contract/not-found" | "contract/method-not-allowed" | "contract/body-too-large" | "contract/unsupported-media" | "contract/malformed-json" | "contract/invalid-input" | "contract/idempotency-key-required" | "contract/handler-failed" | "contract/idempotency-conflict" | "contract/invalid-output" | "contract/malformed-path" | "contract/malformed-query" | "contract/not-implemented" | "contract/precondition-failed" | "contract/invalid-header" | "contract/handler-error" | "contract/client-invalid-input" | "contract/network" | "contract/cancelled" | "contract/invalid-response" | "contract/key-storage-failed" | "contract/undeclared-response" | "contract/not-a-contract" | "contract/incompatible" | "contract/host-failed" | "contract/local-handler-failed" | "contract/unknown-operation" | "contract/port-timeout" | "contract/malformed-frame" | "contract/channel-closed" | "contract/not-a-stream" | "contract/invalid-snapshot" | "contract/seq-regression" | "contract/stream-error" | "contract/heartbeat-missed" | "contract/slow-consumer" | "contract/reconnect-exhausted";
6
6
  }
7
7
  export interface MessageParameters {
@@ -56,6 +56,7 @@ export interface MessageParameters {
56
56
  "x-form/assert": never;
57
57
  "form/addItem": never;
58
58
  "form/removeItem": never;
59
+ "form/jsonPlaceholder": never;
59
60
  "contract/not-found": never;
60
61
  "contract/method-not-allowed": "allow";
61
62
  "contract/body-too-large": "limit" | "op";