@boxxy/dispatch 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (190) hide show
  1. package/dist/attempts/attempt-machine.d.ts +804 -0
  2. package/dist/attempts/attempt-moves.d.ts +2 -0
  3. package/dist/attempts/bind-attempt-session.d.ts +14 -0
  4. package/dist/attempts/build-attempt-change.d.ts +73 -0
  5. package/dist/attempts/build-attempt-fact.d.ts +3 -0
  6. package/dist/attempts/end-item-attempts.d.ts +18 -0
  7. package/dist/attempts/is-same-guard.d.ts +5 -0
  8. package/dist/attempts/pick-call-attempt.d.ts +10 -0
  9. package/dist/attempts/stop-stray-session.d.ts +11 -0
  10. package/dist/attempts/types.d.ts +142 -0
  11. package/dist/attempts/write-attempt-move.d.ts +7 -0
  12. package/dist/attempts/write-session-bind.d.ts +3 -0
  13. package/dist/claims/build-snapshot-hash.d.ts +2 -0
  14. package/dist/claims/claim-next.d.ts +6 -0
  15. package/dist/claims/pick-kept-session.d.ts +9 -0
  16. package/dist/claims/request-wake.d.ts +3 -0
  17. package/dist/claims/resume-claim.d.ts +3 -0
  18. package/dist/claims/start-claim.d.ts +3 -0
  19. package/dist/claims/types.d.ts +44 -0
  20. package/dist/closeouts/build-settle-plans.d.ts +17 -0
  21. package/dist/closeouts/close-out-attempt.d.ts +3 -0
  22. package/dist/closeouts/effect-handlers.d.ts +2 -0
  23. package/dist/closeouts/pick-settlement.d.ts +14 -0
  24. package/dist/closeouts/settle-attempt.d.ts +3 -0
  25. package/dist/closeouts/types.d.ts +50 -0
  26. package/dist/index.d.ts +38 -0
  27. package/dist/notices/constants.d.ts +4 -0
  28. package/dist/notices/create-null-notifier.d.ts +2 -0
  29. package/dist/notices/send-notices.d.ts +2 -0
  30. package/dist/notices/types.d.ts +11 -0
  31. package/dist/outbox/build-backoff-ms.d.ts +2 -0
  32. package/dist/outbox/build-initial-sender-state.d.ts +2 -0
  33. package/dist/outbox/build-outbox-status.d.ts +15 -0
  34. package/dist/outbox/constants.d.ts +2 -0
  35. package/dist/outbox/measure-wire-bytes.d.ts +2 -0
  36. package/dist/outbox/pick-sender-move.d.ts +10 -0
  37. package/dist/outbox/run-sender-once.d.ts +2 -0
  38. package/dist/outbox/take-batch.d.ts +3 -0
  39. package/dist/outbox/types.d.ts +61 -0
  40. package/dist/pull-requests/build-link-change.d.ts +11 -0
  41. package/dist/pull-requests/build-state-fact.d.ts +8 -0
  42. package/dist/pull-requests/collect-attempt-trailers.d.ts +2 -0
  43. package/dist/pull-requests/collect-seen-state.d.ts +7 -0
  44. package/dist/pull-requests/constants.d.ts +3 -0
  45. package/dist/pull-requests/link-pull-request.d.ts +2 -0
  46. package/dist/pull-requests/parse-pull-request-url.d.ts +2 -0
  47. package/dist/pull-requests/types.d.ts +37 -0
  48. package/dist/pull-requests/watch-pull-requests.d.ts +2 -0
  49. package/dist/readings/act-on-reading.d.ts +3 -0
  50. package/dist/readings/find-waiting-since.d.ts +9 -0
  51. package/dist/readings/pick-reading-action.d.ts +2 -0
  52. package/dist/readings/types.d.ts +79 -0
  53. package/dist/sessions/build-keep-writes.d.ts +9 -0
  54. package/dist/sessions/build-kept-clear.d.ts +2 -0
  55. package/dist/sessions/forget-sessions.d.ts +2 -0
  56. package/dist/sessions/is-kept-due.d.ts +2 -0
  57. package/dist/sessions/resume-kept-session.d.ts +6 -0
  58. package/dist/sessions/types.d.ts +19 -0
  59. package/dist/test-utils/apply-memory-change.d.ts +3 -0
  60. package/dist/test-utils/build-memory-reader.d.ts +3 -0
  61. package/dist/test-utils/build-mock-claim-change.d.ts +2 -2
  62. package/dist/test-utils/build-mock-new-id.d.ts +2 -0
  63. package/dist/test-utils/build-mock-note-change.d.ts +2 -0
  64. package/dist/test-utils/build-mock-pull-request-change.d.ts +1 -0
  65. package/dist/test-utils/{build-mock-link-change.d.ts → build-mock-relation-change.d.ts} +2 -2
  66. package/dist/test-utils/build-mock-running-reading.d.ts +6 -0
  67. package/dist/test-utils/build-mock-start-change.d.ts +2 -2
  68. package/dist/test-utils/claim-store-close-cases.d.ts +2 -0
  69. package/dist/test-utils/claim-store-copy-cases.d.ts +2 -0
  70. package/dist/test-utils/claim-store-outbox-cases.d.ts +2 -0
  71. package/dist/test-utils/claim-store-race-cases.d.ts +2 -0
  72. package/dist/test-utils/claim-store-seat-cases.d.ts +2 -0
  73. package/dist/test-utils/claim-store-session-cases.d.ts +2 -0
  74. package/dist/test-utils/collect-memory-limit-fills.d.ts +3 -0
  75. package/dist/test-utils/compare-text.d.ts +1 -0
  76. package/dist/test-utils/create-fake-backoff.d.ts +2 -0
  77. package/dist/test-utils/create-fake-clock.d.ts +5 -0
  78. package/dist/test-utils/create-fake-fact-sink.d.ts +8 -0
  79. package/dist/test-utils/create-fake-forge.d.ts +17 -0
  80. package/dist/test-utils/create-fake-worker.d.ts +42 -0
  81. package/dist/test-utils/create-memory-claim-store.d.ts +2 -2
  82. package/dist/test-utils/create-test-dispatch.d.ts +46 -0
  83. package/dist/test-utils/forge-contract.d.ts +15 -0
  84. package/dist/test-utils/is-same-held-seat.d.ts +2 -0
  85. package/dist/test-utils/types.d.ts +12 -2
  86. package/dist/test-utils/worker-contract.d.ts +14 -0
  87. package/dist/timeouts.d.ts +2 -0
  88. package/dist/types.d.ts +13 -0
  89. package/package.json +10 -3
  90. package/src/attempts/attempt-machine.ts +147 -0
  91. package/src/attempts/attempt-moves.ts +218 -0
  92. package/src/attempts/bind-attempt-session.ts +61 -0
  93. package/src/attempts/build-attempt-change.ts +540 -0
  94. package/src/attempts/build-attempt-fact.ts +135 -0
  95. package/src/attempts/end-item-attempts.ts +95 -0
  96. package/src/attempts/is-same-guard.ts +22 -0
  97. package/src/attempts/pick-call-attempt.ts +32 -0
  98. package/src/attempts/stop-stray-session.ts +48 -0
  99. package/src/attempts/types.ts +231 -0
  100. package/src/attempts/write-attempt-move.ts +41 -0
  101. package/src/attempts/write-session-bind.ts +47 -0
  102. package/src/claims/build-snapshot-hash.ts +9 -0
  103. package/src/claims/claim-next.ts +306 -0
  104. package/src/claims/pick-kept-session.ts +41 -0
  105. package/src/claims/request-wake.ts +17 -0
  106. package/src/claims/resume-claim.ts +56 -0
  107. package/src/claims/start-claim.ts +99 -0
  108. package/src/claims/types.ts +67 -0
  109. package/src/closeouts/build-settle-plans.ts +173 -0
  110. package/src/closeouts/close-out-attempt.ts +156 -0
  111. package/src/closeouts/effect-handlers.ts +232 -0
  112. package/src/closeouts/pick-settlement.ts +80 -0
  113. package/src/closeouts/settle-attempt.ts +161 -0
  114. package/src/closeouts/types.ts +87 -0
  115. package/src/index.ts +61 -0
  116. package/src/notices/constants.ts +7 -0
  117. package/src/notices/create-null-notifier.ts +6 -0
  118. package/src/notices/send-notices.ts +41 -0
  119. package/src/notices/types.ts +14 -0
  120. package/src/outbox/build-backoff-ms.ts +14 -0
  121. package/src/outbox/build-initial-sender-state.ts +14 -0
  122. package/src/outbox/build-outbox-status.ts +43 -0
  123. package/src/outbox/constants.ts +15 -0
  124. package/src/outbox/measure-wire-bytes.ts +31 -0
  125. package/src/outbox/pick-sender-move.ts +37 -0
  126. package/src/outbox/run-sender-once.ts +174 -0
  127. package/src/outbox/take-batch.ts +35 -0
  128. package/src/outbox/types.ts +76 -0
  129. package/src/pull-requests/build-link-change.ts +95 -0
  130. package/src/pull-requests/build-state-fact.ts +39 -0
  131. package/src/pull-requests/collect-attempt-trailers.ts +20 -0
  132. package/src/pull-requests/collect-seen-state.ts +19 -0
  133. package/src/pull-requests/constants.ts +5 -0
  134. package/src/pull-requests/link-pull-request.ts +119 -0
  135. package/src/pull-requests/parse-pull-request-url.ts +30 -0
  136. package/src/pull-requests/types.ts +63 -0
  137. package/src/pull-requests/watch-pull-requests.ts +308 -0
  138. package/src/readings/act-on-reading.ts +318 -0
  139. package/src/readings/find-waiting-since.ts +46 -0
  140. package/src/readings/pick-reading-action.ts +315 -0
  141. package/src/readings/types.ts +98 -0
  142. package/src/sessions/build-keep-writes.ts +55 -0
  143. package/src/sessions/build-kept-clear.ts +12 -0
  144. package/src/sessions/forget-sessions.ts +106 -0
  145. package/src/sessions/is-kept-due.ts +27 -0
  146. package/src/sessions/resume-kept-session.ts +25 -0
  147. package/src/sessions/types.ts +39 -0
  148. package/src/test-utils/apply-memory-change.ts +252 -0
  149. package/src/test-utils/build-memory-reader.ts +364 -0
  150. package/src/test-utils/build-mock-bind-change.ts +18 -11
  151. package/src/test-utils/build-mock-claim-change.ts +18 -8
  152. package/src/test-utils/build-mock-file-item-change.ts +3 -1
  153. package/src/test-utils/build-mock-new-id.ts +12 -0
  154. package/src/test-utils/build-mock-note-change.ts +18 -0
  155. package/src/test-utils/build-mock-pause-change.ts +2 -10
  156. package/src/test-utils/build-mock-pull-request-change.ts +19 -9
  157. package/src/test-utils/{build-mock-link-change.ts → build-mock-relation-change.ts} +6 -6
  158. package/src/test-utils/build-mock-running-reading.ts +19 -0
  159. package/src/test-utils/build-mock-settle-change.ts +18 -6
  160. package/src/test-utils/build-mock-start-change.ts +13 -6
  161. package/src/test-utils/claim-store-attempt-reader-cases.ts +163 -133
  162. package/src/test-utils/claim-store-claim-cases.ts +168 -85
  163. package/src/test-utils/claim-store-close-cases.ts +95 -0
  164. package/src/test-utils/claim-store-contract.ts +12 -2
  165. package/src/test-utils/claim-store-copy-cases.ts +100 -0
  166. package/src/test-utils/claim-store-exclusive-cases.ts +65 -19
  167. package/src/test-utils/claim-store-guard-cases.ts +92 -82
  168. package/src/test-utils/claim-store-item-reader-cases.ts +291 -77
  169. package/src/test-utils/claim-store-outbox-cases.ts +281 -0
  170. package/src/test-utils/claim-store-race-cases.ts +150 -0
  171. package/src/test-utils/claim-store-rollback-cases.ts +50 -50
  172. package/src/test-utils/claim-store-seat-cases.ts +270 -0
  173. package/src/test-utils/claim-store-session-cases.ts +289 -0
  174. package/src/test-utils/collect-memory-limit-fills.ts +15 -0
  175. package/src/test-utils/compare-text.ts +8 -0
  176. package/src/test-utils/create-fake-backoff.ts +16 -0
  177. package/src/test-utils/create-fake-clock.ts +20 -0
  178. package/src/test-utils/create-fake-fact-sink.ts +74 -0
  179. package/src/test-utils/create-fake-forge.ts +117 -0
  180. package/src/test-utils/create-fake-worker.ts +303 -0
  181. package/src/test-utils/create-memory-claim-store.ts +73 -484
  182. package/src/test-utils/create-test-dispatch.ts +597 -0
  183. package/src/test-utils/forge-contract.ts +171 -0
  184. package/src/test-utils/is-same-held-seat.ts +11 -0
  185. package/src/test-utils/types.ts +14 -2
  186. package/src/test-utils/worker-contract.ts +192 -0
  187. package/src/timeouts.ts +17 -0
  188. package/src/types.ts +32 -0
  189. package/dist/test-utils/claim-store-pool-cases.d.ts +0 -2
  190. package/src/test-utils/claim-store-pool-cases.ts +0 -178
