@fragno-dev/db 0.1.13 → 0.1.15

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 (178) hide show
  1. package/.turbo/turbo-build.log +179 -132
  2. package/CHANGELOG.md +30 -0
  3. package/dist/adapters/adapters.d.ts +27 -1
  4. package/dist/adapters/adapters.d.ts.map +1 -1
  5. package/dist/adapters/adapters.js.map +1 -1
  6. package/dist/adapters/drizzle/drizzle-adapter.d.ts +5 -1
  7. package/dist/adapters/drizzle/drizzle-adapter.d.ts.map +1 -1
  8. package/dist/adapters/drizzle/drizzle-adapter.js +15 -3
  9. package/dist/adapters/drizzle/drizzle-adapter.js.map +1 -1
  10. package/dist/adapters/drizzle/drizzle-query.js +7 -5
  11. package/dist/adapters/drizzle/drizzle-query.js.map +1 -1
  12. package/dist/adapters/drizzle/drizzle-uow-compiler.d.ts +0 -1
  13. package/dist/adapters/drizzle/drizzle-uow-compiler.d.ts.map +1 -1
  14. package/dist/adapters/drizzle/drizzle-uow-compiler.js +76 -44
  15. package/dist/adapters/drizzle/drizzle-uow-compiler.js.map +1 -1
  16. package/dist/adapters/drizzle/drizzle-uow-decoder.js +23 -16
  17. package/dist/adapters/drizzle/drizzle-uow-decoder.js.map +1 -1
  18. package/dist/adapters/drizzle/drizzle-uow-executor.js +18 -7
  19. package/dist/adapters/drizzle/drizzle-uow-executor.js.map +1 -1
  20. package/dist/adapters/drizzle/generate.d.ts +4 -1
  21. package/dist/adapters/drizzle/generate.d.ts.map +1 -1
  22. package/dist/adapters/drizzle/generate.js +11 -18
  23. package/dist/adapters/drizzle/generate.js.map +1 -1
  24. package/dist/adapters/drizzle/shared.d.ts +14 -1
  25. package/dist/adapters/drizzle/shared.d.ts.map +1 -0
  26. package/dist/adapters/kysely/kysely-adapter.d.ts +5 -1
  27. package/dist/adapters/kysely/kysely-adapter.d.ts.map +1 -1
  28. package/dist/adapters/kysely/kysely-adapter.js +14 -3
  29. package/dist/adapters/kysely/kysely-adapter.js.map +1 -1
  30. package/dist/adapters/kysely/kysely-query-builder.js +1 -1
  31. package/dist/adapters/kysely/kysely-query-compiler.js +3 -2
  32. package/dist/adapters/kysely/kysely-query-compiler.js.map +1 -1
  33. package/dist/adapters/kysely/kysely-query.d.ts +1 -0
  34. package/dist/adapters/kysely/kysely-query.d.ts.map +1 -1
  35. package/dist/adapters/kysely/kysely-query.js +28 -19
  36. package/dist/adapters/kysely/kysely-query.js.map +1 -1
  37. package/dist/adapters/kysely/kysely-shared.d.ts +14 -0
  38. package/dist/adapters/kysely/kysely-shared.d.ts.map +1 -0
  39. package/dist/adapters/kysely/kysely-shared.js +16 -1
  40. package/dist/adapters/kysely/kysely-shared.js.map +1 -1
  41. package/dist/adapters/kysely/kysely-uow-compiler.js +68 -16
  42. package/dist/adapters/kysely/kysely-uow-compiler.js.map +1 -1
  43. package/dist/adapters/kysely/kysely-uow-executor.js +8 -4
  44. package/dist/adapters/kysely/kysely-uow-executor.js.map +1 -1
  45. package/dist/adapters/kysely/migration/execute-base.js +1 -1
  46. package/dist/adapters/kysely/migration/execute-base.js.map +1 -1
  47. package/dist/db-fragment-definition-builder.d.ts +152 -0
  48. package/dist/db-fragment-definition-builder.d.ts.map +1 -0
  49. package/dist/db-fragment-definition-builder.js +137 -0
  50. package/dist/db-fragment-definition-builder.js.map +1 -0
  51. package/dist/fragments/internal-fragment.d.ts +19 -0
  52. package/dist/fragments/internal-fragment.d.ts.map +1 -0
  53. package/dist/fragments/internal-fragment.js +39 -0
  54. package/dist/fragments/internal-fragment.js.map +1 -0
  55. package/dist/migration-engine/generation-engine.d.ts.map +1 -1
  56. package/dist/migration-engine/generation-engine.js +35 -15
  57. package/dist/migration-engine/generation-engine.js.map +1 -1
  58. package/dist/mod.d.ts +8 -18
  59. package/dist/mod.d.ts.map +1 -1
  60. package/dist/mod.js +7 -34
  61. package/dist/mod.js.map +1 -1
  62. package/dist/node_modules/.pnpm/rou3@0.7.8/node_modules/rou3/dist/index.js +165 -0
  63. package/dist/node_modules/.pnpm/rou3@0.7.8/node_modules/rou3/dist/index.js.map +1 -0
  64. package/dist/packages/fragno/dist/api/bind-services.js +20 -0
  65. package/dist/packages/fragno/dist/api/bind-services.js.map +1 -0
  66. package/dist/packages/fragno/dist/api/error.js +48 -0
  67. package/dist/packages/fragno/dist/api/error.js.map +1 -0
  68. package/dist/packages/fragno/dist/api/fragment-definition-builder.js +320 -0
  69. package/dist/packages/fragno/dist/api/fragment-definition-builder.js.map +1 -0
  70. package/dist/packages/fragno/dist/api/fragment-instantiator.js +487 -0
  71. package/dist/packages/fragno/dist/api/fragment-instantiator.js.map +1 -0
  72. package/dist/packages/fragno/dist/api/fragno-response.js +73 -0
  73. package/dist/packages/fragno/dist/api/fragno-response.js.map +1 -0
  74. package/dist/packages/fragno/dist/api/internal/response-stream.js +81 -0
  75. package/dist/packages/fragno/dist/api/internal/response-stream.js.map +1 -0
  76. package/dist/packages/fragno/dist/api/internal/route.js +10 -0
  77. package/dist/packages/fragno/dist/api/internal/route.js.map +1 -0
  78. package/dist/packages/fragno/dist/api/mutable-request-state.js +97 -0
  79. package/dist/packages/fragno/dist/api/mutable-request-state.js.map +1 -0
  80. package/dist/packages/fragno/dist/api/request-context-storage.js +43 -0
  81. package/dist/packages/fragno/dist/api/request-context-storage.js.map +1 -0
  82. package/dist/packages/fragno/dist/api/request-input-context.js +118 -0
  83. package/dist/packages/fragno/dist/api/request-input-context.js.map +1 -0
  84. package/dist/packages/fragno/dist/api/request-middleware.js +83 -0
  85. package/dist/packages/fragno/dist/api/request-middleware.js.map +1 -0
  86. package/dist/packages/fragno/dist/api/request-output-context.js +119 -0
  87. package/dist/packages/fragno/dist/api/request-output-context.js.map +1 -0
  88. package/dist/packages/fragno/dist/api/route.js +17 -0
  89. package/dist/packages/fragno/dist/api/route.js.map +1 -0
  90. package/dist/packages/fragno/dist/internal/symbols.js +10 -0
  91. package/dist/packages/fragno/dist/internal/symbols.js.map +1 -0
  92. package/dist/query/cursor.d.ts +10 -2
  93. package/dist/query/cursor.d.ts.map +1 -1
  94. package/dist/query/cursor.js +11 -4
  95. package/dist/query/cursor.js.map +1 -1
  96. package/dist/query/execute-unit-of-work.d.ts +123 -0
  97. package/dist/query/execute-unit-of-work.d.ts.map +1 -0
  98. package/dist/query/execute-unit-of-work.js +184 -0
  99. package/dist/query/execute-unit-of-work.js.map +1 -0
  100. package/dist/query/query.d.ts +3 -3
  101. package/dist/query/query.d.ts.map +1 -1
  102. package/dist/query/result-transform.js +4 -2
  103. package/dist/query/result-transform.js.map +1 -1
  104. package/dist/query/retry-policy.d.ts +88 -0
  105. package/dist/query/retry-policy.d.ts.map +1 -0
  106. package/dist/query/retry-policy.js +61 -0
  107. package/dist/query/retry-policy.js.map +1 -0
  108. package/dist/query/unit-of-work.d.ts +171 -32
  109. package/dist/query/unit-of-work.d.ts.map +1 -1
  110. package/dist/query/unit-of-work.js +530 -133
  111. package/dist/query/unit-of-work.js.map +1 -1
  112. package/dist/schema/serialize.js +12 -7
  113. package/dist/schema/serialize.js.map +1 -1
  114. package/dist/with-database.d.ts +28 -0
  115. package/dist/with-database.d.ts.map +1 -0
  116. package/dist/with-database.js +34 -0
  117. package/dist/with-database.js.map +1 -0
  118. package/package.json +10 -3
  119. package/src/adapters/adapters.ts +30 -0
  120. package/src/adapters/drizzle/drizzle-adapter-pglite.test.ts +86 -17
  121. package/src/adapters/drizzle/drizzle-adapter-sqlite.test.ts +291 -7
  122. package/src/adapters/drizzle/drizzle-adapter.test.ts +3 -51
  123. package/src/adapters/drizzle/drizzle-adapter.ts +35 -7
  124. package/src/adapters/drizzle/drizzle-query.ts +25 -15
  125. package/src/adapters/drizzle/drizzle-uow-compiler-mysql.test.ts +1442 -0
  126. package/src/adapters/drizzle/drizzle-uow-compiler-sqlite.test.ts +1414 -0
  127. package/src/adapters/drizzle/drizzle-uow-compiler.test.ts +78 -61
  128. package/src/adapters/drizzle/drizzle-uow-compiler.ts +123 -42
  129. package/src/adapters/drizzle/drizzle-uow-decoder.ts +34 -27
  130. package/src/adapters/drizzle/drizzle-uow-executor.ts +41 -8
  131. package/src/adapters/drizzle/generate.test.ts +102 -269
  132. package/src/adapters/drizzle/generate.ts +12 -30
  133. package/src/adapters/drizzle/test-utils.ts +36 -5
  134. package/src/adapters/kysely/kysely-adapter-pglite.test.ts +66 -22
  135. package/src/adapters/kysely/kysely-adapter-sqlite.test.ts +156 -0
  136. package/src/adapters/kysely/kysely-adapter.ts +25 -2
  137. package/src/adapters/kysely/kysely-query-compiler.ts +3 -8
  138. package/src/adapters/kysely/kysely-query.ts +57 -37
  139. package/src/adapters/kysely/kysely-shared.ts +34 -0
  140. package/src/adapters/kysely/kysely-uow-compiler.test.ts +62 -74
  141. package/src/adapters/kysely/kysely-uow-compiler.ts +92 -24
  142. package/src/adapters/kysely/kysely-uow-executor.ts +26 -7
  143. package/src/adapters/kysely/kysely-uow-joins.test.ts +33 -50
  144. package/src/adapters/kysely/migration/execute-base.ts +1 -1
  145. package/src/db-fragment-definition-builder.test.ts +887 -0
  146. package/src/db-fragment-definition-builder.ts +506 -0
  147. package/src/db-fragment-instantiator.test.ts +467 -0
  148. package/src/db-fragment-integration.test.ts +408 -0
  149. package/src/fragments/internal-fragment.test.ts +160 -0
  150. package/src/fragments/internal-fragment.ts +85 -0
  151. package/src/migration-engine/generation-engine.test.ts +58 -15
  152. package/src/migration-engine/generation-engine.ts +78 -25
  153. package/src/mod.ts +35 -43
  154. package/src/query/cursor.test.ts +119 -0
  155. package/src/query/cursor.ts +17 -4
  156. package/src/query/execute-unit-of-work.test.ts +1310 -0
  157. package/src/query/execute-unit-of-work.ts +463 -0
  158. package/src/query/query.ts +4 -4
  159. package/src/query/result-transform.test.ts +129 -0
  160. package/src/query/result-transform.ts +4 -1
  161. package/src/query/retry-policy.test.ts +217 -0
  162. package/src/query/retry-policy.ts +141 -0
  163. package/src/query/unit-of-work-coordinator.test.ts +833 -0
  164. package/src/query/unit-of-work-types.test.ts +15 -2
  165. package/src/query/unit-of-work.test.ts +878 -200
  166. package/src/query/unit-of-work.ts +963 -321
  167. package/src/schema/serialize.ts +22 -11
  168. package/src/with-database.ts +140 -0
  169. package/tsdown.config.ts +1 -0
  170. package/dist/fragment.d.ts +0 -54
  171. package/dist/fragment.d.ts.map +0 -1
  172. package/dist/fragment.js +0 -92
  173. package/dist/fragment.js.map +0 -1
  174. package/dist/shared/settings-schema.js +0 -36
  175. package/dist/shared/settings-schema.js.map +0 -1
  176. package/src/fragment.test.ts +0 -341
  177. package/src/fragment.ts +0 -198
  178. package/src/shared/settings-schema.ts +0 -61
