@fragno-dev/db 0.2.1 → 0.3.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 (362) hide show
  1. package/.turbo/turbo-build.log +206 -140
  2. package/CHANGELOG.md +67 -0
  3. package/README.md +30 -9
  4. package/dist/adapters/adapters.d.ts +23 -21
  5. package/dist/adapters/adapters.d.ts.map +1 -1
  6. package/dist/adapters/adapters.js.map +1 -1
  7. package/dist/adapters/generic-sql/driver-config.d.ts +16 -1
  8. package/dist/adapters/generic-sql/driver-config.d.ts.map +1 -1
  9. package/dist/adapters/generic-sql/driver-config.js +23 -1
  10. package/dist/adapters/generic-sql/driver-config.js.map +1 -1
  11. package/dist/adapters/generic-sql/generic-sql-adapter.d.ts +27 -9
  12. package/dist/adapters/generic-sql/generic-sql-adapter.d.ts.map +1 -1
  13. package/dist/adapters/generic-sql/generic-sql-adapter.js +55 -16
  14. package/dist/adapters/generic-sql/generic-sql-adapter.js.map +1 -1
  15. package/dist/adapters/generic-sql/generic-sql-uow-executor.js +129 -3
  16. package/dist/adapters/generic-sql/generic-sql-uow-executor.js.map +1 -1
  17. package/dist/adapters/generic-sql/migration/dialect/mysql.js +24 -5
  18. package/dist/adapters/generic-sql/migration/dialect/mysql.js.map +1 -1
  19. package/dist/adapters/generic-sql/migration/dialect/postgres.js +6 -5
  20. package/dist/adapters/generic-sql/migration/dialect/postgres.js.map +1 -1
  21. package/dist/adapters/generic-sql/migration/dialect/sqlite.js +21 -10
  22. package/dist/adapters/generic-sql/migration/dialect/sqlite.js.map +1 -1
  23. package/dist/adapters/generic-sql/migration/prepared-migrations.d.ts.map +1 -1
  24. package/dist/adapters/generic-sql/migration/prepared-migrations.js +8 -8
  25. package/dist/adapters/generic-sql/migration/prepared-migrations.js.map +1 -1
  26. package/dist/adapters/generic-sql/migration/sql-generator.js +74 -51
  27. package/dist/adapters/generic-sql/migration/sql-generator.js.map +1 -1
  28. package/dist/adapters/generic-sql/query/create-sql-query-compiler.js +6 -5
  29. package/dist/adapters/generic-sql/query/create-sql-query-compiler.js.map +1 -1
  30. package/dist/adapters/generic-sql/query/cursor-utils.js +42 -4
  31. package/dist/adapters/generic-sql/query/cursor-utils.js.map +1 -1
  32. package/dist/adapters/generic-sql/query/generic-sql-uow-operation-compiler.js +25 -17
  33. package/dist/adapters/generic-sql/query/generic-sql-uow-operation-compiler.js.map +1 -1
  34. package/dist/adapters/generic-sql/query/select-builder.js +5 -3
  35. package/dist/adapters/generic-sql/query/select-builder.js.map +1 -1
  36. package/dist/adapters/generic-sql/query/sql-query-compiler.js +15 -12
  37. package/dist/adapters/generic-sql/query/sql-query-compiler.js.map +1 -1
  38. package/dist/adapters/generic-sql/query/where-builder.js +38 -28
  39. package/dist/adapters/generic-sql/query/where-builder.js.map +1 -1
  40. package/dist/adapters/generic-sql/sqlite-storage.d.ts +13 -0
  41. package/dist/adapters/generic-sql/sqlite-storage.d.ts.map +1 -0
  42. package/dist/adapters/generic-sql/sqlite-storage.js +15 -0
  43. package/dist/adapters/generic-sql/sqlite-storage.js.map +1 -0
  44. package/dist/adapters/generic-sql/uow-decoder.js +7 -3
  45. package/dist/adapters/generic-sql/uow-decoder.js.map +1 -1
  46. package/dist/adapters/generic-sql/uow-encoder.js +28 -8
  47. package/dist/adapters/generic-sql/uow-encoder.js.map +1 -1
  48. package/dist/adapters/in-memory/condition-evaluator.js +131 -0
  49. package/dist/adapters/in-memory/condition-evaluator.js.map +1 -0
  50. package/dist/adapters/in-memory/errors.d.ts +13 -0
  51. package/dist/adapters/in-memory/errors.d.ts.map +1 -0
  52. package/dist/adapters/in-memory/errors.js +23 -0
  53. package/dist/adapters/in-memory/errors.js.map +1 -0
  54. package/dist/adapters/in-memory/in-memory-adapter.d.ts +27 -0
  55. package/dist/adapters/in-memory/in-memory-adapter.d.ts.map +1 -0
  56. package/dist/adapters/in-memory/in-memory-adapter.js +176 -0
  57. package/dist/adapters/in-memory/in-memory-adapter.js.map +1 -0
  58. package/dist/adapters/in-memory/in-memory-uow.js +648 -0
  59. package/dist/adapters/in-memory/in-memory-uow.js.map +1 -0
  60. package/dist/adapters/in-memory/index.d.ts +4 -0
  61. package/dist/adapters/in-memory/index.js +4 -0
  62. package/dist/adapters/in-memory/options.d.ts +28 -0
  63. package/dist/adapters/in-memory/options.d.ts.map +1 -0
  64. package/dist/adapters/in-memory/options.js +61 -0
  65. package/dist/adapters/in-memory/options.js.map +1 -0
  66. package/dist/adapters/in-memory/reference-resolution.js +26 -0
  67. package/dist/adapters/in-memory/reference-resolution.js.map +1 -0
  68. package/dist/adapters/in-memory/sorted-array-index.js +129 -0
  69. package/dist/adapters/in-memory/sorted-array-index.js.map +1 -0
  70. package/dist/adapters/in-memory/store.js +71 -0
  71. package/dist/adapters/in-memory/store.js.map +1 -0
  72. package/dist/adapters/in-memory/value-comparison.js +28 -0
  73. package/dist/adapters/in-memory/value-comparison.js.map +1 -0
  74. package/dist/adapters/shared/from-unit-of-work-compiler.js.map +1 -1
  75. package/dist/adapters/shared/uow-operation-compiler.js +11 -11
  76. package/dist/adapters/shared/uow-operation-compiler.js.map +1 -1
  77. package/dist/adapters/sql/index.d.ts +5 -0
  78. package/dist/adapters/sql/index.js +4 -0
  79. package/dist/db-fragment-definition-builder.d.ts +45 -96
  80. package/dist/db-fragment-definition-builder.d.ts.map +1 -1
  81. package/dist/db-fragment-definition-builder.js +121 -99
  82. package/dist/db-fragment-definition-builder.js.map +1 -1
  83. package/dist/dispatchers/cloudflare-do/index.d.ts +26 -0
  84. package/dist/dispatchers/cloudflare-do/index.d.ts.map +1 -0
  85. package/dist/dispatchers/cloudflare-do/index.js +63 -0
  86. package/dist/dispatchers/cloudflare-do/index.js.map +1 -0
  87. package/dist/dispatchers/node/index.d.ts +17 -0
  88. package/dist/dispatchers/node/index.d.ts.map +1 -0
  89. package/dist/dispatchers/node/index.js +59 -0
  90. package/dist/dispatchers/node/index.js.map +1 -0
  91. package/dist/fragments/internal-fragment.d.ts +172 -9
  92. package/dist/fragments/internal-fragment.d.ts.map +1 -1
  93. package/dist/fragments/internal-fragment.js +193 -74
  94. package/dist/fragments/internal-fragment.js.map +1 -1
  95. package/dist/fragments/internal-fragment.routes.js +29 -0
  96. package/dist/fragments/internal-fragment.routes.js.map +1 -0
  97. package/dist/fragments/internal-fragment.schema.d.ts +9 -0
  98. package/dist/fragments/internal-fragment.schema.d.ts.map +1 -0
  99. package/dist/fragments/internal-fragment.schema.js +22 -0
  100. package/dist/fragments/internal-fragment.schema.js.map +1 -0
  101. package/dist/hooks/durable-hooks-processor.d.ts +14 -0
  102. package/dist/hooks/durable-hooks-processor.d.ts.map +1 -0
  103. package/dist/hooks/durable-hooks-processor.js +32 -0
  104. package/dist/hooks/durable-hooks-processor.js.map +1 -0
  105. package/dist/hooks/hooks.d.ts +47 -4
  106. package/dist/hooks/hooks.d.ts.map +1 -1
  107. package/dist/hooks/hooks.js +106 -39
  108. package/dist/hooks/hooks.js.map +1 -1
  109. package/dist/migration-engine/auto-from-schema.js +14 -11
  110. package/dist/migration-engine/auto-from-schema.js.map +1 -1
  111. package/dist/migration-engine/generation-engine.d.ts +16 -10
  112. package/dist/migration-engine/generation-engine.d.ts.map +1 -1
  113. package/dist/migration-engine/generation-engine.js +72 -33
  114. package/dist/migration-engine/generation-engine.js.map +1 -1
  115. package/dist/migration-engine/shared.js.map +1 -1
  116. package/dist/mod.d.ts +17 -10
  117. package/dist/mod.d.ts.map +1 -1
  118. package/dist/mod.js +14 -8
  119. package/dist/mod.js.map +1 -1
  120. package/dist/naming/sql-naming.d.ts +19 -0
  121. package/dist/naming/sql-naming.d.ts.map +1 -0
  122. package/dist/naming/sql-naming.js +116 -0
  123. package/dist/naming/sql-naming.js.map +1 -0
  124. package/dist/node_modules/.pnpm/{rou3@0.7.10 → rou3@0.7.12}/node_modules/rou3/dist/index.js +8 -5
  125. package/dist/node_modules/.pnpm/rou3@0.7.12/node_modules/rou3/dist/index.js.map +1 -0
  126. package/dist/outbox/outbox-builder.js +156 -0
  127. package/dist/outbox/outbox-builder.js.map +1 -0
  128. package/dist/outbox/outbox.d.ts +52 -0
  129. package/dist/outbox/outbox.d.ts.map +1 -0
  130. package/dist/outbox/outbox.js +37 -0
  131. package/dist/outbox/outbox.js.map +1 -0
  132. package/dist/packages/fragno/dist/api/fragment-definition-builder.js +3 -2
  133. package/dist/packages/fragno/dist/api/fragment-definition-builder.js.map +1 -1
  134. package/dist/packages/fragno/dist/api/fragment-instantiator.js +164 -20
  135. package/dist/packages/fragno/dist/api/fragment-instantiator.js.map +1 -1
  136. package/dist/packages/fragno/dist/api/request-input-context.js +67 -0
  137. package/dist/packages/fragno/dist/api/request-input-context.js.map +1 -1
  138. package/dist/packages/fragno/dist/api/route.js +14 -1
  139. package/dist/packages/fragno/dist/api/route.js.map +1 -1
  140. package/dist/packages/fragno/dist/internal/trace-context.js +12 -0
  141. package/dist/packages/fragno/dist/internal/trace-context.js.map +1 -0
  142. package/dist/query/column-defaults.js +20 -4
  143. package/dist/query/column-defaults.js.map +1 -1
  144. package/dist/query/cursor.d.ts +3 -1
  145. package/dist/query/cursor.d.ts.map +1 -1
  146. package/dist/query/cursor.js +45 -14
  147. package/dist/query/cursor.js.map +1 -1
  148. package/dist/query/db-now.d.ts +8 -0
  149. package/dist/query/db-now.d.ts.map +1 -0
  150. package/dist/query/db-now.js +7 -0
  151. package/dist/query/db-now.js.map +1 -0
  152. package/dist/query/serialize/create-sql-serializer.js +3 -2
  153. package/dist/query/serialize/create-sql-serializer.js.map +1 -1
  154. package/dist/query/serialize/dialect/mysql-serializer.js +12 -6
  155. package/dist/query/serialize/dialect/mysql-serializer.js.map +1 -1
  156. package/dist/query/serialize/dialect/postgres-serializer.js +25 -7
  157. package/dist/query/serialize/dialect/postgres-serializer.js.map +1 -1
  158. package/dist/query/serialize/dialect/sqlite-serializer.js +55 -11
  159. package/dist/query/serialize/dialect/sqlite-serializer.js.map +1 -1
  160. package/dist/query/serialize/sql-serializer.js +2 -2
  161. package/dist/query/serialize/sql-serializer.js.map +1 -1
  162. package/dist/query/simple-query-interface.d.ts +6 -1
  163. package/dist/query/simple-query-interface.d.ts.map +1 -1
  164. package/dist/query/unit-of-work/execute-unit-of-work.d.ts +351 -100
  165. package/dist/query/unit-of-work/execute-unit-of-work.d.ts.map +1 -1
  166. package/dist/query/unit-of-work/execute-unit-of-work.js +440 -267
  167. package/dist/query/unit-of-work/execute-unit-of-work.js.map +1 -1
  168. package/dist/query/unit-of-work/unit-of-work.d.ts +67 -22
  169. package/dist/query/unit-of-work/unit-of-work.d.ts.map +1 -1
  170. package/dist/query/unit-of-work/unit-of-work.js +110 -13
  171. package/dist/query/unit-of-work/unit-of-work.js.map +1 -1
  172. package/dist/query/value-decoding.js +8 -5
  173. package/dist/query/value-decoding.js.map +1 -1
  174. package/dist/query/value-encoding.js +29 -9
  175. package/dist/query/value-encoding.js.map +1 -1
  176. package/dist/schema/create.d.ts +40 -14
  177. package/dist/schema/create.d.ts.map +1 -1
  178. package/dist/schema/create.js +82 -42
  179. package/dist/schema/create.js.map +1 -1
  180. package/dist/schema/generate-id.d.ts +20 -0
  181. package/dist/schema/generate-id.d.ts.map +1 -0
  182. package/dist/schema/generate-id.js +28 -0
  183. package/dist/schema/generate-id.js.map +1 -0
  184. package/dist/schema/type-conversion/create-sql-type-mapper.js +3 -2
  185. package/dist/schema/type-conversion/create-sql-type-mapper.js.map +1 -1
  186. package/dist/schema/type-conversion/dialect/sqlite.js +9 -0
  187. package/dist/schema/type-conversion/dialect/sqlite.js.map +1 -1
  188. package/dist/schema/validator.d.ts +10 -0
  189. package/dist/schema/validator.d.ts.map +1 -0
  190. package/dist/schema/validator.js +123 -0
  191. package/dist/schema/validator.js.map +1 -0
  192. package/dist/schema-output/drizzle.d.ts +30 -0
  193. package/dist/schema-output/drizzle.d.ts.map +1 -0
  194. package/dist/{adapters/drizzle/generate.js → schema-output/drizzle.js} +82 -56
  195. package/dist/schema-output/drizzle.js.map +1 -0
  196. package/dist/schema-output/prisma.d.ts +17 -0
  197. package/dist/schema-output/prisma.d.ts.map +1 -0
  198. package/dist/schema-output/prisma.js +296 -0
  199. package/dist/schema-output/prisma.js.map +1 -0
  200. package/dist/util/default-database-adapter.js +61 -0
  201. package/dist/util/default-database-adapter.js.map +1 -0
  202. package/dist/with-database.d.ts +1 -1
  203. package/dist/with-database.d.ts.map +1 -1
  204. package/dist/with-database.js +12 -3
  205. package/dist/with-database.js.map +1 -1
  206. package/package.json +43 -28
  207. package/src/adapters/adapters.ts +30 -24
  208. package/src/adapters/drizzle/migrate-drizzle.test.ts +54 -33
  209. package/src/adapters/drizzle/migration-parity-drizzle-kit.test.ts +599 -0
  210. package/src/adapters/drizzle/test-utils.ts +12 -8
  211. package/src/adapters/generic-sql/driver-config.ts +38 -0
  212. package/src/adapters/generic-sql/generic-sql-adapter.test.ts +5 -5
  213. package/src/adapters/generic-sql/generic-sql-adapter.ts +110 -24
  214. package/src/adapters/generic-sql/generic-sql-uow-executor.test.ts +54 -0
  215. package/src/adapters/generic-sql/generic-sql-uow-executor.ts +231 -3
  216. package/src/adapters/generic-sql/migration/adapter-migration-parity.test.ts +118 -0
  217. package/src/adapters/generic-sql/migration/dialect/mysql.test.ts +26 -8
  218. package/src/adapters/generic-sql/migration/dialect/mysql.ts +46 -8
  219. package/src/adapters/generic-sql/migration/dialect/postgres.test.ts +25 -7
  220. package/src/adapters/generic-sql/migration/dialect/postgres.ts +8 -4
  221. package/src/adapters/generic-sql/migration/dialect/sqlite.test.ts +47 -8
  222. package/src/adapters/generic-sql/migration/dialect/sqlite.ts +27 -12
  223. package/src/adapters/generic-sql/migration/prepared-migrations.test.ts +128 -39
  224. package/src/adapters/generic-sql/migration/prepared-migrations.ts +15 -8
  225. package/src/adapters/generic-sql/migration/sql-generator.ts +142 -65
  226. package/src/adapters/generic-sql/query/create-sql-query-compiler.ts +9 -6
  227. package/src/adapters/generic-sql/query/cursor-utils.test.ts +271 -0
  228. package/src/adapters/generic-sql/query/cursor-utils.ts +41 -6
  229. package/src/adapters/generic-sql/query/generic-sql-uow-operation-compiler.test.ts +27 -27
  230. package/src/adapters/generic-sql/query/generic-sql-uow-operation-compiler.ts +38 -24
  231. package/src/adapters/generic-sql/query/select-builder.test.ts +15 -11
  232. package/src/adapters/generic-sql/query/select-builder.ts +6 -2
  233. package/src/adapters/generic-sql/query/sql-query-compiler.test.ts +52 -2
  234. package/src/adapters/generic-sql/query/sql-query-compiler.ts +50 -15
  235. package/src/adapters/generic-sql/query/where-builder.test.ts +91 -17
  236. package/src/adapters/generic-sql/query/where-builder.ts +90 -38
  237. package/src/adapters/{kysely/kysely-adapter-pglite.test.ts → generic-sql/sql-adapter-pglite-migrations.test.ts} +6 -6
  238. package/src/adapters/generic-sql/sql-adapter-pglite-pagination.test.ts +806 -0
  239. package/src/adapters/{drizzle/drizzle-adapter-pglite.test.ts → generic-sql/sql-adapter-pglite-queries.test.ts} +11 -11
  240. package/src/adapters/generic-sql/{test/generic-drizzle-adapter-sqlite3.test.ts → sql-adapter-sqlite3-driver.test.ts} +49 -35
  241. package/src/adapters/{drizzle/drizzle-adapter-sqlite3.test.ts → generic-sql/sql-adapter-sqlite3-uow.test.ts} +48 -32
  242. package/src/adapters/{kysely/kysely-adapter-sqlocal.test.ts → generic-sql/sql-adapter-sqlocal.test.ts} +6 -6
  243. package/src/adapters/generic-sql/sqlite-storage.ts +20 -0
  244. package/src/adapters/generic-sql/uow-decoder.test.ts +1 -1
  245. package/src/adapters/generic-sql/uow-decoder.ts +21 -3
  246. package/src/adapters/generic-sql/uow-encoder.test.ts +33 -2
  247. package/src/adapters/generic-sql/uow-encoder.ts +50 -11
  248. package/src/adapters/in-memory/condition-evaluator.test.ts +193 -0
  249. package/src/adapters/in-memory/condition-evaluator.ts +275 -0
  250. package/src/adapters/in-memory/errors.ts +20 -0
  251. package/src/adapters/in-memory/in-memory-adapter.ts +277 -0
  252. package/src/adapters/in-memory/in-memory-uow.mutations.test.ts +296 -0
  253. package/src/adapters/in-memory/in-memory-uow.retrieval.test.ts +100 -0
  254. package/src/adapters/in-memory/in-memory-uow.ts +1348 -0
  255. package/src/adapters/in-memory/index.ts +3 -0
  256. package/src/adapters/in-memory/options.test.ts +41 -0
  257. package/src/adapters/in-memory/options.ts +87 -0
  258. package/src/adapters/in-memory/reference-resolution.test.ts +50 -0
  259. package/src/adapters/in-memory/reference-resolution.ts +67 -0
  260. package/src/adapters/in-memory/sorted-array-index.test.ts +123 -0
  261. package/src/adapters/in-memory/sorted-array-index.ts +228 -0
  262. package/src/adapters/in-memory/store.test.ts +68 -0
  263. package/src/adapters/in-memory/store.ts +145 -0
  264. package/src/adapters/in-memory/value-comparison.ts +53 -0
  265. package/src/adapters/in-memory/value-normalization.test.ts +57 -0
  266. package/src/adapters/prisma/prisma-adapter-sqlite3.test.ts +1163 -0
  267. package/src/adapters/shared/from-unit-of-work-compiler.ts +3 -1
  268. package/src/adapters/shared/uow-operation-compiler.ts +26 -16
  269. package/src/adapters/sql/index.ts +12 -0
  270. package/src/db-fragment-definition-builder.test.ts +88 -54
  271. package/src/db-fragment-definition-builder.ts +201 -322
  272. package/src/db-fragment-instantiator.test.ts +169 -101
  273. package/src/db-fragment-integration.test.ts +301 -149
  274. package/src/dispatchers/cloudflare-do/index.test.ts +73 -0
  275. package/src/dispatchers/cloudflare-do/index.ts +104 -0
  276. package/src/dispatchers/node/index.test.ts +91 -0
  277. package/src/dispatchers/node/index.ts +87 -0
  278. package/src/fragments/internal-fragment.routes.ts +42 -0
  279. package/src/fragments/internal-fragment.schema.ts +51 -0
  280. package/src/fragments/internal-fragment.test.ts +730 -274
  281. package/src/fragments/internal-fragment.ts +447 -154
  282. package/src/hooks/durable-hooks-processor.test.ts +117 -0
  283. package/src/hooks/durable-hooks-processor.ts +67 -0
  284. package/src/hooks/hooks.test.ts +411 -259
  285. package/src/hooks/hooks.ts +265 -66
  286. package/src/migration-engine/auto-from-schema.test.ts +14 -14
  287. package/src/migration-engine/auto-from-schema.ts +5 -2
  288. package/src/migration-engine/create.test.ts +2 -2
  289. package/src/migration-engine/generation-engine.test.ts +229 -104
  290. package/src/migration-engine/generation-engine.ts +94 -64
  291. package/src/migration-engine/shared.ts +1 -0
  292. package/src/mod.ts +78 -30
  293. package/src/naming/sql-naming.ts +180 -0
  294. package/src/outbox/outbox-builder.ts +241 -0
  295. package/src/outbox/outbox.test.ts +253 -0
  296. package/src/outbox/outbox.ts +137 -0
  297. package/src/query/column-defaults.ts +41 -3
  298. package/src/query/condition-builder.test.ts +3 -3
  299. package/src/query/cursor.test.ts +116 -18
  300. package/src/query/cursor.ts +75 -26
  301. package/src/query/db-now.ts +6 -0
  302. package/src/query/query-type.test.ts +2 -2
  303. package/src/query/serialize/create-sql-serializer.ts +7 -2
  304. package/src/query/serialize/dialect/mysql-serializer.ts +12 -4
  305. package/src/query/serialize/dialect/postgres-serializer.ts +34 -4
  306. package/src/query/serialize/dialect/sqlite-serializer.test.ts +51 -1
  307. package/src/query/serialize/dialect/sqlite-serializer.ts +92 -9
  308. package/src/query/serialize/sql-serializer.ts +4 -4
  309. package/src/query/simple-query-interface.ts +5 -0
  310. package/src/query/unit-of-work/execute-unit-of-work.test.ts +1512 -1458
  311. package/src/query/unit-of-work/execute-unit-of-work.ts +1708 -596
  312. package/src/query/unit-of-work/tx-builder.test.ts +1041 -0
  313. package/src/query/unit-of-work/unit-of-work-coordinator.test.ts +32 -32
  314. package/src/query/unit-of-work/unit-of-work-types.test.ts +1 -1
  315. package/src/query/unit-of-work/unit-of-work.test.ts +231 -36
  316. package/src/query/unit-of-work/unit-of-work.ts +229 -31
  317. package/src/query/value-decoding.test.ts +13 -2
  318. package/src/query/value-decoding.ts +17 -4
  319. package/src/query/value-encoding.test.ts +85 -2
  320. package/src/query/value-encoding.ts +56 -6
  321. package/src/schema/create.test.ts +129 -42
  322. package/src/schema/create.ts +187 -47
  323. package/src/schema/generate-id.test.ts +57 -0
  324. package/src/schema/generate-id.ts +38 -0
  325. package/src/schema/serialize.test.ts +14 -2
  326. package/src/schema/type-conversion/create-sql-type-mapper.ts +7 -2
  327. package/src/schema/type-conversion/dialect/sqlite.ts +18 -0
  328. package/src/schema/type-conversion/type-mapping.test.ts +25 -1
  329. package/src/schema/validator.test.ts +197 -0
  330. package/src/schema/validator.ts +231 -0
  331. package/src/{adapters/drizzle/generate.test.ts → schema-output/drizzle.test.ts} +179 -129
  332. package/src/{adapters/drizzle/generate.ts → schema-output/drizzle.ts} +143 -93
  333. package/src/schema-output/prisma.test.ts +536 -0
  334. package/src/schema-output/prisma.ts +573 -0
  335. package/src/util/default-database-adapter.ts +106 -0
  336. package/src/with-database.ts +22 -3
  337. package/tsdown.config.ts +6 -4
  338. package/dist/adapters/drizzle/drizzle-adapter.d.ts +0 -20
  339. package/dist/adapters/drizzle/drizzle-adapter.d.ts.map +0 -1
  340. package/dist/adapters/drizzle/drizzle-adapter.js +0 -27
  341. package/dist/adapters/drizzle/drizzle-adapter.js.map +0 -1
  342. package/dist/adapters/drizzle/generate.d.ts +0 -30
  343. package/dist/adapters/drizzle/generate.d.ts.map +0 -1
  344. package/dist/adapters/drizzle/generate.js.map +0 -1
  345. package/dist/adapters/kysely/kysely-adapter.d.ts +0 -19
  346. package/dist/adapters/kysely/kysely-adapter.d.ts.map +0 -1
  347. package/dist/adapters/kysely/kysely-adapter.js +0 -17
  348. package/dist/adapters/kysely/kysely-adapter.js.map +0 -1
  349. package/dist/adapters/shared/table-name-mapper.d.ts +0 -12
  350. package/dist/adapters/shared/table-name-mapper.d.ts.map +0 -1
  351. package/dist/adapters/shared/table-name-mapper.js +0 -43
  352. package/dist/adapters/shared/table-name-mapper.js.map +0 -1
  353. package/dist/node_modules/.pnpm/rou3@0.7.10/node_modules/rou3/dist/index.js.map +0 -1
  354. package/dist/schema-generator/schema-generator.d.ts +0 -15
  355. package/dist/schema-generator/schema-generator.d.ts.map +0 -1
  356. package/src/adapters/drizzle/drizzle-adapter.ts +0 -39
  357. package/src/adapters/kysely/kysely-adapter.ts +0 -27
  358. package/src/adapters/shared/table-name-mapper.ts +0 -50
  359. package/src/schema-generator/schema-generator.ts +0 -12
  360. package/src/shared/config.ts +0 -10
  361. package/src/shared/connection-pool.ts +0 -24
  362. package/src/shared/prisma.ts +0 -45
