@fragno-dev/test 2.0.0 → 2.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/.turbo/turbo-build.log +41 -31
  2. package/CHANGELOG.md +69 -0
  3. package/dist/adapters.d.ts +4 -7
  4. package/dist/adapters.d.ts.map +1 -1
  5. package/dist/adapters.js +18 -302
  6. package/dist/adapters.js.map +1 -1
  7. package/dist/db-test.d.ts +120 -18
  8. package/dist/db-test.d.ts.map +1 -1
  9. package/dist/db-test.js +203 -55
  10. package/dist/db-test.js.map +1 -1
  11. package/dist/durable-hooks.d.ts +6 -2
  12. package/dist/durable-hooks.d.ts.map +1 -1
  13. package/dist/durable-hooks.js +10 -5
  14. package/dist/durable-hooks.js.map +1 -1
  15. package/dist/index.d.ts +3 -3
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +1 -1
  18. package/dist/index.js.map +1 -1
  19. package/dist/model-checker-actors.d.ts.map +1 -1
  20. package/dist/model-checker-actors.js +1 -1
  21. package/dist/model-checker-actors.js.map +1 -1
  22. package/dist/model-checker-adapter.d.ts +1 -1
  23. package/dist/model-checker-adapter.d.ts.map +1 -1
  24. package/dist/model-checker-adapter.js.map +1 -1
  25. package/dist/model-checker.d.ts.map +1 -1
  26. package/dist/model-checker.js.map +1 -1
  27. package/dist/test-adapters/drizzle-pglite.js +116 -0
  28. package/dist/test-adapters/drizzle-pglite.js.map +1 -0
  29. package/dist/test-adapters/in-memory.js +39 -0
  30. package/dist/test-adapters/in-memory.js.map +1 -0
  31. package/dist/test-adapters/kysely-pglite.js +105 -0
  32. package/dist/test-adapters/kysely-pglite.js.map +1 -0
  33. package/dist/test-adapters/kysely-sqlite.js +87 -0
  34. package/dist/test-adapters/kysely-sqlite.js.map +1 -0
  35. package/dist/test-adapters/model-checker.js +41 -0
  36. package/dist/test-adapters/model-checker.js.map +1 -0
  37. package/dist/tsconfig.tsbuildinfo +1 -0
  38. package/package.json +32 -33
  39. package/src/adapter-conformance.test.ts +3 -1
  40. package/src/adapters.ts +24 -455
  41. package/src/db-roundtrip-guard.test.ts +206 -0
  42. package/src/db-test.test.ts +131 -77
  43. package/src/db-test.ts +530 -96
  44. package/src/durable-hooks.test.ts +58 -0
  45. package/src/durable-hooks.ts +23 -8
  46. package/src/index.test.ts +188 -104
  47. package/src/index.ts +6 -2
  48. package/src/model-checker-actors.test.ts +5 -2
  49. package/src/model-checker-actors.ts +2 -1
  50. package/src/model-checker-adapter.ts +3 -2
  51. package/src/model-checker.test.ts +4 -1
  52. package/src/model-checker.ts +4 -3
  53. package/src/test-adapters/drizzle-pglite.ts +162 -0
  54. package/src/test-adapters/in-memory.ts +56 -0
  55. package/src/test-adapters/kysely-pglite.ts +151 -0
  56. package/src/test-adapters/kysely-sqlite.ts +119 -0
  57. package/src/test-adapters/model-checker.ts +58 -0
  58. package/tsconfig.json +1 -1
  59. package/vitest.config.ts +1 -0
package/src/db-test.ts CHANGED
@@ -1,21 +1,24 @@
1
+ import type { AnyRouteOrFactory, FlattenRouteFactories } from "@fragno-dev/core/route";
2
+ import type { SimpleQueryInterface } from "@fragno-dev/db/query";
1
3
  import type { AnySchema } from "@fragno-dev/db/schema";
4
+
2
5
  import type {
3
6
  RequestThisContext,
4
7
  FragnoPublicConfig,
5
8
  FragmentInstantiationBuilder,
6
9
  FragnoInstantiatedFragment,
10
+ AnyFragnoInstantiatedFragment,
7
11
  FragmentDefinition,
8
12
  } from "@fragno-dev/core";
9
- import type { AnyRouteOrFactory, FlattenRouteFactories } from "@fragno-dev/core/route";
13
+ import type { DatabaseAdapter, FragnoPublicConfigWithDatabase } from "@fragno-dev/db";
14
+
15
+ import type { BaseTestContext } from ".";
10
16
  import {
11
17
  createAdapter,
12
18
  type SupportedAdapter,
13
19
  type AdapterContext,
14
20
  type SchemaConfig,
15
21
  } from "./adapters";
16
- import type { DatabaseAdapter } from "@fragno-dev/db";
17
- import type { SimpleQueryInterface } from "@fragno-dev/db/query";
18
- import type { BaseTestContext } from ".";
19
22
  import { drainDurableHooks } from "./durable-hooks";
20
23
 
21
24
  // BoundServices is an internal type that strips 'this' parameters from service methods
