@boxxy/dispatch 0.0.0 → 0.1.1

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 (40) hide show
  1. package/README.md +6 -1
  2. package/dist/test-utils/build-mock-bind-change.d.ts +7 -0
  3. package/dist/test-utils/build-mock-claim-change.d.ts +2 -0
  4. package/dist/test-utils/build-mock-file-item-change.d.ts +2 -0
  5. package/dist/test-utils/build-mock-lever-change.d.ts +2 -0
  6. package/dist/test-utils/build-mock-link-change.d.ts +7 -0
  7. package/dist/test-utils/build-mock-pause-change.d.ts +2 -0
  8. package/dist/test-utils/build-mock-pull-request-change.d.ts +7 -0
  9. package/dist/test-utils/build-mock-settle-change.d.ts +7 -0
  10. package/dist/test-utils/build-mock-start-change.d.ts +2 -0
  11. package/dist/test-utils/claim-store-attempt-reader-cases.d.ts +2 -0
  12. package/dist/test-utils/claim-store-claim-cases.d.ts +2 -0
  13. package/dist/test-utils/claim-store-contract.d.ts +2 -0
  14. package/dist/test-utils/claim-store-exclusive-cases.d.ts +2 -0
  15. package/dist/test-utils/claim-store-guard-cases.d.ts +2 -0
  16. package/dist/test-utils/claim-store-item-reader-cases.d.ts +2 -0
  17. package/dist/test-utils/claim-store-pool-cases.d.ts +2 -0
  18. package/dist/test-utils/claim-store-rollback-cases.d.ts +2 -0
  19. package/dist/test-utils/create-memory-claim-store.d.ts +2 -0
  20. package/dist/test-utils/types.d.ts +9 -0
  21. package/package.json +8 -2
  22. package/src/test-utils/build-mock-bind-change.ts +36 -0
  23. package/src/test-utils/build-mock-claim-change.ts +42 -0
  24. package/src/test-utils/build-mock-file-item-change.ts +18 -0
  25. package/src/test-utils/build-mock-lever-change.ts +18 -0
  26. package/src/test-utils/build-mock-link-change.ts +40 -0
  27. package/src/test-utils/build-mock-pause-change.ts +27 -0
  28. package/src/test-utils/build-mock-pull-request-change.ts +53 -0
  29. package/src/test-utils/build-mock-settle-change.ts +58 -0
  30. package/src/test-utils/build-mock-start-change.ts +31 -0
  31. package/src/test-utils/claim-store-attempt-reader-cases.ts +327 -0
  32. package/src/test-utils/claim-store-claim-cases.ts +231 -0
  33. package/src/test-utils/claim-store-contract.ts +18 -0
  34. package/src/test-utils/claim-store-exclusive-cases.ts +197 -0
  35. package/src/test-utils/claim-store-guard-cases.ts +373 -0
  36. package/src/test-utils/claim-store-item-reader-cases.ts +257 -0
  37. package/src/test-utils/claim-store-pool-cases.ts +178 -0
  38. package/src/test-utils/claim-store-rollback-cases.ts +175 -0
  39. package/src/test-utils/create-memory-claim-store.ts +574 -0
  40. package/src/test-utils/types.ts +11 -0
