@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,157 @@
1
+ ---
2
+ title: "Checkpoints and compaction"
3
+ description: "How a checkpoint pins replay state to a sequence, what the compaction floor guarantees, and why a reader below the floor gets a typed compacted failure instead of a shortened history."
4
+ sidebar:
5
+ order: 5
6
+ ---
7
+
8
+ A run that never stops growing eventually costs more to replay than it is worth.
9
+ Compaction is how the journal drops old entries without ever handing a reader a
10
+ history that is quietly missing its beginning.
11
+
12
+ Compaction is off by default. Without a `compaction` policy on the layer, the
13
+ journal never deletes an entry, and checkpointing stays a call you make.
14
+
15
+ ## A checkpoint is replay state pinned to a sequence
16
+
17
+ `checkpoint({ runId, seq, state }, owner)` durably records the caller's own
18
+ replay state at a committed sequence. The journal never interprets `state`; it
19
+ round-trips verbatim, and redaction deliberately does not apply to it for the
20
+ same reason it does not apply to any other executable state.
21
+
22
+ The contract on `seq` is what makes replay work: a checkpoint at `seq` must
23
+ subsume every entry at or below `seq`, so replay is `state` plus
24
+ `stream({ runId, afterSequence: seq })`.
25
+
26
+ `seq` must also name a committed entry of the run, and it must lie above the
27
+ run's compaction floor. Otherwise the write fails `checkpoint_invalid`.
28
+ Re-checkpointing an uncompacted `seq` replaces its state: last writer wins.
29
+
30
+ The committed entry at `seq` is what survives compaction, and it survives for a
31
+ mechanical reason. It holds the run's durable `MAX(seq)` allocation floor at or
32
+ above the compaction boundary, so a process restarted after a compaction can
33
+ never re-allocate a truncated sequence.
34
+
35
+ ## The floor is what a reader sees
36
+
37
+ `compact({ runId, upTo }, owner)` deletes the run's entries strictly below a
38
+ checkpoint and advances the run's compaction floor, atomically. Omit `upTo` to
39
+ use the run's latest checkpoint.
40
+
41
+ `compacted_at_ms` on a checkpoint row is null until a compaction has truncated
42
+ below it. The largest compacted `seq` for a run is that run's compaction floor.
43
+
44
+ Every read whose cursor starts below the floor fails with `compacted`, carrying
45
+ the floor in `checkpointSeq`. `entries` and `stream` behave identically here, so
46
+ a poller and a follower cannot disagree about what history exists. A reader
47
+ never receives a silently shortened history.
48
+
49
+ The read order is part of the guarantee. `entries` reads the page first and the
50
+ floor second, because truncation and the floor advance commit together: any
51
+ deletion that could have shortened the page is visible in the later floor read.
52
+ Reading the floor first would let a compaction commit between the two reads and
53
+ hand back a gapped history.
54
+
55
+ ## Two kinds of reader, two kinds of protection
56
+
57
+ - **Readers this process can see.** A live in-process `stream` registers its
58
+ durable cursor, and `compact` refuses to truncate a sequence one of them
59
+ still needs, failing `reader_behind` with the checkpoint it declined to
60
+ truncate below.
61
+ - **Readers this process cannot see.** A poller of `entries`, or a follower in
62
+ another process, is covered by the read-side `compacted` guard instead. There
63
+ is no way to register their cursors, so the answer is a typed failure and a
64
+ resync point rather than a refusal to compact.
65
+
66
+ ## Resyncing
67
+
68
+ A reader that fails with `compacted` recovers in two calls: read
69
+ `latestCheckpoint(runId)`, apply `checkpoint.state`, then continue from
70
+ `stream({ runId, afterSequence: checkpoint.seq })`.
71
+
72
+ `latestCheckpoint` returns the run's most recent checkpoint whether or not it
73
+ has been compacted, so it is the resync point in both cases.
74
+
75
+ ## The automatic policy
76
+
77
+ `SqlJournalOptions.compaction` turns the manual calls into a policy:
78
+
79
+ ```ts
80
+ import { SqlJournal } from "@smthrs/journal"
81
+
82
+ const layer = SqlJournal.layer({
83
+ capacity: 1024,
84
+ overflow: "reject",
85
+ compaction: {
86
+ entryThreshold: 5_000,
87
+ capture: (runId, upTo) => captureReplayState(runId, upTo)
88
+ }
89
+ })
90
+ ```
91
+
92
+ Once a run's committed entry count reaches `entryThreshold`, the journal asks
93
+ `capture` for the caller's replay state at the run's durable tail, writes it as
94
+ a checkpoint at that sequence, and compacts below it.
95
+
96
+ The policy is the journal's own post-commit maintenance rather than a caller's
97
+ entrypoint, so it holds no fence and needs no lease. It only ever
98
+ truncates below a tail the run's own commits produced, and a retry after a
99
+ reclaim compacts the same committed prefix the live owner sees.
100
+
101
+ Three properties of the hook matter when you write `capture`:
102
+
103
+ - It runs after the triggering commit's allocation permit is free. Lossy
104
+ commits schedule one scoped maintenance fiber per compacting run, so capture
105
+ and compaction never occupy the queue-draining fiber. Capture may read or
106
+ emit through the journal while other runs continue committing.
107
+ - It is interrupted after 30 seconds, so caller code cannot wedge journal
108
+ admission indefinitely.
109
+ - A failed or refused attempt, whether a live stream behind the boundary or a
110
+ `capture` failure, is logged at warning, damped for `entryThreshold` further
111
+ committed entries while the run's policy counter is retained, and never
112
+ surfaced to the emit that triggered it.
113
+
114
+ `flush` waits for queued writes and registered automatic maintenance attempts.
115
+ The attempt is registered before its triggering batch settles, so flush cannot
116
+ miss it. Scope closure flushes before interrupting the maintenance scope.
117
+ Durable emits still await their own policy attempt after commit.
118
+
119
+ Flush also retires per-run barriers and policy counters when there are no
120
+ admissions, writes, queued entries, barrier waiters, live readers, or maintenance
121
+ using the run. A later commit seeds its policy counter from durable history
122
+ again, including after a previously damped failure. Sequence allocation floors
123
+ remain in memory: dropped entries and rolled-back transactions can consume
124
+ sequences that cannot be recovered from durable rows. Retirement does not
125
+ permit those sequences to be reused.
126
+
127
+ ## Retries are idempotent
128
+
129
+ A `compact` whose checkpoint is already the floor returns `deleted: 0` rather
130
+ than failing. The rows below it are already gone.
131
+
132
+ ## Producer identities survive compaction
133
+
134
+ Compaction retains each deleted event's producer identity, event ID, original
135
+ canonical sequence, and SHA-256 fingerprint of its encoded type, payload, and
136
+ metadata in `flows_journal_dedup`. These records commit in the same transaction
137
+ as event deletion and the floor advance. They contain no replay payload.
138
+
139
+ An exact producer retry returns `Duplicate` with the original sequence, even
140
+ after reopening the database. Changed content fails `idempotency_conflict`;
141
+ `dedupe: "identity"` keeps its identity-only behavior. Queued retries cannot
142
+ append another row or enter the checkpoint suffix. Owner fencing still applies.
143
+ The retained identities also preserve each producer's automatic `sourceSeq`
144
+ allocation floor when all its event rows have been compacted.
145
+
146
+ Identity records have no expiry and survive later compactions. Storage therefore
147
+ remains proportional to the number of compacted identities, while event payloads
148
+ can be reclaimed. Migration `0004_dedup` adds this retention for subsequent
149
+ compactions; identities deleted before the upgrade cannot be reconstructed.
150
+
151
+ ## Related reading
152
+
153
+ - [Compact a long-running run](../guides/compact-a-run.md) is the task-shaped
154
+ version of this page.
155
+ - [Run a read-only follower](/docs/guides/sync-followers/) covers a follower
156
+ that meets the floor.
157
+ - [Retention](/docs/guides/retention/) covers what a deployment keeps overall.
@@ -0,0 +1,127 @@
1
+ ---
2
+ title: "Producer identity and idempotency"
3
+ description: "What makes two emissions the same event, why the comparison runs on canonical redacted bytes, what the two dedupe modes mean, and why the in-process index is a cache rather than the authority."
4
+ sidebar:
5
+ order: 3
6
+ ---
7
+
8
+ A producer that retries must not double an entry. The journal answers that with
9
+ one identity and one comparison, both of which are persisted facts rather than
10
+ in-memory bookkeeping.
11
+
12
+ ## The identity
13
+
14
+ `(runId, sourceId, sourceSeq)` is the producer identity. It is unique in the
15
+ database, and `JournalEvent.makeEventId` derives the durable `eventId` from it
16
+ deterministically, with length prefixes so an identifier containing the
17
+ separator cannot forge another tuple's id.
18
+
19
+ Deterministic is the point: retrying the same source event must produce the
20
+ same durable id, so the unique index can collapse the retry onto the committed
21
+ row instead of storing it twice.
22
+
23
+ ## What makes two emissions the same event
24
+
25
+ Two emissions under one identity are the same event when their `eventType` and
26
+ their canonically encoded, redacted `payload` and `meta` match.
27
+
28
+ Canonical encoding is `JSON.stringify` semantics with object keys sorted, so
29
+ key order does not change the answer. A `Date` is its ISO string, an
30
+ `undefined` member is dropped, and `NaN` is `null`. The sort runs over the
31
+ encoded JSON rather than over the raw value, so canonicalization cannot destroy
32
+ anything the encoder would have kept.
33
+
34
+ Two consequences follow from comparing the persisted, redacted bytes, and both
35
+ are deliberate:
36
+
37
+ - Two different secrets that redact to the same placeholder are the same event
38
+ to the journal. Comparing the pre-redaction value instead would keep
39
+ unredacted secrets resident in the in-process index, which is the leak this
40
+ package exists to prevent.
41
+ - `NaN` and `null` are the same value, because JSON says so.
42
+
43
+ ## The two dedupe modes
44
+
45
+ `Input.dedupe` states what a re-emitted identity means. It defaults to
46
+ `content`.
47
+
48
+ `content` is the strict reading: the identity names one set of bytes, so a
49
+ producer that re-emits it with different bytes has a bug, and the journal says
50
+ so with `idempotency_conflict`.
51
+
52
+ `identity` is for a producer that derives the sequence from the event itself.
53
+ There a collision is the same event observed twice, and the bytes that differ
54
+ between the two observations are metadata about the observation rather than the
55
+ event: whether a replayed frame was re-recorded, how long a step took the
56
+ second time a durable engine served it from its record. The first admitted row
57
+ stands and the re-emission settles as `Duplicate`.
58
+
59
+ Declare `identity` only with a sequence derived from the event's own content.
60
+ Otherwise the journal has nothing left with which to notice two different
61
+ events wearing one identity.
62
+
63
+ ## The index is a cache, not the authority
64
+
65
+ `SqlJournalOptions.sourceEventCache` (default 4096) bounds the in-process index
66
+ that answers producer idempotency from memory. The database unique constraint
67
+ on `(run_id, source_id, source_seq)` stays authoritative, so a miss changes the
68
+ receipt, not the durable answer.
69
+
70
+ On a miss for an explicit producer sequence, the entry is admitted
71
+ optimistically without a SQL deduplication lookup:
72
+
73
+ - `emitLossy` returns `Accepted` where a resident entry would have returned
74
+ `Duplicate`.
75
+ - The insert collapses onto the committed row through the unique index rather
76
+ than doubling it.
77
+ - A changed retry behind an evicted entry surfaces its `idempotency_conflict`
78
+ from `flush` instead of from the emit.
79
+
80
+ The deduplication lookup is deliberately deferred to the writer. Allocation
81
+ can still read before admission: a cold run needs `MAX(seq) + 1`, and an
82
+ omitted `sourceSeq` with an uncached producer floor also needs
83
+ `MAX(source_seq) + 1` for `(runId, sourceId)`. Only warmed allocation floors
84
+ make admission read-free; an explicit producer sequence avoids only the
85
+ producer-floor read.
86
+
87
+ These reads use the caller's SQL transaction context when the client is shared.
88
+ A caller holding the connection in another transaction without propagating
89
+ that context can deadlock on a cold admission. See
90
+ [the two channels](./two-channels.md) for supported transaction compositions
91
+ and the separate commit of queued lossy entries.
92
+
93
+ Bounding the index also bounds startup. Only the most recent
94
+ `sourceEventCache` events are decoded when the layer is built, so resident
95
+ memory and startup cost are proportional to the bound rather than to the total
96
+ history ever written.
97
+
98
+ ## Identifiers are the persisted key
99
+
100
+ `RunId`, `SourceId`, and both an `Input`'s and an `Entry`'s `eventType` are
101
+ non-empty, at most 1,024 UTF-16 code units, free of unpaired UTF-16 surrogates,
102
+ and free of NUL. Each rule closes a way for the identifier the caller decoded
103
+ and the identifier the database stores to stop being the same value:
104
+
105
+ - SQLite binds a lone surrogate as U+FFFD, so two ill-formed identifiers that
106
+ differ only in their surrogates land on one persisted key. The second run's
107
+ first event would dedupe into the first run's row, and a read by either id
108
+ would return the same history.
109
+ - SQLite's `length()` stops at the first NUL, so a NUL-bearing identifier fails
110
+ the column's own non-empty check and the caller is told the sink failed about
111
+ an identifier it had just supplied.
112
+ - The length bound exists because every identifier occupies permanent index
113
+ space and layer-lifetime heap.
114
+
115
+ Valid astral text is ordinary text and round-trips exactly. `entries`,
116
+ `stream`, `checkpoint`, `latestCheckpoint`, and `compact` decode the same
117
+ schemas, so an identifier the writer refuses is refused on every read with
118
+ `invalid_event` rather than answered with an empty page.
119
+
120
+ ## Reading a receipt
121
+
122
+ `Duplicate` carries a `status`, which is the one piece of information the tag
123
+ alone does not give you:
124
+
125
+ - `pending` means the original event is in the lossy queue and not yet
126
+ committed.
127
+ - `committed` means the original is already durable.
@@ -0,0 +1,124 @@
1
+ ---
2
+ title: "The owner fence"
3
+ description: "Why the durable channel takes an OwnerId, what makes a write fail with fence_lost, and when the unfenced channel is the correct one."
4
+ sidebar:
5
+ order: 2
6
+ ---
7
+
8
+ A run can outlive the process that claimed it. A machine pauses, a supervisor
9
+ declares the run abandoned, another process takes it over, and then the first
10
+ process wakes up holding a half-finished lifecycle write. Without a fence, that
11
+ zombie write lands behind the live successor and the run's history says two
12
+ processes were driving it.
13
+
14
+ The journal refuses that write. `emitDurable`, `checkpoint`, and `compact` each
15
+ take an `OwnerId`, and each lands only while the consensus strategy the
16
+ journal was built with still records that owner as holding the run.
17
+
18
+ ## The token
19
+
20
+ `OwnerId.OwnerId` is three fields:
21
+
22
+ ```ts
23
+ import type * as OwnerId from "@smthrs/journal/OwnerId"
24
+
25
+ const owner: OwnerId.OwnerId = {
26
+ hostId: "host-1",
27
+ pid: process.pid,
28
+ nonce: "run-1-claim"
29
+ }
30
+ ```
31
+
32
+ `hostId` and `nonce` are plain strings. `pid` is a real operating-system
33
+ process id, so the schema states that: a non-negative integer.
34
+
35
+ The token lives in this package rather than with the ownership arbitration in
36
+ [`@smthrs/run-store`](/api/run-store) because the journal is what it fences.
37
+ The run store stores the token on runs and decides who holds it, and its
38
+ `Ownership` module re-exports `OwnerId` alongside that arbitration.
39
+
40
+ ## What the fence actually checks
41
+
42
+ A fenced write joins the injected `Consensus` strategy's `guard` inside its
43
+ own write transaction. The guard succeeds while the strategy records the
44
+ supplied token as the run's owner and fails `fence_lost` otherwise, and
45
+ because the durable writer serializes write transactions no reclaim can
46
+ commit between the guard and the statements beside it.
47
+
48
+ Two consequences follow:
49
+
50
+ - A run that another process reclaimed fails the write with `fence_lost`.
51
+ - A run whose owner released it, because it suspended or finished, fails the
52
+ same way. A finished run does not accept late lifecycle writes.
53
+
54
+ The strategy is the journal's own service. `SqlJournal.layer` fences through
55
+ `SqlConsensus`, whose lease lives in `flows_consensus_leases`, a table this
56
+ package's migrations create; `SqlJournal.layerWith` fences through whatever
57
+ strategy the composition provides, such as `Consensus.layerLocal` for a
58
+ single process. The same instance must arbitrate the run store's claims, which
59
+ is why `RunStore.layerWith` takes the strategy too.
60
+
61
+ ## Who arbitrates
62
+
63
+ The journal owns the rules: one writer per run, fenced and unfenced appends,
64
+ commit-time admission, the generation fence, steal only on staleness plus
65
+ liveness evidence, and ownership transitions as events. A strategy chooses
66
+ where the lease lives and how the fence is checked. The claim lifecycle is
67
+ two-phase — `claim` or `steal` reserves the run and returns a grant
68
+ timestamp, `activate` presents that grant to take ownership, `release` gives
69
+ it back, and `recover` clears a dead claimant's stale claim — and
70
+ [`@smthrs/run-store`](/api/run-store) drives it for a durable run, mirroring
71
+ the outcome on the run row in the same transaction.
72
+
73
+ The run store appends every transition the strategy grants — `claimed`,
74
+ `activated`, `released`, `stolen`, `expired` — as a `flows.consensus.*` fact on
75
+ the run's `run-store` companion stream (`JournalEvent.companionRunId`), in the
76
+ transaction that made it. The facts live beside the run's own stream, so a
77
+ consumer reading that stream by position never sees one. Heartbeats renew the
78
+ lease and never enter the journal.
79
+
80
+ ## Reconfirm after a host stall
81
+
82
+ `Consensus.reconfirm(runId, owner, nowMs)` returns `Renewed` only while the
83
+ same owner holds the lease and no claim is pending. Its compare-and-swap
84
+ renews the heartbeat monotonically. A missing lease, foreign owner, or
85
+ pending claim returns `Lost` without modifying the lease.
86
+
87
+ `SqlConsensus` performs that check in one update; `layerLocal` performs it
88
+ atomically in memory. If reconfirm wins a race with `steal`, the refreshed
89
+ heartbeat prevents the steal. If steal wins, its pending claim prevents
90
+ reconfirm even before the new owner activates. Reconfirm preserves the
91
+ owner token and generation. The engine records a `lease-reconfirmed`
92
+ decision when supervision uses it to recover from an unconfirmed interval.
93
+
94
+ ## A bad token is not a lost fence
95
+
96
+ An owner that is missing, null, or not an `OwnerId` at all fails
97
+ `invalid_event`. That is a caller contract violation, and reporting it as
98
+ `fence_lost` would send the caller hunting a race that never happened. A
99
+ fractional or negative `pid` is the common case.
100
+
101
+ Read the two codes as different questions:
102
+
103
+ - `fence_lost` asks who owns this run now. Stop writing.
104
+ - `invalid_event` asks what you passed. Fix the argument.
105
+
106
+ ## The unfenced escape
107
+
108
+ `emitDurableUnfenced` is the same durability and the same receipt contract with
109
+ no fence. It exists for admissions that are genuinely ownerless, where
110
+ first-writer-wins is the design rather than an accident. The canonical case is
111
+ an external trigger: a deferred completion or a clock-schedule record delivered
112
+ by a sweeper that owns no run, where the producer dedup index rather than the
113
+ fence is the idempotency mechanism.
114
+
115
+ A caller that holds an `OwnerId` uses `emitDurable`. Reaching for the unfenced
116
+ channel to get past a `fence_lost` writes exactly the zombie entry the fence
117
+ exists to reject.
118
+
119
+ ## Related reading
120
+
121
+ - [Write a fenced lifecycle event](../guides/write-lifecycle-events.md) is the
122
+ task-shaped version of this page.
123
+ - [Execution IDs and ownership](/docs/concepts/ownership/) covers how a run
124
+ acquires and loses the token in the first place.
@@ -0,0 +1,218 @@
1
+ ---
2
+ title: "Redaction"
3
+ description: "Why the journal scrubs payloads on the write path, what the default rules catch, where redaction deliberately stops, and the bounds that keep the scrub safe on hostile input."
4
+ sidebar:
5
+ order: 4
6
+ ---
7
+
8
+ A journal row is permanent and broadly readable. Every entry is replayed
9
+ verbatim to sync followers, to time-travel consumers, and into a support
10
+ bundle. A credential that reaches `payload_json` is therefore not a transient
11
+ leak, and no reader can undo it.
12
+
13
+ So the journal redacts once, on the write path. Every write funnels through one
14
+ preparation step, and that step scrubs `payload` and `meta` before they are
15
+ encoded. No channel can persist a credential.
16
+
17
+ ## What the default rules catch
18
+
19
+ Redaction has two halves.
20
+
21
+ **Structural.** A field whose name reads as a credential has its value replaced
22
+ wholesale, whatever the value is: `authorization`, `cookie`, `apiKey`, `token`,
23
+ `password`, `passphrase`, `secret`, `secretAccessKey`, `credential`, `dsn`,
24
+ `connectionString`, `privateKey`, `sshKey`, `session`, `signature`, `auth`, and
25
+ the rest of that family, plus a trailing `key` that is a word of its own (`key`,
26
+ `api_key`, `x-api-key`). `Redaction.isSensitiveKey` is the predicate, and
27
+ `Redaction.placeholder` is the string that lands, `"[REDACTED]"`.
28
+
29
+ The match is a suffix test on the separator-free lowercase form of the name,
30
+ not a substring test, so `tokenizer`, `secretary`, `monkey`, and
31
+ `idempotencyKey` stay intact. Redacting them would destroy data in a permanent
32
+ row without protecting anything, and `idempotencyKey` in particular is the
33
+ durable identity an effect boundary replays on. Token counters are kept too: a
34
+ non-negative safe integer under `inputTokens`, `outputTokens`,
35
+ `cachedInputTokens`, `reasoningTokens`, or `totalTokens` is accounting, not
36
+ bearer material.
37
+
38
+ **Textual.** `Redaction.defaultRules` scans inside any string for credential
39
+ shapes seen in real bug reports: provider keys, bearer tokens, GitHub, AWS,
40
+ Slack, and Google credentials, URL passwords, Basic authorization, bare JWTs,
41
+ PEM private-key blocks, INI/YAML credential assignments such as
42
+ `SECRET = value` and `password: value`, credential flags such as
43
+ `--password value`, cookie and session assignments, and embedded JSON
44
+ credential members.
45
+
46
+ An assignment's value is read by a scanner rather than a regular expression,
47
+ because where a value ends depends on quotes, escapes, and brackets. Each shape
48
+ below once leaked the part a simpler rule stopped short of:
49
+
50
+ | Shape | Example |
51
+ | ------------------------------------------ | ------------------------------------------------- |
52
+ | Quoted, in any quote `util.inspect` writes | `` password: `it's "a b"` `` |
53
+ | Split by `util.inspect` over several lines | `privateKey: '…\n' +` then `'…'` |
54
+ | Escaped JSON, at any depth | `\"password\":\"a b\"` |
55
+ | A buffer, a cycle, a null prototype | `token: <Buffer 73 65>`, `apiKey: <ref *1> { … }` |
56
+ | A container, brackets in its strings too | `apiKey: { note: '} ', v: '…' }` |
57
+ | A `,` or `;` inside the value | `DB_PASSWORD=Zq7;Syn,thetic` |
58
+
59
+ A quoted value keeps its quotes around the placeholder
60
+ (`PASSWORD="[REDACTED]"`). A name ending in `key` counts only where
61
+ `isSensitiveKey` agrees, so `sortKey` and `idempotencyKey` keep their values,
62
+ and a count under a plural `tokens` name (`max_tokens: 4096`) is kept.
63
+ A credential name that is a URL's user
64
+ (`https://x-access-token:…@github.com/acme/app.git`) takes only the password
65
+ before `@`, so the host and path a failed clone is diagnosed by are kept.
66
+
67
+ In a journal row a bare value is one token, and a string or container the text
68
+ never closes stops at the end of its line: a stray backtick in an agent's
69
+ Markdown must not erase the rest of a permanent row. A PEM or PGP private-key
70
+ block with no footer is redacted to the next header or to the end of the text.
71
+ In an argv array, the element after a credential flag is redacted.
72
+
73
+ The rule set is a best-effort net, not a proof. A value that must never persist
74
+ belongs in a `Redacted` field of the caller's own schema, where the type
75
+ system, rather than a name-suffix guess made at the storage seam, keeps it out.
76
+
77
+ Objects and arrays are rebuilt, a cycle collapses to `"[Circular]"` so the
78
+ result always encodes, and a number, a boolean, `null`, or `undefined` is
79
+ returned untouched. The rules are re-applied to a string until it stops
80
+ changing, so replaying or exporting an entry cannot mutate it again; a string
81
+ that has not settled after eight passes is replaced whole.
82
+
83
+ ## Diagnostics
84
+
85
+ A log line, a stderr message, and an error handed to a remote caller are read
86
+ once, so a false positive there costs a word of context rather than data in a
87
+ permanent row. `Redaction.diagnosticRules` reads further than the journal: a
88
+ bare value runs over several words to the end of its line
89
+ (`password=correct horse battery`), an unclosed string or container runs to the
90
+ end of the text (a value cut by a bound), and a value may sit on the line after
91
+ its name. It adds an authorization or cookie header in any scheme, written as
92
+ `name: value` or as an array pair (`[['Authorization', 'Token …']]`). Whitespace
93
+ `Authorization Token value` and `Proxy-Authorization Token value` forms also
94
+ match, with Token, Bearer, or Basic schemes and the value on the scheme's line.
95
+ It adds bare URL userinfo, a signed query parameter, `curl -u user:pass` (or `-uuser:pass`),
96
+ `zip -P`, `redis-cli -a`, and a `-p` flag's value, a password to `mysql` and
97
+ `sshpass`. A path after `-p` and the single-dash words that are not passwords
98
+ (`-print`, `-pthread`) are kept.
99
+ `Redaction.redactDiagnostic` applies them, and every diagnostic path in
100
+ Smithers shares it: `RedactedLogger`, the engine's failure rendering, and the
101
+ CLI's error output.
102
+
103
+ Redact a diagnostic before rendering or bounding it. Redacting a rendering line
104
+ by line, or after a bound, is how a key's body escaped. A stream that must be
105
+ emitted line by line uses `Redaction.lineRedactor()`: it holds lines while the
106
+ rules say a value is still open and redacts the block whole once it closes. A
107
+ value still open after `Redaction.maxHeldLines` lines or 64 KiB withholds the
108
+ rest of the stream, and `flush` emits one placeholder for it.
109
+
110
+ ```ts
111
+ import { Redaction } from "@smthrs/journal"
112
+
113
+ const redactor = Redaction.lineRedactor()
114
+ const emit = (line: string) => redactor.line(line).forEach((safe) => process.stderr.write(`${safe}\n`))
115
+ // at the end of the stream
116
+ redactor.flush().forEach((safe) => process.stderr.write(`${safe}\n`))
117
+ ```
118
+
119
+ A custom rule whose extent is not regular sets `rewrite: (text) => string`
120
+ instead of `replace`; `pattern` then names what it looks for.
121
+
122
+ ## Where redaction stops
123
+
124
+ Redaction is an observability concern, and it is confined to journal events and
125
+ to display surfaces.
126
+
127
+ It deliberately does not apply to executable state: `flows_runs.state_json`,
128
+ attempt checkpoints, errors, outcomes, cache results, and a journal
129
+ `Checkpoint`'s own `state`. Those values are decoded and re-entered on resume.
130
+ A placeholder there resumes the flow with the wrong data, and replacing a
131
+ non-string value with a placeholder string makes the persisted state fail
132
+ schema decode outright, which leaves the run undrivable.
133
+
134
+ That is why [`@smthrs/run-store`](/api/run-store) and
135
+ [`@smthrs/step-cache`](/api/step-cache) take no `redact` option at all.
136
+
137
+ ## Choosing a redactor
138
+
139
+ `SqlJournalOptions.redact` defaults to `Redaction.make()`. Two other choices
140
+ are supported:
141
+
142
+ ```ts
143
+ import { Redaction, SqlJournal } from "@smthrs/journal"
144
+
145
+ const verbatim = SqlJournal.layer({
146
+ capacity: 1024,
147
+ overflow: "reject",
148
+ redact: Redaction.makeNoop()
149
+ })
150
+
151
+ const extended = SqlJournal.layer({
152
+ capacity: 1024,
153
+ overflow: "reject",
154
+ redact: Redaction.make({
155
+ rules: [...Redaction.defaultRules, { id: "internal-ticket", pattern: /\bTKT-\d{6}\b/g }]
156
+ })
157
+ })
158
+ ```
159
+
160
+ `Redaction.makeNoop()` persists payloads verbatim by choice, for a trusted
161
+ single-tenant store or a suite asserting on raw input. A custom rule set
162
+ replaces the defaults, so spread `Redaction.defaultRules` when you mean to add
163
+ to them rather than to swap them out. A non-global custom pattern is normalized
164
+ to global once, and a sticky one keeps its stickiness.
165
+
166
+ ## Bounds on the scrub
167
+
168
+ Redaction runs on every write, so it is bounded against hostile input rather
169
+ than trusted to terminate:
170
+
171
+ - **Depth.** The walk traverses at most `Redaction.maxDepth` (256) container
172
+ edges and fails a deeper payload as `invalid_event` rather than overflowing
173
+ the stack. The canonical encoder carries the same ceiling, so a value that
174
+ reaches it through `Redaction.makeNoop()` is refused the same way.
175
+ - **Binary views.** Enumerating a typed array's own properties materializes one
176
+ pair per byte, so a view over `Redaction.binaryWalkLimit` (65,536) bytes, or
177
+ one carrying that many own members, is named rather than walked. The size is
178
+ read from the value's own internal slot, where nothing a caller writes can
179
+ answer for it.
180
+ - **Rule cost.** Every alternation inside a repetition starts its branches
181
+ with disjoint characters, so each rule is linear in the length of the value
182
+ and no input backtracks catastrophically.
183
+
184
+ A redaction that throws, from the redactor itself or from a getter or proxy
185
+ trap on the value, fails the write as `invalid_event` with no `cause`. The
186
+ thrown value can quote the unredacted input, so the error carries only which
187
+ field, `payload` or `meta`, could not be redacted.
188
+
189
+ Values a JSON row cannot hold are named rather than dropped:
190
+ `Redaction.binaryMarker`, `Redaction.functionMarker`,
191
+ `Redaction.symbolMarker`, and `Redaction.depthMarker`.
192
+
193
+ ## Rendering a stored column
194
+
195
+ `Redaction.redactJsonString` scrubs an already-encoded JSON string at a display
196
+ surface, leaving the durable row untouched. Use it for a rendered `state_json`
197
+ or a support bundle, where the value is already encoded and must not be decoded
198
+ back into the executable path.
199
+
200
+ ```ts
201
+ import { Redaction } from "@smthrs/journal"
202
+
203
+ const redactor = Redaction.make()
204
+ const forDisplay = (stateJson: string): string => Redaction.redactJsonString(stateJson, redactor)
205
+ ```
206
+
207
+ A string that does not parse is returned untouched: validation belongs to the
208
+ caller, and rejecting here would turn a redaction concern into a schema error.
209
+ Once parsing succeeds the function fails closed, returning the JSON string
210
+ `"[REDACTED]"` rather than the original text, if the redactor throws or the
211
+ result cannot be re-encoded.
212
+
213
+ ## The other half: log output
214
+
215
+ The journal closes the durable half. The operator's terminal is the other place
216
+ the same credential surfaces, and `RedactedLogger` closes it with the same
217
+ rules. See
218
+ [Keep credentials out of log output](../guides/redact-log-output.md).