@@ -28,15 +31,126 @@ type BoundServices<T> = {
28
31
  : T[K];
29
32
  };
30
33
 
34
+ type FragmentFactoryContext = {
35
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
36
+ adapter: DatabaseAdapter<any>;
37
+ test: BaseTestContext & AdapterContext<SupportedAdapter>;
38
+ };
39
+
40
+ const disableAutoSchedule = <TOptions extends FragnoPublicConfig>(options: TOptions) => {
41
+ const durableHooks = (options as { durableHooks?: Record<string, unknown> }).durableHooks ?? {};
42
+ return {
43
+ ...options,
44
+ durableHooks: {
45
+ ...durableHooks,
46
+ autoSchedule: false,
47
+ },
48
+ };
49
+ };
50
+
51
+ type FragmentFactoryResult =
52
+ | FragmentInstantiationBuilder<
53
+ any, // eslint-disable-line @typescript-eslint/no-explicit-any
54
+ any, // eslint-disable-line @typescript-eslint/no-explicit-any
55
+ any, // eslint-disable-line @typescript-eslint/no-explicit-any
56
+ any, // eslint-disable-line @typescript-eslint/no-explicit-any
57
+ any, // eslint-disable-line @typescript-eslint/no-explicit-any
58
+ any, // eslint-disable-line @typescript-eslint/no-explicit-any
59
+ any, // eslint-disable-line @typescript-eslint/no-explicit-any
60
+ any, // eslint-disable-line @typescript-eslint/no-explicit-any
61
+ any, // eslint-disable-line @typescript-eslint/no-explicit-any
62
+ any, // eslint-disable-line @typescript-eslint/no-explicit-any
63
+ any, // eslint-disable-line @typescript-eslint/no-explicit-any
64
+ any // eslint-disable-line @typescript-eslint/no-explicit-any
65
+ >
66
+ | FragnoInstantiatedFragment<
67
+ any, // eslint-disable-line @typescript-eslint/no-explicit-any
68
+ any, // eslint-disable-line @typescript-eslint/no-explicit-any
69
+ any, // eslint-disable-line @typescript-eslint/no-explicit-any
70
+ any, // eslint-disable-line @typescript-eslint/no-explicit-any
71
+ any, // eslint-disable-line @typescript-eslint/no-explicit-any
72
+ any, // eslint-disable-line @typescript-eslint/no-explicit-any
73
+ any // eslint-disable-line @typescript-eslint/no-explicit-any
74
+ >;
75
+
76
+ type HandlerThisContextFromFactoryResult<T> =
77
+ T extends FragmentInstantiationBuilder<
78
+ any, // eslint-disable-line @typescript-eslint/no-explicit-any
79
+ any, // eslint-disable-line @typescript-eslint/no-explicit-any
80
+ any, // eslint-disable-line @typescript-eslint/no-explicit-any
81
+ any, // eslint-disable-line @typescript-eslint/no-explicit-any
82
+ any, // eslint-disable-line @typescript-eslint/no-explicit-any
83
+ any, // eslint-disable-line @typescript-eslint/no-explicit-any
84
+ any, // eslint-disable-line @typescript-eslint/no-explicit-any
85
+ any, // eslint-disable-line @typescript-eslint/no-explicit-any
86
+ infer THandlerThisContext,
87
+ any, // eslint-disable-line @typescript-eslint/no-explicit-any
88
+ any, // eslint-disable-line @typescript-eslint/no-explicit-any
89
+ any // eslint-disable-line @typescript-eslint/no-explicit-any
90
+ >
91
+ ? THandlerThisContext
92
+ : T extends FragnoInstantiatedFragment<
93
+ any, // eslint-disable-line @typescript-eslint/no-explicit-any
94
+ any, // eslint-disable-line @typescript-eslint/no-explicit-any
95
+ any, // eslint-disable-line @typescript-eslint/no-explicit-any
96
+ any, // eslint-disable-line @typescript-eslint/no-explicit-any
97
+ infer THandlerThisContext,
98
+ any, // eslint-disable-line @typescript-eslint/no-explicit-any
99
+ any // eslint-disable-line @typescript-eslint/no-explicit-any
100
+ >
101
+ ? THandlerThisContext
102
+ : RequestThisContext;
103
+
104
+ type FragmentResultFromFactoryResult<T> =
105
+ T extends FragmentInstantiationBuilder<
106
+ any, // eslint-disable-line @typescript-eslint/no-explicit-any
107
+ any, // eslint-disable-line @typescript-eslint/no-explicit-any
108
+ infer TDeps,
109
+ infer TBaseServices,
110
+ infer TServices,
111
+ any, // eslint-disable-line @typescript-eslint/no-explicit-any
112
+ any, // eslint-disable-line @typescript-eslint/no-explicit-any
113
+ infer TServiceThisContext,
114
+ infer THandlerThisContext,
115
+ infer TRequestStorage,
116
+ infer TRoutesOrFactories,
117
+ any // eslint-disable-line @typescript-eslint/no-explicit-any
118
+ >
119
+ ? FragmentResult<
120
+ TDeps,
121
+ BoundServices<TBaseServices & TServices>,
122
+ TServiceThisContext,
123
+ THandlerThisContext,
124
+ TRequestStorage,
125
+ FlattenRouteFactories<TRoutesOrFactories>,
126
+ ExtractSchemaFromDeps<TDeps>
127
+ >
128
+ : T extends FragnoInstantiatedFragment<
129
+ infer TRoutes,
130
+ infer TDeps,
131
+ infer TServices,
132
+ infer TServiceThisContext,
133
+ infer THandlerThisContext,
134
+ infer TRequestStorage,
135
+ any // eslint-disable-line @typescript-eslint/no-explicit-any
136
+ >
137
+ ? FragmentResult<
138
+ TDeps,
139
+ BoundServices<TServices>,
140
+ TServiceThisContext,
141
+ THandlerThisContext,
142
+ TRequestStorage,
143
+ TRoutes,
144
+ ExtractSchemaFromDeps<TDeps>
145
+ >
146
+ : never;
147
+
31
148
  // Extract the schema type from database fragment dependencies
