@boxxy/dispatch 0.1.1 → 0.1.3

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 (190) hide show
  1. package/dist/attempts/attempt-machine.d.ts +804 -0
  2. package/dist/attempts/attempt-moves.d.ts +2 -0
  3. package/dist/attempts/bind-attempt-session.d.ts +14 -0
  4. package/dist/attempts/build-attempt-change.d.ts +73 -0
  5. package/dist/attempts/build-attempt-fact.d.ts +3 -0
  6. package/dist/attempts/end-item-attempts.d.ts +18 -0
  7. package/dist/attempts/is-same-guard.d.ts +5 -0
  8. package/dist/attempts/pick-call-attempt.d.ts +10 -0
  9. package/dist/attempts/stop-stray-session.d.ts +11 -0
  10. package/dist/attempts/types.d.ts +142 -0
  11. package/dist/attempts/write-attempt-move.d.ts +7 -0
  12. package/dist/attempts/write-session-bind.d.ts +3 -0
  13. package/dist/claims/build-snapshot-hash.d.ts +2 -0
  14. package/dist/claims/claim-next.d.ts +6 -0
  15. package/dist/claims/pick-kept-session.d.ts +9 -0
  16. package/dist/claims/request-wake.d.ts +3 -0
  17. package/dist/claims/resume-claim.d.ts +3 -0
  18. package/dist/claims/start-claim.d.ts +3 -0
  19. package/dist/claims/types.d.ts +44 -0
  20. package/dist/closeouts/build-settle-plans.d.ts +17 -0
  21. package/dist/closeouts/close-out-attempt.d.ts +3 -0
  22. package/dist/closeouts/effect-handlers.d.ts +2 -0
  23. package/dist/closeouts/pick-settlement.d.ts +14 -0
  24. package/dist/closeouts/settle-attempt.d.ts +3 -0
  25. package/dist/closeouts/types.d.ts +50 -0
  26. package/dist/index.d.ts +38 -0
  27. package/dist/notices/constants.d.ts +4 -0
  28. package/dist/notices/create-null-notifier.d.ts +2 -0
  29. package/dist/notices/send-notices.d.ts +2 -0
  30. package/dist/notices/types.d.ts +11 -0
  31. package/dist/outbox/build-backoff-ms.d.ts +2 -0
  32. package/dist/outbox/build-initial-sender-state.d.ts +2 -0
  33. package/dist/outbox/build-outbox-status.d.ts +15 -0
  34. package/dist/outbox/constants.d.ts +2 -0
  35. package/dist/outbox/measure-wire-bytes.d.ts +2 -0
  36. package/dist/outbox/pick-sender-move.d.ts +10 -0
  37. package/dist/outbox/run-sender-once.d.ts +2 -0
  38. package/dist/outbox/take-batch.d.ts +3 -0
  39. package/dist/outbox/types.d.ts +61 -0
  40. package/dist/pull-requests/build-link-change.d.ts +11 -0
  41. package/dist/pull-requests/build-state-fact.d.ts +8 -0
  42. package/dist/pull-requests/collect-attempt-trailers.d.ts +2 -0
  43. package/dist/pull-requests/collect-seen-state.d.ts +7 -0
  44. package/dist/pull-requests/constants.d.ts +3 -0
  45. package/dist/pull-requests/link-pull-request.d.ts +2 -0
  46. package/dist/pull-requests/parse-pull-request-url.d.ts +2 -0
  47. package/dist/pull-requests/types.d.ts +37 -0
  48. package/dist/pull-requests/watch-pull-requests.d.ts +2 -0
  49. package/dist/readings/act-on-reading.d.ts +3 -0
  50. package/dist/readings/find-waiting-since.d.ts +9 -0
  51. package/dist/readings/pick-reading-action.d.ts +2 -0
  52. package/dist/readings/types.d.ts +79 -0
  53. package/dist/sessions/build-keep-writes.d.ts +9 -0
  54. package/dist/sessions/build-kept-clear.d.ts +2 -0
  55. package/dist/sessions/forget-sessions.d.ts +2 -0
  56. package/dist/sessions/is-kept-due.d.ts +2 -0
  57. package/dist/sessions/resume-kept-session.d.ts +6 -0
  58. package/dist/sessions/types.d.ts +19 -0
  59. package/dist/test-utils/apply-memory-change.d.ts +3 -0
  60. package/dist/test-utils/build-memory-reader.d.ts +3 -0
  61. package/dist/test-utils/build-mock-claim-change.d.ts +2 -2
  62. package/dist/test-utils/build-mock-new-id.d.ts +2 -0
  63. package/dist/test-utils/build-mock-note-change.d.ts +2 -0
  64. package/dist/test-utils/build-mock-pull-request-change.d.ts +1 -0
  65. package/dist/test-utils/{build-mock-link-change.d.ts → build-mock-relation-change.d.ts} +2 -2
  66. package/dist/test-utils/build-mock-running-reading.d.ts +6 -0
  67. package/dist/test-utils/build-mock-start-change.d.ts +2 -2
  68. package/dist/test-utils/claim-store-close-cases.d.ts +2 -0
  69. package/dist/test-utils/claim-store-copy-cases.d.ts +2 -0
  70. package/dist/test-utils/claim-store-outbox-cases.d.ts +2 -0
  71. package/dist/test-utils/claim-store-race-cases.d.ts +2 -0
  72. package/dist/test-utils/claim-store-seat-cases.d.ts +2 -0
  73. package/dist/test-utils/claim-store-session-cases.d.ts +2 -0
  74. package/dist/test-utils/collect-memory-limit-fills.d.ts +3 -0
  75. package/dist/test-utils/compare-text.d.ts +1 -0
  76. package/dist/test-utils/create-fake-backoff.d.ts +2 -0
  77. package/dist/test-utils/create-fake-clock.d.ts +5 -0
  78. package/dist/test-utils/create-fake-fact-sink.d.ts +8 -0
  79. package/dist/test-utils/create-fake-forge.d.ts +17 -0
  80. package/dist/test-utils/create-fake-worker.d.ts +42 -0
  81. package/dist/test-utils/create-memory-claim-store.d.ts +2 -2
  82. package/dist/test-utils/create-test-dispatch.d.ts +46 -0
  83. package/dist/test-utils/forge-contract.d.ts +15 -0
  84. package/dist/test-utils/is-same-held-seat.d.ts +2 -0
  85. package/dist/test-utils/types.d.ts +12 -2
  86. package/dist/test-utils/worker-contract.d.ts +14 -0
  87. package/dist/timeouts.d.ts +2 -0
  88. package/dist/types.d.ts +13 -0
  89. package/package.json +10 -3
  90. package/src/attempts/attempt-machine.ts +147 -0
  91. package/src/attempts/attempt-moves.ts +218 -0
  92. package/src/attempts/bind-attempt-session.ts +61 -0
  93. package/src/attempts/build-attempt-change.ts +540 -0
  94. package/src/attempts/build-attempt-fact.ts +135 -0
  95. package/src/attempts/end-item-attempts.ts +95 -0
  96. package/src/attempts/is-same-guard.ts +22 -0
  97. package/src/attempts/pick-call-attempt.ts +32 -0
  98. package/src/attempts/stop-stray-session.ts +48 -0
  99. package/src/attempts/types.ts +231 -0
  100. package/src/attempts/write-attempt-move.ts +41 -0
  101. package/src/attempts/write-session-bind.ts +47 -0
  102. package/src/claims/build-snapshot-hash.ts +9 -0
  103. package/src/claims/claim-next.ts +306 -0
  104. package/src/claims/pick-kept-session.ts +41 -0
  105. package/src/claims/request-wake.ts +17 -0
  106. package/src/claims/resume-claim.ts +56 -0
  107. package/src/claims/start-claim.ts +99 -0
  108. package/src/claims/types.ts +67 -0
  109. package/src/closeouts/build-settle-plans.ts +173 -0
  110. package/src/closeouts/close-out-attempt.ts +156 -0
  111. package/src/closeouts/effect-handlers.ts +232 -0
  112. package/src/closeouts/pick-settlement.ts +80 -0
  113. package/src/closeouts/settle-attempt.ts +161 -0
  114. package/src/closeouts/types.ts +87 -0
  115. package/src/index.ts +61 -0
  116. package/src/notices/constants.ts +7 -0
  117. package/src/notices/create-null-notifier.ts +6 -0
  118. package/src/notices/send-notices.ts +41 -0
  119. package/src/notices/types.ts +14 -0
  120. package/src/outbox/build-backoff-ms.ts +14 -0
  121. package/src/outbox/build-initial-sender-state.ts +14 -0
  122. package/src/outbox/build-outbox-status.ts +43 -0
  123. package/src/outbox/constants.ts +15 -0
  124. package/src/outbox/measure-wire-bytes.ts +31 -0
  125. package/src/outbox/pick-sender-move.ts +37 -0
  126. package/src/outbox/run-sender-once.ts +174 -0
  127. package/src/outbox/take-batch.ts +35 -0
  128. package/src/outbox/types.ts +76 -0
  129. package/src/pull-requests/build-link-change.ts +95 -0
  130. package/src/pull-requests/build-state-fact.ts +39 -0
  131. package/src/pull-requests/collect-attempt-trailers.ts +20 -0
  132. package/src/pull-requests/collect-seen-state.ts +19 -0
  133. package/src/pull-requests/constants.ts +5 -0
  134. package/src/pull-requests/link-pull-request.ts +119 -0
  135. package/src/pull-requests/parse-pull-request-url.ts +30 -0
  136. package/src/pull-requests/types.ts +63 -0
  137. package/src/pull-requests/watch-pull-requests.ts +308 -0
  138. package/src/readings/act-on-reading.ts +318 -0
  139. package/src/readings/find-waiting-since.ts +46 -0
  140. package/src/readings/pick-reading-action.ts +315 -0
  141. package/src/readings/types.ts +98 -0
  142. package/src/sessions/build-keep-writes.ts +55 -0
  143. package/src/sessions/build-kept-clear.ts +12 -0
  144. package/src/sessions/forget-sessions.ts +106 -0
  145. package/src/sessions/is-kept-due.ts +27 -0
  146. package/src/sessions/resume-kept-session.ts +25 -0
  147. package/src/sessions/types.ts +39 -0
  148. package/src/test-utils/apply-memory-change.ts +252 -0
  149. package/src/test-utils/build-memory-reader.ts +364 -0
  150. package/src/test-utils/build-mock-bind-change.ts +18 -11
  151. package/src/test-utils/build-mock-claim-change.ts +18 -8
  152. package/src/test-utils/build-mock-file-item-change.ts +3 -1
  153. package/src/test-utils/build-mock-new-id.ts +12 -0
  154. package/src/test-utils/build-mock-note-change.ts +18 -0
  155. package/src/test-utils/build-mock-pause-change.ts +2 -10
  156. package/src/test-utils/build-mock-pull-request-change.ts +19 -9
  157. package/src/test-utils/{build-mock-link-change.ts → build-mock-relation-change.ts} +6 -6
  158. package/src/test-utils/build-mock-running-reading.ts +19 -0
  159. package/src/test-utils/build-mock-settle-change.ts +18 -6
  160. package/src/test-utils/build-mock-start-change.ts +13 -6
  161. package/src/test-utils/claim-store-attempt-reader-cases.ts +163 -133
  162. package/src/test-utils/claim-store-claim-cases.ts +168 -85
  163. package/src/test-utils/claim-store-close-cases.ts +95 -0
  164. package/src/test-utils/claim-store-contract.ts +12 -2
  165. package/src/test-utils/claim-store-copy-cases.ts +100 -0
  166. package/src/test-utils/claim-store-exclusive-cases.ts +65 -19
  167. package/src/test-utils/claim-store-guard-cases.ts +92 -82
  168. package/src/test-utils/claim-store-item-reader-cases.ts +291 -77
  169. package/src/test-utils/claim-store-outbox-cases.ts +281 -0
  170. package/src/test-utils/claim-store-race-cases.ts +150 -0
  171. package/src/test-utils/claim-store-rollback-cases.ts +50 -50
  172. package/src/test-utils/claim-store-seat-cases.ts +270 -0
  173. package/src/test-utils/claim-store-session-cases.ts +289 -0
  174. package/src/test-utils/collect-memory-limit-fills.ts +15 -0
  175. package/src/test-utils/compare-text.ts +8 -0
  176. package/src/test-utils/create-fake-backoff.ts +16 -0
  177. package/src/test-utils/create-fake-clock.ts +20 -0
  178. package/src/test-utils/create-fake-fact-sink.ts +74 -0
  179. package/src/test-utils/create-fake-forge.ts +117 -0
  180. package/src/test-utils/create-fake-worker.ts +303 -0
  181. package/src/test-utils/create-memory-claim-store.ts +73 -484
  182. package/src/test-utils/create-test-dispatch.ts +597 -0
  183. package/src/test-utils/forge-contract.ts +171 -0
  184. package/src/test-utils/is-same-held-seat.ts +11 -0
  185. package/src/test-utils/types.ts +14 -2
  186. package/src/test-utils/worker-contract.ts +192 -0
  187. package/src/timeouts.ts +17 -0
  188. package/src/types.ts +32 -0
  189. package/dist/test-utils/claim-store-pool-cases.d.ts +0 -2
  190. package/src/test-utils/claim-store-pool-cases.ts +0 -178
