@fragno-dev/db 0.1.11 → 0.1.12

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 (51) hide show
  1. package/.turbo/turbo-build.log +30 -28
  2. package/CHANGELOG.md +13 -0
  3. package/dist/adapters/drizzle/drizzle-query.d.ts.map +1 -1
  4. package/dist/adapters/drizzle/drizzle-query.js +38 -34
  5. package/dist/adapters/drizzle/drizzle-query.js.map +1 -1
  6. package/dist/adapters/kysely/kysely-adapter.d.ts +3 -2
  7. package/dist/adapters/kysely/kysely-adapter.d.ts.map +1 -1
  8. package/dist/adapters/kysely/kysely-adapter.js.map +1 -1
  9. package/dist/adapters/kysely/kysely-query.d.ts +22 -0
  10. package/dist/adapters/kysely/kysely-query.d.ts.map +1 -0
  11. package/dist/adapters/kysely/kysely-query.js +72 -50
  12. package/dist/adapters/kysely/kysely-query.js.map +1 -1
  13. package/dist/adapters/kysely/kysely-uow-executor.js +2 -2
  14. package/dist/adapters/kysely/kysely-uow-executor.js.map +1 -1
  15. package/dist/migration-engine/generation-engine.d.ts +1 -1
  16. package/dist/migration-engine/generation-engine.d.ts.map +1 -1
  17. package/dist/migration-engine/generation-engine.js.map +1 -1
  18. package/dist/mod.d.ts +5 -5
  19. package/dist/mod.d.ts.map +1 -1
  20. package/dist/mod.js.map +1 -1
  21. package/dist/query/query.d.ts +24 -8
  22. package/dist/query/query.d.ts.map +1 -1
  23. package/dist/query/result-transform.js +17 -5
  24. package/dist/query/result-transform.js.map +1 -1
  25. package/dist/query/unit-of-work.d.ts +5 -4
  26. package/dist/query/unit-of-work.d.ts.map +1 -1
  27. package/dist/query/unit-of-work.js +2 -3
  28. package/dist/query/unit-of-work.js.map +1 -1
  29. package/dist/schema/serialize.js +2 -0
  30. package/dist/schema/serialize.js.map +1 -1
  31. package/package.json +2 -2
  32. package/src/adapters/drizzle/drizzle-adapter-pglite.test.ts +170 -50
  33. package/src/adapters/drizzle/drizzle-adapter-sqlite.test.ts +89 -35
  34. package/src/adapters/drizzle/drizzle-query.test.ts +54 -4
  35. package/src/adapters/drizzle/drizzle-query.ts +65 -60
  36. package/src/adapters/drizzle/drizzle-uow-compiler.test.ts +63 -3
  37. package/src/adapters/kysely/kysely-adapter-pglite.test.ts +88 -0
  38. package/src/adapters/kysely/kysely-adapter.ts +6 -3
  39. package/src/adapters/kysely/kysely-query.test.ts +498 -0
  40. package/src/adapters/kysely/kysely-query.ts +137 -82
  41. package/src/adapters/kysely/kysely-uow-compiler.test.ts +66 -0
  42. package/src/adapters/kysely/kysely-uow-executor.ts +5 -9
  43. package/src/migration-engine/generation-engine.ts +2 -1
  44. package/src/mod.ts +6 -6
  45. package/src/query/query-type.test.ts +34 -14
  46. package/src/query/query.ts +77 -36
  47. package/src/query/result-transform.test.ts +5 -5
  48. package/src/query/result-transform.ts +29 -11
  49. package/src/query/unit-of-work.ts +8 -11
  50. package/src/schema/serialize.test.ts +223 -0
  51. package/src/schema/serialize.ts +16 -0
@@ -1,12 +1,13 @@
1
1
  import type { AbstractQuery } from "../../query/query";
