@confect/cli 9.0.0-next.4 → 9.0.0-next.6

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.
@@ -22,9 +22,13 @@ import {
22
22
  Stream,
23
23
  String,
24
24
  } from "effect";
25
+ import {
26
+ externalPlugin,
27
+ loadTsConfig,
28
+ tsconfigPathsToRegExp,
29
+ } from "bundle-require";
25
30
  import * as esbuild from "esbuild";
26
31
  import { logCoalescedBuildErrors } from "../BuildError";
27
- import { absoluteExternalsPlugin } from "../Bundler";
28
32
  import * as CodegenError from "../CodegenError";
29
33
  import { ConfectDirectory } from "../ConfectDirectory";
30
34
  import { ConvexDirectory } from "../ConvexDirectory";
@@ -45,8 +49,14 @@ import { ProjectRoot } from "../ProjectRoot";
45
49
  import { generateAuthConfig, generateCrons, generateHttp } from "../utils";
46
50
  import { codegenHandler, loadPreviousFunctionPaths } from "./codegen";
47
51
 
48
- const GENERATED_SPEC_PATH = "_generated/spec.ts";
49
- const GENERATED_NODE_SPEC_PATH = "_generated/nodeSpec.ts";
52
+ const GENERATED_DIRNAME = "_generated";
53
+
54
+ const GENERATED_SPEC_PATH = Effect.andThen(Path.Path, (path) =>
55
+ path.join(GENERATED_DIRNAME, "spec.ts"),
56
+ );
57
+ const GENERATED_NODE_SPEC_PATH = Effect.andThen(Path.Path, (path) =>
58
+ path.join(GENERATED_DIRNAME, "nodeSpec.ts"),
59
+ );
50
60
 
51
61
  // Quiescence window: the sync loop waits this long for further signals
52
62
  // after each batch. One user edit fires `onEnd` on every esbuild
@@ -95,7 +105,7 @@ const changeChar = (change: "Added" | "Removed" | "Modified") =>
95
105
  Match.value(change).pipe(
96
106
  Match.when("Added", () => ({ char: "+", color: Ansi.green })),
97
107
  Match.when("Removed", () => ({ char: "-", color: Ansi.red })),
98
- Match.when("Modified", () => ({ char: "~", color: Ansi.yellow })),
108
+ Match.when("Modified", () => ({ char: "~", color: Ansi.magenta })),
99
109
  Match.exhaustive,
100
110
  );
101
111
 
@@ -410,10 +420,18 @@ const discoverEntryPoints = Effect.gen(function* () {
410
420
  });
411
421
  });
412
422
 
