@fragno-dev/test 1.0.2 → 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 (63) hide show
  1. package/.turbo/turbo-build.log +41 -15
  2. package/CHANGELOG.md +112 -0
  3. package/dist/adapters.d.ts +20 -5
  4. package/dist/adapters.d.ts.map +1 -1
  5. package/dist/adapters.js +20 -210
  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 +236 -57
  10. package/dist/db-test.js.map +1 -1
  11. package/dist/durable-hooks.d.ts +11 -0
  12. package/dist/durable-hooks.d.ts.map +1 -0
  13. package/dist/durable-hooks.js +17 -0
  14. package/dist/durable-hooks.js.map +1 -0
  15. package/dist/index.d.ts +9 -5
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +6 -2
  18. package/dist/index.js.map +1 -1
  19. package/dist/model-checker-actors.d.ts +41 -0
  20. package/dist/model-checker-actors.d.ts.map +1 -0
  21. package/dist/model-checker-actors.js +406 -0
  22. package/dist/model-checker-actors.js.map +1 -0
  23. package/dist/model-checker-adapter.d.ts +32 -0
  24. package/dist/model-checker-adapter.d.ts.map +1 -0
  25. package/dist/model-checker-adapter.js +109 -0
  26. package/dist/model-checker-adapter.js.map +1 -0
  27. package/dist/model-checker.d.ts +128 -0
  28. package/dist/model-checker.d.ts.map +1 -0
  29. package/dist/model-checker.js +443 -0
  30. package/dist/model-checker.js.map +1 -0
  31. package/dist/test-adapters/drizzle-pglite.js +116 -0
  32. package/dist/test-adapters/drizzle-pglite.js.map +1 -0
  33. package/dist/test-adapters/in-memory.js +39 -0
  34. package/dist/test-adapters/in-memory.js.map +1 -0
  35. package/dist/test-adapters/kysely-pglite.js +105 -0
  36. package/dist/test-adapters/kysely-pglite.js.map +1 -0
  37. package/dist/test-adapters/kysely-sqlite.js +87 -0
  38. package/dist/test-adapters/kysely-sqlite.js.map +1 -0
  39. package/dist/test-adapters/model-checker.js +41 -0
  40. package/dist/test-adapters/model-checker.js.map +1 -0
  41. package/dist/tsconfig.tsbuildinfo +1 -0
  42. package/package.json +34 -34
  43. package/src/adapter-conformance.test.ts +324 -0
  44. package/src/adapters.ts +52 -320
  45. package/src/db-roundtrip-guard.test.ts +206 -0
  46. package/src/db-test.test.ts +133 -79
  47. package/src/db-test.ts +583 -99
  48. package/src/durable-hooks.test.ts +58 -0
  49. package/src/durable-hooks.ts +28 -0
  50. package/src/index.test.ts +250 -89
  51. package/src/index.ts +45 -6
  52. package/src/model-checker-actors.test.ts +81 -0
  53. package/src/model-checker-actors.ts +643 -0
  54. package/src/model-checker-adapter.ts +201 -0
  55. package/src/model-checker.test.ts +402 -0
  56. package/src/model-checker.ts +800 -0
  57. package/src/test-adapters/drizzle-pglite.ts +162 -0
  58. package/src/test-adapters/in-memory.ts +56 -0
  59. package/src/test-adapters/kysely-pglite.ts +151 -0
  60. package/src/test-adapters/kysely-sqlite.ts +119 -0
  61. package/src/test-adapters/model-checker.ts +58 -0
  62. package/tsconfig.json +1 -1
  63. package/vitest.config.ts +1 -0
package/src/adapters.ts CHANGED
@@ -1,50 +1,58 @@
1
- import { Kysely } from "kysely";
2
- import { SQLocalKysely } from "sqlocal/kysely";
3
- import { KyselyPGlite } from "kysely-pglite";
4
- import { drizzle } from "drizzle-orm/pglite";
5
- import { PGlite } from "@electric-sql/pglite";
6
- import { KyselyAdapter } from "@fragno-dev/db/adapters/kysely";
7
- import { DrizzleAdapter } from "@fragno-dev/db/adapters/drizzle";
8
- import type { AnySchema } from "@fragno-dev/db/schema";
9
1
  import type { DatabaseAdapter } from "@fragno-dev/db/adapters";
