@fragno-dev/test 1.0.2 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/.turbo/turbo-build.log +31 -15
  2. package/CHANGELOG.md +43 -0
  3. package/dist/adapters.d.ts +21 -3
  4. package/dist/adapters.d.ts.map +1 -1
  5. package/dist/adapters.js +125 -31
  6. package/dist/adapters.js.map +1 -1
  7. package/dist/db-test.d.ts.map +1 -1
  8. package/dist/db-test.js +33 -2
  9. package/dist/db-test.js.map +1 -1
  10. package/dist/durable-hooks.d.ts +7 -0
  11. package/dist/durable-hooks.d.ts.map +1 -0
  12. package/dist/durable-hooks.js +12 -0
  13. package/dist/durable-hooks.js.map +1 -0
  14. package/dist/index.d.ts +8 -4
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +6 -2
  17. package/dist/index.js.map +1 -1
  18. package/dist/model-checker-actors.d.ts +41 -0
  19. package/dist/model-checker-actors.d.ts.map +1 -0
  20. package/dist/model-checker-actors.js +406 -0
  21. package/dist/model-checker-actors.js.map +1 -0
  22. package/dist/model-checker-adapter.d.ts +32 -0
  23. package/dist/model-checker-adapter.d.ts.map +1 -0
  24. package/dist/model-checker-adapter.js +109 -0
  25. package/dist/model-checker-adapter.js.map +1 -0
  26. package/dist/model-checker.d.ts +128 -0
  27. package/dist/model-checker.d.ts.map +1 -0
  28. package/dist/model-checker.js +443 -0
  29. package/dist/model-checker.js.map +1 -0
  30. package/package.json +12 -11
  31. package/src/adapter-conformance.test.ts +322 -0
  32. package/src/adapters.ts +199 -36
  33. package/src/db-test.test.ts +2 -2
  34. package/src/db-test.ts +53 -3
  35. package/src/durable-hooks.ts +13 -0
  36. package/src/index.test.ts +84 -7
  37. package/src/index.ts +39 -4
  38. package/src/model-checker-actors.test.ts +78 -0
  39. package/src/model-checker-actors.ts +642 -0
  40. package/src/model-checker-adapter.ts +200 -0
  41. package/src/model-checker.test.ts +399 -0
  42. package/src/model-checker.ts +799 -0
package/src/adapters.ts CHANGED
@@ -1,15 +1,19 @@
1
+ // Test database adapter helpers and reset logic for fragment suites.
1
2
  import { Kysely } from "kysely";
2
3
  import { SQLocalKysely } from "sqlocal/kysely";
3
4
  import { KyselyPGlite } from "kysely-pglite";
4
5
  import { drizzle } from "drizzle-orm/pglite";
5
6
  import { PGlite } from "@electric-sql/pglite";
6
- import { KyselyAdapter } from "@fragno-dev/db/adapters/kysely";
7
- import { DrizzleAdapter } from "@fragno-dev/db/adapters/drizzle";
7
+ import { InMemoryAdapter, type InMemoryAdapterOptions } from "@fragno-dev/db/adapters/in-memory";
8
+ import { SqlAdapter } from "@fragno-dev/db/adapters/sql";
8
9
  import type { AnySchema } from "@fragno-dev/db/schema";
9
10
  import type { DatabaseAdapter } from "@fragno-dev/db/adapters";
11
+ import type { UnitOfWorkConfig } from "@fragno-dev/db/adapters/sql";
12
+ import type { OutboxConfig } from "@fragno-dev/db/adapters/sql";
10
13
  import { rm } from "node:fs/promises";
11
14
  import { existsSync } from "node:fs";
12
15
  import type { BaseTestContext } from ".";
16
+ import { ModelCheckerAdapter } from "./model-checker-adapter";
13
17
  import { createCommonTestContextMethods } from ".";
14
18
  import { PGLiteDriverConfig, SQLocalDriverConfig } from "@fragno-dev/db/drivers";
15
19
  import { internalFragmentDef } from "@fragno-dev/db";
@@ -18,30 +22,52 @@ import type { SimpleQueryInterface } from "@fragno-dev/db/query";
18
22
  // Adapter configuration types
