@resonatehq/sdk 0.9.6 → 0.10.1

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 (235) hide show
  1. package/README.md +84 -12
  2. package/dist/clock.d.ts.map +1 -0
  3. package/dist/clock.js +12 -0
  4. package/dist/clock.js.map +1 -0
  5. package/dist/codec.d.ts +11 -0
  6. package/dist/codec.d.ts.map +1 -0
  7. package/dist/codec.js +115 -0
  8. package/dist/codec.js.map +1 -0
  9. package/dist/computation.d.ts +40 -0
  10. package/dist/computation.d.ts.map +1 -0
  11. package/dist/computation.js +152 -0
  12. package/dist/computation.js.map +1 -0
  13. package/dist/{src/context.d.ts → context.d.ts} +70 -40
  14. package/dist/context.d.ts.map +1 -0
  15. package/dist/{src/context.js → context.js} +140 -155
  16. package/dist/context.js.map +1 -0
  17. package/dist/core.d.ts +45 -0
  18. package/dist/core.d.ts.map +1 -0
  19. package/dist/core.js +140 -0
  20. package/dist/core.js.map +1 -0
  21. package/dist/coroutine.d.ts +37 -0
  22. package/dist/coroutine.d.ts.map +1 -0
  23. package/dist/coroutine.js +259 -0
  24. package/dist/coroutine.js.map +1 -0
  25. package/dist/{src/decorator.d.ts → decorator.d.ts} +9 -8
  26. package/dist/decorator.d.ts.map +1 -0
  27. package/dist/decorator.js +150 -0
  28. package/dist/decorator.js.map +1 -0
  29. package/dist/encryptor.d.ts +10 -0
  30. package/dist/encryptor.d.ts.map +1 -0
  31. package/dist/encryptor.js +9 -0
  32. package/dist/encryptor.js.map +1 -0
  33. package/dist/{src/exceptions.d.ts → exceptions.d.ts} +4 -1
  34. package/dist/exceptions.d.ts.map +1 -0
  35. package/dist/{src/exceptions.js → exceptions.js} +11 -11
  36. package/dist/exceptions.js.map +1 -0
  37. package/dist/{src/heartbeat.d.ts → heartbeat.d.ts} +5 -3
  38. package/dist/heartbeat.d.ts.map +1 -0
  39. package/dist/heartbeat.js +50 -0
  40. package/dist/heartbeat.js.map +1 -0
  41. package/dist/index.d.ts +19 -0
  42. package/dist/index.d.ts.map +1 -0
  43. package/dist/index.js +15 -0
  44. package/dist/index.js.map +1 -0
  45. package/dist/logger.d.ts +33 -0
  46. package/dist/logger.d.ts.map +1 -0
  47. package/dist/logger.js +54 -0
  48. package/dist/logger.js.map +1 -0
  49. package/dist/network/http.d.ts +86 -0
  50. package/dist/network/http.d.ts.map +1 -0
  51. package/dist/network/http.js +350 -0
  52. package/dist/network/http.js.map +1 -0
  53. package/dist/network/local.d.ts +188 -0
  54. package/dist/network/local.d.ts.map +1 -0
  55. package/dist/network/local.js +1349 -0
  56. package/dist/network/local.js.map +1 -0
  57. package/dist/network/network.d.ts +17 -0
  58. package/dist/network/network.d.ts.map +1 -0
  59. package/dist/network/network.js +2 -0
  60. package/dist/network/network.js.map +1 -0
  61. package/dist/network/types.d.ts +1280 -0
  62. package/dist/network/types.d.ts.map +1 -0
  63. package/dist/network/types.js +920 -0
  64. package/dist/network/types.js.map +1 -0
  65. package/dist/{src/options.d.ts → options.d.ts} +15 -6
  66. package/dist/options.d.ts.map +1 -0
  67. package/dist/options.js +34 -0
  68. package/dist/options.js.map +1 -0
  69. package/dist/promises.d.ts +54 -0
  70. package/dist/promises.d.ts.map +1 -0
  71. package/dist/promises.js +122 -0
  72. package/dist/promises.js.map +1 -0
  73. package/dist/{src/registry.d.ts → registry.d.ts} +1 -1
  74. package/dist/registry.d.ts.map +1 -0
  75. package/dist/{src/registry.js → registry.js} +6 -13
  76. package/dist/registry.js.map +1 -0
  77. package/dist/resonate.d.ts +168 -0
  78. package/dist/resonate.d.ts.map +1 -0
  79. package/dist/resonate.js +475 -0
  80. package/dist/resonate.js.map +1 -0
  81. package/dist/retries.d.ts.map +1 -0
  82. package/dist/{src/retries.js → retries.js} +4 -11
  83. package/dist/retries.js.map +1 -0
  84. package/dist/schedules.d.ts +22 -0
  85. package/dist/schedules.d.ts.map +1 -0
  86. package/dist/schedules.js +65 -0
  87. package/dist/schedules.js.map +1 -0
  88. package/dist/trace.d.ts +79 -0
  89. package/dist/trace.d.ts.map +1 -0
  90. package/dist/trace.js +224 -0
  91. package/dist/trace.js.map +1 -0
  92. package/dist/types.d.ts +21 -0
  93. package/dist/types.d.ts.map +1 -0
  94. package/dist/types.js +2 -0
  95. package/dist/types.js.map +1 -0
  96. package/dist/util.d.ts +28 -0
  97. package/dist/util.d.ts.map +1 -0
  98. package/dist/util.js +196 -0
  99. package/dist/util.js.map +1 -0
  100. package/package.json +18 -10
  101. package/dist/dev/network.d.ts +0 -34
  102. package/dist/dev/network.d.ts.map +0 -1
  103. package/dist/dev/network.js +0 -115
  104. package/dist/dev/network.js.map +0 -1
  105. package/dist/dev/server.d.ts +0 -89
  106. package/dist/dev/server.d.ts.map +0 -1
  107. package/dist/dev/server.js +0 -890
  108. package/dist/dev/server.js.map +0 -1
  109. package/dist/sim/example-1.d.ts +0 -2
  110. package/dist/sim/example-1.d.ts.map +0 -1
  111. package/dist/sim/example-1.js +0 -95
  112. package/dist/sim/example-1.js.map +0 -1
  113. package/dist/sim/example-2.d.ts +0 -2
  114. package/dist/sim/example-2.d.ts.map +0 -1
  115. package/dist/sim/example-2.js +0 -97
  116. package/dist/sim/example-2.js.map +0 -1
  117. package/dist/sim/example-3.d.ts +0 -2
  118. package/dist/sim/example-3.d.ts.map +0 -1
  119. package/dist/sim/example-3.js +0 -97
  120. package/dist/sim/example-3.js.map +0 -1
  121. package/dist/sim/main.d.ts +0 -14
  122. package/dist/sim/main.d.ts.map +0 -1
  123. package/dist/sim/main.js +0 -207
  124. package/dist/sim/main.js.map +0 -1
  125. package/dist/sim/src/server.d.ts +0 -15
  126. package/dist/sim/src/server.d.ts.map +0 -1
  127. package/dist/sim/src/server.js +0 -85
  128. package/dist/sim/src/server.js.map +0 -1
  129. package/dist/sim/src/simulator.d.ts +0 -65
  130. package/dist/sim/src/simulator.d.ts.map +0 -1
  131. package/dist/sim/src/simulator.js +0 -236
  132. package/dist/sim/src/simulator.js.map +0 -1
  133. package/dist/sim/src/worker.d.ts +0 -23
  134. package/dist/sim/src/worker.d.ts.map +0 -1
  135. package/dist/sim/src/worker.js +0 -217
  136. package/dist/sim/src/worker.js.map +0 -1
  137. package/dist/src/clock.d.ts.map +0 -1
  138. package/dist/src/clock.js +0 -17
  139. package/dist/src/clock.js.map +0 -1
  140. package/dist/src/computation.d.ts +0 -49
  141. package/dist/src/computation.d.ts.map +0 -1
  142. package/dist/src/computation.js +0 -314
  143. package/dist/src/computation.js.map +0 -1
  144. package/dist/src/context.d.ts.map +0 -1
  145. package/dist/src/context.js.map +0 -1
  146. package/dist/src/core.d.ts +0 -12
  147. package/dist/src/core.d.ts.map +0 -1
  148. package/dist/src/core.js +0 -28
  149. package/dist/src/core.js.map +0 -1
  150. package/dist/src/coroutine.d.ts +0 -42
  151. package/dist/src/coroutine.d.ts.map +0 -1
  152. package/dist/src/coroutine.js +0 -364
  153. package/dist/src/coroutine.js.map +0 -1
  154. package/dist/src/decorator.d.ts.map +0 -1
  155. package/dist/src/decorator.js +0 -220
  156. package/dist/src/decorator.js.map +0 -1
  157. package/dist/src/encoder.d.ts +0 -12
  158. package/dist/src/encoder.d.ts.map +0 -1
  159. package/dist/src/encoder.js +0 -101
  160. package/dist/src/encoder.js.map +0 -1
  161. package/dist/src/encryptor.d.ts +0 -10
  162. package/dist/src/encryptor.d.ts.map +0 -1
  163. package/dist/src/encryptor.js +0 -13
  164. package/dist/src/encryptor.js.map +0 -1
  165. package/dist/src/exceptions.d.ts.map +0 -1
  166. package/dist/src/exceptions.js.map +0 -1
  167. package/dist/src/handler.d.ts +0 -49
  168. package/dist/src/handler.d.ts.map +0 -1
  169. package/dist/src/handler.js +0 -316
  170. package/dist/src/handler.js.map +0 -1
  171. package/dist/src/heartbeat.d.ts.map +0 -1
  172. package/dist/src/heartbeat.js +0 -87
  173. package/dist/src/heartbeat.js.map +0 -1
  174. package/dist/src/index.d.ts +0 -15
  175. package/dist/src/index.d.ts.map +0 -1
  176. package/dist/src/index.js +0 -61
  177. package/dist/src/index.js.map +0 -1
  178. package/dist/src/network/network.d.ts +0 -254
  179. package/dist/src/network/network.d.ts.map +0 -1
  180. package/dist/src/network/network.js +0 -4
  181. package/dist/src/network/network.js.map +0 -1
  182. package/dist/src/network/remote.d.ts +0 -85
  183. package/dist/src/network/remote.d.ts.map +0 -1
  184. package/dist/src/network/remote.js +0 -523
  185. package/dist/src/network/remote.js.map +0 -1
  186. package/dist/src/nursery.d.ts +0 -19
  187. package/dist/src/nursery.d.ts.map +0 -1
  188. package/dist/src/nursery.js +0 -102
  189. package/dist/src/nursery.js.map +0 -1
  190. package/dist/src/options.d.ts.map +0 -1
  191. package/dist/src/options.js +0 -70
  192. package/dist/src/options.js.map +0 -1
  193. package/dist/src/processor/processor.d.ts +0 -12
  194. package/dist/src/processor/processor.d.ts.map +0 -1
  195. package/dist/src/processor/processor.js +0 -43
  196. package/dist/src/processor/processor.js.map +0 -1
  197. package/dist/src/promises.d.ts +0 -54
  198. package/dist/src/promises.d.ts.map +0 -1
  199. package/dist/src/promises.js +0 -169
  200. package/dist/src/promises.js.map +0 -1
  201. package/dist/src/registry.d.ts.map +0 -1
  202. package/dist/src/registry.js.map +0 -1
  203. package/dist/src/resonate-inner.d.ts +0 -60
  204. package/dist/src/resonate-inner.d.ts.map +0 -1
  205. package/dist/src/resonate-inner.js +0 -97
  206. package/dist/src/resonate-inner.js.map +0 -1
  207. package/dist/src/resonate.d.ts +0 -341
  208. package/dist/src/resonate.d.ts.map +0 -1
  209. package/dist/src/resonate.js +0 -591
  210. package/dist/src/resonate.js.map +0 -1
  211. package/dist/src/retries.d.ts.map +0 -1
  212. package/dist/src/retries.js.map +0 -1
  213. package/dist/src/schedules.d.ts +0 -19
  214. package/dist/src/schedules.d.ts.map +0 -1
  215. package/dist/src/schedules.js +0 -85
  216. package/dist/src/schedules.js.map +0 -1
  217. package/dist/src/tasks.d.ts +0 -9
  218. package/dist/src/tasks.d.ts.map +0 -1
  219. package/dist/src/tasks.js +0 -61
  220. package/dist/src/tasks.js.map +0 -1
  221. package/dist/src/tracer.d.ts +0 -23
  222. package/dist/src/tracer.d.ts.map +0 -1
  223. package/dist/src/tracer.js +0 -25
  224. package/dist/src/tracer.js.map +0 -1
  225. package/dist/src/types.d.ts +0 -28
  226. package/dist/src/types.d.ts.map +0 -1
  227. package/dist/src/types.js +0 -11
  228. package/dist/src/types.js.map +0 -1
  229. package/dist/src/util.d.ts +0 -20
  230. package/dist/src/util.d.ts.map +0 -1
  231. package/dist/src/util.js +0 -120
  232. package/dist/src/util.js.map +0 -1
  233. package/dist/tsconfig.build.tsbuildinfo +0 -1
  234. /package/dist/{src/clock.d.ts → clock.d.ts} +0 -0
  235. /package/dist/{src/retries.d.ts → retries.d.ts} +0 -0