32
149
  // Database fragments have ImplicitDatabaseDependencies<TSchema> which includes `schema: TSchema`
33
150
  type ExtractSchemaFromDeps<TDeps> = TDeps extends { schema: infer TSchema extends AnySchema }
34
151
  ? TSchema
35
152
  : AnySchema;
36
153
 
37
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
38
- type AnyLinkedFragments = Record<string, any>;
39
-
40
154
  // Forward declarations for recursive type references
41
155
  interface FragmentResult<
42
156
  TDeps,
@@ -46,7 +160,6 @@ interface FragmentResult<
46
160
  TRequestStorage,
47
161
  TRoutes extends readonly any[], // eslint-disable-line @typescript-eslint/no-explicit-any
48
162
  TSchema extends AnySchema,
49
- TLinkedFragments extends AnyLinkedFragments = {},
50
163
  > {
51
164
  fragment: FragnoInstantiatedFragment<
52
165
  TRoutes,
@@ -55,8 +168,7 @@ interface FragmentResult<
55
168
  TServiceThisContext,
56
169
  THandlerThisContext,
57
170
  TRequestStorage,
58
- FragnoPublicConfig,
59
- TLinkedFragments
171
+ FragnoPublicConfig
60
172
  >;
61
173
  services: TServices;
62
174
  deps: TDeps;
@@ -67,15 +179,13 @@ interface FragmentResult<
67
179
  TServiceThisContext,
68
180
  THandlerThisContext,
69
181
  TRequestStorage,
70
- FragnoPublicConfig,
71
- TLinkedFragments
182
+ FragnoPublicConfig
72
183
  >["callRoute"];
73
184
  db: SimpleQueryInterface<TSchema>;
74
185
  }
75
186
 
76
187
  // Safe: Catch-all for any fragment result type
77
- type AnyFragmentResult = FragmentResult<
78
- any, // eslint-disable-line @typescript-eslint/no-explicit-any
188
+ export type AnyFragmentResult = FragmentResult<
79
189
  any, // eslint-disable-line @typescript-eslint/no-explicit-any
80
190
  any, // eslint-disable-line @typescript-eslint/no-explicit-any
81
191
  any, // eslint-disable-line @typescript-eslint/no-explicit-any
@@ -116,8 +226,9 @@ interface FragmentBuilderConfig<
116
226
  THandlerThisContext extends RequestThisContext,
117
227
  TRequestStorage,
118
228
  TRoutesOrFactories extends readonly AnyRouteOrFactory[],
119
- TLinkedFragments extends AnyLinkedFragments,
229
+ TInternalRoutes extends readonly AnyRouteOrFactory[],
120
230
  > {
231
+ kind: "builder";
121
232
  definition: FragmentDefinition<
122
233
  TConfig,
123
234
  TOptions,
@@ -129,7 +240,7 @@ interface FragmentBuilderConfig<
129
240
  TServiceThisContext,
130
241
  THandlerThisContext,
131
242
  TRequestStorage,
132
- TLinkedFragments
243
+ TInternalRoutes
133
244
  >;
134
245
  builder: FragmentInstantiationBuilder<
135
246
  TConfig,
@@ -143,11 +254,48 @@ interface FragmentBuilderConfig<
143
254
  THandlerThisContext,
144
255
  TRequestStorage,
145
256
  TRoutesOrFactories,
146
- TLinkedFragments
257
+ TInternalRoutes
258
+ >;
259
+ migrateToVersion?: number;
260
+ }
261
+
262
+ /**
263
+ * Configuration for a pre-built fragment instance
264
+ */
265
+ interface FragmentInstanceConfig<
266
+ TDeps,
267
+ TServices extends Record<string, unknown>,
268
+ TServiceThisContext extends RequestThisContext,
269
+ THandlerThisContext extends RequestThisContext,
270
+ TRequestStorage,
271
+ TRoutes extends readonly any[], // eslint-disable-line @typescript-eslint/no-explicit-any
272
+ > {
273
+ kind: "instance";
274
+ fragment: FragnoInstantiatedFragment<
275
+ TRoutes,
276
+ TDeps,
277
+ TServices,
278
+ TServiceThisContext,
279
+ THandlerThisContext,
280
+ TRequestStorage,
281
+ FragnoPublicConfig
147
282
  >;
148
283
  migrateToVersion?: number;
149
284
  }