19
23
  export interface KyselySqliteAdapter {
20
24
  type: "kysely-sqlite";
25
+ uowConfig?: UnitOfWorkConfig;
26
+ outbox?: OutboxConfig;
21
27
  }
22
28
 
23
29
  export interface KyselyPgliteAdapter {
24
30
  type: "kysely-pglite";
25
31
  databasePath?: string;
32
+ uowConfig?: UnitOfWorkConfig;
33
+ outbox?: OutboxConfig;
26
34
  }
27
35
 
28
36
  export interface DrizzlePgliteAdapter {
29
37
  type: "drizzle-pglite";
30
38
  databasePath?: string;
39
+ uowConfig?: UnitOfWorkConfig;
40
+ outbox?: OutboxConfig;
31
41
  }
32
42
 
33
- export type SupportedAdapter = KyselySqliteAdapter | KyselyPgliteAdapter | DrizzlePgliteAdapter;
43
+ export interface InMemoryAdapterConfig {
44
+ type: "in-memory";
45
+ options?: InMemoryAdapterOptions;
46
+ uowConfig?: UnitOfWorkConfig;
47
+ }
48
+
49
+ export interface ModelCheckerAdapterConfig {
50
+ type: "model-checker";
51
+ options?: InMemoryAdapterOptions;
52
+ }
53
+
54
+ export type SupportedAdapter =
55
+ | KyselySqliteAdapter
56
+ | KyselyPgliteAdapter
57
+ | DrizzlePgliteAdapter
58
+ | InMemoryAdapterConfig
59
+ | ModelCheckerAdapterConfig;
34
60
 
35
61
  // Schema configuration for multi-schema adapters
36
62
  export interface SchemaConfig {
37
63
  schema: AnySchema;
38
- namespace: string;
64
+ namespace: string | null;
39
65
  migrateToVersion?: number;
40
66
  }
41
67
 
42
68
  // Internal test context extends BaseTestContext with getOrm (not exposed publicly)
43
69
  interface InternalTestContext extends BaseTestContext {
44
- getOrm: <TSchema extends AnySchema>(namespace: string) => SimpleQueryInterface<TSchema>;
70
+ getOrm: <TSchema extends AnySchema>(namespace: string | null) => SimpleQueryInterface<TSchema>;
45
71
  }
46
72
 
47
73
  // Conditional return types based on adapter (adapter-specific properties only)
@@ -55,7 +81,9 @@ export type AdapterContext<T extends SupportedAdapter> = T extends
55
81
  ? {
56
82
  readonly drizzle: ReturnType<typeof drizzle<any>>; // eslint-disable-line @typescript-eslint/no-explicit-any
57
83
  }
58
- : never;
84
+ : T extends InMemoryAdapterConfig | ModelCheckerAdapterConfig
85
+ ? {}
86
+ : never;
59
87
 
60
88
  // Factory function return type
