effect-start 0.11.1 → 0.13.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.
@@ -1,12 +1,41 @@
1
+ import * as Error from "@effect/platform/Error"
1
2
  import * as FileSystem from "@effect/platform/FileSystem"
2
3
  import * as t from "bun:test"
3
- import { MemoryFileSystem } from "effect-memfs"
4
4
  import * as Effect from "effect/Effect"
5
+ import * as Schema from "effect/Schema"
6
+ import * as Scope from "effect/Scope"
7
+ import * as path from "node:path"
8
+ import * as FileRouter from "./FileRouter.ts"
5
9
  import { parseRoute } from "./FileRouter.ts"
6
10
  import type { RouteHandle } from "./FileRouter.ts"
7
11
  import * as FileRouterCodegen from "./FileRouterCodegen.ts"
12
+ import * as NodeFileSystem from "./NodeFileSystem.ts"
8
13
  import * as Route from "./Route.ts"
9
- import { effectFn } from "./testing.ts"
14
+ import * as SchemaExtra from "./SchemaExtra.ts"
15
+ import * as TestLogger from "./TestLogger.ts"
16
+
17
+ function createTempDirWithFiles(
18
+ files: Record<string, string>,
19
+ ): Effect.Effect<
20
+ string,
21
+ Error.PlatformError,
22
+ FileSystem.FileSystem | Scope.Scope
23
+ > {
24
+ return Effect.gen(function*() {
25
+ const fs = yield* FileSystem.FileSystem
26
+ const tempDir = yield* fs.makeTempDirectoryScoped()
27
+
28
+ for (const [filePath, content] of Object.entries(files)) {
29
+ const fullPath = path.join(tempDir, filePath)
30
+ const dir = path.dirname(fullPath)
31
+
32
+ yield* fs.makeDirectory(dir, { recursive: true })
33
+ yield* fs.writeFileString(fullPath, content)
34
+ }
35
+
36
+ return tempDir
37
+ })
38
+ }
10
39
 