@@ -0,0 +1,1349 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { CronExpressionParser } from "cron-parser";
3
+ import { assert, VERSION } from "../util.js";
4
+ import { isResponse } from "./types.js";
5
+ // =============================================================================
6
+ // CONSTANTS
7
+ // =============================================================================
8
+ const PENDING_RETRY_TTL = 30000;
9
+ export class Server {
10
+ promises = new Map();
11
+ tasks = new Map();
12
+ schedules = new Map();
13
+ pTimeouts = [];
14
+ tTimeouts = [];
15
+ sTimeouts = [];
16
+ outgoing = [];
17
+ // When "eager", every action handler converges physical state to logical
18
+ // state by running tryEagerTimeout for the relevant id(s) before the
19
+ // handler body. When "none", convergence happens only via debug.tick (or
20
+ // an equivalent background mechanism) — read paths must rely on
21
+ // projection to return the logical state.
22
+ timeoutMode;
23
+ constructor(opts = {}) {
24
+ this.timeoutMode = opts.timeoutMode ?? "eager";
25
+ }
26
+ apply(now, req) {
27
+ const changes = [];
28
+ const error = this.validate(req);
29
+ if (error !== null) {
30
+ return { response: { kind: req.kind, head: { status: 400 }, data: error }, changes };
31
+ }
32
+ let result;
33
+ switch (req.kind) {
34
+ case "promise.get": {
35
+ changes.push(...this.tryEagerTimeout(now, req.data.id));
36
+ result = this.promiseGet(now, req);
37
+ break;
38
+ }
39
+ case "promise.create": {
40
+ changes.push(...this.tryEagerTimeout(now, req.data.id));
41
+ result = this.promiseCreate(now, req);
42
+ break;
43
+ }
44
+ case "promise.settle": {
45
+ changes.push(...this.tryEagerTimeout(now, req.data.id));
46
+ result = this.promiseSettle(now, req);
47
+ break;
48
+ }
49
+ case "promise.register_callback": {
50
+ changes.push(...this.tryEagerTimeout(now, req.data.awaited));
51
+ changes.push(...this.tryEagerTimeout(now, req.data.awaiter));
52
+ result = this.promiseRegisterCallback(now, req);
53
+ break;
54
+ }
55
+ case "promise.register_listener": {
56
+ changes.push(...this.tryEagerTimeout(now, req.data.awaited));
57
+ result = this.promiseRegisterListener(now, req);
58
+ break;
59
+ }
60
+ case "promise.search": {
61
+ result = { response: this.response("promise.search", 501, "Not implemented"), changes: [] };
62
+ break;
63
+ }
64
+ case "task.get": {
65
+ changes.push(...this.tryEagerTimeout(now, req.data.id));
66
+ result = this.taskGet(now, req);
67
+ break;
68
+ }
69
+ case "task.create": {
70
+ changes.push(...this.tryEagerTimeout(now, req.data.action.data.id));
71
+ result = this.taskCreate(now, req);
72
+ break;
73
+ }
74
+ case "task.acquire": {
75
+ changes.push(...this.tryEagerTimeout(now, req.data.id));
76
+ result = this.taskAcquire(now, req);
77
+ break;
78
+ }
79
+ case "task.release": {
80
+ changes.push(...this.tryEagerTimeout(now, req.data.id));
81
+ result = this.taskRelease(now, req);
82
+ break;
83
+ }
84
+ case "task.fulfill": {
85
+ changes.push(...this.tryEagerTimeout(now, req.data.id));
86
+ result = this.taskFulfill(now, req);
87
+ break;
88
+ }
89
+ case "task.suspend": {
90
+ changes.push(...this.tryEagerTimeout(now, req.data.id));
91
+ for (const action of req.data.actions) {
92
+ changes.push(...this.tryEagerTimeout(now, action.data.awaited));
93
+ }
94
+ result = this.taskSuspend(now, req);
95
+ break;
96
+ }
97
+ case "task.halt": {
98
+ changes.push(...this.tryEagerTimeout(now, req.data.id));
99
+ result = this.taskHalt(now, req);
100
+ break;
101
+ }
102
+ case "task.continue": {
103
+ changes.push(...this.tryEagerTimeout(now, req.data.id));
104
+ result = this.taskContinue(now, req);
105
+ break;
106
+ }
107
+ case "task.fence": {
108
+ changes.push(...this.tryEagerTimeout(now, req.data.id));
109
+ changes.push(...this.tryEagerTimeout(now, req.data.action.data.id));
110
+ result = this.taskFence(now, req);
111
+ break;
112
+ }
113
+ case "task.heartbeat": {
114
+ result = this.taskHeartbeat(now, req);
115
+ break;
116
+ }
117
+ case "task.search": {
118
+ result = { response: this.response("task.search", 501, "Not implemented"), changes: [] };
119
+ break;
120
+ }
121
+ case "debug.start": {
122
+ result = this.debugStart();
123
+ break;
124
+ }
125
+ case "debug.reset": {
126
+ result = this.debugReset();
127
+ break;
128
+ }
129
+ case "debug.snap": {
130
+ result = this.debugSnap();
131
+ break;
132
+ }
133
+ case "debug.tick": {
134
+ result = this.debugTick(req);
135
+ break;
136
+ }
137
+ case "debug.stop": {
138
+ result = this.debugStop();
139
+ break;
140
+ }
141
+ case "schedule.get": {
142
+ result = this.scheduleGet(req);
143
+ break;
144
+ }
145
+ case "schedule.create": {
146
+ result = this.scheduleCreate(now, req);
147
+ break;
148
+ }
149
+ case "schedule.delete": {
150
+ result = this.scheduleDelete(req);
151
+ break;
152
+ }
153
+ case "schedule.search": {
154
+ result = { response: this.response("schedule.search", 501, "Not implemented"), changes: [] };
155
+ break;
156
+ }
157
+ }
158
+ changes.push(...result.changes);
159
+ return { response: result.response, changes };
160
+ }
161
+ validate(req) {
162
+ switch (req.kind) {
163
+ case "promise.register_callback":
164
+ if (req.data.awaited === req.data.awaiter) {
165
+ return "Awaited and awaiter must be different";
166
+ }
167
+ return null;
168
+ case "task.suspend":
169
+ if (req.data.actions.length === 0) {
170
+ return "Actions list must not be empty";
171
+ }
172
+ if (req.data.actions.some((a) => a.data.awaiter !== req.data.id)) {
173
+ return "Awaiter must be the suspending task";
174
+ }
175
+ if (req.data.actions.some((a) => a.data.awaited === req.data.id)) {
176
+ return "Task cannot await its own promise";
177
+ }
178
+ return null;
179
+ case "task.create":
180
+ if (!req.data.action.data.tags?.["resonate:target"]) {
181
+ return "Action must have a resonate:target tag";
182
+ }
183
+ return null;
184
+ case "task.fulfill":
185
+ if (req.data.action.data.id !== req.data.id) {
186
+ return "Promise ID must match task ID";
187
+ }
188
+ return null;
189
+ default:
190
+ return null;
191
+ }
192
+ }
193
+ // ===========================================================================
194
+ // PROMISE OPERATIONS
195
+ // ===========================================================================
196
+ promiseGet(now, req) {
197
+ const promise = this.promises.get(req.data.id);
198
+ if (!promise) {
199
+ return { response: this.response("promise.get", 404, "Promise not found"), changes: [] };
200
+ }
201
+ return {
202
+ response: this.response("promise.get", 200, {
203
+ promise: this.toPromiseRecord(this.projectedPromise(now, promise)),
204
+ }),
205
+ changes: [],
206
+ };
207
+ }
208
+ promiseCreate(now, req) {
209
+ const existingPromise = this.promises.get(req.data.id);
210
+ if (existingPromise) {
211
+ return {
212
+ response: this.response("promise.create", 200, {
213
+ promise: this.toPromiseRecord(this.projectedPromise(now, existingPromise)),
214
+ }),
215
+ changes: [],
216
+ };
217
+ }
218
+ const changes = [];
219
+ // Check if the promise is already timed out at creation
220
+ if (now >= req.data.timeoutAt) {
221
+ const promise = {
222
+ id: req.data.id,
223
+ state: this.timeoutState(req.data.tags),
224
+ param: req.data.param ?? { data: undefined, headers: undefined },
225
+ value: {},
226
+ tags: req.data.tags,
227
+ createdAt: req.data.timeoutAt,
228
+ settledAt: req.data.timeoutAt,
229
+ timeoutAt: req.data.timeoutAt,
230
+ callbacks: new Set(),
231
+ listeners: new Set(),
232
+ };
233
+ changes.push(this.setPromise(promise));
234
+ // Create fulfilled task inline if promise has a target
235
+ const address = req.data.tags["resonate:target"];
236
+ if (address) {
237
+ changes.push(this.setTask({
238
+ id: req.data.id,
239
+ state: "fulfilled",
240
+ version: 0,
241
+ pid: undefined,
242
+ ttl: undefined,
243
+ resumes: new Set(),
244
+ }));
245
+ }
246
+ changes.push(...this.triggerSettlement(req.data.id, now));
247
+ return {
248
+ response: this.response("promise.create", 200, { promise: this.toPromiseRecord(promise) }),
249
+ changes,
250
+ };
251
+ }
252
+ const promise = {
253
+ id: req.data.id,
254
+ state: "pending",
255
+ param: req.data.param ?? { data: undefined, headers: undefined },
256
+ value: {},
257
+ tags: req.data.tags,
258
+ createdAt: now,
259
+ settledAt: null,
260
+ timeoutAt: req.data.timeoutAt,
261
+ callbacks: new Set(),
262
+ listeners: new Set(),
263
+ };
264
+ changes.push(this.setPromise(promise));
265
+ changes.push(this.setPTimeout({ id: req.data.id, timeout: req.data.timeoutAt }));
266
+ const address = req.data.tags["resonate:target"];
267
+ if (address) {
268
+ const delay = Number(req.data.tags["resonate:delay"]);
269
+ const deferred = Number.isFinite(delay) && now < delay;
270
+ const task = {
271
+ id: req.data.id,
272
+ state: "pending",
273
+ version: 0,
274
+ resumes: new Set(),
275
+ };
276
+ changes.push(this.setTask(task));
277
+ changes.push(this.setTTimeout({
278
+ id: req.data.id,
279
+ type: 0,
280
+ timeout: deferred ? delay : now + PENDING_RETRY_TTL,
281
+ }));
282
+ if (!deferred) {
283
+ changes.push(this.sendMessage(address, { kind: "execute", head: {}, data: { task: { id: req.data.id, version: 0 } } }));
284
+ }
285
+ }
286
+ return {
287
+ response: this.response("promise.create", 200, { promise: this.toPromiseRecord(promise) }),
288
+ changes,
289
+ };
290
+ }
291
+ promiseSettle(now, req) {
292
+ const promise = this.promises.get(req.data.id);
293
+ if (!promise) {
294
+ return { response: this.response("promise.settle", 404, "Promise not found"), changes: [] };
295
+ }
296
+ // Idempotent return: if the promise is already physically terminal OR
297
+ // pending past its deadline (logically settled), return the projected
298
+ // logical state without writing.
299
+ const projected = this.projectedPromise(now, promise);
300
+ if (projected.state !== "pending") {
301
+ return {
302
+ response: this.response("promise.settle", 200, { promise: this.toPromiseRecord(projected) }),
303
+ changes: [],
304
+ };
305
+ }
306
+ const changes = [];
307
+ const settledPromise = {
308
+ ...promise,
309
+ state: req.data.state,
310
+ value: req.data.value,
311
+ settledAt: now,
312
+ };
313
+ changes.push(this.setPromise(settledPromise));
314
+ changes.push(this.delPTimeout(req.data.id));
315
+ changes.push(...this.triggerSettlement(req.data.id, now));
316
+ return {
317
+ response: this.response("promise.settle", 200, { promise: this.toPromiseRecord(settledPromise) }),
318
+ changes,
319
+ };
320
+ }
321
+ promiseRegisterCallback(now, req) {
322
+ const awaitedPromise = this.promises.get(req.data.awaited);
323
+ if (!awaitedPromise) {
324
+ return { response: this.response("promise.register_callback", 404, "Awaited promise not found"), changes: [] };
325
+ }
326
+ const awaiterPromise = this.promises.get(req.data.awaiter);
327
+ if (!awaiterPromise) {
328
+ return { response: this.response("promise.register_callback", 422, "Awaiter promise not found"), changes: [] };
329
+ }
330
+ // HasAddress check: awaiter must have a resonate:target tag
331
+ if (!awaiterPromise.tags["resonate:target"]) {
332
+ return { response: this.response("promise.register_callback", 422, "Awaiter has no address"), changes: [] };
333
+ }
334
+ const changes = [];
335
+ // Project for the logically-pending check and for the response — the
336
+ // callback is written only when both sides are logically pending, and
337
+ // the response always reflects the projected awaited promise.
338
+ const awaitedProjected = this.projectedPromise(now, awaitedPromise);
339
+ const awaiterProjected = this.projectedPromise(now, awaiterPromise);
340
+ if (awaitedProjected.state === "pending" && awaiterProjected.state === "pending") {
341
+ awaitedPromise.callbacks.add(req.data.awaiter);
342
+ changes.push(this.setPromise(awaitedPromise));
343
+ }
344
+ return {
345
+ response: this.response("promise.register_callback", 200, {
346
+ promise: this.toPromiseRecord(awaitedProjected),
347
+ }),
348
+ changes,
349
+ };
350
+ }
351
+ promiseRegisterListener(now, req) {
352
+ const promise = this.promises.get(req.data.awaited);
353
+ if (!promise) {
354
+ return { response: this.response("promise.register_listener", 404, "Promise not found"), changes: [] };
355
+ }
356
+ const changes = [];
357
+ // Project for the logically-pending check and for the response — the
358
+ // listener is registered only when the promise is logically pending,
359
+ // and the response always reflects the projected awaited promise.
360
+ const projected = this.projectedPromise(now, promise);
361
+ if (projected.state === "pending") {
362
+ promise.listeners.add(req.data.address);
363
+ changes.push(this.setPromise(promise));
364
+ }
365
+ return {
366
+ response: this.response("promise.register_listener", 200, {
367
+ promise: this.toPromiseRecord(projected),
368
+ }),
369
+ changes,
370
+ };
371
+ }
372
+ // ===========================================================================
373
+ // TASK OPERATIONS
374
+ // ===========================================================================
375
+ taskGet(now, req) {
376
+ const task = this.tasks.get(req.data.id);
377
+ if (!task) {
378
+ return { response: this.response("task.get", 404, "Task not found"), changes: [] };
379
+ }
380
+ return {
381
+ response: this.response("task.get", 200, {
382
+ task: this.toTaskRecord(this.projectedTask(now, task)),
383
+ }),
384
+ changes: [],
385
+ };
386
+ }
387
+ taskCreate(now, req) {
388
+ const existingTask = this.tasks.get(req.data.action.data.id);
389
+ if (existingTask) {
390
+ const promise = this.getPromiseOrThrow(existingTask.id);
391
+ // Pending → Acquired (acquire and return 200)
392
+ if (existingTask.state === "pending") {
393
+ const newVersion = existingTask.version + 1;
394
+ const changes = [];
395
+ changes.push(this.setTask({
396
+ ...existingTask,
397
+ state: "acquired",
398
+ version: newVersion,
399
+ pid: req.data.pid,
400
+ ttl: req.data.ttl,
401
+ resumes: new Set(),
402
+ }));
403
+ changes.push(this.setTTimeout({ id: existingTask.id, type: 1, timeout: now + req.data.ttl }));
404
+ return {
405
+ response: this.response("task.create", 200, {
406
+ task: this.toTaskRecord({
407
+ ...existingTask,
408
+ state: "acquired",
409
+ version: newVersion,
410
+ pid: req.data.pid,
411
+ ttl: req.data.ttl,
412
+ resumes: new Set(),
413
+ }),
414
+ promise: this.toPromiseRecord(promise),
415
+ preload: this.preload(existingTask.id),
416
+ }),
417
+ changes,
418
+ };
419
+ }
420
+ // Fulfilled → Fulfilled (idempotent, return 200)
421
+ if (existingTask.state === "fulfilled") {
422
+ return {
423
+ response: this.response("task.create", 200, {
424
+ task: this.toTaskRecord(existingTask),
425
+ promise: this.toPromiseRecord(promise),
426
+ preload: this.preload(existingTask.id),
427
+ }),
428
+ changes: [],
429
+ };
430
+ }
431
+ return { response: this.response("task.create", 409, "Task already exists"), changes: [] };
432
+ }
433
+ const changes = [];
434
+ const actionData = req.data.action.data;
435
+ // Guard: promise already exists
436
+ const existingPromise = this.promises.get(actionData.id);
437
+ if (existingPromise) {
438
+ if (!existingPromise.tags["resonate:target"]) {
439
+ return { response: this.response("task.create", 422, "Promise has no address"), changes: [] };
440
+ }
441
+ return { response: this.response("task.create", 409, "Promise already exists"), changes: [] };
442
+ }
443
+ // Guard: promise already timed out
444
+ if (now >= actionData.timeoutAt) {
445
+ const promise = {
446
+ id: actionData.id,
447
+ state: this.timeoutState(actionData.tags),
448
+ param: actionData.param,
449
+ value: {},
450
+ tags: actionData.tags,
451
+ createdAt: actionData.timeoutAt,
452
+ settledAt: actionData.timeoutAt,
453
+ timeoutAt: actionData.timeoutAt,
454
+ callbacks: new Set(),
455
+ listeners: new Set(),
456
+ };
457
+ changes.push(this.setPromise(promise));
458
+ const task = {
459
+ id: actionData.id,
460
+ state: "fulfilled",
461
+ version: 0,
462
+ pid: undefined,
463
+ ttl: undefined,
464
+ resumes: new Set(),
465
+ };
466
+ changes.push(this.setTask(task));
467
+ return {
468
+ response: this.response("task.create", 200, {
469
+ task: this.toTaskRecord(task),
470
+ promise: this.toPromiseRecord(promise),
471
+ preload: [],
472
+ }),
473
+ changes,
474
+ };
475
+ }
476
+ // Create promise in pending state
477
+ const promise = {
478
+ id: actionData.id,
479
+ state: "pending",
480
+ param: actionData.param,
481
+ value: {},
482
+ tags: actionData.tags,
483
+ createdAt: now,
484
+ settledAt: null,
485
+ timeoutAt: actionData.timeoutAt,
486
+ callbacks: new Set(),
487
+ listeners: new Set(),
488
+ };
489
+ changes.push(this.setPromise(promise));
490
+ changes.push(this.setPTimeout({ id: actionData.id, timeout: actionData.timeoutAt }));
491
+ // Step 2: Create task in acquired state
492
+ const task = {
493
+ id: actionData.id,
494
+ state: "acquired",
495
+ version: 1,
496
+ pid: req.data.pid,
497
+ ttl: req.data.ttl,
498
+ resumes: new Set(),
499
+ };
500
+ changes.push(this.setTask(task));
501
+ // Step 3: Set type=1 (lease) timeout
502
+ changes.push(this.setTTimeout({ id: actionData.id, type: 1, timeout: now + req.data.ttl }));
503
+ return {
504
+ response: this.response("task.create", 200, {
505
+ task: this.toTaskRecord(task),
506
+ promise: this.toPromiseRecord(promise),
507
+ preload: this.preload(actionData.id),
508
+ }),
509
+ changes,
510
+ };
511
+ }
512
+ taskAcquire(now, req) {
513
+ const task = this.tasks.get(req.data.id);
514
+ if (!task) {
515
+ return { response: this.response("task.acquire", 404, "Task not found"), changes: [] };
516
+ }
517
+ if (task.state !== "pending") {
518
+ return { response: this.response("task.acquire", 409, "Task not in pending state"), changes: [] };
519
+ }
520
+ // Logical-state check: if the task's promise is logically settled,
521
+ // the task is logically fulfilled and cannot be acquired. This catches
522
+ // the projection-mode case where the physical task state has not yet
523
+ // converged.
524
+ const promise = this.getPromiseOrThrow(req.data.id);
525
+ if (this.projectedPromise(now, promise).state !== "pending") {
526
+ return { response: this.response("task.acquire", 409, "Task logically fulfilled"), changes: [] };
527
+ }
528
+ if (task.version !== req.data.version) {
529
+ return { response: this.response("task.acquire", 409, "Version mismatch"), changes: [] };
530
+ }
531
+ const newVersion = task.version + 1;
532
+ const changes = [];
533
+ changes.push(this.setTask({
534
+ ...task,
535
+ state: "acquired",
536
+ version: newVersion,
537
+ pid: req.data.pid,
538
+ ttl: req.data.ttl,
539
+ resumes: new Set(),
540
+ }));
541
+ changes.push(this.setTTimeout({ id: req.data.id, type: 1, timeout: now + req.data.ttl }));
542
+ return {
543
+ response: this.response("task.acquire", 200, {
544
+ task: this.toTaskRecord({
545
+ ...task,
546
+ state: "acquired",
547
+ version: newVersion,
548
+ pid: req.data.pid,
549
+ ttl: req.data.ttl,
550
+ resumes: new Set(),
551
+ }),
552
+ promise: this.toPromiseRecord(promise),
553
+ preload: this.preload(req.data.id),
554
+ }),
555
+ changes,
556
+ };
557
+ }
558
+ taskRelease(now, req) {
559
+ const task = this.tasks.get(req.data.id);
560
+ if (!task) {
561
+ return { response: this.response("task.release", 404, "Task not found"), changes: [] };
562
+ }
563
+ if (task.state !== "acquired") {
564
+ return { response: this.response("task.release", 409, "Task not acquired"), changes: [] };
565
+ }
566
+ // Logical-state check: if the task's promise is logically settled,
567
+ // the task is logically fulfilled and cannot be released.
568
+ const taskPromise = this.getPromiseOrThrow(req.data.id);
569
+ if (this.projectedPromise(now, taskPromise).state !== "pending") {
570
+ return { response: this.response("task.release", 409, "Task not acquired"), changes: [] };
571
+ }
572
+ if (task.version !== req.data.version) {
573
+ return { response: this.response("task.release", 409, "Version mismatch"), changes: [] };
574
+ }
575
+ const changes = [];
576
+ changes.push(this.setTask({ ...task, state: "pending", pid: undefined, ttl: undefined }));
577
+ changes.push(this.setTTimeout({ id: req.data.id, type: 0, timeout: now + PENDING_RETRY_TTL }));
578
+ const promise = this.getPromiseOrThrow(req.data.id);
579
+ const address = this.getAddressOrThrow(promise);
580
+ changes.push(this.sendMessage(address, {
581
+ kind: "execute",
582
+ head: {},
583
+ data: { task: { id: req.data.id, version: task.version } },
584
+ }));
585
+ return { response: this.response("task.release", 200, {}), changes };
586
+ }
587
+ taskFulfill(now, req) {
588
+ const task = this.tasks.get(req.data.id);
589
+ if (!task) {
590
+ return { response: this.response("task.fulfill", 404, "Task not found"), changes: [] };
591
+ }
592
+ if (task.state !== "acquired") {
593
+ return { response: this.response("task.fulfill", 409, "Task not acquired"), changes: [] };
594
+ }
595
+ // Logical-state check: if the task's promise is logically settled, the
596
+ // task is logically fulfilled and cannot be fulfilled again.
597
+ const taskPromise = this.getPromiseOrThrow(req.data.id);
598
+ if (this.projectedPromise(now, taskPromise).state !== "pending") {
599
+ return { response: this.response("task.fulfill", 409, "Task not acquired"), changes: [] };
600
+ }
601
+ if (task.version !== req.data.version) {
602
+ return { response: this.response("task.fulfill", 409, "Version mismatch"), changes: [] };
603
+ }
604
+ const promise = this.getPromiseOrThrow(req.data.action.data.id);
605
+ const changes = [];
606
+ // Check if promise is already settled (possibly by auto-timeout above)
607
+ if (promise.state !== "pending") {
608
+ // Still fulfill the task but indicate the promise was already settled
609
+ changes.push(...this.triggerFulfilled(req.data.id));
610
+ return { response: this.response("task.fulfill", 200, { promise: this.toPromiseRecord(promise) }), changes };
611
+ }
612
+ // Settle the promise
613
+ const settledPromise = {
614
+ ...promise,
615
+ state: req.data.action.data.state,
616
+ value: req.data.action.data.value ?? {},
617
+ settledAt: now,
618
+ };
619
+ changes.push(this.setPromise(settledPromise));
620
+ changes.push(this.delPTimeout(req.data.action.data.id));
621
+ changes.push(...this.triggerSettlement(req.data.id, now));
622
+ return { response: this.response("task.fulfill", 200, { promise: this.toPromiseRecord(settledPromise) }), changes };
623
+ }
624
+ taskSuspend(now, req) {
625
+ const task = this.tasks.get(req.data.id);
626
+ if (!task) {
627
+ return { response: this.response("task.suspend", 404, "Task not found"), changes: [] };
628
+ }
629
+ if (task.state !== "acquired") {
630
+ return { response: this.response("task.suspend", 409, "Task not acquired"), changes: [] };
631
+ }
632
+ // Logical-state check: if the task's promise is logically settled, the
633
+ // task is logically fulfilled and cannot be suspended.
634
+ const taskPromise = this.getPromiseOrThrow(req.data.id);
635
+ if (this.projectedPromise(now, taskPromise).state !== "pending") {
636
+ return { response: this.response("task.suspend", 409, "Task not acquired"), changes: [] };
637
+ }
638
+ if (task.version !== req.data.version) {
639
+ return { response: this.response("task.suspend", 409, "Version mismatch"), changes: [] };
640
+ }
641
+ const changes = [];
642
+ // First pass: validate all promises exist and classify as pending or settled.
643
+ // Callbacks are only registered when ALL promises are pending (spec transition #40).
644
+ // If any promise is already settled, return 300 immediately with no callbacks (spec transition #39).
645
+ const pendingPromises = [];
646
+ let hasSettled = false;
647
+ for (const action of req.data.actions) {
648
+ const awaitedPromise = this.promises.get(action.data.awaited);
649
+ if (!awaitedPromise) {
650
+ return { response: this.response("task.suspend", 422, {}), changes: [] };
651
+ }
652
+ if (awaitedPromise.state === "pending") {
653
+ pendingPromises.push(awaitedPromise);
654
+ }
655
+ else {
656
+ hasSettled = true;
657
+ }
658
+ }
659
+ // Immediate resume — stay acquired, clear all resumes, no callbacks registered
660
+ if (hasSettled) {
661
+ changes.push(this.setTask({ ...task, resumes: new Set() }));
662
+ return { response: this.response("task.suspend", 300, { preload: this.preload(req.data.id) }), changes };
663
+ }
664
+ // All pending: register callbacks then suspend
665
+ for (const awaitedPromise of pendingPromises) {
666
+ awaitedPromise.callbacks.add(req.data.id);
667
+ changes.push(this.setPromise(awaitedPromise));
668
+ }
669
+ changes.push(this.setTask({ ...task, state: "suspended", pid: undefined, ttl: undefined, resumes: new Set() }));
670
+ changes.push(this.delTTimeout(req.data.id));
671
+ return { response: this.response("task.suspend", 200, {}), changes };
672
+ }
673
+ taskHalt(now, req) {
674
+ const task = this.tasks.get(req.data.id);
675
+ if (!task) {
676
+ return { response: this.response("task.halt", 404, "Task not found"), changes: [] };
677
+ }
678
+ if (task.state === "fulfilled") {
679
+ return { response: this.response("task.halt", 409, "Task is fulfilled"), changes: [] };
680
+ }
681
+ const changes = [];
682
+ if (task.state !== "halted") {
683
+ changes.push(this.setTask({ ...task, state: "halted", pid: undefined, ttl: undefined }));
684
+ changes.push(this.delTTimeout(req.data.id));
685
+ }
686
+ return { response: this.response("task.halt", 200, {}), changes };
687
+ }
688
+ taskContinue(now, req) {
689
+ const task = this.tasks.get(req.data.id);
690
+ if (!task) {
691
+ return { response: this.response("task.continue", 404, "Task not found"), changes: [] };
692
+ }
693
+ if (task.state !== "halted") {
694
+ return { response: this.response("task.continue", 409, "Task is not halted"), changes: [] };
695
+ }
696
+ const changes = [];
697
+ changes.push(this.setTask({ ...task, state: "pending" }));
698
+ changes.push(this.setTTimeout({ id: req.data.id, type: 0, timeout: now + PENDING_RETRY_TTL }));
699
+ const promise = this.getPromiseOrThrow(req.data.id);
700
+ const address = this.getAddressOrThrow(promise);
701
+ changes.push(this.sendMessage(address, {
702
+ kind: "execute",
703
+ head: {},
704
+ data: { task: { id: req.data.id, version: task.version } },
705
+ }));
706
+ return { response: this.response("task.continue", 200, {}), changes };
707
+ }
708
+ taskFence(now, req) {
709
+ const task = this.tasks.get(req.data.id);
710
+ if (!task) {
711
+ return { response: this.response("task.fence", 404, "Task not found"), changes: [] };
712
+ }
713
+ if (task.state !== "acquired") {
714
+ return { response: this.response("task.fence", 409, "Fence check failed"), changes: [] };
715
+ }
716
+ // Logical-state check: if the task's promise is logically settled,
717
+ // the task is logically fulfilled and the fence fails.
718
+ const taskPromise = this.getPromiseOrThrow(req.data.id);
719
+ if (this.projectedPromise(now, taskPromise).state !== "pending") {
720
+ return { response: this.response("task.fence", 409, "Fence check failed"), changes: [] };
721
+ }
722
+ if (task.version !== req.data.version) {
723
+ return { response: this.response("task.fence", 409, "Fence check failed"), changes: [] };
724
+ }
725
+ const action = req.data.action;
726
+ let inner;
727
+ if (action.kind === "promise.create") {
728
+ inner = this.promiseCreate(now, action);
729
+ }
730
+ else {
731
+ inner = this.promiseSettle(now, action);
732
+ }
733
+ const actionResponse = {
734
+ ...inner.response,
735
+ head: { corrId: action.head.corrId, status: inner.response.head.status, version: action.head.version },
736
+ };
737
+ return {
738
+ response: this.response("task.fence", 200, { action: actionResponse, preload: [] }),
739
+ changes: inner.changes,
740
+ };
741
+ }
742
+ taskHeartbeat(now, req) {
743
+ const changes = [];
744
+ for (const ref of req.data.tasks) {
745
+ const task = this.tasks.get(ref.id);
746
+ if (!task || task.state !== "acquired" || task.version !== ref.version || task.pid !== req.data.pid) {
747
+ continue;
748
+ }
749
+ // Logical-state check: if the task's promise is logically settled,
750
+ // the task is logically fulfilled and its lease cannot be refreshed.
751
+ const promise = this.getPromiseOrThrow(ref.id);
752
+ if (this.projectedPromise(now, promise).state !== "pending") {
753
+ continue;
754
+ }
755
+ const ttl = task.ttl ?? 30000;
756
+ changes.push(this.setTTimeout({ id: ref.id, type: 1, timeout: now + ttl }));
757
+ }
758
+ return { response: this.response("task.heartbeat", 200, {}), changes };
759
+ }
760
+ // ===========================================================================
761
+ // SCHEDULE OPERATIONS
762
+ // ===========================================================================
763
+ scheduleGet(req) {
764
+ const schedule = this.schedules.get(req.data.id);
765
+ if (!schedule) {
766
+ return { response: this.response("schedule.get", 404, "Schedule not found"), changes: [] };
767
+ }
768
+ return { response: this.response("schedule.get", 200, { schedule: this.toScheduleRecord(schedule) }), changes: [] };
769
+ }
770
+ scheduleCreate(now, req) {
771
+ const existingSchedule = this.schedules.get(req.data.id);
772
+ if (existingSchedule) {
773
+ return {
774
+ response: this.response("schedule.create", 200, { schedule: this.toScheduleRecord(existingSchedule) }),
775
+ changes: [],
776
+ };
777
+ }
778
+ let nextRunAt;
779
+ try {
780
+ const interval = CronExpressionParser.parse(req.data.cron, { currentDate: new Date(now) });
781
+ nextRunAt = interval.next().getTime();
782
+ }
783
+ catch {
784
+ return { response: this.response("schedule.create", 400, "Invalid cron expression"), changes: [] };
785
+ }
786
+ const changes = [];
787
+ const schedule = {
788
+ id: req.data.id,
789
+ cron: req.data.cron,
790
+ promiseId: req.data.promiseId,
791
+ promiseTimeout: req.data.promiseTimeout,
792
+ promiseParam: req.data.promiseParam,
793
+ promiseTags: req.data.promiseTags,
794
+ createdAt: now,
795
+ };
796
+ changes.push(this.setSTimeout({ id: schedule.id, timeout: nextRunAt }));
797
+ changes.push(this.setSchedule(schedule));
798
+ return { response: this.response("schedule.create", 200, { schedule: this.toScheduleRecord(schedule) }), changes };
799
+ }
800
+ scheduleDelete(req) {
801
+ const schedule = this.schedules.get(req.data.id);
802
+ if (!schedule) {
803
+ return { response: this.response("schedule.delete", 404, "Schedule not found"), changes: [] };
804
+ }
805
+ const changes = [];
806
+ changes.push(this.delSTimeout(req.data.id));
807
+ changes.push(this.delSchedule(req.data.id));
808
+ return { response: this.response("schedule.delete", 200, {}), changes };
809
+ }
810
+ // ===========================================================================
811
+ // DEBUG OPERATIONS
812
+ // ===========================================================================
813
+ debugStart() {
814
+ return { response: this.response("debug.start", 200, {}), changes: [] };
815
+ }
816
+ debugReset() {
817
+ this.promises.clear();
818
+ this.tasks.clear();
819
+ this.schedules.clear();
820
+ this.outgoing = [];
821
+ this.pTimeouts = [];
822
+ this.tTimeouts = [];
823
+ this.sTimeouts = [];
824
+ return { response: this.response("debug.reset", 200, {}), changes: [] };
825
+ }
826
+ debugSnap() {
827
+ return {
828
+ response: this.response("debug.snap", 200, {
829
+ promises: Array.from(this.promises.values()).map((p) => this.toPromiseRecord(p)),
830
+ promiseTimeouts: this.pTimeouts,
831
+ callbacks: Array.from(this.promises.values()).flatMap((p) => [...p.callbacks].map((awaiter) => ({ awaiter, awaited: p.id }))),
832
+ listeners: Array.from(this.promises.values()).flatMap((p) => [...p.listeners].map((address) => ({ id: p.id, address }))),
833
+ tasks: Array.from(this.tasks.values()).map((t) => this.toTaskRecord(t)),
834
+ taskTimeouts: this.tTimeouts,
835
+ messages: this.outgoing,
836
+ }),
837
+ changes: [],
838
+ };
839
+ }
840
+ debugTick(req) {
841
+ const now = req.data.time;
842
+ const changes = [];
843
+ const actions = [];
844
+ // Promise timeouts -> settle as rejected_timedout (or resolved for timer promises)
845
+ for (const pt of this.pTimeouts) {
846
+ if (now >= pt.timeout) {
847
+ const promise = this.getPromiseOrThrow(pt.id);
848
+ if (promise.state === "pending") {
849
+ const state = this.timeoutState(promise.tags);
850
+ actions.push({
851
+ kind: "promise.settle",
852
+ data: { id: pt.id, state },
853
+ });
854
+ }
855
+ }
856
+ }
857
+ // Task timeouts -> release (lease) or retry (pending)
858
+ for (const tt of this.tTimeouts) {
859
+ if (now < tt.timeout)
860
+ continue;
861
+ if (tt.type === 1) {
862
+ const task = this.getTaskOrThrow(tt.id);
863
+ if (task.state === "acquired") {
864
+ actions.push({
865
+ kind: "task.release",
866
+ data: { id: tt.id, version: task.version },
867
+ });
868
+ }
869
+ }
870
+ else if (tt.type === 0) {
871
+ const task = this.getTaskOrThrow(tt.id);
872
+ if (task.state === "pending") {
873
+ actions.push({
874
+ kind: "task.retry",
875
+ data: { id: tt.id, version: task.version },
876
+ });
877
+ }
878
+ }
879
+ }
880
+ // Apply actions to own state. Promise settlements are split into three
881
+ // phases to make the tick atomic — the result must not depend on the
882
+ // order promises appear in pTimeouts.
883
+ //
884
+ // Phase 1: Settle all expired promises (state change only).
885
+ // Phase 2: Fulfill tasks whose own promise settled (triggerFulfilled).
886
+ // Phase 3: Resume suspended callbacks of settled promises (triggerCallbacks).
887
+ //
888
+ // Phase 2 before phase 3 ensures that a task whose own promise settled
889
+ // is fulfilled before triggerCallbacks runs. This prevents a spurious
890
+ // suspended → pending (version++) → fulfilled path for tasks that
891
+ // should go directly suspended → fulfilled.
892
+ const settledIds = [];
893
+ // Phase 1: Settle promises
894
+ for (const action of actions) {
895
+ if (action.kind === "promise.settle") {
896
+ const promise = this.getPromiseOrThrow(action.data.id);
897
+ if (promise.state !== "pending")
898
+ continue;
899
+ changes.push(this.setPromise({
900
+ ...promise,
901
+ state: action.data.state,
902
+ value: {},
903
+ settledAt: promise.timeoutAt,
904
+ }));
905
+ changes.push(this.delPTimeout(action.data.id));
906
+ settledIds.push(action.data.id);
907
+ }
908
+ }
909
+ // Phase 2: Fulfill tasks whose own promise settled
910
+ for (const id of settledIds) {
911
+ changes.push(...this.triggerFulfilled(id));
912
+ }
913
+ // Phase 3: Resume suspended callbacks and notify listeners
914
+ for (const id of settledIds) {
915
+ changes.push(...this.triggerCallbacks(id, now));
916
+ changes.push(...this.triggerListeners(id));
917
+ }
918
+ for (const action of actions) {
919
+ if (action.kind === "task.release") {
920
+ const task = this.getTaskOrThrow(action.data.id);
921
+ if (task.state === "acquired" && task.version === action.data.version) {
922
+ changes.push(this.setTask({ ...task, state: "pending", pid: undefined, ttl: undefined }));
923
+ changes.push(this.setTTimeout({ id: action.data.id, type: 0, timeout: now + PENDING_RETRY_TTL }));
924
+ const promise = this.getPromiseOrThrow(action.data.id);
925
+ const address = this.getAddressOrThrow(promise);
926
+ changes.push(this.sendMessage(address, {
927
+ kind: "execute",
928
+ head: {},
929
+ data: { task: { id: action.data.id, version: task.version } },
930
+ }));
931
+ }
932
+ }
933
+ else if (action.kind === "task.retry") {
934
+ const task = this.getTaskOrThrow(action.data.id);
935
+ if (task.state === "pending") {
936
+ changes.push(this.setTTimeout({ id: action.data.id, type: 0, timeout: now + PENDING_RETRY_TTL }));
937
+ const promise = this.getPromiseOrThrow(action.data.id);
938
+ const address = this.getAddressOrThrow(promise);
939
+ changes.push(this.sendMessage(address, {
940
+ kind: "execute",
941
+ head: {},
942
+ data: { task: { id: action.data.id, version: task.version } },
943
+ }));
944
+ }
945
+ }
946
+ }
947
+ // Schedule timeouts -> create promises for due schedules
948
+ for (const st of this.sTimeouts) {
949
+ if (now < st.timeout)
950
+ continue;
951
+ const schedule = this.getScheduleOrThrow(st.id);
952
+ const promiseId = schedule.promiseId
953
+ .replaceAll("{{.id}}", schedule.id)
954
+ .replaceAll("{{.timestamp}}", String(st.timeout));
955
+ const { changes: createChanges } = this.promiseCreate(now, {
956
+ kind: "promise.create",
957
+ head: { corrId: "", version: "" },
958
+ data: {
959
+ id: promiseId,
960
+ timeoutAt: st.timeout + schedule.promiseTimeout,
961
+ param: schedule.promiseParam,
962
+ tags: { ...schedule.promiseTags, "resonate:schedule": schedule.id },
963
+ },
964
+ });
965
+ changes.push(...createChanges);
966
+ // Advance to next run
967
+ const interval = CronExpressionParser.parse(schedule.cron, {
968
+ currentDate: new Date(st.timeout),
969
+ });
970
+ const nextRunAt = interval.next().getTime();
971
+ schedule.lastRunAt = st.timeout;
972
+ changes.push(this.setSTimeout({ id: schedule.id, timeout: nextRunAt }));
973
+ changes.push(this.setSchedule(schedule));
974
+ }
975
+ return { response: this.response("debug.tick", 200, []), changes };
976
+ }
977
+ debugStop() {
978
+ return { response: this.response("debug.stop", 200, {}), changes: [] };
979
+ }
980
+ // ===========================================================================
981
+ // CONVERTERS
982
+ // ===========================================================================
983
+ toPromiseRecord(p) {
984
+ const { callbacks, listeners, settledAt, ...rest } = p;
985
+ return settledAt != null ? { ...rest, settledAt } : rest;
986
+ }
987
+ toTaskRecord(t) {
988
+ const record = { id: t.id, version: t.version, state: t.state, resumes: Array.from(t.resumes) };
989
+ if (t.pid !== undefined)
990
+ record.pid = t.pid;
991
+ if (t.ttl !== undefined)
992
+ record.ttl = t.ttl;
993
+ return record;
994
+ }
995
+ toScheduleRecord(s) {
996
+ const st = this.sTimeouts.find((e) => e.id === s.id);
997
+ const record = {
998
+ id: s.id,
999
+ cron: s.cron,
1000
+ promiseId: s.promiseId,
1001
+ promiseTimeout: s.promiseTimeout,
1002
+ promiseParam: s.promiseParam,
1003
+ promiseTags: s.promiseTags,
1004
+ createdAt: s.createdAt,
1005
+ nextRunAt: st.timeout,
1006
+ };
1007
+ if (s.lastRunAt != null) {
1008
+ record.lastRunAt = s.lastRunAt;
1009
+ }
1010
+ return record;
1011
+ }
1012
+ // ===========================================================================
1013
+ // HELPERS
1014
+ // ===========================================================================
1015
+ tryEagerTimeout(now, id) {
1016
+ if (this.timeoutMode !== "eager")
1017
+ return [];
1018
+ const promise = this.promises.get(id);
1019
+ if (!promise || promise.state !== "pending" || now < promise.timeoutAt) {
1020
+ return [];
1021
+ }
1022
+ const changes = [];
1023
+ const state = this.timeoutState(promise.tags);
1024
+ changes.push(this.setPromise({ ...promise, state, settledAt: promise.timeoutAt }));
1025
+ changes.push(this.delPTimeout(id));
1026
+ changes.push(...this.triggerSettlement(id, now));
1027
+ return changes;
1028
+ }
1029
+ triggerSettlement(id, now) {
1030
+ return [...this.triggerFulfilled(id), ...this.triggerCallbacks(id, now), ...this.triggerListeners(id)];
1031
+ }
1032
+ triggerFulfilled(promiseId) {
1033
+ const task = this.tasks.get(promiseId);
1034
+ if (!task)
1035
+ return [];
1036
+ if (task.state === "fulfilled")
1037
+ return [];
1038
+ const changes = [];
1039
+ changes.push(this.setTask({ ...task, state: "fulfilled", pid: undefined, ttl: undefined, resumes: new Set() }));
1040
+ changes.push(this.delTTimeout(promiseId));
1041
+ // Remove this task from all promise callbacks (delete callbacks where awaiter_id = task_id)
1042
+ for (const [, promise] of this.promises) {
1043
+ if (promise.callbacks.delete(promiseId)) {
1044
+ changes.push(this.setPromise(promise));
1045
+ }
1046
+ }
1047
+ return changes;
1048
+ }
1049
+ triggerCallbacks(promiseId, now) {
1050
+ const settledPromise = this.getPromiseOrThrow(promiseId);
1051
+ const changes = [];
1052
+ // Resume or buffer for all tasks that were awaiting this promise
1053
+ for (const awaiterId of settledPromise.callbacks) {
1054
+ const task = this.getTaskOrThrow(awaiterId);
1055
+ if (task.state === "suspended") {
1056
+ changes.push(this.setTask({ ...task, state: "pending", resumes: new Set([promiseId]) }));
1057
+ // Add task timeout entry back (type=0 for pending retry)
1058
+ changes.push(this.setTTimeout({
1059
+ id: awaiterId,
1060
+ type: 0,
1061
+ timeout: now + PENDING_RETRY_TTL,
1062
+ }));
1063
+ const awaiterPromise = this.getPromiseOrThrow(awaiterId);
1064
+ const address = this.getAddressOrThrow(awaiterPromise);
1065
+ changes.push(this.sendMessage(address, {
1066
+ kind: "execute",
1067
+ head: {},
1068
+ data: { task: { id: awaiterId, version: task.version } },
1069
+ }));
1070
+ }
1071
+ else if (task.state === "pending" || task.state === "acquired" || task.state === "halted") {
1072
+ // Buffer the resume — will be checked when task suspends or continues
1073
+ task.resumes.add(promiseId);
1074
+ changes.push(this.setTask(task));
1075
+ }
1076
+ }
1077
+ // Clear callbacks after processing
1078
+ settledPromise.callbacks.clear();
1079
+ changes.push(this.setPromise(settledPromise));
1080
+ return changes;
1081
+ }
1082
+ triggerListeners(promiseId) {
1083
+ const promise = this.getPromiseOrThrow(promiseId);
1084
+ if (promise.listeners.size === 0)
1085
+ return [];
1086
+ const changes = [];
1087
+ for (const address of promise.listeners) {
1088
+ changes.push(this.sendMessage(address, {
1089
+ kind: "unblock",
1090
+ head: {},
1091
+ data: { promise: this.toPromiseRecord(promise) },
1092
+ }));
1093
+ }
1094
+ promise.listeners.clear();
1095
+ changes.push(this.setPromise(promise));
1096
+ return changes;
1097
+ }
1098
+ preload(promiseId) {
1099
+ const promise = this.promises.get(promiseId);
1100
+ if (!promise)
1101
+ return [];
1102
+ const branch = promise.tags["resonate:branch"];
1103
+ if (!branch)
1104
+ return [];
1105
+ const results = [];
1106
+ for (const [, p] of this.promises) {
1107
+ if (p.id !== promiseId && p.tags["resonate:branch"] === branch) {
1108
+ results.push(this.toPromiseRecord(p));
1109
+ }
1110
+ }
1111
+ return results;
1112
+ }
1113
+ timeoutState(tags) {
1114
+ return tags["resonate:timer"] === "true" ? "resolved" : "rejected_timedout";
1115
+ }
1116
+ // Projection: return the LOGICAL view of a promise without mutating
1117
+ // storage. When physical state is `pending` and `now >= timeoutAt`, the
1118
+ // logical state is the terminal state implied by the timer tag. Used at
1119
+ // response-construction sites in read paths so callers always observe
1120
+ // logical state regardless of whether convergence has happened yet.
1121
+ //
1122
+ // Unguarded by timeoutMode: when timeoutMode === "eager", the physical
1123
+ // state already matches the logical state by the time this runs (because
1124
+ // tryEagerTimeout fired at the start of the action), so the projection
1125
+ // condition is never met and this is a no-op.
1126
+ projectedPromise(now, promise) {
1127
+ if (promise.state !== "pending" || now < promise.timeoutAt) {
1128
+ return promise;
1129
+ }
1130
+ return {
1131
+ ...promise,
1132
+ state: this.timeoutState(promise.tags),
1133
+ settledAt: promise.timeoutAt,
1134
+ };
1135
+ }
1136
+ // Projection: return the LOGICAL view of a task without mutating storage.
1137
+ // A task's logical state is bound to its promise's logical state — when
1138
+ // the promise is logically settled, the task is logically fulfilled.
1139
+ //
1140
+ // Unguarded for the same reason as projectedPromise.
1141
+ projectedTask(now, task) {
1142
+ const promise = this.promises.get(task.id);
1143
+ if (!promise)
1144
+ return task;
1145
+ const projected = this.projectedPromise(now, promise);
1146
+ if (projected.state === "pending")
1147
+ return task;
1148
+ if (task.state === "fulfilled")
1149
+ return task;
1150
+ return { ...task, state: "fulfilled", pid: undefined, ttl: undefined };
1151
+ }
1152
+ getPromiseOrThrow(id) {
1153
+ const promise = this.promises.get(id);
1154
+ if (!promise) {
1155
+ throw new Error(`Invariant violation: promise ${id} not found`);
1156
+ }
1157
+ return promise;
1158
+ }
1159
+ getAddressOrThrow(promise) {
1160
+ const address = promise.tags["resonate:target"];
1161
+ if (!address) {
1162
+ throw new Error(`Invariant violation: promise ${promise.id} has no resonate:target tag`);
1163
+ }
1164
+ return address;
1165
+ }
1166
+ getTaskOrThrow(id) {
1167
+ const task = this.tasks.get(id);
1168
+ if (!task) {
1169
+ throw new Error(`Invariant violation: task ${id} not found`);
1170
+ }
1171
+ return task;
1172
+ }
1173
+ getScheduleOrThrow(id) {
1174
+ const schedule = this.schedules.get(id);
1175
+ if (!schedule) {
1176
+ throw new Error(`Invariant violation: schedule ${id} not found`);
1177
+ }
1178
+ return schedule;
1179
+ }
1180
+ // ===========================================================================
1181
+ // ACCESSORS (change-tracking)
1182
+ // ===========================================================================
1183
+ setPromise(p) {
1184
+ this.promises.set(p.id, p);
1185
+ return { kind: "promise.set", promise: this.toPromiseRecord(p) };
1186
+ }
1187
+ setTask(t) {
1188
+ this.tasks.set(t.id, t);
1189
+ return { kind: "task.set", task: this.toTaskRecord(t) };
1190
+ }
1191
+ setSchedule(s) {
1192
+ this.schedules.set(s.id, s);
1193
+ return { kind: "schedule.set", schedule: this.toScheduleRecord(s) };
1194
+ }
1195
+ delSchedule(id) {
1196
+ this.schedules.delete(id);
1197
+ return { kind: "schedule.del", id };
1198
+ }
1199
+ setPTimeout(pt) {
1200
+ const idx = this.pTimeouts.findIndex((e) => e.id === pt.id);
1201
+ if (idx !== -1) {
1202
+ this.pTimeouts[idx] = pt;
1203
+ }
1204
+ else {
1205
+ this.pTimeouts.push(pt);
1206
+ }
1207
+ return { kind: "ptimeout.set", timeout: { id: pt.id, timeout: pt.timeout } };
1208
+ }
1209
+ delPTimeout(id) {
1210
+ const idx = this.pTimeouts.findIndex((e) => e.id === id);
1211
+ if (idx !== -1) {
1212
+ this.pTimeouts.splice(idx, 1);
1213
+ }
1214
+ return { kind: "ptimeout.del", id };
1215
+ }
1216
+ setTTimeout(tt) {
1217
+ const idx = this.tTimeouts.findIndex((e) => e.id === tt.id);
1218
+ if (idx !== -1) {
1219
+ this.tTimeouts[idx] = tt;
1220
+ }
1221
+ else {
1222
+ this.tTimeouts.push(tt);
1223
+ }
1224
+ return { kind: "ttimeout.set", timeout: { id: tt.id, type: tt.type, timeout: tt.timeout } };
1225
+ }
1226
+ delTTimeout(id) {
1227
+ const idx = this.tTimeouts.findIndex((e) => e.id === id);
1228
+ if (idx !== -1) {
1229
+ this.tTimeouts.splice(idx, 1);
1230
+ }
1231
+ return { kind: "ttimeout.del", id };
1232
+ }
1233
+ setSTimeout(st) {
1234
+ const idx = this.sTimeouts.findIndex((e) => e.id === st.id);
1235
+ if (idx !== -1) {
1236
+ this.sTimeouts[idx] = st;
1237
+ }
1238
+ else {
1239
+ this.sTimeouts.push(st);
1240
+ }
1241
+ return { kind: "stimeout.set", timeout: { id: st.id, timeout: st.timeout } };
1242
+ }
1243
+ delSTimeout(id) {
1244
+ const idx = this.sTimeouts.findIndex((e) => e.id === id);
1245
+ if (idx !== -1) {
1246
+ this.sTimeouts.splice(idx, 1);
1247
+ }
1248
+ return { kind: "stimeout.del", id };
1249
+ }
1250
+ // FIX: Change from accumulate (push) to upsert by task ID.
1251
+ // A task can only be claimed by one worker at one version, so when a task
1252
+ // is resumed (version incremented), the previous message is obsolete.
1253
+ // Upsert keeps only the latest message per task ID, matching the HTTP's
1254
+ // outgoing table behavior (ON CONFLICT (id) DO UPDATE).
1255
+ sendMessage(address, msg) {
1256
+ if (msg.kind === "execute") {
1257
+ const taskId = msg.data.task.id;
1258
+ const idx = this.outgoing.findIndex((m) => m.message.kind === "execute" && m.message.data.task.id === taskId);
1259
+ if (idx >= 0) {
1260
+ this.outgoing[idx] = { address, message: msg };
1261
+ }
1262
+ else {
1263
+ this.outgoing.push({ address, message: msg });
1264
+ }
1265
+ }
1266
+ else {
1267
+ this.outgoing.push({ address, message: msg });
1268
+ }
1269
+ return { kind: "message.send", address, message: msg };
1270
+ }
1271
+ // OLD: accumulate (push) — causes divergence with HTTP snapshots
1272
+ // private sendMessage(address: string, msg: Message): Change {
1273
+ // this.outgoing.push({ address, message: msg });
1274
+ // return { kind: "message.send", address, message: msg };
1275
+ // }
1276
+ response(kind, status, data) {
1277
+ return { kind, head: { status }, data };
1278
+ }
1279
+ }
1280
+ // =============================================================================
1281
+ // LOCAL NETWORK
1282
+ // =============================================================================
1283
+ export class LocalNetwork {
1284
+ unicast;
1285
+ anycast;
1286
+ started;
1287
+ server;
1288
+ subscribers = [];
1289
+ tickInterval;
1290
+ constructor({ pid = crypto.randomUUID().replace(/-/g, ""), group = "default", } = {}) {
1291
+ this.started = false;
1292
+ this.server = new Server();
1293
+ this.unicast = `local://uni@${group}/${pid}`;
1294
+ this.anycast = `local://any@${group}/${pid}`;
1295
+ }
1296
+ // -- Network ---------------------------------------------------------------
1297
+ match(target) {
1298
+ return `local://any@${target}`;
1299
+ }
1300
+ async init() {
1301
+ if (this.started)
1302
+ return;
1303
+ this.tickInterval = setInterval(() => {
1304
+ const now = Date.now();
1305
+ const result = this.server.apply(now, {
1306
+ kind: "debug.tick",
1307
+ head: { corrId: randomUUID(), version: VERSION },
1308
+ data: { time: now },
1309
+ });
1310
+ this.dispatchMessages(result);
1311
+ }, 1000);
1312
+ this.started = true;
1313
+ }
1314
+ async stop() {
1315
+ if (this.tickInterval) {
1316
+ clearInterval(this.tickInterval);
1317
+ }
1318
+ this.subscribers = [];
1319
+ this.started = false;
1320
+ }
1321
+ send = (req) => {
1322
+ const { corrId, version } = req.head;
1323
+ const now = Date.now();
1324
+ const result = this.server.apply(now, req);
1325
+ const response = result.response;
1326
+ const res = {
1327
+ kind: response.kind,
1328
+ head: { corrId, status: response.head.status, version },
1329
+ data: response.data,
1330
+ };
1331
+ assert(isResponse(res));
1332
+ setTimeout(() => this.dispatchMessages(result), 0);
1333
+ return Promise.resolve(res);
1334
+ };
1335
+ recv(callback) {
1336
+ this.subscribers.push(callback);
1337
+ }
1338
+ // -- internal: message dispatch --------------------------------------------
1339
+ dispatchMessages(result) {
1340
+ for (const change of result.changes) {
1341
+ if (change.kind !== "message.send")
1342
+ continue;
1343
+ for (const cb of this.subscribers) {
1344
+ cb(change.message);
1345
+ }
1346
+ }
1347
+ }
1348
+ }
1349
+ //# sourceMappingURL=local.js.map