150
285
 
286
+ /**
287
+ * Configuration for a fragment factory
288
+ */
289
+ interface FragmentFactoryConfig {
290
+ kind: "factory";
291
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
292
+ definition: FragmentDefinition<any, any, any, any, any, any, any, any, any, any, any>;
293
+ factory: (context: FragmentFactoryContext) => FragmentFactoryResult;
294
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
295
+ config?: any;
296
+ migrateToVersion?: number;
297
+ }
298
+
151
299
  /**
152
300
  * Test context combining base and adapter-specific functionality
153
301
  */
@@ -183,7 +331,23 @@ interface DatabaseFragmentsTestResult<
183
331
  /**
184
332
  * Internal storage for fragment configurations
185
333
  */
186
- type FragmentConfigMap = Map<string, AnyFragmentBuilderConfig>;
334
+ type AnyFragmentInstanceConfig = FragmentInstanceConfig<
335
+ any, // eslint-disable-line @typescript-eslint/no-explicit-any
336
+ any, // eslint-disable-line @typescript-eslint/no-explicit-any
337
+ any, // eslint-disable-line @typescript-eslint/no-explicit-any
338
+ any, // eslint-disable-line @typescript-eslint/no-explicit-any
339
+ any, // eslint-disable-line @typescript-eslint/no-explicit-any
340
+ any // eslint-disable-line @typescript-eslint/no-explicit-any
341
+ >;
342
+
343
+ type AnyFragmentFactoryConfig = FragmentFactoryConfig;
344
+
345
+ type AnyFragmentConfig =
346
+ | AnyFragmentBuilderConfig
347
+ | AnyFragmentInstanceConfig
348
+ | AnyFragmentFactoryConfig;
349
+
350
+ type FragmentConfigMap = Map<string, AnyFragmentConfig>;
187
351
 
