@confect/cli 9.0.0-next.0 → 9.0.0-next.10

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 (69) hide show
  1. package/CHANGELOG.md +351 -1
  2. package/dist/BuildError.mjs +9 -2
  3. package/dist/BuildError.mjs.map +1 -1
  4. package/dist/Bundler.mjs +67 -57
  5. package/dist/Bundler.mjs.map +1 -1
  6. package/dist/CodeBlockWriter.mjs +1 -1
  7. package/dist/CodeBlockWriter.mjs.map +1 -1
  8. package/dist/CodegenError.mjs +36 -21
  9. package/dist/CodegenError.mjs.map +1 -1
  10. package/dist/ConfectDirectory.mjs +5 -2
  11. package/dist/ConfectDirectory.mjs.map +1 -1
  12. package/dist/ConvexDirectory.mjs +6 -2
  13. package/dist/ConvexDirectory.mjs.map +1 -1
  14. package/dist/FunctionPath.mjs +1 -1
  15. package/dist/FunctionPath.mjs.map +1 -1
  16. package/dist/FunctionPaths.mjs +5 -1
  17. package/dist/FunctionPaths.mjs.map +1 -1
  18. package/dist/GroupPath.mjs +9 -2
  19. package/dist/GroupPath.mjs.map +1 -1
  20. package/dist/GroupPaths.mjs +1 -1
  21. package/dist/GroupPaths.mjs.map +1 -1
  22. package/dist/LeafModule.mjs +20 -24
  23. package/dist/LeafModule.mjs.map +1 -1
  24. package/dist/ProjectRoot.mjs +8 -3
  25. package/dist/ProjectRoot.mjs.map +1 -1
  26. package/dist/SpecAssemblyNode.mjs +9 -11
  27. package/dist/SpecAssemblyNode.mjs.map +1 -1
  28. package/dist/TableModule.mjs +94 -0
  29. package/dist/TableModule.mjs.map +1 -0
  30. package/dist/cliApp.mjs +1 -1
  31. package/dist/cliApp.mjs.map +1 -1
  32. package/dist/confect/codegen.mjs +272 -141
  33. package/dist/confect/codegen.mjs.map +1 -1
  34. package/dist/confect/dev.mjs +36 -17
  35. package/dist/confect/dev.mjs.map +1 -1
  36. package/dist/confect.mjs +2 -2
  37. package/dist/confect.mjs.map +1 -1
  38. package/dist/index.mjs +3 -2
  39. package/dist/index.mjs.map +1 -1
  40. package/dist/log.mjs +9 -4
  41. package/dist/log.mjs.map +1 -1
  42. package/dist/package.mjs +1 -1
  43. package/dist/templates.mjs +139 -48
  44. package/dist/templates.mjs.map +1 -1
  45. package/dist/utils.mjs +42 -22
  46. package/dist/utils.mjs.map +1 -1
  47. package/package.json +33 -50
  48. package/dist/index.d.mts +0 -1
  49. package/src/BuildError.ts +0 -210
  50. package/src/Bundler.ts +0 -144
  51. package/src/CodeBlockWriter.ts +0 -65
  52. package/src/CodegenError.ts +0 -344
  53. package/src/ConfectDirectory.ts +0 -42
  54. package/src/ConvexDirectory.ts +0 -68
  55. package/src/FunctionPath.ts +0 -27
  56. package/src/FunctionPaths.ts +0 -103
  57. package/src/GroupPath.ts +0 -118
  58. package/src/GroupPaths.ts +0 -7
  59. package/src/LeafModule.ts +0 -313
  60. package/src/ProjectRoot.ts +0 -50
  61. package/src/SpecAssemblyNode.ts +0 -82
  62. package/src/cliApp.ts +0 -8
  63. package/src/confect/codegen.ts +0 -589
  64. package/src/confect/dev.ts +0 -749
  65. package/src/confect.ts +0 -19
  66. package/src/index.ts +0 -22
  67. package/src/log.ts +0 -104
  68. package/src/templates.ts +0 -477
  69. package/src/utils.ts +0 -429
