@boxxy/dispatch 0.1.0 → 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 (202) hide show
  1. package/README.md +4 -1
  2. package/dist/attempts/attempt-machine.d.ts +804 -0
  3. package/dist/attempts/attempt-moves.d.ts +2 -0
  4. package/dist/attempts/bind-attempt-session.d.ts +14 -0
  5. package/dist/attempts/build-attempt-change.d.ts +73 -0
  6. package/dist/attempts/build-attempt-fact.d.ts +3 -0
  7. package/dist/attempts/end-item-attempts.d.ts +18 -0
  8. package/dist/attempts/is-same-guard.d.ts +5 -0
  9. package/dist/attempts/pick-call-attempt.d.ts +10 -0
  10. package/dist/attempts/stop-stray-session.d.ts +11 -0
  11. package/dist/attempts/types.d.ts +142 -0
  12. package/dist/attempts/write-attempt-move.d.ts +7 -0
  13. package/dist/attempts/write-session-bind.d.ts +3 -0
  14. package/dist/claims/build-snapshot-hash.d.ts +2 -0
  15. package/dist/claims/claim-next.d.ts +6 -0
  16. package/dist/claims/pick-kept-session.d.ts +9 -0
  17. package/dist/claims/request-wake.d.ts +3 -0
  18. package/dist/claims/resume-claim.d.ts +3 -0
  19. package/dist/claims/start-claim.d.ts +3 -0
  20. package/dist/claims/types.d.ts +44 -0
  21. package/dist/closeouts/build-settle-plans.d.ts +17 -0
  22. package/dist/closeouts/close-out-attempt.d.ts +3 -0
  23. package/dist/closeouts/effect-handlers.d.ts +2 -0
  24. package/dist/closeouts/pick-settlement.d.ts +14 -0
  25. package/dist/closeouts/settle-attempt.d.ts +3 -0
  26. package/dist/closeouts/types.d.ts +50 -0
  27. package/dist/index.d.ts +38 -0
  28. package/dist/notices/constants.d.ts +4 -0
  29. package/dist/notices/create-null-notifier.d.ts +2 -0
  30. package/dist/notices/send-notices.d.ts +2 -0
  31. package/dist/notices/types.d.ts +11 -0
  32. package/dist/outbox/build-backoff-ms.d.ts +2 -0
  33. package/dist/outbox/build-initial-sender-state.d.ts +2 -0
  34. package/dist/outbox/build-outbox-status.d.ts +15 -0
  35. package/dist/outbox/constants.d.ts +2 -0
  36. package/dist/outbox/measure-wire-bytes.d.ts +2 -0
  37. package/dist/outbox/pick-sender-move.d.ts +10 -0
  38. package/dist/outbox/run-sender-once.d.ts +2 -0
  39. package/dist/outbox/take-batch.d.ts +3 -0
  40. package/dist/outbox/types.d.ts +61 -0
  41. package/dist/pull-requests/build-link-change.d.ts +11 -0
  42. package/dist/pull-requests/build-state-fact.d.ts +8 -0
  43. package/dist/pull-requests/collect-attempt-trailers.d.ts +2 -0
  44. package/dist/pull-requests/collect-seen-state.d.ts +7 -0
  45. package/dist/pull-requests/constants.d.ts +3 -0
  46. package/dist/pull-requests/link-pull-request.d.ts +2 -0
  47. package/dist/pull-requests/parse-pull-request-url.d.ts +2 -0
  48. package/dist/pull-requests/types.d.ts +37 -0
  49. package/dist/pull-requests/watch-pull-requests.d.ts +2 -0
  50. package/dist/readings/act-on-reading.d.ts +3 -0
  51. package/dist/readings/find-waiting-since.d.ts +9 -0
  52. package/dist/readings/pick-reading-action.d.ts +2 -0
  53. package/dist/readings/types.d.ts +79 -0
  54. package/dist/sessions/build-keep-writes.d.ts +9 -0
  55. package/dist/sessions/build-kept-clear.d.ts +2 -0
  56. package/dist/sessions/forget-sessions.d.ts +2 -0
  57. package/dist/sessions/is-kept-due.d.ts +2 -0
  58. package/dist/sessions/resume-kept-session.d.ts +6 -0
  59. package/dist/sessions/types.d.ts +19 -0
  60. package/dist/test-utils/apply-memory-change.d.ts +3 -0
  61. package/dist/test-utils/build-memory-reader.d.ts +3 -0
  62. package/dist/test-utils/build-mock-bind-change.d.ts +7 -0
  63. package/dist/test-utils/build-mock-claim-change.d.ts +2 -0
  64. package/dist/test-utils/build-mock-file-item-change.d.ts +2 -0
  65. package/dist/test-utils/build-mock-lever-change.d.ts +2 -0
  66. package/dist/test-utils/build-mock-new-id.d.ts +2 -0
  67. package/dist/test-utils/build-mock-note-change.d.ts +2 -0
  68. package/dist/test-utils/build-mock-pause-change.d.ts +2 -0
  69. package/dist/test-utils/build-mock-pull-request-change.d.ts +8 -0
  70. package/dist/test-utils/build-mock-relation-change.d.ts +7 -0
  71. package/dist/test-utils/build-mock-running-reading.d.ts +6 -0
  72. package/dist/test-utils/build-mock-settle-change.d.ts +7 -0
  73. package/dist/test-utils/build-mock-start-change.d.ts +2 -0
  74. package/dist/test-utils/claim-store-attempt-reader-cases.d.ts +2 -0
  75. package/dist/test-utils/claim-store-claim-cases.d.ts +2 -0
  76. package/dist/test-utils/claim-store-close-cases.d.ts +2 -0
  77. package/dist/test-utils/claim-store-contract.d.ts +2 -0
  78. package/dist/test-utils/claim-store-copy-cases.d.ts +2 -0
  79. package/dist/test-utils/claim-store-exclusive-cases.d.ts +2 -0
  80. package/dist/test-utils/claim-store-guard-cases.d.ts +2 -0
  81. package/dist/test-utils/claim-store-item-reader-cases.d.ts +2 -0
  82. package/dist/test-utils/claim-store-outbox-cases.d.ts +2 -0
  83. package/dist/test-utils/claim-store-race-cases.d.ts +2 -0
  84. package/dist/test-utils/claim-store-rollback-cases.d.ts +2 -0
  85. package/dist/test-utils/claim-store-seat-cases.d.ts +2 -0
  86. package/dist/test-utils/claim-store-session-cases.d.ts +2 -0
  87. package/dist/test-utils/collect-memory-limit-fills.d.ts +3 -0
  88. package/dist/test-utils/compare-text.d.ts +1 -0
  89. package/dist/test-utils/create-fake-backoff.d.ts +2 -0
  90. package/dist/test-utils/create-fake-clock.d.ts +5 -0
  91. package/dist/test-utils/create-fake-fact-sink.d.ts +8 -0
  92. package/dist/test-utils/create-fake-forge.d.ts +17 -0
  93. package/dist/test-utils/create-fake-worker.d.ts +42 -0
  94. package/dist/test-utils/create-memory-claim-store.d.ts +2 -0
  95. package/dist/test-utils/create-test-dispatch.d.ts +46 -0
  96. package/dist/test-utils/forge-contract.d.ts +15 -0
  97. package/dist/test-utils/is-same-held-seat.d.ts +2 -0
  98. package/dist/test-utils/types.d.ts +19 -0
  99. package/dist/test-utils/worker-contract.d.ts +14 -0
  100. package/dist/timeouts.d.ts +2 -0
  101. package/dist/types.d.ts +13 -0
  102. package/package.json +15 -2
  103. package/src/attempts/attempt-machine.ts +147 -0
  104. package/src/attempts/attempt-moves.ts +218 -0
  105. package/src/attempts/bind-attempt-session.ts +61 -0
  106. package/src/attempts/build-attempt-change.ts +540 -0
  107. package/src/attempts/build-attempt-fact.ts +135 -0
  108. package/src/attempts/end-item-attempts.ts +95 -0
  109. package/src/attempts/is-same-guard.ts +22 -0
  110. package/src/attempts/pick-call-attempt.ts +32 -0
  111. package/src/attempts/stop-stray-session.ts +48 -0
  112. package/src/attempts/types.ts +231 -0
  113. package/src/attempts/write-attempt-move.ts +41 -0
  114. package/src/attempts/write-session-bind.ts +47 -0
  115. package/src/claims/build-snapshot-hash.ts +9 -0
  116. package/src/claims/claim-next.ts +306 -0
  117. package/src/claims/pick-kept-session.ts +41 -0
  118. package/src/claims/request-wake.ts +17 -0
  119. package/src/claims/resume-claim.ts +56 -0
  120. package/src/claims/start-claim.ts +99 -0
  121. package/src/claims/types.ts +67 -0
  122. package/src/closeouts/build-settle-plans.ts +173 -0
  123. package/src/closeouts/close-out-attempt.ts +156 -0
  124. package/src/closeouts/effect-handlers.ts +232 -0
  125. package/src/closeouts/pick-settlement.ts +80 -0
  126. package/src/closeouts/settle-attempt.ts +161 -0
  127. package/src/closeouts/types.ts +87 -0
  128. package/src/index.ts +61 -0
  129. package/src/notices/constants.ts +7 -0
  130. package/src/notices/create-null-notifier.ts +6 -0
  131. package/src/notices/send-notices.ts +41 -0
  132. package/src/notices/types.ts +14 -0
  133. package/src/outbox/build-backoff-ms.ts +14 -0
  134. package/src/outbox/build-initial-sender-state.ts +14 -0
  135. package/src/outbox/build-outbox-status.ts +43 -0
  136. package/src/outbox/constants.ts +15 -0
  137. package/src/outbox/measure-wire-bytes.ts +31 -0
  138. package/src/outbox/pick-sender-move.ts +37 -0
  139. package/src/outbox/run-sender-once.ts +174 -0
  140. package/src/outbox/take-batch.ts +35 -0
  141. package/src/outbox/types.ts +76 -0
  142. package/src/pull-requests/build-link-change.ts +95 -0
  143. package/src/pull-requests/build-state-fact.ts +39 -0
  144. package/src/pull-requests/collect-attempt-trailers.ts +20 -0
  145. package/src/pull-requests/collect-seen-state.ts +19 -0
  146. package/src/pull-requests/constants.ts +5 -0
  147. package/src/pull-requests/link-pull-request.ts +119 -0
  148. package/src/pull-requests/parse-pull-request-url.ts +30 -0
  149. package/src/pull-requests/types.ts +63 -0
  150. package/src/pull-requests/watch-pull-requests.ts +308 -0
  151. package/src/readings/act-on-reading.ts +318 -0
  152. package/src/readings/find-waiting-since.ts +46 -0
  153. package/src/readings/pick-reading-action.ts +315 -0
  154. package/src/readings/types.ts +98 -0
  155. package/src/sessions/build-keep-writes.ts +55 -0
  156. package/src/sessions/build-kept-clear.ts +12 -0
  157. package/src/sessions/forget-sessions.ts +106 -0
  158. package/src/sessions/is-kept-due.ts +27 -0
  159. package/src/sessions/resume-kept-session.ts +25 -0
  160. package/src/sessions/types.ts +39 -0
  161. package/src/test-utils/apply-memory-change.ts +252 -0
  162. package/src/test-utils/build-memory-reader.ts +364 -0
  163. package/src/test-utils/build-mock-bind-change.ts +43 -0
  164. package/src/test-utils/build-mock-claim-change.ts +52 -0
  165. package/src/test-utils/build-mock-file-item-change.ts +20 -0
  166. package/src/test-utils/build-mock-lever-change.ts +18 -0
  167. package/src/test-utils/build-mock-new-id.ts +12 -0
  168. package/src/test-utils/build-mock-note-change.ts +18 -0
  169. package/src/test-utils/build-mock-pause-change.ts +19 -0
  170. package/src/test-utils/build-mock-pull-request-change.ts +63 -0
  171. package/src/test-utils/build-mock-relation-change.ts +40 -0
  172. package/src/test-utils/build-mock-running-reading.ts +19 -0
  173. package/src/test-utils/build-mock-settle-change.ts +70 -0
  174. package/src/test-utils/build-mock-start-change.ts +38 -0
  175. package/src/test-utils/claim-store-attempt-reader-cases.ts +357 -0
  176. package/src/test-utils/claim-store-claim-cases.ts +314 -0
  177. package/src/test-utils/claim-store-close-cases.ts +95 -0
  178. package/src/test-utils/claim-store-contract.ts +28 -0
  179. package/src/test-utils/claim-store-copy-cases.ts +100 -0
  180. package/src/test-utils/claim-store-exclusive-cases.ts +243 -0
  181. package/src/test-utils/claim-store-guard-cases.ts +383 -0
  182. package/src/test-utils/claim-store-item-reader-cases.ts +471 -0
  183. package/src/test-utils/claim-store-outbox-cases.ts +281 -0
  184. package/src/test-utils/claim-store-race-cases.ts +150 -0
  185. package/src/test-utils/claim-store-rollback-cases.ts +175 -0
  186. package/src/test-utils/claim-store-seat-cases.ts +270 -0
  187. package/src/test-utils/claim-store-session-cases.ts +289 -0
  188. package/src/test-utils/collect-memory-limit-fills.ts +15 -0
  189. package/src/test-utils/compare-text.ts +8 -0
  190. package/src/test-utils/create-fake-backoff.ts +16 -0
  191. package/src/test-utils/create-fake-clock.ts +20 -0
  192. package/src/test-utils/create-fake-fact-sink.ts +74 -0
  193. package/src/test-utils/create-fake-forge.ts +117 -0
  194. package/src/test-utils/create-fake-worker.ts +303 -0
  195. package/src/test-utils/create-memory-claim-store.ts +163 -0
  196. package/src/test-utils/create-test-dispatch.ts +597 -0
  197. package/src/test-utils/forge-contract.ts +171 -0
  198. package/src/test-utils/is-same-held-seat.ts +11 -0
  199. package/src/test-utils/types.ts +23 -0
  200. package/src/test-utils/worker-contract.ts +192 -0
  201. package/src/timeouts.ts +17 -0
  202. package/src/types.ts +32 -0
