@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,144 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Compaction and resync"
|
|
3
|
+
description: "What a compacted refusal means, how the client resumes from the checkpoint the server names, and why the entries below that checkpoint are a real hole the consumer must fill."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Compaction deletes a run's journal entries below a checkpoint. A cursor under
|
|
7
|
+
that floor names history that no longer exists, so the read cannot be served
|
|
8
|
+
and cannot be served later either.
|
|
9
|
+
|
|
10
|
+
`@smthrs/journal` owns the mechanism, including when a checkpoint is written
|
|
11
|
+
and what it may summarize; see
|
|
12
|
+
[Checkpoints and compaction](/pkg/journal/concepts/compaction). This page is
|
|
13
|
+
about what the sync boundary does with it.
|
|
14
|
+
|
|
15
|
+
## The refusal names where to resume
|
|
16
|
+
|
|
17
|
+
The server maps the journal's compacted failure onto its own `compacted` code
|
|
18
|
+
and attaches a `Resync` of `{ runId, checkpointSeq, generation }`. `checkpointSeq`
|
|
19
|
+
is the compaction floor: the checkpoint's state subsumes every entry at or below
|
|
20
|
+
it, so a follower must restore a snapshot covering that sequence before
|
|
21
|
+
resuming. Its cursor then names the snapshot actually applied, which may be
|
|
22
|
+
newer. `generation` is the run's history after any rewinds; a restored cursor
|
|
23
|
+
that omits its own generation is bound to this one.
|
|
24
|
+
|
|
25
|
+
`compacted` is its own code rather than an `unknown` because it is the one
|
|
26
|
+
recoverable failure here. Folding it into `unknown` cost the checkpoint, and
|
|
27
|
+
with it every path back: the client retries only `transport_failed`, so the
|
|
28
|
+
subscription died and every resubscribe from the same cursors died identically.
|
|
29
|
+
A whole-workspace subscription then died over a single compacted run.
|
|
30
|
+
|
|
31
|
+
The run id comes from the call site rather than from the journal error. The
|
|
32
|
+
journal error carries only a sequence, and a workspace read fans out over many
|
|
33
|
+
runs, so without the id a follower could not tell which cursor to move.
|
|
34
|
+
|
|
35
|
+
`Resync` rides on `SyncError` as one optional field rather than as a new frame
|
|
36
|
+
variant or a new procedure. A follower that never meets a compacted run sees no
|
|
37
|
+
change at all. `Sync.Snapshot` separately fetches an explicitly public,
|
|
38
|
+
versioned and lineage-bound projection when the host provides one.
|
|
39
|
+
|
|
40
|
+
## Recovery requires an explicit handler
|
|
41
|
+
|
|
42
|
+
`SyncClient.subscribe` fails closed by default. An `onResync` handler must
|
|
43
|
+
restore the missing prefix and return `{ runId, afterSeq }` for the snapshot
|
|
44
|
+
actually applied. Only then does the client advance and read the suffix.
|
|
45
|
+
The receipt must name the requested run and cover the reported floor.
|
|
46
|
+
|
|
47
|
+
These cases stay failures on purpose:
|
|
48
|
+
|
|
49
|
+
- A `compacted` error carrying no `Resync` has no resume point to apply.
|
|
50
|
+
- A target outside the subscription's run scope is refused before the hook runs.
|
|
51
|
+
- A missing handler, failed application, or invalid receipt cannot acknowledge history.
|
|
52
|
+
- A checkpoint at or below what the subscription already covers cannot move the
|
|
53
|
+
cursor forward, so retrying would re-read the same refusal forever. That is
|
|
54
|
+
the same non-convergence rule that refuses an incomplete catch-up page which
|
|
55
|
+
makes no progress.
|
|
56
|
+
|
|
57
|
+
## The hole is real
|
|
58
|
+
|
|
59
|
+
:::warning
|
|
60
|
+
The resync moves a cursor, not state. The entries below `checkpointSeq` are
|
|
61
|
+
gone from the journal. A consumer must fetch and apply a public snapshot or
|
|
62
|
+
restore an authorized local checkpoint before claiming to have rebuilt that prefix.
|
|
63
|
+
:::
|
|
64
|
+
|
|
65
|
+
`SubscribeOptions.onResync` is the seam a consumer fills the hole through. It
|
|
66
|
+
runs **before** the cursor moves and must return a valid restored cursor. A failure leaves the cursor
|
|
67
|
+
where it was and fails the subscription, so nothing is skipped behind the
|
|
68
|
+
consumer's back.
|
|
69
|
+
|
|
70
|
+
What a consumer does there depends on what it is holding:
|
|
71
|
+
|
|
72
|
+
- A remote follower fetches a configured public projection with `sync.snapshot`,
|
|
73
|
+
validates its application schema, applies it, and returns its exact cursor.
|
|
74
|
+
- A trusted Node follower can also read the prefix out of band with
|
|
75
|
+
`journal.latestCheckpoint(runId)` and applies `checkpoint.state`, then
|
|
76
|
+
continues from the sync stream.
|
|
77
|
+
- A follower that cannot restore the prefix fails
|
|
78
|
+
the hook, and the subscription fails with the cursor unmoved.
|
|
79
|
+
|
|
80
|
+
Durable consumers own the transaction that commits snapshot state and their
|
|
81
|
+
durable cursor together. This client's in-memory cursor is not a cross-store
|
|
82
|
+
transaction. A moving compaction floor may require another idempotent restore.
|
|
83
|
+
Raw journal checkpoints are unredacted execution state. `SnapshotSource` is
|
|
84
|
+
an explicit public-projection boundary, not a passthrough for those checkpoints.
|
|
85
|
+
Provider failures and defects are logged at warning level with their original
|
|
86
|
+
cause. The caller receives `not_found` without provider details. Interruption
|
|
87
|
+
remains interruption.
|
|
88
|
+
|
|
89
|
+
## Retention and rebuilding
|
|
90
|
+
|
|
91
|
+
The protocol transfers a complete snapshot value. It does not hand out a
|
|
92
|
+
checkpoint reference that might expire between lookup and fetch. A provider
|
|
93
|
+
must capture its selected public state before yielding it, then let the server
|
|
94
|
+
detach and validate the response. Collecting a provider cache after capture
|
|
95
|
+
cannot invalidate those transferred bytes. A newer compaction can still
|
|
96
|
+
invalidate the suffix cursor; the next read returns `compacted` and recovery
|
|
97
|
+
fetches and applies a newer snapshot.
|
|
98
|
+
|
|
99
|
+
Before compacting, durably checkpoint every projection whose history the host
|
|
100
|
+
claims is recoverable. Keep that authoritative checkpoint until a newer complete
|
|
101
|
+
checkpoint covers it. The journal's fenced compaction transaction retains its
|
|
102
|
+
floor checkpoint and allocation entry while collecting older checkpoints and
|
|
103
|
+
events. A disposable public-snapshot cache can then be dropped and rebuilt from
|
|
104
|
+
that checkpoint plus retained history. Deleting both the checkpoint and the
|
|
105
|
+
compacted prefix destroys authority and cannot be repaired by sync.
|
|
106
|
+
|
|
107
|
+
`ProjectionRebuild.integration.test.ts` exercises 17-, 33-, and 257-entry
|
|
108
|
+
histories with independent full-history expectations. It drops the consumer's
|
|
109
|
+
branch and workspace projection tables and the public snapshot cache, kills
|
|
110
|
+
each rebuilding consumer with SIGKILL inside its transaction, then reopens
|
|
111
|
+
SQLite and reconstructs identical state. The 17-entry case ends exactly at its
|
|
112
|
+
compaction floor. A second case collects snapshots during transfer and advances
|
|
113
|
+
the floor before suffix replay. The workspace projection is an application
|
|
114
|
+
example authorized with production `WorkspaceShare` middleware.
|
|
115
|
+
|
|
116
|
+
`WorkspaceShare` itself is an HMAC capability authority, not a journal
|
|
117
|
+
projection. Its signing keys, credential lifetime, and access grants are not
|
|
118
|
+
rebuildable from public history. Branch presence is likewise an ephemeral lease.
|
|
119
|
+
These must not be included in claims about disposable history projections.
|
|
120
|
+
|
|
121
|
+
An older client without a recovery handler receives the typed
|
|
122
|
+
`compacted` result with its `resync` target and unchanged progress. A missing
|
|
123
|
+
public provider returns `not_found`; incompatible identities or versions are
|
|
124
|
+
refused. Neither response authorizes skipping the missing prefix.
|
|
125
|
+
|
|
126
|
+
[Handle a compacted run](../guides/handle-a-compacted-run.md) is the
|
|
127
|
+
task-shaped version, with the hook written out.
|
|
128
|
+
|
|
129
|
+
## Compaction is opt-in
|
|
130
|
+
|
|
131
|
+
Without a compaction policy on the journal layer, no entry is ever deleted and
|
|
132
|
+
no follower ever meets this code. That is the default. The code path matters
|
|
133
|
+
anyway, because the day a workspace enables compaction is the day the
|
|
134
|
+
difference between a follower that catches up and one that never can becomes
|
|
135
|
+
visible, and by then the follower is already deployed.
|
|
136
|
+
|
|
137
|
+
## Related pages
|
|
138
|
+
|
|
139
|
+
- [Checkpoints and compaction](/pkg/journal/concepts/compaction) in
|
|
140
|
+
`@smthrs/journal`: what a checkpoint is and how a floor advances.
|
|
141
|
+
- [Scopes and cursors](./scopes-and-cursors.md): what the cursor being moved
|
|
142
|
+
means.
|
|
143
|
+
- [Troubleshooting](../troubleshooting.md): the `compacted` symptom and what to
|
|
144
|
+
change.
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Replay then follow"
|
|
3
|
+
description: "The two phases of a sync subscription: bounded catch-up pages until the durable tail, then credit windows of live frames, and the bounds and validation that hold at each step."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
A subscription has two phases and one cursor set. First it replays durable
|
|
7
|
+
history through `Sync.Read`, page by page, until the server reports it reached
|
|
8
|
+
the tail. Then it follows through `Sync.Subscribe`. `SyncClient.subscribe`
|
|
9
|
+
performs both and presents them as a single stream of entries, so a consumer
|
|
10
|
+
never sees the switch.
|
|
11
|
+
|
|
12
|
+
## Phase one: pages until `done`
|
|
13
|
+
|
|
14
|
+
`Sync.Read` takes a scope, a cursor set, and a `limit`, and returns entries
|
|
15
|
+
plus the cursor set the page served through. A page stops at the first of three
|
|
16
|
+
bounds:
|
|
17
|
+
|
|
18
|
+
- the request's `limit`, capped at `SyncProtocol.maxReadLimit`;
|
|
19
|
+
- the frame ceiling, `SyncServer.Options.maxFrameBytes`, which defaults to
|
|
20
|
+
2 MiB of summed encoded entries;
|
|
21
|
+
- the durable tail of every covered run.
|
|
22
|
+
|
|
23
|
+
Only the third reports `done: true`, and that is the signal to switch to the
|
|
24
|
+
follow.
|
|
25
|
+
|
|
26
|
+
The frame ceiling is a budget on the page, not a verdict on the read. The
|
|
27
|
+
server serves entries until the next one would cross it, then returns
|
|
28
|
+
`done: false` so the client asks for the rest. The single case that fails is
|
|
29
|
+
one entry whose own encoded size exceeds the ceiling, refused with
|
|
30
|
+
`frame_too_large`, because no page can ever carry it.
|
|
31
|
+
|
|
32
|
+
### Why a page is shared, not filled in order
|
|
33
|
+
|
|
34
|
+
A workspace read offers each covered run a share of the page before offering
|
|
35
|
+
unused budget back to runs that still have entries. Both passes prioritize the
|
|
36
|
+
least advanced supplied cursor, with run ID breaking ties. Runs without a
|
|
37
|
+
cursor come first. Cursor values set scheduling priority only; sequences still
|
|
38
|
+
order entries within their own run.
|
|
39
|
+
|
|
40
|
+
A count or byte budget can end a page before every run gets a share. Reusing the
|
|
41
|
+
returned cursors changes the next page's priority: served runs advance, while
|
|
42
|
+
unserved runs keep their priority. A continuously readable run cannot indefinitely
|
|
43
|
+
hold a slot ahead of another pending run. Empty runs spend no entry budget.
|
|
44
|
+
This requires no server-side session and does not depend on cursor array order.
|
|
45
|
+
|
|
46
|
+
Fair service does not guarantee `done`: a producer that continuously outpaces
|
|
47
|
+
catch-up can keep the follower in this phase. Every pending run still receives
|
|
48
|
+
service; a single page need not contain every run.
|
|
49
|
+
|
|
50
|
+
## Phase two: credit windows
|
|
51
|
+
|
|
52
|
+
`Sync.Subscribe` takes the same scope and cursors plus a `credit` count.
|
|
53
|
+
`credit` is a hard limit on frames that subscription may emit, enforced by the
|
|
54
|
+
server. It is not a sliding acknowledgement window, and there is no `Ack`
|
|
55
|
+
procedure.
|
|
56
|
+
|
|
57
|
+
A follower that wants more frames resubscribes from its current subscription
|
|
58
|
+
progress. `SyncClient` does exactly that, once per window, defaulting to
|
|
59
|
+
`SyncClient.defaultCredit` of 256 frames.
|
|
60
|
+
|
|
61
|
+
The number is a real trade. At `credit: 1` the window closes after every single
|
|
62
|
+
entry, so following a run that emits a hundred entries costs a hundred
|
|
63
|
+
subscribe round trips, each one a fresh cursor snapshot and a fresh server-side
|
|
64
|
+
fan-out. Replenishing a window at a time makes the round-trip cost proportional
|
|
65
|
+
to the window rather than to the traffic, and keeps it bounded: an unbounded
|
|
66
|
+
window would hand the server one never-closing subscription per follower.
|
|
67
|
+
|
|
68
|
+
## What a frame says
|
|
69
|
+
|
|
70
|
+
An `Entries` frame carries one run's entries plus the interval the server
|
|
71
|
+
covered: `runId`, `fromSeq`, `toSeq`. The interval describes what the server
|
|
72
|
+
looked at, not what it carried, because dropped admissions leave legitimate
|
|
73
|
+
holes inside it.
|
|
74
|
+
|
|
75
|
+
`fromSeq` is what gap detection compares against the cursor. A frame whose
|
|
76
|
+
`fromSeq` starts more than one past the client's cursor is a real gap and fails
|
|
77
|
+
the subscription with `SyncGapError`. A frame that merely omits sequences
|
|
78
|
+
inside its own interval is ordinary traffic.
|
|
79
|
+
|
|
80
|
+
No frame carries a cursor. The client tracks its own.
|
|
81
|
+
|
|
82
|
+
A `Closed` frame is terminal and ends the subscription with the `closed` code.
|
|
83
|
+
The `Heartbeat` variant is reserved: no server in this package emits one, so a
|
|
84
|
+
client must not wait for one. It stays in the union so adding it later is not a
|
|
85
|
+
wire break, and so a third-party client written against the schema already
|
|
86
|
+
ignores it. Keepalive is a transport concern here, because a heartbeat would
|
|
87
|
+
spend the subscription's credit.
|
|
88
|
+
|
|
89
|
+
## Server responses are admitted, never trusted
|
|
90
|
+
|
|
91
|
+
Both phases validate before any cursor moves. A schema-valid page or frame can
|
|
92
|
+
still contradict itself, and applying one would corrupt the client's
|
|
93
|
+
bookkeeping. The client refuses:
|
|
94
|
+
|
|
95
|
+
- a page or frame whose summed encoded entries exceed `maxFrameBytes`, with
|
|
96
|
+
`frame_too_large`;
|
|
97
|
+
- an entry for a run the request's scope excludes;
|
|
98
|
+
- entries that repeat or reorder a sequence within one run;
|
|
99
|
+
- an entry at or below the cursor the request carried;
|
|
100
|
+
- an entry outside a frame's own declared interval;
|
|
101
|
+
- a response that echoes one run's cursor twice.
|
|
102
|
+
|
|
103
|
+
Each of the last five is a `protocol_violation`. The strictly-above-the-cursor
|
|
104
|
+
rule is also what makes catch-up converge: a page that carries entries but
|
|
105
|
+
moves no cursor would be re-read forever. An incomplete page with no entries at
|
|
106
|
+
all is refused for the same reason rather than retried.
|
|
107
|
+
|
|
108
|
+
## Reconnecting
|
|
109
|
+
|
|
110
|
+
A live follow that loses its transport reconnects under exponential backoff
|
|
111
|
+
capped at five seconds, resuming from the subscription's progress, and the
|
|
112
|
+
schedule resets once entries flow again. Only `transport_failed` retries. Gaps,
|
|
113
|
+
authorization refusals, and server closes propagate to the consumer, because
|
|
114
|
+
none of them is fixed by trying again. Delivery-only subscriptions resume from
|
|
115
|
+
delivered bookmarks; applying subscriptions resume from applied progress.
|
|
116
|
+
An unrelated defect or interruption accompanying a disconnect stays terminal,
|
|
117
|
+
with its complete cause preserved.
|
|
118
|
+
|
|
119
|
+
Transport, authentication, and reconnect all live in this package rather than
|
|
120
|
+
in the application. A follower that had to implement them would have to
|
|
121
|
+
re-derive the cursor rules above to do it safely.
|
|
122
|
+
|
|
123
|
+
## Related pages
|
|
124
|
+
|
|
125
|
+
- [Scopes and cursors](./scopes-and-cursors.md): what the cursor a phase
|
|
126
|
+
resumes from means.
|
|
127
|
+
- [Compaction and resync](./compaction.md): the one refusal that is recoverable
|
|
128
|
+
rather than terminal.
|
|
129
|
+
- [Wire protocol](../protocol.md): the exact message shapes both phases use.
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Scopes and cursors"
|
|
3
|
+
description: "What a sync request covers, what a cursor means, why journal sequences have holes, and the difference between an entry that was delivered and one that was applied."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Every sync request answers two questions: which runs does it cover, and where
|
|
7
|
+
in each of them does it start. The first is a scope. The second is a cursor
|
|
8
|
+
set.
|
|
9
|
+
|
|
10
|
+
## A scope is one run or the whole workspace
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
import type { JournalEvent } from "@smthrs/journal"
|
|
14
|
+
import type * as SyncProtocol from "@smthrs/sync/SyncProtocol"
|
|
15
|
+
|
|
16
|
+
const workspace: SyncProtocol.Scope = { _tag: "Workspace" }
|
|
17
|
+
const oneRun: SyncProtocol.Scope = { _tag: "Run", runId: "build-42" as JournalEvent.RunId }
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
A run scope names exactly one run. A workspace scope covers every run the
|
|
21
|
+
`RunCatalog` lists and the caller is authorized to read, and it reconciles that
|
|
22
|
+
set on every round, so a run created after the subscription opened joins it.
|
|
23
|
+
|
|
24
|
+
`SyncProtocol.covers(scope, runId)` is the predicate both ends use, so a client
|
|
25
|
+
validating a server's page asks the same question the server asked when it
|
|
26
|
+
built it.
|
|
27
|
+
|
|
28
|
+
## A cursor is per run, and exclusive
|
|
29
|
+
|
|
30
|
+
`RunCursor` is `{ runId, afterSeq, generation }`. A `WorkspaceCursor` is an
|
|
31
|
+
array of them. `generation` names the run's current history: a rewind
|
|
32
|
+
increments it, and sequence order is meaningful only within one generation.
|
|
33
|
+
Server cursors always carry it; a persisted request cursor may omit it for
|
|
34
|
+
generation zero. Persist it with `afterSeq`, because a cursor whose generation
|
|
35
|
+
differs from the run's fails with `lineage_changed`. See
|
|
36
|
+
[Rewind generations](../api.md#rewind-generations).
|
|
37
|
+
|
|
38
|
+
`afterSeq` means "entries after this number". It never means "expect the next
|
|
39
|
+
number to be exactly one greater".
|
|
40
|
+
|
|
41
|
+
That distinction is not a detail. Journal sequences legitimately have holes: an
|
|
42
|
+
admission the journal dropped leaves a number nothing was ever written at. A
|
|
43
|
+
follower that treats a missing number as corruption stalls on ordinary traffic.
|
|
44
|
+
Gap detection therefore compares the server's declared covered interval against
|
|
45
|
+
the cursor, not the entry sequences against each other. See
|
|
46
|
+
[Replay then follow](./replay-then-follow.md) for where that comparison lives.
|
|
47
|
+
|
|
48
|
+
## One cursor per run, or the request is refused
|
|
49
|
+
|
|
50
|
+
A cursor set that names one run twice is ambiguous about where the read starts,
|
|
51
|
+
so both `Sync.Read` and `Sync.Subscribe` refuse it with `invalid_request`
|
|
52
|
+
rather than picking one. `SyncProtocol.duplicateCursorRunId` is the check, and
|
|
53
|
+
the client applies it to the server's echoed cursors as well: a response that
|
|
54
|
+
names a run twice is a `protocol_violation`.
|
|
55
|
+
|
|
56
|
+
The schema cannot express uniqueness, so the rule is stated once and enforced
|
|
57
|
+
at every boundary that could break it.
|
|
58
|
+
|
|
59
|
+
## Delivered and applied are different claims
|
|
60
|
+
|
|
61
|
+
`RunCursor` is an exclusive transport bookmark. The server's response cursors
|
|
62
|
+
and `progress.delivered` describe delivery, and never acknowledge application.
|
|
63
|
+
`client.progress` reports `SyncProtocol.Progress`, two cursor sets:
|
|
64
|
+
|
|
65
|
+
- `delivered` records transport delivery.
|
|
66
|
+
- `applied` records successful `apply` or `onResync` callbacks.
|
|
67
|
+
|
|
68
|
+
Both are `WorkspaceCursor` values; the field name carries the distinction.
|
|
69
|
+
Delivery-only subscriptions leave applied progress empty. Use
|
|
70
|
+
`SubscribeOptions.apply` to acknowledge materialization:
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
import type { JournalEvent } from "@smthrs/journal"
|
|
74
|
+
import * as Effect from "effect/Effect"
|
|
75
|
+
|
|
76
|
+
const apply = (entry: JournalEvent.Entry): Effect.Effect<void> =>
|
|
77
|
+
Effect.logInfo(`applied ${entry.eventType} at ${entry.seq}`)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
The callback runs to success before the cursor moves. It fails with the
|
|
81
|
+
consumer's own error type, which the subscription's error channel carries
|
|
82
|
+
unchanged. A failure fails the subscription with that entry unacknowledged, so
|
|
83
|
+
the next subscription from `client.progress`'s applied cursors delivers it
|
|
84
|
+
again. Redelivery is what a retry is here, so the callback must be idempotent.
|
|
85
|
+
|
|
86
|
+
Use it whenever the consumer writes somewhere durable. Leave it off when the
|
|
87
|
+
consumer is a view that is rebuilt on reconnect anyway.
|
|
88
|
+
|
|
89
|
+
## The client's effective cursor is the later of two
|
|
90
|
+
|
|
91
|
+
A `SyncClient` keeps separate delivered and applied maps. An applying
|
|
92
|
+
subscription uses the later of the caller's cursor and the shared applied
|
|
93
|
+
cursor. A delivery-only subscription uses shared delivered progress. Earlier
|
|
94
|
+
delivery cannot fast-forward an applying consumer past unapplied entries.
|
|
95
|
+
One client instance belongs to one materialization; independent projections
|
|
96
|
+
must use independent clients even when they read the same run.
|
|
97
|
+
|
|
98
|
+
Both halves matter. A caller that restored its own progress from durable
|
|
99
|
+
storage is never regressed and never re-receives entries it has already
|
|
100
|
+
materialized. A caller behind its matching shared progress map is fast-forwarded.
|
|
101
|
+
Failed application can redeliver the unapplied entry; consumers must be idempotent.
|
|
102
|
+
|
|
103
|
+
To rebuild a projection from an earlier position, construct a fresh client. Its
|
|
104
|
+
progress maps are empty, so the caller's cursor is the only one there is.
|
|
105
|
+
|
|
106
|
+
## Persisting a cursor
|
|
107
|
+
|
|
108
|
+
`client.progress` returns canonical sets with one entry per run, sorted by run
|
|
109
|
+
id. Its applied positions advance only after application succeeds. Fetching a
|
|
110
|
+
snapshot does not move either set:
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
import * as SyncClient from "@smthrs/sync/SyncClient"
|
|
114
|
+
import * as Effect from "effect/Effect"
|
|
115
|
+
|
|
116
|
+
const checkpoint = Effect.flatMap(
|
|
117
|
+
SyncClient.SyncClient,
|
|
118
|
+
(sync) => Effect.map(sync.progress, (progress) => progress.applied)
|
|
119
|
+
)
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
For durable recovery, the callback must commit state and its cursor in the
|
|
123
|
+
same application transaction. Reading progress later and writing a separate
|
|
124
|
+
cursor file is not that transaction. Seed a fresh client from the durable
|
|
125
|
+
transaction's cursor after restart. Supply cursors only for state already
|
|
126
|
+
restored; an input bookmark is the caller's assertion, not proof of application.
|
|
127
|
+
|
|
128
|
+
## Related pages
|
|
129
|
+
|
|
130
|
+
- [Replay then follow](./replay-then-follow.md): how the two phases use these
|
|
131
|
+
cursors, and what bounds a page.
|
|
132
|
+
- [Compaction and resync](./compaction.md): what happens when a cursor names
|
|
133
|
+
history the journal has deleted.
|
|
134
|
+
- [Follow a run](../guides/follow-a-run.md): the task-shaped version of this
|
|
135
|
+
page.
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Authorize a connection"
|
|
3
|
+
description: "Provision a workspace share authority, mint a capability, present it from a client, rotate signing keys without invalidating outstanding links, and reach a shared branch instead."
|
|
4
|
+
sidebar:
|
|
5
|
+
order: 3
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
A sync connection reads a workspace's non-branch runs only when it presents a
|
|
9
|
+
verified `WorkspaceShare` capability. This guide provisions the authority,
|
|
10
|
+
mints a capability, and gets it onto the wire.
|
|
11
|
+
|
|
12
|
+
## Provision the authority from configuration
|
|
13
|
+
|
|
14
|
+
`WorkspaceShare.layerConfig` reads one secret and one key name from the
|
|
15
|
+
environment:
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import * as WorkspaceShare from "@smthrs/sync/WorkspaceShare"
|
|
19
|
+
|
|
20
|
+
const shareLayer = WorkspaceShare.layerConfig
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
| Variable | Meaning |
|
|
24
|
+
| ---------------------- | -------------------------------------------------------------------------------------------------------- |
|
|
25
|
+
| `SMITHERS_SYNC_SECRET` | The HMAC signing secret, read as `Redacted` so it is never logged. Required; at least 32 bytes of UTF-8. |
|
|
26
|
+
| `SMITHERS_SYNC_KEY_ID` | The key name recorded in every capability's claims. Defaults to `primary`. |
|
|
27
|
+
|
|
28
|
+
There is deliberately no default secret. A deployment that configures neither
|
|
29
|
+
name fails to construct the authority, and the read path stays closed.
|
|
30
|
+
|
|
31
|
+
## Mint a capability
|
|
32
|
+
|
|
33
|
+
`mint` takes the id you will audit by, the access level, and a lifetime in
|
|
34
|
+
milliseconds:
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
import * as Effect from "effect/Effect"
|
|
38
|
+
|
|
39
|
+
const mint = Effect.gen(function*() {
|
|
40
|
+
const share = yield* WorkspaceShare.WorkspaceShare
|
|
41
|
+
return yield* share.mint({
|
|
42
|
+
capabilityId: "dashboard-session-7",
|
|
43
|
+
access: "read",
|
|
44
|
+
ttlMs: 60 * 60 * 1000
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The result carries its claims and their signature. `capabilityId` is what
|
|
50
|
+
identifies the credential in a span or an audit trail; it is not a secret and
|
|
51
|
+
it is not the signature.
|
|
52
|
+
|
|
53
|
+
`verify` is the other half, and it is the single place a refusal happens. It
|
|
54
|
+
checks the signature in constant time, then that the key name is known, then
|
|
55
|
+
the expiry, then that the access asked for is covered. Every refusal is
|
|
56
|
+
`unauthorized` with a distinct message.
|
|
57
|
+
|
|
58
|
+
## Present it from a client
|
|
59
|
+
|
|
60
|
+
`SyncAuth.layerClient` stamps every outgoing request from a generated RPC
|
|
61
|
+
client with the capability:
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
import * as SyncAuth from "@smthrs/sync/SyncAuth"
|
|
65
|
+
import * as SyncClient from "@smthrs/sync/SyncClient"
|
|
66
|
+
import * as Layer from "effect/Layer"
|
|
67
|
+
|
|
68
|
+
const authorized = (capability: WorkspaceShare.WorkspaceCapability) =>
|
|
69
|
+
SyncClient.layer.pipe(Layer.provide(SyncAuth.layerClient(capability)))
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
A client written against the wire rather than against `SyncClient` sets the
|
|
73
|
+
header itself. The name is `SyncAuth.capabilityHeader`, which is
|
|
74
|
+
`flows-sync-workspace`, and the value is the unpadded base64url of the
|
|
75
|
+
schema-encoded capability JSON:
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
const header = Effect.gen(function*() {
|
|
79
|
+
const share = yield* WorkspaceShare.WorkspaceShare
|
|
80
|
+
const capability = yield* share.mint({ capabilityId: "cli", access: "read", ttlMs: 60_000 })
|
|
81
|
+
return yield* SyncAuth.encodeCapability(capability)
|
|
82
|
+
})
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`SyncAuth.decodeCapability` is the inverse. Every malformation folds to the
|
|
86
|
+
same `unauthorized` refusal, so the header cannot be used as a parsing oracle.
|
|
87
|
+
|
|
88
|
+
## Rotate a signing key
|
|
89
|
+
|
|
90
|
+
A capability's claims carry the `kid` of the key that signed them, and the
|
|
91
|
+
`kid` is itself signed. Build the authority over a keyring to add a new active
|
|
92
|
+
key while capabilities minted under the retired one are still outstanding:
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
import * as Config from "effect/Config"
|
|
96
|
+
import * as Layer from "effect/Layer"
|
|
97
|
+
|
|
98
|
+
const rotated = Layer.unwrap(
|
|
99
|
+
Effect.gen(function*() {
|
|
100
|
+
const keyring: WorkspaceShare.Keyring = {
|
|
101
|
+
activeKid: "2026-q1",
|
|
102
|
+
keys: [
|
|
103
|
+
{ kid: "2026-q1", secret: yield* Config.Redacted("SYNC_SECRET_CURRENT") },
|
|
104
|
+
{ kid: "2025-q4", secret: yield* Config.Redacted("SYNC_SECRET_RETIRED") }
|
|
105
|
+
]
|
|
106
|
+
}
|
|
107
|
+
return WorkspaceShare.layerHmac(keyring)
|
|
108
|
+
})
|
|
109
|
+
)
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
`Config.Redacted` reads each secret without ever rendering it. An unset
|
|
113
|
+
variable fails the layer with a `ConfigError` naming it, so the read path
|
|
114
|
+
stays closed.
|
|
115
|
+
|
|
116
|
+
New capabilities are signed with `activeKid`. Capabilities naming any key in
|
|
117
|
+
`keys` still verify. Drop the retired key once its longest outstanding
|
|
118
|
+
lifetime has passed.
|
|
119
|
+
|
|
120
|
+
Every key is imported when the authority is constructed, so a misconfigured
|
|
121
|
+
keyring fails there rather than at the first request. A malformed keyring
|
|
122
|
+
fails with `invalid_request`:
|
|
123
|
+
|
|
124
|
+
- an `activeKid` that names no key in the ring;
|
|
125
|
+
- a `kid` listed twice;
|
|
126
|
+
- a secret holding an unpaired surrogate, which UTF-8 would fold into the
|
|
127
|
+
same key bytes as a different secret;
|
|
128
|
+
- a secret shorter than 32 bytes of UTF-8. Every capability carries its
|
|
129
|
+
claims and signature in the clear, so one observed capability lets an
|
|
130
|
+
attacker search a short secret offline and then forge capabilities.
|
|
131
|
+
Generate one with `openssl rand -base64 32`.
|
|
132
|
+
|
|
133
|
+
A secret Web Crypto refuses to import fails with `unknown` instead, because
|
|
134
|
+
the refusal is the platform's rather than the request's. The error's `cause`
|
|
135
|
+
carries the rejection text.
|
|
136
|
+
|
|
137
|
+
## Close the door entirely
|
|
138
|
+
|
|
139
|
+
`WorkspaceShare.layerNoop` mints nothing and verifies nothing. Both operations
|
|
140
|
+
**fail** with `unauthorized` rather than dying, so a consumer handling
|
|
141
|
+
`SyncError` gets a refusal where the type promised one. Wire it when a
|
|
142
|
+
deployment deliberately offers no workspace sharing, such as a local CLI whose
|
|
143
|
+
only reader is in process.
|
|
144
|
+
|
|
145
|
+
## Reach a shared branch instead
|
|
146
|
+
|
|
147
|
+
A branch capability is a different credential with a different authority. It
|
|
148
|
+
travels in the request payload rather than in a header, because it names one
|
|
149
|
+
branch rather than one connection:
|
|
150
|
+
|
|
151
|
+
```ts
|
|
152
|
+
import type { JournalEvent } from "@smthrs/journal"
|
|
153
|
+
import type * as BranchProtocol from "@smthrs/sync/BranchProtocol"
|
|
154
|
+
|
|
155
|
+
const followBranch = (capability: BranchProtocol.ShareCapability, runId: JournalEvent.RunId) =>
|
|
156
|
+
Effect.gen(function*() {
|
|
157
|
+
const sync = yield* SyncClient.SyncClient
|
|
158
|
+
return sync.subscribe({ scope: { _tag: "Run", runId }, cursors: [], capability })
|
|
159
|
+
})
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Mint one through `BranchShare`, whose `mint` takes a `branchId` alongside the
|
|
163
|
+
same `capabilityId`, `access`, and `ttlMs`. A connection holding only a branch
|
|
164
|
+
link still reads no engine run: that is the other boundary, and the link does
|
|
165
|
+
not answer it.
|
|
166
|
+
|
|
167
|
+
## Related pages
|
|
168
|
+
|
|
169
|
+
- [Authorization](../concepts/authorization.md): why there are two boundaries
|
|
170
|
+
and what a signed expiry does to an open subscription.
|
|
171
|
+
- [Serve the read path](./serve-the-read-path.md): where the middleware is
|
|
172
|
+
installed.
|
|
173
|
+
- [Troubleshooting](../troubleshooting.md): each `unauthorized` symptom and its
|
|
174
|
+
cause.
|