@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,1858 @@
1
+ /**
2
+ * SQLite-backed logical journal with durable and lossy channels.
3
+ *
4
+ * Lossy telemetry events remain optimistic until the single scoped writer
5
+ * commits them, so a process crash can lose accepted-but-unwritten telemetry.
6
+ * Lifecycle events use `emitDurable` and return after commit, except inside
7
+ * `transact`, where their savepoint completes before the outer commit.
8
+ *
9
+ * Governing design: `packages/smithers/flows/journal/docs/concepts/two-channels.md`.
10
+ * Prior-art decision: `packages/smithers/flows/sync/docs/concepts/replay-then-follow.md`.
11
+ *
12
+ * The replay-then-follow stream follows Effect's `EventJournal` and OpenCode's
13
+ * upstream event stream design. The bounded send queue deliberately deviates
14
+ * from their synchronous durable writes by reserving a provisional per-run
15
+ * sequence before admission. Both channels finalize canonical order at commit.
16
+ * SQLite retry and transaction behavior comes through `@smthrs/database`.
17
+ *
18
+ * @since 0.1.0
19
+ */
20
+ import * as Dialect from "@smthrs/database/Dialect";
21
+ import { afterCommit, DatabaseError, DurableWriter } from "@smthrs/database/DurableWriter";
22
+ import * as Cause from "effect/Cause";
23
+ import * as Clock from "effect/Clock";
24
+ import * as Deferred from "effect/Deferred";
25
+ import * as Effect from "effect/Effect";
26
+ import * as Exit from "effect/Exit";
27
+ import * as Layer from "effect/Layer";
28
+ import * as Metric from "effect/Metric";
29
+ import * as Option from "effect/Option";
30
+ import * as PubSub from "effect/PubSub";
31
+ import * as Queue from "effect/Queue";
32
+ import * as Result from "effect/Result";
33
+ import * as Schema from "effect/Schema";
34
+ import * as Scope from "effect/Scope";
35
+ import * as Semaphore from "effect/Semaphore";
36
+ import * as Stream from "effect/Stream";
37
+ import * as SqlClient from "effect/unstable/sql/SqlClient";
38
+ import { Consensus } from "./Consensus.js";
39
+ import { JournalInternalFault } from "./internal/JournalInternalFault.js";
40
+ import { Checkpoint, CheckpointOptions, CompactOptions, EntriesOptions, Journal, JournalError, make as makeJournal, maxEntriesLimit, StreamOptions } from "./Journal.js";
41
+ import { Entry, Input, makeEventId, RunId } from "./JournalEvent.js";
42
+ import * as JournalGeneration from "./JournalGeneration.js";
43
+ import * as JournalMetrics from "./JournalMetrics.js";
44
+ import { OwnerId } from "./OwnerId.js";
45
+ import * as Redaction from "./Redaction.js";
46
+ import * as SqlConsensus from "./SqlConsensus.js";
47
+ /** JSON text carrying an arbitrary decoded value. */
48
+ const UnknownFromJsonString = Schema.fromJsonString(Schema.Unknown);
49
+ /** Default retained window of the source-event index. */
50
+ const defaultSourceEventCache = 4096;
51
+ /** Bounds caller-supplied compaction capture work before the attempt is damped. */
52
+ const compactionCaptureTimeout = "30 seconds";
53
+ const error = (code, message, cause) => new JournalError({
54
+ code,
55
+ message,
56
+ ...(cause === undefined ? {} : { cause })
57
+ });
58
+ const sourceKey = (runId, sourceId) => `${runId.length}:${runId}${sourceId.length}:${sourceId}`;
59
+ const sourceEventKey = (runId, sourceId, sourceSeq) => `${sourceKey(runId, sourceId)}:${sourceSeq}`;
60
+ /**
61
+ * Sorts a PARSED JSON tree so two encodings of one value compare equal.
62
+ *
63
+ * The argument is always `JSON.parse` output. That precondition is the whole
64
+ * safety argument: parse output has no prototype `toJSON`, no cycle, and no
65
+ * leaf JSON cannot represent, so this walk needs none of the handling
66
+ * {@link Redaction.redact} carries. Running it over a raw value instead is
67
+ * what broke: it rebuilt objects from `Object.keys`, so `{ at: new Date(...) }`
68
+ * persisted as `{"at":{}}` under `Redaction.makeNoop()` while the default
69
+ * redactor persisted the ISO string, and a redactor returning a cycle
70
+ * recursed until the stack gave out and killed the emit with a defect instead
71
+ * of a `JournalError`.
72
+ *
73
+ * `@smthrs/canonical` is the repository's general-purpose implementation, but
74
+ * journal cannot add that dependency while this release's lockfiles are frozen.
75
+ */
76
+ const canonicalizeFingerprint = (value, depth) => {
77
+ if (depth > Redaction.maxDepth) {
78
+ throw new RangeError(`journal value nests deeper than ${Redaction.maxDepth} containers`);
79
+ }
80
+ if (Array.isArray(value))
81
+ return value.map((element) => canonicalizeFingerprint(element, depth + 1));
82
+ if (value === null || typeof value !== "object")
83
+ return value;
84
+ const record = value;
85
+ return Object.fromEntries(Object.keys(record).sort().map((key) => [key, canonicalizeFingerprint(record[key], depth + 1)]));
86
+ };
87
+ /**
88
+ * Encodes a value as JSON, reporting anything the encoder refuses as a typed
89
+ * failure.
90
+ *
91
+ * The encoder's failure channel covers every value it declines, a `BigInt` and
92
+ * a cycle included, so nothing here has to catch a throw. What used to reach
93
+ * the emit as a `Die` came from the canonicalizer running BEFORE this, over a
94
+ * raw value it was never given the guards for; {@link encodeFingerprint} runs
95
+ * it after.
96
+ */
97
+ const encodeJson = (value, field) => Schema.encodeUnknownResult(UnknownFromJsonString)(value).pipe(Result.mapError((cause) => error("invalid_event", `${field} must be JSON-serializable`, cause)));
98
+ /**
99
+ * The persisted bytes of one field: JSON, with object members in a stable key
100
+ * order.
101
+ *
102
+ * Encoding happens FIRST and canonicalization runs over the parsed result, so
103
+ * the bytes keep exactly the `JSON.stringify` semantics they had before
104
+ * idempotency became key-order independent: a `Date` is its ISO string, an
105
+ * `undefined` member is dropped, `NaN` is `null`. Canonicalizing the raw value
106
+ * instead silently destroyed every one of those.
107
+ *
108
+ * Nesting is bounded at `Redaction.maxDepth`, the same ceiling the default
109
+ * redactor enforces one step earlier, so a value that reaches this walk through
110
+ * `Redaction.makeNoop()` is refused as `invalid_event` rather than exhausting
111
+ * the stack as a defect.
112
+ */
113
+ const encodeFingerprint = (value, field) => Result.flatMap(encodeJson(value, field), (json) => Result.try({
114
+ try: () => JSON.stringify(canonicalizeFingerprint(JSON.parse(json), 0)),
115
+ catch: (cause) => error("invalid_event", `${field} could not be canonicalized`, cause)
116
+ }));
117
+ /** The one unfenced write posture, named so a call site cannot mean it by accident. */
118
+ const unfenced = { _tag: "Unfenced" };
119
+ const decodeOwner = Schema.decodeUnknownResult(OwnerId);
120
+ /**
121
+ * Decodes the mandatory owner of a fenced method.
122
+ *
123
+ * A missing, null, or malformed owner is a caller contract violation, not a
124
+ * lost race: reporting it as `fence_lost` would tell the caller another
125
+ * process owns the run and send it looking for a conflict that never
126
+ * happened.
127
+ */
128
+ const requireFence = (owner, method) => Result.match(decodeOwner(owner), {
129
+ onFailure: (cause) => Result.fail(error("invalid_event", `${method} requires a well-formed owner fence`, cause)),
130
+ onSuccess: (value) => Result.succeed({ _tag: "Owned", owner: value })
131
+ });
132
+ const decodeInput = Schema.decodeUnknownResult(Input);
133
+ const decodeEntry = Schema.decodeUnknownEffect(Entry);
134
+ /**
135
+ * The read and maintenance boundaries decode the schemas they publish.
136
+ *
137
+ * `EntriesOptions`, `StreamOptions`, `CheckpointOptions`, `CompactOptions` and
138
+ * `RunId` already carry every invariant these methods need, and hand-checking a
139
+ * subset of them here let the read side disagree with the write side about what
140
+ * an identifier is.
141
+ */
142
+ const decodeEntriesOptions = Schema.decodeUnknownResult(EntriesOptions);
143
+ const decodeStreamOptions = Schema.decodeUnknownResult(StreamOptions);
144
+ const decodeCheckpointOptions = Schema.decodeUnknownResult(CheckpointOptions);
145
+ const decodeCompactOptions = Schema.decodeUnknownResult(CompactOptions);
146
+ const decodeRunId = Schema.decodeUnknownResult(RunId);
147
+ const decodeRow = (row) => Effect.all({
148
+ payload: Schema.decodeUnknownEffect(UnknownFromJsonString)(row.payload_json),
149
+ meta: Schema.decodeUnknownEffect(UnknownFromJsonString)(row.meta_json)
150
+ }).pipe(Effect.map(({ meta, payload }) => ({
151
+ runId: row.run_id,
152
+ seq: Number(row.seq),
153
+ eventId: row.event_id,
154
+ sourceId: row.source_id,
155
+ sourceSeq: Number(row.source_seq),
156
+ emittedAtMs: Number(row.emitted_at_ms),
157
+ eventType: row.event_type,
158
+ payload,
159
+ meta
160
+ })), Effect.flatMap(decodeEntry), Effect.mapError((cause) => error("decode_failed", "could not decode a durable journal row", cause)));
161
+ const decodeCheckpoint = Schema.decodeUnknownEffect(Checkpoint);
162
+ const decodeCheckpointRow = (row) => Schema.decodeUnknownEffect(UnknownFromJsonString)(row.state_json).pipe(Effect.flatMap((state) => decodeCheckpoint({
163
+ runId: row.run_id,
164
+ seq: Number(row.seq),
165
+ state,
166
+ createdAtMs: Number(row.created_at_ms),
167
+ compactedAtMs: row.compacted_at_ms === null ? null : Number(row.compacted_at_ms)
168
+ })), Effect.mapError((cause) => error("decode_failed", "could not decode a durable checkpoint row", cause)));
169
+ /** Freezes the one object graph shared by every changes subscriber. */
170
+ const freezePublished = (value) => {
171
+ const seen = new WeakSet();
172
+ const walk = (node) => {
173
+ if (node === null || typeof node !== "object")
174
+ return;
175
+ /* v8 ignore next -- decoded JSON and Entry instances are acyclic, but this guard keeps the walk total */
176
+ if (seen.has(node))
177
+ return;
178
+ seen.add(node);
179
+ for (const key of Reflect.ownKeys(node)) {
180
+ walk(node[key]);
181
+ }
182
+ Object.freeze(node);
183
+ };
184
+ walk(value);
185
+ return value;
186
+ };
187
+ /**
188
+ * UTF-8 size of the bytes an entry persists.
189
+ *
190
+ * `TextEncoder` rather than `Buffer.byteLength` because this module has no
191
+ * other `node:` import and the journal's browser-facing consumers bundle it.
192
+ */
193
+ const encodedBytes = (encoder, payloadJson, metaJson) => encoder.encode(payloadJson).length + encoder.encode(metaJson).length;
194
+ const validateOptions = (options) => Effect.suspend(() => {
195
+ if (!Number.isSafeInteger(options.capacity) || options.capacity <= 0) {
196
+ return Effect.fail(error("invalid_event", "capacity must be a positive safe integer"));
197
+ }
198
+ const batchSize = options.batchSize ?? Math.min(options.capacity, 64);
199
+ if (!Number.isSafeInteger(batchSize) || batchSize <= 0) {
200
+ return Effect.fail(error("invalid_event", "batchSize must be a positive safe integer"));
201
+ }
202
+ const sourceEventCache = options.sourceEventCache ?? defaultSourceEventCache;
203
+ if (!Number.isSafeInteger(sourceEventCache) || sourceEventCache <= 0) {
204
+ return Effect.fail(error("invalid_event", "sourceEventCache must be a positive safe integer"));
205
+ }
206
+ if (options.maxEntryBytes !== undefined &&
207
+ (!Number.isSafeInteger(options.maxEntryBytes) || options.maxEntryBytes <= 0)) {
208
+ return Effect.fail(error("invalid_event", "maxEntryBytes must be a positive safe integer"));
209
+ }
210
+ if (options.compaction !== undefined &&
211
+ (!Number.isSafeInteger(options.compaction.entryThreshold) || options.compaction.entryThreshold <= 0)) {
212
+ return Effect.fail(error("invalid_event", "compaction.entryThreshold must be a positive safe integer"));
213
+ }
214
+ return Effect.succeed({
215
+ batchSize,
216
+ sourceEventCache,
217
+ maxEntryBytes: options.maxEntryBytes,
218
+ redact: options.redact ?? Redaction.make()
219
+ });
220
+ });
221
+ const isJournalError = Schema.is(JournalError);
222
+ /**
223
+ * Constructs the SQL journal over the context's SQL client, durable writer,
224
+ * and consensus strategy.
225
+ *
226
+ * `emitLossy` validates and admits telemetry to the non-blocking queue;
227
+ * `emitDurable` allocates and commits inside the database transaction, fenced
228
+ * through `Consensus.guard` in that same transaction.
229
+ *
230
+ * @category constructors
231
+ * @since 1.0.0
232
+ */
233
+ export const make = (options) => Effect.gen(function* () {
234
+ const { batchSize, maxEntryBytes, redact, sourceEventCache } = yield* validateOptions(options);
235
+ // `batchSize` sizes the writer's transactions. `stream` pages the durable
236
+ // tail through `entries`, whose `limit` is bounded by `maxEntriesLimit`,
237
+ // so a larger batch must not leak into the read boundary: a layer that
238
+ // was accepted with `batchSize: 16384` streamed nothing but
239
+ // `invalid_event` before this clamp.
240
+ const readPageSize = Math.min(batchSize, maxEntriesLimit);
241
+ const sql = yield* Effect.service(SqlClient.SqlClient);
242
+ const writer = yield* DurableWriter;
243
+ const consensus = yield* Consensus;
244
+ yield* JournalGeneration.initialize.pipe(Effect.mapError((cause) => error("read_failed", "could not initialize journal generations", cause)));
245
+ /** One encoder for the layer: constructing one per emit measured slower. */
246
+ const byteCounter = new TextEncoder();
247
+ // Hash the same encoded content the ordinary dedup check compares.
248
+ // A JSON tuple keeps boundaries unambiguous without retaining payloads.
249
+ const contentFingerprint = (eventType, payloadJson, metaJson) => Effect.tryPromise({
250
+ try: () => crypto.subtle.digest("SHA-256", byteCounter.encode(JSON.stringify([eventType, payloadJson, metaJson]))),
251
+ catch: (cause) => error("sink_failed", "could not fingerprint journal content", cause)
252
+ }).pipe(Effect.map((digest) => Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("")));
253
+ const queue = yield* Queue.dropping(options.capacity);
254
+ const changes = yield* PubSub.sliding(options.capacity);
255
+ const wakes = new Map();
256
+ /**
257
+ * The durable cursor of every live in-process stream, per run: the
258
+ * highest committed sequence the stream has read from the store, or its
259
+ * starting `afterSequence`. `compact` refuses to truncate below a
260
+ * registered cursor, so a live follower's next durable page is never
261
+ * deleted out from under it. Readers this process cannot see, pagers
262
+ * of `entries`, followers in other processes, are covered by the
263
+ * read-side `compacted` guard instead.
264
+ */
265
+ const readers = new Map();
266
+ /**
267
+ * Only the most recent `sourceEventCache` events are decoded at startup.
268
+ * Older events stay durable-only: their idempotency is enforced by the
269
+ * writer's `(run_id, source_id, source_seq)` re-check, so the process
270
+ * never has to hold the whole history to stay correct.
271
+ */
272
+ const sourceEventRows = yield* sql `
273
+ SELECT run_id, seq, event_id, source_id, source_seq, emitted_at_ms,
274
+ event_type, payload_json, meta_json
275
+ FROM flows_journal_events
276
+ ORDER BY emitted_at_ms DESC, run_id DESC, seq DESC
277
+ LIMIT ${sourceEventCache}
278
+ `.pipe(Effect.mapError((cause) => error("sink_failed", "could not initialize journal source events", cause)));
279
+ const durableEntries = yield* Effect.forEach(sourceEventRows, decodeRow);
280
+ const initialized = yield* Effect.fromResult(Result.gen(function* () {
281
+ // The two allocation floors start EMPTY and are filled one run at a
282
+ // time by `ensureFloors`. They used to be seeded by two unbounded
283
+ // `GROUP BY` aggregations, one entry per run that ever wrote an
284
+ // event, one per `(run_id, source_id)` pair, so layer construction
285
+ // scanned the whole table and built a map proportional to total
286
+ // history, which is exactly what the bound below exists to avoid.
287
+ const sequences = new Map();
288
+ const sourceSequences = new Map();
289
+ const sourceEvents = new Map();
290
+ // Seeded oldest-first so the map's insertion order stays the
291
+ // eviction order once `retain` starts adding newer events.
292
+ for (const entry of [...durableEntries].reverse()) {
293
+ if (!Number.isSafeInteger(entry.seq) ||
294
+ !Number.isSafeInteger(entry.sourceSeq) ||
295
+ entry.seq === Number.MAX_SAFE_INTEGER ||
296
+ entry.sourceSeq === Number.MAX_SAFE_INTEGER) {
297
+ return yield* Result.fail(error("decode_failed", "durable event sequence is outside the allocatable safe integer range"));
298
+ }
299
+ sourceEvents.set(sourceEventKey(entry.runId, entry.sourceId, entry.sourceSeq), {
300
+ seq: entry.seq,
301
+ eventType: entry.eventType,
302
+ payloadJson: yield* encodeFingerprint(entry.payload, "payload"),
303
+ metaJson: yield* encodeFingerprint(entry.meta, "meta"),
304
+ status: "committed"
305
+ });
306
+ }
307
+ return {
308
+ sequences,
309
+ sourceSequences,
310
+ sourceEvents
311
+ };
312
+ }));
313
+ const state = {
314
+ status: "open",
315
+ sinkFailure: undefined,
316
+ lossEpoch: 0,
317
+ flushedLossEpoch: 0,
318
+ pending: 0,
319
+ pendingByRun: new Map(),
320
+ sequences: initialized.sequences,
321
+ sourceSequences: initialized.sourceSequences,
322
+ sourceEvents: initialized.sourceEvents,
323
+ flushWaiters: new Set()
324
+ };
325
+ // The permit protects only synchronous in-memory reservation. It must
326
+ // never be held while waiting on SQLite: an enclosing `transact` owns a
327
+ // database transaction, so DB -> allocator and allocator -> DB ordering
328
+ // would otherwise deadlock concurrent lifecycle writers.
329
+ const allocation = yield* Semaphore.make(1);
330
+ // Close this scope AFTER the journal's flushing finalizer. Dynamically
331
+ // forking directly into the layer scope would register interruption
332
+ // finalizers ahead of the flush that must await these attempts.
333
+ const maintenanceScope = yield* Scope.make();
334
+ yield* Effect.addFinalizer((exit) => Scope.close(maintenanceScope, exit));
335
+ let pendingMaintenance = 0;
336
+ const compactionCounts = new Map();
337
+ const compactingRuns = new Set();
338
+ const runBarriers = new Map();
339
+ const runDrainWaiters = new Map();
340
+ const activeWritesByRun = new Map();
341
+ const barrierFor = (runId) => {
342
+ const existing = runBarriers.get(runId);
343
+ if (existing !== undefined)
344
+ return existing;
345
+ const created = {
346
+ semaphore: Semaphore.makeUnsafe(1),
347
+ compactionLock: Semaphore.makeUnsafe(1),
348
+ maintenance: Semaphore.makeUnsafe(1),
349
+ users: 0,
350
+ compaction: undefined
351
+ };
352
+ runBarriers.set(runId, created);
353
+ return created;
354
+ };
355
+ /** A waiter owns a reference too, before it can yield on any permit. */
356
+ const withRunBarrier = (runId, use) => Effect.suspend(() => {
357
+ const barrier = barrierFor(runId);
358
+ barrier.users += 1;
359
+ return use(barrier).pipe(Effect.ensuring(Effect.sync(() => {
360
+ barrier.users -= 1;
361
+ })));
362
+ });
363
+ /** Flush is the retirement boundary; allocation floors remain monotonic. */
364
+ const retireQuiescentRuns = () => {
365
+ for (const [runId, barrier] of runBarriers) {
366
+ if (barrier.users > 0 || barrier.compaction !== undefined ||
367
+ state.pendingByRun.has(runId) || activeWritesByRun.has(runId) ||
368
+ runDrainWaiters.has(runId) || readers.has(runId) || wakes.has(runId) ||
369
+ compactingRuns.has(runId))
370
+ continue;
371
+ runBarriers.delete(runId);
372
+ compactionCounts.delete(runId);
373
+ }
374
+ };
375
+ /** Blocks new admissions while a compaction owns the run. */
376
+ const withRunAdmission = (runId, effect) => withRunBarrier(runId, (barrier) => barrier.semaphore.withPermit(Effect.suspend(() => {
377
+ const gate = barrier.compaction;
378
+ return gate === undefined
379
+ ? Effect.map(effect, (value) => ({ _tag: "Done", value }))
380
+ : Effect.succeed({ _tag: "Wait", gate });
381
+ })).pipe(Effect.flatMap((attempt) => attempt._tag === "Done"
382
+ ? Effect.succeed(attempt.value)
383
+ : Deferred.await(attempt.gate).pipe(Effect.andThen(withRunAdmission(runId, effect))))));
384
+ /** Acquires batch run permits in stable order so mixed-run batches cannot deadlock. */
385
+ const withRunPermits = (runIds, effect) => {
386
+ const ordered = [...new Set(runIds)].sort();
387
+ const acquire = (index) => index === ordered.length
388
+ ? effect
389
+ : withRunBarrier(ordered[index], (barrier) => barrier.semaphore.withPermit(acquire(index + 1)));
390
+ return acquire(0);
391
+ };
392
+ const admitRunPending = (queued) => {
393
+ const pending = state.pendingByRun.get(queued.runId) ?? new Set();
394
+ pending.add(queued);
395
+ state.pendingByRun.set(queued.runId, pending);
396
+ };
397
+ const completeRunDrain = (runId) => {
398
+ if ((state.pendingByRun.get(runId)?.size ?? 0) + (activeWritesByRun.get(runId) ?? 0) > 0)
399
+ return;
400
+ const waiters = runDrainWaiters.get(runId);
401
+ runDrainWaiters.delete(runId);
402
+ for (const waiter of waiters ?? []) {
403
+ Deferred.doneUnsafe(waiter, Effect.void);
404
+ }
405
+ };
406
+ const settleRunPending = (batch) => {
407
+ for (const queued of batch) {
408
+ // Every batched entry was counted by `admitRunPending` when it was
409
+ // admitted, so the run always has a count to settle here.
410
+ const pending = state.pendingByRun.get(queued.runId);
411
+ pending.delete(queued);
412
+ if (pending.size > 0)
413
+ continue;
414
+ state.pendingByRun.delete(queued.runId);
415
+ completeRunDrain(queued.runId);
416
+ }
417
+ };
418
+ const endRunWrite = (runId) => {
419
+ // `withActiveRunWrite` counted this write in before running it, and
420
+ // releases through here exactly once.
421
+ const remaining = activeWritesByRun.get(runId) - 1;
422
+ if (remaining > 0) {
423
+ activeWritesByRun.set(runId, remaining);
424
+ return;
425
+ }
426
+ activeWritesByRun.delete(runId);
427
+ completeRunDrain(runId);
428
+ };
429
+ /**
430
+ * Register only INSIDE the writer transaction, after SQL acquisition.
431
+ * A parked writer has touched neither SQL nor the allocation floors, so
432
+ * the SQL lock orders it after the compactor's commit. It then allocates
433
+ * its canonical seq above the surviving checkpoint/tail. An explicit
434
+ * sourceSeq chosen earlier is an identity, not a replay cursor; the
435
+ * insert still checks that identity against rows and retained tombstones.
436
+ * No SQL owner may drain a writer parked on that owner's connection.
437
+ */
438
+ const withActiveRunWrite = (runId, effect) => withRunBarrier(runId, () => Effect.suspend(() => {
439
+ activeWritesByRun.set(runId, (activeWritesByRun.get(runId) ?? 0) + 1);
440
+ return effect.pipe(Effect.ensuring(Effect.sync(() => endRunWrite(runId))));
441
+ }));
442
+ const awaitRunDrained = (runId) => Effect.suspend(() => {
443
+ if ((state.pendingByRun.get(runId)?.size ?? 0) + (activeWritesByRun.get(runId) ?? 0) === 0) {
444
+ return Effect.void;
445
+ }
446
+ const waiter = Deferred.makeUnsafe();
447
+ const waiters = runDrainWaiters.get(runId) ?? new Set();
448
+ waiters.add(waiter);
449
+ runDrainWaiters.set(runId, waiters);
450
+ // No cleanup on the way out: `completeRunDrain` deletes the whole
451
+ // set as it completes it, and an interrupted waiter is a Deferred
452
+ // nobody awaits that the next drain of this run discards. Deleting
453
+ // it here would only add an unreachable branch.
454
+ return Deferred.await(waiter);
455
+ });
456
+ /**
457
+ * Top-level compaction drains with admission OPEN, acquires SQL before
458
+ * either run lock or the gate, then rechecks the drain. Raced work makes
459
+ * it release SQL and retry. No connection owner waits on work needing SQL.
460
+ *
461
+ * An enclosing managed OR raw SQL transaction cannot release its SQL by
462
+ * returning from our savepoint. Skip both drains there and persist this
463
+ * run's pending entries inline, using the batch insert path under the
464
+ * same transaction and gate. The pending set includes entries already
465
+ * taken by the batch fiber but still waiting for SQL. Keep them queued
466
+ * and counted until that fiber settles: rollback must leave its work
467
+ * intact, and after commit it collapses onto the inline inserts by identity.
468
+ * Publication is owned by the outer commit, exactly as for durable emits.
469
+ * Raw SQL has no managed publication boundary; replay remains authoritative.
470
+ *
471
+ * A reservation allocated before compaction is NOT a committed cursor.
472
+ * The inline insert raises its canonical seq above the durable tail,
473
+ * even if its reservation is below the checkpoint being compacted. Thus
474
+ * it survives replay after that checkpoint with its source identity and
475
+ * content checks unchanged. Leaving it queued without inserting would
476
+ * instead let the new floor reject the old reservation as `compacted`.
477
+ * Already compacted reservations retain that existing loss behavior.
478
+ *
479
+ * The final drain check and gate closure share a short admission permit,
480
+ * released before SQL work. Cold floor reads hold no run permit. Durable
481
+ * writes register only after SQL acquisition. Concurrent compactors also
482
+ * acquire SQL before their compaction lock. All gates/locks unwind before
483
+ * retry or cancellation; no run lock or closed gate waits for SQL.
484
+ */
485
+ const withCompactionBarrier = (runId, effect) => Effect.flatMap(Effect.serviceOption(sql.transactionService), (enclosing) => withRunBarrier(runId, (barrier) => {
486
+ const nested = Option.isSome(enclosing);
487
+ const attempt = (nested ? Effect.void : awaitRunDrained(runId)).pipe(Effect.andThen(writer.write(barrier.compactionLock.withPermit(Effect.uninterruptibleMask((restore) => barrier.semaphore.withPermit(Effect.sync(() => {
488
+ if (!nested && (state.pendingByRun.get(runId)?.size ?? 0) + (activeWritesByRun.get(runId) ?? 0) > 0) {
489
+ return undefined;
490
+ }
491
+ const gate = Deferred.makeUnsafe();
492
+ barrier.compaction = gate;
493
+ return gate;
494
+ })).pipe(Effect.flatMap((gate) => gate === undefined ?
495
+ Effect.succeed(Option.none()) :
496
+ restore(Effect.gen(function* () {
497
+ if (nested) {
498
+ const outcome = yield* insertBatch([...state.pendingByRun.get(runId) ?? []]);
499
+ // The batch still owns loss reporting and pending
500
+ // settlement; neither may escape an outer rollback.
501
+ for (const { queued, commit } of outcome.commits) {
502
+ yield* settleCommit(queued, commit, restore);
503
+ }
504
+ }
505
+ return yield* Effect.asSome(effect);
506
+ })).pipe(Effect.ensuring(Effect.sync(() => {
507
+ barrier.compaction = undefined;
508
+ Deferred.doneUnsafe(gate, Effect.void);
509
+ }))))))))), Effect.flatMap((result) => Option.isSome(result) ? Effect.succeed(result.value) : attempt));
510
+ return attempt;
511
+ }));
512
+ /**
513
+ * Raises the in-process seq allocation floor for a run.
514
+ *
515
+ * Both emit paths call this at the moment they allocate, so the floor
516
+ * never names a seq some writer has already taken. It used to move only
517
+ * in `rememberCommitted`, which `settleCommit` parks until the outermost
518
+ * COMMIT, so a durable emit inside an open `transact` left the floor
519
+ * behind, `emitLossy` allocated the same seq from it, and the lossy
520
+ * INSERT hit `PRIMARY KEY (run_id, seq)`.
521
+ *
522
+ * The floor only ever rises. `insertOne` can settle on a raced duplicate
523
+ * whose row was written by another process at a higher seq, and that
524
+ * still has to raise the floor here.
525
+ */
526
+ const raiseSequenceFloor = (runId, seq) => {
527
+ state.sequences.set(runId, Math.max(state.sequences.get(runId) ?? 0, seq + 1));
528
+ };
529
+ /** Raises the in-process producer-sequence allocation floor. */
530
+ const raiseSourceSequenceFloor = (runId, sourceId, sourceSeq) => {
531
+ const key = sourceKey(runId, sourceId);
532
+ state.sourceSequences.set(key, Math.max(state.sourceSequences.get(key) ?? 0, sourceSeq + 1));
533
+ };
534
+ /**
535
+ * Adds an entry to the bounded source-event index, evicting the
536
+ * least-recently added *committed* entry when the bound is exceeded.
537
+ *
538
+ * Uncommitted entries are never evicted: they are not in the database
539
+ * yet, so memory is the only place their identity exists. Committed ones
540
+ * are always re-derivable from `flows_journal_events`, which is what
541
+ * makes the bound safe.
542
+ */
543
+ const retain = (identity, event) => {
544
+ state.sourceEvents.delete(identity);
545
+ state.sourceEvents.set(identity, event);
546
+ if (state.sourceEvents.size <= sourceEventCache) {
547
+ return;
548
+ }
549
+ for (const [candidate, retained] of state.sourceEvents) {
550
+ if (retained.status !== "committed" || candidate === identity) {
551
+ continue;
552
+ }
553
+ state.sourceEvents.delete(candidate);
554
+ return;
555
+ }
556
+ };
557
+ const completeFlushWaiters = (exit) => {
558
+ const waiters = Array.from(state.flushWaiters);
559
+ state.flushWaiters.clear();
560
+ for (const waiter of waiters) {
561
+ Deferred.doneUnsafe(waiter, exit);
562
+ }
563
+ };
564
+ const flushInternal = Effect.suspend(() => {
565
+ // A loss that happened while nothing was registered still has to reach
566
+ // a caller, so the first flush after it reports it, once. A later
567
+ // flush has nothing to do with the lost batch and must succeed, or a
568
+ // single transient outage would stall every durable delivery that
569
+ // flushes (`DeferredPersistence.completeDeferred`, `recordClockScheduled`)
570
+ // for the process's lifetime.
571
+ if (state.sinkFailure !== undefined && state.lossEpoch > state.flushedLossEpoch) {
572
+ state.flushedLossEpoch = state.lossEpoch;
573
+ return Effect.fail(state.sinkFailure);
574
+ }
575
+ if (state.status === "closed") {
576
+ return Effect.fail(error("journal_closed", "journal is closed"));
577
+ }
578
+ if (state.pending === 0 && pendingMaintenance === 0) {
579
+ return Effect.void;
580
+ }
581
+ const waiter = Deferred.makeUnsafe();
582
+ state.flushWaiters.add(waiter);
583
+ return Deferred.await(waiter).pipe(Effect.ensuring(Effect.sync(() => {
584
+ state.flushWaiters.delete(waiter);
585
+ })));
586
+ }).pipe(Effect.ensuring(Effect.sync(retireQuiescentRuns)));
587
+ const prepare = (input, emittedAtMs) => Result.gen(function* () {
588
+ if (state.status !== "open") {
589
+ return yield* Result.fail(error("journal_closed", "journal is closed"));
590
+ }
591
+ // `RunId`, `SourceId`, and `Input.eventType` carry the non-empty and
592
+ // well-formed-UTF-16 checks themselves, so decode is the whole
593
+ // identifier contract. The service used to re-check emptiness here,
594
+ // which let a caller hold an identifier that decoded and then failed
595
+ // at the write.
596
+ const validated = yield* Result.mapError(decodeInput(input), (cause) => error("invalid_event", "event violates the journal input contract", cause));
597
+ if (!Number.isSafeInteger(emittedAtMs) || emittedAtMs < 0) {
598
+ return yield* Result.fail(error("invalid_event", "emittedAtMs must be a non-negative safe integer"));
599
+ }
600
+ // Redaction happens here, at the single point every channel funnels
601
+ // through, so no write path can bypass it (issue #46). A redaction
602
+ // failure carries no cause: whatever the redactor or a hostile getter
603
+ // threw can quote the unredacted value, and that value never reached
604
+ // the journal, so the error must not be the place it leaks from.
605
+ const redactedPayload = yield* Result.try({
606
+ try: () => redact(validated.payload),
607
+ catch: () => error("invalid_event", "payload could not be redacted")
608
+ });
609
+ const redactedMeta = yield* Result.try({
610
+ try: () => redact(validated.meta ?? null),
611
+ catch: () => error("invalid_event", "meta could not be redacted")
612
+ });
613
+ const payloadJson = yield* encodeFingerprint(redactedPayload, "payload");
614
+ const metaJson = yield* encodeFingerprint(redactedMeta, "meta");
615
+ // Measured on the encoded bytes, and here rather than at admission,
616
+ // so a refused entry costs no sequence and leaves no gap in the run.
617
+ if (maxEntryBytes !== undefined) {
618
+ const bytes = encodedBytes(byteCounter, payloadJson, metaJson);
619
+ if (bytes > maxEntryBytes) {
620
+ return yield* Result.fail(error("invalid_event", `event is ${bytes} bytes, over the ${maxEntryBytes}-byte maxEntryBytes bound`));
621
+ }
622
+ }
623
+ return { validated, payloadJson, metaJson };
624
+ });
625
+ const compareSourceEvent = (prepared, sourceSeq, existing) => {
626
+ const { metaJson, payloadJson, validated } = prepared;
627
+ if (validated.dedupe !== "identity" && (existing.eventType !== validated.eventType ||
628
+ existing.payloadJson !== payloadJson ||
629
+ existing.metaJson !== metaJson)) {
630
+ return Result.fail(error("idempotency_conflict", `source event ${validated.sourceId}:${sourceSeq} for run ${validated.runId} was reused with different content`));
631
+ }
632
+ return Result.succeed({
633
+ _tag: "Duplicate",
634
+ seq: existing.seq,
635
+ sourceSeq,
636
+ status: existing.status
637
+ });
638
+ };
639
+ /**
640
+ * Answers an explicit producer identity from memory alone, before
641
+ * anything is allocated.
642
+ *
643
+ * This performs NO read. That is the whole point of it: `emitLossy` is
644
+ * the channel a producer reaches for precisely because it may be called
645
+ * from inside somebody else's open write transaction, and this used to
646
+ * issue a SELECT here for every explicit sequence. The executor's exit
647
+ * flush in `@smthrs/agent` runs inside the engine's write transaction,
648
+ * so that read waited on the writer that was waiting on the flush and
649
+ * the run stalled at 0% CPU: measured, that case did not finish in
650
+ * 120 s with the read and takes about half a second without it. What the
651
+ * read used to answer, a producer identity that is already durable but
652
+ * no longer in the bounded index, the unique index
653
+ * `(run_id, source_id, source_seq)` answers at insert time instead: the
654
+ * queued insert runs first and reads only the row it actually collided
655
+ * with, inside the writer's own transaction where a read cannot
656
+ * deadlock against it.
657
+ *
658
+ * A cache miss therefore admits optimistically. The receipt is
659
+ * `Accepted` where it would once have been `Duplicate`, which the lossy
660
+ * channel already allows for: `Accepted` is admission to the queue, not
661
+ * a commit, and the entry still collapses onto the committed row rather
662
+ * than doubling it.
663
+ *
664
+ * Lock order: lossy admission checks cached identities without SQL,
665
+ * reads cold allocation floors outside run permits on a miss, then
666
+ * rechecks the identity and reserves sequences under
667
+ * the permit using max(read floor, in-memory floor). Durable writers
668
+ * raise those caches at allocation time and take no run permit. Batches
669
+ * take the writer transaction before run permits, so their SQL already
670
+ * owns the connection. Compaction drains with admission open, acquires
671
+ * SQL before its run locks, then rechecks the drain and closes the gate.
672
+ * At top level, raced work makes it release SQL and drain again. Inside
673
+ * an enclosing transaction it persists pending entries inline instead;
674
+ * draining there would wait on work needing its own connection. Durable
675
+ * writes register as active only after acquiring SQL. No permit or
676
+ * closed gate ever waits for a SQL connection, and
677
+ * a transaction-owning producer cannot encounter a compactor's gate or
678
+ * permit: that compactor cannot acquire SQL until the producer commits.
679
+ *
680
+ * Reading floors under a permit would invert the batch's order: a new
681
+ * producer waits on the batch's connection while the batch waits on its
682
+ * permit. Moving batch permits before the transaction would instead
683
+ * block even readless admission from inside an enclosing transaction.
684
+ * Keep floor reads outside permits and collision reads inside the
685
+ * writer transaction. `ensureFloors` documents cache lifetime, including
686
+ * the quiescent truncation contract that permits floor invalidation.
687
+ */
688
+ const admitFromIndex = (prepared) => {
689
+ const { validated } = prepared;
690
+ const sourceSeq = validated.sourceSeq;
691
+ if (sourceSeq === undefined)
692
+ return Result.succeed(undefined);
693
+ if (!Number.isSafeInteger(sourceSeq) ||
694
+ sourceSeq < 0 ||
695
+ sourceSeq === Number.MAX_SAFE_INTEGER) {
696
+ return Result.fail(error("invalid_event", "journal sequence is outside the allocatable safe integer range"));
697
+ }
698
+ const cached = state.sourceEvents.get(sourceEventKey(validated.runId, validated.sourceId, sourceSeq));
699
+ return cached === undefined ? Result.succeed(undefined) : compareSourceEvent(prepared, sourceSeq, cached);
700
+ };
701
+ /**
702
+ * The in-memory half of a lossy admission: the identity check, the
703
+ * allocation, and the queue offer. It runs under the run permit and
704
+ * performs no I/O there; `admitFromIndex` documents the lock order that
705
+ * keeps.
706
+ */
707
+ const admitQueued = (prepared, floors, emittedAtMs) => Effect.suspend(() => {
708
+ // Preparation precedes the floor read and admission waits. Check
709
+ // closure and the identity again only after acquiring the permit,
710
+ // before allocating or returning a cached duplicate.
711
+ if (state.status !== "open") {
712
+ return Effect.fail(error("journal_closed", "journal is closed"));
713
+ }
714
+ return Effect.flatMap(Effect.fromResult(admitFromIndex(prepared)), (indexed) => indexed !== undefined
715
+ ? Effect.succeed(indexed)
716
+ : allocation.withPermit(Effect.fromResult(Result.gen(function* () {
717
+ const { metaJson, payloadJson, validated } = prepared;
718
+ const key = sourceKey(validated.runId, validated.sourceId);
719
+ const nextSourceSeq = Math.max(floors.sourceSeq, state.sourceSequences.get(key) ?? 0);
720
+ // An explicit producer sequence was validated by
721
+ // `admitFromIndex`, and an implicit one is the floor
722
+ // `ensureFloors` already proved to be a non-negative
723
+ // safe integer. Only exhaustion is left: the allocator
724
+ // cannot advance past MAX_SAFE_INTEGER, and neither can
725
+ // the identity that follows it.
726
+ const sourceSeq = validated.sourceSeq ?? nextSourceSeq;
727
+ if (sourceSeq === Number.MAX_SAFE_INTEGER) {
728
+ return yield* Result.fail(error("invalid_event", "journal sequence is outside the allocatable safe integer range"));
729
+ }
730
+ const nextSeq = Math.max(floors.seq, state.sequences.get(validated.runId) ?? 0);
731
+ if (nextSeq === Number.MAX_SAFE_INTEGER) {
732
+ return yield* Result.fail(error("invalid_event", "journal sequence is outside the allocatable safe integer range"));
733
+ }
734
+ const seq = nextSeq;
735
+ raiseSequenceFloor(validated.runId, seq);
736
+ state.sourceSequences.set(key, Math.max(nextSourceSeq, sourceSeq + 1));
737
+ const queued = {
738
+ runId: validated.runId,
739
+ seq,
740
+ eventId: makeEventId(validated.runId, validated.sourceId, sourceSeq),
741
+ sourceId: validated.sourceId,
742
+ sourceSeq,
743
+ emittedAtMs,
744
+ eventType: validated.eventType,
745
+ payloadJson,
746
+ metaJson,
747
+ dedupe: validated.dedupe ?? "content"
748
+ };
749
+ let evicted;
750
+ if (Queue.sizeUnsafe(queue) >= options.capacity && options.overflow === "drop-oldest") {
751
+ const exit = Queue.takeUnsafe(queue);
752
+ /* v8 ignore next -- size and take run synchronously while the journal and queue are open */
753
+ if (exit === undefined || !Exit.isSuccess(exit)) {
754
+ return yield* Result.fail(error("journal_closed", "journal admission queue is unavailable"));
755
+ }
756
+ evicted = exit.value;
757
+ const evictedIdentity = sourceEventKey(evicted.runId, evicted.sourceId, evicted.sourceSeq);
758
+ state.sourceEvents.delete(evictedIdentity);
759
+ state.pending = Math.max(0, state.pending - 1);
760
+ settleRunPending([evicted]);
761
+ }
762
+ const accepted = Queue.offerUnsafe(queue, queued);
763
+ if (!accepted) {
764
+ if (options.overflow === "reject") {
765
+ return yield* Result.fail(error("queue_overflow", "journal admission queue is full"));
766
+ }
767
+ return {
768
+ _tag: "Dropped",
769
+ seq,
770
+ sourceSeq,
771
+ policy: "drop-newest"
772
+ };
773
+ }
774
+ retain(sourceEventKey(validated.runId, validated.sourceId, sourceSeq), {
775
+ seq,
776
+ eventType: validated.eventType,
777
+ payloadJson,
778
+ metaJson,
779
+ status: "pending"
780
+ });
781
+ state.pending += 1;
782
+ admitRunPending(queued);
783
+ if (evicted !== undefined) {
784
+ return {
785
+ _tag: "Accepted",
786
+ seq,
787
+ sourceSeq,
788
+ evicted: {
789
+ policy: "drop-oldest",
790
+ count: 1
791
+ }
792
+ };
793
+ }
794
+ return {
795
+ _tag: "Accepted",
796
+ seq,
797
+ sourceSeq
798
+ };
799
+ }))));
800
+ });
801
+ const queuedEmit = Effect.fn("Journal.emitLossy")((input) => Effect.annotateCurrentSpan({
802
+ runId: input.runId,
803
+ sourceId: input.sourceId,
804
+ eventType: input.eventType
805
+ }).pipe(Effect.andThen(Clock.currentTimeMillis.pipe(Effect.map(Math.floor))), Effect.flatMap((emittedAtMs) => Effect.flatMap(withRunAdmission(input.runId, Effect.suspend(() => Effect.fromResult(Result.gen(function* () {
806
+ const prepared = yield* prepare(input, emittedAtMs);
807
+ const indexed = yield* admitFromIndex(prepared);
808
+ return { prepared, indexed };
809
+ })))), ({ prepared, indexed }) =>
810
+ // A cached duplicate needs neither allocation floor, including
811
+ // after startup when the identity index is warm but floors are
812
+ // cold. On a miss, release the permit before reading SQL, then
813
+ // recheck identity and allocation under a fresh admission.
814
+ indexed !== undefined ? Effect.succeed(indexed) : Effect.flatMap(ensureFloors(prepared.validated), (floors) => withRunAdmission(prepared.validated.runId, admitQueued(prepared, floors, emittedAtMs))))), Effect.tap((receipt) => Metric.update(JournalMetrics.lossy[receipt._tag], 1))));
815
+ /**
816
+ * The run's compaction floor: the largest checkpoint sequence whose
817
+ * lower entries have been truncated, or `undefined` while the run has
818
+ * never been compacted.
819
+ */
820
+ const compactionFloor = (runId) => Effect.map(sql `
821
+ SELECT MAX(seq) AS floor FROM flows_journal_checkpoints
822
+ WHERE run_id = ${runId} AND compacted_at_ms IS NOT NULL
823
+ `, (rows) => {
824
+ const floor = rows[0]?.floor;
825
+ return floor === null || floor === undefined ? undefined : Number(floor);
826
+ });
827
+ const readPage = Effect.fn("Journal.entries")((pageOptions) => Effect.gen(function* () {
828
+ // Snapshot the caller-owned list before the first suspension so its
829
+ // validated bound is also the bound passed to the SQL client.
830
+ const options = {
831
+ ...pageOptions,
832
+ ...(Array.isArray(pageOptions.eventTypes)
833
+ ? { eventTypes: [...pageOptions.eventTypes] } :
834
+ {})
835
+ };
836
+ yield* Effect.annotateCurrentSpan({
837
+ runId: options.runId,
838
+ limit: options.limit,
839
+ ...(options.eventTypes === undefined ? {} : { eventTypes: options.eventTypes }),
840
+ ...(options.after === undefined ? {} : { after: options.after })
841
+ });
842
+ // `EntriesOptions` already carries every one of these invariants: a
843
+ // well-formed non-empty `runId`, a `Seq` cursor, and a `limit`
844
+ // between 1 and `maxEntriesLimit`. Hand-checking them here let the
845
+ // read boundary disagree with the write boundary, and it did: a
846
+ // lone-surrogate run id was refused at `emit` and answered with an
847
+ // empty page at `entries`.
848
+ yield* Effect.fromResult(Result.mapError(decodeEntriesOptions(options), (cause) => error("invalid_event", "entries options violate the journal read contract", cause)));
849
+ const after = options.after ?? -1;
850
+ // With several types SQLite can prefer the run/sequence index to
851
+ // avoid sorting, scanning every unrelated event instead. Require
852
+ // the journal-owned type index; only matching rows may be sorted.
853
+ const rows = yield* (options.eventTypes === undefined ?
854
+ sql `
855
+ SELECT run_id, seq, event_id, source_id, source_seq, emitted_at_ms,
856
+ event_type, payload_json, meta_json
857
+ FROM flows_journal_events
858
+ WHERE run_id = ${options.runId} AND seq > ${after}
859
+ ORDER BY seq ASC
860
+ LIMIT ${options.limit + 1}
861
+ ` :
862
+ sql `
863
+ SELECT run_id, seq, event_id, source_id, source_seq, emitted_at_ms,
864
+ event_type, payload_json, meta_json
865
+ FROM flows_journal_events ${Dialect.indexHint(sql, "flows_journal_events_run_event_type_idx")}
866
+ WHERE run_id = ${options.runId} AND ${sql.in("event_type", options.eventTypes)} AND seq > ${after}
867
+ ORDER BY seq ASC
868
+ LIMIT ${options.limit + 1}
869
+ `).pipe(Effect.mapError((cause) => error("read_failed", "durable journal read failed", cause)));
870
+ // The floor is read AFTER the page. Truncation and the floor
871
+ // advance commit atomically, so any deletion that could have
872
+ // shortened the page above is visible in this floor read, and a
873
+ // cursor at or above `floor - 1` therefore read a complete page.
874
+ // The converse order would let a compaction commit between the two
875
+ // reads and hand back a silently gapped history.
876
+ const floor = yield* compactionFloor(options.runId).pipe(Effect.mapError((cause) => error("read_failed", "durable journal read failed", cause)));
877
+ if (floor !== undefined && after < floor - 1) {
878
+ return yield* Effect.fail(new JournalError({
879
+ code: "compacted",
880
+ message: `run ${options.runId} is compacted through sequence ${floor}; resync from its checkpoint`,
881
+ checkpointSeq: floor
882
+ }));
883
+ }
884
+ const page = rows.slice(0, options.limit);
885
+ const entries = yield* Effect.forEach(page, decodeRow);
886
+ return {
887
+ entries,
888
+ hasMore: rows.length > options.limit
889
+ };
890
+ }));
891
+ const subscribeRun = (runId) => Effect.gen(function* () {
892
+ const wake = yield* PubSub.sliding(1);
893
+ const subscription = yield* PubSub.subscribe(wake);
894
+ yield* Effect.acquireRelease(Effect.sync(() => {
895
+ const subscribers = wakes.get(runId) ?? new Set();
896
+ subscribers.add(wake);
897
+ wakes.set(runId, subscribers);
898
+ }), () => Effect.sync(() => {
899
+ const subscribers = wakes.get(runId);
900
+ subscribers?.delete(wake);
901
+ if (subscribers?.size === 0) {
902
+ wakes.delete(runId);
903
+ }
904
+ }).pipe(Effect.andThen(PubSub.shutdown(wake))));
905
+ return subscription;
906
+ });
907
+ const stream = (streamOptions) => Stream.unwrap(Effect.fn("Journal.stream")(function* () {
908
+ yield* Effect.annotateCurrentSpan({
909
+ runId: streamOptions.runId,
910
+ ...(streamOptions.afterSequence === undefined ? {} : { afterSequence: streamOptions.afterSequence })
911
+ });
912
+ yield* Effect.fromResult(Result.mapError(decodeStreamOptions(streamOptions), (cause) => error("invalid_event", "stream options violate the journal read contract", cause)));
913
+ const wake = yield* subscribeRun(streamOptions.runId);
914
+ // The cursor lives in a registered box for the stream's lifetime
915
+ // so `compact` can see how far every live follower has read.
916
+ const reader = { cursor: streamOptions.afterSequence ?? -1 };
917
+ yield* Effect.acquireRelease(Effect.sync(() => {
918
+ const registered = readers.get(streamOptions.runId) ?? new Set();
919
+ registered.add(reader);
920
+ readers.set(streamOptions.runId, registered);
921
+ }), () => Effect.sync(() => {
922
+ const registered = readers.get(streamOptions.runId);
923
+ registered?.delete(reader);
924
+ if (registered?.size === 0) {
925
+ readers.delete(streamOptions.runId);
926
+ }
927
+ }));
928
+ // A live consumer is told about losses that happen while it is
929
+ // following, and only about those: a loss it never overlapped is
930
+ // already spent by the time it subscribes.
931
+ const subscribedLossEpoch = state.lossEpoch;
932
+ const readPages = (initialCursor) => Stream.paginate(initialCursor, (cursor) => Effect.suspend(() => state.sinkFailure !== undefined && state.lossEpoch > subscribedLossEpoch
933
+ ? Effect.fail(state.sinkFailure)
934
+ : readPage({
935
+ runId: streamOptions.runId,
936
+ ...(cursor < 0 ? {} : { after: cursor }),
937
+ limit: readPageSize
938
+ })).pipe(Effect.map((page) => {
939
+ const last = page.entries.at(-1);
940
+ if (last !== undefined) {
941
+ // Both channels append above the committed tail, so no
942
+ // pending reservation can later commit below this cursor.
943
+ reader.cursor = last.seq;
944
+ }
945
+ return [
946
+ page.entries,
947
+ page.hasMore ? Option.some(reader.cursor) : Option.none()
948
+ ];
949
+ })));
950
+ const historical = readPages(reader.cursor);
951
+ // PubSub is only a local fast path. Another journal process cannot
952
+ // publish into it, so a bounded poll must recheck both the durable
953
+ // tail and the compaction floor while the follower is otherwise idle.
954
+ // One raced wake keeps the live tail in the consumer fiber instead
955
+ // of merging two background streams, which also preserves a plain
956
+ // interruption cause when the consumer is cancelled.
957
+ const live = Stream.fromEffectRepeat(Effect.raceFirst(PubSub.take(wake), Effect.sleep("1 second"))).pipe(Stream.flatMap(() => readPages(reader.cursor)));
958
+ return Stream.concat(historical, live);
959
+ })());
960
+ /**
961
+ * The owner fence for the fenced append, for checkpoint, and for
962
+ * compaction: the injected strategy's commit-time admission (R3),
963
+ * evaluated inside the caller's write transaction. `DurableWriter`
964
+ * serializes write transactions, so no reclaim can commit between the
965
+ * guard and the statements that run beside it in the same transaction.
966
+ * A strategy that cannot answer is a sink failure, never a lost fence;
967
+ * its cause is preserved so the writer's retry classification still sees
968
+ * a transient conflict through the wrapper.
969
+ */
970
+ const fenceGuard = (runId, owner) => consensus.guard(runId, owner).pipe(Effect.mapError((cause) => cause.code === "fence_lost"
971
+ ? error("fence_lost", cause.message)
972
+ : error("sink_failed", "consensus guard failed", cause)));
973
+ /**
974
+ * Reads the row or compacted identity a duplicate emit collides with.
975
+ * The insert guard in migration 0004 extends the event table's unique
976
+ * constraints to identities retained by compaction. Tombstones return
977
+ * only a receipt sequence and never produce a replayable entry.
978
+ *
979
+ * On the queued channel this runs only AFTER the insert, on the row that
980
+ * insert's `ON CONFLICT DO NOTHING` actually refused: the unique index is
981
+ * the admission decision there, so a batch of entries that collide with
982
+ * nothing costs no lookups at all, and the one a collision costs happens
983
+ * inside the writer's own transaction. See {@link insertOne} for why the
984
+ * durable channel still asks first.
985
+ *
986
+ * The lookup covers BOTH unique constraints the insert can conflict on:
987
+ * `UNIQUE (event_id)` and `UNIQUE (run_id, source_id, source_seq)`. It is
988
+ * tempting to keep only the first, because `makeEventId` is injective in
989
+ * exactly that triple, but that argument holds only for rows this
990
+ * journal minted. `TimeTravelStore.createFork` copies a parent's rows
991
+ * under the child's `run_id` with `'fork:' || run_id || ':' || event_id`,
992
+ * so a forked run carries rows whose triple is live and whose event id it
993
+ * will never mint. Looking those up by event id alone finds nothing, and
994
+ * the caller reports the resulting empty insert as `fence_lost`, a
995
+ * healthy fork failing with "someone else owns this run".
996
+ */
997
+ const selectExisting = (queued) => Effect.gen(function* () {
998
+ const existing = yield* sql `
999
+ SELECT run_id, seq, event_id, source_id, source_seq, emitted_at_ms,
1000
+ event_type, payload_json, meta_json, NULL AS content_hash
1001
+ FROM flows_journal_events
1002
+ WHERE event_id = ${queued.eventId}
1003
+ OR (
1004
+ run_id = ${queued.runId}
1005
+ AND source_id = ${queued.sourceId}
1006
+ AND source_seq = ${queued.sourceSeq}
1007
+ )
1008
+ UNION ALL
1009
+ SELECT run_id, seq, event_id, source_id, source_seq, 0 AS emitted_at_ms,
1010
+ '' AS event_type, '' AS payload_json, '' AS meta_json, content_hash
1011
+ FROM flows_journal_dedup
1012
+ WHERE event_id = ${queued.eventId}
1013
+ OR (
1014
+ run_id = ${queued.runId}
1015
+ AND source_id = ${queued.sourceId}
1016
+ AND source_seq = ${queued.sourceSeq}
1017
+ )
1018
+ ORDER BY seq ASC
1019
+ LIMIT 1
1020
+ `;
1021
+ if (existing.length === 0) {
1022
+ return undefined;
1023
+ }
1024
+ const row = existing[0];
1025
+ if (queued.dedupe !== "identity" && (row.content_hash === null
1026
+ ? row.event_type !== queued.eventType ||
1027
+ row.payload_json !== queued.payloadJson ||
1028
+ row.meta_json !== queued.metaJson
1029
+ : row.content_hash !==
1030
+ (yield* contentFingerprint(queued.eventType, queued.payloadJson, queued.metaJson)))) {
1031
+ return yield* Effect.fail(error("idempotency_conflict", `source event ${queued.sourceId}:${queued.sourceSeq} for run ${queued.runId} was reused with different content`));
1032
+ }
1033
+ return {
1034
+ entry: row.content_hash === null ? yield* decodeRow(row) : { seq: Number(row.seq) },
1035
+ inserted: false
1036
+ };
1037
+ });
1038
+ /**
1039
+ * When the fence is `Owned` the insert is admitted by the consensus
1040
+ * strategy's `guard` in the same serialized write transaction, following
1041
+ * Temporal's shard `rangeID` check
1042
+ * (`service/history/shard/context_impl.go`, `renewRangeLocked`): a
1043
+ * zombie owner whose run was reclaimed cannot append, and fails with
1044
+ * `fence_lost` before any row is written.
1045
+ *
1046
+ * The fence outranks dedup. The guard runs BEFORE the INSERT, so a
1047
+ * fenced insert consults the dedup index only for an owner the strategy
1048
+ * has confirmed; Temporal conditions every request on the `rangeID`
1049
+ * before anything else. Answering a zombie's resubmission from the dedup
1050
+ * index would launder its lost fence into a `Duplicate` receipt for work
1051
+ * the live owner committed; a confirmed owner's conflict, by contrast,
1052
+ * is a genuine duplicate (its own earlier commit, or a forked run's
1053
+ * copied row).
1054
+ *
1055
+ * The queued channel is decided by the constraint alone. The ownerless
1056
+ * DURABLE path keeps its up-front lookup, and the reason is measured
1057
+ * rather than aesthetic: with two connections open on one file, removing
1058
+ * it left `emitDurableUnfenced` transactions overlapping, so one of them
1059
+ * blocked in SQLite's synchronous busy wait until the driver's
1060
+ * `busy_timeout` expired. The two-connection case in `JournalDurable`
1061
+ * went from 17 ms and no write retries to 5.4 s and one, three runs each
1062
+ * way. That is a read this channel is buying scheduling room with, and
1063
+ * it is affordable here: a durable emit already reads its allocation
1064
+ * floor in the same transaction, and its caller is by definition not
1065
+ * flushing from inside somebody else's.
1066
+ */
1067
+ const insertOne = (queued, fence, enforceCompactionFloor = false) => Effect.gen(function* () {
1068
+ if (fence._tag === "Owned") {
1069
+ yield* fenceGuard(queued.runId, fence.owner);
1070
+ }
1071
+ else if (!enforceCompactionFloor) {
1072
+ const duplicate = yield* selectExisting(queued);
1073
+ if (duplicate !== undefined) {
1074
+ return duplicate;
1075
+ }
1076
+ }
1077
+ // Admission reservations are provisional. Allocate above the durable
1078
+ // tail in this transaction so a queued entry can never commit behind
1079
+ // a reader's cursor, even when another connection overtook it.
1080
+ const insert = enforceCompactionFloor
1081
+ ? sql `
1082
+ INSERT INTO flows_journal_events (
1083
+ run_id, seq, event_id, source_id, source_seq, emitted_at_ms,
1084
+ event_type, payload_json, meta_json
1085
+ )
1086
+ SELECT
1087
+ ${queued.runId},
1088
+ (SELECT ${Dialect.greatest(sql)}(${queued.seq}, COALESCE(MAX(seq), -1) + 1)
1089
+ FROM flows_journal_events WHERE run_id = ${queued.runId}),
1090
+ ${queued.eventId},
1091
+ ${queued.sourceId},
1092
+ ${queued.sourceSeq},
1093
+ ${queued.emittedAtMs},
1094
+ ${queued.eventType},
1095
+ ${queued.payloadJson},
1096
+ ${queued.metaJson}
1097
+ WHERE NOT EXISTS (
1098
+ SELECT 1 FROM flows_journal_checkpoints
1099
+ WHERE run_id = ${queued.runId}
1100
+ AND compacted_at_ms IS NOT NULL
1101
+ AND seq >= ${queued.seq}
1102
+ ) AND NOT EXISTS (
1103
+ SELECT 1 FROM flows_journal_events
1104
+ WHERE run_id = ${queued.runId} AND seq >= ${Number.MAX_SAFE_INTEGER - 1}
1105
+ )
1106
+ ON CONFLICT DO NOTHING
1107
+ RETURNING run_id, seq, event_id, source_id, source_seq, emitted_at_ms,
1108
+ event_type, payload_json, meta_json
1109
+ `
1110
+ : sql `
1111
+ INSERT INTO flows_journal_events (
1112
+ run_id, seq, event_id, source_id, source_seq, emitted_at_ms,
1113
+ event_type, payload_json, meta_json
1114
+ ) VALUES (
1115
+ ${queued.runId},
1116
+ ${queued.seq},
1117
+ ${queued.eventId},
1118
+ ${queued.sourceId},
1119
+ ${queued.sourceSeq},
1120
+ ${queued.emittedAtMs},
1121
+ ${queued.eventType},
1122
+ ${queued.payloadJson},
1123
+ ${queued.metaJson}
1124
+ )
1125
+ ON CONFLICT DO NOTHING
1126
+ RETURNING run_id, seq, event_id, source_id, source_seq, emitted_at_ms,
1127
+ event_type, payload_json, meta_json
1128
+ `;
1129
+ const inserted = yield* insert;
1130
+ if (inserted.length > 0) {
1131
+ return {
1132
+ entry: yield* decodeRow(inserted[0]),
1133
+ inserted: true
1134
+ };
1135
+ }
1136
+ // The INSERT produced no row, so a unique constraint fired: the
1137
+ // guard above already confirmed a fenced owner, so this collision
1138
+ // is a genuine duplicate, never a laundered lost fence.
1139
+ const racedDuplicate = yield* selectExisting(queued);
1140
+ if (racedDuplicate !== undefined) {
1141
+ return racedDuplicate;
1142
+ }
1143
+ // The insert produced no row and names no committed identity. Before
1144
+ // reporting sequence exhaustion, ask whether compaction moved the
1145
+ // floor above this reservation: that is a drop, and
1146
+ // the caller needs the floor to resync from. A durable write cannot
1147
+ // reach this case, because it allocates above the surviving
1148
+ // checkpoint row, so the read costs nothing on that path.
1149
+ {
1150
+ const floor = yield* compactionFloor(queued.runId);
1151
+ if (floor !== undefined && queued.seq <= floor) {
1152
+ return yield* Effect.fail(new JournalError({
1153
+ code: "compacted",
1154
+ message: `queued sequence ${queued.seq} for run ${queued.runId} was dropped below compaction floor ${floor}`,
1155
+ checkpointSeq: floor
1156
+ }));
1157
+ }
1158
+ }
1159
+ return yield* Effect.fail(error("invalid_event", "journal sequence is outside the allocatable safe integer range"));
1160
+ });
1161
+ /** Shared transactional insert path; the caller already owns SQL. */
1162
+ const insertBatch = (batch) => Effect.gen(function* () {
1163
+ const results = yield* Effect.forEach(batch, (queued) => Effect.map(Effect.result(insertOne(queued, unfenced, true)), (result) => ({ queued, result })));
1164
+ const commits = [];
1165
+ const losses = [];
1166
+ for (const settled of results) {
1167
+ if (Result.isSuccess(settled.result)) {
1168
+ commits.push({ queued: settled.queued, commit: settled.result.success });
1169
+ }
1170
+ else if (isJournalError(settled.result.failure)) {
1171
+ losses.push({ queued: settled.queued, cause: settled.result.failure });
1172
+ }
1173
+ else {
1174
+ return yield* Effect.fail(settled.result.failure);
1175
+ }
1176
+ }
1177
+ return { commits, losses };
1178
+ });
1179
+ // Transaction first, run permits second. Admissions read floors before
1180
+ // taking a permit; compaction also acquires SQL before its run locks.
1181
+ // No holder of a run permit or closed gate can wait on this batch's
1182
+ // connection (see `admitFromIndex` and `withCompactionBarrier`).
1183
+ const persistBatch = (batch) => writer.write(withRunPermits(batch.map((queued) => queued.runId), insertBatch(batch))).pipe(
1184
+ // Every per-entry `JournalError` is settled inside the transaction
1185
+ // above, so the only failure that escapes is the transaction itself:
1186
+ // a database outage, which is what `sink_failed` names.
1187
+ Effect.mapError((cause) => error("sink_failed", "journal sink failed", cause)));
1188
+ const publish = (commits) => Effect.forEach(commits, (commit) => {
1189
+ if (!commit.inserted) {
1190
+ return Effect.void;
1191
+ }
1192
+ return PubSub.publish(changes, freezePublished(commit.entry)).pipe(Effect.andThen(Effect.forEach(wakes.get(commit.entry.runId) ?? [], (wake) => PubSub.publish(wake, undefined), { discard: true })), Effect.asVoid);
1193
+ }, { discard: true });
1194
+ const rememberCommitted = (queued, seq) => {
1195
+ retain(sourceEventKey(queued.runId, queued.sourceId, queued.sourceSeq), {
1196
+ seq,
1197
+ eventType: queued.eventType,
1198
+ payloadJson: queued.payloadJson,
1199
+ metaJson: queued.metaJson,
1200
+ status: "committed"
1201
+ });
1202
+ raiseSequenceFloor(queued.runId, seq);
1203
+ raiseSourceSequenceFloor(queued.runId, queued.sourceId, queued.sourceSeq);
1204
+ };
1205
+ /**
1206
+ * Reads the durable allocation floor for a run (or a producer). Durable
1207
+ * allocation calls this inside its transaction; lossy admission calls
1208
+ * it before taking any run permit.
1209
+ *
1210
+ * The pinned Node SQLite driver begins IMMEDIATE transactions, acquiring
1211
+ * the writer lock before this floor read. A caller-supplied SQL client
1212
+ * may instead use DEFERRED transactions, whose lock upgrade can fail
1213
+ * with SQLITE_BUSY_SNAPSHOT. In either case DurableWriter retries the
1214
+ * whole transaction, floor read included, against committed state.
1215
+ * The cross-connection allocation invariant is exercised by
1216
+ * `packages/smithers/flows/journal/test/JournalDurable.test.ts` ("emitDurable never
1217
+ * collides when two connections write one run concurrently").
1218
+ *
1219
+ * Governing design: `packages/smithers/flows/journal/docs/concepts/two-channels.md`.
1220
+ */
1221
+ const nextDurable = (column, runId, sourceId) => Effect.map(column === "seq"
1222
+ ? sql `
1223
+ SELECT MAX(seq) + 1 AS next FROM flows_journal_events WHERE run_id = ${runId}
1224
+ `
1225
+ : sql `
1226
+ SELECT MAX(source_seq) + 1 AS next FROM (
1227
+ SELECT MAX(source_seq) AS source_seq FROM flows_journal_events
1228
+ WHERE run_id = ${runId} AND source_id = ${sourceId}
1229
+ UNION ALL
1230
+ SELECT MAX(source_seq) AS source_seq FROM flows_journal_dedup
1231
+ WHERE run_id = ${runId} AND source_id = ${sourceId}
1232
+ )
1233
+ `, (rows) => Number(rows[0]?.next ?? 0));
1234
+ /**
1235
+ * Reads a run's durable allocation floors when the in-process cache has
1236
+ * not observed them yet.
1237
+ *
1238
+ * `emitLossy` allocates from the index alone, it queues rather than
1239
+ * writing, so it cannot read the database mid-allocation, which is why
1240
+ * the floors used to be seeded for every run at construction. Reading
1241
+ * them on first use instead keeps the index proportional to the runs this
1242
+ * process touches rather than to total history, and the durable read is
1243
+ * the same `MAX(...) + 1` the seed computed.
1244
+ *
1245
+ * This function never mutates the cache. Its SQL reads deliberately run
1246
+ * without the allocator and without the run permit; the caller later
1247
+ * takes the run permit and then the short allocator permit, re-checks
1248
+ * the monotonic cache, and reserves both sequences in one synchronous
1249
+ * step. A floor read outside the permit is a lower bound: every
1250
+ * in-process writer raises the cache at allocation time, and the only
1251
+ * cache invalidation is `JournalGeneration.forget`, whose contract
1252
+ * requires quiescent producers and flushed admissions, so it cannot
1253
+ * overlap this read-to-reservation interval. Retention evicts committed
1254
+ * identities only; run retirement and compaction leave allocation floors
1255
+ * intact. Compaction also preserves the checkpoint entry and producer
1256
+ * dedup rows for cold reads. Reading under the permit is what deadlocked
1257
+ * against the batch writer; see `admitFromIndex`.
1258
+ *
1259
+ * A producer that supplies its own `sourceSeq` allocates nothing from
1260
+ * the producer floor, so that floor is not read for it. This is what
1261
+ * makes the explicit path readless end to end once the run's own `seq`
1262
+ * floor is known, which it is from the first entry any producer writes
1263
+ * for the run.
1264
+ */
1265
+ const ensureFloors = (input) => Effect.suspend(() => {
1266
+ const runId = input.runId;
1267
+ const key = sourceKey(runId, input.sourceId);
1268
+ const seq = state.sequences.get(runId);
1269
+ const sourceSeq = input.sourceSeq === undefined ? state.sourceSequences.get(key) : 0;
1270
+ if (seq !== undefined && sourceSeq !== undefined) {
1271
+ return Effect.succeed({ seq, sourceSeq });
1272
+ }
1273
+ return Effect.all({
1274
+ seq: seq === undefined ? nextDurable("seq", runId, undefined) : Effect.succeed(seq),
1275
+ sourceSeq: sourceSeq === undefined
1276
+ ? nextDurable("source_seq", runId, input.sourceId)
1277
+ : Effect.succeed(sourceSeq)
1278
+ }).pipe(Effect.mapError((cause) => error("sink_failed", "could not read journal allocation floor", cause)), Effect.flatMap((floors) => {
1279
+ if (!Number.isSafeInteger(floors.seq) ||
1280
+ floors.seq < 0 ||
1281
+ !Number.isSafeInteger(floors.sourceSeq) ||
1282
+ floors.sourceSeq < 0) {
1283
+ return Effect.fail(error("invalid_event", "journal sequence is outside the allocatable safe integer range"));
1284
+ }
1285
+ return Effect.succeed(floors);
1286
+ }));
1287
+ });
1288
+ /**
1289
+ * Records a committed entry in the in-process index and publishes it,
1290
+ * immediately outside a transaction, or deferred to its owning writer.
1291
+ * A raw SQL transaction has no managed commit boundary: skip this optional
1292
+ * publication rather than exposing uncommitted data. Database replay stays
1293
+ * authoritative, including for tail subscribers.
1294
+ */
1295
+ const settleCommit = (queued, commit, restoreMaintenance) => Effect.gen(function* () {
1296
+ const mandatory = Effect.sync(() => rememberCommitted(queued, commit.entry.seq)).pipe(Effect.andThen(publish([commit])));
1297
+ // Restore the durable caller's policy, not unconditional interruption.
1298
+ // A cancellation finalizer may journal before releasing resources;
1299
+ // even interruptible(void) here re-delivers its pending interruption
1300
+ // after COMMIT and abandons that cleanup. Ordinary callers still make
1301
+ // a slow capture interruptible when the writer publishes after COMMIT.
1302
+ const maintenance = restoreMaintenance(noteCommitted(queued.runId, commit.inserted ? 1 : 0));
1303
+ const enclosing = yield* Effect.serviceOption(sql.transactionService);
1304
+ if (Option.isNone(enclosing)) {
1305
+ yield* mandatory;
1306
+ return maintenance;
1307
+ }
1308
+ yield* afterCommit(mandatory.pipe(Effect.andThen(maintenance)), sql);
1309
+ return Effect.void;
1310
+ });
1311
+ /**
1312
+ * Opens (or joins) the write transaction that keeps the logical WAL
1313
+ * atomic with the executable state it describes.
1314
+ *
1315
+ * The shared writer owns publication across retries, nested savepoint
1316
+ * rollbacks, and transactions opened by other stores on the same client.
1317
+ */
1318
+ const transact = (effect) => writer.write(effect).pipe(Effect.catchIf((cause) => cause instanceof DatabaseError, (cause) => Effect.fail(error("sink_failed", "journal transaction failed", cause))));
1319
+ const writeDurable = (input, fence) => Effect.uninterruptibleMask((restoreMaintenance) => restoreMaintenance(Effect.gen(function* () {
1320
+ yield* Effect.annotateCurrentSpan({
1321
+ runId: input.runId,
1322
+ sourceId: input.sourceId,
1323
+ eventType: input.eventType
1324
+ });
1325
+ const emittedAtMs = yield* Clock.currentTimeMillis.pipe(Effect.map(Math.floor));
1326
+ const prepared = yield* Effect.fromResult(prepare(input, emittedAtMs));
1327
+ const committed = yield* Effect.gen(function* () {
1328
+ const { metaJson, payloadJson, validated } = prepared;
1329
+ return yield* Effect.uninterruptibleMask((restore) => restore(writer.write(withActiveRunWrite(prepared.validated.runId, Effect.gen(function* () {
1330
+ // Read only for a producer that allocates from this floor. A
1331
+ // supplied sequence is not allocated, so reading the floor
1332
+ // for it costs a query whose answer is discarded.
1333
+ const durableSourceSeq = validated.sourceSeq === undefined
1334
+ ? yield* nextDurable("source_seq", validated.runId, validated.sourceId)
1335
+ : 0;
1336
+ const durableSeq = yield* nextDurable("seq", validated.runId, undefined);
1337
+ const reserved = yield* allocation.withPermit(Effect.fromResult(Result.gen(function* () {
1338
+ const key = sourceKey(validated.runId, validated.sourceId);
1339
+ const sourceSeq = validated.sourceSeq ??
1340
+ Math.max(durableSourceSeq, state.sourceSequences.get(key) ?? 0);
1341
+ if (!Number.isSafeInteger(sourceSeq) ||
1342
+ sourceSeq < 0 ||
1343
+ sourceSeq === Number.MAX_SAFE_INTEGER) {
1344
+ return yield* Result.fail(error("invalid_event", "journal sequence is outside the allocatable safe integer range"));
1345
+ }
1346
+ const seq = Math.max(durableSeq, state.sequences.get(validated.runId) ?? 0);
1347
+ if (!Number.isSafeInteger(seq) || seq === Number.MAX_SAFE_INTEGER) {
1348
+ return yield* Result.fail(error("invalid_event", "journal sequence is outside the allocatable safe integer range"));
1349
+ }
1350
+ // Claim the seq NOW, not at commit: a concurrent `emitLossy`
1351
+ // allocates from this floor alone, and `settleCommit` parks
1352
+ // the commit-time raise until the outermost COMMIT.
1353
+ // Re-entering the transaction body is idempotent because the
1354
+ // floor only rises. An abandoned attempt leaves the number
1355
+ // unused, which is a gap: allocation is `MAX(seq) + 1` and
1356
+ // replay is `ORDER BY seq`, so neither reads a gap as anything.
1357
+ raiseSequenceFloor(validated.runId, seq);
1358
+ // Claim the producer sequence at the same allocation seam.
1359
+ // Without this, a lossy emit from the same producer can read
1360
+ // the pre-transaction source floor and reuse this identity
1361
+ // while an enclosing `transact` is still open.
1362
+ raiseSourceSequenceFloor(validated.runId, validated.sourceId, sourceSeq);
1363
+ return { seq, sourceSeq };
1364
+ })));
1365
+ const { seq, sourceSeq } = reserved;
1366
+ const queued = {
1367
+ runId: validated.runId,
1368
+ seq,
1369
+ eventId: makeEventId(validated.runId, validated.sourceId, sourceSeq),
1370
+ sourceId: validated.sourceId,
1371
+ sourceSeq,
1372
+ emittedAtMs,
1373
+ eventType: validated.eventType,
1374
+ payloadJson,
1375
+ metaJson,
1376
+ dedupe: validated.dedupe ?? "content"
1377
+ };
1378
+ const commit = yield* insertOne(queued, fence);
1379
+ return { commit, queued, sourceSeq };
1380
+ })))).pipe(
1381
+ /**
1382
+ * `writer.write` is a retrying transaction: its body replays on
1383
+ * `SQLITE_BUSY(_SNAPSHOT)` and can still abort at COMMIT after the
1384
+ * body succeeded. Cache mutation and publication therefore happen
1385
+ * strictly after the transaction returns, so subscribers never
1386
+ * observe a rolled-back entry and a replayed body never publishes
1387
+ * twice. Mirrors the queued path, which publishes in a `.tap`
1388
+ * outside `persistBatch`. Only the transaction is restored: an
1389
+ * interruption that lands after it returns waits for the index
1390
+ * update and publication, so a committed row always reaches
1391
+ * `changes`.
1392
+ *
1393
+ * Under `transact` "after the transaction returns" is not yet
1394
+ * "after COMMIT": this write is a savepoint of the caller's
1395
+ * transaction, so `settleCommit` parks both effects until the
1396
+ * outermost transaction commits. The active-write reference
1397
+ * is released before that outer commit, which lets automatic
1398
+ * compaction drain the run before its maintenance transaction.
1399
+ */
1400
+ Effect.flatMap((written) => Effect.gen(function* () {
1401
+ const maintenance = yield* settleCommit(written.queued, written.commit, restoreMaintenance);
1402
+ const receipt = written.commit.inserted
1403
+ ? { _tag: "Accepted", seq: written.commit.entry.seq, sourceSeq: written.sourceSeq }
1404
+ : {
1405
+ _tag: "Duplicate",
1406
+ seq: written.commit.entry.seq,
1407
+ sourceSeq: written.sourceSeq,
1408
+ status: "committed"
1409
+ };
1410
+ yield* Metric.update(JournalMetrics.durable[receipt._tag], 1);
1411
+ return { maintenance, receipt };
1412
+ }))));
1413
+ }).pipe(Effect.mapError((cause) => isJournalError(cause) ? cause : error("sink_failed", "durable journal write failed", cause)));
1414
+ yield* committed.maintenance;
1415
+ return committed.receipt;
1416
+ })));
1417
+ const emitDurable = Effect.fn("Journal.emitDurable")((input, owner) => Effect.flatMap(Effect.fromResult(requireFence(owner, "emitDurable")), (fence) => writeDurable(input, fence)));
1418
+ const emitDurableUnfenced = Effect.fn("Journal.emitDurableUnfenced")((input) => writeDurable(input, unfenced));
1419
+ const emitLossy = queuedEmit;
1420
+ const checkpointInternal = (checkpointOptions, fence) => Effect.gen(function* () {
1421
+ yield* Effect.annotateCurrentSpan({
1422
+ runId: checkpointOptions.runId,
1423
+ seq: checkpointOptions.seq
1424
+ });
1425
+ yield* Effect.fromResult(Result.mapError(decodeCheckpointOptions(checkpointOptions), (cause) => error("invalid_event", "checkpoint options violate the journal contract", cause)));
1426
+ // The state round-trips verbatim: it is replay input, so redaction
1427
+ // deliberately does not apply, rewriting it would resume the run
1428
+ // with the wrong data. A secret that must not persist belongs in a
1429
+ // `Redacted` field of the caller's own state schema.
1430
+ const stateJson = yield* Effect.fromResult(encodeJson(checkpointOptions.state, "state"));
1431
+ const receiptState = yield* Schema.decodeUnknownEffect(UnknownFromJsonString)(stateJson).pipe(Effect.mapError(
1432
+ /* v8 ignore next -- encodeJson produced these valid JSON bytes immediately above */
1433
+ (cause) => error("decode_failed", "could not decode persisted checkpoint state", cause)));
1434
+ const createdAtMs = yield* Clock.currentTimeMillis.pipe(Effect.map(Math.floor));
1435
+ return yield* writer.write(Effect.gen(function* () {
1436
+ if (fence._tag === "Owned") {
1437
+ yield* fenceGuard(checkpointOptions.runId, fence.owner);
1438
+ }
1439
+ // The target must be a committed entry: the surviving row is what
1440
+ // keeps the run's durable `MAX(seq)` allocation floor at or above
1441
+ // the compaction boundary, so a process restarted after
1442
+ // compaction can never re-allocate a truncated sequence.
1443
+ const target = yield* sql `
1444
+ SELECT 1 AS ok FROM flows_journal_events
1445
+ WHERE run_id = ${checkpointOptions.runId} AND seq = ${checkpointOptions.seq}
1446
+ `;
1447
+ if (target.length === 0) {
1448
+ return yield* Effect.fail(error("checkpoint_invalid", `checkpoint sequence ${checkpointOptions.seq} names no committed entry of run ${checkpointOptions.runId}`));
1449
+ }
1450
+ const floor = yield* compactionFloor(checkpointOptions.runId);
1451
+ if (floor !== undefined && checkpointOptions.seq <= floor) {
1452
+ return yield* Effect.fail(new JournalError({
1453
+ code: "checkpoint_invalid",
1454
+ message: `run ${checkpointOptions.runId} is already compacted through sequence ${floor}`,
1455
+ checkpointSeq: floor
1456
+ }));
1457
+ }
1458
+ yield* sql `
1459
+ INSERT INTO flows_journal_checkpoints (run_id, seq, state_json, created_at_ms)
1460
+ VALUES (${checkpointOptions.runId}, ${checkpointOptions.seq}, ${stateJson}, ${createdAtMs})
1461
+ ON CONFLICT (run_id, seq) DO UPDATE SET
1462
+ state_json = excluded.state_json,
1463
+ created_at_ms = excluded.created_at_ms
1464
+ `;
1465
+ return new Checkpoint({
1466
+ runId: checkpointOptions.runId,
1467
+ seq: checkpointOptions.seq,
1468
+ state: receiptState,
1469
+ createdAtMs,
1470
+ compactedAtMs: null
1471
+ });
1472
+ })).pipe(Effect.mapError((cause) => isJournalError(cause) ? cause : error("sink_failed", "durable checkpoint write failed", cause)));
1473
+ });
1474
+ const checkpoint = Effect.fn("Journal.checkpoint")((checkpointOptions, owner) => Effect.flatMap(Effect.fromResult(requireFence(owner, "checkpoint")), (fence) => checkpointInternal(checkpointOptions, fence)));
1475
+ const latestCheckpoint = Effect.fn("Journal.latestCheckpoint")((runId) => Effect.gen(function* () {
1476
+ yield* Effect.annotateCurrentSpan({ runId });
1477
+ yield* Effect.fromResult(Result.mapError(decodeRunId(runId), (cause) => error("invalid_event", "runId violates the journal identifier contract", cause)));
1478
+ const rows = yield* sql `
1479
+ SELECT run_id, seq, state_json, created_at_ms, compacted_at_ms
1480
+ FROM flows_journal_checkpoints
1481
+ WHERE run_id = ${runId}
1482
+ ORDER BY seq DESC
1483
+ LIMIT 1
1484
+ `.pipe(Effect.mapError((cause) => error("read_failed", "durable checkpoint read failed", cause)));
1485
+ const row = rows[0];
1486
+ return row === undefined ? Option.none() : Option.some(yield* decodeCheckpointRow(row));
1487
+ }));
1488
+ const compactInternal = (compactOptions, fence) => Effect.gen(function* () {
1489
+ yield* Effect.annotateCurrentSpan({
1490
+ runId: compactOptions.runId,
1491
+ ...(compactOptions.upTo === undefined ? {} : { upTo: compactOptions.upTo })
1492
+ });
1493
+ yield* Effect.fromResult(Result.mapError(decodeCompactOptions(compactOptions), (cause) => error("invalid_event", "compact options violate the journal contract", cause)));
1494
+ const compactedAtMs = yield* Clock.currentTimeMillis.pipe(Effect.map(Math.floor));
1495
+ return yield* withCompactionBarrier(compactOptions.runId, Effect.gen(function* () {
1496
+ if (fence._tag === "Owned") {
1497
+ yield* fenceGuard(compactOptions.runId, fence.owner);
1498
+ }
1499
+ const upTo = compactOptions.upTo;
1500
+ const rows = upTo === undefined
1501
+ ? yield* sql `
1502
+ SELECT run_id, seq, state_json, created_at_ms, compacted_at_ms
1503
+ FROM flows_journal_checkpoints
1504
+ WHERE run_id = ${compactOptions.runId}
1505
+ ORDER BY seq DESC
1506
+ LIMIT 1
1507
+ `
1508
+ : yield* sql `
1509
+ SELECT run_id, seq, state_json, created_at_ms, compacted_at_ms
1510
+ FROM flows_journal_checkpoints
1511
+ WHERE run_id = ${compactOptions.runId} AND seq = ${upTo}
1512
+ `;
1513
+ const row = rows[0];
1514
+ if (row === undefined) {
1515
+ return yield* Effect.fail(error("checkpoint_invalid", `run ${compactOptions.runId} has no checkpoint${upTo === undefined ? "" : ` at sequence ${upTo}`} to compact to`));
1516
+ }
1517
+ const checkpointSeq = Number(row.seq);
1518
+ if (row.compacted_at_ms !== null) {
1519
+ // A retried compaction: the floor is already here and the rows
1520
+ // below it are already gone.
1521
+ return { runId: compactOptions.runId, checkpointSeq, deleted: 0 };
1522
+ }
1523
+ for (const reader of readers.get(compactOptions.runId) ?? []) {
1524
+ if (reader.cursor < checkpointSeq - 1) {
1525
+ return yield* Effect.fail(new JournalError({
1526
+ code: "reader_behind",
1527
+ message: `a live stream of run ${compactOptions.runId} still needs sequences below checkpoint ${checkpointSeq}`,
1528
+ checkpointSeq
1529
+ }));
1530
+ }
1531
+ }
1532
+ const doomed = yield* sql `
1533
+ SELECT COUNT(*) AS total FROM flows_journal_events
1534
+ WHERE run_id = ${compactOptions.runId} AND seq < ${checkpointSeq}
1535
+ `;
1536
+ // Retain identities atomically with deletion, in bounded pages.
1537
+ // These records never participate in replay, but preserve exact
1538
+ // retries and producer allocation floors after a fresh open.
1539
+ let after = -1;
1540
+ while (true) {
1541
+ const retiring = yield* sql `
1542
+ SELECT run_id, seq, event_id, source_id, source_seq, emitted_at_ms,
1543
+ event_type, payload_json, meta_json
1544
+ FROM flows_journal_events
1545
+ WHERE run_id = ${compactOptions.runId} AND seq > ${after} AND seq < ${checkpointSeq}
1546
+ ORDER BY seq ASC LIMIT 256
1547
+ `;
1548
+ if (retiring.length === 0)
1549
+ break;
1550
+ for (const entry of retiring) {
1551
+ const fingerprint = yield* contentFingerprint(entry.event_type, entry.payload_json, entry.meta_json);
1552
+ yield* sql `
1553
+ INSERT INTO flows_journal_dedup (run_id, source_id, source_seq, event_id, seq, content_hash)
1554
+ VALUES (${entry.run_id}, ${entry.source_id}, ${entry.source_seq}, ${entry.event_id},
1555
+ ${entry.seq}, ${fingerprint})
1556
+ `;
1557
+ }
1558
+ after = Number(retiring[retiring.length - 1].seq);
1559
+ }
1560
+ // Strictly below the checkpoint: the checkpointed entry survives,
1561
+ // holding the run's `MAX(seq)` allocation floor. Superseded
1562
+ // checkpoints go with their entries; the truncation and the floor
1563
+ // advance are one transaction, so a crash between them is
1564
+ // unrepresentable.
1565
+ yield* sql `
1566
+ DELETE FROM flows_journal_events
1567
+ WHERE run_id = ${compactOptions.runId} AND seq < ${checkpointSeq}
1568
+ `;
1569
+ yield* sql `
1570
+ DELETE FROM flows_journal_checkpoints
1571
+ WHERE run_id = ${compactOptions.runId} AND seq < ${checkpointSeq}
1572
+ `;
1573
+ yield* sql `
1574
+ UPDATE flows_journal_checkpoints
1575
+ SET compacted_at_ms = ${compactedAtMs}
1576
+ WHERE run_id = ${compactOptions.runId} AND seq = ${checkpointSeq}
1577
+ `;
1578
+ return {
1579
+ runId: compactOptions.runId,
1580
+ checkpointSeq,
1581
+ deleted: Number(doomed[0]?.total ?? 0)
1582
+ };
1583
+ })).pipe(Effect.mapError((cause) => isJournalError(cause) ? cause : error("sink_failed", "journal compaction failed", cause)));
1584
+ });
1585
+ const compact = Effect.fn("Journal.compact")((compactOptions, owner) => Effect.flatMap(Effect.fromResult(requireFence(owner, "compact")), (fence) => compactInternal(compactOptions, fence)));
1586
+ const compactionPolicy = options.compaction;
1587
+ const countEntries = (runId) => Effect.map(sql `
1588
+ SELECT COUNT(*) AS total FROM flows_journal_events WHERE run_id = ${runId}
1589
+ `, (rows) => Number(rows[0]?.total ?? 0));
1590
+ /**
1591
+ * One automatic checkpoint-and-compact attempt at the run's durable
1592
+ * tail. Runs post-commit; a failure or refusal is logged and damped,
1593
+ * the counter restarts, so the next attempt waits for another
1594
+ * `entryThreshold` commits, and is never surfaced to the emit whose
1595
+ * settlement crossed the threshold.
1596
+ */
1597
+ const policyCompact = (policy, runId) => Effect.gen(function* () {
1598
+ const tail = yield* sql `
1599
+ SELECT MAX(seq) AS last FROM flows_journal_events WHERE run_id = ${runId}
1600
+ `;
1601
+ const last = tail[0]?.last;
1602
+ if (last === null || last === undefined) {
1603
+ return;
1604
+ }
1605
+ const upTo = Number(last);
1606
+ const captured = yield* Effect.timeoutOrElse(policy.capture(runId, upTo), {
1607
+ duration: compactionCaptureTimeout,
1608
+ orElse: () => Effect.fail(new JournalInternalFault({
1609
+ code: "compaction_capture_timeout",
1610
+ message: `journal compaction capture for run ${runId} exceeded ${compactionCaptureTimeout}`
1611
+ }))
1612
+ });
1613
+ // The policy is the journal's OWN post-commit maintenance, not a
1614
+ // caller's mutating entrypoint: it owns no run and holds no fence,
1615
+ // so it drives the internal channel. The attempt only ever
1616
+ // truncates below a tail the run's own commits produced, a retry is
1617
+ // idempotent (a re-attempt after a reclaim compacts the same
1618
+ // committed prefix the live owner also sees), and every failure is
1619
+ // damped below, never surfaced to the emit that crossed the
1620
+ // threshold.
1621
+ yield* checkpointInternal({ runId, seq: upTo, state: captured }, unfenced);
1622
+ yield* compactInternal({ runId, upTo }, unfenced);
1623
+ compactionCounts.set(runId, yield* countEntries(runId));
1624
+ }).pipe(Effect.catchCause((cause) => Cause.hasInterruptsOnly(cause)
1625
+ ? Effect.failCause(cause)
1626
+ : Effect.sync(() => {
1627
+ compactionCounts.set(runId, 0);
1628
+ }).pipe(Effect.andThen(Effect.logWarning("journal auto-compaction failed; retrying after the next threshold", cause)))));
1629
+ /**
1630
+ * Counts a run's committed entries toward the compaction policy and
1631
+ * triggers an attempt at the threshold. Lossy settlements register and
1632
+ * fork it; durable settlements await it. Registration and handoff are
1633
+ * uninterruptible so cancellation cannot leak the maintenance count.
1634
+ *
1635
+ * The count is seeded lazily from the durable COUNT on the run's first
1636
+ * committed entry in this process, mirroring `ensureFloors`, so a
1637
+ * restarted process still compacts a long pre-existing history. The
1638
+ * durable COUNT already includes the rows the caller is reporting: they
1639
+ * committed before this settlement ran.
1640
+ *
1641
+ * The read-modify-write runs under the run's `maintenance` permit because
1642
+ * the seeding COUNT is an awaited SQL read. Without the permit two
1643
+ * settlements that overlapped both observed an unseeded counter, both
1644
+ * issued the COUNT, and the later reply overwrote the newer count with a
1645
+ * stale one: measured with `entryThreshold: 10`, ten committed events
1646
+ * produced zero capture calls and no checkpoint. A settlement that
1647
+ * commits BETWEEN the seeding COUNT and the counter write is now counted
1648
+ * twice instead, which only brings an attempt forward, and
1649
+ * {@link policyCompact} re-seeds the counter from a fresh COUNT when it
1650
+ * finishes, so the error does not accumulate.
1651
+ *
1652
+ * The permit is released before the compaction runs. Holding it across
1653
+ * `policyCompact` would make a slow `capture` block every other
1654
+ * settlement of the same run, which is the stall {@link settleCommit}
1655
+ * moved this work off the allocation permit to avoid.
1656
+ */
1657
+ const noteCommitted = (runId, committed, background = false) => {
1658
+ const policy = compactionPolicy;
1659
+ if (policy === undefined || committed <= 0) {
1660
+ return Effect.void;
1661
+ }
1662
+ return Effect.uninterruptibleMask((restore) => withRunBarrier(runId, (barrier) => restore(barrier.maintenance.withPermit(Effect.gen(function* () {
1663
+ if (compactingRuns.has(runId)) {
1664
+ return false;
1665
+ }
1666
+ const known = compactionCounts.get(runId);
1667
+ const current = known === undefined ? yield* countEntries(runId) : known + committed;
1668
+ compactionCounts.set(runId, current);
1669
+ if (current < policy.entryThreshold) {
1670
+ return false;
1671
+ }
1672
+ compactingRuns.add(runId);
1673
+ pendingMaintenance += 1;
1674
+ return true;
1675
+ }))).pipe(Effect.flatMap((triggered) => {
1676
+ if (!triggered)
1677
+ return Effect.void;
1678
+ const attempt = policyCompact(policy, runId).pipe(Effect.ensuring(Effect.sync(() => {
1679
+ compactingRuns.delete(runId);
1680
+ pendingMaintenance -= 1;
1681
+ if (state.pending === 0 && pendingMaintenance === 0)
1682
+ completeFlushWaiters(Effect.void);
1683
+ })));
1684
+ // The sole queue consumer must never await a compaction barrier:
1685
+ // that barrier may need another batch from this very consumer.
1686
+ return background
1687
+ ? Effect.asVoid(Effect.forkIn(Effect.interruptible(attempt), maintenanceScope))
1688
+ : restore(attempt);
1689
+ }), Effect.catchCause((cause) => Cause.hasInterruptsOnly(cause)
1690
+ ? Effect.failCause(cause)
1691
+ : Effect.logWarning("journal compaction policy bookkeeping failed", cause)))));
1692
+ };
1693
+ const recordCommits = (commits) => {
1694
+ for (const { commit, queued } of commits) {
1695
+ rememberCommitted(queued, commit.entry.seq);
1696
+ }
1697
+ };
1698
+ const settle = (count) => {
1699
+ state.pending = Math.max(0, state.pending - count);
1700
+ if (state.pending === 0 && pendingMaintenance === 0) {
1701
+ completeFlushWaiters(Effect.void);
1702
+ }
1703
+ };
1704
+ /** Reports entries that left the queue without durable commits. */
1705
+ const reportLoss = (cause, batch) => {
1706
+ for (const queued of batch) {
1707
+ const identity = sourceEventKey(queued.runId, queued.sourceId, queued.sourceSeq);
1708
+ // Allocation is serialized until this batch settles, so no newer
1709
+ // admission can replace this exact identity before the deletion.
1710
+ state.sourceEvents.delete(identity);
1711
+ }
1712
+ state.sinkFailure = cause;
1713
+ state.lossEpoch += 1;
1714
+ state.pending = Math.max(0, state.pending - batch.length);
1715
+ // A waiter that is already registered is the flush the loss belongs
1716
+ // to, so reporting it there spends the report; only a loss nobody was
1717
+ // waiting on is left for the next flush to pick up.
1718
+ if (state.flushWaiters.size > 0) {
1719
+ state.flushedLossEpoch = state.lossEpoch;
1720
+ }
1721
+ completeFlushWaiters(Effect.fail(cause));
1722
+ for (const subscribers of wakes.values()) {
1723
+ for (const wake of subscribers) {
1724
+ PubSub.publishUnsafe(wake, undefined);
1725
+ }
1726
+ }
1727
+ };
1728
+ /**
1729
+ * Makes a lost batch visible to an operator. `reportLoss` only reaches a
1730
+ * flush caller or a live stream, and a telemetry producer usually has
1731
+ * neither, so the loss is also logged and counted here.
1732
+ */
1733
+ // Callers supply one refused entry or a batch taken with a minimum of one.
1734
+ const observeLoss = (cause, batch) => Effect.andThen(Metric.update(JournalMetrics.lost(cause.code), batch.length), Effect.logWarning(`journal lost ${batch.length} lossy entries (${cause.code}) for runs ${[...new Set(batch.map((queued) => queued.runId))].join(", ")}`, cause));
1735
+ // A failed transaction loses the whole batch. Release its per-run drain
1736
+ // counts before reporting the failure so a waiting compactor can retry
1737
+ // against the same database outage instead of waiting forever.
1738
+ const failSink = (cause, batch) => {
1739
+ settleRunPending(batch);
1740
+ reportLoss(cause, batch);
1741
+ };
1742
+ // One failed batch loses that batch and is reported as such; it never
1743
+ // ends the writer. Only interruption (scope closure) stops the loop, so
1744
+ // the queue keeps draining as soon as the database is healthy again.
1745
+ const writeBatch = Queue.takeBetween(queue, 1, batchSize).pipe(Effect.flatMap((batch) => persistBatch(batch).pipe(Effect.tap((outcome) => Effect.sync(() => recordCommits(outcome.commits))), Effect.tap((outcome) => publish(outcome.commits.map(({ commit }) => commit))),
1746
+ // The transaction has committed and publication is complete. Drop
1747
+ // the barrier counts before policy compaction takes this run's
1748
+ // permit. Maintenance has its own global flush count.
1749
+ Effect.tap(() => Effect.sync(() => settleRunPending(batch))),
1750
+ // Register maintenance BEFORE settling the batch, so flush cannot
1751
+ // observe a gap between queued work and its policy attempt.
1752
+ Effect.tap((outcome) => {
1753
+ const perRun = new Map();
1754
+ outcome.commits.forEach(({ commit, queued }) => {
1755
+ if (!commit.inserted) {
1756
+ return;
1757
+ }
1758
+ perRun.set(queued.runId, (perRun.get(queued.runId) ?? 0) + 1);
1759
+ });
1760
+ return Effect.forEach(perRun, ([runId, committed]) => noteCommitted(runId, committed, true), {
1761
+ discard: true
1762
+ });
1763
+ }), Effect.tap((outcome) =>
1764
+ // Observed before it is reported, so a flush the report wakes
1765
+ // already sees the loss in the log and the registry.
1766
+ Effect.uninterruptible(Effect.andThen(Effect.forEach(outcome.losses, (loss) => observeLoss(loss.cause, [loss.queued]), { discard: true }), Effect.sync(() => {
1767
+ for (const loss of outcome.losses) {
1768
+ reportLoss(loss.cause, [loss.queued]);
1769
+ }
1770
+ settle(outcome.commits.length);
1771
+ })))), Effect.catch((cause) => Effect.uninterruptible(Effect.andThen(observeLoss(cause, batch), Effect.sync(() => failSink(cause, batch))))),
1772
+ // Defects only: an interruption is scope closure, and it must end
1773
+ // the writer rather than be reported as a lost batch.
1774
+ Effect.catchDefect((defect) => {
1775
+ const cause = error("sink_failed", "journal writer failed", Cause.die(defect));
1776
+ return Effect.uninterruptible(Effect.andThen(observeLoss(cause, batch), Effect.sync(() => failSink(cause, batch))));
1777
+ }))));
1778
+ const drain = Effect.forever(writeBatch);
1779
+ yield* Effect.forkScoped(drain);
1780
+ yield* Effect.addFinalizer(() => Effect.gen(function* () {
1781
+ // A scope finalizer runs once, and nothing else moves the status, so
1782
+ // the journal is always `open` here.
1783
+ yield* Effect.sync(() => {
1784
+ state.status = "closing";
1785
+ });
1786
+ // The last flush is the last chance to say that queued entries were
1787
+ // lost; nothing is left to report the failure to once this returns.
1788
+ yield* flushInternal.pipe(Effect.catch((cause) => Effect.logWarning("journal final flush failed while closing", cause)));
1789
+ yield* Effect.sync(() => {
1790
+ state.status = "closed";
1791
+ });
1792
+ yield* Queue.shutdown(queue);
1793
+ yield* PubSub.shutdown(changes);
1794
+ wakes.clear();
1795
+ }));
1796
+ const project = (projection, streamOptions) => Stream.unwrap(Effect.fn("Journal.project")((activeProjection, activeOptions) => Effect.annotateCurrentSpan({
1797
+ projection: activeProjection.name,
1798
+ runId: activeOptions.runId,
1799
+ ...(activeOptions.afterSequence === undefined ? {} : { afterSequence: activeOptions.afterSequence })
1800
+ }).pipe(Effect.andThen(Effect.succeed(stream(activeOptions).pipe(Stream.scanEffect(activeProjection.initial, (state, entry) => Effect.suspend(() => activeProjection.reduce(state, entry)).pipe(Effect.catchCause((cause) => Cause.hasInterruptsOnly(cause)
1801
+ ? Effect.failCause(cause)
1802
+ : Effect.fail(error("projection_failed", `projection ${activeProjection.name} failed`, cause))))))))))(projection, streamOptions));
1803
+ yield* JournalGeneration.onTruncate((runIds) => {
1804
+ for (const runId of runIds) {
1805
+ state.sequences.delete(runId);
1806
+ const prefix = `${runId.length}:${runId}`;
1807
+ for (const key of state.sourceSequences.keys()) {
1808
+ if (key.startsWith(prefix))
1809
+ state.sourceSequences.delete(key);
1810
+ }
1811
+ for (const key of state.sourceEvents.keys()) {
1812
+ if (key.startsWith(prefix))
1813
+ state.sourceEvents.delete(key);
1814
+ }
1815
+ }
1816
+ });
1817
+ return makeJournal({
1818
+ generation: (runId) => sql `
1819
+ SELECT generation, after_seq AS "afterSeq" FROM flows_journal_generations WHERE run_id = ${runId}
1820
+ `.pipe(Effect.map((rows) => rows[0] ?? { generation: 0, afterSeq: -1 }), Effect.mapError((cause) => error("read_failed", "could not read journal generation", cause))),
1821
+ emitLossy,
1822
+ emitDurable,
1823
+ emitDurableUnfenced,
1824
+ transact,
1825
+ whenCommitted: (update) => Effect.flatMap(Effect.serviceOption(sql.transactionService), (transaction) => Option.isNone(transaction) ? Effect.as(update, true) : afterCommit(update, sql)),
1826
+ stream,
1827
+ entries: readPage,
1828
+ changes: PubSub.subscribe(changes),
1829
+ project,
1830
+ flush: Effect.fn("Journal.flush")(() => Effect.suspend(() => Effect.annotateCurrentSpan({ pending: state.pending })).pipe(Effect.andThen(flushInternal)))(),
1831
+ checkpoint,
1832
+ latestCheckpoint,
1833
+ compact
1834
+ });
1835
+ });
1836
+ /**
1837
+ * Provides the SQL journal over an explicitly injected consensus strategy.
1838
+ *
1839
+ * The strategy in context arbitrates the owner fence: `emitDurable`,
1840
+ * `checkpoint`, and `compact` join its `guard` inside their write
1841
+ * transaction. Provide the SAME strategy instance to every service that must
1842
+ * agree on ownership — `RunStore.layerWith` in particular — because
1843
+ * `Consensus.layerLocal` keeps its leases per layer build.
1844
+ *
1845
+ * @category layers
1846
+ * @since 1.0.0
1847
+ */
1848
+ export const layerWith = (options) => Layer.effect(Journal, make(options));
1849
+ /**
1850
+ * Provides the SQL journal fenced by the database-backed strategy,
1851
+ * `SqlConsensus`, over the same client. Use {@link layerWith} to inject a
1852
+ * different strategy.
1853
+ *
1854
+ * @category layers
1855
+ * @since 0.1.0
1856
+ */
1857
+ export const layer = (options) => layerWith(options).pipe(Layer.provide(SqlConsensus.layer));
1858
+ //# sourceMappingURL=SqlJournal.js.map