@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,161 @@
1
+ import type {
2
+ AttemptRow,
3
+ EngineOutcome,
4
+ Instant,
5
+ ItemView,
6
+ LoadedRules,
7
+ OutcomeName,
8
+ StoreChange,
9
+ StoreTx,
10
+ } from '@boxxy/model';
11
+ import invariant from 'tiny-invariant';
12
+ import { buildAttemptChange } from '../attempts/build-attempt-change.ts';
13
+ import { isSameGuard } from '../attempts/is-same-guard.ts';
14
+ import type { MoveResult } from '../attempts/types.ts';
15
+ import { buildKeepWrites } from '../sessions/build-keep-writes.ts';
16
+ import { buildSettlePlans } from './build-settle-plans.ts';
17
+ import { pickSettlement } from './pick-settlement.ts';
18
+ import type { SettleAttemptInput } from './types.ts';
19
+
20
+ // Settles one attempt, with the writes of its effects, in one change and one lock. No step
21
+ // throws on stored data, so only a store error leaves it. When the item holds after the settle,
22
+ // the same lock writes the notice row. A settle decided on an older row refuses, with no write.
23
+ export function settleAttempt(input: SettleAttemptInput): Promise<MoveResult> {
24
+ return input.store.exclusive(async (tx) => {
25
+ const attempt = await tx.readAttempt(input.attempt);
26
+
27
+ invariant(attempt !== null, `the settle names a stored attempt: ${input.attempt}`);
28
+
29
+ if (input.seen !== undefined && !isSameGuard(input.seen, attempt)) {
30
+ return { kind: 'refused', reason: 'guard-mismatch' };
31
+ }
32
+
33
+ const detail = await tx.readItemDetail(attempt.item, input.now, input.rules);
34
+
35
+ invariant(detail !== null, `an attempt's item is stored: ${attempt.item}`);
36
+
37
+ const item = detail.item;
38
+
39
+ const settlement = pickSettlement({
40
+ attempt,
41
+ item,
42
+ rules: input.rules,
43
+ forced: input.forced,
44
+ now: input.now,
45
+ });
46
+
47
+ const hasFileItem = settlement.effects.some((effect) => effect.kind === 'file-item');
48
+
49
+ const settled = buildSettlePlans({
50
+ effects: settlement.effects,
51
+ ctx: {
52
+ attempt,
53
+ item,
54
+ rules: input.rules,
55
+ relations: detail.relations.filter((relation) => relation.source === item.id),
56
+ actor: input.actor,
57
+ now: input.now,
58
+ newId: input.newId,
59
+ },
60
+ openIssues: hasFileItem ? await tx.listOpenIssues(item.repo, input.now, input.rules) : [],
61
+ held: isHoldEdge(input.rules, item, attempt, settlement.outcome),
62
+ });
63
+
64
+ const built = buildAttemptChange({
65
+ attempt,
66
+ event: {
67
+ kind: 'settled',
68
+ state: settlement.state,
69
+ failure: settlement.failure,
70
+ outcome: settlement.outcome,
71
+ returnedOutcome: settlement.returnedOutcome,
72
+ sessionStopped: input.sessionStopped,
73
+ frictionDropped: settlement.frictionDropped,
74
+ criteria: settled.criteria,
75
+ matched: settled.matched,
76
+ skippedEffects: settled.skippedEffects,
77
+ },
78
+ actor: input.actor,
79
+ now: input.now,
80
+ newId: input.newId,
81
+ });
82
+
83
+ if (built.kind === 'not-allowed') {
84
+ return { kind: 'not-allowed' };
85
+ }
86
+
87
+ const change: StoreChange = {
88
+ ...built.change,
89
+ writes: [
90
+ ...built.change.writes,
91
+ ...(settlement.state === 'succeeded' && input.sessionStopped
92
+ ? buildKeepWrites({ attempt, item, rules: input.rules, now: input.now })
93
+ : []),
94
+ ...settled.plans.flatMap((plan) => (plan.kind === 'apply' ? plan.writes : [])),
95
+ ],
96
+ facts: [
97
+ ...built.change.facts,
98
+ ...settled.plans.flatMap((plan) => (plan.kind === 'apply' ? plan.facts : [])),
99
+ ],
100
+ };
101
+
102
+ const result = await tx.applyChange(change);
103
+
104
+ if (result.kind !== 'applied') {
105
+ return { kind: 'refused', reason: result.kind };
106
+ }
107
+
108
+ await writeHoldNotice(tx, attempt, input.rules, input.now);
109
+
110
+ return result;
111
+ });
112
+ }
113
+
114
+ // The one place where settle reads an edge; it stores no position.
115
+ function isHoldEdge(
116
+ rules: LoadedRules,
117
+ item: ItemView,
118
+ attempt: AttemptRow,
119
+ outcome: OutcomeName | EngineOutcome,
120
+ ): boolean {
121
+ const step = rules.kinds
122
+ .get(item.kind)
123
+ ?.pipeline.steps.find((candidate) => candidate.phase === attempt.phase);
124
+
125
+ const edge = step?.on[outcome];
126
+
127
+ return typeof edge === 'object' && 'hold' in edge;
128
+ }
129
+
130
+ async function writeHoldNotice(
131
+ tx: StoreTx,
132
+ attempt: AttemptRow,
133
+ rules: LoadedRules,
134
+ now: Instant,
135
+ ): Promise<void> {
136
+ const after = await tx.readItem(attempt.item, now, rules);
137
+
138
+ if (after?.position.kind !== 'hold') {
139
+ return;
140
+ }
141
+
142
+ // the store numbers each notice, so the ID here is replaced
143
+ await tx.applyChange({
144
+ at: now,
145
+ writes: [
146
+ {
147
+ op: 'insert',
148
+ table: 'notices',
149
+ row: {
150
+ id: 1,
151
+ item: attempt.item,
152
+ reason: after.position.reason,
153
+ at: now,
154
+ tries: 0,
155
+ sentAt: null,
156
+ },
157
+ },
158
+ ],
159
+ facts: [],
160
+ });
161
+ }
@@ -0,0 +1,87 @@
1
+ import type {
2
+ ActorRef,
3
+ AnyNewFact,
4
+ AtcSessionId,
5
+ AttemptId,
6
+ AttemptRow,
7
+ CallerIdentity,
8
+ ClaimStore,
9
+ Effect,
10
+ ForcedFailure,
11
+ Instant,
12
+ ItemView,
13
+ LoadedRules,
14
+ NewId,
15
+ RelationRow,
16
+ RowWrite,
17
+ } from '@boxxy/model';
18
+ import type { AttemptGuard } from '../attempts/is-same-guard.ts';
19
+
20
+ // What one effect of a settle writes, or why it writes nothing.
21
+ export type EffectPlan =
22
+ | {
23
+ readonly kind: 'apply';
24
+ readonly effect: Effect;
25
+ readonly writes: readonly RowWrite[];
26
+ readonly facts: readonly AnyNewFact[];
27
+ }
28
+ | { readonly kind: 'skip'; readonly effect: Effect; readonly reason: string };
29
+
30
+ // What an effect handler reads. A handler does no I/O: settle reads these first.
31
+ export interface EffectContext {
32
+ readonly attempt: AttemptRow;
33
+
34
+ // the attempt's item, open or closed
35
+ readonly item: ItemView;
36
+ readonly rules: LoadedRules;
37
+
38
+ // the live relations of the attempt's item
39
+ readonly relations: readonly RelationRow[];
40
+ readonly actor: ActorRef;
41
+ readonly now: Instant;
42
+ readonly newId: NewId;
43
+ }
44
+
45
+ // One handler for each kind of effect.
46
+ export type EffectHandlers = {
47
+ readonly [Kind in Effect['kind']]: (
48
+ effect: Extract<Effect, { kind: Kind }>,
49
+ ctx: EffectContext,
50
+ ) => EffectPlan;
51
+ };
52
+
53
+ export interface SettleAttemptInput {
54
+ readonly store: ClaimStore;
55
+ readonly attempt: AttemptId;
56
+
57
+ // the cause that ends the attempt with the outcome `lost`; null settles by the close-out
58
+ readonly forced: ForcedFailure | null;
59
+
60
+ // false: settled by the give-up rule while the session still ran
61
+ readonly sessionStopped: boolean;
62
+
63
+ // the guard columns of the row the caller decided on, when it read the row before the lock
64
+ readonly seen?: Partial<AttemptGuard>;
65
+ readonly rules: LoadedRules;
66
+ readonly actor: ActorRef;
67
+ readonly now: Instant;
68
+ readonly newId: NewId;
69
+ }
70
+
71
+ // What every call from inside a session gives the core. `attempt` comes from the request: the ID
72
+ // the session read with `attempt.current`. `session` and `caller` come from the edge, which
73
+ // proved the caller and found its session.
74
+ export interface CallRef {
75
+ readonly attempt: AttemptId;
76
+ readonly session: AtcSessionId;
77
+ readonly caller: CallerIdentity;
78
+ }
79
+
80
+ export interface CloseOutInput {
81
+ readonly store: ClaimStore;
82
+ readonly call: CallRef;
83
+ readonly body: unknown;
84
+ readonly rules: LoadedRules;
85
+ readonly now: Instant;
86
+ readonly newId: NewId;
87
+ }
package/src/index.ts CHANGED
@@ -1 +1,62 @@
1
+ export { bindAttemptSession } from './attempts/bind-attempt-session.ts';
2
+ export type { BindAnswer, BindAttemptSessionInput } from './attempts/bind-attempt-session.ts';
3
+ export { endItemAttempts } from './attempts/end-item-attempts.ts';
4
+ export type { EndedAttempts } from './attempts/end-item-attempts.ts';
5
+ export { pickCallAttempt } from './attempts/pick-call-attempt.ts';
6
+ export type { CallAttempt } from './attempts/pick-call-attempt.ts';
7
+ export { stopStraySession } from './attempts/stop-stray-session.ts';
8
+ export type { StopStraySessionInput } from './attempts/stop-stray-session.ts';
9
+ export type { MoveResult } from './attempts/types.ts';
10
+ export { ClaimRaceError, claimNext } from './claims/claim-next.ts';
11
+ export { requestWake } from './claims/request-wake.ts';
12
+ export { resumeClaim } from './claims/resume-claim.ts';
13
+ export { startClaim } from './claims/start-claim.ts';
14
+
15
+ export type {
16
+ ClaimInput,
17
+ RequestWakeInput,
18
+ ResumeClaimInput,
19
+ SpawnSettings,
20
+ StartClaimInput,
21
+ } from './claims/types.ts';
22
+
23
+ export { closeOutAttempt } from './closeouts/close-out-attempt.ts';
24
+ export { settleAttempt } from './closeouts/settle-attempt.ts';
25
+ export type { CallRef, CloseOutInput, EffectPlan, SettleAttemptInput } from './closeouts/types.ts';
26
+ export { noticeLimits } from './notices/constants.ts';
27
+ export { createNullNotifier } from './notices/create-null-notifier.ts';
28
+ export { sendNotices } from './notices/send-notices.ts';
29
+ export type { NoticeReport, SendNoticesInput } from './notices/types.ts';
1
30
  export { packageName } from './package-name.ts';
