@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,173 @@
1
+ import { dedupeOpenItems } from '@boxxy/closeout';
2
+ import type { Effect, ItemId, ItemView, OpenItem, WorkFacts } from '@boxxy/model';
3
+ import { match } from 'ts-pattern';
4
+ import { effectHandlers } from './effect-handlers.ts';
5
+ import type { EffectContext, EffectPlan } from './types.ts';
6
+
7
+ type SettledFact = WorkFacts['boxxy.attempt.settled'];
8
+
9
+ interface SettlePlansInput {
10
+ readonly effects: readonly Effect[];
11
+ readonly ctx: EffectContext;
12
+
13
+ // the open `core.issue` items of the item's repo, for the dedupe of filed items
14
+ readonly openIssues: readonly ItemView[];
15
+
16
+ // the rules map the outcome to a `{ hold: reason }` edge
17
+ readonly held: boolean;
18
+ }
19
+
20
+ export interface SettlePlans {
21
+ readonly plans: readonly EffectPlan[];
22
+ readonly criteria: SettledFact['criteria'];
23
+ readonly matched: readonly ItemId[];
24
+ readonly skippedEffects: SettledFact['skippedEffects'];
25
+ }
26
+
27
+ // The plan of each effect of a settle, and the `settled` fact's fields that the plans decide.
28
+ // A hold withholds each effect that changes an existing item. Each plan sees what the plans
29
+ // before it write, so a repeated effect skips instead of breaking a key of the store.
30
+ export function buildSettlePlans(input: SettlePlansInput): SettlePlans {
31
+ const fileItems = input.effects.flatMap((effect) =>
32
+ effect.kind === 'file-item' ? [effect.item] : [],
33
+ );
34
+
35
+ const deduped = dedupeOpenItems(fileItems, input.openIssues);
36
+
37
+ const toFile = new Set(deduped.file);
38
+
39
+ // with no open issues to match, the dedupe keeps the first entry of each title
40
+ const firsts = new Set(dedupeOpenItems(fileItems, []).file);
41
+ const repeats = new Set(fileItems.filter((entry) => !firsts.has(entry)));
42
+
43
+ const effects = input.effects
44
+ .filter(
45
+ (effect) =>
46
+ effect.kind !== 'file-item' || toFile.has(effect.item) || repeats.has(effect.item),
47
+ )
48
+
49
+ // each item update expects the item open, so the close goes last
50
+ .toSorted(
51
+ (first, second) => Number(first.kind === 'close-item') - Number(second.kind === 'close-item'),
52
+ );
53
+
54
+ const plans = collectPlans(effects, input, repeats);
55
+
56
+ return {
57
+ plans,
58
+ criteria: collectCopiedEvidence(plans, input.ctx.item),
59
+ matched: deduped.matched,
60
+ skippedEffects: plans.flatMap((plan) =>
61
+ plan.kind === 'skip' ? [{ effect: plan.effect.kind, reason: plan.reason }] : [],
62
+ ),
63
+ };
64
+ }
65
+
66
+ function collectPlans(
67
+ effects: readonly Effect[],
68
+ input: SettlePlansInput,
69
+ repeats: ReadonlySet<OpenItem>,
70
+ ): EffectPlan[] {
71
+ const plans: EffectPlan[] = [];
72
+ let ctx = input.ctx;
73
+
74
+ for (const effect of effects) {
75
+ const reason = findSkipReason(effect, input.held, repeats);
76
+
77
+ const plan: EffectPlan =
78
+ reason === null ? planEffect(effect, ctx) : { kind: 'skip', effect, reason };
79
+
80
+ plans.push(plan);
81
+
82
+ ctx = buildPlannedContext(ctx, plan);
83
+ }
84
+
85
+ return plans;
86
+ }
87
+
88
+ function findSkipReason(
89
+ effect: Effect,
90
+ held: boolean,
91
+ repeats: ReadonlySet<OpenItem>,
92
+ ): string | null {
93
+ if (effect.kind === 'file-item' && repeats.has(effect.item)) {
94
+ return 'the close-out names this title already';
95
+ }
96
+
97
+ return held && isItemChange(effect) ? 'the item holds for the owner' : null;
98
+ }
99
+
100
+ const itemChanges: ReadonlySet<Effect['kind']> = new Set([
101
+ 'close-item',
102
+ 'add-relation',
103
+ 'set-kind',
104
+ 'set-field',
105
+ 'set-acceptance',
106
+ ]);
107
+
108
+ function isItemChange(effect: Effect): boolean {
109
+ return itemChanges.has(effect.kind);
110
+ }
111
+
112
+ // A handler that throws is a bug in the handler, not in the stored data, but the settle still
113
+ // lands: the effect is skipped and listed.
114
+ function planEffect(effect: Effect, ctx: EffectContext): EffectPlan {
115
+ try {
116
+ return pickHandler(effect)(ctx);
117
+ } catch (error) {
118
+ return { kind: 'skip', effect, reason: `the handler failed: ${String(error)}` };
119
+ }
120
+ }
121
+
122
+ function pickHandler(effect: Effect): (ctx: EffectContext) => EffectPlan {
123
+ return match(effect)
124
+ .returnType<(ctx: EffectContext) => EffectPlan>()
125
+ .with({ kind: 'file-item' }, (filed) => (ctx) => effectHandlers['file-item'](filed, ctx))
126
+ .with({ kind: 'close-item' }, (close) => (ctx) => effectHandlers['close-item'](close, ctx))
127
+ .with({ kind: 'add-relation' }, (add) => (ctx) => effectHandlers['add-relation'](add, ctx))
128
+ .with({ kind: 'set-kind' }, (set) => (ctx) => effectHandlers['set-kind'](set, ctx))
129
+ .with({ kind: 'set-field' }, (set) => (ctx) => effectHandlers['set-field'](set, ctx))
130
+ .with({ kind: 'set-acceptance' }, (set) => (ctx) => effectHandlers['set-acceptance'](set, ctx))
131
+ .with(
132
+ { kind: 'record-proposal' },
133
+ (record) => (ctx) => effectHandlers['record-proposal'](record, ctx),
134
+ )
135
+ .exhaustive();
136
+ }
137
+
138
+ // The context as the store will hold it after the plan's writes.
139
+ function buildPlannedContext(ctx: EffectContext, plan: EffectPlan): EffectContext {
140
+ if (plan.kind === 'skip') {
141
+ return ctx;
142
+ }
143
+
144
+ const added = plan.writes.flatMap((write) =>
145
+ write.op === 'insert' && write.table === 'relations' ? [write.row] : [],
146
+ );
147
+
148
+ return {
149
+ ...ctx,
150
+ item: plan.effect.kind === 'close-item' ? { ...ctx.item, closedAt: ctx.now } : ctx.item,
151
+ relations: [...ctx.relations, ...added],
152
+ };
153
+ }
154
+
155
+ // The evidence ids that an applied `set-acceptance` added to each criterion.
156
+ function collectCopiedEvidence(
157
+ plans: readonly EffectPlan[],
158
+ item: ItemView,
159
+ ): SettledFact['criteria'] {
160
+ return plans.flatMap((plan) => {
161
+ if (plan.kind !== 'apply' || plan.effect.kind !== 'set-acceptance') {
162
+ return [];
163
+ }
164
+
165
+ return plan.effect.criteria.flatMap((criterion) => {
166
+ const before = new Set(item.acceptance.find((old) => old.id === criterion.id)?.evidence);
167
+
168
+ const added = criterion.evidence.filter((evidence) => !before.has(evidence));
169
+
170
+ return added.length === 0 ? [] : [{ criterion: criterion.id, evidence: added }];
171
+ });
172
+ });
173
+ }
@@ -0,0 +1,156 @@
1
+ import type { ContentHash } from '@boxxy/content-hash';
2
+ import { contentHash } from '@boxxy/content-hash';
3
+ import type {
4
+ AttemptRow,
5
+ CloseOutAnswer,
6
+ CloseOutContext,
7
+ FactId,
8
+ LoadedRules,
9
+ StoreChange,
10
+ StoreTx,
11
+ } from '@boxxy/model';
12
+ import { closeOutSchema, toJSONValue } from '@boxxy/model';
13
+ import invariant from 'tiny-invariant';
14
+ import { buildAttemptChange } from '../attempts/build-attempt-change.ts';
15
+ import { pickCallAttempt } from '../attempts/pick-call-attempt.ts';
16
+ import type { CloseOutInput } from './types.ts';
17
+
18
+ // Records an attempt's close-out, in one store lock. The session check runs first, so the
19
+ // answer to a call for an attempt that is not current does not depend on the body.
20
+ export function closeOutAttempt(input: CloseOutInput): Promise<CloseOutAnswer> {
21
+ return input.store.exclusive(async (tx) => {
22
+ const latest = await tx.findAttemptBySession(input.call.session);
23
+
24
+ const found = pickCallAttempt(latest, input.call);
25
+
26
+ if (found.kind === 'refused') {
27
+ return found;
28
+ }
29
+
30
+ const attempt = found.attempt;
31
+
32
+ // the body only: the phase is the attempt's
33
+ const hash = buildBodyHash(input.body);
34
+
35
+ if (hash === null) {
36
+ return {
37
+ kind: 'rejected',
38
+ problems: [{ kind: 'schema', path: '', message: 'the close-out is not valid JSON text' }],
39
+ };
40
+ }
41
+
42
+ if (attempt.closeOutHash === hash && attempt.closeOutFact !== null) {
43
+ return { kind: 'recorded', attempt: attempt.id, factId: attempt.closeOutFact, repeat: true };
44
+ }
45
+
46
+ if (attempt.state === 'closing' && attempt.closing === 'closed-out') {
47
+ return { kind: 'refused', reason: 'already-closed-out' };
48
+ }
49
+
50
+ if (
51
+ attempt.state === 'closing' ||
52
+ attempt.state === 'succeeded' ||
53
+ attempt.state === 'failed'
54
+ ) {
55
+ return { kind: 'refused', reason: 'not-current' };
56
+ }
57
+
58
+ return writeCloseOut(tx, input, attempt, hash);
59
+ });
60
+ }
61
+
62
+ // null for a body that has no canonical JSON form, such as a string with a lone surrogate
63
+ function buildBodyHash(body: unknown): ContentHash | null {
64
+ try {
65
+ return contentHash(toJSONValue(body));
66
+ } catch {
67
+ return null;
68
+ }
69
+ }
70
+
71
+ async function writeCloseOut(
72
+ tx: StoreTx,
73
+ input: CloseOutInput,
74
+ attempt: AttemptRow,
75
+ hash: ContentHash,
76
+ ): Promise<CloseOutAnswer> {
77
+ const phase = input.rules.phases.get(attempt.phase);
78
+
79
+ // the phase left the rules after the claim: the attempt can no longer close out
80
+ if (phase === undefined) {
81
+ return { kind: 'refused', reason: 'not-current' };
82
+ }
83
+
84
+ const ctx = await readCloseOutContext(tx, input, attempt);
85
+
86
+ const parsed = phase.closeOut.parse(input.body, ctx);
87
+
88
+ if (parsed.kind === 'invalid') {
89
+ return { kind: 'rejected', problems: parsed.problems };
90
+ }
91
+
92
+ const closeOut = closeOutSchema.safeParse(parsed.closeOut);
93
+
94
+ invariant(closeOut.success, `the phase kind ${attempt.phase} gives a close-out of a known shape`);
95
+
96
+ const built = buildAttemptChange({
97
+ attempt,
98
+ event: { kind: 'closed-out', closeOut: closeOut.data, hash },
99
+ actor: `boxxy:attempt:${attempt.id}`,
100
+ now: input.now,
101
+ newId: input.newId,
102
+ });
103
+
104
+ if (built.kind === 'not-allowed') {
105
+ return { kind: 'refused', reason: 'not-current' };
106
+ }
107
+
108
+ const result = await tx.applyChange(built.change);
109
+
110
+ return result.kind === 'applied'
111
+ ? {
112
+ kind: 'recorded',
113
+ attempt: attempt.id,
114
+ factId: getClosedOutFact(built.change),
115
+ repeat: false,
116
+ }
117
+ : { kind: 'refused', reason: 'not-current' };
118
+ }
119
+
120
+ async function readCloseOutContext(
121
+ tx: StoreTx,
122
+ input: CloseOutInput,
123
+ attempt: AttemptRow,
124
+ ): Promise<CloseOutContext> {
125
+ const item = await tx.readItem(attempt.item, input.now, input.rules);
126
+
127
+ invariant(item !== null, `an attempt's item is stored: ${attempt.item}`);
128
+
129
+ const items = await tx.listItems({}, input.now, input.rules);
130
+
131
+ return {
132
+ item,
133
+ attempt: attempt.id,
134
+ evidence: await tx.listEvidence(attempt.id),
135
+ world: buildWorld(
136
+ input.rules,
137
+ items.map((each) => each.id),
138
+ ),
139
+ };
140
+ }
141
+
142
+ function buildWorld(rules: LoadedRules, items: readonly CloseOutContext['item']['id'][]) {
143
+ return {
144
+ items: new Set(items),
145
+ kinds: new Set(rules.kinds.keys()),
146
+ relationTypes: new Set(rules.relationTypes.keys()),
147
+ };
148
+ }
149
+
150
+ function getClosedOutFact(change: StoreChange): FactId {
151
+ const fact = change.facts.find((candidate) => candidate.type === 'boxxy.attempt.closed_out');
152
+
153
+ invariant(fact !== undefined, 'the closed-out move records its fact');
154
+
155
+ return fact.id;
156
+ }
@@ -0,0 +1,232 @@
1
+ import type {
2
+ AnyNewFact,
3
+ ItemId,
4
+ ItemRow,
5
+ RelationRow,
6
+ RelationTypeName,
7
+ RowWrite,
8
+ } from '@boxxy/model';
9
+ import { brandSchemas } from '@boxxy/model';
10
+ import type { EffectContext, EffectHandlers, EffectPlan } from './types.ts';
11
+
12
+ // The plan of each kind of effect. Pure: each handler reads only its context, and refuses with
13
+ // `skip` rather than throw, so a settle never fails on stored data.
14
+ export const effectHandlers: EffectHandlers = {
15
+ 'file-item': (effect, ctx) => {
16
+ const kind = brandSchemas.itemKindName.parse(effect.item.kind);
17
+
18
+ if (!ctx.rules.kinds.has(kind)) {
19
+ return { kind: 'skip', effect, reason: `the rules have no kind ${kind}` };
20
+ }
21
+
22
+ const row: ItemRow = {
23
+ id: brandSchemas.itemId.parse(ctx.newId()),
24
+ kind,
25
+ repo: ctx.item.repo,
26
+ title: effect.item.title,
27
+ outcome: effect.item.body,
28
+ acceptance: [],
29
+ fields: {},
30
+ labels: [],
31
+ foundInAttempt: ctx.attempt.id,
32
+ filedBy: ctx.actor,
33
+ filedAt: ctx.now,
34
+ closedAt: null,
35
+ closeReason: null,
36
+ keptSession: null,
37
+ };
38
+
39
+ const { keptSession: _keptSession, ...filed } = row;
40
+
41
+ return {
42
+ kind: 'apply',
43
+ effect,
44
+ writes: [{ op: 'insert', table: 'items', row }],
45
+ facts: [buildItemFact(ctx, row.id, { type: 'boxxy.item.filed', payload: filed })],
46
+ };
47
+ },
48
+ 'close-item': (effect, ctx) => {
49
+ const closed = findClosedReason(ctx);
50
+
51
+ if (closed !== null) {
52
+ return { kind: 'skip', effect, reason: closed };
53
+ }
54
+
55
+ // a relation that is live already, such as one an earlier effect adds, is not added again
56
+ const relation = effect.of === null ? null : buildRelationPlan(ctx, 'duplicate-of', effect.of);
57
+ const duplicateOf = typeof relation === 'string' ? null : relation;
58
+
59
+ return {
60
+ kind: 'apply',
61
+ effect,
62
+ writes: [
63
+ ...(duplicateOf?.writes ?? []),
64
+ buildItemUpdate(ctx, { closedAt: ctx.now, closeReason: effect.reason }),
65
+ ],
66
+ facts: [
67
+ ...(duplicateOf?.facts ?? []),
68
+ buildItemFact(ctx, ctx.item.id, {
69
+ type: 'boxxy.item.closed',
70
+ payload: { reason: effect.reason, byAttempt: ctx.attempt.id, ended: [] },
71
+ }),
72
+ ],
73
+ };
74
+ },
75
+ 'add-relation': (effect, ctx) => {
76
+ const closed = findClosedReason(ctx);
77
+
78
+ if (closed !== null) {
79
+ return { kind: 'skip', effect, reason: closed };
80
+ }
81
+
82
+ const plan = buildRelationPlan(ctx, effect.relation.type, effect.relation.target);
83
+
84
+ return typeof plan === 'string'
85
+ ? { kind: 'skip', effect, reason: plan }
86
+ : { kind: 'apply', effect, ...plan };
87
+ },
88
+ 'set-kind': (effect, ctx) => {
89
+ const closed = findClosedReason(ctx);
90
+
91
+ if (closed !== null) {
92
+ return { kind: 'skip', effect, reason: closed };
93
+ }
94
+
95
+ if (!ctx.rules.kinds.has(effect.itemKind)) {
96
+ return { kind: 'skip', effect, reason: `the rules have no kind ${effect.itemKind}` };
97
+ }
98
+
99
+ return buildEditPlan(effect, ctx, { kind: effect.itemKind });
100
+ },
101
+ 'set-field': (effect, ctx) => {
102
+ const closed = findClosedReason(ctx);
103
+
104
+ if (closed !== null) {
105
+ return { kind: 'skip', effect, reason: closed };
106
+ }
107
+
108
+ return buildEditPlan(effect, ctx, {
109
+ fields: { ...ctx.item.fields, [effect.field]: effect.value },
110
+ });
111
+ },
112
+ 'set-acceptance': (effect, ctx) => {
113
+ const closed = findClosedReason(ctx);
114
+
115
+ if (closed !== null) {
116
+ return { kind: 'skip', effect, reason: closed };
117
+ }
118
+
119
+ return buildEditPlan(effect, ctx, { acceptance: effect.criteria });
120
+ },
121
+
122
+ // a fact about the attempt only, so it records on a closed item too
123
+ 'record-proposal': (effect, ctx) => ({
124
+ kind: 'apply',
125
+ effect,
126
+ writes: [],
127
+ facts: [
128
+ buildItemFact(ctx, ctx.item.id, {
129
+ type: 'boxxy.proposal.recorded',
130
+ payload: { attempt: ctx.attempt.id, ...effect.friction },
131
+ }),
132
+ ],
133
+ }),
134
+ };
135
+
136
+ type FactBody = {
137
+ [T in AnyNewFact['type']]: Pick<Extract<AnyNewFact, { type: T }>, 'type' | 'payload'>;
138
+ }[AnyNewFact['type']];
139
+
140
+ function buildItemFact(ctx: EffectContext, item: ItemId, body: FactBody): AnyNewFact {
141
+ const fact: AnyNewFact = {
142
+ ...body,
143
+ id: brandSchemas.factId.parse(ctx.newId()),
144
+ entity: `boxxy:item:${item}`,
145
+ actor: ctx.actor,
146
+ };
147
+
148
+ return fact;
149
+ }
150
+
151
+ function findClosedReason(ctx: EffectContext): string | null {
152
+ return ctx.item.closedAt === null ? null : 'the item is closed';
153
+ }
154
+
155
+ function buildRelationPlan(
156
+ ctx: EffectContext,
157
+ typeName: string,
158
+ target: ItemId,
159
+ ): Pick<Extract<EffectPlan, { kind: 'apply' }>, 'writes' | 'facts'> | string {
160
+ const type = ctx.rules.relationTypes.get(brandSchemas.relationTypeName.parse(typeName))?.name;
161
+
162
+ if (type === undefined) {
163
+ return `the rules have no relation type ${typeName}`;
164
+ }
165
+
166
+ if (target === ctx.item.id) {
167
+ return 'an item has no relation to itself';
168
+ }
169
+
170
+ if (isLive(ctx.relations, target, type)) {
171
+ return 'the relation is live already';
172
+ }
173
+
174
+ const row: RelationRow = {
175
+ source: ctx.item.id,
176
+ target,
177
+ type,
178
+ addedBy: ctx.actor,
179
+ addedAt: ctx.now,
180
+ removedAt: null,
181
+ };
182
+
183
+ return {
184
+ writes: [{ op: 'insert', table: 'relations', row }],
185
+ facts: [
186
+ buildItemFact(ctx, ctx.item.id, {
187
+ type: 'boxxy.relation.added',
188
+ payload: { source: row.source, target, type },
189
+ }),
190
+ ],
191
+ };
192
+ }
193
+
194
+ function isLive(
195
+ relations: readonly RelationRow[],
196
+ target: ItemId,
197
+ type: RelationTypeName,
198
+ ): boolean {
199
+ return relations.some(
200
+ (relation) =>
201
+ relation.target === target && relation.type === type && relation.removedAt === null,
202
+ );
203
+ }
204
+
205
+ // Every update of the item expects it open, so a close of the same change goes last.
206
+ function buildItemUpdate(ctx: EffectContext, set: Partial<ItemRow>): RowWrite {
207
+ return {
208
+ op: 'update',
209
+ table: 'items',
210
+ key: { id: ctx.item.id },
211
+ expect: { closedAt: null },
212
+ set,
213
+ };
214
+ }
215
+
216
+ function buildEditPlan(
217
+ effect: Extract<EffectPlan, { kind: 'apply' }>['effect'],
218
+ ctx: EffectContext,
219
+ set: Pick<Partial<ItemRow>, 'kind' | 'fields' | 'acceptance'>,
220
+ ): EffectPlan {
221
+ return {
222
+ kind: 'apply',
223
+ effect,
224
+ writes: [buildItemUpdate(ctx, set)],
225
+ facts: [
226
+ buildItemFact(ctx, ctx.item.id, {
227
+ type: 'boxxy.item.edited',
228
+ payload: { ...set, byAttempt: ctx.attempt.id },
229
+ }),
230
+ ],
231
+ };
232
+ }
@@ -0,0 +1,80 @@
1
+ import type {
2
+ AttemptRow,
3
+ EngineOutcome,
4
+ ForcedFailure,
5
+ Instant,
6
+ ItemView,
7
+ LoadedRules,
8
+ OutcomeName,
9
+ Settlement,
10
+ } from '@boxxy/model';
11
+ import { brandSchemas } from '@boxxy/model';
12
+
13
+ interface SettlementInput {
14
+ readonly attempt: AttemptRow;
15
+ readonly item: ItemView;
16
+ readonly rules: LoadedRules;
17
+ readonly forced: ForcedFailure | null;
18
+ readonly now: Instant;
19
+ }
20
+
21
+ export interface PickedSettlement extends Omit<Settlement, 'outcome'> {
22
+ readonly outcome: OutcomeName | EngineOutcome;
23
+
24
+ // the name the phase code returned that its kind does not list; else null
25
+ readonly returnedOutcome: string | null;
26
+ }
27
+
28
+ // How the attempt ends. Never throws: phase code that throws, or answers an outcome its kind
29
+ // does not list, gives `unsettleable`. The returned name is never stored as the outcome, so a
30
+ // rollback to rules that map it cannot move the item on with no effect applied.
31
+ export function pickSettlement(input: SettlementInput): PickedSettlement {
32
+ const closeOut = input.attempt.closeOut;
33
+
34
+ if (closeOut === null || input.forced !== null) {
35
+ return {
36
+ state: 'failed',
37
+ failure: input.forced ?? 'closeout-missing',
38
+ outcome: 'lost',
39
+ returnedOutcome: null,
40
+ effects: [],
41
+ frictionDropped: 0,
42
+ };
43
+ }
44
+
45
+ const phase = input.rules.phases.get(input.attempt.phase);
46
+
47
+ if (phase === undefined) {
48
+ return buildUnsettleable(null);
49
+ }
50
+
51
+ try {
52
+ const settlement = phase.settle({
53
+ attempt: input.attempt,
54
+ item: input.item,
55
+ closeOut,
56
+ now: input.now,
57
+ });
58
+
59
+ return phase.outcomes.includes(settlement.outcome)
60
+ ? {
61
+ ...settlement,
62
+ outcome: brandSchemas.outcomeName.parse(settlement.outcome),
63
+ returnedOutcome: null,
64
+ }
65
+ : buildUnsettleable(settlement.outcome);
66
+ } catch {
67
+ return buildUnsettleable(null);
68
+ }
69
+ }
70
+
71
+ function buildUnsettleable(returnedOutcome: string | null): PickedSettlement {
72
+ return {
73
+ state: 'failed',
74
+ failure: 'closeout-unsettleable',
75
+ outcome: 'unsettleable',
76
+ returnedOutcome,
77
+ effects: [],
78
+ frictionDropped: 0,
79
+ };
80
+ }