@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,273 @@
1
+ ---
2
+ title: "Test a follower"
3
+ description: "Bind a real sync server to a real sync client over an in-memory socket, then drop, stall, and disconnect frames to prove your follower survives a faulty transport."
4
+ sidebar:
5
+ order: 6
6
+ ---
7
+
8
+ This guide tests the code you wrote against
9
+ [Follow a run](./follow-a-run.md), using the production server and the
10
+ production client. Only the transport is a double: an in-memory socket pair
11
+ stands in for a WebSocket, and it can drop, stall, and break frames on demand.
12
+
13
+ Two subpaths ship for this:
14
+
15
+ | Import | What it gives you |
16
+ | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------ |
17
+ | `@smthrs/sync/test/TestSocket` | `makePair`, the `Pair` it returns, its `TestFaults` controls, and the `FrameFilter` type. Runs anywhere. |
18
+ | `@smthrs/sync/test/TestSync` | `layerTest`, `layerTrustAllAsOwner`, `layerNoop`, and `connect`. Binds the Node SQLite test journal, so it is Node only. |
19
+
20
+ ## Bind a server to a client
21
+
22
+ `TestSync.layerTest` provides the in-memory SQLite journal, an empty mutable
23
+ run catalog, a middleware that trusts the connection as the workspace owner,
24
+ and the production `SyncServer`. `TestSocket.makePair` allocates the socket,
25
+ and `TestSync.connect` starts an RPC server over its server end and returns a
26
+ `SyncClient.Service` over its client end:
27
+
28
+ ```ts
29
+ import { Journal, JournalEvent } from "@smthrs/journal"
30
+ import * as TestSocket from "@smthrs/sync/test/TestSocket"
31
+ import * as TestSync from "@smthrs/sync/test/TestSync"
32
+ import { Effect, Fiber, Stream } from "effect"
33
+
34
+ const runId = "build-42" as JournalEvent.RunId
35
+ const sourceId = "test" as JournalEvent.SourceId
36
+
37
+ const collectTwo = Effect.gen(function*() {
38
+ const journal = yield* Journal.Journal
39
+ const pair = yield* TestSocket.makePair()
40
+ const client = yield* TestSync.connect(pair)
41
+
42
+ yield* journal.emitDurableUnfenced({ runId, sourceId, eventType: "first", payload: { value: 0 } })
43
+ yield* journal.flush
44
+
45
+ return yield* Stream.runCollect(
46
+ client.subscribe({ scope: { _tag: "Run", runId }, cursors: [] }).pipe(Stream.take(1))
47
+ )
48
+ }).pipe(Effect.provide(TestSync.layerTest), Effect.scoped)
49
+ ```
50
+
51
+ Both `makePair` and `connect` are scoped, so `Effect.scoped` closes the socket
52
+ and the RPC server together. Everything below builds on this shape.
53
+
54
+ ## Use real elapsed time
55
+
56
+ Run these cases with `it.live`, not `it.effect`. A follower's reconnect
57
+ backoff, the server's tail interval, and the socket's own delivery loop all
58
+ advance on the wall clock, and `it.effect` freezes the `TestClock` so a live
59
+ follow never wakes:
60
+
61
+ ```ts
62
+ import { describe, expect, it } from "@effect/vitest"
63
+
64
+ describe("my follower", () => {
65
+ it.live("receives the durable history", () =>
66
+ Effect.gen(function*() {
67
+ const entries = yield* collectTwo
68
+ expect(Array.from(entries).map((entry) => entry.eventType)).toEqual(["first"])
69
+ }))
70
+ })
71
+ ```
72
+
73
+ Fork a subscription with `Effect.forkChild({ startImmediately: true })` when
74
+ the test commits an entry after subscribing. Without it the entry can land
75
+ before the stream attaches, and the follower reads it out of the durable
76
+ history instead of following it, which proves less than the test intends.
77
+
78
+ ## Drop the frames that carry one range
79
+
80
+ `faults.dropRange(runId, from, to)` drops every `Entries` frame whose covered
81
+ interval intersects `from..to`. Install it before you connect, then assert
82
+ that the follower never delivers entries past the hole:
83
+
84
+ ```ts
85
+ const withHole = Effect.gen(function*() {
86
+ const pair = yield* TestSocket.makePair()
87
+ pair.faults.dropRange(runId, 1, 1)
88
+ return yield* TestSync.connect(pair)
89
+ })
90
+ ```
91
+
92
+ The follower stalls at sequence 0 forever. That is the correct outcome:
93
+ sequence 1 is not a legitimate journal hole, it is a frame the transport ate,
94
+ and skipping it would lose an entry. The package's own transport-fault suite
95
+ asserts exactly this, by requiring the subscription to time out rather than
96
+ complete.
97
+
98
+ `dropRange` recognizes the JSON form of an `Entries` frame without importing
99
+ the protocol, so it works only when the connection uses JSON serialization,
100
+ which `TestSync.connect` does.
101
+
102
+ ## Stall and resume delivery
103
+
104
+ `faults.stall()` holds every frame in the socket, and `faults.resume()`
105
+ releases them. Nothing is lost, so this reproduces a slow link rather than a
106
+ broken one:
107
+
108
+ ```ts
109
+ const stallThenResume = Effect.gen(function*() {
110
+ const journal = yield* Journal.Journal
111
+ const pair = yield* TestSocket.makePair()
112
+ const client = yield* TestSync.connect(pair)
113
+
114
+ yield* journal.emitDurableUnfenced({ runId, sourceId, eventType: "before-stall", payload: { value: 0 } })
115
+ yield* journal.flush
116
+ pair.faults.stall()
117
+
118
+ const fiber = yield* Stream.runCollect(
119
+ client.subscribe({ scope: { _tag: "Run", runId }, cursors: [] }).pipe(Stream.take(1))
120
+ ).pipe(Effect.forkChild({ startImmediately: true }))
121
+
122
+ const stalled = yield* Fiber.join(fiber).pipe(Effect.timeoutOption("200 millis"))
123
+ pair.faults.resume()
124
+ return { stalled, entries: yield* Fiber.join(fiber) }
125
+ })
126
+ ```
127
+
128
+ `stalled` is `None`, and `entries` carries the frame the stall held. Assert
129
+ both: the first proves the stall took effect, and the second proves the resume
130
+ delivered rather than dropped.
131
+
132
+ ## Break the connection
133
+
134
+ `faults.disconnect()` fails both queues with a socket error, which is what a
135
+ dropped WebSocket looks like to the client. The subscription does not fail:
136
+ `SyncClient` classifies the failure as `transport_failed` and retries under
137
+ exponential backoff capped at five seconds, resuming from the subscription's
138
+ progress. Assert that the stream stays open, not that it errors:
139
+
140
+ ```ts
141
+ const survivesDisconnect = Effect.gen(function*() {
142
+ const journal = yield* Journal.Journal
143
+ const pair = yield* TestSocket.makePair()
144
+ const client = yield* TestSync.connect(pair)
145
+
146
+ yield* journal.emitDurableUnfenced({ runId, sourceId, eventType: "first", payload: { value: 0 } })
147
+ yield* journal.flush
148
+
149
+ const fiber = yield* Stream.runCollect(
150
+ client.subscribe({ scope: { _tag: "Run", runId }, cursors: [] }).pipe(Stream.take(2))
151
+ ).pipe(Effect.forkChild({ startImmediately: true }))
152
+
153
+ yield* pair.faults.disconnect()
154
+ const settled = yield* Fiber.await(fiber).pipe(Effect.timeoutOption("500 millis"))
155
+ yield* Fiber.interrupt(fiber)
156
+ return settled._tag
157
+ })
158
+ ```
159
+
160
+ `survivesDisconnect` returns `"None"`: the fiber was still running when the
161
+ timeout elapsed. A test that expects a failure here is asserting the opposite
162
+ of the contract.
163
+
164
+ ## Corrupt a frame
165
+
166
+ `faults.installFilter` takes a `FrameFilter`, which is called with each frame's
167
+ bytes. Return `false` to drop the frame, or return replacement bytes to rewrite
168
+ it. Use it to feed your follower a frame the server would never send, and prove
169
+ the client refuses it:
170
+
171
+ ```ts
172
+ import type * as TestSocket from "@smthrs/sync/test/TestSocket"
173
+
174
+ const misattribute: TestSocket.FrameFilter = (bytes) => {
175
+ const text = new TextDecoder().decode(bytes)
176
+ if (!text.includes("\"Entries\"")) return true
177
+ return new TextEncoder().encode(text.replace("\"runId\":\"build-42\"", "\"runId\":\"other-run\""))
178
+ }
179
+
180
+ pair.faults.installFilter(misattribute)
181
+ ```
182
+
183
+ The rewrite changes exactly one of the two run ids an `Entries` frame carries,
184
+ its own or its first entry's, so the frame ends up claiming one run and
185
+ carrying another. The subscription fails with `protocol_violation` before any
186
+ cursor moves. The client admits server frames rather than trusting them, and
187
+ this is how you prove your own error handling sees that.
188
+
189
+ Filters run in installation order on every frame in both directions. A filter
190
+ that returns bytes hands them to the next filter.
191
+
192
+ ## Choose the authorization the test needs
193
+
194
+ `layerTest` uses `TestSync.layerTrustAllAsOwner`, which trusts every connection
195
+ as the workspace owner. That is what a replication-mechanics test wants: it
196
+ exercises paging and streaming without provisioning capabilities.
197
+
198
+ To test authorization itself, compose the production middleware instead.
199
+ `SyncAuth.layer` verifies a `WorkspaceShare` capability from the
200
+ `flows-sync-workspace` header, so the test provides a real keyring and mints a
201
+ real capability:
202
+
203
+ ```ts
204
+ import * as TestJournal from "@smthrs/journal/test/TestJournal"
205
+ import * as RunCatalog from "@smthrs/sync/RunCatalog"
206
+ import * as SyncAuth from "@smthrs/sync/SyncAuth"
207
+ import * as SyncServer from "@smthrs/sync/SyncServer"
208
+ import * as WorkspaceShare from "@smthrs/sync/WorkspaceShare"
209
+ import { Layer, Redacted } from "effect"
210
+
211
+ const keyring: WorkspaceShare.Keyring = {
212
+ activeKid: "k1",
213
+ keys: [{ kid: "k1", secret: Redacted.make("test-secret-fixture-only-not-for-prod") }]
214
+ }
215
+
216
+ const servingStack = Layer.mergeAll(SyncServer.layerHandlers, SyncAuth.layer).pipe(
217
+ Layer.provideMerge(Layer.mergeAll(SyncServer.layer, WorkspaceShare.layerHmac(keyring))),
218
+ Layer.provideMerge(Layer.mergeAll(TestJournal.layer(), RunCatalog.layerStatic([runId])))
219
+ )
220
+ ```
221
+
222
+ A connection that presents no header reads no non-branch run, so the
223
+ unauthenticated case is the one to assert first. See
224
+ [Authorize a connection](./authorize-a-connection.md) for minting and
225
+ presenting the capability.
226
+
227
+ For an in-process caller with no wire at all, provide the principal directly
228
+ with `SyncPrincipal.layerWorkspace("my-suite")`. That path presents no
229
+ credential and therefore has no expiry.
230
+
231
+ ## Register runs a workspace subscription can see
232
+
233
+ `layerTest`'s run catalog starts empty, and a workspace subscription covers
234
+ only what the catalog lists. Build your own catalog when the test needs runs in
235
+ it. `RunCatalog.makeMemory` returns both the service and a `register` function:
236
+
237
+ ```ts
238
+ const catalog = Effect.gen(function*() {
239
+ const memory = yield* RunCatalog.makeMemory()
240
+ yield* memory.register(runId)
241
+ return memory.catalog
242
+ })
243
+ ```
244
+
245
+ Provide that catalog as `RunCatalog.RunCatalog` in place of the one `layerTest`
246
+ supplies. See
247
+ [List a workspace's runs](./list-workspace-runs.md) for the production forms.
248
+
249
+ ## Stub the ports entirely
250
+
251
+ A consumer that only needs the sync services to exist, and never exercises
252
+ them, takes `TestSync.layerNoop`. It provides a closed client, a closed server,
253
+ an empty catalog, and a pass-through middleware. Every subscription it hands
254
+ out fails with `closed`, so it is a compile-time convenience and not a fixture
255
+ to assert against.
256
+
257
+ ## What these fixtures do not cover
258
+
259
+ `TestSync` binds the read path only. It mounts `SyncRpcs`, never `BranchRpcs`,
260
+ so a branch test drives `BranchServer` and the branch services directly. See
261
+ [Branch collaboration](../concepts/branches.md) for why the branch surface
262
+ ships unserved.
263
+
264
+ The socket pair's queues hold 16 frames each. A test that writes more than
265
+ that without a reader blocks, which is a bounded transport behaving correctly
266
+ rather than a fixture defect.
267
+
268
+ ## Next steps
269
+
270
+ - [Troubleshooting](../troubleshooting.md): every failure code these faults
271
+ produce, and what each one means.
272
+ - [Replay then follow](../concepts/replay-then-follow.md): why the client
273
+ retries a transport failure and propagates everything else.
@@ -0,0 +1,92 @@
1
+ ---
2
+ title: "Installation"
3
+ description: "Install @smthrs/sync, choose an import form, and add the journal, run catalog, and transport that a serving composition needs."
4
+ sidebar:
5
+ order: 1
6
+ ---
7
+
8
+ ## Install the package
9
+
10
+ Not on npm yet; see [Installation](https://github.com/smithersai/smithers/blob/main/packages/smithers/flows/flow/docs/installation.md#use-the-libraries).
11
+
12
+ ## Requirements
13
+
14
+ - Node.js 26.4.0 or later, or a browser with Web Crypto. The signing paths
15
+ call `crypto.subtle` directly so one module serves both.
16
+ - `effect` exactly at the version the package declares. It is a peer dependency
17
+ so the follower and Smithers use one runtime.
18
+ - [`@smthrs/journal`](/api/journal), also a direct dependency. The entry
19
+ envelope every frame carries is the journal's `JournalEvent.Entry`.
20
+
21
+ ## Import forms
22
+
23
+ The root is browser safe and re-exports every namespace:
24
+
25
+ ```ts
26
+ import { RunCatalog, SyncClient, SyncServer } from "@smthrs/sync"
27
+ ```
28
+
29
+ Every module is also importable on its own path, which is what a bundler
30
+ targeting a browser wants because it leaves the server modules out:
31
+
32
+ ```ts
33
+ import * as SyncClient from "@smthrs/sync/SyncClient"
34
+ import * as SyncProtocol from "@smthrs/sync/SyncProtocol"
35
+ ```
36
+
37
+ The two test doubles live under `test/` subpaths and are documented in
38
+ [Test a follower](./guides/test-a-follower.md):
39
+
40
+ ```ts
41
+ import * as TestSocket from "@smthrs/sync/test/TestSocket"
42
+ import * as TestSync from "@smthrs/sync/test/TestSync"
43
+ ```
44
+
45
+ `@smthrs/sync/test/TestSync` binds the Node SQLite test journal, so it is a
46
+ Node-only import. `@smthrs/sync/test/TestSocket` is an in-memory socket pair
47
+ and runs anywhere. Install the optional SQLite driver when using `TestSync`:
48
+
49
+ ```bash
50
+ pnpm add @effect/sql-sqlite-node@4.0.0-rc.115
51
+ ```
52
+
53
+ `@smthrs/sync/internal/*` is not exported. A path under it fails to resolve.
54
+
55
+ ## What a follower adds
56
+
57
+ A follower needs a transport. `SyncClient.layer` derives its RPC client from
58
+ `RpcClient.Protocol`, so the composition supplies the protocol and the
59
+ serialization:
60
+
61
+ ```bash
62
+ pnpm add @effect/platform-node@4.0.0-rc.115 @effect/platform-node-shared@4.0.0-rc.115
63
+ ```
64
+
65
+ A browser follower needs no extra package: the WebSocket protocol and the JSON
66
+ serialization both come from `effect/unstable/rpc`.
67
+
68
+ ## What a server adds
69
+
70
+ Serving the read path requires a real journal and a run catalog:
71
+
72
+ - [`@smthrs/journal`](/api/journal) supplies `Journal`. Its production layer,
73
+ `SqlJournal`, sits above [`@smthrs/database`](/api/database), so a serving
74
+ host installs a database driver and the journal migrations too.
75
+ - `RunCatalog` is supplied by the host. `RunCatalog.layerStatic` covers a fixed
76
+ set. [`@smthrs/engine-store`](/api/engine-store) supplies `RunCatalogRead`,
77
+ the durable read a polling catalog uses so a follower learns of runs another
78
+ engine created. See [List a workspace's runs](./guides/list-workspace-runs.md).
79
+ - A transport mounts the RPC group. [`@smthrs/gateway`](/api/gateway) already
80
+ does it, on `POST /sync` and `/sync/ws`.
81
+
82
+ Authorization is a separate decision with its own dependency: `SyncAuth.layer`
83
+ requires a `WorkspaceShare` authority, and `WorkspaceShare.layerConfig` reads
84
+ its secret from the environment. See
85
+ [Authorize a connection](./guides/authorize-a-connection.md).
86
+
87
+ ## Next steps
88
+
89
+ - [Quickstart](./quickstart.md) runs a follower end to end with no transport
90
+ and no database of your own.
91
+ - [Serve the read path](./guides/serve-the-read-path.md) composes the server
92
+ side against a real journal.
@@ -0,0 +1,183 @@
1
+ ---
2
+ title: "Wire protocol"
3
+ description: "The normative message shapes of the sync read path: scopes, cursors, read requests and responses, subscription frames, and the limits every side enforces."
4
+ ---
5
+
6
+ ## Scopes and cursors
7
+
8
+ A client reads either one run or every run in a workspace:
9
+
10
+ ```ts
11
+ import { SyncProtocol } from "@smthrs/sync"
12
+ import { Schema } from "effect"
13
+
14
+ const scope = Schema.decodeUnknownSync(SyncProtocol.RunScope)({
15
+ _tag: "Run",
16
+ runId: "build-42"
17
+ })
18
+ const cursor = Schema.decodeUnknownSync(SyncProtocol.RunCursor)({
19
+ runId: "build-42",
20
+ afterSeq: 17
21
+ })
22
+ ```
23
+
24
+ A run cursor stores the last sequence the client was delivered. A workspace
25
+ cursor is an array of per-run cursors with at most one entry per run: a request
26
+ that names one run twice is refused with `invalid_request`, because the read
27
+ position and the echoed response state would otherwise disagree. Journal
28
+ sequences may have holes, so a cursor means "read entries after this number",
29
+ not "expect the next number to be exactly one greater".
30
+
31
+ A read and a subscribe request carry the same envelope, `SyncProtocol.RequestEnvelope`:
32
+ one scope and one cursor set. Server read, server subscribe and client
33
+ subscribe admit a caller-built request against that single schema.
34
+
35
+ `generation` is asymmetric on purpose. A request may omit it, which is how a
36
+ cursor persisted before rewind support still decodes; a response may not. The
37
+ server-side shapes state that requirement in the type system:
38
+ `SyncProtocol.ServerCursor`, `ServerReadResponse`, `ServerEntriesFrame` and
39
+ `ServerFrame` all require `generation`, and `SyncServer.Service` answers with
40
+ them, so a server that omits one does not compile. The wire schemas stay
41
+ lenient so a non-conforming server is refused with `protocol_violation` naming
42
+ the missing generation rather than a bare decode failure.
43
+
44
+ ## Protocol version
45
+
46
+ `SyncProtocol.protocolVersion` is the one accepted wire revision, and the only
47
+ place the number is written. Read, subscribe and snapshot requests all decode a
48
+ version this revision does not speak, so the refusal is a typed
49
+ `protocol_violation` with cause `protocol_version_mismatch` on every path,
50
+ in process and over the wire, request and response alike. There is no second
51
+ code for a version mismatch.
52
+
53
+ ## Read
54
+
55
+ `Sync.Read` accepts `protocolVersion: 1`, a scope, a cursor set, and a limit, and returns journal
56
+ entries plus the cursor set the page served through. `RunCatalog` supplies the
57
+ run list for workspace reads; `SyncServer.layer` combines it with `Journal`.
58
+
59
+ `limit` is bounded at `SyncProtocol.maxReadLimit`. A page is also bounded by
60
+ the encoded-entry ceiling, `maxFrameBytes`, which defaults to 2 MiB. The
61
+ ceiling is a budget on the page, not a verdict on the read: the server serves
62
+ entries until the next one would cross it, then returns `done: false` so the
63
+ client asks for the rest. Only a single entry whose own encoded size exceeds
64
+ the ceiling fails, with `frame_too_large`, because no page can carry it.
65
+
66
+ Persist the returned cursors only after applying the returned batch.
67
+
68
+ There is no application acknowledgement RPC. Response cursors are delivery
69
+ bookmarks. `SyncClient.progress` reports `Progress`, two cursor sets:
70
+ `delivered` holds delivery bookmarks and `applied` holds application
71
+ positions. Only successful `apply` and snapshot-restore callbacks advance the
72
+ applied set. An applying subscription never inherits delivery-only progress.
73
+
74
+ Both server and client validate complete admitted batches before serving or
75
+ applying them. A malformed envelope or non-JSON payload fails `decode_failed`;
76
+ foreign runs, foreign branch identities, and non-monotonic sequences fail
77
+ `protocol_violation`. Causes retain a bounded classification without publishing
78
+ private record contents. Unknown event namespaces remain open JSON values.
79
+ Known branch commands must decode; existing defaults for absent `args` and
80
+ `target` remain unchanged. No stored row is rewritten or silently skipped.
81
+
82
+ ## Subscribe
83
+
84
+ `Sync.Subscribe` requires the same `protocolVersion: 1` and streams `Entries` frames, each carrying one run's entries and
85
+ the interval the server covered (`runId`, `fromSeq`, `toSeq`), and a terminal
86
+ `Closed` frame when the server ends the subscription. `fromSeq` and `toSeq`
87
+ describe the covered interval, not the sequences actually carried: dropped
88
+ admissions leave legitimate holes. No frame carries a cursor; a client tracks
89
+ its own, which is what `SyncClient` does.
90
+
91
+ The `Heartbeat` variant is RESERVED. No server emits one at 1.0.0-rc.0, so a
92
+ client must not wait for one; it stays in the union so adding it later is not a
93
+ wire break. Keepalive is a transport concern, because a heartbeat here would
94
+ spend the subscription's credit.
95
+
96
+ The request includes a credit count between 1 and
97
+ `SyncProtocol.maxSubscribeCredit`. Credit is a hard limit on frames emitted by
98
+ that subscription, not a sliding acknowledgement window, and there is no Ack
99
+ RPC. A client that needs more resubscribes from its current cursor positions,
100
+ which is what `SyncClient` does once per window. These are applied positions
101
+ when an `apply` callback is supplied and delivery bookmarks otherwise.
102
+
103
+ A subscription also ends when the credential that opened it expires, with
104
+ `SyncError` code `unauthorized`. Authorization happens once, at open, so
105
+ without that deadline a signed expiry could not revoke a stream that simply
106
+ stayed connected.
107
+
108
+ `SyncClient.subscribe` wraps the RPC stream. It detects invalid cursor movement
109
+ as `SyncGapError`, refuses a frame or a catch-up page that contradicts itself
110
+ as a `protocol_violation`, reconnects transport failures under exponential
111
+ backoff capped at five seconds, and resumes from the subscription's matching
112
+ progress map. Authentication is `SyncAuth`, and both are part of the package
113
+ rather than left to the application.
114
+
115
+ ## Compacted runs
116
+
117
+ A cursor below a run's compaction floor names entries the journal has deleted.
118
+ The read or the subscription fails with `SyncError` code `compacted`, and the
119
+ error carries a `Resync` of `{ runId, checkpointSeq, generation }`: the floor to
120
+ resume from and the run history it belongs to. `SyncClient.subscribe` fails
121
+ closed unless `onResync` restores the missing prefix and returns
122
+ `{ runId, afterSeq }`. The cursor advances to that actual restored sequence, not
123
+ necessarily the older reported floor, before replaying the suffix. A receipt
124
+ without a generation takes the refusal's, so a fresh follower of a rewound run
125
+ resumes the right history. Foreign-run and behind-floor receipts are refused.
126
+
127
+ :::warning
128
+ The resync moves a cursor, not state. The entries below `checkpointSeq` are
129
+ never delivered. Fetch an explicitly public projection with `Sync.Snapshot`
130
+ or use an authorized local source. `SubscribeOptions.onResync` runs before the cursor moves and must
131
+ succeed with a valid restored cursor, so a consumer that cannot restore the prefix fails instead of silently
132
+ skipping it. A Node follower reads that prefix out of band with
133
+ `journal.latestCheckpoint(runId)`, applies `checkpoint.state`, and continues
134
+ from the sync stream. See [Checkpoints and compaction](/pkg/journal/concepts/compaction).
135
+ :::
136
+
137
+ ## Public snapshots
138
+
139
+ `Sync.Snapshot` accepts `SnapshotRequest`: `protocolVersion: 1`, `runId`,
140
+ `lineageId`, `projection`, `projectionVersion`, `atLeastSeq`, and an optional
141
+ branch `capability`. The response repeats that identity with `seq` and JSON
142
+ `state`, representing the complete public projection through `seq` inclusive.
143
+ Lineage IDs are nonempty and at most 512 characters; projection names are
144
+ nonempty and at most 128. Projection versions are positive safe integers.
145
+
146
+ Run/branch read authorization is the same as for history. An explicit host
147
+ `SyncServer.SnapshotSource` must select a public projection safe for every reader
148
+ of that run; raw journal checkpoints are never served automatically. Missing
149
+ providers or unavailable projections fail closed. Credential expiry is checked
150
+ before and after provider work, and provider failures do not expose private text.
151
+
152
+ Both ends enforce the requested identity, its protocol version, and the
153
+ minimum sequence; each mismatch is a `protocol_violation`. Both also bound
154
+ the entire encoded UTF-8 snapshot response by `maxFrameBytes` (default 2 MiB,
155
+ excluding the outer RPC envelope), and reject non-JSON provider state. The
156
+ response is detached from mutable provider objects.
157
+
158
+ `SyncClient.snapshot` fetches only: it does not apply state or move cursors.
159
+ After transactional application, `onResync` returns the actual restored cursor.
160
+ If compaction advances before the next suffix read, recovery runs again at the
161
+ newer floor. Snapshot bytes are inline, so no temporary download reference can
162
+ expire during application. Providers must retain a usable latest snapshot
163
+ covering every compacted prefix and version they continue to serve.
164
+
165
+ ## Directionality
166
+
167
+ `SyncRpcs` is read only: `Sync.Read`, `Sync.Subscribe`, and `Sync.Snapshot`. Client-to-server
168
+ submission is not planned work, it is `BranchRpcs`: `Branch.Submit` admits one
169
+ command onto a branch's journal run under a client-minted idempotency key, and
170
+ `Branch.Announce`, `Branch.Leave`, `Branch.Roster`, and `Branch.WatchRoster`
171
+ carry ephemeral presence. `BranchRpcs` has no production mount at
172
+ 1.0.0-rc.0; the gateway serves `SyncRpcs` alone.
173
+
174
+ Bidirectional reconciliation of the read path, acknowledgement windows, and
175
+ resumable transport sessions remain unplanned.
176
+
177
+ Command admission separately permits up to 1 MiB of UTF-8 JSON submission
178
+ bytes. Before writing, it also checks the complete journal envelope against
179
+ `BranchCommands.Options.maxFrameBytes` (2 MiB by default), reserving the largest
180
+ generated sequence/timestamp representation. Custom admission, server, and
181
+ client budgets must agree. `maxFrameBytes` sums encoded entries; an RPC transport
182
+ must additionally allow its frame/envelope overhead. Neither journal cursors nor
183
+ entries change when admission refuses a command.