@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.
- package/CHANGELOG.md +208 -0
- package/LICENSE +21 -0
- package/README.md +208 -2
- package/dist/cjs/BranchCommands.d.ts +228 -0
- package/dist/cjs/BranchCommands.d.ts.map +1 -0
- package/dist/cjs/BranchCommands.js +316 -0
- package/dist/cjs/BranchCommands.js.map +7 -0
- package/dist/cjs/BranchIds.d.ts +65 -0
- package/dist/cjs/BranchIds.d.ts.map +1 -0
- package/dist/cjs/BranchIds.js +53 -0
- package/dist/cjs/BranchIds.js.map +7 -0
- package/dist/cjs/BranchPresence.d.ts +195 -0
- package/dist/cjs/BranchPresence.d.ts.map +1 -0
- package/dist/cjs/BranchPresence.js +276 -0
- package/dist/cjs/BranchPresence.js.map +7 -0
- package/dist/cjs/BranchProjection.d.ts +179 -0
- package/dist/cjs/BranchProjection.d.ts.map +1 -0
- package/dist/cjs/BranchProjection.js +197 -0
- package/dist/cjs/BranchProjection.js.map +7 -0
- package/dist/cjs/BranchProtocol.d.ts +415 -0
- package/dist/cjs/BranchProtocol.d.ts.map +1 -0
- package/dist/cjs/BranchProtocol.js +172 -0
- package/dist/cjs/BranchProtocol.js.map +7 -0
- package/dist/cjs/BranchRpcs.d.ts +177 -0
- package/dist/cjs/BranchRpcs.d.ts.map +1 -0
- package/dist/cjs/BranchRpcs.js +85 -0
- package/dist/cjs/BranchRpcs.js.map +7 -0
- package/dist/cjs/BranchServer.d.ts +55 -0
- package/dist/cjs/BranchServer.d.ts.map +1 -0
- package/dist/cjs/BranchServer.js +147 -0
- package/dist/cjs/BranchServer.js.map +7 -0
- package/dist/cjs/BranchShare.d.ts +159 -0
- package/dist/cjs/BranchShare.d.ts.map +1 -0
- package/dist/cjs/BranchShare.js +147 -0
- package/dist/cjs/BranchShare.js.map +7 -0
- package/dist/cjs/RunCatalog.d.ts +207 -0
- package/dist/cjs/RunCatalog.d.ts.map +1 -0
- package/dist/cjs/RunCatalog.js +122 -0
- package/dist/cjs/RunCatalog.js.map +7 -0
- package/dist/cjs/SyncAuth.d.ts +70 -0
- package/dist/cjs/SyncAuth.d.ts.map +1 -0
- package/dist/cjs/SyncAuth.js +109 -0
- package/dist/cjs/SyncAuth.js.map +7 -0
- package/dist/cjs/SyncClient.d.ts +274 -0
- package/dist/cjs/SyncClient.d.ts.map +1 -0
- package/dist/cjs/SyncClient.js +474 -0
- package/dist/cjs/SyncClient.js.map +7 -0
- package/dist/cjs/SyncError.d.ts +107 -0
- package/dist/cjs/SyncError.d.ts.map +1 -0
- package/dist/cjs/SyncError.js +110 -0
- package/dist/cjs/SyncError.js.map +7 -0
- package/dist/cjs/SyncPrincipal.d.ts +100 -0
- package/dist/cjs/SyncPrincipal.d.ts.map +1 -0
- package/dist/cjs/SyncPrincipal.js +48 -0
- package/dist/cjs/SyncPrincipal.js.map +7 -0
- package/dist/cjs/SyncProtocol.d.ts +592 -0
- package/dist/cjs/SyncProtocol.d.ts.map +1 -0
- package/dist/cjs/SyncProtocol.js +165 -0
- package/dist/cjs/SyncProtocol.js.map +7 -0
- package/dist/cjs/SyncRpcs.d.ts +92 -0
- package/dist/cjs/SyncRpcs.d.ts.map +1 -0
- package/dist/cjs/SyncRpcs.js +35 -0
- package/dist/cjs/SyncRpcs.js.map +7 -0
- package/dist/cjs/SyncServer.d.ts +184 -0
- package/dist/cjs/SyncServer.d.ts.map +1 -0
- package/dist/cjs/SyncServer.js +513 -0
- package/dist/cjs/SyncServer.js.map +7 -0
- package/dist/cjs/WorkspaceShare.d.ts +174 -0
- package/dist/cjs/WorkspaceShare.d.ts.map +1 -0
- package/dist/cjs/WorkspaceShare.js +149 -0
- package/dist/cjs/WorkspaceShare.js.map +7 -0
- package/dist/cjs/index.d.ts +91 -0
- package/dist/cjs/index.d.ts.map +1 -0
- package/dist/cjs/index.js +67 -0
- package/dist/cjs/index.js.map +7 -0
- package/dist/cjs/internal/Admission.d.ts +43 -0
- package/dist/cjs/internal/Admission.d.ts.map +1 -0
- package/dist/cjs/internal/Admission.js +90 -0
- package/dist/cjs/internal/Admission.js.map +7 -0
- package/dist/cjs/internal/CauseText.d.ts +55 -0
- package/dist/cjs/internal/CauseText.d.ts.map +1 -0
- package/dist/cjs/internal/CauseText.js +38 -0
- package/dist/cjs/internal/CauseText.js.map +7 -0
- package/dist/cjs/internal/JournalErrorCode.d.ts +23 -0
- package/dist/cjs/internal/JournalErrorCode.d.ts.map +1 -0
- package/dist/cjs/internal/JournalErrorCode.js +34 -0
- package/dist/cjs/internal/JournalErrorCode.js.map +7 -0
- package/dist/cjs/internal/PolicyOptions.d.ts +57 -0
- package/dist/cjs/internal/PolicyOptions.d.ts.map +1 -0
- package/dist/cjs/internal/PolicyOptions.js +62 -0
- package/dist/cjs/internal/PolicyOptions.js.map +7 -0
- package/dist/cjs/internal/ProtocolVersion.d.ts +17 -0
- package/dist/cjs/internal/ProtocolVersion.d.ts.map +1 -0
- package/dist/cjs/internal/ProtocolVersion.js +44 -0
- package/dist/cjs/internal/ProtocolVersion.js.map +7 -0
- package/dist/cjs/internal/ShareSigner.d.ts +173 -0
- package/dist/cjs/internal/ShareSigner.d.ts.map +1 -0
- package/dist/cjs/internal/ShareSigner.js +150 -0
- package/dist/cjs/internal/ShareSigner.js.map +7 -0
- package/dist/cjs/internal/SnapshotBoundary.d.ts +17 -0
- package/dist/cjs/internal/SnapshotBoundary.d.ts.map +1 -0
- package/dist/cjs/internal/SnapshotBoundary.js +97 -0
- package/dist/cjs/internal/SnapshotBoundary.js.map +7 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/test/TestSocket.d.ts +55 -0
- package/dist/cjs/test/TestSocket.d.ts.map +1 -0
- package/dist/cjs/test/TestSocket.js +145 -0
- package/dist/cjs/test/TestSocket.js.map +7 -0
- package/dist/cjs/test/TestSync.d.ts +54 -0
- package/dist/cjs/test/TestSync.d.ts.map +1 -0
- package/dist/cjs/test/TestSync.js +96 -0
- package/dist/cjs/test/TestSync.js.map +7 -0
- package/dist/esm/BranchCommands.d.ts +228 -0
- package/dist/esm/BranchCommands.d.ts.map +1 -0
- package/dist/esm/BranchCommands.js +496 -0
- package/dist/esm/BranchCommands.js.map +1 -0
- package/dist/esm/BranchIds.d.ts +65 -0
- package/dist/esm/BranchIds.d.ts.map +1 -0
- package/dist/esm/BranchIds.js +61 -0
- package/dist/esm/BranchIds.js.map +1 -0
- package/dist/esm/BranchPresence.d.ts +195 -0
- package/dist/esm/BranchPresence.d.ts.map +1 -0
- package/dist/esm/BranchPresence.js +361 -0
- package/dist/esm/BranchPresence.js.map +1 -0
- package/dist/esm/BranchProjection.d.ts +179 -0
- package/dist/esm/BranchProjection.d.ts.map +1 -0
- package/dist/esm/BranchProjection.js +269 -0
- package/dist/esm/BranchProjection.js.map +1 -0
- package/dist/esm/BranchProtocol.d.ts +415 -0
- package/dist/esm/BranchProtocol.d.ts.map +1 -0
- package/dist/esm/BranchProtocol.js +323 -0
- package/dist/esm/BranchProtocol.js.map +1 -0
- package/dist/esm/BranchRpcs.d.ts +177 -0
- package/dist/esm/BranchRpcs.d.ts.map +1 -0
- package/dist/esm/BranchRpcs.js +103 -0
- package/dist/esm/BranchRpcs.js.map +1 -0
- package/dist/esm/BranchServer.d.ts +55 -0
- package/dist/esm/BranchServer.d.ts.map +1 -0
- package/dist/esm/BranchServer.js +168 -0
- package/dist/esm/BranchServer.js.map +1 -0
- package/dist/esm/BranchShare.d.ts +159 -0
- package/dist/esm/BranchShare.d.ts.map +1 -0
- package/dist/esm/BranchShare.js +213 -0
- package/dist/esm/BranchShare.js.map +1 -0
- package/dist/esm/RunCatalog.d.ts +207 -0
- package/dist/esm/RunCatalog.d.ts.map +1 -0
- package/dist/esm/RunCatalog.js +196 -0
- package/dist/esm/RunCatalog.js.map +1 -0
- package/dist/esm/SyncAuth.d.ts +70 -0
- package/dist/esm/SyncAuth.d.ts.map +1 -0
- package/dist/esm/SyncAuth.js +119 -0
- package/dist/esm/SyncAuth.js.map +1 -0
- package/dist/esm/SyncClient.d.ts +274 -0
- package/dist/esm/SyncClient.d.ts.map +1 -0
- package/dist/esm/SyncClient.js +586 -0
- package/dist/esm/SyncClient.js.map +1 -0
- package/dist/esm/SyncError.d.ts +107 -0
- package/dist/esm/SyncError.d.ts.map +1 -0
- package/dist/esm/SyncError.js +133 -0
- package/dist/esm/SyncError.js.map +1 -0
- package/dist/esm/SyncPrincipal.d.ts +100 -0
- package/dist/esm/SyncPrincipal.d.ts.map +1 -0
- package/dist/esm/SyncPrincipal.js +58 -0
- package/dist/esm/SyncPrincipal.js.map +1 -0
- package/dist/esm/SyncProtocol.d.ts +592 -0
- package/dist/esm/SyncProtocol.d.ts.map +1 -0
- package/dist/esm/SyncProtocol.js +434 -0
- package/dist/esm/SyncProtocol.js.map +1 -0
- package/dist/esm/SyncRpcs.d.ts +92 -0
- package/dist/esm/SyncRpcs.d.ts.map +1 -0
- package/dist/esm/SyncRpcs.js +35 -0
- package/dist/esm/SyncRpcs.js.map +1 -0
- package/dist/esm/SyncServer.d.ts +184 -0
- package/dist/esm/SyncServer.d.ts.map +1 -0
- package/dist/esm/SyncServer.js +897 -0
- package/dist/esm/SyncServer.js.map +1 -0
- package/dist/esm/WorkspaceShare.d.ts +174 -0
- package/dist/esm/WorkspaceShare.d.ts.map +1 -0
- package/dist/esm/WorkspaceShare.js +208 -0
- package/dist/esm/WorkspaceShare.js.map +1 -0
- package/dist/esm/index.d.ts +91 -0
- package/dist/esm/index.d.ts.map +1 -0
- package/dist/esm/index.js +91 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/internal/Admission.d.ts +43 -0
- package/dist/esm/internal/Admission.d.ts.map +1 -0
- package/dist/esm/internal/Admission.js +80 -0
- package/dist/esm/internal/Admission.js.map +1 -0
- package/dist/esm/internal/CauseText.d.ts +55 -0
- package/dist/esm/internal/CauseText.d.ts.map +1 -0
- package/dist/esm/internal/CauseText.js +67 -0
- package/dist/esm/internal/CauseText.js.map +1 -0
- package/dist/esm/internal/JournalErrorCode.d.ts +23 -0
- package/dist/esm/internal/JournalErrorCode.d.ts.map +1 -0
- package/dist/esm/internal/JournalErrorCode.js +44 -0
- package/dist/esm/internal/JournalErrorCode.js.map +1 -0
- package/dist/esm/internal/PolicyOptions.d.ts +57 -0
- package/dist/esm/internal/PolicyOptions.d.ts.map +1 -0
- package/dist/esm/internal/PolicyOptions.js +70 -0
- package/dist/esm/internal/PolicyOptions.js.map +1 -0
- package/dist/esm/internal/ProtocolVersion.d.ts +17 -0
- package/dist/esm/internal/ProtocolVersion.d.ts.map +1 -0
- package/dist/esm/internal/ProtocolVersion.js +24 -0
- package/dist/esm/internal/ProtocolVersion.js.map +1 -0
- package/dist/esm/internal/ShareSigner.d.ts +173 -0
- package/dist/esm/internal/ShareSigner.d.ts.map +1 -0
- package/dist/esm/internal/ShareSigner.js +219 -0
- package/dist/esm/internal/ShareSigner.js.map +1 -0
- package/dist/esm/internal/SnapshotBoundary.d.ts +17 -0
- package/dist/esm/internal/SnapshotBoundary.d.ts.map +1 -0
- package/dist/esm/internal/SnapshotBoundary.js +68 -0
- package/dist/esm/internal/SnapshotBoundary.js.map +1 -0
- package/dist/esm/test/TestSocket.d.ts +55 -0
- package/dist/esm/test/TestSocket.d.ts.map +1 -0
- package/dist/esm/test/TestSocket.js +128 -0
- package/dist/esm/test/TestSocket.js.map +1 -0
- package/dist/esm/test/TestSync.d.ts +54 -0
- package/dist/esm/test/TestSync.d.ts.map +1 -0
- package/dist/esm/test/TestSync.js +76 -0
- package/dist/esm/test/TestSync.js.map +1 -0
- package/docs/README.md +156 -0
- package/docs/api.md +328 -0
- package/docs/concepts/authorization.md +117 -0
- package/docs/concepts/branches.md +195 -0
- package/docs/concepts/compaction.md +144 -0
- package/docs/concepts/replay-then-follow.md +129 -0
- package/docs/concepts/scopes-and-cursors.md +135 -0
- package/docs/guides/authorize-a-connection.md +174 -0
- package/docs/guides/follow-a-run.md +174 -0
- package/docs/guides/handle-a-compacted-run.md +158 -0
- package/docs/guides/list-workspace-runs.md +120 -0
- package/docs/guides/run-a-long-soak.md +80 -0
- package/docs/guides/serve-the-read-path.md +146 -0
- package/docs/guides/test-a-follower.md +273 -0
- package/docs/installation.md +92 -0
- package/docs/protocol.md +183 -0
- package/docs/quickstart.md +159 -0
- package/docs/troubleshooting.md +234 -0
- package/package.json +304 -3
- package/src/BranchCommands.ts +684 -0
- package/src/BranchIds.ts +76 -0
- package/src/BranchPresence.ts +512 -0
- package/src/BranchProjection.ts +308 -0
- package/src/BranchProtocol.ts +411 -0
- package/src/BranchRpcs.ts +163 -0
- package/src/BranchServer.ts +202 -0
- package/src/BranchShare.ts +297 -0
- package/src/RunCatalog.ts +308 -0
- package/src/SyncAuth.ts +146 -0
- package/src/SyncClient.ts +902 -0
- package/src/SyncError.ts +147 -0
- package/src/SyncPrincipal.ts +115 -0
- package/src/SyncProtocol.ts +594 -0
- package/src/SyncRpcs.ts +40 -0
- package/src/SyncServer.ts +1184 -0
- package/src/WorkspaceShare.ts +292 -0
- package/src/index.ts +107 -0
- package/src/internal/Admission.ts +119 -0
- package/src/internal/CauseText.ts +74 -0
- package/src/internal/JournalErrorCode.ts +46 -0
- package/src/internal/PolicyOptions.ts +97 -0
- package/src/internal/ProtocolVersion.ts +28 -0
- package/src/internal/ShareSigner.ts +300 -0
- package/src/internal/SnapshotBoundary.ts +91 -0
- package/src/test/TestSocket.ts +192 -0
- 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
|
+
}) {}
|