@smthrs/journal 0.0.0-stage → 1.0.0-rc.4

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 (257) hide show
  1. package/CHANGELOG.md +149 -0
  2. package/LICENSE +21 -0
  3. package/README.md +206 -2
  4. package/dist/cjs/CallFact.d.ts +129 -0
  5. package/dist/cjs/CallFact.d.ts.map +1 -0
  6. package/dist/cjs/CallFact.js +106 -0
  7. package/dist/cjs/CallFact.js.map +7 -0
  8. package/dist/cjs/Consensus.d.ts +440 -0
  9. package/dist/cjs/Consensus.d.ts.map +1 -0
  10. package/dist/cjs/Consensus.js +251 -0
  11. package/dist/cjs/Consensus.js.map +7 -0
  12. package/dist/cjs/EngineEvent.d.ts +890 -0
  13. package/dist/cjs/EngineEvent.d.ts.map +1 -0
  14. package/dist/cjs/EngineEvent.js +383 -0
  15. package/dist/cjs/EngineEvent.js.map +7 -0
  16. package/dist/cjs/ExecutionFact.d.ts +216 -0
  17. package/dist/cjs/ExecutionFact.d.ts.map +1 -0
  18. package/dist/cjs/ExecutionFact.js +303 -0
  19. package/dist/cjs/ExecutionFact.js.map +7 -0
  20. package/dist/cjs/Journal.d.ts +617 -0
  21. package/dist/cjs/Journal.d.ts.map +1 -0
  22. package/dist/cjs/Journal.js +190 -0
  23. package/dist/cjs/Journal.js.map +7 -0
  24. package/dist/cjs/JournalEvent.d.ts +421 -0
  25. package/dist/cjs/JournalEvent.d.ts.map +1 -0
  26. package/dist/cjs/JournalEvent.js +281 -0
  27. package/dist/cjs/JournalEvent.js.map +7 -0
  28. package/dist/cjs/JournalGeneration.d.ts +34 -0
  29. package/dist/cjs/JournalGeneration.d.ts.map +1 -0
  30. package/dist/cjs/JournalGeneration.js +72 -0
  31. package/dist/cjs/JournalGeneration.js.map +7 -0
  32. package/dist/cjs/JournalMetrics.d.ts +72 -0
  33. package/dist/cjs/JournalMetrics.d.ts.map +1 -0
  34. package/dist/cjs/JournalMetrics.js +55 -0
  35. package/dist/cjs/JournalMetrics.js.map +7 -0
  36. package/dist/cjs/Migrations.d.ts +42 -0
  37. package/dist/cjs/Migrations.d.ts.map +1 -0
  38. package/dist/cjs/Migrations.js +58 -0
  39. package/dist/cjs/Migrations.js.map +7 -0
  40. package/dist/cjs/OwnerId.d.ts +40 -0
  41. package/dist/cjs/OwnerId.d.ts.map +1 -0
  42. package/dist/cjs/OwnerId.js +40 -0
  43. package/dist/cjs/OwnerId.js.map +7 -0
  44. package/dist/cjs/Projection.d.ts +29 -0
  45. package/dist/cjs/Projection.d.ts.map +1 -0
  46. package/dist/cjs/Projection.js +25 -0
  47. package/dist/cjs/Projection.js.map +7 -0
  48. package/dist/cjs/RedactedLogger.d.ts +121 -0
  49. package/dist/cjs/RedactedLogger.d.ts.map +1 -0
  50. package/dist/cjs/RedactedLogger.js +235 -0
  51. package/dist/cjs/RedactedLogger.js.map +7 -0
  52. package/dist/cjs/Redaction.d.ts +350 -0
  53. package/dist/cjs/Redaction.d.ts.map +1 -0
  54. package/dist/cjs/Redaction.js +928 -0
  55. package/dist/cjs/Redaction.js.map +7 -0
  56. package/dist/cjs/SqlConsensus.d.ts +44 -0
  57. package/dist/cjs/SqlConsensus.d.ts.map +1 -0
  58. package/dist/cjs/SqlConsensus.js +243 -0
  59. package/dist/cjs/SqlConsensus.js.map +7 -0
  60. package/dist/cjs/SqlJournal.d.ts +173 -0
  61. package/dist/cjs/SqlJournal.d.ts.map +1 -0
  62. package/dist/cjs/SqlJournal.js +1568 -0
  63. package/dist/cjs/SqlJournal.js.map +7 -0
  64. package/dist/cjs/StepFact.d.ts +66 -0
  65. package/dist/cjs/StepFact.d.ts.map +1 -0
  66. package/dist/cjs/StepFact.js +68 -0
  67. package/dist/cjs/StepFact.js.map +7 -0
  68. package/dist/cjs/index.d.ts +95 -0
  69. package/dist/cjs/index.d.ts.map +1 -0
  70. package/dist/cjs/index.js +63 -0
  71. package/dist/cjs/index.js.map +7 -0
  72. package/dist/cjs/internal/JournalInternalFault.d.ts +37 -0
  73. package/dist/cjs/internal/JournalInternalFault.d.ts.map +1 -0
  74. package/dist/cjs/internal/JournalInternalFault.js +43 -0
  75. package/dist/cjs/internal/JournalInternalFault.js.map +7 -0
  76. package/dist/cjs/migrations/0001_initial.d.ts +15 -0
  77. package/dist/cjs/migrations/0001_initial.d.ts.map +1 -0
  78. package/dist/cjs/migrations/0001_initial.js +54 -0
  79. package/dist/cjs/migrations/0001_initial.js.map +7 -0
  80. package/dist/cjs/migrations/0002_checkpoints.d.ts +27 -0
  81. package/dist/cjs/migrations/0002_checkpoints.d.ts.map +1 -0
  82. package/dist/cjs/migrations/0002_checkpoints.js +48 -0
  83. package/dist/cjs/migrations/0002_checkpoints.js.map +7 -0
  84. package/dist/cjs/migrations/0003_startup_index.d.ts +15 -0
  85. package/dist/cjs/migrations/0003_startup_index.d.ts.map +1 -0
  86. package/dist/cjs/migrations/0003_startup_index.js +40 -0
  87. package/dist/cjs/migrations/0003_startup_index.js.map +7 -0
  88. package/dist/cjs/migrations/0004_dedup.d.ts +15 -0
  89. package/dist/cjs/migrations/0004_dedup.d.ts.map +1 -0
  90. package/dist/cjs/migrations/0004_dedup.js +57 -0
  91. package/dist/cjs/migrations/0004_dedup.js.map +7 -0
  92. package/dist/cjs/migrations/0005_run_event_type.d.ts +15 -0
  93. package/dist/cjs/migrations/0005_run_event_type.d.ts.map +1 -0
  94. package/dist/cjs/migrations/0005_run_event_type.js +40 -0
  95. package/dist/cjs/migrations/0005_run_event_type.js.map +7 -0
  96. package/dist/cjs/migrations/0006_consensus.d.ts +22 -0
  97. package/dist/cjs/migrations/0006_consensus.d.ts.map +1 -0
  98. package/dist/cjs/migrations/0006_consensus.js +84 -0
  99. package/dist/cjs/migrations/0006_consensus.js.map +7 -0
  100. package/dist/cjs/package.json +1 -0
  101. package/dist/cjs/test/Notifying.d.ts +65 -0
  102. package/dist/cjs/test/Notifying.d.ts.map +1 -0
  103. package/dist/cjs/test/Notifying.js +62 -0
  104. package/dist/cjs/test/Notifying.js.map +7 -0
  105. package/dist/cjs/test/TestJournal.d.ts +43 -0
  106. package/dist/cjs/test/TestJournal.d.ts.map +1 -0
  107. package/dist/cjs/test/TestJournal.js +43 -0
  108. package/dist/cjs/test/TestJournal.js.map +7 -0
  109. package/dist/esm/CallFact.d.ts +129 -0
  110. package/dist/esm/CallFact.d.ts.map +1 -0
  111. package/dist/esm/CallFact.js +96 -0
  112. package/dist/esm/CallFact.js.map +1 -0
  113. package/dist/esm/Consensus.d.ts +440 -0
  114. package/dist/esm/Consensus.d.ts.map +1 -0
  115. package/dist/esm/Consensus.js +454 -0
  116. package/dist/esm/Consensus.js.map +1 -0
  117. package/dist/esm/EngineEvent.d.ts +890 -0
  118. package/dist/esm/EngineEvent.d.ts.map +1 -0
  119. package/dist/esm/EngineEvent.js +614 -0
  120. package/dist/esm/EngineEvent.js.map +1 -0
  121. package/dist/esm/ExecutionFact.d.ts +216 -0
  122. package/dist/esm/ExecutionFact.d.ts.map +1 -0
  123. package/dist/esm/ExecutionFact.js +374 -0
  124. package/dist/esm/ExecutionFact.js.map +1 -0
  125. package/dist/esm/Journal.d.ts +617 -0
  126. package/dist/esm/Journal.d.ts.map +1 -0
  127. package/dist/esm/Journal.js +323 -0
  128. package/dist/esm/Journal.js.map +1 -0
  129. package/dist/esm/JournalEvent.d.ts +421 -0
  130. package/dist/esm/JournalEvent.d.ts.map +1 -0
  131. package/dist/esm/JournalEvent.js +517 -0
  132. package/dist/esm/JournalEvent.js.map +1 -0
  133. package/dist/esm/JournalGeneration.d.ts +34 -0
  134. package/dist/esm/JournalGeneration.d.ts.map +1 -0
  135. package/dist/esm/JournalGeneration.js +65 -0
  136. package/dist/esm/JournalGeneration.js.map +1 -0
  137. package/dist/esm/JournalMetrics.d.ts +72 -0
  138. package/dist/esm/JournalMetrics.d.ts.map +1 -0
  139. package/dist/esm/JournalMetrics.js +79 -0
  140. package/dist/esm/JournalMetrics.js.map +1 -0
  141. package/dist/esm/Migrations.d.ts +42 -0
  142. package/dist/esm/Migrations.d.ts.map +1 -0
  143. package/dist/esm/Migrations.js +59 -0
  144. package/dist/esm/Migrations.js.map +1 -0
  145. package/dist/esm/OwnerId.d.ts +40 -0
  146. package/dist/esm/OwnerId.d.ts.map +1 -0
  147. package/dist/esm/OwnerId.js +33 -0
  148. package/dist/esm/OwnerId.js.map +1 -0
  149. package/dist/esm/Projection.d.ts +29 -0
  150. package/dist/esm/Projection.d.ts.map +1 -0
  151. package/dist/esm/Projection.js +13 -0
  152. package/dist/esm/Projection.js.map +1 -0
  153. package/dist/esm/RedactedLogger.d.ts +121 -0
  154. package/dist/esm/RedactedLogger.d.ts.map +1 -0
  155. package/dist/esm/RedactedLogger.js +445 -0
  156. package/dist/esm/RedactedLogger.js.map +1 -0
  157. package/dist/esm/Redaction.d.ts +350 -0
  158. package/dist/esm/Redaction.d.ts.map +1 -0
  159. package/dist/esm/Redaction.js +1584 -0
  160. package/dist/esm/Redaction.js.map +1 -0
  161. package/dist/esm/SqlConsensus.d.ts +44 -0
  162. package/dist/esm/SqlConsensus.d.ts.map +1 -0
  163. package/dist/esm/SqlConsensus.js +252 -0
  164. package/dist/esm/SqlConsensus.js.map +1 -0
  165. package/dist/esm/SqlJournal.d.ts +173 -0
  166. package/dist/esm/SqlJournal.d.ts.map +1 -0
  167. package/dist/esm/SqlJournal.js +1858 -0
  168. package/dist/esm/SqlJournal.js.map +1 -0
  169. package/dist/esm/StepFact.d.ts +66 -0
  170. package/dist/esm/StepFact.d.ts.map +1 -0
  171. package/dist/esm/StepFact.js +50 -0
  172. package/dist/esm/StepFact.js.map +1 -0
  173. package/dist/esm/index.d.ts +95 -0
  174. package/dist/esm/index.d.ts.map +1 -0
  175. package/dist/esm/index.js +95 -0
  176. package/dist/esm/index.js.map +1 -0
  177. package/dist/esm/internal/JournalInternalFault.d.ts +37 -0
  178. package/dist/esm/internal/JournalInternalFault.d.ts.map +1 -0
  179. package/dist/esm/internal/JournalInternalFault.js +28 -0
  180. package/dist/esm/internal/JournalInternalFault.js.map +1 -0
  181. package/dist/esm/migrations/0001_initial.d.ts +15 -0
  182. package/dist/esm/migrations/0001_initial.d.ts.map +1 -0
  183. package/dist/esm/migrations/0001_initial.js +32 -0
  184. package/dist/esm/migrations/0001_initial.js.map +1 -0
  185. package/dist/esm/migrations/0002_checkpoints.d.ts +27 -0
  186. package/dist/esm/migrations/0002_checkpoints.d.ts.map +1 -0
  187. package/dist/esm/migrations/0002_checkpoints.js +38 -0
  188. package/dist/esm/migrations/0002_checkpoints.js.map +1 -0
  189. package/dist/esm/migrations/0003_startup_index.d.ts +15 -0
  190. package/dist/esm/migrations/0003_startup_index.d.ts.map +1 -0
  191. package/dist/esm/migrations/0003_startup_index.js +18 -0
  192. package/dist/esm/migrations/0003_startup_index.js.map +1 -0
  193. package/dist/esm/migrations/0004_dedup.d.ts +15 -0
  194. package/dist/esm/migrations/0004_dedup.d.ts.map +1 -0
  195. package/dist/esm/migrations/0004_dedup.js +38 -0
  196. package/dist/esm/migrations/0004_dedup.js.map +1 -0
  197. package/dist/esm/migrations/0005_run_event_type.d.ts +15 -0
  198. package/dist/esm/migrations/0005_run_event_type.d.ts.map +1 -0
  199. package/dist/esm/migrations/0005_run_event_type.js +18 -0
  200. package/dist/esm/migrations/0005_run_event_type.js.map +1 -0
  201. package/dist/esm/migrations/0006_consensus.d.ts +22 -0
  202. package/dist/esm/migrations/0006_consensus.d.ts.map +1 -0
  203. package/dist/esm/migrations/0006_consensus.js +69 -0
  204. package/dist/esm/migrations/0006_consensus.js.map +1 -0
  205. package/dist/esm/test/Notifying.d.ts +65 -0
  206. package/dist/esm/test/Notifying.d.ts.map +1 -0
  207. package/dist/esm/test/Notifying.js +62 -0
  208. package/dist/esm/test/Notifying.js.map +1 -0
  209. package/dist/esm/test/TestJournal.d.ts +43 -0
  210. package/dist/esm/test/TestJournal.d.ts.map +1 -0
  211. package/dist/esm/test/TestJournal.js +27 -0
  212. package/dist/esm/test/TestJournal.js.map +1 -0
  213. package/docs/README.md +164 -0
  214. package/docs/api.md +639 -0
  215. package/docs/concepts/compaction.md +157 -0
  216. package/docs/concepts/idempotency.md +127 -0
  217. package/docs/concepts/owner-fence.md +124 -0
  218. package/docs/concepts/redaction.md +218 -0
  219. package/docs/concepts/state-event-authority.md +159 -0
  220. package/docs/concepts/two-channels.md +167 -0
  221. package/docs/guides/commit-state-and-entry.md +114 -0
  222. package/docs/guides/compact-a-run.md +134 -0
  223. package/docs/guides/fold-a-projection.md +106 -0
  224. package/docs/guides/read-a-run.md +139 -0
  225. package/docs/guides/redact-log-output.md +94 -0
  226. package/docs/guides/testing.md +124 -0
  227. package/docs/guides/write-lifecycle-events.md +110 -0
  228. package/docs/installation.md +133 -0
  229. package/docs/quickstart.md +143 -0
  230. package/docs/troubleshooting.md +214 -0
  231. package/package.json +263 -3
  232. package/src/CallFact.ts +117 -0
  233. package/src/Consensus.ts +683 -0
  234. package/src/EngineEvent.ts +823 -0
  235. package/src/ExecutionFact.ts +433 -0
  236. package/src/Journal.ts +688 -0
  237. package/src/JournalEvent.ts +654 -0
  238. package/src/JournalGeneration.ts +77 -0
  239. package/src/JournalMetrics.ts +89 -0
  240. package/src/Migrations.ts +62 -0
  241. package/src/OwnerId.ts +42 -0
  242. package/src/Projection.ts +31 -0
  243. package/src/RedactedLogger.ts +483 -0
  244. package/src/Redaction.ts +1784 -0
  245. package/src/SqlConsensus.ts +344 -0
  246. package/src/SqlJournal.ts +2732 -0
  247. package/src/StepFact.ts +69 -0
  248. package/src/index.ts +109 -0
  249. package/src/internal/JournalInternalFault.ts +40 -0
  250. package/src/migrations/0001_initial.ts +41 -0
  251. package/src/migrations/0002_checkpoints.ts +46 -0
  252. package/src/migrations/0003_startup_index.ts +19 -0
  253. package/src/migrations/0004_dedup.ts +43 -0
  254. package/src/migrations/0005_run_event_type.ts +19 -0
  255. package/src/migrations/0006_consensus.ts +83 -0
  256. package/src/test/Notifying.ts +101 -0
  257. package/src/test/TestJournal.ts +52 -0
