@fragno-dev/db 0.1.11 → 0.1.13

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 (71) hide show
  1. package/.turbo/turbo-build.log +41 -39
  2. package/CHANGELOG.md +19 -0
  3. package/dist/adapters/drizzle/drizzle-adapter.d.ts.map +1 -1
  4. package/dist/adapters/drizzle/drizzle-adapter.js +1 -1
  5. package/dist/adapters/drizzle/drizzle-query.d.ts.map +1 -1
  6. package/dist/adapters/drizzle/drizzle-query.js +42 -34
  7. package/dist/adapters/drizzle/drizzle-query.js.map +1 -1
  8. package/dist/adapters/drizzle/drizzle-uow-compiler.js +2 -1
  9. package/dist/adapters/drizzle/drizzle-uow-compiler.js.map +1 -1
  10. package/dist/adapters/drizzle/drizzle-uow-decoder.js +25 -1
  11. package/dist/adapters/drizzle/drizzle-uow-decoder.js.map +1 -1
  12. package/dist/adapters/drizzle/generate.js +1 -1
  13. package/dist/adapters/kysely/kysely-adapter.d.ts +4 -3
  14. package/dist/adapters/kysely/kysely-adapter.d.ts.map +1 -1
  15. package/dist/adapters/kysely/kysely-adapter.js.map +1 -1
  16. package/dist/adapters/kysely/kysely-query.d.ts +22 -0
  17. package/dist/adapters/kysely/kysely-query.d.ts.map +1 -0
  18. package/dist/adapters/kysely/kysely-query.js +101 -51
  19. package/dist/adapters/kysely/kysely-query.js.map +1 -1
  20. package/dist/adapters/kysely/kysely-uow-compiler.js +2 -1
  21. package/dist/adapters/kysely/kysely-uow-compiler.js.map +1 -1
  22. package/dist/adapters/kysely/kysely-uow-executor.js +2 -2
  23. package/dist/adapters/kysely/kysely-uow-executor.js.map +1 -1
  24. package/dist/adapters/kysely/migration/execute-base.js +1 -1
  25. package/dist/migration-engine/generation-engine.d.ts +1 -1
  26. package/dist/migration-engine/generation-engine.d.ts.map +1 -1
  27. package/dist/migration-engine/generation-engine.js.map +1 -1
  28. package/dist/mod.d.ts +7 -6
  29. package/dist/mod.d.ts.map +1 -1
  30. package/dist/mod.js +2 -1
  31. package/dist/mod.js.map +1 -1
  32. package/dist/query/cursor.d.ts +67 -32
  33. package/dist/query/cursor.d.ts.map +1 -1
  34. package/dist/query/cursor.js +84 -31
  35. package/dist/query/cursor.js.map +1 -1
  36. package/dist/query/query.d.ts +29 -8
  37. package/dist/query/query.d.ts.map +1 -1
  38. package/dist/query/result-transform.js +17 -5
  39. package/dist/query/result-transform.js.map +1 -1
  40. package/dist/query/unit-of-work.d.ts +19 -8
  41. package/dist/query/unit-of-work.d.ts.map +1 -1
  42. package/dist/query/unit-of-work.js +54 -12
  43. package/dist/query/unit-of-work.js.map +1 -1
  44. package/dist/schema/serialize.js +2 -0
  45. package/dist/schema/serialize.js.map +1 -1
  46. package/package.json +3 -3
  47. package/src/adapters/drizzle/drizzle-adapter-pglite.test.ts +242 -55
  48. package/src/adapters/drizzle/drizzle-adapter-sqlite.test.ts +95 -39
  49. package/src/adapters/drizzle/drizzle-query.test.ts +54 -4
  50. package/src/adapters/drizzle/drizzle-query.ts +74 -60
  51. package/src/adapters/drizzle/drizzle-uow-compiler.test.ts +82 -6
  52. package/src/adapters/drizzle/drizzle-uow-compiler.ts +3 -2
  53. package/src/adapters/drizzle/drizzle-uow-decoder.ts +40 -1
  54. package/src/adapters/kysely/kysely-adapter-pglite.test.ts +190 -4
  55. package/src/adapters/kysely/kysely-adapter.ts +6 -3
  56. package/src/adapters/kysely/kysely-query.test.ts +498 -0
  57. package/src/adapters/kysely/kysely-query.ts +187 -83
  58. package/src/adapters/kysely/kysely-uow-compiler.test.ts +85 -3
  59. package/src/adapters/kysely/kysely-uow-compiler.ts +3 -2
  60. package/src/adapters/kysely/kysely-uow-executor.ts +5 -9
  61. package/src/migration-engine/generation-engine.ts +2 -1
  62. package/src/mod.ts +12 -7
  63. package/src/query/cursor.test.ts +113 -68
  64. package/src/query/cursor.ts +127 -36
  65. package/src/query/query-type.test.ts +34 -14
  66. package/src/query/query.ts +94 -34
  67. package/src/query/result-transform.test.ts +5 -5
  68. package/src/query/result-transform.ts +29 -11
  69. package/src/query/unit-of-work.ts +141 -26
  70. package/src/schema/serialize.test.ts +223 -0
  71. package/src/schema/serialize.ts +16 -0
