@adonis-agora/durable 0.49.1 → 0.50.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 (324) hide show
  1. package/CHANGELOG.md +251 -0
  2. package/README.md +53 -22
  3. package/dist/assets/spa/assets/index-DupjrK7N.js +16 -0
  4. package/dist/assets/spa/assets/index-DupjrK7N.js.map +1 -0
  5. package/dist/assets/spa/index.html +1 -1
  6. package/dist/commands/durable_worker.d.ts.map +1 -1
  7. package/dist/commands/durable_worker.js +9 -8
  8. package/dist/commands/durable_worker.js.map +1 -1
  9. package/dist/configure.d.ts.map +1 -1
  10. package/dist/configure.js +1 -0
  11. package/dist/configure.js.map +1 -1
  12. package/dist/providers/dashboard_provider.d.ts +2 -0
  13. package/dist/providers/dashboard_provider.d.ts.map +1 -1
  14. package/dist/providers/dashboard_provider.js +50 -6
  15. package/dist/providers/dashboard_provider.js.map +1 -1
  16. package/dist/providers/durable_provider.d.ts.map +1 -1
  17. package/dist/providers/durable_provider.js +143 -34
  18. package/dist/providers/durable_provider.js.map +1 -1
  19. package/dist/src/admission-redis/redis-admission-backend.d.ts.map +1 -1
  20. package/dist/src/admission-redis/redis-admission-backend.js +3 -1
  21. package/dist/src/admission-redis/redis-admission-backend.js.map +1 -1
  22. package/dist/src/ambient-step.d.ts +24 -0
  23. package/dist/src/ambient-step.d.ts.map +1 -0
  24. package/dist/src/ambient-step.js +42 -0
  25. package/dist/src/ambient-step.js.map +1 -0
  26. package/dist/src/base-workflow.d.ts +10 -1
  27. package/dist/src/base-workflow.d.ts.map +1 -1
  28. package/dist/src/base-workflow.js.map +1 -1
  29. package/dist/src/codec-state-store.d.ts +28 -2
  30. package/dist/src/codec-state-store.d.ts.map +1 -1
  31. package/dist/src/codec-state-store.js +61 -3
  32. package/dist/src/codec-state-store.js.map +1 -1
  33. package/dist/src/commands/worker.d.ts +4 -1
  34. package/dist/src/commands/worker.d.ts.map +1 -1
  35. package/dist/src/commands/worker.js +27 -3
  36. package/dist/src/commands/worker.js.map +1 -1
  37. package/dist/src/config_types.d.ts +6 -0
  38. package/dist/src/config_types.d.ts.map +1 -1
  39. package/dist/src/control-plane-redis/redis-control-plane.d.ts.map +1 -1
  40. package/dist/src/control-plane-redis/redis-control-plane.js +22 -6
  41. package/dist/src/control-plane-redis/redis-control-plane.js.map +1 -1
  42. package/dist/src/control-planes/factory.d.ts +6 -0
  43. package/dist/src/control-planes/factory.d.ts.map +1 -1
  44. package/dist/src/control-planes/factory.js +1 -0
  45. package/dist/src/control-planes/factory.js.map +1 -1
  46. package/dist/src/dashboard/gateway-adapter.d.ts +15 -5
  47. package/dist/src/dashboard/gateway-adapter.d.ts.map +1 -1
  48. package/dist/src/dashboard/gateway-adapter.js +71 -7
  49. package/dist/src/dashboard/gateway-adapter.js.map +1 -1
  50. package/dist/src/dashboard/handlers.d.ts +53 -1
  51. package/dist/src/dashboard/handlers.d.ts.map +1 -1
  52. package/dist/src/dashboard/handlers.js +200 -20
  53. package/dist/src/dashboard/handlers.js.map +1 -1
  54. package/dist/src/dashboard/index.d.ts +2 -2
  55. package/dist/src/dashboard/index.d.ts.map +1 -1
  56. package/dist/src/dashboard/index.js +2 -2
  57. package/dist/src/dashboard/index.js.map +1 -1
  58. package/dist/src/dashboard/openapi.d.ts.map +1 -1
  59. package/dist/src/dashboard/openapi.js +71 -1
  60. package/dist/src/dashboard/openapi.js.map +1 -1
  61. package/dist/src/define_config.d.ts +122 -8
  62. package/dist/src/define_config.d.ts.map +1 -1
  63. package/dist/src/define_config.js.map +1 -1
  64. package/dist/src/diagnostics-bridge.d.ts +16 -3
  65. package/dist/src/diagnostics-bridge.d.ts.map +1 -1
  66. package/dist/src/diagnostics-bridge.js +9 -3
  67. package/dist/src/diagnostics-bridge.js.map +1 -1
  68. package/dist/src/discovery-roots.d.ts +92 -0
  69. package/dist/src/discovery-roots.d.ts.map +1 -0
  70. package/dist/src/discovery-roots.js +150 -0
  71. package/dist/src/discovery-roots.js.map +1 -0
  72. package/dist/src/engine.d.ts +418 -20
  73. package/dist/src/engine.d.ts.map +1 -1
  74. package/dist/src/engine.js +1523 -159
  75. package/dist/src/engine.js.map +1 -1
  76. package/dist/src/entities.d.ts +36 -1
  77. package/dist/src/entities.d.ts.map +1 -1
  78. package/dist/src/entities.js +65 -4
  79. package/dist/src/entities.js.map +1 -1
  80. package/dist/src/errors.d.ts +36 -0
  81. package/dist/src/errors.d.ts.map +1 -1
  82. package/dist/src/errors.js +44 -0
  83. package/dist/src/errors.js.map +1 -1
  84. package/dist/src/event-trigger-bridge.d.ts +8 -0
  85. package/dist/src/event-trigger-bridge.d.ts.map +1 -1
  86. package/dist/src/event-trigger-bridge.js +24 -3
  87. package/dist/src/event-trigger-bridge.js.map +1 -1
  88. package/dist/src/handshake/announced.d.ts +27 -0
  89. package/dist/src/handshake/announced.d.ts.map +1 -1
  90. package/dist/src/handshake/announced.js +62 -0
  91. package/dist/src/handshake/announced.js.map +1 -1
  92. package/dist/src/handshake/directory.d.ts +92 -0
  93. package/dist/src/handshake/directory.d.ts.map +1 -0
  94. package/dist/src/handshake/directory.js +139 -0
  95. package/dist/src/handshake/directory.js.map +1 -0
  96. package/dist/src/hooks/barrel-sources.d.ts +28 -0
  97. package/dist/src/hooks/barrel-sources.d.ts.map +1 -0
  98. package/dist/src/hooks/barrel-sources.js +92 -0
  99. package/dist/src/hooks/barrel-sources.js.map +1 -0
  100. package/dist/src/hooks/steps.d.ts +12 -3
  101. package/dist/src/hooks/steps.d.ts.map +1 -1
  102. package/dist/src/hooks/steps.js +7 -11
  103. package/dist/src/hooks/steps.js.map +1 -1
  104. package/dist/src/hooks/workflows.d.ts +12 -3
  105. package/dist/src/hooks/workflows.d.ts.map +1 -1
  106. package/dist/src/hooks/workflows.js +7 -13
  107. package/dist/src/hooks/workflows.js.map +1 -1
  108. package/dist/src/index.d.ts +12 -3
  109. package/dist/src/index.d.ts.map +1 -1
  110. package/dist/src/index.js +12 -3
  111. package/dist/src/index.js.map +1 -1
  112. package/dist/src/interfaces.d.ts +309 -21
  113. package/dist/src/interfaces.d.ts.map +1 -1
  114. package/dist/src/interfaces.js +10 -0
  115. package/dist/src/interfaces.js.map +1 -1
  116. package/dist/src/metrics.d.ts.map +1 -1
  117. package/dist/src/metrics.js +5 -0
  118. package/dist/src/metrics.js.map +1 -1
  119. package/dist/src/namespaces.d.ts +17 -0
  120. package/dist/src/namespaces.d.ts.map +1 -0
  121. package/dist/src/namespaces.js +24 -0
  122. package/dist/src/namespaces.js.map +1 -0
  123. package/dist/src/otel/durable-metrics.d.ts.map +1 -1
  124. package/dist/src/otel/durable-metrics.js +5 -0
  125. package/dist/src/otel/durable-metrics.js.map +1 -1
  126. package/dist/src/otel/durable-otel.d.ts.map +1 -1
  127. package/dist/src/otel/durable-otel.js +5 -0
  128. package/dist/src/otel/durable-otel.js.map +1 -1
  129. package/dist/src/pollers.d.ts +10 -1
  130. package/dist/src/pollers.d.ts.map +1 -1
  131. package/dist/src/pollers.js +15 -5
  132. package/dist/src/pollers.js.map +1 -1
  133. package/dist/src/protocol.d.ts +12 -0
  134. package/dist/src/protocol.d.ts.map +1 -1
  135. package/dist/src/protocol.js +27 -1
  136. package/dist/src/protocol.js.map +1 -1
  137. package/dist/src/remote-event.d.ts +6 -0
  138. package/dist/src/remote-event.d.ts.map +1 -0
  139. package/dist/src/remote-event.js +19 -0
  140. package/dist/src/remote-event.js.map +1 -0
  141. package/dist/src/remote-workflow-executor.d.ts +34 -12
  142. package/dist/src/remote-workflow-executor.d.ts.map +1 -1
  143. package/dist/src/remote-workflow-executor.js +42 -40
  144. package/dist/src/remote-workflow-executor.js.map +1 -1
  145. package/dist/src/run-detail-summary.d.ts +90 -3
  146. package/dist/src/run-detail-summary.d.ts.map +1 -1
  147. package/dist/src/run-detail-summary.js +360 -2
  148. package/dist/src/run-detail-summary.js.map +1 -1
  149. package/dist/src/run-facets.d.ts +48 -0
  150. package/dist/src/run-facets.d.ts.map +1 -0
  151. package/dist/src/run-facets.js +100 -0
  152. package/dist/src/run-facets.js.map +1 -0
  153. package/dist/src/run-gateway/interface.d.ts +27 -1
  154. package/dist/src/run-gateway/interface.d.ts.map +1 -1
  155. package/dist/src/run-gateway/proxy-run-gateway.d.ts +32 -4
  156. package/dist/src/run-gateway/proxy-run-gateway.d.ts.map +1 -1
  157. package/dist/src/run-gateway/proxy-run-gateway.js +101 -5
  158. package/dist/src/run-gateway/proxy-run-gateway.js.map +1 -1
  159. package/dist/src/run-gateway/run-request-responder.d.ts +18 -5
  160. package/dist/src/run-gateway/run-request-responder.d.ts.map +1 -1
  161. package/dist/src/run-gateway/run-request-responder.js +155 -32
  162. package/dist/src/run-gateway/run-request-responder.js.map +1 -1
  163. package/dist/src/run-gateway/store-run-gateway.d.ts +23 -1
  164. package/dist/src/run-gateway/store-run-gateway.d.ts.map +1 -1
  165. package/dist/src/run-gateway/store-run-gateway.js +38 -0
  166. package/dist/src/run-gateway/store-run-gateway.js.map +1 -1
  167. package/dist/src/search-attributes-schema.d.ts +44 -0
  168. package/dist/src/search-attributes-schema.d.ts.map +1 -0
  169. package/dist/src/search-attributes-schema.js +51 -0
  170. package/dist/src/search-attributes-schema.js.map +1 -0
  171. package/dist/src/singleton-admission.d.ts +75 -0
  172. package/dist/src/singleton-admission.d.ts.map +1 -0
  173. package/dist/src/singleton-admission.js +156 -0
  174. package/dist/src/singleton-admission.js.map +1 -0
  175. package/dist/src/singleton-gate.d.ts +23 -8
  176. package/dist/src/singleton-gate.d.ts.map +1 -1
  177. package/dist/src/singleton-gate.js +59 -12
  178. package/dist/src/singleton-gate.js.map +1 -1
  179. package/dist/src/standard-schema.d.ts +83 -0
  180. package/dist/src/standard-schema.d.ts.map +1 -0
  181. package/dist/src/standard-schema.js +31 -0
  182. package/dist/src/standard-schema.js.map +1 -0
  183. package/dist/src/step-discovery.d.ts +21 -11
  184. package/dist/src/step-discovery.d.ts.map +1 -1
  185. package/dist/src/step-discovery.js +86 -45
  186. package/dist/src/step-discovery.js.map +1 -1
  187. package/dist/src/step-ref.d.ts +38 -12
  188. package/dist/src/step-ref.d.ts.map +1 -1
  189. package/dist/src/step-ref.js +5 -13
  190. package/dist/src/step-ref.js.map +1 -1
  191. package/dist/src/stores/factory.d.ts +6 -0
  192. package/dist/src/stores/factory.d.ts.map +1 -1
  193. package/dist/src/stores/factory.js +4 -1
  194. package/dist/src/stores/factory.js.map +1 -1
  195. package/dist/src/stores/lucid-helpers.d.ts +2 -0
  196. package/dist/src/stores/lucid-helpers.d.ts.map +1 -1
  197. package/dist/src/stores/lucid-helpers.js +7 -0
  198. package/dist/src/stores/lucid-helpers.js.map +1 -1
  199. package/dist/src/stores/lucid-mappers.d.ts +25 -0
  200. package/dist/src/stores/lucid-mappers.d.ts.map +1 -1
  201. package/dist/src/stores/lucid-mappers.js +81 -18
  202. package/dist/src/stores/lucid-mappers.js.map +1 -1
  203. package/dist/src/stores/lucid-schema.d.ts +59 -1
  204. package/dist/src/stores/lucid-schema.d.ts.map +1 -1
  205. package/dist/src/stores/lucid-schema.js +271 -25
  206. package/dist/src/stores/lucid-schema.js.map +1 -1
  207. package/dist/src/stores/lucid.d.ts +61 -2
  208. package/dist/src/stores/lucid.d.ts.map +1 -1
  209. package/dist/src/stores/lucid.js +346 -41
  210. package/dist/src/stores/lucid.js.map +1 -1
  211. package/dist/src/telescope/dashboard.d.ts.map +1 -1
  212. package/dist/src/telescope/dashboard.js +30 -1
  213. package/dist/src/telescope/dashboard.js.map +1 -1
  214. package/dist/src/telescope/data-providers.d.ts +19 -1
  215. package/dist/src/telescope/data-providers.d.ts.map +1 -1
  216. package/dist/src/telescope/data-providers.js +119 -3
  217. package/dist/src/telescope/data-providers.js.map +1 -1
  218. package/dist/src/telescope/extension.d.ts.map +1 -1
  219. package/dist/src/telescope/extension.js +2 -1
  220. package/dist/src/telescope/extension.js.map +1 -1
  221. package/dist/src/telescope/index.d.ts +1 -1
  222. package/dist/src/telescope/index.d.ts.map +1 -1
  223. package/dist/src/telescope/index.js +1 -1
  224. package/dist/src/telescope/index.js.map +1 -1
  225. package/dist/src/testing/in-memory-state-store.d.ts +44 -15
  226. package/dist/src/testing/in-memory-state-store.d.ts.map +1 -1
  227. package/dist/src/testing/in-memory-state-store.js +204 -41
  228. package/dist/src/testing/in-memory-state-store.js.map +1 -1
  229. package/dist/src/testing-kit/checkpoint-aggregation-contract.d.ts +16 -0
  230. package/dist/src/testing-kit/checkpoint-aggregation-contract.d.ts.map +1 -0
  231. package/dist/src/testing-kit/checkpoint-aggregation-contract.js +228 -0
  232. package/dist/src/testing-kit/checkpoint-aggregation-contract.js.map +1 -0
  233. package/dist/src/testing-kit/state-store-conformance.d.ts.map +1 -1
  234. package/dist/src/testing-kit/state-store-conformance.js +355 -3
  235. package/dist/src/testing-kit/state-store-conformance.js.map +1 -1
  236. package/dist/src/transport-pool.d.ts +15 -1
  237. package/dist/src/transport-pool.d.ts.map +1 -1
  238. package/dist/src/transport-pool.js +50 -4
  239. package/dist/src/transport-pool.js.map +1 -1
  240. package/dist/src/transports/adaptive-concurrency.d.ts +168 -0
  241. package/dist/src/transports/adaptive-concurrency.d.ts.map +1 -0
  242. package/dist/src/transports/adaptive-concurrency.js +353 -0
  243. package/dist/src/transports/adaptive-concurrency.js.map +1 -0
  244. package/dist/src/transports/bullmq/adaptive-concurrency.d.ts +1 -165
  245. package/dist/src/transports/bullmq/adaptive-concurrency.d.ts.map +1 -1
  246. package/dist/src/transports/bullmq/adaptive-concurrency.js +3 -351
  247. package/dist/src/transports/bullmq/adaptive-concurrency.js.map +1 -1
  248. package/dist/src/transports/bullmq/bullmq-transport.d.ts +49 -1
  249. package/dist/src/transports/bullmq/bullmq-transport.d.ts.map +1 -1
  250. package/dist/src/transports/bullmq/bullmq-transport.js +330 -38
  251. package/dist/src/transports/bullmq/bullmq-transport.js.map +1 -1
  252. package/dist/src/transports/bullmq/deps.d.ts +18 -2
  253. package/dist/src/transports/bullmq/deps.d.ts.map +1 -1
  254. package/dist/src/transports/bullmq/deps.js +44 -2
  255. package/dist/src/transports/bullmq/deps.js.map +1 -1
  256. package/dist/src/transports/db-helpers.d.ts +39 -0
  257. package/dist/src/transports/db-helpers.d.ts.map +1 -1
  258. package/dist/src/transports/db-helpers.js +73 -0
  259. package/dist/src/transports/db-helpers.js.map +1 -1
  260. package/dist/src/transports/db-remote.d.ts +53 -0
  261. package/dist/src/transports/db-remote.d.ts.map +1 -0
  262. package/dist/src/transports/db-remote.js +147 -0
  263. package/dist/src/transports/db-remote.js.map +1 -0
  264. package/dist/src/transports/db-schema.d.ts +44 -1
  265. package/dist/src/transports/db-schema.d.ts.map +1 -1
  266. package/dist/src/transports/db-schema.js +227 -88
  267. package/dist/src/transports/db-schema.js.map +1 -1
  268. package/dist/src/transports/db.d.ts +189 -6
  269. package/dist/src/transports/db.d.ts.map +1 -1
  270. package/dist/src/transports/db.js +1291 -137
  271. package/dist/src/transports/db.js.map +1 -1
  272. package/dist/src/transports/event-emitter.d.ts +8 -0
  273. package/dist/src/transports/event-emitter.d.ts.map +1 -1
  274. package/dist/src/transports/event-emitter.js +36 -3
  275. package/dist/src/transports/event-emitter.js.map +1 -1
  276. package/dist/src/transports/factory.d.ts +110 -5
  277. package/dist/src/transports/factory.d.ts.map +1 -1
  278. package/dist/src/transports/factory.js +56 -9
  279. package/dist/src/transports/factory.js.map +1 -1
  280. package/dist/src/transports/queue-mock-adapter.d.ts +7 -2
  281. package/dist/src/transports/queue-mock-adapter.d.ts.map +1 -1
  282. package/dist/src/transports/queue-mock-adapter.js +18 -2
  283. package/dist/src/transports/queue-mock-adapter.js.map +1 -1
  284. package/dist/src/transports/queue-remote.d.ts +146 -0
  285. package/dist/src/transports/queue-remote.d.ts.map +1 -0
  286. package/dist/src/transports/queue-remote.js +398 -0
  287. package/dist/src/transports/queue-remote.js.map +1 -0
  288. package/dist/src/transports/queue.d.ts +159 -6
  289. package/dist/src/transports/queue.d.ts.map +1 -1
  290. package/dist/src/transports/queue.js +747 -37
  291. package/dist/src/transports/queue.js.map +1 -1
  292. package/dist/src/transports/task-failure.d.ts +31 -0
  293. package/dist/src/transports/task-failure.d.ts.map +1 -0
  294. package/dist/src/transports/task-failure.js +50 -0
  295. package/dist/src/transports/task-failure.js.map +1 -0
  296. package/dist/src/workflow-als.d.ts +1 -1
  297. package/dist/src/workflow-als.d.ts.map +1 -1
  298. package/dist/src/workflow-ctx.d.ts +10 -0
  299. package/dist/src/workflow-ctx.d.ts.map +1 -1
  300. package/dist/src/workflow-ctx.js +97 -15
  301. package/dist/src/workflow-ctx.js.map +1 -1
  302. package/dist/src/workflow-discovery.d.ts +32 -41
  303. package/dist/src/workflow-discovery.d.ts.map +1 -1
  304. package/dist/src/workflow-discovery.js +65 -88
  305. package/dist/src/workflow-discovery.js.map +1 -1
  306. package/dist/src/workflow-ref.d.ts +24 -0
  307. package/dist/src/workflow-ref.d.ts.map +1 -1
  308. package/dist/src/workflow-ref.js.map +1 -1
  309. package/dist/stubs/config/durable.stub +6 -0
  310. package/dist/stubs/config/durable_dashboard.stub +7 -3
  311. package/dist/stubs/database/migrations/create_durable_tables.stub +2 -1
  312. package/dist/stubs/database/migrations/create_durable_transport_messages.stub +43 -0
  313. package/dist/stubs/database/migrations/create_durable_transport_tables.stub +12 -9
  314. package/dist/stubs/database/migrations/create_durable_transport_worker_keys.stub +1 -1
  315. package/dist/stubs/make/workflow/main.stub +1 -1
  316. package/package.json +6 -4
  317. package/skills/durable-cluster/SKILL.md +80 -11
  318. package/skills/durable-determinism/SKILL.md +11 -5
  319. package/skills/durable-reliability/SKILL.md +77 -37
  320. package/skills/durable-setup/SKILL.md +51 -22
  321. package/skills/durable-transports-stores/SKILL.md +128 -18
  322. package/skills/durable-workflows/SKILL.md +71 -18
  323. package/dist/assets/spa/assets/index-DdB9zxuX.js +0 -16
  324. package/dist/assets/spa/assets/index-DdB9zxuX.js.map +0 -1
