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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (257) hide show
  1. package/CHANGELOG.md +149 -0
  2. package/LICENSE +21 -0
  3. package/README.md +206 -2
  4. package/dist/cjs/CallFact.d.ts +129 -0
  5. package/dist/cjs/CallFact.d.ts.map +1 -0
  6. package/dist/cjs/CallFact.js +106 -0
  7. package/dist/cjs/CallFact.js.map +7 -0
  8. package/dist/cjs/Consensus.d.ts +440 -0
  9. package/dist/cjs/Consensus.d.ts.map +1 -0
  10. package/dist/cjs/Consensus.js +251 -0
  11. package/dist/cjs/Consensus.js.map +7 -0
  12. package/dist/cjs/EngineEvent.d.ts +890 -0
  13. package/dist/cjs/EngineEvent.d.ts.map +1 -0
  14. package/dist/cjs/EngineEvent.js +383 -0
  15. package/dist/cjs/EngineEvent.js.map +7 -0
  16. package/dist/cjs/ExecutionFact.d.ts +216 -0
  17. package/dist/cjs/ExecutionFact.d.ts.map +1 -0
  18. package/dist/cjs/ExecutionFact.js +303 -0
  19. package/dist/cjs/ExecutionFact.js.map +7 -0
  20. package/dist/cjs/Journal.d.ts +617 -0
  21. package/dist/cjs/Journal.d.ts.map +1 -0
  22. package/dist/cjs/Journal.js +190 -0
  23. package/dist/cjs/Journal.js.map +7 -0
  24. package/dist/cjs/JournalEvent.d.ts +421 -0
  25. package/dist/cjs/JournalEvent.d.ts.map +1 -0
  26. package/dist/cjs/JournalEvent.js +281 -0
  27. package/dist/cjs/JournalEvent.js.map +7 -0
  28. package/dist/cjs/JournalGeneration.d.ts +34 -0
  29. package/dist/cjs/JournalGeneration.d.ts.map +1 -0
  30. package/dist/cjs/JournalGeneration.js +72 -0
  31. package/dist/cjs/JournalGeneration.js.map +7 -0
  32. package/dist/cjs/JournalMetrics.d.ts +72 -0
  33. package/dist/cjs/JournalMetrics.d.ts.map +1 -0
  34. package/dist/cjs/JournalMetrics.js +55 -0
  35. package/dist/cjs/JournalMetrics.js.map +7 -0
  36. package/dist/cjs/Migrations.d.ts +42 -0
  37. package/dist/cjs/Migrations.d.ts.map +1 -0
  38. package/dist/cjs/Migrations.js +58 -0
  39. package/dist/cjs/Migrations.js.map +7 -0
  40. package/dist/cjs/OwnerId.d.ts +40 -0
  41. package/dist/cjs/OwnerId.d.ts.map +1 -0
  42. package/dist/cjs/OwnerId.js +40 -0
  43. package/dist/cjs/OwnerId.js.map +7 -0
  44. package/dist/cjs/Projection.d.ts +29 -0
  45. package/dist/cjs/Projection.d.ts.map +1 -0
  46. package/dist/cjs/Projection.js +25 -0
  47. package/dist/cjs/Projection.js.map +7 -0
  48. package/dist/cjs/RedactedLogger.d.ts +121 -0
  49. package/dist/cjs/RedactedLogger.d.ts.map +1 -0
  50. package/dist/cjs/RedactedLogger.js +235 -0
  51. package/dist/cjs/RedactedLogger.js.map +7 -0
  52. package/dist/cjs/Redaction.d.ts +350 -0
  53. package/dist/cjs/Redaction.d.ts.map +1 -0
  54. package/dist/cjs/Redaction.js +928 -0
  55. package/dist/cjs/Redaction.js.map +7 -0
  56. package/dist/cjs/SqlConsensus.d.ts +44 -0
  57. package/dist/cjs/SqlConsensus.d.ts.map +1 -0
  58. package/dist/cjs/SqlConsensus.js +243 -0
  59. package/dist/cjs/SqlConsensus.js.map +7 -0
  60. package/dist/cjs/SqlJournal.d.ts +173 -0
  61. package/dist/cjs/SqlJournal.d.ts.map +1 -0
  62. package/dist/cjs/SqlJournal.js +1568 -0
  63. package/dist/cjs/SqlJournal.js.map +7 -0
  64. package/dist/cjs/StepFact.d.ts +66 -0
  65. package/dist/cjs/StepFact.d.ts.map +1 -0
  66. package/dist/cjs/StepFact.js +68 -0
  67. package/dist/cjs/StepFact.js.map +7 -0
  68. package/dist/cjs/index.d.ts +95 -0
  69. package/dist/cjs/index.d.ts.map +1 -0
  70. package/dist/cjs/index.js +63 -0
  71. package/dist/cjs/index.js.map +7 -0
  72. package/dist/cjs/internal/JournalInternalFault.d.ts +37 -0
  73. package/dist/cjs/internal/JournalInternalFault.d.ts.map +1 -0
  74. package/dist/cjs/internal/JournalInternalFault.js +43 -0
  75. package/dist/cjs/internal/JournalInternalFault.js.map +7 -0
  76. package/dist/cjs/migrations/0001_initial.d.ts +15 -0
  77. package/dist/cjs/migrations/0001_initial.d.ts.map +1 -0
  78. package/dist/cjs/migrations/0001_initial.js +54 -0
  79. package/dist/cjs/migrations/0001_initial.js.map +7 -0
  80. package/dist/cjs/migrations/0002_checkpoints.d.ts +27 -0
  81. package/dist/cjs/migrations/0002_checkpoints.d.ts.map +1 -0
  82. package/dist/cjs/migrations/0002_checkpoints.js +48 -0
  83. package/dist/cjs/migrations/0002_checkpoints.js.map +7 -0
  84. package/dist/cjs/migrations/0003_startup_index.d.ts +15 -0
  85. package/dist/cjs/migrations/0003_startup_index.d.ts.map +1 -0
  86. package/dist/cjs/migrations/0003_startup_index.js +40 -0
  87. package/dist/cjs/migrations/0003_startup_index.js.map +7 -0
  88. package/dist/cjs/migrations/0004_dedup.d.ts +15 -0
  89. package/dist/cjs/migrations/0004_dedup.d.ts.map +1 -0
  90. package/dist/cjs/migrations/0004_dedup.js +57 -0
  91. package/dist/cjs/migrations/0004_dedup.js.map +7 -0
  92. package/dist/cjs/migrations/0005_run_event_type.d.ts +15 -0
  93. package/dist/cjs/migrations/0005_run_event_type.d.ts.map +1 -0
  94. package/dist/cjs/migrations/0005_run_event_type.js +40 -0
  95. package/dist/cjs/migrations/0005_run_event_type.js.map +7 -0
  96. package/dist/cjs/migrations/0006_consensus.d.ts +22 -0
  97. package/dist/cjs/migrations/0006_consensus.d.ts.map +1 -0
  98. package/dist/cjs/migrations/0006_consensus.js +84 -0
  99. package/dist/cjs/migrations/0006_consensus.js.map +7 -0
  100. package/dist/cjs/package.json +1 -0
  101. package/dist/cjs/test/Notifying.d.ts +65 -0
  102. package/dist/cjs/test/Notifying.d.ts.map +1 -0
  103. package/dist/cjs/test/Notifying.js +62 -0
  104. package/dist/cjs/test/Notifying.js.map +7 -0
  105. package/dist/cjs/test/TestJournal.d.ts +43 -0
  106. package/dist/cjs/test/TestJournal.d.ts.map +1 -0
  107. package/dist/cjs/test/TestJournal.js +43 -0
  108. package/dist/cjs/test/TestJournal.js.map +7 -0
  109. package/dist/esm/CallFact.d.ts +129 -0
  110. package/dist/esm/CallFact.d.ts.map +1 -0
  111. package/dist/esm/CallFact.js +96 -0
  112. package/dist/esm/CallFact.js.map +1 -0
  113. package/dist/esm/Consensus.d.ts +440 -0
  114. package/dist/esm/Consensus.d.ts.map +1 -0
  115. package/dist/esm/Consensus.js +454 -0
  116. package/dist/esm/Consensus.js.map +1 -0
  117. package/dist/esm/EngineEvent.d.ts +890 -0
  118. package/dist/esm/EngineEvent.d.ts.map +1 -0
  119. package/dist/esm/EngineEvent.js +614 -0
  120. package/dist/esm/EngineEvent.js.map +1 -0
  121. package/dist/esm/ExecutionFact.d.ts +216 -0
  122. package/dist/esm/ExecutionFact.d.ts.map +1 -0
  123. package/dist/esm/ExecutionFact.js +374 -0
  124. package/dist/esm/ExecutionFact.js.map +1 -0
  125. package/dist/esm/Journal.d.ts +617 -0
  126. package/dist/esm/Journal.d.ts.map +1 -0
  127. package/dist/esm/Journal.js +323 -0
  128. package/dist/esm/Journal.js.map +1 -0
  129. package/dist/esm/JournalEvent.d.ts +421 -0
  130. package/dist/esm/JournalEvent.d.ts.map +1 -0
  131. package/dist/esm/JournalEvent.js +517 -0
  132. package/dist/esm/JournalEvent.js.map +1 -0
  133. package/dist/esm/JournalGeneration.d.ts +34 -0
  134. package/dist/esm/JournalGeneration.d.ts.map +1 -0
  135. package/dist/esm/JournalGeneration.js +65 -0
  136. package/dist/esm/JournalGeneration.js.map +1 -0
  137. package/dist/esm/JournalMetrics.d.ts +72 -0
  138. package/dist/esm/JournalMetrics.d.ts.map +1 -0
  139. package/dist/esm/JournalMetrics.js +79 -0
  140. package/dist/esm/JournalMetrics.js.map +1 -0
  141. package/dist/esm/Migrations.d.ts +42 -0
  142. package/dist/esm/Migrations.d.ts.map +1 -0
  143. package/dist/esm/Migrations.js +59 -0
  144. package/dist/esm/Migrations.js.map +1 -0
  145. package/dist/esm/OwnerId.d.ts +40 -0
  146. package/dist/esm/OwnerId.d.ts.map +1 -0
  147. package/dist/esm/OwnerId.js +33 -0
  148. package/dist/esm/OwnerId.js.map +1 -0
  149. package/dist/esm/Projection.d.ts +29 -0
  150. package/dist/esm/Projection.d.ts.map +1 -0
  151. package/dist/esm/Projection.js +13 -0
  152. package/dist/esm/Projection.js.map +1 -0
  153. package/dist/esm/RedactedLogger.d.ts +121 -0
  154. package/dist/esm/RedactedLogger.d.ts.map +1 -0
  155. package/dist/esm/RedactedLogger.js +445 -0
  156. package/dist/esm/RedactedLogger.js.map +1 -0
  157. package/dist/esm/Redaction.d.ts +350 -0
  158. package/dist/esm/Redaction.d.ts.map +1 -0
  159. package/dist/esm/Redaction.js +1584 -0
  160. package/dist/esm/Redaction.js.map +1 -0
  161. package/dist/esm/SqlConsensus.d.ts +44 -0
  162. package/dist/esm/SqlConsensus.d.ts.map +1 -0
  163. package/dist/esm/SqlConsensus.js +252 -0
  164. package/dist/esm/SqlConsensus.js.map +1 -0
  165. package/dist/esm/SqlJournal.d.ts +173 -0
  166. package/dist/esm/SqlJournal.d.ts.map +1 -0
  167. package/dist/esm/SqlJournal.js +1858 -0
  168. package/dist/esm/SqlJournal.js.map +1 -0
  169. package/dist/esm/StepFact.d.ts +66 -0
  170. package/dist/esm/StepFact.d.ts.map +1 -0
  171. package/dist/esm/StepFact.js +50 -0
  172. package/dist/esm/StepFact.js.map +1 -0
  173. package/dist/esm/index.d.ts +95 -0
  174. package/dist/esm/index.d.ts.map +1 -0
  175. package/dist/esm/index.js +95 -0
  176. package/dist/esm/index.js.map +1 -0
  177. package/dist/esm/internal/JournalInternalFault.d.ts +37 -0
  178. package/dist/esm/internal/JournalInternalFault.d.ts.map +1 -0
  179. package/dist/esm/internal/JournalInternalFault.js +28 -0
  180. package/dist/esm/internal/JournalInternalFault.js.map +1 -0
  181. package/dist/esm/migrations/0001_initial.d.ts +15 -0
  182. package/dist/esm/migrations/0001_initial.d.ts.map +1 -0
  183. package/dist/esm/migrations/0001_initial.js +32 -0
  184. package/dist/esm/migrations/0001_initial.js.map +1 -0
  185. package/dist/esm/migrations/0002_checkpoints.d.ts +27 -0
  186. package/dist/esm/migrations/0002_checkpoints.d.ts.map +1 -0
  187. package/dist/esm/migrations/0002_checkpoints.js +38 -0
  188. package/dist/esm/migrations/0002_checkpoints.js.map +1 -0
  189. package/dist/esm/migrations/0003_startup_index.d.ts +15 -0
  190. package/dist/esm/migrations/0003_startup_index.d.ts.map +1 -0
  191. package/dist/esm/migrations/0003_startup_index.js +18 -0
  192. package/dist/esm/migrations/0003_startup_index.js.map +1 -0
  193. package/dist/esm/migrations/0004_dedup.d.ts +15 -0
  194. package/dist/esm/migrations/0004_dedup.d.ts.map +1 -0
  195. package/dist/esm/migrations/0004_dedup.js +38 -0
  196. package/dist/esm/migrations/0004_dedup.js.map +1 -0
  197. package/dist/esm/migrations/0005_run_event_type.d.ts +15 -0
  198. package/dist/esm/migrations/0005_run_event_type.d.ts.map +1 -0
  199. package/dist/esm/migrations/0005_run_event_type.js +18 -0
  200. package/dist/esm/migrations/0005_run_event_type.js.map +1 -0
  201. package/dist/esm/migrations/0006_consensus.d.ts +22 -0
  202. package/dist/esm/migrations/0006_consensus.d.ts.map +1 -0
  203. package/dist/esm/migrations/0006_consensus.js +69 -0
  204. package/dist/esm/migrations/0006_consensus.js.map +1 -0
  205. package/dist/esm/test/Notifying.d.ts +65 -0
  206. package/dist/esm/test/Notifying.d.ts.map +1 -0
  207. package/dist/esm/test/Notifying.js +62 -0
  208. package/dist/esm/test/Notifying.js.map +1 -0
  209. package/dist/esm/test/TestJournal.d.ts +43 -0
  210. package/dist/esm/test/TestJournal.d.ts.map +1 -0
  211. package/dist/esm/test/TestJournal.js +27 -0
  212. package/dist/esm/test/TestJournal.js.map +1 -0
  213. package/docs/README.md +164 -0
  214. package/docs/api.md +639 -0
  215. package/docs/concepts/compaction.md +157 -0
  216. package/docs/concepts/idempotency.md +127 -0
  217. package/docs/concepts/owner-fence.md +124 -0
  218. package/docs/concepts/redaction.md +218 -0
  219. package/docs/concepts/state-event-authority.md +159 -0
  220. package/docs/concepts/two-channels.md +167 -0
  221. package/docs/guides/commit-state-and-entry.md +114 -0
  222. package/docs/guides/compact-a-run.md +134 -0
  223. package/docs/guides/fold-a-projection.md +106 -0
  224. package/docs/guides/read-a-run.md +139 -0
  225. package/docs/guides/redact-log-output.md +94 -0
  226. package/docs/guides/testing.md +124 -0
  227. package/docs/guides/write-lifecycle-events.md +110 -0
  228. package/docs/installation.md +133 -0
  229. package/docs/quickstart.md +143 -0
  230. package/docs/troubleshooting.md +214 -0
  231. package/package.json +263 -3
  232. package/src/CallFact.ts +117 -0
  233. package/src/Consensus.ts +683 -0
  234. package/src/EngineEvent.ts +823 -0
  235. package/src/ExecutionFact.ts +433 -0
  236. package/src/Journal.ts +688 -0
  237. package/src/JournalEvent.ts +654 -0
  238. package/src/JournalGeneration.ts +77 -0
  239. package/src/JournalMetrics.ts +89 -0
  240. package/src/Migrations.ts +62 -0
  241. package/src/OwnerId.ts +42 -0
  242. package/src/Projection.ts +31 -0
  243. package/src/RedactedLogger.ts +483 -0
  244. package/src/Redaction.ts +1784 -0
  245. package/src/SqlConsensus.ts +344 -0
  246. package/src/SqlJournal.ts +2732 -0
  247. package/src/StepFact.ts +69 -0
  248. package/src/index.ts +109 -0
  249. package/src/internal/JournalInternalFault.ts +40 -0
  250. package/src/migrations/0001_initial.ts +41 -0
  251. package/src/migrations/0002_checkpoints.ts +46 -0
  252. package/src/migrations/0003_startup_index.ts +19 -0
  253. package/src/migrations/0004_dedup.ts +43 -0
  254. package/src/migrations/0005_run_event_type.ts +19 -0
  255. package/src/migrations/0006_consensus.ts +83 -0
  256. package/src/test/Notifying.ts +101 -0
  257. package/src/test/TestJournal.ts +52 -0
