@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,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace run enumeration for synchronization.
|
|
3
|
+
*
|
|
4
|
+
* @since 0.1.0
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { JournalEvent } from "@smthrs/journal"
|
|
8
|
+
import * as Context from "effect/Context"
|
|
9
|
+
import * as Effect from "effect/Effect"
|
|
10
|
+
import * as Layer from "effect/Layer"
|
|
11
|
+
import * as PubSub from "effect/PubSub"
|
|
12
|
+
import * as Ref from "effect/Ref"
|
|
13
|
+
import type * as Scope from "effect/Scope"
|
|
14
|
+
import * as Stream from "effect/Stream"
|
|
15
|
+
import { positiveInt } from "./internal/PolicyOptions.ts"
|
|
16
|
+
import type { SyncError } from "./SyncError.ts"
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Workspace run enumeration operations.
|
|
20
|
+
*
|
|
21
|
+
* `list` is the AUTHORITATIVE run set and `changes` is only a low-latency
|
|
22
|
+
* wake: a subscriber that misses an announcement re-lists rather than losing
|
|
23
|
+
* state, and a subscriber whose covered set must stay accurate re-lists on a
|
|
24
|
+
* cadence of its own. Every implementation returns a fresh array from `list`,
|
|
25
|
+
* so a caller may retain and sort what it receives without disturbing another
|
|
26
|
+
* reader.
|
|
27
|
+
*
|
|
28
|
+
* `list` names each run AT MOST ONCE. Every implementation here deduplicates,
|
|
29
|
+
* and `SyncServer` deduplicates again at the seam rather than trusting a
|
|
30
|
+
* host's catalog, because both fan-out paths key a run's served position by
|
|
31
|
+
* run id: a run named twice is read twice from the same position and its
|
|
32
|
+
* entries are served twice.
|
|
33
|
+
*
|
|
34
|
+
* The catalog is supplied by the host rather than derived from the journal:
|
|
35
|
+
* `@smthrs/journal` has no workspace-wide list or watch contract, and
|
|
36
|
+
* `@smthrs/engine-store` owns the durable run set that {@link makePolling}
|
|
37
|
+
* reads.
|
|
38
|
+
*
|
|
39
|
+
* @category models
|
|
40
|
+
* @since 0.1.0
|
|
41
|
+
*/
|
|
42
|
+
export interface Service {
|
|
43
|
+
readonly list: Effect.Effect<ReadonlyArray<JournalEvent.RunId>>
|
|
44
|
+
readonly changes: Stream.Stream<JournalEvent.RunId>
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Host-provided workspace run catalog.
|
|
49
|
+
*
|
|
50
|
+
* @category services
|
|
51
|
+
* @since 0.1.0
|
|
52
|
+
*/
|
|
53
|
+
export class RunCatalog extends Context.Service<RunCatalog, Service>()("@smthrs/sync/RunCatalog") {}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Provides an immutable run catalog.
|
|
57
|
+
*
|
|
58
|
+
* `list` answers with a fresh array each time, matching {@link makeMemory} and
|
|
59
|
+
* {@link makePolling}. Handing every caller the same instance let one reader
|
|
60
|
+
* sort or splice the catalog in place for every other reader, which is a
|
|
61
|
+
* defect a `ReadonlyArray` type hides from TypeScript and not from JavaScript.
|
|
62
|
+
*
|
|
63
|
+
* @category layers
|
|
64
|
+
* @since 0.1.0
|
|
65
|
+
*/
|
|
66
|
+
export const layerStatic = (
|
|
67
|
+
ids: ReadonlyArray<JournalEvent.RunId>
|
|
68
|
+
): Layer.Layer<RunCatalog> => {
|
|
69
|
+
const known = Array.from(new Set(ids))
|
|
70
|
+
return Layer.succeed(
|
|
71
|
+
RunCatalog,
|
|
72
|
+
RunCatalog.of({
|
|
73
|
+
list: Effect.sync(() => [...known]),
|
|
74
|
+
changes: Stream.empty
|
|
75
|
+
})
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Announcements a stalled {@link Service.changes} subscriber may fall behind
|
|
81
|
+
* by before the oldest are dropped.
|
|
82
|
+
*
|
|
83
|
+
* `changes` is a notification feed, not a log: a follower reads it to learn
|
|
84
|
+
* that the workspace gained a run and then reads the run itself. Retaining an
|
|
85
|
+
* unbounded backlog for a subscriber that has stopped pulling — a disconnected
|
|
86
|
+
* tab whose socket has not timed out yet, a follower blocked behind a slow
|
|
87
|
+
* consumer — is therefore pure cost, and the workspace's whole run history is
|
|
88
|
+
* the upper bound on it. A subscriber that falls further behind than this
|
|
89
|
+
* bound loses the oldest announcements; `list` still names every run, so a
|
|
90
|
+
* follower that reconnects or re-lists converges.
|
|
91
|
+
*
|
|
92
|
+
* @category constants
|
|
93
|
+
* @since 0.1.0
|
|
94
|
+
*/
|
|
95
|
+
export const defaultChangesCapacity = 1024
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Options for the in-memory run catalog.
|
|
99
|
+
*
|
|
100
|
+
* @category models
|
|
101
|
+
* @since 0.1.0
|
|
102
|
+
*/
|
|
103
|
+
export interface MemoryOptions {
|
|
104
|
+
/**
|
|
105
|
+
* Announcements a stalled subscriber may fall behind by. Defaults to
|
|
106
|
+
* {@link defaultChangesCapacity}.
|
|
107
|
+
*/
|
|
108
|
+
readonly changesCapacity?: number | undefined
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Constructs a mutable in-memory run catalog.
|
|
113
|
+
*
|
|
114
|
+
* A run is published exactly once, when it is first registered. The
|
|
115
|
+
* announcement feed slides: registering never waits on a stalled subscriber,
|
|
116
|
+
* and never grows the process on its behalf.
|
|
117
|
+
*
|
|
118
|
+
* Fails with `invalid_request` when `changesCapacity` is not a positive safe
|
|
119
|
+
* integer, rather than handing it to `PubSub.sliding` as a capacity.
|
|
120
|
+
*
|
|
121
|
+
* @category constructors
|
|
122
|
+
* @since 0.1.0
|
|
123
|
+
*/
|
|
124
|
+
export const makeMemory = (options: MemoryOptions = {}): Effect.Effect<{
|
|
125
|
+
readonly catalog: Service
|
|
126
|
+
readonly register: (runId: JournalEvent.RunId) => Effect.Effect<void>
|
|
127
|
+
}, SyncError> =>
|
|
128
|
+
Effect.gen(function*() {
|
|
129
|
+
const known = new Set<JournalEvent.RunId>()
|
|
130
|
+
const changes = yield* PubSub.sliding<JournalEvent.RunId>(
|
|
131
|
+
yield* positiveInt("RunCatalog.MemoryOptions.changesCapacity", options.changesCapacity, defaultChangesCapacity)
|
|
132
|
+
)
|
|
133
|
+
return {
|
|
134
|
+
catalog: RunCatalog.of({
|
|
135
|
+
list: Effect.sync(() => Array.from(known)).pipe(Effect.withSpan("RunCatalog.list")),
|
|
136
|
+
changes: Stream.fromPubSub(changes)
|
|
137
|
+
}),
|
|
138
|
+
register: Effect.fn("RunCatalog.register")((runId) =>
|
|
139
|
+
Effect.suspend(() => {
|
|
140
|
+
if (known.has(runId)) return Effect.void
|
|
141
|
+
known.add(runId)
|
|
142
|
+
return PubSub.publish(changes, runId).pipe(Effect.asVoid)
|
|
143
|
+
})
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* How often {@link makePolling} re-reads its source when the caller names no
|
|
150
|
+
* interval, in milliseconds.
|
|
151
|
+
*
|
|
152
|
+
* A workspace follower learns of a new run within one interval of it being
|
|
153
|
+
* created, and the read costs one bounded query per interval per composition,
|
|
154
|
+
* not per subscriber. A second is short enough that a run appears in a UI
|
|
155
|
+
* while the operator is still looking at the command that started it, and
|
|
156
|
+
* long enough that an idle workspace is close to free.
|
|
157
|
+
*
|
|
158
|
+
* @category constants
|
|
159
|
+
* @since 0.1.0
|
|
160
|
+
*/
|
|
161
|
+
export const defaultPollIntervalMs = 1000
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Options for the polling run catalog.
|
|
165
|
+
*
|
|
166
|
+
* @category models
|
|
167
|
+
* @since 0.1.0
|
|
168
|
+
*/
|
|
169
|
+
export interface PollingOptions<E, R> {
|
|
170
|
+
/**
|
|
171
|
+
* Reads the workspace's current run set. `@smthrs/engine-store` supplies
|
|
172
|
+
* the durable one over `flows_runs`; anything that answers the same
|
|
173
|
+
* question works, and the catalog holds no assumption about which.
|
|
174
|
+
*/
|
|
175
|
+
readonly read: Effect.Effect<ReadonlyArray<JournalEvent.RunId>, E, R>
|
|
176
|
+
/** Milliseconds between reads. Defaults to {@link defaultPollIntervalMs}. */
|
|
177
|
+
readonly intervalMs?: number | undefined
|
|
178
|
+
/**
|
|
179
|
+
* Announcements a stalled subscriber may fall behind by. Defaults to
|
|
180
|
+
* {@link defaultChangesCapacity}.
|
|
181
|
+
*/
|
|
182
|
+
readonly changesCapacity?: number | undefined
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Constructs a run catalog that polls a durable source.
|
|
187
|
+
*
|
|
188
|
+
* Polling, not waking. rc.0 has no cross-process event delivery, so a
|
|
189
|
+
* follower learns of a run another engine created by asking the workspace
|
|
190
|
+
* again, and the interval is the whole of the policy.
|
|
191
|
+
*
|
|
192
|
+
* The catalog primes itself before it returns, so the first read a follower
|
|
193
|
+
* makes is already current and a workspace that cannot be read at all fails
|
|
194
|
+
* the composition instead of serving an empty run set. After that a failed
|
|
195
|
+
* read is a warning and nothing else: the previous view stands, the interval
|
|
196
|
+
* holds, and no subscription attached to the catalog is torn down for it.
|
|
197
|
+
* The warning is written once per outage, with the first failure's cause, and
|
|
198
|
+
* one info line marks the read that ends it; the polls in between are silent.
|
|
199
|
+
*
|
|
200
|
+
* A run is announced once, when it first appears. A run the read stops
|
|
201
|
+
* naming, which is what retention collecting it looks like, leaves the view;
|
|
202
|
+
* `list` is the workspace's run set, not a log of every run it ever had.
|
|
203
|
+
*
|
|
204
|
+
* `list` answers with a fresh array each time, matching {@link layerStatic} and
|
|
205
|
+
* {@link makeMemory}. Handing back the stored snapshot let a caller sort or
|
|
206
|
+
* splice the authoritative run set in place under the poll fiber that owns it,
|
|
207
|
+
* which is a defect a `ReadonlyArray` type hides from TypeScript and not from
|
|
208
|
+
* JavaScript.
|
|
209
|
+
*
|
|
210
|
+
* The poll fiber belongs to the caller's scope. Closing the scope stops it.
|
|
211
|
+
*
|
|
212
|
+
* Both numeric options are validated here, before the first read: an
|
|
213
|
+
* `intervalMs` of `NaN` or zero reached `Effect.delay` as the whole of the
|
|
214
|
+
* poll cadence, which is a loop with no interval rather than a refusal.
|
|
215
|
+
*
|
|
216
|
+
* @category constructors
|
|
217
|
+
* @since 0.1.0
|
|
218
|
+
*/
|
|
219
|
+
export const makePolling = <E, R>(
|
|
220
|
+
options: PollingOptions<E, R>
|
|
221
|
+
): Effect.Effect<Service, E | SyncError, R | Scope.Scope> =>
|
|
222
|
+
Effect.gen(function*() {
|
|
223
|
+
const intervalMs = yield* positiveInt(
|
|
224
|
+
"RunCatalog.PollingOptions.intervalMs",
|
|
225
|
+
options.intervalMs,
|
|
226
|
+
defaultPollIntervalMs
|
|
227
|
+
)
|
|
228
|
+
const changes = yield* PubSub.sliding<JournalEvent.RunId>(
|
|
229
|
+
yield* positiveInt("RunCatalog.PollingOptions.changesCapacity", options.changesCapacity, defaultChangesCapacity)
|
|
230
|
+
)
|
|
231
|
+
const snapshot = yield* Ref.make<ReadonlyArray<JournalEvent.RunId>>([])
|
|
232
|
+
|
|
233
|
+
const refresh = Effect.gen(function*() {
|
|
234
|
+
const read = yield* options.read
|
|
235
|
+
const next = Array.from(new Set(read))
|
|
236
|
+
const previous = new Set(yield* Ref.get(snapshot))
|
|
237
|
+
yield* Ref.set(snapshot, next)
|
|
238
|
+
for (const runId of next) {
|
|
239
|
+
// Publishing never waits on a stalled subscriber, and never grows the
|
|
240
|
+
// process on its behalf: the feed slides.
|
|
241
|
+
if (!previous.has(runId)) yield* PubSub.publish(changes, runId)
|
|
242
|
+
}
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
yield* refresh
|
|
246
|
+
// The log follows the outage's transitions, not the poll cadence. With
|
|
247
|
+
// the default interval a workspace that stays unreadable would otherwise
|
|
248
|
+
// write one identical warning per second per catalog for as long as it
|
|
249
|
+
// lasts, and the line that says it ended would never be written.
|
|
250
|
+
const consecutiveFailures = yield* Ref.make(0)
|
|
251
|
+
const poll = refresh.pipe(
|
|
252
|
+
Effect.matchCauseEffect({
|
|
253
|
+
onFailure: (cause) =>
|
|
254
|
+
Effect.flatMap(Ref.updateAndGet(consecutiveFailures, (count) => count + 1), (count) =>
|
|
255
|
+
count === 1
|
|
256
|
+
? Effect.logWarning("The workspace run catalog could not be read; the previous view stands", cause)
|
|
257
|
+
: Effect.void),
|
|
258
|
+
onSuccess: () =>
|
|
259
|
+
Effect.flatMap(Ref.getAndSet(consecutiveFailures, 0), (count) =>
|
|
260
|
+
count === 0
|
|
261
|
+
? Effect.void
|
|
262
|
+
: Effect.logInfo(`The workspace run catalog is readable again after ${count} failed reads`))
|
|
263
|
+
})
|
|
264
|
+
)
|
|
265
|
+
yield* poll.pipe(
|
|
266
|
+
Effect.delay(intervalMs),
|
|
267
|
+
Effect.forever,
|
|
268
|
+
Effect.forkScoped
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
return RunCatalog.of({
|
|
272
|
+
list: Effect.map(Ref.get(snapshot), (ids) => [...ids]).pipe(Effect.withSpan("RunCatalog.list")),
|
|
273
|
+
changes: Stream.fromPubSub(changes)
|
|
274
|
+
})
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Provides a run catalog that polls a durable source. The poll fiber lives as
|
|
279
|
+
* long as the layer's scope.
|
|
280
|
+
*
|
|
281
|
+
* @category layers
|
|
282
|
+
* @since 0.1.0
|
|
283
|
+
*/
|
|
284
|
+
export const layerPolling = <E, R>(
|
|
285
|
+
options: PollingOptions<E, R>
|
|
286
|
+
): Layer.Layer<RunCatalog, E | SyncError, R> => Layer.effect(RunCatalog, makePolling(options))
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Provides the mutable in-memory run catalog, matching how every other
|
|
290
|
+
* implementation here is provided. Fails with `invalid_request` when
|
|
291
|
+
* `changesCapacity` is not a positive safe integer.
|
|
292
|
+
*
|
|
293
|
+
* The layer provides the catalog alone. A composition that also registers runs
|
|
294
|
+
* builds {@link makeMemory} itself and keeps its `register`.
|
|
295
|
+
*
|
|
296
|
+
* @category layers
|
|
297
|
+
* @since 1.0.0-rc.0
|
|
298
|
+
*/
|
|
299
|
+
export const layerMemory = (options: MemoryOptions = {}): Layer.Layer<RunCatalog, SyncError> =>
|
|
300
|
+
Layer.effect(RunCatalog, Effect.map(makeMemory(options), ({ catalog }) => catalog))
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Provides an empty run catalog.
|
|
304
|
+
*
|
|
305
|
+
* @category layers
|
|
306
|
+
* @since 0.1.0
|
|
307
|
+
*/
|
|
308
|
+
export const layerNoop: Layer.Layer<RunCatalog> = layerStatic([])
|
package/src/SyncAuth.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The sync read path's connection authentication: implementations of the
|
|
3
|
+
* `SyncRpcs.SyncAuth` RPC middleware.
|
|
4
|
+
*
|
|
5
|
+
* A connection presents a {@link WorkspaceShare.WorkspaceCapability} in the
|
|
6
|
+
* {@link capabilityHeader} request header. {@link layer} verifies it against
|
|
7
|
+
* the workspace's {@link WorkspaceShare.WorkspaceShare} authority and runs
|
|
8
|
+
* the handler as the workspace principal. A request with no header runs as
|
|
9
|
+
* the anonymous principal — branch share capabilities carried in request
|
|
10
|
+
* payloads still authorize branch reads — and the server refuses anonymous
|
|
11
|
+
* access to every non-branch run. A request whose header is present but
|
|
12
|
+
* malformed, forged, expired, or signed by an unknown key is refused
|
|
13
|
+
* outright: presence of a credential is a claim, and a false claim is never
|
|
14
|
+
* downgraded to anonymity.
|
|
15
|
+
*
|
|
16
|
+
* There is deliberately no trusted pass-through implementation here: the only
|
|
17
|
+
* sanctioned bypass is an in-process caller providing `SyncPrincipal` itself,
|
|
18
|
+
* never a transport.
|
|
19
|
+
*
|
|
20
|
+
* @since 0.1.0
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import * as Effect from "effect/Effect"
|
|
24
|
+
import * as Encoding from "effect/Encoding"
|
|
25
|
+
import * as Layer from "effect/Layer"
|
|
26
|
+
import * as Result from "effect/Result"
|
|
27
|
+
import * as Schema from "effect/Schema"
|
|
28
|
+
import * as Headers from "effect/unstable/http/Headers"
|
|
29
|
+
import * as RpcMiddleware from "effect/unstable/rpc/RpcMiddleware"
|
|
30
|
+
import { causeText } from "./internal/CauseText.ts"
|
|
31
|
+
import * as shareSigner from "./internal/ShareSigner.ts"
|
|
32
|
+
import { SyncError } from "./SyncError.ts"
|
|
33
|
+
import * as SyncPrincipal from "./SyncPrincipal.ts"
|
|
34
|
+
import { SyncAuth } from "./SyncRpcs.ts"
|
|
35
|
+
import * as WorkspaceShare from "./WorkspaceShare.ts"
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The request header a connection presents its workspace capability in.
|
|
39
|
+
*
|
|
40
|
+
* @category constants
|
|
41
|
+
* @since 0.1.0
|
|
42
|
+
*/
|
|
43
|
+
export const capabilityHeader = "flows-sync-workspace"
|
|
44
|
+
|
|
45
|
+
const denied = shareSigner.unauthorized
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Renders a workspace capability as its header value: unpadded base64url of
|
|
49
|
+
* the schema-encoded capability JSON.
|
|
50
|
+
*
|
|
51
|
+
* @category encoding
|
|
52
|
+
* @since 0.1.0
|
|
53
|
+
*/
|
|
54
|
+
export const encodeCapability = (
|
|
55
|
+
capability: WorkspaceShare.WorkspaceCapability
|
|
56
|
+
): Effect.Effect<string, SyncError> => {
|
|
57
|
+
const encoded = Schema.encodeUnknownResult(WorkspaceShare.WorkspaceCapability)(capability)
|
|
58
|
+
return Result.isFailure(encoded)
|
|
59
|
+
? Effect.fail(
|
|
60
|
+
new SyncError({
|
|
61
|
+
code: "invalid_request",
|
|
62
|
+
message: "The workspace capability does not encode",
|
|
63
|
+
// The rendering, never the parse failure itself: a schema issue
|
|
64
|
+
// carries the value it rejected, and the value here is a credential.
|
|
65
|
+
cause: causeText(encoded.failure)
|
|
66
|
+
})
|
|
67
|
+
)
|
|
68
|
+
: Effect.succeed(Encoding.encodeBase64Url(JSON.stringify(encoded.success)))
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Decodes a capability header value. Every malformation folds to the same
|
|
73
|
+
* `unauthorized` refusal so the header is not a parsing oracle.
|
|
74
|
+
*
|
|
75
|
+
* @category encoding
|
|
76
|
+
* @since 0.1.0
|
|
77
|
+
*/
|
|
78
|
+
export const decodeCapability = (value: string): Effect.Effect<WorkspaceShare.WorkspaceCapability, SyncError> => {
|
|
79
|
+
const text = Encoding.decodeBase64UrlString(value)
|
|
80
|
+
if (Result.isFailure(text)) {
|
|
81
|
+
return Effect.fail(denied("The workspace capability header is not a capability"))
|
|
82
|
+
}
|
|
83
|
+
let parsed: unknown
|
|
84
|
+
try {
|
|
85
|
+
parsed = JSON.parse(text.success)
|
|
86
|
+
} catch {
|
|
87
|
+
return Effect.fail(denied("The workspace capability header is not a capability"))
|
|
88
|
+
}
|
|
89
|
+
const decoded = Schema.decodeUnknownResult(WorkspaceShare.WorkspaceCapability)(parsed)
|
|
90
|
+
return Result.isFailure(decoded)
|
|
91
|
+
? Effect.fail(denied("The workspace capability header is not a capability"))
|
|
92
|
+
: Effect.succeed(decoded.success)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Provides the workspace-capability authentication middleware: no header
|
|
97
|
+
* runs the handler as anonymous, a verified header runs it as the workspace
|
|
98
|
+
* principal, and anything else refuses the request. Verification faults
|
|
99
|
+
* (Web Crypto failing) propagate as their own error rather than as a
|
|
100
|
+
* refusal.
|
|
101
|
+
*
|
|
102
|
+
* @category layers
|
|
103
|
+
* @since 0.1.0
|
|
104
|
+
*/
|
|
105
|
+
export const layer: Layer.Layer<SyncAuth, never, WorkspaceShare.WorkspaceShare> = Layer.effect(
|
|
106
|
+
SyncAuth,
|
|
107
|
+
Effect.gen(function*() {
|
|
108
|
+
const share = yield* WorkspaceShare.WorkspaceShare
|
|
109
|
+
const middleware: RpcMiddleware.RpcMiddleware<never, SyncError, never> = (effect, options) => {
|
|
110
|
+
const header = Headers.get(options.headers, capabilityHeader)
|
|
111
|
+
if (header._tag === "None") {
|
|
112
|
+
return Effect.provideService(effect, SyncPrincipal.SyncPrincipal, SyncPrincipal.anonymous)
|
|
113
|
+
}
|
|
114
|
+
return decodeCapability(header.value).pipe(
|
|
115
|
+
Effect.flatMap((capability) => share.verify(capability, { access: "read" })),
|
|
116
|
+
Effect.flatMap((claims) =>
|
|
117
|
+
Effect.provideService(
|
|
118
|
+
effect,
|
|
119
|
+
SyncPrincipal.SyncPrincipal,
|
|
120
|
+
// The signed expiry travels WITH the identity. A handler that
|
|
121
|
+
// opens a stream authorizes once; reducing the verified claims to
|
|
122
|
+
// a capability id here left the stream with nothing to expire on.
|
|
123
|
+
SyncPrincipal.workspace(claims.capabilityId, claims.expiresAtMs)
|
|
124
|
+
)
|
|
125
|
+
)
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
return middleware
|
|
129
|
+
})
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Stamps every outgoing request from a generated RPC client with the given
|
|
134
|
+
* workspace capability.
|
|
135
|
+
*
|
|
136
|
+
* @category layers
|
|
137
|
+
* @since 0.1.0
|
|
138
|
+
*/
|
|
139
|
+
export const layerClient = (
|
|
140
|
+
capability: WorkspaceShare.WorkspaceCapability
|
|
141
|
+
): Layer.Layer<RpcMiddleware.ForClient<SyncAuth>, SyncError> =>
|
|
142
|
+
RpcMiddleware.layerClient(
|
|
143
|
+
SyncAuth,
|
|
144
|
+
Effect.map(encodeCapability(capability), (header) => ({ next, request }) =>
|
|
145
|
+
next({ ...request, headers: Headers.set(request.headers, capabilityHeader, header) }))
|
|
146
|
+
)
|