10
- import { rm } from "node:fs/promises";
11
- import { existsSync } from "node:fs";
12
- import type { BaseTestContext } from ".";
13
- import { createCommonTestContextMethods } from ".";
14
- import { PGLiteDriverConfig, SQLocalDriverConfig } from "@fragno-dev/db/drivers";
15
- import { internalFragmentDef } from "@fragno-dev/db";
2
+ import type { InMemoryAdapterOptions } from "@fragno-dev/db/adapters/in-memory";
3
+ import type { UnitOfWorkConfig } from "@fragno-dev/db/adapters/sql";
16
4
  import type { SimpleQueryInterface } from "@fragno-dev/db/query";
5
+ import type { AnySchema } from "@fragno-dev/db/schema";
6
+ import type { drizzle } from "drizzle-orm/pglite";
7
+ import type { Kysely } from "kysely";
8
+
9
+ import type { BaseTestContext } from ".";
17
10
 
18
- // Adapter configuration types
19
11
  export interface KyselySqliteAdapter {
20
12
  type: "kysely-sqlite";
13
+ uowConfig?: UnitOfWorkConfig;
21
14
  }
22
15
 
23
16
  export interface KyselyPgliteAdapter {
24
17
  type: "kysely-pglite";
25
18
  databasePath?: string;
19
+ uowConfig?: UnitOfWorkConfig;
26
20
  }
27
21
 
28
22
  export interface DrizzlePgliteAdapter {
29
23
  type: "drizzle-pglite";
30
24
  databasePath?: string;
25
+ uowConfig?: UnitOfWorkConfig;
31
26
  }
32
27
 
33
- export type SupportedAdapter = KyselySqliteAdapter | KyselyPgliteAdapter | DrizzlePgliteAdapter;
28
+ export interface InMemoryAdapterConfig {
29
+ type: "in-memory";
30
+ options?: InMemoryAdapterOptions;
31
+ uowConfig?: UnitOfWorkConfig;
32
+ }
33
+
34
+ export interface ModelCheckerAdapterConfig {
35
+ type: "model-checker";
36
+ options?: InMemoryAdapterOptions;
37
+ }
38
+
39
+ export type SupportedAdapter =
40
+ | KyselySqliteAdapter
41
+ | KyselyPgliteAdapter
42
+ | DrizzlePgliteAdapter
43
+ | InMemoryAdapterConfig
44
+ | ModelCheckerAdapterConfig;
34
45
 
35
- // Schema configuration for multi-schema adapters
36
46
  export interface SchemaConfig {
37
47
  schema: AnySchema;
38
- namespace: string;
48
+ namespace: string | null;
39
49
  migrateToVersion?: number;
40
50
  }
41
51
 
42
- // Internal test context extends BaseTestContext with getOrm (not exposed publicly)
43
52
  interface InternalTestContext extends BaseTestContext {
44
- getOrm: <TSchema extends AnySchema>(namespace: string) => SimpleQueryInterface<TSchema>;
53
+ getOrm: <TSchema extends AnySchema>(namespace: string | null) => SimpleQueryInterface<TSchema>;
45
54
  }
46
55
 
47
- // Conditional return types based on adapter (adapter-specific properties only)
48
56
  export type AdapterContext<T extends SupportedAdapter> = T extends
49
57
  | KyselySqliteAdapter
50
58
  | KyselyPgliteAdapter
@@ -55,320 +63,44 @@ export type AdapterContext<T extends SupportedAdapter> = T extends
55
63
  ? {
56
64
  readonly drizzle: ReturnType<typeof drizzle<any>>; // eslint-disable-line @typescript-eslint/no-explicit-any
57
65
  }
58
- : never;
66
+ : T extends InMemoryAdapterConfig | ModelCheckerAdapterConfig
67
+ ? {}
68
+ : never;
59
69
 