@@ -1,6 +1,11 @@
1
- import type { AbstractQuery } from "../../query/query";
2
- import type { AnySchema } from "../../schema/create";
3
- import type { CompiledMutation, UOWDecoder, UOWExecutor } from "../../query/unit-of-work";
1
+ import type { AbstractQuery, TableToUpdateValues } from "../../query/query";
2
+ import type { AnySchema, AnyTable } from "../../schema/create";
3
+ import type {
4
+ CompiledMutation,
5
+ UOWDecoder,
6
+ UOWExecutor,
7
+ ValidIndexName,
8
+ } from "../../query/unit-of-work";
4
9
  import { decodeResult } from "../../query/result-transform";
5
10
  import { createKyselyUOWCompiler } from "./kysely-uow-compiler";
6
11
  import { executeKyselyRetrievalPhase, executeKyselyMutationPhase } from "./kysely-uow-executor";
@@ -9,10 +14,58 @@ import type { CompiledQuery, Kysely } from "kysely";
9
14
  import type { TableNameMapper } from "./kysely-shared";
10
15
  import type { ConnectionPool } from "../../shared/connection-pool";
11
16
  import type { SQLProvider } from "../../shared/providers";
17
+ import { createCursorFromRecord, Cursor, type CursorResult } from "../../query/cursor";
12
18
 
13
19
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
14
20
  type KyselyAny = Kysely<any>;
15
21
 
