@confect/cli 1.0.0-next.3 → 1.0.0-next.4

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.
@@ -5,7 +5,7 @@ import { Ansi, AnsiDoc } from "@effect/printer-ansi";
5
5
  import {
6
6
  Array,
7
7
  Console,
8
- Data,
8
+ Deferred,
9
9
  Duration,
10
10
  Effect,
11
11
  Equal,
@@ -25,7 +25,7 @@ import * as tsx from "tsx/esm/api";
25
25
  import type * as FunctionPath from "../FunctionPath";
26
26
  import * as FunctionPaths from "../FunctionPaths";
27
27
  import * as GroupPath from "../GroupPath";
28
- import { logCompleted, logFailed } from "../log";
28
+ import { logFailure, logPending, logSuccess } from "../log";
29
29
  import { ConfectDirectory } from "../services/ConfectDirectory";
30
30
  import { ConvexDirectory } from "../services/ConvexDirectory";
31
31
  import { ProjectRoot } from "../services/ProjectRoot";
@@ -37,10 +37,15 @@ import {
37
37
  removeGroups,
38
38
  writeGroups,
39
39
  } from "../utils";
40
- import { codegenHandler } from "./codegen";
40
+ import {
41
+ codegenHandler,
42
+ generateNodeApi,
43
+ generateNodeRegisteredFunctions,
44
+ } from "./codegen";
41
45
 
42
46
  type Pending = {
43
47
  readonly specDirty: boolean;
48
+ readonly nodeImplDirty: boolean;
44
49
  readonly httpDirty: boolean;
45
50
  readonly appDirty: boolean;
46
51
  readonly cronsDirty: boolean;
@@ -49,56 +54,13 @@ type Pending = {
49
54
 
50
55
  const pendingInit: Pending = {
51
56
  specDirty: false,
57
+ nodeImplDirty: false,
52
58
  httpDirty: false,
53
59
  appDirty: false,
54
60
  cronsDirty: false,
55
61
  authDirty: false,
56
62
  };
57
63
 
58
- type FileChange = Data.TaggedEnum<{
59
- OptionalFile: {
60
- readonly change: "Added" | "Removed" | "Modified";
61
- readonly filePath: string;
62
- };
63
- GroupModule: {
64
- readonly change: "Added" | "Removed" | "Modified";
65
- readonly filePath: string;
66
- readonly functionsAdded: ReadonlyArray<FunctionPath.FunctionPath>;
67
- readonly functionsRemoved: ReadonlyArray<FunctionPath.FunctionPath>;
68
- };
69
- }>;
70
-
71
- const FileChange = Data.taggedEnum<FileChange>();
72
-
73
- const logChangeReport = (changes: ReadonlyArray<FileChange>) =>
74
- Effect.gen(function* () {
75
- yield* logCompleted("Generated files are up-to-date");
76
-
77
- yield* Effect.when(
78
- Effect.forEach(changes, (change) =>
79
- FileChange.$match(change, {
80
- OptionalFile: ({ change: c, filePath }) =>
81
- logFileChangeIndented(c, filePath),
82
- GroupModule: ({
83
- change: c,
84
- filePath,
85
- functionsAdded,
86
- functionsRemoved,
87
- }) =>
88
- Effect.gen(function* () {
89
- yield* logFileChangeIndented(c, filePath);
90
- yield* Effect.forEach(functionsAdded, logFunctionAddedIndented);
91
- yield* Effect.forEach(
92
- functionsRemoved,
93
- logFunctionRemovedIndented,
94
- );
95
- }),
96
- }),
97
- ),
98
- () => Array.isNonEmptyReadonlyArray(changes),
99
- );
100
- });
101
-
102
64
  const changeChar = (change: "Added" | "Removed" | "Modified") =>
103
65
  Match.value(change).pipe(
104
66
  Match.when("Added", () => ({ char: "+", color: Ansi.green })),
@@ -124,8 +86,8 @@ const logFileChangeIndented = (
124
86
 
125
87
  yield* Console.log(
126
88
  pipe(
127
- AnsiDoc.text(" "),
128
- AnsiDoc.cat(pipe(AnsiDoc.char(char), AnsiDoc.annotate(color))),
89
+ AnsiDoc.char(char),
90
+ AnsiDoc.annotate(color),
129
91
  AnsiDoc.catWithSpace(
130
92
  AnsiDoc.hcat([
131
93
  pipe(AnsiDoc.text(prefix), AnsiDoc.annotate(Ansi.blackBright)),
@@ -140,7 +102,7 @@ const logFileChangeIndented = (
140
102
  const logFunctionAddedIndented = (functionPath: FunctionPath.FunctionPath) =>
141
103
  Console.log(
142
104
  pipe(
143
- AnsiDoc.text(" "),
105
+ AnsiDoc.text(" "),
144
106
  AnsiDoc.cat(pipe(AnsiDoc.char("+"), AnsiDoc.annotate(Ansi.green))),
145
107
  AnsiDoc.catWithSpace(
146
108
  AnsiDoc.hcat([
@@ -158,7 +120,7 @@ const logFunctionAddedIndented = (functionPath: FunctionPath.FunctionPath) =>
158
120
  const logFunctionRemovedIndented = (functionPath: FunctionPath.FunctionPath) =>
159
121
  Console.log(
160
122
  pipe(
161
- AnsiDoc.text(" "),
123
+ AnsiDoc.text(" "),
162
124
  AnsiDoc.cat(pipe(AnsiDoc.char("-"), AnsiDoc.annotate(Ansi.red))),
163
125
  AnsiDoc.catWithSpace(
164
126
  AnsiDoc.hcat([
@@ -175,15 +137,17 @@ const logFunctionRemovedIndented = (functionPath: FunctionPath.FunctionPath) =>
175
137
 
176
138
  export const dev = Command.make("dev", {}, () =>
177
139
  Effect.gen(function* () {
140
+ yield* logPending("Performing initial sync…");
178
141
  const initialFunctionPaths = yield* codegenHandler;
179
142
 
180
143
  const pendingRef = yield* Ref.make<Pending>(pendingInit);
181
144
  const signal = yield* Queue.sliding<void>(1);
145
+ const specWatcherRestartQueue = yield* Queue.sliding<void>(1);
182
146
 
183
147
  yield* Effect.all(
184
148
  [
185
- specFileWatcher(signal, pendingRef),
186
- confectDirectoryWatcher(signal, pendingRef),
149
+ specFileWatcher(signal, pendingRef, specWatcherRestartQueue),
150
+ confectDirectoryWatcher(signal, pendingRef, specWatcherRestartQueue),
187
151
  syncLoop(signal, pendingRef, initialFunctionPaths),
188
152
  ],
189
153
  { concurrency: "unbounded" },
@@ -198,17 +162,30 @@ const syncLoop = (
198
162
  ) =>
199
163
  Effect.gen(function* () {
200
164
  const functionPathsRef = yield* Ref.make(initialFunctionPaths);
201
- const changesRef = yield* Ref.make<ReadonlyArray<FileChange>>([]);
165
+ const initialSyncDone = yield* Deferred.make<void>();
202
166
 
203
167
  return yield* Effect.forever(
204
168
  Effect.gen(function* () {
205
169
  yield* Effect.logDebug("Running sync loop...");
206
170
  yield* Queue.take(signal);
207
171
 
172
+ const isDone = yield* Deferred.isDone(initialSyncDone);
173
+ yield* Effect.when(
174
+ logPending("Dependencies changed, reloading…"),
175
+ () => isDone,
176
+ );
177
+ yield* Deferred.succeed(initialSyncDone, undefined);
178
+
208
179
  const pending = yield* Ref.getAndSet(pendingRef, pendingInit);
209
180
 
210
- const specResult: Option.Option<ReadonlyArray<FileChange>> =
211
- yield* Effect.if(pending.specDirty, {
181
+ if (pending.specDirty || pending.nodeImplDirty) {
182
+ yield* generateNodeApi;
183
+ yield* generateNodeRegisteredFunctions;
184
+ }
185
+
186
+ const specResult: Option.Option<void> = yield* Effect.if(
187
+ pending.specDirty,
188
+ {
212
189
  onTrue: () =>
213
190
  loadSpec.pipe(
214
191
  Effect.andThen(
@@ -231,104 +208,102 @@ const syncLoop = (
231
208
 
232
209
  // Removed groups
233
210
  yield* removeGroups(groupsRemoved);
234
- const removedChanges = yield* Effect.forEach(
235
- groupsRemoved,
236
- (gp) =>
237
- Effect.gen(function* () {
238
- const relativeModulePath =
239
- yield* GroupPath.modulePath(gp);
240
- return FileChange.GroupModule({
241
- change: "Removed",
242
- filePath: path.join(
243
- convexDirectory,
244
- relativeModulePath,
245
- ),
246
- functionsAdded: [],
247
- functionsRemoved: Array.fromIterable(
248
- HashSet.filter(functionsRemoved, (fp) =>
249
- Equal.equals(fp.groupPath, gp),
250
- ),
211
+ yield* Effect.forEach(groupsRemoved, (gp) =>
212
+ Effect.gen(function* () {
213
+ const relativeModulePath =
214
+ yield* GroupPath.modulePath(gp);
215
+ const filePath = path.join(
216
+ convexDirectory,
217
+ relativeModulePath,
218
+ );
219
+ yield* logFileChangeIndented("Removed", filePath);
220
+ yield* Effect.forEach(
221
+ Array.fromIterable(
222
+ HashSet.filter(functionsRemoved, (fp) =>
223
+ Equal.equals(fp.groupPath, gp),
251
224
  ),
252
- });
253
- }),
225
+ ),
226
+ logFunctionRemovedIndented,
227
+ );
228
+ }),
254
229
  );
255
230
 
256
231
  // Added groups
257
232
  yield* writeGroups(spec, groupsAdded);
258
- const addedChanges = yield* Effect.forEach(
259
- groupsAdded,
260
- (gp) =>
261
- Effect.gen(function* () {
262
- const relativeModulePath =
263
- yield* GroupPath.modulePath(gp);
264
- return FileChange.GroupModule({
265
- change: "Added",
266
- filePath: path.join(
267
- convexDirectory,
268
- relativeModulePath,
269
- ),
270
- functionsAdded: Array.fromIterable(
271
- HashSet.filter(functionsAdded, (fp) =>
272
- Equal.equals(fp.groupPath, gp),
273
- ),
233
+ yield* Effect.forEach(groupsAdded, (gp) =>
234
+ Effect.gen(function* () {
235
+ const relativeModulePath =
236
+ yield* GroupPath.modulePath(gp);
237
+ const filePath = path.join(
238
+ convexDirectory,
239
+ relativeModulePath,
240
+ );
241
+ yield* logFileChangeIndented("Added", filePath);
242
+ yield* Effect.forEach(
243
+ Array.fromIterable(
244
+ HashSet.filter(functionsAdded, (fp) =>
245
+ Equal.equals(fp.groupPath, gp),
274
246
  ),
275
- functionsRemoved: [],
276
- });
277
- }),
247
+ ),
248
+ logFunctionAddedIndented,
249
+ );
250
+ }),
278
251
  );
279
252
 
280
253
  // Changed groups
281
254
  yield* writeGroups(spec, groupsChanged);
282
- const changedChanges = yield* Effect.forEach(
283
- groupsChanged,
284
- (gp) =>
285
- Effect.gen(function* () {
286
- const relativeModulePath =
287
- yield* GroupPath.modulePath(gp);
288
- return FileChange.GroupModule({
289
- change: "Modified",
290
- filePath: path.join(
291
- convexDirectory,
292
- relativeModulePath,
293
- ),
294
- functionsAdded: Array.fromIterable(
295
- HashSet.filter(functionsAdded, (fp) =>
296
- Equal.equals(fp.groupPath, gp),
297
- ),
255
+ yield* Effect.forEach(groupsChanged, (gp) =>
256
+ Effect.gen(function* () {
257
+ const relativeModulePath =
258
+ yield* GroupPath.modulePath(gp);
259
+ const filePath = path.join(
260
+ convexDirectory,
261
+ relativeModulePath,
262
+ );
263
+ yield* logFileChangeIndented("Modified", filePath);
264
+ yield* Effect.forEach(
265
+ Array.fromIterable(
266
+ HashSet.filter(functionsAdded, (fp) =>
267
+ Equal.equals(fp.groupPath, gp),
298
268
  ),
299
- functionsRemoved: Array.fromIterable(
300
- HashSet.filter(functionsRemoved, (fp) =>
301
- Equal.equals(fp.groupPath, gp),
302
- ),
269
+ ),
270
+ logFunctionAddedIndented,
271
+ );
272
+ yield* Effect.forEach(
273
+ Array.fromIterable(
274
+ HashSet.filter(functionsRemoved, (fp) =>
275
+ Equal.equals(fp.groupPath, gp),
303
276
  ),
304
- });
305
- }),
277
+ ),
278
+ logFunctionRemovedIndented,
279
+ );
280
+ }),
306
281
  );
307
282
 
308
283
  yield* Ref.set(functionPathsRef, current);
309
284
 
310
- return Option.some([
311
- ...removedChanges,
312
- ...addedChanges,
313
- ...changedChanges,
314
- ]);
285
+ return Option.some(undefined);
315
286
  }),
316
287
  ),
317
288
  Effect.catchTag("SpecImportFailedError", () =>
318
- logFailed("Spec import failed").pipe(
289
+ logFailure("Spec import failed").pipe(
319
290
  Effect.as(Option.none()),
320
291
  ),
321
292
  ),
322
293
  Effect.catchTag("SpecFileDoesNotExportSpecError", () =>
323
- logFailed("Spec file does not default export a spec").pipe(
324
- Effect.as(Option.none()),
325
- ),
294
+ logFailure(
295
+ "Spec file does not default export a Convex spec",
296
+ ).pipe(Effect.as(Option.none())),
297
+ ),
298
+ Effect.catchTag("NodeSpecFileDoesNotExportSpecError", () =>
299
+ logFailure(
300
+ "Node spec file does not default export a Node spec",
301
+ ).pipe(Effect.as(Option.none())),
326
302
  ),
327
303
  ),
328
- onFalse: () => Effect.succeed(Option.some([])),
329
- });
330
-
331
- const specChanges = Option.getOrElse(specResult, () => []);
304
+ onFalse: () => Effect.succeed(Option.some(undefined)),
305
+ },
306
+ );
332
307
 
333
308
  const dirtyOptionalFiles = [
334
309
  ...(pending.httpDirty
@@ -345,42 +320,22 @@ const syncLoop = (
345
320
  : []),
346
321
  ];
347
322
 
348
- const optionalChanges: ReadonlyArray<FileChange> =
349
- Array.isNonEmptyReadonlyArray(dirtyOptionalFiles)
350
- ? yield* pipe(
351
- Effect.all(dirtyOptionalFiles, {
352
- concurrency: "unbounded",
353
- }),
354
- Effect.map(Array.getSomes),
355
- )
356
- : [];
357
-
358
- yield* Ref.update(changesRef, (prev) => [
359
- ...prev,
360
- ...specChanges,
361
- ...optionalChanges,
362
- ]);
323
+ yield* Array.isNonEmptyReadonlyArray(dirtyOptionalFiles)
324
+ ? Effect.all(dirtyOptionalFiles, { concurrency: "unbounded" })
325
+ : Effect.void;
363
326
 
364
327
  yield* Option.match(specResult, {
365
- onSome: () =>
366
- Effect.gen(function* () {
367
- const pendingSize = yield* Queue.size(signal);
368
- yield* Effect.when(
369
- Effect.gen(function* () {
370
- const allChanges = yield* Ref.getAndSet(changesRef, []);
371
- yield* logChangeReport(allChanges);
372
- }),
373
- () => pendingSize === 0,
374
- );
375
- }),
376
- onNone: () => Ref.set(changesRef, []),
328
+ onSome: () => logSuccess("Generated files are up-to-date"),
329
+ onNone: () => Effect.void,
377
330
  });
378
331
  }),
379
332
  );
380
333
  });
381
334
 
382
335
  const loadSpec = Effect.gen(function* () {
336
+ const fs = yield* FileSystem.FileSystem;
383
337
  const path = yield* Path.Path;
338
+ const confectDirectory = yield* ConfectDirectory.get;
384
339
  const specPathUrl = yield* path.toFileUrl(yield* getSpecPath);
385
340
  const specModule = yield* Effect.tryPromise({
386
341
  try: () => tsx.tsImport(specPathUrl.href, import.meta.url),
@@ -388,11 +343,19 @@ const loadSpec = Effect.gen(function* () {
388
343
  });
389
344
  const spec = specModule.default;
390
345
 
391
- if (Spec.isSpec(spec)) {
392
- return spec;
393
- } else {
394
- return yield* Effect.fail(new SpecFileDoesNotExportSpecError());
346
+ if (!Spec.isConvexSpec(spec)) {
347
+ return yield* new SpecFileDoesNotExportSpecError();
395
348
  }
349
+
350
+ const nodeImplPath = path.join(confectDirectory, "nodeImpl.ts");
351
+ const nodeImplExists = yield* fs.exists(nodeImplPath);
352
+ const nodeSpecOption = yield* loadNodeSpec;
353
+ const mergedSpec = Option.match(nodeSpecOption, {
354
+ onNone: () => spec,
355
+ onSome: (nodeSpec) => (nodeImplExists ? Spec.merge(spec, nodeSpec) : spec),
356
+ });
357
+
358
+ return mergedSpec;
396
359
  });
397
360
 
398
361
  const getSpecPath = Effect.gen(function* () {
@@ -402,87 +365,152 @@ const getSpecPath = Effect.gen(function* () {
402
365
  return path.join(confectDirectory, "spec.ts");
403
366
  });
404
367
 
368
+ const getNodeSpecPath = Effect.gen(function* () {
369
+ const path = yield* Path.Path;
370
+ const confectDirectory = yield* ConfectDirectory.get;
371
+
372
+ return path.join(confectDirectory, "nodeSpec.ts");
373
+ });
374
+
375
+ const loadNodeSpec = Effect.gen(function* () {
376
+ const fs = yield* FileSystem.FileSystem;
377
+ const path = yield* Path.Path;
378
+ const nodeSpecPath = yield* getNodeSpecPath;
379
+
380
+ if (!(yield* fs.exists(nodeSpecPath))) {
381
+ return Option.none();
382
+ }
383
+
384
+ const nodeSpecPathUrl = yield* path.toFileUrl(nodeSpecPath);
385
+ const nodeSpecModule = yield* Effect.tryPromise({
386
+ try: () => tsx.tsImport(nodeSpecPathUrl.href, import.meta.url),
387
+ catch: (error) => new SpecImportFailedError({ error }),
388
+ });
389
+ const nodeSpec = nodeSpecModule.default;
390
+
391
+ if (!Spec.isNodeSpec(nodeSpec)) {
392
+ return yield* new NodeSpecFileDoesNotExportSpecError();
393
+ }
394
+
395
+ return Option.some(nodeSpec);
396
+ });
397
+
398
+ const esbuildOptions = (entryPoint: string) => ({
399
+ entryPoints: [entryPoint],
400
+ bundle: true,
401
+ write: false,
402
+ metafile: true,
403
+ platform: "node" as const,
404
+ format: "esm" as const,
405
+ logLevel: "silent" as const,
406
+ external: ["@confect/core", "@confect/server", "effect", "@effect/*"],
407
+ plugins: [
408
+ {
409
+ name: "notify-rebuild",
410
+ setup(build: esbuild.PluginBuild) {
411
+ build.onEnd((result) => {
412
+ if (result.errors.length === 0) {
413
+ (build as { _emit?: (v: void) => void })._emit?.();
414
+ } else {
415
+ Effect.runPromise(
416
+ Effect.gen(function* () {
417
+ const formattedMessages = yield* Effect.promise(() =>
418
+ esbuild.formatMessages(result.errors, {
419
+ kind: "error",
420
+ color: true,
421
+ terminalWidth: 80,
422
+ }),
423
+ );
424
+ const output = formatBuildErrors(
425
+ result.errors,
426
+ formattedMessages,
427
+ );
428
+ yield* Console.error("\n" + output + "\n");
429
+ yield* logFailure("Build errors found");
430
+ }),
431
+ );
432
+ }
433
+ });
434
+ },
435
+ },
436
+ ],
437
+ });
438
+
439
+ const createSpecWatcher = (entryPoint: string) =>
440
+ Stream.asyncPush<void>(
441
+ (emit) =>
442
+ Effect.acquireRelease(
443
+ Effect.promise(async () => {
444
+ const opts = esbuildOptions(entryPoint);
445
+ const plugin = opts.plugins[0];
446
+ const originalSetup = plugin!.setup!;
447
+ (plugin as { setup: (build: esbuild.PluginBuild) => void }).setup = (
448
+ build,
449
+ ) => {
450
+ (build as { _emit?: (v: void) => void })._emit = () =>
451
+ emit.single();
452
+ return originalSetup(build);
453
+ };
454
+
455
+ const ctx = await esbuild.context({
456
+ ...opts,
457
+ plugins: [plugin],
458
+ });
459
+
460
+ await ctx.watch();
461
+ return ctx;
462
+ }),
463
+ (ctx) =>
464
+ Effect.promise(() => ctx.dispose()).pipe(
465
+ Effect.tap(() => Effect.logDebug("esbuild watcher disposed")),
466
+ ),
467
+ ),
468
+ { bufferSize: 1, strategy: "sliding" },
469
+ );
470
+
471
+ type SpecWatcherEvent = "change" | "restart";
472
+
405
473
  const specFileWatcher = (
406
474
  signal: Queue.Queue<void>,
407
475
  pendingRef: Ref.Ref<Pending>,
476
+ specWatcherRestartQueue: Queue.Queue<void>,
408
477
  ) =>
409
- Effect.gen(function* () {
410
- const specPath = yield* getSpecPath;
411
-
412
- const specChanges: Stream.Stream<void> = Stream.asyncPush(
413
- (emit) =>
414
- Effect.acquireRelease(
415
- Effect.promise(async () => {
416
- const ctx = await esbuild.context({
417
- entryPoints: [specPath],
418
- bundle: true,
419
- write: false,
420
- metafile: true,
421
- platform: "node",
422
- format: "esm",
423
- logLevel: "silent",
424
- external: [
425
- "@confect/core",
426
- "@confect/server",
427
- "effect",
428
- "@effect/*",
429
- ],
430
- plugins: [
431
- {
432
- name: "notify-rebuild",
433
- setup(build) {
434
- build.onEnd((result) => {
435
- if (result.errors.length === 0) {
436
- emit.single();
437
- } else {
438
- Effect.runPromise(
439
- Effect.gen(function* () {
440
- yield* logFailed("Build errors");
441
- const formattedMessages = yield* Effect.promise(
442
- () =>
443
- esbuild.formatMessages(result.errors, {
444
- kind: "error",
445
- color: true,
446
- terminalWidth: 80,
447
- }),
448
- );
449
- const output = formatBuildErrors(
450
- result.errors,
451
- formattedMessages,
452
- );
453
- yield* Console.error("\n" + output + "\n");
454
- }),
455
- );
456
- }
457
- });
458
- },
459
- },
460
- ],
461
- });
462
-
463
- await ctx.watch();
464
-
465
- return ctx;
466
- }),
467
- (ctx) =>
468
- Effect.promise(() => ctx.dispose()).pipe(
469
- Effect.tap(() => Effect.logDebug("esbuild watcher disposed")),
470
- ),
478
+ Effect.forever(
479
+ Effect.gen(function* () {
480
+ const fs = yield* FileSystem.FileSystem;
481
+ const specPath = yield* getSpecPath;
482
+ const nodeSpecPath = yield* getNodeSpecPath;
483
+ const nodeSpecExists = yield* fs.exists(nodeSpecPath);
484
+
485
+ const specWatcher = createSpecWatcher(specPath);
486
+ const nodeSpecWatcher = nodeSpecExists
487
+ ? createSpecWatcher(nodeSpecPath)
488
+ : Stream.empty;
489
+
490
+ const specChanges = pipe(
491
+ Stream.merge(specWatcher, nodeSpecWatcher),
492
+ Stream.map((): SpecWatcherEvent => "change"),
493
+ );
494
+ const restartStream = pipe(
495
+ Stream.fromQueue(specWatcherRestartQueue),
496
+ Stream.map((): SpecWatcherEvent => "restart"),
497
+ );
498
+
499
+ yield* pipe(
500
+ Stream.merge(specChanges, restartStream),
501
+ Stream.debounce(Duration.millis(200)),
502
+ Stream.takeUntil((event): event is "restart" => event === "restart"),
503
+ Stream.runForEach((event) =>
504
+ event === "change"
505
+ ? Ref.update(pendingRef, (pending) => ({
506
+ ...pending,
507
+ specDirty: true,
508
+ })).pipe(Effect.andThen(Queue.offer(signal, undefined)))
509
+ : Effect.void,
471
510
  ),
472
- { bufferSize: 1, strategy: "sliding" },
473
- );
474
-
475
- yield* pipe(
476
- specChanges,
477
- Stream.debounce(Duration.millis(200)),
478
- Stream.runForEach(() =>
479
- Ref.update(pendingRef, (pending) => ({
480
- ...pending,
481
- specDirty: true,
482
- })).pipe(Effect.andThen(Queue.offer(signal, undefined))),
483
- ),
484
- );
485
- });
511
+ );
512
+ }),
513
+ );
486
514
 
487
515
  const formatBuildError = (
488
516
  error: esbuild.Message | undefined,
@@ -498,14 +526,10 @@ const formatBuildError = (
498
526
  Array.findFirstIndex(lines, (l) => pipe(l, String.trim, String.isNonEmpty)),
499
527
  Option.match({
500
528
  onNone: () => lines,
501
- onSome: (idx) => Array.modify(lines, idx, () => redErrorText),
529
+ onSome: (index) => Array.modify(lines, index, () => redErrorText),
502
530
  }),
503
531
  );
504
- return pipe(
505
- replaced,
506
- Array.map((l) => (pipe(l, String.trim, String.isNonEmpty) ? ` ${l}` : l)),
507
- Array.join("\n"),
508
- );
532
+ return pipe(replaced, Array.join("\n"));
509
533
  };
510
534
 
511
535
  const formatBuildErrors = (
@@ -519,15 +543,22 @@ const formatBuildErrors = (
519
543
  String.trimEnd,
520
544
  );
521
545
 
522
- export class SpecFileDoesNotExportSpecError extends Schema.TaggedError<SpecFileDoesNotExportSpecError>(
546
+ export class SpecFileDoesNotExportSpecError extends Schema.TaggedError<SpecFileDoesNotExportSpecError>()(
523
547
  "SpecFileDoesNotExportSpecError",
524
- )("SpecFileDoesNotExportSpecError", {}) {}
548
+ {},
549
+ ) {}
525
550
 
526
- export class SpecImportFailedError extends Schema.TaggedError<SpecImportFailedError>(
551
+ export class NodeSpecFileDoesNotExportSpecError extends Schema.TaggedError<NodeSpecFileDoesNotExportSpecError>()(
552
+ "NodeSpecFileDoesNotExportSpecError",
553
+ {},
554
+ ) {}
555
+
556
+ export class SpecImportFailedError extends Schema.TaggedError<SpecImportFailedError>()(
527
557
  "SpecImportFailedError",
528
- )("SpecImportFailedError", {
529
- error: Schema.Unknown,
530
- }) {}
558
+ {
559
+ error: Schema.Unknown,
560
+ },
561
+ ) {}
531
562
 
532
563
  const syncOptionalFile = (generate: typeof generateHttp, convexFile: string) =>
533
564
  pipe(
@@ -536,16 +567,9 @@ const syncOptionalFile = (generate: typeof generateHttp, convexFile: string) =>
536
567
  Option.match({
537
568
  onSome: ({ change, convexFilePath }) =>
538
569
  Match.value(change).pipe(
539
- Match.when("Unchanged", () => Effect.succeed(Option.none())),
570
+ Match.when("Unchanged", () => Effect.void),
540
571
  Match.whenOr("Added", "Modified", (addedOrModified) =>
541
- Effect.succeed(
542
- Option.some(
543
- FileChange.OptionalFile({
544
- change: addedOrModified,
545
- filePath: convexFilePath,
546
- }),
547
- ),
548
- ),
572
+ logFileChangeIndented(addedOrModified, convexFilePath),
549
573
  ),
550
574
  Match.exhaustive,
551
575
  ),
@@ -558,15 +582,7 @@ const syncOptionalFile = (generate: typeof generateHttp, convexFile: string) =>
558
582
 
559
583
  if (yield* fs.exists(convexFilePath)) {
560
584
  yield* fs.remove(convexFilePath);
561
-
562
- return Option.some(
563
- FileChange.OptionalFile({
564
- change: "Removed",
565
- filePath: convexFilePath,
566
- }),
567
- );
568
- } else {
569
- return Option.none();
585
+ yield* logFileChangeIndented("Removed", convexFilePath);
570
586
  }
571
587
  }),
572
588
  }),
@@ -578,34 +594,45 @@ const optionalConfectFiles: ReadonlyRecord<string, keyof Pending> = {
578
594
  "app.ts": "appDirty",
579
595
  "crons.ts": "cronsDirty",
580
596
  "auth.ts": "authDirty",
597
+ "nodeSpec.ts": "specDirty",
598
+ "nodeImpl.ts": "nodeImplDirty",
581
599
  };
582
600
 
583
601
  const confectDirectoryWatcher = (
584
602
  signal: Queue.Queue<void>,
585
603
  pendingRef: Ref.Ref<Pending>,
604
+ specWatcherRestartQueue: Queue.Queue<void>,
586
605
  ) =>
587
606
  Effect.gen(function* () {
588
607
  const fs = yield* FileSystem.FileSystem;
608
+ const path = yield* Path.Path;
589
609
  const confectDirectory = yield* ConfectDirectory.get;
590
610
 
591
611
  yield* pipe(
592
612
  fs.watch(confectDirectory),
593
- Stream.runForEach((event) =>
594
- pipe(
595
- Option.fromNullable(optionalConfectFiles[event.path]),
596
- Option.match({
597
- onNone: () => Effect.void,
598
- onSome: (pendingKey) =>
599
- pipe(
600
- pendingRef,
601
- Ref.update((pending) => ({
602
- ...pending,
603
- [pendingKey]: true,
604
- })),
605
- Effect.andThen(Queue.offer(signal, undefined)),
606
- ),
607
- }),
608
- ),
609
- ),
613
+ Stream.runForEach((event) => {
614
+ const basename = path.basename(event.path);
615
+ const pendingKey = optionalConfectFiles[basename];
616
+
617
+ if (pendingKey !== undefined) {
618
+ return pipe(
619
+ pendingRef,
620
+ Ref.update((pending) => {
621
+ const next = { ...pending, [pendingKey]: true };
622
+ if (basename === "nodeImpl.ts") {
623
+ return { ...next, specDirty: true };
624
+ }
625
+ return next;
626
+ }),
627
+ Effect.andThen(Queue.offer(signal, undefined)),
628
+ Effect.andThen(
629
+ basename === "nodeSpec.ts"
630
+ ? Queue.offer(specWatcherRestartQueue, undefined)
631
+ : Effect.void,
632
+ ),
633
+ );
634
+ }
635
+ return Effect.void;
636
+ }),
610
637
  );
611
638
  });