1/**
2 * Builds self-installing modules from ahead-of-time compiled Schema decoders.
3 *
4 * @stability unstable
5 * @since 4.0.0
6 */
7import * as Data from "../../Data.ts"
8import * as Effect from "../../Effect.ts"
9import * as FileSystem from "../../FileSystem.ts"
10import * as Path from "../../Path.ts"
11import type * as PlatformError from "../../PlatformError.ts"
12import * as Schema from "../../Schema.ts"
13import * as SchemaAST from "../../SchemaAST.ts"
14import * as SchemaAOTCompiler from "../SchemaAOTCompiler.ts"
15
16/**
17 * An operation whose root AST should be prepared by {@link build}.
18 *
19 * @stability unstable
20 * @category models
21 * @since 4.0.0
22 */
23export type Operation = "decode" | "encode" | "is" | "make"
24
25/**
26 * Loads the exports of a schema module during a build.
27 *
28 * @stability unstable
29 * @category models
30 * @since 4.0.0
31 */
32export interface ModuleLoader {
33 (): PromiseLike<unknown>
34}
35
36/**
37 * Options for {@link build}.
38 *
39 * @stability unstable
40 * @category models
41 * @since 4.0.0
42 */
43export interface BuildOptions {
44 /**
45 * Maps import specifiers relative to `baseUrl` to loaders for those same
46 * modules. Only directly exported Schema values are compiled.
47 */
48 readonly modules: Readonly<Record<string, ModuleLoader>>
49 /** The URL against which relative module specifiers are resolved. */
50 readonly baseUrl: string | URL
51 /** The file-system path of the generated module. */
52 readonly outFile: string
53 /** The parser operations to prepare. Defaults to decoding. */
54 readonly operations?: ReadonlyArray<Operation> | undefined
55}
56
57/**
58 * A summary of a completed {@link build}.
59 *
60 * @stability unstable
61 * @category models
62 * @since 4.0.0
63 */
64export interface BuildResult {
65 /** The absolute path written by the build. */
66 readonly outFile: string
67 /** The number of loaded modules containing at least one Schema export. */
68 readonly modules: number
69 /** The number of directly exported Schema values found in those modules. */
70 readonly schemas: number
71}
72
73/**
74 * An error raised while loading modules or generating an AOT module.
75 *
76 * @stability unstable
77 * @category errors
78 * @since 4.0.0
79 */
80export class BuildError extends Data.TaggedError("BuildError")<{
81 readonly _tag: "BuildError"
82 readonly cause: unknown
83 readonly kind: "Generate" | "InvalidModule" | "LoadModule" | "ResolveModule"
84 readonly message: string
85 readonly module?: string | undefined
86}> {}
87
88interface ExportedSchema {
89 readonly alias: string
90 readonly exportName: string
91 readonly schema: Schema.Top
92}
93
94const operationOrder: ReadonlyArray<Operation> = ["decode", "encode", "is", "make"]
95
96const importPath = (
97 path: Path.Path,
98 outFile: string,
99 baseUrl: string | URL,
100 specifier: string
101): Effect.Effect<string, BuildError> =>
102 Effect.gen(function*() {
103 const url = yield* Effect.try({
104 try: () => new URL(specifier, baseUrl),
105 catch: (cause) =>
106 new BuildError({
107 cause,
108 kind: "ResolveModule",
109 message: `Could not resolve schema module ${JSON.stringify(specifier)}`,
110 module: specifier
111 })
112 })
113 const sourcePath = yield* path.fromFileUrl(url).pipe(
114 Effect.mapError((cause) =>
115 new BuildError({
116 cause,
117 kind: "ResolveModule",
118 message: `Schema module ${JSON.stringify(specifier)} does not resolve to a file`,
119 module: specifier
120 })
121 )
122 )
123 const relative = path.relative(path.dirname(outFile), sourcePath)
124 if (path.isAbsolute(relative)) {
125 return yield* new BuildError({
126 cause: undefined,
127 kind: "ResolveModule",
128 message: `Schema module ${JSON.stringify(specifier)} cannot be imported relative to the output file`,
129 module: specifier
130 })
131 }
132 const normalized = path.sep === "/" ? relative : relative.split(path.sep).join("/")
133 return normalized.startsWith(".") ? normalized : `./${normalized}`
134 })
135
136const root = (
137 exported: ExportedSchema,
138 operation: Operation
139): readonly [
140 ast: SchemaAST.AST,
141 operation: SchemaAOTCompiler.Operation,
142 expression: string,
143 derived: boolean
144] => {
145 const expression = `${exported.alias}[${JSON.stringify(exported.exportName)}].ast`
146 switch (operation) {
147 case "decode":
148 return [exported.schema.ast, "decode", expression, false]
149 case "encode": {
150 const ast = SchemaAST.flip(exported.schema.ast)
151 return ast === exported.schema.ast
152 ? [ast, "decode", expression, false]
153 : [ast, "decode", `A.flip(${expression})`, true]
154 }
155 case "is":
156 case "make": {
157 const ast = SchemaAST.toType(exported.schema.ast)
158 return ast === exported.schema.ast
159 ? [ast, operation, expression, false]
160 : [ast, operation, `A.toType(${expression})`, true]
161 }
162 }
163}
164
165/**
166 * Writes a self-installing AOT module for Schema values exported by a set of
167 * modules.
168 *
169 * **When to use**
170 *
171 * Use when a build script should let an application install generated decoders
172 * by importing one generated module at startup.
173 *
174 * **Details**
175 *
176 * Module keys are import specifiers relative to `baseUrl`. Their loaders run
177 * sequentially during the build. The builder sorts module specifiers and export
178 * names, compiles direct Schema exports, and writes deterministic source using
179 * `FileSystem`. The generated module imports those exports and installs their
180 * decoders in the shared Schema parser registry as a module side effect. A lazy
181 * record returned by `import.meta.glob` can be passed as `modules` directly.
182 *
183 * Decoding is prepared by default. `is` and `make` prepare the type-side AST;
184 * `encode` prepares the flipped AST. Nested dependencies are discovered by the
185 * AOT compiler and do not need separate exports.
186 *
187 * **Gotchas**
188 *
189 * Loaders execute application modules during the build. Each loader must return
190 * the same module named by its key. The generated file must be rebuilt after a
191 * schema definition or Effect version changes. Unsupported operations retain
192 * the interpreter fallback. Build configurations that mark modules as
193 * side-effect free must retain the generated import.
194 *
195 * @stability unstable
196 * @category compilation
197 * @since 4.0.0
198 */
199export const build: (
200 options: BuildOptions
201) => Effect.Effect<
202 BuildResult,
203 BuildError | PlatformError.PlatformError,
204 FileSystem.FileSystem | Path.Path
205> = Effect.fnUntraced(function*(options) {
206 const fs = yield* FileSystem.FileSystem
207 const path = yield* Path.Path
208 const outFile = path.resolve(options.outFile)
209 const requested = new Set(options.operations ?? ["decode"])
210 const operations = operationOrder.filter((operation) => requested.has(operation))
211 const imports: Array<string> = []
212 const exportedSchemas: Array<ExportedSchema> = []
213
214 for (const specifier of Object.keys(options.modules).sort()) {
215 const loader = options.modules[specifier]!
216 const loaded = yield* Effect.tryPromise({
217 try: () => loader(),
218 catch: (cause) =>
219 new BuildError({
220 cause,
221 kind: "LoadModule",
222 message: `Could not load schema module ${JSON.stringify(specifier)}`,
223 module: specifier
224 })
225 })
226 if (typeof loaded !== "object" || loaded === null) {
227 return yield* new BuildError({
228 cause: loaded,
229 kind: "InvalidModule",
230 message: `Schema module ${JSON.stringify(specifier)} did not load a module namespace`,
231 module: specifier
232 })
233 }
234 const namespace = loaded as Readonly<Record<string, unknown>>
235 const exports = Object.keys(namespace).filter((name) => Schema.isSchema(namespace[name])).sort()
236 if (exports.length === 0) continue
237 const alias = `m${imports.length}`
238 const specifierFromOutput = yield* importPath(path, outFile, options.baseUrl, specifier)
239 imports.push(`import * as ${alias} from ${JSON.stringify(specifierFromOutput)};`)
240 for (const exportName of exports) {
241 exportedSchemas.push({
242 alias,
243 exportName,
244 schema: namespace[exportName] as Schema.Top
245 })
246 }
247 }
248
249 const source = yield* Effect.try({
250 try: () => {
251 const targets: Array<{
252 readonly ast: SchemaAST.AST
253 readonly operations: Array<SchemaAOTCompiler.Operation>
254 readonly expression: string
255 }> = []
256 const seen = new Map<SchemaAST.AST, number>()
257 let needsSchemaASTImport = false
258 for (const exported of exportedSchemas) {
259 for (const operation of operations) {
260 const [ast, targetOperation, expression, isDerived] = root(exported, operation)
261 const index = seen.get(ast)
262 if (index !== undefined) {
263 const targetOperations = targets[index].operations
264 if (!targetOperations.includes(targetOperation)) targetOperations.push(targetOperation)
265 continue
266 }
267 seen.set(ast, targets.length)
268 targets.push({ ast, operations: [targetOperation], expression })
269 needsSchemaASTImport ||= isDerived
270 }
271 }
272 return [
273 ...imports,
274 ...(needsSchemaASTImport ? ["import * as A from \"effect/SchemaAST\";"] : []),
275 SchemaAOTCompiler.compile(targets),
276 `install([${targets.map((target) => target.expression).join(",")}]);`,
277 ""
278 ].join("\n")
279 },
280 catch: (cause) =>
281 new BuildError({
282 cause,
283 kind: "Generate",
284 message: "Could not generate the Schema AOT module"
285 })
286 })
287
288 yield* fs.makeDirectory(path.dirname(outFile), { recursive: true })
289 yield* fs.writeFileString(outFile, source)
290 return {
291 outFile,
292 modules: imports.length,
293 schemas: exportedSchemas.length
294 }
295})