@aventara/client 1.0.0-rc.11 → 1.0.0-rc.13
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 +87 -0
- package/README.md +60 -10
- package/dist/cli/command.parser.d.ts +6 -2
- package/dist/cli/command.parser.js +20 -1
- package/dist/cli.js +17 -2
- package/dist/config/config.resolver.js +1 -1
- package/dist/emit/access.emitter.js +2 -2
- package/dist/emit/client-surface.emitter.js +81 -13
- package/dist/emit/client-tree.emitter.js +3 -3
- package/dist/emit/name.deriver.js +2 -1
- package/dist/emit/operation-set.emitter.d.ts +1 -1
- package/dist/emit/operation-set.emitter.js +346 -29
- package/dist/emit/runtime.emitter.js +171 -2
- package/dist/init/client-init.orchestrator.js +13 -1
- package/dist/skill/client-skill.generated.d.ts +2 -0
- package/dist/skill/client-skill.generated.js +4 -0
- package/dist/skill/client-skill.writer.d.ts +16 -0
- package/dist/skill/client-skill.writer.js +63 -0
- package/package.json +4 -3
|
@@ -252,6 +252,15 @@ const IDENTITY_HEADERS = (() => {
|
|
|
252
252
|
requestId: requestId.slice(prefix.length),
|
|
253
253
|
};
|
|
254
254
|
})();
|
|
255
|
+
const OWNED_HEADER_MEMBERS = [
|
|
256
|
+
"Content-Type",
|
|
257
|
+
"Accept",
|
|
258
|
+
AvProtocol.headers.protocolVersion,
|
|
259
|
+
AvProtocol.headers.contractHash,
|
|
260
|
+
AvProtocol.headers.requestId,
|
|
261
|
+
]
|
|
262
|
+
.flatMap((name) => [name, name.toLowerCase()].map((key) => `\t\treadonly ${JSON.stringify(key)}?: ${JSON.stringify(`Error: avClient sets the ${name} header itself; onCall cannot set it`)};\n`))
|
|
263
|
+
.join("");
|
|
255
264
|
function transportModuleSource(from) {
|
|
256
265
|
return `import { CLIENT_CONTRACT_HASH, PROTOCOL_VERSION } from ${from("../metadata")};
|
|
257
266
|
import { decodeResult, encodeArguments, readArgument, serializeWireBody, unsentValues } from ${from("./codec")};
|
|
@@ -317,6 +326,21 @@ export interface FetchInit {
|
|
|
317
326
|
/** The JSON body of a POST; a GET has none. */
|
|
318
327
|
readonly body?: string;
|
|
319
328
|
readonly signal?: PlatformSignal;
|
|
329
|
+
/** The members below are sent only when onCall added them. */
|
|
330
|
+
readonly credentials?: "omit" | "same-origin" | "include";
|
|
331
|
+
readonly cache?: "default" | "no-store" | "reload" | "no-cache" | "force-cache" | "only-if-cached";
|
|
332
|
+
readonly keepalive?: boolean;
|
|
333
|
+
readonly priority?: "high" | "low" | "auto";
|
|
334
|
+
readonly referrerPolicy?:
|
|
335
|
+
| ""
|
|
336
|
+
| "no-referrer"
|
|
337
|
+
| "no-referrer-when-downgrade"
|
|
338
|
+
| "origin"
|
|
339
|
+
| "origin-when-cross-origin"
|
|
340
|
+
| "same-origin"
|
|
341
|
+
| "strict-origin"
|
|
342
|
+
| "strict-origin-when-cross-origin"
|
|
343
|
+
| "unsafe-url";
|
|
320
344
|
}
|
|
321
345
|
|
|
322
346
|
/** What the transport reads of a fetch Response. */
|
|
@@ -358,6 +382,146 @@ export interface TransportConnection {
|
|
|
358
382
|
/** The framework entrypoint, an absolute URL without a trailing slash. */
|
|
359
383
|
readonly entrypoint: string;
|
|
360
384
|
readonly fetch: AvFetch;
|
|
385
|
+
/** What onCall added to the call (or the calls) this request sends: none when absent. */
|
|
386
|
+
readonly additions?: CallAdditions;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* What \`onCall\` may add to a call's request: headers, and five fetch members.
|
|
391
|
+
* avClient's own headers always win over these, and so does a call's own
|
|
392
|
+
* \`headers\` option. Each member a call cannot take is typed as the sentence that
|
|
393
|
+
* refuses it.
|
|
394
|
+
*/
|
|
395
|
+
export interface AvCallAdditions {
|
|
396
|
+
/** Headers to send with the call. avClient's own are its to set, in any case. */
|
|
397
|
+
readonly headers?: { readonly [name: string]: string } & {
|
|
398
|
+
${OWNED_HEADER_MEMBERS}
|
|
399
|
+
};
|
|
400
|
+
readonly credentials?: FetchInit["credentials"];
|
|
401
|
+
readonly cache?: FetchInit["cache"];
|
|
402
|
+
readonly keepalive?: FetchInit["keepalive"];
|
|
403
|
+
readonly priority?: FetchInit["priority"];
|
|
404
|
+
readonly referrerPolicy?: FetchInit["referrerPolicy"];
|
|
405
|
+
/** avClient sends every call with the method its protocol gives it. */
|
|
406
|
+
readonly method?: "Error: onCall cannot set method: avClient sends each call with its protocol's own";
|
|
407
|
+
/** avClient writes every request's body from the call. */
|
|
408
|
+
readonly body?: "Error: onCall cannot set body: avClient writes it from the call";
|
|
409
|
+
/** A call's signal is its own option. */
|
|
410
|
+
readonly signal?: "Error: onCall cannot set signal: pass it in the call's options";
|
|
411
|
+
/** avClient reads every answer where it arrives. */
|
|
412
|
+
readonly redirect?: "Error: onCall cannot set redirect";
|
|
413
|
+
/** The platform's default mode stands. */
|
|
414
|
+
readonly mode?: "Error: onCall cannot set mode";
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** What onCall added to one call, read once: its headers (none of avClient's own) and its fetch members. */
|
|
418
|
+
export interface CallAdditions {
|
|
419
|
+
readonly headers: Readonly<Record<string, string>>;
|
|
420
|
+
/** Its fetch members, each one of FetchInit's. */
|
|
421
|
+
readonly init: Readonly<Record<string, unknown>>;
|
|
422
|
+
/** The same for two calls whose additions are equal, whatever their order: the calls of one Operation Set share it. */
|
|
423
|
+
readonly key: string;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** The fetch members onCall may add, in the order their key compares them. */
|
|
427
|
+
const ADDED_MEMBERS = ["cache", "credentials", "keepalive", "priority", "referrerPolicy"] as const;
|
|
428
|
+
|
|
429
|
+
/** A call onCall added nothing to. */
|
|
430
|
+
export const NO_ADDITIONS: CallAdditions = { headers: {}, init: {}, key: "" };
|
|
431
|
+
|
|
432
|
+
/** The headers avClient sets itself, lower-cased: an addition never sets one. */
|
|
433
|
+
const OWNED_HEADERS: ReadonlySet<string> = new Set(
|
|
434
|
+
["Content-Type", "Accept", ...Object.keys(FRAMEWORK_HEADERS), REQUEST_ID_HEADER].map((name) => name.toLowerCase()),
|
|
435
|
+
);
|
|
436
|
+
|
|
437
|
+
/** Sets a header, replacing one of the same name in another case. */
|
|
438
|
+
function setHeader(headers: Record<string, string>, name: string, value: string): void {
|
|
439
|
+
for (const present of Object.keys(headers)) {
|
|
440
|
+
if (present.toLowerCase() === name.toLowerCase()) {
|
|
441
|
+
delete headers[present];
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
headers[name] = value;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** A header name a request can carry: an HTTP token. */
|
|
448
|
+
const HEADER_NAME = /^[!#$%&'*+.^_\`|~0-9A-Za-z-]+$/;
|
|
449
|
+
|
|
450
|
+
/** What no header value a request carries may hold: anything but a tab and Latin-1's printable characters. */
|
|
451
|
+
const HEADER_BREAK = /[^\\t\\x20-\\x7e\\x80-\\xff]/;
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* What onCall returned, read as additions: its headers and the fetch members
|
|
455
|
+
* it may add; nothing for a value that is not an object, and every other
|
|
456
|
+
* member dropped. A header avClient sets itself (in any case), a name that is
|
|
457
|
+
* not a token and a value no request can carry (a line break, a control
|
|
458
|
+
* character, or a character outside Latin-1) are dropped, each handed to
|
|
459
|
+
* \`drop\` with why.
|
|
460
|
+
*/
|
|
461
|
+
export function readAdditions(value: unknown, drop: (name: string, reason: string) => void): CallAdditions {
|
|
462
|
+
if (!isRecord(value)) {
|
|
463
|
+
return NO_ADDITIONS;
|
|
464
|
+
}
|
|
465
|
+
const headers: Record<string, string> = {};
|
|
466
|
+
const given = value["headers"];
|
|
467
|
+
if (isRecord(given)) {
|
|
468
|
+
for (const name of Object.keys(given)) {
|
|
469
|
+
const header = given[name];
|
|
470
|
+
if (header === undefined) {
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
const reason = OWNED_HEADERS.has(name.toLowerCase())
|
|
474
|
+
? "avClient sets itself"
|
|
475
|
+
: !HEADER_NAME.test(name)
|
|
476
|
+
? "is not a valid header name"
|
|
477
|
+
: typeof header !== "string" || HEADER_BREAK.test(header)
|
|
478
|
+
? "has a value no request can carry"
|
|
479
|
+
: undefined;
|
|
480
|
+
if (reason === undefined) {
|
|
481
|
+
setHeader(headers, name, header as string);
|
|
482
|
+
} else {
|
|
483
|
+
drop(name, reason);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
const init: Record<string, unknown> = {};
|
|
488
|
+
let key = "";
|
|
489
|
+
for (const name of Object.keys(headers).sort(byLowerCase)) {
|
|
490
|
+
key += "h" + JSON.stringify([name.toLowerCase(), headers[name]]);
|
|
491
|
+
}
|
|
492
|
+
for (const member of ADDED_MEMBERS) {
|
|
493
|
+
if (value[member] !== undefined) {
|
|
494
|
+
init[member] = value[member];
|
|
495
|
+
key += "m" + JSON.stringify([member, value[member]]);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
return { headers, init, key };
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** Orders header names as their lower case does. */
|
|
502
|
+
function byLowerCase(left: string, right: string): number {
|
|
503
|
+
const a = left.toLowerCase();
|
|
504
|
+
const b = right.toLowerCase();
|
|
505
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* A request's init with the call's additions under it: the added fetch members
|
|
510
|
+
* where the request sets none of its own, and the added headers, each of the
|
|
511
|
+
* request's own headers replacing one of the same name in any case.
|
|
512
|
+
*/
|
|
513
|
+
export function withAdditions<I extends { readonly headers: Readonly<Record<string, string>> }>(
|
|
514
|
+
additions: CallAdditions | undefined,
|
|
515
|
+
init: I,
|
|
516
|
+
): I {
|
|
517
|
+
if (additions === undefined || additions.key === "") {
|
|
518
|
+
return init;
|
|
519
|
+
}
|
|
520
|
+
const headers: Record<string, string> = { ...additions.headers };
|
|
521
|
+
for (const [name, value] of Object.entries(init.headers)) {
|
|
522
|
+
setHeader(headers, name, value);
|
|
523
|
+
}
|
|
524
|
+
return { ...additions.init, ...init, headers };
|
|
361
525
|
}
|
|
362
526
|
|
|
363
527
|
/** Per-call options. Never serialized into the operation body. */
|
|
@@ -451,6 +615,11 @@ export interface PreparedOperation {
|
|
|
451
615
|
readonly body: string;
|
|
452
616
|
}
|
|
453
617
|
|
|
618
|
+
/** How a failure, a warning and a hook name one operation: \`"Post" find.many\`. */
|
|
619
|
+
export function labelOf(resource: string, family: string, variant: string): string {
|
|
620
|
+
return JSON.stringify(resource) + " " + family + "." + variant;
|
|
621
|
+
}
|
|
622
|
+
|
|
454
623
|
/**
|
|
455
624
|
* Checks and encodes one operation call, sending nothing: the checks
|
|
456
625
|
* {@link execute} runs before it sends, in its order, and with its errors.
|
|
@@ -465,7 +634,7 @@ export function prepareOperation(
|
|
|
465
634
|
): PreparedOperation {
|
|
466
635
|
refuseUnsentValues(resource, family, variant, args, ["arguments"]);
|
|
467
636
|
check?.(resource, family, args);
|
|
468
|
-
const label =
|
|
637
|
+
const label = labelOf(resource, family, variant);
|
|
469
638
|
const callerHeaders = callerHeadersOf(options);
|
|
470
639
|
const headers = { ...FRAMEWORK_HEADERS, ...callerHeaders };
|
|
471
640
|
const body = serializeWireBody(encodeArguments(argumentObject(args)));
|
|
@@ -612,7 +781,7 @@ async function send(
|
|
|
612
781
|
// Called unbound: a browser's fetch refuses a this that is not the global.
|
|
613
782
|
const fetch = connection.fetch as StructuralFetch;
|
|
614
783
|
try {
|
|
615
|
-
return await fetch(url, init);
|
|
784
|
+
return await fetch(url, withAdditions(connection.additions, init));
|
|
616
785
|
} catch (error) {
|
|
617
786
|
if (signal?.aborted === true) {
|
|
618
787
|
throw error;
|
|
@@ -6,6 +6,7 @@ import { CLIENT_CONFIG_FILE } from "../config/config.loader.js";
|
|
|
6
6
|
import { resolveModuleStyle } from "../config/module-style.resolver.js";
|
|
7
7
|
import { AvContractTransportError } from "../contract/contract.fetcher.js";
|
|
8
8
|
import { CLIENT_ENTRY_FILE } from "../emit/emitted-tree.interface.js";
|
|
9
|
+
import { writeClientSkill } from "../skill/client-skill.writer.js";
|
|
9
10
|
import { AvClientInitNotConfirmedError, AvClientInitStepError, } from "./client-init.errors.js";
|
|
10
11
|
import { GENERATE_SCRIPT, planClientInit } from "./client-init.planner.js";
|
|
11
12
|
import { resolveClientInitAnswers } from "./client-init.questions.js";
|
|
@@ -67,7 +68,18 @@ export async function runClientInit(command, io) {
|
|
|
67
68
|
writeFileSync(target, write.content);
|
|
68
69
|
}
|
|
69
70
|
});
|
|
70
|
-
|
|
71
|
+
const skill = writeClientSkill({
|
|
72
|
+
directory: io.cwd,
|
|
73
|
+
clientVersion,
|
|
74
|
+
force: false,
|
|
75
|
+
});
|
|
76
|
+
for (const warning of skill.warnings) {
|
|
77
|
+
io.stderr(`avclient: warning: ${warning}.\n`);
|
|
78
|
+
}
|
|
79
|
+
io.stdout(`avclient: initialized ${io.cwd}: ${[
|
|
80
|
+
...writes.map((write) => write.content === undefined ? `removed ${write.path}` : write.path),
|
|
81
|
+
...skill.written,
|
|
82
|
+
].join(", ") || "nothing to change"}.\n`);
|
|
71
83
|
const install = `${answers.packageManager} install`;
|
|
72
84
|
if (command.skipInstall) {
|
|
73
85
|
progress.finish("avclient", "done");
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export const CLIENT_SKILL_FILES = {
|
|
2
|
+
"SKILL.md": "---\nname: aventara-client\ndescription: Call this frontend's Aventara API through the generated client (avclient generate, AvClient.ts; @/aventara/client in Next.js). Use when calling operations (find, create, update, delete, upsert), reading results (Date, bigint, AvDecimal) and errors (AvValidationError, cause.issues, AvTransportError), writing transactions (avClient.transaction, $ref), loading several calls at once (Promise.all, avClient.all, avLazy), aborting calls, reading cached reads, editing framework.client.ts (defineClientConfig, env, AVENTARA_API_URL, generateAt), or regenerating the client after the server changed (avclient generate, A2005, AvContractMismatchError).\n---\n# Calling the API\n\nHow frontend and server code call the operations: several calls in one round trip, aborting, `avLazy`, and how cached reads go out. The generated client is the default export of the file `avclient generate` writes (`api/AvClient.ts`, at the frontend's `generateAt`); in a Next.js app it is `@/aventara/client` in the browser and `@/aventara/server` on the server.\n\n- `reference/client-setup.md`: `framework.client.ts`, pointing the client at a server, regenerating it after the server changed, CI, and the stale-client error (`A2005`).\n\nFor anything not covered here, read the docs, which are Markdown for agents: `https://aventara.razor34.dev/framework/llms.txt` lists every page, `https://aventara.razor34.dev/framework/markdown/<slug>` is one page (`…/markdown/error-codes` explains every code).\n\n## The query language is not Prisma's\n\nThe types catch each of these; knowing them saves a round per guess.\n\n| Prisma | Aventara |\n|---|---|\n| `select: { id: true, title: true }` | `select: [\"id\", \"title\"]`; a relation is `include: { author: { select: [\"name\"] } }` |\n| `take`, `skip` | `limit`, `offset`; `cursor: { id }` needs an explicit `orderBy` (`V1009` without one) |\n| `_count` | `\"$count\"` in a `select` list, or `find.count({ where })` |\n| `connect`, `create`, `set` (relations) | `$connect`, `$create`, `$connectOrCreate`, `$disconnect`, `$set`, `$update`, `$delete`, `$upsert` |\n| `increment`, `push` | `{ $increment: 1 }`, `$decrement`, `$multiply`, `$divide`, `$push` |\n| `undefined` in a `where` (ignored) | refused (`V1001`): leave the key out |\n| `findMany`, `update` | `find.many`, `update.unique`; `update.first` writes one row a `where` matches |\n\n## What a call returns\n\nA call resolves to the data: the record, the list or the count. `find.first`, `update.first` and `delete.first` resolve `null` when no row matches; a `unique` operation that matches no row throws `AvNotFoundError` (`A2003`).\n\n| Prisma type | In arguments and results |\n|---|---|\n| `DateTime` | `Date` |\n| `BigInt` | `bigint` |\n| `Decimal` | `AvDecimal`, from the generated client: `new AvDecimal(\"12.50\")`, read with `toString()`; it does no arithmetic |\n| `Bytes` | `Uint8Array` |\n| `Json` | the JSON value as it is |\n\nThe client converts to and from the strings on the wire for you. To type a component's props, import the generated types: each Resource's (`Post`, `PostWhere`, `PostCreateData`, …, each only when the operation it belongs to is exposed) and each enum, as a type and an `as const` object.\n\n## Errors\n\nEvery outcome that is not a success throws. Branch on the class or on `error.code`, never on the message. Server code (`framework.application`, `ctx` calls) throws the same classes from `@aventara/core`.\n\n| Class | Codes | When |\n|---|---|---|\n| `AvValidationError` | `A2004`, `A2007`, `A2009` | The arguments break a rule, a transaction `$ref` is wrong, or a limit is crossed. |\n| `AvNotFoundError` | `A2003` | A `unique` operation matched no row. |\n| `AvConflictError` | `A2008`, `A2013`, `A2014` | A duplicate or a missing parent (`A2008`), or the row changed under the write (`A2013`, `A2014`: read again and retry). |\n| `AvAuthError` | `A4000`, `A4001`, `A4002` | Sign in first (`A4000`), or not allowed (`A4001`, `A4002`). |\n| `AvContractMismatchError` | `A2005` | The client is stale: regenerate it (`reference/client-setup.md`). |\n| `AvProtocolError` | `A2000`, `A2001`, `A2002`, `A2006`, `A2010`, `A2011`, `A2012` | A request the server cannot read or route. |\n| `AvInternalError` | `A3000`, `A3001`, `A3002`, `A3003`, `A3004` | The server failed; the real error is in its log. |\n| `AvTransportError` | none | No Aventara answer arrived: the network failed, or a proxy answered with its own page. `error.status` is the HTTP status, or `null`. |\n\n`AvError` is the base class of every class in the table but `AvTransportError`. A `$access` call (Aventara Access) may also throw `AvAccessError`, which is not an `AvError`.\n\nAn `AvValidationError` lists each problem in `error.cause.issues`: `{ path, code, message }`, plus `check` and `params` for a failed field validator (`check: \"max_length\"`, `params: { maximum: 80 }`). `path` is where in the call (`[\"arguments\", \"data\", \"email\"]`), so a form can show `message` beside the field `path` ends with. The client runs the server's field validators before sending: a value they refuse throws this same error, and nothing is sent.\n\n```ts\nimport avClient, { AvValidationError } from \"./api/AvClient\";\n\ntry {\n await avClient.User.create.one({ data: { email: \"not-an-email\" } });\n} catch (error) {\n if (!(error instanceof AvValidationError)) throw error;\n for (const issue of error.cause.issues ?? []) console.log(issue.path?.at(-1), issue.message);\n}\n```\n\n## Calls made together share one request\n\nCalls made in the same tick (before the microtask queue settles) go out as one `POST <api>/_all`, an Operation Set. Each call still resolves or throws on its own. Write nothing for it: no batching endpoint, no loader.\n\n```ts\nimport avClient from \"./api/AvClient\";\n\nconst [posts, published] = await Promise.all([\n avClient.Post.find.many({ where: { published: true }, select: [\"id\", \"title\"] }),\n avClient.Post.find.count({ where: { published: true } }),\n]); // one POST /api/_all\n```\n\n- Each call runs exactly as alone: validation, exposure, policy, limits and pipelines are those of its own route, and so is its answer.\n- Not atomic and in no order: the calls run concurrently, and a write that ran stays written whatever the others answered. Writes that must hold together, or a call that needs another's result, belong in a transaction (`avClient.transaction([avClient.tx.Post.create.one(…), …])`, `POST /_transactions`).\n- Reads and writes mix. A group larger than `limits.maxAll` (10) or `limits.maxRequestBytes` is split by the client.\n- Always sent alone: a call with `headers` or `requestId` in its options, a call or client with `grouping: false`, a transaction, and a cached read (a `GET`). Headers every call needs (a token) go in `onCall`, set once, `new AvClient({ onCall })`, so the calls still group (see \"Hooks\" below); calls whose `onCall` additions differ never share a request.\n- A server older than rc.11 answers `/_all` with `A2002`; the client resends the calls one by one and stops grouping.\n\n## Transactions\n\nWrites that must commit together, or a step that uses an earlier step's result, go in one transaction:\n\n```ts\nimport avClient from \"./api/AvClient\";\n\nconst author = avClient.tx.User.create.one({ data: { email: \"ada@example.com\" }, select: [\"id\"] });\nconst post = avClient.tx.Post.create.one({ data: { title: \"Hello\", authorId: author.$ref(\"id\") }, select: [\"id\"] });\nconst [user, created] = await avClient.transaction([author, post]); // one result per step, typed, in order\n```\n\n- `avClient.tx.<Resource>.<family>.<variant>(args)` describes a step and sends nothing. `avClient.transaction([...])` sends the steps once, as one `POST <api>/_transactions`; they all commit or none does.\n- `step.$ref(\"field\")` stands for that field of an earlier step's result, on a step whose result is one row (`create.one`, `find.unique`, `update.unique`, …). The field must be in that step's result: put it in its `select` (a field the result does not carry is refused, `A2007` / `V1010`). Write it in `data` at any depth (a field, a `$connect`), in a `where` or in a `cursor`, never in a projection's nested `where` or `orderBy`. Its type must fit where it is written; a mismatch is a type error.\n- A failing step rolls every step back and throws its error, with `error.cause.operation` the failing step's index.\n- The client has no `tx` when the server's client layer sets `transactionsStyle: \"server\"` or `\"none\"`.\n- Server code: `framework.transaction([framework.appTx.…])`, the same `$ref`s. In Next.js, `avApp.appTx` with `avApp.transaction([...])`, or `avClient.tx` with `avClient.transaction([...])` from `@/aventara/server`, never mixed in one list.\n\n## Hooks: `onCall`, `onSuccess`, `onError`\n\nThree `AvClient` options see every call: operations, cached reads, a transaction (once), `$access` calls. `onCall` adds to the request; `onSuccess` and `onError` only watch.\n\n```ts\nimport { AvAuthError, AvClient } from \"./api/AvClient\";\n\nexport const authed = new AvClient({\n onCall: () => ({ headers: { Authorization: `Bearer ${localStorage.getItem(\"token\") ?? \"\"}` }, credentials: \"include\" }),\n onError: (_call, error) => {\n if (error instanceof AvAuthError) location.assign(\"/login\");\n },\n});\n```\n\n- `onCall(call)` runs when the call is made, before grouping, and may return or resolve to `{ headers, credentials, cache, keepalive, priority, referrerPolicy }`. Calls with equal additions share one `/_all`. An async one holds back only its own call: it is sent when its `onCall` settles, with the calls queued then, so calls group when their `onCall` settles together with equal additions. An `onCall` doing its own I/O per call (reading the token again each time) sends each call alone; to keep grouping, await one shared in-flight promise, cleared once it settles: `let inFlight; const token = () => (inFlight ??= readToken().finally(() => { inFlight = undefined; }));`. If `token()` rejects, the call is sent without `Authorization` (the hook warning fires) and will likely reach `onError` as a refusal. A hanging `onCall` stalls only its own call, and an abort settles that call at once. A call made inside a hook never waits on the call that ran it.\n- Order: `onCall`'s headers, then the call's own `headers` option (wins), then the client's own (always win). `Content-Type`, `Accept` and the `Aventara-*` headers (canonical or lower-case), and `method`, `body`, `signal`, `redirect`, `mode`, are type errors in `onCall`; at runtime those headers in any case, invalid names and values with a line break, a control character, or a character outside Latin-1 are dropped with a warning (`AVENTARA_CLIENT_HOOK_HEADER_DROPPED`), once per header name for each `onCall`.\n- `call` is `{ kind, label, resource, family, variant, requestId? }` for an operation (`kind: \"transaction\"` or `\"access\"` with `verb` otherwise); never the arguments. To type a hook declared on its own, import `AvCall` and `AvCallAdditions` from the generated client.\n- `onSuccess(call, result)` and `onError(call, error)` are sinks returning `void` (any function fits, async too): side effects only, what they return is ignored and never awaited, so they never delay the caller, and they cannot swallow, change or retry a call. `onError` sees refusals, validation errors, transport failures (`AvTransportError`) and aborts, grouped or alone.\n- A hook that throws or rejects is reported once as a warning (`AVENTARA_CLIENT_HOOK_THREW`) and skipped; the call goes on, with no additions when it was `onCall`.\n- `fetch` only swaps the transport (tests, another runtime's fetch): it must return the `Response` unread. Do not put headers or a token in it. An entrypoint URL carrying credentials (`https://user:pass@…`) is refused by `avclient`: add them through `onCall`.\n- Next.js: put the hooks in `src/aventara/client-options.ts` (or `.js`; no other extension) as `export default { onCall, onSuccess, onError } satisfies Pick<AvClientOptions, \"onCall\" | \"onSuccess\" | \"onError\">` (type from `@/aventara/client`), then run `aventara:prepare` (it runs `aventara-build --client`). Both `@/aventara/client` and `@/aventara/universal` use them, reading the default export when a hook runs, so the file may value-import from `@/aventara/client` (an error class); `fetch`, `grouping` and `entrypoint` there are type errors. A file with no default export leaves the client without hooks, with one warning (`AVENTARA_CLIENT_OPTIONS_NO_DEFAULT`). The file is bundled into the browser: no secrets or server-only imports; branch on `typeof window`. On the server, onCall's headers reach the in-process request but not `ctx.$state`, which comes from the request being served. `@/aventara/server` is not an AvClient and takes no hooks.\n\n## Calls run when you make them\n\nA call is checked, encoded and queued when it is made, as `fetch` is: a call never awaited still runs, and awaiting it twice sends it once. To describe a call now and make it later, wrap it in `avLazy`:\n\n```ts\nimport avClient, { avLazy } from \"./api/AvClient\";\n\nconst drafts = avLazy(() => avClient.Post.find.many({ where: { published: false }, select: [\"id\"] })); // nothing sent\nconst list = await drafts; // sent now\n```\n\n| Where `avLazy` comes from | Awaited again |\n|---|---|\n| The generated client (browser) | Keeps its one answer. |\n| `@aventara/core` (server code) | Calls again each time; it keeps no answer, so never hold one in module scope expecting a cache. |\n| Next.js `@/aventara/universal` on the server | Keeps its answer within one request's React render only. |\n\n## `avClient.all`\n\nAn object of calls in, one typed value per key out. The keys are yours and never sent.\n\n```ts\nimport avClient, { AvNotFoundError } from \"./api/AvClient\";\n\n// Strict (default): the first failure rejects `all` with that error and aborts its other calls.\nconst { list, total } = await avClient.all({\n list: avClient.Post.find.many({ select: [\"id\", \"title\"] }),\n total: avClient.Post.find.count({}),\n});\n\n// settle: true: every key settles on its own; `all` never rejects for one key.\nconst panels = await avClient.all(\n {\n posts: avClient.Post.find.many({ select: [\"id\", \"title\"] }),\n me: avClient.User.find.unique({ where: { email: \"ada@example.com\" }, select: [\"id\", \"email\"] }),\n },\n { settle: true },\n);\nif (panels.me.status === \"rejected\") {\n if (panels.me.reason instanceof AvNotFoundError) console.log(\"no such user\");\n} else {\n console.log(panels.me.value.email, list.length, total);\n}\n```\n\nStrict is fail-fast, not atomic: a write that already ran stays written. Use strict when the page needs every value, `settle: true` when each panel renders or fails alone. Every entry must be a call (or an `avLazy`); a plain value does not compile.\n\n## Aborting\n\nEach call has its own abort: `{ signal }` in a call's options aborts that call only; `avClient.all({ … }, { signal })` aborts every call of that `all`, and a call inside it stops on whichever signal fires first.\n\n```ts\nimport avClient from \"./api/AvClient\";\n\nconst page = new AbortController();\nconst search = new AbortController();\n\nconst result = avClient.all(\n {\n hits: avClient.Post.find.many({ where: { title: { contains: \"rc\" } } }, { signal: search.signal }),\n count: avClient.Post.find.count({}),\n },\n { signal: page.signal },\n);\nsearch.abort(); // only `hits` stops; strict `all` then rejects with its AbortError\n```\n\n- Aborted before the request is sent: the call is dropped. After: it rejects at once; the other calls of the same request go on.\n- Calls grouped into the same request but not in that `all` are never touched. The HTTP request itself is aborted only when every call in it is.\n- A started database query still finishes on the server (no cancel); a started write is written.\n- Nothing aborts for you: no default timeout, no abort on navigation. Pass the signal your data library gives (TanStack Query's `queryFn` receives one: `queryFn: ({ signal }) => avClient.Post.find.many({}, { signal })`), or abort in an effect's cleanup.\n\n## Turning grouping off\n\n```ts\nimport avClient, { AvClient } from \"./api/AvClient\";\n\nawait avClient.Post.find.many({ select: [\"id\"] }, { grouping: false }); // this call, on its own route\nconst plain = new AvClient({ grouping: false }); // every call of this client\nawait plain.Post.find.count({});\n```\n\nOnly for a reason: debugging in the network tab, per-route monitoring or rate limits, or a proxy that lets through `/_resources/…` but not `/_all`. A transaction takes no `grouping` option.\n\n## On the server\n\nServer code has the same `all`: `framework.serverClient.all` (NestJS, any host) and `avClient.all` from `@/aventara/server` (Next.js). `framework.application` and `avApp` have no `all`.\n\n```tsx\n// src/app/dashboard/page.tsx\nimport { avLazy } from \"@aventara/core\";\nimport { avClient } from \"@/aventara/server\";\n\nexport default async function Dashboard() {\n const { posts, published } = await avClient.all({\n posts: avClient.Post.find.many({ select: [\"id\", \"title\"] }),\n published: avLazy(() => avClient.Post.find.count({ where: { published: true } })),\n });\n return <p>{posts.length} posts, {published} published</p>;\n}\n```\n\n- Nothing is grouped and nothing goes over HTTP: each call runs in process, in the request that made it, with that request's state and principal (`framework.serverClient` calls take `{ state }` like any server call).\n- Any number of entries: `limits.maxAll` guards `POST /_all` only.\n- A plain call has already started when made, so `all` can only await it. An `avLazy` entry is started by `all`: at most `limits.maxAllConcurrency` (4) at once, in key order, and none after a strict `all` failed or was aborted.\n- Next.js `@/aventara/universal` groups in the browser only; on the server each of its calls runs on its own.\n\n## The two limits\n\n| Limit | Default | Bounds |\n|---|---|---|\n| `limits.maxAll` | `10` | Operations in one `POST /_all`. More is refused whole: `422 A2009` / `V1014`. The generated client splits for you. |\n| `limits.maxAllConcurrency` | `4` | Operations of one set (HTTP or in process) running at once; the rest wait, in key order. At least `1`. |\n\nOrdinary limits: set them at the root, override them in `client` or `application` (`limits: { maxAll: 20 }`). Do not grow the database pool for `/_all`; the concurrency limit keeps one request to its share.\n\n## Cached reads from the client\n\nA read the server caches (its Resource's `cache` stage) is sent by the generated client as `GET <api>/_resources/<R>/find/<variant>?q=<args>&v=<contract hash>`, alone, never in an Operation Set. Leave `requestId` off a cached cross-origin read (it adds a CORS preflight). A URL over 4096 characters goes as a `POST` and is not cached. The `POST` route stays for any client.\n\n## By hand (curl, a custom client)\n\n`POST <api>/_all` takes the two identity headers and `{ \"operations\": { \"<key>\": { \"resource\": \"Post\", \"family\": \"find\", \"variant\": \"many\", \"args\": { … } } } }` (keys are identifiers; each entry is exactly those four members). It answers `200 A1000` with `data` holding each key's own envelope (`{ data, code, cause }`): read each key's `code`, not the status. With `Accept: application/x-ndjson` it streams one line per entry as it finishes, `{\"key\":\"…\",\"envelope\":{…}}`; the generated client does this for you, so write it only in a client of your own. Too many entries: `422 A2009`; none: `422 A2004`.\n\nA plan of your own, `POST <api>/_transactions` with `{ \"operations\": [ … ] }`, needs each step's `fingerprint`: compute it with `await AvProtocol.fingerprint({ resource, family, variant, args })` from `@aventara/core/protocol` over the step's wire-form `args`, the function the server checks it with. It is async: a step whose `fingerprint` is the unawaited promise serializes as `{}`, and a wrong fingerprint answers `422 A2007` / `V1017`. A layer with `transactionsStyle: \"server\"` or `\"none\"` has no such route (`404 A2002`).\n\n## Keeping this file current\n\nGenerated by `avclient` {{CLIENT_VERSION}}. After upgrading `@aventara/client`, refresh it: `npx avclient skill`. A copy you edited is kept (pass `--force` to replace it).\n",
|
|
3
|
+
"reference/client-setup.md": "# Setting up and regenerating the client\n\nA separate frontend calls the API through a client generated from the server's client contract. In a Next.js app with Aventara inside it, `aventara:prepare` writes the browser client (`@/aventara/client`) instead, and nothing on this page applies.\n\n## `framework.client.ts`\n\n`avclient init` writes it in the frontend's root, with the `avclient:generate` script and `@aventara/client` as a devDependency:\n\n```ts\n// framework.client.ts\nimport { defineClientConfig, env } from \"@aventara/client\";\n\nexport default defineClientConfig({\n entrypoint: env(\"AVENTARA_API_URL\"),\n generateAt: \"./src/api\",\n});\n```\n\n- `entrypoint` is the server's URL with its `api` path (`http://localhost:3000/api`), with no credentials in it. `env(\"NAME\")` reads the variable from the environment, then `.env.<mode>.local`, `.env.<mode>`, `.env.local` and `.env` (`mode` is `NODE_ENV`, else `development`). `avclient init` writes `AVENTARA_API_URL` into `.env`.\n- The generated client calls the `entrypoint` it was generated with. To build a client for staging or production, set `AVENTARA_API_URL` to that server and regenerate. `new AvClient({ entrypoint })` points one client at another deployment that serves the same contract (a test server, SSR).\n- `generateAt` is where the client goes: `avclient generate` owns `AvClient.ts` and `generated/` in it and touches nothing else there.\n- `tsconfigFile` names the tsconfig that compiles the client, only when it is not the nearest `tsconfig.json` above `generateAt` (Vite's `tsconfig.app.json`).\n- `accessOrigins`, only with Aventara Access on another origin than the entrypoint's: list that origin (`[\"https://auth.example.com\"]`). Without it `avclient generate` refuses with an error naming that origin and `accessOrigins`, because sign-in credentials would go there.\n\n## Regenerate after the server changes\n\nRun the frontend's `avclient:generate` script (it runs `avclient generate`) while the server runs: it reads the client contract from `entrypoint`. Regenerate after any change to the server's client contract: the schema, exposure, field validators, limits, operations, the client layer's `transactionsStyle`, a Json field's shape, a `cache` stage, or which fields compute fills.\n\nA client generated before such a change is refused on its next call with `409 A2005`, thrown as `AvContractMismatchError`. The fix is always the same: regenerate against the server the client calls. Never edit the generated files to make a call fit.\n\n- A failed run leaves the previous client as it was.\n- `avclient generate` lists anything in `AvClient.ts` or `generated/` it did not write and asks before replacing it; `--yes` answers yes. Where nobody can answer (stdin is not a terminal, as in CI), it refuses and changes nothing: pass `--yes` there.\n- Commit the generated client. Regenerating it needs a running server, so CI and teammates build from the committed copy.\n- The same contract always gives the same bytes.\n"
|
|
4
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** The client skill's directory in a frontend. */
|
|
2
|
+
export declare const CLIENT_SKILL_DIRECTORY = ".claude/skills/aventara-client";
|
|
3
|
+
/** What writing the client skill did. */
|
|
4
|
+
export type ClientSkillWrite = {
|
|
5
|
+
/** The files written, relative to the frontend. */
|
|
6
|
+
readonly written: readonly string[];
|
|
7
|
+
/** One sentence per edited file kept. */
|
|
8
|
+
readonly warnings: readonly string[];
|
|
9
|
+
};
|
|
10
|
+
/** Writes the client skill for `clientVersion` into the frontend at `directory`. */
|
|
11
|
+
export declare function writeClientSkill(input: {
|
|
12
|
+
readonly directory: string;
|
|
13
|
+
readonly clientVersion: string;
|
|
14
|
+
/** Replace files the developer edited. */
|
|
15
|
+
readonly force: boolean;
|
|
16
|
+
}): ClientSkillWrite;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { CLIENT_SKILL_FILES } from "./client-skill.generated.js";
|
|
5
|
+
export const CLIENT_SKILL_DIRECTORY = ".claude/skills/aventara-client";
|
|
6
|
+
const MANIFEST = `${CLIENT_SKILL_DIRECTORY}/.aventara-client-skill.json`;
|
|
7
|
+
const sha256 = (text) => createHash("sha256").update(text).digest("hex");
|
|
8
|
+
function read(file) {
|
|
9
|
+
try {
|
|
10
|
+
return readFileSync(file, "utf8");
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
function recordedHashes(directory) {
|
|
17
|
+
try {
|
|
18
|
+
const parsed = JSON.parse(read(path.join(directory, MANIFEST)) ?? "");
|
|
19
|
+
return typeof parsed.files === "object" && parsed.files !== null
|
|
20
|
+
? parsed.files
|
|
21
|
+
: {};
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return {};
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export function writeClientSkill(input) {
|
|
28
|
+
const previous = recordedHashes(input.directory);
|
|
29
|
+
const recorded = {};
|
|
30
|
+
const written = [];
|
|
31
|
+
const warnings = [];
|
|
32
|
+
for (const [file, template] of Object.entries(CLIENT_SKILL_FILES)) {
|
|
33
|
+
const content = template.replaceAll("{{CLIENT_VERSION}}", input.clientVersion);
|
|
34
|
+
const target = `${CLIENT_SKILL_DIRECTORY}/${file}`;
|
|
35
|
+
const absolute = path.join(input.directory, target);
|
|
36
|
+
const before = read(absolute);
|
|
37
|
+
if (before === content) {
|
|
38
|
+
recorded[file] = sha256(content);
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
const ours = before === undefined ||
|
|
42
|
+
(previous[file] !== undefined && sha256(before) === previous[file]);
|
|
43
|
+
if (!ours && !input.force) {
|
|
44
|
+
warnings.push(`${target} was changed since avclient wrote it, so it was kept; run \`avclient skill --force\` to replace it with this version's`);
|
|
45
|
+
const kept = previous[file];
|
|
46
|
+
if (kept !== undefined) {
|
|
47
|
+
recorded[file] = kept;
|
|
48
|
+
}
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
mkdirSync(path.dirname(absolute), { recursive: true });
|
|
52
|
+
writeFileSync(absolute, content);
|
|
53
|
+
written.push(target);
|
|
54
|
+
recorded[file] = sha256(content);
|
|
55
|
+
}
|
|
56
|
+
const manifest = `${JSON.stringify({ clientVersion: input.clientVersion, files: recorded }, null, 2)}\n`;
|
|
57
|
+
const manifestFile = path.join(input.directory, MANIFEST);
|
|
58
|
+
if (read(manifestFile) !== manifest) {
|
|
59
|
+
mkdirSync(path.dirname(manifestFile), { recursive: true });
|
|
60
|
+
writeFileSync(manifestFile, manifest);
|
|
61
|
+
}
|
|
62
|
+
return { written, warnings };
|
|
63
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aventara/client",
|
|
3
|
-
"version": "1.0.0-rc.
|
|
3
|
+
"version": "1.0.0-rc.13",
|
|
4
4
|
"license": "SEE LICENSE IN LICENSE",
|
|
5
5
|
"description": "Development-time generator for Aventara typed remote clients.",
|
|
6
6
|
"type": "module",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"CHANGELOG.md"
|
|
29
29
|
],
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@aventara/core": "1.0.0-rc.
|
|
31
|
+
"@aventara/core": "1.0.0-rc.13",
|
|
32
32
|
"c12": "3.3.4",
|
|
33
33
|
"get-tsconfig": "4.10.0"
|
|
34
34
|
},
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
"access": "public"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|
|
47
|
-
"@aventara/testing": "1.0.0-rc.
|
|
47
|
+
"@aventara/testing": "1.0.0-rc.13",
|
|
48
48
|
"@types/node": "24.10.1",
|
|
49
49
|
"typescript": "^5.9.2"
|
|
50
50
|
},
|
|
@@ -52,6 +52,7 @@
|
|
|
52
52
|
"build": "node ../../scripts/build/build-output.cleaner.ts dist && tsc -p tsconfig.json --removeComments --declaration false && tsc -p tsconfig.json --emitDeclarationOnly && node ../../scripts/build/declaration-comments.sanitizer.ts dist",
|
|
53
53
|
"typecheck": "tsc -p tsconfig.typecheck.json --noEmit",
|
|
54
54
|
"test": "vitest run --typecheck --config ../../vitest.config.mts --root .",
|
|
55
|
+
"test:published-docs": "node scripts/docs-pack.guard.ts && vitest run --config ../../vitest.config.mts --root . src/skill/client-skill.published-docs.spec.ts",
|
|
55
56
|
"lint": "biome lint src"
|
|
56
57
|
}
|
|
57
58
|
}
|