@smthrs/sync 0.0.0-stage → 1.0.0-rc.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +208 -0
- package/LICENSE +21 -0
- package/README.md +208 -2
- package/dist/cjs/BranchCommands.d.ts +228 -0
- package/dist/cjs/BranchCommands.d.ts.map +1 -0
- package/dist/cjs/BranchCommands.js +316 -0
- package/dist/cjs/BranchCommands.js.map +7 -0
- package/dist/cjs/BranchIds.d.ts +65 -0
- package/dist/cjs/BranchIds.d.ts.map +1 -0
- package/dist/cjs/BranchIds.js +53 -0
- package/dist/cjs/BranchIds.js.map +7 -0
- package/dist/cjs/BranchPresence.d.ts +195 -0
- package/dist/cjs/BranchPresence.d.ts.map +1 -0
- package/dist/cjs/BranchPresence.js +276 -0
- package/dist/cjs/BranchPresence.js.map +7 -0
- package/dist/cjs/BranchProjection.d.ts +179 -0
- package/dist/cjs/BranchProjection.d.ts.map +1 -0
- package/dist/cjs/BranchProjection.js +197 -0
- package/dist/cjs/BranchProjection.js.map +7 -0
- package/dist/cjs/BranchProtocol.d.ts +415 -0
- package/dist/cjs/BranchProtocol.d.ts.map +1 -0
- package/dist/cjs/BranchProtocol.js +172 -0
- package/dist/cjs/BranchProtocol.js.map +7 -0
- package/dist/cjs/BranchRpcs.d.ts +177 -0
- package/dist/cjs/BranchRpcs.d.ts.map +1 -0
- package/dist/cjs/BranchRpcs.js +85 -0
- package/dist/cjs/BranchRpcs.js.map +7 -0
- package/dist/cjs/BranchServer.d.ts +55 -0
- package/dist/cjs/BranchServer.d.ts.map +1 -0
- package/dist/cjs/BranchServer.js +147 -0
- package/dist/cjs/BranchServer.js.map +7 -0
- package/dist/cjs/BranchShare.d.ts +159 -0
- package/dist/cjs/BranchShare.d.ts.map +1 -0
- package/dist/cjs/BranchShare.js +147 -0
- package/dist/cjs/BranchShare.js.map +7 -0
- package/dist/cjs/RunCatalog.d.ts +207 -0
- package/dist/cjs/RunCatalog.d.ts.map +1 -0
- package/dist/cjs/RunCatalog.js +122 -0
- package/dist/cjs/RunCatalog.js.map +7 -0
- package/dist/cjs/SyncAuth.d.ts +70 -0
- package/dist/cjs/SyncAuth.d.ts.map +1 -0
- package/dist/cjs/SyncAuth.js +109 -0
- package/dist/cjs/SyncAuth.js.map +7 -0
- package/dist/cjs/SyncClient.d.ts +274 -0
- package/dist/cjs/SyncClient.d.ts.map +1 -0
- package/dist/cjs/SyncClient.js +474 -0
- package/dist/cjs/SyncClient.js.map +7 -0
- package/dist/cjs/SyncError.d.ts +107 -0
- package/dist/cjs/SyncError.d.ts.map +1 -0
- package/dist/cjs/SyncError.js +110 -0
- package/dist/cjs/SyncError.js.map +7 -0
- package/dist/cjs/SyncPrincipal.d.ts +100 -0
- package/dist/cjs/SyncPrincipal.d.ts.map +1 -0
- package/dist/cjs/SyncPrincipal.js +48 -0
- package/dist/cjs/SyncPrincipal.js.map +7 -0
- package/dist/cjs/SyncProtocol.d.ts +592 -0
- package/dist/cjs/SyncProtocol.d.ts.map +1 -0
- package/dist/cjs/SyncProtocol.js +165 -0
- package/dist/cjs/SyncProtocol.js.map +7 -0
- package/dist/cjs/SyncRpcs.d.ts +92 -0
- package/dist/cjs/SyncRpcs.d.ts.map +1 -0
- package/dist/cjs/SyncRpcs.js +35 -0
- package/dist/cjs/SyncRpcs.js.map +7 -0
- package/dist/cjs/SyncServer.d.ts +184 -0
- package/dist/cjs/SyncServer.d.ts.map +1 -0
- package/dist/cjs/SyncServer.js +513 -0
- package/dist/cjs/SyncServer.js.map +7 -0
- package/dist/cjs/WorkspaceShare.d.ts +174 -0
- package/dist/cjs/WorkspaceShare.d.ts.map +1 -0
- package/dist/cjs/WorkspaceShare.js +149 -0
- package/dist/cjs/WorkspaceShare.js.map +7 -0
- package/dist/cjs/index.d.ts +91 -0
- package/dist/cjs/index.d.ts.map +1 -0
- package/dist/cjs/index.js +67 -0
- package/dist/cjs/index.js.map +7 -0
- package/dist/cjs/internal/Admission.d.ts +43 -0
- package/dist/cjs/internal/Admission.d.ts.map +1 -0
- package/dist/cjs/internal/Admission.js +90 -0
- package/dist/cjs/internal/Admission.js.map +7 -0
- package/dist/cjs/internal/CauseText.d.ts +55 -0
- package/dist/cjs/internal/CauseText.d.ts.map +1 -0
- package/dist/cjs/internal/CauseText.js +38 -0
- package/dist/cjs/internal/CauseText.js.map +7 -0
- package/dist/cjs/internal/JournalErrorCode.d.ts +23 -0
- package/dist/cjs/internal/JournalErrorCode.d.ts.map +1 -0
- package/dist/cjs/internal/JournalErrorCode.js +34 -0
- package/dist/cjs/internal/JournalErrorCode.js.map +7 -0
- package/dist/cjs/internal/PolicyOptions.d.ts +57 -0
- package/dist/cjs/internal/PolicyOptions.d.ts.map +1 -0
- package/dist/cjs/internal/PolicyOptions.js +62 -0
- package/dist/cjs/internal/PolicyOptions.js.map +7 -0
- package/dist/cjs/internal/ProtocolVersion.d.ts +17 -0
- package/dist/cjs/internal/ProtocolVersion.d.ts.map +1 -0
- package/dist/cjs/internal/ProtocolVersion.js +44 -0
- package/dist/cjs/internal/ProtocolVersion.js.map +7 -0
- package/dist/cjs/internal/ShareSigner.d.ts +173 -0
- package/dist/cjs/internal/ShareSigner.d.ts.map +1 -0
- package/dist/cjs/internal/ShareSigner.js +150 -0
- package/dist/cjs/internal/ShareSigner.js.map +7 -0
- package/dist/cjs/internal/SnapshotBoundary.d.ts +17 -0
- package/dist/cjs/internal/SnapshotBoundary.d.ts.map +1 -0
- package/dist/cjs/internal/SnapshotBoundary.js +97 -0
- package/dist/cjs/internal/SnapshotBoundary.js.map +7 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/test/TestSocket.d.ts +55 -0
- package/dist/cjs/test/TestSocket.d.ts.map +1 -0
- package/dist/cjs/test/TestSocket.js +145 -0
- package/dist/cjs/test/TestSocket.js.map +7 -0
- package/dist/cjs/test/TestSync.d.ts +54 -0
- package/dist/cjs/test/TestSync.d.ts.map +1 -0
- package/dist/cjs/test/TestSync.js +96 -0
- package/dist/cjs/test/TestSync.js.map +7 -0
- package/dist/esm/BranchCommands.d.ts +228 -0
- package/dist/esm/BranchCommands.d.ts.map +1 -0
- package/dist/esm/BranchCommands.js +496 -0
- package/dist/esm/BranchCommands.js.map +1 -0
- package/dist/esm/BranchIds.d.ts +65 -0
- package/dist/esm/BranchIds.d.ts.map +1 -0
- package/dist/esm/BranchIds.js +61 -0
- package/dist/esm/BranchIds.js.map +1 -0
- package/dist/esm/BranchPresence.d.ts +195 -0
- package/dist/esm/BranchPresence.d.ts.map +1 -0
- package/dist/esm/BranchPresence.js +361 -0
- package/dist/esm/BranchPresence.js.map +1 -0
- package/dist/esm/BranchProjection.d.ts +179 -0
- package/dist/esm/BranchProjection.d.ts.map +1 -0
- package/dist/esm/BranchProjection.js +269 -0
- package/dist/esm/BranchProjection.js.map +1 -0
- package/dist/esm/BranchProtocol.d.ts +415 -0
- package/dist/esm/BranchProtocol.d.ts.map +1 -0
- package/dist/esm/BranchProtocol.js +323 -0
- package/dist/esm/BranchProtocol.js.map +1 -0
- package/dist/esm/BranchRpcs.d.ts +177 -0
- package/dist/esm/BranchRpcs.d.ts.map +1 -0
- package/dist/esm/BranchRpcs.js +103 -0
- package/dist/esm/BranchRpcs.js.map +1 -0
- package/dist/esm/BranchServer.d.ts +55 -0
- package/dist/esm/BranchServer.d.ts.map +1 -0
- package/dist/esm/BranchServer.js +168 -0
- package/dist/esm/BranchServer.js.map +1 -0
- package/dist/esm/BranchShare.d.ts +159 -0
- package/dist/esm/BranchShare.d.ts.map +1 -0
- package/dist/esm/BranchShare.js +213 -0
- package/dist/esm/BranchShare.js.map +1 -0
- package/dist/esm/RunCatalog.d.ts +207 -0
- package/dist/esm/RunCatalog.d.ts.map +1 -0
- package/dist/esm/RunCatalog.js +196 -0
- package/dist/esm/RunCatalog.js.map +1 -0
- package/dist/esm/SyncAuth.d.ts +70 -0
- package/dist/esm/SyncAuth.d.ts.map +1 -0
- package/dist/esm/SyncAuth.js +119 -0
- package/dist/esm/SyncAuth.js.map +1 -0
- package/dist/esm/SyncClient.d.ts +274 -0
- package/dist/esm/SyncClient.d.ts.map +1 -0
- package/dist/esm/SyncClient.js +586 -0
- package/dist/esm/SyncClient.js.map +1 -0
- package/dist/esm/SyncError.d.ts +107 -0
- package/dist/esm/SyncError.d.ts.map +1 -0
- package/dist/esm/SyncError.js +133 -0
- package/dist/esm/SyncError.js.map +1 -0
- package/dist/esm/SyncPrincipal.d.ts +100 -0
- package/dist/esm/SyncPrincipal.d.ts.map +1 -0
- package/dist/esm/SyncPrincipal.js +58 -0
- package/dist/esm/SyncPrincipal.js.map +1 -0
- package/dist/esm/SyncProtocol.d.ts +592 -0
- package/dist/esm/SyncProtocol.d.ts.map +1 -0
- package/dist/esm/SyncProtocol.js +434 -0
- package/dist/esm/SyncProtocol.js.map +1 -0
- package/dist/esm/SyncRpcs.d.ts +92 -0
- package/dist/esm/SyncRpcs.d.ts.map +1 -0
- package/dist/esm/SyncRpcs.js +35 -0
- package/dist/esm/SyncRpcs.js.map +1 -0
- package/dist/esm/SyncServer.d.ts +184 -0
- package/dist/esm/SyncServer.d.ts.map +1 -0
- package/dist/esm/SyncServer.js +897 -0
- package/dist/esm/SyncServer.js.map +1 -0
- package/dist/esm/WorkspaceShare.d.ts +174 -0
- package/dist/esm/WorkspaceShare.d.ts.map +1 -0
- package/dist/esm/WorkspaceShare.js +208 -0
- package/dist/esm/WorkspaceShare.js.map +1 -0
- package/dist/esm/index.d.ts +91 -0
- package/dist/esm/index.d.ts.map +1 -0
- package/dist/esm/index.js +91 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/internal/Admission.d.ts +43 -0
- package/dist/esm/internal/Admission.d.ts.map +1 -0
- package/dist/esm/internal/Admission.js +80 -0
- package/dist/esm/internal/Admission.js.map +1 -0
- package/dist/esm/internal/CauseText.d.ts +55 -0
- package/dist/esm/internal/CauseText.d.ts.map +1 -0
- package/dist/esm/internal/CauseText.js +67 -0
- package/dist/esm/internal/CauseText.js.map +1 -0
- package/dist/esm/internal/JournalErrorCode.d.ts +23 -0
- package/dist/esm/internal/JournalErrorCode.d.ts.map +1 -0
- package/dist/esm/internal/JournalErrorCode.js +44 -0
- package/dist/esm/internal/JournalErrorCode.js.map +1 -0
- package/dist/esm/internal/PolicyOptions.d.ts +57 -0
- package/dist/esm/internal/PolicyOptions.d.ts.map +1 -0
- package/dist/esm/internal/PolicyOptions.js +70 -0
- package/dist/esm/internal/PolicyOptions.js.map +1 -0
- package/dist/esm/internal/ProtocolVersion.d.ts +17 -0
- package/dist/esm/internal/ProtocolVersion.d.ts.map +1 -0
- package/dist/esm/internal/ProtocolVersion.js +24 -0
- package/dist/esm/internal/ProtocolVersion.js.map +1 -0
- package/dist/esm/internal/ShareSigner.d.ts +173 -0
- package/dist/esm/internal/ShareSigner.d.ts.map +1 -0
- package/dist/esm/internal/ShareSigner.js +219 -0
- package/dist/esm/internal/ShareSigner.js.map +1 -0
- package/dist/esm/internal/SnapshotBoundary.d.ts +17 -0
- package/dist/esm/internal/SnapshotBoundary.d.ts.map +1 -0
- package/dist/esm/internal/SnapshotBoundary.js +68 -0
- package/dist/esm/internal/SnapshotBoundary.js.map +1 -0
- package/dist/esm/test/TestSocket.d.ts +55 -0
- package/dist/esm/test/TestSocket.d.ts.map +1 -0
- package/dist/esm/test/TestSocket.js +128 -0
- package/dist/esm/test/TestSocket.js.map +1 -0
- package/dist/esm/test/TestSync.d.ts +54 -0
- package/dist/esm/test/TestSync.d.ts.map +1 -0
- package/dist/esm/test/TestSync.js +76 -0
- package/dist/esm/test/TestSync.js.map +1 -0
- package/docs/README.md +156 -0
- package/docs/api.md +328 -0
- package/docs/concepts/authorization.md +117 -0
- package/docs/concepts/branches.md +195 -0
- package/docs/concepts/compaction.md +144 -0
- package/docs/concepts/replay-then-follow.md +129 -0
- package/docs/concepts/scopes-and-cursors.md +135 -0
- package/docs/guides/authorize-a-connection.md +174 -0
- package/docs/guides/follow-a-run.md +174 -0
- package/docs/guides/handle-a-compacted-run.md +158 -0
- package/docs/guides/list-workspace-runs.md +120 -0
- package/docs/guides/run-a-long-soak.md +80 -0
- package/docs/guides/serve-the-read-path.md +146 -0
- package/docs/guides/test-a-follower.md +273 -0
- package/docs/installation.md +92 -0
- package/docs/protocol.md +183 -0
- package/docs/quickstart.md +159 -0
- package/docs/troubleshooting.md +234 -0
- package/package.json +304 -3
- package/src/BranchCommands.ts +684 -0
- package/src/BranchIds.ts +76 -0
- package/src/BranchPresence.ts +512 -0
- package/src/BranchProjection.ts +308 -0
- package/src/BranchProtocol.ts +411 -0
- package/src/BranchRpcs.ts +163 -0
- package/src/BranchServer.ts +202 -0
- package/src/BranchShare.ts +297 -0
- package/src/RunCatalog.ts +308 -0
- package/src/SyncAuth.ts +146 -0
- package/src/SyncClient.ts +902 -0
- package/src/SyncError.ts +147 -0
- package/src/SyncPrincipal.ts +115 -0
- package/src/SyncProtocol.ts +594 -0
- package/src/SyncRpcs.ts +40 -0
- package/src/SyncServer.ts +1184 -0
- package/src/WorkspaceShare.ts +292 -0
- package/src/index.ts +107 -0
- package/src/internal/Admission.ts +119 -0
- package/src/internal/CauseText.ts +74 -0
- package/src/internal/JournalErrorCode.ts +46 -0
- package/src/internal/PolicyOptions.ts +97 -0
- package/src/internal/ProtocolVersion.ts +28 -0
- package/src/internal/ShareSigner.ts +300 -0
- package/src/internal/SnapshotBoundary.ts +91 -0
- package/src/test/TestSocket.ts +192 -0
- package/src/test/TestSync.ts +113 -0
|
@@ -0,0 +1,897 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The workspace-side implementation of the sync read path.
|
|
3
|
+
*
|
|
4
|
+
* @since 0.1.0
|
|
5
|
+
*/
|
|
6
|
+
import { Journal } from "@smthrs/journal";
|
|
7
|
+
import * as Cause from "effect/Cause";
|
|
8
|
+
import * as Clock from "effect/Clock";
|
|
9
|
+
import * as Context from "effect/Context";
|
|
10
|
+
import * as Effect from "effect/Effect";
|
|
11
|
+
import * as Layer from "effect/Layer";
|
|
12
|
+
import * as Option from "effect/Option";
|
|
13
|
+
import * as PubSub from "effect/PubSub";
|
|
14
|
+
import * as Stream from "effect/Stream";
|
|
15
|
+
import { branchOfRunId } from "./BranchProtocol.js";
|
|
16
|
+
import * as BranchShare from "./BranchShare.js";
|
|
17
|
+
import * as Admission from "./internal/Admission.js";
|
|
18
|
+
import { causeCode } from "./internal/CauseText.js";
|
|
19
|
+
import { journalErrorCode } from "./internal/JournalErrorCode.js";
|
|
20
|
+
import { positiveInt, requestCount } from "./internal/PolicyOptions.js";
|
|
21
|
+
import { requireVersion } from "./internal/ProtocolVersion.js";
|
|
22
|
+
import * as SnapshotBoundary from "./internal/SnapshotBoundary.js";
|
|
23
|
+
import * as RunCatalog from "./RunCatalog.js";
|
|
24
|
+
import { SyncError } from "./SyncError.js";
|
|
25
|
+
import * as SyncPrincipal from "./SyncPrincipal.js";
|
|
26
|
+
import * as SyncProtocol from "./SyncProtocol.js";
|
|
27
|
+
import { SyncRpcs } from "./SyncRpcs.js";
|
|
28
|
+
/**
|
|
29
|
+
* The workspace sync server.
|
|
30
|
+
*
|
|
31
|
+
* @category services
|
|
32
|
+
* @since 0.1.0
|
|
33
|
+
*/
|
|
34
|
+
export class SyncServer extends Context.Service()("@smthrs/sync/SyncServer") {
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Explicit host opt-in for public projection snapshots. Never bind this to raw
|
|
38
|
+
* Journal.latestCheckpoint: executable checkpoints are intentionally unredacted.
|
|
39
|
+
* Providers must select the requested lineage/projection/version, retain a
|
|
40
|
+
* snapshot covering compaction, and return a complete state through its sequence.
|
|
41
|
+
* Run/branch read authorization occurs before this callback. Its output must be
|
|
42
|
+
* safe for every reader authorized for that run; narrower data needs a separate
|
|
43
|
+
* authorization boundary. Missing providers fail closed.
|
|
44
|
+
* @category services
|
|
45
|
+
* @since 1.0.0-rc.0
|
|
46
|
+
*/
|
|
47
|
+
export class SnapshotSource extends Context.Service()("@smthrs/sync/SnapshotSource") {
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Constructs a closed sync server stub.
|
|
51
|
+
*
|
|
52
|
+
* @category constructors
|
|
53
|
+
* @since 0.1.0
|
|
54
|
+
*/
|
|
55
|
+
export const makeNoop = (overrides = {}) => SyncServer.of({
|
|
56
|
+
snapshot: () => Effect.fail(new SyncError({ code: "not_found", message: "Public snapshots are unavailable" })),
|
|
57
|
+
read: Effect.fn("SyncServer.read")(() => Effect.succeed({ entries: [], cursors: [], done: true })),
|
|
58
|
+
subscribe: () => Stream.succeed({ _tag: "Closed", reason: "Sync server is unavailable" }),
|
|
59
|
+
...overrides
|
|
60
|
+
});
|
|
61
|
+
/**
|
|
62
|
+
* Provides a closed sync server stub.
|
|
63
|
+
*
|
|
64
|
+
* @category layers
|
|
65
|
+
* @since 0.1.0
|
|
66
|
+
*/
|
|
67
|
+
export const layerNoop = Layer.succeed(SyncServer, makeNoop());
|
|
68
|
+
/**
|
|
69
|
+
* Projects a journal failure onto the sync boundary.
|
|
70
|
+
*
|
|
71
|
+
* `compacted` is the one journal code that survives the crossing with its own
|
|
72
|
+
* identity. Every other failure is a fault the follower can only report, but a
|
|
73
|
+
* compacted read is a refusal with a documented resume point — the journal
|
|
74
|
+
* hands back the run's compaction floor for exactly that — and flattening it
|
|
75
|
+
* into `unknown` threw the floor away. The client retries only
|
|
76
|
+
* `transport_failed`, so the subscription died and every resubscribe from the
|
|
77
|
+
* same cursors died identically; because a whole-workspace subscription merges
|
|
78
|
+
* per-run reads, one compacted run took down the workspace.
|
|
79
|
+
*
|
|
80
|
+
* The run id comes from the call site rather than the error because the
|
|
81
|
+
* journal error carries only the sequence, and a workspace read fans out over
|
|
82
|
+
* many runs: without it a follower could not tell which cursor to move. The
|
|
83
|
+
* generation comes from the read's own fence for the same reason: a fresh
|
|
84
|
+
* follower recovering a rewound run has no other way to learn it.
|
|
85
|
+
*/
|
|
86
|
+
const journalFailure = (runId, generation) => (cause) => cause instanceof Journal.JournalError && cause.code === "compacted" && cause.checkpointSeq !== undefined
|
|
87
|
+
? new SyncError({
|
|
88
|
+
code: "compacted",
|
|
89
|
+
message: `Run ${runId} is compacted above the requested cursor`,
|
|
90
|
+
cause: causeCode(cause),
|
|
91
|
+
resync: { runId, checkpointSeq: cause.checkpointSeq, ...(generation === undefined ? {} : { generation }) }
|
|
92
|
+
})
|
|
93
|
+
: new SyncError({
|
|
94
|
+
// Constant message, rendered cause. A follower may hold nothing but a
|
|
95
|
+
// branch share link, and the journal's message is the SQLite driver's:
|
|
96
|
+
// it routinely carries SQL text, table and column names, and constraint
|
|
97
|
+
// identifiers. `decodeCapability` already refuses to be a parsing
|
|
98
|
+
// oracle; this is the same rule for the read path. The CODE is a
|
|
99
|
+
// different question from the message: a journal code this boundary
|
|
100
|
+
// also declares crosses as that code, so a shut-down journal is not
|
|
101
|
+
// reported as an unexplained fault.
|
|
102
|
+
code: journalErrorCode(cause),
|
|
103
|
+
message: `Journal read failed for run ${runId}`,
|
|
104
|
+
cause: causeCode(cause)
|
|
105
|
+
});
|
|
106
|
+
/** The one refusal a lapsed credential produces, wherever it is noticed. */
|
|
107
|
+
const expired = new SyncError({
|
|
108
|
+
code: "unauthorized",
|
|
109
|
+
message: "The capability authorizing this subscription has expired"
|
|
110
|
+
});
|
|
111
|
+
const cursorOf = (cursors, runId) => cursors.find((cursor) => cursor.runId === runId)?.afterSeq;
|
|
112
|
+
/**
|
|
113
|
+
* The one shape a live path emits: one entry, with the gap it closes.
|
|
114
|
+
*
|
|
115
|
+
* Every subscription frame carries exactly one entry, so `fromSeq` is not the
|
|
116
|
+
* entry's own sequence — it is one past the last sequence this run SERVED,
|
|
117
|
+
* which is how a follower learns that the journal skipped compacted or
|
|
118
|
+
* filtered sequences instead of losing them. The run-scoped stream and the
|
|
119
|
+
* workspace tail both emit it, so both build it here.
|
|
120
|
+
*/
|
|
121
|
+
const frameOf = (runId, generation, previous, entry) => ({
|
|
122
|
+
_tag: "Entries",
|
|
123
|
+
runId,
|
|
124
|
+
generation,
|
|
125
|
+
fromSeq: (previous + 1),
|
|
126
|
+
toSeq: entry.seq,
|
|
127
|
+
entries: [entry]
|
|
128
|
+
});
|
|
129
|
+
/**
|
|
130
|
+
* Largest number of journal reads one workspace subscription keeps open at
|
|
131
|
+
* once.
|
|
132
|
+
*
|
|
133
|
+
* A workspace subscription serves every run it covers, and every open read is
|
|
134
|
+
* a cursor plus a page buffer. Without a bound the cost of one follower is the
|
|
135
|
+
* size of the workspace: a thousand-run workspace opened a thousand journal
|
|
136
|
+
* streams the moment a subscription started, whether or not the follower ever
|
|
137
|
+
* read from them, and a follower that stalled held all of them. Sixty-four
|
|
138
|
+
* bounds what one follower costs without bounding what it sees: the workspace
|
|
139
|
+
* tail visits every covered run each round, so a run past the bound waits for
|
|
140
|
+
* a slot for the length of one round rather than being starved.
|
|
141
|
+
*
|
|
142
|
+
* @category constants
|
|
143
|
+
* @since 0.1.0
|
|
144
|
+
*/
|
|
145
|
+
export const defaultConcurrency = 64;
|
|
146
|
+
/**
|
|
147
|
+
* How long a workspace subscription waits before re-reading the runs it
|
|
148
|
+
* covers, when nothing announces a change, in milliseconds.
|
|
149
|
+
*
|
|
150
|
+
* A workspace tail is a fan-in over many runs, and the runs another engine
|
|
151
|
+
* process owns reach this process only through the database. rc.0 has no
|
|
152
|
+
* cross-process wake, so the interval is the whole of the freshness policy for
|
|
153
|
+
* them: a follower sees an entry within one interval of it being durable. A
|
|
154
|
+
* second matches the rest of the rc.0 posture — the heartbeat sweep, the
|
|
155
|
+
* cancel poll, and {@link RunCatalog.defaultPollIntervalMs}.
|
|
156
|
+
*
|
|
157
|
+
* It is not what an in-process append waits for. An entry this process commits
|
|
158
|
+
* is published on `Journal.changes`, and a workspace subscription that covers
|
|
159
|
+
* its run wakes the round on it, so a follower beside the writer sees it at
|
|
160
|
+
* once rather than at the next tick. A run-scoped subscription is likewise
|
|
161
|
+
* unaffected: it follows one journal stream directly and keeps that stream's
|
|
162
|
+
* wake.
|
|
163
|
+
*
|
|
164
|
+
* @category constants
|
|
165
|
+
* @since 0.1.0
|
|
166
|
+
*/
|
|
167
|
+
export const defaultTailIntervalMs = 1000;
|
|
168
|
+
/**
|
|
169
|
+
* Entries one workspace tail read pulls per run per round. A round reads at
|
|
170
|
+
* most `concurrency` pages at once, so this is the page half of the memory one
|
|
171
|
+
* follower costs; a run with more unserved entries than this wakes the next
|
|
172
|
+
* round and is served by it, rather than holding its slot until it catches up.
|
|
173
|
+
*/
|
|
174
|
+
const tailBatchSize = 256;
|
|
175
|
+
const defaults = {
|
|
176
|
+
maxFrameBytes: SyncProtocol.defaultMaxFrameBytes,
|
|
177
|
+
concurrency: defaultConcurrency,
|
|
178
|
+
tailIntervalMs: defaultTailIntervalMs
|
|
179
|
+
};
|
|
180
|
+
/**
|
|
181
|
+
* The read path over an already-validated policy.
|
|
182
|
+
*
|
|
183
|
+
* A read SHARES its page across the runs it covers and stops at the first of
|
|
184
|
+
* three bounds: the request's `limit`, the frame ceiling, or the durable tail
|
|
185
|
+
* of every covered run, which is the only case that reports `done: true`.
|
|
186
|
+
* Every covered run takes a share of the budget before any run takes a second
|
|
187
|
+
* helping, and what the shares leave unspent is offered back in run order, so
|
|
188
|
+
* a run with a backlog still takes the larger part of a page but never all of
|
|
189
|
+
* it. Filling in run order instead let a producer that stayed one page ahead
|
|
190
|
+
* take every slot of every page: `done` never became true, so a bootstrapping
|
|
191
|
+
* follower never reached the runs behind it and never reached the live follow
|
|
192
|
+
* either. The frame ceiling is a page budget, not a verdict on the read:
|
|
193
|
+
* entries are served until the next one would cross it, and the page then
|
|
194
|
+
* reports `done: false` so the follower asks for the rest. Only a SINGLE
|
|
195
|
+
* entry whose own encoded size exceeds the ceiling is refused with
|
|
196
|
+
* `frame_too_large`, because no page can ever carry it.
|
|
197
|
+
*
|
|
198
|
+
* A workspace subscription's fan-out is bounded without bounding what it
|
|
199
|
+
* serves. Each round reconciles the runs it covers against `RunCatalog.list`,
|
|
200
|
+
* then reads ONE bounded page per run, at most `Options.concurrency` reads
|
|
201
|
+
* open at once. A run with more waiting wakes the next round rather than
|
|
202
|
+
* holding its slot, so no run is starved by a run that stays busy. A round
|
|
203
|
+
* repeats on a journal entry committed in this process, on a catalog
|
|
204
|
+
* announcement, on a run that reported more, or after
|
|
205
|
+
* `Options.tailIntervalMs`. A wake for an entry committed in this process
|
|
206
|
+
* reads only the runs that entry named; a round that re-lists the catalog
|
|
207
|
+
* reads every covered run, so the bound caps what is open at once while a
|
|
208
|
+
* round's work and the positions a follower keeps grow with the runs it
|
|
209
|
+
* covers. The interval is the freshness policy for the runs another process
|
|
210
|
+
* owns, not for the runs written beside the follower. A run-scoped
|
|
211
|
+
* subscription follows that one run's journal stream directly and keeps its
|
|
212
|
+
* in-process wake.
|
|
213
|
+
*
|
|
214
|
+
* Authorization is fail-closed along two boundaries, both consulted per
|
|
215
|
+
* request:
|
|
216
|
+
*
|
|
217
|
+
* - Branch runs: a run whose id maps to a shared branch is visible only when
|
|
218
|
+
* the request's share capability verifies for that branch. An explicitly
|
|
219
|
+
* scoped branch read without one fails; a workspace listing excludes branch
|
|
220
|
+
* runs the caller's capability does not cover, so one share link never
|
|
221
|
+
* leaks another branch's log. Without a {@link BranchShare} in scope every
|
|
222
|
+
* branch run is closed.
|
|
223
|
+
* - Non-branch runs: visible only to the workspace principal
|
|
224
|
+
* (`SyncPrincipal`), whose default is anonymous. A workspace-scoped
|
|
225
|
+
* request and a run-scoped request for a non-branch run are both refused
|
|
226
|
+
* for anonymous callers, so a connection with no credential — or with only
|
|
227
|
+
* a branch share link — can never read engine runs.
|
|
228
|
+
*
|
|
229
|
+
* A SUBSCRIPTION is additionally bounded in time: it ends with `unauthorized`
|
|
230
|
+
* when the credential that opened it expires, because a stream authorized
|
|
231
|
+
* once at open is otherwise the one thing a signed expiry cannot revoke.
|
|
232
|
+
*/
|
|
233
|
+
const makeWith = ({ concurrency, maxFrameBytes, tailIntervalMs }) => Effect.gen(function* () {
|
|
234
|
+
const journal = yield* Journal.Journal;
|
|
235
|
+
const catalog = yield* RunCatalog.RunCatalog;
|
|
236
|
+
const share = yield* Effect.serviceOption(BranchShare.BranchShare);
|
|
237
|
+
const snapshots = yield* Effect.serviceOption(SnapshotSource);
|
|
238
|
+
/**
|
|
239
|
+
* Refuses ONE entry whose own encoded size outgrows the ceiling.
|
|
240
|
+
*
|
|
241
|
+
* This is the only shape the ceiling refuses. A single entry above it can
|
|
242
|
+
* never be served in any page, so paging around it would spin; a page that
|
|
243
|
+
* merely SUMS past it is ordinary traffic and is truncated instead (see
|
|
244
|
+
* {@link read}).
|
|
245
|
+
*/
|
|
246
|
+
const frameTooLarge = (bytes) => Effect.fail(new SyncError({
|
|
247
|
+
code: "frame_too_large",
|
|
248
|
+
message: `Encoded entries of ${bytes} bytes exceed the ${maxFrameBytes}-byte frame ceiling`
|
|
249
|
+
}));
|
|
250
|
+
/**
|
|
251
|
+
* Chunk-level admission for the live path. Each subscription frame
|
|
252
|
+
* carries one entry, so the per-frame ceiling is a per-entry ceiling —
|
|
253
|
+
* but the check runs once per journal chunk, not once per entry, because
|
|
254
|
+
* a per-element effect hop de-chunks the stream and multiplies the cost
|
|
255
|
+
* of every downstream combinator on the hot follow path.
|
|
256
|
+
*/
|
|
257
|
+
const guardEntryChunk = (chunk) => {
|
|
258
|
+
for (const entry of chunk) {
|
|
259
|
+
const bytes = SyncProtocol.encodedByteLength(entry);
|
|
260
|
+
if (bytes > maxFrameBytes)
|
|
261
|
+
return frameTooLarge(bytes);
|
|
262
|
+
}
|
|
263
|
+
return Effect.void;
|
|
264
|
+
};
|
|
265
|
+
/**
|
|
266
|
+
* The catalog's run set: deduplicated, then ordered.
|
|
267
|
+
*
|
|
268
|
+
* `RunCatalog` is a host seam whose `list` is typed `ReadonlyArray<RunId>`,
|
|
269
|
+
* and an array is not a set. Both fan-out paths key a run's served
|
|
270
|
+
* position by run id, so a run named twice is read twice from the same
|
|
271
|
+
* position and its entries are served twice — inside one read page, and as
|
|
272
|
+
* two concurrent tails emitting identical frames. This is the one place
|
|
273
|
+
* that can rule it out for every path below, so it does.
|
|
274
|
+
*/
|
|
275
|
+
const covered = Effect.map(catalog.list, (ids) => Array.from(new Set(ids)).sort());
|
|
276
|
+
/**
|
|
277
|
+
* The workspace principal's expiry, or a refusal.
|
|
278
|
+
*
|
|
279
|
+
* It answers with the expiry rather than with nothing because the two
|
|
280
|
+
* questions have one answer: a request that may read the workspace may
|
|
281
|
+
* read it until its credential stops authorizing. An in-process owner
|
|
282
|
+
* presented no credential and answers `Infinity`.
|
|
283
|
+
*/
|
|
284
|
+
const requireWorkspace = Effect.flatMap(Effect.service(SyncPrincipal.SyncPrincipal), (principal) => SyncPrincipal.isWorkspace(principal) ? Effect.succeed(principal.expiresAtMs) : Effect.fail(new SyncError({
|
|
285
|
+
code: "unauthorized",
|
|
286
|
+
message: "Reading workspace runs requires an authenticated workspace principal"
|
|
287
|
+
})));
|
|
288
|
+
/**
|
|
289
|
+
* The verified claims a branch read is granted by, or `null` when the
|
|
290
|
+
* request holds no capability for that branch. Only an `unauthorized`
|
|
291
|
+
* refusal folds to `null`; an infrastructure fault (Web Crypto rejecting
|
|
292
|
+
* the HMAC) propagates, so "the signer is broken" is never reported as
|
|
293
|
+
* "not authorized".
|
|
294
|
+
*
|
|
295
|
+
* The CLAIMS are returned, not a boolean: `expiresAtMs` is what bounds a
|
|
296
|
+
* subscription opened against this branch, and reducing the answer to
|
|
297
|
+
* "yes" threw it away.
|
|
298
|
+
*/
|
|
299
|
+
const branchClaims = (branchId, capability) => Option.isNone(share) || capability === undefined
|
|
300
|
+
? Effect.succeed(null)
|
|
301
|
+
: share.value.verify(capability, { branchId, access: "read" }).pipe(Effect.map((claims) => claims), Effect.catch((error) => error.code === "unauthorized" ? Effect.succeed(null) : Effect.fail(error)));
|
|
302
|
+
/**
|
|
303
|
+
* When this request stops being allowed to follow one catalog-advertised
|
|
304
|
+
* run, or `null` when it may not follow it at all.
|
|
305
|
+
*
|
|
306
|
+
* The answer is the EXPIRY, not a yes. A workspace subscription discovers
|
|
307
|
+
* runs after it opens, and a branch run discovered then is admitted under
|
|
308
|
+
* a capability whose expiry was not part of the subscription's deadline:
|
|
309
|
+
* reducing this to a boolean let a branch found by reconciliation stream
|
|
310
|
+
* for as long as the subscription lived, which is exactly the revocation
|
|
311
|
+
* hole the deadline exists to close. A non-branch run answers `Infinity`
|
|
312
|
+
* because the workspace principal's own expiry already bounds the
|
|
313
|
+
* subscription.
|
|
314
|
+
*/
|
|
315
|
+
const followUntil = (runId, capability) => {
|
|
316
|
+
const branchId = branchOfRunId(runId);
|
|
317
|
+
return branchId === null
|
|
318
|
+
// A NON-branch run is admitted by the workspace principal, and only a
|
|
319
|
+
// workspace-scoped subscription reaches here: `runIdsFor` refused the
|
|
320
|
+
// request outright if the principal was anything else, and a
|
|
321
|
+
// principal is fixed for the life of a request. Its expiry is already
|
|
322
|
+
// the deadline this subscription opened with.
|
|
323
|
+
? Effect.succeed(Number.POSITIVE_INFINITY)
|
|
324
|
+
: Effect.map(branchClaims(branchId, capability), (claims) => claims === null ? null : claims.expiresAtMs);
|
|
325
|
+
};
|
|
326
|
+
/**
|
|
327
|
+
* The runs a request may observe, and when the credential that admits them
|
|
328
|
+
* stops doing so. A run-scoped request the caller is not authorized for
|
|
329
|
+
* fails outright — a scoped read must never silently answer with an empty
|
|
330
|
+
* or partial view — and a workspace-scoped request is only answered for
|
|
331
|
+
* the workspace principal at all.
|
|
332
|
+
*/
|
|
333
|
+
const runIdsFor = (scope, capability) => scope._tag === "Run"
|
|
334
|
+
? Effect.gen(function* () {
|
|
335
|
+
const branchId = branchOfRunId(scope.runId);
|
|
336
|
+
if (branchId === null) {
|
|
337
|
+
return { runIds: [scope.runId], expiresAtMs: yield* requireWorkspace };
|
|
338
|
+
}
|
|
339
|
+
const claims = yield* branchClaims(branchId, capability);
|
|
340
|
+
if (claims === null) {
|
|
341
|
+
return yield* Effect.fail(new SyncError({
|
|
342
|
+
code: "unauthorized",
|
|
343
|
+
message: "Reading a shared branch requires a valid share capability"
|
|
344
|
+
}));
|
|
345
|
+
}
|
|
346
|
+
return { runIds: [scope.runId], expiresAtMs: claims.expiresAtMs };
|
|
347
|
+
})
|
|
348
|
+
: Effect.gen(function* () {
|
|
349
|
+
// The soonest expiry of every credential this view rests on: the
|
|
350
|
+
// workspace principal's, plus each branch capability that admitted
|
|
351
|
+
// a branch run into it.
|
|
352
|
+
let expiresAtMs = yield* requireWorkspace;
|
|
353
|
+
const runIds = yield* covered;
|
|
354
|
+
const visible = [];
|
|
355
|
+
for (const runId of runIds) {
|
|
356
|
+
const branchId = branchOfRunId(runId);
|
|
357
|
+
if (branchId === null) {
|
|
358
|
+
visible.push(runId);
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
const claims = yield* branchClaims(branchId, capability);
|
|
362
|
+
if (claims === null)
|
|
363
|
+
continue;
|
|
364
|
+
expiresAtMs = Math.min(expiresAtMs, claims.expiresAtMs);
|
|
365
|
+
visible.push(runId);
|
|
366
|
+
}
|
|
367
|
+
return { runIds: visible, expiresAtMs };
|
|
368
|
+
});
|
|
369
|
+
/**
|
|
370
|
+
* Refuses a cursor set that names one run twice.
|
|
371
|
+
*
|
|
372
|
+
* The read position came from the FIRST occurrence and the echoed response
|
|
373
|
+
* state from the LAST, so a request carrying `[{r,0},{r,2}]` read after 0
|
|
374
|
+
* and answered with 2: a follower that persisted the returned cursors, as
|
|
375
|
+
* the protocol tells it to, skipped entries the page never carried. There
|
|
376
|
+
* is no correct choice between the two readings, so neither is made.
|
|
377
|
+
*/
|
|
378
|
+
const requireUniqueCursors = (cursors) => {
|
|
379
|
+
const duplicate = SyncProtocol.duplicateCursorRunId(cursors);
|
|
380
|
+
return duplicate === undefined ? Effect.void : Effect.fail(new SyncError({
|
|
381
|
+
code: "invalid_request",
|
|
382
|
+
message: `Cursors name run ${duplicate} more than once`
|
|
383
|
+
}));
|
|
384
|
+
};
|
|
385
|
+
// Compare around every durable read as well as while idle: a rewind can
|
|
386
|
+
// happen in another process and can reuse every sequence in the next page.
|
|
387
|
+
const generationOf = (runId, expected) => Effect.gen(function* () {
|
|
388
|
+
const current = journal.generation === undefined
|
|
389
|
+
? { generation: 0, afterSeq: -1 }
|
|
390
|
+
: yield* journal.generation(runId).pipe(Effect.mapError(journalFailure(runId)));
|
|
391
|
+
if (expected !== undefined && current.generation !== expected) {
|
|
392
|
+
return yield* Effect.fail(new SyncError({
|
|
393
|
+
code: "lineage_changed",
|
|
394
|
+
message: `Run ${runId} was rewound; rebuild from the archive boundary`,
|
|
395
|
+
rewind: { runId, ...current }
|
|
396
|
+
}));
|
|
397
|
+
}
|
|
398
|
+
return current.generation;
|
|
399
|
+
});
|
|
400
|
+
/**
|
|
401
|
+
* One generation-fenced page of one run's unserved entries.
|
|
402
|
+
*
|
|
403
|
+
* This is the whole of the rewind invariant for both paged paths: the
|
|
404
|
+
* generation is compared BEFORE the read, so a cursor from a superseded
|
|
405
|
+
* lineage is refused rather than served, and again AFTER it, so a rewind
|
|
406
|
+
* that lands mid-read cannot deliver the replacement history's sequences
|
|
407
|
+
* as if they continued the old one. The two comparisons only mean
|
|
408
|
+
* anything together, so they live in one place rather than at each call
|
|
409
|
+
* site. The page read and the paged read (see {@link read} and the
|
|
410
|
+
* workspace tail in {@link workspaceStream}) differ in nothing but the
|
|
411
|
+
* page size and where they keep the expected generation.
|
|
412
|
+
*/
|
|
413
|
+
const readPage = (runId, after, expectedGeneration, limit) => Effect.gen(function* () {
|
|
414
|
+
const generation = yield* generationOf(runId, expectedGeneration);
|
|
415
|
+
const page = yield* journal.entries({
|
|
416
|
+
runId,
|
|
417
|
+
...(after === undefined ? {} : { after }),
|
|
418
|
+
limit
|
|
419
|
+
}).pipe(Effect.mapError(journalFailure(runId, generation)));
|
|
420
|
+
yield* generationOf(runId, generation);
|
|
421
|
+
const admitted = yield* Admission.entries(page.entries, runId, after ?? -1);
|
|
422
|
+
return { admitted, generation, hasMore: page.hasMore };
|
|
423
|
+
});
|
|
424
|
+
const snapshot = (input) => Effect.gen(function* () {
|
|
425
|
+
const request = yield* SnapshotBoundary.request(input);
|
|
426
|
+
const { expiresAtMs } = yield* runIdsFor({ _tag: "Run", runId: request.runId }, request.capability);
|
|
427
|
+
if (expiresAtMs <= (yield* Clock.currentTimeMillis))
|
|
428
|
+
return yield* Effect.fail(expired);
|
|
429
|
+
if (Option.isNone(snapshots)) {
|
|
430
|
+
return yield* Effect.fail(new SyncError({ code: "not_found", message: "Public snapshots are unavailable" }));
|
|
431
|
+
}
|
|
432
|
+
const supplied = yield* Effect.suspend(() => snapshots.value.read({ ...request })).pipe(Effect.catchCause((cause) => Cause.hasInterrupts(cause) ? Effect.interrupt : Effect.logWarning("The public snapshot provider failed", cause).pipe(Effect.andThen(Effect.fail(new SyncError({ code: "not_found", message: "Public snapshot is unavailable" }))))));
|
|
433
|
+
// A slow provider may finish after the credential that admitted it expires.
|
|
434
|
+
if (expiresAtMs <= (yield* Clock.currentTimeMillis))
|
|
435
|
+
return yield* Effect.fail(expired);
|
|
436
|
+
return yield* SnapshotBoundary.response(request, supplied, maxFrameBytes);
|
|
437
|
+
});
|
|
438
|
+
const read = (input) => Effect.gen(function* () {
|
|
439
|
+
const request = {
|
|
440
|
+
...input,
|
|
441
|
+
...yield* Admission.decode(SyncProtocol.RequestEnvelope, input, "invalid_request")
|
|
442
|
+
};
|
|
443
|
+
yield* requireVersion(request.protocolVersion);
|
|
444
|
+
yield* requireUniqueCursors(request.cursors);
|
|
445
|
+
// The schema bounds `limit` at the wire; this bounds it again for an
|
|
446
|
+
// in-process caller that constructed the request directly, so no path
|
|
447
|
+
// reaches the journal with an unbounded page size. BOTH halves of that
|
|
448
|
+
// bound are re-applied, not just the ceiling: clamping alone let a
|
|
449
|
+
// `NaN` through — `Math.min` propagates it, `entries.length >= NaN` is
|
|
450
|
+
// false so no loop ever stopped, and the value arrived at
|
|
451
|
+
// `journal.entries` as the page size once per covered run — and let a
|
|
452
|
+
// zero or a negative ask the journal for a page it cannot serve.
|
|
453
|
+
// `subscribe` has floored `credit` since it was written; this is the
|
|
454
|
+
// same floor on the same kind of value.
|
|
455
|
+
const limit = yield* requestCount("A read's limit", request.limit, SyncProtocol.maxReadLimit);
|
|
456
|
+
const { runIds } = yield* runIdsFor(request.scope, request.capability);
|
|
457
|
+
const entries = [];
|
|
458
|
+
const cursors = new Map(request.cursors.map((cursor) => [cursor.runId, cursor.afterSeq]));
|
|
459
|
+
const generations = new Map(request.cursors.map((cursor) => [cursor.runId, cursor.generation ?? 0]));
|
|
460
|
+
let frameBytes = 0;
|
|
461
|
+
// Runs that still had more when their share ran out, in the order the
|
|
462
|
+
// page visited them. They get the budget the page did not spend.
|
|
463
|
+
const behind = [];
|
|
464
|
+
/**
|
|
465
|
+
* Serves at most `cap` of one run's unserved entries into the page.
|
|
466
|
+
*
|
|
467
|
+
* The byte ceiling is a PAGE budget, not a verdict on the read. A run
|
|
468
|
+
* whose next unserved entries sum past 2 MiB is ordinary — branch
|
|
469
|
+
* commands are admitted individually up to 1 MiB, so three
|
|
470
|
+
* 800 KB commands produce it — and failing the read for it wedged the
|
|
471
|
+
* follower forever: `frame_too_large` is neither retried nor
|
|
472
|
+
* retryable, and the client's next bootstrap carries the same cursors
|
|
473
|
+
* and gets the same refusal. So the page stops at the budget and
|
|
474
|
+
* reports `done: false`; only an entry that alone outgrows the
|
|
475
|
+
* ceiling still refuses, because no page can ever carry it.
|
|
476
|
+
*/
|
|
477
|
+
const serve = (runId, cap) => Effect.gen(function* () {
|
|
478
|
+
const after = cursors.get(runId);
|
|
479
|
+
const { admitted, generation, hasMore } = yield* readPage(runId, after, generations.get(runId), cap);
|
|
480
|
+
generations.set(runId, generation);
|
|
481
|
+
for (const accepted of admitted) {
|
|
482
|
+
const bytes = SyncProtocol.encodedByteLength(accepted);
|
|
483
|
+
if (bytes > maxFrameBytes)
|
|
484
|
+
return { _tag: "oversized", bytes };
|
|
485
|
+
if (frameBytes + bytes > maxFrameBytes)
|
|
486
|
+
return { _tag: "truncated" };
|
|
487
|
+
frameBytes += bytes;
|
|
488
|
+
entries.push(accepted);
|
|
489
|
+
// Cursors track what was SERVED, not what was read, so the next
|
|
490
|
+
// page resumes at the first entry this one dropped: no entry is
|
|
491
|
+
// skipped and none is served twice.
|
|
492
|
+
cursors.set(runId, accepted.seq);
|
|
493
|
+
}
|
|
494
|
+
return hasMore ? { _tag: "more" } : { _tag: "drained" };
|
|
495
|
+
});
|
|
496
|
+
/**
|
|
497
|
+
* Serves runs in the given order until one of them ends the page.
|
|
498
|
+
*
|
|
499
|
+
* Returns what ended it, or `undefined` when the pass reached the end
|
|
500
|
+
* of its runs with the page still open. A full page ends the pass but
|
|
501
|
+
* is not an outcome of any one run, so it reads as `undefined` too and
|
|
502
|
+
* is answered by `entries.length` below.
|
|
503
|
+
*
|
|
504
|
+
* `more` collects the runs that still had entries left. It is an
|
|
505
|
+
* argument rather than the return value because the second pass reads
|
|
506
|
+
* the list the first one filled, and appending to the array being
|
|
507
|
+
* iterated would serve a run twice in one page.
|
|
508
|
+
*/
|
|
509
|
+
const pass = (runs, cap, more) => Effect.gen(function* () {
|
|
510
|
+
for (const runId of runs) {
|
|
511
|
+
if (entries.length >= limit)
|
|
512
|
+
return undefined;
|
|
513
|
+
const outcome = yield* serve(runId, cap());
|
|
514
|
+
if (outcome._tag === "more")
|
|
515
|
+
more.push(runId);
|
|
516
|
+
if (outcome._tag === "truncated" || outcome._tag === "oversized")
|
|
517
|
+
return outcome;
|
|
518
|
+
}
|
|
519
|
+
return undefined;
|
|
520
|
+
});
|
|
521
|
+
// Serve the least advanced cursors first, with stable run-id ties.
|
|
522
|
+
// A page may have fewer slots than runs, or exhaust its byte budget
|
|
523
|
+
// mid-share. Restarting in catalog order then starved later runs on
|
|
524
|
+
// EVERY page. Served cursors strictly advance; pending ones keep
|
|
525
|
+
// their priority until reached. Empty runs spend no budget. This is
|
|
526
|
+
// scheduling only, not a comparison of events across runs, and works
|
|
527
|
+
// even when clients reorder cursors or requests hit another server.
|
|
528
|
+
const pending = [...runIds].sort((left, right) => (cursors.get(left) ?? -1) - (cursors.get(right) ?? -1));
|
|
529
|
+
const share = Math.max(1, Math.floor(limit / Math.max(runIds.length, 1)));
|
|
530
|
+
const shares = yield* pass(pending, () => Math.min(share, limit - entries.length), behind);
|
|
531
|
+
// The budget the shares did not spend, offered back in the same order.
|
|
532
|
+
// What this pass leaves behind is discarded: one run reported more, so
|
|
533
|
+
// the page is already unfinished however much of it this pass drains.
|
|
534
|
+
const stop = shares ?? (yield* pass(behind, () => limit - entries.length, []));
|
|
535
|
+
if (stop?._tag === "oversized")
|
|
536
|
+
return yield* frameTooLarge(stop.bytes);
|
|
537
|
+
return {
|
|
538
|
+
entries,
|
|
539
|
+
cursors: Array.from(cursors, ([runId, afterSeq]) => ({
|
|
540
|
+
runId,
|
|
541
|
+
afterSeq,
|
|
542
|
+
generation: generations.get(runId)
|
|
543
|
+
})),
|
|
544
|
+
// A run that reported more keeps the read unfinished even when the
|
|
545
|
+
// second pass then drained it: the page is a snapshot of a journal
|
|
546
|
+
// that is still being written, and only an untruncated pass over
|
|
547
|
+
// runs that all reported drained can promise there is nothing left.
|
|
548
|
+
done: stop === undefined && behind.length === 0 && entries.length < limit
|
|
549
|
+
};
|
|
550
|
+
});
|
|
551
|
+
const runStream = (runId, cursors) => Stream.unwrap(Effect.gen(function* () {
|
|
552
|
+
const supplied = cursors.find((cursor) => cursor.runId === runId);
|
|
553
|
+
const after = supplied?.afterSeq;
|
|
554
|
+
const generation = yield* generationOf(runId, supplied === undefined ? undefined : supplied.generation ?? 0);
|
|
555
|
+
let admitted = after ?? -1;
|
|
556
|
+
const entries = journal.stream({ runId, ...(after === undefined ? {} : { afterSequence: after }) }).pipe(Stream.mapError(journalFailure(runId, generation)), Stream.chunks, Stream.mapEffect((chunk) => Admission.entries(chunk, runId, admitted).pipe(Effect.tap(() => generationOf(runId, generation)), Effect.tap(guardEntryChunk), Effect.map((captured) => {
|
|
557
|
+
for (const entry of captured)
|
|
558
|
+
admitted = entry.seq;
|
|
559
|
+
return captured;
|
|
560
|
+
}))), Stream.flattenArray, Stream.mapAccum(() => after === undefined ? -1 : after, (previous, entry) => [entry.seq, [frameOf(runId, generation, previous, entry)]]));
|
|
561
|
+
if (journal.generation === undefined)
|
|
562
|
+
return entries;
|
|
563
|
+
// Journal.stream can stay silent when its sequence cursor is beyond a
|
|
564
|
+
// rewound head. Poll independently so even an idle follower fails typed.
|
|
565
|
+
const guard = Stream.drain(Stream.fromEffectRepeat(generationOf(runId, generation).pipe(Effect.delay(tailIntervalMs))));
|
|
566
|
+
return Stream.merge(entries, guard, { haltStrategy: "either" });
|
|
567
|
+
}));
|
|
568
|
+
/**
|
|
569
|
+
* The live tail of a whole workspace.
|
|
570
|
+
*
|
|
571
|
+
* A journal run stream is replay-then-follow: it never ends. One such
|
|
572
|
+
* stream per run is therefore not something a bound can be put on and
|
|
573
|
+
* still serve the workspace — a bounded `Stream.flatMap` over streams that
|
|
574
|
+
* never end fills its slots with the first `concurrency` runs and no run
|
|
575
|
+
* behind them is ever attached. The workspace tail inverts that. It reads
|
|
576
|
+
* each covered run's unserved entries as a finite page walk, so the bound
|
|
577
|
+
* limits how many reads are open at once. A catalog-wide round visits
|
|
578
|
+
* every covered run; it runs at open, on a catalog announcement, and once
|
|
579
|
+
* {@link defaultTailIntervalMs} has passed since the last one. A wake for
|
|
580
|
+
* a journal entry committed in this process, or for a run whose page
|
|
581
|
+
* reported more, reads only the runs marked dirty, so a spaced append
|
|
582
|
+
* costs one read and not one per covered run. The bound is what is open
|
|
583
|
+
* at once; a full round's work and the served positions grow with the
|
|
584
|
+
* runs the subscription covers, and freshness of another process's writes
|
|
585
|
+
* is a function of the interval.
|
|
586
|
+
*
|
|
587
|
+
* A run-scoped subscription does not go through here: it follows one
|
|
588
|
+
* journal stream directly (see {@link runStream}) and keeps that stream's
|
|
589
|
+
* in-process wake.
|
|
590
|
+
*/
|
|
591
|
+
const workspaceStream = (covering, openedUntil, request) => Stream.unwrap(Effect.gen(function* () {
|
|
592
|
+
// How far each run this subscription has ever covered was served.
|
|
593
|
+
// A run the catalog stops naming keeps its position here and simply
|
|
594
|
+
// stops being read: dropping the position instead would re-serve the
|
|
595
|
+
// run's history from the request's own cursor if the catalog ever
|
|
596
|
+
// named it again, which is the one thing this stream promises not to
|
|
597
|
+
// do. The map is bounded by the runs one subscription sees, which is
|
|
598
|
+
// what it has always been.
|
|
599
|
+
const served = new Map();
|
|
600
|
+
const generations = new Map(request.cursors.map((cursor) => [cursor.runId, cursor.generation ?? 0]));
|
|
601
|
+
// Runs the catalog names that this request may not read. Remembered so
|
|
602
|
+
// one refusal costs one signature check for the life of the
|
|
603
|
+
// subscription rather than one per round per run.
|
|
604
|
+
const excluded = new Set();
|
|
605
|
+
// The runs the NEXT round reads: the catalog's current list, minus
|
|
606
|
+
// what this request may not read. `reconcile` is what moves it.
|
|
607
|
+
let visible = new Set(covering);
|
|
608
|
+
// The soonest expiry of every credential this subscription now rests
|
|
609
|
+
// on. It only ever moves EARLIER: `runIdsFor` seeds it from the
|
|
610
|
+
// credentials the request opened with, and `reconcile` lowers it when
|
|
611
|
+
// it admits a branch run the catalog named later. Each round arms its
|
|
612
|
+
// own deadline from this, so a capability discovered mid-subscription
|
|
613
|
+
// bounds the frames it authorizes just as the opening ones do.
|
|
614
|
+
let deadline = openedUntil;
|
|
615
|
+
// Runs with something known to be waiting: an entry committed in this
|
|
616
|
+
// process, or a page that reported more. A wake for them reads THESE
|
|
617
|
+
// runs and nothing else; the catalog-wide round is what the opening,
|
|
618
|
+
// an announcement, and the interval pay for. A Set coalesces a burst
|
|
619
|
+
// on one run into one read, and it only ever names covered runs, so
|
|
620
|
+
// it is bounded by what `served` is bounded by.
|
|
621
|
+
let dirty = new Set();
|
|
622
|
+
// Set by an announcement: the next wake re-lists the catalog whatever
|
|
623
|
+
// `dirty` holds, since the announced run is not covered yet.
|
|
624
|
+
let announced = false;
|
|
625
|
+
// When the last catalog-wide round began. A wake that arrives once
|
|
626
|
+
// the interval has elapsed since then runs a full round, so local
|
|
627
|
+
// appends faster than the interval cannot defer reconciliation of
|
|
628
|
+
// removals, generation changes, and other processes' writes forever.
|
|
629
|
+
let reconciledAtMs = -Infinity;
|
|
630
|
+
// Published when `reconcile` lowers `deadline`, so the expiry
|
|
631
|
+
// interrupt below re-arms at the earlier moment.
|
|
632
|
+
const lowered = yield* PubSub.sliding(1);
|
|
633
|
+
const loweredSignal = yield* PubSub.subscribe(lowered);
|
|
634
|
+
// Every caller establishes that the run has no position yet:
|
|
635
|
+
// `covering` is a deduplicated list, and `reconcile` skips what it
|
|
636
|
+
// already holds.
|
|
637
|
+
const cover = (runId) => {
|
|
638
|
+
served.set(runId, cursorOf(request.cursors, runId));
|
|
639
|
+
};
|
|
640
|
+
for (const runId of covering)
|
|
641
|
+
cover(runId);
|
|
642
|
+
// Sliding by one: a wake published while a round is running is
|
|
643
|
+
// remembered, and never more than one round's worth of them.
|
|
644
|
+
const wake = yield* PubSub.sliding(1);
|
|
645
|
+
const woken = yield* PubSub.subscribe(wake);
|
|
646
|
+
/**
|
|
647
|
+
* Re-reads the catalog and decides what the next round covers.
|
|
648
|
+
*
|
|
649
|
+
* Trusting the announcement feed alone broke in both directions.
|
|
650
|
+
* A run created between `runIdsFor`'s list and the moment
|
|
651
|
+
* `catalog.changes` is actually pulled is announced to nobody and
|
|
652
|
+
* absent from the initial list, so it stayed invisible for the life
|
|
653
|
+
* of the subscription; both catalogs publish through a SLIDING
|
|
654
|
+
* PubSub, so a subscriber that fell behind lost an announcement
|
|
655
|
+
* permanently. In the other direction nothing published a removal at
|
|
656
|
+
* all, so a retention-collected run was queried once per interval
|
|
657
|
+
* forever. The README already promised the fix: `list` is the
|
|
658
|
+
* authoritative state and a subscriber that loses a notification
|
|
659
|
+
* re-lists.
|
|
660
|
+
*/
|
|
661
|
+
const reconcile = Effect.gen(function* () {
|
|
662
|
+
// Deduplicated at the seam: a host catalog that names a run twice
|
|
663
|
+
// would otherwise put two tails of it in one round, both reading
|
|
664
|
+
// the same served position and emitting the same frames.
|
|
665
|
+
const present = new Set(yield* catalog.list);
|
|
666
|
+
for (const runId of excluded) {
|
|
667
|
+
if (!present.has(runId))
|
|
668
|
+
excluded.delete(runId);
|
|
669
|
+
}
|
|
670
|
+
const next = [];
|
|
671
|
+
for (const runId of present) {
|
|
672
|
+
if (excluded.has(runId))
|
|
673
|
+
continue;
|
|
674
|
+
if (!served.has(runId)) {
|
|
675
|
+
// An `unauthorized` run is skipped; a signer fault propagates,
|
|
676
|
+
// so "the signer is broken" is never reported as "not
|
|
677
|
+
// authorized".
|
|
678
|
+
const until = yield* followUntil(runId, request.capability);
|
|
679
|
+
if (until === null) {
|
|
680
|
+
excluded.add(runId);
|
|
681
|
+
continue;
|
|
682
|
+
}
|
|
683
|
+
if (until < deadline) {
|
|
684
|
+
deadline = until;
|
|
685
|
+
yield* PubSub.publish(lowered, undefined);
|
|
686
|
+
}
|
|
687
|
+
cover(runId);
|
|
688
|
+
}
|
|
689
|
+
next.push(runId);
|
|
690
|
+
}
|
|
691
|
+
// The deadline is not checked here: the interrupt below enforces
|
|
692
|
+
// it, and re-arms whenever this round lowered it, so a round that
|
|
693
|
+
// begins under a lapsed credential is ended before it emits.
|
|
694
|
+
reconciledAtMs = yield* Clock.currentTimeMillis;
|
|
695
|
+
visible = new Set(next);
|
|
696
|
+
});
|
|
697
|
+
/**
|
|
698
|
+
* One BOUNDED page of one run's unserved entries, as frames.
|
|
699
|
+
*
|
|
700
|
+
* A run with more waiting does not recurse here; it publishes a wake
|
|
701
|
+
* and returns, so its `flatMap` slot is released and the next round
|
|
702
|
+
* picks it up. Recursing held the slot for as long as the run stayed
|
|
703
|
+
* behind, which meant `concurrency` sustained-hot runs pinned every
|
|
704
|
+
* slot, the round never completed, and every run behind them was
|
|
705
|
+
* never attached at all rather than merely delayed.
|
|
706
|
+
*/
|
|
707
|
+
const tail = (runId) => Stream.unwrap(Effect.gen(function* () {
|
|
708
|
+
const after = served.get(runId);
|
|
709
|
+
const { admitted, generation, hasMore } = yield* readPage(runId, after, generations.get(runId), tailBatchSize);
|
|
710
|
+
generations.set(runId, generation);
|
|
711
|
+
yield* guardEntryChunk(admitted);
|
|
712
|
+
const frames = [];
|
|
713
|
+
let previous = after === undefined ? -1 : after;
|
|
714
|
+
for (const accepted of admitted) {
|
|
715
|
+
frames.push(frameOf(runId, generation, previous, accepted));
|
|
716
|
+
previous = accepted.seq;
|
|
717
|
+
}
|
|
718
|
+
const last = admitted.at(-1);
|
|
719
|
+
// An empty page ends the walk whatever the page claims, so a
|
|
720
|
+
// journal that reports more without returning any cannot spin.
|
|
721
|
+
if (last === undefined)
|
|
722
|
+
return Stream.empty;
|
|
723
|
+
served.set(runId, last.seq);
|
|
724
|
+
// A backlog costs another round, not another slot. The wake makes
|
|
725
|
+
// that round start at once rather than at the next interval, and
|
|
726
|
+
// it reads this run and the other dirty ones, not the workspace.
|
|
727
|
+
if (hasMore) {
|
|
728
|
+
dirty.add(runId);
|
|
729
|
+
yield* PubSub.publish(wake, undefined);
|
|
730
|
+
}
|
|
731
|
+
return Stream.fromIterable(frames);
|
|
732
|
+
}));
|
|
733
|
+
/**
|
|
734
|
+
* One pass over every covered run, `concurrency` reads open at most,
|
|
735
|
+
* against the run set the catalog names right now. Anything marked
|
|
736
|
+
* dirty is read by this pass, so the set starts over; a run marked
|
|
737
|
+
* while the pass runs is remembered for the next wake.
|
|
738
|
+
*/
|
|
739
|
+
const fullRound = Stream.unwrap(Effect.map(reconcile, () => {
|
|
740
|
+
dirty = new Set();
|
|
741
|
+
announced = false;
|
|
742
|
+
return Stream.flatMap(Stream.fromIterable(visible), tail, { concurrency });
|
|
743
|
+
}));
|
|
744
|
+
/**
|
|
745
|
+
* One pass over the dirty runs only, under the same bound. No catalog
|
|
746
|
+
* read: a dirty run is by construction one this subscription already
|
|
747
|
+
* covers, and a run the catalog has since dropped is skipped.
|
|
748
|
+
*/
|
|
749
|
+
const dirtyRound = Stream.suspend(() => {
|
|
750
|
+
const runs = dirty;
|
|
751
|
+
dirty = new Set();
|
|
752
|
+
return Stream.flatMap(Stream.fromIterable(Array.from(runs).filter((runId) => visible.has(runId))), tail, { concurrency });
|
|
753
|
+
});
|
|
754
|
+
/** Which pass a tick pays for. */
|
|
755
|
+
const roundFor = (tick) => Effect.map(Clock.currentTimeMillis, (nowMs) => tick === "interval" || announced || nowMs - reconciledAtMs >= tailIntervalMs
|
|
756
|
+
? fullRound
|
|
757
|
+
: dirtyRound);
|
|
758
|
+
// An announcement is a WAKE and nothing else: the round that follows
|
|
759
|
+
// reads the catalog itself and authorizes what it finds. Covering the
|
|
760
|
+
// announced run here as well made the notification feed a second,
|
|
761
|
+
// weaker source of truth — one that could never remove a run and that
|
|
762
|
+
// lost a run permanently whenever its sliding feed overflowed.
|
|
763
|
+
// Draining keeps this a control path that emits nothing.
|
|
764
|
+
const announcements = Stream.drain(Stream.tap(catalog.changes, () => Effect.suspend(() => {
|
|
765
|
+
announced = true;
|
|
766
|
+
return PubSub.publish(wake, undefined);
|
|
767
|
+
})));
|
|
768
|
+
// An entry committed in this process marks its run dirty and wakes
|
|
769
|
+
// the pass that reads it. The interval is the freshness policy for
|
|
770
|
+
// the runs another engine process owns, which reach this one only
|
|
771
|
+
// through the database; it must not also be what a follower waits
|
|
772
|
+
// for an entry written beside it. `Journal.changes` is one
|
|
773
|
+
// process-wide sliding feed of every committed entry, so this costs
|
|
774
|
+
// one subscription per workspace subscription — not one per run — and
|
|
775
|
+
// the fan-out bound is unchanged. A run this subscription does not
|
|
776
|
+
// cover is left to the announcement path, which covers it and wakes
|
|
777
|
+
// the round itself. A wake the feed slides away under a burst costs
|
|
778
|
+
// its entry at most one interval, which is what a write from another
|
|
779
|
+
// process already waits.
|
|
780
|
+
const commits = yield* journal.changes;
|
|
781
|
+
const appends = Stream.fromSubscription(commits).pipe(Stream.filter((entry) => served.has(entry.runId)), Stream.tap((entry) => Effect.suspend(() => {
|
|
782
|
+
dirty.add(entry.runId);
|
|
783
|
+
return PubSub.publish(wake, undefined);
|
|
784
|
+
})), Stream.drain);
|
|
785
|
+
const ticks = Stream.fromEffectRepeat(Effect.raceFirst(Effect.as(PubSub.take(woken), "wake"), Effect.as(Effect.sleep(tailIntervalMs), "interval")));
|
|
786
|
+
// The deadline, enforced as it moves: sleeps until it and starts over
|
|
787
|
+
// whenever `reconcile` lowers it, so a branch admitted after the
|
|
788
|
+
// subscription opened ends the stream at ITS expiry — not one round
|
|
789
|
+
// later, and not never, should a round stall in a journal read. An
|
|
790
|
+
// in-process owner opens with no deadline and gains one only through
|
|
791
|
+
// such an admission.
|
|
792
|
+
const untilDeadline = Effect.gen(function* () {
|
|
793
|
+
while (true) {
|
|
794
|
+
const nowMs = yield* Clock.currentTimeMillis;
|
|
795
|
+
if (nowMs >= deadline) {
|
|
796
|
+
return yield* Effect.fail(expired);
|
|
797
|
+
}
|
|
798
|
+
yield* Number.isFinite(deadline)
|
|
799
|
+
? Effect.raceFirst(Effect.sleep(deadline - nowMs), PubSub.take(loweredSignal))
|
|
800
|
+
: PubSub.take(loweredSignal);
|
|
801
|
+
}
|
|
802
|
+
});
|
|
803
|
+
return Stream.interruptWhen(Stream.merge(Stream.merge(Stream.concat(fullRound, Stream.flatMap(ticks, (tick) => Stream.unwrap(roundFor(tick)))), announcements), appends), untilDeadline);
|
|
804
|
+
}));
|
|
805
|
+
/**
|
|
806
|
+
* Ends a subscription when the credential that opened it stops
|
|
807
|
+
* authorizing.
|
|
808
|
+
*
|
|
809
|
+
* Authorization is one-shot at open, and a journal follow never ends on
|
|
810
|
+
* its own, so without this a holder of an expired capability kept reading
|
|
811
|
+
* for as long as it declined to disconnect. The refusal is typed and
|
|
812
|
+
* carries the same `unauthorized` code a fresh request would be refused
|
|
813
|
+
* with. An in-process owner has no credential and therefore no deadline.
|
|
814
|
+
* A workspace subscription's deadline moves as reconciliation admits
|
|
815
|
+
* branches, so it arms its own (see {@link workspaceStream}); this is
|
|
816
|
+
* the run-scoped subscription's.
|
|
817
|
+
*/
|
|
818
|
+
const untilExpiry = (expiresAtMs, stream) => Number.isFinite(expiresAtMs)
|
|
819
|
+
? Stream.interruptWhen(stream, Effect.flatMap(Clock.currentTimeMillis, (nowMs) => Effect.andThen(Effect.sleep(Math.max(expiresAtMs - nowMs, 0)), Effect.fail(expired))))
|
|
820
|
+
: stream;
|
|
821
|
+
const subscribe = (input) => Stream.unwrap(Effect.gen(function* () {
|
|
822
|
+
const request = {
|
|
823
|
+
...input,
|
|
824
|
+
...yield* Admission.decode(SyncProtocol.RequestEnvelope, input, "invalid_request")
|
|
825
|
+
};
|
|
826
|
+
yield* requireVersion(request.protocolVersion);
|
|
827
|
+
yield* requireUniqueCursors(request.cursors);
|
|
828
|
+
// The schema bounds `credit` at the wire; this bounds it again for
|
|
829
|
+
// an in-process caller that constructed the request directly.
|
|
830
|
+
const credit = yield* requestCount("A subscription's credit", request.credit, SyncProtocol.maxSubscribeCredit);
|
|
831
|
+
const { expiresAtMs, runIds } = yield* runIdsFor(request.scope, request.capability);
|
|
832
|
+
const frames = request.scope._tag === "Run"
|
|
833
|
+
? untilExpiry(expiresAtMs, runStream(request.scope.runId, request.cursors))
|
|
834
|
+
: workspaceStream(runIds, expiresAtMs, request);
|
|
835
|
+
return Stream.take(frames, credit);
|
|
836
|
+
}));
|
|
837
|
+
return SyncServer.of({ snapshot, read, subscribe });
|
|
838
|
+
});
|
|
839
|
+
/**
|
|
840
|
+
* Constructs the workspace sync server with default policy.
|
|
841
|
+
*
|
|
842
|
+
* @category constructors
|
|
843
|
+
* @since 0.1.0
|
|
844
|
+
*/
|
|
845
|
+
export const makeLive = makeWith(defaults);
|
|
846
|
+
/**
|
|
847
|
+
* Constructs the workspace sync server over a journal and a run catalog, under
|
|
848
|
+
* an explicit policy.
|
|
849
|
+
*
|
|
850
|
+
* Every option is validated as a positive safe integer at construction, so a
|
|
851
|
+
* policy fails loudly instead of quietly not existing: the TypeScript type
|
|
852
|
+
* says `number`, and `maxFrameBytes: NaN` made every `bytes > maxFrameBytes`
|
|
853
|
+
* comparison false, which disabled the ceiling it configured.
|
|
854
|
+
*
|
|
855
|
+
* @category constructors
|
|
856
|
+
* @since 0.1.0
|
|
857
|
+
*/
|
|
858
|
+
export const makeLiveWith = (options = {}) => Effect.flatMap(Effect.all({
|
|
859
|
+
maxFrameBytes: positiveInt("SyncServer.Options.maxFrameBytes", options.maxFrameBytes, defaults.maxFrameBytes),
|
|
860
|
+
concurrency: positiveInt("SyncServer.Options.concurrency", options.concurrency, defaults.concurrency),
|
|
861
|
+
tailIntervalMs: positiveInt("SyncServer.Options.tailIntervalMs", options.tailIntervalMs, defaults.tailIntervalMs)
|
|
862
|
+
}), makeWith);
|
|
863
|
+
/**
|
|
864
|
+
* Provides the workspace sync server.
|
|
865
|
+
*
|
|
866
|
+
* @category layers
|
|
867
|
+
* @since 0.1.0
|
|
868
|
+
*/
|
|
869
|
+
export const layer = Layer.effect(SyncServer, makeLive);
|
|
870
|
+
/**
|
|
871
|
+
* Provides the workspace sync server under an explicit policy. Fails with
|
|
872
|
+
* `invalid_request` when an option is not a positive safe integer.
|
|
873
|
+
*
|
|
874
|
+
* @category layers
|
|
875
|
+
* @since 0.1.0
|
|
876
|
+
*/
|
|
877
|
+
export const layerWith = (options) => Layer.effect(SyncServer, makeLiveWith(options));
|
|
878
|
+
/**
|
|
879
|
+
* Provides the sync RPC handlers over the sync server: a thin, honest
|
|
880
|
+
* projection of {@link Service} onto the wire, mirroring
|
|
881
|
+
* `BranchServer.layerHandlers`. Serving these handlers requires an
|
|
882
|
+
* implementation of the group's `SyncAuth` middleware — `SyncAuth.layer` is
|
|
883
|
+
* the production one — and without a middleware-installed principal every
|
|
884
|
+
* request runs as anonymous and non-branch reads are refused.
|
|
885
|
+
*
|
|
886
|
+
* @category layers
|
|
887
|
+
* @since 0.1.0
|
|
888
|
+
*/
|
|
889
|
+
export const layerHandlers = SyncRpcs.toLayer(Effect.gen(function* () {
|
|
890
|
+
const sync = yield* SyncServer;
|
|
891
|
+
return SyncRpcs.of({
|
|
892
|
+
"Sync.Snapshot": (request) => sync.snapshot(request),
|
|
893
|
+
"Sync.Read": (request) => sync.read(request),
|
|
894
|
+
"Sync.Subscribe": (request) => sync.subscribe(request)
|
|
895
|
+
});
|
|
896
|
+
}));
|
|
897
|
+
//# sourceMappingURL=SyncServer.js.map
|