@smthrs/sync 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 (266) hide show
  1. package/CHANGELOG.md +208 -0
  2. package/LICENSE +21 -0
  3. package/README.md +208 -2
  4. package/dist/cjs/BranchCommands.d.ts +228 -0
  5. package/dist/cjs/BranchCommands.d.ts.map +1 -0
  6. package/dist/cjs/BranchCommands.js +316 -0
  7. package/dist/cjs/BranchCommands.js.map +7 -0
  8. package/dist/cjs/BranchIds.d.ts +65 -0
  9. package/dist/cjs/BranchIds.d.ts.map +1 -0
  10. package/dist/cjs/BranchIds.js +53 -0
  11. package/dist/cjs/BranchIds.js.map +7 -0
  12. package/dist/cjs/BranchPresence.d.ts +195 -0
  13. package/dist/cjs/BranchPresence.d.ts.map +1 -0
  14. package/dist/cjs/BranchPresence.js +276 -0
  15. package/dist/cjs/BranchPresence.js.map +7 -0
  16. package/dist/cjs/BranchProjection.d.ts +179 -0
  17. package/dist/cjs/BranchProjection.d.ts.map +1 -0
  18. package/dist/cjs/BranchProjection.js +197 -0
  19. package/dist/cjs/BranchProjection.js.map +7 -0
  20. package/dist/cjs/BranchProtocol.d.ts +415 -0
  21. package/dist/cjs/BranchProtocol.d.ts.map +1 -0
  22. package/dist/cjs/BranchProtocol.js +172 -0
  23. package/dist/cjs/BranchProtocol.js.map +7 -0
  24. package/dist/cjs/BranchRpcs.d.ts +177 -0
  25. package/dist/cjs/BranchRpcs.d.ts.map +1 -0
  26. package/dist/cjs/BranchRpcs.js +85 -0
  27. package/dist/cjs/BranchRpcs.js.map +7 -0
  28. package/dist/cjs/BranchServer.d.ts +55 -0
  29. package/dist/cjs/BranchServer.d.ts.map +1 -0
  30. package/dist/cjs/BranchServer.js +147 -0
  31. package/dist/cjs/BranchServer.js.map +7 -0
  32. package/dist/cjs/BranchShare.d.ts +159 -0
  33. package/dist/cjs/BranchShare.d.ts.map +1 -0
  34. package/dist/cjs/BranchShare.js +147 -0
  35. package/dist/cjs/BranchShare.js.map +7 -0
  36. package/dist/cjs/RunCatalog.d.ts +207 -0
  37. package/dist/cjs/RunCatalog.d.ts.map +1 -0
  38. package/dist/cjs/RunCatalog.js +122 -0
  39. package/dist/cjs/RunCatalog.js.map +7 -0
  40. package/dist/cjs/SyncAuth.d.ts +70 -0
  41. package/dist/cjs/SyncAuth.d.ts.map +1 -0
  42. package/dist/cjs/SyncAuth.js +109 -0
  43. package/dist/cjs/SyncAuth.js.map +7 -0
  44. package/dist/cjs/SyncClient.d.ts +274 -0
  45. package/dist/cjs/SyncClient.d.ts.map +1 -0
  46. package/dist/cjs/SyncClient.js +474 -0
  47. package/dist/cjs/SyncClient.js.map +7 -0
  48. package/dist/cjs/SyncError.d.ts +107 -0
  49. package/dist/cjs/SyncError.d.ts.map +1 -0
  50. package/dist/cjs/SyncError.js +110 -0
  51. package/dist/cjs/SyncError.js.map +7 -0
  52. package/dist/cjs/SyncPrincipal.d.ts +100 -0
  53. package/dist/cjs/SyncPrincipal.d.ts.map +1 -0
  54. package/dist/cjs/SyncPrincipal.js +48 -0
  55. package/dist/cjs/SyncPrincipal.js.map +7 -0
  56. package/dist/cjs/SyncProtocol.d.ts +592 -0
  57. package/dist/cjs/SyncProtocol.d.ts.map +1 -0
  58. package/dist/cjs/SyncProtocol.js +165 -0
  59. package/dist/cjs/SyncProtocol.js.map +7 -0
  60. package/dist/cjs/SyncRpcs.d.ts +92 -0
  61. package/dist/cjs/SyncRpcs.d.ts.map +1 -0
  62. package/dist/cjs/SyncRpcs.js +35 -0
  63. package/dist/cjs/SyncRpcs.js.map +7 -0
  64. package/dist/cjs/SyncServer.d.ts +184 -0
  65. package/dist/cjs/SyncServer.d.ts.map +1 -0
  66. package/dist/cjs/SyncServer.js +513 -0
  67. package/dist/cjs/SyncServer.js.map +7 -0
  68. package/dist/cjs/WorkspaceShare.d.ts +174 -0
  69. package/dist/cjs/WorkspaceShare.d.ts.map +1 -0
  70. package/dist/cjs/WorkspaceShare.js +149 -0
  71. package/dist/cjs/WorkspaceShare.js.map +7 -0
  72. package/dist/cjs/index.d.ts +91 -0
  73. package/dist/cjs/index.d.ts.map +1 -0
  74. package/dist/cjs/index.js +67 -0
  75. package/dist/cjs/index.js.map +7 -0
  76. package/dist/cjs/internal/Admission.d.ts +43 -0
  77. package/dist/cjs/internal/Admission.d.ts.map +1 -0
  78. package/dist/cjs/internal/Admission.js +90 -0
  79. package/dist/cjs/internal/Admission.js.map +7 -0
  80. package/dist/cjs/internal/CauseText.d.ts +55 -0
  81. package/dist/cjs/internal/CauseText.d.ts.map +1 -0
  82. package/dist/cjs/internal/CauseText.js +38 -0
  83. package/dist/cjs/internal/CauseText.js.map +7 -0
  84. package/dist/cjs/internal/JournalErrorCode.d.ts +23 -0
  85. package/dist/cjs/internal/JournalErrorCode.d.ts.map +1 -0
  86. package/dist/cjs/internal/JournalErrorCode.js +34 -0
  87. package/dist/cjs/internal/JournalErrorCode.js.map +7 -0
  88. package/dist/cjs/internal/PolicyOptions.d.ts +57 -0
  89. package/dist/cjs/internal/PolicyOptions.d.ts.map +1 -0
  90. package/dist/cjs/internal/PolicyOptions.js +62 -0
  91. package/dist/cjs/internal/PolicyOptions.js.map +7 -0
  92. package/dist/cjs/internal/ProtocolVersion.d.ts +17 -0
  93. package/dist/cjs/internal/ProtocolVersion.d.ts.map +1 -0
  94. package/dist/cjs/internal/ProtocolVersion.js +44 -0
  95. package/dist/cjs/internal/ProtocolVersion.js.map +7 -0
  96. package/dist/cjs/internal/ShareSigner.d.ts +173 -0
  97. package/dist/cjs/internal/ShareSigner.d.ts.map +1 -0
  98. package/dist/cjs/internal/ShareSigner.js +150 -0
  99. package/dist/cjs/internal/ShareSigner.js.map +7 -0
  100. package/dist/cjs/internal/SnapshotBoundary.d.ts +17 -0
  101. package/dist/cjs/internal/SnapshotBoundary.d.ts.map +1 -0
  102. package/dist/cjs/internal/SnapshotBoundary.js +97 -0
  103. package/dist/cjs/internal/SnapshotBoundary.js.map +7 -0
  104. package/dist/cjs/package.json +1 -0
  105. package/dist/cjs/test/TestSocket.d.ts +55 -0
  106. package/dist/cjs/test/TestSocket.d.ts.map +1 -0
  107. package/dist/cjs/test/TestSocket.js +145 -0
  108. package/dist/cjs/test/TestSocket.js.map +7 -0
  109. package/dist/cjs/test/TestSync.d.ts +54 -0
  110. package/dist/cjs/test/TestSync.d.ts.map +1 -0
  111. package/dist/cjs/test/TestSync.js +96 -0
  112. package/dist/cjs/test/TestSync.js.map +7 -0
  113. package/dist/esm/BranchCommands.d.ts +228 -0
  114. package/dist/esm/BranchCommands.d.ts.map +1 -0
  115. package/dist/esm/BranchCommands.js +496 -0
  116. package/dist/esm/BranchCommands.js.map +1 -0
  117. package/dist/esm/BranchIds.d.ts +65 -0
  118. package/dist/esm/BranchIds.d.ts.map +1 -0
  119. package/dist/esm/BranchIds.js +61 -0
  120. package/dist/esm/BranchIds.js.map +1 -0
  121. package/dist/esm/BranchPresence.d.ts +195 -0
  122. package/dist/esm/BranchPresence.d.ts.map +1 -0
  123. package/dist/esm/BranchPresence.js +361 -0
  124. package/dist/esm/BranchPresence.js.map +1 -0
  125. package/dist/esm/BranchProjection.d.ts +179 -0
  126. package/dist/esm/BranchProjection.d.ts.map +1 -0
  127. package/dist/esm/BranchProjection.js +269 -0
  128. package/dist/esm/BranchProjection.js.map +1 -0
  129. package/dist/esm/BranchProtocol.d.ts +415 -0
  130. package/dist/esm/BranchProtocol.d.ts.map +1 -0
  131. package/dist/esm/BranchProtocol.js +323 -0
  132. package/dist/esm/BranchProtocol.js.map +1 -0
  133. package/dist/esm/BranchRpcs.d.ts +177 -0
  134. package/dist/esm/BranchRpcs.d.ts.map +1 -0
  135. package/dist/esm/BranchRpcs.js +103 -0
  136. package/dist/esm/BranchRpcs.js.map +1 -0
  137. package/dist/esm/BranchServer.d.ts +55 -0
  138. package/dist/esm/BranchServer.d.ts.map +1 -0
  139. package/dist/esm/BranchServer.js +168 -0
  140. package/dist/esm/BranchServer.js.map +1 -0
  141. package/dist/esm/BranchShare.d.ts +159 -0
  142. package/dist/esm/BranchShare.d.ts.map +1 -0
  143. package/dist/esm/BranchShare.js +213 -0
  144. package/dist/esm/BranchShare.js.map +1 -0
  145. package/dist/esm/RunCatalog.d.ts +207 -0
  146. package/dist/esm/RunCatalog.d.ts.map +1 -0
  147. package/dist/esm/RunCatalog.js +196 -0
  148. package/dist/esm/RunCatalog.js.map +1 -0
  149. package/dist/esm/SyncAuth.d.ts +70 -0
  150. package/dist/esm/SyncAuth.d.ts.map +1 -0
  151. package/dist/esm/SyncAuth.js +119 -0
  152. package/dist/esm/SyncAuth.js.map +1 -0
  153. package/dist/esm/SyncClient.d.ts +274 -0
  154. package/dist/esm/SyncClient.d.ts.map +1 -0
  155. package/dist/esm/SyncClient.js +586 -0
  156. package/dist/esm/SyncClient.js.map +1 -0
  157. package/dist/esm/SyncError.d.ts +107 -0
  158. package/dist/esm/SyncError.d.ts.map +1 -0
  159. package/dist/esm/SyncError.js +133 -0
  160. package/dist/esm/SyncError.js.map +1 -0
  161. package/dist/esm/SyncPrincipal.d.ts +100 -0
  162. package/dist/esm/SyncPrincipal.d.ts.map +1 -0
  163. package/dist/esm/SyncPrincipal.js +58 -0
  164. package/dist/esm/SyncPrincipal.js.map +1 -0
  165. package/dist/esm/SyncProtocol.d.ts +592 -0
  166. package/dist/esm/SyncProtocol.d.ts.map +1 -0
  167. package/dist/esm/SyncProtocol.js +434 -0
  168. package/dist/esm/SyncProtocol.js.map +1 -0
  169. package/dist/esm/SyncRpcs.d.ts +92 -0
  170. package/dist/esm/SyncRpcs.d.ts.map +1 -0
  171. package/dist/esm/SyncRpcs.js +35 -0
  172. package/dist/esm/SyncRpcs.js.map +1 -0
  173. package/dist/esm/SyncServer.d.ts +184 -0
  174. package/dist/esm/SyncServer.d.ts.map +1 -0
  175. package/dist/esm/SyncServer.js +897 -0
  176. package/dist/esm/SyncServer.js.map +1 -0
  177. package/dist/esm/WorkspaceShare.d.ts +174 -0
  178. package/dist/esm/WorkspaceShare.d.ts.map +1 -0
  179. package/dist/esm/WorkspaceShare.js +208 -0
  180. package/dist/esm/WorkspaceShare.js.map +1 -0
  181. package/dist/esm/index.d.ts +91 -0
  182. package/dist/esm/index.d.ts.map +1 -0
  183. package/dist/esm/index.js +91 -0
  184. package/dist/esm/index.js.map +1 -0
  185. package/dist/esm/internal/Admission.d.ts +43 -0
  186. package/dist/esm/internal/Admission.d.ts.map +1 -0
  187. package/dist/esm/internal/Admission.js +80 -0
  188. package/dist/esm/internal/Admission.js.map +1 -0
  189. package/dist/esm/internal/CauseText.d.ts +55 -0
  190. package/dist/esm/internal/CauseText.d.ts.map +1 -0
  191. package/dist/esm/internal/CauseText.js +67 -0
  192. package/dist/esm/internal/CauseText.js.map +1 -0
  193. package/dist/esm/internal/JournalErrorCode.d.ts +23 -0
  194. package/dist/esm/internal/JournalErrorCode.d.ts.map +1 -0
  195. package/dist/esm/internal/JournalErrorCode.js +44 -0
  196. package/dist/esm/internal/JournalErrorCode.js.map +1 -0
  197. package/dist/esm/internal/PolicyOptions.d.ts +57 -0
  198. package/dist/esm/internal/PolicyOptions.d.ts.map +1 -0
  199. package/dist/esm/internal/PolicyOptions.js +70 -0
  200. package/dist/esm/internal/PolicyOptions.js.map +1 -0
  201. package/dist/esm/internal/ProtocolVersion.d.ts +17 -0
  202. package/dist/esm/internal/ProtocolVersion.d.ts.map +1 -0
  203. package/dist/esm/internal/ProtocolVersion.js +24 -0
  204. package/dist/esm/internal/ProtocolVersion.js.map +1 -0
  205. package/dist/esm/internal/ShareSigner.d.ts +173 -0
  206. package/dist/esm/internal/ShareSigner.d.ts.map +1 -0
  207. package/dist/esm/internal/ShareSigner.js +219 -0
  208. package/dist/esm/internal/ShareSigner.js.map +1 -0
  209. package/dist/esm/internal/SnapshotBoundary.d.ts +17 -0
  210. package/dist/esm/internal/SnapshotBoundary.d.ts.map +1 -0
  211. package/dist/esm/internal/SnapshotBoundary.js +68 -0
  212. package/dist/esm/internal/SnapshotBoundary.js.map +1 -0
  213. package/dist/esm/test/TestSocket.d.ts +55 -0
  214. package/dist/esm/test/TestSocket.d.ts.map +1 -0
  215. package/dist/esm/test/TestSocket.js +128 -0
  216. package/dist/esm/test/TestSocket.js.map +1 -0
  217. package/dist/esm/test/TestSync.d.ts +54 -0
  218. package/dist/esm/test/TestSync.d.ts.map +1 -0
  219. package/dist/esm/test/TestSync.js +76 -0
  220. package/dist/esm/test/TestSync.js.map +1 -0
  221. package/docs/README.md +156 -0
  222. package/docs/api.md +328 -0
  223. package/docs/concepts/authorization.md +117 -0
  224. package/docs/concepts/branches.md +195 -0
  225. package/docs/concepts/compaction.md +144 -0
  226. package/docs/concepts/replay-then-follow.md +129 -0
  227. package/docs/concepts/scopes-and-cursors.md +135 -0
  228. package/docs/guides/authorize-a-connection.md +174 -0
  229. package/docs/guides/follow-a-run.md +174 -0
  230. package/docs/guides/handle-a-compacted-run.md +158 -0
  231. package/docs/guides/list-workspace-runs.md +120 -0
  232. package/docs/guides/run-a-long-soak.md +80 -0
  233. package/docs/guides/serve-the-read-path.md +146 -0
  234. package/docs/guides/test-a-follower.md +273 -0
  235. package/docs/installation.md +92 -0
  236. package/docs/protocol.md +183 -0
  237. package/docs/quickstart.md +159 -0
  238. package/docs/troubleshooting.md +234 -0
  239. package/package.json +304 -3
  240. package/src/BranchCommands.ts +684 -0
  241. package/src/BranchIds.ts +76 -0
  242. package/src/BranchPresence.ts +512 -0
  243. package/src/BranchProjection.ts +308 -0
  244. package/src/BranchProtocol.ts +411 -0
  245. package/src/BranchRpcs.ts +163 -0
  246. package/src/BranchServer.ts +202 -0
  247. package/src/BranchShare.ts +297 -0
  248. package/src/RunCatalog.ts +308 -0
  249. package/src/SyncAuth.ts +146 -0
  250. package/src/SyncClient.ts +902 -0
  251. package/src/SyncError.ts +147 -0
  252. package/src/SyncPrincipal.ts +115 -0
  253. package/src/SyncProtocol.ts +594 -0
  254. package/src/SyncRpcs.ts +40 -0
  255. package/src/SyncServer.ts +1184 -0
  256. package/src/WorkspaceShare.ts +292 -0
  257. package/src/index.ts +107 -0
  258. package/src/internal/Admission.ts +119 -0
  259. package/src/internal/CauseText.ts +74 -0
  260. package/src/internal/JournalErrorCode.ts +46 -0
  261. package/src/internal/PolicyOptions.ts +97 -0
  262. package/src/internal/ProtocolVersion.ts +28 -0
  263. package/src/internal/ShareSigner.ts +300 -0
  264. package/src/internal/SnapshotBoundary.ts +91 -0
  265. package/src/test/TestSocket.ts +192 -0
  266. package/src/test/TestSync.ts +113 -0
