@confect/cli 7.0.0 → 9.0.0-next.0

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.
@@ -1,64 +1,96 @@
1
- import { Spec } from "@confect/core";
2
1
  import { Command } from "@effect/cli";
3
2
  import { FileSystem, Path } from "@effect/platform";
4
3
  import { Ansi, AnsiDoc } from "@effect/printer-ansi";
5
4
  import {
6
5
  Array,
6
+ Chunk,
7
+ Clock,
7
8
  Console,
8
- Deferred,
9
9
  Duration,
10
10
  Effect,
11
11
  Equal,
12
+ ExecutionStrategy,
13
+ Exit,
12
14
  HashSet,
13
15
  Match,
14
16
  Option,
17
+ Order,
15
18
  pipe,
16
19
  Queue,
17
20
  Ref,
18
- Schema,
21
+ Scope,
19
22
  Stream,
20
23
  String,
21
24
  } from "effect";
22
- import type { ReadonlyRecord } from "effect/Record";
23
25
  import * as esbuild from "esbuild";
24
- import type * as FunctionPath from "../FunctionPath";
25
- import * as FunctionPaths from "../FunctionPaths";
26
- import * as GroupPath from "../GroupPath";
27
- import { logFailure, logPending, logSuccess } from "../log";
26
+ import { logCoalescedBuildErrors } from "../BuildError";
27
+ import { absoluteExternalsPlugin, EXTERNAL_PACKAGES } from "../Bundler";
28
+ import * as CodegenError from "../CodegenError";
28
29
  import { ConfectDirectory } from "../ConfectDirectory";
29
30
  import { ConvexDirectory } from "../ConvexDirectory";
30
- import { ProjectRoot } from "../ProjectRoot";
31
+ import * as FunctionPaths from "../FunctionPaths";
32
+ import type * as GroupPaths from "../GroupPaths";
31
33
  import {
32
- bundleAndImport,
33
- EXTERNAL_PACKAGES,
34
- generateAuthConfig,
35
- generateCrons,
36
- generateHttp,
37
- removeGroups,
38
- writeGroups,
39
- } from "../utils";
34
+ discoverLeafImplFiles,
35
+ isLeafImplPath,
36
+ isLeafSpecPath,
37
+ } from "../LeafModule";
40
38
  import {
41
- codegenHandler,
42
- generateNodeApi,
43
- generateNodeRegisteredFunctions,
44
- } from "./codegen";
39
+ logFunctionAdded,
40
+ logFunctionRemoved,
41
+ logPending,
42
+ logSuccess,
43
+ } from "../log";
44
+ import { ProjectRoot } from "../ProjectRoot";
45
+ import { generateAuthConfig, generateCrons, generateHttp } from "../utils";
46
+ import { codegenHandler, loadPreviousFunctionPaths } from "./codegen";
47
+
48
+ const GENERATED_SPEC_PATH = "_generated/spec.ts";
49
+ const GENERATED_NODE_SPEC_PATH = "_generated/nodeSpec.ts";
50
+
51
+ // Quiescence window: the sync loop waits this long for further signals
52
+ // after each batch. One user edit fires `onEnd` on every esbuild
53
+ // watcher that touches the file, and rebuild times vary across entry
54
+ // points so the onEnds can be spread over hundreds of milliseconds.
55
+ // The drain keeps extending its wait (bounded by `COALESCE_MAX_WAIT`)
56
+ // until no new signals arrive within the window, collapsing the whole
57
+ // burst into a single codegen cycle.
58
+ const COALESCE_QUIESCENCE = Duration.millis(300);
59
+
60
+ // Upper bound on `drainUntilQuiescent` so a pathological infinite
61
+ // signal stream can't pin the sync loop forever.
62
+ const COALESCE_MAX_WAIT = Duration.seconds(5);
63
+
64
+ // How long to wait for esbuild watchers to react to codegen's own
65
+ // writes (e.g. an updated `_generated/spec.ts`). Added on top of the
66
+ // quiescence drain when codegen reported writes happened.
67
+ const ECHO_COOLDOWN = Duration.millis(500);
68
+
69
+ const emptyFunctionPaths = FunctionPaths.FunctionPaths.make(HashSet.empty());
45
70
 
46
71
  type Pending = {
47
72
  readonly specDirty: boolean;
48
- readonly nodeImplDirty: boolean;
49
73
  readonly httpDirty: boolean;
50
74
  readonly cronsDirty: boolean;
51
75
  readonly authDirty: boolean;
52
76
  };
53
77
 
78
+ type PendingKey = keyof Pending;
79
+
54
80
  const pendingInit: Pending = {
55
81
  specDirty: false,
56
- nodeImplDirty: false,
57
82
  httpDirty: false,
58
83
  cronsDirty: false,
59
84
  authDirty: false,
60
85
  };
61
86
 
87
+ const isPendingDirty = (p: Pending): boolean =>
88
+ p.specDirty || p.httpDirty || p.cronsDirty || p.authDirty;
89
+
90
+ type WatcherErrors = ReadonlyMap<string, readonly esbuild.Message[]>;
91
+
92
+ const emptyWatcherErrors: WatcherErrors = new Map();
93
+
62
94
  const changeChar = (change: "Added" | "Removed" | "Modified") =>
63
95
  Match.value(change).pipe(
64
96
  Match.when("Added", () => ({ char: "+", color: Ansi.green })),
@@ -97,211 +129,234 @@ const logFileChangeIndented = (
97
129
  );
98
130
  });
99
131
 
