@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.
- package/.turbo/turbo-build.log +179 -132
- package/CHANGELOG.md +30 -0
- package/dist/adapters/adapters.d.ts +27 -1
- package/dist/adapters/adapters.d.ts.map +1 -1
- package/dist/adapters/adapters.js.map +1 -1
- package/dist/adapters/drizzle/drizzle-adapter.d.ts +5 -1
- package/dist/adapters/drizzle/drizzle-adapter.d.ts.map +1 -1
- package/dist/adapters/drizzle/drizzle-adapter.js +15 -3
- package/dist/adapters/drizzle/drizzle-adapter.js.map +1 -1
- package/dist/adapters/drizzle/drizzle-query.js +7 -5
- package/dist/adapters/drizzle/drizzle-query.js.map +1 -1
- package/dist/adapters/drizzle/drizzle-uow-compiler.d.ts +0 -1
- package/dist/adapters/drizzle/drizzle-uow-compiler.d.ts.map +1 -1
- package/dist/adapters/drizzle/drizzle-uow-compiler.js +76 -44
- package/dist/adapters/drizzle/drizzle-uow-compiler.js.map +1 -1
- package/dist/adapters/drizzle/drizzle-uow-decoder.js +23 -16
- package/dist/adapters/drizzle/drizzle-uow-decoder.js.map +1 -1
- package/dist/adapters/drizzle/drizzle-uow-executor.js +18 -7
- package/dist/adapters/drizzle/drizzle-uow-executor.js.map +1 -1
- package/dist/adapters/drizzle/generate.d.ts +4 -1
- package/dist/adapters/drizzle/generate.d.ts.map +1 -1
- package/dist/adapters/drizzle/generate.js +11 -18
- package/dist/adapters/drizzle/generate.js.map +1 -1
- package/dist/adapters/drizzle/shared.d.ts +14 -1
- package/dist/adapters/drizzle/shared.d.ts.map +1 -0
- package/dist/adapters/kysely/kysely-adapter.d.ts +5 -1
- package/dist/adapters/kysely/kysely-adapter.d.ts.map +1 -1
- package/dist/adapters/kysely/kysely-adapter.js +14 -3
- package/dist/adapters/kysely/kysely-adapter.js.map +1 -1
- package/dist/adapters/kysely/kysely-query-builder.js +1 -1
- package/dist/adapters/kysely/kysely-query-compiler.js +3 -2
- package/dist/adapters/kysely/kysely-query-compiler.js.map +1 -1
- package/dist/adapters/kysely/kysely-query.d.ts +1 -0
- package/dist/adapters/kysely/kysely-query.d.ts.map +1 -1
- package/dist/adapters/kysely/kysely-query.js +28 -19
- package/dist/adapters/kysely/kysely-query.js.map +1 -1
- package/dist/adapters/kysely/kysely-shared.d.ts +14 -0
- package/dist/adapters/kysely/kysely-shared.d.ts.map +1 -0
- package/dist/adapters/kysely/kysely-shared.js +16 -1
- package/dist/adapters/kysely/kysely-shared.js.map +1 -1
- package/dist/adapters/kysely/kysely-uow-compiler.js +68 -16
- package/dist/adapters/kysely/kysely-uow-compiler.js.map +1 -1
- package/dist/adapters/kysely/kysely-uow-executor.js +8 -4
- package/dist/adapters/kysely/kysely-uow-executor.js.map +1 -1
- package/dist/adapters/kysely/migration/execute-base.js +1 -1
- package/dist/adapters/kysely/migration/execute-base.js.map +1 -1
- package/dist/db-fragment-definition-builder.d.ts +152 -0
- package/dist/db-fragment-definition-builder.d.ts.map +1 -0
- package/dist/db-fragment-definition-builder.js +137 -0
- package/dist/db-fragment-definition-builder.js.map +1 -0
- package/dist/fragments/internal-fragment.d.ts +19 -0
- package/dist/fragments/internal-fragment.d.ts.map +1 -0
- package/dist/fragments/internal-fragment.js +39 -0
- package/dist/fragments/internal-fragment.js.map +1 -0
- package/dist/migration-engine/generation-engine.d.ts.map +1 -1
- package/dist/migration-engine/generation-engine.js +35 -15
- package/dist/migration-engine/generation-engine.js.map +1 -1
- package/dist/mod.d.ts +8 -18
- package/dist/mod.d.ts.map +1 -1
- package/dist/mod.js +7 -34
- package/dist/mod.js.map +1 -1
- package/dist/node_modules/.pnpm/rou3@0.7.8/node_modules/rou3/dist/index.js +165 -0
- package/dist/node_modules/.pnpm/rou3@0.7.8/node_modules/rou3/dist/index.js.map +1 -0
- package/dist/packages/fragno/dist/api/bind-services.js +20 -0
- package/dist/packages/fragno/dist/api/bind-services.js.map +1 -0
- package/dist/packages/fragno/dist/api/error.js +48 -0
- package/dist/packages/fragno/dist/api/error.js.map +1 -0
- package/dist/packages/fragno/dist/api/fragment-definition-builder.js +320 -0
- package/dist/packages/fragno/dist/api/fragment-definition-builder.js.map +1 -0
- package/dist/packages/fragno/dist/api/fragment-instantiator.js +487 -0
- package/dist/packages/fragno/dist/api/fragment-instantiator.js.map +1 -0
- package/dist/packages/fragno/dist/api/fragno-response.js +73 -0
- package/dist/packages/fragno/dist/api/fragno-response.js.map +1 -0
- package/dist/packages/fragno/dist/api/internal/response-stream.js +81 -0
- package/dist/packages/fragno/dist/api/internal/response-stream.js.map +1 -0
- package/dist/packages/fragno/dist/api/internal/route.js +10 -0
- package/dist/packages/fragno/dist/api/internal/route.js.map +1 -0
- package/dist/packages/fragno/dist/api/mutable-request-state.js +97 -0
- package/dist/packages/fragno/dist/api/mutable-request-state.js.map +1 -0
- package/dist/packages/fragno/dist/api/request-context-storage.js +43 -0
- package/dist/packages/fragno/dist/api/request-context-storage.js.map +1 -0
- package/dist/packages/fragno/dist/api/request-input-context.js +118 -0
- package/dist/packages/fragno/dist/api/request-input-context.js.map +1 -0
- package/dist/packages/fragno/dist/api/request-middleware.js +83 -0
- package/dist/packages/fragno/dist/api/request-middleware.js.map +1 -0
- package/dist/packages/fragno/dist/api/request-output-context.js +119 -0
- package/dist/packages/fragno/dist/api/request-output-context.js.map +1 -0
- package/dist/packages/fragno/dist/api/route.js +17 -0
- package/dist/packages/fragno/dist/api/route.js.map +1 -0
- package/dist/packages/fragno/dist/internal/symbols.js +10 -0
- package/dist/packages/fragno/dist/internal/symbols.js.map +1 -0
- package/dist/query/cursor.d.ts +10 -2
- package/dist/query/cursor.d.ts.map +1 -1
- package/dist/query/cursor.js +11 -4
- package/dist/query/cursor.js.map +1 -1
- package/dist/query/execute-unit-of-work.d.ts +123 -0
- package/dist/query/execute-unit-of-work.d.ts.map +1 -0
- package/dist/query/execute-unit-of-work.js +184 -0
- package/dist/query/execute-unit-of-work.js.map +1 -0
- package/dist/query/query.d.ts +3 -3
- package/dist/query/query.d.ts.map +1 -1
- package/dist/query/result-transform.js +4 -2
- package/dist/query/result-transform.js.map +1 -1
- package/dist/query/retry-policy.d.ts +88 -0
- package/dist/query/retry-policy.d.ts.map +1 -0
- package/dist/query/retry-policy.js +61 -0
- package/dist/query/retry-policy.js.map +1 -0
- package/dist/query/unit-of-work.d.ts +171 -32
- package/dist/query/unit-of-work.d.ts.map +1 -1
- package/dist/query/unit-of-work.js +530 -133
- package/dist/query/unit-of-work.js.map +1 -1
- package/dist/schema/serialize.js +12 -7
- package/dist/schema/serialize.js.map +1 -1
- package/dist/with-database.d.ts +28 -0
- package/dist/with-database.d.ts.map +1 -0
- package/dist/with-database.js +34 -0
- package/dist/with-database.js.map +1 -0
- package/package.json +10 -3
- package/src/adapters/adapters.ts +30 -0
- package/src/adapters/drizzle/drizzle-adapter-pglite.test.ts +86 -17
- package/src/adapters/drizzle/drizzle-adapter-sqlite.test.ts +291 -7
- package/src/adapters/drizzle/drizzle-adapter.test.ts +3 -51
- package/src/adapters/drizzle/drizzle-adapter.ts +35 -7
- package/src/adapters/drizzle/drizzle-query.ts +25 -15
- package/src/adapters/drizzle/drizzle-uow-compiler-mysql.test.ts +1442 -0
- package/src/adapters/drizzle/drizzle-uow-compiler-sqlite.test.ts +1414 -0
- package/src/adapters/drizzle/drizzle-uow-compiler.test.ts +78 -61
- package/src/adapters/drizzle/drizzle-uow-compiler.ts +123 -42
- package/src/adapters/drizzle/drizzle-uow-decoder.ts +34 -27
- package/src/adapters/drizzle/drizzle-uow-executor.ts +41 -8
- package/src/adapters/drizzle/generate.test.ts +102 -269
- package/src/adapters/drizzle/generate.ts +12 -30
- package/src/adapters/drizzle/test-utils.ts +36 -5
- package/src/adapters/kysely/kysely-adapter-pglite.test.ts +66 -22
- package/src/adapters/kysely/kysely-adapter-sqlite.test.ts +156 -0
- package/src/adapters/kysely/kysely-adapter.ts +25 -2
- package/src/adapters/kysely/kysely-query-compiler.ts +3 -8
- package/src/adapters/kysely/kysely-query.ts +57 -37
- package/src/adapters/kysely/kysely-shared.ts +34 -0
- package/src/adapters/kysely/kysely-uow-compiler.test.ts +62 -74
- package/src/adapters/kysely/kysely-uow-compiler.ts +92 -24
- package/src/adapters/kysely/kysely-uow-executor.ts +26 -7
- package/src/adapters/kysely/kysely-uow-joins.test.ts +33 -50
- package/src/adapters/kysely/migration/execute-base.ts +1 -1
- package/src/db-fragment-definition-builder.test.ts +887 -0
- package/src/db-fragment-definition-builder.ts +506 -0
- package/src/db-fragment-instantiator.test.ts +467 -0
- package/src/db-fragment-integration.test.ts +408 -0
- package/src/fragments/internal-fragment.test.ts +160 -0
- package/src/fragments/internal-fragment.ts +85 -0
- package/src/migration-engine/generation-engine.test.ts +58 -15
- package/src/migration-engine/generation-engine.ts +78 -25
- package/src/mod.ts +35 -43
- package/src/query/cursor.test.ts +119 -0
- package/src/query/cursor.ts +17 -4
- package/src/query/execute-unit-of-work.test.ts +1310 -0
- package/src/query/execute-unit-of-work.ts +463 -0
- package/src/query/query.ts +4 -4
- package/src/query/result-transform.test.ts +129 -0
- package/src/query/result-transform.ts +4 -1
- package/src/query/retry-policy.test.ts +217 -0
- package/src/query/retry-policy.ts +141 -0
- package/src/query/unit-of-work-coordinator.test.ts +833 -0
- package/src/query/unit-of-work-types.test.ts +15 -2
- package/src/query/unit-of-work.test.ts +878 -200
- package/src/query/unit-of-work.ts +963 -321
- package/src/schema/serialize.ts +22 -11
- package/src/with-database.ts +140 -0
- package/tsdown.config.ts +1 -0
- package/dist/fragment.d.ts +0 -54
- package/dist/fragment.d.ts.map +0 -1
- package/dist/fragment.js +0 -92
- package/dist/fragment.js.map +0 -1
- package/dist/shared/settings-schema.js +0 -36
- package/dist/shared/settings-schema.js.map +0 -1
- package/src/fragment.test.ts +0 -341
- package/src/fragment.ts +0 -198
- 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
|
-
//
|
|
42
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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 <=
|
|
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 (
|
|
893
|
+
// Fetch first page with cursor (pageSize=10, total=15 items)
|
|
893
894
|
const firstPage = await queryEngine.findWithCursor("users", (b) =>
|
|
894
|
-
b
|
|
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
|
|
906
|
+
expect(firstPage.items).toHaveLength(10);
|
|
907
|
+
expect(firstPage.hasNextPage).toBe(true);
|
|
908
|
+
expect(firstPage.cursor).toBeInstanceOf(Cursor);
|
|
902
909
|
|
|
903
|
-
|
|
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
|
|
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
|
-
//
|
|
930
|
-
const
|
|
931
|
-
b
|
|
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
|
-
|
|
934
|
-
expect(
|
|
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
|
-
|
|
970
|
-
|
|
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 "../../
|
|
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(
|
|
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
|
|
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
|
|
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 =
|
|
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(
|
|
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<
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
//
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
|
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(
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
+
}
|