188
352
  /**
189
353
  * Builder for creating multiple database fragments for testing
@@ -195,6 +359,7 @@ export class DatabaseFragmentsTestBuilder<
195
359
  > {
196
360
  #adapter?: SupportedAdapter;
197
361
  #fragments: FragmentConfigMap = new Map();
362
+ #dbRoundtripGuard?: FragnoPublicConfigWithDatabase["dbRoundtripGuard"] = true;
198
363
 
199
364
  /**
200
365
  * Set the test adapter configuration
@@ -206,6 +371,17 @@ export class DatabaseFragmentsTestBuilder<
206
371
  return this as any; // eslint-disable-line @typescript-eslint/no-explicit-any
207
372
  }
208
373
 
374
+ /**
375
+ * Opt out of the default roundtrip guard (enabled by default), or override its configuration.
376
+ * Useful for allowing multi-roundtrip routes in tests.
377
+ */
378
+ withDbRoundtripGuard(
379
+ guard: FragnoPublicConfigWithDatabase["dbRoundtripGuard"] = false,
380
+ ): DatabaseFragmentsTestBuilder<TFragments, TAdapter, TFirstFragmentThisContext> {
381
+ this.#dbRoundtripGuard = guard;
382
+ return this;
383
+ }
384
+
209
385
  /**
210
386
  * Add a fragment to the test setup
211
387
  *
@@ -226,7 +402,7 @@ export class DatabaseFragmentsTestBuilder<
226
402
  THandlerThisContext extends RequestThisContext,
227
403
  TRequestStorage,
228
404
  TRoutesOrFactories extends readonly AnyRouteOrFactory[],
229
- TLinkedFragments extends AnyLinkedFragments,
405
+ TInternalRoutes extends readonly AnyRouteOrFactory[],
230
406
  >(
231
407
  name: TName,
232
408
  builder: FragmentInstantiationBuilder<
@@ -241,7 +417,7 @@ export class DatabaseFragmentsTestBuilder<
241
417
  THandlerThisContext,
242
418
  TRequestStorage,
243
419
  TRoutesOrFactories,
244
- TLinkedFragments
420
+ TInternalRoutes
245
421
  >,
246
422
  options?: {
247
423
  migrateToVersion?: number;
@@ -255,8 +431,7 @@ export class DatabaseFragmentsTestBuilder<
255
431
  THandlerThisContext,
256
432
  TRequestStorage,
257
433
  FlattenRouteFactories<TRoutesOrFactories>,
258
- ExtractSchemaFromDeps<TDeps>, // Extract actual schema type from deps
259
- TLinkedFragments
434
+ ExtractSchemaFromDeps<TDeps> // Extract actual schema type from deps
260
435
  >;
261
436
  },
262
437
  TAdapter,
@@ -264,6 +439,7 @@ export class DatabaseFragmentsTestBuilder<
264
439
  keyof TFragments extends never ? THandlerThisContext : TFirstFragmentThisContext
265
440
  > {
266
441
  this.#fragments.set(name, {
442
+ kind: "builder",
267
443
  definition: builder.definition,
268
444
  builder,
269
445
  migrateToVersion: options?.migrateToVersion,
@@ -271,6 +447,94 @@ export class DatabaseFragmentsTestBuilder<
271
447
  return this as any; // eslint-disable-line @typescript-eslint/no-explicit-any
272
448
  }
273
449
 
450
+ /**
451
+ * Add a pre-built fragment instance to the test setup
452
+ *
453
+ * @param name - Unique name for the fragment
454
+ * @param fragment - Already-built fragment instance
455
+ */
456
+ withFragmentInstance<
457
+ TName extends string,
458
+ TRoutes extends readonly any[], // eslint-disable-line @typescript-eslint/no-explicit-any
459
+ TDeps,
460
+ TServices extends Record<string, unknown>,
461
+ TServiceThisContext extends RequestThisContext,
462
+ THandlerThisContext extends RequestThisContext,
463
+ TRequestStorage,
464
+ >(
465
+ name: TName,
466
+ fragment: FragnoInstantiatedFragment<
467
+ TRoutes,
468
+ TDeps,
469
+ TServices,
470
+ TServiceThisContext,
471
+ THandlerThisContext,
472
+ TRequestStorage,
473
+ FragnoPublicConfig
474
+ >,
475
+ options?: { migrateToVersion?: number },
476
+ ): DatabaseFragmentsTestBuilder<
477
+ TFragments & {
478
+ [K in TName]: FragmentResult<
479
+ TDeps,
480
+ BoundServices<TServices>,
481
+ TServiceThisContext,
482
+ THandlerThisContext,
483
+ TRequestStorage,
484
+ TRoutes,
485
+ ExtractSchemaFromDeps<TDeps>
486
+ >;
487
+ },
488
+ TAdapter,
489
+ keyof TFragments extends never ? THandlerThisContext : TFirstFragmentThisContext
490
+ > {
491
+ this.#fragments.set(name, {
492
+ kind: "instance",
493
+ fragment,
494
+ migrateToVersion: options?.migrateToVersion,
495
+ });
496
+
497
+ return this as any; // eslint-disable-line @typescript-eslint/no-explicit-any
498
+ }
499
+
500
+ /**
501
+ * Add a fragment factory to the test setup.
502
+ * The factory runs after the adapter is created.
503
+ *
504
+ * @param name - Unique name for the fragment
505
+ * @param definition - Fragment definition (used to extract schema/namespace)
506
+ * @param factory - Factory that returns a builder or a pre-built fragment
507
+ */
508
+ withFragmentFactory<TName extends string, TFactoryResult extends FragmentFactoryResult>(
509
+ name: TName,
510
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
511
+ definition: FragmentDefinition<any, any, any, any, any, any, any, any, any, any, any>,
512
+ factory: (context: FragmentFactoryContext) => TFactoryResult,
513
+ options?: {
514
+ migrateToVersion?: number;
515
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
516
+ config?: any;
517
+ },
518
+ ): DatabaseFragmentsTestBuilder<
519
+ TFragments & {
520
+ [K in TName]: FragmentResultFromFactoryResult<TFactoryResult>;
521
+ },
522
+ TAdapter,
523
+ keyof TFragments extends never
524
+ ? HandlerThisContextFromFactoryResult<TFactoryResult>
525
+ : TFirstFragmentThisContext
526
+ > {
527
+ this.#fragments.set(name, {
528
+ kind: "factory",
529
+ definition,
530
+ factory,
531
+ config: options?.config,
532
+ migrateToVersion: options?.migrateToVersion,
533
+ });
534
+
535
+ return this as any; // eslint-disable-line @typescript-eslint/no-explicit-any
536
+ }
537
+
274
538
  /**
275
539
  * Build the test setup and return fragments and test context
276
540
  */
@@ -284,25 +548,47 @@ export class DatabaseFragmentsTestBuilder<
284
548
  }
285
549
 
286
550
  if (this.#fragments.size === 0) {
287
- throw new Error("At least one fragment must be added using withFragment()");
551
+ throw new Error(
552
+ "At least one fragment must be added using withFragment(), withFragmentFactory(), or withFragmentInstance().",
553
+ );
288
554
  }
289
555
 
290
556
  const adapterConfig = this.#adapter;
291
557
 
292
558
  // Extract fragment names and configs
