@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.
Files changed (258) hide show
  1. package/AGENTS.md +255 -0
  2. package/CHANGELOG.md +15 -0
  3. package/LICENSE +21 -0
  4. package/README.md +836 -0
  5. package/dist/api-fetch.d.ts +16 -0
  6. package/dist/api-fetch.d.ts.map +1 -0
  7. package/dist/api-fetch.js +97 -0
  8. package/dist/api-fetch.js.map +1 -0
  9. package/dist/api-headers.d.ts +3 -0
  10. package/dist/api-headers.d.ts.map +1 -0
  11. package/dist/api-headers.js +25 -0
  12. package/dist/api-headers.js.map +1 -0
  13. package/dist/auth-flow.d.ts +23 -0
  14. package/dist/auth-flow.d.ts.map +1 -0
  15. package/dist/auth-flow.js +76 -0
  16. package/dist/auth-flow.js.map +1 -0
  17. package/dist/client.d.ts +106 -0
  18. package/dist/client.d.ts.map +1 -0
  19. package/dist/client.js +1278 -0
  20. package/dist/client.js.map +1 -0
  21. package/dist/doc-url-sync.d.ts +19 -0
  22. package/dist/doc-url-sync.d.ts.map +1 -0
  23. package/dist/doc-url-sync.js +181 -0
  24. package/dist/doc-url-sync.js.map +1 -0
  25. package/dist/document-branches.d.ts +32 -0
  26. package/dist/document-branches.d.ts.map +1 -0
  27. package/dist/document-branches.js +89 -0
  28. package/dist/document-branches.js.map +1 -0
  29. package/dist/document-checkpoints.d.ts +24 -0
  30. package/dist/document-checkpoints.d.ts.map +1 -0
  31. package/dist/document-checkpoints.js +115 -0
  32. package/dist/document-checkpoints.js.map +1 -0
  33. package/dist/document-commands.d.ts +93 -0
  34. package/dist/document-commands.d.ts.map +1 -0
  35. package/dist/document-commands.js +423 -0
  36. package/dist/document-commands.js.map +1 -0
  37. package/dist/document-signals.d.ts +53 -0
  38. package/dist/document-signals.d.ts.map +1 -0
  39. package/dist/document-signals.js +114 -0
  40. package/dist/document-signals.js.map +1 -0
  41. package/dist/document-streams.d.ts +22 -0
  42. package/dist/document-streams.d.ts.map +1 -0
  43. package/dist/document-streams.js +98 -0
  44. package/dist/document-streams.js.map +1 -0
  45. package/dist/document-thumbnail-capture.d.ts +27 -0
  46. package/dist/document-thumbnail-capture.d.ts.map +1 -0
  47. package/dist/document-thumbnail-capture.js +160 -0
  48. package/dist/document-thumbnail-capture.js.map +1 -0
  49. package/dist/documents-namespace.d.ts +134 -0
  50. package/dist/documents-namespace.d.ts.map +1 -0
  51. package/dist/documents-namespace.js +190 -0
  52. package/dist/documents-namespace.js.map +1 -0
  53. package/dist/documents.d.ts +146 -0
  54. package/dist/documents.d.ts.map +1 -0
  55. package/dist/documents.js +2045 -0
  56. package/dist/documents.js.map +1 -0
  57. package/dist/emitter.d.ts +15 -0
  58. package/dist/emitter.d.ts.map +1 -0
  59. package/dist/emitter.js +90 -0
  60. package/dist/emitter.js.map +1 -0
  61. package/dist/errors.d.ts +18 -0
  62. package/dist/errors.d.ts.map +1 -0
  63. package/dist/errors.js +22 -0
  64. package/dist/errors.js.map +1 -0
  65. package/dist/export-guest.d.ts +9 -0
  66. package/dist/export-guest.d.ts.map +1 -0
  67. package/dist/export-guest.js +225 -0
  68. package/dist/export-guest.js.map +1 -0
  69. package/dist/fetch-retry.d.ts +16 -0
  70. package/dist/fetch-retry.d.ts.map +1 -0
  71. package/dist/fetch-retry.js +90 -0
  72. package/dist/fetch-retry.js.map +1 -0
  73. package/dist/guest-link.d.ts +3 -0
  74. package/dist/guest-link.d.ts.map +1 -0
  75. package/dist/guest-link.js +13 -0
  76. package/dist/guest-link.js.map +1 -0
  77. package/dist/handles.d.ts +57 -0
  78. package/dist/handles.d.ts.map +1 -0
  79. package/dist/handles.js +1375 -0
  80. package/dist/handles.js.map +1 -0
  81. package/dist/hosted-runtime.d.ts +33 -0
  82. package/dist/hosted-runtime.d.ts.map +1 -0
  83. package/dist/hosted-runtime.js +99 -0
  84. package/dist/hosted-runtime.js.map +1 -0
  85. package/dist/hosted-ui.d.ts +84 -0
  86. package/dist/hosted-ui.d.ts.map +1 -0
  87. package/dist/hosted-ui.js +673 -0
  88. package/dist/hosted-ui.js.map +1 -0
  89. package/dist/importer.d.ts +22 -0
  90. package/dist/importer.d.ts.map +1 -0
  91. package/dist/importer.js +54 -0
  92. package/dist/importer.js.map +1 -0
  93. package/dist/index.d.ts +54 -0
  94. package/dist/index.d.ts.map +1 -0
  95. package/dist/index.js +132 -0
  96. package/dist/index.js.map +1 -0
  97. package/dist/init-keys.d.ts +3 -0
  98. package/dist/init-keys.d.ts.map +1 -0
  99. package/dist/init-keys.js +13 -0
  100. package/dist/init-keys.js.map +1 -0
  101. package/dist/internal.d.ts +6 -0
  102. package/dist/internal.d.ts.map +1 -0
  103. package/dist/internal.js +17 -0
  104. package/dist/internal.js.map +1 -0
  105. package/dist/jwt.d.ts +3 -0
  106. package/dist/jwt.d.ts.map +1 -0
  107. package/dist/jwt.js +22 -0
  108. package/dist/jwt.js.map +1 -0
  109. package/dist/launch-context.d.ts +4 -0
  110. package/dist/launch-context.d.ts.map +1 -0
  111. package/dist/launch-context.js +44 -0
  112. package/dist/launch-context.js.map +1 -0
  113. package/dist/login-hint.d.ts +4 -0
  114. package/dist/login-hint.d.ts.map +1 -0
  115. package/dist/login-hint.js +63 -0
  116. package/dist/login-hint.js.map +1 -0
  117. package/dist/mutate-batching.d.ts +14 -0
  118. package/dist/mutate-batching.d.ts.map +1 -0
  119. package/dist/mutate-batching.js +65 -0
  120. package/dist/mutate-batching.js.map +1 -0
  121. package/dist/open-document.d.ts +127 -0
  122. package/dist/open-document.d.ts.map +1 -0
  123. package/dist/open-document.js +2121 -0
  124. package/dist/open-document.js.map +1 -0
  125. package/dist/page-title.d.ts +3 -0
  126. package/dist/page-title.d.ts.map +1 -0
  127. package/dist/page-title.js +32 -0
  128. package/dist/page-title.js.map +1 -0
  129. package/dist/perf.d.ts +9 -0
  130. package/dist/perf.d.ts.map +1 -0
  131. package/dist/perf.js +39 -0
  132. package/dist/perf.js.map +1 -0
  133. package/dist/presence.d.ts +62 -0
  134. package/dist/presence.d.ts.map +1 -0
  135. package/dist/presence.js +331 -0
  136. package/dist/presence.js.map +1 -0
  137. package/dist/record-paging.d.ts +99 -0
  138. package/dist/record-paging.d.ts.map +1 -0
  139. package/dist/record-paging.js +220 -0
  140. package/dist/record-paging.js.map +1 -0
  141. package/dist/schema-conflict-dialog.d.ts +10 -0
  142. package/dist/schema-conflict-dialog.d.ts.map +1 -0
  143. package/dist/schema-conflict-dialog.js +164 -0
  144. package/dist/schema-conflict-dialog.js.map +1 -0
  145. package/dist/selection-persistence.d.ts +5 -0
  146. package/dist/selection-persistence.d.ts.map +1 -0
  147. package/dist/selection-persistence.js +90 -0
  148. package/dist/selection-persistence.js.map +1 -0
  149. package/dist/serializer.d.ts +3 -0
  150. package/dist/serializer.d.ts.map +1 -0
  151. package/dist/serializer.js +2 -0
  152. package/dist/serializer.js.map +1 -0
  153. package/dist/session-persistence.d.ts +4 -0
  154. package/dist/session-persistence.d.ts.map +1 -0
  155. package/dist/session-persistence.js +68 -0
  156. package/dist/session-persistence.js.map +1 -0
  157. package/dist/session-toolbar.d.ts +62 -0
  158. package/dist/session-toolbar.d.ts.map +1 -0
  159. package/dist/session-toolbar.js +1205 -0
  160. package/dist/session-toolbar.js.map +1 -0
  161. package/dist/session.d.ts +69 -0
  162. package/dist/session.d.ts.map +1 -0
  163. package/dist/session.js +926 -0
  164. package/dist/session.js.map +1 -0
  165. package/dist/tail-ops.d.ts +28 -0
  166. package/dist/tail-ops.d.ts.map +1 -0
  167. package/dist/tail-ops.js +46 -0
  168. package/dist/tail-ops.js.map +1 -0
  169. package/dist/topic-lifecycle.d.ts +41 -0
  170. package/dist/topic-lifecycle.d.ts.map +1 -0
  171. package/dist/topic-lifecycle.js +169 -0
  172. package/dist/topic-lifecycle.js.map +1 -0
  173. package/dist/types.d.ts +22 -0
  174. package/dist/types.d.ts.map +1 -0
  175. package/dist/types.js +2 -0
  176. package/dist/types.js.map +1 -0
  177. package/dist/undo.d.ts +18 -0
  178. package/dist/undo.d.ts.map +1 -0
  179. package/dist/undo.js +110 -0
  180. package/dist/undo.js.map +1 -0
  181. package/dist/version-bar.d.ts +22 -0
  182. package/dist/version-bar.d.ts.map +1 -0
  183. package/dist/version-bar.js +169 -0
  184. package/dist/version-bar.js.map +1 -0
  185. package/dist/version.d.ts +3 -0
  186. package/dist/version.d.ts.map +1 -0
  187. package/dist/version.js +8 -0
  188. package/dist/version.js.map +1 -0
  189. package/dist/vite.d.ts +15 -0
  190. package/dist/vite.d.ts.map +1 -0
  191. package/dist/vite.js +36 -0
  192. package/dist/vite.js.map +1 -0
  193. package/dist/ws-client.d.ts +53 -0
  194. package/dist/ws-client.d.ts.map +1 -0
  195. package/dist/ws-client.js +604 -0
  196. package/dist/ws-client.js.map +1 -0
  197. package/dist/ws-frame-limit.d.ts +21 -0
  198. package/dist/ws-frame-limit.d.ts.map +1 -0
  199. package/dist/ws-frame-limit.js +40 -0
  200. package/dist/ws-frame-limit.js.map +1 -0
  201. package/docs/app-manifest.md +65 -0
  202. package/docs/custom-chrome.md +147 -0
  203. package/docs/hosted-ui-advanced.md +77 -0
  204. package/docs/imported-documents.md +261 -0
  205. package/docs/presence.md +129 -0
  206. package/docs/schemas.md +135 -0
  207. package/docs/streams.md +248 -0
  208. package/package.json +60 -0
  209. package/scripts/prepare.mjs +50 -0
  210. package/src/api-fetch.ts +127 -0
  211. package/src/api-headers.ts +28 -0
  212. package/src/auth-flow.ts +125 -0
  213. package/src/client.ts +1457 -0
  214. package/src/doc-url-sync.ts +183 -0
  215. package/src/document-branches.ts +124 -0
  216. package/src/document-checkpoints.ts +135 -0
  217. package/src/document-commands.ts +651 -0
  218. package/src/document-signals.ts +234 -0
  219. package/src/document-streams.ts +132 -0
  220. package/src/document-thumbnail-capture.ts +191 -0
  221. package/src/documents-namespace.ts +516 -0
  222. package/src/documents.ts +2426 -0
  223. package/src/emitter.ts +129 -0
  224. package/src/errors.ts +45 -0
  225. package/src/export-guest.ts +282 -0
  226. package/src/fetch-retry.ts +119 -0
  227. package/src/guest-link.ts +14 -0
  228. package/src/handles.ts +1731 -0
  229. package/src/hosted-runtime.ts +109 -0
  230. package/src/hosted-ui.ts +1020 -0
  231. package/src/importer.ts +89 -0
  232. package/src/index.ts +267 -0
  233. package/src/init-keys.ts +15 -0
  234. package/src/internal.ts +23 -0
  235. package/src/jwt.ts +18 -0
  236. package/src/launch-context.ts +43 -0
  237. package/src/login-hint.ts +60 -0
  238. package/src/mutate-batching.ts +63 -0
  239. package/src/open-document.ts +2869 -0
  240. package/src/page-title.ts +33 -0
  241. package/src/perf.ts +46 -0
  242. package/src/presence.ts +471 -0
  243. package/src/record-paging.ts +270 -0
  244. package/src/schema-conflict-dialog.ts +176 -0
  245. package/src/selection-persistence.ts +98 -0
  246. package/src/serializer.ts +15 -0
  247. package/src/session-persistence.ts +69 -0
  248. package/src/session-toolbar.ts +1457 -0
  249. package/src/session.ts +1262 -0
  250. package/src/tail-ops.ts +63 -0
  251. package/src/topic-lifecycle.ts +217 -0
  252. package/src/types.ts +75 -0
  253. package/src/undo.ts +125 -0
  254. package/src/version-bar.ts +221 -0
  255. package/src/version.ts +8 -0
  256. package/src/vite.ts +49 -0
  257. package/src/ws-client.ts +678 -0
  258. 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
+ }