@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.
- package/dist/attempts/attempt-machine.d.ts +804 -0
- package/dist/attempts/attempt-moves.d.ts +2 -0
- package/dist/attempts/bind-attempt-session.d.ts +14 -0
- package/dist/attempts/build-attempt-change.d.ts +73 -0
- package/dist/attempts/build-attempt-fact.d.ts +3 -0
- package/dist/attempts/end-item-attempts.d.ts +18 -0
- package/dist/attempts/is-same-guard.d.ts +5 -0
- package/dist/attempts/pick-call-attempt.d.ts +10 -0
- package/dist/attempts/stop-stray-session.d.ts +11 -0
- package/dist/attempts/types.d.ts +142 -0
- package/dist/attempts/write-attempt-move.d.ts +7 -0
- package/dist/attempts/write-session-bind.d.ts +3 -0
- package/dist/claims/build-snapshot-hash.d.ts +2 -0
- package/dist/claims/claim-next.d.ts +6 -0
- package/dist/claims/pick-kept-session.d.ts +9 -0
- package/dist/claims/request-wake.d.ts +3 -0
- package/dist/claims/resume-claim.d.ts +3 -0
- package/dist/claims/start-claim.d.ts +3 -0
- package/dist/claims/types.d.ts +44 -0
- package/dist/closeouts/build-settle-plans.d.ts +17 -0
- package/dist/closeouts/close-out-attempt.d.ts +3 -0
- package/dist/closeouts/effect-handlers.d.ts +2 -0
- package/dist/closeouts/pick-settlement.d.ts +14 -0
- package/dist/closeouts/settle-attempt.d.ts +3 -0
- package/dist/closeouts/types.d.ts +50 -0
- package/dist/index.d.ts +38 -0
- package/dist/notices/constants.d.ts +4 -0
- package/dist/notices/create-null-notifier.d.ts +2 -0
- package/dist/notices/send-notices.d.ts +2 -0
- package/dist/notices/types.d.ts +11 -0
- package/dist/outbox/build-backoff-ms.d.ts +2 -0
- package/dist/outbox/build-initial-sender-state.d.ts +2 -0
- package/dist/outbox/build-outbox-status.d.ts +15 -0
- package/dist/outbox/constants.d.ts +2 -0
- package/dist/outbox/measure-wire-bytes.d.ts +2 -0
- package/dist/outbox/pick-sender-move.d.ts +10 -0
- package/dist/outbox/run-sender-once.d.ts +2 -0
- package/dist/outbox/take-batch.d.ts +3 -0
- package/dist/outbox/types.d.ts +61 -0
- package/dist/pull-requests/build-link-change.d.ts +11 -0
- package/dist/pull-requests/build-state-fact.d.ts +8 -0
- package/dist/pull-requests/collect-attempt-trailers.d.ts +2 -0
- package/dist/pull-requests/collect-seen-state.d.ts +7 -0
- package/dist/pull-requests/constants.d.ts +3 -0
- package/dist/pull-requests/link-pull-request.d.ts +2 -0
- package/dist/pull-requests/parse-pull-request-url.d.ts +2 -0
- package/dist/pull-requests/types.d.ts +37 -0
- package/dist/pull-requests/watch-pull-requests.d.ts +2 -0
- package/dist/readings/act-on-reading.d.ts +3 -0
- package/dist/readings/find-waiting-since.d.ts +9 -0
- package/dist/readings/pick-reading-action.d.ts +2 -0
- package/dist/readings/types.d.ts +79 -0
- package/dist/sessions/build-keep-writes.d.ts +9 -0
- package/dist/sessions/build-kept-clear.d.ts +2 -0
- package/dist/sessions/forget-sessions.d.ts +2 -0
- package/dist/sessions/is-kept-due.d.ts +2 -0
- package/dist/sessions/resume-kept-session.d.ts +6 -0
- package/dist/sessions/types.d.ts +19 -0
- package/dist/test-utils/apply-memory-change.d.ts +3 -0
- package/dist/test-utils/build-memory-reader.d.ts +3 -0
- package/dist/test-utils/build-mock-claim-change.d.ts +2 -2
- package/dist/test-utils/build-mock-new-id.d.ts +2 -0
- package/dist/test-utils/build-mock-note-change.d.ts +2 -0
- package/dist/test-utils/build-mock-pull-request-change.d.ts +1 -0
- package/dist/test-utils/{build-mock-link-change.d.ts → build-mock-relation-change.d.ts} +2 -2
- package/dist/test-utils/build-mock-running-reading.d.ts +6 -0
- package/dist/test-utils/build-mock-start-change.d.ts +2 -2
- package/dist/test-utils/claim-store-close-cases.d.ts +2 -0
- package/dist/test-utils/claim-store-copy-cases.d.ts +2 -0
- package/dist/test-utils/claim-store-outbox-cases.d.ts +2 -0
- package/dist/test-utils/claim-store-race-cases.d.ts +2 -0
- package/dist/test-utils/claim-store-seat-cases.d.ts +2 -0
- package/dist/test-utils/claim-store-session-cases.d.ts +2 -0
- package/dist/test-utils/collect-memory-limit-fills.d.ts +3 -0
- package/dist/test-utils/compare-text.d.ts +1 -0
- package/dist/test-utils/create-fake-backoff.d.ts +2 -0
- package/dist/test-utils/create-fake-clock.d.ts +5 -0
- package/dist/test-utils/create-fake-fact-sink.d.ts +8 -0
- package/dist/test-utils/create-fake-forge.d.ts +17 -0
- package/dist/test-utils/create-fake-worker.d.ts +42 -0
- package/dist/test-utils/create-memory-claim-store.d.ts +2 -2
- package/dist/test-utils/create-test-dispatch.d.ts +46 -0
- package/dist/test-utils/forge-contract.d.ts +15 -0
- package/dist/test-utils/is-same-held-seat.d.ts +2 -0
- package/dist/test-utils/types.d.ts +12 -2
- package/dist/test-utils/worker-contract.d.ts +14 -0
- package/dist/timeouts.d.ts +2 -0
- package/dist/types.d.ts +13 -0
- package/package.json +10 -3
- package/src/attempts/attempt-machine.ts +147 -0
- package/src/attempts/attempt-moves.ts +218 -0
- package/src/attempts/bind-attempt-session.ts +61 -0
- package/src/attempts/build-attempt-change.ts +540 -0
- package/src/attempts/build-attempt-fact.ts +135 -0
- package/src/attempts/end-item-attempts.ts +95 -0
- package/src/attempts/is-same-guard.ts +22 -0
- package/src/attempts/pick-call-attempt.ts +32 -0
- package/src/attempts/stop-stray-session.ts +48 -0
- package/src/attempts/types.ts +231 -0
- package/src/attempts/write-attempt-move.ts +41 -0
- package/src/attempts/write-session-bind.ts +47 -0
- package/src/claims/build-snapshot-hash.ts +9 -0
- package/src/claims/claim-next.ts +306 -0
- package/src/claims/pick-kept-session.ts +41 -0
- package/src/claims/request-wake.ts +17 -0
- package/src/claims/resume-claim.ts +56 -0
- package/src/claims/start-claim.ts +99 -0
- package/src/claims/types.ts +67 -0
- package/src/closeouts/build-settle-plans.ts +173 -0
- package/src/closeouts/close-out-attempt.ts +156 -0
- package/src/closeouts/effect-handlers.ts +232 -0
- package/src/closeouts/pick-settlement.ts +80 -0
- package/src/closeouts/settle-attempt.ts +161 -0
- package/src/closeouts/types.ts +87 -0
- package/src/index.ts +61 -0
- package/src/notices/constants.ts +7 -0
- package/src/notices/create-null-notifier.ts +6 -0
- package/src/notices/send-notices.ts +41 -0
- package/src/notices/types.ts +14 -0
- package/src/outbox/build-backoff-ms.ts +14 -0
- package/src/outbox/build-initial-sender-state.ts +14 -0
- package/src/outbox/build-outbox-status.ts +43 -0
- package/src/outbox/constants.ts +15 -0
- package/src/outbox/measure-wire-bytes.ts +31 -0
- package/src/outbox/pick-sender-move.ts +37 -0
- package/src/outbox/run-sender-once.ts +174 -0
- package/src/outbox/take-batch.ts +35 -0
- package/src/outbox/types.ts +76 -0
- package/src/pull-requests/build-link-change.ts +95 -0
- package/src/pull-requests/build-state-fact.ts +39 -0
- package/src/pull-requests/collect-attempt-trailers.ts +20 -0
- package/src/pull-requests/collect-seen-state.ts +19 -0
- package/src/pull-requests/constants.ts +5 -0
- package/src/pull-requests/link-pull-request.ts +119 -0
- package/src/pull-requests/parse-pull-request-url.ts +30 -0
- package/src/pull-requests/types.ts +63 -0
- package/src/pull-requests/watch-pull-requests.ts +308 -0
- package/src/readings/act-on-reading.ts +318 -0
- package/src/readings/find-waiting-since.ts +46 -0
- package/src/readings/pick-reading-action.ts +315 -0
- package/src/readings/types.ts +98 -0
- package/src/sessions/build-keep-writes.ts +55 -0
- package/src/sessions/build-kept-clear.ts +12 -0
- package/src/sessions/forget-sessions.ts +106 -0
- package/src/sessions/is-kept-due.ts +27 -0
- package/src/sessions/resume-kept-session.ts +25 -0
- package/src/sessions/types.ts +39 -0
- package/src/test-utils/apply-memory-change.ts +252 -0
- package/src/test-utils/build-memory-reader.ts +364 -0
- package/src/test-utils/build-mock-bind-change.ts +18 -11
- package/src/test-utils/build-mock-claim-change.ts +18 -8
- package/src/test-utils/build-mock-file-item-change.ts +3 -1
- package/src/test-utils/build-mock-new-id.ts +12 -0
- package/src/test-utils/build-mock-note-change.ts +18 -0
- package/src/test-utils/build-mock-pause-change.ts +2 -10
- package/src/test-utils/build-mock-pull-request-change.ts +19 -9
- package/src/test-utils/{build-mock-link-change.ts → build-mock-relation-change.ts} +6 -6
- package/src/test-utils/build-mock-running-reading.ts +19 -0
- package/src/test-utils/build-mock-settle-change.ts +18 -6
- package/src/test-utils/build-mock-start-change.ts +13 -6
- package/src/test-utils/claim-store-attempt-reader-cases.ts +163 -133
- package/src/test-utils/claim-store-claim-cases.ts +168 -85
- package/src/test-utils/claim-store-close-cases.ts +95 -0
- package/src/test-utils/claim-store-contract.ts +12 -2
- package/src/test-utils/claim-store-copy-cases.ts +100 -0
- package/src/test-utils/claim-store-exclusive-cases.ts +65 -19
- package/src/test-utils/claim-store-guard-cases.ts +92 -82
- package/src/test-utils/claim-store-item-reader-cases.ts +291 -77
- package/src/test-utils/claim-store-outbox-cases.ts +281 -0
- package/src/test-utils/claim-store-race-cases.ts +150 -0
- package/src/test-utils/claim-store-rollback-cases.ts +50 -50
- package/src/test-utils/claim-store-seat-cases.ts +270 -0
- package/src/test-utils/claim-store-session-cases.ts +289 -0
- package/src/test-utils/collect-memory-limit-fills.ts +15 -0
- package/src/test-utils/compare-text.ts +8 -0
- package/src/test-utils/create-fake-backoff.ts +16 -0
- package/src/test-utils/create-fake-clock.ts +20 -0
- package/src/test-utils/create-fake-fact-sink.ts +74 -0
- package/src/test-utils/create-fake-forge.ts +117 -0
- package/src/test-utils/create-fake-worker.ts +303 -0
- package/src/test-utils/create-memory-claim-store.ts +73 -484
- package/src/test-utils/create-test-dispatch.ts +597 -0
- package/src/test-utils/forge-contract.ts +171 -0
- package/src/test-utils/is-same-held-seat.ts +11 -0
- package/src/test-utils/types.ts +14 -2
- package/src/test-utils/worker-contract.ts +192 -0
- package/src/timeouts.ts +17 -0
- package/src/types.ts +32 -0
- package/dist/test-utils/claim-store-pool-cases.d.ts +0 -2
- 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
|
+
}
|