293
- const fragmentNames = Array.from(this.#fragments.keys());
294
- const fragmentConfigs = Array.from(this.#fragments.values());
559
+ const fragmentEntries = Array.from(this.#fragments.entries());
560
+ const fragmentNames = fragmentEntries.map(([name]) => name);
295
561
 
296
562
  // Extract schemas from definitions and prepare schema configs
297
563
  const schemaConfigs: SchemaConfig[] = [];
298
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
299
- const builderConfigs: Array<{ config: any; routes: any; options: any }> = [];
300
-
301
- for (const fragmentConfig of fragmentConfigs) {
302
- const builder = fragmentConfig.builder;
303
- const definition = builder.definition;
564
+ const fragmentPlans: Array<{
565
+ name: string;
566
+ kind: "builder" | "instance" | "factory";
567
+ schema: AnySchema;
568
+ namespace: string | null;
569
+ migrateToVersion?: number;
570
+ builderConfig?: {
571
+ builder: AnyFragmentBuilderConfig["builder"];
572
+ definition: AnyFragmentBuilderConfig["definition"];
573
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
574
+ config: any;
575
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
576
+ routes: any;
577
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
578
+ options: any;
579
+ };
580
+ factory?: FragmentFactoryConfig["factory"];
581
+ cachedFactoryResult?: FragmentFactoryResult;
582
+ fragment?: AnyFragnoInstantiatedFragment;
583
+ }> = [];
304
584
 
305
- // Extract schema and namespace from definition by calling dependencies with a mock adapter
585
+ const extractSchemaFromDefinition = (
586
+ definition: FragmentDefinition<any, any, any, any, any, any, any, any, any, any, any>, // eslint-disable-line @typescript-eslint/no-explicit-any
587
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
588
+ actualConfig: any,
589
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
590
+ actualOptions?: any,
591
+ ) => {
306
592
  let schema: AnySchema | undefined;
307
593
  let namespace: string | null | undefined;
308
594
 
@@ -326,24 +612,19 @@ export class DatabaseFragmentsTestBuilder<
326
612
  close: async () => {},
327
613
  };
328
614
 
329
- // Use the actual config from the builder instead of an empty mock
330
- // This ensures dependencies can be properly initialized (e.g., Stripe with API keys)
331
- const actualConfig = builder.config ?? {};
332
-
333
615
  const deps = definition.dependencies({
334
- config: actualConfig,
616
+ config: actualConfig ?? {},
335
617
  options: {
618
+ ...actualOptions,
336
619
  databaseAdapter: mockAdapter as any, // eslint-disable-line @typescript-eslint/no-explicit-any
337
620
  } as any, // eslint-disable-line @typescript-eslint/no-explicit-any
338
621
  });
339
622
 
340
- // The schema and namespace are in deps for database fragments
341
623
  if (deps && typeof deps === "object" && "schema" in deps) {
342
624
  schema = (deps as any).schema; // eslint-disable-line @typescript-eslint/no-explicit-any
343
625
  namespace = (deps as any).namespace; // eslint-disable-line @typescript-eslint/no-explicit-any
344
626
  }
345
627
  } catch (error) {
346
- // If extraction fails, provide a helpful error message
347
628
  const errorMessage =
348
629
  error instanceof Error
349
630
  ? error.message
@@ -373,51 +654,199 @@ export class DatabaseFragmentsTestBuilder<
373
654
  );
374
655
  }
375
656
 
657
+ return { schema, namespace };
658
+ };
659
+
660
+ for (const [name, fragmentConfig] of fragmentEntries) {
661
+ if (fragmentConfig.kind === "builder") {
662
+ const builder = fragmentConfig.builder;
663
+ const definition = builder.definition;
664
+ const { schema, namespace } = extractSchemaFromDefinition(
665
+ definition,
666
+ builder.config ?? {},
667
+ builder.options ?? {},
668
+ );
669
+
670
+ schemaConfigs.push({
671
+ schema,
672
+ namespace,
673
+ migrateToVersion: fragmentConfig.migrateToVersion,
674
+ });
675
+
676
+ fragmentPlans.push({
677
+ name,
678
+ kind: "builder",
679
+ schema,
680
+ namespace,
681
+ migrateToVersion: fragmentConfig.migrateToVersion,
682
+ builderConfig: {
683
+ builder: fragmentConfig.builder,
684
+ definition: fragmentConfig.definition,
685
+ config: builder.config ?? {},
686
+ routes: builder.routes ?? [],
687
+ options: builder.options ?? {},
688
+ },
689
+ });
690
+ continue;
691
+ }
692
+
693
+ if (fragmentConfig.kind === "factory") {
694
+ const definition = fragmentConfig.definition;
695
+ const { schema, namespace } = extractSchemaFromDefinition(
696
+ definition,
697
+ fragmentConfig.config ?? {},
698
+ );
699
+
700
+ schemaConfigs.push({
701
+ schema,
702
+ namespace,
703
+ migrateToVersion: fragmentConfig.migrateToVersion,
704
+ });
705
+
706
+ fragmentPlans.push({
707
+ name,
708
+ kind: "factory",
709
+ schema,
710
+ namespace,
711
+ migrateToVersion: fragmentConfig.migrateToVersion,
712
+ factory: fragmentConfig.factory,
713
+ });
714
+
715
+ continue;
716
+ }
717
+
718
+ const fragment = fragmentConfig.fragment;
719
+ const deps = fragment.$internal?.deps as
720
+ | {
721
+ schema?: AnySchema;
722
+ namespace?: string | null;
723
+ }
724
+ | undefined;
725
+
726
+ if (!deps?.schema) {
727
+ throw new Error(
728
+ `Fragment '${name}' does not have a database schema in deps. ` +
729
+ `Make sure you're using defineFragment().extend(withDatabase(schema)).`,
730
+ );
731
+ }
732
+
376
733
  schemaConfigs.push({
377
- schema,
378
- namespace,
734
+ schema: deps.schema,
735
+ namespace: deps.namespace ?? null,
379
736
  migrateToVersion: fragmentConfig.migrateToVersion,
380
737
  });
381
738
 
382
- // Extract config, routes, and options from builder using public getters
383
- builderConfigs.push({
384
- config: builder.config ?? {},
385
- routes: builder.routes ?? [],
386
- options: builder.options ?? {},
739
+ fragmentPlans.push({
740
+ name,
741
+ kind: "instance",
742
+ schema: deps.schema,
743
+ namespace: deps.namespace ?? null,
744
+ migrateToVersion: fragmentConfig.migrateToVersion,
745
+ fragment,
387
746
  });
388
747
  }
389
748
 
390
749
  const { testContext, adapter } = await createAdapter(adapterConfig, schemaConfigs);
391
750
 
751
+ const resolveDbRoundtripGuardOption = (options: unknown) => {
752
+ if (options && typeof options === "object") {
753
+ if (Object.prototype.hasOwnProperty.call(options, "dbRoundtripGuard")) {
754
+ return (options as { dbRoundtripGuard?: unknown }).dbRoundtripGuard as
755
+ | FragnoPublicConfigWithDatabase["dbRoundtripGuard"]
756
+ | undefined;
757
+ }
758
+ }
759
+ return this.#dbRoundtripGuard;
760
+ };
761
+
762
+ const mergeBuilderOptions = (options: unknown) => {
763
+ const resolvedOptions = disableAutoSchedule((options ?? {}) as FragnoPublicConfig);
764
+ const resolvedOptionsRecord = resolvedOptions as unknown as Record<string, unknown>;
765
+ const merged = {
766
+ ...resolvedOptionsRecord,
767
+ databaseAdapter: adapter,
768
+ } as Record<string, unknown>;
769
+ const guardOption = resolveDbRoundtripGuardOption(resolvedOptions);
770
+ if (guardOption !== undefined) {
771
+ merged["dbRoundtripGuard"] = guardOption;
772
+ }
773
+ return merged;
774
+ };
775
+
392
776
  // Helper to create fragments with service wiring
393
777
  const createFragments = () => {
778
+ const resolveBuilderConfig = (builder: AnyFragmentBuilderConfig["builder"]) => ({
779
+ builder,
780
+ definition: builder.definition,
781
+ config: builder.config ?? {},
782
+ routes: builder.routes ?? [],
783
+ options: builder.options ?? {},
784
+ });
785
+
786
+ const isBuilder = (
787
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
788
+ value: any,
789
+ ): value is AnyFragmentBuilderConfig["builder"] =>
790
+ Boolean(value) && typeof value === "object" && "build" in value && "definition" in value;
791
+
394
792
  // First pass: create fragments without service dependencies to extract provided services
395
793
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
396
794
  const providedServicesByName: Record<string, { service: any; orm: any }> = {};
397
- const fragmentResults: any[] = []; // eslint-disable-line @typescript-eslint/no-explicit-any
795
+ const instanceResults = new Map<string, any>(); // eslint-disable-line @typescript-eslint/no-explicit-any
796
+ const builderConfigs = new Map<string, ReturnType<typeof resolveBuilderConfig>>();
398
797
 
399
- for (let i = 0; i < fragmentConfigs.length; i++) {
400
- const fragmentConfig = fragmentConfigs[i]!;
401
- const builderConfig = builderConfigs[i]!;
402
- const namespace = schemaConfigs[i]!.namespace;
403
- const schema = schemaConfigs[i]!.schema;
404
- const orm = testContext.getOrm(namespace);
798
+ for (const plan of fragmentPlans) {
799
+ const orm = testContext.getOrm(plan.namespace);
800
+ let fragment: AnyFragnoInstantiatedFragment | undefined;
801
+ let builderConfig = plan.builderConfig;
405
802
 
406
- // Merge builder options with database adapter
407
- const mergedOptions = {
408
- ...builderConfig.options,
409
- databaseAdapter: adapter,
410
- };
803
+ if (plan.kind === "factory") {
804
+ const result =
805
+ plan.cachedFactoryResult ??
806
+ plan.factory!({
807
+ adapter,
808
+ test: testContext,
809
+ });
810
+
811
+ if (!plan.cachedFactoryResult) {
812
+ plan.cachedFactoryResult = result;
813
+ }
814
+
815
+ if (isBuilder(result)) {
816
+ builderConfig = resolveBuilderConfig(result);
817
+ } else {
818
+ fragment = result;
819
+ }
820
+ }
821
+
822
+ const usesBuilder = plan.kind === "builder" || !!builderConfig;
411
823
 
412
- // Instantiate fragment using the builder
413
- const fragment = fragmentConfig.builder.withOptions(mergedOptions).build();
824
+ if (usesBuilder) {
825
+ const resolvedBuilderConfig = builderConfig ?? plan.builderConfig!;
826
+ const mergedOptions = mergeBuilderOptions(resolvedBuilderConfig.options);
414
827
 
415
- // Extract provided services based on serviceDependencies metadata
416
- // Note: serviceDependencies lists services this fragment USES, not provides
417
- // For provided services, we need to check what's actually in fragment.services
418
- // and match against other fragments' service dependencies
828
+ fragment = resolvedBuilderConfig.builder.withOptions(mergedOptions).build();
829
+ builderConfigs.set(plan.name, resolvedBuilderConfig);
830
+ } else {
831
+ fragment = fragment ?? plan.fragment!;
832
+
833
+ const deps = fragment.$internal?.deps as
834
+ | { databaseAdapter?: DatabaseAdapter<unknown> }
835
+ | undefined;
836
+ if (deps?.databaseAdapter && deps.databaseAdapter !== adapter) {
837
+ throw new Error(
838
+ `Fragment '${plan.name}' was built with a different database adapter instance. ` +
839
+ `Use withFragment() or ensure the fragment uses the same adapter instance as the test builder.`,
840
+ );
841
+ }
842
+ }
843
+
844
+ if (!fragment) {
845
+ throw new Error(
846
+ `Fragment '${plan.name}' did not return a valid fragment instance from its factory.`,
847
+ );
848
+ }
419
849
 
420
- // Store all services as potentially provided
421
850
  for (const [serviceName, serviceImpl] of Object.entries(fragment.services)) {
422
851
  providedServicesByName[serviceName] = {
423
852
  service: serviceImpl,
@@ -425,30 +854,40 @@ export class DatabaseFragmentsTestBuilder<
425
854
  };
426
855
  }
427
856
 
428
- // Store the fragment result
429
- const deps = fragment.$internal?.deps;
430
-
431
- fragmentResults.push({
432
- fragment,
433
- services: fragment.services,
434
- deps: deps || {},
435
- callRoute: fragment.callRoute.bind(fragment),
436
- get db() {
437
- return orm;
438
- },
439
- _orm: orm,
440
- _schema: schema,
441
- });
857
+ if (!usesBuilder) {
858
+ const deps = fragment.$internal?.deps;
859
+ instanceResults.set(plan.name, {
860
+ fragment,
861
+ services: fragment.services,
862
+ deps: deps || {},
863
+ callRoute: fragment.callRoute.bind(fragment),
864
+ get db() {
865
+ return orm;
866
+ },
867
+ _orm: orm,
868
+ _schema: plan.schema,
869
+ });
870
+ }
442
871
  }
443
872
 
444
873
  // Second pass: rebuild fragments with service dependencies wired up
445
- for (let i = 0; i < fragmentConfigs.length; i++) {
446
- const fragmentConfig = fragmentConfigs[i]!;
447
- const definition = fragmentConfig.builder.definition;
448
- const builderConfig = builderConfigs[i]!;
449
- const namespace = schemaConfigs[i]!.namespace;
450
- const schema = schemaConfigs[i]!.schema;
451
- const orm = testContext.getOrm(namespace);
874
+ const fragmentResults: any[] = []; // eslint-disable-line @typescript-eslint/no-explicit-any
875
+
876
+ for (const plan of fragmentPlans) {
877
+ const orm = testContext.getOrm(plan.namespace);
878
+
879
+ if (instanceResults.has(plan.name)) {
880
+ fragmentResults.push(instanceResults.get(plan.name));
881
+ continue;
882
+ }
883
+
884
+ const builderConfig = builderConfigs.get(plan.name);
885
+ if (!builderConfig) {
886
+ throw new Error(
887
+ `Fragment '${plan.name}' was expected to produce a builder for service wiring.`,
888
+ );
889
+ }
890
+ const definition = builderConfig.definition;
452
891
 
453
892
  // Build service implementations for services this fragment uses
454
893
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -464,22 +903,17 @@ export class DatabaseFragmentsTestBuilder<
464
903
  }
465
904
 
466
905
  // Merge builder options with database adapter
467
- const mergedOptions = {
468
- ...builderConfig.options,
469
- databaseAdapter: adapter,
470
- };
906
+ const mergedOptions = mergeBuilderOptions(builderConfig.options);
471
907
 
472
908
  // Rebuild the fragment with service implementations using the builder
473
- const fragment = fragmentConfig.builder
909
+ const fragment = builderConfig.builder
474
910
  .withOptions(mergedOptions)
475
911
  .withServices(serviceImplementations as any) // eslint-disable-line @typescript-eslint/no-explicit-any
476
912
  .build();
477
913
 
478
- // Update the result
479
- // Access deps from the internal property
480
914
  const deps = fragment.$internal?.deps;
481
915
 
482
- fragmentResults[i] = {
916
+ fragmentResults.push({
483
917
  fragment,
484
918
  services: fragment.services,
485
919
  deps: deps || {},
@@ -488,8 +922,8 @@ export class DatabaseFragmentsTestBuilder<
488
922
  return orm;
489
923
  },
490
924
  _orm: orm,
491
- _schema: schema,
492
- };
925
+ _schema: plan.schema,
926
+ });
493
927
  }
494
928
 
495
929
  return fragmentResults;