@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,897 @@
1
+ /**
2
+ * The workspace-side implementation of the sync read path.
3
+ *
4
+ * @since 0.1.0
5
+ */
6
+ import { Journal } from "@smthrs/journal";
7
+ import * as Cause from "effect/Cause";
8
+ import * as Clock from "effect/Clock";
9
+ import * as Context from "effect/Context";
10
+ import * as Effect from "effect/Effect";
11
+ import * as Layer from "effect/Layer";
12
+ import * as Option from "effect/Option";
13
+ import * as PubSub from "effect/PubSub";
14
+ import * as Stream from "effect/Stream";
15
+ import { branchOfRunId } from "./BranchProtocol.js";
16
+ import * as BranchShare from "./BranchShare.js";
17
+ import * as Admission from "./internal/Admission.js";
18
+ import { causeCode } from "./internal/CauseText.js";
19
+ import { journalErrorCode } from "./internal/JournalErrorCode.js";
20
+ import { positiveInt, requestCount } from "./internal/PolicyOptions.js";
21
+ import { requireVersion } from "./internal/ProtocolVersion.js";
22
+ import * as SnapshotBoundary from "./internal/SnapshotBoundary.js";
23
+ import * as RunCatalog from "./RunCatalog.js";
24
+ import { SyncError } from "./SyncError.js";
25
+ import * as SyncPrincipal from "./SyncPrincipal.js";
26
+ import * as SyncProtocol from "./SyncProtocol.js";
27
+ import { SyncRpcs } from "./SyncRpcs.js";
28
+ /**
29
+ * The workspace sync server.
30
+ *
31
+ * @category services
32
+ * @since 0.1.0
33
+ */
34
+ export class SyncServer extends Context.Service()("@smthrs/sync/SyncServer") {
35
+ }
36
+ /**
37
+ * Explicit host opt-in for public projection snapshots. Never bind this to raw
38
+ * Journal.latestCheckpoint: executable checkpoints are intentionally unredacted.
39
+ * Providers must select the requested lineage/projection/version, retain a
40
+ * snapshot covering compaction, and return a complete state through its sequence.
41
+ * Run/branch read authorization occurs before this callback. Its output must be
42
+ * safe for every reader authorized for that run; narrower data needs a separate
43
+ * authorization boundary. Missing providers fail closed.
44
+ * @category services
45
+ * @since 1.0.0-rc.0
46
+ */
47
+ export class SnapshotSource extends Context.Service()("@smthrs/sync/SnapshotSource") {
48
+ }
49
+ /**
50
+ * Constructs a closed sync server stub.
51
+ *
52
+ * @category constructors
53
+ * @since 0.1.0
54
+ */
55
+ export const makeNoop = (overrides = {}) => SyncServer.of({
56
+ snapshot: () => Effect.fail(new SyncError({ code: "not_found", message: "Public snapshots are unavailable" })),
57
+ read: Effect.fn("SyncServer.read")(() => Effect.succeed({ entries: [], cursors: [], done: true })),
58
+ subscribe: () => Stream.succeed({ _tag: "Closed", reason: "Sync server is unavailable" }),
59
+ ...overrides
60
+ });
61
+ /**
62
+ * Provides a closed sync server stub.
63
+ *
64
+ * @category layers
65
+ * @since 0.1.0
66
+ */
67
+ export const layerNoop = Layer.succeed(SyncServer, makeNoop());
68
+ /**
69
+ * Projects a journal failure onto the sync boundary.
70
+ *
71
+ * `compacted` is the one journal code that survives the crossing with its own
72
+ * identity. Every other failure is a fault the follower can only report, but a
73
+ * compacted read is a refusal with a documented resume point — the journal
74
+ * hands back the run's compaction floor for exactly that — and flattening it
75
+ * into `unknown` threw the floor away. The client retries only
76
+ * `transport_failed`, so the subscription died and every resubscribe from the
77
+ * same cursors died identically; because a whole-workspace subscription merges
78
+ * per-run reads, one compacted run took down the workspace.
79
+ *
80
+ * The run id comes from the call site rather than the error because the
81
+ * journal error carries only the sequence, and a workspace read fans out over
82
+ * many runs: without it a follower could not tell which cursor to move. The
83
+ * generation comes from the read's own fence for the same reason: a fresh
84
+ * follower recovering a rewound run has no other way to learn it.
85
+ */
86
+ const journalFailure = (runId, generation) => (cause) => cause instanceof Journal.JournalError && cause.code === "compacted" && cause.checkpointSeq !== undefined
87
+ ? new SyncError({
88
+ code: "compacted",
89
+ message: `Run ${runId} is compacted above the requested cursor`,
90
+ cause: causeCode(cause),
91
+ resync: { runId, checkpointSeq: cause.checkpointSeq, ...(generation === undefined ? {} : { generation }) }
92
+ })
93
+ : new SyncError({
94
+ // Constant message, rendered cause. A follower may hold nothing but a
95
+ // branch share link, and the journal's message is the SQLite driver's:
96
+ // it routinely carries SQL text, table and column names, and constraint
97
+ // identifiers. `decodeCapability` already refuses to be a parsing
98
+ // oracle; this is the same rule for the read path. The CODE is a
99
+ // different question from the message: a journal code this boundary
100
+ // also declares crosses as that code, so a shut-down journal is not
101
+ // reported as an unexplained fault.
102
+ code: journalErrorCode(cause),
103
+ message: `Journal read failed for run ${runId}`,
104
+ cause: causeCode(cause)
105
+ });
106
+ /** The one refusal a lapsed credential produces, wherever it is noticed. */
107
+ const expired = new SyncError({
108
+ code: "unauthorized",
109
+ message: "The capability authorizing this subscription has expired"
110
+ });
111
+ const cursorOf = (cursors, runId) => cursors.find((cursor) => cursor.runId === runId)?.afterSeq;
112
+ /**
113
+ * The one shape a live path emits: one entry, with the gap it closes.
114
+ *
115
+ * Every subscription frame carries exactly one entry, so `fromSeq` is not the
116
+ * entry's own sequence — it is one past the last sequence this run SERVED,
117
+ * which is how a follower learns that the journal skipped compacted or
118
+ * filtered sequences instead of losing them. The run-scoped stream and the
119
+ * workspace tail both emit it, so both build it here.
120
+ */
121
+ const frameOf = (runId, generation, previous, entry) => ({
122
+ _tag: "Entries",
123
+ runId,
124
+ generation,
125
+ fromSeq: (previous + 1),
126
+ toSeq: entry.seq,
127
+ entries: [entry]
128
+ });
129
+ /**
130
+ * Largest number of journal reads one workspace subscription keeps open at
131
+ * once.
132
+ *
133
+ * A workspace subscription serves every run it covers, and every open read is
134
+ * a cursor plus a page buffer. Without a bound the cost of one follower is the
135
+ * size of the workspace: a thousand-run workspace opened a thousand journal
136
+ * streams the moment a subscription started, whether or not the follower ever
137
+ * read from them, and a follower that stalled held all of them. Sixty-four
138
+ * bounds what one follower costs without bounding what it sees: the workspace
139
+ * tail visits every covered run each round, so a run past the bound waits for
140
+ * a slot for the length of one round rather than being starved.
141
+ *
142
+ * @category constants
143
+ * @since 0.1.0
144
+ */
145
+ export const defaultConcurrency = 64;
146
+ /**
147
+ * How long a workspace subscription waits before re-reading the runs it
148
+ * covers, when nothing announces a change, in milliseconds.
149
+ *
150
+ * A workspace tail is a fan-in over many runs, and the runs another engine
151
+ * process owns reach this process only through the database. rc.0 has no
152
+ * cross-process wake, so the interval is the whole of the freshness policy for
153
+ * them: a follower sees an entry within one interval of it being durable. A
154
+ * second matches the rest of the rc.0 posture — the heartbeat sweep, the
155
+ * cancel poll, and {@link RunCatalog.defaultPollIntervalMs}.
156
+ *
157
+ * It is not what an in-process append waits for. An entry this process commits
158
+ * is published on `Journal.changes`, and a workspace subscription that covers
159
+ * its run wakes the round on it, so a follower beside the writer sees it at
160
+ * once rather than at the next tick. A run-scoped subscription is likewise
161
+ * unaffected: it follows one journal stream directly and keeps that stream's
162
+ * wake.
163
+ *
164
+ * @category constants
165
+ * @since 0.1.0
166
+ */
167
+ export const defaultTailIntervalMs = 1000;
168
+ /**
169
+ * Entries one workspace tail read pulls per run per round. A round reads at
170
+ * most `concurrency` pages at once, so this is the page half of the memory one
171
+ * follower costs; a run with more unserved entries than this wakes the next
172
+ * round and is served by it, rather than holding its slot until it catches up.
173
+ */
174
+ const tailBatchSize = 256;
175
+ const defaults = {
176
+ maxFrameBytes: SyncProtocol.defaultMaxFrameBytes,
177
+ concurrency: defaultConcurrency,
178
+ tailIntervalMs: defaultTailIntervalMs
179
+ };
180
+ /**
181
+ * The read path over an already-validated policy.
182
+ *
183
+ * A read SHARES its page across the runs it covers and stops at the first of
184
+ * three bounds: the request's `limit`, the frame ceiling, or the durable tail
185
+ * of every covered run, which is the only case that reports `done: true`.
186
+ * Every covered run takes a share of the budget before any run takes a second
187
+ * helping, and what the shares leave unspent is offered back in run order, so
188
+ * a run with a backlog still takes the larger part of a page but never all of
189
+ * it. Filling in run order instead let a producer that stayed one page ahead
190
+ * take every slot of every page: `done` never became true, so a bootstrapping
191
+ * follower never reached the runs behind it and never reached the live follow
192
+ * either. The frame ceiling is a page budget, not a verdict on the read:
193
+ * entries are served until the next one would cross it, and the page then
194
+ * reports `done: false` so the follower asks for the rest. Only a SINGLE
195
+ * entry whose own encoded size exceeds the ceiling is refused with
196
+ * `frame_too_large`, because no page can ever carry it.
197
+ *
198
+ * A workspace subscription's fan-out is bounded without bounding what it
199
+ * serves. Each round reconciles the runs it covers against `RunCatalog.list`,
200
+ * then reads ONE bounded page per run, at most `Options.concurrency` reads
201
+ * open at once. A run with more waiting wakes the next round rather than
202
+ * holding its slot, so no run is starved by a run that stays busy. A round
203
+ * repeats on a journal entry committed in this process, on a catalog
204
+ * announcement, on a run that reported more, or after
205
+ * `Options.tailIntervalMs`. A wake for an entry committed in this process
206
+ * reads only the runs that entry named; a round that re-lists the catalog
207
+ * reads every covered run, so the bound caps what is open at once while a
208
+ * round's work and the positions a follower keeps grow with the runs it
209
+ * covers. The interval is the freshness policy for the runs another process
210
+ * owns, not for the runs written beside the follower. A run-scoped
211
+ * subscription follows that one run's journal stream directly and keeps its
212
+ * in-process wake.
213
+ *
214
+ * Authorization is fail-closed along two boundaries, both consulted per
215
+ * request:
216
+ *
217
+ * - Branch runs: a run whose id maps to a shared branch is visible only when
218
+ * the request's share capability verifies for that branch. An explicitly
219
+ * scoped branch read without one fails; a workspace listing excludes branch
220
+ * runs the caller's capability does not cover, so one share link never
221
+ * leaks another branch's log. Without a {@link BranchShare} in scope every
222
+ * branch run is closed.
223
+ * - Non-branch runs: visible only to the workspace principal
224
+ * (`SyncPrincipal`), whose default is anonymous. A workspace-scoped
225
+ * request and a run-scoped request for a non-branch run are both refused
226
+ * for anonymous callers, so a connection with no credential — or with only
227
+ * a branch share link — can never read engine runs.
228
+ *
229
+ * A SUBSCRIPTION is additionally bounded in time: it ends with `unauthorized`
230
+ * when the credential that opened it expires, because a stream authorized
231
+ * once at open is otherwise the one thing a signed expiry cannot revoke.
232
+ */
233
+ const makeWith = ({ concurrency, maxFrameBytes, tailIntervalMs }) => Effect.gen(function* () {
234
+ const journal = yield* Journal.Journal;
235
+ const catalog = yield* RunCatalog.RunCatalog;
236
+ const share = yield* Effect.serviceOption(BranchShare.BranchShare);
237
+ const snapshots = yield* Effect.serviceOption(SnapshotSource);
238
+ /**
239
+ * Refuses ONE entry whose own encoded size outgrows the ceiling.
240
+ *
241
+ * This is the only shape the ceiling refuses. A single entry above it can
242
+ * never be served in any page, so paging around it would spin; a page that
243
+ * merely SUMS past it is ordinary traffic and is truncated instead (see
244
+ * {@link read}).
245
+ */
246
+ const frameTooLarge = (bytes) => Effect.fail(new SyncError({
247
+ code: "frame_too_large",
248
+ message: `Encoded entries of ${bytes} bytes exceed the ${maxFrameBytes}-byte frame ceiling`
249
+ }));
250
+ /**
251
+ * Chunk-level admission for the live path. Each subscription frame
252
+ * carries one entry, so the per-frame ceiling is a per-entry ceiling —
253
+ * but the check runs once per journal chunk, not once per entry, because
254
+ * a per-element effect hop de-chunks the stream and multiplies the cost
255
+ * of every downstream combinator on the hot follow path.
256
+ */
257
+ const guardEntryChunk = (chunk) => {
258
+ for (const entry of chunk) {
259
+ const bytes = SyncProtocol.encodedByteLength(entry);
260
+ if (bytes > maxFrameBytes)
261
+ return frameTooLarge(bytes);
262
+ }
263
+ return Effect.void;
264
+ };
265
+ /**
266
+ * The catalog's run set: deduplicated, then ordered.
267
+ *
268
+ * `RunCatalog` is a host seam whose `list` is typed `ReadonlyArray<RunId>`,
269
+ * and an array is not a set. Both fan-out paths key a run's served
270
+ * position by run id, so a run named twice is read twice from the same
271
+ * position and its entries are served twice — inside one read page, and as
272
+ * two concurrent tails emitting identical frames. This is the one place
273
+ * that can rule it out for every path below, so it does.
274
+ */
275
+ const covered = Effect.map(catalog.list, (ids) => Array.from(new Set(ids)).sort());
276
+ /**
277
+ * The workspace principal's expiry, or a refusal.
278
+ *
279
+ * It answers with the expiry rather than with nothing because the two
280
+ * questions have one answer: a request that may read the workspace may
281
+ * read it until its credential stops authorizing. An in-process owner
282
+ * presented no credential and answers `Infinity`.
283
+ */
284
+ const requireWorkspace = Effect.flatMap(Effect.service(SyncPrincipal.SyncPrincipal), (principal) => SyncPrincipal.isWorkspace(principal) ? Effect.succeed(principal.expiresAtMs) : Effect.fail(new SyncError({
285
+ code: "unauthorized",
286
+ message: "Reading workspace runs requires an authenticated workspace principal"
287
+ })));
288
+ /**
289
+ * The verified claims a branch read is granted by, or `null` when the
290
+ * request holds no capability for that branch. Only an `unauthorized`
291
+ * refusal folds to `null`; an infrastructure fault (Web Crypto rejecting
292
+ * the HMAC) propagates, so "the signer is broken" is never reported as
293
+ * "not authorized".
294
+ *
295
+ * The CLAIMS are returned, not a boolean: `expiresAtMs` is what bounds a
296
+ * subscription opened against this branch, and reducing the answer to
297
+ * "yes" threw it away.
298
+ */
299
+ const branchClaims = (branchId, capability) => Option.isNone(share) || capability === undefined
300
+ ? Effect.succeed(null)
301
+ : share.value.verify(capability, { branchId, access: "read" }).pipe(Effect.map((claims) => claims), Effect.catch((error) => error.code === "unauthorized" ? Effect.succeed(null) : Effect.fail(error)));
302
+ /**
303
+ * When this request stops being allowed to follow one catalog-advertised
304
+ * run, or `null` when it may not follow it at all.
305
+ *
306
+ * The answer is the EXPIRY, not a yes. A workspace subscription discovers
307
+ * runs after it opens, and a branch run discovered then is admitted under
308
+ * a capability whose expiry was not part of the subscription's deadline:
309
+ * reducing this to a boolean let a branch found by reconciliation stream
310
+ * for as long as the subscription lived, which is exactly the revocation
311
+ * hole the deadline exists to close. A non-branch run answers `Infinity`
312
+ * because the workspace principal's own expiry already bounds the
313
+ * subscription.
314
+ */
315
+ const followUntil = (runId, capability) => {
316
+ const branchId = branchOfRunId(runId);
317
+ return branchId === null
318
+ // A NON-branch run is admitted by the workspace principal, and only a
319
+ // workspace-scoped subscription reaches here: `runIdsFor` refused the
320
+ // request outright if the principal was anything else, and a
321
+ // principal is fixed for the life of a request. Its expiry is already
322
+ // the deadline this subscription opened with.
323
+ ? Effect.succeed(Number.POSITIVE_INFINITY)
324
+ : Effect.map(branchClaims(branchId, capability), (claims) => claims === null ? null : claims.expiresAtMs);
325
+ };
326
+ /**
327
+ * The runs a request may observe, and when the credential that admits them
328
+ * stops doing so. A run-scoped request the caller is not authorized for
329
+ * fails outright — a scoped read must never silently answer with an empty
330
+ * or partial view — and a workspace-scoped request is only answered for
331
+ * the workspace principal at all.
332
+ */
333
+ const runIdsFor = (scope, capability) => scope._tag === "Run"
334
+ ? Effect.gen(function* () {
335
+ const branchId = branchOfRunId(scope.runId);
336
+ if (branchId === null) {
337
+ return { runIds: [scope.runId], expiresAtMs: yield* requireWorkspace };
338
+ }
339
+ const claims = yield* branchClaims(branchId, capability);
340
+ if (claims === null) {
341
+ return yield* Effect.fail(new SyncError({
342
+ code: "unauthorized",
343
+ message: "Reading a shared branch requires a valid share capability"
344
+ }));
345
+ }
346
+ return { runIds: [scope.runId], expiresAtMs: claims.expiresAtMs };
347
+ })
348
+ : Effect.gen(function* () {
349
+ // The soonest expiry of every credential this view rests on: the
350
+ // workspace principal's, plus each branch capability that admitted
351
+ // a branch run into it.
352
+ let expiresAtMs = yield* requireWorkspace;
353
+ const runIds = yield* covered;
354
+ const visible = [];
355
+ for (const runId of runIds) {
356
+ const branchId = branchOfRunId(runId);
357
+ if (branchId === null) {
358
+ visible.push(runId);
359
+ continue;
360
+ }
361
+ const claims = yield* branchClaims(branchId, capability);
362
+ if (claims === null)
363
+ continue;
364
+ expiresAtMs = Math.min(expiresAtMs, claims.expiresAtMs);
365
+ visible.push(runId);
366
+ }
367
+ return { runIds: visible, expiresAtMs };
368
+ });
369
+ /**
370
+ * Refuses a cursor set that names one run twice.
371
+ *
372
+ * The read position came from the FIRST occurrence and the echoed response
373
+ * state from the LAST, so a request carrying `[{r,0},{r,2}]` read after 0
374
+ * and answered with 2: a follower that persisted the returned cursors, as
375
+ * the protocol tells it to, skipped entries the page never carried. There
376
+ * is no correct choice between the two readings, so neither is made.
377
+ */
378
+ const requireUniqueCursors = (cursors) => {
379
+ const duplicate = SyncProtocol.duplicateCursorRunId(cursors);
380
+ return duplicate === undefined ? Effect.void : Effect.fail(new SyncError({
381
+ code: "invalid_request",
382
+ message: `Cursors name run ${duplicate} more than once`
383
+ }));
384
+ };
385
+ // Compare around every durable read as well as while idle: a rewind can
386
+ // happen in another process and can reuse every sequence in the next page.
387
+ const generationOf = (runId, expected) => Effect.gen(function* () {
388
+ const current = journal.generation === undefined
389
+ ? { generation: 0, afterSeq: -1 }
390
+ : yield* journal.generation(runId).pipe(Effect.mapError(journalFailure(runId)));
391
+ if (expected !== undefined && current.generation !== expected) {
392
+ return yield* Effect.fail(new SyncError({
393
+ code: "lineage_changed",
394
+ message: `Run ${runId} was rewound; rebuild from the archive boundary`,
395
+ rewind: { runId, ...current }
396
+ }));
397
+ }
398
+ return current.generation;
399
+ });
400
+ /**
401
+ * One generation-fenced page of one run's unserved entries.
402
+ *
403
+ * This is the whole of the rewind invariant for both paged paths: the
404
+ * generation is compared BEFORE the read, so a cursor from a superseded
405
+ * lineage is refused rather than served, and again AFTER it, so a rewind
406
+ * that lands mid-read cannot deliver the replacement history's sequences
407
+ * as if they continued the old one. The two comparisons only mean
408
+ * anything together, so they live in one place rather than at each call
409
+ * site. The page read and the paged read (see {@link read} and the
410
+ * workspace tail in {@link workspaceStream}) differ in nothing but the
411
+ * page size and where they keep the expected generation.
412
+ */
413
+ const readPage = (runId, after, expectedGeneration, limit) => Effect.gen(function* () {
414
+ const generation = yield* generationOf(runId, expectedGeneration);
415
+ const page = yield* journal.entries({
416
+ runId,
417
+ ...(after === undefined ? {} : { after }),
418
+ limit
419
+ }).pipe(Effect.mapError(journalFailure(runId, generation)));
420
+ yield* generationOf(runId, generation);
421
+ const admitted = yield* Admission.entries(page.entries, runId, after ?? -1);
422
+ return { admitted, generation, hasMore: page.hasMore };
423
+ });
424
+ const snapshot = (input) => Effect.gen(function* () {
425
+ const request = yield* SnapshotBoundary.request(input);
426
+ const { expiresAtMs } = yield* runIdsFor({ _tag: "Run", runId: request.runId }, request.capability);
427
+ if (expiresAtMs <= (yield* Clock.currentTimeMillis))
428
+ return yield* Effect.fail(expired);
429
+ if (Option.isNone(snapshots)) {
430
+ return yield* Effect.fail(new SyncError({ code: "not_found", message: "Public snapshots are unavailable" }));
431
+ }
432
+ const supplied = yield* Effect.suspend(() => snapshots.value.read({ ...request })).pipe(Effect.catchCause((cause) => Cause.hasInterrupts(cause) ? Effect.interrupt : Effect.logWarning("The public snapshot provider failed", cause).pipe(Effect.andThen(Effect.fail(new SyncError({ code: "not_found", message: "Public snapshot is unavailable" }))))));
433
+ // A slow provider may finish after the credential that admitted it expires.
434
+ if (expiresAtMs <= (yield* Clock.currentTimeMillis))
435
+ return yield* Effect.fail(expired);
436
+ return yield* SnapshotBoundary.response(request, supplied, maxFrameBytes);
437
+ });
438
+ const read = (input) => Effect.gen(function* () {
439
+ const request = {
440
+ ...input,
441
+ ...yield* Admission.decode(SyncProtocol.RequestEnvelope, input, "invalid_request")
442
+ };
443
+ yield* requireVersion(request.protocolVersion);
444
+ yield* requireUniqueCursors(request.cursors);
445
+ // The schema bounds `limit` at the wire; this bounds it again for an
446
+ // in-process caller that constructed the request directly, so no path
447
+ // reaches the journal with an unbounded page size. BOTH halves of that
448
+ // bound are re-applied, not just the ceiling: clamping alone let a
449
+ // `NaN` through — `Math.min` propagates it, `entries.length >= NaN` is
450
+ // false so no loop ever stopped, and the value arrived at
451
+ // `journal.entries` as the page size once per covered run — and let a
452
+ // zero or a negative ask the journal for a page it cannot serve.
453
+ // `subscribe` has floored `credit` since it was written; this is the
454
+ // same floor on the same kind of value.
455
+ const limit = yield* requestCount("A read's limit", request.limit, SyncProtocol.maxReadLimit);
456
+ const { runIds } = yield* runIdsFor(request.scope, request.capability);
457
+ const entries = [];
458
+ const cursors = new Map(request.cursors.map((cursor) => [cursor.runId, cursor.afterSeq]));
459
+ const generations = new Map(request.cursors.map((cursor) => [cursor.runId, cursor.generation ?? 0]));
460
+ let frameBytes = 0;
461
+ // Runs that still had more when their share ran out, in the order the
462
+ // page visited them. They get the budget the page did not spend.
463
+ const behind = [];
464
+ /**
465
+ * Serves at most `cap` of one run's unserved entries into the page.
466
+ *
467
+ * The byte ceiling is a PAGE budget, not a verdict on the read. A run
468
+ * whose next unserved entries sum past 2 MiB is ordinary — branch
469
+ * commands are admitted individually up to 1 MiB, so three
470
+ * 800 KB commands produce it — and failing the read for it wedged the
471
+ * follower forever: `frame_too_large` is neither retried nor
472
+ * retryable, and the client's next bootstrap carries the same cursors
473
+ * and gets the same refusal. So the page stops at the budget and
474
+ * reports `done: false`; only an entry that alone outgrows the
475
+ * ceiling still refuses, because no page can ever carry it.
476
+ */
477
+ const serve = (runId, cap) => Effect.gen(function* () {
478
+ const after = cursors.get(runId);
479
+ const { admitted, generation, hasMore } = yield* readPage(runId, after, generations.get(runId), cap);
480
+ generations.set(runId, generation);
481
+ for (const accepted of admitted) {
482
+ const bytes = SyncProtocol.encodedByteLength(accepted);
483
+ if (bytes > maxFrameBytes)
484
+ return { _tag: "oversized", bytes };
485
+ if (frameBytes + bytes > maxFrameBytes)
486
+ return { _tag: "truncated" };
487
+ frameBytes += bytes;
488
+ entries.push(accepted);
489
+ // Cursors track what was SERVED, not what was read, so the next
490
+ // page resumes at the first entry this one dropped: no entry is
491
+ // skipped and none is served twice.
492
+ cursors.set(runId, accepted.seq);
493
+ }
494
+ return hasMore ? { _tag: "more" } : { _tag: "drained" };
495
+ });
496
+ /**
497
+ * Serves runs in the given order until one of them ends the page.
498
+ *
499
+ * Returns what ended it, or `undefined` when the pass reached the end
500
+ * of its runs with the page still open. A full page ends the pass but
501
+ * is not an outcome of any one run, so it reads as `undefined` too and
502
+ * is answered by `entries.length` below.
503
+ *
504
+ * `more` collects the runs that still had entries left. It is an
505
+ * argument rather than the return value because the second pass reads
506
+ * the list the first one filled, and appending to the array being
507
+ * iterated would serve a run twice in one page.
508
+ */
509
+ const pass = (runs, cap, more) => Effect.gen(function* () {
510
+ for (const runId of runs) {
511
+ if (entries.length >= limit)
512
+ return undefined;
513
+ const outcome = yield* serve(runId, cap());
514
+ if (outcome._tag === "more")
515
+ more.push(runId);
516
+ if (outcome._tag === "truncated" || outcome._tag === "oversized")
517
+ return outcome;
518
+ }
519
+ return undefined;
520
+ });
521
+ // Serve the least advanced cursors first, with stable run-id ties.
522
+ // A page may have fewer slots than runs, or exhaust its byte budget
523
+ // mid-share. Restarting in catalog order then starved later runs on
524
+ // EVERY page. Served cursors strictly advance; pending ones keep
525
+ // their priority until reached. Empty runs spend no budget. This is
526
+ // scheduling only, not a comparison of events across runs, and works
527
+ // even when clients reorder cursors or requests hit another server.
528
+ const pending = [...runIds].sort((left, right) => (cursors.get(left) ?? -1) - (cursors.get(right) ?? -1));
529
+ const share = Math.max(1, Math.floor(limit / Math.max(runIds.length, 1)));
530
+ const shares = yield* pass(pending, () => Math.min(share, limit - entries.length), behind);
531
+ // The budget the shares did not spend, offered back in the same order.
532
+ // What this pass leaves behind is discarded: one run reported more, so
533
+ // the page is already unfinished however much of it this pass drains.
534
+ const stop = shares ?? (yield* pass(behind, () => limit - entries.length, []));
535
+ if (stop?._tag === "oversized")
536
+ return yield* frameTooLarge(stop.bytes);
537
+ return {
538
+ entries,
539
+ cursors: Array.from(cursors, ([runId, afterSeq]) => ({
540
+ runId,
541
+ afterSeq,
542
+ generation: generations.get(runId)
543
+ })),
544
+ // A run that reported more keeps the read unfinished even when the
545
+ // second pass then drained it: the page is a snapshot of a journal
546
+ // that is still being written, and only an untruncated pass over
547
+ // runs that all reported drained can promise there is nothing left.
548
+ done: stop === undefined && behind.length === 0 && entries.length < limit
549
+ };
550
+ });
551
+ const runStream = (runId, cursors) => Stream.unwrap(Effect.gen(function* () {
552
+ const supplied = cursors.find((cursor) => cursor.runId === runId);
553
+ const after = supplied?.afterSeq;
554
+ const generation = yield* generationOf(runId, supplied === undefined ? undefined : supplied.generation ?? 0);
555
+ let admitted = after ?? -1;
556
+ const entries = journal.stream({ runId, ...(after === undefined ? {} : { afterSequence: after }) }).pipe(Stream.mapError(journalFailure(runId, generation)), Stream.chunks, Stream.mapEffect((chunk) => Admission.entries(chunk, runId, admitted).pipe(Effect.tap(() => generationOf(runId, generation)), Effect.tap(guardEntryChunk), Effect.map((captured) => {
557
+ for (const entry of captured)
558
+ admitted = entry.seq;
559
+ return captured;
560
+ }))), Stream.flattenArray, Stream.mapAccum(() => after === undefined ? -1 : after, (previous, entry) => [entry.seq, [frameOf(runId, generation, previous, entry)]]));
561
+ if (journal.generation === undefined)
562
+ return entries;
563
+ // Journal.stream can stay silent when its sequence cursor is beyond a
564
+ // rewound head. Poll independently so even an idle follower fails typed.
565
+ const guard = Stream.drain(Stream.fromEffectRepeat(generationOf(runId, generation).pipe(Effect.delay(tailIntervalMs))));
566
+ return Stream.merge(entries, guard, { haltStrategy: "either" });
567
+ }));
568
+ /**
569
+ * The live tail of a whole workspace.
570
+ *
571
+ * A journal run stream is replay-then-follow: it never ends. One such
572
+ * stream per run is therefore not something a bound can be put on and
573
+ * still serve the workspace — a bounded `Stream.flatMap` over streams that
574
+ * never end fills its slots with the first `concurrency` runs and no run
575
+ * behind them is ever attached. The workspace tail inverts that. It reads
576
+ * each covered run's unserved entries as a finite page walk, so the bound
577
+ * limits how many reads are open at once. A catalog-wide round visits
578
+ * every covered run; it runs at open, on a catalog announcement, and once
579
+ * {@link defaultTailIntervalMs} has passed since the last one. A wake for
580
+ * a journal entry committed in this process, or for a run whose page
581
+ * reported more, reads only the runs marked dirty, so a spaced append
582
+ * costs one read and not one per covered run. The bound is what is open
583
+ * at once; a full round's work and the served positions grow with the
584
+ * runs the subscription covers, and freshness of another process's writes
585
+ * is a function of the interval.
586
+ *
587
+ * A run-scoped subscription does not go through here: it follows one
588
+ * journal stream directly (see {@link runStream}) and keeps that stream's
589
+ * in-process wake.
590
+ */
591
+ const workspaceStream = (covering, openedUntil, request) => Stream.unwrap(Effect.gen(function* () {
592
+ // How far each run this subscription has ever covered was served.
593
+ // A run the catalog stops naming keeps its position here and simply
594
+ // stops being read: dropping the position instead would re-serve the
595
+ // run's history from the request's own cursor if the catalog ever
596
+ // named it again, which is the one thing this stream promises not to
597
+ // do. The map is bounded by the runs one subscription sees, which is
598
+ // what it has always been.
599
+ const served = new Map();
600
+ const generations = new Map(request.cursors.map((cursor) => [cursor.runId, cursor.generation ?? 0]));
601
+ // Runs the catalog names that this request may not read. Remembered so
602
+ // one refusal costs one signature check for the life of the
603
+ // subscription rather than one per round per run.
604
+ const excluded = new Set();
605
+ // The runs the NEXT round reads: the catalog's current list, minus
606
+ // what this request may not read. `reconcile` is what moves it.
607
+ let visible = new Set(covering);
608
+ // The soonest expiry of every credential this subscription now rests
609
+ // on. It only ever moves EARLIER: `runIdsFor` seeds it from the
610
+ // credentials the request opened with, and `reconcile` lowers it when
611
+ // it admits a branch run the catalog named later. Each round arms its
612
+ // own deadline from this, so a capability discovered mid-subscription
613
+ // bounds the frames it authorizes just as the opening ones do.
614
+ let deadline = openedUntil;
615
+ // Runs with something known to be waiting: an entry committed in this
616
+ // process, or a page that reported more. A wake for them reads THESE
617
+ // runs and nothing else; the catalog-wide round is what the opening,
618
+ // an announcement, and the interval pay for. A Set coalesces a burst
619
+ // on one run into one read, and it only ever names covered runs, so
620
+ // it is bounded by what `served` is bounded by.
621
+ let dirty = new Set();
622
+ // Set by an announcement: the next wake re-lists the catalog whatever
623
+ // `dirty` holds, since the announced run is not covered yet.
624
+ let announced = false;
625
+ // When the last catalog-wide round began. A wake that arrives once
626
+ // the interval has elapsed since then runs a full round, so local
627
+ // appends faster than the interval cannot defer reconciliation of
628
+ // removals, generation changes, and other processes' writes forever.
629
+ let reconciledAtMs = -Infinity;
630
+ // Published when `reconcile` lowers `deadline`, so the expiry
631
+ // interrupt below re-arms at the earlier moment.
632
+ const lowered = yield* PubSub.sliding(1);
633
+ const loweredSignal = yield* PubSub.subscribe(lowered);
634
+ // Every caller establishes that the run has no position yet:
635
+ // `covering` is a deduplicated list, and `reconcile` skips what it
636
+ // already holds.
637
+ const cover = (runId) => {
638
+ served.set(runId, cursorOf(request.cursors, runId));
639
+ };
640
+ for (const runId of covering)
641
+ cover(runId);
642
+ // Sliding by one: a wake published while a round is running is
643
+ // remembered, and never more than one round's worth of them.
644
+ const wake = yield* PubSub.sliding(1);
645
+ const woken = yield* PubSub.subscribe(wake);
646
+ /**
647
+ * Re-reads the catalog and decides what the next round covers.
648
+ *
649
+ * Trusting the announcement feed alone broke in both directions.
650
+ * A run created between `runIdsFor`'s list and the moment
651
+ * `catalog.changes` is actually pulled is announced to nobody and
652
+ * absent from the initial list, so it stayed invisible for the life
653
+ * of the subscription; both catalogs publish through a SLIDING
654
+ * PubSub, so a subscriber that fell behind lost an announcement
655
+ * permanently. In the other direction nothing published a removal at
656
+ * all, so a retention-collected run was queried once per interval
657
+ * forever. The README already promised the fix: `list` is the
658
+ * authoritative state and a subscriber that loses a notification
659
+ * re-lists.
660
+ */
661
+ const reconcile = Effect.gen(function* () {
662
+ // Deduplicated at the seam: a host catalog that names a run twice
663
+ // would otherwise put two tails of it in one round, both reading
664
+ // the same served position and emitting the same frames.
665
+ const present = new Set(yield* catalog.list);
666
+ for (const runId of excluded) {
667
+ if (!present.has(runId))
668
+ excluded.delete(runId);
669
+ }
670
+ const next = [];
671
+ for (const runId of present) {
672
+ if (excluded.has(runId))
673
+ continue;
674
+ if (!served.has(runId)) {
675
+ // An `unauthorized` run is skipped; a signer fault propagates,
676
+ // so "the signer is broken" is never reported as "not
677
+ // authorized".
678
+ const until = yield* followUntil(runId, request.capability);
679
+ if (until === null) {
680
+ excluded.add(runId);
681
+ continue;
682
+ }
683
+ if (until < deadline) {
684
+ deadline = until;
685
+ yield* PubSub.publish(lowered, undefined);
686
+ }
687
+ cover(runId);
688
+ }
689
+ next.push(runId);
690
+ }
691
+ // The deadline is not checked here: the interrupt below enforces
692
+ // it, and re-arms whenever this round lowered it, so a round that
693
+ // begins under a lapsed credential is ended before it emits.
694
+ reconciledAtMs = yield* Clock.currentTimeMillis;
695
+ visible = new Set(next);
696
+ });
697
+ /**
698
+ * One BOUNDED page of one run's unserved entries, as frames.
699
+ *
700
+ * A run with more waiting does not recurse here; it publishes a wake
701
+ * and returns, so its `flatMap` slot is released and the next round
702
+ * picks it up. Recursing held the slot for as long as the run stayed
703
+ * behind, which meant `concurrency` sustained-hot runs pinned every
704
+ * slot, the round never completed, and every run behind them was
705
+ * never attached at all rather than merely delayed.
706
+ */
707
+ const tail = (runId) => Stream.unwrap(Effect.gen(function* () {
708
+ const after = served.get(runId);
709
+ const { admitted, generation, hasMore } = yield* readPage(runId, after, generations.get(runId), tailBatchSize);
710
+ generations.set(runId, generation);
711
+ yield* guardEntryChunk(admitted);
712
+ const frames = [];
713
+ let previous = after === undefined ? -1 : after;
714
+ for (const accepted of admitted) {
715
+ frames.push(frameOf(runId, generation, previous, accepted));
716
+ previous = accepted.seq;
717
+ }
718
+ const last = admitted.at(-1);
719
+ // An empty page ends the walk whatever the page claims, so a
720
+ // journal that reports more without returning any cannot spin.
721
+ if (last === undefined)
722
+ return Stream.empty;
723
+ served.set(runId, last.seq);
724
+ // A backlog costs another round, not another slot. The wake makes
725
+ // that round start at once rather than at the next interval, and
726
+ // it reads this run and the other dirty ones, not the workspace.
727
+ if (hasMore) {
728
+ dirty.add(runId);
729
+ yield* PubSub.publish(wake, undefined);
730
+ }
731
+ return Stream.fromIterable(frames);
732
+ }));
733
+ /**
734
+ * One pass over every covered run, `concurrency` reads open at most,
735
+ * against the run set the catalog names right now. Anything marked
736
+ * dirty is read by this pass, so the set starts over; a run marked
737
+ * while the pass runs is remembered for the next wake.
738
+ */
739
+ const fullRound = Stream.unwrap(Effect.map(reconcile, () => {
740
+ dirty = new Set();
741
+ announced = false;
742
+ return Stream.flatMap(Stream.fromIterable(visible), tail, { concurrency });
743
+ }));
744
+ /**
745
+ * One pass over the dirty runs only, under the same bound. No catalog
746
+ * read: a dirty run is by construction one this subscription already
747
+ * covers, and a run the catalog has since dropped is skipped.
748
+ */
749
+ const dirtyRound = Stream.suspend(() => {
750
+ const runs = dirty;
751
+ dirty = new Set();
752
+ return Stream.flatMap(Stream.fromIterable(Array.from(runs).filter((runId) => visible.has(runId))), tail, { concurrency });
753
+ });
754
+ /** Which pass a tick pays for. */
755
+ const roundFor = (tick) => Effect.map(Clock.currentTimeMillis, (nowMs) => tick === "interval" || announced || nowMs - reconciledAtMs >= tailIntervalMs
756
+ ? fullRound
757
+ : dirtyRound);
758
+ // An announcement is a WAKE and nothing else: the round that follows
759
+ // reads the catalog itself and authorizes what it finds. Covering the
760
+ // announced run here as well made the notification feed a second,
761
+ // weaker source of truth — one that could never remove a run and that
762
+ // lost a run permanently whenever its sliding feed overflowed.
763
+ // Draining keeps this a control path that emits nothing.
764
+ const announcements = Stream.drain(Stream.tap(catalog.changes, () => Effect.suspend(() => {
765
+ announced = true;
766
+ return PubSub.publish(wake, undefined);
767
+ })));
768
+ // An entry committed in this process marks its run dirty and wakes
769
+ // the pass that reads it. The interval is the freshness policy for
770
+ // the runs another engine process owns, which reach this one only
771
+ // through the database; it must not also be what a follower waits
772
+ // for an entry written beside it. `Journal.changes` is one
773
+ // process-wide sliding feed of every committed entry, so this costs
774
+ // one subscription per workspace subscription — not one per run — and
775
+ // the fan-out bound is unchanged. A run this subscription does not
776
+ // cover is left to the announcement path, which covers it and wakes
777
+ // the round itself. A wake the feed slides away under a burst costs
778
+ // its entry at most one interval, which is what a write from another
779
+ // process already waits.
780
+ const commits = yield* journal.changes;
781
+ const appends = Stream.fromSubscription(commits).pipe(Stream.filter((entry) => served.has(entry.runId)), Stream.tap((entry) => Effect.suspend(() => {
782
+ dirty.add(entry.runId);
783
+ return PubSub.publish(wake, undefined);
784
+ })), Stream.drain);
785
+ const ticks = Stream.fromEffectRepeat(Effect.raceFirst(Effect.as(PubSub.take(woken), "wake"), Effect.as(Effect.sleep(tailIntervalMs), "interval")));
786
+ // The deadline, enforced as it moves: sleeps until it and starts over
787
+ // whenever `reconcile` lowers it, so a branch admitted after the
788
+ // subscription opened ends the stream at ITS expiry — not one round
789
+ // later, and not never, should a round stall in a journal read. An
790
+ // in-process owner opens with no deadline and gains one only through
791
+ // such an admission.
792
+ const untilDeadline = Effect.gen(function* () {
793
+ while (true) {
794
+ const nowMs = yield* Clock.currentTimeMillis;
795
+ if (nowMs >= deadline) {
796
+ return yield* Effect.fail(expired);
797
+ }
798
+ yield* Number.isFinite(deadline)
799
+ ? Effect.raceFirst(Effect.sleep(deadline - nowMs), PubSub.take(loweredSignal))
800
+ : PubSub.take(loweredSignal);
801
+ }
802
+ });
803
+ return Stream.interruptWhen(Stream.merge(Stream.merge(Stream.concat(fullRound, Stream.flatMap(ticks, (tick) => Stream.unwrap(roundFor(tick)))), announcements), appends), untilDeadline);
804
+ }));
805
+ /**
806
+ * Ends a subscription when the credential that opened it stops
807
+ * authorizing.
808
+ *
809
+ * Authorization is one-shot at open, and a journal follow never ends on
810
+ * its own, so without this a holder of an expired capability kept reading
811
+ * for as long as it declined to disconnect. The refusal is typed and
812
+ * carries the same `unauthorized` code a fresh request would be refused
813
+ * with. An in-process owner has no credential and therefore no deadline.
814
+ * A workspace subscription's deadline moves as reconciliation admits
815
+ * branches, so it arms its own (see {@link workspaceStream}); this is
816
+ * the run-scoped subscription's.
817
+ */
818
+ const untilExpiry = (expiresAtMs, stream) => Number.isFinite(expiresAtMs)
819
+ ? Stream.interruptWhen(stream, Effect.flatMap(Clock.currentTimeMillis, (nowMs) => Effect.andThen(Effect.sleep(Math.max(expiresAtMs - nowMs, 0)), Effect.fail(expired))))
820
+ : stream;
821
+ const subscribe = (input) => Stream.unwrap(Effect.gen(function* () {
822
+ const request = {
823
+ ...input,
824
+ ...yield* Admission.decode(SyncProtocol.RequestEnvelope, input, "invalid_request")
825
+ };
826
+ yield* requireVersion(request.protocolVersion);
827
+ yield* requireUniqueCursors(request.cursors);
828
+ // The schema bounds `credit` at the wire; this bounds it again for
829
+ // an in-process caller that constructed the request directly.
830
+ const credit = yield* requestCount("A subscription's credit", request.credit, SyncProtocol.maxSubscribeCredit);
831
+ const { expiresAtMs, runIds } = yield* runIdsFor(request.scope, request.capability);
832
+ const frames = request.scope._tag === "Run"
833
+ ? untilExpiry(expiresAtMs, runStream(request.scope.runId, request.cursors))
834
+ : workspaceStream(runIds, expiresAtMs, request);
835
+ return Stream.take(frames, credit);
836
+ }));
837
+ return SyncServer.of({ snapshot, read, subscribe });
838
+ });
839
+ /**
840
+ * Constructs the workspace sync server with default policy.
841
+ *
842
+ * @category constructors
843
+ * @since 0.1.0
844
+ */
845
+ export const makeLive = makeWith(defaults);
846
+ /**
847
+ * Constructs the workspace sync server over a journal and a run catalog, under
848
+ * an explicit policy.
849
+ *
850
+ * Every option is validated as a positive safe integer at construction, so a
851
+ * policy fails loudly instead of quietly not existing: the TypeScript type
852
+ * says `number`, and `maxFrameBytes: NaN` made every `bytes > maxFrameBytes`
853
+ * comparison false, which disabled the ceiling it configured.
854
+ *
855
+ * @category constructors
856
+ * @since 0.1.0
857
+ */
858
+ export const makeLiveWith = (options = {}) => Effect.flatMap(Effect.all({
859
+ maxFrameBytes: positiveInt("SyncServer.Options.maxFrameBytes", options.maxFrameBytes, defaults.maxFrameBytes),
860
+ concurrency: positiveInt("SyncServer.Options.concurrency", options.concurrency, defaults.concurrency),
861
+ tailIntervalMs: positiveInt("SyncServer.Options.tailIntervalMs", options.tailIntervalMs, defaults.tailIntervalMs)
862
+ }), makeWith);
863
+ /**
864
+ * Provides the workspace sync server.
865
+ *
866
+ * @category layers
867
+ * @since 0.1.0
868
+ */
869
+ export const layer = Layer.effect(SyncServer, makeLive);
870
+ /**
871
+ * Provides the workspace sync server under an explicit policy. Fails with
872
+ * `invalid_request` when an option is not a positive safe integer.
873
+ *
874
+ * @category layers
875
+ * @since 0.1.0
876
+ */
877
+ export const layerWith = (options) => Layer.effect(SyncServer, makeLiveWith(options));
878
+ /**
879
+ * Provides the sync RPC handlers over the sync server: a thin, honest
880
+ * projection of {@link Service} onto the wire, mirroring
881
+ * `BranchServer.layerHandlers`. Serving these handlers requires an
882
+ * implementation of the group's `SyncAuth` middleware — `SyncAuth.layer` is
883
+ * the production one — and without a middleware-installed principal every
884
+ * request runs as anonymous and non-branch reads are refused.
885
+ *
886
+ * @category layers
887
+ * @since 0.1.0
888
+ */
889
+ export const layerHandlers = SyncRpcs.toLayer(Effect.gen(function* () {
890
+ const sync = yield* SyncServer;
891
+ return SyncRpcs.of({
892
+ "Sync.Snapshot": (request) => sync.snapshot(request),
893
+ "Sync.Read": (request) => sync.read(request),
894
+ "Sync.Subscribe": (request) => sync.subscribe(request)
895
+ });
896
+ }));
897
+ //# sourceMappingURL=SyncServer.js.map