@smthrs/sync 0.0.0-stage → 1.0.0-rc.3
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,273 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Test a follower"
|
|
3
|
+
description: "Bind a real sync server to a real sync client over an in-memory socket, then drop, stall, and disconnect frames to prove your follower survives a faulty transport."
|
|
4
|
+
sidebar:
|
|
5
|
+
order: 6
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
This guide tests the code you wrote against
|
|
9
|
+
[Follow a run](./follow-a-run.md), using the production server and the
|
|
10
|
+
production client. Only the transport is a double: an in-memory socket pair
|
|
11
|
+
stands in for a WebSocket, and it can drop, stall, and break frames on demand.
|
|
12
|
+
|
|
13
|
+
Two subpaths ship for this:
|
|
14
|
+
|
|
15
|
+
| Import | What it gives you |
|
|
16
|
+
| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------ |
|
|
17
|
+
| `@smthrs/sync/test/TestSocket` | `makePair`, the `Pair` it returns, its `TestFaults` controls, and the `FrameFilter` type. Runs anywhere. |
|
|
18
|
+
| `@smthrs/sync/test/TestSync` | `layerTest`, `layerTrustAllAsOwner`, `layerNoop`, and `connect`. Binds the Node SQLite test journal, so it is Node only. |
|
|
19
|
+
|
|
20
|
+
## Bind a server to a client
|
|
21
|
+
|
|
22
|
+
`TestSync.layerTest` provides the in-memory SQLite journal, an empty mutable
|
|
23
|
+
run catalog, a middleware that trusts the connection as the workspace owner,
|
|
24
|
+
and the production `SyncServer`. `TestSocket.makePair` allocates the socket,
|
|
25
|
+
and `TestSync.connect` starts an RPC server over its server end and returns a
|
|
26
|
+
`SyncClient.Service` over its client end:
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
import { Journal, JournalEvent } from "@smthrs/journal"
|
|
30
|
+
import * as TestSocket from "@smthrs/sync/test/TestSocket"
|
|
31
|
+
import * as TestSync from "@smthrs/sync/test/TestSync"
|
|
32
|
+
import { Effect, Fiber, Stream } from "effect"
|
|
33
|
+
|
|
34
|
+
const runId = "build-42" as JournalEvent.RunId
|
|
35
|
+
const sourceId = "test" as JournalEvent.SourceId
|
|
36
|
+
|
|
37
|
+
const collectTwo = Effect.gen(function*() {
|
|
38
|
+
const journal = yield* Journal.Journal
|
|
39
|
+
const pair = yield* TestSocket.makePair()
|
|
40
|
+
const client = yield* TestSync.connect(pair)
|
|
41
|
+
|
|
42
|
+
yield* journal.emitDurableUnfenced({ runId, sourceId, eventType: "first", payload: { value: 0 } })
|
|
43
|
+
yield* journal.flush
|
|
44
|
+
|
|
45
|
+
return yield* Stream.runCollect(
|
|
46
|
+
client.subscribe({ scope: { _tag: "Run", runId }, cursors: [] }).pipe(Stream.take(1))
|
|
47
|
+
)
|
|
48
|
+
}).pipe(Effect.provide(TestSync.layerTest), Effect.scoped)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Both `makePair` and `connect` are scoped, so `Effect.scoped` closes the socket
|
|
52
|
+
and the RPC server together. Everything below builds on this shape.
|
|
53
|
+
|
|
54
|
+
## Use real elapsed time
|
|
55
|
+
|
|
56
|
+
Run these cases with `it.live`, not `it.effect`. A follower's reconnect
|
|
57
|
+
backoff, the server's tail interval, and the socket's own delivery loop all
|
|
58
|
+
advance on the wall clock, and `it.effect` freezes the `TestClock` so a live
|
|
59
|
+
follow never wakes:
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
import { describe, expect, it } from "@effect/vitest"
|
|
63
|
+
|
|
64
|
+
describe("my follower", () => {
|
|
65
|
+
it.live("receives the durable history", () =>
|
|
66
|
+
Effect.gen(function*() {
|
|
67
|
+
const entries = yield* collectTwo
|
|
68
|
+
expect(Array.from(entries).map((entry) => entry.eventType)).toEqual(["first"])
|
|
69
|
+
}))
|
|
70
|
+
})
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Fork a subscription with `Effect.forkChild({ startImmediately: true })` when
|
|
74
|
+
the test commits an entry after subscribing. Without it the entry can land
|
|
75
|
+
before the stream attaches, and the follower reads it out of the durable
|
|
76
|
+
history instead of following it, which proves less than the test intends.
|
|
77
|
+
|
|
78
|
+
## Drop the frames that carry one range
|
|
79
|
+
|
|
80
|
+
`faults.dropRange(runId, from, to)` drops every `Entries` frame whose covered
|
|
81
|
+
interval intersects `from..to`. Install it before you connect, then assert
|
|
82
|
+
that the follower never delivers entries past the hole:
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
const withHole = Effect.gen(function*() {
|
|
86
|
+
const pair = yield* TestSocket.makePair()
|
|
87
|
+
pair.faults.dropRange(runId, 1, 1)
|
|
88
|
+
return yield* TestSync.connect(pair)
|
|
89
|
+
})
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
The follower stalls at sequence 0 forever. That is the correct outcome:
|
|
93
|
+
sequence 1 is not a legitimate journal hole, it is a frame the transport ate,
|
|
94
|
+
and skipping it would lose an entry. The package's own transport-fault suite
|
|
95
|
+
asserts exactly this, by requiring the subscription to time out rather than
|
|
96
|
+
complete.
|
|
97
|
+
|
|
98
|
+
`dropRange` recognizes the JSON form of an `Entries` frame without importing
|
|
99
|
+
the protocol, so it works only when the connection uses JSON serialization,
|
|
100
|
+
which `TestSync.connect` does.
|
|
101
|
+
|
|
102
|
+
## Stall and resume delivery
|
|
103
|
+
|
|
104
|
+
`faults.stall()` holds every frame in the socket, and `faults.resume()`
|
|
105
|
+
releases them. Nothing is lost, so this reproduces a slow link rather than a
|
|
106
|
+
broken one:
|
|
107
|
+
|
|
108
|
+
```ts
|
|
109
|
+
const stallThenResume = Effect.gen(function*() {
|
|
110
|
+
const journal = yield* Journal.Journal
|
|
111
|
+
const pair = yield* TestSocket.makePair()
|
|
112
|
+
const client = yield* TestSync.connect(pair)
|
|
113
|
+
|
|
114
|
+
yield* journal.emitDurableUnfenced({ runId, sourceId, eventType: "before-stall", payload: { value: 0 } })
|
|
115
|
+
yield* journal.flush
|
|
116
|
+
pair.faults.stall()
|
|
117
|
+
|
|
118
|
+
const fiber = yield* Stream.runCollect(
|
|
119
|
+
client.subscribe({ scope: { _tag: "Run", runId }, cursors: [] }).pipe(Stream.take(1))
|
|
120
|
+
).pipe(Effect.forkChild({ startImmediately: true }))
|
|
121
|
+
|
|
122
|
+
const stalled = yield* Fiber.join(fiber).pipe(Effect.timeoutOption("200 millis"))
|
|
123
|
+
pair.faults.resume()
|
|
124
|
+
return { stalled, entries: yield* Fiber.join(fiber) }
|
|
125
|
+
})
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
`stalled` is `None`, and `entries` carries the frame the stall held. Assert
|
|
129
|
+
both: the first proves the stall took effect, and the second proves the resume
|
|
130
|
+
delivered rather than dropped.
|
|
131
|
+
|
|
132
|
+
## Break the connection
|
|
133
|
+
|
|
134
|
+
`faults.disconnect()` fails both queues with a socket error, which is what a
|
|
135
|
+
dropped WebSocket looks like to the client. The subscription does not fail:
|
|
136
|
+
`SyncClient` classifies the failure as `transport_failed` and retries under
|
|
137
|
+
exponential backoff capped at five seconds, resuming from the subscription's
|
|
138
|
+
progress. Assert that the stream stays open, not that it errors:
|
|
139
|
+
|
|
140
|
+
```ts
|
|
141
|
+
const survivesDisconnect = Effect.gen(function*() {
|
|
142
|
+
const journal = yield* Journal.Journal
|
|
143
|
+
const pair = yield* TestSocket.makePair()
|
|
144
|
+
const client = yield* TestSync.connect(pair)
|
|
145
|
+
|
|
146
|
+
yield* journal.emitDurableUnfenced({ runId, sourceId, eventType: "first", payload: { value: 0 } })
|
|
147
|
+
yield* journal.flush
|
|
148
|
+
|
|
149
|
+
const fiber = yield* Stream.runCollect(
|
|
150
|
+
client.subscribe({ scope: { _tag: "Run", runId }, cursors: [] }).pipe(Stream.take(2))
|
|
151
|
+
).pipe(Effect.forkChild({ startImmediately: true }))
|
|
152
|
+
|
|
153
|
+
yield* pair.faults.disconnect()
|
|
154
|
+
const settled = yield* Fiber.await(fiber).pipe(Effect.timeoutOption("500 millis"))
|
|
155
|
+
yield* Fiber.interrupt(fiber)
|
|
156
|
+
return settled._tag
|
|
157
|
+
})
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
`survivesDisconnect` returns `"None"`: the fiber was still running when the
|
|
161
|
+
timeout elapsed. A test that expects a failure here is asserting the opposite
|
|
162
|
+
of the contract.
|
|
163
|
+
|
|
164
|
+
## Corrupt a frame
|
|
165
|
+
|
|
166
|
+
`faults.installFilter` takes a `FrameFilter`, which is called with each frame's
|
|
167
|
+
bytes. Return `false` to drop the frame, or return replacement bytes to rewrite
|
|
168
|
+
it. Use it to feed your follower a frame the server would never send, and prove
|
|
169
|
+
the client refuses it:
|
|
170
|
+
|
|
171
|
+
```ts
|
|
172
|
+
import type * as TestSocket from "@smthrs/sync/test/TestSocket"
|
|
173
|
+
|
|
174
|
+
const misattribute: TestSocket.FrameFilter = (bytes) => {
|
|
175
|
+
const text = new TextDecoder().decode(bytes)
|
|
176
|
+
if (!text.includes("\"Entries\"")) return true
|
|
177
|
+
return new TextEncoder().encode(text.replace("\"runId\":\"build-42\"", "\"runId\":\"other-run\""))
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
pair.faults.installFilter(misattribute)
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
The rewrite changes exactly one of the two run ids an `Entries` frame carries,
|
|
184
|
+
its own or its first entry's, so the frame ends up claiming one run and
|
|
185
|
+
carrying another. The subscription fails with `protocol_violation` before any
|
|
186
|
+
cursor moves. The client admits server frames rather than trusting them, and
|
|
187
|
+
this is how you prove your own error handling sees that.
|
|
188
|
+
|
|
189
|
+
Filters run in installation order on every frame in both directions. A filter
|
|
190
|
+
that returns bytes hands them to the next filter.
|
|
191
|
+
|
|
192
|
+
## Choose the authorization the test needs
|
|
193
|
+
|
|
194
|
+
`layerTest` uses `TestSync.layerTrustAllAsOwner`, which trusts every connection
|
|
195
|
+
as the workspace owner. That is what a replication-mechanics test wants: it
|
|
196
|
+
exercises paging and streaming without provisioning capabilities.
|
|
197
|
+
|
|
198
|
+
To test authorization itself, compose the production middleware instead.
|
|
199
|
+
`SyncAuth.layer` verifies a `WorkspaceShare` capability from the
|
|
200
|
+
`flows-sync-workspace` header, so the test provides a real keyring and mints a
|
|
201
|
+
real capability:
|
|
202
|
+
|
|
203
|
+
```ts
|
|
204
|
+
import * as TestJournal from "@smthrs/journal/test/TestJournal"
|
|
205
|
+
import * as RunCatalog from "@smthrs/sync/RunCatalog"
|
|
206
|
+
import * as SyncAuth from "@smthrs/sync/SyncAuth"
|
|
207
|
+
import * as SyncServer from "@smthrs/sync/SyncServer"
|
|
208
|
+
import * as WorkspaceShare from "@smthrs/sync/WorkspaceShare"
|
|
209
|
+
import { Layer, Redacted } from "effect"
|
|
210
|
+
|
|
211
|
+
const keyring: WorkspaceShare.Keyring = {
|
|
212
|
+
activeKid: "k1",
|
|
213
|
+
keys: [{ kid: "k1", secret: Redacted.make("test-secret-fixture-only-not-for-prod") }]
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const servingStack = Layer.mergeAll(SyncServer.layerHandlers, SyncAuth.layer).pipe(
|
|
217
|
+
Layer.provideMerge(Layer.mergeAll(SyncServer.layer, WorkspaceShare.layerHmac(keyring))),
|
|
218
|
+
Layer.provideMerge(Layer.mergeAll(TestJournal.layer(), RunCatalog.layerStatic([runId])))
|
|
219
|
+
)
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
A connection that presents no header reads no non-branch run, so the
|
|
223
|
+
unauthenticated case is the one to assert first. See
|
|
224
|
+
[Authorize a connection](./authorize-a-connection.md) for minting and
|
|
225
|
+
presenting the capability.
|
|
226
|
+
|
|
227
|
+
For an in-process caller with no wire at all, provide the principal directly
|
|
228
|
+
with `SyncPrincipal.layerWorkspace("my-suite")`. That path presents no
|
|
229
|
+
credential and therefore has no expiry.
|
|
230
|
+
|
|
231
|
+
## Register runs a workspace subscription can see
|
|
232
|
+
|
|
233
|
+
`layerTest`'s run catalog starts empty, and a workspace subscription covers
|
|
234
|
+
only what the catalog lists. Build your own catalog when the test needs runs in
|
|
235
|
+
it. `RunCatalog.makeMemory` returns both the service and a `register` function:
|
|
236
|
+
|
|
237
|
+
```ts
|
|
238
|
+
const catalog = Effect.gen(function*() {
|
|
239
|
+
const memory = yield* RunCatalog.makeMemory()
|
|
240
|
+
yield* memory.register(runId)
|
|
241
|
+
return memory.catalog
|
|
242
|
+
})
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
Provide that catalog as `RunCatalog.RunCatalog` in place of the one `layerTest`
|
|
246
|
+
supplies. See
|
|
247
|
+
[List a workspace's runs](./list-workspace-runs.md) for the production forms.
|
|
248
|
+
|
|
249
|
+
## Stub the ports entirely
|
|
250
|
+
|
|
251
|
+
A consumer that only needs the sync services to exist, and never exercises
|
|
252
|
+
them, takes `TestSync.layerNoop`. It provides a closed client, a closed server,
|
|
253
|
+
an empty catalog, and a pass-through middleware. Every subscription it hands
|
|
254
|
+
out fails with `closed`, so it is a compile-time convenience and not a fixture
|
|
255
|
+
to assert against.
|
|
256
|
+
|
|
257
|
+
## What these fixtures do not cover
|
|
258
|
+
|
|
259
|
+
`TestSync` binds the read path only. It mounts `SyncRpcs`, never `BranchRpcs`,
|
|
260
|
+
so a branch test drives `BranchServer` and the branch services directly. See
|
|
261
|
+
[Branch collaboration](../concepts/branches.md) for why the branch surface
|
|
262
|
+
ships unserved.
|
|
263
|
+
|
|
264
|
+
The socket pair's queues hold 16 frames each. A test that writes more than
|
|
265
|
+
that without a reader blocks, which is a bounded transport behaving correctly
|
|
266
|
+
rather than a fixture defect.
|
|
267
|
+
|
|
268
|
+
## Next steps
|
|
269
|
+
|
|
270
|
+
- [Troubleshooting](../troubleshooting.md): every failure code these faults
|
|
271
|
+
produce, and what each one means.
|
|
272
|
+
- [Replay then follow](../concepts/replay-then-follow.md): why the client
|
|
273
|
+
retries a transport failure and propagates everything else.
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Installation"
|
|
3
|
+
description: "Install @smthrs/sync, choose an import form, and add the journal, run catalog, and transport that a serving composition needs."
|
|
4
|
+
sidebar:
|
|
5
|
+
order: 1
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Install the package
|
|
9
|
+
|
|
10
|
+
Not on npm yet; see [Installation](https://github.com/smithersai/smithers/blob/main/packages/smithers/flows/flow/docs/installation.md#use-the-libraries).
|
|
11
|
+
|
|
12
|
+
## Requirements
|
|
13
|
+
|
|
14
|
+
- Node.js 26.4.0 or later, or a browser with Web Crypto. The signing paths
|
|
15
|
+
call `crypto.subtle` directly so one module serves both.
|
|
16
|
+
- `effect` exactly at the version the package declares. It is a peer dependency
|
|
17
|
+
so the follower and Smithers use one runtime.
|
|
18
|
+
- [`@smthrs/journal`](/api/journal), also a direct dependency. The entry
|
|
19
|
+
envelope every frame carries is the journal's `JournalEvent.Entry`.
|
|
20
|
+
|
|
21
|
+
## Import forms
|
|
22
|
+
|
|
23
|
+
The root is browser safe and re-exports every namespace:
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
import { RunCatalog, SyncClient, SyncServer } from "@smthrs/sync"
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Every module is also importable on its own path, which is what a bundler
|
|
30
|
+
targeting a browser wants because it leaves the server modules out:
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
import * as SyncClient from "@smthrs/sync/SyncClient"
|
|
34
|
+
import * as SyncProtocol from "@smthrs/sync/SyncProtocol"
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
The two test doubles live under `test/` subpaths and are documented in
|
|
38
|
+
[Test a follower](./guides/test-a-follower.md):
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
import * as TestSocket from "@smthrs/sync/test/TestSocket"
|
|
42
|
+
import * as TestSync from "@smthrs/sync/test/TestSync"
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
`@smthrs/sync/test/TestSync` binds the Node SQLite test journal, so it is a
|
|
46
|
+
Node-only import. `@smthrs/sync/test/TestSocket` is an in-memory socket pair
|
|
47
|
+
and runs anywhere. Install the optional SQLite driver when using `TestSync`:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
pnpm add @effect/sql-sqlite-node@4.0.0-rc.115
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`@smthrs/sync/internal/*` is not exported. A path under it fails to resolve.
|
|
54
|
+
|
|
55
|
+
## What a follower adds
|
|
56
|
+
|
|
57
|
+
A follower needs a transport. `SyncClient.layer` derives its RPC client from
|
|
58
|
+
`RpcClient.Protocol`, so the composition supplies the protocol and the
|
|
59
|
+
serialization:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
pnpm add @effect/platform-node@4.0.0-rc.115 @effect/platform-node-shared@4.0.0-rc.115
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
A browser follower needs no extra package: the WebSocket protocol and the JSON
|
|
66
|
+
serialization both come from `effect/unstable/rpc`.
|
|
67
|
+
|
|
68
|
+
## What a server adds
|
|
69
|
+
|
|
70
|
+
Serving the read path requires a real journal and a run catalog:
|
|
71
|
+
|
|
72
|
+
- [`@smthrs/journal`](/api/journal) supplies `Journal`. Its production layer,
|
|
73
|
+
`SqlJournal`, sits above [`@smthrs/database`](/api/database), so a serving
|
|
74
|
+
host installs a database driver and the journal migrations too.
|
|
75
|
+
- `RunCatalog` is supplied by the host. `RunCatalog.layerStatic` covers a fixed
|
|
76
|
+
set. [`@smthrs/engine-store`](/api/engine-store) supplies `RunCatalogRead`,
|
|
77
|
+
the durable read a polling catalog uses so a follower learns of runs another
|
|
78
|
+
engine created. See [List a workspace's runs](./guides/list-workspace-runs.md).
|
|
79
|
+
- A transport mounts the RPC group. [`@smthrs/gateway`](/api/gateway) already
|
|
80
|
+
does it, on `POST /sync` and `/sync/ws`.
|
|
81
|
+
|
|
82
|
+
Authorization is a separate decision with its own dependency: `SyncAuth.layer`
|
|
83
|
+
requires a `WorkspaceShare` authority, and `WorkspaceShare.layerConfig` reads
|
|
84
|
+
its secret from the environment. See
|
|
85
|
+
[Authorize a connection](./guides/authorize-a-connection.md).
|
|
86
|
+
|
|
87
|
+
## Next steps
|
|
88
|
+
|
|
89
|
+
- [Quickstart](./quickstart.md) runs a follower end to end with no transport
|
|
90
|
+
and no database of your own.
|
|
91
|
+
- [Serve the read path](./guides/serve-the-read-path.md) composes the server
|
|
92
|
+
side against a real journal.
|
package/docs/protocol.md
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Wire protocol"
|
|
3
|
+
description: "The normative message shapes of the sync read path: scopes, cursors, read requests and responses, subscription frames, and the limits every side enforces."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## Scopes and cursors
|
|
7
|
+
|
|
8
|
+
A client reads either one run or every run in a workspace:
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
import { SyncProtocol } from "@smthrs/sync"
|
|
12
|
+
import { Schema } from "effect"
|
|
13
|
+
|
|
14
|
+
const scope = Schema.decodeUnknownSync(SyncProtocol.RunScope)({
|
|
15
|
+
_tag: "Run",
|
|
16
|
+
runId: "build-42"
|
|
17
|
+
})
|
|
18
|
+
const cursor = Schema.decodeUnknownSync(SyncProtocol.RunCursor)({
|
|
19
|
+
runId: "build-42",
|
|
20
|
+
afterSeq: 17
|
|
21
|
+
})
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
A run cursor stores the last sequence the client was delivered. A workspace
|
|
25
|
+
cursor is an array of per-run cursors with at most one entry per run: a request
|
|
26
|
+
that names one run twice is refused with `invalid_request`, because the read
|
|
27
|
+
position and the echoed response state would otherwise disagree. Journal
|
|
28
|
+
sequences may have holes, so a cursor means "read entries after this number",
|
|
29
|
+
not "expect the next number to be exactly one greater".
|
|
30
|
+
|
|
31
|
+
A read and a subscribe request carry the same envelope, `SyncProtocol.RequestEnvelope`:
|
|
32
|
+
one scope and one cursor set. Server read, server subscribe and client
|
|
33
|
+
subscribe admit a caller-built request against that single schema.
|
|
34
|
+
|
|
35
|
+
`generation` is asymmetric on purpose. A request may omit it, which is how a
|
|
36
|
+
cursor persisted before rewind support still decodes; a response may not. The
|
|
37
|
+
server-side shapes state that requirement in the type system:
|
|
38
|
+
`SyncProtocol.ServerCursor`, `ServerReadResponse`, `ServerEntriesFrame` and
|
|
39
|
+
`ServerFrame` all require `generation`, and `SyncServer.Service` answers with
|
|
40
|
+
them, so a server that omits one does not compile. The wire schemas stay
|
|
41
|
+
lenient so a non-conforming server is refused with `protocol_violation` naming
|
|
42
|
+
the missing generation rather than a bare decode failure.
|
|
43
|
+
|
|
44
|
+
## Protocol version
|
|
45
|
+
|
|
46
|
+
`SyncProtocol.protocolVersion` is the one accepted wire revision, and the only
|
|
47
|
+
place the number is written. Read, subscribe and snapshot requests all decode a
|
|
48
|
+
version this revision does not speak, so the refusal is a typed
|
|
49
|
+
`protocol_violation` with cause `protocol_version_mismatch` on every path,
|
|
50
|
+
in process and over the wire, request and response alike. There is no second
|
|
51
|
+
code for a version mismatch.
|
|
52
|
+
|
|
53
|
+
## Read
|
|
54
|
+
|
|
55
|
+
`Sync.Read` accepts `protocolVersion: 1`, a scope, a cursor set, and a limit, and returns journal
|
|
56
|
+
entries plus the cursor set the page served through. `RunCatalog` supplies the
|
|
57
|
+
run list for workspace reads; `SyncServer.layer` combines it with `Journal`.
|
|
58
|
+
|
|
59
|
+
`limit` is bounded at `SyncProtocol.maxReadLimit`. A page is also bounded by
|
|
60
|
+
the encoded-entry ceiling, `maxFrameBytes`, which defaults to 2 MiB. The
|
|
61
|
+
ceiling is a budget on the page, not a verdict on the read: the server serves
|
|
62
|
+
entries until the next one would cross it, then returns `done: false` so the
|
|
63
|
+
client asks for the rest. Only a single entry whose own encoded size exceeds
|
|
64
|
+
the ceiling fails, with `frame_too_large`, because no page can carry it.
|
|
65
|
+
|
|
66
|
+
Persist the returned cursors only after applying the returned batch.
|
|
67
|
+
|
|
68
|
+
There is no application acknowledgement RPC. Response cursors are delivery
|
|
69
|
+
bookmarks. `SyncClient.progress` reports `Progress`, two cursor sets:
|
|
70
|
+
`delivered` holds delivery bookmarks and `applied` holds application
|
|
71
|
+
positions. Only successful `apply` and snapshot-restore callbacks advance the
|
|
72
|
+
applied set. An applying subscription never inherits delivery-only progress.
|
|
73
|
+
|
|
74
|
+
Both server and client validate complete admitted batches before serving or
|
|
75
|
+
applying them. A malformed envelope or non-JSON payload fails `decode_failed`;
|
|
76
|
+
foreign runs, foreign branch identities, and non-monotonic sequences fail
|
|
77
|
+
`protocol_violation`. Causes retain a bounded classification without publishing
|
|
78
|
+
private record contents. Unknown event namespaces remain open JSON values.
|
|
79
|
+
Known branch commands must decode; existing defaults for absent `args` and
|
|
80
|
+
`target` remain unchanged. No stored row is rewritten or silently skipped.
|
|
81
|
+
|
|
82
|
+
## Subscribe
|
|
83
|
+
|
|
84
|
+
`Sync.Subscribe` requires the same `protocolVersion: 1` and streams `Entries` frames, each carrying one run's entries and
|
|
85
|
+
the interval the server covered (`runId`, `fromSeq`, `toSeq`), and a terminal
|
|
86
|
+
`Closed` frame when the server ends the subscription. `fromSeq` and `toSeq`
|
|
87
|
+
describe the covered interval, not the sequences actually carried: dropped
|
|
88
|
+
admissions leave legitimate holes. No frame carries a cursor; a client tracks
|
|
89
|
+
its own, which is what `SyncClient` does.
|
|
90
|
+
|
|
91
|
+
The `Heartbeat` variant is RESERVED. No server emits one at 1.0.0-rc.0, so a
|
|
92
|
+
client must not wait for one; it stays in the union so adding it later is not a
|
|
93
|
+
wire break. Keepalive is a transport concern, because a heartbeat here would
|
|
94
|
+
spend the subscription's credit.
|
|
95
|
+
|
|
96
|
+
The request includes a credit count between 1 and
|
|
97
|
+
`SyncProtocol.maxSubscribeCredit`. Credit is a hard limit on frames emitted by
|
|
98
|
+
that subscription, not a sliding acknowledgement window, and there is no Ack
|
|
99
|
+
RPC. A client that needs more resubscribes from its current cursor positions,
|
|
100
|
+
which is what `SyncClient` does once per window. These are applied positions
|
|
101
|
+
when an `apply` callback is supplied and delivery bookmarks otherwise.
|
|
102
|
+
|
|
103
|
+
A subscription also ends when the credential that opened it expires, with
|
|
104
|
+
`SyncError` code `unauthorized`. Authorization happens once, at open, so
|
|
105
|
+
without that deadline a signed expiry could not revoke a stream that simply
|
|
106
|
+
stayed connected.
|
|
107
|
+
|
|
108
|
+
`SyncClient.subscribe` wraps the RPC stream. It detects invalid cursor movement
|
|
109
|
+
as `SyncGapError`, refuses a frame or a catch-up page that contradicts itself
|
|
110
|
+
as a `protocol_violation`, reconnects transport failures under exponential
|
|
111
|
+
backoff capped at five seconds, and resumes from the subscription's matching
|
|
112
|
+
progress map. Authentication is `SyncAuth`, and both are part of the package
|
|
113
|
+
rather than left to the application.
|
|
114
|
+
|
|
115
|
+
## Compacted runs
|
|
116
|
+
|
|
117
|
+
A cursor below a run's compaction floor names entries the journal has deleted.
|
|
118
|
+
The read or the subscription fails with `SyncError` code `compacted`, and the
|
|
119
|
+
error carries a `Resync` of `{ runId, checkpointSeq, generation }`: the floor to
|
|
120
|
+
resume from and the run history it belongs to. `SyncClient.subscribe` fails
|
|
121
|
+
closed unless `onResync` restores the missing prefix and returns
|
|
122
|
+
`{ runId, afterSeq }`. The cursor advances to that actual restored sequence, not
|
|
123
|
+
necessarily the older reported floor, before replaying the suffix. A receipt
|
|
124
|
+
without a generation takes the refusal's, so a fresh follower of a rewound run
|
|
125
|
+
resumes the right history. Foreign-run and behind-floor receipts are refused.
|
|
126
|
+
|
|
127
|
+
:::warning
|
|
128
|
+
The resync moves a cursor, not state. The entries below `checkpointSeq` are
|
|
129
|
+
never delivered. Fetch an explicitly public projection with `Sync.Snapshot`
|
|
130
|
+
or use an authorized local source. `SubscribeOptions.onResync` runs before the cursor moves and must
|
|
131
|
+
succeed with a valid restored cursor, so a consumer that cannot restore the prefix fails instead of silently
|
|
132
|
+
skipping it. A Node follower reads that prefix out of band with
|
|
133
|
+
`journal.latestCheckpoint(runId)`, applies `checkpoint.state`, and continues
|
|
134
|
+
from the sync stream. See [Checkpoints and compaction](/pkg/journal/concepts/compaction).
|
|
135
|
+
:::
|
|
136
|
+
|
|
137
|
+
## Public snapshots
|
|
138
|
+
|
|
139
|
+
`Sync.Snapshot` accepts `SnapshotRequest`: `protocolVersion: 1`, `runId`,
|
|
140
|
+
`lineageId`, `projection`, `projectionVersion`, `atLeastSeq`, and an optional
|
|
141
|
+
branch `capability`. The response repeats that identity with `seq` and JSON
|
|
142
|
+
`state`, representing the complete public projection through `seq` inclusive.
|
|
143
|
+
Lineage IDs are nonempty and at most 512 characters; projection names are
|
|
144
|
+
nonempty and at most 128. Projection versions are positive safe integers.
|
|
145
|
+
|
|
146
|
+
Run/branch read authorization is the same as for history. An explicit host
|
|
147
|
+
`SyncServer.SnapshotSource` must select a public projection safe for every reader
|
|
148
|
+
of that run; raw journal checkpoints are never served automatically. Missing
|
|
149
|
+
providers or unavailable projections fail closed. Credential expiry is checked
|
|
150
|
+
before and after provider work, and provider failures do not expose private text.
|
|
151
|
+
|
|
152
|
+
Both ends enforce the requested identity, its protocol version, and the
|
|
153
|
+
minimum sequence; each mismatch is a `protocol_violation`. Both also bound
|
|
154
|
+
the entire encoded UTF-8 snapshot response by `maxFrameBytes` (default 2 MiB,
|
|
155
|
+
excluding the outer RPC envelope), and reject non-JSON provider state. The
|
|
156
|
+
response is detached from mutable provider objects.
|
|
157
|
+
|
|
158
|
+
`SyncClient.snapshot` fetches only: it does not apply state or move cursors.
|
|
159
|
+
After transactional application, `onResync` returns the actual restored cursor.
|
|
160
|
+
If compaction advances before the next suffix read, recovery runs again at the
|
|
161
|
+
newer floor. Snapshot bytes are inline, so no temporary download reference can
|
|
162
|
+
expire during application. Providers must retain a usable latest snapshot
|
|
163
|
+
covering every compacted prefix and version they continue to serve.
|
|
164
|
+
|
|
165
|
+
## Directionality
|
|
166
|
+
|
|
167
|
+
`SyncRpcs` is read only: `Sync.Read`, `Sync.Subscribe`, and `Sync.Snapshot`. Client-to-server
|
|
168
|
+
submission is not planned work, it is `BranchRpcs`: `Branch.Submit` admits one
|
|
169
|
+
command onto a branch's journal run under a client-minted idempotency key, and
|
|
170
|
+
`Branch.Announce`, `Branch.Leave`, `Branch.Roster`, and `Branch.WatchRoster`
|
|
171
|
+
carry ephemeral presence. `BranchRpcs` has no production mount at
|
|
172
|
+
1.0.0-rc.0; the gateway serves `SyncRpcs` alone.
|
|
173
|
+
|
|
174
|
+
Bidirectional reconciliation of the read path, acknowledgement windows, and
|
|
175
|
+
resumable transport sessions remain unplanned.
|
|
176
|
+
|
|
177
|
+
Command admission separately permits up to 1 MiB of UTF-8 JSON submission
|
|
178
|
+
bytes. Before writing, it also checks the complete journal envelope against
|
|
179
|
+
`BranchCommands.Options.maxFrameBytes` (2 MiB by default), reserving the largest
|
|
180
|
+
generated sequence/timestamp representation. Custom admission, server, and
|
|
181
|
+
client budgets must agree. `maxFrameBytes` sums encoded entries; an RPC transport
|
|
182
|
+
must additionally allow its frame/envelope overhead. Neither journal cursors nor
|
|
183
|
+
entries change when admission refuses a command.
|