60
- // Factory function return type
61
- interface AdapterFactoryResult<T extends SupportedAdapter> {
70
+ export interface AdapterFactoryResult<T extends SupportedAdapter> {
62
71
  testContext: InternalTestContext & AdapterContext<T>;
63
72
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
64
73
  adapter: DatabaseAdapter<any>;
65
74
  }
66
75
 
67
- /**
68
- * Create Kysely + SQLite adapter using SQLocalKysely (always in-memory)
69
- * Supports multiple schemas with separate namespaces
70
- */
71
- export async function createKyselySqliteAdapter(
72
- _config: KyselySqliteAdapter,
73
- schemas: SchemaConfig[],
74
- ): Promise<AdapterFactoryResult<KyselySqliteAdapter>> {
75
- // Helper to create a new database instance and run migrations for all schemas
76
- const createDatabase = async () => {
77
- // Create SQLocalKysely instance (always in-memory for tests)
78
- const { dialect } = new SQLocalKysely(":memory:");
79
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
80
- const kysely = new Kysely<any>({
81
- dialect,
82
- });
83
-
84
- // Create KyselyAdapter
85
- const adapter = new KyselyAdapter({
86
- dialect,
87
- driverConfig: new SQLocalDriverConfig(),
88
- });
89
-
90
- // Run migrations for all schemas in order
91
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
92
- const ormMap = new Map<string, SimpleQueryInterface<any, any>>();
93
-
94
- for (const { schema, namespace, migrateToVersion } of schemas) {
95
- // Run migrations
96
- const preparedMigrations = adapter.prepareMigrations(schema, namespace);
97
- if (migrateToVersion !== undefined) {
98
- await preparedMigrations.execute(0, migrateToVersion, { updateVersionInMigration: false });
99
- } else {
100
- await preparedMigrations.execute(0, schema.version, { updateVersionInMigration: false });
101
- }
102
-
103
- // Create ORM instance and store in map
104
- const orm = adapter.createQueryEngine(schema, namespace);
105
- ormMap.set(namespace, orm);
106
- }
107
-
108
- return { kysely, adapter, ormMap };
109
- };
110
-
111
- // Create initial database
112
- let { kysely, adapter, ormMap } = await createDatabase();
113
-
114
- // Reset database function - truncates all tables (only supported for in-memory databases)
115
- const resetDatabase = async () => {
116
- // For SQLite, truncate all tables by deleting rows
117
- for (const { schema, namespace } of schemas) {
118
- const mapper = adapter.createTableNameMapper(namespace);
119
- for (const tableName of Object.keys(schema.tables)) {
120
- const physicalTableName = mapper.toPhysical(tableName);
121
- await kysely.deleteFrom(physicalTableName).execute();
122
- }
123
- }
124
- };
125
-
126
- // Cleanup function - closes connections (no files to delete for in-memory)
127
- const cleanup = async () => {
128
- await kysely.destroy();
129
- };
130
-
131
- const commonMethods = createCommonTestContextMethods(ormMap);
132
-
133
- return {
134
- testContext: {
135
- get kysely() {
136
- return kysely;
137
- },
138
- get adapter() {
139
- return adapter;
140
- },
141
- ...commonMethods,
142
- resetDatabase,
143
- cleanup,
144
- },
145
- get adapter() {
146
- return adapter;
147
- },
148
- };
149
- }
150
-
151
- /**
152
- * Create Kysely + PGLite adapter using kysely-pglite
153
- * Supports multiple schemas with separate namespaces
154
- */
155
- export async function createKyselyPgliteAdapter(
156
- config: KyselyPgliteAdapter,
157
- schemas: SchemaConfig[],
158
- ): Promise<AdapterFactoryResult<KyselyPgliteAdapter>> {
159
- const databasePath = config.databasePath;
160
-
161
- // Helper to create a new database instance and run migrations for all schemas
162
- const createDatabase = async () => {
163
- // Create KyselyPGlite instance
164
- const kyselyPglite = await KyselyPGlite.create(databasePath);
165
-
166
- // Create Kysely instance with PGlite dialect
167
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
168
- const kysely = new Kysely<any>({
169
- dialect: kyselyPglite.dialect,
170
- });
171
-
172
- // Create KyselyAdapter
173
- const adapter = new KyselyAdapter({
174
- dialect: kyselyPglite.dialect,
175
- driverConfig: new PGLiteDriverConfig(),
176
- });
177
-
178
- // Run migrations for all schemas in order
179
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
180
- const ormMap = new Map<string, SimpleQueryInterface<any, any>>();
181
-
182
- for (const { schema, namespace, migrateToVersion } of schemas) {
183
- // Run migrations
184
- const preparedMigrations = adapter.prepareMigrations(schema, namespace);
185
- if (migrateToVersion !== undefined) {
186
- await preparedMigrations.execute(0, migrateToVersion, { updateVersionInMigration: false });
187
- } else {
188
- await preparedMigrations.execute(0, schema.version, { updateVersionInMigration: false });
189
- }
190
-
191
- // Create ORM instance and store in map
192
- const orm = adapter.createQueryEngine(schema, namespace);
193
- ormMap.set(namespace, orm);
194
- }
195
-
196
- return { kysely, adapter, kyselyPglite, ormMap };
197
- };
198
-
199
- // Create initial database
200
- const { kysely, adapter, kyselyPglite, ormMap } = await createDatabase();
201
-
202
- // Reset database function - truncates all tables (only supported for in-memory databases)
203
- const resetDatabase = async () => {
204
- if (databasePath && databasePath !== ":memory:") {
205
- throw new Error("resetDatabase is only supported for in-memory databases");
206
- }
207
-
208
- // Truncate all tables
209
- for (const { schema, namespace } of schemas) {
210
- const mapper = adapter.createTableNameMapper(namespace);
211
- for (const tableName of Object.keys(schema.tables)) {
212
- const physicalTableName = mapper.toPhysical(tableName);
213
- await kysely.deleteFrom(physicalTableName).execute();
214
- }
215
- }
216
- };
217
-
218
- // Cleanup function - closes connections and deletes database directory
219
- const cleanup = async () => {
220
- await kysely.destroy();
221
-
222
- try {
223
- await kyselyPglite.client.close();
224
- } catch {
225
- // Ignore if already closed
226
- }
227
-
228
- // Delete the database directory if it exists and is a file path
229
- if (databasePath && databasePath !== ":memory:" && existsSync(databasePath)) {
230
- await rm(databasePath, { recursive: true, force: true });
231
- }
232
- };
233
-
234
- const commonMethods = createCommonTestContextMethods(ormMap);
235
-
236
- return {
237
- testContext: {
238
- get kysely() {
239
- return kysely;
240
- },
241
- get adapter() {
242
- return adapter;
243
- },
244
- ...commonMethods,
245
- resetDatabase,
246
- cleanup,
247
- },
248
- get adapter() {
249
- return adapter;
250
- },
251
- };
252
- }
253
-
254
- /**
255
- * Create Drizzle + PGLite adapter using drizzle-orm/pglite
256
- * Supports multiple schemas with separate namespaces
257
- */
258
- export async function createDrizzlePgliteAdapter(
259
- config: DrizzlePgliteAdapter,
260
- schemas: SchemaConfig[],
261
- ): Promise<AdapterFactoryResult<DrizzlePgliteAdapter>> {
262
- const databasePath = config.databasePath;
263
-
264
- // Helper to create a new database instance and run migrations for all schemas
265
- const createDatabase = async () => {
266
- const pglite = new PGlite(databasePath);
267
-
268
- const { dialect } = new KyselyPGlite(pglite);
269
-
270
- const adapter = new DrizzleAdapter({
271
- dialect,
272
- driverConfig: new PGLiteDriverConfig(),
273
- });
274
-
275
- // Run migrations for all schemas
276
- // 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
- }
287
-
288
- for (const { schema, namespace, migrateToVersion } of schemas) {
289
- const preparedMigrations = adapter.prepareMigrations(schema, namespace);
290
- if (migrateToVersion !== undefined) {
291
- await preparedMigrations.execute(0, migrateToVersion, { updateVersionInMigration: false });
292
- } else {
293
- await preparedMigrations.execute(0, schema.version, { updateVersionInMigration: false });
294
- }
295
-
296
- // Create ORM instance and store in map
297
- const orm = adapter.createQueryEngine(schema, namespace);
298
- ormMap.set(namespace, orm);
299
- }
300
-
301
- // Create Drizzle instance for backward compatibility (if needed)
302
- const db = drizzle(pglite) as any; // eslint-disable-line @typescript-eslint/no-explicit-any
303
-
304
- return { drizzle: db, adapter, pglite, ormMap };
305
- };
306
-
307
- // Create initial database
308
- const { drizzle: drizzleDb, adapter, ormMap } = await createDatabase();
309
-
310
- // Reset database function - truncates all tables (only supported for in-memory databases)
311
- const resetDatabase = async () => {
312
- if (databasePath && databasePath !== ":memory:") {
313
- throw new Error("resetDatabase is only supported for in-memory databases");
314
- }
315
-
316
- // 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}"`);
322
- }
323
- }
324
- };
325
-
326
- // Cleanup function - closes connections and deletes database directory
327
- const cleanup = async () => {
328
- // Close the adapter (which will handle closing the underlying database connection)
329
- await adapter.close();
330
-
331
- // Delete the database directory if it exists and is a file path
332
- if (databasePath && databasePath !== ":memory:" && existsSync(databasePath)) {
333
- await rm(databasePath, { recursive: true, force: true });
334
- }
335
- };
336
-
337
- const commonMethods = createCommonTestContextMethods(ormMap);
338
-
339
- return {
340
- testContext: {
341
- get drizzle() {
342
- return drizzleDb;
343
- },
344
- get adapter() {
345
- return adapter;
346
- },
347
- ...commonMethods,
348
- resetDatabase,
349
- cleanup,
350
- },
351
- get adapter() {
352
- return adapter;
353
- },
354
- };
355
- }
356
-
357
- /**
358
- * Create adapter based on configuration
359
- * Supports multiple schemas with separate namespaces
360
- */
361
76
  export async function createAdapter<T extends SupportedAdapter>(
362
77
  adapterConfig: T,
363
78
  schemas: SchemaConfig[],
364
79
  ): Promise<AdapterFactoryResult<T>> {
365
80
  if (adapterConfig.type === "kysely-sqlite") {
81
+ const { createKyselySqliteAdapter } = await import("./test-adapters/kysely-sqlite");
366
82
  return createKyselySqliteAdapter(adapterConfig, schemas) as Promise<AdapterFactoryResult<T>>;
367
- } else if (adapterConfig.type === "kysely-pglite") {
83
+ }
84
+
85
+ if (adapterConfig.type === "kysely-pglite") {
86
+ const { createKyselyPgliteAdapter } = await import("./test-adapters/kysely-pglite");
368
87
  return createKyselyPgliteAdapter(adapterConfig, schemas) as Promise<AdapterFactoryResult<T>>;
369
- } else if (adapterConfig.type === "drizzle-pglite") {
88
+ }
89
+
90
+ if (adapterConfig.type === "drizzle-pglite") {
91
+ const { createDrizzlePgliteAdapter } = await import("./test-adapters/drizzle-pglite");
370
92
  return createDrizzlePgliteAdapter(adapterConfig, schemas) as Promise<AdapterFactoryResult<T>>;
371
93
  }
372
94
 
95
+ if (adapterConfig.type === "in-memory") {
96
+ const { createInMemoryAdapter } = await import("./test-adapters/in-memory");
97
+ return createInMemoryAdapter(adapterConfig, schemas) as Promise<AdapterFactoryResult<T>>;
98
+ }
99
+
100
+ if (adapterConfig.type === "model-checker") {
101
+ const { createModelCheckerAdapter } = await import("./test-adapters/model-checker");
102
+ return createModelCheckerAdapter(adapterConfig, schemas) as Promise<AdapterFactoryResult<T>>;
103
+ }
104
+
373
105
  throw new Error(`Unsupported adapter type: ${(adapterConfig as SupportedAdapter).type}`);
374
106
  }
