@aventara/client 1.0.0-rc.14 → 1.0.0-rc.15
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 +26 -0
- package/README.md +9 -0
- package/dist/emit/banner.emitter.d.ts +4 -1
- package/dist/emit/banner.emitter.js +2 -1
- package/dist/emit/client-surface.emitter.js +6 -6
- package/dist/emit/client-tree.emitter.d.ts +5 -2
- package/dist/emit/client-tree.emitter.js +9 -10
- package/dist/emit/contract-carrier.emitter.js +1 -1
- package/dist/emit/derivation.emitter.js +9 -1
- package/dist/emit/json-shape.emitter.d.ts +0 -9
- package/dist/emit/json-shape.emitter.js +1 -58
- package/dist/emit/named-type.emitter.js +1 -1
- package/dist/emit/runtime.emitter.js +3 -3
- package/dist/skill/client-skill.generated.js +2 -2
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
# @aventara/client
|
|
2
2
|
|
|
3
|
+
## 1.0.0-rc.15
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- aab6d72: The client `avclient generate` writes now passes a default `biome check` as written, on Biome 2.4.0 and later (Biome's default rules; an opt-in rule or assist such as `useSortedKeys` is not covered): every generated file opens with `biome-ignore-all` lines for formatting, lint and import order (`organizeImports`), core's copied declarations have a blank line after their imports, the copied field-check evaluator carries no line suppression that the file-wide one makes unused, and each file ends without a final line break, which Biome 2.4.0 to 2.4.10 reported on every file under `biome-ignore-all format`. `aventara-build`'s own files pass too, in NestJS and Next.js projects: `resources.generated.ts`, `computed-fields.generated.ts` and `contracts.generated.ts` now open with the same suppressions and end without a final line break, and `--client`'s `src/aventara/client.ts` and `src/aventara/universal.ts` are written the same way. The discovery artifact `aventara-prisma7-generate` writes passes too. A file an earlier version wrote is reported stale by `aventara-build --check` and replaced by the next run. A client `avclient generate` wrote before is replaced on its next run. The client skill now says the generated files need no exclusion under Biome's default rules, and how to skip them in another linter or formatter.
|
|
8
|
+
- bf98da7: A pipeline stage's types now know the root's field definitions. `aventara-build` writes `src/generated/aventara/config-facts.generated.ts` after it compiles the Contracts, and the discovery artifact puts it on the model's type:
|
|
9
|
+
|
|
10
|
+
- A Json field the root shapes reads as its shape in a stage's `ctx` calls, in `tx` and in compute's read-only `ctx`: `ctx.Product.find.many({ select: ["name"] })` types `name` as `{ ar: string; en: string }`. A read gives the shape's own type, the same as `framework.application`'s.
|
|
11
|
+
- A `VIRTUAL` field the root adds is in a variant's typed `ctx.$args` (`ctx.$args.data.tenderedMinor`), and a `ctx` create must write a non-null added input it can create.
|
|
12
|
+
- A variant's hooks and interceptors are told resolved arguments: their `ctx.$args` holds no `$ref` (an `AvTransactionReference`), since references are resolved before they run. Guards, pipes and filters still may see one.
|
|
13
|
+
- A write takes a shaped Json value read-only, deep, on every surface (`framework.application`, `ctx`, the generated client too), so a frozen value, such as a stage's `ctx.$args`, fits as well as a mutable one.
|
|
14
|
+
- A compute stage on a shaped Json field returns the shape's type. A compute stage on an added field returns the type its generated fact states, read from the model instead of the configuration. Without the generated file, its return is not typed.
|
|
15
|
+
|
|
16
|
+
The server warns at startup (`AVENTARA_STALE_GENERATED_TYPES`) when the configuration's definitions differ from the ones the generated types were written with, naming each field. It is the same one warning as for stale computed fields: one stale build gives one warning, with a paragraph for each part that is stale, including a facts file it cannot read. `aventara-build --check` also fails on a stale file. Run `aventara:prepare` after you change a `definition`.
|
|
17
|
+
|
|
18
|
+
The discovery artifact's format is now 5: regenerate it with `aventara:prepare` (an artifact of format 4 is refused, as before). The Json Shape type emitter the client generator uses now lives in `@aventara/core`; the generated clients are unchanged. The CLI's agent skill says this in `SKILL.md` and `reference/stages.md`.
|
|
19
|
+
|
|
20
|
+
- f8cd860: The transaction docs and skills now say where a `$ref` works with a Json field: as the field's whole value, from another Json field. Inside a Json value nothing is read as a reference, so a nested `$ref` is stored as the reference object (`{ "$ref": { "operation", "fingerprint", "path" } }`), unresolved. A Json value cannot have `$ref` as its own root key: it is read as a `$ref` directive and refused with `A2004` (`V1006`, or `V1001` for other shapes). To build Json from an earlier step's result, write the `$ref` into a `VIRTUAL` input field and build the Json in a compute stage. No behaviour changed.
|
|
21
|
+
- Updated dependencies [aab6d72]
|
|
22
|
+
- Updated dependencies [bf98da7]
|
|
23
|
+
- Updated dependencies [f8cd860]
|
|
24
|
+
- Updated dependencies [8a64af2]
|
|
25
|
+
- Updated dependencies [548881c]
|
|
26
|
+
- Updated dependencies [62fa91f]
|
|
27
|
+
- @aventara/core@1.0.0-rc.15
|
|
28
|
+
|
|
3
29
|
## 1.0.0-rc.14
|
|
4
30
|
|
|
5
31
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -264,6 +264,15 @@ Its source field must have the same type as where it is written:
|
|
|
264
264
|
|
|
265
265
|
A mismatch is a type error, and the server refuses it before anything runs (`A2007`/`V1011`).
|
|
266
266
|
|
|
267
|
+
A Json field takes a `$ref` only as its whole value, and only from another Json field; one from a field of another
|
|
268
|
+
type is refused (`A2007`/`V1011`). Inside a Json value nothing is read as a reference: a `$ref` nested in it is stored
|
|
269
|
+
as the reference object (`{ "$ref": { "operation", "fingerprint", "path" } }`), unresolved. The types do not catch
|
|
270
|
+
either case yet, so check it yourself. A Json value also cannot have `$ref` as its own root key, in any call:
|
|
271
|
+
`{ "$ref": "#/$defs/x" }` as the whole value is read as a `$ref` directive and refused with `A2004` (`V1006`, or
|
|
272
|
+
`V1001` for other shapes); one level down it is data. To put part of an earlier step's result into Json, add a
|
|
273
|
+
`VIRTUAL` input field on the server, write the `$ref` there, and build the Json in the Json field's `compute.create`
|
|
274
|
+
or `compute.update` stage: compute runs after references resolve, so it sees the value.
|
|
275
|
+
|
|
267
276
|
`avClient.tx.…` builds a handle and sends nothing. `avClient.transaction([...])` sends the plan once, when made, and resolves one
|
|
268
277
|
result per handle, typed, in order; a failing step throws its error with `cause.operation` naming the step. `$ref` binds
|
|
269
278
|
to the handle's position in the list it runs with. Two mistakes are refused before anything is sent, as core refuses
|
|
@@ -5,7 +5,10 @@ export type RegenerateCommand = `\`${string}\`${string}`;
|
|
|
5
5
|
export declare const AVCLIENT_REGENERATE: RegenerateCommand;
|
|
6
6
|
/** `avclient generate`'s banner. */
|
|
7
7
|
export declare const GENERATED_BANNER: readonly string[];
|
|
8
|
-
/**
|
|
8
|
+
/**
|
|
9
|
+
* `source` with the banner before it and one blank line between, and without
|
|
10
|
+
* the line break `source` ends with.
|
|
11
|
+
*/
|
|
9
12
|
export declare function withGeneratedBanner(source: string, regenerate?: RegenerateCommand): string;
|
|
10
13
|
/**
|
|
11
14
|
* How far into a file the ownership line is looked for. The banner opens every
|
|
@@ -4,13 +4,14 @@ function generatedBanner(regenerate = AVCLIENT_REGENERATE) {
|
|
|
4
4
|
return [
|
|
5
5
|
"// biome-ignore-all format: generated output",
|
|
6
6
|
"// biome-ignore-all lint: generated output",
|
|
7
|
+
"// biome-ignore-all assist/source/organizeImports: generated output",
|
|
7
8
|
GENERATED_OWNERSHIP_LINE,
|
|
8
9
|
`/* Regenerate with ${regenerate}. */`,
|
|
9
10
|
];
|
|
10
11
|
}
|
|
11
12
|
export const GENERATED_BANNER = generatedBanner();
|
|
12
13
|
export function withGeneratedBanner(source, regenerate = AVCLIENT_REGENERATE) {
|
|
13
|
-
return `${generatedBanner(regenerate).join("\n")}\n\n${source}`;
|
|
14
|
+
return `${generatedBanner(regenerate).join("\n")}\n\n${source.replace(/\n$/, "")}`;
|
|
14
15
|
}
|
|
15
16
|
export const GENERATED_OWNERSHIP_HEAD_BYTES = 1024;
|
|
16
17
|
export function carriesGeneratedOwnership(head) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { ACCESS_VERB_NAMES } from "@aventara/core/internal";
|
|
1
|
+
import { ACCESS_VERB_NAMES, jsonSchemaType } from "@aventara/core/internal";
|
|
2
2
|
import { accessVerbShape, accessVerbsOf, } from "./access.emitter.js";
|
|
3
3
|
import { contractHasFieldChecks } from "./field-check.emitter.js";
|
|
4
|
-
import {
|
|
4
|
+
import { shapedContractType } from "./json-shape.emitter.js";
|
|
5
5
|
import { importSpecifier, } from "./module-style.interface.js";
|
|
6
6
|
import { ownPropertyKey } from "./name.deriver.js";
|
|
7
7
|
export function emitClientSurfaceModule(contract, names, style) {
|
|
@@ -127,12 +127,12 @@ export function emitClientSurfaceModule(contract, names, style) {
|
|
|
127
127
|
`import type { ${interactive ? "AvDeferredOperationCall, " : ""}AvOperationCall, AvOperationGrammar } from ${from("./derivation/operations/operation-call")};\n` +
|
|
128
128
|
`import type { AvOperationFamily, AvOperationIdentity } from ${from("./derivation/operations/operation-identity")};\n` +
|
|
129
129
|
`import type { AdmittedOperationResult } from ${from("./derivation/operations/operation-result")};\n` +
|
|
130
|
-
(interactive
|
|
131
|
-
? `import type { AvOperation, AvTransactionResults } from ${from("./derivation/transactions/deferred-operation")};\n`
|
|
132
|
-
: "") +
|
|
133
130
|
(access === undefined
|
|
134
131
|
? ""
|
|
135
132
|
: `import type { AvAccessVerbs } from ${from("./derivation/policy/access-verbs")};\n`) +
|
|
133
|
+
(interactive
|
|
134
|
+
? `import type { AvOperation, AvTransactionResults } from ${from("./derivation/transactions/deferred-operation")};\n`
|
|
135
|
+
: "") +
|
|
136
136
|
`import { DEFAULT_ENTRYPOINT } from ${from("./metadata")};\n` +
|
|
137
137
|
(verbs.length === 0
|
|
138
138
|
? ""
|
|
@@ -148,7 +148,7 @@ export function emitClientSurfaceModule(contract, names, style) {
|
|
|
148
148
|
: "") +
|
|
149
149
|
(access === undefined
|
|
150
150
|
? `import type { AvCallAdditions, AvCallOptions, AvFetch, PlatformSignal, TransportConnection } from ${from("./runtime/transport")};\n`
|
|
151
|
-
: `import {
|
|
151
|
+
: `import { type AvCallAdditions, type AvCallOptions, type AvFetch, askCan, type PlatformSignal, type TransportConnection } from ${from("./runtime/transport")};\n`) +
|
|
152
152
|
(verbs.includes("me")
|
|
153
153
|
? `import type { AvAccessMe } from ${from("./types")};\n`
|
|
154
154
|
: "") +
|
|
@@ -9,7 +9,10 @@ import { type ClientModuleStyle } from "./module-style.interface.js";
|
|
|
9
9
|
* origin other than the entrypoint's that `accessOrigins` does not list.
|
|
10
10
|
*/
|
|
11
11
|
export declare function emitClientTree(contract: AvContractClient, entrypoint: EmittedEntrypoint, style: ClientModuleStyle, regenerate?: RegenerateCommand, accessOrigins?: readonly string[]): ClientEmission;
|
|
12
|
-
/**
|
|
12
|
+
/**
|
|
13
|
+
* How an entry re-exports `AvClient`: the class itself and the ready client as
|
|
14
|
+
* the default (`AvClient.ts`), or the class's type alone.
|
|
15
|
+
*/
|
|
13
16
|
export type ClientClassExport = "value" | "type";
|
|
14
|
-
/** The entry's re-exports
|
|
17
|
+
/** The entry's re-exports as source: one statement per module. */
|
|
15
18
|
export declare function clientEntryReExports(style: ClientModuleStyle, clientClass?: ClientClassExport, accessVerbs?: boolean): string;
|
|
@@ -62,8 +62,7 @@ export function emitClientTree(contract, entrypoint, style, regenerate = AVCLIEN
|
|
|
62
62
|
return { tree, style, warnings: names.warnings };
|
|
63
63
|
}
|
|
64
64
|
function clientEntrySource(style, accessVerbs) {
|
|
65
|
-
return (
|
|
66
|
-
clientEntryReExports(style, "value", accessVerbs));
|
|
65
|
+
return clientEntryReExports(style, "value", accessVerbs);
|
|
67
66
|
}
|
|
68
67
|
function generatedModuleSpecifier(style, module) {
|
|
69
68
|
return moduleSpecifierWriter(style)(`./${GENERATED_DIRECTORY}/${module.replace(/\.d\.ts$|\.ts$/, "")}`);
|
|
@@ -71,20 +70,20 @@ function generatedModuleSpecifier(style, module) {
|
|
|
71
70
|
export function clientEntryReExports(style, clientClass = "value", accessVerbs = false) {
|
|
72
71
|
const from = (module) => generatedModuleSpecifier(style, module);
|
|
73
72
|
return ((clientClass === "value"
|
|
74
|
-
? `export {\n\
|
|
75
|
-
: `export type {
|
|
76
|
-
`export type { AvFetch } from ${from("runtime/transport.ts")};\n` +
|
|
73
|
+
? `export {\n\ttype AvCall,\n\ttype AvCallAdditions,\n\ttype AvCallOptions,\n\tAvClient,\n\ttype AvClientOptions,\n\tdefault,\n} from ${from("client.ts")};\n`
|
|
74
|
+
: `export type { AvCall, AvCallAdditions, AvCallOptions, AvClient, AvClientOptions } from ${from("client.ts")};\n`) +
|
|
77
75
|
`export type { AvOperation } from ${from("derivation/transactions/deferred-operation.d.ts")};\n` +
|
|
78
|
-
`export type * from ${from("types.d.ts")};\n` +
|
|
79
76
|
`export * from ${from("enums.ts")};\n` +
|
|
77
|
+
(accessVerbs
|
|
78
|
+
? `export { AvAccessError } from ${from("runtime/access.ts")};\n`
|
|
79
|
+
: "") +
|
|
80
80
|
`export { AvDecimal } from ${from("runtime/decimal.ts")};\n` +
|
|
81
|
-
`export { avLazy } from ${from("runtime/operation-set.ts")};\n` +
|
|
82
81
|
`export {\n${errorsModuleExports()
|
|
83
82
|
.map((name) => `\t${name},\n`)
|
|
84
83
|
.join("")}} from ${from("runtime/errors.ts")};\n` +
|
|
85
|
-
(
|
|
86
|
-
|
|
87
|
-
|
|
84
|
+
`export { avLazy } from ${from("runtime/operation-set.ts")};\n` +
|
|
85
|
+
`export type { AvFetch } from ${from("runtime/transport.ts")};\n` +
|
|
86
|
+
`export type * from ${from("types.d.ts")};\n`);
|
|
88
87
|
}
|
|
89
88
|
function refuseForeignAccessOrigin(contract, entrypoint, accessOrigins) {
|
|
90
89
|
const url = contract.access?.url;
|
|
@@ -2,7 +2,7 @@ import { canonicalizeContract } from "@aventara/core/internal";
|
|
|
2
2
|
import { acceptClientContract } from "../contract/contract.acceptance.js";
|
|
3
3
|
import { withGeneratedBanner } from "./banner.emitter.js";
|
|
4
4
|
const CARRIER_PREFIX = "export type ClientContractShape = ";
|
|
5
|
-
const CARRIER_SUFFIX = "
|
|
5
|
+
const CARRIER_SUFFIX = ";";
|
|
6
6
|
export function emitContractCarrierModule(contract) {
|
|
7
7
|
return {
|
|
8
8
|
path: "contract.ts",
|
|
@@ -49,7 +49,7 @@ export function emitDerivationModules(read = publishedCoreDeclarations(), roots
|
|
|
49
49
|
for (const specifier of scanned.specifiers) {
|
|
50
50
|
queue.push(declarationTarget(file, specifier));
|
|
51
51
|
}
|
|
52
|
-
copied.set(file, withoutSourceMapTrailer(text));
|
|
52
|
+
copied.set(file, withImportsSetApart(withoutSourceMapTrailer(text)));
|
|
53
53
|
}
|
|
54
54
|
return [...copied.keys()]
|
|
55
55
|
.sort((left, right) => (left < right ? -1 : left > right ? 1 : 0))
|
|
@@ -73,6 +73,14 @@ function declarationTarget(file, specifier) {
|
|
|
73
73
|
}
|
|
74
74
|
return target.replace(/\.js$/, ".d.ts");
|
|
75
75
|
}
|
|
76
|
+
function withImportsSetApart(text) {
|
|
77
|
+
const lines = text.split("\n");
|
|
78
|
+
const end = lines.findIndex((line) => !line.startsWith("import "));
|
|
79
|
+
if (end <= 0 || lines[end] === "") {
|
|
80
|
+
return text;
|
|
81
|
+
}
|
|
82
|
+
return [...lines.slice(0, end), "", ...lines.slice(end)].join("\n");
|
|
83
|
+
}
|
|
76
84
|
function withoutSourceMapTrailer(text) {
|
|
77
85
|
return text.replace(/(^|\n)\/\/# sourceMappingURL=[^\n]*\n?$/, "$1");
|
|
78
86
|
}
|
|
@@ -1,16 +1,7 @@
|
|
|
1
1
|
import type { AvContractClient } from "@aventara/core";
|
|
2
|
-
type Schema = Readonly<Record<string, unknown>>;
|
|
3
|
-
/**
|
|
4
|
-
* The TypeScript type a Json Shape schema describes, as source text.
|
|
5
|
-
*
|
|
6
|
-
* @throws Error on a schema outside the accepted forms — unreachable for a
|
|
7
|
-
* ClientContract that passed the structure check.
|
|
8
|
-
*/
|
|
9
|
-
export declare function jsonSchemaType(schema: Schema): string;
|
|
10
2
|
/**
|
|
11
3
|
* The type `client.ts` gives its derivation in place of the carrier when the
|
|
12
4
|
* contract shapes a Json field — the carrier with each shaped field's type — or
|
|
13
5
|
* `undefined` when it shapes none.
|
|
14
6
|
*/
|
|
15
7
|
export declare function shapedContractType(contract: AvContractClient): string | undefined;
|
|
16
|
-
export {};
|
|
@@ -1,61 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
string: "string",
|
|
3
|
-
number: "number",
|
|
4
|
-
integer: "number",
|
|
5
|
-
boolean: "boolean",
|
|
6
|
-
null: "null",
|
|
7
|
-
};
|
|
8
|
-
const plain = (text) => ({ text, union: false });
|
|
9
|
-
function unionOf(members) {
|
|
10
|
-
return members.length === 1
|
|
11
|
-
? members[0]
|
|
12
|
-
: { text: members.map((member) => member.text).join(" | "), union: true };
|
|
13
|
-
}
|
|
14
|
-
function operand(type) {
|
|
15
|
-
return type.union ? `(${type.text})` : type.text;
|
|
16
|
-
}
|
|
17
|
-
function emitted(schema) {
|
|
18
|
-
if (Object.hasOwn(schema, "const")) {
|
|
19
|
-
return plain(JSON.stringify(schema.const));
|
|
20
|
-
}
|
|
21
|
-
if (Array.isArray(schema.enum)) {
|
|
22
|
-
return unionOf(schema.enum.map((value) => plain(JSON.stringify(value))));
|
|
23
|
-
}
|
|
24
|
-
for (const keyword of ["anyOf", "oneOf"]) {
|
|
25
|
-
const options = schema[keyword];
|
|
26
|
-
if (Array.isArray(options)) {
|
|
27
|
-
return unionOf(options.map((option) => emitted(option)));
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
const type = schema.type;
|
|
31
|
-
if (typeof type === "string" && Object.hasOwn(SCALAR_TYPES, type)) {
|
|
32
|
-
return plain(SCALAR_TYPES[type]);
|
|
33
|
-
}
|
|
34
|
-
if (type === "array") {
|
|
35
|
-
if (Array.isArray(schema.prefixItems)) {
|
|
36
|
-
return plain(`[${schema.prefixItems.map((item) => emitted(item).text).join(", ")}]`);
|
|
37
|
-
}
|
|
38
|
-
return plain(`${operand(emitted(schema.items))}[]`);
|
|
39
|
-
}
|
|
40
|
-
if (type === "object") {
|
|
41
|
-
const properties = schema.properties;
|
|
42
|
-
if (properties === undefined) {
|
|
43
|
-
return plain(`{ [key: string]: ${emitted(schema.additionalProperties).text} }`);
|
|
44
|
-
}
|
|
45
|
-
const required = new Set(Array.isArray(schema.required) ? schema.required : []);
|
|
46
|
-
const members = Object.keys(properties).map((key) => {
|
|
47
|
-
const member = emitted(properties[key]).text;
|
|
48
|
-
return required.has(key)
|
|
49
|
-
? `${JSON.stringify(key)}: ${member}`
|
|
50
|
-
: `${JSON.stringify(key)}?: ${member} | undefined`;
|
|
51
|
-
});
|
|
52
|
-
return plain(members.length === 0 ? "{}" : `{ ${members.join("; ")} }`);
|
|
53
|
-
}
|
|
54
|
-
throw new Error(`a Json Shape schema outside the accepted forms reached the emitter: ${JSON.stringify(schema)}`);
|
|
55
|
-
}
|
|
56
|
-
export function jsonSchemaType(schema) {
|
|
57
|
-
return emitted(schema).text;
|
|
58
|
-
}
|
|
1
|
+
import { jsonSchemaType } from "@aventara/core/internal";
|
|
59
2
|
export function shapedContractType(contract) {
|
|
60
3
|
const order = (keys) => [...keys].sort((left, right) => (left < right ? -1 : left > right ? 1 : 0));
|
|
61
4
|
const resources = order(Object.keys(contract.resources)).flatMap((resourceName) => {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { jsonSchemaType } from "
|
|
1
|
+
import { jsonSchemaType } from "@aventara/core/internal";
|
|
2
2
|
import { importSpecifier, } from "./module-style.interface.js";
|
|
3
3
|
import { namedTypesOf } from "./name.deriver.js";
|
|
4
4
|
export function emitNamedTypesModule(contract, names, style) {
|
|
@@ -267,14 +267,14 @@ import { decodeResult, encodeArguments, readArgument, serializeWireBody, unsentV
|
|
|
267
267
|
import { DECODE_TABLE } from ${from("./descriptor")};
|
|
268
268
|
import {
|
|
269
269
|
type AvCause,
|
|
270
|
-
frameworkErrorOf,
|
|
271
|
-
OPERATION_CODES,
|
|
272
270
|
type AvOperationCode,
|
|
273
271
|
type AvOperationErrorCode,
|
|
274
272
|
AvTransportError,
|
|
275
|
-
VALIDATION_CODES,
|
|
276
273
|
type AvValidationCode,
|
|
277
274
|
type AvValidationIssue,
|
|
275
|
+
frameworkErrorOf,
|
|
276
|
+
OPERATION_CODES,
|
|
277
|
+
VALIDATION_CODES,
|
|
278
278
|
} from ${from("./errors")};
|
|
279
279
|
|
|
280
280
|
/**
|
|
@@ -1,4 +1,4 @@
|
|
|
1
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: run `avclient skill` through your package manager. A file you edited is kept, with a warning naming it, and stops receiving fixes (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"
|
|
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 Json field takes a `$ref` only as its whole value, from another Json field (one from another type is `A2007` / `V1011`). Inside a Json value nothing is a reference: a nested `$ref` is stored as the reference object (`{ $ref: { operation, fingerprint, path } }`), unresolved. The types accept both mistakes, so don't write them. A Json value can't have `$ref` as its own root key in any call: `meta: { $ref: \"#/$defs/x\" }` is read as a `$ref` directive and refused with `A2004` (`V1006`, or `V1001` for other shapes); nested one level down it is data. To build Json from an earlier step's result, add a `VIRTUAL` input field, write the `$ref` there, and build the Json in the Json field's `compute.create` / `compute.update`, which runs after references resolve.\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: run `avclient skill` through your package manager. A file you edited is kept, with a warning naming it, and stops receiving fixes (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 generated files pass a default `biome check` as written (Biome 2.4 or later), so a project on Biome's default rules needs no exclusion for them. A rule or assist you turn on beyond the defaults, such as `useSortedKeys`, is not covered. Another linter or formatter should skip `AvClient.ts` and `generated/` under `generateAt` through its own ignore setting, such as `ignores` in an ESLint flat config or a `.prettierignore` line.\n- The same contract always gives the same bytes.\n"
|
|
4
4
|
};
|
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.15",
|
|
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.15",
|
|
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.15",
|
|
48
48
|
"@types/node": "24.10.1",
|
|
49
49
|
"typescript": "^5.9.2"
|
|
50
50
|
},
|