@@ -0,0 +1,574 @@
1
+ import { isDeepStrictEqual } from 'node:util';
2
+ import { buildAppliedChange, buildItemViews, findSlotTake, toJSONValue } from '@boxxy/model';
3
+ import type {
4
+ ApplyResult,
5
+ AttemptRow,
6
+ ClaimStore,
7
+ EvidenceRow,
8
+ Instant,
9
+ ItemSetRows,
10
+ ItemView,
11
+ ItemWithLevers,
12
+ LinkRow,
13
+ MaxAttemptsByKind,
14
+ OutboxFact,
15
+ PoolName,
16
+ PullRequestRow,
17
+ RowWrite,
18
+ SlotTake,
19
+ StoreChange,
20
+ StoreReader,
21
+ StoreTx,
22
+ TableName,
23
+ Tables,
24
+ TakenSlot,
25
+ } from '@boxxy/model';
26
+ import invariant from 'tiny-invariant';
27
+ import { match } from 'ts-pattern';
28
+
29
+ export function createMemoryClaimStore(): ClaimStore {
30
+ let committed = buildEmptyTables();
31
+ let turn: Promise<void> = Promise.resolve();
32
+
33
+ return {
34
+ exclusive: async (work) => {
35
+ const previous = turn;
36
+ const next = Promise.withResolvers<void>();
37
+
38
+ turn = next.promise;
39
+
40
+ await previous;
41
+
42
+ try {
43
+ const draft = structuredClone(committed);
44
+
45
+ const result = await work(buildMemoryTx(draft));
46
+
47
+ committed = draft;
48
+
49
+ return result;
50
+ } finally {
51
+ next.resolve();
52
+ }
53
+ },
54
+ read: (work) => {
55
+ const snapshot = committed;
56
+
57
+ return work(buildMemoryReader(() => snapshot));
58
+ },
59
+ };
60
+ }
61
+
62
+ type MemoryTables = { [T in TableName]: Tables[T][] } & { outbox: OutboxFact[] };
63
+
64
+ function buildEmptyTables(): MemoryTables {
65
+ return {
66
+ items: [],
67
+ links: [],
68
+ levers: [],
69
+ attempts: [],
70
+ attempt_sessions: [],
71
+ leases: [],
72
+ pool_slots: [],
73
+ decisions: [],
74
+ evidence: [],
75
+ pull_requests: [],
76
+ runners: [],
77
+ outbox: [],
78
+ };
79
+ }
80
+
81
+ function buildMemoryTx(tables: MemoryTables): StoreTx {
82
+ return {
83
+ ...buildMemoryReader(() => tables),
84
+ applyChange: (change) => Promise.try(() => applyMemoryChange(tables, change)),
85
+ markFactsSent: (ids, at) => {
86
+ for (const fact of tables.outbox) {
87
+ if (ids.includes(fact.factId) && fact.sentAt === null) {
88
+ Object.assign(fact, { sentAt: at });
89
+ }
90
+ }
91
+
92
+ return Promise.resolve();
93
+ },
94
+ writeHeartbeat: (runner, at) => {
95
+ const row = tables.runners.find((candidate) => candidate.id === runner);
96
+
97
+ if (row === undefined) {
98
+ tables.runners.push({ id: runner, heartbeatAt: at, paused: false });
99
+ } else {
100
+ Object.assign(row, { heartbeatAt: at });
101
+ }
102
+
103
+ return Promise.resolve();
104
+ },
105
+ syncPoolSlots: (pools) => {
106
+ for (const pool of pools) {
107
+ for (let slot = 1; slot <= pool.slots; slot += 1) {
108
+ if (!tables.pool_slots.some((row) => row.pool === pool.name && row.slot === slot)) {
109
+ tables.pool_slots.push({ pool: pool.name, slot, holder: null, gen: 0, retired: false });
110
+ }
111
+ }
112
+ }
113
+
114
+ for (const row of tables.pool_slots) {
115
+ const slots = pools.find((pool) => pool.name === row.pool)?.slots ?? 0;
116
+
117
+ Object.assign(row, { retired: row.slot > slots });
118
+ }
119
+
120
+ return Promise.resolve();
121
+ },
122
+ markPullRequestChecked: (ref, at) => {
123
+ for (const row of tables.pull_requests) {
124
+ if (row.repo === ref.repo && row.number === ref.number) {
125
+ Object.assign(row, { checkedAt: at });
126
+ }
127
+ }
128
+
129
+ return Promise.resolve();
130
+ },
131
+ };
132
+ }
133
+
134
+ function buildMemoryReader(getTables: () => MemoryTables): StoreReader {
135
+ const buildViews = (now: Instant, maxAttemptsByKind: MaxAttemptsByKind): ItemWithLevers[] =>
136
+ buildItemViews(toItemSetRows(getTables()), now, maxAttemptsByKind);
137
+
138
+ return {
139
+ readSnapshot: (now, maxAttemptsByKind) => {
140
+ const open = buildViews(now, maxAttemptsByKind).filter((item) => isOpen(item));
141
+
142
+ return Promise.resolve({
143
+ storeVersion: getTables().outbox.length,
144
+ items: open.map((item) => toItemView(item)),
145
+ links: sortLinks(getTables().links.filter((link) => link.removedAt === null)),
146
+ levers: open
147
+ .flatMap((item) => item.levers)
148
+ .toSorted((first, second) => first.seq - second.seq),
149
+ freeSlots: collectFreeSlots(getTables()),
150
+ });
151
+ },
152
+ readItem: (id, now, maxAttemptsByKind) => {
153
+ const item = buildViews(now, maxAttemptsByKind).find((view) => view.id === id);
154
+ const view = item === undefined ? null : toItemView(item);
155
+
156
+ return Promise.resolve(view);
157
+ },
158
+ readItemDetail: (id, now, maxAttemptsByKind) => {
159
+ const tables = getTables();
160
+ const item = buildViews(now, maxAttemptsByKind).find((view) => view.id === id);
161
+
162
+ if (item === undefined) {
163
+ return Promise.resolve(null);
164
+ }
165
+
166
+ const attempts = sortAttempts(tables.attempts.filter((attempt) => attempt.item === id));
167
+
168
+ const attemptIds = new Set(attempts.map((attempt) => attempt.id));
169
+
170
+ const links = sortLinks(
171
+ tables.links.filter(
172
+ (link) => link.removedAt === null && (link.source === id || link.target === id),
173
+ ),
174
+ );
175
+
176
+ return Promise.resolve({
177
+ item,
178
+ attempts,
179
+ links,
180
+ evidence: sortEvidence(tables.evidence.filter((row) => attemptIds.has(row.attempt))),
181
+ pullRequests: sortPullRequests(
182
+ tables.pull_requests.filter((row) => attemptIds.has(row.attempt)),
183
+ ),
184
+ });
185
+ },
186
+ listItems: (filter, now, maxAttemptsByKind) =>
187
+ Promise.resolve(
188
+ buildViews(now, maxAttemptsByKind).filter(
189
+ (item) =>
190
+ (filter.state === undefined || item.derived.state === filter.state) &&
191
+ (filter.repo === undefined || item.repo === filter.repo),
192
+ ),
193
+ ),
194
+ listOpenIssues: (repo, now, maxAttemptsByKind) =>
195
+ Promise.resolve(
196
+ buildViews(now, maxAttemptsByKind)
197
+ .filter((item) => item.kind === 'core.issue' && item.repo === repo && isOpen(item))
198
+ .map((item) => toItemView(item)),
199
+ ),
200
+ readLastLeaseGen: (item, phase) => {
201
+ const gens = getTables()
202
+ .leases.filter((lease) => lease.item === item && lease.phase === phase)
203
+ .map((lease) => lease.gen);
204
+
205
+ return Promise.resolve(Math.max(0, ...gens));
206
+ },
207
+ readAttempt: (id) =>
208
+ Promise.resolve(getTables().attempts.find((attempt) => attempt.id === id) ?? null),
209
+ listEvidence: (attempt) =>
210
+ Promise.resolve(sortEvidence(getTables().evidence.filter((row) => row.attempt === attempt))),
211
+ listLiveAttempts: () =>
212
+ Promise.resolve(
213
+ sortAttempts(
214
+ getTables().attempts.filter((attempt) =>
215
+ ['starting', 'running', 'reconciling'].includes(attempt.state),
216
+ ),
217
+ ),
218
+ ),
219
+ listSucceededAttemptsWithoutPr: () => {
220
+ const tables = getTables();
221
+
222
+ const linked = new Set(tables.pull_requests.map((row) => row.attempt));
223
+
224
+ return Promise.resolve(
225
+ sortAttempts(
226
+ tables.attempts.filter(
227
+ (attempt) => attempt.state === 'succeeded' && !linked.has(attempt.id),
228
+ ),
229
+ ),
230
+ );
231
+ },
232
+ findAttemptBySession: (session) =>
233
+ Promise.resolve(
234
+ sortAttempts(getTables().attempts).find((attempt) => attempt.atcSession === session) ??
235
+ null,
236
+ ),
237
+ findAttemptByAgentSession: (session) =>
238
+ Promise.resolve(
239
+ getTables().attempt_sessions.find((row) => row.agentSession === session)?.attempt ?? null,
240
+ ),
241
+ listWatchedPullRequests: () =>
242
+ Promise.resolve(
243
+ sortPullRequests(getTables().pull_requests.filter((row) => row.state === 'open')),
244
+ ),
245
+ listPoolSlots: () =>
246
+ Promise.resolve(
247
+ getTables().pool_slots.toSorted(
248
+ (first, second) => compareText(first.pool, second.pool) || first.slot - second.slot,
249
+ ),
250
+ ),
251
+ readUnsentFacts: (limit) =>
252
+ Promise.resolve(
253
+ getTables()
254
+ .outbox.filter((fact) => fact.sentAt === null)
255
+ .slice(0, limit),
256
+ ),
257
+ readOutboxLag: () => {
258
+ const unsent = getTables().outbox.filter((fact) => fact.sentAt === null);
259
+ const oldest = unsent.toSorted((first, second) => first.at - second.at).at(0);
260
+
261
+ return Promise.resolve({ unsent: unsent.length, oldestUnsentAt: oldest?.at ?? null });
262
+ },
263
+ readRunner: (id) => Promise.resolve(getTables().runners.find((row) => row.id === id) ?? null),
264
+ };
265
+ }
266
+
267
+ function toItemSetRows(tables: MemoryTables): ItemSetRows {
268
+ return {
269
+ items: tables.items.toSorted(
270
+ (first, second) => first.filedAt - second.filedAt || compareText(first.id, second.id),
271
+ ),
272
+ links: tables.links.filter((link) => link.removedAt === null),
273
+ levers: tables.levers.toSorted((first, second) => first.seq - second.seq),
274
+ attempts: sortAttempts(tables.attempts),
275
+ liveLeases: tables.leases.filter((lease) => lease.releasedAt === null),
276
+ pullRequests: tables.pull_requests,
277
+ };
278
+ }
279
+
280
+ // SQLite's ORDER BY compares text byte by byte, and localeCompare doesn't
281
+ function compareText(first: string, second: string): number {
282
+ if (first === second) {
283
+ return 0;
284
+ }
285
+
286
+ return first < second ? -1 : 1;
287
+ }
288
+
289
+ function sortAttempts(attempts: readonly AttemptRow[]): AttemptRow[] {
290
+ return attempts.toSorted(
291
+ (first, second) => first.claimedAt - second.claimedAt || compareText(first.id, second.id),
292
+ );
293
+ }
294
+
295
+ function isOpen(item: ItemView): boolean {
296
+ return item.derived.state !== 'done' && item.derived.state !== 'archived';
297
+ }
298
+
299
+ function toItemView(item: ItemWithLevers): ItemView {
300
+ const { levers: _levers, ...view } = item;
301
+
302
+ return view;
303
+ }
304
+
305
+ function sortLinks(links: readonly LinkRow[]): LinkRow[] {
306
+ return links.toSorted(
307
+ (first, second) =>
308
+ first.addedAt - second.addedAt ||
309
+ compareText(first.source, second.source) ||
310
+ compareText(first.target, second.target),
311
+ );
312
+ }
313
+
314
+ function collectFreeSlots(tables: MemoryTables): Record<PoolName, number> {
315
+ const free: Record<PoolName, number> = {};
316
+
317
+ for (const row of tables.pool_slots) {
318
+ if (!row.retired) {
319
+ free[row.pool] = (free[row.pool] ?? 0) + (row.holder === null ? 1 : 0);
320
+ }
321
+ }
322
+
323
+ return free;
324
+ }
325
+
326
+ function sortEvidence(rows: readonly EvidenceRow[]): EvidenceRow[] {
327
+ return rows.toSorted(
328
+ (first, second) => first.at - second.at || compareText(first.factId, second.factId),
329
+ );
330
+ }
331
+
332
+ function sortPullRequests(rows: readonly PullRequestRow[]): PullRequestRow[] {
333
+ return rows.toSorted(
334
+ (first, second) => compareText(first.repo, second.repo) || first.number - second.number,
335
+ );
336
+ }
337
+
338
+ function applyMemoryChange(tables: MemoryTables, change: StoreChange): ApplyResult {
339
+ const before = structuredClone(tables);
340
+
341
+ try {
342
+ const result = writeMemoryChange(tables, change);
343
+
344
+ if (result.kind !== 'applied') {
345
+ Object.assign(tables, before);
346
+ }
347
+
348
+ return result;
349
+ } catch (error) {
350
+ Object.assign(tables, before);
351
+ throw error;
352
+ }
353
+ }
354
+
355
+ function writeMemoryChange(tables: MemoryTables, change: StoreChange): ApplyResult {
356
+ invariant(change.facts.length > 0, 'a change writes at least one fact');
357
+
358
+ if (!hasLiveUnder(tables, change.under)) {
359
+ return { kind: 'stale-generation' };
360
+ }
361
+
362
+ const take = findSlotTake(change.writes);
363
+ const taken = take === null ? null : takeMemorySlot(tables, take);
364
+
365
+ if (take !== null && taken === null) {
366
+ return { kind: 'no-free-slot' };
367
+ }
368
+
369
+ const lastLeverSeq = Math.max(0, ...tables.levers.map((lever) => lever.seq));
370
+ const applied = buildAppliedChange(change, { taken, lastLeverSeq });
371
+
372
+ for (const write of applied.writes) {
373
+ const refusal = applyMemoryWrite(tables, write);
374
+
375
+ if (refusal !== null) {
376
+ return refusal;
377
+ }
378
+ }
379
+
380
+ for (const fact of applied.facts) {
381
+ invariant(
382
+ !tables.outbox.some((row) => row.factId === fact.id),
383
+ `the outbox holds fact ${fact.id}`,
384
+ );
385
+
386
+ tables.outbox.push({
387
+ seq: tables.outbox.length + 1,
388
+ factId: fact.id,
389
+ type: fact.type,
390
+ entity: fact.entity,
391
+ actor: fact.actor,
392
+ at: change.at,
393
+ payload: toJSONValue(fact.payload),
394
+ sentAt: null,
395
+ });
396
+ }
397
+
398
+ return { kind: 'applied', slot: taken === null ? null : toSlotLease(taken) };
399
+ }
400
+
401
+ function hasLiveUnder(tables: MemoryTables, under: StoreChange['under']): boolean {
402
+ if (under === undefined) {
403
+ return true;
404
+ }
405
+
406
+ const hasLease = tables.leases.some(
407
+ (row) =>
408
+ row.item === under.item &&
409
+ row.phase === under.phase &&
410
+ row.gen === under.gen &&
411
+ row.releasedAt === null,
412
+ );
413
+
414
+ const slot = under.slot;
415
+
416
+ return (
417
+ hasLease &&
418
+ (slot === undefined ||
419
+ tables.pool_slots.some(
420
+ (row) =>
421
+ row.pool === slot.pool &&
422
+ row.slot === slot.slot &&
423
+ row.gen === slot.gen &&
424
+ row.holder !== null,
425
+ ))
426
+ );
427
+ }
428
+
429
+ function takeMemorySlot(tables: MemoryTables, take: SlotTake): TakenSlot | null {
430
+ const free = tables.pool_slots
431
+ .filter((row) => row.pool === take.pool && row.holder === null && !row.retired)
432
+ .toSorted((first, second) => first.slot - second.slot)
433
+ .at(0);
434
+
435
+ if (free === undefined) {
436
+ return null;
437
+ }
438
+
439
+ Object.assign(free, { holder: take.holder, gen: free.gen + 1 });
440
+
441
+ return { holder: take.holder, pool: take.pool, slot: free.slot, gen: free.gen };
442
+ }
443
+
444
+ function applyMemoryWrite(tables: MemoryTables, write: RowWrite): ApplyResult | null {
445
+ return match(write)
446
+ .with({ op: 'take-slot' }, () => null)
447
+ .with({ op: 'release-slot' }, (release) => {
448
+ const row = tables.pool_slots.find(
449
+ (candidate) =>
450
+ candidate.pool === release.pool &&
451
+ candidate.slot === release.slot &&
452
+ candidate.holder === release.holder,
453
+ );
454
+
455
+ if (row === undefined) {
456
+ return { kind: 'stale-generation' } as const;
457
+ }
458
+
459
+ Object.assign(row, { holder: null });
460
+
461
+ return null;
462
+ })
463
+ .with({ op: 'insert', table: 'leases' }, (insert) => {
464
+ const isHeld = tables.leases.some(
465
+ (row) =>
466
+ row.item === insert.row.item && row.phase === insert.row.phase && row.releasedAt === null,
467
+ );
468
+
469
+ if (isHeld) {
470
+ return { kind: 'lease-held' } as const;
471
+ }
472
+
473
+ insertMemoryRow(tables, insert.table, insert.row);
474
+
475
+ return null;
476
+ })
477
+ .with({ op: 'insert' }, (insert) => {
478
+ insertMemoryRow(tables, insert.table, insert.row);
479
+
480
+ return null;
481
+ })
482
+ .with({ op: 'update' }, (update) => {
483
+ const rows: object[] = tables[update.table];
484
+
485
+ const matched = rows.filter(
486
+ (row) => hasFields(row, update.key) && hasFields(row, update.expect),
487
+ );
488
+
489
+ if (matched.length === 0) {
490
+ const key = toJSONValue(update.key);
491
+
492
+ return { kind: 'guard-mismatch', table: update.table, key } as const;
493
+ }
494
+
495
+ for (const row of matched) {
496
+ Object.assign(row, update.set);
497
+
498
+ assertMemoryUnique(update.table, rows, row);
499
+ }
500
+
501
+ return null;
502
+ })
503
+ .exhaustive();
504
+ }
505
+
506
+ function insertMemoryRow(tables: MemoryTables, table: TableName, row: object): void {
507
+ const rows: object[] = tables[table];
508
+ const copy = structuredClone(row);
509
+
510
+ rows.push(copy);
511
+
512
+ assertMemoryUnique(table, rows, copy);
513
+ }
514
+
515
+ interface UniqueKey {
516
+ readonly fields: readonly string[];
517
+
518
+ // a partial unique index covers only the rows where this field is null
519
+ readonly live?: string;
520
+ }
521
+
522
+ const uniqueKeys: Readonly<Record<TableName, readonly UniqueKey[]>> = {
523
+ items: [{ fields: ['id'] }],
524
+ links: [
525
+ { fields: ['source', 'target', 'type', 'addedAt'] },
526
+ { fields: ['source', 'target', 'type'], live: 'removedAt' },
527
+ ],
528
+ levers: [{ fields: ['seq'] }],
529
+ attempts: [{ fields: ['id'] }, { fields: ['item', 'phase', 'number'] }],
530
+ attempt_sessions: [{ fields: ['attempt', 'agentSession'] }, { fields: ['agentSession'] }],
531
+ leases: [{ fields: ['item', 'phase', 'gen'] }, { fields: ['item', 'phase'], live: 'releasedAt' }],
532
+ pool_slots: [{ fields: ['pool', 'slot'] }],
533
+ decisions: [{ fields: ['id'] }],
534
+ evidence: [{ fields: ['factId'] }, { fields: ['attempt', 'kind', 'ref'] }],
535
+ pull_requests: [{ fields: ['repo', 'number'] }],
536
+ runners: [{ fields: ['id'] }],
537
+ };
538
+
539
+ function assertMemoryUnique(table: TableName, rows: readonly object[], row: object): void {
540
+ const fields = toFieldMap(row);
541
+
542
+ for (const key of uniqueKeys[table]) {
543
+ const hasClash = rows.some((other) => {
544
+ const otherFields = toFieldMap(other);
545
+
546
+ return (
547
+ other !== row &&
548
+ (key.live === undefined ||
549
+ (fields.get(key.live) === null && otherFields.get(key.live) === null)) &&
550
+ key.fields.every((field) => isDeepStrictEqual(otherFields.get(field), fields.get(field)))
551
+ );
552
+ });
553
+
554
+ if (hasClash) {
555
+ throw new Error(`UNIQUE constraint failed: ${table} (${key.fields.join(', ')})`);
556
+ }
557
+ }
558
+ }
559
+
560
+ function toFieldMap(row: object): Map<string, unknown> {
561
+ return new Map<string, unknown>(Object.entries(row));
562
+ }
563
+
564
+ function hasFields(row: object, fields: object): boolean {
565
+ const rowFields = toFieldMap(row);
566
+
567
+ return Object.entries(fields).every(([field, value]: [string, unknown]) =>
568
+ isDeepStrictEqual(rowFields.get(field), value),
569
+ );
570
+ }
571
+
572
+ function toSlotLease(taken: TakenSlot): { pool: PoolName; slot: number; gen: number } {
573
+ return { pool: taken.pool, slot: taken.slot, gen: taken.gen };
574
+ }
@@ -0,0 +1,11 @@
1
+ import type { ClaimStore } from '@boxxy/model';
2
+
3
+ export interface ClaimStoreOutcome {
4
+ readonly received: unknown;
5
+ readonly expected: unknown;
6
+ }
7
+
8
+ export type ClaimStoreCase = readonly [
9
+ title: string,
10
+ check: (store: ClaimStore) => Promise<ClaimStoreOutcome>,
11
+ ];