22
+ /**
23
+ * Configuration options for creating a Kysely Unit of Work
24
+ */
25
+ export interface KyselyUOWConfig {
26
+ /**
27
+ * Optional callback to receive compiled SQL queries for logging/debugging
28
+ * This callback is invoked for each query as it's compiled
29
+ */
30
+ onQuery?: (query: CompiledQuery) => void;
31
+ /**
32
+ * If true, the query will not be executed and the query will be returned. Not respected for UOWs
33
+ * since those have to be manually executed.
34
+ */
35
+ dryRun?: boolean;
36
+ }
37
+
38
+ /**
39
+ * Special builder for updateMany operations that captures configuration
40
+ */
41
+ class UpdateManySpecialBuilder<TTable extends AnyTable> {
42
+ #indexName?: string;
43
+ #condition?: unknown;
44
+ #setValues?: TableToUpdateValues<TTable>;
45
+
46
+ whereIndex<TIndexName extends ValidIndexName<TTable>>(
47
+ indexName: TIndexName,
48
+ condition?: unknown,
49
+ ): this {
50
+ this.#indexName = indexName as string;
51
+ this.#condition = condition;
52
+ return this;
53
+ }
54
+
55
+ set(values: TableToUpdateValues<TTable>): this {
56
+ this.#setValues = values;
57
+ return this;
58
+ }
59
+
60
+ getConfig() {
61
+ return {
62
+ indexName: this.#indexName,
63
+ condition: this.#condition,
64
+ setValues: this.#setValues,
65
+ };
66
+ }
67
+ }
68
+
16
69
  /**
17
70
  * Creates a Kysely-based query engine for the given schema.
18
71
  *
@@ -42,12 +95,18 @@ export function fromKysely<T extends AnySchema>(
42
95
  pool: ConnectionPool<KyselyAny>,
43
96
  provider: SQLProvider,
44
97
  mapper?: TableNameMapper,
45
- ): AbstractQuery<T> {
46
- function createUOW(name?: string): UnitOfWork<T, []> {
98
+ uowConfig?: KyselyUOWConfig,
99
+ ): AbstractQuery<T, KyselyUOWConfig> {
100
+ function createUOW(opts: { name?: string; config?: KyselyUOWConfig }) {
47
101
  const uowCompiler = createKyselyUOWCompiler(schema, pool, provider, mapper);
48
102
 
49
103
  const executor: UOWExecutor<CompiledQuery, unknown> = {
50
104
  async executeRetrievalPhase(retrievalBatch: CompiledQuery[]) {
105
+ // In dryRun mode, skip execution and return empty results
106
+ if (opts.config?.dryRun) {
107
+ return retrievalBatch.map(() => []);
108
+ }
109
+
51
110
  const conn = await pool.connect();
52
111
  try {
53
112
  return await executeKyselyRetrievalPhase(conn.db, retrievalBatch);
@@ -56,6 +115,14 @@ export function fromKysely<T extends AnySchema>(
56
115
  }
57
116
  },
58
117
  async executeMutationPhase(mutationBatch: CompiledMutation<CompiledQuery>[]) {
118
+ // In dryRun mode, skip execution and return success with mock internal IDs
119
+ if (opts.config?.dryRun) {
120
+ return {
121
+ success: true,
122
+ createdInternalIds: mutationBatch.map(() => null),
123
+ };
124
+ }
125
+
59
126
  const conn = await pool.connect();
60
127
  try {
61
128
  return await executeKyselyMutationPhase(conn.db, mutationBatch);
@@ -93,27 +160,95 @@ export function fromKysely<T extends AnySchema>(
93
160
 
94
161
  // Each result is an array of rows - decode each row
95
162
  const rowArray = rows as Record<string, unknown>[];
96
- return rowArray.map((row) => decodeResult(row, op.table, provider));
163
+ const decodedRows = rowArray.map((row) => decodeResult(row, op.table, provider));
164
+
165
+ // If cursor generation is requested, wrap in CursorResult
166
+ if (op.withCursor) {
167
+ 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;
182
+ }
183
+ }
184
+
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
+ });
191
+ }
192
+ }
193
+
194
+ const result: CursorResult<unknown> = {
195
+ items: decodedRows,
196
+ cursor,
197
+ };
198
+ return result;
199
+ }
200
+
201
+ return decodedRows;
97
202
  });
98
203
  };
99
204
 
100
- return new UnitOfWork(schema, uowCompiler, executor, decoder, name);
205
+ const { onQuery, ...restUowConfig } = opts.config ?? {};
206
+
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);
219
+ },
220
+ });
101
221
  }
102
222
 
103
223
  return {
104
224
  async find(tableName, builderFn) {
105
- const uow = createUOW();
106
- uow.find(tableName, builderFn);
225
+ const uow = createUOW({ config: uowConfig });
226
+ // Safe: builderFn returns a FindBuilder (or void), which matches UnitOfWork signature
227
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
228
+ uow.find(tableName, builderFn as any);
107
229
  // executeRetrieve returns an array of results (one per find operation)
108
230
  // Since we only have one find, unwrap the first result
109
231
  const [result]: unknown[][] = await uow.executeRetrieve();
110
232
  return result ?? [];
111
233
  },
112
234
 
235
+ async findWithCursor(tableName, builderFn) {
236
+ // Safe: builderFn returns a FindBuilder, which matches UnitOfWork signature
237
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
238
+ const uow = createUOW({ config: uowConfig }).findWithCursor(tableName, builderFn as any);
239
+ // executeRetrieve returns an array of results (one per find operation)
240
+ // Since we only have one findWithCursor, unwrap the first result
241
+ const [result] = await uow.executeRetrieve();
242
+ return result as CursorResult<unknown>;
243
+ },
244
+
113
245
  async findFirst(tableName, builderFn) {
114
- const uow = createUOW();
246
+ const uow = createUOW({ config: uowConfig });
115
247
  if (builderFn) {
116
- uow.find(tableName, (b) => builderFn(b as never).pageSize(1));
248
+ uow.find(tableName, (b) => {
249
+ builderFn(b);
250
+ return b.pageSize(1);
251
+ });
117
252
  } else {
118
253
  uow.find(tableName, (b) => b.whereIndex("primary").pageSize(1));
119
254
  }
@@ -123,16 +258,15 @@ export function fromKysely<T extends AnySchema>(
123
258
  },
124
259
 
125
260
  async create(tableName, values) {
126
- const uow = createUOW();
261
+ const uow = createUOW({ config: uowConfig });
127
262
  uow.create(tableName, values);
128
263
  const { success } = await uow.executeMutations();
129
264
  if (!success) {
130
- // This should not happen because we don't `.check()` this call.
131
- // TODO: Verify what happens when there are unique constraints
132
265
  throw new Error("Failed to create record");
133
266
  }
134
267
 
135
- const [createdId] = uow.getCreatedIds();
268
+ const createdIds = uow.getCreatedIds();
269
+ const createdId = createdIds[0];
136
270
  if (!createdId) {
137
271
  throw new Error("Failed to get created ID");
138
272
  }
@@ -140,7 +274,7 @@ export function fromKysely<T extends AnySchema>(
140
274
  },
141
275
 
142
276
  async createMany(tableName, valuesArray) {
143
- const uow = createUOW();
277
+ const uow = createUOW({ config: uowConfig });
144
278
  for (const values of valuesArray) {
145
279
  uow.create(tableName, values);
146
280
  }
@@ -153,8 +287,8 @@ export function fromKysely<T extends AnySchema>(
153
287
  },
154
288
 
155
289
  async update(tableName, id, builderFn) {
156
- const uow = createUOW();
157
- uow.update(tableName, id, builderFn as never);
290
+ const uow = createUOW({ config: uowConfig });
291
+ uow.update(tableName, id, builderFn);
158
292
  const { success } = await uow.executeMutations();
159
293
  if (!success) {
160
294
  throw new Error("Failed to update record (version conflict or record not found)");
@@ -162,51 +296,41 @@ export function fromKysely<T extends AnySchema>(
162
296
  },
163
297
 
164
298
  async updateMany(tableName, builderFn) {
165
- // Create a special builder that captures both where and set operations
166
- let whereConfig: { indexName?: string; condition?: unknown } = {};
167
- let setValues: unknown;
168
-
169
- const specialBuilder = {
170
- whereIndex(indexName: string, condition?: unknown) {
171
- whereConfig = { indexName, condition };
172
- return this;
173
- },
174
- set(values: unknown) {
175
- setValues = values;
176
- return this;
177
- },
178
- };
299
+ const table = schema.tables[tableName];
300
+ if (!table) {
301
+ throw new Error(`Table ${tableName} not found in schema`);
302
+ }
179
303
 
304
+ const specialBuilder = new UpdateManySpecialBuilder<typeof table>();
180
305
  builderFn(specialBuilder);
181
306
 
182
- if (!whereConfig.indexName) {
307
+ const { indexName, condition, setValues } = specialBuilder.getConfig();
308
+
309
+ if (!indexName) {
183
310
  throw new Error("whereIndex() must be called in updateMany");
184
311
  }
185
312
  if (!setValues) {
186
313
  throw new Error("set() must be called in updateMany");
187
314
  }
188
315
 
189
- // First, find all matching records
190
- const findUow = createUOW();
316
+ const findUow = createUOW({ config: uowConfig });
191
317
  findUow.find(tableName, (b) => {
192
- if (whereConfig.condition) {
193
- return b.whereIndex(whereConfig.indexName as never, whereConfig.condition as never);
318
+ if (condition) {
319
+ // Safe: condition is captured from whereIndex call with proper typing
320
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
321
+ return b.whereIndex(indexName as ValidIndexName<typeof table>, condition as any);
194
322
  }
195
- return b.whereIndex(whereConfig.indexName as never);
323
+ return b.whereIndex(indexName as ValidIndexName<typeof table>);
196
324
  });
197
- const findResults: unknown[][] = await findUow.executeRetrieve();
198
- const records = findResults[0];
325
+ const [records]: unknown[][] = await findUow.executeRetrieve();
199
326
 
200
327
  if (!records || records.length === 0) {
201
328
  return;
202
329
  }
203
330
 
204
- // Now update all found records
205
- const updateUow = createUOW();
206
- for (const record of records as never as Array<{ id: unknown }>) {
207
- updateUow.update(tableName as string, record.id as string, (b) =>
208
- b.set(setValues as never),
209
- );
331
+ const updateUow = createUOW({ config: uowConfig });
332
+ for (const record of records as Array<{ id: unknown }>) {
333
+ updateUow.update(tableName, record.id as string, (b) => b.set(setValues));
210
334
  }
211
335
  const { success } = await updateUow.executeMutations();
212
336
  if (!success) {
@@ -215,8 +339,8 @@ export function fromKysely<T extends AnySchema>(
215
339
  },
216
340
 
217
341
  async delete(tableName, id, builderFn) {
218
- const uow = createUOW();
219
- uow.delete(tableName, id, builderFn as never);
342
+ const uow = createUOW({ config: uowConfig });
343
+ uow.delete(tableName, id, builderFn);
220
344
  const { success } = await uow.executeMutations();
221
345
  if (!success) {
222
346
  throw new Error("Failed to delete record (version conflict or record not found)");
@@ -224,43 +348,17 @@ export function fromKysely<T extends AnySchema>(
224
348
  },
225
349
 
226
350
  async deleteMany(tableName, builderFn) {
227
- // Create a special builder that captures where configuration
228
- let whereConfig: { indexName?: string; condition?: unknown } = {};
229
-
230
- const specialBuilder = {
231
- whereIndex(indexName: string, condition?: unknown) {
232
- whereConfig = { indexName, condition };
233
- return this;
234
- },
235
- };
236
-
237
- // Safe: Call builderFn to capture the configuration
238
- builderFn(specialBuilder as never);
239
-
240
- if (!whereConfig.indexName) {
241
- throw new Error("whereIndex() must be called in deleteMany");
242
- }
351
+ const findUow = createUOW({ config: uowConfig });
352
+ findUow.find(tableName, builderFn);
353
+ const [records]: unknown[][] = await findUow.executeRetrieve();
243
354
 
244
- // First, find all matching records
245
- const findUow = createUOW();
246
- findUow.find(tableName as string, (b) => {
247
- if (whereConfig.condition) {
248
- return b.whereIndex(whereConfig.indexName as never, whereConfig.condition as never);
249
- }
250
- return b.whereIndex(whereConfig.indexName as never);
251
- });
252
- const findResults2 = await findUow.executeRetrieve();
253
- const records = (findResults2 as unknown as [unknown])[0];
254
-
255
- // @ts-expect-error - Type narrowing doesn't work through unknown cast
256
355
  if (!records || records.length === 0) {
257
356
  return;
258
357
  }
259
358
 
260
- // Now delete all found records
261
- const deleteUow = createUOW();
262
- for (const record of records as never as Array<{ id: unknown }>) {
263
- deleteUow.delete(tableName as string, record.id as string);
359
+ const deleteUow = createUOW({ config: uowConfig });
360
+ for (const record of records as Array<{ id: unknown }>) {
361
+ deleteUow.delete(tableName, record.id as string);
264
362
  }
265
363
  const { success } = await deleteUow.executeMutations();
266
364
  if (!success) {
@@ -268,8 +366,14 @@ export function fromKysely<T extends AnySchema>(
268
366
  }
269
367
  },
270
368
 
271
- createUnitOfWork(name) {
272
- return createUOW(name);
369
+ createUnitOfWork(name, nestedUowConfig) {
370
+ return createUOW({
371
+ name,
372
+ config: {
373
+ ...uowConfig,
374
+ ...nestedUowConfig,
375
+ },
376
+ });
273
377
  },
274
- } as AbstractQuery<T>;
378
+ } as AbstractQuery<T, KyselyUOWConfig>;
275
379
  }
@@ -5,6 +5,7 @@ import { UnitOfWork, type UOWDecoder } from "../../query/unit-of-work";
5
5
  import { createKyselyUOWCompiler } from "./kysely-uow-compiler";
6
6
  import type { ConnectionPool } from "../../shared/connection-pool";
7
7
  import { createKyselyConnectionPool } from "./kysely-connection-pool";
8
+ import { Cursor } from "../../query/cursor";
8
9
 
9
10
  describe("kysely-uow-compiler", () => {
10
11
  const testSchema = schema((s) => {
@@ -252,7 +253,12 @@ describe("kysely-uow-compiler", () => {
252
253
 
253
254
  it("should compile find operation with cursor pagination using after", () => {
254
255
  const uow = createTestUOW();
255
- const cursor = "eyJpbmRleFZhbHVlcyI6eyJuYW1lIjoiQWxpY2UifSwiZGlyZWN0aW9uIjoiZm9yd2FyZCJ9"; // {"indexValues":{"name":"Alice"},"direction":"forward"}
256
+ const cursor = new Cursor({
257
+ indexName: "idx_name",
258
+ orderDirection: "asc",
259
+ pageSize: 10,
260
+ indexValues: { name: "Alice" },
261
+ });
256
262
  uow.find("users", (b) =>
257
263
  b.whereIndex("idx_name").orderByIndex("idx_name", "asc").after(cursor).pageSize(10),
258
264
  );
@@ -269,7 +275,12 @@ describe("kysely-uow-compiler", () => {
269
275
 
270
276
  it("should compile find operation with cursor pagination using before", () => {
271
277
  const uow = createTestUOW();
272
- const cursor = "eyJpbmRleFZhbHVlcyI6eyJuYW1lIjoiQm9iIn0sImRpcmVjdGlvbiI6ImJhY2t3YXJkIn0="; // {"indexValues":{"name":"Bob"},"direction":"backward"}
278
+ const cursor = new Cursor({
279
+ indexName: "idx_name",
280
+ orderDirection: "desc",
281
+ pageSize: 10,
282
+ indexValues: { name: "Bob" },
283
+ });
273
284
  uow.find("users", (b) =>
274
285
  b.whereIndex("idx_name").orderByIndex("idx_name", "desc").before(cursor).pageSize(10),
275
286
  );
@@ -286,7 +297,12 @@ describe("kysely-uow-compiler", () => {
286
297
 
287
298
  it("should compile find operation with cursor pagination and additional where conditions", () => {
288
299
  const uow = createTestUOW();
289
- const cursor = "eyJpbmRleFZhbHVlcyI6eyJuYW1lIjoiQWxpY2UifSwiZGlyZWN0aW9uIjoiZm9yd2FyZCJ9";
300
+ const cursor = new Cursor({
301
+ indexName: "idx_name",
302
+ orderDirection: "asc",
303
+ pageSize: 5,
304
+ indexValues: { name: "Alice" },
305
+ });
290
306
  uow.find("users", (b) =>
291
307
  b
292
308
  .whereIndex("idx_name", (eb) => eb("name", "starts with", "John"))
@@ -913,4 +929,70 @@ describe("kysely-uow-compiler", () => {
913
929
  expect(compiled.mutationBatch).toHaveLength(1);
914
930
  });
915
931
  });
932
+
933
+ describe("create and use ID in same UOW", () => {
934
+ it("should support creating and using ID in same UOW", () => {
935
+ const uow = createTestUOW("create-user-and-post");
936
+
937
+ // Create user and capture the returned ID
938
+ const userId = uow.create("users", {
939
+ name: "John Doe",
940
+ email: "john@example.com",
941
+ age: 30,
942
+ });
943
+
944
+ // Use the returned FragnoId directly to create a post
945
+ // The compiler should extract externalId and generate a subquery
946
+ uow.create("posts", {
947
+ userId: userId,
948
+ title: "My First Post",
949
+ content: "This is my first post",
950
+ });
951
+
952
+ const compiler = createKyselyUOWCompiler(testSchema, pool, "postgresql");
953
+ const compiled = uow.compile(compiler);
954
+
955
+ // Should have no retrieval operations
956
+ expect(compiled.retrievalBatch).toHaveLength(0);
957
+
958
+ // Should have 2 mutation operations (create user, create post)
959
+ expect(compiled.mutationBatch).toHaveLength(2);
960
+
961
+ const [userCreate, postCreate] = compiled.mutationBatch;
962
+ assert(userCreate);
963
+ assert(postCreate);
964
+
965
+ // Verify user create SQL
966
+ expect(userCreate.query.sql).toMatchInlineSnapshot(
967
+ `"insert into "users" ("id", "name", "email", "age") values ($1, $2, $3, $4) returning "users"."id" as "id", "users"."name" as "name", "users"."email" as "email", "users"."age" as "age", "users"."invitedBy" as "invitedBy", "users"."_internalId" as "_internalId", "users"."_version" as "_version""`,
968
+ );
969
+ expect(userCreate.query.parameters).toMatchObject([
970
+ userId.externalId, // The generated ID
971
+ "John Doe",
972
+ "john@example.com",
973
+ 30,
974
+ ]);
975
+ expect(userCreate.expectedAffectedRows).toBeNull();
976
+
977
+ // Verify post create SQL - FragnoId generates subquery to lookup internal ID
978
+ expect(postCreate.query.sql).toMatchInlineSnapshot(
979
+ `"insert into "posts" ("id", "title", "content", "userId") values ($1, $2, $3, (select "_internalId" from "users" where "id" = $4 limit $5)) returning "posts"."id" as "id", "posts"."title" as "title", "posts"."content" as "content", "posts"."userId" as "userId", "posts"."viewCount" as "viewCount", "posts"."_internalId" as "_internalId", "posts"."_version" as "_version""`,
980
+ );
981
+ expect(postCreate.query.parameters).toMatchObject([
982
+ expect.any(String), // generated post ID
983
+ "My First Post",
984
+ "This is my first post",
985
+ userId.externalId, // FragnoId's externalId is used in the subquery
986
+ 1, // limit parameter
987
+ ]);
988
+ expect(postCreate.expectedAffectedRows).toBeNull();
989
+
990
+ // Verify the returned FragnoId has the expected structure
991
+ expect(userId).toMatchObject({
992
+ externalId: expect.any(String),
993
+ version: 0,
994
+ internalId: undefined,
995
+ });
996
+ });
997
+ });
916
998
  });
@@ -105,8 +105,9 @@ export function createKyselyUOWCompiler<TSchema extends AnySchema>(
105
105
 
106
106
  if ((after || before) && indexColumns.length > 0) {
107
107
  const cursor = after || before;
108
- const cursorData = decodeCursor(cursor!);
109
- const serializedValues = serializeCursorValues(cursorData, indexColumns, provider);
108
+ // Decode cursor if it's a string, otherwise use it as-is
109
+ const cursorObj = typeof cursor === "string" ? decodeCursor(cursor!) : cursor!;
110
+ const serializedValues = serializeCursorValues(cursorObj, indexColumns, provider);
110
111
 
111
112
  // Build tuple comparison for cursor pagination
112
113
  // For "after" with "asc": (col1, col2, ...) > (val1, val2, ...)
@@ -1,4 +1,4 @@
1
- import type { Kysely, QueryResult } from "kysely";
1
+ import type { CompiledQuery, Kysely, QueryResult } from "kysely";
2
2
  import type { CompiledMutation, MutationResult } from "../../query/unit-of-work";
3
3
 
4
4
  function getAffectedRows(result: QueryResult<unknown>): number {
@@ -43,9 +43,7 @@ function getAffectedRows(result: QueryResult<unknown>): number {
43
43
  export async function executeKyselyRetrievalPhase(
44
44
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
45
45
  kysely: Kysely<any>,
46
- retrievalBatch: (Kysely<unknown>["executeQuery"] extends (query: infer Q) => unknown
47
- ? Q
48
- : never)[],
46
+ retrievalBatch: CompiledQuery[],
49
47
  ): Promise<unknown[]> {
50
48
  // If no retrieval operations, return empty array immediately
51
49
  if (retrievalBatch.length === 0) {
@@ -56,8 +54,8 @@ export async function executeKyselyRetrievalPhase(
56
54
 
57
55
  // Execute all retrieval queries inside a transaction for snapshot isolation
58
56
  await kysely.transaction().execute(async (tx) => {
59
- for (const query of retrievalBatch) {
60
- const result = await tx.executeQuery(query);
57
+ for (const compiledQuery of retrievalBatch) {
58
+ const result = await tx.executeQuery(compiledQuery);
61
59
  retrievalResults.push(result.rows);
62
60
  }
63
61
  });
@@ -87,9 +85,7 @@ export async function executeKyselyRetrievalPhase(
87
85
  export async function executeKyselyMutationPhase(
88
86
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
89
87
  kysely: Kysely<any>,
90
- mutationBatch: CompiledMutation<
91
- Kysely<unknown>["executeQuery"] extends (query: infer Q) => unknown ? Q : never
92
- >[],
88
+ mutationBatch: CompiledMutation<CompiledQuery>[],
93
89
  ): Promise<MutationResult> {
94
90
  // If there are no mutations, return success immediately
95
91
  if (mutationBatch.length === 0) {
@@ -34,7 +34,8 @@ export interface ExecuteMigrationResult {
34
34
  }
35
35
 
36
36
  export async function generateMigrationsOrSchema<
37
- const TDatabases extends FragnoDatabase<AnySchema>[],
37
+ // oxlint-disable-next-line no-explicit-any
38
+ const TDatabases extends FragnoDatabase<AnySchema, any>[],
38
39
  >(
39
40
  databases: TDatabases,
40
41
  options?: {
package/src/mod.ts CHANGED
@@ -1,8 +1,11 @@
1
1
  import type { DatabaseAdapter } from "./adapters/adapters";
2
2
  import type { AnySchema } from "./schema/create";
3
3
  import type { AbstractQuery } from "./query/query";
4
+ import type { CursorResult } from "./query/cursor";
5
+ import { Cursor } from "./query/cursor";
4
6
 
5
- export type { DatabaseAdapter };
7
+ export type { DatabaseAdapter, CursorResult };
8
+ export { Cursor };
6
9
 
7
10
  export const fragnoDatabaseFakeSymbol = "$fragno-database" as const;
8
11
  export const fragnoDatabaseLibraryVersion = "0.1" as const;
@@ -52,7 +55,7 @@ export class FragnoDatabaseDefinition<const T extends AnySchema> {
52
55
  /**
53
56
  * Creates a FragnoDatabase instance by binding an adapter to this definition.
54
57
  */
