@aventara/build 1.0.0-rc.12 → 1.0.0-rc.14
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 +61 -0
- package/README.md +47 -4
- package/dist/cli.js +110 -33
- package/dist/client/client-file.js +116 -22
- package/dist/computed/computed-fields-file.js +4 -1
- package/dist/computed/computed-fields.reader.js +30 -3
- package/dist/contracts/contracts-file.js +1 -4
- package/dist/project/build.lock.js +80 -0
- package/dist/project/generated-file.js +86 -2
- package/dist/project/project-modules.loader.js +9 -5
- package/dist/project/tsconfig.reader.js +12 -0
- package/package.json +4 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,66 @@
|
|
|
1
1
|
# @aventara/build
|
|
2
2
|
|
|
3
|
+
## 1.0.0-rc.14
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 0895a93: `avDehydrate` and `avHydrate` are typed: data handed from a Server Component to a Client Component as props keeps its type, with no cast.
|
|
8
|
+
|
|
9
|
+
- `avDehydrate(data)` (`@aventara/next`) returns an `AvDehydrated<T>` for data of type `T`, and `avHydrate(value)` (`@/aventara/client`) of an `AvDehydrated<T>` returns the `T`, its `Date`, `bigint`, `Uint8Array` and `AvDecimal` values included. Type an SWR `fallback` prop `AvDehydrated<T>` and pass `avHydrate(fallback)` to `SWRConfig` as it is.
|
|
10
|
+
- `AvDehydrated<T>` is new, exported by `@aventara/next` and by the `src/aventara/client.ts` that `aventara-build --client` writes; the two are one type. It is opaque: a plain object, the data itself, or the dehydrated form of another type is a compile error where an `AvDehydrated<T>` is expected.
|
|
11
|
+
- A Resource or enum named `AvDehydrated` or `avHydrate` is now emitted under a renamed identifier (`AvDehydratedModel`, `avHydrateModel`), with a warning, as other names the generated client owns are. Before, `src/aventara/client.ts`'s own declaration hid a Resource type of that name, which could not be imported from it.
|
|
12
|
+
- Read data you dehydrate through `@/aventara/universal`: `@/aventara/server` types a decimal as core's `AvDecimal`, while `avHydrate` revives it as the browser client's.
|
|
13
|
+
- Pass `avHydrate(fallback)` to `SWRConfig`, never the dehydrated value itself: SWR's `fallback` is typed `any`, so the compiler cannot catch it.
|
|
14
|
+
- `avHydrate` still takes `unknown` and returns `unknown`; `avDehydrate` of an `unknown` value returns an `AvDehydrated<unknown>`. `serializeData: avDehydrate` and `deserializeData: avHydrate` in TanStack Query are written as before.
|
|
15
|
+
- The agent skills show the SWR props pattern typed, with no cast. The frontend skill's refresh line no longer names `npx`, and says a file you edited is kept with a warning naming it, and replaced with `--force`.
|
|
16
|
+
|
|
17
|
+
- Updated dependencies [1c5eaa3]
|
|
18
|
+
- Updated dependencies [0895a93]
|
|
19
|
+
- @aventara/core@1.0.0-rc.14
|
|
20
|
+
- @aventara/client@1.0.0-rc.14
|
|
21
|
+
|
|
22
|
+
## 1.0.0-rc.13
|
|
23
|
+
|
|
24
|
+
### Minor Changes
|
|
25
|
+
|
|
26
|
+
- a4569dd: In a Next.js project, `aventara-build --client` gives the browser client hooks from `src/aventara/client-options.ts`
|
|
27
|
+
(or `.js`; another extension is refused by name): its default export, `{ onCall, onSuccess, onError }`, typed with
|
|
28
|
+
`satisfies Pick<AvClientOptions, "onCall" | "onSuccess" | "onError">` (`AvClientOptions` from `@/aventara/client`).
|
|
29
|
+
`@/aventara/client` and `@/aventara/universal` import it and give its hooks to every client they make, the browser's
|
|
30
|
+
one and each call's own on the server; without the file both are written as before. The entries keep their own
|
|
31
|
+
`fetch`, `grouping` and entrypoint: in the file those are type errors naming the entry, and are ignored at run time.
|
|
32
|
+
The file is bundled into the browser too, so it holds no secret and imports nothing server-only. On the server
|
|
33
|
+
`onCall`'s headers reach the in-process request, while its state still comes from the request being served.
|
|
34
|
+
The hooks read the default export when they run, never when the entries load, so the file may import from
|
|
35
|
+
`@/aventara/client` (an error class); a file with no default export leaves the client working without hooks, with one
|
|
36
|
+
warning (`AVENTARA_CLIENT_OPTIONS_NO_DEFAULT`). `@/aventara/server` is not an `AvClient` and takes no hooks. Adding or removing the file makes `--check` find both
|
|
37
|
+
entries stale.
|
|
38
|
+
|
|
39
|
+
### Patch Changes
|
|
40
|
+
|
|
41
|
+
- 5c65195: `aventara new` and `aventara init` now add a `typecheck` script, so a monorepo's `turbo typecheck` reaches the new project: `tsc --noEmit` in a NestJS project, `next typegen && tsc --noEmit` in a Next.js one. A `typecheck` script the project already has is kept as it is. The agent skill's "Before you finish" runs it.
|
|
42
|
+
|
|
43
|
+
`aventara-build` no longer leaves a project whose `tsc` reports `Type 'AvModel' does not satisfy the constraint 'AvAdapterModel'` in every Resource file, which hid the real error. A run that fails (or is interrupted) now changes no generated file, the Next.js browser client included: each keeps what the last successful run wrote. The failure prints before the run's other lines. Each file is replaced in one step, and a second run started while one is writing the same project is refused. When the configuration's computed fields cannot be read and the project does not type-check (read as `tsc -p .` reads it), the build prints those type errors first and keeps `computed-fields.generated.ts` as it was, instead of rewriting it to state nothing. A file that does state nothing (a configuration that is not a literal) now types its model correctly. If you see that cascade from an older build, run `aventara:prepare` again and fix the first error it prints.
|
|
44
|
+
|
|
45
|
+
- Updated dependencies [6c3e25c]
|
|
46
|
+
- Updated dependencies [33419de]
|
|
47
|
+
- Updated dependencies [2aaa27d]
|
|
48
|
+
- Updated dependencies [f3b291f]
|
|
49
|
+
- Updated dependencies [2aaa27d]
|
|
50
|
+
- Updated dependencies [e4e8abd]
|
|
51
|
+
- Updated dependencies [e662048]
|
|
52
|
+
- Updated dependencies [f3b291f]
|
|
53
|
+
- Updated dependencies [f3b291f]
|
|
54
|
+
- Updated dependencies [6c3e25c]
|
|
55
|
+
- Updated dependencies [6c3e25c]
|
|
56
|
+
- Updated dependencies [73aa797]
|
|
57
|
+
- Updated dependencies [a6cebba]
|
|
58
|
+
- Updated dependencies [e6495a0]
|
|
59
|
+
- Updated dependencies [a708188]
|
|
60
|
+
- Updated dependencies [a9260d2]
|
|
61
|
+
- @aventara/core@1.0.0-rc.13
|
|
62
|
+
- @aventara/client@1.0.0-rc.13
|
|
63
|
+
|
|
3
64
|
## 1.0.0-rc.12
|
|
4
65
|
|
|
5
66
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -77,8 +77,11 @@ configuration's TypeScript type, after it wrote the tree, and writes them to `co
|
|
|
77
77
|
adapter's discovery artifact imports that file as its sibling (its generate step is given `--config src/aventara.config.ts`), so a
|
|
78
78
|
variant's guards, pipes and filters, which run before compute, may leave exactly those fields out of a create. Where the
|
|
79
79
|
configuration is not a literal (a wide `AvConfig` annotation, a conditional spread), the build prints where and the
|
|
80
|
-
file states nothing (`null`): those stages may then leave any create field out.
|
|
81
|
-
|
|
80
|
+
file states nothing (`null`): those stages may then leave any create field out. When the configuration is not read and
|
|
81
|
+
the configuration or a Resource file it imports does not type-check either, the build prints those type errors first
|
|
82
|
+
(the project read as `tsc -p .` reads it) and leaves the file as it was: fix them, or write the configuration `as const`,
|
|
83
|
+
whichever kept it from being read. `--check` then exits 1: the file cannot be judged until they are fixed. A server warns `AVENTARA_STALE_GENERATED_TYPES` at startup when the
|
|
84
|
+
configuration computes other fields than the file says.
|
|
82
85
|
|
|
83
86
|
### The Contracts
|
|
84
87
|
|
|
@@ -95,7 +98,15 @@ description holds, and writes `src/generated/aventara/contracts.generated.ts`, w
|
|
|
95
98
|
keep it out of git, as the scaffold's `.gitignore` does, and never edit it.
|
|
96
99
|
|
|
97
100
|
A configuration that a server would refuse at startup fails the build instead: every `CONFIG_…` and `COMPILER_…`
|
|
98
|
-
diagnostic is printed with its path and documentation link,
|
|
101
|
+
diagnostic is printed with its path and documentation link, and the command exits 1. A run that fails, for this or any
|
|
102
|
+
other reason (Ctrl-C included), prints why before anything else and changes no generated file, the browser client's
|
|
103
|
+
included: each keeps what the last run that succeeded wrote (a fresh project gets none). Each file is replaced in one
|
|
104
|
+
step, never left half-written, and one run at a time writes a project's files: a second, started while the first
|
|
105
|
+
writes, is refused (the lock is `node_modules/.cache/aventara-build/build.lock`; a dead run's is taken over).
|
|
106
|
+
|
|
107
|
+
`Type 'AvModel' does not satisfy the constraint 'AvAdapterModel'` in every Resource file means the generated files and
|
|
108
|
+
your configuration are out of step: `aventara:prepare` failed or has not run since a change. Run it, and fix the first
|
|
109
|
+
error it prints.
|
|
99
110
|
|
|
100
111
|
Because the build runs `src/aventara.config.ts`, everything it imports runs at build time too. Keep its top level free of
|
|
101
112
|
side effects (no connection, no request). A value read from `process.env` is read when the build runs.
|
|
@@ -124,7 +135,7 @@ so no running server is needed:
|
|
|
124
135
|
|
|
125
136
|
| File | What it holds |
|
|
126
137
|
|---|---|
|
|
127
|
-
| `src/aventara/client.ts` | `avClient`, the typed client calling the app's own `api` (`/api`) from the browser, and `
|
|
138
|
+
| `src/aventara/client.ts` | `avClient`, the typed client calling the app's own `api` (`/api`) from the browser, `avHydrate` and its `AvDehydrated<T>` type; and every name `avclient generate`'s `AvClient.ts` exports besides its default: `AvClient`, the Resource types, the enums, `AvDecimal` and the error classes |
|
|
128
139
|
| `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 |
|
|
129
140
|
| `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` |
|
|
130
141
|
|
|
@@ -136,6 +147,38 @@ The universal `avClient` answers the same values on both sides; on a server wher
|
|
|
136
147
|
refused with a sentence naming `src/instrumentation.ts` and `ready()`. `avHydrate` revives data `avDehydrate` sent from the server (dates, `bigint`s, byte arrays and decimals, a decimal as
|
|
137
148
|
the client's own `AvDecimal`), for TanStack Query's `deserializeData` or an SWR `fallback`.
|
|
138
149
|
|
|
150
|
+
### The client's hooks: `src/aventara/client-options.ts`
|
|
151
|
+
|
|
152
|
+
To give the browser client hooks (`onCall`, `onSuccess`, `onError`, see `@aventara/client`'s README), export them from
|
|
153
|
+
`src/aventara/client-options.ts` (or `.js`; any other extension, `.tsx` and `.mts` included, is refused, since the
|
|
154
|
+
entries must import it under webpack and Turbopack alike):
|
|
155
|
+
|
|
156
|
+
```ts
|
|
157
|
+
import type { AvClientOptions } from "@/aventara/client";
|
|
158
|
+
|
|
159
|
+
export default {
|
|
160
|
+
onCall: () => ({ headers: { "X-Tenant": "acme" } }),
|
|
161
|
+
onError: (call, error) => console.warn(call.label, error),
|
|
162
|
+
} satisfies Pick<AvClientOptions, "onCall" | "onSuccess" | "onError">;
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
- When the file is there, `client.ts` and `universal.ts` import it and give its default export's hooks to every
|
|
166
|
+
`AvClient` they make: the browser's one, and on the server the one each `@/aventara/universal` call gets. Without
|
|
167
|
+
the file they are written as before.
|
|
168
|
+
- The hooks read the file's default export when they run, never when the entries load, so the file may import from
|
|
169
|
+
`@/aventara/client` (an error class for `instanceof`). A file with no default export leaves the client working
|
|
170
|
+
without hooks, with one warning (`AVENTARA_CLIENT_OPTIONS_NO_DEFAULT`).
|
|
171
|
+
- The entries keep their own `fetch` and `grouping`, and the default entrypoint: a `fetch`, `grouping` or
|
|
172
|
+
`entrypoint` in the file is a type error naming the entry that sets it, and is ignored at run time.
|
|
173
|
+
- The file is bundled into the browser too, since `client.ts` imports it. Import nothing server-only into it and keep
|
|
174
|
+
no secret there; when a hook must act differently on the server, branch on `typeof window`.
|
|
175
|
+
- On the server, `@/aventara/universal` calls the app in process: `onCall`'s headers reach that request, but its
|
|
176
|
+
state is seeded from the request being served, as every server call's is, so a header added there signs no one in.
|
|
177
|
+
In the browser they are on the HTTP request, where your `state` reads them like any other header.
|
|
178
|
+
- `@/aventara/server`'s `avClient` is the Framework's own in-process client, not an `AvClient`: the file does not reach
|
|
179
|
+
it.
|
|
180
|
+
- Adding or removing the file changes both entries: run the build again (`--check` finds them stale).
|
|
181
|
+
|
|
139
182
|
`avclient generate` stays the way to generate a client for a separate frontend. In a NestJS project `--client` is
|
|
140
183
|
refused.
|
|
141
184
|
|
package/dist/cli.js
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
+
import ts from "typescript";
|
|
2
3
|
import { AvBuildError } from "./build.error.js";
|
|
3
4
|
import { checkBrowserClient, emitBrowserClient, writeBrowserClient, } from "./client/client-file.js";
|
|
4
5
|
import { readConfigComputedFields } from "./computed/computed-fields.reader.js";
|
|
5
6
|
import { emitComputedFieldsModule } from "./computed/computed-fields-file.js";
|
|
6
|
-
import { checkContractsFile
|
|
7
|
+
import { checkContractsFile } from "./contracts/contracts-file.js";
|
|
7
8
|
import { compilePrebuiltContracts } from "./contracts/prebuilt-contracts.compiler.js";
|
|
8
|
-
import {
|
|
9
|
+
import { acquireBuildLock } from "./project/build.lock.js";
|
|
10
|
+
import { generatedFileBatch, readGeneratedFile, } from "./project/generated-file.js";
|
|
9
11
|
import { DEFAULT_GENERATED_FILES, resolveGeneratedFiles, } from "./project/generated-files.resolver.js";
|
|
10
12
|
import { applyProjectEnv } from "./project/project-env.loader.js";
|
|
11
13
|
import { PROJECT_LAYOUTS, resolveProjectLayout, } from "./project/project-layout.resolver.js";
|
|
12
14
|
import { findProjectModules, loadProjectModules, } from "./project/project-modules.loader.js";
|
|
13
|
-
import { importSuffixOf, projectCompilerOptions, } from "./project/tsconfig.reader.js";
|
|
15
|
+
import { importSuffixOf, projectCompilerOptions, projectSourceFiles, } from "./project/tsconfig.reader.js";
|
|
14
16
|
import { RESOURCES_GLOB, readResourceDefinitions, } from "./resources/resource-definitions.reader.js";
|
|
15
17
|
import { emitResourcesModule } from "./resources/resources-file.js";
|
|
16
18
|
const USAGE = `aventara-build — compile this project's Aventara Contracts ahead of time.
|
|
@@ -91,6 +93,36 @@ function counted(count) {
|
|
|
91
93
|
function wrote(written, file, detail) {
|
|
92
94
|
return `aventara-build: ${written === "written" ? "wrote" : "unchanged:"} ${file}${detail === undefined ? "" : ` (${detail})`}.`;
|
|
93
95
|
}
|
|
96
|
+
function defectText(error) {
|
|
97
|
+
return (error instanceof Error && error.stack) || String(error);
|
|
98
|
+
}
|
|
99
|
+
const SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"];
|
|
100
|
+
function restoreOnSignal(undo) {
|
|
101
|
+
const stop = () => {
|
|
102
|
+
for (const signal of SIGNALS) {
|
|
103
|
+
process.removeListener(signal, onSignal);
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
const onSignal = (signal) => {
|
|
107
|
+
stop();
|
|
108
|
+
undo();
|
|
109
|
+
process.kill(process.pid, signal);
|
|
110
|
+
};
|
|
111
|
+
for (const signal of SIGNALS) {
|
|
112
|
+
process.once(signal, onSignal);
|
|
113
|
+
}
|
|
114
|
+
return stop;
|
|
115
|
+
}
|
|
116
|
+
const LISTED_TYPE_ERRORS = 10;
|
|
117
|
+
function typeErrors(projectDirectory, errors) {
|
|
118
|
+
const listed = ts.formatDiagnostics(errors.slice(0, LISTED_TYPE_ERRORS), {
|
|
119
|
+
getCanonicalFileName: (file) => file,
|
|
120
|
+
getCurrentDirectory: () => projectDirectory,
|
|
121
|
+
getNewLine: () => "\n",
|
|
122
|
+
});
|
|
123
|
+
const more = errors.length - LISTED_TYPE_ERRORS;
|
|
124
|
+
return `${listed.trimEnd()}${more > 0 ? `\n(+${more} more)` : ""}`;
|
|
125
|
+
}
|
|
94
126
|
function checked(projectDirectory, file, text, stale) {
|
|
95
127
|
const current = readGeneratedFile(projectDirectory, file);
|
|
96
128
|
return current === undefined
|
|
@@ -99,8 +131,8 @@ function checked(projectDirectory, file, text, stale) {
|
|
|
99
131
|
? undefined
|
|
100
132
|
: { file, what: `is stale: ${stale}` };
|
|
101
133
|
}
|
|
102
|
-
function refusal({ file, what, deploy }) {
|
|
103
|
-
return `aventara-build: ${file} ${what}. Run aventara-build (npm run aventara:prepare)${deploy === true ? " and deploy the file it writes" : ""}
|
|
134
|
+
function refusal({ file, what, deploy, remedy }) {
|
|
135
|
+
return `aventara-build: ${file} ${what}. ${remedy ?? `Run aventara-build (npm run aventara:prepare)${deploy === true ? " and deploy the file it writes" : ""}.`}`;
|
|
104
136
|
}
|
|
105
137
|
export async function runBuild(argv, projectDirectory, output) {
|
|
106
138
|
if (argv.includes("-h") || argv.includes("--help")) {
|
|
@@ -108,12 +140,30 @@ export async function runBuild(argv, projectDirectory, output) {
|
|
|
108
140
|
return 0;
|
|
109
141
|
}
|
|
110
142
|
let restoreEnv;
|
|
143
|
+
const lines = [];
|
|
144
|
+
const note = (text) => {
|
|
145
|
+
lines.push({ text });
|
|
146
|
+
};
|
|
147
|
+
const noteWrite = (text) => {
|
|
148
|
+
lines.push({ text, write: true });
|
|
149
|
+
};
|
|
150
|
+
const batch = generatedFileBatch(projectDirectory);
|
|
151
|
+
let failed = false;
|
|
152
|
+
let releaseLock;
|
|
153
|
+
let stopSignals;
|
|
111
154
|
try {
|
|
112
155
|
const { check, client } = parse(argv);
|
|
156
|
+
if (!check) {
|
|
157
|
+
releaseLock = acquireBuildLock(projectDirectory);
|
|
158
|
+
stopSignals = restoreOnSignal(() => {
|
|
159
|
+
batch.restore();
|
|
160
|
+
releaseLock?.();
|
|
161
|
+
});
|
|
162
|
+
}
|
|
113
163
|
const env = applyProjectEnv(projectDirectory);
|
|
114
164
|
restoreEnv = env.restore;
|
|
115
165
|
if (env.loaded.length > 0) {
|
|
116
|
-
|
|
166
|
+
note(`aventara-build: environment from ${listed(env.loaded)} (a variable already set keeps its value).`);
|
|
117
167
|
}
|
|
118
168
|
const layout = resolveProjectLayout(projectDirectory);
|
|
119
169
|
const clientLayout = client ? layout.client : undefined;
|
|
@@ -126,7 +176,7 @@ export async function runBuild(argv, projectDirectory, output) {
|
|
|
126
176
|
const found = readResourceDefinitions(projectDirectory, options);
|
|
127
177
|
const tree = emitResourcesModule(projectDirectory, generated.resources, found.definitions, importSuffixOf(options, path.dirname(path.join(projectDirectory, generated.resources))));
|
|
128
178
|
for (const file of found.withoutDefinition) {
|
|
129
|
-
|
|
179
|
+
note(`aventara-build: ${file} exports no defineResource(…) result; it is not in aventaraResources.`);
|
|
130
180
|
}
|
|
131
181
|
if (check) {
|
|
132
182
|
const stale = checked(projectDirectory, generated.resources, tree, "the Resource files changed since it was written");
|
|
@@ -136,25 +186,41 @@ export async function runBuild(argv, projectDirectory, output) {
|
|
|
136
186
|
}
|
|
137
187
|
}
|
|
138
188
|
else {
|
|
139
|
-
|
|
189
|
+
noteWrite(wrote(batch.write(generated.resources, tree), generated.resources, counted(found.definitions.length)));
|
|
140
190
|
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
}
|
|
191
|
+
const hadComputed = readGeneratedFile(projectDirectory, generated.computedFields) !==
|
|
192
|
+
undefined;
|
|
193
|
+
if (check && !hadComputed) {
|
|
194
|
+
output.stderr(refusal({ file: generated.computedFields, what: "is missing" }));
|
|
195
|
+
return 1;
|
|
147
196
|
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
writeGeneratedFile(projectDirectory, generated.computedFields, emitComputedFieldsModule(files.configFile, {
|
|
197
|
+
if (!check && !hadComputed) {
|
|
198
|
+
batch.write(generated.computedFields, emitComputedFieldsModule(files.configFile, {
|
|
151
199
|
kind: "unread",
|
|
152
200
|
reason: "the build has not read the configuration yet",
|
|
153
201
|
}));
|
|
154
202
|
}
|
|
155
|
-
const reading = readConfigComputedFields(path.join(projectDirectory, files.configFile), options, found.program);
|
|
156
|
-
|
|
157
|
-
|
|
203
|
+
const reading = readConfigComputedFields(path.join(projectDirectory, files.configFile), options, found.program, projectSourceFiles(projectDirectory));
|
|
204
|
+
if (reading.kind === "ill-typed") {
|
|
205
|
+
output.stderr(`aventara-build: computed fields not read from ${files.configFile}: ${reading.reason}. It does not type-check either: if these errors are what kept it from being read, fix them; if not, write the configuration \`as const\` there.${hadComputed ? ` ${generated.computedFields} keeps what it said.` : ""}\n${typeErrors(projectDirectory, reading.errors)}`);
|
|
206
|
+
}
|
|
207
|
+
const kept = reading.kind === "ill-typed" && hadComputed;
|
|
208
|
+
const statement = reading.kind === "ill-typed"
|
|
209
|
+
? { kind: "unread", reason: reading.reason }
|
|
210
|
+
: reading;
|
|
211
|
+
const computed = emitComputedFieldsModule(files.configFile, statement);
|
|
212
|
+
if (kept && check) {
|
|
213
|
+
output.stderr(refusal({
|
|
214
|
+
file: generated.computedFields,
|
|
215
|
+
what: "cannot be checked while the configuration does not type-check",
|
|
216
|
+
remedy: "Fix the type errors listed above; running aventara-build changes nothing until then.",
|
|
217
|
+
}));
|
|
218
|
+
return 1;
|
|
219
|
+
}
|
|
220
|
+
if (kept) {
|
|
221
|
+
noteWrite(wrote("unchanged", generated.computedFields));
|
|
222
|
+
}
|
|
223
|
+
else if (check) {
|
|
158
224
|
const stale = checked(projectDirectory, generated.computedFields, computed, "the fields the configuration computes changed since it was written");
|
|
159
225
|
if (stale !== undefined) {
|
|
160
226
|
output.stderr(refusal(stale));
|
|
@@ -162,9 +228,9 @@ export async function runBuild(argv, projectDirectory, output) {
|
|
|
162
228
|
}
|
|
163
229
|
}
|
|
164
230
|
else {
|
|
165
|
-
|
|
231
|
+
noteWrite(wrote(batch.write(generated.computedFields, computed), generated.computedFields));
|
|
166
232
|
if (reading.kind === "unread") {
|
|
167
|
-
|
|
233
|
+
note(`aventara-build: computed fields not read from ${files.configFile}: ${reading.reason}. The stages before compute may leave any create field out; write the configuration \`as const\` there.`);
|
|
168
234
|
}
|
|
169
235
|
}
|
|
170
236
|
const project = await loadProjectModules(projectDirectory, files);
|
|
@@ -178,17 +244,17 @@ export async function runBuild(argv, projectDirectory, output) {
|
|
|
178
244
|
canonicalText: (contract) => project.internal.canonicalizeContract(contract),
|
|
179
245
|
});
|
|
180
246
|
if (!check) {
|
|
181
|
-
|
|
247
|
+
noteWrite(wrote(batch.write(generated.contracts, compiled.source), generated.contracts, counted(resources)));
|
|
182
248
|
if (browserClient !== undefined) {
|
|
183
249
|
for (const warning of browserClient.warnings) {
|
|
184
|
-
|
|
250
|
+
note(`aventara-build: ${warning}`);
|
|
185
251
|
}
|
|
186
|
-
const written = writeBrowserClient(projectDirectory, browserClient);
|
|
252
|
+
const written = writeBrowserClient(projectDirectory, browserClient, batch);
|
|
187
253
|
for (const file of written.removed) {
|
|
188
|
-
|
|
254
|
+
note(`aventara-build: removed ${file}, which this ClientContract's browser client no longer has.`);
|
|
189
255
|
}
|
|
190
256
|
const [entry, universal] = browserClient.files;
|
|
191
|
-
|
|
257
|
+
noteWrite(wrote(written.write, `${entry?.file}, ${universal?.file} and ${browserClient.tree}/`, `the browser client, calling ${browserClient.entrypoint === "" ? "/" : browserClient.entrypoint}`));
|
|
192
258
|
}
|
|
193
259
|
return 0;
|
|
194
260
|
}
|
|
@@ -201,7 +267,7 @@ export async function runBuild(argv, projectDirectory, output) {
|
|
|
201
267
|
output.stderr(refusal(stale));
|
|
202
268
|
return 1;
|
|
203
269
|
}
|
|
204
|
-
|
|
270
|
+
note(browserClient === undefined
|
|
205
271
|
? `aventara-build: ${generated.resources}, ${generated.computedFields} and ${generated.contracts} are up to date.`
|
|
206
272
|
: `aventara-build: ${generated.resources}, ${generated.computedFields}, ${generated.contracts} and the browser client (${browserClient.files[0]?.file}, ${browserClient.files[1]?.file} and ${browserClient.tree}/) are up to date.`);
|
|
207
273
|
return 0;
|
|
@@ -216,14 +282,25 @@ export async function runBuild(argv, projectDirectory, output) {
|
|
|
216
282
|
return 1;
|
|
217
283
|
}
|
|
218
284
|
catch (error) {
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
285
|
+
failed = true;
|
|
286
|
+
const unrestored = batch.restore();
|
|
287
|
+
output.stderr(error instanceof AvBuildError
|
|
288
|
+
? `aventara-build: ${error.message}`
|
|
289
|
+
: defectText(error));
|
|
290
|
+
for (const { file, error: cause } of unrestored) {
|
|
291
|
+
output.stderr(`aventara-build: ${file} could not be put back as it was (${cause instanceof Error ? cause.message : String(cause)}); run aventara-build again.`);
|
|
222
292
|
}
|
|
223
|
-
|
|
293
|
+
return 1;
|
|
224
294
|
}
|
|
225
295
|
finally {
|
|
296
|
+
stopSignals?.();
|
|
297
|
+
releaseLock?.();
|
|
226
298
|
restoreEnv?.();
|
|
299
|
+
for (const line of lines) {
|
|
300
|
+
if (!(failed && line.write === true)) {
|
|
301
|
+
output.stdout(line.text);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
227
304
|
}
|
|
228
305
|
}
|
|
229
306
|
export async function runFromProcess() {
|
|
@@ -234,7 +311,7 @@ export async function runFromProcess() {
|
|
|
234
311
|
});
|
|
235
312
|
}
|
|
236
313
|
catch (error) {
|
|
237
|
-
process.stderr.write(`${(error
|
|
314
|
+
process.stderr.write(`${defectText(error)}\n`);
|
|
238
315
|
process.exitCode = 1;
|
|
239
316
|
}
|
|
240
317
|
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { readdirSync,
|
|
1
|
+
import { readdirSync, statSync } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { AvClientConfigError, AvGeneratedNameError, acceptClientContract, accessVerbsOf, carriesGeneratedOwnership, clientEntryReExports, emitClientTree, GENERATED_DIRECTORY, HYDRATION_FORMAT, importSpecifier, resolveModuleStyle, sameOriginEntrypoint, } from "@aventara/client/internal";
|
|
4
4
|
import { AvBuildError } from "../build.error.js";
|
|
5
|
-
import { readGeneratedFile,
|
|
5
|
+
import { readGeneratedFile, } from "../project/generated-file.js";
|
|
6
|
+
import { optionalProjectFile } from "../project/project-modules.loader.js";
|
|
6
7
|
export const REGENERATE = "`aventara-build --client` (the project's `aventara:prepare` script)";
|
|
7
8
|
const ENTRY_BANNER = [
|
|
8
9
|
"// biome-ignore-all format: generated output",
|
|
@@ -87,17 +88,79 @@ function guardedClient(template: AvClient): AvClient {
|
|
|
87
88
|
return copy;
|
|
88
89
|
}
|
|
89
90
|
`;
|
|
90
|
-
|
|
91
|
+
export const CLIENT_OPTIONS_MODULE = "client-options";
|
|
92
|
+
const NO_DEFAULT_KEY = "aventara.build.client-options.no-default";
|
|
93
|
+
const OPTIONS_NO_DEFAULT_WARNING = "AVENTARA_CLIENT_OPTIONS_NO_DEFAULT";
|
|
94
|
+
export const NO_DEFAULT_WARNING = "src/aventara/client-options has no default export: the client runs without hooks. Export them as its default: export default { onCall, onSuccess, onError }.";
|
|
95
|
+
const CLIENT_OPTIONS_EXTENSIONS = [".ts", ".js"];
|
|
96
|
+
function optionsSpecifier(file, style) {
|
|
97
|
+
const base = `./${CLIENT_OPTIONS_MODULE}`;
|
|
98
|
+
if (path.extname(file) === ".ts") {
|
|
99
|
+
return importSpecifier(base, style);
|
|
100
|
+
}
|
|
101
|
+
return style.importFileExtension === "" ? base : `${base}.js`;
|
|
102
|
+
}
|
|
103
|
+
const ownedOption = (name) => JSON.stringify(`Error: src/aventara/client-options sets onCall, onSuccess and onError only; @/aventara/client sets ${name}`);
|
|
104
|
+
const HOOKS_SOURCE = `/** What src/aventara/client-options exports by default: the client's hooks. This module sets the rest. */
|
|
105
|
+
type ClientOptions = Pick<AvClientOptions, "onCall" | "onSuccess" | "onError"> & {
|
|
106
|
+
readonly fetch?: ${ownedOption("fetch")};
|
|
107
|
+
readonly grouping?: ${ownedOption("grouping")};
|
|
108
|
+
readonly entrypoint?: ${ownedOption("entrypoint")};
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/** The options module's default export, checked: the hooks, and nothing this module sets; none at all is fine. */
|
|
112
|
+
type CheckedDefault<D extends ClientOptions | undefined> = D;
|
|
113
|
+
type OptionsDefault = CheckedDefault<typeof optionsModule extends { readonly default: infer D } ? D : undefined>;
|
|
114
|
+
|
|
115
|
+
/** The process-wide mark that the missing default export was reported. */
|
|
116
|
+
const NO_DEFAULT_REPORTED = Symbol.for(${JSON.stringify(NO_DEFAULT_KEY)});
|
|
117
|
+
|
|
118
|
+
/** The options module's default export, read when a hook runs; a missing one is reported once. */
|
|
119
|
+
function clientOptions(): ClientOptions | undefined {
|
|
120
|
+
const options: unknown = (optionsModule as unknown as { readonly default?: unknown }).default;
|
|
121
|
+
if (typeof options === "object" && options !== null) {
|
|
122
|
+
return options as ClientOptions & OptionsDefault;
|
|
123
|
+
}
|
|
124
|
+
const holder = globalThis as { [key: symbol]: unknown };
|
|
125
|
+
if (holder[NO_DEFAULT_REPORTED] !== true) {
|
|
126
|
+
holder[NO_DEFAULT_REPORTED] = true;
|
|
127
|
+
const platform = globalThis as {
|
|
128
|
+
readonly process?: { readonly emitWarning?: (message: string, options: { readonly type: string; readonly code: string }) => void };
|
|
129
|
+
readonly console?: { warn(message: string): void };
|
|
130
|
+
};
|
|
131
|
+
const message = ${JSON.stringify(NO_DEFAULT_WARNING)};
|
|
132
|
+
if (typeof platform.process?.emitWarning === "function") {
|
|
133
|
+
platform.process.emitWarning(message, { type: "AventaraWarning", code: ${JSON.stringify(OPTIONS_NO_DEFAULT_WARNING)} });
|
|
134
|
+
} else {
|
|
135
|
+
platform.console?.warn("AventaraWarning: " + message);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The hooks every client this module makes runs, on either side: the options module's, read when each runs. */
|
|
142
|
+
const HOOKS: Pick<AvClientOptions, "onCall" | "onSuccess" | "onError"> = {
|
|
143
|
+
onCall: (call) => clientOptions()?.onCall?.(call),
|
|
144
|
+
onSuccess: (call, result) => clientOptions()?.onSuccess?.(call, result),
|
|
145
|
+
onError: (call, error) => clientOptions()?.onError?.(call, error),
|
|
146
|
+
};
|
|
147
|
+
`;
|
|
148
|
+
function optionsImport(options) {
|
|
149
|
+
return options === undefined
|
|
150
|
+
? ""
|
|
151
|
+
: `import * as optionsModule from ${JSON.stringify(options)};\n`;
|
|
152
|
+
}
|
|
153
|
+
function entrySource(style, accessVerbs, options) {
|
|
91
154
|
const from = (module) => JSON.stringify(importSpecifier(module, style));
|
|
92
|
-
const { tagKey, valueKey, objectTag, scalarTags } = HYDRATION_FORMAT;
|
|
155
|
+
const { tagKey, valueKey, objectTag, scalarTags, typeKey } = HYDRATION_FORMAT;
|
|
93
156
|
const tagged = [...scalarTags, objectTag]
|
|
94
157
|
.map((tag) => JSON.stringify(tag))
|
|
95
158
|
.join(" | ");
|
|
96
159
|
return `${ENTRY_BANNER}
|
|
97
160
|
|
|
98
|
-
import { AvClient } from ${from(`./${GENERATED_DIRECTORY}/client`)};
|
|
161
|
+
import { AvClient${options === undefined ? "" : ", type AvClientOptions"} } from ${from(`./${GENERATED_DIRECTORY}/client`)};
|
|
99
162
|
import { type AvBuiltInScalar, decodeScalar } from ${from(`./${GENERATED_DIRECTORY}/runtime/codec`)};
|
|
100
|
-
|
|
163
|
+
${optionsImport(options)}
|
|
101
164
|
${clientEntryReExports(style, "type", accessVerbs)}
|
|
102
165
|
/** What a call made on the server is refused with. */
|
|
103
166
|
const SERVER_CALL = ${JSON.stringify(SERVER_CALL_REFUSAL)};
|
|
@@ -117,8 +180,8 @@ function refusal(): string | undefined {
|
|
|
117
180
|
|
|
118
181
|
type Call = (...args: unknown[]) => unknown;
|
|
119
182
|
|
|
120
|
-
/** The one client every call is made on. */
|
|
121
|
-
const CLIENT = new AvClient();
|
|
183
|
+
${options === undefined ? "" : HOOKS_SOURCE + "\n"}/** The one client every call is made on. */
|
|
184
|
+
const CLIENT = new AvClient(${options === undefined ? "" : "{ ...HOOKS }"});
|
|
122
185
|
|
|
123
186
|
/** The client a call made here and now is made on: the one client. */
|
|
124
187
|
function current(): AvClient {
|
|
@@ -158,17 +221,35 @@ function revivedMembers(record: Readonly<Record<string, unknown>>): Record<strin
|
|
|
158
221
|
return Object.fromEntries(Object.entries(record).map(([key, member]) => [key, avHydrate(member)]));
|
|
159
222
|
}
|
|
160
223
|
|
|
224
|
+
/**
|
|
225
|
+
* Data of type \`T\` as \`avDehydrate\` (from \`@aventara/next\`) sends it from the
|
|
226
|
+
* server: opaque, read back as the \`T\` by \`avHydrate\`. Type a Client
|
|
227
|
+
* Component's prop with it to take server data with no cast. It is
|
|
228
|
+
* \`@aventara/next\`'s \`AvDehydrated\`, declared here again: its one member is a
|
|
229
|
+
* brand of the type only, never of the value.
|
|
230
|
+
*/
|
|
231
|
+
export type AvDehydrated<T> = {
|
|
232
|
+
/** The type the value was dehydrated from: the type's brand, no member of the value. */
|
|
233
|
+
readonly ${JSON.stringify(typeKey)}: T;
|
|
234
|
+
};
|
|
235
|
+
|
|
161
236
|
/**
|
|
162
237
|
* Revives data that \`avDehydrate\` (from \`@aventara/next\`) made safe to pass
|
|
163
238
|
* from the server to the browser: each tagged Date, bigint, Uint8Array and
|
|
164
239
|
* decimal becomes the value this client reads, a decimal this client's own
|
|
165
240
|
* AvDecimal.
|
|
166
241
|
* Arrays and plain objects are copied with their members revived; any other
|
|
167
|
-
* value is returned as it is.
|
|
168
|
-
*
|
|
242
|
+
* value is returned as it is. An \`AvDehydrated<T>\` revives as the \`T\`: call it
|
|
243
|
+
* on a prop of that type, such as an SWR \`fallback\`, and pass on what it
|
|
244
|
+
* returns, never the dehydrated value itself (SWR's \`fallback\` is typed
|
|
245
|
+
* \`any\`, so the type cannot catch it). Give it to TanStack Query as
|
|
246
|
+
* \`deserializeData\`.
|
|
169
247
|
*
|
|
170
248
|
* @throws TypeError when a tagged value is not one avDehydrate writes.
|
|
171
249
|
*/
|
|
250
|
+
export function avHydrate<T>(dehydrated: AvDehydrated<T>): T;
|
|
251
|
+
/** For a caller that cannot name the data's type: TanStack Query's \`deserializeData\`. */
|
|
252
|
+
export function avHydrate(dehydrated: unknown): unknown;
|
|
172
253
|
export function avHydrate(dehydrated: unknown): unknown {
|
|
173
254
|
if (Array.isArray(dehydrated)) {
|
|
174
255
|
return dehydrated.map((member: unknown) => avHydrate(member));
|
|
@@ -197,15 +278,15 @@ export function avHydrate(dehydrated: unknown): unknown {
|
|
|
197
278
|
}
|
|
198
279
|
export const UNIVERSAL_PATH_KEY = "aventara.next.universal";
|
|
199
280
|
export const UNREGISTERED_REFUSAL = 'avClient from @/aventara/universal ran on the server, where it calls this app in process through what ready() registers, and nothing is registered: export register() from src/instrumentation.ts and, when process.env.NEXT_RUNTIME is "nodejs", await ready() from @/aventara/server there.';
|
|
200
|
-
function universalSource(style, accessVerbs) {
|
|
281
|
+
function universalSource(style, accessVerbs, options) {
|
|
201
282
|
const from = (module) => JSON.stringify(importSpecifier(module, style));
|
|
202
283
|
return `${ENTRY_BANNER}
|
|
203
284
|
|
|
204
285
|
import { io } from ${JSON.stringify(style.importFileExtension === "" ? "next/cache" : "next/cache.js")};
|
|
205
286
|
import { cache } from "react";
|
|
206
|
-
import { AvClient } from ${from(`./${GENERATED_DIRECTORY}/client`)};
|
|
287
|
+
import { AvClient${options === undefined ? "" : ", type AvClientOptions"} } from ${from(`./${GENERATED_DIRECTORY}/client`)};
|
|
207
288
|
import type { AvFetch } from ${from(`./${GENERATED_DIRECTORY}/runtime/transport`)};
|
|
208
|
-
|
|
289
|
+
${optionsImport(options)}
|
|
209
290
|
import { avLazy as lazyCall } from ${from(`./${GENERATED_DIRECTORY}/runtime/operation-set`)};
|
|
210
291
|
|
|
211
292
|
/** The key the server's in-process path is registered under, on globalThis, by ready() from @aventara/next. */
|
|
@@ -242,8 +323,8 @@ async function universalFetch(...request: unknown[]): Promise<unknown> {
|
|
|
242
323
|
|
|
243
324
|
type Call = (...args: unknown[]) => unknown;
|
|
244
325
|
|
|
245
|
-
/** The browser's one client, whose calls are grouped, and the shape \`avClient\` copies. */
|
|
246
|
-
const BROWSER = new AvClient({ fetch: universalFetch as AvFetch });
|
|
326
|
+
${options === undefined ? "" : HOOKS_SOURCE + "\n"}/** The browser's one client, whose calls are grouped, and the shape \`avClient\` copies. */
|
|
327
|
+
const BROWSER = new AvClient({ ${options === undefined ? "" : "...HOOKS, "}fetch: universalFetch as AvFetch });
|
|
247
328
|
|
|
248
329
|
/**
|
|
249
330
|
* The client a call made here and now is made on: the browser's one; on the
|
|
@@ -252,7 +333,7 @@ const BROWSER = new AvClient({ fetch: universalFetch as AvFetch });
|
|
|
252
333
|
* nested in it), and shares no queue with any other call.
|
|
253
334
|
*/
|
|
254
335
|
function current(): AvClient {
|
|
255
|
-
return onServer() ? new AvClient({ fetch: universalFetch as AvFetch, grouping: false }) : BROWSER;
|
|
336
|
+
return onServer() ? new AvClient({ ${options === undefined ? "" : "...HOOKS, "}fetch: universalFetch as AvFetch, grouping: false }) : BROWSER;
|
|
256
337
|
}
|
|
257
338
|
|
|
258
339
|
/** On the server, the calls each avLazy made in the request being rendered (React's \`cache\`). */
|
|
@@ -369,6 +450,19 @@ export async function emitBrowserClient(projectDirectory, layout, source) {
|
|
|
369
450
|
moduleFormat: style.moduleFormat,
|
|
370
451
|
}, REGENERATE));
|
|
371
452
|
const accessVerbs = accessVerbsOf(acceptance.contract).length > 0;
|
|
453
|
+
const optionsModule = `${layout.folder}/${CLIENT_OPTIONS_MODULE}`;
|
|
454
|
+
const optionsFile = [".tsx", ".jsx"]
|
|
455
|
+
.map((extension) => `${optionsModule}${extension}`)
|
|
456
|
+
.find((file) => statSync(path.join(projectDirectory, file), {
|
|
457
|
+
throwIfNoEntry: false,
|
|
458
|
+
})?.isFile() === true) ?? optionalProjectFile(projectDirectory, optionsModule);
|
|
459
|
+
if (optionsFile !== undefined &&
|
|
460
|
+
!CLIENT_OPTIONS_EXTENSIONS.includes(path.extname(optionsFile))) {
|
|
461
|
+
throw new AvBuildError(`${optionsFile} is not read: the client's options module is ${layout.folder}/${CLIENT_OPTIONS_MODULE}.ts or .js, the two the entries import under every Next.js bundler; rename it.`);
|
|
462
|
+
}
|
|
463
|
+
const options = optionsFile === undefined
|
|
464
|
+
? undefined
|
|
465
|
+
: optionsSpecifier(optionsFile, style);
|
|
372
466
|
const decoder = new TextDecoder();
|
|
373
467
|
const tree = emission.tree
|
|
374
468
|
.filter((file) => file.path.startsWith(`${GENERATED_DIRECTORY}/`))
|
|
@@ -380,11 +474,11 @@ export async function emitBrowserClient(projectDirectory, layout, source) {
|
|
|
380
474
|
files: [
|
|
381
475
|
{
|
|
382
476
|
file: `${layout.folder}/${layout.entry}`,
|
|
383
|
-
text: entrySource(style, accessVerbs),
|
|
477
|
+
text: entrySource(style, accessVerbs, options),
|
|
384
478
|
},
|
|
385
479
|
{
|
|
386
480
|
file: `${layout.folder}/${layout.universal}`,
|
|
387
|
-
text: universalSource(style, accessVerbs),
|
|
481
|
+
text: universalSource(style, accessVerbs, options),
|
|
388
482
|
},
|
|
389
483
|
...tree,
|
|
390
484
|
],
|
|
@@ -413,16 +507,16 @@ function leftovers(projectDirectory, client) {
|
|
|
413
507
|
return filesUnder(projectDirectory, client.tree).filter((file) => !written.has(file) &&
|
|
414
508
|
carriesGeneratedOwnership(readGeneratedFile(projectDirectory, file) ?? ""));
|
|
415
509
|
}
|
|
416
|
-
export function writeBrowserClient(projectDirectory, client) {
|
|
510
|
+
export function writeBrowserClient(projectDirectory, client, writer) {
|
|
417
511
|
let write = "unchanged";
|
|
418
512
|
for (const { file, text } of client.files) {
|
|
419
|
-
if (
|
|
513
|
+
if (writer.write(file, text) === "written") {
|
|
420
514
|
write = "written";
|
|
421
515
|
}
|
|
422
516
|
}
|
|
423
517
|
const removed = leftovers(projectDirectory, client);
|
|
424
518
|
for (const file of removed) {
|
|
425
|
-
|
|
519
|
+
writer.remove(file);
|
|
426
520
|
}
|
|
427
521
|
return { write: removed.length > 0 ? "written" : write, removed };
|
|
428
522
|
}
|
|
@@ -435,7 +529,7 @@ export function checkBrowserClient(projectDirectory, client) {
|
|
|
435
529
|
if (current !== text) {
|
|
436
530
|
return {
|
|
437
531
|
file,
|
|
438
|
-
what:
|
|
532
|
+
what: `is stale: the ClientContract, the project's module settings or its ${CLIENT_OPTIONS_MODULE} module (added or removed) changed since it was written`,
|
|
439
533
|
};
|
|
440
534
|
}
|
|
441
535
|
}
|
|
@@ -22,7 +22,10 @@ export function emitComputedFieldsModule(configFile, reading) {
|
|
|
22
22
|
`// Not read: ${reading.reason.replaceAll("\n", " ")}. The stages that run before compute may leave any`,
|
|
23
23
|
"// create field out; write the configuration `as const` there to have it read.",
|
|
24
24
|
"export const computedFields = null;",
|
|
25
|
-
|
|
25
|
+
"",
|
|
26
|
+
"// What an adapter's discovery artifact carries on its model's type for the stages that run before compute:",
|
|
27
|
+
"// nothing, so they may leave any create field out.",
|
|
28
|
+
"export type AvComputedFields = undefined;",
|
|
26
29
|
].join("\n")}\n`;
|
|
27
30
|
}
|
|
28
31
|
return `${[
|
|
@@ -6,10 +6,13 @@ const AGENT_PARENTS = ["root", "application", "client"];
|
|
|
6
6
|
const PHASES = ["create", "update"];
|
|
7
7
|
class NotExact extends Error {
|
|
8
8
|
}
|
|
9
|
-
export function readConfigComputedFields(configFile, options, oldProgram) {
|
|
9
|
+
export function readConfigComputedFields(configFile, options, oldProgram, projectFiles = []) {
|
|
10
10
|
const file = path.resolve(configFile);
|
|
11
11
|
const program = ts.createProgram({
|
|
12
|
-
rootNames: [
|
|
12
|
+
rootNames: [
|
|
13
|
+
file,
|
|
14
|
+
...projectFiles.filter((name) => path.resolve(name) !== file),
|
|
15
|
+
],
|
|
13
16
|
options: { ...options, noEmit: true, skipLibCheck: true },
|
|
14
17
|
...(oldProgram === undefined ? {} : { oldProgram }),
|
|
15
18
|
});
|
|
@@ -44,11 +47,35 @@ export function readConfigComputedFields(configFile, options, oldProgram) {
|
|
|
44
47
|
}
|
|
45
48
|
catch (error) {
|
|
46
49
|
if (error instanceof NotExact) {
|
|
47
|
-
|
|
50
|
+
const errors = configurationErrors(program, file, options);
|
|
51
|
+
return errors.length > 0
|
|
52
|
+
? { kind: "ill-typed", reason: error.message, errors }
|
|
53
|
+
: { kind: "unread", reason: error.message };
|
|
48
54
|
}
|
|
49
55
|
throw error;
|
|
50
56
|
}
|
|
51
57
|
}
|
|
58
|
+
function configurationErrors(program, file, options) {
|
|
59
|
+
const reached = new Set(ts
|
|
60
|
+
.createProgram({
|
|
61
|
+
rootNames: [file],
|
|
62
|
+
options: { ...options, noEmit: true, skipLibCheck: true },
|
|
63
|
+
oldProgram: program,
|
|
64
|
+
})
|
|
65
|
+
.getSourceFiles()
|
|
66
|
+
.map((source) => path.resolve(source.fileName)));
|
|
67
|
+
return program
|
|
68
|
+
.getSourceFiles()
|
|
69
|
+
.filter((source) => reached.has(path.resolve(source.fileName)) &&
|
|
70
|
+
!source.isDeclarationFile &&
|
|
71
|
+
!program.isSourceFileFromExternalLibrary(source) &&
|
|
72
|
+
!source.fileName.includes("/node_modules/"))
|
|
73
|
+
.flatMap((source) => [
|
|
74
|
+
...program.getSyntacticDiagnostics(source),
|
|
75
|
+
...program.getSemanticDiagnostics(source),
|
|
76
|
+
])
|
|
77
|
+
.filter((diagnostic) => diagnostic.category === ts.DiagnosticCategory.Error);
|
|
78
|
+
}
|
|
52
79
|
function computedFieldsOfType(checker, config) {
|
|
53
80
|
assertExact(checker, config, "the configuration");
|
|
54
81
|
const overlays = Object.fromEntries(LAYERS.map((layer) => {
|
|
@@ -1,10 +1,7 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { pathToFileURL } from "node:url";
|
|
3
3
|
import { createJiti } from "jiti";
|
|
4
|
-
import { readGeneratedFile
|
|
5
|
-
export function writeContractsFile(projectDirectory, contractsFile, compiled) {
|
|
6
|
-
return writeGeneratedFile(projectDirectory, contractsFile, compiled.source);
|
|
7
|
-
}
|
|
4
|
+
import { readGeneratedFile } from "../project/generated-file.js";
|
|
8
5
|
export async function checkContractsFile(projectDirectory, contractsFile, compiled, changesOf) {
|
|
9
6
|
const file = path.join(projectDirectory, contractsFile);
|
|
10
7
|
const text = readGeneratedFile(projectDirectory, contractsFile);
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdirSync, readFileSync, rmSync, statSync, writeFileSync, } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { AvBuildError } from "../build.error.js";
|
|
5
|
+
const STALE_AFTER_MS = 10 * 60 * 1000;
|
|
6
|
+
export function buildLockFileOf(projectDirectory) {
|
|
7
|
+
return path.join(projectDirectory, "node_modules", ".cache", "aventara-build", "build.lock");
|
|
8
|
+
}
|
|
9
|
+
function running(pid) {
|
|
10
|
+
try {
|
|
11
|
+
process.kill(pid, 0);
|
|
12
|
+
return true;
|
|
13
|
+
}
|
|
14
|
+
catch (error) {
|
|
15
|
+
return error.code === "EPERM";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function liveHolder(lock) {
|
|
19
|
+
try {
|
|
20
|
+
const holder = Number.parseInt(readFileSync(lock, "utf8"), 10);
|
|
21
|
+
const age = Date.now() - statSync(lock).mtimeMs;
|
|
22
|
+
return Number.isInteger(holder) &&
|
|
23
|
+
holder !== process.pid &&
|
|
24
|
+
age < STALE_AFTER_MS &&
|
|
25
|
+
running(holder)
|
|
26
|
+
? holder
|
|
27
|
+
: undefined;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export function acquireBuildLock(projectDirectory) {
|
|
34
|
+
const lock = buildLockFileOf(projectDirectory);
|
|
35
|
+
const shown = path.relative(projectDirectory, lock);
|
|
36
|
+
const unwritable = (error) => {
|
|
37
|
+
const { code, path: at } = error;
|
|
38
|
+
const where = at === undefined
|
|
39
|
+
? ""
|
|
40
|
+
: ` at ${path.relative(projectDirectory, at) || "."}`;
|
|
41
|
+
return new AvBuildError(`this project's lock file ${shown} cannot be written (${code ?? String(error)}${where}). Make node_modules a writable folder (the package manager's install makes one) and run it again.`);
|
|
42
|
+
};
|
|
43
|
+
try {
|
|
44
|
+
mkdirSync(path.dirname(lock), { recursive: true });
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
throw unwritable(error);
|
|
48
|
+
}
|
|
49
|
+
const held = `${process.pid} ${randomUUID()}`;
|
|
50
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
51
|
+
try {
|
|
52
|
+
writeFileSync(lock, held, { flag: "wx" });
|
|
53
|
+
return () => {
|
|
54
|
+
try {
|
|
55
|
+
if (readFileSync(lock, "utf8") === held) {
|
|
56
|
+
rmSync(lock, { force: true });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
if (error.code !== "EEXIST") {
|
|
65
|
+
throw unwritable(error);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const holder = liveHolder(lock);
|
|
69
|
+
if (holder !== undefined) {
|
|
70
|
+
throw new AvBuildError(`another aventara-build (process ${holder}) is writing this project's generated files. Run it again when that one has finished, or delete ${shown} if no build is running.`);
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
rmSync(lock, { force: true });
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
throw unwritable(error);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
throw new AvBuildError("another aventara-build took this project's lock at the same moment. Run it again.");
|
|
80
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmdirSync, rmSync, writeFileSync, } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
export function readGeneratedFile(projectDirectory, file) {
|
|
4
4
|
try {
|
|
@@ -8,12 +8,96 @@ export function readGeneratedFile(projectDirectory, file) {
|
|
|
8
8
|
return undefined;
|
|
9
9
|
}
|
|
10
10
|
}
|
|
11
|
+
export function replaceFile(target, text, rename = renameSync) {
|
|
12
|
+
const temporary = `${target}.${process.pid}.tmp`;
|
|
13
|
+
try {
|
|
14
|
+
writeFileSync(temporary, text);
|
|
15
|
+
for (let attempt = 1;; attempt += 1) {
|
|
16
|
+
try {
|
|
17
|
+
rename(temporary, target);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
const code = error.code;
|
|
22
|
+
if (code !== "EPERM" && code !== "EBUSY" && code !== "EACCES") {
|
|
23
|
+
throw error;
|
|
24
|
+
}
|
|
25
|
+
if (attempt === RENAME_ATTEMPTS) {
|
|
26
|
+
writeFileSync(target, text);
|
|
27
|
+
rmSync(temporary, { force: true });
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
Atomics.wait(PAUSE, 0, 0, RENAME_PAUSE_MS);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
rmSync(temporary, { force: true });
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const RENAME_ATTEMPTS = 5;
|
|
40
|
+
const RENAME_PAUSE_MS = 50;
|
|
41
|
+
const PAUSE = new Int32Array(new SharedArrayBuffer(4));
|
|
11
42
|
export function writeGeneratedFile(projectDirectory, file, text) {
|
|
12
43
|
if (readGeneratedFile(projectDirectory, file) === text) {
|
|
13
44
|
return "unchanged";
|
|
14
45
|
}
|
|
15
46
|
const target = path.join(projectDirectory, file);
|
|
16
47
|
mkdirSync(path.dirname(target), { recursive: true });
|
|
17
|
-
|
|
48
|
+
replaceFile(target, text);
|
|
18
49
|
return "written";
|
|
19
50
|
}
|
|
51
|
+
export function generatedFileBatch(projectDirectory) {
|
|
52
|
+
const before = new Map();
|
|
53
|
+
const made = [];
|
|
54
|
+
const remember = (file) => {
|
|
55
|
+
if (!before.has(file)) {
|
|
56
|
+
before.set(file, readGeneratedFile(projectDirectory, file));
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
return {
|
|
60
|
+
write: (file, text) => {
|
|
61
|
+
if (readGeneratedFile(projectDirectory, file) === text) {
|
|
62
|
+
return "unchanged";
|
|
63
|
+
}
|
|
64
|
+
remember(file);
|
|
65
|
+
const missing = [];
|
|
66
|
+
for (let folder = path.dirname(path.join(projectDirectory, file)); !existsSync(folder) && folder !== path.dirname(folder); folder = path.dirname(folder)) {
|
|
67
|
+
missing.unshift(folder);
|
|
68
|
+
}
|
|
69
|
+
made.push(...missing);
|
|
70
|
+
return writeGeneratedFile(projectDirectory, file, text);
|
|
71
|
+
},
|
|
72
|
+
remove: (file) => {
|
|
73
|
+
remember(file);
|
|
74
|
+
rmSync(path.join(projectDirectory, file), { force: true });
|
|
75
|
+
},
|
|
76
|
+
restore: () => {
|
|
77
|
+
const failures = [];
|
|
78
|
+
for (const [file, text] of before) {
|
|
79
|
+
const target = path.join(projectDirectory, file);
|
|
80
|
+
try {
|
|
81
|
+
if (text === undefined) {
|
|
82
|
+
rmSync(target, { force: true });
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
mkdirSync(path.dirname(target), { recursive: true });
|
|
86
|
+
replaceFile(target, text);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
failures.push({ file, error });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
for (const folder of [...made].reverse()) {
|
|
94
|
+
try {
|
|
95
|
+
rmdirSync(folder);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return failures;
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
@@ -7,19 +7,23 @@ const EXTENSIONS = [".js", ".ts", ".mjs", ".cjs", ".mts", ".cts"];
|
|
|
7
7
|
const EXTENSIONS_AS_LISTED = [".ts", ".mts", ".cts", ".js", ".mjs", ".cjs"];
|
|
8
8
|
const SERVER_MARKERS = { "server-only": {} };
|
|
9
9
|
const CONFIG_EXPORTS = ["aventaraConfig", "default"];
|
|
10
|
-
function
|
|
10
|
+
export function optionalProjectFile(projectDirectory, module) {
|
|
11
11
|
const present = EXTENSIONS.map((extension) => `${module}${extension}`).filter((file) => statSync(path.join(projectDirectory, file), {
|
|
12
12
|
throwIfNoEntry: false,
|
|
13
13
|
})?.isFile() === true);
|
|
14
|
-
if (present.length === 0) {
|
|
15
|
-
const [first, ...others] = EXTENSIONS_AS_LISTED;
|
|
16
|
-
throw new AvBuildError(`no ${module}${first}, ${others.slice(0, -1).join(", ")} or ${others.at(-1)} in ${projectDirectory}, where ${layout.described} keeps it. Run aventara-build from the project's root (the aventara:prepare script does).`);
|
|
17
|
-
}
|
|
18
14
|
if (present.length > 1) {
|
|
19
15
|
throw new AvBuildError(`${present.slice(0, -1).join(", ")} and ${present.at(-1)} are all ${module}; keep one and delete the other${present.length > 2 ? "s" : ""}.`);
|
|
20
16
|
}
|
|
21
17
|
return present[0];
|
|
22
18
|
}
|
|
19
|
+
function projectFile(projectDirectory, module, layout) {
|
|
20
|
+
const file = optionalProjectFile(projectDirectory, module);
|
|
21
|
+
if (file === undefined) {
|
|
22
|
+
const [first, ...others] = EXTENSIONS_AS_LISTED;
|
|
23
|
+
throw new AvBuildError(`no ${module}${first}, ${others.slice(0, -1).join(", ")} or ${others.at(-1)} in ${projectDirectory}, where ${layout.described} keeps it. Run aventara-build from the project's root (the aventara:prepare script does).`);
|
|
24
|
+
}
|
|
25
|
+
return file;
|
|
26
|
+
}
|
|
23
27
|
async function evaluate(jiti, projectDirectory, file) {
|
|
24
28
|
try {
|
|
25
29
|
return await jiti.import(path.join(projectDirectory, file));
|
|
@@ -22,6 +22,18 @@ export function projectCompilerOptions(projectDirectory) {
|
|
|
22
22
|
}
|
|
23
23
|
return ts.parseJsonConfigFileContent(read.config, ts.sys, path.dirname(tsconfig)).options;
|
|
24
24
|
}
|
|
25
|
+
export function projectSourceFiles(projectDirectory) {
|
|
26
|
+
assertClassicCompilerApi();
|
|
27
|
+
const tsconfig = ts.findConfigFile(projectDirectory, ts.sys.fileExists, "tsconfig.json");
|
|
28
|
+
if (tsconfig === undefined) {
|
|
29
|
+
return [];
|
|
30
|
+
}
|
|
31
|
+
const read = ts.readConfigFile(tsconfig, ts.sys.readFile);
|
|
32
|
+
return read.error === undefined
|
|
33
|
+
? ts.parseJsonConfigFileContent(read.config, ts.sys, path.dirname(tsconfig))
|
|
34
|
+
.fileNames
|
|
35
|
+
: [];
|
|
36
|
+
}
|
|
25
37
|
export function importSuffixOf(options, directory) {
|
|
26
38
|
if (options.allowImportingTsExtensions === true ||
|
|
27
39
|
options
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aventara/build",
|
|
3
|
-
"version": "1.0.0-rc.
|
|
3
|
+
"version": "1.0.0-rc.14",
|
|
4
4
|
"license": "SEE LICENSE IN LICENSE",
|
|
5
5
|
"description": "Compiles an Aventara application's Contracts at build time, for serverless and edge cold starts.",
|
|
6
6
|
"type": "module",
|
|
@@ -16,18 +16,18 @@
|
|
|
16
16
|
"CHANGELOG.md"
|
|
17
17
|
],
|
|
18
18
|
"dependencies": {
|
|
19
|
-
"@aventara/client": "1.0.0-rc.
|
|
19
|
+
"@aventara/client": "1.0.0-rc.14",
|
|
20
20
|
"jiti": "2.7.0"
|
|
21
21
|
},
|
|
22
22
|
"peerDependencies": {
|
|
23
|
-
"@aventara/core": "1.0.0-rc.
|
|
23
|
+
"@aventara/core": "1.0.0-rc.14",
|
|
24
24
|
"typescript": ">=5.7.0 <7"
|
|
25
25
|
},
|
|
26
26
|
"publishConfig": {
|
|
27
27
|
"access": "public"
|
|
28
28
|
},
|
|
29
29
|
"devDependencies": {
|
|
30
|
-
"@aventara/core": "1.0.0-rc.
|
|
30
|
+
"@aventara/core": "1.0.0-rc.14",
|
|
31
31
|
"@types/node": "24.10.1",
|
|
32
32
|
"typescript": "^5.9.2"
|
|
33
33
|
},
|