@@ -0,0 +1,684 @@
1
+ /**
2
+ * Idempotent admission of collaborative commands onto a branch document.
3
+ *
4
+ * Three different things look identical from the server's side — an optimistic
5
+ * client re-sending after a timeout, a reconnecting client flushing its
6
+ * outbox, and two people pressing the same button at once — and all three must
7
+ * produce exactly one durable command. The client-minted `commandId` is the
8
+ * idempotency key for all three.
9
+ *
10
+ * The exactly-once constraint is durable, not process-local: every append
11
+ * carries the producer identity `(branch run, commandSourceId(commandId),
12
+ * commandSourceSeq)`, which the journal enforces inside its own write
13
+ * transaction. Two independently constructed servers that race the same
14
+ * command therefore collide in the journal — one appends, the other receives
15
+ * a duplicate receipt or an idempotency conflict and resolves the canonical
16
+ * sequence by replaying the branch (audit finding F-14). The in-memory
17
+ * ledger, permit, and replay cursor are a fast path only: they answer known
18
+ * duplicates without a journal write and keep a restarted server from
19
+ * re-executing history, but correctness never depends on them.
20
+ *
21
+ * @since 0.1.0
22
+ */
23
+
24
+ import { Journal } from "@smthrs/journal"
25
+ import * as JournalEvent from "@smthrs/journal/JournalEvent"
26
+ import * as Context from "effect/Context"
27
+ import * as Effect from "effect/Effect"
28
+ import * as Layer from "effect/Layer"
29
+ import * as RcMap from "effect/RcMap"
30
+ import type * as Scope from "effect/Scope"
31
+ import * as Semaphore from "effect/Semaphore"
32
+ import {
33
+ type BranchId,
34
+ branchRunId,
35
+ CommandEvent,
36
+ type CommandId,
37
+ CommandReceipt,
38
+ commandSourceId,
39
+ commandSourceSeq,
40
+ CommandSubmission,
41
+ SubmitRequest
42
+ } from "./BranchProtocol.ts"
43
+ import { maximumBranchTtlMs } from "./BranchRpcs.ts"
44
+ import * as BranchShare from "./BranchShare.ts"
45
+ import * as Admission from "./internal/Admission.ts"
46
+ import { causeCode } from "./internal/CauseText.ts"
47
+ import { journalErrorCode } from "./internal/JournalErrorCode.ts"
48
+ import { positiveInt } from "./internal/PolicyOptions.ts"
49
+ import { SyncError } from "./SyncError.ts"
50
+ import * as SyncProtocol from "./SyncProtocol.ts"
51
+
52
+ /**
53
+ * Branch command admission operations.
54
+ *
55
+ * @category models
56
+ * @since 0.1.0
57
+ */
58
+ export interface Service {
59
+ readonly submit: (request: SubmitRequest) => Effect.Effect<CommandReceipt, SyncError>
60
+ }
61
+
62
+ /**
63
+ * The branch command ledger.
64
+ *
65
+ * @category services
66
+ * @since 0.1.0
67
+ */
68
+ export class BranchCommands extends Context.Service<BranchCommands, Service>()("@smthrs/sync/BranchCommands") {}
69
+
70
+ /**
71
+ * Constructs a command ledger that admits nothing.
72
+ *
73
+ * @category constructors
74
+ * @since 0.1.0
75
+ */
76
+ export const makeNoop = (overrides: Partial<Service> = {}): Service =>
77
+ BranchCommands.of({
78
+ submit: () => Effect.fail(new SyncError({ code: "closed", message: "Branch commands are unavailable" })),
79
+ ...overrides
80
+ })
81
+
82
+ /**
83
+ * Provides a command ledger that admits nothing.
84
+ *
85
+ * @category layers
86
+ * @since 0.1.0
87
+ */
88
+ export const layerNoop: Layer.Layer<BranchCommands> = Layer.succeed(BranchCommands, makeNoop())
89
+
90
+ /**
91
+ * Projects a journal write failure onto the sync boundary.
92
+ *
93
+ * The public `message` is a constant and the `cause` names the failure's type
94
+ * only. A branch writer may hold nothing but a share link, and the journal's
95
+ * own message is the SQLite driver's, which carries SQL text, table and column
96
+ * names, and constraint identifiers. The CODE is a different question from the
97
+ * message: a journal code this boundary also declares crosses as that code, so
98
+ * a writer whose commit was lost to a closing journal is told that rather than
99
+ * being told nothing.
100
+ */
101
+ const journalFailure = (cause: unknown): SyncError =>
102
+ new SyncError({
103
+ code: journalErrorCode(cause),
104
+ message: "Branch journal write failed",
105
+ cause: causeCode(cause)
106
+ })
107
+
108
+ /** How many entries one rehydration page reads. */
109
+ const pageSize = 256
110
+
111
+ /**
112
+ * Receipts one branch keeps in memory before the oldest are evicted.
113
+ *
114
+ * The ledger is a fast path: it answers a known duplicate without a journal
115
+ * write, and losing an entry costs a round trip, never correctness, because
116
+ * the journal's own producer identity is the durable exactly-once constraint
117
+ * and returns a `Duplicate` receipt for a command already admitted. Retaining
118
+ * one receipt per command forever meant a process serving a hundred branches
119
+ * of a hundred thousand commands held ten million of them, so the fast path
120
+ * is bounded and the durable path is what makes it safe to bound.
121
+ *
122
+ * @category constants
123
+ * @since 1.0.0-rc.0
124
+ */
125
+ export const defaultLedgerCapacity = 4096
126
+
127
+ /**
128
+ * Inclusive UTF-8 JSON submission ceiling. The separate sync entry ceiling
129
+ * leaves room for the durable envelope around every admitted command.
130
+ *
131
+ * @category constants
132
+ * @since 1.0.0-rc.0
133
+ */
134
+ export const defaultMaxCommandBytes = 1024 * 1024
135
+
136
+ /**
137
+ * Milliseconds a branch may have no active submissions before its in-memory state is
138
+ * dropped.
139
+ *
140
+ * The receipt ledger is bounded per branch; this bounds the branches. A
141
+ * dropped branch re-hydrates on its next submission exactly as it does after
142
+ * a restart, so eviction costs a journal read, never correctness. The default
143
+ * is the longest lifetime a branch capability may carry, so a branch is only
144
+ * dropped once every capability minted at its last touch can have expired.
145
+ *
146
+ * @category constants
147
+ * @since 1.0.0-rc.0
148
+ */
149
+ export const defaultBranchIdleMs = maximumBranchTtlMs
150
+
151
+ /**
152
+ * Entries one branch's first-touch hydration reads before it stops.
153
+ *
154
+ * Hydration is the only full history read that ever sat on the WRITE path: the
155
+ * first submission a process makes to a branch paged that branch's whole
156
+ * durable log, 256 entries at a time, while holding the branch's admission
157
+ * permit, before its own append was attempted. A hundred-thousand-entry branch
158
+ * therefore charged its next writer four hundred journal reads for a fast path
159
+ * that keeps four thousand receipts.
160
+ *
161
+ * The budget defaults to {@link defaultLedgerCapacity} because hydration
162
+ * cannot seed more receipts than the ledger holds: everything a longer walk
163
+ * records displaces something the same walk already recorded. What the extra
164
+ * reads buy is a FRESHER selection — the tail of history rather than its head
165
+ * — and the price is an unbounded wait on somebody's write. This trades that
166
+ * back: the walk stops, the cursor stays where it stopped, and a later replay
167
+ * continues forward from there.
168
+ *
169
+ * A branch longer than the budget starts with a partly cold fast path and
170
+ * nothing else. Correctness never rested on the ledger: the journal's producer
171
+ * identity is the durable exactly-once constraint, an exact retry receives a
172
+ * `Duplicate` receipt carrying the canonical sequence, and an
173
+ * `idempotency_conflict` is resolved by a read of the whole history that the
174
+ * ledger's own bound already made necessary.
175
+ *
176
+ * @category constants
177
+ * @since 1.0.0-rc.0
178
+ */
179
+ export const defaultHydrationLimit = defaultLedgerCapacity
180
+
181
+ /**
182
+ * Admission policy.
183
+ *
184
+ * The default caps one encoded command submission at 1 MiB, one branch's
185
+ * in-memory receipt ledger at {@link defaultLedgerCapacity} commands, and one
186
+ * branch's first-touch hydration at {@link defaultHydrationLimit} entries.
187
+ *
188
+ * @category models
189
+ * @since 0.1.0
190
+ */
191
+ export interface Options {
192
+ /**
193
+ * Largest encoded journal entry followers accept. Defaults to
194
+ * {@link SyncProtocol.defaultMaxFrameBytes}. Configure the server and client
195
+ * with the same or a larger ceiling. Admission reserves the full durable
196
+ * envelope, including generated sequence and timestamp fields, before writing.
197
+ */
198
+ readonly maxFrameBytes?: number | undefined
199
+ /**
200
+ * Largest encoded command submission admitted, in bytes. Defaults to
201
+ * {@link defaultMaxCommandBytes}.
202
+ */
203
+ readonly maxCommandBytes?: number | undefined
204
+ /**
205
+ * Receipts one branch keeps in memory before the oldest are evicted.
206
+ * Defaults to {@link defaultLedgerCapacity}.
207
+ */
208
+ readonly ledgerCapacity?: number | undefined
209
+ /**
210
+ * Entries one branch's first-touch hydration reads before it stops.
211
+ * Defaults to {@link defaultHydrationLimit}.
212
+ */
213
+ readonly hydrationLimit?: number | undefined
214
+ /**
215
+ * Milliseconds a branch may have no active submissions before its in-memory state is
216
+ * dropped. Defaults to {@link defaultBranchIdleMs}.
217
+ */
218
+ readonly branchIdleMs?: number | undefined
219
+ }
220
+
221
+ /** Everything one process holds for one branch. */
222
+ interface BranchState {
223
+ // One permit PER BRANCH. A single process-wide permit serialized admission
224
+ // across every branch the process served, so one branch's first-touch
225
+ // history replay blocked every other branch's writes.
226
+ readonly permit: Semaphore.Semaphore
227
+ receipts: Map<CommandId, CommandReceipt>
228
+ cursor: JournalEvent.Seq | undefined
229
+ hydrated: boolean
230
+ }
231
+
232
+ /** The ledger over an already-validated policy. */
233
+ const makeWith = (
234
+ resolved: {
235
+ readonly maxCommandBytes: number
236
+ readonly maxFrameBytes: number
237
+ readonly ledgerCapacity: number
238
+ readonly hydrationLimit: number
239
+ readonly branchIdleMs: number
240
+ }
241
+ ): Effect.Effect<Service, never, Journal.Journal | BranchShare.BranchShare | Scope.Scope> =>
242
+ Effect.gen(
243
+ function*() {
244
+ const journal = yield* Journal.Journal
245
+ const share = yield* BranchShare.BranchShare
246
+ const { branchIdleMs, hydrationLimit, ledgerCapacity, maxCommandBytes, maxFrameBytes } = resolved
247
+ // Active submissions share one state; its idle lifetime starts at release.
248
+ const branches = yield* RcMap.make({
249
+ idleTimeToLive: branchIdleMs,
250
+ lookup: (_branchId: BranchId) =>
251
+ Effect.sync((): BranchState => ({
252
+ permit: Semaphore.makeUnsafe(1),
253
+ receipts: new Map(),
254
+ cursor: undefined,
255
+ hydrated: false
256
+ }))
257
+ })
258
+
259
+ /**
260
+ * Records one receipt, evicting the branch's oldest once the branch is
261
+ * at capacity. `Map` iterates in insertion order, so the first key is
262
+ * the least recently recorded one; re-recording a known command
263
+ * refreshes it in place rather than growing the branch.
264
+ */
265
+ const recordIn = (branch: Map<CommandId, CommandReceipt>, receipt: CommandReceipt): void => {
266
+ branch.delete(receipt.commandId)
267
+ branch.set(receipt.commandId, receipt)
268
+ for (const oldest of branch.keys()) {
269
+ if (branch.size <= ledgerCapacity) break
270
+ branch.delete(oldest)
271
+ }
272
+ }
273
+
274
+ const duplicateOf = (known: CommandReceipt): CommandReceipt =>
275
+ new CommandReceipt({
276
+ branchId: known.branchId,
277
+ commandId: known.commandId,
278
+ status: "duplicate",
279
+ seq: known.seq
280
+ })
281
+
282
+ /**
283
+ * Pages a branch's journal forward from `from`, handing each entry to
284
+ * `visit` along with the command identity it carries, if any, and
285
+ * stopping once it has visited `budget` entries. Admission already
286
+ * decoded every command payload in full, so the identity is read off
287
+ * that decoded command rather than decoded a second time.
288
+ *
289
+ * The budget is entries VISITED, not pages read, so a caller names a
290
+ * bound in the unit its own memory is measured in.
291
+ * `Number.POSITIVE_INFINITY` is the unbounded walk the recovery reads
292
+ * take: they answer a question about the whole durable history, and a
293
+ * bounded answer to that question is a wrong one.
294
+ */
295
+ const walk = (
296
+ branchId: BranchId,
297
+ from: JournalEvent.Seq | undefined,
298
+ budget: number,
299
+ visit: (entry: JournalEvent.Entry, commandId: CommandId | undefined) => void
300
+ ): Effect.Effect<void, SyncError> =>
301
+ Effect.gen(function*() {
302
+ const runId = branchRunId(branchId)
303
+ let after = from
304
+ let hasMore = true
305
+ let remaining = budget
306
+ while (hasMore && remaining > 0) {
307
+ const page = yield* journal.entries({
308
+ runId,
309
+ ...(after === undefined ? {} : { after }),
310
+ limit: Math.min(pageSize, remaining)
311
+ }).pipe(Effect.mapError(journalFailure))
312
+ const admitted = yield* Admission.withCommands(page.entries, runId, after ?? -1)
313
+ for (const { command, entry } of admitted) {
314
+ after = entry.seq
315
+ remaining -= 1
316
+ visit(entry, command?.commandId)
317
+ }
318
+ // An empty page ends the walk whatever the page claims, the same
319
+ // guard `SyncServer.tail` carries: `after` cannot move, so the
320
+ // next read is byte-identical and the loop would never terminate
321
+ // — while holding this branch's admission permit.
322
+ if (page.entries.length === 0) return
323
+ hasMore = page.hasMore
324
+ }
325
+ })
326
+
327
+ /**
328
+ * Replays the branch journal forward from the last replayed sequence into
329
+ * the ledger. Used once on first touch, so a process that restarts
330
+ * mid-collaboration still recognises every command it already admitted,
331
+ * and again after an admission conflict, to read the command another
332
+ * writer admitted after this process last looked.
333
+ *
334
+ * The staged cursor tracks each visited entry. A successful bounded walk
335
+ * commits exactly where it stopped, so the next replay resumes there.
336
+ * Failure or interruption discards the staged cursor and receipts together.
337
+ */
338
+ const replay = (branchId: BranchId, state: BranchState, budget: number): Effect.Effect<void, SyncError> =>
339
+ Effect.gen(function*() {
340
+ // Stage at most ledgerCapacity receipts. A malformed later page or an
341
+ // interruption must leave both the ledger and replay cursor untouched.
342
+ const staged = new Map(state.receipts)
343
+ let position = state.cursor
344
+ yield* walk(branchId, position, budget, (entry, commandId) => {
345
+ position = entry.seq
346
+ if (commandId !== undefined) {
347
+ recordIn(staged, new CommandReceipt({ branchId, commandId, status: "admitted", seq: entry.seq }))
348
+ }
349
+ })
350
+ state.cursor = position
351
+ state.receipts = staged
352
+ })
353
+
354
+ /**
355
+ * The sequence one command was admitted at, read from the branch's whole
356
+ * durable history rather than from the ledger.
357
+ *
358
+ * The ledger is BOUNDED, so the receipt for a command admitted far
359
+ * enough back has been evicted, and a forward replay from the ledger's
360
+ * own cursor can never bring it back — the entry is below that cursor.
361
+ * The durable log still holds it, and this is the read that says so.
362
+ * Neither the cursor nor the ledger moves: this answers one question
363
+ * about history and leaves the fast path exactly as it found it.
364
+ */
365
+ const admittedSeq = (
366
+ branchId: BranchId,
367
+ commandId: CommandId
368
+ ): Effect.Effect<JournalEvent.Seq | undefined, SyncError> =>
369
+ Effect.gen(function*() {
370
+ let found: JournalEvent.Seq | undefined
371
+ yield* walk(branchId, undefined, Number.POSITIVE_INFINITY, (entry, admitted) => {
372
+ if (admitted === commandId) found = entry.seq
373
+ })
374
+ return found
375
+ })
376
+
377
+ /**
378
+ * Seeds the branch's fast path ONCE, from its durable history, within
379
+ * the configured budget (see {@link defaultHydrationLimit}).
380
+ *
381
+ * It runs on the write path, inside the branch's admission permit, so
382
+ * the budget is what keeps the length of a branch's history out of the
383
+ * latency of the next submission to it. The branch is marked hydrated
384
+ * whether or not the walk reached the tail, because the point of the
385
+ * budget is that no submission pays for the rest: what the walk did not
386
+ * reach is answered by the journal, and by the unbounded replay
387
+ * {@link lostRace} runs when the journal reports a conflict.
388
+ */
389
+ const hydrate = (branchId: BranchId, state: BranchState): Effect.Effect<void, SyncError> =>
390
+ Effect.gen(function*() {
391
+ if (state.hydrated) return
392
+ yield* replay(branchId, state, hydrationLimit)
393
+ state.hydrated = true
394
+ })
395
+
396
+ /**
397
+ * The losing side of a cross-server admission race: the journal refused
398
+ * this append because another writer already holds the command's producer
399
+ * identity with different content — same `commandId`, different
400
+ * participant or arguments. The original admission is durable, so
401
+ * replaying the branch forward must surface it; a replay that does not is
402
+ * a journal whose conflict report and entries disagree, and that failure
403
+ * is reported honestly instead of being masked as a duplicate.
404
+ *
405
+ * Two reads, because the ledger is bounded and the answer must not be.
406
+ * The forward replay picks up an admission another writer landed after
407
+ * this process last looked. A command this process admitted itself and
408
+ * has since evicted is BELOW that cursor, so only a read of the whole
409
+ * history finds it — and without one, bounding the ledger turned an
410
+ * ordinary duplicate into a report that the journal contradicts itself.
411
+ */
412
+ const lostRace = (
413
+ submission: CommandSubmission,
414
+ state: BranchState,
415
+ cause: Journal.JournalError
416
+ ): Effect.Effect<CommandReceipt, SyncError> =>
417
+ Effect.gen(function*() {
418
+ // Unbounded, unlike hydration: this is the recovery read, and the
419
+ // sequence it is looking for may sit anywhere in the history.
420
+ yield* replay(submission.branchId, state, Number.POSITIVE_INFINITY)
421
+ const known = state.receipts.get(submission.commandId)
422
+ if (known !== undefined) return duplicateOf(known)
423
+ // A branch still under the ledger's capacity has never evicted
424
+ // anything, and `replay` has just read this branch to its tail, so a
425
+ // command missing from the ledger is missing from the journal too.
426
+ // Reading the whole history to confirm that would let one small
427
+ // request cost one full log scan.
428
+ const mayHaveEvicted = state.receipts.size >= ledgerCapacity
429
+ const seq = mayHaveEvicted
430
+ ? yield* admittedSeq(submission.branchId, submission.commandId)
431
+ : undefined
432
+ if (seq === undefined) return yield* Effect.fail(journalFailure(cause))
433
+ return new CommandReceipt({
434
+ branchId: submission.branchId,
435
+ commandId: submission.commandId,
436
+ status: "duplicate",
437
+ seq
438
+ })
439
+ })
440
+
441
+ const journalInput = (submission: CommandSubmission, capabilityId: string): JournalEvent.Input =>
442
+ new JournalEvent.Input({
443
+ runId: branchRunId(submission.branchId),
444
+ sourceId: commandSourceId(submission.commandId),
445
+ sourceSeq: commandSourceSeq,
446
+ eventType: CommandEvent,
447
+ payload: {
448
+ branchId: submission.branchId,
449
+ commandId: submission.commandId,
450
+ participantId: submission.participantId,
451
+ name: submission.name,
452
+ args: submission.args,
453
+ target: submission.target,
454
+ capabilityId
455
+ },
456
+ meta: null
457
+ })
458
+
459
+ const admit = (
460
+ request: SubmitRequest,
461
+ capabilityId: string,
462
+ state: BranchState
463
+ ): Effect.Effect<CommandReceipt, SyncError> =>
464
+ Effect.gen(function*() {
465
+ const submission = request.submission
466
+ yield* hydrate(submission.branchId, state)
467
+ const known = state.receipts.get(submission.commandId)
468
+ if (known !== undefined) return duplicateOf(known)
469
+ // Unfenced: the sync command journal is a multi-writer admission
470
+ // log — participants own no branch run, and command admissions are
471
+ // first-writer-wins on the command id.
472
+ const receipt = yield* journal.emitDurableUnfenced(
473
+ journalInput(submission, capabilityId)
474
+ ).pipe(
475
+ // A `Duplicate` receipt is another writer landing the identical
476
+ // submission first: the journal deduplicated durably and returned
477
+ // the canonical sequence the original append committed at.
478
+ Effect.map((accepted) =>
479
+ new CommandReceipt({
480
+ branchId: submission.branchId,
481
+ commandId: submission.commandId,
482
+ status: accepted._tag === "Duplicate" ? "duplicate" : "admitted",
483
+ seq: accepted.seq
484
+ })
485
+ ),
486
+ Effect.catch((cause) =>
487
+ cause.code === "idempotency_conflict"
488
+ ? lostRace(submission, state, cause)
489
+ : Effect.fail(journalFailure(cause))
490
+ )
491
+ )
492
+ recordIn(
493
+ state.receipts,
494
+ new CommandReceipt({
495
+ branchId: submission.branchId,
496
+ commandId: submission.commandId,
497
+ status: "admitted",
498
+ seq: receipt.seq
499
+ })
500
+ )
501
+ return receipt
502
+ })
503
+
504
+ const submit = Effect.fn("BranchCommands.submit")(function*(supplied: SubmitRequest) {
505
+ const decoded = yield* Admission.decode(SubmitRequest, supplied, "invalid_request")
506
+ // Freeze the authorized submission across asynchronous signature verification.
507
+ const request = { capability: decoded.capability, submission: { ...decoded.submission } }
508
+ yield* Effect.annotateCurrentSpan({
509
+ branchId: request.submission.branchId,
510
+ commandId: request.submission.commandId,
511
+ participantId: request.submission.participantId
512
+ })
513
+ // The verified capability, not the client's `participantId`, is who
514
+ // submitted: it is stamped on the durable payload for attribution.
515
+ const claims = yield* share.verify(request.capability, {
516
+ branchId: request.submission.branchId,
517
+ access: "write"
518
+ })
519
+ const bytes = SyncProtocol.encodedByteLength(request.submission)
520
+ if (bytes > maxCommandBytes) {
521
+ // Refused BEFORE the append: an oversized command must never reach
522
+ // the journal, and therefore never reaches any follower.
523
+ return yield* Effect.fail(
524
+ new SyncError({
525
+ code: "frame_too_large",
526
+ message: `Encoded command submission of ${bytes} bytes exceeds the ${maxCommandBytes}-byte ceiling`
527
+ })
528
+ )
529
+ }
530
+ const input = journalInput(request.submission, claims.capabilityId)
531
+ // The journal owns these fields. Reserve their largest encoded forms,
532
+ // so acceptance does not depend on wall clock or current history length.
533
+ // Event identity repeats run/source IDs; their escaping and UTF-8 cost
534
+ // must be measured, not replaced by a fixed envelope allowance.
535
+ const entryBytes = SyncProtocol.encodedByteLength({
536
+ ...input,
537
+ seq: Number.MAX_SAFE_INTEGER - 1,
538
+ eventId: JournalEvent.makeEventId(input.runId, input.sourceId, commandSourceSeq),
539
+ emittedAtMs: Number.MAX_VALUE
540
+ })
541
+ if (entryBytes > maxFrameBytes) {
542
+ return yield* Effect.fail(
543
+ new SyncError({
544
+ code: "frame_too_large",
545
+ message:
546
+ `Encoded command journal entry requires up to ${entryBytes} bytes, exceeding the ${maxFrameBytes}-byte frame ceiling`
547
+ })
548
+ )
549
+ }
550
+ // The permit is taken AFTER authorization so an unauthorized caller
551
+ // cannot serialize (and therefore stall) legitimate collaborators,
552
+ // and it is this BRANCH's permit so a slow branch stalls only itself.
553
+ return yield* RcMap.get(branches, request.submission.branchId).pipe(
554
+ Effect.flatMap((state) => state.permit.withPermit(admit(request, claims.capabilityId, state))),
555
+ Effect.scoped
556
+ )
557
+ })
558
+
559
+ return BranchCommands.of({ submit })
560
+ }
561
+ )
562
+
563
+ const defaults = {
564
+ maxCommandBytes: defaultMaxCommandBytes,
565
+ maxFrameBytes: SyncProtocol.defaultMaxFrameBytes,
566
+ ledgerCapacity: defaultLedgerCapacity,
567
+ hydrationLimit: defaultHydrationLimit,
568
+ branchIdleMs: defaultBranchIdleMs
569
+ }
570
+
571
+ /**
572
+ * Constructs the journal-backed branch command ledger under an explicit
573
+ * policy. The caller scope owns cached states and their idle-expiration timers.
574
+ *
575
+ * A submission whose encoded form exceeds the command ceiling is refused with
576
+ * `frame_too_large` before anything is appended, so one oversized `args`
577
+ * cannot enter the branch journal and poison every follower that replays it.
578
+ *
579
+ * First-touch hydration reads at most `hydrationLimit` entries, so the length
580
+ * of a branch's history is not charged to the next writer's latency; see
581
+ * {@link defaultHydrationLimit} for why the fast path may be seeded partially
582
+ * without any effect on what the ledger admits.
583
+ *
584
+ * Every option is validated as a positive safe integer at construction: the
585
+ * TypeScript type says `number`, and `NaN` silently disabled the ceiling it
586
+ * was compared against.
587
+ *
588
+ * @category constructors
589
+ * @since 0.1.0
590
+ */
591
+ export const makeLiveWith = (
592
+ options: Options = {}
593
+ ): Effect.Effect<Service, SyncError, Journal.Journal | BranchShare.BranchShare | Scope.Scope> =>
594
+ Effect.flatMap(
595
+ Effect.all({
596
+ maxFrameBytes: positiveInt(
597
+ "BranchCommands.Options.maxFrameBytes",
598
+ options.maxFrameBytes,
599
+ defaults.maxFrameBytes
600
+ ),
601
+ maxCommandBytes: positiveInt(
602
+ "BranchCommands.Options.maxCommandBytes",
603
+ options.maxCommandBytes,
604
+ defaults.maxCommandBytes
605
+ ),
606
+ ledgerCapacity: positiveInt(
607
+ "BranchCommands.Options.ledgerCapacity",
608
+ options.ledgerCapacity,
609
+ defaults.ledgerCapacity
610
+ ),
611
+ hydrationLimit: positiveInt(
612
+ "BranchCommands.Options.hydrationLimit",
613
+ options.hydrationLimit,
614
+ defaults.hydrationLimit
615
+ ),
616
+ branchIdleMs: positiveInt(
617
+ "BranchCommands.Options.branchIdleMs",
618
+ options.branchIdleMs,
619
+ defaults.branchIdleMs
620
+ )
621
+ }),
622
+ makeWith
623
+ )
624
+
625
+ /**
626
+ * Constructs the journal-backed branch command ledger with default policy.
627
+ * The caller scope owns cached states and their idle-expiration timers.
628
+ *
629
+ * @category constructors
630
+ * @since 0.1.0
631
+ */
632
+ export const makeLive: Effect.Effect<Service, never, Journal.Journal | BranchShare.BranchShare | Scope.Scope> =
633
+ makeWith(defaults)
634
+
635
+ /**
636
+ * Provides the journal-backed branch command ledger.
637
+ *
638
+ * @category layers
639
+ * @since 0.1.0
640
+ */
641
+ export const layer: Layer.Layer<BranchCommands, never, Journal.Journal | BranchShare.BranchShare> = Layer.effect(
642
+ BranchCommands,
643
+ makeLive
644
+ )
645
+
646
+ /**
647
+ * Provides the journal-backed branch command ledger under an explicit policy.
648
+ * Fails with `invalid_request` when an option is not a positive safe integer.
649
+ *
650
+ * @category layers
651
+ * @since 0.1.0
652
+ */
653
+ export const layerWith = (
654
+ options: Options
655
+ ): Layer.Layer<BranchCommands, SyncError, Journal.Journal | BranchShare.BranchShare> =>
656
+ Layer.effect(BranchCommands, makeLiveWith(options))
657
+
658
+ /**
659
+ * Builds a submission, filling in the fields a plain command never sets, and
660
+ * refusing a field the schema forbids with a `SyncError` rather than a defect.
661
+ *
662
+ * @category constructors
663
+ * @since 0.1.0
664
+ */
665
+ export const submission = (fields: {
666
+ readonly branchId: BranchId
667
+ readonly commandId: CommandId
668
+ readonly participantId: CommandSubmission["participantId"]
669
+ readonly name: string
670
+ readonly args?: string
671
+ readonly target?: string
672
+ }): Effect.Effect<CommandSubmission, SyncError> =>
673
+ // Decoded, not constructed: `name` is a `NonEmptyString` the parameter type
674
+ // admits `""` for, and `new CommandSubmission` throws on it. A builder that
675
+ // dies on a caller's argument is the same defect `submit` already refuses
676
+ // typed, one call earlier.
677
+ Admission.decode(CommandSubmission, {
678
+ branchId: fields.branchId,
679
+ commandId: fields.commandId,
680
+ participantId: fields.participantId,
681
+ name: fields.name,
682
+ args: fields.args ?? "",
683
+ target: fields.target ?? ""
684
+ }, "invalid_request")