@@ -1,7 +1,8 @@
1
- import { mkdir, writeFile, rm } from "node:fs/promises";
1
+ import { mkdir, writeFile, rm, access } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import { generateSchema, type SupportedProvider } from "./generate";
4
4
  import type { Schema } from "../../schema/create";
5
+ import { settingsSchema } from "../../fragments/internal-fragment";
5
6
 
6
7
  /**
7
8
  * Writes a Fragno schema to a temporary TypeScript file and dynamically imports it.
@@ -34,20 +35,50 @@ export async function writeAndLoadSchema(
34
35
  // Generate unique schema file path
35
36
  const schemaFilePath = join(
36
37
  testDir,
37
- `test-schema-${Date.now()}-${Math.random().toString(36).slice(2, 9)}.ts`,
38
+ `test-schema-${testFileName}-${Date.now()}-${Math.random().toString(36).slice(2, 9)}.ts`,
38
39
  );
39
40
 
40
41
  // Generate and write the Drizzle schema to file
41
- // Use empty namespace for tests to avoid table name prefixing
42
- const drizzleSchemaTs = generateSchema([{ namespace: namespace ?? "", schema }], dialect);
42
+ // Always include settings schema first (as done in generation-engine.ts), then the test schema
43
+ // De-duplicate: if the test schema IS the settings schema, don't add it twice
44
+ const fragments: Array<{ namespace: string; schema: Schema }> = [
45
+ { namespace: "", schema: settingsSchema },
46
+ ];
47
+
48
+ if (schema !== settingsSchema) {
49
+ fragments.push({ namespace: namespace ?? "", schema });
50
+ }
51
+
52
+ const drizzleSchemaTs = generateSchema(fragments, dialect);
43
53
  await writeFile(schemaFilePath, drizzleSchemaTs, "utf-8");
44
54
 
55
+ // Ensure the file is accessible before importing (handle race conditions)
56
+ let retries = 0;
57
+ const maxRetries = 10;
58
+ while (retries < maxRetries) {
59
+ try {
60
+ await access(schemaFilePath);
61
+ break;
62
+ } catch {
63
+ if (retries === maxRetries - 1) {
64
+ throw new Error(`Schema file was not accessible after writing: ${schemaFilePath}`);
65
+ }
66
+ // Wait a bit before retrying
67
+ await new Promise((resolve) => setTimeout(resolve, 10));
68
+ retries++;
69
+ }
70
+ }
71
+
45
72
  // Dynamically import the generated schema (with cache busting)
46
73
  const schemaModule = await import(`${schemaFilePath}?t=${Date.now()}`);
47
74
 
48
75
  // Cleanup function to remove the test-specific directory
49
76
  const cleanup = async () => {
50
- await rm(testDir, { recursive: true, force: true });
77
+ try {
78
+ await rm(testDir, { recursive: true, force: true });
79
+ } catch {
80
+ // Ignore error if directory doesn't exist
81
+ }
51
82
  };
52
83
 
53
84
  return {
@@ -787,7 +787,7 @@ describe("KyselyAdapter PGLite", () => {
787
787
  });
788
788
  });
789
789
 
790
- it("should handle timestamps and timezones correctly", async () => {
790
+ it("should handle timestamps and time zones correctly", async () => {
791
791
  const queryEngine = adapter.createQueryEngine(testSchema, "test");
792
792
 
793
793
  // Create a user
@@ -826,7 +826,7 @@ describe("KyselyAdapter PGLite", () => {
826
826
  const specificDate = new Date("2024-06-15T14:30:00Z");
827
827
  expect(specificDate.toISOString()).toBe("2024-06-15T14:30:00.000Z");
828
828
 
829
- // Verify that dates from different timezones are handled correctly
829
+ // Verify that dates from different time zones are handled correctly
830
830
  const localDate = new Date("2024-06-15T14:30:00");
831
831
  expect(localDate).toBeInstanceOf(Date);
832
832
  expect(typeof localDate.getTimezoneOffset()).toBe("number");
@@ -878,10 +878,11 @@ describe("KyselyAdapter PGLite", () => {
878
878
  it("should support cursor-based pagination with findWithCursor()", async () => {
879
879
  const queryEngine = adapter.createQueryEngine(testSchema, "test");
880
880
 
881
- // Create multiple users for pagination testing with unique prefix
881
+ // Create exactly 15 users for precise pagination testing with unique prefix
882
882
  const prefix = "CursorPagTest";
883
+
883
884
  const userIds: FragnoId[] = [];
884
- for (let i = 1; i <= 25; i++) {
885
+ for (let i = 1; i <= 15; i++) {
885
886
  const userId = await queryEngine.create("users", {
886
887
  name: `${prefix} ${i.toString().padStart(2, "0")}`,
887
888
  age: 20 + i,
@@ -889,29 +890,35 @@ describe("KyselyAdapter PGLite", () => {
889
890
  userIds.push(userId);
890
891
  }
891
892
 
892
- // Fetch first page with cursor (filter by prefix to avoid other test data)
893
+ // Fetch first page with cursor (pageSize=10, total=15 items)
893
894
  const firstPage = await queryEngine.findWithCursor("users", (b) =>
894
- b.whereIndex("name_idx").orderByIndex("name_idx", "asc").pageSize(10),
895
+ b
896
+ .whereIndex("name_idx", (eb) => eb("name", "starts with", prefix))
897
+ .orderByIndex("name_idx", "asc")
898
+ .pageSize(10),
895
899
  );
896
900
 
897
- // Check structure
901
+ // Check structure and hasNextPage
898
902
  expect(firstPage).toHaveProperty("items");
899
903
  expect(firstPage).toHaveProperty("cursor");
904
+ expect(firstPage).toHaveProperty("hasNextPage");
900
905
  expect(Array.isArray(firstPage.items)).toBe(true);
901
- expect(firstPage.items.length).toBeGreaterThan(0);
906
+ expect(firstPage.items).toHaveLength(10);
907
+ expect(firstPage.hasNextPage).toBe(true);
908
+ expect(firstPage.cursor).toBeInstanceOf(Cursor);
902
909
 
903
- assert(firstPage.cursor instanceof Cursor);
904
-
905
- // Fetch second page using cursor
910
+ // Fetch second page using cursor (last page with 5 remaining items)
906
911
  const secondPage = await queryEngine.findWithCursor("users", (b) =>
907
912
  b
908
- .whereIndex("name_idx")
913
+ .whereIndex("name_idx", (eb) => eb("name", "starts with", prefix))
909
914
  .after(firstPage.cursor!)
910
915
  .orderByIndex("name_idx", "asc")
911
916
  .pageSize(10),
912
917
  );
913
918
 
914
- expect(secondPage.items.length).toBeGreaterThan(0);
919
+ expect(secondPage.items).toHaveLength(5);
920
+ expect(secondPage.hasNextPage).toBe(false);
921
+ expect(secondPage.cursor).toBeUndefined();
915
922
 
916
923
  // Verify no overlap - all names in second page should be different from first page
917
924
  const firstPageNames = new Set(firstPage.items.map((u) => u.name));
@@ -926,12 +933,16 @@ describe("KyselyAdapter PGLite", () => {
926
933
  const secondPageFirst = secondPage.items[0].name;
927
934
  expect(firstPageLast < secondPageFirst).toBe(true);
928
935
 
929
- // Verify our test data is present
930
- const testUsers = await queryEngine.find("users", (b) =>
931
- b.whereIndex("name_idx").pageSize(100),
936
+ // Test empty results
937
+ const emptyPage = await queryEngine.findWithCursor("users", (b) =>
938
+ b
939
+ .whereIndex("name_idx", (eb) => eb("name", "starts with", "NonExistentPrefix"))
940
+ .orderByIndex("name_idx", "asc")
941
+ .pageSize(10),
932
942
  );
933
- const testUserNames = testUsers.filter((u) => u.name.startsWith(prefix)).map((u) => u.name);
934
- expect(testUserNames).toHaveLength(25);
943
+ expect(emptyPage.items).toHaveLength(0);
944
+ expect(emptyPage.hasNextPage).toBe(false);
945
+ expect(emptyPage.cursor).toBeUndefined();
935
946
  });
936
947
 
937
948
  it("should support findWithCursor() in Unit of Work", async () => {
@@ -960,14 +971,47 @@ describe("KyselyAdapter PGLite", () => {
960
971
 
961
972
  const [result] = await uow.executeRetrieve();
962
973
 
963
- // Verify result structure
974
+ // Verify result structure including hasNextPage
964
975
  expect(result).toHaveProperty("items");
965
976
  expect(result).toHaveProperty("cursor");
977
+ expect(result).toHaveProperty("hasNextPage");
966
978
  expect(Array.isArray(result.items)).toBe(true);
967
979
  expect(result.items.length).toBeGreaterThan(0);
980
+ expect(typeof result.hasNextPage).toBe("boolean");
981
+ expect(result.cursor).toBeInstanceOf(Cursor);
982
+ });
968
983
 
969
- if (result.items.length === 3) {
970
- expect(result.cursor).toBeInstanceOf(Cursor);
971
- }
984
+ it("should fail check() when version changes", async () => {
985
+ const queryEngine = adapter.createQueryEngine(testSchema, "test");
986
+
987
+ // Create a user
988
+ const userId = await queryEngine.create("users", {
989
+ name: "Version Conflict User",
990
+ age: 40,
991
+ });
992
+
993
+ // Update the user to increment their version
994
+ await queryEngine.updateMany("users", (b) =>
995
+ b.whereIndex("primary", (eb) => eb("id", "=", userId)).set({ age: 41 }),
996
+ );
997
+
998
+ // Try to check with the old version (should fail)
999
+ const uow = queryEngine.createUnitOfWork("check-stale-version");
1000
+ uow.check("users", userId); // This has version 0, but the user now has version 1
1001
+ uow.create("posts", {
1002
+ user_id: userId,
1003
+ title: "Should Not Be Created",
1004
+ content: "Content",
1005
+ });
1006
+
1007
+ const { success } = await uow.executeMutations();
1008
+ expect(success).toBe(false);
1009
+
1010
+ // Verify the post was NOT created
1011
+ const posts = await queryEngine.find("posts", (b) =>
1012
+ b.whereIndex("posts_user_idx", (eb) => eb("user_id", "=", userId)),
1013
+ );
1014
+ const conflictPosts = posts.filter((p) => p.title === "Should Not Be Created");
1015
+ expect(conflictPosts).toHaveLength(0);
972
1016
  });
973
1017
  });
@@ -0,0 +1,156 @@
1
+ import { Kysely } from "kysely";
2
+ import { SQLocalKysely } from "sqlocal/kysely";
3
+ import { beforeAll, describe, expect, it } from "vitest";
4
+ import { KyselyAdapter } from "./kysely-adapter";
5
+ import { column, idColumn, referenceColumn, schema } from "../../schema/create";
6
+
7
+ describe("KyselyAdapter SQLite", () => {
8
+ const testSchema = schema((s) => {
9
+ return s
10
+ .addTable("accounts", (t) => {
11
+ return t
12
+ .addColumn("id", idColumn())
13
+ .addColumn("userId", column("string"))
14
+ .addColumn("balance", column("integer"))
15
+ .createIndex("idx_user", ["userId"]);
16
+ })
17
+ .addTable("transactions", (t) => {
18
+ return t
19
+ .addColumn("id", idColumn())
20
+ .addColumn("fromAccountId", referenceColumn())
21
+ .addColumn("toAccountId", referenceColumn())
22
+ .addColumn("amount", column("integer"));
23
+ })
24
+ .addReference("fromAccount", {
25
+ type: "one",
26
+ from: { table: "transactions", column: "fromAccountId" },
27
+ to: { table: "accounts", column: "id" },
28
+ })
29
+ .addReference("toAccount", {
30
+ type: "one",
31
+ from: { table: "transactions", column: "toAccountId" },
32
+ to: { table: "accounts", column: "id" },
33
+ });
34
+ });
35
+
36
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
37
+ let kysely: Kysely<any>;
38
+ let adapter: KyselyAdapter;
39
+
40
+ beforeAll(async () => {
41
+ const { dialect } = new SQLocalKysely(":memory:");
42
+ kysely = new Kysely({
43
+ dialect,
44
+ });
45
+
46
+ adapter = new KyselyAdapter({
47
+ db: kysely,
48
+ provider: "sqlite",
49
+ });
50
+
51
+ // Run migrations
52
+ const migrator = adapter.createMigrationEngine(testSchema, "test");
53
+ const preparedMigration = await migrator.prepareMigration({
54
+ updateSettings: false,
55
+ });
56
+ await preparedMigration.execute();
57
+ });
58
+
59
+ // TODO(Wilco): The SQLocal adapter does not return the number of affected rows for updates/deletes.
60
+ // (this only seems to happens when the query is compiled and then executed with executeQuery())
61
+ it.todo("should perform a balance transfer between accounts", async () => {
62
+ const queryEngine = adapter.createQueryEngine(testSchema, "test");
63
+
64
+ // Create two accounts with initial balances
65
+ const account1Id = await queryEngine.create("accounts", {
66
+ userId: "user1",
67
+ balance: 1000,
68
+ });
69
+
70
+ const account2Id = await queryEngine.create("accounts", {
71
+ userId: "user2",
72
+ balance: 500,
73
+ });
74
+
75
+ // Verify initial balances
76
+ const initialAccount1 = await queryEngine.findFirst("accounts", (b) => {
77
+ return b.whereIndex("primary", (eb) => eb("id", "=", account1Id));
78
+ });
79
+
80
+ const initialAccount2 = await queryEngine.findFirst("accounts", (b) => {
81
+ return b.whereIndex("primary", (eb) => eb("id", "=", account2Id));
82
+ });
83
+
84
+ expect(initialAccount1?.balance).toBe(1000);
85
+ expect(initialAccount2?.balance).toBe(500);
86
+
87
+ // Perform a balance transfer using Unit of Work
88
+ const transferAmount = 300;
89
+
90
+ // Read current balances - chain the calls to properly set generics
91
+ const uow = queryEngine
92
+ .createUnitOfWork("balance-transfer")
93
+ .find("accounts", (b) => {
94
+ return b.whereIndex("primary", (eb) => eb("id", "=", account1Id));
95
+ })
96
+ .find("accounts", (b) => {
97
+ return b.whereIndex("primary", (eb) => eb("id", "=", account2Id));
98
+ });
99
+
100
+ // Execute retrieval phase
101
+ const [[fromAccount], [toAccount]] = await uow.executeRetrieve();
102
+
103
+ expect(fromAccount).toBeDefined();
104
+ expect(toAccount).toBeDefined();
105
+
106
+ // Mutation phase: update balances and record transaction
107
+ uow.update("accounts", account1Id, (b) => {
108
+ return b.set({ balance: fromAccount!.balance - transferAmount }).check();
109
+ });
110
+
111
+ uow.update("accounts", account2Id, (b) => {
112
+ return b.set({ balance: toAccount!.balance + transferAmount }).check();
113
+ });
114
+
115
+ uow.create("transactions", {
116
+ fromAccountId: account1Id,
117
+ toAccountId: account2Id,
118
+ amount: transferAmount,
119
+ });
120
+
121
+ // Execute mutations
122
+ const { success } = await uow.executeMutations();
123
+ expect(success).toBe(true);
124
+
125
+ // Verify final balances
126
+ const finalAccount1 = await queryEngine.findFirst("accounts", (b) => {
127
+ return b.whereIndex("primary", (eb) => eb("id", "=", account1Id));
128
+ });
129
+
130
+ const finalAccount2 = await queryEngine.findFirst("accounts", (b) => {
131
+ return b.whereIndex("primary", (eb) => eb("id", "=", account2Id));
132
+ });
133
+
134
+ expect(finalAccount1?.balance).toBe(700); // 1000 - 300
135
+ expect(finalAccount2?.balance).toBe(800); // 500 + 300
136
+
137
+ // Verify versions were incremented
138
+ expect(finalAccount1?.id.version).toBe(1);
139
+ expect(finalAccount2?.id.version).toBe(1);
140
+
141
+ // Verify transaction was recorded
142
+ const transaction = await queryEngine.findFirst("transactions", (b) => {
143
+ return b.whereIndex("primary");
144
+ });
145
+
146
+ expect(transaction).toMatchObject({
147
+ fromAccountId: expect.objectContaining({
148
+ internalId: account1Id.internalId,
149
+ }),
150
+ toAccountId: expect.objectContaining({
151
+ internalId: account2Id.internalId,
152
+ }),
153
+ amount: transferAmount,
154
+ });
155
+ });
156
+ });
@@ -4,6 +4,7 @@ import {
4
4
  fragnoDatabaseAdapterNameFakeSymbol,
5
5
  fragnoDatabaseAdapterVersionFakeSymbol,
6
6
  type DatabaseAdapter,
7
+ type DatabaseContextStorage,
7
8
  } from "../adapters";
8
9
  import { createMigrator, type Migrator } from "../../migration-engine/create";
9
10
  import type { AnySchema } from "../../schema/create";
@@ -13,9 +14,10 @@ import type { AbstractQuery } from "../../query/query";
13
14
  import { fromKysely, type KyselyUOWConfig } from "./kysely-query";
14
15
  import { createTableNameMapper } from "./kysely-shared";
15
16
  import { createHash } from "node:crypto";
16
- import { SETTINGS_TABLE_NAME } from "../../shared/settings-schema";
17
+ import { SETTINGS_TABLE_NAME } from "../../fragments/internal-fragment";
17
18
  import type { ConnectionPool } from "../../shared/connection-pool";
18
19
  import { createKyselyConnectionPool } from "./kysely-connection-pool";
20
+ import { RequestContextStorage } from "@fragno-dev/core/internal/request-context-storage";
19
21
 
20
22
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
21
23
  type KyselyAny = Kysely<any>;
@@ -28,10 +30,13 @@ export interface KyselyConfig {
28
30
  export class KyselyAdapter implements DatabaseAdapter<KyselyUOWConfig> {
29
31
  #connectionPool: ConnectionPool<KyselyAny>;
30
32
  #provider: SQLProvider;
33
+ #schemaNamespaceMap = new WeakMap<AnySchema, string>();
34
+ #contextStorage: RequestContextStorage<DatabaseContextStorage>;
31
35
 
32
36
  constructor(config: KyselyConfig) {
33
37
  this.#connectionPool = createKyselyConnectionPool(config.db);
34
38
  this.#provider = config.provider;
39
+ this.#contextStorage = new RequestContextStorage();
35
40
  }
36
41
 
37
42
  get [fragnoDatabaseAdapterNameFakeSymbol](): string {
@@ -42,17 +47,35 @@ export class KyselyAdapter implements DatabaseAdapter<KyselyUOWConfig> {
42
47
  return 0;
43
48
  }
44
49
 
50
+ get contextStorage(): RequestContextStorage<DatabaseContextStorage> {
51
+ return this.#contextStorage;
52
+ }
53
+
45
54
  async close(): Promise<void> {
46
55
  await this.#connectionPool.close();
47
56
  }
48
57
 
58
+ createTableNameMapper(namespace: string) {
59
+ return createTableNameMapper(namespace);
60
+ }
61
+
49
62
  createQueryEngine<T extends AnySchema>(
50
63
  schema: T,
51
64
  namespace: string,
52
65
  ): AbstractQuery<T, KyselyUOWConfig> {
66
+ // Register schema-namespace mapping
67
+ this.#schemaNamespaceMap.set(schema, namespace);
68
+
53
69
  // Only create mapper if namespace is non-empty
54
70
  const mapper = namespace ? createTableNameMapper(namespace) : undefined;
55
- return fromKysely(schema, this.#connectionPool, this.#provider, mapper);
71
+ return fromKysely(
72
+ schema,
73
+ this.#connectionPool,
74
+ this.#provider,
75
+ mapper,
76
+ undefined,
77
+ this.#schemaNamespaceMap,
78
+ );
56
79
  }
57
80
 
58
81
  async isConnectionHealthy(): Promise<boolean> {
@@ -1,16 +1,12 @@
1
- import type { CompiledQuery, Kysely } from "kysely";
1
+ import type { CompiledQuery } from "kysely";
2
2
  import type { AnySchema, AnyTable } from "../../schema/create";
3
3
  import { buildCondition } from "../../query/condition-builder";
4
4
  import { buildFindOptions } from "../../query/orm/orm";
5
5
  import { createKyselyQueryBuilder } from "./kysely-query-builder";
6
6
  import type { ConditionBuilder, Condition } from "../../query/condition-builder";
7
- import type { TableNameMapper } from "./kysely-shared";
8
- import type { ConnectionPool } from "../../shared/connection-pool";
7
+ import { createKysely, type TableNameMapper } from "./kysely-shared";
9
8
  import type { SQLProvider } from "../../shared/providers";
10
9
 
11
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
12
- type KyselyAny = Kysely<any>;
13
-
14
10
  /**
15
11
  * Internal query compiler interface for Kysely
16
12
  * Used by the UOW compiler to generate compiled queries
@@ -31,12 +27,11 @@ export interface KyselyQueryCompiler {
31
27
 
32
28
  export function createKyselyQueryCompiler<T extends AnySchema>(
33
29
  schema: T,
34
- pool: ConnectionPool<KyselyAny>,
35
30
  provider: SQLProvider,
36
31
  mapper?: TableNameMapper,
37
32
  ): KyselyQueryCompiler {
38
33
  // Get kysely instance for query building (compilation doesn't execute, just builds SQL)
39
- const kysely = pool.getDatabaseSync();
34
+ const kysely = createKysely(provider);
40
35
  const queryBuilder = createKyselyQueryBuilder(kysely, provider, mapper);
41
36
 
42
37
  function toTable(name: unknown): AnyTable {
@@ -77,6 +77,8 @@ class UpdateManySpecialBuilder<TTable extends AnyTable> {
77
77
  * @param pool - Connection pool for acquiring database connections
78
78
  * @param provider - SQL provider (postgresql, mysql, sqlite, etc.)
79
79
  * @param mapper - Optional table name mapper for namespace prefixing
80
+ * @param uowConfig - Optional UOW configuration
81
+ * @param schemaNamespaceMap - Optional WeakMap for schema-to-namespace lookups
80
82
  * @returns An AbstractQuery instance for performing database operations
81
83
  *
82
84
  * @example
@@ -96,9 +98,10 @@ export function fromKysely<T extends AnySchema>(
96
98
  provider: SQLProvider,
97
99
  mapper?: TableNameMapper,
98
100
  uowConfig?: KyselyUOWConfig,
101
+ schemaNamespaceMap?: WeakMap<AnySchema, string>,
99
102
  ): AbstractQuery<T, KyselyUOWConfig> {
100
103
  function createUOW(opts: { name?: string; config?: KyselyUOWConfig }) {
101
- const uowCompiler = createKyselyUOWCompiler(schema, pool, provider, mapper);
104
+ const uowCompiler = createKyselyUOWCompiler(provider, mapper);
102
105
 
103
106
  const executor: UOWExecutor<CompiledQuery, unknown> = {
104
107
  async executeRetrievalPhase(retrievalBatch: CompiledQuery[]) {
@@ -133,7 +136,7 @@ export function fromKysely<T extends AnySchema>(
133
136
  };
134
137
 
135
138
  // Create a decoder function to transform raw results into application format
136
- const decoder: UOWDecoder<T> = (rawResults, ops) => {
139
+ const decoder: UOWDecoder<unknown> = (rawResults, ops) => {
137
140
  if (rawResults.length !== ops.length) {
138
141
  throw new Error("rawResults and ops must have the same length");
139
142
  }
@@ -165,35 +168,45 @@ export function fromKysely<T extends AnySchema>(
165
168
  // If cursor generation is requested, wrap in CursorResult
166
169
  if (op.withCursor) {
167
170
  let cursor: Cursor | undefined;
168
-
169
- // Generate cursor from last item if results exist
170
- if (decodedRows.length > 0 && op.options.orderByIndex && op.options.pageSize) {
171
- const lastItem = decodedRows[decodedRows.length - 1];
172
- const indexName = op.options.orderByIndex.indexName;
173
-
174
- // Get index columns
175
- let indexColumns;
176
- if (indexName === "_primary") {
177
- indexColumns = [op.table.getIdColumn()];
178
- } else {
179
- const index = op.table.indexes[indexName];
180
- if (index) {
181
- indexColumns = index.columns;
171
+ let hasNextPage = false;
172
+ let items = decodedRows;
173
+
174
+ // Check if there are more results (we fetched pageSize + 1)
175
+ if (op.options.pageSize && decodedRows.length > op.options.pageSize) {
176
+ hasNextPage = true;
177
+ // Trim to requested pageSize
178
+ items = decodedRows.slice(0, op.options.pageSize);
179
+
180
+ // Generate cursor from the last item we're returning
181
+ if (op.options.orderByIndex) {
182
+ const lastItem = items[items.length - 1];
183
+ const indexName = op.options.orderByIndex.indexName;
184
+
185
+ // Get index columns
186
+ let indexColumns;
187
+ if (indexName === "_primary") {
188
+ indexColumns = [op.table.getIdColumn()];
189
+ } else {
190
+ const index = op.table.indexes[indexName];
191
+ if (index) {
192
+ indexColumns = index.columns;
193
+ }
182
194
  }
183
- }
184
195
 
185
- if (indexColumns && lastItem) {
186
- cursor = createCursorFromRecord(lastItem as Record<string, unknown>, indexColumns, {
187
- indexName: op.options.orderByIndex.indexName,
188
- orderDirection: op.options.orderByIndex.direction,
189
- pageSize: op.options.pageSize,
190
- });
196
+ if (indexColumns && lastItem) {
197
+ cursor = createCursorFromRecord(lastItem as Record<string, unknown>, indexColumns, {
198
+ indexName: op.options.orderByIndex.indexName,
199
+ orderDirection: op.options.orderByIndex.direction,
200
+ pageSize: op.options.pageSize,
201
+ });
202
+ }
191
203
  }
192
204
  }
193
205
 
194
206
  const result: CursorResult<unknown> = {
195
- items: decodedRows,
207
+ items,
196
208
  cursor,
209
+ hasNextPage,
197
210
  };
198
211
  return result;
199
212
  }
@@ -204,20 +217,27 @@ export function fromKysely<T extends AnySchema>(
204
217
 
205
218
  const { onQuery, ...restUowConfig } = opts.config ?? {};
206
219
 
207
- return new UnitOfWork(schema, uowCompiler, executor, decoder, opts.name, {
208
- ...restUowConfig,
209
- onQuery: (query) => {
210
- // CompiledMutation has { query: CompiledQuery, expectedAffectedRows: number | null }
211
- // CompiledQuery has { query: QueryAST, sql: string, parameters: unknown[] }
212
- // Check for expectedAffectedRows to distinguish CompiledMutation from CompiledQuery
213
- const actualQuery =
214
- query && typeof query === "object" && "expectedAffectedRows" in query
215
- ? (query as CompiledMutation<CompiledQuery>).query
216
- : (query as CompiledQuery);
217
-
218
- opts.config?.onQuery?.(actualQuery);
220
+ return new UnitOfWork(
221
+ uowCompiler,
222
+ executor,
223
+ decoder,
224
+ opts.name,
225
+ {
226
+ ...restUowConfig,
227
+ onQuery: (query) => {
228
+ // CompiledMutation has { query: CompiledQuery, expectedAffectedRows: number | null }
229
+ // CompiledQuery has { query: QueryAST, sql: string, parameters: unknown[] }
230
+ // Check for expectedAffectedRows to distinguish CompiledMutation from CompiledQuery
231
+ const actualQuery =
232
+ query && typeof query === "object" && "expectedAffectedRows" in query
233
+ ? (query as CompiledMutation<CompiledQuery>).query
234
+ : (query as CompiledQuery);
235
+
236
+ opts.config?.onQuery?.(actualQuery);
237
+ },
219
238
  },
220
- });
239
+ schemaNamespaceMap,
240
+ ).forSchema(schema);
221
241
  }
222
242
 
223
243
  return {
@@ -1,3 +1,7 @@
1
+ import { Kysely } from "kysely";
2
+ import type { SQLProvider } from "../../shared/providers";
3
+ import { MysqlDialect, PostgresDialect, SqliteDialect } from "kysely";
4
+
1
5
  /**
2
6
  * Maps logical table names (used by fragment authors) to physical table names (with namespace suffix)
3
7
  */
@@ -21,3 +25,33 @@ export function createTableNameMapper(namespace: string): TableNameMapper {
21
25
  },
22
26
  };
23
27
  }
28
+
29
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
30
+ type KyselyAny = Kysely<any>;
31
+
32
+ /**
33
+ * Creates a Kysely instance that can only build queries, not execute them.
34
+ */
35
+ export function createKysely(provider: SQLProvider): KyselyAny {
36
+ // oxlint-disable-next-line no-explicit-any
37
+ const fakeObj = {} as any;
38
+ switch (provider) {
39
+ case "postgresql":
40
+ case "cockroachdb":
41
+ return new Kysely({
42
+ dialect: new PostgresDialect(fakeObj),
43
+ });
44
+
45
+ case "mysql":
46
+ return new Kysely({
47
+ dialect: new MysqlDialect(fakeObj),
48
+ });
49
+
50
+ case "sqlite":
51
+ return new Kysely({
52
+ dialect: new SqliteDialect(fakeObj),
53
+ });
54
+ }
55
+
56
+ throw new Error(`Unsupported provider: ${provider}`);
57
+ }