@brass-build/client 0.4.0 → 0.5.0
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/AGENTS.md +142 -41
- package/CHANGELOG.md +93 -4
- package/README.md +666 -179
- package/dist/acting-organization.d.ts.map +1 -1
- package/dist/acting-organization.js +6 -11
- package/dist/acting-organization.js.map +1 -1
- package/dist/api-fetch.d.ts +8 -0
- package/dist/api-fetch.d.ts.map +1 -1
- package/dist/api-fetch.js +35 -8
- package/dist/api-fetch.js.map +1 -1
- package/dist/batched-get.d.ts +11 -0
- package/dist/batched-get.d.ts.map +1 -0
- package/dist/batched-get.js +92 -0
- package/dist/batched-get.js.map +1 -0
- package/dist/call-service.d.ts +4 -0
- package/dist/call-service.d.ts.map +1 -1
- package/dist/call-service.js +66 -3
- package/dist/call-service.js.map +1 -1
- package/dist/client.d.ts +51 -2
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +982 -124
- package/dist/client.js.map +1 -1
- package/dist/document-checkpoints.d.ts +25 -2
- package/dist/document-checkpoints.d.ts.map +1 -1
- package/dist/document-checkpoints.js +258 -61
- package/dist/document-checkpoints.js.map +1 -1
- package/dist/document-commands.d.ts.map +1 -1
- package/dist/document-commands.js +5 -3
- package/dist/document-commands.js.map +1 -1
- package/dist/document-contents.d.ts +10 -0
- package/dist/document-contents.d.ts.map +1 -1
- package/dist/document-contents.js +23 -1
- package/dist/document-contents.js.map +1 -1
- package/dist/document-set-subscription.d.ts +56 -0
- package/dist/document-set-subscription.d.ts.map +1 -0
- package/dist/document-set-subscription.js +240 -0
- package/dist/document-set-subscription.js.map +1 -0
- package/dist/document-signals.d.ts +6 -0
- package/dist/document-signals.d.ts.map +1 -1
- package/dist/document-signals.js +31 -1
- package/dist/document-signals.js.map +1 -1
- package/dist/document-streams.d.ts +36 -2
- package/dist/document-streams.d.ts.map +1 -1
- package/dist/document-streams.js +69 -18
- package/dist/document-streams.js.map +1 -1
- package/dist/document-thumbnail-capture.d.ts +12 -2
- package/dist/document-thumbnail-capture.d.ts.map +1 -1
- package/dist/document-thumbnail-capture.js +103 -27
- package/dist/document-thumbnail-capture.js.map +1 -1
- package/dist/documents-namespace.d.ts +92 -10
- package/dist/documents-namespace.d.ts.map +1 -1
- package/dist/documents-namespace.js +285 -39
- package/dist/documents-namespace.js.map +1 -1
- package/dist/documents.d.ts +173 -8
- package/dist/documents.d.ts.map +1 -1
- package/dist/documents.js +1778 -310
- package/dist/documents.js.map +1 -1
- package/dist/errors.d.ts +3 -3
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/fetch-retry.d.ts +1 -0
- package/dist/fetch-retry.d.ts.map +1 -1
- package/dist/fetch-retry.js +2 -0
- package/dist/fetch-retry.js.map +1 -1
- package/dist/file-choice.d.ts +2 -0
- package/dist/file-choice.d.ts.map +1 -0
- package/dist/file-choice.js +27 -0
- package/dist/file-choice.js.map +1 -0
- package/dist/guest-link.d.ts +18 -1
- package/dist/guest-link.d.ts.map +1 -1
- package/dist/guest-link.js +329 -1
- package/dist/guest-link.js.map +1 -1
- package/dist/handle-types.d.ts +67 -5
- package/dist/handle-types.d.ts.map +1 -1
- package/dist/handles.d.ts +15 -19
- package/dist/handles.d.ts.map +1 -1
- package/dist/handles.js +231 -76
- package/dist/handles.js.map +1 -1
- package/dist/hosted-ui.d.ts +61 -0
- package/dist/hosted-ui.d.ts.map +1 -1
- package/dist/hosted-ui.js +94 -42
- package/dist/hosted-ui.js.map +1 -1
- package/dist/import-contract.d.ts +74 -0
- package/dist/import-contract.d.ts.map +1 -0
- package/dist/import-contract.js +163 -0
- package/dist/import-contract.js.map +1 -0
- package/dist/import-guest.d.ts +9 -0
- package/dist/import-guest.d.ts.map +1 -0
- package/dist/import-guest.js +155 -0
- package/dist/import-guest.js.map +1 -0
- package/dist/import-report.d.ts +17 -0
- package/dist/import-report.d.ts.map +1 -0
- package/dist/import-report.js +7 -0
- package/dist/import-report.js.map +1 -0
- package/dist/importer-wait.d.ts +2 -0
- package/dist/importer-wait.d.ts.map +1 -0
- package/dist/importer-wait.js +5 -0
- package/dist/importer-wait.js.map +1 -0
- package/dist/importer.d.ts +18 -4
- package/dist/importer.d.ts.map +1 -1
- package/dist/importer.js +322 -105
- package/dist/importer.js.map +1 -1
- package/dist/in-development.d.ts +2 -0
- package/dist/in-development.d.ts.map +1 -0
- package/dist/in-development.js +10 -0
- package/dist/in-development.js.map +1 -0
- package/dist/index.d.ts +16 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +24 -8
- package/dist/index.js.map +1 -1
- package/dist/init-keys.d.ts +1 -1
- package/dist/init-keys.d.ts.map +1 -1
- package/dist/init-keys.js +6 -1
- package/dist/init-keys.js.map +1 -1
- package/dist/input-documents.d.ts +67 -0
- package/dist/input-documents.d.ts.map +1 -0
- package/dist/input-documents.js +693 -0
- package/dist/input-documents.js.map +1 -0
- package/dist/inputs-status.d.ts +80 -0
- package/dist/inputs-status.d.ts.map +1 -0
- package/dist/inputs-status.js +460 -0
- package/dist/inputs-status.js.map +1 -0
- package/dist/inputs.d.ts +2 -0
- package/dist/inputs.d.ts.map +1 -0
- package/dist/inputs.js +793 -0
- package/dist/inputs.js.map +1 -0
- package/dist/internal.d.ts +6 -1
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +4 -1
- package/dist/internal.js.map +1 -1
- package/dist/join-content.d.ts +33 -0
- package/dist/join-content.d.ts.map +1 -0
- package/dist/join-content.js +114 -0
- package/dist/join-content.js.map +1 -0
- package/dist/join-stamp.d.ts +3 -0
- package/dist/join-stamp.d.ts.map +1 -0
- package/dist/join-stamp.js +12 -0
- package/dist/join-stamp.js.map +1 -0
- package/dist/manifest-drift.d.ts +9 -0
- package/dist/manifest-drift.d.ts.map +1 -0
- package/dist/manifest-drift.js +46 -0
- package/dist/manifest-drift.js.map +1 -0
- package/dist/open-document.d.ts +109 -2
- package/dist/open-document.d.ts.map +1 -1
- package/dist/open-document.js +1822 -811
- package/dist/open-document.js.map +1 -1
- package/dist/page-restore.d.ts +12 -0
- package/dist/page-restore.d.ts.map +1 -0
- package/dist/page-restore.js +23 -0
- package/dist/page-restore.js.map +1 -0
- package/dist/placement.d.ts +13 -0
- package/dist/placement.d.ts.map +1 -0
- package/dist/placement.js +34 -0
- package/dist/placement.js.map +1 -0
- package/dist/presence.d.ts +1 -0
- package/dist/presence.d.ts.map +1 -1
- package/dist/presence.js +16 -4
- package/dist/presence.js.map +1 -1
- package/dist/read-only-document.d.ts +87 -0
- package/dist/read-only-document.d.ts.map +1 -0
- package/dist/read-only-document.js +4 -0
- package/dist/read-only-document.js.map +1 -0
- package/dist/record-paging.d.ts +63 -6
- package/dist/record-paging.d.ts.map +1 -1
- package/dist/record-paging.js +122 -29
- package/dist/record-paging.js.map +1 -1
- package/dist/references.d.ts +40 -0
- package/dist/references.d.ts.map +1 -0
- package/dist/references.js +135 -0
- package/dist/references.js.map +1 -0
- package/dist/request-lane.d.ts +5 -0
- package/dist/request-lane.d.ts.map +1 -0
- package/dist/request-lane.js +48 -0
- package/dist/request-lane.js.map +1 -0
- package/dist/sealed-arrow.d.ts +170 -0
- package/dist/sealed-arrow.d.ts.map +1 -0
- package/dist/sealed-arrow.js +769 -0
- package/dist/sealed-arrow.js.map +1 -0
- package/dist/sealed-cache.d.ts +21 -0
- package/dist/sealed-cache.d.ts.map +1 -0
- package/dist/sealed-cache.js +247 -0
- package/dist/sealed-cache.js.map +1 -0
- package/dist/sealed-encode.d.ts +87 -0
- package/dist/sealed-encode.d.ts.map +1 -0
- package/dist/sealed-encode.js +876 -0
- package/dist/sealed-encode.js.map +1 -0
- package/dist/sealed-handles.d.ts +45 -0
- package/dist/sealed-handles.d.ts.map +1 -0
- package/dist/sealed-handles.js +408 -0
- package/dist/sealed-handles.js.map +1 -0
- package/dist/sealed-store.d.ts +242 -0
- package/dist/sealed-store.d.ts.map +1 -0
- package/dist/sealed-store.js +1155 -0
- package/dist/sealed-store.js.map +1 -0
- package/dist/serializer.d.ts +6 -15
- package/dist/serializer.d.ts.map +1 -1
- package/dist/session-toolbar.d.ts +20 -5
- package/dist/session-toolbar.d.ts.map +1 -1
- package/dist/session-toolbar.js +419 -67
- package/dist/session-toolbar.js.map +1 -1
- package/dist/session.d.ts +38 -1
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +562 -79
- package/dist/session.js.map +1 -1
- package/dist/sha256.d.ts +3 -0
- package/dist/sha256.d.ts.map +1 -0
- package/dist/sha256.js +74 -0
- package/dist/sha256.js.map +1 -0
- package/dist/stable-handles.d.ts +19 -0
- package/dist/stable-handles.d.ts.map +1 -0
- package/dist/stable-handles.js +193 -0
- package/dist/stable-handles.js.map +1 -0
- package/dist/staged-seal.d.ts +2 -0
- package/dist/staged-seal.d.ts.map +1 -0
- package/dist/staged-seal.js +76 -0
- package/dist/staged-seal.js.map +1 -0
- package/dist/subscriber-delivery.d.ts +8 -0
- package/dist/subscriber-delivery.d.ts.map +1 -0
- package/dist/subscriber-delivery.js +31 -0
- package/dist/subscriber-delivery.js.map +1 -0
- package/dist/tail-op-import.d.ts +10 -0
- package/dist/tail-op-import.d.ts.map +1 -0
- package/dist/tail-op-import.js +33 -0
- package/dist/tail-op-import.js.map +1 -0
- package/dist/topic-lifecycle.d.ts +6 -0
- package/dist/topic-lifecycle.d.ts.map +1 -1
- package/dist/topic-lifecycle.js +32 -2
- package/dist/topic-lifecycle.js.map +1 -1
- package/dist/trial-replace-dialog.d.ts +10 -0
- package/dist/trial-replace-dialog.d.ts.map +1 -0
- package/dist/trial-replace-dialog.js +153 -0
- package/dist/trial-replace-dialog.js.map +1 -0
- package/dist/types.d.ts +8 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/undo.d.ts +60 -5
- package/dist/undo.d.ts.map +1 -1
- package/dist/undo.js +344 -75
- package/dist/undo.js.map +1 -1
- package/dist/version-bar.d.ts +1 -0
- package/dist/version-bar.d.ts.map +1 -1
- package/dist/version-bar.js +2 -1
- package/dist/version-bar.js.map +1 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/dist/ws-client.d.ts +21 -0
- package/dist/ws-client.d.ts.map +1 -1
- package/dist/ws-client.js +192 -8
- package/dist/ws-client.js.map +1 -1
- package/dist/ws-frame-limit.d.ts +1 -0
- package/dist/ws-frame-limit.d.ts.map +1 -1
- package/dist/ws-frame-limit.js.map +1 -1
- package/docs/app-manifest.md +121 -17
- package/docs/custom-chrome.md +24 -16
- package/docs/hosted-ui-advanced.md +38 -6
- package/docs/imported-documents.md +104 -51
- package/docs/presence.md +16 -4
- package/docs/schemas.md +8 -5
- package/docs/services.md +6 -6
- package/docs/streams.md +139 -150
- package/docs/text-fields.md +26 -19
- package/package.json +5 -2
- package/src/acting-organization.ts +6 -12
- package/src/api-fetch.ts +53 -3
- package/src/auth-flow.ts +1 -1
- package/src/batched-get.ts +117 -0
- package/src/call-service.ts +78 -3
- package/src/client.ts +1092 -121
- package/src/document-checkpoints.ts +307 -65
- package/src/document-commands.ts +5 -5
- package/src/document-contents.ts +27 -1
- package/src/document-set-subscription.ts +274 -0
- package/src/document-signals.ts +47 -5
- package/src/document-streams.ts +85 -16
- package/src/document-thumbnail-capture.ts +118 -32
- package/src/documents-namespace.ts +539 -84
- package/src/documents.ts +2017 -348
- package/src/errors.ts +17 -6
- package/src/fetch-retry.ts +5 -0
- package/src/file-choice.ts +26 -0
- package/src/guest-link.ts +362 -1
- package/src/handle-types.ts +73 -47
- package/src/handles.ts +253 -83
- package/src/hosted-ui.ts +211 -50
- package/src/import-contract.ts +227 -0
- package/src/import-guest.ts +193 -0
- package/src/import-report.ts +24 -0
- package/src/importer-wait.ts +4 -0
- package/src/importer.ts +386 -131
- package/src/in-development.ts +10 -0
- package/src/index.ts +76 -16
- package/src/init-keys.ts +6 -1
- package/src/input-documents.ts +819 -0
- package/src/inputs-status.ts +598 -0
- package/src/inputs.ts +1090 -0
- package/src/internal.ts +7 -0
- package/src/join-content.ts +135 -0
- package/src/join-stamp.ts +12 -0
- package/src/manifest-drift.ts +54 -0
- package/src/open-document.ts +2346 -930
- package/src/page-restore.ts +38 -0
- package/src/placement.ts +54 -0
- package/src/presence.ts +21 -5
- package/src/read-only-document.ts +133 -0
- package/src/record-paging.ts +147 -25
- package/src/references.ts +207 -0
- package/src/request-lane.ts +59 -0
- package/src/sealed-arrow.ts +843 -0
- package/src/sealed-cache.ts +267 -0
- package/src/sealed-encode.ts +1015 -0
- package/src/sealed-handles.ts +486 -0
- package/src/sealed-store.ts +1330 -0
- package/src/serializer.ts +8 -20
- package/src/session-toolbar.ts +463 -72
- package/src/session.ts +740 -99
- package/src/sha256.ts +65 -0
- package/src/stable-handles.ts +235 -0
- package/src/staged-seal.ts +90 -0
- package/src/subscriber-delivery.ts +37 -0
- package/src/tail-op-import.ts +30 -0
- package/src/topic-lifecycle.ts +38 -2
- package/src/trial-replace-dialog.ts +163 -0
- package/src/types.ts +6 -6
- package/src/undo.ts +386 -73
- package/src/version-bar.ts +5 -1
- package/src/version.ts +1 -1
- package/src/ws-client.ts +208 -16
- package/src/ws-frame-limit.ts +1 -0
package/src/open-document.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// raw Loro import at 50k entities, so the materialisation path adds no
|
|
5
5
|
// full-snapshot logging or deep cloning of decoded bytes.
|
|
6
6
|
|
|
7
|
+
import { awaitSeal, type SealStatus } from './staged-seal.js';
|
|
7
8
|
import {
|
|
8
9
|
DEFAULT_STREAM_NAME,
|
|
9
10
|
Document,
|
|
@@ -15,15 +16,24 @@ import {
|
|
|
15
16
|
type DocumentDriveTrackingState,
|
|
16
17
|
type DriveMirrorDirection,
|
|
17
18
|
type HostedUiProvider,
|
|
18
|
-
type
|
|
19
|
+
type ForcedReadOnlyReason,
|
|
19
20
|
type ResyncPushOutcome,
|
|
21
|
+
type CheckoutFetcher,
|
|
20
22
|
} from './documents.js';
|
|
21
23
|
import { ensureWasmReady } from './wasm-runtime.js';
|
|
22
24
|
import { createPresenceController } from './presence.js';
|
|
23
25
|
import type { WsTopicCallback } from './ws-client.js';
|
|
24
26
|
import { applyAppAssertionHeader, applyClientVersionHeader } from './api-headers.js';
|
|
25
27
|
import { brassErrorFromBody } from './api-fetch.js';
|
|
26
|
-
import {
|
|
28
|
+
import type { SelectDocumentResult } from './hosted-ui.js';
|
|
29
|
+
import type { ImportReport } from './import-report.js';
|
|
30
|
+
import type { ImportClaimResponse, ImportFailedResponse } from './importer.js';
|
|
31
|
+
import { randomToken, type DocumentSourceBlob } from './documents-namespace.js';
|
|
32
|
+
import {
|
|
33
|
+
fetchWithRetry,
|
|
34
|
+
RETRY_IDEMPOTENT_WRITE,
|
|
35
|
+
RETRY_WRITE_503,
|
|
36
|
+
} from './fetch-retry.js';
|
|
27
37
|
import {
|
|
28
38
|
deadlineForUpload,
|
|
29
39
|
fetchWithDeadline,
|
|
@@ -41,8 +51,21 @@ type ClientReportReason =
|
|
|
41
51
|
| 'divergence_healed';
|
|
42
52
|
import { BrassError } from './errors.js';
|
|
43
53
|
import { perfStart } from './perf.js';
|
|
54
|
+
import { SealedContent } from './sealed-store.js';
|
|
44
55
|
import { joinCoverageFloor, snapshotSeqOf } from './join-coverage.js';
|
|
56
|
+
import { parseInputStatusNudge } from './inputs.js';
|
|
57
|
+
import { TEMPORARY_INPUTS_RUNTIME, type InputsStatusRuntime } from './inputs-status.js';
|
|
58
|
+
import { setJoinStamp } from './join-stamp.js';
|
|
59
|
+
import { storeClaimSecret } from './guest-link.js';
|
|
60
|
+
import {
|
|
61
|
+
base64ToBytes,
|
|
62
|
+
decodeJoinContent,
|
|
63
|
+
joinEncoding,
|
|
64
|
+
type ContentFetch,
|
|
65
|
+
type JoinContent,
|
|
66
|
+
} from './join-content.js';
|
|
45
67
|
import { declaredTailBytes, resolveTailOps, type WireTailOp } from './tail-ops.js';
|
|
68
|
+
import { SEALED_ACCEPT } from './sealed-store.js';
|
|
46
69
|
import {
|
|
47
70
|
opBlobExceedsDurableLimit,
|
|
48
71
|
opBlobNeedsStaging,
|
|
@@ -66,8 +89,7 @@ export interface OpenDocumentByIdOptions {
|
|
|
66
89
|
* join a document this app's declared schema cannot read, so that a
|
|
67
90
|
* bookmark or a reload does not silently open one; this says the refusal
|
|
68
91
|
* has already been answered. Set by the client after a person answers its
|
|
69
|
-
* dialog
|
|
70
|
-
* `openIncompatibleDocuments` at init.
|
|
92
|
+
* dialog.
|
|
71
93
|
* @internal
|
|
72
94
|
*/
|
|
73
95
|
acceptSchemaConflict?: boolean;
|
|
@@ -93,6 +115,19 @@ export interface OpenDocumentByIdOptions {
|
|
|
93
115
|
* @internal
|
|
94
116
|
*/
|
|
95
117
|
version?: number;
|
|
118
|
+
/**
|
|
119
|
+
* Open a live copy with nothing on it that writes: it follows other
|
|
120
|
+
* people's edits, and `mutate` throws. `false` opens to write whatever
|
|
121
|
+
* `documentOptions.readOnly` says. Unset, `documentOptions.readOnly`
|
|
122
|
+
* decides.
|
|
123
|
+
*/
|
|
124
|
+
readOnly?: boolean;
|
|
125
|
+
/**
|
|
126
|
+
* Open a read-only copy that changes only when the app asks: it reports
|
|
127
|
+
* that the document moved (`onChanged`) and holds what it loaded
|
|
128
|
+
* until `reload()`. Implies `readOnly`.
|
|
129
|
+
*/
|
|
130
|
+
frozen?: boolean;
|
|
96
131
|
/**
|
|
97
132
|
* SDK-internal load-progress sink. The session threads it through to
|
|
98
133
|
* surface a progress state before the Document exists; apps read that via
|
|
@@ -103,7 +138,12 @@ export interface OpenDocumentByIdOptions {
|
|
|
103
138
|
* the wire, because the two are paced by different things and a document
|
|
104
139
|
* can spend the whole of the first in what looks like silence: `tail` is
|
|
105
140
|
* the spilled ops the join fetches, `snapshot` the snapshot itself, and
|
|
106
|
-
* absent means `snapshot`.
|
|
141
|
+
* absent means `snapshot`. `preparing` reports no bytes: the platform is
|
|
142
|
+
* making the document editable, and the open is waiting for it. `importing`
|
|
143
|
+
* reports no bytes either: a reader's open is waiting for work that replaces
|
|
144
|
+
* the content (a new version of the file being read), and `message` is the
|
|
145
|
+
* platform's own words for it. Answering `false` to either says nobody wants
|
|
146
|
+
* this open any more, and the open rejects rather than going on waiting.
|
|
107
147
|
*
|
|
108
148
|
* `total` is the whole download in both phases. The tail declares it from
|
|
109
149
|
* each spilled op's own size plus the snapshot still to come, so the
|
|
@@ -122,8 +162,9 @@ export interface OpenDocumentByIdOptions {
|
|
|
122
162
|
export type LoadProgress = (
|
|
123
163
|
loaded: number,
|
|
124
164
|
total: number | null,
|
|
125
|
-
phase?: 'snapshot' | 'tail',
|
|
126
|
-
|
|
165
|
+
phase?: 'snapshot' | 'tail' | 'preparing' | 'importing',
|
|
166
|
+
message?: string,
|
|
167
|
+
) => void | boolean;
|
|
127
168
|
|
|
128
169
|
// `Brass.createDocument(options?)` creates a new document and returns it
|
|
129
170
|
// fully editable. `name` defaults to "Untitled". Placement is resolved from
|
|
@@ -166,6 +207,136 @@ export type CreateDocumentResult =
|
|
|
166
207
|
| { kind: 'needs-destination' }
|
|
167
208
|
| { kind: 'needs-organization' };
|
|
168
209
|
|
|
210
|
+
// What `POST /anonymous-documents` minted: the document, the editor link
|
|
211
|
+
// token that is its only credential, when the platform removes it, and the
|
|
212
|
+
// secret that claims it on a sign-in in this browser.
|
|
213
|
+
export interface MintedAnonymousDocument {
|
|
214
|
+
docId: string;
|
|
215
|
+
linkToken: string;
|
|
216
|
+
expiresAt: Date;
|
|
217
|
+
claimSecret?: string;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// The file types a visitor can start a document from, as an `<input
|
|
221
|
+
// accept>` value (`''` when there are none), and the largest file taken.
|
|
222
|
+
export interface AnonymousUploadAccept {
|
|
223
|
+
accept: string;
|
|
224
|
+
maxBytes: number;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// The app's upload-types read: whether the app starts new documents at all,
|
|
228
|
+
// and either the file types a visitor can start one from or the app's
|
|
229
|
+
// refusal of signed-out use.
|
|
230
|
+
export type AnonymousUploadTypes =
|
|
231
|
+
| { offersNew: boolean; accept: AnonymousUploadAccept }
|
|
232
|
+
| { offersNew: boolean; refusal: BrassError };
|
|
233
|
+
|
|
234
|
+
export function acceptFromUploadTypes(body: unknown): AnonymousUploadAccept {
|
|
235
|
+
const wire = body as
|
|
236
|
+
| {
|
|
237
|
+
import_types?: { extensions?: unknown; content_types?: unknown }[];
|
|
238
|
+
max_bytes?: unknown;
|
|
239
|
+
}
|
|
240
|
+
| undefined;
|
|
241
|
+
const tokens = new Set<string>();
|
|
242
|
+
for (const type of Array.isArray(wire?.import_types) ? wire.import_types : []) {
|
|
243
|
+
for (const list of [type.extensions, type.content_types]) {
|
|
244
|
+
if (!Array.isArray(list)) continue;
|
|
245
|
+
for (const token of list) if (typeof token === 'string' && token !== '') tokens.add(token);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return {
|
|
249
|
+
accept: [...tokens].join(','),
|
|
250
|
+
maxBytes: typeof wire?.max_bytes === 'number' ? wire.max_bytes : Number.POSITIVE_INFINITY,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function parseMintedAnonymousDocument(parsed: unknown): MintedAnonymousDocument {
|
|
255
|
+
const minted = parsed as {
|
|
256
|
+
doc_id?: unknown;
|
|
257
|
+
link_token?: unknown;
|
|
258
|
+
claim_secret?: unknown;
|
|
259
|
+
expires_at?: unknown;
|
|
260
|
+
} | undefined;
|
|
261
|
+
const expiresAt =
|
|
262
|
+
typeof minted?.expires_at === 'string' ? new Date(minted.expires_at) : null;
|
|
263
|
+
if (
|
|
264
|
+
typeof minted?.doc_id !== 'string' ||
|
|
265
|
+
typeof minted.link_token !== 'string' ||
|
|
266
|
+
expiresAt === null ||
|
|
267
|
+
Number.isNaN(expiresAt.getTime())
|
|
268
|
+
) {
|
|
269
|
+
throw new BrassError('Malformed anonymous document response', { status: 500 });
|
|
270
|
+
}
|
|
271
|
+
return {
|
|
272
|
+
docId: minted.doc_id,
|
|
273
|
+
linkToken: minted.link_token,
|
|
274
|
+
expiresAt,
|
|
275
|
+
...(typeof minted.claim_secret === 'string' ? { claimSecret: minted.claim_secret } : {}),
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function readJsonBody(response: Response): Promise<unknown> {
|
|
280
|
+
const text = await response.text();
|
|
281
|
+
try {
|
|
282
|
+
return text ? JSON.parse(text) : undefined;
|
|
283
|
+
} catch {
|
|
284
|
+
return undefined;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// PUT a file to a presigned URL. Through XHR where there is one, which is
|
|
289
|
+
// the browser API that reports upload progress.
|
|
290
|
+
function putFile(
|
|
291
|
+
url: string,
|
|
292
|
+
file: File,
|
|
293
|
+
onProgress?: (loaded: number, total: number) => void,
|
|
294
|
+
): Promise<void> {
|
|
295
|
+
const failed = (status: number) =>
|
|
296
|
+
new BrassError('The file could not be uploaded.', { status: status || 503 });
|
|
297
|
+
if (typeof XMLHttpRequest === 'undefined') {
|
|
298
|
+
return fetch(url, { method: 'PUT', body: file }).then((r) => {
|
|
299
|
+
if (!r.ok) throw failed(r.status);
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
return new Promise((resolve, reject) => {
|
|
303
|
+
const xhr = new XMLHttpRequest();
|
|
304
|
+
xhr.open('PUT', url);
|
|
305
|
+
xhr.upload.onprogress = (e) => {
|
|
306
|
+
if (e.lengthComputable) onProgress?.(e.loaded, e.total);
|
|
307
|
+
};
|
|
308
|
+
xhr.onload = () => (xhr.status >= 200 && xhr.status < 300 ? resolve() : reject(failed(xhr.status)));
|
|
309
|
+
xhr.onerror = () => reject(failed(0));
|
|
310
|
+
xhr.send(file);
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export interface GuestImportCalls {
|
|
315
|
+
claimImport(): Promise<ImportClaimResponse>;
|
|
316
|
+
stageImport(bundle: Uint8Array): Promise<string>;
|
|
317
|
+
completeImport(report: ImportReport | undefined, uploadKey: string): Promise<unknown>;
|
|
318
|
+
failImport(message: string): Promise<ImportFailedResponse>;
|
|
319
|
+
sourceBlob(): Promise<DocumentSourceBlob>;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export interface ClaimAnonymousDocumentArgs {
|
|
323
|
+
linkToken: string;
|
|
324
|
+
claimSecret: string;
|
|
325
|
+
// The hosted picker's opaque destination, relayed verbatim like a create's.
|
|
326
|
+
destination?: unknown;
|
|
327
|
+
// The name the person settled on in the destination dialog.
|
|
328
|
+
name?: string;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// The claim's outcome, classified the way a create's is so the same
|
|
332
|
+
// placement loop runs over it. `record` is the claimed document as the
|
|
333
|
+
// server answered it, in the picker's shape, so the claim can be stashed as
|
|
334
|
+
// a selection; null when the answer did not carry the fields.
|
|
335
|
+
export type ClaimDocumentResult =
|
|
336
|
+
| { kind: 'claimed'; record: SelectDocumentResult | null }
|
|
337
|
+
| { kind: 'needs-destination' }
|
|
338
|
+
| { kind: 'needs-organization' };
|
|
339
|
+
|
|
169
340
|
// Wire-protocol-pinned response shape from
|
|
170
341
|
// `GET /documents/{docId}?include=snapshot`.
|
|
171
342
|
export interface DocumentSnapshotResponse {
|
|
@@ -180,9 +351,13 @@ export interface DocumentSnapshotResponse {
|
|
|
180
351
|
stream_branch_id?: string;
|
|
181
352
|
// `inline` carries base64 bytes; `presigned` is a signed URL the
|
|
182
353
|
// client GETs directly; `null` means no snapshot yet (empty new doc).
|
|
354
|
+
// `sealed` is SEALED CONTENT rather than a snapshot: the document's
|
|
355
|
+
// records as they were produced, which this reader decodes with no CRDT
|
|
356
|
+
// engine. Always a URL, never inline.
|
|
183
357
|
snapshot:
|
|
184
358
|
| { kind: 'inline'; data_b64: string }
|
|
185
359
|
| { kind: 'presigned'; url: string }
|
|
360
|
+
| { kind: 'sealed'; url: string }
|
|
186
361
|
| null;
|
|
187
362
|
// Tail ops since the last snapshot, seq ascending, each a base64 Loro op
|
|
188
363
|
// blob. Applied in order; `applyRemoteOp` is idempotent.
|
|
@@ -202,6 +377,14 @@ export interface DocumentSnapshotResponse {
|
|
|
202
377
|
// ground truth). The handshake runs off the `doc_init` frame's copy; this
|
|
203
378
|
// is carried for wire parallelism. Absent until the branch is compacted.
|
|
204
379
|
snapshot_version_vector?: string;
|
|
380
|
+
// The seq the line's current import was sealed at (`importSeqMoved`).
|
|
381
|
+
import_seq?: number;
|
|
382
|
+
// True while the document is its stored file and no import has run on it:
|
|
383
|
+
// an importer claiming it will replace this (empty) content, so a client
|
|
384
|
+
// with one holds the document before the claim answers.
|
|
385
|
+
awaits_import?: boolean;
|
|
386
|
+
// Why `can_edit` is false when the grant itself would allow writing.
|
|
387
|
+
read_only_reason?: string;
|
|
205
388
|
// Whether the caller may write to this document, as the server's own
|
|
206
389
|
// write gate answers it. `false` opens the Document read-only, so
|
|
207
390
|
// `doc.readOnly` covers a view-only grant at bind time rather than
|
|
@@ -243,6 +426,7 @@ export interface DrivePublishTargetWire {
|
|
|
243
426
|
source_unreadable?: unknown;
|
|
244
427
|
publishing?: unknown;
|
|
245
428
|
publish_failed?: unknown;
|
|
429
|
+
unpublishable?: unknown;
|
|
246
430
|
}
|
|
247
431
|
|
|
248
432
|
// Wire shape of the `drive` block carried on the join response and the
|
|
@@ -266,6 +450,9 @@ export interface DocumentDriveSummaryWire {
|
|
|
266
450
|
// Optional: a frame from an older server omits it, treated as not
|
|
267
451
|
// publishing (the `canPush` affordance then behaves as before).
|
|
268
452
|
publishing?: boolean;
|
|
453
|
+
// The server's publish decision: something a publish would change now.
|
|
454
|
+
publishable?: boolean;
|
|
455
|
+
keeps_prior_versions?: boolean;
|
|
269
456
|
source_unreadable?: string;
|
|
270
457
|
}
|
|
271
458
|
|
|
@@ -277,9 +464,12 @@ export interface DocumentAtVersionResponse {
|
|
|
277
464
|
effective_version: number;
|
|
278
465
|
requested_version: number;
|
|
279
466
|
branch_id: string;
|
|
467
|
+
// `sealed` when the version is the head of a document still stored as a
|
|
468
|
+
// record set (see `DocumentSnapshotResponse`).
|
|
280
469
|
snapshot:
|
|
281
470
|
| { kind: 'inline'; data_b64: string }
|
|
282
471
|
| { kind: 'presigned'; url: string }
|
|
472
|
+
| { kind: 'sealed'; url: string }
|
|
283
473
|
| null;
|
|
284
474
|
tail_ops: Array<{ seq: number; op_blob_b64: string }>;
|
|
285
475
|
current_seq: number;
|
|
@@ -294,6 +484,8 @@ export interface OpenDocumentWsClient {
|
|
|
294
484
|
options?: {
|
|
295
485
|
ownsFrameRetry?: boolean;
|
|
296
486
|
onEnded?: (info: { reason: string }) => void;
|
|
487
|
+
onSubscribed?: (info: { resubscribed: boolean; currentSeq?: number }) => void;
|
|
488
|
+
notice?: boolean;
|
|
297
489
|
},
|
|
298
490
|
): () => void;
|
|
299
491
|
send(frame: unknown): void;
|
|
@@ -306,6 +498,14 @@ export interface OpenDocumentWsClient {
|
|
|
306
498
|
// Whether a socket is open right now. Absent reads as connected, which is
|
|
307
499
|
// what a transport-only test stub is.
|
|
308
500
|
readonly connected?: boolean;
|
|
501
|
+
// Whether any socket has opened in this page load. Absent reads as opened,
|
|
502
|
+
// for the same reason.
|
|
503
|
+
readonly everOpened?: boolean;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
export interface LinkCredential {
|
|
507
|
+
accessToken: string;
|
|
508
|
+
appAssertion: string;
|
|
309
509
|
}
|
|
310
510
|
|
|
311
511
|
export interface OpenDocumentFactoryOptions {
|
|
@@ -315,11 +515,24 @@ export interface OpenDocumentFactoryOptions {
|
|
|
315
515
|
// Forwarded as `X-Brass-App-Assertion` on the Brass-API fetches only; the
|
|
316
516
|
// presigned snapshot downloads carry no Brass headers.
|
|
317
517
|
getAppAssertion?: () => Promise<string | null>;
|
|
518
|
+
// The person's own credential for a link open, held when they signed in on
|
|
519
|
+
// a link page. An app that opens public links only for people signed in
|
|
520
|
+
// to it refuses the open without one.
|
|
521
|
+
getLinkCredential?: () => LinkCredential | null | Promise<LinkCredential | null>;
|
|
522
|
+
// How a link open proceeds when the server answers that the signed-in
|
|
523
|
+
// person opens the document as themselves. Defaults to this factory's own
|
|
524
|
+
// `open`.
|
|
525
|
+
openAsMember?: (docId: string, options?: OpenDocumentByIdOptions) => Promise<OpenedDocument>;
|
|
318
526
|
// The shared WS client. When provided (production), the factory wires a
|
|
319
527
|
// doc-topic subscription + local-op send hook so the Document is editable.
|
|
320
528
|
// When absent (tests / snapshot reads), the local-op hook is inert: state
|
|
321
529
|
// still mutates locally but nothing leaves the process.
|
|
322
530
|
wsClient?: OpenDocumentWsClient;
|
|
531
|
+
// The socket a document opened through `token` uses. A link admits a socket
|
|
532
|
+
// to its one document only, so a link's socket carries nothing else and the
|
|
533
|
+
// page's own socket keeps the signed-in person's credential. Falls back to
|
|
534
|
+
// `wsClient`.
|
|
535
|
+
linkWsClient?: (token: string) => OpenDocumentWsClient | undefined;
|
|
323
536
|
// ID minter, exposed for deterministic test clientOpIds. Defaults to
|
|
324
537
|
// `crypto.randomUUID()`, falling back to a 32-hex Math.random() string.
|
|
325
538
|
// Correlation is SDK-side only (the server never reads the value), so
|
|
@@ -337,10 +550,12 @@ export interface OpenDocumentFactoryOptions {
|
|
|
337
550
|
image: Blob,
|
|
338
551
|
width: number,
|
|
339
552
|
height: number,
|
|
553
|
+
version?: number,
|
|
340
554
|
) => Promise<unknown>;
|
|
341
555
|
// Routes `doc.createCheckpoint(...)`. When absent (tests), it throws.
|
|
342
556
|
checkpointCreator?: (
|
|
343
557
|
doc: Document,
|
|
558
|
+
anchor: import('./document-checkpoints.js').CheckpointAnchor,
|
|
344
559
|
) => Promise<import('./documents-namespace.js').DocumentCheckpoint>;
|
|
345
560
|
// Routes `doc.pushToDrive(...)`. When absent (tests / offline), it throws.
|
|
346
561
|
drivePusher?: (
|
|
@@ -352,6 +567,18 @@ export interface OpenDocumentFactoryOptions {
|
|
|
352
567
|
sourceBlobFetcher?: (
|
|
353
568
|
doc: Document,
|
|
354
569
|
) => Promise<import('./documents-namespace.js').DocumentSourceBlob>;
|
|
570
|
+
// Routes `subscribeInputStatus` / `inputStatus` for a document or one of
|
|
571
|
+
// its stream lines. When absent (tests / offline), those methods throw.
|
|
572
|
+
inputsRuntime?: (docId: string) => import('./inputs-status.js').InputsStatusRuntime;
|
|
573
|
+
// Posts a key's provisional entry once the op carrying a `dependsOn` /
|
|
574
|
+
// `markRead` change is acknowledged. When absent, nothing is posted and the
|
|
575
|
+
// platform reads the key from the document when it compacts.
|
|
576
|
+
/** @internal */
|
|
577
|
+
provisionalInputsPoster?: (
|
|
578
|
+
docId: string,
|
|
579
|
+
post: import('./inputs.js').ProvisionalInputsPost,
|
|
580
|
+
opts?: { superseded?: () => boolean },
|
|
581
|
+
) => Promise<import('./inputs.js').ProvisionalInputsAnswer | null>;
|
|
355
582
|
// Streams manifest plumbing (`GET` / `POST /documents/{docId}/streams`).
|
|
356
583
|
// When supplied, every returned Document exposes `doc.stream(name)` /
|
|
357
584
|
// `doc.stream(name)` / `doc.listStreams()`; the factory owns the
|
|
@@ -409,6 +636,16 @@ const WORK_READY_STATUS: DocumentWorkStatus = {
|
|
|
409
636
|
blocking: false,
|
|
410
637
|
};
|
|
411
638
|
|
|
639
|
+
// How long an edit waits on a client that has never opened a socket before
|
|
640
|
+
// the document tells the reader it is not saved. Timed from the first waiting
|
|
641
|
+
// edit rather than counted in failed handshakes, because a network that
|
|
642
|
+
// swallows the upgrade can hold one attempt open with no failure to count.
|
|
643
|
+
// The socket starts connecting when the document opens, so by the time this
|
|
644
|
+
// expires it has had at least this long, which is several times a slow
|
|
645
|
+
// mobile handshake, and a network that refuses upgrades outright has failed
|
|
646
|
+
// four or five of them under the reconnect backoff (1s, 2s, 4s).
|
|
647
|
+
export const NEVER_OPENED_NOTICE_MS = 10_000;
|
|
648
|
+
|
|
412
649
|
const CONTENT_PULL_ATTEMPTS = 5;
|
|
413
650
|
const CONTENT_PULL_BACKOFF_MS = 1000;
|
|
414
651
|
|
|
@@ -499,6 +736,35 @@ export interface DocumentFactory {
|
|
|
499
736
|
createDocumentId: (args: CreateDocumentArgs) => Promise<CreateDocumentResult>;
|
|
500
737
|
// Read-only guest open from a public-link token (no auth, no live sub).
|
|
501
738
|
openByLink: OpenDocumentByLinkFn;
|
|
739
|
+
// Mint a document with no account: `POST /anonymous-documents`, no
|
|
740
|
+
// Authorization header. The returned link token opens it through
|
|
741
|
+
// `openByLink` read-write until it expires.
|
|
742
|
+
mintAnonymousDocument: (args: {
|
|
743
|
+
name?: string;
|
|
744
|
+
}) => Promise<MintedAnonymousDocument>;
|
|
745
|
+
// Whether this app starts new documents, and the file types a visitor
|
|
746
|
+
// without an account can start one from:
|
|
747
|
+
// `GET /anonymous-documents/upload-types`.
|
|
748
|
+
anonymousUploadTypes: () => Promise<AnonymousUploadTypes>;
|
|
749
|
+
// Mint a document with no account from a file: stage it through a
|
|
750
|
+
// presigned PUT, then `POST /anonymous-documents/from-upload`. The file's
|
|
751
|
+
// import runs after this resolves, and the open reports it as pending.
|
|
752
|
+
// The app-run import calls for a document a visitor started from a file,
|
|
753
|
+
// through the member import routes with its edit link as the credential.
|
|
754
|
+
guestImports: (token: string, docId: string) => GuestImportCalls;
|
|
755
|
+
mintAnonymousDocumentFromFile: (args: {
|
|
756
|
+
file: File;
|
|
757
|
+
name?: string;
|
|
758
|
+
onUploadProgress?: (loaded: number, total: number) => void;
|
|
759
|
+
}) => Promise<MintedAnonymousDocument>;
|
|
760
|
+
// Move a document minted without an account into the caller's
|
|
761
|
+
// organization: `POST /documents/{docId}/claim`. Classified like
|
|
762
|
+
// `createDocumentId` so the caller can place it through the same hosted
|
|
763
|
+
// surfaces.
|
|
764
|
+
claimAnonymousDocument: (
|
|
765
|
+
docId: string,
|
|
766
|
+
args: ClaimAnonymousDocumentArgs,
|
|
767
|
+
) => Promise<ClaimDocumentResult>;
|
|
502
768
|
}
|
|
503
769
|
|
|
504
770
|
// Wire shapes on the `doc:` topic. Frame names match the server's exactly, so
|
|
@@ -592,6 +858,7 @@ interface DocInitFrame {
|
|
|
592
858
|
// frontier and resync-push anything local holds beyond it. Absent on
|
|
593
859
|
// never-compacted docs; the handshake is skipped (nothing to verify).
|
|
594
860
|
snapshot_version_vector?: string;
|
|
861
|
+
import_seq?: number;
|
|
595
862
|
}
|
|
596
863
|
|
|
597
864
|
// Server-pushed "the document moved past you" prompt, naming the seq to
|
|
@@ -678,8 +945,7 @@ interface DocOpsDroppedFrame {
|
|
|
678
945
|
}
|
|
679
946
|
|
|
680
947
|
// Map the snake_case wire `drive` block to the public camelCase
|
|
681
|
-
// `DocumentDriveStatus`. `canPush` is
|
|
682
|
-
// and ahead/diverged) so apps don't re-derive it.
|
|
948
|
+
// `DocumentDriveStatus`. `canPush` is the server's `publishable`.
|
|
683
949
|
function toDriveStatus(wire: DocumentDriveSummaryWire): DocumentDriveStatus {
|
|
684
950
|
const state = wire.tracking_state;
|
|
685
951
|
// Only an explicit `import_only` restricts publishing; a frame omitting the
|
|
@@ -691,22 +957,14 @@ function toDriveStatus(wire: DocumentDriveSummaryWire): DocumentDriveStatus {
|
|
|
691
957
|
driveType: wire.drive_type,
|
|
692
958
|
driveName: wire.drive_name,
|
|
693
959
|
state,
|
|
694
|
-
// `
|
|
695
|
-
//
|
|
696
|
-
|
|
697
|
-
// would reject. Require an explicit `false` to enable, and an explicit
|
|
698
|
-
// `true` to flag broken. Behaviour is unchanged for today's frames, which
|
|
699
|
-
// always carry the boolean. Whether the file is written server-side or
|
|
700
|
-
// from the app's own serializer is a REQUEST SHAPE the push path resolves
|
|
701
|
-
// for itself, so it says nothing about whether this document can publish.
|
|
702
|
-
canPush:
|
|
703
|
-
wire.broken === false &&
|
|
704
|
-
mirrorDirection === 'two_way' &&
|
|
705
|
-
(state === 'ahead' || state === 'diverged'),
|
|
960
|
+
// Only an explicit `true` offers a publish, so a frame that omits the
|
|
961
|
+
// field never offers one the server would refuse.
|
|
962
|
+
canPush: wire.publishable === true,
|
|
706
963
|
label: wire.tracking_label,
|
|
707
964
|
detail: wire.tracking_detail,
|
|
708
965
|
broken: wire.broken === true,
|
|
709
966
|
pushReplacesRemote: wire.push_replaces_remote,
|
|
967
|
+
keepsPriorVersions: wire.keeps_prior_versions === true,
|
|
710
968
|
mirrorDirection,
|
|
711
969
|
ref: wire.tracking_ref,
|
|
712
970
|
refUpdatedAt: wire.tracking_ref_updated_at,
|
|
@@ -738,6 +996,7 @@ function toPublishTarget(
|
|
|
738
996
|
detail: typeof wire.detail === 'string' ? wire.detail : '',
|
|
739
997
|
publishing: wire.publishing === true,
|
|
740
998
|
publishFailed: wire.publish_failed === true,
|
|
999
|
+
unpublishable: wire.unpublishable === true,
|
|
741
1000
|
};
|
|
742
1001
|
}
|
|
743
1002
|
|
|
@@ -797,6 +1056,7 @@ function parseDocInitFrame(frame: unknown): DocInitFrame | null {
|
|
|
797
1056
|
...(typeof f.snapshot_version_vector === 'string'
|
|
798
1057
|
? { snapshot_version_vector: f.snapshot_version_vector }
|
|
799
1058
|
: {}),
|
|
1059
|
+
...(typeof f.import_seq === 'number' ? { import_seq: f.import_seq } : {}),
|
|
800
1060
|
};
|
|
801
1061
|
}
|
|
802
1062
|
|
|
@@ -861,7 +1121,15 @@ type OversizedOpResult =
|
|
|
861
1121
|
// Set when the refusal was about the writer rather than the op, which
|
|
862
1122
|
// is the same distinction the socket's `op_rejected` reasons draw:
|
|
863
1123
|
// every later op meets it too, so the Document turns read-only.
|
|
864
|
-
readOnly?:
|
|
1124
|
+
readOnly?: ForcedReadOnlyReason;
|
|
1125
|
+
// A new version's import replaced the line this op was built on: the
|
|
1126
|
+
// copy reopens rather than failing the op (`OP_REJECTED_MOVED`).
|
|
1127
|
+
moved?: boolean;
|
|
1128
|
+
// The document still holds only its uploaded file, and it takes no
|
|
1129
|
+
// edits until that file is imported (`OP_REJECTED_NOT_IMPORTED`).
|
|
1130
|
+
notImported?: boolean;
|
|
1131
|
+
// No transport this writer has can carry the op (a 413).
|
|
1132
|
+
tooLarge?: boolean;
|
|
865
1133
|
};
|
|
866
1134
|
|
|
867
1135
|
// Whether re-sending the same op is the right move. Two statuses refuse to
|
|
@@ -886,6 +1154,25 @@ function isCapacityRefusal(status: number, parsed: unknown): boolean {
|
|
|
886
1154
|
return (parsed as { code?: unknown } | undefined | null)?.code === 'at_capacity';
|
|
887
1155
|
}
|
|
888
1156
|
|
|
1157
|
+
// The refusal of an op built on a version of the line that a new version's
|
|
1158
|
+
// import has since replaced. It is about the copy rather than the op: no
|
|
1159
|
+
// resend or heal can place an op naming the replaced version's containers, so
|
|
1160
|
+
// the copy reopens, as it does on `doc_gone` `moved`.
|
|
1161
|
+
const OP_REJECTED_MOVED = 'moved';
|
|
1162
|
+
|
|
1163
|
+
// The document still holds only its uploaded file. Every op meets the same
|
|
1164
|
+
// refusal until the import lands, so a smaller or later push cannot place
|
|
1165
|
+
// the refused work and the heal is not armed: the copy locks and reloads.
|
|
1166
|
+
const OP_REJECTED_NOT_IMPORTED = 'not_imported';
|
|
1167
|
+
|
|
1168
|
+
function isNotImportedRefusal(parsed: unknown): boolean {
|
|
1169
|
+
return (parsed as { code?: unknown } | undefined | null)?.code === OP_REJECTED_NOT_IMPORTED;
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
function isMovedRefusal(parsed: unknown): boolean {
|
|
1173
|
+
return (parsed as { code?: unknown } | undefined | null)?.code === OP_REJECTED_MOVED;
|
|
1174
|
+
}
|
|
1175
|
+
|
|
889
1176
|
// A failure uploading an oversized op's blob to storage. It carries its own
|
|
890
1177
|
// type because staging runs strictly BEFORE the op write, so what a replay
|
|
891
1178
|
// means differs from a throw out of the write itself: nothing durable exists
|
|
@@ -931,7 +1218,7 @@ function opRejectedMessage(frame: OpRejectedFrame): string {
|
|
|
931
1218
|
if (typeof frame.message === 'string' && frame.message !== '') {
|
|
932
1219
|
return frame.message;
|
|
933
1220
|
}
|
|
934
|
-
return 'A change could not be saved and will not be retried. Reload
|
|
1221
|
+
return 'A change could not be saved and will not be retried. Reload to continue.';
|
|
935
1222
|
}
|
|
936
1223
|
|
|
937
1224
|
// The one rejection reason that describes the DOCUMENT rather than the op:
|
|
@@ -961,14 +1248,14 @@ function isCapacityRejection(frame: OpRejectedFrame): boolean {
|
|
|
961
1248
|
// object answers for the names it inherits, so a reason reading `constructor`
|
|
962
1249
|
// or `toString` would resolve a function and lock the document, which is the
|
|
963
1250
|
// opposite of what an unclassified refusal means.
|
|
964
|
-
const READ_ONLY_REJECTION_REASONS = new Map<string,
|
|
1251
|
+
const READ_ONLY_REJECTION_REASONS = new Map<string, ForcedReadOnlyReason>([
|
|
965
1252
|
['role_denied', 'grant'],
|
|
966
1253
|
['scope_denied', 'app'],
|
|
967
1254
|
]);
|
|
968
1255
|
|
|
969
1256
|
function readOnlyReasonForRejection(
|
|
970
1257
|
frame: OpRejectedFrame,
|
|
971
|
-
):
|
|
1258
|
+
): ForcedReadOnlyReason | null {
|
|
972
1259
|
if (typeof frame.reason !== 'string') return null;
|
|
973
1260
|
return READ_ONLY_REJECTION_REASONS.get(frame.reason) ?? null;
|
|
974
1261
|
}
|
|
@@ -1098,16 +1385,6 @@ function bytesToBase64(bytes: Uint8Array): string {
|
|
|
1098
1385
|
return btoa(bin);
|
|
1099
1386
|
}
|
|
1100
1387
|
|
|
1101
|
-
// Uses `atob` (present in modern browsers, Node 20+, and jsdom) rather than
|
|
1102
|
-
// Node-only `Buffer`, which would force a browser-bundle polyfill.
|
|
1103
|
-
function base64ToBytes(b64: string): Uint8Array {
|
|
1104
|
-
const bin = atob(b64);
|
|
1105
|
-
const bytes = new Uint8Array(bin.length);
|
|
1106
|
-
for (let i = 0; i < bin.length; i++) {
|
|
1107
|
-
bytes[i] = bin.charCodeAt(i);
|
|
1108
|
-
}
|
|
1109
|
-
return bytes;
|
|
1110
|
-
}
|
|
1111
1388
|
|
|
1112
1389
|
function isSnapshotResponse(body: unknown): body is DocumentSnapshotResponse {
|
|
1113
1390
|
if (typeof body !== 'object' || body === null) return false;
|
|
@@ -1124,16 +1401,171 @@ function isSnapshotResponse(body: unknown): body is DocumentSnapshotResponse {
|
|
|
1124
1401
|
return true;
|
|
1125
1402
|
}
|
|
1126
1403
|
|
|
1404
|
+
/**
|
|
1405
|
+
* The body a join answered with, or the error it is.
|
|
1406
|
+
*
|
|
1407
|
+
* Every open starts here: check the status, then check the shape. Both
|
|
1408
|
+
* failures are open-time and neither is recoverable by the caller, so they
|
|
1409
|
+
* throw rather than returning, and the shape check is what stops a response
|
|
1410
|
+
* the server changed out from under a pinned SDK being read as an empty
|
|
1411
|
+
* document. `describe` names the response in the message, since a reader
|
|
1412
|
+
* debugging one wants to know which route answered.
|
|
1413
|
+
*/
|
|
1414
|
+
function unwrapJoinResponse<T>(
|
|
1415
|
+
result: { status: number; parsed: unknown; parseFailed: boolean },
|
|
1416
|
+
isShape: (body: unknown) => body is T,
|
|
1417
|
+
describe: string,
|
|
1418
|
+
): T {
|
|
1419
|
+
const { status, parsed, parseFailed } = result;
|
|
1420
|
+
if (status < 200 || status >= 300) {
|
|
1421
|
+
throw brassErrorFromBody(status, parseFailed ? undefined : parsed);
|
|
1422
|
+
}
|
|
1423
|
+
if (!isShape(parsed)) {
|
|
1424
|
+
throw new BrassError(`Malformed document ${describe} response`, {
|
|
1425
|
+
status: 500,
|
|
1426
|
+
});
|
|
1427
|
+
}
|
|
1428
|
+
return parsed;
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1127
1431
|
// Fold the join's write verdict into the Document options, so a reader the
|
|
1128
1432
|
// server will not accept writes from opens read-only instead of learning it
|
|
1129
1433
|
// from the first refused op. Only ever ADDS the restriction: a caller that
|
|
1130
1434
|
// asked for a read-only Document keeps it whatever the grant says.
|
|
1435
|
+
/** The longest one pause between joins of a converting document. The wait
|
|
1436
|
+
* itself has no bound, because a slow conversion is still a healthy one:
|
|
1437
|
+
* each join is answered with what is left of it, and a conversion that
|
|
1438
|
+
* cannot finish ends as `at_capacity`, which stops the wait. */
|
|
1439
|
+
const CONVERSION_MAX_DELAY_MS = 60_000;
|
|
1440
|
+
/** The shortest pause, so a server answering without holding the join is not
|
|
1441
|
+
* asked again in a tight loop. */
|
|
1442
|
+
const CONVERSION_MIN_DELAY_MS = 250;
|
|
1443
|
+
const CONVERSION_POLL_MIN_MS = 1_000;
|
|
1444
|
+
const CONVERSION_POLL_MAX_MS = 5_000;
|
|
1445
|
+
/** Joins in a row that may fail outright while a conversion is awaited. */
|
|
1446
|
+
const CONVERSION_JOIN_FAILURES = 5;
|
|
1447
|
+
// How often a reader's open waiting on an import reads the work status, as
|
|
1448
|
+
// the backstop for a terminal frame it did not hear.
|
|
1449
|
+
const IMPORT_WAIT_POLL_MIN_MS = 2_000;
|
|
1450
|
+
const IMPORT_WAIT_POLL_MAX_MS = 15_000;
|
|
1451
|
+
const OPEN_SUPERSEDED_MESSAGE = 'This open was replaced by another';
|
|
1452
|
+
// How long an open waits on the platform's work (a conversion, an import)
|
|
1453
|
+
// before it rejects. Above the longest the platform itself takes to end that
|
|
1454
|
+
// work either way: an import is two runs of the hydration worker (one retry)
|
|
1455
|
+
// before the platform marks it failed, about 32 minutes, and a conversion is
|
|
1456
|
+
// three compaction runs before it is marked full, about 18. A wait past this
|
|
1457
|
+
// is work the platform has stopped reporting on.
|
|
1458
|
+
// Exported for the fake-timer tests.
|
|
1459
|
+
export const OPEN_WAIT_CEILING_MS = 40 * 60 * 1000;
|
|
1460
|
+
const OPEN_WAIT_EXPIRED_MESSAGE = 'This is taking too long to open. Reload to try again.';
|
|
1461
|
+
const IMPORT_FAILED_MESSAGE = 'This file could not be imported';
|
|
1462
|
+
/** How long the server holds a writable join for a conversion, less a margin,
|
|
1463
|
+
* so a join sent this long before the conversion is expected is held until
|
|
1464
|
+
* it lands. */
|
|
1465
|
+
const CONVERSION_HOLD_MS = 10_000;
|
|
1466
|
+
|
|
1467
|
+
/** When to send the next held join, given how long the conversion is
|
|
1468
|
+
* expected to take from now. */
|
|
1469
|
+
function heldJoinDelay(retryAfterMs: number): number {
|
|
1470
|
+
return Math.min(
|
|
1471
|
+
Math.max(CONVERSION_MIN_DELAY_MS, retryAfterMs - CONVERSION_HOLD_MS),
|
|
1472
|
+
CONVERSION_MAX_DELAY_MS,
|
|
1473
|
+
);
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
/** Whether a response is the join's retryable refusal for a conversion,
|
|
1477
|
+
* which names its own wait, so the caller paces it rather than a backoff. */
|
|
1478
|
+
async function namesConversionWait(response: Response): Promise<boolean> {
|
|
1479
|
+
if (response.status !== 503) return false;
|
|
1480
|
+
try {
|
|
1481
|
+
const body = (await response.clone().json()) as { retry_after_ms?: unknown };
|
|
1482
|
+
return typeof body.retry_after_ms === 'number';
|
|
1483
|
+
} catch {
|
|
1484
|
+
return false;
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
type JoinAnswer = { status: number; parsed: unknown; parseFailed: boolean };
|
|
1489
|
+
|
|
1490
|
+
/**
|
|
1491
|
+
* Work a join reports that the open waits out before it resolves: pending work
|
|
1492
|
+
* that locks readers, such as the platform importing the file or a new
|
|
1493
|
+
* version of it.
|
|
1494
|
+
*/
|
|
1495
|
+
function pendingImportWork(
|
|
1496
|
+
join: JoinAnswer,
|
|
1497
|
+
): { status: DocumentWorkStatus; topic: string } | null {
|
|
1498
|
+
if (join.status !== 200 || join.parseFailed) return null;
|
|
1499
|
+
const parsed = join.parsed as {
|
|
1500
|
+
doc_id?: unknown;
|
|
1501
|
+
branch_id?: unknown;
|
|
1502
|
+
status?: unknown;
|
|
1503
|
+
} | null;
|
|
1504
|
+
if (parsed === null || typeof parsed !== 'object') return null;
|
|
1505
|
+
if (typeof parsed.doc_id !== 'string' || typeof parsed.branch_id !== 'string') return null;
|
|
1506
|
+
const status = parseWorkStatus(parsed.status);
|
|
1507
|
+
if (status === null || !status.pending || status.blocking === false) return null;
|
|
1508
|
+
return { status, topic: `doc:${parsed.doc_id}:${parsed.branch_id}` };
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
/**
|
|
1512
|
+
* The error an open rejects with when the file's import failed and the
|
|
1513
|
+
* document holds nothing from it, or `null`.
|
|
1514
|
+
*/
|
|
1515
|
+
function failedImportOf(join: JoinAnswer): BrassError | null {
|
|
1516
|
+
if (join.status !== 200 || join.parseFailed) return null;
|
|
1517
|
+
const parsed = join.parsed as {
|
|
1518
|
+
current_seq?: unknown;
|
|
1519
|
+
awaits_import?: unknown;
|
|
1520
|
+
status?: unknown;
|
|
1521
|
+
} | null;
|
|
1522
|
+
if (parsed === null || typeof parsed !== 'object') return null;
|
|
1523
|
+
const status = parseWorkStatus(parsed.status);
|
|
1524
|
+
if (status === null || status.pending || status.blocking !== true) return null;
|
|
1525
|
+
// An import lands at seq 1 or later, so seq 0 is a document no import has
|
|
1526
|
+
// filled.
|
|
1527
|
+
const empty =
|
|
1528
|
+
parsed.awaits_import === true ||
|
|
1529
|
+
typeof parsed.current_seq !== 'number' ||
|
|
1530
|
+
parsed.current_seq <= 0;
|
|
1531
|
+
if (!empty) return null;
|
|
1532
|
+
return new BrassError(status.message !== '' ? status.message : IMPORT_FAILED_MESSAGE, {
|
|
1533
|
+
status: 0,
|
|
1534
|
+
});
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
/** Why a writable join refused, or `null` for one that served or failed some
|
|
1538
|
+
* other way. */
|
|
1539
|
+
function joinRefusalOf(join: {
|
|
1540
|
+
status: number;
|
|
1541
|
+
parsed: unknown;
|
|
1542
|
+
}):
|
|
1543
|
+
| { kind: 'converting'; retryAfterMs?: number }
|
|
1544
|
+
| { kind: 'at_capacity'; error?: string }
|
|
1545
|
+
| null {
|
|
1546
|
+
const body =
|
|
1547
|
+
typeof join.parsed === 'object' && join.parsed !== null
|
|
1548
|
+
? (join.parsed as { retry_after_ms?: unknown; code?: unknown; error?: unknown })
|
|
1549
|
+
: {};
|
|
1550
|
+
if (join.status === 503 && typeof body.retry_after_ms === 'number' && body.retry_after_ms >= 0) {
|
|
1551
|
+
return { kind: 'converting', retryAfterMs: body.retry_after_ms };
|
|
1552
|
+
}
|
|
1553
|
+
if (join.status === 507 && body.code === 'at_capacity') {
|
|
1554
|
+
return typeof body.error === 'string' ? { kind: 'at_capacity', error: body.error } : { kind: 'at_capacity' };
|
|
1555
|
+
}
|
|
1556
|
+
return null;
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1131
1559
|
function readOnlyForGrant(
|
|
1132
1560
|
body: DocumentSnapshotResponse,
|
|
1133
1561
|
options: DocumentOptions | undefined,
|
|
1134
1562
|
): DocumentOptions | undefined {
|
|
1135
1563
|
if (body.can_edit !== false) return options;
|
|
1136
|
-
return {
|
|
1564
|
+
return {
|
|
1565
|
+
...options,
|
|
1566
|
+
readOnly: true,
|
|
1567
|
+
readOnlyReason: body.read_only_reason === 'not_imported' ? 'not_imported' : 'grant',
|
|
1568
|
+
};
|
|
1137
1569
|
}
|
|
1138
1570
|
|
|
1139
1571
|
function isAtVersionResponse(
|
|
@@ -1166,11 +1598,156 @@ export function createOpenDocument(
|
|
|
1166
1598
|
// fourth one from silently dropping it.
|
|
1167
1599
|
const consented = new Set<string>();
|
|
1168
1600
|
|
|
1601
|
+
// What a document is opened with: the signed-in session, a share link, or
|
|
1602
|
+
// the edit link of a document started without an account. Every document
|
|
1603
|
+
// this factory returns is built by `materialiseAs`, which wires what the
|
|
1604
|
+
// credential reaches, so an open path cannot leave a capability out.
|
|
1605
|
+
type OpenCredential =
|
|
1606
|
+
| { kind: 'session' }
|
|
1607
|
+
| { kind: 'link'; token: string }
|
|
1608
|
+
| { kind: 'temporary'; token: string };
|
|
1609
|
+
const SESSION: OpenCredential = { kind: 'session' };
|
|
1610
|
+
|
|
1611
|
+
function inputsRuntimeFor(credential: OpenCredential, docId: string): InputsStatusRuntime | null {
|
|
1612
|
+
switch (credential.kind) {
|
|
1613
|
+
case 'session':
|
|
1614
|
+
return opts.inputsRuntime ? opts.inputsRuntime(docId) : null;
|
|
1615
|
+
case 'link':
|
|
1616
|
+
return {
|
|
1617
|
+
readKey: (key, query) =>
|
|
1618
|
+
getWithLink(
|
|
1619
|
+
credential.token,
|
|
1620
|
+
`/documents/${encodeURIComponent(docId)}/inputs/${encodeURIComponent(key)}`,
|
|
1621
|
+
query,
|
|
1622
|
+
),
|
|
1623
|
+
};
|
|
1624
|
+
case 'temporary':
|
|
1625
|
+
return TEMPORARY_INPUTS_RUNTIME;
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
// The join, the op write and the work-status read, through whichever
|
|
1630
|
+
// credential the document was opened with.
|
|
1631
|
+
// `rejoin` marks a read for a document this page already has open, which a
|
|
1632
|
+
// link meters apart from opens.
|
|
1633
|
+
async function joinAs(
|
|
1634
|
+
credential: OpenCredential,
|
|
1635
|
+
docId: string,
|
|
1636
|
+
options: OpenDocumentByIdOptions | undefined,
|
|
1637
|
+
{ rejoin = true }: { rejoin?: boolean } = {},
|
|
1638
|
+
): Promise<JoinAnswer> {
|
|
1639
|
+
return credential.kind === 'session'
|
|
1640
|
+
? fetchSnapshot(docId, options, await requireToken())
|
|
1641
|
+
: fetchLinkView(credential.token, options ?? {}, undefined, { rejoin });
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
async function postOpAs(
|
|
1645
|
+
credential: OpenCredential,
|
|
1646
|
+
docId: string,
|
|
1647
|
+
lineId: string,
|
|
1648
|
+
opBlobB64: string,
|
|
1649
|
+
clientOpId: string,
|
|
1650
|
+
base: { base_import_seq?: number },
|
|
1651
|
+
): Promise<{ status: number; parsed: unknown }> {
|
|
1652
|
+
if (credential.kind === 'session') {
|
|
1653
|
+
return postDocumentOp(docId, lineId, opBlobB64, clientOpId, await requireToken(), base);
|
|
1654
|
+
}
|
|
1655
|
+
return postLinkOp(credential.token, docId, lineId, opBlobB64, clientOpId, base);
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
async function readStatusAs(credential: OpenCredential, docId: string): Promise<unknown> {
|
|
1659
|
+
const headers: Record<string, string> = {};
|
|
1660
|
+
let url: string;
|
|
1661
|
+
if (credential.kind === 'session') {
|
|
1662
|
+
headers['authorization'] = `Bearer ${await requireToken()}`;
|
|
1663
|
+
applyClientVersionHeader(headers);
|
|
1664
|
+
await applyAppAssertionHeader(headers, opts.getAppAssertion);
|
|
1665
|
+
url = `${baseUrl}/documents/${encodeURIComponent(docId)}`;
|
|
1666
|
+
} else {
|
|
1667
|
+
Object.assign(headers, await linkViewHeaders());
|
|
1668
|
+
url = `${baseUrl}/links/${encodeURIComponent(credential.token)}/view?app_id=${encodeURIComponent(opts.appId)}&rejoin=1`;
|
|
1669
|
+
}
|
|
1670
|
+
const response = await fetch(url, { method: 'GET', headers });
|
|
1671
|
+
if (!response.ok) {
|
|
1672
|
+
throw new BrassError(`document read returned ${response.status}`, { status: response.status });
|
|
1673
|
+
}
|
|
1674
|
+
return response.json();
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
function wsClientFor(credential: OpenCredential): OpenDocumentWsClient | undefined {
|
|
1678
|
+
return credential.kind === 'session' ? opts.wsClient : (opts.linkWsClient?.(credential.token) ?? opts.wsClient);
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
async function materialiseAs(
|
|
1682
|
+
credential: OpenCredential,
|
|
1683
|
+
...args: Parameters<typeof materialise>
|
|
1684
|
+
): Promise<OpenedDocument> {
|
|
1685
|
+
const doc = await materialise(...args);
|
|
1686
|
+
doc._setInputsRuntime(inputsRuntimeFor(credential, args[0].doc_id));
|
|
1687
|
+
return doc;
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
// A join. One that will write and takes nothing else waits while the
|
|
1691
|
+
// platform converts the document: the join is refused with how long is
|
|
1692
|
+
// left, and asked again then, for as long as that takes. The open's
|
|
1693
|
+
// progress sink hears `preparing` on each wait, and its `false` ends the
|
|
1694
|
+
// wait with a rejection.
|
|
1169
1695
|
async function fetchSnapshot(
|
|
1170
1696
|
docId: string,
|
|
1171
1697
|
options: OpenDocumentByIdOptions | undefined,
|
|
1172
1698
|
token: string,
|
|
1173
|
-
): Promise<
|
|
1699
|
+
): Promise<JoinAnswer> {
|
|
1700
|
+
if (opensReadOnly(options)) {
|
|
1701
|
+
return joinOnce(docId, options, token);
|
|
1702
|
+
}
|
|
1703
|
+
// The first join spends the token the caller holds; a re-join after a
|
|
1704
|
+
// long wait asks for a current one.
|
|
1705
|
+
let held: string | null = token;
|
|
1706
|
+
return waitWhileConverting(async () => {
|
|
1707
|
+
const t = held ?? (await requireToken());
|
|
1708
|
+
held = null;
|
|
1709
|
+
return joinOnce(docId, options, t);
|
|
1710
|
+
}, options?.onSnapshotProgress);
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
async function waitWhileConverting(
|
|
1714
|
+
join: () => Promise<JoinAnswer>,
|
|
1715
|
+
progress: LoadProgress | undefined,
|
|
1716
|
+
): Promise<JoinAnswer> {
|
|
1717
|
+
const deadline = Date.now() + OPEN_WAIT_CEILING_MS;
|
|
1718
|
+
let answer = await join();
|
|
1719
|
+
let failures = 0;
|
|
1720
|
+
for (;;) {
|
|
1721
|
+
const refused = joinRefusalOf(answer);
|
|
1722
|
+
if (refused?.kind !== 'converting') return answer;
|
|
1723
|
+
if (progress?.(0, null, 'preparing') === false) {
|
|
1724
|
+
throw new BrassError(OPEN_SUPERSEDED_MESSAGE, { status: 0 });
|
|
1725
|
+
}
|
|
1726
|
+
if (Date.now() >= deadline) throw new BrassError(OPEN_WAIT_EXPIRED_MESSAGE, { status: 0 });
|
|
1727
|
+
let delay =
|
|
1728
|
+
refused.retryAfterMs === undefined ? CONVERSION_POLL_MIN_MS : heldJoinDelay(refused.retryAfterMs);
|
|
1729
|
+
for (;;) {
|
|
1730
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
1731
|
+
try {
|
|
1732
|
+
answer = await join();
|
|
1733
|
+
failures = 0;
|
|
1734
|
+
break;
|
|
1735
|
+
} catch (err) {
|
|
1736
|
+
// The document is known to exist and to be converting, so a join
|
|
1737
|
+
// that fails outright is the connection rather than the answer.
|
|
1738
|
+
failures += 1;
|
|
1739
|
+
if (failures >= CONVERSION_JOIN_FAILURES) throw err;
|
|
1740
|
+
delay = Math.min(CONVERSION_POLL_MIN_MS * 2 ** (failures - 1), CONVERSION_POLL_MAX_MS);
|
|
1741
|
+
}
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
async function joinOnce(
|
|
1747
|
+
docId: string,
|
|
1748
|
+
options: OpenDocumentByIdOptions | undefined,
|
|
1749
|
+
token: string,
|
|
1750
|
+
): Promise<JoinAnswer> {
|
|
1174
1751
|
const search = new URLSearchParams();
|
|
1175
1752
|
search.set('include', 'snapshot');
|
|
1176
1753
|
if (options?.filter !== undefined) {
|
|
@@ -1184,16 +1761,46 @@ export function createOpenDocument(
|
|
|
1184
1761
|
}
|
|
1185
1762
|
if (options?.acceptSchemaConflict === true) consented.add(docId);
|
|
1186
1763
|
if (consented.has(docId)) search.set('accept_schema_conflict', '1');
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1764
|
+
// A caller that will not write asks for the document as it is stored. It
|
|
1765
|
+
// matters on a document the platform holds as a record set: serving one
|
|
1766
|
+
// to a reader is the point of storing one, and serving it to a writer
|
|
1767
|
+
// means converting it to a CRDT first, which the server does and this
|
|
1768
|
+
// reader has no reason to wait for.
|
|
1769
|
+
if (opensReadOnly(options)) search.set('intent', 'read');
|
|
1770
|
+
return sendJoin(`/documents/${encodeURIComponent(docId)}`, search, {
|
|
1771
|
+
headers: await memberHeaders(token),
|
|
1772
|
+
perf: 'open: GET /documents (http + read body)',
|
|
1773
|
+
...(options?.onSnapshotProgress !== undefined ? { progress: options.onSnapshotProgress } : {}),
|
|
1774
|
+
});
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
// Every join's request: the stored frames this build decodes (a client that
|
|
1778
|
+
// does not name them is served the CRDT), the answer parsed, and its
|
|
1779
|
+
// spilled tail ops resolved. A reader holding a CRDT names the frames too:
|
|
1780
|
+
// the line as stored is the cheapest answer to serve it, and `applyJoin`
|
|
1781
|
+
// takes either form in place.
|
|
1782
|
+
async function sendJoin(
|
|
1783
|
+
path: string,
|
|
1784
|
+
search: URLSearchParams,
|
|
1785
|
+
{
|
|
1786
|
+
headers,
|
|
1787
|
+
perf,
|
|
1788
|
+
progress,
|
|
1789
|
+
}: {
|
|
1790
|
+
headers?: Record<string, string>;
|
|
1791
|
+
perf?: string;
|
|
1792
|
+
progress?: LoadProgress;
|
|
1793
|
+
},
|
|
1794
|
+
): Promise<JoinAnswer> {
|
|
1795
|
+
search.set('accept', SEALED_ACCEPT);
|
|
1796
|
+
const qs = search.toString();
|
|
1797
|
+
const url = `${baseUrl}${path}${qs ? `?${qs}` : ''}`;
|
|
1798
|
+
const endFetch = perf !== undefined ? perfStart(perf) : null;
|
|
1192
1799
|
// Idempotent read: ride out a transient 5xx / 429 instead of failing the
|
|
1193
1800
|
// open on something that clears on its own.
|
|
1194
1801
|
//
|
|
1195
1802
|
// Longer than the default handful of attempts because of what a 503 means
|
|
1196
|
-
// on
|
|
1803
|
+
// on a join: the document is intact and its storage is busy. A
|
|
1197
1804
|
// document's own writes contend with each other, so the one most likely
|
|
1198
1805
|
// to answer 503 is one that was just written hard, which is exactly the
|
|
1199
1806
|
// moment a person reloads to see their work. That pressure clears over
|
|
@@ -1207,15 +1814,18 @@ export function createOpenDocument(
|
|
|
1207
1814
|
// answered the first, with the reader watching a loading surface
|
|
1208
1815
|
// throughout.
|
|
1209
1816
|
const response = await fetchWithRetry(
|
|
1210
|
-
() =>
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1817
|
+
() => fetch(url, { method: 'GET', ...(headers !== undefined ? { headers } : {}) }),
|
|
1818
|
+
{
|
|
1819
|
+
maxAttempts: 7,
|
|
1820
|
+
baseDelayMs: 700,
|
|
1821
|
+
maxNetworkAttempts: 3,
|
|
1822
|
+
// A conversion's refusal says how long to wait, and the caller waits
|
|
1823
|
+
// that long rather than this backoff's second or two.
|
|
1824
|
+
isFinal: namesConversionWait,
|
|
1825
|
+
},
|
|
1216
1826
|
);
|
|
1217
1827
|
const text = await response.text();
|
|
1218
|
-
endFetch({ status: response.status, responseChars: text.length });
|
|
1828
|
+
endFetch?.({ status: response.status, responseChars: text.length });
|
|
1219
1829
|
let parsed: unknown = undefined;
|
|
1220
1830
|
let parseFailed = false;
|
|
1221
1831
|
if (text) {
|
|
@@ -1225,44 +1835,34 @@ export function createOpenDocument(
|
|
|
1225
1835
|
parseFailed = true;
|
|
1226
1836
|
}
|
|
1227
1837
|
}
|
|
1228
|
-
|
|
1838
|
+
// A tail op whose bytes spilled to storage arrives as a signed URL, which
|
|
1839
|
+
// carries its own signature, so a guest resolves it as a member does.
|
|
1840
|
+
await resolveParsedTailOps(parsed, progress);
|
|
1229
1841
|
return { status: response.status, parsed, parseFailed };
|
|
1230
1842
|
}
|
|
1231
1843
|
|
|
1844
|
+
async function memberHeaders(token: string): Promise<Record<string, string>> {
|
|
1845
|
+
const headers: Record<string, string> = { authorization: `Bearer ${token}` };
|
|
1846
|
+
applyClientVersionHeader(headers);
|
|
1847
|
+
await applyAppAssertionHeader(headers, opts.getAppAssertion);
|
|
1848
|
+
return headers;
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1232
1851
|
// `GET /documents/{docId}/at/{version}`: read-only materialization at a
|
|
1233
|
-
// historical version.
|
|
1852
|
+
// historical version.
|
|
1234
1853
|
async function fetchAtVersion(
|
|
1235
1854
|
docId: string,
|
|
1236
1855
|
version: number,
|
|
1237
1856
|
branch: string | undefined,
|
|
1238
1857
|
token: string,
|
|
1239
|
-
): Promise<
|
|
1858
|
+
): Promise<JoinAnswer> {
|
|
1240
1859
|
const search = new URLSearchParams();
|
|
1241
1860
|
if (branch !== undefined) search.set('branch', branch);
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
const headers: Record<string, string> = { authorization: `Bearer ${token}` };
|
|
1248
|
-
applyClientVersionHeader(headers);
|
|
1249
|
-
await applyAppAssertionHeader(headers, opts.getAppAssertion);
|
|
1250
|
-
const response = await fetch(url, {
|
|
1251
|
-
method: 'GET',
|
|
1252
|
-
headers,
|
|
1253
|
-
});
|
|
1254
|
-
const text = await response.text();
|
|
1255
|
-
let parsed: unknown = undefined;
|
|
1256
|
-
let parseFailed = false;
|
|
1257
|
-
if (text) {
|
|
1258
|
-
try {
|
|
1259
|
-
parsed = JSON.parse(text);
|
|
1260
|
-
} catch {
|
|
1261
|
-
parseFailed = true;
|
|
1262
|
-
}
|
|
1263
|
-
}
|
|
1264
|
-
await resolveParsedTailOps(parsed);
|
|
1265
|
-
return { status: response.status, parsed, parseFailed };
|
|
1861
|
+
return sendJoin(
|
|
1862
|
+
`/documents/${encodeURIComponent(docId)}/at/${encodeURIComponent(String(version))}`,
|
|
1863
|
+
search,
|
|
1864
|
+
{ headers: await memberHeaders(token) },
|
|
1865
|
+
);
|
|
1266
1866
|
}
|
|
1267
1867
|
|
|
1268
1868
|
// The historical-materialise fetcher `doc._checkoutVersion(v)` calls back
|
|
@@ -1271,16 +1871,7 @@ export function createOpenDocument(
|
|
|
1271
1871
|
// `stream_seqs` in the response (present only on a default/branch preview
|
|
1272
1872
|
// that resolved a checkpoint, absent on a line's own preview) rides
|
|
1273
1873
|
// through so the parent can preview every opened stream at the same point.
|
|
1274
|
-
function makeCheckoutFetcher():
|
|
1275
|
-
id: string,
|
|
1276
|
-
version: number,
|
|
1277
|
-
branchId: string,
|
|
1278
|
-
) => Promise<{
|
|
1279
|
-
snapshotBytes: Uint8Array | null;
|
|
1280
|
-
tailOpBlobs: Uint8Array[];
|
|
1281
|
-
effectiveVersion: number;
|
|
1282
|
-
streamSeqs?: Record<string, number>;
|
|
1283
|
-
}> {
|
|
1874
|
+
function makeCheckoutFetcher(): CheckoutFetcher {
|
|
1284
1875
|
return async (id, version, branchId) => {
|
|
1285
1876
|
const t = await requireToken();
|
|
1286
1877
|
const result = await fetchAtVersion(id, version, branchId, t);
|
|
@@ -1295,26 +1886,9 @@ export function createOpenDocument(
|
|
|
1295
1886
|
status: 500,
|
|
1296
1887
|
});
|
|
1297
1888
|
}
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
if (snap.kind === 'inline') {
|
|
1302
|
-
snapshotBytes = base64ToBytes(snap.data_b64);
|
|
1303
|
-
} else if (snap.kind === 'presigned') {
|
|
1304
|
-
const snapResponse = await fetch(snap.url, { method: 'GET' });
|
|
1305
|
-
if (!snapResponse.ok) {
|
|
1306
|
-
throw new BrassError('Presigned snapshot fetch failed', {
|
|
1307
|
-
status: snapResponse.status,
|
|
1308
|
-
});
|
|
1309
|
-
}
|
|
1310
|
-
const buf = await snapResponse.arrayBuffer();
|
|
1311
|
-
snapshotBytes = new Uint8Array(buf);
|
|
1312
|
-
} else {
|
|
1313
|
-
// A delivery kind this build does not implement; refuse rather than
|
|
1314
|
-
// preview an empty version (same rationale as `materialise`).
|
|
1315
|
-
throw snapshotKindTooNewError();
|
|
1316
|
-
}
|
|
1317
|
-
}
|
|
1889
|
+
const content = await decodeJoinContent(result.parsed.snapshot, (url) =>
|
|
1890
|
+
fetchPresignedSnapshot(url),
|
|
1891
|
+
);
|
|
1318
1892
|
const tailOpBlobs = result.parsed.tail_ops.map((entry) =>
|
|
1319
1893
|
base64ToBytes(entry.op_blob_b64),
|
|
1320
1894
|
);
|
|
@@ -1328,7 +1902,7 @@ export function createOpenDocument(
|
|
|
1328
1902
|
if (Object.keys(parsed).length > 0) streamSeqs = parsed;
|
|
1329
1903
|
}
|
|
1330
1904
|
return {
|
|
1331
|
-
|
|
1905
|
+
content,
|
|
1332
1906
|
tailOpBlobs,
|
|
1333
1907
|
effectiveVersion: result.parsed.effective_version,
|
|
1334
1908
|
...(streamSeqs ? { streamSeqs } : {}),
|
|
@@ -1352,8 +1926,8 @@ export function createOpenDocument(
|
|
|
1352
1926
|
if (typeof serverMessage === 'string' && serverMessage.length > 0) {
|
|
1353
1927
|
return serverMessage;
|
|
1354
1928
|
}
|
|
1355
|
-
if (status === 403) return 'You do not have permission to
|
|
1356
|
-
if (status === 404) return 'This
|
|
1929
|
+
if (status === 403) return 'You do not have permission to make changes here.';
|
|
1930
|
+
if (status === 404) return 'This is no longer available.';
|
|
1357
1931
|
return `This change could not be saved (${status}).`;
|
|
1358
1932
|
}
|
|
1359
1933
|
|
|
@@ -1364,27 +1938,30 @@ export function createOpenDocument(
|
|
|
1364
1938
|
// outstanding tracker and re-asserts it, and a blind replay would write a
|
|
1365
1939
|
// second stored copy of the same edit.
|
|
1366
1940
|
// Mint a presigned PUT and upload the blob to it, returning the staging
|
|
1367
|
-
// key the op write names.
|
|
1941
|
+
// key the op write names. A share link mints on the same route with the
|
|
1942
|
+
// link as the credential. The PUT carries no Brass headers: the URL is
|
|
1368
1943
|
// signed, and an extra header falls outside that signature.
|
|
1369
1944
|
async function stageOpBlob(
|
|
1370
1945
|
docId: string,
|
|
1371
1946
|
opBlobB64: string,
|
|
1372
|
-
|
|
1947
|
+
credential: { bearer: string } | { link: string },
|
|
1373
1948
|
): Promise<string> {
|
|
1374
1949
|
// Everything in here happens before the op write, so every failure it
|
|
1375
1950
|
// can take is tagged as one: what the tag buys the writer is the right
|
|
1376
1951
|
// to send the op again, which a failure of the write itself does not
|
|
1377
1952
|
// carry.
|
|
1378
1953
|
try {
|
|
1379
|
-
const headers: Record<string, string> = {
|
|
1380
|
-
|
|
1381
|
-
'
|
|
1382
|
-
}
|
|
1383
|
-
|
|
1384
|
-
|
|
1954
|
+
const headers: Record<string, string> = { 'content-type': 'application/json' };
|
|
1955
|
+
if ('link' in credential) {
|
|
1956
|
+
headers['authorization'] = `Link ${credential.link}`;
|
|
1957
|
+
} else {
|
|
1958
|
+
headers['authorization'] = `Bearer ${credential.bearer}`;
|
|
1959
|
+
applyClientVersionHeader(headers);
|
|
1960
|
+
await applyAppAssertionHeader(headers, opts.getAppAssertion);
|
|
1961
|
+
}
|
|
1385
1962
|
const mint = await fetchWithDeadline(
|
|
1386
1963
|
`${baseUrl}/documents/${encodeURIComponent(docId)}/ops/upload`,
|
|
1387
|
-
{ method: 'POST', headers, body:
|
|
1964
|
+
{ method: 'POST', headers, body: JSON.stringify({ byte_size: opBlobB64.length }) },
|
|
1388
1965
|
OP_WRITE_TIMEOUT_MS,
|
|
1389
1966
|
);
|
|
1390
1967
|
let minted: unknown = undefined;
|
|
@@ -1427,6 +2004,7 @@ export function createOpenDocument(
|
|
|
1427
2004
|
opBlobB64: string,
|
|
1428
2005
|
clientOpId: string,
|
|
1429
2006
|
token: string,
|
|
2007
|
+
base: { base_import_seq?: number } = {},
|
|
1430
2008
|
): Promise<{ status: number; parsed: unknown }> {
|
|
1431
2009
|
const headers: Record<string, string> = {
|
|
1432
2010
|
authorization: `Bearer ${token}`,
|
|
@@ -1439,7 +2017,7 @@ export function createOpenDocument(
|
|
|
1439
2017
|
// through a request body.
|
|
1440
2018
|
let stagedKey: string | undefined;
|
|
1441
2019
|
if (opBlobNeedsStaging(opBlobB64)) {
|
|
1442
|
-
stagedKey = await stageOpBlob(docId, opBlobB64, token);
|
|
2020
|
+
stagedKey = await stageOpBlob(docId, opBlobB64, { bearer: token });
|
|
1443
2021
|
}
|
|
1444
2022
|
const endFetch = perfStart('op: POST /documents/{id}/ops');
|
|
1445
2023
|
const response = await fetchWithDeadline(
|
|
@@ -1453,6 +2031,7 @@ export function createOpenDocument(
|
|
|
1453
2031
|
: { upload_key: stagedKey }),
|
|
1454
2032
|
branch_id: branchId,
|
|
1455
2033
|
client_op_id: clientOpId,
|
|
2034
|
+
...base,
|
|
1456
2035
|
}),
|
|
1457
2036
|
},
|
|
1458
2037
|
// An op under the staging threshold travels inline, so this request
|
|
@@ -1617,24 +2196,27 @@ export function createOpenDocument(
|
|
|
1617
2196
|
async function openStreamLine(
|
|
1618
2197
|
docId: string,
|
|
1619
2198
|
lineId: string,
|
|
2199
|
+
// The parent document's open, whose read intent every line of it shares.
|
|
2200
|
+
parentOptions: OpenDocumentByIdOptions | undefined,
|
|
2201
|
+
credential: OpenCredential = SESSION,
|
|
1620
2202
|
): Promise<Document> {
|
|
1621
|
-
const
|
|
1622
|
-
const
|
|
1623
|
-
|
|
1624
|
-
{ branch: lineId },
|
|
1625
|
-
token,
|
|
1626
|
-
);
|
|
1627
|
-
if (status < 200 || status >= 300) {
|
|
1628
|
-
throw brassErrorFromBody(status, parseFailed ? undefined : parsed);
|
|
1629
|
-
}
|
|
1630
|
-
if (!isSnapshotResponse(parsed)) {
|
|
1631
|
-
throw new BrassError('Malformed document snapshot response', {
|
|
1632
|
-
status: 500,
|
|
1633
|
-
});
|
|
1634
|
-
}
|
|
2203
|
+
const lineOptions = rejoinOptions(lineId, parentOptions);
|
|
2204
|
+
const result = await joinAs(credential, docId, lineOptions, { rejoin: false });
|
|
2205
|
+
const parsed = unwrapJoinResponse(result, isSnapshotResponse, 'snapshot');
|
|
1635
2206
|
// A stream's write authority is the document's, so the line opens
|
|
1636
|
-
// read-only for the same reader the document opens read-only for
|
|
1637
|
-
|
|
2207
|
+
// read-only for the same reader the document opens read-only for, and for
|
|
2208
|
+
// a caller who asked for a read-only document.
|
|
2209
|
+
const asked = parentOptions?.documentOptions;
|
|
2210
|
+
const doc = await materialiseAs(
|
|
2211
|
+
credential,
|
|
2212
|
+
parsed,
|
|
2213
|
+
readOnlyForGrant(
|
|
2214
|
+
parsed,
|
|
2215
|
+
asked?.readOnly === true
|
|
2216
|
+
? { readOnly: true, readOnlyReason: asked.readOnlyReason ?? 'snapshot' }
|
|
2217
|
+
: undefined,
|
|
2218
|
+
),
|
|
2219
|
+
);
|
|
1638
2220
|
// A stream line materializes at a historical seq the same way the
|
|
1639
2221
|
// default line does, so the parent's version preview can bring this
|
|
1640
2222
|
// stream to a point in time (`doc.stream(name)._previewAt(seq)`). The
|
|
@@ -1697,24 +2279,15 @@ export function createOpenDocument(
|
|
|
1697
2279
|
do {
|
|
1698
2280
|
lineResyncAgain = false;
|
|
1699
2281
|
try {
|
|
1700
|
-
const
|
|
1701
|
-
const res = await fetchSnapshot(docId, { branch: lineId }, t);
|
|
2282
|
+
const res = await joinAs(credential, docId, lineOptions);
|
|
1702
2283
|
if (res.status < 200 || res.status >= 300) continue;
|
|
1703
2284
|
if (!isSnapshotResponse(res.parsed)) continue;
|
|
1704
|
-
const
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
doc._applySnapshot(bytes);
|
|
1711
|
-
}
|
|
1712
|
-
const seqs: number[] = [];
|
|
1713
|
-
for (const entry of res.parsed.tail_ops) {
|
|
1714
|
-
doc.applyRemoteOp(base64ToBytes(entry.op_blob_b64));
|
|
1715
|
-
if (entry.seq > currentSeq) currentSeq = entry.seq;
|
|
1716
|
-
seqs.push(entry.seq);
|
|
1717
|
-
}
|
|
2285
|
+
const applied = await applyJoin(doc, res.parsed, (url) =>
|
|
2286
|
+
fetchPresignedSnapshot(url),
|
|
2287
|
+
);
|
|
2288
|
+
if (applied.kind !== 'applied') return;
|
|
2289
|
+
const seqs = applied.tailSeqs;
|
|
2290
|
+
for (const seq of seqs) if (seq > currentSeq) currentSeq = seq;
|
|
1718
2291
|
const joined = res.parsed.current_seq;
|
|
1719
2292
|
if (typeof joined === 'number' && joined > currentSeq) {
|
|
1720
2293
|
currentSeq = joined;
|
|
@@ -1750,13 +2323,13 @@ export function createOpenDocument(
|
|
|
1750
2323
|
opBlobB64: string,
|
|
1751
2324
|
clientOpId: string,
|
|
1752
2325
|
): Promise<OversizedOpResult> => {
|
|
1753
|
-
const
|
|
1754
|
-
|
|
2326
|
+
const { status, parsed: opResult } = await postOpAs(
|
|
2327
|
+
credential,
|
|
1755
2328
|
docId,
|
|
1756
2329
|
lineId,
|
|
1757
2330
|
opBlobB64,
|
|
1758
2331
|
clientOpId,
|
|
1759
|
-
|
|
2332
|
+
baseImportSeqField(doc),
|
|
1760
2333
|
);
|
|
1761
2334
|
if (status < 200 || status >= 300) {
|
|
1762
2335
|
return {
|
|
@@ -1764,6 +2337,9 @@ export function createOpenDocument(
|
|
|
1764
2337
|
message: opWriteFailureMessage(status, opResult),
|
|
1765
2338
|
retryable: opWriteIsRetryable(status),
|
|
1766
2339
|
atCapacity: isCapacityRefusal(status, opResult),
|
|
2340
|
+
...(isMovedRefusal(opResult) ? { moved: true } : {}),
|
|
2341
|
+
...(isNotImportedRefusal(opResult) ? { notImported: true } : {}),
|
|
2342
|
+
...(status === 413 ? { tooLarge: true } : {}),
|
|
1767
2343
|
// The route answers 403 when the caller's grant carries no write
|
|
1768
2344
|
// access, which is the HTTP face of the socket's `role_denied`.
|
|
1769
2345
|
...(status === 403 ? { readOnly: 'grant' as const } : {}),
|
|
@@ -1780,13 +2356,33 @@ export function createOpenDocument(
|
|
|
1780
2356
|
return { ok: true, seq };
|
|
1781
2357
|
};
|
|
1782
2358
|
|
|
1783
|
-
|
|
2359
|
+
// The durability handshake's ground truth for this line, read from the
|
|
2360
|
+
// line's own join so the diff is against this stream's frontier.
|
|
2361
|
+
const refetchDurableFrontier = async (): Promise<{
|
|
2362
|
+
snapshotVersionVector?: string;
|
|
2363
|
+
tailBlobs: Uint8Array[];
|
|
2364
|
+
} | null> => {
|
|
2365
|
+
const res = await joinAs(credential, docId, lineOptions);
|
|
2366
|
+
if (res.status < 200 || res.status >= 300) return null;
|
|
2367
|
+
if (!isSnapshotResponse(res.parsed)) return null;
|
|
2368
|
+
const out: { snapshotVersionVector?: string; tailBlobs: Uint8Array[] } = {
|
|
2369
|
+
tailBlobs: res.parsed.tail_ops.map((entry) => base64ToBytes(entry.op_blob_b64)),
|
|
2370
|
+
};
|
|
2371
|
+
if (typeof res.parsed.snapshot_version_vector === 'string') {
|
|
2372
|
+
out.snapshotVersionVector = res.parsed.snapshot_version_vector;
|
|
2373
|
+
}
|
|
2374
|
+
return out;
|
|
2375
|
+
};
|
|
2376
|
+
|
|
2377
|
+
const lineWsClient = wsClientFor(credential);
|
|
2378
|
+
if (lineWsClient) {
|
|
1784
2379
|
wireDocTransport({
|
|
1785
2380
|
doc,
|
|
1786
2381
|
docId,
|
|
1787
2382
|
branchId: lineId,
|
|
1788
|
-
wsClient:
|
|
2383
|
+
wsClient: lineWsClient,
|
|
1789
2384
|
postOversizedOp,
|
|
2385
|
+
refetchDurableFrontier,
|
|
1790
2386
|
newClientOpId: opts.newClientOpId ?? defaultClientOpIdMinter,
|
|
1791
2387
|
getCurrentSeq: () => currentSeq,
|
|
1792
2388
|
setCurrentSeq: (next) => {
|
|
@@ -1803,42 +2399,465 @@ export function createOpenDocument(
|
|
|
1803
2399
|
return doc;
|
|
1804
2400
|
}
|
|
1805
2401
|
|
|
1806
|
-
|
|
1807
|
-
|
|
2402
|
+
/**
|
|
2403
|
+
* A snapshot-only open: the ordinary join, materialised read-only, with no
|
|
2404
|
+
* transport wired.
|
|
2405
|
+
*
|
|
2406
|
+
* It reads the JOIN rather than the at-version route even though the two
|
|
2407
|
+
* envelopes match, because the join is what serves a record set as itself.
|
|
2408
|
+
* The at-version route materialises a LoroDoc to replay a tail onto, so it
|
|
2409
|
+
* converts a record set before answering; a reader asking for the document
|
|
2410
|
+
* as it stands has no tail to replay and would pay that conversion for
|
|
2411
|
+
* nothing.
|
|
2412
|
+
*
|
|
2413
|
+
* `readOnly` is what the caller asked for rather than a fact about their
|
|
2414
|
+
* grant, which is why it carries its own reason: an app that disables its
|
|
2415
|
+
* editor on `doc.readOnly` is right to, and the message a refused `mutate`
|
|
2416
|
+
* throws should say the caller opened a snapshot rather than blame a
|
|
2417
|
+
* permission they may well hold.
|
|
2418
|
+
*/
|
|
2419
|
+
async function openSnapshotOnly(
|
|
1808
2420
|
snapshot: { status: number; parsed: unknown; parseFailed: boolean },
|
|
1809
|
-
options: OpenDocumentByIdOptions
|
|
2421
|
+
options: OpenDocumentByIdOptions,
|
|
1810
2422
|
): Promise<OpenedDocument> {
|
|
1811
|
-
const
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
2423
|
+
const parsed = unwrapJoinResponse(snapshot, isSnapshotResponse, 'snapshot');
|
|
2424
|
+
return openParsedSnapshot(parsed, options);
|
|
2425
|
+
}
|
|
2426
|
+
|
|
2427
|
+
async function openParsedSnapshot(
|
|
2428
|
+
parsed: DocumentSnapshotResponse,
|
|
2429
|
+
options: OpenDocumentByIdOptions,
|
|
2430
|
+
): Promise<OpenedDocument> {
|
|
2431
|
+
// The grant's own reason wins where there is one. A viewer who asks for a
|
|
2432
|
+
// snapshot cannot edit either way, and telling them to reopen without
|
|
2433
|
+
// asking for one would be advice that does not work: the refusal has to
|
|
2434
|
+
// name the limit they actually have.
|
|
2435
|
+
const byGrant = readOnlyForGrant(parsed, options.documentOptions);
|
|
2436
|
+
const doc = await materialiseAs(
|
|
2437
|
+
SESSION,
|
|
1819
2438
|
parsed,
|
|
1820
|
-
|
|
1821
|
-
|
|
2439
|
+
{
|
|
2440
|
+
...byGrant,
|
|
2441
|
+
readOnly: true,
|
|
2442
|
+
readOnlyReason: byGrant?.readOnlyReason ?? 'snapshot',
|
|
2443
|
+
},
|
|
2444
|
+
options.onSnapshotProgress,
|
|
2445
|
+
{ emptyAsFrozen: true },
|
|
1822
2446
|
);
|
|
1823
|
-
//
|
|
1824
|
-
//
|
|
1825
|
-
//
|
|
1826
|
-
|
|
1827
|
-
//
|
|
1828
|
-
//
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
doc
|
|
1840
|
-
|
|
1841
|
-
|
|
2447
|
+
// What one load brought: the point it was taken at, fixed until the next
|
|
2448
|
+
// load. A live open's provider is the transport's cursor and a
|
|
2449
|
+
// version-pinned one answers from its checkout, so this is what makes
|
|
2450
|
+
// `doc.currentSeq` mean the same thing on all three: the version the
|
|
2451
|
+
// reader is looking at. The change notice compares the document's head
|
|
2452
|
+
// against it.
|
|
2453
|
+
const takeLoad = (load: DocumentSnapshotResponse): void => {
|
|
2454
|
+
const takenAt = load.current_seq;
|
|
2455
|
+
if (typeof takenAt === 'number') doc.setCurrentSeqProvider(() => takenAt);
|
|
2456
|
+
// Work the join reported over the content it served: work that does not
|
|
2457
|
+
// lock readers (a push), or a new version's import that failed and left
|
|
2458
|
+
// the content in place. A join carries a status only while work is in
|
|
2459
|
+
// flight, so one with none after a pending load is the work having ended.
|
|
2460
|
+
if (load.status !== undefined) doc._emitStatus(load.status);
|
|
2461
|
+
else if (doc._workStatusNow?.pending === true) doc._emitStatus(WORK_READY_STATUS, { contentLoaded: true });
|
|
2462
|
+
doc._setImportReport(importReportOf(load));
|
|
2463
|
+
setJoinStamp(doc, (load as { updated_at?: unknown }).updated_at);
|
|
2464
|
+
setJoinedName(doc, load);
|
|
2465
|
+
watchFrom(load);
|
|
2466
|
+
};
|
|
2467
|
+
// An import is stored frozen, so a frozen open of a file nobody has
|
|
2468
|
+
// imported yet runs the app's importer the way an editable open does.
|
|
2469
|
+
doc._awaitsImport = parsed.awaits_import === true;
|
|
2470
|
+
if (opts.sourceBlobFetcher) {
|
|
2471
|
+
const fetcher = opts.sourceBlobFetcher;
|
|
2472
|
+
doc.setSourceBlobFetcher(() => fetcher(doc));
|
|
2473
|
+
}
|
|
2474
|
+
// A document's other streams open as frozen copies of their own lines,
|
|
2475
|
+
// read the same way and watched on their own topics.
|
|
2476
|
+
doc.setOpenedStreamName(options.stream ?? DEFAULT_STREAM_NAME);
|
|
2477
|
+
if (opts.streamProvider) {
|
|
2478
|
+
const provider = opts.streamProvider;
|
|
2479
|
+
const docId = parsed.doc_id;
|
|
2480
|
+
const openedBranchId = parsed.stream_branch_id ?? parsed.branch_id;
|
|
2481
|
+
doc.setStreamRuntime({
|
|
2482
|
+
list: async () => (await provider.list(docId, openedBranchId)).streams,
|
|
2483
|
+
create: () => Promise.reject(new Error('A read-only copy cannot create a stream.')),
|
|
2484
|
+
openLine: (lineId) => openFrozen(docId, frozenLineOptions(lineId)),
|
|
2485
|
+
});
|
|
2486
|
+
}
|
|
2487
|
+
const ws = opts.wsClient;
|
|
2488
|
+
const watchFrom = (load: DocumentSnapshotResponse): void => {
|
|
2489
|
+
if (!ws) return;
|
|
2490
|
+
const topic = `doc:${load.doc_id}:${load.branch_id}`;
|
|
2491
|
+
const at = typeof load.current_seq === 'number' ? load.current_seq : 0;
|
|
2492
|
+
// The copy reports the work that was in flight when it loaded, through to
|
|
2493
|
+
// its outcome, and nothing that starts afterwards: later work describes a
|
|
2494
|
+
// version of the document this copy does not hold.
|
|
2495
|
+
let following = load.status?.pending === true;
|
|
2496
|
+
doc._setChangeNotice((fire) =>
|
|
2497
|
+
ws.subscribe(
|
|
2498
|
+
topic,
|
|
2499
|
+
(frame) => {
|
|
2500
|
+
if (following && isDocStatusFrame(frame)) {
|
|
2501
|
+
const status = normalizeWorkStatus(frame.status);
|
|
2502
|
+
if (!status.pending) following = false;
|
|
2503
|
+
doc._emitStatus(status);
|
|
2504
|
+
doc._settleChangeNotice();
|
|
2505
|
+
}
|
|
2506
|
+
if (frameMovesDocument(frame, at)) fire();
|
|
2507
|
+
},
|
|
2508
|
+
{
|
|
2509
|
+
notice: true,
|
|
2510
|
+
onSubscribed: ({ currentSeq }) => {
|
|
2511
|
+
if (currentSeq !== undefined && currentSeq > at) fire();
|
|
2512
|
+
// Work that ends between the join and this subscribe sent its
|
|
2513
|
+
// frame to nobody, and may or may not have moved the seq, so a
|
|
2514
|
+
// copy taken mid-work asks the row whether it is still running.
|
|
2515
|
+
if (load.status?.pending !== true) return;
|
|
2516
|
+
void readFrozenWorkStatus(load.doc_id).then((now) => {
|
|
2517
|
+
if (now === 'unread' || now?.pending === true) return;
|
|
2518
|
+
doc._emitStatus(now ?? { pending: false, message: '' });
|
|
2519
|
+
fire();
|
|
2520
|
+
});
|
|
2521
|
+
},
|
|
2522
|
+
},
|
|
2523
|
+
),
|
|
2524
|
+
);
|
|
2525
|
+
};
|
|
2526
|
+
takeLoad(parsed);
|
|
2527
|
+
// A frozen copy loads again only when the app asks. The join keeps the
|
|
2528
|
+
// open's read intent and names the stored frames whatever the copy holds
|
|
2529
|
+
// now, and the result applies in place (`applyJoin`) under the same
|
|
2530
|
+
// handles, switching between frozen content and a CRDT when the line has.
|
|
2531
|
+
const openedId = parsed.doc_id;
|
|
2532
|
+
//
|
|
2533
|
+
// A reload follows the open's rule: it waits out an import in flight and
|
|
2534
|
+
// loads what the import landed, and rejects (keeping what the copy holds)
|
|
2535
|
+
// when the import failed with nothing landed or the file is one only
|
|
2536
|
+
// another app imports. The open's progress sink belongs to the open, so
|
|
2537
|
+
// the reload's wait reports to none.
|
|
2538
|
+
const { onSnapshotProgress: _openProgress, ...reloadOptions } = options;
|
|
2539
|
+
doc._setFrozenReloader(async () => {
|
|
2540
|
+
const join = (): Promise<JoinAnswer> =>
|
|
2541
|
+
paceFrozen(async () => fetchSnapshot(openedId, reloadOptions, await requireToken()));
|
|
2542
|
+
const snapshot = await joinPastWork(openedId, reloadOptions, await join(), join);
|
|
2543
|
+
const load = unwrapJoinResponse(snapshot, isSnapshotResponse, 'snapshot');
|
|
2544
|
+
const applied = await applyJoin(doc, load, (url) => fetchPresignedSnapshot(url));
|
|
2545
|
+
if (applied.kind !== 'reloaded') takeLoad(load);
|
|
2546
|
+
});
|
|
2547
|
+
return doc;
|
|
2548
|
+
}
|
|
2549
|
+
|
|
2550
|
+
// The row's work status as it is now, without a snapshot, or 'unread' when
|
|
2551
|
+
// the read fails.
|
|
2552
|
+
async function readFrozenWorkStatus(docId: string): Promise<DocumentWorkStatus | null | 'unread'> {
|
|
2553
|
+
const now = await readWorkStatus(docId);
|
|
2554
|
+
return now === 'gone' ? 'unread' : now;
|
|
2555
|
+
}
|
|
2556
|
+
|
|
2557
|
+
// As `readFrozenWorkStatus`, with 'gone' for a document the caller can no
|
|
2558
|
+
// longer read (403 / 404).
|
|
2559
|
+
async function readWorkStatus(docId: string): Promise<DocumentWorkStatus | null | 'unread' | 'gone'> {
|
|
2560
|
+
try {
|
|
2561
|
+
const token = await requireToken();
|
|
2562
|
+
const headers: Record<string, string> = { authorization: `Bearer ${token}` };
|
|
2563
|
+
applyClientVersionHeader(headers);
|
|
2564
|
+
await applyAppAssertionHeader(headers, opts.getAppAssertion);
|
|
2565
|
+
const response = await fetch(`${baseUrl}/documents/${encodeURIComponent(docId)}`, {
|
|
2566
|
+
method: 'GET',
|
|
2567
|
+
headers,
|
|
2568
|
+
});
|
|
2569
|
+
if (response.status === 403 || response.status === 404) return 'gone';
|
|
2570
|
+
if (!response.ok) return 'unread';
|
|
2571
|
+
const body = (await response.json()) as { status?: unknown };
|
|
2572
|
+
return parseWorkStatus(body.status);
|
|
2573
|
+
} catch {
|
|
2574
|
+
return 'unread';
|
|
2575
|
+
}
|
|
2576
|
+
}
|
|
2577
|
+
|
|
2578
|
+
// An open joins past an import the join reports (`pendingImportWork`): it
|
|
2579
|
+
// waits for the import to end and joins again, so it resolves with what the
|
|
2580
|
+
// import landed, and rejects when the import failed with nothing landed.
|
|
2581
|
+
async function joinPastWork(
|
|
2582
|
+
docId: string,
|
|
2583
|
+
options: OpenDocumentByIdOptions | undefined,
|
|
2584
|
+
first: JoinAnswer,
|
|
2585
|
+
join: () => Promise<JoinAnswer>,
|
|
2586
|
+
): Promise<JoinAnswer> {
|
|
2587
|
+
const deadline = Date.now() + OPEN_WAIT_CEILING_MS;
|
|
2588
|
+
let answer = first;
|
|
2589
|
+
for (;;) {
|
|
2590
|
+
const work = pendingImportWork(answer);
|
|
2591
|
+
if (work === null) {
|
|
2592
|
+
const failed = failedImportOf(answer);
|
|
2593
|
+
if (failed !== null) throw failed;
|
|
2594
|
+
return answer;
|
|
2595
|
+
}
|
|
2596
|
+
await waitForWorkToEnd(docId, work.topic, work.status, deadline, options?.onSnapshotProgress);
|
|
2597
|
+
answer = await join();
|
|
2598
|
+
}
|
|
2599
|
+
}
|
|
2600
|
+
|
|
2601
|
+
// Resolves once the work ends: a terminal `doc_status` frame on the
|
|
2602
|
+
// document's topic, or a status read that finds it ended (on subscribe, and
|
|
2603
|
+
// on a backing-off poll for a frame that never arrives). Rejects when the
|
|
2604
|
+
// progress sink says a newer open superseded this one, and at `deadline`.
|
|
2605
|
+
async function waitForWorkToEnd(
|
|
2606
|
+
docId: string,
|
|
2607
|
+
topic: string,
|
|
2608
|
+
status: DocumentWorkStatus,
|
|
2609
|
+
deadline: number,
|
|
2610
|
+
progress: LoadProgress | undefined,
|
|
2611
|
+
): Promise<void> {
|
|
2612
|
+
const superseded = (): boolean => progress?.(0, null, 'importing', status.message) === false;
|
|
2613
|
+
if (superseded()) throw new BrassError(OPEN_SUPERSEDED_MESSAGE, { status: 0 });
|
|
2614
|
+
const ended = (now: DocumentWorkStatus): boolean => !now.pending || now.blocking === false;
|
|
2615
|
+
await new Promise<void>((resolve, reject) => {
|
|
2616
|
+
let settled = false;
|
|
2617
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
2618
|
+
let unsubscribe: (() => void) | null = null;
|
|
2619
|
+
const expiry = setTimeout(
|
|
2620
|
+
() => end(new BrassError(OPEN_WAIT_EXPIRED_MESSAGE, { status: 0 })),
|
|
2621
|
+
Math.max(0, deadline - Date.now()),
|
|
2622
|
+
);
|
|
2623
|
+
const end = (err?: Error): void => {
|
|
2624
|
+
if (settled) return;
|
|
2625
|
+
settled = true;
|
|
2626
|
+
if (timer !== null) clearTimeout(timer);
|
|
2627
|
+
clearTimeout(expiry);
|
|
2628
|
+
unsubscribe?.();
|
|
2629
|
+
if (err) reject(err);
|
|
2630
|
+
else resolve();
|
|
2631
|
+
};
|
|
2632
|
+
const check = async (): Promise<void> => {
|
|
2633
|
+
const now = await readWorkStatus(docId);
|
|
2634
|
+
if (now === 'unread') return;
|
|
2635
|
+
// A document the caller lost is left to the next join to report.
|
|
2636
|
+
if (now === 'gone' || now === null || ended(now)) end();
|
|
2637
|
+
};
|
|
2638
|
+
let delay = IMPORT_WAIT_POLL_MIN_MS;
|
|
2639
|
+
const poll = (): void => {
|
|
2640
|
+
timer = setTimeout(() => {
|
|
2641
|
+
timer = null;
|
|
2642
|
+
if (settled) return;
|
|
2643
|
+
if (superseded()) {
|
|
2644
|
+
end(new BrassError(OPEN_SUPERSEDED_MESSAGE, { status: 0 }));
|
|
2645
|
+
return;
|
|
2646
|
+
}
|
|
2647
|
+
void check().finally(() => {
|
|
2648
|
+
if (settled) return;
|
|
2649
|
+
delay = Math.min(delay * 2, IMPORT_WAIT_POLL_MAX_MS);
|
|
2650
|
+
poll();
|
|
2651
|
+
});
|
|
2652
|
+
}, delay);
|
|
2653
|
+
};
|
|
2654
|
+
if (opts.wsClient) {
|
|
2655
|
+
unsubscribe = opts.wsClient.subscribe(
|
|
2656
|
+
topic,
|
|
2657
|
+
(frame) => {
|
|
2658
|
+
if (isDocStatusFrame(frame) && ended(normalizeWorkStatus(frame.status))) end();
|
|
2659
|
+
},
|
|
2660
|
+
{ notice: true, onSubscribed: () => void check() },
|
|
2661
|
+
);
|
|
2662
|
+
}
|
|
2663
|
+
poll();
|
|
2664
|
+
});
|
|
2665
|
+
}
|
|
2666
|
+
|
|
2667
|
+
// A frozen open: a snapshot that watches for the document moving once a
|
|
2668
|
+
// listener asks it to. A set is opened as a loop over its ids, so joins
|
|
2669
|
+
// are paced here, which is what keeps such a loop fast without letting it
|
|
2670
|
+
// flood the api. The pace covers the join alone: the content download
|
|
2671
|
+
// goes to storage rather than the api, and holding a lane through it
|
|
2672
|
+
// leaves the next join waiting on bytes the api is not serving.
|
|
2673
|
+
async function openFrozen(docId: string, options: OpenDocumentByIdOptions): Promise<OpenedDocument> {
|
|
2674
|
+
const join = (): Promise<JoinAnswer> =>
|
|
2675
|
+
paceFrozen(async () => fetchSnapshot(docId, options, await requireToken()));
|
|
2676
|
+
// The wait sits outside the pace, which covers a join and not the time
|
|
2677
|
+
// between two of them.
|
|
2678
|
+
const snapshot = await joinPastWork(docId, options, await join(), join);
|
|
2679
|
+
return openSnapshotOnly(snapshot, options);
|
|
2680
|
+
}
|
|
2681
|
+
const paceFrozen = createPacer(FROZEN_OPEN_LANES);
|
|
2682
|
+
|
|
2683
|
+
// What a signed-in open reaches beyond the transport.
|
|
2684
|
+
function wireSessionCapabilities(
|
|
2685
|
+
doc: OpenedDocument,
|
|
2686
|
+
docId: string,
|
|
2687
|
+
parsed: DocumentSnapshotResponse,
|
|
2688
|
+
options: OpenDocumentByIdOptions | undefined,
|
|
2689
|
+
): void {
|
|
2690
|
+
// `doc._checkoutVersion(v)` calls back here to materialise historical
|
|
2691
|
+
// state, leaving the live subscription untouched.
|
|
2692
|
+
doc.setCheckoutFetcher(makeCheckoutFetcher());
|
|
2693
|
+
// Wire the branch provider + populate the initial list, so the
|
|
2694
|
+
// toolbar's branch chip can name the branch on the next subscribe
|
|
2695
|
+
// tick. Branch management is hosted; this list feeds the SDK's own
|
|
2696
|
+
// chrome, not an app method.
|
|
2697
|
+
if (opts.branchProvider) {
|
|
2698
|
+
doc.setBranchProvider(opts.branchProvider);
|
|
2699
|
+
void doc._refreshBranches().catch(() => {
|
|
2700
|
+
// Best-effort: a failure leaves the chip unnamed until a hosted
|
|
2701
|
+
// branch action refreshes the list.
|
|
2702
|
+
});
|
|
2703
|
+
}
|
|
2704
|
+
// `doc.useCanvas(canvas)` captures + uploads after each local commit; the
|
|
2705
|
+
// doc handles debounce + readOnly gating internally.
|
|
2706
|
+
if (opts.thumbnailUploader) {
|
|
2707
|
+
const uploader = opts.thumbnailUploader;
|
|
2708
|
+
doc.setThumbnailUploader(async (image, width, height, version) => {
|
|
2709
|
+
await uploader(doc, image, width, height, version);
|
|
2710
|
+
});
|
|
2711
|
+
}
|
|
2712
|
+
doc._setThumbnailVersion(parsed.thumbnail?.version ?? null);
|
|
2713
|
+
if (opts.checkpointCreator) {
|
|
2714
|
+
const creator = opts.checkpointCreator;
|
|
2715
|
+
doc.setCheckpointCreator((anchor) => creator(doc, anchor));
|
|
2716
|
+
}
|
|
2717
|
+
if (opts.drivePusher) {
|
|
2718
|
+
const pusher = opts.drivePusher;
|
|
2719
|
+
doc.setDrivePusher((input) => pusher(doc, input));
|
|
2720
|
+
}
|
|
2721
|
+
// `doc.resolveDrive()` opens the hosted resolution surface and, when it
|
|
2722
|
+
// names a document to switch onto, re-opens onto it via the same
|
|
2723
|
+
// `openSiblingDoc` path the `history` fork uses.
|
|
2724
|
+
const hostedForResolve = opts.hostedUiProvider;
|
|
2725
|
+
const resolveSurface = hostedForResolve?.driveResolve;
|
|
2726
|
+
if (hostedForResolve && resolveSurface) {
|
|
2727
|
+
doc.setDriveResolver(async () => {
|
|
2728
|
+
const docId = doc.id;
|
|
2729
|
+
if (docId === null) return null;
|
|
2730
|
+
const result = await resolveSurface(docId);
|
|
2731
|
+
if (result?.newDocId && hostedForResolve.openSiblingDoc) {
|
|
2732
|
+
await hostedForResolve.openSiblingDoc(result.newDocId, {});
|
|
2733
|
+
}
|
|
2734
|
+
return result;
|
|
2735
|
+
});
|
|
2736
|
+
}
|
|
2737
|
+
if (opts.sourceBlobFetcher) {
|
|
2738
|
+
const fetcher = opts.sourceBlobFetcher;
|
|
2739
|
+
doc.setSourceBlobFetcher(() => fetcher(doc));
|
|
2740
|
+
}
|
|
2741
|
+
setJoinStamp(doc, (parsed as { updated_at?: unknown }).updated_at);
|
|
2742
|
+
setJoinedName(doc, parsed);
|
|
2743
|
+
const poster = opts.provisionalInputsPoster;
|
|
2744
|
+
if (poster) doc._setProvisionalInputsPoster((post, o) => poster(docId, post, o));
|
|
2745
|
+
doc.setOpenedStreamName(options?.stream ?? DEFAULT_STREAM_NAME);
|
|
2746
|
+
// Streams: manifest list/create over HTTP plus the per-line open
|
|
2747
|
+
// (join + WS subscribe). Bound to the BRANCH this Document opened on,
|
|
2748
|
+
// which is not the line it materialized when the open named a stream: a
|
|
2749
|
+
// manifest belongs to a branch and a line owns none, so binding to the
|
|
2750
|
+
// line would leave `doc.stream(name)` resolving against nothing.
|
|
2751
|
+
if (opts.streamProvider) {
|
|
2752
|
+
const provider = opts.streamProvider;
|
|
2753
|
+
const openedBranchId = parsed.stream_branch_id ?? parsed.branch_id;
|
|
2754
|
+
doc.setStreamRuntime({
|
|
2755
|
+
list: async () =>
|
|
2756
|
+
(await provider.list(docId, openedBranchId)).streams,
|
|
2757
|
+
create: (name) => provider.create(docId, openedBranchId, name),
|
|
2758
|
+
openLine: (lineId) => openStreamLine(docId, lineId, options),
|
|
2759
|
+
});
|
|
2760
|
+
}
|
|
2761
|
+
// Hosted-UI provider backing the doc's built-in `history` / `details` /
|
|
2762
|
+
// `share` / `delete` commands.
|
|
2763
|
+
if (opts.hostedUiProvider) {
|
|
2764
|
+
doc.setHostedUiProvider(opts.hostedUiProvider);
|
|
2765
|
+
const refreshName = opts.hostedUiProvider.refreshName;
|
|
2766
|
+
if (refreshName && docId) {
|
|
2767
|
+
void refreshName(docId).then((name) => {
|
|
2768
|
+
if (name !== null) doc._setName(name);
|
|
2769
|
+
});
|
|
2770
|
+
}
|
|
2771
|
+
}
|
|
2772
|
+
}
|
|
2773
|
+
|
|
2774
|
+
// What a share link's open reaches beyond the transport: the routes that
|
|
2775
|
+
// take the link as the credential, and the link's own stream manifest.
|
|
2776
|
+
function wireLinkCapabilities(
|
|
2777
|
+
doc: OpenedDocument,
|
|
2778
|
+
docId: string,
|
|
2779
|
+
parsed: DocumentSnapshotResponse,
|
|
2780
|
+
credential: Exclude<OpenCredential, { kind: 'session' }>,
|
|
2781
|
+
options: OpenDocumentByIdOptions | undefined,
|
|
2782
|
+
writes: boolean,
|
|
2783
|
+
): void {
|
|
2784
|
+
const { token } = credential;
|
|
2785
|
+
setJoinStamp(doc, (parsed as { updated_at?: unknown }).updated_at);
|
|
2786
|
+
setJoinedName(doc, parsed);
|
|
2787
|
+
doc.setOpenedStreamName(options?.stream ?? DEFAULT_STREAM_NAME);
|
|
2788
|
+
if (writes) {
|
|
2789
|
+
doc.setCheckpointCreator(async (anchor) => {
|
|
2790
|
+
const version = anchor?.version ?? doc.currentSeq;
|
|
2791
|
+
if (version === null) {
|
|
2792
|
+
throw new BrassError('createCheckpoint: the document has no resolved version yet', { status: 400 });
|
|
2793
|
+
}
|
|
2794
|
+
const res = await postWithLink<{
|
|
2795
|
+
checkpoint: import('./documents-namespace.js').DocumentCheckpoint;
|
|
2796
|
+
}>(token, `/documents/${encodeURIComponent(docId)}/checkpoints`, {
|
|
2797
|
+
version,
|
|
2798
|
+
...(doc.branchId !== null ? { branch_id: doc.branchId } : {}),
|
|
2799
|
+
});
|
|
2800
|
+
return res.checkpoint;
|
|
2801
|
+
});
|
|
2802
|
+
}
|
|
2803
|
+
doc.setStreamRuntime({
|
|
2804
|
+
list: () => fetchLinkStreamManifest(token),
|
|
2805
|
+
openLine: (lineId) => openStreamLine(docId, lineId, options, credential),
|
|
2806
|
+
create: async (name) => {
|
|
2807
|
+
if (!writes) {
|
|
2808
|
+
throw new BrassError('A shared-link guest cannot create streams.', { status: 403 });
|
|
2809
|
+
}
|
|
2810
|
+
const info = await postWithLink<{ name: string; format: string; line_id: string }>(
|
|
2811
|
+
token,
|
|
2812
|
+
`/documents/${encodeURIComponent(docId)}/streams`,
|
|
2813
|
+
{ name, branch_id: parsed.branch_id },
|
|
2814
|
+
);
|
|
2815
|
+
return { name: info.name, format: info.format, line_id: info.line_id, created_at: '', created_by: '' };
|
|
2816
|
+
},
|
|
2817
|
+
});
|
|
2818
|
+
}
|
|
2819
|
+
|
|
2820
|
+
async function finalise(
|
|
2821
|
+
docId: string,
|
|
2822
|
+
snapshot: { status: number; parsed: unknown; parseFailed: boolean },
|
|
2823
|
+
options: OpenDocumentByIdOptions | undefined,
|
|
2824
|
+
credential: OpenCredential = SESSION,
|
|
2825
|
+
): Promise<OpenedDocument> {
|
|
2826
|
+
const parsed = unwrapJoinResponse(snapshot, isSnapshotResponse, 'snapshot');
|
|
2827
|
+
// A caller who asked for a read-only document is told so in the words for
|
|
2828
|
+
// that, not a share link's.
|
|
2829
|
+
const asked = options?.documentOptions;
|
|
2830
|
+
const doc = await materialiseAs(
|
|
2831
|
+
credential,
|
|
2832
|
+
parsed,
|
|
2833
|
+
readOnlyForGrant(
|
|
2834
|
+
parsed,
|
|
2835
|
+
asked?.readOnly === true && asked.readOnlyReason === undefined
|
|
2836
|
+
? { ...asked, readOnlyReason: 'snapshot' }
|
|
2837
|
+
: asked,
|
|
2838
|
+
),
|
|
2839
|
+
options?.onSnapshotProgress,
|
|
2840
|
+
);
|
|
2841
|
+
// Populate `doc.status` from the join before the first `doc_status` frame.
|
|
2842
|
+
if (parsed.status !== undefined) doc._emitStatus(parsed.status);
|
|
2843
|
+
doc._setImportReport(importReportOf(parsed));
|
|
2844
|
+
setJoinStamp(doc, (parsed as { updated_at?: unknown }).updated_at);
|
|
2845
|
+
doc._awaitsImport = parsed.awaits_import === true;
|
|
2846
|
+
// Whether this app is the one to serialize the document, which is what
|
|
2847
|
+
// decides whether a push carries bytes. Answered by the server for the
|
|
2848
|
+
// caller's own app and only on the join, since the fanout frame is one
|
|
2849
|
+
// payload for every subscriber and has no caller to answer for.
|
|
2850
|
+
doc._setExportRunsHere(parsed.calling_app_exports === true);
|
|
2851
|
+
// Seed `doc.driveStatus` from the join before the first
|
|
2852
|
+
// `doc_drive_status` frame.
|
|
2853
|
+
if (parsed.drive !== undefined) {
|
|
2854
|
+
doc._emitDriveStatus(toDriveStatus(parsed.drive));
|
|
2855
|
+
} else {
|
|
2856
|
+
// Only for a document that mirrors nothing: a mirror publishes through
|
|
2857
|
+
// its link, and `_emitDriveStatus` clears any targets anyway.
|
|
2858
|
+
doc._setPublishTarget(toPublishTarget(parsed.drive_publish_target));
|
|
2859
|
+
}
|
|
2860
|
+
// Highest seq seen; later `doc_op` / `op_ack` frames advance it. Kept in
|
|
1842
2861
|
// a closure, not on the Document: it is a transport concern, not CRDT
|
|
1843
2862
|
// state.
|
|
1844
2863
|
let currentSeq = Math.max(
|
|
@@ -1909,12 +2928,13 @@ export function createOpenDocument(
|
|
|
1909
2928
|
| { ok: false; reason: string }
|
|
1910
2929
|
> => {
|
|
1911
2930
|
const endPerf = perfStart('doc: content reload (fetch + import)');
|
|
1912
|
-
const token = await requireToken();
|
|
1913
2931
|
// Refetch the branch this doc was opened on, not the default. `parsed`
|
|
1914
2932
|
// is the opened-branch join, so its `branch_id` is authoritative;
|
|
1915
2933
|
// dropping it would import the default branch's snapshot + tail into a
|
|
1916
2934
|
// doc open on another branch and silently merge the wrong CRDT state.
|
|
1917
|
-
|
|
2935
|
+
// The open's read intent too: a reader who opened a record set as one
|
|
2936
|
+
// re-joins as one, rather than as a writer whose join converts it.
|
|
2937
|
+
const result = await joinAs(credential, docId, rejoinOptions(parsed.branch_id, options));
|
|
1918
2938
|
if (result.status < 200 || result.status >= 300) {
|
|
1919
2939
|
endPerf({ ok: false, joinStatus: result.status });
|
|
1920
2940
|
return { ok: false, reason: `join fetch returned ${result.status}` };
|
|
@@ -1923,40 +2943,35 @@ export function createOpenDocument(
|
|
|
1923
2943
|
endPerf({ ok: false, joinStatus: result.status });
|
|
1924
2944
|
return { ok: false, reason: 'malformed join response' };
|
|
1925
2945
|
}
|
|
1926
|
-
|
|
1927
|
-
let
|
|
1928
|
-
|
|
1929
|
-
snapshotBytes = base64ToBytes(snap.data_b64);
|
|
1930
|
-
} else if (snap !== null && snap.kind === 'presigned') {
|
|
2946
|
+
doc._awaitsImport = result.parsed.awaits_import === true;
|
|
2947
|
+
let applied: JoinApplied;
|
|
2948
|
+
try {
|
|
1931
2949
|
// Stream through the same progress sink the initial open uses, so the
|
|
1932
2950
|
// session surfaces "Downloading... NN%" instead of sitting on the
|
|
1933
2951
|
// last server phase.
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
: `presigned snapshot fetch failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
1948
|
-
};
|
|
1949
|
-
}
|
|
2952
|
+
applied = await applyJoin(doc, result.parsed, (url) =>
|
|
2953
|
+
fetchPresignedSnapshot(url, options?.onSnapshotProgress),
|
|
2954
|
+
);
|
|
2955
|
+
} catch (err) {
|
|
2956
|
+
const status = err instanceof BrassError ? err.status ?? 0 : 0;
|
|
2957
|
+
endPerf({ ok: false, snapshotStatus: status });
|
|
2958
|
+
return {
|
|
2959
|
+
ok: false,
|
|
2960
|
+
reason:
|
|
2961
|
+
status > 0
|
|
2962
|
+
? `snapshot read returned ${status}`
|
|
2963
|
+
: `snapshot read failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
2964
|
+
};
|
|
1950
2965
|
}
|
|
1951
|
-
if (
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
2966
|
+
if (applied.kind !== 'applied') {
|
|
2967
|
+
endPerf({ ok: true, joinStatus: result.status, reloaded: applied.kind === 'reloaded' });
|
|
2968
|
+
return { ok: true, status: result.parsed.status };
|
|
2969
|
+
}
|
|
2970
|
+
const tailSeqs = applied.tailSeqs;
|
|
1955
2971
|
let maxTailSeq = 0;
|
|
1956
|
-
for (const
|
|
1957
|
-
|
|
1958
|
-
if (
|
|
1959
|
-
if (entry.seq > maxTailSeq) maxTailSeq = entry.seq;
|
|
2972
|
+
for (const seq of tailSeqs) {
|
|
2973
|
+
if (seq > currentSeq) currentSeq = seq;
|
|
2974
|
+
if (seq > maxTailSeq) maxTailSeq = seq;
|
|
1960
2975
|
}
|
|
1961
2976
|
const fetchedSeq =
|
|
1962
2977
|
typeof result.parsed.current_seq === 'number'
|
|
@@ -1977,23 +2992,17 @@ export function createOpenDocument(
|
|
|
1977
2992
|
// under it. That resolves at the next compaction, which folds the tail
|
|
1978
2993
|
// past the hole and raises `snapshot_seq` over it; until then the gap
|
|
1979
2994
|
// pull's own budget is what stops the reader asking forever.
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
joinCoverageFloor(
|
|
1987
|
-
snapshotSeqOf(result.parsed.snapshot_seq, tailSeqs, fetchedSeq),
|
|
1988
|
-
tailSeqs,
|
|
1989
|
-
),
|
|
1990
|
-
);
|
|
1991
|
-
}
|
|
2995
|
+
raiseCoverage(
|
|
2996
|
+
joinCoverageFloor(
|
|
2997
|
+
snapshotSeqOf(result.parsed.snapshot_seq, tailSeqs, fetchedSeq),
|
|
2998
|
+
tailSeqs,
|
|
2999
|
+
),
|
|
3000
|
+
);
|
|
1992
3001
|
for (const seq of tailSeqs) advanceCoverage(seq);
|
|
1993
3002
|
endPerf({
|
|
1994
3003
|
ok: true,
|
|
1995
|
-
snapshotBytes:
|
|
1996
|
-
tailOps:
|
|
3004
|
+
snapshotBytes: applied.content.kind === 'crdt' ? applied.content.bytes.byteLength : 0,
|
|
3005
|
+
tailOps: tailSeqs.length,
|
|
1997
3006
|
});
|
|
1998
3007
|
return { ok: true, status: result.parsed.status };
|
|
1999
3008
|
};
|
|
@@ -2007,10 +3016,9 @@ export function createOpenDocument(
|
|
|
2007
3016
|
snapshotVersionVector?: string;
|
|
2008
3017
|
tailBlobs: Uint8Array[];
|
|
2009
3018
|
} | null> => {
|
|
2010
|
-
const token = await requireToken();
|
|
2011
3019
|
// Same-branch as `refetchAndImport`: the durability handshake must diff
|
|
2012
3020
|
// against the opened branch's frontier, not the default branch's.
|
|
2013
|
-
const result = await
|
|
3021
|
+
const result = await joinAs(credential, docId, rejoinOptions(parsed.branch_id, options));
|
|
2014
3022
|
if (result.status < 200 || result.status >= 300) return null;
|
|
2015
3023
|
if (!isSnapshotResponse(result.parsed)) return null;
|
|
2016
3024
|
const tailBlobs = result.parsed.tail_ops.map((entry) =>
|
|
@@ -2113,6 +3121,47 @@ export function createOpenDocument(
|
|
|
2113
3121
|
})();
|
|
2114
3122
|
};
|
|
2115
3123
|
|
|
3124
|
+
// The row's work status as it is now, read without a snapshot, for a
|
|
3125
|
+
// frame whose claim about the row needs checking.
|
|
3126
|
+
const readWorkStatus = async (): Promise<DocumentWorkStatus | null> => {
|
|
3127
|
+
const body = (await readStatusAs(credential, docId)) as { status?: unknown };
|
|
3128
|
+
return parseWorkStatus(body.status);
|
|
3129
|
+
};
|
|
3130
|
+
|
|
3131
|
+
// A `pending` frame that would take a settled document back to loading is
|
|
3132
|
+
// checked against the row before it locks anything. A frame is the
|
|
3133
|
+
// stream's view of the row at the time of that write, delivered after the
|
|
3134
|
+
// stream's own lag, while the reconcile above reads the row directly: a
|
|
3135
|
+
// client that reconciles between a hydration's last phase write and the
|
|
3136
|
+
// stream delivering it has already imported the content when that
|
|
3137
|
+
// phase's frame arrives, and applying it locks a document whose work is
|
|
3138
|
+
// done. An importer that ran off the reconcile's own ready status then
|
|
3139
|
+
// writes into that lock and is refused. Progress inside a run the
|
|
3140
|
+
// document already shows as pending applies without a read; only the
|
|
3141
|
+
// regression from settled to pending pays one, and that one read answers
|
|
3142
|
+
// for every frame that arrives while it is out. A read that fails applies
|
|
3143
|
+
// the frame, since locking is the side that loses nothing.
|
|
3144
|
+
let pendingVerifyInFlight = false;
|
|
3145
|
+
const verifyPendingStatus = (status: DocumentWorkStatus): void => {
|
|
3146
|
+
if (pendingVerifyInFlight) return;
|
|
3147
|
+
pendingVerifyInFlight = true;
|
|
3148
|
+
void (async () => {
|
|
3149
|
+
let current: DocumentWorkStatus | null | 'unread' = 'unread';
|
|
3150
|
+
try {
|
|
3151
|
+
current = await readWorkStatus();
|
|
3152
|
+
} catch {
|
|
3153
|
+
// Unread: the frame stands.
|
|
3154
|
+
} finally {
|
|
3155
|
+
pendingVerifyInFlight = false;
|
|
3156
|
+
}
|
|
3157
|
+
if (current === 'unread') {
|
|
3158
|
+
doc._emitStatus(status);
|
|
3159
|
+
} else if (current !== null && current.pending) {
|
|
3160
|
+
doc._emitStatus(current);
|
|
3161
|
+
}
|
|
3162
|
+
})();
|
|
3163
|
+
};
|
|
3164
|
+
|
|
2116
3165
|
// Evidence the document moved on the server after a give-up: one more
|
|
2117
3166
|
// pull, which on success imports the content and releases the lock the
|
|
2118
3167
|
// failed terminal status left in place.
|
|
@@ -2122,15 +3171,15 @@ export function createOpenDocument(
|
|
|
2122
3171
|
reloadAfterHydration(WORK_READY_STATUS);
|
|
2123
3172
|
};
|
|
2124
3173
|
|
|
2125
|
-
// Reconcile
|
|
2126
|
-
// terminal `doc_status` frame is best-effort: if
|
|
2127
|
-
//
|
|
2128
|
-
// and drops the frame, stranding the app on the pending
|
|
2129
|
-
// is posted only after the subscription record is
|
|
2130
|
-
//
|
|
2131
|
-
//
|
|
3174
|
+
// Reconcile pending work once the live subscription exists (a resubscribe
|
|
3175
|
+
// while work runs). The terminal `doc_status` frame is best-effort: if the
|
|
3176
|
+
// work finishes while this client has no subscription record, the fan-out
|
|
3177
|
+
// finds no subscriber and drops the frame, stranding the app on the pending
|
|
3178
|
+
// overlay. `doc_init` is posted only after the subscription record is
|
|
3179
|
+
// written, so one re-fetch here closes the gap. Only a pending document
|
|
3180
|
+
// pays the extra fetch.
|
|
2132
3181
|
const reconcileStatusOnInit = (): void => {
|
|
2133
|
-
const current = doc.
|
|
3182
|
+
const current = doc._workStatusNow;
|
|
2134
3183
|
if (!current || !current.pending) return;
|
|
2135
3184
|
if (reconcileInFlight || reloadInFlight) return;
|
|
2136
3185
|
reconcileInFlight = true;
|
|
@@ -2171,13 +3220,13 @@ export function createOpenDocument(
|
|
|
2171
3220
|
opBlobB64: string,
|
|
2172
3221
|
clientOpId: string,
|
|
2173
3222
|
): Promise<OversizedOpResult> => {
|
|
2174
|
-
const
|
|
2175
|
-
|
|
3223
|
+
const { status, parsed: opResult } = await postOpAs(
|
|
3224
|
+
credential,
|
|
2176
3225
|
docId,
|
|
2177
3226
|
parsed.branch_id,
|
|
2178
3227
|
opBlobB64,
|
|
2179
3228
|
clientOpId,
|
|
2180
|
-
|
|
3229
|
+
baseImportSeqField(doc),
|
|
2181
3230
|
);
|
|
2182
3231
|
if (status < 200 || status >= 300) {
|
|
2183
3232
|
return {
|
|
@@ -2185,6 +3234,9 @@ export function createOpenDocument(
|
|
|
2185
3234
|
message: opWriteFailureMessage(status, opResult),
|
|
2186
3235
|
retryable: opWriteIsRetryable(status),
|
|
2187
3236
|
atCapacity: isCapacityRefusal(status, opResult),
|
|
3237
|
+
...(isMovedRefusal(opResult) ? { moved: true } : {}),
|
|
3238
|
+
...(isNotImportedRefusal(opResult) ? { notImported: true } : {}),
|
|
3239
|
+
...(status === 413 ? { tooLarge: true } : {}),
|
|
2188
3240
|
// The route answers 403 when the caller's grant carries no write
|
|
2189
3241
|
// access, which is the HTTP face of the socket's `role_denied`.
|
|
2190
3242
|
...(status === 403 ? { readOnly: 'grant' as const } : {}),
|
|
@@ -2201,7 +3253,8 @@ export function createOpenDocument(
|
|
|
2201
3253
|
return { ok: true, seq };
|
|
2202
3254
|
};
|
|
2203
3255
|
|
|
2204
|
-
|
|
3256
|
+
const docWsClient = wsClientFor(credential);
|
|
3257
|
+
if (docWsClient) {
|
|
2205
3258
|
wireDocTransport({
|
|
2206
3259
|
doc,
|
|
2207
3260
|
docId,
|
|
@@ -2218,7 +3271,10 @@ export function createOpenDocument(
|
|
|
2218
3271
|
? { documentOptions: options.documentOptions }
|
|
2219
3272
|
: {}),
|
|
2220
3273
|
...(opts.identity !== undefined ? { identity: opts.identity } : {}),
|
|
2221
|
-
|
|
3274
|
+
// A link's open is parked under nobody, and a frozen copy hears
|
|
3275
|
+
// whole-document notices rather than joining the room.
|
|
3276
|
+
...(credential.kind === 'session' ? {} : { parkable: false, skipPresence: options?.frozen === true }),
|
|
3277
|
+
wsClient: docWsClient,
|
|
2222
3278
|
postOversizedOp,
|
|
2223
3279
|
newClientOpId: opts.newClientOpId ?? defaultClientOpIdMinter,
|
|
2224
3280
|
getCurrentSeq: () => currentSeq,
|
|
@@ -2231,87 +3287,12 @@ export function createOpenDocument(
|
|
|
2231
3287
|
refetchDurableFrontier,
|
|
2232
3288
|
reloadAfterHydration,
|
|
2233
3289
|
reconcileStatusOnInit,
|
|
3290
|
+
verifyPendingStatus,
|
|
2234
3291
|
onDocumentMoved: retryContentPull,
|
|
2235
3292
|
});
|
|
2236
3293
|
}
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
doc.setCheckoutFetcher(makeCheckoutFetcher());
|
|
2240
|
-
// Wire the branch provider + populate the initial list, so the
|
|
2241
|
-
// toolbar's branch chip can name the branch on the next subscribe
|
|
2242
|
-
// tick. Branch management is hosted; this list feeds the SDK's own
|
|
2243
|
-
// chrome, not an app method.
|
|
2244
|
-
if (opts.branchProvider) {
|
|
2245
|
-
doc.setBranchProvider(opts.branchProvider);
|
|
2246
|
-
void doc._refreshBranches().catch(() => {
|
|
2247
|
-
// Best-effort: a failure leaves the chip unnamed until a hosted
|
|
2248
|
-
// branch action refreshes the list.
|
|
2249
|
-
});
|
|
2250
|
-
}
|
|
2251
|
-
// `doc.useCanvas(canvas)` captures + uploads after each local commit; the
|
|
2252
|
-
// doc handles debounce + readOnly gating internally.
|
|
2253
|
-
if (opts.thumbnailUploader) {
|
|
2254
|
-
const uploader = opts.thumbnailUploader;
|
|
2255
|
-
doc.setThumbnailUploader(async (image, width, height) => {
|
|
2256
|
-
await uploader(doc, image, width, height);
|
|
2257
|
-
});
|
|
2258
|
-
}
|
|
2259
|
-
doc._setThumbnailVersion(parsed.thumbnail?.version ?? null);
|
|
2260
|
-
if (opts.checkpointCreator) {
|
|
2261
|
-
const creator = opts.checkpointCreator;
|
|
2262
|
-
doc.setCheckpointCreator(() => creator(doc));
|
|
2263
|
-
}
|
|
2264
|
-
if (opts.drivePusher) {
|
|
2265
|
-
const pusher = opts.drivePusher;
|
|
2266
|
-
doc.setDrivePusher((input) => pusher(doc, input));
|
|
2267
|
-
}
|
|
2268
|
-
// `doc.resolveDrive()` opens the hosted resolution surface and, when it
|
|
2269
|
-
// mints a new mirror doc (take-source / keep-both), re-opens onto it via
|
|
2270
|
-
// the same `openSiblingDoc` path the `history` fork uses.
|
|
2271
|
-
const hostedForResolve = opts.hostedUiProvider;
|
|
2272
|
-
const resolveSurface = hostedForResolve?.driveResolve;
|
|
2273
|
-
if (hostedForResolve && resolveSurface) {
|
|
2274
|
-
doc.setDriveResolver(async () => {
|
|
2275
|
-
const docId = doc.id;
|
|
2276
|
-
if (docId === null) return null;
|
|
2277
|
-
const result = await resolveSurface(docId);
|
|
2278
|
-
if (result?.newDocId && hostedForResolve.openSiblingDoc) {
|
|
2279
|
-
await hostedForResolve.openSiblingDoc(result.newDocId, {});
|
|
2280
|
-
}
|
|
2281
|
-
return result;
|
|
2282
|
-
});
|
|
2283
|
-
}
|
|
2284
|
-
if (opts.sourceBlobFetcher) {
|
|
2285
|
-
const fetcher = opts.sourceBlobFetcher;
|
|
2286
|
-
doc.setSourceBlobFetcher(() => fetcher(doc));
|
|
2287
|
-
}
|
|
2288
|
-
doc.setOpenedStreamName(options?.stream ?? DEFAULT_STREAM_NAME);
|
|
2289
|
-
// Streams: manifest list/create over HTTP plus the per-line open
|
|
2290
|
-
// (join + WS subscribe). Bound to the BRANCH this Document opened on,
|
|
2291
|
-
// which is not the line it materialized when the open named a stream: a
|
|
2292
|
-
// manifest belongs to a branch and a line owns none, so binding to the
|
|
2293
|
-
// line would leave `doc.stream(name)` resolving against nothing.
|
|
2294
|
-
if (opts.streamProvider) {
|
|
2295
|
-
const provider = opts.streamProvider;
|
|
2296
|
-
const openedBranchId = parsed.stream_branch_id ?? parsed.branch_id;
|
|
2297
|
-
doc.setStreamRuntime({
|
|
2298
|
-
list: async () =>
|
|
2299
|
-
(await provider.list(docId, openedBranchId)).streams,
|
|
2300
|
-
create: (name) => provider.create(docId, openedBranchId, name),
|
|
2301
|
-
openLine: (lineId) => openStreamLine(docId, lineId),
|
|
2302
|
-
});
|
|
2303
|
-
}
|
|
2304
|
-
// Hosted-UI provider backing the doc's built-in `history` / `details` /
|
|
2305
|
-
// `share` / `delete` commands.
|
|
2306
|
-
if (opts.hostedUiProvider) {
|
|
2307
|
-
doc.setHostedUiProvider(opts.hostedUiProvider);
|
|
2308
|
-
const refreshName = opts.hostedUiProvider.refreshName;
|
|
2309
|
-
if (refreshName && docId) {
|
|
2310
|
-
void refreshName(docId).then((name) => {
|
|
2311
|
-
if (name !== null) doc._setName(name);
|
|
2312
|
-
});
|
|
2313
|
-
}
|
|
2314
|
-
}
|
|
3294
|
+
if (credential.kind === 'session') wireSessionCapabilities(doc, docId, parsed, options);
|
|
3295
|
+
else wireLinkCapabilities(doc, docId, parsed, credential, options, doc.readOnly !== true);
|
|
2315
3296
|
if (opts.reportError) doc.setErrorReporter(opts.reportError);
|
|
2316
3297
|
// No socket on a document the caller can write to: the write path reaches
|
|
2317
3298
|
// no server, so the document reports the first attempt rather than
|
|
@@ -2332,21 +3313,13 @@ export function createOpenDocument(
|
|
|
2332
3313
|
options: OpenDocumentByIdOptions,
|
|
2333
3314
|
token: string,
|
|
2334
3315
|
): Promise<OpenedDocument> {
|
|
2335
|
-
const
|
|
2336
|
-
docId,
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
token,
|
|
3316
|
+
const parsed = unwrapJoinResponse(
|
|
3317
|
+
await fetchAtVersion(docId, version, options.branch, token),
|
|
3318
|
+
isAtVersionResponse,
|
|
3319
|
+
'at-version',
|
|
2340
3320
|
);
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
}
|
|
2344
|
-
if (!isAtVersionResponse(parsed)) {
|
|
2345
|
-
throw new BrassError('Malformed document at-version response', {
|
|
2346
|
-
status: 500,
|
|
2347
|
-
});
|
|
2348
|
-
}
|
|
2349
|
-
return materialise(
|
|
3321
|
+
return materialiseAs(
|
|
3322
|
+
SESSION,
|
|
2350
3323
|
{
|
|
2351
3324
|
doc_id: docId,
|
|
2352
3325
|
branch_id: parsed.branch_id,
|
|
@@ -2367,29 +3340,50 @@ export function createOpenDocument(
|
|
|
2367
3340
|
// same snapshot + tail_ops envelope, member-stripped and read-only.
|
|
2368
3341
|
async function fetchLinkView(
|
|
2369
3342
|
token: string,
|
|
2370
|
-
|
|
2371
|
-
|
|
3343
|
+
// The line, and whether this guest will write. A view link's reader never
|
|
3344
|
+
// does, and an edit link's may, which is the same question the
|
|
3345
|
+
// authenticated join asks with `intent=read` and decides the same thing:
|
|
3346
|
+
// whether a document the platform stores frozen is served as it stands or
|
|
3347
|
+
// converted first.
|
|
3348
|
+
join: OpenDocumentByIdOptions,
|
|
3349
|
+
progress?: LoadProgress,
|
|
3350
|
+
{ rejoin = false }: { rejoin?: boolean } = {},
|
|
3351
|
+
): Promise<JoinAnswer> {
|
|
3352
|
+
const once = (): Promise<JoinAnswer> => linkViewOnce(token, join, rejoin);
|
|
3353
|
+
// An edit link's guest waits out a conversion as a member's editable open
|
|
3354
|
+
// does.
|
|
3355
|
+
return opensReadOnly(join) ? once() : waitWhileConverting(once, progress);
|
|
3356
|
+
}
|
|
3357
|
+
|
|
3358
|
+
async function linkViewOnce(
|
|
3359
|
+
token: string,
|
|
3360
|
+
join: OpenDocumentByIdOptions,
|
|
3361
|
+
rejoin: boolean,
|
|
3362
|
+
): Promise<JoinAnswer> {
|
|
2372
3363
|
const search = new URLSearchParams();
|
|
2373
3364
|
search.set('include', 'snapshot');
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
const
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
if (
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
3365
|
+
search.set('app_id', opts.appId);
|
|
3366
|
+
if (rejoin) search.set('rejoin', '1');
|
|
3367
|
+
const headers = await linkViewHeaders();
|
|
3368
|
+
// A person signed in here who reaches the document on their own opens it
|
|
3369
|
+
// as themselves, with their own role and identity.
|
|
3370
|
+
if (!rejoin && headers['authorization'] !== undefined) search.set('as_member', '1');
|
|
3371
|
+
if (join.branch !== undefined) search.set('branch', join.branch);
|
|
3372
|
+
if (opensReadOnly(join)) search.set('intent', 'read');
|
|
3373
|
+
return sendJoin(`/links/${encodeURIComponent(token)}/view`, search, { headers });
|
|
3374
|
+
}
|
|
3375
|
+
|
|
3376
|
+
// A link open carries no credential unless the person signed in on this
|
|
3377
|
+
// link page, in which case it carries theirs.
|
|
3378
|
+
async function linkViewHeaders(): Promise<Record<string, string>> {
|
|
3379
|
+
const headers: Record<string, string> = {};
|
|
3380
|
+
applyClientVersionHeader(headers);
|
|
3381
|
+
const credential = (await opts.getLinkCredential?.()) ?? null;
|
|
3382
|
+
if (credential !== null) {
|
|
3383
|
+
headers['authorization'] = `Bearer ${credential.accessToken}`;
|
|
3384
|
+
headers['x-brass-app-assertion'] = credential.appAssertion;
|
|
2386
3385
|
}
|
|
2387
|
-
|
|
2388
|
-
// a guest join carries them exactly as a signed-in one does. The URL
|
|
2389
|
-
// carries its own signature, so an anonymous reader fetches it with the
|
|
2390
|
-
// same call. Resolving here is what lets `materialise` see one shape.
|
|
2391
|
-
await resolveParsedTailOps(parsed);
|
|
2392
|
-
return { status: response.status, parsed, parseFailed };
|
|
3386
|
+
return headers;
|
|
2393
3387
|
}
|
|
2394
3388
|
|
|
2395
3389
|
// `GET /links/{token}/streams`: the anonymous stream manifest, the guest
|
|
@@ -2400,7 +3394,9 @@ export function createOpenDocument(
|
|
|
2400
3394
|
token: string,
|
|
2401
3395
|
): Promise<import('./documents-namespace.js').DocumentStreamInfo[]> {
|
|
2402
3396
|
const url = `${baseUrl}/links/${encodeURIComponent(token)}/streams`;
|
|
2403
|
-
const
|
|
3397
|
+
const headers: Record<string, string> = {};
|
|
3398
|
+
applyClientVersionHeader(headers);
|
|
3399
|
+
const response = await fetchWithRetry(() => fetch(url, { method: 'GET', headers }));
|
|
2404
3400
|
const text = await response.text();
|
|
2405
3401
|
if (response.status < 200 || response.status >= 300) {
|
|
2406
3402
|
let parsed: unknown = undefined;
|
|
@@ -2439,140 +3435,72 @@ export function createOpenDocument(
|
|
|
2439
3435
|
);
|
|
2440
3436
|
}
|
|
2441
3437
|
|
|
2442
|
-
const openByLink: OpenDocumentByLinkFn = async (token,
|
|
2443
|
-
const
|
|
3438
|
+
const openByLink: OpenDocumentByLinkFn = async (token, asked) => {
|
|
3439
|
+
const options = withReadIntent(asked);
|
|
3440
|
+
const view = await fetchLinkView(
|
|
2444
3441
|
token,
|
|
2445
|
-
options?.branch,
|
|
3442
|
+
rejoinOptions(options?.branch, options),
|
|
3443
|
+
options?.onSnapshotProgress,
|
|
2446
3444
|
);
|
|
2447
|
-
if (status < 200 || status >= 300) {
|
|
2448
|
-
throw brassErrorFromBody(status, parseFailed ? undefined : parsed);
|
|
3445
|
+
if (view.status < 200 || view.status >= 300) {
|
|
3446
|
+
throw brassErrorFromBody(view.status, view.parseFailed ? undefined : view.parsed);
|
|
2449
3447
|
}
|
|
2450
|
-
const b = parsed as {
|
|
3448
|
+
const b = view.parsed as {
|
|
2451
3449
|
doc_id?: unknown;
|
|
2452
|
-
|
|
2453
|
-
snapshot?: unknown;
|
|
2454
|
-
tail_ops?: unknown;
|
|
2455
|
-
current_seq?: unknown;
|
|
2456
|
-
name?: unknown;
|
|
3450
|
+
opens_as_member?: unknown;
|
|
2457
3451
|
link_role?: unknown;
|
|
2458
3452
|
status?: unknown;
|
|
3453
|
+
anonymous?: unknown;
|
|
3454
|
+
expires_at?: unknown;
|
|
3455
|
+
awaits_import?: unknown;
|
|
3456
|
+
can_edit?: unknown;
|
|
3457
|
+
read_only_reason?: unknown;
|
|
2459
3458
|
};
|
|
2460
|
-
if (
|
|
2461
|
-
typeof b.doc_id !== 'string' ||
|
|
2462
|
-
typeof b.branch_id !== 'string' ||
|
|
2463
|
-
!Array.isArray(b.tail_ops)
|
|
2464
|
-
) {
|
|
3459
|
+
if (typeof b.doc_id !== 'string') {
|
|
2465
3460
|
throw new BrassError('Malformed link view response', { status: 500 });
|
|
2466
3461
|
}
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
//
|
|
2471
|
-
//
|
|
2472
|
-
|
|
2473
|
-
const
|
|
2474
|
-
const
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
if (opts.reportError) doc.setErrorReporter(opts.reportError);
|
|
2498
|
-
|
|
2499
|
-
wireGuestTransport(doc, token, b.doc_id, b.branch_id, canEdit);
|
|
2500
|
-
|
|
2501
|
-
// Streams: a guest resolves `doc.stream(name)` through the token
|
|
2502
|
-
// routes. `list` hits the anonymous manifest; `openLine` joins the
|
|
2503
|
-
// line read/write exactly like the default stream above, over the same
|
|
2504
|
-
// token-authenticated socket. `create` is refused (a guest cannot mint
|
|
2505
|
-
// a line). Absent when there is no WS client (a snapshot-only guest),
|
|
2506
|
-
// matching the default stream having no live transport there.
|
|
2507
|
-
doc.setStreamRuntime({
|
|
2508
|
-
list: () => fetchLinkStreamManifest(token),
|
|
2509
|
-
openLine: async (lineId) => {
|
|
2510
|
-
const view = await fetchLinkView(token, lineId);
|
|
2511
|
-
if (view.status < 200 || view.status >= 300) {
|
|
2512
|
-
throw brassErrorFromBody(
|
|
2513
|
-
view.status,
|
|
2514
|
-
view.parseFailed ? undefined : view.parsed,
|
|
2515
|
-
);
|
|
2516
|
-
}
|
|
2517
|
-
const lb = view.parsed as {
|
|
2518
|
-
doc_id?: unknown;
|
|
2519
|
-
branch_id?: unknown;
|
|
2520
|
-
snapshot?: unknown;
|
|
2521
|
-
tail_ops?: unknown;
|
|
2522
|
-
};
|
|
2523
|
-
if (
|
|
2524
|
-
typeof lb.doc_id !== 'string' ||
|
|
2525
|
-
typeof lb.branch_id !== 'string' ||
|
|
2526
|
-
!Array.isArray(lb.tail_ops)
|
|
2527
|
-
) {
|
|
2528
|
-
throw new BrassError('Malformed link stream view response', {
|
|
2529
|
-
status: 500,
|
|
2530
|
-
});
|
|
2531
|
-
}
|
|
2532
|
-
const inner = await materialise(
|
|
2533
|
-
{
|
|
2534
|
-
doc_id: lb.doc_id,
|
|
2535
|
-
branch_id: lb.branch_id,
|
|
2536
|
-
snapshot: lb.snapshot as DocumentSnapshotResponse['snapshot'],
|
|
2537
|
-
tail_ops: lb.tail_ops as DocumentSnapshotResponse['tail_ops'],
|
|
2538
|
-
},
|
|
2539
|
-
{ readOnly: !canEdit },
|
|
2540
|
-
);
|
|
2541
|
-
if (opts.reportError) inner.setErrorReporter(opts.reportError);
|
|
2542
|
-
wireGuestTransport(inner, token, lb.doc_id, lb.branch_id, canEdit);
|
|
2543
|
-
return inner;
|
|
2544
|
-
},
|
|
2545
|
-
create: () => {
|
|
2546
|
-
throw new BrassError('A shared-link guest cannot create streams.', {
|
|
2547
|
-
status: 403,
|
|
2548
|
-
});
|
|
2549
|
-
},
|
|
2550
|
-
});
|
|
3462
|
+
if (b.opens_as_member === true) {
|
|
3463
|
+
return (opts.openAsMember ?? open)(b.doc_id, asked);
|
|
3464
|
+
}
|
|
3465
|
+
// The link's grant decides read vs write; the view answers `can_edit`
|
|
3466
|
+
// as the member join does, so a view link reads as a viewer's grant.
|
|
3467
|
+
const canEdit = b.link_role === 'editor' && b.can_edit !== false;
|
|
3468
|
+
const askedToRead = canEdit && opensReadOnly(options);
|
|
3469
|
+
const linkOptions: OpenDocumentByIdOptions = {
|
|
3470
|
+
...options,
|
|
3471
|
+
documentOptions: askedToRead
|
|
3472
|
+
? { ...options?.documentOptions, readOnly: true, readOnlyReason: 'snapshot' }
|
|
3473
|
+
: canEdit
|
|
3474
|
+
? { ...options?.documentOptions }
|
|
3475
|
+
: { ...options?.documentOptions, readOnly: true, readOnlyReason: 'link' },
|
|
3476
|
+
};
|
|
3477
|
+
// A document minted without an account names when the platform removes
|
|
3478
|
+
// it; every other link carries neither key.
|
|
3479
|
+
const expiresAt = anonymousExpiry(b);
|
|
3480
|
+
const credential: OpenCredential =
|
|
3481
|
+
expiresAt !== null ? { kind: 'temporary', token } : { kind: 'link', token };
|
|
3482
|
+
const doc = await finalise(b.doc_id, view, linkOptions, credential);
|
|
3483
|
+
if (expiresAt !== null) {
|
|
3484
|
+
doc._setExpiresAt(expiresAt);
|
|
3485
|
+
// A document the visitor started from a file is imported by this app
|
|
3486
|
+
// in this page, so it reads its file through the link as a member's
|
|
3487
|
+
// import reads it through the document.
|
|
3488
|
+
const imports = guestImports(token, b.doc_id);
|
|
3489
|
+
doc.setSourceBlobFetcher(() => imports.sourceBlob());
|
|
3490
|
+
doc._awaitsImport = canEdit && b.awaits_import === true;
|
|
3491
|
+
}
|
|
2551
3492
|
return doc;
|
|
2552
3493
|
};
|
|
2553
3494
|
|
|
2554
|
-
//
|
|
2555
|
-
//
|
|
2556
|
-
//
|
|
2557
|
-
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
): Promise<{ status: number; parsed: unknown }> {
|
|
2564
|
-
const response = await fetch(
|
|
2565
|
-
`${baseUrl}/links/${encodeURIComponent(token)}/ops`,
|
|
2566
|
-
{
|
|
2567
|
-
method: 'POST',
|
|
2568
|
-
headers: { 'content-type': 'application/json' },
|
|
2569
|
-
body: JSON.stringify({
|
|
2570
|
-
op_blob: opBlobB64,
|
|
2571
|
-
branch_id: lineId,
|
|
2572
|
-
client_op_id: clientOpId,
|
|
2573
|
-
}),
|
|
2574
|
-
},
|
|
2575
|
-
);
|
|
3495
|
+
// A read through a route a member's open reads, with the link as the
|
|
3496
|
+
// credential. A 404 answers null, as the session's reads of the same
|
|
3497
|
+
// routes do.
|
|
3498
|
+
async function getWithLink(token: string, path: string, query: Record<string, string>): Promise<unknown> {
|
|
3499
|
+
const search = new URLSearchParams(query).toString();
|
|
3500
|
+
const response = await fetch(`${baseUrl}${search ? `${path}?${search}` : path}`, {
|
|
3501
|
+
headers: { authorization: `Link ${token}` },
|
|
3502
|
+
});
|
|
3503
|
+
if (response.status === 404) return null;
|
|
2576
3504
|
const text = await response.text();
|
|
2577
3505
|
let parsed: unknown = undefined;
|
|
2578
3506
|
try {
|
|
@@ -2580,344 +3508,89 @@ export function createOpenDocument(
|
|
|
2580
3508
|
} catch {
|
|
2581
3509
|
parsed = undefined;
|
|
2582
3510
|
}
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
// Wire a guest Document (the default stream or a stream line) to the
|
|
2587
|
-
// token-authenticated socket. Inbound frames (`doc_init` / `doc_op`)
|
|
2588
|
-
// apply for both link kinds; an edit link ALSO wires the outbound path
|
|
2589
|
-
// (`op_ack` resolves the outstanding-op tracker, `setLocalOpHook` ships
|
|
2590
|
-
// each local commit). Best-effort on the read side (no resync / durability
|
|
2591
|
-
// handshake, which need a signed-in token); a guest that misses ops
|
|
2592
|
-
// re-opens. The WRITE side answers a refusal the way the signed-in
|
|
2593
|
-
// transport does, because a guest editor's edit is the same person's work
|
|
2594
|
-
// as a member's and the same two things can happen to it: a frame the
|
|
2595
|
-
// socket cannot carry takes the HTTP route, and a capacity shed is
|
|
2596
|
-
// re-asserted on the shared backoff and reported once the budget is out.
|
|
2597
|
-
// Patches `close` to detach, mirroring `wireDocTransport`, so `Document`
|
|
2598
|
-
// stays free of WS knowledge. A no-op with no WS client (a snapshot-only
|
|
2599
|
-
// guest).
|
|
2600
|
-
function wireGuestTransport(
|
|
2601
|
-
doc: Document,
|
|
2602
|
-
token: string,
|
|
2603
|
-
docId: string,
|
|
2604
|
-
lineId: string,
|
|
2605
|
-
canEdit: boolean,
|
|
2606
|
-
): void {
|
|
2607
|
-
const wsClient = opts.wsClient;
|
|
2608
|
-
if (!wsClient) {
|
|
2609
|
-
// No socket: a write would change local state and reach no server, so
|
|
2610
|
-
// the document reports the first one instead of discarding it quietly.
|
|
2611
|
-
if (canEdit) doc.markTransportUnavailable();
|
|
2612
|
-
return;
|
|
2613
|
-
}
|
|
2614
|
-
const topic = `doc:${docId}:${lineId}`;
|
|
2615
|
-
|
|
2616
|
-
// Ops on the socket awaiting an answer, keyed by `client_op_id`. An
|
|
2617
|
-
// `unavailable` shed names no op, so re-asserting one needs the frames
|
|
2618
|
-
// in hand. Scoped to what the socket carried: an op that took the HTTP
|
|
2619
|
-
// write must never reach the socket, and that write re-asserts itself.
|
|
2620
|
-
const outstanding = new Map<string, OpFrame>();
|
|
2621
|
-
let shedRounds = 0;
|
|
2622
|
-
let shedTimer: ReturnType<typeof setTimeout> | null = null;
|
|
2623
|
-
|
|
2624
|
-
function clearShedRecovery(): void {
|
|
2625
|
-
if (shedTimer !== null) {
|
|
2626
|
-
clearTimeout(shedTimer);
|
|
2627
|
-
shedTimer = null;
|
|
2628
|
-
}
|
|
2629
|
-
shedRounds = 0;
|
|
2630
|
-
}
|
|
2631
|
-
|
|
2632
|
-
// Every outstanding op is dead: drop the tracker entries and tell the
|
|
2633
|
-
// Document, which stops counting them in `savingCount` and reports each
|
|
2634
|
-
// through the error channel the app reads. The ids are taken first
|
|
2635
|
-
// because `_failOutstandingOp` re-enters app code.
|
|
2636
|
-
function failAllOutstanding(message: string): void {
|
|
2637
|
-
const clientOpIds = [...outstanding.keys()];
|
|
2638
|
-
outstanding.clear();
|
|
2639
|
-
for (const clientOpId of clientOpIds) {
|
|
2640
|
-
doc._failOutstandingOp(clientOpId, message);
|
|
2641
|
-
}
|
|
3511
|
+
if (response.status < 200 || response.status >= 300) {
|
|
3512
|
+
throw brassErrorFromBody(response.status, parsed);
|
|
2642
3513
|
}
|
|
3514
|
+
return parsed;
|
|
3515
|
+
}
|
|
2643
3516
|
|
|
2644
|
-
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
|
|
2648
|
-
|
|
2649
|
-
|
|
2650
|
-
|
|
2651
|
-
|
|
2652
|
-
|
|
2653
|
-
};
|
|
2654
|
-
|
|
2655
|
-
const onServerUnavailable = (): void => {
|
|
2656
|
-
if (outstanding.size === 0) return;
|
|
2657
|
-
if (shedTimer !== null) return;
|
|
2658
|
-
shedRounds += 1;
|
|
2659
|
-
if (shedRounds > UNAVAILABLE_MAX_ROUNDS) {
|
|
2660
|
-
clearShedRecovery();
|
|
2661
|
-
failAllOutstanding(UNAVAILABLE_GIVE_UP_MESSAGE);
|
|
2662
|
-
return;
|
|
2663
|
-
}
|
|
2664
|
-
shedTimer = setTimeout(() => {
|
|
2665
|
-
shedTimer = null;
|
|
2666
|
-
resendAllOutstanding();
|
|
2667
|
-
}, unavailableDelayMs(shedRounds));
|
|
2668
|
-
(shedTimer as { unref?: () => void }).unref?.();
|
|
2669
|
-
};
|
|
2670
|
-
|
|
2671
|
-
const unsub = wsClient.subscribe(
|
|
2672
|
-
topic,
|
|
2673
|
-
(frame) => {
|
|
2674
|
-
const init = parseDocInitFrame(frame);
|
|
2675
|
-
if (init) {
|
|
2676
|
-
for (const entry of init.tail_ops) {
|
|
2677
|
-
if (entry.op_blob_b64 === undefined) continue;
|
|
2678
|
-
doc.applyRemoteOp(base64ToBytes(entry.op_blob_b64));
|
|
2679
|
-
}
|
|
2680
|
-
// `doc_init` follows every `subscribed` ack, so this is the point
|
|
2681
|
-
// the topic can carry frames again. A fresh connection also returns
|
|
2682
|
-
// the shed budget to full: this re-assert is the round a pending
|
|
2683
|
-
// one would have run.
|
|
2684
|
-
clearShedRecovery();
|
|
2685
|
-
resendAllOutstanding();
|
|
2686
|
-
return;
|
|
2687
|
-
}
|
|
2688
|
-
if (isDocOpFrame(frame)) {
|
|
2689
|
-
doc.applyRemoteOp(base64ToBytes(frame.op_blob));
|
|
2690
|
-
} else if (isDocGoneFrame(frame)) {
|
|
2691
|
-
// The server fans `doc_gone` to EVERY subscriber on the branched
|
|
2692
|
-
// content topic, guest-link connections included, on a soft-delete /
|
|
2693
|
-
// move of the opened doc.
|
|
2694
|
-
// Without this branch the guest keeps showing removed content and
|
|
2695
|
-
// `doc.onGone(...)` never fires; for an edit link the next local op
|
|
2696
|
-
// would ship onto the evicted subscription record and bounce as a misleading
|
|
2697
|
-
// `forbidden`. Drop the shed recovery first so nothing re-asserts an
|
|
2698
|
-
// op onto an evicted subscription record, then mark the doc gone (mapping the
|
|
2699
|
-
// reason like the signed-in transport).
|
|
2700
|
-
clearShedRecovery();
|
|
2701
|
-
outstanding.clear();
|
|
2702
|
-
doc._markGone(goneReasonOf(frame));
|
|
2703
|
-
} else if (isDocStatusFrame(frame)) {
|
|
2704
|
-
// A guest can open a document whose inbound work is still running,
|
|
2705
|
-
// and hydration writes a SNAPSHOT rather than ops, so nothing on the
|
|
2706
|
-
// op stream would ever fill the view in. Same split as the signed-in
|
|
2707
|
-
// transport: the `reload` hint pulls the content and emits the
|
|
2708
|
-
// terminal status after the import, and anything else renders.
|
|
2709
|
-
const status = normalizeWorkStatus(frame.status);
|
|
2710
|
-
if (frame.reload) pullGuestContent(doc, token, lineId, status);
|
|
2711
|
-
else doc._emitStatus(status);
|
|
2712
|
-
} else if (isDocMetaFrame(frame)) {
|
|
2713
|
-
// `_setName` de-dupes, so a re-emit of the current name is a no-op.
|
|
2714
|
-
doc._setName(frame.name);
|
|
2715
|
-
} else if (canEdit && isOpAckFrame(frame)) {
|
|
2716
|
-
outstanding.delete(frame.client_op_id);
|
|
2717
|
-
doc.resolveOutstandingOp(frame.client_op_id);
|
|
2718
|
-
// The server is durably accepting writes again, so the shed budget
|
|
2719
|
-
// starts over. A round already scheduled still runs: the ops it
|
|
2720
|
-
// re-asserts are the ones this ack did not cover.
|
|
2721
|
-
shedRounds = 0;
|
|
2722
|
-
} else if (canEdit && isServerUnavailableFrame(frame)) {
|
|
2723
|
-
onServerUnavailable();
|
|
2724
|
-
} else if (canEdit && isOpRejectedFrame(frame)) {
|
|
2725
|
-
// Terminal, not transient: stop re-asserting this op (retrying
|
|
2726
|
-
// would only draw the same rejection) and fail it on the Document
|
|
2727
|
-
// so "could not be saved" surfaces instead of a stuck saving count.
|
|
2728
|
-
// A refusal naming the writer (an edit link downgraded or revoked
|
|
2729
|
-
// while the guest had it open) turns the Document read-only, the
|
|
2730
|
-
// same way it does for a signed-in reader.
|
|
2731
|
-
outstanding.delete(frame.client_op_id);
|
|
2732
|
-
if (isCapacityRejection(frame)) doc._markAtCapacity();
|
|
2733
|
-
const readOnly = readOnlyReasonForRejection(frame);
|
|
2734
|
-
if (readOnly !== null) doc._markReadOnly(readOnly);
|
|
2735
|
-
doc._failOutstandingOp(frame.client_op_id, opRejectedMessage(frame));
|
|
2736
|
-
}
|
|
3517
|
+
// A write through a route a member's open writes, with the link as the
|
|
3518
|
+
// credential. Resolves the parsed body, or rejects with the server's
|
|
3519
|
+
// refusal.
|
|
3520
|
+
async function postWithLink<T>(token: string, path: string, body: unknown): Promise<T> {
|
|
3521
|
+
const response = await fetch(`${baseUrl}${path}`, {
|
|
3522
|
+
method: 'POST',
|
|
3523
|
+
headers: {
|
|
3524
|
+
'content-type': 'application/json',
|
|
3525
|
+
authorization: `Link ${token}`,
|
|
2737
3526
|
},
|
|
2738
|
-
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
|
|
2745
|
-
|
|
2746
|
-
// the signed-in path runs: refuse locally what no write can hold, and
|
|
2747
|
-
// re-assert a capacity refusal on the shared backoff before reporting it.
|
|
2748
|
-
async function writeOversizedGuestOp(
|
|
2749
|
-
opBlobB64: string,
|
|
2750
|
-
clientOpId: string,
|
|
2751
|
-
): Promise<void> {
|
|
2752
|
-
// Yield before any outcome resolves the op. The hook's caller records
|
|
2753
|
-
// the outstanding op AFTER the hook returns, and an async function body
|
|
2754
|
-
// runs synchronously up to its first await, so an exit taken inline
|
|
2755
|
-
// would find nothing tracked and leave the entry behind with
|
|
2756
|
-
// `savingCount` stuck above zero.
|
|
2757
|
-
await Promise.resolve();
|
|
2758
|
-
|
|
2759
|
-
// Both sizes are known here, so refuse without spending a round trip
|
|
2760
|
-
// on a blob that can only come back 400.
|
|
2761
|
-
if (opBlobExceedsDurableLimit(opBlobB64)) {
|
|
2762
|
-
doc._failOutstandingOp(clientOpId, TOO_LARGE_MESSAGE);
|
|
2763
|
-
return;
|
|
2764
|
-
}
|
|
2765
|
-
if (opBlobNeedsStaging(opBlobB64)) {
|
|
2766
|
-
doc._failOutstandingOp(clientOpId, LINK_OP_TOO_LARGE_MESSAGE);
|
|
2767
|
-
return;
|
|
2768
|
-
}
|
|
2769
|
-
|
|
2770
|
-
let status = 0;
|
|
2771
|
-
let parsed: unknown = undefined;
|
|
2772
|
-
for (let round = 0; round <= UNAVAILABLE_MAX_ROUNDS; round += 1) {
|
|
2773
|
-
if (round > 0) {
|
|
2774
|
-
await new Promise((resolve) => {
|
|
2775
|
-
const timer = setTimeout(resolve, unavailableDelayMs(round));
|
|
2776
|
-
(timer as { unref?: () => void }).unref?.();
|
|
2777
|
-
});
|
|
2778
|
-
}
|
|
2779
|
-
try {
|
|
2780
|
-
({ status, parsed } = await postLinkOp(
|
|
2781
|
-
token,
|
|
2782
|
-
lineId,
|
|
2783
|
-
opBlobB64,
|
|
2784
|
-
clientOpId,
|
|
2785
|
-
));
|
|
2786
|
-
} catch (err) {
|
|
2787
|
-
// A throw leaves it unknown whether the handler ran, and this op
|
|
2788
|
-
// carries no idempotency the server dedupes on, so replaying it
|
|
2789
|
-
// could double-apply.
|
|
2790
|
-
doc._failOutstandingOp(
|
|
2791
|
-
clientOpId,
|
|
2792
|
-
err instanceof Error ? err.message : String(err),
|
|
2793
|
-
);
|
|
2794
|
-
return;
|
|
2795
|
-
}
|
|
2796
|
-
if (status >= 200 && status < 300) break;
|
|
2797
|
-
if (!opWriteIsRetryable(status)) break;
|
|
2798
|
-
}
|
|
2799
|
-
if (status < 200 || status >= 300) {
|
|
2800
|
-
doc._failOutstandingOp(
|
|
2801
|
-
clientOpId,
|
|
2802
|
-
opWriteIsRetryable(status)
|
|
2803
|
-
? UNAVAILABLE_GIVE_UP_MESSAGE
|
|
2804
|
-
: opWriteFailureMessage(status, parsed),
|
|
2805
|
-
);
|
|
2806
|
-
return;
|
|
2807
|
-
}
|
|
2808
|
-
doc.resolveOutstandingOp(clientOpId);
|
|
3527
|
+
body: JSON.stringify(body),
|
|
3528
|
+
});
|
|
3529
|
+
const text = await response.text();
|
|
3530
|
+
let parsed: unknown = undefined;
|
|
3531
|
+
try {
|
|
3532
|
+
parsed = text ? JSON.parse(text) : undefined;
|
|
3533
|
+
} catch {
|
|
3534
|
+
parsed = undefined;
|
|
2809
3535
|
}
|
|
2810
|
-
|
|
2811
|
-
|
|
2812
|
-
const minter = opts.newClientOpId ?? defaultClientOpIdMinter;
|
|
2813
|
-
doc.setLocalOpHook((opBlob) => {
|
|
2814
|
-
const clientOpId = minter();
|
|
2815
|
-
const opBlobB64 = bytesToBase64(opBlob);
|
|
2816
|
-
const sendFrame: OpFrame = {
|
|
2817
|
-
type: 'op',
|
|
2818
|
-
topic,
|
|
2819
|
-
op_blob: opBlobB64,
|
|
2820
|
-
client_op_id: clientOpId,
|
|
2821
|
-
};
|
|
2822
|
-
// A frame past the transport's cap is not delivered and takes the
|
|
2823
|
-
// connection down with it, so it must never reach the socket: a
|
|
2824
|
-
// guest's document, presence and every other op ride that one
|
|
2825
|
-
// connection. `mutate` batches to a budget well above what a frame
|
|
2826
|
-
// carries, so an ordinary bulk edit arrives here.
|
|
2827
|
-
if (opFrameExceedsWsLimit(sendFrame)) {
|
|
2828
|
-
void writeOversizedGuestOp(opBlobB64, clientOpId);
|
|
2829
|
-
return clientOpId;
|
|
2830
|
-
}
|
|
2831
|
-
outstanding.set(clientOpId, sendFrame);
|
|
2832
|
-
wsClient.send(sendFrame);
|
|
2833
|
-
return clientOpId;
|
|
2834
|
-
});
|
|
3536
|
+
if (response.status < 200 || response.status >= 300) {
|
|
3537
|
+
throw brassErrorFromBody(response.status, parsed);
|
|
2835
3538
|
}
|
|
2836
|
-
|
|
2837
|
-
(doc as Document & { close: () => void }).close = () => {
|
|
2838
|
-
clearShedRecovery();
|
|
2839
|
-
try {
|
|
2840
|
-
unsub();
|
|
2841
|
-
} catch {
|
|
2842
|
-
// Already detached; closing the Document is what matters.
|
|
2843
|
-
}
|
|
2844
|
-
originalClose();
|
|
2845
|
-
};
|
|
2846
|
-
}
|
|
2847
|
-
|
|
2848
|
-
// Guest documents with a content pull running, so a repeated terminal frame
|
|
2849
|
-
// does not stack a second pull on one document.
|
|
2850
|
-
const guestPullInFlight = new WeakSet<Document>();
|
|
2851
|
-
|
|
2852
|
-
function pullGuestContent(
|
|
2853
|
-
doc: Document,
|
|
2854
|
-
token: string,
|
|
2855
|
-
lineId: string,
|
|
2856
|
-
finalStatus: DocumentWorkStatus,
|
|
2857
|
-
): void {
|
|
2858
|
-
if (guestPullInFlight.has(doc)) return;
|
|
2859
|
-
guestPullInFlight.add(doc);
|
|
2860
|
-
void (async () => {
|
|
2861
|
-
await pullContentWithRetry({
|
|
2862
|
-
doc,
|
|
2863
|
-
docId: doc.id ?? '',
|
|
2864
|
-
finalStatus,
|
|
2865
|
-
fetchAndImport: () => importLinkView(doc, token, lineId),
|
|
2866
|
-
...(opts.reportError ? { reportError: opts.reportError } : {}),
|
|
2867
|
-
});
|
|
2868
|
-
guestPullInFlight.delete(doc);
|
|
2869
|
-
})();
|
|
3539
|
+
return parsed as T;
|
|
2870
3540
|
}
|
|
2871
3541
|
|
|
2872
|
-
//
|
|
2873
|
-
//
|
|
2874
|
-
//
|
|
2875
|
-
// is scoped to.
|
|
2876
|
-
|
|
2877
|
-
|
|
3542
|
+
// Write a guest's op over HTTP: `POST /links/{token}/ops`, the anonymous
|
|
3543
|
+
// counterpart of `POST /documents/{docId}/ops`. Sends NO Authorization
|
|
3544
|
+
// header (the token is the credential) and names the line the guest
|
|
3545
|
+
// opened, which is the branch its socket topic is scoped to. An op past
|
|
3546
|
+
// what a request body carries is staged first, as a member's is.
|
|
3547
|
+
async function postLinkOp(
|
|
2878
3548
|
token: string,
|
|
3549
|
+
docId: string,
|
|
2879
3550
|
lineId: string,
|
|
2880
|
-
|
|
2881
|
-
|
|
2882
|
-
|
|
2883
|
-
|
|
2884
|
-
|
|
2885
|
-
|
|
2886
|
-
|
|
2887
|
-
|
|
2888
|
-
|
|
2889
|
-
|
|
2890
|
-
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
|
|
2894
|
-
|
|
2895
|
-
|
|
2896
|
-
|
|
2897
|
-
|
|
2898
|
-
|
|
2899
|
-
|
|
2900
|
-
|
|
2901
|
-
|
|
2902
|
-
|
|
2903
|
-
|
|
2904
|
-
|
|
2905
|
-
|
|
2906
|
-
|
|
2907
|
-
|
|
2908
|
-
}
|
|
2909
|
-
}
|
|
2910
|
-
if (snapshotBytes) doc._applySnapshot(snapshotBytes);
|
|
2911
|
-
const tail = b.tail_ops as DocumentSnapshotResponse['tail_ops'];
|
|
2912
|
-
for (const entry of [...tail].sort((x, y) => x.seq - y.seq)) {
|
|
2913
|
-
doc.applyRemoteOp(base64ToBytes(entry.op_blob_b64));
|
|
3551
|
+
opBlobB64: string,
|
|
3552
|
+
clientOpId: string,
|
|
3553
|
+
base: { base_import_seq?: number } = {},
|
|
3554
|
+
): Promise<{ status: number; parsed: unknown }> {
|
|
3555
|
+
const stagedKey = opBlobNeedsStaging(opBlobB64)
|
|
3556
|
+
? await stageOpBlob(docId, opBlobB64, { link: token })
|
|
3557
|
+
: undefined;
|
|
3558
|
+
const headers: Record<string, string> = { 'content-type': 'application/json' };
|
|
3559
|
+
applyClientVersionHeader(headers);
|
|
3560
|
+
const response = await fetch(
|
|
3561
|
+
`${baseUrl}/links/${encodeURIComponent(token)}/ops`,
|
|
3562
|
+
{
|
|
3563
|
+
method: 'POST',
|
|
3564
|
+
headers,
|
|
3565
|
+
body: JSON.stringify({
|
|
3566
|
+
...(stagedKey === undefined ? { op_blob: opBlobB64 } : { upload_key: stagedKey }),
|
|
3567
|
+
branch_id: lineId,
|
|
3568
|
+
client_op_id: clientOpId,
|
|
3569
|
+
...base,
|
|
3570
|
+
}),
|
|
3571
|
+
},
|
|
3572
|
+
);
|
|
3573
|
+
const text = await response.text();
|
|
3574
|
+
let parsed: unknown = undefined;
|
|
3575
|
+
try {
|
|
3576
|
+
parsed = text ? JSON.parse(text) : undefined;
|
|
3577
|
+
} catch {
|
|
3578
|
+
parsed = undefined;
|
|
2914
3579
|
}
|
|
2915
|
-
return {
|
|
3580
|
+
return { status: response.status, parsed };
|
|
2916
3581
|
}
|
|
2917
3582
|
|
|
3583
|
+
|
|
3584
|
+
|
|
2918
3585
|
// Strict open: a 404 propagates as `BrassError`. A `version` option
|
|
2919
3586
|
// diverts to the read-only at-version path.
|
|
2920
|
-
const open: OpenDocumentByIdFn = async (docId,
|
|
3587
|
+
const open: OpenDocumentByIdFn = async (docId, asked) => {
|
|
3588
|
+
if (asked?.frozen === true && asked.readOnly === false) {
|
|
3589
|
+
throw new TypeError('openDocumentById: a frozen copy is read-only, so `readOnly: false` cannot apply');
|
|
3590
|
+
}
|
|
3591
|
+
const requested = withReadIntent(asked);
|
|
3592
|
+
const options = requested?.frozen === true ? frozenOptions(requested) : requested;
|
|
3593
|
+
if (requested?.frozen === true) return openFrozen(docId, options!);
|
|
2921
3594
|
// Cache hit: return the parked Document. Its subscription stayed alive
|
|
2922
3595
|
// while parked, and the UndoController + outstanding-op map survived, so
|
|
2923
3596
|
// undo works as if the user never switched away.
|
|
@@ -2938,12 +3611,47 @@ export function createOpenDocument(
|
|
|
2938
3611
|
endTotal({ docId, path: 'at-version' });
|
|
2939
3612
|
return atVersion;
|
|
2940
3613
|
}
|
|
2941
|
-
|
|
3614
|
+
// An editable open waits until the document takes edits. One that never
|
|
3615
|
+
// will is refused terminally, and is then read as a reader would read it.
|
|
3616
|
+
// An import the platform runs holds every open until it lands, the file's
|
|
3617
|
+
// first import or a new version's, so the open resolves with its content.
|
|
3618
|
+
let snapshot = await fetchSnapshot(docId, options, token);
|
|
3619
|
+
snapshot = await joinPastWork(docId, options, snapshot, async () =>
|
|
3620
|
+
fetchSnapshot(docId, options, await requireToken()),
|
|
3621
|
+
);
|
|
3622
|
+
const refused = joinRefusalOf(snapshot);
|
|
3623
|
+
if (refused?.kind === 'at_capacity') {
|
|
3624
|
+
const held = await openTooLarge(docId, options, refused.error);
|
|
3625
|
+
endTotal({ docId, path: 'read-only:at_capacity' });
|
|
3626
|
+
return held;
|
|
3627
|
+
}
|
|
2942
3628
|
const opened = await finalise(docId, snapshot, options);
|
|
2943
3629
|
endTotal({ docId });
|
|
2944
3630
|
return opened;
|
|
2945
3631
|
};
|
|
2946
3632
|
|
|
3633
|
+
// A writable open of a document too large to edit, joined again as a
|
|
3634
|
+
// reader and shown read-only.
|
|
3635
|
+
async function openTooLarge(
|
|
3636
|
+
docId: string,
|
|
3637
|
+
options: OpenDocumentByIdOptions | undefined,
|
|
3638
|
+
error: string | undefined,
|
|
3639
|
+
): Promise<OpenedDocument> {
|
|
3640
|
+
const readOptions: OpenDocumentByIdOptions = {
|
|
3641
|
+
...options,
|
|
3642
|
+
documentOptions: {
|
|
3643
|
+
...options?.documentOptions,
|
|
3644
|
+
readOnly: true,
|
|
3645
|
+
readOnlyReason: 'too_large',
|
|
3646
|
+
},
|
|
3647
|
+
};
|
|
3648
|
+
const snapshot = await fetchSnapshot(docId, readOptions, await requireToken());
|
|
3649
|
+
const doc = await finalise(docId, snapshot, readOptions);
|
|
3650
|
+
doc._markAtCapacity();
|
|
3651
|
+
if (error !== undefined) doc._emitStatus({ message: error, pending: false, blocking: false });
|
|
3652
|
+
return doc;
|
|
3653
|
+
}
|
|
3654
|
+
|
|
2947
3655
|
// Create a doc and return it fully editable. Two round trips (POST to
|
|
2948
3656
|
// create + obtain the doc_id, then `open(docId)`); they aren't fused
|
|
2949
3657
|
// because the create response is metadata-only and the open path already
|
|
@@ -2980,7 +3688,290 @@ export function createOpenDocument(
|
|
|
2980
3688
|
return open(result.docId, openOptions);
|
|
2981
3689
|
};
|
|
2982
3690
|
|
|
2983
|
-
|
|
3691
|
+
// The mint's answer is the claim secret's only copy, so it is kept as it
|
|
3692
|
+
// arrives, in the browser that made the document.
|
|
3693
|
+
const rememberClaimSecret = (minted: MintedAnonymousDocument): MintedAnonymousDocument => {
|
|
3694
|
+
if (minted.claimSecret !== undefined) {
|
|
3695
|
+
storeClaimSecret(opts.appId, minted.linkToken, minted.claimSecret, minted.expiresAt);
|
|
3696
|
+
}
|
|
3697
|
+
return minted;
|
|
3698
|
+
};
|
|
3699
|
+
|
|
3700
|
+
const mintAnonymousDocument: DocumentFactory['mintAnonymousDocument'] =
|
|
3701
|
+
async (args) => {
|
|
3702
|
+
const body: Record<string, unknown> = { app_id: opts.appId };
|
|
3703
|
+
if (args.name !== undefined) body['name'] = args.name;
|
|
3704
|
+
const headers: Record<string, string> = {
|
|
3705
|
+
'content-type': 'application/json',
|
|
3706
|
+
};
|
|
3707
|
+
applyClientVersionHeader(headers);
|
|
3708
|
+
// No idempotency key on this route, so a replay could mint twice;
|
|
3709
|
+
// only the gateway's own 503 (the handler never ran) is replayed.
|
|
3710
|
+
const response = await fetchWithRetry(
|
|
3711
|
+
() =>
|
|
3712
|
+
fetch(`${baseUrl}/anonymous-documents`, {
|
|
3713
|
+
method: 'POST',
|
|
3714
|
+
headers,
|
|
3715
|
+
body: JSON.stringify(body),
|
|
3716
|
+
}),
|
|
3717
|
+
{ policy: RETRY_WRITE_503, baseDelayMs: 250 },
|
|
3718
|
+
);
|
|
3719
|
+
const text = await response.text();
|
|
3720
|
+
let parsed: unknown = undefined;
|
|
3721
|
+
try {
|
|
3722
|
+
parsed = text ? JSON.parse(text) : undefined;
|
|
3723
|
+
} catch {
|
|
3724
|
+
parsed = undefined;
|
|
3725
|
+
}
|
|
3726
|
+
if (response.status < 200 || response.status >= 300) {
|
|
3727
|
+
throw brassErrorFromBody(response.status, parsed);
|
|
3728
|
+
}
|
|
3729
|
+
return rememberClaimSecret(parseMintedAnonymousDocument(parsed));
|
|
3730
|
+
};
|
|
3731
|
+
|
|
3732
|
+
// The unauthenticated JSON calls a visitor's file-started document makes.
|
|
3733
|
+
const postAnonymous = async (
|
|
3734
|
+
path: string,
|
|
3735
|
+
body: Record<string, unknown>,
|
|
3736
|
+
): Promise<unknown> => {
|
|
3737
|
+
const headers: Record<string, string> = { 'content-type': 'application/json' };
|
|
3738
|
+
applyClientVersionHeader(headers);
|
|
3739
|
+
const response = await fetchWithRetry(
|
|
3740
|
+
() =>
|
|
3741
|
+
fetch(`${baseUrl}${path}`, {
|
|
3742
|
+
method: 'POST',
|
|
3743
|
+
headers,
|
|
3744
|
+
body: JSON.stringify(body),
|
|
3745
|
+
}),
|
|
3746
|
+
{ policy: RETRY_WRITE_503, baseDelayMs: 250 },
|
|
3747
|
+
);
|
|
3748
|
+
const parsed = await readJsonBody(response);
|
|
3749
|
+
if (response.status < 200 || response.status >= 300) {
|
|
3750
|
+
throw brassErrorFromBody(response.status, parsed);
|
|
3751
|
+
}
|
|
3752
|
+
return parsed;
|
|
3753
|
+
};
|
|
3754
|
+
|
|
3755
|
+
const anonymousUploadTypes: DocumentFactory['anonymousUploadTypes'] = async () => {
|
|
3756
|
+
const headers: Record<string, string> = {};
|
|
3757
|
+
applyClientVersionHeader(headers);
|
|
3758
|
+
// `refusal=body` answers an app's refusal of signed-out use in a 200 body,
|
|
3759
|
+
// which keeps an expected answer out of the visitor's console.
|
|
3760
|
+
const response = await fetchWithRetry(
|
|
3761
|
+
() =>
|
|
3762
|
+
fetch(
|
|
3763
|
+
`${baseUrl}/anonymous-documents/upload-types?app_id=${encodeURIComponent(opts.appId)}&refusal=body`,
|
|
3764
|
+
{ headers },
|
|
3765
|
+
),
|
|
3766
|
+
{ policy: RETRY_WRITE_503, baseDelayMs: 250 },
|
|
3767
|
+
);
|
|
3768
|
+
const parsed = await readJsonBody(response);
|
|
3769
|
+
if (response.status < 200 || response.status >= 300) {
|
|
3770
|
+
throw brassErrorFromBody(response.status, parsed);
|
|
3771
|
+
}
|
|
3772
|
+
const body = parsed as { refusal?: unknown; offers_new?: unknown } | null;
|
|
3773
|
+
const offersNew = body?.offers_new !== false;
|
|
3774
|
+
if (body?.refusal !== undefined) {
|
|
3775
|
+
return { offersNew, refusal: brassErrorFromBody(409, body.refusal) };
|
|
3776
|
+
}
|
|
3777
|
+
return { offersNew, accept: acceptFromUploadTypes(parsed) };
|
|
3778
|
+
};
|
|
3779
|
+
|
|
3780
|
+
const mintAnonymousDocumentFromFile: DocumentFactory['mintAnonymousDocumentFromFile'] =
|
|
3781
|
+
async (args) => {
|
|
3782
|
+
const minted = (await postAnonymous('/anonymous-documents/upload-url', {
|
|
3783
|
+
app_id: opts.appId,
|
|
3784
|
+
file_name: args.file.name,
|
|
3785
|
+
byte_size: args.file.size,
|
|
3786
|
+
})) as { upload_url?: unknown; upload_key?: unknown } | undefined;
|
|
3787
|
+
if (typeof minted?.upload_url !== 'string' || typeof minted.upload_key !== 'string') {
|
|
3788
|
+
throw new BrassError('Malformed upload response', { status: 500 });
|
|
3789
|
+
}
|
|
3790
|
+
await putFile(minted.upload_url, args.file, args.onUploadProgress);
|
|
3791
|
+
const body: Record<string, unknown> = {
|
|
3792
|
+
app_id: opts.appId,
|
|
3793
|
+
upload_key: minted.upload_key,
|
|
3794
|
+
file_name: args.file.name,
|
|
3795
|
+
};
|
|
3796
|
+
if (args.name !== undefined) body['name'] = args.name;
|
|
3797
|
+
return rememberClaimSecret(
|
|
3798
|
+
parseMintedAnonymousDocument(
|
|
3799
|
+
await postAnonymous('/anonymous-documents/from-upload', body),
|
|
3800
|
+
),
|
|
3801
|
+
);
|
|
3802
|
+
};
|
|
3803
|
+
|
|
3804
|
+
// One client id per page, which is what holds a claim across the calls
|
|
3805
|
+
// of one import (the signed-in namespace keeps its own).
|
|
3806
|
+
const guestImportClientId = randomToken();
|
|
3807
|
+
const guestImports: DocumentFactory['guestImports'] = (token, docId) => {
|
|
3808
|
+
const base = `${baseUrl}/documents/${encodeURIComponent(docId)}`;
|
|
3809
|
+
const call = async <T>(method: 'GET' | 'POST', path: string, body?: unknown): Promise<T> => {
|
|
3810
|
+
const headers: Record<string, string> = { authorization: `Link ${token}` };
|
|
3811
|
+
if (body !== undefined) headers['content-type'] = 'application/json';
|
|
3812
|
+
applyClientVersionHeader(headers);
|
|
3813
|
+
const response = await fetchWithRetry(
|
|
3814
|
+
() =>
|
|
3815
|
+
fetch(`${base}${path}`, {
|
|
3816
|
+
method,
|
|
3817
|
+
headers,
|
|
3818
|
+
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
3819
|
+
}),
|
|
3820
|
+
{ policy: RETRY_WRITE_503, baseDelayMs: 250 },
|
|
3821
|
+
);
|
|
3822
|
+
const parsed = await readJsonBody(response);
|
|
3823
|
+
if (response.status < 200 || response.status >= 300) {
|
|
3824
|
+
throw brassErrorFromBody(response.status, parsed);
|
|
3825
|
+
}
|
|
3826
|
+
return parsed as T;
|
|
3827
|
+
};
|
|
3828
|
+
return {
|
|
3829
|
+
claimImport: () =>
|
|
3830
|
+
call<ImportClaimResponse>('POST', '/import-claim', {
|
|
3831
|
+
client_id: guestImportClientId,
|
|
3832
|
+
stages: true,
|
|
3833
|
+
}),
|
|
3834
|
+
async stageImport(bundle) {
|
|
3835
|
+
const staged = await call<{ upload_key: string; url: string }>(
|
|
3836
|
+
'POST',
|
|
3837
|
+
'/import-complete/upload',
|
|
3838
|
+
{ byte_size: bundle.byteLength },
|
|
3839
|
+
);
|
|
3840
|
+
const put = await fetch(staged.url, {
|
|
3841
|
+
method: 'PUT',
|
|
3842
|
+
body: bundle.buffer.slice(
|
|
3843
|
+
bundle.byteOffset,
|
|
3844
|
+
bundle.byteOffset + bundle.byteLength,
|
|
3845
|
+
) as ArrayBuffer,
|
|
3846
|
+
});
|
|
3847
|
+
if (!put.ok) {
|
|
3848
|
+
throw new BrassError('Uploading the import failed', { status: put.status });
|
|
3849
|
+
}
|
|
3850
|
+
return staged.upload_key;
|
|
3851
|
+
},
|
|
3852
|
+
completeImport: async (report, uploadKey) =>
|
|
3853
|
+
awaitSeal(
|
|
3854
|
+
await call<unknown>('POST', '/import-complete', {
|
|
3855
|
+
client_id: guestImportClientId,
|
|
3856
|
+
...(report !== undefined ? { report } : {}),
|
|
3857
|
+
upload_key: uploadKey,
|
|
3858
|
+
}),
|
|
3859
|
+
(sealId) => call<SealStatus>('GET', `/seals/${encodeURIComponent(sealId)}`),
|
|
3860
|
+
),
|
|
3861
|
+
failImport: (message) =>
|
|
3862
|
+
call<ImportFailedResponse>('POST', '/import-failed', {
|
|
3863
|
+
client_id: guestImportClientId,
|
|
3864
|
+
message,
|
|
3865
|
+
}),
|
|
3866
|
+
sourceBlob: () => call<DocumentSourceBlob>('GET', '/source-blob'),
|
|
3867
|
+
};
|
|
3868
|
+
};
|
|
3869
|
+
|
|
3870
|
+
const claimAnonymousDocument: DocumentFactory['claimAnonymousDocument'] =
|
|
3871
|
+
async (docId, args) => {
|
|
3872
|
+
const token = await requireToken();
|
|
3873
|
+
const body: Record<string, unknown> = {
|
|
3874
|
+
link_token: args.linkToken,
|
|
3875
|
+
claim_secret: args.claimSecret,
|
|
3876
|
+
};
|
|
3877
|
+
if (args.destination !== undefined) body['destination'] = args.destination;
|
|
3878
|
+
if (args.name !== undefined) body['name'] = args.name;
|
|
3879
|
+
const headers: Record<string, string> = {
|
|
3880
|
+
authorization: `Bearer ${token}`,
|
|
3881
|
+
'content-type': 'application/json',
|
|
3882
|
+
};
|
|
3883
|
+
applyClientVersionHeader(headers);
|
|
3884
|
+
await applyAppAssertionHeader(headers, opts.getAppAssertion);
|
|
3885
|
+
const response = await fetchWithRetry(
|
|
3886
|
+
() =>
|
|
3887
|
+
fetch(`${baseUrl}/documents/${encodeURIComponent(docId)}/claim`, {
|
|
3888
|
+
method: 'POST',
|
|
3889
|
+
headers,
|
|
3890
|
+
body: JSON.stringify(body),
|
|
3891
|
+
}),
|
|
3892
|
+
{ policy: RETRY_WRITE_503, baseDelayMs: 250 },
|
|
3893
|
+
);
|
|
3894
|
+
const text = await response.text();
|
|
3895
|
+
let parsed: unknown = undefined;
|
|
3896
|
+
let parseFailed = false;
|
|
3897
|
+
if (text) {
|
|
3898
|
+
try {
|
|
3899
|
+
parsed = JSON.parse(text);
|
|
3900
|
+
} catch {
|
|
3901
|
+
parseFailed = true;
|
|
3902
|
+
}
|
|
3903
|
+
}
|
|
3904
|
+
if (response.status >= 200 && response.status < 300) {
|
|
3905
|
+
return { kind: 'claimed', record: selectionFromDetail(parsed) };
|
|
3906
|
+
}
|
|
3907
|
+
if (response.status === 409 && !parseFailed) {
|
|
3908
|
+
const code = (parsed as { code?: unknown })?.code;
|
|
3909
|
+
if (code === 'destination_required') return { kind: 'needs-destination' };
|
|
3910
|
+
if (code === 'no_organization') return { kind: 'needs-organization' };
|
|
3911
|
+
}
|
|
3912
|
+
throw brassErrorFromBody(response.status, parseFailed ? undefined : parsed);
|
|
3913
|
+
};
|
|
3914
|
+
|
|
3915
|
+
return {
|
|
3916
|
+
open,
|
|
3917
|
+
create,
|
|
3918
|
+
createDocumentId,
|
|
3919
|
+
openByLink,
|
|
3920
|
+
mintAnonymousDocument,
|
|
3921
|
+
anonymousUploadTypes,
|
|
3922
|
+
mintAnonymousDocumentFromFile,
|
|
3923
|
+
guestImports,
|
|
3924
|
+
claimAnonymousDocument,
|
|
3925
|
+
};
|
|
3926
|
+
}
|
|
3927
|
+
|
|
3928
|
+
// The claimed document in the picker's shape, read off the claim's
|
|
3929
|
+
// `DocumentDetail` answer; null when a field the shape needs is missing.
|
|
3930
|
+
function selectionFromDetail(parsed: unknown): SelectDocumentResult | null {
|
|
3931
|
+
const d = parsed as Record<string, unknown> | undefined | null;
|
|
3932
|
+
if (!d || typeof d !== 'object') return null;
|
|
3933
|
+
const str = (key: string): string | null =>
|
|
3934
|
+
typeof d[key] === 'string' ? (d[key] as string) : null;
|
|
3935
|
+
const docId = str('doc_id');
|
|
3936
|
+
const organizationId = str('organization_id');
|
|
3937
|
+
const name = str('name');
|
|
3938
|
+
const createdAt = str('created_at');
|
|
3939
|
+
const updatedAt = str('updated_at');
|
|
3940
|
+
const role = d['role'];
|
|
3941
|
+
const projectId = d['project_id'];
|
|
3942
|
+
if (
|
|
3943
|
+
docId === null ||
|
|
3944
|
+
organizationId === null ||
|
|
3945
|
+
name === null ||
|
|
3946
|
+
createdAt === null ||
|
|
3947
|
+
updatedAt === null ||
|
|
3948
|
+
(role !== 'owner' && role !== 'editor' && role !== 'viewer') ||
|
|
3949
|
+
(projectId !== null && typeof projectId !== 'string')
|
|
3950
|
+
) {
|
|
3951
|
+
return null;
|
|
3952
|
+
}
|
|
3953
|
+
return {
|
|
3954
|
+
doc_id: docId,
|
|
3955
|
+
organization_id: organizationId,
|
|
3956
|
+
project_id: projectId ?? null,
|
|
3957
|
+
role,
|
|
3958
|
+
name,
|
|
3959
|
+
created_at: createdAt,
|
|
3960
|
+
updated_at: updatedAt,
|
|
3961
|
+
};
|
|
3962
|
+
}
|
|
3963
|
+
|
|
3964
|
+
// The expiry a link view carries for a document minted without an account,
|
|
3965
|
+
// or null for every other link.
|
|
3966
|
+
function anonymousExpiry(body: {
|
|
3967
|
+
anonymous?: unknown;
|
|
3968
|
+
expires_at?: unknown;
|
|
3969
|
+
}): Date | null {
|
|
3970
|
+
if (body.anonymous !== true || typeof body.expires_at !== 'string') {
|
|
3971
|
+
return null;
|
|
3972
|
+
}
|
|
3973
|
+
const at = new Date(body.expires_at);
|
|
3974
|
+
return Number.isNaN(at.getTime()) ? null : at;
|
|
2984
3975
|
}
|
|
2985
3976
|
|
|
2986
3977
|
interface WireDocTransportArgs {
|
|
@@ -3035,6 +4026,11 @@ interface WireDocTransportArgs {
|
|
|
3035
4026
|
// is the safe point to reconcile a pending open whose terminal
|
|
3036
4027
|
// `doc_status` frame may have been dropped. Absent in transport-only tests.
|
|
3037
4028
|
reconcileStatusOnInit?: () => void;
|
|
4029
|
+
// Invoked on a `doc_status` frame that would lock a document not currently
|
|
4030
|
+
// pending: checks the row before applying it, since such a frame can be
|
|
4031
|
+
// the stream's late delivery of a phase the reconcile already read past.
|
|
4032
|
+
// Absent in transport-only tests, where the frame applies directly.
|
|
4033
|
+
verifyPendingStatus?: (status: DocumentWorkStatus) => void;
|
|
3038
4034
|
// Invoked on every frame carrying server-side movement (`doc_init`, a
|
|
3039
4035
|
// remote op). Evidence the document has content to pull for a client whose
|
|
3040
4036
|
// content pull gave up. Absent in transport-only tests.
|
|
@@ -3173,7 +4169,13 @@ function takeParkedDocument(
|
|
|
3173
4169
|
options: OpenDocumentByIdOptions | undefined,
|
|
3174
4170
|
identity: string | null,
|
|
3175
4171
|
): Document | null {
|
|
4172
|
+
// A parked Document is a live, writable session with its subscription still
|
|
4173
|
+
// alive. Neither pinned open may take one: it would hand back a document
|
|
4174
|
+
// that receives ops and accepts writes, which is the opposite of what both
|
|
4175
|
+
// asked for. Parking itself already refuses them (`!doc.readOnly`), so this
|
|
4176
|
+
// is the other half of that.
|
|
3176
4177
|
if (options?.version !== undefined) return null;
|
|
4178
|
+
if (options?.frozen === true) return null;
|
|
3177
4179
|
const key = parkCacheKey(docId, options?.branch, options?.stream);
|
|
3178
4180
|
if (!key) return null;
|
|
3179
4181
|
const entry = parkedDocs.get(key);
|
|
@@ -3211,6 +4213,24 @@ export function clearParkedDocuments(): void {
|
|
|
3211
4213
|
// a clean slate between cases.
|
|
3212
4214
|
export function _resetParkCacheForTesting(): void {
|
|
3213
4215
|
clearParkedDocuments();
|
|
4216
|
+
subscriptionEndedAt.clear();
|
|
4217
|
+
}
|
|
4218
|
+
|
|
4219
|
+
// When each doc topic last had its subscription ended by the server, so a
|
|
4220
|
+
// copy re-opened after one ends for good on a second inside the window. The
|
|
4221
|
+
// re-open asks the HTTP join, and a socket gate that keeps refusing what the
|
|
4222
|
+
// join admits would otherwise re-open the document in a loop.
|
|
4223
|
+
const SUBSCRIPTION_ENDED_REOPEN_WINDOW_MS = 60_000;
|
|
4224
|
+
const subscriptionEndedAt = new Map<string, number>();
|
|
4225
|
+
|
|
4226
|
+
function endedReasonFor(topic: string): 'moved' | 'deleted' {
|
|
4227
|
+
const now = Date.now();
|
|
4228
|
+
for (const [key, at] of subscriptionEndedAt) {
|
|
4229
|
+
if (now - at >= SUBSCRIPTION_ENDED_REOPEN_WINDOW_MS) subscriptionEndedAt.delete(key);
|
|
4230
|
+
}
|
|
4231
|
+
const recent = subscriptionEndedAt.has(topic);
|
|
4232
|
+
subscriptionEndedAt.set(topic, now);
|
|
4233
|
+
return recent ? 'deleted' : 'moved';
|
|
3214
4234
|
}
|
|
3215
4235
|
|
|
3216
4236
|
// Wire the WS transport onto a materialised Document. Three pieces:
|
|
@@ -3268,6 +4288,17 @@ export const RESEND_MAX_DELAY_MS = 30_000;
|
|
|
3268
4288
|
// Exported for the fake-timer tests.
|
|
3269
4289
|
export const RESEND_FORCE_CEILING_MS = 60_000;
|
|
3270
4290
|
|
|
4291
|
+
// How long one op may go unacked on the socket before the reader is told,
|
|
4292
|
+
// through `status`, that their changes are not saved. A server that answers
|
|
4293
|
+
// an op anonymously every time (an error frame naming no op) draws a resend
|
|
4294
|
+
// per backoff step, or a forced one per ceiling window, forever, while
|
|
4295
|
+
// `savingCount` stays above zero with nothing on screen saying why. Three
|
|
4296
|
+
// ceiling windows is past any loss a resend recovers from, since a lost op
|
|
4297
|
+
// is answered on its first forced resend. The notice changes nothing about
|
|
4298
|
+
// how the op is resent, and clears itself once nothing is waiting.
|
|
4299
|
+
// Exported for the fake-timer tests.
|
|
4300
|
+
export const UNANSWERED_OP_NOTICE_MS = 3 * RESEND_FORCE_CEILING_MS;
|
|
4301
|
+
|
|
3271
4302
|
// Server-shed recovery policy, driven by `unavailable` error frames. The
|
|
3272
4303
|
// frame names no op, so a round re-asserts every outstanding op at once and
|
|
3273
4304
|
// any further frame arriving before that round runs is part of the same shed
|
|
@@ -3285,16 +4316,7 @@ const UNAVAILABLE_BASE_DELAY_MS = 1000;
|
|
|
3285
4316
|
const UNAVAILABLE_MAX_DELAY_MS = 30_000;
|
|
3286
4317
|
const UNAVAILABLE_GIVE_UP_MESSAGE =
|
|
3287
4318
|
'The server could not store this change, and re-sending it did not ' +
|
|
3288
|
-
'help. Reload
|
|
3289
|
-
|
|
3290
|
-
// Reported for every op left behind when the server ends this document's
|
|
3291
|
-
// subscription (it refused the subscribe, or evicted it again after the one
|
|
3292
|
-
// silent re-subscribe a connection grants). The ops can never be acked on a
|
|
3293
|
-
// subscription that is over, and a fresh one arrives only with a fresh open,
|
|
3294
|
-
// so the app's cue is to reload.
|
|
3295
|
-
const SUBSCRIPTION_ENDED_MESSAGE =
|
|
3296
|
-
'The live connection to this document ended, so this change was not ' +
|
|
3297
|
-
'saved. Reload the document to continue from the last saved state.';
|
|
4319
|
+
'help. Reload to continue from the last saved state.';
|
|
3298
4320
|
|
|
3299
4321
|
/** How long the Nth (1-based) recovery round waits before re-asserting. */
|
|
3300
4322
|
function unavailableDelayMs(round: number): number {
|
|
@@ -3311,16 +4333,38 @@ const TOO_LARGE_MESSAGE =
|
|
|
3311
4333
|
'update; store the bulk of it as a file instead, or split it across ' +
|
|
3312
4334
|
'several records.';
|
|
3313
4335
|
|
|
3314
|
-
// What a guest hears for an op no request body can carry. A signed-in
|
|
3315
|
-
// editor stages such an op to storage first; that route is behind the
|
|
3316
|
-
// sign-in gate, so the anonymous tier writes ops that fit one request.
|
|
3317
|
-
const LINK_OP_TOO_LARGE_MESSAGE =
|
|
3318
|
-
'This change is too large to save over a shared link. Sign in to the ' +
|
|
3319
|
-
'document to make a change this size.';
|
|
3320
4336
|
|
|
3321
4337
|
function wireDocTransport(args: WireDocTransportArgs): void {
|
|
3322
4338
|
const topic = `doc:${args.docId}:${args.branchId}`;
|
|
3323
4339
|
|
|
4340
|
+
// A remote op over frozen content means the document is now a CRDT. The
|
|
4341
|
+
// re-join keeps the open's read intent and is served that CRDT, which
|
|
4342
|
+
// `applyJoin` swaps in under the same handles. A run that leaves the
|
|
4343
|
+
// document on frozen content (the join failed, or the document is showing
|
|
4344
|
+
// a version) falls back to the reload. Ops arriving during the run ask the
|
|
4345
|
+
// resync for another pass rather than starting a second upgrade.
|
|
4346
|
+
const resync = args.resyncFromSnapshot;
|
|
4347
|
+
if (resync !== undefined) {
|
|
4348
|
+
let upgradeRun: Promise<void> | null = null;
|
|
4349
|
+
args.doc._setStorageUpgrader(() => {
|
|
4350
|
+
if (upgradeRun !== null) {
|
|
4351
|
+
void Promise.resolve(resync()).catch(() => {});
|
|
4352
|
+
return;
|
|
4353
|
+
}
|
|
4354
|
+
upgradeRun = (async () => {
|
|
4355
|
+
try {
|
|
4356
|
+
await resync();
|
|
4357
|
+
} catch {
|
|
4358
|
+
// Judged by what the document holds afterwards.
|
|
4359
|
+
}
|
|
4360
|
+
const doc = args.doc;
|
|
4361
|
+
if (doc._holdsRecordSet() && !doc._isClosed && doc._goneReason === null) doc._reopen();
|
|
4362
|
+
})().finally(() => {
|
|
4363
|
+
upgradeRun = null;
|
|
4364
|
+
});
|
|
4365
|
+
});
|
|
4366
|
+
}
|
|
4367
|
+
|
|
3324
4368
|
// Per-op retry tracker. The op-write endpoint can answer with an untyped
|
|
3325
4369
|
// error frame instead of reaching the handler. That frame has no `type`, so
|
|
3326
4370
|
// the multiplexer drops it,
|
|
@@ -3356,7 +4400,7 @@ function wireDocTransport(args: WireDocTransportArgs): void {
|
|
|
3356
4400
|
const outstandingResends = new Map<
|
|
3357
4401
|
string,
|
|
3358
4402
|
{
|
|
3359
|
-
frame:
|
|
4403
|
+
frame: OpFrame;
|
|
3360
4404
|
attempts: number;
|
|
3361
4405
|
timer: ReturnType<typeof setTimeout>;
|
|
3362
4406
|
// When this op's current timer was armed, compared against the last
|
|
@@ -3374,6 +4418,9 @@ function wireDocTransport(args: WireDocTransportArgs): void {
|
|
|
3374
4418
|
// neither queueing nor forgetting an op has to scan the queue, which
|
|
3375
4419
|
// would be quadratic over a mutation that queues thousands.
|
|
3376
4420
|
queued: boolean;
|
|
4421
|
+
// When this op was first put on the socket, or 0 before then. See
|
|
4422
|
+
// `UNANSWERED_OP_NOTICE_MS`.
|
|
4423
|
+
firstDispatchedAt: number;
|
|
3377
4424
|
}
|
|
3378
4425
|
>();
|
|
3379
4426
|
|
|
@@ -3422,6 +4469,7 @@ function wireDocTransport(args: WireDocTransportArgs): void {
|
|
|
3422
4469
|
entry.queued = false;
|
|
3423
4470
|
entry.inFlight = size;
|
|
3424
4471
|
entry.dispatchedAt = Date.now();
|
|
4472
|
+
if (entry.firstDispatchedAt === 0) entry.firstDispatchedAt = entry.dispatchedAt;
|
|
3425
4473
|
inFlightBytes += size;
|
|
3426
4474
|
args.wsClient.send(entry.frame);
|
|
3427
4475
|
scheduleResend(clientOpId);
|
|
@@ -3598,6 +4646,12 @@ function wireDocTransport(args: WireDocTransportArgs): void {
|
|
|
3598
4646
|
entry.timer = setTimeout(() => {
|
|
3599
4647
|
const current = outstandingResends.get(clientOpId);
|
|
3600
4648
|
if (!current) return;
|
|
4649
|
+
if (
|
|
4650
|
+
current.firstDispatchedAt !== 0 &&
|
|
4651
|
+
Date.now() - current.firstDispatchedAt > UNANSWERED_OP_NOTICE_MS
|
|
4652
|
+
) {
|
|
4653
|
+
args.doc._showConnectionNotice('unanswered');
|
|
4654
|
+
}
|
|
3601
4655
|
const forced = forceResendDue(clientOpId, current);
|
|
3602
4656
|
if (lastAckAt >= current.armedAt && !forced) {
|
|
3603
4657
|
scheduleResend(clientOpId);
|
|
@@ -3720,6 +4774,25 @@ function wireDocTransport(args: WireDocTransportArgs): void {
|
|
|
3720
4774
|
|
|
3721
4775
|
args.doc._setDiagnosticsProvider(collectDiagnostics);
|
|
3722
4776
|
|
|
4777
|
+
// A network that refuses WebSocket upgrades opens the document over HTTP
|
|
4778
|
+
// and then carries none of its edits: they apply locally and queue, and the
|
|
4779
|
+
// reconnect loop reports only after its full budget (about five minutes),
|
|
4780
|
+
// so a reload inside that window loses them with nothing said. When edits
|
|
4781
|
+
// have waited `NEVER_OPENED_NOTICE_MS` on a client that has never opened a
|
|
4782
|
+
// socket, the document says so on `status`. Never-opened rather than
|
|
4783
|
+
// currently-closed, because a connection that has worked recovers through
|
|
4784
|
+
// the resend machinery and a drop there is ordinarily brief.
|
|
4785
|
+
let neverOpenedTimer: ReturnType<typeof setTimeout> | null = null;
|
|
4786
|
+
function armNeverOpenedNotice(): void {
|
|
4787
|
+
if (neverOpenedTimer !== null || args.wsClient.everOpened !== false) return;
|
|
4788
|
+
neverOpenedTimer = setTimeout(() => {
|
|
4789
|
+
neverOpenedTimer = null;
|
|
4790
|
+
if (args.wsClient.everOpened !== false || outstandingResends.size === 0) return;
|
|
4791
|
+
args.doc._showConnectionNotice();
|
|
4792
|
+
}, NEVER_OPENED_NOTICE_MS);
|
|
4793
|
+
(neverOpenedTimer as { unref?: () => void }).unref?.();
|
|
4794
|
+
}
|
|
4795
|
+
|
|
3723
4796
|
// Resend every outstanding op now and restart its backoff. Called on
|
|
3724
4797
|
// reconnect (`doc_init`): a dropped-and-restored connection is the most
|
|
3725
4798
|
// common reason an op went unacked, and the backoff may have grown to its
|
|
@@ -3782,15 +4855,18 @@ function wireDocTransport(args: WireDocTransportArgs): void {
|
|
|
3782
4855
|
// and never apply, and its own escape for an op nothing can apply is to
|
|
3783
4856
|
// discard it. Most refusals do not change that: the handshake pushes
|
|
3784
4857
|
// whatever slice the durable frontier is missing and splits it under the
|
|
3785
|
-
// frame cap, so it heals the dependents
|
|
3786
|
-
//
|
|
4858
|
+
// frame cap, so it heals the dependents of an op a transient refusal
|
|
4859
|
+
// dropped.
|
|
3787
4860
|
//
|
|
3788
4861
|
// `healable: false` is for the refusal where pushing again is asking the
|
|
3789
4862
|
// same question. A full document refuses every op whatever its size, so
|
|
3790
4863
|
// the handshake's own chunks come back refused, and the counter reset
|
|
3791
4864
|
// below turns that into a loop rather than a bounded retry. What recovers
|
|
3792
4865
|
// that document is the periodic re-verify, once the server can take a
|
|
3793
|
-
// write at all.
|
|
4866
|
+
// write at all. An op too large for any transport this writer has is the
|
|
4867
|
+
// same: a split runs between changes, never inside one, so the slice the
|
|
4868
|
+
// handshake would push still carries the whole op, and every edit after it
|
|
4869
|
+
// waits on an op the server will never hold.
|
|
3794
4870
|
// A refusal the heal cannot work around leaves the writer holding a change
|
|
3795
4871
|
// that reached no database and no other reader, so the writer is the only
|
|
3796
4872
|
// copy of the document that shows it. Left editable it reads as applied: the
|
|
@@ -3864,7 +4940,28 @@ function wireDocTransport(args: WireDocTransportArgs): void {
|
|
|
3864
4940
|
} catch {
|
|
3865
4941
|
// Told or not, the copy is resolved.
|
|
3866
4942
|
}
|
|
3867
|
-
args.doc.
|
|
4943
|
+
args.doc._reopen();
|
|
4944
|
+
}
|
|
4945
|
+
|
|
4946
|
+
function markGone(reason: 'moved' | 'deleted'): void {
|
|
4947
|
+
// Cancel every resend timer FIRST so a doc that was saving when delete
|
|
4948
|
+
// landed stops re-trying ops the server will only reject as `forbidden`
|
|
4949
|
+
// (the subscription record was evicted at fanout). Draining before the `_markGone`
|
|
4950
|
+
// listeners keeps the lifecycle observably clean.
|
|
4951
|
+
forgetAllOps();
|
|
4952
|
+
clearUnavailableRecovery();
|
|
4953
|
+
// Stop re-verifying durability against an evicted doc: a resync push
|
|
4954
|
+
// would only bounce as `forbidden`, and a gap pull would only 404.
|
|
4955
|
+
if (reverifyTimer !== null) {
|
|
4956
|
+
clearInterval(reverifyTimer);
|
|
4957
|
+
reverifyTimer = null;
|
|
4958
|
+
}
|
|
4959
|
+
clearDivergenceHeal();
|
|
4960
|
+
if (gapTimer !== null) {
|
|
4961
|
+
clearTimeout(gapTimer);
|
|
4962
|
+
gapTimer = null;
|
|
4963
|
+
}
|
|
4964
|
+
args.doc._markGone(reason);
|
|
3868
4965
|
}
|
|
3869
4966
|
|
|
3870
4967
|
function failOpTerminally(
|
|
@@ -3914,6 +5011,25 @@ function wireDocTransport(args: WireDocTransportArgs): void {
|
|
|
3914
5011
|
(unavailableTimer as { unref?: () => void }).unref?.();
|
|
3915
5012
|
}
|
|
3916
5013
|
|
|
5014
|
+
// The ops the durability handshake leaves to this transport: those still
|
|
5015
|
+
// queued behind the in-flight budget, and those first sent too recently for
|
|
5016
|
+
// their resend timer to have doubted them. An op past its first resend step
|
|
5017
|
+
// is fair game for the handshake, since the resend path does not promise it
|
|
5018
|
+
// arrives.
|
|
5019
|
+
function deliveringOpBlobs(): Uint8Array[] {
|
|
5020
|
+
const now = Date.now();
|
|
5021
|
+
const blobs: Uint8Array[] = [];
|
|
5022
|
+
for (const entry of outstandingResends.values()) {
|
|
5023
|
+
if (
|
|
5024
|
+
entry.firstDispatchedAt === 0 ||
|
|
5025
|
+
now - entry.firstDispatchedAt < RESEND_BASE_DELAY_MS
|
|
5026
|
+
) {
|
|
5027
|
+
blobs.push(base64ToBytes(entry.frame.op_blob));
|
|
5028
|
+
}
|
|
5029
|
+
}
|
|
5030
|
+
return blobs;
|
|
5031
|
+
}
|
|
5032
|
+
|
|
3917
5033
|
// Durability handshake, run on every `doc_init`. The persisted version
|
|
3918
5034
|
// vector + durable tail ops define the durable frontier; if the local doc
|
|
3919
5035
|
// holds CRDT knowledge beyond it (an op acked then discarded by the
|
|
@@ -3930,6 +5046,7 @@ function wireDocTransport(args: WireDocTransportArgs): void {
|
|
|
3930
5046
|
const outcome = args.doc.maybeResyncPush(
|
|
3931
5047
|
base64ToBytes(snapshotVersionVectorB64),
|
|
3932
5048
|
durableTailBlobs,
|
|
5049
|
+
deliveringOpBlobs(),
|
|
3933
5050
|
);
|
|
3934
5051
|
// Reported here as well as from the re-verify, or the record holds only
|
|
3935
5052
|
// the heals that took the slower route and an operator reading it cannot
|
|
@@ -3970,6 +5087,7 @@ function wireDocTransport(args: WireDocTransportArgs): void {
|
|
|
3970
5087
|
outcome = args.doc.maybeResyncPush(
|
|
3971
5088
|
base64ToBytes(frontier.snapshotVersionVector),
|
|
3972
5089
|
frontier.tailBlobs,
|
|
5090
|
+
deliveringOpBlobs(),
|
|
3973
5091
|
);
|
|
3974
5092
|
// The handshake having work to do means the server's copy was
|
|
3975
5093
|
// behind this client's, which is the state a lost op leaves and
|
|
@@ -4098,46 +5216,30 @@ function wireDocTransport(args: WireDocTransportArgs): void {
|
|
|
4098
5216
|
gapPullRounds = 0;
|
|
4099
5217
|
return;
|
|
4100
5218
|
}
|
|
4101
|
-
|
|
5219
|
+
// The head can be this client's own op still being written over HTTP,
|
|
5220
|
+
// so the judgement waits for that write as a catch-up prompt does.
|
|
5221
|
+
resyncPastSeq(awaited);
|
|
4102
5222
|
}, delay);
|
|
4103
5223
|
(gapTimer as { unref?: () => void }).unref?.();
|
|
4104
5224
|
}
|
|
4105
5225
|
|
|
4106
|
-
// The server ended this subscription: it refused the subscribe (
|
|
4107
|
-
//
|
|
4108
|
-
//
|
|
4109
|
-
//
|
|
5226
|
+
// The server ended this subscription: it refused the subscribe (access
|
|
5227
|
+
// that ended between the HTTP join and the subscribe, which no eviction can
|
|
5228
|
+
// reach because no subscription row exists yet), or evicted it again after
|
|
5229
|
+
// the one silent re-subscribe a connection grants. The client detaches this
|
|
5230
|
+
// transport from the topic as it reports that, so no ack, no fanout and no
|
|
5231
|
+
// reconnect reaches it again.
|
|
4110
5232
|
//
|
|
4111
|
-
//
|
|
4112
|
-
//
|
|
4113
|
-
//
|
|
4114
|
-
//
|
|
4115
|
-
//
|
|
4116
|
-
// its cue to reload, rather than a document that looks like it is saving
|
|
4117
|
-
// forever. The ids are taken before the reports because
|
|
4118
|
-
// `_failOutstandingOp` re-enters app code.
|
|
5233
|
+
// That is the same fact a `doc_gone` reports, so the copy ends the same
|
|
5234
|
+
// way: `moved`, which a session answers by re-opening the id, so the
|
|
5235
|
+
// current gate re-admits a reader who still reaches the document and
|
|
5236
|
+
// refuses one who does not. A second end inside the window is `deleted`,
|
|
5237
|
+
// which ends the copy without the re-open.
|
|
4119
5238
|
let subscriptionEnded = false;
|
|
4120
5239
|
function endSubscription(): void {
|
|
4121
5240
|
if (subscriptionEnded) return;
|
|
4122
5241
|
subscriptionEnded = true;
|
|
4123
|
-
|
|
4124
|
-
forgetAllOps();
|
|
4125
|
-
clearUnavailableRecovery();
|
|
4126
|
-
// The heal and the gap pull both work through this transport, so they
|
|
4127
|
-
// are stopped rather than armed: what they would push or fetch has no
|
|
4128
|
-
// subscription left to travel on.
|
|
4129
|
-
clearDivergenceHeal();
|
|
4130
|
-
if (reverifyTimer !== null) {
|
|
4131
|
-
clearInterval(reverifyTimer);
|
|
4132
|
-
reverifyTimer = null;
|
|
4133
|
-
}
|
|
4134
|
-
if (gapTimer !== null) {
|
|
4135
|
-
clearTimeout(gapTimer);
|
|
4136
|
-
gapTimer = null;
|
|
4137
|
-
}
|
|
4138
|
-
for (const clientOpId of clientOpIds) {
|
|
4139
|
-
args.doc._failOutstandingOp(clientOpId, SUBSCRIPTION_ENDED_MESSAGE);
|
|
4140
|
-
}
|
|
5242
|
+
markGone(endedReasonFor(topic));
|
|
4141
5243
|
}
|
|
4142
5244
|
|
|
4143
5245
|
// 1. Subscribe to the doc topic. Frame routing:
|
|
@@ -4161,12 +5263,21 @@ function wireDocTransport(args: WireDocTransportArgs): void {
|
|
|
4161
5263
|
(frame) => {
|
|
4162
5264
|
const init = parseDocInitFrame(frame);
|
|
4163
5265
|
if (init) {
|
|
5266
|
+
// A new version replaced the line while this copy was away (the
|
|
5267
|
+
// `doc_gone` naming it went to a socket that was down). Its tail is
|
|
5268
|
+
// the new version's, and nothing this copy holds or would re-assert
|
|
5269
|
+
// applies to it, so it reopens before applying or resending anything.
|
|
5270
|
+
if (importSeqMoved(args.doc, init.import_seq)) {
|
|
5271
|
+
markGone('moved');
|
|
5272
|
+
return;
|
|
5273
|
+
}
|
|
4164
5274
|
// Recorded as the frame arrives rather than after the tail applies.
|
|
4165
5275
|
// Importing a tail op can throw, and the client swallows it, so a
|
|
4166
5276
|
// latch written further down would stay behind and read the NEXT
|
|
4167
5277
|
// reconnect as the opening subscribe, suppressing the report this
|
|
4168
5278
|
// exists to make.
|
|
4169
5279
|
const reconnects = args.wsClient.reconnectCount ?? 0;
|
|
5280
|
+
const wasSubscribed = docInitReconnectCount !== null;
|
|
4170
5281
|
const socketDropped =
|
|
4171
5282
|
docInitReconnectCount !== null && reconnects > docInitReconnectCount;
|
|
4172
5283
|
docInitReconnectCount = reconnects;
|
|
@@ -4255,6 +5366,9 @@ function wireDocTransport(args: WireDocTransportArgs): void {
|
|
|
4255
5366
|
reportSyncHealth('reconnected_unsaved');
|
|
4256
5367
|
}
|
|
4257
5368
|
resendAllOutstanding();
|
|
5369
|
+
// A nudge posted while the topic was down is gone, so every status
|
|
5370
|
+
// this document holds asks what moved since its revision.
|
|
5371
|
+
if (wasSubscribed) args.doc._inputStatusNudged(null, null);
|
|
4258
5372
|
// The handshake needs the WHOLE durable tail, and this frame carries
|
|
4259
5373
|
// it only when every entry came inline. A spilled op is named here
|
|
4260
5374
|
// with its bytes withheld, and the HTTP join the reader already ran
|
|
@@ -4328,7 +5442,7 @@ function wireDocTransport(args: WireDocTransportArgs): void {
|
|
|
4328
5442
|
// Evidence the connection is working through its queue, which is what
|
|
4329
5443
|
// the resend timers read to tell a slow ack from a silent server.
|
|
4330
5444
|
lastAckAt = Date.now();
|
|
4331
|
-
args.doc.resolveOutstandingOp(frame.client_op_id);
|
|
5445
|
+
args.doc.resolveOutstandingOp(frame.client_op_id, frame.seq);
|
|
4332
5446
|
args.setCurrentSeq(frame.seq);
|
|
4333
5447
|
args.advanceCoveredSeq(frame.seq);
|
|
4334
5448
|
// An ack for a local op sits above any remote op this client never
|
|
@@ -4351,6 +5465,13 @@ function wireDocTransport(args: WireDocTransportArgs): void {
|
|
|
4351
5465
|
// the Document so the app hears "could not be saved" instead of a silent
|
|
4352
5466
|
// wedge. Scoped to this one op; the topic subscription stays live.
|
|
4353
5467
|
forgetOp(frame.client_op_id);
|
|
5468
|
+
// A new version replaced the line under this copy. Not healable and
|
|
5469
|
+
// not this op's alone: every op it holds names the replaced version,
|
|
5470
|
+
// so the copy reopens, which drops them all.
|
|
5471
|
+
if (frame.reason === OP_REJECTED_MOVED) {
|
|
5472
|
+
markGone('moved');
|
|
5473
|
+
return;
|
|
5474
|
+
}
|
|
4354
5475
|
// The flip is the one moment the refusal is worth reporting: every op
|
|
4355
5476
|
// in flight draws the same sentence, and a bulk write has as many of
|
|
4356
5477
|
// those as it has unacked writes.
|
|
@@ -4376,30 +5497,13 @@ function wireDocTransport(args: WireDocTransportArgs): void {
|
|
|
4376
5497
|
// slice under the frame cap, which is what reaches the dependents of
|
|
4377
5498
|
// an op that is itself too large to re-send.
|
|
4378
5499
|
failOpTerminally(frame.client_op_id, opRejectedMessage(frame), {
|
|
4379
|
-
healable: !atCapacity,
|
|
5500
|
+
healable: !atCapacity && frame.reason !== OP_REJECTED_NOT_IMPORTED,
|
|
4380
5501
|
report: !atCapacity || firstCapacityRefusal,
|
|
4381
5502
|
});
|
|
4382
5503
|
return;
|
|
4383
5504
|
}
|
|
4384
5505
|
if (isDocGoneFrame(frame)) {
|
|
4385
|
-
|
|
4386
|
-
// landed stops re-trying ops the server will only reject as `forbidden`
|
|
4387
|
-
// (the subscription record was evicted at fanout). Draining before the `_markGone`
|
|
4388
|
-
// listeners keeps the lifecycle observably clean.
|
|
4389
|
-
forgetAllOps();
|
|
4390
|
-
clearUnavailableRecovery();
|
|
4391
|
-
// Stop re-verifying durability against an evicted doc: a resync push
|
|
4392
|
-
// would only bounce as `forbidden`, and a gap pull would only 404.
|
|
4393
|
-
if (reverifyTimer !== null) {
|
|
4394
|
-
clearInterval(reverifyTimer);
|
|
4395
|
-
reverifyTimer = null;
|
|
4396
|
-
}
|
|
4397
|
-
clearDivergenceHeal();
|
|
4398
|
-
if (gapTimer !== null) {
|
|
4399
|
-
clearTimeout(gapTimer);
|
|
4400
|
-
gapTimer = null;
|
|
4401
|
-
}
|
|
4402
|
-
args.doc._markGone(goneReasonOf(frame));
|
|
5506
|
+
markGone(goneReasonOf(frame));
|
|
4403
5507
|
return;
|
|
4404
5508
|
}
|
|
4405
5509
|
if (isDocStatusFrame(frame)) {
|
|
@@ -4411,6 +5515,13 @@ function wireDocTransport(args: WireDocTransportArgs): void {
|
|
|
4411
5515
|
// nothing (no racy read, no loop).
|
|
4412
5516
|
if (frame.reload && args.reloadAfterHydration) {
|
|
4413
5517
|
args.reloadAfterHydration(status);
|
|
5518
|
+
} else if (
|
|
5519
|
+
status.pending &&
|
|
5520
|
+
status.blocking !== false &&
|
|
5521
|
+
args.doc._workStatusNow?.pending !== true &&
|
|
5522
|
+
args.verifyPendingStatus
|
|
5523
|
+
) {
|
|
5524
|
+
args.verifyPendingStatus(status);
|
|
4414
5525
|
} else {
|
|
4415
5526
|
args.doc._emitStatus(status);
|
|
4416
5527
|
}
|
|
@@ -4421,6 +5532,13 @@ function wireDocTransport(args: WireDocTransportArgs): void {
|
|
|
4421
5532
|
args.doc._setName(frame.name);
|
|
4422
5533
|
return;
|
|
4423
5534
|
}
|
|
5535
|
+
const nudge = parseInputStatusNudge(frame);
|
|
5536
|
+
if (nudge !== null) {
|
|
5537
|
+
// The frame names the key and its new revision alone; the status
|
|
5538
|
+
// fetches what moved.
|
|
5539
|
+
args.doc._inputStatusNudged(nudge.key, nudge.rev);
|
|
5540
|
+
return;
|
|
5541
|
+
}
|
|
4424
5542
|
if (isDocDriveStatusFrame(frame)) {
|
|
4425
5543
|
// The signal de-dupes a no-op re-emit, so forwarding every frame is safe.
|
|
4426
5544
|
args.doc._emitDriveStatus(toDriveStatus(frame.drive));
|
|
@@ -4469,33 +5587,14 @@ function wireDocTransport(args: WireDocTransportArgs): void {
|
|
|
4469
5587
|
// tracker so a silently-dropped server response triggers a retry.
|
|
4470
5588
|
args.doc.setLocalOpHook((opBlob) => {
|
|
4471
5589
|
const clientOpId = args.newClientOpId();
|
|
4472
|
-
// Nothing carries an op once the subscription is over, so it is failed
|
|
4473
|
-
// instead of tracked: an entry nothing can ack pins `savingCount` and
|
|
4474
|
-
// its resends reach a topic the server refused. The caller records the
|
|
4475
|
-
// entry AFTER this hook returns, so the failure waits a microtask for
|
|
4476
|
-
// there to be one to fail.
|
|
4477
|
-
//
|
|
4478
|
-
// Taken before the op is timed, because this path fails the op through
|
|
4479
|
-
// the Document directly rather than through `failOpTerminally`, so a
|
|
4480
|
-
// timing entry written here is one nothing ever drops.
|
|
4481
|
-
if (subscriptionEnded) {
|
|
4482
|
-
void Promise.resolve().then(() => {
|
|
4483
|
-
args.doc._failOutstandingOp(clientOpId, SUBSCRIPTION_ENDED_MESSAGE);
|
|
4484
|
-
});
|
|
4485
|
-
return clientOpId;
|
|
4486
|
-
}
|
|
4487
5590
|
opStartedAt.set(clientOpId, Date.now());
|
|
4488
5591
|
const opBlobB64 = bytesToBase64(opBlob);
|
|
4489
|
-
const sendFrame: {
|
|
4490
|
-
type: 'op';
|
|
4491
|
-
topic: string;
|
|
4492
|
-
op_blob: string;
|
|
4493
|
-
client_op_id: string;
|
|
4494
|
-
} = {
|
|
5592
|
+
const sendFrame: OpFrame = {
|
|
4495
5593
|
type: 'op',
|
|
4496
5594
|
topic,
|
|
4497
5595
|
op_blob: opBlobB64,
|
|
4498
5596
|
client_op_id: clientOpId,
|
|
5597
|
+
...baseImportSeqField(args.doc),
|
|
4499
5598
|
};
|
|
4500
5599
|
|
|
4501
5600
|
// A frame past the transport's cap is not delivered and takes the
|
|
@@ -4519,8 +5618,10 @@ function wireDocTransport(args: WireDocTransportArgs): void {
|
|
|
4519
5618
|
dispatchedAt: 0,
|
|
4520
5619
|
inFlight: 0,
|
|
4521
5620
|
queued: false,
|
|
5621
|
+
firstDispatchedAt: 0,
|
|
4522
5622
|
});
|
|
4523
5623
|
dispatchOp(clientOpId);
|
|
5624
|
+
armNeverOpenedNotice();
|
|
4524
5625
|
return clientOpId;
|
|
4525
5626
|
});
|
|
4526
5627
|
|
|
@@ -4552,12 +5653,12 @@ function wireDocTransport(args: WireDocTransportArgs): void {
|
|
|
4552
5653
|
// The size is known here, so refuse without spending a round trip on a
|
|
4553
5654
|
// blob that can only come back 400.
|
|
4554
5655
|
if (opBlobExceedsDurableLimit(opBlobB64)) {
|
|
4555
|
-
failOpTerminally(clientOpId, TOO_LARGE_MESSAGE);
|
|
5656
|
+
failOpTerminally(clientOpId, TOO_LARGE_MESSAGE, { healable: false });
|
|
4556
5657
|
return;
|
|
4557
5658
|
}
|
|
4558
5659
|
|
|
4559
5660
|
if (!args.postOversizedOp) {
|
|
4560
|
-
failOpTerminally(clientOpId, TOO_LARGE_MESSAGE);
|
|
5661
|
+
failOpTerminally(clientOpId, TOO_LARGE_MESSAGE, { healable: false });
|
|
4561
5662
|
return;
|
|
4562
5663
|
}
|
|
4563
5664
|
// An oversized op is deliberately kept out of `outstandingResends` (a
|
|
@@ -4596,6 +5697,11 @@ function wireDocTransport(args: WireDocTransportArgs): void {
|
|
|
4596
5697
|
opStartedAt.delete(clientOpId);
|
|
4597
5698
|
return;
|
|
4598
5699
|
}
|
|
5700
|
+
if (!result.ok && result.moved === true) {
|
|
5701
|
+
opStartedAt.delete(clientOpId);
|
|
5702
|
+
markGone('moved');
|
|
5703
|
+
return;
|
|
5704
|
+
}
|
|
4599
5705
|
if (!result.ok) {
|
|
4600
5706
|
// Terminal for this op, the same way `op_rejected` is: the local
|
|
4601
5707
|
// commit stays applied but never persists, so the app hears about it
|
|
@@ -4609,7 +5715,10 @@ function wireDocTransport(args: WireDocTransportArgs): void {
|
|
|
4609
5715
|
clientOpId,
|
|
4610
5716
|
result.retryable ? UNAVAILABLE_GIVE_UP_MESSAGE : result.message,
|
|
4611
5717
|
{
|
|
4612
|
-
healable:
|
|
5718
|
+
healable:
|
|
5719
|
+
result.atCapacity !== true &&
|
|
5720
|
+
result.notImported !== true &&
|
|
5721
|
+
result.tooLarge !== true,
|
|
4613
5722
|
report: result.atCapacity !== true || firstCapacityRefusal,
|
|
4614
5723
|
},
|
|
4615
5724
|
);
|
|
@@ -4618,7 +5727,7 @@ function wireDocTransport(args: WireDocTransportArgs): void {
|
|
|
4618
5727
|
// Same bookkeeping an `op_ack` does: resolve the outstanding entry and
|
|
4619
5728
|
// advance both cursors past the seq the server allocated.
|
|
4620
5729
|
opStartedAt.delete(clientOpId);
|
|
4621
|
-
args.doc.resolveOutstandingOp(clientOpId);
|
|
5730
|
+
args.doc.resolveOutstandingOp(clientOpId, result.seq);
|
|
4622
5731
|
args.setCurrentSeq(result.seq);
|
|
4623
5732
|
args.advanceCoveredSeq(result.seq);
|
|
4624
5733
|
}
|
|
@@ -4674,6 +5783,10 @@ function wireDocTransport(args: WireDocTransportArgs): void {
|
|
|
4674
5783
|
clearInterval(reverifyTimer);
|
|
4675
5784
|
reverifyTimer = null;
|
|
4676
5785
|
}
|
|
5786
|
+
if (neverOpenedTimer !== null) {
|
|
5787
|
+
clearTimeout(neverOpenedTimer);
|
|
5788
|
+
neverOpenedTimer = null;
|
|
5789
|
+
}
|
|
4677
5790
|
clearDivergenceHeal();
|
|
4678
5791
|
if (gapTimer !== null) {
|
|
4679
5792
|
clearTimeout(gapTimer);
|
|
@@ -4710,7 +5823,7 @@ function wireDocTransport(args: WireDocTransportArgs): void {
|
|
|
4710
5823
|
const parkable =
|
|
4711
5824
|
!args.doc.readOnly &&
|
|
4712
5825
|
args.parkable !== false &&
|
|
4713
|
-
args.doc.
|
|
5826
|
+
args.doc._workStatusNow?.pending !== true &&
|
|
4714
5827
|
args.doc._goneReason === null;
|
|
4715
5828
|
const cacheKey = parkable
|
|
4716
5829
|
? parkCacheKey(args.docId, args.requestedBranchId, args.requestedStream)
|
|
@@ -4786,18 +5899,237 @@ async function fetchPresignedSnapshot(
|
|
|
4786
5899
|
// legible upgrade prompt (rendered verbatim through `session.error`), never an
|
|
4787
5900
|
// empty document: treating an unreadable snapshot as "no snapshot" would let
|
|
4788
5901
|
// the user edit and overwrite content that is actually there.
|
|
4789
|
-
|
|
4790
|
-
|
|
4791
|
-
|
|
4792
|
-
|
|
4793
|
-
|
|
5902
|
+
/** Whether the server names a head above the seq its snapshot was sealed at,
|
|
5903
|
+
* which is state the payload has not delivered. Read beside `tail_ops`, so a
|
|
5904
|
+
* response that dropped its tail entries for budget is still seen as having
|
|
5905
|
+
* one rather than as a document that stops at its base. A server that sends
|
|
5906
|
+
* neither seq answers by its entries alone, which is all it ever had. */
|
|
5907
|
+
function coversMoreThanSnapshot(
|
|
5908
|
+
body: Pick<DocumentSnapshotResponse, 'current_seq' | 'snapshot_seq'>,
|
|
5909
|
+
): boolean {
|
|
5910
|
+
const { current_seq: current, snapshot_seq: sealed } = body;
|
|
5911
|
+
return current !== undefined && sealed !== undefined && current > sealed;
|
|
5912
|
+
}
|
|
5913
|
+
|
|
5914
|
+
/** The open with its top-level `readOnly` folded into `documentOptions`,
|
|
5915
|
+
* which is where every path below reads the intent from. The top-level
|
|
5916
|
+
* value wins over `documentOptions.readOnly` when both are set. */
|
|
5917
|
+
function withReadIntent(
|
|
5918
|
+
options: OpenDocumentByIdOptions | undefined,
|
|
5919
|
+
): OpenDocumentByIdOptions | undefined {
|
|
5920
|
+
if (options?.readOnly === undefined) return options;
|
|
5921
|
+
const { readOnly, ...rest } = options;
|
|
5922
|
+
const { readOnly: _alias, ...documentOptions } = rest.documentOptions ?? {};
|
|
5923
|
+
return {
|
|
5924
|
+
...rest,
|
|
5925
|
+
documentOptions: readOnly ? { ...documentOptions, readOnly: true } : documentOptions,
|
|
5926
|
+
};
|
|
5927
|
+
}
|
|
5928
|
+
|
|
5929
|
+
/** Whether this open will never write: the caller asked for a snapshot, or
|
|
5930
|
+
* for a read-only Document. The grant is not consulted, because a caller who
|
|
5931
|
+
* may edit and says nothing is one that may edit. */
|
|
5932
|
+
function opensReadOnly(options: OpenDocumentByIdOptions | undefined): boolean {
|
|
5933
|
+
return options?.frozen === true || options?.documentOptions?.readOnly === true;
|
|
5934
|
+
}
|
|
5935
|
+
|
|
5936
|
+
/** The options every re-join of `branch` sends, a member's and a guest's
|
|
5937
|
+
* alike: the line it names, and the read intent of the open it serves, so a
|
|
5938
|
+
* document opened read-only is re-joined read-only and one opened to write
|
|
5939
|
+
* is re-joined to write. */
|
|
5940
|
+
function rejoinOptions(
|
|
5941
|
+
branch: string | undefined,
|
|
5942
|
+
open: OpenDocumentByIdOptions | undefined,
|
|
5943
|
+
): OpenDocumentByIdOptions {
|
|
5944
|
+
const base = branch !== undefined ? { branch } : {};
|
|
5945
|
+
return opensReadOnly(open) ? { ...base, documentOptions: { readOnly: true } } : base;
|
|
5946
|
+
}
|
|
5947
|
+
|
|
5948
|
+
/** What re-joining an open document did to it. */
|
|
5949
|
+
type JoinApplied =
|
|
5950
|
+
/** The join's content and tail were imported into the document. */
|
|
5951
|
+
| { kind: 'applied'; content: JoinContent; tailSeqs: number[] }
|
|
5952
|
+
/** The document holds the frozen content the join serves. */
|
|
5953
|
+
| { kind: 'unchanged' }
|
|
5954
|
+
/** The join serves content the document cannot take in place, so it was
|
|
5955
|
+
* reloaded. */
|
|
5956
|
+
| { kind: 'reloaded' };
|
|
5957
|
+
|
|
5958
|
+
/**
|
|
5959
|
+
* Apply a re-join to an open document: the reload, a line's resync and a
|
|
5960
|
+
* guest's pull all end here.
|
|
5961
|
+
*
|
|
5962
|
+
* CRDT content over a CRDT document merges in place. Over frozen content it is
|
|
5963
|
+
* a storage upgrade (`Document._upgradeStorage`), and frozen content sealed at
|
|
5964
|
+
* another seq replaces the frozen content; both keep the app's handles and
|
|
5965
|
+
* their subscribers. Frozen content served to a CRDT document, or content the
|
|
5966
|
+
* document cannot take in place, reloads it.
|
|
5967
|
+
*/
|
|
5968
|
+
// The seal of frozen content that holds nothing yet: no join's seq matches it.
|
|
5969
|
+
const NO_SEAL = -1;
|
|
5970
|
+
|
|
5971
|
+
// The `import_seq` each CRDT copy was opened at. A new version's import
|
|
5972
|
+
// replaces a line's content with a CRDT authored by the same pinned peer as
|
|
5973
|
+
// the one it replaces, so merging the new content into an old copy dedupes it
|
|
5974
|
+
// away and leaves the old copy's ops pointing at containers the line no
|
|
5975
|
+
// longer has. A copy that sees the line under a different import reopens
|
|
5976
|
+
// instead, and names its base on every op so the server refuses one that
|
|
5977
|
+
// raced the import (`moved`).
|
|
5978
|
+
const openedImportSeq = new WeakMap<Document, number>();
|
|
5979
|
+
|
|
5980
|
+
function recordImportSeq(doc: Document, importSeq: unknown): void {
|
|
5981
|
+
if (typeof importSeq === 'number') openedImportSeq.set(doc, importSeq);
|
|
5982
|
+
}
|
|
5983
|
+
|
|
5984
|
+
function importSeqMoved(doc: Document, importSeq: unknown): boolean {
|
|
5985
|
+
const base = openedImportSeq.get(doc);
|
|
5986
|
+
return base !== undefined && typeof importSeq === 'number' && importSeq !== base;
|
|
5987
|
+
}
|
|
5988
|
+
|
|
5989
|
+
function baseImportSeqField(doc: Document): { base_import_seq?: number } {
|
|
5990
|
+
const base = openedImportSeq.get(doc);
|
|
5991
|
+
return base === undefined ? {} : { base_import_seq: base };
|
|
5992
|
+
}
|
|
5993
|
+
|
|
5994
|
+
async function applyJoin(
|
|
5995
|
+
doc: Document,
|
|
5996
|
+
body: {
|
|
5997
|
+
snapshot: unknown;
|
|
5998
|
+
tail_ops: Array<{ seq: number; op_blob_b64: string }>;
|
|
5999
|
+
snapshot_seq?: number;
|
|
6000
|
+
current_seq?: number;
|
|
6001
|
+
import_seq?: number;
|
|
6002
|
+
},
|
|
6003
|
+
fetchBody: ContentFetch,
|
|
6004
|
+
): Promise<JoinApplied> {
|
|
6005
|
+
// Decided off the wire's own statement of the encoding, so content the
|
|
6006
|
+
// document will not keep is never downloaded.
|
|
6007
|
+
const encoding = joinEncoding(body.snapshot);
|
|
6008
|
+
// Frozen content replaces a reader's CRDT rather than merging into it, so a
|
|
6009
|
+
// new version's import is taken in place below.
|
|
6010
|
+
const replacesCrdt = encoding === 'frozen' && !doc._holdsRecordSet();
|
|
6011
|
+
if (!replacesCrdt && importSeqMoved(doc, body.import_seq)) {
|
|
6012
|
+
doc._reopen();
|
|
6013
|
+
return { kind: 'reloaded' };
|
|
6014
|
+
}
|
|
6015
|
+
if (doc._holdsRecordSet()) {
|
|
6016
|
+
const tail = [...body.tail_ops].sort((a, b) => a.seq - b.seq);
|
|
6017
|
+
const tailSeqs = tail.map((entry) => entry.seq);
|
|
6018
|
+
if (encoding === 'frozen') {
|
|
6019
|
+
const held = doc._frozenSealSeq;
|
|
6020
|
+
const sameSeal =
|
|
6021
|
+
held === null || body.snapshot_seq === undefined || held === body.snapshot_seq;
|
|
6022
|
+
if (tail.length === 0 && sameSeal) return { kind: 'unchanged' };
|
|
6023
|
+
if (tail.length === 0 && !coversMoreThanSnapshot(body)) {
|
|
6024
|
+
const content = await decodeJoinContent(body.snapshot, fetchBody);
|
|
6025
|
+
if (
|
|
6026
|
+
content.kind === 'frozen' &&
|
|
6027
|
+
doc._replaceSealedContent(content.content, body.snapshot_seq ?? null)
|
|
6028
|
+
) {
|
|
6029
|
+
return { kind: 'applied', content, tailSeqs };
|
|
6030
|
+
}
|
|
6031
|
+
}
|
|
6032
|
+
doc._reopen();
|
|
6033
|
+
return { kind: 'reloaded' };
|
|
6034
|
+
}
|
|
6035
|
+
const content = await decodeJoinContent(body.snapshot, fetchBody);
|
|
6036
|
+
await ensureWasmReady();
|
|
6037
|
+
const upgraded = doc._upgradeStorage(
|
|
6038
|
+
content.kind === 'crdt' ? content.bytes : null,
|
|
6039
|
+
tail.map((entry) => base64ToBytes(entry.op_blob_b64)),
|
|
6040
|
+
);
|
|
6041
|
+
if (!upgraded) {
|
|
6042
|
+
doc._reopen();
|
|
6043
|
+
return { kind: 'reloaded' };
|
|
6044
|
+
}
|
|
6045
|
+
return { kind: 'applied', content, tailSeqs };
|
|
6046
|
+
}
|
|
6047
|
+
// A line a reader holds as a CRDT and the server now stores frozen (a new
|
|
6048
|
+
// version of its file was imported over it) is taken as stored, in place.
|
|
6049
|
+
// An editable open asks as a writer and is never served frozen content.
|
|
6050
|
+
if (encoding === 'frozen') {
|
|
6051
|
+
if (
|
|
6052
|
+
doc._openMode !== 'edit' &&
|
|
6053
|
+
body.tail_ops.length === 0 &&
|
|
6054
|
+
!coversMoreThanSnapshot(body)
|
|
6055
|
+
) {
|
|
6056
|
+
const content = await decodeJoinContent(body.snapshot, fetchBody);
|
|
6057
|
+
if (
|
|
6058
|
+
content.kind === 'frozen' &&
|
|
6059
|
+
doc._downgradeStorage(content.content, body.snapshot_seq ?? null)
|
|
6060
|
+
) {
|
|
6061
|
+
openedImportSeq.delete(doc);
|
|
6062
|
+
return { kind: 'applied', content, tailSeqs: [] };
|
|
6063
|
+
}
|
|
6064
|
+
}
|
|
6065
|
+
doc._reopen();
|
|
6066
|
+
return { kind: 'reloaded' };
|
|
6067
|
+
}
|
|
6068
|
+
const content = await decodeJoinContent(body.snapshot, fetchBody);
|
|
6069
|
+
if (content.kind === 'crdt') doc._applySnapshot(content.bytes);
|
|
6070
|
+
const tail = [...body.tail_ops].sort((a, b) => a.seq - b.seq);
|
|
6071
|
+
for (const entry of tail) doc.applyRemoteOp(base64ToBytes(entry.op_blob_b64));
|
|
6072
|
+
return { kind: 'applied', content, tailSeqs: tail.map((entry) => entry.seq) };
|
|
4794
6073
|
}
|
|
4795
6074
|
|
|
4796
6075
|
async function materialise(
|
|
4797
6076
|
body: DocumentSnapshotResponse,
|
|
4798
6077
|
documentOptions?: DocumentOptions,
|
|
4799
6078
|
onSnapshotProgress?: (loaded: number, total: number | null) => void,
|
|
6079
|
+
// A frozen copy of a document whose import has not landed yet starts as
|
|
6080
|
+
// empty frozen content sealed at nothing, so the reload that follows the
|
|
6081
|
+
// import asks for the content as stored and swaps it in place, where an
|
|
6082
|
+
// empty CRDT would ask the server to convert the line for it.
|
|
6083
|
+
{ emptyAsFrozen = false }: { emptyAsFrozen?: boolean } = {},
|
|
4800
6084
|
): Promise<OpenedDocument> {
|
|
6085
|
+
const endFetch = perfStart('open: snapshot fetch + decode');
|
|
6086
|
+
const content = await decodeJoinContent(body.snapshot, (url) =>
|
|
6087
|
+
fetchPresignedSnapshot(url, onSnapshotProgress),
|
|
6088
|
+
);
|
|
6089
|
+
endFetch({ kind: content.kind });
|
|
6090
|
+
if (content.kind === 'none' && emptyAsFrozen && body.tail_ops.length === 0) {
|
|
6091
|
+
return Document.openFromSealedContent(SealedContent.fromJSON({}), {
|
|
6092
|
+
...documentOptions,
|
|
6093
|
+
readOnly: true,
|
|
6094
|
+
readOnlyReason: documentOptions?.readOnlyReason ?? 'snapshot',
|
|
6095
|
+
docId: body.doc_id,
|
|
6096
|
+
branchId: body.branch_id,
|
|
6097
|
+
frozenSeal: NO_SEAL,
|
|
6098
|
+
}) as OpenedDocument;
|
|
6099
|
+
}
|
|
6100
|
+
// Frozen content is read with no CRDT engine: no engine is built and the
|
|
6101
|
+
// WASM is not waited on.
|
|
6102
|
+
//
|
|
6103
|
+
// It is served to a reader and never to a writer. Converting it to a CRDT
|
|
6104
|
+
// is the SERVER's, because the conversion's output depends on the paging
|
|
6105
|
+
// constants, the mutation batching budget and loro's encoding, and two
|
|
6106
|
+
// clients on different builds would produce documents that merge into each
|
|
6107
|
+
// other with no error and no agreement. The tail is empty for the same
|
|
6108
|
+
// reason: the conversion is what makes a branch writable, so the ops it
|
|
6109
|
+
// takes are authored after it and against the converted document.
|
|
6110
|
+
if (content.kind === 'frozen') {
|
|
6111
|
+
// Frozen content with state above it is a document the server should not
|
|
6112
|
+
// have been able to build, and the content alone is the document as it
|
|
6113
|
+
// stood before those ops. Refused rather than read, because nothing here
|
|
6114
|
+
// would say so.
|
|
6115
|
+
if (body.tail_ops.length > 0 || coversMoreThanSnapshot(body)) {
|
|
6116
|
+
throw new BrassError(
|
|
6117
|
+
'This could not be opened: its stored records do not ' +
|
|
6118
|
+
'account for every change made to it. Reopen it in a moment.',
|
|
6119
|
+
{ status: 500 },
|
|
6120
|
+
);
|
|
6121
|
+
}
|
|
6122
|
+
return Document.openFromSealedContent(content.content, {
|
|
6123
|
+
...documentOptions,
|
|
6124
|
+
// A property of the content, which holds no op log for an edit to be
|
|
6125
|
+
// written into.
|
|
6126
|
+
readOnly: true,
|
|
6127
|
+
readOnlyReason: documentOptions?.readOnlyReason ?? 'snapshot',
|
|
6128
|
+
docId: body.doc_id,
|
|
6129
|
+
branchId: body.branch_id,
|
|
6130
|
+
...(body.snapshot_seq !== undefined ? { frozenSeal: body.snapshot_seq } : {}),
|
|
6131
|
+
}) as OpenedDocument;
|
|
6132
|
+
}
|
|
4801
6133
|
// Constructing the document is the first thing in this package that runs the
|
|
4802
6134
|
// WASM, and `Brass.init` only starts the fetch, so this is where it is
|
|
4803
6135
|
// waited on. Usually it resolved during the auth round-trip and this costs
|
|
@@ -4812,25 +6144,12 @@ async function materialise(
|
|
|
4812
6144
|
branchId: body.branch_id,
|
|
4813
6145
|
};
|
|
4814
6146
|
let doc: Document;
|
|
4815
|
-
|
|
4816
|
-
if (snap === null) {
|
|
6147
|
+
if (content.kind === 'none') {
|
|
4817
6148
|
doc = Document.create(optionsWithId);
|
|
4818
|
-
} else if (snap.kind === 'inline') {
|
|
4819
|
-
const endDecode = perfStart('open: base64 decode (inline snapshot)');
|
|
4820
|
-
const bytes = base64ToBytes(snap.data_b64);
|
|
4821
|
-
endDecode({ base64Chars: snap.data_b64.length, snapshotBytes: bytes.byteLength });
|
|
4822
|
-
const endImport = perfStart('open: loro import (inline snapshot)');
|
|
4823
|
-
doc = Document.openFromSnapshot(bytes, optionsWithId);
|
|
4824
|
-
endImport({ snapshotBytes: bytes.byteLength });
|
|
4825
|
-
} else if (snap.kind === 'presigned') {
|
|
4826
|
-
const endPresigned = perfStart('open: presigned snapshot fetch');
|
|
4827
|
-
const bytes = await fetchPresignedSnapshot(snap.url, onSnapshotProgress);
|
|
4828
|
-
endPresigned({ snapshotBytes: bytes.byteLength });
|
|
4829
|
-
const endImport = perfStart('open: loro import (presigned snapshot)');
|
|
4830
|
-
doc = Document.openFromSnapshot(bytes, optionsWithId);
|
|
4831
|
-
endImport({ snapshotBytes: bytes.byteLength });
|
|
4832
6149
|
} else {
|
|
4833
|
-
|
|
6150
|
+
const endImport = perfStart('open: loro import');
|
|
6151
|
+
doc = Document.openFromSnapshot(content.bytes, optionsWithId);
|
|
6152
|
+
endImport({ snapshotBytes: content.bytes.byteLength });
|
|
4834
6153
|
}
|
|
4835
6154
|
|
|
4836
6155
|
// Apply tail ops in seq order. The server delivers them sorted; we sort
|
|
@@ -4842,8 +6161,105 @@ async function materialise(
|
|
|
4842
6161
|
doc.applyRemoteOp(base64ToBytes(entry.op_blob_b64));
|
|
4843
6162
|
}
|
|
4844
6163
|
endTail({ count: tail.length });
|
|
6164
|
+
recordImportSeq(doc, body.import_seq);
|
|
4845
6165
|
|
|
4846
6166
|
// `doc.id` is non-null at runtime (stamped above); the cast closes the type
|
|
4847
6167
|
// gap without weakening the class's `string | null` signature.
|
|
4848
6168
|
return doc as OpenedDocument;
|
|
4849
6169
|
}
|
|
6170
|
+
|
|
6171
|
+
/** How many frozen opens run at once. */
|
|
6172
|
+
export const FROZEN_OPEN_LANES = 8;
|
|
6173
|
+
|
|
6174
|
+
/** Runs at most `lanes` of the tasks handed to it at once, in the order given. */
|
|
6175
|
+
export function createPacer(lanes: number): <T>(task: () => Promise<T>) => Promise<T> {
|
|
6176
|
+
let running = 0;
|
|
6177
|
+
const waiting: Array<() => void> = [];
|
|
6178
|
+
return (task) =>
|
|
6179
|
+
new Promise((resolve, reject) => {
|
|
6180
|
+
const start = (): void => {
|
|
6181
|
+
running += 1;
|
|
6182
|
+
task()
|
|
6183
|
+
.then(resolve, reject)
|
|
6184
|
+
.finally(() => {
|
|
6185
|
+
running -= 1;
|
|
6186
|
+
waiting.shift()?.();
|
|
6187
|
+
});
|
|
6188
|
+
};
|
|
6189
|
+
if (running < lanes) start();
|
|
6190
|
+
else waiting.push(start);
|
|
6191
|
+
});
|
|
6192
|
+
}
|
|
6193
|
+
|
|
6194
|
+
function frozenOptions(options: OpenDocumentByIdOptions): OpenDocumentByIdOptions {
|
|
6195
|
+
// Reading as data: a schema this app did not declare is read rather than
|
|
6196
|
+
// refused, since nothing written through this copy can misread it.
|
|
6197
|
+
return {
|
|
6198
|
+
...options,
|
|
6199
|
+
acceptSchemaConflict: true,
|
|
6200
|
+
documentOptions: { ...options.documentOptions, readOnly: true },
|
|
6201
|
+
};
|
|
6202
|
+
}
|
|
6203
|
+
|
|
6204
|
+
// A stream line opened for a frozen copy: the same reading as the copy
|
|
6205
|
+
// itself, of the line its manifest names.
|
|
6206
|
+
function frozenLineOptions(lineId: string): OpenDocumentByIdOptions {
|
|
6207
|
+
return {
|
|
6208
|
+
branch: lineId,
|
|
6209
|
+
frozen: true,
|
|
6210
|
+
acceptSchemaConflict: true,
|
|
6211
|
+
documentOptions: { readOnly: true },
|
|
6212
|
+
};
|
|
6213
|
+
}
|
|
6214
|
+
|
|
6215
|
+
function importReportOf(parsed: DocumentSnapshotResponse): ImportReport | null {
|
|
6216
|
+
const report = (parsed as { import_report?: unknown }).import_report;
|
|
6217
|
+
return report !== null && typeof report === 'object' && Array.isArray((report as ImportReport).findings)
|
|
6218
|
+
? (report as ImportReport)
|
|
6219
|
+
: null;
|
|
6220
|
+
}
|
|
6221
|
+
|
|
6222
|
+
/**
|
|
6223
|
+
* Whether a frame on a document's topic says the document moved past the
|
|
6224
|
+
* point a frozen copy was taken at: a later op, work that locked the
|
|
6225
|
+
* document finishing (an import landing or failing), or the document going.
|
|
6226
|
+
*/
|
|
6227
|
+
export function frameMovesDocument(frame: unknown, takenAt: number): boolean {
|
|
6228
|
+
if (frame === null || typeof frame !== 'object') return false;
|
|
6229
|
+
const f = frame as {
|
|
6230
|
+
type?: unknown;
|
|
6231
|
+
seq?: unknown;
|
|
6232
|
+
current_seq?: unknown;
|
|
6233
|
+
reload?: unknown;
|
|
6234
|
+
status?: { pending?: unknown; blocking?: unknown };
|
|
6235
|
+
};
|
|
6236
|
+
switch (f.type) {
|
|
6237
|
+
// The server fans a `doc_op` out to every subscriber but the writer's
|
|
6238
|
+
// connection, so an edit made through another copy on this page's socket
|
|
6239
|
+
// reaches the topic only as that copy's `op_ack`.
|
|
6240
|
+
case 'doc_op':
|
|
6241
|
+
case 'op_ack':
|
|
6242
|
+
return typeof f.seq === 'number' && f.seq > takenAt;
|
|
6243
|
+
case 'doc_init':
|
|
6244
|
+
case 'doc_catch_up':
|
|
6245
|
+
return typeof f.current_seq === 'number' && f.current_seq > takenAt;
|
|
6246
|
+
case 'doc_status':
|
|
6247
|
+
// `reload` is work that replaced the content (an import landing); a
|
|
6248
|
+
// terminal status still blocking is work that failed.
|
|
6249
|
+
return (
|
|
6250
|
+
f.reload === true ||
|
|
6251
|
+
(f.status?.pending === false && f.status.blocking !== false)
|
|
6252
|
+
);
|
|
6253
|
+
case 'doc_gone':
|
|
6254
|
+
return true;
|
|
6255
|
+
default:
|
|
6256
|
+
return false;
|
|
6257
|
+
}
|
|
6258
|
+
}
|
|
6259
|
+
|
|
6260
|
+
// The document's name as the join that opened it read it, so a copy opened by
|
|
6261
|
+
// id carries it from its first frame as a session open does.
|
|
6262
|
+
function setJoinedName(doc: Document, parsed: unknown): void {
|
|
6263
|
+
const name = (parsed as { name?: unknown }).name;
|
|
6264
|
+
if (typeof name === 'string') doc._setName(name);
|
|
6265
|
+
}
|