@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,76 @@
1
+ /**
2
+ * The source of freshly minted branch and capability identifiers.
3
+ *
4
+ * Minting an id is nondeterminism against the host, so it enters through a
5
+ * port like every other host effect: `BranchServer.layerHandlers` asks
6
+ * this service for the ids it puts on the wire instead of calling a global
7
+ * generator, which is what lets a deterministic composition — a test, a
8
+ * replayed transcript, a deployment that derives ids from its own namespace —
9
+ * supply its own without touching the handlers.
10
+ *
11
+ * @since 0.1.0
12
+ */
13
+
14
+ import * as Context from "effect/Context"
15
+ import * as Effect from "effect/Effect"
16
+ import * as Layer from "effect/Layer"
17
+
18
+ /**
19
+ * Identifier minting operations.
20
+ *
21
+ * @category models
22
+ * @since 0.1.0
23
+ */
24
+ export interface Service {
25
+ /** A fresh, unguessable identifier. Never returns the same value twice. */
26
+ readonly fresh: Effect.Effect<string>
27
+ }
28
+
29
+ /**
30
+ * The branch identifier source.
31
+ *
32
+ * @category services
33
+ * @since 0.1.0
34
+ */
35
+ export class BranchIds extends Context.Service<BranchIds, Service>()("@smthrs/sync/BranchIds") {}
36
+
37
+ /**
38
+ * Constructs the default source: Web Crypto UUIDs.
39
+ *
40
+ * Web Crypto is used directly, as it is for the share signature in
41
+ * `BranchShare`, so the same module runs in the browser and on node.
42
+ * A branch id is not itself an authorization — every operation still
43
+ * authorizes through a signed capability — but an unguessable id keeps a
44
+ * branch from being enumerated, which is why the default stays with a
45
+ * cryptographic generator rather than `Random`.
46
+ *
47
+ * @category constructors
48
+ * @since 0.1.0
49
+ */
50
+ export const makeWebCrypto = (): Service => BranchIds.of({ fresh: Effect.sync(() => crypto.randomUUID()) })
51
+
52
+ /**
53
+ * Provides {@link makeWebCrypto}.
54
+ *
55
+ * @category layers
56
+ * @since 0.1.0
57
+ */
58
+ export const layer: Layer.Layer<BranchIds> = Layer.sync(BranchIds)(makeWebCrypto)
59
+
60
+ /**
61
+ * Provides a deterministic counter, `<prefix>-1`, `<prefix>-2`, and so on.
62
+ * For tests and reproducible transcripts only — the ids are guessable.
63
+ *
64
+ * @category layers
65
+ * @since 0.1.0
66
+ */
67
+ export const layerSequential = (prefix: string): Layer.Layer<BranchIds> =>
68
+ Layer.sync(BranchIds)(() => {
69
+ let sequence = 0
70
+ return BranchIds.of({
71
+ fresh: Effect.sync(() => {
72
+ sequence += 1
73
+ return `${prefix}-${sequence}`
74
+ })
75
+ })
76
+ })
@@ -0,0 +1,512 @@
1
+ /**
2
+ * Ephemeral presence and cursors for one shared branch.
3
+ *
4
+ * Presence is deliberately NOT journalled. A roster is a lease table: every
5
+ * announcement extends a lease, and a participant that stops announcing —
6
+ * because the tab closed, the network dropped, or the process died — ages out
7
+ * without anyone reporting the disconnect. Writing presence to the journal
8
+ * would make "who was looking at this" part of the durable, replayable history
9
+ * of a run, which is both unbounded and wrong: replaying a branch must not
10
+ * resurrect a stranger's caret.
11
+ *
12
+ * Every operation authorizes through {@link BranchShare}, so a capability for
13
+ * one branch can neither read nor write another branch's roster. A live
14
+ * participant is bound to the capability that announced it: announcing or
15
+ * leaving under that `participantId` with any other capability is refused
16
+ * with `unauthorized` until the lease lapses.
17
+ *
18
+ * @since 0.1.0
19
+ */
20
+
21
+ import * as Clock from "effect/Clock"
22
+ import * as Context from "effect/Context"
23
+ import * as Effect from "effect/Effect"
24
+ import * as Layer from "effect/Layer"
25
+ import * as PubSub from "effect/PubSub"
26
+ import * as Schema from "effect/Schema"
27
+ import * as Stream from "effect/Stream"
28
+ import {
29
+ type Announcement,
30
+ type BranchId,
31
+ Cursor,
32
+ type LeaveRequest,
33
+ Participant,
34
+ type ParticipantId,
35
+ PresenceWhere,
36
+ type RosterRequest
37
+ } from "./BranchProtocol.ts"
38
+ import * as BranchShare from "./BranchShare.ts"
39
+ import { positiveInt } from "./internal/PolicyOptions.ts"
40
+ import { SyncError } from "./SyncError.ts"
41
+
42
+ /**
43
+ * Ephemeral branch presence operations.
44
+ *
45
+ * @category models
46
+ * @since 0.1.0
47
+ */
48
+ export interface Service {
49
+ readonly announce: (announcement: Announcement) => Effect.Effect<Participant, SyncError>
50
+ readonly leave: (request: LeaveRequest) => Effect.Effect<void, SyncError>
51
+ /**
52
+ * One branch's live roster, as a fresh array of detached participants and
53
+ * cursors. Reading drops expired leases and advances a cross-branch sweep.
54
+ */
55
+ readonly list: (request: RosterRequest) => Effect.Effect<ReadonlyArray<Participant>, SyncError>
56
+ /**
57
+ * Returns `unknown` for one lease after host startup or while a required
58
+ * heartbeat source is unavailable; otherwise reports `present` or `empty`.
59
+ *
60
+ * @since 1.0.0
61
+ */
62
+ readonly presenceOn: (request: RosterRequest) => Effect.Effect<"unknown" | "present" | "empty", SyncError>
63
+ readonly changes: Stream.Stream<BranchId>
64
+ /**
65
+ * How long one announcement keeps a participant on the roster, in
66
+ * milliseconds.
67
+ *
68
+ * A lease lapses without anyone reporting it, and nothing publishes on
69
+ * `changes` when it does, so a watcher cannot learn of the last
70
+ * participant's departure from the change feed alone. It re-lists on this
71
+ * cadence capped at one second, bounding how long an expired lease stays visible.
72
+ */
73
+ readonly leaseMs: number
74
+ }
75
+
76
+ /**
77
+ * The branch presence registry.
78
+ *
79
+ * @category services
80
+ * @since 0.1.0
81
+ */
82
+ export class BranchPresence extends Context.Service<BranchPresence, Service>()("@smthrs/sync/BranchPresence") {}
83
+
84
+ const unavailable = new SyncError({ code: "unsupported", message: "Branch presence is unavailable" })
85
+
86
+ /**
87
+ * The lease {@link makeNoop} reports. It holds no one, so the value only has
88
+ * to be a positive number a watcher can build a re-list cadence from.
89
+ *
90
+ * @category constants
91
+ * @since 1.0.0-rc.0
92
+ */
93
+ export const defaultLeaseMs = 30_000
94
+
95
+ /**
96
+ * Constructs an unavailable presence registry.
97
+ *
98
+ * @category constructors
99
+ * @since 0.1.0
100
+ */
101
+ export const makeNoop = (overrides: Partial<Service> = {}): Service =>
102
+ BranchPresence.of({
103
+ announce: () => Effect.fail(unavailable),
104
+ leave: () => Effect.fail(unavailable),
105
+ list: () => Effect.fail(unavailable),
106
+ presenceOn: () => Effect.succeed("unknown"),
107
+ changes: Stream.empty,
108
+ leaseMs: defaultLeaseMs,
109
+ ...overrides
110
+ })
111
+
112
+ /**
113
+ * Provides an unavailable presence registry.
114
+ *
115
+ * @category layers
116
+ * @since 0.1.0
117
+ */
118
+ export const layerNoop: Layer.Layer<BranchPresence> = Layer.succeed(BranchPresence, makeNoop())
119
+
120
+ /**
121
+ * The policy one presence registry runs under. Every field defaults, and
122
+ * {@link makeMemory} and {@link layerWith} validate what a caller supplies.
123
+ *
124
+ * @category models
125
+ * @since 0.1.0
126
+ */
127
+ export interface PresenceOptions {
128
+ /**
129
+ * How long an announcement keeps a participant on the roster, in
130
+ * milliseconds. Defaults to {@link defaultLeaseMs}.
131
+ */
132
+ readonly leaseMs?: number | undefined
133
+ /**
134
+ * Roster changes a stalled {@link Service.changes} subscriber may fall
135
+ * behind by. Defaults to {@link defaultChangesCapacity}.
136
+ */
137
+ readonly changesCapacity?: number | undefined
138
+ /**
139
+ * Participants one branch may hold at once. Defaults to
140
+ * {@link defaultMaxParticipants}.
141
+ */
142
+ readonly maxParticipants?: number | undefined
143
+ /**
144
+ * Host readiness, including authorization, revocation, bridge and session
145
+ * sources. Defaults to unavailable.
146
+ *
147
+ * @since 1.0.0
148
+ */
149
+ readonly sourcesReady?: ((branchId: BranchId) => Effect.Effect<boolean>) | undefined
150
+ }
151
+
152
+ /**
153
+ * One roster slot: the participant, and the capability that announced it.
154
+ *
155
+ * `participantId` is chosen by the client, so it proves nothing on its own.
156
+ * The slot is bound to the verified `capabilityId` of its first live
157
+ * announcement, and only that capability may refresh or leave it until the
158
+ * lease lapses. Without the binding any write link on the branch could
159
+ * rename, move the caret of, or evict another link's participant.
160
+ */
161
+ interface Seat {
162
+ readonly participant: Participant
163
+ readonly capabilityId: string
164
+ }
165
+
166
+ const heldByAnother = (participantId: ParticipantId): SyncError =>
167
+ new SyncError({
168
+ code: "unauthorized",
169
+ message: `Participant ${participantId} is held by another share capability`
170
+ })
171
+
172
+ /** The resolved, already-validated presence policy. */
173
+ interface Resolved {
174
+ readonly leaseMs: number
175
+ readonly changesCapacity: number
176
+ readonly maxParticipants: number
177
+ readonly sourcesReady: (branchId: BranchId) => Effect.Effect<boolean>
178
+ }
179
+
180
+ /**
181
+ * Roster changes a stalled {@link Service.changes} subscriber may fall behind
182
+ * by before the oldest are dropped.
183
+ *
184
+ * Presence is a lease table, and `changes` only says that some branch's roster
185
+ * moved: a follower answers it with `list`. Holding an unbounded backlog for a
186
+ * subscriber that has stopped pulling would let one abandoned watcher retain
187
+ * every announcement the process has seen since. A subscriber that falls
188
+ * further behind than this bound loses the oldest notifications and re-lists;
189
+ * because every notification is answered by a fresh `list`, dropping one never
190
+ * loses roster state.
191
+ *
192
+ * @category constants
193
+ * @since 0.1.0
194
+ */
195
+ export const defaultChangesCapacity = 256
196
+
197
+ /**
198
+ * Participants one branch may hold at once.
199
+ *
200
+ * A roster is unbounded fan-out in two directions: it is walked on every
201
+ * `list`, and `Branch.Roster` returns it whole, which no frame ceiling covers.
202
+ * Nothing caps how many distinct `participantId`s one write capability may
203
+ * announce, so without this a single share link could pin an arbitrary number
204
+ * of `Participant` objects until their leases expired. Two hundred and fifty
205
+ * six is far above any real collaborative session; a further announce is
206
+ * refused with `backpressure`.
207
+ *
208
+ * @category constants
209
+ * @since 1.0.0-rc.0
210
+ */
211
+ export const defaultMaxParticipants = 256
212
+
213
+ const defaults: Resolved = {
214
+ leaseMs: defaultLeaseMs,
215
+ changesCapacity: defaultChangesCapacity,
216
+ maxParticipants: defaultMaxParticipants,
217
+ sourcesReady: () => Effect.succeed(false)
218
+ }
219
+
220
+ /**
221
+ * Constructs the in-memory, lease-expiring presence registry.
222
+ *
223
+ * Announcing requires write access: a read-only share link may watch the
224
+ * roster but never appears on it, so a shared read link cannot be used to
225
+ * impersonate a collaborator. A live participant belongs to the capability
226
+ * that announced it, so another write link can neither overwrite nor evict it.
227
+ * Holders of one link share its `capabilityId` and stay indistinguishable.
228
+ *
229
+ * The roster is keyed by branch and then by participant, so listing one branch
230
+ * costs that branch plus a bounded sweep of other branches, and no two
231
+ * branches can share a slot. One flat key built by concatenation collided
232
+ * for valid branded ids, which let one announcement overwrite another branch's
233
+ * participant.
234
+ *
235
+ * The change feed slides at {@link defaultChangesCapacity}: announcing never
236
+ * waits on a stalled watcher, and never grows the process on its behalf.
237
+ *
238
+ * Fails with `invalid_request` when an option is not a positive safe integer.
239
+ *
240
+ * @category constructors
241
+ * @since 0.1.0
242
+ */
243
+ export const makeMemory = (
244
+ options: PresenceOptions = {}
245
+ ): Effect.Effect<Service, SyncError, BranchShare.BranchShare> =>
246
+ Effect.flatMap(
247
+ Effect.all({
248
+ leaseMs: positiveInt("BranchPresence.PresenceOptions.leaseMs", options.leaseMs, defaults.leaseMs),
249
+ changesCapacity: positiveInt(
250
+ "BranchPresence.PresenceOptions.changesCapacity",
251
+ options.changesCapacity,
252
+ defaults.changesCapacity
253
+ ),
254
+ maxParticipants: positiveInt(
255
+ "BranchPresence.PresenceOptions.maxParticipants",
256
+ options.maxParticipants,
257
+ defaults.maxParticipants
258
+ )
259
+ }),
260
+ (resolved) => makeResolved({ ...resolved, sourcesReady: options.sourcesReady ?? defaults.sourcesReady })
261
+ )
262
+
263
+ /** The registry over an already-validated policy. */
264
+ const makeResolved = (
265
+ { changesCapacity, leaseMs, maxParticipants, sourcesReady }: Resolved
266
+ ): Effect.Effect<Service, never, BranchShare.BranchShare> =>
267
+ Effect.gen(function*() {
268
+ const startedAtMs = yield* Clock.currentTimeMillis
269
+ const share = yield* BranchShare.BranchShare
270
+ const roster = new Map<BranchId, Map<string, Seat>>()
271
+ const changes = yield* PubSub.sliding<BranchId>(changesCapacity)
272
+
273
+ const expire = (branchId: BranchId, nowMs: number) => {
274
+ const branch = roster.get(branchId)
275
+ if (branch === undefined) return undefined
276
+ for (const [participantId, seat] of branch) {
277
+ if (seat.participant.leaseExpiresAtMs <= nowMs) branch.delete(participantId)
278
+ }
279
+ if (branch.size === 0) {
280
+ roster.delete(branchId)
281
+ return undefined
282
+ }
283
+ return branch
284
+ }
285
+
286
+ // Each announce/list advances through at most 16 branch maps. Their
287
+ // participant counts are capped, so unrelated activity reclaims abandoned
288
+ // rosters without scanning the entire registry in a single request.
289
+ let sweepCursor = roster.keys()
290
+ const sweep = (nowMs: number) => {
291
+ for (let index = 0; index < 16; index++) {
292
+ const next = sweepCursor.next()
293
+ if (next.done) {
294
+ sweepCursor = roster.keys()
295
+ break
296
+ }
297
+ expire(next.value, nowMs)
298
+ }
299
+ }
300
+
301
+ const seatKey = (request: { participantId: ParticipantId; sessionId?: string }) =>
302
+ JSON.stringify([request.participantId, request.sessionId ?? ""])
303
+ const copyWhere = (where: NonNullable<Participant["where"]>) => ({ ...where })
304
+ const detach = (participant: Participant): Participant =>
305
+ new Participant({
306
+ ...participant,
307
+ ...(participant.where === undefined ? {} : { where: copyWhere(participant.where) }),
308
+ cursor: participant.cursor === null ? null : new Cursor(participant.cursor)
309
+ })
310
+
311
+ /**
312
+ * Drop this branch's expired leases and advance cleanup of other branches.
313
+ * Abandoned maps are reclaimed as announce/list calls advance the sweep;
314
+ * an idle registry keeps them until activity resumes. Results are detached
315
+ * from storage, including each participant's cursor.
316
+ */
317
+ const live = (branchId: BranchId, nowMs: number): Array<Participant> => {
318
+ sweep(nowMs)
319
+ const branch = expire(branchId, nowMs)
320
+ if (branch === undefined) return []
321
+ // Map entries have distinct seat keys, so compared seats cannot be equal.
322
+ return Array.from(branch.values(), (seat) => detach(seat.participant)).sort((left, right) =>
323
+ seatKey(left) < seatKey(right) ? -1 : 1
324
+ )
325
+ }
326
+
327
+ /**
328
+ * Copy a request out of the caller's object before the first await.
329
+ *
330
+ * `share.verify` awaits Web Crypto between authorizing `branchId` and the
331
+ * roster access that follows. The request objects are plain structs the
332
+ * in-process caller still holds, so reading them again after the await let
333
+ * a caller move `branchId` (or the participant identity) onto a branch the
334
+ * capability never authorized. Matches the claim snapshot in `BranchShare`.
335
+ */
336
+ const detachRoster = (request: RosterRequest): RosterRequest => ({
337
+ capability: request.capability,
338
+ branchId: request.branchId
339
+ })
340
+ const detachLeave = (request: LeaveRequest): LeaveRequest => ({
341
+ ...detachRoster(request),
342
+ participantId: request.participantId,
343
+ ...(request.sessionId === undefined ? {} : { sessionId: request.sessionId })
344
+ })
345
+ const detachAnnouncement = (request: Announcement) => {
346
+ const suppliedCursor: unknown = request.cursor
347
+ const cursor: { readonly cardId: unknown; readonly offset: unknown } | null = suppliedCursor === null
348
+ ? null
349
+ : typeof suppliedCursor === "object"
350
+ ? { cardId: (suppliedCursor as Cursor).cardId, offset: (suppliedCursor as Cursor).offset }
351
+ : { cardId: undefined, offset: undefined }
352
+ return {
353
+ ...detachLeave(request),
354
+ displayName: request.displayName,
355
+ cursor,
356
+ ...(request.kind === undefined ? {} : { kind: request.kind }),
357
+ ...(request.where === undefined ? {} : { where: copyWhere(request.where) }),
358
+ ...(request.watching === undefined ? {} : { watching: request.watching }),
359
+ ...(request.for_member === undefined ? {} : { for_member: request.for_member }),
360
+ ...(request.agentKind === undefined ? {} : { agentKind: request.agentKind }),
361
+ ...(request.via === undefined ? {} : { via: request.via }),
362
+ ...(request.runId === undefined ? {} : { runId: request.runId })
363
+ }
364
+ }
365
+
366
+ const announce = Effect.fn("BranchPresence.announce")(function*(supplied: Announcement) {
367
+ const detached = detachAnnouncement(supplied)
368
+ yield* Effect.annotateCurrentSpan({
369
+ branchId: detached.branchId,
370
+ participantId: detached.participantId
371
+ })
372
+ const claims = yield* share.verify(detached.capability, {
373
+ branchId: detached.branchId,
374
+ access: "write"
375
+ })
376
+ // The wire schema IS `Announcement`, so a remote caller cannot reach
377
+ // here with an empty name. An in-process caller can, and `Participant`
378
+ // requires a `NonEmptyString`: without this the constructor threw a
379
+ // defect out of an operation whose type promises a `SyncError`.
380
+ if (detached.displayName.length === 0) {
381
+ return yield* Effect.fail(
382
+ new SyncError({ code: "invalid_request", message: "A participant's display name must not be empty" })
383
+ )
384
+ }
385
+ if (detached.where !== undefined && !Schema.is(PresenceWhere)(detached.where)) {
386
+ return yield* Effect.fail(new SyncError({ code: "invalid_request", message: "Invalid presence location" }))
387
+ }
388
+ if (detached.sessionId !== undefined && detached.sessionId.length === 0) {
389
+ return yield* Effect.fail(new SyncError({ code: "invalid_request", message: "Invalid presence session" }))
390
+ }
391
+ let cursor: Cursor | null = null
392
+ if (detached.cursor !== null) {
393
+ const { cardId, offset } = detached.cursor
394
+ if (typeof cardId !== "string" || cardId.length === 0) {
395
+ return yield* Effect.fail(
396
+ new SyncError({ code: "invalid_request", message: "A cursor's card ID must not be empty" })
397
+ )
398
+ }
399
+ if (typeof offset !== "number" || !Number.isSafeInteger(offset) || offset < 0) {
400
+ return yield* Effect.fail(
401
+ new SyncError({ code: "invalid_request", message: "A cursor's offset must be a nonnegative safe integer" })
402
+ )
403
+ }
404
+ cursor = new Cursor({ cardId, offset })
405
+ }
406
+ const announcement = { ...detached, cursor }
407
+ const nowMs = yield* Clock.currentTimeMillis
408
+ // Run-out leases are dropped before the cap is judged, so a branch that
409
+ // has simply been busy over time is never refused for a stale roster.
410
+ live(announcement.branchId, nowMs)
411
+ const branch = roster.get(announcement.branchId) ?? new Map<string, Seat>()
412
+ const held = branch.get(seatKey(announcement))
413
+ // A session suffix must not bypass the existing participant ownership
414
+ // fence. The host may bind multiple sessions to one actor capability.
415
+ const foreignActor = Array.from(branch.values()).some((seat) =>
416
+ seat.participant.participantId === announcement.participantId && seat.capabilityId !== claims.capabilityId
417
+ )
418
+ if (foreignActor || (held !== undefined && held.capabilityId !== claims.capabilityId)) {
419
+ return yield* Effect.fail(heldByAnother(announcement.participantId))
420
+ }
421
+ if (held === undefined && branch.size >= maxParticipants) {
422
+ return yield* Effect.fail(
423
+ new SyncError({
424
+ code: "backpressure",
425
+ message: `Branch ${announcement.branchId} already holds ${maxParticipants} participants`
426
+ })
427
+ )
428
+ }
429
+ const participant = new Participant({
430
+ branchId: announcement.branchId,
431
+ participantId: announcement.participantId,
432
+ displayName: announcement.displayName,
433
+ cursor: announcement.cursor,
434
+ ...(announcement.sessionId === undefined ? {} : { sessionId: announcement.sessionId }),
435
+ ...(announcement.kind === undefined ? {} : { kind: announcement.kind }),
436
+ ...(announcement.where === undefined ? {} : { where: announcement.where }),
437
+ ...(announcement.watching === undefined ? {} : { watching: announcement.watching }),
438
+ ...(announcement.for_member === undefined ? {} : { for_member: announcement.for_member }),
439
+ ...(announcement.agentKind === undefined ? {} : { agentKind: announcement.agentKind }),
440
+ ...(announcement.via === undefined ? {} : { via: announcement.via }),
441
+ ...(announcement.runId === undefined ? {} : { runId: announcement.runId }),
442
+ leaseExpiresAtMs: nowMs + leaseMs
443
+ })
444
+ branch.set(seatKey(announcement), { participant, capabilityId: claims.capabilityId })
445
+ roster.set(announcement.branchId, branch)
446
+ yield* PubSub.publish(changes, announcement.branchId)
447
+ return detach(participant)
448
+ })
449
+
450
+ const leave = Effect.fn("BranchPresence.leave")(function*(supplied: LeaveRequest) {
451
+ const request = detachLeave(supplied)
452
+ yield* Effect.annotateCurrentSpan({ branchId: request.branchId, participantId: request.participantId })
453
+ const claims = yield* share.verify(request.capability, { branchId: request.branchId, access: "write" })
454
+ const branch = expire(request.branchId, yield* Clock.currentTimeMillis)
455
+ if (branch !== undefined) {
456
+ const keys = request.sessionId === undefined
457
+ ? [...branch].filter(([, seat]) => seat.participant.participantId === request.participantId)
458
+ .map(([key]) => key)
459
+ : [seatKey(request)]
460
+ for (const key of keys) {
461
+ const held = branch.get(key)
462
+ if (held !== undefined && held.capabilityId !== claims.capabilityId) {
463
+ return yield* Effect.fail(heldByAnother(request.participantId))
464
+ }
465
+ }
466
+ for (const key of keys) branch.delete(key)
467
+ if (branch.size === 0) roster.delete(request.branchId)
468
+ }
469
+ yield* PubSub.publish(changes, request.branchId)
470
+ })
471
+
472
+ const list = Effect.fn("BranchPresence.list")(function*(supplied: RosterRequest) {
473
+ const request = detachRoster(supplied)
474
+ yield* Effect.annotateCurrentSpan({ branchId: request.branchId })
475
+ yield* share.verify(request.capability, { branchId: request.branchId, access: "read" })
476
+ return live(request.branchId, yield* Clock.currentTimeMillis)
477
+ })
478
+
479
+ const presenceOn = Effect.fn("BranchPresence.presenceOn")(function*(supplied: RosterRequest) {
480
+ const request = detachRoster(supplied)
481
+ yield* share.verify(request.capability, { branchId: request.branchId, access: "read" })
482
+ const nowMs = yield* Clock.currentTimeMillis
483
+ if (nowMs - startedAtMs < leaseMs || !(yield* sourcesReady(request.branchId))) return "unknown" as const
484
+ return live(request.branchId, nowMs).length === 0 ? "empty" as const : "present" as const
485
+ })
486
+
487
+ return BranchPresence.of({ announce, leave, list, presenceOn, changes: Stream.fromPubSub(changes), leaseMs })
488
+ })
489
+
490
+ /**
491
+ * Provides the in-memory, lease-expiring presence registry under the default
492
+ * policy, which is valid by construction and so cannot fail.
493
+ *
494
+ * @category layers
495
+ * @since 0.1.0
496
+ */
497
+ export const layer: Layer.Layer<BranchPresence, never, BranchShare.BranchShare> = Layer.effect(
498
+ BranchPresence,
499
+ makeResolved(defaults)
500
+ )
501
+
502
+ /**
503
+ * Provides the in-memory, lease-expiring presence registry under an explicit
504
+ * policy. Fails with `invalid_request` when an option is not a positive safe
505
+ * integer, so a bad policy fails the composition rather than an announcement.
506
+ *
507
+ * @category layers
508
+ * @since 1.0.0-rc.0
509
+ */
510
+ export const layerWith = (
511
+ options: PresenceOptions
512
+ ): Layer.Layer<BranchPresence, SyncError, BranchShare.BranchShare> => Layer.effect(BranchPresence, makeMemory(options))