@@ -1,749 +0,0 @@
1
- import { Command } from "@effect/cli";
2
- import { FileSystem, Path } from "@effect/platform";
3
- import { Ansi, AnsiDoc } from "@effect/printer-ansi";
4
- import {
5
- Array,
6
- Chunk,
7
- Clock,
8
- Console,
9
- Duration,
10
- Effect,
11
- Equal,
12
- ExecutionStrategy,
13
- Exit,
14
- HashSet,
15
- Match,
16
- Option,
17
- Order,
18
- pipe,
19
- Queue,
20
- Ref,
21
- Scope,
22
- Stream,
23
- String,
24
- } from "effect";
25
- import * as esbuild from "esbuild";
26
- import { logCoalescedBuildErrors } from "../BuildError";
27
- import { absoluteExternalsPlugin, EXTERNAL_PACKAGES } from "../Bundler";
28
- import * as CodegenError from "../CodegenError";
29
- import { ConfectDirectory } from "../ConfectDirectory";
30
- import { ConvexDirectory } from "../ConvexDirectory";
31
- import * as FunctionPaths from "../FunctionPaths";
32
- import type * as GroupPaths from "../GroupPaths";
33
- import {
34
- discoverLeafImplFiles,
35
- isLeafImplPath,
36
- isLeafSpecPath,
37
- } from "../LeafModule";
38
- import {
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());
70
-
71
- type Pending = {
72
- readonly specDirty: boolean;
73
- readonly httpDirty: boolean;
74
- readonly cronsDirty: boolean;
75
- readonly authDirty: boolean;
76
- };
77
-
78
- type PendingKey = keyof Pending;
79
-
80
- const pendingInit: Pending = {
81
- specDirty: false,
82
- httpDirty: false,
83
- cronsDirty: false,
84
- authDirty: false,
85
- };
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
-
94
- const changeChar = (change: "Added" | "Removed" | "Modified") =>
95
- Match.value(change).pipe(
96
- Match.when("Added", () => ({ char: "+", color: Ansi.green })),
97
- Match.when("Removed", () => ({ char: "-", color: Ansi.red })),
98
- Match.when("Modified", () => ({ char: "~", color: Ansi.yellow })),
99
- Match.exhaustive,
100
- );
101
-
102
- const logFileChangeIndented = (
103
- change: "Added" | "Removed" | "Modified",
104
- fullPath: string,
105
- ) =>
106
- Effect.gen(function* () {
107
- const projectRoot = yield* ProjectRoot.get;
108
- const path = yield* Path.Path;
109
-
110
- const prefix = projectRoot + path.sep;
111
- const suffix = pipe(fullPath, String.startsWith(prefix))
112
- ? pipe(fullPath, String.slice(prefix.length))
113
- : fullPath;
114
-
115
- const { char, color } = changeChar(change);
116
-
117
- yield* Console.log(
118
- pipe(
119
- AnsiDoc.char(char),
120
- AnsiDoc.annotate(color),
121
- AnsiDoc.catWithSpace(
122
- AnsiDoc.hcat([
123
- pipe(AnsiDoc.text(prefix), AnsiDoc.annotate(Ansi.blackBright)),
124
- pipe(AnsiDoc.text(suffix), AnsiDoc.annotate(color)),
125
- ]),
126
- ),
127
- AnsiDoc.render({ style: "pretty" }),
128
- ),
129
- );
130
- });
131
-
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)),
154
- ),
155
- logFn,
156
- ),
157
- );
158
-
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
- ),
168
- ),
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
- });
182
-
183
- export const dev = Command.make("dev", {}, () =>
184
- Effect.gen(function* () {
185
- yield* logPending("Performing initial sync…");
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
- });
198
-
199
- const pendingRef = yield* Ref.make<Pending>(pendingInit);
200
- const signal = yield* Queue.sliding<void>(1);
201
- const restartQueue = yield* Queue.sliding<void>(1);
202
- const watcherErrorsRef = yield* Ref.make<WatcherErrors>(emptyWatcherErrors);
203
-
204
- yield* Effect.all(
205
- [
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),
216
- ],
217
- { concurrency: "unbounded" },
218
- );
219
- }),
220
- ).pipe(Command.withDescription("Start the Confect development server"));
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
-
296
- const syncLoop = (
297
- signal: Queue.Queue<void>,
298
- pendingRef: Ref.Ref<Pending>,
299
- initialFunctionPaths: FunctionPaths.FunctionPaths,
300
- watcherErrorsRef: Ref.Ref<WatcherErrors>,
301
- ) =>
302
- Effect.gen(function* () {
303
- const functionPathsRef = yield* Ref.make(initialFunctionPaths);
304
- const lastLoggedErrorsRef = yield* Ref.make<string>("");
305
-
306
- return yield* Effect.forever(
307
- Effect.gen(function* () {
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`.
312
- yield* Queue.take(signal);
313
- yield* drainUntilQuiescent(
314
- signal,
315
- COALESCE_QUIESCENCE,
316
- COALESCE_MAX_WAIT,
317
- );
318
-
319
- yield* logChangedWatcherErrors(watcherErrorsRef, lastLoggedErrorsRef);
320
-
321
- const pending = yield* Ref.getAndSet(pendingRef, pendingInit);
322
-
323
- if (!isPendingDirty(pending)) {
324
- // No-op signal (e.g. a late echo after a previous cycle
325
- // already drained). Stay silent.
326
- return;
327
- }
328
-
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
- }
360
-
361
- const dirtyOptionalFiles = [
362
- ...(pending.httpDirty
363
- ? [syncOptionalFile(generateHttp, "http.ts")]
364
- : []),
365
- ...(pending.cronsDirty
366
- ? [syncOptionalFile(generateCrons, "crons.ts")]
367
- : []),
368
- ...(pending.authDirty
369
- ? [syncOptionalFile(generateAuthConfig, "auth.config.ts")]
370
- : []),
371
- ];
372
-
373
- yield* Array.isNonEmptyReadonlyArray(dirtyOptionalFiles)
374
- ? Effect.all(dirtyOptionalFiles, { concurrency: "unbounded" })
375
- : Effect.void;
376
-
377
- yield* logSuccess("Generated files are up-to-date");
378
- }),
379
- );
380
- });
381
-
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* () {
395
- const fs = yield* FileSystem.FileSystem;
396
- const path = yield* Path.Path;
397
- const projectRoot = yield* ProjectRoot.get;
398
- const confectDirectory = yield* ConfectDirectory.get;
399
-
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"),
426
- );
427
-
428
- return Array.getSomes([...fixedEntryOptions, ...implEntryOptions]);
429
- });
430
-
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) => {
460
- Effect.runPromise(
461
- Effect.gen(function* () {
462
- const wasInitial = yield* Ref.getAndSet(
463
- initialBuildSeenRef,
464
- true,
465
- );
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);
482
- }),
483
- );
484
- });
485
- },
486
- },
487
- ],
488
- };
489
- };
490
-
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
- }),
520
- );
521
-
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 = (
530
- signal: Queue.Queue<void>,
531
- pendingRef: Ref.Ref<Pending>,
532
- restartQueue: Queue.Queue<void>,
533
- watcherErrorsRef: Ref.Ref<WatcherErrors>,
534
- ) =>
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]),
543
- );
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
- ),
560
- );
561
-
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
- }),
583
- );
584
- });
585
-
586
- yield* sync;
587
-
588
- return yield* Effect.forever(
589
- Queue.take(restartQueue).pipe(Effect.andThen(sync)),
590
- );
591
- });
592
-
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
- ) =>
638
- pipe(
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
- ),
644
- );
645
-
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
- }
702
-
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
- }
718
-
719
- return Effect.void;
720
- };
721
-
722
- const syncOptionalFile = (generate: typeof generateHttp, convexFile: string) =>
723
- pipe(
724
- generate,
725
- Effect.andThen(
726
- Option.match({
727
- onSome: ({ change, convexFilePath }) =>
728
- Match.value(change).pipe(
729
- Match.when("Unchanged", () => Effect.void),
730
- Match.whenOr("Added", "Modified", (addedOrModified) =>
731
- logFileChangeIndented(addedOrModified, convexFilePath),
732
- ),
733
- Match.exhaustive,
734
- ),
735
- onNone: () =>
736
- Effect.gen(function* () {
737
- const fs = yield* FileSystem.FileSystem;
738
- const path = yield* Path.Path;
739
- const convexDirectory = yield* ConvexDirectory.get;
740
- const convexFilePath = path.join(convexDirectory, convexFile);
741
-
742
- if (yield* fs.exists(convexFilePath)) {
743
- yield* fs.remove(convexFilePath);
744
- yield* logFileChangeIndented("Removed", convexFilePath);
745
- }
746
- }),
747
- }),
748
- ),
749
- );