@@ -0,0 +1,540 @@
1
+ import type {
2
+ ActorRef,
3
+ AnyNewFact,
4
+ AttemptRow,
5
+ AttemptSessionRow,
6
+ AttemptState,
7
+ DecisionRow,
8
+ FactId,
9
+ FactType,
10
+ Instant,
11
+ ItemId,
12
+ LeaseRow,
13
+ NewId,
14
+ PhaseName,
15
+ SeatRelease,
16
+ SeatTake,
17
+ StartPick,
18
+ } from '@boxxy/model';
19
+ import { brandSchemas } from '@boxxy/model';
20
+ import invariant from 'tiny-invariant';
21
+ import { P, match } from 'ts-pattern';
22
+ import { initialTransition, isUnhandled, transition } from 'xstate';
23
+ import { attemptMachine } from './attempt-machine.ts';
24
+ import { buildAttemptFact } from './build-attempt-fact.ts';
25
+ import type { AttemptGuard } from './is-same-guard.ts';
26
+ import type {
27
+ AttemptContext,
28
+ AttemptEvent,
29
+ AttemptMachineEvent,
30
+ ClaimedEvent,
31
+ DecisionDraft,
32
+ MoveEffect,
33
+ MoveEvent,
34
+ MoveParts,
35
+ } from './types.ts';
36
+
37
+ type AttemptWrite =
38
+ | { readonly op: 'insert'; readonly table: 'attempts'; readonly row: AttemptRow }
39
+ | {
40
+ readonly op: 'update';
41
+ readonly table: 'attempts';
42
+ readonly key: { readonly id: AttemptRow['id'] };
43
+
44
+ // each guard column as the builder read it, so a move that another caller applied first
45
+ // makes this change a `guard-mismatch`
46
+ readonly expect: AttemptGuard;
47
+ readonly set: Partial<AttemptRow>;
48
+ }
49
+ | { readonly op: 'insert'; readonly table: 'attempt_sessions'; readonly row: AttemptSessionRow }
50
+ | { readonly op: 'insert'; readonly table: 'leases'; readonly row: LeaseRow }
51
+ | {
52
+ readonly op: 'update';
53
+ readonly table: 'leases';
54
+ readonly key: Pick<LeaseRow, 'item' | 'phase' | 'gen'>;
55
+ readonly expect: Pick<LeaseRow, 'attempt' | 'releasedAt'>;
56
+ readonly set: Pick<LeaseRow, 'releasedAt'>;
57
+ }
58
+ | { readonly op: 'insert'; readonly table: 'decisions'; readonly row: DecisionRow }
59
+ | { readonly op: 'take-seats'; readonly take: SeatTake }
60
+ | { readonly op: 'release-seats'; readonly release: SeatRelease };
61
+
62
+ interface AttemptStoreChange {
63
+ readonly at: Instant;
64
+
65
+ // the live lease and hold generation the change was built under; null for the claim, which
66
+ // takes the lease, and for a settled attempt, whose lease is released
67
+ readonly under: {
68
+ readonly item: ItemId;
69
+ readonly phase: PhaseName;
70
+ readonly gen: number;
71
+ readonly holdGen: number;
72
+ } | null;
73
+ readonly writes: readonly AttemptWrite[];
74
+
75
+ // empty for a move that records no fact, such as the suspend mark
76
+ readonly facts: readonly AnyNewFact[];
77
+ }
78
+
79
+ export type AttemptChange =
80
+ | { readonly kind: 'change'; readonly change: AttemptStoreChange }
81
+ | {
82
+ readonly kind: 'not-allowed';
83
+ readonly state: AttemptState;
84
+ readonly event: AttemptEvent['kind'];
85
+ };
86
+
87
+ interface BuildAttemptChangeBase {
88
+ readonly actor: ActorRef;
89
+ readonly now: Instant;
90
+ readonly newId: NewId;
91
+ }
92
+
93
+ export type BuildAttemptChangeInput = BuildAttemptChangeBase &
94
+ (
95
+ | { readonly attempt: null; readonly event: ClaimedEvent }
96
+ | { readonly attempt: AttemptRow; readonly event: MoveEvent }
97
+ );
98
+
99
+ // The one builder of every write to the attempts and attempt sessions tables, and of every seat
100
+ // write. The attempt machine decides whether the move is allowed and which writes and facts it
101
+ // makes; this function fills them in.
102
+ export function buildAttemptChange(input: BuildAttemptChangeInput): AttemptChange {
103
+ if (input.attempt === null) {
104
+ return { kind: 'change', change: buildClaimedChange(input, input.event) };
105
+ }
106
+
107
+ return buildMoveChange(input, input.attempt, input.event);
108
+ }
109
+
110
+ function buildClaimedChange(base: BuildAttemptChangeBase, event: ClaimedEvent): AttemptStoreChange {
111
+ const [snapshot, effects] = initialTransition(attemptMachine, { mode: event.mode });
112
+
113
+ const row: AttemptRow = {
114
+ id: event.attempt,
115
+ item: event.pick.item,
116
+ phase: event.pick.phase,
117
+ number: event.number,
118
+ runner: event.runner,
119
+ recipeHash: event.recipe.hash,
120
+ rulesVersion: event.recipe.rulesVersion,
121
+ pool: event.pick.pool,
122
+ leaseGen: event.leaseGen,
123
+ spawnRequest: event.spawnRequest,
124
+ spawnRequestHash: event.spawnRequestHash,
125
+ state: toAttemptState(snapshot.value),
126
+ failure: null,
127
+ atcSession: null,
128
+ caller: event.caller,
129
+ claimedAt: base.now,
130
+ startedAt: null,
131
+ endedAt: null,
132
+ fields: event.pick.fields,
133
+ mode: event.mode,
134
+ round: event.pick.round,
135
+ input: event.input,
136
+ holdGen: snapshot.context.holdGen,
137
+ closeOut: null,
138
+ closeOutHash: null,
139
+ closeOutFact: null,
140
+ closing: null,
141
+ closingAt: null,
142
+ suspendRequestedAt: null,
143
+ sleptAt: null,
144
+ wakeRequestedAt: null,
145
+ wokenAt: null,
146
+ outcome: null,
147
+ returnedOutcome: null,
148
+ strayedAt: null,
149
+ sessionStoppedAt: null,
150
+ sessionForgottenAt: null,
151
+ };
152
+
153
+ const parts: MoveParts = {
154
+ before: row,
155
+ after: row,
156
+ event,
157
+ decision: buildDecisionRow(base, row, event.decision),
158
+ };
159
+
160
+ const moveEffects = collectMoveEffects(effects);
161
+
162
+ return {
163
+ at: base.now,
164
+ under: null,
165
+ writes: [
166
+ { op: 'insert', table: 'attempts', row },
167
+ ...collectEffectWrites(moveEffects, parts, base.now),
168
+ ],
169
+ facts: collectEffectFacts(buildFactIDs(moveEffects, base), parts, base),
170
+ };
171
+ }
172
+
173
+ function toAttemptState(value: unknown): AttemptState {
174
+ invariant(isAttemptState(value), `the attempt machine has no state ${String(value)}`);
175
+
176
+ return value;
177
+ }
178
+
179
+ const attemptStates: readonly string[] = [
180
+ 'starting',
181
+ 'running',
182
+ 'reconciling',
183
+ 'sleeping',
184
+ 'waking',
185
+ 'closing',
186
+ 'succeeded',
187
+ 'failed',
188
+ ] satisfies readonly AttemptState[];
189
+
190
+ function isAttemptState(value: unknown): value is AttemptState {
191
+ return typeof value === 'string' && attemptStates.includes(value);
192
+ }
193
+
194
+ function buildDecisionRow(
195
+ base: BuildAttemptChangeBase,
196
+ attempt: AttemptRow,
197
+ draft: DecisionDraft,
198
+ ): DecisionRow {
199
+ return {
200
+ ...draft,
201
+ id: brandSchemas.decisionId.parse(base.newId()),
202
+ attempt: attempt.id,
203
+ at: base.now,
204
+ };
205
+ }
206
+
207
+ function collectMoveEffects(effects: readonly unknown[]): MoveEffect[] {
208
+ return effects.flatMap((effect) => (isEffectEmit(effect) ? [effect.event.effect] : []));
209
+ }
210
+
211
+ interface EffectEmit {
212
+ readonly kind: 'emit';
213
+ readonly event: { readonly type: 'effect'; readonly effect: MoveEffect };
214
+ }
215
+
216
+ function isEffectEmit(effect: unknown): effect is EffectEmit {
217
+ return (
218
+ typeof effect === 'object' &&
219
+ effect !== null &&
220
+ 'kind' in effect &&
221
+ effect.kind === 'emit' &&
222
+ 'event' in effect &&
223
+ typeof effect.event === 'object' &&
224
+ effect.event !== null &&
225
+ 'type' in effect.event &&
226
+ effect.event.type === 'effect'
227
+ );
228
+ }
229
+
230
+ function collectEffectWrites(
231
+ effects: readonly MoveEffect[],
232
+ parts: MoveParts,
233
+ now: Instant,
234
+ ): AttemptWrite[] {
235
+ return effects.flatMap((effect) =>
236
+ isFactEffect(effect) ? [] : [buildEffectWrite(effect, parts, now)],
237
+ );
238
+ }
239
+
240
+ function isFactEffect(effect: MoveEffect): effect is FactType {
241
+ return effect.startsWith('boxxy.');
242
+ }
243
+
244
+ function buildEffectWrite(
245
+ effect: Exclude<MoveEffect, FactType>,
246
+ parts: MoveParts,
247
+ now: Instant,
248
+ ): AttemptWrite {
249
+ const before = parts.before;
250
+ const after = parts.after;
251
+
252
+ return match(effect)
253
+ .with('take-lease', () => ({
254
+ op: 'insert' as const,
255
+ table: 'leases' as const,
256
+ row: {
257
+ item: after.item,
258
+ phase: after.phase,
259
+ attempt: after.id,
260
+ gen: after.leaseGen,
261
+ takenAt: now,
262
+ releasedAt: null,
263
+ },
264
+ }))
265
+ .with('release-lease', () => ({
266
+ op: 'update' as const,
267
+ table: 'leases' as const,
268
+ key: { item: before.item, phase: before.phase, gen: before.leaseGen },
269
+ expect: { attempt: before.id, releasedAt: null },
270
+ set: { releasedAt: now },
271
+ }))
272
+ .with('take-seats', () => ({
273
+ op: 'take-seats' as const,
274
+ take: {
275
+ limits: getSeatLimits(parts.event),
276
+ attempt: after.id,
277
+ holdGen: after.holdGen,
278
+ overCap: parts.event.kind === 'woken' && parts.event.overCap,
279
+ },
280
+ }))
281
+ .with('release-seats', () => ({
282
+ op: 'release-seats' as const,
283
+ release: { attempt: before.id, holdGen: before.holdGen },
284
+ }))
285
+ .with('record-decision', () => {
286
+ invariant(parts.decision !== null, 'a move that records a decision carries one');
287
+
288
+ return { op: 'insert' as const, table: 'decisions' as const, row: parts.decision };
289
+ })
290
+ .with('bind-session', () => {
291
+ invariant(parts.event.kind === 'session-seen', 'only session-seen binds a session');
292
+
293
+ return { op: 'insert' as const, table: 'attempt_sessions' as const, row: parts.event.row };
294
+ })
295
+ .exhaustive();
296
+ }
297
+
298
+ function getSeatLimits(event: AttemptEvent): StartPick['limits'] {
299
+ invariant(event.kind === 'claimed' || event.kind === 'woken', 'only a pick takes seats');
300
+
301
+ return event.pick.limits;
302
+ }
303
+
304
+ function collectEffectFacts(
305
+ factIDs: ReadonlyMap<FactType, FactId>,
306
+ parts: MoveParts,
307
+ base: BuildAttemptChangeBase,
308
+ ): AnyNewFact[] {
309
+ return [...factIDs].map(([type, id]) =>
310
+ buildAttemptFact(type, parts, { id, actor: base.actor, now: base.now }),
311
+ );
312
+ }
313
+
314
+ function buildFactIDs(
315
+ effects: readonly MoveEffect[],
316
+ base: BuildAttemptChangeBase,
317
+ ): Map<FactType, FactId> {
318
+ return new Map(
319
+ effects
320
+ .filter((effect) => isFactEffect(effect))
321
+ .map((type) => [type, brandSchemas.factId.parse(base.newId())]),
322
+ );
323
+ }
324
+
325
+ function buildMoveChange(
326
+ base: BuildAttemptChangeBase,
327
+ attempt: AttemptRow,
328
+ event: MoveEvent,
329
+ ): AttemptChange {
330
+ const before = attemptMachine.resolveState({
331
+ value: attempt.state,
332
+ context: toAttemptContext(attempt),
333
+ });
334
+
335
+ const result = transition(attemptMachine, before, toMachineEvent(event));
336
+
337
+ if (isUnhandled(before, result)) {
338
+ return { kind: 'not-allowed', state: attempt.state, event: event.kind };
339
+ }
340
+
341
+ const [after, effects] = result;
342
+ const moveEffects = collectMoveEffects(effects);
343
+ const decision = event.kind === 'woken' ? buildDecisionRow(base, attempt, event.decision) : null;
344
+
345
+ // the IDs first: the close-out's row names the ID of its fact
346
+ const factIDs = buildFactIDs(moveEffects, base);
347
+
348
+ const set: Partial<AttemptRow> = {
349
+ state: toAttemptState(after.value),
350
+ ...buildContextColumns(before.context, after.context, base.now),
351
+ ...buildEventColumns(event, attempt, base.now, factIDs),
352
+ };
353
+
354
+ const parts: MoveParts = { before: attempt, after: { ...attempt, ...set }, event, decision };
355
+
356
+ const update: AttemptWrite = {
357
+ op: 'update',
358
+ table: 'attempts',
359
+ key: { id: attempt.id },
360
+ expect: toAttemptGuard(attempt),
361
+ set,
362
+ };
363
+
364
+ return {
365
+ kind: 'change',
366
+ change: {
367
+ at: base.now,
368
+ under: isLiveState(attempt.state)
369
+ ? {
370
+ item: attempt.item,
371
+ phase: attempt.phase,
372
+ gen: attempt.leaseGen,
373
+ holdGen: attempt.holdGen,
374
+ }
375
+ : null,
376
+
377
+ // a bound session changes no column of the attempt
378
+ writes: [
379
+ ...(event.kind === 'session-seen' ? [] : [update]),
380
+ ...collectEffectWrites(moveEffects, parts, base.now),
381
+ ],
382
+ facts: collectEffectFacts(factIDs, parts, base),
383
+ },
384
+ };
385
+ }
386
+
387
+ function toAttemptContext(attempt: AttemptRow): AttemptContext {
388
+ return {
389
+ mark: attempt.suspendRequestedAt !== null,
390
+ wakeRequested: attempt.wakeRequestedAt !== null,
391
+ mode: attempt.mode,
392
+ holdGen: attempt.holdGen,
393
+ closing: attempt.closing,
394
+ hasSession: attempt.atcSession !== null,
395
+ strayed: attempt.strayedAt !== null,
396
+ sessionStopped: attempt.sessionStoppedAt !== null,
397
+ sessionForgotten: attempt.sessionForgottenAt !== null,
398
+ };
399
+ }
400
+
401
+ function toMachineEvent(event: MoveEvent): AttemptMachineEvent {
402
+ return match(event)
403
+ .with({ kind: 'started' }, (started) => ({ type: 'started', via: started.via }) as const)
404
+ .with({ kind: 'woken' }, (woken) => ({ type: 'woken', overCap: woken.overCap }) as const)
405
+ .with(
406
+ { kind: 'settled' },
407
+ (settled) =>
408
+ ({
409
+ type: 'settled',
410
+ state: settled.state,
411
+ sessionStopped: settled.sessionStopped,
412
+ }) as const,
413
+ )
414
+ .with(
415
+ {
416
+ kind: P.union(
417
+ 'spawn-unanswered',
418
+ 'session-seen',
419
+ 'closed-out',
420
+ 'timed-out',
421
+ 'item-closed',
422
+ 'turn-ended',
423
+ 'stalled',
424
+ 'skill-missing',
425
+ 'usage-limited',
426
+ 'suspend-asked',
427
+ 'suspend-refused',
428
+ 'slept',
429
+ 'wake-requested',
430
+ 'resumed',
431
+ 'wake-refused',
432
+ 'stray-stopped',
433
+ 'session-forgotten',
434
+ ),
435
+ },
436
+ (other) => ({ type: other.kind }),
437
+ )
438
+ .exhaustive();
439
+ }
440
+
441
+ // The columns behind the context fields that the move changed.
442
+ function buildContextColumns(
443
+ before: AttemptContext,
444
+ after: AttemptContext,
445
+ now: Instant,
446
+ ): Partial<AttemptRow> {
447
+ return {
448
+ ...(before.mark === after.mark ? {} : { suspendRequestedAt: after.mark ? now : null }),
449
+ ...(before.wakeRequested === after.wakeRequested
450
+ ? {}
451
+ : { wakeRequestedAt: after.wakeRequested ? now : null }),
452
+ ...(before.holdGen === after.holdGen ? {} : { holdGen: after.holdGen }),
453
+ ...(before.closing === after.closing ? {} : { closing: after.closing, closingAt: now }),
454
+ ...(before.strayed === after.strayed ? {} : { strayedAt: after.strayed ? now : null }),
455
+ ...(before.sessionStopped === after.sessionStopped
456
+ ? {}
457
+ : { sessionStoppedAt: after.sessionStopped ? now : null }),
458
+ ...(before.sessionForgotten === after.sessionForgotten ? {} : { sessionForgottenAt: now }),
459
+ };
460
+ }
461
+
462
+ // The columns that hold the event's own data.
463
+ function buildEventColumns(
464
+ event: MoveEvent,
465
+ attempt: AttemptRow,
466
+ now: Instant,
467
+ factIDs: ReadonlyMap<FactType, FactId>,
468
+ ): Partial<AttemptRow> {
469
+ return match(event)
470
+ .with({ kind: 'started' }, (started) => ({
471
+ atcSession: started.atcSession,
472
+ caller: started.caller,
473
+ startedAt: attempt.startedAt ?? now,
474
+ }))
475
+ .with({ kind: 'closed-out' }, (closed) => ({
476
+ closeOut: closed.closeOut,
477
+ closeOutHash: closed.hash,
478
+ closeOutFact: getFactID(factIDs, 'boxxy.attempt.closed_out'),
479
+ }))
480
+ .with({ kind: 'slept' }, () => ({ sleptAt: now }))
481
+ .with({ kind: 'woken' }, () => ({ wokenAt: now }))
482
+ .with({ kind: 'resumed' }, (resumed) => ({
483
+ atcSession: resumed.atcSession,
484
+ caller: resumed.caller,
485
+ }))
486
+ .with({ kind: 'settled' }, (settled) => ({
487
+ failure: settled.failure,
488
+ outcome: settled.outcome,
489
+ returnedOutcome: settled.returnedOutcome,
490
+ endedAt: now,
491
+ }))
492
+ .with(
493
+ {
494
+ kind: P.union(
495
+ 'spawn-unanswered',
496
+ 'session-seen',
497
+ 'timed-out',
498
+ 'item-closed',
499
+ 'turn-ended',
500
+ 'stalled',
501
+ 'skill-missing',
502
+ 'usage-limited',
503
+ 'suspend-asked',
504
+ 'suspend-refused',
505
+ 'wake-requested',
506
+ 'wake-refused',
507
+ 'stray-stopped',
508
+ 'session-forgotten',
509
+ ),
510
+ },
511
+ () => ({}),
512
+ )
513
+ .exhaustive();
514
+ }
515
+
516
+ function getFactID(factIDs: ReadonlyMap<FactType, FactId>, type: FactType): FactId {
517
+ const id = factIDs.get(type);
518
+
519
+ invariant(id !== undefined, `the move records ${type}`);
520
+
521
+ return id;
522
+ }
523
+
524
+ function toAttemptGuard(attempt: AttemptRow): AttemptGuard {
525
+ return {
526
+ state: attempt.state,
527
+ holdGen: attempt.holdGen,
528
+ suspendRequestedAt: attempt.suspendRequestedAt,
529
+ wakeRequestedAt: attempt.wakeRequestedAt,
530
+ closing: attempt.closing,
531
+ atcSession: attempt.atcSession,
532
+ strayedAt: attempt.strayedAt,
533
+ sessionStoppedAt: attempt.sessionStoppedAt,
534
+ sessionForgottenAt: attempt.sessionForgottenAt,
535
+ };
536
+ }
537
+
538
+ function isLiveState(state: AttemptState): boolean {
539
+ return state !== 'succeeded' && state !== 'failed';
540
+ }
@@ -0,0 +1,135 @@
1
+ import type { AnyNewFact, FactType, NewFact, WorkFacts } from '@boxxy/model';
2
+ import invariant from 'tiny-invariant';
3
+ import { P, match } from 'ts-pattern';
4
+ import type { FactEnvelopeParts, MoveParts } from './types.ts';
5
+
6
+ // The fact of one type that a move records, filled from the move's event and rows.
7
+ export function buildAttemptFact(
8
+ type: FactType,
9
+ parts: MoveParts,
10
+ envelope: FactEnvelopeParts,
11
+ ): AnyNewFact {
12
+ const after = parts.after;
13
+ const attempt = after.id;
14
+
15
+ const wrap = <T extends FactType>(factType: T, payload: WorkFacts[T]): NewFact<T> => ({
16
+ id: envelope.id,
17
+ type: factType,
18
+ entity: `boxxy:item:${after.item}`,
19
+ actor: envelope.actor,
20
+ payload,
21
+ });
22
+
23
+ return (
24
+ match([type, parts.event] as const)
25
+ .with(['boxxy.attempt.claimed', { kind: 'claimed' }], ([factType, claimed]) =>
26
+ wrap(factType, {
27
+ attempt,
28
+ phase: after.phase,
29
+ number: after.number,
30
+ round: after.round,
31
+ mode: after.mode,
32
+ pool: after.pool,
33
+ fields: after.fields,
34
+ seats: claimed.pick.limits.map((limit) => ({ limit })),
35
+ recipeHash: after.recipeHash,
36
+ skillsRef: claimed.skillsRef,
37
+ rulesVersion: after.rulesVersion,
38
+ }),
39
+ )
40
+ .with(['boxxy.decision.recorded', P._], ([factType]) => {
41
+ invariant(parts.decision !== null, 'a move that records a decision carries one');
42
+
43
+ return wrap(factType, parts.decision);
44
+ })
45
+ .with(['boxxy.attempt.started', { kind: 'started' }], ([factType, started]) =>
46
+ wrap(factType, {
47
+ attempt,
48
+ atcSession: started.atcSession,
49
+ resumeFallback: started.resumeFallback,
50
+ caller: started.caller,
51
+ }),
52
+ )
53
+ .with(['boxxy.attempt.reconciled', { kind: 'started' }], ([factType, started]) =>
54
+ wrap(factType, { attempt, atcSession: started.atcSession, caller: started.caller }),
55
+ )
56
+ .with(
57
+ ['boxxy.attempt.spawn_unanswered', { kind: 'spawn-unanswered' }],
58
+ ([factType, unanswered]) => wrap(factType, { attempt, reason: unanswered.reason }),
59
+ )
60
+ .with(['boxxy.attempt.session_bound', { kind: 'session-seen' }], ([factType, seen]) =>
61
+ wrap(factType, {
62
+ attempt,
63
+ atcSession: seen.row.atcSession,
64
+ agentSession: seen.row.agentSession,
65
+ product: seen.row.product,
66
+ source: seen.row.source,
67
+ seenAt: seen.row.seenAt,
68
+ }),
69
+ )
70
+ .with(['boxxy.attempt.slept', { kind: 'slept' }], ([factType, slept]) =>
71
+ wrap(factType, { attempt, idleSince: slept.idleSince, asked: slept.asked }),
72
+ )
73
+
74
+ // the host refused the wake, so the attempt sleeps again from its earlier sleep
75
+ .with(['boxxy.attempt.slept', { kind: 'wake-refused' }], ([factType]) =>
76
+ wrap(factType, {
77
+ attempt,
78
+ idleSince: parts.before.sleptAt ?? envelope.now,
79
+ asked: true,
80
+ }),
81
+ )
82
+ .with(['boxxy.attempt.wake_requested', { kind: 'wake-requested' }], ([factType, wake]) =>
83
+ wrap(factType, { attempt, by: wake.by }),
84
+ )
85
+ .with(['boxxy.attempt.woken', { kind: 'woken' }], ([factType, woken]) =>
86
+ wrap(factType, {
87
+ attempt,
88
+ holdGen: after.holdGen,
89
+ seats: woken.pick.limits.map((limit) => ({ limit })),
90
+ overCap: woken.overCap,
91
+ }),
92
+ )
93
+ .with(['boxxy.attempt.resumed', { kind: 'resumed' }], ([factType, resumed]) =>
94
+ wrap(factType, { attempt, atcSession: resumed.atcSession, caller: resumed.caller }),
95
+ )
96
+ .with(['boxxy.attempt.closed_out', { kind: 'closed-out' }], ([factType, closed]) =>
97
+ wrap(factType, {
98
+ attempt,
99
+ phase: after.phase,
100
+ closeOut: closed.closeOut,
101
+ hash: closed.hash,
102
+ }),
103
+ )
104
+ .with(['boxxy.attempt.timed_out', { kind: 'timed-out' }], ([factType, timedOut]) =>
105
+ wrap(factType, { attempt, phase: after.phase, afterMinutes: timedOut.afterMinutes }),
106
+ )
107
+ .with(['boxxy.attempt.turn_ended', { kind: 'turn-ended' }], ([factType]) =>
108
+ wrap(factType, { attempt, phase: after.phase }),
109
+ )
110
+ .with(['boxxy.attempt.settled', { kind: 'settled' }], ([factType, settled]) =>
111
+ wrap(factType, {
112
+ attempt,
113
+ phase: after.phase,
114
+ round: after.round,
115
+ state: settled.state,
116
+ failure: settled.failure,
117
+ outcome: settled.outcome,
118
+ returnedOutcome: settled.returnedOutcome,
119
+ closing: parts.before.closing,
120
+ asleep: parts.before.state === 'sleeping',
121
+ sessionStopped: settled.sessionStopped,
122
+ endedAt: envelope.now,
123
+ criteria: settled.criteria,
124
+ matched: settled.matched,
125
+ frictionDropped: settled.frictionDropped,
126
+ skippedEffects: settled.skippedEffects,
127
+ }),
128
+ )
129
+
130
+ // the table of moves pairs each fact with an event above, so another pair is a bug in it
131
+ .otherwise(([factType, other]) => {
132
+ throw new Error(`the move ${other.kind} cannot record ${factType}`);
133
+ })
134
+ );
135
+ }