@confect/cli 9.0.0-next.5 → 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.
package/package.json CHANGED
@@ -1,42 +1,14 @@
1
1
  {
2
2
  "name": "@confect/cli",
3
- "version": "9.0.0-next.5",
4
3
  "description": "Developer tooling for codegen and sync",
5
- "repository": {
6
- "type": "git",
7
- "url": "https://github.com/rjdellecese/confect.git"
8
- },
9
- "bugs": {
10
- "url": "https://github.com/rjdellecese/confect/issues"
11
- },
12
- "homepage": "https://confect.dev",
13
- "sideEffects": false,
14
- "type": "module",
4
+ "version": "9.0.0-next.6",
5
+ "author": "RJ Dellecese",
15
6
  "bin": {
16
7
  "confect": "./dist/index.mjs"
17
8
  },
18
- "files": [
19
- "CHANGELOG.md",
20
- "LICENSE",
21
- "README.md",
22
- "dist",
23
- "package.json",
24
- "src"
25
- ],
26
- "exports": {
27
- ".": {
28
- "types": "./dist/index.d.ts",
29
- "default": "./dist/index.js"
30
- },
31
- "./package.json": "./package.json"
9
+ "bugs": {
10
+ "url": "https://github.com/rjdellecese/confect/issues"
32
11
  },
33
- "keywords": [
34
- "effect",
35
- "convex",
36
- "cli"
37
- ],
38
- "author": "RJ Dellecese",
39
- "license": "ISC",
40
12
  "dependencies": {
41
13
  "@effect/cli": "^0.75.1",
42
14
  "@effect/platform": "0.96.1",
@@ -64,26 +36,53 @@
64
36
  "vite-tsconfig-paths": "6.1.1",
65
37
  "vitest": "3.2.4"
66
38
  },
67
- "peerDependencies": {
68
- "effect": "^3.21.2",
69
- "@confect/core": "^9.0.0-next.5",
70
- "@confect/server": "^9.0.0-next.5"
71
- },
72
39
  "engines": {
73
- "node": ">=22",
74
- "pnpm": ">=10"
40
+ "node": ">=22"
41
+ },
42
+ "exports": {
43
+ ".": {
44
+ "types": "./dist/index.d.ts",
45
+ "default": "./dist/index.js"
46
+ },
47
+ "./package.json": "./package.json"
75
48
  },
49
+ "files": [
50
+ "CHANGELOG.md",
51
+ "LICENSE",
52
+ "README.md",
53
+ "dist",
54
+ "package.json",
55
+ "src"
56
+ ],
57
+ "homepage": "https://confect.dev",
58
+ "keywords": [
59
+ "cli",
60
+ "convex",
61
+ "effect"
62
+ ],
63
+ "license": "ISC",
76
64
  "main": "./dist/index.js",
77
65
  "module": "./dist/index.js",
66
+ "peerDependencies": {
67
+ "effect": "^3.21.2",
68
+ "@confect/core": "^9.0.0-next.6",
69
+ "@confect/server": "^9.0.0-next.6"
70
+ },
71
+ "repository": {
72
+ "type": "git",
73
+ "url": "https://github.com/rjdellecese/confect.git"
74
+ },
75
+ "sideEffects": false,
76
+ "type": "module",
78
77
  "types": "./dist/index.d.ts",
79
78
  "scripts": {
80
79
  "build": "tsdown --config-loader unrun",
80
+ "clean": "rm -rf dist coverage node_modules",
81
81
  "dev": "tsdown --watch --config-loader unrun",
82
- "test": "vitest run",
83
- "typecheck": "tsc --noEmit --project tsconfig.json",
84
82
  "fix": "prettier --write . && eslint --fix . --max-warnings=0",
85
83
  "format": "prettier --check .",
86
84
  "lint": "eslint . --max-warnings=0",
87
- "clean": "rm -rf dist coverage node_modules"
85
+ "test": "vitest run",
86
+ "typecheck": "tsc --noEmit --project tsconfig.json"
88
87
  }
89
88
  }
@@ -68,13 +68,6 @@ export class ImplMissingFunctionsError extends Schema.TaggedError<ImplMissingFun
68
68
  },
69
69
  ) {}
70
70
 
