@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,144 @@
1
+ ---
2
+ title: "Compaction and resync"
3
+ description: "What a compacted refusal means, how the client resumes from the checkpoint the server names, and why the entries below that checkpoint are a real hole the consumer must fill."
4
+ ---
5
+
6
+ Compaction deletes a run's journal entries below a checkpoint. A cursor under
7
+ that floor names history that no longer exists, so the read cannot be served
8
+ and cannot be served later either.
9
+
10
+ `@smthrs/journal` owns the mechanism, including when a checkpoint is written
11
+ and what it may summarize; see
12
+ [Checkpoints and compaction](/pkg/journal/concepts/compaction). This page is
13
+ about what the sync boundary does with it.
14
+
15
+ ## The refusal names where to resume
16
+
17
+ The server maps the journal's compacted failure onto its own `compacted` code
18
+ and attaches a `Resync` of `{ runId, checkpointSeq, generation }`. `checkpointSeq`
19
+ is the compaction floor: the checkpoint's state subsumes every entry at or below
20
+ it, so a follower must restore a snapshot covering that sequence before
21
+ resuming. Its cursor then names the snapshot actually applied, which may be
22
+ newer. `generation` is the run's history after any rewinds; a restored cursor
23
+ that omits its own generation is bound to this one.
24
+
25
+ `compacted` is its own code rather than an `unknown` because it is the one
26
+ recoverable failure here. Folding it into `unknown` cost the checkpoint, and
27
+ with it every path back: the client retries only `transport_failed`, so the
28
+ subscription died and every resubscribe from the same cursors died identically.
29
+ A whole-workspace subscription then died over a single compacted run.
30
+
31
+ The run id comes from the call site rather than from the journal error. The
32
+ journal error carries only a sequence, and a workspace read fans out over many
33
+ runs, so without the id a follower could not tell which cursor to move.
34
+
35
+ `Resync` rides on `SyncError` as one optional field rather than as a new frame
36
+ variant or a new procedure. A follower that never meets a compacted run sees no
37
+ change at all. `Sync.Snapshot` separately fetches an explicitly public,
38
+ versioned and lineage-bound projection when the host provides one.
39
+
40
+ ## Recovery requires an explicit handler
41
+
42
+ `SyncClient.subscribe` fails closed by default. An `onResync` handler must
43
+ restore the missing prefix and return `{ runId, afterSeq }` for the snapshot
44
+ actually applied. Only then does the client advance and read the suffix.
45
+ The receipt must name the requested run and cover the reported floor.
46
+
47
+ These cases stay failures on purpose:
48
+
49
+ - A `compacted` error carrying no `Resync` has no resume point to apply.
50
+ - A target outside the subscription's run scope is refused before the hook runs.
51
+ - A missing handler, failed application, or invalid receipt cannot acknowledge history.
52
+ - A checkpoint at or below what the subscription already covers cannot move the
53
+ cursor forward, so retrying would re-read the same refusal forever. That is
54
+ the same non-convergence rule that refuses an incomplete catch-up page which
55
+ makes no progress.
56
+
57
+ ## The hole is real
58
+
59
+ :::warning
60
+ The resync moves a cursor, not state. The entries below `checkpointSeq` are
61
+ gone from the journal. A consumer must fetch and apply a public snapshot or
62
+ restore an authorized local checkpoint before claiming to have rebuilt that prefix.
63
+ :::
64
+
65
+ `SubscribeOptions.onResync` is the seam a consumer fills the hole through. It
66
+ runs **before** the cursor moves and must return a valid restored cursor. A failure leaves the cursor
67
+ where it was and fails the subscription, so nothing is skipped behind the
68
+ consumer's back.
69
+
70
+ What a consumer does there depends on what it is holding:
71
+
72
+ - A remote follower fetches a configured public projection with `sync.snapshot`,
73
+ validates its application schema, applies it, and returns its exact cursor.
74
+ - A trusted Node follower can also read the prefix out of band with
75
+ `journal.latestCheckpoint(runId)` and applies `checkpoint.state`, then
76
+ continues from the sync stream.
77
+ - A follower that cannot restore the prefix fails
78
+ the hook, and the subscription fails with the cursor unmoved.
79
+
80
+ Durable consumers own the transaction that commits snapshot state and their
81
+ durable cursor together. This client's in-memory cursor is not a cross-store
82
+ transaction. A moving compaction floor may require another idempotent restore.
83
+ Raw journal checkpoints are unredacted execution state. `SnapshotSource` is
84
+ an explicit public-projection boundary, not a passthrough for those checkpoints.
85
+ Provider failures and defects are logged at warning level with their original
86
+ cause. The caller receives `not_found` without provider details. Interruption
87
+ remains interruption.
88
+
89
+ ## Retention and rebuilding
90
+
91
+ The protocol transfers a complete snapshot value. It does not hand out a
92
+ checkpoint reference that might expire between lookup and fetch. A provider
93
+ must capture its selected public state before yielding it, then let the server
94
+ detach and validate the response. Collecting a provider cache after capture
95
+ cannot invalidate those transferred bytes. A newer compaction can still
96
+ invalidate the suffix cursor; the next read returns `compacted` and recovery
97
+ fetches and applies a newer snapshot.
98
+
99
+ Before compacting, durably checkpoint every projection whose history the host
100
+ claims is recoverable. Keep that authoritative checkpoint until a newer complete
101
+ checkpoint covers it. The journal's fenced compaction transaction retains its
102
+ floor checkpoint and allocation entry while collecting older checkpoints and
103
+ events. A disposable public-snapshot cache can then be dropped and rebuilt from
104
+ that checkpoint plus retained history. Deleting both the checkpoint and the
105
+ compacted prefix destroys authority and cannot be repaired by sync.
106
+
107
+ `ProjectionRebuild.integration.test.ts` exercises 17-, 33-, and 257-entry
108
+ histories with independent full-history expectations. It drops the consumer's
109
+ branch and workspace projection tables and the public snapshot cache, kills
110
+ each rebuilding consumer with SIGKILL inside its transaction, then reopens
111
+ SQLite and reconstructs identical state. The 17-entry case ends exactly at its
112
+ compaction floor. A second case collects snapshots during transfer and advances
113
+ the floor before suffix replay. The workspace projection is an application
114
+ example authorized with production `WorkspaceShare` middleware.
115
+
116
+ `WorkspaceShare` itself is an HMAC capability authority, not a journal
117
+ projection. Its signing keys, credential lifetime, and access grants are not
118
+ rebuildable from public history. Branch presence is likewise an ephemeral lease.
119
+ These must not be included in claims about disposable history projections.
120
+
121
+ An older client without a recovery handler receives the typed
122
+ `compacted` result with its `resync` target and unchanged progress. A missing
123
+ public provider returns `not_found`; incompatible identities or versions are
124
+ refused. Neither response authorizes skipping the missing prefix.
125
+
126
+ [Handle a compacted run](../guides/handle-a-compacted-run.md) is the
127
+ task-shaped version, with the hook written out.
128
+
129
+ ## Compaction is opt-in
130
+
131
+ Without a compaction policy on the journal layer, no entry is ever deleted and
132
+ no follower ever meets this code. That is the default. The code path matters
133
+ anyway, because the day a workspace enables compaction is the day the
134
+ difference between a follower that catches up and one that never can becomes
135
+ visible, and by then the follower is already deployed.
136
+
137
+ ## Related pages
138
+
139
+ - [Checkpoints and compaction](/pkg/journal/concepts/compaction) in
140
+ `@smthrs/journal`: what a checkpoint is and how a floor advances.
141
+ - [Scopes and cursors](./scopes-and-cursors.md): what the cursor being moved
142
+ means.
143
+ - [Troubleshooting](../troubleshooting.md): the `compacted` symptom and what to
144
+ change.
@@ -0,0 +1,129 @@
1
+ ---
2
+ title: "Replay then follow"
3
+ description: "The two phases of a sync subscription: bounded catch-up pages until the durable tail, then credit windows of live frames, and the bounds and validation that hold at each step."
4
+ ---
5
+
6
+ A subscription has two phases and one cursor set. First it replays durable
7
+ history through `Sync.Read`, page by page, until the server reports it reached
8
+ the tail. Then it follows through `Sync.Subscribe`. `SyncClient.subscribe`
9
+ performs both and presents them as a single stream of entries, so a consumer
10
+ never sees the switch.
11
+
12
+ ## Phase one: pages until `done`
13
+
14
+ `Sync.Read` takes a scope, a cursor set, and a `limit`, and returns entries
15
+ plus the cursor set the page served through. A page stops at the first of three
16
+ bounds:
17
+
18
+ - the request's `limit`, capped at `SyncProtocol.maxReadLimit`;
19
+ - the frame ceiling, `SyncServer.Options.maxFrameBytes`, which defaults to
20
+ 2 MiB of summed encoded entries;
21
+ - the durable tail of every covered run.
22
+
23
+ Only the third reports `done: true`, and that is the signal to switch to the
24
+ follow.
25
+
26
+ The frame ceiling is a budget on the page, not a verdict on the read. The
27
+ server serves entries until the next one would cross it, then returns
28
+ `done: false` so the client asks for the rest. The single case that fails is
29
+ one entry whose own encoded size exceeds the ceiling, refused with
30
+ `frame_too_large`, because no page can ever carry it.
31
+
32
+ ### Why a page is shared, not filled in order
33
+
34
+ A workspace read offers each covered run a share of the page before offering
35
+ unused budget back to runs that still have entries. Both passes prioritize the
36
+ least advanced supplied cursor, with run ID breaking ties. Runs without a
37
+ cursor come first. Cursor values set scheduling priority only; sequences still
38
+ order entries within their own run.
39
+
40
+ A count or byte budget can end a page before every run gets a share. Reusing the
41
+ returned cursors changes the next page's priority: served runs advance, while
42
+ unserved runs keep their priority. A continuously readable run cannot indefinitely
43
+ hold a slot ahead of another pending run. Empty runs spend no entry budget.
44
+ This requires no server-side session and does not depend on cursor array order.
45
+
46
+ Fair service does not guarantee `done`: a producer that continuously outpaces
47
+ catch-up can keep the follower in this phase. Every pending run still receives
48
+ service; a single page need not contain every run.
49
+
50
+ ## Phase two: credit windows
51
+
52
+ `Sync.Subscribe` takes the same scope and cursors plus a `credit` count.
53
+ `credit` is a hard limit on frames that subscription may emit, enforced by the
54
+ server. It is not a sliding acknowledgement window, and there is no `Ack`
55
+ procedure.
56
+
57
+ A follower that wants more frames resubscribes from its current subscription
58
+ progress. `SyncClient` does exactly that, once per window, defaulting to
59
+ `SyncClient.defaultCredit` of 256 frames.
60
+
61
+ The number is a real trade. At `credit: 1` the window closes after every single
62
+ entry, so following a run that emits a hundred entries costs a hundred
63
+ subscribe round trips, each one a fresh cursor snapshot and a fresh server-side
64
+ fan-out. Replenishing a window at a time makes the round-trip cost proportional
65
+ to the window rather than to the traffic, and keeps it bounded: an unbounded
66
+ window would hand the server one never-closing subscription per follower.
67
+
68
+ ## What a frame says
69
+
70
+ An `Entries` frame carries one run's entries plus the interval the server
71
+ covered: `runId`, `fromSeq`, `toSeq`. The interval describes what the server
72
+ looked at, not what it carried, because dropped admissions leave legitimate
73
+ holes inside it.
74
+
75
+ `fromSeq` is what gap detection compares against the cursor. A frame whose
76
+ `fromSeq` starts more than one past the client's cursor is a real gap and fails
77
+ the subscription with `SyncGapError`. A frame that merely omits sequences
78
+ inside its own interval is ordinary traffic.
79
+
80
+ No frame carries a cursor. The client tracks its own.
81
+
82
+ A `Closed` frame is terminal and ends the subscription with the `closed` code.
83
+ The `Heartbeat` variant is reserved: no server in this package emits one, so a
84
+ client must not wait for one. It stays in the union so adding it later is not a
85
+ wire break, and so a third-party client written against the schema already
86
+ ignores it. Keepalive is a transport concern here, because a heartbeat would
87
+ spend the subscription's credit.
88
+
89
+ ## Server responses are admitted, never trusted
90
+
91
+ Both phases validate before any cursor moves. A schema-valid page or frame can
92
+ still contradict itself, and applying one would corrupt the client's
93
+ bookkeeping. The client refuses:
94
+
95
+ - a page or frame whose summed encoded entries exceed `maxFrameBytes`, with
96
+ `frame_too_large`;
97
+ - an entry for a run the request's scope excludes;
98
+ - entries that repeat or reorder a sequence within one run;
99
+ - an entry at or below the cursor the request carried;
100
+ - an entry outside a frame's own declared interval;
101
+ - a response that echoes one run's cursor twice.
102
+
103
+ Each of the last five is a `protocol_violation`. The strictly-above-the-cursor
104
+ rule is also what makes catch-up converge: a page that carries entries but
105
+ moves no cursor would be re-read forever. An incomplete page with no entries at
106
+ all is refused for the same reason rather than retried.
107
+
108
+ ## Reconnecting
109
+
110
+ A live follow that loses its transport reconnects under exponential backoff
111
+ capped at five seconds, resuming from the subscription's progress, and the
112
+ schedule resets once entries flow again. Only `transport_failed` retries. Gaps,
113
+ authorization refusals, and server closes propagate to the consumer, because
114
+ none of them is fixed by trying again. Delivery-only subscriptions resume from
115
+ delivered bookmarks; applying subscriptions resume from applied progress.
116
+ An unrelated defect or interruption accompanying a disconnect stays terminal,
117
+ with its complete cause preserved.
118
+
119
+ Transport, authentication, and reconnect all live in this package rather than
120
+ in the application. A follower that had to implement them would have to
121
+ re-derive the cursor rules above to do it safely.
122
+
123
+ ## Related pages
124
+
125
+ - [Scopes and cursors](./scopes-and-cursors.md): what the cursor a phase
126
+ resumes from means.
127
+ - [Compaction and resync](./compaction.md): the one refusal that is recoverable
128
+ rather than terminal.
129
+ - [Wire protocol](../protocol.md): the exact message shapes both phases use.
@@ -0,0 +1,135 @@
1
+ ---
2
+ title: "Scopes and cursors"
3
+ description: "What a sync request covers, what a cursor means, why journal sequences have holes, and the difference between an entry that was delivered and one that was applied."
4
+ ---
5
+
6
+ Every sync request answers two questions: which runs does it cover, and where
7
+ in each of them does it start. The first is a scope. The second is a cursor
8
+ set.
9
+
10
+ ## A scope is one run or the whole workspace
11
+
12
+ ```ts
13
+ import type { JournalEvent } from "@smthrs/journal"
14
+ import type * as SyncProtocol from "@smthrs/sync/SyncProtocol"
15
+
16
+ const workspace: SyncProtocol.Scope = { _tag: "Workspace" }
17
+ const oneRun: SyncProtocol.Scope = { _tag: "Run", runId: "build-42" as JournalEvent.RunId }
18
+ ```
19
+
20
+ A run scope names exactly one run. A workspace scope covers every run the
21
+ `RunCatalog` lists and the caller is authorized to read, and it reconciles that
22
+ set on every round, so a run created after the subscription opened joins it.
23
+
24
+ `SyncProtocol.covers(scope, runId)` is the predicate both ends use, so a client
25
+ validating a server's page asks the same question the server asked when it
26
+ built it.
27
+
28
+ ## A cursor is per run, and exclusive
29
+
30
+ `RunCursor` is `{ runId, afterSeq, generation }`. A `WorkspaceCursor` is an
31
+ array of them. `generation` names the run's current history: a rewind
32
+ increments it, and sequence order is meaningful only within one generation.
33
+ Server cursors always carry it; a persisted request cursor may omit it for
34
+ generation zero. Persist it with `afterSeq`, because a cursor whose generation
35
+ differs from the run's fails with `lineage_changed`. See
36
+ [Rewind generations](../api.md#rewind-generations).
37
+
38
+ `afterSeq` means "entries after this number". It never means "expect the next
39
+ number to be exactly one greater".
40
+
41
+ That distinction is not a detail. Journal sequences legitimately have holes: an
42
+ admission the journal dropped leaves a number nothing was ever written at. A
43
+ follower that treats a missing number as corruption stalls on ordinary traffic.
44
+ Gap detection therefore compares the server's declared covered interval against
45
+ the cursor, not the entry sequences against each other. See
46
+ [Replay then follow](./replay-then-follow.md) for where that comparison lives.
47
+
48
+ ## One cursor per run, or the request is refused
49
+
50
+ A cursor set that names one run twice is ambiguous about where the read starts,
51
+ so both `Sync.Read` and `Sync.Subscribe` refuse it with `invalid_request`
52
+ rather than picking one. `SyncProtocol.duplicateCursorRunId` is the check, and
53
+ the client applies it to the server's echoed cursors as well: a response that
54
+ names a run twice is a `protocol_violation`.
55
+
56
+ The schema cannot express uniqueness, so the rule is stated once and enforced
57
+ at every boundary that could break it.
58
+
59
+ ## Delivered and applied are different claims
60
+
61
+ `RunCursor` is an exclusive transport bookmark. The server's response cursors
62
+ and `progress.delivered` describe delivery, and never acknowledge application.
63
+ `client.progress` reports `SyncProtocol.Progress`, two cursor sets:
64
+
65
+ - `delivered` records transport delivery.
66
+ - `applied` records successful `apply` or `onResync` callbacks.
67
+
68
+ Both are `WorkspaceCursor` values; the field name carries the distinction.
69
+ Delivery-only subscriptions leave applied progress empty. Use
70
+ `SubscribeOptions.apply` to acknowledge materialization:
71
+
72
+ ```ts
73
+ import type { JournalEvent } from "@smthrs/journal"
74
+ import * as Effect from "effect/Effect"
75
+
76
+ const apply = (entry: JournalEvent.Entry): Effect.Effect<void> =>
77
+ Effect.logInfo(`applied ${entry.eventType} at ${entry.seq}`)
78
+ ```
79
+
80
+ The callback runs to success before the cursor moves. It fails with the
81
+ consumer's own error type, which the subscription's error channel carries
82
+ unchanged. A failure fails the subscription with that entry unacknowledged, so
83
+ the next subscription from `client.progress`'s applied cursors delivers it
84
+ again. Redelivery is what a retry is here, so the callback must be idempotent.
85
+
86
+ Use it whenever the consumer writes somewhere durable. Leave it off when the
87
+ consumer is a view that is rebuilt on reconnect anyway.
88
+
89
+ ## The client's effective cursor is the later of two
90
+
91
+ A `SyncClient` keeps separate delivered and applied maps. An applying
92
+ subscription uses the later of the caller's cursor and the shared applied
93
+ cursor. A delivery-only subscription uses shared delivered progress. Earlier
94
+ delivery cannot fast-forward an applying consumer past unapplied entries.
95
+ One client instance belongs to one materialization; independent projections
96
+ must use independent clients even when they read the same run.
97
+
98
+ Both halves matter. A caller that restored its own progress from durable
99
+ storage is never regressed and never re-receives entries it has already
100
+ materialized. A caller behind its matching shared progress map is fast-forwarded.
101
+ Failed application can redeliver the unapplied entry; consumers must be idempotent.
102
+
103
+ To rebuild a projection from an earlier position, construct a fresh client. Its
104
+ progress maps are empty, so the caller's cursor is the only one there is.
105
+
106
+ ## Persisting a cursor
107
+
108
+ `client.progress` returns canonical sets with one entry per run, sorted by run
109
+ id. Its applied positions advance only after application succeeds. Fetching a
110
+ snapshot does not move either set:
111
+
112
+ ```ts
113
+ import * as SyncClient from "@smthrs/sync/SyncClient"
114
+ import * as Effect from "effect/Effect"
115
+
116
+ const checkpoint = Effect.flatMap(
117
+ SyncClient.SyncClient,
118
+ (sync) => Effect.map(sync.progress, (progress) => progress.applied)
119
+ )
120
+ ```
121
+
122
+ For durable recovery, the callback must commit state and its cursor in the
123
+ same application transaction. Reading progress later and writing a separate
124
+ cursor file is not that transaction. Seed a fresh client from the durable
125
+ transaction's cursor after restart. Supply cursors only for state already
126
+ restored; an input bookmark is the caller's assertion, not proof of application.
127
+
128
+ ## Related pages
129
+
130
+ - [Replay then follow](./replay-then-follow.md): how the two phases use these
131
+ cursors, and what bounds a page.
132
+ - [Compaction and resync](./compaction.md): what happens when a cursor names
133
+ history the journal has deleted.
134
+ - [Follow a run](../guides/follow-a-run.md): the task-shaped version of this
135
+ page.
@@ -0,0 +1,174 @@
1
+ ---
2
+ title: "Authorize a connection"
3
+ description: "Provision a workspace share authority, mint a capability, present it from a client, rotate signing keys without invalidating outstanding links, and reach a shared branch instead."
4
+ sidebar:
5
+ order: 3
6
+ ---
7
+
8
+ A sync connection reads a workspace's non-branch runs only when it presents a
9
+ verified `WorkspaceShare` capability. This guide provisions the authority,
10
+ mints a capability, and gets it onto the wire.
11
+
12
+ ## Provision the authority from configuration
13
+
14
+ `WorkspaceShare.layerConfig` reads one secret and one key name from the
15
+ environment:
16
+
17
+ ```ts
18
+ import * as WorkspaceShare from "@smthrs/sync/WorkspaceShare"
19
+
20
+ const shareLayer = WorkspaceShare.layerConfig
21
+ ```
22
+
23
+ | Variable | Meaning |
24
+ | ---------------------- | -------------------------------------------------------------------------------------------------------- |
25
+ | `SMITHERS_SYNC_SECRET` | The HMAC signing secret, read as `Redacted` so it is never logged. Required; at least 32 bytes of UTF-8. |
26
+ | `SMITHERS_SYNC_KEY_ID` | The key name recorded in every capability's claims. Defaults to `primary`. |
27
+
28
+ There is deliberately no default secret. A deployment that configures neither
29
+ name fails to construct the authority, and the read path stays closed.
30
+
31
+ ## Mint a capability
32
+
33
+ `mint` takes the id you will audit by, the access level, and a lifetime in
34
+ milliseconds:
35
+
36
+ ```ts
37
+ import * as Effect from "effect/Effect"
38
+
39
+ const mint = Effect.gen(function*() {
40
+ const share = yield* WorkspaceShare.WorkspaceShare
41
+ return yield* share.mint({
42
+ capabilityId: "dashboard-session-7",
43
+ access: "read",
44
+ ttlMs: 60 * 60 * 1000
45
+ })
46
+ })
47
+ ```
48
+
49
+ The result carries its claims and their signature. `capabilityId` is what
50
+ identifies the credential in a span or an audit trail; it is not a secret and
51
+ it is not the signature.
52
+
53
+ `verify` is the other half, and it is the single place a refusal happens. It
54
+ checks the signature in constant time, then that the key name is known, then
55
+ the expiry, then that the access asked for is covered. Every refusal is
56
+ `unauthorized` with a distinct message.
57
+
58
+ ## Present it from a client
59
+
60
+ `SyncAuth.layerClient` stamps every outgoing request from a generated RPC
61
+ client with the capability:
62
+
63
+ ```ts
64
+ import * as SyncAuth from "@smthrs/sync/SyncAuth"
65
+ import * as SyncClient from "@smthrs/sync/SyncClient"
66
+ import * as Layer from "effect/Layer"
67
+
68
+ const authorized = (capability: WorkspaceShare.WorkspaceCapability) =>
69
+ SyncClient.layer.pipe(Layer.provide(SyncAuth.layerClient(capability)))
70
+ ```
71
+
72
+ A client written against the wire rather than against `SyncClient` sets the
73
+ header itself. The name is `SyncAuth.capabilityHeader`, which is
74
+ `flows-sync-workspace`, and the value is the unpadded base64url of the
75
+ schema-encoded capability JSON:
76
+
77
+ ```ts
78
+ const header = Effect.gen(function*() {
79
+ const share = yield* WorkspaceShare.WorkspaceShare
80
+ const capability = yield* share.mint({ capabilityId: "cli", access: "read", ttlMs: 60_000 })
81
+ return yield* SyncAuth.encodeCapability(capability)
82
+ })
83
+ ```
84
+
85
+ `SyncAuth.decodeCapability` is the inverse. Every malformation folds to the
86
+ same `unauthorized` refusal, so the header cannot be used as a parsing oracle.
87
+
88
+ ## Rotate a signing key
89
+
90
+ A capability's claims carry the `kid` of the key that signed them, and the
91
+ `kid` is itself signed. Build the authority over a keyring to add a new active
92
+ key while capabilities minted under the retired one are still outstanding:
93
+
94
+ ```ts
95
+ import * as Config from "effect/Config"
96
+ import * as Layer from "effect/Layer"
97
+
98
+ const rotated = Layer.unwrap(
99
+ Effect.gen(function*() {
100
+ const keyring: WorkspaceShare.Keyring = {
101
+ activeKid: "2026-q1",
102
+ keys: [
103
+ { kid: "2026-q1", secret: yield* Config.Redacted("SYNC_SECRET_CURRENT") },
104
+ { kid: "2025-q4", secret: yield* Config.Redacted("SYNC_SECRET_RETIRED") }
105
+ ]
106
+ }
107
+ return WorkspaceShare.layerHmac(keyring)
108
+ })
109
+ )
110
+ ```
111
+
112
+ `Config.Redacted` reads each secret without ever rendering it. An unset
113
+ variable fails the layer with a `ConfigError` naming it, so the read path
114
+ stays closed.
115
+
116
+ New capabilities are signed with `activeKid`. Capabilities naming any key in
117
+ `keys` still verify. Drop the retired key once its longest outstanding
118
+ lifetime has passed.
119
+
120
+ Every key is imported when the authority is constructed, so a misconfigured
121
+ keyring fails there rather than at the first request. A malformed keyring
122
+ fails with `invalid_request`:
123
+
124
+ - an `activeKid` that names no key in the ring;
125
+ - a `kid` listed twice;
126
+ - a secret holding an unpaired surrogate, which UTF-8 would fold into the
127
+ same key bytes as a different secret;
128
+ - a secret shorter than 32 bytes of UTF-8. Every capability carries its
129
+ claims and signature in the clear, so one observed capability lets an
130
+ attacker search a short secret offline and then forge capabilities.
131
+ Generate one with `openssl rand -base64 32`.
132
+
133
+ A secret Web Crypto refuses to import fails with `unknown` instead, because
134
+ the refusal is the platform's rather than the request's. The error's `cause`
135
+ carries the rejection text.
136
+
137
+ ## Close the door entirely
138
+
139
+ `WorkspaceShare.layerNoop` mints nothing and verifies nothing. Both operations
140
+ **fail** with `unauthorized` rather than dying, so a consumer handling
141
+ `SyncError` gets a refusal where the type promised one. Wire it when a
142
+ deployment deliberately offers no workspace sharing, such as a local CLI whose
143
+ only reader is in process.
144
+
145
+ ## Reach a shared branch instead
146
+
147
+ A branch capability is a different credential with a different authority. It
148
+ travels in the request payload rather than in a header, because it names one
149
+ branch rather than one connection:
150
+
151
+ ```ts
152
+ import type { JournalEvent } from "@smthrs/journal"
153
+ import type * as BranchProtocol from "@smthrs/sync/BranchProtocol"
154
+
155
+ const followBranch = (capability: BranchProtocol.ShareCapability, runId: JournalEvent.RunId) =>
156
+ Effect.gen(function*() {
157
+ const sync = yield* SyncClient.SyncClient
158
+ return sync.subscribe({ scope: { _tag: "Run", runId }, cursors: [], capability })
159
+ })
160
+ ```
161
+
162
+ Mint one through `BranchShare`, whose `mint` takes a `branchId` alongside the
163
+ same `capabilityId`, `access`, and `ttlMs`. A connection holding only a branch
164
+ link still reads no engine run: that is the other boundary, and the link does
165
+ not answer it.
166
+
167
+ ## Related pages
168
+
169
+ - [Authorization](../concepts/authorization.md): why there are two boundaries
170
+ and what a signed expiry does to an open subscription.
171
+ - [Serve the read path](./serve-the-read-path.md): where the middleware is
172
+ installed.
173
+ - [Troubleshooting](../troubleshooting.md): each `unauthorized` symptom and its
174
+ cause.