@aventara/client 1.0.0-rc.10 → 1.0.0-rc.11

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/CHANGELOG.md CHANGED
@@ -1,5 +1,58 @@
1
1
  # @aventara/client
2
2
 
3
+ ## 1.0.0-rc.11
4
+
5
+ ### Patch Changes
6
+
7
+ - 6197c08: `@/aventara/universal`'s calls are the generated client's own calls again. The entry no longer wraps each one in
8
+ `io().then(call)`, so its own `signal`, `avClient.all`'s `signal` and strict abort, and `avLazy` all reach it, in the
9
+ browser and on the server. Next's `io()` is now awaited before the request is sent.
10
+
11
+ In the browser, the calls made in one tick are still grouped into one `/_all`. On the server, none is grouped: each
12
+ call has a client of its own with grouping off, and it is sent from the async context of the code that made it. Before,
13
+ one queue served every request. Calls from two concurrent requests, or a call inside `unstable_cache` and one outside
14
+ it, could share a flush and run with the first call's headers and `state`.
15
+
16
+ `avLazy` no longer keeps one answer for every request on a server:
17
+
18
+ - The generated client's `avLazy` keeps its answer in a page. On a server (no `location`) it keeps none, and each await
19
+ makes the call again.
20
+ - `@aventara/core`'s `avLazy` keeps none: each await makes the call again.
21
+ - `@/aventara/universal` now exports its own `avLazy`, which keeps one answer per request inside a React render
22
+ (React's `cache`, which the entry now imports) and none where React keeps no request.
23
+
24
+ Before, an `avLazy` made at module scope handed the first request's answer to every request after it.
25
+
26
+ `ready()`'s in-process path now splits a cached read's GET URL into its path and its `?q=&v=` query, with an empty
27
+ body, so the read reaches its GET route instead of being answered as absent.
28
+
29
+ - 5ac10f7: The generated client's calls are grouped. A call runs when it is made, like `fetch` (a call never awaited still runs;
30
+ a transaction too), and `avLazy(() => call)`, exported beside `avClient`, makes one only when it is first awaited. The
31
+ calls made in one tick go out as one `POST /_all` Operation Set,
32
+ split at `limits.maxAll`, each resolving or throwing on its own as its streamed (NDJSON) or plain JSON answer arrives; a
33
+ call made alone keeps its own route. A call with `headers` or a `requestId`, a transaction, and any call made with
34
+ `grouping: false` (per call, or `new AvClient({ grouping: false })`) is sent alone. A server without `/_all` gets the calls
35
+ again one by one. A call's `signal` aborts that call only, and is never sent if it aborts first. `avClient.all({ … })`
36
+ awaits an object of calls: strict by default, or `{ settle: true }` for one settled entry per key, with a `signal` that
37
+ aborts its calls. `all` joins the client's reserved member names: a Resource named `all` is reached as `allModel`.
38
+
39
+ A read the ClientContract marks `cache` (a `cache({ for, maxAge })` stage) is sent as
40
+ `GET /_resources/<R>/find/<variant>?q=<args>&v=<hash>`, alone and never grouped, with only the caller's own headers: `q`
41
+ is the arguments' canonical JSON, percent-encoded or deflate-raw and base64url when shorter (plain where there is no
42
+ `CompressionStream`), and a URL longer than 4096 characters is POSTed instead. A client with a cached read carries two
43
+ more runtime modules, `runtime/cached-read.ts` and `runtime/canonical-json.ts` (core's writer, copied).
44
+
45
+ - Updated dependencies [6197c08]
46
+ - Updated dependencies [e0e89c1]
47
+ - Updated dependencies [186bc29]
48
+ - Updated dependencies [33b5f83]
49
+ - Updated dependencies [6d8b589]
50
+ - Updated dependencies [11ece49]
51
+ - Updated dependencies [92fd093]
52
+ - Updated dependencies [e5e98e6]
53
+ - Updated dependencies [a05d970]
54
+ - @aventara/core@1.0.0-rc.11
55
+
3
56
  ## 1.0.0-rc.10
4
57
 
5
58
  ### Patch Changes
package/README.md CHANGED
@@ -125,20 +125,112 @@ const maybe = await avClient.User.find.first({ where }); // User | null — a mi
125
125
  ClientContract (tests, SSR), and `fetch` is for a custom fetch.
126
126
  - The client has one property per Resource, one per family under it, and one per advertised variant. Only what the
127
127
  ClientContract advertises exists, in the type and at runtime. A Resource named like one of the client's own members
128
- (`tx`, `transaction`, `then`, `constructor`, `Object.prototype`'s names) is reached as `<name>Model`, with one
128
+ (`all`, `tx`, `transaction`, `then`, `constructor`, `Object.prototype`'s names) is reached as `<name>Model`, with one
129
129
  warning; its wire name is unchanged.
130
130
  - A call resolves to the data: the record, a list, a count. A first-style miss (`A1001`) is `null`. Every other
131
131
  outcome throws the `AvError` subclass of its code (`AvNotFoundError` for a strict-unique miss,
132
132
  `AvContractMismatchError` for a stale client, …), or `AvTransportError` when no framework answer arrived.
133
133
  - Values are application values: `bigint`, `Date`, `Uint8Array`, the client's `AvDecimal`, JSON values as they are.
134
134
  Arguments are encoded and results revived for you; a value that cannot be read throws `AvTransportError`.
135
- - Per-call options (`AvCallOptions`) are never sent in the body: `signal` (an abort rejects with what `fetch` rejected
136
- with), `headers` (the framework's own identity headers are refused), and `requestId`, sent as `Aventara-Request-Id`
137
- and winning over that header in `headers`.
135
+ - Per-call options (`AvCallOptions`) are never sent in the body: `signal` (aborts this call only, see below),
136
+ `headers` (the framework's own identity headers are refused), `requestId`, sent as `Aventara-Request-Id` and winning
137
+ over that header in `headers`, and `grouping: false` (see below).
138
138
  - The named types (`User`, `UserWhere`, `UserUniqueWhere`, `UserOrderBy`, `UserCreateData`, `UserUpdateData`,
139
139
  `UserSelect`, `UserInclude`) are named exports, each only when the operation it reads is advertised. A name that would
140
140
  clash walks the rename ladder (`User` beside a Resource `UserWhere` becomes `UserModel`), one warning each.
141
141
 
142
+ ## Calls run when made, together
143
+
144
+ A call runs when you make it, as `fetch` does: it checks and encodes its arguments and is queued at once, and a call
145
+ you never await still runs. A transaction too. Awaiting a call again does not send it again.
146
+
147
+ The calls made in one tick go out as one request, `POST /_all` (an Operation Set):
148
+
149
+ ```ts
150
+ const [campaigns, unread, me] = await Promise.all([
151
+ avClient.Campaign.find.many({ where: { ownerId } }),
152
+ avClient.Message.find.count({ where: { toId: ownerId, readAt: null } }),
153
+ avClient.User.find.unique({ where: { id: ownerId } }),
154
+ ]); // one request
155
+ ```
156
+
157
+ - Each call runs on the server exactly as it would alone (validation, exposure, policy, limits, pipelines), concurrently
158
+ with the others and not atomically: a write that ran stays written whatever the others answered. Each call resolves
159
+ or throws on its own, as soon as its own answer arrives (the client asks for a streamed answer,
160
+ `Accept: application/x-ndjson`, where the runtime can read one, and reads a plain JSON answer otherwise, as on React
161
+ Native).
162
+ - Together means made before the microtask queue settles: one `Promise.all`, several async functions run together,
163
+ `avClient.all`. No timer is involved, and a call made on its own goes to its own route as before.
164
+ - A group larger than the ClientContract's `limits.maxAll` (or `limits.maxRequestBytes`) is split into several
165
+ requests.
166
+ - Sent alone, never grouped: a call with `headers` or a `requestId` (they belong to one request), a call made with
167
+ `{ grouping: false }`, every call of a client made with `new AvClient({ grouping: false })`, and a transaction.
168
+ - A server without `/_all` (it answers `A2002`) gets the calls again one by one, and that client stops grouping.
169
+
170
+ ### Aborting a call
171
+
172
+ A call's `signal` aborts that call and nothing else. Aborted before it is sent, it is never sent. Aborted while its
173
+ group is in flight, it rejects at once with the signal's reason (an `AbortError`), and the others in the request go on;
174
+ the request itself is aborted once no call in it still waits for an answer. Sent alone, the abort aborts its request,
175
+ as before. The server still finishes an operation it has already started: there is no way to cancel a database query
176
+ once it runs.
177
+
178
+ Nothing is aborted for you: there is no default timeout and no abort on navigation. Pass the signal your data library
179
+ gives you (React Query and SWR pass one per query), or your own.
180
+
181
+ ### `avClient.all`
182
+
183
+ ```ts
184
+ const { campaigns, unread } = await avClient.all({
185
+ campaigns: avClient.Campaign.find.many({ where: { ownerId } }),
186
+ unread: avClient.Message.find.count({ where: { toId: ownerId, readAt: null } }),
187
+ });
188
+ ```
189
+
190
+ `all` takes an object of calls and resolves one value per key, typed per key. It is strict: the first failure rejects
191
+ it with that call's error and aborts its other calls. That is fail-fast, not atomic: a write that already ran stays
192
+ written. With `{ settle: true }` every key settles on its own, as `{ status: "fulfilled", value }` or
193
+ `{ status: "rejected", reason }`, and `all` never rejects for one key:
194
+
195
+ ```ts
196
+ const panels = await avClient.all({ campaigns, unread }, { settle: true });
197
+ if (panels.unread.status === "rejected") { /* show that panel's error */ }
198
+ ```
199
+
200
+ `{ signal }` aborts every call of that `all`; a call inside it is aborted by its own signal or by the `all`'s. Calls
201
+ grouped beside it but not in it are never touched. The keys are yours and are never sent.
202
+
203
+ ### `avLazy`: describe now, run later
204
+
205
+ ```ts
206
+ import avClient, { avLazy } from "./api/AvClient";
207
+
208
+ const posts = avLazy(() => avClient.Post.find.many({ where: { published: true } })); // nothing sent yet
209
+ const list = await posts; // sent now (once, in a page)
210
+ ```
211
+
212
+ `avLazy(() => call)` makes the call on its first `then`, `catch` or `finally`, and settles as that call does. In a
213
+ page it makes the call once and keeps the answer. On a server, where one module serves every request, it keeps nothing:
214
+ each await makes the call again. `avClient.all` accepts it as it accepts a call (its `signal` aborts that call too).
215
+
216
+ ## Cached reads are GETs
217
+
218
+ A read the deployment caches (a `cache({ for, maxAge })` stage on it, which its ClientContract marks) is sent as a GET,
219
+ so the browser, or a CDN when the stage allows one, may keep its answer:
220
+
221
+ ```
222
+ GET <entrypoint>/_resources/Product/find/many?q=<arguments>&v=<ClientContract hash>
223
+ ```
224
+
225
+ - `q` is the arguments' canonical JSON (keys sorted, so the same read is the same URL), percent-encoded, or that JSON
226
+ deflate-raw'd and base64url'd when that is shorter. Where the runtime has no `CompressionStream` the plain form is sent.
227
+ - A cached read is never grouped into `/_all`: each keeps its own URL, its own cache entry. Your `headers` and
228
+ `requestId` go with the GET; it sends no header of its own, so a cross-origin read needs no CORS preflight. Its
229
+ `signal` aborts it as any call's does.
230
+ - A URL longer than 4096 characters (the whole URL) is POSTed to the same route instead, uncached, as are arguments
231
+ canonical JSON cannot write.
232
+ - The URL holds the arguments, so it lands in server and proxy logs and in browser history.
233
+
142
234
  ## Transactions
143
235
 
144
236
  When the ClientContract advertises `interactive` transactions, the client has `avClient.tx` and `avClient.transaction`
@@ -170,7 +262,7 @@ Its source field must have the same type as where it is written:
170
262
 
171
263
  A mismatch is a type error, and the server refuses it before anything runs (`A2007`/`V1011`).
172
264
 
173
- `avClient.tx.…` builds a handle and sends nothing. `avClient.transaction([...])` sends the plan once and resolves one
265
+ `avClient.tx.…` builds a handle and sends nothing. `avClient.transaction([...])` sends the plan once, when made, and resolves one
174
266
  result per handle, typed, in order; a failing step throws its error with `cause.operation` naming the step. `$ref` binds
175
267
  to the handle's position in the list it runs with. Two mistakes are refused before anything is sent, as core refuses
176
268
  them in process: one handle twice (`AvValidationError`, `A2004`/`V1001`), and a `$ref` to a handle not in the list
@@ -244,7 +336,7 @@ repository uses. The `.d.ts` files are core's copied declarations under `generat
244
336
  and the named types in `generated/types.d.ts`; the `.ts` sources carry their own types.
245
337
 
246
338
  - `AvClient.ts`: the entry point you import: `avClient` (the default export), `AvClient`, `AvClientOptions`,
247
- `AvCallOptions`, `AvFetch`, `AvOperation`, the named Resource types, the enums, `AvDecimal`, `AvError` and its
339
+ `AvCallOptions`, `AvFetch`, `AvOperation`, `avLazy`, the named Resource types, the enums, `AvDecimal`, `AvError` and its
248
340
  subclasses, `AvTransportError`, and the types `AvCause`, `AvValidationIssue`, `AvOperationCode` and `AvValidationCode`.
249
341
  - `generated/client.ts`: the typed client: the class `AvClient`, `AvClientOptions`, `AvCallOptions` and the ready
250
342
  instance `avClient`. Every argument and result type is an instantiation of core's own derivation.
@@ -255,6 +347,10 @@ and the named types in `generated/types.d.ts`; the `.ts` sources carry their own
255
347
  - `generated/enums.ts`: each enum as a union type and a same-named `as const` object.
256
348
  - `generated/metadata.ts`: the ClientContract hash and protocol version the client is bound to, and the entrypoint it
257
349
  was generated from, which is its default deployment (an entrypoint carrying credentials is refused at resolution).
350
+ - `generated/runtime/cached-read.ts`: a cached read's GET: its `q` (canonical JSON, plain or deflate-raw), its
351
+ `v`, and the POST past 4096 characters. Emitted only when the ClientContract caches a read.
352
+ - `generated/runtime/canonical-json.ts`: `@aventara/core`'s canonical JSON writer, its compiled source copied as it is
353
+ (unchecked: `// @ts-nocheck`). Emitted only when the ClientContract caches a read.
258
354
  - `generated/runtime/codec.ts`: the nine scalars' wire codecs, and the request-body serializer.
259
355
  - `generated/runtime/decimal.ts`: the framework's `AvDecimal`, with a constructor, `toString` and `toJSON`.
260
356
  - `generated/runtime/descriptor.ts`: what the runtime reads of the contract: the result fields it revives and the
@@ -271,12 +367,16 @@ and the named types in `generated/types.d.ts`; the `.ts` sources carry their own
271
367
  carries a check.
272
368
  - `generated/runtime/fingerprint.ts`: the operation fingerprint (RFC 8785 canonical JSON, SHA-256, base64url),
273
369
  dependency-free and pinned to core's. Emitted only when transactions are `interactive`.
370
+ - `generated/runtime/operation-set.ts`: the calls, queued when made, the grouping of the calls made together into
371
+ Operation Sets
372
+ (`POST /_all`), per-call abort, and `avClient.all`.
274
373
  - `generated/runtime/transaction.ts`: `avClient.tx`'s deferred handles and `avClient.transaction`'s plan assembly,
275
374
  refusals and one POST to `/_transactions`. Emitted only when transactions are `interactive`.
276
- - `generated/runtime/transport.ts`: one POST per operation with the identity headers, and the response read by the
277
- protocol's rule. Arguments are encoded by value, results revived by the decode table, and the platform's own `fetch`
278
- type is accepted. Its `execute` primitive is module-private: no public file re-exports it, and `AvClient`'s methods
279
- wrap it.
375
+ - `generated/runtime/transport.ts`: one POST per operation, or one `POST /_all` per Operation Set (its answer read
376
+ streamed or whole), with the identity headers, and the response read by the protocol's rule. Arguments are encoded
377
+ by value, results revived by the decode table, and the platform's own `fetch` type is accepted. Its primitives are
378
+ module-private: no public file re-exports them, and `AvClient`'s methods reach them through
379
+ `generated/runtime/operation-set.ts`.
280
380
  - `generated/types.d.ts`: the named Resource types, as a declaration file (`User`, `UserWhere`, `UserUniqueWhere`,
281
381
  `UserOrderBy`, `UserCreateData`, `UserUpdateData`, `UserSelect`, `UserInclude`), each only when its operation is
282
382
  advertised.
@@ -0,0 +1,3 @@
1
+ import type { EmittedModule } from "./emitted-tree.interface.js";
2
+ import { type ClientModuleStyle } from "./module-style.interface.js";
3
+ export declare function emitCachedReadModules(style: ClientModuleStyle): readonly EmittedModule[];
@@ -0,0 +1,158 @@
1
+ import { canonicalJson } from "@aventara/core/internal";
2
+ import { moduleSpecifierWriter, } from "./module-style.interface.js";
3
+ export function emitCachedReadModules(style) {
4
+ return [
5
+ { path: "runtime/canonical-json.ts", source: CANONICAL_JSON_MODULE },
6
+ {
7
+ path: "runtime/cached-read.ts",
8
+ source: cachedReadModuleSource(moduleSpecifierWriter(style)),
9
+ },
10
+ ];
11
+ }
12
+ const MAX_READ_URL_LENGTH = 4096;
13
+ const CANONICAL_JSON_MODULE = `// @ts-nocheck
14
+ /**
15
+ * The canonical JSON (RFC 8785) of a cached read's arguments: @aventara/core's
16
+ * own writer, its compiled source copied as it is, so one read is one URL.
17
+ */
18
+ export ${canonicalJson.toString()}
19
+ `;
20
+ function cachedReadModuleSource(from) {
21
+ return `import { CLIENT_CONTRACT_HASH } from ${from("../metadata")};
22
+ import { canonicalJson as canonicalJsonOf } from ${from("./canonical-json")};
23
+ import { CACHED_READS } from ${from("./descriptor")};
24
+ import {
25
+ type PlatformSignal,
26
+ type PreparedOperation,
27
+ routeOf,
28
+ sendOperation,
29
+ sendRead,
30
+ type TransportConnection,
31
+ } from ${from("./transport")};
32
+
33
+ /**
34
+ * Cached reads: a read the ClientContract marks cache is sent as
35
+ * GET <entrypoint>/_resources/<resource>/find/<variant>?q=<arguments>&v=<hash>,
36
+ * alone, so a browser or a CDN may keep its answer. q is the arguments'
37
+ * canonical JSON, percent-encoded, or deflate-raw and base64url when that is
38
+ * shorter. A URL longer than MAX_READ_URL_LENGTH is POSTed instead.
39
+ */
40
+
41
+ /** The longest URL, in characters, a cached read is sent as a GET; a longer one is POSTed. */
42
+ export const MAX_READ_URL_LENGTH = ${MAX_READ_URL_LENGTH};
43
+
44
+ /** The canonical JSON of a JSON value; throws a TypeError for anything else. */
45
+ const canonicalJson: (value: unknown) => string = canonicalJsonOf;
46
+
47
+ /** Whether the ClientContract marks this read cache: it is sent as a GET, never grouped. */
48
+ export function isCachedRead(operation: PreparedOperation): boolean {
49
+ return (
50
+ operation.family === "find" &&
51
+ Object.hasOwn(CACHED_READS, operation.resource) &&
52
+ (CACHED_READS[operation.resource] ?? []).includes(operation.variant)
53
+ );
54
+ }
55
+
56
+ /**
57
+ * Sends one cached read: a GET with its q and v, or a POST to the same route
58
+ * when its URL would pass MAX_READ_URL_LENGTH or canonical JSON cannot write its
59
+ * arguments. Settles as every call does.
60
+ */
61
+ export async function sendCachedRead(
62
+ connection: TransportConnection,
63
+ operation: PreparedOperation,
64
+ signal?: PlatformSignal,
65
+ ): Promise<unknown> {
66
+ let json: string;
67
+ try {
68
+ json = canonicalJson(JSON.parse(operation.body));
69
+ } catch {
70
+ return sendOperation(connection, operation, signal);
71
+ }
72
+ const query = "q=" + (await queryArgument(json)) + "&v=" + encodeURIComponent(CLIENT_CONTRACT_HASH);
73
+ return (connection.entrypoint + routeOf(operation) + "?" + query).length > MAX_READ_URL_LENGTH
74
+ ? sendOperation(connection, operation, signal)
75
+ : sendRead(connection, operation, query, signal);
76
+ }
77
+
78
+ /** q: the JSON percent-encoded, or deflate-raw and base64url when that is shorter. */
79
+ async function queryArgument(json: string): Promise<string> {
80
+ const plain = encodeURIComponent(json);
81
+ const compressed = await deflated(json);
82
+ return compressed !== undefined && compressed.length < plain.length ? compressed : plain;
83
+ }
84
+
85
+ /** What the client reads of a CompressionStream, where the consumer's lib declares none. */
86
+ interface Compression {
87
+ readonly writable: {
88
+ getWriter(): { write(chunk: Uint8Array): Promise<void>; close(): Promise<void> };
89
+ };
90
+ readonly readable: {
91
+ getReader(): { read(): Promise<{ readonly done: boolean; readonly value?: Uint8Array | undefined }> };
92
+ };
93
+ }
94
+
95
+ /** The JSON's UTF-8 bytes deflate-raw'd, in base64url; undefined where the runtime cannot. */
96
+ async function deflated(json: string): Promise<string | undefined> {
97
+ const platform = globalThis as unknown as {
98
+ readonly CompressionStream?: new (format: string) => Compression;
99
+ readonly TextEncoder?: new () => { encode(text: string): Uint8Array };
100
+ };
101
+ const Stream = platform.CompressionStream;
102
+ const Encoder = platform.TextEncoder;
103
+ if (typeof Stream !== "function" || typeof Encoder !== "function") {
104
+ return undefined;
105
+ }
106
+ try {
107
+ const stream = new Stream("deflate-raw");
108
+ const writer = stream.writable.getWriter();
109
+ const written = writer.write(new Encoder().encode(json)).then(() => writer.close());
110
+ written.catch((): void => undefined);
111
+ const reader = stream.readable.getReader();
112
+ const chunks: Uint8Array[] = [];
113
+ let total = 0;
114
+ for (;;) {
115
+ const chunk = await reader.read();
116
+ if (chunk.done) {
117
+ break;
118
+ }
119
+ if (chunk.value !== undefined) {
120
+ chunks.push(chunk.value);
121
+ total += chunk.value.length;
122
+ }
123
+ }
124
+ await written;
125
+ const bytes = new Uint8Array(total);
126
+ let offset = 0;
127
+ for (const chunk of chunks) {
128
+ bytes.set(chunk, offset);
129
+ offset += chunk.length;
130
+ }
131
+ return base64Url(bytes);
132
+ } catch {
133
+ return undefined;
134
+ }
135
+ }
136
+
137
+ const BASE64URL = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
138
+
139
+ /** Base64url without padding. */
140
+ function base64Url(bytes: Uint8Array): string {
141
+ let text = "";
142
+ for (let index = 0; index < bytes.length; index += 3) {
143
+ const first = bytes[index] ?? 0;
144
+ const second = bytes[index + 1];
145
+ const third = bytes[index + 2];
146
+ const group = (first << 16) | ((second ?? 0) << 8) | (third ?? 0);
147
+ text += BASE64URL.charAt((group >> 18) & 63) + BASE64URL.charAt((group >> 12) & 63);
148
+ if (second !== undefined) {
149
+ text += BASE64URL.charAt((group >> 6) & 63);
150
+ }
151
+ if (third !== undefined) {
152
+ text += BASE64URL.charAt(group & 63);
153
+ }
154
+ }
155
+ return text;
156
+ }
157
+ `;
158
+ }
@@ -49,7 +49,7 @@ export function emitClientSurfaceModule(contract, names, style) {
49
49
  "\t\tAccessQuestion,\n" +
50
50
  `\t\t${verbs.includes("me") ? "AvAccessMe" : "never"},\n` +
51
51
  `\t\t{ ${argsMembers} },\n` +
52
- "\t\tAvCallOptions\n" +
52
+ '\t\tOmit<AvCallOptions, "grouping">\n' +
53
53
  "\t>,\n" +
54
54
  `\t${["can", ...verbs].map((name) => JSON.stringify(name)).join(" | ")}\n` +
55
55
  ">;\n";
@@ -107,12 +107,13 @@ export function emitClientSurfaceModule(contract, names, style) {
107
107
  (checked
108
108
  ? `import { ${interactive ? "checkOperation, checkPlan" : "checkOperation"} } from ${from("./runtime/field-validators")};\n`
109
109
  : "") +
110
+ `import { all, CallQueue${interactive ? ", withSignal" : ""} } from ${from("./runtime/operation-set")};\n` +
110
111
  (interactive
111
112
  ? `import { deferOperation, runTransaction } from ${from("./runtime/transaction")};\n`
112
113
  : "") +
113
114
  (access === undefined
114
- ? `import { type AvCallOptions, execute, type AvFetch, type TransportConnection } from ${from("./runtime/transport")};\n`
115
- : `import { askCan, type AvCallOptions, execute, type AvFetch, type TransportConnection } from ${from("./runtime/transport")};\n`) +
115
+ ? `import type { AvCallOptions, AvFetch, PlatformSignal, TransportConnection } from ${from("./runtime/transport")};\n`
116
+ : `import { askCan, type AvCallOptions, type AvFetch, type PlatformSignal, type TransportConnection } from ${from("./runtime/transport")};\n`) +
116
117
  (verbs.includes("me")
117
118
  ? `import type { AvAccessMe } from ${from("./types")};\n`
118
119
  : "") +
@@ -133,6 +134,11 @@ export function emitClientSurfaceModule(contract, names, style) {
133
134
  "\treadonly entrypoint?: string;\n" +
134
135
  "\t/** A custom fetch, for authentication or tests; the platform's otherwise. */\n" +
135
136
  "\treadonly fetch?: AvFetch;\n" +
137
+ "\t/**\n" +
138
+ "\t * false sends every call to its own route. Otherwise the calls made\n" +
139
+ "\t * together (in one tick) go out as one Operation Set, POST /_all.\n" +
140
+ "\t */\n" +
141
+ "\treadonly grouping?: boolean;\n" +
136
142
  "}\n" +
137
143
  "\n" +
138
144
  (shaped === undefined
@@ -166,11 +172,31 @@ export function emitClientSurfaceModule(contract, names, style) {
166
172
  "/** Sends the handles as one plan and resolves their results as a typed tuple. */\n" +
167
173
  "type TransactionCall = <const Steps extends readonly AvOperation<unknown>[]>(\n" +
168
174
  "\tsteps: Steps,\n" +
169
- "\toptions?: AvCallOptions,\n" +
175
+ '\toptions?: Omit<AvCallOptions, "grouping">,\n' +
170
176
  ") => Promise<AvTransactionResults<Steps>>;\n"
171
177
  : "") +
172
178
  accessTypes +
173
179
  "\n" +
180
+ "/** How one call of `all(…, { settle: true })` settled. */\n" +
181
+ "type AllSettledEntry<T> =\n" +
182
+ '\t| { readonly status: "fulfilled"; readonly value: T }\n' +
183
+ '\t| { readonly status: "rejected"; readonly reason: unknown };\n' +
184
+ "\n" +
185
+ "/** Awaits an object of calls together and resolves one value per key, or with `settle: true` one settled entry per key. */\n" +
186
+ "type AllCall = <E extends { readonly [key: string]: PromiseLike<unknown> }, S extends boolean = false>(\n" +
187
+ "\tentries: E,\n" +
188
+ "\toptions?: {\n" +
189
+ "\t\t/** Aborts every call of this `all`, and no other. */\n" +
190
+ "\t\treadonly signal?: PlatformSignal;\n" +
191
+ "\t\t/** true settles every key on its own and never rejects for one; strict otherwise. */\n" +
192
+ "\t\treadonly settle?: S;\n" +
193
+ "\t},\n" +
194
+ ") => Promise<\n" +
195
+ "\tS extends true\n" +
196
+ "\t\t? { -readonly [K in keyof E]: AllSettledEntry<Awaited<E[K]>> }\n" +
197
+ "\t\t: { -readonly [K in keyof E]: Awaited<E[K]> }\n" +
198
+ ">;\n" +
199
+ "\n" +
174
200
  "/** The generated client: one property per Resource this deployment's ClientContract advertises. */\n" +
175
201
  "export interface AvClient extends AvClientSurface {}\n" +
176
202
  "\n" +
@@ -188,6 +214,14 @@ export function emitClientSurfaceModule(contract, names, style) {
188
214
  " */\n"
189
215
  : "/** The generated client. */\n") +
190
216
  "export class AvClient {\n" +
217
+ "\t/**\n" +
218
+ "\t * Awaits an object of calls together, so they go out as one Operation Set,\n" +
219
+ "\t * and resolves one value per key. Strict: the first failure rejects it and\n" +
220
+ "\t * aborts its other calls (fail-fast, not atomic: what already ran stays\n" +
221
+ "\t * done). With `settle: true` every key settles as `{ status, value }` or\n" +
222
+ "\t * `{ status, reason }`. Its `signal` aborts each of its calls and no other.\n" +
223
+ "\t */\n" +
224
+ "\tdeclare readonly all: AllCall;\n" +
191
225
  (access === undefined
192
226
  ? ""
193
227
  : "\t/** The policy's answers for the UI (`can`), and the Aventara Access verbs this deployment offers. */\n" +
@@ -204,13 +238,14 @@ export function emitClientSurfaceModule(contract, names, style) {
204
238
  '\t\tconst entrypoint = (options.entrypoint ?? DEFAULT_ENTRYPOINT).replace(/\\/+$/, "");\n' +
205
239
  "\t\tconst given = options.fetch;\n" +
206
240
  "\t\tconst connect = (): TransportConnection => ({ entrypoint, fetch: platformFetch(given) });\n" +
241
+ "\t\tconst queue = new CallQueue(connect, options.grouping !== false);\n" +
207
242
  "\t\tfor (const [resource, families] of operationTree(\n" +
208
243
  "\t\t\t(resource, family, variant): OperationMethod =>\n" +
209
244
  (checked
210
- ? "\t\t\t\tasync (args = {}, callOptions = {}) =>\n" +
211
- "\t\t\t\t\texecute(connect(), resource, family, variant, args, callOptions, checkOperation),\n"
212
- : "\t\t\t\tasync (args = {}, callOptions = {}) =>\n" +
213
- "\t\t\t\t\texecute(connect(), resource, family, variant, args, callOptions),\n") +
245
+ ? "\t\t\t\t(args = {}, callOptions = {}) =>\n" +
246
+ "\t\t\t\t\tqueue.operation(resource, family, variant, args, callOptions, checkOperation),\n"
247
+ : "\t\t\t\t(args = {}, callOptions = {}) =>\n" +
248
+ "\t\t\t\t\tqueue.operation(resource, family, variant, args, callOptions),\n") +
214
249
  "\t\t)) {\n" +
215
250
  "\t\t\tObject.defineProperty(this, propertyOf(resource), { value: families, enumerable: true });\n" +
216
251
  "\t\t}\n" +
@@ -225,12 +260,22 @@ export function emitClientSurfaceModule(contract, names, style) {
225
260
  "\t\t}\n" +
226
261
  '\t\tObject.defineProperty(this, "tx", { value: Object.freeze(deferred) });\n' +
227
262
  '\t\tObject.defineProperty(this, "transaction", {\n' +
228
- "\t\t\tvalue: async (steps: unknown, callOptions: AvCallOptions = {}) =>\n" +
263
+ "\t\t\tvalue: (steps: unknown, callOptions: AvCallOptions = {}) => {\n" +
264
+ "\t\t\t\tconst plan = Array.isArray(steps) ? [...(steps as readonly unknown[])] : steps;\n" +
265
+ "\t\t\t\treturn queue.alone(\n" +
266
+ "\t\t\t\t\t(signal) =>\n" +
229
267
  (checked
230
- ? "\t\t\t\trunTransaction(connect(), steps, callOptions, checkPlan),\n"
231
- : "\t\t\t\trunTransaction(connect(), steps, callOptions),\n") +
268
+ ? "\t\t\t\t\t\trunTransaction(connect(), plan, withSignal(callOptions, signal), checkPlan),\n"
269
+ : "\t\t\t\t\t\trunTransaction(connect(), plan, withSignal(callOptions, signal)),\n") +
270
+ "\t\t\t\t\tcallOptions.signal,\n" +
271
+ "\t\t\t\t);\n" +
272
+ "\t\t\t},\n" +
232
273
  "\t\t});\n"
233
274
  : "") +
275
+ '\t\tObject.defineProperty(this, "all", {\n' +
276
+ "\t\t\tvalue: (entries: unknown, allOptions?: { readonly signal?: PlatformSignal; readonly settle?: boolean }) =>\n" +
277
+ "\t\t\t\tall(entries, allOptions),\n" +
278
+ "\t\t});\n" +
234
279
  accessRuntime +
235
280
  "\t}\n" +
236
281
  "}\n" +
@@ -1,16 +1,18 @@
1
1
  import { AvClientConfigError } from "../config/config.resolver.js";
2
2
  import { accessVerbsOf, emitAccessModules } from "./access.emitter.js";
3
3
  import { AVCLIENT_REGENERATE, withGeneratedBanner, } from "./banner.emitter.js";
4
+ import { emitCachedReadModules } from "./cached-read.emitter.js";
4
5
  import { emitClientSurfaceModule } from "./client-surface.emitter.js";
5
6
  import { emitContractCarrierModule } from "./contract-carrier.emitter.js";
6
7
  import { ACCESS_DERIVATION_ROOTS, DERIVATION_ROOTS, emitDerivationModules, } from "./derivation.emitter.js";
7
- import { emitDescriptorModule } from "./descriptor.emitter.js";
8
+ import { contractHasCachedReads, emitDescriptorModule, } from "./descriptor.emitter.js";
8
9
  import { CLIENT_ENTRY_FILE, GENERATED_DIRECTORY, } from "./emitted-tree.interface.js";
9
10
  import { emitEnumsModule } from "./enum.emitter.js";
10
11
  import { emitFieldCheckModules } from "./field-check.emitter.js";
11
12
  import { moduleSpecifierWriter, } from "./module-style.interface.js";
12
13
  import { deriveEmittedNames } from "./name.deriver.js";
13
14
  import { emitNamedTypesModule } from "./named-type.emitter.js";
15
+ import { emitOperationSetModule } from "./operation-set.emitter.js";
14
16
  import { emitDecimalModule, emitErrorsModule, emitMetadataModule, emitTransportModule, errorsModuleExports, } from "./runtime.emitter.js";
15
17
  import { emitScalarCodecModule } from "./scalar.codec.js";
16
18
  import { emitTransactionModules } from "./transaction.emitter.js";
@@ -31,6 +33,8 @@ export function emitClientTree(contract, entrypoint, style, regenerate = AVCLIEN
31
33
  emitDecimalModule(),
32
34
  emitErrorsModule(regenerate),
33
35
  emitTransportModule(style, contract.access !== undefined),
36
+ emitOperationSetModule(style, contractHasCachedReads(contract)),
37
+ ...(contractHasCachedReads(contract) ? emitCachedReadModules(style) : []),
34
38
  ...emitAccessModules(contract, style),
35
39
  emitNamedTypesModule(contract, names, style),
36
40
  ...(contract.transactions === "interactive"
@@ -74,6 +78,7 @@ export function clientEntryReExports(style, clientClass = "value", accessVerbs =
74
78
  `export type * from ${from("types.d.ts")};\n` +
75
79
  `export * from ${from("enums.ts")};\n` +
76
80
  `export { AvDecimal } from ${from("runtime/decimal.ts")};\n` +
81
+ `export { avLazy } from ${from("runtime/operation-set.ts")};\n` +
77
82
  `export {\n${errorsModuleExports()
78
83
  .map((name) => `\t${name},\n`)
79
84
  .join("")}} from ${from("runtime/errors.ts")};\n` +
@@ -2,3 +2,5 @@ import type { AvContractClient } from "@aventara/core";
2
2
  import type { EmittedModule } from "./emitted-tree.interface.js";
3
3
  /** `generated/runtime/descriptor.ts`, before the banner. */
4
4
  export declare function emitDescriptorModule(contract: AvContractClient): EmittedModule;
5
+ /** Whether the ClientContract marks any read `cache`: a client that sends GETs. */
6
+ export declare function contractHasCachedReads(contract: AvContractClient): boolean;
@@ -51,9 +51,36 @@ export function emitDescriptorModule(contract) {
51
51
  "\tresource: string,\n" +
52
52
  "\tfamily: string,\n" +
53
53
  "\tvariant: string,\n" +
54
- `])[] = [${advertised.length === 0 ? "" : `\n${advertised.join("")}`}];\n`,
54
+ `])[] = [${advertised.length === 0 ? "" : `\n${advertised.join("")}`}];\n` +
55
+ "\n" +
56
+ "/** The ClientContract's limits a group of calls is split by: entries per Operation Set, and request bytes. */\n" +
57
+ "export const OPERATION_SET_LIMITS: { readonly maxAll: number; readonly maxRequestBytes: number } = {\n" +
58
+ `\tmaxAll: ${JSON.stringify(contract.limits.maxAll)},\n` +
59
+ `\tmaxRequestBytes: ${JSON.stringify(contract.limits.maxRequestBytes)},\n` +
60
+ "};\n" +
61
+ cachedReadsSource(contract),
55
62
  };
56
63
  }
64
+ function cachedReadsSource(contract) {
65
+ const entries = codeUnitOrder(Object.keys(contract.resources)).flatMap((name) => {
66
+ const variants = codeUnitOrder(Object.keys(contract.resources[name]?.cache ?? {}));
67
+ return variants.length === 0
68
+ ? []
69
+ : [
70
+ `\t${ownPropertyKey(name)}: [${variants.map((variant) => JSON.stringify(variant)).join(", ")}],\n`,
71
+ ];
72
+ });
73
+ return entries.length === 0
74
+ ? ""
75
+ : "\n" +
76
+ "/** Per Resource, the find variants served as a cached read: sent as a GET. */\n" +
77
+ "export const CACHED_READS: { readonly [resource: string]: readonly string[] } = {\n" +
78
+ entries.join("") +
79
+ "};\n";
80
+ }
81
+ export function contractHasCachedReads(contract) {
82
+ return Object.values(contract.resources).some((resource) => Object.keys(resource.cache ?? {}).length > 0);
83
+ }
57
84
  function decodingOf(field) {
58
85
  if (field === undefined) {
59
86
  return undefined;
@@ -8,7 +8,7 @@ export declare const LEGACY_CLIENT_ENTRY_FILES: readonly string[];
8
8
  /** The directory holding every other emitted module, under `generateAt`. */
9
9
  export declare const GENERATED_DIRECTORY = "generated";
10
10
  export type DerivationModulePath = `derivation/${string}.d.ts`;
11
- export type GeneratedModulePath = DerivationModulePath | "client.ts" | "contract.ts" | "enums.ts" | "metadata.ts" | "runtime/access.ts" | "runtime/codec.ts" | "runtime/decimal.ts" | "runtime/descriptor.ts" | "runtime/errors.ts" | "runtime/field-check.evaluator.ts" | "runtime/field-validators.ts" | "runtime/fingerprint.ts" | "runtime/transaction.ts" | "runtime/transport.ts" | "types.d.ts";
11
+ export type GeneratedModulePath = DerivationModulePath | "client.ts" | "contract.ts" | "enums.ts" | "metadata.ts" | "runtime/access.ts" | "runtime/cached-read.ts" | "runtime/canonical-json.ts" | "runtime/codec.ts" | "runtime/decimal.ts" | "runtime/descriptor.ts" | "runtime/errors.ts" | "runtime/field-check.evaluator.ts" | "runtime/field-validators.ts" | "runtime/fingerprint.ts" | "runtime/operation-set.ts" | "runtime/transaction.ts" | "runtime/transport.ts" | "types.d.ts";
12
12
  export declare const CONTRACT_CARRIER_MODULE = "contract.ts";
13
13
  /** A path in the emitted tree: relative to `generateAt`, POSIX-separated. */
14
14
  export type EmittedFilePath = typeof CLIENT_ENTRY_FILE | `${typeof GENERATED_DIRECTORY}/${GeneratedModulePath}`;
@@ -20,6 +20,7 @@ export const RESERVED_EMITTED_NAMES = [
20
20
  "AvValidationCode",
21
21
  "AvValidationIssue",
22
22
  "avClient",
23
+ "avLazy",
23
24
  ...FRAMEWORK_ERROR_CLASS_NAMES,
24
25
  ]),
25
26
  ].sort();
@@ -54,6 +55,7 @@ export const CLIENT_MEMBER_NAMES = [
54
55
  "__lookupGetter__",
55
56
  "__lookupSetter__",
56
57
  "__proto__",
58
+ "all",
57
59
  "constructor",
58
60
  "hasOwnProperty",
59
61
  "isPrototypeOf",
@@ -0,0 +1,3 @@
1
+ import type { EmittedModule } from "./emitted-tree.interface.js";
2
+ import { type ClientModuleStyle } from "./module-style.interface.js";
3
+ export declare function emitOperationSetModule(style: ClientModuleStyle, cached?: boolean): EmittedModule;