11
40
  t.it("generates code for routes only", () => {
12
41
  const handles: RouteHandle[] = [
@@ -34,9 +63,7 @@ export const routes = [
34
63
  ] as const
35
64
  `
36
65
 
37
- t
38
- .expect(code)
39
- .toBe(expected)
66
+ t.expect(code).toBe(expected)
40
67
  })
41
68
 
42
69
  t.it("generates code with layers", () => {
@@ -72,9 +99,7 @@ export const routes = [
72
99
  ] as const
73
100
  `
74
101
 
75
- t
76
- .expect(code)
77
- .toBe(expected)
102
+ t.expect(code).toBe(expected)
78
103
  })
79
104
 
80
105
  t.it("generates code with nested layers", () => {
@@ -113,9 +138,7 @@ export const routes = [
113
138
  ] as const
114
139
  `
115
140
 
116
- t
117
- .expect(code)
118
- .toBe(expected)
141
+ t.expect(code).toBe(expected)
119
142
  })
120
143
 
121
144
  t.it("only includes group layers for routes in that group", () => {
@@ -128,22 +151,14 @@ t.it("only includes group layers for routes in that group", () => {
128
151
 
129
152
  const code = FileRouterCodegen.generateCode(handles)
130
153
 
131
- t
132
- .expect(code)
133
- .toContain("path: \"/users\"")
154
+ t.expect(code).toContain("path: \"/users\"")
134
155
 
135
- t
136
- .expect(code)
137
- .toContain("path: \"/movies\"")
156
+ t.expect(code).toContain("path: \"/movies\"")
138
157
 
139
158
  // /users should have both root layer and (admin) layer
140
- t
141
- .expect(code)
142
- .toContain("() => import(\"./layer.tsx\")")
159
+ t.expect(code).toContain("() => import(\"./layer.tsx\")")
143
160
 
144
- t
145
- .expect(code)
146
- .toContain("() => import(\"./(admin)/layer.ts\")")
161
+ t.expect(code).toContain("() => import(\"./(admin)/layer.ts\")")
147
162
 
148
163
  // /movies should only have root layer, not (admin) layer
149
164
  const expectedMovies = ` {
@@ -154,9 +169,7 @@ t.it("only includes group layers for routes in that group", () => {
154
169
  ],
155
170
  },`
156
171
 
157
- t
158
- .expect(code)
159
- .toContain(expectedMovies)
172
+ t.expect(code).toContain(expectedMovies)
160
173
  })
161
174
 
162
175
  t.it("handles dynamic routes with params", () => {
@@ -168,15 +181,9 @@ t.it("handles dynamic routes with params", () => {
168
181
 
169
182
  const code = FileRouterCodegen.generateCode(handles)
170
183
 
171
- t
172
- .expect(code)
173
- .toContain("path: \"/users\"")
174
- t
175
- .expect(code)
176
- .toContain("path: \"/users/[userId]\"")
177
- t
178
- .expect(code)
179
- .toContain("path: \"/posts/[postId]/comments/[commentId]\"")
184
+ t.expect(code).toContain("path: \"/users\"")
185
+ t.expect(code).toContain("path: \"/users/[userId]\"")
186
+ t.expect(code).toContain("path: \"/posts/[postId]/comments/[commentId]\"")
180
187
  })
181
188
 
182
189
  t.it("handles rest parameters", () => {
@@ -187,12 +194,8 @@ t.it("handles rest parameters", () => {
187
194
 
188
195
  const code = FileRouterCodegen.generateCode(handles)
189
196
 
190
- t
191
- .expect(code)
192
- .toContain("path: \"/docs/[[...slug]]\"")
193
- t
194
- .expect(code)
195
- .toContain("path: \"/api/[...path]\"")
197
+ t.expect(code).toContain("path: \"/docs/[[...slug]]\"")
198
+ t.expect(code).toContain("path: \"/api/[...path]\"")
196
199
  })
197
200
 
198
201
  t.it("handles groups in path", () => {
@@ -203,12 +206,10 @@ t.it("handles groups in path", () => {
203
206
 
204
207
  const code = FileRouterCodegen.generateCode(handles)
205
208
 
206
- t
207
- .expect(code)
208
- .toContain("path: \"/users\"") // groups stripped from URL
209
- t
210
- .expect(code)
211
- .toContain("layers: [\n () => import(\"./(admin)/layer.tsx\"),\n ]")
209
+ t.expect(code).toContain("path: \"/users\"") // groups stripped from URL
210
+ t.expect(code).toContain(
211
+ "layers: [\n () => import(\"./(admin)/layer.tsx\"),\n ]",
212
+ )
212
213
  })
213
214
 
214
215
  t.it("generates correct variable names for root routes", () => {
@@ -218,9 +219,7 @@ t.it("generates correct variable names for root routes", () => {
218
219
 
219
220
  const code = FileRouterCodegen.generateCode(handles)
220
221
 
221
- t
222
- .expect(code)
223
- .toContain("path: \"/\"")
222
+ t.expect(code).toContain("path: \"/\"")
224
223
  })
225
224
 
226
225
  t.it("handles routes with dots in path segments", () => {
@@ -231,12 +230,8 @@ t.it("handles routes with dots in path segments", () => {
231
230
 
232
231
  const code = FileRouterCodegen.generateCode(handles)
233
232
 
234
- t
235
- .expect(code)
236
- .toContain("path: \"/events.json\"")
237
- t
238
- .expect(code)
239
- .toContain("path: \"/config.yaml.backup\"")
233
+ t.expect(code).toContain("path: \"/events.json\"")
234
+ t.expect(code).toContain("path: \"/config.yaml.backup\"")
240
235
  })
241
236
 
242
237
  t.it("uses default module identifier", () => {
@@ -246,9 +241,7 @@ t.it("uses default module identifier", () => {
246
241
 
247
242
  const code = FileRouterCodegen.generateCode(handles)
248
243
 
249
- t
250
- .expect(code)
251
- .toContain("import type { Router } from \"effect-start\"")
244
+ t.expect(code).toContain("import type { Router } from \"effect-start\"")
252
245
  })
253
246
 
254
247
  t.it("generates empty routes array when no handles provided", () => {
@@ -256,9 +249,7 @@ t.it("generates empty routes array when no handles provided", () => {
256
249
 
257
250
  const code = FileRouterCodegen.generateCode(handles)
258
251
 
259
- t
260
- .expect(code)
261
- .toContain("export const routes = [] as const")
252
+ t.expect(code).toContain("export const routes = [] as const")
262
253
  })
263
254
 
264
255
  t.it("only includes routes, not layers", () => {
@@ -269,9 +260,7 @@ t.it("only includes routes, not layers", () => {
269
260
 
270
261
  const code = FileRouterCodegen.generateCode(handles)
271
262
 
272
- t
273
- .expect(code)
274
- .toContain("export const routes = [] as const")
263
+ t.expect(code).toContain("export const routes = [] as const")
275
264
  })
276
265
 
277
266
  t.it("complex nested routes with multiple layers", () => {
@@ -287,29 +276,15 @@ t.it("complex nested routes with multiple layers", () => {
287
276
 
288
277
  const code = FileRouterCodegen.generateCode(handles)
289
278
 
290
- t
291
- .expect(code)
292
- .toContain("path: \"/login\"") // group stripped
293
- t
294
- .expect(code)
295
- .toContain("path: \"/signup\"") // group stripped
296
- t
297
- .expect(code)
298
- .toContain("path: \"/dashboard\"")
299
- t
300
- .expect(code)
301
- .toContain("path: \"/dashboard/settings\"")
279
+ t.expect(code).toContain("path: \"/login\"") // group stripped
280
+ t.expect(code).toContain("path: \"/signup\"") // group stripped
281
+ t.expect(code).toContain("path: \"/dashboard\"")
282
+ t.expect(code).toContain("path: \"/dashboard/settings\"")
302
283
 
303
284
  // Check layers are properly inherited
304
- t
305
- .expect(code)
306
- .toContain("() => import(\"./layer.tsx\")")
307
- t
308
- .expect(code)
309
- .toContain("() => import(\"./(auth)/layer.tsx\")")
310
- t
311
- .expect(code)
312
- .toContain("() => import(\"./dashboard/layer.tsx\")")
285
+ t.expect(code).toContain("() => import(\"./layer.tsx\")")
286
+ t.expect(code).toContain("() => import(\"./(auth)/layer.tsx\")")
287
+ t.expect(code).toContain("() => import(\"./dashboard/layer.tsx\")")
313
288
  })
314
289
 
315
290
  t.it("handles routes with hyphens and underscores in path segments", () => {
@@ -320,78 +295,52 @@ t.it("handles routes with hyphens and underscores in path segments", () => {
320
295
 
321
296
  const code = FileRouterCodegen.generateCode(handles)
322
297
 
323
- t
324
- .expect(code)
325
- .toContain("path: \"/api-v1\"")
326
- t
327
- .expect(code)
328
- .toContain("path: \"/my_resource\"")
298
+ t.expect(code).toContain("path: \"/api-v1\"")
299
+ t.expect(code).toContain("path: \"/my_resource\"")
329
300
  })
330
301
 
331
302
  t.it("validateRouteModule returns true for valid modules", () => {
332
303
  const validRoute = Route.text("Hello")
333
304
 
334
305
  t
335
- .expect(
336
- FileRouterCodegen.validateRouteModule({ default: validRoute }),
337
- )
306
+ .expect(FileRouterCodegen.validateRouteModule({ default: validRoute }))
338
307
  .toBe(true)
339
308
 
340
309
  t
341
- .expect(
342
- FileRouterCodegen.validateRouteModule({
343
- default: Route.html(Effect.succeed("<div>Hello</div>")),
344
- }),
345
- )
310
+ .expect(FileRouterCodegen.validateRouteModule({
311
+ default: Route.html(Effect.succeed("<div>Hello</div>")),
312
+ }))
346
313
  .toBe(true)
347
314
 
348
315
  t
349
- .expect(
350
- FileRouterCodegen.validateRouteModule({
351
- default: Route.json({ message: "Hello" }),
352
- }),
353
- )
316
+ .expect(FileRouterCodegen.validateRouteModule({
317
+ default: Route.json({ message: "Hello" }),
318
+ }))
354
319
  .toBe(true)
355
320
  })
356
321
 
357
322
  t.it("validateRouteModule returns false for invalid modules", () => {
358
- t
359
- .expect(FileRouterCodegen.validateRouteModule({}))
360
- .toBe(false)
323
+ t.expect(FileRouterCodegen.validateRouteModule({})).toBe(false)
361
324
 
362
325
  t
363
- .expect(
364
- FileRouterCodegen.validateRouteModule({ default: {} }),
365
- )
326
+ .expect(FileRouterCodegen.validateRouteModule({ default: {} }))
366
327
  .toBe(false)
367
328
 
368
329
  t
369
- .expect(
370
- FileRouterCodegen.validateRouteModule({ default: "not a route" }),
371
- )
330
+ .expect(FileRouterCodegen.validateRouteModule({ default: "not a route" }))
372
331
  .toBe(false)
373
332
 
374
333
  t
375
- .expect(
376
- FileRouterCodegen.validateRouteModule({ foo: "bar" }),
377
- )
334
+ .expect(FileRouterCodegen.validateRouteModule({ foo: "bar" }))
378
335
  .toBe(false)
379
336
 
380
- t
381
- .expect(FileRouterCodegen.validateRouteModule(null))
382
- .toBe(false)
337
+ t.expect(FileRouterCodegen.validateRouteModule(null)).toBe(false)
383
338
 
384
- t
385
- .expect(FileRouterCodegen.validateRouteModule(undefined))
386
- .toBe(false)
339
+ t.expect(FileRouterCodegen.validateRouteModule(undefined)).toBe(false)
387
340
 
388
- t
389
- .expect(FileRouterCodegen.validateRouteModule("string"))
390
- .toBe(false)
341
+ t.expect(FileRouterCodegen.validateRouteModule("string")).toBe(false)
391
342
 
392
- t
393
- .expect(FileRouterCodegen.validateRouteModule(42))
394
- .toBe(false)
343
+ t.expect(FileRouterCodegen.validateRouteModule(42)).toBe(false)
395
344
  })
396
345
 
397
346
  t.it("mixed params and rest in same route", () => {
@@ -401,9 +350,7 @@ t.it("mixed params and rest in same route", () => {
401
350
 
402
351
  const code = FileRouterCodegen.generateCode(handles)
403
352
 
404
- t
405
- .expect(code)
406
- .toContain("path: \"/users/[userId]/files/[...path]\"")
353
+ t.expect(code).toContain("path: \"/users/[userId]/files/[...path]\"")
407
354
  })
408
355
 
409
356
  t.describe("layerMatchesRoute", () => {
@@ -424,18 +371,14 @@ t.describe("layerMatchesRoute", () => {
424
371
  ],
425
372
  },`
426
373
 
427
- t
428
- .expect(code)
429
- .toContain(expectedUserIdPosts)
374
+ t.expect(code).toContain(expectedUserIdPosts)
430
375
 
431
376
  const expectedOtherId = ` {
432
377
  path: "/[otherId]",
433
378
  load: () => import("./[otherId]/route.tsx"),
434
379
  },`
435
380
 
436
- t
437
- .expect(code)
438
- .toContain(expectedOtherId)
381
+ t.expect(code).toContain(expectedOtherId)
439
382
  })
440
383
 
441
384
  t.it("nested groups only apply to routes in those groups", () => {
@@ -458,9 +401,7 @@ t.describe("layerMatchesRoute", () => {
458
401
  ],
459
402
  },`
460
403
 
461
- t
462
- .expect(code)
463
- .toContain(expectedAdminDashboardUsers)
404
+ t.expect(code).toContain(expectedAdminDashboardUsers)
464
405
 
465
406
  const expectedAdminSettings = ` {
466
407
  path: "/settings",
@@ -470,9 +411,7 @@ t.describe("layerMatchesRoute", () => {
470
411
  ],
471
412
  },`
472
413
 
473
- t
474
- .expect(code)
475
- .toContain(expectedAdminSettings)
414
+ t.expect(code).toContain(expectedAdminSettings)
476
415
 
477
416
  const expectedOtherDashboard = ` {
478
417
  path: "/",
@@ -482,9 +421,7 @@ t.describe("layerMatchesRoute", () => {
482
421
  ],
483
422
  },`
484
423
 
485
- t
486
- .expect(code)
487
- .toContain(expectedOtherDashboard)
424
+ t.expect(code).toContain(expectedOtherDashboard)
488
425
  })
489
426
 
490
427
  t.it("similar directory names do not match (user vs users)", () => {
@@ -504,18 +441,14 @@ t.describe("layerMatchesRoute", () => {
504
441
  ],
505
442
  },`
506
443
 
507
- t
508
- .expect(code)
509
- .toContain(expectedUser)
444
+ t.expect(code).toContain(expectedUser)
510
445
 
511
446
  const expectedUsers = ` {
512
447
  path: "/users",
513
448
  load: () => import("./users/route.tsx"),
514
449
  },`
515
450
 
516
- t
517
- .expect(code)
518
- .toContain(expectedUsers)
451
+ t.expect(code).toContain(expectedUsers)
519
452
  })
520
453
 
521
454
  t.it("mixed groups and literals layer matching", () => {
@@ -536,27 +469,21 @@ t.describe("layerMatchesRoute", () => {
536
469
  ],
537
470
  },`
538
471
 
539
- t
540
- .expect(code)
541
- .toContain(expectedAdminUsersId)
472
+ t.expect(code).toContain(expectedAdminUsersId)
542
473
 
543
474
  const expectedUsers = ` {
544
475
  path: "/users",
545
476
  load: () => import("./users/route.tsx"),
546
477
  },`
547
478
 
548
- t
549
- .expect(code)
550
- .toContain(expectedUsers)
479
+ t.expect(code).toContain(expectedUsers)
551
480
 
552
481
  const expectedAdminPosts = ` {
553
482
  path: "/posts",
554
483
  load: () => import("./(admin)/posts/route.tsx"),
555
484
  },`
556
485
 
557
- t
558
- .expect(code)
559
- .toContain(expectedAdminPosts)
486
+ t.expect(code).toContain(expectedAdminPosts)
560
487
  })
561
488
 
562
489
  t.it("param directory layer only applies to routes in that dir", () => {
@@ -576,18 +503,14 @@ t.describe("layerMatchesRoute", () => {
576
503
  ],
577
504
  },`
578
505
 
579
- t
580
- .expect(code)
581
- .toContain(expectedTenantSettings)
506
+ t.expect(code).toContain(expectedTenantSettings)
582
507
 
583
508
  const expectedOther = ` {
584
509
  path: "/other",
585
510
  load: () => import("./other/route.tsx"),
586
511
  },`
587
512
 
588
- t
589
- .expect(code)
590
- .toContain(expectedOther)
513
+ t.expect(code).toContain(expectedOther)
591
514
  })
592
515
 
593
516
  t.it(
@@ -609,18 +532,14 @@ t.describe("layerMatchesRoute", () => {
609
532
  ],
610
533
  },`
611
534
 
612
- t
613
- .expect(code)
614
- .toContain(expectedIdSettings)
535
+ t.expect(code).toContain(expectedIdSettings)
615
536
 
616
537
  const expectedOther = ` {
617
538
  path: "/other",
618
539
  load: () => import("./other/route.tsx"),
619
540
  },`
620
541
 
621
- t
622
- .expect(code)
623
- .toContain(expectedOther)
542
+ t.expect(code).toContain(expectedOther)
624
543
  },
625
544
  )
626
545
 
@@ -640,145 +559,410 @@ t.describe("layerMatchesRoute", () => {
640
559
  ],
641
560
  },`
642
561
 
643
- t
644
- .expect(code)
645
- .toContain(expected)
562
+ t.expect(code).toContain(expected)
646
563
  })
647
564
  })
648
565
 
649
- const effect = effectFn()
650
-
651
- const update_FilesWithRoutes = {
652
- "/routes/route.tsx": "",
653
- "/routes/about/route.tsx": "",
654
- "/routes/_manifest.ts": "",
655
- }
566
+ const simpleRouteContent = `import * as Route from "${
567
+ path.resolve(import.meta.dirname, "./Route.ts")
568
+ }"
569
+ export default Route.text("Hello")
570
+ `
656
571
 
657
572
  t.it("update() > writes file", () =>
658
573
  Effect
659
574
  .gen(function*() {
660
- yield* FileRouterCodegen.update("/routes")
661
-
662
575
  const fs = yield* FileSystem.FileSystem
663
- const content = yield* fs.readFileString("/routes/_manifest.ts")
576
+ const tempDir = yield* createTempDirWithFiles({
577
+ "routes/route.tsx": simpleRouteContent,
578
+ "routes/about/route.tsx": simpleRouteContent,
579
+ })
580
+ const routesPath = path.join(tempDir, "routes")
581
+
582
+ yield* FileRouterCodegen.update(routesPath)
664
583
 
665
- t
666
- .expect(content)
667
- .toContain("export const routes =")
584
+ const content = yield* fs.readFileString(
585
+ path.join(routesPath, "manifest.ts"),
586
+ )
587
+
588
+ t.expect(content).toContain("export const routes =")
668
589
  })
669
590
  .pipe(
670
- Effect.provide(MemoryFileSystem.layerWith(update_FilesWithRoutes)),
591
+ Effect.scoped,
592
+ Effect.provide(NodeFileSystem.layer),
671
593
  Effect.runPromise,
672
594
  ))
673
595
 
674
596
  t.it("update() > writes only when it changes", () =>
675
597
  Effect
676
598
  .gen(function*() {
677
- yield* FileRouterCodegen.update("/routes")
678
-
679
599
  const fs = yield* FileSystem.FileSystem
680
- const content = yield* fs.readFileString("/routes/_manifest.ts")
600
+ const tempDir = yield* createTempDirWithFiles({
601
+ "routes/route.tsx": simpleRouteContent,
602
+ "routes/about/route.tsx": simpleRouteContent,
603
+ })
604
+ const routesPath = path.join(tempDir, "routes")
605
+
606
+ yield* FileRouterCodegen.update(routesPath)
681
607
 
682
- yield* FileRouterCodegen.update("/routes")
608
+ const content = yield* fs.readFileString(
609
+ path.join(routesPath, "manifest.ts"),
610
+ )
683
611
 
684
- const content2 = yield* fs.readFileString("/routes/_manifest.ts")
612
+ yield* FileRouterCodegen.update(routesPath)
685
613
 
686
- t
687
- .expect(content2)
688
- .not
689
- .toBe("")
614
+ const content2 = yield* fs.readFileString(
615
+ path.join(routesPath, "manifest.ts"),
616
+ )
690
617
 
691
- t
692
- .expect(content2)
693
- .toBe(content)
618
+ t.expect(content2).not.toBe("")
619
+ t.expect(content2).toBe(content)
694
620
  })
695
621
  .pipe(
696
- Effect.provide(MemoryFileSystem.layerWith(update_FilesWithRoutes)),
622
+ Effect.scoped,
623
+ Effect.provide(NodeFileSystem.layer),
697
624
  Effect.runPromise,
698
625
  ))
699
626
 
700
- t.it("update() > removes deleted routes from manifest", () =>
701
- Effect
702
- .gen(function*() {
703
- const fs = yield* FileSystem.FileSystem
704
-
705
- yield* FileRouterCodegen.update("/routes")
627
+ t.it(
628
+ "update() > removes deleted routes from manifest",
629
+ () =>
630
+ Effect
631
+ .gen(function*() {
632
+ const fs = yield* FileSystem.FileSystem
633
+ const tempDir = yield* createTempDirWithFiles({
634
+ "routes/route.tsx": simpleRouteContent,
635
+ "routes/about/route.tsx": simpleRouteContent,
636
+ })
637
+ const routesPath = path.join(tempDir, "routes")
638
+
639
+ yield* FileRouterCodegen.update(routesPath)
640
+
641
+ const content = yield* fs.readFileString(
642
+ path.join(routesPath, "manifest.ts"),
643
+ )
644
+
645
+ t.expect(content).toContain("path: \"/\"")
646
+ t.expect(content).toContain("path: \"/about\"")
647
+
648
+ yield* fs.remove(path.join(routesPath, "about/route.tsx"))
649
+
650
+ yield* FileRouterCodegen.update(routesPath)
651
+
652
+ const content2 = yield* fs.readFileString(
653
+ path.join(routesPath, "manifest.ts"),
654
+ )
655
+
656
+ t.expect(content2).toContain("path: \"/\"")
657
+ t.expect(content2).not.toContain("path: \"/about\"")
658
+ })
659
+ .pipe(
660
+ Effect.scoped,
661
+ Effect.provide(NodeFileSystem.layer),
662
+ Effect.runPromise,
663
+ ),
664
+ )
665
+
666
+ t.it(
667
+ "update() > removes routes when entire directory is deleted",
668
+ () =>
669
+ Effect
670
+ .gen(function*() {
671
+ const fs = yield* FileSystem.FileSystem
672
+ const tempDir = yield* createTempDirWithFiles({
673
+ "routes/route.tsx": simpleRouteContent,
674
+ "routes/about/route.tsx": simpleRouteContent,
675
+ "routes/users/route.tsx": simpleRouteContent,
676
+ })
677
+ const routesPath = path.join(tempDir, "routes")
678
+
679
+ yield* FileRouterCodegen.update(routesPath)
680
+
681
+ const content = yield* fs.readFileString(
682
+ path.join(routesPath, "manifest.ts"),
683
+ )
684
+
685
+ t.expect(content).toContain("path: \"/\"")
686
+ t.expect(content).toContain("path: \"/about\"")
687
+ t.expect(content).toContain("path: \"/users\"")
688
+
689
+ yield* fs.remove(path.join(routesPath, "users"), {
690
+ recursive: true,
691
+ })
692
+
693
+ yield* FileRouterCodegen.update(routesPath)
694
+
695
+ const content2 = yield* fs.readFileString(
696
+ path.join(routesPath, "manifest.ts"),
697
+ )
698
+
699
+ t.expect(content2).toContain("path: \"/\"")
700
+ t.expect(content2).toContain("path: \"/about\"")
701
+ t.expect(content2).not.toContain("path: \"/users\"")
702
+ })
703
+ .pipe(
704
+ Effect.scoped,
705
+ Effect.provide(NodeFileSystem.layer),
706
+ Effect.runPromise,
707
+ ),
708
+ )
709
+
710
+ t.describe("PathParams schema generation and validation", () => {
711
+ t.describe("generatePathParamsSchema", () => {
712
+ t.it("returns null for routes with no params", () => {
713
+ const handle = parseRoute("users/route.tsx")
714
+ const schema = FileRouterCodegen.generatePathParamsSchema(handle.segments)
715
+ t.expect(schema).toBe(null)
716
+ })
706
717
 
707
- const content = yield* fs.readFileString("/routes/_manifest.ts")
718
+ t.it("generates schema for single required param", () => {
719
+ const handle = parseRoute("users/[id]/route.tsx")
720
+ const schema = FileRouterCodegen.generatePathParamsSchema(handle.segments)
721
+ t.expect(schema).not.toBe(null)
722
+ t.expect(Object.keys(schema!.fields)).toEqual(["id"])
723
+ })
708
724
 
709
- t
710
- .expect(content)
711
- .toContain("path: \"/\"")
725
+ t.it("generates schema for single optional param", () => {
726
+ const handle = parseRoute("about/[[section]]/route.tsx")
727
+ const schema = FileRouterCodegen.generatePathParamsSchema(handle.segments)
728
+ t.expect(schema).not.toBe(null)
729
+ t.expect(Object.keys(schema!.fields)).toEqual(["section"])
730
+ })
712
731
 
713
- t
714
- .expect(content)
715
- .toContain("path: \"/about\"")
732
+ t.it("generates schema for rest segment", () => {
733
+ const handle = parseRoute("docs/[...path]/route.tsx")
734
+ const schema = FileRouterCodegen.generatePathParamsSchema(handle.segments)
735
+ t.expect(schema).not.toBe(null)
736
+ t.expect(Object.keys(schema!.fields)).toEqual(["path"])
737
+ })
716
738
 
717
- yield* fs.remove("/routes/about/route.tsx")
739
+ t.it("rest segment should capture path starting with /", () => {
740
+ const handle = parseRoute("docs/[...path]/route.tsx")
741
+ const schema = FileRouterCodegen.generatePathParamsSchema(handle.segments)
742
+ t.expect(schema).not.toBe(null)
718
743
 
719
- yield* FileRouterCodegen.update("/routes")
744
+ // Rest segments capture remaining path as string
745
+ // For route /docs/[...path] matching /docs/guide/getting-started
746
+ // The path param should be: "/guide/getting-started" (with leading /)
747
+ const formatted = SchemaExtra.formatSchemaCode(schema!)
748
+ t.expect(formatted).toBe("{ path: Schema.String }")
749
+ })
720
750
 
721
- const content2 = yield* fs.readFileString("/routes/_manifest.ts")
751
+ t.it("generates schema for optional rest segment", () => {
752
+ const handle = parseRoute("docs/[[...slug]]/route.tsx")
753
+ const schema = FileRouterCodegen.generatePathParamsSchema(handle.segments)
754
+ t.expect(schema).not.toBe(null)
755
+ t.expect(Object.keys(schema!.fields)).toEqual(["slug"])
756
+ })
722
757
 
723
- t
724
- .expect(content2)
725
- .toContain("path: \"/\"")
758
+ t.it("generates schema for multiple params", () => {
759
+ const handle = parseRoute("posts/[postId]/comments/[commentId]/route.tsx")
760
+ const schema = FileRouterCodegen.generatePathParamsSchema(handle.segments)
761
+ t.expect(schema).not.toBe(null)
762
+ t.expect(Object.keys(schema!.fields).sort()).toEqual([
763
+ "commentId",
764
+ "postId",
765
+ ])
766
+ })
726
767
 
727
- t
728
- .expect(content2)
729
- .not
730
- .toContain("path: \"/about\"")
768
+ t.it("generates schema for mixed required and optional params", () => {
769
+ const handle = parseRoute("users/[userId]/posts/[[postId]]/route.tsx")
770
+ const schema = FileRouterCodegen.generatePathParamsSchema(handle.segments)
771
+ t.expect(schema).not.toBe(null)
772
+ t.expect(Object.keys(schema!.fields).sort()).toEqual(["postId", "userId"])
731
773
  })
732
- .pipe(
733
- Effect.provide(MemoryFileSystem.layerWith(update_FilesWithRoutes)),
734
- Effect.runPromise,
735
- ))
736
774
 
737
- t.it("update() > removes routes when entire directory is deleted", () =>
738
- Effect
739
- .gen(function*() {
740
- const fs = yield* FileSystem.FileSystem
775
+ t.it("ignores group segments", () => {
776
+ const handle = parseRoute("(admin)/users/[id]/route.tsx")
777
+ const schema = FileRouterCodegen.generatePathParamsSchema(handle.segments)
778
+ t.expect(schema).not.toBe(null)
779
+ t.expect(Object.keys(schema!.fields)).toEqual(["id"])
780
+ })
781
+ })
741
782
 
742
- yield* fs.makeDirectory("/routes/users", { recursive: true })
783
+ t.describe("schemaEqual", () => {
784
+ t.it("returns true when both schemas are undefined/null", () => {
785
+ t.expect(SchemaExtra.schemaEqual(undefined, null)).toBe(true)
786
+ })
743
787
 
744
- yield* fs.writeFileString("/routes/users/route.tsx", "")
788
+ t.it("returns false when only one schema is undefined", () => {
789
+ const schema = Schema.Struct({ id: Schema.String })
790
+ t.expect(SchemaExtra.schemaEqual(undefined, schema)).toBe(false)
791
+ t.expect(SchemaExtra.schemaEqual(schema, null)).toBe(false)
792
+ })
745
793
 
746
- yield* FileRouterCodegen.update("/routes")
794
+ t.it("returns true for exact matches", () => {
795
+ const schema1 = Schema.Struct({ id: Schema.String })
796
+ const schema2 = Schema.Struct({ id: Schema.String })
797
+ t.expect(SchemaExtra.schemaEqual(schema1, schema2)).toBe(true)
798
+ })
747
799
 
748
- const content = yield* fs.readFileString("/routes/_manifest.ts")
800
+ t.it("returns true for refinement matches (UUID = String)", () => {
801
+ const userSchema = Schema.Struct({ id: Schema.UUID })
802
+ const expectedSchema = Schema.Struct({ id: Schema.String })
803
+ t.expect(SchemaExtra.schemaEqual(userSchema, expectedSchema)).toBe(true)
804
+ })
749
805
 
750
- t
751
- .expect(content)
752
- .toContain("path: \"/\"")
806
+ t.it("returns false for type mismatches", () => {
807
+ const schema1 = Schema.Struct({ id: Schema.String })
808
+ const schema2 = Schema.Struct({ id: Schema.Number })
809
+ t.expect(SchemaExtra.schemaEqual(schema1, schema2)).toBe(false)
810
+ })
753
811
 
754
- t
755
- .expect(content)
756
- .toContain("path: \"/about\"")
812
+ t.it("returns false for field name mismatches", () => {
813
+ const schema1 = Schema.Struct({ id: Schema.String })
814
+ const schema2 = Schema.Struct({ userId: Schema.String })
815
+ t.expect(SchemaExtra.schemaEqual(schema1, schema2)).toBe(false)
816
+ })
757
817
 
758
- t
759
- .expect(content)
760
- .toContain("path: \"/users\"")
818
+ t.it("returns false for field count mismatches", () => {
819
+ const schema1 = Schema.Struct({ id: Schema.String })
820
+ const schema2 = Schema.Struct({ id: Schema.String, name: Schema.String })
821
+ t.expect(SchemaExtra.schemaEqual(schema1, schema2)).toBe(false)
822
+ })
761
823
 
762
- yield* fs.remove("/routes/users", { recursive: true })
824
+ t.it("returns true for multiple fields with UUID refinement", () => {
825
+ const userSchema = Schema.Struct({
826
+ id: Schema.UUID,
827
+ name: Schema.String,
828
+ })
829
+ const expectedSchema = Schema.Struct({
830
+ id: Schema.String,
831
+ name: Schema.String,
832
+ })
833
+ t.expect(SchemaExtra.schemaEqual(userSchema, expectedSchema)).toBe(true)
834
+ })
763
835
 
764
- yield* FileRouterCodegen.update("/routes")
836
+ t.it("handles optional fields correctly", () => {
837
+ const schema1 = Schema.Struct({
838
+ id: Schema.String,
839
+ name: Schema.optional(Schema.String),
840
+ })
841
+ const schema2 = Schema.Struct({
842
+ id: Schema.String,
843
+ name: Schema.optional(Schema.String),
844
+ })
845
+ t.expect(SchemaExtra.schemaEqual(schema1, schema2)).toBe(true)
846
+ })
847
+ })
765
848
 
766
- const content2 = yield* fs.readFileString("/routes/_manifest.ts")
849
+ t.describe("formatSchemaCode", () => {
850
+ t.it("formats single required field", () => {
851
+ const schema = Schema.Struct({ id: Schema.String })
852
+ const formatted = SchemaExtra.formatSchemaCode(schema)
853
+ t.expect(formatted).toBe("{ id: Schema.String }")
854
+ })
767
855
 
768
- t
769
- .expect(content2)
770
- .toContain("path: \"/\"")
856
+ t.it("formats multiple fields", () => {
857
+ const schema = Schema.Struct({
858
+ id: Schema.String,
859
+ count: Schema.Number,
860
+ })
861
+ const formatted = SchemaExtra.formatSchemaCode(schema)
862
+ t.expect(formatted).toContain("id: Schema.String")
863
+ t.expect(formatted).toContain("count: Schema.Number")
864
+ })
771
865
 
772
- t
773
- .expect(content2)
774
- .toContain("path: \"/about\"")
866
+ t.it("formats optional fields with ? marker", () => {
867
+ const schema = Schema.Struct({
868
+ id: Schema.String,
869
+ name: Schema.optional(Schema.String),
870
+ })
871
+ const formatted = SchemaExtra.formatSchemaCode(schema)
872
+ t.expect(formatted).toContain("id: Schema.String")
873
+ t.expect(formatted).toContain("name")
874
+ })
775
875
 
776
- t
777
- .expect(content2)
778
- .not
779
- .toContain("path: \"/users\"")
876
+ t.it("formats boolean fields", () => {
877
+ const schema = Schema.Struct({
878
+ active: Schema.Boolean,
879
+ })
880
+ const formatted = SchemaExtra.formatSchemaCode(schema)
881
+ t.expect(formatted).toBe("{ active: Schema.Boolean }")
780
882
  })
781
- .pipe(
782
- Effect.provide(MemoryFileSystem.layerWith(update_FilesWithRoutes)),
783
- Effect.runPromise,
784
- ))
883
+ })
884
+
885
+ t.describe("validateRouteModules", () => {
886
+ t.it(
887
+ "does not log when PathParams schema is missing",
888
+ () =>
889
+ Effect
890
+ .gen(function*() {
891
+ const fs = yield* FileSystem.FileSystem
892
+ const routeContent = `import * as Route from "${
893
+ path.resolve(import.meta.dirname, "./Route.ts")
894
+ }"
895
+ export default Route.text("User")
896
+ `
897
+ const tempDir = yield* createTempDirWithFiles({
898
+ "routes/users/[id]/route.tsx": routeContent,
899
+ })
900
+ const routesPath = path.join(tempDir, "routes")
901
+
902
+ const files = yield* fs.readDirectory(routesPath, {
903
+ recursive: true,
904
+ })
905
+ const handles = FileRouter.getRouteHandlesFromPaths(files)
906
+
907
+ yield* FileRouterCodegen.validateRouteModules(routesPath, handles)
908
+
909
+ // Verify no logs were created
910
+ const messages = yield* TestLogger.messages
911
+ t.expect(messages).toHaveLength(0)
912
+ })
913
+ .pipe(
914
+ Effect.scoped,
915
+ Effect.provide([
916
+ TestLogger.layer(),
917
+ NodeFileSystem.layer,
918
+ ]),
919
+ Effect.runPromise,
920
+ ),
921
+ )
922
+
923
+ t.it(
924
+ "logs error when PathParams schema is incorrect",
925
+ () =>
926
+ Effect
927
+ .gen(function*() {
928
+ const fs = yield* FileSystem.FileSystem
929
+ const schemaPath = path.resolve(
930
+ import.meta.dirname,
931
+ "../node_modules/effect/Schema",
932
+ )
933
+ const routeContent = `import * as Route from "${
934
+ path.resolve(import.meta.dirname, "./Route.ts")
935
+ }"
936
+ import * as Schema from "${schemaPath}"
937
+ export default Route.text("User").schemaPathParams({ userId: Schema.String })
938
+ `
939
+ const tempDir = yield* createTempDirWithFiles({
940
+ "routes/users/[id]/route.tsx": routeContent,
941
+ })
942
+ const routesPath = path.join(tempDir, "routes")
943
+
944
+ const files = yield* fs.readDirectory(routesPath, {
945
+ recursive: true,
946
+ })
947
+ const handles = FileRouter.getRouteHandlesFromPaths(files)
948
+
949
+ yield* FileRouterCodegen.validateRouteModules(routesPath, handles)
950
+
951
+ // Verify error was logged
952
+ const messages = yield* TestLogger.messages
953
+ t.expect(messages).toHaveLength(1)
954
+ t.expect(messages[0]).toContain("[Error]")
955
+ t.expect(messages[0]).toContain("incorrect PathParams schema")
956
+ t.expect(messages[0]).toContain("expected schemaPathParams")
957
+ })
958
+ .pipe(
959
+ Effect.scoped,
960
+ Effect.provide([
961
+ TestLogger.layer(),
962
+ NodeFileSystem.layer,
963
+ ]),
964
+ Effect.runPromise,
965
+ ),
966
+ )
967
+ })
968
+ })