@@ -0,0 +1,159 @@
1
+ ---
2
+ title: "State and event authority"
3
+ description: "Bounded identities, versioned engine history, explicit consumer admission, and the limits of projection recovery."
4
+ sidebar:
5
+ order: 7
6
+ ---
7
+
8
+ The journal owns committed history. Engine stores still own executable run,
9
+ attempt, deferred and clock state. The additive `EngineEvent` contracts make
10
+ history safer to consume; they do not move recovery into the journal.
11
+
12
+ | Durable concern | Authority | History relationship |
13
+ | ----------------------------------------- | -------------------------------- | -------------------------------------------------------------- |
14
+ | Control admission, approvals and signals | Control records and local events | Separate outbox and receipt contract. |
15
+ | Execution, attempts, deferreds and clocks | Engine-owned stores | State and required events share a `DurableWriter` transaction. |
16
+ | Ownership leases and fences | Arbitration store | Never restored from an old projection. |
17
+ | Engine history and time-travel evidence | Typed journal families | Consumers validate family, version, source and lineage. |
18
+ | Cache and artifacts | Validated content and provenance | Cache loss does not erase authoritative completion. |
19
+ | UI and listing summaries | Read projections | Rebuildability must be demonstrated for each projection. |
20
+
21
+ ## Shared boundary primitives
22
+
23
+ `JournalEvent` exports `RunId`, `LineageId`, `WaitId`, `CommandId`, `PlanId`,
24
+ `DispatchId` and `ArtifactId`. Each offers a validating `.make(value)`
25
+ constructor and a named Effect decoder, such as `decodeRunId(unknown)`.
26
+ Identifiers preserve bytes, require 1 through 1,024 UTF-16 code units, and
27
+ refuse NUL and unpaired surrogates. Plan, dispatch and artifact brands are
28
+ distinct, even when their encoded strings happen to match.
29
+
30
+ `NonNegativeQuantity` and `TimestampMs` admit safe integers from zero through
31
+ `Number.MAX_SAFE_INTEGER`. `PositiveQuantity` starts at one. Fractions,
32
+ infinity, string coercion and missing-value defaults are refused. Existing
33
+ journal sequences retain their stricter exclusive upper bound.
34
+
35
+ ## Engine families and consumer admission
36
+
37
+ `@smthrs/journal/EngineEvent` keeps the journal's generic envelope open while
38
+ defining two version-2 families:
39
+
40
+ | Event type | Typed payload |
41
+ | ----------------------------------- | ------------------------------------------------------------------------------------------ |
42
+ | `flows.engine.v2.attempt-lifecycle` | Execution, dispatch, attempt number and running, suspended, succeeded or failed lifecycle. |
43
+ | `flows.engine.v2.state-event` | Execution lifecycle, deferred completion or an absolute clock schedule. |
44
+
45
+ Both payloads require version, run, lineage id, root run, round and parent
46
+ where applicable. The committed journal envelope supplies global sequence,
47
+ source identity, source sequence and emission time. Root runs have round zero
48
+ and no parent; derived runs name a distinct parent and root. A continuation
49
+ requires a positive round. Diagnostic `meta` is encoded JSON and grants no
50
+ semantic authority.
51
+
52
+ `decodeEntry(input, consumer)` requires an explicit expected run, lineage,
53
+ root, round, parent and source allowlist. It returns a typed `Attempt` or
54
+ `State`. Conflicting identity fails `EventError` with code `foreign`.
55
+ Malformed supported families fail `malformed`, preserving the original
56
+ schema or accessor error in `cause`. Unsupported families and versions in
57
+ `flows.engine.*` fail `unsupported`. Other namespaces follow the consumer's
58
+ explicit `unknown: "ignore" | "surface"` policy. Source and run admission
59
+ still applies to those extensions.
60
+
61
+ Running and suspended attempts cannot contain completion fields. A terminal
62
+ attempt requires its timestamp and a matching encoded success or failure.
63
+ Failure reasons distinguish typed error, defect, interruption and encoding
64
+ failure. Values must be JSON after the value's own codec has run; a class
65
+ instance accepted by `Schema.Unknown` is not an encoded result. Wall clocks
66
+ can move backwards, so completion timestamps need not exceed start time.
67
+ Suspended executions require at least one typed wait; completed executions
68
+ require a result. Ownership and cancellation remain separate facts.
69
+
70
+ ## Node and plan records
71
+
72
+ Two executors drive a graph: the plan scheduler and the flow interpreter. Both
73
+ write the same six records, and `EngineEvent` publishes a schema for each so a
74
+ reader decodes one shape rather than guessing per writer.
75
+
76
+ | Event type | Typed payload |
77
+ | -------------------------------- | ---------------------------------------------------------------- |
78
+ | `flows.engine.plan-recorded` | `PlanRecordedPayload`: the flow, the generation, the node count. |
79
+ | `flows.engine.subgraph-appended` | `SubgraphAppendedPayload`: the ids a generation or a page added. |
80
+ | `flows.engine.node-scheduled` | `NodeScheduledPayload`: the node, its kind, attempt and tag. |
81
+ | `flows.engine.node-settled` | `NodeSettledPayload`: the outcome, the attempts, the join. |
82
+ | `flows.engine.node-invalidated` | `NodeInvalidatedPayload`: the re-keying and the reason for it. |
83
+ | `flows.engine.node-reconciled` | `NodeReconciledPayload`: the verdict for one deviation. |
84
+
85
+ `nodes` on a plan record is the node COUNT it has always been. The node list
86
+ rides beside it as `graph`, optional and paged, because a journal entry has a
87
+ byte bound and a record that clipped its list would lose nodes silently.
88
+ `graph.edges` is optional for the same reason a fact is optional anywhere here:
89
+ a plan names its edges through each node's `dependsOn` and knows no reason for
90
+ them, while an interpreted graph knows the reason it drew each one.
91
+
92
+ A node's `declaredAt` is where its action was written. It is repo-relative by
93
+ contract and the schema refuses an absolute path: a journal is read on machines
94
+ that did not write it, so an absolute path is at best noise and at worst an
95
+ operator's home directory published into a run's history. A writer that cannot
96
+ make a path relative omits the field. `EngineEvent.relativePath(root, path)` is
97
+ how a writer obeys that rule: it answers the path relative to the root, or
98
+ nothing at all for a path outside the root, for no root, and for a root that
99
+ names the whole filesystem. Every writer of a declaration site uses it, the
100
+ engine's node records and a control plan card alike.
101
+
102
+ The five settlement words are `built`, `clean`, `failed`, `skipped` and
103
+ `deferred`. `clean` means a recorded result served the node and no executor
104
+ ran, which covers same-run durable replay as well as a cross-run cache hit.
105
+
106
+ A settlement's `attempts` is the executor's own count. For the plan scheduler
107
+ it is the node's; for the interpreter it is the highest durable attempt any of
108
+ the node's dispatches ran as, because the interpreter settles each node once
109
+ and a retry happens underneath it inside one dispatch.
110
+
111
+ `stepKeyDigests` is the join. An attempt record carries a step key digest and
112
+ no node id, so before it an attempt belonged to no node and "attempt 2" could
113
+ not be attributed. A settlement now names the distinct step keys its node
114
+ dispatched under, and an attempt record belongs to the node of the same
115
+ execution whose settlement claims its digest. A retried dispatch keeps ONE
116
+ digest, because the attempt is folded into no key, so the list counts
117
+ dispatches and not attempts. A node that dispatched nothing records an empty
118
+ list, which is a different statement from a writer that derives no digests and
119
+ omits the field.
120
+
121
+ `result` is what the node settled with: the success value for `built` and
122
+ `clean`, the typed failure for `failed`. It is a bounded, redacted `preview`
123
+ of the value's JSON, the `bytes` of the encoding that preview was cut from,
124
+ and a `truncated` flag that is also the warning that the text is a prefix of
125
+ JSON and no longer parses. The writer redacts before it truncates, because
126
+ cutting first can split a credential across the boundary and the textual rules
127
+ would no longer recognise what is left; a value too large to redact at all is
128
+ named by the size of its own encoding, with an empty preview. A writer that
129
+ kept no summary omits the field.
130
+
131
+ ## Additive cutover and retained history
132
+
133
+ Current engine writers are unchanged. `decodeCurrentAttempt` validates their
134
+ actual started/finished markers, using the recorded metadata lineage. It does
135
+ not invent a root, round, timestamp or result absent from an old row. That
136
+ adapter is explicitly separate from version-2 admission. Generic store
137
+ extension points remain generic.
138
+
139
+ A writer cutover must persist complete lineage and completion evidence,
140
+ introduce the new family identity, and write state plus event in the same
141
+ transaction. Old histories retain their original bytes and decoder. Backfill
142
+ requires authoritative state evidence and an explicit migration; an old
143
+ finished marker alone cannot backfill a result. No database column or journal
144
+ migration is needed for the additive schemas themselves.
145
+
146
+ The engine's additive attempt projection demonstrates full-history and
147
+ snapshot-plus-suffix equality against real attempt rows at 2 and 70 attempts.
148
+ Its tests drop the projection in a subprocess, kill that process, reopen
149
+ SQLite with a fresh connection and rebuild it. A version-2 snapshot binds its
150
+ rows to lineage and a covering sequence; duplicate identities, foreign runs
151
+ and rows past the covering sequence are refused. Compaction requires that
152
+ validated snapshot before reading the surviving suffix.
153
+
154
+ This proof covers the disclosed attempt projection. Journal redaction can
155
+ change result content, so it does not prove executable recovery of arbitrary
156
+ private values. Retain the authoritative stores, their backup, and operational
157
+ fences. A concrete sync consumer still owns atomic snapshot application and
158
+ its applied cursor. Extending retention to a public sync projection requires
159
+ its own authorization and retention policy.
@@ -0,0 +1,167 @@
1
+ ---
2
+ title: "The two channels"
3
+ description: "Why the journal has a lossless lifecycle channel and a lossy telemetry channel, what each receipt means, and why sequence gaps are valid."
4
+ sidebar:
5
+ order: 1
6
+ ---
7
+
8
+ The journal has one table and two ways into it. The split is deliberate: a
9
+ lifecycle fact and a telemetry sample have different costs when they are lost,
10
+ so they get different guarantees and the caller picks.
11
+
12
+ ## The durable channel
13
+
14
+ `emitDurable(input, owner)` and `emitDurableUnfenced(input)` open a write
15
+ transaction inline, allocate the sequence inside it, insert the row, and return
16
+ only after the transaction commits. The returned `seq` is therefore already on
17
+ disk.
18
+
19
+ The receipt type says the rest. `DurableReceipt` is `Accepted | Duplicate`, so
20
+ a dropped lifecycle event is unrepresentable: the write commits, collapses onto
21
+ an identical earlier write, or fails with a typed `JournalError`.
22
+
23
+ Use this channel for anything a run's correctness depends on. A durable
24
+ boundary must not advance a run or expose its result until the lifecycle entry
25
+ that describes the move has committed.
26
+
27
+ ## The lossy channel
28
+
29
+ `emitLossy(input)` validates the event, allocates its sequences from an
30
+ in-memory clock, and hands it to a bounded queue that one scoped writer drains
31
+ in batches. The call returns before SQL commits. Its `seq` is provisional until
32
+ commit; `sourceSeq` remains the stable producer identity.
33
+
34
+ `EmitReceipt` is `Accepted | Duplicate | Dropped`, and which of those you can
35
+ see depends on the layer's `overflow` policy:
36
+
37
+ | `overflow` | A full queue produces |
38
+ | ------------- | -------------------------------------------------------------------- |
39
+ | `reject` | a `queue_overflow` failure |
40
+ | `drop-newest` | a `Dropped` receipt for the event just offered |
41
+ | `drop-oldest` | an `Accepted` receipt whose `evicted` field counts what it displaced |
42
+
43
+ Use this channel for telemetry: spans, progress, per-frame samples. A process
44
+ crash can lose an accepted but unwritten entry, and that is the trade the
45
+ channel exists to make.
46
+
47
+ `emitLossy` opens no transaction and performs no deduplication lookup in SQL.
48
+ New allocation is read-free only when its allocation floors are warmed. On a cold
49
+ run, it reads `MAX(seq) + 1` from the run's durable entries. If `sourceSeq` is
50
+ omitted and the producer floor is uncached, it also reads
51
+ `MAX(source_seq) + 1` for `(runId, sourceId)`. An explicit `sourceSeq` avoids
52
+ the producer-floor read, but does not warm the run floor by itself.
53
+
54
+ A retained duplicate needs no allocation floors and returns from the identity
55
+ cache even when those floors are cold. For a new admission, cold floor reads
56
+ run outside the run permit. Admission then checks the compaction gate,
57
+ rechecks the producer identity, and takes the maximum of each read floor and
58
+ its current in-memory floor before reserving sequences. A batch holding the
59
+ database transaction can therefore drain while a new producer waits for SQL.
60
+ Top-level compaction drains accepted work with admission open, then acquires
61
+ its SQL transaction before its run locks. It checks the drain again before closing the
62
+ admission gate and reading the checkpoint. If work arrived while it waited for
63
+ SQL, compaction releases the transaction and drains again. It never holds a run
64
+ permit or a closed gate while waiting for a database connection. Producers in
65
+ an enclosing transaction can therefore finish admission and release SQL before
66
+ compaction closes the gate. Continuous admissions can postpone compaction until
67
+ the run drains; accepted work takes precedence over truncation.
68
+
69
+ Compaction inside an enclosing managed or raw SQL transaction already owns the
70
+ connection. It cannot wait for the queued writer. Instead, it inserts this run's
71
+ pending lossy entries inline before truncation, including the batch waiting for
72
+ SQL. It uses the same transactional insert path as the batch writer. Queue
73
+ settlement stays with that writer: an outer rollback leaves the entries queued,
74
+ and an outer commit makes their later batch attempts collapse by identity.
75
+ Managed transactions publish only after the outer commit; raw SQL transactions
76
+ leave replay authoritative, as with durable emits.
77
+
78
+ A pending reservation below the chosen checkpoint moves above the durable tail
79
+ when inserted inline. It survives replay after the checkpoint, with unchanged
80
+ producer identity and content checks. Reservations below a previously compacted
81
+ floor retain the existing loss behavior. Durable writers register as active
82
+ only after acquiring SQL; a writer waiting for the compactor's connection has
83
+ allocated no canonical sequence and inserts above the surviving tail later.
84
+
85
+ Inside `Journal.transact` or `DurableWriter.write` using the same SQL client,
86
+ these floor reads join the caller's transaction. A raw `sql.withTransaction`
87
+ on that client also supplies the read transaction, but lossy admission remains
88
+ a separate queued write: it is not atomic with the caller's commit or rollback.
89
+ For atomic state and event writes, use
90
+ [`transact` with `emitDurable`](../guides/commit-state-and-entry.md).
91
+
92
+ If another transaction holds the shared connection without passing its SQL
93
+ transaction context to the admission, a cold floor read can wait on that
94
+ connection while its holder waits on the caller. Only warmed allocations avoid
95
+ that read dependency. Admit outside that transaction when the required floors
96
+ may be cold, including after truncation invalidates them. Do not wait for
97
+ `flush` while holding a transaction the queued writer needs.
98
+
99
+ ## flush is the lossy barrier only
100
+
101
+ `flush` waits for everything currently queued to reach disk. It says nothing
102
+ about the durable channel, which was already committed when its receipt
103
+ arrived.
104
+
105
+ A producer still waiting for a cold allocation-floor read has not queued an
106
+ entry. Await its `emitLossy` receipt before flushing to cover that entry. If
107
+ the journal closes during this admission wait, the emit fails with
108
+ `journal_closed` after the wait instead of offering to the closed queue.
109
+
110
+ A batch the writer cannot persist is lost and reported once: to the `flush`
111
+ waiters that covered it, to live `stream` consumers that were following at the
112
+ time, and, if nobody was waiting, to the next `flush`. The writer fiber
113
+ survives the failure. Entries queued behind the lost batch stay outstanding, so
114
+ a later `flush` still waits for them rather than vouching for unpersisted work.
115
+
116
+ Never call `flush` inside `transact`. It waits on the queued writer while your
117
+ transaction holds the write lock the writer needs.
118
+
119
+ ## Two sequence domains
120
+
121
+ Every admitted event gets two numbers, and they answer different questions.
122
+
123
+ - `seq` is the run's canonical durable order. Replay, paging, streams, and
124
+ projections all use it, and it is shared by both channels: a durable entry
125
+ and a lossy entry from the same run interleave in one history.
126
+ - `sourceSeq` is per `(runId, sourceId)`. It identifies a producer's retries
127
+ and is the idempotency key. A producer may supply its own; otherwise the
128
+ journal allocates the next one.
129
+
130
+ ## Commit ordering
131
+
132
+ Both channels allocate canonical `seq` inside the write transaction, above the
133
+ run's committed tail. The lossy writer uses its admission reservation as a
134
+ minimum. If a durable commit or another journal instance overtakes that
135
+ reservation, the queued entry moves forward when it commits. For example,
136
+ lossy admission `0`, followed by durable commit `1`, commits the lossy entry at
137
+ `2`. Replay and followers then deliver `1, 2`.
138
+
139
+ The durable tail is therefore a committed-prefix watermark: a pending entry
140
+ cannot later commit below it. Readers can advance to the last returned row
141
+ without skipping later commits, including commits from independent connections.
142
+ No persisted reservation or local pending-writer registry is needed.
143
+
144
+ Use committed entries or committed duplicate receipts for paging and checkpoint
145
+ cursors. An `Accepted` lossy receipt or a pending `Duplicate` carries the
146
+ provisional reservation, not a durable cursor. `sourceSeq` and event identity do
147
+ not change when the canonical sequence moves. The compaction guard still drops
148
+ queued reservations at or below an already compacted boundary and reports the
149
+ loss through `flush`.
150
+
151
+ ## Gaps are valid
152
+
153
+ A rejected or dropped admission still consumes both allocations. Allocation is
154
+ `MAX(seq) + 1` and replay is `ORDER BY seq`, so nothing reads a gap as
155
+ anything: a missing number is not a missing entry, and no reader should treat
156
+ it as one.
157
+
158
+ The one receipt that consumes neither allocation is a `Duplicate` the
159
+ in-process producer index still holds. It returns the original event's
160
+ `seq`, provisional while pending and canonical after commit.
161
+
162
+ ## Where the durable channel is fenced
163
+
164
+ `emitDurable` requires an `OwnerId` and refuses to write behind a live
165
+ successor. That is the subject of [the owner fence](./owner-fence.md).
166
+ `emitDurableUnfenced` is the same durability with no fence, for an admission
167
+ that is genuinely ownerless.
@@ -0,0 +1,114 @@
1
+ ---
2
+ title: "Commit state and its entry together"
3
+ description: "Use Journal.transact to append a lifecycle entry in the same write transaction as the state transition it describes, and learn the three rules that keeps."
4
+ sidebar:
5
+ order: 2
6
+ ---
7
+
8
+ Committing an entry makes that entry durable. It does not by itself make a
9
+ host's whole view crash-consistent, because the executable state lives in other
10
+ stores: `RunStore` and `AttemptStore` in
11
+ [`@smthrs/run-store`](/api/run-store), `CacheStore` in
12
+ [`@smthrs/step-cache`](/api/step-cache), and `DurableEngineState` in
13
+ [`@smthrs/engine-store`](/api/engine-store).
14
+
15
+ `transact` closes that seam. It runs a state transition and the `emitDurable`
16
+ calls describing it in one write transaction, so either both land or neither
17
+ does.
18
+
19
+ ## Wrap the transition and the entry
20
+
21
+ ```ts
22
+ import { Journal, JournalEvent } from "@smthrs/journal"
23
+ import { RunStore } from "@smthrs/run-store"
24
+ import * as Effect from "effect/Effect"
25
+
26
+ const finish = Effect.gen(function*() {
27
+ const journal = yield* Journal.Journal
28
+ const runs = yield* RunStore.RunStore
29
+
30
+ return yield* journal.transact(Effect.gen(function*() {
31
+ const receipt = yield* journal.emitDurable({
32
+ runId,
33
+ sourceId,
34
+ sourceSeq: 7 as JournalEvent.SourceSeq,
35
+ eventType: "run.finished",
36
+ payload: { outcome: "succeeded" }
37
+ }, owner)
38
+ const result = yield* runs.transitionOwned(runId, owner, "completed")
39
+ if (result._tag !== "Transitioned") return yield* Effect.fail(result)
40
+ return receipt
41
+ }))
42
+ })
43
+ ```
44
+
45
+ The fenced append runs while the owned row is still running. Transitioning to
46
+ `completed` first invalidates the journal fence. Both stores write through
47
+ the same `DurableWriter`, so the entry and terminal transition join this
48
+ transaction as savepoints and commit or roll back together. If the transition
49
+ is rejected, the entry rolls back too.
50
+
51
+ ## Three rules the transaction keeps
52
+
53
+ **Publication follows commit.** Inside a transaction, `emitDurable` returning
54
+ means a savepoint was released, not that anything is visible. The `changes` and
55
+ `stream` publish and the in-process producer index update are parked until the
56
+ outermost transaction commits. A subscriber therefore never sees an entry that
57
+ later rolls back, and a rolled-back producer identity stays re-emittable
58
+ instead of deduplicating against a sequence that does not exist.
59
+
60
+ **Only storage work belongs inside.** The transaction is held for the whole
61
+ body. No flow bodies, no host calls, no waits. In particular, never call
62
+ `flush` inside `transact`: it waits on the lossy writer, which is waiting on
63
+ the transaction you are holding.
64
+
65
+ **Nesting is a savepoint.** An inner `transact` becomes a savepoint of the
66
+ outer one and defers its publication to the outermost commit. If its failure
67
+ is caught by the outer transaction, its publication is discarded even when
68
+ the outer transaction succeeds. This also holds when another store opens the
69
+ outer transaction through the same `DurableWriter` and SQL client.
70
+
71
+ Projections can register short, non-failing cache updates with the service's
72
+ `journal.whenCommitted(update)`. It uses the shared database commit owner and
73
+ is bound to this journal's database: an unrelated database's nested commit
74
+ cannot publish its updates. Outside a transaction the update runs immediately;
75
+ inside a managed transaction it waits for the outer commit. Its `true` result
76
+ means the update was accepted, not necessarily already executed. A `false`
77
+ result means ownership is unknown; skip optional cache updates then. Raw
78
+ `sql.withTransaction` and raw savepoints are not owned by this hook. Journal
79
+ writes in them remain durable on commit, but skip process-local publication;
80
+ `entries` and the polling `stream` still read the authoritative SQL history.
81
+ Use `transact` or `DurableWriter.write` when immediate `changes` notifications
82
+ are required.
83
+
84
+ The lower-level `Journal.afterCommit` re-export has the database helper's
85
+ different outside-transaction contract: it returns `false` without publishing.
86
+ Test doubles implementing transactions must preserve commit/rollback semantics
87
+ for `whenCommitted`, not run updates immediately from transaction bodies.
88
+
89
+ ## What a transaction does not buy
90
+
91
+ A crash before COMMIT loses the whole unit, so work that had already run, an
92
+ action body for instance, re-executes on the next drive. Design the body to
93
+ tolerate that.
94
+
95
+ And no local transaction makes a remote effect atomic. External effects still
96
+ need idempotency keys, fencing tokens, or compensation.
97
+
98
+ ## A note on retries
99
+
100
+ The pinned Node SQLite driver uses `BEGIN IMMEDIATE`, acquiring its writer
101
+ lock before reading allocation floors. A caller-supplied SQL client may use
102
+ DEFERRED transactions; under WAL its lock upgrade can fail with
103
+ `SQLITE_BUSY_SNAPSHOT`. The shared writer retries transient contention at the
104
+ whole-transaction boundary in either case, floor read included.
105
+
106
+ A `transact` body must therefore be safe to run more than once. Process-local
107
+ publication belongs in `afterCommit`, outside that retry loop.
108
+
109
+ ## Next steps
110
+
111
+ - [The two channels](../concepts/two-channels.md) for why the durable channel
112
+ returns only after commit.
113
+ - [Durable execution](/docs/concepts/durable-execution/) for the execution
114
+ model this transaction supports.
@@ -0,0 +1,134 @@
1
+ ---
2
+ title: "Compact a long-running run"
3
+ description: "Checkpoint a run's replay state, truncate the entries below it, resync a reader that fails with compacted, and hand the work to the automatic policy."
4
+ sidebar:
5
+ order: 5
6
+ ---
7
+
8
+ Compaction drops the entries a run no longer needs to replay. It is off by
9
+ default, so nothing here happens unless you ask for it.
10
+
11
+ Every step is fenced: `checkpoint` and `compact` both take an `OwnerId` and
12
+ both need the run claimed and activated through the journal's `Consensus`
13
+ strategy, which [`@smthrs/run-store`](/api/run-store) drives. See
14
+ [Installation](../installation.md#what-a-fenced-write-needs).
15
+
16
+ ## Checkpoint the replay state
17
+
18
+ Capture whatever your reader needs to resume, and pin it to a committed
19
+ sequence:
20
+
21
+ ```ts
22
+ import { Journal } from "@smthrs/journal"
23
+ import * as Effect from "effect/Effect"
24
+
25
+ const checkpointTail = Effect.gen(function*() {
26
+ const journal = yield* Journal.Journal
27
+
28
+ const page = yield* journal.entries({ runId, limit: 1000 })
29
+ const tip = page.entries.at(-1)
30
+ if (tip === undefined) return
31
+
32
+ yield* journal.checkpoint({ runId, seq: tip.seq, state: { started: 3, finished: 2 } }, owner)
33
+ })
34
+ ```
35
+
36
+ `state` round-trips verbatim; the journal never interprets it and never redacts
37
+ it. A value that must not persist belongs in a `Redacted` field of your own
38
+ state schema.
39
+
40
+ The sequence must name a committed entry and must lie above the run's current
41
+ floor, or the write fails `checkpoint_invalid`. Re-checkpointing an uncompacted
42
+ sequence replaces its state.
43
+
44
+ ## Truncate below it
45
+
46
+ ```ts
47
+ const truncate = Effect.gen(function*() {
48
+ const journal = yield* Journal.Journal
49
+ const compacted = yield* journal.compact({ runId }, owner)
50
+ console.log(compacted.deleted, "entries removed below", compacted.checkpointSeq)
51
+ })
52
+ ```
53
+
54
+ Omit `upTo` to compact to the run's latest checkpoint, or pass a specific
55
+ sequence. The deletion and the floor advance commit atomically. The checkpointed
56
+ entry itself survives: it holds the run's allocation floor, so a restarted
57
+ process can never re-allocate a truncated sequence.
58
+
59
+ A retried compaction returns `deleted: 0` rather than failing.
60
+
61
+ ## Handle a refusal
62
+
63
+ `compact` refuses in three typed ways, and each asks for something different:
64
+
65
+ - `checkpoint_invalid`: the run has no checkpoint to truncate below, or none at
66
+ the sequence you named. Write one first.
67
+ - `reader_behind`: a live in-process `stream` still needs a sequence the
68
+ truncation would delete. `checkpointSeq` carries the checkpoint it declined
69
+ to truncate below. Wait for the reader, or drop it.
70
+ - `fence_lost`: another process owns the run. Stop; a zombie owner must not
71
+ truncate history behind a live successor.
72
+
73
+ ## Resync a reader
74
+
75
+ A reader whose cursor is below the floor fails with `compacted`, carrying the
76
+ floor in `checkpointSeq`. Recovery is the checkpoint plus the tail after it:
77
+
78
+ ```ts
79
+ import * as Option from "effect/Option"
80
+ import * as Stream from "effect/Stream"
81
+
82
+ const resync = Effect.gen(function*() {
83
+ const journal = yield* Journal.Journal
84
+ return yield* journal.stream({ runId }).pipe(
85
+ Stream.catchTag("@smthrs/journal/JournalError", (failure) =>
86
+ failure.code === "compacted"
87
+ ? Stream.unwrap(Effect.gen(function*() {
88
+ const checkpoint = yield* journal.latestCheckpoint(runId)
89
+ if (Option.isNone(checkpoint)) return Stream.fail(failure)
90
+ return journal.stream({ runId, afterSequence: checkpoint.value.seq })
91
+ }))
92
+ : Stream.fail(failure)),
93
+ Stream.take(100),
94
+ Stream.runCollect
95
+ )
96
+ })
97
+ ```
98
+
99
+ `latestCheckpoint` returns the run's most recent checkpoint whether or not it
100
+ has been compacted, so the same call serves both cases.
101
+
102
+ ## Hand it to the policy
103
+
104
+ Set `compaction` on the layer to make this automatic:
105
+
106
+ ```ts
107
+ import { SqlJournal } from "@smthrs/journal"
108
+
109
+ const layer = SqlJournal.layer({
110
+ capacity: 1024,
111
+ overflow: "reject",
112
+ compaction: {
113
+ entryThreshold: 5_000,
114
+ capture: (runId, upTo) => captureReplayState(runId, upTo)
115
+ }
116
+ })
117
+ ```
118
+
119
+ Once a run's committed entry count reaches `entryThreshold`, the journal
120
+ captures your replay state at the run's durable tail, checkpoints it, and
121
+ compacts below it. The policy holds no fence, so it needs no owner and no
122
+ lease.
123
+
124
+ Write `capture` to finish quickly and to tolerate being abandoned: it is
125
+ interrupted after 30 seconds, and a failed or refused attempt is logged at
126
+ warning, damped for `entryThreshold` further entries, and never surfaced to the
127
+ emit that triggered it.
128
+
129
+ ## Next steps
130
+
131
+ - [Checkpoints and compaction](../concepts/compaction.md) for the model behind
132
+ these calls.
133
+ - [Run a read-only follower](/docs/guides/sync-followers/) for a follower that
134
+ meets the floor.
@@ -0,0 +1,106 @@
1
+ ---
2
+ title: "Fold a run into a projection"
3
+ description: "Write a reproducible reducer over journal entries, run it with project, and bound a live projection so it terminates."
4
+ sidebar:
5
+ order: 4
6
+ ---
7
+
8
+ A projection is a fold over a run's entries: `{ name, initial, reduce }`. It has
9
+ no durable state of its own, so replaying the same entries through the same
10
+ reducer must reproduce the same result. That is the whole contract, and it is
11
+ what lets a served view be recomputed rather than patched.
12
+
13
+ ## Write the reducer
14
+
15
+ ```ts
16
+ import { Projection } from "@smthrs/journal"
17
+ import * as Effect from "effect/Effect"
18
+
19
+ interface StepCounts {
20
+ readonly started: number
21
+ readonly finished: number
22
+ }
23
+
24
+ const counts: Projection.Projection<StepCounts> = Projection.make({
25
+ name: "docs/step-counts",
26
+ initial: { started: 0, finished: 0 },
27
+ reduce: (state, entry) =>
28
+ Effect.succeed(
29
+ entry.eventType === "step.started"
30
+ ? { ...state, started: state.started + 1 }
31
+ : entry.eventType === "step.finished"
32
+ ? { ...state, finished: state.finished + 1 }
33
+ : state
34
+ )
35
+ })
36
+ ```
37
+
38
+ `reduce` returns an `Effect`, so a projection may read a service. Keep it
39
+ deterministic anyway: an entry that is not yours returns the state unchanged,
40
+ as the fall-through above does.
41
+
42
+ ## Run it
43
+
44
+ `project` folds `stream` through the reducer and emits a state per step. It
45
+ emits `initial` first, then one state for each entry:
46
+
47
+ ```ts
48
+ import { Journal } from "@smthrs/journal"
49
+ import * as Stream from "effect/Stream"
50
+
51
+ const snapshot = Effect.gen(function*() {
52
+ const journal = yield* Journal.Journal
53
+ return yield* journal.project(counts, { runId }).pipe(
54
+ Stream.take(4),
55
+ Stream.runCollect
56
+ )
57
+ })
58
+ ```
59
+
60
+ For a run with three entries, that collects:
61
+
62
+ ```text
63
+ [
64
+ { started: 0, finished: 0 },
65
+ { started: 1, finished: 0 },
66
+ { started: 1, finished: 1 },
67
+ { started: 2, finished: 1 }
68
+ ]
69
+ ```
70
+
71
+ `afterSequence` resumes a fold from a cursor, and then only the entries after
72
+ it are folded.
73
+
74
+ ## Bound the stream
75
+
76
+ `project` inherits `stream`'s behavior: it replays and then follows, so it
77
+ never completes on its own. A `Stream.runCollect` or `Stream.runLast` with no
78
+ bound hangs.
79
+
80
+ Bound it one of three ways, by what you actually want:
81
+
82
+ - A **snapshot**: `Stream.take(n)` where `n` is `1` plus the entry count you
83
+ read from `entries`.
84
+ - A **live view**: fork the stream and interrupt the fiber when the view is torn
85
+ down. Interruption is clean.
86
+ - A **condition**: `Stream.takeUntil` on the state you were waiting for.
87
+
88
+ ## Handle a failing reducer
89
+
90
+ A reducer that fails ends the stream with `projection_failed`. The journal is
91
+ unaffected: later emissions still succeed, and a fresh `project` call folds the
92
+ same history again from the start.
93
+
94
+ ## Where projections are used
95
+
96
+ [`@smthrs/notifications`](/api/notifications) derives its pending-notification
97
+ queue this way, and [`@smthrs/gateway`](/api/gateway) recomputes a selector's
98
+ rows from accumulated events rather than patching them, precisely because a
99
+ reproducible fold is the only delta that cannot disagree with a fresh
100
+ snapshot.
101
+
102
+ ## Next steps
103
+
104
+ - [Read and follow a run](./read-a-run.md) for the reads underneath `project`.
105
+ - [Checkpoints and compaction](../concepts/compaction.md) for what a projection
106
+ does when its cursor falls below the floor.