423
+ const generatedSpecPath = yield* GENERATED_SPEC_PATH;
424
+ const generatedNodeSpecPath = yield* GENERATED_NODE_SPEC_PATH;
425
+
413
426
  const fixedEntryOptions = yield* Effect.all([
414
- tryEntry(GENERATED_SPEC_PATH, "specDirty"),
415
- tryEntry(GENERATED_NODE_SPEC_PATH, "specDirty"),
416
- tryEntry("schema.ts", "specDirty"),
427
+ tryEntry(generatedSpecPath, "specDirty"),
428
+ tryEntry(generatedNodeSpecPath, "specDirty"),
429
+ // `confect/schema.ts` is no longer user-authored; the runtime
430
+ // `DatabaseSchema` lives at `_generated/schema.ts` (codegen-written,
431
+ // so not an entry point — wiring it through esbuild would form a
432
+ // codegen→write→onEnd→codegen loop). Updates to `confect/tables/*.ts`
433
+ // still reach this dev loop via the impl entry points' import graphs;
434
+ // brand-new tables are caught by the Create-event safety net below.
417
435
  tryEntry("http.ts", "httpDirty"),
418
436
  tryEntry("crons.ts", "cronsDirty"),
419
437
  tryEntry("auth.ts", "authDirty"),
@@ -430,6 +448,7 @@ const discoverEntryPoints = Effect.gen(function* () {
430
448
 
431
449
  const esbuildOptions = (
432
450
  entry: EntryPoint,
451
+ notExternal: ReadonlyArray<RegExp>,
433
452
  signal: Queue.Queue<void>,
434
453
  pendingRef: Ref.Ref<Pending>,
435
454
  watcherErrorsRef: Ref.Ref<WatcherErrors>,
@@ -451,7 +470,7 @@ const esbuildOptions = (
451
470
  format: "esm" as const,
452
471
  logLevel: "silent" as const,
453
472
  plugins: [
454
- absoluteExternalsPlugin,
473
+ externalPlugin({ notExternal: [...notExternal] }),
455
474
  {
456
475
  name: "notify-rebuild",
457
476
  setup(build: esbuild.PluginBuild) {
@@ -489,6 +508,7 @@ const esbuildOptions = (
489
508
 
490
509
  const createEntryPointWatcher = (
491
510
  entry: EntryPoint,
511
+ notExternal: ReadonlyArray<RegExp>,
492
512
  signal: Queue.Queue<void>,
493
513
  pendingRef: Ref.Ref<Pending>,
494
514
  watcherErrorsRef: Ref.Ref<WatcherErrors>,
@@ -496,7 +516,13 @@ const createEntryPointWatcher = (
496
516
  Effect.acquireRelease(
497
517
  Effect.promise(async () => {
498
518
  const ctx = await esbuild.context(
499
- esbuildOptions(entry, signal, pendingRef, watcherErrorsRef),
519
+ esbuildOptions(
520
+ entry,
521
+ notExternal,
522
+ signal,
523
+ pendingRef,
524
+ watcherErrorsRef,
525
+ ),
500
526
  );
501
527
  await ctx.watch();
502
528
  return ctx;
@@ -534,6 +560,17 @@ const entryPointsWatcher = (
534
560
  Effect.gen(function* () {
535
561
  const parentScope = yield* Effect.scope;
536
562
  const scopesRef = yield* Ref.make(new Map<string, Scope.CloseableScope>());
563
+ const projectRoot = yield* ProjectRoot.get;
564
+ // Discover the user's `tsconfig.json#paths` once at watcher startup so
565
+ // `~/...`-style aliases pointing into the user's source tree get bundled
566
+ // by esbuild instead of externalized via `bundle-require`'s `node_modules`
567
+ // heuristic. `loadTsConfig` walks up from `projectRoot` to find a
568
+ // `tsconfig.json`; if none exists, `paths` is empty and `notExternal` is
569
+ // `[]`, leaving the externalization rule unchanged.
570
+ const tsconfig = loadTsConfig(projectRoot);
571
+ const notExternal = tsconfigPathsToRegExp(
572
+ tsconfig?.data.compilerOptions?.paths ?? {},
573
+ );
537
574
 
538
575
  const sync = Effect.gen(function* () {
539
576
  const desired = yield* discoverEntryPoints;
@@ -569,6 +606,7 @@ const entryPointsWatcher = (
569
606
  );
570
607
  yield* createEntryPointWatcher(
571
608
  entry,
609
+ notExternal,
572
610
  signal,
573
611
  pendingRef,
574
612
  watcherErrorsRef,
@@ -679,6 +717,10 @@ const handleConfectChange = ({
679
717
  );
680
718
  }
681
719
 
720
+ // A stray `confect/schema.ts` (now codegen-owned at
721
+ // `_generated/schema.ts`) shouldn't exist; flagging it here ensures the
722
+ // next codegen pass surfaces the migration error
723
+ // (`LegacySchemaFileError`) instead of silently ignoring the file.
682
724
  if (relativePath === "schema.ts") {
683
725
  return flipDirtyAndSignal(
684
726
  pendingRef,
@@ -699,7 +741,7 @@ const handleConfectChange = ({
699
741
  );
700
742
  }
701
743
 
702
- // Any other `.ts` under `confect/` (helpers like `tables/Notes.ts`).
744
+ // Any other `.ts` under `confect/` (helpers like `tables/notes.ts`).
703
745
  // Updates to such files are handled by the esbuild watcher for whichever
704
746
  // entry point imports them — its onEnd flips the right dirty flag.
705
747
  // Creates are our safety net: when a previously-missing import is added,
package/src/log.ts CHANGED
@@ -57,7 +57,7 @@ export const logFileAdded = logFile("+", Ansi.green);
57
57
 
58
58
  export const logFileRemoved = logFile("-", Ansi.red);
59
59
 
60
- export const logFileModified = logFile("~", Ansi.yellow);
60
+ export const logFileModified = logFile("~", Ansi.magenta);
61
61
 
62
62
  // --- Function subline logs ---
63
63
 
@@ -101,4 +101,6 @@ export const logSuccess = logStatus("✔︎", Ansi.green);
101
101
 
102
102
  export const logFailure = logStatus("✘", Ansi.red);
103
103
 
104
- export const logPending = logStatus("⭘", Ansi.yellow);
104
+ export const logPending = logStatus("⭘", Ansi.cyan);
105
+
106
+ export const logWarn = logStatus("⚠", Ansi.yellow);
package/src/templates.ts CHANGED
@@ -2,7 +2,6 @@ import { Array, Effect, Option } from "effect";
2
2
  import { CodeBlockWriter } from "./CodeBlockWriter";
3
3
  import {
4
4
  collectImportBindings,
5
- collectLeafPaths,
6
5
  type SpecAssemblyNode,
7
6
  } from "./SpecAssemblyNode";
8
7
 
@@ -36,15 +35,197 @@ export const functions = ({
36
35
  return yield* cbw.toString();
37
36
  });
38
37
 
39
- export const schema = ({ schemaImportPath }: { schemaImportPath: string }) =>
38
+ /**
39
+ * Emit `convex/schema.ts` as a one-line re-export of the codegen-emitted
40
+ * deploy schema in `confect/_generated/convexSchema.ts`. Deploy-time
41
+ * consumers (the Convex CLI, `convex-test`) keep reading
42
+ * `convex/schema.ts`; the runtime `DatabaseSchema` in
43
+ * `confect/_generated/schema.ts` is untouched by this file.
44
+ */
45
+ export const schema = ({
46
+ convexSchemaImportPath,
47
+ }: {
48
+ convexSchemaImportPath: string;
49
+ }) =>
40
50
  Effect.gen(function* () {
41
51
  const cbw = new CodeBlockWriter({ indentNumberOfSpaces: 2 });
42
52
 
43
- yield* cbw.writeLine(`import schemaDefinition from "${schemaImportPath}";`);
44
- yield* cbw.newLine();
45
53
  yield* cbw.writeLine(
46
- `export default schemaDefinition.convexSchemaDefinition;`,
54
+ `export { default } from "${convexSchemaImportPath}";`,
55
+ );
56
+
57
+ return yield* cbw.toString();
58
+ });
59
+
60
+ interface TableModuleBinding {
61
+ readonly importPath: string;
62
+ readonly tableName: string;
63
+ }
64
+
65
+ /**
66
+ * Emit `confect/_generated/schema.ts` — the runtime `DatabaseSchema` used
67
+ * by impls and the per-group registries (and downstream by per-function
68
+ * bundles for codec lookup). Every table wrapper at
69
+ * `confect/_generated/tables/<name>.ts` is imported statically and
70
+ * registered as a value entry on the `DatabaseSchema.make({...})` call.
71
+ * Per-table laziness lives inside each `Table`: its `Fields`, `Doc`, and
72
+ * `tableDefinition` are lazy memoised getters that only evaluate the
73
+ * user-supplied field-schema callback on first access, so unused tables in
74
+ * a function bundle never pay schema-construction cost despite the
75
+ * static import.
76
+ *
77
+ * The `DatabaseSchema` import is aliased to `$DatabaseSchema` because each
78
+ * table is imported under its own (filename-derived) name; a table named
79
+ * `DatabaseSchema` would otherwise collide with the library import and emit
80
+ * a duplicate-binding file. The leading `$` makes the alias collision-proof:
81
+ * `validateConfectTableIdentifier` requires names to match
82
+ * `/^[a-zA-Z][a-zA-Z0-9_]*$/`, which forbids `$`, so no valid table import
83
+ * can ever shadow it.
84
+ */
85
+ export const runtimeSchema = ({
86
+ tableModules,
87
+ }: {
88
+ tableModules: ReadonlyArray<TableModuleBinding>;
89
+ }) =>
90
+ Effect.gen(function* () {
91
+ const cbw = new CodeBlockWriter({ indentNumberOfSpaces: 2 });
92
+
93
+ yield* cbw.writeLine(
94
+ `import { DatabaseSchema as $DatabaseSchema } from "@confect/server";`,
95
+ );
96
+
97
+ if (tableModules.length > 0) {
98
+ yield* cbw.blankLine();
99
+ yield* Effect.forEach(tableModules, ({ tableName, importPath }) =>
100
+ cbw.writeLine(`import ${tableName} from "${importPath}";`),
101
+ );
102
+ }
103
+
104
+ yield* cbw.blankLine();
105
+
106
+ if (tableModules.length === 0) {
107
+ yield* cbw.writeLine(`export default $DatabaseSchema.make({});`);
108
+ } else {
109
+ yield* cbw.writeLine(`export default $DatabaseSchema.make({`);
110
+ yield* cbw.indent(
111
+ Effect.gen(function* () {
112
+ for (const { tableName } of tableModules) {
113
+ yield* cbw.writeLine(`${tableName},`);
114
+ }
115
+ }),
116
+ );
117
+ yield* cbw.writeLine(`});`);
118
+ }
119
+
120
+ return yield* cbw.toString();
121
+ });
122
+
123
+ /**
124
+ * Emit `confect/_generated/convexSchema.ts` — the Convex deploy-time
125
+ * `SchemaDefinition`. Imports every table from its generated wrapper at
126
+ * `_generated/tables/<name>` and calls `defineSchema({...})` exactly once.
127
+ * The file deliberately avoids any `@confect/server` import so that the
128
+ * deploy artifact's import graph stays decoupled from the runtime
129
+ * `DatabaseSchema` machinery.
130
+ *
131
+ * The `defineSchema` import is aliased to `$defineSchema` because each table
132
+ * is imported under its own (filename-derived) name; a table named
133
+ * `defineSchema` would otherwise collide with the library import and emit a
134
+ * duplicate-binding file. The leading `$` makes the alias collision-proof:
135
+ * `validateConfectTableIdentifier` requires names to match
136
+ * `/^[a-zA-Z][a-zA-Z0-9_]*$/`, which forbids `$`, so no valid table import
137
+ * can ever shadow it.
138
+ */
139
+ export const convexSchema = ({
140
+ tableModules,
141
+ }: {
142
+ tableModules: ReadonlyArray<TableModuleBinding>;
143
+ }) =>
144
+ Effect.gen(function* () {
145
+ const cbw = new CodeBlockWriter({ indentNumberOfSpaces: 2 });
146
+
147
+ yield* cbw.writeLine(
148
+ `import { defineSchema as $defineSchema } from "convex/server";`,
149
+ );
150
+
151
+ if (tableModules.length > 0) {
152
+ yield* cbw.blankLine();
153
+ yield* Effect.forEach(tableModules, ({ tableName, importPath }) =>
154
+ cbw.writeLine(`import ${tableName} from "${importPath}";`),
155
+ );
156
+ }
157
+
158
+ yield* cbw.blankLine();
159
+
160
+ if (tableModules.length === 0) {
161
+ yield* cbw.writeLine(`export default $defineSchema({});`);
162
+ } else {
163
+ yield* cbw.writeLine(`export default $defineSchema({`);
164
+ yield* cbw.indent(
165
+ Effect.gen(function* () {
166
+ for (const { tableName } of tableModules) {
167
+ yield* cbw.writeLine(`${tableName}: ${tableName}.tableDefinition,`);
168
+ }
169
+ }),
170
+ );
171
+ yield* cbw.writeLine(`});`);
172
+ }
173
+
174
+ return yield* cbw.toString();
175
+ });
176
+
177
+ /**
178
+ * Emit `confect/_generated/id.ts` — a type-constrained `Id` constructor and
179
+ * a `TableNames` union derived from the user's `confect/tables/*.ts`
180
+ * filenames. User-authored table modules import `Id` from this file to
181
+ * declare cross-table id references without typing the destination name as
182
+ * a free string (and without ever importing each other transitively).
183
+ *
184
+ * When the table directory is empty the `TableNames` union resolves to
185
+ * `never`, which still lets the file typecheck against an empty workspace.
186
+ */
187
+ export const id = ({ tableNames }: { tableNames: ReadonlyArray<string> }) =>
188
+ Effect.gen(function* () {
189
+ const cbw = new CodeBlockWriter({ indentNumberOfSpaces: 2 });
190
+
191
+ yield* cbw.writeLine(`import { GenericId } from "@confect/core";`);
192
+ yield* cbw.blankLine();
193
+
194
+ const union =
195
+ tableNames.length === 0
196
+ ? "never"
197
+ : tableNames.map((n) => `"${n}"`).join(" | ");
198
+ yield* cbw.writeLine(`export type TableNames = ${union};`);
199
+ yield* cbw.blankLine();
200
+
201
+ yield* cbw.writeLine(
202
+ `export const Id = <const TableName extends TableNames>(`,
47
203
  );
204
+ yield* cbw.indent(cbw.writeLine(`tableName: TableName,`));
205
+ yield* cbw.writeLine(`) => GenericId.GenericId(tableName);`);
206
+
207
+ return yield* cbw.toString();
208
+ });
209
+
210
+ /**
211
+ * Emit `confect/_generated/tables/<tableName>.ts` — a two-line wrapper that
212
+ * imports the user-authored `UnnamedTable` and binds the file basename to
213
+ * it, producing the fully-named `Table` value that downstream consumers
214
+ * (schema, specs, impls) read.
215
+ */
216
+ export const tableWrapper = ({
217
+ tableName,
218
+ unnamedImportPath,
219
+ }: {
220
+ tableName: string;
221
+ unnamedImportPath: string;
222
+ }) =>
223
+ Effect.gen(function* () {
224
+ const cbw = new CodeBlockWriter({ indentNumberOfSpaces: 2 });
225
+
226
+ yield* cbw.writeLine(`import unnamed from "${unnamedImportPath}";`);
227
+ yield* cbw.blankLine();
228
+ yield* cbw.writeLine(`export default unnamed("${tableName}");`);
48
229
 
49
230
  return yield* cbw.toString();
50
231
  });
@@ -110,54 +291,15 @@ export const refs = ({
110
291
  return yield* cbw.toString();
111
292
  });
112
293
 
113
- export const api = ({
294
+ export const registeredFunctionsForGroup = ({
114
295
  schemaImportPath,
115
296
  specImportPath,
116
- }: {
117
- schemaImportPath: string;
118
- specImportPath: string;
119
- }) =>
120
- Effect.gen(function* () {
121
- const cbw = new CodeBlockWriter({ indentNumberOfSpaces: 2 });
122
-
123
- yield* cbw.writeLine(`import { Api } from "@confect/server";`);
124
- yield* cbw.writeLine(`import schema from "${schemaImportPath}";`);
125
- yield* cbw.writeLine(`import spec from "${specImportPath}";`);
126
- yield* cbw.blankLine();
127
- yield* cbw.writeLine(`export default Api.make(schema, spec);`);
128
-
129
- return yield* cbw.toString();
130
- });
131
-
132
- export const nodeApi = ({
133
- schemaImportPath,
134
- nodeSpecImportPath,
135
- }: {
136
- schemaImportPath: string;
137
- nodeSpecImportPath: string;
138
- }) =>
139
- Effect.gen(function* () {
140
- const cbw = new CodeBlockWriter({ indentNumberOfSpaces: 2 });
141
-
142
- yield* cbw.writeLine(`import { Api } from "@confect/server";`);
143
- yield* cbw.blankLine();
144
- yield* cbw.writeLine(`import schema from "${schemaImportPath}";`);
145
- yield* cbw.writeLine(`import nodeSpec from "${nodeSpecImportPath}";`);
146
- yield* cbw.blankLine();
147
- yield* cbw.writeLine(`export default Api.make(schema, nodeSpec);`);
148
-
149
- return yield* cbw.toString();
150
- });
151
-
152
- export const registeredFunctionsForGroup = ({
153
- apiImportPath,
154
- groupPathDot,
155
297
  implImportPath,
156
298
  layerExportName,
157
299
  useNode = false,
158
300
  }: {
159
- apiImportPath: string;
160
- groupPathDot: string;
301
+ schemaImportPath: string;
302
+ specImportPath: string;
161
303
  implImportPath: string;
162
304
  layerExportName: string;
163
305
  useNode?: boolean;
@@ -178,14 +320,20 @@ export const registeredFunctionsForGroup = ({
178
320
  );
179
321
  }
180
322
 
181
- yield* cbw.writeLine(`import api from "${apiImportPath}";`);
323
+ yield* cbw.writeLine(`import databaseSchema from "${schemaImportPath}";`);
182
324
  yield* cbw.writeLine(`import ${layerExportName} from "${implImportPath}";`);
183
325
  yield* cbw.blankLine();
184
- const quotedGroupPath = `"${groupPathDot.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
326
+ // The group's own leaf spec is referenced type-only (`typeof import(...)`),
327
+ // so the spec module is erased at transpile time and never enters the
328
+ // per-function bundle; only `databaseSchema` and the impl are runtime
329
+ // imports. Typing from the leaf spec (not the project-wide assembled spec)
330
+ // keeps the registry's type dependent solely on its own group.
331
+ const specType = `typeof import("${specImportPath}")["default"]`;
332
+ const makeFn = useNode
333
+ ? "RegisteredNodeFunction.make"
334
+ : "RegisteredConvexFunction.make";
185
335
  yield* cbw.writeLine(
186
- useNode
187
- ? `export default RegisteredFunctions.buildForGroup(api, ${quotedGroupPath}, ${layerExportName}, RegisteredNodeFunction.make);`
188
- : `export default RegisteredFunctions.buildForGroup(api, ${quotedGroupPath}, ${layerExportName}, RegisteredConvexFunction.make);`,
336
+ `export default RegisteredFunctions.buildForGroup<${specType}>(databaseSchema, ${layerExportName}, ${makeFn});`,
189
337
  );
190
338
 
191
339
  return yield* cbw.toString();
@@ -473,13 +621,6 @@ export const assembledSpec = ({
473
621
  runtime === "Convex" ? "GroupSpec.makeAt" : "GroupSpec.makeNodeAt";
474
622
 
475
623
  yield* cbw.write(`export default ${specFactory}`);
476
- yield* Effect.forEach(collectLeafPaths(nodes), (leaf) =>
477
- Effect.gen(function* () {
478
- yield* cbw.write(`.addPath(${leaf.binding.localName}, `);
479
- yield* cbw.quote(leaf.dotPath);
480
- yield* cbw.write(")");
481
- }),
482
- );
483
624
  yield* Effect.forEach(nodes, (node) =>
484
625
  writeRootAddAt(cbw, node, groupFactory),
485
626
  );