55
- create(adapter: DatabaseAdapter): FragnoDatabase<T> {
58
+ create<TUOWConfig = void>(adapter: DatabaseAdapter<TUOWConfig>): FragnoDatabase<T, TUOWConfig> {
56
59
  return new FragnoDatabase({
57
60
  namespace: this.#namespace,
58
61
  schema: this.#schema,
@@ -65,12 +68,12 @@ export class FragnoDatabaseDefinition<const T extends AnySchema> {
65
68
  * A Fragno database instance with a bound adapter.
66
69
  * Created from a FragnoDatabaseDefinition by calling .create(adapter).
67
70
  */
68
- export class FragnoDatabase<const T extends AnySchema> {
71
+ export class FragnoDatabase<const T extends AnySchema, TUOWConfig = void> {
69
72
  #namespace: string;
70
73
  #schema: T;
71
- #adapter: DatabaseAdapter;
74
+ #adapter: DatabaseAdapter<TUOWConfig>;
72
75
 
73
- constructor(options: { namespace: string; schema: T; adapter: DatabaseAdapter }) {
76
+ constructor(options: { namespace: string; schema: T; adapter: DatabaseAdapter<TUOWConfig> }) {
74
77
  this.#namespace = options.namespace;
75
78
  this.#schema = options.schema;
76
79
  this.#adapter = options.adapter;
@@ -80,7 +83,7 @@ export class FragnoDatabase<const T extends AnySchema> {
80
83
  return fragnoDatabaseFakeSymbol;
81
84
  }
82
85
 
83
- async createClient(): Promise<AbstractQuery<T>> {
86
+ async createClient(): Promise<AbstractQuery<T, TUOWConfig>> {
84
87
  const dbVersion = await this.#adapter.getSchemaVersion(this.#namespace);
85
88
  if (dbVersion !== this.#schema.version.toString()) {
86
89
  throw new Error(
@@ -112,7 +115,7 @@ export class FragnoDatabase<const T extends AnySchema> {
112
115
  return this.#schema;
113
116
  }
114
117
 
115
- get adapter(): DatabaseAdapter {
118
+ get adapter(): DatabaseAdapter<TUOWConfig> {
116
119
  return this.#adapter;
117
120
  }
118
121
  }
@@ -129,3 +132,5 @@ export {
129
132
  type FragnoPublicConfigWithDatabase,
130
133
  type DatabaseFragmentContext,
131
134
  } from "./fragment";
135
+
136
+ export { decodeCursor, type CursorData } from "./query/cursor";