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