@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
package/docs/api.md
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "API reference"
|
|
3
|
+
description: "Every public export of @smthrs/sync: the wire protocol, the read-path RPC group, the fail-closed server, the replay-then-follow client, the run catalog, the two capability authorities, and the branch collaboration modules."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
```ts
|
|
7
|
+
import { JournalEvent } from "@smthrs/journal"
|
|
8
|
+
import { RunCatalog, SyncClient, SyncServer } from "@smthrs/sync"
|
|
9
|
+
import { Effect, Layer } from "effect"
|
|
10
|
+
|
|
11
|
+
const serverLayer = SyncServer.layer.pipe(Layer.provide(RunCatalog.layerStatic([])))
|
|
12
|
+
|
|
13
|
+
const follow = Effect.gen(function*() {
|
|
14
|
+
const sync = yield* SyncClient.SyncClient
|
|
15
|
+
return sync.subscribe({ scope: { _tag: "Run", runId: "build-42" as JournalEvent.RunId }, cursors: [] })
|
|
16
|
+
})
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Entry points
|
|
20
|
+
|
|
21
|
+
| Import | Source | Platform |
|
|
22
|
+
| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------ | -------- |
|
|
23
|
+
| `@smthrs/sync` | [src/index.ts](https://github.com/smithersai/smithers/blob/main/packages/smithers/flows/sync/src/index.ts) | any |
|
|
24
|
+
| `@smthrs/sync/test/TestSync` | [src/test/TestSync.ts](https://github.com/smithersai/smithers/blob/main/packages/smithers/flows/sync/src/test/TestSync.ts) | Node |
|
|
25
|
+
| `@smthrs/sync/test/TestSocket` | [src/test/TestSocket.ts](https://github.com/smithersai/smithers/blob/main/packages/smithers/flows/sync/src/test/TestSocket.ts) | any |
|
|
26
|
+
|
|
27
|
+
The root is browser safe, and the package's own bundle check holds it that
|
|
28
|
+
way. The signing paths call Web Crypto directly for that reason, so one module
|
|
29
|
+
serves Node and the browser. `TestSync.layerTest` binds the Node SQLite test
|
|
30
|
+
journal, which is why it is a separate subpath.
|
|
31
|
+
|
|
32
|
+
## Authorization
|
|
33
|
+
|
|
34
|
+
Fail closed along two boundaries, both consulted per request:
|
|
35
|
+
|
|
36
|
+
- **Branch runs.** A run whose id maps to a shared branch is visible only when
|
|
37
|
+
the request's share capability verifies for that branch. An explicitly scoped
|
|
38
|
+
branch read without one fails; a workspace listing excludes the branch runs
|
|
39
|
+
the caller's capability does not cover. Without a `BranchShare` in scope
|
|
40
|
+
every branch run is closed.
|
|
41
|
+
- **Non-branch runs and workspace listings.** Visible only to the workspace
|
|
42
|
+
principal (`SyncPrincipal`), whose default is anonymous. Over RPC,
|
|
43
|
+
`SyncAuth.layer` establishes that principal by verifying the
|
|
44
|
+
`WorkspaceShare` capability presented in the `flows-sync-workspace` header.
|
|
45
|
+
A header that is present but malformed, forged, expired, or signed by an
|
|
46
|
+
unknown key is refused outright rather than downgraded to anonymity.
|
|
47
|
+
|
|
48
|
+
A SUBSCRIPTION is additionally bounded in time. It is authorized once, when it
|
|
49
|
+
opens, so the signed expiry travels with the identity (`SyncPrincipal.Workspace.expiresAtMs`,
|
|
50
|
+
or the branch capability's own `expiresAtMs`) and the stream ends with
|
|
51
|
+
`unauthorized` when that moment arrives. An in-process owner that provides
|
|
52
|
+
`SyncPrincipal` itself presented no credential and has no deadline.
|
|
53
|
+
|
|
54
|
+
Both authorities sign a length-prefixed encoding of their claims under
|
|
55
|
+
HMAC-SHA-256, led by a scheme label, so neither's signature can be replayed as
|
|
56
|
+
the other's under a shared secret. Length prefixes count UTF-8 bytes, and a
|
|
57
|
+
claim set that does not survive UTF-8 (an unpaired surrogate) is refused with
|
|
58
|
+
`invalid_request` rather than signed. Secrets are `Redacted` on both sides and
|
|
59
|
+
must be at least 32 bytes of UTF-8, or the keyring is refused with
|
|
60
|
+
`invalid_request`. Both carry a `kid` inside the signed claims, so keys rotate without
|
|
61
|
+
invalidating capabilities minted under a retired one. Each authority takes its
|
|
62
|
+
own keyring: `BranchShare.layerConfig` reads `SMITHERS_SYNC_BRANCH_SECRET` and
|
|
63
|
+
`SMITHERS_SYNC_BRANCH_KEY_ID`, `WorkspaceShare.layerConfig` reads
|
|
64
|
+
`SMITHERS_SYNC_SECRET` and `SMITHERS_SYNC_KEY_ID`.
|
|
65
|
+
|
|
66
|
+
Both authorities decode their mint request before signing it, so an empty id
|
|
67
|
+
or a non-positive `ttlMs` from an in-process caller is refused with
|
|
68
|
+
`invalid_request` rather than crashing the operation. `BranchCommands.submission`
|
|
69
|
+
returns an `Effect` for the same reason: a command name the schema forbids is a
|
|
70
|
+
refusal, not a throw.
|
|
71
|
+
|
|
72
|
+
## Bounds
|
|
73
|
+
|
|
74
|
+
The bounds cap what is in flight at once: entries per page, frames per
|
|
75
|
+
subscription, journal reads open per workspace subscription, bytes per frame.
|
|
76
|
+
What one follower holds open is therefore a function of the configured bound,
|
|
77
|
+
not of how far behind it has fallen. Two things still scale with the workspace.
|
|
78
|
+
A workspace subscription keeps a served position for every run it has ever
|
|
79
|
+
covered, including a run the catalog later drops, so its bookkeeping is O(runs
|
|
80
|
+
covered). A catalog-wide round, which runs at open, on an announcement, and
|
|
81
|
+
once per `tailIntervalMs`, reads one page and two generations per covered run,
|
|
82
|
+
so its work is O(runs listed) with `concurrency` reads open at a time. A wake
|
|
83
|
+
for an entry committed in this process reads only the runs it named. Size
|
|
84
|
+
`concurrency` and `tailIntervalMs` for the catalog, not only for the follower.
|
|
85
|
+
|
|
86
|
+
| Bound | Default | What it caps |
|
|
87
|
+
| ------------------------------------------------ | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
88
|
+
| `SyncProtocol.maxReadLimit` | 1024 | Entries one `Sync.Read` may ask for. Over the limit is refused at the wire; an in-process caller is clamped. |
|
|
89
|
+
| `SyncProtocol.maxSubscribeCredit` | 4096 | Frames one `Sync.Subscribe` may hold open. Zero is refused rather than served as an empty stream. |
|
|
90
|
+
| `SyncServer.Options.concurrency` | `SyncServer.defaultConcurrency` (64) | Journal reads one workspace subscription holds open at once. A round reads one bounded page per run it visits, so a busy run wakes the next round instead of holding its slot. |
|
|
91
|
+
| `SyncServer.Options.tailIntervalMs` | `SyncServer.defaultTailIntervalMs` (1000) | Milliseconds a workspace subscription waits before revisiting every covered run when nothing wakes it, and the longest that local wakes may defer that catalog-wide round. |
|
|
92
|
+
| `SyncServer.Options.maxFrameBytes` | `SyncProtocol.defaultMaxFrameBytes` (2 MiB) | Summed encoded entries of one read page or subscription frame. |
|
|
93
|
+
| `SyncClient.SubscribeOptions.credit` | `SyncClient.defaultCredit` (256) | Frames one subscription round carries before the follow replenishes the window by resubscribing from its acknowledged cursors. |
|
|
94
|
+
| `SyncClient.Options.bootstrapLimit` | `SyncClient.defaultBootstrapLimit` (256) | Entries one catch-up page asks for. |
|
|
95
|
+
| `BranchCommands.Options.maxCommandBytes` | `BranchCommands.defaultMaxCommandBytes` (1 MiB) | Encoded size of one command submission, refused before anything is appended. |
|
|
96
|
+
| `BranchCommands.Options.ledgerCapacity` | `BranchCommands.defaultLedgerCapacity` (4096) | Receipts one branch keeps in memory. The journal's producer identity is the durable dedupe, so an evicted receipt costs a round trip and never correctness. |
|
|
97
|
+
| `BranchCommands.Options.hydrationLimit` | `BranchCommands.defaultHydrationLimit` (4096) | Entries one branch's first-touch hydration reads before it stops, so a long history is not charged to the next writer's latency. What the walk misses, the journal answers. |
|
|
98
|
+
| `BranchCommands.Options.branchIdleMs` | `BranchCommands.defaultBranchIdleMs` (24 h) | Untouched time before a branch's in-memory state is dropped. The next submission re-hydrates it from the journal, as after a restart. |
|
|
99
|
+
| `BranchPresence.PresenceOptions.maxParticipants` | `BranchPresence.defaultMaxParticipants` (256) | Participants one branch may hold at once; a further announce is refused with `backpressure`. |
|
|
100
|
+
| `RunCatalog.MemoryOptions.changesCapacity` | `RunCatalog.defaultChangesCapacity` (1024) | Announcements a stalled `changes` subscriber may fall behind by; the oldest slide out. |
|
|
101
|
+
| `RunCatalog.PollingOptions.intervalMs` | `RunCatalog.defaultPollIntervalMs` (1000) | Milliseconds between reads of the durable run set: one bounded query per interval per composition, not per subscriber. |
|
|
102
|
+
| `BranchPresence.PresenceOptions.changesCapacity` | `BranchPresence.defaultChangesCapacity` (256) | Roster notifications a stalled `changes` subscriber may fall behind by; the oldest slide out. |
|
|
103
|
+
|
|
104
|
+
Every numeric option is validated where it enters. A policy belongs to the
|
|
105
|
+
constructor that takes it: `SyncServer.makeLiveWith`, `BranchCommands.makeLiveWith`,
|
|
106
|
+
`SyncClient.makeWith`, `BranchPresence.makeMemory`, `RunCatalog.makeMemory` and
|
|
107
|
+
`RunCatalog.makePolling`, plus the `layerWith`, `layerMemory` and `layerPolling`
|
|
108
|
+
layers over them, fail with `invalid_request` when a value is not a positive
|
|
109
|
+
safe integer, so a bad policy fails the composition instead of quietly
|
|
110
|
+
disabling the comparison it configures. The plain `make`, `makeLive` and
|
|
111
|
+
`layer` forms carry the defaults, which are valid by construction, and cannot
|
|
112
|
+
fail on a policy. A count a REQUEST carries rather than a policy, meaning
|
|
113
|
+
`Sync.Read`'s `limit` and `SyncClient.SubscribeOptions.credit`, is checked
|
|
114
|
+
where the request is built.
|
|
115
|
+
|
|
116
|
+
Both change feeds slide rather than block: a publisher never waits on a stalled
|
|
117
|
+
subscriber and never grows the process on its behalf. Neither feed is a source
|
|
118
|
+
of truth. `RunCatalog.list` and `BranchPresence.list` are the authoritative
|
|
119
|
+
state, and every reader re-lists on a cadence of its own: a workspace
|
|
120
|
+
subscription reconciles its covered run set against `RunCatalog.list` on every
|
|
121
|
+
round, and a roster watch re-lists once per `BranchPresence` lease, capped at one second. A dropped
|
|
122
|
+
notification therefore costs latency and never state.
|
|
123
|
+
|
|
124
|
+
## Read path
|
|
125
|
+
|
|
126
|
+
`SyncServer.Service` has `read(request)`, `subscribe(request)`, and `snapshot(request)`.
|
|
127
|
+
`makeLiveWith(options)` and `layerWith(options)` take the `Options` policy
|
|
128
|
+
above; `makeLive` and `layer` use the defaults. The live layer requires
|
|
129
|
+
`Journal` and `RunCatalog`.
|
|
130
|
+
|
|
131
|
+
`SyncServer.SnapshotSource` is an optional host service with
|
|
132
|
+
`read(SnapshotRequest): Effect<Snapshot, SyncError>`. It provides only public
|
|
133
|
+
projections, never raw unredacted execution checkpoints. The server authorizes
|
|
134
|
+
the run/branch before invoking it, checks expiry before returning data, and
|
|
135
|
+
refuses missing providers. Providers must select the exact requested lineage,
|
|
136
|
+
projection and version and retain state covering the requested sequence.
|
|
137
|
+
|
|
138
|
+
`SyncClient.Service.snapshot(request)` fetches that projection through
|
|
139
|
+
`Sync.Snapshot` without applying state or advancing cursors. `SnapshotRequest`
|
|
140
|
+
and `Snapshot` in `SyncProtocol` carry protocol version 1, run/lineage identity,
|
|
141
|
+
projection name/version, minimum or actual sequence, and response JSON state.
|
|
142
|
+
Both ends validate identities and the full response's encoded UTF-8 byte limit
|
|
143
|
+
using `maxFrameBytes`. Invalid or stale state is refused, not coerced or skipped.
|
|
144
|
+
|
|
145
|
+
`SyncClient.Service.progress` returns `SyncProtocol.Progress`: two cursor sets,
|
|
146
|
+
`delivered` and `applied`. `delivered` is a delivery bookmark only. `applied`
|
|
147
|
+
advances only after successful application or restoration; an applying subscription
|
|
148
|
+
uses the shared applied map when choosing its start position. Use one client
|
|
149
|
+
per projection and persist projection state and its cursor in one transaction.
|
|
150
|
+
|
|
151
|
+
A read shares its page across the runs it covers and stops at the first of three
|
|
152
|
+
bounds: the request's `limit`, the frame ceiling, or the durable tail of every
|
|
153
|
+
covered run, which is the only case that reports `done: true`. Every covered run
|
|
154
|
+
takes a share of the budget before any run takes a second helping, and the
|
|
155
|
+
budget the shares leave unspent is offered back in run order, so a run with a
|
|
156
|
+
backlog takes the larger part of a page but never all of it. Filling in run
|
|
157
|
+
order instead let a producer that stayed one page ahead take every slot of every
|
|
158
|
+
page, so `done` never became true and a bootstrapping follower never reached the
|
|
159
|
+
runs behind it. The frame ceiling is a page budget rather than a verdict on the
|
|
160
|
+
read, so a page whose entries sum past it reports `done: false`; only a SINGLE
|
|
161
|
+
entry whose own encoded size exceeds the ceiling is refused with
|
|
162
|
+
`frame_too_large`, because no page can ever carry it.
|
|
163
|
+
|
|
164
|
+
Cursors are unique per run. A request that names one run twice is refused with
|
|
165
|
+
`invalid_request` on both `read` and `subscribe`, because the read position and
|
|
166
|
+
the echoed response state would otherwise disagree about where the page began.
|
|
167
|
+
|
|
168
|
+
## Follow path
|
|
169
|
+
|
|
170
|
+
`SyncClient.SyncClient` is the browser-safe service tag; `SyncClient.Sync` is
|
|
171
|
+
its deprecated former name. `make({ client })` adapts an Effect RPC client and
|
|
172
|
+
`layer` derives that client from `RpcClient.Protocol`.
|
|
173
|
+
A subscription replays through `Sync.Read` until the server reports `done`,
|
|
174
|
+
then follows through `Sync.Subscribe` in credit windows, replenishing each
|
|
175
|
+
window by resubscribing from its matching delivered or applied progress.
|
|
176
|
+
|
|
177
|
+
A frame or page whose encoded entries exceed `maxFrameBytes` is refused with
|
|
178
|
+
`frame_too_large`. Out-of-scope entries, repeated or reordered sequences, and
|
|
179
|
+
missing response generations fail with `protocol_violation` before delivery.
|
|
180
|
+
Each run represented in a `Sync.Read` page's entries must have exactly one
|
|
181
|
+
cursor with an explicit generation matching the request, and every entry must
|
|
182
|
+
be above the requested cursor. Live frames instead drop already-covered
|
|
183
|
+
entries and deliver only the suffix above the cursor. An incomplete bootstrap
|
|
184
|
+
page with no entries or a completed subscription window with no frames fails
|
|
185
|
+
with `protocol_violation` instead of reopening immediately.
|
|
186
|
+
|
|
187
|
+
Transport, authentication, and reconnect are handled here rather than left to
|
|
188
|
+
the application. A live follow that loses its transport reconnects under
|
|
189
|
+
exponential backoff capped at five seconds, resuming from the subscription's
|
|
190
|
+
progress; gaps, authorization refusals, and server closes propagate to the
|
|
191
|
+
consumer instead of retrying.
|
|
192
|
+
|
|
193
|
+
A delivery bookmark names what was delivered. `SubscribeOptions.apply`
|
|
194
|
+
additionally records `progress.applied`: the callback runs to success before
|
|
195
|
+
that cursor moves, so a failed application is retried by the next applying
|
|
196
|
+
subscription. A delivery-only subscription cannot acknowledge application.
|
|
197
|
+
|
|
198
|
+
`apply` and `onResync` fail with the consumer's own error types, and
|
|
199
|
+
`subscribe` returns `Stream<Entry, SyncError | SyncGapError | EApply | EResync>`.
|
|
200
|
+
A consumer failure keeps its own type, so it never borrows a wire code and
|
|
201
|
+
never passes `SyncError.is`.
|
|
202
|
+
|
|
203
|
+
## Compaction and resync
|
|
204
|
+
|
|
205
|
+
Compaction deletes a run's entries below a checkpoint, so a cursor under that
|
|
206
|
+
floor names history that no longer exists. The server maps the journal's
|
|
207
|
+
`compacted` failure onto its own code with the run id the read was issued for,
|
|
208
|
+
and `SyncError.resync` carries `{ runId, checkpointSeq, generation }`. The client
|
|
209
|
+
fails closed unless `onResync` restores a snapshot and returns its
|
|
210
|
+
`{ runId, afterSeq }`. It validates that receipt before advancing to the actual
|
|
211
|
+
restored sequence, in the refusal's generation unless the receipt names its own. A
|
|
212
|
+
checkpoint at or below what the subscription already covers cannot move the
|
|
213
|
+
cursor forward, so it stays a failure rather than a retry of the same refusal.
|
|
214
|
+
|
|
215
|
+
:::warning
|
|
216
|
+
The resync moves a cursor, not state. The entries below `checkpointSeq` are
|
|
217
|
+
gone from the journal. `SyncClient.snapshot` can fetch a configured public
|
|
218
|
+
projection, but does not apply it. `SubscribeOptions.onResync` is the seam a consumer fills that hole
|
|
219
|
+
through: it runs BEFORE the cursor moves and must return a valid restored cursor, so a failure leaves
|
|
220
|
+
the cursor where it was and nothing is skipped silently. A Node follower reads
|
|
221
|
+
the prefix from `Journal.latestCheckpoint(runId)` and applies it. With no handler,
|
|
222
|
+
the original refusal is preserved. A consumer that cannot restore the prefix
|
|
223
|
+
must not return an applied receipt for it. Durable consumers must commit snapshot state and
|
|
224
|
+
their durable cursor in their own transaction; the client cursor is in memory.
|
|
225
|
+
:::
|
|
226
|
+
|
|
227
|
+
## Errors
|
|
228
|
+
|
|
229
|
+
`ErrorCode` includes `unsupported` (since 1.0.0) for unavailable host
|
|
230
|
+
operations or presence transport; it does not mean an empty roster.
|
|
231
|
+
|
|
232
|
+
`SyncError` carries a stable `code` from `ErrorCode`, a `message`, an optional
|
|
233
|
+
bounded `cause` string, and a `resync` that is set only on `compacted`.
|
|
234
|
+
`SyncGapError` reports a server interval that starts beyond the client's
|
|
235
|
+
covered cursor.
|
|
236
|
+
|
|
237
|
+
`cause` is a STRING and never the host object that failed. `SyncError` is the
|
|
238
|
+
declared error schema of every RPC in both groups, so what it carries reaches a
|
|
239
|
+
remote follower that may hold nothing but a branch share link: a journal
|
|
240
|
+
failure crosses as its stable journal code, never as the driver's own sentence,
|
|
241
|
+
and the public message names the run rather than the storage fault.
|
|
242
|
+
|
|
243
|
+
`SyncError.is` is a structural check: the tag, a declared `code`, a string
|
|
244
|
+
`message`, and a `resync` only alongside `compacted`. It deliberately does not
|
|
245
|
+
require the prototype, because every value that reaches it has crossed a
|
|
246
|
+
boundary that rebuilds it.
|
|
247
|
+
|
|
248
|
+
## Branch collaboration
|
|
249
|
+
|
|
250
|
+
`BranchPresence.Service.presenceOn(request)` (since 1.0.0) returns
|
|
251
|
+
`"unknown" | "present" | "empty"`, authorizing read access first. It stays
|
|
252
|
+
`unknown` for one configured `leaseMs` after startup so live participants can
|
|
253
|
+
re-announce, and while any required heartbeat source is unavailable. Treat
|
|
254
|
+
`unknown` as present for safe-idle decisions.
|
|
255
|
+
|
|
256
|
+
`PresenceOptions.sourcesReady(branchId)` (since 1.0.0) supplies an
|
|
257
|
+
`Effect<boolean>` readiness check for authorization, revocation, bridge and
|
|
258
|
+
session sources. It defaults to unavailable.
|
|
259
|
+
|
|
260
|
+
`BranchPresence.makeNoop()` and `layerNoop` fail announce, leave and list with
|
|
261
|
+
`unsupported`; `presenceOn` returns `unknown`. Consequently
|
|
262
|
+
`BranchServer` roster reads and watches also fail with `unsupported` under
|
|
263
|
+
`layerNoop`, rather than reporting an empty roster.
|
|
264
|
+
|
|
265
|
+
A branch is one shared live document whose durable state is exactly one journal
|
|
266
|
+
run (`BranchProtocol.branchRunId`), so multiplayer reuses the canonical `seq`,
|
|
267
|
+
cursors, gap detection, and resumable follow rather than introducing a second
|
|
268
|
+
source of truth. Presence is a lease and is never journalled. Commands are
|
|
269
|
+
admitted through a client-minted idempotency key whose exactly-once constraint
|
|
270
|
+
is the journal's own producer identity, so two servers racing one command
|
|
271
|
+
collide durably inside the write transaction.
|
|
272
|
+
|
|
273
|
+
`BranchRpcs` is the wire group and `BranchServer.layerHandlers` projects the
|
|
274
|
+
branch services onto it. The payload schemas ARE the service schemas, so the
|
|
275
|
+
wire and the services cannot drift about what a legal message is.
|
|
276
|
+
|
|
277
|
+
:::warning
|
|
278
|
+
Branch collaboration ships unserved at 1.0.0-rc.0.
|
|
279
|
+
[`@smthrs/gateway`](/api/gateway) mounts `SyncRpcs` on `POST /sync` and
|
|
280
|
+
`/sync/ws`; nothing outside this package's own tests mounts `BranchRpcs`, so
|
|
281
|
+
the seven branch procedures have no integration against a real gateway yet.
|
|
282
|
+
Treat the branch modules as a library surface pending a host, not as a served
|
|
283
|
+
endpoint.
|
|
284
|
+
:::
|
|
285
|
+
|
|
286
|
+
## Test helpers
|
|
287
|
+
|
|
288
|
+
| Export | Source | Notes |
|
|
289
|
+
| -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------ |
|
|
290
|
+
| `TestSync.layerTest`, `layerTrustAllAsOwner`, `layerNoop`, `connect` | [src/test/TestSync.ts](https://github.com/smithersai/smithers/blob/main/packages/smithers/flows/sync/src/test/TestSync.ts) | a real server and client over an in-memory socket pair |
|
|
291
|
+
| `TestSocket.makePair`, `Pair`, `TestFaults`, `FrameFilter` | [src/test/TestSocket.ts](https://github.com/smithersai/smithers/blob/main/packages/smithers/flows/sync/src/test/TestSocket.ts) | fault-injecting socket pair |
|
|
292
|
+
|
|
293
|
+
Subscription fan-out is held to budgets, not only to frame assertions. The
|
|
294
|
+
package's soak suite runs five concurrent workspace subscribers and requires an
|
|
295
|
+
identical frame set from each, drains 200 subscribe and complete cycles and
|
|
296
|
+
requires every per-run journal stream to be released afterwards, and soaks 200
|
|
297
|
+
five-subscriber rounds under a retained-heap budget. A regression that retains
|
|
298
|
+
per-subscriber state satisfies every frame assertion elsewhere, so those are
|
|
299
|
+
the checks that catch it.
|
|
300
|
+
|
|
301
|
+
The optional [long soak tier](./guides/run-a-long-soak.md) adds repeated
|
|
302
|
+
post-warmup resource samples, growth slopes, real TCP reconnects, on-disk
|
|
303
|
+
compaction and checkpoint retention, and a stalled subscriber. It writes a
|
|
304
|
+
machine-verifiable JSON artifact; ordinary PR gates do not start a timed run.
|
|
305
|
+
|
|
306
|
+
### Rewind generations
|
|
307
|
+
|
|
308
|
+
`ReadRequest` and `SubscribeRequest` carry `protocolVersion: 1`. The server
|
|
309
|
+
refuses a missing or different version with typed `protocol_violation`.
|
|
310
|
+
Server cursors and entry frames always carry a nonnegative `generation`,
|
|
311
|
+
including zero; a client refuses missing response generations with the same
|
|
312
|
+
error before delivery. Persisted request cursors may omit generation for zero.
|
|
313
|
+
Persist the returned
|
|
314
|
+
generation alongside `afterSeq`. A mismatch raises `lineage_changed` before
|
|
315
|
+
sequence deduplication. Server errors carry
|
|
316
|
+
`rewind: { runId, generation, afterSeq }`, where `afterSeq` is the archive
|
|
317
|
+
boundary (`-1` for a full reset). Rebuild the projection from the current
|
|
318
|
+
retained history through that boundary, create a fresh client, and resume from
|
|
319
|
+
that position. Idle subscriptions check generations at
|
|
320
|
+
`tailIntervalMs`, as well as around journal reads.
|
|
321
|
+
|
|
322
|
+
## Presence coordinates
|
|
323
|
+
|
|
324
|
+
`BranchProtocol.PresenceWhere` validates file coordinates (a nonempty path and
|
|
325
|
+
optional positive integer line), a terminal id, a step label, or a branch.
|
|
326
|
+
`Participant.where` is optional so recorded frames without coordinates decode.
|
|
327
|
+
A Leave with `sessionId` removes that seat; without it, Leave removes every seat
|
|
328
|
+
for the participant, after verifying ownership of all matching seats.
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Authorization"
|
|
3
|
+
description: "The two fail-closed boundaries of the read path, the workspace principal and the branch capability that cross them, and why a subscription carries its own expiry."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Authorization in `@smthrs/sync` fails closed along two boundaries, and both are
|
|
7
|
+
consulted on every request. Neither has a trusted pass-through: code that never
|
|
8
|
+
authenticates reads nothing.
|
|
9
|
+
|
|
10
|
+
## Boundary one: non-branch runs and workspace listings
|
|
11
|
+
|
|
12
|
+
Every run whose id does not map to a branch is visible only to the workspace
|
|
13
|
+
principal. `SyncPrincipal` is a `Context.Reference` whose default value is
|
|
14
|
+
`SyncPrincipal.anonymous`, which is what makes the default posture closed
|
|
15
|
+
rather than open. A workspace-scoped request and a run-scoped request for an
|
|
16
|
+
engine run are both refused with `unauthorized` for an anonymous caller.
|
|
17
|
+
|
|
18
|
+
There are exactly two ways to become the workspace principal:
|
|
19
|
+
|
|
20
|
+
- **Over a transport.** `SyncAuth.layer` verifies the `WorkspaceShare`
|
|
21
|
+
capability presented in the `flows-sync-workspace` request header and
|
|
22
|
+
installs the resulting principal. A request with no header runs as anonymous.
|
|
23
|
+
A header that is present but malformed, forged, expired, or signed by an
|
|
24
|
+
unknown key is refused outright rather than downgraded to anonymity, because
|
|
25
|
+
presenting a credential is a claim and a false claim is not the same thing as
|
|
26
|
+
no claim.
|
|
27
|
+
- **In process.** A caller that already owns the journal provides
|
|
28
|
+
`SyncPrincipal.layerWorkspace(capabilityId)` itself. That is the sanctioned
|
|
29
|
+
bypass, and it is never a transport's to take.
|
|
30
|
+
|
|
31
|
+
Every decode failure of the header folds to the same `unauthorized` refusal, so
|
|
32
|
+
the header is not a parsing oracle for an attacker probing its shape.
|
|
33
|
+
|
|
34
|
+
## Boundary two: branch runs
|
|
35
|
+
|
|
36
|
+
A run whose id carries the branch prefix is visible only when the request's own
|
|
37
|
+
`capability` verifies for that branch through `BranchShare`. The capability
|
|
38
|
+
travels in the request payload, not in a header, because a share link is a
|
|
39
|
+
per-branch credential rather than a per-connection one.
|
|
40
|
+
|
|
41
|
+
The two boundaries compose rather than override each other:
|
|
42
|
+
|
|
43
|
+
- An explicitly scoped branch read with no capability fails.
|
|
44
|
+
- A workspace listing excludes the branch runs the caller's capability does not
|
|
45
|
+
cover, so one share link never leaks another branch's log.
|
|
46
|
+
- With no `BranchShare` service in scope at all, every branch run is closed.
|
|
47
|
+
- A connection holding only a branch share link can never read an engine run,
|
|
48
|
+
because that is boundary one's question and the link does not answer it.
|
|
49
|
+
|
|
50
|
+
## What a capability is
|
|
51
|
+
|
|
52
|
+
Both authorities mint the same shape: a claim set plus an HMAC-SHA-256
|
|
53
|
+
signature over a length-prefixed canonical encoding of it, compared in constant
|
|
54
|
+
time. A capability names one subject, one access level (`read` or `write`), and
|
|
55
|
+
one expiry. The holder cannot widen it because widening changes the claims and
|
|
56
|
+
therefore the signature.
|
|
57
|
+
|
|
58
|
+
Three details make that hold up:
|
|
59
|
+
|
|
60
|
+
- **Length prefixes.** Without them a subject id ending in the separator could
|
|
61
|
+
be re-cut into a different, still validly signed claim set. Prefixes count
|
|
62
|
+
UTF-8 bytes, and a claim set that does not survive UTF-8 encoding, such as
|
|
63
|
+
one carrying an unpaired surrogate, is refused with `invalid_request` rather
|
|
64
|
+
than signed.
|
|
65
|
+
- **A scheme label leading the encoding.** A branch signature can never verify
|
|
66
|
+
as a workspace signature, even if one secret is misconfigured into both
|
|
67
|
+
authorities.
|
|
68
|
+
- **`Redacted` secrets.** A keyring never holds a plain string, so a log, a
|
|
69
|
+
span, or an inspection of the options object cannot render it.
|
|
70
|
+
|
|
71
|
+
Both authorities carry a `kid` inside the signed claims. The verifier both
|
|
72
|
+
selects the right key and refuses a capability whose key name was swapped after
|
|
73
|
+
minting, which is what lets a deployment rotate keys while capabilities minted
|
|
74
|
+
under a retired key are still outstanding. The keyrings are separate:
|
|
75
|
+
`BranchShare.layerConfig` provisions the branch authority from
|
|
76
|
+
`SMITHERS_SYNC_BRANCH_SECRET` and `SMITHERS_SYNC_BRANCH_KEY_ID`, so rotating
|
|
77
|
+
one credential leaves the other's outstanding capabilities alone. See
|
|
78
|
+
[Authorize a connection](../guides/authorize-a-connection.md).
|
|
79
|
+
|
|
80
|
+
## A subscription carries its own deadline
|
|
81
|
+
|
|
82
|
+
A signed expiry is this package's only revocation mechanism, and a subscription
|
|
83
|
+
is not a sequence of requests. It is authorized once, at open, and then
|
|
84
|
+
streams. Without a deadline travelling with the identity, a holder of an
|
|
85
|
+
expired capability could keep reading for as long as it declined to disconnect.
|
|
86
|
+
|
|
87
|
+
So the expiry is on the principal itself, as
|
|
88
|
+
`SyncPrincipal.Workspace.expiresAtMs`, or on the branch capability's own
|
|
89
|
+
`expiresAtMs`, and the stream ends with `unauthorized` when that moment
|
|
90
|
+
arrives. An in-process owner that provided `SyncPrincipal` directly presented no
|
|
91
|
+
credential, so its `expiresAtMs` is `Infinity` and no deadline applies.
|
|
92
|
+
|
|
93
|
+
## Two stages on the branch side
|
|
94
|
+
|
|
95
|
+
Opening a branch is a bootstrap: no capability for it can exist yet.
|
|
96
|
+
`Branch.CreateBranch` therefore requires an authenticated workspace principal
|
|
97
|
+
supplied by the same `SyncAuth` middleware, and its handler refuses any other
|
|
98
|
+
principal. Every later branch procedure carries its own capability and
|
|
99
|
+
authorizes through `BranchShare`, so past the bootstrap the capability is the
|
|
100
|
+
credential.
|
|
101
|
+
|
|
102
|
+
`Branch.MintShare` requires write access to the branch, so only a collaborator
|
|
103
|
+
can invite one, and the minted link never outlives the capability it was minted
|
|
104
|
+
from. The handler rechecks the parent expiry after generating the child ID.
|
|
105
|
+
It passes the parent's absolute expiry as `BranchShare.mint`'s
|
|
106
|
+
`maxExpiresAtMs`; the authority clamps the requested TTL against that ceiling
|
|
107
|
+
using the clock read that sets `issuedAtMs`. If the ceiling has elapsed,
|
|
108
|
+
minting fails with `unauthorized`.
|
|
109
|
+
|
|
110
|
+
## Related pages
|
|
111
|
+
|
|
112
|
+
- [Authorize a connection](../guides/authorize-a-connection.md): minting,
|
|
113
|
+
presenting, and rotating a workspace capability.
|
|
114
|
+
- [Branch collaboration](./branches.md): what a branch capability authorizes
|
|
115
|
+
beyond reading.
|
|
116
|
+
- [Troubleshooting](../troubleshooting.md): the `unauthorized` symptoms and
|
|
117
|
+
what each one means.
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Branch collaboration"
|
|
3
|
+
description: "A branch is one shared live document whose durable state is exactly one journal run: how commands are admitted exactly once, why presence is never journalled, and what the projection guarantees."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
:::warning
|
|
7
|
+
Branch collaboration ships unserved at 1.0.0-rc.0.
|
|
8
|
+
[`@smthrs/gateway`](/api/gateway) mounts `SyncRpcs` on `POST /sync` and
|
|
9
|
+
`/sync/ws`; nothing outside this package's own tests mounts `BranchRpcs`, so
|
|
10
|
+
the seven branch procedures have no integration against a real host yet. Treat
|
|
11
|
+
these modules as a library surface pending a host, not as a served endpoint.
|
|
12
|
+
:::
|
|
13
|
+
|
|
14
|
+
A branch is one shared live document that several people edit at once. It has
|
|
15
|
+
no store of its own. Its durable state is exactly one journal run, and
|
|
16
|
+
`BranchProtocol.branchRunId` is the only mapping:
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
import * as BranchProtocol from "@smthrs/sync/BranchProtocol"
|
|
20
|
+
|
|
21
|
+
const branchId = "design-review" as BranchProtocol.BranchId
|
|
22
|
+
const runId = BranchProtocol.branchRunId(branchId)
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
That single decision is what the rest of the design rests on. Every replication
|
|
26
|
+
guarantee the read path already provides carries over unchanged: the canonical
|
|
27
|
+
per-run `seq`, exclusive cursors, gap detection, resumable follow. Multiplayer
|
|
28
|
+
introduces no second source of truth, and a branch is followed with the same
|
|
29
|
+
`SyncClient.subscribe` call any other run is followed with.
|
|
30
|
+
|
|
31
|
+
`BranchProtocol.branchOfRunId` reverses the mapping, and returns `null` for a
|
|
32
|
+
run that is not a branch. The bare prefix with nothing after it is a non-branch
|
|
33
|
+
run rather than a branch with an empty id, because `BranchId` is a branded
|
|
34
|
+
non-empty string and branding `""` would hand a value the brand forbids to
|
|
35
|
+
`share.verify`.
|
|
36
|
+
|
|
37
|
+
## Commands are admitted exactly once
|
|
38
|
+
|
|
39
|
+
Three things look identical from the server's side: an optimistic client
|
|
40
|
+
re-sending after a timeout, a reconnecting client flushing its outbox, and two
|
|
41
|
+
people pressing the same button at once. All three must produce exactly one
|
|
42
|
+
durable command.
|
|
43
|
+
|
|
44
|
+
The client mints a `CommandId` once per user intent and reuses it across
|
|
45
|
+
optimistic application, retransmission, and concurrent submission. That is what
|
|
46
|
+
makes it an idempotency key rather than a correlation id.
|
|
47
|
+
|
|
48
|
+
The constraint is durable, not process-local. Every append carries the journal
|
|
49
|
+
producer identity `(branch run, commandSourceId(commandId), commandSourceSeq)`,
|
|
50
|
+
which the journal enforces inside its own write transaction. Two independently
|
|
51
|
+
constructed servers racing the same command therefore collide in the journal:
|
|
52
|
+
one appends, and the other receives a duplicate receipt or an idempotency
|
|
53
|
+
conflict and resolves the canonical sequence by replaying the branch.
|
|
54
|
+
|
|
55
|
+
`commandSourceSeq` is fixed at 0 and supplied explicitly. Letting the journal
|
|
56
|
+
allocate a sequence would hand a resubmission after a lost response a fresh
|
|
57
|
+
identity, and therefore a second durable append.
|
|
58
|
+
|
|
59
|
+
A `CommandReceipt` reports `admitted` or `duplicate`, and both are successes.
|
|
60
|
+
`duplicate` names the canonical sequence the original submission already
|
|
61
|
+
occupies, so a retransmitting client settles its optimistic row instead of
|
|
62
|
+
executing the command twice.
|
|
63
|
+
|
|
64
|
+
`BranchCommands` keeps an in-memory receipt ledger, a per-branch admission
|
|
65
|
+
permit, and a replay cursor. All three are a fast path: they answer known
|
|
66
|
+
duplicates without a journal write and keep a restarted server from
|
|
67
|
+
re-executing history. Correctness never depends on them, which is what makes it
|
|
68
|
+
safe to bound them. See the ledger, hydration, and command size bounds in the
|
|
69
|
+
[API reference](../api.md).
|
|
70
|
+
|
|
71
|
+
## One event type, one decoder
|
|
72
|
+
|
|
73
|
+
A branch journal has exactly one durable event shape,
|
|
74
|
+
`BranchProtocol.CommandEvent`. Chat messages, edits, and flow launches are all
|
|
75
|
+
commands, so the projection has exactly one decoder. `branch.say` is the
|
|
76
|
+
command that appends a chat message; a command's `target` names the shared
|
|
77
|
+
field it durably edits and is `""` for one that only appends.
|
|
78
|
+
|
|
79
|
+
A command's `participantId` is whatever the submitting client claimed.
|
|
80
|
+
`BranchCommands.submit` stamps the verified `capabilityId` of the share
|
|
81
|
+
capability that sent it onto the durable payload, and the projection carries it
|
|
82
|
+
on every message, applied command, and field. Attribute a command by its
|
|
83
|
+
`capabilityId`; a row admitted before the field existed decodes it as `""`.
|
|
84
|
+
|
|
85
|
+
Sync admission and command hydration validate known command records before
|
|
86
|
+
advancing progress. Malformed commands and foreign run/branch identities fail
|
|
87
|
+
typed; they cannot become a partial hydration or a new durable write. Hydration
|
|
88
|
+
stages a bounded receipt map and commits it with the replay cursor only after
|
|
89
|
+
all pages in that replay succeed. Existing decoding defaults for absent `args`
|
|
90
|
+
and `target` remain in force. Malformed retained records require explicit
|
|
91
|
+
repair by their authority; this package never rewrites them or silently treats
|
|
92
|
+
them as successful commands.
|
|
93
|
+
|
|
94
|
+
`BranchProjection.apply` and `project` are pure folds over admitted canonical
|
|
95
|
+
records, not admission APIs. Their convergence rules permit redelivery and
|
|
96
|
+
reordering of that canonical set. Use the validated sync read path before
|
|
97
|
+
folding transport or host input. The read path rejects malformed known records
|
|
98
|
+
before those pure folds can ignore them.
|
|
99
|
+
|
|
100
|
+
## Presence is a lease, and is never journalled
|
|
101
|
+
|
|
102
|
+
A roster is a lease table. Every announcement extends a lease, and a
|
|
103
|
+
participant that stops announcing, because the tab closed or the process died,
|
|
104
|
+
ages out without anyone reporting the disconnect. An announcement is idempotent
|
|
105
|
+
and the same shape serves join, heartbeat, and cursor movement, so a client that
|
|
106
|
+
reconnects simply announces again.
|
|
107
|
+
|
|
108
|
+
Writing presence to the journal would make "who was looking at this" part of the
|
|
109
|
+
durable, replayable history of a run. That is both unbounded and wrong:
|
|
110
|
+
replaying a branch must not resurrect a stranger's caret.
|
|
111
|
+
|
|
112
|
+
Announcing requires write access, so a read-only share link may watch the roster
|
|
113
|
+
but never appears on it, and a shared read link cannot be used to impersonate a
|
|
114
|
+
collaborator. A live participant is bound to the verified `capabilityId` that
|
|
115
|
+
first announced it: an announce or leave for that `participantId` under any
|
|
116
|
+
other capability is refused with `unauthorized` until the lease lapses, so one
|
|
117
|
+
write link cannot rename, move, or evict another link's participant. Holders of
|
|
118
|
+
the same link share its `capabilityId` and are not told apart. A branch holds at most
|
|
119
|
+
`BranchPresence.defaultMaxParticipants` participants at once; a further announce
|
|
120
|
+
is refused with `backpressure`.
|
|
121
|
+
|
|
122
|
+
`BranchPresence.list` is authoritative and `changes` is only a low-latency wake.
|
|
123
|
+
Each announcement or list drops that branch's expired leases and sweeps up to
|
|
124
|
+
16 branch rosters for expired participants, deleting empty maps. The sweep
|
|
125
|
+
resumes where the previous call stopped, so activity on other branches reclaims
|
|
126
|
+
abandoned rosters. An idle registry retains expired storage until activity
|
|
127
|
+
resumes; no timer fiber is needed. Both operations return detached participant
|
|
128
|
+
and cursor values, so callers cannot mutate the stored roster through a result.
|
|
129
|
+
|
|
130
|
+
A watcher re-lists once per lease as well as on every change, because a lapsed
|
|
131
|
+
lease publishes nothing: a watch driven by change events alone would never
|
|
132
|
+
observe the last participant leaving.
|
|
133
|
+
|
|
134
|
+
## The projection converges
|
|
135
|
+
|
|
136
|
+
`BranchProjection` folds journal entries into what two people looking at the
|
|
137
|
+
same branch must agree on: the message timeline, the applied commands, and the
|
|
138
|
+
current value of each durably edited field.
|
|
139
|
+
|
|
140
|
+
The fold is order-independent over the canonical entry set, which makes
|
|
141
|
+
convergence a property rather than a hope. Any two clients that have applied the
|
|
142
|
+
same set of entries hold the same state, whatever order their transports
|
|
143
|
+
delivered frames in and however many times a frame arrived. Three guards do the
|
|
144
|
+
work:
|
|
145
|
+
|
|
146
|
+
- an entry from another branch's run is ignored, so a mis-routed frame cannot
|
|
147
|
+
leak into a projection;
|
|
148
|
+
- an entry already folded, whether the cursor tip redelivered or a command id
|
|
149
|
+
already applied, is ignored, so an at-least-once transport cannot double-apply
|
|
150
|
+
a user action;
|
|
151
|
+
- an entry below the cursor whose command was never applied is folded into its
|
|
152
|
+
canonical `seq` position, so out-of-order delivery converges instead of
|
|
153
|
+
permanently dropping the late entry.
|
|
154
|
+
|
|
155
|
+
Two durable edits of the same field are resolved by highest canonical sequence,
|
|
156
|
+
with a lexicographic `participantId` tie-break. The rule is deliberately not
|
|
157
|
+
wall-clock based: client clocks disagree, and a merge rule that depends on them
|
|
158
|
+
is not deterministic.
|
|
159
|
+
|
|
160
|
+
## The wire group
|
|
161
|
+
|
|
162
|
+
`BranchRpcs` carries seven procedures: `Branch.CreateBranch`,
|
|
163
|
+
`Branch.MintShare`, `Branch.Submit`, `Branch.Announce`, `Branch.Leave`,
|
|
164
|
+
`Branch.Roster`, and `Branch.WatchRoster`. `BranchServer.layerHandlers` projects
|
|
165
|
+
the branch services onto them. Five procedures forward to the service that owns
|
|
166
|
+
their boundary and add nothing: `Branch.Submit` to `BranchCommands`,
|
|
167
|
+
`Branch.Announce`, `Branch.Leave`, `Branch.Roster`, and `Branch.WatchRoster`
|
|
168
|
+
to `BranchPresence`. For those, an in-process caller and a remote caller face
|
|
169
|
+
the same rules.
|
|
170
|
+
|
|
171
|
+
Two procedures enforce policy in the adapter itself, on top of what
|
|
172
|
+
`BranchShare` checks:
|
|
173
|
+
|
|
174
|
+
- `Branch.CreateBranch` refuses any principal that is not an authenticated
|
|
175
|
+
workspace with `unauthorized` before it mints the first capability;
|
|
176
|
+
- `Branch.MintShare` verifies the presented capability for write access to the
|
|
177
|
+
branch, refuses an expired parent with `unauthorized`, and passes the parent's
|
|
178
|
+
expiry as `maxExpiresAtMs` so the child never outlives it.
|
|
179
|
+
|
|
180
|
+
`BranchShare.mint` performs none of those checks. It decodes its request, so a
|
|
181
|
+
malformed id or ttl is refused with `invalid_request`, but an in-process host
|
|
182
|
+
that mints through the service directly must gate the principal and the parent
|
|
183
|
+
capability itself or it hands out branches and links the wire would refuse.
|
|
184
|
+
|
|
185
|
+
The payload schemas **are** the service schemas rather than copies of them, so
|
|
186
|
+
the wire and the services cannot drift about what a legal message is.
|
|
187
|
+
|
|
188
|
+
For the two-stage authorization the group uses, see
|
|
189
|
+
[Authorization](./authorization.md).
|
|
190
|
+
|
|
191
|
+
## Related pages
|
|
192
|
+
|
|
193
|
+
- [Authorization](./authorization.md): what a branch capability grants and how
|
|
194
|
+
it is checked.
|
|
195
|
+
- [API reference](../api.md): every branch export, with its bounds and defaults.
|