@fragno-dev/db 0.0.1 → 0.1.0

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 (200) hide show
  1. package/.turbo/turbo-build.log +137 -13
  2. package/.turbo/turbo-test.log +36 -0
  3. package/CHANGELOG.md +7 -0
  4. package/dist/adapters/adapters.d.ts +18 -0
  5. package/dist/adapters/adapters.d.ts.map +1 -0
  6. package/dist/adapters/drizzle/drizzle-adapter.d.ts +21 -0
  7. package/dist/adapters/drizzle/drizzle-adapter.d.ts.map +1 -0
  8. package/dist/adapters/drizzle/drizzle-adapter.js +62 -0
  9. package/dist/adapters/drizzle/drizzle-adapter.js.map +1 -0
  10. package/dist/adapters/drizzle/drizzle-query.d.ts +17 -0
  11. package/dist/adapters/drizzle/drizzle-query.d.ts.map +1 -0
  12. package/dist/adapters/drizzle/drizzle-query.js +139 -0
  13. package/dist/adapters/drizzle/drizzle-query.js.map +1 -0
  14. package/dist/adapters/drizzle/drizzle-uow-compiler.d.ts +9 -0
  15. package/dist/adapters/drizzle/drizzle-uow-compiler.d.ts.map +1 -0
  16. package/dist/adapters/drizzle/drizzle-uow-compiler.js +300 -0
  17. package/dist/adapters/drizzle/drizzle-uow-compiler.js.map +1 -0
  18. package/dist/adapters/drizzle/drizzle-uow-decoder.js +82 -0
  19. package/dist/adapters/drizzle/drizzle-uow-decoder.js.map +1 -0
  20. package/dist/adapters/drizzle/drizzle-uow-executor.js +125 -0
  21. package/dist/adapters/drizzle/drizzle-uow-executor.js.map +1 -0
  22. package/dist/adapters/drizzle/generate.js +273 -0
  23. package/dist/adapters/drizzle/generate.js.map +1 -0
  24. package/dist/adapters/drizzle/join-column-utils.js +28 -0
  25. package/dist/adapters/drizzle/join-column-utils.js.map +1 -0
  26. package/dist/adapters/drizzle/shared.js +11 -0
  27. package/dist/adapters/drizzle/shared.js.map +1 -0
  28. package/dist/adapters/kysely/kysely-adapter.d.ts +23 -0
  29. package/dist/adapters/kysely/kysely-adapter.d.ts.map +1 -0
  30. package/dist/adapters/kysely/kysely-adapter.js +119 -0
  31. package/dist/adapters/kysely/kysely-adapter.js.map +1 -0
  32. package/dist/adapters/kysely/kysely-query-builder.js +306 -0
  33. package/dist/adapters/kysely/kysely-query-builder.js.map +1 -0
  34. package/dist/adapters/kysely/kysely-query-compiler.js +67 -0
  35. package/dist/adapters/kysely/kysely-query-compiler.js.map +1 -0
  36. package/dist/adapters/kysely/kysely-query.js +158 -0
  37. package/dist/adapters/kysely/kysely-query.js.map +1 -0
  38. package/dist/adapters/kysely/kysely-uow-compiler.js +139 -0
  39. package/dist/adapters/kysely/kysely-uow-compiler.js.map +1 -0
  40. package/dist/adapters/kysely/kysely-uow-executor.js +89 -0
  41. package/dist/adapters/kysely/kysely-uow-executor.js.map +1 -0
  42. package/dist/adapters/kysely/migration/execute.js +176 -0
  43. package/dist/adapters/kysely/migration/execute.js.map +1 -0
  44. package/dist/fragment.d.ts +54 -0
  45. package/dist/fragment.d.ts.map +1 -0
  46. package/dist/fragment.js +92 -0
  47. package/dist/fragment.js.map +1 -0
  48. package/dist/id.d.ts +2 -0
  49. package/dist/migration-engine/auto-from-schema.js +116 -0
  50. package/dist/migration-engine/auto-from-schema.js.map +1 -0
  51. package/dist/migration-engine/create.d.ts +41 -0
  52. package/dist/migration-engine/create.d.ts.map +1 -0
  53. package/dist/migration-engine/create.js +58 -0
  54. package/dist/migration-engine/create.js.map +1 -0
  55. package/dist/migration-engine/shared.d.ts +90 -0
  56. package/dist/migration-engine/shared.d.ts.map +1 -0
  57. package/dist/migration-engine/shared.js +8 -0
  58. package/dist/migration-engine/shared.js.map +1 -0
  59. package/dist/mod.d.ts +55 -2
  60. package/dist/mod.d.ts.map +1 -1
  61. package/dist/mod.js +111 -2
  62. package/dist/mod.js.map +1 -1
  63. package/dist/node_modules/.bun/drizzle-orm@0.44.6_4fae081eecb963e2/node_modules/drizzle-orm/column-builder.js +108 -0
  64. package/dist/node_modules/.bun/drizzle-orm@0.44.6_4fae081eecb963e2/node_modules/drizzle-orm/column-builder.js.map +1 -0
  65. package/dist/node_modules/.bun/drizzle-orm@0.44.6_4fae081eecb963e2/node_modules/drizzle-orm/column.js +55 -0
  66. package/dist/node_modules/.bun/drizzle-orm@0.44.6_4fae081eecb963e2/node_modules/drizzle-orm/column.js.map +1 -0
  67. package/dist/node_modules/.bun/drizzle-orm@0.44.6_4fae081eecb963e2/node_modules/drizzle-orm/entity.js +18 -0
  68. package/dist/node_modules/.bun/drizzle-orm@0.44.6_4fae081eecb963e2/node_modules/drizzle-orm/entity.js.map +1 -0
  69. package/dist/node_modules/.bun/drizzle-orm@0.44.6_4fae081eecb963e2/node_modules/drizzle-orm/pg-core/columns/common.js +183 -0
  70. package/dist/node_modules/.bun/drizzle-orm@0.44.6_4fae081eecb963e2/node_modules/drizzle-orm/pg-core/columns/common.js.map +1 -0
  71. package/dist/node_modules/.bun/drizzle-orm@0.44.6_4fae081eecb963e2/node_modules/drizzle-orm/pg-core/columns/enum.js +58 -0
  72. package/dist/node_modules/.bun/drizzle-orm@0.44.6_4fae081eecb963e2/node_modules/drizzle-orm/pg-core/columns/enum.js.map +1 -0
  73. package/dist/node_modules/.bun/drizzle-orm@0.44.6_4fae081eecb963e2/node_modules/drizzle-orm/pg-core/foreign-keys.js +68 -0
  74. package/dist/node_modules/.bun/drizzle-orm@0.44.6_4fae081eecb963e2/node_modules/drizzle-orm/pg-core/foreign-keys.js.map +1 -0
  75. package/dist/node_modules/.bun/drizzle-orm@0.44.6_4fae081eecb963e2/node_modules/drizzle-orm/pg-core/unique-constraint.js +56 -0
  76. package/dist/node_modules/.bun/drizzle-orm@0.44.6_4fae081eecb963e2/node_modules/drizzle-orm/pg-core/unique-constraint.js.map +1 -0
  77. package/dist/node_modules/.bun/drizzle-orm@0.44.6_4fae081eecb963e2/node_modules/drizzle-orm/pg-core/utils/array.js +65 -0
  78. package/dist/node_modules/.bun/drizzle-orm@0.44.6_4fae081eecb963e2/node_modules/drizzle-orm/pg-core/utils/array.js.map +1 -0
  79. package/dist/node_modules/.bun/drizzle-orm@0.44.6_4fae081eecb963e2/node_modules/drizzle-orm/sql/expressions/conditions.js +81 -0
  80. package/dist/node_modules/.bun/drizzle-orm@0.44.6_4fae081eecb963e2/node_modules/drizzle-orm/sql/expressions/conditions.js.map +1 -0
  81. package/dist/node_modules/.bun/drizzle-orm@0.44.6_4fae081eecb963e2/node_modules/drizzle-orm/sql/expressions/select.js +13 -0
  82. package/dist/node_modules/.bun/drizzle-orm@0.44.6_4fae081eecb963e2/node_modules/drizzle-orm/sql/expressions/select.js.map +1 -0
  83. package/dist/node_modules/.bun/drizzle-orm@0.44.6_4fae081eecb963e2/node_modules/drizzle-orm/sql/functions/aggregate.js +10 -0
  84. package/dist/node_modules/.bun/drizzle-orm@0.44.6_4fae081eecb963e2/node_modules/drizzle-orm/sql/functions/aggregate.js.map +1 -0
  85. package/dist/node_modules/.bun/drizzle-orm@0.44.6_4fae081eecb963e2/node_modules/drizzle-orm/sql/sql.js +372 -0
  86. package/dist/node_modules/.bun/drizzle-orm@0.44.6_4fae081eecb963e2/node_modules/drizzle-orm/sql/sql.js.map +1 -0
  87. package/dist/node_modules/.bun/drizzle-orm@0.44.6_4fae081eecb963e2/node_modules/drizzle-orm/subquery.js +23 -0
  88. package/dist/node_modules/.bun/drizzle-orm@0.44.6_4fae081eecb963e2/node_modules/drizzle-orm/subquery.js.map +1 -0
  89. package/dist/node_modules/.bun/drizzle-orm@0.44.6_4fae081eecb963e2/node_modules/drizzle-orm/table.js +62 -0
  90. package/dist/node_modules/.bun/drizzle-orm@0.44.6_4fae081eecb963e2/node_modules/drizzle-orm/table.js.map +1 -0
  91. package/dist/node_modules/.bun/drizzle-orm@0.44.6_4fae081eecb963e2/node_modules/drizzle-orm/table.utils.js +6 -0
  92. package/dist/node_modules/.bun/drizzle-orm@0.44.6_4fae081eecb963e2/node_modules/drizzle-orm/table.utils.js.map +1 -0
  93. package/dist/node_modules/.bun/drizzle-orm@0.44.6_4fae081eecb963e2/node_modules/drizzle-orm/tracing-utils.js +8 -0
  94. package/dist/node_modules/.bun/drizzle-orm@0.44.6_4fae081eecb963e2/node_modules/drizzle-orm/tracing-utils.js.map +1 -0
  95. package/dist/node_modules/.bun/drizzle-orm@0.44.6_4fae081eecb963e2/node_modules/drizzle-orm/tracing.js +8 -0
  96. package/dist/node_modules/.bun/drizzle-orm@0.44.6_4fae081eecb963e2/node_modules/drizzle-orm/tracing.js.map +1 -0
  97. package/dist/node_modules/.bun/drizzle-orm@0.44.6_4fae081eecb963e2/node_modules/drizzle-orm/view-common.js +6 -0
  98. package/dist/node_modules/.bun/drizzle-orm@0.44.6_4fae081eecb963e2/node_modules/drizzle-orm/view-common.js.map +1 -0
  99. package/dist/query/condition-builder.d.ts +41 -0
  100. package/dist/query/condition-builder.d.ts.map +1 -0
  101. package/dist/query/condition-builder.js +93 -0
  102. package/dist/query/condition-builder.js.map +1 -0
  103. package/dist/query/cursor.d.ts +88 -0
  104. package/dist/query/cursor.d.ts.map +1 -0
  105. package/dist/query/cursor.js +103 -0
  106. package/dist/query/cursor.js.map +1 -0
  107. package/dist/query/orm/orm.d.ts +18 -0
  108. package/dist/query/orm/orm.d.ts.map +1 -0
  109. package/dist/query/orm/orm.js +48 -0
  110. package/dist/query/orm/orm.js.map +1 -0
  111. package/dist/query/query.d.ts +79 -0
  112. package/dist/query/query.d.ts.map +1 -0
  113. package/dist/query/query.js +1 -0
  114. package/dist/query/result-transform.js +155 -0
  115. package/dist/query/result-transform.js.map +1 -0
  116. package/dist/query/unit-of-work.d.ts +435 -0
  117. package/dist/query/unit-of-work.d.ts.map +1 -0
  118. package/dist/query/unit-of-work.js +549 -0
  119. package/dist/query/unit-of-work.js.map +1 -0
  120. package/dist/schema/create.d.ts +273 -116
  121. package/dist/schema/create.d.ts.map +1 -1
  122. package/dist/schema/create.js +410 -222
  123. package/dist/schema/create.js.map +1 -1
  124. package/dist/schema/serialize.js +101 -0
  125. package/dist/schema/serialize.js.map +1 -0
  126. package/dist/schema-generator/schema-generator.d.ts +15 -0
  127. package/dist/schema-generator/schema-generator.d.ts.map +1 -0
  128. package/dist/shared/providers.d.ts +6 -0
  129. package/dist/shared/providers.d.ts.map +1 -0
  130. package/dist/util/import-generator.js +26 -0
  131. package/dist/util/import-generator.js.map +1 -0
  132. package/dist/util/parse.js +15 -0
  133. package/dist/util/parse.js.map +1 -0
  134. package/dist/util/types.d.ts +8 -0
  135. package/dist/util/types.d.ts.map +1 -0
  136. package/package.json +63 -2
  137. package/src/adapters/adapters.ts +22 -0
  138. package/src/adapters/drizzle/drizzle-adapter-pglite.test.ts +433 -0
  139. package/src/adapters/drizzle/drizzle-adapter.test.ts +122 -0
  140. package/src/adapters/drizzle/drizzle-adapter.ts +118 -0
  141. package/src/adapters/drizzle/drizzle-query.ts +234 -0
  142. package/src/adapters/drizzle/drizzle-uow-compiler.test.ts +1084 -0
  143. package/src/adapters/drizzle/drizzle-uow-compiler.ts +546 -0
  144. package/src/adapters/drizzle/drizzle-uow-decoder.ts +165 -0
  145. package/src/adapters/drizzle/drizzle-uow-executor.ts +213 -0
  146. package/src/adapters/drizzle/generate.test.ts +643 -0
  147. package/src/adapters/drizzle/generate.ts +481 -0
  148. package/src/adapters/drizzle/join-column-utils.test.ts +79 -0
  149. package/src/adapters/drizzle/join-column-utils.ts +39 -0
  150. package/src/adapters/drizzle/migrate-drizzle.test.ts +226 -0
  151. package/src/adapters/drizzle/shared.ts +22 -0
  152. package/src/adapters/drizzle/test-utils.ts +56 -0
  153. package/src/adapters/kysely/kysely-adapter-pglite.test.ts +789 -0
  154. package/src/adapters/kysely/kysely-adapter.ts +196 -0
  155. package/src/adapters/kysely/kysely-query-builder.test.ts +1344 -0
  156. package/src/adapters/kysely/kysely-query-builder.ts +611 -0
  157. package/src/adapters/kysely/kysely-query-compiler.ts +124 -0
  158. package/src/adapters/kysely/kysely-query.ts +254 -0
  159. package/src/adapters/kysely/kysely-uow-compiler.test.ts +916 -0
  160. package/src/adapters/kysely/kysely-uow-compiler.ts +271 -0
  161. package/src/adapters/kysely/kysely-uow-executor.ts +149 -0
  162. package/src/adapters/kysely/kysely-uow-joins.test.ts +811 -0
  163. package/src/adapters/kysely/migration/execute-mysql.test.ts +1173 -0
  164. package/src/adapters/kysely/migration/execute-postgres.test.ts +2657 -0
  165. package/src/adapters/kysely/migration/execute.ts +382 -0
  166. package/src/adapters/kysely/migration/kysely-migrator.test.ts +197 -0
  167. package/src/fragment.test.ts +287 -0
  168. package/src/fragment.ts +198 -0
  169. package/src/migration-engine/auto-from-schema.test.ts +118 -58
  170. package/src/migration-engine/auto-from-schema.ts +103 -32
  171. package/src/migration-engine/create.test.ts +34 -46
  172. package/src/migration-engine/create.ts +41 -26
  173. package/src/migration-engine/shared.ts +26 -6
  174. package/src/mod.ts +197 -1
  175. package/src/query/condition-builder.test.ts +379 -0
  176. package/src/query/condition-builder.ts +294 -0
  177. package/src/query/cursor.test.ts +296 -0
  178. package/src/query/cursor.ts +147 -0
  179. package/src/query/orm/orm.ts +92 -0
  180. package/src/query/query-type.test.ts +429 -0
  181. package/src/query/query.ts +200 -0
  182. package/src/query/result-transform.test.ts +795 -0
  183. package/src/query/result-transform.ts +247 -0
  184. package/src/query/unit-of-work-types.test.ts +192 -0
  185. package/src/query/unit-of-work.test.ts +947 -0
  186. package/src/query/unit-of-work.ts +1199 -0
  187. package/src/schema/create.test.ts +653 -110
  188. package/src/schema/create.ts +708 -337
  189. package/src/schema/serialize.test.ts +559 -0
  190. package/src/schema/serialize.ts +359 -0
  191. package/src/schema-generator/schema-generator.ts +12 -0
  192. package/src/shared/config.ts +0 -8
  193. package/src/util/import-generator.ts +28 -0
  194. package/src/util/parse.ts +16 -0
  195. package/src/util/types.ts +4 -0
  196. package/tsconfig.json +1 -1
  197. package/tsdown.config.ts +11 -1
  198. package/vitest.config.ts +3 -0
  199. /package/dist/{cuid.js → id.js} +0 -0
  200. /package/src/{cuid.ts → id.ts} +0 -0