71
- export class SchemaInvalidDefaultExportError extends Schema.TaggedError<SchemaInvalidDefaultExportError>()(
72
- "SchemaInvalidDefaultExportError",
73
- {
74
- schemaPath: Schema.String,
75
- },
76
- ) {}
77
-
78
71
  export class ParentChildNameCollisionError extends Schema.TaggedError<ParentChildNameCollisionError>()(
79
72
  "ParentChildNameCollisionError",
80
73
  {
@@ -85,8 +78,39 @@ export class ParentChildNameCollisionError extends Schema.TaggedError<ParentChil
85
78
  },
86
79
  ) {}
87
80
 
88
- export class MissingSchemaFileError extends Schema.TaggedError<MissingSchemaFileError>()(
89
- "MissingSchemaFileError",
81
+ export class InvalidTableDefaultExportError extends Schema.TaggedError<InvalidTableDefaultExportError>()(
82
+ "InvalidTableDefaultExportError",
83
+ {
84
+ tablePath: Schema.String,
85
+ },
86
+ ) {}
87
+
88
+ export class InvalidTableFilenameError extends Schema.TaggedError<InvalidTableFilenameError>()(
89
+ "InvalidTableFilenameError",
90
+ {
91
+ tablePath: Schema.String,
92
+ reason: Schema.String,
93
+ },
94
+ ) {}
95
+
96
+ export class DuplicateTableNameError extends Schema.TaggedError<DuplicateTableNameError>()(
97
+ "DuplicateTableNameError",
98
+ {
99
+ // Every table name that more than one file resolves to, each paired with
100
+ // the colliding file paths. All collisions are captured in a single pass
101
+ // so the user can fix them together rather than re-running codegen once
102
+ // per conflict.
103
+ collisions: Schema.Array(
104
+ Schema.Struct({
105
+ tableName: Schema.String,
106
+ tablePaths: Schema.Array(Schema.String),
107
+ }),
108
+ ),
109
+ },
110
+ ) {}
111
+
112
+ export class LegacySchemaFileError extends Schema.TaggedError<LegacySchemaFileError>()(
113
+ "LegacySchemaFileError",
90
114
  {
91
115
  schemaPath: Schema.String,
92
116
  },
@@ -102,9 +126,11 @@ export const CodegenError = Schema.Union(
102
126
  ImplMissingDefaultLayerError,
103
127
  ImplNotFinalizedError,
104
128
  ImplMissingFunctionsError,
105
- SchemaInvalidDefaultExportError,
106
- MissingSchemaFileError,
107
129
  ParentChildNameCollisionError,
130
+ InvalidTableDefaultExportError,
131
+ InvalidTableFilenameError,
132
+ DuplicateTableNameError,
133
+ LegacySchemaFileError,
108
134
  );
109
135
  export type CodegenError = typeof CodegenError.Type;
110
136
 
@@ -207,7 +233,7 @@ const renderImplMissingDefaultLayerError = (
207
233
  AnsiDoc.text("Impl "),
208
234
  formatPathDoc(error.implPath),
209
235
  AnsiDoc.text(
210
- " must default-export a GroupImpl layer; wrap your handlers with `GroupImpl.make(api, groupSpec).pipe(Layer.provide(...))` and `export default` it.",
236
+ " must default-export a GroupImpl layer; wrap your handlers with `GroupImpl.make(databaseSchema, groupSpec).pipe(Layer.provide(...))` and `export default` it.",
211
237
  ),
212
238
  );
213
239
 
@@ -218,7 +244,7 @@ const renderImplNotFinalizedError = (
218
244
  AnsiDoc.text("Impl "),
219
245
  formatPathDoc(error.implPath),
220
246
  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)`).",
247
+ " is not finalized; append `GroupImpl.finalize` to the end of the pipeline (e.g. `GroupImpl.make(databaseSchema, group).pipe(Layer.provide(...), GroupImpl.finalize)`).",
222
248
  ),
223
249
  );
224
250
 
@@ -235,25 +261,52 @@ const renderImplMissingFunctionsError = (
235
261
  );
236
262
  };
237
263
 
238
- const renderSchemaInvalidDefaultExportError = (
239
- error: SchemaInvalidDefaultExportError,
264
+ const renderInvalidTableDefaultExportError = (
265
+ error: InvalidTableDefaultExportError,
240
266
  ): AnsiDoc.AnsiDoc =>
241
267
  singleLine(
242
- AnsiDoc.text("Schema "),
243
- formatPathDoc(error.schemaPath),
268
+ AnsiDoc.text("Table "),
269
+ formatPathDoc(error.tablePath),
244
270
  AnsiDoc.text(
245
- " must default-export a DatabaseSchema; build it with DatabaseSchema.make({ ... }).",
271
+ " must default-export a Table (e.g. `export default Table.make({ ... })`); convert any named export to a default export.",
246
272
  ),
247
273
  );
248
274
 
249
- const renderMissingSchemaFileError = (
250
- error: MissingSchemaFileError,
275
+ const renderInvalidTableFilenameError = (
276
+ error: InvalidTableFilenameError,
251
277
  ): AnsiDoc.AnsiDoc =>
252
278
  singleLine(
253
- AnsiDoc.text("Schema "),
279
+ AnsiDoc.text("Table "),
280
+ formatPathDoc(error.tablePath),
281
+ AnsiDoc.text(
282
+ ` has an invalid filename: ${error.reason} Convex table names must start with a letter and contain only letters, numbers, and underscores; leading underscores are reserved for system tables.`,
283
+ ),
284
+ );
285
+
286
+ const renderDuplicateTableNameError = (
287
+ error: DuplicateTableNameError,
288
+ ): AnsiDoc.AnsiDoc => {
289
+ const conflicts = error.collisions
290
+ .map(
291
+ ({ tableName, tablePaths }) =>
292
+ `\`${tableName}\` (${tablePaths.join(", ")})`,
293
+ )
294
+ .join("; ");
295
+ return singleLine(
296
+ AnsiDoc.text(
297
+ `Multiple files under \`confect/tables/\` resolve to the same table name. Table names are derived from filenames, so each must be unique across the directory (including subdirectories); rename or remove all but one. Conflicts: ${conflicts}.`,
298
+ ),
299
+ );
300
+ };
301
+
302
+ const renderLegacySchemaFileError = (
303
+ error: LegacySchemaFileError,
304
+ ): AnsiDoc.AnsiDoc =>
305
+ singleLine(
306
+ AnsiDoc.text("Found a legacy "),
254
307
  formatPathDoc(error.schemaPath),
255
308
  AnsiDoc.text(
256
- " is required but is missing; create it and default-export a DatabaseSchema (DatabaseSchema.make({ ... })).",
309
+ ". Delete it: tables in `confect/tables/*.ts` are now the single source of truth, and the runtime schema is generated as `confect/_generated/schema.ts`.",
257
310
  ),
258
311
  );
