@confect/cli 8.0.0 → 9.0.0-next.1

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,26 +1,35 @@
1
- import { modulePath, toString } from "../GroupPath.mjs";
2
1
  import { ProjectRoot } from "../ProjectRoot.mjs";
3
- import { logFailure, logPending, logSuccess } from "../log.mjs";
2
+ import { logFunctionAdded, logFunctionRemoved, logPending, logSuccess } from "../log.mjs";
3
+ import { logCoalescedBuildErrors } from "../BuildError.mjs";
4
+ import { catchAndLog } from "../CodegenError.mjs";
4
5
  import { ConvexDirectory } from "../ConvexDirectory.mjs";
5
6
  import { ConfectDirectory } from "../ConfectDirectory.mjs";
6
- import { diff, make } from "../FunctionPaths.mjs";
7
- import { EXTERNAL_PACKAGES, bundleAndImport, generateAuthConfig, generateCrons, generateHttp, removeGroups, writeGroups } from "../utils.mjs";
8
- import { codegenHandler, generateNodeApi, generateNodeRegisteredFunctions } from "./codegen.mjs";
9
- import { Array, Console, Deferred, Duration, Effect, Equal, HashSet, Match, Option, Queue, Ref, Schema, Stream, String, pipe } from "effect";
7
+ import { FunctionPaths, diff } from "../FunctionPaths.mjs";
8
+ import { EXTERNAL_PACKAGES, absoluteExternalsPlugin } from "../Bundler.mjs";
9
+ import { generateAuthConfig, generateCrons, generateHttp } from "../utils.mjs";
10
+ import { discoverLeafImplFiles, isLeafImplPath, isLeafSpecPath } from "../LeafModule.mjs";
11
+ import { codegenHandler, loadPreviousFunctionPaths } from "./codegen.mjs";
12
+ import { Array, Chunk, Clock, Console, Duration, Effect, Equal, ExecutionStrategy, Exit, HashSet, Match, Option, Order, Queue, Ref, Scope, Stream, String, pipe } from "effect";
10
13
  import { Command } from "@effect/cli";
11
- import { Spec } from "@confect/core";
12
14
  import { FileSystem, Path } from "@effect/platform";
13
15
  import { Ansi, AnsiDoc } from "@effect/printer-ansi";
14
16
  import * as esbuild from "esbuild";
15
17
 
16
18
  //#region src/confect/dev.ts
19
+ const GENERATED_SPEC_PATH = "_generated/spec.ts";
20
+ const GENERATED_NODE_SPEC_PATH = "_generated/nodeSpec.ts";
21
+ const COALESCE_QUIESCENCE = Duration.millis(300);
22
+ const COALESCE_MAX_WAIT = Duration.seconds(5);
23
+ const ECHO_COOLDOWN = Duration.millis(500);
24
+ const emptyFunctionPaths = FunctionPaths.make(HashSet.empty());
17
25
  const pendingInit = {
18
26
  specDirty: false,
19
- nodeImplDirty: false,
20
27
  httpDirty: false,
21
28
  cronsDirty: false,
22
29
  authDirty: false
23
30
  };
