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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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,902 @@
1
+ /**
2
+ * Browser-safe client for replaying and following journal entries.
3
+ *
4
+ * @since 0.1.0
5
+ */
6
+
7
+ import type * as JournalEvent from "@smthrs/journal/JournalEvent"
8
+ import * as Cause from "effect/Cause"
9
+ import * as Context from "effect/Context"
10
+ import * as Effect from "effect/Effect"
11
+ import * as Layer from "effect/Layer"
12
+ import * as Ref from "effect/Ref"
13
+ import * as Schedule from "effect/Schedule"
14
+ import * as Schema from "effect/Schema"
15
+ import * as Stream from "effect/Stream"
16
+ import * as RpcClient from "effect/unstable/rpc/RpcClient"
17
+ import * as RpcClientError from "effect/unstable/rpc/RpcClientError"
18
+ import type * as RpcGroup from "effect/unstable/rpc/RpcGroup"
19
+ import type { ShareCapability } from "./BranchProtocol.ts"
20
+ import * as Admission from "./internal/Admission.ts"
21
+ import { boundedText, causeCode, causeText } from "./internal/CauseText.ts"
22
+ import { boundedInt, positiveInt } from "./internal/PolicyOptions.ts"
23
+ import * as SnapshotBoundary from "./internal/SnapshotBoundary.ts"
24
+ import { SyncError, SyncGapError } from "./SyncError.ts"
25
+ import {
26
+ covers,
27
+ defaultMaxFrameBytes,
28
+ duplicateCursorRunId,
29
+ encodedByteLength,
30
+ type EntriesFrame,
31
+ Frame,
32
+ maxReadLimit,
33
+ maxSubscribeCredit,
34
+ type Progress,
35
+ protocolVersion,
36
+ ReadResponse,
37
+ RequestEnvelope,
38
+ type Resync,
39
+ RunCursor,
40
+ type Scope,
41
+ type Snapshot,
42
+ type SnapshotRequest,
43
+ type WorkspaceCursor
44
+ } from "./SyncProtocol.ts"
45
+ import { SyncRpcs } from "./SyncRpcs.ts"
46
+
47
+ /**
48
+ * Frames one subscription round may carry before the follow replenishes it.
49
+ *
50
+ * A subscription's `credit` is a hard frame limit the server enforces with
51
+ * `Stream.take`, so the number the client sends is the size of its window, not
52
+ * a hint. At `credit: 1` the window closed after every single entry and the
53
+ * follow resubscribed: following a run that emits a hundred entries cost a
54
+ * hundred subscribe round trips, each of them a fresh cursor snapshot and a
55
+ * fresh server-side fan-out. Replenishing 256 frames at a time makes the
56
+ * round-trip cost proportional to the window rather than to the traffic, and
57
+ * keeps it bounded — an unbounded window would hand the server one
58
+ * never-closing subscription per follower.
59
+ *
60
+ * @category constants
61
+ * @since 0.1.0
62
+ */
63
+ export const defaultCredit = 256
64
+
65
+ /**
66
+ * Entries one catch-up page asks for.
67
+ *
68
+ * Bootstrap is the replay half of replay-then-follow, so the page size is the
69
+ * memory one catch-up costs and the granularity at which its cursor advances.
70
+ * It matches {@link defaultCredit} and stays under
71
+ * `SyncProtocol.maxReadLimit`, which is the ceiling the server enforces.
72
+ *
73
+ * @category constants
74
+ * @since 1.0.0-rc.0
75
+ */
76
+ export const defaultBootstrapLimit = 256
77
+
78
+ /**
79
+ * The policy one sync client runs every operation under.
80
+ *
81
+ * Both fields are validated by {@link makeWith} and {@link layerWith}, so a
82
+ * value that is not a positive safe integer fails the constructor rather than
83
+ * disabling the bound it configures.
84
+ *
85
+ * @category models
86
+ * @since 1.0.0-rc.0
87
+ */
88
+ export interface Options {
89
+ /**
90
+ * Largest summed encoded-entry size one frame or bootstrap page may carry,
91
+ * in bytes. Defaults to {@link defaultMaxFrameBytes}.
92
+ */
93
+ readonly maxFrameBytes?: number | undefined
94
+ /**
95
+ * Entries one catch-up page asks for. Defaults to
96
+ * {@link defaultBootstrapLimit}, and may not exceed
97
+ * `SyncProtocol.maxReadLimit`.
98
+ */
99
+ readonly bootstrapLimit?: number | undefined
100
+ }
101
+
102
+ /** The resolved, already-validated client policy. */
103
+ interface Resolved {
104
+ readonly maxFrameBytes: number
105
+ readonly bootstrapLimit: number
106
+ }
107
+
108
+ const defaults: Resolved = {
109
+ maxFrameBytes: defaultMaxFrameBytes,
110
+ bootstrapLimit: defaultBootstrapLimit
111
+ }
112
+
113
+ /**
114
+ * Input accepted by a sync subscription.
115
+ *
116
+ * `capability` authorizes branch reads: following a shared branch's run
117
+ * requires a capability that verifies for that branch on the server.
118
+ *
119
+ * `EApply` and `EResync` are the consumer's own failure types for `apply` and
120
+ * `onResync`. The subscription fails with them unchanged, so an application
121
+ * failure is never dressed as a transport or protocol {@link SyncError}.
122
+ *
123
+ * @category models
124
+ * @since 0.1.0
125
+ */
126
+ export interface SubscribeOptions<EApply = never, EResync = never> {
127
+ readonly scope: Scope
128
+ /**
129
+ * Where this subscription starts, per run. The effective position is the
130
+ * LATER of this and the client's matching progress map (applied when an
131
+ * apply callback is present, delivered otherwise), so a caller
132
+ * ahead of the shared view keeps its place and a caller behind it is
133
+ * fast-forwarded. Rebuild from an earlier position with a fresh client.
134
+ */
135
+ readonly cursors: WorkspaceCursor
136
+ readonly capability?: ShareCapability
137
+ /**
138
+ * Frames one subscription round may carry before the follow replenishes the
139
+ * window. Defaults to {@link defaultCredit}, and must be a positive integer
140
+ * no greater than `SyncProtocol.maxSubscribeCredit`.
141
+ */
142
+ readonly credit?: number | undefined
143
+ /**
144
+ * Applies one entry, and DEFINES what this subscription's cursor means.
145
+ *
146
+ * Without it the cursor advances as each entry is handed to the consumer,
147
+ * so a consumer whose own application then fails has a cursor naming an
148
+ * entry it never materialized. With it the cursor advances only after this
149
+ * effect succeeds, and records it in `Progress.applied`. A failure here
150
+ * fails the subscription with the entry unacknowledged, so the
151
+ * next applying subscription delivers it again; the effect must
152
+ * therefore be idempotent, because a redelivery is exactly what a retry is.
153
+ */
154
+ readonly apply?: ((entry: JournalEvent.Entry) => Effect.Effect<void, EApply>) | undefined
155
+ /**
156
+ * Restores state when the server refuses a cursor below a compaction floor.
157
+ * Return the exact run and sequence actually restored, after applying it.
158
+ *
159
+ * The entries under the floor are deleted. Fetch an explicitly public
160
+ * projection through `snapshot` or an authorized local source; a
161
+ * projection has a real hole to fill. The returned sequence must cover the
162
+ * requested floor; a newer snapshot resumes at its own sequence, not the
163
+ * older floor. Missing handlers, failures, interruption, and invalid receipts
164
+ * leave the cursor unmoved. There is no implicit skip of deleted history.
165
+ *
166
+ * Durable consumers must commit their restored state and durable cursor in
167
+ * their own transaction and seed a fresh client from that cursor on restart.
168
+ * This client's cursor is in memory, not an atomic cross-store transaction.
169
+ * Recovery must be idempotent, including when a newer compaction requires
170
+ * another restoration before the suffix can be read.
171
+ */
172
+ readonly onResync?: ((resync: Resync) => Effect.Effect<RunCursor, EResync>) | undefined
173
+ }
174
+
175
+ /**
176
+ * Replicated journal subscription operations.
177
+ *
178
+ * @category models
179
+ * @since 0.1.0
180
+ */
181
+ export interface Service {
182
+ /** Fetches a public snapshot without applying state or advancing a cursor. */
183
+ readonly snapshot: (request: SnapshotRequest) => Effect.Effect<Snapshot, SyncError>
184
+ /** Replays then follows `options.scope`, also failing with the callbacks' own errors. */
185
+ readonly subscribe: <EApply = never, EResync = never>(
186
+ options: SubscribeOptions<EApply, EResync>
187
+ ) => Stream.Stream<JournalEvent.Entry, SyncError | SyncGapError | EApply | EResync>
188
+ /**
189
+ * Delivery and application positions. `delivered` is a transport bookmark
190
+ * only; `applied` is what the `apply` and `onResync` callbacks acknowledged.
191
+ */
192
+ readonly progress: Effect.Effect<Progress>
193
+ }
194
+
195
+ /**
196
+ * The browser-safe journal replication client.
197
+ *
198
+ * @category services
199
+ * @since 1.0.0-rc.0
200
+ */
201
+ export class SyncClient extends Context.Service<SyncClient, Service>()("@smthrs/sync/SyncClient") {}
202
+
203
+ /**
204
+ * The client tag's former name, kept for one release.
205
+ *
206
+ * @deprecated Use {@link SyncClient}.
207
+ * @category services
208
+ * @since 0.1.0
209
+ */
210
+ export const Sync = SyncClient
211
+
212
+ /**
213
+ * The client tag's former name, kept for one release.
214
+ *
215
+ * @deprecated Use {@link SyncClient}.
216
+ * @category services
217
+ * @since 0.1.0
218
+ */
219
+ export type Sync = SyncClient
220
+
221
+ /**
222
+ * Constructs a closed sync stub, optionally overriding individual operations.
223
+ *
224
+ * @category constructors
225
+ * @since 0.1.0
226
+ */
227
+ export const makeNoop = (overrides: Partial<Service> = {}): Service =>
228
+ SyncClient.of({
229
+ snapshot: () => Effect.fail(new SyncError({ code: "closed", message: "Sync client is closed" })),
230
+ subscribe: () => Stream.fail(new SyncError({ code: "closed", message: "Sync client is closed" })),
231
+ progress: Effect.succeed({ delivered: [], applied: [] }),
232
+ ...overrides
233
+ })
234
+
235
+ /**
236
+ * Provides a closed sync stub.
237
+ *
238
+ * @category layers
239
+ * @since 0.1.0
240
+ */
241
+ export const layerNoop = (overrides: Partial<Service> = {}): Layer.Layer<SyncClient> =>
242
+ Layer.succeed(SyncClient, makeNoop(overrides))
243
+
244
+ type Client = RpcClient.RpcClient<RpcGroup.Rpcs<typeof SyncRpcs>, RpcClientError.RpcClientError>
245
+
246
+ const transportError = (cause: unknown): SyncError =>
247
+ cause instanceof SyncError
248
+ ? cause
249
+ : cause instanceof RpcClientError.RpcClientError && cause.reason._tag === "RpcClientDefect"
250
+ ? new SyncError({ code: "protocol_violation", message: "Sync RPC protocol failed", cause: causeCode(cause.reason) })
251
+ : new SyncError({
252
+ code: "transport_failed",
253
+ // Bounded like `cause`. `message` is `Schema.String` and it is the
254
+ // declared error schema of every RPC, so a host's own sentence must not
255
+ // be able to be the largest thing this error carries.
256
+ message: cause instanceof Error ? boundedText(cause.message) : "Sync RPC transport failed",
257
+ cause: causeText(cause)
258
+ })
259
+
260
+ /** Effect RPC decodes malformed peer values with orDie. Restore the sync
261
+ * boundary's typed refusal without swallowing unrelated defects or interruption.
262
+ */
263
+ const rpcCause = (cause: Cause.Cause<unknown>): Cause.Cause<SyncError> =>
264
+ Cause.fromReasons(
265
+ Cause.map(cause, transportError).reasons.map((reason) =>
266
+ Cause.isDieReason(reason) && Schema.isSchemaError(reason.defect)
267
+ ? Cause.makeFailReason(
268
+ new SyncError({ code: "decode_failed", message: "Invalid sync RPC response", cause: "SchemaError" })
269
+ )
270
+ .annotate(Context.makeUnsafe(reason.annotations))
271
+ : reason
272
+ )
273
+ )
274
+
275
+ type Cursors = Map<JournalEvent.RunId, RunCursor>
276
+
277
+ const cursorMap = (cursors: WorkspaceCursor): Cursors => new Map(cursors.map((cursor) => [cursor.runId, { ...cursor }]))
278
+
279
+ const canonicalCursors = (cursors: ReadonlyMap<JournalEvent.RunId, RunCursor>): WorkspaceCursor =>
280
+ Array.from(cursors.values(), (cursor) => ({ ...cursor })).sort((left, right) =>
281
+ // A Map has one value per key, so this comparator never receives equal
282
+ // run IDs. `cursorMap` and the acknowledged cursor state both establish
283
+ // that invariant before reaching this boundary.
284
+ left.runId > right.runId ? 1 : -1
285
+ )
286
+
287
+ /**
288
+ * Advances the shared acknowledged view monotonically. A lagging subscription
289
+ * commits what it just consumed, but it must never move the shared cursor
290
+ * backward past another subscription's progress.
291
+ */
292
+ const advance = (
293
+ cursors: ReadonlyMap<JournalEvent.RunId, RunCursor>,
294
+ runId: JournalEvent.RunId,
295
+ throughSeq: JournalEvent.Seq,
296
+ generation: number
297
+ ): ReadonlyMap<JournalEvent.RunId, RunCursor> => {
298
+ const current = cursors.get(runId)
299
+ if (current !== undefined && current.afterSeq >= throughSeq) return cursors
300
+ const next = new Map(cursors)
301
+ next.set(runId, { runId, afterSeq: throughSeq, generation })
302
+ return next
303
+ }
304
+
305
+ /**
306
+ * Live-follow reconnect policy: exponential backoff capped at five seconds,
307
+ * the same shape as `RpcClient`'s own transport retry policy, and only for
308
+ * transport failures — gaps, authorization refusals, server closes, and the
309
+ * consumer's own callback failures propagate to the consumer.
310
+ */
311
+ const reconnectPolicy = Schedule.min([
312
+ Schedule.exponential(500, 1.5),
313
+ Schedule.spaced(5000)
314
+ ]).pipe(
315
+ Schedule.while(({ input }: { readonly input: Cause.Cause<unknown> }) => isTransportCause(input))
316
+ )
317
+
318
+ const isTransportCause = (cause: Cause.Cause<unknown>): boolean => {
319
+ const reason = cause.reasons[0]!
320
+ return cause.reasons.length === 1 && Cause.isFailReason(reason) &&
321
+ SyncError.is(reason.error) && reason.error.code === "transport_failed"
322
+ }
323
+
324
+ const lineageChanged = (runId: JournalEvent.RunId): SyncError =>
325
+ new SyncError({
326
+ code: "lineage_changed",
327
+ message: `Run ${runId} was rewound; rebuild from its archive boundary with a fresh sync client`
328
+ })
329
+
330
+ const entriesByteLength = (entries: ReadonlyArray<JournalEvent.Entry>): number =>
331
+ entries.reduce((bytes, entry) => bytes + encodedByteLength(entry), 0)
332
+
333
+ const oversized = (bytes: number, maxFrameBytes: number): SyncError =>
334
+ new SyncError({
335
+ code: "frame_too_large",
336
+ message: `Encoded entries of ${bytes} bytes exceed the ${maxFrameBytes}-byte frame ceiling`
337
+ })
338
+
339
+ /**
340
+ * The first internal inconsistency of a server frame, or `undefined` for a
341
+ * consistent one.
342
+ *
343
+ * A schema-valid frame can still contradict itself: carry another run's
344
+ * entry, repeat or reorder sequences, or carry an entry outside its own
345
+ * declared covered interval. Applying one would corrupt the client's cursor
346
+ * bookkeeping, so an inconsistent frame is refused as a protocol violation
347
+ * before any entry is admitted or any cursor moves.
348
+ */
349
+ const frameViolation = (frame: EntriesFrame): SyncError | undefined => {
350
+ let previous = -1
351
+ for (const entry of frame.entries) {
352
+ if (entry.runId !== frame.runId) {
353
+ return new SyncError({
354
+ code: "protocol_violation",
355
+ cause: "foreign_run",
356
+ message: `Frame for run ${frame.runId} carried an entry for run ${entry.runId}`
357
+ })
358
+ }
359
+ if (entry.seq <= previous) {
360
+ return new SyncError({
361
+ code: "protocol_violation",
362
+ cause: "non_monotonic_sequence",
363
+ message: `Frame entries must ascend strictly: sequence ${entry.seq} arrived after ${previous}`
364
+ })
365
+ }
366
+ if (entry.seq < frame.fromSeq || frame.toSeq < entry.seq) {
367
+ return new SyncError({
368
+ code: "protocol_violation",
369
+ cause: "invalid_frame_interval",
370
+ message: `Entry ${entry.seq} lies outside the frame's covered interval ${frame.fromSeq}..${frame.toSeq}`
371
+ })
372
+ }
373
+ previous = entry.seq
374
+ }
375
+ return undefined
376
+ }
377
+
378
+ /**
379
+ * The first semantic inconsistency of a catch-up page, or `undefined` for a
380
+ * consistent one.
381
+ *
382
+ * Every entry must belong to the requested scope, ascend strictly within its
383
+ * run, and lie above the requested cursor. Every entry-bearing run must have
384
+ * exactly one response cursor with an explicit generation matching the request.
385
+ *
386
+ * Strictly-above-the-cursor is also what makes an incomplete page CONVERGE: a
387
+ * page that carries entries but moves no cursor would be re-read forever.
388
+ */
389
+ const pageViolation = (
390
+ scope: Scope,
391
+ requested: ReadonlyMap<JournalEvent.RunId, RunCursor>,
392
+ response: ReadResponse
393
+ ): SyncError | undefined => {
394
+ const violation = (message: string): SyncError =>
395
+ new SyncError({ code: "protocol_violation", message, cause: "invalid_read_page" })
396
+ const generations = new Set<JournalEvent.RunId>()
397
+ for (const cursor of response.cursors) {
398
+ generations.add(cursor.runId)
399
+ if (cursor.generation === undefined) return violation("Read response lacks a generation")
400
+ const previous = requested.get(cursor.runId)
401
+ if (previous !== undefined && (previous.generation ?? 0) !== cursor.generation) {
402
+ return lineageChanged(cursor.runId)
403
+ }
404
+ }
405
+ const highest = new Map<JournalEvent.RunId, JournalEvent.Seq>()
406
+ for (const entry of response.entries) {
407
+ if (!covers(scope, entry.runId)) {
408
+ return violation(`Read page carried an entry for run ${entry.runId}, which the request's scope excludes`)
409
+ }
410
+ const floor = requested.get(entry.runId)?.afterSeq
411
+ if (floor !== undefined && entry.seq <= floor) {
412
+ return violation(
413
+ `Read page carried sequence ${entry.seq} for run ${entry.runId}, at or below the requested cursor ${floor}`
414
+ )
415
+ }
416
+ const previous = highest.get(entry.runId)
417
+ if (previous !== undefined && entry.seq <= previous) {
418
+ return violation(
419
+ `Read page entries for run ${entry.runId} must ascend strictly: sequence ${entry.seq} arrived after ${previous}`
420
+ )
421
+ }
422
+ highest.set(entry.runId, entry.seq)
423
+ }
424
+ for (const runId of highest.keys()) {
425
+ if (!generations.has(runId)) return violation(`Read page lacks a generation cursor for run ${runId}`)
426
+ }
427
+ const duplicate = duplicateCursorRunId(response.cursors)
428
+ return duplicate === undefined ? undefined : violation(`Read page echoed run ${duplicate} more than once`)
429
+ }
430
+
431
+ /**
432
+ * Projects a Sync RPC client into the local replication service.
433
+ *
434
+ * Delivered and applied cursors are separate per-service `Ref` maps: concurrent
435
+ * subscriptions for one materialization share them, and each commit advances
436
+ * its matching map monotonically. A
437
+ * cursor advances in the same stream pull that admits each entry to the
438
+ * consumer, or — when the caller supplies `SubscribeOptions.apply` — only
439
+ * after that callback has applied the entry. Interrupted partial pages
440
+ * therefore retain the last admitted entry rather than incorrectly
441
+ * acknowledging the server's whole page.
442
+ *
443
+ * A frame or bootstrap page whose encoded entries exceed `maxFrameBytes` is
444
+ * refused with `frame_too_large`. Out-of-scope entries, repeated or reordered
445
+ * sequences, and missing response generations fail with `protocol_violation`
446
+ * before delivery. Each entry-bearing run in a Read page must have exactly one
447
+ * explicit generation cursor, and every entry must be above the requested
448
+ * cursor. Live frames instead drop already-covered entries and deliver only
449
+ * the suffix above the cursor. An incomplete bootstrap page with no entries
450
+ * or a completed subscription window with no frames fails with
451
+ * `protocol_violation` instead of reopening immediately.
452
+ *
453
+ * A live follow that loses its transport reconnects under
454
+ * {@link reconnectPolicy}, resuming from the acknowledged cursors; the failure
455
+ * cause is logged before the retry folds it. The follow spends a credit window
456
+ * of {@link defaultCredit} frames per subscription round and replenishes it by
457
+ * resubscribing from the cursors it has acknowledged. Already-covered entries
458
+ * are not delivered again; the round-trip cost scales with credit windows.
459
+ *
460
+ * A `compacted` refusal fails closed unless `SubscribeOptions.onResync`
461
+ * restores the missing prefix and returns its exact cursor. Snapshot fetching
462
+ * never applies state or advances cursors. Only a validated receipt for the requested run at or
463
+ * above its compaction floor advances the cursor before reading the suffix.
464
+ *
465
+ * A subscription's effective cursor per run is the LATER of the caller's and
466
+ * the matching progress map: a caller that restored its own progress is never
467
+ * regressed, and a caller asking for history this client already acknowledged
468
+ * is fast-forwarded. Rebuild a projection from an earlier position with a
469
+ * fresh client, whose acknowledged map is empty.
470
+ *
471
+ * This constructor carries the default policy, which is valid by
472
+ * construction and so cannot fail. {@link makeWith} takes an explicit
473
+ * {@link Options} and validates it.
474
+ *
475
+ * @category constructors
476
+ * @since 0.1.0
477
+ */
478
+ export const make = ({ client }: { readonly client: Client }): Effect.Effect<Service> => service(client, defaults)
479
+
480
+ /**
481
+ * Constructs the sync client under an explicit policy.
482
+ *
483
+ * Every option is validated here, once, so a client either exists under a
484
+ * policy it can serve or never exists at all. Validating on each `subscribe`
485
+ * and each `snapshot` instead accepted a `maxFrameBytes` of `NaN` at
486
+ * construction and then refused every operation under it, which reported a
487
+ * composition's mistake as a run-time failure of the follow. This mirrors
488
+ * `SyncServer.makeLiveWith` and `BranchCommands.makeLiveWith`.
489
+ *
490
+ * `SubscribeOptions.credit` is a REQUEST count rather than a client policy, so
491
+ * it is still checked where the request is built.
492
+ *
493
+ * @category constructors
494
+ * @since 1.0.0-rc.0
495
+ */
496
+ export const makeWith = ({
497
+ bootstrapLimit,
498
+ client,
499
+ maxFrameBytes
500
+ }: Options & { readonly client: Client }): Effect.Effect<Service, SyncError> =>
501
+ Effect.flatMap(
502
+ Effect.all({
503
+ maxFrameBytes: positiveInt("SyncClient.Options.maxFrameBytes", maxFrameBytes, defaults.maxFrameBytes),
504
+ bootstrapLimit: boundedInt(
505
+ "SyncClient.Options.bootstrapLimit",
506
+ bootstrapLimit,
507
+ defaults.bootstrapLimit,
508
+ maxReadLimit
509
+ )
510
+ }),
511
+ (policy) => service(client, policy)
512
+ )
513
+
514
+ const service = (client: Client, { bootstrapLimit, maxFrameBytes }: Resolved): Effect.Effect<Service> =>
515
+ Effect.sync(() => {
516
+ const delivered = Ref.makeUnsafe<ReadonlyMap<JournalEvent.RunId, RunCursor>>(new Map())
517
+ const applied = Ref.makeUnsafe<ReadonlyMap<JournalEvent.RunId, RunCursor>>(new Map())
518
+
519
+ const progress: Effect.Effect<Progress> = Effect.gen(function*() {
520
+ return {
521
+ delivered: canonicalCursors(yield* Ref.get(delivered)),
522
+ applied: canonicalCursors(yield* Ref.get(applied))
523
+ }
524
+ })
525
+
526
+ const snapshot = (input: SnapshotRequest): Effect.Effect<Snapshot, SyncError> =>
527
+ Effect.gen(function*() {
528
+ const request = yield* SnapshotBoundary.request(input)
529
+ const supplied = yield* client["Sync.Snapshot"]({ ...request }).pipe(
530
+ Effect.catchCause((cause) => Effect.failCause(rpcCause(cause)))
531
+ )
532
+ return yield* SnapshotBoundary.response(request, supplied, maxFrameBytes)
533
+ })
534
+
535
+ const subscribe = <EApply = never, EResync = never>(
536
+ input: SubscribeOptions<EApply, EResync>
537
+ ): Stream.Stream<JournalEvent.Entry, SyncError | SyncGapError | EApply | EResync> =>
538
+ Stream.unwrap(Effect.gen(function*() {
539
+ /** Every way this subscription fails: the wire's refusals and the consumer's own callback errors. */
540
+ type Failure = SyncError | SyncGapError | EApply | EResync
541
+
542
+ const options = {
543
+ ...input,
544
+ ...yield* Admission.decode(RequestEnvelope, input, "invalid_request")
545
+ }
546
+ // The client's own policy was checked once, by the constructor. What
547
+ // is left is the count this REQUEST carries: the declared type says
548
+ // `number`, and `NaN` disabled each comparison it appears in rather
549
+ // than tightening it.
550
+ const credit = yield* boundedInt(
551
+ "SyncClient.SubscribeOptions.credit",
552
+ options.credit,
553
+ defaultCredit,
554
+ maxSubscribeCredit
555
+ )
556
+ const cursor = cursorMap(options.cursors)
557
+ // A previous delivery-only subscriber cannot skip work for an applying subscriber.
558
+ // Rebuilding an independent projection requires a fresh client.
559
+ for (const [runId, position] of yield* Ref.get(options.apply === undefined ? delivered : applied)) {
560
+ const supplied = cursor.get(runId)
561
+ if (supplied !== undefined && (supplied.generation ?? 0) !== position.generation) {
562
+ return yield* Effect.fail(lineageChanged(runId))
563
+ }
564
+ if (supplied === undefined || supplied.afterSeq < position.afterSeq) cursor.set(runId, position)
565
+ }
566
+
567
+ const snapshot = (): WorkspaceCursor => canonicalCursors(cursor)
568
+
569
+ const commit = (
570
+ runId: JournalEvent.RunId,
571
+ throughSeq: JournalEvent.Seq,
572
+ generation: number,
573
+ wasApplied: boolean
574
+ ) =>
575
+ Effect.gen(function*() {
576
+ // Delivery and application have distinct cursors, but both are bound
577
+ // to the same journal generation and advance without an interrupt gap.
578
+ for (const view of wasApplied ? [delivered, applied] : [delivered]) {
579
+ const previous = (yield* Ref.get(view)).get(runId)
580
+ if (previous !== undefined && previous.generation !== generation) {
581
+ return yield* Effect.fail(lineageChanged(runId))
582
+ }
583
+ }
584
+ yield* Ref.update(delivered, (shared) => advance(shared, runId, throughSeq, generation))
585
+ if (wasApplied) yield* Ref.update(applied, (shared) => advance(shared, runId, throughSeq, generation))
586
+ cursor.set(runId, { runId, afterSeq: throughSeq, generation })
587
+ }).pipe(Effect.uninterruptible)
588
+
589
+ const applyEntry = options.apply
590
+
591
+ /**
592
+ * Hands one entry to the consumer and moves its cursor.
593
+ *
594
+ * With an `apply` callback the cursor moves only after the callback
595
+ * succeeds, so a cursor names what the consumer materialized. Without
596
+ * one it moves as the entry is admitted, which is the weaker promise
597
+ * this client has always made and the one `RunCursor`'s JSDoc now
598
+ * states.
599
+ *
600
+ * The position committed is SNAPSHOTTED before the callback runs.
601
+ * `Entry` is a `Schema.Class`, and instances are not frozen, so
602
+ * reading `entry.seq` again afterwards let the consumer's own callback
603
+ * choose the cursor it was being handed an entry for.
604
+ */
605
+ const deliver = (
606
+ runId: JournalEvent.RunId,
607
+ entry: JournalEvent.Entry,
608
+ generation: number
609
+ ): Stream.Stream<JournalEvent.Entry, Failure> => {
610
+ const through = entry.seq
611
+ return Stream.concat(
612
+ Stream.drain(Stream.fromEffect(
613
+ applyEntry === undefined
614
+ ? commit(runId, through, generation, false)
615
+ : Effect.uninterruptibleMask((restore) =>
616
+ Effect.andThen(restore(applyEntry(entry)), commit(runId, through, generation, true))
617
+ )
618
+ )),
619
+ Stream.succeed(entry)
620
+ )
621
+ }
622
+
623
+ const batch = (frame: EntriesFrame): Stream.Stream<JournalEvent.Entry, Failure> => {
624
+ if (frame.generation === undefined) {
625
+ return Stream.fail(
626
+ new SyncError({ code: "protocol_violation", message: "Entries frame lacks a generation" })
627
+ )
628
+ }
629
+ if (!covers(options.scope, frame.runId)) {
630
+ return Stream.fail(
631
+ new SyncError({
632
+ code: "protocol_violation",
633
+ message: "Live frame is outside the subscription scope",
634
+ cause: "foreign_run"
635
+ })
636
+ )
637
+ }
638
+ const frameBytes = entriesByteLength(frame.entries)
639
+ if (frameBytes > maxFrameBytes) return Stream.fail(oversized(frameBytes, maxFrameBytes))
640
+ const violation = frameViolation(frame)
641
+ if (violation !== undefined) return Stream.fail(violation)
642
+ const position = cursor.get(frame.runId)
643
+ const generation = frame.generation
644
+ if (position !== undefined && (position.generation ?? 0) !== generation) {
645
+ return Stream.fail(lineageChanged(frame.runId))
646
+ }
647
+ const afterSeq: number = position?.afterSeq ?? -1
648
+ // `fromSeq` is the start of the server-declared covered interval. It is
649
+ // intentionally compared to the cursor, not to the first entry's seq:
650
+ // dropped journal admissions legitimately leave holes inside that range.
651
+ if (frame.fromSeq > afterSeq + 1) {
652
+ return Stream.fail(
653
+ new SyncGapError({
654
+ runId: frame.runId,
655
+ expectedFrom: (afterSeq + 1) as JournalEvent.Seq,
656
+ receivedFrom: frame.fromSeq
657
+ })
658
+ )
659
+ }
660
+ if (frame.toSeq <= afterSeq) return Stream.empty
661
+
662
+ const entries = frame.entries.filter((entry) => entry.seq > afterSeq)
663
+ return Stream.flatMap(Stream.fromIterable(entries), (entry) => deliver(frame.runId, entry, generation))
664
+ }
665
+
666
+ const livePage = (onFrame: () => void): Stream.Stream<JournalEvent.Entry, Failure> =>
667
+ Stream.unwrap(
668
+ Effect.sync(() =>
669
+ client["Sync.Subscribe"]({
670
+ protocolVersion,
671
+ scope: options.scope,
672
+ cursors: snapshot(),
673
+ credit,
674
+ ...(options.capability === undefined ? {} : { capability: options.capability })
675
+ })
676
+ )
677
+ ).pipe(
678
+ Stream.catchCause((cause) => Stream.failCause(rpcCause(cause))),
679
+ Stream.mapEffect((frame) => Admission.decode(Frame, frame)),
680
+ Stream.mapEffect((frame): Effect.Effect<Frame, SyncError> =>
681
+ frame._tag === "Entries"
682
+ ? Effect.map(Admission.records(frame.entries), (entries) => ({ ...frame, entries }))
683
+ : Effect.succeed(frame)
684
+ ),
685
+ Stream.flatMap((frame) => {
686
+ onFrame()
687
+ switch (frame._tag) {
688
+ case "Entries":
689
+ return batch(frame)
690
+ case "Closed":
691
+ return Stream.fail(new SyncError({ code: "closed", message: "Sync subscription closed" }))
692
+ default:
693
+ return Stream.empty
694
+ }
695
+ })
696
+ )
697
+
698
+ const live = (): Stream.Stream<JournalEvent.Entry, Failure> =>
699
+ Stream.unwrap(Effect.sync(() => {
700
+ let receivedFrame = false
701
+ return Stream.concat(
702
+ livePage(() => {
703
+ receivedFrame = true
704
+ }),
705
+ Stream.unwrap(Effect.sync(() =>
706
+ receivedFrame ? live() : Stream.fail(
707
+ new SyncError({
708
+ code: "protocol_violation",
709
+ message: "Subscription window closed without frames"
710
+ })
711
+ )
712
+ ))
713
+ )
714
+ }))
715
+
716
+ // The retry re-runs the whole follow from the acknowledged cursors,
717
+ // and its schedule resets once entries flow again, so a long-lived
718
+ // subscription pays the backoff only across consecutive failures.
719
+ const follow = (): Stream.Stream<JournalEvent.Entry, Failure> =>
720
+ live().pipe(
721
+ Stream.tapCause((cause) =>
722
+ isTransportCause(cause)
723
+ ? Effect.logWarning("sync live follow transport failed; reconnecting with backoff", cause)
724
+ : Effect.void
725
+ ),
726
+ // Schedule the complete cause so retry refusal cannot discard an
727
+ // accompanying defect, interruption, or tracing annotation.
728
+ Stream.catchCause((cause) => Stream.fail(cause)),
729
+ Stream.retry(reconnectPolicy),
730
+ Stream.catch((cause) => Stream.failCause(cause))
731
+ )
732
+
733
+ const bootstrap = (): Stream.Stream<JournalEvent.Entry, Failure> =>
734
+ Stream.unwrap(
735
+ // SUSPENDED, exactly as `livePage` is. Building the payload where
736
+ // the recursive call is written snapshotted the cursors as page N
737
+ // was mapped, before a single one of its entries had reached
738
+ // `commit` — so read N+1 carried read N's request cursors and the
739
+ // server correctly re-served the page it had just served. Every
740
+ // catch-up page past the first arrived twice.
741
+ Effect.suspend(() => {
742
+ const requested = snapshot()
743
+ return client["Sync.Read"]({
744
+ protocolVersion,
745
+ scope: options.scope,
746
+ cursors: requested,
747
+ limit: bootstrapLimit,
748
+ ...(options.capability === undefined ? {} : { capability: options.capability })
749
+ }).pipe(
750
+ Effect.catchCause((cause) => Effect.failCause(rpcCause(cause))),
751
+ Effect.flatMap((response) => Admission.decode(ReadResponse, response)),
752
+ Effect.flatMap((response) =>
753
+ Effect.map(Admission.records(response.entries), (entries) => ({ ...response, entries }))
754
+ ),
755
+ Effect.map((response) => {
756
+ const pageBytes = entriesByteLength(response.entries)
757
+ if (pageBytes > maxFrameBytes) return Stream.fail(oversized(pageBytes, maxFrameBytes))
758
+ const violation = pageViolation(options.scope, cursorMap(requested), response)
759
+ if (violation !== undefined) return Stream.fail(violation)
760
+ const generations = new Map(response.cursors.map((cursor) => [cursor.runId, cursor.generation!]))
761
+ const page = Stream.flatMap(
762
+ Stream.fromIterable(response.entries),
763
+ (entry) =>
764
+ deliver(
765
+ entry.runId,
766
+ entry,
767
+ generations.get(entry.runId)!
768
+ )
769
+ )
770
+ if (response.done) return Stream.concat(page, follow())
771
+ if (response.entries.length === 0) {
772
+ // An incomplete page with no entries can never converge:
773
+ // the next read would carry the same cursors and receive
774
+ // the same page. Fail typed instead of spinning.
775
+ return Stream.fail(
776
+ new SyncError({
777
+ code: "protocol_violation",
778
+ message: "Bootstrap read reported more durable entries but returned an empty page"
779
+ })
780
+ )
781
+ }
782
+ return Stream.concat(page, bootstrap())
783
+ })
784
+ )
785
+ })
786
+ )
787
+
788
+ /**
789
+ * The checkpoint one failure says to resume from, or `undefined` when
790
+ * the failure is not a recoverable compaction.
791
+ *
792
+ * A compaction whose checkpoint is at or below what this subscription
793
+ * already covers cannot move it forward, so retrying would re-read the
794
+ * same refusal forever. That is the same non-convergence rule the
795
+ * empty incomplete bootstrap page is refused under, and it is why a
796
+ * `compacted` failure carrying no {@link Resync} stays a failure
797
+ * rather than becoming a silent stall.
798
+ */
799
+ const resyncTarget = (failure: Failure): Resync | undefined => {
800
+ if (!SyncError.is(failure) || failure.code !== "compacted" || failure.resync === undefined) {
801
+ return undefined
802
+ }
803
+ const covered: number = cursor.get(failure.resync.runId)?.afterSeq ?? -1
804
+ return covered >= failure.resync.checkpointSeq ? undefined : failure.resync
805
+ }
806
+
807
+ const onResync = options.onResync
808
+
809
+ const resynced = (): Stream.Stream<JournalEvent.Entry, Failure> =>
810
+ Stream.catchCause(bootstrap(), (cause) => {
811
+ const reason = cause.reasons[0]!
812
+ if (cause.reasons.length !== 1 || !Cause.isFailReason(reason)) return Stream.failCause(cause)
813
+ const failure = reason.error
814
+ const resync = resyncTarget(failure)
815
+ if (resync === undefined) return Stream.failCause(cause)
816
+ if (!covers(options.scope, resync.runId)) {
817
+ return Stream.fail(
818
+ new SyncError({
819
+ code: "protocol_violation",
820
+ message: "Compaction target is outside the subscription scope"
821
+ })
822
+ )
823
+ }
824
+ if (onResync === undefined) return Stream.failCause(cause)
825
+ // Snapshotted before the hook runs, for the reason `deliver`
826
+ // snapshots: the hook is handed the value the commit is read from,
827
+ // and re-reading it afterwards let a consumer move its own cursor
828
+ // arbitrarily far forward from inside the callback.
829
+ const runId = resync.runId
830
+ const checkpointSeq = resync.checkpointSeq
831
+ const refusedGeneration = resync.generation
832
+ return Stream.unwrap(Effect.uninterruptibleMask((restore) =>
833
+ Effect.gen(function*() {
834
+ // Application remains interruptible. Once it returns success,
835
+ // validation and the in-memory commit finish without an async
836
+ // cancellation gap. Durable state/cursor atomicity belongs to
837
+ // the consumer's own transaction, not to this mask.
838
+ const receipt = yield* restore(onResync(resync))
839
+ const restoredCursor = () =>
840
+ new SyncError({
841
+ code: "invalid_request",
842
+ message: "Recovery must return the restored run cursor at or above its compaction floor"
843
+ })
844
+ // A receipt that omits its generation restored the floor's
845
+ // history, which the refusal names. The previous cursor's
846
+ // generation is no substitute: a fresh client has none,
847
+ // and a rewind since then has replaced it.
848
+ const captured = yield* Effect.try({
849
+ try: () => ({
850
+ runId: receipt.runId,
851
+ afterSeq: receipt.afterSeq,
852
+ generation: receipt.generation ?? refusedGeneration
853
+ }),
854
+ catch: restoredCursor
855
+ })
856
+ if (
857
+ !Schema.is(RunCursor)(captured) || captured.runId !== runId || captured.afterSeq < checkpointSeq
858
+ ) {
859
+ return yield* Effect.fail(restoredCursor())
860
+ }
861
+ const applied = captured
862
+ if (applied.generation === undefined) {
863
+ return yield* Effect.fail(
864
+ new SyncError({
865
+ code: "protocol_violation",
866
+ message: "Compaction refusal names no generation for a receipt that omits one"
867
+ })
868
+ )
869
+ }
870
+ yield* commit(applied.runId, applied.afterSeq, applied.generation, true)
871
+ return resynced()
872
+ })
873
+ ))
874
+ })
875
+
876
+ return resynced()
877
+ }))
878
+
879
+ return SyncClient.of({ snapshot, subscribe, progress })
880
+ })
881
+
882
+ /**
883
+ * Provides the sync client from the active RPC protocol.
884
+ *
885
+ * @category layers
886
+ * @since 0.1.0
887
+ */
888
+ export const layer: Layer.Layer<SyncClient, never, RpcClient.Protocol> = Layer.effect(
889
+ SyncClient,
890
+ Effect.flatMap(RpcClient.make(SyncRpcs), (client) => make({ client }))
891
+ )
892
+
893
+ /**
894
+ * Provides the sync client under an explicit policy. Fails with
895
+ * `invalid_request` when an option is not a positive safe integer, so a bad
896
+ * policy fails the composition rather than every operation under it.
897
+ *
898
+ * @category layers
899
+ * @since 1.0.0-rc.0
900
+ */
901
+ export const layerWith = (options: Options): Layer.Layer<SyncClient, SyncError, RpcClient.Protocol> =>
902
+ Layer.effect(SyncClient, Effect.flatMap(RpcClient.make(SyncRpcs), (client) => makeWith({ ...options, client })))