@brass-build/client 0.4.1 → 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 +124 -45
- package/CHANGELOG.md +80 -4
- package/README.md +666 -179
- 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 +48 -2
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +918 -115
- 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/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 +1022 -112
- 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/dist/open-document.js
CHANGED
|
@@ -3,18 +3,91 @@
|
|
|
3
3
|
// Cold-load contract: `Document.openFromSnapshot` must stay within 2x of
|
|
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
|
+
import { awaitSeal } from './staged-seal.js';
|
|
6
7
|
import { DEFAULT_STREAM_NAME, Document, } from './documents.js';
|
|
7
8
|
import { ensureWasmReady } from './wasm-runtime.js';
|
|
8
9
|
import { createPresenceController } from './presence.js';
|
|
9
10
|
import { applyAppAssertionHeader, applyClientVersionHeader } from './api-headers.js';
|
|
10
11
|
import { brassErrorFromBody } from './api-fetch.js';
|
|
11
|
-
import {
|
|
12
|
+
import { randomToken } from './documents-namespace.js';
|
|
13
|
+
import { fetchWithRetry, RETRY_IDEMPOTENT_WRITE, RETRY_WRITE_503, } from './fetch-retry.js';
|
|
12
14
|
import { deadlineForUpload, fetchWithDeadline, OP_WRITE_TIMEOUT_MS, } from './fetch-deadline.js';
|
|
13
15
|
import { BrassError } from './errors.js';
|
|
14
16
|
import { perfStart } from './perf.js';
|
|
17
|
+
import { SealedContent } from './sealed-store.js';
|
|
15
18
|
import { joinCoverageFloor, snapshotSeqOf } from './join-coverage.js';
|
|
19
|
+
import { parseInputStatusNudge } from './inputs.js';
|
|
20
|
+
import { TEMPORARY_INPUTS_RUNTIME } from './inputs-status.js';
|
|
21
|
+
import { setJoinStamp } from './join-stamp.js';
|
|
22
|
+
import { storeClaimSecret } from './guest-link.js';
|
|
23
|
+
import { base64ToBytes, decodeJoinContent, joinEncoding, } from './join-content.js';
|
|
16
24
|
import { declaredTailBytes, resolveTailOps } from './tail-ops.js';
|
|
25
|
+
import { SEALED_ACCEPT } from './sealed-store.js';
|
|
17
26
|
import { opBlobExceedsDurableLimit, opBlobNeedsStaging, opFrameExceedsWsLimit, } from './ws-frame-limit.js';
|
|
27
|
+
export function acceptFromUploadTypes(body) {
|
|
28
|
+
const wire = body;
|
|
29
|
+
const tokens = new Set();
|
|
30
|
+
for (const type of Array.isArray(wire?.import_types) ? wire.import_types : []) {
|
|
31
|
+
for (const list of [type.extensions, type.content_types]) {
|
|
32
|
+
if (!Array.isArray(list))
|
|
33
|
+
continue;
|
|
34
|
+
for (const token of list)
|
|
35
|
+
if (typeof token === 'string' && token !== '')
|
|
36
|
+
tokens.add(token);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
accept: [...tokens].join(','),
|
|
41
|
+
maxBytes: typeof wire?.max_bytes === 'number' ? wire.max_bytes : Number.POSITIVE_INFINITY,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function parseMintedAnonymousDocument(parsed) {
|
|
45
|
+
const minted = parsed;
|
|
46
|
+
const expiresAt = typeof minted?.expires_at === 'string' ? new Date(minted.expires_at) : null;
|
|
47
|
+
if (typeof minted?.doc_id !== 'string' ||
|
|
48
|
+
typeof minted.link_token !== 'string' ||
|
|
49
|
+
expiresAt === null ||
|
|
50
|
+
Number.isNaN(expiresAt.getTime())) {
|
|
51
|
+
throw new BrassError('Malformed anonymous document response', { status: 500 });
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
docId: minted.doc_id,
|
|
55
|
+
linkToken: minted.link_token,
|
|
56
|
+
expiresAt,
|
|
57
|
+
...(typeof minted.claim_secret === 'string' ? { claimSecret: minted.claim_secret } : {}),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
async function readJsonBody(response) {
|
|
61
|
+
const text = await response.text();
|
|
62
|
+
try {
|
|
63
|
+
return text ? JSON.parse(text) : undefined;
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
// PUT a file to a presigned URL. Through XHR where there is one, which is
|
|
70
|
+
// the browser API that reports upload progress.
|
|
71
|
+
function putFile(url, file, onProgress) {
|
|
72
|
+
const failed = (status) => new BrassError('The file could not be uploaded.', { status: status || 503 });
|
|
73
|
+
if (typeof XMLHttpRequest === 'undefined') {
|
|
74
|
+
return fetch(url, { method: 'PUT', body: file }).then((r) => {
|
|
75
|
+
if (!r.ok)
|
|
76
|
+
throw failed(r.status);
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
return new Promise((resolve, reject) => {
|
|
80
|
+
const xhr = new XMLHttpRequest();
|
|
81
|
+
xhr.open('PUT', url);
|
|
82
|
+
xhr.upload.onprogress = (e) => {
|
|
83
|
+
if (e.lengthComputable)
|
|
84
|
+
onProgress?.(e.loaded, e.total);
|
|
85
|
+
};
|
|
86
|
+
xhr.onload = () => (xhr.status >= 200 && xhr.status < 300 ? resolve() : reject(failed(xhr.status)));
|
|
87
|
+
xhr.onerror = () => reject(failed(0));
|
|
88
|
+
xhr.send(file);
|
|
89
|
+
});
|
|
90
|
+
}
|
|
18
91
|
// The terminal frame the platform sends when it clears a document's work
|
|
19
92
|
// status: the work finished and the content is in. The SDK emits the same
|
|
20
93
|
// shape wherever it settles that transition off its own import (a
|
|
@@ -25,6 +98,15 @@ const WORK_READY_STATUS = {
|
|
|
25
98
|
pending: false,
|
|
26
99
|
blocking: false,
|
|
27
100
|
};
|
|
101
|
+
// How long an edit waits on a client that has never opened a socket before
|
|
102
|
+
// the document tells the reader it is not saved. Timed from the first waiting
|
|
103
|
+
// edit rather than counted in failed handshakes, because a network that
|
|
104
|
+
// swallows the upgrade can hold one attempt open with no failure to count.
|
|
105
|
+
// The socket starts connecting when the document opens, so by the time this
|
|
106
|
+
// expires it has had at least this long, which is several times a slow
|
|
107
|
+
// mobile handshake, and a network that refuses upgrades outright has failed
|
|
108
|
+
// four or five of them under the reconnect backoff (1s, 2s, 4s).
|
|
109
|
+
export const NEVER_OPENED_NOTICE_MS = 10_000;
|
|
28
110
|
const CONTENT_PULL_ATTEMPTS = 5;
|
|
29
111
|
const CONTENT_PULL_BACKOFF_MS = 1000;
|
|
30
112
|
// Pull a document's content in after the platform reports its inbound work
|
|
@@ -95,8 +177,7 @@ async function resolveParsedTailOps(parsed, onProgress) {
|
|
|
95
177
|
: (loadedBytes) => onProgress(loadedBytes, totalBytes > 0 ? totalBytes : null, 'tail'));
|
|
96
178
|
}
|
|
97
179
|
// Map the snake_case wire `drive` block to the public camelCase
|
|
98
|
-
// `DocumentDriveStatus`. `canPush` is
|
|
99
|
-
// and ahead/diverged) so apps don't re-derive it.
|
|
180
|
+
// `DocumentDriveStatus`. `canPush` is the server's `publishable`.
|
|
100
181
|
function toDriveStatus(wire) {
|
|
101
182
|
const state = wire.tracking_state;
|
|
102
183
|
// Only an explicit `import_only` restricts publishing; a frame omitting the
|
|
@@ -107,21 +188,14 @@ function toDriveStatus(wire) {
|
|
|
107
188
|
driveType: wire.drive_type,
|
|
108
189
|
driveName: wire.drive_name,
|
|
109
190
|
state,
|
|
110
|
-
// `
|
|
111
|
-
//
|
|
112
|
-
|
|
113
|
-
// would reject. Require an explicit `false` to enable, and an explicit
|
|
114
|
-
// `true` to flag broken. Behaviour is unchanged for today's frames, which
|
|
115
|
-
// always carry the boolean. Whether the file is written server-side or
|
|
116
|
-
// from the app's own serializer is a REQUEST SHAPE the push path resolves
|
|
117
|
-
// for itself, so it says nothing about whether this document can publish.
|
|
118
|
-
canPush: wire.broken === false &&
|
|
119
|
-
mirrorDirection === 'two_way' &&
|
|
120
|
-
(state === 'ahead' || state === 'diverged'),
|
|
191
|
+
// Only an explicit `true` offers a publish, so a frame that omits the
|
|
192
|
+
// field never offers one the server would refuse.
|
|
193
|
+
canPush: wire.publishable === true,
|
|
121
194
|
label: wire.tracking_label,
|
|
122
195
|
detail: wire.tracking_detail,
|
|
123
196
|
broken: wire.broken === true,
|
|
124
197
|
pushReplacesRemote: wire.push_replaces_remote,
|
|
198
|
+
keepsPriorVersions: wire.keeps_prior_versions === true,
|
|
125
199
|
mirrorDirection,
|
|
126
200
|
ref: wire.tracking_ref,
|
|
127
201
|
refUpdatedAt: wire.tracking_ref_updated_at,
|
|
@@ -151,6 +225,7 @@ function toPublishTarget(wire) {
|
|
|
151
225
|
detail: typeof wire.detail === 'string' ? wire.detail : '',
|
|
152
226
|
publishing: wire.publishing === true,
|
|
153
227
|
publishFailed: wire.publish_failed === true,
|
|
228
|
+
unpublishable: wire.unpublishable === true,
|
|
154
229
|
};
|
|
155
230
|
}
|
|
156
231
|
function isDocOpFrame(frame) {
|
|
@@ -210,6 +285,7 @@ function parseDocInitFrame(frame) {
|
|
|
210
285
|
...(typeof f.snapshot_version_vector === 'string'
|
|
211
286
|
? { snapshot_version_vector: f.snapshot_version_vector }
|
|
212
287
|
: {}),
|
|
288
|
+
...(typeof f.import_seq === 'number' ? { import_seq: f.import_seq } : {}),
|
|
213
289
|
};
|
|
214
290
|
}
|
|
215
291
|
function isDocCatchUpFrame(frame) {
|
|
@@ -258,6 +334,21 @@ function isCapacityRefusal(status, parsed) {
|
|
|
258
334
|
return true;
|
|
259
335
|
return parsed?.code === 'at_capacity';
|
|
260
336
|
}
|
|
337
|
+
// The refusal of an op built on a version of the line that a new version's
|
|
338
|
+
// import has since replaced. It is about the copy rather than the op: no
|
|
339
|
+
// resend or heal can place an op naming the replaced version's containers, so
|
|
340
|
+
// the copy reopens, as it does on `doc_gone` `moved`.
|
|
341
|
+
const OP_REJECTED_MOVED = 'moved';
|
|
342
|
+
// The document still holds only its uploaded file. Every op meets the same
|
|
343
|
+
// refusal until the import lands, so a smaller or later push cannot place
|
|
344
|
+
// the refused work and the heal is not armed: the copy locks and reloads.
|
|
345
|
+
const OP_REJECTED_NOT_IMPORTED = 'not_imported';
|
|
346
|
+
function isNotImportedRefusal(parsed) {
|
|
347
|
+
return parsed?.code === OP_REJECTED_NOT_IMPORTED;
|
|
348
|
+
}
|
|
349
|
+
function isMovedRefusal(parsed) {
|
|
350
|
+
return parsed?.code === OP_REJECTED_MOVED;
|
|
351
|
+
}
|
|
261
352
|
// A failure uploading an oversized op's blob to storage. It carries its own
|
|
262
353
|
// type because staging runs strictly BEFORE the op write, so what a replay
|
|
263
354
|
// means differs from a throw out of the write itself: nothing durable exists
|
|
@@ -298,7 +389,7 @@ function opRejectedMessage(frame) {
|
|
|
298
389
|
if (typeof frame.message === 'string' && frame.message !== '') {
|
|
299
390
|
return frame.message;
|
|
300
391
|
}
|
|
301
|
-
return 'A change could not be saved and will not be retried. Reload
|
|
392
|
+
return 'A change could not be saved and will not be retried. Reload to continue.';
|
|
302
393
|
}
|
|
303
394
|
// The one rejection reason that describes the DOCUMENT rather than the op:
|
|
304
395
|
// every later write meets it too, so the app is told once about the document
|
|
@@ -454,16 +545,6 @@ function bytesToBase64(bytes) {
|
|
|
454
545
|
}
|
|
455
546
|
return btoa(bin);
|
|
456
547
|
}
|
|
457
|
-
// Uses `atob` (present in modern browsers, Node 20+, and jsdom) rather than
|
|
458
|
-
// Node-only `Buffer`, which would force a browser-bundle polyfill.
|
|
459
|
-
function base64ToBytes(b64) {
|
|
460
|
-
const bin = atob(b64);
|
|
461
|
-
const bytes = new Uint8Array(bin.length);
|
|
462
|
-
for (let i = 0; i < bin.length; i++) {
|
|
463
|
-
bytes[i] = bin.charCodeAt(i);
|
|
464
|
-
}
|
|
465
|
-
return bytes;
|
|
466
|
-
}
|
|
467
548
|
function isSnapshotResponse(body) {
|
|
468
549
|
if (typeof body !== 'object' || body === null)
|
|
469
550
|
return false;
|
|
@@ -477,14 +558,145 @@ function isSnapshotResponse(body) {
|
|
|
477
558
|
// snapshot validated structurally in materialise(); a null is fine.
|
|
478
559
|
return true;
|
|
479
560
|
}
|
|
561
|
+
/**
|
|
562
|
+
* The body a join answered with, or the error it is.
|
|
563
|
+
*
|
|
564
|
+
* Every open starts here: check the status, then check the shape. Both
|
|
565
|
+
* failures are open-time and neither is recoverable by the caller, so they
|
|
566
|
+
* throw rather than returning, and the shape check is what stops a response
|
|
567
|
+
* the server changed out from under a pinned SDK being read as an empty
|
|
568
|
+
* document. `describe` names the response in the message, since a reader
|
|
569
|
+
* debugging one wants to know which route answered.
|
|
570
|
+
*/
|
|
571
|
+
function unwrapJoinResponse(result, isShape, describe) {
|
|
572
|
+
const { status, parsed, parseFailed } = result;
|
|
573
|
+
if (status < 200 || status >= 300) {
|
|
574
|
+
throw brassErrorFromBody(status, parseFailed ? undefined : parsed);
|
|
575
|
+
}
|
|
576
|
+
if (!isShape(parsed)) {
|
|
577
|
+
throw new BrassError(`Malformed document ${describe} response`, {
|
|
578
|
+
status: 500,
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
return parsed;
|
|
582
|
+
}
|
|
480
583
|
// Fold the join's write verdict into the Document options, so a reader the
|
|
481
584
|
// server will not accept writes from opens read-only instead of learning it
|
|
482
585
|
// from the first refused op. Only ever ADDS the restriction: a caller that
|
|
483
586
|
// asked for a read-only Document keeps it whatever the grant says.
|
|
587
|
+
/** The longest one pause between joins of a converting document. The wait
|
|
588
|
+
* itself has no bound, because a slow conversion is still a healthy one:
|
|
589
|
+
* each join is answered with what is left of it, and a conversion that
|
|
590
|
+
* cannot finish ends as `at_capacity`, which stops the wait. */
|
|
591
|
+
const CONVERSION_MAX_DELAY_MS = 60_000;
|
|
592
|
+
/** The shortest pause, so a server answering without holding the join is not
|
|
593
|
+
* asked again in a tight loop. */
|
|
594
|
+
const CONVERSION_MIN_DELAY_MS = 250;
|
|
595
|
+
const CONVERSION_POLL_MIN_MS = 1_000;
|
|
596
|
+
const CONVERSION_POLL_MAX_MS = 5_000;
|
|
597
|
+
/** Joins in a row that may fail outright while a conversion is awaited. */
|
|
598
|
+
const CONVERSION_JOIN_FAILURES = 5;
|
|
599
|
+
// How often a reader's open waiting on an import reads the work status, as
|
|
600
|
+
// the backstop for a terminal frame it did not hear.
|
|
601
|
+
const IMPORT_WAIT_POLL_MIN_MS = 2_000;
|
|
602
|
+
const IMPORT_WAIT_POLL_MAX_MS = 15_000;
|
|
603
|
+
const OPEN_SUPERSEDED_MESSAGE = 'This open was replaced by another';
|
|
604
|
+
// How long an open waits on the platform's work (a conversion, an import)
|
|
605
|
+
// before it rejects. Above the longest the platform itself takes to end that
|
|
606
|
+
// work either way: an import is two runs of the hydration worker (one retry)
|
|
607
|
+
// before the platform marks it failed, about 32 minutes, and a conversion is
|
|
608
|
+
// three compaction runs before it is marked full, about 18. A wait past this
|
|
609
|
+
// is work the platform has stopped reporting on.
|
|
610
|
+
// Exported for the fake-timer tests.
|
|
611
|
+
export const OPEN_WAIT_CEILING_MS = 40 * 60 * 1000;
|
|
612
|
+
const OPEN_WAIT_EXPIRED_MESSAGE = 'This is taking too long to open. Reload to try again.';
|
|
613
|
+
const IMPORT_FAILED_MESSAGE = 'This file could not be imported';
|
|
614
|
+
/** How long the server holds a writable join for a conversion, less a margin,
|
|
615
|
+
* so a join sent this long before the conversion is expected is held until
|
|
616
|
+
* it lands. */
|
|
617
|
+
const CONVERSION_HOLD_MS = 10_000;
|
|
618
|
+
/** When to send the next held join, given how long the conversion is
|
|
619
|
+
* expected to take from now. */
|
|
620
|
+
function heldJoinDelay(retryAfterMs) {
|
|
621
|
+
return Math.min(Math.max(CONVERSION_MIN_DELAY_MS, retryAfterMs - CONVERSION_HOLD_MS), CONVERSION_MAX_DELAY_MS);
|
|
622
|
+
}
|
|
623
|
+
/** Whether a response is the join's retryable refusal for a conversion,
|
|
624
|
+
* which names its own wait, so the caller paces it rather than a backoff. */
|
|
625
|
+
async function namesConversionWait(response) {
|
|
626
|
+
if (response.status !== 503)
|
|
627
|
+
return false;
|
|
628
|
+
try {
|
|
629
|
+
const body = (await response.clone().json());
|
|
630
|
+
return typeof body.retry_after_ms === 'number';
|
|
631
|
+
}
|
|
632
|
+
catch {
|
|
633
|
+
return false;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
/**
|
|
637
|
+
* Work a join reports that the open waits out before it resolves: pending work
|
|
638
|
+
* that locks readers, such as the platform importing the file or a new
|
|
639
|
+
* version of it.
|
|
640
|
+
*/
|
|
641
|
+
function pendingImportWork(join) {
|
|
642
|
+
if (join.status !== 200 || join.parseFailed)
|
|
643
|
+
return null;
|
|
644
|
+
const parsed = join.parsed;
|
|
645
|
+
if (parsed === null || typeof parsed !== 'object')
|
|
646
|
+
return null;
|
|
647
|
+
if (typeof parsed.doc_id !== 'string' || typeof parsed.branch_id !== 'string')
|
|
648
|
+
return null;
|
|
649
|
+
const status = parseWorkStatus(parsed.status);
|
|
650
|
+
if (status === null || !status.pending || status.blocking === false)
|
|
651
|
+
return null;
|
|
652
|
+
return { status, topic: `doc:${parsed.doc_id}:${parsed.branch_id}` };
|
|
653
|
+
}
|
|
654
|
+
/**
|
|
655
|
+
* The error an open rejects with when the file's import failed and the
|
|
656
|
+
* document holds nothing from it, or `null`.
|
|
657
|
+
*/
|
|
658
|
+
function failedImportOf(join) {
|
|
659
|
+
if (join.status !== 200 || join.parseFailed)
|
|
660
|
+
return null;
|
|
661
|
+
const parsed = join.parsed;
|
|
662
|
+
if (parsed === null || typeof parsed !== 'object')
|
|
663
|
+
return null;
|
|
664
|
+
const status = parseWorkStatus(parsed.status);
|
|
665
|
+
if (status === null || status.pending || status.blocking !== true)
|
|
666
|
+
return null;
|
|
667
|
+
// An import lands at seq 1 or later, so seq 0 is a document no import has
|
|
668
|
+
// filled.
|
|
669
|
+
const empty = parsed.awaits_import === true ||
|
|
670
|
+
typeof parsed.current_seq !== 'number' ||
|
|
671
|
+
parsed.current_seq <= 0;
|
|
672
|
+
if (!empty)
|
|
673
|
+
return null;
|
|
674
|
+
return new BrassError(status.message !== '' ? status.message : IMPORT_FAILED_MESSAGE, {
|
|
675
|
+
status: 0,
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
/** Why a writable join refused, or `null` for one that served or failed some
|
|
679
|
+
* other way. */
|
|
680
|
+
function joinRefusalOf(join) {
|
|
681
|
+
const body = typeof join.parsed === 'object' && join.parsed !== null
|
|
682
|
+
? join.parsed
|
|
683
|
+
: {};
|
|
684
|
+
if (join.status === 503 && typeof body.retry_after_ms === 'number' && body.retry_after_ms >= 0) {
|
|
685
|
+
return { kind: 'converting', retryAfterMs: body.retry_after_ms };
|
|
686
|
+
}
|
|
687
|
+
if (join.status === 507 && body.code === 'at_capacity') {
|
|
688
|
+
return typeof body.error === 'string' ? { kind: 'at_capacity', error: body.error } : { kind: 'at_capacity' };
|
|
689
|
+
}
|
|
690
|
+
return null;
|
|
691
|
+
}
|
|
484
692
|
function readOnlyForGrant(body, options) {
|
|
485
693
|
if (body.can_edit !== false)
|
|
486
694
|
return options;
|
|
487
|
-
return {
|
|
695
|
+
return {
|
|
696
|
+
...options,
|
|
697
|
+
readOnly: true,
|
|
698
|
+
readOnlyReason: body.read_only_reason === 'not_imported' ? 'not_imported' : 'grant',
|
|
699
|
+
};
|
|
488
700
|
}
|
|
489
701
|
function isAtVersionResponse(body) {
|
|
490
702
|
if (typeof body !== 'object' || body === null)
|
|
@@ -509,7 +721,112 @@ export function createOpenDocument(opts) {
|
|
|
509
721
|
// here rather than threading it through each call site is what keeps a
|
|
510
722
|
// fourth one from silently dropping it.
|
|
511
723
|
const consented = new Set();
|
|
724
|
+
const SESSION = { kind: 'session' };
|
|
725
|
+
function inputsRuntimeFor(credential, docId) {
|
|
726
|
+
switch (credential.kind) {
|
|
727
|
+
case 'session':
|
|
728
|
+
return opts.inputsRuntime ? opts.inputsRuntime(docId) : null;
|
|
729
|
+
case 'link':
|
|
730
|
+
return {
|
|
731
|
+
readKey: (key, query) => getWithLink(credential.token, `/documents/${encodeURIComponent(docId)}/inputs/${encodeURIComponent(key)}`, query),
|
|
732
|
+
};
|
|
733
|
+
case 'temporary':
|
|
734
|
+
return TEMPORARY_INPUTS_RUNTIME;
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
// The join, the op write and the work-status read, through whichever
|
|
738
|
+
// credential the document was opened with.
|
|
739
|
+
// `rejoin` marks a read for a document this page already has open, which a
|
|
740
|
+
// link meters apart from opens.
|
|
741
|
+
async function joinAs(credential, docId, options, { rejoin = true } = {}) {
|
|
742
|
+
return credential.kind === 'session'
|
|
743
|
+
? fetchSnapshot(docId, options, await requireToken())
|
|
744
|
+
: fetchLinkView(credential.token, options ?? {}, undefined, { rejoin });
|
|
745
|
+
}
|
|
746
|
+
async function postOpAs(credential, docId, lineId, opBlobB64, clientOpId, base) {
|
|
747
|
+
if (credential.kind === 'session') {
|
|
748
|
+
return postDocumentOp(docId, lineId, opBlobB64, clientOpId, await requireToken(), base);
|
|
749
|
+
}
|
|
750
|
+
return postLinkOp(credential.token, docId, lineId, opBlobB64, clientOpId, base);
|
|
751
|
+
}
|
|
752
|
+
async function readStatusAs(credential, docId) {
|
|
753
|
+
const headers = {};
|
|
754
|
+
let url;
|
|
755
|
+
if (credential.kind === 'session') {
|
|
756
|
+
headers['authorization'] = `Bearer ${await requireToken()}`;
|
|
757
|
+
applyClientVersionHeader(headers);
|
|
758
|
+
await applyAppAssertionHeader(headers, opts.getAppAssertion);
|
|
759
|
+
url = `${baseUrl}/documents/${encodeURIComponent(docId)}`;
|
|
760
|
+
}
|
|
761
|
+
else {
|
|
762
|
+
Object.assign(headers, await linkViewHeaders());
|
|
763
|
+
url = `${baseUrl}/links/${encodeURIComponent(credential.token)}/view?app_id=${encodeURIComponent(opts.appId)}&rejoin=1`;
|
|
764
|
+
}
|
|
765
|
+
const response = await fetch(url, { method: 'GET', headers });
|
|
766
|
+
if (!response.ok) {
|
|
767
|
+
throw new BrassError(`document read returned ${response.status}`, { status: response.status });
|
|
768
|
+
}
|
|
769
|
+
return response.json();
|
|
770
|
+
}
|
|
771
|
+
function wsClientFor(credential) {
|
|
772
|
+
return credential.kind === 'session' ? opts.wsClient : (opts.linkWsClient?.(credential.token) ?? opts.wsClient);
|
|
773
|
+
}
|
|
774
|
+
async function materialiseAs(credential, ...args) {
|
|
775
|
+
const doc = await materialise(...args);
|
|
776
|
+
doc._setInputsRuntime(inputsRuntimeFor(credential, args[0].doc_id));
|
|
777
|
+
return doc;
|
|
778
|
+
}
|
|
779
|
+
// A join. One that will write and takes nothing else waits while the
|
|
780
|
+
// platform converts the document: the join is refused with how long is
|
|
781
|
+
// left, and asked again then, for as long as that takes. The open's
|
|
782
|
+
// progress sink hears `preparing` on each wait, and its `false` ends the
|
|
783
|
+
// wait with a rejection.
|
|
512
784
|
async function fetchSnapshot(docId, options, token) {
|
|
785
|
+
if (opensReadOnly(options)) {
|
|
786
|
+
return joinOnce(docId, options, token);
|
|
787
|
+
}
|
|
788
|
+
// The first join spends the token the caller holds; a re-join after a
|
|
789
|
+
// long wait asks for a current one.
|
|
790
|
+
let held = token;
|
|
791
|
+
return waitWhileConverting(async () => {
|
|
792
|
+
const t = held ?? (await requireToken());
|
|
793
|
+
held = null;
|
|
794
|
+
return joinOnce(docId, options, t);
|
|
795
|
+
}, options?.onSnapshotProgress);
|
|
796
|
+
}
|
|
797
|
+
async function waitWhileConverting(join, progress) {
|
|
798
|
+
const deadline = Date.now() + OPEN_WAIT_CEILING_MS;
|
|
799
|
+
let answer = await join();
|
|
800
|
+
let failures = 0;
|
|
801
|
+
for (;;) {
|
|
802
|
+
const refused = joinRefusalOf(answer);
|
|
803
|
+
if (refused?.kind !== 'converting')
|
|
804
|
+
return answer;
|
|
805
|
+
if (progress?.(0, null, 'preparing') === false) {
|
|
806
|
+
throw new BrassError(OPEN_SUPERSEDED_MESSAGE, { status: 0 });
|
|
807
|
+
}
|
|
808
|
+
if (Date.now() >= deadline)
|
|
809
|
+
throw new BrassError(OPEN_WAIT_EXPIRED_MESSAGE, { status: 0 });
|
|
810
|
+
let delay = refused.retryAfterMs === undefined ? CONVERSION_POLL_MIN_MS : heldJoinDelay(refused.retryAfterMs);
|
|
811
|
+
for (;;) {
|
|
812
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
813
|
+
try {
|
|
814
|
+
answer = await join();
|
|
815
|
+
failures = 0;
|
|
816
|
+
break;
|
|
817
|
+
}
|
|
818
|
+
catch (err) {
|
|
819
|
+
// The document is known to exist and to be converting, so a join
|
|
820
|
+
// that fails outright is the connection rather than the answer.
|
|
821
|
+
failures += 1;
|
|
822
|
+
if (failures >= CONVERSION_JOIN_FAILURES)
|
|
823
|
+
throw err;
|
|
824
|
+
delay = Math.min(CONVERSION_POLL_MIN_MS * 2 ** (failures - 1), CONVERSION_POLL_MAX_MS);
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
async function joinOnce(docId, options, token) {
|
|
513
830
|
const search = new URLSearchParams();
|
|
514
831
|
search.set('include', 'snapshot');
|
|
515
832
|
if (options?.filter !== undefined) {
|
|
@@ -525,16 +842,34 @@ export function createOpenDocument(opts) {
|
|
|
525
842
|
consented.add(docId);
|
|
526
843
|
if (consented.has(docId))
|
|
527
844
|
search.set('accept_schema_conflict', '1');
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
845
|
+
// A caller that will not write asks for the document as it is stored. It
|
|
846
|
+
// matters on a document the platform holds as a record set: serving one
|
|
847
|
+
// to a reader is the point of storing one, and serving it to a writer
|
|
848
|
+
// means converting it to a CRDT first, which the server does and this
|
|
849
|
+
// reader has no reason to wait for.
|
|
850
|
+
if (opensReadOnly(options))
|
|
851
|
+
search.set('intent', 'read');
|
|
852
|
+
return sendJoin(`/documents/${encodeURIComponent(docId)}`, search, {
|
|
853
|
+
headers: await memberHeaders(token),
|
|
854
|
+
perf: 'open: GET /documents (http + read body)',
|
|
855
|
+
...(options?.onSnapshotProgress !== undefined ? { progress: options.onSnapshotProgress } : {}),
|
|
856
|
+
});
|
|
857
|
+
}
|
|
858
|
+
// Every join's request: the stored frames this build decodes (a client that
|
|
859
|
+
// does not name them is served the CRDT), the answer parsed, and its
|
|
860
|
+
// spilled tail ops resolved. A reader holding a CRDT names the frames too:
|
|
861
|
+
// the line as stored is the cheapest answer to serve it, and `applyJoin`
|
|
862
|
+
// takes either form in place.
|
|
863
|
+
async function sendJoin(path, search, { headers, perf, progress, }) {
|
|
864
|
+
search.set('accept', SEALED_ACCEPT);
|
|
865
|
+
const qs = search.toString();
|
|
866
|
+
const url = `${baseUrl}${path}${qs ? `?${qs}` : ''}`;
|
|
867
|
+
const endFetch = perf !== undefined ? perfStart(perf) : null;
|
|
533
868
|
// Idempotent read: ride out a transient 5xx / 429 instead of failing the
|
|
534
869
|
// open on something that clears on its own.
|
|
535
870
|
//
|
|
536
871
|
// Longer than the default handful of attempts because of what a 503 means
|
|
537
|
-
// on
|
|
872
|
+
// on a join: the document is intact and its storage is busy. A
|
|
538
873
|
// document's own writes contend with each other, so the one most likely
|
|
539
874
|
// to answer 503 is one that was just written hard, which is exactly the
|
|
540
875
|
// moment a person reloads to see their work. That pressure clears over
|
|
@@ -547,12 +882,16 @@ export function createOpenDocument(opts) {
|
|
|
547
882
|
// or a host that does not resolve answers the seventh attempt as it
|
|
548
883
|
// answered the first, with the reader watching a loading surface
|
|
549
884
|
// throughout.
|
|
550
|
-
const response = await fetchWithRetry(() => fetch(url, {
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
885
|
+
const response = await fetchWithRetry(() => fetch(url, { method: 'GET', ...(headers !== undefined ? { headers } : {}) }), {
|
|
886
|
+
maxAttempts: 7,
|
|
887
|
+
baseDelayMs: 700,
|
|
888
|
+
maxNetworkAttempts: 3,
|
|
889
|
+
// A conversion's refusal says how long to wait, and the caller waits
|
|
890
|
+
// that long rather than this backoff's second or two.
|
|
891
|
+
isFinal: namesConversionWait,
|
|
892
|
+
});
|
|
554
893
|
const text = await response.text();
|
|
555
|
-
endFetch({ status: response.status, responseChars: text.length });
|
|
894
|
+
endFetch?.({ status: response.status, responseChars: text.length });
|
|
556
895
|
let parsed = undefined;
|
|
557
896
|
let parseFailed = false;
|
|
558
897
|
if (text) {
|
|
@@ -563,39 +902,24 @@ export function createOpenDocument(opts) {
|
|
|
563
902
|
parseFailed = true;
|
|
564
903
|
}
|
|
565
904
|
}
|
|
566
|
-
|
|
905
|
+
// A tail op whose bytes spilled to storage arrives as a signed URL, which
|
|
906
|
+
// carries its own signature, so a guest resolves it as a member does.
|
|
907
|
+
await resolveParsedTailOps(parsed, progress);
|
|
567
908
|
return { status: response.status, parsed, parseFailed };
|
|
568
909
|
}
|
|
910
|
+
async function memberHeaders(token) {
|
|
911
|
+
const headers = { authorization: `Bearer ${token}` };
|
|
912
|
+
applyClientVersionHeader(headers);
|
|
913
|
+
await applyAppAssertionHeader(headers, opts.getAppAssertion);
|
|
914
|
+
return headers;
|
|
915
|
+
}
|
|
569
916
|
// `GET /documents/{docId}/at/{version}`: read-only materialization at a
|
|
570
|
-
// historical version.
|
|
917
|
+
// historical version.
|
|
571
918
|
async function fetchAtVersion(docId, version, branch, token) {
|
|
572
919
|
const search = new URLSearchParams();
|
|
573
920
|
if (branch !== undefined)
|
|
574
921
|
search.set('branch', branch);
|
|
575
|
-
|
|
576
|
-
const url = `${baseUrl}/documents/${encodeURIComponent(docId)}` +
|
|
577
|
-
`/at/${encodeURIComponent(String(version))}` +
|
|
578
|
-
(qs ? `?${qs}` : '');
|
|
579
|
-
const headers = { authorization: `Bearer ${token}` };
|
|
580
|
-
applyClientVersionHeader(headers);
|
|
581
|
-
await applyAppAssertionHeader(headers, opts.getAppAssertion);
|
|
582
|
-
const response = await fetch(url, {
|
|
583
|
-
method: 'GET',
|
|
584
|
-
headers,
|
|
585
|
-
});
|
|
586
|
-
const text = await response.text();
|
|
587
|
-
let parsed = undefined;
|
|
588
|
-
let parseFailed = false;
|
|
589
|
-
if (text) {
|
|
590
|
-
try {
|
|
591
|
-
parsed = JSON.parse(text);
|
|
592
|
-
}
|
|
593
|
-
catch {
|
|
594
|
-
parseFailed = true;
|
|
595
|
-
}
|
|
596
|
-
}
|
|
597
|
-
await resolveParsedTailOps(parsed);
|
|
598
|
-
return { status: response.status, parsed, parseFailed };
|
|
922
|
+
return sendJoin(`/documents/${encodeURIComponent(docId)}/at/${encodeURIComponent(String(version))}`, search, { headers: await memberHeaders(token) });
|
|
599
923
|
}
|
|
600
924
|
// The historical-materialise fetcher `doc._checkoutVersion(v)` calls back
|
|
601
925
|
// to, shared by the default Document and each opened stream line (both
|
|
@@ -615,28 +939,7 @@ export function createOpenDocument(opts) {
|
|
|
615
939
|
status: 500,
|
|
616
940
|
});
|
|
617
941
|
}
|
|
618
|
-
|
|
619
|
-
const snap = result.parsed.snapshot;
|
|
620
|
-
if (snap !== null) {
|
|
621
|
-
if (snap.kind === 'inline') {
|
|
622
|
-
snapshotBytes = base64ToBytes(snap.data_b64);
|
|
623
|
-
}
|
|
624
|
-
else if (snap.kind === 'presigned') {
|
|
625
|
-
const snapResponse = await fetch(snap.url, { method: 'GET' });
|
|
626
|
-
if (!snapResponse.ok) {
|
|
627
|
-
throw new BrassError('Presigned snapshot fetch failed', {
|
|
628
|
-
status: snapResponse.status,
|
|
629
|
-
});
|
|
630
|
-
}
|
|
631
|
-
const buf = await snapResponse.arrayBuffer();
|
|
632
|
-
snapshotBytes = new Uint8Array(buf);
|
|
633
|
-
}
|
|
634
|
-
else {
|
|
635
|
-
// A delivery kind this build does not implement; refuse rather than
|
|
636
|
-
// preview an empty version (same rationale as `materialise`).
|
|
637
|
-
throw snapshotKindTooNewError();
|
|
638
|
-
}
|
|
639
|
-
}
|
|
942
|
+
const content = await decodeJoinContent(result.parsed.snapshot, (url) => fetchPresignedSnapshot(url));
|
|
640
943
|
const tailOpBlobs = result.parsed.tail_ops.map((entry) => base64ToBytes(entry.op_blob_b64));
|
|
641
944
|
const rawSeqs = result.parsed.stream_seqs;
|
|
642
945
|
let streamSeqs;
|
|
@@ -650,7 +953,7 @@ export function createOpenDocument(opts) {
|
|
|
650
953
|
streamSeqs = parsed;
|
|
651
954
|
}
|
|
652
955
|
return {
|
|
653
|
-
|
|
956
|
+
content,
|
|
654
957
|
tailOpBlobs,
|
|
655
958
|
effectiveVersion: result.parsed.effective_version,
|
|
656
959
|
...(streamSeqs ? { streamSeqs } : {}),
|
|
@@ -674,9 +977,9 @@ export function createOpenDocument(opts) {
|
|
|
674
977
|
return serverMessage;
|
|
675
978
|
}
|
|
676
979
|
if (status === 403)
|
|
677
|
-
return 'You do not have permission to
|
|
980
|
+
return 'You do not have permission to make changes here.';
|
|
678
981
|
if (status === 404)
|
|
679
|
-
return 'This
|
|
982
|
+
return 'This is no longer available.';
|
|
680
983
|
return `This change could not be saved (${status}).`;
|
|
681
984
|
}
|
|
682
985
|
// Write one op over HTTP. The socket carries an op only up to its own
|
|
@@ -686,21 +989,25 @@ export function createOpenDocument(opts) {
|
|
|
686
989
|
// outstanding tracker and re-asserts it, and a blind replay would write a
|
|
687
990
|
// second stored copy of the same edit.
|
|
688
991
|
// Mint a presigned PUT and upload the blob to it, returning the staging
|
|
689
|
-
// key the op write names.
|
|
992
|
+
// key the op write names. A share link mints on the same route with the
|
|
993
|
+
// link as the credential. The PUT carries no Brass headers: the URL is
|
|
690
994
|
// signed, and an extra header falls outside that signature.
|
|
691
|
-
async function stageOpBlob(docId, opBlobB64,
|
|
995
|
+
async function stageOpBlob(docId, opBlobB64, credential) {
|
|
692
996
|
// Everything in here happens before the op write, so every failure it
|
|
693
997
|
// can take is tagged as one: what the tag buys the writer is the right
|
|
694
998
|
// to send the op again, which a failure of the write itself does not
|
|
695
999
|
// carry.
|
|
696
1000
|
try {
|
|
697
|
-
const headers = {
|
|
698
|
-
|
|
699
|
-
'
|
|
700
|
-
}
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
1001
|
+
const headers = { 'content-type': 'application/json' };
|
|
1002
|
+
if ('link' in credential) {
|
|
1003
|
+
headers['authorization'] = `Link ${credential.link}`;
|
|
1004
|
+
}
|
|
1005
|
+
else {
|
|
1006
|
+
headers['authorization'] = `Bearer ${credential.bearer}`;
|
|
1007
|
+
applyClientVersionHeader(headers);
|
|
1008
|
+
await applyAppAssertionHeader(headers, opts.getAppAssertion);
|
|
1009
|
+
}
|
|
1010
|
+
const mint = await fetchWithDeadline(`${baseUrl}/documents/${encodeURIComponent(docId)}/ops/upload`, { method: 'POST', headers, body: JSON.stringify({ byte_size: opBlobB64.length }) }, OP_WRITE_TIMEOUT_MS);
|
|
704
1011
|
let minted = undefined;
|
|
705
1012
|
try {
|
|
706
1013
|
minted = mint.text ? JSON.parse(mint.text) : undefined;
|
|
@@ -729,7 +1036,7 @@ export function createOpenDocument(opts) {
|
|
|
729
1036
|
throw asOpStagingError(err);
|
|
730
1037
|
}
|
|
731
1038
|
}
|
|
732
|
-
async function postDocumentOp(docId, branchId, opBlobB64, clientOpId, token) {
|
|
1039
|
+
async function postDocumentOp(docId, branchId, opBlobB64, clientOpId, token, base = {}) {
|
|
733
1040
|
const headers = {
|
|
734
1041
|
authorization: `Bearer ${token}`,
|
|
735
1042
|
'content-type': 'application/json',
|
|
@@ -741,7 +1048,7 @@ export function createOpenDocument(opts) {
|
|
|
741
1048
|
// through a request body.
|
|
742
1049
|
let stagedKey;
|
|
743
1050
|
if (opBlobNeedsStaging(opBlobB64)) {
|
|
744
|
-
stagedKey = await stageOpBlob(docId, opBlobB64, token);
|
|
1051
|
+
stagedKey = await stageOpBlob(docId, opBlobB64, { bearer: token });
|
|
745
1052
|
}
|
|
746
1053
|
const endFetch = perfStart('op: POST /documents/{id}/ops');
|
|
747
1054
|
const response = await fetchWithDeadline(`${baseUrl}/documents/${encodeURIComponent(docId)}/ops`, {
|
|
@@ -753,6 +1060,7 @@ export function createOpenDocument(opts) {
|
|
|
753
1060
|
: { upload_key: stagedKey }),
|
|
754
1061
|
branch_id: branchId,
|
|
755
1062
|
client_op_id: clientOpId,
|
|
1063
|
+
...base,
|
|
756
1064
|
}),
|
|
757
1065
|
},
|
|
758
1066
|
// An op under the staging threshold travels inline, so this request
|
|
@@ -902,20 +1210,19 @@ export function createOpenDocument(opts) {
|
|
|
902
1210
|
// checkpoints / commands, and no park cache; a stream's extra cost
|
|
903
1211
|
// over its content is one join + one subscribe, which is what keeps
|
|
904
1212
|
// per-stream opens proportional to what an app touches.
|
|
905
|
-
async function openStreamLine(docId, lineId
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
if (!isSnapshotResponse(parsed)) {
|
|
912
|
-
throw new BrassError('Malformed document snapshot response', {
|
|
913
|
-
status: 500,
|
|
914
|
-
});
|
|
915
|
-
}
|
|
1213
|
+
async function openStreamLine(docId, lineId,
|
|
1214
|
+
// The parent document's open, whose read intent every line of it shares.
|
|
1215
|
+
parentOptions, credential = SESSION) {
|
|
1216
|
+
const lineOptions = rejoinOptions(lineId, parentOptions);
|
|
1217
|
+
const result = await joinAs(credential, docId, lineOptions, { rejoin: false });
|
|
1218
|
+
const parsed = unwrapJoinResponse(result, isSnapshotResponse, 'snapshot');
|
|
916
1219
|
// A stream's write authority is the document's, so the line opens
|
|
917
|
-
// read-only for the same reader the document opens read-only for
|
|
918
|
-
|
|
1220
|
+
// read-only for the same reader the document opens read-only for, and for
|
|
1221
|
+
// a caller who asked for a read-only document.
|
|
1222
|
+
const asked = parentOptions?.documentOptions;
|
|
1223
|
+
const doc = await materialiseAs(credential, parsed, readOnlyForGrant(parsed, asked?.readOnly === true
|
|
1224
|
+
? { readOnly: true, readOnlyReason: asked.readOnlyReason ?? 'snapshot' }
|
|
1225
|
+
: undefined));
|
|
919
1226
|
// A stream line materializes at a historical seq the same way the
|
|
920
1227
|
// default line does, so the parent's version preview can bring this
|
|
921
1228
|
// stream to a point in time (`doc.stream(name)._previewAt(seq)`). The
|
|
@@ -967,26 +1274,18 @@ export function createOpenDocument(opts) {
|
|
|
967
1274
|
do {
|
|
968
1275
|
lineResyncAgain = false;
|
|
969
1276
|
try {
|
|
970
|
-
const
|
|
971
|
-
const res = await fetchSnapshot(docId, { branch: lineId }, t);
|
|
1277
|
+
const res = await joinAs(credential, docId, lineOptions);
|
|
972
1278
|
if (res.status < 200 || res.status >= 300)
|
|
973
1279
|
continue;
|
|
974
1280
|
if (!isSnapshotResponse(res.parsed))
|
|
975
1281
|
continue;
|
|
976
|
-
const
|
|
977
|
-
if (
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
const seqs = [];
|
|
984
|
-
for (const entry of res.parsed.tail_ops) {
|
|
985
|
-
doc.applyRemoteOp(base64ToBytes(entry.op_blob_b64));
|
|
986
|
-
if (entry.seq > currentSeq)
|
|
987
|
-
currentSeq = entry.seq;
|
|
988
|
-
seqs.push(entry.seq);
|
|
989
|
-
}
|
|
1282
|
+
const applied = await applyJoin(doc, res.parsed, (url) => fetchPresignedSnapshot(url));
|
|
1283
|
+
if (applied.kind !== 'applied')
|
|
1284
|
+
return;
|
|
1285
|
+
const seqs = applied.tailSeqs;
|
|
1286
|
+
for (const seq of seqs)
|
|
1287
|
+
if (seq > currentSeq)
|
|
1288
|
+
currentSeq = seq;
|
|
990
1289
|
const joined = res.parsed.current_seq;
|
|
991
1290
|
if (typeof joined === 'number' && joined > currentSeq) {
|
|
992
1291
|
currentSeq = joined;
|
|
@@ -1016,14 +1315,16 @@ export function createOpenDocument(opts) {
|
|
|
1016
1315
|
// one seq; only the transport differs, and the response carries the
|
|
1017
1316
|
// seq an `op_ack` would have.
|
|
1018
1317
|
const postOversizedOp = async (opBlobB64, clientOpId) => {
|
|
1019
|
-
const
|
|
1020
|
-
const { status, parsed: opResult } = await postDocumentOp(docId, lineId, opBlobB64, clientOpId, token);
|
|
1318
|
+
const { status, parsed: opResult } = await postOpAs(credential, docId, lineId, opBlobB64, clientOpId, baseImportSeqField(doc));
|
|
1021
1319
|
if (status < 200 || status >= 300) {
|
|
1022
1320
|
return {
|
|
1023
1321
|
ok: false,
|
|
1024
1322
|
message: opWriteFailureMessage(status, opResult),
|
|
1025
1323
|
retryable: opWriteIsRetryable(status),
|
|
1026
1324
|
atCapacity: isCapacityRefusal(status, opResult),
|
|
1325
|
+
...(isMovedRefusal(opResult) ? { moved: true } : {}),
|
|
1326
|
+
...(isNotImportedRefusal(opResult) ? { notImported: true } : {}),
|
|
1327
|
+
...(status === 413 ? { tooLarge: true } : {}),
|
|
1027
1328
|
// The route answers 403 when the caller's grant carries no write
|
|
1028
1329
|
// access, which is the HTTP face of the socket's `role_denied`.
|
|
1029
1330
|
...(status === 403 ? { readOnly: 'grant' } : {}),
|
|
@@ -1039,13 +1340,31 @@ export function createOpenDocument(opts) {
|
|
|
1039
1340
|
}
|
|
1040
1341
|
return { ok: true, seq };
|
|
1041
1342
|
};
|
|
1042
|
-
|
|
1343
|
+
// The durability handshake's ground truth for this line, read from the
|
|
1344
|
+
// line's own join so the diff is against this stream's frontier.
|
|
1345
|
+
const refetchDurableFrontier = async () => {
|
|
1346
|
+
const res = await joinAs(credential, docId, lineOptions);
|
|
1347
|
+
if (res.status < 200 || res.status >= 300)
|
|
1348
|
+
return null;
|
|
1349
|
+
if (!isSnapshotResponse(res.parsed))
|
|
1350
|
+
return null;
|
|
1351
|
+
const out = {
|
|
1352
|
+
tailBlobs: res.parsed.tail_ops.map((entry) => base64ToBytes(entry.op_blob_b64)),
|
|
1353
|
+
};
|
|
1354
|
+
if (typeof res.parsed.snapshot_version_vector === 'string') {
|
|
1355
|
+
out.snapshotVersionVector = res.parsed.snapshot_version_vector;
|
|
1356
|
+
}
|
|
1357
|
+
return out;
|
|
1358
|
+
};
|
|
1359
|
+
const lineWsClient = wsClientFor(credential);
|
|
1360
|
+
if (lineWsClient) {
|
|
1043
1361
|
wireDocTransport({
|
|
1044
1362
|
doc,
|
|
1045
1363
|
docId,
|
|
1046
1364
|
branchId: lineId,
|
|
1047
|
-
wsClient:
|
|
1365
|
+
wsClient: lineWsClient,
|
|
1048
1366
|
postOversizedOp,
|
|
1367
|
+
refetchDurableFrontier,
|
|
1049
1368
|
newClientOpId: opts.newClientOpId ?? defaultClientOpIdMinter,
|
|
1050
1369
|
getCurrentSeq: () => currentSeq,
|
|
1051
1370
|
setCurrentSeq: (next) => {
|
|
@@ -1062,20 +1381,399 @@ export function createOpenDocument(opts) {
|
|
|
1062
1381
|
}
|
|
1063
1382
|
return doc;
|
|
1064
1383
|
}
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1384
|
+
/**
|
|
1385
|
+
* A snapshot-only open: the ordinary join, materialised read-only, with no
|
|
1386
|
+
* transport wired.
|
|
1387
|
+
*
|
|
1388
|
+
* It reads the JOIN rather than the at-version route even though the two
|
|
1389
|
+
* envelopes match, because the join is what serves a record set as itself.
|
|
1390
|
+
* The at-version route materialises a LoroDoc to replay a tail onto, so it
|
|
1391
|
+
* converts a record set before answering; a reader asking for the document
|
|
1392
|
+
* as it stands has no tail to replay and would pay that conversion for
|
|
1393
|
+
* nothing.
|
|
1394
|
+
*
|
|
1395
|
+
* `readOnly` is what the caller asked for rather than a fact about their
|
|
1396
|
+
* grant, which is why it carries its own reason: an app that disables its
|
|
1397
|
+
* editor on `doc.readOnly` is right to, and the message a refused `mutate`
|
|
1398
|
+
* throws should say the caller opened a snapshot rather than blame a
|
|
1399
|
+
* permission they may well hold.
|
|
1400
|
+
*/
|
|
1401
|
+
async function openSnapshotOnly(snapshot, options) {
|
|
1402
|
+
const parsed = unwrapJoinResponse(snapshot, isSnapshotResponse, 'snapshot');
|
|
1403
|
+
return openParsedSnapshot(parsed, options);
|
|
1404
|
+
}
|
|
1405
|
+
async function openParsedSnapshot(parsed, options) {
|
|
1406
|
+
// The grant's own reason wins where there is one. A viewer who asks for a
|
|
1407
|
+
// snapshot cannot edit either way, and telling them to reopen without
|
|
1408
|
+
// asking for one would be advice that does not work: the refusal has to
|
|
1409
|
+
// name the limit they actually have.
|
|
1410
|
+
const byGrant = readOnlyForGrant(parsed, options.documentOptions);
|
|
1411
|
+
const doc = await materialiseAs(SESSION, parsed, {
|
|
1412
|
+
...byGrant,
|
|
1413
|
+
readOnly: true,
|
|
1414
|
+
readOnlyReason: byGrant?.readOnlyReason ?? 'snapshot',
|
|
1415
|
+
}, options.onSnapshotProgress, { emptyAsFrozen: true });
|
|
1416
|
+
// What one load brought: the point it was taken at, fixed until the next
|
|
1417
|
+
// load. A live open's provider is the transport's cursor and a
|
|
1418
|
+
// version-pinned one answers from its checkout, so this is what makes
|
|
1419
|
+
// `doc.currentSeq` mean the same thing on all three: the version the
|
|
1420
|
+
// reader is looking at. The change notice compares the document's head
|
|
1421
|
+
// against it.
|
|
1422
|
+
const takeLoad = (load) => {
|
|
1423
|
+
const takenAt = load.current_seq;
|
|
1424
|
+
if (typeof takenAt === 'number')
|
|
1425
|
+
doc.setCurrentSeqProvider(() => takenAt);
|
|
1426
|
+
// Work the join reported over the content it served: work that does not
|
|
1427
|
+
// lock readers (a push), or a new version's import that failed and left
|
|
1428
|
+
// the content in place. A join carries a status only while work is in
|
|
1429
|
+
// flight, so one with none after a pending load is the work having ended.
|
|
1430
|
+
if (load.status !== undefined)
|
|
1431
|
+
doc._emitStatus(load.status);
|
|
1432
|
+
else if (doc._workStatusNow?.pending === true)
|
|
1433
|
+
doc._emitStatus(WORK_READY_STATUS, { contentLoaded: true });
|
|
1434
|
+
doc._setImportReport(importReportOf(load));
|
|
1435
|
+
setJoinStamp(doc, load.updated_at);
|
|
1436
|
+
setJoinedName(doc, load);
|
|
1437
|
+
watchFrom(load);
|
|
1438
|
+
};
|
|
1439
|
+
// An import is stored frozen, so a frozen open of a file nobody has
|
|
1440
|
+
// imported yet runs the app's importer the way an editable open does.
|
|
1441
|
+
doc._awaitsImport = parsed.awaits_import === true;
|
|
1442
|
+
if (opts.sourceBlobFetcher) {
|
|
1443
|
+
const fetcher = opts.sourceBlobFetcher;
|
|
1444
|
+
doc.setSourceBlobFetcher(() => fetcher(doc));
|
|
1069
1445
|
}
|
|
1070
|
-
|
|
1071
|
-
|
|
1446
|
+
// A document's other streams open as frozen copies of their own lines,
|
|
1447
|
+
// read the same way and watched on their own topics.
|
|
1448
|
+
doc.setOpenedStreamName(options.stream ?? DEFAULT_STREAM_NAME);
|
|
1449
|
+
if (opts.streamProvider) {
|
|
1450
|
+
const provider = opts.streamProvider;
|
|
1451
|
+
const docId = parsed.doc_id;
|
|
1452
|
+
const openedBranchId = parsed.stream_branch_id ?? parsed.branch_id;
|
|
1453
|
+
doc.setStreamRuntime({
|
|
1454
|
+
list: async () => (await provider.list(docId, openedBranchId)).streams,
|
|
1455
|
+
create: () => Promise.reject(new Error('A read-only copy cannot create a stream.')),
|
|
1456
|
+
openLine: (lineId) => openFrozen(docId, frozenLineOptions(lineId)),
|
|
1457
|
+
});
|
|
1458
|
+
}
|
|
1459
|
+
const ws = opts.wsClient;
|
|
1460
|
+
const watchFrom = (load) => {
|
|
1461
|
+
if (!ws)
|
|
1462
|
+
return;
|
|
1463
|
+
const topic = `doc:${load.doc_id}:${load.branch_id}`;
|
|
1464
|
+
const at = typeof load.current_seq === 'number' ? load.current_seq : 0;
|
|
1465
|
+
// The copy reports the work that was in flight when it loaded, through to
|
|
1466
|
+
// its outcome, and nothing that starts afterwards: later work describes a
|
|
1467
|
+
// version of the document this copy does not hold.
|
|
1468
|
+
let following = load.status?.pending === true;
|
|
1469
|
+
doc._setChangeNotice((fire) => ws.subscribe(topic, (frame) => {
|
|
1470
|
+
if (following && isDocStatusFrame(frame)) {
|
|
1471
|
+
const status = normalizeWorkStatus(frame.status);
|
|
1472
|
+
if (!status.pending)
|
|
1473
|
+
following = false;
|
|
1474
|
+
doc._emitStatus(status);
|
|
1475
|
+
doc._settleChangeNotice();
|
|
1476
|
+
}
|
|
1477
|
+
if (frameMovesDocument(frame, at))
|
|
1478
|
+
fire();
|
|
1479
|
+
}, {
|
|
1480
|
+
notice: true,
|
|
1481
|
+
onSubscribed: ({ currentSeq }) => {
|
|
1482
|
+
if (currentSeq !== undefined && currentSeq > at)
|
|
1483
|
+
fire();
|
|
1484
|
+
// Work that ends between the join and this subscribe sent its
|
|
1485
|
+
// frame to nobody, and may or may not have moved the seq, so a
|
|
1486
|
+
// copy taken mid-work asks the row whether it is still running.
|
|
1487
|
+
if (load.status?.pending !== true)
|
|
1488
|
+
return;
|
|
1489
|
+
void readFrozenWorkStatus(load.doc_id).then((now) => {
|
|
1490
|
+
if (now === 'unread' || now?.pending === true)
|
|
1491
|
+
return;
|
|
1492
|
+
doc._emitStatus(now ?? { pending: false, message: '' });
|
|
1493
|
+
fire();
|
|
1494
|
+
});
|
|
1495
|
+
},
|
|
1496
|
+
}));
|
|
1497
|
+
};
|
|
1498
|
+
takeLoad(parsed);
|
|
1499
|
+
// A frozen copy loads again only when the app asks. The join keeps the
|
|
1500
|
+
// open's read intent and names the stored frames whatever the copy holds
|
|
1501
|
+
// now, and the result applies in place (`applyJoin`) under the same
|
|
1502
|
+
// handles, switching between frozen content and a CRDT when the line has.
|
|
1503
|
+
const openedId = parsed.doc_id;
|
|
1504
|
+
//
|
|
1505
|
+
// A reload follows the open's rule: it waits out an import in flight and
|
|
1506
|
+
// loads what the import landed, and rejects (keeping what the copy holds)
|
|
1507
|
+
// when the import failed with nothing landed or the file is one only
|
|
1508
|
+
// another app imports. The open's progress sink belongs to the open, so
|
|
1509
|
+
// the reload's wait reports to none.
|
|
1510
|
+
const { onSnapshotProgress: _openProgress, ...reloadOptions } = options;
|
|
1511
|
+
doc._setFrozenReloader(async () => {
|
|
1512
|
+
const join = () => paceFrozen(async () => fetchSnapshot(openedId, reloadOptions, await requireToken()));
|
|
1513
|
+
const snapshot = await joinPastWork(openedId, reloadOptions, await join(), join);
|
|
1514
|
+
const load = unwrapJoinResponse(snapshot, isSnapshotResponse, 'snapshot');
|
|
1515
|
+
const applied = await applyJoin(doc, load, (url) => fetchPresignedSnapshot(url));
|
|
1516
|
+
if (applied.kind !== 'reloaded')
|
|
1517
|
+
takeLoad(load);
|
|
1518
|
+
});
|
|
1519
|
+
return doc;
|
|
1520
|
+
}
|
|
1521
|
+
// The row's work status as it is now, without a snapshot, or 'unread' when
|
|
1522
|
+
// the read fails.
|
|
1523
|
+
async function readFrozenWorkStatus(docId) {
|
|
1524
|
+
const now = await readWorkStatus(docId);
|
|
1525
|
+
return now === 'gone' ? 'unread' : now;
|
|
1526
|
+
}
|
|
1527
|
+
// As `readFrozenWorkStatus`, with 'gone' for a document the caller can no
|
|
1528
|
+
// longer read (403 / 404).
|
|
1529
|
+
async function readWorkStatus(docId) {
|
|
1530
|
+
try {
|
|
1531
|
+
const token = await requireToken();
|
|
1532
|
+
const headers = { authorization: `Bearer ${token}` };
|
|
1533
|
+
applyClientVersionHeader(headers);
|
|
1534
|
+
await applyAppAssertionHeader(headers, opts.getAppAssertion);
|
|
1535
|
+
const response = await fetch(`${baseUrl}/documents/${encodeURIComponent(docId)}`, {
|
|
1536
|
+
method: 'GET',
|
|
1537
|
+
headers,
|
|
1538
|
+
});
|
|
1539
|
+
if (response.status === 403 || response.status === 404)
|
|
1540
|
+
return 'gone';
|
|
1541
|
+
if (!response.ok)
|
|
1542
|
+
return 'unread';
|
|
1543
|
+
const body = (await response.json());
|
|
1544
|
+
return parseWorkStatus(body.status);
|
|
1545
|
+
}
|
|
1546
|
+
catch {
|
|
1547
|
+
return 'unread';
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
// An open joins past an import the join reports (`pendingImportWork`): it
|
|
1551
|
+
// waits for the import to end and joins again, so it resolves with what the
|
|
1552
|
+
// import landed, and rejects when the import failed with nothing landed.
|
|
1553
|
+
async function joinPastWork(docId, options, first, join) {
|
|
1554
|
+
const deadline = Date.now() + OPEN_WAIT_CEILING_MS;
|
|
1555
|
+
let answer = first;
|
|
1556
|
+
for (;;) {
|
|
1557
|
+
const work = pendingImportWork(answer);
|
|
1558
|
+
if (work === null) {
|
|
1559
|
+
const failed = failedImportOf(answer);
|
|
1560
|
+
if (failed !== null)
|
|
1561
|
+
throw failed;
|
|
1562
|
+
return answer;
|
|
1563
|
+
}
|
|
1564
|
+
await waitForWorkToEnd(docId, work.topic, work.status, deadline, options?.onSnapshotProgress);
|
|
1565
|
+
answer = await join();
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
// Resolves once the work ends: a terminal `doc_status` frame on the
|
|
1569
|
+
// document's topic, or a status read that finds it ended (on subscribe, and
|
|
1570
|
+
// on a backing-off poll for a frame that never arrives). Rejects when the
|
|
1571
|
+
// progress sink says a newer open superseded this one, and at `deadline`.
|
|
1572
|
+
async function waitForWorkToEnd(docId, topic, status, deadline, progress) {
|
|
1573
|
+
const superseded = () => progress?.(0, null, 'importing', status.message) === false;
|
|
1574
|
+
if (superseded())
|
|
1575
|
+
throw new BrassError(OPEN_SUPERSEDED_MESSAGE, { status: 0 });
|
|
1576
|
+
const ended = (now) => !now.pending || now.blocking === false;
|
|
1577
|
+
await new Promise((resolve, reject) => {
|
|
1578
|
+
let settled = false;
|
|
1579
|
+
let timer = null;
|
|
1580
|
+
let unsubscribe = null;
|
|
1581
|
+
const expiry = setTimeout(() => end(new BrassError(OPEN_WAIT_EXPIRED_MESSAGE, { status: 0 })), Math.max(0, deadline - Date.now()));
|
|
1582
|
+
const end = (err) => {
|
|
1583
|
+
if (settled)
|
|
1584
|
+
return;
|
|
1585
|
+
settled = true;
|
|
1586
|
+
if (timer !== null)
|
|
1587
|
+
clearTimeout(timer);
|
|
1588
|
+
clearTimeout(expiry);
|
|
1589
|
+
unsubscribe?.();
|
|
1590
|
+
if (err)
|
|
1591
|
+
reject(err);
|
|
1592
|
+
else
|
|
1593
|
+
resolve();
|
|
1594
|
+
};
|
|
1595
|
+
const check = async () => {
|
|
1596
|
+
const now = await readWorkStatus(docId);
|
|
1597
|
+
if (now === 'unread')
|
|
1598
|
+
return;
|
|
1599
|
+
// A document the caller lost is left to the next join to report.
|
|
1600
|
+
if (now === 'gone' || now === null || ended(now))
|
|
1601
|
+
end();
|
|
1602
|
+
};
|
|
1603
|
+
let delay = IMPORT_WAIT_POLL_MIN_MS;
|
|
1604
|
+
const poll = () => {
|
|
1605
|
+
timer = setTimeout(() => {
|
|
1606
|
+
timer = null;
|
|
1607
|
+
if (settled)
|
|
1608
|
+
return;
|
|
1609
|
+
if (superseded()) {
|
|
1610
|
+
end(new BrassError(OPEN_SUPERSEDED_MESSAGE, { status: 0 }));
|
|
1611
|
+
return;
|
|
1612
|
+
}
|
|
1613
|
+
void check().finally(() => {
|
|
1614
|
+
if (settled)
|
|
1615
|
+
return;
|
|
1616
|
+
delay = Math.min(delay * 2, IMPORT_WAIT_POLL_MAX_MS);
|
|
1617
|
+
poll();
|
|
1618
|
+
});
|
|
1619
|
+
}, delay);
|
|
1620
|
+
};
|
|
1621
|
+
if (opts.wsClient) {
|
|
1622
|
+
unsubscribe = opts.wsClient.subscribe(topic, (frame) => {
|
|
1623
|
+
if (isDocStatusFrame(frame) && ended(normalizeWorkStatus(frame.status)))
|
|
1624
|
+
end();
|
|
1625
|
+
}, { notice: true, onSubscribed: () => void check() });
|
|
1626
|
+
}
|
|
1627
|
+
poll();
|
|
1628
|
+
});
|
|
1629
|
+
}
|
|
1630
|
+
// A frozen open: a snapshot that watches for the document moving once a
|
|
1631
|
+
// listener asks it to. A set is opened as a loop over its ids, so joins
|
|
1632
|
+
// are paced here, which is what keeps such a loop fast without letting it
|
|
1633
|
+
// flood the api. The pace covers the join alone: the content download
|
|
1634
|
+
// goes to storage rather than the api, and holding a lane through it
|
|
1635
|
+
// leaves the next join waiting on bytes the api is not serving.
|
|
1636
|
+
async function openFrozen(docId, options) {
|
|
1637
|
+
const join = () => paceFrozen(async () => fetchSnapshot(docId, options, await requireToken()));
|
|
1638
|
+
// The wait sits outside the pace, which covers a join and not the time
|
|
1639
|
+
// between two of them.
|
|
1640
|
+
const snapshot = await joinPastWork(docId, options, await join(), join);
|
|
1641
|
+
return openSnapshotOnly(snapshot, options);
|
|
1642
|
+
}
|
|
1643
|
+
const paceFrozen = createPacer(FROZEN_OPEN_LANES);
|
|
1644
|
+
// What a signed-in open reaches beyond the transport.
|
|
1645
|
+
function wireSessionCapabilities(doc, docId, parsed, options) {
|
|
1646
|
+
// `doc._checkoutVersion(v)` calls back here to materialise historical
|
|
1647
|
+
// state, leaving the live subscription untouched.
|
|
1648
|
+
doc.setCheckoutFetcher(makeCheckoutFetcher());
|
|
1649
|
+
// Wire the branch provider + populate the initial list, so the
|
|
1650
|
+
// toolbar's branch chip can name the branch on the next subscribe
|
|
1651
|
+
// tick. Branch management is hosted; this list feeds the SDK's own
|
|
1652
|
+
// chrome, not an app method.
|
|
1653
|
+
if (opts.branchProvider) {
|
|
1654
|
+
doc.setBranchProvider(opts.branchProvider);
|
|
1655
|
+
void doc._refreshBranches().catch(() => {
|
|
1656
|
+
// Best-effort: a failure leaves the chip unnamed until a hosted
|
|
1657
|
+
// branch action refreshes the list.
|
|
1658
|
+
});
|
|
1659
|
+
}
|
|
1660
|
+
// `doc.useCanvas(canvas)` captures + uploads after each local commit; the
|
|
1661
|
+
// doc handles debounce + readOnly gating internally.
|
|
1662
|
+
if (opts.thumbnailUploader) {
|
|
1663
|
+
const uploader = opts.thumbnailUploader;
|
|
1664
|
+
doc.setThumbnailUploader(async (image, width, height, version) => {
|
|
1665
|
+
await uploader(doc, image, width, height, version);
|
|
1666
|
+
});
|
|
1667
|
+
}
|
|
1668
|
+
doc._setThumbnailVersion(parsed.thumbnail?.version ?? null);
|
|
1669
|
+
if (opts.checkpointCreator) {
|
|
1670
|
+
const creator = opts.checkpointCreator;
|
|
1671
|
+
doc.setCheckpointCreator((anchor) => creator(doc, anchor));
|
|
1672
|
+
}
|
|
1673
|
+
if (opts.drivePusher) {
|
|
1674
|
+
const pusher = opts.drivePusher;
|
|
1675
|
+
doc.setDrivePusher((input) => pusher(doc, input));
|
|
1676
|
+
}
|
|
1677
|
+
// `doc.resolveDrive()` opens the hosted resolution surface and, when it
|
|
1678
|
+
// names a document to switch onto, re-opens onto it via the same
|
|
1679
|
+
// `openSiblingDoc` path the `history` fork uses.
|
|
1680
|
+
const hostedForResolve = opts.hostedUiProvider;
|
|
1681
|
+
const resolveSurface = hostedForResolve?.driveResolve;
|
|
1682
|
+
if (hostedForResolve && resolveSurface) {
|
|
1683
|
+
doc.setDriveResolver(async () => {
|
|
1684
|
+
const docId = doc.id;
|
|
1685
|
+
if (docId === null)
|
|
1686
|
+
return null;
|
|
1687
|
+
const result = await resolveSurface(docId);
|
|
1688
|
+
if (result?.newDocId && hostedForResolve.openSiblingDoc) {
|
|
1689
|
+
await hostedForResolve.openSiblingDoc(result.newDocId, {});
|
|
1690
|
+
}
|
|
1691
|
+
return result;
|
|
1692
|
+
});
|
|
1693
|
+
}
|
|
1694
|
+
if (opts.sourceBlobFetcher) {
|
|
1695
|
+
const fetcher = opts.sourceBlobFetcher;
|
|
1696
|
+
doc.setSourceBlobFetcher(() => fetcher(doc));
|
|
1072
1697
|
}
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1698
|
+
setJoinStamp(doc, parsed.updated_at);
|
|
1699
|
+
setJoinedName(doc, parsed);
|
|
1700
|
+
const poster = opts.provisionalInputsPoster;
|
|
1701
|
+
if (poster)
|
|
1702
|
+
doc._setProvisionalInputsPoster((post, o) => poster(docId, post, o));
|
|
1703
|
+
doc.setOpenedStreamName(options?.stream ?? DEFAULT_STREAM_NAME);
|
|
1704
|
+
// Streams: manifest list/create over HTTP plus the per-line open
|
|
1705
|
+
// (join + WS subscribe). Bound to the BRANCH this Document opened on,
|
|
1706
|
+
// which is not the line it materialized when the open named a stream: a
|
|
1707
|
+
// manifest belongs to a branch and a line owns none, so binding to the
|
|
1708
|
+
// line would leave `doc.stream(name)` resolving against nothing.
|
|
1709
|
+
if (opts.streamProvider) {
|
|
1710
|
+
const provider = opts.streamProvider;
|
|
1711
|
+
const openedBranchId = parsed.stream_branch_id ?? parsed.branch_id;
|
|
1712
|
+
doc.setStreamRuntime({
|
|
1713
|
+
list: async () => (await provider.list(docId, openedBranchId)).streams,
|
|
1714
|
+
create: (name) => provider.create(docId, openedBranchId, name),
|
|
1715
|
+
openLine: (lineId) => openStreamLine(docId, lineId, options),
|
|
1716
|
+
});
|
|
1717
|
+
}
|
|
1718
|
+
// Hosted-UI provider backing the doc's built-in `history` / `details` /
|
|
1719
|
+
// `share` / `delete` commands.
|
|
1720
|
+
if (opts.hostedUiProvider) {
|
|
1721
|
+
doc.setHostedUiProvider(opts.hostedUiProvider);
|
|
1722
|
+
const refreshName = opts.hostedUiProvider.refreshName;
|
|
1723
|
+
if (refreshName && docId) {
|
|
1724
|
+
void refreshName(docId).then((name) => {
|
|
1725
|
+
if (name !== null)
|
|
1726
|
+
doc._setName(name);
|
|
1727
|
+
});
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
}
|
|
1731
|
+
// What a share link's open reaches beyond the transport: the routes that
|
|
1732
|
+
// take the link as the credential, and the link's own stream manifest.
|
|
1733
|
+
function wireLinkCapabilities(doc, docId, parsed, credential, options, writes) {
|
|
1734
|
+
const { token } = credential;
|
|
1735
|
+
setJoinStamp(doc, parsed.updated_at);
|
|
1736
|
+
setJoinedName(doc, parsed);
|
|
1737
|
+
doc.setOpenedStreamName(options?.stream ?? DEFAULT_STREAM_NAME);
|
|
1738
|
+
if (writes) {
|
|
1739
|
+
doc.setCheckpointCreator(async (anchor) => {
|
|
1740
|
+
const version = anchor?.version ?? doc.currentSeq;
|
|
1741
|
+
if (version === null) {
|
|
1742
|
+
throw new BrassError('createCheckpoint: the document has no resolved version yet', { status: 400 });
|
|
1743
|
+
}
|
|
1744
|
+
const res = await postWithLink(token, `/documents/${encodeURIComponent(docId)}/checkpoints`, {
|
|
1745
|
+
version,
|
|
1746
|
+
...(doc.branchId !== null ? { branch_id: doc.branchId } : {}),
|
|
1747
|
+
});
|
|
1748
|
+
return res.checkpoint;
|
|
1749
|
+
});
|
|
1750
|
+
}
|
|
1751
|
+
doc.setStreamRuntime({
|
|
1752
|
+
list: () => fetchLinkStreamManifest(token),
|
|
1753
|
+
openLine: (lineId) => openStreamLine(docId, lineId, options, credential),
|
|
1754
|
+
create: async (name) => {
|
|
1755
|
+
if (!writes) {
|
|
1756
|
+
throw new BrassError('A shared-link guest cannot create streams.', { status: 403 });
|
|
1757
|
+
}
|
|
1758
|
+
const info = await postWithLink(token, `/documents/${encodeURIComponent(docId)}/streams`, { name, branch_id: parsed.branch_id });
|
|
1759
|
+
return { name: info.name, format: info.format, line_id: info.line_id, created_at: '', created_by: '' };
|
|
1760
|
+
},
|
|
1761
|
+
});
|
|
1762
|
+
}
|
|
1763
|
+
async function finalise(docId, snapshot, options, credential = SESSION) {
|
|
1764
|
+
const parsed = unwrapJoinResponse(snapshot, isSnapshotResponse, 'snapshot');
|
|
1765
|
+
// A caller who asked for a read-only document is told so in the words for
|
|
1766
|
+
// that, not a share link's.
|
|
1767
|
+
const asked = options?.documentOptions;
|
|
1768
|
+
const doc = await materialiseAs(credential, parsed, readOnlyForGrant(parsed, asked?.readOnly === true && asked.readOnlyReason === undefined
|
|
1769
|
+
? { ...asked, readOnlyReason: 'snapshot' }
|
|
1770
|
+
: asked), options?.onSnapshotProgress);
|
|
1771
|
+
// Populate `doc.status` from the join before the first `doc_status` frame.
|
|
1077
1772
|
if (parsed.status !== undefined)
|
|
1078
1773
|
doc._emitStatus(parsed.status);
|
|
1774
|
+
doc._setImportReport(importReportOf(parsed));
|
|
1775
|
+
setJoinStamp(doc, parsed.updated_at);
|
|
1776
|
+
doc._awaitsImport = parsed.awaits_import === true;
|
|
1079
1777
|
// Whether this app is the one to serialize the document, which is what
|
|
1080
1778
|
// decides whether a push carries bytes. Answered by the server for the
|
|
1081
1779
|
// caller's own app and only on the join, since the fanout frame is one
|
|
@@ -1148,12 +1846,13 @@ export function createOpenDocument(opts) {
|
|
|
1148
1846
|
// snapshot / ops are no-ops in Loro.
|
|
1149
1847
|
const refetchAndImport = async () => {
|
|
1150
1848
|
const endPerf = perfStart('doc: content reload (fetch + import)');
|
|
1151
|
-
const token = await requireToken();
|
|
1152
1849
|
// Refetch the branch this doc was opened on, not the default. `parsed`
|
|
1153
1850
|
// is the opened-branch join, so its `branch_id` is authoritative;
|
|
1154
1851
|
// dropping it would import the default branch's snapshot + tail into a
|
|
1155
1852
|
// doc open on another branch and silently merge the wrong CRDT state.
|
|
1156
|
-
|
|
1853
|
+
// The open's read intent too: a reader who opened a record set as one
|
|
1854
|
+
// re-joins as one, rather than as a writer whose join converts it.
|
|
1855
|
+
const result = await joinAs(credential, docId, rejoinOptions(parsed.branch_id, options));
|
|
1157
1856
|
if (result.status < 200 || result.status >= 300) {
|
|
1158
1857
|
endPerf({ ok: false, joinStatus: result.status });
|
|
1159
1858
|
return { ok: false, reason: `join fetch returned ${result.status}` };
|
|
@@ -1162,39 +1861,35 @@ export function createOpenDocument(opts) {
|
|
|
1162
1861
|
endPerf({ ok: false, joinStatus: result.status });
|
|
1163
1862
|
return { ok: false, reason: 'malformed join response' };
|
|
1164
1863
|
}
|
|
1165
|
-
|
|
1166
|
-
let
|
|
1167
|
-
|
|
1168
|
-
snapshotBytes = base64ToBytes(snap.data_b64);
|
|
1169
|
-
}
|
|
1170
|
-
else if (snap !== null && snap.kind === 'presigned') {
|
|
1864
|
+
doc._awaitsImport = result.parsed.awaits_import === true;
|
|
1865
|
+
let applied;
|
|
1866
|
+
try {
|
|
1171
1867
|
// Stream through the same progress sink the initial open uses, so the
|
|
1172
1868
|
// session surfaces "Downloading... NN%" instead of sitting on the
|
|
1173
1869
|
// last server phase.
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
};
|
|
1186
|
-
}
|
|
1870
|
+
applied = await applyJoin(doc, result.parsed, (url) => fetchPresignedSnapshot(url, options?.onSnapshotProgress));
|
|
1871
|
+
}
|
|
1872
|
+
catch (err) {
|
|
1873
|
+
const status = err instanceof BrassError ? err.status ?? 0 : 0;
|
|
1874
|
+
endPerf({ ok: false, snapshotStatus: status });
|
|
1875
|
+
return {
|
|
1876
|
+
ok: false,
|
|
1877
|
+
reason: status > 0
|
|
1878
|
+
? `snapshot read returned ${status}`
|
|
1879
|
+
: `snapshot read failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
1880
|
+
};
|
|
1187
1881
|
}
|
|
1188
|
-
if (
|
|
1189
|
-
|
|
1190
|
-
|
|
1882
|
+
if (applied.kind !== 'applied') {
|
|
1883
|
+
endPerf({ ok: true, joinStatus: result.status, reloaded: applied.kind === 'reloaded' });
|
|
1884
|
+
return { ok: true, status: result.parsed.status };
|
|
1885
|
+
}
|
|
1886
|
+
const tailSeqs = applied.tailSeqs;
|
|
1191
1887
|
let maxTailSeq = 0;
|
|
1192
|
-
for (const
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
maxTailSeq = entry.seq;
|
|
1888
|
+
for (const seq of tailSeqs) {
|
|
1889
|
+
if (seq > currentSeq)
|
|
1890
|
+
currentSeq = seq;
|
|
1891
|
+
if (seq > maxTailSeq)
|
|
1892
|
+
maxTailSeq = seq;
|
|
1198
1893
|
}
|
|
1199
1894
|
const fetchedSeq = typeof result.parsed.current_seq === 'number'
|
|
1200
1895
|
? result.parsed.current_seq
|
|
@@ -1215,19 +1910,13 @@ export function createOpenDocument(opts) {
|
|
|
1215
1910
|
// under it. That resolves at the next compaction, which folds the tail
|
|
1216
1911
|
// past the hole and raises `snapshot_seq` over it; until then the gap
|
|
1217
1912
|
// pull's own budget is what stops the reader asking forever.
|
|
1218
|
-
|
|
1219
|
-
// A snapshot only counts as a floor once its bytes are in: a shape this
|
|
1220
|
-
// pin does not decode leaves `snapshot_seq` describing content the doc
|
|
1221
|
-
// never received, and the tail alone is then what it holds.
|
|
1222
|
-
if (snapshotBytes !== null || result.parsed.snapshot === null) {
|
|
1223
|
-
raiseCoverage(joinCoverageFloor(snapshotSeqOf(result.parsed.snapshot_seq, tailSeqs, fetchedSeq), tailSeqs));
|
|
1224
|
-
}
|
|
1913
|
+
raiseCoverage(joinCoverageFloor(snapshotSeqOf(result.parsed.snapshot_seq, tailSeqs, fetchedSeq), tailSeqs));
|
|
1225
1914
|
for (const seq of tailSeqs)
|
|
1226
1915
|
advanceCoverage(seq);
|
|
1227
1916
|
endPerf({
|
|
1228
1917
|
ok: true,
|
|
1229
|
-
snapshotBytes:
|
|
1230
|
-
tailOps:
|
|
1918
|
+
snapshotBytes: applied.content.kind === 'crdt' ? applied.content.bytes.byteLength : 0,
|
|
1919
|
+
tailOps: tailSeqs.length,
|
|
1231
1920
|
});
|
|
1232
1921
|
return { ok: true, status: result.parsed.status };
|
|
1233
1922
|
};
|
|
@@ -1237,10 +1926,9 @@ export function createOpenDocument(opts) {
|
|
|
1237
1926
|
// nothing (the handshake only needs the frontier to diff against), so it
|
|
1238
1927
|
// is one cheap JSON round-trip.
|
|
1239
1928
|
const refetchDurableFrontier = async () => {
|
|
1240
|
-
const token = await requireToken();
|
|
1241
1929
|
// Same-branch as `refetchAndImport`: the durability handshake must diff
|
|
1242
1930
|
// against the opened branch's frontier, not the default branch's.
|
|
1243
|
-
const result = await
|
|
1931
|
+
const result = await joinAs(credential, docId, rejoinOptions(parsed.branch_id, options));
|
|
1244
1932
|
if (result.status < 200 || result.status >= 300)
|
|
1245
1933
|
return null;
|
|
1246
1934
|
if (!isSnapshotResponse(result.parsed))
|
|
@@ -1338,6 +2026,49 @@ export function createOpenDocument(opts) {
|
|
|
1338
2026
|
retryPullOnNextFrame = !imported;
|
|
1339
2027
|
})();
|
|
1340
2028
|
};
|
|
2029
|
+
// The row's work status as it is now, read without a snapshot, for a
|
|
2030
|
+
// frame whose claim about the row needs checking.
|
|
2031
|
+
const readWorkStatus = async () => {
|
|
2032
|
+
const body = (await readStatusAs(credential, docId));
|
|
2033
|
+
return parseWorkStatus(body.status);
|
|
2034
|
+
};
|
|
2035
|
+
// A `pending` frame that would take a settled document back to loading is
|
|
2036
|
+
// checked against the row before it locks anything. A frame is the
|
|
2037
|
+
// stream's view of the row at the time of that write, delivered after the
|
|
2038
|
+
// stream's own lag, while the reconcile above reads the row directly: a
|
|
2039
|
+
// client that reconciles between a hydration's last phase write and the
|
|
2040
|
+
// stream delivering it has already imported the content when that
|
|
2041
|
+
// phase's frame arrives, and applying it locks a document whose work is
|
|
2042
|
+
// done. An importer that ran off the reconcile's own ready status then
|
|
2043
|
+
// writes into that lock and is refused. Progress inside a run the
|
|
2044
|
+
// document already shows as pending applies without a read; only the
|
|
2045
|
+
// regression from settled to pending pays one, and that one read answers
|
|
2046
|
+
// for every frame that arrives while it is out. A read that fails applies
|
|
2047
|
+
// the frame, since locking is the side that loses nothing.
|
|
2048
|
+
let pendingVerifyInFlight = false;
|
|
2049
|
+
const verifyPendingStatus = (status) => {
|
|
2050
|
+
if (pendingVerifyInFlight)
|
|
2051
|
+
return;
|
|
2052
|
+
pendingVerifyInFlight = true;
|
|
2053
|
+
void (async () => {
|
|
2054
|
+
let current = 'unread';
|
|
2055
|
+
try {
|
|
2056
|
+
current = await readWorkStatus();
|
|
2057
|
+
}
|
|
2058
|
+
catch {
|
|
2059
|
+
// Unread: the frame stands.
|
|
2060
|
+
}
|
|
2061
|
+
finally {
|
|
2062
|
+
pendingVerifyInFlight = false;
|
|
2063
|
+
}
|
|
2064
|
+
if (current === 'unread') {
|
|
2065
|
+
doc._emitStatus(status);
|
|
2066
|
+
}
|
|
2067
|
+
else if (current !== null && current.pending) {
|
|
2068
|
+
doc._emitStatus(current);
|
|
2069
|
+
}
|
|
2070
|
+
})();
|
|
2071
|
+
};
|
|
1341
2072
|
// Evidence the document moved on the server after a give-up: one more
|
|
1342
2073
|
// pull, which on success imports the content and releases the lock the
|
|
1343
2074
|
// failed terminal status left in place.
|
|
@@ -1347,15 +2078,15 @@ export function createOpenDocument(opts) {
|
|
|
1347
2078
|
retryPullOnNextFrame = false;
|
|
1348
2079
|
reloadAfterHydration(WORK_READY_STATUS);
|
|
1349
2080
|
};
|
|
1350
|
-
// Reconcile
|
|
1351
|
-
// terminal `doc_status` frame is best-effort: if
|
|
1352
|
-
//
|
|
1353
|
-
// and drops the frame, stranding the app on the pending
|
|
1354
|
-
// is posted only after the subscription record is
|
|
1355
|
-
//
|
|
1356
|
-
//
|
|
2081
|
+
// Reconcile pending work once the live subscription exists (a resubscribe
|
|
2082
|
+
// while work runs). The terminal `doc_status` frame is best-effort: if the
|
|
2083
|
+
// work finishes while this client has no subscription record, the fan-out
|
|
2084
|
+
// finds no subscriber and drops the frame, stranding the app on the pending
|
|
2085
|
+
// overlay. `doc_init` is posted only after the subscription record is
|
|
2086
|
+
// written, so one re-fetch here closes the gap. Only a pending document
|
|
2087
|
+
// pays the extra fetch.
|
|
1357
2088
|
const reconcileStatusOnInit = () => {
|
|
1358
|
-
const current = doc.
|
|
2089
|
+
const current = doc._workStatusNow;
|
|
1359
2090
|
if (!current || !current.pending)
|
|
1360
2091
|
return;
|
|
1361
2092
|
if (reconcileInFlight || reloadInFlight)
|
|
@@ -1401,14 +2132,16 @@ export function createOpenDocument(opts) {
|
|
|
1401
2132
|
// `op_ack` would have. Targets the branch this doc was opened on, the
|
|
1402
2133
|
// same branch the transport's topic is scoped to.
|
|
1403
2134
|
const postOversizedOp = async (opBlobB64, clientOpId) => {
|
|
1404
|
-
const
|
|
1405
|
-
const { status, parsed: opResult } = await postDocumentOp(docId, parsed.branch_id, opBlobB64, clientOpId, token);
|
|
2135
|
+
const { status, parsed: opResult } = await postOpAs(credential, docId, parsed.branch_id, opBlobB64, clientOpId, baseImportSeqField(doc));
|
|
1406
2136
|
if (status < 200 || status >= 300) {
|
|
1407
2137
|
return {
|
|
1408
2138
|
ok: false,
|
|
1409
2139
|
message: opWriteFailureMessage(status, opResult),
|
|
1410
2140
|
retryable: opWriteIsRetryable(status),
|
|
1411
2141
|
atCapacity: isCapacityRefusal(status, opResult),
|
|
2142
|
+
...(isMovedRefusal(opResult) ? { moved: true } : {}),
|
|
2143
|
+
...(isNotImportedRefusal(opResult) ? { notImported: true } : {}),
|
|
2144
|
+
...(status === 413 ? { tooLarge: true } : {}),
|
|
1412
2145
|
// The route answers 403 when the caller's grant carries no write
|
|
1413
2146
|
// access, which is the HTTP face of the socket's `role_denied`.
|
|
1414
2147
|
...(status === 403 ? { readOnly: 'grant' } : {}),
|
|
@@ -1424,7 +2157,8 @@ export function createOpenDocument(opts) {
|
|
|
1424
2157
|
}
|
|
1425
2158
|
return { ok: true, seq };
|
|
1426
2159
|
};
|
|
1427
|
-
|
|
2160
|
+
const docWsClient = wsClientFor(credential);
|
|
2161
|
+
if (docWsClient) {
|
|
1428
2162
|
wireDocTransport({
|
|
1429
2163
|
doc,
|
|
1430
2164
|
docId,
|
|
@@ -1441,7 +2175,10 @@ export function createOpenDocument(opts) {
|
|
|
1441
2175
|
? { documentOptions: options.documentOptions }
|
|
1442
2176
|
: {}),
|
|
1443
2177
|
...(opts.identity !== undefined ? { identity: opts.identity } : {}),
|
|
1444
|
-
|
|
2178
|
+
// A link's open is parked under nobody, and a frozen copy hears
|
|
2179
|
+
// whole-document notices rather than joining the room.
|
|
2180
|
+
...(credential.kind === 'session' ? {} : { parkable: false, skipPresence: options?.frozen === true }),
|
|
2181
|
+
wsClient: docWsClient,
|
|
1445
2182
|
postOversizedOp,
|
|
1446
2183
|
newClientOpId: opts.newClientOpId ?? defaultClientOpIdMinter,
|
|
1447
2184
|
getCurrentSeq: () => currentSeq,
|
|
@@ -1455,88 +2192,14 @@ export function createOpenDocument(opts) {
|
|
|
1455
2192
|
refetchDurableFrontier,
|
|
1456
2193
|
reloadAfterHydration,
|
|
1457
2194
|
reconcileStatusOnInit,
|
|
2195
|
+
verifyPendingStatus,
|
|
1458
2196
|
onDocumentMoved: retryContentPull,
|
|
1459
2197
|
});
|
|
1460
2198
|
}
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
// toolbar's branch chip can name the branch on the next subscribe
|
|
1466
|
-
// tick. Branch management is hosted; this list feeds the SDK's own
|
|
1467
|
-
// chrome, not an app method.
|
|
1468
|
-
if (opts.branchProvider) {
|
|
1469
|
-
doc.setBranchProvider(opts.branchProvider);
|
|
1470
|
-
void doc._refreshBranches().catch(() => {
|
|
1471
|
-
// Best-effort: a failure leaves the chip unnamed until a hosted
|
|
1472
|
-
// branch action refreshes the list.
|
|
1473
|
-
});
|
|
1474
|
-
}
|
|
1475
|
-
// `doc.useCanvas(canvas)` captures + uploads after each local commit; the
|
|
1476
|
-
// doc handles debounce + readOnly gating internally.
|
|
1477
|
-
if (opts.thumbnailUploader) {
|
|
1478
|
-
const uploader = opts.thumbnailUploader;
|
|
1479
|
-
doc.setThumbnailUploader(async (image, width, height) => {
|
|
1480
|
-
await uploader(doc, image, width, height);
|
|
1481
|
-
});
|
|
1482
|
-
}
|
|
1483
|
-
doc._setThumbnailVersion(parsed.thumbnail?.version ?? null);
|
|
1484
|
-
if (opts.checkpointCreator) {
|
|
1485
|
-
const creator = opts.checkpointCreator;
|
|
1486
|
-
doc.setCheckpointCreator(() => creator(doc));
|
|
1487
|
-
}
|
|
1488
|
-
if (opts.drivePusher) {
|
|
1489
|
-
const pusher = opts.drivePusher;
|
|
1490
|
-
doc.setDrivePusher((input) => pusher(doc, input));
|
|
1491
|
-
}
|
|
1492
|
-
// `doc.resolveDrive()` opens the hosted resolution surface and, when it
|
|
1493
|
-
// mints a new mirror doc (take-source / keep-both), re-opens onto it via
|
|
1494
|
-
// the same `openSiblingDoc` path the `history` fork uses.
|
|
1495
|
-
const hostedForResolve = opts.hostedUiProvider;
|
|
1496
|
-
const resolveSurface = hostedForResolve?.driveResolve;
|
|
1497
|
-
if (hostedForResolve && resolveSurface) {
|
|
1498
|
-
doc.setDriveResolver(async () => {
|
|
1499
|
-
const docId = doc.id;
|
|
1500
|
-
if (docId === null)
|
|
1501
|
-
return null;
|
|
1502
|
-
const result = await resolveSurface(docId);
|
|
1503
|
-
if (result?.newDocId && hostedForResolve.openSiblingDoc) {
|
|
1504
|
-
await hostedForResolve.openSiblingDoc(result.newDocId, {});
|
|
1505
|
-
}
|
|
1506
|
-
return result;
|
|
1507
|
-
});
|
|
1508
|
-
}
|
|
1509
|
-
if (opts.sourceBlobFetcher) {
|
|
1510
|
-
const fetcher = opts.sourceBlobFetcher;
|
|
1511
|
-
doc.setSourceBlobFetcher(() => fetcher(doc));
|
|
1512
|
-
}
|
|
1513
|
-
doc.setOpenedStreamName(options?.stream ?? DEFAULT_STREAM_NAME);
|
|
1514
|
-
// Streams: manifest list/create over HTTP plus the per-line open
|
|
1515
|
-
// (join + WS subscribe). Bound to the BRANCH this Document opened on,
|
|
1516
|
-
// which is not the line it materialized when the open named a stream: a
|
|
1517
|
-
// manifest belongs to a branch and a line owns none, so binding to the
|
|
1518
|
-
// line would leave `doc.stream(name)` resolving against nothing.
|
|
1519
|
-
if (opts.streamProvider) {
|
|
1520
|
-
const provider = opts.streamProvider;
|
|
1521
|
-
const openedBranchId = parsed.stream_branch_id ?? parsed.branch_id;
|
|
1522
|
-
doc.setStreamRuntime({
|
|
1523
|
-
list: async () => (await provider.list(docId, openedBranchId)).streams,
|
|
1524
|
-
create: (name) => provider.create(docId, openedBranchId, name),
|
|
1525
|
-
openLine: (lineId) => openStreamLine(docId, lineId),
|
|
1526
|
-
});
|
|
1527
|
-
}
|
|
1528
|
-
// Hosted-UI provider backing the doc's built-in `history` / `details` /
|
|
1529
|
-
// `share` / `delete` commands.
|
|
1530
|
-
if (opts.hostedUiProvider) {
|
|
1531
|
-
doc.setHostedUiProvider(opts.hostedUiProvider);
|
|
1532
|
-
const refreshName = opts.hostedUiProvider.refreshName;
|
|
1533
|
-
if (refreshName && docId) {
|
|
1534
|
-
void refreshName(docId).then((name) => {
|
|
1535
|
-
if (name !== null)
|
|
1536
|
-
doc._setName(name);
|
|
1537
|
-
});
|
|
1538
|
-
}
|
|
1539
|
-
}
|
|
2199
|
+
if (credential.kind === 'session')
|
|
2200
|
+
wireSessionCapabilities(doc, docId, parsed, options);
|
|
2201
|
+
else
|
|
2202
|
+
wireLinkCapabilities(doc, docId, parsed, credential, options, doc.readOnly !== true);
|
|
1540
2203
|
if (opts.reportError)
|
|
1541
2204
|
doc.setErrorReporter(opts.reportError);
|
|
1542
2205
|
// No socket on a document the caller can write to: the write path reaches
|
|
@@ -1553,16 +2216,8 @@ export function createOpenDocument(opts) {
|
|
|
1553
2216
|
// through `documentOptions` and `wireDocTransport` is skipped (a frozen
|
|
1554
2217
|
// point has no live op stream).
|
|
1555
2218
|
async function openAtVersion(docId, version, options, token) {
|
|
1556
|
-
const
|
|
1557
|
-
|
|
1558
|
-
throw brassErrorFromBody(status, parseFailed ? undefined : parsed);
|
|
1559
|
-
}
|
|
1560
|
-
if (!isAtVersionResponse(parsed)) {
|
|
1561
|
-
throw new BrassError('Malformed document at-version response', {
|
|
1562
|
-
status: 500,
|
|
1563
|
-
});
|
|
1564
|
-
}
|
|
1565
|
-
return materialise({
|
|
2219
|
+
const parsed = unwrapJoinResponse(await fetchAtVersion(docId, version, options.branch, token), isAtVersionResponse, 'at-version');
|
|
2220
|
+
return materialiseAs(SESSION, {
|
|
1566
2221
|
doc_id: docId,
|
|
1567
2222
|
branch_id: parsed.branch_id,
|
|
1568
2223
|
snapshot: parsed.snapshot,
|
|
@@ -1577,30 +2232,46 @@ export function createOpenDocument(opts) {
|
|
|
1577
2232
|
// Sends NO Authorization header (the token is the credential), so it must
|
|
1578
2233
|
// not go through the auth-bearing `fetchSnapshot`. The server returns the
|
|
1579
2234
|
// same snapshot + tail_ops envelope, member-stripped and read-only.
|
|
1580
|
-
async function fetchLinkView(token,
|
|
2235
|
+
async function fetchLinkView(token,
|
|
2236
|
+
// The line, and whether this guest will write. A view link's reader never
|
|
2237
|
+
// does, and an edit link's may, which is the same question the
|
|
2238
|
+
// authenticated join asks with `intent=read` and decides the same thing:
|
|
2239
|
+
// whether a document the platform stores frozen is served as it stands or
|
|
2240
|
+
// converted first.
|
|
2241
|
+
join, progress, { rejoin = false } = {}) {
|
|
2242
|
+
const once = () => linkViewOnce(token, join, rejoin);
|
|
2243
|
+
// An edit link's guest waits out a conversion as a member's editable open
|
|
2244
|
+
// does.
|
|
2245
|
+
return opensReadOnly(join) ? once() : waitWhileConverting(once, progress);
|
|
2246
|
+
}
|
|
2247
|
+
async function linkViewOnce(token, join, rejoin) {
|
|
1581
2248
|
const search = new URLSearchParams();
|
|
1582
2249
|
search.set('include', 'snapshot');
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
const
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
2250
|
+
search.set('app_id', opts.appId);
|
|
2251
|
+
if (rejoin)
|
|
2252
|
+
search.set('rejoin', '1');
|
|
2253
|
+
const headers = await linkViewHeaders();
|
|
2254
|
+
// A person signed in here who reaches the document on their own opens it
|
|
2255
|
+
// as themselves, with their own role and identity.
|
|
2256
|
+
if (!rejoin && headers['authorization'] !== undefined)
|
|
2257
|
+
search.set('as_member', '1');
|
|
2258
|
+
if (join.branch !== undefined)
|
|
2259
|
+
search.set('branch', join.branch);
|
|
2260
|
+
if (opensReadOnly(join))
|
|
2261
|
+
search.set('intent', 'read');
|
|
2262
|
+
return sendJoin(`/links/${encodeURIComponent(token)}/view`, search, { headers });
|
|
2263
|
+
}
|
|
2264
|
+
// A link open carries no credential unless the person signed in on this
|
|
2265
|
+
// link page, in which case it carries theirs.
|
|
2266
|
+
async function linkViewHeaders() {
|
|
2267
|
+
const headers = {};
|
|
2268
|
+
applyClientVersionHeader(headers);
|
|
2269
|
+
const credential = (await opts.getLinkCredential?.()) ?? null;
|
|
2270
|
+
if (credential !== null) {
|
|
2271
|
+
headers['authorization'] = `Bearer ${credential.accessToken}`;
|
|
2272
|
+
headers['x-brass-app-assertion'] = credential.appAssertion;
|
|
1597
2273
|
}
|
|
1598
|
-
|
|
1599
|
-
// a guest join carries them exactly as a signed-in one does. The URL
|
|
1600
|
-
// carries its own signature, so an anonymous reader fetches it with the
|
|
1601
|
-
// same call. Resolving here is what lets `materialise` see one shape.
|
|
1602
|
-
await resolveParsedTailOps(parsed);
|
|
1603
|
-
return { status: response.status, parsed, parseFailed };
|
|
2274
|
+
return headers;
|
|
1604
2275
|
}
|
|
1605
2276
|
// `GET /links/{token}/streams`: the anonymous stream manifest, the guest
|
|
1606
2277
|
// counterpart of the signed-in streams list. No Authorization header (the
|
|
@@ -1608,7 +2279,9 @@ export function createOpenDocument(opts) {
|
|
|
1608
2279
|
// `doc.stream(name)` resolves against.
|
|
1609
2280
|
async function fetchLinkStreamManifest(token) {
|
|
1610
2281
|
const url = `${baseUrl}/links/${encodeURIComponent(token)}/streams`;
|
|
1611
|
-
const
|
|
2282
|
+
const headers = {};
|
|
2283
|
+
applyClientVersionHeader(headers);
|
|
2284
|
+
const response = await fetchWithRetry(() => fetch(url, { method: 'GET', headers }));
|
|
1612
2285
|
const text = await response.text();
|
|
1613
2286
|
if (response.status < 200 || response.status >= 300) {
|
|
1614
2287
|
let parsed = undefined;
|
|
@@ -1639,102 +2312,57 @@ export function createOpenDocument(opts) {
|
|
|
1639
2312
|
]
|
|
1640
2313
|
: []);
|
|
1641
2314
|
}
|
|
1642
|
-
const openByLink = async (token,
|
|
1643
|
-
const
|
|
1644
|
-
|
|
1645
|
-
|
|
2315
|
+
const openByLink = async (token, asked) => {
|
|
2316
|
+
const options = withReadIntent(asked);
|
|
2317
|
+
const view = await fetchLinkView(token, rejoinOptions(options?.branch, options), options?.onSnapshotProgress);
|
|
2318
|
+
if (view.status < 200 || view.status >= 300) {
|
|
2319
|
+
throw brassErrorFromBody(view.status, view.parseFailed ? undefined : view.parsed);
|
|
1646
2320
|
}
|
|
1647
|
-
const b = parsed;
|
|
1648
|
-
if (typeof b.doc_id !== 'string'
|
|
1649
|
-
typeof b.branch_id !== 'string' ||
|
|
1650
|
-
!Array.isArray(b.tail_ops)) {
|
|
2321
|
+
const b = view.parsed;
|
|
2322
|
+
if (typeof b.doc_id !== 'string') {
|
|
1651
2323
|
throw new BrassError('Malformed link view response', { status: 500 });
|
|
1652
2324
|
}
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
//
|
|
1657
|
-
//
|
|
1658
|
-
|
|
1659
|
-
const
|
|
1660
|
-
const
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
doc.setErrorReporter(opts.reportError);
|
|
1683
|
-
wireGuestTransport(doc, token, b.doc_id, b.branch_id, canEdit);
|
|
1684
|
-
// Streams: a guest resolves `doc.stream(name)` through the token
|
|
1685
|
-
// routes. `list` hits the anonymous manifest; `openLine` joins the
|
|
1686
|
-
// line read/write exactly like the default stream above, over the same
|
|
1687
|
-
// token-authenticated socket. `create` is refused (a guest cannot mint
|
|
1688
|
-
// a line). Absent when there is no WS client (a snapshot-only guest),
|
|
1689
|
-
// matching the default stream having no live transport there.
|
|
1690
|
-
doc.setStreamRuntime({
|
|
1691
|
-
list: () => fetchLinkStreamManifest(token),
|
|
1692
|
-
openLine: async (lineId) => {
|
|
1693
|
-
const view = await fetchLinkView(token, lineId);
|
|
1694
|
-
if (view.status < 200 || view.status >= 300) {
|
|
1695
|
-
throw brassErrorFromBody(view.status, view.parseFailed ? undefined : view.parsed);
|
|
1696
|
-
}
|
|
1697
|
-
const lb = view.parsed;
|
|
1698
|
-
if (typeof lb.doc_id !== 'string' ||
|
|
1699
|
-
typeof lb.branch_id !== 'string' ||
|
|
1700
|
-
!Array.isArray(lb.tail_ops)) {
|
|
1701
|
-
throw new BrassError('Malformed link stream view response', {
|
|
1702
|
-
status: 500,
|
|
1703
|
-
});
|
|
1704
|
-
}
|
|
1705
|
-
const inner = await materialise({
|
|
1706
|
-
doc_id: lb.doc_id,
|
|
1707
|
-
branch_id: lb.branch_id,
|
|
1708
|
-
snapshot: lb.snapshot,
|
|
1709
|
-
tail_ops: lb.tail_ops,
|
|
1710
|
-
}, { readOnly: !canEdit });
|
|
1711
|
-
if (opts.reportError)
|
|
1712
|
-
inner.setErrorReporter(opts.reportError);
|
|
1713
|
-
wireGuestTransport(inner, token, lb.doc_id, lb.branch_id, canEdit);
|
|
1714
|
-
return inner;
|
|
1715
|
-
},
|
|
1716
|
-
create: () => {
|
|
1717
|
-
throw new BrassError('A shared-link guest cannot create streams.', {
|
|
1718
|
-
status: 403,
|
|
1719
|
-
});
|
|
1720
|
-
},
|
|
1721
|
-
});
|
|
2325
|
+
if (b.opens_as_member === true) {
|
|
2326
|
+
return (opts.openAsMember ?? open)(b.doc_id, asked);
|
|
2327
|
+
}
|
|
2328
|
+
// The link's grant decides read vs write; the view answers `can_edit`
|
|
2329
|
+
// as the member join does, so a view link reads as a viewer's grant.
|
|
2330
|
+
const canEdit = b.link_role === 'editor' && b.can_edit !== false;
|
|
2331
|
+
const askedToRead = canEdit && opensReadOnly(options);
|
|
2332
|
+
const linkOptions = {
|
|
2333
|
+
...options,
|
|
2334
|
+
documentOptions: askedToRead
|
|
2335
|
+
? { ...options?.documentOptions, readOnly: true, readOnlyReason: 'snapshot' }
|
|
2336
|
+
: canEdit
|
|
2337
|
+
? { ...options?.documentOptions }
|
|
2338
|
+
: { ...options?.documentOptions, readOnly: true, readOnlyReason: 'link' },
|
|
2339
|
+
};
|
|
2340
|
+
// A document minted without an account names when the platform removes
|
|
2341
|
+
// it; every other link carries neither key.
|
|
2342
|
+
const expiresAt = anonymousExpiry(b);
|
|
2343
|
+
const credential = expiresAt !== null ? { kind: 'temporary', token } : { kind: 'link', token };
|
|
2344
|
+
const doc = await finalise(b.doc_id, view, linkOptions, credential);
|
|
2345
|
+
if (expiresAt !== null) {
|
|
2346
|
+
doc._setExpiresAt(expiresAt);
|
|
2347
|
+
// A document the visitor started from a file is imported by this app
|
|
2348
|
+
// in this page, so it reads its file through the link as a member's
|
|
2349
|
+
// import reads it through the document.
|
|
2350
|
+
const imports = guestImports(token, b.doc_id);
|
|
2351
|
+
doc.setSourceBlobFetcher(() => imports.sourceBlob());
|
|
2352
|
+
doc._awaitsImport = canEdit && b.awaits_import === true;
|
|
2353
|
+
}
|
|
1722
2354
|
return doc;
|
|
1723
2355
|
};
|
|
1724
|
-
//
|
|
1725
|
-
//
|
|
1726
|
-
//
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
const response = await fetch(`${baseUrl}
|
|
1730
|
-
|
|
1731
|
-
headers: { 'content-type': 'application/json' },
|
|
1732
|
-
body: JSON.stringify({
|
|
1733
|
-
op_blob: opBlobB64,
|
|
1734
|
-
branch_id: lineId,
|
|
1735
|
-
client_op_id: clientOpId,
|
|
1736
|
-
}),
|
|
2356
|
+
// A read through a route a member's open reads, with the link as the
|
|
2357
|
+
// credential. A 404 answers null, as the session's reads of the same
|
|
2358
|
+
// routes do.
|
|
2359
|
+
async function getWithLink(token, path, query) {
|
|
2360
|
+
const search = new URLSearchParams(query).toString();
|
|
2361
|
+
const response = await fetch(`${baseUrl}${search ? `${path}?${search}` : path}`, {
|
|
2362
|
+
headers: { authorization: `Link ${token}` },
|
|
1737
2363
|
});
|
|
2364
|
+
if (response.status === 404)
|
|
2365
|
+
return null;
|
|
1738
2366
|
const text = await response.text();
|
|
1739
2367
|
let parsed = undefined;
|
|
1740
2368
|
try {
|
|
@@ -1743,316 +2371,77 @@ export function createOpenDocument(opts) {
|
|
|
1743
2371
|
catch {
|
|
1744
2372
|
parsed = undefined;
|
|
1745
2373
|
}
|
|
1746
|
-
|
|
2374
|
+
if (response.status < 200 || response.status >= 300) {
|
|
2375
|
+
throw brassErrorFromBody(response.status, parsed);
|
|
2376
|
+
}
|
|
2377
|
+
return parsed;
|
|
1747
2378
|
}
|
|
1748
|
-
//
|
|
1749
|
-
//
|
|
1750
|
-
//
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
if (!wsClient) {
|
|
1765
|
-
// No socket: a write would change local state and reach no server, so
|
|
1766
|
-
// the document reports the first one instead of discarding it quietly.
|
|
1767
|
-
if (canEdit)
|
|
1768
|
-
doc.markTransportUnavailable();
|
|
1769
|
-
return;
|
|
2379
|
+
// A write through a route a member's open writes, with the link as the
|
|
2380
|
+
// credential. Resolves the parsed body, or rejects with the server's
|
|
2381
|
+
// refusal.
|
|
2382
|
+
async function postWithLink(token, path, body) {
|
|
2383
|
+
const response = await fetch(`${baseUrl}${path}`, {
|
|
2384
|
+
method: 'POST',
|
|
2385
|
+
headers: {
|
|
2386
|
+
'content-type': 'application/json',
|
|
2387
|
+
authorization: `Link ${token}`,
|
|
2388
|
+
},
|
|
2389
|
+
body: JSON.stringify(body),
|
|
2390
|
+
});
|
|
2391
|
+
const text = await response.text();
|
|
2392
|
+
let parsed = undefined;
|
|
2393
|
+
try {
|
|
2394
|
+
parsed = text ? JSON.parse(text) : undefined;
|
|
1770
2395
|
}
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
const outstanding = new Map();
|
|
1777
|
-
let shedRounds = 0;
|
|
1778
|
-
let shedTimer = null;
|
|
1779
|
-
function clearShedRecovery() {
|
|
1780
|
-
if (shedTimer !== null) {
|
|
1781
|
-
clearTimeout(shedTimer);
|
|
1782
|
-
shedTimer = null;
|
|
1783
|
-
}
|
|
1784
|
-
shedRounds = 0;
|
|
1785
|
-
}
|
|
1786
|
-
// Every outstanding op is dead: drop the tracker entries and tell the
|
|
1787
|
-
// Document, which stops counting them in `savingCount` and reports each
|
|
1788
|
-
// through the error channel the app reads. The ids are taken first
|
|
1789
|
-
// because `_failOutstandingOp` re-enters app code.
|
|
1790
|
-
function failAllOutstanding(message) {
|
|
1791
|
-
const clientOpIds = [...outstanding.keys()];
|
|
1792
|
-
outstanding.clear();
|
|
1793
|
-
for (const clientOpId of clientOpIds) {
|
|
1794
|
-
doc._failOutstandingOp(clientOpId, message);
|
|
1795
|
-
}
|
|
1796
|
-
}
|
|
1797
|
-
// Put every op still awaiting an answer back on the wire. The client
|
|
1798
|
-
// DROPS an op frame it cannot send right now rather than buffering one
|
|
1799
|
-
// (`ownsFrameRetry` below), so this is the only thing that carries a
|
|
1800
|
-
// shed or reconnect-orphaned op across the window, and it is also what
|
|
1801
|
-
// keeps a frame from being written twice: a copy the client held would
|
|
1802
|
-
// flush on the `subscribed` ack while this re-asserts on the `doc_init`
|
|
1803
|
-
// behind it, and the server allocates a fresh seq per send.
|
|
1804
|
-
const resendAllOutstanding = () => {
|
|
1805
|
-
for (const frame of outstanding.values())
|
|
1806
|
-
wsClient.send(frame);
|
|
1807
|
-
};
|
|
1808
|
-
const onServerUnavailable = () => {
|
|
1809
|
-
if (outstanding.size === 0)
|
|
1810
|
-
return;
|
|
1811
|
-
if (shedTimer !== null)
|
|
1812
|
-
return;
|
|
1813
|
-
shedRounds += 1;
|
|
1814
|
-
if (shedRounds > UNAVAILABLE_MAX_ROUNDS) {
|
|
1815
|
-
clearShedRecovery();
|
|
1816
|
-
failAllOutstanding(UNAVAILABLE_GIVE_UP_MESSAGE);
|
|
1817
|
-
return;
|
|
1818
|
-
}
|
|
1819
|
-
shedTimer = setTimeout(() => {
|
|
1820
|
-
shedTimer = null;
|
|
1821
|
-
resendAllOutstanding();
|
|
1822
|
-
}, unavailableDelayMs(shedRounds));
|
|
1823
|
-
shedTimer.unref?.();
|
|
1824
|
-
};
|
|
1825
|
-
const unsub = wsClient.subscribe(topic, (frame) => {
|
|
1826
|
-
const init = parseDocInitFrame(frame);
|
|
1827
|
-
if (init) {
|
|
1828
|
-
for (const entry of init.tail_ops) {
|
|
1829
|
-
if (entry.op_blob_b64 === undefined)
|
|
1830
|
-
continue;
|
|
1831
|
-
doc.applyRemoteOp(base64ToBytes(entry.op_blob_b64));
|
|
1832
|
-
}
|
|
1833
|
-
// `doc_init` follows every `subscribed` ack, so this is the point
|
|
1834
|
-
// the topic can carry frames again. A fresh connection also returns
|
|
1835
|
-
// the shed budget to full: this re-assert is the round a pending
|
|
1836
|
-
// one would have run.
|
|
1837
|
-
clearShedRecovery();
|
|
1838
|
-
resendAllOutstanding();
|
|
1839
|
-
return;
|
|
1840
|
-
}
|
|
1841
|
-
if (isDocOpFrame(frame)) {
|
|
1842
|
-
doc.applyRemoteOp(base64ToBytes(frame.op_blob));
|
|
1843
|
-
}
|
|
1844
|
-
else if (isDocGoneFrame(frame)) {
|
|
1845
|
-
// The server fans `doc_gone` to EVERY subscriber on the branched
|
|
1846
|
-
// content topic, guest-link connections included, on a soft-delete /
|
|
1847
|
-
// move of the opened doc.
|
|
1848
|
-
// Without this branch the guest keeps showing removed content and
|
|
1849
|
-
// `doc.onGone(...)` never fires; for an edit link the next local op
|
|
1850
|
-
// would ship onto the evicted subscription record and bounce as a misleading
|
|
1851
|
-
// `forbidden`. Drop the shed recovery first so nothing re-asserts an
|
|
1852
|
-
// op onto an evicted subscription record, then mark the doc gone (mapping the
|
|
1853
|
-
// reason like the signed-in transport).
|
|
1854
|
-
clearShedRecovery();
|
|
1855
|
-
outstanding.clear();
|
|
1856
|
-
doc._markGone(goneReasonOf(frame));
|
|
1857
|
-
}
|
|
1858
|
-
else if (isDocStatusFrame(frame)) {
|
|
1859
|
-
// A guest can open a document whose inbound work is still running,
|
|
1860
|
-
// and hydration writes a SNAPSHOT rather than ops, so nothing on the
|
|
1861
|
-
// op stream would ever fill the view in. Same split as the signed-in
|
|
1862
|
-
// transport: the `reload` hint pulls the content and emits the
|
|
1863
|
-
// terminal status after the import, and anything else renders.
|
|
1864
|
-
const status = normalizeWorkStatus(frame.status);
|
|
1865
|
-
if (frame.reload)
|
|
1866
|
-
pullGuestContent(doc, token, lineId, status);
|
|
1867
|
-
else
|
|
1868
|
-
doc._emitStatus(status);
|
|
1869
|
-
}
|
|
1870
|
-
else if (isDocMetaFrame(frame)) {
|
|
1871
|
-
// `_setName` de-dupes, so a re-emit of the current name is a no-op.
|
|
1872
|
-
doc._setName(frame.name);
|
|
1873
|
-
}
|
|
1874
|
-
else if (canEdit && isOpAckFrame(frame)) {
|
|
1875
|
-
outstanding.delete(frame.client_op_id);
|
|
1876
|
-
doc.resolveOutstandingOp(frame.client_op_id);
|
|
1877
|
-
// The server is durably accepting writes again, so the shed budget
|
|
1878
|
-
// starts over. A round already scheduled still runs: the ops it
|
|
1879
|
-
// re-asserts are the ones this ack did not cover.
|
|
1880
|
-
shedRounds = 0;
|
|
1881
|
-
}
|
|
1882
|
-
else if (canEdit && isServerUnavailableFrame(frame)) {
|
|
1883
|
-
onServerUnavailable();
|
|
1884
|
-
}
|
|
1885
|
-
else if (canEdit && isOpRejectedFrame(frame)) {
|
|
1886
|
-
// Terminal, not transient: stop re-asserting this op (retrying
|
|
1887
|
-
// would only draw the same rejection) and fail it on the Document
|
|
1888
|
-
// so "could not be saved" surfaces instead of a stuck saving count.
|
|
1889
|
-
// A refusal naming the writer (an edit link downgraded or revoked
|
|
1890
|
-
// while the guest had it open) turns the Document read-only, the
|
|
1891
|
-
// same way it does for a signed-in reader.
|
|
1892
|
-
outstanding.delete(frame.client_op_id);
|
|
1893
|
-
if (isCapacityRejection(frame))
|
|
1894
|
-
doc._markAtCapacity();
|
|
1895
|
-
const readOnly = readOnlyReasonForRejection(frame);
|
|
1896
|
-
if (readOnly !== null)
|
|
1897
|
-
doc._markReadOnly(readOnly);
|
|
1898
|
-
doc._failOutstandingOp(frame.client_op_id, opRejectedMessage(frame));
|
|
1899
|
-
}
|
|
1900
|
-
},
|
|
1901
|
-
// The guest owns its outstanding ops: `resendAllOutstanding` on the
|
|
1902
|
-
// `doc_init` behind every `subscribed` ack IS this topic's
|
|
1903
|
-
// re-assertion, so the client drops an op frame it cannot send now
|
|
1904
|
-
// rather than buffering a second copy of it.
|
|
1905
|
-
{ ownsFrameRetry: true });
|
|
1906
|
-
// An op the socket cannot carry, written over HTTP on the same policy
|
|
1907
|
-
// the signed-in path runs: refuse locally what no write can hold, and
|
|
1908
|
-
// re-assert a capacity refusal on the shared backoff before reporting it.
|
|
1909
|
-
async function writeOversizedGuestOp(opBlobB64, clientOpId) {
|
|
1910
|
-
// Yield before any outcome resolves the op. The hook's caller records
|
|
1911
|
-
// the outstanding op AFTER the hook returns, and an async function body
|
|
1912
|
-
// runs synchronously up to its first await, so an exit taken inline
|
|
1913
|
-
// would find nothing tracked and leave the entry behind with
|
|
1914
|
-
// `savingCount` stuck above zero.
|
|
1915
|
-
await Promise.resolve();
|
|
1916
|
-
// Both sizes are known here, so refuse without spending a round trip
|
|
1917
|
-
// on a blob that can only come back 400.
|
|
1918
|
-
if (opBlobExceedsDurableLimit(opBlobB64)) {
|
|
1919
|
-
doc._failOutstandingOp(clientOpId, TOO_LARGE_MESSAGE);
|
|
1920
|
-
return;
|
|
1921
|
-
}
|
|
1922
|
-
if (opBlobNeedsStaging(opBlobB64)) {
|
|
1923
|
-
doc._failOutstandingOp(clientOpId, LINK_OP_TOO_LARGE_MESSAGE);
|
|
1924
|
-
return;
|
|
1925
|
-
}
|
|
1926
|
-
let status = 0;
|
|
1927
|
-
let parsed = undefined;
|
|
1928
|
-
for (let round = 0; round <= UNAVAILABLE_MAX_ROUNDS; round += 1) {
|
|
1929
|
-
if (round > 0) {
|
|
1930
|
-
await new Promise((resolve) => {
|
|
1931
|
-
const timer = setTimeout(resolve, unavailableDelayMs(round));
|
|
1932
|
-
timer.unref?.();
|
|
1933
|
-
});
|
|
1934
|
-
}
|
|
1935
|
-
try {
|
|
1936
|
-
({ status, parsed } = await postLinkOp(token, lineId, opBlobB64, clientOpId));
|
|
1937
|
-
}
|
|
1938
|
-
catch (err) {
|
|
1939
|
-
// A throw leaves it unknown whether the handler ran, and this op
|
|
1940
|
-
// carries no idempotency the server dedupes on, so replaying it
|
|
1941
|
-
// could double-apply.
|
|
1942
|
-
doc._failOutstandingOp(clientOpId, err instanceof Error ? err.message : String(err));
|
|
1943
|
-
return;
|
|
1944
|
-
}
|
|
1945
|
-
if (status >= 200 && status < 300)
|
|
1946
|
-
break;
|
|
1947
|
-
if (!opWriteIsRetryable(status))
|
|
1948
|
-
break;
|
|
1949
|
-
}
|
|
1950
|
-
if (status < 200 || status >= 300) {
|
|
1951
|
-
doc._failOutstandingOp(clientOpId, opWriteIsRetryable(status)
|
|
1952
|
-
? UNAVAILABLE_GIVE_UP_MESSAGE
|
|
1953
|
-
: opWriteFailureMessage(status, parsed));
|
|
1954
|
-
return;
|
|
1955
|
-
}
|
|
1956
|
-
doc.resolveOutstandingOp(clientOpId);
|
|
1957
|
-
}
|
|
1958
|
-
if (canEdit) {
|
|
1959
|
-
const minter = opts.newClientOpId ?? defaultClientOpIdMinter;
|
|
1960
|
-
doc.setLocalOpHook((opBlob) => {
|
|
1961
|
-
const clientOpId = minter();
|
|
1962
|
-
const opBlobB64 = bytesToBase64(opBlob);
|
|
1963
|
-
const sendFrame = {
|
|
1964
|
-
type: 'op',
|
|
1965
|
-
topic,
|
|
1966
|
-
op_blob: opBlobB64,
|
|
1967
|
-
client_op_id: clientOpId,
|
|
1968
|
-
};
|
|
1969
|
-
// A frame past the transport's cap is not delivered and takes the
|
|
1970
|
-
// connection down with it, so it must never reach the socket: a
|
|
1971
|
-
// guest's document, presence and every other op ride that one
|
|
1972
|
-
// connection. `mutate` batches to a budget well above what a frame
|
|
1973
|
-
// carries, so an ordinary bulk edit arrives here.
|
|
1974
|
-
if (opFrameExceedsWsLimit(sendFrame)) {
|
|
1975
|
-
void writeOversizedGuestOp(opBlobB64, clientOpId);
|
|
1976
|
-
return clientOpId;
|
|
1977
|
-
}
|
|
1978
|
-
outstanding.set(clientOpId, sendFrame);
|
|
1979
|
-
wsClient.send(sendFrame);
|
|
1980
|
-
return clientOpId;
|
|
1981
|
-
});
|
|
2396
|
+
catch {
|
|
2397
|
+
parsed = undefined;
|
|
2398
|
+
}
|
|
2399
|
+
if (response.status < 200 || response.status >= 300) {
|
|
2400
|
+
throw brassErrorFromBody(response.status, parsed);
|
|
1982
2401
|
}
|
|
1983
|
-
|
|
1984
|
-
doc.close = () => {
|
|
1985
|
-
clearShedRecovery();
|
|
1986
|
-
try {
|
|
1987
|
-
unsub();
|
|
1988
|
-
}
|
|
1989
|
-
catch {
|
|
1990
|
-
// Already detached; closing the Document is what matters.
|
|
1991
|
-
}
|
|
1992
|
-
originalClose();
|
|
1993
|
-
};
|
|
2402
|
+
return parsed;
|
|
1994
2403
|
}
|
|
1995
|
-
//
|
|
1996
|
-
//
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
return { ok: false, reason: `link view returned ${result.status}` };
|
|
2021
|
-
}
|
|
2022
|
-
const b = result.parsed;
|
|
2023
|
-
if (!Array.isArray(b.tail_ops)) {
|
|
2024
|
-
return { ok: false, reason: 'malformed link view response' };
|
|
2025
|
-
}
|
|
2026
|
-
const snap = b.snapshot;
|
|
2027
|
-
let snapshotBytes = null;
|
|
2028
|
-
if (snap !== null && snap !== undefined && snap.kind === 'inline') {
|
|
2029
|
-
snapshotBytes = base64ToBytes(snap.data_b64);
|
|
2030
|
-
}
|
|
2031
|
-
else if (snap !== null && snap !== undefined && snap.kind === 'presigned') {
|
|
2032
|
-
try {
|
|
2033
|
-
snapshotBytes = await fetchPresignedSnapshot(snap.url);
|
|
2034
|
-
}
|
|
2035
|
-
catch (err) {
|
|
2036
|
-
const status = err instanceof BrassError ? err.status ?? 0 : 0;
|
|
2037
|
-
return {
|
|
2038
|
-
ok: false,
|
|
2039
|
-
reason: status > 0
|
|
2040
|
-
? `presigned snapshot fetch returned ${status}`
|
|
2041
|
-
: `presigned snapshot fetch failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
2042
|
-
};
|
|
2043
|
-
}
|
|
2404
|
+
// Write a guest's op over HTTP: `POST /links/{token}/ops`, the anonymous
|
|
2405
|
+
// counterpart of `POST /documents/{docId}/ops`. Sends NO Authorization
|
|
2406
|
+
// header (the token is the credential) and names the line the guest
|
|
2407
|
+
// opened, which is the branch its socket topic is scoped to. An op past
|
|
2408
|
+
// what a request body carries is staged first, as a member's is.
|
|
2409
|
+
async function postLinkOp(token, docId, lineId, opBlobB64, clientOpId, base = {}) {
|
|
2410
|
+
const stagedKey = opBlobNeedsStaging(opBlobB64)
|
|
2411
|
+
? await stageOpBlob(docId, opBlobB64, { link: token })
|
|
2412
|
+
: undefined;
|
|
2413
|
+
const headers = { 'content-type': 'application/json' };
|
|
2414
|
+
applyClientVersionHeader(headers);
|
|
2415
|
+
const response = await fetch(`${baseUrl}/links/${encodeURIComponent(token)}/ops`, {
|
|
2416
|
+
method: 'POST',
|
|
2417
|
+
headers,
|
|
2418
|
+
body: JSON.stringify({
|
|
2419
|
+
...(stagedKey === undefined ? { op_blob: opBlobB64 } : { upload_key: stagedKey }),
|
|
2420
|
+
branch_id: lineId,
|
|
2421
|
+
client_op_id: clientOpId,
|
|
2422
|
+
...base,
|
|
2423
|
+
}),
|
|
2424
|
+
});
|
|
2425
|
+
const text = await response.text();
|
|
2426
|
+
let parsed = undefined;
|
|
2427
|
+
try {
|
|
2428
|
+
parsed = text ? JSON.parse(text) : undefined;
|
|
2044
2429
|
}
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
const tail = b.tail_ops;
|
|
2048
|
-
for (const entry of [...tail].sort((x, y) => x.seq - y.seq)) {
|
|
2049
|
-
doc.applyRemoteOp(base64ToBytes(entry.op_blob_b64));
|
|
2430
|
+
catch {
|
|
2431
|
+
parsed = undefined;
|
|
2050
2432
|
}
|
|
2051
|
-
return {
|
|
2433
|
+
return { status: response.status, parsed };
|
|
2052
2434
|
}
|
|
2053
2435
|
// Strict open: a 404 propagates as `BrassError`. A `version` option
|
|
2054
2436
|
// diverts to the read-only at-version path.
|
|
2055
|
-
const open = async (docId,
|
|
2437
|
+
const open = async (docId, asked) => {
|
|
2438
|
+
if (asked?.frozen === true && asked.readOnly === false) {
|
|
2439
|
+
throw new TypeError('openDocumentById: a frozen copy is read-only, so `readOnly: false` cannot apply');
|
|
2440
|
+
}
|
|
2441
|
+
const requested = withReadIntent(asked);
|
|
2442
|
+
const options = requested?.frozen === true ? frozenOptions(requested) : requested;
|
|
2443
|
+
if (requested?.frozen === true)
|
|
2444
|
+
return openFrozen(docId, options);
|
|
2056
2445
|
// Cache hit: return the parked Document. Its subscription stayed alive
|
|
2057
2446
|
// while parked, and the UndoController + outstanding-op map survived, so
|
|
2058
2447
|
// undo works as if the user never switched away.
|
|
@@ -2068,11 +2457,40 @@ export function createOpenDocument(opts) {
|
|
|
2068
2457
|
endTotal({ docId, path: 'at-version' });
|
|
2069
2458
|
return atVersion;
|
|
2070
2459
|
}
|
|
2071
|
-
|
|
2460
|
+
// An editable open waits until the document takes edits. One that never
|
|
2461
|
+
// will is refused terminally, and is then read as a reader would read it.
|
|
2462
|
+
// An import the platform runs holds every open until it lands, the file's
|
|
2463
|
+
// first import or a new version's, so the open resolves with its content.
|
|
2464
|
+
let snapshot = await fetchSnapshot(docId, options, token);
|
|
2465
|
+
snapshot = await joinPastWork(docId, options, snapshot, async () => fetchSnapshot(docId, options, await requireToken()));
|
|
2466
|
+
const refused = joinRefusalOf(snapshot);
|
|
2467
|
+
if (refused?.kind === 'at_capacity') {
|
|
2468
|
+
const held = await openTooLarge(docId, options, refused.error);
|
|
2469
|
+
endTotal({ docId, path: 'read-only:at_capacity' });
|
|
2470
|
+
return held;
|
|
2471
|
+
}
|
|
2072
2472
|
const opened = await finalise(docId, snapshot, options);
|
|
2073
2473
|
endTotal({ docId });
|
|
2074
2474
|
return opened;
|
|
2075
2475
|
};
|
|
2476
|
+
// A writable open of a document too large to edit, joined again as a
|
|
2477
|
+
// reader and shown read-only.
|
|
2478
|
+
async function openTooLarge(docId, options, error) {
|
|
2479
|
+
const readOptions = {
|
|
2480
|
+
...options,
|
|
2481
|
+
documentOptions: {
|
|
2482
|
+
...options?.documentOptions,
|
|
2483
|
+
readOnly: true,
|
|
2484
|
+
readOnlyReason: 'too_large',
|
|
2485
|
+
},
|
|
2486
|
+
};
|
|
2487
|
+
const snapshot = await fetchSnapshot(docId, readOptions, await requireToken());
|
|
2488
|
+
const doc = await finalise(docId, snapshot, readOptions);
|
|
2489
|
+
doc._markAtCapacity();
|
|
2490
|
+
if (error !== undefined)
|
|
2491
|
+
doc._emitStatus({ message: error, pending: false, blocking: false });
|
|
2492
|
+
return doc;
|
|
2493
|
+
}
|
|
2076
2494
|
// Create a doc and return it fully editable. Two round trips (POST to
|
|
2077
2495
|
// create + obtain the doc_id, then `open(docId)`); they aren't fused
|
|
2078
2496
|
// because the create response is metadata-only and the open path already
|
|
@@ -2109,7 +2527,239 @@ export function createOpenDocument(opts) {
|
|
|
2109
2527
|
: undefined;
|
|
2110
2528
|
return open(result.docId, openOptions);
|
|
2111
2529
|
};
|
|
2112
|
-
|
|
2530
|
+
// The mint's answer is the claim secret's only copy, so it is kept as it
|
|
2531
|
+
// arrives, in the browser that made the document.
|
|
2532
|
+
const rememberClaimSecret = (minted) => {
|
|
2533
|
+
if (minted.claimSecret !== undefined) {
|
|
2534
|
+
storeClaimSecret(opts.appId, minted.linkToken, minted.claimSecret, minted.expiresAt);
|
|
2535
|
+
}
|
|
2536
|
+
return minted;
|
|
2537
|
+
};
|
|
2538
|
+
const mintAnonymousDocument = async (args) => {
|
|
2539
|
+
const body = { app_id: opts.appId };
|
|
2540
|
+
if (args.name !== undefined)
|
|
2541
|
+
body['name'] = args.name;
|
|
2542
|
+
const headers = {
|
|
2543
|
+
'content-type': 'application/json',
|
|
2544
|
+
};
|
|
2545
|
+
applyClientVersionHeader(headers);
|
|
2546
|
+
// No idempotency key on this route, so a replay could mint twice;
|
|
2547
|
+
// only the gateway's own 503 (the handler never ran) is replayed.
|
|
2548
|
+
const response = await fetchWithRetry(() => fetch(`${baseUrl}/anonymous-documents`, {
|
|
2549
|
+
method: 'POST',
|
|
2550
|
+
headers,
|
|
2551
|
+
body: JSON.stringify(body),
|
|
2552
|
+
}), { policy: RETRY_WRITE_503, baseDelayMs: 250 });
|
|
2553
|
+
const text = await response.text();
|
|
2554
|
+
let parsed = undefined;
|
|
2555
|
+
try {
|
|
2556
|
+
parsed = text ? JSON.parse(text) : undefined;
|
|
2557
|
+
}
|
|
2558
|
+
catch {
|
|
2559
|
+
parsed = undefined;
|
|
2560
|
+
}
|
|
2561
|
+
if (response.status < 200 || response.status >= 300) {
|
|
2562
|
+
throw brassErrorFromBody(response.status, parsed);
|
|
2563
|
+
}
|
|
2564
|
+
return rememberClaimSecret(parseMintedAnonymousDocument(parsed));
|
|
2565
|
+
};
|
|
2566
|
+
// The unauthenticated JSON calls a visitor's file-started document makes.
|
|
2567
|
+
const postAnonymous = async (path, body) => {
|
|
2568
|
+
const headers = { 'content-type': 'application/json' };
|
|
2569
|
+
applyClientVersionHeader(headers);
|
|
2570
|
+
const response = await fetchWithRetry(() => fetch(`${baseUrl}${path}`, {
|
|
2571
|
+
method: 'POST',
|
|
2572
|
+
headers,
|
|
2573
|
+
body: JSON.stringify(body),
|
|
2574
|
+
}), { policy: RETRY_WRITE_503, baseDelayMs: 250 });
|
|
2575
|
+
const parsed = await readJsonBody(response);
|
|
2576
|
+
if (response.status < 200 || response.status >= 300) {
|
|
2577
|
+
throw brassErrorFromBody(response.status, parsed);
|
|
2578
|
+
}
|
|
2579
|
+
return parsed;
|
|
2580
|
+
};
|
|
2581
|
+
const anonymousUploadTypes = async () => {
|
|
2582
|
+
const headers = {};
|
|
2583
|
+
applyClientVersionHeader(headers);
|
|
2584
|
+
// `refusal=body` answers an app's refusal of signed-out use in a 200 body,
|
|
2585
|
+
// which keeps an expected answer out of the visitor's console.
|
|
2586
|
+
const response = await fetchWithRetry(() => fetch(`${baseUrl}/anonymous-documents/upload-types?app_id=${encodeURIComponent(opts.appId)}&refusal=body`, { headers }), { policy: RETRY_WRITE_503, baseDelayMs: 250 });
|
|
2587
|
+
const parsed = await readJsonBody(response);
|
|
2588
|
+
if (response.status < 200 || response.status >= 300) {
|
|
2589
|
+
throw brassErrorFromBody(response.status, parsed);
|
|
2590
|
+
}
|
|
2591
|
+
const body = parsed;
|
|
2592
|
+
const offersNew = body?.offers_new !== false;
|
|
2593
|
+
if (body?.refusal !== undefined) {
|
|
2594
|
+
return { offersNew, refusal: brassErrorFromBody(409, body.refusal) };
|
|
2595
|
+
}
|
|
2596
|
+
return { offersNew, accept: acceptFromUploadTypes(parsed) };
|
|
2597
|
+
};
|
|
2598
|
+
const mintAnonymousDocumentFromFile = async (args) => {
|
|
2599
|
+
const minted = (await postAnonymous('/anonymous-documents/upload-url', {
|
|
2600
|
+
app_id: opts.appId,
|
|
2601
|
+
file_name: args.file.name,
|
|
2602
|
+
byte_size: args.file.size,
|
|
2603
|
+
}));
|
|
2604
|
+
if (typeof minted?.upload_url !== 'string' || typeof minted.upload_key !== 'string') {
|
|
2605
|
+
throw new BrassError('Malformed upload response', { status: 500 });
|
|
2606
|
+
}
|
|
2607
|
+
await putFile(minted.upload_url, args.file, args.onUploadProgress);
|
|
2608
|
+
const body = {
|
|
2609
|
+
app_id: opts.appId,
|
|
2610
|
+
upload_key: minted.upload_key,
|
|
2611
|
+
file_name: args.file.name,
|
|
2612
|
+
};
|
|
2613
|
+
if (args.name !== undefined)
|
|
2614
|
+
body['name'] = args.name;
|
|
2615
|
+
return rememberClaimSecret(parseMintedAnonymousDocument(await postAnonymous('/anonymous-documents/from-upload', body)));
|
|
2616
|
+
};
|
|
2617
|
+
// One client id per page, which is what holds a claim across the calls
|
|
2618
|
+
// of one import (the signed-in namespace keeps its own).
|
|
2619
|
+
const guestImportClientId = randomToken();
|
|
2620
|
+
const guestImports = (token, docId) => {
|
|
2621
|
+
const base = `${baseUrl}/documents/${encodeURIComponent(docId)}`;
|
|
2622
|
+
const call = async (method, path, body) => {
|
|
2623
|
+
const headers = { authorization: `Link ${token}` };
|
|
2624
|
+
if (body !== undefined)
|
|
2625
|
+
headers['content-type'] = 'application/json';
|
|
2626
|
+
applyClientVersionHeader(headers);
|
|
2627
|
+
const response = await fetchWithRetry(() => fetch(`${base}${path}`, {
|
|
2628
|
+
method,
|
|
2629
|
+
headers,
|
|
2630
|
+
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
2631
|
+
}), { policy: RETRY_WRITE_503, baseDelayMs: 250 });
|
|
2632
|
+
const parsed = await readJsonBody(response);
|
|
2633
|
+
if (response.status < 200 || response.status >= 300) {
|
|
2634
|
+
throw brassErrorFromBody(response.status, parsed);
|
|
2635
|
+
}
|
|
2636
|
+
return parsed;
|
|
2637
|
+
};
|
|
2638
|
+
return {
|
|
2639
|
+
claimImport: () => call('POST', '/import-claim', {
|
|
2640
|
+
client_id: guestImportClientId,
|
|
2641
|
+
stages: true,
|
|
2642
|
+
}),
|
|
2643
|
+
async stageImport(bundle) {
|
|
2644
|
+
const staged = await call('POST', '/import-complete/upload', { byte_size: bundle.byteLength });
|
|
2645
|
+
const put = await fetch(staged.url, {
|
|
2646
|
+
method: 'PUT',
|
|
2647
|
+
body: bundle.buffer.slice(bundle.byteOffset, bundle.byteOffset + bundle.byteLength),
|
|
2648
|
+
});
|
|
2649
|
+
if (!put.ok) {
|
|
2650
|
+
throw new BrassError('Uploading the import failed', { status: put.status });
|
|
2651
|
+
}
|
|
2652
|
+
return staged.upload_key;
|
|
2653
|
+
},
|
|
2654
|
+
completeImport: async (report, uploadKey) => awaitSeal(await call('POST', '/import-complete', {
|
|
2655
|
+
client_id: guestImportClientId,
|
|
2656
|
+
...(report !== undefined ? { report } : {}),
|
|
2657
|
+
upload_key: uploadKey,
|
|
2658
|
+
}), (sealId) => call('GET', `/seals/${encodeURIComponent(sealId)}`)),
|
|
2659
|
+
failImport: (message) => call('POST', '/import-failed', {
|
|
2660
|
+
client_id: guestImportClientId,
|
|
2661
|
+
message,
|
|
2662
|
+
}),
|
|
2663
|
+
sourceBlob: () => call('GET', '/source-blob'),
|
|
2664
|
+
};
|
|
2665
|
+
};
|
|
2666
|
+
const claimAnonymousDocument = async (docId, args) => {
|
|
2667
|
+
const token = await requireToken();
|
|
2668
|
+
const body = {
|
|
2669
|
+
link_token: args.linkToken,
|
|
2670
|
+
claim_secret: args.claimSecret,
|
|
2671
|
+
};
|
|
2672
|
+
if (args.destination !== undefined)
|
|
2673
|
+
body['destination'] = args.destination;
|
|
2674
|
+
if (args.name !== undefined)
|
|
2675
|
+
body['name'] = args.name;
|
|
2676
|
+
const headers = {
|
|
2677
|
+
authorization: `Bearer ${token}`,
|
|
2678
|
+
'content-type': 'application/json',
|
|
2679
|
+
};
|
|
2680
|
+
applyClientVersionHeader(headers);
|
|
2681
|
+
await applyAppAssertionHeader(headers, opts.getAppAssertion);
|
|
2682
|
+
const response = await fetchWithRetry(() => fetch(`${baseUrl}/documents/${encodeURIComponent(docId)}/claim`, {
|
|
2683
|
+
method: 'POST',
|
|
2684
|
+
headers,
|
|
2685
|
+
body: JSON.stringify(body),
|
|
2686
|
+
}), { policy: RETRY_WRITE_503, baseDelayMs: 250 });
|
|
2687
|
+
const text = await response.text();
|
|
2688
|
+
let parsed = undefined;
|
|
2689
|
+
let parseFailed = false;
|
|
2690
|
+
if (text) {
|
|
2691
|
+
try {
|
|
2692
|
+
parsed = JSON.parse(text);
|
|
2693
|
+
}
|
|
2694
|
+
catch {
|
|
2695
|
+
parseFailed = true;
|
|
2696
|
+
}
|
|
2697
|
+
}
|
|
2698
|
+
if (response.status >= 200 && response.status < 300) {
|
|
2699
|
+
return { kind: 'claimed', record: selectionFromDetail(parsed) };
|
|
2700
|
+
}
|
|
2701
|
+
if (response.status === 409 && !parseFailed) {
|
|
2702
|
+
const code = parsed?.code;
|
|
2703
|
+
if (code === 'destination_required')
|
|
2704
|
+
return { kind: 'needs-destination' };
|
|
2705
|
+
if (code === 'no_organization')
|
|
2706
|
+
return { kind: 'needs-organization' };
|
|
2707
|
+
}
|
|
2708
|
+
throw brassErrorFromBody(response.status, parseFailed ? undefined : parsed);
|
|
2709
|
+
};
|
|
2710
|
+
return {
|
|
2711
|
+
open,
|
|
2712
|
+
create,
|
|
2713
|
+
createDocumentId,
|
|
2714
|
+
openByLink,
|
|
2715
|
+
mintAnonymousDocument,
|
|
2716
|
+
anonymousUploadTypes,
|
|
2717
|
+
mintAnonymousDocumentFromFile,
|
|
2718
|
+
guestImports,
|
|
2719
|
+
claimAnonymousDocument,
|
|
2720
|
+
};
|
|
2721
|
+
}
|
|
2722
|
+
// The claimed document in the picker's shape, read off the claim's
|
|
2723
|
+
// `DocumentDetail` answer; null when a field the shape needs is missing.
|
|
2724
|
+
function selectionFromDetail(parsed) {
|
|
2725
|
+
const d = parsed;
|
|
2726
|
+
if (!d || typeof d !== 'object')
|
|
2727
|
+
return null;
|
|
2728
|
+
const str = (key) => typeof d[key] === 'string' ? d[key] : null;
|
|
2729
|
+
const docId = str('doc_id');
|
|
2730
|
+
const organizationId = str('organization_id');
|
|
2731
|
+
const name = str('name');
|
|
2732
|
+
const createdAt = str('created_at');
|
|
2733
|
+
const updatedAt = str('updated_at');
|
|
2734
|
+
const role = d['role'];
|
|
2735
|
+
const projectId = d['project_id'];
|
|
2736
|
+
if (docId === null ||
|
|
2737
|
+
organizationId === null ||
|
|
2738
|
+
name === null ||
|
|
2739
|
+
createdAt === null ||
|
|
2740
|
+
updatedAt === null ||
|
|
2741
|
+
(role !== 'owner' && role !== 'editor' && role !== 'viewer') ||
|
|
2742
|
+
(projectId !== null && typeof projectId !== 'string')) {
|
|
2743
|
+
return null;
|
|
2744
|
+
}
|
|
2745
|
+
return {
|
|
2746
|
+
doc_id: docId,
|
|
2747
|
+
organization_id: organizationId,
|
|
2748
|
+
project_id: projectId ?? null,
|
|
2749
|
+
role,
|
|
2750
|
+
name,
|
|
2751
|
+
created_at: createdAt,
|
|
2752
|
+
updated_at: updatedAt,
|
|
2753
|
+
};
|
|
2754
|
+
}
|
|
2755
|
+
// The expiry a link view carries for a document minted without an account,
|
|
2756
|
+
// or null for every other link.
|
|
2757
|
+
function anonymousExpiry(body) {
|
|
2758
|
+
if (body.anonymous !== true || typeof body.expires_at !== 'string') {
|
|
2759
|
+
return null;
|
|
2760
|
+
}
|
|
2761
|
+
const at = new Date(body.expires_at);
|
|
2762
|
+
return Number.isNaN(at.getTime()) ? null : at;
|
|
2113
2763
|
}
|
|
2114
2764
|
// Per-process parking cache for opened Documents. `doc.close()` does NOT
|
|
2115
2765
|
// free the LoroDoc / UndoController; it parks the Document keyed on
|
|
@@ -2172,8 +2822,15 @@ function parkDocument(entry) {
|
|
|
2172
2822
|
}
|
|
2173
2823
|
}
|
|
2174
2824
|
function takeParkedDocument(docId, options, identity) {
|
|
2825
|
+
// A parked Document is a live, writable session with its subscription still
|
|
2826
|
+
// alive. Neither pinned open may take one: it would hand back a document
|
|
2827
|
+
// that receives ops and accepts writes, which is the opposite of what both
|
|
2828
|
+
// asked for. Parking itself already refuses them (`!doc.readOnly`), so this
|
|
2829
|
+
// is the other half of that.
|
|
2175
2830
|
if (options?.version !== undefined)
|
|
2176
2831
|
return null;
|
|
2832
|
+
if (options?.frozen === true)
|
|
2833
|
+
return null;
|
|
2177
2834
|
const key = parkCacheKey(docId, options?.branch, options?.stream);
|
|
2178
2835
|
if (!key)
|
|
2179
2836
|
return null;
|
|
@@ -2209,6 +2866,23 @@ export function clearParkedDocuments() {
|
|
|
2209
2866
|
// a clean slate between cases.
|
|
2210
2867
|
export function _resetParkCacheForTesting() {
|
|
2211
2868
|
clearParkedDocuments();
|
|
2869
|
+
subscriptionEndedAt.clear();
|
|
2870
|
+
}
|
|
2871
|
+
// When each doc topic last had its subscription ended by the server, so a
|
|
2872
|
+
// copy re-opened after one ends for good on a second inside the window. The
|
|
2873
|
+
// re-open asks the HTTP join, and a socket gate that keeps refusing what the
|
|
2874
|
+
// join admits would otherwise re-open the document in a loop.
|
|
2875
|
+
const SUBSCRIPTION_ENDED_REOPEN_WINDOW_MS = 60_000;
|
|
2876
|
+
const subscriptionEndedAt = new Map();
|
|
2877
|
+
function endedReasonFor(topic) {
|
|
2878
|
+
const now = Date.now();
|
|
2879
|
+
for (const [key, at] of subscriptionEndedAt) {
|
|
2880
|
+
if (now - at >= SUBSCRIPTION_ENDED_REOPEN_WINDOW_MS)
|
|
2881
|
+
subscriptionEndedAt.delete(key);
|
|
2882
|
+
}
|
|
2883
|
+
const recent = subscriptionEndedAt.has(topic);
|
|
2884
|
+
subscriptionEndedAt.set(topic, now);
|
|
2885
|
+
return recent ? 'deleted' : 'moved';
|
|
2212
2886
|
}
|
|
2213
2887
|
// Wire the WS transport onto a materialised Document. Three pieces:
|
|
2214
2888
|
// 1. WS subscribe on the doc topic; the server sends `doc_op` (other
|
|
@@ -2260,6 +2934,16 @@ export const RESEND_MAX_DELAY_MS = 30_000;
|
|
|
2260
2934
|
// server never received and nothing ever sends again.
|
|
2261
2935
|
// Exported for the fake-timer tests.
|
|
2262
2936
|
export const RESEND_FORCE_CEILING_MS = 60_000;
|
|
2937
|
+
// How long one op may go unacked on the socket before the reader is told,
|
|
2938
|
+
// through `status`, that their changes are not saved. A server that answers
|
|
2939
|
+
// an op anonymously every time (an error frame naming no op) draws a resend
|
|
2940
|
+
// per backoff step, or a forced one per ceiling window, forever, while
|
|
2941
|
+
// `savingCount` stays above zero with nothing on screen saying why. Three
|
|
2942
|
+
// ceiling windows is past any loss a resend recovers from, since a lost op
|
|
2943
|
+
// is answered on its first forced resend. The notice changes nothing about
|
|
2944
|
+
// how the op is resent, and clears itself once nothing is waiting.
|
|
2945
|
+
// Exported for the fake-timer tests.
|
|
2946
|
+
export const UNANSWERED_OP_NOTICE_MS = 3 * RESEND_FORCE_CEILING_MS;
|
|
2263
2947
|
// Server-shed recovery policy, driven by `unavailable` error frames. The
|
|
2264
2948
|
// frame names no op, so a round re-asserts every outstanding op at once and
|
|
2265
2949
|
// any further frame arriving before that round runs is part of the same shed
|
|
@@ -2276,14 +2960,7 @@ const UNAVAILABLE_MAX_ROUNDS = 6;
|
|
|
2276
2960
|
const UNAVAILABLE_BASE_DELAY_MS = 1000;
|
|
2277
2961
|
const UNAVAILABLE_MAX_DELAY_MS = 30_000;
|
|
2278
2962
|
const UNAVAILABLE_GIVE_UP_MESSAGE = 'The server could not store this change, and re-sending it did not ' +
|
|
2279
|
-
'help. Reload
|
|
2280
|
-
// Reported for every op left behind when the server ends this document's
|
|
2281
|
-
// subscription (it refused the subscribe, or evicted it again after the one
|
|
2282
|
-
// silent re-subscribe a connection grants). The ops can never be acked on a
|
|
2283
|
-
// subscription that is over, and a fresh one arrives only with a fresh open,
|
|
2284
|
-
// so the app's cue is to reload.
|
|
2285
|
-
const SUBSCRIPTION_ENDED_MESSAGE = 'The live connection to this document ended, so this change was not ' +
|
|
2286
|
-
'saved. Reload the document to continue from the last saved state.';
|
|
2963
|
+
'help. Reload to continue from the last saved state.';
|
|
2287
2964
|
/** How long the Nth (1-based) recovery round waits before re-asserting. */
|
|
2288
2965
|
function unavailableDelayMs(round) {
|
|
2289
2966
|
return Math.min(UNAVAILABLE_BASE_DELAY_MS * Math.pow(2, round - 1), UNAVAILABLE_MAX_DELAY_MS);
|
|
@@ -2293,13 +2970,37 @@ function unavailableDelayMs(round) {
|
|
|
2293
2970
|
const TOO_LARGE_MESSAGE = 'This record is too large to save. A single record must fit in one ' +
|
|
2294
2971
|
'update; store the bulk of it as a file instead, or split it across ' +
|
|
2295
2972
|
'several records.';
|
|
2296
|
-
// What a guest hears for an op no request body can carry. A signed-in
|
|
2297
|
-
// editor stages such an op to storage first; that route is behind the
|
|
2298
|
-
// sign-in gate, so the anonymous tier writes ops that fit one request.
|
|
2299
|
-
const LINK_OP_TOO_LARGE_MESSAGE = 'This change is too large to save over a shared link. Sign in to the ' +
|
|
2300
|
-
'document to make a change this size.';
|
|
2301
2973
|
function wireDocTransport(args) {
|
|
2302
2974
|
const topic = `doc:${args.docId}:${args.branchId}`;
|
|
2975
|
+
// A remote op over frozen content means the document is now a CRDT. The
|
|
2976
|
+
// re-join keeps the open's read intent and is served that CRDT, which
|
|
2977
|
+
// `applyJoin` swaps in under the same handles. A run that leaves the
|
|
2978
|
+
// document on frozen content (the join failed, or the document is showing
|
|
2979
|
+
// a version) falls back to the reload. Ops arriving during the run ask the
|
|
2980
|
+
// resync for another pass rather than starting a second upgrade.
|
|
2981
|
+
const resync = args.resyncFromSnapshot;
|
|
2982
|
+
if (resync !== undefined) {
|
|
2983
|
+
let upgradeRun = null;
|
|
2984
|
+
args.doc._setStorageUpgrader(() => {
|
|
2985
|
+
if (upgradeRun !== null) {
|
|
2986
|
+
void Promise.resolve(resync()).catch(() => { });
|
|
2987
|
+
return;
|
|
2988
|
+
}
|
|
2989
|
+
upgradeRun = (async () => {
|
|
2990
|
+
try {
|
|
2991
|
+
await resync();
|
|
2992
|
+
}
|
|
2993
|
+
catch {
|
|
2994
|
+
// Judged by what the document holds afterwards.
|
|
2995
|
+
}
|
|
2996
|
+
const doc = args.doc;
|
|
2997
|
+
if (doc._holdsRecordSet() && !doc._isClosed && doc._goneReason === null)
|
|
2998
|
+
doc._reopen();
|
|
2999
|
+
})().finally(() => {
|
|
3000
|
+
upgradeRun = null;
|
|
3001
|
+
});
|
|
3002
|
+
});
|
|
3003
|
+
}
|
|
2303
3004
|
// Per-op retry tracker. The op-write endpoint can answer with an untyped
|
|
2304
3005
|
// error frame instead of reaching the handler. That frame has no `type`, so
|
|
2305
3006
|
// the multiplexer drops it,
|
|
@@ -2376,6 +3077,8 @@ function wireDocTransport(args) {
|
|
|
2376
3077
|
entry.queued = false;
|
|
2377
3078
|
entry.inFlight = size;
|
|
2378
3079
|
entry.dispatchedAt = Date.now();
|
|
3080
|
+
if (entry.firstDispatchedAt === 0)
|
|
3081
|
+
entry.firstDispatchedAt = entry.dispatchedAt;
|
|
2379
3082
|
inFlightBytes += size;
|
|
2380
3083
|
args.wsClient.send(entry.frame);
|
|
2381
3084
|
scheduleResend(clientOpId);
|
|
@@ -2548,6 +3251,10 @@ function wireDocTransport(args) {
|
|
|
2548
3251
|
const current = outstandingResends.get(clientOpId);
|
|
2549
3252
|
if (!current)
|
|
2550
3253
|
return;
|
|
3254
|
+
if (current.firstDispatchedAt !== 0 &&
|
|
3255
|
+
Date.now() - current.firstDispatchedAt > UNANSWERED_OP_NOTICE_MS) {
|
|
3256
|
+
args.doc._showConnectionNotice('unanswered');
|
|
3257
|
+
}
|
|
2551
3258
|
const forced = forceResendDue(clientOpId, current);
|
|
2552
3259
|
if (lastAckAt >= current.armedAt && !forced) {
|
|
2553
3260
|
scheduleResend(clientOpId);
|
|
@@ -2665,6 +3372,26 @@ function wireDocTransport(args) {
|
|
|
2665
3372
|
});
|
|
2666
3373
|
}
|
|
2667
3374
|
args.doc._setDiagnosticsProvider(collectDiagnostics);
|
|
3375
|
+
// A network that refuses WebSocket upgrades opens the document over HTTP
|
|
3376
|
+
// and then carries none of its edits: they apply locally and queue, and the
|
|
3377
|
+
// reconnect loop reports only after its full budget (about five minutes),
|
|
3378
|
+
// so a reload inside that window loses them with nothing said. When edits
|
|
3379
|
+
// have waited `NEVER_OPENED_NOTICE_MS` on a client that has never opened a
|
|
3380
|
+
// socket, the document says so on `status`. Never-opened rather than
|
|
3381
|
+
// currently-closed, because a connection that has worked recovers through
|
|
3382
|
+
// the resend machinery and a drop there is ordinarily brief.
|
|
3383
|
+
let neverOpenedTimer = null;
|
|
3384
|
+
function armNeverOpenedNotice() {
|
|
3385
|
+
if (neverOpenedTimer !== null || args.wsClient.everOpened !== false)
|
|
3386
|
+
return;
|
|
3387
|
+
neverOpenedTimer = setTimeout(() => {
|
|
3388
|
+
neverOpenedTimer = null;
|
|
3389
|
+
if (args.wsClient.everOpened !== false || outstandingResends.size === 0)
|
|
3390
|
+
return;
|
|
3391
|
+
args.doc._showConnectionNotice();
|
|
3392
|
+
}, NEVER_OPENED_NOTICE_MS);
|
|
3393
|
+
neverOpenedTimer.unref?.();
|
|
3394
|
+
}
|
|
2668
3395
|
// Resend every outstanding op now and restart its backoff. Called on
|
|
2669
3396
|
// reconnect (`doc_init`): a dropped-and-restored connection is the most
|
|
2670
3397
|
// common reason an op went unacked, and the backoff may have grown to its
|
|
@@ -2723,15 +3450,18 @@ function wireDocTransport(args) {
|
|
|
2723
3450
|
// and never apply, and its own escape for an op nothing can apply is to
|
|
2724
3451
|
// discard it. Most refusals do not change that: the handshake pushes
|
|
2725
3452
|
// whatever slice the durable frontier is missing and splits it under the
|
|
2726
|
-
// frame cap, so it heals the dependents
|
|
2727
|
-
//
|
|
3453
|
+
// frame cap, so it heals the dependents of an op a transient refusal
|
|
3454
|
+
// dropped.
|
|
2728
3455
|
//
|
|
2729
3456
|
// `healable: false` is for the refusal where pushing again is asking the
|
|
2730
3457
|
// same question. A full document refuses every op whatever its size, so
|
|
2731
3458
|
// the handshake's own chunks come back refused, and the counter reset
|
|
2732
3459
|
// below turns that into a loop rather than a bounded retry. What recovers
|
|
2733
3460
|
// that document is the periodic re-verify, once the server can take a
|
|
2734
|
-
// write at all.
|
|
3461
|
+
// write at all. An op too large for any transport this writer has is the
|
|
3462
|
+
// same: a split runs between changes, never inside one, so the slice the
|
|
3463
|
+
// handshake would push still carries the whole op, and every edit after it
|
|
3464
|
+
// waits on an op the server will never hold.
|
|
2735
3465
|
// A refusal the heal cannot work around leaves the writer holding a change
|
|
2736
3466
|
// that reached no database and no other reader, so the writer is the only
|
|
2737
3467
|
// copy of the document that shows it. Left editable it reads as applied: the
|
|
@@ -2808,7 +3538,27 @@ function wireDocTransport(args) {
|
|
|
2808
3538
|
catch {
|
|
2809
3539
|
// Told or not, the copy is resolved.
|
|
2810
3540
|
}
|
|
2811
|
-
args.doc.
|
|
3541
|
+
args.doc._reopen();
|
|
3542
|
+
}
|
|
3543
|
+
function markGone(reason) {
|
|
3544
|
+
// Cancel every resend timer FIRST so a doc that was saving when delete
|
|
3545
|
+
// landed stops re-trying ops the server will only reject as `forbidden`
|
|
3546
|
+
// (the subscription record was evicted at fanout). Draining before the `_markGone`
|
|
3547
|
+
// listeners keeps the lifecycle observably clean.
|
|
3548
|
+
forgetAllOps();
|
|
3549
|
+
clearUnavailableRecovery();
|
|
3550
|
+
// Stop re-verifying durability against an evicted doc: a resync push
|
|
3551
|
+
// would only bounce as `forbidden`, and a gap pull would only 404.
|
|
3552
|
+
if (reverifyTimer !== null) {
|
|
3553
|
+
clearInterval(reverifyTimer);
|
|
3554
|
+
reverifyTimer = null;
|
|
3555
|
+
}
|
|
3556
|
+
clearDivergenceHeal();
|
|
3557
|
+
if (gapTimer !== null) {
|
|
3558
|
+
clearTimeout(gapTimer);
|
|
3559
|
+
gapTimer = null;
|
|
3560
|
+
}
|
|
3561
|
+
args.doc._markGone(reason);
|
|
2812
3562
|
}
|
|
2813
3563
|
function failOpTerminally(clientOpId, message, opts = {}) {
|
|
2814
3564
|
// Read before `_failOutstandingOp`, which clears the id from the
|
|
@@ -2854,6 +3604,22 @@ function wireDocTransport(args) {
|
|
|
2854
3604
|
}, delay);
|
|
2855
3605
|
unavailableTimer.unref?.();
|
|
2856
3606
|
}
|
|
3607
|
+
// The ops the durability handshake leaves to this transport: those still
|
|
3608
|
+
// queued behind the in-flight budget, and those first sent too recently for
|
|
3609
|
+
// their resend timer to have doubted them. An op past its first resend step
|
|
3610
|
+
// is fair game for the handshake, since the resend path does not promise it
|
|
3611
|
+
// arrives.
|
|
3612
|
+
function deliveringOpBlobs() {
|
|
3613
|
+
const now = Date.now();
|
|
3614
|
+
const blobs = [];
|
|
3615
|
+
for (const entry of outstandingResends.values()) {
|
|
3616
|
+
if (entry.firstDispatchedAt === 0 ||
|
|
3617
|
+
now - entry.firstDispatchedAt < RESEND_BASE_DELAY_MS) {
|
|
3618
|
+
blobs.push(base64ToBytes(entry.frame.op_blob));
|
|
3619
|
+
}
|
|
3620
|
+
}
|
|
3621
|
+
return blobs;
|
|
3622
|
+
}
|
|
2857
3623
|
// Durability handshake, run on every `doc_init`. The persisted version
|
|
2858
3624
|
// vector + durable tail ops define the durable frontier; if the local doc
|
|
2859
3625
|
// holds CRDT knowledge beyond it (an op acked then discarded by the
|
|
@@ -2865,7 +3631,7 @@ function wireDocTransport(args) {
|
|
|
2865
3631
|
function maybeResyncPush(snapshotVersionVectorB64, durableTailBlobs) {
|
|
2866
3632
|
if (!snapshotVersionVectorB64)
|
|
2867
3633
|
return;
|
|
2868
|
-
const outcome = args.doc.maybeResyncPush(base64ToBytes(snapshotVersionVectorB64), durableTailBlobs);
|
|
3634
|
+
const outcome = args.doc.maybeResyncPush(base64ToBytes(snapshotVersionVectorB64), durableTailBlobs, deliveringOpBlobs());
|
|
2869
3635
|
// Reported here as well as from the re-verify, or the record holds only
|
|
2870
3636
|
// the heals that took the slower route and an operator reading it cannot
|
|
2871
3637
|
// tell that the ordinary reconnect handshake is missing from the set.
|
|
@@ -2902,7 +3668,7 @@ function wireDocTransport(args) {
|
|
|
2902
3668
|
try {
|
|
2903
3669
|
const frontier = await args.refetchDurableFrontier?.();
|
|
2904
3670
|
if (frontier && frontier.snapshotVersionVector) {
|
|
2905
|
-
outcome = args.doc.maybeResyncPush(base64ToBytes(frontier.snapshotVersionVector), frontier.tailBlobs);
|
|
3671
|
+
outcome = args.doc.maybeResyncPush(base64ToBytes(frontier.snapshotVersionVector), frontier.tailBlobs, deliveringOpBlobs());
|
|
2906
3672
|
// The handshake having work to do means the server's copy was
|
|
2907
3673
|
// behind this client's, which is the state a lost op leaves and
|
|
2908
3674
|
// which nothing on the server side reports.
|
|
@@ -3030,46 +3796,30 @@ function wireDocTransport(args) {
|
|
|
3030
3796
|
gapPullRounds = 0;
|
|
3031
3797
|
return;
|
|
3032
3798
|
}
|
|
3033
|
-
|
|
3799
|
+
// The head can be this client's own op still being written over HTTP,
|
|
3800
|
+
// so the judgement waits for that write as a catch-up prompt does.
|
|
3801
|
+
resyncPastSeq(awaited);
|
|
3034
3802
|
}, delay);
|
|
3035
3803
|
gapTimer.unref?.();
|
|
3036
3804
|
}
|
|
3037
|
-
// The server ended this subscription: it refused the subscribe (
|
|
3038
|
-
//
|
|
3039
|
-
//
|
|
3040
|
-
//
|
|
3805
|
+
// The server ended this subscription: it refused the subscribe (access
|
|
3806
|
+
// that ended between the HTTP join and the subscribe, which no eviction can
|
|
3807
|
+
// reach because no subscription row exists yet), or evicted it again after
|
|
3808
|
+
// the one silent re-subscribe a connection grants. The client detaches this
|
|
3809
|
+
// transport from the topic as it reports that, so no ack, no fanout and no
|
|
3810
|
+
// reconnect reaches it again.
|
|
3041
3811
|
//
|
|
3042
|
-
//
|
|
3043
|
-
//
|
|
3044
|
-
//
|
|
3045
|
-
//
|
|
3046
|
-
//
|
|
3047
|
-
// its cue to reload, rather than a document that looks like it is saving
|
|
3048
|
-
// forever. The ids are taken before the reports because
|
|
3049
|
-
// `_failOutstandingOp` re-enters app code.
|
|
3812
|
+
// That is the same fact a `doc_gone` reports, so the copy ends the same
|
|
3813
|
+
// way: `moved`, which a session answers by re-opening the id, so the
|
|
3814
|
+
// current gate re-admits a reader who still reaches the document and
|
|
3815
|
+
// refuses one who does not. A second end inside the window is `deleted`,
|
|
3816
|
+
// which ends the copy without the re-open.
|
|
3050
3817
|
let subscriptionEnded = false;
|
|
3051
3818
|
function endSubscription() {
|
|
3052
3819
|
if (subscriptionEnded)
|
|
3053
3820
|
return;
|
|
3054
3821
|
subscriptionEnded = true;
|
|
3055
|
-
|
|
3056
|
-
forgetAllOps();
|
|
3057
|
-
clearUnavailableRecovery();
|
|
3058
|
-
// The heal and the gap pull both work through this transport, so they
|
|
3059
|
-
// are stopped rather than armed: what they would push or fetch has no
|
|
3060
|
-
// subscription left to travel on.
|
|
3061
|
-
clearDivergenceHeal();
|
|
3062
|
-
if (reverifyTimer !== null) {
|
|
3063
|
-
clearInterval(reverifyTimer);
|
|
3064
|
-
reverifyTimer = null;
|
|
3065
|
-
}
|
|
3066
|
-
if (gapTimer !== null) {
|
|
3067
|
-
clearTimeout(gapTimer);
|
|
3068
|
-
gapTimer = null;
|
|
3069
|
-
}
|
|
3070
|
-
for (const clientOpId of clientOpIds) {
|
|
3071
|
-
args.doc._failOutstandingOp(clientOpId, SUBSCRIPTION_ENDED_MESSAGE);
|
|
3072
|
-
}
|
|
3822
|
+
markGone(endedReasonFor(topic));
|
|
3073
3823
|
}
|
|
3074
3824
|
// 1. Subscribe to the doc topic. Frame routing:
|
|
3075
3825
|
// - `doc_init`: bridge the join-vs-fanout race. Ops committed between
|
|
@@ -3090,12 +3840,21 @@ function wireDocTransport(args) {
|
|
|
3090
3840
|
const unsub = args.wsClient.subscribe(topic, (frame) => {
|
|
3091
3841
|
const init = parseDocInitFrame(frame);
|
|
3092
3842
|
if (init) {
|
|
3843
|
+
// A new version replaced the line while this copy was away (the
|
|
3844
|
+
// `doc_gone` naming it went to a socket that was down). Its tail is
|
|
3845
|
+
// the new version's, and nothing this copy holds or would re-assert
|
|
3846
|
+
// applies to it, so it reopens before applying or resending anything.
|
|
3847
|
+
if (importSeqMoved(args.doc, init.import_seq)) {
|
|
3848
|
+
markGone('moved');
|
|
3849
|
+
return;
|
|
3850
|
+
}
|
|
3093
3851
|
// Recorded as the frame arrives rather than after the tail applies.
|
|
3094
3852
|
// Importing a tail op can throw, and the client swallows it, so a
|
|
3095
3853
|
// latch written further down would stay behind and read the NEXT
|
|
3096
3854
|
// reconnect as the opening subscribe, suppressing the report this
|
|
3097
3855
|
// exists to make.
|
|
3098
3856
|
const reconnects = args.wsClient.reconnectCount ?? 0;
|
|
3857
|
+
const wasSubscribed = docInitReconnectCount !== null;
|
|
3099
3858
|
const socketDropped = docInitReconnectCount !== null && reconnects > docInitReconnectCount;
|
|
3100
3859
|
docInitReconnectCount = reconnects;
|
|
3101
3860
|
// Resync check BEFORE applying. The tail covers (snapshot_seq,
|
|
@@ -3179,6 +3938,10 @@ function wireDocTransport(args) {
|
|
|
3179
3938
|
reportSyncHealth('reconnected_unsaved');
|
|
3180
3939
|
}
|
|
3181
3940
|
resendAllOutstanding();
|
|
3941
|
+
// A nudge posted while the topic was down is gone, so every status
|
|
3942
|
+
// this document holds asks what moved since its revision.
|
|
3943
|
+
if (wasSubscribed)
|
|
3944
|
+
args.doc._inputStatusNudged(null, null);
|
|
3182
3945
|
// The handshake needs the WHOLE durable tail, and this frame carries
|
|
3183
3946
|
// it only when every entry came inline. A spilled op is named here
|
|
3184
3947
|
// with its bytes withheld, and the HTTP join the reader already ran
|
|
@@ -3255,7 +4018,7 @@ function wireDocTransport(args) {
|
|
|
3255
4018
|
// Evidence the connection is working through its queue, which is what
|
|
3256
4019
|
// the resend timers read to tell a slow ack from a silent server.
|
|
3257
4020
|
lastAckAt = Date.now();
|
|
3258
|
-
args.doc.resolveOutstandingOp(frame.client_op_id);
|
|
4021
|
+
args.doc.resolveOutstandingOp(frame.client_op_id, frame.seq);
|
|
3259
4022
|
args.setCurrentSeq(frame.seq);
|
|
3260
4023
|
args.advanceCoveredSeq(frame.seq);
|
|
3261
4024
|
// An ack for a local op sits above any remote op this client never
|
|
@@ -3278,6 +4041,13 @@ function wireDocTransport(args) {
|
|
|
3278
4041
|
// the Document so the app hears "could not be saved" instead of a silent
|
|
3279
4042
|
// wedge. Scoped to this one op; the topic subscription stays live.
|
|
3280
4043
|
forgetOp(frame.client_op_id);
|
|
4044
|
+
// A new version replaced the line under this copy. Not healable and
|
|
4045
|
+
// not this op's alone: every op it holds names the replaced version,
|
|
4046
|
+
// so the copy reopens, which drops them all.
|
|
4047
|
+
if (frame.reason === OP_REJECTED_MOVED) {
|
|
4048
|
+
markGone('moved');
|
|
4049
|
+
return;
|
|
4050
|
+
}
|
|
3281
4051
|
// The flip is the one moment the refusal is worth reporting: every op
|
|
3282
4052
|
// in flight draws the same sentence, and a bulk write has as many of
|
|
3283
4053
|
// those as it has unacked writes.
|
|
@@ -3304,30 +4074,13 @@ function wireDocTransport(args) {
|
|
|
3304
4074
|
// slice under the frame cap, which is what reaches the dependents of
|
|
3305
4075
|
// an op that is itself too large to re-send.
|
|
3306
4076
|
failOpTerminally(frame.client_op_id, opRejectedMessage(frame), {
|
|
3307
|
-
healable: !atCapacity,
|
|
4077
|
+
healable: !atCapacity && frame.reason !== OP_REJECTED_NOT_IMPORTED,
|
|
3308
4078
|
report: !atCapacity || firstCapacityRefusal,
|
|
3309
4079
|
});
|
|
3310
4080
|
return;
|
|
3311
4081
|
}
|
|
3312
4082
|
if (isDocGoneFrame(frame)) {
|
|
3313
|
-
|
|
3314
|
-
// landed stops re-trying ops the server will only reject as `forbidden`
|
|
3315
|
-
// (the subscription record was evicted at fanout). Draining before the `_markGone`
|
|
3316
|
-
// listeners keeps the lifecycle observably clean.
|
|
3317
|
-
forgetAllOps();
|
|
3318
|
-
clearUnavailableRecovery();
|
|
3319
|
-
// Stop re-verifying durability against an evicted doc: a resync push
|
|
3320
|
-
// would only bounce as `forbidden`, and a gap pull would only 404.
|
|
3321
|
-
if (reverifyTimer !== null) {
|
|
3322
|
-
clearInterval(reverifyTimer);
|
|
3323
|
-
reverifyTimer = null;
|
|
3324
|
-
}
|
|
3325
|
-
clearDivergenceHeal();
|
|
3326
|
-
if (gapTimer !== null) {
|
|
3327
|
-
clearTimeout(gapTimer);
|
|
3328
|
-
gapTimer = null;
|
|
3329
|
-
}
|
|
3330
|
-
args.doc._markGone(goneReasonOf(frame));
|
|
4083
|
+
markGone(goneReasonOf(frame));
|
|
3331
4084
|
return;
|
|
3332
4085
|
}
|
|
3333
4086
|
if (isDocStatusFrame(frame)) {
|
|
@@ -3340,6 +4093,12 @@ function wireDocTransport(args) {
|
|
|
3340
4093
|
if (frame.reload && args.reloadAfterHydration) {
|
|
3341
4094
|
args.reloadAfterHydration(status);
|
|
3342
4095
|
}
|
|
4096
|
+
else if (status.pending &&
|
|
4097
|
+
status.blocking !== false &&
|
|
4098
|
+
args.doc._workStatusNow?.pending !== true &&
|
|
4099
|
+
args.verifyPendingStatus) {
|
|
4100
|
+
args.verifyPendingStatus(status);
|
|
4101
|
+
}
|
|
3343
4102
|
else {
|
|
3344
4103
|
args.doc._emitStatus(status);
|
|
3345
4104
|
}
|
|
@@ -3350,6 +4109,13 @@ function wireDocTransport(args) {
|
|
|
3350
4109
|
args.doc._setName(frame.name);
|
|
3351
4110
|
return;
|
|
3352
4111
|
}
|
|
4112
|
+
const nudge = parseInputStatusNudge(frame);
|
|
4113
|
+
if (nudge !== null) {
|
|
4114
|
+
// The frame names the key and its new revision alone; the status
|
|
4115
|
+
// fetches what moved.
|
|
4116
|
+
args.doc._inputStatusNudged(nudge.key, nudge.rev);
|
|
4117
|
+
return;
|
|
4118
|
+
}
|
|
3353
4119
|
if (isDocDriveStatusFrame(frame)) {
|
|
3354
4120
|
// The signal de-dupes a no-op re-emit, so forwarding every frame is safe.
|
|
3355
4121
|
args.doc._emitDriveStatus(toDriveStatus(frame.drive));
|
|
@@ -3393,21 +4159,6 @@ function wireDocTransport(args) {
|
|
|
3393
4159
|
// tracker so a silently-dropped server response triggers a retry.
|
|
3394
4160
|
args.doc.setLocalOpHook((opBlob) => {
|
|
3395
4161
|
const clientOpId = args.newClientOpId();
|
|
3396
|
-
// Nothing carries an op once the subscription is over, so it is failed
|
|
3397
|
-
// instead of tracked: an entry nothing can ack pins `savingCount` and
|
|
3398
|
-
// its resends reach a topic the server refused. The caller records the
|
|
3399
|
-
// entry AFTER this hook returns, so the failure waits a microtask for
|
|
3400
|
-
// there to be one to fail.
|
|
3401
|
-
//
|
|
3402
|
-
// Taken before the op is timed, because this path fails the op through
|
|
3403
|
-
// the Document directly rather than through `failOpTerminally`, so a
|
|
3404
|
-
// timing entry written here is one nothing ever drops.
|
|
3405
|
-
if (subscriptionEnded) {
|
|
3406
|
-
void Promise.resolve().then(() => {
|
|
3407
|
-
args.doc._failOutstandingOp(clientOpId, SUBSCRIPTION_ENDED_MESSAGE);
|
|
3408
|
-
});
|
|
3409
|
-
return clientOpId;
|
|
3410
|
-
}
|
|
3411
4162
|
opStartedAt.set(clientOpId, Date.now());
|
|
3412
4163
|
const opBlobB64 = bytesToBase64(opBlob);
|
|
3413
4164
|
const sendFrame = {
|
|
@@ -3415,6 +4166,7 @@ function wireDocTransport(args) {
|
|
|
3415
4166
|
topic,
|
|
3416
4167
|
op_blob: opBlobB64,
|
|
3417
4168
|
client_op_id: clientOpId,
|
|
4169
|
+
...baseImportSeqField(args.doc),
|
|
3418
4170
|
};
|
|
3419
4171
|
// A frame past the transport's cap is not delivered and takes the
|
|
3420
4172
|
// connection down with it, so it must never reach the socket OR the
|
|
@@ -3436,8 +4188,10 @@ function wireDocTransport(args) {
|
|
|
3436
4188
|
dispatchedAt: 0,
|
|
3437
4189
|
inFlight: 0,
|
|
3438
4190
|
queued: false,
|
|
4191
|
+
firstDispatchedAt: 0,
|
|
3439
4192
|
});
|
|
3440
4193
|
dispatchOp(clientOpId);
|
|
4194
|
+
armNeverOpenedNotice();
|
|
3441
4195
|
return clientOpId;
|
|
3442
4196
|
});
|
|
3443
4197
|
async function writeOversizedOp(opBlobB64, clientOpId) {
|
|
@@ -3461,11 +4215,11 @@ function wireDocTransport(args) {
|
|
|
3461
4215
|
// The size is known here, so refuse without spending a round trip on a
|
|
3462
4216
|
// blob that can only come back 400.
|
|
3463
4217
|
if (opBlobExceedsDurableLimit(opBlobB64)) {
|
|
3464
|
-
failOpTerminally(clientOpId, TOO_LARGE_MESSAGE);
|
|
4218
|
+
failOpTerminally(clientOpId, TOO_LARGE_MESSAGE, { healable: false });
|
|
3465
4219
|
return;
|
|
3466
4220
|
}
|
|
3467
4221
|
if (!args.postOversizedOp) {
|
|
3468
|
-
failOpTerminally(clientOpId, TOO_LARGE_MESSAGE);
|
|
4222
|
+
failOpTerminally(clientOpId, TOO_LARGE_MESSAGE, { healable: false });
|
|
3469
4223
|
return;
|
|
3470
4224
|
}
|
|
3471
4225
|
// An oversized op is deliberately kept out of `outstandingResends` (a
|
|
@@ -3505,6 +4259,11 @@ function wireDocTransport(args) {
|
|
|
3505
4259
|
opStartedAt.delete(clientOpId);
|
|
3506
4260
|
return;
|
|
3507
4261
|
}
|
|
4262
|
+
if (!result.ok && result.moved === true) {
|
|
4263
|
+
opStartedAt.delete(clientOpId);
|
|
4264
|
+
markGone('moved');
|
|
4265
|
+
return;
|
|
4266
|
+
}
|
|
3508
4267
|
if (!result.ok) {
|
|
3509
4268
|
// Terminal for this op, the same way `op_rejected` is: the local
|
|
3510
4269
|
// commit stays applied but never persists, so the app hears about it
|
|
@@ -3515,7 +4274,9 @@ function wireDocTransport(args) {
|
|
|
3515
4274
|
if (result.readOnly)
|
|
3516
4275
|
args.doc._markReadOnly(result.readOnly);
|
|
3517
4276
|
failOpTerminally(clientOpId, result.retryable ? UNAVAILABLE_GIVE_UP_MESSAGE : result.message, {
|
|
3518
|
-
healable: result.atCapacity !== true
|
|
4277
|
+
healable: result.atCapacity !== true &&
|
|
4278
|
+
result.notImported !== true &&
|
|
4279
|
+
result.tooLarge !== true,
|
|
3519
4280
|
report: result.atCapacity !== true || firstCapacityRefusal,
|
|
3520
4281
|
});
|
|
3521
4282
|
return;
|
|
@@ -3523,7 +4284,7 @@ function wireDocTransport(args) {
|
|
|
3523
4284
|
// Same bookkeeping an `op_ack` does: resolve the outstanding entry and
|
|
3524
4285
|
// advance both cursors past the seq the server allocated.
|
|
3525
4286
|
opStartedAt.delete(clientOpId);
|
|
3526
|
-
args.doc.resolveOutstandingOp(clientOpId);
|
|
4287
|
+
args.doc.resolveOutstandingOp(clientOpId, result.seq);
|
|
3527
4288
|
args.setCurrentSeq(result.seq);
|
|
3528
4289
|
args.advanceCoveredSeq(result.seq);
|
|
3529
4290
|
}
|
|
@@ -3575,6 +4336,10 @@ function wireDocTransport(args) {
|
|
|
3575
4336
|
clearInterval(reverifyTimer);
|
|
3576
4337
|
reverifyTimer = null;
|
|
3577
4338
|
}
|
|
4339
|
+
if (neverOpenedTimer !== null) {
|
|
4340
|
+
clearTimeout(neverOpenedTimer);
|
|
4341
|
+
neverOpenedTimer = null;
|
|
4342
|
+
}
|
|
3578
4343
|
clearDivergenceHeal();
|
|
3579
4344
|
if (gapTimer !== null) {
|
|
3580
4345
|
clearTimeout(gapTimer);
|
|
@@ -3611,7 +4376,7 @@ function wireDocTransport(args) {
|
|
|
3611
4376
|
// reopening the same id, so the evicted copy is retired on every move.
|
|
3612
4377
|
const parkable = !args.doc.readOnly &&
|
|
3613
4378
|
args.parkable !== false &&
|
|
3614
|
-
args.doc.
|
|
4379
|
+
args.doc._workStatusNow?.pending !== true &&
|
|
3615
4380
|
args.doc._goneReason === null;
|
|
3616
4381
|
const cacheKey = parkable
|
|
3617
4382
|
? parkCacheKey(args.docId, args.requestedBranchId, args.requestedStream)
|
|
@@ -3682,10 +4447,186 @@ async function fetchPresignedSnapshot(url, onProgress) {
|
|
|
3682
4447
|
// legible upgrade prompt (rendered verbatim through `session.error`), never an
|
|
3683
4448
|
// empty document: treating an unreadable snapshot as "no snapshot" would let
|
|
3684
4449
|
// the user edit and overwrite content that is actually there.
|
|
3685
|
-
|
|
3686
|
-
|
|
4450
|
+
/** Whether the server names a head above the seq its snapshot was sealed at,
|
|
4451
|
+
* which is state the payload has not delivered. Read beside `tail_ops`, so a
|
|
4452
|
+
* response that dropped its tail entries for budget is still seen as having
|
|
4453
|
+
* one rather than as a document that stops at its base. A server that sends
|
|
4454
|
+
* neither seq answers by its entries alone, which is all it ever had. */
|
|
4455
|
+
function coversMoreThanSnapshot(body) {
|
|
4456
|
+
const { current_seq: current, snapshot_seq: sealed } = body;
|
|
4457
|
+
return current !== undefined && sealed !== undefined && current > sealed;
|
|
4458
|
+
}
|
|
4459
|
+
/** The open with its top-level `readOnly` folded into `documentOptions`,
|
|
4460
|
+
* which is where every path below reads the intent from. The top-level
|
|
4461
|
+
* value wins over `documentOptions.readOnly` when both are set. */
|
|
4462
|
+
function withReadIntent(options) {
|
|
4463
|
+
if (options?.readOnly === undefined)
|
|
4464
|
+
return options;
|
|
4465
|
+
const { readOnly, ...rest } = options;
|
|
4466
|
+
const { readOnly: _alias, ...documentOptions } = rest.documentOptions ?? {};
|
|
4467
|
+
return {
|
|
4468
|
+
...rest,
|
|
4469
|
+
documentOptions: readOnly ? { ...documentOptions, readOnly: true } : documentOptions,
|
|
4470
|
+
};
|
|
4471
|
+
}
|
|
4472
|
+
/** Whether this open will never write: the caller asked for a snapshot, or
|
|
4473
|
+
* for a read-only Document. The grant is not consulted, because a caller who
|
|
4474
|
+
* may edit and says nothing is one that may edit. */
|
|
4475
|
+
function opensReadOnly(options) {
|
|
4476
|
+
return options?.frozen === true || options?.documentOptions?.readOnly === true;
|
|
4477
|
+
}
|
|
4478
|
+
/** The options every re-join of `branch` sends, a member's and a guest's
|
|
4479
|
+
* alike: the line it names, and the read intent of the open it serves, so a
|
|
4480
|
+
* document opened read-only is re-joined read-only and one opened to write
|
|
4481
|
+
* is re-joined to write. */
|
|
4482
|
+
function rejoinOptions(branch, open) {
|
|
4483
|
+
const base = branch !== undefined ? { branch } : {};
|
|
4484
|
+
return opensReadOnly(open) ? { ...base, documentOptions: { readOnly: true } } : base;
|
|
4485
|
+
}
|
|
4486
|
+
/**
|
|
4487
|
+
* Apply a re-join to an open document: the reload, a line's resync and a
|
|
4488
|
+
* guest's pull all end here.
|
|
4489
|
+
*
|
|
4490
|
+
* CRDT content over a CRDT document merges in place. Over frozen content it is
|
|
4491
|
+
* a storage upgrade (`Document._upgradeStorage`), and frozen content sealed at
|
|
4492
|
+
* another seq replaces the frozen content; both keep the app's handles and
|
|
4493
|
+
* their subscribers. Frozen content served to a CRDT document, or content the
|
|
4494
|
+
* document cannot take in place, reloads it.
|
|
4495
|
+
*/
|
|
4496
|
+
// The seal of frozen content that holds nothing yet: no join's seq matches it.
|
|
4497
|
+
const NO_SEAL = -1;
|
|
4498
|
+
// The `import_seq` each CRDT copy was opened at. A new version's import
|
|
4499
|
+
// replaces a line's content with a CRDT authored by the same pinned peer as
|
|
4500
|
+
// the one it replaces, so merging the new content into an old copy dedupes it
|
|
4501
|
+
// away and leaves the old copy's ops pointing at containers the line no
|
|
4502
|
+
// longer has. A copy that sees the line under a different import reopens
|
|
4503
|
+
// instead, and names its base on every op so the server refuses one that
|
|
4504
|
+
// raced the import (`moved`).
|
|
4505
|
+
const openedImportSeq = new WeakMap();
|
|
4506
|
+
function recordImportSeq(doc, importSeq) {
|
|
4507
|
+
if (typeof importSeq === 'number')
|
|
4508
|
+
openedImportSeq.set(doc, importSeq);
|
|
4509
|
+
}
|
|
4510
|
+
function importSeqMoved(doc, importSeq) {
|
|
4511
|
+
const base = openedImportSeq.get(doc);
|
|
4512
|
+
return base !== undefined && typeof importSeq === 'number' && importSeq !== base;
|
|
4513
|
+
}
|
|
4514
|
+
function baseImportSeqField(doc) {
|
|
4515
|
+
const base = openedImportSeq.get(doc);
|
|
4516
|
+
return base === undefined ? {} : { base_import_seq: base };
|
|
4517
|
+
}
|
|
4518
|
+
async function applyJoin(doc, body, fetchBody) {
|
|
4519
|
+
// Decided off the wire's own statement of the encoding, so content the
|
|
4520
|
+
// document will not keep is never downloaded.
|
|
4521
|
+
const encoding = joinEncoding(body.snapshot);
|
|
4522
|
+
// Frozen content replaces a reader's CRDT rather than merging into it, so a
|
|
4523
|
+
// new version's import is taken in place below.
|
|
4524
|
+
const replacesCrdt = encoding === 'frozen' && !doc._holdsRecordSet();
|
|
4525
|
+
if (!replacesCrdt && importSeqMoved(doc, body.import_seq)) {
|
|
4526
|
+
doc._reopen();
|
|
4527
|
+
return { kind: 'reloaded' };
|
|
4528
|
+
}
|
|
4529
|
+
if (doc._holdsRecordSet()) {
|
|
4530
|
+
const tail = [...body.tail_ops].sort((a, b) => a.seq - b.seq);
|
|
4531
|
+
const tailSeqs = tail.map((entry) => entry.seq);
|
|
4532
|
+
if (encoding === 'frozen') {
|
|
4533
|
+
const held = doc._frozenSealSeq;
|
|
4534
|
+
const sameSeal = held === null || body.snapshot_seq === undefined || held === body.snapshot_seq;
|
|
4535
|
+
if (tail.length === 0 && sameSeal)
|
|
4536
|
+
return { kind: 'unchanged' };
|
|
4537
|
+
if (tail.length === 0 && !coversMoreThanSnapshot(body)) {
|
|
4538
|
+
const content = await decodeJoinContent(body.snapshot, fetchBody);
|
|
4539
|
+
if (content.kind === 'frozen' &&
|
|
4540
|
+
doc._replaceSealedContent(content.content, body.snapshot_seq ?? null)) {
|
|
4541
|
+
return { kind: 'applied', content, tailSeqs };
|
|
4542
|
+
}
|
|
4543
|
+
}
|
|
4544
|
+
doc._reopen();
|
|
4545
|
+
return { kind: 'reloaded' };
|
|
4546
|
+
}
|
|
4547
|
+
const content = await decodeJoinContent(body.snapshot, fetchBody);
|
|
4548
|
+
await ensureWasmReady();
|
|
4549
|
+
const upgraded = doc._upgradeStorage(content.kind === 'crdt' ? content.bytes : null, tail.map((entry) => base64ToBytes(entry.op_blob_b64)));
|
|
4550
|
+
if (!upgraded) {
|
|
4551
|
+
doc._reopen();
|
|
4552
|
+
return { kind: 'reloaded' };
|
|
4553
|
+
}
|
|
4554
|
+
return { kind: 'applied', content, tailSeqs };
|
|
4555
|
+
}
|
|
4556
|
+
// A line a reader holds as a CRDT and the server now stores frozen (a new
|
|
4557
|
+
// version of its file was imported over it) is taken as stored, in place.
|
|
4558
|
+
// An editable open asks as a writer and is never served frozen content.
|
|
4559
|
+
if (encoding === 'frozen') {
|
|
4560
|
+
if (doc._openMode !== 'edit' &&
|
|
4561
|
+
body.tail_ops.length === 0 &&
|
|
4562
|
+
!coversMoreThanSnapshot(body)) {
|
|
4563
|
+
const content = await decodeJoinContent(body.snapshot, fetchBody);
|
|
4564
|
+
if (content.kind === 'frozen' &&
|
|
4565
|
+
doc._downgradeStorage(content.content, body.snapshot_seq ?? null)) {
|
|
4566
|
+
openedImportSeq.delete(doc);
|
|
4567
|
+
return { kind: 'applied', content, tailSeqs: [] };
|
|
4568
|
+
}
|
|
4569
|
+
}
|
|
4570
|
+
doc._reopen();
|
|
4571
|
+
return { kind: 'reloaded' };
|
|
4572
|
+
}
|
|
4573
|
+
const content = await decodeJoinContent(body.snapshot, fetchBody);
|
|
4574
|
+
if (content.kind === 'crdt')
|
|
4575
|
+
doc._applySnapshot(content.bytes);
|
|
4576
|
+
const tail = [...body.tail_ops].sort((a, b) => a.seq - b.seq);
|
|
4577
|
+
for (const entry of tail)
|
|
4578
|
+
doc.applyRemoteOp(base64ToBytes(entry.op_blob_b64));
|
|
4579
|
+
return { kind: 'applied', content, tailSeqs: tail.map((entry) => entry.seq) };
|
|
3687
4580
|
}
|
|
3688
|
-
async function materialise(body, documentOptions, onSnapshotProgress
|
|
4581
|
+
async function materialise(body, documentOptions, onSnapshotProgress,
|
|
4582
|
+
// A frozen copy of a document whose import has not landed yet starts as
|
|
4583
|
+
// empty frozen content sealed at nothing, so the reload that follows the
|
|
4584
|
+
// import asks for the content as stored and swaps it in place, where an
|
|
4585
|
+
// empty CRDT would ask the server to convert the line for it.
|
|
4586
|
+
{ emptyAsFrozen = false } = {}) {
|
|
4587
|
+
const endFetch = perfStart('open: snapshot fetch + decode');
|
|
4588
|
+
const content = await decodeJoinContent(body.snapshot, (url) => fetchPresignedSnapshot(url, onSnapshotProgress));
|
|
4589
|
+
endFetch({ kind: content.kind });
|
|
4590
|
+
if (content.kind === 'none' && emptyAsFrozen && body.tail_ops.length === 0) {
|
|
4591
|
+
return Document.openFromSealedContent(SealedContent.fromJSON({}), {
|
|
4592
|
+
...documentOptions,
|
|
4593
|
+
readOnly: true,
|
|
4594
|
+
readOnlyReason: documentOptions?.readOnlyReason ?? 'snapshot',
|
|
4595
|
+
docId: body.doc_id,
|
|
4596
|
+
branchId: body.branch_id,
|
|
4597
|
+
frozenSeal: NO_SEAL,
|
|
4598
|
+
});
|
|
4599
|
+
}
|
|
4600
|
+
// Frozen content is read with no CRDT engine: no engine is built and the
|
|
4601
|
+
// WASM is not waited on.
|
|
4602
|
+
//
|
|
4603
|
+
// It is served to a reader and never to a writer. Converting it to a CRDT
|
|
4604
|
+
// is the SERVER's, because the conversion's output depends on the paging
|
|
4605
|
+
// constants, the mutation batching budget and loro's encoding, and two
|
|
4606
|
+
// clients on different builds would produce documents that merge into each
|
|
4607
|
+
// other with no error and no agreement. The tail is empty for the same
|
|
4608
|
+
// reason: the conversion is what makes a branch writable, so the ops it
|
|
4609
|
+
// takes are authored after it and against the converted document.
|
|
4610
|
+
if (content.kind === 'frozen') {
|
|
4611
|
+
// Frozen content with state above it is a document the server should not
|
|
4612
|
+
// have been able to build, and the content alone is the document as it
|
|
4613
|
+
// stood before those ops. Refused rather than read, because nothing here
|
|
4614
|
+
// would say so.
|
|
4615
|
+
if (body.tail_ops.length > 0 || coversMoreThanSnapshot(body)) {
|
|
4616
|
+
throw new BrassError('This could not be opened: its stored records do not ' +
|
|
4617
|
+
'account for every change made to it. Reopen it in a moment.', { status: 500 });
|
|
4618
|
+
}
|
|
4619
|
+
return Document.openFromSealedContent(content.content, {
|
|
4620
|
+
...documentOptions,
|
|
4621
|
+
// A property of the content, which holds no op log for an edit to be
|
|
4622
|
+
// written into.
|
|
4623
|
+
readOnly: true,
|
|
4624
|
+
readOnlyReason: documentOptions?.readOnlyReason ?? 'snapshot',
|
|
4625
|
+
docId: body.doc_id,
|
|
4626
|
+
branchId: body.branch_id,
|
|
4627
|
+
...(body.snapshot_seq !== undefined ? { frozenSeal: body.snapshot_seq } : {}),
|
|
4628
|
+
});
|
|
4629
|
+
}
|
|
3689
4630
|
// Constructing the document is the first thing in this package that runs the
|
|
3690
4631
|
// WASM, and `Brass.init` only starts the fetch, so this is where it is
|
|
3691
4632
|
// waited on. Usually it resolved during the auth round-trip and this costs
|
|
@@ -3700,28 +4641,13 @@ async function materialise(body, documentOptions, onSnapshotProgress) {
|
|
|
3700
4641
|
branchId: body.branch_id,
|
|
3701
4642
|
};
|
|
3702
4643
|
let doc;
|
|
3703
|
-
|
|
3704
|
-
if (snap === null) {
|
|
4644
|
+
if (content.kind === 'none') {
|
|
3705
4645
|
doc = Document.create(optionsWithId);
|
|
3706
4646
|
}
|
|
3707
|
-
else if (snap.kind === 'inline') {
|
|
3708
|
-
const endDecode = perfStart('open: base64 decode (inline snapshot)');
|
|
3709
|
-
const bytes = base64ToBytes(snap.data_b64);
|
|
3710
|
-
endDecode({ base64Chars: snap.data_b64.length, snapshotBytes: bytes.byteLength });
|
|
3711
|
-
const endImport = perfStart('open: loro import (inline snapshot)');
|
|
3712
|
-
doc = Document.openFromSnapshot(bytes, optionsWithId);
|
|
3713
|
-
endImport({ snapshotBytes: bytes.byteLength });
|
|
3714
|
-
}
|
|
3715
|
-
else if (snap.kind === 'presigned') {
|
|
3716
|
-
const endPresigned = perfStart('open: presigned snapshot fetch');
|
|
3717
|
-
const bytes = await fetchPresignedSnapshot(snap.url, onSnapshotProgress);
|
|
3718
|
-
endPresigned({ snapshotBytes: bytes.byteLength });
|
|
3719
|
-
const endImport = perfStart('open: loro import (presigned snapshot)');
|
|
3720
|
-
doc = Document.openFromSnapshot(bytes, optionsWithId);
|
|
3721
|
-
endImport({ snapshotBytes: bytes.byteLength });
|
|
3722
|
-
}
|
|
3723
4647
|
else {
|
|
3724
|
-
|
|
4648
|
+
const endImport = perfStart('open: loro import');
|
|
4649
|
+
doc = Document.openFromSnapshot(content.bytes, optionsWithId);
|
|
4650
|
+
endImport({ snapshotBytes: content.bytes.byteLength });
|
|
3725
4651
|
}
|
|
3726
4652
|
// Apply tail ops in seq order. The server delivers them sorted; we sort
|
|
3727
4653
|
// defensively (Loro dedupes by lamport, so out-of-order is safe) rather
|
|
@@ -3732,8 +4658,93 @@ async function materialise(body, documentOptions, onSnapshotProgress) {
|
|
|
3732
4658
|
doc.applyRemoteOp(base64ToBytes(entry.op_blob_b64));
|
|
3733
4659
|
}
|
|
3734
4660
|
endTail({ count: tail.length });
|
|
4661
|
+
recordImportSeq(doc, body.import_seq);
|
|
3735
4662
|
// `doc.id` is non-null at runtime (stamped above); the cast closes the type
|
|
3736
4663
|
// gap without weakening the class's `string | null` signature.
|
|
3737
4664
|
return doc;
|
|
3738
4665
|
}
|
|
4666
|
+
/** How many frozen opens run at once. */
|
|
4667
|
+
export const FROZEN_OPEN_LANES = 8;
|
|
4668
|
+
/** Runs at most `lanes` of the tasks handed to it at once, in the order given. */
|
|
4669
|
+
export function createPacer(lanes) {
|
|
4670
|
+
let running = 0;
|
|
4671
|
+
const waiting = [];
|
|
4672
|
+
return (task) => new Promise((resolve, reject) => {
|
|
4673
|
+
const start = () => {
|
|
4674
|
+
running += 1;
|
|
4675
|
+
task()
|
|
4676
|
+
.then(resolve, reject)
|
|
4677
|
+
.finally(() => {
|
|
4678
|
+
running -= 1;
|
|
4679
|
+
waiting.shift()?.();
|
|
4680
|
+
});
|
|
4681
|
+
};
|
|
4682
|
+
if (running < lanes)
|
|
4683
|
+
start();
|
|
4684
|
+
else
|
|
4685
|
+
waiting.push(start);
|
|
4686
|
+
});
|
|
4687
|
+
}
|
|
4688
|
+
function frozenOptions(options) {
|
|
4689
|
+
// Reading as data: a schema this app did not declare is read rather than
|
|
4690
|
+
// refused, since nothing written through this copy can misread it.
|
|
4691
|
+
return {
|
|
4692
|
+
...options,
|
|
4693
|
+
acceptSchemaConflict: true,
|
|
4694
|
+
documentOptions: { ...options.documentOptions, readOnly: true },
|
|
4695
|
+
};
|
|
4696
|
+
}
|
|
4697
|
+
// A stream line opened for a frozen copy: the same reading as the copy
|
|
4698
|
+
// itself, of the line its manifest names.
|
|
4699
|
+
function frozenLineOptions(lineId) {
|
|
4700
|
+
return {
|
|
4701
|
+
branch: lineId,
|
|
4702
|
+
frozen: true,
|
|
4703
|
+
acceptSchemaConflict: true,
|
|
4704
|
+
documentOptions: { readOnly: true },
|
|
4705
|
+
};
|
|
4706
|
+
}
|
|
4707
|
+
function importReportOf(parsed) {
|
|
4708
|
+
const report = parsed.import_report;
|
|
4709
|
+
return report !== null && typeof report === 'object' && Array.isArray(report.findings)
|
|
4710
|
+
? report
|
|
4711
|
+
: null;
|
|
4712
|
+
}
|
|
4713
|
+
/**
|
|
4714
|
+
* Whether a frame on a document's topic says the document moved past the
|
|
4715
|
+
* point a frozen copy was taken at: a later op, work that locked the
|
|
4716
|
+
* document finishing (an import landing or failing), or the document going.
|
|
4717
|
+
*/
|
|
4718
|
+
export function frameMovesDocument(frame, takenAt) {
|
|
4719
|
+
if (frame === null || typeof frame !== 'object')
|
|
4720
|
+
return false;
|
|
4721
|
+
const f = frame;
|
|
4722
|
+
switch (f.type) {
|
|
4723
|
+
// The server fans a `doc_op` out to every subscriber but the writer's
|
|
4724
|
+
// connection, so an edit made through another copy on this page's socket
|
|
4725
|
+
// reaches the topic only as that copy's `op_ack`.
|
|
4726
|
+
case 'doc_op':
|
|
4727
|
+
case 'op_ack':
|
|
4728
|
+
return typeof f.seq === 'number' && f.seq > takenAt;
|
|
4729
|
+
case 'doc_init':
|
|
4730
|
+
case 'doc_catch_up':
|
|
4731
|
+
return typeof f.current_seq === 'number' && f.current_seq > takenAt;
|
|
4732
|
+
case 'doc_status':
|
|
4733
|
+
// `reload` is work that replaced the content (an import landing); a
|
|
4734
|
+
// terminal status still blocking is work that failed.
|
|
4735
|
+
return (f.reload === true ||
|
|
4736
|
+
(f.status?.pending === false && f.status.blocking !== false));
|
|
4737
|
+
case 'doc_gone':
|
|
4738
|
+
return true;
|
|
4739
|
+
default:
|
|
4740
|
+
return false;
|
|
4741
|
+
}
|
|
4742
|
+
}
|
|
4743
|
+
// The document's name as the join that opened it read it, so a copy opened by
|
|
4744
|
+
// id carries it from its first frame as a session open does.
|
|
4745
|
+
function setJoinedName(doc, parsed) {
|
|
4746
|
+
const name = parsed.name;
|
|
4747
|
+
if (typeof name === 'string')
|
|
4748
|
+
doc._setName(name);
|
|
4749
|
+
}
|
|
3739
4750
|
//# sourceMappingURL=open-document.js.map
|