@@ -0,0 +1,270 @@
1
+ import { brandSchemas } from '@boxxy/model';
2
+ import type { StoreChange } from '@boxxy/model';
3
+ import { buildMockV1AttemptRow } from '@boxxy/model/test-utils/build-mock-v1-attempt-row';
4
+ import { buildMockV1ItemRow } from '@boxxy/model/test-utils/build-mock-v1-item-row';
5
+ import { buildMockClaimChange } from './build-mock-claim-change.ts';
6
+ import { buildMockFileItemChange } from './build-mock-file-item-change.ts';
7
+ import { buildMockSettleChange } from './build-mock-settle-change.ts';
8
+ import type { ClaimStoreCase } from './types.ts';
9
+
10
+ export const claimStoreSeatCases: readonly ClaimStoreCase[] = [
11
+ [
12
+ '#syncLimits stores the loaded limits by name, each with no seat held',
13
+ async (store) => {
14
+ await store.exclusive((transaction) =>
15
+ transaction.syncLimits(
16
+ [
17
+ { name: brandSchemas.limitName.parse('workers'), max: 7, counts: {} },
18
+ {
19
+ name: brandSchemas.limitName.parse('owner-design'),
20
+ max: 1,
21
+ counts: { designMode: ['attended'] },
22
+ },
23
+ ],
24
+ brandSchemas.instant.parse(500),
25
+ ),
26
+ );
27
+
28
+ const limits: unknown = await store.read((transaction) => transaction.listLimits());
29
+
30
+ return {
31
+ received: limits,
32
+ expected: [
33
+ { name: 'owner-design', max: 1, counts: { designMode: ['attended'] }, held: 0 },
34
+ { name: 'workers', max: 7, counts: {}, held: 0 },
35
+ ],
36
+ };
37
+ },
38
+ ],
39
+ [
40
+ '#syncLimits releases the seats of a limit the rules no longer name',
41
+ async (store) => {
42
+ const workers = brandSchemas.limitName.parse('workers');
43
+ const reviews = brandSchemas.limitName.parse('reviews');
44
+ const attempt = buildMockV1AttemptRow({ state: 'starting' });
45
+
46
+ await store.exclusive(async (transaction) => {
47
+ await transaction.syncLimits(
48
+ [
49
+ { name: workers, max: 2, counts: {} },
50
+ { name: reviews, max: 2, counts: {} },
51
+ ],
52
+ brandSchemas.instant.parse(500),
53
+ );
54
+
55
+ await transaction.applyChange(
56
+ buildMockFileItemChange(buildMockV1ItemRow({ id: attempt.item })),
57
+ );
58
+
59
+ await transaction.applyChange(buildMockClaimChange(attempt, [workers, reviews]));
60
+ });
61
+
62
+ await store.exclusive((transaction) =>
63
+ transaction.syncLimits(
64
+ [{ name: workers, max: 2, counts: {} }],
65
+ brandSchemas.instant.parse(6000),
66
+ ),
67
+ );
68
+
69
+ const state: unknown = await store.read(async (transaction) => {
70
+ const limits = await transaction.listLimits();
71
+ const seats = await transaction.listLimitSeats();
72
+
73
+ return {
74
+ limits: limits.map((limit) => [limit.name, limit.held]),
75
+ seats: seats.map((seat) => seat.limit),
76
+ };
77
+ });
78
+
79
+ return {
80
+ received: state,
81
+ expected: { limits: [['workers', 1]], seats: ['workers'] },
82
+ };
83
+ },
84
+ ],
85
+ [
86
+ '#syncLimits releases a seat whose limit no longer counts the attempt fields',
87
+ async (store) => {
88
+ const ownerDesign = brandSchemas.limitName.parse('owner-design');
89
+
90
+ const attempt = buildMockV1AttemptRow({
91
+ state: 'starting',
92
+ fields: { designMode: ['attended'] },
93
+ });
94
+
95
+ await store.exclusive(async (transaction) => {
96
+ await transaction.syncLimits(
97
+ [{ name: ownerDesign, max: 1, counts: { designMode: ['attended'] } }],
98
+ brandSchemas.instant.parse(500),
99
+ );
100
+
101
+ await transaction.applyChange(
102
+ buildMockFileItemChange(buildMockV1ItemRow({ id: attempt.item })),
103
+ );
104
+
105
+ await transaction.applyChange(buildMockClaimChange(attempt, [ownerDesign]));
106
+ });
107
+
108
+ await store.exclusive((transaction) =>
109
+ transaction.syncLimits(
110
+ [{ name: ownerDesign, max: 1, counts: { designMode: ['agent'] } }],
111
+ brandSchemas.instant.parse(6000),
112
+ ),
113
+ );
114
+
115
+ const seats = await store.read((transaction) => transaction.listLimitSeats());
116
+
117
+ return { received: seats, expected: [] };
118
+ },
119
+ ],
120
+ [
121
+ '#syncLimits gives a seated attempt a seat past max in a new limit that counts it',
122
+ async (store) => {
123
+ const workers = brandSchemas.limitName.parse('workers');
124
+ const builds = brandSchemas.limitName.parse('builds');
125
+ const first = buildMockV1AttemptRow({ state: 'starting' });
126
+ const second = buildMockV1AttemptRow({ state: 'starting' });
127
+
128
+ await store.exclusive(async (transaction) => {
129
+ await transaction.syncLimits(
130
+ [{ name: workers, max: 2, counts: {} }],
131
+ brandSchemas.instant.parse(500),
132
+ );
133
+
134
+ await transaction.applyChange(
135
+ buildMockFileItemChange(buildMockV1ItemRow({ id: first.item })),
136
+ );
137
+
138
+ await transaction.applyChange(
139
+ buildMockFileItemChange(buildMockV1ItemRow({ id: second.item })),
140
+ );
141
+
142
+ await transaction.applyChange(buildMockClaimChange(first, [workers]));
143
+ await transaction.applyChange(buildMockClaimChange(second, [workers]));
144
+ });
145
+
146
+ await store.exclusive((transaction) =>
147
+ transaction.syncLimits(
148
+ [
149
+ { name: workers, max: 2, counts: {} },
150
+ { name: builds, max: 1, counts: {} },
151
+ ],
152
+ brandSchemas.instant.parse(6000),
153
+ ),
154
+ );
155
+
156
+ const state: unknown = await store.read(async (transaction) => {
157
+ const limits = await transaction.listLimits();
158
+ const seats = await transaction.listLimitSeats();
159
+
160
+ return {
161
+ limits: limits.map((limit) => [limit.name, limit.held]),
162
+ overCap: seats
163
+ .filter((seat) => seat.limit === 'builds')
164
+ .map((seat) => seat.overCap)
165
+ .toSorted((left, right) => Number(left) - Number(right)),
166
+ };
167
+ });
168
+
169
+ return {
170
+ received: state,
171
+ expected: {
172
+ limits: [
173
+ ['builds', 2],
174
+ ['workers', 2],
175
+ ],
176
+ overCap: [false, true],
177
+ },
178
+ };
179
+ },
180
+ ],
181
+ [
182
+ '#syncLimits gives no seat to a sleeping attempt',
183
+ async (store) => {
184
+ const builds = brandSchemas.limitName.parse('builds');
185
+ const attempt = buildMockV1AttemptRow({ state: 'sleeping' });
186
+
187
+ await store.exclusive(async (transaction) => {
188
+ await transaction.applyChange(
189
+ buildMockFileItemChange(buildMockV1ItemRow({ id: attempt.item })),
190
+ );
191
+
192
+ await transaction.applyChange(buildMockClaimChange(attempt));
193
+
194
+ await transaction.syncLimits(
195
+ [{ name: builds, max: 1, counts: {} }],
196
+ brandSchemas.instant.parse(6000),
197
+ );
198
+ });
199
+
200
+ const seats = await store.read((transaction) => transaction.listLimitSeats());
201
+
202
+ return { received: seats, expected: [] };
203
+ },
204
+ ],
205
+ [
206
+ '#applyChange takes a seat over the cap past a full limit',
207
+ async (store) => {
208
+ const workers = brandSchemas.limitName.parse('workers');
209
+ const holder = buildMockV1AttemptRow({ state: 'starting' });
210
+ const waking = buildMockV1AttemptRow({ state: 'starting' });
211
+ const claim = buildMockClaimChange(waking);
212
+
213
+ const overCap: StoreChange = {
214
+ ...claim,
215
+ writes: [
216
+ {
217
+ op: 'take-seats',
218
+ take: { limits: [workers], attempt: waking.id, holdGen: 1, overCap: true },
219
+ },
220
+ ...claim.writes.filter((write) => write.op !== 'take-seats'),
221
+ ],
222
+ };
223
+
224
+ await store.exclusive(async (transaction) => {
225
+ await transaction.syncLimits(
226
+ [{ name: workers, max: 1, counts: {} }],
227
+ brandSchemas.instant.parse(500),
228
+ );
229
+
230
+ await transaction.applyChange(
231
+ buildMockFileItemChange(buildMockV1ItemRow({ id: holder.item })),
232
+ );
233
+
234
+ await transaction.applyChange(
235
+ buildMockFileItemChange(buildMockV1ItemRow({ id: waking.item })),
236
+ );
237
+
238
+ await transaction.applyChange(buildMockClaimChange(holder, [workers]));
239
+ });
240
+
241
+ const result = await store.exclusive((transaction) => transaction.applyChange(overCap));
242
+ const limits = await store.read((transaction) => transaction.listLimits());
243
+
244
+ return {
245
+ received: { result, held: limits.map((limit) => limit.held) },
246
+ expected: { result: { kind: 'applied' }, held: [2] },
247
+ };
248
+ },
249
+ ],
250
+ [
251
+ '#applyChange applies a seat release of an attempt that holds no seat',
252
+ async (store) => {
253
+ const attempt = buildMockV1AttemptRow({ state: 'starting' });
254
+
255
+ await store.exclusive(async (transaction) => {
256
+ await transaction.applyChange(
257
+ buildMockFileItemChange(buildMockV1ItemRow({ id: attempt.item })),
258
+ );
259
+
260
+ await transaction.applyChange(buildMockClaimChange(attempt));
261
+ });
262
+
263
+ const result = await store.exclusive((transaction) =>
264
+ transaction.applyChange(buildMockSettleChange({ attempt, state: 'failed' })),
265
+ );
266
+
267
+ return { received: result, expected: { kind: 'applied' } };
268
+ },
269
+ ],
270
+ ];
@@ -0,0 +1,289 @@
1
+ import { brandSchemas } from '@boxxy/model';
2
+ import { buildMockV1AttemptRow } from '@boxxy/model/test-utils/build-mock-v1-attempt-row';
3
+ import { buildMockV1ItemRow } from '@boxxy/model/test-utils/build-mock-v1-item-row';
4
+ import { buildMockClaimChange } from './build-mock-claim-change.ts';
5
+ import { buildMockFileItemChange } from './build-mock-file-item-change.ts';
6
+ import { buildMockSettleChange } from './build-mock-settle-change.ts';
7
+ import { buildMockStartChange } from './build-mock-start-change.ts';
8
+ import type { ClaimStoreCase } from './types.ts';
9
+
10
+ export const claimStoreSessionCases: readonly ClaimStoreCase[] = [
11
+ [
12
+ '#findSessionByCaller returns the session whose latest attempt holds the caller',
13
+ async (store) => {
14
+ const caller = { issuer: 'https://id.example.test', subject: 'worker-1' };
15
+ const attempt = buildMockV1AttemptRow({ state: 'starting' });
16
+
17
+ await store.exclusive(async (transaction) => {
18
+ await transaction.applyChange(
19
+ buildMockFileItemChange(buildMockV1ItemRow({ id: attempt.item })),
20
+ );
21
+
22
+ await transaction.applyChange(buildMockClaimChange(attempt));
23
+ await transaction.applyChange(buildMockStartChange(attempt, caller));
24
+ });
25
+
26
+ const found = await store.read((transaction) => transaction.findSessionByCaller(caller));
27
+
28
+ return { received: found, expected: attempt.atcSession };
29
+ },
30
+ ],
31
+ [
32
+ '#findSessionByCaller returns null when only an older attempt of the session holds the caller',
33
+ async (store) => {
34
+ const caller = { issuer: 'https://id.example.test', subject: 'worker-1' };
35
+ const item = buildMockV1ItemRow();
36
+
37
+ const earlier = buildMockV1AttemptRow({
38
+ item: item.id,
39
+ state: 'starting',
40
+ caller,
41
+ claimedAt: brandSchemas.instant.parse(1000),
42
+ });
43
+
44
+ const later = buildMockV1AttemptRow({
45
+ item: item.id,
46
+ state: 'starting',
47
+ number: 2,
48
+ leaseGen: 2,
49
+ atcSession: earlier.atcSession,
50
+ caller: { issuer: 'https://id.example.test', subject: 'worker-2' },
51
+ claimedAt: brandSchemas.instant.parse(4000),
52
+ });
53
+
54
+ await store.exclusive(async (transaction) => {
55
+ await transaction.applyChange(buildMockFileItemChange(item));
56
+ await transaction.applyChange(buildMockClaimChange(earlier));
57
+ await transaction.applyChange(buildMockSettleChange({ attempt: earlier, state: 'failed' }));
58
+ await transaction.applyChange(buildMockClaimChange(later));
59
+ });
60
+
61
+ const found = await store.read((transaction) => transaction.findSessionByCaller(caller));
62
+
63
+ return { received: found, expected: null };
64
+ },
65
+ ],
66
+ [
67
+ '#findSessionByCaller returns ambiguous when the latest attempts of two sessions hold it',
68
+ async (store) => {
69
+ const caller = { issuer: 'https://id.example.test', subject: 'worker-1' };
70
+ const first = buildMockV1AttemptRow({ state: 'starting', caller });
71
+ const second = buildMockV1AttemptRow({ state: 'starting', caller });
72
+
73
+ await store.exclusive(async (transaction) => {
74
+ for (const attempt of [first, second]) {
75
+ await transaction.applyChange(
76
+ buildMockFileItemChange(buildMockV1ItemRow({ id: attempt.item })),
77
+ );
78
+
79
+ await transaction.applyChange(buildMockClaimChange(attempt));
80
+ }
81
+ });
82
+
83
+ const found = await store.read((transaction) => transaction.findSessionByCaller(caller));
84
+
85
+ return { received: found, expected: 'ambiguous' };
86
+ },
87
+ ],
88
+ [
89
+ '#findSessionByCaller returns null for a caller no attempt holds',
90
+ async (store) => {
91
+ const found = await store.read((transaction) =>
92
+ transaction.findSessionByCaller({ issuer: 'https://id.example.test', subject: 'nobody' }),
93
+ );
94
+
95
+ return { received: found, expected: null };
96
+ },
97
+ ],
98
+ [
99
+ '#listKeptSessions returns the kept session of each item, oldest kept first',
100
+ async (store) => {
101
+ const later = {
102
+ atcSession: brandSchemas.atcSessionId.parse('atc-session-2'),
103
+ target: 'worker-host-a',
104
+ attempt: buildMockV1AttemptRow().id,
105
+ keptAt: brandSchemas.instant.parse(4000),
106
+ caller: null,
107
+ };
108
+
109
+ const earlier = {
110
+ atcSession: brandSchemas.atcSessionId.parse('atc-session-1'),
111
+ target: 'worker-host-b',
112
+ attempt: buildMockV1AttemptRow().id,
113
+ keptAt: brandSchemas.instant.parse(3000),
114
+ caller: { issuer: 'https://id.example.test', subject: 'worker-1' },
115
+ };
116
+
117
+ const laterItem = buildMockV1ItemRow({ keptSession: later });
118
+ const earlierItem = buildMockV1ItemRow({ keptSession: earlier });
119
+
120
+ await store.exclusive(async (transaction) => {
121
+ await transaction.applyChange(buildMockFileItemChange(laterItem));
122
+ await transaction.applyChange(buildMockFileItemChange(earlierItem));
123
+ await transaction.applyChange(buildMockFileItemChange(buildMockV1ItemRow()));
124
+ });
125
+
126
+ const kept: unknown = await store.read((transaction) => transaction.listKeptSessions());
127
+
128
+ return {
129
+ received: kept,
130
+ expected: [
131
+ { item: earlierItem.id, kept: earlier },
132
+ { item: laterItem.id, kept: later },
133
+ ],
134
+ };
135
+ },
136
+ ],
137
+ [
138
+ '#listSessionsToForget returns a succeeded session at once and a failed one past the cutoff',
139
+ async (store) => {
140
+ const succeeded = buildMockV1AttemptRow({
141
+ state: 'succeeded',
142
+ endedAt: brandSchemas.instant.parse(5000),
143
+ claimedAt: brandSchemas.instant.parse(1000),
144
+ });
145
+
146
+ const failedOld = buildMockV1AttemptRow({
147
+ state: 'failed',
148
+ endedAt: brandSchemas.instant.parse(2000),
149
+ claimedAt: brandSchemas.instant.parse(1100),
150
+ });
151
+
152
+ const failedNew = buildMockV1AttemptRow({
153
+ state: 'failed',
154
+ endedAt: brandSchemas.instant.parse(5000),
155
+ claimedAt: brandSchemas.instant.parse(1200),
156
+ });
157
+
158
+ const forgotten = buildMockV1AttemptRow({
159
+ state: 'succeeded',
160
+ endedAt: brandSchemas.instant.parse(2000),
161
+ sessionForgottenAt: brandSchemas.instant.parse(2500),
162
+ claimedAt: brandSchemas.instant.parse(1300),
163
+ });
164
+
165
+ const running = buildMockV1AttemptRow({ claimedAt: brandSchemas.instant.parse(1400) });
166
+
167
+ await store.exclusive(async (transaction) => {
168
+ for (const attempt of [succeeded, failedOld, failedNew, forgotten, running]) {
169
+ await transaction.applyChange(
170
+ buildMockFileItemChange(buildMockV1ItemRow({ id: attempt.item })),
171
+ );
172
+
173
+ await transaction.applyChange(buildMockClaimChange(attempt));
174
+ }
175
+ });
176
+
177
+ const listed = await store.read((transaction) =>
178
+ transaction.listSessionsToForget(brandSchemas.instant.parse(3000)),
179
+ );
180
+
181
+ return {
182
+ received: listed.map((attempt) => attempt.id),
183
+ expected: [succeeded.id, failedOld.id],
184
+ };
185
+ },
186
+ ],
187
+ [
188
+ '#listSessionsToForget leaves out a kept session, an unstopped stray and an older attempt',
189
+ async (store) => {
190
+ const keptSession = brandSchemas.atcSessionId.parse('atc-session-1');
191
+
192
+ const kept = buildMockV1AttemptRow({
193
+ state: 'succeeded',
194
+ atcSession: keptSession,
195
+ endedAt: brandSchemas.instant.parse(2000),
196
+ claimedAt: brandSchemas.instant.parse(1000),
197
+ });
198
+
199
+ const stray = buildMockV1AttemptRow({
200
+ state: 'failed',
201
+ endedAt: brandSchemas.instant.parse(2000),
202
+ strayedAt: brandSchemas.instant.parse(2000),
203
+ claimedAt: brandSchemas.instant.parse(1100),
204
+ });
205
+
206
+ const older = buildMockV1AttemptRow({
207
+ state: 'succeeded',
208
+ endedAt: brandSchemas.instant.parse(2000),
209
+ claimedAt: brandSchemas.instant.parse(1200),
210
+ });
211
+
212
+ const newer = buildMockV1AttemptRow({
213
+ item: older.item,
214
+ phase: brandSchemas.phaseName.parse('review'),
215
+ atcSession: older.atcSession,
216
+ claimedAt: brandSchemas.instant.parse(2500),
217
+ });
218
+
219
+ const keptItem = buildMockV1ItemRow({
220
+ id: kept.item,
221
+ keptSession: {
222
+ atcSession: keptSession,
223
+ target: 'worker-host-a',
224
+ attempt: kept.id,
225
+ keptAt: brandSchemas.instant.parse(2000),
226
+ caller: null,
227
+ },
228
+ });
229
+
230
+ await store.exclusive(async (transaction) => {
231
+ await transaction.applyChange(buildMockFileItemChange(keptItem));
232
+
233
+ await transaction.applyChange(
234
+ buildMockFileItemChange(buildMockV1ItemRow({ id: stray.item })),
235
+ );
236
+
237
+ await transaction.applyChange(
238
+ buildMockFileItemChange(buildMockV1ItemRow({ id: older.item })),
239
+ );
240
+
241
+ for (const attempt of [kept, stray, older, newer]) {
242
+ await transaction.applyChange(buildMockClaimChange(attempt));
243
+ }
244
+ });
245
+
246
+ const listed = await store.read((transaction) =>
247
+ transaction.listSessionsToForget(brandSchemas.instant.parse(3000)),
248
+ );
249
+
250
+ return { received: listed, expected: [] };
251
+ },
252
+ ],
253
+ [
254
+ '#listStrayAttempts returns the strays since a time whose session has not stopped',
255
+ async (store) => {
256
+ const recent = buildMockV1AttemptRow({
257
+ state: 'failed',
258
+ strayedAt: brandSchemas.instant.parse(5000),
259
+ });
260
+
261
+ const stopped = buildMockV1AttemptRow({
262
+ state: 'failed',
263
+ strayedAt: brandSchemas.instant.parse(5000),
264
+ sessionStoppedAt: brandSchemas.instant.parse(5500),
265
+ });
266
+
267
+ const old = buildMockV1AttemptRow({
268
+ state: 'failed',
269
+ strayedAt: brandSchemas.instant.parse(1000),
270
+ });
271
+
272
+ await store.exclusive(async (transaction) => {
273
+ for (const attempt of [recent, stopped, old]) {
274
+ await transaction.applyChange(
275
+ buildMockFileItemChange(buildMockV1ItemRow({ id: attempt.item })),
276
+ );
277
+
278
+ await transaction.applyChange(buildMockClaimChange(attempt));
279
+ }
280
+ });
281
+
282
+ const listed = await store.read((transaction) =>
283
+ transaction.listStrayAttempts(brandSchemas.instant.parse(4000)),
284
+ );
285
+
286
+ return { received: listed.map((attempt) => attempt.id), expected: [recent.id] };
287
+ },
288
+ ],
289
+ ];
@@ -0,0 +1,15 @@
1
+ import type { LimitFill } from '@boxxy/model';
2
+ import { compareText } from './compare-text.ts';
3
+ import type { MemoryTables } from './types.ts';
4
+
5
+ export function collectMemoryLimitFills(tables: MemoryTables): LimitFill[] {
6
+ const fills = tables.limits.map((limit) => {
7
+ const held = tables.limit_seats.filter(
8
+ (seat) => seat.limit === limit.name && seat.releasedAt === null,
9
+ );
10
+
11
+ return { ...limit, held: held.length };
12
+ });
13
+
14
+ return fills.toSorted((first, second) => compareText(first.name, second.name));
15
+ }
@@ -0,0 +1,8 @@
1
+ // SQLite's ORDER BY compares text byte by byte, and localeCompare doesn't
2
+ export function compareText(first: string, second: string): number {
3
+ if (first === second) {
4
+ return 0;
5
+ }
6
+
7
+ return first < second ? -1 : 1;
8
+ }
@@ -0,0 +1,16 @@
1
+ import type { CapacityBackoff, Instant, PoolName } from '@boxxy/model';
2
+
3
+ // The runner's memory of the pools in backoff. A second hold of a pool keeps the later end.
4
+ export function createFakeBackoff(): CapacityBackoff {
5
+ const until = new Map<PoolName, Instant>();
6
+
7
+ return {
8
+ hold: (pool, end) => {
9
+ const held = until.get(pool);
10
+ const later = held !== undefined && held > end ? held : end;
11
+
12
+ until.set(pool, later);
13
+ },
14
+ listHeld: (now) => [...until].filter(([, end]) => end > now).map(([pool]) => pool),
15
+ };
16
+ }
@@ -0,0 +1,20 @@
1
+ import type { Clock, Instant } from '@boxxy/model';
2
+ import { brandSchemas } from '@boxxy/model';
3
+
4
+ export interface FakeClock extends Clock {
5
+ advance: (byMs: number) => Instant;
6
+ }
7
+
8
+ // A clock that moves only when the test moves it.
9
+ export function createFakeClock(startMs = 1_000_000): FakeClock {
10
+ let nowMs = startMs;
11
+
12
+ return {
13
+ now: () => brandSchemas.instant.parse(nowMs),
14
+ advance: (byMs) => {
15
+ nowMs += byMs;
16
+
17
+ return brandSchemas.instant.parse(nowMs);
18
+ },
19
+ };
20
+ }
@@ -0,0 +1,74 @@
1
+ import type { FactId, FactSink, OutboxFact, SinkAnswer } from '@boxxy/model';
2
+
3
+ export interface FakeFactSink extends FactSink {
4
+ // every call, in order
5
+ readonly sends: readonly (readonly FactId[])[];
6
+
7
+ // one copy per fact ID, as a pepys consumer keeps them
8
+ readonly facts: ReadonlyMap<FactId, OutboxFact>;
9
+
10
+ // the answers of the next calls, one for each call; then the fake answers as ingest does
11
+ answerNext: (...answers: SinkAnswer[]) => void;
12
+
13
+ // any batch that holds the fact is refused whole, with the status
14
+ refuseFact: (id: FactId, status: 413 | 422) => void;
15
+ }
16
+
17
+ // A sink that stores a batch whole and answers `stored`, unless a scripted answer or a refused fact
18
+ // says otherwise. A scripted `stored` stores the batch; any other scripted answer stores nothing.
19
+ export function createFakeFactSink(): FakeFactSink {
20
+ const sends: FactId[][] = [];
21
+
22
+ const facts = new Map<FactId, OutboxFact>();
23
+ const refused = new Map<FactId, 413 | 422>();
24
+
25
+ const scripted: SinkAnswer[] = [];
26
+
27
+ const keepBatch = (batch: readonly OutboxFact[]): void => {
28
+ for (const fact of batch) {
29
+ if (!facts.has(fact.factId)) {
30
+ facts.set(fact.factId, fact);
31
+ }
32
+ }
33
+ };
34
+
35
+ return {
36
+ sends,
37
+ facts,
38
+ answerNext: (...answers) => {
39
+ scripted.push(...answers);
40
+ },
41
+ refuseFact: (id, status) => {
42
+ refused.set(id, status);
43
+ },
44
+ send: (batch) => {
45
+ sends.push(batch.map((fact) => fact.factId));
46
+
47
+ const next = scripted.shift();
48
+
49
+ if (next !== undefined) {
50
+ if (next.kind === 'stored') {
51
+ keepBatch(batch);
52
+ }
53
+
54
+ return Promise.resolve(next);
55
+ }
56
+
57
+ const status = batch
58
+ .map((fact) => refused.get(fact.factId))
59
+ .find((found) => found !== undefined);
60
+
61
+ if (status !== undefined) {
62
+ return Promise.resolve({
63
+ kind: 'refused-batch',
64
+ status,
65
+ reason: 'a fact of the batch is refused',
66
+ });
67
+ }
68
+
69
+ keepBatch(batch);
70
+
71
+ return Promise.resolve({ kind: 'stored' });
72
+ },
73
+ };
74
+ }