@aventara/build
v1.0.0-rc.12
Published
Compiles an Aventara application's Contracts at build time, for serverless and edge cold starts.
Readme
@aventara/build
The aventara-build command compiles an Aventara application's Contracts when the project is built, so a serverless
or edge function loads them at a cold start instead of compiling them. It is a development dependency: nothing in your
deployed application imports it.
npm install --save-dev --save-exact @aventara/build@<your @aventara/core version>aventara new and aventara init add it and run it from the project's aventara:prepare script, after the adapter's
generate step:
aventara-prisma7-generate … && aventara-buildWhat it reads and writes
Run it from the project's root. It writes three files, in this order, each from what the one before wrote, so a fresh
install is right on its first aventara:prepare. They go to the directory your configuration imports
aventaraResources from, beside the discovery artifact: src/generated/aventara/ normally, src/aventara/ when
aventara init found Prisma's client generated into src/generated (which prisma generate would empty). A
configuration that imports no tree keeps src/generated/aventara/.
| File | What it holds |
|---|---|
| resources.generated.ts | aventaraResources: every Resource your src/resources/**/*.resource.ts files define, one resources tree per layer |
| computed-fields.generated.ts | computedFields (typed AvComputedFields): which fields the configuration's write compute fills, read off its type |
| contracts.generated.ts | aventaraContracts (typed AvPrebuiltContracts): the compiled Contracts |
The Resource tree
Each Resource's configuration lives in its own file under src/resources/, named <name>.resource.ts, as an exported
defineResource(...) result. The build reads those files through TypeScript (it never runs them), takes every export
whose type is a defineResource result, and writes the spreads you would otherwise write by hand:
// src/generated/aventara/resources.generated.ts (generated)
import { post } from "../../resources/post.resource.js";
import { session } from "../../resources/session.resource.js";
export const aventaraResources = {
root: { ...post.root, ...session.root },
client: { ...post.client },
} as const;root is always there; application, client and agent only when some Resource gives that layer a block. The
configuration takes the trees once, and a new Resource file needs no edit to it:
// src/aventara.config.ts
import { aventaraResources } from "./generated/aventara/resources.generated.js";
export const aventaraConfig = {
api: "/api",
exposed: true,
resources: aventaraResources.root,
client: { exposed: true, resources: aventaraResources.client },
} as const;Two files defining the same Resource stop the build, naming both: spread by hand, the later spread silently replaced
the earlier block. The imports are spelled as your project spells its own, by the rule avclient follows for the client it writes:
.js in an ES module under nodenext, none with module: "commonjs" or moduleResolution: "bundler", .ts with
allowImportingTsExtensions. One difference: a CommonJS package under nodenext (Nest's CommonJS scaffold) gets no
suffix either, as the scaffold writes its own imports there, because these files import values and the jest such a
project tests with resolves no .js specifier to a .ts file. The tree is the same expression you would write, so it
costs the same type checking. The files matched are src/resources/**/*.resource.ts; the pattern is not configurable
yet.
The computed fields
The build reads which fields the configuration's compute.create and compute.update stages fill off the
configuration's TypeScript type, after it wrote the tree, and writes them to computed-fields.generated.ts. The Prisma
adapter's discovery artifact imports that file as its sibling (its generate step is given --config src/aventara.config.ts), so a
variant's guards, pipes and filters, which run before compute, may leave exactly those fields out of a create. Where the
configuration is not a literal (a wide AvConfig annotation, a conditional spread), the build prints where and the
file states nothing (null): those stages may then leave any create field out. A server warns
AVENTARA_STALE_GENERATED_TYPES at startup when the configuration computes other fields than the file says.
The Contracts
To compile the Contracts it runs two of your modules, in TypeScript or JavaScript, ES module or CommonJS:
| File | What it must export |
|---|---|
| src/aventara.config.ts | aventaraConfig (or a default export): the configuration as a plain value, without adapter |
| src/aventara.adapter.ts | a default export: the adapter's description, describePrismaAdapter({ discovery, provider, driver }) for Prisma 7 |
It compiles the four Contracts with your project's own @aventara/core, from the configuration and the model the
description holds, and writes src/generated/aventara/contracts.generated.ts, whose export aventaraContracts is typed
AvPrebuiltContracts. It never constructs a database client and never connects to a database. The file is generated:
keep it out of git, as the scaffold's .gitignore does, and never edit it.
A configuration that a server would refuse at startup fails the build instead: every CONFIG_… and COMPILER_…
diagnostic is printed with its path and documentation link, nothing is written, and the command exits 1.
Because the build runs src/aventara.config.ts, everything it imports runs at build time too. Keep its top level free of
side effects (no connection, no request). A value read from process.env is read when the build runs.
Before it runs them, the build reads the project's .env files into process.env, the same files avclient generate
reads and with the same precedence: a variable already set in the environment wins, then .env.<mode>.local,
.env.<mode>, .env.local and .env, where the mode is NODE_ENV (else development). So a module that reads
DATABASE_URL when it is imported runs at build time as it does under next dev. A .env line that would be ignored
(not a NAME=value assignment) fails the build, naming the file and the line.
Next.js projects
A project whose package.json depends on @aventara/next keeps everything Aventara in one folder, src/aventara/. The
build runs src/aventara/config.ts and src/aventara/adapter.ts there instead of src/aventara.config.ts and
src/aventara.adapter.ts, and a configuration that imports no Resource tree has its files in src/aventara/generated/.
Every other project keeps the paths above.
--client
aventara-build --clientIn a Next.js project, --client also writes the browser client, from the ClientContract the build has just compiled,
so no running server is needed:
| File | What it holds |
|---|---|
| src/aventara/client.ts | avClient, the typed client calling the app's own api (/api) from the browser, and avHydrate; and every name avclient generate's AvClient.ts exports besides its default: AvClient, the Resource types, the enums, AvDecimal and the error classes |
| src/aventara/universal.ts | avClient for both sides: in the browser it calls the app's own api, on the server it calls the app in process, through what ready() from @aventara/next registers in src/instrumentation.ts, with that request's state |
| src/aventara/generated/ (the client's modules) | the same modules avclient generate writes for the same ClientContract, but with the app's own api as the entrypoint, and saying to regenerate them with aventara-build --client |
Import it as @/aventara/client in Client Components, and @/aventara/universal in code that runs on both sides (a
TanStack Query queryFn prefetched on the server, for example). The files import nothing from @aventara/client or
@aventara/next, so the app needs no generator at run time. avClient calls the API from the browser only: a call made on the server, while a
Client Component renders there, is refused with a sentence pointing to @/aventara/server and @/aventara/universal.
The universal avClient answers the same values on both sides; on a server where ready() has not run, a call is
refused with a sentence naming src/instrumentation.ts and ready(). avHydrate revives data avDehydrate sent from the server (dates, bigints, byte arrays and decimals, a decimal as
the client's own AvDecimal), for TanStack Query's deserializeData or an SWR fallback.
avclient generate stays the way to generate a client for a separate frontend. In a NestJS project --client is
refused.
--check
aventara-build --check
aventara-build --check --clientWrites nothing. It exits 0 when the three files on disk are the ones a build would write now, and 1 on the first that is
missing or stale, naming it (and, for the Contracts, the Resources whose configuration or model changed). With
--client, the browser client's files are checked too, after the Contracts: a missing or stale file, or one an earlier
ClientContract's client left, exits 1. Run it in CI before a deploy.
Using the file
Pass aventaraContracts as prebuilt where the framework is created, beside the adapter:
// a serverless or edge entry
import { createFramework } from "@aventara/core";
import { createPrismaAdapter } from "@aventara/prisma7-adapter";
import adapter from "./aventara.adapter.js";
import { aventaraConfig } from "./aventara.config.js";
import { aventaraContracts } from "./generated/aventara/contracts.generated.js";
import { prisma } from "./prisma.client.js";
export const framework = await createFramework({
...aventaraConfig,
adapter: await createPrismaAdapter({ ...adapter, client: prisma }),
prebuilt: aventaraContracts,
});The framework uses the file only when it was compiled by the same @aventara/core, for the same protocol, from the same
configuration and model. Otherwise it ignores the whole file, compiles at startup as it would without one, and warns
once with AVENTARA_PREBUILT_STALE, naming what changed. A stale file never serves a wrong Contract; it only costs the
startup time the file was meant to save.
A long-running server does not need the file. The Nest project aventara new writes compiles its Contracts at startup
and passes no prebuilt.
