@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
package/src/Journal.ts ADDED
@@ -0,0 +1,688 @@
1
+ /**
2
+ * Logical journal contract with durable lifecycle and lossy telemetry channels.
3
+ *
4
+ * The ownership fence and the lossless `emitDurable` / lossy `emitLossy`
5
+ * split are described at https://journal.smithers.sh/concepts/owner-fence/
6
+ * and https://journal.smithers.sh/concepts/two-channels/.
7
+ *
8
+ * @since 0.1.0
9
+ */
10
+
11
+ import * as Context from "effect/Context"
12
+ import * as Effect from "effect/Effect"
13
+ import * as Layer from "effect/Layer"
14
+ import type * as Option from "effect/Option"
15
+ import * as PubSub from "effect/PubSub"
16
+ import * as Schema from "effect/Schema"
17
+ import type * as Scope from "effect/Scope"
18
+ import * as Stream from "effect/Stream"
19
+ import { Entry, RunId, Seq, SourceSeq } from "./JournalEvent.ts"
20
+ import type { Input } from "./JournalEvent.ts"
21
+ import type { OwnerId } from "./OwnerId.ts"
22
+ import type { Projection } from "./Projection.ts"
23
+
24
+ /**
25
+ * Defers a fold/cache update until the outermost durable write commits.
26
+ * Returns false without running it when no managed commit is available.
27
+ * Failed savepoints and retried transaction attempts never publish the update.
28
+ *
29
+ * @category transactions
30
+ * @since 1.0.0
31
+ */
32
+ export { afterCommit } from "@smthrs/database/DurableWriter"
33
+
34
+ /**
35
+ * Stable error codes returned by journal operations.
36
+ *
37
+ * `read_failed` identifies a database failure on a durable read path. Callers
38
+ * can distinguish that retryable storage condition from an unclassified
39
+ * `unknown` journal defect.
40
+ *
41
+ * @category models
42
+ * @since 0.1.0
43
+ */
44
+ export const JournalErrorCode = Schema.Literals([
45
+ "invalid_event",
46
+ "idempotency_conflict",
47
+ "sequence_conflict",
48
+ "fence_lost",
49
+ "queue_overflow",
50
+ "journal_closed",
51
+ "sink_failed",
52
+ "read_failed",
53
+ "decode_failed",
54
+ "projection_failed",
55
+ "checkpoint_invalid",
56
+ "reader_behind",
57
+ "compacted",
58
+ "unknown"
59
+ ])
60
+
61
+ /**
62
+ * Stable error codes returned by journal operations.
63
+ *
64
+ * @category models
65
+ * @since 0.1.0
66
+ */
67
+ export type JournalErrorCode = typeof JournalErrorCode.Type
68
+
69
+ /**
70
+ * Error raised by journal admission, persistence, replay, or projection.
71
+ *
72
+ * `checkpointSeq` is set on the compaction-aware codes: on `compacted` it is
73
+ * the run's compaction floor, the checkpoint sequence a reader must resync
74
+ * from, and on `reader_behind` it is the checkpoint a compaction refused to
75
+ * truncate below.
76
+ *
77
+ * @category errors
78
+ * @since 0.1.0
79
+ */
80
+ export class JournalError extends Schema.TaggedError<JournalError>()("@smthrs/journal/JournalError", {
81
+ code: JournalErrorCode,
82
+ message: Schema.String,
83
+ cause: Schema.optional(Schema.Unknown),
84
+ checkpointSeq: Schema.optional(Seq)
85
+ }) {}
86
+
87
+ /**
88
+ * Policy applied when the non-blocking admission queue is full.
89
+ *
90
+ * @category models
91
+ * @since 0.1.0
92
+ */
93
+ export const OverflowPolicy = Schema.Literals(["reject", "drop-newest", "drop-oldest"])
94
+
95
+ /**
96
+ * Policy applied when the non-blocking admission queue is full.
97
+ *
98
+ * @category models
99
+ * @since 0.1.0
100
+ */
101
+ export type OverflowPolicy = typeof OverflowPolicy.Type
102
+
103
+ /**
104
+ * Receipt for a newly admitted event.
105
+ *
106
+ * On the lossy channel, `seq` and `sourceSeq` are allocated synchronously and
107
+ * returned before SQL commits. This `seq` is provisional: the SQL writer
108
+ * moves it above the committed tail if another commit overtakes its reservation.
109
+ * Use committed entries for durable cursors. On the durable channel, the same
110
+ * receipt shape is returned only after the entry commits. `seq` orders the run;
111
+ * `sourceSeq` identifies producer retries and does not change at commit.
112
+ *
113
+ * @category models
114
+ * @since 0.1.0
115
+ */
116
+ export const Accepted = Schema.TaggedStruct("Accepted", {
117
+ seq: Seq,
118
+ sourceSeq: SourceSeq,
119
+ evicted: Schema.optionalKey(Schema.Struct({
120
+ policy: Schema.Literal("drop-oldest"),
121
+ count: Schema.Int.check(Schema.isGreaterThan(0))
122
+ }))
123
+ })
124
+
125
+ /**
126
+ * Receipt for a newly admitted event.
127
+ *
128
+ * @category models
129
+ * @since 0.1.0
130
+ */
131
+ export type Accepted = typeof Accepted.Type
132
+
133
+ /**
134
+ * Receipt for an exact retry of an already pending or committed source event.
135
+ *
136
+ * `seq` is the original event's sequence, provisional while pending and
137
+ * canonical after commit. A duplicate does not allocate another sequence or
138
+ * enqueue another write. `status` distinguishes
139
+ * an optimistic retry from one whose original event is already durable.
140
+ *
141
+ * @category models
142
+ * @since 0.1.0
143
+ */
144
+ export const Duplicate = Schema.TaggedStruct("Duplicate", {
145
+ seq: Seq,
146
+ sourceSeq: SourceSeq,
147
+ status: Schema.Literals(["pending", "committed"])
148
+ })
149
+
150
+ /**
151
+ * Receipt for an exact producer retry.
152
+ *
153
+ * @category models
154
+ * @since 0.1.0
155
+ */
156
+ export type Duplicate = typeof Duplicate.Type
157
+
158
+ /**
159
+ * Receipt for an event discarded by an explicit dropping policy.
160
+ *
161
+ * A dropped admission still consumes its synchronously allocated `seq` and
162
+ * `sourceSeq`, so sequence gaps are expected.
163
+ *
164
+ * @category models
165
+ * @since 0.1.0
166
+ */
167
+ export const Dropped = Schema.TaggedStruct("Dropped", {
168
+ seq: Seq,
169
+ sourceSeq: SourceSeq,
170
+ policy: Schema.Literal("drop-newest")
171
+ })
172
+
173
+ /**
174
+ * Receipt for an event discarded by policy.
175
+ *
176
+ * @category models
177
+ * @since 0.1.0
178
+ */
179
+ export type Dropped = typeof Dropped.Type
180
+
181
+ /**
182
+ * Receipt union for admission that may use the lossy queue.
183
+ *
184
+ * @category models
185
+ * @since 0.1.0
186
+ */
187
+ export const EmitReceipt = Schema.Union([Accepted, Duplicate, Dropped])
188
+
189
+ /**
190
+ * Receipt union for the lossy channel.
191
+ *
192
+ * @category models
193
+ * @since 0.1.0
194
+ */
195
+ export type EmitReceipt = typeof EmitReceipt.Type
196
+
197
+ /**
198
+ * Result of a synchronously durable journal admission.
199
+ *
200
+ * A durable admission is never dropped: it either commits, returns the
201
+ * committed sequence of an exact producer retry, or fails.
202
+ *
203
+ * @category models
204
+ * @since 0.1.0
205
+ */
206
+ export const DurableReceipt = Schema.Union([Accepted, Duplicate])
207
+
208
+ /**
209
+ * Receipt union for the durable channel.
210
+ *
211
+ * @category models
212
+ * @since 0.1.0
213
+ */
214
+ export type DurableReceipt = typeof DurableReceipt.Type
215
+
216
+ /**
217
+ * Cursor used to replay a run and then follow its committed tail.
218
+ *
219
+ * @category models
220
+ * @since 0.1.0
221
+ */
222
+ export const StreamOptions = Schema.Struct({
223
+ runId: RunId,
224
+ afterSequence: Schema.optionalKey(Seq)
225
+ })
226
+
227
+ /**
228
+ * Cursor used to replay a run and follow its committed tail.
229
+ *
230
+ * @category models
231
+ * @since 0.1.0
232
+ */
233
+ export type StreamOptions = typeof StreamOptions.Type
234
+
235
+ /**
236
+ * Largest page `entries` will read.
237
+ *
238
+ * A page is decoded into memory in full before the caller sees its first
239
+ * entry, so an unbounded limit lets one call materialize a whole run. The
240
+ * largest page any caller in this repository asks for is 512, so this
241
+ * ceiling refuses only a limit that was going to be a memory incident.
242
+ *
243
+ * @since 1.0.0
244
+ * @category constants
245
+ */
246
+ export const maxEntriesLimit = 10_000
247
+
248
+ /**
249
+ * Cursor and page size for durable journal reads.
250
+ *
251
+ * `limit` is at most `maxEntriesLimit` (10,000 entries). Optional `eventTypes`
252
+ * selects a nonempty list of at most 64 exact types before pagination; cursors remain canonical run
253
+ * sequences and `hasMore` describes matching entries. Omit it for the full run.
254
+ * Filtering does not bypass the run's compaction floor.
255
+ *
256
+ * @category models
257
+ * @since 0.1.0
258
+ */
259
+ export const EntriesOptions = Schema.Struct({
260
+ runId: RunId,
261
+ after: Schema.optionalKey(Seq),
262
+ eventTypes: Schema.optionalKey(
263
+ Schema.Array(Entry.fields.eventType).check(Schema.isMinLength(1), Schema.isMaxLength(64))
264
+ ),
265
+ limit: Schema.Int.check(Schema.isGreaterThan(0), Schema.isLessThanOrEqualTo(maxEntriesLimit))
266
+ })
267
+
268
+ /**
269
+ * Cursor and page size for durable journal reads.
270
+ *
271
+ * @category models
272
+ * @since 0.1.0
273
+ */
274
+ export type EntriesOptions = typeof EntriesOptions.Type
275
+
276
+ /**
277
+ * One page of canonical durable journal entries.
278
+ *
279
+ * @category models
280
+ * @since 0.1.0
281
+ */
282
+ export const EntriesPage = Schema.Struct({
283
+ entries: Schema.Array(Entry),
284
+ hasMore: Schema.Boolean
285
+ })
286
+
287
+ /**
288
+ * One page of durable journal entries.
289
+ *
290
+ * @category models
291
+ * @since 0.1.0
292
+ */
293
+ export type EntriesPage = typeof EntriesPage.Type
294
+
295
+ /**
296
+ * A durable checkpoint: the caller-captured state that replays a run from
297
+ * `seq` without the entries before it.
298
+ *
299
+ * `state` is the caller's own replay snapshot and round-trips verbatim: the
300
+ * journal never interprets it, and redaction deliberately does not apply,
301
+ * exactly as it does not apply to executable state. A checkpoint at `seq`
302
+ * must subsume every entry with a sequence at or below `seq`: replay is
303
+ * `state` plus `stream({ runId, afterSequence: seq })`.
304
+ *
305
+ * `compactedAtMs` is `null` until a compaction has truncated the entries
306
+ * strictly below `seq`. The largest compacted `seq` for a run is its
307
+ * compaction floor.
308
+ *
309
+ * Prior art: Temporal's mutable state, a durable snapshot pinned to a
310
+ * history offset, with history below it never replayed
311
+ * (`reference/temporal/common/persistence/data_interfaces.go`,
312
+ * `WorkflowSnapshot`).
313
+ *
314
+ * @category models
315
+ * @since 0.1.0
316
+ */
317
+ export class Checkpoint extends Schema.Class<Checkpoint>("@smthrs/journal/Journal/Checkpoint")({
318
+ runId: RunId,
319
+ seq: Seq,
320
+ state: Schema.Unknown,
321
+ createdAtMs: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
322
+ compactedAtMs: Schema.NullOr(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)))
323
+ }) {}
324
+
325
+ /**
326
+ * Arguments for writing a checkpoint.
327
+ *
328
+ * `seq` must name a committed entry of the run: the surviving row is what
329
+ * keeps the run's durable `MAX(seq)` allocation floor at or above the
330
+ * compaction boundary, so a process restarted after compaction can never
331
+ * re-allocate a truncated sequence.
332
+ *
333
+ * @category models
334
+ * @since 0.1.0
335
+ */
336
+ export const CheckpointOptions = Schema.Struct({
337
+ runId: RunId,
338
+ seq: Seq,
339
+ state: Schema.Unknown
340
+ })
341
+
342
+ /**
343
+ * Arguments for writing a checkpoint.
344
+ *
345
+ * @category models
346
+ * @since 0.1.0
347
+ */
348
+ export type CheckpointOptions = typeof CheckpointOptions.Type
349
+
350
+ /**
351
+ * Arguments for compacting a run.
352
+ *
353
+ * `upTo` selects the checkpoint to truncate below; omitted, the run's latest
354
+ * checkpoint is used.
355
+ *
356
+ * @category models
357
+ * @since 0.1.0
358
+ */
359
+ export const CompactOptions = Schema.Struct({
360
+ runId: RunId,
361
+ upTo: Schema.optionalKey(Seq)
362
+ })
363
+
364
+ /**
365
+ * Arguments for compacting a run.
366
+ *
367
+ * @category models
368
+ * @since 0.1.0
369
+ */
370
+ export type CompactOptions = typeof CompactOptions.Type
371
+
372
+ /**
373
+ * Receipt for a completed compaction.
374
+ *
375
+ * `deleted` is `0` when the checkpoint was already the compaction floor, a
376
+ * retried compaction is idempotent.
377
+ *
378
+ * @category models
379
+ * @since 0.1.0
380
+ */
381
+ export const Compacted = Schema.Struct({
382
+ runId: RunId,
383
+ checkpointSeq: Seq,
384
+ deleted: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))
385
+ })
386
+
387
+ /**
388
+ * Receipt for a completed compaction.
389
+ *
390
+ * @category models
391
+ * @since 0.1.0
392
+ */
393
+ export type Compacted = typeof Compacted.Type
394
+
395
+ /**
396
+ * Journal operations.
397
+ *
398
+ * There are two sequence domains:
399
+ *
400
+ * - `seq` is reserved synchronously per run. A lossy reservation is provisional;
401
+ * the SQL writer moves it above the committed tail if overtaken. The committed
402
+ * sequence is the canonical order for replay, paging, streams, and projections.
403
+ * - `sourceSeq` is assigned synchronously per `(runId, sourceId)`, or supplied
404
+ * by the producer, and is the idempotency key for producer retries.
405
+ *
406
+ * Rejected or dropped admissions consume both allocations, so gaps are valid.
407
+ * A retry the in-process index still holds returns `Duplicate` with the
408
+ * original `seq` (provisional while pending) and consumes neither allocation.
409
+ *
410
+ * A retry it no longer holds is admitted optimistically and settled by
411
+ * `UNIQUE (run_id, source_id, source_seq)` at the insert: admission issues no
412
+ * dedup read, so the receipt is `Accepted`, the entry collapses onto the
413
+ * committed row instead of doubling it, and a reused identity carrying
414
+ * different bytes surfaces its `idempotency_conflict` through `flush` rather
415
+ * than through the emit. The read is deliberately absent. `emitLossy` exists
416
+ * to be callable from inside somebody else's open write transaction, and a
417
+ * SELECT there waits on the writer that is waiting on the caller: the agent
418
+ * executor's exit flush deadlocked exactly so. What a producer keeps in
419
+ * exchange is the choice of what a collision means, through `Input.dedupe`.
420
+ *
421
+ * This table is Smithers' logical (domain) write-ahead log and is intended to
422
+ * become the authoritative state history. The storage engine's own WAL
423
+ * underneath it is a durability substrate only and is never consumed as the
424
+ * application event API. A durable boundary must not advance a run or expose
425
+ * its result until its lifecycle entry is committed. No local commit makes a
426
+ * remote effect atomic, so external effects still need idempotency keys,
427
+ * fencing tokens, or compensation.
428
+ *
429
+ * `emitDurable` allocates `seq` inside the writer's SQL transaction, so the
430
+ * returned sequence is already committed and independent writers never fork
431
+ * the per-run clock.
432
+ *
433
+ * Caveat on scope: the stores above this log (`RunStore`, `AttemptStore`,
434
+ * `CacheStore`, and engine-store's `DurableEngineState`) hold the executable
435
+ * authoritative state, and no state is derived from these entries today.
436
+ * `transact` is what keeps the two halves consistent anyway: a state
437
+ * transition and the lifecycle entry describing it are appended in ONE write
438
+ * transaction, so a crash can never leave durable state the journal does not
439
+ * explain (or an entry for a transition that never landed).
440
+ *
441
+ * The surface is split into two channels:
442
+ *
443
+ * - The lifecycle channel, `emitDurable`, returns `DurableReceipt`, so a
444
+ * dropped lifecycle event is unrepresentable: the write commits, dedupes, or
445
+ * fails with a typed error.
446
+ * - The lossy channel, `emitLossy`, keeps the optimistic queue and its
447
+ * overflow policies for telemetry, where `Dropped` receipts and
448
+ * `drop-oldest` evictions are acceptable.
449
+ *
450
+ * `emitDurable` is fenced on the run's ownership as arbitrated by the
451
+ * injected `Consensus` strategy: the caller hands over its `OwnerId`, the
452
+ * durable insert only commits while that strategy still records the owner as
453
+ * holding the run, and a reclaimed run fails the write with a `fence_lost`
454
+ * error. The fence is mandatory on the lifecycle channel: a lifecycle write is
455
+ * what advances a run, and a zombie owner must not advance anything. The one
456
+ * escape is {@link Service.emitDurableUnfenced}, for the rare admission that
457
+ * is genuinely ownerless.
458
+ *
459
+ * @category models
460
+ * @since 0.1.0
461
+ */
462
+ export interface Service {
463
+ /**
464
+ * The durable rewind generation and its archive boundary. An append-only
465
+ * adapter may omit this operation, which means generation zero forever.
466
+ * Rewinding adapters must advance the generation atomically with truncation.
467
+ */
468
+ readonly generation?:
469
+ | ((runId: RunId) => Effect.Effect<{
470
+ readonly generation: number
471
+ readonly afterSeq: number
472
+ }, JournalError>)
473
+ | undefined
474
+ readonly emitLossy: (input: Input) => Effect.Effect<EmitReceipt, JournalError>
475
+ readonly emitDurable: (input: Input, owner: OwnerId) => Effect.Effect<DurableReceipt, JournalError>
476
+ /**
477
+ * The unfenced durable write: same durability and receipt contract as
478
+ * {@link Service.emitDurable}, with no ownership fence.
479
+ *
480
+ * This exists ONLY for admissions that are genuinely ownerless: writes
481
+ * whose correctness does not depend on who currently owns the run because
482
+ * they are first-writer-wins by design. The canonical case is the
483
+ * external-trigger admission: a deferred completion or clock-schedule
484
+ * record delivered by a sweeper that owns nothing, where the dedup index,
485
+ * not the fence, is the idempotency mechanism. A caller that holds an
486
+ * `OwnerId` must use `emitDurable`; reaching for this channel to dodge a
487
+ * `fence_lost` is exactly the zombie write the fence exists to reject.
488
+ */
489
+ readonly emitDurableUnfenced: (input: Input) => Effect.Effect<DurableReceipt, JournalError>
490
+ /**
491
+ * Runs `effect`, a state projection plus the `emitDurable` calls describing
492
+ * it, inside ONE write transaction.
493
+ *
494
+ * This is the seam that makes the logical WAL crash-consistent with
495
+ * executable state. The stores above the journal (`RunStore`,
496
+ * `AttemptStore`, `CacheStore`, `DurableEngineState`) write through the same
497
+ * `DurableWriter`, so their writes join this transaction as savepoints: either
498
+ * the transition and its lifecycle entry both commit, or neither does. It is
499
+ * the local analogue of Temporal closing mutable state into a mutation plus
500
+ * event batches and submitting them as one persistence request
501
+ * (`reference/temporal/service/history/workflow/transaction_impl.go`).
502
+ *
503
+ * A `Duplicate` receipt is unaffected: an exact producer retry still
504
+ * collapses onto the already-committed sequence.
505
+ *
506
+ * Two consequences a caller must plan for:
507
+ *
508
+ * - Publication (`changes`, `stream`, and the in-process source-event index)
509
+ * is deferred until the transaction commits, so a subscriber never
510
+ * observes an entry that later rolls back, and a rolled-back producer
511
+ * identity stays re-emittable rather than deduplicating against a
512
+ * sequence that does not exist.
513
+ * - Everything inside runs while a write transaction is open. Keep it to
514
+ * storage work: no flow bodies, no host calls, no waits.
515
+ *
516
+ * Nesting is safe: an inner `transact` becomes a savepoint of the outer
517
+ * one and defers its publication to the outermost commit.
518
+ */
519
+ readonly transact: <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E | JournalError, R>
520
+ /**
521
+ * Publishes a short, non-failing process-local update after the current
522
+ * transaction commits, or immediately when no transaction is open.
523
+ *
524
+ * Returns false without publishing inside a raw SQL transaction whose
525
+ * commit this journal cannot observe. A true result can mean deferred, not
526
+ * already run. Failed savepoints and retries discard their registrations.
527
+ * Test doubles that implement transactions must preserve this contract.
528
+ */
529
+ readonly whenCommitted: (update: Effect.Effect<void>) => Effect.Effect<boolean>
530
+ /**
531
+ * Replays the run's durable history from `afterSequence`, then follows it.
532
+ *
533
+ * This is the lossless follower. It pages the history rather than
534
+ * materializing it, so the first entry arrives after one page rather than
535
+ * after the whole run; it reports a sink loss that happens while it is
536
+ * following; and a cursor below the run's compaction floor fails with
537
+ * `compacted` and the sequence to resync from, never a silently shortened
538
+ * history.
539
+ */
540
+ readonly stream: (options: StreamOptions) => Stream.Stream<Entry, JournalError>
541
+ /**
542
+ * Reads one page of the run's durable history in sequence order.
543
+ *
544
+ * `hasMore` reports whether another page follows. A cursor below the run's
545
+ * compaction floor fails with `compacted`, exactly as `stream` does, so a
546
+ * poller and a follower cannot disagree about what history exists.
547
+ */
548
+ readonly entries: (options: EntriesOptions) => Effect.Effect<EntriesPage, JournalError>
549
+ /**
550
+ * Subscribes to entries this process commits, across every run.
551
+ *
552
+ * This is a bounded SLIDING buffer sized by the layer's `capacity`: a slow
553
+ * consumer silently loses entries, with no error and no gap signal. Use it
554
+ * for a local tail or a UI feed, and use `stream` wherever a missing entry
555
+ * would be a correctness bug, because that is the lossless path and the one
556
+ * that reports sink losses.
557
+ *
558
+ * Only this process's commits arrive here. Another writer on the same
559
+ * database publishes into its own buffer, which is the second reason
560
+ * `stream` exists: it rechecks the durable tail.
561
+ *
562
+ * Published entries are deeply frozen, so one subscriber cannot mutate the
563
+ * value another subscriber is about to read.
564
+ */
565
+ readonly changes: Effect.Effect<PubSub.Subscription<Entry>, never, Scope.Scope>
566
+ readonly project: <S, E, R>(
567
+ projection: Projection<S, E, R>,
568
+ options: StreamOptions
569
+ ) => Stream.Stream<S, JournalError, R>
570
+ readonly flush: Effect.Effect<void, JournalError>
571
+ /**
572
+ * Durably records the state that replays the run from `options.seq`.
573
+ *
574
+ * The write shares `transact`'s discipline: it runs through the same
575
+ * `DurableWriter`, so inside an open `transact` it joins the caller's
576
+ * transaction as a savepoint and rolls back with it. `options.seq` must
577
+ * name a committed entry and must lie above the run's compaction floor;
578
+ * otherwise the write fails with `checkpoint_invalid`. Re-checkpointing an
579
+ * uncompacted `seq` replaces its state: last writer wins.
580
+ *
581
+ * The write is fenced on the run's ownership through the consensus
582
+ * strategy, exactly as `emitDurable` is: a reclaimed run fails with
583
+ * `fence_lost`. The fence is mandatory: a zombie owner must not replace
584
+ * replay state behind a live successor.
585
+ */
586
+ readonly checkpoint: (options: CheckpointOptions, owner: OwnerId) => Effect.Effect<Checkpoint, JournalError>
587
+ /**
588
+ * Reads the run's most recent checkpoint, compacted or not.
589
+ *
590
+ * A reader that fails with `compacted` resyncs here: apply
591
+ * `checkpoint.state`, then continue from
592
+ * `stream({ runId, afterSequence: checkpoint.seq })`.
593
+ */
594
+ readonly latestCheckpoint: (runId: RunId) => Effect.Effect<Option.Option<Checkpoint>, JournalError>
595
+ /**
596
+ * Deletes the run's entries strictly below a checkpoint, atomically with
597
+ * advancing the run's compaction floor.
598
+ *
599
+ * Refusals are typed: `checkpoint_invalid` when the run has no checkpoint
600
+ * to truncate below, `reader_behind` when a live in-process stream still
601
+ * needs a sequence the truncation would delete, and `fence_lost` when the
602
+ * supplied `owner` no longer holds the run. The fence is mandatory, so a
603
+ * zombie owner can never truncate history behind a live successor. Readers
604
+ * this process cannot see (pollers of `entries` and followers in other
605
+ * processes) are protected by the read-side guard instead: any read whose cursor starts
606
+ * below the floor fails with `compacted` and the floor to resync from,
607
+ * never a silently shortened history.
608
+ */
609
+ readonly compact: (options: CompactOptions, owner: OwnerId) => Effect.Effect<Compacted, JournalError>
610
+ }
611
+
612
+ /**
613
+ * Context service for durable lifecycle evidence and lossy telemetry.
614
+ *
615
+ * @category services
616
+ * @since 0.1.0
617
+ */
618
+ export class Journal extends Context.Service<Journal, Service>()("@smthrs/journal/Journal") {}
619
+
620
+ /**
621
+ * Constructs a journal service from an implementation.
622
+ *
623
+ * @category constructors
624
+ * @since 0.1.0
625
+ */
626
+ export const make = (implementation: Service): Service => Journal.of(implementation)
627
+
628
+ const unavailable = (method: string): JournalError =>
629
+ new JournalError({
630
+ code: "journal_closed",
631
+ message: `${method} is unavailable`
632
+ })
633
+
634
+ /**
635
+ * Constructs a closed journal stub, optionally overriding individual methods.
636
+ *
637
+ * @category constructors
638
+ * @since 0.1.0
639
+ */
640
+ export const makeNoop = (overrides: Partial<Service> = {}): Service => {
641
+ const service: Service = {
642
+ emitLossy: Effect.fn("Journal.emitLossy")(() => Effect.fail(unavailable("emitLossy"))),
643
+ emitDurable: Effect.fn("Journal.emitDurable")(() => Effect.fail(unavailable("emitDurable"))),
644
+ emitDurableUnfenced: Effect.fn("Journal.emitDurableUnfenced")(() =>
645
+ Effect.fail(unavailable("emitDurableUnfenced"))
646
+ ),
647
+ /**
648
+ * The closed stub has no sink and therefore no transaction to open, so it
649
+ * runs the effect directly, the same posture as
650
+ * `DurableEngineState`'s in-memory twin, whose `transaction` has no crash
651
+ * window to close. A test double that models rollback overrides it.
652
+ */
653
+ transact: <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E | JournalError, R> => effect,
654
+ whenCommitted: (update) => Effect.as(update, true),
655
+ stream: (options) =>
656
+ Stream.unwrap(
657
+ Effect.fn("Journal.stream")((_options: StreamOptions) => Effect.succeed(Stream.fail(unavailable("stream"))))(
658
+ options
659
+ )
660
+ ),
661
+ entries: Effect.fn("Journal.entries")(() => Effect.fail(unavailable("entries"))),
662
+ changes: Effect.acquireRelease(
663
+ PubSub.sliding<Entry>(1),
664
+ PubSub.shutdown
665
+ ).pipe(Effect.flatMap(PubSub.subscribe)),
666
+ project: (projection, options) =>
667
+ Stream.unwrap(
668
+ Effect.fn("Journal.project")(
669
+ <S, E, R>(_projection: Projection<S, E, R>, _options: StreamOptions) =>
670
+ Effect.succeed(Stream.fail(unavailable("project")))
671
+ )(projection, options)
672
+ ),
673
+ flush: Effect.fn("Journal.flush")(() => Effect.fail(unavailable("flush")))(),
674
+ checkpoint: Effect.fn("Journal.checkpoint")(() => Effect.fail(unavailable("checkpoint"))),
675
+ latestCheckpoint: Effect.fn("Journal.latestCheckpoint")(() => Effect.fail(unavailable("latestCheckpoint"))),
676
+ compact: Effect.fn("Journal.compact")(() => Effect.fail(unavailable("compact")))
677
+ }
678
+ return Journal.of({ ...service, ...overrides })
679
+ }
680
+
681
+ /**
682
+ * Provides a closed journal stub.
683
+ *
684
+ * @category layers
685
+ * @since 0.1.0
686
+ */
687
+ export const layerNoop = (overrides: Partial<Service> = {}): Layer.Layer<Journal> =>
688
+ Layer.succeed(Journal)(makeNoop(overrides))