@@ -0,0 +1,206 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { defineRoute } from "@fragno-dev/core/route";
4
+ import { column, idColumn, schema } from "@fragno-dev/db/schema";
5
+ import { z } from "zod";
6
+
7
+ import { defineFragment, instantiate } from "@fragno-dev/core";
8
+ import { withDatabase, type DatabaseRequestContext, type TxResult } from "@fragno-dev/db";
9
+
10
+ import { buildDatabaseFragmentsTest } from "./db-test";
11
+
12
+ const userSchema = schema("user-roundtrip", (s) => {
13
+ return s.addTable("users", (t) => {
14
+ return t
15
+ .addColumn("id", idColumn())
16
+ .addColumn("name", column("string"))
17
+ .addColumn("email", column("string"))
18
+ .createIndex("idx_users_all", ["id"]);
19
+ });
20
+ });
21
+
22
+ describe("dbRoundtripGuard", () => {
23
+ it("blocks multiple handlerTx().execute() calls by default", async () => {
24
+ const userFragmentDef = defineFragment<{}>("user-roundtrip-fragment")
25
+ .extend(withDatabase(userSchema))
26
+ .providesBaseService(() => ({}))
27
+ .build();
28
+
29
+ const multiRoundtripRoute = defineRoute({
30
+ method: "POST",
31
+ path: "/multi",
32
+ outputSchema: z.object({ ok: z.boolean() }),
33
+ handler: async function (this: DatabaseRequestContext, _input, { json }) {
34
+ await this.handlerTx()
35
+ .mutate(({ forSchema }) =>
36
+ forSchema(userSchema).create("users", {
37
+ name: "User 1",
38
+ email: "user1@example.com",
39
+ }),
40
+ )
41
+ .execute();
42
+
43
+ await this.handlerTx()
44
+ .mutate(({ forSchema }) =>
45
+ forSchema(userSchema).create("users", {
46
+ name: "User 2",
47
+ email: "user2@example.com",
48
+ }),
49
+ )
50
+ .execute();
51
+
52
+ return json({ ok: true });
53
+ },
54
+ });
55
+
56
+ const { fragments, test } = await buildDatabaseFragmentsTest()
57
+ .withTestAdapter({ type: "kysely-sqlite" })
58
+ .withFragment(
59
+ "user",
60
+ instantiate(userFragmentDef).withConfig({}).withRoutes([multiRoundtripRoute]),
61
+ )
62
+ .build();
63
+
64
+ const response = await fragments.user.callRoute("POST", "/multi");
65
+
66
+ expect(response.type).toBe("error");
67
+ if (response.type === "error") {
68
+ expect(response.error.code).toBe("DB_ROUNDTRIP_LIMIT_EXCEEDED");
69
+ }
70
+
71
+ await test.cleanup();
72
+ });
73
+
74
+ it("counts withServiceCalls retrieve roundtrips", async () => {
75
+ const userFragmentDef = defineFragment<{}>("user-roundtrip-fragment")
76
+ .extend(withDatabase(userSchema))
77
+ .providesBaseService(({ defineService }) =>
78
+ defineService({
79
+ listUsers: function () {
80
+ return this.serviceTx(userSchema)
81
+ .retrieve((uow) => uow.find("users"))
82
+ .build();
83
+ },
84
+ }),
85
+ )
86
+ .build();
87
+
88
+ const serviceRoutesFactory = ({
89
+ services,
90
+ }: {
91
+ services: { listUsers: () => TxResult<unknown, unknown> };
92
+ }) => [
93
+ defineRoute({
94
+ method: "POST",
95
+ path: "/service-multi",
96
+ outputSchema: z.object({ ok: z.boolean() }),
97
+ handler: async function (this: DatabaseRequestContext, _input, { json }) {
98
+ await this.handlerTx()
99
+ .withServiceCalls(() => [services.listUsers()])
100
+ .execute();
101
+
102
+ await this.handlerTx()
103
+ .withServiceCalls(() => [services.listUsers()])
104
+ .execute();
105
+
106
+ return json({ ok: true });
107
+ },
108
+ }),
109
+ ];
110
+
111
+ const { fragments, test } = await buildDatabaseFragmentsTest()
112
+ .withTestAdapter({ type: "kysely-sqlite" })
113
+ .withFragment(
114
+ "user",
115
+ instantiate(userFragmentDef).withConfig({}).withRoutes([serviceRoutesFactory]),
116
+ )
117
+ .build();
118
+
119
+ const response = await fragments.user.callRoute("POST", "/service-multi");
120
+
121
+ expect(response.type).toBe("error");
122
+ if (response.type === "error") {
123
+ expect(response.error.code).toBe("DB_ROUNDTRIP_LIMIT_EXCEEDED");
124
+ }
125
+
126
+ await test.cleanup();
127
+ });
128
+
129
+ it("allows retrieve-only then mutate-only when maxRoundtrips is 1", async () => {
130
+ const userFragmentDef = defineFragment<{}>("user-roundtrip-fragment")
131
+ .extend(withDatabase(userSchema))
132
+ .providesBaseService(() => ({}))
133
+ .build();
134
+
135
+ const splitRoundtripRoute = defineRoute({
136
+ method: "POST",
137
+ path: "/split",
138
+ outputSchema: z.object({ count: z.number() }),
139
+ handler: async function (this: DatabaseRequestContext, _input, { json }) {
140
+ const existing = await this.handlerTx()
141
+ .retrieve(({ forSchema }) => forSchema(userSchema).find("users"))
142
+ .transformRetrieve(([users]) => users)
143
+ .execute();
144
+
145
+ await this.handlerTx()
146
+ .mutate(({ forSchema }) =>
147
+ forSchema(userSchema).create("users", {
148
+ name: "User 3",
149
+ email: "user3@example.com",
150
+ }),
151
+ )
152
+ .execute();
153
+
154
+ return json({ count: existing.length });
155
+ },
156
+ });
157
+
158
+ const { fragments, test } = await buildDatabaseFragmentsTest()
159
+ .withTestAdapter({ type: "kysely-sqlite" })
160
+ .withDbRoundtripGuard({ maxRoundtrips: 1 })
161
+ .withFragment(
162
+ "user",
163
+ instantiate(userFragmentDef).withConfig({}).withRoutes([splitRoundtripRoute]),
164
+ )
165
+ .build();
166
+
167
+ const response = await fragments.user.callRoute("POST", "/split");
168
+
169
+ expect(response.type).toBe("json");
170
+ if (response.type === "json") {
171
+ expect(response.data.count).toBe(0);
172
+ }
173
+
174
+ await test.cleanup();
175
+ });
176
+
177
+ it("does not enforce the guard inside inContext", async () => {
178
+ const userFragmentDef = defineFragment<{}>("user-roundtrip-fragment")
179
+ .extend(withDatabase(userSchema))
180
+ .providesBaseService(() => ({}))
181
+ .build();
182
+
183
+ const { fragments, test } = await buildDatabaseFragmentsTest()
184
+ .withTestAdapter({ type: "kysely-sqlite" })
185
+ .withFragment("user", instantiate(userFragmentDef).withConfig({}))
186
+ .build();
187
+
188
+ const result = await fragments.user.fragment.inContext(
189
+ async function (this: DatabaseRequestContext) {
190
+ await this.handlerTx()
191
+ .retrieve(({ forSchema }) => forSchema(userSchema).find("users"))
192
+ .execute();
193
+
194
+ await this.handlerTx()
195
+ .retrieve(({ forSchema }) => forSchema(userSchema).find("users"))
196
+ .execute();
197
+
198
+ return "ok";
199
+ },
200
+ );
201
+
202
+ expect(result).toBe("ok");
203
+
204
+ await test.cleanup();
205
+ });
206
+ });