@smthrs/journal 0.0.0-stage → 1.0.0-rc.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 (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,344 @@
1
+ /**
2
+ * The default database-backed consensus strategy.
3
+ *
4
+ * This is the run store's former `flows_runs` ownership compare-and-swap
5
+ * relocated: the owner tuple, the two-phase claim columns, claim recovery, and
6
+ * the grant timestamp are the private schema of one strategy — the
7
+ * `flows_consensus_leases` table — instead of a public contract on the run
8
+ * row. The run row still mirrors the outcome, but the lease is what
9
+ * arbitrates.
10
+ *
11
+ * Commit-time admission (R3) is exact: `guard` is a plain SELECT that joins
12
+ * the caller's open write transaction — `DurableWriter` serializes write
13
+ * transactions, so no reclaim can commit between the guard read and the
14
+ * statements beside it — and every mutating operation runs through
15
+ * `DurableWriter.write`, joining an enclosing transaction as a savepoint.
16
+ * Prior art: Temporal's shard `rangeID` check
17
+ * (`reference/temporal/service/history/shard/context_impl.go`,
18
+ * `renewRangeLocked`).
19
+ *
20
+ * Governing design: `packages/smithers/flows/journal/docs/concepts/owner-fence.md`.
21
+ *
22
+ * @since 1.0.0
23
+ */
24
+
25
+ import * as Dialect from "@smthrs/database/Dialect"
26
+ import { DurableWriter } from "@smthrs/database/DurableWriter"
27
+ import * as Duration from "effect/Duration"
28
+ import * as Effect from "effect/Effect"
29
+ import * as Layer from "effect/Layer"
30
+ import * as SqlClient from "effect/unstable/sql/SqlClient"
31
+ import {
32
+ type ActivateOutcome,
33
+ type Claimed,
34
+ type ClaimOutcome,
35
+ Consensus,
36
+ ConsensusError,
37
+ fenceLost,
38
+ type HeartbeatOutcome,
39
+ heartbeatStaleAfter,
40
+ type LivenessEvidence,
41
+ matchesEvidence,
42
+ type RecoverOutcome,
43
+ type Rejected,
44
+ type RejectionReason,
45
+ type Service
46
+ } from "./Consensus.ts"
47
+ import type { OwnerId } from "./OwnerId.ts"
48
+
49
+ const staleAfterMs = Duration.toMillis(heartbeatStaleAfter)
50
+
51
+ const rejected = (reason: RejectionReason): Rejected => ({ _tag: "Rejected", reason })
52
+ const claimed = (grantedAtMs: number): Claimed => ({ _tag: "Claimed", grantedAtMs })
53
+ const lost = { _tag: "Lost" } as const
54
+ const activated = { _tag: "Activated" } as const
55
+ const recovered = { _tag: "Recovered" } as const
56
+
57
+ interface LeaseRow {
58
+ readonly owner_host_id: string | null
59
+ readonly owner_pid: number | null
60
+ readonly owner_nonce: string | null
61
+ readonly claim_host_id: string | null
62
+ readonly claim_pid: number | null
63
+ readonly claim_nonce: string | null
64
+ }
65
+
66
+ /**
67
+ * Constructs the database-backed strategy over the context's SQL client and
68
+ * durable writer.
69
+ *
70
+ * @since 1.0.0
71
+ * @category constructors
72
+ */
73
+ export const make: Effect.Effect<Service, never, DurableWriter | SqlClient.SqlClient> = Effect.gen(function*() {
74
+ const sql = yield* Effect.service(SqlClient.SqlClient)
75
+ const writer = yield* DurableWriter
76
+
77
+ const strategyError = (cause: unknown): ConsensusError =>
78
+ new ConsensusError({
79
+ code: "persistence_failed",
80
+ message: "consensus lease operation failed",
81
+ cause
82
+ })
83
+
84
+ // The original cause is preserved through the wrapper so an enclosing
85
+ // `DurableWriter.write`'s retry classification still sees a transient
86
+ // conflict raised by a savepoint-nested lease statement.
87
+ const write = <A>(
88
+ body: Effect.Effect<A, unknown>
89
+ ): Effect.Effect<A, ConsensusError> =>
90
+ writer.write(body).pipe(Effect.mapError((cause) => cause instanceof ConsensusError ? cause : strategyError(cause)))
91
+
92
+ const leaseOf = (runId: string) =>
93
+ sql<LeaseRow>`
94
+ SELECT owner_host_id, owner_pid, owner_nonce, claim_host_id, claim_pid, claim_nonce
95
+ FROM flows_consensus_leases
96
+ WHERE run_id = ${runId}
97
+ `
98
+
99
+ const prune = (runId: string) =>
100
+ sql`
101
+ DELETE FROM flows_consensus_leases
102
+ WHERE run_id = ${runId} AND owner_host_id IS NULL AND claim_host_id IS NULL
103
+ `
104
+
105
+ const claim: Service["claim"] = Effect.fn("Consensus.claim")((
106
+ runId: string,
107
+ claimant: OwnerId,
108
+ nowMs: number
109
+ ) =>
110
+ write(Effect.gen(function*() {
111
+ const rows = yield* sql<{ readonly run_id: string }>`
112
+ INSERT INTO flows_consensus_leases (run_id, claim_host_id, claim_pid, claim_nonce, claimed_at_ms)
113
+ VALUES (${runId}, ${claimant.hostId}, ${claimant.pid}, ${claimant.nonce}, ${nowMs})
114
+ ON CONFLICT (run_id) DO UPDATE SET
115
+ claim_host_id = excluded.claim_host_id,
116
+ claim_pid = excluded.claim_pid,
117
+ claim_nonce = excluded.claim_nonce,
118
+ claimed_at_ms = excluded.claimed_at_ms
119
+ WHERE flows_consensus_leases.claim_host_id IS NULL
120
+ AND flows_consensus_leases.owner_host_id IS NULL
121
+ RETURNING run_id
122
+ `
123
+ if (rows.length > 0) return claimed(nowMs)
124
+ // The upsert only misses on a conflicting row, so the lease exists and
125
+ // holds a claim, an owner, or both.
126
+ const lease = (yield* leaseOf(runId))[0]!
127
+ return lease.claim_host_id !== null ? rejected("already_claimed") : rejected("owner_held")
128
+ }))
129
+ )
130
+
131
+ const activate: Service["activate"] = Effect.fn("Consensus.activate")((
132
+ runId: string,
133
+ owner: OwnerId,
134
+ grantedAtMs: number,
135
+ nowMs: number
136
+ ) =>
137
+ write(Effect.gen(function*() {
138
+ const rows = yield* sql<{ readonly run_id: string }>`
139
+ UPDATE flows_consensus_leases
140
+ SET
141
+ owner_host_id = ${owner.hostId},
142
+ owner_pid = ${owner.pid},
143
+ owner_nonce = ${owner.nonce},
144
+ granted_at_ms = ${grantedAtMs},
145
+ heartbeat_at_ms = ${nowMs},
146
+ claim_host_id = NULL,
147
+ claim_pid = NULL,
148
+ claim_nonce = NULL,
149
+ claimed_at_ms = NULL
150
+ WHERE run_id = ${runId}
151
+ AND claim_host_id = ${owner.hostId}
152
+ AND claim_pid = ${owner.pid}
153
+ AND claim_nonce = ${owner.nonce}
154
+ AND claimed_at_ms = ${grantedAtMs}
155
+ RETURNING run_id
156
+ `
157
+ return (rows.length > 0 ? activated : lost) satisfies ActivateOutcome
158
+ }))
159
+ )
160
+
161
+ const heartbeat: Service["heartbeat"] = Effect.fn("Consensus.heartbeat")((
162
+ runId: string,
163
+ owner: OwnerId,
164
+ nowMs: number
165
+ ) =>
166
+ write(Effect.gen(function*() {
167
+ // Monotonic: a pulse delayed past a newer one from the same owner never
168
+ // moves the lease backwards and never makes a live run look stale.
169
+ const rows = yield* sql<{ readonly heartbeat_at_ms: number }>`
170
+ UPDATE flows_consensus_leases
171
+ SET heartbeat_at_ms = ${Dialect.greatest(sql)}(heartbeat_at_ms, ${nowMs})
172
+ WHERE run_id = ${runId}
173
+ AND owner_host_id = ${owner.hostId}
174
+ AND owner_pid = ${owner.pid}
175
+ AND owner_nonce = ${owner.nonce}
176
+ RETURNING heartbeat_at_ms
177
+ `
178
+ const row = rows[0]
179
+ return (row === undefined
180
+ ? lost
181
+ : { _tag: "Renewed", heartbeatAtMs: Number(row.heartbeat_at_ms) }) satisfies HeartbeatOutcome
182
+ }))
183
+ )
184
+
185
+ const reconfirm: Service["reconfirm"] = Effect.fn("Consensus.reconfirm")((
186
+ runId: string,
187
+ owner: OwnerId,
188
+ nowMs: number
189
+ ) =>
190
+ write(Effect.gen(function*() {
191
+ // A pending steal has already reserved the lease, even before activation.
192
+ // Compare both owner and claim in the same update as the renewal.
193
+ const rows = yield* sql<{ readonly heartbeat_at_ms: number }>`
194
+ UPDATE flows_consensus_leases
195
+ SET heartbeat_at_ms = ${Dialect.greatest(sql)}(heartbeat_at_ms, ${nowMs})
196
+ WHERE run_id = ${runId}
197
+ AND owner_host_id = ${owner.hostId}
198
+ AND owner_pid = ${owner.pid}
199
+ AND owner_nonce = ${owner.nonce}
200
+ AND claim_host_id IS NULL
201
+ RETURNING heartbeat_at_ms
202
+ `
203
+ const row = rows[0]
204
+ return (row === undefined
205
+ ? lost
206
+ : { _tag: "Renewed", heartbeatAtMs: Number(row.heartbeat_at_ms) }) satisfies HeartbeatOutcome
207
+ }))
208
+ )
209
+
210
+ const release: Service["release"] = Effect.fn("Consensus.release")((runId: string, owner: OwnerId) =>
211
+ write(Effect.gen(function*() {
212
+ yield* sql`
213
+ UPDATE flows_consensus_leases
214
+ SET claim_host_id = NULL, claim_pid = NULL, claim_nonce = NULL, claimed_at_ms = NULL
215
+ WHERE run_id = ${runId}
216
+ AND claim_host_id = ${owner.hostId}
217
+ AND claim_pid = ${owner.pid}
218
+ AND claim_nonce = ${owner.nonce}
219
+ `
220
+ yield* sql`
221
+ UPDATE flows_consensus_leases
222
+ SET owner_host_id = NULL, owner_pid = NULL, owner_nonce = NULL,
223
+ granted_at_ms = NULL, heartbeat_at_ms = NULL
224
+ WHERE run_id = ${runId}
225
+ AND owner_host_id = ${owner.hostId}
226
+ AND owner_pid = ${owner.pid}
227
+ AND owner_nonce = ${owner.nonce}
228
+ `
229
+ yield* prune(runId)
230
+ }))
231
+ )
232
+
233
+ const steal: Service["steal"] = Effect.fn("Consensus.steal")((
234
+ runId: string,
235
+ claimant: OwnerId,
236
+ nowMs: number,
237
+ evidence: LivenessEvidence
238
+ ) =>
239
+ Effect.suspend((): Effect.Effect<ClaimOutcome, ConsensusError> => {
240
+ if (!matchesEvidence(evidence.expectedOwner, claimant, nowMs, evidence)) {
241
+ return Effect.succeed(rejected("evidence_invalid"))
242
+ }
243
+ return write(Effect.gen(function*() {
244
+ const rows = yield* sql<{ readonly run_id: string }>`
245
+ UPDATE flows_consensus_leases
246
+ SET
247
+ claim_host_id = ${claimant.hostId},
248
+ claim_pid = ${claimant.pid},
249
+ claim_nonce = ${claimant.nonce},
250
+ claimed_at_ms = ${nowMs}
251
+ WHERE run_id = ${runId}
252
+ AND owner_host_id = ${evidence.expectedOwner.hostId}
253
+ AND owner_pid = ${evidence.expectedOwner.pid}
254
+ AND owner_nonce = ${evidence.expectedOwner.nonce}
255
+ AND heartbeat_at_ms < ${nowMs - staleAfterMs}
256
+ AND claim_host_id IS NULL
257
+ RETURNING run_id
258
+ `
259
+ if (rows.length > 0) return claimed(nowMs)
260
+ const lease = (yield* leaseOf(runId))[0]
261
+ if (lease !== undefined && lease.claim_host_id !== null) return rejected("already_claimed")
262
+ if (
263
+ lease === undefined ||
264
+ lease.owner_host_id !== evidence.expectedOwner.hostId ||
265
+ Number(lease.owner_pid) !== evidence.expectedOwner.pid ||
266
+ lease.owner_nonce !== evidence.expectedOwner.nonce
267
+ ) {
268
+ // A run nobody owns, or one owned by somebody else, has no owner
269
+ // this evidence could disprove.
270
+ return rejected("evidence_invalid")
271
+ }
272
+ return rejected("owner_live")
273
+ }))
274
+ })
275
+ )
276
+
277
+ const recover: Service["recover"] = Effect.fn("Consensus.recover")((
278
+ runId: string,
279
+ staleClaimant: OwnerId,
280
+ grantedAtMs: number,
281
+ observer: OwnerId,
282
+ nowMs: number,
283
+ evidence: LivenessEvidence
284
+ ) =>
285
+ Effect.suspend((): Effect.Effect<RecoverOutcome, ConsensusError> => {
286
+ if (!matchesEvidence(staleClaimant, observer, nowMs, evidence)) {
287
+ return Effect.succeed(rejected("evidence_invalid"))
288
+ }
289
+ return write(Effect.gen(function*() {
290
+ const rows = yield* sql<{ readonly run_id: string }>`
291
+ UPDATE flows_consensus_leases
292
+ SET claim_host_id = NULL, claim_pid = NULL, claim_nonce = NULL, claimed_at_ms = NULL
293
+ WHERE run_id = ${runId}
294
+ AND claim_host_id = ${staleClaimant.hostId}
295
+ AND claim_pid = ${staleClaimant.pid}
296
+ AND claim_nonce = ${staleClaimant.nonce}
297
+ AND claimed_at_ms = ${grantedAtMs}
298
+ AND claimed_at_ms < ${nowMs - staleAfterMs}
299
+ RETURNING run_id
300
+ `
301
+ if (rows.length > 0) {
302
+ yield* prune(runId)
303
+ return recovered
304
+ }
305
+ const matches = yield* sql<{ readonly run_id: string }>`
306
+ SELECT run_id FROM flows_consensus_leases
307
+ WHERE run_id = ${runId}
308
+ AND claim_host_id = ${staleClaimant.hostId}
309
+ AND claim_pid = ${staleClaimant.pid}
310
+ AND claim_nonce = ${staleClaimant.nonce}
311
+ AND claimed_at_ms = ${grantedAtMs}
312
+ `
313
+ // The exact claim survived the compare-and-swap only because it is
314
+ // not yet stale; anything else means the claim moved on.
315
+ return matches.length > 0 ? rejected("claim_fresh") : rejected("claim_changed")
316
+ }))
317
+ })
318
+ )
319
+
320
+ const guard: Service["guard"] = Effect.fn("Consensus.guard")((runId: string, owner: OwnerId) =>
321
+ write(Effect.gen(function*() {
322
+ const held = yield* sql<{ readonly ok: number }>`
323
+ SELECT 1 AS ok FROM flows_consensus_leases
324
+ WHERE run_id = ${runId}
325
+ AND owner_host_id = ${owner.hostId}
326
+ AND owner_pid = ${owner.pid}
327
+ AND owner_nonce = ${owner.nonce}
328
+ `
329
+ if (held.length === 0) {
330
+ return yield* Effect.fail(fenceLost(runId, owner))
331
+ }
332
+ }))
333
+ )
334
+
335
+ return Consensus.of({ claim, activate, heartbeat, reconfirm, release, steal, recover, guard })
336
+ })
337
+
338
+ /**
339
+ * Provides the database-backed strategy.
340
+ *
341
+ * @since 1.0.0
342
+ * @category layers
343
+ */
344
+ export const layer: Layer.Layer<Consensus, never, DurableWriter | SqlClient.SqlClient> = Layer.effect(Consensus, make)