259
312
 
@@ -320,24 +373,33 @@ export const renderCodegenError = (error: CodegenError): string => {
320
373
  AnsiDoc.render({ style: "pretty" }),
321
374
  ),
322
375
  ),
323
- Match.tag("SchemaInvalidDefaultExportError", (e) =>
376
+ Match.tag("ParentChildNameCollisionError", (e) =>
324
377
  pipe(
325
- renderSchemaInvalidDefaultExportError(e),
378
+ renderParentChildNameCollisionError(e),
326
379
  AnsiDoc.render({ style: "pretty" }),
327
380
  ),
328
381
  ),
329
- Match.tag("MissingSchemaFileError", (e) =>
382
+ Match.tag("InvalidTableDefaultExportError", (e) =>
330
383
  pipe(
331
- renderMissingSchemaFileError(e),
384
+ renderInvalidTableDefaultExportError(e),
332
385
  AnsiDoc.render({ style: "pretty" }),
333
386
  ),
334
387
  ),
335
- Match.tag("ParentChildNameCollisionError", (e) =>
388
+ Match.tag("InvalidTableFilenameError", (e) =>
336
389
  pipe(
337
- renderParentChildNameCollisionError(e),
390
+ renderInvalidTableFilenameError(e),
338
391
  AnsiDoc.render({ style: "pretty" }),
339
392
  ),
340
393
  ),
394
+ Match.tag("DuplicateTableNameError", (e) =>
395
+ pipe(
396
+ renderDuplicateTableNameError(e),
397
+ AnsiDoc.render({ style: "pretty" }),
398
+ ),
399
+ ),
400
+ Match.tag("LegacySchemaFileError", (e) =>
401
+ pipe(renderLegacySchemaFileError(e), AnsiDoc.render({ style: "pretty" })),
402
+ ),
341
403
  Match.exhaustive,
342
404
  );
343
405
  };
package/src/LeafModule.ts CHANGED
@@ -310,7 +310,7 @@ export const validateImpl = (leaf: LeafModule) =>
310
310
  if (missing.length > 0) {
311
311
  return yield* new ImplMissingFunctionsError({
312
312
  implPath: implRelativePath,
313
- groupPath: finalizedGroupImpl.groupPath,
313
+ groupPath: leaf.groupPathDot,
314
314
  missingFunctionNames: missing,
315
315
  });
316
316
  }
@@ -82,39 +82,3 @@ export const collectImportBindings = (
82
82
  Array.map(([, binding]) => binding),
83
83
  Array.sortBy(Order.mapInput(Order.string, (binding) => binding.localName)),
84
84
  );
85
-
86
- export interface SpecLeafPath {
87
- readonly binding: SpecImportBinding;
88
- readonly dotPath: string;
89
- }
90
-
91
- const leafPathsForNode = (
92
- node: SpecAssemblyNode,
93
- ancestorSegments: ReadonlyArray<string>,
94
- ): ReadonlyArray<SpecLeafPath> => {
95
- const segments = [...ancestorSegments, node.segment];
96
- const childPaths = Array.flatMap(node.children, (child) =>
97
- leafPathsForNode(child, segments),
98
- );
99
- return Option.match(node.importBinding, {
100
- onNone: () => childPaths,
101
- onSome: (binding) =>
102
- Array.prepend(childPaths, { binding, dotPath: segments.join(".") }),
103
- });
104
- };
105
-
106
- /**
107
- * Walk the assembly tree and produce one entry per leaf spec, pairing its
108
- * import binding with the full dot-path codegen will register via
109
- * `Spec.addPath`. Ordering matches `collectImportBindings` (sorted by the
110
- * binding's local name) so the generated file is stable across runs.
111
- */
112
- export const collectLeafPaths = (
113
- nodes: ReadonlyArray<SpecAssemblyNode>,
114
- ): ReadonlyArray<SpecLeafPath> =>
115
- pipe(
116
- Array.flatMap(nodes, (node) => leafPathsForNode(node, [])),
117
- Array.sortBy(
118
- Order.mapInput(Order.string, (entry) => entry.binding.localName),
119
- ),
120
- );
@@ -0,0 +1,153 @@
1
+ import { Identifier } from "@confect/core";
2
+ import * as Table from "@confect/server/Table";
3
+ import { FileSystem, Path } from "@effect/platform";
4
+ import { Array, Effect, Order, pipe } from "effect";
5
+ import { fromBundlerError } from "./BuildError";
6
+ import * as Bundler from "./Bundler";
7
+ import {
8
+ DuplicateTableNameError,
9
+ InvalidTableDefaultExportError,
10
+ InvalidTableFilenameError,
11
+ } from "./CodegenError";
12
+ import { ConfectDirectory } from "./ConfectDirectory";
13
+
14
+ export const TABLES_DIRNAME = "tables";
15
+
16
+ /**
17
+ * Discovered metadata for a single user-authored table module under
18
+ * `confect/tables/`.
19
+ *
20
+ * - `relativePath` — path from `confect/` to the file (e.g. `tables/notes.ts`).
21
+ * - `tableName` — the file basename (e.g. `notes`). This is also the import
22
+ * binding used in generated files, and the table name surfaced to Convex.
23
+ */
24
+ export interface TableModule {
25
+ readonly relativePath: string;
26
+ readonly tableName: string;
27
+ }
28
+
29
+ const tableNameFromRelativePath = (relativePath: string) =>
30
+ Effect.gen(function* () {
31
+ const path = yield* Path.Path;
32
+ const { name } = path.parse(relativePath);
33
+ return name;
34
+ });
35
+
36
+ const listTableFiles = Effect.gen(function* () {
37
+ const fs = yield* FileSystem.FileSystem;
38
+ const path = yield* Path.Path;
39
+ const confectDirectory = yield* ConfectDirectory.get;
40
+ const tablesDirectory = path.join(confectDirectory, TABLES_DIRNAME);
41
+
42
+ if (!(yield* fs.exists(tablesDirectory))) {
43
+ return [] as ReadonlyArray<string>;
44
+ }
45
+
46
+ const allPaths = yield* fs.readDirectory(tablesDirectory, {
47
+ recursive: true,
48
+ });
49
+
50
+ return pipe(
51
+ allPaths,
52
+ Array.filter((p) => p.endsWith(".ts") && !p.endsWith(".test.ts")),
53
+ Array.map((p) => path.join(TABLES_DIRNAME, p)),
54
+ );
55
+ });
56
+
57
+ const byTableName = Order.mapInput(
58
+ Order.string,
59
+ (tableModule: TableModule) => tableModule.tableName,
60
+ );
61
+
62
+ /**
63
+ * Discover every `confect/tables/**\/*.ts` module by listing the directory.
64
+ * Validates that each filename is a legal table identifier — the table name is
65
+ * derived from the file basename, so the filename must be a valid JavaScript
66
+ * identifier with no leading underscore (Convex reserves `_<name>` for system
67
+ * tables).
68
+ *
69
+ * This step does *not* bundle the table modules. It runs early in the codegen
70
+ * pipeline so the `_generated/id.ts` constructor can be emitted *before* any
71
+ * user-authored table is bundled (those modules import from `_generated/id.ts`
72
+ * for cross-table refs).
73
+ *
74
+ * A missing `confect/tables/` directory is allowed and produces an empty list.
75
+ *
76
+ * Fails with {@link InvalidTableFilenameError} if any filename is not a valid
77
+ * table identifier, or {@link DuplicateTableNameError} if two files resolve to
78
+ * the same table name (the directory is scanned recursively but names are
79
+ * derived from the basename alone, so `tables/a/notes.ts` and
80
+ * `tables/b/notes.ts` would collide).
81
+ */
82
+ export const discover = Effect.gen(function* () {
83
+ const relativePaths = yield* listTableFiles;
84
+
85
+ const tableModules = yield* Effect.forEach(
86
+ relativePaths,
87
+ (relativePath) =>
88
+ Effect.gen(function* () {
89
+ const tableName = yield* tableNameFromRelativePath(relativePath);
90
+ yield* Effect.try({
91
+ try: () => Identifier.validateConfectTableIdentifier(tableName),
92
+ catch: (e) =>
93
+ new InvalidTableFilenameError({
94
+ tablePath: relativePath,
95
+ reason: e instanceof Error ? e.message : String(e),
96
+ }),
97
+ });
98
+ return { relativePath, tableName } satisfies TableModule;
99
+ }),
100
+ { concurrency: "unbounded" },
101
+ );
102
+
103
+ const sorted = pipe(tableModules, Array.sortBy(byTableName));
104
+
105
+ const collisions = Object.entries(
106
+ Array.groupBy(sorted, (tableModule) => tableModule.tableName),
107
+ )
108
+ .filter(([, group]) => group.length > 1)
109
+ .map(([tableName, group]) => ({
110
+ tableName,
111
+ tablePaths: Array.map(group, (tableModule) => tableModule.relativePath),
112
+ }));
113
+
114
+ if (collisions.length > 0) {
115
+ return yield* new DuplicateTableNameError({ collisions });
116
+ }
117
+
118
+ return sorted;
119
+ });
120
+
121
+ /**
122
+ * Bundle every discovered table module and verify that its default export is
123
+ * an {@link Table.UnnamedTable} (the result of `Table.make(...)` before a
124
+ * name has been bound). Fails with {@link InvalidTableDefaultExportError} if
125
+ * any module's default export is missing or has the wrong shape.
126
+ *
127
+ * Must run *after* `_generated/id.ts` has been emitted, because user-authored
128
+ * table modules typically `import { Id } from "../_generated/id"` for
129
+ * cross-table references.
130
+ */
131
+ export const validate = (tableModules: ReadonlyArray<TableModule>) =>
132
+ Effect.gen(function* () {
133
+ const path = yield* Path.Path;
134
+ const confectDirectory = yield* ConfectDirectory.get;
135
+
136
+ yield* Effect.forEach(
137
+ tableModules,
138
+ ({ relativePath }) =>
139
+ Effect.gen(function* () {
140
+ const absolutePath = path.resolve(confectDirectory, relativePath);
141
+ const { module } = yield* Bundler.bundle(absolutePath).pipe(
142
+ Effect.mapError((error) => fromBundlerError(relativePath, error)),
143
+ );
144
+
145
+ if (!Table.isUnnamedTable(module.default)) {
146
+ return yield* new InvalidTableDefaultExportError({
147
+ tablePath: relativePath,
148
+ });
149
+ }
150
+ }),
151
+ { concurrency: "unbounded" },
152
+ );
153
+ });