@brass-build/client 0.1.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 +255 -0
- package/CHANGELOG.md +15 -0
- package/LICENSE +21 -0
- package/README.md +836 -0
- package/dist/api-fetch.d.ts +16 -0
- package/dist/api-fetch.d.ts.map +1 -0
- package/dist/api-fetch.js +97 -0
- package/dist/api-fetch.js.map +1 -0
- package/dist/api-headers.d.ts +3 -0
- package/dist/api-headers.d.ts.map +1 -0
- package/dist/api-headers.js +25 -0
- package/dist/api-headers.js.map +1 -0
- package/dist/auth-flow.d.ts +23 -0
- package/dist/auth-flow.d.ts.map +1 -0
- package/dist/auth-flow.js +76 -0
- package/dist/auth-flow.js.map +1 -0
- package/dist/client.d.ts +106 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +1278 -0
- package/dist/client.js.map +1 -0
- package/dist/doc-url-sync.d.ts +19 -0
- package/dist/doc-url-sync.d.ts.map +1 -0
- package/dist/doc-url-sync.js +181 -0
- package/dist/doc-url-sync.js.map +1 -0
- package/dist/document-branches.d.ts +32 -0
- package/dist/document-branches.d.ts.map +1 -0
- package/dist/document-branches.js +89 -0
- package/dist/document-branches.js.map +1 -0
- package/dist/document-checkpoints.d.ts +24 -0
- package/dist/document-checkpoints.d.ts.map +1 -0
- package/dist/document-checkpoints.js +115 -0
- package/dist/document-checkpoints.js.map +1 -0
- package/dist/document-commands.d.ts +93 -0
- package/dist/document-commands.d.ts.map +1 -0
- package/dist/document-commands.js +423 -0
- package/dist/document-commands.js.map +1 -0
- package/dist/document-signals.d.ts +53 -0
- package/dist/document-signals.d.ts.map +1 -0
- package/dist/document-signals.js +114 -0
- package/dist/document-signals.js.map +1 -0
- package/dist/document-streams.d.ts +22 -0
- package/dist/document-streams.d.ts.map +1 -0
- package/dist/document-streams.js +98 -0
- package/dist/document-streams.js.map +1 -0
- package/dist/document-thumbnail-capture.d.ts +27 -0
- package/dist/document-thumbnail-capture.d.ts.map +1 -0
- package/dist/document-thumbnail-capture.js +160 -0
- package/dist/document-thumbnail-capture.js.map +1 -0
- package/dist/documents-namespace.d.ts +134 -0
- package/dist/documents-namespace.d.ts.map +1 -0
- package/dist/documents-namespace.js +190 -0
- package/dist/documents-namespace.js.map +1 -0
- package/dist/documents.d.ts +146 -0
- package/dist/documents.d.ts.map +1 -0
- package/dist/documents.js +2045 -0
- package/dist/documents.js.map +1 -0
- package/dist/emitter.d.ts +15 -0
- package/dist/emitter.d.ts.map +1 -0
- package/dist/emitter.js +90 -0
- package/dist/emitter.js.map +1 -0
- package/dist/errors.d.ts +18 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +22 -0
- package/dist/errors.js.map +1 -0
- package/dist/export-guest.d.ts +9 -0
- package/dist/export-guest.d.ts.map +1 -0
- package/dist/export-guest.js +225 -0
- package/dist/export-guest.js.map +1 -0
- package/dist/fetch-retry.d.ts +16 -0
- package/dist/fetch-retry.d.ts.map +1 -0
- package/dist/fetch-retry.js +90 -0
- package/dist/fetch-retry.js.map +1 -0
- package/dist/guest-link.d.ts +3 -0
- package/dist/guest-link.d.ts.map +1 -0
- package/dist/guest-link.js +13 -0
- package/dist/guest-link.js.map +1 -0
- package/dist/handles.d.ts +57 -0
- package/dist/handles.d.ts.map +1 -0
- package/dist/handles.js +1375 -0
- package/dist/handles.js.map +1 -0
- package/dist/hosted-runtime.d.ts +33 -0
- package/dist/hosted-runtime.d.ts.map +1 -0
- package/dist/hosted-runtime.js +99 -0
- package/dist/hosted-runtime.js.map +1 -0
- package/dist/hosted-ui.d.ts +84 -0
- package/dist/hosted-ui.d.ts.map +1 -0
- package/dist/hosted-ui.js +673 -0
- package/dist/hosted-ui.js.map +1 -0
- package/dist/importer.d.ts +22 -0
- package/dist/importer.d.ts.map +1 -0
- package/dist/importer.js +54 -0
- package/dist/importer.js.map +1 -0
- package/dist/index.d.ts +54 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +132 -0
- package/dist/index.js.map +1 -0
- package/dist/init-keys.d.ts +3 -0
- package/dist/init-keys.d.ts.map +1 -0
- package/dist/init-keys.js +13 -0
- package/dist/init-keys.js.map +1 -0
- package/dist/internal.d.ts +6 -0
- package/dist/internal.d.ts.map +1 -0
- package/dist/internal.js +17 -0
- package/dist/internal.js.map +1 -0
- package/dist/jwt.d.ts +3 -0
- package/dist/jwt.d.ts.map +1 -0
- package/dist/jwt.js +22 -0
- package/dist/jwt.js.map +1 -0
- package/dist/launch-context.d.ts +4 -0
- package/dist/launch-context.d.ts.map +1 -0
- package/dist/launch-context.js +44 -0
- package/dist/launch-context.js.map +1 -0
- package/dist/login-hint.d.ts +4 -0
- package/dist/login-hint.d.ts.map +1 -0
- package/dist/login-hint.js +63 -0
- package/dist/login-hint.js.map +1 -0
- package/dist/mutate-batching.d.ts +14 -0
- package/dist/mutate-batching.d.ts.map +1 -0
- package/dist/mutate-batching.js +65 -0
- package/dist/mutate-batching.js.map +1 -0
- package/dist/open-document.d.ts +127 -0
- package/dist/open-document.d.ts.map +1 -0
- package/dist/open-document.js +2121 -0
- package/dist/open-document.js.map +1 -0
- package/dist/page-title.d.ts +3 -0
- package/dist/page-title.d.ts.map +1 -0
- package/dist/page-title.js +32 -0
- package/dist/page-title.js.map +1 -0
- package/dist/perf.d.ts +9 -0
- package/dist/perf.d.ts.map +1 -0
- package/dist/perf.js +39 -0
- package/dist/perf.js.map +1 -0
- package/dist/presence.d.ts +62 -0
- package/dist/presence.d.ts.map +1 -0
- package/dist/presence.js +331 -0
- package/dist/presence.js.map +1 -0
- package/dist/record-paging.d.ts +99 -0
- package/dist/record-paging.d.ts.map +1 -0
- package/dist/record-paging.js +220 -0
- package/dist/record-paging.js.map +1 -0
- package/dist/schema-conflict-dialog.d.ts +10 -0
- package/dist/schema-conflict-dialog.d.ts.map +1 -0
- package/dist/schema-conflict-dialog.js +164 -0
- package/dist/schema-conflict-dialog.js.map +1 -0
- package/dist/selection-persistence.d.ts +5 -0
- package/dist/selection-persistence.d.ts.map +1 -0
- package/dist/selection-persistence.js +90 -0
- package/dist/selection-persistence.js.map +1 -0
- package/dist/serializer.d.ts +3 -0
- package/dist/serializer.d.ts.map +1 -0
- package/dist/serializer.js +2 -0
- package/dist/serializer.js.map +1 -0
- package/dist/session-persistence.d.ts +4 -0
- package/dist/session-persistence.d.ts.map +1 -0
- package/dist/session-persistence.js +68 -0
- package/dist/session-persistence.js.map +1 -0
- package/dist/session-toolbar.d.ts +62 -0
- package/dist/session-toolbar.d.ts.map +1 -0
- package/dist/session-toolbar.js +1205 -0
- package/dist/session-toolbar.js.map +1 -0
- package/dist/session.d.ts +69 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +926 -0
- package/dist/session.js.map +1 -0
- package/dist/tail-ops.d.ts +28 -0
- package/dist/tail-ops.d.ts.map +1 -0
- package/dist/tail-ops.js +46 -0
- package/dist/tail-ops.js.map +1 -0
- package/dist/topic-lifecycle.d.ts +41 -0
- package/dist/topic-lifecycle.d.ts.map +1 -0
- package/dist/topic-lifecycle.js +169 -0
- package/dist/topic-lifecycle.js.map +1 -0
- package/dist/types.d.ts +22 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/undo.d.ts +18 -0
- package/dist/undo.d.ts.map +1 -0
- package/dist/undo.js +110 -0
- package/dist/undo.js.map +1 -0
- package/dist/version-bar.d.ts +22 -0
- package/dist/version-bar.d.ts.map +1 -0
- package/dist/version-bar.js +169 -0
- package/dist/version-bar.js.map +1 -0
- package/dist/version.d.ts +3 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +8 -0
- package/dist/version.js.map +1 -0
- package/dist/vite.d.ts +15 -0
- package/dist/vite.d.ts.map +1 -0
- package/dist/vite.js +36 -0
- package/dist/vite.js.map +1 -0
- package/dist/ws-client.d.ts +53 -0
- package/dist/ws-client.d.ts.map +1 -0
- package/dist/ws-client.js +604 -0
- package/dist/ws-client.js.map +1 -0
- package/dist/ws-frame-limit.d.ts +21 -0
- package/dist/ws-frame-limit.d.ts.map +1 -0
- package/dist/ws-frame-limit.js +40 -0
- package/dist/ws-frame-limit.js.map +1 -0
- package/docs/app-manifest.md +65 -0
- package/docs/custom-chrome.md +147 -0
- package/docs/hosted-ui-advanced.md +77 -0
- package/docs/imported-documents.md +261 -0
- package/docs/presence.md +129 -0
- package/docs/schemas.md +135 -0
- package/docs/streams.md +248 -0
- package/package.json +60 -0
- package/scripts/prepare.mjs +50 -0
- package/src/api-fetch.ts +127 -0
- package/src/api-headers.ts +28 -0
- package/src/auth-flow.ts +125 -0
- package/src/client.ts +1457 -0
- package/src/doc-url-sync.ts +183 -0
- package/src/document-branches.ts +124 -0
- package/src/document-checkpoints.ts +135 -0
- package/src/document-commands.ts +651 -0
- package/src/document-signals.ts +234 -0
- package/src/document-streams.ts +132 -0
- package/src/document-thumbnail-capture.ts +191 -0
- package/src/documents-namespace.ts +516 -0
- package/src/documents.ts +2426 -0
- package/src/emitter.ts +129 -0
- package/src/errors.ts +45 -0
- package/src/export-guest.ts +282 -0
- package/src/fetch-retry.ts +119 -0
- package/src/guest-link.ts +14 -0
- package/src/handles.ts +1731 -0
- package/src/hosted-runtime.ts +109 -0
- package/src/hosted-ui.ts +1020 -0
- package/src/importer.ts +89 -0
- package/src/index.ts +267 -0
- package/src/init-keys.ts +15 -0
- package/src/internal.ts +23 -0
- package/src/jwt.ts +18 -0
- package/src/launch-context.ts +43 -0
- package/src/login-hint.ts +60 -0
- package/src/mutate-batching.ts +63 -0
- package/src/open-document.ts +2869 -0
- package/src/page-title.ts +33 -0
- package/src/perf.ts +46 -0
- package/src/presence.ts +471 -0
- package/src/record-paging.ts +270 -0
- package/src/schema-conflict-dialog.ts +176 -0
- package/src/selection-persistence.ts +98 -0
- package/src/serializer.ts +15 -0
- package/src/session-persistence.ts +69 -0
- package/src/session-toolbar.ts +1457 -0
- package/src/session.ts +1262 -0
- package/src/tail-ops.ts +63 -0
- package/src/topic-lifecycle.ts +217 -0
- package/src/types.ts +75 -0
- package/src/undo.ts +125 -0
- package/src/version-bar.ts +221 -0
- package/src/version.ts +8 -0
- package/src/vite.ts +49 -0
- package/src/ws-client.ts +678 -0
- package/src/ws-frame-limit.ts +52 -0
|
@@ -0,0 +1,2869 @@
|
|
|
1
|
+
// SDK `Brass.openDocumentById(id, options?)`: the editable join path.
|
|
2
|
+
//
|
|
3
|
+
// Cold-load contract: `Document.openFromSnapshot` must stay within 2x of
|
|
4
|
+
// raw Loro import at 50k entities, so the materialisation path adds no
|
|
5
|
+
// full-snapshot logging or deep cloning of decoded bytes.
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
DEFAULT_STREAM_NAME,
|
|
9
|
+
Document,
|
|
10
|
+
type BranchProvider,
|
|
11
|
+
type DocumentOptions,
|
|
12
|
+
type DocumentWorkStatus,
|
|
13
|
+
type DocumentDriveStatus,
|
|
14
|
+
type DocumentDriveTrackingState,
|
|
15
|
+
type DriveMirrorDirection,
|
|
16
|
+
type HostedUiProvider,
|
|
17
|
+
} from './documents.js';
|
|
18
|
+
import { createPresenceController } from './presence.js';
|
|
19
|
+
import type { WsTopicCallback } from './ws-client.js';
|
|
20
|
+
import { applyAppAssertionHeader, applyClientVersionHeader } from './api-headers.js';
|
|
21
|
+
import { brassErrorFromBody } from './api-fetch.js';
|
|
22
|
+
import { fetchWithRetry, RETRY_IDEMPOTENT_WRITE } from './fetch-retry.js';
|
|
23
|
+
import { BrassError } from './errors.js';
|
|
24
|
+
import { perfStart } from './perf.js';
|
|
25
|
+
import { resolveTailOps, type WireTailOp } from './tail-ops.js';
|
|
26
|
+
import {
|
|
27
|
+
opBlobExceedsDurableLimit,
|
|
28
|
+
opFrameExceedsWsLimit,
|
|
29
|
+
opBlobNeedsStaging,
|
|
30
|
+
} from './ws-frame-limit.js';
|
|
31
|
+
|
|
32
|
+
export interface OpenDocumentByIdOptions {
|
|
33
|
+
// Forwarded to `Document.openFromSnapshot` / `Document.create`; overrides
|
|
34
|
+
// the root-map key for a doc created against a non-default rootKey.
|
|
35
|
+
documentOptions?: DocumentOptions;
|
|
36
|
+
/**
|
|
37
|
+
* Reserved for future server-side filtering; v1 servers ignore it. Still
|
|
38
|
+
* serialised onto the query string so the request shape is
|
|
39
|
+
* forward-compatible.
|
|
40
|
+
* @internal
|
|
41
|
+
*/
|
|
42
|
+
filter?: Record<string, unknown>;
|
|
43
|
+
/**
|
|
44
|
+
* Carry past the server's schema-conflict refusal. The platform refuses to
|
|
45
|
+
* join a document this app's declared schema cannot read, so that a
|
|
46
|
+
* bookmark or a reload does not silently open one; this says the refusal
|
|
47
|
+
* has already been answered. Set by the client after a person answers its
|
|
48
|
+
* dialog, or for every open when the app passes
|
|
49
|
+
* `openIncompatibleDocuments` at init.
|
|
50
|
+
* @internal
|
|
51
|
+
*/
|
|
52
|
+
acceptSchemaConflict?: boolean;
|
|
53
|
+
/**
|
|
54
|
+
* The branch to open; omitting opens the doc's default branch. Branch
|
|
55
|
+
* selection is hosted chrome (the `branches` command / `?branch=` URL,
|
|
56
|
+
* which the session restores through this); an app names no branch, so
|
|
57
|
+
* this is internal.
|
|
58
|
+
* @internal
|
|
59
|
+
*/
|
|
60
|
+
branch?: string;
|
|
61
|
+
/**
|
|
62
|
+
* Which stream of the document to open. The returned Document's own
|
|
63
|
+
* handles (`doc.map(...)`) read that stream, so an app that works in one
|
|
64
|
+
* part of a partitioned document downloads only that part. Defaults to
|
|
65
|
+
* `'default'`, the stream every document has.
|
|
66
|
+
*/
|
|
67
|
+
stream?: string;
|
|
68
|
+
/**
|
|
69
|
+
* Open at a historical version: the returned Document is read-only with no
|
|
70
|
+
* live subscription. Version navigation is hosted chrome (the `history`
|
|
71
|
+
* command drives it), so an app never opens a version itself.
|
|
72
|
+
* @internal
|
|
73
|
+
*/
|
|
74
|
+
version?: number;
|
|
75
|
+
/**
|
|
76
|
+
* SDK-internal snapshot-download progress sink (`total` is the decompressed
|
|
77
|
+
* byte count from `x-amz-meta-raw-bytes`, or null). The session threads it
|
|
78
|
+
* through to surface a "Downloading... NN%" state before the Document
|
|
79
|
+
* exists; apps read that via `Brass.session`, never this.
|
|
80
|
+
* @internal
|
|
81
|
+
*/
|
|
82
|
+
onSnapshotProgress?: (loaded: number, total: number | null) => void;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// `Brass.createDocument(options?)` creates a new document and returns it
|
|
86
|
+
// fully editable. `projectId` creates inside a project; `organizationId`
|
|
87
|
+
// alone creates a loose doc in that org; both create a project-parented doc
|
|
88
|
+
// in an explicit org. With neither and one org, the SDK picks it; with
|
|
89
|
+
// multiple orgs it 409s. `name` defaults to "Untitled".
|
|
90
|
+
export interface CreateDocumentOptions {
|
|
91
|
+
name?: string;
|
|
92
|
+
app_metadata?: Record<string, unknown>;
|
|
93
|
+
// Forwarded to `Document.openFromSnapshot` / `Document.create`; overrides
|
|
94
|
+
// the root-map key for a doc created against a non-default rootKey.
|
|
95
|
+
documentOptions?: DocumentOptions;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Internal create inputs. Placement is expressed by CONTEXT, never by an
|
|
99
|
+
// org / project the caller names: `siblingOf` anchors the new doc to an
|
|
100
|
+
// existing one (the session's active doc), `destination` is the opaque
|
|
101
|
+
// blob the hosted picker returned and is relayed verbatim (never
|
|
102
|
+
// destructured, so the hierarchy shape can change server-side without an
|
|
103
|
+
// SDK change). Neither present = let the server resolve the caller's
|
|
104
|
+
// single membership.
|
|
105
|
+
export interface CreateDocumentArgs {
|
|
106
|
+
name?: string;
|
|
107
|
+
app_metadata?: Record<string, unknown>;
|
|
108
|
+
documentOptions?: DocumentOptions;
|
|
109
|
+
siblingOf?: string;
|
|
110
|
+
destination?: unknown;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// The server could not resolve a destination on its own: the caller
|
|
114
|
+
// belongs to more than one organization (open the hosted picker) or to
|
|
115
|
+
// none (open the acquire-organization surface). The SDK reacts to these;
|
|
116
|
+
// an app never sees them.
|
|
117
|
+
export type CreateDocumentResult =
|
|
118
|
+
| { kind: 'created'; docId: string }
|
|
119
|
+
| { kind: 'needs-destination' }
|
|
120
|
+
| { kind: 'needs-organization' };
|
|
121
|
+
|
|
122
|
+
// Wire-protocol-pinned response shape from
|
|
123
|
+
// `GET /documents/{docId}?include=snapshot`.
|
|
124
|
+
export interface DocumentSnapshotResponse {
|
|
125
|
+
doc_id: string;
|
|
126
|
+
// The LINE this snapshot was materialized from; the SDK subscribes on
|
|
127
|
+
// `doc:<doc_id>:<branch_id>`. Always present. When the open named a
|
|
128
|
+
// stream, this is that stream's line rather than the branch itself.
|
|
129
|
+
branch_id: string;
|
|
130
|
+
// The BRANCH the opened stream hangs off, present only when it differs
|
|
131
|
+
// from `branch_id`. The stream manifest belongs to a branch, so
|
|
132
|
+
// `doc.stream(...)` binds to this.
|
|
133
|
+
stream_branch_id?: string;
|
|
134
|
+
// `inline` carries base64 bytes; `presigned` is a signed URL the
|
|
135
|
+
// client GETs directly; `null` means no snapshot yet (empty new doc).
|
|
136
|
+
snapshot:
|
|
137
|
+
| { kind: 'inline'; data_b64: string }
|
|
138
|
+
| { kind: 'presigned'; url: string }
|
|
139
|
+
| null;
|
|
140
|
+
// Tail ops since the last snapshot, seq ascending, each a base64 Loro op
|
|
141
|
+
// blob. Applied in order; `applyRemoteOp` is idempotent.
|
|
142
|
+
tail_ops: Array<{ seq: number; op_blob_b64: string }>;
|
|
143
|
+
// The branch's seq counter at join time. The consistent-read join means
|
|
144
|
+
// snapshot + tail cover every op at or below it; the SDK seeds its
|
|
145
|
+
// coverage cursor from it. Optional (older fixtures omit it).
|
|
146
|
+
current_seq?: number;
|
|
147
|
+
// Base64 version vector of the persisted snapshot (durability-handshake
|
|
148
|
+
// ground truth). The handshake runs off the `doc_init` frame's copy; this
|
|
149
|
+
// is carried for wire parallelism. Absent until the branch is compacted.
|
|
150
|
+
snapshot_version_vector?: string;
|
|
151
|
+
// Opaque status of slow server-side work (e.g. a drive stub hydrating on
|
|
152
|
+
// first open, with `snapshot` then `null`). Surfaced on `doc.status`;
|
|
153
|
+
// phases stream in via `doc_status` frames.
|
|
154
|
+
status?: DocumentWorkStatus;
|
|
155
|
+
// Drive provenance + tracking state, present only on drive-linked docs.
|
|
156
|
+
// Seeds `doc.driveStatus`; live changes stream in via `doc_drive_status`.
|
|
157
|
+
drive?: DocumentDriveSummaryWire;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Wire shape of the `drive` block carried on the join response and the
|
|
161
|
+
// `doc_drive_status` frame. Snake_case, mirroring the server's
|
|
162
|
+
// `DocumentDriveSummary` (CONTRACT.md "Drive document tracking state").
|
|
163
|
+
// The SDK maps it to the public camelCase `DocumentDriveStatus` via
|
|
164
|
+
// `toDriveStatus`.
|
|
165
|
+
export interface DocumentDriveSummaryWire {
|
|
166
|
+
drive_type: string;
|
|
167
|
+
drive_name: string;
|
|
168
|
+
item_name: string;
|
|
169
|
+
item_url: string;
|
|
170
|
+
tracking_state: DocumentDriveTrackingState;
|
|
171
|
+
tracking_ref: string;
|
|
172
|
+
tracking_ref_updated_at: string;
|
|
173
|
+
tracking_label: string | null;
|
|
174
|
+
tracking_detail: string | null;
|
|
175
|
+
broken: boolean;
|
|
176
|
+
push_replaces_remote: boolean;
|
|
177
|
+
mirror_direction: DriveMirrorDirection;
|
|
178
|
+
// Optional: a frame from an older server omits it, treated as not
|
|
179
|
+
// publishing (the `canPush` affordance then behaves as before).
|
|
180
|
+
publishing?: boolean;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Wire-protocol-pinned response shape from
|
|
184
|
+
// `GET /documents/{docId}/at/{version}`. Mirrors the join's snapshot
|
|
185
|
+
// envelope minus `doc_id`, plus `effective_version` (rounded down from the
|
|
186
|
+
// requested one when compaction TTL'd the exact op) and `branch_id`.
|
|
187
|
+
export interface DocumentAtVersionResponse {
|
|
188
|
+
effective_version: number;
|
|
189
|
+
requested_version: number;
|
|
190
|
+
branch_id: string;
|
|
191
|
+
snapshot:
|
|
192
|
+
| { kind: 'inline'; data_b64: string }
|
|
193
|
+
| { kind: 'presigned'; url: string }
|
|
194
|
+
| null;
|
|
195
|
+
tail_ops: Array<{ seq: number; op_blob_b64: string }>;
|
|
196
|
+
current_seq: number;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Structural subset of `BrassWsClient` the factory uses, so it can be
|
|
200
|
+
// unit-tested against a mock.
|
|
201
|
+
export interface OpenDocumentWsClient {
|
|
202
|
+
subscribe(topic: string, callback: WsTopicCallback): () => void;
|
|
203
|
+
send(frame: unknown): void;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export interface OpenDocumentFactoryOptions {
|
|
207
|
+
appId: string;
|
|
208
|
+
apiBaseUrl: string;
|
|
209
|
+
getAccessToken: () => Promise<string | null>;
|
|
210
|
+
// Forwarded as `X-Brass-App-Assertion` on the Brass-API fetches only; the
|
|
211
|
+
// presigned snapshot downloads carry no Brass headers.
|
|
212
|
+
getAppAssertion?: () => Promise<string | null>;
|
|
213
|
+
// The shared WS client. When provided (production), the factory wires a
|
|
214
|
+
// doc-topic subscription + local-op send hook so the Document is editable.
|
|
215
|
+
// When absent (tests / snapshot reads), the local-op hook is inert: state
|
|
216
|
+
// still mutates locally but nothing leaves the process.
|
|
217
|
+
wsClient?: OpenDocumentWsClient;
|
|
218
|
+
// ID minter, exposed for deterministic test clientOpIds. Defaults to
|
|
219
|
+
// `crypto.randomUUID()`, falling back to a 32-hex Math.random() string.
|
|
220
|
+
// Correlation is SDK-side only (the server never reads the value), so
|
|
221
|
+
// cryptographic uniqueness is not required.
|
|
222
|
+
newClientOpId?: () => string;
|
|
223
|
+
// The HTTP capability the internal branch controller lists / creates
|
|
224
|
+
// through, feeding the toolbar's branch chip + the hosted branch
|
|
225
|
+
// dialog. When absent (tests) the doc reports no branch and the hosted
|
|
226
|
+
// branch actions are unavailable. Not an app-facing surface.
|
|
227
|
+
branchProvider?: BranchProvider;
|
|
228
|
+
// When supplied, `doc.useCanvas(canvas)` captures + uploads a screenshot
|
|
229
|
+
// after each local commit. When absent (tests), the upload step no-ops.
|
|
230
|
+
thumbnailUploader?: (
|
|
231
|
+
doc: Document,
|
|
232
|
+
image: Blob,
|
|
233
|
+
width: number,
|
|
234
|
+
height: number,
|
|
235
|
+
) => Promise<unknown>;
|
|
236
|
+
// Routes `doc.createCheckpoint(...)`. When absent (tests), it throws.
|
|
237
|
+
checkpointCreator?: (
|
|
238
|
+
doc: Document,
|
|
239
|
+
) => Promise<import('./documents-namespace.js').DocumentCheckpoint>;
|
|
240
|
+
// Routes `doc.pushToDrive(...)`. When absent (tests / offline), it throws.
|
|
241
|
+
drivePusher?: (
|
|
242
|
+
doc: Document,
|
|
243
|
+
input: import('./documents-namespace.js').PushToDriveInput,
|
|
244
|
+
) => Promise<import('./documents-namespace.js').PushToDriveResult>;
|
|
245
|
+
// Routes `doc.sourceBlob()` (the namespace's `GET .../source-blob`). When
|
|
246
|
+
// absent (tests / offline), it throws. Wired by `client.ts`.
|
|
247
|
+
sourceBlobFetcher?: (
|
|
248
|
+
doc: Document,
|
|
249
|
+
) => Promise<import('./documents-namespace.js').DocumentSourceBlob>;
|
|
250
|
+
// Streams manifest plumbing (`GET` / `POST /documents/{docId}/streams`).
|
|
251
|
+
// When supplied, every returned Document exposes `doc.stream(name)` /
|
|
252
|
+
// `doc.stream(name)` / `doc.listStreams()`; the factory owns the
|
|
253
|
+
// per-line open (join + subscribe). When absent (tests), those methods
|
|
254
|
+
// reject with "no runtime attached".
|
|
255
|
+
streamProvider?: {
|
|
256
|
+
list: (
|
|
257
|
+
docId: string,
|
|
258
|
+
branchId: string,
|
|
259
|
+
) => Promise<{
|
|
260
|
+
streams: import('./documents-namespace.js').DocumentStreamInfo[];
|
|
261
|
+
}>;
|
|
262
|
+
create: (
|
|
263
|
+
docId: string,
|
|
264
|
+
branchId: string,
|
|
265
|
+
name: string,
|
|
266
|
+
) => Promise<import('./documents-namespace.js').DocumentStreamInfo>;
|
|
267
|
+
};
|
|
268
|
+
// Backs the Document's built-in 'history' / 'manage' / 'share'
|
|
269
|
+
// commands. When absent (tests), those commands render disabled.
|
|
270
|
+
hostedUiProvider?: HostedUiProvider;
|
|
271
|
+
// The organization this page was launched from (`?brass_org=`), read
|
|
272
|
+
// through a getter because `init` resolves it after the factory's
|
|
273
|
+
// options are written. See `createDocumentId`.
|
|
274
|
+
launchOrganizationId?: () => string | null;
|
|
275
|
+
// Async-error sink for built-in command failures; `null` falls back to
|
|
276
|
+
// console.
|
|
277
|
+
reportError?: (err: Error) => void;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// A Document from `openDocumentById` / `createDocument` always carries a
|
|
281
|
+
// server-minted `id`, narrowed non-null here so callsites skip the guard.
|
|
282
|
+
// The `Document` class keeps the `| null` union for the test-only path.
|
|
283
|
+
// (The join also always materializes a branch, tracked in the Document's
|
|
284
|
+
// internal `branchId`; branch is hosted chrome, not app surface, so it is
|
|
285
|
+
// not narrowed onto the public type.)
|
|
286
|
+
export type OpenedDocument = Document & {
|
|
287
|
+
readonly id: string;
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
export type OpenDocumentByIdFn = (
|
|
291
|
+
docId: string,
|
|
292
|
+
options?: OpenDocumentByIdOptions,
|
|
293
|
+
) => Promise<OpenedDocument>;
|
|
294
|
+
|
|
295
|
+
// Open a document read-only from a public-link token (the "anyone with the
|
|
296
|
+
// link" guest path). No auth header is sent: the token IS the credential,
|
|
297
|
+
// and the server serves a read-only, member-stripped snapshot. The result
|
|
298
|
+
// is a read-only Document with no live subscription, the same shape as an
|
|
299
|
+
// at-version open.
|
|
300
|
+
export type OpenDocumentByLinkFn = (
|
|
301
|
+
token: string,
|
|
302
|
+
options?: OpenDocumentByIdOptions,
|
|
303
|
+
) => Promise<OpenedDocument>;
|
|
304
|
+
|
|
305
|
+
export type CreateDocumentFn = (
|
|
306
|
+
options?: CreateDocumentOptions,
|
|
307
|
+
) => Promise<OpenedDocument>;
|
|
308
|
+
|
|
309
|
+
export interface DocumentFactory {
|
|
310
|
+
// Strict open: a 404 stays a 404.
|
|
311
|
+
open: OpenDocumentByIdFn;
|
|
312
|
+
// POSTs to create a doc then pipes the returned doc_id through `open`, so
|
|
313
|
+
// the result is identical in shape to `open(docId)`. The low-level path:
|
|
314
|
+
// resolves the caller's single org, opens the acquire surface for none,
|
|
315
|
+
// and rejects with a 409 for a multi-org caller (no hosted picker).
|
|
316
|
+
create: CreateDocumentFn;
|
|
317
|
+
// Create-only (no open): POST the context body and report the outcome so
|
|
318
|
+
// a caller (the session) can react to a needed destination / organization
|
|
319
|
+
// before entering its loading state. The reactive picker / acquire dance
|
|
320
|
+
// lives in the caller, not here.
|
|
321
|
+
createDocumentId: (args: CreateDocumentArgs) => Promise<CreateDocumentResult>;
|
|
322
|
+
// Read-only guest open from a public-link token (no auth, no live sub).
|
|
323
|
+
openByLink: OpenDocumentByLinkFn;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Wire shapes on the `doc:` topic. Server schemas live in
|
|
327
|
+
// `infra/lambda/ws/default.ts`; frame names match exactly so the CONTRACT.md
|
|
328
|
+
// type tests stay synced.
|
|
329
|
+
interface DocOpFrame {
|
|
330
|
+
type: 'doc_op';
|
|
331
|
+
topic: string;
|
|
332
|
+
op_blob: string;
|
|
333
|
+
seq: number;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
interface OpAckFrame {
|
|
337
|
+
type: 'op_ack';
|
|
338
|
+
topic: string;
|
|
339
|
+
client_op_id: string;
|
|
340
|
+
seq: number;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Server-pushed terminal rejection of a single local op: the op can never be
|
|
344
|
+
// accepted (it failed frame validation, e.g. it exceeded the server's op-blob
|
|
345
|
+
// cap), so unlike a transient drop it must NOT be retried. Carries the
|
|
346
|
+
// `client_op_id` so the SDK fails exactly that op (stops its resend, surfaces
|
|
347
|
+
// the failure) without touching the topic subscription. Wire shape pinned in
|
|
348
|
+
// CONTRACT.md "Server-pushed `op_rejected`".
|
|
349
|
+
interface OpRejectedFrame {
|
|
350
|
+
type: 'op_rejected';
|
|
351
|
+
topic: string;
|
|
352
|
+
client_op_id: string;
|
|
353
|
+
// A machine-readable hint at why (the validator's reason); informational.
|
|
354
|
+
reason?: string;
|
|
355
|
+
// Human-readable copy the SDK surfaces through the error reporter.
|
|
356
|
+
message?: string;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Both join fetches and the `doc_init` frame funnel through this, so a
|
|
360
|
+
// tail op whose bytes spilled to storage is fetched once, here. Every
|
|
361
|
+
// reader downstream then sees the inline shape it already handles, and
|
|
362
|
+
// none of the seq bookkeeping has to learn a second representation.
|
|
363
|
+
async function resolveParsedTailOps(parsed: unknown): Promise<void> {
|
|
364
|
+
if (typeof parsed !== 'object' || parsed === null) return;
|
|
365
|
+
const body = parsed as { tail_ops?: unknown };
|
|
366
|
+
if (!Array.isArray(body.tail_ops)) return;
|
|
367
|
+
body.tail_ops = await resolveTailOps(body.tail_ops as WireTailOp[]);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Post-subscribe server frame mirroring the HTTP join payload. Shape pinned
|
|
371
|
+
// in `infra/lib/document-snapshot.ts`; the SDK needs the tail_ops +
|
|
372
|
+
// current_seq to cover the join-vs-fanout race (see `wireDocTransport`).
|
|
373
|
+
interface DocInitTailOp {
|
|
374
|
+
seq: number;
|
|
375
|
+
// Absent when the op's blob spilled to storage. The server withholds
|
|
376
|
+
// such a tail from this frame (see `isDocInitFrame`), so a reader that
|
|
377
|
+
// finds one skips it and picks the op up on the HTTP re-import.
|
|
378
|
+
op_blob_b64?: string;
|
|
379
|
+
op_blob_url?: string;
|
|
380
|
+
}
|
|
381
|
+
interface DocInitFrame {
|
|
382
|
+
type: 'doc_init';
|
|
383
|
+
topic: string;
|
|
384
|
+
tail_ops: DocInitTailOp[];
|
|
385
|
+
current_seq: number;
|
|
386
|
+
// Base64 version vector of the persisted snapshot, stamped at seal time.
|
|
387
|
+
// Drives the durability handshake: fold the tail ops in for the durable
|
|
388
|
+
// frontier and resync-push anything local holds beyond it. Absent on
|
|
389
|
+
// never-compacted docs; the handshake is skipped (nothing to verify).
|
|
390
|
+
snapshot_version_vector?: string;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Server-pushed eviction frame. Carried on both the content and presence
|
|
394
|
+
// topics; the SDK collapses the duplicate via `Document._markGone`'s
|
|
395
|
+
// idempotency. Wire shape pinned in CONTRACT.md "Server-pushed `doc_gone`".
|
|
396
|
+
interface DocGoneFrame {
|
|
397
|
+
type: 'doc_gone';
|
|
398
|
+
topic: string;
|
|
399
|
+
// The server may grow this vocabulary (a future revoked-access or
|
|
400
|
+
// org-cascade eviction), so it is read as an open string and mapped to the
|
|
401
|
+
// public `DocumentGoneReason` by `goneReasonOf`. An unrecognized reason
|
|
402
|
+
// still evicts: the frame means the document is gone, whatever the label.
|
|
403
|
+
reason: string;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Server-pushed status frame for slow async work (hydration, extraction,
|
|
407
|
+
// push). Wire shape pinned in CONTRACT.md "Server-pushed `doc_status`".
|
|
408
|
+
interface DocStatusFrame {
|
|
409
|
+
type: 'doc_status';
|
|
410
|
+
topic: string;
|
|
411
|
+
status: DocumentWorkStatus;
|
|
412
|
+
// Internal hint, set on the terminal success frame so the SDK pulls the
|
|
413
|
+
// now-available content into the live doc. Authoritative (set from the
|
|
414
|
+
// committed row change), so success is never inferred from a racy read.
|
|
415
|
+
reload?: boolean;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Server-pushed display name, so every reader with the doc open re-titles
|
|
419
|
+
// after a rename, not only the one who made it. Wire shape pinned in
|
|
420
|
+
// CONTRACT.md "Server-pushed `doc_meta`".
|
|
421
|
+
interface DocMetaFrame {
|
|
422
|
+
type: 'doc_meta';
|
|
423
|
+
topic: string;
|
|
424
|
+
name: string;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Server-pushed drive tracking state. Wire shape pinned in CONTRACT.md
|
|
428
|
+
// "Server-pushed `doc_drive_status`". The SDK maps `drive` to the public
|
|
429
|
+
// `DocumentDriveStatus` on `doc.driveStatus` / `doc.onDriveStatus`.
|
|
430
|
+
interface DocDriveStatusFrame {
|
|
431
|
+
type: 'doc_drive_status';
|
|
432
|
+
topic: string;
|
|
433
|
+
drive: DocumentDriveSummaryWire;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// Server-pushed frame fired when the compaction worker's aged-orphan escape
|
|
437
|
+
// discards causally unapplyable ops. The prompt half of the durability
|
|
438
|
+
// handshake: the SDK re-checks durability against ground truth now rather
|
|
439
|
+
// than waiting for the author's next reconnect, and resync-pushes anything
|
|
440
|
+
// it holds. Best-effort and connected-only; the `doc_init` handshake is the
|
|
441
|
+
// correctness backstop. `dropped_seqs` / `actor` are informational (the
|
|
442
|
+
// handshake self-selects the author via the version-vector diff).
|
|
443
|
+
interface DocOpsDroppedFrame {
|
|
444
|
+
type: 'doc_ops_dropped';
|
|
445
|
+
topic: string;
|
|
446
|
+
dropped_seqs: number[];
|
|
447
|
+
actor: string;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Map the snake_case wire `drive` block to the public camelCase
|
|
451
|
+
// `DocumentDriveStatus`. `canPush` is derived once here (unbroken, two-way,
|
|
452
|
+
// and ahead/diverged) so apps don't re-derive it.
|
|
453
|
+
function toDriveStatus(wire: DocumentDriveSummaryWire): DocumentDriveStatus {
|
|
454
|
+
const state = wire.tracking_state;
|
|
455
|
+
// Only an explicit `import_only` restricts publishing; a frame omitting the
|
|
456
|
+
// field must not strip the Publish affordance from a two-way link, so
|
|
457
|
+
// anything not `import_only` is treated as two-way.
|
|
458
|
+
const mirrorDirection =
|
|
459
|
+
wire.mirror_direction === 'import_only' ? 'import_only' : 'two_way';
|
|
460
|
+
return {
|
|
461
|
+
driveType: wire.drive_type,
|
|
462
|
+
driveName: wire.drive_name,
|
|
463
|
+
state,
|
|
464
|
+
// `broken` gates the Publish affordance, so read it explicitly rather
|
|
465
|
+
// than by truthiness: a frame that OMITS it (a future partial drive
|
|
466
|
+
// update) must not be treated as unbroken and offered a push the server
|
|
467
|
+
// would reject. Require an explicit `false` to enable, and an explicit
|
|
468
|
+
// `true` to flag broken. Behaviour is unchanged for today's frames, which
|
|
469
|
+
// always carry the boolean.
|
|
470
|
+
canPush:
|
|
471
|
+
wire.broken === false &&
|
|
472
|
+
mirrorDirection === 'two_way' &&
|
|
473
|
+
(state === 'ahead' || state === 'diverged'),
|
|
474
|
+
label: wire.tracking_label,
|
|
475
|
+
detail: wire.tracking_detail,
|
|
476
|
+
broken: wire.broken === true,
|
|
477
|
+
pushReplacesRemote: wire.push_replaces_remote,
|
|
478
|
+
mirrorDirection,
|
|
479
|
+
ref: wire.tracking_ref,
|
|
480
|
+
refUpdatedAt: wire.tracking_ref_updated_at,
|
|
481
|
+
itemName: wire.item_name,
|
|
482
|
+
itemUrl: wire.item_url,
|
|
483
|
+
publishing: wire.publishing === true,
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function isDocOpFrame(frame: unknown): frame is DocOpFrame {
|
|
488
|
+
if (typeof frame !== 'object' || frame === null) return false;
|
|
489
|
+
const f = frame as Record<string, unknown>;
|
|
490
|
+
return (
|
|
491
|
+
f.type === 'doc_op' &&
|
|
492
|
+
typeof f.topic === 'string' &&
|
|
493
|
+
typeof f.op_blob === 'string' &&
|
|
494
|
+
typeof f.seq === 'number'
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function isDocInitFrame(frame: unknown): frame is DocInitFrame {
|
|
499
|
+
if (typeof frame !== 'object' || frame === null) return false;
|
|
500
|
+
const f = frame as Record<string, unknown>;
|
|
501
|
+
if (f.type !== 'doc_init' || typeof f.topic !== 'string') return false;
|
|
502
|
+
if (typeof f.current_seq !== 'number') return false;
|
|
503
|
+
// `snapshot_version_vector` is an optional durability-handshake input, read
|
|
504
|
+
// defensively at its use site. A present-but-non-string value (a future
|
|
505
|
+
// server retyping it) is tolerated as absent rather than rejecting the whole
|
|
506
|
+
// frame, which would silently break the reconnect / durability path.
|
|
507
|
+
if (!Array.isArray(f.tail_ops)) return false;
|
|
508
|
+
for (const entry of f.tail_ops) {
|
|
509
|
+
if (typeof entry !== 'object' || entry === null) return false;
|
|
510
|
+
const e = entry as Record<string, unknown>;
|
|
511
|
+
if (typeof e.seq !== 'number') return false;
|
|
512
|
+
// A tail op is normally inline here: the server withholds a tail
|
|
513
|
+
// carrying a signed URL from this frame, because fetching one needs
|
|
514
|
+
// an await this reader does not have, and sends `current_seq` alone
|
|
515
|
+
// so the client re-imports the join over HTTP instead. Tolerating
|
|
516
|
+
// the shape anyway keeps such a frame's `current_seq` usable rather
|
|
517
|
+
// than dropping the whole frame and with it the resync trigger.
|
|
518
|
+
if (typeof e.op_blob_b64 !== 'string' && typeof e.op_blob_url !== 'string') {
|
|
519
|
+
return false;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
return true;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function isOpAckFrame(frame: unknown): frame is OpAckFrame {
|
|
526
|
+
if (typeof frame !== 'object' || frame === null) return false;
|
|
527
|
+
const f = frame as Record<string, unknown>;
|
|
528
|
+
return (
|
|
529
|
+
f.type === 'op_ack' &&
|
|
530
|
+
typeof f.topic === 'string' &&
|
|
531
|
+
typeof f.client_op_id === 'string' &&
|
|
532
|
+
typeof f.seq === 'number'
|
|
533
|
+
);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function isOpRejectedFrame(frame: unknown): frame is OpRejectedFrame {
|
|
537
|
+
if (typeof frame !== 'object' || frame === null) return false;
|
|
538
|
+
const f = frame as Record<string, unknown>;
|
|
539
|
+
return (
|
|
540
|
+
f.type === 'op_rejected' &&
|
|
541
|
+
typeof f.topic === 'string' &&
|
|
542
|
+
typeof f.client_op_id === 'string'
|
|
543
|
+
);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// The message surfaced through the error reporter for a terminal op rejection.
|
|
547
|
+
// Prefers the server's copy; falls back to a generic, actionable line. The
|
|
548
|
+
// local edit already applied but can never persist, so the app's cue is to
|
|
549
|
+
// reload the document to return to server state.
|
|
550
|
+
function opRejectedMessage(frame: OpRejectedFrame): string {
|
|
551
|
+
if (typeof frame.message === 'string' && frame.message !== '') {
|
|
552
|
+
return frame.message;
|
|
553
|
+
}
|
|
554
|
+
return 'A change could not be saved and will not be retried. Reload the document to continue.';
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function isDocGoneFrame(frame: unknown): frame is DocGoneFrame {
|
|
558
|
+
if (typeof frame !== 'object' || frame === null) return false;
|
|
559
|
+
const f = frame as Record<string, unknown>;
|
|
560
|
+
// Accept any string `reason`: a `doc_gone` the SDK can't classify still
|
|
561
|
+
// evicts the open document. Gating on the known values would drop a frame
|
|
562
|
+
// carrying a future reason, leaving the editor live on a document the
|
|
563
|
+
// server has already removed.
|
|
564
|
+
return (
|
|
565
|
+
f.type === 'doc_gone' &&
|
|
566
|
+
typeof f.topic === 'string' &&
|
|
567
|
+
typeof f.reason === 'string'
|
|
568
|
+
);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// Map the wire reason to the public `DocumentGoneReason`. `moved` is
|
|
572
|
+
// preserved (it has its own re-open path); every other value, including a
|
|
573
|
+
// future eviction reason, collapses to `deleted`, the terminal "it is gone"
|
|
574
|
+
// the app's cleanup reacts to. One definition so the signed-in and guest
|
|
575
|
+
// transports cannot drift on the collapse rule.
|
|
576
|
+
function goneReasonOf(frame: DocGoneFrame): 'moved' | 'deleted' {
|
|
577
|
+
return frame.reason === 'moved' ? 'moved' : 'deleted';
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function isDocStatusFrame(frame: unknown): frame is DocStatusFrame {
|
|
581
|
+
if (typeof frame !== 'object' || frame === null) return false;
|
|
582
|
+
const f = frame as Record<string, unknown>;
|
|
583
|
+
if (f.type !== 'doc_status' || typeof f.topic !== 'string') return false;
|
|
584
|
+
const s = f.status;
|
|
585
|
+
if (typeof s !== 'object' || s === null) return false;
|
|
586
|
+
const st = s as Record<string, unknown>;
|
|
587
|
+
// Gate only on `pending`, the render-control discriminant. `message` is
|
|
588
|
+
// display copy read defensively at the use site, so a future message-less
|
|
589
|
+
// or progress-only status still updates `doc.status` (clearing the loading
|
|
590
|
+
// overlay) instead of being dropped as an unknown frame and stranding the
|
|
591
|
+
// app on "loading". The HTTP-join path already emits this status
|
|
592
|
+
// unvalidated, so this keeps the WS path from being the stricter of the two.
|
|
593
|
+
return typeof st.pending === 'boolean';
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function isDocMetaFrame(frame: unknown): frame is DocMetaFrame {
|
|
597
|
+
if (typeof frame !== 'object' || frame === null) return false;
|
|
598
|
+
const f = frame as Record<string, unknown>;
|
|
599
|
+
return (
|
|
600
|
+
f.type === 'doc_meta' &&
|
|
601
|
+
typeof f.topic === 'string' &&
|
|
602
|
+
typeof f.name === 'string'
|
|
603
|
+
);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function isDocDriveStatusFrame(frame: unknown): frame is DocDriveStatusFrame {
|
|
607
|
+
if (typeof frame !== 'object' || frame === null) return false;
|
|
608
|
+
const f = frame as Record<string, unknown>;
|
|
609
|
+
if (f.type !== 'doc_drive_status' || typeof f.topic !== 'string') {
|
|
610
|
+
return false;
|
|
611
|
+
}
|
|
612
|
+
const d = f.drive;
|
|
613
|
+
if (typeof d !== 'object' || d === null) return false;
|
|
614
|
+
const dr = d as Record<string, unknown>;
|
|
615
|
+
// Gate on the discriminant (`drive_type`) + the affordance state
|
|
616
|
+
// (`tracking_state`). `broken` and the rest are read defensively in
|
|
617
|
+
// `toDriveStatus` (which now requires an explicit `false` to enable Publish),
|
|
618
|
+
// so a future partial drive update still updates `doc.driveStatus` instead of
|
|
619
|
+
// being dropped and leaving the sync/Publish surface stale.
|
|
620
|
+
return (
|
|
621
|
+
typeof dr.drive_type === 'string' && typeof dr.tracking_state === 'string'
|
|
622
|
+
);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function isDocOpsDroppedFrame(frame: unknown): frame is DocOpsDroppedFrame {
|
|
626
|
+
if (typeof frame !== 'object' || frame === null) return false;
|
|
627
|
+
const f = frame as Record<string, unknown>;
|
|
628
|
+
return (
|
|
629
|
+
f.type === 'doc_ops_dropped' &&
|
|
630
|
+
typeof f.topic === 'string' &&
|
|
631
|
+
Array.isArray(f.dropped_seqs) &&
|
|
632
|
+
typeof f.actor === 'string'
|
|
633
|
+
);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
function defaultClientOpIdMinter(): string {
|
|
637
|
+
const g = globalThis as { crypto?: { randomUUID?: () => string } };
|
|
638
|
+
if (g.crypto?.randomUUID) return g.crypto.randomUUID();
|
|
639
|
+
// Fallback for environments without `crypto.randomUUID`. Uniqueness is
|
|
640
|
+
// only needed SDK-side (correlate `op_ack` to the outstanding local op).
|
|
641
|
+
let s = '';
|
|
642
|
+
for (let i = 0; i < 32; i++) {
|
|
643
|
+
s += Math.floor(Math.random() * 16).toString(16);
|
|
644
|
+
}
|
|
645
|
+
return s;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function bytesToBase64(bytes: Uint8Array): string {
|
|
649
|
+
let bin = '';
|
|
650
|
+
for (let i = 0; i < bytes.byteLength; i++) {
|
|
651
|
+
bin += String.fromCharCode(bytes[i]!);
|
|
652
|
+
}
|
|
653
|
+
return btoa(bin);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// Uses `atob` (present in modern browsers, Node 20+, and jsdom) rather than
|
|
657
|
+
// Node-only `Buffer`, which would force a browser-bundle polyfill.
|
|
658
|
+
function base64ToBytes(b64: string): Uint8Array {
|
|
659
|
+
const bin = atob(b64);
|
|
660
|
+
const bytes = new Uint8Array(bin.length);
|
|
661
|
+
for (let i = 0; i < bin.length; i++) {
|
|
662
|
+
bytes[i] = bin.charCodeAt(i);
|
|
663
|
+
}
|
|
664
|
+
return bytes;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
function isSnapshotResponse(body: unknown): body is DocumentSnapshotResponse {
|
|
668
|
+
if (typeof body !== 'object' || body === null) return false;
|
|
669
|
+
const b = body as {
|
|
670
|
+
doc_id?: unknown;
|
|
671
|
+
branch_id?: unknown;
|
|
672
|
+
snapshot?: unknown;
|
|
673
|
+
tail_ops?: unknown;
|
|
674
|
+
};
|
|
675
|
+
if (typeof b.doc_id !== 'string') return false;
|
|
676
|
+
if (typeof b.branch_id !== 'string') return false;
|
|
677
|
+
if (!Array.isArray(b.tail_ops)) return false;
|
|
678
|
+
// snapshot validated structurally in materialise(); a null is fine.
|
|
679
|
+
return true;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
function isAtVersionResponse(
|
|
683
|
+
body: unknown,
|
|
684
|
+
): body is DocumentAtVersionResponse {
|
|
685
|
+
if (typeof body !== 'object' || body === null) return false;
|
|
686
|
+
const b = body as {
|
|
687
|
+
effective_version?: unknown;
|
|
688
|
+
branch_id?: unknown;
|
|
689
|
+
tail_ops?: unknown;
|
|
690
|
+
};
|
|
691
|
+
if (typeof b.effective_version !== 'number') return false;
|
|
692
|
+
if (typeof b.branch_id !== 'string') return false;
|
|
693
|
+
if (!Array.isArray(b.tail_ops)) return false;
|
|
694
|
+
// snapshot validated structurally in materialise(); a null is fine.
|
|
695
|
+
return true;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
export function createOpenDocument(
|
|
699
|
+
opts: OpenDocumentFactoryOptions,
|
|
700
|
+
): DocumentFactory {
|
|
701
|
+
const baseUrl = opts.apiBaseUrl.replace(/\/$/, '');
|
|
702
|
+
|
|
703
|
+
// Documents this session has answered the schema-conflict prompt for.
|
|
704
|
+
// Consent is per DOCUMENT, not per request: an open makes several joins
|
|
705
|
+
// (the branch refetch, the durability handshake, a resync), each building
|
|
706
|
+
// its own options from the branch it needs, and every one of them would
|
|
707
|
+
// otherwise be refused again after the person already said yes. Holding it
|
|
708
|
+
// here rather than threading it through each call site is what keeps a
|
|
709
|
+
// fourth one from silently dropping it.
|
|
710
|
+
const consented = new Set<string>();
|
|
711
|
+
|
|
712
|
+
async function fetchSnapshot(
|
|
713
|
+
docId: string,
|
|
714
|
+
options: OpenDocumentByIdOptions | undefined,
|
|
715
|
+
token: string,
|
|
716
|
+
): Promise<{ status: number; parsed: unknown; parseFailed: boolean }> {
|
|
717
|
+
const search = new URLSearchParams();
|
|
718
|
+
search.set('include', 'snapshot');
|
|
719
|
+
if (options?.filter !== undefined) {
|
|
720
|
+
search.set('filter', JSON.stringify(options.filter));
|
|
721
|
+
}
|
|
722
|
+
if (options?.branch !== undefined) {
|
|
723
|
+
search.set('branch', options.branch);
|
|
724
|
+
}
|
|
725
|
+
if (options?.stream !== undefined) {
|
|
726
|
+
search.set('stream', options.stream);
|
|
727
|
+
}
|
|
728
|
+
if (options?.acceptSchemaConflict === true) consented.add(docId);
|
|
729
|
+
if (consented.has(docId)) search.set('accept_schema_conflict', '1');
|
|
730
|
+
const url = `${baseUrl}/documents/${encodeURIComponent(docId)}?${search.toString()}`;
|
|
731
|
+
const headers: Record<string, string> = { authorization: `Bearer ${token}` };
|
|
732
|
+
applyClientVersionHeader(headers);
|
|
733
|
+
await applyAppAssertionHeader(headers, opts.getAppAssertion);
|
|
734
|
+
const endFetch = perfStart('open: GET /documents (http + read body)');
|
|
735
|
+
// Idempotent read: ride out a cold-API transient 5xx / 429 (a 503
|
|
736
|
+
// while the API scales into a concurrent open burst is the common
|
|
737
|
+
// one) instead of failing the open on a blip that clears in a second.
|
|
738
|
+
const response = await fetchWithRetry(() =>
|
|
739
|
+
fetch(url, {
|
|
740
|
+
method: 'GET',
|
|
741
|
+
headers,
|
|
742
|
+
}),
|
|
743
|
+
);
|
|
744
|
+
const text = await response.text();
|
|
745
|
+
endFetch({ status: response.status, responseChars: text.length });
|
|
746
|
+
let parsed: unknown = undefined;
|
|
747
|
+
let parseFailed = false;
|
|
748
|
+
if (text) {
|
|
749
|
+
try {
|
|
750
|
+
parsed = JSON.parse(text);
|
|
751
|
+
} catch {
|
|
752
|
+
parseFailed = true;
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
await resolveParsedTailOps(parsed);
|
|
756
|
+
return { status: response.status, parsed, parseFailed };
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// `GET /documents/{docId}/at/{version}`: read-only materialization at a
|
|
760
|
+
// historical version. Same auth + parse shape as `fetchSnapshot`.
|
|
761
|
+
async function fetchAtVersion(
|
|
762
|
+
docId: string,
|
|
763
|
+
version: number,
|
|
764
|
+
branch: string | undefined,
|
|
765
|
+
token: string,
|
|
766
|
+
): Promise<{ status: number; parsed: unknown; parseFailed: boolean }> {
|
|
767
|
+
const search = new URLSearchParams();
|
|
768
|
+
if (branch !== undefined) search.set('branch', branch);
|
|
769
|
+
const qs = search.toString();
|
|
770
|
+
const url =
|
|
771
|
+
`${baseUrl}/documents/${encodeURIComponent(docId)}` +
|
|
772
|
+
`/at/${encodeURIComponent(String(version))}` +
|
|
773
|
+
(qs ? `?${qs}` : '');
|
|
774
|
+
const headers: Record<string, string> = { authorization: `Bearer ${token}` };
|
|
775
|
+
applyClientVersionHeader(headers);
|
|
776
|
+
await applyAppAssertionHeader(headers, opts.getAppAssertion);
|
|
777
|
+
const response = await fetch(url, {
|
|
778
|
+
method: 'GET',
|
|
779
|
+
headers,
|
|
780
|
+
});
|
|
781
|
+
const text = await response.text();
|
|
782
|
+
let parsed: unknown = undefined;
|
|
783
|
+
let parseFailed = false;
|
|
784
|
+
if (text) {
|
|
785
|
+
try {
|
|
786
|
+
parsed = JSON.parse(text);
|
|
787
|
+
} catch {
|
|
788
|
+
parseFailed = true;
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
await resolveParsedTailOps(parsed);
|
|
792
|
+
return { status: response.status, parsed, parseFailed };
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
// The historical-materialise fetcher `doc._checkoutVersion(v)` calls back
|
|
796
|
+
// to, shared by the default Document and each opened stream line (both
|
|
797
|
+
// materialize a branch at a version through `GET .../at/{version}`). The
|
|
798
|
+
// `stream_seqs` in the response (present only on a default/branch preview
|
|
799
|
+
// that resolved a checkpoint, absent on a line's own preview) rides
|
|
800
|
+
// through so the parent can preview every opened stream at the same point.
|
|
801
|
+
function makeCheckoutFetcher(): (
|
|
802
|
+
id: string,
|
|
803
|
+
version: number,
|
|
804
|
+
branchId: string,
|
|
805
|
+
) => Promise<{
|
|
806
|
+
snapshotBytes: Uint8Array | null;
|
|
807
|
+
tailOpBlobs: Uint8Array[];
|
|
808
|
+
effectiveVersion: number;
|
|
809
|
+
streamSeqs?: Record<string, number>;
|
|
810
|
+
}> {
|
|
811
|
+
return async (id, version, branchId) => {
|
|
812
|
+
const t = await requireToken();
|
|
813
|
+
const result = await fetchAtVersion(id, version, branchId, t);
|
|
814
|
+
if (result.status < 200 || result.status >= 300) {
|
|
815
|
+
throw brassErrorFromBody(
|
|
816
|
+
result.status,
|
|
817
|
+
result.parseFailed ? undefined : result.parsed,
|
|
818
|
+
);
|
|
819
|
+
}
|
|
820
|
+
if (!isAtVersionResponse(result.parsed)) {
|
|
821
|
+
throw new BrassError('Malformed document at-version response', {
|
|
822
|
+
status: 500,
|
|
823
|
+
});
|
|
824
|
+
}
|
|
825
|
+
let snapshotBytes: Uint8Array | null = null;
|
|
826
|
+
const snap = result.parsed.snapshot;
|
|
827
|
+
if (snap !== null) {
|
|
828
|
+
if (snap.kind === 'inline') {
|
|
829
|
+
snapshotBytes = base64ToBytes(snap.data_b64);
|
|
830
|
+
} else if (snap.kind === 'presigned') {
|
|
831
|
+
const snapResponse = await fetch(snap.url, { method: 'GET' });
|
|
832
|
+
if (!snapResponse.ok) {
|
|
833
|
+
throw new BrassError('Presigned snapshot fetch failed', {
|
|
834
|
+
status: snapResponse.status,
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
const buf = await snapResponse.arrayBuffer();
|
|
838
|
+
snapshotBytes = new Uint8Array(buf);
|
|
839
|
+
} else {
|
|
840
|
+
// A delivery kind this build does not implement; refuse rather than
|
|
841
|
+
// preview an empty version (same rationale as `materialise`).
|
|
842
|
+
throw snapshotKindTooNewError();
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
const tailOpBlobs = result.parsed.tail_ops.map((entry) =>
|
|
846
|
+
base64ToBytes(entry.op_blob_b64),
|
|
847
|
+
);
|
|
848
|
+
const rawSeqs = (result.parsed as { stream_seqs?: unknown }).stream_seqs;
|
|
849
|
+
let streamSeqs: Record<string, number> | undefined;
|
|
850
|
+
if (rawSeqs && typeof rawSeqs === 'object' && !Array.isArray(rawSeqs)) {
|
|
851
|
+
const parsed: Record<string, number> = {};
|
|
852
|
+
for (const [lineId, seq] of Object.entries(rawSeqs)) {
|
|
853
|
+
if (typeof seq === 'number') parsed[lineId] = seq;
|
|
854
|
+
}
|
|
855
|
+
if (Object.keys(parsed).length > 0) streamSeqs = parsed;
|
|
856
|
+
}
|
|
857
|
+
return {
|
|
858
|
+
snapshotBytes,
|
|
859
|
+
tailOpBlobs,
|
|
860
|
+
effectiveVersion: result.parsed.effective_version,
|
|
861
|
+
...(streamSeqs ? { streamSeqs } : {}),
|
|
862
|
+
};
|
|
863
|
+
};
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// POSTs the context-create body to `/documents`. The server resolves the
|
|
867
|
+
// destination from the body (a `sibling_of` doc, the opaque `destination`
|
|
868
|
+
// the hosted picker returned, or the caller's single membership), so the
|
|
869
|
+
// SDK never names an org. Returns the raw status so `createDocumentId`
|
|
870
|
+
// can branch on the 409 destination / organization codes. An empty new
|
|
871
|
+
// doc has no snapshot, so the response is metadata-only and its doc_id is
|
|
872
|
+
// piped through `open` to materialise the editable Document.
|
|
873
|
+
// What the app hears when an op the socket could not carry also failed
|
|
874
|
+
// over HTTP. The server's message is preferred where it has one, since it
|
|
875
|
+
// names the actual refusal (too large for the durable row, a revoked
|
|
876
|
+
// role); the status-shaped fallbacks cover a response that carries none.
|
|
877
|
+
function opWriteFailureMessage(status: number, parsed: unknown): string {
|
|
878
|
+
const serverMessage = (parsed as { error?: unknown } | undefined)?.error;
|
|
879
|
+
if (typeof serverMessage === 'string' && serverMessage.length > 0) {
|
|
880
|
+
return serverMessage;
|
|
881
|
+
}
|
|
882
|
+
if (status === 403) return 'You do not have permission to edit this document.';
|
|
883
|
+
if (status === 404) return 'This document is no longer available.';
|
|
884
|
+
return `This change could not be saved (${status}).`;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
// Write one op over HTTP. The socket carries an op only up to API
|
|
888
|
+
// Gateway's message cap; past it the message is not delivered and the
|
|
889
|
+
// connection is closed, so `wireDocTransport` sends the large ones here.
|
|
890
|
+
// Not retried on a transient failure: the caller holds the op in its
|
|
891
|
+
// outstanding tracker and re-asserts it, and a blind replay would write a
|
|
892
|
+
// second durable row for the same edit.
|
|
893
|
+
// Mint a presigned PUT and upload the blob to it, returning the staging
|
|
894
|
+
// key the op write names. The PUT carries no Brass headers: the URL is
|
|
895
|
+
// signed, and an extra header falls outside that signature.
|
|
896
|
+
async function stageOpBlob(
|
|
897
|
+
docId: string,
|
|
898
|
+
opBlobB64: string,
|
|
899
|
+
token: string,
|
|
900
|
+
): Promise<string> {
|
|
901
|
+
const headers: Record<string, string> = {
|
|
902
|
+
authorization: `Bearer ${token}`,
|
|
903
|
+
'content-type': 'application/json',
|
|
904
|
+
};
|
|
905
|
+
applyClientVersionHeader(headers);
|
|
906
|
+
await applyAppAssertionHeader(headers, opts.getAppAssertion);
|
|
907
|
+
const mint = await fetch(
|
|
908
|
+
`${baseUrl}/documents/${encodeURIComponent(docId)}/ops/upload`,
|
|
909
|
+
{ method: 'POST', headers, body: '{}' },
|
|
910
|
+
);
|
|
911
|
+
if (!mint.ok) {
|
|
912
|
+
const text = await mint.text();
|
|
913
|
+
let parsed: unknown = undefined;
|
|
914
|
+
try {
|
|
915
|
+
parsed = text ? JSON.parse(text) : undefined;
|
|
916
|
+
} catch {
|
|
917
|
+
parsed = undefined;
|
|
918
|
+
}
|
|
919
|
+
throw brassErrorFromBody(mint.status, parsed);
|
|
920
|
+
}
|
|
921
|
+
const body = (await mint.json()) as { upload_key?: unknown; url?: unknown };
|
|
922
|
+
if (typeof body.upload_key !== 'string' || typeof body.url !== 'string') {
|
|
923
|
+
throw new BrassError('Malformed op upload response', { status: 500 });
|
|
924
|
+
}
|
|
925
|
+
const endUpload = perfStart('op: PUT staged op blob');
|
|
926
|
+
const put = await fetch(body.url, { method: 'PUT', body: opBlobB64 });
|
|
927
|
+
endUpload({ status: put.status, opChars: opBlobB64.length });
|
|
928
|
+
if (!put.ok) {
|
|
929
|
+
throw new BrassError('Could not upload the update', { status: put.status });
|
|
930
|
+
}
|
|
931
|
+
return body.upload_key;
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
async function postDocumentOp(
|
|
935
|
+
docId: string,
|
|
936
|
+
branchId: string,
|
|
937
|
+
opBlobB64: string,
|
|
938
|
+
clientOpId: string,
|
|
939
|
+
token: string,
|
|
940
|
+
): Promise<{ status: number; parsed: unknown }> {
|
|
941
|
+
const headers: Record<string, string> = {
|
|
942
|
+
authorization: `Bearer ${token}`,
|
|
943
|
+
'content-type': 'application/json',
|
|
944
|
+
};
|
|
945
|
+
applyClientVersionHeader(headers);
|
|
946
|
+
await applyAppAssertionHeader(headers, opts.getAppAssertion);
|
|
947
|
+
// Past what a durable row holds, the blob goes to storage directly
|
|
948
|
+
// and the write names the staged object, so a multi-megabyte op
|
|
949
|
+
// never travels through a request body.
|
|
950
|
+
let stagedKey: string | undefined;
|
|
951
|
+
if (opBlobNeedsStaging(opBlobB64)) {
|
|
952
|
+
stagedKey = await stageOpBlob(docId, opBlobB64, token);
|
|
953
|
+
}
|
|
954
|
+
const endFetch = perfStart('op: POST /documents/{id}/ops');
|
|
955
|
+
const response = await fetch(
|
|
956
|
+
`${baseUrl}/documents/${encodeURIComponent(docId)}/ops`,
|
|
957
|
+
{
|
|
958
|
+
method: 'POST',
|
|
959
|
+
headers,
|
|
960
|
+
body: JSON.stringify({
|
|
961
|
+
...(stagedKey === undefined
|
|
962
|
+
? { op_blob: opBlobB64 }
|
|
963
|
+
: { upload_key: stagedKey }),
|
|
964
|
+
branch_id: branchId,
|
|
965
|
+
client_op_id: clientOpId,
|
|
966
|
+
}),
|
|
967
|
+
},
|
|
968
|
+
);
|
|
969
|
+
const text = await response.text();
|
|
970
|
+
endFetch({ status: response.status, opChars: opBlobB64.length });
|
|
971
|
+
let parsed: unknown = undefined;
|
|
972
|
+
try {
|
|
973
|
+
parsed = text ? JSON.parse(text) : undefined;
|
|
974
|
+
} catch {
|
|
975
|
+
parsed = undefined;
|
|
976
|
+
}
|
|
977
|
+
return { status: response.status, parsed };
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
async function postCreateDocument(
|
|
981
|
+
args: CreateDocumentArgs,
|
|
982
|
+
token: string,
|
|
983
|
+
): Promise<{ status: number; parsed: unknown; parseFailed: boolean }> {
|
|
984
|
+
// `creating_app_id` powers the dashboard's "Open in {appName}"
|
|
985
|
+
// affordance and is the Layer 2 scope subject; re-derived from the
|
|
986
|
+
// verified app assertion server-side.
|
|
987
|
+
const body: Record<string, unknown> = { creating_app_id: opts.appId };
|
|
988
|
+
if (args.name !== undefined) body['name'] = args.name;
|
|
989
|
+
if (args.app_metadata !== undefined) body['app_metadata'] = args.app_metadata;
|
|
990
|
+
if (args.siblingOf !== undefined) body['sibling_of'] = args.siblingOf;
|
|
991
|
+
// Relayed verbatim: the SDK never reads the destination's fields, so
|
|
992
|
+
// the hierarchy shape can grow server-side without an SDK change.
|
|
993
|
+
if (args.destination !== undefined) body['destination'] = args.destination;
|
|
994
|
+
// Idempotency key: one per logical create, minted BEFORE the retry loop so
|
|
995
|
+
// every replay carries the SAME key. The server records the first create
|
|
996
|
+
// under it and returns that same document for any later request with the
|
|
997
|
+
// key, so a replay after a lost response never creates a second document.
|
|
998
|
+
body['idempotency_key'] = defaultClientOpIdMinter();
|
|
999
|
+
const headers: Record<string, string> = {
|
|
1000
|
+
authorization: `Bearer ${token}`,
|
|
1001
|
+
'content-type': 'application/json',
|
|
1002
|
+
};
|
|
1003
|
+
applyClientVersionHeader(headers);
|
|
1004
|
+
await applyAppAssertionHeader(headers, opts.getAppAssertion);
|
|
1005
|
+
// The create carries an idempotency key, so a replay the server already
|
|
1006
|
+
// applied returns the original document instead of a second one. That
|
|
1007
|
+
// makes the full transient set (and a network throw) replay-safe here,
|
|
1008
|
+
// not just a pre-handler 503 (`RETRY_IDEMPOTENT_WRITE`). Resilience
|
|
1009
|
+
// matters most on create because a 503 that swallowed it would also
|
|
1010
|
+
// swallow the `app_not_installed` consent flow that rides the response.
|
|
1011
|
+
const response = await fetchWithRetry(
|
|
1012
|
+
() =>
|
|
1013
|
+
fetch(`${baseUrl}/documents`, {
|
|
1014
|
+
method: 'POST',
|
|
1015
|
+
headers,
|
|
1016
|
+
body: JSON.stringify(body),
|
|
1017
|
+
}),
|
|
1018
|
+
{ policy: RETRY_IDEMPOTENT_WRITE, baseDelayMs: 250 },
|
|
1019
|
+
);
|
|
1020
|
+
const text = await response.text();
|
|
1021
|
+
let parsed: unknown = undefined;
|
|
1022
|
+
let parseFailed = false;
|
|
1023
|
+
if (text) {
|
|
1024
|
+
try {
|
|
1025
|
+
parsed = JSON.parse(text);
|
|
1026
|
+
} catch {
|
|
1027
|
+
parseFailed = true;
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
return { status: response.status, parsed, parseFailed };
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
// Create-only (no open). Classifies the response so a caller can react to
|
|
1034
|
+
// a needed destination / organization before showing a loading state; any
|
|
1035
|
+
// other non-2xx throws.
|
|
1036
|
+
//
|
|
1037
|
+
// A create that names no placement of its own inherits the organization
|
|
1038
|
+
// the page was launched from, so a member of several organizations who
|
|
1039
|
+
// opened this app from one of them is not asked to name a destination
|
|
1040
|
+
// they already chose. An explicit `siblingOf` / `destination` outranks it:
|
|
1041
|
+
// placing a document beside the open one is the more specific answer.
|
|
1042
|
+
const createDocumentId = async (
|
|
1043
|
+
args: CreateDocumentArgs,
|
|
1044
|
+
): Promise<CreateDocumentResult> => {
|
|
1045
|
+
const launchOrganizationId =
|
|
1046
|
+
args.siblingOf === undefined && args.destination === undefined
|
|
1047
|
+
? (opts.launchOrganizationId?.() ?? null)
|
|
1048
|
+
: null;
|
|
1049
|
+
if (launchOrganizationId === null) return postAndClassify(args);
|
|
1050
|
+
try {
|
|
1051
|
+
return await postAndClassify({
|
|
1052
|
+
...args,
|
|
1053
|
+
destination: { organization_id: launchOrganizationId },
|
|
1054
|
+
});
|
|
1055
|
+
} catch (err) {
|
|
1056
|
+
// The launch organization is a hint carried on a URL, so it can name
|
|
1057
|
+
// an organization the person has since left, which the server answers
|
|
1058
|
+
// with a plain 404. Drop the hint and let the server resolve the
|
|
1059
|
+
// destination on its own, which routes a multi-organization caller to
|
|
1060
|
+
// the picker. A coded rejection (`app_not_installed`) belongs to the
|
|
1061
|
+
// caller's own consent flow and passes through untouched, as does the
|
|
1062
|
+
// 404 for an organization that exists but denies the app.
|
|
1063
|
+
if (err instanceof BrassError && err.status === 404 && !err.code) {
|
|
1064
|
+
return postAndClassify(args);
|
|
1065
|
+
}
|
|
1066
|
+
throw err;
|
|
1067
|
+
}
|
|
1068
|
+
};
|
|
1069
|
+
|
|
1070
|
+
// One POST plus its classification. Each call mints its own idempotency
|
|
1071
|
+
// key inside `postCreateDocument`, so a second call after a rejected
|
|
1072
|
+
// first cannot land a second document.
|
|
1073
|
+
const postAndClassify = async (
|
|
1074
|
+
args: CreateDocumentArgs,
|
|
1075
|
+
): Promise<CreateDocumentResult> => {
|
|
1076
|
+
const token = await requireToken();
|
|
1077
|
+
// `postCreateDocument` owns the idempotency-keyed write replay
|
|
1078
|
+
// (`RETRY_IDEMPOTENT_WRITE`), so a retried create never double-applies.
|
|
1079
|
+
const created = await postCreateDocument(args, token);
|
|
1080
|
+
if (created.status >= 200 && created.status < 300) {
|
|
1081
|
+
const docId =
|
|
1082
|
+
typeof created.parsed === 'object' &&
|
|
1083
|
+
created.parsed !== null &&
|
|
1084
|
+
typeof (created.parsed as { doc_id?: unknown }).doc_id === 'string'
|
|
1085
|
+
? (created.parsed as { doc_id: string }).doc_id
|
|
1086
|
+
: null;
|
|
1087
|
+
if (!docId) {
|
|
1088
|
+
throw new BrassError('Malformed createDocument response', { status: 500 });
|
|
1089
|
+
}
|
|
1090
|
+
return { kind: 'created', docId };
|
|
1091
|
+
}
|
|
1092
|
+
if (created.status === 409 && !created.parseFailed) {
|
|
1093
|
+
const code = (created.parsed as { code?: unknown })?.code;
|
|
1094
|
+
if (code === 'destination_required') return { kind: 'needs-destination' };
|
|
1095
|
+
if (code === 'no_organization') return { kind: 'needs-organization' };
|
|
1096
|
+
}
|
|
1097
|
+
throw brassErrorFromBody(
|
|
1098
|
+
created.status,
|
|
1099
|
+
created.parseFailed ? undefined : created.parsed,
|
|
1100
|
+
);
|
|
1101
|
+
};
|
|
1102
|
+
|
|
1103
|
+
async function requireToken(): Promise<string> {
|
|
1104
|
+
const token = await opts.getAccessToken();
|
|
1105
|
+
if (!token) {
|
|
1106
|
+
// Reject before fetch so callers get a deterministic 401 with no
|
|
1107
|
+
// network round-trip (matches the projects / users namespaces).
|
|
1108
|
+
throw new BrassError('Not signed in', { status: 401 });
|
|
1109
|
+
}
|
|
1110
|
+
return token;
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
// Open one stream LINE: the per-stream sibling of the document open.
|
|
1114
|
+
// Joins the line over the same HTTP join (`?include=snapshot&branch=
|
|
1115
|
+
// <lineId>`), imports its (usually much smaller) snapshot, and wires
|
|
1116
|
+
// the same op transport on the line's own topic. Deliberately lean
|
|
1117
|
+
// next to `finalise`: no presence, no branches fetch, no thumbnails /
|
|
1118
|
+
// checkpoints / commands, and no park cache; a stream's extra cost
|
|
1119
|
+
// over its content is one join + one subscribe, which is what keeps
|
|
1120
|
+
// per-stream opens proportional to what an app touches.
|
|
1121
|
+
async function openStreamLine(
|
|
1122
|
+
docId: string,
|
|
1123
|
+
lineId: string,
|
|
1124
|
+
): Promise<Document> {
|
|
1125
|
+
const token = await requireToken();
|
|
1126
|
+
const { status, parsed, parseFailed } = await fetchSnapshot(
|
|
1127
|
+
docId,
|
|
1128
|
+
{ branch: lineId },
|
|
1129
|
+
token,
|
|
1130
|
+
);
|
|
1131
|
+
if (status < 200 || status >= 300) {
|
|
1132
|
+
throw brassErrorFromBody(status, parseFailed ? undefined : parsed);
|
|
1133
|
+
}
|
|
1134
|
+
if (!isSnapshotResponse(parsed)) {
|
|
1135
|
+
throw new BrassError('Malformed document snapshot response', {
|
|
1136
|
+
status: 500,
|
|
1137
|
+
});
|
|
1138
|
+
}
|
|
1139
|
+
const doc = await materialise(parsed);
|
|
1140
|
+
// A stream line materializes at a historical seq the same way the
|
|
1141
|
+
// default line does, so the parent's version preview can bring this
|
|
1142
|
+
// stream to a point in time (`doc.stream(name)._previewAt(seq)`). The
|
|
1143
|
+
// line's own timeline carries no checkpoints, so its at-version response
|
|
1144
|
+
// resolves no `stream_seqs` (only the doc-level preview does).
|
|
1145
|
+
doc.setCheckoutFetcher(makeCheckoutFetcher());
|
|
1146
|
+
|
|
1147
|
+
// Transport cursors, the same shape `finalise` keeps for the default
|
|
1148
|
+
// line (see the comments there); scoped per line because each stream
|
|
1149
|
+
// has its own seq space.
|
|
1150
|
+
let currentSeq = Math.max(
|
|
1151
|
+
parsed.tail_ops.reduce(
|
|
1152
|
+
(acc, entry) => (entry.seq > acc ? entry.seq : acc),
|
|
1153
|
+
0,
|
|
1154
|
+
),
|
|
1155
|
+
typeof parsed.current_seq === 'number' ? parsed.current_seq : 0,
|
|
1156
|
+
);
|
|
1157
|
+
let coveredSeq =
|
|
1158
|
+
typeof parsed.current_seq === 'number' ? parsed.current_seq : 0;
|
|
1159
|
+
const coverageParked = new Set<number>();
|
|
1160
|
+
const advanceCoverage = (seq: number): void => {
|
|
1161
|
+
if (seq <= coveredSeq) return;
|
|
1162
|
+
coverageParked.add(seq);
|
|
1163
|
+
while (coverageParked.has(coveredSeq + 1)) {
|
|
1164
|
+
coverageParked.delete(coveredSeq + 1);
|
|
1165
|
+
coveredSeq += 1;
|
|
1166
|
+
}
|
|
1167
|
+
};
|
|
1168
|
+
for (const entry of parsed.tail_ops) advanceCoverage(entry.seq);
|
|
1169
|
+
|
|
1170
|
+
// Reconnect-across-compaction recovery: re-import the line's join.
|
|
1171
|
+
// Loro imports are idempotent, so re-applying over live state is a
|
|
1172
|
+
// clean merge.
|
|
1173
|
+
const resyncFromSnapshot = (): void => {
|
|
1174
|
+
void (async () => {
|
|
1175
|
+
try {
|
|
1176
|
+
const t = await requireToken();
|
|
1177
|
+
const res = await fetchSnapshot(docId, { branch: lineId }, t);
|
|
1178
|
+
if (res.status < 200 || res.status >= 300) return;
|
|
1179
|
+
if (!isSnapshotResponse(res.parsed)) return;
|
|
1180
|
+
const snap = res.parsed.snapshot;
|
|
1181
|
+
if (snap !== null) {
|
|
1182
|
+
const bytes =
|
|
1183
|
+
snap.kind === 'inline'
|
|
1184
|
+
? base64ToBytes(snap.data_b64)
|
|
1185
|
+
: await fetchPresignedSnapshot(snap.url);
|
|
1186
|
+
doc._applySnapshot(bytes);
|
|
1187
|
+
}
|
|
1188
|
+
for (const entry of res.parsed.tail_ops) {
|
|
1189
|
+
doc.applyRemoteOp(base64ToBytes(entry.op_blob_b64));
|
|
1190
|
+
if (entry.seq > currentSeq) currentSeq = entry.seq;
|
|
1191
|
+
advanceCoverage(entry.seq);
|
|
1192
|
+
}
|
|
1193
|
+
const joined = res.parsed.current_seq;
|
|
1194
|
+
if (typeof joined === 'number') {
|
|
1195
|
+
if (joined > currentSeq) currentSeq = joined;
|
|
1196
|
+
if (joined > coveredSeq) {
|
|
1197
|
+
coveredSeq = joined;
|
|
1198
|
+
for (const seq of coverageParked) {
|
|
1199
|
+
if (seq <= coveredSeq) coverageParked.delete(seq);
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
} catch {
|
|
1204
|
+
// Best-effort; the next doc_init retries.
|
|
1205
|
+
}
|
|
1206
|
+
})();
|
|
1207
|
+
};
|
|
1208
|
+
|
|
1209
|
+
// Write an op the socket cannot carry. The op stays one commit and
|
|
1210
|
+
// one seq; only the transport differs, and the response carries the
|
|
1211
|
+
// seq an `op_ack` would have.
|
|
1212
|
+
const postOversizedOp = async (
|
|
1213
|
+
opBlobB64: string,
|
|
1214
|
+
clientOpId: string,
|
|
1215
|
+
): Promise<{ ok: true; seq: number } | { ok: false; message: string }> => {
|
|
1216
|
+
const token = await requireToken();
|
|
1217
|
+
const { status, parsed: opResult } = await postDocumentOp(
|
|
1218
|
+
docId,
|
|
1219
|
+
lineId,
|
|
1220
|
+
opBlobB64,
|
|
1221
|
+
clientOpId,
|
|
1222
|
+
token,
|
|
1223
|
+
);
|
|
1224
|
+
if (status < 200 || status >= 300) {
|
|
1225
|
+
return { ok: false, message: opWriteFailureMessage(status, opResult) };
|
|
1226
|
+
}
|
|
1227
|
+
const seq = (opResult as { seq?: unknown } | undefined)?.seq;
|
|
1228
|
+
if (typeof seq !== 'number') {
|
|
1229
|
+
return { ok: false, message: 'The server did not report a version for this change.' };
|
|
1230
|
+
}
|
|
1231
|
+
return { ok: true, seq };
|
|
1232
|
+
};
|
|
1233
|
+
|
|
1234
|
+
if (opts.wsClient) {
|
|
1235
|
+
wireDocTransport({
|
|
1236
|
+
doc,
|
|
1237
|
+
docId,
|
|
1238
|
+
branchId: lineId,
|
|
1239
|
+
wsClient: opts.wsClient,
|
|
1240
|
+
postOversizedOp,
|
|
1241
|
+
newClientOpId: opts.newClientOpId ?? defaultClientOpIdMinter,
|
|
1242
|
+
getCurrentSeq: () => currentSeq,
|
|
1243
|
+
setCurrentSeq: (next) => {
|
|
1244
|
+
if (next > currentSeq) currentSeq = next;
|
|
1245
|
+
},
|
|
1246
|
+
getCoveredSeq: () => coveredSeq,
|
|
1247
|
+
advanceCoveredSeq: advanceCoverage,
|
|
1248
|
+
resyncFromSnapshot,
|
|
1249
|
+
skipPresence: true,
|
|
1250
|
+
parkable: false,
|
|
1251
|
+
});
|
|
1252
|
+
}
|
|
1253
|
+
return doc;
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
async function finalise(
|
|
1257
|
+
docId: string,
|
|
1258
|
+
snapshot: { status: number; parsed: unknown; parseFailed: boolean },
|
|
1259
|
+
options: OpenDocumentByIdOptions | undefined,
|
|
1260
|
+
): Promise<OpenedDocument> {
|
|
1261
|
+
const { status, parsed, parseFailed } = snapshot;
|
|
1262
|
+
if (status < 200 || status >= 300) {
|
|
1263
|
+
throw brassErrorFromBody(status, parseFailed ? undefined : parsed);
|
|
1264
|
+
}
|
|
1265
|
+
if (!isSnapshotResponse(parsed)) {
|
|
1266
|
+
throw new BrassError('Malformed document snapshot response', { status: 500 });
|
|
1267
|
+
}
|
|
1268
|
+
const doc = await materialise(
|
|
1269
|
+
parsed,
|
|
1270
|
+
options?.documentOptions,
|
|
1271
|
+
options?.onSnapshotProgress,
|
|
1272
|
+
);
|
|
1273
|
+
// Populate `doc.status` from the join before the first `doc_status` frame
|
|
1274
|
+
// (a stub opened mid-hydration returns `snapshot: null` + a `pending`
|
|
1275
|
+
// status).
|
|
1276
|
+
if (parsed.status !== undefined) doc._emitStatus(parsed.status);
|
|
1277
|
+
// Seed `doc.driveStatus` from the join before the first
|
|
1278
|
+
// `doc_drive_status` frame.
|
|
1279
|
+
if (parsed.drive !== undefined) {
|
|
1280
|
+
doc._emitDriveStatus(toDriveStatus(parsed.drive));
|
|
1281
|
+
}
|
|
1282
|
+
// Highest seq seen; later `doc_op` / `op_ack` frames advance it. Kept in
|
|
1283
|
+
// a closure, not on the Document: it is a transport concern, not CRDT
|
|
1284
|
+
// state.
|
|
1285
|
+
let currentSeq = Math.max(
|
|
1286
|
+
parsed.tail_ops.reduce(
|
|
1287
|
+
(acc, entry) => (entry.seq > acc ? entry.seq : acc),
|
|
1288
|
+
0,
|
|
1289
|
+
),
|
|
1290
|
+
typeof parsed.current_seq === 'number' ? parsed.current_seq : 0,
|
|
1291
|
+
);
|
|
1292
|
+
|
|
1293
|
+
// Contiguous-coverage floor: every op with seq <= coveredSeq is applied
|
|
1294
|
+
// (or never existed). Separate from `currentSeq` (max seq SEEN) because
|
|
1295
|
+
// out-of-order fanout can inflate a max-seen cursor past a hole, which
|
|
1296
|
+
// then can't tell "caught up" from "missed ops since compacted into the
|
|
1297
|
+
// snapshot". The floor never overestimates, so the `doc_init` resync
|
|
1298
|
+
// check always detects such a hole (at worst a transient out-of-order
|
|
1299
|
+
// arrival costs one idempotent re-fetch). Seeded from the consistent-read
|
|
1300
|
+
// join's `current_seq`.
|
|
1301
|
+
let coveredSeq =
|
|
1302
|
+
typeof parsed.current_seq === 'number' ? parsed.current_seq : 0;
|
|
1303
|
+
// Seqs applied above the floor, parked until the gap below fills.
|
|
1304
|
+
const coverageParked = new Set<number>();
|
|
1305
|
+
const drainCoverage = (): void => {
|
|
1306
|
+
while (coverageParked.has(coveredSeq + 1)) {
|
|
1307
|
+
coverageParked.delete(coveredSeq + 1);
|
|
1308
|
+
coveredSeq += 1;
|
|
1309
|
+
}
|
|
1310
|
+
};
|
|
1311
|
+
const advanceCoverage = (seq: number): void => {
|
|
1312
|
+
if (seq <= coveredSeq) return;
|
|
1313
|
+
coverageParked.add(seq);
|
|
1314
|
+
drainCoverage();
|
|
1315
|
+
};
|
|
1316
|
+
// Mark everything at or below `floor` covered (a full consistent join was
|
|
1317
|
+
// imported). Also steps the floor past any abandoned server-side seq
|
|
1318
|
+
// (allocated, never written) that `advanceCoverage` would stall under.
|
|
1319
|
+
const raiseCoverage = (floor: number): void => {
|
|
1320
|
+
if (floor > coveredSeq) coveredSeq = floor;
|
|
1321
|
+
for (const seq of coverageParked) {
|
|
1322
|
+
if (seq <= coveredSeq) coverageParked.delete(seq);
|
|
1323
|
+
}
|
|
1324
|
+
drainCoverage();
|
|
1325
|
+
};
|
|
1326
|
+
for (const entry of parsed.tail_ops) advanceCoverage(entry.seq);
|
|
1327
|
+
|
|
1328
|
+
// Re-fetch the join snapshot and import it into the live doc. Shared by
|
|
1329
|
+
// the authoritative reload and the subscribe-time reconcile; returns the
|
|
1330
|
+
// refreshed work-status (so reconcile tells pending / failed / succeeded
|
|
1331
|
+
// apart) and a failure `reason` naming the broken step. `_applySnapshot`
|
|
1332
|
+
// clears the undo stack; the doc was held read-only (empty) while
|
|
1333
|
+
// pending, so this is a clean merge. Idempotent: re-importing the same
|
|
1334
|
+
// snapshot / ops are no-ops in Loro.
|
|
1335
|
+
const refetchAndImport = async (): Promise<
|
|
1336
|
+
| { ok: true; status: DocumentWorkStatus | undefined }
|
|
1337
|
+
| { ok: false; reason: string }
|
|
1338
|
+
> => {
|
|
1339
|
+
const endPerf = perfStart('doc: content reload (fetch + import)');
|
|
1340
|
+
const token = await requireToken();
|
|
1341
|
+
// Refetch the branch this doc was opened on, not the default. `parsed`
|
|
1342
|
+
// is the opened-branch join, so its `branch_id` is authoritative;
|
|
1343
|
+
// dropping it would import the default branch's snapshot + tail into a
|
|
1344
|
+
// doc open on another branch and silently merge the wrong CRDT state.
|
|
1345
|
+
const result = await fetchSnapshot(docId, { branch: parsed.branch_id }, token);
|
|
1346
|
+
if (result.status < 200 || result.status >= 300) {
|
|
1347
|
+
endPerf({ ok: false, joinStatus: result.status });
|
|
1348
|
+
return { ok: false, reason: `join fetch returned ${result.status}` };
|
|
1349
|
+
}
|
|
1350
|
+
if (!isSnapshotResponse(result.parsed)) {
|
|
1351
|
+
endPerf({ ok: false, joinStatus: result.status });
|
|
1352
|
+
return { ok: false, reason: 'malformed join response' };
|
|
1353
|
+
}
|
|
1354
|
+
const snap = result.parsed.snapshot;
|
|
1355
|
+
let snapshotBytes: Uint8Array | null = null;
|
|
1356
|
+
if (snap !== null && snap.kind === 'inline') {
|
|
1357
|
+
snapshotBytes = base64ToBytes(snap.data_b64);
|
|
1358
|
+
} else if (snap !== null && snap.kind === 'presigned') {
|
|
1359
|
+
// Stream through the same progress sink the initial open uses, so the
|
|
1360
|
+
// session surfaces "Downloading... NN%" instead of sitting on the
|
|
1361
|
+
// last server phase.
|
|
1362
|
+
try {
|
|
1363
|
+
snapshotBytes = await fetchPresignedSnapshot(
|
|
1364
|
+
snap.url,
|
|
1365
|
+
options?.onSnapshotProgress,
|
|
1366
|
+
);
|
|
1367
|
+
} catch (err) {
|
|
1368
|
+
const status = err instanceof BrassError ? err.status ?? 0 : 0;
|
|
1369
|
+
endPerf({ ok: false, snapshotStatus: status });
|
|
1370
|
+
return {
|
|
1371
|
+
ok: false,
|
|
1372
|
+
reason:
|
|
1373
|
+
status > 0
|
|
1374
|
+
? `presigned snapshot fetch returned ${status}`
|
|
1375
|
+
: `presigned snapshot fetch failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
1376
|
+
};
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
if (snapshotBytes) doc._applySnapshot(snapshotBytes);
|
|
1380
|
+
const sortedTail = [...result.parsed.tail_ops].sort(
|
|
1381
|
+
(a, b) => a.seq - b.seq,
|
|
1382
|
+
);
|
|
1383
|
+
let maxTailSeq = 0;
|
|
1384
|
+
for (const entry of sortedTail) {
|
|
1385
|
+
doc.applyRemoteOp(base64ToBytes(entry.op_blob_b64));
|
|
1386
|
+
if (entry.seq > currentSeq) currentSeq = entry.seq;
|
|
1387
|
+
if (entry.seq > maxTailSeq) maxTailSeq = entry.seq;
|
|
1388
|
+
}
|
|
1389
|
+
// Raise both cursors so the floor reflects the import (this clears a
|
|
1390
|
+
// detected missed-compaction hole).
|
|
1391
|
+
const fetchedSeq =
|
|
1392
|
+
typeof result.parsed.current_seq === 'number'
|
|
1393
|
+
? result.parsed.current_seq
|
|
1394
|
+
: maxTailSeq;
|
|
1395
|
+
if (fetchedSeq > currentSeq) currentSeq = fetchedSeq;
|
|
1396
|
+
raiseCoverage(fetchedSeq);
|
|
1397
|
+
endPerf({
|
|
1398
|
+
ok: true,
|
|
1399
|
+
snapshotBytes: snapshotBytes?.byteLength ?? 0,
|
|
1400
|
+
tailOps: sortedTail.length,
|
|
1401
|
+
});
|
|
1402
|
+
return { ok: true, status: result.parsed.status };
|
|
1403
|
+
};
|
|
1404
|
+
|
|
1405
|
+
// Fetch just the durability-handshake ground truth from the HTTP join:
|
|
1406
|
+
// the persisted snapshot version vector and the durable tail op blobs.
|
|
1407
|
+
// Unlike `refetchAndImport` this downloads no snapshot blob and imports
|
|
1408
|
+
// nothing (the handshake only needs the frontier to diff against), so it
|
|
1409
|
+
// is one cheap JSON round-trip.
|
|
1410
|
+
const refetchDurableFrontier = async (): Promise<{
|
|
1411
|
+
snapshotVersionVector?: string;
|
|
1412
|
+
tailBlobs: Uint8Array[];
|
|
1413
|
+
} | null> => {
|
|
1414
|
+
const token = await requireToken();
|
|
1415
|
+
// Same-branch as `refetchAndImport`: the durability handshake must diff
|
|
1416
|
+
// against the opened branch's frontier, not the default branch's.
|
|
1417
|
+
const result = await fetchSnapshot(docId, { branch: parsed.branch_id }, token);
|
|
1418
|
+
if (result.status < 200 || result.status >= 300) return null;
|
|
1419
|
+
if (!isSnapshotResponse(result.parsed)) return null;
|
|
1420
|
+
const tailBlobs = result.parsed.tail_ops.map((entry) =>
|
|
1421
|
+
base64ToBytes(entry.op_blob_b64),
|
|
1422
|
+
);
|
|
1423
|
+
const out: { snapshotVersionVector?: string; tailBlobs: Uint8Array[] } = {
|
|
1424
|
+
tailBlobs,
|
|
1425
|
+
};
|
|
1426
|
+
if (typeof result.parsed.snapshot_version_vector === 'string') {
|
|
1427
|
+
out.snapshotVersionVector = result.parsed.snapshot_version_vector;
|
|
1428
|
+
}
|
|
1429
|
+
return out;
|
|
1430
|
+
};
|
|
1431
|
+
|
|
1432
|
+
let resyncInFlight = false;
|
|
1433
|
+
const resyncFromSnapshot = (): void => {
|
|
1434
|
+
if (resyncInFlight) return;
|
|
1435
|
+
resyncInFlight = true;
|
|
1436
|
+
void (async () => {
|
|
1437
|
+
try {
|
|
1438
|
+
const outcome = await refetchAndImport();
|
|
1439
|
+
if (!outcome.ok) {
|
|
1440
|
+
console.error(
|
|
1441
|
+
'Brass: document resync after reconnect failed; state may lag until the next reconnect or reload',
|
|
1442
|
+
);
|
|
1443
|
+
}
|
|
1444
|
+
} catch (err) {
|
|
1445
|
+
console.error(
|
|
1446
|
+
'Brass: document resync after reconnect failed; state may lag until the next reconnect or reload',
|
|
1447
|
+
err,
|
|
1448
|
+
);
|
|
1449
|
+
} finally {
|
|
1450
|
+
resyncInFlight = false;
|
|
1451
|
+
}
|
|
1452
|
+
})();
|
|
1453
|
+
};
|
|
1454
|
+
|
|
1455
|
+
// When a doc opened mid-load finishes, pull the now-available content
|
|
1456
|
+
// into the live doc so it appears without the app reopening. Only ever
|
|
1457
|
+
// called on the server's authoritative `reload` hint, so it never has to
|
|
1458
|
+
// tell success from failure: a failure carries no `reload`, so no racy
|
|
1459
|
+
// read and no loop against a failed hydration.
|
|
1460
|
+
//
|
|
1461
|
+
// A failed fetch here is transient (network, dropped presigned download)
|
|
1462
|
+
// and is retried with backoff: a silent give-up would strand the doc
|
|
1463
|
+
// empty-but-editable, reading as "the model never loaded" and invisible
|
|
1464
|
+
// in every log. On exhaustion the failure is reported and the terminal
|
|
1465
|
+
// status is an honest failure, not the server's success copy.
|
|
1466
|
+
let reloadInFlight = false;
|
|
1467
|
+
const RELOAD_ATTEMPTS = 5;
|
|
1468
|
+
const RELOAD_BACKOFF_MS = 1000;
|
|
1469
|
+
const reloadAfterHydration = (finalStatus: DocumentWorkStatus): void => {
|
|
1470
|
+
if (reloadInFlight) return;
|
|
1471
|
+
reloadInFlight = true;
|
|
1472
|
+
void (async () => {
|
|
1473
|
+
let lastError: Error | null = null;
|
|
1474
|
+
for (let attempt = 1; attempt <= RELOAD_ATTEMPTS; attempt += 1) {
|
|
1475
|
+
try {
|
|
1476
|
+
const outcome = await refetchAndImport();
|
|
1477
|
+
if (outcome.ok) {
|
|
1478
|
+
reloadInFlight = false;
|
|
1479
|
+
// Emit the terminal status AFTER the import settles, so an app
|
|
1480
|
+
// re-reading on `onStatus` sees the content present and (with
|
|
1481
|
+
// `readOnly` now false) clears its loading overlay.
|
|
1482
|
+
doc._emitStatus(finalStatus);
|
|
1483
|
+
return;
|
|
1484
|
+
}
|
|
1485
|
+
lastError = new Error(outcome.reason);
|
|
1486
|
+
} catch (err) {
|
|
1487
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
1488
|
+
}
|
|
1489
|
+
// A retrying attempt is exceptional, so this warn is quiet in
|
|
1490
|
+
// normal operation and decisive in a bug report; without it a
|
|
1491
|
+
// failure here is undiagnosable from any log.
|
|
1492
|
+
console.warn(
|
|
1493
|
+
`[Brass] document content reload attempt ${attempt}/${RELOAD_ATTEMPTS} failed (doc ${docId}): ${lastError.message}`,
|
|
1494
|
+
);
|
|
1495
|
+
if (attempt < RELOAD_ATTEMPTS) {
|
|
1496
|
+
await new Promise((r) => setTimeout(r, RELOAD_BACKOFF_MS * attempt));
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
reloadInFlight = false;
|
|
1500
|
+
const error = new BrassError(
|
|
1501
|
+
`Document content reload failed after ${RELOAD_ATTEMPTS} attempts: ${lastError?.message ?? 'unknown error'}`,
|
|
1502
|
+
{ status: 0 },
|
|
1503
|
+
);
|
|
1504
|
+
if (opts.reportError) opts.reportError(error);
|
|
1505
|
+
else console.error('[Brass]', error);
|
|
1506
|
+
doc._emitStatus({
|
|
1507
|
+
message: 'The model could not be loaded. Reload to try again.',
|
|
1508
|
+
pending: false,
|
|
1509
|
+
});
|
|
1510
|
+
})();
|
|
1511
|
+
};
|
|
1512
|
+
|
|
1513
|
+
// Reconcile a pending open once the live subscription exists. The
|
|
1514
|
+
// terminal `doc_status` frame is best-effort: if hydration finishes
|
|
1515
|
+
// before this client's SUB# row exists, the fan-out finds no subscriber
|
|
1516
|
+
// and drops the frame, stranding the app on the pending overlay. `doc_init`
|
|
1517
|
+
// is posted only after the SUB# row is written, so one re-fetch here
|
|
1518
|
+
// closes the gap for a completion that landed before the row existed.
|
|
1519
|
+
// Only a pending open pays the extra fetch.
|
|
1520
|
+
let reconcileInFlight = false;
|
|
1521
|
+
const reconcileStatusOnInit = (): void => {
|
|
1522
|
+
const current = doc.status;
|
|
1523
|
+
if (!current || !current.pending) return;
|
|
1524
|
+
if (reconcileInFlight || reloadInFlight) return;
|
|
1525
|
+
reconcileInFlight = true;
|
|
1526
|
+
void (async () => {
|
|
1527
|
+
try {
|
|
1528
|
+
const outcome = await refetchAndImport();
|
|
1529
|
+
if (!outcome.ok) return;
|
|
1530
|
+
// Still pending: leave the overlay up; subscribed now, so the live
|
|
1531
|
+
// fan-out delivers the terminal frame when the work finishes.
|
|
1532
|
+
if (outcome.status?.pending) return;
|
|
1533
|
+
// Terminal. A present status is a failure (server message,
|
|
1534
|
+
// pending:false); an absent status is success. On success retain
|
|
1535
|
+
// the last server message and flip pending so the overlay clears.
|
|
1536
|
+
doc._emitStatus(
|
|
1537
|
+
outcome.status ?? { message: current.message, pending: false },
|
|
1538
|
+
);
|
|
1539
|
+
} catch {
|
|
1540
|
+
// Best-effort; the live fan-out remains the primary path when
|
|
1541
|
+
// the work finishes after this subscribe.
|
|
1542
|
+
} finally {
|
|
1543
|
+
reconcileInFlight = false;
|
|
1544
|
+
}
|
|
1545
|
+
})();
|
|
1546
|
+
};
|
|
1547
|
+
|
|
1548
|
+
// Write an op the socket cannot carry. The op stays one commit and one
|
|
1549
|
+
// seq; only the transport differs, and the response carries the seq an
|
|
1550
|
+
// `op_ack` would have. Targets the branch this doc was opened on, the
|
|
1551
|
+
// same branch the transport's topic is scoped to.
|
|
1552
|
+
const postOversizedOp = async (
|
|
1553
|
+
opBlobB64: string,
|
|
1554
|
+
clientOpId: string,
|
|
1555
|
+
): Promise<{ ok: true; seq: number } | { ok: false; message: string }> => {
|
|
1556
|
+
const token = await requireToken();
|
|
1557
|
+
const { status, parsed: opResult } = await postDocumentOp(
|
|
1558
|
+
docId,
|
|
1559
|
+
parsed.branch_id,
|
|
1560
|
+
opBlobB64,
|
|
1561
|
+
clientOpId,
|
|
1562
|
+
token,
|
|
1563
|
+
);
|
|
1564
|
+
if (status < 200 || status >= 300) {
|
|
1565
|
+
return { ok: false, message: opWriteFailureMessage(status, opResult) };
|
|
1566
|
+
}
|
|
1567
|
+
const seq = (opResult as { seq?: unknown } | undefined)?.seq;
|
|
1568
|
+
if (typeof seq !== 'number') {
|
|
1569
|
+
return {
|
|
1570
|
+
ok: false,
|
|
1571
|
+
message: 'The server did not report a version for this change.',
|
|
1572
|
+
};
|
|
1573
|
+
}
|
|
1574
|
+
return { ok: true, seq };
|
|
1575
|
+
};
|
|
1576
|
+
|
|
1577
|
+
if (opts.wsClient) {
|
|
1578
|
+
wireDocTransport({
|
|
1579
|
+
doc,
|
|
1580
|
+
docId,
|
|
1581
|
+
// Scope the subscription to the branch the server materialized so
|
|
1582
|
+
// edits land on the opened branch rather than the default.
|
|
1583
|
+
branchId: parsed.branch_id,
|
|
1584
|
+
// The park cache keys on the requested branch, not the resolved one,
|
|
1585
|
+
// so take/park stay symmetric (see WireDocTransportArgs).
|
|
1586
|
+
...(options?.branch !== undefined
|
|
1587
|
+
? { requestedBranchId: options.branch }
|
|
1588
|
+
: {}),
|
|
1589
|
+
...(options?.stream !== undefined ? { requestedStream: options.stream } : {}),
|
|
1590
|
+
wsClient: opts.wsClient,
|
|
1591
|
+
postOversizedOp,
|
|
1592
|
+
newClientOpId: opts.newClientOpId ?? defaultClientOpIdMinter,
|
|
1593
|
+
getCurrentSeq: () => currentSeq,
|
|
1594
|
+
setCurrentSeq: (next) => {
|
|
1595
|
+
if (next > currentSeq) currentSeq = next;
|
|
1596
|
+
},
|
|
1597
|
+
getCoveredSeq: () => coveredSeq,
|
|
1598
|
+
advanceCoveredSeq: advanceCoverage,
|
|
1599
|
+
resyncFromSnapshot,
|
|
1600
|
+
refetchDurableFrontier,
|
|
1601
|
+
reloadAfterHydration,
|
|
1602
|
+
reconcileStatusOnInit,
|
|
1603
|
+
});
|
|
1604
|
+
}
|
|
1605
|
+
// `doc._checkoutVersion(v)` calls back here to materialise historical
|
|
1606
|
+
// state, leaving the live subscription untouched.
|
|
1607
|
+
doc.setCheckoutFetcher(makeCheckoutFetcher());
|
|
1608
|
+
// Wire the branch provider + populate the initial list, so the
|
|
1609
|
+
// toolbar's branch chip can name the branch on the next subscribe
|
|
1610
|
+
// tick. Branch management is hosted; this list feeds the SDK's own
|
|
1611
|
+
// chrome, not an app method.
|
|
1612
|
+
if (opts.branchProvider) {
|
|
1613
|
+
doc.setBranchProvider(opts.branchProvider);
|
|
1614
|
+
void doc._refreshBranches().catch(() => {
|
|
1615
|
+
// Best-effort: a failure leaves the chip unnamed until a hosted
|
|
1616
|
+
// branch action refreshes the list.
|
|
1617
|
+
});
|
|
1618
|
+
}
|
|
1619
|
+
// `doc.useCanvas(canvas)` captures + uploads after each local commit; the
|
|
1620
|
+
// doc handles debounce + readOnly gating internally.
|
|
1621
|
+
if (opts.thumbnailUploader) {
|
|
1622
|
+
const uploader = opts.thumbnailUploader;
|
|
1623
|
+
doc.setThumbnailUploader(async (image, width, height) => {
|
|
1624
|
+
await uploader(doc, image, width, height);
|
|
1625
|
+
});
|
|
1626
|
+
}
|
|
1627
|
+
if (opts.checkpointCreator) {
|
|
1628
|
+
const creator = opts.checkpointCreator;
|
|
1629
|
+
doc.setCheckpointCreator(() => creator(doc));
|
|
1630
|
+
}
|
|
1631
|
+
if (opts.drivePusher) {
|
|
1632
|
+
const pusher = opts.drivePusher;
|
|
1633
|
+
doc.setDrivePusher((input) => pusher(doc, input));
|
|
1634
|
+
}
|
|
1635
|
+
// `doc.resolveDrive()` opens the hosted resolution surface and, when it
|
|
1636
|
+
// mints a new mirror doc (take-source / keep-both), re-opens onto it via
|
|
1637
|
+
// the same `openSiblingDoc` path the `history` fork uses.
|
|
1638
|
+
const hostedForResolve = opts.hostedUiProvider;
|
|
1639
|
+
const resolveSurface = hostedForResolve?.driveResolve;
|
|
1640
|
+
if (hostedForResolve && resolveSurface) {
|
|
1641
|
+
doc.setDriveResolver(async () => {
|
|
1642
|
+
const docId = doc.id;
|
|
1643
|
+
if (docId === null) return null;
|
|
1644
|
+
const result = await resolveSurface(docId);
|
|
1645
|
+
if (result?.newDocId && hostedForResolve.openSiblingDoc) {
|
|
1646
|
+
await hostedForResolve.openSiblingDoc(result.newDocId, {});
|
|
1647
|
+
}
|
|
1648
|
+
return result;
|
|
1649
|
+
});
|
|
1650
|
+
}
|
|
1651
|
+
if (opts.sourceBlobFetcher) {
|
|
1652
|
+
const fetcher = opts.sourceBlobFetcher;
|
|
1653
|
+
doc.setSourceBlobFetcher(() => fetcher(doc));
|
|
1654
|
+
}
|
|
1655
|
+
doc.setOpenedStreamName(options?.stream ?? DEFAULT_STREAM_NAME);
|
|
1656
|
+
// Streams: manifest list/create over HTTP plus the per-line open
|
|
1657
|
+
// (join + WS subscribe). Bound to the BRANCH this Document opened on,
|
|
1658
|
+
// which is not the line it materialized when the open named a stream: a
|
|
1659
|
+
// manifest belongs to a branch and a line owns none, so binding to the
|
|
1660
|
+
// line would leave `doc.stream(name)` resolving against nothing.
|
|
1661
|
+
if (opts.streamProvider) {
|
|
1662
|
+
const provider = opts.streamProvider;
|
|
1663
|
+
const openedBranchId = parsed.stream_branch_id ?? parsed.branch_id;
|
|
1664
|
+
doc.setStreamRuntime({
|
|
1665
|
+
list: async () =>
|
|
1666
|
+
(await provider.list(docId, openedBranchId)).streams,
|
|
1667
|
+
create: (name) => provider.create(docId, openedBranchId, name),
|
|
1668
|
+
openLine: (lineId) => openStreamLine(docId, lineId),
|
|
1669
|
+
});
|
|
1670
|
+
}
|
|
1671
|
+
// Hosted-UI provider backing the doc's built-in `history` / `details` /
|
|
1672
|
+
// `share` / `delete` commands.
|
|
1673
|
+
if (opts.hostedUiProvider) {
|
|
1674
|
+
doc.setHostedUiProvider(opts.hostedUiProvider);
|
|
1675
|
+
const refreshName = opts.hostedUiProvider.refreshName;
|
|
1676
|
+
if (refreshName && docId) {
|
|
1677
|
+
void refreshName(docId).then((name) => {
|
|
1678
|
+
if (name !== null) doc._setName(name);
|
|
1679
|
+
});
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
if (opts.reportError) doc.setErrorReporter(opts.reportError);
|
|
1683
|
+
return doc;
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
// Open at a historical version: read-only, no transport. The at-version
|
|
1687
|
+
// route returns the same snapshot + tail_ops envelope the join uses, so
|
|
1688
|
+
// `materialise` builds it; `readOnly` + the effective version thread
|
|
1689
|
+
// through `documentOptions` and `wireDocTransport` is skipped (a frozen
|
|
1690
|
+
// point has no live op stream).
|
|
1691
|
+
async function openAtVersion(
|
|
1692
|
+
docId: string,
|
|
1693
|
+
version: number,
|
|
1694
|
+
options: OpenDocumentByIdOptions,
|
|
1695
|
+
token: string,
|
|
1696
|
+
): Promise<OpenedDocument> {
|
|
1697
|
+
const { status, parsed, parseFailed } = await fetchAtVersion(
|
|
1698
|
+
docId,
|
|
1699
|
+
version,
|
|
1700
|
+
options.branch,
|
|
1701
|
+
token,
|
|
1702
|
+
);
|
|
1703
|
+
if (status < 200 || status >= 300) {
|
|
1704
|
+
throw brassErrorFromBody(status, parseFailed ? undefined : parsed);
|
|
1705
|
+
}
|
|
1706
|
+
if (!isAtVersionResponse(parsed)) {
|
|
1707
|
+
throw new BrassError('Malformed document at-version response', {
|
|
1708
|
+
status: 500,
|
|
1709
|
+
});
|
|
1710
|
+
}
|
|
1711
|
+
return materialise(
|
|
1712
|
+
{
|
|
1713
|
+
doc_id: docId,
|
|
1714
|
+
branch_id: parsed.branch_id,
|
|
1715
|
+
snapshot: parsed.snapshot,
|
|
1716
|
+
tail_ops: parsed.tail_ops,
|
|
1717
|
+
},
|
|
1718
|
+
{
|
|
1719
|
+
...options.documentOptions,
|
|
1720
|
+
readOnly: true,
|
|
1721
|
+
version: parsed.effective_version,
|
|
1722
|
+
},
|
|
1723
|
+
);
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
// `GET /links/{token}/view?include=snapshot`: the anonymous guest read.
|
|
1727
|
+
// Sends NO Authorization header (the token is the credential), so it must
|
|
1728
|
+
// not go through the auth-bearing `fetchSnapshot`. The server returns the
|
|
1729
|
+
// same snapshot + tail_ops envelope, member-stripped and read-only.
|
|
1730
|
+
async function fetchLinkView(
|
|
1731
|
+
token: string,
|
|
1732
|
+
branch: string | undefined,
|
|
1733
|
+
): Promise<{ status: number; parsed: unknown; parseFailed: boolean }> {
|
|
1734
|
+
const search = new URLSearchParams();
|
|
1735
|
+
search.set('include', 'snapshot');
|
|
1736
|
+
if (branch !== undefined) search.set('branch', branch);
|
|
1737
|
+
const url = `${baseUrl}/links/${encodeURIComponent(token)}/view?${search.toString()}`;
|
|
1738
|
+
const response = await fetchWithRetry(() => fetch(url, { method: 'GET' }));
|
|
1739
|
+
const text = await response.text();
|
|
1740
|
+
let parsed: unknown = undefined;
|
|
1741
|
+
let parseFailed = false;
|
|
1742
|
+
if (text) {
|
|
1743
|
+
try {
|
|
1744
|
+
parsed = JSON.parse(text);
|
|
1745
|
+
} catch {
|
|
1746
|
+
parseFailed = true;
|
|
1747
|
+
}
|
|
1748
|
+
}
|
|
1749
|
+
return { status: response.status, parsed, parseFailed };
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
// `GET /links/{token}/streams`: the anonymous stream manifest, the guest
|
|
1753
|
+
// counterpart of the signed-in streams list. No Authorization header (the
|
|
1754
|
+
// token is the credential). Returns the manifest entries the guest's
|
|
1755
|
+
// `doc.stream(name)` resolves against.
|
|
1756
|
+
async function fetchLinkStreamManifest(
|
|
1757
|
+
token: string,
|
|
1758
|
+
): Promise<import('./documents-namespace.js').DocumentStreamInfo[]> {
|
|
1759
|
+
const url = `${baseUrl}/links/${encodeURIComponent(token)}/streams`;
|
|
1760
|
+
const response = await fetchWithRetry(() => fetch(url, { method: 'GET' }));
|
|
1761
|
+
const text = await response.text();
|
|
1762
|
+
if (response.status < 200 || response.status >= 300) {
|
|
1763
|
+
let parsed: unknown = undefined;
|
|
1764
|
+
try {
|
|
1765
|
+
parsed = text ? JSON.parse(text) : undefined;
|
|
1766
|
+
} catch {
|
|
1767
|
+
parsed = undefined;
|
|
1768
|
+
}
|
|
1769
|
+
throw brassErrorFromBody(response.status, parsed);
|
|
1770
|
+
}
|
|
1771
|
+
const body = (text ? JSON.parse(text) : {}) as {
|
|
1772
|
+
streams?: Array<{
|
|
1773
|
+
name?: unknown;
|
|
1774
|
+
format?: unknown;
|
|
1775
|
+
line_id?: unknown;
|
|
1776
|
+
}>;
|
|
1777
|
+
};
|
|
1778
|
+
const streams = Array.isArray(body.streams) ? body.streams : [];
|
|
1779
|
+
// `created_at` / `created_by` are stripped from the guest manifest (a
|
|
1780
|
+
// guest never learns who a doc's members are); default them so the info
|
|
1781
|
+
// shape stays uniform. Apps only need `name`.
|
|
1782
|
+
return streams.flatMap((s) =>
|
|
1783
|
+
typeof s.name === 'string' &&
|
|
1784
|
+
typeof s.format === 'string' &&
|
|
1785
|
+
typeof s.line_id === 'string'
|
|
1786
|
+
? [
|
|
1787
|
+
{
|
|
1788
|
+
name: s.name,
|
|
1789
|
+
format: s.format,
|
|
1790
|
+
line_id: s.line_id,
|
|
1791
|
+
created_at: '',
|
|
1792
|
+
created_by: '',
|
|
1793
|
+
},
|
|
1794
|
+
]
|
|
1795
|
+
: [],
|
|
1796
|
+
);
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
const openByLink: OpenDocumentByLinkFn = async (token, options) => {
|
|
1800
|
+
const { status, parsed, parseFailed } = await fetchLinkView(
|
|
1801
|
+
token,
|
|
1802
|
+
options?.branch,
|
|
1803
|
+
);
|
|
1804
|
+
if (status < 200 || status >= 300) {
|
|
1805
|
+
throw brassErrorFromBody(status, parseFailed ? undefined : parsed);
|
|
1806
|
+
}
|
|
1807
|
+
const b = parsed as {
|
|
1808
|
+
doc_id?: unknown;
|
|
1809
|
+
branch_id?: unknown;
|
|
1810
|
+
snapshot?: unknown;
|
|
1811
|
+
tail_ops?: unknown;
|
|
1812
|
+
current_seq?: unknown;
|
|
1813
|
+
name?: unknown;
|
|
1814
|
+
link_role?: unknown;
|
|
1815
|
+
};
|
|
1816
|
+
if (
|
|
1817
|
+
typeof b.doc_id !== 'string' ||
|
|
1818
|
+
typeof b.branch_id !== 'string' ||
|
|
1819
|
+
!Array.isArray(b.tail_ops)
|
|
1820
|
+
) {
|
|
1821
|
+
throw new BrassError('Malformed link view response', { status: 500 });
|
|
1822
|
+
}
|
|
1823
|
+
// The link's grant decides read vs write. An `editor` link is anonymous
|
|
1824
|
+
// EDIT: a live Document (not read-only) whose local mutations ship over
|
|
1825
|
+
// the same token-authenticated socket, with the server admitting the
|
|
1826
|
+
// op-write because the guest's SUB# row carries `role: editor`. A
|
|
1827
|
+
// `viewer` link (the default) is read-only: mutate throws and only
|
|
1828
|
+
// inbound frames apply.
|
|
1829
|
+
const canEdit = b.link_role === 'editor';
|
|
1830
|
+
const doc = await materialise(
|
|
1831
|
+
{
|
|
1832
|
+
doc_id: b.doc_id,
|
|
1833
|
+
branch_id: b.branch_id,
|
|
1834
|
+
snapshot: b.snapshot as DocumentSnapshotResponse['snapshot'],
|
|
1835
|
+
tail_ops: b.tail_ops as DocumentSnapshotResponse['tail_ops'],
|
|
1836
|
+
},
|
|
1837
|
+
{ ...options?.documentOptions, readOnly: !canEdit },
|
|
1838
|
+
options?.onSnapshotProgress,
|
|
1839
|
+
);
|
|
1840
|
+
// The view carries the document's display name; set it so the guest
|
|
1841
|
+
// sees the title (the signed-in open gets this from `finalise`).
|
|
1842
|
+
if (typeof b.name === 'string') doc._setName(b.name);
|
|
1843
|
+
|
|
1844
|
+
wireGuestTransport(doc, b.doc_id, b.branch_id, canEdit);
|
|
1845
|
+
|
|
1846
|
+
// Streams: a guest resolves `doc.stream(name)` through the token
|
|
1847
|
+
// routes. `list` hits the anonymous manifest; `openLine` joins the
|
|
1848
|
+
// line read/write exactly like the default stream above, over the same
|
|
1849
|
+
// token-authenticated socket. `create` is refused (a guest cannot mint
|
|
1850
|
+
// a line). Absent when there is no WS client (a snapshot-only guest),
|
|
1851
|
+
// matching the default stream having no live transport there.
|
|
1852
|
+
doc.setStreamRuntime({
|
|
1853
|
+
list: () => fetchLinkStreamManifest(token),
|
|
1854
|
+
openLine: async (lineId) => {
|
|
1855
|
+
const view = await fetchLinkView(token, lineId);
|
|
1856
|
+
if (view.status < 200 || view.status >= 300) {
|
|
1857
|
+
throw brassErrorFromBody(
|
|
1858
|
+
view.status,
|
|
1859
|
+
view.parseFailed ? undefined : view.parsed,
|
|
1860
|
+
);
|
|
1861
|
+
}
|
|
1862
|
+
const lb = view.parsed as {
|
|
1863
|
+
doc_id?: unknown;
|
|
1864
|
+
branch_id?: unknown;
|
|
1865
|
+
snapshot?: unknown;
|
|
1866
|
+
tail_ops?: unknown;
|
|
1867
|
+
};
|
|
1868
|
+
if (
|
|
1869
|
+
typeof lb.doc_id !== 'string' ||
|
|
1870
|
+
typeof lb.branch_id !== 'string' ||
|
|
1871
|
+
!Array.isArray(lb.tail_ops)
|
|
1872
|
+
) {
|
|
1873
|
+
throw new BrassError('Malformed link stream view response', {
|
|
1874
|
+
status: 500,
|
|
1875
|
+
});
|
|
1876
|
+
}
|
|
1877
|
+
const inner = await materialise(
|
|
1878
|
+
{
|
|
1879
|
+
doc_id: lb.doc_id,
|
|
1880
|
+
branch_id: lb.branch_id,
|
|
1881
|
+
snapshot: lb.snapshot as DocumentSnapshotResponse['snapshot'],
|
|
1882
|
+
tail_ops: lb.tail_ops as DocumentSnapshotResponse['tail_ops'],
|
|
1883
|
+
},
|
|
1884
|
+
{ readOnly: !canEdit },
|
|
1885
|
+
);
|
|
1886
|
+
wireGuestTransport(inner, lb.doc_id, lb.branch_id, canEdit);
|
|
1887
|
+
return inner;
|
|
1888
|
+
},
|
|
1889
|
+
create: () => {
|
|
1890
|
+
throw new BrassError('A shared-link guest cannot create streams.', {
|
|
1891
|
+
status: 403,
|
|
1892
|
+
});
|
|
1893
|
+
},
|
|
1894
|
+
});
|
|
1895
|
+
return doc;
|
|
1896
|
+
};
|
|
1897
|
+
|
|
1898
|
+
// Wire a guest Document (the default stream or a stream line) to the
|
|
1899
|
+
// token-authenticated socket. Inbound frames (`doc_init` / `doc_op`)
|
|
1900
|
+
// apply for both link kinds; an edit link ALSO wires the outbound path
|
|
1901
|
+
// (`op_ack` resolves the outstanding-op tracker, `setLocalOpHook` ships
|
|
1902
|
+
// each local commit as an `op` frame). Best-effort (no resync / durability
|
|
1903
|
+
// handshake, which need a signed-in token); a guest that misses ops
|
|
1904
|
+
// re-opens. Patches `close` to detach, mirroring `wireDocTransport`, so
|
|
1905
|
+
// `Document` stays free of WS knowledge. A no-op with no WS client (a
|
|
1906
|
+
// snapshot-only guest).
|
|
1907
|
+
function wireGuestTransport(
|
|
1908
|
+
doc: Document,
|
|
1909
|
+
docId: string,
|
|
1910
|
+
lineId: string,
|
|
1911
|
+
canEdit: boolean,
|
|
1912
|
+
): void {
|
|
1913
|
+
const wsClient = opts.wsClient;
|
|
1914
|
+
if (!wsClient) return;
|
|
1915
|
+
const topic = `doc:${docId}:${lineId}`;
|
|
1916
|
+
const unsub = wsClient.subscribe(topic, (frame) => {
|
|
1917
|
+
if (isDocInitFrame(frame)) {
|
|
1918
|
+
for (const entry of frame.tail_ops) {
|
|
1919
|
+
if (entry.op_blob_b64 === undefined) continue;
|
|
1920
|
+
doc.applyRemoteOp(base64ToBytes(entry.op_blob_b64));
|
|
1921
|
+
}
|
|
1922
|
+
} else if (isDocOpFrame(frame)) {
|
|
1923
|
+
doc.applyRemoteOp(base64ToBytes(frame.op_blob));
|
|
1924
|
+
} else if (isDocGoneFrame(frame)) {
|
|
1925
|
+
// The server fans `doc_gone` to EVERY subscriber on the branched
|
|
1926
|
+
// content topic, guest-link connections included (`fanoutDocGone` /
|
|
1927
|
+
// `fanoutBranchGone`), on a soft-delete / move of the opened doc.
|
|
1928
|
+
// Without this branch the guest keeps showing removed content and
|
|
1929
|
+
// `doc.onGone(...)` never fires; for an edit link the next local op
|
|
1930
|
+
// would ship onto the evicted SUB# row and bounce as a misleading
|
|
1931
|
+
// `forbidden`. Mark the doc gone (mapping the reason like the
|
|
1932
|
+
// signed-in transport); the guest path has no resend / reverify
|
|
1933
|
+
// timers to drain.
|
|
1934
|
+
doc._markGone(goneReasonOf(frame));
|
|
1935
|
+
} else if (canEdit && isOpAckFrame(frame)) {
|
|
1936
|
+
doc.resolveOutstandingOp(frame.client_op_id);
|
|
1937
|
+
} else if (canEdit && isOpRejectedFrame(frame)) {
|
|
1938
|
+
// A guest edit the server terminally rejected. No resend tracker here
|
|
1939
|
+
// (guest transport is best-effort), so just fail the op on the doc so
|
|
1940
|
+
// "could not be saved" surfaces instead of a stuck saving count.
|
|
1941
|
+
doc._failOutstandingOp(frame.client_op_id, opRejectedMessage(frame));
|
|
1942
|
+
}
|
|
1943
|
+
});
|
|
1944
|
+
if (canEdit) {
|
|
1945
|
+
const minter = opts.newClientOpId ?? defaultClientOpIdMinter;
|
|
1946
|
+
doc.setLocalOpHook((opBlob) => {
|
|
1947
|
+
const clientOpId = minter();
|
|
1948
|
+
wsClient.send({
|
|
1949
|
+
type: 'op',
|
|
1950
|
+
topic,
|
|
1951
|
+
op_blob: bytesToBase64(opBlob),
|
|
1952
|
+
client_op_id: clientOpId,
|
|
1953
|
+
});
|
|
1954
|
+
return clientOpId;
|
|
1955
|
+
});
|
|
1956
|
+
}
|
|
1957
|
+
const originalClose = doc.close.bind(doc);
|
|
1958
|
+
(doc as Document & { close: () => void }).close = () => {
|
|
1959
|
+
try {
|
|
1960
|
+
unsub();
|
|
1961
|
+
} catch {
|
|
1962
|
+
// Already detached; closing the Document is what matters.
|
|
1963
|
+
}
|
|
1964
|
+
originalClose();
|
|
1965
|
+
};
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1968
|
+
// Strict open: a 404 propagates as `BrassError`. A `version` option
|
|
1969
|
+
// diverts to the read-only at-version path.
|
|
1970
|
+
const open: OpenDocumentByIdFn = async (docId, options) => {
|
|
1971
|
+
// Cache hit: return the parked Document. Its subscription stayed alive
|
|
1972
|
+
// while parked, and the UndoController + outstanding-op map survived, so
|
|
1973
|
+
// undo works as if the user never switched away.
|
|
1974
|
+
const parked = takeParkedDocument(docId, options);
|
|
1975
|
+
if (parked) {
|
|
1976
|
+
parked._resume();
|
|
1977
|
+
return parked as OpenedDocument;
|
|
1978
|
+
}
|
|
1979
|
+
const endTotal = perfStart('open: openDocumentById total');
|
|
1980
|
+
const token = await requireToken();
|
|
1981
|
+
if (options?.version !== undefined) {
|
|
1982
|
+
const atVersion = await openAtVersion(
|
|
1983
|
+
docId,
|
|
1984
|
+
options.version,
|
|
1985
|
+
options,
|
|
1986
|
+
token,
|
|
1987
|
+
);
|
|
1988
|
+
endTotal({ docId, path: 'at-version' });
|
|
1989
|
+
return atVersion;
|
|
1990
|
+
}
|
|
1991
|
+
const snapshot = await fetchSnapshot(docId, options, token);
|
|
1992
|
+
const opened = await finalise(docId, snapshot, options);
|
|
1993
|
+
endTotal({ docId });
|
|
1994
|
+
return opened;
|
|
1995
|
+
};
|
|
1996
|
+
|
|
1997
|
+
// Create a doc and return it fully editable. Two round trips (POST to
|
|
1998
|
+
// create + obtain the doc_id, then `open(docId)`); they aren't fused
|
|
1999
|
+
// because the create response is metadata-only and the open path already
|
|
2000
|
+
// handles the empty-snapshot case via `Document.create()`. This is the
|
|
2001
|
+
// low-level path (`Brass.createDocument`): the server places the doc in
|
|
2002
|
+
// the caller's single organization; a caller with none is routed to the
|
|
2003
|
+
// hosted acquire-organization surface and the create retries; a multi-org
|
|
2004
|
+
// caller rejects with a 409 (no hosted picker here, that is the session's
|
|
2005
|
+
// `create()`).
|
|
2006
|
+
const create: CreateDocumentFn = async (options) => {
|
|
2007
|
+
const args: CreateDocumentArgs = { name: options?.name ?? 'Untitled' };
|
|
2008
|
+
if (options?.app_metadata !== undefined) args.app_metadata = options.app_metadata;
|
|
2009
|
+
let result = await createDocumentId(args);
|
|
2010
|
+
if (result.kind === 'needs-organization') {
|
|
2011
|
+
const acquired = await opts.hostedUiProvider?.acquireOrganization?.();
|
|
2012
|
+
if (!acquired || typeof acquired.organization_id !== 'string') {
|
|
2013
|
+
throw new BrassError('No organization available', { status: 404 });
|
|
2014
|
+
}
|
|
2015
|
+
result = await createDocumentId({
|
|
2016
|
+
...args,
|
|
2017
|
+
destination: { organization_id: acquired.organization_id },
|
|
2018
|
+
});
|
|
2019
|
+
}
|
|
2020
|
+
if (result.kind === 'needs-destination') {
|
|
2021
|
+
throw new BrassError('Choose a destination organization', { status: 409 });
|
|
2022
|
+
}
|
|
2023
|
+
if (result.kind !== 'created') {
|
|
2024
|
+
throw new BrassError('Malformed createDocument response', { status: 500 });
|
|
2025
|
+
}
|
|
2026
|
+
const openOptions = options?.documentOptions
|
|
2027
|
+
? { documentOptions: options.documentOptions }
|
|
2028
|
+
: undefined;
|
|
2029
|
+
return open(result.docId, openOptions);
|
|
2030
|
+
};
|
|
2031
|
+
|
|
2032
|
+
return { open, create, createDocumentId, openByLink };
|
|
2033
|
+
}
|
|
2034
|
+
|
|
2035
|
+
interface WireDocTransportArgs {
|
|
2036
|
+
doc: Document;
|
|
2037
|
+
docId: string;
|
|
2038
|
+
// The branch the subscription scopes to; the topic is
|
|
2039
|
+
// `doc:<docId>:<branchId>` so op-write / fanout / presence stay
|
|
2040
|
+
// branch-scoped server-side.
|
|
2041
|
+
branchId: string;
|
|
2042
|
+
wsClient: OpenDocumentWsClient;
|
|
2043
|
+
newClientOpId: () => string;
|
|
2044
|
+
getCurrentSeq: () => number;
|
|
2045
|
+
setCurrentSeq: (next: number) => void;
|
|
2046
|
+
// Contiguous-coverage floor accessors (see the closure in `finalise`):
|
|
2047
|
+
// highest seq below which every op is applied. Distinct from the max-seen
|
|
2048
|
+
// cursor, which out-of-order fanout can inflate past a hole.
|
|
2049
|
+
getCoveredSeq: () => number;
|
|
2050
|
+
advanceCoveredSeq: (seq: number) => void;
|
|
2051
|
+
// Re-import the HTTP join. Invoked when a `doc_init` tail starts above the
|
|
2052
|
+
// coverage floor: ops missed while disconnected were compacted into the
|
|
2053
|
+
// snapshot and the tail cannot bridge the hole. Optional only for
|
|
2054
|
+
// transport-less paths.
|
|
2055
|
+
resyncFromSnapshot?: () => void;
|
|
2056
|
+
// Re-fetch the durable frontier (version vector + tail blobs) without
|
|
2057
|
+
// importing. Invoked by the `doc_ops_dropped` handler so the handshake
|
|
2058
|
+
// runs against fresh ground truth the instant the server discards ops, not
|
|
2059
|
+
// only at reconnect. Null on a failed fetch; the `doc_init` handshake is
|
|
2060
|
+
// the backstop.
|
|
2061
|
+
refetchDurableFrontier?: () => Promise<{
|
|
2062
|
+
snapshotVersionVector?: string;
|
|
2063
|
+
tailBlobs: Uint8Array[];
|
|
2064
|
+
} | null>;
|
|
2065
|
+
// Write an op the socket cannot carry, over HTTP. Resolves to the
|
|
2066
|
+
// server-allocated seq, or an error message the Document surfaces on the
|
|
2067
|
+
// op. Absent in transport-only tests, where an oversized op is failed
|
|
2068
|
+
// rather than rerouted.
|
|
2069
|
+
postOversizedOp?: (
|
|
2070
|
+
opBlobB64: string,
|
|
2071
|
+
clientOpId: string,
|
|
2072
|
+
) => Promise<{ ok: true; seq: number } | { ok: false; message: string }>;
|
|
2073
|
+
// Invoked on a terminal `doc_status` frame: re-fetches once and, on
|
|
2074
|
+
// success, imports the now-available snapshot so content appears without
|
|
2075
|
+
// the app reopening; on failure leaves the status and does not loop.
|
|
2076
|
+
// Absent in transport-only tests.
|
|
2077
|
+
reloadAfterHydration?: (finalStatus: DocumentWorkStatus) => void;
|
|
2078
|
+
// Invoked on `doc_init`. Posted only after the SUB# row is written, so it
|
|
2079
|
+
// is the safe point to reconcile a pending open whose terminal
|
|
2080
|
+
// `doc_status` frame may have been dropped. Absent in transport-only tests.
|
|
2081
|
+
reconcileStatusOnInit?: () => void;
|
|
2082
|
+
// True for a stream line's internal Document: presence is a
|
|
2083
|
+
// document-level surface (one roster per open doc, on the default
|
|
2084
|
+
// line's presence topic), so per-stream transports skip attaching a
|
|
2085
|
+
// presence controller rather than multiplying subscriptions per stream.
|
|
2086
|
+
skipPresence?: boolean;
|
|
2087
|
+
// False for a stream line's internal Document: its lifecycle is owned
|
|
2088
|
+
// by the parent Document (closed on parent close), so it bypasses the
|
|
2089
|
+
// park cache and `close()` tears down immediately.
|
|
2090
|
+
parkable?: boolean;
|
|
2091
|
+
// The branch the caller REQUESTED (`options.branch`), undefined for a
|
|
2092
|
+
// default-branch open. Distinct from `branchId`, which is the branch the
|
|
2093
|
+
// server RESOLVED. The park cache keys on this (symmetric with
|
|
2094
|
+
// `takeParkedDocument`, which looks up by the requested branch): keying on
|
|
2095
|
+
// the resolved branch instead would park a no-branch open under
|
|
2096
|
+
// `<id>/<default>` while a no-branch reopen keys as `null`, so the parked
|
|
2097
|
+
// copy (with its live subscription) could never be retrieved and would leak
|
|
2098
|
+
// until LRU eviction. This honors the `parkCacheKey` rule that a
|
|
2099
|
+
// branch-unknown open skips the cache rather than risk returning a doc for
|
|
2100
|
+
// the wrong (possibly since-changed) branch.
|
|
2101
|
+
requestedBranchId?: string;
|
|
2102
|
+
// The stream the caller REQUESTED (`options.stream`), undefined for an
|
|
2103
|
+
// open that named none. Part of the park key for the same reason the
|
|
2104
|
+
// branch is: it says which state the parked Document holds.
|
|
2105
|
+
requestedStream?: string;
|
|
2106
|
+
}
|
|
2107
|
+
|
|
2108
|
+
// Per-process parking cache for opened Documents. `doc.close()` does NOT
|
|
2109
|
+
// free the LoroDoc / UndoController; it parks the Document keyed on
|
|
2110
|
+
// (docId, branchId), and the next `openDocumentById` for that key returns it
|
|
2111
|
+
// with its undo stack intact, so "switch branches and come back" preserves
|
|
2112
|
+
// per-branch undo without app-side bookkeeping.
|
|
2113
|
+
//
|
|
2114
|
+
// LRU eviction past `PARK_CACHE_CAPACITY`; the evicted Document gets its full
|
|
2115
|
+
// teardown (WS detach, presence leave, LoroDoc free). Version-pinned
|
|
2116
|
+
// (read-only) opens bypass the cache: they are transient with no editable
|
|
2117
|
+
// state to keep. Module-scoped so it persists across factory invocations in
|
|
2118
|
+
// one process; keys are app-supplied so no cross-app leak within a tab.
|
|
2119
|
+
const PARK_CACHE_CAPACITY = 4;
|
|
2120
|
+
|
|
2121
|
+
interface ParkedDocEntry {
|
|
2122
|
+
doc: Document;
|
|
2123
|
+
// Bound full-teardown closure: detaches WS, leaves presence, drops the
|
|
2124
|
+
// local-op hook, runs the underlying `Document.close()`.
|
|
2125
|
+
teardown: () => void;
|
|
2126
|
+
key: string;
|
|
2127
|
+
}
|
|
2128
|
+
|
|
2129
|
+
const parkedDocs = new Map<string, ParkedDocEntry>();
|
|
2130
|
+
|
|
2131
|
+
function parkCacheKey(
|
|
2132
|
+
docId: string,
|
|
2133
|
+
branchId: string | null | undefined,
|
|
2134
|
+
streamName: string | null | undefined,
|
|
2135
|
+
): string | null {
|
|
2136
|
+
// Skip caching when the branch is unknown: the next open() may resolve to
|
|
2137
|
+
// a different branch (the server default), and returning the wrong branch
|
|
2138
|
+
// is worse than a lost undo stack.
|
|
2139
|
+
if (!branchId) return null;
|
|
2140
|
+
// The stream is part of the identity of what was opened: a Document
|
|
2141
|
+
// opened at `geometry` holds different state from one opened at the same
|
|
2142
|
+
// branch's default, so handing either back for the other would swap the
|
|
2143
|
+
// caller's document out from under it.
|
|
2144
|
+
return `${docId}/${branchId}/${streamName ?? DEFAULT_STREAM_NAME}`;
|
|
2145
|
+
}
|
|
2146
|
+
|
|
2147
|
+
function parkDocument(entry: ParkedDocEntry): void {
|
|
2148
|
+
parkedDocs.set(entry.key, entry);
|
|
2149
|
+
// Map iteration is insertion order, so evicting the front entry is LRU
|
|
2150
|
+
// (a newly parked entry is the most-recent; a hit removes the entry
|
|
2151
|
+
// entirely, so there's no bump-on-hit).
|
|
2152
|
+
while (parkedDocs.size > PARK_CACHE_CAPACITY) {
|
|
2153
|
+
const oldestKey = parkedDocs.keys().next().value;
|
|
2154
|
+
if (oldestKey === undefined) break;
|
|
2155
|
+
const oldest = parkedDocs.get(oldestKey);
|
|
2156
|
+
parkedDocs.delete(oldestKey);
|
|
2157
|
+
try {
|
|
2158
|
+
oldest?.teardown();
|
|
2159
|
+
} catch {
|
|
2160
|
+
// Teardown errors must not strand the cache; the evicted Doc is gone
|
|
2161
|
+
// from the user's perspective either way.
|
|
2162
|
+
}
|
|
2163
|
+
}
|
|
2164
|
+
}
|
|
2165
|
+
|
|
2166
|
+
function takeParkedDocument(
|
|
2167
|
+
docId: string,
|
|
2168
|
+
options: OpenDocumentByIdOptions | undefined,
|
|
2169
|
+
): Document | null {
|
|
2170
|
+
if (options?.version !== undefined) return null;
|
|
2171
|
+
const key = parkCacheKey(docId, options?.branch, options?.stream);
|
|
2172
|
+
if (!key) return null;
|
|
2173
|
+
const entry = parkedDocs.get(key);
|
|
2174
|
+
if (!entry) return null;
|
|
2175
|
+
parkedDocs.delete(key);
|
|
2176
|
+
return entry.doc;
|
|
2177
|
+
}
|
|
2178
|
+
|
|
2179
|
+
// Test-only: drop the parking cache. Used by unit tests that need
|
|
2180
|
+
// a clean slate between cases.
|
|
2181
|
+
export function _resetParkCacheForTesting(): void {
|
|
2182
|
+
for (const entry of parkedDocs.values()) {
|
|
2183
|
+
try {
|
|
2184
|
+
entry.teardown();
|
|
2185
|
+
} catch {
|
|
2186
|
+
// ignore
|
|
2187
|
+
}
|
|
2188
|
+
}
|
|
2189
|
+
parkedDocs.clear();
|
|
2190
|
+
}
|
|
2191
|
+
|
|
2192
|
+
// Wire the WS transport onto a materialised Document. Three pieces:
|
|
2193
|
+
// 1. WS subscribe on the doc topic; the server sends `doc_op` (other
|
|
2194
|
+
// clients' edits) and `op_ack` (our ops), topic-scoped so the
|
|
2195
|
+
// `BrassWsClient` multiplexer routes them to our callback.
|
|
2196
|
+
// 2. Local-op send hook: `subscribeLocalUpdates` fires per-commit; the
|
|
2197
|
+
// hook mints a `client_op_id`, records it on the outstanding-op map (so
|
|
2198
|
+
// the server echo reads as self-originated), and sends the frame.
|
|
2199
|
+
// 3. Teardown: `close()` clears the hook and detaches the subscription via
|
|
2200
|
+
// the closure-held unsubscribe handle.
|
|
2201
|
+
|
|
2202
|
+
// How often a long-lived connected tab re-fetches the durable frontier and
|
|
2203
|
+
// re-runs the durability handshake. The handshake also fires on `doc_init`
|
|
2204
|
+
// (reconnect) and `doc_ops_dropped`, but both are event-driven: a tab
|
|
2205
|
+
// connected for days that missed its best-effort drop notification would not
|
|
2206
|
+
// self-heal until it reconnects. This periodic re-verify closes that window.
|
|
2207
|
+
// Cheap (one JSON round-trip, no snapshot download). Exported for the
|
|
2208
|
+
// fake-timer test.
|
|
2209
|
+
export const PERIODIC_REVERIFY_INTERVAL_MS = 5 * 60_000;
|
|
2210
|
+
|
|
2211
|
+
// How long a hole below the coverage floor may stand before the transport
|
|
2212
|
+
// re-imports the join to fill it. An op arriving ahead of its predecessor
|
|
2213
|
+
// opens a hole that closes within a round trip; one still open after this
|
|
2214
|
+
// delay is an op the server will not send again. Exported for the
|
|
2215
|
+
// fake-timer test.
|
|
2216
|
+
export const MISSED_OP_RESYNC_DELAY_MS = 2_000;
|
|
2217
|
+
|
|
2218
|
+
// A mutation carrying many records commits as it goes, so the only write
|
|
2219
|
+
// that reaches this is one record nothing can split.
|
|
2220
|
+
const TOO_LARGE_MESSAGE =
|
|
2221
|
+
'This record is too large to save. A single record must fit in one ' +
|
|
2222
|
+
'update; store the bulk of it as a file instead, or split it across ' +
|
|
2223
|
+
'several records.';
|
|
2224
|
+
|
|
2225
|
+
function wireDocTransport(args: WireDocTransportArgs): void {
|
|
2226
|
+
const topic = `doc:${args.docId}:${args.branchId}`;
|
|
2227
|
+
|
|
2228
|
+
// Per-op retry tracker. The op-write endpoint sometimes returns
|
|
2229
|
+
// `{"message": "Internal server error"}` without reaching the handler
|
|
2230
|
+
// (burst input against a cold-started server giving up before scale-up).
|
|
2231
|
+
// That frame has no `type`, so the multiplexer drops it,
|
|
2232
|
+
// leaving the op stuck in `Document._outstandingByClientId` and the
|
|
2233
|
+
// actor's lamport stream with a gap that blocks other subscribers' Loro
|
|
2234
|
+
// imports from converging (Loro requires per-actor causal contiguity).
|
|
2235
|
+
//
|
|
2236
|
+
// Recovery is retry-forever while the doc is open: a capped backoff plus an
|
|
2237
|
+
// immediate resend of every outstanding op on each reconnect (`doc_init`).
|
|
2238
|
+
// Re-sends are idempotent (a duplicate seq is a Loro no-op) and loss
|
|
2239
|
+
// windows sit inside the server's 15-minute discard window, so the moment a
|
|
2240
|
+
// retry lands the dependent chain converges everywhere. `doc.savingCount`
|
|
2241
|
+
// stays above zero until the `op_ack` lands (the "Saving…" surface); there
|
|
2242
|
+
// is no give-up to surface because the SDK keeps trying.
|
|
2243
|
+
const outstandingResends = new Map<
|
|
2244
|
+
string,
|
|
2245
|
+
{
|
|
2246
|
+
frame: { type: 'op'; topic: string; op_blob: string; client_op_id: string };
|
|
2247
|
+
attempts: number;
|
|
2248
|
+
timer: ReturnType<typeof setTimeout>;
|
|
2249
|
+
}
|
|
2250
|
+
>();
|
|
2251
|
+
// Ops this client is writing over HTTP, and the highest seq a catch-up
|
|
2252
|
+
// prompt named while one was in flight.
|
|
2253
|
+
//
|
|
2254
|
+
// The server tells a document's subscribers about an op the socket could
|
|
2255
|
+
// not carry by prompting them to catch up, and the writer is one of those
|
|
2256
|
+
// subscribers: the prompt names the seq of the op this client just wrote,
|
|
2257
|
+
// and arrives while the response that would resolve it is still in
|
|
2258
|
+
// flight. Answered immediately, the writer re-imports a whole snapshot
|
|
2259
|
+
// for an op it already applied, and `_applySnapshot` drops its undo and
|
|
2260
|
+
// redo stacks with it. So a prompt that lands mid-write is held and
|
|
2261
|
+
// re-judged by the same predicate once the write settles, where the
|
|
2262
|
+
// client's own seq is covered and the prompt is answered only if
|
|
2263
|
+
// something else moved the head.
|
|
2264
|
+
let httpOpWritesInFlight = 0;
|
|
2265
|
+
let heldCatchUpSeq = 0;
|
|
2266
|
+
|
|
2267
|
+
function settleHttpOpWrite(): void {
|
|
2268
|
+
httpOpWritesInFlight -= 1;
|
|
2269
|
+
if (httpOpWritesInFlight > 0) return;
|
|
2270
|
+
const seq = heldCatchUpSeq;
|
|
2271
|
+
heldCatchUpSeq = 0;
|
|
2272
|
+
if (seq > 0 && args.getCoveredSeq() < seq) args.resyncFromSnapshot?.();
|
|
2273
|
+
}
|
|
2274
|
+
|
|
2275
|
+
// Backoff: 1.5s, ~1.6x per attempt, ceiling 30s. The ceiling keeps a doc
|
|
2276
|
+
// on a dead connection from hammering; the reconnect-driven resend below
|
|
2277
|
+
// fires the instant `doc_init` arrives, so the ceiling never delays
|
|
2278
|
+
// recovery past the next reconnect.
|
|
2279
|
+
const RESEND_BASE_DELAY_MS = 1500;
|
|
2280
|
+
const RESEND_MAX_DELAY_MS = 30_000;
|
|
2281
|
+
|
|
2282
|
+
function resendDelayMs(attempts: number): number {
|
|
2283
|
+
const grown = RESEND_BASE_DELAY_MS * Math.pow(1.6, attempts - 1);
|
|
2284
|
+
return Math.min(grown, RESEND_MAX_DELAY_MS);
|
|
2285
|
+
}
|
|
2286
|
+
|
|
2287
|
+
function scheduleResend(clientOpId: string): void {
|
|
2288
|
+
const entry = outstandingResends.get(clientOpId);
|
|
2289
|
+
if (!entry) return;
|
|
2290
|
+
entry.timer = setTimeout(() => {
|
|
2291
|
+
const current = outstandingResends.get(clientOpId);
|
|
2292
|
+
if (!current) return;
|
|
2293
|
+
current.attempts += 1;
|
|
2294
|
+
args.wsClient.send(current.frame);
|
|
2295
|
+
scheduleResend(clientOpId);
|
|
2296
|
+
}, resendDelayMs(entry.attempts));
|
|
2297
|
+
}
|
|
2298
|
+
|
|
2299
|
+
// Resend every outstanding op now and restart its backoff. Called on
|
|
2300
|
+
// reconnect (`doc_init`): a dropped-and-restored connection is the most
|
|
2301
|
+
// common reason an op went unacked, and the backoff may have grown to its
|
|
2302
|
+
// ceiling while the socket was down, so re-asserting immediately is the
|
|
2303
|
+
// fast recovery path.
|
|
2304
|
+
function resendAllOutstanding(): void {
|
|
2305
|
+
for (const [clientOpId, entry] of outstandingResends) {
|
|
2306
|
+
clearTimeout(entry.timer);
|
|
2307
|
+
entry.attempts += 1;
|
|
2308
|
+
args.wsClient.send(entry.frame);
|
|
2309
|
+
scheduleResend(clientOpId);
|
|
2310
|
+
}
|
|
2311
|
+
}
|
|
2312
|
+
|
|
2313
|
+
// Durability handshake, run on every `doc_init`. The persisted version
|
|
2314
|
+
// vector + durable tail ops define the durable frontier; if the local doc
|
|
2315
|
+
// holds CRDT knowledge beyond it (an op acked then discarded by the
|
|
2316
|
+
// aged-orphan escape), resync-push the missing slice. Import is idempotent,
|
|
2317
|
+
// so what the server has no-ops and only the holes fill. The loop guard +
|
|
2318
|
+
// compute-and-push live on the Document (`maybeResyncPush`) so the
|
|
2319
|
+
// `doc_ops_dropped` path shares them; here we decode the vector and
|
|
2320
|
+
// delegate, skipping a missing vector (nothing to verify against).
|
|
2321
|
+
function maybeResyncPush(
|
|
2322
|
+
snapshotVersionVectorB64: string | undefined,
|
|
2323
|
+
durableTailBlobs: Uint8Array[],
|
|
2324
|
+
): void {
|
|
2325
|
+
if (!snapshotVersionVectorB64) return;
|
|
2326
|
+
args.doc.maybeResyncPush(
|
|
2327
|
+
base64ToBytes(snapshotVersionVectorB64),
|
|
2328
|
+
durableTailBlobs,
|
|
2329
|
+
);
|
|
2330
|
+
}
|
|
2331
|
+
|
|
2332
|
+
// Re-fetch the durable frontier from the HTTP join and run the handshake.
|
|
2333
|
+
// Shared by the `doc_ops_dropped` prompt path and the periodic re-verify:
|
|
2334
|
+
// both need the post-discard frontier, which only a fresh fetch carries,
|
|
2335
|
+
// not the `doc_init` frame in hand. Cheap (version-vector-and-tail JSON,
|
|
2336
|
+
// no snapshot blob).
|
|
2337
|
+
//
|
|
2338
|
+
// Coalesced, not just guarded: a request arriving mid-fetch sets
|
|
2339
|
+
// `reverifyAgain` so exactly one more fetch runs after this one settles. A
|
|
2340
|
+
// plain skip-if-in-flight would drop a `doc_ops_dropped` that landed while
|
|
2341
|
+
// a tick read the pre-discard frontier (the discard seals the new snapshot
|
|
2342
|
+
// then fans out), deferring the heal to the next tick or reconnect.
|
|
2343
|
+
let reverifyInFlight = false;
|
|
2344
|
+
let reverifyAgain = false;
|
|
2345
|
+
// Armed after the subscription is wired, cleared on `doc_gone` and teardown.
|
|
2346
|
+
let reverifyTimer: ReturnType<typeof setInterval> | null = null;
|
|
2347
|
+
async function reverifyDurability(): Promise<void> {
|
|
2348
|
+
if (reverifyInFlight) {
|
|
2349
|
+
reverifyAgain = true;
|
|
2350
|
+
return;
|
|
2351
|
+
}
|
|
2352
|
+
reverifyInFlight = true;
|
|
2353
|
+
try {
|
|
2354
|
+
do {
|
|
2355
|
+
reverifyAgain = false;
|
|
2356
|
+
try {
|
|
2357
|
+
const frontier = await args.refetchDurableFrontier?.();
|
|
2358
|
+
if (frontier && frontier.snapshotVersionVector) {
|
|
2359
|
+
args.doc.maybeResyncPush(
|
|
2360
|
+
base64ToBytes(frontier.snapshotVersionVector),
|
|
2361
|
+
frontier.tailBlobs,
|
|
2362
|
+
);
|
|
2363
|
+
}
|
|
2364
|
+
} catch {
|
|
2365
|
+
// Best-effort: a failed re-fetch defers the check to the next tick
|
|
2366
|
+
// or reconnect's `doc_init` handshake. Swallowed so the timer's
|
|
2367
|
+
// fire-and-forget never surfaces an unhandled rejection and one
|
|
2368
|
+
// failed tick does not wedge the loop guard.
|
|
2369
|
+
}
|
|
2370
|
+
} while (reverifyAgain);
|
|
2371
|
+
} finally {
|
|
2372
|
+
reverifyInFlight = false;
|
|
2373
|
+
}
|
|
2374
|
+
}
|
|
2375
|
+
|
|
2376
|
+
// A coverage floor below the highest seq seen records ops this client
|
|
2377
|
+
// applied nothing for, and the server posts each op once, so nothing
|
|
2378
|
+
// re-sends them. Pull the join instead, after a delay long enough for an
|
|
2379
|
+
// out-of-order arrival to close the hole itself.
|
|
2380
|
+
let gapTimer: ReturnType<typeof setTimeout> | null = null;
|
|
2381
|
+
function scheduleGapCheck(): void {
|
|
2382
|
+
if (gapTimer !== null) return;
|
|
2383
|
+
if (args.getCoveredSeq() >= args.getCurrentSeq()) return;
|
|
2384
|
+
gapTimer = setTimeout(() => {
|
|
2385
|
+
gapTimer = null;
|
|
2386
|
+
if (args.getCoveredSeq() >= args.getCurrentSeq()) return;
|
|
2387
|
+
args.resyncFromSnapshot?.();
|
|
2388
|
+
}, MISSED_OP_RESYNC_DELAY_MS);
|
|
2389
|
+
(gapTimer as { unref?: () => void }).unref?.();
|
|
2390
|
+
}
|
|
2391
|
+
|
|
2392
|
+
// 1. Subscribe to the doc topic. Frame routing:
|
|
2393
|
+
// - `doc_init`: bridge the join-vs-fanout race. Ops committed between
|
|
2394
|
+
// the HTTP join (T0) and the WS subscribe (T1) miss the HTTP tail and
|
|
2395
|
+
// can race the SUB# row so the first `doc_op` fanout may arrive BEFORE
|
|
2396
|
+
// doc_init (separate server invocations, no wire ordering). tail_ops
|
|
2397
|
+
// apply unconditionally: filtering on `seq > currentSeq` would mask
|
|
2398
|
+
// tail_ops a racing `doc_op` inflated the cursor past, leaving lamport
|
|
2399
|
+
// gaps Loro cannot converge across (re-applying is an idempotent
|
|
2400
|
+
// no-op).
|
|
2401
|
+
// - `doc_op`: apply, advance cursor.
|
|
2402
|
+
// - `op_ack`: resolve the outstanding-op tracker, advance cursor, cancel
|
|
2403
|
+
// that op's resend timer.
|
|
2404
|
+
// - everything else: handled by `BrassWsClient`.
|
|
2405
|
+
const unsub = args.wsClient.subscribe(topic, (frame) => {
|
|
2406
|
+
if (isDocInitFrame(frame)) {
|
|
2407
|
+
// Resync check BEFORE applying. The tail covers (snapshot_seq,
|
|
2408
|
+
// current_seq]; if the coverage floor sits below the tail's lower edge
|
|
2409
|
+
// (or below current_seq when the tail is empty), ops in between were
|
|
2410
|
+
// compacted into the snapshot while disconnected and exist nowhere in
|
|
2411
|
+
// this tail or future fanout, so re-import the join's snapshot. The
|
|
2412
|
+
// initial subscribe never trips this (the floor was just seeded), so
|
|
2413
|
+
// the cost is paid only on a reconnect that crossed a compaction.
|
|
2414
|
+
const minTailSeq = frame.tail_ops.reduce<number | null>(
|
|
2415
|
+
(acc, entry) => (acc === null || entry.seq < acc ? entry.seq : acc),
|
|
2416
|
+
null,
|
|
2417
|
+
);
|
|
2418
|
+
const snapshotFloor =
|
|
2419
|
+
minTailSeq !== null ? minTailSeq - 1 : frame.current_seq;
|
|
2420
|
+
const needsResync = args.getCoveredSeq() < snapshotFloor;
|
|
2421
|
+
const tailBlobs: Uint8Array[] = [];
|
|
2422
|
+
for (const entry of frame.tail_ops) {
|
|
2423
|
+
// An op with no inline bytes is deliberately left uncovered: it
|
|
2424
|
+
// has not been applied, so marking it covered would retire the
|
|
2425
|
+
// very resync that fetches it.
|
|
2426
|
+
if (entry.op_blob_b64 === undefined) continue;
|
|
2427
|
+
const blob = base64ToBytes(entry.op_blob_b64);
|
|
2428
|
+
tailBlobs.push(blob);
|
|
2429
|
+
args.doc.applyRemoteOp(blob);
|
|
2430
|
+
if (entry.seq > args.getCurrentSeq()) args.setCurrentSeq(entry.seq);
|
|
2431
|
+
args.advanceCoveredSeq(entry.seq);
|
|
2432
|
+
}
|
|
2433
|
+
if (frame.current_seq > args.getCurrentSeq()) {
|
|
2434
|
+
args.setCurrentSeq(frame.current_seq);
|
|
2435
|
+
}
|
|
2436
|
+
if (needsResync) {
|
|
2437
|
+
if (httpOpWritesInFlight > 0) {
|
|
2438
|
+
if (frame.current_seq > heldCatchUpSeq) heldCatchUpSeq = frame.current_seq;
|
|
2439
|
+
} else {
|
|
2440
|
+
args.resyncFromSnapshot?.();
|
|
2441
|
+
}
|
|
2442
|
+
}
|
|
2443
|
+
// `doc_init` is the reconnect signal the retry-forever recovery hangs
|
|
2444
|
+
// off: re-assert every outstanding op and run the durability handshake
|
|
2445
|
+
// now, so a client holding work the server lost re-asserts it on
|
|
2446
|
+
// reconnect rather than waiting out the per-op backoff.
|
|
2447
|
+
resendAllOutstanding();
|
|
2448
|
+
// Tolerate a non-string vector (a future retyping) as absent: the
|
|
2449
|
+
// handshake is skipped, but the frame still delivers.
|
|
2450
|
+
maybeResyncPush(
|
|
2451
|
+
typeof frame.snapshot_version_vector === 'string'
|
|
2452
|
+
? frame.snapshot_version_vector
|
|
2453
|
+
: undefined,
|
|
2454
|
+
tailBlobs,
|
|
2455
|
+
);
|
|
2456
|
+
// Posted after the SUB# row exists, so this is the safe point to
|
|
2457
|
+
// reconcile a pending open whose terminal status frame was dropped.
|
|
2458
|
+
args.reconcileStatusOnInit?.();
|
|
2459
|
+
return;
|
|
2460
|
+
}
|
|
2461
|
+
if (isDocOpFrame(frame)) {
|
|
2462
|
+
// Apply unconditionally. A `seq <= currentSeq` short-circuit looks safe
|
|
2463
|
+
// (import is idempotent) but permanently drops ops on out-of-order
|
|
2464
|
+
// fanout: seq=14 before seq=13 inflates the cursor past 13, after which
|
|
2465
|
+
// seq=13 is skipped forever, leaving a lamport gap Loro cannot converge
|
|
2466
|
+
// across.
|
|
2467
|
+
const bytes = base64ToBytes(frame.op_blob);
|
|
2468
|
+
args.doc.applyRemoteOp(bytes);
|
|
2469
|
+
if (frame.seq > args.getCurrentSeq()) args.setCurrentSeq(frame.seq);
|
|
2470
|
+
args.advanceCoveredSeq(frame.seq);
|
|
2471
|
+
scheduleGapCheck();
|
|
2472
|
+
return;
|
|
2473
|
+
}
|
|
2474
|
+
if (isOpAckFrame(frame)) {
|
|
2475
|
+
args.doc.resolveOutstandingOp(frame.client_op_id);
|
|
2476
|
+
args.setCurrentSeq(frame.seq);
|
|
2477
|
+
args.advanceCoveredSeq(frame.seq);
|
|
2478
|
+
// An ack for a local op sits above any remote op this client never
|
|
2479
|
+
// received, so the user's own next edit is a second chance to notice.
|
|
2480
|
+
scheduleGapCheck();
|
|
2481
|
+
const pending = outstandingResends.get(frame.client_op_id);
|
|
2482
|
+
if (pending) {
|
|
2483
|
+
clearTimeout(pending.timer);
|
|
2484
|
+
outstandingResends.delete(frame.client_op_id);
|
|
2485
|
+
}
|
|
2486
|
+
return;
|
|
2487
|
+
}
|
|
2488
|
+
if (isOpRejectedFrame(frame)) {
|
|
2489
|
+
// Terminal, not transient: cancel this op's resend (retrying would only
|
|
2490
|
+
// draw the same rejection forever and pin `savingCount`) and fail it on
|
|
2491
|
+
// the Document so the app hears "could not be saved" instead of a silent
|
|
2492
|
+
// wedge. Scoped to this one op; the topic subscription stays live.
|
|
2493
|
+
const pending = outstandingResends.get(frame.client_op_id);
|
|
2494
|
+
if (pending) {
|
|
2495
|
+
clearTimeout(pending.timer);
|
|
2496
|
+
outstandingResends.delete(frame.client_op_id);
|
|
2497
|
+
}
|
|
2498
|
+
args.doc._failOutstandingOp(frame.client_op_id, opRejectedMessage(frame));
|
|
2499
|
+
return;
|
|
2500
|
+
}
|
|
2501
|
+
if (isDocGoneFrame(frame)) {
|
|
2502
|
+
// Cancel every resend timer FIRST so a doc that was saving when delete
|
|
2503
|
+
// landed stops re-trying ops the server will only reject as `forbidden`
|
|
2504
|
+
// (the SUB# row was evicted at fanout). Draining before the `_markGone`
|
|
2505
|
+
// listeners keeps the lifecycle observably clean.
|
|
2506
|
+
for (const pending of outstandingResends.values()) {
|
|
2507
|
+
clearTimeout(pending.timer);
|
|
2508
|
+
}
|
|
2509
|
+
outstandingResends.clear();
|
|
2510
|
+
// Stop re-verifying durability against an evicted doc: a resync push
|
|
2511
|
+
// would only bounce as `forbidden`, and a gap pull would only 404.
|
|
2512
|
+
if (reverifyTimer !== null) {
|
|
2513
|
+
clearInterval(reverifyTimer);
|
|
2514
|
+
reverifyTimer = null;
|
|
2515
|
+
}
|
|
2516
|
+
if (gapTimer !== null) {
|
|
2517
|
+
clearTimeout(gapTimer);
|
|
2518
|
+
gapTimer = null;
|
|
2519
|
+
}
|
|
2520
|
+
args.doc._markGone(goneReasonOf(frame));
|
|
2521
|
+
return;
|
|
2522
|
+
}
|
|
2523
|
+
if (isDocStatusFrame(frame)) {
|
|
2524
|
+
// The validator gates only on `pending`; default `message` here so a
|
|
2525
|
+
// message-less status still carries a valid `DocumentWorkStatus`
|
|
2526
|
+
// (`message` is typed non-null and rendered verbatim).
|
|
2527
|
+
const status: DocumentWorkStatus = {
|
|
2528
|
+
...frame.status,
|
|
2529
|
+
message:
|
|
2530
|
+
typeof frame.status.message === 'string' ? frame.status.message : '',
|
|
2531
|
+
};
|
|
2532
|
+
// `reload` is the server's authoritative "succeeded, content ready"
|
|
2533
|
+
// hint: pull the content in and emit the terminal status only AFTER the
|
|
2534
|
+
// import lands, so an app re-reading on `onStatus` sees content present.
|
|
2535
|
+
// A failure frame has no `reload`, so emit immediately and fetch
|
|
2536
|
+
// nothing (no racy read, no loop).
|
|
2537
|
+
if (frame.reload && args.reloadAfterHydration) {
|
|
2538
|
+
args.reloadAfterHydration(status);
|
|
2539
|
+
} else {
|
|
2540
|
+
args.doc._emitStatus(status);
|
|
2541
|
+
}
|
|
2542
|
+
return;
|
|
2543
|
+
}
|
|
2544
|
+
if (isDocMetaFrame(frame)) {
|
|
2545
|
+
// `_setName` de-dupes, so a re-emit of the current name is a no-op.
|
|
2546
|
+
args.doc._setName(frame.name);
|
|
2547
|
+
return;
|
|
2548
|
+
}
|
|
2549
|
+
if (isDocDriveStatusFrame(frame)) {
|
|
2550
|
+
// The signal de-dupes a no-op re-emit, so forwarding every frame is safe.
|
|
2551
|
+
args.doc._emitDriveStatus(toDriveStatus(frame.drive));
|
|
2552
|
+
return;
|
|
2553
|
+
}
|
|
2554
|
+
if (isDocOpsDroppedFrame(frame)) {
|
|
2555
|
+
// Prompt durability re-check: the discard sealed a new snapshot, so
|
|
2556
|
+
// re-fetch the moved frontier and run the handshake now instead of at
|
|
2557
|
+
// the author's next reconnect. The handshake self-selects: the author
|
|
2558
|
+
// finds local ahead and resync-pushes under a new seq, others no-op.
|
|
2559
|
+
// Covered by the `doc_init` handshake / periodic re-verify if missed.
|
|
2560
|
+
void reverifyDurability();
|
|
2561
|
+
return;
|
|
2562
|
+
}
|
|
2563
|
+
// Unknown frame: ignore, so server-side schema growth doesn't break older
|
|
2564
|
+
// SDK builds.
|
|
2565
|
+
});
|
|
2566
|
+
|
|
2567
|
+
// Arm the periodic durability re-verify (the long-lived-tab backstop).
|
|
2568
|
+
// `unref` so the interval never keeps a Node host alive (no-op in the
|
|
2569
|
+
// browser); cleared on `doc_gone` and teardown either way.
|
|
2570
|
+
reverifyTimer = setInterval(() => {
|
|
2571
|
+
void reverifyDurability();
|
|
2572
|
+
}, PERIODIC_REVERIFY_INTERVAL_MS);
|
|
2573
|
+
(reverifyTimer as { unref?: () => void }).unref?.();
|
|
2574
|
+
|
|
2575
|
+
// 2. Install the local-op hook, called by `subscribeLocalUpdates` after
|
|
2576
|
+
// every successful `commit()`. Each frame is registered in the resend
|
|
2577
|
+
// tracker so a silently-dropped server response triggers a retry.
|
|
2578
|
+
args.doc.setLocalOpHook((opBlob) => {
|
|
2579
|
+
const clientOpId = args.newClientOpId();
|
|
2580
|
+
const opBlobB64 = bytesToBase64(opBlob);
|
|
2581
|
+
const sendFrame: {
|
|
2582
|
+
type: 'op';
|
|
2583
|
+
topic: string;
|
|
2584
|
+
op_blob: string;
|
|
2585
|
+
client_op_id: string;
|
|
2586
|
+
} = {
|
|
2587
|
+
type: 'op',
|
|
2588
|
+
topic,
|
|
2589
|
+
op_blob: opBlobB64,
|
|
2590
|
+
client_op_id: clientOpId,
|
|
2591
|
+
};
|
|
2592
|
+
|
|
2593
|
+
// A frame past the transport's cap is not delivered and takes the
|
|
2594
|
+
// connection down with it, so it must never reach the socket OR the
|
|
2595
|
+
// resend tracker: re-sending it on each reconnect is what turns one
|
|
2596
|
+
// bulk mutation into a reconnect loop that persists nothing and churns
|
|
2597
|
+
// presence. Route it to the HTTP op write instead, which shares the
|
|
2598
|
+
// durable path and tells other subscribers to catch up.
|
|
2599
|
+
if (opFrameExceedsWsLimit(sendFrame)) {
|
|
2600
|
+
void writeOversizedOp(opBlobB64, clientOpId);
|
|
2601
|
+
return clientOpId;
|
|
2602
|
+
}
|
|
2603
|
+
|
|
2604
|
+
args.wsClient.send(sendFrame);
|
|
2605
|
+
outstandingResends.set(clientOpId, {
|
|
2606
|
+
frame: sendFrame,
|
|
2607
|
+
attempts: 1,
|
|
2608
|
+
// Placeholder, overwritten immediately below.
|
|
2609
|
+
timer: setTimeout(() => undefined, 0),
|
|
2610
|
+
});
|
|
2611
|
+
scheduleResend(clientOpId);
|
|
2612
|
+
return clientOpId;
|
|
2613
|
+
});
|
|
2614
|
+
|
|
2615
|
+
async function writeOversizedOp(
|
|
2616
|
+
opBlobB64: string,
|
|
2617
|
+
clientOpId: string,
|
|
2618
|
+
): Promise<void> {
|
|
2619
|
+
// Counted before the first await, so a catch-up prompt for this op
|
|
2620
|
+
// cannot arrive between the frame going out and the count going up.
|
|
2621
|
+
httpOpWritesInFlight += 1;
|
|
2622
|
+
try {
|
|
2623
|
+
await writeOversizedOpInner(opBlobB64, clientOpId);
|
|
2624
|
+
} finally {
|
|
2625
|
+
settleHttpOpWrite();
|
|
2626
|
+
}
|
|
2627
|
+
}
|
|
2628
|
+
|
|
2629
|
+
async function writeOversizedOpInner(
|
|
2630
|
+
opBlobB64: string,
|
|
2631
|
+
clientOpId: string,
|
|
2632
|
+
): Promise<void> {
|
|
2633
|
+
// Yield before any outcome resolves the op. The hook's caller records
|
|
2634
|
+
// the outstanding op AFTER the hook returns, and an async function body
|
|
2635
|
+
// runs synchronously up to its first await, so an exit taken inline
|
|
2636
|
+
// would find nothing tracked and leave the entry behind with
|
|
2637
|
+
// `savingCount` stuck above zero.
|
|
2638
|
+
await Promise.resolve();
|
|
2639
|
+
|
|
2640
|
+
// The size is known here, so refuse without spending a round trip on a
|
|
2641
|
+
// blob that can only come back 400.
|
|
2642
|
+
if (opBlobExceedsDurableLimit(opBlobB64)) {
|
|
2643
|
+
args.doc._failOutstandingOp(clientOpId, TOO_LARGE_MESSAGE);
|
|
2644
|
+
return;
|
|
2645
|
+
}
|
|
2646
|
+
|
|
2647
|
+
if (!args.postOversizedOp) {
|
|
2648
|
+
args.doc._failOutstandingOp(clientOpId, TOO_LARGE_MESSAGE);
|
|
2649
|
+
return;
|
|
2650
|
+
}
|
|
2651
|
+
let result: Awaited<ReturnType<NonNullable<WireDocTransportArgs['postOversizedOp']>>>;
|
|
2652
|
+
try {
|
|
2653
|
+
result = await args.postOversizedOp(opBlobB64, clientOpId);
|
|
2654
|
+
} catch (err) {
|
|
2655
|
+
result = {
|
|
2656
|
+
ok: false,
|
|
2657
|
+
message: err instanceof Error ? err.message : String(err),
|
|
2658
|
+
};
|
|
2659
|
+
}
|
|
2660
|
+
if (!result.ok) {
|
|
2661
|
+
// Terminal for this op, the same way `op_rejected` is: the local
|
|
2662
|
+
// commit stays applied but never persists, so the app hears about it
|
|
2663
|
+
// instead of watching `savingCount` sit above zero forever.
|
|
2664
|
+
args.doc._failOutstandingOp(clientOpId, result.message);
|
|
2665
|
+
return;
|
|
2666
|
+
}
|
|
2667
|
+
// Same bookkeeping an `op_ack` does: resolve the outstanding entry and
|
|
2668
|
+
// advance both cursors past the seq the server allocated.
|
|
2669
|
+
args.doc.resolveOutstandingOp(clientOpId);
|
|
2670
|
+
args.setCurrentSeq(result.seq);
|
|
2671
|
+
args.advanceCoveredSeq(result.seq);
|
|
2672
|
+
}
|
|
2673
|
+
|
|
2674
|
+
// 2b. Expose the live-head-seq cursor so `Brass.documents.setThumbnail` can
|
|
2675
|
+
// anchor a screenshot to the version it depicts.
|
|
2676
|
+
args.doc.setCurrentSeqProvider(args.getCurrentSeq);
|
|
2677
|
+
|
|
2678
|
+
// 3. Attach the presence controller. It subscribes to the sibling
|
|
2679
|
+
// `presence:doc:<docId>:<branchId>` topic on the same shared client, so
|
|
2680
|
+
// presence and ops are independent fanout channels on one connection,
|
|
2681
|
+
// both scoped to the opened branch. Exposed as `doc.presence`.
|
|
2682
|
+
// Presence is document-level (one roster per open doc), so a stream
|
|
2683
|
+
// line's transport skips it.
|
|
2684
|
+
if (args.skipPresence !== true) {
|
|
2685
|
+
args.doc.presence = createPresenceController(
|
|
2686
|
+
args.wsClient,
|
|
2687
|
+
args.docId,
|
|
2688
|
+
args.branchId,
|
|
2689
|
+
);
|
|
2690
|
+
}
|
|
2691
|
+
|
|
2692
|
+
// 4. Tear-down hook. Patch `Document.close` once (rather than have Document
|
|
2693
|
+
// accept a teardown callback) so `Document` stays free of WS knowledge:
|
|
2694
|
+
// the first close detaches the subscription, drops the hook, tears down
|
|
2695
|
+
// presence, then runs the original close.
|
|
2696
|
+
const originalClose = args.doc.close.bind(args.doc);
|
|
2697
|
+
// Set once `fullTeardown` has run (via the unparkable close path OR a park
|
|
2698
|
+
// cache eviction). Guards both `fullTeardown` (so its intermediate steps
|
|
2699
|
+
// don't run twice) and the patched close below (so a double-close of an
|
|
2700
|
+
// already-evicted, already-closed Document does not re-park it, which would
|
|
2701
|
+
// hand a later open a closed Document whose every method throws).
|
|
2702
|
+
let tornDown = false;
|
|
2703
|
+
// Captured so it can run from either the patched close (unparkable case) or
|
|
2704
|
+
// the park cache's eviction.
|
|
2705
|
+
const fullTeardown = (): void => {
|
|
2706
|
+
if (tornDown) return;
|
|
2707
|
+
tornDown = true;
|
|
2708
|
+
try {
|
|
2709
|
+
unsub();
|
|
2710
|
+
} catch {
|
|
2711
|
+
// unsub failure is non-fatal (already-detached); closing the Document
|
|
2712
|
+
// below is what matters.
|
|
2713
|
+
}
|
|
2714
|
+
try {
|
|
2715
|
+
args.doc.presence?.leave();
|
|
2716
|
+
} catch {
|
|
2717
|
+
// Best-effort; never block the Document close below.
|
|
2718
|
+
}
|
|
2719
|
+
args.doc.setLocalOpHook(null);
|
|
2720
|
+
args.doc.setCurrentSeqProvider(null);
|
|
2721
|
+
if (reverifyTimer !== null) {
|
|
2722
|
+
clearInterval(reverifyTimer);
|
|
2723
|
+
reverifyTimer = null;
|
|
2724
|
+
}
|
|
2725
|
+
if (gapTimer !== null) {
|
|
2726
|
+
clearTimeout(gapTimer);
|
|
2727
|
+
gapTimer = null;
|
|
2728
|
+
}
|
|
2729
|
+
for (const entry of outstandingResends.values()) {
|
|
2730
|
+
clearTimeout(entry.timer);
|
|
2731
|
+
}
|
|
2732
|
+
outstandingResends.clear();
|
|
2733
|
+
originalClose();
|
|
2734
|
+
};
|
|
2735
|
+
(args.doc as Document & { close: () => void }).close = () => {
|
|
2736
|
+
// Already fully torn down (an LRU eviction closed this Document while it
|
|
2737
|
+
// was parked): a second close must not re-park a closed Document.
|
|
2738
|
+
if (tornDown) return;
|
|
2739
|
+
// Read-only previews bypass parking: no undo stack to preserve, and a
|
|
2740
|
+
// stale preview would shadow a later live-head open. Same for a doc with
|
|
2741
|
+
// no stable branchId to key on, and for a stream line's internal doc
|
|
2742
|
+
// (its lifecycle is the parent Document's). Keying on the REQUESTED branch
|
|
2743
|
+
// (not the resolved one) keeps park and take symmetric; a default-branch
|
|
2744
|
+
// open has no requested branch and so skips the cache.
|
|
2745
|
+
const cacheKey =
|
|
2746
|
+
args.doc.readOnly || args.parkable === false
|
|
2747
|
+
? null
|
|
2748
|
+
: parkCacheKey(args.docId, args.requestedBranchId, args.requestedStream);
|
|
2749
|
+
if (cacheKey === null) {
|
|
2750
|
+
fullTeardown();
|
|
2751
|
+
return;
|
|
2752
|
+
}
|
|
2753
|
+
args.doc._suspend();
|
|
2754
|
+
parkDocument({ doc: args.doc, teardown: fullTeardown, key: cacheKey });
|
|
2755
|
+
};
|
|
2756
|
+
}
|
|
2757
|
+
|
|
2758
|
+
/**
|
|
2759
|
+
* Fetch a presigned snapshot, streaming the body so download progress
|
|
2760
|
+
* can be reported as bytes arrive. The total is the decompressed size
|
|
2761
|
+
* from the object's `x-amz-meta-raw-bytes` metadata header (snapshots
|
|
2762
|
+
* are stored gzip-compressed; the browser decompresses transparently,
|
|
2763
|
+
* so the streamed chunk sizes sum to the decompressed total). Falls
|
|
2764
|
+
* back to a non-streamed read when the body stream or progress sink is
|
|
2765
|
+
* unavailable.
|
|
2766
|
+
*/
|
|
2767
|
+
async function fetchPresignedSnapshot(
|
|
2768
|
+
url: string,
|
|
2769
|
+
onProgress?: (loaded: number, total: number | null) => void,
|
|
2770
|
+
): Promise<Uint8Array> {
|
|
2771
|
+
const response = await fetch(url, { method: 'GET' });
|
|
2772
|
+
if (!response.ok) {
|
|
2773
|
+
throw new BrassError('Presigned snapshot fetch failed', {
|
|
2774
|
+
status: response.status,
|
|
2775
|
+
});
|
|
2776
|
+
}
|
|
2777
|
+
const rawHeader = response.headers.get('x-amz-meta-raw-bytes');
|
|
2778
|
+
const total =
|
|
2779
|
+
rawHeader !== null && Number.isFinite(Number(rawHeader))
|
|
2780
|
+
? Number(rawHeader)
|
|
2781
|
+
: null;
|
|
2782
|
+
if (!onProgress || !response.body) {
|
|
2783
|
+
const buf = await response.arrayBuffer();
|
|
2784
|
+
onProgress?.(buf.byteLength, total);
|
|
2785
|
+
return new Uint8Array(buf);
|
|
2786
|
+
}
|
|
2787
|
+
const reader = response.body.getReader();
|
|
2788
|
+
const chunks: Uint8Array[] = [];
|
|
2789
|
+
let loaded = 0;
|
|
2790
|
+
onProgress(0, total);
|
|
2791
|
+
for (;;) {
|
|
2792
|
+
const { done, value } = await reader.read();
|
|
2793
|
+
if (done) break;
|
|
2794
|
+
if (value) {
|
|
2795
|
+
chunks.push(value);
|
|
2796
|
+
loaded += value.byteLength;
|
|
2797
|
+
onProgress(loaded, total);
|
|
2798
|
+
}
|
|
2799
|
+
}
|
|
2800
|
+
const out = new Uint8Array(loaded);
|
|
2801
|
+
let offset = 0;
|
|
2802
|
+
for (const chunk of chunks) {
|
|
2803
|
+
out.set(chunk, offset);
|
|
2804
|
+
offset += chunk.byteLength;
|
|
2805
|
+
}
|
|
2806
|
+
return out;
|
|
2807
|
+
}
|
|
2808
|
+
|
|
2809
|
+
// A snapshot delivery `kind` this build does not implement means the server is
|
|
2810
|
+
// serving a document in a shape newer than this pinned SDK can read. Surface a
|
|
2811
|
+
// legible upgrade prompt (rendered verbatim through `session.error`), never an
|
|
2812
|
+
// empty document: treating an unreadable snapshot as "no snapshot" would let
|
|
2813
|
+
// the user edit and overwrite content that is actually there.
|
|
2814
|
+
function snapshotKindTooNewError(): BrassError {
|
|
2815
|
+
return new BrassError(
|
|
2816
|
+
'This document needs a newer version of this app. Update to the latest @brass-build/client to open it.',
|
|
2817
|
+
{ status: 426 },
|
|
2818
|
+
);
|
|
2819
|
+
}
|
|
2820
|
+
|
|
2821
|
+
async function materialise(
|
|
2822
|
+
body: DocumentSnapshotResponse,
|
|
2823
|
+
documentOptions?: DocumentOptions,
|
|
2824
|
+
onSnapshotProgress?: (loaded: number, total: number | null) => void,
|
|
2825
|
+
): Promise<OpenedDocument> {
|
|
2826
|
+
// Stamp the server-minted doc_id onto the Document (via DocumentOptions, so
|
|
2827
|
+
// it threads through the LoroDoc constructor without changing the static
|
|
2828
|
+
// factory signatures) so callers can read `doc.id` to correlate.
|
|
2829
|
+
const optionsWithId: DocumentOptions = {
|
|
2830
|
+
...documentOptions,
|
|
2831
|
+
docId: body.doc_id,
|
|
2832
|
+
branchId: body.branch_id,
|
|
2833
|
+
};
|
|
2834
|
+
let doc: Document;
|
|
2835
|
+
const snap = body.snapshot;
|
|
2836
|
+
if (snap === null) {
|
|
2837
|
+
doc = Document.create(optionsWithId);
|
|
2838
|
+
} else if (snap.kind === 'inline') {
|
|
2839
|
+
const endDecode = perfStart('open: base64 decode (inline snapshot)');
|
|
2840
|
+
const bytes = base64ToBytes(snap.data_b64);
|
|
2841
|
+
endDecode({ base64Chars: snap.data_b64.length, snapshotBytes: bytes.byteLength });
|
|
2842
|
+
const endImport = perfStart('open: loro import (inline snapshot)');
|
|
2843
|
+
doc = Document.openFromSnapshot(bytes, optionsWithId);
|
|
2844
|
+
endImport({ snapshotBytes: bytes.byteLength });
|
|
2845
|
+
} else if (snap.kind === 'presigned') {
|
|
2846
|
+
const endPresigned = perfStart('open: presigned snapshot fetch');
|
|
2847
|
+
const bytes = await fetchPresignedSnapshot(snap.url, onSnapshotProgress);
|
|
2848
|
+
endPresigned({ snapshotBytes: bytes.byteLength });
|
|
2849
|
+
const endImport = perfStart('open: loro import (presigned snapshot)');
|
|
2850
|
+
doc = Document.openFromSnapshot(bytes, optionsWithId);
|
|
2851
|
+
endImport({ snapshotBytes: bytes.byteLength });
|
|
2852
|
+
} else {
|
|
2853
|
+
throw snapshotKindTooNewError();
|
|
2854
|
+
}
|
|
2855
|
+
|
|
2856
|
+
// Apply tail ops in seq order. The server delivers them sorted; we sort
|
|
2857
|
+
// defensively (Loro dedupes by lamport, so out-of-order is safe) rather
|
|
2858
|
+
// than let a misbehaving server produce divergent state.
|
|
2859
|
+
const endTail = perfStart('open: apply tail ops');
|
|
2860
|
+
const tail = [...body.tail_ops].sort((a, b) => a.seq - b.seq);
|
|
2861
|
+
for (const entry of tail) {
|
|
2862
|
+
doc.applyRemoteOp(base64ToBytes(entry.op_blob_b64));
|
|
2863
|
+
}
|
|
2864
|
+
endTail({ count: tail.length });
|
|
2865
|
+
|
|
2866
|
+
// `doc.id` is non-null at runtime (stamped above); the cast closes the type
|
|
2867
|
+
// gap without weakening the class's `string | null` signature.
|
|
2868
|
+
return doc as OpenedDocument;
|
|
2869
|
+
}
|