31
+ const isPendingDirty = (p) => p.specDirty || p.httpDirty || p.cronsDirty || p.authDirty;
32
+ const emptyWatcherErrors = /* @__PURE__ */ new Map();
24
33
  const changeChar = (change) => Match.value(change).pipe(Match.when("Added", () => ({
25
34
  char: "+",
26
35
  color: Ansi.green
@@ -37,182 +46,255 @@ const logFileChangeIndented = (change, fullPath) => Effect.gen(function* () {
37
46
  const { char, color } = changeChar(change);
38
47
  yield* Console.log(pipe(AnsiDoc.char(char), AnsiDoc.annotate(color), AnsiDoc.catWithSpace(AnsiDoc.hcat([pipe(AnsiDoc.text(prefix), AnsiDoc.annotate(Ansi.blackBright)), pipe(AnsiDoc.text(suffix), AnsiDoc.annotate(color))])), AnsiDoc.render({ style: "pretty" })));
39
48
  });
40
- const logFunctionAddedIndented = (functionPath) => Console.log(pipe(AnsiDoc.text(" "), AnsiDoc.cat(pipe(AnsiDoc.char("+"), AnsiDoc.annotate(Ansi.green))), AnsiDoc.catWithSpace(AnsiDoc.hcat([pipe(AnsiDoc.text(toString(functionPath.groupPath) + "."), AnsiDoc.annotate(Ansi.blackBright)), pipe(AnsiDoc.text(functionPath.name), AnsiDoc.annotate(Ansi.green))])), AnsiDoc.render({ style: "pretty" })));
41
- const logFunctionRemovedIndented = (functionPath) => Console.log(pipe(AnsiDoc.text(" "), AnsiDoc.cat(pipe(AnsiDoc.char("-"), AnsiDoc.annotate(Ansi.red))), AnsiDoc.catWithSpace(AnsiDoc.hcat([pipe(AnsiDoc.text(toString(functionPath.groupPath) + "."), AnsiDoc.annotate(Ansi.blackBright)), pipe(AnsiDoc.text(functionPath.name), AnsiDoc.annotate(Ansi.red))])), AnsiDoc.render({ style: "pretty" })));
49
+ const logFunctionPathDiff = (previous, current) => Effect.gen(function* () {
50
+ const { functionsAdded, functionsRemoved, groupsRemoved, groupsAdded, groupsChanged } = diff(previous, current);
51
+ const logForGroups = (groupPaths, fnPaths, logFn) => Effect.forEach(groupPaths, (gp) => Effect.forEach(Array.fromIterable(HashSet.filter(fnPaths, (fp) => Equal.equals(fp.groupPath, gp))), logFn));
52
+ yield* logForGroups(groupsRemoved, functionsRemoved, logFunctionRemoved);
53
+ yield* logForGroups(groupsAdded, functionsAdded, logFunctionAdded);
54
+ yield* Effect.forEach(groupsChanged, (gp) => Effect.gen(function* () {
55
+ yield* Effect.forEach(Array.fromIterable(HashSet.filter(functionsAdded, (fp) => Equal.equals(fp.groupPath, gp))), logFunctionAdded);
56
+ yield* Effect.forEach(Array.fromIterable(HashSet.filter(functionsRemoved, (fp) => Equal.equals(fp.groupPath, gp))), logFunctionRemoved);
57
+ }));
58
+ });
42
59
  const dev = Command.make("dev", {}, () => Effect.gen(function* () {
43
60
  yield* logPending("Performing initial sync…");
44
- const initialFunctionPaths = yield* codegenHandler;
61
+ const previousFunctionPaths = yield* loadPreviousFunctionPaths;
62
+ const initialResult = yield* codegenHandler.pipe(Effect.tap(({ functionPaths }) => logFunctionPathDiff(previousFunctionPaths, functionPaths)), Effect.tap(() => logSuccess("Generated files are up-to-date")), catchAndLog);
63
+ const initialFunctionPaths = Option.match(initialResult, {
64
+ onNone: () => emptyFunctionPaths,
65
+ onSome: ({ functionPaths }) => functionPaths
66
+ });
45
67
  const pendingRef = yield* Ref.make(pendingInit);
46
68
  const signal = yield* Queue.sliding(1);
47
- const specWatcherRestartQueue = yield* Queue.sliding(1);
69
+ const restartQueue = yield* Queue.sliding(1);
70
+ const watcherErrorsRef = yield* Ref.make(emptyWatcherErrors);
48
71
  yield* Effect.all([
49
- specFileWatcher(signal, pendingRef, specWatcherRestartQueue),
50
- confectDirectoryWatcher(signal, pendingRef, specWatcherRestartQueue),
51
- syncLoop(signal, pendingRef, initialFunctionPaths)
72
+ Effect.scoped(entryPointsWatcher(signal, pendingRef, restartQueue, watcherErrorsRef)),
73
+ confectStructureWatcher(signal, pendingRef, restartQueue),
74
+ syncLoop(signal, pendingRef, initialFunctionPaths, watcherErrorsRef)
52
75
  ], { concurrency: "unbounded" });
53
76
  })).pipe(Command.withDescription("Start the Confect development server"));
54
- const syncLoop = (signal, pendingRef, initialFunctionPaths) => Effect.gen(function* () {
77
+ const esbuildMessageKey = (m) => `${m.location?.file ?? ""}:${m.location?.line ?? ""}:${m.location?.column ?? ""}:${m.text}`;
78
+ const allMessages = (errors) => pipe(Array.fromIterable(errors.values()), Array.flatten);
79
+ const dedupeWatcherErrors = (errors) => pipe(allMessages(errors), Array.dedupeWith((messageA, messageB) => esbuildMessageKey(messageA) === esbuildMessageKey(messageB)));
80
+ const watcherErrorsSignature = (errors) => pipe(allMessages(errors), Array.map(esbuildMessageKey), Array.dedupe, Array.sort(Order.string), Array.join("\n"));
81
+ /**
82
+ * Log any watcher errors that haven't already been logged at their
83
+ * current signature. Suppresses the per-watcher fanout that happens
84
+ * when one root cause (e.g. a missing import) breaks every entry
85
+ * point's build at the same source location.
86
+ */
87
+ const logChangedWatcherErrors = (watcherErrorsRef, lastLoggedSignatureRef) => Effect.gen(function* () {
88
+ const errors = yield* Ref.get(watcherErrorsRef);
89
+ const signature = watcherErrorsSignature(errors);
90
+ if (signature === (yield* Ref.get(lastLoggedSignatureRef))) return;
91
+ yield* Ref.set(lastLoggedSignatureRef, signature);
92
+ if (errors.size === 0) return;
93
+ yield* logCoalescedBuildErrors(dedupeWatcherErrors(errors));
94
+ });
95
+ /**
96
+ * Block until the signal queue has been quiet for `quiescence`. esbuild
97
+ * watchers' `onEnd` events for a single user edit can be spread across
98
+ * hundreds of milliseconds, so a fixed window misses late arrivals.
99
+ * Bounded by `maxWait` so pathological signal floods can't pin the
100
+ * loop forever.
101
+ */
102
+ const drainUntilQuiescent = (signal, quiescence, maxWait) => Effect.gen(function* () {
103
+ const start = yield* Clock.currentTimeMillis;
104
+ const maxMillis = Duration.toMillis(maxWait);
105
+ yield* Effect.iterate(true, {
106
+ while: (keepGoing) => keepGoing,
107
+ body: () => Effect.gen(function* () {
108
+ yield* Effect.sleep(quiescence);
109
+ const drained = yield* Queue.takeAll(signal);
110
+ if (Chunk.isEmpty(drained)) return false;
111
+ return (yield* Clock.currentTimeMillis) - start < maxMillis;
112
+ })
113
+ });
114
+ });
115
+ const syncLoop = (signal, pendingRef, initialFunctionPaths, watcherErrorsRef) => Effect.gen(function* () {
55
116
  const functionPathsRef = yield* Ref.make(initialFunctionPaths);
56
- const initialSyncDone = yield* Deferred.make();
117
+ const lastLoggedErrorsRef = yield* Ref.make("");
57
118
  return yield* Effect.forever(Effect.gen(function* () {
58
- yield* Effect.logDebug("Running sync loop...");
119
+ yield* Effect.logDebug("Running sync loop");
59
120
  yield* Queue.take(signal);
60
- const isDone = yield* Deferred.isDone(initialSyncDone);
61
- yield* Effect.when(logPending("Dependencies changed, reloading…"), () => isDone);
62
- yield* Deferred.succeed(initialSyncDone, void 0);
121
+ yield* drainUntilQuiescent(signal, COALESCE_QUIESCENCE, COALESCE_MAX_WAIT);
122
+ yield* logChangedWatcherErrors(watcherErrorsRef, lastLoggedErrorsRef);
63
123
  const pending = yield* Ref.getAndSet(pendingRef, pendingInit);
64
- if (pending.specDirty || pending.nodeImplDirty) {
65
- yield* generateNodeApi;
66
- yield* generateNodeRegisteredFunctions;
124
+ if (!isPendingDirty(pending)) return;
125
+ yield* logPending("Dependencies may have changed, reloading…");
126
+ if (pending.specDirty) {
127
+ const current = yield* codegenHandler.pipe(Effect.tap(({ functionPaths: nextFunctionPaths }) => Effect.gen(function* () {
128
+ yield* logFunctionPathDiff(yield* Ref.get(functionPathsRef), nextFunctionPaths);
129
+ yield* Ref.set(functionPathsRef, nextFunctionPaths);
130
+ })), catchAndLog);
131
+ if (Option.isNone(current)) return;
132
+ if (current.value.anyWritesHappened) yield* Effect.sleep(ECHO_COOLDOWN);
133
+ yield* drainUntilQuiescent(signal, COALESCE_QUIESCENCE, COALESCE_MAX_WAIT);
134
+ yield* Ref.set(pendingRef, pendingInit);
67
135
  }
68
- const specResult = yield* Effect.if(pending.specDirty, {
69
- onTrue: () => loadSpec.pipe(Effect.andThen(Effect.fn(function* (spec) {
70
- yield* Effect.logDebug("Spec loaded");
71
- const previous = yield* Ref.get(functionPathsRef);
72
- const path = yield* Path.Path;
73
- const convexDirectory = yield* ConvexDirectory.get;
74
- const current = make(spec);
75
- const { functionsAdded, functionsRemoved, groupsRemoved, groupsAdded, groupsChanged } = diff(previous, current);
76
- yield* removeGroups(groupsRemoved);
77
- yield* Effect.forEach(groupsRemoved, (gp) => Effect.gen(function* () {
78
- const relativeModulePath = yield* modulePath(gp);
79
- yield* logFileChangeIndented("Removed", path.join(convexDirectory, relativeModulePath));
80
- yield* Effect.forEach(Array.fromIterable(HashSet.filter(functionsRemoved, (fp) => Equal.equals(fp.groupPath, gp))), logFunctionRemovedIndented);
81
- }));
82
- yield* writeGroups(spec, groupsAdded);
83
- yield* Effect.forEach(groupsAdded, (gp) => Effect.gen(function* () {
84
- const relativeModulePath = yield* modulePath(gp);
85
- yield* logFileChangeIndented("Added", path.join(convexDirectory, relativeModulePath));
86
- yield* Effect.forEach(Array.fromIterable(HashSet.filter(functionsAdded, (fp) => Equal.equals(fp.groupPath, gp))), logFunctionAddedIndented);
87
- }));
88
- yield* writeGroups(spec, groupsChanged);
89
- yield* Effect.forEach(groupsChanged, (gp) => Effect.gen(function* () {
90
- const relativeModulePath = yield* modulePath(gp);
91
- yield* logFileChangeIndented("Modified", path.join(convexDirectory, relativeModulePath));
92
- yield* Effect.forEach(Array.fromIterable(HashSet.filter(functionsAdded, (fp) => Equal.equals(fp.groupPath, gp))), logFunctionAddedIndented);
93
- yield* Effect.forEach(Array.fromIterable(HashSet.filter(functionsRemoved, (fp) => Equal.equals(fp.groupPath, gp))), logFunctionRemovedIndented);
94
- }));
95
- yield* Ref.set(functionPathsRef, current);
96
- return Option.some(void 0);
97
- })), Effect.catchTag("SpecImportFailedError", () => logFailure("Spec import failed").pipe(Effect.as(Option.none()))), Effect.catchTag("SpecFileDoesNotExportSpecError", () => logFailure("Spec file does not default export a Convex spec").pipe(Effect.as(Option.none()))), Effect.catchTag("NodeSpecFileDoesNotExportSpecError", () => logFailure("Node spec file does not default export a Node spec").pipe(Effect.as(Option.none())))),
98
- onFalse: () => Effect.succeed(Option.some(void 0))
99
- });
100
136
  const dirtyOptionalFiles = [
101
137
  ...pending.httpDirty ? [syncOptionalFile(generateHttp, "http.ts")] : [],
102
138
  ...pending.cronsDirty ? [syncOptionalFile(generateCrons, "crons.ts")] : [],
103
139
  ...pending.authDirty ? [syncOptionalFile(generateAuthConfig, "auth.config.ts")] : []
104
140
  ];
105
141
  yield* Array.isNonEmptyReadonlyArray(dirtyOptionalFiles) ? Effect.all(dirtyOptionalFiles, { concurrency: "unbounded" }) : Effect.void;
106
- yield* Option.match(specResult, {
107
- onSome: () => logSuccess("Generated files are up-to-date"),
108
- onNone: () => Effect.void
109
- });
142
+ yield* logSuccess("Generated files are up-to-date");
110
143
  }));
111
144
  });
112
- const loadSpec = Effect.gen(function* () {
145
+ /**
146
+ * Every file whose import graph codegen should react to. Each one becomes
147
+ * its own scoped esbuild watcher; the union of their watches gives us
148
+ * dependency-aware tracking of anything reachable from `confect/`,
149
+ * including files outside `confect/`.
150
+ */
151
+ const discoverEntryPoints = Effect.gen(function* () {
113
152
  const fs = yield* FileSystem.FileSystem;
114
153
  const path = yield* Path.Path;
154
+ const projectRoot = yield* ProjectRoot.get;
115
155
  const confectDirectory = yield* ConfectDirectory.get;
116
- const spec = (yield* bundleAndImport(yield* getSpecPath).pipe(Effect.mapError((error) => new SpecImportFailedError({ error })))).default;
117
- if (!Spec.isConvexSpec(spec)) return yield* new SpecFileDoesNotExportSpecError();
118
- const nodeImplPath = path.join(confectDirectory, "nodeImpl.ts");
119
- const nodeImplExists = yield* fs.exists(nodeImplPath);
120
- const nodeSpecOption = yield* loadNodeSpec;
121
- return Option.match(nodeSpecOption, {
122
- onNone: () => spec,
123
- onSome: (nodeSpec) => nodeImplExists ? Spec.merge(spec, nodeSpec) : spec
156
+ const tryEntry = (relativePath, pendingKey) => Effect.gen(function* () {
157
+ const absolutePath = path.join(confectDirectory, relativePath);
158
+ if (!(yield* fs.exists(absolutePath))) return Option.none();
159
+ return Option.some({
160
+ absolutePath,
161
+ displayPath: path.relative(projectRoot, absolutePath),
162
+ pendingKey
163
+ });
124
164
  });
165
+ const fixedEntryOptions = yield* Effect.all([
166
+ tryEntry(GENERATED_SPEC_PATH, "specDirty"),
167
+ tryEntry(GENERATED_NODE_SPEC_PATH, "specDirty"),
168
+ tryEntry("schema.ts", "specDirty"),
169
+ tryEntry("http.ts", "httpDirty"),
170
+ tryEntry("crons.ts", "cronsDirty"),
171
+ tryEntry("auth.ts", "authDirty")
172
+ ]);
173
+ const implRelativePaths = yield* discoverLeafImplFiles;
174
+ const implEntryOptions = yield* Effect.forEach(implRelativePaths, (relativePath) => tryEntry(relativePath, "specDirty"));
175
+ return Array.getSomes([...fixedEntryOptions, ...implEntryOptions]);
125
176
  });
126
- const getSpecPath = Effect.gen(function* () {
127
- const path = yield* Path.Path;
128
- const confectDirectory = yield* ConfectDirectory.get;
129
- return path.join(confectDirectory, "spec.ts");
130
- });
131
- const getNodeSpecPath = Effect.gen(function* () {
132
- const path = yield* Path.Path;
133
- const confectDirectory = yield* ConfectDirectory.get;
134
- return path.join(confectDirectory, "nodeSpec.ts");
135
- });
136
- const loadNodeSpec = Effect.gen(function* () {
137
- const fs = yield* FileSystem.FileSystem;
138
- const nodeSpecPath = yield* getNodeSpecPath;
139
- if (!(yield* fs.exists(nodeSpecPath))) return Option.none();
140
- const nodeSpec = (yield* bundleAndImport(nodeSpecPath).pipe(Effect.mapError((error) => new SpecImportFailedError({ error })))).default;
141
- if (!Spec.isNodeSpec(nodeSpec)) return yield* new NodeSpecFileDoesNotExportSpecError();
142
- return Option.some(nodeSpec);
143
- });
144
- const esbuildOptions = (entryPoint) => ({
145
- entryPoints: [entryPoint],
146
- bundle: true,
147
- write: false,
148
- metafile: true,
149
- platform: "node",
150
- format: "esm",
151
- logLevel: "silent",
152
- external: EXTERNAL_PACKAGES,
153
- plugins: [{
154
- name: "notify-rebuild",
155
- setup(build) {
156
- build.onEnd((result) => {
157
- if (result.errors.length === 0) build._emit?.();
158
- else Effect.runPromise(Effect.gen(function* () {
159
- const formattedMessages = yield* Effect.promise(() => esbuild.formatMessages(result.errors, {
160
- kind: "error",
161
- color: true,
162
- terminalWidth: 80
177
+ const esbuildOptions = (entry, signal, pendingRef, watcherErrorsRef) => {
178
+ const initialBuildSeenRef = Ref.unsafeMake(false);
179
+ return {
180
+ entryPoints: [entry.absolutePath],
181
+ bundle: true,
182
+ write: false,
183
+ metafile: true,
184
+ platform: "node",
185
+ format: "esm",
186
+ logLevel: "silent",
187
+ external: EXTERNAL_PACKAGES,
188
+ plugins: [absoluteExternalsPlugin, {
189
+ name: "notify-rebuild",
190
+ setup(build) {
191
+ build.onEnd((result) => {
192
+ Effect.runPromise(Effect.gen(function* () {
193
+ const isInitial = !(yield* Ref.getAndSet(initialBuildSeenRef, true));
194
+ yield* Ref.update(watcherErrorsRef, (current) => {
195
+ const next = new Map(current);
196
+ if (result.errors.length > 0) next.set(entry.absolutePath, result.errors);
197
+ else next.delete(entry.absolutePath);
198
+ return next;
199
+ });
200
+ if (isInitial && result.errors.length === 0) return;
201
+ yield* Ref.update(pendingRef, (p) => ({
202
+ ...p,
203
+ [entry.pendingKey]: true
204
+ }));
205
+ yield* Queue.offer(signal, void 0);
163
206
  }));
164
- const output = formatBuildErrors(result.errors, formattedMessages);
165
- yield* Console.error("\n" + output + "\n");
166
- yield* logFailure("Build errors found");
167
- }));
168
- });
169
- }
170
- }]
171
- });
172
- const createSpecWatcher = (entryPoint) => Stream.asyncPush((emit) => Effect.acquireRelease(Effect.promise(async () => {
173
- const opts = esbuildOptions(entryPoint);
174
- const plugin = opts.plugins[0];
175
- const originalSetup = plugin.setup;
176
- plugin.setup = (build) => {
177
- build._emit = () => emit.single();
178
- return originalSetup(build);
207
+ });
208
+ }
209
+ }]
179
210
  };
180
- const ctx = await esbuild.context({
181
- ...opts,
182
- plugins: [plugin]
183
- });
211
+ };
212
+ const createEntryPointWatcher = (entry, signal, pendingRef, watcherErrorsRef) => Effect.acquireRelease(Effect.promise(async () => {
213
+ const ctx = await esbuild.context(esbuildOptions(entry, signal, pendingRef, watcherErrorsRef));
184
214
  await ctx.watch();
185
215
  return ctx;
186
- }), (ctx) => Effect.promise(() => ctx.dispose()).pipe(Effect.tap(() => Effect.logDebug("esbuild watcher disposed")))), {
187
- bufferSize: 1,
188
- strategy: "sliding"
216
+ }), (ctx) => Effect.gen(function* () {
217
+ yield* Effect.promise(() => ctx.dispose());
218
+ yield* Ref.update(watcherErrorsRef, (current) => {
219
+ if (!current.has(entry.absolutePath)) return current;
220
+ const next = new Map(current);
221
+ next.delete(entry.absolutePath);
222
+ return next;
223
+ });
224
+ yield* Effect.logDebug(`esbuild watcher disposed: ${entry.displayPath}`);
225
+ }));
226
+ /**
227
+ * Holds one scoped esbuild watcher per entry point and reconciles the set
228
+ * whenever something offers to `restartQueue`. Adding or removing an entry
229
+ * point only spawns/disposes the affected watcher; unchanged entries keep
230
+ * their existing context, so a structural change doesn't churn watchers
231
+ * for unrelated files.
232
+ */
233
+ const entryPointsWatcher = (signal, pendingRef, restartQueue, watcherErrorsRef) => Effect.gen(function* () {
234
+ const parentScope = yield* Effect.scope;
235
+ const scopesRef = yield* Ref.make(/* @__PURE__ */ new Map());
236
+ const sync = Effect.gen(function* () {
237
+ const desired = yield* discoverEntryPoints;
238
+ const desiredByPath = new Map(desired.map((entryPoint) => [entryPoint.absolutePath, entryPoint]));
239
+ const current = yield* Ref.get(scopesRef);
240
+ yield* Effect.forEach(Array.fromIterable(current), ([absolutePath, childScope]) => desiredByPath.has(absolutePath) ? Effect.void : Scope.close(childScope, Exit.void).pipe(Effect.andThen(Ref.update(scopesRef, (scopes) => {
241
+ const updated = new Map(scopes);
242
+ updated.delete(absolutePath);
243
+ return updated;
244
+ }))));
245
+ yield* Effect.forEach(desired, (entry) => Effect.gen(function* () {
246
+ if ((yield* Ref.get(scopesRef)).has(entry.absolutePath)) return;
247
+ const childScope = yield* Scope.fork(parentScope, ExecutionStrategy.sequential);
248
+ yield* createEntryPointWatcher(entry, signal, pendingRef, watcherErrorsRef).pipe(Scope.extend(childScope));
249
+ yield* Ref.update(scopesRef, (scopes) => {
250
+ const updated = new Map(scopes);
251
+ updated.set(entry.absolutePath, childScope);
252
+ return updated;
253
+ });
254
+ }));
255
+ });
256
+ yield* sync;
257
+ return yield* Effect.forever(Queue.take(restartQueue).pipe(Effect.andThen(sync)));
189
258
  });
190
- const specFileWatcher = (signal, pendingRef, specWatcherRestartQueue) => Effect.forever(Effect.gen(function* () {
259
+ /**
260
+ * Single recursive `fs.watch` on `confect/`. Flips the matching dirty flag
261
+ * for any change to an entry-point-shaped file (so codegen runs without
262
+ * waiting on a newly spawned esbuild watcher), and offers to
263
+ * `restartQueue` when an entry point is created or removed so the watcher
264
+ * manager picks up the new set.
265
+ */
266
+ const confectStructureWatcher = (signal, pendingRef, restartQueue) => Effect.gen(function* () {
191
267
  const fs = yield* FileSystem.FileSystem;
192
- const specPath = yield* getSpecPath;
193
- const nodeSpecPath = yield* getNodeSpecPath;
194
- const nodeSpecExists = yield* fs.exists(nodeSpecPath);
195
- const specWatcher = createSpecWatcher(specPath);
196
- const nodeSpecWatcher = nodeSpecExists ? createSpecWatcher(nodeSpecPath) : Stream.empty;
197
- const specChanges = pipe(Stream.merge(specWatcher, nodeSpecWatcher), Stream.map(() => "change"));
198
- const restartStream = pipe(Stream.fromQueue(specWatcherRestartQueue), Stream.map(() => "restart"));
199
- yield* pipe(Stream.merge(specChanges, restartStream), Stream.debounce(Duration.millis(200)), Stream.takeUntil((event) => event === "restart"), Stream.runForEach((event) => event === "change" ? Ref.update(pendingRef, (pending) => ({
200
- ...pending,
201
- specDirty: true
202
- })).pipe(Effect.andThen(Queue.offer(signal, void 0))) : Effect.void));
203
- }));
204
- const formatBuildError = (error, formattedMessage) => {
205
- const lines = String.split(formattedMessage, "\n");
206
- const redErrorText = pipe(AnsiDoc.text(error?.text ?? ""), AnsiDoc.annotate(Ansi.red), AnsiDoc.render({ style: "pretty" }));
207
- return pipe(pipe(Array.findFirstIndex(lines, (l) => pipe(l, String.trim, String.isNonEmpty)), Option.match({
208
- onNone: () => lines,
209
- onSome: (index) => Array.modify(lines, index, () => redErrorText)
210
- })), Array.join("\n"));
268
+ const path = yield* Path.Path;
269
+ const confectDirectory = yield* ConfectDirectory.get;
270
+ yield* pipe(fs.watch(confectDirectory, { recursive: true }), Stream.debounce(Duration.millis(200)), Stream.runForEach((event) => handleConfectChange({
271
+ relativePath: path.relative(confectDirectory, event.path),
272
+ eventTag: event._tag,
273
+ signal,
274
+ pendingRef,
275
+ restartQueue
276
+ })));
277
+ });
278
+ const TOP_LEVEL_OPTIONAL_KEYS = new Map([
279
+ ["http.ts", "httpDirty"],
280
+ ["crons.ts", "cronsDirty"],
281
+ ["auth.ts", "authDirty"]
282
+ ]);
283
+ const flipDirtyAndSignal = (pendingRef, signal, key, restartQueue, restart) => pipe(Ref.update(pendingRef, (p) => ({
284
+ ...p,
285
+ [key]: true
286
+ })), Effect.andThen(Queue.offer(signal, void 0)), Effect.andThen(restart ? Queue.offer(restartQueue, void 0) : Effect.void));
287
+ const handleConfectChange = ({ relativePath, eventTag, signal, pendingRef, restartQueue }) => {
288
+ if (relativePath.split(/[/\\]/).includes("_generated")) return Effect.void;
289
+ if (!relativePath.endsWith(".ts")) return Effect.void;
290
+ const isLifecycleChange = eventTag !== "Update";
291
+ const topLevelKey = TOP_LEVEL_OPTIONAL_KEYS.get(relativePath);
292
+ if (topLevelKey !== void 0) return flipDirtyAndSignal(pendingRef, signal, topLevelKey, restartQueue, isLifecycleChange);
293
+ if (relativePath === "schema.ts") return flipDirtyAndSignal(pendingRef, signal, "specDirty", restartQueue, isLifecycleChange);
294
+ if (isLeafSpecPath(relativePath) || isLeafImplPath(relativePath)) return flipDirtyAndSignal(pendingRef, signal, "specDirty", restartQueue, isLifecycleChange);
295
+ if (eventTag === "Create") return flipDirtyAndSignal(pendingRef, signal, "specDirty", restartQueue, false);
296
+ return Effect.void;
211
297
  };
212
- const formatBuildErrors = (errors, formattedMessages) => pipe(formattedMessages, Array.map((message, i) => formatBuildError(errors[i], message)), Array.join(""), String.trimEnd);
213
- var SpecFileDoesNotExportSpecError = class extends Schema.TaggedError()("SpecFileDoesNotExportSpecError", {}) {};
214
- var NodeSpecFileDoesNotExportSpecError = class extends Schema.TaggedError()("NodeSpecFileDoesNotExportSpecError", {}) {};
215
- var SpecImportFailedError = class extends Schema.TaggedError()("SpecImportFailedError", { error: Schema.Unknown }) {};
216
298
  const syncOptionalFile = (generate, convexFile) => pipe(generate, Effect.andThen(Option.match({
217
299
  onSome: ({ change, convexFilePath }) => Match.value(change).pipe(Match.when("Unchanged", () => Effect.void), Match.whenOr("Added", "Modified", (addedOrModified) => logFileChangeIndented(addedOrModified, convexFilePath)), Match.exhaustive),
218
300
  onNone: () => Effect.gen(function* () {
@@ -226,34 +308,6 @@ const syncOptionalFile = (generate, convexFile) => pipe(generate, Effect.andThen
226
308
  }
227
309
  })
228
310
  })));
229
- const optionalConfectFiles = {
230
- "http.ts": "httpDirty",
231
- "crons.ts": "cronsDirty",
232
- "auth.ts": "authDirty",
233
- "nodeSpec.ts": "specDirty",
234
- "nodeImpl.ts": "nodeImplDirty"
235
- };
236
- const confectDirectoryWatcher = (signal, pendingRef, specWatcherRestartQueue) => Effect.gen(function* () {
237
- const fs = yield* FileSystem.FileSystem;
238
- const path = yield* Path.Path;
239
- const confectDirectory = yield* ConfectDirectory.get;
240
- yield* pipe(fs.watch(confectDirectory), Stream.runForEach((event) => {
241
- const basename = path.basename(event.path);
242
- const pendingKey = optionalConfectFiles[basename];
243
- if (pendingKey !== void 0) return pipe(pendingRef, Ref.update((pending) => {
244
- const next = {
245
- ...pending,
246
- [pendingKey]: true
247
- };
248
- if (basename === "nodeImpl.ts") return {
249
- ...next,
250
- specDirty: true
251
- };
252
- return next;
253
- }), Effect.andThen(Queue.offer(signal, void 0)), Effect.andThen(basename === "nodeSpec.ts" ? Queue.offer(specWatcherRestartQueue, void 0) : Effect.void));
254
- return Effect.void;
255
- }));
256
- });
257
311
 
258
312
  //#endregion
259
313
  export { dev };