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