@@ -1,29 +1,26 @@
1
- import { describe, it, expect, vi, assert, expectTypeOf } from "vitest";
1
+ import { describe, it, expect, expectTypeOf } from "vitest";
2
2
  import { schema, idColumn, FragnoId } from "../../schema/create";
3
3
  import {
4
4
  createUnitOfWork,
5
- type TypedUnitOfWork,
6
5
  type IUnitOfWork,
7
6
  type UOWCompiler,
8
7
  type UOWDecoder,
9
8
  type UOWExecutor,
10
9
  } from "./unit-of-work";
11
10
  import {
12
- executeUnitOfWork,
13
- executeRestrictedUnitOfWork,
14
- executeTxArray,
15
- executeTxCallbacks,
16
- executeServiceTx,
11
+ createServiceTxBuilder,
12
+ createHandlerTxBuilder,
13
+ isTxResult,
14
+ ConcurrencyConflictError,
17
15
  } from "./execute-unit-of-work";
18
16
  import {
19
17
  ExponentialBackoffRetryPolicy,
20
18
  LinearBackoffRetryPolicy,
21
19
  NoRetryPolicy,
22
20
  } from "./retry-policy";
23
- import type { AwaitedPromisesInObject } from "./execute-unit-of-work";
21
+ import type { AwaitedPromisesInObject, TxResult } from "./execute-unit-of-work";
24
22
 
