@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
+ * The branch document projection: journal entries folded into what two people
3
+ * looking at the same branch must agree on.
4
+ *
5
+ * The fold is order-independent over the canonical entry set, which is what
6
+ * makes convergence a property rather than a hope: any two clients that have
7
+ * applied the same set of entries hold the same state, regardless of the
8
+ * order their transports delivered frames in or how many times a frame
9
+ * arrived.
10
+ *
11
+ * Three guards do all the work, and each is a production invariant:
12
+ *
13
+ * - entries from another branch's run are ignored, so a mis-routed frame can
14
+ * never leak into a projection;
15
+ * - an entry already folded — the cursor tip redelivered, or a command id
16
+ * already applied — is ignored, so an at-least-once transport cannot
17
+ * double-apply a user action and a reconnect replays no side effects;
18
+ * - an entry below the cursor whose command was never applied is folded into
19
+ * its canonical `seq` position, so out-of-order delivery converges on the
20
+ * same document instead of permanently dropping the late entry.
21
+ *
22
+ * @since 0.1.0
23
+ */
24
+
25
+ import * as JournalEvent from "@smthrs/journal/JournalEvent"
26
+ import * as HashSet from "effect/HashSet"
27
+ import * as Option from "effect/Option"
28
+ import * as Schema from "effect/Schema"
29
+ import {
30
+ BranchId,
31
+ branchRunId,
32
+ CommandEvent,
33
+ CommandEventPayload,
34
+ CommandId,
35
+ ParticipantId,
36
+ SayCommand
37
+ } from "./BranchProtocol.ts"
38
+
39
+ /**
40
+ * One collaborative chat message in the branch timeline.
41
+ *
42
+ * @category models
43
+ * @since 0.1.0
44
+ */
45
+ export const Message = Schema.Struct({
46
+ seq: JournalEvent.Seq,
47
+ commandId: CommandId,
48
+ participantId: ParticipantId,
49
+ /** The verified share capability that submitted the message. */
50
+ capabilityId: Schema.String,
51
+ text: Schema.String
52
+ })
53
+ /**
54
+ * The value form of {@link Message}.
55
+ *
56
+ * @category models
57
+ * @since 0.1.0
58
+ */
59
+ export type Message = typeof Message.Type
60
+
61
+ /**
62
+ * One command applied to the branch document.
63
+ *
64
+ * @category models
65
+ * @since 0.1.0
66
+ */
67
+ export const AppliedCommand = Schema.Struct({
68
+ seq: JournalEvent.Seq,
69
+ commandId: CommandId,
70
+ participantId: ParticipantId,
71
+ /** The verified share capability that submitted the command. */
72
+ capabilityId: Schema.String,
73
+ name: Schema.NonEmptyString,
74
+ args: Schema.String,
75
+ target: Schema.String
76
+ })
77
+ /**
78
+ * The value form of {@link AppliedCommand}.
79
+ *
80
+ * @category models
81
+ * @since 0.1.0
82
+ */
83
+ export type AppliedCommand = typeof AppliedCommand.Type
84
+
85
+ /**
86
+ * The current winning value of one durably edited shared field.
87
+ *
88
+ * @category models
89
+ * @since 0.1.0
90
+ */
91
+ export const Field = Schema.Struct({
92
+ target: Schema.NonEmptyString,
93
+ value: Schema.String,
94
+ seq: JournalEvent.Seq,
95
+ participantId: ParticipantId,
96
+ /** The verified share capability that wrote the winning value. */
97
+ capabilityId: Schema.String
98
+ })
99
+ /**
100
+ * The value form of {@link Field}.
101
+ *
102
+ * @category models
103
+ * @since 0.1.0
104
+ */
105
+ export type Field = typeof Field.Type
106
+
107
+ /**
108
+ * The projected state of one branch document.
109
+ *
110
+ * `seq` is the highest journal sequence folded in and is exactly the cursor a
111
+ * client resumes from.
112
+ *
113
+ * @category models
114
+ * @since 0.1.0
115
+ */
116
+ export const State = Schema.Struct({
117
+ branchId: BranchId,
118
+ seq: Schema.Int.check(Schema.isGreaterThanOrEqualTo(-1)),
119
+ messages: Schema.Array(Message),
120
+ commands: Schema.Array(AppliedCommand),
121
+ fields: Schema.Array(Field)
122
+ })
123
+ /**
124
+ * The value form of {@link State}.
125
+ *
126
+ * @category models
127
+ * @since 0.1.0
128
+ */
129
+ export type State = typeof State.Type
130
+
131
+ /**
132
+ * The empty projection of a branch, whose cursor is "nothing applied".
133
+ *
134
+ * @category constructors
135
+ * @since 0.1.0
136
+ */
137
+ export const empty = (branchId: BranchId): State => ({
138
+ branchId,
139
+ seq: -1,
140
+ messages: [],
141
+ commands: [],
142
+ fields: []
143
+ })
144
+
145
+ /**
146
+ * The conflict policy for two durable edits of the same field.
147
+ *
148
+ * Highest canonical sequence wins. Within a branch the sequence is a total
149
+ * order assigned by the journal, so this is already decisive; the
150
+ * lexicographic `participantId` tie-break exists so the policy stays total if
151
+ * a future deployment ever merges two sequence domains. It is deliberately not
152
+ * wall-clock based — client clocks disagree, and a merge rule that depends on
153
+ * them is not deterministic.
154
+ *
155
+ * @category combinators
156
+ * @since 0.1.0
157
+ */
158
+ export const resolveField = (existing: Field | undefined, candidate: Field): Field => {
159
+ if (existing === undefined) return candidate
160
+ if (candidate.seq !== existing.seq) return candidate.seq > existing.seq ? candidate : existing
161
+ return candidate.participantId > existing.participantId ? candidate : existing
162
+ }
163
+
164
+ /**
165
+ * Inserts one item into a sequence-ordered array at its canonical position.
166
+ *
167
+ * Canonical sequences are unique within a run, so an equal-sequence collision
168
+ * is unrepresentable here: dedup happens before insertion.
169
+ */
170
+ const insertBySeq = <Item extends { readonly seq: JournalEvent.Seq }>(
171
+ items: ReadonlyArray<Item>,
172
+ item: Item
173
+ ): Array<Item> => {
174
+ const last = items[items.length - 1]
175
+ if (last === undefined || last.seq <= item.seq) return [...items, item]
176
+ let low = 0
177
+ let high = items.length
178
+ while (low < high) {
179
+ const middle = Math.floor((low + high) / 2)
180
+ if (items[middle]!.seq <= item.seq) low = middle + 1
181
+ else high = middle
182
+ }
183
+ const result = items.slice()
184
+ result.splice(low, 0, item)
185
+ return result
186
+ }
187
+
188
+ // Cache by the immutable command array so cursor-only updates share the index.
189
+ // Persistent sets let independent continuations share history without changing
190
+ // an older state's deduplication. Restored states build their index on first use.
191
+ const commandIds = new WeakMap<ReadonlyArray<AppliedCommand>, HashSet.HashSet<CommandId>>()
192
+ const indexCommands = (commands: ReadonlyArray<AppliedCommand>): HashSet.HashSet<CommandId> => {
193
+ const cached = commandIds.get(commands)
194
+ if (cached !== undefined) return cached
195
+ const index = HashSet.fromIterable(commands.map((command) => command.commandId))
196
+ commandIds.set(commands, index)
197
+ return index
198
+ }
199
+
200
+ const decodeCommand = Schema.decodeUnknownOption(CommandEventPayload)
201
+
202
+ /**
203
+ * Folds one journal entry into a branch projection.
204
+ *
205
+ * Redelivery and out-of-order delivery are told apart by identity, not by
206
+ * position: the cursor tip redelivered and a command id already applied are
207
+ * no-ops, while a below-cursor entry never applied is folded into canonical
208
+ * order. Non-command entries only raise the cursor, so re-applying one is
209
+ * idempotent by construction.
210
+ *
211
+ * @category combinators
212
+ * @since 0.1.0
213
+ */
214
+ export const apply = (state: State, entry: JournalEvent.Entry): State => {
215
+ if (entry.runId !== branchRunId(state.branchId) || entry.seq === state.seq) return state
216
+ const advanced = entry.seq < state.seq ? state : { ...state, seq: entry.seq }
217
+ if (entry.eventType !== CommandEvent) return advanced
218
+ const decoded = decodeCommand(entry.payload)
219
+ if (Option.isNone(decoded)) return advanced
220
+ const commandSubmission = decoded.value
221
+ const ids = indexCommands(state.commands)
222
+ if (HashSet.has(ids, commandSubmission.commandId)) return advanced
223
+ const command: AppliedCommand = { ...commandSubmission, seq: entry.seq }
224
+ const messages = commandSubmission.name === SayCommand
225
+ ? insertBySeq(state.messages, {
226
+ seq: entry.seq,
227
+ commandId: commandSubmission.commandId,
228
+ participantId: commandSubmission.participantId,
229
+ capabilityId: commandSubmission.capabilityId,
230
+ text: commandSubmission.args
231
+ })
232
+ : state.messages
233
+ const fields = commandSubmission.target === ""
234
+ ? state.fields
235
+ : (() => {
236
+ const candidate: Field = {
237
+ target: commandSubmission.target,
238
+ value: commandSubmission.args,
239
+ seq: entry.seq,
240
+ participantId: commandSubmission.participantId,
241
+ capabilityId: commandSubmission.capabilityId
242
+ }
243
+ const winner = resolveField(
244
+ state.fields.find((field) => field.target === commandSubmission.target),
245
+ candidate
246
+ )
247
+ return [...state.fields.filter((field) => field.target !== commandSubmission.target), winner]
248
+ .sort((left, right) => left.target < right.target ? -1 : 1)
249
+ })()
250
+ const commands = insertBySeq(state.commands, command)
251
+ commandIds.set(commands, HashSet.add(ids, command.commandId))
252
+ return { ...advanced, messages, commands, fields }
253
+ }
254
+
255
+ /**
256
+ * Folds a whole entry stream into a branch projection.
257
+ *
258
+ * @category combinators
259
+ * @since 0.1.0
260
+ */
261
+ export const project = (branchId: BranchId, entries: Iterable<JournalEvent.Entry>): State => {
262
+ const runId = branchRunId(branchId)
263
+ let seq = -1
264
+ const ids = new Set<CommandId>()
265
+ const commands: Array<AppliedCommand> = []
266
+ const messages: Array<Message> = []
267
+ const fields = new Map<string, Field>()
268
+ for (const entry of entries) {
269
+ if (entry.runId !== runId || entry.seq === seq) continue
270
+ seq = Math.max(seq, entry.seq)
271
+ if (entry.eventType !== CommandEvent) continue
272
+ const decoded = decodeCommand(entry.payload)
273
+ if (Option.isNone(decoded)) continue
274
+ const submission = decoded.value
275
+ if (ids.has(submission.commandId)) continue
276
+ ids.add(submission.commandId)
277
+ commands.push({ ...submission, seq: entry.seq })
278
+ if (submission.name === SayCommand) {
279
+ messages.push({
280
+ seq: entry.seq,
281
+ commandId: submission.commandId,
282
+ participantId: submission.participantId,
283
+ capabilityId: submission.capabilityId,
284
+ text: submission.args
285
+ })
286
+ }
287
+ if (submission.target !== "") {
288
+ const candidate: Field = {
289
+ target: submission.target,
290
+ value: submission.args,
291
+ seq: entry.seq,
292
+ participantId: submission.participantId,
293
+ capabilityId: submission.capabilityId
294
+ }
295
+ fields.set(submission.target, resolveField(fields.get(submission.target), candidate))
296
+ }
297
+ }
298
+ commands.sort((left, right) => left.seq - right.seq)
299
+ messages.sort((left, right) => left.seq - right.seq)
300
+ commandIds.set(commands, HashSet.fromIterable(ids))
301
+ return {
302
+ branchId,
303
+ seq,
304
+ commands,
305
+ messages,
306
+ fields: Array.from(fields.values()).sort((left, right) => left.target < right.target ? -1 : 1)
307
+ }
308
+ }
@@ -0,0 +1,411 @@
1
+ /**
2
+ * The branch collaboration contract: a branch is one shared live document.
3
+ *
4
+ * A branch has no store of its own. Its durable state is exactly one journal
5
+ * run — `branchRunId` is the only mapping — so every replication guarantee the
6
+ * rest of this package already provides (canonical per-run `seq`, exclusive
7
+ * cursors, gap detection, resumable follow) carries over unchanged, and
8
+ * multiplayer never introduces a second source of truth.
9
+ *
10
+ * Ephemeral collaboration state (presence, cursors) deliberately has no
11
+ * representation here beyond the wire shapes: it is leased, never journalled.
12
+ *
13
+ * @since 0.1.0
14
+ */
15
+
16
+ import * as JournalEvent from "@smthrs/journal/JournalEvent"
17
+ import * as Effect from "effect/Effect"
18
+ import * as Schema from "effect/Schema"
19
+
20
+ /**
21
+ * Schema for the identifier of one collaboratively edited branch.
22
+ *
23
+ * @category schemas
24
+ * @since 0.1.0
25
+ */
26
+ export const BranchId = Schema.NonEmptyString.pipe(Schema.brand("@smthrs/sync/BranchProtocol/BranchId"))
27
+
28
+ /**
29
+ * Identifier of one collaboratively edited branch.
30
+ *
31
+ * @category models
32
+ * @since 0.1.0
33
+ */
34
+ export type BranchId = typeof BranchId.Type
35
+
36
+ /**
37
+ * Schema for the identifier of one connected client.
38
+ *
39
+ * @category schemas
40
+ * @since 0.1.0
41
+ */
42
+ export const ParticipantId = Schema.NonEmptyString.pipe(Schema.brand("@smthrs/sync/BranchProtocol/ParticipantId"))
43
+
44
+ /**
45
+ * Identifier of one connected client.
46
+ *
47
+ * @category models
48
+ * @since 0.1.0
49
+ */
50
+ export type ParticipantId = typeof ParticipantId.Type
51
+
52
+ /**
53
+ * Schema for a client-minted command identifier.
54
+ *
55
+ * The identifier is minted once per user intent and reused across optimistic
56
+ * application, retransmission, and concurrent submission, which is what makes
57
+ * it the idempotency key rather than a correlation id.
58
+ *
59
+ * @category schemas
60
+ * @since 0.1.0
61
+ */
62
+ export const CommandId = Schema.NonEmptyString.pipe(Schema.brand("@smthrs/sync/BranchProtocol/CommandId"))
63
+
64
+ /**
65
+ * Client-minted command identifier used as the idempotency key.
66
+ *
67
+ * @category models
68
+ * @since 0.1.0
69
+ */
70
+ export type CommandId = typeof CommandId.Type
71
+
72
+ /**
73
+ * Schema for the access a share capability grants.
74
+ *
75
+ * @category schemas
76
+ * @since 0.1.0
77
+ */
78
+ export const Access = Schema.Literals(["read", "write"])
79
+
80
+ /**
81
+ * The access a share capability grants.
82
+ *
83
+ * @category models
84
+ * @since 0.1.0
85
+ */
86
+ export type Access = typeof Access.Type
87
+
88
+ /**
89
+ * The journal run backing a branch document.
90
+ *
91
+ * The prefix keeps branch runs from colliding with engine runs in the same
92
+ * workspace journal, and makes the mapping reversible for tooling.
93
+ *
94
+ * @category constructors
95
+ * @since 0.1.0
96
+ */
97
+ export const branchRunId = (branchId: BranchId): JournalEvent.RunId =>
98
+ `${branchRunPrefix}${branchId}` as JournalEvent.RunId
99
+
100
+ /** The one prefix {@link branchRunId} writes and {@link branchOfRunId} reads. */
101
+ const branchRunPrefix = "flows/branch/"
102
+
103
+ /**
104
+ * The branch a journal run belongs to, or `null` for a non-branch run.
105
+ *
106
+ * The bare prefix is a non-branch run, not a branch with an empty id.
107
+ * `BranchId` is a branded `NonEmptyString`, so branding `""` would produce a
108
+ * value the brand forbids and hand it to `share.verify` as the branch under
109
+ * authorization. Refusing it here keeps the brand's promise true everywhere
110
+ * downstream of this function.
111
+ *
112
+ * @category constructors
113
+ * @since 0.1.0
114
+ */
115
+ export const branchOfRunId = (runId: JournalEvent.RunId): BranchId | null => {
116
+ if (!runId.startsWith(branchRunPrefix)) return null
117
+ const rest = runId.slice(branchRunPrefix.length)
118
+ return rest.length === 0 ? null : rest as BranchId
119
+ }
120
+
121
+ /**
122
+ * The journal producer identity of one admitted command.
123
+ *
124
+ * `(runId, sourceId, sourceSeq)` is the journal's own durable idempotency
125
+ * key. Deriving the source from the client-minted `commandId` — with the
126
+ * fixed {@link commandSourceSeq} — turns that key into the branch's
127
+ * exactly-once admission constraint: two server instances racing the same
128
+ * command collide inside the journal's own transaction, so one appends and
129
+ * the other reads back the canonical sequence, instead of both appending
130
+ * (audit finding F-14). The branch run id already scopes the key, so equal
131
+ * command ids on different branches never collide.
132
+ *
133
+ * @category constructors
134
+ * @since 0.1.0
135
+ */
136
+ export const commandSourceId = (commandId: CommandId): JournalEvent.SourceId =>
137
+ `flows/branch-command/${commandId}` as JournalEvent.SourceId
138
+
139
+ /**
140
+ * The fixed producer sequence of an admitted command.
141
+ *
142
+ * A command's identity lives entirely in {@link commandSourceId}, so its
143
+ * producer sequence is always 0. It must be supplied explicitly: letting the
144
+ * journal allocate would hand a resubmission after a lost response sequence 1,
145
+ * a fresh identity, and therefore a second durable append.
146
+ *
147
+ * @category constructors
148
+ * @since 0.1.0
149
+ */
150
+ export const commandSourceSeq: JournalEvent.SourceSeq = 0 as JournalEvent.SourceSeq
151
+
152
+ /**
153
+ * The single durable event type a branch document is built from.
154
+ *
155
+ * Chat, edits, and flow launches are all commands, so the branch journal has
156
+ * exactly one event shape and the projection has exactly one decoder.
157
+ *
158
+ * @category models
159
+ * @since 0.1.0
160
+ */
161
+ export const CommandEvent = "flows/branch/command"
162
+
163
+ /**
164
+ * Schema for the claims a share capability carries.
165
+ *
166
+ * `kid` names the key that signed the claims and is itself signed, so a
167
+ * verifier both selects the right key out of a rotating keyring and refuses a
168
+ * capability whose key name was swapped after minting. It matches
169
+ * `WorkspaceShare.WorkspaceClaims`: the two authorities are one scheme, and a
170
+ * branch link that could not survive a secret rotation was the only place they
171
+ * differed.
172
+ *
173
+ * @category schemas
174
+ * @since 0.1.0
175
+ */
176
+ export class ShareClaims extends Schema.Class<ShareClaims>("@smthrs/sync/BranchProtocol/ShareClaims")({
177
+ kid: Schema.NonEmptyString,
178
+ branchId: BranchId,
179
+ capabilityId: Schema.NonEmptyString,
180
+ access: Access,
181
+ issuedAtMs: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
182
+ expiresAtMs: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))
183
+ }) {}
184
+
185
+ /**
186
+ * Schema for a signed, expiring, branch-scoped share capability.
187
+ *
188
+ * @category schemas
189
+ * @since 0.1.0
190
+ */
191
+ export class ShareCapability extends Schema.Class<ShareCapability>("@smthrs/sync/BranchProtocol/ShareCapability")({
192
+ claims: ShareClaims,
193
+ signature: Schema.String
194
+ }) {}
195
+
196
+ /**
197
+ * Schema for a participant's caret inside one embedded card.
198
+ *
199
+ * @category schemas
200
+ * @since 0.1.0
201
+ */
202
+ export class Cursor extends Schema.Class<Cursor>("@smthrs/sync/BranchProtocol/Cursor")({
203
+ cardId: Schema.NonEmptyString,
204
+ offset: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))
205
+ }) {}
206
+
207
+ /**
208
+ * BranchCard-compatible display coordinates; paths are metadata only.
209
+ *
210
+ * @category schemas
211
+ * @since 1.0.0-rc.1
212
+ */
213
+ export const PresenceWhere = Schema.Union([
214
+ Schema.Struct({
215
+ kind: Schema.Literal("file"),
216
+ path: Schema.NonEmptyString,
217
+ line: Schema.optionalKey(Schema.Int.check(Schema.isGreaterThan(0)))
218
+ }),
219
+ Schema.Struct({ kind: Schema.Literal("terminal"), id: Schema.NonEmptyString }),
220
+ Schema.Struct({ kind: Schema.Literal("step"), label: Schema.NonEmptyString }),
221
+ Schema.Struct({ kind: Schema.Literal("branch") })
222
+ ])
223
+
224
+ // Optional fields preserve decoding of recorded branch-protocol frames.
225
+ const presenceFields = {
226
+ sessionId: Schema.optionalKey(Schema.NonEmptyString),
227
+ kind: Schema.optionalKey(Schema.Literals(["person", "agent"])),
228
+ where: Schema.optionalKey(PresenceWhere),
229
+ watching: Schema.optionalKey(Schema.NonEmptyString),
230
+ for_member: Schema.optionalKey(Schema.NonEmptyString),
231
+ agentKind: Schema.optionalKey(
232
+ Schema.Literals(["smithers", "coding", "reviewer", "claude-code", "codex", "external"])
233
+ ),
234
+ via: Schema.optionalKey(Schema.Literals(["ssh", "terminal", "cli"])),
235
+ runId: Schema.optionalKey(Schema.NonEmptyString)
236
+ }
237
+
238
+ /**
239
+ * Schema for one live participant on a branch.
240
+ *
241
+ * `leaseExpiresAtMs` is the whole lifetime contract: presence is a lease, so a
242
+ * client that disconnects without saying so disappears on its own.
243
+ *
244
+ * @category schemas
245
+ * @since 0.1.0
246
+ */
247
+ export class Participant extends Schema.Class<Participant>("@smthrs/sync/BranchProtocol/Participant")({
248
+ branchId: BranchId,
249
+ participantId: ParticipantId,
250
+ displayName: Schema.NonEmptyString,
251
+ cursor: Schema.NullOr(Cursor),
252
+ ...presenceFields,
253
+ leaseExpiresAtMs: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))
254
+ }) {}
255
+
256
+ /**
257
+ * Schema for a capability-bearing request for one branch's roster.
258
+ *
259
+ * @category schemas
260
+ * @since 0.1.0
261
+ */
262
+ export const RosterRequest = Schema.Struct({
263
+ capability: ShareCapability,
264
+ branchId: BranchId,
265
+ /** Only the authenticated host adapter consumes this readiness assertion. */
266
+ sourcesReady: Schema.optionalKey(Schema.Boolean)
267
+ })
268
+
269
+ /**
270
+ * A capability-bearing request for one branch's roster.
271
+ *
272
+ * @category models
273
+ * @since 0.1.0
274
+ */
275
+ export type RosterRequest = typeof RosterRequest.Type
276
+
277
+ /**
278
+ * Schema for a capability-bearing request to drop one participant.
279
+ *
280
+ * @category schemas
281
+ * @since 0.1.0
282
+ */
283
+ export const LeaveRequest = Schema.Struct({
284
+ ...RosterRequest.fields,
285
+ participantId: ParticipantId,
286
+ sessionId: Schema.optionalKey(Schema.NonEmptyString)
287
+ })
288
+
289
+ /**
290
+ * A capability-bearing request to drop one participant.
291
+ *
292
+ * @category models
293
+ * @since 0.1.0
294
+ */
295
+ export type LeaveRequest = typeof LeaveRequest.Type
296
+
297
+ /**
298
+ * Schema for one participant's announcement of itself on a branch.
299
+ *
300
+ * The same shape serves join, heartbeat, and cursor movement: an announcement
301
+ * is idempotent, so a client that reconnects simply announces again.
302
+ *
303
+ * @category schemas
304
+ * @since 0.1.0
305
+ */
306
+ export const Announcement = Schema.Struct({
307
+ ...presenceFields,
308
+ capability: ShareCapability,
309
+ branchId: BranchId,
310
+ participantId: ParticipantId,
311
+ displayName: Schema.NonEmptyString,
312
+ cursor: Schema.NullOr(Cursor)
313
+ })
314
+
315
+ /**
316
+ * One participant's announcement of itself on a branch.
317
+ *
318
+ * @category models
319
+ * @since 0.1.0
320
+ */
321
+ export type Announcement = typeof Announcement.Type
322
+
323
+ /**
324
+ * Schema for one submitted command or flow invocation.
325
+ *
326
+ * Every user action on a shared branch arrives here first — chat messages are
327
+ * the `branch.say` command, renames are `branch.rename`, and a flow launch is
328
+ * that flow's own name. `target` names the shared field a command durably
329
+ * edits and is `""` for commands that only append.
330
+ *
331
+ * @category schemas
332
+ * @since 0.1.0
333
+ */
334
+ export class CommandSubmission extends Schema.Class<CommandSubmission>(
335
+ "@smthrs/sync/BranchProtocol/CommandSubmission"
336
+ )({
337
+ branchId: BranchId,
338
+ commandId: CommandId,
339
+ participantId: ParticipantId,
340
+ name: Schema.NonEmptyString,
341
+ args: Schema.String,
342
+ target: Schema.String
343
+ }) {}
344
+
345
+ /**
346
+ * Schema for a capability-bearing command submission.
347
+ *
348
+ * @category schemas
349
+ * @since 0.1.0
350
+ */
351
+ export const SubmitRequest = Schema.Struct({ capability: ShareCapability, submission: CommandSubmission })
352
+
353
+ /**
354
+ * A capability-bearing command submission.
355
+ *
356
+ * @category models
357
+ * @since 0.1.0
358
+ */
359
+ export type SubmitRequest = typeof SubmitRequest.Type
360
+
361
+ /**
362
+ * The durable payload stored for an admitted command.
363
+ *
364
+ * Distinct from the wire request: `args` and `target` carry decoding defaults,
365
+ * so a row written before those fields existed still decodes rather than
366
+ * failing the whole ledger rebuild.
367
+ *
368
+ * `capabilityId` is the verified id of the share capability that submitted the
369
+ * command, stamped by `BranchCommands.submit` and never read from the client.
370
+ * `participantId` is whatever the client claimed, so attribute a command to
371
+ * its `capabilityId`. A row admitted before the field existed decodes with
372
+ * `""`, which no capability can carry.
373
+ *
374
+ * @category schemas
375
+ * @since 0.1.0
376
+ */
377
+ export class CommandEventPayload extends Schema.Class<CommandEventPayload>(
378
+ "@smthrs/sync/BranchProtocol/CommandEventPayload"
379
+ )({
380
+ commandId: CommandId,
381
+ participantId: ParticipantId,
382
+ name: Schema.NonEmptyString,
383
+ args: Schema.String.pipe(Schema.withDecodingDefaultKey(Effect.succeed(""))),
384
+ target: Schema.String.pipe(Schema.withDecodingDefaultKey(Effect.succeed(""))),
385
+ capabilityId: Schema.String.pipe(Schema.withDecodingDefaultKey(Effect.succeed("")))
386
+ }) {}
387
+
388
+ /**
389
+ * The command that appends a collaborative chat message.
390
+ *
391
+ * @category models
392
+ * @since 0.1.0
393
+ */
394
+ export const SayCommand = "branch.say"
395
+
396
+ /**
397
+ * Schema for the outcome of admitting a command.
398
+ *
399
+ * `duplicate` is a success, not a failure: it reports the canonical sequence
400
+ * the original submission already occupies so a retransmitting client can
401
+ * settle its optimistic row instead of executing the command twice.
402
+ *
403
+ * @category schemas
404
+ * @since 0.1.0
405
+ */
406
+ export class CommandReceipt extends Schema.Class<CommandReceipt>("@smthrs/sync/BranchProtocol/CommandReceipt")({
407
+ branchId: BranchId,
408
+ commandId: CommandId,
409
+ status: Schema.Literals(["admitted", "duplicate"]),
410
+ seq: JournalEvent.Seq
411
+ }) {}