100
- const logFunctionAddedIndented = (functionPath: FunctionPath.FunctionPath) =>
101
- Console.log(
102
- pipe(
103
- AnsiDoc.text(" "),
104
- AnsiDoc.cat(pipe(AnsiDoc.char("+"), AnsiDoc.annotate(Ansi.green))),
105
- AnsiDoc.catWithSpace(
106
- AnsiDoc.hcat([
107
- pipe(
108
- AnsiDoc.text(GroupPath.toString(functionPath.groupPath) + "."),
109
- AnsiDoc.annotate(Ansi.blackBright),
132
+ const logFunctionPathDiff = (
133
+ previous: FunctionPaths.FunctionPaths,
134
+ current: FunctionPaths.FunctionPaths,
135
+ ) =>
136
+ Effect.gen(function* () {
137
+ const {
138
+ functionsAdded,
139
+ functionsRemoved,
140
+ groupsRemoved,
141
+ groupsAdded,
142
+ groupsChanged,
143
+ } = FunctionPaths.diff(previous, current);
144
+
145
+ const logForGroups = (
146
+ groupPaths: GroupPaths.GroupPaths,
147
+ fnPaths: FunctionPaths.FunctionPaths,
148
+ logFn: typeof logFunctionAdded,
149
+ ) =>
150
+ Effect.forEach(groupPaths, (gp) =>
151
+ Effect.forEach(
152
+ Array.fromIterable(
153
+ HashSet.filter(fnPaths, (fp) => Equal.equals(fp.groupPath, gp)),
110
154
  ),
111
- pipe(AnsiDoc.text(functionPath.name), AnsiDoc.annotate(Ansi.green)),
112
- ]),
113
- ),
114
- AnsiDoc.render({ style: "pretty" }),
115
- ),
116
- );
155
+ logFn,
156
+ ),
157
+ );
117
158
 
118
- const logFunctionRemovedIndented = (functionPath: FunctionPath.FunctionPath) =>
119
- Console.log(
120
- pipe(
121
- AnsiDoc.text(" "),
122
- AnsiDoc.cat(pipe(AnsiDoc.char("-"), AnsiDoc.annotate(Ansi.red))),
123
- AnsiDoc.catWithSpace(
124
- AnsiDoc.hcat([
125
- pipe(
126
- AnsiDoc.text(GroupPath.toString(functionPath.groupPath) + "."),
127
- AnsiDoc.annotate(Ansi.blackBright),
159
+ yield* logForGroups(groupsRemoved, functionsRemoved, logFunctionRemoved);
160
+ yield* logForGroups(groupsAdded, functionsAdded, logFunctionAdded);
161
+ yield* Effect.forEach(groupsChanged, (gp) =>
162
+ Effect.gen(function* () {
163
+ yield* Effect.forEach(
164
+ Array.fromIterable(
165
+ HashSet.filter(functionsAdded, (fp) =>
166
+ Equal.equals(fp.groupPath, gp),
167
+ ),
128
168
  ),
129
- pipe(AnsiDoc.text(functionPath.name), AnsiDoc.annotate(Ansi.red)),
130
- ]),
131
- ),
132
- AnsiDoc.render({ style: "pretty" }),
133
- ),
134
- );
169
+ logFunctionAdded,
170
+ );
171
+ yield* Effect.forEach(
172
+ Array.fromIterable(
173
+ HashSet.filter(functionsRemoved, (fp) =>
174
+ Equal.equals(fp.groupPath, gp),
175
+ ),
176
+ ),
177
+ logFunctionRemoved,
178
+ );
179
+ }),
180
+ );
181
+ });
135
182
 
136
183
  export const dev = Command.make("dev", {}, () =>
137
184
  Effect.gen(function* () {
138
185
  yield* logPending("Performing initial sync…");
139
- const initialFunctionPaths = yield* codegenHandler;
186
+ const previousFunctionPaths = yield* loadPreviousFunctionPaths;
187
+ const initialResult = yield* codegenHandler.pipe(
188
+ Effect.tap(({ functionPaths }) =>
189
+ logFunctionPathDiff(previousFunctionPaths, functionPaths),
190
+ ),
191
+ Effect.tap(() => logSuccess("Generated files are up-to-date")),
192
+ CodegenError.catchAndLog,
193
+ );
194
+ const initialFunctionPaths = Option.match(initialResult, {
195
+ onNone: () => emptyFunctionPaths,
196
+ onSome: ({ functionPaths }) => functionPaths,
197
+ });
140
198
 
141
199
  const pendingRef = yield* Ref.make<Pending>(pendingInit);
142
200
  const signal = yield* Queue.sliding<void>(1);
143
- const specWatcherRestartQueue = yield* Queue.sliding<void>(1);
201
+ const restartQueue = yield* Queue.sliding<void>(1);
202
+ const watcherErrorsRef = yield* Ref.make<WatcherErrors>(emptyWatcherErrors);
144
203
 
145
204
  yield* Effect.all(
146
205
  [
147
- specFileWatcher(signal, pendingRef, specWatcherRestartQueue),
148
- confectDirectoryWatcher(signal, pendingRef, specWatcherRestartQueue),
149
- syncLoop(signal, pendingRef, initialFunctionPaths),
206
+ Effect.scoped(
207
+ entryPointsWatcher(
208
+ signal,
209
+ pendingRef,
210
+ restartQueue,
211
+ watcherErrorsRef,
212
+ ),
213
+ ),
214
+ confectStructureWatcher(signal, pendingRef, restartQueue),
215
+ syncLoop(signal, pendingRef, initialFunctionPaths, watcherErrorsRef),
150
216
  ],
151
217
  { concurrency: "unbounded" },
152
218
  );
153
219
  }),
154
220
  ).pipe(Command.withDescription("Start the Confect development server"));
155
221
 
222
+ const esbuildMessageKey = (m: esbuild.Message): string =>
223
+ `${m.location?.file ?? ""}:${m.location?.line ?? ""}:${m.location?.column ?? ""}:${m.text}`;
224
+
225
+ const allMessages = (errors: WatcherErrors): ReadonlyArray<esbuild.Message> =>
226
+ pipe(Array.fromIterable(errors.values()), Array.flatten);
227
+
228
+ const dedupeWatcherErrors = (
229
+ errors: WatcherErrors,
230
+ ): ReadonlyArray<esbuild.Message> =>
231
+ pipe(
232
+ allMessages(errors),
233
+ Array.dedupeWith(
234
+ (messageA, messageB) =>
235
+ esbuildMessageKey(messageA) === esbuildMessageKey(messageB),
236
+ ),
237
+ );
238
+
239
+ const watcherErrorsSignature = (errors: WatcherErrors): string =>
240
+ pipe(
241
+ allMessages(errors),
242
+ Array.map(esbuildMessageKey),
243
+ Array.dedupe,
244
+ Array.sort(Order.string),
245
+ Array.join("\n"),
246
+ );
247
+
248
+ /**
249
+ * Log any watcher errors that haven't already been logged at their
250
+ * current signature. Suppresses the per-watcher fanout that happens
251
+ * when one root cause (e.g. a missing import) breaks every entry
252
+ * point's build at the same source location.
253
+ */
254
+ const logChangedWatcherErrors = (
255
+ watcherErrorsRef: Ref.Ref<WatcherErrors>,
256
+ lastLoggedSignatureRef: Ref.Ref<string>,
257
+ ) =>
258
+ Effect.gen(function* () {
259
+ const errors = yield* Ref.get(watcherErrorsRef);
260
+ const signature = watcherErrorsSignature(errors);
261
+ const previous = yield* Ref.get(lastLoggedSignatureRef);
262
+ if (signature === previous) return;
263
+ yield* Ref.set(lastLoggedSignatureRef, signature);
264
+ if (errors.size === 0) return;
265
+ yield* logCoalescedBuildErrors(dedupeWatcherErrors(errors));
266
+ });
267
+
268
+ /**
269
+ * Block until the signal queue has been quiet for `quiescence`. esbuild
270
+ * watchers' `onEnd` events for a single user edit can be spread across
271
+ * hundreds of milliseconds, so a fixed window misses late arrivals.
272
+ * Bounded by `maxWait` so pathological signal floods can't pin the
273
+ * loop forever.
274
+ */
275
+ const drainUntilQuiescent = (
276
+ signal: Queue.Queue<void>,
277
+ quiescence: Duration.Duration,
278
+ maxWait: Duration.Duration,
279
+ ) =>
280
+ Effect.gen(function* () {
281
+ const start = yield* Clock.currentTimeMillis;
282
+ const maxMillis = Duration.toMillis(maxWait);
283
+ yield* Effect.iterate(true as boolean, {
284
+ while: (keepGoing) => keepGoing,
285
+ body: () =>
286
+ Effect.gen(function* () {
287
+ yield* Effect.sleep(quiescence);
288
+ const drained = yield* Queue.takeAll(signal);
289
+ if (Chunk.isEmpty(drained)) return false;
290
+ const now = yield* Clock.currentTimeMillis;
291
+ return now - start < maxMillis;
292
+ }),
293
+ });
294
+ });
295
+
156
296
  const syncLoop = (
157
297
  signal: Queue.Queue<void>,
158
298
  pendingRef: Ref.Ref<Pending>,
159
299
  initialFunctionPaths: FunctionPaths.FunctionPaths,
300
+ watcherErrorsRef: Ref.Ref<WatcherErrors>,
160
301
  ) =>
161
302
  Effect.gen(function* () {
162
303
  const functionPathsRef = yield* Ref.make(initialFunctionPaths);
163
- const initialSyncDone = yield* Deferred.make<void>();
304
+ const lastLoggedErrorsRef = yield* Ref.make<string>("");
164
305
 
165
306
  return yield* Effect.forever(
166
307
  Effect.gen(function* () {
167
- yield* Effect.logDebug("Running sync loop...");
308
+ yield* Effect.logDebug("Running sync loop");
309
+ // Wait for the first signal of a burst, then keep absorbing
310
+ // follow-up signals from other watchers' onEnds until the queue
311
+ // stays quiet for `COALESCE_QUIESCENCE`.
168
312
  yield* Queue.take(signal);
169
-
170
- const isDone = yield* Deferred.isDone(initialSyncDone);
171
- yield* Effect.when(
172
- logPending("Dependencies changed, reloading…"),
173
- () => isDone,
313
+ yield* drainUntilQuiescent(
314
+ signal,
315
+ COALESCE_QUIESCENCE,
316
+ COALESCE_MAX_WAIT,
174
317
  );
175
- yield* Deferred.succeed(initialSyncDone, undefined);
318
+
319
+ yield* logChangedWatcherErrors(watcherErrorsRef, lastLoggedErrorsRef);
176
320
 
177
321
  const pending = yield* Ref.getAndSet(pendingRef, pendingInit);
178
322
 
179
- if (pending.specDirty || pending.nodeImplDirty) {
180
- yield* generateNodeApi;
181
- yield* generateNodeRegisteredFunctions;
323
+ if (!isPendingDirty(pending)) {
324
+ // No-op signal (e.g. a late echo after a previous cycle
325
+ // already drained). Stay silent.
326
+ return;
182
327
  }
183
328
 
184
- const specResult: Option.Option<void> = yield* Effect.if(
185
- pending.specDirty,
186
- {
187
- onTrue: () =>
188
- loadSpec.pipe(
189
- Effect.andThen(
190
- Effect.fn(function* (spec) {
191
- yield* Effect.logDebug("Spec loaded");
192
-
193
- const previous = yield* Ref.get(functionPathsRef);
194
-
195
- const path = yield* Path.Path;
196
- const convexDirectory = yield* ConvexDirectory.get;
197
-
198
- const current = FunctionPaths.make(spec);
199
- const {
200
- functionsAdded,
201
- functionsRemoved,
202
- groupsRemoved,
203
- groupsAdded,
204
- groupsChanged,
205
- } = FunctionPaths.diff(previous, current);
206
-
207
- // Removed groups
208
- yield* removeGroups(groupsRemoved);
209
- yield* Effect.forEach(groupsRemoved, (gp) =>
210
- Effect.gen(function* () {
211
- const relativeModulePath =
212
- yield* GroupPath.modulePath(gp);
213
- const filePath = path.join(
214
- convexDirectory,
215
- relativeModulePath,
216
- );
217
- yield* logFileChangeIndented("Removed", filePath);
218
- yield* Effect.forEach(
219
- Array.fromIterable(
220
- HashSet.filter(functionsRemoved, (fp) =>
221
- Equal.equals(fp.groupPath, gp),
222
- ),
223
- ),
224
- logFunctionRemovedIndented,
225
- );
226
- }),
227
- );
228
-
229
- // Added groups
230
- yield* writeGroups(spec, groupsAdded);
231
- yield* Effect.forEach(groupsAdded, (gp) =>
232
- Effect.gen(function* () {
233
- const relativeModulePath =
234
- yield* GroupPath.modulePath(gp);
235
- const filePath = path.join(
236
- convexDirectory,
237
- relativeModulePath,
238
- );
239
- yield* logFileChangeIndented("Added", filePath);
240
- yield* Effect.forEach(
241
- Array.fromIterable(
242
- HashSet.filter(functionsAdded, (fp) =>
243
- Equal.equals(fp.groupPath, gp),
244
- ),
245
- ),
246
- logFunctionAddedIndented,
247
- );
248
- }),
249
- );
250
-
251
- // Changed groups
252
- yield* writeGroups(spec, groupsChanged);
253
- yield* Effect.forEach(groupsChanged, (gp) =>
254
- Effect.gen(function* () {
255
- const relativeModulePath =
256
- yield* GroupPath.modulePath(gp);
257
- const filePath = path.join(
258
- convexDirectory,
259
- relativeModulePath,
260
- );
261
- yield* logFileChangeIndented("Modified", filePath);
262
- yield* Effect.forEach(
263
- Array.fromIterable(
264
- HashSet.filter(functionsAdded, (fp) =>
265
- Equal.equals(fp.groupPath, gp),
266
- ),
267
- ),
268
- logFunctionAddedIndented,
269
- );
270
- yield* Effect.forEach(
271
- Array.fromIterable(
272
- HashSet.filter(functionsRemoved, (fp) =>
273
- Equal.equals(fp.groupPath, gp),
274
- ),
275
- ),
276
- logFunctionRemovedIndented,
277
- );
278
- }),
279
- );
280
-
281
- yield* Ref.set(functionPathsRef, current);
282
-
283
- return Option.some(undefined);
284
- }),
285
- ),
286
- Effect.catchTag("SpecImportFailedError", () =>
287
- logFailure("Spec import failed").pipe(
288
- Effect.as(Option.none()),
289
- ),
290
- ),
291
- Effect.catchTag("SpecFileDoesNotExportSpecError", () =>
292
- logFailure(
293
- "Spec file does not default export a Convex spec",
294
- ).pipe(Effect.as(Option.none())),
295
- ),
296
- Effect.catchTag("NodeSpecFileDoesNotExportSpecError", () =>
297
- logFailure(
298
- "Node spec file does not default export a Node spec",
299
- ).pipe(Effect.as(Option.none())),
300
- ),
301
- ),
302
- onFalse: () => Effect.succeed(Option.some(undefined)),
303
- },
304
- );
329
+ yield* logPending("Dependencies may have changed, reloading…");
330
+
331
+ if (pending.specDirty) {
332
+ const current = yield* codegenHandler.pipe(
333
+ Effect.tap(({ functionPaths: nextFunctionPaths }) =>
334
+ Effect.gen(function* () {
335
+ const previous = yield* Ref.get(functionPathsRef);
336
+ yield* logFunctionPathDiff(previous, nextFunctionPaths);
337
+ yield* Ref.set(functionPathsRef, nextFunctionPaths);
338
+ }),
339
+ ),
340
+ CodegenError.catchAndLog,
341
+ );
342
+ if (Option.isNone(current)) {
343
+ return;
344
+ }
345
+ // Drain any stragglers from this cycle's burst (slow watchers
346
+ // whose onEnd fired after the first quiescence) plus, when
347
+ // codegen wrote, the echo signals esbuild emits in response
348
+ // to our writes. Reset `pendingRef` so those drained signals
349
+ // don't carry a dirty flag into the next cycle.
350
+ if (current.value.anyWritesHappened) {
351
+ yield* Effect.sleep(ECHO_COOLDOWN);
352
+ }
353
+ yield* drainUntilQuiescent(
354
+ signal,
355
+ COALESCE_QUIESCENCE,
356
+ COALESCE_MAX_WAIT,
357
+ );
358
+ yield* Ref.set(pendingRef, pendingInit);
359
+ }
305
360
 
306
361
  const dirtyOptionalFiles = [
307
362
  ...(pending.httpDirty
@@ -319,237 +374,350 @@ const syncLoop = (
319
374
  ? Effect.all(dirtyOptionalFiles, { concurrency: "unbounded" })
320
375
  : Effect.void;
321
376
 
322
- yield* Option.match(specResult, {
323
- onSome: () => logSuccess("Generated files are up-to-date"),
324
- onNone: () => Effect.void,
325
- });
377
+ yield* logSuccess("Generated files are up-to-date");
326
378
  }),
327
379
  );
328
380
  });
329
381
 
330
- const loadSpec = Effect.gen(function* () {
382
+ interface EntryPoint {
383
+ readonly absolutePath: string;
384
+ readonly displayPath: string;
385
+ readonly pendingKey: PendingKey;
386
+ }
387
+
388
+ /**
389
+ * Every file whose import graph codegen should react to. Each one becomes
390
+ * its own scoped esbuild watcher; the union of their watches gives us
391
+ * dependency-aware tracking of anything reachable from `confect/`,
392
+ * including files outside `confect/`.
393
+ */
394
+ const discoverEntryPoints = Effect.gen(function* () {
331
395
  const fs = yield* FileSystem.FileSystem;
332
396
  const path = yield* Path.Path;
397
+ const projectRoot = yield* ProjectRoot.get;
333
398
  const confectDirectory = yield* ConfectDirectory.get;
334
- const specPath = yield* getSpecPath;
335
- const specModule = yield* bundleAndImport(specPath).pipe(
336
- Effect.mapError((error) => new SpecImportFailedError({ error })),
337
- );
338
- const spec = specModule.default;
339
399
 
340
- if (!Spec.isConvexSpec(spec)) {
341
- return yield* new SpecFileDoesNotExportSpecError();
342
- }
343
-
344
- const nodeImplPath = path.join(confectDirectory, "nodeImpl.ts");
345
- const nodeImplExists = yield* fs.exists(nodeImplPath);
346
- const nodeSpecOption = yield* loadNodeSpec;
347
- const mergedSpec = Option.match(nodeSpecOption, {
348
- onNone: () => spec,
349
- onSome: (nodeSpec) => (nodeImplExists ? Spec.merge(spec, nodeSpec) : spec),
350
- });
351
-
352
- return mergedSpec;
353
- });
354
-
355
- const getSpecPath = Effect.gen(function* () {
356
- const path = yield* Path.Path;
357
- const confectDirectory = yield* ConfectDirectory.get;
358
-
359
- return path.join(confectDirectory, "spec.ts");
360
- });
361
-
362
- const getNodeSpecPath = Effect.gen(function* () {
363
- const path = yield* Path.Path;
364
- const confectDirectory = yield* ConfectDirectory.get;
365
-
366
- return path.join(confectDirectory, "nodeSpec.ts");
367
- });
368
-
369
- const loadNodeSpec = Effect.gen(function* () {
370
- const fs = yield* FileSystem.FileSystem;
371
- const nodeSpecPath = yield* getNodeSpecPath;
372
-
373
- if (!(yield* fs.exists(nodeSpecPath))) {
374
- return Option.none();
375
- }
376
-
377
- const nodeSpecModule = yield* bundleAndImport(nodeSpecPath).pipe(
378
- Effect.mapError((error) => new SpecImportFailedError({ error })),
400
+ const tryEntry = (relativePath: string, pendingKey: PendingKey) =>
401
+ Effect.gen(function* () {
402
+ const absolutePath = path.join(confectDirectory, relativePath);
403
+ if (!(yield* fs.exists(absolutePath))) {
404
+ return Option.none<EntryPoint>();
405
+ }
406
+ return Option.some<EntryPoint>({
407
+ absolutePath,
408
+ displayPath: path.relative(projectRoot, absolutePath),
409
+ pendingKey,
410
+ });
411
+ });
412
+
413
+ const fixedEntryOptions = yield* Effect.all([
414
+ tryEntry(GENERATED_SPEC_PATH, "specDirty"),
415
+ tryEntry(GENERATED_NODE_SPEC_PATH, "specDirty"),
416
+ tryEntry("schema.ts", "specDirty"),
417
+ tryEntry("http.ts", "httpDirty"),
418
+ tryEntry("crons.ts", "cronsDirty"),
419
+ tryEntry("auth.ts", "authDirty"),
420
+ ]);
421
+
422
+ const implRelativePaths = yield* discoverLeafImplFiles;
423
+ const implEntryOptions = yield* Effect.forEach(
424
+ implRelativePaths,
425
+ (relativePath) => tryEntry(relativePath, "specDirty"),
379
426
  );
380
- const nodeSpec = nodeSpecModule.default;
381
-
382
- if (!Spec.isNodeSpec(nodeSpec)) {
383
- return yield* new NodeSpecFileDoesNotExportSpecError();
384
- }
385
427
 
386
- return Option.some(nodeSpec);
428
+ return Array.getSomes([...fixedEntryOptions, ...implEntryOptions]);
387
429
  });
388
430
 
389
- const esbuildOptions = (entryPoint: string) => ({
390
- entryPoints: [entryPoint],
391
- bundle: true,
392
- write: false,
393
- metafile: true,
394
- platform: "node" as const,
395
- format: "esm" as const,
396
- logLevel: "silent" as const,
397
- external: EXTERNAL_PACKAGES,
398
- plugins: [
399
- {
400
- name: "notify-rebuild",
401
- setup(build: esbuild.PluginBuild) {
402
- build.onEnd((result) => {
403
- if (result.errors.length === 0) {
404
- (build as { _emit?: (v: void) => void })._emit?.();
405
- } else {
431
+ const esbuildOptions = (
432
+ entry: EntryPoint,
433
+ signal: Queue.Queue<void>,
434
+ pendingRef: Ref.Ref<Pending>,
435
+ watcherErrorsRef: Ref.Ref<WatcherErrors>,
436
+ ) => {
437
+ // First `onEnd` fires when esbuild finishes the watcher's initial
438
+ // build. At startup that's an echo of the just-completed initial
439
+ // codegen pass; for a watcher spawned mid-session (e.g. a newly
440
+ // added impl) it's an echo of the codegen run that triggered the
441
+ // restart. Either way, the entry's contents were already accounted
442
+ // for, so we record any errors but don't flip dirty or push a
443
+ // signal — only genuine subsequent rebuilds should do that.
444
+ const initialBuildSeenRef = Ref.unsafeMake(false);
445
+ return {
446
+ entryPoints: [entry.absolutePath],
447
+ bundle: true,
448
+ write: false,
449
+ metafile: true,
450
+ platform: "node" as const,
451
+ format: "esm" as const,
452
+ logLevel: "silent" as const,
453
+ external: EXTERNAL_PACKAGES,
454
+ plugins: [
455
+ absoluteExternalsPlugin,
456
+ {
457
+ name: "notify-rebuild",
458
+ setup(build: esbuild.PluginBuild) {
459
+ build.onEnd((result) => {
406
460
  Effect.runPromise(
407
461
  Effect.gen(function* () {
408
- const formattedMessages = yield* Effect.promise(() =>
409
- esbuild.formatMessages(result.errors, {
410
- kind: "error",
411
- color: true,
412
- terminalWidth: 80,
413
- }),
414
- );
415
- const output = formatBuildErrors(
416
- result.errors,
417
- formattedMessages,
462
+ const wasInitial = yield* Ref.getAndSet(
463
+ initialBuildSeenRef,
464
+ true,
418
465
  );
419
- yield* Console.error("\n" + output + "\n");
420
- yield* logFailure("Build errors found");
466
+ const isInitial = !wasInitial;
467
+ yield* Ref.update(watcherErrorsRef, (current) => {
468
+ const next = new Map(current);
469
+ if (result.errors.length > 0) {
470
+ next.set(entry.absolutePath, result.errors);
471
+ } else {
472
+ next.delete(entry.absolutePath);
473
+ }
474
+ return next;
475
+ });
476
+ if (isInitial && result.errors.length === 0) return;
477
+ yield* Ref.update(pendingRef, (p) => ({
478
+ ...p,
479
+ [entry.pendingKey]: true,
480
+ }));
481
+ yield* Queue.offer(signal, undefined);
421
482
  }),
422
483
  );
423
- }
424
- });
425
- },
426
- },
427
- ],
428
- });
429
-
430
- const createSpecWatcher = (entryPoint: string) =>
431
- Stream.asyncPush<void>(
432
- (emit) =>
433
- Effect.acquireRelease(
434
- Effect.promise(async () => {
435
- const opts = esbuildOptions(entryPoint);
436
- const plugin = opts.plugins[0];
437
- const originalSetup = plugin!.setup!;
438
- (plugin as { setup: (build: esbuild.PluginBuild) => void }).setup = (
439
- build,
440
- ) => {
441
- (build as { _emit?: (v: void) => void })._emit = () =>
442
- emit.single();
443
- return originalSetup(build);
444
- };
445
-
446
- const ctx = await esbuild.context({
447
- ...opts,
448
- plugins: [plugin],
449
484
  });
485
+ },
486
+ },
487
+ ],
488
+ };
489
+ };
450
490
 
451
- await ctx.watch();
452
- return ctx;
453
- }),
454
- (ctx) =>
455
- Effect.promise(() => ctx.dispose()).pipe(
456
- Effect.tap(() => Effect.logDebug("esbuild watcher disposed")),
457
- ),
458
- ),
459
- { bufferSize: 1, strategy: "sliding" },
491
+ const createEntryPointWatcher = (
492
+ entry: EntryPoint,
493
+ signal: Queue.Queue<void>,
494
+ pendingRef: Ref.Ref<Pending>,
495
+ watcherErrorsRef: Ref.Ref<WatcherErrors>,
496
+ ) =>
497
+ Effect.acquireRelease(
498
+ Effect.promise(async () => {
499
+ const ctx = await esbuild.context(
500
+ esbuildOptions(entry, signal, pendingRef, watcherErrorsRef),
501
+ );
502
+ await ctx.watch();
503
+ return ctx;
504
+ }),
505
+ (ctx) =>
506
+ Effect.gen(function* () {
507
+ yield* Effect.promise(() => ctx.dispose());
508
+ // Clear any errors recorded by this watcher so a disposed
509
+ // watcher can't leave stale errors visible to the sync loop.
510
+ yield* Ref.update(watcherErrorsRef, (current) => {
511
+ if (!current.has(entry.absolutePath)) return current;
512
+ const next = new Map(current);
513
+ next.delete(entry.absolutePath);
514
+ return next;
515
+ });
516
+ yield* Effect.logDebug(
517
+ `esbuild watcher disposed: ${entry.displayPath}`,
518
+ );
519
+ }),
460
520
  );
461
521
 
462
- type SpecWatcherEvent = "change" | "restart";
463
-
464
- const specFileWatcher = (
522
+ /**
523
+ * Holds one scoped esbuild watcher per entry point and reconciles the set
524
+ * whenever something offers to `restartQueue`. Adding or removing an entry
525
+ * point only spawns/disposes the affected watcher; unchanged entries keep
526
+ * their existing context, so a structural change doesn't churn watchers
527
+ * for unrelated files.
528
+ */
529
+ const entryPointsWatcher = (
465
530
  signal: Queue.Queue<void>,
466
531
  pendingRef: Ref.Ref<Pending>,
467
- specWatcherRestartQueue: Queue.Queue<void>,
532
+ restartQueue: Queue.Queue<void>,
533
+ watcherErrorsRef: Ref.Ref<WatcherErrors>,
468
534
  ) =>
469
- Effect.forever(
470
- Effect.gen(function* () {
471
- const fs = yield* FileSystem.FileSystem;
472
- const specPath = yield* getSpecPath;
473
- const nodeSpecPath = yield* getNodeSpecPath;
474
- const nodeSpecExists = yield* fs.exists(nodeSpecPath);
475
-
476
- const specWatcher = createSpecWatcher(specPath);
477
- const nodeSpecWatcher = nodeSpecExists
478
- ? createSpecWatcher(nodeSpecPath)
479
- : Stream.empty;
480
-
481
- const specChanges = pipe(
482
- Stream.merge(specWatcher, nodeSpecWatcher),
483
- Stream.map((): SpecWatcherEvent => "change"),
535
+ Effect.gen(function* () {
536
+ const parentScope = yield* Effect.scope;
537
+ const scopesRef = yield* Ref.make(new Map<string, Scope.CloseableScope>());
538
+
539
+ const sync = Effect.gen(function* () {
540
+ const desired = yield* discoverEntryPoints;
541
+ const desiredByPath = new Map(
542
+ desired.map((entryPoint) => [entryPoint.absolutePath, entryPoint]),
484
543
  );
485
- const restartStream = pipe(
486
- Stream.fromQueue(specWatcherRestartQueue),
487
- Stream.map((): SpecWatcherEvent => "restart"),
544
+ const current = yield* Ref.get(scopesRef);
545
+
546
+ yield* Effect.forEach(
547
+ Array.fromIterable(current),
548
+ ([absolutePath, childScope]) =>
549
+ desiredByPath.has(absolutePath)
550
+ ? Effect.void
551
+ : Scope.close(childScope, Exit.void).pipe(
552
+ Effect.andThen(
553
+ Ref.update(scopesRef, (scopes) => {
554
+ const updated = new Map(scopes);
555
+ updated.delete(absolutePath);
556
+ return updated;
557
+ }),
558
+ ),
559
+ ),
488
560
  );
489
561
 
490
- yield* pipe(
491
- Stream.merge(specChanges, restartStream),
492
- Stream.debounce(Duration.millis(200)),
493
- Stream.takeUntil((event): event is "restart" => event === "restart"),
494
- Stream.runForEach((event) =>
495
- event === "change"
496
- ? Ref.update(pendingRef, (pending) => ({
497
- ...pending,
498
- specDirty: true,
499
- })).pipe(Effect.andThen(Queue.offer(signal, undefined)))
500
- : Effect.void,
501
- ),
562
+ yield* Effect.forEach(desired, (entry) =>
563
+ Effect.gen(function* () {
564
+ const existing = yield* Ref.get(scopesRef);
565
+ if (existing.has(entry.absolutePath)) return;
566
+
567
+ const childScope = yield* Scope.fork(
568
+ parentScope,
569
+ ExecutionStrategy.sequential,
570
+ );
571
+ yield* createEntryPointWatcher(
572
+ entry,
573
+ signal,
574
+ pendingRef,
575
+ watcherErrorsRef,
576
+ ).pipe(Scope.extend(childScope));
577
+ yield* Ref.update(scopesRef, (scopes) => {
578
+ const updated = new Map(scopes);
579
+ updated.set(entry.absolutePath, childScope);
580
+ return updated;
581
+ });
582
+ }),
502
583
  );
503
- }),
504
- );
584
+ });
505
585
 
506
- const formatBuildError = (
507
- error: esbuild.Message | undefined,
508
- formattedMessage: string,
509
- ): string => {
510
- const lines = String.split(formattedMessage, "\n");
511
- const redErrorText = pipe(
512
- AnsiDoc.text(error?.text ?? ""),
513
- AnsiDoc.annotate(Ansi.red),
514
- AnsiDoc.render({ style: "pretty" }),
515
- );
516
- const replaced = pipe(
517
- Array.findFirstIndex(lines, (l) => pipe(l, String.trim, String.isNonEmpty)),
518
- Option.match({
519
- onNone: () => lines,
520
- onSome: (index) => Array.modify(lines, index, () => redErrorText),
521
- }),
522
- );
523
- return pipe(replaced, Array.join("\n"));
524
- };
586
+ yield* sync;
587
+
588
+ return yield* Effect.forever(
589
+ Queue.take(restartQueue).pipe(Effect.andThen(sync)),
590
+ );
591
+ });
525
592
 
526
- const formatBuildErrors = (
527
- errors: readonly esbuild.Message[],
528
- formattedMessages: readonly string[],
529
- ): string =>
593
+ /**
594
+ * Single recursive `fs.watch` on `confect/`. Flips the matching dirty flag
595
+ * for any change to an entry-point-shaped file (so codegen runs without
596
+ * waiting on a newly spawned esbuild watcher), and offers to
597
+ * `restartQueue` when an entry point is created or removed so the watcher
598
+ * manager picks up the new set.
599
+ */
600
+ const confectStructureWatcher = (
601
+ signal: Queue.Queue<void>,
602
+ pendingRef: Ref.Ref<Pending>,
603
+ restartQueue: Queue.Queue<void>,
604
+ ) =>
605
+ Effect.gen(function* () {
606
+ const fs = yield* FileSystem.FileSystem;
607
+ const path = yield* Path.Path;
608
+ const confectDirectory = yield* ConfectDirectory.get;
609
+
610
+ yield* pipe(
611
+ fs.watch(confectDirectory, { recursive: true }),
612
+ Stream.debounce(Duration.millis(200)),
613
+ Stream.runForEach((event) =>
614
+ handleConfectChange({
615
+ relativePath: path.relative(confectDirectory, event.path),
616
+ eventTag: event._tag,
617
+ signal,
618
+ pendingRef,
619
+ restartQueue,
620
+ }),
621
+ ),
622
+ );
623
+ });
624
+
625
+ const TOP_LEVEL_OPTIONAL_KEYS: ReadonlyMap<string, PendingKey> = new Map([
626
+ ["http.ts", "httpDirty"],
627
+ ["crons.ts", "cronsDirty"],
628
+ ["auth.ts", "authDirty"],
629
+ ]);
630
+
631
+ const flipDirtyAndSignal = (
632
+ pendingRef: Ref.Ref<Pending>,
633
+ signal: Queue.Queue<void>,
634
+ key: PendingKey,
635
+ restartQueue: Queue.Queue<void>,
636
+ restart: boolean,
637
+ ) =>
530
638
  pipe(
531
- formattedMessages,
532
- Array.map((message, i) => formatBuildError(errors[i], message)),
533
- Array.join(""),
534
- String.trimEnd,
639
+ Ref.update(pendingRef, (p) => ({ ...p, [key]: true })),
640
+ Effect.andThen(Queue.offer(signal, undefined)),
641
+ Effect.andThen(
642
+ restart ? Queue.offer(restartQueue, undefined) : Effect.void,
643
+ ),
535
644
  );
536
645
 
537
- export class SpecFileDoesNotExportSpecError extends Schema.TaggedError<SpecFileDoesNotExportSpecError>()(
538
- "SpecFileDoesNotExportSpecError",
539
- {},
540
- ) {}
646
+ const handleConfectChange = ({
647
+ relativePath,
648
+ eventTag,
649
+ signal,
650
+ pendingRef,
651
+ restartQueue,
652
+ }: {
653
+ relativePath: string;
654
+ eventTag: "Create" | "Update" | "Remove";
655
+ signal: Queue.Queue<void>;
656
+ pendingRef: Ref.Ref<Pending>;
657
+ restartQueue: Queue.Queue<void>;
658
+ }) => {
659
+ // _generated/ files are written by codegen itself; reacting to them here
660
+ // would form a loop. The esbuild watchers track the generated specs as
661
+ // entry points, so changes there flow back through `notify-rebuild`.
662
+ if (relativePath.split(/[/\\]/).includes("_generated")) {
663
+ return Effect.void;
664
+ }
665
+
666
+ if (!relativePath.endsWith(".ts")) {
667
+ return Effect.void;
668
+ }
669
+
670
+ const isLifecycleChange = eventTag !== "Update";
671
+
672
+ const topLevelKey = TOP_LEVEL_OPTIONAL_KEYS.get(relativePath);
673
+ if (topLevelKey !== undefined) {
674
+ return flipDirtyAndSignal(
675
+ pendingRef,
676
+ signal,
677
+ topLevelKey,
678
+ restartQueue,
679
+ isLifecycleChange,
680
+ );
681
+ }
682
+
683
+ if (relativePath === "schema.ts") {
684
+ return flipDirtyAndSignal(
685
+ pendingRef,
686
+ signal,
687
+ "specDirty",
688
+ restartQueue,
689
+ isLifecycleChange,
690
+ );
691
+ }
692
+
693
+ if (isLeafSpecPath(relativePath) || isLeafImplPath(relativePath)) {
694
+ return flipDirtyAndSignal(
695
+ pendingRef,
696
+ signal,
697
+ "specDirty",
698
+ restartQueue,
699
+ isLifecycleChange,
700
+ );
701
+ }
541
702
 
542
- export class NodeSpecFileDoesNotExportSpecError extends Schema.TaggedError<NodeSpecFileDoesNotExportSpecError>()(
543
- "NodeSpecFileDoesNotExportSpecError",
544
- {},
545
- ) {}
703
+ // Any other `.ts` under `confect/` (helpers like `tables/Notes.ts`).
704
+ // Updates to such files are handled by the esbuild watcher for whichever
705
+ // entry point imports them — its onEnd flips the right dirty flag.
706
+ // Creates are our safety net: when a previously-missing import is added,
707
+ // esbuild may not have its parent directory on a poll path, so we
708
+ // re-run codegen on Create here.
709
+ if (eventTag === "Create") {
710
+ return flipDirtyAndSignal(
711
+ pendingRef,
712
+ signal,
713
+ "specDirty",
714
+ restartQueue,
715
+ false,
716
+ );
717
+ }
546
718
 
547
- export class SpecImportFailedError extends Schema.TaggedError<SpecImportFailedError>()(
548
- "SpecImportFailedError",
549
- {
550
- error: Schema.Unknown,
551
- },
552
- ) {}
719
+ return Effect.void;
720
+ };
553
721
 
554
722
  const syncOptionalFile = (generate: typeof generateHttp, convexFile: string) =>
555
723
  pipe(
@@ -579,50 +747,3 @@ const syncOptionalFile = (generate: typeof generateHttp, convexFile: string) =>
579
747
  }),
580
748
  ),
581
749
  );
582
-
583
- const optionalConfectFiles: ReadonlyRecord<string, keyof Pending> = {
584
- "http.ts": "httpDirty",
585
- "crons.ts": "cronsDirty",
586
- "auth.ts": "authDirty",
587
- "nodeSpec.ts": "specDirty",
588
- "nodeImpl.ts": "nodeImplDirty",
589
- };
590
-
591
- const confectDirectoryWatcher = (
592
- signal: Queue.Queue<void>,
593
- pendingRef: Ref.Ref<Pending>,
594
- specWatcherRestartQueue: Queue.Queue<void>,
595
- ) =>
596
- Effect.gen(function* () {
597
- const fs = yield* FileSystem.FileSystem;
598
- const path = yield* Path.Path;
599
- const confectDirectory = yield* ConfectDirectory.get;
600
-
601
- yield* pipe(
602
- fs.watch(confectDirectory),
603
- Stream.runForEach((event) => {
604
- const basename = path.basename(event.path);
605
- const pendingKey = optionalConfectFiles[basename];
606
-
607
- if (pendingKey !== undefined) {
608
- return pipe(
609
- pendingRef,
610
- Ref.update((pending) => {
611
- const next = { ...pending, [pendingKey]: true };
612
- if (basename === "nodeImpl.ts") {
613
- return { ...next, specDirty: true };
614
- }
615
- return next;
616
- }),
617
- Effect.andThen(Queue.offer(signal, undefined)),
618
- Effect.andThen(
619
- basename === "nodeSpec.ts"
620
- ? Queue.offer(specWatcherRestartQueue, undefined)
621
- : Effect.void,
622
- ),
623
- );
624
- }
625
- return Effect.void;
626
- }),
627
- );
628
- });