61
89
  interface AdapterFactoryResult<T extends SupportedAdapter> {
@@ -64,14 +92,49 @@ interface AdapterFactoryResult<T extends SupportedAdapter> {
64
92
  adapter: DatabaseAdapter<any>;
65
93
  }
66
94
 
95
+ const runInternalFragmentMigrations = async (
96
+ adapter: SqlAdapter,
97
+ ): Promise<SchemaConfig | undefined> => {
98
+ const dependencies = internalFragmentDef.dependencies;
99
+ if (!dependencies) {
100
+ return undefined;
101
+ }
102
+
103
+ const databaseDeps = dependencies({
104
+ config: {},
105
+ options: { databaseAdapter: adapter, databaseNamespace: null },
106
+ });
107
+ if (databaseDeps?.schema) {
108
+ const migrations = adapter.prepareMigrations(databaseDeps.schema, databaseDeps.namespace);
109
+ await migrations.executeWithDriver(adapter.driver, 0);
110
+ return { schema: databaseDeps.schema, namespace: databaseDeps.namespace };
111
+ }
112
+ return undefined;
113
+ };
114
+
115
+ const resolveSchemaName = (
116
+ adapter: DatabaseAdapter<any>, // eslint-disable-line @typescript-eslint/no-explicit-any
117
+ namespace: string | null,
118
+ ): string | null => {
119
+ if (adapter.namingStrategy.namespaceScope !== "schema") {
120
+ return null;
121
+ }
122
+ if (!namespace || namespace.length === 0) {
123
+ return null;
124
+ }
125
+ return adapter.namingStrategy.namespaceToSchema(namespace);
126
+ };
127
+
67
128
  /**
68
129
  * Create Kysely + SQLite adapter using SQLocalKysely (always in-memory)
69
130
  * Supports multiple schemas with separate namespaces
70
131
  */
71
132
  export async function createKyselySqliteAdapter(
72
- _config: KyselySqliteAdapter,
133
+ config: KyselySqliteAdapter,
73
134
  schemas: SchemaConfig[],
74
135
  ): Promise<AdapterFactoryResult<KyselySqliteAdapter>> {
136
+ let internalSchemaConfig: SchemaConfig | undefined;
137
+
75
138
  // Helper to create a new database instance and run migrations for all schemas
76
139
  const createDatabase = async () => {
77
140
  // Create SQLocalKysely instance (always in-memory for tests)
@@ -81,15 +144,18 @@ export async function createKyselySqliteAdapter(
81
144
  dialect,
82
145
  });
83
146
 
84
- // Create KyselyAdapter
85
- const adapter = new KyselyAdapter({
147
+ // Create SqlAdapter
148
+ const adapter = new SqlAdapter({
86
149
  dialect,
87
150
  driverConfig: new SQLocalDriverConfig(),
151
+ uowConfig: config.uowConfig,
152
+ outbox: config.outbox,
88
153
  });
154
+ internalSchemaConfig = await runInternalFragmentMigrations(adapter);
89
155
 
90
156
  // Run migrations for all schemas in order
91
157
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
92
- const ormMap = new Map<string, SimpleQueryInterface<any, any>>();
158
+ const ormMap = new Map<string | null, SimpleQueryInterface<any, any>>();
93
159
 
94
160
  for (const { schema, namespace, migrateToVersion } of schemas) {
95
161
  // Run migrations
@@ -113,11 +179,12 @@ export async function createKyselySqliteAdapter(
113
179
 
114
180
  // Reset database function - truncates all tables (only supported for in-memory databases)
115
181
  const resetDatabase = async () => {
182
+ const schemasToTruncate = internalSchemaConfig ? [internalSchemaConfig, ...schemas] : schemas;
183
+
116
184
  // For SQLite, truncate all tables by deleting rows
117
- for (const { schema, namespace } of schemas) {
118
- const mapper = adapter.createTableNameMapper(namespace);
185
+ for (const { schema, namespace } of schemasToTruncate) {
119
186
  for (const tableName of Object.keys(schema.tables)) {
120
- const physicalTableName = mapper.toPhysical(tableName);
187
+ const physicalTableName = adapter.namingStrategy.tableName(tableName, namespace);
121
188
  await kysely.deleteFrom(physicalTableName).execute();
122
189
  }
123
190
  }
@@ -157,6 +224,7 @@ export async function createKyselyPgliteAdapter(
157
224
  schemas: SchemaConfig[],
158
225
  ): Promise<AdapterFactoryResult<KyselyPgliteAdapter>> {
159
226
  const databasePath = config.databasePath;
227
+ let internalSchemaConfig: SchemaConfig | undefined;
160
228
 
161
229
  // Helper to create a new database instance and run migrations for all schemas
162
230
  const createDatabase = async () => {
@@ -169,15 +237,18 @@ export async function createKyselyPgliteAdapter(
169
237
  dialect: kyselyPglite.dialect,
170
238
  });
171
239
 
172
- // Create KyselyAdapter
173
- const adapter = new KyselyAdapter({
240
+ // Create SqlAdapter
241
+ const adapter = new SqlAdapter({
174
242
  dialect: kyselyPglite.dialect,
175
243
  driverConfig: new PGLiteDriverConfig(),
244
+ uowConfig: config.uowConfig,
245
+ outbox: config.outbox,
176
246
  });
247
+ internalSchemaConfig = await runInternalFragmentMigrations(adapter);
177
248
 
178
249
  // Run migrations for all schemas in order
179
250
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
180
- const ormMap = new Map<string, SimpleQueryInterface<any, any>>();
251
+ const ormMap = new Map<string | null, SimpleQueryInterface<any, any>>();
181
252
 
182
253
  for (const { schema, namespace, migrateToVersion } of schemas) {
183
254
  // Run migrations
@@ -205,12 +276,15 @@ export async function createKyselyPgliteAdapter(
205
276
  throw new Error("resetDatabase is only supported for in-memory databases");
206
277
  }
207
278
 
279
+ const schemasToTruncate = internalSchemaConfig ? [internalSchemaConfig, ...schemas] : schemas;
280
+
208
281
  // Truncate all tables
209
- for (const { schema, namespace } of schemas) {
210
- const mapper = adapter.createTableNameMapper(namespace);
282
+ for (const { schema, namespace } of schemasToTruncate) {
211
283
  for (const tableName of Object.keys(schema.tables)) {
212
- const physicalTableName = mapper.toPhysical(tableName);
213
- await kysely.deleteFrom(physicalTableName).execute();
284
+ const physicalTableName = adapter.namingStrategy.tableName(tableName, namespace);
285
+ const schemaName = resolveSchemaName(adapter, namespace);
286
+ const scopedKysely = schemaName ? kysely.withSchema(schemaName) : kysely;
287
+ await scopedKysely.deleteFrom(physicalTableName).execute();
214
288
  }
215
289
  }
216
290
  };
@@ -260,6 +334,7 @@ export async function createDrizzlePgliteAdapter(
260
334
  schemas: SchemaConfig[],
261
335
  ): Promise<AdapterFactoryResult<DrizzlePgliteAdapter>> {
262
336
  const databasePath = config.databasePath;
337
+ let internalSchemaConfig: SchemaConfig | undefined;
263
338
 
264
339
  // Helper to create a new database instance and run migrations for all schemas
265
340
  const createDatabase = async () => {
@@ -267,23 +342,18 @@ export async function createDrizzlePgliteAdapter(
267
342
 
268
343
  const { dialect } = new KyselyPGlite(pglite);
269
344
 
270
- const adapter = new DrizzleAdapter({
345
+ const adapter = new SqlAdapter({
271
346
  dialect,
272
347
  driverConfig: new PGLiteDriverConfig(),
348
+ uowConfig: config.uowConfig,
349
+ outbox: config.outbox,
273
350
  });
274
351
 
352
+ internalSchemaConfig = await runInternalFragmentMigrations(adapter);
353
+
275
354
  // Run migrations for all schemas
276
355
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
277
- const ormMap = new Map<string, SimpleQueryInterface<any, any>>();
278
-
279
- const databaseDeps = internalFragmentDef.dependencies?.({
280
- config: {},
281
- options: { databaseAdapter: adapter },
282
- });
283
- if (databaseDeps?.schema) {
284
- const migrations = adapter.prepareMigrations(databaseDeps.schema, databaseDeps.namespace);
285
- await migrations.executeWithDriver(adapter.driver, 0);
286
- }
356
+ const ormMap = new Map<string | null, SimpleQueryInterface<any, any>>();
287
357
 
288
358
  for (const { schema, namespace, migrateToVersion } of schemas) {
289
359
  const preparedMigrations = adapter.prepareMigrations(schema, namespace);
@@ -313,12 +383,18 @@ export async function createDrizzlePgliteAdapter(
313
383
  throw new Error("resetDatabase is only supported for in-memory databases");
314
384
  }
315
385
 
386
+ const schemasToTruncate = internalSchemaConfig ? [internalSchemaConfig, ...schemas] : schemas;
387
+
316
388
  // Truncate all tables by deleting rows
317
- for (const { schema, namespace } of schemas) {
318
- const mapper = adapter.createTableNameMapper(namespace);
319
- for (const tableName of Object.keys(schema.tables)) {
320
- const physicalTableName = mapper.toPhysical(tableName);
321
- await drizzleDb.execute(`DELETE FROM "${physicalTableName}"`);
389
+ for (const { schema, namespace } of schemasToTruncate) {
390
+ const tableNames = Object.keys(schema.tables).slice().reverse();
391
+ for (const tableName of tableNames) {
392
+ const physicalTableName = adapter.namingStrategy.tableName(tableName, namespace);
393
+ const schemaName = resolveSchemaName(adapter, namespace);
394
+ const qualifiedTable = schemaName
395
+ ? `"${schemaName}"."${physicalTableName}"`
396
+ : `"${physicalTableName}"`;
397
+ await drizzleDb.execute(`DELETE FROM ${qualifiedTable}`);
322
398
  }
323
399
  }
324
400
  };
@@ -354,6 +430,89 @@ export async function createDrizzlePgliteAdapter(
354
430
  };
355
431
  }
356
432
 
433
+ /**
434
+ * Create InMemory adapter (no migrations required).
435
+ */
436
+ export async function createInMemoryAdapter(
437
+ config: InMemoryAdapterConfig,
438
+ schemas: SchemaConfig[],
439
+ ): Promise<AdapterFactoryResult<InMemoryAdapterConfig>> {
440
+ const adapter = new InMemoryAdapter(config.options);
441
+
442
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
443
+ const ormMap = new Map<string | null, SimpleQueryInterface<any, any>>();
444
+ for (const { schema, namespace } of schemas) {
445
+ const orm = adapter.createQueryEngine(schema, namespace);
446
+ ormMap.set(namespace, orm);
447
+ }
448
+
449
+ const resetDatabase = async () => {
450
+ await adapter.reset();
451
+ };
452
+
453
+ const cleanup = async () => {
454
+ await adapter.close();
455
+ };
456
+
457
+ const commonMethods = createCommonTestContextMethods(ormMap);
458
+
459
+ return {
460
+ testContext: {
461
+ get adapter() {
462
+ return adapter;
463
+ },
464
+ ...commonMethods,
465
+ resetDatabase,
466
+ cleanup,
467
+ },
468
+ get adapter() {
469
+ return adapter;
470
+ },
471
+ };
472
+ }
473
+
474
+ /**
475
+ * Create ModelChecker adapter (wraps the in-memory adapter).
476
+ */
477
+ export async function createModelCheckerAdapter(
478
+ config: ModelCheckerAdapterConfig,
479
+ schemas: SchemaConfig[],
480
+ ): Promise<AdapterFactoryResult<ModelCheckerAdapterConfig>> {
481
+ const baseAdapter = new InMemoryAdapter(config.options);
482
+ const adapter = new ModelCheckerAdapter(baseAdapter);
483
+
484
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
485
+ const ormMap = new Map<string | null, SimpleQueryInterface<any, any>>();
486
+ for (const { schema, namespace } of schemas) {
487
+ const orm = adapter.createQueryEngine(schema, namespace);
488
+ ormMap.set(namespace, orm);
489
+ }
490
+
491
+ const resetDatabase = async () => {
492
+ await baseAdapter.reset();
493
+ };
494
+
495
+ const cleanup = async () => {
496
+ await adapter.close();
497
+ };
498
+
499
+ const commonMethods = createCommonTestContextMethods(ormMap);
500
+
501
+ return {
502
+ testContext: {
503
+ get adapter() {
504
+ return adapter;
505
+ },
506
+ ...commonMethods,
507
+ resetDatabase,
508
+ cleanup,
509
+ },
510
+ get adapter() {
511
+ return adapter;
512
+ },
513
+ };
514
+ }
515
+
357
516
  /**
358
517
  * Create adapter based on configuration
359
518
  * Supports multiple schemas with separate namespaces
@@ -368,6 +527,10 @@ export async function createAdapter<T extends SupportedAdapter>(
368
527
  return createKyselyPgliteAdapter(adapterConfig, schemas) as Promise<AdapterFactoryResult<T>>;
369
528
  } else if (adapterConfig.type === "drizzle-pglite") {
370
529
  return createDrizzlePgliteAdapter(adapterConfig, schemas) as Promise<AdapterFactoryResult<T>>;
530
+ } else if (adapterConfig.type === "in-memory") {
531
+ return createInMemoryAdapter(adapterConfig, schemas) as Promise<AdapterFactoryResult<T>>;
532
+ } else if (adapterConfig.type === "model-checker") {
533
+ return createModelCheckerAdapter(adapterConfig, schemas) as Promise<AdapterFactoryResult<T>>;
371
534
  }
372
535
 
373
536
  throw new Error(`Unsupported adapter type: ${(adapterConfig as SupportedAdapter).type}`);
@@ -7,7 +7,7 @@ import { z } from "zod";
7
7
  import { buildDatabaseFragmentsTest } from "./db-test";
8
8
 
9
9
  // Test schema with users table
10
- const userSchema = schema((s) => {
10
+ const userSchema = schema("user", (s) => {
11
11
  return s.addTable("users", (t) => {
12
12
  return t
13
13
  .addColumn("id", idColumn())
@@ -18,7 +18,7 @@ const userSchema = schema((s) => {
18
18
  });
19
19
 
20
20
  // Test schema with posts table
21
- const postSchema = schema((s) => {
21
+ const postSchema = schema("post", (s) => {
22
22
  return s.addTable("posts", (t) => {
23
23
  return t
24
24
  .addColumn("id", idColumn())
package/src/db-test.ts CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  import type { DatabaseAdapter } from "@fragno-dev/db";
17
17
  import type { SimpleQueryInterface } from "@fragno-dev/db/query";
18
18
  import type { BaseTestContext } from ".";
19
+ import { drainDurableHooks } from "./durable-hooks";
19
20
 
20
21
  // BoundServices is an internal type that strips 'this' parameters from service methods
21
22
  // It's used to represent services after they've been bound to a context
@@ -303,14 +304,26 @@ export class DatabaseFragmentsTestBuilder<
303
304
 
304
305
  // Extract schema and namespace from definition by calling dependencies with a mock adapter
305
306
  let schema: AnySchema | undefined;
306
- let namespace: string | undefined;
307
+ let namespace: string | null | undefined;
307
308
 
308
309
  if (definition.dependencies) {
309
310
  try {
310
311
  // Create a mock adapter to extract the schema
311
312
  const mockAdapter = {
312
313
  createQueryEngine: () => ({ schema: null }),
314
+ getSchemaVersion: async () => undefined,
315
+ namingStrategy: {
316
+ namespaceScope: "suffix",
317
+ namespaceToSchema: (value: string) => value,
318
+ tableName: (logicalTable: string, ns: string | null) =>
319
+ ns ? `${logicalTable}_${ns}` : logicalTable,
320
+ columnName: (logicalColumn: string) => logicalColumn,
321
+ indexName: (logicalIndex: string) => logicalIndex,
322
+ uniqueIndexName: (logicalIndex: string) => logicalIndex,
323
+ foreignKeyName: ({ referenceName }: { referenceName: string }) => referenceName,
324
+ },
313
325
  contextStorage: { run: (_data: unknown, fn: () => unknown) => fn() },
326
+ close: async () => {},
314
327
  };
315
328
 
316
329
  // Use the actual config from the builder instead of an empty mock
@@ -353,7 +366,7 @@ export class DatabaseFragmentsTestBuilder<
353
366
  );
354
367
  }
355
368
 
356
- if (!namespace) {
369
+ if (namespace === undefined) {
357
370
  throw new Error(
358
371
  `Fragment '${definition.name}' does not have a namespace in dependencies. ` +
359
372
  `This should be automatically provided by withDatabase().`,
@@ -374,7 +387,6 @@ export class DatabaseFragmentsTestBuilder<
374
387
  });
375
388
  }
376
389
 
377
- // Create adapter with all schemas
378
390
  const { testContext, adapter } = await createAdapter(adapterConfig, schemaConfigs);
379
391
 
380
392
  // Helper to create fragments with service wiring
@@ -510,9 +522,47 @@ export class DatabaseFragmentsTestBuilder<
510
522
  throw new Error("At least one fragment must be added");
511
523
  }
512
524
 
525
+ const originalCleanup = testContext.cleanup;
526
+ const cleanup = async () => {
527
+ let drainError: unknown;
528
+ let cleanupError: unknown;
529
+
530
+ for (const result of fragmentResults) {
531
+ try {
532
+ await drainDurableHooks(result.fragment);
533
+ } catch (error) {
534
+ if (!drainError) {
535
+ drainError = error;
536
+ }
537
+ }
538
+ }
539
+
540
+ try {
541
+ await originalCleanup();
542
+ } catch (error) {
543
+ cleanupError = error;
544
+ }
545
+
546
+ if (drainError && cleanupError) {
547
+ throw new AggregateError(
548
+ [drainError, cleanupError],
549
+ "Failed to drain durable hooks and clean up test context",
550
+ );
551
+ }
552
+
553
+ if (drainError) {
554
+ throw drainError;
555
+ }
556
+
557
+ if (cleanupError) {
558
+ throw cleanupError;
559
+ }
560
+ };
561
+
513
562
  const finalTestContext = {
514
563
  ...testContext,
515
564
  resetDatabase,
565
+ cleanup,
516
566
  adapter,
517
567
  inContext: firstFragment.inContext.bind(firstFragment),
518
568
  };
@@ -0,0 +1,13 @@
1
+ import {
2
+ createDurableHooksProcessor,
3
+ type AnyFragnoInstantiatedDatabaseFragment,
4
+ } from "@fragno-dev/db";
5
+ import type { AnyFragnoInstantiatedFragment } from "@fragno-dev/core";
6
+
7
+ export async function drainDurableHooks(fragment: AnyFragnoInstantiatedFragment): Promise<void> {
8
+ const processor = createDurableHooksProcessor(fragment as AnyFragnoInstantiatedDatabaseFragment);
9
+ if (!processor) {
10
+ return;
11
+ }
12
+ await processor.drain();
13
+ }
package/src/index.test.ts CHANGED
@@ -1,19 +1,20 @@
1
1
  import { describe, expect, expectTypeOf, it } from "vitest";
2
2
  import { column, idColumn, schema } from "@fragno-dev/db/schema";
3
- import { withDatabase } from "@fragno-dev/db";
3
+ import { Cursor, withDatabase } from "@fragno-dev/db";
4
4
  import { defineFragment } from "@fragno-dev/core";
5
5
  import { instantiate } from "@fragno-dev/core";
6
6
  import { buildDatabaseFragmentsTest } from "./db-test";
7
7
  import type { ExtractFragmentServices } from "@fragno-dev/core/route";
8
8
 
9
9
  // Test schema with multiple versions
10
- const testSchema = schema((s) => {
10
+ const testSchema = schema("test", (s) => {
11
11
  return s
12
12
  .addTable("users", (t) => {
13
13
  return t
14
14
  .addColumn("id", idColumn())
15
15
  .addColumn("name", column("string"))
16
16
  .addColumn("email", column("string"))
17
+ .createIndex("idx_users_name", ["name"])
17
18
  .createIndex("idx_users_all", ["id"]); // Index for querying
18
19
  })
19
20
  .alterTable("users", (t) => {
@@ -36,6 +37,18 @@ const testFragmentDef = defineFragment<{}>("test-fragment")
36
37
  );
37
38
  return users.map((u) => ({ ...u, id: u.id.valueOf() }));
38
39
  },
40
+ getUsersWithCursor: async (cursor?: Cursor | string) => {
41
+ return deps.db.findWithCursor("users", (b) => {
42
+ let builder = b
43
+ .whereIndex("idx_users_name")
44
+ .orderByIndex("idx_users_name", "asc")
45
+ .pageSize(2);
46
+ if (cursor) {
47
+ builder = builder.after(cursor);
48
+ }
49
+ return builder;
50
+ });
51
+ },
39
52
  };
40
53
  })
41
54
  .build();
@@ -83,6 +96,70 @@ describe("buildDatabaseFragmentsTest", () => {
83
96
  ).rejects.toThrow("Fragment 'non-db-fragment' does not have a database schema");
84
97
  });
85
98
 
99
+ it("should support the in-memory adapter", async () => {
100
+ const { fragments, test } = await buildDatabaseFragmentsTest()
101
+ .withTestAdapter({ type: "in-memory" })
102
+ .withFragment("test", instantiate(testFragmentDef).withConfig({}).withRoutes([]))
103
+ .build();
104
+
105
+ const user = await fragments.test.services.createUser({
106
+ name: "Memory User",
107
+ email: "memory@example.com",
108
+ age: 31,
109
+ });
110
+
111
+ expect(user).toMatchObject({
112
+ id: expect.any(String),
113
+ name: "Memory User",
114
+ email: "memory@example.com",
115
+ age: 31,
116
+ });
117
+
118
+ const users = await fragments.test.services.getUsers();
119
+ expect(users).toHaveLength(1);
120
+ expect(users[0]).toMatchObject(user);
121
+
122
+ await test.cleanup();
123
+ });
124
+
125
+ it("should support cursor pagination with in-memory adapter", async () => {
126
+ const { fragments, test } = await buildDatabaseFragmentsTest()
127
+ .withTestAdapter({ type: "in-memory" })
128
+ .withFragment("test", instantiate(testFragmentDef).withConfig({}).withRoutes([]))
129
+ .build();
130
+
131
+ const fragment = fragments.test;
132
+
133
+ const users = [
134
+ { name: "Alice", email: "alice@example.com" },
135
+ { name: "Brett", email: "brett@example.com" },
136
+ { name: "Cora", email: "cora@example.com" },
137
+ { name: "Dylan", email: "dylan@example.com" },
138
+ { name: "Emma", email: "emma@example.com" },
139
+ ];
140
+
141
+ for (const user of users) {
142
+ await fragment.services.createUser(user);
143
+ }
144
+
145
+ const firstPage = await fragment.services.getUsersWithCursor();
146
+ expect(firstPage.items.map((item) => item.name)).toEqual(["Alice", "Brett"]);
147
+ expect(firstPage.hasNextPage).toBe(true);
148
+ expect(firstPage.cursor).toBeDefined();
149
+
150
+ const secondPage = await fragment.services.getUsersWithCursor(firstPage.cursor);
151
+ expect(secondPage.items.map((item) => item.name)).toEqual(["Cora", "Dylan"]);
152
+ expect(secondPage.hasNextPage).toBe(true);
153
+ expect(secondPage.cursor).toBeDefined();
154
+
155
+ const thirdPage = await fragment.services.getUsersWithCursor(secondPage.cursor);
156
+ expect(thirdPage.items.map((item) => item.name)).toEqual(["Emma"]);
157
+ expect(thirdPage.hasNextPage).toBe(false);
158
+ expect(thirdPage.cursor).toBeUndefined();
159
+
160
+ await test.cleanup();
161
+ });
162
+
86
163
  it("should reset database by truncating tables", async () => {
87
164
  const { fragments, test } = await buildDatabaseFragmentsTest()
88
165
  .withTestAdapter({ type: "kysely-sqlite" })
@@ -149,7 +226,7 @@ describe("buildDatabaseFragmentsTest", () => {
149
226
 
150
227
  it("should work with multi-table schema", async () => {
151
228
  // Simplified auth schema for testing
152
- const authSchema = schema((s) => {
229
+ const authSchema = schema("auth", (s) => {
153
230
  return s
154
231
  .addTable("user", (t) => {
155
232
  return t
@@ -214,7 +291,7 @@ describe("buildDatabaseFragmentsTest", () => {
214
291
 
215
292
  describe("multi-fragment tests", () => {
216
293
  // Create two different schemas
217
- const userSchema = schema((s) => {
294
+ const userSchema = schema("user", (s) => {
218
295
  return s.addTable("user", (t) => {
219
296
  return t
220
297
  .addColumn("id", idColumn())
@@ -224,7 +301,7 @@ describe("multi-fragment tests", () => {
224
301
  });
225
302
  });
226
303
 
227
- const postSchema = schema((s) => {
304
+ const postSchema = schema("post", (s) => {
228
305
  return s.addTable("post", (t) => {
229
306
  return t
230
307
  .addColumn("id", idColumn())
@@ -325,7 +402,7 @@ describe("multi-fragment tests", () => {
325
402
 
326
403
  describe("ExtractFragmentServices", () => {
327
404
  it("extracts provided services from database fragment with new API", () => {
328
- const testSchema = schema((s) => s);
405
+ const testSchema = schema("test", (s) => s);
329
406
 
330
407
  interface ITestService {
331
408
  doSomething: (input: string) => Promise<string>;
@@ -352,7 +429,7 @@ describe("ExtractFragmentServices", () => {
352
429
  });
353
430
 
354
431
  it("merges base services and provided services in database fragment", () => {
355
- const testSchema = schema((s) => s);
432
+ const testSchema = schema("test", (s) => s);
356
433
 
357
434
  const fragment = defineFragment<{}>("test-db-fragment")
358
435
  .extend(withDatabase(testSchema))