31
+ export { buildInitialSenderState } from './outbox/build-initial-sender-state.ts';
32
+ export { buildOutboxStatus } from './outbox/build-outbox-status.ts';
33
+ export { outboxLimits } from './outbox/constants.ts';
34
+ export { runSenderOnce } from './outbox/run-sender-once.ts';
35
+
36
+ export type {
37
+ OutboxLimits,
38
+ SenderDeps,
39
+ SenderState,
40
+ SenderStep,
41
+ SenderTurn,
42
+ } from './outbox/types.ts';
43
+
44
+ export { actOnReading } from './readings/act-on-reading.ts';
45
+ export { findWaitingSince } from './readings/find-waiting-since.ts';
46
+ export { pickReadingAction } from './readings/pick-reading-action.ts';
47
+ export type { ActOnReadingInput, ReadingAction, ReadingInput } from './readings/types.ts';
48
+ export { forgetSessions } from './sessions/forget-sessions.ts';
49
+ export type { ForgetReport, ForgetSessionsInput } from './sessions/types.ts';
50
+ export { timeouts } from './timeouts.ts';
51
+ export type { DispatchTimeouts } from './types.ts';
52
+ export { linkPullRequest } from './pull-requests/link-pull-request.ts';
53
+ export { parsePullRequestUrl } from './pull-requests/parse-pull-request-url.ts';
54
+
55
+ export type {
56
+ LinkPullRequestInput,
57
+ LinkPullRequestResult,
58
+ WatchPullRequestsInput,
59
+ WatchReport,
60
+ } from './pull-requests/types.ts';
61
+
62
+ export { watchPullRequests } from './pull-requests/watch-pull-requests.ts';
@@ -0,0 +1,7 @@
1
+ export const noticeLimits = {
2
+ // a row with this many tries is no longer sent, and stays listed in `runner.status`
3
+ maxTries: 5,
4
+
5
+ // the unsent rows one tick reads, tried or not
6
+ readRows: 200,
7
+ } as const;
@@ -0,0 +1,6 @@
1
+ import type { Notifier } from '@boxxy/model';
2
+
3
+ // For a configuration that names no notice session: every notice counts as sent.
4
+ export function createNullNotifier(): Notifier {
5
+ return { tellHold: () => Promise.resolve({ kind: 'sent' }) };
6
+ }
@@ -0,0 +1,41 @@
1
+ import type { NoticeOutcome, NoticeRow } from '@boxxy/model';
2
+ import { noticeLimits } from './constants.ts';
3
+ import type { NoticeReport, SendNoticesInput } from './types.ts';
4
+
5
+ // Sends each unsent hold notice through the notifier and counts the try on its row. A failed
6
+ // notice stays unsent until it has used `maxTries`. A notifier that throws counts as a failed
7
+ // one, and no notice changes an attempt or an item.
8
+ export async function sendNotices(input: SendNoticesInput): Promise<NoticeReport> {
9
+ const rows = await input.store.read((tx) => tx.listUnsentNotices(noticeLimits.readRows));
10
+
11
+ let sent = 0;
12
+ let failed = 0;
13
+
14
+ for (const row of rows.filter((candidate) => candidate.tries < noticeLimits.maxTries)) {
15
+ const outcome = await tellHold(input, row);
16
+
17
+ const isSent = outcome.kind === 'sent';
18
+
19
+ await input.store.exclusive((tx) => tx.markNoticeTried(row.id, input.now, isSent));
20
+
21
+ if (isSent) {
22
+ sent += 1;
23
+ } else {
24
+ failed += 1;
25
+ }
26
+ }
27
+
28
+ return {
29
+ sent,
30
+ failed,
31
+ stuck: rows.filter((candidate) => candidate.tries >= noticeLimits.maxTries).length,
32
+ };
33
+ }
34
+
35
+ async function tellHold(input: SendNoticesInput, row: NoticeRow): Promise<NoticeOutcome> {
36
+ try {
37
+ return await input.notifier.tellHold({ item: row.item, reason: row.reason, at: row.at });
38
+ } catch (error) {
39
+ return { kind: 'failed', reason: error instanceof Error ? error.message : 'notifier threw' };
40
+ }
41
+ }
@@ -0,0 +1,14 @@
1
+ import type { ClaimStore, Instant, Notifier } from '@boxxy/model';
2
+
3
+ export interface SendNoticesInput {
4
+ readonly store: ClaimStore;
5
+ readonly notifier: Notifier;
6
+ readonly now: Instant;
7
+ }
8
+
9
+ // What one tick did with the unsent rows. `stuck` rows have used all their tries.
10
+ export interface NoticeReport {
11
+ readonly sent: number;
12
+ readonly failed: number;
13
+ readonly stuck: number;
14
+ }
@@ -0,0 +1,14 @@
1
+ import type { OutboxLimits } from './types.ts';
2
+
3
+ // The wait before the next try, after this many `retry` answers in a row: the first wait, doubled
4
+ // each time up to the longest wait, then moved by the jitter. `random` is in [0, 1).
5
+ export function buildBackoffMs(retries: number, limits: OutboxLimits, random: number): number {
6
+ const backoff = limits.backoff;
7
+
8
+ const base = Math.min(
9
+ backoff.firstMs * backoff.factor ** Math.max(retries - 1, 0),
10
+ backoff.maxMs,
11
+ );
12
+
13
+ return Math.round(base * (1 + backoff.jitter * (2 * random - 1)));
14
+ }
@@ -0,0 +1,14 @@
1
+ import type { SenderState } from './types.ts';
2
+
3
+ export function buildInitialSenderState(): SenderState {
4
+ return {
5
+ queue: [],
6
+ retries: 0,
7
+ parkRun: 0,
8
+ phase: 'idle',
9
+ failingSince: null,
10
+ nextTryAt: null,
11
+ lastError: null,
12
+ lastStoredAt: null,
13
+ };
14
+ }
@@ -0,0 +1,43 @@
1
+ import type { Instant, OutboxStatus } from '@boxxy/model';
2
+ import type { SenderState } from './types.ts';
3
+
4
+ const msPerMinute = 60_000;
5
+
6
+ interface OutboxStatusInput {
7
+ readonly sink: 'pepys' | 'none';
8
+ readonly state: SenderState;
9
+ readonly counts: {
10
+ readonly unsent: number;
11
+ readonly parked: number;
12
+ readonly oldestUnsentAt: Instant | null;
13
+ };
14
+ readonly now: Instant;
15
+
16
+ // a batch is in flight
17
+ readonly isSending: boolean;
18
+ }
19
+
20
+ export function buildOutboxStatus(input: OutboxStatusInput): OutboxStatus {
21
+ return {
22
+ sink: input.sink,
23
+ state: pickState(input),
24
+ unsent: input.counts.unsent,
25
+ parked: input.counts.parked,
26
+ lag:
27
+ input.counts.oldestUnsentAt === null
28
+ ? null
29
+ : Math.max(0, Math.floor((input.now - input.counts.oldestUnsentAt) / msPerMinute)),
30
+ failingSince: input.state.failingSince,
31
+ nextTryAt: input.state.nextTryAt,
32
+ lastError: input.state.lastError,
33
+ lastStoredAt: input.state.lastStoredAt,
34
+ };
35
+ }
36
+
37
+ function pickState(input: OutboxStatusInput): OutboxStatus['state'] {
38
+ if (input.sink === 'none') {
39
+ return 'off';
40
+ }
41
+
42
+ return input.isSending ? 'sending' : input.state.phase;
43
+ }
@@ -0,0 +1,15 @@
1
+ import type { OutboxLimits } from './types.ts';
2
+
3
+ export const outboxLimits: OutboxLimits = {
4
+ batchFacts: 100,
5
+ factBytes: 262_144,
6
+
7
+ // One batch is one S2 append, which holds 1,048,576 metered bytes. The rest is margin for S2's
8
+ // JSON request of about 2 MiB, which a body made mostly of quotes reaches at 1 MiB.
9
+ batchBytes: 900_000,
10
+ envelopeBytes: 512,
11
+ requestTimeoutMs: 10_000,
12
+ backoff: { firstMs: 1000, factor: 2, maxMs: 300_000, jitter: 0.2 },
13
+ blockedRetryMs: 300_000,
14
+ parkRun: 25,
15
+ };
@@ -0,0 +1,31 @@
1
+ import type { OutboxFact } from '@boxxy/model';
2
+
3
+ const encoder = new TextEncoder();
4
+
5
+ // An ISO 8601 time of a UTC instant has this one length, so the size needs no date function.
6
+ const isoTimePlaceholder = '2026-01-01T00:00:00.000Z';
7
+ const nullBytes = 4;
8
+ const storeVersionKey = '"storeVersion":';
9
+
10
+ // The UTF-8 bytes of the fact as the sink sends it: the row's fields, the time as ISO 8601, and
11
+ // the store version added to the payload. The payload is encoded once, and the added field is
12
+ // counted by its length.
13
+ export function measureWireBytes(fact: OutboxFact): number {
14
+ const envelope = JSON.stringify({
15
+ type: fact.type,
16
+ event: fact.factId,
17
+ entity: fact.entity,
18
+ actor: fact.actor,
19
+ at: isoTimePlaceholder,
20
+ payload: null,
21
+ });
22
+
23
+ const payload = JSON.stringify(fact.payload);
24
+ const separatorBytes = payload === '{}' ? 0 : 1;
25
+
26
+ const addedBytes = payload.startsWith('{')
27
+ ? separatorBytes + storeVersionKey.length + String(fact.seq).length
28
+ : 0;
29
+
30
+ return encoder.encode(envelope).length - nullBytes + encoder.encode(payload).length + addedBytes;
31
+ }
@@ -0,0 +1,37 @@
1
+ import type { SinkAnswer } from '@boxxy/model';
2
+ import { match } from 'ts-pattern';
3
+ import type { OutboxLimits, SenderMove } from './types.ts';
4
+
5
+ interface SenderMoveInput {
6
+ readonly answer: SinkAnswer;
7
+ readonly batchSize: number;
8
+
9
+ // rows parked since the last `stored`
10
+ readonly parkRun: number;
11
+ readonly limits: OutboxLimits;
12
+ }
13
+
14
+ const versionSkewReason = 'pepys refuses every fact';
15
+
16
+ // What the sender does with each answer of the sink.
17
+ export function pickSenderMove(input: SenderMoveInput): SenderMove {
18
+ return match(input.answer)
19
+ .with({ kind: 'stored' }, (): SenderMove => ({ kind: 'mark-sent' }))
20
+ .with({ kind: 'retry' }, (): SenderMove => ({ kind: 'retry-same' }))
21
+ .with({ kind: 'refused-sink' }, (refused): SenderMove => ({
22
+ kind: 'block',
23
+ reason: refused.reason,
24
+ }))
25
+ .with({ kind: 'refused-batch' }, (refused): SenderMove => {
26
+ if (input.batchSize > 1) {
27
+ return { kind: 'split' };
28
+ }
29
+
30
+ if (input.parkRun >= input.limits.parkRun) {
31
+ return { kind: 'block', reason: versionSkewReason };
32
+ }
33
+
34
+ return { kind: 'park', reason: refused.status === 413 ? 'too-large' : refused.reason };
35
+ })
36
+ .exhaustive();
37
+ }