2
- import type { AnySchema } from "../../schema/create";
3
- import type { CompiledMutation, UOWExecutor } from "../../query/unit-of-work";
2
+ import type { AnySchema, AnyTable } from "../../schema/create";
3
+ import type { CompiledMutation, UOWExecutor, ValidIndexName } from "../../query/unit-of-work";
4
4
  import { createDrizzleUOWCompiler, type DrizzleCompiledQuery } from "./drizzle-uow-compiler";
5
5
  import { executeDrizzleRetrievalPhase, executeDrizzleMutationPhase } from "./drizzle-uow-executor";
6
6
  import { UnitOfWork } from "../../query/unit-of-work";
7
7
  import { parseDrizzle, type DrizzleResult, type TableNameMapper, type DBType } from "./shared";
8
8
  import { createDrizzleUOWDecoder } from "./drizzle-uow-decoder";
9
9
  import type { ConnectionPool } from "../../shared/connection-pool";
10
+ import type { TableToUpdateValues } from "../../query/query";
10
11
 
11
12
  /**
12
13
  * Configuration options for creating a Drizzle Unit of Work
@@ -24,6 +25,37 @@ export interface DrizzleUOWConfig {
24
25
  dryRun?: boolean;
25
26
  }
26
27
 
28
+ /**
29
+ * Special builder for updateMany operations that captures configuration
30
+ */
31
+ class UpdateManySpecialBuilder<TTable extends AnyTable> {
32
+ #indexName?: string;
33
+ #condition?: unknown;
34
+ #setValues?: TableToUpdateValues<TTable>;
35
+
36
+ whereIndex<TIndexName extends ValidIndexName<TTable>>(
37
+ indexName: TIndexName,
38
+ condition?: unknown,
39
+ ): this {
40
+ this.#indexName = indexName as string;
41
+ this.#condition = condition;
42
+ return this;
43
+ }
44
+
45
+ set(values: TableToUpdateValues<TTable>): this {
46
+ this.#setValues = values;
47
+ return this;
48
+ }
49
+
50
+ getConfig() {
51
+ return {
52
+ indexName: this.#indexName,
53
+ condition: this.#condition,
54
+ setValues: this.#setValues,
55
+ };
56
+ }
57
+ }
58
+
27
59
  /**
28
60
  * Creates a Drizzle-based query engine for the given schema.
29
61
  *
@@ -114,7 +146,9 @@ export function fromDrizzle<T extends AnySchema>(
114
146
 
115
147
  return {
116
148
  async find(tableName, builderFn) {
117
- const uow = createUOW({ config: uowConfig }).find(tableName, builderFn);
149
+ // Safe: builderFn returns a FindBuilder (or void), which matches UnitOfWork signature
150
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
151
+ const uow = createUOW({ config: uowConfig }).find(tableName, builderFn as any);
118
152
  const [result] = await uow.executeRetrieve();
119
153
  return result;
120
154
  },
@@ -122,7 +156,10 @@ export function fromDrizzle<T extends AnySchema>(
122
156
  async findFirst(tableName, builderFn) {
123
157
  const uow = createUOW({ config: uowConfig });
124
158
  if (builderFn) {
125
- uow.find(tableName, (b) => builderFn(b as never).pageSize(1));
159
+ uow.find(tableName, (b) => {
160
+ builderFn(b);
161
+ return b.pageSize(1);
162
+ });
126
163
  } else {
127
164
  uow.find(tableName, (b) => b.whereIndex("primary").pageSize(1));
128
165
  }
@@ -133,7 +170,7 @@ export function fromDrizzle<T extends AnySchema>(
133
170
 
134
171
  async create(tableName, values) {
135
172
  const uow = createUOW({ config: uowConfig });
136
- uow.create(tableName as string, values as never);
173
+ uow.create(tableName, values);
137
174
  const { success } = await uow.executeMutations();
138
175
  if (!success) {
139
176
  throw new Error("Failed to create record");
@@ -150,7 +187,7 @@ export function fromDrizzle<T extends AnySchema>(
150
187
  async createMany(tableName, valuesArray) {
151
188
  const uow = createUOW({ config: uowConfig });
152
189
  for (const values of valuesArray) {
153
- uow.create(tableName as string, values as never);
190
+ uow.create(tableName, values);
154
191
  }
155
192
  const { success } = await uow.executeMutations();
156
193
  if (!success) {
@@ -162,7 +199,7 @@ export function fromDrizzle<T extends AnySchema>(
162
199
 
163
200
  async update(tableName, id, builderFn) {
164
201
  const uow = createUOW({ config: uowConfig });
165
- uow.update(tableName as string, id, builderFn as never);
202
+ uow.update(tableName, id, builderFn);
166
203
  const { success } = await uow.executeMutations();
167
204
  if (!success) {
168
205
  throw new Error("Failed to update record (version conflict or record not found)");
@@ -170,25 +207,17 @@ export function fromDrizzle<T extends AnySchema>(
170
207
  },
171
208
 
172
209
  async updateMany(tableName, builderFn) {
173
- // FIXME: This is not correct
174
-
175
- let whereConfig: { indexName?: string; condition?: unknown } = {};
176
- let setValues: unknown;
177
-
178
- const specialBuilder = {
179
- whereIndex(indexName: string, condition?: unknown) {
180
- whereConfig = { indexName, condition };
181
- return this;
182
- },
183
- set(values: unknown) {
184
- setValues = values;
185
- return this;
186
- },
187
- };
210
+ const table = schema.tables[tableName];
211
+ if (!table) {
212
+ throw new Error(`Table ${tableName} not found in schema`);
213
+ }
188
214
 
215
+ const specialBuilder = new UpdateManySpecialBuilder<typeof table>();
189
216
  builderFn(specialBuilder);
190
217
 
191
- if (!whereConfig.indexName) {
218
+ const { indexName, condition, setValues } = specialBuilder.getConfig();
219
+
220
+ if (!indexName) {
192
221
  throw new Error("whereIndex() must be called in updateMany");
193
222
  }
194
223
  if (!setValues) {
@@ -197,24 +226,22 @@ export function fromDrizzle<T extends AnySchema>(
197
226
 
198
227
  const findUow = createUOW({ config: uowConfig });
199
228
  findUow.find(tableName, (b) => {
200
- if (whereConfig.condition) {
201
- return b.whereIndex(whereConfig.indexName as never, whereConfig.condition as never);
229
+ if (condition) {
230
+ // Safe: condition is captured from whereIndex call with proper typing
231
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
232
+ return b.whereIndex(indexName as ValidIndexName<typeof table>, condition as any);
202
233
  }
203
- return b.whereIndex(whereConfig.indexName as never);
234
+ return b.whereIndex(indexName as ValidIndexName<typeof table>);
204
235
  });
205
- const findResults = await findUow.executeRetrieve();
206
- const records = (findResults as unknown as [unknown])[0];
236
+ const [records]: unknown[][] = await findUow.executeRetrieve();
207
237
 
208
- // @ts-expect-error - Type narrowing doesn't work through unknown cast
209
238
  if (!records || records.length === 0) {
210
239
  return;
211
240
  }
212
241
 
213
242
  const updateUow = createUOW({ config: uowConfig });
214
- for (const record of records as never as Array<{ id: unknown }>) {
215
- updateUow.update(tableName as string, record.id as string, (b) =>
216
- b.set(setValues as never),
217
- );
243
+ for (const record of records as Array<{ id: unknown }>) {
244
+ updateUow.update(tableName, record.id as string, (b) => b.set(setValues));
218
245
  }
219
246
  const { success } = await updateUow.executeMutations();
220
247
  if (!success) {
@@ -224,7 +251,7 @@ export function fromDrizzle<T extends AnySchema>(
224
251
 
225
252
  async delete(tableName, id, builderFn) {
226
253
  const uow = createUOW({ config: uowConfig });
227
- uow.delete(tableName as string, id, builderFn as never);
254
+ uow.delete(tableName, id, builderFn);
228
255
  const { success } = await uow.executeMutations();
229
256
  if (!success) {
230
257
  throw new Error("Failed to delete record (version conflict or record not found)");
@@ -232,39 +259,17 @@ export function fromDrizzle<T extends AnySchema>(
232
259
  },
233
260
 
234
261
  async deleteMany(tableName, builderFn) {
235
- let whereConfig: { indexName?: string; condition?: unknown } = {};
236
-
237
- const specialBuilder = {
238
- whereIndex(indexName: string, condition?: unknown) {
239
- whereConfig = { indexName, condition };
240
- return this;
241
- },
242
- };
243
-
244
- builderFn(specialBuilder as never);
245
-
246
- if (!whereConfig.indexName) {
247
- throw new Error("whereIndex() must be called in deleteMany");
248
- }
249
-
250
262
  const findUow = createUOW({ config: uowConfig });
251
- findUow.find(tableName as string, (b) => {
252
- if (whereConfig.condition) {
253
- return b.whereIndex(whereConfig.indexName as never, whereConfig.condition as never);
254
- }
255
- return b.whereIndex(whereConfig.indexName as never);
256
- });
257
- const findResults2 = await findUow.executeRetrieve();
258
- const records = (findResults2 as unknown as [unknown])[0];
263
+ findUow.find(tableName, builderFn);
264
+ const [records]: unknown[][] = await findUow.executeRetrieve();
259
265
 
260
- // @ts-expect-error - Type narrowing doesn't work through unknown cast
261
266
  if (!records || records.length === 0) {
262
267
  return;
263
268
  }
264
269
 
265
270
  const deleteUow = createUOW({ config: uowConfig });
266
- for (const record of records as never as Array<{ id: unknown }>) {
267
- deleteUow.delete(tableName as string, record.id as string);
271
+ for (const record of records as Array<{ id: unknown }>) {
272
+ deleteUow.delete(tableName, record.id as string);
268
273
  }
269
274
  const { success } = await deleteUow.executeMutations();
270
275
  if (!success) {
@@ -457,10 +457,9 @@ describe("drizzle-uow-compiler", () => {
457
457
  const [batch] = compiled.mutationBatch;
458
458
  assert(batch);
459
459
  expect(batch.expectedAffectedRows).toBeNull();
460
- // FragnoId should use internal ID directly (no subquery needed if available)
461
- // But since we don't have the internal ID populated in the test, it should serialize
460
+ // FragnoId should generate a subquery to lookup the internal ID from external ID
462
461
  expect(batch.query.sql).toMatchInlineSnapshot(
463
- `"insert into "posts" ("id", "title", "content", "userId", "viewCount", "_internalId", "_version") values ($1, $2, $3, $4, default, default, default)"`,
462
+ `"insert into "posts" ("id", "title", "content", "userId", "viewCount", "_internalId", "_version") values ($1, $2, $3, (select "_internalId" from "users" where "id" = $4 limit 1), default, default, default)"`,
464
463
  );
465
464
  });
466
465
 
@@ -1303,5 +1302,66 @@ describe("drizzle-uow-compiler", () => {
1303
1302
  );
1304
1303
  expect(compiled.retrievalBatch[0].params).toEqual([1, sessionId]);
1305
1304
  });
1305
+
1306
+ it("should support creating and using ID in same UOW", () => {
1307
+ const uow = createAuthUOW("create-user-and-session");
1308
+
1309
+ // Create user and capture the returned ID
1310
+ const userId = uow.create("user", {
1311
+ email: "test@example.com",
1312
+ passwordHash: "hashed_password",
1313
+ });
1314
+
1315
+ // Use the returned FragnoId directly to create a session
1316
+ // The compiler should extract externalId and generate a subquery
1317
+ uow.create("session", {
1318
+ userId: userId,
1319
+ expiresAt: new Date("2025-12-31"),
1320
+ });
1321
+
1322
+ const compiler = createDrizzleUOWCompiler(authSchema, authPool, "postgresql");
1323
+ const compiled = uow.compile(compiler);
1324
+
1325
+ // Should have no retrieval operations
1326
+ expect(compiled.retrievalBatch).toHaveLength(0);
1327
+
1328
+ // Should have 2 mutation operations (create user, create session)
1329
+ expect(compiled.mutationBatch).toHaveLength(2);
1330
+
1331
+ const [userCreate, sessionCreate] = compiled.mutationBatch;
1332
+ assert(userCreate);
1333
+ assert(sessionCreate);
1334
+
1335
+ // Verify user create SQL
1336
+ expect(userCreate.query.sql).toMatchInlineSnapshot(
1337
+ `"insert into "user" ("id", "email", "passwordHash", "createdAt", "_internalId", "_version") values ($1, $2, $3, $4, default, default)"`,
1338
+ );
1339
+ expect(userCreate.query.params).toMatchObject([
1340
+ userId.externalId, // The generated ID
1341
+ "test@example.com",
1342
+ "hashed_password",
1343
+ expect.any(String), // timestamp
1344
+ ]);
1345
+ expect(userCreate.expectedAffectedRows).toBeNull();
1346
+
1347
+ // Verify session create SQL - FragnoId generates subquery to lookup internal ID
1348
+ expect(sessionCreate.query.sql).toMatchInlineSnapshot(
1349
+ `"insert into "session" ("id", "userId", "expiresAt", "createdAt", "_internalId", "_version") values ($1, (select "_internalId" from "user" where "id" = $2 limit 1), $3, $4, default, default)"`,
1350
+ );
1351
+ expect(sessionCreate.query.params).toMatchObject([
1352
+ expect.any(String), // generated session ID
1353
+ userId.externalId, // FragnoId's externalId is used in the subquery
1354
+ expect.any(String), // expiresAt timestamp
1355
+ expect.any(String), // createdAt timestamp
1356
+ ]);
1357
+ expect(sessionCreate.expectedAffectedRows).toBeNull();
1358
+
1359
+ // Verify the returned FragnoId has the expected structure
1360
+ expect(userId).toMatchObject({
1361
+ externalId: expect.any(String),
1362
+ version: 0,
1363
+ internalId: undefined,
1364
+ });
1365
+ });
1306
1366
  });
1307
1367
  });
@@ -784,4 +784,92 @@ describe("KyselyAdapter PGLite", () => {
784
784
  age: 60,
785
785
  });
786
786
  });
787
+
788
+ it("should handle timestamps and timezones correctly", async () => {
789
+ const queryEngine = adapter.createQueryEngine(testSchema, "test");
790
+
791
+ // Create a user
792
+ const userId = await queryEngine.create("users", {
793
+ name: "Timestamp Test User",
794
+ age: 28,
795
+ });
796
+
797
+ // Create a post
798
+ const postId = await queryEngine.create("posts", {
799
+ user_id: userId,
800
+ title: "Timestamp Test Post",
801
+ content: "Testing timestamp handling",
802
+ });
803
+
804
+ // Retrieve the post
805
+ const post = await queryEngine.findFirst("posts", (b) =>
806
+ b.whereIndex("primary", (eb) => eb("id", "=", postId)),
807
+ );
808
+
809
+ expect(post).toBeDefined();
810
+
811
+ // Test with a table that doesn't have timestamps
812
+ // Verify that Date handling works in general by checking basic Date operations
813
+ const now = new Date();
814
+ expect(now).toBeInstanceOf(Date);
815
+ expect(typeof now.getTime).toBe("function");
816
+ expect(typeof now.toISOString).toBe("function");
817
+
818
+ // Verify date serialization/deserialization works
819
+ const isoString = now.toISOString();
820
+ expect(typeof isoString).toBe("string");
821
+ expect(new Date(isoString).getTime()).toBe(now.getTime());
822
+
823
+ // Test timezone preservation
824
+ const specificDate = new Date("2024-06-15T14:30:00Z");
825
+ expect(specificDate.toISOString()).toBe("2024-06-15T14:30:00.000Z");
826
+
827
+ // Verify that dates from different timezones are handled correctly
828
+ const localDate = new Date("2024-06-15T14:30:00");
829
+ expect(localDate).toBeInstanceOf(Date);
830
+ expect(typeof localDate.getTimezoneOffset()).toBe("number");
831
+ });
832
+
833
+ it("should create user and post in same transaction using returned ID", async () => {
834
+ const queryEngine = adapter.createQueryEngine(testSchema, "test");
835
+
836
+ // Create UOW and create both user and post in same transaction
837
+ const uow = queryEngine.createUnitOfWork("create-user-and-post");
838
+
839
+ // Create user and capture the returned ID
840
+ const userId = uow.create("users", {
841
+ name: "UOW Test User",
842
+ age: 35,
843
+ });
844
+
845
+ // Use the returned FragnoId directly to create a post in the same transaction
846
+ // The compiler will extract externalId and generate a subquery to lookup the internal ID
847
+ const postId = uow.create("posts", {
848
+ user_id: userId,
849
+ title: "UOW Test Post",
850
+ content: "This post was created in the same transaction as the user",
851
+ });
852
+
853
+ // Execute all mutations in a single transaction
854
+ const { success } = await uow.executeMutations();
855
+ expect(success).toBe(true);
856
+
857
+ // Verify both records were created
858
+ const user = await queryEngine.findFirst("users", (b) =>
859
+ b.whereIndex("primary", (eb) => eb("id", "=", userId)),
860
+ );
861
+
862
+ expect(user?.name).toBe("UOW Test User");
863
+ expect(user?.age).toBe(35);
864
+
865
+ const post = await queryEngine.findFirst("posts", (b) =>
866
+ b.whereIndex("primary", (eb) => eb("id", "=", postId.externalId)),
867
+ );
868
+
869
+ expect(post?.title).toBe("UOW Test Post");
870
+ expect(post?.content).toBe("This post was created in the same transaction as the user");
871
+
872
+ // Verify the foreign key relationship is correct
873
+ expect(post?.user_id.internalId).toBe(user?.id.internalId);
874
+ });
787
875
  });
@@ -10,7 +10,7 @@ import type { AnySchema } from "../../schema/create";
10
10
  import type { CustomOperation, MigrationOperation } from "../../migration-engine/shared";
11
11
  import { execute, preprocessOperations } from "./migration/execute";
12
12
  import type { AbstractQuery } from "../../query/query";
13
- import { fromKysely } from "./kysely-query";
13
+ import { fromKysely, type KyselyUOWConfig } from "./kysely-query";
14
14
  import { createTableNameMapper } from "./kysely-shared";
15
15
  import { createHash } from "node:crypto";
16
16
  import { SETTINGS_TABLE_NAME } from "../../shared/settings-schema";
@@ -25,7 +25,7 @@ export interface KyselyConfig {
25
25
  provider: SQLProvider;
26
26
  }
27
27
 
28
- export class KyselyAdapter implements DatabaseAdapter {
28
+ export class KyselyAdapter implements DatabaseAdapter<KyselyUOWConfig> {
29
29
  #connectionPool: ConnectionPool<KyselyAny>;
30
30
  #provider: SQLProvider;
31
31
 
@@ -46,7 +46,10 @@ export class KyselyAdapter implements DatabaseAdapter {
46
46
  await this.#connectionPool.close();
47
47
  }
48
48
 
49
- createQueryEngine<T extends AnySchema>(schema: T, namespace: string): AbstractQuery<T> {
49
+ createQueryEngine<T extends AnySchema>(
50
+ schema: T,
51
+ namespace: string,
52
+ ): AbstractQuery<T, KyselyUOWConfig> {
50
53
  // Only create mapper if namespace is non-empty
51
54
  const mapper = namespace ? createTableNameMapper(namespace) : undefined;
52
55
  return fromKysely(schema, this.#connectionPool, this.#provider, mapper);