@@ -1,5 +1,6 @@
1
1
  import { beforeEach, describe, expect, it } from 'vitest';
2
2
  import { WorkflowEngine, } from '../index.js';
3
+ import { registerCheckpointAggregationCases } from './checkpoint-aggregation-contract.js';
3
4
  /**
4
5
  * Thrown by a {@link StateStoreFactory} to SKIP the contract for an unavailable backend instead of
5
6
  * failing it — e.g. a testcontainers DB spec when Docker isn't running. Every contract case catches
@@ -85,13 +86,14 @@ export function runStateStoreContract(name, makeStore) {
85
86
  await cleanup?.();
86
87
  };
87
88
  });
88
- /** Register a contract case that no-ops (passes) when the backend was reported unavailable. */
89
- const t = (label, fn) => {
89
+ /** Register a contract case that no-ops (passes) when the backend was reported unavailable.
90
+ * `timeoutMs` widens the runner's per-test timeout for a case that does many round-trips. */
91
+ const t = (label, fn, timeoutMs) => {
90
92
  it(label, async () => {
91
93
  if (skipReason)
92
94
  return;
93
95
  await fn();
94
- });
96
+ }, timeoutMs);
95
97
  };
96
98
  // ---- create / get / update --------------------------------------------------------------
97
99
  t('creates a run and reads it back with its JSON input', async () => {
@@ -168,6 +170,14 @@ export function runStateStoreContract(name, makeStore) {
168
170
  expect(r?.lockedBy).toBe('owner-2');
169
171
  expect(r?.wakeAt).toBe(123_456);
170
172
  expect(r?.lockedUntil).toBe(222_222);
173
+ // The remote-turn decision marker must round-trip AND clear: the engine sets it when it
174
+ // suspends on a dispatched turn and clears it (`undefined`) when the decision is applied. A
175
+ // store that drops it on read, or ignores `undefined` instead of writing NULL, makes every
176
+ // replica discard the decision (it reads back a stale or absent marker).
177
+ await store.updateRun('r1', { awaitingDecisionTaskId: 'r1:wf:turn-7' });
178
+ expect((await store.getRun('r1'))?.awaitingDecisionTaskId).toBe('r1:wf:turn-7');
179
+ expect(await store.updateRunIf('r1', ['completed'], { awaitingDecisionTaskId: undefined })).toBe(true);
180
+ expect((await store.getRun('r1'))?.awaitingDecisionTaskId).toBeUndefined();
171
181
  });
172
182
  // ---- updateRunIf (conditional / compare-and-set) ----------------------------------------
173
183
  t('updateRunIf applies the patch and returns true when the current status is expected', async () => {
@@ -216,6 +226,150 @@ export function runStateStoreContract(name, makeStore) {
216
226
  expect(after?.status).toBe('cancelled');
217
227
  expect(after?.output).toBeUndefined();
218
228
  });
229
+ // ---- tryAdmitSingleton (atomic singleton slots) -----------------------------------------
230
+ // Optional SPI: a store without it can't run singleton workflows, so these cases pass vacuously.
231
+ const ADMITTED = 'durable:singleton:admitted';
232
+ const TRACKED = 'durable:singleton:tracked';
233
+ const gated = (id, createdMs, over = {}) => run({
234
+ id,
235
+ workflow: 'job',
236
+ status: 'running',
237
+ // `running` and tracked: the engine stamps the tracked marker on a singleton run's first
238
+ // execution, before it claims (an untracked, untagged running run reads as a pre-0.51 holder).
239
+ tags: ['singleton:k', TRACKED],
240
+ createdAt: new Date(createdMs),
241
+ updatedAt: new Date(createdMs),
242
+ ...over,
243
+ });
244
+ t('tryAdmitSingleton admits the oldest waiter and persists the slot in its tags', async () => {
245
+ if (!store.tryAdmitSingleton)
246
+ return;
247
+ await store.createRun(gated('a', 1000));
248
+ await store.createRun(gated('b', 2000));
249
+ expect(await store.tryAdmitSingleton('b', 'singleton:k', 'job', 1, 5000)).toBe(false);
250
+ expect(await store.tryAdmitSingleton('a', 'singleton:k', 'job', 1, 5000)).toBe(true);
251
+ expect((await store.getRun('a'))?.tags).toEqual(['singleton:k', ADMITTED]);
252
+ // The denied waiter was parked on its retry timer, in the same claim.
253
+ const parked = await store.getRun('b');
254
+ expect(parked?.status).toBe('suspended');
255
+ expect(parked?.wakeAt).toBe(5000);
256
+ // A holder re-checking (a resumed turn) keeps its slot.
257
+ expect(await store.tryAdmitSingleton('a', 'singleton:k', 'job', 1, 5000)).toBe(true);
258
+ });
259
+ t('tryAdmitSingleton never lets a later or same-instant run displace a holder', async () => {
260
+ if (!store.tryAdmitSingleton)
261
+ return;
262
+ await store.createRun(gated('z-holder', 1000, { tags: ['singleton:k', ADMITTED] }));
263
+ // Same createdAt, smaller id: it would sort first in a positional scan.
264
+ await store.createRun(gated('a-waiter', 1000));
265
+ expect(await store.tryAdmitSingleton('a-waiter', 'singleton:k', 'job', 1)).toBe(false);
266
+ expect((await store.getRun('a-waiter'))?.tags).toEqual(['singleton:k', TRACKED]);
267
+ // A second slot goes to the waiter.
268
+ expect(await store.tryAdmitSingleton('a-waiter', 'singleton:k', 'job', 2)).toBe(true);
269
+ });
270
+ t('tryAdmitSingleton scopes slots to the workflow and the key', async () => {
271
+ if (!store.tryAdmitSingleton)
272
+ return;
273
+ await store.createRun(gated('other-key', 1000, { tags: ['singleton:x', ADMITTED] }));
274
+ await store.createRun(gated('other-wf', 1000, { workflow: 'job2', tags: ['singleton:k', ADMITTED] }));
275
+ await store.createRun(gated('mine', 2000));
276
+ expect(await store.tryAdmitSingleton('mine', 'singleton:k', 'job', 1)).toBe(true);
277
+ });
278
+ t('tryAdmitSingleton never parks a run a concurrent cancel already settled', async () => {
279
+ if (!store.tryAdmitSingleton)
280
+ return;
281
+ await store.createRun(gated('holder', 1000, { status: 'suspended', tags: ['singleton:k', ADMITTED] }));
282
+ await store.createRun(gated('cancelled', 2000, { status: 'cancelled' }));
283
+ expect(await store.tryAdmitSingleton('cancelled', 'singleton:k', 'job', 1, 5000)).toBe(false);
284
+ const after = await store.getRun('cancelled');
285
+ expect(after?.status).toBe('cancelled');
286
+ expect(after?.wakeAt).toBeUndefined();
287
+ expect(await store.tryAdmitSingleton('missing', 'singleton:k', 'job', 1, 5000)).toBe(false);
288
+ });
289
+ t('a terminal settle releases the slot; non-terminal writes keep it', async () => {
290
+ if (!store.tryAdmitSingleton)
291
+ return;
292
+ await store.createRun(gated('a', 1000));
293
+ expect(await store.tryAdmitSingleton('a', 'singleton:k', 'job', 1)).toBe(true);
294
+ await store.updateRun('a', { status: 'suspended', wakeAt: 9000 });
295
+ expect((await store.getRun('a'))?.tags).toContain(ADMITTED);
296
+ await store.updateRun('a', { status: 'failed', error: { message: 'x' } });
297
+ expect((await store.getRun('a'))?.tags).toEqual(['singleton:k']);
298
+ await store.createRun(gated('b', 2000));
299
+ expect(await store.tryAdmitSingleton('b', 'singleton:k', 'job', 1)).toBe(true);
300
+ expect(await store.updateRunIf('b', ['running'], { status: 'completed' })).toBe(true);
301
+ expect((await store.getRun('b'))?.tags).toEqual(['singleton:k']);
302
+ });
303
+ // A replica older than 0.51 admits by a scan (the `limit` oldest running/suspended runs of the
304
+ // key) and writes no admission tag. During a rolling upgrade such a holder still occupies its
305
+ // slot for the new replicas.
306
+ t('tryAdmitSingleton counts an untagged pre-0.51 holder (rolling upgrade)', async () => {
307
+ if (!store.tryAdmitSingleton)
308
+ return;
309
+ const claim = (id, wakeAt) => store.tryAdmitSingleton?.(id, 'singleton:k', 'job', 1, wakeAt);
310
+ // Written by an old replica: admitted by its scan, running, no tag at all. It is NEWER than the
311
+ // run below, which was still `pending` (invisible to the old scan) back then.
312
+ await store.createRun(gated('legacy', 2000, { tags: ['singleton:k'] }));
313
+ // The older run, now picked up by a new replica: tracked from its first execution.
314
+ await store.createRun(gated('new', 1000));
315
+ expect(await claim('new', 12_000)).toBe(false);
316
+ expect(await store.getRun('new')).toMatchObject({ status: 'suspended', wakeAt: 12_000 });
317
+ expect((await store.getRun('new'))?.tags).toContain(TRACKED);
318
+ // A legacy holder parked on its own sleep still holds the slot.
319
+ await store.updateRun('legacy', { status: 'suspended', wakeAt: 20_000 });
320
+ expect(await claim('new')).toBe(false);
321
+ // Once it settles, the new admission takes over, swapping the marker for the admitted tag.
322
+ await store.updateRun('legacy', { status: 'completed' });
323
+ expect(await claim('new')).toBe(true);
324
+ expect((await store.getRun('new'))?.tags).toEqual(['singleton:k', ADMITTED]);
325
+ await store.updateRun('new', { status: 'completed' });
326
+ expect((await store.getRun('new'))?.tags).toEqual(['singleton:k']);
327
+ });
328
+ t('legacy singleton waiters keep their FIFO place across the upgrade', async () => {
329
+ if (!store.tryAdmitSingleton)
330
+ return;
331
+ const claim = (id, wakeAt) => store.tryAdmitSingleton?.(id, 'singleton:k', 'job', 1, wakeAt);
332
+ await store.createRun(gated('holder', 1000, { tags: ['singleton:k'] }));
333
+ // Denied by an old replica: suspended on its retry timer, no tag.
334
+ await store.createRun(gated('old-waiter', 2000, { status: 'suspended', wakeAt: 5_000, tags: ['singleton:k'] }));
335
+ await store.createRun(gated('new-run', 3000));
336
+ expect(await claim('new-run')).toBe(false);
337
+ expect(await claim('old-waiter')).toBe(false);
338
+ // A legacy waiter re-checked by a new replica is tracked from then on; it keeps its place.
339
+ expect(await claim('old-waiter', 6_000)).toBe(false);
340
+ expect((await store.getRun('old-waiter'))?.tags).toContain(TRACKED);
341
+ await store.updateRun('holder', { status: 'completed' });
342
+ expect(await claim('new-run')).toBe(false);
343
+ expect(await claim('old-waiter')).toBe(true);
344
+ expect(await claim('new-run')).toBe(false);
345
+ await store.updateRun('old-waiter', { status: 'completed' });
346
+ expect(await claim('new-run')).toBe(true);
347
+ });
348
+ t('concurrent claims never over-admit beside an untagged legacy holder', async () => {
349
+ if (!store.tryAdmitSingleton)
350
+ return;
351
+ await store.createRun(gated('legacy', 1000, { tags: ['singleton:k'] }));
352
+ const ids = ['c1', 'c2', 'c3', 'c4'];
353
+ for (const [i, id] of ids.entries())
354
+ await store.createRun(gated(id, 2000 + i * 1000));
355
+ const results = await Promise.all([...ids].reverse().map((id) => store.tryAdmitSingleton?.(id, 'singleton:k', 'job', 2)));
356
+ // One slot is the legacy holder's; only the oldest new run takes the other.
357
+ expect(results).toEqual([false, false, false, true]);
358
+ const rows = await Promise.all(ids.map((id) => store.getRun(id)));
359
+ expect(rows.filter((r) => r?.tags?.includes(ADMITTED)).map((r) => r?.id)).toEqual(['c1']);
360
+ });
361
+ t('concurrent claims for one slot admit exactly one run', async () => {
362
+ if (!store.tryAdmitSingleton)
363
+ return;
364
+ const ids = Array.from({ length: 6 }, (_, i) => `c${i}`);
365
+ // Same createdAt for all, so the claim order is decided only by the store's serialization.
366
+ for (const id of ids)
367
+ await store.createRun(gated(id, 1000));
368
+ const verdicts = await Promise.all(ids.map((id) => store.tryAdmitSingleton?.(id, 'singleton:k', 'job', 1, 5000)));
369
+ expect(verdicts.filter(Boolean)).toHaveLength(1);
370
+ const rows = await Promise.all(ids.map((id) => store.getRun(id)));
371
+ expect(rows.filter((r) => r?.tags?.includes(ADMITTED))).toHaveLength(1);
372
+ });
219
373
  // ---- checkpoints ------------------------------------------------------------------------
220
374
  t('countCheckpoints counts a seq range by status', async () => {
221
375
  // Optional on the interface (the engine has a fallback), but a conformant store MUST provide
@@ -276,6 +430,186 @@ export function runStateStoreContract(name, makeStore) {
276
430
  expect(await store.saveCheckpointIfPending({ ...pending, seq: 9 })).toBe(false);
277
431
  expect(await store.getCheckpoint('r1', 9)).toBeNull();
278
432
  });
433
+ t('saveCheckpointsIfPending settles a batch row by row, in order', async () => {
434
+ // Optional on the interface (the engine falls back to one saveCheckpointIfPending per entry),
435
+ // but a conformant store MUST provide it — assert presence, then exercise it.
436
+ expect(store.saveCheckpointsIfPending).toBeDefined();
437
+ if (!store.saveCheckpointsIfPending)
438
+ return;
439
+ expect(await store.saveCheckpointsIfPending([])).toEqual([]);
440
+ await store.createRun(run());
441
+ await store.createRun(run({ id: 'r2' }));
442
+ const pending = (runId, seq) => checkpoint({
443
+ runId,
444
+ seq,
445
+ stepId: `${runId}:${seq}`,
446
+ status: 'pending',
447
+ output: undefined,
448
+ wakeAt: 5_000,
449
+ });
450
+ for (const seq of [0, 1, 2, 3])
451
+ await store.saveCheckpoint(pending('r1', seq));
452
+ await store.saveCheckpoint(pending('r2', 0));
453
+ // seq 1 already settled: a stale write must not land on it.
454
+ await store.saveCheckpoint({
455
+ ...pending('r1', 1),
456
+ status: 'completed',
457
+ output: { first: 1 },
458
+ });
459
+ const done = (runId, seq, output) => ({
460
+ ...pending(runId, seq),
461
+ status: 'completed',
462
+ output,
463
+ wakeAt: undefined,
464
+ });
465
+ const wrote = await store.saveCheckpointsIfPending([
466
+ done('r1', 0, { n: 0 }),
467
+ done('r1', 1, { n: 1 }),
468
+ // A second copy of the same row: compared against the first copy's write, so refused.
469
+ done('r1', 0, { n: 'dup' }),
470
+ { ...pending('r1', 2), status: 'failed', error: { message: 'boom' }, wakeAt: undefined },
471
+ // No such row: nothing is written.
472
+ done('r1', 9, { n: 9 }),
473
+ // Another run's row, same seq as one above.
474
+ done('r2', 0, { n: 'r2' }),
475
+ ]);
476
+ expect(wrote).toEqual([true, false, false, true, false, true]);
477
+ const r1 = await store.listCheckpoints('r1');
478
+ expect(r1.map((c) => [c.seq, c.status, c.output])).toEqual([
479
+ [0, 'completed', { n: 0 }],
480
+ [1, 'completed', { first: 1 }],
481
+ [2, 'failed', undefined],
482
+ [3, 'pending', undefined],
483
+ ]);
484
+ expect(r1[2]?.error).toEqual({ message: 'boom' });
485
+ // The settling write carried no deadline, so the one the pending row held is cleared.
486
+ expect(r1[0]?.wakeAt).toBeUndefined();
487
+ expect(r1[3]?.wakeAt).toBe(5_000);
488
+ expect(await store.getCheckpoint('r1', 9)).toBeNull();
489
+ expect((await store.getCheckpoint('r2', 0))?.output).toEqual({ n: 'r2' });
490
+ // A pending-to-pending write keeps the row pending, so a later entry still lands on it.
491
+ expect(await store.saveCheckpointsIfPending([
492
+ { ...pending('r1', 3), wakeAt: 9_000 },
493
+ done('r1', 3, { n: 3 }),
494
+ ])).toEqual([true, true]);
495
+ expect((await store.getCheckpoint('r1', 3))?.output).toEqual({ n: 3 });
496
+ });
497
+ t('listBufferedSignalTokens lists distinct prefixed tokens with a buffered signal (when implemented)', async () => {
498
+ if (!store.listBufferedSignalTokens)
499
+ return;
500
+ await store.bufferSignal('entity:a:1', { op: 'x' });
501
+ await store.bufferSignal('entity:a:1', { op: 'y' });
502
+ await store.bufferSignal('entity:b:2', { op: 'z' });
503
+ await store.bufferSignal('other', 1);
504
+ expect([...(await store.listBufferedSignalTokens('entity:', 10))].sort()).toEqual([
505
+ 'entity:a:1',
506
+ 'entity:b:2',
507
+ ]);
508
+ expect(await store.listBufferedSignalTokens('entity:', 1)).toHaveLength(1);
509
+ // Listing consumes nothing; a drained token drops out.
510
+ await store.takeBufferedSignal('entity:b:2');
511
+ expect(await store.listBufferedSignalTokens('entity:', 10)).toEqual(['entity:a:1']);
512
+ // `%` / `_` in the prefix are literal.
513
+ expect(await store.listBufferedSignalTokens('entity_', 10)).toEqual([]);
514
+ });
515
+ t('saveCheckpointsIfPending writes a wide batch', async () => {
516
+ if (!store.saveCheckpointsIfPending)
517
+ return;
518
+ await store.createRun(run());
519
+ const n = 600;
520
+ const rows = Array.from({ length: n }, (_, seq) => checkpoint({ seq, stepId: `r1:${seq}`, status: 'pending', output: undefined }));
521
+ for (const row of rows)
522
+ await store.saveCheckpoint(row);
523
+ const wrote = await store.saveCheckpointsIfPending(rows.map((row) => ({ ...row, status: 'completed', output: { seq: row.seq } })));
524
+ expect(wrote).toHaveLength(n);
525
+ expect(wrote.every((w) => w === true)).toBe(true);
526
+ expect(await store.countCheckpoints?.('r1', 0, n - 1, ['completed'])).toBe(n);
527
+ expect((await store.getCheckpoint('r1', n - 1))?.output).toEqual({ seq: n - 1 });
528
+ // ~1,200 sequential round-trips: a networked store on a loaded runner needs more than 5s.
529
+ }, 30_000);
530
+ t('deleteRuns removes every listed run and its rows, and nothing else', async () => {
531
+ expect(store.deleteRuns).toBeDefined();
532
+ if (!store.deleteRuns)
533
+ return;
534
+ await store.deleteRuns([]);
535
+ for (const id of ['a', 'b', 'keep']) {
536
+ await store.createRun(run({ id, searchAttributes: { tier: 'pro' } }));
537
+ await store.saveCheckpoint(checkpoint({ runId: id, stepId: `${id}:0` }));
538
+ await store.putSignalWaiter({ token: `tok-${id}`, runId: id, seq: 1 });
539
+ }
540
+ await store.deleteRuns(['a', 'b', 'missing', 'a']);
541
+ expect(await store.getRun('a')).toBeNull();
542
+ expect(await store.getRun('b')).toBeNull();
543
+ expect(await store.listCheckpoints('a')).toEqual([]);
544
+ expect(await store.listSignalWaiters('tok-a')).toEqual([]);
545
+ expect((await store.listRuns({ attributes: [{ key: 'tier', op: 'eq', value: 'pro' }] })).map((r) => r.id)).toEqual(['keep']);
546
+ expect((await store.getRun('keep'))?.id).toBe('keep');
547
+ expect(await store.listCheckpoints('keep')).toHaveLength(1);
548
+ expect(await store.listSignalWaiters('tok-keep')).toHaveLength(1);
549
+ });
550
+ t('runFacets counts the matching runs by (status, origin)', async () => {
551
+ expect(store.runFacets).toBeDefined();
552
+ if (!store.runFacets)
553
+ return;
554
+ const seedRun = (id, status, origin, workflow = 'checkout') => store.createRun(run({ id, status, workflow, ...(origin !== undefined ? { origin } : {}) }));
555
+ await seedRun('f1', 'completed', 'api');
556
+ await seedRun('f2', 'completed', 'api');
557
+ await seedRun('f3', 'failed', 'api');
558
+ await seedRun('f4', 'completed');
559
+ await seedRun('f5', 'completed', ' ');
560
+ await seedRun('f6', 'running', 'cron', 'other');
561
+ const sort = (rows) => [...rows].sort((a, b) => `${a.status}|${a.origin}`.localeCompare(`${b.status}|${b.origin}`));
562
+ expect(sort(await store.runFacets({}))).toEqual(sort([
563
+ { status: 'completed', origin: 'api', count: 2 },
564
+ { status: 'failed', origin: 'api', count: 1 },
565
+ // An absent and a blank origin are the same unattributed bucket.
566
+ { status: 'completed', origin: null, count: 2 },
567
+ { status: 'running', origin: 'cron', count: 1 },
568
+ ]));
569
+ expect(sort(await store.runFacets({ workflow: 'other' }))).toEqual([
570
+ { status: 'running', origin: 'cron', count: 1 },
571
+ ]);
572
+ expect(await store.runFacets({ workflow: 'none' })).toEqual([]);
573
+ });
574
+ t('pruneBufferedEvents deletes the oldest expired events, capped at limit', async () => {
575
+ expect(store.pruneBufferedEvents).toBeDefined();
576
+ if (!store.pruneBufferedEvents)
577
+ return;
578
+ for (const [id, name, publishedAt] of [
579
+ ['e1', 'a', 100],
580
+ ['e2', 'b', 200],
581
+ ['e3', 'a', 300],
582
+ ['e4', 'b', 1_000],
583
+ ]) {
584
+ await store.bufferEvent({ id, name, payload: { id }, publishedAt });
585
+ }
586
+ expect(await store.pruneBufferedEvents(500, 2)).toBe(2);
587
+ expect((await store.listBufferedEvents('a', 10)).map((e) => e.id)).toEqual(['e3']);
588
+ expect((await store.listBufferedEvents('b', 10)).map((e) => e.id)).toEqual(['e4']);
589
+ expect(await store.pruneBufferedEvents(500, 10)).toBe(1);
590
+ expect(await store.pruneBufferedEvents(500, 10)).toBe(0);
591
+ expect((await store.listBufferedEvents('b', 10)).map((e) => e.id)).toEqual(['e4']);
592
+ });
593
+ t('withScope fences run reads to one namespace (when the store scopes)', async () => {
594
+ const scopeable = store;
595
+ if (typeof scopeable.withScope !== 'function')
596
+ return;
597
+ await store.createRun(run({ id: 'mine', namespace: 'acme', status: 'pending' }));
598
+ await store.createRun(run({ id: 'theirs', namespace: 'globex', status: 'pending' }));
599
+ const acme = scopeable.withScope({ namespace: 'acme' });
600
+ expect((await acme.getRun('mine'))?.id).toBe('mine');
601
+ expect(await acme.getRun('theirs')).toBeNull();
602
+ expect((await acme.listRuns({})).map((r) => r.id)).toEqual(['mine']);
603
+ expect((await acme.listPendingRuns(10)).map((r) => r.id)).toEqual(['mine']);
604
+ expect(await acme.countRuns?.({})).toBe(1);
605
+ // Writes are not fenced, and the operator view still sees both.
606
+ await acme.updateRun('theirs', { status: 'running' });
607
+ expect((await store.getRun('theirs'))?.status).toBe('running');
608
+ expect((await store.listRuns({})).map((r) => r.id).sort()).toEqual(['mine', 'theirs']);
609
+ const operator = scopeable.withScope({ namespace: undefined });
610
+ expect(await operator.listRuns({})).toHaveLength(2);
611
+ });
612
+ registerCheckpointAggregationCases(t, () => store, run);
279
613
  t('upserts checkpoints, reads them by (runId, seq), and lists them ordered by seq', async () => {
280
614
  await store.createRun(run());
281
615
  await store.saveCheckpoint(checkpoint());
@@ -403,6 +737,22 @@ export function runStateStoreContract(name, makeStore) {
403
737
  // The limit caps the batch.
404
738
  expect((await store.listOrphanedRuns(5_000, 1)).length).toBe(1);
405
739
  });
740
+ t('listOrphanedRuns with an owner also returns the runs still leased to that owner', async () => {
741
+ if (!store.listOrphanedRuns)
742
+ return;
743
+ await store.createRun(run({ id: 'free', status: 'running' }));
744
+ await store.createRun(run({ id: 'mine', status: 'running' }));
745
+ await store.createRun(run({ id: 'theirs', status: 'running' }));
746
+ await store.createRun(run({ id: 'mine-idle', status: 'suspended' }));
747
+ await store.tryLockRun('mine', 'A', 9_000, 1_000);
748
+ await store.tryLockRun('theirs', 'B', 9_000, 1_000);
749
+ await store.tryLockRun('mine-idle', 'A', 9_000, 1_000);
750
+ const withOwner = await store.listOrphanedRuns(5_000, 10, undefined, 'A');
751
+ // A store that predates the `owner` parameter ignores it (expiry-only): still conformant.
752
+ const ids = withOwner.map((r) => r.id).sort();
753
+ expect([['free'], ['free', 'mine']]).toContainEqual(ids);
754
+ expect((await store.listOrphanedRuns(5_000, 10)).map((r) => r.id)).toEqual(['free']);
755
+ });
406
756
  t('listDueTimers returns the most overdue first, capped at limit', async () => {
407
757
  for (const [id, wakeAt] of [
408
758
  ['late', 3_000],
@@ -1025,6 +1375,8 @@ export function runStateStoreContract(name, makeStore) {
1025
1375
  expect(resumed.output).toBe(15);
1026
1376
  // Step `a` checkpointed `completed` the first turn, so replay returns it instead of re-running.
1027
1377
  expect(aRuns).toBe(1);
1378
+ // Settle the engine's background work before the store is cleaned up (closed or dropped).
1379
+ await engine.drain();
1028
1380
  });
1029
1381
  });
1030
1382
  }