@@ -0,0 +1,683 @@
1
+ /**
2
+ * The injectable consensus strategy arbitrating the journal's ownership rules.
3
+ *
4
+ * The journal owns the consensus rules — single writer per run, fenced and
5
+ * unfenced appends, commit-time admission, the generation fence, and steal
6
+ * only on staleness plus liveness evidence — and this service is how a deployment chooses *how* those rules are
7
+ * arbitrated: where the lease lives, how liveness is probed, and how the
8
+ * fence is checked at commit time. `Consensus` lives here beside `OwnerId`
9
+ * for the same reason `OwnerId` does: the journal is what it fences.
10
+ * `@smthrs/run-store`'s `Ownership` keeps its public API and rules but
11
+ * delegates arbitration to this strategy.
12
+ *
13
+ * Every operation that reasons about staleness takes the caller's clock
14
+ * reading (`nowMs`) rather than reading a clock itself: staleness (R5) and
15
+ * the monotonic lease stamp are defined against the arbitrating caller's
16
+ * reading, exactly as `RunStore`'s compare-and-swap operations always were,
17
+ * so the recorded lease never differs from the outcome the caller observed.
18
+ *
19
+ * Governing design: `packages/smithers/flows/journal/docs/concepts/owner-fence.md`.
20
+ * Rule constants and their provenance:
21
+ * `packages/smithers/flows/run-store/docs/concepts/leases.md`.
22
+ *
23
+ * Prior art: Effect `EventJournal` (`reference/effect` `unstable/eventlog`)
24
+ * resolves multi-writer by merge with conflict surfacing; driving a run is not
25
+ * mergeable, so this surface deviates to exclusive-writer admission. The
26
+ * fencing and generation semantics follow Temporal's shard `rangeID`
27
+ * (`reference/temporal/service/history/shard/context_impl.go`).
28
+ *
29
+ * @since 1.0.0
30
+ */
31
+
32
+ import * as Context from "effect/Context"
33
+ import * as Duration from "effect/Duration"
34
+ import * as Effect from "effect/Effect"
35
+ import * as Layer from "effect/Layer"
36
+ import * as Schema from "effect/Schema"
37
+ import { OwnerId } from "./OwnerId.ts"
38
+
39
+ /**
40
+ * How often the supervision loop pulses to renew the owner's lease.
41
+ *
42
+ * @since 1.0.0
43
+ * @category constants
44
+ */
45
+ export const heartbeatInterval: Duration.Duration = Duration.seconds(1)
46
+
47
+ /**
48
+ * How old a persisted heartbeat must be before a peer may steal the run.
49
+ * Every strategy judges R5 staleness against this one definition.
50
+ *
51
+ * @since 1.0.0
52
+ * @category constants
53
+ */
54
+ export const heartbeatStaleAfter: Duration.Duration = Duration.seconds(30)
55
+
56
+ /**
57
+ * How far the owner's wall clock may run behind a peer's before the lease
58
+ * reasoning stops holding.
59
+ *
60
+ * The owner stamps its heartbeat from *its* clock and a would-be stealer
61
+ * compares that stamp against *its own*, so the two hosts' clock offset is
62
+ * subtracted directly from the owner's real safety margin. This constant names
63
+ * that allowance instead of leaving it implicit in a "two ticks" arithmetic
64
+ * accident, and it is a budgeted allowance, not a guarantee: see
65
+ * {@link heartbeatWriteTolerance} for what happens once it is exceeded.
66
+ *
67
+ * @since 1.0.0
68
+ * @category constants
69
+ */
70
+ export const heartbeatSkewAllowance: Duration.Duration = Duration.seconds(10)
71
+
72
+ /**
73
+ * How long heartbeat writes may go unconfirmed before ownership must be reconfirmed.
74
+ *
75
+ * A peer judges staleness by the persisted heartbeat and may steal the run the
76
+ * instant it is {@link heartbeatStaleAfter} old, so an owner that tolerated
77
+ * write failures for exactly that long would still be executing side effects
78
+ * when the steal is admitted. The budget is therefore
79
+ * {@link heartbeatStaleAfter} minus {@link heartbeatSkewAllowance} (the peer's
80
+ * clock may already read that much later than the owner's) minus one
81
+ * {@link heartbeatInterval} (reserved for one bounded compare-and-swap
82
+ * reconfirmation before the owner interrupts).
83
+ *
84
+ * This bounds, but does not eliminate, overlap. Beyond
85
+ * {@link heartbeatSkewAllowance} of clock offset a peer can be admitted while
86
+ * the old owner is still running. *Durable* writes stay safe regardless — they
87
+ * are fenced by the strategy's commit-time admission, so the displaced owner's
88
+ * writes fail rather than corrupt. Non-durable external side effects (an HTTP
89
+ * call, a spawned process) can genuinely overlap. That is inherent to any
90
+ * wall-clock lease and is stated here rather than asserted away; a caller that
91
+ * cannot tolerate any overlap needs an external fencing token at the side
92
+ * effect itself, not a larger timeout.
93
+ *
94
+ * @since 1.0.0
95
+ * @category constants
96
+ */
97
+ export const heartbeatWriteTolerance: Duration.Duration = Duration.millis(
98
+ Duration.toMillis(heartbeatStaleAfter) -
99
+ Duration.toMillis(heartbeatSkewAllowance) -
100
+ Duration.toMillis(heartbeatInterval)
101
+ )
102
+
103
+ const staleAfterMs = Duration.toMillis(heartbeatStaleAfter)
104
+
105
+ /**
106
+ * Evidence that the owner in an exact run snapshot is no longer live.
107
+ *
108
+ * Defined here because R5 — steal requires staleness plus liveness evidence —
109
+ * is a consensus rule every strategy validates. `@smthrs/run-store`'s
110
+ * `Ownership` re-exports it alongside the probes that mint it.
111
+ *
112
+ * Two of the three kinds are collected outside the strategy:
113
+ * `same-host-pid-dead` is a local process probe, and
114
+ * `cross-host-unreachable-stale` is a reachability judgement the deployment
115
+ * makes. `lease-expired` is different — it asserts only that the persisted
116
+ * heartbeat is older than the staleness cutoff, which is the one claim a
117
+ * strategy can check for itself, and `steal` checks it: the takeover refuses
118
+ * any lease whose heartbeat is still inside the window. It is therefore
119
+ * accepted from a claimant on any host, while the other two stay bound to the
120
+ * host relation that makes them meaningful. The observation must carry the
121
+ * exact instant supplied to the consuming operation: `checkedAtMs` must equal
122
+ * that operation's `nowMs`. A caller that probes at T and calls at T+1 is
123
+ * refused and must build fresh evidence.
124
+ *
125
+ * @since 1.0.0
126
+ * @category models
127
+ */
128
+ export const LivenessEvidence = Schema.Struct({
129
+ expectedOwner: OwnerId,
130
+ checkedAtMs: Schema.Number,
131
+ kind: Schema.Literals(["same-host-pid-dead", "cross-host-unreachable-stale", "lease-expired"])
132
+ })
133
+
134
+ /**
135
+ * Evidence that the owner in an exact run snapshot is no longer live.
136
+ *
137
+ * @since 1.0.0
138
+ * @category models
139
+ */
140
+ export type LivenessEvidence = typeof LivenessEvidence.Type
141
+
142
+ /**
143
+ * Structural equality on the process identity tuple.
144
+ *
145
+ * @since 1.0.0
146
+ * @category predicates
147
+ */
148
+ export const sameOwner = (left: OwnerId, right: OwnerId): boolean =>
149
+ left.hostId === right.hostId && left.pid === right.pid && left.nonce === right.nonce
150
+
151
+ /**
152
+ * Validates liveness evidence against the owner it claims to disprove.
153
+ *
154
+ * The evidence must name the expected owner, carry the observer's current
155
+ * clock reading, and respect the host rule: a `same-host-pid-dead` probe is
156
+ * only meaningful from the same host, a `cross-host-unreachable-stale`
157
+ * observation only from a different one, and `lease-expired` is host-neutral
158
+ * because the strategy verifies the cutoff it names for itself.
159
+ *
160
+ * @since 1.0.0
161
+ * @category predicates
162
+ */
163
+ export const matchesEvidence = (
164
+ expectedOwner: OwnerId,
165
+ observer: OwnerId,
166
+ nowMs: number,
167
+ evidence: LivenessEvidence
168
+ ): boolean => {
169
+ if (!sameOwner(expectedOwner, evidence.expectedOwner) || evidence.checkedAtMs !== nowMs) return false
170
+ switch (evidence.kind) {
171
+ // A pid means nothing outside the host that owns the process namespace.
172
+ case "same-host-pid-dead":
173
+ return expectedOwner.hostId === observer.hostId
174
+ // Unreachability is what a peer host observes; on the owner's own host it
175
+ // would be an unprobed guess dressed as evidence.
176
+ case "cross-host-unreachable-stale":
177
+ return expectedOwner.hostId !== observer.hostId
178
+ // The lease is host-neutral, and the strategy verifies the same
179
+ // caller-clock cutoff named by this evidence.
180
+ case "lease-expired":
181
+ return true
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Why a strategy refused a claim, steal, or recovery.
187
+ *
188
+ * @since 1.0.0
189
+ * @category models
190
+ */
191
+ export const RejectionReason = Schema.Literals([
192
+ "already_claimed",
193
+ "owner_held",
194
+ "owner_live",
195
+ "evidence_invalid",
196
+ "claim_fresh",
197
+ "claim_changed",
198
+ "unavailable"
199
+ ])
200
+
201
+ /**
202
+ * Why a strategy refused a claim, steal, or recovery.
203
+ *
204
+ * @since 1.0.0
205
+ * @category models
206
+ */
207
+ export type RejectionReason = typeof RejectionReason.Type
208
+
209
+ /**
210
+ * A granted claim, carrying the grant timestamp the later activation must
211
+ * present (R4 — the generation fence).
212
+ *
213
+ * @since 1.0.0
214
+ * @category models
215
+ */
216
+ export const Claimed = Schema.TaggedStruct("Claimed", {
217
+ grantedAtMs: Schema.Number
218
+ })
219
+
220
+ /**
221
+ * A granted claim.
222
+ *
223
+ * @since 1.0.0
224
+ * @category models
225
+ */
226
+ export type Claimed = typeof Claimed.Type
227
+
228
+ /**
229
+ * A refused claim, steal, or recovery, with the strategy's typed reason.
230
+ *
231
+ * @since 1.0.0
232
+ * @category models
233
+ */
234
+ export const Rejected = Schema.TaggedStruct("Rejected", {
235
+ reason: RejectionReason
236
+ })
237
+
238
+ /**
239
+ * A refused claim, steal, or recovery.
240
+ *
241
+ * @since 1.0.0
242
+ * @category models
243
+ */
244
+ export type Rejected = typeof Rejected.Type
245
+
246
+ /**
247
+ * Outcome of `claim` and `steal`.
248
+ *
249
+ * @since 1.0.0
250
+ * @category models
251
+ */
252
+ export const ClaimOutcome = Schema.Union([Claimed, Rejected])
253
+
254
+ /**
255
+ * Outcome of `claim` and `steal`.
256
+ *
257
+ * @since 1.0.0
258
+ * @category models
259
+ */
260
+ export type ClaimOutcome = typeof ClaimOutcome.Type
261
+
262
+ /**
263
+ * A claim promoted to ownership.
264
+ *
265
+ * @since 1.0.0
266
+ * @category models
267
+ */
268
+ export const Activated = Schema.TaggedStruct("Activated", {})
269
+
270
+ /**
271
+ * A claim promoted to ownership.
272
+ *
273
+ * @since 1.0.0
274
+ * @category models
275
+ */
276
+ export type Activated = typeof Activated.Type
277
+
278
+ /**
279
+ * The claim or lease the caller presented no longer exists.
280
+ *
281
+ * @since 1.0.0
282
+ * @category models
283
+ */
284
+ export const Lost = Schema.TaggedStruct("Lost", {})
285
+
286
+ /**
287
+ * The claim or lease the caller presented no longer exists.
288
+ *
289
+ * @since 1.0.0
290
+ * @category models
291
+ */
292
+ export type Lost = typeof Lost.Type
293
+
294
+ /**
295
+ * Outcome of `activate`.
296
+ *
297
+ * @since 1.0.0
298
+ * @category models
299
+ */
300
+ export const ActivateOutcome = Schema.Union([Activated, Lost])
301
+
302
+ /**
303
+ * Outcome of `activate`.
304
+ *
305
+ * @since 1.0.0
306
+ * @category models
307
+ */
308
+ export type ActivateOutcome = typeof ActivateOutcome.Type
309
+
310
+ /**
311
+ * A renewed lease, carrying the monotonic heartbeat stamp the strategy
312
+ * recorded — the maximum of the stored stamp and the caller's reading, so a
313
+ * late pulse never moves the lease backwards.
314
+ *
315
+ * @since 1.0.0
316
+ * @category models
317
+ */
318
+ export const Renewed = Schema.TaggedStruct("Renewed", {
319
+ heartbeatAtMs: Schema.Number
320
+ })
321
+
322
+ /**
323
+ * A renewed lease.
324
+ *
325
+ * @since 1.0.0
326
+ * @category models
327
+ */
328
+ export type Renewed = typeof Renewed.Type
329
+
330
+ /**
331
+ * Outcome of `heartbeat` and `reconfirm`.
332
+ *
333
+ * @since 1.0.0
334
+ * @category models
335
+ */
336
+ export const HeartbeatOutcome = Schema.Union([Renewed, Lost])
337
+
338
+ /**
339
+ * Outcome of `heartbeat` and `reconfirm`.
340
+ *
341
+ * @since 1.0.0
342
+ * @category models
343
+ */
344
+ export type HeartbeatOutcome = typeof HeartbeatOutcome.Type
345
+
346
+ /**
347
+ * A stale claim cleared after its claimant was proven dead.
348
+ *
349
+ * @since 1.0.0
350
+ * @category models
351
+ */
352
+ export const Recovered = Schema.TaggedStruct("Recovered", {})
353
+
354
+ /**
355
+ * A stale claim cleared after its claimant was proven dead.
356
+ *
357
+ * @since 1.0.0
358
+ * @category models
359
+ */
360
+ export type Recovered = typeof Recovered.Type
361
+
362
+ /**
363
+ * Outcome of `recover`.
364
+ *
365
+ * @since 1.0.0
366
+ * @category models
367
+ */
368
+ export const RecoverOutcome = Schema.Union([Recovered, Rejected])
369
+
370
+ /**
371
+ * Outcome of `recover`.
372
+ *
373
+ * @since 1.0.0
374
+ * @category models
375
+ */
376
+ export type RecoverOutcome = typeof RecoverOutcome.Type
377
+
378
+ /**
379
+ * Failure raised by a consensus strategy.
380
+ *
381
+ * `fence_lost` is `guard`'s refusal: the supplied owner no longer holds the
382
+ * run, so the fenced write it joins must not commit. `persistence_failed`
383
+ * wraps a strategy's storage failure; the original cause is preserved so the
384
+ * durable writer's retry classification still sees a transient conflict
385
+ * through the wrapper.
386
+ *
387
+ * @since 1.0.0
388
+ * @category errors
389
+ */
390
+ export class ConsensusError extends Schema.TaggedError<ConsensusError>()("@smthrs/journal/ConsensusError", {
391
+ code: Schema.Literals(["fence_lost", "persistence_failed"]),
392
+ message: Schema.String,
393
+ cause: Schema.optional(Schema.Unknown)
394
+ }) {}
395
+
396
+ /**
397
+ * Consensus operations.
398
+ *
399
+ * The rules being arbitrated are strategy-independent; a strategy
400
+ * chooses where the lease lives and how the fence is checked at commit time.
401
+ * The claim lifecycle is two-phase, exactly as `RunStore`'s always was:
402
+ * `claim` (or `steal`, its evidence-gated form) reserves the run and returns
403
+ * the grant timestamp, `activate` presents that grant to take ownership, and
404
+ * `release` clears whichever of the caller's claim or ownership still stands.
405
+ * `recover` clears a *third party's* stale claim after its claimant was proven
406
+ * dead — the relocated `recoverClaim` mechanic. `guard` is the commit-time
407
+ * admission check (R3) `Journal.emitDurable`, `checkpoint`, and `compact`
408
+ * join: it succeeds while the owner holds the run and fails with `fence_lost`
409
+ * otherwise.
410
+ *
411
+ * Heartbeats and lease state are the strategy's private evidence and never
412
+ * enter the journal.
413
+ *
414
+ * @since 1.0.0
415
+ * @category models
416
+ */
417
+ export interface Service {
418
+ readonly claim: (
419
+ runId: string,
420
+ claimant: OwnerId,
421
+ nowMs: number
422
+ ) => Effect.Effect<ClaimOutcome, ConsensusError>
423
+ readonly activate: (
424
+ runId: string,
425
+ owner: OwnerId,
426
+ grantedAtMs: number,
427
+ nowMs: number
428
+ ) => Effect.Effect<ActivateOutcome, ConsensusError>
429
+ readonly heartbeat: (
430
+ runId: string,
431
+ owner: OwnerId,
432
+ nowMs: number
433
+ ) => Effect.Effect<HeartbeatOutcome, ConsensusError>
434
+ /** Renews only while this owner holds the lease and no takeover claim is pending. */
435
+ readonly reconfirm: (
436
+ runId: string,
437
+ owner: OwnerId,
438
+ nowMs: number
439
+ ) => Effect.Effect<HeartbeatOutcome, ConsensusError>
440
+ readonly release: (runId: string, owner: OwnerId) => Effect.Effect<void, ConsensusError>
441
+ readonly steal: (
442
+ runId: string,
443
+ claimant: OwnerId,
444
+ nowMs: number,
445
+ evidence: LivenessEvidence
446
+ ) => Effect.Effect<ClaimOutcome, ConsensusError>
447
+ readonly recover: (
448
+ runId: string,
449
+ staleClaimant: OwnerId,
450
+ grantedAtMs: number,
451
+ observer: OwnerId,
452
+ nowMs: number,
453
+ evidence: LivenessEvidence
454
+ ) => Effect.Effect<RecoverOutcome, ConsensusError>
455
+ readonly guard: (runId: string, owner: OwnerId) => Effect.Effect<void, ConsensusError>
456
+ }
457
+
458
+ /**
459
+ * Context service for the injected consensus strategy.
460
+ *
461
+ * @since 1.0.0
462
+ * @category services
463
+ */
464
+ export class Consensus extends Context.Service<Consensus, Service>()("@smthrs/journal/Consensus") {}
465
+
466
+ /**
467
+ * Constructs a consensus service from an implementation.
468
+ *
469
+ * @since 1.0.0
470
+ * @category constructors
471
+ */
472
+ export const make = (implementation: Service): Service => Consensus.of(implementation)
473
+
474
+ const rejected = (reason: RejectionReason): Rejected => ({ _tag: "Rejected", reason })
475
+ const claimed = (grantedAtMs: number): Claimed => ({ _tag: "Claimed", grantedAtMs })
476
+ const lost: Lost = { _tag: "Lost" }
477
+ const activated: Activated = { _tag: "Activated" }
478
+ const recovered: Recovered = { _tag: "Recovered" }
479
+
480
+ /**
481
+ * The refusal `guard` raises for an owner that no longer holds the run.
482
+ *
483
+ * @since 1.0.0
484
+ * @category errors
485
+ */
486
+ export const fenceLost = (runId: string, owner: OwnerId): ConsensusError =>
487
+ new ConsensusError({
488
+ code: "fence_lost",
489
+ message: `run ${runId} is no longer owned by ${owner.hostId}:${owner.pid}:${owner.nonce}`
490
+ })
491
+
492
+ /**
493
+ * Constructs a strategy that arbitrates nothing: claims are refused, leases
494
+ * are never held, and every fenced write is rejected.
495
+ *
496
+ * @since 1.0.0
497
+ * @category constructors
498
+ */
499
+ export const makeNoop = (overrides: Partial<Service> = {}): Service =>
500
+ Consensus.of({
501
+ claim: Effect.fn("Consensus.claim")(() => Effect.succeed(rejected("unavailable"))),
502
+ activate: Effect.fn("Consensus.activate")(() => Effect.succeed(lost)),
503
+ heartbeat: Effect.fn("Consensus.heartbeat")(() => Effect.succeed(lost)),
504
+ reconfirm: Effect.fn("Consensus.reconfirm")(() => Effect.succeed(lost)),
505
+ release: Effect.fn("Consensus.release")(() => Effect.void),
506
+ steal: Effect.fn("Consensus.steal")(() => Effect.succeed(rejected("unavailable"))),
507
+ recover: Effect.fn("Consensus.recover")(() => Effect.succeed(rejected("unavailable"))),
508
+ guard: Effect.fn("Consensus.guard")((runId: string, owner: OwnerId) => Effect.fail(fenceLost(runId, owner))),
509
+ ...overrides
510
+ })
511
+
512
+ /**
513
+ * Provides the refusing strategy stub.
514
+ *
515
+ * @since 1.0.0
516
+ * @category layers
517
+ */
518
+ export const layerNoop = (overrides: Partial<Service> = {}): Layer.Layer<Consensus> =>
519
+ Layer.succeed(Consensus)(makeNoop(overrides))
520
+
521
+ interface LocalLease {
522
+ owner: { readonly id: OwnerId; readonly grantedAtMs: number; heartbeatAtMs: number } | undefined
523
+ claim: { readonly id: OwnerId; readonly claimedAtMs: number } | undefined
524
+ }
525
+
526
+ /**
527
+ * Constructs the in-memory single-process strategy.
528
+ *
529
+ * @since 1.0.0
530
+ * @category constructors
531
+ */
532
+ export const makeLocal: Effect.Effect<Service> = Effect.sync(() => {
533
+ const leases = new Map<string, LocalLease>()
534
+
535
+ const leaseOf = (runId: string): LocalLease => {
536
+ const existing = leases.get(runId)
537
+ if (existing !== undefined) return existing
538
+ const fresh: LocalLease = { owner: undefined, claim: undefined }
539
+ leases.set(runId, fresh)
540
+ return fresh
541
+ }
542
+
543
+ const prune = (runId: string, lease: LocalLease): void => {
544
+ if (lease.owner === undefined && lease.claim === undefined) {
545
+ leases.delete(runId)
546
+ }
547
+ }
548
+
549
+ return Consensus.of({
550
+ claim: Effect.fn("Consensus.claim")((runId: string, claimant: OwnerId, nowMs: number) =>
551
+ Effect.sync(() => {
552
+ const lease = leaseOf(runId)
553
+ if (lease.claim !== undefined) return rejected("already_claimed")
554
+ if (lease.owner !== undefined) return rejected("owner_held")
555
+ lease.claim = { id: claimant, claimedAtMs: nowMs }
556
+ return claimed(nowMs)
557
+ })
558
+ ),
559
+ activate: Effect.fn("Consensus.activate")((
560
+ runId: string,
561
+ owner: OwnerId,
562
+ grantedAtMs: number,
563
+ nowMs: number
564
+ ) =>
565
+ Effect.sync(() => {
566
+ const lease = leaseOf(runId)
567
+ if (
568
+ lease.claim === undefined ||
569
+ !sameOwner(lease.claim.id, owner) ||
570
+ lease.claim.claimedAtMs !== grantedAtMs
571
+ ) {
572
+ prune(runId, lease)
573
+ return lost
574
+ }
575
+ lease.claim = undefined
576
+ lease.owner = { id: owner, grantedAtMs, heartbeatAtMs: nowMs }
577
+ return activated
578
+ })
579
+ ),
580
+ heartbeat: Effect.fn("Consensus.heartbeat")((runId: string, owner: OwnerId, nowMs: number) =>
581
+ Effect.sync(() => {
582
+ const lease = leases.get(runId)
583
+ if (lease?.owner === undefined || !sameOwner(lease.owner.id, owner)) return lost
584
+ lease.owner.heartbeatAtMs = Math.max(lease.owner.heartbeatAtMs, nowMs)
585
+ return ({ _tag: "Renewed", heartbeatAtMs: lease.owner.heartbeatAtMs } satisfies Renewed)
586
+ })
587
+ ),
588
+ reconfirm: Effect.fn("Consensus.reconfirm")((runId: string, owner: OwnerId, nowMs: number) =>
589
+ Effect.sync(() => {
590
+ const lease = leases.get(runId)
591
+ if (lease?.owner === undefined || lease.claim !== undefined || !sameOwner(lease.owner.id, owner)) return lost
592
+ lease.owner.heartbeatAtMs = Math.max(lease.owner.heartbeatAtMs, nowMs)
593
+ return ({ _tag: "Renewed", heartbeatAtMs: lease.owner.heartbeatAtMs } satisfies Renewed)
594
+ })
595
+ ),
596
+ release: Effect.fn("Consensus.release")((runId: string, owner: OwnerId) =>
597
+ Effect.sync(() => {
598
+ const lease = leases.get(runId)
599
+ if (lease === undefined) return
600
+ if (lease.claim !== undefined && sameOwner(lease.claim.id, owner)) {
601
+ lease.claim = undefined
602
+ }
603
+ if (lease.owner !== undefined && sameOwner(lease.owner.id, owner)) {
604
+ lease.owner = undefined
605
+ }
606
+ prune(runId, lease)
607
+ })
608
+ ),
609
+ steal: Effect.fn("Consensus.steal")((
610
+ runId: string,
611
+ claimant: OwnerId,
612
+ nowMs: number,
613
+ evidence: LivenessEvidence
614
+ ) =>
615
+ Effect.sync(() => {
616
+ if (!matchesEvidence(evidence.expectedOwner, claimant, nowMs, evidence)) {
617
+ return rejected("evidence_invalid")
618
+ }
619
+ const lease = leases.get(runId)
620
+ if (lease?.claim !== undefined) return rejected("already_claimed")
621
+ if (lease?.owner === undefined || !sameOwner(lease.owner.id, evidence.expectedOwner)) {
622
+ return rejected("evidence_invalid")
623
+ }
624
+ if (lease.owner.heartbeatAtMs >= nowMs - staleAfterMs) return rejected("owner_live")
625
+ lease.claim = { id: claimant, claimedAtMs: nowMs }
626
+ return claimed(nowMs)
627
+ })
628
+ ),
629
+ recover: Effect.fn("Consensus.recover")((
630
+ runId: string,
631
+ staleClaimant: OwnerId,
632
+ grantedAtMs: number,
633
+ observer: OwnerId,
634
+ nowMs: number,
635
+ evidence: LivenessEvidence
636
+ ) =>
637
+ Effect.sync(() => {
638
+ if (!matchesEvidence(staleClaimant, observer, nowMs, evidence)) return rejected("evidence_invalid")
639
+ const lease = leases.get(runId)
640
+ if (
641
+ lease?.claim === undefined ||
642
+ !sameOwner(lease.claim.id, staleClaimant) ||
643
+ lease.claim.claimedAtMs !== grantedAtMs
644
+ ) {
645
+ return rejected("claim_changed")
646
+ }
647
+ if (lease.claim.claimedAtMs >= nowMs - staleAfterMs) return rejected("claim_fresh")
648
+ lease.claim = undefined
649
+ prune(runId, lease)
650
+ return recovered
651
+ })
652
+ ),
653
+ guard: Effect.fn("Consensus.guard")((runId: string, owner: OwnerId) =>
654
+ Effect.suspend(() => {
655
+ const lease = leases.get(runId)
656
+ return lease?.owner !== undefined && sameOwner(lease.owner.id, owner)
657
+ ? Effect.void
658
+ : Effect.fail(fenceLost(runId, owner))
659
+ })
660
+ )
661
+ })
662
+ })
663
+
664
+ /**
665
+ * Provides the in-memory single-process strategy.
666
+ *
667
+ * The lease lives in process memory, so R3 is exact within the process — the
668
+ * strategy's transitions and the serialized append transactions interleave
669
+ * atomically on one event loop — and browser support needs nothing beyond
670
+ * this layer plus the browser-safe journal. State is per layer build: provide
671
+ * one instance to every service that must agree on it, which is what
672
+ * `SqlJournal.layerWith` and `RunStore.layerWith` exist for.
673
+ *
674
+ * The lease is not part of any database transaction: a transition made inside
675
+ * a `Journal.transact` that later rolls back stays applied in memory, so the
676
+ * lease and a rolled-back run row can disagree until the next transition.
677
+ * `SqlConsensus` keeps the two in one transaction; prefer it wherever a
678
+ * database is present.
679
+ *
680
+ * @since 1.0.0
681
+ * @category layers
682
+ */
683
+ export const layerLocal: Layer.Layer<Consensus> = Layer.effect(Consensus, makeLocal)