@abloatai/transaction 0.37.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 (723) hide show
  1. package/CONVENTIONS.md +83 -0
  2. package/README.md +48 -0
  3. package/dist/ablo.d.ts +90 -0
  4. package/dist/ablo.d.ts.map +1 -0
  5. package/dist/ablo.js +34 -0
  6. package/dist/ablo.js.map +1 -0
  7. package/dist/ai-sdk/coordinatedTool.d.ts +123 -0
  8. package/dist/ai-sdk/coordinatedTool.d.ts.map +1 -0
  9. package/dist/ai-sdk/coordinatedTool.js +135 -0
  10. package/dist/ai-sdk/coordinatedTool.js.map +1 -0
  11. package/dist/ai-sdk/index.d.ts +2 -0
  12. package/dist/ai-sdk/index.d.ts.map +1 -0
  13. package/dist/ai-sdk/index.js +2 -0
  14. package/dist/ai-sdk/index.js.map +1 -0
  15. package/dist/auth/apiKey.d.ts +154 -0
  16. package/dist/auth/apiKey.d.ts.map +1 -0
  17. package/dist/auth/apiKey.js +408 -0
  18. package/dist/auth/apiKey.js.map +1 -0
  19. package/dist/auth/bootstrapScope.d.ts +16 -0
  20. package/dist/auth/bootstrapScope.d.ts.map +1 -0
  21. package/dist/auth/bootstrapScope.js +2 -0
  22. package/dist/auth/bootstrapScope.js.map +1 -0
  23. package/dist/auth/browserCredentialSafety.d.ts +7 -0
  24. package/dist/auth/browserCredentialSafety.d.ts.map +1 -0
  25. package/dist/auth/browserCredentialSafety.js +29 -0
  26. package/dist/auth/browserCredentialSafety.js.map +1 -0
  27. package/dist/auth/capability.d.ts +241 -0
  28. package/dist/auth/capability.d.ts.map +1 -0
  29. package/dist/auth/capability.js +253 -0
  30. package/dist/auth/capability.js.map +1 -0
  31. package/dist/auth/capabilityLifecycle.d.ts +47 -0
  32. package/dist/auth/capabilityLifecycle.d.ts.map +1 -0
  33. package/dist/auth/capabilityLifecycle.js +108 -0
  34. package/dist/auth/capabilityLifecycle.js.map +1 -0
  35. package/dist/auth/credentialEndpoint.d.ts +10 -0
  36. package/dist/auth/credentialEndpoint.d.ts.map +1 -0
  37. package/dist/auth/credentialEndpoint.js +58 -0
  38. package/dist/auth/credentialEndpoint.js.map +1 -0
  39. package/dist/auth/credentialEndpointProtocol.d.ts +24 -0
  40. package/dist/auth/credentialEndpointProtocol.d.ts.map +1 -0
  41. package/dist/auth/credentialEndpointProtocol.js +34 -0
  42. package/dist/auth/credentialEndpointProtocol.js.map +1 -0
  43. package/dist/auth/credentialKind.d.ts +4 -0
  44. package/dist/auth/credentialKind.d.ts.map +1 -0
  45. package/dist/auth/credentialKind.js +15 -0
  46. package/dist/auth/credentialKind.js.map +1 -0
  47. package/dist/auth/credentialPolicy.d.ts +141 -0
  48. package/dist/auth/credentialPolicy.d.ts.map +1 -0
  49. package/dist/auth/credentialPolicy.js +113 -0
  50. package/dist/auth/credentialPolicy.js.map +1 -0
  51. package/dist/auth/credentialResult.d.ts +7 -0
  52. package/dist/auth/credentialResult.d.ts.map +1 -0
  53. package/dist/auth/credentialResult.js +9 -0
  54. package/dist/auth/credentialResult.js.map +1 -0
  55. package/dist/auth/credentialSource.d.ts +31 -0
  56. package/dist/auth/credentialSource.d.ts.map +1 -0
  57. package/dist/auth/credentialSource.js +56 -0
  58. package/dist/auth/credentialSource.js.map +1 -0
  59. package/dist/auth/hostedEndpoints.d.ts +22 -0
  60. package/dist/auth/hostedEndpoints.d.ts.map +1 -0
  61. package/dist/auth/hostedEndpoints.js +22 -0
  62. package/dist/auth/hostedEndpoints.js.map +1 -0
  63. package/dist/auth/identity.d.ts +62 -0
  64. package/dist/auth/identity.d.ts.map +1 -0
  65. package/dist/auth/identity.js +224 -0
  66. package/dist/auth/identity.js.map +1 -0
  67. package/dist/auth/index.d.ts +189 -0
  68. package/dist/auth/index.d.ts.map +1 -0
  69. package/dist/auth/index.js +312 -0
  70. package/dist/auth/index.js.map +1 -0
  71. package/dist/auth/schemas.d.ts +41 -0
  72. package/dist/auth/schemas.d.ts.map +1 -0
  73. package/dist/auth/schemas.js +69 -0
  74. package/dist/auth/schemas.js.map +1 -0
  75. package/dist/auth/sessionMint.d.ts +29 -0
  76. package/dist/auth/sessionMint.d.ts.map +1 -0
  77. package/dist/auth/sessionMint.js +92 -0
  78. package/dist/auth/sessionMint.js.map +1 -0
  79. package/dist/auth/token.d.ts +4 -0
  80. package/dist/auth/token.d.ts.map +1 -0
  81. package/dist/auth/token.js +4 -0
  82. package/dist/auth/token.js.map +1 -0
  83. package/dist/batching/index.d.ts +56 -0
  84. package/dist/batching/index.d.ts.map +1 -0
  85. package/dist/batching/index.js +148 -0
  86. package/dist/batching/index.js.map +1 -0
  87. package/dist/coordination/awaitClaimGrant.d.ts +57 -0
  88. package/dist/coordination/awaitClaimGrant.d.ts.map +1 -0
  89. package/dist/coordination/awaitClaimGrant.js +138 -0
  90. package/dist/coordination/awaitClaimGrant.js.map +1 -0
  91. package/dist/coordination/claimHeartbeatLoop.d.ts +85 -0
  92. package/dist/coordination/claimHeartbeatLoop.d.ts.map +1 -0
  93. package/dist/coordination/claimHeartbeatLoop.js +109 -0
  94. package/dist/coordination/claimHeartbeatLoop.js.map +1 -0
  95. package/dist/coordination/claimMeta.d.ts +50 -0
  96. package/dist/coordination/claimMeta.d.ts.map +1 -0
  97. package/dist/coordination/claimMeta.js +53 -0
  98. package/dist/coordination/claimMeta.js.map +1 -0
  99. package/dist/coordination/events.d.ts +75 -0
  100. package/dist/coordination/events.d.ts.map +1 -0
  101. package/dist/coordination/events.js +8 -0
  102. package/dist/coordination/events.js.map +1 -0
  103. package/dist/coordination/index.d.ts +20 -0
  104. package/dist/coordination/index.d.ts.map +1 -0
  105. package/dist/coordination/index.js +46 -0
  106. package/dist/coordination/index.js.map +1 -0
  107. package/dist/coordination/locator.d.ts +106 -0
  108. package/dist/coordination/locator.d.ts.map +1 -0
  109. package/dist/coordination/locator.js +110 -0
  110. package/dist/coordination/locator.js.map +1 -0
  111. package/dist/coordination/schema.d.ts +1332 -0
  112. package/dist/coordination/schema.d.ts.map +1 -0
  113. package/dist/coordination/schema.js +1137 -0
  114. package/dist/coordination/schema.js.map +1 -0
  115. package/dist/coordination/targetConflict.d.ts +3 -0
  116. package/dist/coordination/targetConflict.d.ts.map +1 -0
  117. package/dist/coordination/targetConflict.js +74 -0
  118. package/dist/coordination/targetConflict.js.map +1 -0
  119. package/dist/coordination/trace.d.ts +79 -0
  120. package/dist/coordination/trace.d.ts.map +1 -0
  121. package/dist/coordination/trace.js +139 -0
  122. package/dist/coordination/trace.js.map +1 -0
  123. package/dist/docs/catalog.d.ts +73 -0
  124. package/dist/docs/catalog.d.ts.map +1 -0
  125. package/dist/docs/catalog.js +231 -0
  126. package/dist/docs/catalog.js.map +1 -0
  127. package/dist/docs/index.d.ts +11 -0
  128. package/dist/docs/index.d.ts.map +1 -0
  129. package/dist/docs/index.js +11 -0
  130. package/dist/docs/index.js.map +1 -0
  131. package/dist/durableWrites.d.ts +63 -0
  132. package/dist/durableWrites.d.ts.map +1 -0
  133. package/dist/durableWrites.js +72 -0
  134. package/dist/durableWrites.js.map +1 -0
  135. package/dist/environment.d.ts +106 -0
  136. package/dist/environment.d.ts.map +1 -0
  137. package/dist/environment.js +109 -0
  138. package/dist/environment.js.map +1 -0
  139. package/dist/errorCodes.d.ts +411 -0
  140. package/dist/errorCodes.d.ts.map +1 -0
  141. package/dist/errorCodes.js +500 -0
  142. package/dist/errorCodes.js.map +1 -0
  143. package/dist/errors.d.ts +429 -0
  144. package/dist/errors.d.ts.map +1 -0
  145. package/dist/errors.js +687 -0
  146. package/dist/errors.js.map +1 -0
  147. package/dist/footprint.d.ts +112 -0
  148. package/dist/footprint.d.ts.map +1 -0
  149. package/dist/footprint.js +0 -0
  150. package/dist/footprint.js.map +1 -0
  151. package/dist/headlessClient.d.ts +10 -0
  152. package/dist/headlessClient.d.ts.map +1 -0
  153. package/dist/headlessClient.js +114 -0
  154. package/dist/headlessClient.js.map +1 -0
  155. package/dist/index.d.ts +21 -0
  156. package/dist/index.d.ts.map +1 -0
  157. package/dist/index.js +21 -0
  158. package/dist/index.js.map +1 -0
  159. package/dist/keys/index.d.ts +88 -0
  160. package/dist/keys/index.d.ts.map +1 -0
  161. package/dist/keys/index.js +208 -0
  162. package/dist/keys/index.js.map +1 -0
  163. package/dist/log/syncDeltaRow.d.ts +159 -0
  164. package/dist/log/syncDeltaRow.d.ts.map +1 -0
  165. package/dist/log/syncDeltaRow.js +96 -0
  166. package/dist/log/syncDeltaRow.js.map +1 -0
  167. package/dist/logger.d.ts +17 -0
  168. package/dist/logger.d.ts.map +1 -0
  169. package/dist/logger.js +8 -0
  170. package/dist/logger.js.map +1 -0
  171. package/dist/observability.d.ts +54 -0
  172. package/dist/observability.d.ts.map +1 -0
  173. package/dist/observability.js +20 -0
  174. package/dist/observability.js.map +1 -0
  175. package/dist/policy/types.d.ts +218 -0
  176. package/dist/policy/types.d.ts.map +1 -0
  177. package/dist/policy/types.js +127 -0
  178. package/dist/policy/types.js.map +1 -0
  179. package/dist/resources/functionalUpdate.d.ts +80 -0
  180. package/dist/resources/functionalUpdate.d.ts.map +1 -0
  181. package/dist/resources/functionalUpdate.js +88 -0
  182. package/dist/resources/functionalUpdate.js.map +1 -0
  183. package/dist/resources/httpResources.d.ts +449 -0
  184. package/dist/resources/httpResources.d.ts.map +1 -0
  185. package/dist/resources/httpResources.js +8 -0
  186. package/dist/resources/httpResources.js.map +1 -0
  187. package/dist/resources/modelOperations.d.ts +380 -0
  188. package/dist/resources/modelOperations.d.ts.map +1 -0
  189. package/dist/resources/modelOperations.js +13 -0
  190. package/dist/resources/modelOperations.js.map +1 -0
  191. package/dist/resources/mutationOptions.d.ts +67 -0
  192. package/dist/resources/mutationOptions.d.ts.map +1 -0
  193. package/dist/resources/mutationOptions.js +10 -0
  194. package/dist/resources/mutationOptions.js.map +1 -0
  195. package/dist/resources/where.d.ts +102 -0
  196. package/dist/resources/where.d.ts.map +1 -0
  197. package/dist/resources/where.js +116 -0
  198. package/dist/resources/where.js.map +1 -0
  199. package/dist/resources/writeOptionsSchema.d.ts +48 -0
  200. package/dist/resources/writeOptionsSchema.d.ts.map +1 -0
  201. package/dist/resources/writeOptionsSchema.js +74 -0
  202. package/dist/resources/writeOptionsSchema.js.map +1 -0
  203. package/dist/schema/coordination.d.ts +113 -0
  204. package/dist/schema/coordination.d.ts.map +1 -0
  205. package/dist/schema/coordination.js +134 -0
  206. package/dist/schema/coordination.js.map +1 -0
  207. package/dist/schema/ddl.d.ts +98 -0
  208. package/dist/schema/ddl.d.ts.map +1 -0
  209. package/dist/schema/ddl.js +492 -0
  210. package/dist/schema/ddl.js.map +1 -0
  211. package/dist/schema/ddlLock.d.ts +36 -0
  212. package/dist/schema/ddlLock.d.ts.map +1 -0
  213. package/dist/schema/ddlLock.js +47 -0
  214. package/dist/schema/ddlLock.js.map +1 -0
  215. package/dist/schema/diff.d.ts +226 -0
  216. package/dist/schema/diff.d.ts.map +1 -0
  217. package/dist/schema/diff.js +290 -0
  218. package/dist/schema/diff.js.map +1 -0
  219. package/dist/schema/field.d.ts +121 -0
  220. package/dist/schema/field.d.ts.map +1 -0
  221. package/dist/schema/field.js +266 -0
  222. package/dist/schema/field.js.map +1 -0
  223. package/dist/schema/fieldRef.d.ts +58 -0
  224. package/dist/schema/fieldRef.d.ts.map +1 -0
  225. package/dist/schema/fieldRef.js +26 -0
  226. package/dist/schema/fieldRef.js.map +1 -0
  227. package/dist/schema/generate.d.ts +20 -0
  228. package/dist/schema/generate.d.ts.map +1 -0
  229. package/dist/schema/generate.js +87 -0
  230. package/dist/schema/generate.js.map +1 -0
  231. package/dist/schema/index.d.ts +43 -0
  232. package/dist/schema/index.d.ts.map +1 -0
  233. package/dist/schema/index.js +81 -0
  234. package/dist/schema/index.js.map +1 -0
  235. package/dist/schema/loadStrategy.d.ts +46 -0
  236. package/dist/schema/loadStrategy.d.ts.map +1 -0
  237. package/dist/schema/loadStrategy.js +47 -0
  238. package/dist/schema/loadStrategy.js.map +1 -0
  239. package/dist/schema/model.d.ts +380 -0
  240. package/dist/schema/model.d.ts.map +1 -0
  241. package/dist/schema/model.js +124 -0
  242. package/dist/schema/model.js.map +1 -0
  243. package/dist/schema/openapi.d.ts +59 -0
  244. package/dist/schema/openapi.d.ts.map +1 -0
  245. package/dist/schema/openapi.js +508 -0
  246. package/dist/schema/openapi.js.map +1 -0
  247. package/dist/schema/queries.d.ts +202 -0
  248. package/dist/schema/queries.d.ts.map +1 -0
  249. package/dist/schema/queries.js +144 -0
  250. package/dist/schema/queries.js.map +1 -0
  251. package/dist/schema/relation.d.ts +205 -0
  252. package/dist/schema/relation.d.ts.map +1 -0
  253. package/dist/schema/relation.js +105 -0
  254. package/dist/schema/relation.js.map +1 -0
  255. package/dist/schema/residency.d.ts +30 -0
  256. package/dist/schema/residency.d.ts.map +1 -0
  257. package/dist/schema/residency.js +26 -0
  258. package/dist/schema/residency.js.map +1 -0
  259. package/dist/schema/roles.d.ts +250 -0
  260. package/dist/schema/roles.d.ts.map +1 -0
  261. package/dist/schema/roles.js +231 -0
  262. package/dist/schema/roles.js.map +1 -0
  263. package/dist/schema/schema.d.ts +352 -0
  264. package/dist/schema/schema.d.ts.map +1 -0
  265. package/dist/schema/schema.js +326 -0
  266. package/dist/schema/schema.js.map +1 -0
  267. package/dist/schema/select.d.ts +41 -0
  268. package/dist/schema/select.d.ts.map +1 -0
  269. package/dist/schema/select.js +91 -0
  270. package/dist/schema/select.js.map +1 -0
  271. package/dist/schema/serialize.d.ts +116 -0
  272. package/dist/schema/serialize.d.ts.map +1 -0
  273. package/dist/schema/serialize.js +278 -0
  274. package/dist/schema/serialize.js.map +1 -0
  275. package/dist/schema/sugar.d.ts +110 -0
  276. package/dist/schema/sugar.d.ts.map +1 -0
  277. package/dist/schema/sugar.js +84 -0
  278. package/dist/schema/sugar.js.map +1 -0
  279. package/dist/schema/tenancy.d.ts +140 -0
  280. package/dist/schema/tenancy.d.ts.map +1 -0
  281. package/dist/schema/tenancy.js +191 -0
  282. package/dist/schema/tenancy.js.map +1 -0
  283. package/dist/server/adapter.d.ts +174 -0
  284. package/dist/server/adapter.d.ts.map +1 -0
  285. package/dist/server/adapter.js +19 -0
  286. package/dist/server/adapter.js.map +1 -0
  287. package/dist/server/commit.d.ts +108 -0
  288. package/dist/server/commit.d.ts.map +1 -0
  289. package/dist/server/commit.js +2 -0
  290. package/dist/server/commit.js.map +1 -0
  291. package/dist/server/index.d.ts +15 -0
  292. package/dist/server/index.d.ts.map +1 -0
  293. package/dist/server/index.js +3 -0
  294. package/dist/server/index.js.map +1 -0
  295. package/dist/server/readConfig.d.ts +81 -0
  296. package/dist/server/readConfig.d.ts.map +1 -0
  297. package/dist/server/readConfig.js +9 -0
  298. package/dist/server/readConfig.js.map +1 -0
  299. package/dist/server/storageMode.d.ts +24 -0
  300. package/dist/server/storageMode.d.ts.map +1 -0
  301. package/dist/server/storageMode.js +18 -0
  302. package/dist/server/storageMode.js.map +1 -0
  303. package/dist/source/adapter.d.ts +84 -0
  304. package/dist/source/adapter.d.ts.map +1 -0
  305. package/dist/source/adapter.js +25 -0
  306. package/dist/source/adapter.js.map +1 -0
  307. package/dist/source/adapters/drizzle.d.ts +49 -0
  308. package/dist/source/adapters/drizzle.d.ts.map +1 -0
  309. package/dist/source/adapters/drizzle.js +220 -0
  310. package/dist/source/adapters/drizzle.js.map +1 -0
  311. package/dist/source/adapters/kysely.d.ts +43 -0
  312. package/dist/source/adapters/kysely.d.ts.map +1 -0
  313. package/dist/source/adapters/kysely.js +206 -0
  314. package/dist/source/adapters/kysely.js.map +1 -0
  315. package/dist/source/adapters/kyselyMutationCore.d.ts +77 -0
  316. package/dist/source/adapters/kyselyMutationCore.d.ts.map +1 -0
  317. package/dist/source/adapters/kyselyMutationCore.js +126 -0
  318. package/dist/source/adapters/kyselyMutationCore.js.map +1 -0
  319. package/dist/source/adapters/memory.d.ts +14 -0
  320. package/dist/source/adapters/memory.d.ts.map +1 -0
  321. package/dist/source/adapters/memory.js +131 -0
  322. package/dist/source/adapters/memory.js.map +1 -0
  323. package/dist/source/adapters/prisma.d.ts +64 -0
  324. package/dist/source/adapters/prisma.d.ts.map +1 -0
  325. package/dist/source/adapters/prisma.js +203 -0
  326. package/dist/source/adapters/prisma.js.map +1 -0
  327. package/dist/source/conformance.d.ts +38 -0
  328. package/dist/source/conformance.d.ts.map +1 -0
  329. package/dist/source/conformance.js +216 -0
  330. package/dist/source/conformance.js.map +1 -0
  331. package/dist/source/connector.d.ts +96 -0
  332. package/dist/source/connector.d.ts.map +1 -0
  333. package/dist/source/connector.js +267 -0
  334. package/dist/source/connector.js.map +1 -0
  335. package/dist/source/connectorProtocol.d.ts +155 -0
  336. package/dist/source/connectorProtocol.d.ts.map +1 -0
  337. package/dist/source/connectorProtocol.js +164 -0
  338. package/dist/source/connectorProtocol.js.map +1 -0
  339. package/dist/source/contract.d.ts +196 -0
  340. package/dist/source/contract.d.ts.map +1 -0
  341. package/dist/source/contract.js +165 -0
  342. package/dist/source/contract.js.map +1 -0
  343. package/dist/source/drizzle.d.ts +2 -0
  344. package/dist/source/drizzle.d.ts.map +1 -0
  345. package/dist/source/drizzle.js +2 -0
  346. package/dist/source/drizzle.js.map +1 -0
  347. package/dist/source/factory.d.ts +93 -0
  348. package/dist/source/factory.d.ts.map +1 -0
  349. package/dist/source/factory.js +287 -0
  350. package/dist/source/factory.js.map +1 -0
  351. package/dist/source/idempotency.d.ts +62 -0
  352. package/dist/source/idempotency.d.ts.map +1 -0
  353. package/dist/source/idempotency.js +145 -0
  354. package/dist/source/idempotency.js.map +1 -0
  355. package/dist/source/index.d.ts +24 -0
  356. package/dist/source/index.d.ts.map +1 -0
  357. package/dist/source/index.js +29 -0
  358. package/dist/source/index.js.map +1 -0
  359. package/dist/source/kysely.d.ts +3 -0
  360. package/dist/source/kysely.d.ts.map +1 -0
  361. package/dist/source/kysely.js +3 -0
  362. package/dist/source/kysely.js.map +1 -0
  363. package/dist/source/migrations.d.ts +22 -0
  364. package/dist/source/migrations.d.ts.map +1 -0
  365. package/dist/source/migrations.js +104 -0
  366. package/dist/source/migrations.js.map +1 -0
  367. package/dist/source/next.d.ts +33 -0
  368. package/dist/source/next.d.ts.map +1 -0
  369. package/dist/source/next.js +26 -0
  370. package/dist/source/next.js.map +1 -0
  371. package/dist/source/pushQueue.d.ts +135 -0
  372. package/dist/source/pushQueue.d.ts.map +1 -0
  373. package/dist/source/pushQueue.js +257 -0
  374. package/dist/source/pushQueue.js.map +1 -0
  375. package/dist/source/signing.d.ts +93 -0
  376. package/dist/source/signing.d.ts.map +1 -0
  377. package/dist/source/signing.js +163 -0
  378. package/dist/source/signing.js.map +1 -0
  379. package/dist/source/types.d.ts +402 -0
  380. package/dist/source/types.d.ts.map +1 -0
  381. package/dist/source/types.js +60 -0
  382. package/dist/source/types.js.map +1 -0
  383. package/dist/syncLog/contract.d.ts +21 -0
  384. package/dist/syncLog/contract.d.ts.map +1 -0
  385. package/dist/syncLog/contract.js +20 -0
  386. package/dist/syncLog/contract.js.map +1 -0
  387. package/dist/syncLog/index.d.ts +2 -0
  388. package/dist/syncLog/index.d.ts.map +1 -0
  389. package/dist/syncLog/index.js +2 -0
  390. package/dist/syncLog/index.js.map +1 -0
  391. package/dist/testing/fixtures/httpResponses.d.ts +74 -0
  392. package/dist/testing/fixtures/httpResponses.d.ts.map +1 -0
  393. package/dist/testing/fixtures/httpResponses.js +102 -0
  394. package/dist/testing/fixtures/httpResponses.js.map +1 -0
  395. package/dist/transactionLayer.d.ts +110 -0
  396. package/dist/transactionLayer.d.ts.map +1 -0
  397. package/dist/transactionLayer.js +25 -0
  398. package/dist/transactionLayer.js.map +1 -0
  399. package/dist/transactions/settlement/commitEnvelope.d.ts +144 -0
  400. package/dist/transactions/settlement/commitEnvelope.d.ts.map +1 -0
  401. package/dist/transactions/settlement/commitEnvelope.js +162 -0
  402. package/dist/transactions/settlement/commitEnvelope.js.map +1 -0
  403. package/dist/transactions/settlement/httpCommitEnvelope.d.ts +54 -0
  404. package/dist/transactions/settlement/httpCommitEnvelope.d.ts.map +1 -0
  405. package/dist/transactions/settlement/httpCommitEnvelope.js +208 -0
  406. package/dist/transactions/settlement/httpCommitEnvelope.js.map +1 -0
  407. package/dist/transactions/settlement/idempotencyKey.d.ts +11 -0
  408. package/dist/transactions/settlement/idempotencyKey.d.ts.map +1 -0
  409. package/dist/transactions/settlement/idempotencyKey.js +10 -0
  410. package/dist/transactions/settlement/idempotencyKey.js.map +1 -0
  411. package/dist/transactions/settlement/pendingWrite.d.ts +113 -0
  412. package/dist/transactions/settlement/pendingWrite.d.ts.map +1 -0
  413. package/dist/transactions/settlement/pendingWrite.js +21 -0
  414. package/dist/transactions/settlement/pendingWrite.js.map +1 -0
  415. package/dist/transport/commitFrames.d.ts +91 -0
  416. package/dist/transport/commitFrames.d.ts.map +1 -0
  417. package/dist/transport/commitFrames.js +135 -0
  418. package/dist/transport/commitFrames.js.map +1 -0
  419. package/dist/transport/connectionManager.d.ts +216 -0
  420. package/dist/transport/connectionManager.d.ts.map +1 -0
  421. package/dist/transport/connectionManager.js +674 -0
  422. package/dist/transport/connectionManager.js.map +1 -0
  423. package/dist/transport/credentialLifecycle.d.ts +178 -0
  424. package/dist/transport/credentialLifecycle.d.ts.map +1 -0
  425. package/dist/transport/credentialLifecycle.js +324 -0
  426. package/dist/transport/credentialLifecycle.js.map +1 -0
  427. package/dist/transport/heartbeat.d.ts +66 -0
  428. package/dist/transport/heartbeat.d.ts.map +1 -0
  429. package/dist/transport/heartbeat.js +94 -0
  430. package/dist/transport/heartbeat.js.map +1 -0
  431. package/dist/transport/httpClient.d.ts +143 -0
  432. package/dist/transport/httpClient.d.ts.map +1 -0
  433. package/dist/transport/httpClient.js +150 -0
  434. package/dist/transport/httpClient.js.map +1 -0
  435. package/dist/transport/httpFeed.d.ts +4 -0
  436. package/dist/transport/httpFeed.d.ts.map +1 -0
  437. package/dist/transport/httpFeed.js +94 -0
  438. package/dist/transport/httpFeed.js.map +1 -0
  439. package/dist/transport/httpOptions.d.ts +34 -0
  440. package/dist/transport/httpOptions.d.ts.map +1 -0
  441. package/dist/transport/httpOptions.js +13 -0
  442. package/dist/transport/httpOptions.js.map +1 -0
  443. package/dist/transport/httpTransport.d.ts +68 -0
  444. package/dist/transport/httpTransport.d.ts.map +1 -0
  445. package/dist/transport/httpTransport.js +1433 -0
  446. package/dist/transport/httpTransport.js.map +1 -0
  447. package/dist/transport/networkProbe.d.ts +85 -0
  448. package/dist/transport/networkProbe.d.ts.map +1 -0
  449. package/dist/transport/networkProbe.js +208 -0
  450. package/dist/transport/networkProbe.js.map +1 -0
  451. package/dist/transport/wsFrameHandlers.d.ts +129 -0
  452. package/dist/transport/wsFrameHandlers.d.ts.map +1 -0
  453. package/dist/transport/wsFrameHandlers.js +429 -0
  454. package/dist/transport/wsFrameHandlers.js.map +1 -0
  455. package/dist/transport/wsTransport.d.ts +575 -0
  456. package/dist/transport/wsTransport.d.ts.map +1 -0
  457. package/dist/transport/wsTransport.js +1024 -0
  458. package/dist/transport/wsTransport.js.map +1 -0
  459. package/dist/types/assertExact.d.ts +18 -0
  460. package/dist/types/assertExact.d.ts.map +1 -0
  461. package/dist/types/assertExact.js +2 -0
  462. package/dist/types/assertExact.js.map +1 -0
  463. package/dist/types/global.d.ts +108 -0
  464. package/dist/types/global.d.ts.map +1 -0
  465. package/dist/types/global.js +41 -0
  466. package/dist/types/global.js.map +1 -0
  467. package/dist/types/index.d.ts +206 -0
  468. package/dist/types/index.d.ts.map +1 -0
  469. package/dist/types/index.js +57 -0
  470. package/dist/types/index.js.map +1 -0
  471. package/dist/types/modelData.d.ts +11 -0
  472. package/dist/types/modelData.d.ts.map +1 -0
  473. package/dist/types/modelData.js +10 -0
  474. package/dist/types/modelData.js.map +1 -0
  475. package/dist/types/participant.d.ts +21 -0
  476. package/dist/types/participant.d.ts.map +1 -0
  477. package/dist/types/participant.js +11 -0
  478. package/dist/types/participant.js.map +1 -0
  479. package/dist/types/streams.d.ts +545 -0
  480. package/dist/types/streams.d.ts.map +1 -0
  481. package/dist/types/streams.js +12 -0
  482. package/dist/types/streams.js.map +1 -0
  483. package/dist/utils/asyncIterator.d.ts +35 -0
  484. package/dist/utils/asyncIterator.d.ts.map +1 -0
  485. package/dist/utils/asyncIterator.js +136 -0
  486. package/dist/utils/asyncIterator.js.map +1 -0
  487. package/dist/utils/duration.d.ts +51 -0
  488. package/dist/utils/duration.d.ts.map +1 -0
  489. package/dist/utils/duration.js +78 -0
  490. package/dist/utils/duration.js.map +1 -0
  491. package/dist/utils/json.d.ts +58 -0
  492. package/dist/utils/json.d.ts.map +1 -0
  493. package/dist/utils/json.js +277 -0
  494. package/dist/utils/json.js.map +1 -0
  495. package/dist/webhooks/events.d.ts +44 -0
  496. package/dist/webhooks/events.d.ts.map +1 -0
  497. package/dist/webhooks/events.js +43 -0
  498. package/dist/webhooks/events.js.map +1 -0
  499. package/dist/webhooks/index.d.ts +9 -0
  500. package/dist/webhooks/index.d.ts.map +1 -0
  501. package/dist/webhooks/index.js +9 -0
  502. package/dist/webhooks/index.js.map +1 -0
  503. package/dist/wire/accountResponses.d.ts +463 -0
  504. package/dist/wire/accountResponses.d.ts.map +1 -0
  505. package/dist/wire/accountResponses.js +294 -0
  506. package/dist/wire/accountResponses.js.map +1 -0
  507. package/dist/wire/auth.d.ts +57 -0
  508. package/dist/wire/auth.d.ts.map +1 -0
  509. package/dist/wire/auth.js +71 -0
  510. package/dist/wire/auth.js.map +1 -0
  511. package/dist/wire/bootstrapReason.d.ts +10 -0
  512. package/dist/wire/bootstrapReason.d.ts.map +1 -0
  513. package/dist/wire/bootstrapReason.js +9 -0
  514. package/dist/wire/bootstrapReason.js.map +1 -0
  515. package/dist/wire/claimEvent.d.ts +70 -0
  516. package/dist/wire/claimEvent.d.ts.map +1 -0
  517. package/dist/wire/claimEvent.js +74 -0
  518. package/dist/wire/claimEvent.js.map +1 -0
  519. package/dist/wire/claims.d.ts +475 -0
  520. package/dist/wire/claims.d.ts.map +1 -0
  521. package/dist/wire/claims.js +326 -0
  522. package/dist/wire/claims.js.map +1 -0
  523. package/dist/wire/commit.d.ts +604 -0
  524. package/dist/wire/commit.d.ts.map +1 -0
  525. package/dist/wire/commit.js +322 -0
  526. package/dist/wire/commit.js.map +1 -0
  527. package/dist/wire/delta.d.ts +251 -0
  528. package/dist/wire/delta.d.ts.map +1 -0
  529. package/dist/wire/delta.js +148 -0
  530. package/dist/wire/delta.js.map +1 -0
  531. package/dist/wire/errorEnvelope.d.ts +73 -0
  532. package/dist/wire/errorEnvelope.d.ts.map +1 -0
  533. package/dist/wire/errorEnvelope.js +124 -0
  534. package/dist/wire/errorEnvelope.js.map +1 -0
  535. package/dist/wire/feedCursor.d.ts +61 -0
  536. package/dist/wire/feedCursor.d.ts.map +1 -0
  537. package/dist/wire/feedCursor.js +83 -0
  538. package/dist/wire/feedCursor.js.map +1 -0
  539. package/dist/wire/feedEvent.d.ts +264 -0
  540. package/dist/wire/feedEvent.d.ts.map +1 -0
  541. package/dist/wire/feedEvent.js +66 -0
  542. package/dist/wire/feedEvent.js.map +1 -0
  543. package/dist/wire/frames.d.ts +195 -0
  544. package/dist/wire/frames.d.ts.map +1 -0
  545. package/dist/wire/frames.js +51 -0
  546. package/dist/wire/frames.js.map +1 -0
  547. package/dist/wire/inboundFrames.d.ts +490 -0
  548. package/dist/wire/inboundFrames.d.ts.map +1 -0
  549. package/dist/wire/inboundFrames.js +117 -0
  550. package/dist/wire/inboundFrames.js.map +1 -0
  551. package/dist/wire/index.d.ts +55 -0
  552. package/dist/wire/index.d.ts.map +1 -0
  553. package/dist/wire/index.js +84 -0
  554. package/dist/wire/index.js.map +1 -0
  555. package/dist/wire/listEnvelope.d.ts +38 -0
  556. package/dist/wire/listEnvelope.d.ts.map +1 -0
  557. package/dist/wire/listEnvelope.js +43 -0
  558. package/dist/wire/listEnvelope.js.map +1 -0
  559. package/dist/wire/modelMutations.d.ts +32 -0
  560. package/dist/wire/modelMutations.d.ts.map +1 -0
  561. package/dist/wire/modelMutations.js +53 -0
  562. package/dist/wire/modelMutations.js.map +1 -0
  563. package/dist/wire/modelResponses.d.ts +79 -0
  564. package/dist/wire/modelResponses.d.ts.map +1 -0
  565. package/dist/wire/modelResponses.js +44 -0
  566. package/dist/wire/modelResponses.js.map +1 -0
  567. package/dist/wire/modelShape.d.ts +79 -0
  568. package/dist/wire/modelShape.d.ts.map +1 -0
  569. package/dist/wire/modelShape.js +75 -0
  570. package/dist/wire/modelShape.js.map +1 -0
  571. package/dist/wire/protocol.d.ts +39 -0
  572. package/dist/wire/protocol.d.ts.map +1 -0
  573. package/dist/wire/protocol.js +39 -0
  574. package/dist/wire/protocol.js.map +1 -0
  575. package/dist/wire/protocolVersion.d.ts +74 -0
  576. package/dist/wire/protocolVersion.d.ts.map +1 -0
  577. package/dist/wire/protocolVersion.js +84 -0
  578. package/dist/wire/protocolVersion.js.map +1 -0
  579. package/package.json +187 -0
  580. package/src/ablo.ts +139 -0
  581. package/src/ai-sdk/coordinatedTool.ts +214 -0
  582. package/src/ai-sdk/index.ts +7 -0
  583. package/src/auth/apiKey.ts +540 -0
  584. package/src/auth/bootstrapScope.ts +15 -0
  585. package/src/auth/browserCredentialSafety.ts +48 -0
  586. package/src/auth/capability.ts +326 -0
  587. package/src/auth/capabilityLifecycle.ts +174 -0
  588. package/src/auth/credentialEndpoint.ts +79 -0
  589. package/src/auth/credentialEndpointProtocol.ts +51 -0
  590. package/src/auth/credentialKind.ts +20 -0
  591. package/src/auth/credentialPolicy.ts +244 -0
  592. package/src/auth/credentialResult.ts +23 -0
  593. package/src/auth/credentialSource.ts +99 -0
  594. package/src/auth/hostedEndpoints.ts +24 -0
  595. package/src/auth/identity.ts +330 -0
  596. package/src/auth/index.ts +592 -0
  597. package/src/auth/schemas.ts +94 -0
  598. package/src/auth/sessionMint.ts +126 -0
  599. package/src/auth/token.ts +4 -0
  600. package/src/batching/index.ts +200 -0
  601. package/src/coordination/awaitClaimGrant.ts +243 -0
  602. package/src/coordination/claimHeartbeatLoop.ts +165 -0
  603. package/src/coordination/claimMeta.ts +56 -0
  604. package/src/coordination/events.ts +86 -0
  605. package/src/coordination/index.ts +181 -0
  606. package/src/coordination/locator.ts +200 -0
  607. package/src/coordination/schema.ts +1346 -0
  608. package/src/coordination/targetConflict.ts +85 -0
  609. package/src/coordination/trace.ts +173 -0
  610. package/src/docs/catalog.ts +274 -0
  611. package/src/docs/index.ts +18 -0
  612. package/src/durableWrites.ts +134 -0
  613. package/src/environment.ts +132 -0
  614. package/src/errorCodes.ts +1591 -0
  615. package/src/errors.ts +918 -0
  616. package/src/footprint.ts +0 -0
  617. package/src/headlessClient.ts +161 -0
  618. package/src/index.ts +52 -0
  619. package/src/keys/index.ts +254 -0
  620. package/src/log/syncDeltaRow.ts +119 -0
  621. package/src/logger.ts +22 -0
  622. package/src/observability.ts +85 -0
  623. package/src/policy/types.ts +292 -0
  624. package/src/resources/functionalUpdate.ts +151 -0
  625. package/src/resources/httpResources.ts +520 -0
  626. package/src/resources/modelOperations.ts +444 -0
  627. package/src/resources/mutationOptions.ts +66 -0
  628. package/src/resources/where.ts +160 -0
  629. package/src/resources/writeOptionsSchema.ts +91 -0
  630. package/src/schema/coordination.ts +161 -0
  631. package/src/schema/ddl.ts +593 -0
  632. package/src/schema/ddlLock.ts +53 -0
  633. package/src/schema/diff.ts +489 -0
  634. package/src/schema/field.ts +321 -0
  635. package/src/schema/fieldRef.ts +88 -0
  636. package/src/schema/generate.ts +95 -0
  637. package/src/schema/index.ts +278 -0
  638. package/src/schema/loadStrategy.ts +52 -0
  639. package/src/schema/model.ts +476 -0
  640. package/src/schema/openapi.ts +658 -0
  641. package/src/schema/queries.ts +273 -0
  642. package/src/schema/relation.ts +291 -0
  643. package/src/schema/residency.ts +30 -0
  644. package/src/schema/roles.ts +325 -0
  645. package/src/schema/schema.ts +825 -0
  646. package/src/schema/select.ts +115 -0
  647. package/src/schema/serialize.ts +373 -0
  648. package/src/schema/sugar.ts +194 -0
  649. package/src/schema/tenancy.ts +221 -0
  650. package/src/server/adapter.ts +203 -0
  651. package/src/server/commit.ts +109 -0
  652. package/src/server/index.ts +26 -0
  653. package/src/server/readConfig.ts +82 -0
  654. package/src/server/storageMode.ts +20 -0
  655. package/src/source/adapter.ts +85 -0
  656. package/src/source/adapters/drizzle.ts +291 -0
  657. package/src/source/adapters/kysely.ts +346 -0
  658. package/src/source/adapters/kyselyMutationCore.ts +220 -0
  659. package/src/source/adapters/memory.ts +154 -0
  660. package/src/source/adapters/prisma.ts +282 -0
  661. package/src/source/conformance.ts +287 -0
  662. package/src/source/connector.ts +394 -0
  663. package/src/source/connectorProtocol.ts +189 -0
  664. package/src/source/contract.ts +221 -0
  665. package/src/source/drizzle.ts +1 -0
  666. package/src/source/factory.ts +444 -0
  667. package/src/source/idempotency.ts +189 -0
  668. package/src/source/index.ts +182 -0
  669. package/src/source/kysely.ts +2 -0
  670. package/src/source/migrations.ts +109 -0
  671. package/src/source/next.ts +38 -0
  672. package/src/source/pushQueue.ts +368 -0
  673. package/src/source/signing.ts +279 -0
  674. package/src/source/types.ts +502 -0
  675. package/src/syncLog/contract.ts +32 -0
  676. package/src/syncLog/index.ts +1 -0
  677. package/src/testing/fixtures/httpResponses.ts +155 -0
  678. package/src/transactionLayer.ts +122 -0
  679. package/src/transactions/settlement/commitEnvelope.ts +192 -0
  680. package/src/transactions/settlement/httpCommitEnvelope.ts +250 -0
  681. package/src/transactions/settlement/idempotencyKey.ts +11 -0
  682. package/src/transactions/settlement/pendingWrite.ts +24 -0
  683. package/src/transport/commitFrames.ts +225 -0
  684. package/src/transport/connectionManager.ts +856 -0
  685. package/src/transport/credentialLifecycle.ts +389 -0
  686. package/src/transport/heartbeat.ts +118 -0
  687. package/src/transport/httpClient.ts +354 -0
  688. package/src/transport/httpFeed.ts +111 -0
  689. package/src/transport/httpOptions.ts +41 -0
  690. package/src/transport/httpTransport.ts +1976 -0
  691. package/src/transport/networkProbe.ts +249 -0
  692. package/src/transport/wsFrameHandlers.ts +619 -0
  693. package/src/transport/wsTransport.ts +1491 -0
  694. package/src/types/assertExact.ts +17 -0
  695. package/src/types/global.ts +123 -0
  696. package/src/types/index.ts +270 -0
  697. package/src/types/modelData.ts +11 -0
  698. package/src/types/participant.ts +22 -0
  699. package/src/types/streams.ts +702 -0
  700. package/src/utils/asyncIterator.ts +141 -0
  701. package/src/utils/duration.ts +88 -0
  702. package/src/utils/json.ts +281 -0
  703. package/src/webhooks/events.ts +96 -0
  704. package/src/webhooks/index.ts +12 -0
  705. package/src/wire/accountResponses.ts +328 -0
  706. package/src/wire/auth.ts +86 -0
  707. package/src/wire/bootstrapReason.ts +10 -0
  708. package/src/wire/claimEvent.ts +85 -0
  709. package/src/wire/claims.ts +370 -0
  710. package/src/wire/commit.ts +379 -0
  711. package/src/wire/delta.ts +173 -0
  712. package/src/wire/errorEnvelope.ts +133 -0
  713. package/src/wire/feedCursor.ts +91 -0
  714. package/src/wire/feedEvent.ts +74 -0
  715. package/src/wire/frames.ts +77 -0
  716. package/src/wire/inboundFrames.ts +182 -0
  717. package/src/wire/index.ts +328 -0
  718. package/src/wire/listEnvelope.ts +51 -0
  719. package/src/wire/modelMutations.ts +55 -0
  720. package/src/wire/modelResponses.ts +48 -0
  721. package/src/wire/modelShape.ts +83 -0
  722. package/src/wire/protocol.ts +39 -0
  723. package/src/wire/protocolVersion.ts +97 -0