@@ -0,0 +1,1199 @@
1
+ import type { AnySchema, AnyTable, Index, IdColumn, AnyColumn, Relation } from "../schema/create";
2
+ import { FragnoId } from "../schema/create";
3
+ import type { Condition, ConditionBuilder } from "./condition-builder";
4
+ import type { SelectClause, TableToInsertValues, TableToUpdateValues, SelectResult } from "./query";
5
+ import { buildCondition } from "./condition-builder";
6
+ import type { CompiledJoin } from "./orm/orm";
7
+
8
+ /**
9
+ * Builder for updateMany operations that supports both whereIndex and set chaining
10
+ */
11
+ export interface UpdateManyBuilder<TTable extends AnyTable> {
12
+ whereIndex<TIndexName extends ValidIndexName<TTable>>(
13
+ indexName: TIndexName,
14
+ condition?: (eb: IndexSpecificConditionBuilder<TTable, TIndexName>) => Condition | boolean,
15
+ ): this;
16
+ set(values: TableToUpdateValues<TTable>): this;
17
+ }
18
+
19
+ /**
20
+ * Extract column names from a single index
21
+ */
22
+ export type IndexColumns<TIndex extends Index> = TIndex["columnNames"][number];
23
+
24
+ type RemoveEmptyObject<T> = T extends object ? (keyof T extends never ? never : T) : never;
25
+
26
+ /**
27
+ * Extract all indexed column names from a table's indexes
28
+ */
29
+ type IndexedColumns<TIndexes extends Record<string, Index>> = TIndexes[keyof TIndexes] extends Index
30
+ ? IndexColumns<TIndexes[keyof TIndexes]>
31
+ : never;
32
+
33
+ type OmitNever<T> = { [K in keyof T as T[K] extends never ? never : K]: T[K] };
34
+
35
+ /**
36
+ * Extract the name of the ID column from a table
37
+ * Checks if column has 'id' property set to true (which IdColumn class has)
38
+ */
39
+ export type InferIdColumnName<TTable extends AnyTable> = keyof OmitNever<{
40
+ [K in keyof TTable["columns"]]: TTable["columns"][K] extends IdColumn<
41
+ infer _,
42
+ infer __,
43
+ infer ___
44
+ >
45
+ ? K
46
+ : never;
47
+ }>;
48
+
49
+ /**
50
+ * Get the columns for a specific index name.
51
+ * For "primary", returns only the ID column.
52
+ * For named indexes, returns the columns defined in that index.
53
+ */
54
+ type ColumnsForIndex<
55
+ TTable extends AnyTable,
56
+ TIndexName extends ValidIndexName<TTable>,
57
+ > = TIndexName extends "primary"
58
+ ? Pick<TTable["columns"], InferIdColumnName<TTable>>
59
+ : TIndexName extends keyof TTable["indexes"]
60
+ ? Pick<TTable["columns"], IndexColumns<TTable["indexes"][TIndexName]>>
61
+ : never;
62
+
63
+ /**
64
+ * ConditionBuilder restricted to indexed columns only.
65
+ * Used throughout Unit of Work to ensure all queries can leverage indexes for optimal performance.
66
+ */
67
+ export type IndexedConditionBuilder<TTable extends AnyTable> = ConditionBuilder<
68
+ Pick<TTable["columns"], IndexedColumns<TTable["indexes"]>>
69
+ >;
70
+
71
+ /**
72
+ * ConditionBuilder restricted to columns in a specific index.
73
+ */
74
+ type IndexSpecificConditionBuilder<
75
+ TTable extends AnyTable,
76
+ TIndexName extends ValidIndexName<TTable>,
77
+ > = ConditionBuilder<ColumnsForIndex<TTable, TIndexName>>;
78
+
79
+ /**
80
+ * Valid index names for a table, including the static "primary" index
81
+ */
82
+ export type ValidIndexName<TTable extends AnyTable> =
83
+ | "primary"
84
+ | (string & keyof TTable["indexes"]);
85
+
86
+ /**
87
+ * Find options for Unit of Work (internal, used after builder finalization)
88
+ */
89
+ type FindOptions<
90
+ TTable extends AnyTable = AnyTable,
91
+ TSelect extends SelectClause<TTable> = SelectClause<TTable>,
92
+ > = {
93
+ /**
94
+ * Which index to use for this query (required)
95
+ */
96
+ useIndex: string;
97
+ /**
98
+ * Select clause - which columns to return
99
+ */
100
+ select?: TSelect;
101
+ /**
102
+ * Where clause - filtering restricted to indexed columns only
103
+ */
104
+ where?: (eb: IndexedConditionBuilder<TTable>) => Condition | boolean;
105
+ /**
106
+ * Order by index - specify which index to order by and direction
107
+ */
108
+ orderByIndex?: {
109
+ indexName: string;
110
+ direction: "asc" | "desc";
111
+ };
112
+ /**
113
+ * Cursor for pagination - continue after this cursor
114
+ */
115
+ after?: string;
116
+ /**
117
+ * Cursor for pagination - continue before this cursor
118
+ */
119
+ before?: string;
120
+ /**
121
+ * Number of results per page
122
+ */
123
+ pageSize?: number;
124
+ /**
125
+ * Join operations to include related data
126
+ */
127
+ joins?: CompiledJoin[];
128
+ };
129
+
130
+ /**
131
+ * Unit of Work state machine
132
+ */
133
+ export type UOWState = "building-retrieval" | "building-mutation" | "executed";
134
+
135
+ /**
136
+ * Retrieval operation - read operations in the first phase
137
+ */
138
+ export type RetrievalOperation<
139
+ TSchema extends AnySchema,
140
+ TTable extends AnyTable = TSchema["tables"][keyof TSchema["tables"]],
141
+ > =
142
+ | {
143
+ type: "find";
144
+ table: TTable;
145
+ indexName: string;
146
+ options: FindOptions<TTable, SelectClause<TTable>>;
147
+ }
148
+ | {
149
+ type: "count";
150
+ table: TTable;
151
+ indexName: string;
152
+ options: Pick<FindOptions<TTable>, "where" | "useIndex">;
153
+ };
154
+
155
+ /**
156
+ * Mutation operations - write operations in the second phase
157
+ */
158
+ export type MutationOperation<
159
+ TSchema extends AnySchema,
160
+ TTable extends AnyTable = TSchema["tables"][keyof TSchema["tables"]],
161
+ > =
162
+ | {
163
+ type: "update";
164
+ table: TTable["name"];
165
+ id: FragnoId | string;
166
+ checkVersion: boolean;
167
+ set: TableToUpdateValues<TTable>;
168
+ }
169
+ | {
170
+ type: "create";
171
+ table: TTable["name"];
172
+ values: TableToInsertValues<TTable>;
173
+ generatedExternalId: string;
174
+ }
175
+ | {
176
+ type: "delete";
177
+ table: TTable["name"];
178
+ id: FragnoId | string;
179
+ checkVersion: boolean;
180
+ };
181
+
182
+ /**
183
+ * Compiled mutation with metadata for execution
184
+ */
185
+ export interface CompiledMutation<TOutput> {
186
+ query: TOutput;
187
+ /**
188
+ * Number of rows this operation must affect for the transaction to succeed.
189
+ * If actual affected rows doesn't match, it indicates a version conflict.
190
+ * null means don't check affected rows (e.g., for create operations).
191
+ */
192
+ expectedAffectedRows: number | null;
193
+ }
194
+
195
+ /**
196
+ * Compiler interface for Unit of Work operations
197
+ */
198
+ export interface UOWCompiler<TSchema extends AnySchema, TOutput> {
199
+ /**
200
+ * Compile a retrieval operation to the adapter's query format
201
+ */
202
+ compileRetrievalOperation(op: RetrievalOperation<TSchema>): TOutput | null;
203
+
204
+ /**
205
+ * Compile a mutation operation to the adapter's query format
206
+ */
207
+ compileMutationOperation(op: MutationOperation<TSchema>): CompiledMutation<TOutput> | null;
208
+ }
209
+
210
+ export type MutationResult =
211
+ | { success: true; createdInternalIds: (bigint | null)[] }
212
+ | { success: false };
213
+
214
+ /**
215
+ * Executor interface for Unit of Work operations
216
+ */
217
+ export interface UOWExecutor<TOutput, TRawResult = unknown> {
218
+ /**
219
+ * Execute the retrieval phase - all queries run in a single transaction for snapshot isolation
220
+ */
221
+ executeRetrievalPhase(retrievalBatch: TOutput[]): Promise<TRawResult[]>;
222
+
223
+ /**
224
+ * Execute the mutation phase - all queries run in a transaction with version checks
225
+ * Returns success status indicating if mutations completed without conflicts,
226
+ * and internal IDs for create operations (null if database doesn't support RETURNING)
227
+ */
228
+ executeMutationPhase(mutationBatch: CompiledMutation<TOutput>[]): Promise<MutationResult>;
229
+ }
230
+
231
+ /**
232
+ * Decoder interface for Unit of Work retrieval results
233
+ *
234
+ * Transforms raw database results into application format (e.g., converting raw columns
235
+ * into FragnoId objects with external ID, internal ID, and version).
236
+ */
237
+ export interface UOWDecoder<TSchema extends AnySchema, TRawInput = unknown> {
238
+ /**
239
+ * Decode raw database results from the retrieval phase
240
+ *
241
+ * @param rawResults - Array of raw result sets from database queries
242
+ * @param operations - Array of retrieval operations that produced these results
243
+ * @returns Decoded results in application format
244
+ */
245
+ (rawResults: TRawInput[], operations: RetrievalOperation<TSchema>[]): unknown[];
246
+ }
247
+
248
+ /**
249
+ * Builder for find operations in Unit of Work
250
+ */
251
+ export class FindBuilder<
252
+ TTable extends AnyTable,
253
+ TSelect extends SelectClause<TTable> = true,
254
+ TJoinOut = {},
255
+ > {
256
+ readonly #table: TTable;
257
+ readonly #tableName: string;
258
+
259
+ #indexName?: string;
260
+ #whereClause?: (eb: IndexedConditionBuilder<TTable>) => Condition | boolean;
261
+ #orderByIndexClause?: {
262
+ indexName: string;
263
+ direction: "asc" | "desc";
264
+ };
265
+ #afterCursor?: string;
266
+ #beforeCursor?: string;
267
+ #pageSizeValue?: number;
268
+ #selectClause?: TSelect;
269
+ #joinClause?: (jb: IndexedJoinBuilder<TTable, {}>) => IndexedJoinBuilder<TTable, TJoinOut>;
270
+ #countMode = false;
271
+
272
+ constructor(tableName: string, table: TTable) {
273
+ this.#tableName = tableName;
274
+ this.#table = table;
275
+ }
276
+
277
+ /**
278
+ * Specify which index to use and optionally filter the results
279
+ */
280
+ whereIndex<TIndexName extends ValidIndexName<TTable>>(
281
+ indexName: TIndexName,
282
+ condition?: (eb: IndexSpecificConditionBuilder<TTable, TIndexName>) => Condition | boolean,
283
+ ): this {
284
+ // Validate index exists (primary is always valid)
285
+ if (indexName !== "primary" && !(indexName in this.#table.indexes)) {
286
+ throw new Error(
287
+ `Index "${String(indexName)}" not found on table "${this.#tableName}". ` +
288
+ `Available indexes: primary, ${Object.keys(this.#table.indexes).join(", ")}`,
289
+ );
290
+ }
291
+
292
+ this.#indexName = indexName === "primary" ? "_primary" : indexName;
293
+ if (condition) {
294
+ // Safe: IndexSpecificConditionBuilder is a subset of IndexedConditionBuilder.
295
+ // The condition will only reference columns in the specific index, which are also indexed columns.
296
+ this.#whereClause = condition as unknown as (
297
+ eb: IndexedConditionBuilder<TTable>,
298
+ ) => Condition | boolean;
299
+ }
300
+ return this;
301
+ }
302
+
303
+ /**
304
+ * Specify columns to select
305
+ * @throws Error if selectCount() has already been called
306
+ */
307
+ select<const TNewSelect extends SelectClause<TTable>>(
308
+ columns: TNewSelect,
309
+ ): FindBuilder<TTable, TNewSelect, TJoinOut> {
310
+ if (this.#countMode) {
311
+ throw new Error(
312
+ `Cannot call select() after selectCount() on table "${this.#tableName}". ` +
313
+ `Use either select() or selectCount(), not both.`,
314
+ );
315
+ }
316
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
317
+ (this as any).#selectClause = columns;
318
+ return this as unknown as FindBuilder<TTable, TNewSelect, TJoinOut>;
319
+ }
320
+
321
+ /**
322
+ * Select count instead of records
323
+ * @throws Error if select() has already been called
324
+ */
325
+ selectCount(): this {
326
+ if (this.#selectClause !== undefined) {
327
+ throw new Error(
328
+ `Cannot call selectCount() after select() on table "${this.#tableName}". ` +
329
+ `Use either select() or selectCount(), not both.`,
330
+ );
331
+ }
332
+ this.#countMode = true;
333
+ return this;
334
+ }
335
+
336
+ /**
337
+ * Order results by index in ascending or descending order
338
+ */
339
+ orderByIndex<TIndexName extends ValidIndexName<TTable>>(
340
+ indexName: TIndexName,
341
+ direction: "asc" | "desc",
342
+ ): this {
343
+ // Validate index exists (primary is always valid)
344
+ if (indexName !== "primary" && !(indexName in this.#table.indexes)) {
345
+ throw new Error(
346
+ `Index "${String(indexName)}" not found on table "${this.#tableName}". ` +
347
+ `Available indexes: primary, ${Object.keys(this.#table.indexes).join(", ")}`,
348
+ );
349
+ }
350
+
351
+ this.#orderByIndexClause = {
352
+ indexName: indexName === "primary" ? "_primary" : indexName,
353
+ direction,
354
+ };
355
+ return this;
356
+ }
357
+
358
+ /**
359
+ * Set cursor to continue pagination after this point (forward pagination)
360
+ */
361
+ after(cursor: string): this {
362
+ this.#afterCursor = cursor;
363
+ return this;
364
+ }
365
+
366
+ /**
367
+ * Set cursor to continue pagination before this point (backward pagination)
368
+ */
369
+ before(cursor: string): this {
370
+ this.#beforeCursor = cursor;
371
+ return this;
372
+ }
373
+
374
+ /**
375
+ * Set the number of results per page
376
+ */
377
+ pageSize(size: number): this {
378
+ this.#pageSizeValue = size;
379
+ return this;
380
+ }
381
+
382
+ /**
383
+ * Add joins to include related data
384
+ * Join where clauses are restricted to indexed columns only
385
+ */
386
+ join<TNewJoinOut>(
387
+ joinFn: (jb: IndexedJoinBuilder<TTable, {}>) => IndexedJoinBuilder<TTable, TNewJoinOut>,
388
+ ): FindBuilder<TTable, TSelect, TNewJoinOut> {
389
+ this.#joinClause = joinFn;
390
+ return this as unknown as FindBuilder<TTable, TSelect, TNewJoinOut>;
391
+ }
392
+
393
+ /**
394
+ * @internal
395
+ */
396
+ build():
397
+ | { type: "find"; indexName: string; options: FindOptions<TTable, TSelect> }
398
+ | {
399
+ type: "count";
400
+ indexName: string;
401
+ options: Pick<FindOptions<TTable>, "where" | "useIndex">;
402
+ } {
403
+ if (!this.#indexName) {
404
+ throw new Error(
405
+ `Must specify an index using .whereIndex() before finalizing find operation on table "${this.#tableName}"`,
406
+ );
407
+ }
408
+
409
+ // If in count mode, return count operation
410
+ if (this.#countMode) {
411
+ return {
412
+ type: "count",
413
+ indexName: this.#indexName,
414
+ options: {
415
+ useIndex: this.#indexName,
416
+ where: this.#whereClause,
417
+ },
418
+ };
419
+ }
420
+
421
+ // Compile joins if provided
422
+ let compiledJoins: CompiledJoin[] | undefined;
423
+ if (this.#joinClause) {
424
+ compiledJoins = buildJoinIndexed(this.#table, this.#joinClause);
425
+ }
426
+
427
+ const options: FindOptions<TTable, TSelect> = {
428
+ useIndex: this.#indexName,
429
+ select: this.#selectClause,
430
+ where: this.#whereClause,
431
+ orderByIndex: this.#orderByIndexClause,
432
+ after: this.#afterCursor,
433
+ before: this.#beforeCursor,
434
+ pageSize: this.#pageSizeValue,
435
+ joins: compiledJoins,
436
+ };
437
+
438
+ return { type: "find", indexName: this.#indexName, options };
439
+ }
440
+ }
441
+
442
+ /**
443
+ * Builder for update operations in Unit of Work
444
+ */
445
+ export class UpdateBuilder<TTable extends AnyTable> {
446
+ readonly #tableName: string;
447
+ readonly #id: FragnoId | string;
448
+
449
+ #checkVersion = false;
450
+ #setValues?: TableToUpdateValues<TTable>;
451
+
452
+ constructor(tableName: string, id: FragnoId | string) {
453
+ this.#tableName = tableName;
454
+ this.#id = id;
455
+ }
456
+
457
+ /**
458
+ * Specify values to update
459
+ */
460
+ set(values: TableToUpdateValues<TTable>): this {
461
+ this.#setValues = values;
462
+ return this;
463
+ }
464
+
465
+ /**
466
+ * Enable version checking for optimistic concurrency control
467
+ * @throws Error if the ID is just a string (no version available)
468
+ */
469
+ check(): this {
470
+ if (typeof this.#id === "string") {
471
+ throw new Error(
472
+ `Cannot use check() with a string ID on table "${this.#tableName}". ` +
473
+ `Version checking requires a FragnoId with version information.`,
474
+ );
475
+ }
476
+ this.#checkVersion = true;
477
+ return this;
478
+ }
479
+
480
+ /**
481
+ * @internal
482
+ */
483
+ build(): {
484
+ id: FragnoId | string;
485
+ checkVersion: boolean;
486
+ set: TableToUpdateValues<TTable>;
487
+ } {
488
+ if (!this.#setValues) {
489
+ throw new Error(
490
+ `Must specify values using .set() before finalizing update operation on table "${this.#tableName}"`,
491
+ );
492
+ }
493
+
494
+ return {
495
+ id: this.#id,
496
+ checkVersion: this.#checkVersion,
497
+ set: this.#setValues,
498
+ };
499
+ }
500
+ }
501
+
502
+ /**
503
+ * Builder for delete operations in Unit of Work
504
+ */
505
+ export class DeleteBuilder {
506
+ readonly #tableName: string;
507
+ readonly #id: FragnoId | string;
508
+
509
+ #checkVersion = false;
510
+
511
+ constructor(tableName: string, id: FragnoId | string) {
512
+ this.#tableName = tableName;
513
+ this.#id = id;
514
+ }
515
+
516
+ /**
517
+ * Enable version checking for optimistic concurrency control
518
+ * @throws Error if the ID is just a string (no version available)
519
+ */
520
+ check(): this {
521
+ if (typeof this.#id === "string") {
522
+ throw new Error(
523
+ `Cannot use check() with a string ID on table "${this.#tableName}". ` +
524
+ `Version checking requires a FragnoId with version information.`,
525
+ );
526
+ }
527
+ this.#checkVersion = true;
528
+ return this;
529
+ }
530
+
531
+ /**
532
+ * @internal
533
+ */
534
+ build(): { id: FragnoId | string; checkVersion: boolean } {
535
+ return {
536
+ id: this.#id,
537
+ checkVersion: this.#checkVersion,
538
+ };
539
+ }
540
+ }
541
+
542
+ /**
543
+ * Builder for join operations in Unit of Work
544
+ * Similar to FindBuilder but tailored for joins (no cursor pagination, no count mode)
545
+ */
546
+ export class JoinFindBuilder<
547
+ TTable extends AnyTable,
548
+ TSelect extends SelectClause<TTable> = true,
549
+ TJoinOut = {},
550
+ > {
551
+ readonly #table: TTable;
552
+ readonly #tableName: string;
553
+
554
+ #indexName?: string;
555
+ #whereClause?: (eb: IndexedConditionBuilder<TTable>) => Condition | boolean;
556
+ #orderByIndexClause?: {
557
+ indexName: string;
558
+ direction: "asc" | "desc";
559
+ };
560
+ #pageSizeValue?: number;
561
+ #selectClause?: TSelect;
562
+ #joinClause?: (jb: IndexedJoinBuilder<TTable, TJoinOut>) => IndexedJoinBuilder<TTable, TJoinOut>;
563
+
564
+ constructor(tableName: string, table: TTable) {
565
+ this.#tableName = tableName;
566
+ this.#table = table;
567
+ }
568
+
569
+ /**
570
+ * Specify which index to use and optionally filter the results
571
+ */
572
+ whereIndex<TIndexName extends ValidIndexName<TTable>>(
573
+ indexName: TIndexName,
574
+ condition?: (eb: IndexSpecificConditionBuilder<TTable, TIndexName>) => Condition | boolean,
575
+ ): this {
576
+ // Validate index exists (primary is always valid)
577
+ if (indexName !== "primary" && !(indexName in this.#table.indexes)) {
578
+ throw new Error(
579
+ `Index "${String(indexName)}" not found on table "${this.#tableName}". ` +
580
+ `Available indexes: primary, ${Object.keys(this.#table.indexes).join(", ")}`,
581
+ );
582
+ }
583
+
584
+ this.#indexName = indexName === "primary" ? "_primary" : indexName;
585
+ if (condition) {
586
+ // Safe: IndexSpecificConditionBuilder is a subset of IndexedConditionBuilder.
587
+ // The condition will only reference columns in the specific index, which are also indexed columns.
588
+ this.#whereClause = condition as unknown as (
589
+ eb: IndexedConditionBuilder<TTable>,
590
+ ) => Condition | boolean;
591
+ }
592
+ return this;
593
+ }
594
+
595
+ /**
596
+ * Specify columns to select
597
+ */
598
+ select<const TNewSelect extends SelectClause<TTable>>(
599
+ columns: TNewSelect,
600
+ ): JoinFindBuilder<TTable, TNewSelect, TJoinOut> {
601
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
602
+ (this as any).#selectClause = columns;
603
+ return this as unknown as JoinFindBuilder<TTable, TNewSelect, TJoinOut>;
604
+ }
605
+
606
+ /**
607
+ * Order results by index in ascending or descending order
608
+ */
609
+ orderByIndex<TIndexName extends ValidIndexName<TTable>>(
610
+ indexName: TIndexName,
611
+ direction: "asc" | "desc",
612
+ ): this {
613
+ // Validate index exists (primary is always valid)
614
+ if (indexName !== "primary" && !(indexName in this.#table.indexes)) {
615
+ throw new Error(
616
+ `Index "${String(indexName)}" not found on table "${this.#tableName}". ` +
617
+ `Available indexes: primary, ${Object.keys(this.#table.indexes).join(", ")}`,
618
+ );
619
+ }
620
+
621
+ this.#orderByIndexClause = {
622
+ indexName: indexName === "primary" ? "_primary" : indexName,
623
+ direction,
624
+ };
625
+ return this;
626
+ }
627
+
628
+ /**
629
+ * Set the number of results to return
630
+ */
631
+ pageSize(size: number): this {
632
+ this.#pageSizeValue = size;
633
+ return this;
634
+ }
635
+
636
+ /**
637
+ * Add joins to include related data
638
+ * Join where clauses are restricted to indexed columns only
639
+ */
640
+ join<TNewJoinOut>(
641
+ joinFn: (jb: IndexedJoinBuilder<TTable, {}>) => IndexedJoinBuilder<TTable, TNewJoinOut>,
642
+ ): JoinFindBuilder<TTable, TSelect, TJoinOut & TNewJoinOut> {
643
+ this.#joinClause = joinFn;
644
+ return this as unknown as JoinFindBuilder<TTable, TSelect, TJoinOut & TNewJoinOut>;
645
+ }
646
+
647
+ /**
648
+ * @internal
649
+ */
650
+ build(): {
651
+ indexName: string | undefined;
652
+ select: TSelect | undefined;
653
+ where: ((eb: IndexedConditionBuilder<TTable>) => Condition | boolean) | undefined;
654
+ orderByIndex:
655
+ | {
656
+ indexName: string;
657
+ direction: "asc" | "desc";
658
+ }
659
+ | undefined;
660
+ pageSize: number | undefined;
661
+ joins: CompiledJoin[] | undefined;
662
+ } {
663
+ // Compile joins if provided
664
+ let compiledJoins: CompiledJoin[] | undefined;
665
+ if (this.#joinClause) {
666
+ compiledJoins = buildJoinIndexed(this.#table, this.#joinClause);
667
+ }
668
+
669
+ return {
670
+ indexName: this.#indexName,
671
+ select: this.#selectClause,
672
+ where: this.#whereClause,
673
+ orderByIndex: this.#orderByIndexClause,
674
+ pageSize: this.#pageSizeValue,
675
+ joins: compiledJoins,
676
+ };
677
+ }
678
+ }
679
+
680
+ interface MapRelationType<T> {
681
+ // FIXME: Not sure why we need the RemoveEmptyObject, we should somehow fix at the source where it's added to the union
682
+ one: RemoveEmptyObject<T> | null;
683
+ many: RemoveEmptyObject<T>[];
684
+ }
685
+
686
+ /**
687
+ * Join builder with indexed-only where clauses for Unit of Work
688
+ * TJoinOut accumulates the types of all joined relations
689
+ */
690
+ export type IndexedJoinBuilder<TTable extends AnyTable, TJoinOut> = {
691
+ [K in keyof TTable["relations"]]: TTable["relations"][K] extends Relation<
692
+ infer TRelationType,
693
+ infer TTargetTable
694
+ >
695
+ ? <TSelect extends SelectClause<TTable["relations"][K]["table"]> = true, TNestedJoinOut = {}>(
696
+ builderFn?: (
697
+ builder: JoinFindBuilder<TTable["relations"][K]["table"]>,
698
+ ) => JoinFindBuilder<TTable["relations"][K]["table"], TSelect, TNestedJoinOut>,
699
+ ) => IndexedJoinBuilder<
700
+ TTable,
701
+ TJoinOut & {
702
+ [P in K]: MapRelationType<
703
+ SelectResult<TTargetTable, TNestedJoinOut, TSelect>
704
+ >[TRelationType];
705
+ }
706
+ >
707
+ : never;
708
+ };
709
+
710
+ /**
711
+ * Build join operations with indexed-only where clauses for Unit of Work
712
+ * This ensures all join conditions can leverage indexes for optimal performance
713
+ */
714
+ export function buildJoinIndexed<TTable extends AnyTable, TJoinOut>(
715
+ table: TTable,
716
+ fn: (builder: IndexedJoinBuilder<TTable, {}>) => IndexedJoinBuilder<TTable, TJoinOut>,
717
+ ): CompiledJoin[] {
718
+ const compiled: CompiledJoin[] = [];
719
+ const builder: Record<string, unknown> = {};
720
+
721
+ for (const name in table.relations) {
722
+ const relation = table.relations[name]!;
723
+
724
+ builder[name] = (builderFn?: (b: JoinFindBuilder<AnyTable>) => JoinFindBuilder<AnyTable>) => {
725
+ // Create join builder for this relation's table
726
+ const joinBuilder = new JoinFindBuilder(relation.table.ormName, relation.table);
727
+ if (builderFn) {
728
+ builderFn(joinBuilder);
729
+ }
730
+ const config = joinBuilder.build();
731
+
732
+ // Build condition with indexed columns only
733
+ let conditions: Condition | undefined;
734
+ if (config.where) {
735
+ const cond = buildCondition(relation.table.columns, config.where);
736
+ if (cond === true) {
737
+ conditions = undefined;
738
+ } else if (cond === false) {
739
+ // If condition evaluates to false, skip this join
740
+ compiled.push({
741
+ relation,
742
+ options: false,
743
+ });
744
+ delete builder[name];
745
+ return builder;
746
+ } else {
747
+ conditions = cond;
748
+ }
749
+ }
750
+
751
+ // Build orderBy from orderByIndex if provided
752
+ let orderBy: [AnyColumn, "asc" | "desc"][] | undefined;
753
+ if (config.orderByIndex) {
754
+ const index = relation.table.indexes[config.orderByIndex.indexName];
755
+ if (index) {
756
+ // Use all columns from the index for ordering
757
+ orderBy = index.columns.map(
758
+ (col) => [col, config.orderByIndex!.direction] as [AnyColumn, "asc" | "desc"],
759
+ );
760
+ } else {
761
+ // Fallback to ID column if index not found
762
+ orderBy = [[relation.table.getIdColumn(), config.orderByIndex.direction]];
763
+ }
764
+ }
765
+
766
+ compiled.push({
767
+ relation,
768
+ options: {
769
+ select: config.select ?? true,
770
+ where: conditions,
771
+ orderBy,
772
+ join: config.joins,
773
+ limit: config.pageSize,
774
+ },
775
+ });
776
+
777
+ delete builder[name];
778
+ return builder;
779
+ };
780
+ }
781
+
782
+ fn(builder as IndexedJoinBuilder<TTable, {}>);
783
+ return compiled;
784
+ }
785
+
786
+ export function createUnitOfWork<
787
+ const TSchema extends AnySchema,
788
+ const TRetrievalResults extends unknown[] = [],
789
+ const TRawInput = unknown,
790
+ >(
791
+ schema: TSchema,
792
+ compiler: UOWCompiler<TSchema, unknown>,
793
+ executor: UOWExecutor<unknown, TRawInput>,
794
+ decoder: UOWDecoder<TSchema, TRawInput>,
795
+ name?: string,
796
+ ): UnitOfWork<TSchema, TRetrievalResults, TRawInput> {
797
+ return new UnitOfWork(schema, compiler, executor, decoder, name) as UnitOfWork<
798
+ TSchema,
799
+ TRetrievalResults,
800
+ TRawInput
801
+ >;
802
+ }
803
+
804
+ /**
805
+ * Unit of Work implementation with optimistic concurrency control
806
+ *
807
+ * UOW has two phases:
808
+ * 1. Retrieval phase: Read operations to fetch entities with their versions
809
+ * 2. Mutation phase: Write operations that check versions before committing
810
+ *
811
+ * @example
812
+ * ```ts
813
+ * const uow = queryEngine.createUnitOfWork("update-user-balance");
814
+ *
815
+ * // Retrieval phase
816
+ * uow.find("users", (b) => b.where("primary", (eb) => eb("id", "=", userId)));
817
+ *
818
+ * // Execute retrieval and transition to mutation phase
819
+ * const [users] = await uow.executeRetrieve();
820
+ *
821
+ * // Mutation phase with version check
822
+ * const user = users[0];
823
+ * uow.update("users", user.id, (b) => b.set({ balance: newBalance }).check());
824
+ *
825
+ * // Execute mutations
826
+ * const { success } = await uow.executeMutations();
827
+ * if (!success) {
828
+ * // Handle version conflict
829
+ * }
830
+ * ```
831
+ */
832
+ export class UnitOfWork<
833
+ const TSchema extends AnySchema,
834
+ const TRetrievalResults extends unknown[] = [],
835
+ const TRawInput = unknown,
836
+ > {
837
+ #schema: TSchema;
838
+ #name?: string;
839
+ #state: UOWState = "building-retrieval";
840
+ #retrievalOps: RetrievalOperation<TSchema>[] = [];
841
+ #mutationOps: MutationOperation<TSchema>[] = [];
842
+ #compiler: UOWCompiler<TSchema, unknown>;
843
+ #executor: UOWExecutor<unknown, TRawInput>;
844
+ #decoder: UOWDecoder<TSchema, TRawInput>;
845
+ #retrievalResults?: TRetrievalResults;
846
+ #createdInternalIds: (bigint | null)[] = [];
847
+
848
+ constructor(
849
+ schema: TSchema,
850
+ compiler: UOWCompiler<TSchema, unknown>,
851
+ executor: UOWExecutor<unknown, TRawInput>,
852
+ decoder: UOWDecoder<TSchema, TRawInput>,
853
+ name?: string,
854
+ ) {
855
+ this.#schema = schema;
856
+ this.#compiler = compiler;
857
+ this.#executor = executor;
858
+ this.#decoder = decoder;
859
+ this.#name = name;
860
+ }
861
+
862
+ get schema(): TSchema {
863
+ return this.#schema;
864
+ }
865
+
866
+ get state(): UOWState {
867
+ return this.#state;
868
+ }
869
+
870
+ get name(): string | undefined {
871
+ return this.#name;
872
+ }
873
+
874
+ /**
875
+ * Execute the retrieval phase and transition to mutation phase
876
+ * Returns all results from find operations
877
+ */
878
+ async executeRetrieve(): Promise<TRetrievalResults> {
879
+ if (this.#retrievalOps.length === 0) {
880
+ return [] as unknown as TRetrievalResults;
881
+ }
882
+
883
+ if (this.#state !== "building-retrieval") {
884
+ throw new Error(
885
+ `Cannot execute retrieval from state ${this.#state}. Must be in building-retrieval state.`,
886
+ );
887
+ }
888
+
889
+ // Compile retrieval operations
890
+ const retrievalBatch: unknown[] = [];
891
+ for (const op of this.#retrievalOps) {
892
+ const compiled = this.#compiler.compileRetrievalOperation(op);
893
+ if (compiled !== null) {
894
+ retrievalBatch.push(compiled);
895
+ }
896
+ }
897
+
898
+ const results = this.#decoder(
899
+ await this.#executor.executeRetrievalPhase(retrievalBatch),
900
+ this.#retrievalOps,
901
+ );
902
+
903
+ // Store results and transition to mutation phase
904
+ this.#retrievalResults = results as TRetrievalResults;
905
+ this.#state = "building-mutation";
906
+
907
+ return this.#retrievalResults;
908
+ }
909
+
910
+ /**
911
+ * Add a find operation using a builder callback (retrieval phase only)
912
+ */
913
+ find<
914
+ TTableName extends keyof TSchema["tables"] & string,
915
+ TSelect extends SelectClause<TSchema["tables"][TTableName]> = true,
916
+ TJoinOut = {},
917
+ >(
918
+ tableName: TTableName,
919
+ builderFn?: (
920
+ // We omit "build" because we don't want to expose it to the user
921
+ builder: Omit<FindBuilder<TSchema["tables"][TTableName]>, "build">,
922
+ ) => Omit<FindBuilder<TSchema["tables"][TTableName], TSelect, TJoinOut>, "build">,
923
+ ): UnitOfWork<
924
+ TSchema,
925
+ [...TRetrievalResults, SelectResult<TSchema["tables"][TTableName], TJoinOut, TSelect>[]],
926
+ TRawInput
927
+ > {
928
+ if (this.#state !== "building-retrieval") {
929
+ throw new Error(
930
+ `find() can only be called during retrieval phase. Current state: ${this.#state}`,
931
+ );
932
+ }
933
+
934
+ const table = this.#schema.tables[tableName];
935
+ if (!table) {
936
+ throw new Error(`Table ${tableName} not found in schema`);
937
+ }
938
+
939
+ // Create builder, pass to callback (or use default), then extract configuration
940
+ const builder = new FindBuilder(tableName, table as TSchema["tables"][TTableName]);
941
+ if (builderFn) {
942
+ builderFn(builder);
943
+ } else {
944
+ // Default to primary index with no filter
945
+ builder.whereIndex("primary");
946
+ }
947
+ const { indexName, options, type } = builder.build();
948
+
949
+ this.#retrievalOps.push({
950
+ type,
951
+ // Safe: we know the table is part of the schema from the find() method
952
+ table: table as TSchema["tables"][TTableName],
953
+ indexName,
954
+ // Safe: we're storing the options for later compilation
955
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
956
+ options: options as any,
957
+ });
958
+
959
+ return this as unknown as UnitOfWork<
960
+ TSchema,
961
+ [...TRetrievalResults, SelectResult<TSchema["tables"][TTableName], TJoinOut, TSelect>[]],
962
+ TRawInput
963
+ >;
964
+ }
965
+
966
+ /**
967
+ * Add a create operation (mutation phase only)
968
+ */
969
+ create<TableName extends keyof TSchema["tables"] & string>(
970
+ table: TableName,
971
+ values: TableToInsertValues<TSchema["tables"][TableName]>,
972
+ ): this {
973
+ if (this.#state === "executed") {
974
+ throw new Error(`create() can only be called during mutation phase.`);
975
+ }
976
+
977
+ const tableSchema = this.#schema.tables[table];
978
+ if (!tableSchema) {
979
+ throw new Error(`Table ${table} not found in schema`);
980
+ }
981
+
982
+ const idColumn = tableSchema.getIdColumn();
983
+ let externalId: string;
984
+ let updatedValues = values;
985
+
986
+ // Check if ID value is provided in values
987
+ const providedIdValue = (values as Record<string, unknown>)[idColumn.ormName];
988
+
989
+ if (providedIdValue !== undefined) {
990
+ // Extract string from FragnoId or use string directly
991
+ if (
992
+ typeof providedIdValue === "object" &&
993
+ providedIdValue !== null &&
994
+ "externalId" in providedIdValue
995
+ ) {
996
+ externalId = (providedIdValue as FragnoId).externalId;
997
+ } else {
998
+ externalId = providedIdValue as string;
999
+ }
1000
+ } else {
1001
+ // Generate using the column's default configuration
1002
+ const generated = idColumn.generateDefaultValue();
1003
+ if (generated === undefined) {
1004
+ throw new Error(
1005
+ `No ID value provided and ID column ${idColumn.ormName} has no default generator`,
1006
+ );
1007
+ }
1008
+ externalId = generated as string;
1009
+
1010
+ // Add the generated ID to values so it's used in the insert
1011
+ updatedValues = {
1012
+ ...values,
1013
+ [idColumn.ormName]: externalId,
1014
+ } as TableToInsertValues<TSchema["tables"][TableName]>;
1015
+ }
1016
+
1017
+ this.#mutationOps.push({
1018
+ type: "create",
1019
+ table,
1020
+ values: updatedValues,
1021
+ generatedExternalId: externalId,
1022
+ });
1023
+
1024
+ return this;
1025
+ }
1026
+
1027
+ /**
1028
+ * Add an update operation using a builder callback (mutation phase only)
1029
+ */
1030
+ update<TableName extends keyof TSchema["tables"] & string>(
1031
+ table: TableName,
1032
+ id: FragnoId | string,
1033
+ builderFn: (
1034
+ // We omit "build" because we don't want to expose it to the user
1035
+ builder: Omit<UpdateBuilder<TSchema["tables"][TableName]>, "build">,
1036
+ ) => Omit<UpdateBuilder<TSchema["tables"][TableName]>, "build">,
1037
+ ): this {
1038
+ if (this.#state === "executed") {
1039
+ throw new Error(`update() can only be called during mutation phase.`);
1040
+ }
1041
+
1042
+ // Create builder, pass to callback, then extract configuration
1043
+ const builder = new UpdateBuilder<TSchema["tables"][TableName]>(table, id);
1044
+ builderFn(builder);
1045
+ const { id: opId, checkVersion, set } = builder.build();
1046
+
1047
+ this.#mutationOps.push({
1048
+ type: "update",
1049
+ table,
1050
+ id: opId,
1051
+ checkVersion,
1052
+ set,
1053
+ });
1054
+
1055
+ return this;
1056
+ }
1057
+
1058
+ /**
1059
+ * Add a delete operation using a builder callback (mutation phase only)
1060
+ */
1061
+ delete<TableName extends keyof TSchema["tables"] & string>(
1062
+ table: TableName,
1063
+ id: FragnoId | string,
1064
+ builderFn?: (
1065
+ // We omit "build" because we don't want to expose it to the user
1066
+ builder: Omit<DeleteBuilder, "build">,
1067
+ ) => Omit<DeleteBuilder, "build">,
1068
+ ): this {
1069
+ if (this.#state === "executed") {
1070
+ throw new Error(`delete() can only be called during mutation phase.`);
1071
+ }
1072
+
1073
+ // Create builder, optionally pass to callback, then extract configuration
1074
+ const builder = new DeleteBuilder(table, id);
1075
+ builderFn?.(builder);
1076
+ const { id: opId, checkVersion } = builder.build();
1077
+
1078
+ this.#mutationOps.push({
1079
+ type: "delete",
1080
+ table,
1081
+ id: opId,
1082
+ checkVersion,
1083
+ });
1084
+
1085
+ return this;
1086
+ }
1087
+
1088
+ /**
1089
+ * Execute the mutation phase
1090
+ * Returns success flag indicating if mutations completed without conflicts
1091
+ */
1092
+ async executeMutations(): Promise<{ success: boolean }> {
1093
+ if (this.#state === "executed") {
1094
+ throw new Error(`Cannot execute mutations from state ${this.#state}.`);
1095
+ }
1096
+
1097
+ // Compile mutation operations
1098
+ const mutationBatch: CompiledMutation<unknown>[] = [];
1099
+ for (const op of this.#mutationOps) {
1100
+ const compiled = this.#compiler.compileMutationOperation(op);
1101
+ if (compiled !== null) {
1102
+ mutationBatch.push(compiled);
1103
+ }
1104
+ }
1105
+
1106
+ // Execute mutation phase
1107
+ const result = await this.#executor.executeMutationPhase(mutationBatch);
1108
+ this.#state = "executed";
1109
+
1110
+ if (result.success) {
1111
+ this.#createdInternalIds = result.createdInternalIds;
1112
+ }
1113
+
1114
+ return {
1115
+ success: result.success,
1116
+ };
1117
+ }
1118
+
1119
+ /**
1120
+ * Get the retrieval operations (for inspection/debugging)
1121
+ */
1122
+ getRetrievalOperations(): ReadonlyArray<RetrievalOperation<TSchema>> {
1123
+ return this.#retrievalOps;
1124
+ }
1125
+
1126
+ /**
1127
+ * Get the mutation operations (for inspection/debugging)
1128
+ */
1129
+ getMutationOperations(): ReadonlyArray<MutationOperation<TSchema>> {
1130
+ return this.#mutationOps;
1131
+ }
1132
+
1133
+ /**
1134
+ * Get the IDs of created entities after executeMutations() has been called.
1135
+ * Returns FragnoId objects with external IDs (always available) and internal IDs
1136
+ * (available when database supports RETURNING).
1137
+ *
1138
+ * @throws Error if called before executeMutations()
1139
+ * @returns Array of FragnoIds in the same order as create() calls
1140
+ */
1141
+ getCreatedIds(): FragnoId[] {
1142
+ if (this.#state !== "executed") {
1143
+ throw new Error(
1144
+ `getCreatedIds() can only be called after executeMutations(). Current state: ${this.#state}`,
1145
+ );
1146
+ }
1147
+
1148
+ const createdIds: FragnoId[] = [];
1149
+ let createIndex = 0;
1150
+
1151
+ for (const op of this.#mutationOps) {
1152
+ if (op.type === "create") {
1153
+ const internalId = this.#createdInternalIds[createIndex] ?? undefined;
1154
+ createdIds.push(
1155
+ new FragnoId({
1156
+ externalId: op.generatedExternalId,
1157
+ internalId,
1158
+ version: 0, // New records always start at version 0
1159
+ }),
1160
+ );
1161
+ createIndex++;
1162
+ }
1163
+ }
1164
+
1165
+ return createdIds;
1166
+ }
1167
+
1168
+ /**
1169
+ * @internal
1170
+ * Compile the unit of work to executable queries for testing
1171
+ */
1172
+ compile<TOutput>(compiler: UOWCompiler<TSchema, TOutput>): {
1173
+ name?: string;
1174
+ retrievalBatch: TOutput[];
1175
+ mutationBatch: CompiledMutation<TOutput>[];
1176
+ } {
1177
+ const retrievalBatch: TOutput[] = [];
1178
+ for (const op of this.#retrievalOps) {
1179
+ const compiled = compiler.compileRetrievalOperation(op);
1180
+ if (compiled !== null) {
1181
+ retrievalBatch.push(compiled);
1182
+ }
1183
+ }
1184
+
1185
+ const mutationBatch: CompiledMutation<TOutput>[] = [];
1186
+ for (const op of this.#mutationOps) {
1187
+ const compiled = compiler.compileMutationOperation(op);
1188
+ if (compiled !== null) {
1189
+ mutationBatch.push(compiled);
1190
+ }
1191
+ }
1192
+
1193
+ return {
1194
+ name: this.#name,
1195
+ retrievalBatch,
1196
+ mutationBatch,
1197
+ };
1198
+ }
1199
+ }