25
- // Create test schema
26
- const testSchema = schema((s) =>
23
+ const testSchema = schema("test", (s) =>
27
24
  s.addTable("users", (t) =>
28
25
  t
29
26
  .addColumn("id", idColumn())
@@ -34,7 +31,6 @@ const testSchema = schema((s) =>
34
31
  ),
35
32
  );
36
33
 
37
- // Type tests for AwaitedPromisesInObject
38
34
  describe("AwaitedPromisesInObject type tests", () => {
39
35
  it("should unwrap promises in objects", () => {
40
36
  type Input = { a: Promise<string>; b: number };
@@ -132,1329 +128,1155 @@ function createMockDecoder(): UOWDecoder {
132
128
  };
133
129
  }
134
130
 
135
- // Helper to create a UOW factory that tracks how many times it's called
136
- function createMockUOWFactory(mutationResults: Array<{ success: boolean }>) {
137
- const callCount = { value: 0 };
138
- // Share callIndex across all UOW instances
139
- let callIndex = 0;
131
+ describe("Unified Tx API", () => {
132
+ describe("isTxResult", () => {
133
+ it("should return true for TxResult objects", () => {
134
+ const compiler = createMockCompiler();
135
+ const executor: UOWExecutor<unknown, unknown> = {
136
+ executeRetrievalPhase: async () => [],
137
+ executeMutationPhase: async () => ({ success: true, createdInternalIds: [] }),
138
+ };
139
+ const decoder = createMockDecoder();
140
+ const baseUow = createUnitOfWork(compiler, executor, decoder);
140
141
 
141
- const factory = () => {
142
- callCount.value++;
142
+ const txResult = createServiceTxBuilder(testSchema, baseUow)
143
+ .retrieve((uow) => uow.find("users", (b) => b.whereIndex("idx_email")))
144
+ .build();
143
145
 
144
- // Create executor that uses shared callIndex
145
- const executor: UOWExecutor<unknown, unknown> = {
146
- executeRetrievalPhase: async () => {
147
- return [
146
+ expect(isTxResult(txResult)).toBe(true);
147
+ });
148
+
149
+ it("should return false for non-TxResult objects", () => {
150
+ expect(isTxResult(null)).toBe(false);
151
+ expect(isTxResult(undefined)).toBe(false);
152
+ expect(isTxResult({})).toBe(false);
153
+ expect(isTxResult({ _internal: {} })).toBe(false);
154
+ expect(isTxResult(Promise.resolve())).toBe(false);
155
+ });
156
+ });
157
+
158
+ describe("createServiceTx", () => {
159
+ it("should create a TxResult with retrieve callback", () => {
160
+ const compiler = createMockCompiler();
161
+ const executor: UOWExecutor<unknown, unknown> = {
162
+ executeRetrievalPhase: async () => [
148
163
  [
149
164
  {
150
- id: FragnoId.fromExternal("user-1", 1),
165
+ id: FragnoId.fromExternal("1", 1),
151
166
  email: "test@example.com",
152
- name: "Test User",
167
+ name: "Test",
153
168
  balance: 100,
154
169
  },
155
170
  ],
156
- ];
157
- },
158
- executeMutationPhase: async () => {
159
- const result = mutationResults[callIndex] || { success: false };
160
- callIndex++;
161
- return { ...result, createdInternalIds: [] };
162
- },
163
- };
164
-
165
- return createUnitOfWork(createMockCompiler(), executor, createMockDecoder()).forSchema(
166
- testSchema,
167
- );
168
- };
169
- return { factory, callCount };
170
- }
171
-
172
- describe("executeUnitOfWork", () => {
173
- describe("validation", () => {
174
- it("should throw error when neither retrieve nor mutate is provided", async () => {
175
- const { factory } = createMockUOWFactory([{ success: true }]);
176
-
177
- await expect(executeUnitOfWork({}, { createUnitOfWork: factory })).rejects.toThrow(
178
- "At least one of 'retrieve' or 'mutate' callbacks must be provided",
179
- );
180
- });
181
- });
182
-
183
- describe("success scenarios", () => {
184
- it("should succeed on first attempt without retries", async () => {
185
- const { factory } = createMockUOWFactory([{ success: true }]);
186
- const onSuccess = vi.fn();
187
-
188
- const result = await executeUnitOfWork(
189
- {
190
- retrieve: (uow) => uow.find("users", (b) => b.whereIndex("primary")),
191
- mutate: (uow, [users]) => {
192
- const newBalance = users[0].balance + 100;
193
- uow.update("users", users[0].id, (b) => b.set({ balance: newBalance }).check());
194
- return { newBalance };
195
- },
196
- onSuccess,
197
- },
198
- { createUnitOfWork: factory },
199
- );
200
-
201
- assert(result.success);
202
- expect(result.mutationResult).toEqual({ newBalance: 200 });
203
- expect(onSuccess).toHaveBeenCalledExactlyOnceWith({
204
- results: expect.any(Array),
205
- mutationResult: { newBalance: 200 },
206
- createdIds: [],
207
- nonce: expect.any(String),
208
- });
209
- });
210
- });
211
-
212
- describe("retry scenarios", () => {
213
- it("should retry on conflict with eventual success", async () => {
214
- const { factory, callCount } = createMockUOWFactory([
215
- { success: false },
216
- { success: false },
217
- { success: true },
218
- ]);
219
-
220
- const result = await executeUnitOfWork(
221
- {
222
- retrieve: (uow) => uow.find("users", (b) => b.whereIndex("primary")),
223
- mutate: async (uow, [users]) => {
224
- uow.update("users", users[0].id, (b) => b.set({ balance: 200 }).check());
225
- },
226
- },
227
- {
228
- createUnitOfWork: factory,
229
- retryPolicy: new ExponentialBackoffRetryPolicy({ maxRetries: 3, initialDelayMs: 1 }),
230
- },
231
- );
232
-
233
- expect(result.success).toBe(true);
234
- expect(callCount.value).toBe(3); // Initial + 2 retries
235
- });
236
-
237
- it("should fail when max retries exceeded", async () => {
238
- const { factory, callCount } = createMockUOWFactory([
239
- { success: false },
240
- { success: false },
241
- { success: false },
242
- { success: false },
243
- ]);
244
-
245
- const result = await executeUnitOfWork(
246
- {
247
- retrieve: (uow) => uow.find("users", (b) => b.whereIndex("primary")),
248
- mutate: async (uow, [users]) => {
249
- uow.update("users", users[0].id, (b) => b.set({ balance: 200 }));
250
- },
251
- },
252
- {
253
- createUnitOfWork: factory,
254
- retryPolicy: new ExponentialBackoffRetryPolicy({ maxRetries: 2, initialDelayMs: 1 }),
255
- },
256
- );
257
-
258
- assert(!result.success);
259
- expect(result.reason).toBe("conflict");
260
- expect(callCount.value).toBe(3); // Initial + 2 retries
261
- });
262
-
263
- it("should create fresh UOW on each retry attempt", async () => {
264
- const { factory, callCount } = createMockUOWFactory([
265
- { success: false },
266
- { success: false },
267
- { success: true },
268
- ]);
269
-
270
- await executeUnitOfWork(
271
- {
272
- retrieve: (uow) => uow.find("users", (b) => b.whereIndex("primary")),
273
- mutate: async (uow, [users]) => {
274
- uow.update("users", users[0].id, (b) => b.set({ balance: 200 }));
275
- },
276
- },
277
- {
278
- createUnitOfWork: factory,
279
- retryPolicy: new LinearBackoffRetryPolicy({ maxRetries: 3, delayMs: 1 }),
280
- },
281
- );
282
-
283
- expect(callCount.value).toBe(3); // Each attempt creates a new UOW
284
- });
285
- });
286
-
287
- describe("AbortSignal handling", () => {
288
- it("should abort when signal is aborted before execution", async () => {
289
- const { factory } = createMockUOWFactory([{ success: false }]);
290
- const controller = new AbortController();
291
- controller.abort();
292
-
293
- const result = await executeUnitOfWork(
294
- {
295
- retrieve: (uow) => uow.find("users", (b) => b.whereIndex("primary")),
296
- mutate: async (uow, [users]) => {
297
- uow.update("users", users[0].id, (b) => b.set({ balance: 200 }));
298
- },
299
- },
300
- {
301
- createUnitOfWork: factory,
302
- retryPolicy: new ExponentialBackoffRetryPolicy({ maxRetries: 5, initialDelayMs: 1 }),
303
- signal: controller.signal,
304
- },
305
- );
306
-
307
- assert(!result.success);
308
- expect(result.reason).toBe("aborted");
309
- });
310
-
311
- it("should abort when signal is aborted during retry", async () => {
312
- const { factory } = createMockUOWFactory([{ success: false }, { success: false }]);
313
- const controller = new AbortController();
314
-
315
- // Abort after first attempt
316
- setTimeout(() => controller.abort(), 50);
317
-
318
- const result = await executeUnitOfWork(
319
- {
320
- retrieve: (uow) => uow.find("users", (b) => b.whereIndex("primary")),
321
- mutate: async (uow, [users]) => {
322
- uow.update("users", users[0].id, (b) => b.set({ balance: 200 }));
323
- },
324
- },
325
- {
326
- createUnitOfWork: factory,
327
- retryPolicy: new LinearBackoffRetryPolicy({ maxRetries: 5, delayMs: 100 }),
328
- signal: controller.signal,
329
- },
330
- );
331
-
332
- assert(!result.success);
333
- expect(result.reason).toBe("aborted");
334
- });
335
- });
336
-
337
- describe("onSuccess callback", () => {
338
- it("should pass mutation result to onSuccess callback", async () => {
339
- const { factory } = createMockUOWFactory([{ success: true }]);
340
- const onSuccess = vi.fn();
341
-
342
- await executeUnitOfWork(
343
- {
344
- retrieve: (uow) => uow.find("users", (b) => b.whereIndex("primary")),
345
- mutate: async () => {
346
- return { updatedCount: 5 };
347
- },
348
- onSuccess,
349
- },
350
- { createUnitOfWork: factory },
351
- );
352
-
353
- expect(onSuccess).toHaveBeenCalledTimes(1);
354
- expect(onSuccess).toHaveBeenCalledWith({
355
- results: expect.any(Array),
356
- mutationResult: { updatedCount: 5 },
357
- createdIds: [],
358
- nonce: expect.any(String),
359
- });
360
- });
361
-
362
- it("should only execute onSuccess callback on success", async () => {
363
- const { factory } = createMockUOWFactory([{ success: false }]);
364
- const onSuccess = vi.fn();
365
-
366
- const result = await executeUnitOfWork(
367
- {
368
- retrieve: (uow) => uow.find("users", (b) => b.whereIndex("primary")),
369
- mutate: async (uow, [users]) => {
370
- uow.update("users", users[0].id, (b) => b.set({ balance: 200 }));
371
- },
372
- onSuccess,
373
- },
374
- {
375
- createUnitOfWork: factory,
376
- retryPolicy: new NoRetryPolicy(),
377
- },
378
- );
379
-
380
- assert(!result.success);
381
- expect(result.reason).toBe("conflict");
382
- expect(onSuccess).not.toHaveBeenCalled();
383
- });
384
-
385
- it("should execute onSuccess only once even after retries", async () => {
386
- const { factory } = createMockUOWFactory([
387
- { success: false },
388
- { success: false },
389
- { success: true },
390
- ]);
391
- const onSuccess = vi.fn();
392
-
393
- await executeUnitOfWork(
394
- {
395
- retrieve: (uow) => uow.find("users", (b) => b.whereIndex("primary")),
396
- mutate: async (uow, [users]) => {
397
- uow.update("users", users[0].id, (b) => b.set({ balance: 200 }));
398
- },
399
- onSuccess,
400
- },
401
- {
402
- createUnitOfWork: factory,
403
- retryPolicy: new ExponentialBackoffRetryPolicy({ maxRetries: 3, initialDelayMs: 1 }),
404
- },
405
- );
406
-
407
- expect(onSuccess).toHaveBeenCalledTimes(1);
408
- });
409
-
410
- it("should handle async onSuccess callback", async () => {
411
- const { factory } = createMockUOWFactory([{ success: true }]);
412
- const onSuccess = vi.fn(async () => {
413
- await new Promise((resolve) => setTimeout(resolve, 10));
414
- });
415
-
416
- await executeUnitOfWork(
417
- {
418
- retrieve: (uow) => uow.find("users", (b) => b.whereIndex("primary")),
419
- mutate: async (uow, [users]) => {
420
- uow.update("users", users[0].id, (b) => b.set({ balance: 200 }));
421
- },
422
- onSuccess,
423
- },
424
- { createUnitOfWork: factory },
425
- );
426
-
427
- expect(onSuccess).toHaveBeenCalledTimes(1);
428
- });
429
- });
171
+ ],
172
+ executeMutationPhase: async () => ({ success: true, createdInternalIds: [] }),
173
+ };
174
+ const decoder = createMockDecoder();
175
+ const baseUow = createUnitOfWork(compiler, executor, decoder);
430
176
 
431
- describe("error handling", () => {
432
- it("should return error result when retrieve callback throws", async () => {
433
- const { factory } = createMockUOWFactory([{ success: true }]);
434
- const testError = new Error("Retrieve failed");
177
+ const txResult = createServiceTxBuilder(testSchema, baseUow)
178
+ .retrieve((uow) => uow.find("users", (b) => b.whereIndex("idx_email")))
179
+ .build();
435
180
 
436
- const result = await executeUnitOfWork(
437
- {
438
- retrieve: () => {
439
- throw testError;
440
- },
441
- mutate: async () => {},
442
- },
443
- { createUnitOfWork: factory },
444
- );
445
-
446
- assert(!result.success);
447
- assert(result.reason === "error");
448
- expect(result.error).toBe(testError);
181
+ expect(isTxResult(txResult)).toBe(true);
182
+ expect(txResult._internal.schema).toBe(testSchema);
183
+ expect(txResult._internal.callbacks.retrieve).toBeDefined();
449
184
  });
450
185
 
451
- it("should return error result when mutate callback throws", async () => {
452
- const { factory } = createMockUOWFactory([{ success: true }]);
453
- const testError = new Error("Mutate failed");
186
+ it("should create a TxResult with transformRetrieve callback", () => {
187
+ const compiler = createMockCompiler();
188
+ const executor: UOWExecutor<unknown, unknown> = {
189
+ executeRetrievalPhase: async () => [
190
+ [
191
+ {
192
+ id: FragnoId.fromExternal("1", 1),
193
+ email: "test@example.com",
194
+ name: "Test",
195
+ balance: 100,
196
+ },
197
+ ],
198
+ ],
199
+ executeMutationPhase: async () => ({ success: true, createdInternalIds: [] }),
200
+ };
201
+ const decoder = createMockDecoder();
202
+ const baseUow = createUnitOfWork(compiler, executor, decoder);
454
203
 
455
- const result = await executeUnitOfWork(
456
- {
457
- retrieve: (uow) => uow.find("users", (b) => b.whereIndex("primary")),
458
- mutate: async () => {
459
- throw testError;
460
- },
461
- },
462
- { createUnitOfWork: factory },
463
- );
204
+ const txResult = createServiceTxBuilder(testSchema, baseUow)
205
+ .retrieve((uow) => uow.find("users", (b) => b.whereIndex("idx_email")))
206
+ .transformRetrieve(([users]) => users[0] ?? null)
207
+ .build();
464
208
 
465
- assert(!result.success);
466
- assert(result.reason === "error");
467
- expect(result.error).toBe(testError);
209
+ expect(isTxResult(txResult)).toBe(true);
210
+ expect(txResult._internal.callbacks.retrieveSuccess).toBeDefined();
468
211
  });
469
212
 
470
- it("should return error result when onSuccess callback throws", async () => {
471
- const { factory } = createMockUOWFactory([{ success: true }]);
472
- const testError = new Error("onSuccess failed");
213
+ it("should create a TxResult with serviceCalls", () => {
214
+ const compiler = createMockCompiler();
215
+ const executor: UOWExecutor<unknown, unknown> = {
216
+ executeRetrievalPhase: async () => [
217
+ [
218
+ {
219
+ id: FragnoId.fromExternal("1", 1),
220
+ email: "test@example.com",
221
+ name: "Test",
222
+ balance: 100,
223
+ },
224
+ ],
225
+ ],
226
+ executeMutationPhase: async () => ({ success: true, createdInternalIds: [] }),
227
+ };
228
+ const decoder = createMockDecoder();
229
+ const baseUow = createUnitOfWork(compiler, executor, decoder);
473
230
 
474
- const result = await executeUnitOfWork(
475
- {
476
- retrieve: (uow) => uow.find("users", (b) => b.whereIndex("primary")),
477
- mutate: async () => {},
478
- onSuccess: async () => {
479
- throw testError;
480
- },
481
- },
482
- { createUnitOfWork: factory },
483
- );
231
+ // Create a dependency TxResult
232
+ const depTxResult = createServiceTxBuilder(testSchema, baseUow)
233
+ .retrieve((uow) => uow.find("users", (b) => b.whereIndex("idx_email")))
234
+ .transformRetrieve(([users]) => users[0] ?? null)
235
+ .build();
236
+
237
+ // Create a TxResult that depends on it
238
+ const txResult = createServiceTxBuilder(testSchema, baseUow)
239
+ .withServiceCalls(() => [depTxResult])
240
+ .mutate(({ uow, serviceIntermediateResult: [user] }) => {
241
+ if (!user) {
242
+ throw new Error("User not found");
243
+ }
244
+ return uow.create("users", { email: "new@example.com", name: "New", balance: 0 });
245
+ })
246
+ .build();
484
247
 
485
- assert(!result.success);
486
- assert(result.reason === "error");
487
- expect(result.error).toBe(testError);
248
+ expect(isTxResult(txResult)).toBe(true);
249
+ expect(txResult._internal.serviceCalls).toHaveLength(1);
488
250
  });
489
251
 
490
- it("should capture non-Error thrown values", async () => {
491
- const { factory } = createMockUOWFactory([{ success: true }]);
492
-
493
- const result = await executeUnitOfWork(
494
- {
495
- retrieve: (uow) => uow.find("users", (b) => b.whereIndex("primary")),
496
- mutate: async () => {
497
- throw "string error";
498
- },
499
- },
500
- { createUnitOfWork: factory },
501
- );
252
+ it("should type mutateResult as non-undefined when success AND mutate are provided", () => {
253
+ const compiler = createMockCompiler();
254
+ const executor: UOWExecutor<unknown, unknown> = {
255
+ executeRetrievalPhase: async () => [],
256
+ executeMutationPhase: async () => ({ success: true, createdInternalIds: [] }),
257
+ };
258
+ const decoder = createMockDecoder();
259
+ const baseUow = createUnitOfWork(compiler, executor, decoder);
502
260
 
503
- assert(!result.success);
504
- assert(result.reason === "error");
505
- expect(result.error).toBe("string error");
261
+ // When BOTH mutate AND success are provided, mutateResult should NOT be undefined
262
+ createServiceTxBuilder(testSchema, baseUow)
263
+ .mutate(({ uow }) => {
264
+ uow.create("users", { email: "test@example.com", name: "Test", balance: 0 });
265
+ return { created: true as const, code: "ABC123" };
266
+ })
267
+ .transform(({ mutateResult }) => {
268
+ // Key type assertion: mutateResult is NOT undefined when mutate callback IS provided
269
+ expectTypeOf(mutateResult).toEqualTypeOf<{ created: true; code: string }>();
270
+ // Should be able to access properties without null check
271
+ return { success: true, code: mutateResult.code };
272
+ })
273
+ .build();
506
274
  });
507
- });
508
-
509
- describe("retrieval results", () => {
510
- it("should pass retrieval results to mutation phase", async () => {
511
- const { factory } = createMockUOWFactory([{ success: true }]);
512
- const mutationPhase = vi.fn(async (_uow: unknown, _results: unknown) => {});
513
275
 
514
- await executeUnitOfWork(
515
- {
516
- retrieve: (uow) => uow.find("users", (b) => b.whereIndex("primary")),
517
- mutate: mutationPhase,
518
- },
519
- { createUnitOfWork: factory },
520
- );
276
+ it("should type mutateResult as undefined when success is provided but mutate is NOT", () => {
277
+ const compiler = createMockCompiler();
278
+ const executor: UOWExecutor<unknown, unknown> = {
279
+ executeRetrievalPhase: async () => [
280
+ [
281
+ {
282
+ id: FragnoId.fromExternal("1", 1),
283
+ email: "test@example.com",
284
+ name: "Test",
285
+ balance: 100,
286
+ },
287
+ ],
288
+ ],
289
+ executeMutationPhase: async () => ({ success: true, createdInternalIds: [] }),
290
+ };
291
+ const decoder = createMockDecoder();
292
+ const baseUow = createUnitOfWork(compiler, executor, decoder);
521
293
 
522
- expect(mutationPhase).toHaveBeenCalledTimes(1);
523
- const call = mutationPhase.mock.calls[0];
524
- assert(call);
525
- const [_uow, results] = call;
526
- expect(results).toBeInstanceOf(Array);
527
- expect(results as unknown[]).toHaveLength(1);
528
- expect((results as unknown[])[0]).toBeInstanceOf(Array);
294
+ // When success is provided but mutate is NOT, mutateResult should be undefined
295
+ createServiceTxBuilder(testSchema, baseUow)
296
+ .retrieve((uow) => uow.find("users", (b) => b.whereIndex("idx_email")))
297
+ .transformRetrieve(([users]) => users[0] ?? null)
298
+ // NO mutate callback
299
+ .transform(({ mutateResult, retrieveResult }) => {
300
+ // Key type assertion: mutateResult IS undefined when no mutate callback
301
+ expectTypeOf(mutateResult).toEqualTypeOf<undefined>();
302
+ // retrieveResult should still be properly typed (can be null from ?? null)
303
+ if (retrieveResult !== null) {
304
+ expectTypeOf(retrieveResult.email).toEqualTypeOf<string>();
305
+ }
306
+ return { user: retrieveResult };
307
+ })
308
+ .build();
529
309
  });
530
310
 
531
- it("should return retrieval results in the result object", async () => {
532
- const { factory } = createMockUOWFactory([{ success: true }]);
533
-
534
- const result = await executeUnitOfWork(
535
- {
536
- retrieve: (uow) => uow.find("users", (b) => b.whereIndex("primary")),
537
- mutate: async () => {},
538
- },
539
- { createUnitOfWork: factory },
540
- );
311
+ it("should type retrieveResult as TRetrieveResults when retrieve is provided but retrieveSuccess is NOT", () => {
312
+ const compiler = createMockCompiler();
313
+ const executor: UOWExecutor<unknown, unknown> = {
314
+ executeRetrievalPhase: async () => [
315
+ [
316
+ {
317
+ id: FragnoId.fromExternal("1", 1),
318
+ email: "test@example.com",
319
+ name: "Test",
320
+ balance: 100,
321
+ },
322
+ ],
323
+ ],
324
+ executeMutationPhase: async () => ({ success: true, createdInternalIds: [] }),
325
+ };
326
+ const decoder = createMockDecoder();
327
+ const baseUow = createUnitOfWork(compiler, executor, decoder);
541
328
 
542
- assert(result.success);
543
- expect(result.results).toBeInstanceOf(Array);
544
- expect(result.results).toHaveLength(1);
329
+ // When retrieve IS provided but retrieveSuccess is NOT, retrieveResult should be TRetrieveResults
330
+ // (the raw array from the retrieve callback), NOT unknown
331
+ createServiceTxBuilder(testSchema, baseUow)
332
+ .retrieve((uow) => uow.find("users", (b) => b.whereIndex("idx_email")))
333
+ // NO transformRetrieve callback - this is the key scenario
334
+ .mutate(({ uow, retrieveResult }) => {
335
+ // Key type assertion: retrieveResult should be the raw array type, NOT unknown
336
+ // The retrieve callback returns TypedUnitOfWork with [users[]] as the results type
337
+ expectTypeOf(retrieveResult).toEqualTypeOf<
338
+ [{ id: FragnoId; email: string; name: string; balance: number }[]]
339
+ >();
340
+
341
+ // Should be able to access the array without type errors
342
+ const users = retrieveResult[0];
343
+ expectTypeOf(users).toEqualTypeOf<
344
+ { id: FragnoId; email: string; name: string; balance: number }[]
345
+ >();
346
+
347
+ if (users.length > 0) {
348
+ const user = users[0];
349
+ uow.update("users", user.id, (b) => b.set({ balance: user.balance + 100 }));
350
+ }
351
+ return { processed: true };
352
+ })
353
+ .build();
545
354
  });
546
355
  });
547
356
 
548
- describe("promise awaiting in mutation result", () => {
549
- it("should await promises in mutation result object (1 level deep)", async () => {
550
- const { factory } = createMockUOWFactory([{ success: true }]);
551
-
552
- const result = await executeUnitOfWork(
553
- {
554
- retrieve: (uow) => uow.find("users", (b) => b.whereIndex("primary")),
555
- mutate: async () => {
556
- return {
557
- userId: Promise.resolve("user-123"),
558
- count: Promise.resolve(42),
559
- data: "plain-value",
560
- };
561
- },
562
- },
563
- { createUnitOfWork: factory },
564
- );
565
-
566
- assert(result.success);
567
- expect(result.mutationResult).toEqual({
568
- userId: "user-123",
569
- count: 42,
570
- data: "plain-value",
571
- });
572
- });
573
-
574
- it("should await promises in mutation result array", async () => {
575
- const { factory } = createMockUOWFactory([{ success: true }]);
576
-
577
- const result = await executeUnitOfWork(
578
- {
579
- retrieve: (uow) => uow.find("users", (b) => b.whereIndex("primary")),
580
- mutate: async () => {
581
- return [Promise.resolve("a"), Promise.resolve("b"), "c"];
582
- },
583
- },
584
- { createUnitOfWork: factory },
585
- );
586
-
587
- assert(result.success);
588
- expect(result.mutationResult).toEqual(["a", "b", "c"]);
589
- });
590
-
591
- it("should await direct promise mutation result", async () => {
592
- const { factory } = createMockUOWFactory([{ success: true }]);
593
-
594
- const result = await executeUnitOfWork(
595
- {
596
- retrieve: (uow) => uow.find("users", (b) => b.whereIndex("primary")),
597
- mutate: async () => {
598
- return Promise.resolve({ value: "resolved" });
599
- },
600
- },
601
- { createUnitOfWork: factory },
602
- );
603
-
604
- assert(result.success);
605
- expect(result.mutationResult).toEqual({ value: "resolved" });
606
- });
607
-
608
- it("should NOT await nested promises (only 1 level deep)", async () => {
609
- const { factory } = createMockUOWFactory([{ success: true }]);
610
-
611
- const result = await executeUnitOfWork(
357
+ describe("executeTx", () => {
358
+ it("should execute a simple retrieve-only transaction", async () => {
359
+ const compiler = createMockCompiler();
360
+ const mockUsers = [
612
361
  {
613
- retrieve: (uow) => uow.find("users", (b) => b.whereIndex("primary")),
614
- mutate: async () => {
615
- return {
616
- nested: { promise: Promise.resolve("still-a-promise") },
617
- };
618
- },
362
+ id: FragnoId.fromExternal("1", 1),
363
+ email: "alice@example.com",
364
+ name: "Alice",
365
+ balance: 100,
619
366
  },
620
- { createUnitOfWork: factory },
621
- );
622
-
623
- assert(result.success);
624
- // The nested promise should still be a promise
625
- expect(result.mutationResult.nested.promise).toBeInstanceOf(Promise);
626
- });
627
-
628
- it("should handle mixed types in mutation result", async () => {
629
- const { factory } = createMockUOWFactory([{ success: true }]);
630
-
631
- const result = await executeUnitOfWork(
632
367
  {
633
- retrieve: (uow) => uow.find("users", (b) => b.whereIndex("primary")),
634
- mutate: async () => {
635
- return {
636
- promise: Promise.resolve(100),
637
- number: 42,
638
- string: "test",
639
- null: null,
640
- undefined: undefined,
641
- nested: { value: "nested" },
642
- };
643
- },
644
- },
645
- { createUnitOfWork: factory },
646
- );
647
-
648
- assert(result.success);
649
- expect(result.mutationResult).toEqual({
650
- promise: 100,
651
- number: 42,
652
- string: "test",
653
- null: null,
654
- undefined: undefined,
655
- nested: { value: "nested" },
656
- });
657
- });
658
-
659
- it("should pass awaited mutation result to onSuccess callback", async () => {
660
- const { factory } = createMockUOWFactory([{ success: true }]);
661
- const onSuccess = vi.fn();
662
-
663
- await executeUnitOfWork(
664
- {
665
- retrieve: (uow) => uow.find("users", (b) => b.whereIndex("primary")),
666
- mutate: async () => {
667
- return {
668
- userId: Promise.resolve("user-456"),
669
- status: Promise.resolve("active"),
670
- };
671
- },
672
- onSuccess,
673
- },
674
- { createUnitOfWork: factory },
675
- );
676
-
677
- expect(onSuccess).toHaveBeenCalledExactlyOnceWith({
678
- results: expect.any(Array),
679
- mutationResult: {
680
- userId: "user-456",
681
- status: "active",
682
- },
683
- createdIds: [],
684
- nonce: expect.any(String),
685
- });
686
- });
687
- });
688
- });
689
-
690
- describe("executeRestrictedUnitOfWork", () => {
691
- describe("basic success cases", () => {
692
- it("should execute a simple mutation-only workflow", async () => {
693
- const { factory, callCount } = createMockUOWFactory([{ success: true }]);
694
-
695
- const result = await executeRestrictedUnitOfWork(
696
- async ({ forSchema, executeMutate }) => {
697
- const uow = forSchema(testSchema);
698
- const userId = uow.create("users", {
699
- id: "user-1",
700
- email: "test@example.com",
701
- name: "Test User",
702
- balance: 100,
703
- });
704
-
705
- await executeMutate();
706
-
707
- return { userId: userId.externalId };
708
- },
709
- { createUnitOfWork: factory },
710
- );
711
-
712
- expect(result).toEqual({ userId: "user-1" });
713
- expect(callCount.value).toBe(1);
714
- });
715
-
716
- it("should execute retrieval and mutation phases", async () => {
717
- const { factory, callCount } = createMockUOWFactory([{ success: true }]);
718
-
719
- const result = await executeRestrictedUnitOfWork(
720
- async ({ forSchema, executeRetrieve, executeMutate }) => {
721
- const uow = forSchema(testSchema).find("users", (b) => b.whereIndex("primary"));
722
- await executeRetrieve();
723
- const [[user]] = await uow.retrievalPhase;
724
-
725
- uow.update("users", user.id, (b) => b.set({ balance: user.balance + 50 }).check());
726
- await executeMutate();
727
-
728
- return { newBalance: user.balance + 50 };
729
- },
730
- { createUnitOfWork: factory },
731
- );
732
-
733
- expect(result).toEqual({ newBalance: 150 });
734
- expect(callCount.value).toBe(1);
735
- });
736
-
737
- it("should return callback result directly", async () => {
738
- const { factory } = createMockUOWFactory([{ success: true }]);
739
-
740
- const result = await executeRestrictedUnitOfWork(
741
- async () => {
742
- return { data: "test", count: 42, nested: { value: true } };
368
+ id: FragnoId.fromExternal("2", 1),
369
+ email: "bob@example.com",
370
+ name: "Bob",
371
+ balance: 200,
743
372
  },
744
- { createUnitOfWork: factory },
745
- );
373
+ ];
374
+ const executor: UOWExecutor<unknown, unknown> = {
375
+ executeRetrievalPhase: async () => [mockUsers],
376
+ executeMutationPhase: async () => ({ success: true, createdInternalIds: [] }),
377
+ };
378
+ const decoder = createMockDecoder();
746
379
 
747
- expect(result).toEqual({ data: "test", count: 42, nested: { value: true } });
380
+ const [users] = await createHandlerTxBuilder({
381
+ createUnitOfWork: () => createUnitOfWork(compiler, executor, decoder),
382
+ })
383
+ .retrieve(({ forSchema }) =>
384
+ forSchema(testSchema).find("users", (b) => b.whereIndex("idx_email")),
385
+ )
386
+ .execute();
387
+
388
+ expect(users).toHaveLength(2);
389
+ expect(users[0].email).toBe("alice@example.com");
390
+ expect(users[1].name).toBe("Bob");
748
391
  });
749
- });
750
-
751
- describe("retry behavior", () => {
752
- it("should retry on conflict and eventually succeed", async () => {
753
- const { factory, callCount } = createMockUOWFactory([
754
- { success: false }, // First attempt fails
755
- { success: false }, // Second attempt fails
756
- { success: true }, // Third attempt succeeds
757
- ]);
758
392
 
759
- const callbackExecutions = { count: 0 };
760
-
761
- const result = await executeRestrictedUnitOfWork(
762
- async ({ forSchema, executeMutate }) => {
763
- callbackExecutions.count++;
764
- const uow = forSchema(testSchema);
393
+ it("should execute a simple mutate-only transaction", async () => {
394
+ const compiler = createMockCompiler();
395
+ const executor: UOWExecutor<unknown, unknown> = {
396
+ executeRetrievalPhase: async () => [],
397
+ executeMutationPhase: async () => ({ success: true, createdInternalIds: [BigInt(1)] }),
398
+ };
399
+ const decoder = createMockDecoder();
765
400
 
766
- uow.create("users", {
767
- id: "user-1",
401
+ const result = await createHandlerTxBuilder({
402
+ createUnitOfWork: () => createUnitOfWork(compiler, executor, decoder),
403
+ })
404
+ .mutate(({ forSchema }) => {
405
+ const userId = forSchema(testSchema).create("users", {
768
406
  email: "test@example.com",
769
- name: "Test User",
407
+ name: "Test",
770
408
  balance: 100,
771
409
  });
410
+ return { userId };
411
+ })
412
+ .execute();
772
413
 
773
- await executeMutate();
774
-
775
- return { attempt: callbackExecutions.count };
776
- },
777
- { createUnitOfWork: factory },
778
- );
779
-
780
- expect(result.attempt).toBe(3);
781
- expect(callCount.value).toBe(3);
782
- expect(callbackExecutions.count).toBe(3);
414
+ expect(result.userId).toBeInstanceOf(FragnoId);
783
415
  });
784
416
 
785
- it("should throw error when retries are exhausted", async () => {
786
- const { factory, callCount } = createMockUOWFactory([
787
- { success: false }, // First attempt fails
788
- { success: false }, // Second attempt fails
789
- { success: false }, // Third attempt fails
790
- { success: false }, // Fourth attempt fails (exceeds default maxRetries: 3)
791
- ]);
417
+ it("should throw when retry policy is provided without retrieve operations", async () => {
418
+ const compiler = createMockCompiler();
419
+ const executor: UOWExecutor<unknown, unknown> = {
420
+ executeRetrievalPhase: async () => [],
421
+ executeMutationPhase: async () => ({ success: false }),
422
+ };
423
+ const decoder = createMockDecoder();
792
424
 
793
425
  await expect(
794
- executeRestrictedUnitOfWork(
795
- async ({ executeMutate }) => {
796
- await executeMutate();
797
- return { hello: "world" };
798
- },
799
- { createUnitOfWork: factory },
800
- ),
801
- ).rejects.toThrow("Unit of Work execution failed: optimistic concurrency conflict");
802
-
803
- // Default policy has maxRetries: 5, so we make 6 attempts (initial + 5 retries)
804
- expect(callCount.value).toBe(6);
805
- });
806
-
807
- it("should respect custom retry policy", async () => {
808
- const { factory, callCount } = createMockUOWFactory([
809
- { success: false },
810
- { success: false },
811
- { success: false },
812
- { success: false },
813
- { success: false },
814
- { success: true },
815
- ]);
816
-
817
- const result = await executeRestrictedUnitOfWork(
818
- async ({ executeMutate }) => {
819
- await executeMutate();
820
- return { done: true };
821
- },
822
- {
823
- createUnitOfWork: factory,
824
- retryPolicy: new ExponentialBackoffRetryPolicy({ maxRetries: 5, initialDelayMs: 1 }),
825
- },
826
- );
827
-
828
- expect(result).toEqual({ done: true });
829
- expect(callCount.value).toBe(6); // Initial + 5 retries
830
- });
831
-
832
- it("should use default ExponentialBackoffRetryPolicy with small delays", async () => {
833
- const { factory } = createMockUOWFactory([{ success: false }, { success: true }]);
834
-
835
- const startTime = Date.now();
836
- await executeRestrictedUnitOfWork(
837
- async ({ executeMutate }) => {
838
- await executeMutate();
839
- return {};
840
- },
841
- { createUnitOfWork: factory },
426
+ createHandlerTxBuilder({
427
+ createUnitOfWork: () => createUnitOfWork(compiler, executor, decoder),
428
+ retryPolicy: new NoRetryPolicy(),
429
+ })
430
+ .mutate(() => ({ done: true }))
431
+ .execute(),
432
+ ).rejects.toThrow(
433
+ "Retry policy is only supported when the transaction includes retrieve operations.",
842
434
  );
843
- const elapsed = Date.now() - startTime;
844
-
845
- // Default policy has initialDelayMs: 10, maxDelayMs: 100
846
- // First retry delay should be around 10ms
847
- expect(elapsed).toBeLessThan(200); // Allow some margin
848
- });
849
- });
850
-
851
- describe("error handling", () => {
852
- it("should throw error from callback immediately without retry", async () => {
853
- const { factory, callCount } = createMockUOWFactory([{ success: true }]);
854
-
855
- await expect(
856
- executeRestrictedUnitOfWork(
857
- async () => {
858
- throw new Error("Callback error");
859
- },
860
- { createUnitOfWork: factory },
861
- ),
862
- ).rejects.toThrow("Callback error");
863
-
864
- // Should NOT retry non-conflict errors
865
- expect(callCount.value).toBe(1); // Only initial attempt
866
435
  });
867
436
 
868
- it("should throw callback error directly", async () => {
869
- const { factory } = createMockUOWFactory([{ success: true }]);
870
- const originalError = new Error("Original error");
871
-
872
- try {
873
- await executeRestrictedUnitOfWork(
874
- async () => {
875
- throw originalError;
876
- },
877
- {
878
- createUnitOfWork: factory,
879
- retryPolicy: new NoRetryPolicy(), // Don't retry
880
- },
881
- );
882
- expect.fail("Should have thrown");
883
- } catch (error) {
884
- // Error should be thrown directly, not wrapped
885
- expect(error).toBe(originalError);
886
- }
887
- });
888
- });
889
-
890
- describe("abort signal", () => {
891
- it("should throw when aborted before execution", async () => {
892
- const { factory } = createMockUOWFactory([{ success: true }]);
893
- const controller = new AbortController();
894
- controller.abort();
437
+ it("should execute a transaction with serviceCalls as retrieve source", async () => {
438
+ const compiler = createMockCompiler();
439
+ const mockUser = {
440
+ id: FragnoId.fromExternal("1", 1),
441
+ email: "test@example.com",
442
+ name: "Test",
443
+ balance: 100,
444
+ };
445
+ const executor: UOWExecutor<unknown, unknown> = {
446
+ executeRetrievalPhase: async () => [[mockUser]],
447
+ executeMutationPhase: async () => ({ success: true, createdInternalIds: [] }),
448
+ };
449
+ const decoder = createMockDecoder();
895
450
 
896
- await expect(
897
- executeRestrictedUnitOfWork(
898
- async () => {
899
- return {};
900
- },
901
- { createUnitOfWork: factory, signal: controller.signal },
902
- ),
903
- ).rejects.toThrow("Unit of Work execution aborted");
904
- });
451
+ let currentUow: IUnitOfWork | null = null;
905
452
 
906
- it("should stop retrying when aborted during retry", async () => {
907
- const { factory, callCount } = createMockUOWFactory([
908
- { success: false },
909
- { success: false },
910
- { success: true },
911
- ]);
912
- const controller = new AbortController();
453
+ // Service that retrieves
454
+ const getUserById = () => {
455
+ return createServiceTxBuilder(testSchema, currentUow!)
456
+ .retrieve((uow) => uow.find("users", (b) => b.whereIndex("idx_email")))
457
+ .transformRetrieve(([users]) => users[0] ?? null)
458
+ .build();
459
+ };
913
460
 
914
- const promise = executeRestrictedUnitOfWork(
915
- async ({ executeMutate }) => {
916
- if (callCount.value === 2) {
917
- controller.abort();
918
- }
919
- await executeMutate();
920
- return {};
461
+ const result = await createHandlerTxBuilder({
462
+ createUnitOfWork: () => {
463
+ currentUow = createUnitOfWork(compiler, executor, decoder);
464
+ return currentUow;
921
465
  },
922
- { createUnitOfWork: factory, signal: controller.signal },
923
- );
466
+ })
467
+ .withServiceCalls(() => [getUserById()])
468
+ .transform(({ serviceResult: [user] }) => user)
469
+ .execute();
924
470
 
925
- await expect(promise).rejects.toThrow("Unit of Work execution aborted");
926
- expect(callCount.value).toBeLessThanOrEqual(2);
471
+ expect(result).toEqual(mockUser);
927
472
  });
928
- });
929
-
930
- describe("restricted UOW interface", () => {
931
- it("should provide access to forSchema", async () => {
932
- const { factory } = createMockUOWFactory([{ success: true }]);
933
473
 
934
- await executeRestrictedUnitOfWork(
935
- async ({ forSchema }) => {
936
- const uow = forSchema(testSchema);
937
- expect(uow).toBeDefined();
938
- expect(uow.schema).toBe(testSchema);
939
- return {};
940
- },
941
- { createUnitOfWork: factory },
942
- );
943
- });
474
+ it("should execute a transaction with mutate callback using serviceCalls", async () => {
475
+ const compiler = createMockCompiler();
476
+ const mockUser = {
477
+ id: FragnoId.fromExternal("1", 1),
478
+ email: "test@example.com",
479
+ name: "Test",
480
+ balance: 100,
481
+ };
482
+ const executor: UOWExecutor<unknown, unknown> = {
483
+ executeRetrievalPhase: async () => [[mockUser]],
484
+ executeMutationPhase: async () => ({ success: true, createdInternalIds: [BigInt(2)] }),
485
+ };
486
+ const decoder = createMockDecoder();
944
487
 
945
- it("should allow creating entities via forSchema", async () => {
946
- const { factory } = createMockUOWFactory([{ success: true }]);
488
+ let currentUow: IUnitOfWork | null = null;
947
489
 
948
- const result = await executeRestrictedUnitOfWork(
949
- async ({ forSchema, executeRetrieve, executeMutate }) => {
950
- const uow = forSchema(testSchema);
951
- await executeRetrieve();
490
+ // Service that retrieves
491
+ const getUserById = () => {
492
+ return createServiceTxBuilder(testSchema, currentUow!)
493
+ .retrieve((uow) => uow.find("users", (b) => b.whereIndex("idx_email")))
494
+ .transformRetrieve(([users]) => users[0] ?? null)
495
+ .build();
496
+ };
952
497
 
953
- const userId = uow.create("users", {
954
- id: "user-123",
955
- email: "test@example.com",
956
- name: "Test",
498
+ const result = await createHandlerTxBuilder({
499
+ createUnitOfWork: () => {
500
+ currentUow = createUnitOfWork(compiler, executor, decoder);
501
+ return currentUow;
502
+ },
503
+ })
504
+ .withServiceCalls(() => [getUserById()])
505
+ .mutate(({ forSchema, serviceIntermediateResult: [user] }) => {
506
+ if (!user) {
507
+ return { ok: false as const };
508
+ }
509
+ const newUserId = forSchema(testSchema).create("users", {
510
+ email: "new@example.com",
511
+ name: "New User",
957
512
  balance: 0,
958
513
  });
514
+ return { ok: true as const, newUserId };
515
+ })
516
+ .execute();
959
517
 
960
- await executeMutate();
518
+ expect(result.ok).toBe(true);
519
+ if (result.ok) {
520
+ expect(result.newUserId).toBeInstanceOf(FragnoId);
521
+ }
522
+ });
961
523
 
962
- return { userId };
963
- },
964
- { createUnitOfWork: factory },
965
- );
524
+ it("should execute a transaction with transform callback", async () => {
525
+ const compiler = createMockCompiler();
526
+ const mockUser = {
527
+ id: FragnoId.fromExternal("1", 1),
528
+ email: "test@example.com",
529
+ name: "Test",
530
+ balance: 100,
531
+ };
532
+ const executor: UOWExecutor<unknown, unknown> = {
533
+ executeRetrievalPhase: async () => [[mockUser]],
534
+ executeMutationPhase: async () => ({ success: true, createdInternalIds: [BigInt(2)] }),
535
+ };
536
+ const decoder = createMockDecoder();
966
537
 
967
- expect(result.userId).toBeInstanceOf(FragnoId);
968
- expect(result.userId.externalId).toBe("user-123");
969
- });
970
- });
538
+ let currentUow: IUnitOfWork | null = null;
971
539
 
972
- describe("promise awaiting in callback result", () => {
973
- it("should await promises in result object (1 level deep)", async () => {
974
- const { factory } = createMockUOWFactory([{ success: true }]);
540
+ // Service that retrieves
541
+ const getUserById = () => {
542
+ return createServiceTxBuilder(testSchema, currentUow!)
543
+ .retrieve((uow) => uow.find("users", (b) => b.whereIndex("idx_email")))
544
+ .transformRetrieve(([users]) => users[0] ?? null)
545
+ .build();
546
+ };
975
547
 
976
- const result = await executeRestrictedUnitOfWork(
977
- async ({ executeMutate }) => {
978
- await executeMutate();
548
+ const result = await createHandlerTxBuilder({
549
+ createUnitOfWork: () => {
550
+ currentUow = createUnitOfWork(compiler, executor, decoder);
551
+ return currentUow;
552
+ },
553
+ })
554
+ .withServiceCalls(() => [getUserById()])
555
+ .mutate(({ forSchema, serviceIntermediateResult: [user] }) => {
556
+ if (!user) {
557
+ return { created: false as const };
558
+ }
559
+ const newUserId = forSchema(testSchema).create("users", {
560
+ email: "new@example.com",
561
+ name: "New User",
562
+ balance: 0,
563
+ });
564
+ return { created: true as const, newUserId };
565
+ })
566
+ .transform(({ serviceResult: [user], mutateResult }) => {
979
567
  return {
980
- userId: Promise.resolve("user-123"),
981
- profileId: Promise.resolve("profile-456"),
982
- status: "completed",
568
+ originalUser: user,
569
+ mutationResult: mutateResult,
570
+ summary: "Transaction completed",
983
571
  };
984
- },
985
- { createUnitOfWork: factory },
986
- );
572
+ })
573
+ .execute();
987
574
 
988
- expect(result).toEqual({
989
- userId: "user-123",
990
- profileId: "profile-456",
991
- status: "completed",
992
- });
575
+ expect(result.summary).toBe("Transaction completed");
576
+ expect(result.originalUser).toEqual(mockUser);
577
+ expect(result.mutationResult?.created).toBe(true);
993
578
  });
994
579
 
995
- it("should await promises in result array", async () => {
996
- const { factory } = createMockUOWFactory([{ success: true }]);
997
-
998
- const result = await executeRestrictedUnitOfWork(
999
- async ({ executeMutate }) => {
1000
- await executeMutate();
1001
- return [Promise.resolve(1), Promise.resolve(2), 3];
1002
- },
1003
- { createUnitOfWork: factory },
1004
- );
580
+ it("should execute a transaction with serviceCalls (service composition)", async () => {
581
+ const compiler = createMockCompiler();
582
+ const mockUser = {
583
+ id: FragnoId.fromExternal("1", 1),
584
+ email: "test@example.com",
585
+ name: "Test",
586
+ balance: 100,
587
+ };
588
+ const executor: UOWExecutor<unknown, unknown> = {
589
+ executeRetrievalPhase: async () => [[mockUser]],
590
+ executeMutationPhase: async () => ({ success: true, createdInternalIds: [BigInt(2)] }),
591
+ };
592
+ const decoder = createMockDecoder();
1005
593
 
1006
- expect(result).toEqual([1, 2, 3]);
1007
- });
594
+ let currentUow: IUnitOfWork | null = null;
1008
595
 
1009
- it("should await direct promise result", async () => {
1010
- const { factory } = createMockUOWFactory([{ success: true }]);
596
+ // Simulate a service method that returns a TxResult
597
+ const getUserById = (userId: string) => {
598
+ return createServiceTxBuilder(testSchema, currentUow!)
599
+ .retrieve((uow) =>
600
+ uow.find("users", (b) => b.whereIndex("primary", (eb) => eb("id", "=", userId))),
601
+ )
602
+ .transformRetrieve(([users]) => users[0] ?? null)
603
+ .build();
604
+ };
1011
605
 
1012
- const result = await executeRestrictedUnitOfWork(
1013
- async ({ executeMutate }) => {
1014
- await executeMutate();
1015
- return Promise.resolve({ data: "test" });
606
+ const result = await createHandlerTxBuilder({
607
+ createUnitOfWork: () => {
608
+ currentUow = createUnitOfWork(compiler, executor, decoder);
609
+ return currentUow;
1016
610
  },
1017
- { createUnitOfWork: factory },
1018
- );
1019
-
1020
- expect(result).toEqual({ data: "test" });
611
+ })
612
+ .withServiceCalls(() => [getUserById("1")])
613
+ .mutate(({ forSchema, serviceIntermediateResult: [user] }) => {
614
+ if (!user) {
615
+ return { ok: false as const };
616
+ }
617
+ const orderId = forSchema(testSchema).create("users", {
618
+ email: "order@example.com",
619
+ name: "Order",
620
+ balance: 0,
621
+ });
622
+ return { ok: true as const, orderId, forUser: user.email };
623
+ })
624
+ .execute();
625
+
626
+ expect(result.ok).toBe(true);
627
+ if (result.ok) {
628
+ expect(result.forUser).toBe("test@example.com");
629
+ expect(result.orderId).toBeInstanceOf(FragnoId);
630
+ }
1021
631
  });
1022
632
 
1023
- it("should NOT await nested promises (only 1 level deep)", async () => {
1024
- const { factory } = createMockUOWFactory([{ success: true }]);
633
+ it("should type check serviceCalls with undefined (optional service pattern)", async () => {
634
+ const compiler = createMockCompiler();
635
+ const executor: UOWExecutor<unknown, unknown> = {
636
+ executeRetrievalPhase: async () => [],
637
+ executeMutationPhase: async () => ({ success: true, createdInternalIds: [BigInt(1)] }),
638
+ };
639
+ const decoder = createMockDecoder();
1025
640
 
1026
- const result = await executeRestrictedUnitOfWork(
1027
- async ({ executeMutate }) => {
1028
- await executeMutate();
1029
- return {
1030
- nested: {
1031
- promise: Promise.resolve("still-a-promise"),
1032
- },
1033
- };
1034
- },
1035
- { createUnitOfWork: factory },
1036
- );
641
+ // Simulate optional service pattern: optionalService?.method()
642
+ // When optionalService is undefined, this evaluates to undefined
643
+ // Use type assertion to prevent TypeScript from narrowing to literal undefined
644
+ const optionalService = undefined as
645
+ | { getUser: () => TxResult<{ name: string }, { name: string }> }
646
+ | undefined;
1037
647
 
1038
- // The nested promise should still be a promise
1039
- expect(result.nested.promise).toBeInstanceOf(Promise);
648
+ // This test demonstrates that serviceCalls can contain TxResult | undefined
649
+ // This is useful for optional service patterns like: optionalService?.method()
650
+ const result = await createHandlerTxBuilder({
651
+ createUnitOfWork: () => createUnitOfWork(compiler, executor, decoder),
652
+ })
653
+ .withServiceCalls(() => [optionalService?.getUser()])
654
+ .mutate(({ forSchema, serviceIntermediateResult: [maybeUser] }) => {
655
+ // maybeUser is typed as { name: string } | undefined
656
+ // This demonstrates the optional chaining pattern works correctly
657
+ expectTypeOf(maybeUser).toEqualTypeOf<{ name: string } | undefined>();
658
+ if (!maybeUser) {
659
+ const userId = forSchema(testSchema).create("users", {
660
+ email: "fallback@example.com",
661
+ name: "Fallback User",
662
+ balance: 0,
663
+ });
664
+ return { hadUser: false as const, userId };
665
+ }
666
+ return { hadUser: true as const, userName: maybeUser.name };
667
+ })
668
+ .execute();
669
+
670
+ // Since optionalService was undefined, maybeUser was undefined, so we hit the fallback path
671
+ expect(result.hadUser).toBe(false);
672
+ if (!result.hadUser) {
673
+ expect(result.userId).toBeInstanceOf(FragnoId);
674
+ }
1040
675
  });
1041
676
 
1042
- it("should handle mixed types in result", async () => {
1043
- const { factory } = createMockUOWFactory([{ success: true }]);
677
+ it("should handle serviceCalls with mix of TxResult and undefined", async () => {
678
+ const compiler = createMockCompiler();
679
+ const executor: UOWExecutor<unknown, unknown> = {
680
+ executeRetrievalPhase: async () => [],
681
+ executeMutationPhase: async () => ({ success: true, createdInternalIds: [BigInt(1)] }),
682
+ };
683
+ const decoder = createMockDecoder();
1044
684
 
1045
- const result = await executeRestrictedUnitOfWork(
1046
- async ({ executeMutate }) => {
1047
- await executeMutate();
1048
- return {
1049
- promise: Promise.resolve("resolved"),
1050
- number: 42,
1051
- string: "test",
1052
- boolean: true,
1053
- null: null,
1054
- undefined: undefined,
1055
- object: { nested: "value" },
1056
- };
1057
- },
1058
- { createUnitOfWork: factory },
1059
- );
685
+ let currentUow: IUnitOfWork | null = null;
1060
686
 
1061
- expect(result).toEqual({
1062
- promise: "resolved",
1063
- number: 42,
1064
- string: "test",
1065
- boolean: true,
1066
- null: null,
1067
- undefined: undefined,
1068
- object: { nested: "value" },
1069
- });
1070
- });
687
+ // Type for the created data returned by the defined service
688
+ type CreatedData = { userId: FragnoId; generatedCode: string };
689
+ // Type for the extra data that would be returned by the optional service
690
+ type ExtraData = { extraCode: string; timestamp: number };
691
+
692
+ // One defined service with mutation that returns data
693
+ const definedService = {
694
+ createUserAndReturnCode: (): TxResult<CreatedData, CreatedData> =>
695
+ createServiceTxBuilder(testSchema, currentUow!)
696
+ .mutate(({ uow }): CreatedData => {
697
+ const userId = uow.create("users", {
698
+ email: "created@example.com",
699
+ name: "Created User",
700
+ balance: 0,
701
+ });
702
+ // Return arbitrary data from the mutation
703
+ return { userId, generatedCode: "ABC123" };
704
+ })
705
+ .build(),
706
+ };
1071
707
 
1072
- it("should await promises even after retries", async () => {
1073
- const { factory, callCount } = createMockUOWFactory([{ success: false }, { success: true }]);
708
+ // Optional service that would also return mutation data
709
+ const optionalService = undefined as
710
+ | {
711
+ generateExtra: () => TxResult<ExtraData, ExtraData>;
712
+ }
713
+ | undefined;
714
+
715
+ const result = await createHandlerTxBuilder({
716
+ createUnitOfWork: () => {
717
+ currentUow = createUnitOfWork(compiler, executor, decoder);
718
+ return currentUow;
719
+ },
720
+ })
721
+ .withServiceCalls(
722
+ () =>
723
+ [definedService.createUserAndReturnCode(), optionalService?.generateExtra()] as const,
724
+ )
725
+ .mutate(({ forSchema, serviceIntermediateResult }) => {
726
+ // serviceIntermediateResult contains the mutation results from service calls
727
+ // (since service calls have no retrieveSuccess, the mutate result becomes the retrieve result for dependents)
728
+ const [createdData, maybeExtra] = serviceIntermediateResult;
729
+
730
+ // Type checks: createdData should have userId and generatedCode
731
+ // maybeExtra should be ExtraData | undefined
732
+ expectTypeOf(createdData).toEqualTypeOf<CreatedData>();
733
+ expectTypeOf(maybeExtra).toEqualTypeOf<ExtraData | undefined>();
734
+
735
+ forSchema(testSchema).create("users", {
736
+ email: "handler@example.com",
737
+ name: "Handler User",
738
+ balance: 0,
739
+ });
1074
740
 
1075
- const result = await executeRestrictedUnitOfWork(
1076
- async ({ executeMutate }) => {
1077
- await executeMutate();
1078
741
  return {
1079
- attempt: callCount.value,
1080
- data: Promise.resolve("final-result"),
742
+ depCode: createdData.generatedCode,
743
+ hadExtra: maybeExtra !== undefined,
1081
744
  };
1082
- },
1083
- { createUnitOfWork: factory },
1084
- );
745
+ })
746
+ .transform(({ serviceResult, serviceIntermediateResult, mutateResult }) => {
747
+ // Verify serviceResult types - these are the FINAL results from serviceCalls
748
+ const [finalCreatedData, maybeFinalExtra] = serviceResult;
1085
749
 
1086
- expect(result).toEqual({
1087
- attempt: 2,
1088
- data: "final-result",
1089
- });
1090
- });
750
+ // Type check: serviceResult should have same structure
751
+ // The final result is CreatedData (since there's no transform callback on the dep)
752
+ expectTypeOf(finalCreatedData).toEqualTypeOf<CreatedData>();
753
+ expectTypeOf(maybeFinalExtra).toEqualTypeOf<ExtraData | undefined>();
1091
754
 
1092
- it("should handle complex objects with multiple promises at top level", async () => {
1093
- const { factory } = createMockUOWFactory([{ success: true }]);
755
+ // serviceIntermediateResult should still be accessible in transform
756
+ const [_retrieveData, maybeRetrieveExtra] = serviceIntermediateResult;
757
+ expectTypeOf(maybeRetrieveExtra).toEqualTypeOf<ExtraData | undefined>();
1094
758
 
1095
- const result = await executeRestrictedUnitOfWork(
1096
- async ({ executeMutate }) => {
1097
- await executeMutate();
1098
759
  return {
1099
- userId: Promise.resolve("user-1"),
1100
- email: Promise.resolve("test@example.com"),
1101
- count: Promise.resolve(100),
1102
- active: Promise.resolve(true),
1103
- metadata: {
1104
- timestamp: Date.now(),
1105
- version: 1,
1106
- },
760
+ ...mutateResult,
761
+ finalDepUserId: finalCreatedData.userId,
762
+ finalDepCode: finalCreatedData.generatedCode,
763
+ extraWasUndefined: maybeFinalExtra === undefined,
1107
764
  };
1108
- },
1109
- { createUnitOfWork: factory },
1110
- );
1111
-
1112
- expect(typeof result.userId).toBe("string");
1113
- expect(result.userId).toBe("user-1");
1114
- expect(typeof result.email).toBe("string");
1115
- expect(result.email).toBe("test@example.com");
1116
- expect(typeof result.count).toBe("number");
1117
- expect(result.count).toBe(100);
1118
- expect(typeof result.active).toBe("boolean");
1119
- expect(result.active).toBe(true);
1120
- expect(typeof result.metadata.timestamp).toBe("number");
1121
- expect(result.metadata.version).toBe(1);
765
+ })
766
+ .execute();
767
+
768
+ // Verify runtime behavior
769
+ expect(result.depCode).toBe("ABC123");
770
+ expect(result.hadExtra).toBe(false);
771
+ expect(result.finalDepCode).toBe("ABC123");
772
+ expect(result.extraWasUndefined).toBe(true);
773
+ expect(result.finalDepUserId).toBeInstanceOf(FragnoId);
1122
774
  });
1123
775
 
1124
- it("should handle empty object result", async () => {
1125
- const { factory } = createMockUOWFactory([{ success: true }]);
1126
-
1127
- const result = await executeRestrictedUnitOfWork(
1128
- async ({ executeMutate }) => {
1129
- await executeMutate();
1130
- return {};
776
+ it("should retry on concurrency conflict", async () => {
777
+ const compiler = createMockCompiler();
778
+ let mutationAttempts = 0;
779
+ const executor: UOWExecutor<unknown, unknown> = {
780
+ executeRetrievalPhase: async () => [],
781
+ executeMutationPhase: async () => {
782
+ mutationAttempts++;
783
+ if (mutationAttempts < 3) {
784
+ return { success: false };
785
+ }
786
+ return { success: true, createdInternalIds: [] };
1131
787
  },
1132
- { createUnitOfWork: factory },
1133
- );
788
+ };
789
+ const decoder = createMockDecoder();
1134
790
 
1135
- expect(result).toEqual({});
791
+ const result = await createHandlerTxBuilder({
792
+ createUnitOfWork: () => createUnitOfWork(compiler, executor, decoder),
793
+ retryPolicy: new ExponentialBackoffRetryPolicy({ maxRetries: 5, initialDelayMs: 1 }),
794
+ })
795
+ .retrieve(({ forSchema }) => forSchema(testSchema).find("users"))
796
+ .mutate(({ forSchema }) => {
797
+ forSchema(testSchema).create("users", {
798
+ email: "test@example.com",
799
+ name: "Test",
800
+ balance: 0,
801
+ });
802
+ return { createdAt: Date.now() };
803
+ })
804
+ .execute();
805
+
806
+ // Verify we retried the correct number of times
807
+ expect(mutationAttempts).toBe(3);
808
+ // Verify we got a result
809
+ expect(result.createdAt).toBeGreaterThan(0);
1136
810
  });
1137
811
 
1138
- it("should handle primitive result types", async () => {
1139
- const { factory } = createMockUOWFactory([{ success: true }]);
812
+ it("should throw ConcurrencyConflictError when retries are exhausted", async () => {
813
+ const compiler = createMockCompiler();
814
+ const executor: UOWExecutor<unknown, unknown> = {
815
+ executeRetrievalPhase: async () => [],
816
+ executeMutationPhase: async () => ({ success: false }),
817
+ };
818
+ const decoder = createMockDecoder();
1140
819
 
1141
- const stringResult = await executeRestrictedUnitOfWork(
1142
- async ({ executeMutate }) => {
1143
- await executeMutate();
1144
- return "test-string";
1145
- },
1146
- { createUnitOfWork: factory },
1147
- );
820
+ await expect(
821
+ createHandlerTxBuilder({
822
+ createUnitOfWork: () => createUnitOfWork(compiler, executor, decoder),
823
+ retryPolicy: new NoRetryPolicy(),
824
+ })
825
+ .retrieve(({ forSchema }) => forSchema(testSchema).find("users"))
826
+ .mutate(() => ({ done: true }))
827
+ .execute(),
828
+ ).rejects.toThrow(ConcurrencyConflictError);
829
+ });
1148
830
 
1149
- expect(stringResult).toBe("test-string");
831
+ it("should abort when signal is aborted", async () => {
832
+ const compiler = createMockCompiler();
833
+ const executor: UOWExecutor<unknown, unknown> = {
834
+ executeRetrievalPhase: async () => [],
835
+ executeMutationPhase: async () => ({ success: true, createdInternalIds: [] }),
836
+ };
837
+ const decoder = createMockDecoder();
1150
838
 
1151
- const { factory: factory2 } = createMockUOWFactory([{ success: true }]);
1152
- const numberResult = await executeRestrictedUnitOfWork(
1153
- async ({ executeMutate }) => {
1154
- await executeMutate();
1155
- return 42;
1156
- },
1157
- { createUnitOfWork: factory2 },
1158
- );
839
+ const controller = new AbortController();
840
+ controller.abort();
1159
841
 
1160
- expect(numberResult).toBe(42);
842
+ await expect(
843
+ createHandlerTxBuilder({
844
+ createUnitOfWork: () => createUnitOfWork(compiler, executor, decoder),
845
+ signal: controller.signal,
846
+ })
847
+ .mutate(() => ({ done: true }))
848
+ .execute(),
849
+ ).rejects.toThrow("Transaction execution aborted");
1161
850
  });
1162
- });
1163
851
 
1164
- describe("tuple return types", () => {
1165
- it("should await promises in tuple and preserve tuple structure", async () => {
1166
- const { factory } = createMockUOWFactory([{ success: true }]);
852
+ it("should create fresh UOW on each retry attempt", async () => {
853
+ const compiler = createMockCompiler();
854
+ let callCount = 0;
855
+ let mutationAttempts = 0;
856
+ const executor: UOWExecutor<unknown, unknown> = {
857
+ executeRetrievalPhase: async () => [],
858
+ executeMutationPhase: async () => {
859
+ mutationAttempts++;
860
+ if (mutationAttempts < 3) {
861
+ return { success: false };
862
+ }
863
+ return { success: true, createdInternalIds: [] };
864
+ },
865
+ };
866
+ const decoder = createMockDecoder();
1167
867
 
1168
- const result = await executeRestrictedUnitOfWork(
1169
- async ({ executeMutate }) => {
1170
- await executeMutate();
1171
- // Return a tuple with promises
1172
- return [Promise.resolve("user-123"), Promise.resolve(42)] as const;
868
+ await createHandlerTxBuilder({
869
+ createUnitOfWork: () => {
870
+ callCount++;
871
+ return createUnitOfWork(compiler, executor, decoder);
1173
872
  },
1174
- { createUnitOfWork: factory },
1175
- );
873
+ retryPolicy: new LinearBackoffRetryPolicy({ maxRetries: 3, delayMs: 1 }),
874
+ })
875
+ .retrieve(({ forSchema }) => forSchema(testSchema).find("users"))
876
+ .mutate(({ forSchema }) => {
877
+ forSchema(testSchema).create("users", {
878
+ email: "test@example.com",
879
+ name: "Test",
880
+ balance: 0,
881
+ });
882
+ })
883
+ .execute();
1176
884
 
1177
- // Runtime behavior: promises should be awaited
1178
- expect(result).toEqual(["user-123", 42]);
1179
- expect(result[0]).toBe("user-123");
1180
- expect(result[1]).toBe(42);
885
+ // Verify factory was called for each attempt (initial + 2 retries)
886
+ expect(callCount).toBe(3);
1181
887
  });
1182
888
 
1183
- it("should handle tuple with mixed promise and non-promise values", async () => {
1184
- const { factory } = createMockUOWFactory([{ success: true }]);
889
+ it("should abort when signal is aborted during retry delay", async () => {
890
+ const compiler = createMockCompiler();
891
+ const executor: UOWExecutor<unknown, unknown> = {
892
+ executeRetrievalPhase: async () => [],
893
+ executeMutationPhase: async () => ({ success: false }),
894
+ };
895
+ const decoder = createMockDecoder();
896
+
897
+ const controller = new AbortController();
1185
898
 
1186
- const result = await executeRestrictedUnitOfWork(
1187
- async ({ executeMutate }) => {
1188
- await executeMutate();
1189
- // Tuple with mixed types
1190
- return [Promise.resolve("first"), "second", Promise.resolve(3)] as const;
1191
- },
1192
- { createUnitOfWork: factory },
1193
- );
899
+ // Abort after first attempt during retry delay
900
+ setTimeout(() => controller.abort(), 50);
1194
901
 
1195
- expect(result).toEqual(["first", "second", 3]);
1196
- expect(result[0]).toBe("first");
1197
- expect(result[1]).toBe("second");
1198
- expect(result[2]).toBe(3);
902
+ await expect(
903
+ createHandlerTxBuilder({
904
+ createUnitOfWork: () => createUnitOfWork(compiler, executor, decoder),
905
+ retryPolicy: new LinearBackoffRetryPolicy({ maxRetries: 5, delayMs: 100 }),
906
+ signal: controller.signal,
907
+ })
908
+ .retrieve(({ forSchema }) => forSchema(testSchema).find("users"))
909
+ .mutate(() => ({ done: true }))
910
+ .execute(),
911
+ ).rejects.toThrow("Transaction execution aborted");
1199
912
  });
1200
913
 
1201
- it("should handle Promise.all pattern with tuple", async () => {
1202
- const { factory } = createMockUOWFactory([{ success: true }]);
1203
-
1204
- const result = await executeRestrictedUnitOfWork(
1205
- async ({ executeMutate }) => {
1206
- await executeMutate();
1207
- // Simulate the pattern from db-fragment-integration.test.ts
1208
- const userPromise = Promise.resolve({ id: "user-1", name: "John" });
1209
- const ordersPromise = Promise.resolve([
1210
- { id: "order-1", total: 100 },
1211
- { id: "order-2", total: 200 },
1212
- ]);
1213
- return await Promise.all([userPromise, ordersPromise]);
1214
- },
1215
- { createUnitOfWork: factory },
1216
- );
914
+ it("should pass serviceResult to transform callback with final results", async () => {
915
+ const compiler = createMockCompiler();
916
+ const mockUser = {
917
+ id: FragnoId.fromExternal("1", 1),
918
+ email: "test@example.com",
919
+ name: "Test",
920
+ balance: 100,
921
+ };
922
+ const executor: UOWExecutor<unknown, unknown> = {
923
+ executeRetrievalPhase: async () => [[mockUser]],
924
+ executeMutationPhase: async () => ({ success: true, createdInternalIds: [BigInt(2)] }),
925
+ };
926
+ const decoder = createMockDecoder();
1217
927
 
1218
- // Runtime behavior
1219
- expect(result).toHaveLength(2);
1220
- expect(result[0]).toEqual({ id: "user-1", name: "John" });
1221
- expect(result[1]).toEqual([
1222
- { id: "order-1", total: 100 },
1223
- { id: "order-2", total: 200 },
1224
- ]);
1225
-
1226
- // Type check: result should be [{ id: string; name: string }, { id: string; total: number }[]]
1227
- // But with current implementation, it's incorrectly typed as an array union
1228
- const [user, orders] = result;
1229
- expect(user).toBeDefined();
1230
- expect(orders).toBeDefined();
1231
- });
928
+ let currentUow: IUnitOfWork | null = null;
1232
929
 
1233
- it("should handle array (not tuple) with promises", async () => {
1234
- const { factory } = createMockUOWFactory([{ success: true }]);
930
+ // Service that retrieves and mutates
931
+ const getUserAndUpdateBalance = (userId: string) => {
932
+ return createServiceTxBuilder(testSchema, currentUow!)
933
+ .retrieve((uow) =>
934
+ uow.find("users", (b) => b.whereIndex("primary", (eb) => eb("id", "=", userId))),
935
+ )
936
+ .transformRetrieve(([users]) => users[0] ?? null)
937
+ .mutate(({ uow, retrieveResult: user }) => {
938
+ expect(user).toEqual(mockUser);
939
+ expectTypeOf(user).toEqualTypeOf<typeof mockUser>();
940
+ if (!user) {
941
+ return { updated: false as const };
942
+ }
943
+ uow.update("users", user.id, (b) => b.set({ balance: user.balance + 100 }).check());
944
+ return { updated: true as const, newBalance: user.balance + 100 };
945
+ })
946
+ .build();
947
+ };
1235
948
 
1236
- const result = await executeRestrictedUnitOfWork(
1237
- async ({ executeMutate }) => {
1238
- await executeMutate();
1239
- // Regular array (not a tuple)
1240
- const items = [Promise.resolve(1), Promise.resolve(2), Promise.resolve(3)];
1241
- return items;
949
+ const result = await createHandlerTxBuilder({
950
+ createUnitOfWork: () => {
951
+ currentUow = createUnitOfWork(compiler, executor, decoder);
952
+ return currentUow;
1242
953
  },
1243
- { createUnitOfWork: factory },
1244
- );
954
+ })
955
+ .withServiceCalls(() => [getUserAndUpdateBalance("1")])
956
+ .transform(
957
+ ({ serviceResult: [depResult], serviceIntermediateResult: [depRetrieveResult] }) => {
958
+ expect(depResult).toEqual({
959
+ updated: true,
960
+ newBalance: 200,
961
+ });
962
+
963
+ expect(depRetrieveResult).toEqual(mockUser);
964
+
965
+ const dep = depResult;
966
+ return {
967
+ depUpdated: dep.updated,
968
+ depNewBalance: dep.updated ? dep.newBalance : null,
969
+ };
970
+ },
971
+ )
972
+ .execute();
1245
973
 
1246
- expect(result).toEqual([1, 2, 3]);
1247
- expect(result).toHaveLength(3);
974
+ expect(result.depUpdated).toBe(true);
975
+ expect(result.depNewBalance).toBe(200);
1248
976
  });
1249
977
  });
1250
978
 
1251
- describe("unhandled rejection handling", () => {
1252
- it("should not cause unhandled rejection when service method awaits retrievalPhase and executeRetrieve fails", async () => {
1253
- const settingsSchema = schema((s) =>
1254
- s.addTable("settings", (t) =>
1255
- t
1256
- .addColumn("id", idColumn())
1257
- .addColumn("key", "string")
1258
- .addColumn("value", "string")
1259
- .createIndex("unique_key", ["key"], { unique: true }),
1260
- ),
1261
- );
1262
-
1263
- // Create executor that throws "table does not exist" error
1264
- const failingExecutor: UOWExecutor<unknown, unknown> = {
1265
- executeRetrievalPhase: async () => {
1266
- throw new Error('relation "settings" does not exist');
1267
- },
979
+ describe("return type priority", () => {
980
+ it("should return transform result when transform callback is provided", async () => {
981
+ const compiler = createMockCompiler();
982
+ const executor: UOWExecutor<unknown, unknown> = {
983
+ executeRetrievalPhase: async () => [],
1268
984
  executeMutationPhase: async () => ({ success: true, createdInternalIds: [] }),
1269
985
  };
986
+ const decoder = createMockDecoder();
1270
987
 
1271
- const factory = () =>
1272
- createUnitOfWork(createMockCompiler(), failingExecutor, createMockDecoder());
1273
-
1274
- const deferred = Promise.withResolvers<string>();
988
+ const result = await createHandlerTxBuilder({
989
+ createUnitOfWork: () => createUnitOfWork(compiler, executor, decoder),
990
+ })
991
+ .retrieve(() => {
992
+ // Empty retrieve - defaults to empty array
993
+ })
994
+ .transformRetrieve((ctx) => {
995
+ expectTypeOf(ctx).toEqualTypeOf<unknown[]>();
996
+
997
+ return "retrieveSuccess result" as const;
998
+ })
999
+ .mutate((ctx) => {
1000
+ expectTypeOf(ctx.retrieveResult).toEqualTypeOf<"retrieveSuccess result">();
1001
+
1002
+ return "mutate result" as const;
1003
+ })
1004
+ .transform((ctx) => {
1005
+ expectTypeOf(ctx.retrieveResult).toEqualTypeOf<"retrieveSuccess result">();
1006
+ // mutateResult is NOT | undefined because mutate callback IS provided
1007
+ expectTypeOf(ctx.mutateResult).toEqualTypeOf<"mutate result">();
1008
+ // serviceResult and serviceIntermediateResult are empty tuples since no service calls callback
1009
+ expectTypeOf(ctx.serviceResult).toEqualTypeOf<readonly []>();
1010
+ expectTypeOf(ctx.serviceIntermediateResult).toEqualTypeOf<readonly []>();
1011
+
1012
+ return "success result" as const;
1013
+ })
1014
+ .execute();
1015
+
1016
+ expect(result).toBe("success result");
1017
+ });
1275
1018
 
1276
- // Service method that awaits retrievalPhase (simulating settingsService.get())
1277
- const getSettingValue = async (typedUow: TypedUnitOfWork<typeof settingsSchema>) => {
1278
- const uow = typedUow.find("settings", (b) =>
1279
- b.whereIndex("unique_key", (eb) => eb("key", "=", "version")),
1280
- );
1281
- const [results] = await uow.retrievalPhase;
1282
- return results?.[0];
1019
+ it("should return mutate result when no transform callback", async () => {
1020
+ const compiler = createMockCompiler();
1021
+ const executor: UOWExecutor<unknown, unknown> = {
1022
+ executeRetrievalPhase: async () => [],
1023
+ executeMutationPhase: async () => ({ success: true, createdInternalIds: [] }),
1283
1024
  };
1025
+ const decoder = createMockDecoder();
1284
1026
 
1285
- // Execute with executeRestrictedUnitOfWork
1286
- try {
1287
- await executeRestrictedUnitOfWork(
1288
- async ({ forSchema, executeRetrieve }) => {
1289
- const uow = forSchema(settingsSchema);
1027
+ const result = await createHandlerTxBuilder({
1028
+ createUnitOfWork: () => createUnitOfWork(compiler, executor, decoder),
1029
+ })
1030
+ .transformRetrieve(() => "retrieveSuccess result")
1031
+ .mutate(() => "mutate result")
1032
+ .execute();
1290
1033
 
1291
- const settingPromise = getSettingValue(uow);
1034
+ expect(result).toBe("mutate result");
1035
+ });
1292
1036
 
1293
- // Execute retrieval - this will fail
1294
- await executeRetrieve();
1037
+ it("should return transformRetrieve result when no mutate or transform", async () => {
1038
+ const compiler = createMockCompiler();
1039
+ const executor: UOWExecutor<unknown, unknown> = {
1040
+ executeRetrievalPhase: async () => [],
1041
+ executeMutationPhase: async () => ({ success: true, createdInternalIds: [] }),
1042
+ };
1043
+ const decoder = createMockDecoder();
1295
1044
 
1296
- // Won't reach here
1297
- return await settingPromise;
1298
- },
1299
- {
1300
- createUnitOfWork: factory,
1301
- retryPolicy: new NoRetryPolicy(),
1302
- },
1303
- );
1304
- expect.fail("Should have thrown an error");
1305
- } catch (error) {
1306
- // The error should be thrown directly (not wrapped) since it's not a concurrency conflict
1307
- expect(error).toBeInstanceOf(Error);
1308
- expect((error as Error).message).toContain('relation "settings" does not exist');
1309
- deferred.resolve((error as Error).message);
1310
- }
1045
+ const result = await createHandlerTxBuilder({
1046
+ createUnitOfWork: () => createUnitOfWork(compiler, executor, decoder),
1047
+ })
1048
+ .transformRetrieve(() => "retrieveSuccess result")
1049
+ .execute();
1311
1050
 
1312
- // Verify no unhandled rejection occurred
1313
- // If the test completes without throwing, the promise rejection was properly handled
1314
- expect(await deferred.promise).toContain('relation "settings" does not exist');
1051
+ expect(result).toBe("retrieveSuccess result");
1315
1052
  });
1316
- });
1317
1053
 
1318
- describe("executeTxArray", () => {
1319
- it("should execute multiple service promises and await them before mutations", async () => {
1054
+ it("should return serviceCalls final results when no local callbacks", async () => {
1320
1055
  const compiler = createMockCompiler();
1056
+ const mockUser = {
1057
+ id: FragnoId.fromExternal("1", 1),
1058
+ email: "test@example.com",
1059
+ name: "Test",
1060
+ balance: 100,
1061
+ };
1321
1062
  const executor: UOWExecutor<unknown, unknown> = {
1322
- executeRetrievalPhase: async () => [],
1323
- executeMutationPhase: async () => ({
1324
- success: true,
1325
- createdInternalIds: [],
1326
- }),
1063
+ executeRetrievalPhase: async () => [[mockUser]],
1064
+ executeMutationPhase: async () => ({ success: true, createdInternalIds: [] }),
1327
1065
  };
1328
1066
  const decoder = createMockDecoder();
1329
1067
 
1330
- let retrievalExecuted = false;
1331
- let servicesResolved = false;
1332
-
1333
- const result = await executeTxArray(
1334
- () => [
1335
- Promise.resolve().then(async () => {
1336
- await new Promise((resolve) => setTimeout(resolve, 10));
1337
- servicesResolved = true;
1338
- return { result1: "value1" };
1339
- }),
1340
- Promise.resolve().then(async () => {
1341
- await new Promise((resolve) => setTimeout(resolve, 10));
1342
- return { result2: "value2" };
1343
- }),
1344
- ],
1345
- {
1346
- createUnitOfWork: () => {
1347
- const uow = createUnitOfWork(compiler, executor, decoder);
1348
- const originalExecuteRetrieve = uow.executeRetrieve.bind(uow);
1349
- uow.executeRetrieve = async () => {
1350
- retrievalExecuted = true;
1351
- return originalExecuteRetrieve();
1352
- };
1353
- return uow;
1354
- },
1068
+ let currentUow: IUnitOfWork | null = null;
1069
+
1070
+ // Service that just retrieves
1071
+ const getUser = () => {
1072
+ return createServiceTxBuilder(testSchema, currentUow!)
1073
+ .retrieve((uow) => uow.find("users", (b) => b.whereIndex("idx_email")))
1074
+ .transformRetrieve(([users]) => users[0] ?? null)
1075
+ .build();
1076
+ };
1077
+
1078
+ // executeTx with only serviceCalls - should return serviceCalls' final results
1079
+ const result = await createHandlerTxBuilder({
1080
+ createUnitOfWork: () => {
1081
+ currentUow = createUnitOfWork(compiler, executor, decoder);
1082
+ return currentUow;
1355
1083
  },
1356
- );
1084
+ })
1085
+ .withServiceCalls(() => [getUser()])
1086
+ .execute();
1357
1087
 
1358
- expect(retrievalExecuted).toBe(true);
1359
- expect(servicesResolved).toBe(true);
1360
- expect(result).toEqual([{ result1: "value1" }, { result2: "value2" }]);
1088
+ expect(result).toEqual([mockUser]);
1361
1089
  });
1090
+ });
1362
1091
 
1363
- it("should retry on concurrency conflict", async () => {
1092
+ describe("serviceResult vs serviceIntermediateResult", () => {
1093
+ it("serviceIntermediateResult in mutate should contain transformRetrieve results", async () => {
1364
1094
  const compiler = createMockCompiler();
1365
- let attemptCount = 0;
1095
+ const mockUser = {
1096
+ id: FragnoId.fromExternal("1", 1),
1097
+ email: "test@example.com",
1098
+ name: "Test",
1099
+ balance: 100,
1100
+ };
1366
1101
  const executor: UOWExecutor<unknown, unknown> = {
1367
- executeRetrievalPhase: async () => [],
1368
- executeMutationPhase: async () => {
1369
- attemptCount++;
1370
- if (attemptCount < 2) {
1371
- return { success: false };
1372
- }
1373
- return {
1374
- success: true,
1375
- createdInternalIds: [],
1376
- };
1377
- },
1102
+ executeRetrievalPhase: async () => [[mockUser]],
1103
+ executeMutationPhase: async () => ({ success: true, createdInternalIds: [] }),
1378
1104
  };
1379
1105
  const decoder = createMockDecoder();
1380
1106
 
1381
- const result = await executeTxArray(() => [Promise.resolve({ result: "value" })], {
1382
- createUnitOfWork: () => createUnitOfWork(compiler, executor, decoder),
1383
- retryPolicy: new ExponentialBackoffRetryPolicy({ maxRetries: 3, initialDelayMs: 1 }),
1384
- });
1107
+ let currentUow: IUnitOfWork | null = null;
1108
+ let capturedServiceIntermediateResult: unknown[] = [];
1109
+
1110
+ const getUserById = () => {
1111
+ return (
1112
+ createServiceTxBuilder(testSchema, currentUow!)
1113
+ .retrieve((uow) => uow.find("users", (b) => b.whereIndex("idx_email")))
1114
+ // This transformRetrieve transforms the result
1115
+ .transformRetrieve(([users]) => ({ transformed: true, user: users[0] }))
1116
+ .build()
1117
+ );
1118
+ };
1119
+
1120
+ await createHandlerTxBuilder({
1121
+ createUnitOfWork: () => {
1122
+ currentUow = createUnitOfWork(compiler, executor, decoder);
1123
+ return currentUow;
1124
+ },
1125
+ })
1126
+ .withServiceCalls(() => [getUserById()])
1127
+ .mutate(({ serviceIntermediateResult }) => {
1128
+ // Should receive the transformed (transformRetrieve) result
1129
+ capturedServiceIntermediateResult = [...serviceIntermediateResult];
1130
+ return { done: true };
1131
+ })
1132
+ .execute();
1385
1133
 
1386
- expect(attemptCount).toBe(2);
1387
- expect(result).toEqual([{ result: "value" }]);
1134
+ expect(capturedServiceIntermediateResult[0]).toEqual({ transformed: true, user: mockUser });
1388
1135
  });
1389
1136
 
1390
- it("should throw if retries exhausted", async () => {
1137
+ it("serviceResult in transform should contain final (mutate) results", async () => {
1391
1138
  const compiler = createMockCompiler();
1139
+ const mockUser = {
1140
+ id: FragnoId.fromExternal("1", 1),
1141
+ email: "test@example.com",
1142
+ name: "Test",
1143
+ balance: 100,
1144
+ };
1392
1145
  const executor: UOWExecutor<unknown, unknown> = {
1393
- executeRetrievalPhase: async () => [],
1394
- executeMutationPhase: async () => ({ success: false }),
1146
+ executeRetrievalPhase: async () => [[mockUser]],
1147
+ executeMutationPhase: async () => ({ success: true, createdInternalIds: [] }),
1395
1148
  };
1396
1149
  const decoder = createMockDecoder();
1397
1150
 
1398
- await expect(
1399
- executeTxArray(() => [Promise.resolve({ result: "value" })], {
1400
- createUnitOfWork: () => createUnitOfWork(compiler, executor, decoder),
1401
- retryPolicy: new NoRetryPolicy(),
1402
- }),
1403
- ).rejects.toThrow("optimistic concurrency conflict");
1151
+ let currentUow: IUnitOfWork | null = null;
1152
+ let capturedServiceResult: unknown[] = [];
1153
+
1154
+ const getUserById = () => {
1155
+ return (
1156
+ createServiceTxBuilder(testSchema, currentUow!)
1157
+ .retrieve((uow) => uow.find("users", (b) => b.whereIndex("idx_email")))
1158
+ .transformRetrieve(([users]) => users[0])
1159
+ // This mutate returns a different result
1160
+ .mutate(({ retrieveResult: user }) => ({ mutated: true, userId: user?.id }))
1161
+ .build()
1162
+ );
1163
+ };
1164
+
1165
+ await createHandlerTxBuilder({
1166
+ createUnitOfWork: () => {
1167
+ currentUow = createUnitOfWork(compiler, executor, decoder);
1168
+ return currentUow;
1169
+ },
1170
+ })
1171
+ .withServiceCalls(() => [getUserById()])
1172
+ .transform(({ serviceResult }) => {
1173
+ // Should receive the mutate result (final), not transformRetrieve result
1174
+ capturedServiceResult = [...serviceResult];
1175
+ return { done: true };
1176
+ })
1177
+ .execute();
1178
+
1179
+ expect(capturedServiceResult[0]).toEqual({ mutated: true, userId: mockUser.id });
1404
1180
  });
1405
- });
1406
1181
 
1407
- describe("executeTxCallbacks", () => {
1408
- it("should execute retrieve and mutate callbacks in order", async () => {
1182
+ it("serviceResult in transform should contain mutateResult for mutate-only serviceCalls (via processTxResultAfterMutate)", async () => {
1183
+ // This test exercises the buggy code path in processTxResultAfterMutate:
1184
+ // When a nested TxResult has a transform callback and its serviceCall is mutate-only,
1185
+ // the transform callback should receive the mutateResult in
1186
+ // serviceResult, NOT the empty array.
1187
+ //
1188
+ // The key is that the nested service itself (wrapperService) has a transform callback,
1189
+ // so processTxResultAfterMutate is called for it.
1409
1190
  const compiler = createMockCompiler();
1410
1191
  const executor: UOWExecutor<unknown, unknown> = {
1411
1192
  executeRetrievalPhase: async () => [],
1412
1193
  executeMutationPhase: async () => ({
1413
1194
  success: true,
1414
- createdInternalIds: [],
1195
+ createdInternalIds: [BigInt(1)],
1415
1196
  }),
1416
1197
  };
1417
1198
  const decoder = createMockDecoder();
1418
1199
 
1419
- const executionOrder: string[] = [];
1200
+ let currentUow: IUnitOfWork | null = null;
1201
+ let capturedServiceIntermediateResultInNestedTransform: unknown[] = [];
1202
+
1203
+ // Mutate-only service - no retrieve or transformRetrieve callbacks
1204
+ const createItem = () => {
1205
+ return (
1206
+ createServiceTxBuilder(testSchema, currentUow!)
1207
+ // NO retrieve or transformRetrieve - this is a mutate-only dep
1208
+ .mutate(({ uow }) => {
1209
+ const itemId = uow.create("users", {
1210
+ email: "new-item@example.com",
1211
+ name: "New Item",
1212
+ balance: 0,
1213
+ });
1214
+ return { created: true, itemId };
1215
+ })
1216
+ .build()
1217
+ );
1218
+ };
1420
1219
 
1421
- const result = await executeTxCallbacks(
1422
- {
1423
- retrieve: ({ forSchema }) => {
1424
- executionOrder.push("retrieve");
1425
- const uow = forSchema(testSchema);
1426
- uow.find("users", (b) => b.whereIndex("idx_email"));
1427
- return { servicePromise: Promise.resolve({ value: "result" }) };
1428
- },
1429
- mutate: ({ forSchema }, { servicePromise }) => {
1430
- executionOrder.push("mutate");
1431
- const uow = forSchema(testSchema);
1432
- uow.create("users", { email: "test@example.com", name: "Test", balance: 0 });
1433
- return servicePromise;
1434
- },
1435
- },
1436
- {
1437
- createUnitOfWork: () => createUnitOfWork(compiler, executor, decoder),
1220
+ // Wrapper service that has a transform callback and uses createItem as a dep
1221
+ // This forces processTxResultAfterMutate to be called for this TxResult
1222
+ const wrapperService = () => {
1223
+ return (
1224
+ createServiceTxBuilder(testSchema, currentUow!)
1225
+ .withServiceCalls(() => [createItem()] as const)
1226
+ // NO mutate callback - just pass through
1227
+ // The transform callback is the key: it makes processTxResultAfterMutate get called
1228
+ .transform(({ serviceResult, serviceIntermediateResult }) => {
1229
+ capturedServiceIntermediateResultInNestedTransform = [...serviceIntermediateResult];
1230
+ // serviceResult should equal serviceIntermediateResult since dep has no transform callback
1231
+ expect(serviceResult[0]).toEqual(serviceIntermediateResult[0]);
1232
+ return { wrapped: true, innerResult: serviceIntermediateResult[0] };
1233
+ })
1234
+ .build()
1235
+ );
1236
+ };
1237
+
1238
+ const result = await createHandlerTxBuilder({
1239
+ createUnitOfWork: () => {
1240
+ currentUow = createUnitOfWork(compiler, executor, decoder);
1241
+ return currentUow;
1438
1242
  },
1439
- );
1243
+ })
1244
+ .withServiceCalls(() => [wrapperService()] as const)
1245
+ .transform(({ serviceResult: [wrapperResult] }) => wrapperResult)
1246
+ .execute();
1247
+
1248
+ // The wrapper service's transform callback should have received the mutateResult, NOT empty array
1249
+ expect(capturedServiceIntermediateResultInNestedTransform[0]).toEqual({
1250
+ created: true,
1251
+ itemId: expect.any(FragnoId),
1252
+ });
1253
+ // Verify it's not the empty array sentinel
1254
+ expect(capturedServiceIntermediateResultInNestedTransform[0]).not.toEqual([]);
1440
1255
 
1441
- expect(executionOrder).toEqual(["retrieve", "mutate"]);
1442
- expect(result).toEqual({ value: "result" });
1256
+ // And the handler should get the wrapped result
1257
+ expect(result).toEqual({
1258
+ wrapped: true,
1259
+ innerResult: { created: true, itemId: expect.any(FragnoId) },
1260
+ });
1443
1261
  });
1262
+ });
1444
1263
 
1445
- it("should handle retrieve-only transactions", async () => {
1264
+ describe("nested TxResult serviceCalls (service composition)", () => {
1265
+ it("should collect nested serviceCalls in dependency order", async () => {
1266
+ // Simpler test to verify collectAllTxResults works correctly
1446
1267
  const compiler = createMockCompiler();
1268
+ const mockUser = {
1269
+ id: FragnoId.fromExternal("user-1", 1),
1270
+ email: "test@example.com",
1271
+ name: "Test User",
1272
+ balance: 100,
1273
+ };
1274
+ let retrievePhaseExecuted = false;
1447
1275
  const executor: UOWExecutor<unknown, unknown> = {
1448
- executeRetrievalPhase: async () => [
1449
- [
1450
- {
1451
- id: FragnoId.fromExternal("1", 1),
1452
- email: "test@example.com",
1453
- name: "Test",
1454
- balance: 0,
1455
- },
1456
- ],
1457
- ],
1276
+ executeRetrievalPhase: async () => {
1277
+ retrievePhaseExecuted = true;
1278
+ return [[mockUser]];
1279
+ },
1458
1280
  executeMutationPhase: async () => ({
1459
1281
  success: true,
1460
1282
  createdInternalIds: [],
@@ -1462,26 +1284,80 @@ describe("executeRestrictedUnitOfWork", () => {
1462
1284
  };
1463
1285
  const decoder = createMockDecoder();
1464
1286
 
1465
- const result = await executeTxCallbacks(
1466
- {
1467
- retrieve: ({ forSchema }) => {
1468
- const uow = forSchema(testSchema);
1469
- uow.find("users", (b) => b.whereIndex("idx_email"));
1470
- return { users: [] };
1471
- },
1472
- },
1473
- {
1474
- createUnitOfWork: () => createUnitOfWork(compiler, executor, decoder),
1475
- },
1476
- );
1287
+ let currentUow: IUnitOfWork | null = null;
1288
+ let getUserByIdCalled = false;
1289
+ let validateUserCalled = false;
1290
+
1291
+ // Simple service that retrieves - no nested serviceCalls
1292
+ const getUserById = (userId: string) => {
1293
+ getUserByIdCalled = true;
1294
+ if (!currentUow) {
1295
+ throw new Error("currentUow is null in getUserById!");
1296
+ }
1297
+ return createServiceTxBuilder(testSchema, currentUow)
1298
+ .retrieve((uow) =>
1299
+ uow.find("users", (b) => b.whereIndex("primary", (eb) => eb("id", "=", userId))),
1300
+ )
1301
+ .transformRetrieve(([users]) => users[0] ?? null)
1302
+ .build();
1303
+ };
1477
1304
 
1478
- expect(result).toEqual({ users: [] });
1479
- });
1305
+ // Service with serviceCalls - depends on getUserById
1306
+ const validateUser = (userId: string) => {
1307
+ validateUserCalled = true;
1308
+ if (!currentUow) {
1309
+ throw new Error("currentUow is null in validateUser!");
1310
+ }
1311
+ return (
1312
+ createServiceTxBuilder(testSchema, currentUow)
1313
+ .withServiceCalls(() => [getUserById(userId)] as const)
1314
+ // mutate callback receives serviceIntermediateResult
1315
+ .mutate(({ serviceIntermediateResult: [user] }) => {
1316
+ return { valid: user !== null, user };
1317
+ })
1318
+ .build()
1319
+ );
1320
+ };
1321
+
1322
+ // Handler calls executeTx with serviceCalls containing validateUser
1323
+ // This tests 2-level nesting: handler -> validateUser -> getUserById
1324
+ const result = await createHandlerTxBuilder({
1325
+ createUnitOfWork: () => {
1326
+ currentUow = createUnitOfWork(compiler, executor, decoder);
1327
+ return currentUow;
1328
+ },
1329
+ })
1330
+ .withServiceCalls(() => [validateUser("user-1")] as const)
1331
+ .transform(({ serviceResult: [validationResult] }) => {
1332
+ return {
1333
+ isValid: validationResult.valid,
1334
+ userName: validationResult.user?.name,
1335
+ };
1336
+ })
1337
+ .execute();
1338
+
1339
+ // Verify services were called
1340
+ expect(getUserByIdCalled).toBe(true);
1341
+ expect(validateUserCalled).toBe(true);
1342
+ expect(retrievePhaseExecuted).toBe(true);
1343
+ expect(result.isValid).toBe(true);
1344
+ expect(result.userName).toBe("Test User");
1345
+ }, 500);
1346
+
1347
+ it("should handle a TxResult with serviceCalls that returns another TxResult", async () => {
1348
+ // This test reproduces the integration test scenario where:
1349
+ // - orderService.createOrderWithValidation has serviceCalls: () => [userService.getUserById(...)]
1350
+ // - handler has serviceCalls: () => [orderService.createOrderWithValidation(...)]
1480
1351
 
1481
- it("should handle mutate-only transactions", async () => {
1482
1352
  const compiler = createMockCompiler();
1353
+ const mockUser = {
1354
+ id: FragnoId.fromExternal("user-1", 1),
1355
+ email: "test@example.com",
1356
+ name: "Test User",
1357
+ balance: 100,
1358
+ };
1483
1359
  const executor: UOWExecutor<unknown, unknown> = {
1484
- executeRetrievalPhase: async () => [],
1360
+ executeRetrievalPhase: async () => [[mockUser]],
1485
1361
  executeMutationPhase: async () => ({
1486
1362
  success: true,
1487
1363
  createdInternalIds: [BigInt(1)],
@@ -1489,94 +1365,146 @@ describe("executeRestrictedUnitOfWork", () => {
1489
1365
  };
1490
1366
  const decoder = createMockDecoder();
1491
1367
 
1492
- const result = await executeTxCallbacks(
1493
- {
1494
- mutate: ({ forSchema }) => {
1495
- const uow = forSchema(testSchema);
1496
- uow.create("users", { email: "test@example.com", name: "Test", balance: 0 });
1497
- return { created: true };
1498
- },
1499
- },
1500
- {
1501
- createUnitOfWork: () => createUnitOfWork(compiler, executor, decoder),
1368
+ let currentUow: IUnitOfWork | null = null;
1369
+
1370
+ // Simulates userService.getUserById - returns a TxResult that retrieves a user
1371
+ const getUserById = (userId: string) => {
1372
+ return createServiceTxBuilder(testSchema, currentUow!)
1373
+ .retrieve((uow) =>
1374
+ uow.find("users", (b) => b.whereIndex("primary", (eb) => eb("id", "=", userId))),
1375
+ )
1376
+ .transformRetrieve(([users]) => users[0] ?? null)
1377
+ .build();
1378
+ };
1379
+
1380
+ // Simulates orderService.createOrderWithValidation - has serviceCalls on getUserById
1381
+ const createOrderWithValidation = (userId: string, productName: string) => {
1382
+ return createServiceTxBuilder(testSchema, currentUow!)
1383
+ .withServiceCalls(() => [getUserById(userId)] as const)
1384
+ .mutate(({ uow, serviceIntermediateResult: [user] }) => {
1385
+ if (!user) {
1386
+ throw new Error("User not found");
1387
+ }
1388
+ // Create an order (simulated by creating a user for simplicity)
1389
+ const orderId = uow.create("users", {
1390
+ email: `order-${productName}@example.com`,
1391
+ name: productName,
1392
+ balance: 0,
1393
+ });
1394
+ return { orderId, forUser: user.email };
1395
+ })
1396
+ .build();
1397
+ };
1398
+
1399
+ // Handler calls executeTx with serviceCalls containing the order service
1400
+ const result = await createHandlerTxBuilder({
1401
+ createUnitOfWork: () => {
1402
+ currentUow = createUnitOfWork(compiler, executor, decoder);
1403
+ return currentUow;
1502
1404
  },
1503
- );
1405
+ })
1406
+ .withServiceCalls(() => [createOrderWithValidation("user-1", "TypeScript Book")] as const)
1407
+ .transform(({ serviceResult: [orderResult] }) => {
1408
+ return {
1409
+ orderId: orderResult.orderId,
1410
+ forUser: orderResult.forUser,
1411
+ completed: true,
1412
+ };
1413
+ })
1414
+ .execute();
1504
1415
 
1505
- expect(result).toEqual({ created: true });
1506
- });
1416
+ expect(result.completed).toBe(true);
1417
+ expect(result.forUser).toBe("test@example.com");
1418
+ expect(result.orderId).toBeInstanceOf(FragnoId);
1419
+ }, 500); // Set 500ms timeout to catch deadlock
1507
1420
 
1508
- it("should await promises returned from mutate callback", async () => {
1421
+ it("should handle deeply nested TxResult serviceCalls (3 levels)", async () => {
1509
1422
  const compiler = createMockCompiler();
1423
+ const mockUser = {
1424
+ id: FragnoId.fromExternal("user-1", 1),
1425
+ email: "test@example.com",
1426
+ name: "Test User",
1427
+ balance: 100,
1428
+ };
1510
1429
  const executor: UOWExecutor<unknown, unknown> = {
1511
- executeRetrievalPhase: async () => [],
1430
+ executeRetrievalPhase: async () => [[mockUser]],
1512
1431
  executeMutationPhase: async () => ({
1513
1432
  success: true,
1514
- createdInternalIds: [],
1433
+ createdInternalIds: [BigInt(1)],
1515
1434
  }),
1516
1435
  };
1517
1436
  const decoder = createMockDecoder();
1518
1437
 
1519
- const result = await executeTxCallbacks(
1520
- {
1521
- mutate: () => {
1522
- return Promise.resolve({ value: "async result" });
1523
- },
1524
- },
1525
- {
1526
- createUnitOfWork: () => createUnitOfWork(compiler, executor, decoder),
1527
- },
1528
- );
1438
+ let currentUow: IUnitOfWork | null = null;
1529
1439
 
1530
- expect(result).toEqual({ value: "async result" });
1531
- });
1440
+ // Level 3: Basic user retrieval
1441
+ const getUserById = (userId: string) => {
1442
+ return createServiceTxBuilder(testSchema, currentUow!)
1443
+ .retrieve((uow) =>
1444
+ uow.find("users", (b) => b.whereIndex("primary", (eb) => eb("id", "=", userId))),
1445
+ )
1446
+ .transformRetrieve(([users]) => users[0] ?? null)
1447
+ .build();
1448
+ };
1532
1449
 
1533
- it("should retry on concurrency conflict", async () => {
1534
- const compiler = createMockCompiler();
1535
- let attemptCount = 0;
1536
- const executor: UOWExecutor<unknown, unknown> = {
1537
- executeRetrievalPhase: async () => [],
1538
- executeMutationPhase: async () => {
1539
- attemptCount++;
1540
- if (attemptCount < 2) {
1541
- return { success: false };
1542
- }
1543
- return {
1544
- success: true,
1545
- createdInternalIds: [],
1546
- };
1547
- },
1450
+ // Level 2: Depends on getUserById
1451
+ const validateUser = (userId: string) => {
1452
+ return createServiceTxBuilder(testSchema, currentUow!)
1453
+ .withServiceCalls(() => [getUserById(userId)] as const)
1454
+ .mutate(({ serviceIntermediateResult: [user] }) => {
1455
+ if (!user) {
1456
+ return { valid: false as const, reason: "User not found" };
1457
+ }
1458
+ return { valid: true as const, user };
1459
+ })
1460
+ .build();
1548
1461
  };
1549
- const decoder = createMockDecoder();
1550
1462
 
1551
- const result = await executeTxCallbacks(
1552
- {
1553
- mutate: () => ({ value: "result" }),
1554
- },
1555
- {
1556
- createUnitOfWork: () => createUnitOfWork(compiler, executor, decoder),
1557
- retryPolicy: new ExponentialBackoffRetryPolicy({ maxRetries: 3, initialDelayMs: 1 }),
1558
- },
1559
- );
1463
+ // Level 1: Depends on validateUser
1464
+ const createOrder = (userId: string, productName: string) => {
1465
+ return createServiceTxBuilder(testSchema, currentUow!)
1466
+ .withServiceCalls(() => [validateUser(userId)] as const)
1467
+ .mutate(({ uow, serviceIntermediateResult: [validation] }) => {
1468
+ if (!validation.valid) {
1469
+ throw new Error(validation.reason);
1470
+ }
1471
+ const orderId = uow.create("users", {
1472
+ email: `order-${productName}@example.com`,
1473
+ name: productName,
1474
+ balance: 0,
1475
+ });
1476
+ return { orderId, forUser: validation.user.email };
1477
+ })
1478
+ .build();
1479
+ };
1560
1480
 
1561
- expect(attemptCount).toBe(2);
1562
- expect(result).toEqual({ value: "result" });
1563
- });
1481
+ // Handler: Depends on createOrder (3 levels deep)
1482
+ const result = await createHandlerTxBuilder({
1483
+ createUnitOfWork: () => {
1484
+ currentUow = createUnitOfWork(compiler, executor, decoder);
1485
+ return currentUow;
1486
+ },
1487
+ })
1488
+ .withServiceCalls(() => [createOrder("user-1", "Advanced TypeScript")] as const)
1489
+ .transform(({ serviceResult: [orderResult] }) => ({
1490
+ orderId: orderResult.orderId,
1491
+ forUser: orderResult.forUser,
1492
+ completed: true,
1493
+ }))
1494
+ .execute();
1495
+
1496
+ expect(result.completed).toBe(true);
1497
+ expect(result.forUser).toBe("test@example.com");
1498
+ expect(result.orderId).toBeInstanceOf(FragnoId);
1499
+ }, 500); // Set 500ms timeout to catch deadlock
1564
1500
  });
1565
1501
 
1566
- describe("executeServiceTx", () => {
1567
- it("should execute service transaction with retrieve and mutate", async () => {
1502
+ describe("triggerHook in serviceTx", () => {
1503
+ it("should record triggered hooks on the base UOW when using createServiceTx with retrieve and mutate", async () => {
1568
1504
  const compiler = createMockCompiler();
1505
+ // Return empty array to simulate no existing user, so the hook will be triggered
1569
1506
  const executor: UOWExecutor<unknown, unknown> = {
1570
- executeRetrievalPhase: async () => [
1571
- [
1572
- {
1573
- id: FragnoId.fromExternal("1", 1),
1574
- email: "test@example.com",
1575
- name: "Test",
1576
- balance: 100,
1577
- },
1578
- ],
1579
- ],
1507
+ executeRetrievalPhase: async () => [[]],
1580
1508
  executeMutationPhase: async () => ({
1581
1509
  success: true,
1582
1510
  createdInternalIds: [],
@@ -1584,167 +1512,179 @@ describe("executeRestrictedUnitOfWork", () => {
1584
1512
  };
1585
1513
  const decoder = createMockDecoder();
1586
1514
 
1587
- const baseUow = createUnitOfWork(compiler, executor, decoder);
1588
- const restrictedUow = baseUow.restrict();
1589
-
1590
- // Start service tx
1591
- const servicePromise = executeServiceTx(
1592
- testSchema,
1593
- {
1594
- retrieve: (uow) => {
1595
- return uow.findFirst("users", (b) => b.whereIndex("idx_email"));
1596
- },
1597
- mutate: async (uow, [user]) => {
1598
- if (!user) {
1599
- return { ok: false };
1600
- }
1601
- await new Promise((resolve) => setTimeout(resolve, 10)); // Async work
1602
- uow.update("users", user.id, (b) => b.set({ balance: user.balance - 10 }));
1603
- return { ok: true, newBalance: user.balance - 10 };
1604
- },
1605
- },
1606
- restrictedUow,
1607
- );
1608
-
1609
- // Simulate handler executing phases concurrently with service
1610
- // Yield to let service start awaiting retrievalPhase
1611
- await new Promise((resolve) => setImmediate(resolve));
1515
+ let currentUow: IUnitOfWork | null = null;
1612
1516
 
1613
- // Execute retrieve phase
1614
- await baseUow.executeRetrieve();
1517
+ // Define hooks type for this test
1518
+ type TestHooks = {
1519
+ onSubscribe: (payload: { email: string }) => void;
1520
+ };
1615
1521
 
1616
- // Wait for service mutate callback to schedule mutations
1617
- await new Promise((resolve) => setTimeout(resolve, 20));
1522
+ const hooks: TestHooks = {
1523
+ onSubscribe: (payload: { email: string }) => {
1524
+ console.log(`onSubscribe: ${payload.email}`);
1525
+ },
1526
+ };
1618
1527
 
1619
- // Execute mutation phase
1620
- await baseUow.executeMutations();
1528
+ // Service that retrieves a user and triggers a hook in mutate
1529
+ const subscribeUser = (email: string) => {
1530
+ return createServiceTxBuilder(testSchema, currentUow!, hooks)
1531
+ .retrieve((uow) =>
1532
+ uow.find("users", (b) => b.whereIndex("idx_email", (eb) => eb("email", "=", email))),
1533
+ )
1534
+ .transformRetrieve(([users]) => users[0] ?? null)
1535
+ .mutate(({ uow, retrieveResult: existingUser }) => {
1536
+ if (existingUser) {
1537
+ return { subscribed: false, email };
1538
+ }
1539
+ // Trigger hook when subscribing a new user
1540
+ uow.triggerHook("onSubscribe", { email });
1541
+ return { subscribed: true, email };
1542
+ })
1543
+ .build();
1544
+ };
1621
1545
 
1622
- // Wait for service to complete
1623
- const serviceResult = await servicePromise;
1624
- expect(serviceResult).toEqual({ ok: true, newBalance: 90 });
1546
+ // Execute the transaction
1547
+ await createHandlerTxBuilder({
1548
+ createUnitOfWork: () => {
1549
+ currentUow = createUnitOfWork(compiler, executor, decoder);
1550
+ return currentUow;
1551
+ },
1552
+ })
1553
+ .withServiceCalls(() => [subscribeUser("new@example.com")] as const)
1554
+ .transform(({ serviceResult: [result] }) => result)
1555
+ .execute();
1556
+
1557
+ // Verify that the hook was triggered and recorded on the base UOW
1558
+ const triggeredHooks = currentUow!.getTriggeredHooks();
1559
+ expect(triggeredHooks).toHaveLength(1);
1560
+ expect(triggeredHooks[0]).toMatchObject({
1561
+ hookName: "onSubscribe",
1562
+ payload: { email: "new@example.com" },
1563
+ });
1625
1564
  });
1626
1565
 
1627
- it("should handle async mutate callback", async () => {
1566
+ it("should record triggered hooks when service has only retrieve (no retrieveSuccess) and mutate", async () => {
1628
1567
  const compiler = createMockCompiler();
1568
+ const mockUser = {
1569
+ id: FragnoId.fromExternal("user-1", 1),
1570
+ email: "test@example.com",
1571
+ name: "Test User",
1572
+ balance: 100,
1573
+ };
1629
1574
  const executor: UOWExecutor<unknown, unknown> = {
1630
- executeRetrievalPhase: async () => [[]],
1575
+ executeRetrievalPhase: async () => [[mockUser]],
1631
1576
  executeMutationPhase: async () => ({
1632
1577
  success: true,
1633
- createdInternalIds: [BigInt(1)],
1578
+ createdInternalIds: [],
1634
1579
  }),
1635
1580
  };
1636
1581
  const decoder = createMockDecoder();
1637
1582
 
1638
- const baseUow = createUnitOfWork(compiler, executor, decoder);
1639
- const restrictedUow = baseUow.restrict();
1640
-
1641
- // Simulate handler executing phases concurrently with service
1642
- const handlerSimulation = (async () => {
1643
- // Yield to let service start
1644
- await Promise.resolve();
1645
- // Execute retrieve phase
1646
- await baseUow.executeRetrieve();
1647
- // Wait for service mutate callback to schedule mutations
1648
- await new Promise((resolve) => setTimeout(resolve, 20));
1649
- // Execute mutation phase
1650
- await baseUow.executeMutations();
1651
- })();
1652
-
1653
- // Start service tx
1654
- const servicePromise = executeServiceTx(
1655
- testSchema,
1656
- {
1657
- retrieve: (uow) => {
1658
- return uow.find("users", (b) => b.whereIndex("idx_email"));
1659
- },
1660
- mutate: async (uow) => {
1661
- await new Promise((resolve) => setTimeout(resolve, 10));
1662
- uow.create("users", { email: "new@example.com", name: "New", balance: 0 });
1663
- return { created: true };
1664
- },
1583
+ let currentUow: IUnitOfWork | null = null;
1584
+
1585
+ // Define hooks type for this test
1586
+ type TestHooks = {
1587
+ onUserUpdated: (payload: { userId: string }) => void;
1588
+ };
1589
+
1590
+ const hooks: TestHooks = {
1591
+ onUserUpdated: (payload: { userId: string }) => {
1592
+ console.log(`onUserUpdated: ${payload.userId}`);
1665
1593
  },
1666
- restrictedUow,
1667
- );
1594
+ };
1668
1595
 
1669
- // Wait for both handler and service to complete
1670
- await Promise.all([handlerSimulation, servicePromise]);
1596
+ // Service that uses raw retrieve results (no transformRetrieve) and triggers hook
1597
+ const updateUser = (userId: string) => {
1598
+ return (
1599
+ createServiceTxBuilder(testSchema, currentUow!, hooks)
1600
+ .retrieve((uow) =>
1601
+ uow.find("users", (b) => b.whereIndex("primary", (eb) => eb("id", "=", userId))),
1602
+ )
1603
+ // NO transformRetrieve - mutate receives raw [users[]] array
1604
+ .mutate(({ uow, retrieveResult: [users] }) => {
1605
+ const user = users[0];
1606
+ if (!user) {
1607
+ return { updated: false };
1608
+ }
1609
+ uow.triggerHook("onUserUpdated", { userId: user.id.toString() });
1610
+ return { updated: true };
1611
+ })
1612
+ .build()
1613
+ );
1614
+ };
1671
1615
 
1672
- const serviceResult = await servicePromise;
1673
- expect(serviceResult).toEqual({ created: true });
1616
+ await createHandlerTxBuilder({
1617
+ createUnitOfWork: () => {
1618
+ currentUow = createUnitOfWork(compiler, executor, decoder);
1619
+ return currentUow;
1620
+ },
1621
+ })
1622
+ .withServiceCalls(() => [updateUser("user-1")] as const)
1623
+ .transform(({ serviceResult: [result] }) => result)
1624
+ .execute();
1625
+
1626
+ // Verify hook was triggered
1627
+ const triggeredHooks = currentUow!.getTriggeredHooks();
1628
+ expect(triggeredHooks).toHaveLength(1);
1629
+ expect(triggeredHooks[0]).toMatchObject({
1630
+ hookName: "onUserUpdated",
1631
+ payload: { userId: expect.any(String) },
1632
+ });
1674
1633
  });
1634
+ });
1635
+
1636
+ describe("error handling in createServiceTx", () => {
1637
+ it("should not cause unhandled rejection when retrieve callback throws synchronously in serviceCalls", async () => {
1638
+ // This test verifies that when a service's retrieve callback throws synchronously,
1639
+ // the error is properly propagated without causing an unhandled rejection warning.
1640
+ // Without the fix (adding retrievePhase.catch(() => {}) before rejecting and throwing),
1641
+ // this test would cause an "Unhandled Rejection" warning from Vitest.
1675
1642
 
1676
- it("should prevent anti-pattern: service async work completes before mutations execute", async () => {
1677
1643
  const compiler = createMockCompiler();
1678
1644
  const executor: UOWExecutor<unknown, unknown> = {
1679
- executeRetrievalPhase: async () => [[]],
1645
+ executeRetrievalPhase: async () => [],
1680
1646
  executeMutationPhase: async () => ({
1681
1647
  success: true,
1682
- createdInternalIds: [BigInt(1)],
1648
+ createdInternalIds: [],
1683
1649
  }),
1684
1650
  };
1685
1651
  const decoder = createMockDecoder();
1686
1652
 
1687
- const baseUow = createUnitOfWork(compiler, executor, decoder);
1688
- const restrictedUow = baseUow.restrict();
1689
-
1690
- let asyncWorkCompleted = false;
1691
- let mutationScheduled = false;
1692
-
1693
- // Simulate handler executing phases concurrently with service
1694
- const handlerSimulation = (async () => {
1695
- // Yield to let service start
1696
- await Promise.resolve();
1697
- // Execute retrieve phase
1698
- await baseUow.executeRetrieve();
1699
- // Wait for service mutate callback to schedule mutations (including async work)
1700
- await new Promise((resolve) => setTimeout(resolve, 30));
1701
- // Execute mutation phase
1702
- await baseUow.executeMutations();
1703
- })();
1704
-
1705
- // Start service tx
1706
- const servicePromise = executeServiceTx(
1707
- testSchema,
1708
- {
1709
- retrieve: (uow) => {
1710
- return uow.find("users", (b) => b.whereIndex("idx_email"));
1711
- },
1712
- mutate: async (uow) => {
1713
- // Simulate async work (like hashing backup codes)
1714
- await new Promise((resolve) => setTimeout(resolve, 20));
1715
- asyncWorkCompleted = true;
1716
-
1717
- // Schedule mutation
1718
- uow.create("users", { email: "test@example.com", name: "Test", balance: 0 });
1719
- mutationScheduled = true;
1720
- return { success: true };
1721
- },
1722
- },
1723
- restrictedUow,
1724
- );
1653
+ let currentUow: IUnitOfWork | null = null;
1725
1654
 
1726
- // Wait for both handler and service to complete
1727
- await Promise.all([handlerSimulation, servicePromise]);
1655
+ const syncError = new Error("Retrieve callback threw synchronously");
1656
+
1657
+ // Service that throws synchronously in retrieve callback
1658
+ const failingService = () => {
1659
+ return createServiceTxBuilder(testSchema, currentUow!)
1660
+ .retrieve(() => {
1661
+ throw syncError;
1662
+ })
1663
+ .build();
1664
+ };
1728
1665
 
1729
- expect(asyncWorkCompleted).toBe(true);
1730
- expect(mutationScheduled).toBe(true);
1666
+ // Execute with serviceCalls that contain the failing service
1667
+ // The error should be properly caught and re-thrown without unhandled rejection
1668
+ await expect(
1669
+ createHandlerTxBuilder({
1670
+ createUnitOfWork: () => {
1671
+ currentUow = createUnitOfWork(compiler, executor, decoder);
1672
+ return currentUow;
1673
+ },
1674
+ })
1675
+ .withServiceCalls(() => [failingService()] as const)
1676
+ .transform(({ serviceResult: [result] }) => result)
1677
+ .execute(),
1678
+ ).rejects.toThrow("Retrieve callback threw synchronously");
1731
1679
  });
1732
- });
1733
1680
 
1734
- describe("executeTxArray with executeServiceTx", () => {
1735
- it("should execute a single service promise created with executeServiceTx", async () => {
1681
+ it("should not cause unhandled rejection when serviceCalls callback throws synchronously", async () => {
1682
+ // This test verifies that when a service's serviceCalls callback throws synchronously,
1683
+ // the error is properly propagated without causing an unhandled rejection warning.
1684
+
1736
1685
  const compiler = createMockCompiler();
1737
1686
  const executor: UOWExecutor<unknown, unknown> = {
1738
- executeRetrievalPhase: async () => [
1739
- [
1740
- {
1741
- id: FragnoId.fromExternal("1", 1),
1742
- email: "user1@example.com",
1743
- name: "User 1",
1744
- balance: 100,
1745
- },
1746
- ],
1747
- ],
1687
+ executeRetrievalPhase: async () => [],
1748
1688
  executeMutationPhase: async () => ({
1749
1689
  success: true,
1750
1690
  createdInternalIds: [],
@@ -1754,63 +1694,52 @@ describe("executeRestrictedUnitOfWork", () => {
1754
1694
 
1755
1695
  let currentUow: IUnitOfWork | null = null;
1756
1696
 
1757
- // Execute the service promise using executeTxArray
1758
- const result = await executeTxArray(
1759
- () => [
1760
- executeServiceTx(
1761
- testSchema,
1762
- {
1763
- retrieve: (uow) => {
1764
- return uow.findFirst("users", (b) => b.whereIndex("idx_email"));
1765
- },
1766
- mutate: async (uow, [user]) => {
1767
- if (!user) {
1768
- return { ok: false };
1769
- }
1770
- // simulate async work
1771
- await new Promise((resolve) => setTimeout(resolve, 10));
1772
-
1773
- uow.update("users", user.id, (b) => b.set({ balance: user.balance + 50 }));
1774
- return { ok: true, newBalance: user.balance + 50 };
1775
- },
1776
- },
1777
- currentUow!,
1778
- ),
1779
- ],
1780
- {
1697
+ const syncError = new Error("Deps callback threw synchronously");
1698
+
1699
+ // Service that throws synchronously in serviceCalls callback
1700
+ const failingService = () => {
1701
+ return createServiceTxBuilder(testSchema, currentUow!)
1702
+ .withServiceCalls(() => {
1703
+ throw syncError;
1704
+ })
1705
+ .mutate(() => ({ done: true }))
1706
+ .build();
1707
+ };
1708
+
1709
+ // Execute with serviceCalls that contain the failing service
1710
+ await expect(
1711
+ createHandlerTxBuilder({
1781
1712
  createUnitOfWork: () => {
1782
1713
  currentUow = createUnitOfWork(compiler, executor, decoder);
1783
1714
  return currentUow;
1784
1715
  },
1785
- },
1786
- );
1787
-
1788
- expect(result).toHaveLength(1);
1789
- expect(result[0]).toEqual({ ok: true, newBalance: 150 });
1716
+ })
1717
+ .withServiceCalls(() => [failingService()] as const)
1718
+ .transform(({ serviceResult: [result] }) => result)
1719
+ .execute(),
1720
+ ).rejects.toThrow("Deps callback threw synchronously");
1790
1721
  });
1722
+ });
1791
1723
 
1792
- it("should retry and eventually succeed when mutations fail on first attempts", async () => {
1793
- const compiler = createMockCompiler();
1794
- let executionAttemptCount = 0;
1795
- let factoryCallCount = 0;
1724
+ describe("mutate-only service type inference", () => {
1725
+ it("should correctly type serviceIntermediateResult when dependent service only has mutate (no retrieve)", async () => {
1726
+ // This test verifies that when a service has ONLY a mutate callback (no retrieve),
1727
+ // the mutate result is correctly typed as the serviceIntermediateResult for dependent services.
1728
+ //
1729
+ // Execution order:
1730
+ // 1. generateOTP's retrieve phase runs (empty - no retrieve callback)
1731
+ // 2. generateOTP's mutate runs → returns { otpId, code }
1732
+ // 3. sendOTPEmail's mutate runs → serviceIntermediateResult[0] is { otpId, code, userId }
1733
+ //
1734
+ // Without the InferBuilderRetrieveSuccessResult fix, serviceIntermediateResult[0] would be
1735
+ // typed as `[]` (empty tuple) even though at runtime it's the mutate result.
1796
1736
 
1737
+ const compiler = createMockCompiler();
1797
1738
  const executor: UOWExecutor<unknown, unknown> = {
1798
- executeRetrievalPhase: async () => [
1799
- [
1800
- {
1801
- id: FragnoId.fromExternal("1", 1),
1802
- email: "user1@example.com",
1803
- name: "User 1",
1804
- balance: 100,
1805
- },
1806
- ],
1807
- ],
1739
+ executeRetrievalPhase: async () => {
1740
+ return [];
1741
+ },
1808
1742
  executeMutationPhase: async () => {
1809
- executionAttemptCount++;
1810
- // Fail on first 2 attempts, succeed on 3rd
1811
- if (executionAttemptCount < 3) {
1812
- return { success: false };
1813
- }
1814
1743
  return {
1815
1744
  success: true,
1816
1745
  createdInternalIds: [],
@@ -1821,44 +1750,169 @@ describe("executeRestrictedUnitOfWork", () => {
1821
1750
 
1822
1751
  let currentUow: IUnitOfWork | null = null;
1823
1752
 
1824
- const result = await executeTxArray(
1825
- () => {
1826
- factoryCallCount++;
1827
- return [
1828
- executeServiceTx(
1829
- testSchema,
1830
- {
1831
- retrieve: (uow) => {
1832
- return uow.findFirst("users", (b) => b.whereIndex("idx_email"));
1833
- },
1834
- mutate: async (uow, [user]) => {
1835
- if (!user) {
1836
- return { ok: false };
1837
- }
1838
- // simulate async work
1839
- await new Promise((resolve) => setTimeout(resolve, 10));
1840
-
1841
- uow.update("users", user.id, (b) => b.set({ balance: user.balance + 50 }));
1842
- return { ok: true, newBalance: user.balance + 50, attempt: factoryCallCount };
1843
- },
1844
- },
1845
- currentUow!,
1846
- ),
1847
- ];
1848
- },
1849
- {
1850
- createUnitOfWork: () => {
1851
- currentUow = createUnitOfWork(compiler, executor, decoder);
1852
- return currentUow;
1853
- },
1854
- retryPolicy: new ExponentialBackoffRetryPolicy({ maxRetries: 3, initialDelayMs: 1 }),
1855
- },
1856
- );
1753
+ // Capture the runtime value of serviceResult to verify it contains the mutate result
1754
+ let capturedOtpResult: unknown = null;
1755
+
1756
+ // Mutate-only service - simulates OTP generation
1757
+ // No .retrieve() - this service doesn't need to read anything first
1758
+ const generateOTP = (userId: string) => {
1759
+ return createServiceTxBuilder(testSchema, currentUow!)
1760
+ .mutate(({ uow }) => {
1761
+ const otpId = uow.create("users", {
1762
+ email: `otp-${userId}@example.com`,
1763
+ name: "OTP Record",
1764
+ balance: 0,
1765
+ });
1766
+ // Return data that dependents need - this becomes serviceResult for them
1767
+ return { otpId, code: "ABC123", userId };
1768
+ })
1769
+ .build();
1770
+ };
1857
1771
 
1858
- expect(result).toHaveLength(1);
1859
- expect(result[0]).toEqual({ ok: true, newBalance: 150, attempt: 3 });
1860
- expect(factoryCallCount).toBe(3); // Factory called 3 times (once per attempt)
1861
- expect(executionAttemptCount).toBe(3); // 3 execution attempts total
1772
+ // Service that depends on generateOTP
1773
+ // The key test: serviceIntermediateResult[0] should be typed as { otpId, code, userId }
1774
+ const sendOTPEmail = (userId: string, email: string) => {
1775
+ return createServiceTxBuilder(testSchema, currentUow!)
1776
+ .withServiceCalls(() => [generateOTP(userId)] as const)
1777
+ .mutate(({ uow, serviceIntermediateResult: [otpResult] }) => {
1778
+ // RUNTIME CAPTURE: Store the actual runtime value for verification
1779
+ capturedOtpResult = otpResult;
1780
+
1781
+ // TYPE TEST: Without the fix, this would require a manual cast.
1782
+ // With the fix, TypeScript knows otpResult is { otpId: FragnoId, code: string, userId: string }
1783
+ expectTypeOf(otpResult).toEqualTypeOf<{
1784
+ otpId: FragnoId;
1785
+ code: string;
1786
+ userId: string;
1787
+ }>();
1788
+
1789
+ // Access properties without type errors - proves the type inference works
1790
+ const message = `Your OTP code is: ${otpResult.code}`;
1791
+
1792
+ uow.create("users", {
1793
+ email,
1794
+ name: message,
1795
+ balance: 0,
1796
+ });
1797
+
1798
+ return { sent: true, forUser: otpResult.userId };
1799
+ })
1800
+ .build();
1801
+ };
1802
+
1803
+ const result = await createHandlerTxBuilder({
1804
+ createUnitOfWork: () => {
1805
+ currentUow = createUnitOfWork(compiler, executor, decoder);
1806
+ return currentUow;
1807
+ },
1808
+ })
1809
+ .withServiceCalls(() => [sendOTPEmail("user-1", "test@example.com")] as const)
1810
+ .transform(({ serviceResult: [emailResult] }) => emailResult)
1811
+ .execute();
1812
+
1813
+ expect(result.sent).toBe(true);
1814
+ expect(result.forUser).toBe("user-1");
1815
+
1816
+ // RUNTIME VERIFICATION: Verify the actual runtime value of serviceIntermediateResult
1817
+ // This proves generateOTP's mutate result is actually passed as serviceIntermediateResult
1818
+ expect(capturedOtpResult).not.toBeNull();
1819
+ expect(capturedOtpResult).toMatchObject({
1820
+ code: "ABC123",
1821
+ userId: "user-1",
1822
+ });
1823
+ expect((capturedOtpResult as { otpId: FragnoId }).otpId).toBeInstanceOf(FragnoId);
1824
+ });
1825
+
1826
+ it("should correctly type serviceIntermediateResult with multiple mutate-only service calls", async () => {
1827
+ // Test with multiple mutate-only dependencies to verify tuple typing works
1828
+
1829
+ const compiler = createMockCompiler();
1830
+ const executor: UOWExecutor<unknown, unknown> = {
1831
+ executeRetrievalPhase: async () => [],
1832
+ executeMutationPhase: async () => ({
1833
+ success: true,
1834
+ createdInternalIds: [],
1835
+ }),
1836
+ };
1837
+ const decoder = createMockDecoder();
1838
+
1839
+ let currentUow: IUnitOfWork | null = null;
1840
+
1841
+ // Capture runtime values for verification
1842
+ let capturedAuditResult: unknown = null;
1843
+ let capturedCounterResult: unknown = null;
1844
+
1845
+ // First mutate-only service
1846
+ const createAuditLog = (action: string) => {
1847
+ return createServiceTxBuilder(testSchema, currentUow!)
1848
+ .mutate(({ uow }) => {
1849
+ const logId = uow.create("users", {
1850
+ email: `audit-${action}@example.com`,
1851
+ name: action,
1852
+ balance: 0,
1853
+ });
1854
+ return { logId, action, timestamp: 1234567890 };
1855
+ })
1856
+ .build();
1857
+ };
1858
+
1859
+ // Second mutate-only service
1860
+ const incrementCounter = (name: string) => {
1861
+ return createServiceTxBuilder(testSchema, currentUow!)
1862
+ .mutate(() => {
1863
+ // Simulates incrementing a counter - returns the new value
1864
+ return { counterName: name, newValue: 42 };
1865
+ })
1866
+ .build();
1867
+ };
1868
+
1869
+ const result = await createHandlerTxBuilder({
1870
+ createUnitOfWork: () => {
1871
+ currentUow = createUnitOfWork(compiler, executor, decoder);
1872
+ return currentUow;
1873
+ },
1874
+ })
1875
+ .withServiceCalls(
1876
+ () => [createAuditLog("user_login"), incrementCounter("login_count")] as const,
1877
+ )
1878
+ .mutate(({ serviceIntermediateResult: [auditResult, counterResult] }) => {
1879
+ // RUNTIME CAPTURE: Store the actual runtime values
1880
+ capturedAuditResult = auditResult;
1881
+ capturedCounterResult = counterResult;
1882
+
1883
+ // TYPE TESTS: Both should be correctly typed from their mutate results
1884
+ expectTypeOf(auditResult).toEqualTypeOf<{
1885
+ logId: FragnoId;
1886
+ action: string;
1887
+ timestamp: number;
1888
+ }>();
1889
+ expectTypeOf(counterResult).toEqualTypeOf<{
1890
+ counterName: string;
1891
+ newValue: number;
1892
+ }>();
1893
+
1894
+ // Access properties - proves type inference works
1895
+ return {
1896
+ auditAction: auditResult.action,
1897
+ loginCount: counterResult.newValue,
1898
+ };
1899
+ })
1900
+ .execute();
1901
+
1902
+ expect(result.auditAction).toBe("user_login");
1903
+ expect(result.loginCount).toBe(42);
1904
+
1905
+ // RUNTIME VERIFICATION: Verify the actual runtime values
1906
+ expect(capturedAuditResult).toMatchObject({
1907
+ action: "user_login",
1908
+ timestamp: 1234567890,
1909
+ });
1910
+ expect((capturedAuditResult as { logId: FragnoId }).logId).toBeInstanceOf(FragnoId);
1911
+
1912
+ expect(capturedCounterResult).toEqual({
1913
+ counterName: "login_count",
1914
+ newValue: 42,
1915
+ });
1862
1916
  });
1863
1917
  });
1864
1918
  });