@@ -0,0 +1,1976 @@
1
+ /**
2
+ * Private HTTP protocol client behind `Ablo({ schema, transport: 'http' })`.
3
+ * It carries no object pool, local database, or WebSocket and maps Model,
4
+ * Claim, and Commit protocol shapes directly to server routes. The typed
5
+ * facade in `httpClient.ts` is the application boundary; this module owns
6
+ * transport envelopes, watermarks, replay, and route details.
7
+ */
8
+
9
+ import {
10
+ AbloClaimedError,
11
+ AbloAuthenticationError,
12
+ AbloConnectionError,
13
+ AbloIdempotencyError,
14
+ AbloValidationError,
15
+ AbloNotFoundError,
16
+ claimedError,
17
+ translateHttpError,
18
+ } from '../errors.js';
19
+ import { v5 as uuidv5 } from 'uuid';
20
+ import { z } from 'zod';
21
+ import {
22
+ reconcileFunctionalUpdate,
23
+ type ModelUpdater,
24
+ type ContentionOptions,
25
+ } from '../resources/functionalUpdate.js';
26
+ import {
27
+ assertBrowserSafety,
28
+ readProcessEnv,
29
+ resolveApiKey,
30
+ resolveApiKeyValue,
31
+ resolveAuthToken,
32
+ resolveBaseURL,
33
+ resolveBootstrapBaseUrl,
34
+ rejectRemovedDatabaseUrlOption,
35
+ warnIfCliKeyMismatch,
36
+ } from '../auth/apiKey.js';
37
+ import { PROTOCOL_VERSION, PROTOCOL_VERSION_HEADER } from '../wire/protocolVersion.js';
38
+ import { commitReceiptSchema, type CommitReceiptWire } from '../wire/commit.js';
39
+ import { logListResponseSchema } from '../wire/feedEvent.js';
40
+ import {
41
+ claimAcquireResponseSchema,
42
+ claimHeartbeatBatchReplySchema,
43
+ claimHeartbeatReplySchema,
44
+ claimListResponseSchema,
45
+ claimStateSchema,
46
+ type ClaimHeartbeatReply,
47
+ type ClaimListResponse,
48
+ type ClaimQueuedResponse,
49
+ type ClaimRequest,
50
+ type ClaimState,
51
+ type ClaimTargetBody,
52
+ } from '../wire/claims.js';
53
+ import {
54
+ modelListResponseSchema,
55
+ modelReadResponseSchema,
56
+ } from '../wire/modelResponses.js';
57
+ import { toMs } from '../utils/duration.js';
58
+ import {
59
+ heartbeatCadenceMs,
60
+ resolveHeartbeatOptions,
61
+ resolveHeartbeatPlan,
62
+ startClaimHeartbeatLoop,
63
+ } from '../coordination/claimHeartbeatLoop.js';
64
+ import type { HttpClientConfig } from './httpOptions.js';
65
+ import type {
66
+ ClaimedOptions,
67
+ CommitCreateOptions,
68
+ CommitOperationInput,
69
+ CommitReceipt,
70
+ CommitResource,
71
+ CommitWait,
72
+ HttpClaimApi,
73
+ HttpClaimsResource,
74
+ HttpTransportModel,
75
+ ModelClaim,
76
+ ModelMutationOptions,
77
+ ModelReadOptions,
78
+ HttpTransportRead,
79
+ HttpLogsResource,
80
+ ModelTarget,
81
+ CreateSessionParams,
82
+ AbloSession,
83
+ SessionResource,
84
+ } from '../resources/httpResources.js';
85
+ import { mintSession } from '../auth/sessionMint.js';
86
+ import {
87
+ revokeCapability,
88
+ rotateCapability,
89
+ } from '../auth/capabilityLifecycle.js';
90
+ import { parseIdentityResolveResponse } from '../auth/schemas.js';
91
+
92
+ /**
93
+ * Interpret a heartbeat reply for a lease this handle HOLDS: anything other
94
+ * than `held` means the lease is no longer ours (a holder cannot be `queued`;
95
+ * `lost` rides a 409 that the wire error mapping already surfaces as
96
+ * AbloClaimedError before reaching here). The thrown loss is the definitive
97
+ * signal that stops the auto-heartbeat loop.
98
+ */
99
+ function heldHeartbeatReply(reply: ClaimHeartbeatReply, label: string): ClaimHeartbeat {
100
+ if (reply.status === 'held' && typeof reply.expiresAt === 'number') {
101
+ return {
102
+ expiresAt: reply.expiresAt,
103
+ ...(reply.queueDepth !== undefined ? { queueDepth: reply.queueDepth } : {}),
104
+ };
105
+ }
106
+ throw new AbloClaimedError(
107
+ `The lease behind ${label} is no longer held — it expired or was granted onward. Re-acquire the claim and retry; a write attempted under the old lease is rejected by its \`readAt\` guard.`,
108
+ { code: 'claim_lost' }
109
+ );
110
+ }
111
+ import type { SchemaRecord } from '../schema/schema.js';
112
+ import type {
113
+ ClaimLookupParams,
114
+ ClaimOptions,
115
+ ClaimParams,
116
+ ClaimReorderParams,
117
+ ModelTrackParams,
118
+ ModelTrackResult,
119
+ ServerReadOptions,
120
+ } from '../resources/modelOperations.js';
121
+ import type { Duration } from '../utils/duration.js';
122
+ import type { TrackDependency } from '../coordination/schema.js';
123
+ import { claimDescription, partName } from '../coordination/schema.js';
124
+ import type { BatchFence } from '../coordination/locator.js';
125
+ import {
126
+ subTarget,
127
+ streamTarget,
128
+ batchFence,
129
+ fenceTokenFor,
130
+ } from '../coordination/locator.js';
131
+ import { declaredMeta, wireMeta } from '../coordination/claimMeta.js';
132
+ import type { Claim, ClaimHeartbeat, ClaimHeartbeatOptions, HeldClaim } from '../types/streams.js';
133
+ import type { CoordinationObservability } from '../observability.js';
134
+ import { assertWriteOptions } from '../resources/writeOptionsSchema.js';
135
+ import {
136
+ createDurableHttpCommitEnvelope,
137
+ canonicalHttpCommitBody,
138
+ durableHttpCommitEnvelopeSchema,
139
+ httpCommitEnvelopeRecordId,
140
+ isHttpCommitReplayExpired,
141
+ type DurableHttpCommitEnvelope,
142
+ type DurableHttpCommitMethod,
143
+ } from '../transactions/settlement/httpCommitEnvelope.js';
144
+ import type { CommitOutboxScope } from '../transactions/settlement/commitEnvelope.js';
145
+ import { resolveDurableWrites } from '../durableWrites.js';
146
+
147
+ /** @internal Private options for the schema-agnostic HTTP protocol transport. */
148
+ export type HttpTransportOptions = Omit<HttpClientConfig, 'schema'> & {
149
+ readonly bootstrapBaseUrl?: string | undefined;
150
+ /** Schema-key to wire-typename mapping used only when minting agent sessions. */
151
+ readonly modelTypenames?: Readonly<Record<string, string>> | undefined;
152
+ /**
153
+ * The observability provider forwarded from `Ablo({ observability })`. The HTTP
154
+ * transport emits the same claim and conflict events as the WebSocket transport,
155
+ * so a `ClaimLog` works identically for headless server-agent evaluations.
156
+ */
157
+ readonly observability?: CoordinationObservability;
158
+ /**
159
+ * Per-request deadline in milliseconds for the stateless HTTP transport.
160
+ * Every request this client issues is aborted after this long and surfaces
161
+ * as a retryable connection error — without it a black-holed server hangs
162
+ * a headless agent forever (browsers never time fetch out on their own).
163
+ * Pass `0` to disable the deadline.
164
+ *
165
+ * @default 30_000
166
+ */
167
+ readonly timeoutMs?: number;
168
+ };
169
+
170
+ /** @internal Default per-request deadline for the private HTTP transport. */
171
+ export const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
172
+ const HTTP_CONFIRMATION_POLL_INTERVAL_MS = 250;
173
+ /**
174
+ * The server's acquire window, mirrored here as the client-side default for a
175
+ * claim that names no `ttl` — it sets the auto-heartbeat cadence.
176
+ */
177
+ const DEFAULT_CLAIM_TTL_MS = 60_000;
178
+
179
+ // NOTE: end-user / agent session minting is `ablo.sessions.create(...)` (typed
180
+ // against the schema, see Ablo.ts `CreateSessionParams`). There is no separate
181
+ // `ephemeralKeys` resource — `sessions` is the one front door for both.
182
+
183
+ /** @internal Private protocol surface wrapped by `AbloHttpClient`. */
184
+ export interface HttpTransport {
185
+ ready(): Promise<void>;
186
+ waitForFlush(): Promise<void>;
187
+ dispose(): Promise<void>;
188
+ purge(): Promise<void>;
189
+ readonly commits: CommitResource;
190
+ /**
191
+ * Claim-ticket operations keyed by `claimId` — the id a queued acquire
192
+ * hands back on `AbloClaimedError('claim_queued')`. See
193
+ * {@link HttpClaimsResource}.
194
+ */
195
+ readonly claims: HttpClaimsResource;
196
+ readonly logs: HttpLogsResource;
197
+ model<T = Record<string, unknown>, Fields = T>(
198
+ name: string,
199
+ ): HttpTransportModel<T, Fields>;
200
+ /**
201
+ * Resolve the active bearer credential this client authenticates with — the
202
+ * same token its own requests carry in `Authorization`. Returns `null` when
203
+ * no credential is configured. Async because the API key may be supplied as
204
+ * an async setter. Use it to authenticate a side-band request to the same
205
+ * server with the credential this client already holds — no re-mint.
206
+ */
207
+ getAuthToken(): Promise<string | null>;
208
+ /**
209
+ * Mint a short-lived scoped session. Minting is a control-plane HTTP call (no
210
+ * socket), so it lives on this stateless client too, not only the realtime one.
211
+ * `{ user }` mints an `ek_`; `{ agent, can }` mints an `rk_`.
212
+ */
213
+ readonly sessions: SessionResource<SchemaRecord>;
214
+ }
215
+
216
+ type CommitResponse = CommitReceiptWire;
217
+
218
+ function parseSuccessfulCommitResponse(value: unknown, idempotencyKey: string): CommitResponse {
219
+ const parsed = commitReceiptSchema.safeParse(value);
220
+ if (!parsed.success || parsed.data.clientTxId !== idempotencyKey) {
221
+ throw new AbloConnectionError(
222
+ 'The commit endpoint returned an invalid success receipt; its outcome remains pending and is safe to retry.',
223
+ {
224
+ code: 'commit_no_result',
225
+ cause: parsed.success
226
+ ? new Error('Commit receipt clientTxId did not match its idempotency key')
227
+ : parsed.error,
228
+ }
229
+ );
230
+ }
231
+ return parsed.data;
232
+ }
233
+
234
+ /** Decode the HTTP claim DTO into the one public Claim shape. */
235
+ function claimFromModelClaim(claim: ModelClaim): Claim {
236
+ // The handle a caller reads back is a public claim, so its `meta` is the
237
+ // declared shape; the rest of the sub-entity locator crosses whole rather
238
+ // than member by member, which is how `fields` used to die on this hop.
239
+ const { meta, ...details } = subTarget(claim.target);
240
+ return {
241
+ object: 'claim',
242
+ id: claim.id,
243
+ ...(claim.status ? { status: claim.status } : {}),
244
+ // The server always stamps a description; default only for total safety.
245
+ description: claim.description ?? 'editing',
246
+ heldBy: claim.actor,
247
+ participantKind: claim.participantKind,
248
+ expiresAt: claim.expiresAt,
249
+ ...(claim.position !== undefined ? { position: claim.position } : {}),
250
+ target: {
251
+ ...streamTarget(claim.target),
252
+ ...details,
253
+ ...(meta !== undefined ? { meta: declaredMeta(meta) } : {}),
254
+ },
255
+ };
256
+ }
257
+
258
+ /** @internal Constructed only by the typed HTTP facade. */
259
+ export function createHttpTransport(options: HttpTransportOptions): HttpTransport {
260
+ const env = readProcessEnv();
261
+ const authInput = { options, env };
262
+ const configuredApiKey = resolveApiKey(authInput);
263
+ const configuredAuthToken = resolveAuthToken(authInput);
264
+ void warnIfCliKeyMismatch(authInput);
265
+ rejectRemovedDatabaseUrlOption(options);
266
+ assertBrowserSafety({
267
+ apiKey: configuredApiKey,
268
+ dangerouslyAllowBrowser: options.dangerouslyAllowBrowser,
269
+ });
270
+
271
+ // Observability hook for the stateless HTTP transport. The WebSocket transport
272
+ // emits claim and conflict events; the HTTP path (server-side agents,
273
+ // `transport: 'http'`) emitted nothing, so a `ClaimLog` handed to a headless
274
+ // agent evaluation stayed empty. This mirrors the two WebSocket events here:
275
+ // claim acquired and coordination-conflict rejection. A no-op when no provider
276
+ // is configured.
277
+ const observability = options.observability;
278
+
279
+ // Shared by the two HTTP write doors (`commits.create` + per-model
280
+ // `mutateModel`): a rejected write whose code is a coordination conflict is
281
+ // the collision ClaimLog exists to surface. Prefer the server's `conflicts`
282
+ // detail (carried on the typed error / envelope); fall back to the rows the
283
+ // caller tried to write so the collision always names a target. Inert without
284
+ // a provider or for non-conflict errors. Never throws (capture is best-effort).
285
+ const recordCoordinationConflict = (
286
+ error: unknown,
287
+ clientTxId: string,
288
+ fallbackRows: readonly { model: string; id: string }[]
289
+ ): void => {
290
+ if (!observability) return;
291
+ const errorRecord =
292
+ typeof error === 'object' && error !== null
293
+ ? (error as { code?: unknown; conflicts?: unknown })
294
+ : undefined;
295
+ const code = errorRecord?.code;
296
+ const isConflict =
297
+ code === 'stale_context' ||
298
+ code === 'claim_conflict' ||
299
+ code === 'entity_claimed' ||
300
+ (typeof code === 'string' && code.startsWith('policy:'));
301
+ if (!isConflict) return;
302
+ const rawConflicts = errorRecord?.conflicts;
303
+ const rows =
304
+ Array.isArray(rawConflicts) && rawConflicts.length > 0
305
+ ? (rawConflicts as readonly { model?: unknown; id?: unknown }[]).map((r) => ({
306
+ model: typeof r.model === 'string' ? r.model : 'unknown',
307
+ id: typeof r.id === 'string' ? r.id : 'unknown',
308
+ fields: [] as string[],
309
+ }))
310
+ : fallbackRows.map((r) => ({ model: r.model, id: r.id, fields: [] as string[] }));
311
+ observability.captureConflict({ clientTxId, rows });
312
+ };
313
+
314
+ const fetchImpl = options.fetch ?? globalThis.fetch;
315
+ if (typeof fetchImpl !== 'function') {
316
+ throw new AbloConnectionError(
317
+ 'Ablo API client requires a fetch implementation. Pass `fetch` in Ablo({ ... }) for this runtime.',
318
+ { code: 'fetch_unavailable' }
319
+ );
320
+ }
321
+
322
+ const url = resolveBaseURL(authInput);
323
+ const apiBaseUrl = resolveBootstrapBaseUrl({
324
+ url,
325
+ bootstrapBaseUrl: options.bootstrapBaseUrl,
326
+ }).replace(/\/+$/, '');
327
+ const durableWrites = resolveDurableWrites(options);
328
+ // Internal replay code retains transactional-outbox terminology. The public
329
+ // constructor exposes the behavior as `durableWrites`.
330
+ const commitOutbox = durableWrites.store;
331
+ const durableWriteNamespace = durableWrites.namespace ?? 'http';
332
+ const legacyCommitOutboxScope = (options as { readonly commitOutboxScope?: CommitOutboxScope })
333
+ .commitOutboxScope;
334
+ const httpOutboxPlaneNamespace = canonicalHttpCommitBody({
335
+ apiBaseUrl,
336
+ defaultQuery: Object.entries(options.defaultQuery ?? {}).sort(([a], [b]) => a.localeCompare(b)),
337
+ });
338
+ let httpOutboxScopeNamespace: string | null = null;
339
+
340
+ let readyPromise: Promise<void> | null = null;
341
+ let httpCommitLane: Promise<void> = Promise.resolve();
342
+
343
+ function runInHttpCommitLane<T>(work: () => Promise<T>): Promise<T> {
344
+ const result = httpCommitLane.then(work);
345
+ httpCommitLane = result.then(
346
+ () => undefined,
347
+ () => undefined
348
+ );
349
+ return result;
350
+ }
351
+
352
+ async function resolveHttpOutboxScope(): Promise<string | null> {
353
+ if (!commitOutbox) return null;
354
+ if (httpOutboxScopeNamespace) return httpOutboxScopeNamespace;
355
+
356
+ let scope: CommitOutboxScope | undefined = legacyCommitOutboxScope
357
+ ? {
358
+ ...legacyCommitOutboxScope,
359
+ namespace: durableWriteNamespace,
360
+ }
361
+ : undefined;
362
+ if (!scope) {
363
+ const rawIdentity = await requestRaw('/auth/identity', { method: 'GET' }, true);
364
+ const identity = parseIdentityResolveResponse(rawIdentity);
365
+ scope = {
366
+ organizationId: identity.accountScope,
367
+ participantId: identity.participantId,
368
+ namespace: durableWriteNamespace,
369
+ };
370
+ }
371
+ httpOutboxScopeNamespace = canonicalHttpCommitBody({
372
+ ...scope,
373
+ plane: httpOutboxPlaneNamespace,
374
+ });
375
+ return httpOutboxScopeNamespace;
376
+ }
377
+
378
+ async function ready(): Promise<void> {
379
+ if (readyPromise) return readyPromise;
380
+
381
+ readyPromise = (async () => {
382
+ await resolveHttpOutboxScope();
383
+ await replayHttpCommitOutbox();
384
+ })();
385
+
386
+ try {
387
+ await readyPromise;
388
+ } catch (error) {
389
+ readyPromise = null;
390
+ throw error;
391
+ }
392
+ }
393
+
394
+ async function authHeaders(sealedProtocolVersion?: number): Promise<Record<string, string>> {
395
+ const apiKey = await resolveApiKeyValue(configuredApiKey);
396
+ const token = apiKey ?? configuredAuthToken;
397
+ if (!token) {
398
+ throw new AbloAuthenticationError(
399
+ 'The HTTP client requires an API key. Pass `apiKey` or set ABLO_API_KEY.',
400
+ { code: 'api_key_required' }
401
+ );
402
+ }
403
+
404
+ const headers: Record<string, string> = {
405
+ 'Content-Type': 'application/json',
406
+ Authorization: `Bearer ${token}`,
407
+ // Protocol handshake for the HTTP transport (wire/protocolVersion.ts):
408
+ // the server answers an out-of-range version with a typed 426.
409
+ [PROTOCOL_VERSION_HEADER]: String(PROTOCOL_VERSION),
410
+ };
411
+
412
+ for (const [key, value] of Object.entries(options.defaultHeaders ?? {})) {
413
+ if (value == null) {
414
+ delete headers[key];
415
+ } else {
416
+ headers[key] = value;
417
+ }
418
+ }
419
+
420
+ // A durable write owns its wire version. Force the sealed value after
421
+ // caller defaults so a restarted (or rolled-back) SDK cannot rewrite the
422
+ // protocol identity of a request that may already have reached the server.
423
+ if (sealedProtocolVersion !== undefined) {
424
+ headers[PROTOCOL_VERSION_HEADER] = String(sealedProtocolVersion);
425
+ }
426
+
427
+ return headers;
428
+ }
429
+
430
+ function endpoint(path: string): string {
431
+ const target = new URL(`${apiBaseUrl}${path.startsWith('/') ? path : `/${path}`}`);
432
+ for (const [key, value] of Object.entries(options.defaultQuery ?? {})) {
433
+ if (value !== undefined) target.searchParams.set(key, value);
434
+ }
435
+ return target.toString();
436
+ }
437
+
438
+ const requestTimeoutMs = options.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
439
+
440
+ /**
441
+ * Issues one request and returns its decoded body without a contract.
442
+ *
443
+ * Use this only where the response has no shape worth checking — a release
444
+ * that answers `{}` — or where the caller runs a richer check of its own, as
445
+ * the commit paths do with their receipt schema. Everywhere else, go through
446
+ * {@link requestJson}, which will not let a response past unvalidated.
447
+ */
448
+ async function requestRaw(
449
+ path: string,
450
+ init: RequestInit & {
451
+ readonly idempotencyKey?: string | null;
452
+ readonly sealedProtocolVersion?: number;
453
+ },
454
+ skipReady = false
455
+ ): Promise<unknown> {
456
+ if (!skipReady) await ready();
457
+ const { idempotencyKey, sealedProtocolVersion, ...requestInit } = init;
458
+ const headers = await authHeaders(sealedProtocolVersion);
459
+ if (idempotencyKey) headers['Idempotency-Key'] = idempotencyKey;
460
+
461
+ // Deadline: abort the request after `timeoutMs` so a black-holed server
462
+ // can't hang the caller forever (fetch has NO default timeout in browsers,
463
+ // and only undici's generous defaults in Node). A caller-supplied signal
464
+ // is combined with the deadline via a shared controller — the portable
465
+ // equivalent of `AbortSignal.any([caller, AbortSignal.timeout(t)])`,
466
+ // which older runtimes (and the jsdom test env) don't implement. The
467
+ // same pattern already guards `query/client.ts` and `BootstrapFetcher`.
468
+ const callerSignal = requestInit.signal ?? undefined;
469
+ const controller = new AbortController();
470
+ const onCallerAbort = (): void => {
471
+ controller.abort(callerSignal?.reason);
472
+ };
473
+ if (callerSignal) {
474
+ if (callerSignal.aborted) onCallerAbort();
475
+ else callerSignal.addEventListener('abort', onCallerAbort, { once: true });
476
+ }
477
+ let timedOut = false;
478
+ const deadline =
479
+ requestTimeoutMs > 0
480
+ ? setTimeout(() => {
481
+ timedOut = true;
482
+ controller.abort();
483
+ }, requestTimeoutMs)
484
+ : null;
485
+
486
+ let res: Response;
487
+ let bodyText: string;
488
+ try {
489
+ res = await fetchImpl(endpoint(path), {
490
+ ...requestInit,
491
+ signal: controller.signal,
492
+ headers: {
493
+ ...headers,
494
+ ...(requestInit.headers as Record<string, string> | undefined),
495
+ },
496
+ });
497
+ // Keep the deadline armed while the body streams — a server that sends
498
+ // headers then stalls the body is the same hang with better manners.
499
+ bodyText = await res.text();
500
+ } catch (error) {
501
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- assigned asynchronously by the deadline callback
502
+ if (timedOut) {
503
+ // Retryable by contract: `wait_for_timeout` is a registered transient
504
+ // transport code, so `isRetryableCode` steers callers to retry.
505
+ throw new AbloConnectionError(
506
+ `The Ablo API did not respond within ${requestTimeoutMs}ms ` +
507
+ `(${requestInit.method ?? 'GET'} ${path}). The request was aborted; ` +
508
+ 'it is safe to retry.',
509
+ { code: 'wait_for_timeout', cause: error }
510
+ );
511
+ }
512
+ throw error;
513
+ } finally {
514
+ if (deadline) clearTimeout(deadline);
515
+ callerSignal?.removeEventListener('abort', onCallerAbort);
516
+ }
517
+
518
+ const body = parseBody(bodyText);
519
+ if (!res.ok) {
520
+ throw translateHttpError(
521
+ res.status,
522
+ body ?? `Ablo API request failed: ${res.status} ${res.statusText}`,
523
+ res.headers.get('x-request-id') ?? undefined
524
+ );
525
+ }
526
+
527
+ return body;
528
+ }
529
+
530
+ /**
531
+ * Issues one request and validates its body against the route's schema.
532
+ *
533
+ * The schema is the route's response contract, declared once in `wire/` and
534
+ * shared with the server that produces it. A body that does not match is a
535
+ * version disagreement between the two, so it is refused whole rather than
536
+ * read field by field and half-trusted.
537
+ */
538
+ async function requestJson<T>(
539
+ path: string,
540
+ init: RequestInit & {
541
+ readonly idempotencyKey?: string | null;
542
+ readonly sealedProtocolVersion?: number;
543
+ },
544
+ responseSchema: z.ZodType<T>,
545
+ skipReady = false
546
+ ): Promise<T> {
547
+ const body = await requestRaw(path, init, skipReady);
548
+ const parsed = responseSchema.safeParse(body);
549
+ if (!parsed.success) {
550
+ throw new AbloConnectionError(
551
+ `The Ablo API returned a response for ${init.method ?? 'GET'} ${path} that this client could not read; nothing was applied.`,
552
+ { code: 'malformed_response', cause: parsed.error }
553
+ );
554
+ }
555
+ return parsed.data;
556
+ }
557
+
558
+ function isDefinitiveHttpRejection(error: unknown): boolean {
559
+ if (typeof error !== 'object' || error === null) return false;
560
+ const candidate = error as { httpStatus?: unknown; status?: unknown };
561
+ const status =
562
+ typeof candidate.httpStatus === 'number'
563
+ ? candidate.httpStatus
564
+ : typeof candidate.status === 'number'
565
+ ? candidate.status
566
+ : undefined;
567
+ return (
568
+ status !== undefined &&
569
+ status >= 400 &&
570
+ status < 500 &&
571
+ status !== 408 &&
572
+ status !== 425 &&
573
+ status !== 429
574
+ );
575
+ }
576
+
577
+ async function settleHttpEnvelope(recordId: string): Promise<void> {
578
+ if (!commitOutbox) return;
579
+ try {
580
+ await commitOutbox.remove(recordId);
581
+ } catch (cause) {
582
+ // Do not report the remote outcome until local settlement is durable.
583
+ // The retained record can still be replayed inside the safe window.
584
+ throw new AbloConnectionError(
585
+ 'The server settled the commit, but its local outbox record could not be cleared.',
586
+ { code: 'db_not_opened', cause }
587
+ );
588
+ }
589
+ }
590
+
591
+ /**
592
+ * Persist the first queued source receipt before exposing acceptance to the
593
+ * caller. This is a monotonic upgrade of the same sealed request: connected
594
+ * source keys are permanent, so the envelope may safely remain replayable
595
+ * after the hosted 24-hour idempotency window while it awaits its WAL echo.
596
+ */
597
+ async function persistHttpAcceptance(
598
+ envelope: DurableHttpCommitEnvelope,
599
+ response: CommitResponse
600
+ ): Promise<DurableHttpCommitEnvelope> {
601
+ if (!commitOutbox || response.status !== 'queued') return envelope;
602
+ const correlationId = response.correlationId;
603
+ if (!correlationId) {
604
+ throw new AbloConnectionError(
605
+ 'The source accepted the commit without durable correlation evidence.',
606
+ { code: 'commit_no_result' }
607
+ );
608
+ }
609
+ if (envelope.correlationId !== undefined && envelope.correlationId !== correlationId) {
610
+ throw new AbloIdempotencyError(
611
+ 'The same HTTP commit replay returned a different source correlation.',
612
+ { code: 'idempotency_conflict' }
613
+ );
614
+ }
615
+ if (envelope.acceptedAt !== undefined) return envelope;
616
+ const accepted = durableHttpCommitEnvelopeSchema.parse({
617
+ ...envelope,
618
+ acceptedAt: Date.now(),
619
+ correlationId,
620
+ });
621
+ try {
622
+ await commitOutbox.seal(accepted, []);
623
+ } catch (cause) {
624
+ throw new AbloConnectionError(
625
+ 'The source accepted the commit, but that acceptance could not be persisted locally.',
626
+ { code: 'db_not_opened', cause }
627
+ );
628
+ }
629
+ return accepted;
630
+ }
631
+
632
+ interface ReplayedHttpCommit {
633
+ readonly envelope: DurableHttpCommitEnvelope;
634
+ readonly response: CommitResponse;
635
+ }
636
+
637
+ interface ExactHttpCommitRequest {
638
+ readonly idempotencyKey: string;
639
+ readonly method: DurableHttpCommitMethod;
640
+ readonly path: string;
641
+ readonly body: string;
642
+ readonly sealedProtocolVersion?: number;
643
+ }
644
+
645
+ function replicationLagTimeout(
646
+ request: ExactHttpCommitRequest,
647
+ response: CommitResponse
648
+ ): AbloConnectionError {
649
+ return new AbloConnectionError(
650
+ `The source accepted commit ${request.idempotencyKey}, but its replication echo did not arrive within ${requestTimeoutMs}ms.`,
651
+ {
652
+ code: 'replication_lag_timeout',
653
+ httpStatus: 504,
654
+ details: {
655
+ clientTxId: request.idempotencyKey,
656
+ ...(response.correlationId ? { correlationId: response.correlationId } : {}),
657
+ timeoutMs: requestTimeoutMs,
658
+ accepted: true,
659
+ },
660
+ }
661
+ );
662
+ }
663
+
664
+ /**
665
+ * Replays one byte-identical, idempotent HTTP commit until mutation-log
666
+ * replay reports the source echo as confirmed. `queued` is acceptance only:
667
+ * this loop never clears the durable envelope and never converts it into a
668
+ * successful `wait: 'confirmed'` result.
669
+ */
670
+ async function pollHttpCommitConfirmation(
671
+ request: ExactHttpCommitRequest,
672
+ initial: CommitResponse
673
+ ): Promise<CommitResponse> {
674
+ let current = initial;
675
+ const correlationId = initial.correlationId;
676
+ const deadlineAt = requestTimeoutMs > 0 ? Date.now() + requestTimeoutMs : null;
677
+
678
+ while (current.status === 'queued') {
679
+ const remaining = deadlineAt === null ? null : deadlineAt - Date.now();
680
+ if (remaining !== null && remaining <= 0) {
681
+ throw replicationLagTimeout(request, current);
682
+ }
683
+
684
+ const confirmationController = new AbortController();
685
+ const confirmationDeadline =
686
+ remaining !== null
687
+ ? setTimeout(() => {
688
+ confirmationController.abort();
689
+ }, remaining)
690
+ : null;
691
+ try {
692
+ const raw = await requestRaw(
693
+ request.path,
694
+ {
695
+ method: request.method,
696
+ idempotencyKey: request.idempotencyKey,
697
+ ...(request.sealedProtocolVersion !== undefined
698
+ ? { sealedProtocolVersion: request.sealedProtocolVersion }
699
+ : {}),
700
+ body: request.body,
701
+ signal: confirmationController.signal,
702
+ },
703
+ true
704
+ );
705
+ const next = parseSuccessfulCommitResponse(raw, request.idempotencyKey);
706
+ if (next.correlationId !== correlationId) {
707
+ throw new AbloIdempotencyError(
708
+ 'The same HTTP commit replay returned different source correlation evidence.',
709
+ { code: 'idempotency_conflict' }
710
+ );
711
+ }
712
+ current = next;
713
+ } catch (error) {
714
+ if (
715
+ confirmationController.signal.aborted ||
716
+ (deadlineAt !== null && Date.now() >= deadlineAt)
717
+ ) {
718
+ throw replicationLagTimeout(request, current);
719
+ }
720
+ throw error;
721
+ } finally {
722
+ if (confirmationDeadline) clearTimeout(confirmationDeadline);
723
+ }
724
+
725
+ if (current.status === 'confirmed') return current;
726
+ const delayMs =
727
+ deadlineAt === null
728
+ ? HTTP_CONFIRMATION_POLL_INTERVAL_MS
729
+ : Math.min(HTTP_CONFIRMATION_POLL_INTERVAL_MS, Math.max(0, deadlineAt - Date.now()));
730
+ if (delayMs > 0) {
731
+ await new Promise<void>((resolve) => {
732
+ setTimeout(resolve, delayMs);
733
+ });
734
+ }
735
+ }
736
+ return current;
737
+ }
738
+
739
+ async function replayHttpCommitOutbox(): Promise<Map<string, ReplayedHttpCommit>> {
740
+ const replayed = new Map<string, ReplayedHttpCommit>();
741
+ if (!commitOutbox) return replayed;
742
+ const scopeNamespace = await resolveHttpOutboxScope();
743
+ if (!scopeNamespace) return replayed;
744
+ const rows = await commitOutbox.list();
745
+ const envelopes: DurableHttpCommitEnvelope[] = [];
746
+ for (const row of rows) {
747
+ if (
748
+ typeof row !== 'object' ||
749
+ row === null ||
750
+ (row as { type?: unknown }).type !== 'http_commit_envelope'
751
+ )
752
+ continue;
753
+ const parsed = durableHttpCommitEnvelopeSchema.safeParse(row);
754
+ if (!parsed.success) {
755
+ throw new AbloValidationError(
756
+ 'A saved HTTP write is unreadable; replay stopped before any newer write was sent.',
757
+ { code: 'write_options_invalid', cause: parsed.error }
758
+ );
759
+ }
760
+ if (parsed.data.scopeNamespace !== scopeNamespace) continue;
761
+ if (isHttpCommitReplayExpired(parsed.data)) {
762
+ throw new AbloIdempotencyError(
763
+ 'A saved HTTP write is older than the server idempotency window and cannot be replayed safely.',
764
+ { code: 'idempotency_conflict' }
765
+ );
766
+ }
767
+ envelopes.push(parsed.data);
768
+ }
769
+ envelopes.sort(
770
+ (a, b) =>
771
+ (a.sequence ?? a.sealedAt * 1_000) - (b.sequence ?? b.sealedAt * 1_000) ||
772
+ a.id.localeCompare(b.id)
773
+ );
774
+ for (const envelope of envelopes) {
775
+ try {
776
+ const raw = await requestRaw(
777
+ envelope.request.path,
778
+ {
779
+ method: envelope.request.method,
780
+ idempotencyKey: envelope.idempotencyKey,
781
+ sealedProtocolVersion: envelope.protocolVersion,
782
+ body: envelope.request.body,
783
+ },
784
+ true
785
+ );
786
+ const response = parseSuccessfulCommitResponse(raw, envelope.idempotencyKey);
787
+ if (
788
+ envelope.correlationId !== undefined &&
789
+ response.correlationId !== envelope.correlationId
790
+ ) {
791
+ throw new AbloIdempotencyError(
792
+ 'The saved HTTP commit replay returned different source correlation evidence.',
793
+ { code: 'idempotency_conflict' }
794
+ );
795
+ }
796
+ const replayEnvelope = await persistHttpAcceptance(envelope, response);
797
+ // A queued source receipt is only acceptance. Keep the exact request
798
+ // durable so startup/retry can ask mutation-log replay whether its WAL
799
+ // echo has materialized; only confirmed is a definitive success.
800
+ if (response.status === 'confirmed') {
801
+ await settleHttpEnvelope(envelope.id);
802
+ }
803
+ replayed.set(envelope.idempotencyKey, {
804
+ envelope: replayEnvelope,
805
+ response,
806
+ });
807
+ } catch (error) {
808
+ if (isDefinitiveHttpRejection(error)) {
809
+ await settleHttpEnvelope(envelope.id);
810
+ }
811
+ throw error;
812
+ }
813
+ }
814
+ return replayed;
815
+ }
816
+
817
+ /**
818
+ * A flush is stronger than startup replay: it promises that every retained
819
+ * envelope has reached a definitive outcome, not merely that the server
820
+ * accepted it for forwarding. Poll queued receipts through mutation-log
821
+ * replay and leave their envelopes intact if the confirmation deadline
822
+ * expires.
823
+ */
824
+ async function confirmReplayedHttpCommits(
825
+ replayed: ReadonlyMap<string, ReplayedHttpCommit>
826
+ ): Promise<void> {
827
+ for (const { envelope, response } of replayed.values()) {
828
+ if (response.status !== 'queued') continue;
829
+ try {
830
+ const confirmed = await pollHttpCommitConfirmation(
831
+ {
832
+ idempotencyKey: envelope.idempotencyKey,
833
+ method: envelope.request.method,
834
+ path: envelope.request.path,
835
+ body: envelope.request.body,
836
+ sealedProtocolVersion: envelope.protocolVersion,
837
+ },
838
+ response
839
+ );
840
+ if (confirmed.status === 'confirmed') {
841
+ await settleHttpEnvelope(envelope.id);
842
+ }
843
+ } catch (error) {
844
+ if (isDefinitiveHttpRejection(error)) {
845
+ await settleHttpEnvelope(envelope.id);
846
+ }
847
+ throw error;
848
+ }
849
+ }
850
+ }
851
+
852
+ let lastHttpCommitSequence = 0;
853
+ function nextHttpCommitSequence(): number {
854
+ const wallSequence = Date.now() * 1_000;
855
+ lastHttpCommitSequence = Math.max(wallSequence, lastHttpCommitSequence + 1);
856
+ return lastHttpCommitSequence;
857
+ }
858
+
859
+ async function sealHttpCommit(input: {
860
+ idempotencyKey: string;
861
+ method: DurableHttpCommitMethod;
862
+ path: string;
863
+ body: unknown;
864
+ }): Promise<DurableHttpCommitEnvelope | null> {
865
+ if (!commitOutbox) return null;
866
+ const scopeNamespace = await resolveHttpOutboxScope();
867
+ if (!scopeNamespace) {
868
+ throw new AbloValidationError('HTTP durable-write scope was not resolved', {
869
+ code: 'write_options_invalid',
870
+ });
871
+ }
872
+ const recordId = httpCommitEnvelopeRecordId(input.idempotencyKey, scopeNamespace);
873
+ const legacyRecordId = httpCommitEnvelopeRecordId(input.idempotencyKey);
874
+ const existingRows = await commitOutbox.list();
875
+ const existingRaw = existingRows.find(
876
+ (row) =>
877
+ typeof row === 'object' &&
878
+ row !== null &&
879
+ ((row as { id?: unknown }).id === recordId ||
880
+ (row as { id?: unknown }).id === legacyRecordId)
881
+ );
882
+ const serializedBody = canonicalHttpCommitBody(input.body);
883
+ if (existingRaw !== undefined) {
884
+ const existing = durableHttpCommitEnvelopeSchema.parse(existingRaw);
885
+ if (isHttpCommitReplayExpired(existing)) {
886
+ throw new AbloIdempotencyError(
887
+ 'This saved HTTP write is older than the server idempotency window and cannot be retried safely.',
888
+ { code: 'idempotency_conflict' }
889
+ );
890
+ }
891
+ if (
892
+ existing.scopeNamespace !== scopeNamespace ||
893
+ existing.request.method !== input.method ||
894
+ existing.request.path !== input.path ||
895
+ existing.request.body !== serializedBody
896
+ ) {
897
+ throw new AbloIdempotencyError(
898
+ 'Idempotency key reused with a different HTTP commit request',
899
+ { code: 'idempotency_conflict' }
900
+ );
901
+ }
902
+ return existing;
903
+ }
904
+ const envelope = createDurableHttpCommitEnvelope({
905
+ idempotencyKey: input.idempotencyKey,
906
+ request: { method: input.method, path: input.path, body: input.body },
907
+ scopeNamespace,
908
+ sequence: nextHttpCommitSequence(),
909
+ });
910
+ await commitOutbox.seal(envelope, []);
911
+ return envelope;
912
+ }
913
+
914
+ async function dispatchHttpCommit(
915
+ input: {
916
+ idempotencyKey: string;
917
+ method: DurableHttpCommitMethod;
918
+ path: string;
919
+ body: unknown;
920
+ wait: CommitWait;
921
+ },
922
+ beforeSettlement?: (response: CommitResponse) => Promise<void>
923
+ ): Promise<CommitResponse> {
924
+ return runInHttpCommitLane(async () => {
925
+ await ready();
926
+ // `ready()` covers startup. Re-draining here makes every later write wait
927
+ // behind an ambiguous predecessor from this same process.
928
+ const replayed = await replayHttpCommitOutbox();
929
+ const prior = replayed.get(input.idempotencyKey);
930
+ if (prior) {
931
+ const serializedBody = canonicalHttpCommitBody(input.body);
932
+ if (
933
+ prior.envelope.request.method !== input.method ||
934
+ prior.envelope.request.path !== input.path ||
935
+ prior.envelope.request.body !== serializedBody
936
+ ) {
937
+ throw new AbloIdempotencyError(
938
+ 'Idempotency key reused with a different HTTP commit request',
939
+ { code: 'idempotency_conflict' }
940
+ );
941
+ }
942
+ let priorResponse = prior.response;
943
+ if (priorResponse.status === 'queued' && input.wait === 'confirmed') {
944
+ try {
945
+ priorResponse = await pollHttpCommitConfirmation(
946
+ {
947
+ idempotencyKey: prior.envelope.idempotencyKey,
948
+ method: prior.envelope.request.method,
949
+ path: prior.envelope.request.path,
950
+ body: prior.envelope.request.body,
951
+ sealedProtocolVersion: prior.envelope.protocolVersion,
952
+ },
953
+ priorResponse
954
+ );
955
+ } catch (error) {
956
+ if (isDefinitiveHttpRejection(error)) {
957
+ await settleHttpEnvelope(prior.envelope.id);
958
+ }
959
+ throw error;
960
+ }
961
+ }
962
+ if (priorResponse.status === 'confirmed') {
963
+ await beforeSettlement?.(priorResponse);
964
+ await settleHttpEnvelope(prior.envelope.id);
965
+ }
966
+ return priorResponse;
967
+ }
968
+ const durableEnvelope = await sealHttpCommit(input);
969
+ const requestBody = durableEnvelope?.request.body ?? canonicalHttpCommitBody(input.body);
970
+ const exactRequest: ExactHttpCommitRequest = {
971
+ idempotencyKey: input.idempotencyKey,
972
+ method: input.method,
973
+ path: input.path,
974
+ body: requestBody,
975
+ ...(durableEnvelope ? { sealedProtocolVersion: durableEnvelope.protocolVersion } : {}),
976
+ };
977
+
978
+ let response: CommitResponse;
979
+ try {
980
+ const raw = await requestRaw(
981
+ exactRequest.path,
982
+ {
983
+ method: exactRequest.method,
984
+ idempotencyKey: exactRequest.idempotencyKey,
985
+ ...(exactRequest.sealedProtocolVersion !== undefined
986
+ ? { sealedProtocolVersion: exactRequest.sealedProtocolVersion }
987
+ : {}),
988
+ body: exactRequest.body,
989
+ },
990
+ true
991
+ );
992
+ response = parseSuccessfulCommitResponse(raw, input.idempotencyKey);
993
+ if (durableEnvelope && response.status === 'queued') {
994
+ await persistHttpAcceptance(durableEnvelope, response);
995
+ }
996
+ if (response.status === 'queued' && input.wait === 'confirmed') {
997
+ response = await pollHttpCommitConfirmation(exactRequest, response);
998
+ }
999
+ } catch (error) {
1000
+ if (durableEnvelope && isDefinitiveHttpRejection(error)) {
1001
+ await settleHttpEnvelope(durableEnvelope.id);
1002
+ }
1003
+ throw error;
1004
+ }
1005
+
1006
+ // A model-create readback can participate in settlement: if it fails,
1007
+ // retain the exact write so a same-key retry recovers the generated id.
1008
+ // A queued source receipt cannot be read back from the log yet and stays
1009
+ // durable until a later confirmed replay.
1010
+ if (response.status === 'confirmed') {
1011
+ await beforeSettlement?.(response);
1012
+ if (durableEnvelope) await settleHttpEnvelope(durableEnvelope.id);
1013
+ }
1014
+ return response;
1015
+ });
1016
+ }
1017
+
1018
+ function createClientTxId(idempotencyKey?: string | null): string {
1019
+ if (idempotencyKey && idempotencyKey.length > 0) return idempotencyKey;
1020
+ return typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
1021
+ ? crypto.randomUUID()
1022
+ : `tx_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
1023
+ }
1024
+
1025
+ function createModelId(modelName: string, idempotencyKey?: string | null): string {
1026
+ if (idempotencyKey) {
1027
+ return uuidv5(`${modelName}:${idempotencyKey}`, 'aa4ba6d4-bf0b-5b38-9c45-116f79a6e548');
1028
+ }
1029
+ return typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
1030
+ ? crypto.randomUUID()
1031
+ : `id_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
1032
+ }
1033
+
1034
+ function normalizeCommitOperation(
1035
+ op: CommitOperationInput,
1036
+ defaults: Pick<CommitCreateOptions, 'readAt' | 'onStale'>,
1037
+ fence: BatchFence | null
1038
+ ): CommitOperationInput {
1039
+ return {
1040
+ action: op.action,
1041
+ model: op.model,
1042
+ id: op.id ?? null,
1043
+ data: op.data ?? null,
1044
+ transactionId: op.transactionId ?? null,
1045
+ readAt: op.readAt ?? defaults.readAt ?? null,
1046
+ onStale: op.onStale ?? defaults.onStale ?? null,
1047
+ fenceToken: op.fenceToken ?? fenceTokenFor(fence, op.model, op.id ?? null),
1048
+ };
1049
+ }
1050
+
1051
+ function normalizeCommitOperations(
1052
+ commitOptions: CommitCreateOptions,
1053
+ fence: BatchFence | null
1054
+ ): readonly CommitOperationInput[] {
1055
+ if (commitOptions.operations.length === 0) {
1056
+ throw new AbloValidationError('Commit requires a non-empty `operations` array.', {
1057
+ code: 'commit_operation_required',
1058
+ });
1059
+ }
1060
+ return commitOptions.operations.map((op) =>
1061
+ normalizeCommitOperation(op, commitOptions, fence)
1062
+ );
1063
+ }
1064
+
1065
+ async function listClaimState(
1066
+ target?: Partial<ModelTarget>
1067
+ ): Promise<{ active: readonly ModelClaim[]; queue: readonly ModelClaim[] }> {
1068
+ const params = new URLSearchParams();
1069
+ if (target?.model) params.set('model', target.model);
1070
+ if (target?.id) params.set('id', target.id);
1071
+ if (target?.field) params.set('field', target.field);
1072
+
1073
+ const suffix = params.toString();
1074
+ const body = await requestJson(
1075
+ `/v1/claims${suffix ? `?${suffix}` : ''}`,
1076
+ { method: 'GET' },
1077
+ claimListResponseSchema
1078
+ );
1079
+ // One list, one resource in two states: holders and waiters are told
1080
+ // apart by each entry's `status`, not by bespoke envelope members.
1081
+ return {
1082
+ active: body.data.filter((row) => row.status !== 'queued'),
1083
+ queue: body.data.filter((row) => row.status === 'queued'),
1084
+ };
1085
+ }
1086
+
1087
+ // The claim-ticket surface: everything a caller does holding only a
1088
+ // `claimId` — which is all a queued acquire leaves in its hand. Each method
1089
+ // is a thin cast of its route; the reply schemas are the wire's own, so the
1090
+ // surface cannot describe a response the server does not send.
1091
+ const claims: HttpClaimsResource = {
1092
+ retrieve({ claimId }): Promise<ClaimState> {
1093
+ return requestJson(
1094
+ `/v1/claims/${encodeURIComponent(claimId)}`,
1095
+ { method: 'GET' },
1096
+ claimStateSchema
1097
+ );
1098
+ },
1099
+ heartbeat({ claimId, ttl }): Promise<ClaimHeartbeatReply> {
1100
+ return requestJson(
1101
+ `/v1/claims/${encodeURIComponent(claimId)}/heartbeat`,
1102
+ {
1103
+ method: 'POST',
1104
+ body: JSON.stringify(ttl !== undefined ? { ttl } : {}),
1105
+ },
1106
+ claimHeartbeatReplySchema
1107
+ );
1108
+ },
1109
+ async heartbeatAll(options) {
1110
+ const reply = await requestJson(
1111
+ '/v1/claims/heartbeat',
1112
+ {
1113
+ method: 'POST',
1114
+ body: JSON.stringify(options?.ttl !== undefined ? { ttl: options.ttl } : {}),
1115
+ },
1116
+ claimHeartbeatBatchReplySchema
1117
+ );
1118
+ return reply.results;
1119
+ },
1120
+ async release({ claimId }) {
1121
+ await requestRaw(`/v1/claims/${encodeURIComponent(claimId)}`, {
1122
+ method: 'DELETE',
1123
+ });
1124
+ },
1125
+ };
1126
+
1127
+ // How the stateless client waits its turn. The queued slot is real server
1128
+ // state, so one heartbeat per tick does both jobs: it refreshes this
1129
+ // waiter's slot and reports the line's answer — `queued` (still waiting) or
1130
+ // `held` (granted). The first check comes quickly because most holds are a
1131
+ // short claim→write→release; after that the cadence relaxes, with jitter so
1132
+ // a fleet of waiters doesn't beat in step.
1133
+ const GRANT_POLL_FIRST_MS = 250;
1134
+ const GRANT_POLL_INTERVAL_MS = 1_000;
1135
+ // An abort cuts the sleep short so the wait ends within a tick of the
1136
+ // signal, not at the next scheduled beat.
1137
+ const sleep = (ms: number, signal?: AbortSignal): Promise<void> =>
1138
+ new Promise((resolve) => {
1139
+ const done = (): void => {
1140
+ clearTimeout(timer);
1141
+ signal?.removeEventListener('abort', done);
1142
+ resolve();
1143
+ };
1144
+ const timer = setTimeout(done, ms);
1145
+ signal?.addEventListener('abort', done, { once: true });
1146
+ });
1147
+
1148
+ async function awaitGrantOverHttp(
1149
+ targetLabel: string,
1150
+ queued: ClaimQueuedResponse,
1151
+ options: { maxQueueDepth?: number; waitTimeoutMs?: number; signal?: AbortSignal }
1152
+ ): Promise<{ id: string; fenceToken?: number }> {
1153
+ // The queued reply is a claim resource in its waiting state, so the
1154
+ // handle is its `id` — same rule as the 201 and the poll.
1155
+ const claimId = queued.id;
1156
+ const { signal } = options;
1157
+ // Leave the line before rejecting: an abandoned slot would otherwise sit
1158
+ // in the queue until its TTL lapses, stalling every waiter behind it.
1159
+ const rejectAndLeave = async (error: AbloClaimedError): Promise<never> => {
1160
+ await claims.release({ claimId }).catch(() => {});
1161
+ throw error;
1162
+ };
1163
+
1164
+ if (options.maxQueueDepth !== undefined && queued.position >= options.maxQueueDepth) {
1165
+ return rejectAndLeave(
1166
+ new AbloClaimedError(
1167
+ `Claim queue for ${targetLabel} is ${queued.position} deep (max ${options.maxQueueDepth}).`,
1168
+ { code: 'queue_too_deep' }
1169
+ )
1170
+ );
1171
+ }
1172
+
1173
+ const deadline =
1174
+ options.waitTimeoutMs !== undefined ? Date.now() + options.waitTimeoutMs : undefined;
1175
+ let delay = GRANT_POLL_FIRST_MS;
1176
+ for (;;) {
1177
+ if (signal?.aborted) {
1178
+ return rejectAndLeave(
1179
+ new AbloClaimedError(
1180
+ `The wait for the claim on ${targetLabel} was aborted before the grant arrived.`,
1181
+ { code: 'claim_wait_aborted' }
1182
+ )
1183
+ );
1184
+ }
1185
+ if (deadline !== undefined && Date.now() >= deadline) {
1186
+ return rejectAndLeave(
1187
+ new AbloClaimedError(
1188
+ `Timed out after ${options.waitTimeoutMs}ms waiting for the queue grant on ${targetLabel}.`,
1189
+ { code: 'grant_timeout' }
1190
+ )
1191
+ );
1192
+ }
1193
+ await sleep(
1194
+ deadline !== undefined ? Math.min(delay, Math.max(0, deadline - Date.now())) : delay,
1195
+ signal
1196
+ );
1197
+ if (signal?.aborted) {
1198
+ return rejectAndLeave(
1199
+ new AbloClaimedError(
1200
+ `The wait for the claim on ${targetLabel} was aborted before the grant arrived.`,
1201
+ { code: 'claim_wait_aborted' }
1202
+ )
1203
+ );
1204
+ }
1205
+ delay = GRANT_POLL_INTERVAL_MS * (0.85 + Math.random() * 0.3);
1206
+ // A lease that ended answers the beat with 409 `claim_lost`, which the
1207
+ // wire error mapping raises as AbloClaimedError before this reads
1208
+ // anything — the wait fails with the loss, as the socket wait does.
1209
+ const beat = await claims.heartbeat({ claimId });
1210
+ if (beat.status !== 'held') continue;
1211
+ // Granted. The heartbeat ack does not carry the fence token — the claim
1212
+ // state does, server-stamped at grant.
1213
+ const state = await claims.retrieve({ claimId });
1214
+ if (state.status !== 'active') {
1215
+ return rejectAndLeave(
1216
+ new AbloClaimedError(`Claim lost while queued for ${targetLabel}.`, {
1217
+ code: 'claim_lost',
1218
+ })
1219
+ );
1220
+ }
1221
+ return state.fenceToken !== undefined
1222
+ ? { id: claimId, fenceToken: state.fenceToken }
1223
+ : { id: claimId };
1224
+ }
1225
+ }
1226
+
1227
+ async function applyClaimedPolicy(
1228
+ target: Partial<ModelTarget>,
1229
+ options?: ClaimedOptions,
1230
+ defaultPolicy: ClaimedOptions['ifClaimed'] = 'return'
1231
+ ): Promise<void> {
1232
+ const policy = options?.ifClaimed ?? defaultPolicy;
1233
+ if (policy === 'return') return;
1234
+
1235
+ // policy === 'fail' — gate the read only when the caller opts in.
1236
+ const state = await listClaimState(target);
1237
+ if (state.active.length === 0) return;
1238
+ throw claimedError(target, state.active, 'model_claimed');
1239
+ }
1240
+
1241
+ const commits: CommitResource = {
1242
+ async create(commitOptions: CommitCreateOptions): Promise<CommitReceipt> {
1243
+ // Same runtime contract as every other write door — one schema.
1244
+ assertWriteOptions(
1245
+ {
1246
+ idempotencyKey: commitOptions.idempotencyKey,
1247
+ readAt: commitOptions.readAt,
1248
+ onStale: commitOptions.onStale,
1249
+ wait: commitOptions.wait,
1250
+ claim: commitOptions.claim,
1251
+ },
1252
+ 'commits.create'
1253
+ );
1254
+ const clientTxId = createClientTxId(commitOptions.idempotencyKey);
1255
+ // Same claim vocabulary as the WS client's `commits.create`: a handle
1256
+ // supplies the batch stale-guard defaults; explicit options win.
1257
+ const claim = commitOptions.claim ?? null;
1258
+ const operations = normalizeCommitOperations(
1259
+ {
1260
+ ...commitOptions,
1261
+ readAt: commitOptions.readAt ?? claim?.readAt ?? null,
1262
+ onStale: commitOptions.onStale ?? (claim?.readAt !== undefined ? 'reject' : null),
1263
+ },
1264
+ batchFence(claim?.target, claim?.fenceToken)
1265
+ );
1266
+ const requestBody = {
1267
+ operations,
1268
+ reads: commitOptions.reads,
1269
+ track: commitOptions.track,
1270
+ };
1271
+ const wait = commitOptions.wait ?? 'confirmed';
1272
+ let body: CommitResponse;
1273
+ try {
1274
+ body = await dispatchHttpCommit({
1275
+ path: '/v1/commits',
1276
+ method: 'POST',
1277
+ idempotencyKey: clientTxId,
1278
+ body: requestBody,
1279
+ wait,
1280
+ });
1281
+ } catch (error) {
1282
+ // Coordination collision over HTTP — surface it to observability on the
1283
+ // same footing as the WS transport, then rethrow unchanged. Fall back to
1284
+ // the ops we tried to write so the collision always names a row.
1285
+ recordCoordinationConflict(
1286
+ error,
1287
+ clientTxId,
1288
+ operations.map((o) => ({
1289
+ model: typeof o.model === 'string' ? o.model : 'unknown',
1290
+ id: typeof o.id === 'string' ? o.id : 'unknown',
1291
+ }))
1292
+ );
1293
+ throw error;
1294
+ }
1295
+
1296
+ // `requestJson` throws via `translateHttpError` on any non-2xx, so
1297
+ // reaching here implies success and `body` is already the success-only
1298
+ // receipt union — a rejection is a separate type that never arrives here.
1299
+ // The settlement status therefore passes through verbatim: no branch may
1300
+ // collapse a state the server reported into a different one.
1301
+ return {
1302
+ id: body.id ?? body.clientTxId,
1303
+ status: body.status,
1304
+ lastSyncId: body.lastSyncId,
1305
+ ...(body.notifications && body.notifications.length > 0
1306
+ ? { notifications: body.notifications }
1307
+ : {}),
1308
+ ...(body.missingIds && body.missingIds.length > 0 ? { missingIds: body.missingIds } : {}),
1309
+ };
1310
+ },
1311
+ };
1312
+
1313
+ const logs: HttpLogsResource = {
1314
+ list(logOptions = {}) {
1315
+ const params = new URLSearchParams();
1316
+ if (logOptions.after !== undefined) params.set('after', logOptions.after);
1317
+ if (logOptions.limit !== undefined) params.set('limit', String(logOptions.limit));
1318
+ if (logOptions.model !== undefined) params.set('model', logOptions.model);
1319
+ if (logOptions.op !== undefined) params.set('op', logOptions.op);
1320
+ if (logOptions.since !== undefined) params.set('since', logOptions.since);
1321
+ const query = params.size > 0 ? `?${params.toString()}` : '';
1322
+ return requestJson(
1323
+ `/v1/logs${query}`,
1324
+ {
1325
+ method: 'GET',
1326
+ ...(logOptions.signal ? { signal: logOptions.signal } : {}),
1327
+ },
1328
+ logListResponseSchema,
1329
+ );
1330
+ },
1331
+ };
1332
+
1333
+ async function listModel<T>(modelName: string, options?: ServerReadOptions<T>): Promise<T[]> {
1334
+ const params = new URLSearchParams();
1335
+ if (options?.limit !== undefined) params.set('limit', String(options.limit));
1336
+ if (options?.orderBy) {
1337
+ const [col, dir] = Object.entries(options.orderBy)[0] ?? [];
1338
+ if (col) {
1339
+ params.set('order_by', col);
1340
+ if (dir === 'desc') params.set('order', 'desc');
1341
+ }
1342
+ }
1343
+ // The collection route turns any non-reserved query param into an equality
1344
+ // filter (`?status=todo`). The wire is AND-only equality — matches what a
1345
+ // stateless reactor needs; richer predicates stay on the stateful path.
1346
+ if (options?.where && typeof options.where === 'object') {
1347
+ for (const [k, v] of Object.entries(options.where as Record<string, unknown>)) {
1348
+ if (v !== undefined && v !== null && typeof v !== 'object') params.set(k, String(v));
1349
+ }
1350
+ }
1351
+ const qs = params.toString();
1352
+ const res = await requestJson(
1353
+ `/v1/models/${encodeURIComponent(modelName)}${qs ? `?${qs}` : ''}`,
1354
+ { method: 'GET' },
1355
+ modelListResponseSchema
1356
+ );
1357
+ // The envelope is checked; the rows are not, and cannot be here. This
1358
+ // transport is schema-agnostic — it moves rows for whatever schema the
1359
+ // caller declared, and `T` is that declaration. Row validation belongs to
1360
+ // the typed facade above, which holds the model's schema.
1361
+ return res.data as T[];
1362
+ }
1363
+
1364
+ async function retrieveModel<T>(
1365
+ modelName: string,
1366
+ params: ModelReadOptions & { readonly id: string }
1367
+ ): Promise<HttpTransportRead<T>> {
1368
+ await applyClaimedPolicy({ model: modelName, id: params.id }, params);
1369
+
1370
+ const query = await requestJson(
1371
+ `/v1/models/${encodeURIComponent(modelName)}/${encodeURIComponent(params.id)}`,
1372
+ { method: 'GET' },
1373
+ modelReadResponseSchema
1374
+ );
1375
+
1376
+ // A miss is `data: undefined`, not a thrown error. The WebSocket client's
1377
+ // `retrieve` returns `T | undefined` for a missing row; throwing only here
1378
+ // made the obvious read ("does this row exist?") a hard edge that an agent
1379
+ // had to wrap in try/catch. Both transports agree: an absent row means absent
1380
+ // data. Callers branch on `.data` (the documented `.data?.x` usage).
1381
+ // Normalize a miss to `undefined` (the server may send `null` or omit it).
1382
+ // The row itself is the caller's declared type — see the note in `listModel`
1383
+ // on why this transport validates the envelope and not the row.
1384
+ const data = (query.data ?? undefined) as T | undefined;
1385
+
1386
+ return { data, stamp: query.stamp, claims: query.claims };
1387
+ }
1388
+
1389
+ /**
1390
+ * A single-operation mutation over the model-scoped routes — the canonical
1391
+ * surface that mirrors `ablo.<model>.create/update/delete`:
1392
+ *
1393
+ * POST /v1/models/:model create
1394
+ * PATCH /v1/models/:model/:id update
1395
+ * DELETE /v1/models/:model/:id delete
1396
+ *
1397
+ * The `commits.create(...)` resource remains the path for atomic
1398
+ * multi-operation envelopes; this helper handles the one-operation,
1399
+ * one-record case.
1400
+ */
1401
+ async function mutateModel(
1402
+ action: 'create' | 'update' | 'delete',
1403
+ modelName: string,
1404
+ id: string,
1405
+ data: Record<string, unknown> | undefined,
1406
+ options: ModelMutationOptions | undefined,
1407
+ beforeSettlement?: (response: CommitResponse) => Promise<void>
1408
+ ): Promise<CommitReceipt> {
1409
+ assertWriteOptions(
1410
+ options && {
1411
+ idempotencyKey: options.idempotencyKey,
1412
+ readAt: options.readAt,
1413
+ onStale: options.onStale,
1414
+ wait: options.wait,
1415
+ claim: options.claim,
1416
+ },
1417
+ `${modelName} ${action}`
1418
+ );
1419
+ const clientTxId = createClientTxId(options?.idempotencyKey);
1420
+ const encModel = encodeURIComponent(modelName);
1421
+ const path =
1422
+ action === 'create'
1423
+ ? `/v1/models/${encModel}`
1424
+ : `/v1/models/${encModel}/${encodeURIComponent(id)}`;
1425
+ const method = action === 'create' ? 'POST' : action === 'update' ? 'PATCH' : 'DELETE';
1426
+
1427
+ // A carried claim handle supplies the stale-guard defaults — one claim
1428
+ // vocabulary across the WS proxy, `commits.create`, and these routes.
1429
+ const rawClaim = options?.claim;
1430
+ const claimHandle =
1431
+ typeof rawClaim === 'object' &&
1432
+ rawClaim !== null &&
1433
+ (rawClaim as { object?: unknown }).object === 'claim' &&
1434
+ typeof (rawClaim as { id?: unknown }).id === 'string'
1435
+ ? (rawClaim as Claim)
1436
+ : undefined;
1437
+ const readAt = options?.readAt ?? claimHandle?.readAt;
1438
+ const requestBody: Record<string, unknown> = {
1439
+ claim: normalizeClaimId(options?.claimRef) ?? claimHandle?.id,
1440
+ onStale: options?.onStale ?? (claimHandle?.readAt !== undefined ? 'reject' : undefined),
1441
+ readAt,
1442
+ // The claim's fencing token (Option B), so the per-model HTTP write door
1443
+ // fences the same as the WS proxy and `commits.create`.
1444
+ fenceToken: options?.fenceToken ?? claimHandle?.fenceToken,
1445
+ };
1446
+ if (action === 'create') requestBody.id = id;
1447
+ if (data !== undefined) requestBody.data = data;
1448
+
1449
+ let body: CommitResponse;
1450
+ try {
1451
+ body = await dispatchHttpCommit(
1452
+ {
1453
+ path,
1454
+ method,
1455
+ idempotencyKey: clientTxId,
1456
+ body: requestBody,
1457
+ wait: options?.wait ?? 'confirmed',
1458
+ },
1459
+ beforeSettlement
1460
+ );
1461
+ } catch (error) {
1462
+ // The per-model write door (`ablo.<model>.update/create/delete`). Capture
1463
+ // coordination collisions here too; this single row is the fallback target.
1464
+ recordCoordinationConflict(error, clientTxId, [{ model: modelName, id }]);
1465
+ throw error;
1466
+ }
1467
+
1468
+ // Same contract as `commits.create` above: a non-2xx already threw, so
1469
+ // `body` is the success-only receipt union and its settlement status passes
1470
+ // through verbatim rather than through a catch-all branch.
1471
+ return {
1472
+ id: body.serverTxId,
1473
+ status: body.status,
1474
+ lastSyncId: body.lastSyncId,
1475
+ };
1476
+ }
1477
+
1478
+ function model<T = Record<string, unknown>, Fields = T>(
1479
+ name: string,
1480
+ ): HttpTransportModel<T, Fields> {
1481
+ // Durable lease + FIFO wait-line over HTTP (the existing claim routes). A
1482
+ // claim is server state, not a subscription — acquire/hold/release are plain
1483
+ // request/response, so a stateless agent participates in coordination too.
1484
+ const claimPath = (id: string): string =>
1485
+ `/v1/models/${encodeURIComponent(name)}/${encodeURIComponent(id)}/claim`;
1486
+ const isClaimHandle = (value: unknown): value is Claim<T> =>
1487
+ typeof value === 'object' &&
1488
+ value !== null &&
1489
+ (value as { object?: unknown }).object === 'claim' &&
1490
+ typeof (value as { id?: unknown }).id === 'string' &&
1491
+ typeof (value as { release?: unknown }).release === 'function';
1492
+ const acquireClaim = async (
1493
+ params: ClaimParams<Fields>
1494
+ ): Promise<{ id: string; fenceToken?: number }> => {
1495
+ // The row is named by the URL, so `target` carries only the narrowing a
1496
+ // claim adds below it. Sending it is what makes a field-scoped claim
1497
+ // actually field-scoped: the server's conflict rule reads `path`,
1498
+ // `range`, and `field`, so a claim that keeps them client-side takes a
1499
+ // lease on the whole row while its handle says otherwise.
1500
+ // Projected in one move rather than member by member. The member-by-member
1501
+ // version is how `field` came to be sent while `fields` was not, which
1502
+ // left a set-scoped claim silently holding the whole row.
1503
+ const narrowing: ClaimTargetBody = subTarget(params, name);
1504
+ // Typed as the request contract rather than a bare literal — the omission
1505
+ // above was invisible for exactly as long as this was an untyped object.
1506
+ const request: ClaimRequest = {
1507
+ description: claimDescription(params),
1508
+ ...(params.ttl !== undefined ? { ttl: params.ttl } : {}),
1509
+ // The caller's `meta` is the declared shape; the body is wire-shaped,
1510
+ // so it crosses through the same conversion `subTarget` used above.
1511
+ ...(params.meta !== undefined ? { meta: wireMeta(params.meta) } : {}),
1512
+ ...(Object.keys(narrowing).length > 0 ? { target: narrowing } : {}),
1513
+ // `queue` (default true) → queue behind the holder; false → fail-fast
1514
+ // with AbloClaimedError (work-distribution dedup).
1515
+ queue: params.queue ?? true,
1516
+ };
1517
+ const body = await requestJson(
1518
+ claimPath(params.id),
1519
+ { method: 'POST', body: JSON.stringify(request) },
1520
+ claimAcquireResponseSchema
1521
+ );
1522
+ // One resource, two states, discriminated by `status`. The queued arm
1523
+ // WAITS, exactly as the socket client does: `claim({ id })` means
1524
+ // "serialize me behind the holder" on every transport, and the grant
1525
+ // machinery is the SDK's to own, not a loop each caller re-derives.
1526
+ // (Being queued is still not an error — ADR 0018 — which is precisely
1527
+ // why it no longer surfaces as one here. The `claims` namespace remains
1528
+ // the manual ticket surface.)
1529
+ if (body.status === 'queued') {
1530
+ return awaitGrantOverHttp(`${name}/${params.id}`, body, params);
1531
+ }
1532
+ // The lease's own fields are mirrored at the top level, the same place
1533
+ // the poll puts them — one reader for both answers.
1534
+ return body.fenceToken !== undefined
1535
+ ? { id: body.id, fenceToken: body.fenceToken }
1536
+ : { id: body.id };
1537
+ };
1538
+ const releaseClaim = (
1539
+ params: ClaimLookupParams<T> | ClaimParams<Fields> | Claim<T>,
1540
+ ): Promise<void> =>
1541
+ requestRaw(claimPath(isClaimHandle(params) ? params.target.id : params.id), {
1542
+ method: 'DELETE',
1543
+ }).then(() => undefined);
1544
+
1545
+ // One beat on the held lease. A lapsed lease answers `claim_lost`
1546
+ // (409), which the wire error mapping surfaces as AbloClaimedError —
1547
+ // the definitive signal that stops the auto-heartbeat loop.
1548
+ const heartbeatClaim = async (
1549
+ id: string,
1550
+ claimId: string,
1551
+ options: ClaimHeartbeatOptions
1552
+ ): Promise<ClaimHeartbeat> => {
1553
+ const reply = await requestJson(
1554
+ `${claimPath(id)}/heartbeat`,
1555
+ {
1556
+ method: 'POST',
1557
+ body: JSON.stringify({
1558
+ claimId,
1559
+ ...(options.ttl !== undefined ? { ttl: options.ttl } : {}),
1560
+ ...(options.details !== undefined ? { details: options.details } : {}),
1561
+ }),
1562
+ },
1563
+ claimHeartbeatReplySchema
1564
+ );
1565
+ return heldHeartbeatReply(reply, `claim ${claimId} on ${name}/${id}`);
1566
+ };
1567
+
1568
+ function claimImpl(
1569
+ params: ClaimParams<Fields> & { queue: false }
1570
+ ): Promise<HeldClaim<T> | null>;
1571
+ function claimImpl(params: ClaimParams<Fields>): Promise<HeldClaim<T>>;
1572
+ async function claimImpl(
1573
+ params: ClaimParams<Fields>,
1574
+ ): Promise<HeldClaim<T> | null> {
1575
+ let acquired: { id: string; fenceToken?: number };
1576
+ try {
1577
+ acquired = await acquireClaim(params);
1578
+ } catch (error) {
1579
+ // The try-claim: a held target is an expected outcome of `queue:
1580
+ // false`, not an error — resolve `null` and let the caller move on.
1581
+ // Every other failure (auth, validation, network) stays a rejection,
1582
+ // and the write-site claim path calls `acquireClaim` directly, so a
1583
+ // write that could not claim still fails loudly.
1584
+ if (
1585
+ params.queue === false &&
1586
+ error instanceof AbloClaimedError &&
1587
+ (error.code === 'entity_claimed' || error.code === 'claim_conflict')
1588
+ ) {
1589
+ return null;
1590
+ }
1591
+ throw error;
1592
+ }
1593
+ const { id: claimId, fenceToken } = acquired;
1594
+ observability?.captureClaim({
1595
+ phase: 'acquired',
1596
+ claimId,
1597
+ model: name,
1598
+ id: params.id,
1599
+ description: claimDescription(params),
1600
+ });
1601
+ const { data, stamp } = await retrieveModel<T>(name, { id: params.id });
1602
+ // A held claim hands back a snapshot; the typed `HeldClaim.data` is `T`.
1603
+ // `retrieve` now reports a miss as `undefined` rather than throwing, but a
1604
+ // claim on a row that doesn't exist has nothing to hold — surface it.
1605
+ if (data === undefined) {
1606
+ throw new AbloNotFoundError(
1607
+ `Cannot claim ${name}/${params.id}: it does not exist (or is outside this credential's scope).`,
1608
+ [params.id]
1609
+ );
1610
+ }
1611
+ // One reading of the heartbeat options — cadence and callbacks from
1612
+ // whichever spelling the caller used (plan object, shorthand, or the
1613
+ // deprecated flat callbacks).
1614
+ const plan = resolveHeartbeatPlan(params);
1615
+ const heartbeat = async (
1616
+ beatOptions?: Duration | ClaimHeartbeatOptions
1617
+ ): Promise<ClaimHeartbeat> => {
1618
+ const resolved = resolveHeartbeatOptions(beatOptions);
1619
+ const beat = await heartbeatClaim(params.id, claimId, {
1620
+ ttl: resolved.ttl ?? params.ttl,
1621
+ ...(resolved.details !== undefined ? { details: resolved.details } : {}),
1622
+ });
1623
+ plan.onBeat?.(beat);
1624
+ return beat;
1625
+ };
1626
+
1627
+ // Opt-in auto-heartbeat — the background-worker cadence. The stateless
1628
+ // HTTP claim defaults to the server's acquire window when no TTL
1629
+ // was requested, so the default cadence lands at 20s beats.
1630
+ const stopHeartbeatLoop = plan.loop
1631
+ ? startClaimHeartbeatLoop({
1632
+ beat: () => heartbeat(),
1633
+ intervalMs: heartbeatCadenceMs(
1634
+ params.ttl !== undefined ? toMs(params.ttl) : DEFAULT_CLAIM_TTL_MS,
1635
+ plan.cadence
1636
+ ),
1637
+ ...(plan.onLost ? { onLost: plan.onLost } : {}),
1638
+ })
1639
+ : undefined;
1640
+
1641
+ const release = () => {
1642
+ stopHeartbeatLoop?.();
1643
+ return releaseClaim(params);
1644
+ };
1645
+ // The handle handed back is a public claim, so its `meta` is the declared
1646
+ // shape — the same crossing the two decodes above make, spelled the same
1647
+ // way. `subTarget` is wire-shaped by contract, including here, where the
1648
+ // value happens to have started out declared.
1649
+ const { meta, ...narrowed } = subTarget(params, name);
1650
+ return {
1651
+ object: 'claim',
1652
+ id: claimId,
1653
+ readAt: stamp,
1654
+ ...(fenceToken !== undefined ? { fenceToken } : {}),
1655
+ target: {
1656
+ ...streamTarget({ model: name, id: params.id }),
1657
+ ...narrowed,
1658
+ ...(meta !== undefined ? { meta: declaredMeta(meta) } : {}),
1659
+ },
1660
+ description: claimDescription(params),
1661
+ data,
1662
+ release,
1663
+ revoke: () => {
1664
+ void release().catch(() => {});
1665
+ },
1666
+ heartbeat,
1667
+ [Symbol.asyncDispose]: release,
1668
+ };
1669
+ }
1670
+ const claimsForEntity = (
1671
+ params: ClaimLookupParams<T>
1672
+ ): Promise<ClaimListResponse> =>
1673
+ requestJson(
1674
+ `/v1/claims?model=${encodeURIComponent(name)}&id=${encodeURIComponent(params.id)}${
1675
+ ''
1676
+ }`,
1677
+ { method: 'GET' },
1678
+ claimListResponseSchema
1679
+ );
1680
+ const claim = Object.assign(claimImpl, {
1681
+ release: releaseClaim,
1682
+ state: async (params: ClaimLookupParams<T>): Promise<Claim | null> => {
1683
+ const res = await claimsForEntity(params);
1684
+ // Holders come first in the one list; a `queued` entry is a waiter.
1685
+ const first = res.data.find((row) => row.status !== 'queued');
1686
+ return first ? claimFromModelClaim(first) : null;
1687
+ },
1688
+ queue: async (
1689
+ params: ClaimLookupParams<T>
1690
+ ): Promise<{ readonly object: 'list'; readonly data: readonly Claim[] }> => {
1691
+ const res = await claimsForEntity(params);
1692
+ return {
1693
+ object: 'list',
1694
+ data: res.data
1695
+ .filter((row) => row.status === 'queued')
1696
+ .map(claimFromModelClaim),
1697
+ };
1698
+ },
1699
+ reorder: async (params: ClaimReorderParams<T>): Promise<void> => {
1700
+ await requestRaw(`${claimPath(params.id)}/reorder`, {
1701
+ method: 'POST',
1702
+ // The reorder route's payload is `{ heldBy, claimId }[]` — a Claim's id
1703
+ // is the claimId.
1704
+ body: JSON.stringify({
1705
+ order: params.order.map((i) => ({ heldBy: i.heldBy, claimId: i.id })),
1706
+ }),
1707
+ });
1708
+ },
1709
+ }) as HttpClaimApi<T, Fields>;
1710
+
1711
+ const withMutationClaim = async <R>(
1712
+ id: string,
1713
+ input: ModelMutationOptions | undefined,
1714
+ run: (options: ModelMutationOptions | undefined) => Promise<R>
1715
+ ): Promise<R> => {
1716
+ const claimInput = input?.claim;
1717
+ if (!claimInput) return run(input);
1718
+
1719
+ if (isClaimHandle(claimInput)) {
1720
+ return run({ ...input, claimRef: { id: claimInput.id }, claim: undefined });
1721
+ }
1722
+
1723
+ // `isClaimHandle` ruled out the handle form above; the generic mismatch
1724
+ // (the union carries `Claim`, the guard narrows `Claim<T>`) keeps the
1725
+ // compiler from subtracting it, so narrow to the inline-options form.
1726
+ const { id: claimId, fenceToken } = await acquireClaim({
1727
+ id,
1728
+ ...(claimInput as ClaimOptions<Fields>),
1729
+ });
1730
+ try {
1731
+ return await run({
1732
+ ...input,
1733
+ claimRef: { id: claimId },
1734
+ ...(fenceToken !== undefined ? { fenceToken } : {}),
1735
+ claim: undefined,
1736
+ });
1737
+ } finally {
1738
+ await releaseClaim({ id }).catch(() => {});
1739
+ }
1740
+ };
1741
+
1742
+ // `update` is overloaded: the classic `update({ id, data })` and the
1743
+ // functional `update(id, current => next)`. Declared as a real overloaded
1744
+ // function (not an arrow assigned to the property) so the two public
1745
+ // signatures survive — the implementation's `| undefined` return is for the
1746
+ // updater's opt-out, hidden from the fixed-value form's callers.
1747
+ function updateModel(
1748
+ params: ModelMutationOptions & { readonly id: string; readonly data: Record<string, unknown> }
1749
+ ): Promise<CommitReceipt>;
1750
+ function updateModel(
1751
+ id: string,
1752
+ updater: ModelUpdater<T>,
1753
+ options?: ContentionOptions
1754
+ ): Promise<CommitReceipt | undefined>;
1755
+ function updateModel(
1756
+ arg:
1757
+ | (ModelMutationOptions & { readonly id: string; readonly data: Record<string, unknown> })
1758
+ | string,
1759
+ updater?: ModelUpdater<T>,
1760
+ contention?: ContentionOptions
1761
+ ): Promise<CommitReceipt | undefined> {
1762
+ // Functional form: update(id, current => next). The SDK owns the
1763
+ // read-fresh → compute → compare-and-swap → reconcile loop; correctness
1764
+ // rides on the row's watermark (readAt + onStale:'reject'), so no claim
1765
+ // or per-participant identity is needed and contention never clobbers.
1766
+ if (typeof arg === 'string') {
1767
+ const id = arg;
1768
+ if (typeof updater !== 'function') {
1769
+ throw new AbloValidationError(
1770
+ `${name}.update('${id}', updater): the second argument must be an updater ` +
1771
+ `function (current) => next. To write a fixed value, use update({ id, data }).`,
1772
+ { code: 'write_options_invalid' }
1773
+ );
1774
+ }
1775
+ return reconcileFunctionalUpdate<T, CommitReceipt>(updater, contention, {
1776
+ model: name,
1777
+ id,
1778
+ readFresh: async () => {
1779
+ const read = await retrieveModel<T>(name, { id });
1780
+ return { data: read.data, stamp: read.stamp };
1781
+ },
1782
+ writeNext: (patch, readAt) =>
1783
+ mutateModel('update', name, id, patch, {
1784
+ readAt,
1785
+ onStale: 'reject',
1786
+ wait: 'confirmed',
1787
+ }),
1788
+ });
1789
+ }
1790
+ const params = arg;
1791
+ return withMutationClaim(params.id, params, async (options) => {
1792
+ await applyClaimedPolicy({ model: name, id: params.id }, options);
1793
+ return mutateModel('update', name, params.id, params.data, options);
1794
+ });
1795
+ }
1796
+
1797
+ return {
1798
+ claim,
1799
+ get(params: ModelReadOptions & { readonly id: string }): Promise<HttpTransportRead<T>> {
1800
+ return retrieveModel<T>(name, params);
1801
+ },
1802
+ retrieve(params: ModelReadOptions & { readonly id: string }): Promise<HttpTransportRead<T>> {
1803
+ return retrieveModel<T>(name, params);
1804
+ },
1805
+ list(options?: ServerReadOptions<T>): Promise<T[]> {
1806
+ return listModel<T>(name, options);
1807
+ },
1808
+ async create(
1809
+ params: ModelMutationOptions & {
1810
+ readonly data: Record<string, unknown>;
1811
+ readonly id?: string | null;
1812
+ }
1813
+ ): Promise<T> {
1814
+ const id = params.id ?? createModelId(name, params.idempotencyKey);
1815
+ return withMutationClaim(id, params, async (options) => {
1816
+ await applyClaimedPolicy({ model: name, id }, options);
1817
+ // Confirm the write, then return the row — the obvious expectation of
1818
+ // "create" (the WebSocket client already returns the row). The read-
1819
+ // back is the authoritative server row, so it carries the framework
1820
+ // defaults (createdAt, createdBy, …) and, for an idempotent re-create of
1821
+ // an existing id, the existing row rather than the caller's input.
1822
+ let created: T | undefined;
1823
+ await mutateModel(
1824
+ 'create',
1825
+ name,
1826
+ id,
1827
+ params.data,
1828
+ {
1829
+ ...options,
1830
+ // This method returns the authoritative row, not a receipt. A
1831
+ // queued source acceptance cannot satisfy that return contract,
1832
+ // even when the caller supplied `wait: 'queued'`.
1833
+ wait: 'confirmed',
1834
+ },
1835
+ async () => {
1836
+ const read = await retrieveModel<T>(name, { id });
1837
+ if (read.data === undefined) {
1838
+ throw new AbloNotFoundError(
1839
+ `create ${name}/${id} did not yield a readable row (the write did not confirm).`,
1840
+ [id]
1841
+ );
1842
+ }
1843
+ created = read.data;
1844
+ }
1845
+ );
1846
+ if (created === undefined) {
1847
+ throw new AbloConnectionError('Create settlement did not return its row.', {
1848
+ code: 'commit_no_result',
1849
+ });
1850
+ }
1851
+ return created;
1852
+ });
1853
+ },
1854
+ update: updateModel,
1855
+ async delete(params: ModelMutationOptions & { readonly id: string }): Promise<CommitReceipt> {
1856
+ return withMutationClaim(params.id, params, async (options) => {
1857
+ await applyClaimedPolicy({ model: name, id: params.id }, options);
1858
+ return mutateModel('delete', name, params.id, undefined, options);
1859
+ });
1860
+ },
1861
+ async track(params: ModelTrackParams): Promise<ModelTrackResult> {
1862
+ const dependency: TrackDependency = {
1863
+ model: name.toLowerCase(),
1864
+ id: params.id,
1865
+ ...(params.readAt !== undefined ? { readAt: params.readAt } : {}),
1866
+ };
1867
+ // A track carries no write, so it rides the commit lane as a
1868
+ // zero-operation body — the shape `/v1/commits` accepts for registering
1869
+ // a premise without one. Going through the same durable lane as every
1870
+ // other commit means a disconnect replays the registration rather than
1871
+ // dropping it, and a notification that had already fired is not lost to
1872
+ // a retry.
1873
+ const body = await dispatchHttpCommit({
1874
+ path: '/v1/commits',
1875
+ method: 'POST',
1876
+ idempotencyKey: createClientTxId(),
1877
+ body: { track: [dependency] },
1878
+ wait: 'confirmed',
1879
+ });
1880
+ return body.notifications && body.notifications.length > 0
1881
+ ? { notifications: body.notifications }
1882
+ : {};
1883
+ },
1884
+ };
1885
+ }
1886
+
1887
+ return {
1888
+ ready,
1889
+ waitForFlush: () =>
1890
+ runInHttpCommitLane(async () => {
1891
+ await ready();
1892
+ const replayed = await replayHttpCommitOutbox();
1893
+ await confirmReplayedHttpCommits(replayed);
1894
+ }),
1895
+ async dispose() {},
1896
+ async purge() {},
1897
+ commits,
1898
+ claims,
1899
+ logs,
1900
+ model,
1901
+ sessions: {
1902
+ async create(params: CreateSessionParams<SchemaRecord>): Promise<AbloSession> {
1903
+ // Stateless mint: the configured key is the control-plane credential here
1904
+ // (no startup `rk_` exchange runs on this client). It reuses the resolved
1905
+ // base URL and fetch; the shared `mintSession` handles the two server routes.
1906
+ const apiKey = await resolveApiKeyValue(configuredApiKey);
1907
+ if (!apiKey) {
1908
+ throw new AbloAuthenticationError(
1909
+ 'sessions.create requires a secret (sk_) API key — call it from your backend, not the browser.',
1910
+ { code: 'apikey_missing' }
1911
+ );
1912
+ }
1913
+ // A transport built without a schema has no way to translate `can`'s
1914
+ // schema keys into the type names the server gates on. Minting anyway
1915
+ // would spell every override wrong and surface as
1916
+ // `capability_scope_denied` on the agent's first write, so refuse here
1917
+ // instead of guessing.
1918
+ if (!options.modelTypenames) {
1919
+ throw new AbloValidationError(
1920
+ 'sessions.create needs the schema this client is bound to. Construct it ' +
1921
+ "through Ablo({ schema, apiKey, transport: 'http' }) rather than the " +
1922
+ 'bare transport.',
1923
+ { code: 'invalid_options', param: 'schema' },
1924
+ );
1925
+ }
1926
+ return mintSession(params, {
1927
+ apiKey,
1928
+ baseUrl: apiBaseUrl,
1929
+ modelTypenames: options.modelTypenames,
1930
+ ...(options.fetch ? { fetch: options.fetch } : {}),
1931
+ });
1932
+ },
1933
+ async revoke({ id }) {
1934
+ const apiKey = await resolveApiKeyValue(configuredApiKey);
1935
+ return revokeCapability({
1936
+ apiKey: apiKey ?? '',
1937
+ baseUrl: apiBaseUrl,
1938
+ id,
1939
+ ...(options.fetch ? { fetch: options.fetch } : {}),
1940
+ });
1941
+ },
1942
+ async rotate({ id, graceSeconds, ttlSeconds }) {
1943
+ const apiKey = await resolveApiKeyValue(configuredApiKey);
1944
+ return rotateCapability({
1945
+ apiKey: apiKey ?? '',
1946
+ baseUrl: apiBaseUrl,
1947
+ id,
1948
+ ...(graceSeconds !== undefined ? { graceSeconds } : {}),
1949
+ ...(ttlSeconds !== undefined ? { ttlSeconds } : {}),
1950
+ ...(options.fetch ? { fetch: options.fetch } : {}),
1951
+ });
1952
+ },
1953
+ },
1954
+ async getAuthToken(): Promise<string | null> {
1955
+ // Mirror `authHeaders()`: a configured API key wins, else the
1956
+ // construction-time auth token. Resolve the (possibly async) key setter.
1957
+ return (await resolveApiKeyValue(configuredApiKey)) ?? configuredAuthToken ?? null;
1958
+ },
1959
+ };
1960
+ }
1961
+
1962
+ function normalizeClaimId(
1963
+ claim: string | { readonly id: string } | null | undefined
1964
+ ): string | undefined {
1965
+ if (typeof claim === 'string') return claim;
1966
+ return claim?.id;
1967
+ }
1968
+
1969
+ function parseBody(bodyText: string): unknown {
1970
+ if (bodyText.length === 0) return null;
1971
+ try {
1972
+ return JSON.parse(bodyText);
1973
+ } catch {
1974
+ return bodyText;
1975
+ }
1976
+ }