@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,308 @@
1
+ /**
2
+ * Workspace run enumeration for synchronization.
3
+ *
4
+ * @since 0.1.0
5
+ */
6
+
7
+ import type { JournalEvent } from "@smthrs/journal"
8
+ import * as Context from "effect/Context"
9
+ import * as Effect from "effect/Effect"
10
+ import * as Layer from "effect/Layer"
11
+ import * as PubSub from "effect/PubSub"
12
+ import * as Ref from "effect/Ref"
13
+ import type * as Scope from "effect/Scope"
14
+ import * as Stream from "effect/Stream"
15
+ import { positiveInt } from "./internal/PolicyOptions.ts"
16
+ import type { SyncError } from "./SyncError.ts"
17
+
18
+ /**
19
+ * Workspace run enumeration operations.
20
+ *
21
+ * `list` is the AUTHORITATIVE run set and `changes` is only a low-latency
22
+ * wake: a subscriber that misses an announcement re-lists rather than losing
23
+ * state, and a subscriber whose covered set must stay accurate re-lists on a
24
+ * cadence of its own. Every implementation returns a fresh array from `list`,
25
+ * so a caller may retain and sort what it receives without disturbing another
26
+ * reader.
27
+ *
28
+ * `list` names each run AT MOST ONCE. Every implementation here deduplicates,
29
+ * and `SyncServer` deduplicates again at the seam rather than trusting a
30
+ * host's catalog, because both fan-out paths key a run's served position by
31
+ * run id: a run named twice is read twice from the same position and its
32
+ * entries are served twice.
33
+ *
34
+ * The catalog is supplied by the host rather than derived from the journal:
35
+ * `@smthrs/journal` has no workspace-wide list or watch contract, and
36
+ * `@smthrs/engine-store` owns the durable run set that {@link makePolling}
37
+ * reads.
38
+ *
39
+ * @category models
40
+ * @since 0.1.0
41
+ */
42
+ export interface Service {
43
+ readonly list: Effect.Effect<ReadonlyArray<JournalEvent.RunId>>
44
+ readonly changes: Stream.Stream<JournalEvent.RunId>
45
+ }
46
+
47
+ /**
48
+ * Host-provided workspace run catalog.
49
+ *
50
+ * @category services
51
+ * @since 0.1.0
52
+ */
53
+ export class RunCatalog extends Context.Service<RunCatalog, Service>()("@smthrs/sync/RunCatalog") {}
54
+
55
+ /**
56
+ * Provides an immutable run catalog.
57
+ *
58
+ * `list` answers with a fresh array each time, matching {@link makeMemory} and
59
+ * {@link makePolling}. Handing every caller the same instance let one reader
60
+ * sort or splice the catalog in place for every other reader, which is a
61
+ * defect a `ReadonlyArray` type hides from TypeScript and not from JavaScript.
62
+ *
63
+ * @category layers
64
+ * @since 0.1.0
65
+ */
66
+ export const layerStatic = (
67
+ ids: ReadonlyArray<JournalEvent.RunId>
68
+ ): Layer.Layer<RunCatalog> => {
69
+ const known = Array.from(new Set(ids))
70
+ return Layer.succeed(
71
+ RunCatalog,
72
+ RunCatalog.of({
73
+ list: Effect.sync(() => [...known]),
74
+ changes: Stream.empty
75
+ })
76
+ )
77
+ }
78
+
79
+ /**
80
+ * Announcements a stalled {@link Service.changes} subscriber may fall behind
81
+ * by before the oldest are dropped.
82
+ *
83
+ * `changes` is a notification feed, not a log: a follower reads it to learn
84
+ * that the workspace gained a run and then reads the run itself. Retaining an
85
+ * unbounded backlog for a subscriber that has stopped pulling — a disconnected
86
+ * tab whose socket has not timed out yet, a follower blocked behind a slow
87
+ * consumer — is therefore pure cost, and the workspace's whole run history is
88
+ * the upper bound on it. A subscriber that falls further behind than this
89
+ * bound loses the oldest announcements; `list` still names every run, so a
90
+ * follower that reconnects or re-lists converges.
91
+ *
92
+ * @category constants
93
+ * @since 0.1.0
94
+ */
95
+ export const defaultChangesCapacity = 1024
96
+
97
+ /**
98
+ * Options for the in-memory run catalog.
99
+ *
100
+ * @category models
101
+ * @since 0.1.0
102
+ */
103
+ export interface MemoryOptions {
104
+ /**
105
+ * Announcements a stalled subscriber may fall behind by. Defaults to
106
+ * {@link defaultChangesCapacity}.
107
+ */
108
+ readonly changesCapacity?: number | undefined
109
+ }
110
+
111
+ /**
112
+ * Constructs a mutable in-memory run catalog.
113
+ *
114
+ * A run is published exactly once, when it is first registered. The
115
+ * announcement feed slides: registering never waits on a stalled subscriber,
116
+ * and never grows the process on its behalf.
117
+ *
118
+ * Fails with `invalid_request` when `changesCapacity` is not a positive safe
119
+ * integer, rather than handing it to `PubSub.sliding` as a capacity.
120
+ *
121
+ * @category constructors
122
+ * @since 0.1.0
123
+ */
124
+ export const makeMemory = (options: MemoryOptions = {}): Effect.Effect<{
125
+ readonly catalog: Service
126
+ readonly register: (runId: JournalEvent.RunId) => Effect.Effect<void>
127
+ }, SyncError> =>
128
+ Effect.gen(function*() {
129
+ const known = new Set<JournalEvent.RunId>()
130
+ const changes = yield* PubSub.sliding<JournalEvent.RunId>(
131
+ yield* positiveInt("RunCatalog.MemoryOptions.changesCapacity", options.changesCapacity, defaultChangesCapacity)
132
+ )
133
+ return {
134
+ catalog: RunCatalog.of({
135
+ list: Effect.sync(() => Array.from(known)).pipe(Effect.withSpan("RunCatalog.list")),
136
+ changes: Stream.fromPubSub(changes)
137
+ }),
138
+ register: Effect.fn("RunCatalog.register")((runId) =>
139
+ Effect.suspend(() => {
140
+ if (known.has(runId)) return Effect.void
141
+ known.add(runId)
142
+ return PubSub.publish(changes, runId).pipe(Effect.asVoid)
143
+ })
144
+ )
145
+ }
146
+ })
147
+
148
+ /**
149
+ * How often {@link makePolling} re-reads its source when the caller names no
150
+ * interval, in milliseconds.
151
+ *
152
+ * A workspace follower learns of a new run within one interval of it being
153
+ * created, and the read costs one bounded query per interval per composition,
154
+ * not per subscriber. A second is short enough that a run appears in a UI
155
+ * while the operator is still looking at the command that started it, and
156
+ * long enough that an idle workspace is close to free.
157
+ *
158
+ * @category constants
159
+ * @since 0.1.0
160
+ */
161
+ export const defaultPollIntervalMs = 1000
162
+
163
+ /**
164
+ * Options for the polling run catalog.
165
+ *
166
+ * @category models
167
+ * @since 0.1.0
168
+ */
169
+ export interface PollingOptions<E, R> {
170
+ /**
171
+ * Reads the workspace's current run set. `@smthrs/engine-store` supplies
172
+ * the durable one over `flows_runs`; anything that answers the same
173
+ * question works, and the catalog holds no assumption about which.
174
+ */
175
+ readonly read: Effect.Effect<ReadonlyArray<JournalEvent.RunId>, E, R>
176
+ /** Milliseconds between reads. Defaults to {@link defaultPollIntervalMs}. */
177
+ readonly intervalMs?: number | undefined
178
+ /**
179
+ * Announcements a stalled subscriber may fall behind by. Defaults to
180
+ * {@link defaultChangesCapacity}.
181
+ */
182
+ readonly changesCapacity?: number | undefined
183
+ }
184
+
185
+ /**
186
+ * Constructs a run catalog that polls a durable source.
187
+ *
188
+ * Polling, not waking. rc.0 has no cross-process event delivery, so a
189
+ * follower learns of a run another engine created by asking the workspace
190
+ * again, and the interval is the whole of the policy.
191
+ *
192
+ * The catalog primes itself before it returns, so the first read a follower
193
+ * makes is already current and a workspace that cannot be read at all fails
194
+ * the composition instead of serving an empty run set. After that a failed
195
+ * read is a warning and nothing else: the previous view stands, the interval
196
+ * holds, and no subscription attached to the catalog is torn down for it.
197
+ * The warning is written once per outage, with the first failure's cause, and
198
+ * one info line marks the read that ends it; the polls in between are silent.
199
+ *
200
+ * A run is announced once, when it first appears. A run the read stops
201
+ * naming, which is what retention collecting it looks like, leaves the view;
202
+ * `list` is the workspace's run set, not a log of every run it ever had.
203
+ *
204
+ * `list` answers with a fresh array each time, matching {@link layerStatic} and
205
+ * {@link makeMemory}. Handing back the stored snapshot let a caller sort or
206
+ * splice the authoritative run set in place under the poll fiber that owns it,
207
+ * which is a defect a `ReadonlyArray` type hides from TypeScript and not from
208
+ * JavaScript.
209
+ *
210
+ * The poll fiber belongs to the caller's scope. Closing the scope stops it.
211
+ *
212
+ * Both numeric options are validated here, before the first read: an
213
+ * `intervalMs` of `NaN` or zero reached `Effect.delay` as the whole of the
214
+ * poll cadence, which is a loop with no interval rather than a refusal.
215
+ *
216
+ * @category constructors
217
+ * @since 0.1.0
218
+ */
219
+ export const makePolling = <E, R>(
220
+ options: PollingOptions<E, R>
221
+ ): Effect.Effect<Service, E | SyncError, R | Scope.Scope> =>
222
+ Effect.gen(function*() {
223
+ const intervalMs = yield* positiveInt(
224
+ "RunCatalog.PollingOptions.intervalMs",
225
+ options.intervalMs,
226
+ defaultPollIntervalMs
227
+ )
228
+ const changes = yield* PubSub.sliding<JournalEvent.RunId>(
229
+ yield* positiveInt("RunCatalog.PollingOptions.changesCapacity", options.changesCapacity, defaultChangesCapacity)
230
+ )
231
+ const snapshot = yield* Ref.make<ReadonlyArray<JournalEvent.RunId>>([])
232
+
233
+ const refresh = Effect.gen(function*() {
234
+ const read = yield* options.read
235
+ const next = Array.from(new Set(read))
236
+ const previous = new Set(yield* Ref.get(snapshot))
237
+ yield* Ref.set(snapshot, next)
238
+ for (const runId of next) {
239
+ // Publishing never waits on a stalled subscriber, and never grows the
240
+ // process on its behalf: the feed slides.
241
+ if (!previous.has(runId)) yield* PubSub.publish(changes, runId)
242
+ }
243
+ })
244
+
245
+ yield* refresh
246
+ // The log follows the outage's transitions, not the poll cadence. With
247
+ // the default interval a workspace that stays unreadable would otherwise
248
+ // write one identical warning per second per catalog for as long as it
249
+ // lasts, and the line that says it ended would never be written.
250
+ const consecutiveFailures = yield* Ref.make(0)
251
+ const poll = refresh.pipe(
252
+ Effect.matchCauseEffect({
253
+ onFailure: (cause) =>
254
+ Effect.flatMap(Ref.updateAndGet(consecutiveFailures, (count) => count + 1), (count) =>
255
+ count === 1
256
+ ? Effect.logWarning("The workspace run catalog could not be read; the previous view stands", cause)
257
+ : Effect.void),
258
+ onSuccess: () =>
259
+ Effect.flatMap(Ref.getAndSet(consecutiveFailures, 0), (count) =>
260
+ count === 0
261
+ ? Effect.void
262
+ : Effect.logInfo(`The workspace run catalog is readable again after ${count} failed reads`))
263
+ })
264
+ )
265
+ yield* poll.pipe(
266
+ Effect.delay(intervalMs),
267
+ Effect.forever,
268
+ Effect.forkScoped
269
+ )
270
+
271
+ return RunCatalog.of({
272
+ list: Effect.map(Ref.get(snapshot), (ids) => [...ids]).pipe(Effect.withSpan("RunCatalog.list")),
273
+ changes: Stream.fromPubSub(changes)
274
+ })
275
+ })
276
+
277
+ /**
278
+ * Provides a run catalog that polls a durable source. The poll fiber lives as
279
+ * long as the layer's scope.
280
+ *
281
+ * @category layers
282
+ * @since 0.1.0
283
+ */
284
+ export const layerPolling = <E, R>(
285
+ options: PollingOptions<E, R>
286
+ ): Layer.Layer<RunCatalog, E | SyncError, R> => Layer.effect(RunCatalog, makePolling(options))
287
+
288
+ /**
289
+ * Provides the mutable in-memory run catalog, matching how every other
290
+ * implementation here is provided. Fails with `invalid_request` when
291
+ * `changesCapacity` is not a positive safe integer.
292
+ *
293
+ * The layer provides the catalog alone. A composition that also registers runs
294
+ * builds {@link makeMemory} itself and keeps its `register`.
295
+ *
296
+ * @category layers
297
+ * @since 1.0.0-rc.0
298
+ */
299
+ export const layerMemory = (options: MemoryOptions = {}): Layer.Layer<RunCatalog, SyncError> =>
300
+ Layer.effect(RunCatalog, Effect.map(makeMemory(options), ({ catalog }) => catalog))
301
+
302
+ /**
303
+ * Provides an empty run catalog.
304
+ *
305
+ * @category layers
306
+ * @since 0.1.0
307
+ */
308
+ export const layerNoop: Layer.Layer<RunCatalog> = layerStatic([])
@@ -0,0 +1,146 @@
1
+ /**
2
+ * The sync read path's connection authentication: implementations of the
3
+ * `SyncRpcs.SyncAuth` RPC middleware.
4
+ *
5
+ * A connection presents a {@link WorkspaceShare.WorkspaceCapability} in the
6
+ * {@link capabilityHeader} request header. {@link layer} verifies it against
7
+ * the workspace's {@link WorkspaceShare.WorkspaceShare} authority and runs
8
+ * the handler as the workspace principal. A request with no header runs as
9
+ * the anonymous principal — branch share capabilities carried in request
10
+ * payloads still authorize branch reads — and the server refuses anonymous
11
+ * access to every non-branch run. A request whose header is present but
12
+ * malformed, forged, expired, or signed by an unknown key is refused
13
+ * outright: presence of a credential is a claim, and a false claim is never
14
+ * downgraded to anonymity.
15
+ *
16
+ * There is deliberately no trusted pass-through implementation here: the only
17
+ * sanctioned bypass is an in-process caller providing `SyncPrincipal` itself,
18
+ * never a transport.
19
+ *
20
+ * @since 0.1.0
21
+ */
22
+
23
+ import * as Effect from "effect/Effect"
24
+ import * as Encoding from "effect/Encoding"
25
+ import * as Layer from "effect/Layer"
26
+ import * as Result from "effect/Result"
27
+ import * as Schema from "effect/Schema"
28
+ import * as Headers from "effect/unstable/http/Headers"
29
+ import * as RpcMiddleware from "effect/unstable/rpc/RpcMiddleware"
30
+ import { causeText } from "./internal/CauseText.ts"
31
+ import * as shareSigner from "./internal/ShareSigner.ts"
32
+ import { SyncError } from "./SyncError.ts"
33
+ import * as SyncPrincipal from "./SyncPrincipal.ts"
34
+ import { SyncAuth } from "./SyncRpcs.ts"
35
+ import * as WorkspaceShare from "./WorkspaceShare.ts"
36
+
37
+ /**
38
+ * The request header a connection presents its workspace capability in.
39
+ *
40
+ * @category constants
41
+ * @since 0.1.0
42
+ */
43
+ export const capabilityHeader = "flows-sync-workspace"
44
+
45
+ const denied = shareSigner.unauthorized
46
+
47
+ /**
48
+ * Renders a workspace capability as its header value: unpadded base64url of
49
+ * the schema-encoded capability JSON.
50
+ *
51
+ * @category encoding
52
+ * @since 0.1.0
53
+ */
54
+ export const encodeCapability = (
55
+ capability: WorkspaceShare.WorkspaceCapability
56
+ ): Effect.Effect<string, SyncError> => {
57
+ const encoded = Schema.encodeUnknownResult(WorkspaceShare.WorkspaceCapability)(capability)
58
+ return Result.isFailure(encoded)
59
+ ? Effect.fail(
60
+ new SyncError({
61
+ code: "invalid_request",
62
+ message: "The workspace capability does not encode",
63
+ // The rendering, never the parse failure itself: a schema issue
64
+ // carries the value it rejected, and the value here is a credential.
65
+ cause: causeText(encoded.failure)
66
+ })
67
+ )
68
+ : Effect.succeed(Encoding.encodeBase64Url(JSON.stringify(encoded.success)))
69
+ }
70
+
71
+ /**
72
+ * Decodes a capability header value. Every malformation folds to the same
73
+ * `unauthorized` refusal so the header is not a parsing oracle.
74
+ *
75
+ * @category encoding
76
+ * @since 0.1.0
77
+ */
78
+ export const decodeCapability = (value: string): Effect.Effect<WorkspaceShare.WorkspaceCapability, SyncError> => {
79
+ const text = Encoding.decodeBase64UrlString(value)
80
+ if (Result.isFailure(text)) {
81
+ return Effect.fail(denied("The workspace capability header is not a capability"))
82
+ }
83
+ let parsed: unknown
84
+ try {
85
+ parsed = JSON.parse(text.success)
86
+ } catch {
87
+ return Effect.fail(denied("The workspace capability header is not a capability"))
88
+ }
89
+ const decoded = Schema.decodeUnknownResult(WorkspaceShare.WorkspaceCapability)(parsed)
90
+ return Result.isFailure(decoded)
91
+ ? Effect.fail(denied("The workspace capability header is not a capability"))
92
+ : Effect.succeed(decoded.success)
93
+ }
94
+
95
+ /**
96
+ * Provides the workspace-capability authentication middleware: no header
97
+ * runs the handler as anonymous, a verified header runs it as the workspace
98
+ * principal, and anything else refuses the request. Verification faults
99
+ * (Web Crypto failing) propagate as their own error rather than as a
100
+ * refusal.
101
+ *
102
+ * @category layers
103
+ * @since 0.1.0
104
+ */
105
+ export const layer: Layer.Layer<SyncAuth, never, WorkspaceShare.WorkspaceShare> = Layer.effect(
106
+ SyncAuth,
107
+ Effect.gen(function*() {
108
+ const share = yield* WorkspaceShare.WorkspaceShare
109
+ const middleware: RpcMiddleware.RpcMiddleware<never, SyncError, never> = (effect, options) => {
110
+ const header = Headers.get(options.headers, capabilityHeader)
111
+ if (header._tag === "None") {
112
+ return Effect.provideService(effect, SyncPrincipal.SyncPrincipal, SyncPrincipal.anonymous)
113
+ }
114
+ return decodeCapability(header.value).pipe(
115
+ Effect.flatMap((capability) => share.verify(capability, { access: "read" })),
116
+ Effect.flatMap((claims) =>
117
+ Effect.provideService(
118
+ effect,
119
+ SyncPrincipal.SyncPrincipal,
120
+ // The signed expiry travels WITH the identity. A handler that
121
+ // opens a stream authorizes once; reducing the verified claims to
122
+ // a capability id here left the stream with nothing to expire on.
123
+ SyncPrincipal.workspace(claims.capabilityId, claims.expiresAtMs)
124
+ )
125
+ )
126
+ )
127
+ }
128
+ return middleware
129
+ })
130
+ )
131
+
132
+ /**
133
+ * Stamps every outgoing request from a generated RPC client with the given
134
+ * workspace capability.
135
+ *
136
+ * @category layers
137
+ * @since 0.1.0
138
+ */
139
+ export const layerClient = (
140
+ capability: WorkspaceShare.WorkspaceCapability
141
+ ): Layer.Layer<RpcMiddleware.ForClient<SyncAuth>, SyncError> =>
142
+ RpcMiddleware.layerClient(
143
+ SyncAuth,
144
+ Effect.map(encodeCapability(capability), (header) => ({ next, request }) =>
145
+ next({ ...request, headers: Headers.set(request.headers, capabilityHeader, header) }))
146
+ )