@confect/cli 8.0.0 → 9.0.0-next.1

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/src/Bundler.ts ADDED
@@ -0,0 +1,144 @@
1
+ import { createRequire } from "node:module";
2
+ import { pathToFileURL } from "node:url";
3
+ import { Path } from "@effect/platform";
4
+ import { Array, Effect, Option, pipe } from "effect";
5
+ import * as esbuild from "esbuild";
6
+ import { BundlerError } from "./BuildError";
7
+
8
+ export interface Bundled {
9
+ readonly module: any;
10
+ readonly metafile: esbuild.Metafile;
11
+ }
12
+
13
+ export const EXTERNAL_PACKAGES = [
14
+ "@confect/core",
15
+ "@confect/server",
16
+ "effect",
17
+ "@effect/*",
18
+ ];
19
+
20
+ const isExternalImport = (path: string) =>
21
+ EXTERNAL_PACKAGES.some((p) => {
22
+ if (p.endsWith("/*")) {
23
+ return path.startsWith(p.slice(0, -1));
24
+ }
25
+ return path === p || path.startsWith(p + "/");
26
+ });
27
+
28
+ export const absoluteExternalsPlugin: esbuild.Plugin = {
29
+ name: "absolute-externals",
30
+ setup(build) {
31
+ build.onResolve({ filter: /.*/ }, async (args) => {
32
+ if (args.kind !== "import-statement" && args.kind !== "dynamic-import")
33
+ return;
34
+ if (!isExternalImport(args.path)) return;
35
+ // `import.meta.resolve`'s second argument is silently ignored in modern
36
+ // Node, so resolution would always walk up from the CLI's bundled file
37
+ // (`packages/cli/dist/utils.mjs`) instead of from the user's project.
38
+ // Use `createRequire` keyed on the importing file's directory so we
39
+ // resolve out of *their* `node_modules`. The synthetic filename is just
40
+ // a CommonJS resolution anchor; the file does not need to exist.
41
+ const parentFile = pathToFileURL(args.resolveDir + "/_").href;
42
+ const require_ = createRequire(parentFile);
43
+ const resolvedPath = require_.resolve(args.path);
44
+ const resolved = pathToFileURL(resolvedPath).href;
45
+ return { path: resolved, external: true };
46
+ });
47
+ },
48
+ };
49
+
50
+ const buildEntry = (entryPoint: string) =>
51
+ Effect.tryPromise({
52
+ try: () =>
53
+ esbuild.build({
54
+ entryPoints: [entryPoint],
55
+ bundle: true,
56
+ write: false,
57
+ platform: "node",
58
+ format: "esm",
59
+ logLevel: "silent",
60
+ metafile: true,
61
+ plugins: [absoluteExternalsPlugin],
62
+ }),
63
+ catch: (cause) => new BundlerError({ cause }),
64
+ });
65
+
66
+ const importBundledModule = (result: esbuild.BuildResult) => {
67
+ const code = result.outputFiles![0]!.text;
68
+ const dataUrl =
69
+ "data:text/javascript;base64," + Buffer.from(code).toString("base64");
70
+ return import(dataUrl);
71
+ };
72
+
73
+ /**
74
+ * Bundle a TypeScript entry point with esbuild and import the result via a
75
+ * data URL. This handles extensionless `.ts` imports regardless of whether
76
+ * the user's project sets `"type": "module"` in package.json. The returned
77
+ * pair carries both the imported module and the esbuild metafile so callers
78
+ * can inspect the import graph (see {@link directlyImports}).
79
+ */
80
+ export const bundle = (
81
+ entryPoint: string,
82
+ ): Effect.Effect<Bundled, BundlerError> =>
83
+ Effect.gen(function* () {
84
+ const result = yield* buildEntry(entryPoint);
85
+ const module = yield* Effect.tryPromise({
86
+ try: () => importBundledModule(result),
87
+ catch: (cause) => new BundlerError({ cause }),
88
+ });
89
+ if (!result.metafile) {
90
+ return yield* Effect.dieMessage("esbuild metafile missing");
91
+ }
92
+ return { module, metafile: result.metafile };
93
+ });
94
+
95
+ const findMetafileInputKey = (
96
+ metafile: esbuild.Metafile,
97
+ absolutePath: string,
98
+ ) =>
99
+ Effect.gen(function* () {
100
+ const path = yield* Path.Path;
101
+ const resolved = path.resolve(absolutePath);
102
+ return Array.findFirst(
103
+ Object.keys(metafile.inputs),
104
+ (key) => path.resolve(key) === resolved,
105
+ );
106
+ });
107
+
108
+ /**
109
+ * Returns `true` when the module bundled from `sourceAbsolutePath` declares a
110
+ * direct import of `targetAbsolutePath` (according to the bundle's esbuild
111
+ * metafile). Returns `false` if either path is missing from the metafile.
112
+ */
113
+ export const directlyImports = (
114
+ bundled: Bundled,
115
+ sourceAbsolutePath: string,
116
+ targetAbsolutePath: string,
117
+ ) =>
118
+ Effect.gen(function* () {
119
+ const path = yield* Path.Path;
120
+ const sourceKey = yield* findMetafileInputKey(
121
+ bundled.metafile,
122
+ sourceAbsolutePath,
123
+ );
124
+ const targetKey = yield* findMetafileInputKey(
125
+ bundled.metafile,
126
+ targetAbsolutePath,
127
+ );
128
+
129
+ return pipe(
130
+ Option.all([sourceKey, targetKey]),
131
+ Option.flatMap(([sourceKey_, targetKey_]) =>
132
+ Option.fromNullable(bundled.metafile.inputs[sourceKey_]).pipe(
133
+ Option.map((sourceInput) => {
134
+ const targetResolved = path.resolve(targetKey_);
135
+ return sourceInput.imports.some(
136
+ (importedFile) =>
137
+ path.resolve(importedFile.path) === targetResolved,
138
+ );
139
+ }),
140
+ ),
141
+ ),
142
+ Option.getOrElse(() => false),
143
+ );
144
+ });
@@ -0,0 +1,65 @@
1
+ import type { Options as CodeBlockWriterOptions } from "code-block-writer";
2
+ import CodeBlockWriter_ from "code-block-writer";
3
+ import { Effect } from "effect";
4
+
5
+ export class CodeBlockWriter {
6
+ private readonly writer: CodeBlockWriter_;
7
+
8
+ constructor(opts?: Partial<CodeBlockWriterOptions>) {
9
+ this.writer = new CodeBlockWriter_(opts);
10
+ }
11
+
12
+ indent<E = never, R = never>(
13
+ effect: Effect.Effect<void, E, R>,
14
+ ): Effect.Effect<void, E, R> {
15
+ return Effect.gen(this, function* () {
16
+ const indentationLevel = this.writer.getIndentationLevel();
17
+ this.writer.setIndentationLevel(indentationLevel + 1);
18
+ yield* effect;
19
+ this.writer.setIndentationLevel(indentationLevel);
20
+ });
21
+ }
22
+
23
+ writeLine<E = never, R = never>(line: string): Effect.Effect<void, E, R> {
24
+ return Effect.sync(() => {
25
+ this.writer.writeLine(line);
26
+ });
27
+ }
28
+
29
+ write<E = never, R = never>(text: string): Effect.Effect<void, E, R> {
30
+ return Effect.sync(() => {
31
+ this.writer.write(text);
32
+ });
33
+ }
34
+
35
+ quote<E = never, R = never>(text: string): Effect.Effect<void, E, R> {
36
+ return Effect.sync(() => {
37
+ this.writer.quote(text);
38
+ });
39
+ }
40
+
41
+ conditionalWriteLine<E = never, R = never>(
42
+ condition: boolean,
43
+ text: string,
44
+ ): Effect.Effect<void, E, R> {
45
+ return Effect.sync(() => {
46
+ this.writer.conditionalWriteLine(condition, text);
47
+ });
48
+ }
49
+
50
+ newLine<E = never, R = never>(): Effect.Effect<void, E, R> {
51
+ return Effect.sync(() => {
52
+ this.writer.newLine();
53
+ });
54
+ }
55
+
56
+ blankLine<E = never, R = never>(): Effect.Effect<void, E, R> {
57
+ return Effect.sync(() => {
58
+ this.writer.blankLine();
59
+ });
60
+ }
61
+
62
+ toString<E = never, R = never>(): Effect.Effect<string, E, R> {
63
+ return Effect.sync(() => this.writer.toString());
64
+ }
65
+ }
@@ -0,0 +1,376 @@
1
+ import { Ansi, AnsiDoc } from "@effect/printer-ansi";
2
+ import { Effect, Match, Option, pipe, Schema } from "effect";
3
+ import { BuildError, isBuildError, renderBuildError } from "./BuildError";
4
+ import { formatPathDoc } from "./log";
5
+
6
+ // --- Variants ---
7
+
8
+ export class MissingImplFileError extends Schema.TaggedError<MissingImplFileError>()(
9
+ "MissingImplFileError",
10
+ {
11
+ specPath: Schema.String,
12
+ expectedImplPath: Schema.String,
13
+ },
14
+ ) {}
15
+
16
+ export class MissingSpecFileError extends Schema.TaggedError<MissingSpecFileError>()(
17
+ "MissingSpecFileError",
18
+ {
19
+ implPath: Schema.String,
20
+ expectedSpecPath: Schema.String,
21
+ },
22
+ ) {}
23
+
24
+ export class SpecMissingDefaultGroupSpecError extends Schema.TaggedError<SpecMissingDefaultGroupSpecError>()(
25
+ "SpecMissingDefaultGroupSpecError",
26
+ {
27
+ specPath: Schema.String,
28
+ },
29
+ ) {}
30
+
31
+ export class SpecRuntimeMismatchError extends Schema.TaggedError<SpecRuntimeMismatchError>()(
32
+ "SpecRuntimeMismatchError",
33
+ {
34
+ specPath: Schema.String,
35
+ expectedRuntime: Schema.Literal("Convex", "Node"),
36
+ actualRuntime: Schema.Literal("Convex", "Node"),
37
+ },
38
+ ) {}
39
+
40
+ export class ImplMissingSpecImportError extends Schema.TaggedError<ImplMissingSpecImportError>()(
41
+ "ImplMissingSpecImportError",
42
+ {
43
+ implPath: Schema.String,
44
+ expectedSpecPath: Schema.String,
45
+ },
46
+ ) {}
47
+
48
+ export class ImplMissingDefaultLayerError extends Schema.TaggedError<ImplMissingDefaultLayerError>()(
49
+ "ImplMissingDefaultLayerError",
50
+ {
51
+ implPath: Schema.String,
52
+ },
53
+ ) {}
54
+
55
+ export class ImplNotFinalizedError extends Schema.TaggedError<ImplNotFinalizedError>()(
56
+ "ImplNotFinalizedError",
57
+ {
58
+ implPath: Schema.String,
59
+ },
60
+ ) {}
61
+
62
+ export class ImplMissingFunctionsError extends Schema.TaggedError<ImplMissingFunctionsError>()(
63
+ "ImplMissingFunctionsError",
64
+ {
65
+ implPath: Schema.String,
66
+ groupPath: Schema.String,
67
+ missingFunctionNames: Schema.Array(Schema.String),
68
+ },
69
+ ) {}
70
+
71
+ export class SchemaInvalidDefaultExportError extends Schema.TaggedError<SchemaInvalidDefaultExportError>()(
72
+ "SchemaInvalidDefaultExportError",
73
+ {
74
+ schemaPath: Schema.String,
75
+ },
76
+ ) {}
77
+
78
+ export class ParentChildNameCollisionError extends Schema.TaggedError<ParentChildNameCollisionError>()(
79
+ "ParentChildNameCollisionError",
80
+ {
81
+ parentSpecPath: Schema.String,
82
+ childSpecPath: Schema.String,
83
+ collisionName: Schema.String,
84
+ collisionKind: Schema.Literal("function", "group"),
85
+ },
86
+ ) {}
87
+
88
+ export class MissingSchemaFileError extends Schema.TaggedError<MissingSchemaFileError>()(
89
+ "MissingSchemaFileError",
90
+ {
91
+ schemaPath: Schema.String,
92
+ },
93
+ ) {}
94
+
95
+ export const CodegenError = Schema.Union(
96
+ BuildError,
97
+ MissingImplFileError,
98
+ MissingSpecFileError,
99
+ SpecMissingDefaultGroupSpecError,
100
+ SpecRuntimeMismatchError,
101
+ ImplMissingSpecImportError,
102
+ ImplMissingDefaultLayerError,
103
+ ImplNotFinalizedError,
104
+ ImplMissingFunctionsError,
105
+ SchemaInvalidDefaultExportError,
106
+ MissingSchemaFileError,
107
+ ParentChildNameCollisionError,
108
+ );
109
+ export type CodegenError = typeof CodegenError.Type;
110
+
111
+ export const isCodegenError = (error: unknown): error is CodegenError => {
112
+ if (isBuildError(error)) return true;
113
+ return Schema.is(CodegenError)(error);
114
+ };
115
+
116
+ // --- Per-variant rendering ---
117
+
118
+ const cross = pipe(AnsiDoc.char("✘"), AnsiDoc.annotate(Ansi.red));
119
+
120
+ const stemFromSpecPath = (specPath: string): string => {
121
+ const lastSep = Math.max(
122
+ specPath.lastIndexOf("/"),
123
+ specPath.lastIndexOf("\\"),
124
+ );
125
+ const basename = lastSep < 0 ? specPath : specPath.slice(lastSep + 1);
126
+ return basename.endsWith(".spec.ts")
127
+ ? basename.slice(0, -".spec.ts".length)
128
+ : basename;
129
+ };
130
+
131
+ const singleLine = (
132
+ ...parts: ReadonlyArray<AnsiDoc.AnsiDoc>
133
+ ): AnsiDoc.AnsiDoc => pipe(cross, AnsiDoc.catWithSpace(AnsiDoc.hcat(parts)));
134
+
135
+ const renderMissingImplFileError = (
136
+ error: MissingImplFileError,
137
+ ): AnsiDoc.AnsiDoc =>
138
+ singleLine(
139
+ AnsiDoc.text("Spec "),
140
+ formatPathDoc(error.specPath),
141
+ AnsiDoc.text(" has no sibling impl; create "),
142
+ formatPathDoc(error.expectedImplPath),
143
+ AnsiDoc.text(" and default-export a GroupImpl layer from it."),
144
+ );
145
+
146
+ const renderMissingSpecFileError = (
147
+ error: MissingSpecFileError,
148
+ ): AnsiDoc.AnsiDoc =>
149
+ singleLine(
150
+ AnsiDoc.text("Impl "),
151
+ formatPathDoc(error.implPath),
152
+ AnsiDoc.text(" has no sibling spec; create "),
153
+ formatPathDoc(error.expectedSpecPath),
154
+ AnsiDoc.text(
155
+ " and default-export a GroupSpec from it, or remove the impl.",
156
+ ),
157
+ );
158
+
159
+ const renderSpecMissingDefaultGroupSpecError = (
160
+ error: SpecMissingDefaultGroupSpecError,
161
+ ): AnsiDoc.AnsiDoc =>
162
+ singleLine(
163
+ AnsiDoc.text("Spec "),
164
+ formatPathDoc(error.specPath),
165
+ AnsiDoc.text(
166
+ " must default-export a GroupSpec; build it with GroupSpec.make() or GroupSpec.makeNode().",
167
+ ),
168
+ );
169
+
170
+ const renderSpecRuntimeMismatchError = (
171
+ error: SpecRuntimeMismatchError,
172
+ ): AnsiDoc.AnsiDoc => {
173
+ const constructor =
174
+ error.expectedRuntime === "Node"
175
+ ? "GroupSpec.makeNode()"
176
+ : "GroupSpec.make()";
177
+ const moveHint =
178
+ error.expectedRuntime === "Node"
179
+ ? " or move the file into confect/node/."
180
+ : " or move the file out of confect/node/.";
181
+ return singleLine(
182
+ AnsiDoc.text("Spec "),
183
+ formatPathDoc(error.specPath),
184
+ AnsiDoc.text(
185
+ ` declares a ${error.actualRuntime} GroupSpec but its location requires ${error.expectedRuntime}; use ${constructor}${moveHint}`,
186
+ ),
187
+ );
188
+ };
189
+
190
+ const renderImplMissingSpecImportError = (
191
+ error: ImplMissingSpecImportError,
192
+ ): AnsiDoc.AnsiDoc => {
193
+ const stem = stemFromSpecPath(error.expectedSpecPath);
194
+ return singleLine(
195
+ AnsiDoc.text("Impl "),
196
+ formatPathDoc(error.implPath),
197
+ AnsiDoc.text(
198
+ ` does not import its sibling spec; add \`import ${stem} from "./${stem}.spec"\` and pass it to FunctionImpl.make / GroupImpl.make.`,
199
+ ),
200
+ );
201
+ };
202
+
203
+ const renderImplMissingDefaultLayerError = (
204
+ error: ImplMissingDefaultLayerError,
205
+ ): AnsiDoc.AnsiDoc =>
206
+ singleLine(
207
+ AnsiDoc.text("Impl "),
208
+ formatPathDoc(error.implPath),
209
+ AnsiDoc.text(
210
+ " must default-export a GroupImpl layer; wrap your handlers with `GroupImpl.make(api, groupSpec).pipe(Layer.provide(...))` and `export default` it.",
211
+ ),
212
+ );
213
+
214
+ const renderImplNotFinalizedError = (
215
+ error: ImplNotFinalizedError,
216
+ ): AnsiDoc.AnsiDoc =>
217
+ singleLine(
218
+ AnsiDoc.text("Impl "),
219
+ formatPathDoc(error.implPath),
220
+ AnsiDoc.text(
221
+ " is not finalized; append `GroupImpl.finalize` to the end of the pipeline (e.g. `GroupImpl.make(api, group).pipe(Layer.provide(...), GroupImpl.finalize)`).",
222
+ ),
223
+ );
224
+
225
+ const renderImplMissingFunctionsError = (
226
+ error: ImplMissingFunctionsError,
227
+ ): AnsiDoc.AnsiDoc => {
228
+ const names = error.missingFunctionNames.join(", ");
229
+ return singleLine(
230
+ AnsiDoc.text("Impl "),
231
+ formatPathDoc(error.implPath),
232
+ AnsiDoc.text(
233
+ ` does not implement every function declared by group \`${error.groupPath}\`; missing: ${names}. Add a \`FunctionImpl.make\` for each missing function and provide it to the group layer.`,
234
+ ),
235
+ );
236
+ };
237
+
238
+ const renderSchemaInvalidDefaultExportError = (
239
+ error: SchemaInvalidDefaultExportError,
240
+ ): AnsiDoc.AnsiDoc =>
241
+ singleLine(
242
+ AnsiDoc.text("Schema "),
243
+ formatPathDoc(error.schemaPath),
244
+ AnsiDoc.text(
245
+ " must default-export a DatabaseSchema; build it with DatabaseSchema.make({ ... }).",
246
+ ),
247
+ );
248
+
249
+ const renderMissingSchemaFileError = (
250
+ error: MissingSchemaFileError,
251
+ ): AnsiDoc.AnsiDoc =>
252
+ singleLine(
253
+ AnsiDoc.text("Schema "),
254
+ formatPathDoc(error.schemaPath),
255
+ AnsiDoc.text(
256
+ " is required but is missing; create it and default-export a DatabaseSchema (DatabaseSchema.make({ ... })).",
257
+ ),
258
+ );
259
+
260
+ const renderParentChildNameCollisionError = (
261
+ error: ParentChildNameCollisionError,
262
+ ): AnsiDoc.AnsiDoc =>
263
+ singleLine(
264
+ AnsiDoc.text("Spec "),
265
+ formatPathDoc(error.parentSpecPath),
266
+ AnsiDoc.text(
267
+ ` declares a ${error.collisionKind} \`${error.collisionName}\` whose name collides with the sibling subdirectory spec `,
268
+ ),
269
+ formatPathDoc(error.childSpecPath),
270
+ AnsiDoc.text(
271
+ `. Rename one of them so the assembled spec has a unique key at this path.`,
272
+ ),
273
+ );
274
+
275
+ /**
276
+ * Render any {@link CodegenError} into a styled, ready-to-print string.
277
+ * Single-error variants render to a one-line `✘`-prefixed message;
278
+ * `BundleFailedError` (the only multi-error variant) renders to a header
279
+ * plus an esbuild diagnostic block.
280
+ */
281
+ export const renderCodegenError = (error: CodegenError): string => {
282
+ if (isBuildError(error)) return renderBuildError(error);
283
+ return Match.value(error).pipe(
284
+ Match.tag("MissingImplFileError", (e) =>
285
+ pipe(renderMissingImplFileError(e), AnsiDoc.render({ style: "pretty" })),
286
+ ),
287
+ Match.tag("MissingSpecFileError", (e) =>
288
+ pipe(renderMissingSpecFileError(e), AnsiDoc.render({ style: "pretty" })),
289
+ ),
290
+ Match.tag("SpecMissingDefaultGroupSpecError", (e) =>
291
+ pipe(
292
+ renderSpecMissingDefaultGroupSpecError(e),
293
+ AnsiDoc.render({ style: "pretty" }),
294
+ ),
295
+ ),
296
+ Match.tag("SpecRuntimeMismatchError", (e) =>
297
+ pipe(
298
+ renderSpecRuntimeMismatchError(e),
299
+ AnsiDoc.render({ style: "pretty" }),
300
+ ),
301
+ ),
302
+ Match.tag("ImplMissingSpecImportError", (e) =>
303
+ pipe(
304
+ renderImplMissingSpecImportError(e),
305
+ AnsiDoc.render({ style: "pretty" }),
306
+ ),
307
+ ),
308
+ Match.tag("ImplMissingDefaultLayerError", (e) =>
309
+ pipe(
310
+ renderImplMissingDefaultLayerError(e),
311
+ AnsiDoc.render({ style: "pretty" }),
312
+ ),
313
+ ),
314
+ Match.tag("ImplNotFinalizedError", (e) =>
315
+ pipe(renderImplNotFinalizedError(e), AnsiDoc.render({ style: "pretty" })),
316
+ ),
317
+ Match.tag("ImplMissingFunctionsError", (e) =>
318
+ pipe(
319
+ renderImplMissingFunctionsError(e),
320
+ AnsiDoc.render({ style: "pretty" }),
321
+ ),
322
+ ),
323
+ Match.tag("SchemaInvalidDefaultExportError", (e) =>
324
+ pipe(
325
+ renderSchemaInvalidDefaultExportError(e),
326
+ AnsiDoc.render({ style: "pretty" }),
327
+ ),
328
+ ),
329
+ Match.tag("MissingSchemaFileError", (e) =>
330
+ pipe(
331
+ renderMissingSchemaFileError(e),
332
+ AnsiDoc.render({ style: "pretty" }),
333
+ ),
334
+ ),
335
+ Match.tag("ParentChildNameCollisionError", (e) =>
336
+ pipe(
337
+ renderParentChildNameCollisionError(e),
338
+ AnsiDoc.render({ style: "pretty" }),
339
+ ),
340
+ ),
341
+ Match.exhaustive,
342
+ );
343
+ };
344
+
345
+ export const logCodegenError = (error: CodegenError) =>
346
+ Effect.sync(() => console.error(renderCodegenError(error)));
347
+
348
+ // --- Effect combinators ---
349
+
350
+ /**
351
+ * Log any {@link CodegenError} thrown by `effect` and propagate the failure
352
+ * unchanged so the caller's error channel is preserved (used by the
353
+ * `codegen` command, which needs the failure to surface as a non-zero exit
354
+ * code).
355
+ */
356
+ export const tapAndLog = <A, E, R>(
357
+ effect: Effect.Effect<A, E, R>,
358
+ ): Effect.Effect<A, E, R> =>
359
+ effect.pipe(
360
+ Effect.tapError((error) =>
361
+ isCodegenError(error) ? logCodegenError(error) : Effect.void,
362
+ ),
363
+ );
364
+
365
+ /**
366
+ * Catch any {@link CodegenError} thrown by `effect`, log it, and resolve to
367
+ * `Option.none()` (used by the `dev` command's sync loop, which continues
368
+ * after a failed sync rather than exiting). Success resolves to
369
+ * `Option.some(value)`.
370
+ */
371
+ export const catchAndLog = <A, E, R>(
372
+ effect: Effect.Effect<A, E, R>,
373
+ ): Effect.Effect<Option.Option<A>, Exclude<E, CodegenError>, R> =>
374
+ Effect.catchIf(Effect.map(effect, Option.some<A>), isCodegenError, (error) =>
375
+ logCodegenError(error).pipe(Effect.as(Option.none<A>())),
376
+ ) as Effect.Effect<Option.Option<A>, Exclude<E, CodegenError>, R>;