@@ -0,0 +1,174 @@
1
+ import { brandSchemas } from '@boxxy/model';
2
+ import type { Instant, OutboxFact, SinkAnswer } from '@boxxy/model';
3
+ import { match } from 'ts-pattern';
4
+ import { buildBackoffMs } from './build-backoff-ms.ts';
5
+ import { pickSenderMove } from './pick-sender-move.ts';
6
+ import { takeBatch } from './take-batch.ts';
7
+ import type { SenderDeps, SenderMove, SenderState, SenderTurn } from './types.ts';
8
+
9
+ // One turn of the sender: sends one batch and does what the answer says. It takes the state that
10
+ // the last turn returned. The caller waits until `nextTryAt` after `retry` and `blocked`, and
11
+ // calls again at once after `stored`, `parked` and `split`.
12
+ export async function runSenderOnce(deps: SenderDeps, state: SenderState): Promise<SenderTurn> {
13
+ const queue = state.queue.length > 0 ? state.queue : await readQueue(deps);
14
+
15
+ if (queue === 'parked') {
16
+ return { step: 'parked', state: { ...state, phase: 'idle', nextTryAt: null } };
17
+ }
18
+
19
+ const [head, ...rest] = queue;
20
+
21
+ if (head === undefined) {
22
+ return { step: 'idle', state: { ...state, queue: [], phase: 'idle', nextTryAt: null } };
23
+ }
24
+
25
+ const answer = await deps.sink.send(head);
26
+
27
+ const move = pickSenderMove({
28
+ answer,
29
+ batchSize: head.length,
30
+ parkRun: state.parkRun,
31
+ limits: deps.limits,
32
+ });
33
+
34
+ return runMove(deps, { state, batch: head, rest, answer, move, at: deps.clock.now() });
35
+ }
36
+
37
+ // The next batch from the store. An oversize first row is parked and no batch is sent this turn.
38
+ async function readQueue(deps: SenderDeps): Promise<readonly (readonly OutboxFact[])[] | 'parked'> {
39
+ const rows = await deps.store.read((tx) => tx.readUnsentFacts(deps.limits.batchFacts));
40
+
41
+ const taken = takeBatch(rows, deps.limits);
42
+
43
+ if (taken.kind === 'batch') {
44
+ return taken.facts.length === 0 ? [] : [taken.facts];
45
+ }
46
+
47
+ const at = deps.clock.now();
48
+
49
+ await deps.store.exclusive((tx) => tx.parkFacts([taken.fact.factId], 'too-large', at));
50
+
51
+ return 'parked';
52
+ }
53
+
54
+ interface MoveInput {
55
+ readonly state: SenderState;
56
+ readonly batch: readonly OutboxFact[];
57
+ readonly rest: readonly (readonly OutboxFact[])[];
58
+ readonly answer: SinkAnswer;
59
+ readonly move: SenderMove;
60
+ readonly at: Instant;
61
+ }
62
+
63
+ function runMove(deps: SenderDeps, input: MoveInput): Promise<SenderTurn> {
64
+ const state = input.state;
65
+ const failingSince = state.failingSince ?? input.at;
66
+ const lastError = buildLastError(input.answer, input.move, input.at);
67
+
68
+ return match(input.move)
69
+ .with({ kind: 'mark-sent' }, async (): Promise<SenderTurn> => {
70
+ const ids = input.batch.map((fact) => fact.factId);
71
+
72
+ await deps.store.exclusive((tx) => tx.markFactsSent(ids, input.at));
73
+
74
+ return {
75
+ step: 'stored',
76
+ state: {
77
+ ...state,
78
+ queue: input.rest,
79
+ retries: 0,
80
+ parkRun: 0,
81
+ phase: 'idle',
82
+ failingSince: null,
83
+ nextTryAt: null,
84
+ lastStoredAt: input.at,
85
+ },
86
+ };
87
+ })
88
+ .with({ kind: 'retry-same' }, (): Promise<SenderTurn> => {
89
+ const retries = state.retries + 1;
90
+ const waitMs = buildBackoffMs(retries, deps.limits, deps.random());
91
+
92
+ return Promise.resolve({
93
+ step: 'retry',
94
+ state: {
95
+ ...state,
96
+ queue: [input.batch, ...input.rest],
97
+ retries,
98
+ phase: 'backing-off',
99
+ failingSince,
100
+ nextTryAt: brandSchemas.instant.parse(input.at + waitMs),
101
+ lastError,
102
+ },
103
+ });
104
+ })
105
+ .with({ kind: 'split' }, (): Promise<SenderTurn> => {
106
+ const middle = Math.ceil(input.batch.length / 2);
107
+
108
+ return Promise.resolve({
109
+ step: 'split',
110
+ state: {
111
+ ...state,
112
+ queue: [input.batch.slice(0, middle), input.batch.slice(middle), ...input.rest],
113
+ phase: 'idle',
114
+ failingSince,
115
+ nextTryAt: null,
116
+ lastError,
117
+ },
118
+ });
119
+ })
120
+ .with({ kind: 'park' }, async (parked): Promise<SenderTurn> => {
121
+ const ids = input.batch.map((fact) => fact.factId);
122
+
123
+ await deps.store.exclusive((tx) => tx.parkFacts(ids, parked.reason, input.at));
124
+
125
+ return {
126
+ step: 'parked',
127
+ state: {
128
+ ...state,
129
+ queue: input.rest,
130
+ parkRun: state.parkRun + 1,
131
+ phase: 'idle',
132
+ failingSince,
133
+ nextTryAt: null,
134
+ lastError,
135
+ },
136
+ };
137
+ })
138
+ .with({ kind: 'block' }, (): Promise<SenderTurn> =>
139
+ Promise.resolve({
140
+ step: 'blocked',
141
+ state: {
142
+ ...state,
143
+ queue: [input.batch, ...input.rest],
144
+ phase: 'blocked',
145
+ failingSince,
146
+ nextTryAt: brandSchemas.instant.parse(input.at + deps.limits.blockedRetryMs),
147
+ lastError,
148
+ },
149
+ }),
150
+ )
151
+ .exhaustive();
152
+ }
153
+
154
+ function buildLastError(
155
+ answer: SinkAnswer,
156
+ move: SenderMove,
157
+ at: Instant,
158
+ ): SenderState['lastError'] {
159
+ return match(answer)
160
+ .with({ kind: 'stored' }, () => null)
161
+ .with({ kind: 'retry' }, (retry) => ({
162
+ kind: retry.kind,
163
+ status: null,
164
+ message: retry.reason,
165
+ at,
166
+ }))
167
+ .with({ kind: 'refused-batch' }, { kind: 'refused-sink' }, (refused) => ({
168
+ kind: refused.kind,
169
+ status: refused.status,
170
+ message: move.kind === 'block' ? move.reason : refused.reason,
171
+ at,
172
+ }))
173
+ .exhaustive();
174
+ }
@@ -0,0 +1,35 @@
1
+ import type { OutboxFact } from '@boxxy/model';
2
+ import { measureWireBytes } from './measure-wire-bytes.ts';
3
+ import type { OutboxLimits, TakenBatch } from './types.ts';
4
+
5
+ // The longest prefix of the rows that holds `batchFacts` facts or fewer and stays inside
6
+ // `batchBytes`. A first row over `factBytes` comes back alone as `oversize`. The batch is empty
7
+ // only when no row is given, because one fact and its allowance fit `batchBytes`.
8
+ export function takeBatch(rows: readonly OutboxFact[], limits: OutboxLimits): TakenBatch {
9
+ const taken: OutboxFact[] = [];
10
+ let batchBytes = 0;
11
+
12
+ for (const row of rows) {
13
+ const wireBytes = measureWireBytes(row);
14
+
15
+ if (wireBytes > limits.factBytes) {
16
+ if (taken.length === 0) {
17
+ return { kind: 'oversize', fact: row };
18
+ }
19
+
20
+ break;
21
+ }
22
+
23
+ const rowBytes = wireBytes + limits.envelopeBytes + 8;
24
+
25
+ if (taken.length >= limits.batchFacts || batchBytes + rowBytes > limits.batchBytes) {
26
+ break;
27
+ }
28
+
29
+ taken.push(row);
30
+
31
+ batchBytes += rowBytes;
32
+ }
33
+
34
+ return { kind: 'batch', facts: taken };
35
+ }
@@ -0,0 +1,76 @@
1
+ import type { ClaimStore, Clock, FactSink, Instant, OutboxFact, OutboxStatus } from '@boxxy/model';
2
+
3
+ export interface OutboxLimits {
4
+ // ingest stores 100 facts of one batch at most
5
+ readonly batchFacts: 100;
6
+
7
+ // one fact on the wire: the UTF-8 bytes of its JSON
8
+ readonly factBytes: 262_144;
9
+
10
+ // the sum over a batch of (wire bytes + `envelopeBytes` + 8)
11
+ readonly batchBytes: 900_000;
12
+
13
+ // what the stored envelope adds to a wire item
14
+ readonly envelopeBytes: 512;
15
+ readonly requestTimeoutMs: 10_000;
16
+ readonly backoff: {
17
+ readonly firstMs: 1000;
18
+ readonly factor: 2;
19
+ readonly maxMs: 300_000;
20
+ readonly jitter: 0.2;
21
+ };
22
+ readonly blockedRetryMs: 300_000;
23
+
24
+ // this many rows parked in a row, with none stored between, reads as "pepys refuses everything"
25
+ readonly parkRun: 25;
26
+ }
27
+
28
+ export type TakenBatch =
29
+ | { readonly kind: 'batch'; readonly facts: readonly OutboxFact[] }
30
+ | { readonly kind: 'oversize'; readonly fact: OutboxFact };
31
+
32
+ export type SenderMove =
33
+ | { readonly kind: 'mark-sent' }
34
+ | { readonly kind: 'retry-same' }
35
+ | { readonly kind: 'split' }
36
+ | { readonly kind: 'park'; readonly reason: string }
37
+ | { readonly kind: 'block'; readonly reason: string };
38
+
39
+ export type SenderStep = 'idle' | 'stored' | 'retry' | 'split' | 'parked' | 'blocked';
40
+
41
+ type SenderPhase = 'idle' | 'backing-off' | 'blocked';
42
+
43
+ type LastError = NonNullable<OutboxStatus['lastError']>;
44
+
45
+ // What the sender remembers between turns. A restart forgets all of it.
46
+ export interface SenderState {
47
+ // batches to send before the next read of the store, in order: the one that got `retry`, or the
48
+ // halves of a split
49
+ readonly queue: readonly (readonly OutboxFact[])[];
50
+
51
+ // consecutive `retry` answers
52
+ readonly retries: number;
53
+
54
+ // rows parked since the last `stored`
55
+ readonly parkRun: number;
56
+ readonly phase: SenderPhase;
57
+ readonly failingSince: Instant | null;
58
+ readonly nextTryAt: Instant | null;
59
+ readonly lastError: LastError | null;
60
+ readonly lastStoredAt: Instant | null;
61
+ }
62
+
63
+ export interface SenderDeps {
64
+ readonly store: ClaimStore;
65
+ readonly sink: FactSink;
66
+ readonly clock: Clock;
67
+ readonly limits: OutboxLimits;
68
+
69
+ // a number in [0, 1), for the jitter of the backoff
70
+ readonly random: () => number;
71
+ }
72
+
73
+ export interface SenderTurn {
74
+ readonly step: SenderStep;
75
+ readonly state: SenderState;
76
+ }
@@ -0,0 +1,95 @@
1
+ import type {
2
+ ActorRef,
3
+ AnyNewFact,
4
+ AttemptRow,
5
+ Instant,
6
+ NewId,
7
+ PullRequestReading,
8
+ PullRequestRef,
9
+ PullRequestRow,
10
+ StoreChange,
11
+ } from '@boxxy/model';
12
+ import { brandSchemas } from '@boxxy/model';
13
+ import { buildStateFact } from './build-state-fact.ts';
14
+ import { collectSeenState } from './collect-seen-state.ts';
15
+
16
+ export interface LinkChangeInput {
17
+ readonly attempt: AttemptRow;
18
+ readonly ref: PullRequestRef;
19
+ readonly reading: PullRequestReading;
20
+ readonly source: 'hook' | 'watch';
21
+ readonly actor: ActorRef;
22
+ readonly now: Instant;
23
+ readonly newId: NewId;
24
+ }
25
+
26
+ // The change that links a pull request to an attempt: the pull request row, the evidence row
27
+ // and `boxxy.pull_request.linked` in one change. A pull request the forge already shows merged
28
+ // or closed also gets its merge or close fact, because the watch reads only open rows.
29
+ export function buildLinkChange(input: LinkChangeInput): StoreChange {
30
+ const attempt = input.attempt;
31
+ const ref = input.ref;
32
+ const reading = input.reading;
33
+ const now = input.now;
34
+ const seen = collectSeenState(reading);
35
+ const linkedFactId = brandSchemas.factId.parse(input.newId());
36
+
37
+ const row: PullRequestRow = {
38
+ repo: ref.repo,
39
+ number: ref.number,
40
+ attempt: attempt.id,
41
+ githubId: reading.githubId,
42
+ url: reading.url,
43
+ state: seen.state,
44
+ mergedAt: seen.mergedAt,
45
+ mergeCommit: seen.mergeCommit,
46
+ checkedAt: now,
47
+ };
48
+
49
+ const entity = `boxxy:item:${attempt.item}` as const;
50
+
51
+ const linked: AnyNewFact = {
52
+ id: linkedFactId,
53
+ type: 'boxxy.pull_request.linked',
54
+ entity,
55
+ actor: input.actor,
56
+ payload: {
57
+ attempt: attempt.id,
58
+ repo: ref.repo,
59
+ number: ref.number,
60
+ url: reading.url,
61
+ pullRequestId: String(reading.githubId),
62
+ source: input.source,
63
+ },
64
+ };
65
+
66
+ const stateFact = buildStateFact({
67
+ row,
68
+ item: attempt.item,
69
+ actor: input.actor,
70
+ id: brandSchemas.factId.parse(input.newId()),
71
+ });
72
+
73
+ const stateFacts = stateFact === null ? [] : [stateFact];
74
+
75
+ return {
76
+ at: now,
77
+ writes: [
78
+ { op: 'insert', table: 'pull_requests', row },
79
+ {
80
+ op: 'insert',
81
+ table: 'evidence',
82
+ row: {
83
+ factId: linkedFactId,
84
+ attempt: attempt.id,
85
+ kind: 'pull_request',
86
+ ref: reading.url,
87
+ observed: null,
88
+ source: input.source,
89
+ at: now,
90
+ },
91
+ },
92
+ ],
93
+ facts: [linked, ...stateFacts],
94
+ };
95
+ }
@@ -0,0 +1,39 @@
1
+ import type { ActorRef, AnyNewFact, FactId, ItemId, PullRequestRow } from '@boxxy/model';
2
+
3
+ export interface StateFactInput {
4
+ readonly row: PullRequestRow;
5
+ readonly item: ItemId;
6
+ readonly actor: ActorRef;
7
+ readonly id: FactId;
8
+ }
9
+
10
+ // The fact for a pull request row that is not open: its merge, or its close with no merge. An
11
+ // open row has none.
12
+ export function buildStateFact(input: StateFactInput): AnyNewFact | null {
13
+ const row = input.row;
14
+
15
+ const base = {
16
+ id: input.id,
17
+ entity: `boxxy:item:${input.item}`,
18
+ actor: input.actor,
19
+ } as const;
20
+
21
+ const shared = {
22
+ attempt: row.attempt,
23
+ repo: row.repo,
24
+ number: row.number,
25
+ pullRequestId: String(row.githubId),
26
+ };
27
+
28
+ if (row.state === 'merged' && row.mergedAt !== null && row.mergeCommit !== null) {
29
+ return {
30
+ ...base,
31
+ type: 'boxxy.pull_request.merged',
32
+ payload: { ...shared, mergedAt: row.mergedAt, mergeCommit: row.mergeCommit },
33
+ };
34
+ }
35
+
36
+ return row.state === 'closed'
37
+ ? { ...base, type: 'boxxy.pull_request.closed', payload: shared }
38
+ : null;
39
+ }
@@ -0,0 +1,20 @@
1
+ import type { AttemptId } from '@boxxy/model';
2
+ import { brandSchemas } from '@boxxy/model';
3
+
4
+ const trailerPattern = /^Boxxy-Attempt:[ \t]*(?<attempt>\S+)[ \t]*$/gimu;
5
+
6
+ // The attempt IDs that the `Boxxy-Attempt` trailers of a commit message carry, in order. A
7
+ // value that is not an attempt ID is left out.
8
+ export function collectAttemptTrailers(message: string): AttemptId[] {
9
+ const attempts: AttemptId[] = [];
10
+
11
+ for (const match of message.matchAll(trailerPattern)) {
12
+ const parsed = brandSchemas.attemptId.safeParse(match.groups?.['attempt']);
13
+
14
+ if (parsed.success) {
15
+ attempts.push(parsed.data);
16
+ }
17
+ }
18
+
19
+ return attempts;
20
+ }
@@ -0,0 +1,19 @@
1
+ import type { CommitSha, Instant, PullRequestReading, PullRequestRow } from '@boxxy/model';
2
+
3
+ export interface SeenState {
4
+ readonly state: PullRequestRow['state'];
5
+ readonly mergedAt: Instant | null;
6
+ readonly mergeCommit: CommitSha | null;
7
+ }
8
+
9
+ // The state the watch records for a reading. A merged reading with no merge time or no merge
10
+ // commit reads as open, so the next pass sees the merge once the forge shows it whole.
11
+ export function collectSeenState(reading: PullRequestReading): SeenState {
12
+ if (reading.state === 'merged') {
13
+ return reading.mergedAt !== null && reading.mergeCommit !== null
14
+ ? { state: 'merged', mergedAt: reading.mergedAt, mergeCommit: reading.mergeCommit }
15
+ : { state: 'open', mergedAt: null, mergeCommit: null };
16
+ }
17
+
18
+ return { state: reading.state, mergedAt: null, mergeCommit: null };
19
+ }
@@ -0,0 +1,5 @@
1
+ export const watchLimits = {
2
+ // The watch looks for the pull request of a succeeded build for this long after the build
3
+ // ended; a build that opened none stops costing forge calls after it.
4
+ linkWindowMs: 7 * 24 * 60 * 60 * 1000,
5
+ } as const;
@@ -0,0 +1,119 @@
1
+ import type { ItemId, PullRequestReading, PullRequestRef, StoreReader } from '@boxxy/model';
2
+ import invariant from 'tiny-invariant';
3
+ import { pickCallAttempt } from '../attempts/pick-call-attempt.ts';
4
+ import { buildLinkChange } from './build-link-change.ts';
5
+ import { parsePullRequestUrl } from './parse-pull-request-url.ts';
6
+ import type { LinkPullRequestInput, LinkPullRequestResult } from './types.ts';
7
+
8
+ // It checks the call before the forge read and again in the write's lock, because a newer attempt
9
+ // can take the session while the forge answers.
10
+ export async function linkPullRequest(input: LinkPullRequestInput): Promise<LinkPullRequestResult> {
11
+ const ref = parsePullRequestUrl(input.url);
12
+
13
+ if (ref === null) {
14
+ return { kind: 'refused', reason: 'not-a-pull-request-url' };
15
+ }
16
+
17
+ const latest = await input.store.read((reader) =>
18
+ reader.findAttemptBySession(input.call.session),
19
+ );
20
+
21
+ const picked = pickCallAttempt(latest, input.call);
22
+
23
+ if (picked.kind === 'refused') {
24
+ return picked;
25
+ }
26
+
27
+ const attempt = picked.attempt;
28
+
29
+ const item = await input.store.read((reader) =>
30
+ reader.readItem(attempt.item, input.now, input.rules),
31
+ );
32
+
33
+ invariant(item !== null, 'the attempt of a call has its item');
34
+
35
+ if (item.repo.toLowerCase() !== ref.repo.toLowerCase()) {
36
+ return { kind: 'refused', reason: 'not-a-pull-request-url' };
37
+ }
38
+
39
+ const known = await input.store.read((reader) => readExisting(reader, ref, item.id));
40
+
41
+ if (known !== 'none') {
42
+ return toAnswer(known, ref);
43
+ }
44
+
45
+ const reading = await readOrNull(input, ref);
46
+
47
+ if (reading === null) {
48
+ return { kind: 'retry' };
49
+ }
50
+
51
+ return input.store.exclusive(async (tx) => {
52
+ const latestInLock = await tx.findAttemptBySession(input.call.session);
53
+
54
+ const current = pickCallAttempt(latestInLock, input.call);
55
+
56
+ if (current.kind === 'refused') {
57
+ return current;
58
+ }
59
+
60
+ const raced = await readExisting(tx, ref, item.id);
61
+
62
+ if (raced !== 'none') {
63
+ return toAnswer(raced, ref);
64
+ }
65
+
66
+ const applied = await tx.applyChange(
67
+ buildLinkChange({
68
+ attempt: current.attempt,
69
+ ref,
70
+ reading,
71
+ source: 'hook',
72
+ actor: `boxxy:hook:${current.attempt.id}`,
73
+ now: input.now,
74
+ newId: input.newId,
75
+ }),
76
+ );
77
+
78
+ return applied.kind === 'applied'
79
+ ? { kind: 'linked', repo: ref.repo, number: ref.number, repeat: false }
80
+ : { kind: 'retry' };
81
+ });
82
+ }
83
+
84
+ type Existing = 'none' | 'repeat' | 'elsewhere';
85
+
86
+ async function readExisting(
87
+ reader: StoreReader,
88
+ ref: PullRequestRef,
89
+ item: ItemId,
90
+ ): Promise<Existing> {
91
+ const row = await reader.readPullRequestRow(ref);
92
+
93
+ if (row === null) {
94
+ return 'none';
95
+ }
96
+
97
+ const owner = await reader.readAttempt(row.attempt);
98
+
99
+ return owner?.item === item ? 'repeat' : 'elsewhere';
100
+ }
101
+
102
+ function toAnswer(existing: Exclude<Existing, 'none'>, ref: PullRequestRef): LinkPullRequestResult {
103
+ return existing === 'repeat'
104
+ ? { kind: 'linked', repo: ref.repo, number: ref.number, repeat: true }
105
+ : { kind: 'refused', reason: 'linked-elsewhere' };
106
+ }
107
+
108
+ async function readOrNull(
109
+ input: LinkPullRequestInput,
110
+ ref: PullRequestRef,
111
+ ): Promise<PullRequestReading | null> {
112
+ try {
113
+ const reading = await input.forge.readPullRequest(ref);
114
+
115
+ return reading === 'not-found' ? null : reading;
116
+ } catch {
117
+ return null;
118
+ }
119
+ }
@@ -0,0 +1,30 @@
1
+ import type { PullRequestRef } from '@boxxy/model';
2
+ import { brandSchemas } from '@boxxy/model';
3
+
4
+ const pathPattern = /^\/(?<owner>[^/]+)\/(?<name>[^/]+)\/pull\/(?<number>\d+)(?:\/.*)?$/u;
5
+
6
+ // The repo and number a pull request URL names, or null for any other URL. The host is not
7
+ // checked: the forge decides which host it serves, and the stored URL comes from the forge.
8
+ export function parsePullRequestUrl(url: string): PullRequestRef | null {
9
+ const parsed = URL.parse(url);
10
+
11
+ if (parsed === null || (parsed.protocol !== 'https:' && parsed.protocol !== 'http:')) {
12
+ return null;
13
+ }
14
+
15
+ const match = pathPattern.exec(parsed.pathname);
16
+
17
+ if (match === null) {
18
+ return null;
19
+ }
20
+
21
+ const repo = brandSchemas.repoName.safeParse(
22
+ `${match.groups?.['owner']}/${match.groups?.['name']}`,
23
+ );
24
+
25
+ const number = Number(match.groups?.['number']);
26
+
27
+ return repo.success && Number.isSafeInteger(number) && number > 0
28
+ ? { repo: repo.data, number }
29
+ : null;
30
+ }
@@ -0,0 +1,63 @@
1
+ import type {
2
+ ClaimStore,
3
+ Forge,
4
+ Instant,
5
+ ItemRules,
6
+ NewId,
7
+ RepoName,
8
+ RunnerId,
9
+ } from '@boxxy/model';
10
+ import type { CallRef } from '../closeouts/types.ts';
11
+
12
+ export interface LinkPullRequestInput {
13
+ readonly store: ClaimStore;
14
+ readonly forge: Forge;
15
+ readonly rules: ItemRules;
16
+
17
+ // the call from the hook; the link reaches only the current attempt of the call's session
18
+ readonly call: CallRef;
19
+
20
+ // the URL the hook read from the output of `gh pr create`
21
+ readonly url: string;
22
+ readonly now: Instant;
23
+ readonly newId: NewId;
24
+ }
25
+
26
+ export type LinkPullRequestResult =
27
+ | {
28
+ readonly kind: 'linked';
29
+ readonly repo: RepoName;
30
+ readonly number: number;
31
+
32
+ // the pull request was already linked to this item and nothing was written
33
+ readonly repeat: boolean;
34
+ }
35
+
36
+ // the forge did not answer, or does not know the pull request yet; nothing was written
37
+ | { readonly kind: 'retry' }
38
+
39
+ // `not-a-pull-request-url`: no pull request URL, or one of a repo other than the item's;
40
+ // `no-attempt` and `not-current`: the call names no current attempt of its session
41
+ | {
42
+ readonly kind: 'refused';
43
+ readonly reason: 'not-a-pull-request-url' | 'linked-elsewhere' | 'no-attempt' | 'not-current';
44
+ };
45
+
46
+ export interface WatchPullRequestsInput {
47
+ readonly store: ClaimStore;
48
+ readonly forge: Forge;
49
+ readonly rules: ItemRules;
50
+ readonly runner: RunnerId;
51
+ readonly now: Instant;
52
+ readonly newId: NewId;
53
+ }
54
+
55
+ // What one pass of the watch did. `unanswered` counts forge calls that threw; each is tried
56
+ // again on the next pass.
57
+ export interface WatchReport {
58
+ readonly linked: number;
59
+ readonly commitsLinked: number;
60
+ readonly merged: number;
61
+ readonly closed: number;
62
+ readonly unanswered: number;
63
+ }