@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,2121 @@
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
+ import { DEFAULT_STREAM_NAME, Document, } from './documents.js';
7
+ import { createPresenceController } from './presence.js';
8
+ import { applyAppAssertionHeader, applyClientVersionHeader } from './api-headers.js';
9
+ import { brassErrorFromBody } from './api-fetch.js';
10
+ import { fetchWithRetry, RETRY_IDEMPOTENT_WRITE } from './fetch-retry.js';
11
+ import { BrassError } from './errors.js';
12
+ import { perfStart } from './perf.js';
13
+ import { resolveTailOps } from './tail-ops.js';
14
+ import { opBlobExceedsDurableLimit, opFrameExceedsWsLimit, opBlobNeedsStaging, } from './ws-frame-limit.js';
15
+ // Both join fetches and the `doc_init` frame funnel through this, so a
16
+ // tail op whose bytes spilled to storage is fetched once, here. Every
17
+ // reader downstream then sees the inline shape it already handles, and
18
+ // none of the seq bookkeeping has to learn a second representation.
19
+ async function resolveParsedTailOps(parsed) {
20
+ if (typeof parsed !== 'object' || parsed === null)
21
+ return;
22
+ const body = parsed;
23
+ if (!Array.isArray(body.tail_ops))
24
+ return;
25
+ body.tail_ops = await resolveTailOps(body.tail_ops);
26
+ }
27
+ // Map the snake_case wire `drive` block to the public camelCase
28
+ // `DocumentDriveStatus`. `canPush` is derived once here (unbroken, two-way,
29
+ // and ahead/diverged) so apps don't re-derive it.
30
+ function toDriveStatus(wire) {
31
+ const state = wire.tracking_state;
32
+ // Only an explicit `import_only` restricts publishing; a frame omitting the
33
+ // field must not strip the Publish affordance from a two-way link, so
34
+ // anything not `import_only` is treated as two-way.
35
+ const mirrorDirection = wire.mirror_direction === 'import_only' ? 'import_only' : 'two_way';
36
+ return {
37
+ driveType: wire.drive_type,
38
+ driveName: wire.drive_name,
39
+ state,
40
+ // `broken` gates the Publish affordance, so read it explicitly rather
41
+ // than by truthiness: a frame that OMITS it (a future partial drive
42
+ // update) must not be treated as unbroken and offered a push the server
43
+ // would reject. Require an explicit `false` to enable, and an explicit
44
+ // `true` to flag broken. Behaviour is unchanged for today's frames, which
45
+ // always carry the boolean.
46
+ canPush: wire.broken === false &&
47
+ mirrorDirection === 'two_way' &&
48
+ (state === 'ahead' || state === 'diverged'),
49
+ label: wire.tracking_label,
50
+ detail: wire.tracking_detail,
51
+ broken: wire.broken === true,
52
+ pushReplacesRemote: wire.push_replaces_remote,
53
+ mirrorDirection,
54
+ ref: wire.tracking_ref,
55
+ refUpdatedAt: wire.tracking_ref_updated_at,
56
+ itemName: wire.item_name,
57
+ itemUrl: wire.item_url,
58
+ publishing: wire.publishing === true,
59
+ };
60
+ }
61
+ function isDocOpFrame(frame) {
62
+ if (typeof frame !== 'object' || frame === null)
63
+ return false;
64
+ const f = frame;
65
+ return (f.type === 'doc_op' &&
66
+ typeof f.topic === 'string' &&
67
+ typeof f.op_blob === 'string' &&
68
+ typeof f.seq === 'number');
69
+ }
70
+ function isDocInitFrame(frame) {
71
+ if (typeof frame !== 'object' || frame === null)
72
+ return false;
73
+ const f = frame;
74
+ if (f.type !== 'doc_init' || typeof f.topic !== 'string')
75
+ return false;
76
+ if (typeof f.current_seq !== 'number')
77
+ return false;
78
+ // `snapshot_version_vector` is an optional durability-handshake input, read
79
+ // defensively at its use site. A present-but-non-string value (a future
80
+ // server retyping it) is tolerated as absent rather than rejecting the whole
81
+ // frame, which would silently break the reconnect / durability path.
82
+ if (!Array.isArray(f.tail_ops))
83
+ return false;
84
+ for (const entry of f.tail_ops) {
85
+ if (typeof entry !== 'object' || entry === null)
86
+ return false;
87
+ const e = entry;
88
+ if (typeof e.seq !== 'number')
89
+ return false;
90
+ // A tail op is normally inline here: the server withholds a tail
91
+ // carrying a signed URL from this frame, because fetching one needs
92
+ // an await this reader does not have, and sends `current_seq` alone
93
+ // so the client re-imports the join over HTTP instead. Tolerating
94
+ // the shape anyway keeps such a frame's `current_seq` usable rather
95
+ // than dropping the whole frame and with it the resync trigger.
96
+ if (typeof e.op_blob_b64 !== 'string' && typeof e.op_blob_url !== 'string') {
97
+ return false;
98
+ }
99
+ }
100
+ return true;
101
+ }
102
+ function isOpAckFrame(frame) {
103
+ if (typeof frame !== 'object' || frame === null)
104
+ return false;
105
+ const f = frame;
106
+ return (f.type === 'op_ack' &&
107
+ typeof f.topic === 'string' &&
108
+ typeof f.client_op_id === 'string' &&
109
+ typeof f.seq === 'number');
110
+ }
111
+ function isOpRejectedFrame(frame) {
112
+ if (typeof frame !== 'object' || frame === null)
113
+ return false;
114
+ const f = frame;
115
+ return (f.type === 'op_rejected' &&
116
+ typeof f.topic === 'string' &&
117
+ typeof f.client_op_id === 'string');
118
+ }
119
+ // The message surfaced through the error reporter for a terminal op rejection.
120
+ // Prefers the server's copy; falls back to a generic, actionable line. The
121
+ // local edit already applied but can never persist, so the app's cue is to
122
+ // reload the document to return to server state.
123
+ function opRejectedMessage(frame) {
124
+ if (typeof frame.message === 'string' && frame.message !== '') {
125
+ return frame.message;
126
+ }
127
+ return 'A change could not be saved and will not be retried. Reload the document to continue.';
128
+ }
129
+ function isDocGoneFrame(frame) {
130
+ if (typeof frame !== 'object' || frame === null)
131
+ return false;
132
+ const f = frame;
133
+ // Accept any string `reason`: a `doc_gone` the SDK can't classify still
134
+ // evicts the open document. Gating on the known values would drop a frame
135
+ // carrying a future reason, leaving the editor live on a document the
136
+ // server has already removed.
137
+ return (f.type === 'doc_gone' &&
138
+ typeof f.topic === 'string' &&
139
+ typeof f.reason === 'string');
140
+ }
141
+ // Map the wire reason to the public `DocumentGoneReason`. `moved` is
142
+ // preserved (it has its own re-open path); every other value, including a
143
+ // future eviction reason, collapses to `deleted`, the terminal "it is gone"
144
+ // the app's cleanup reacts to. One definition so the signed-in and guest
145
+ // transports cannot drift on the collapse rule.
146
+ function goneReasonOf(frame) {
147
+ return frame.reason === 'moved' ? 'moved' : 'deleted';
148
+ }
149
+ function isDocStatusFrame(frame) {
150
+ if (typeof frame !== 'object' || frame === null)
151
+ return false;
152
+ const f = frame;
153
+ if (f.type !== 'doc_status' || typeof f.topic !== 'string')
154
+ return false;
155
+ const s = f.status;
156
+ if (typeof s !== 'object' || s === null)
157
+ return false;
158
+ const st = s;
159
+ // Gate only on `pending`, the render-control discriminant. `message` is
160
+ // display copy read defensively at the use site, so a future message-less
161
+ // or progress-only status still updates `doc.status` (clearing the loading
162
+ // overlay) instead of being dropped as an unknown frame and stranding the
163
+ // app on "loading". The HTTP-join path already emits this status
164
+ // unvalidated, so this keeps the WS path from being the stricter of the two.
165
+ return typeof st.pending === 'boolean';
166
+ }
167
+ function isDocMetaFrame(frame) {
168
+ if (typeof frame !== 'object' || frame === null)
169
+ return false;
170
+ const f = frame;
171
+ return (f.type === 'doc_meta' &&
172
+ typeof f.topic === 'string' &&
173
+ typeof f.name === 'string');
174
+ }
175
+ function isDocDriveStatusFrame(frame) {
176
+ if (typeof frame !== 'object' || frame === null)
177
+ return false;
178
+ const f = frame;
179
+ if (f.type !== 'doc_drive_status' || typeof f.topic !== 'string') {
180
+ return false;
181
+ }
182
+ const d = f.drive;
183
+ if (typeof d !== 'object' || d === null)
184
+ return false;
185
+ const dr = d;
186
+ // Gate on the discriminant (`drive_type`) + the affordance state
187
+ // (`tracking_state`). `broken` and the rest are read defensively in
188
+ // `toDriveStatus` (which now requires an explicit `false` to enable Publish),
189
+ // so a future partial drive update still updates `doc.driveStatus` instead of
190
+ // being dropped and leaving the sync/Publish surface stale.
191
+ return (typeof dr.drive_type === 'string' && typeof dr.tracking_state === 'string');
192
+ }
193
+ function isDocOpsDroppedFrame(frame) {
194
+ if (typeof frame !== 'object' || frame === null)
195
+ return false;
196
+ const f = frame;
197
+ return (f.type === 'doc_ops_dropped' &&
198
+ typeof f.topic === 'string' &&
199
+ Array.isArray(f.dropped_seqs) &&
200
+ typeof f.actor === 'string');
201
+ }
202
+ function defaultClientOpIdMinter() {
203
+ const g = globalThis;
204
+ if (g.crypto?.randomUUID)
205
+ return g.crypto.randomUUID();
206
+ // Fallback for environments without `crypto.randomUUID`. Uniqueness is
207
+ // only needed SDK-side (correlate `op_ack` to the outstanding local op).
208
+ let s = '';
209
+ for (let i = 0; i < 32; i++) {
210
+ s += Math.floor(Math.random() * 16).toString(16);
211
+ }
212
+ return s;
213
+ }
214
+ function bytesToBase64(bytes) {
215
+ let bin = '';
216
+ for (let i = 0; i < bytes.byteLength; i++) {
217
+ bin += String.fromCharCode(bytes[i]);
218
+ }
219
+ return btoa(bin);
220
+ }
221
+ // Uses `atob` (present in modern browsers, Node 20+, and jsdom) rather than
222
+ // Node-only `Buffer`, which would force a browser-bundle polyfill.
223
+ function base64ToBytes(b64) {
224
+ const bin = atob(b64);
225
+ const bytes = new Uint8Array(bin.length);
226
+ for (let i = 0; i < bin.length; i++) {
227
+ bytes[i] = bin.charCodeAt(i);
228
+ }
229
+ return bytes;
230
+ }
231
+ function isSnapshotResponse(body) {
232
+ if (typeof body !== 'object' || body === null)
233
+ return false;
234
+ const b = body;
235
+ if (typeof b.doc_id !== 'string')
236
+ return false;
237
+ if (typeof b.branch_id !== 'string')
238
+ return false;
239
+ if (!Array.isArray(b.tail_ops))
240
+ return false;
241
+ // snapshot validated structurally in materialise(); a null is fine.
242
+ return true;
243
+ }
244
+ function isAtVersionResponse(body) {
245
+ if (typeof body !== 'object' || body === null)
246
+ return false;
247
+ const b = body;
248
+ if (typeof b.effective_version !== 'number')
249
+ return false;
250
+ if (typeof b.branch_id !== 'string')
251
+ return false;
252
+ if (!Array.isArray(b.tail_ops))
253
+ return false;
254
+ // snapshot validated structurally in materialise(); a null is fine.
255
+ return true;
256
+ }
257
+ export function createOpenDocument(opts) {
258
+ const baseUrl = opts.apiBaseUrl.replace(/\/$/, '');
259
+ // Documents this session has answered the schema-conflict prompt for.
260
+ // Consent is per DOCUMENT, not per request: an open makes several joins
261
+ // (the branch refetch, the durability handshake, a resync), each building
262
+ // its own options from the branch it needs, and every one of them would
263
+ // otherwise be refused again after the person already said yes. Holding it
264
+ // here rather than threading it through each call site is what keeps a
265
+ // fourth one from silently dropping it.
266
+ const consented = new Set();
267
+ async function fetchSnapshot(docId, options, token) {
268
+ const search = new URLSearchParams();
269
+ search.set('include', 'snapshot');
270
+ if (options?.filter !== undefined) {
271
+ search.set('filter', JSON.stringify(options.filter));
272
+ }
273
+ if (options?.branch !== undefined) {
274
+ search.set('branch', options.branch);
275
+ }
276
+ if (options?.stream !== undefined) {
277
+ search.set('stream', options.stream);
278
+ }
279
+ if (options?.acceptSchemaConflict === true)
280
+ consented.add(docId);
281
+ if (consented.has(docId))
282
+ search.set('accept_schema_conflict', '1');
283
+ const url = `${baseUrl}/documents/${encodeURIComponent(docId)}?${search.toString()}`;
284
+ const headers = { authorization: `Bearer ${token}` };
285
+ applyClientVersionHeader(headers);
286
+ await applyAppAssertionHeader(headers, opts.getAppAssertion);
287
+ const endFetch = perfStart('open: GET /documents (http + read body)');
288
+ // Idempotent read: ride out a cold-API transient 5xx / 429 (a 503
289
+ // while the API scales into a concurrent open burst is the common
290
+ // one) instead of failing the open on a blip that clears in a second.
291
+ const response = await fetchWithRetry(() => fetch(url, {
292
+ method: 'GET',
293
+ headers,
294
+ }));
295
+ const text = await response.text();
296
+ endFetch({ status: response.status, responseChars: text.length });
297
+ let parsed = undefined;
298
+ let parseFailed = false;
299
+ if (text) {
300
+ try {
301
+ parsed = JSON.parse(text);
302
+ }
303
+ catch {
304
+ parseFailed = true;
305
+ }
306
+ }
307
+ await resolveParsedTailOps(parsed);
308
+ return { status: response.status, parsed, parseFailed };
309
+ }
310
+ // `GET /documents/{docId}/at/{version}`: read-only materialization at a
311
+ // historical version. Same auth + parse shape as `fetchSnapshot`.
312
+ async function fetchAtVersion(docId, version, branch, token) {
313
+ const search = new URLSearchParams();
314
+ if (branch !== undefined)
315
+ search.set('branch', branch);
316
+ const qs = search.toString();
317
+ const url = `${baseUrl}/documents/${encodeURIComponent(docId)}` +
318
+ `/at/${encodeURIComponent(String(version))}` +
319
+ (qs ? `?${qs}` : '');
320
+ const headers = { authorization: `Bearer ${token}` };
321
+ applyClientVersionHeader(headers);
322
+ await applyAppAssertionHeader(headers, opts.getAppAssertion);
323
+ const response = await fetch(url, {
324
+ method: 'GET',
325
+ headers,
326
+ });
327
+ const text = await response.text();
328
+ let parsed = undefined;
329
+ let parseFailed = false;
330
+ if (text) {
331
+ try {
332
+ parsed = JSON.parse(text);
333
+ }
334
+ catch {
335
+ parseFailed = true;
336
+ }
337
+ }
338
+ await resolveParsedTailOps(parsed);
339
+ return { status: response.status, parsed, parseFailed };
340
+ }
341
+ // The historical-materialise fetcher `doc._checkoutVersion(v)` calls back
342
+ // to, shared by the default Document and each opened stream line (both
343
+ // materialize a branch at a version through `GET .../at/{version}`). The
344
+ // `stream_seqs` in the response (present only on a default/branch preview
345
+ // that resolved a checkpoint, absent on a line's own preview) rides
346
+ // through so the parent can preview every opened stream at the same point.
347
+ function makeCheckoutFetcher() {
348
+ return async (id, version, branchId) => {
349
+ const t = await requireToken();
350
+ const result = await fetchAtVersion(id, version, branchId, t);
351
+ if (result.status < 200 || result.status >= 300) {
352
+ throw brassErrorFromBody(result.status, result.parseFailed ? undefined : result.parsed);
353
+ }
354
+ if (!isAtVersionResponse(result.parsed)) {
355
+ throw new BrassError('Malformed document at-version response', {
356
+ status: 500,
357
+ });
358
+ }
359
+ let snapshotBytes = null;
360
+ const snap = result.parsed.snapshot;
361
+ if (snap !== null) {
362
+ if (snap.kind === 'inline') {
363
+ snapshotBytes = base64ToBytes(snap.data_b64);
364
+ }
365
+ else if (snap.kind === 'presigned') {
366
+ const snapResponse = await fetch(snap.url, { method: 'GET' });
367
+ if (!snapResponse.ok) {
368
+ throw new BrassError('Presigned snapshot fetch failed', {
369
+ status: snapResponse.status,
370
+ });
371
+ }
372
+ const buf = await snapResponse.arrayBuffer();
373
+ snapshotBytes = new Uint8Array(buf);
374
+ }
375
+ else {
376
+ // A delivery kind this build does not implement; refuse rather than
377
+ // preview an empty version (same rationale as `materialise`).
378
+ throw snapshotKindTooNewError();
379
+ }
380
+ }
381
+ const tailOpBlobs = result.parsed.tail_ops.map((entry) => base64ToBytes(entry.op_blob_b64));
382
+ const rawSeqs = result.parsed.stream_seqs;
383
+ let streamSeqs;
384
+ if (rawSeqs && typeof rawSeqs === 'object' && !Array.isArray(rawSeqs)) {
385
+ const parsed = {};
386
+ for (const [lineId, seq] of Object.entries(rawSeqs)) {
387
+ if (typeof seq === 'number')
388
+ parsed[lineId] = seq;
389
+ }
390
+ if (Object.keys(parsed).length > 0)
391
+ streamSeqs = parsed;
392
+ }
393
+ return {
394
+ snapshotBytes,
395
+ tailOpBlobs,
396
+ effectiveVersion: result.parsed.effective_version,
397
+ ...(streamSeqs ? { streamSeqs } : {}),
398
+ };
399
+ };
400
+ }
401
+ // POSTs the context-create body to `/documents`. The server resolves the
402
+ // destination from the body (a `sibling_of` doc, the opaque `destination`
403
+ // the hosted picker returned, or the caller's single membership), so the
404
+ // SDK never names an org. Returns the raw status so `createDocumentId`
405
+ // can branch on the 409 destination / organization codes. An empty new
406
+ // doc has no snapshot, so the response is metadata-only and its doc_id is
407
+ // piped through `open` to materialise the editable Document.
408
+ // What the app hears when an op the socket could not carry also failed
409
+ // over HTTP. The server's message is preferred where it has one, since it
410
+ // names the actual refusal (too large for the durable row, a revoked
411
+ // role); the status-shaped fallbacks cover a response that carries none.
412
+ function opWriteFailureMessage(status, parsed) {
413
+ const serverMessage = parsed?.error;
414
+ if (typeof serverMessage === 'string' && serverMessage.length > 0) {
415
+ return serverMessage;
416
+ }
417
+ if (status === 403)
418
+ return 'You do not have permission to edit this document.';
419
+ if (status === 404)
420
+ return 'This document is no longer available.';
421
+ return `This change could not be saved (${status}).`;
422
+ }
423
+ // Write one op over HTTP. The socket carries an op only up to API
424
+ // Gateway's message cap; past it the message is not delivered and the
425
+ // connection is closed, so `wireDocTransport` sends the large ones here.
426
+ // Not retried on a transient failure: the caller holds the op in its
427
+ // outstanding tracker and re-asserts it, and a blind replay would write a
428
+ // second durable row for the same edit.
429
+ // Mint a presigned PUT and upload the blob to it, returning the staging
430
+ // key the op write names. The PUT carries no Brass headers: the URL is
431
+ // signed, and an extra header falls outside that signature.
432
+ async function stageOpBlob(docId, opBlobB64, token) {
433
+ const headers = {
434
+ authorization: `Bearer ${token}`,
435
+ 'content-type': 'application/json',
436
+ };
437
+ applyClientVersionHeader(headers);
438
+ await applyAppAssertionHeader(headers, opts.getAppAssertion);
439
+ const mint = await fetch(`${baseUrl}/documents/${encodeURIComponent(docId)}/ops/upload`, { method: 'POST', headers, body: '{}' });
440
+ if (!mint.ok) {
441
+ const text = await mint.text();
442
+ let parsed = undefined;
443
+ try {
444
+ parsed = text ? JSON.parse(text) : undefined;
445
+ }
446
+ catch {
447
+ parsed = undefined;
448
+ }
449
+ throw brassErrorFromBody(mint.status, parsed);
450
+ }
451
+ const body = (await mint.json());
452
+ if (typeof body.upload_key !== 'string' || typeof body.url !== 'string') {
453
+ throw new BrassError('Malformed op upload response', { status: 500 });
454
+ }
455
+ const endUpload = perfStart('op: PUT staged op blob');
456
+ const put = await fetch(body.url, { method: 'PUT', body: opBlobB64 });
457
+ endUpload({ status: put.status, opChars: opBlobB64.length });
458
+ if (!put.ok) {
459
+ throw new BrassError('Could not upload the update', { status: put.status });
460
+ }
461
+ return body.upload_key;
462
+ }
463
+ async function postDocumentOp(docId, branchId, opBlobB64, clientOpId, token) {
464
+ const headers = {
465
+ authorization: `Bearer ${token}`,
466
+ 'content-type': 'application/json',
467
+ };
468
+ applyClientVersionHeader(headers);
469
+ await applyAppAssertionHeader(headers, opts.getAppAssertion);
470
+ // Past what a durable row holds, the blob goes to storage directly
471
+ // and the write names the staged object, so a multi-megabyte op
472
+ // never travels through a request body.
473
+ let stagedKey;
474
+ if (opBlobNeedsStaging(opBlobB64)) {
475
+ stagedKey = await stageOpBlob(docId, opBlobB64, token);
476
+ }
477
+ const endFetch = perfStart('op: POST /documents/{id}/ops');
478
+ const response = await fetch(`${baseUrl}/documents/${encodeURIComponent(docId)}/ops`, {
479
+ method: 'POST',
480
+ headers,
481
+ body: JSON.stringify({
482
+ ...(stagedKey === undefined
483
+ ? { op_blob: opBlobB64 }
484
+ : { upload_key: stagedKey }),
485
+ branch_id: branchId,
486
+ client_op_id: clientOpId,
487
+ }),
488
+ });
489
+ const text = await response.text();
490
+ endFetch({ status: response.status, opChars: opBlobB64.length });
491
+ let parsed = undefined;
492
+ try {
493
+ parsed = text ? JSON.parse(text) : undefined;
494
+ }
495
+ catch {
496
+ parsed = undefined;
497
+ }
498
+ return { status: response.status, parsed };
499
+ }
500
+ async function postCreateDocument(args, token) {
501
+ // `creating_app_id` powers the dashboard's "Open in {appName}"
502
+ // affordance and is the Layer 2 scope subject; re-derived from the
503
+ // verified app assertion server-side.
504
+ const body = { creating_app_id: opts.appId };
505
+ if (args.name !== undefined)
506
+ body['name'] = args.name;
507
+ if (args.app_metadata !== undefined)
508
+ body['app_metadata'] = args.app_metadata;
509
+ if (args.siblingOf !== undefined)
510
+ body['sibling_of'] = args.siblingOf;
511
+ // Relayed verbatim: the SDK never reads the destination's fields, so
512
+ // the hierarchy shape can grow server-side without an SDK change.
513
+ if (args.destination !== undefined)
514
+ body['destination'] = args.destination;
515
+ // Idempotency key: one per logical create, minted BEFORE the retry loop so
516
+ // every replay carries the SAME key. The server records the first create
517
+ // under it and returns that same document for any later request with the
518
+ // key, so a replay after a lost response never creates a second document.
519
+ body['idempotency_key'] = defaultClientOpIdMinter();
520
+ const headers = {
521
+ authorization: `Bearer ${token}`,
522
+ 'content-type': 'application/json',
523
+ };
524
+ applyClientVersionHeader(headers);
525
+ await applyAppAssertionHeader(headers, opts.getAppAssertion);
526
+ // The create carries an idempotency key, so a replay the server already
527
+ // applied returns the original document instead of a second one. That
528
+ // makes the full transient set (and a network throw) replay-safe here,
529
+ // not just a pre-handler 503 (`RETRY_IDEMPOTENT_WRITE`). Resilience
530
+ // matters most on create because a 503 that swallowed it would also
531
+ // swallow the `app_not_installed` consent flow that rides the response.
532
+ const response = await fetchWithRetry(() => fetch(`${baseUrl}/documents`, {
533
+ method: 'POST',
534
+ headers,
535
+ body: JSON.stringify(body),
536
+ }), { policy: RETRY_IDEMPOTENT_WRITE, baseDelayMs: 250 });
537
+ const text = await response.text();
538
+ let parsed = undefined;
539
+ let parseFailed = false;
540
+ if (text) {
541
+ try {
542
+ parsed = JSON.parse(text);
543
+ }
544
+ catch {
545
+ parseFailed = true;
546
+ }
547
+ }
548
+ return { status: response.status, parsed, parseFailed };
549
+ }
550
+ // Create-only (no open). Classifies the response so a caller can react to
551
+ // a needed destination / organization before showing a loading state; any
552
+ // other non-2xx throws.
553
+ //
554
+ // A create that names no placement of its own inherits the organization
555
+ // the page was launched from, so a member of several organizations who
556
+ // opened this app from one of them is not asked to name a destination
557
+ // they already chose. An explicit `siblingOf` / `destination` outranks it:
558
+ // placing a document beside the open one is the more specific answer.
559
+ const createDocumentId = async (args) => {
560
+ const launchOrganizationId = args.siblingOf === undefined && args.destination === undefined
561
+ ? (opts.launchOrganizationId?.() ?? null)
562
+ : null;
563
+ if (launchOrganizationId === null)
564
+ return postAndClassify(args);
565
+ try {
566
+ return await postAndClassify({
567
+ ...args,
568
+ destination: { organization_id: launchOrganizationId },
569
+ });
570
+ }
571
+ catch (err) {
572
+ // The launch organization is a hint carried on a URL, so it can name
573
+ // an organization the person has since left, which the server answers
574
+ // with a plain 404. Drop the hint and let the server resolve the
575
+ // destination on its own, which routes a multi-organization caller to
576
+ // the picker. A coded rejection (`app_not_installed`) belongs to the
577
+ // caller's own consent flow and passes through untouched, as does the
578
+ // 404 for an organization that exists but denies the app.
579
+ if (err instanceof BrassError && err.status === 404 && !err.code) {
580
+ return postAndClassify(args);
581
+ }
582
+ throw err;
583
+ }
584
+ };
585
+ // One POST plus its classification. Each call mints its own idempotency
586
+ // key inside `postCreateDocument`, so a second call after a rejected
587
+ // first cannot land a second document.
588
+ const postAndClassify = async (args) => {
589
+ const token = await requireToken();
590
+ // `postCreateDocument` owns the idempotency-keyed write replay
591
+ // (`RETRY_IDEMPOTENT_WRITE`), so a retried create never double-applies.
592
+ const created = await postCreateDocument(args, token);
593
+ if (created.status >= 200 && created.status < 300) {
594
+ const docId = typeof created.parsed === 'object' &&
595
+ created.parsed !== null &&
596
+ typeof created.parsed.doc_id === 'string'
597
+ ? created.parsed.doc_id
598
+ : null;
599
+ if (!docId) {
600
+ throw new BrassError('Malformed createDocument response', { status: 500 });
601
+ }
602
+ return { kind: 'created', docId };
603
+ }
604
+ if (created.status === 409 && !created.parseFailed) {
605
+ const code = created.parsed?.code;
606
+ if (code === 'destination_required')
607
+ return { kind: 'needs-destination' };
608
+ if (code === 'no_organization')
609
+ return { kind: 'needs-organization' };
610
+ }
611
+ throw brassErrorFromBody(created.status, created.parseFailed ? undefined : created.parsed);
612
+ };
613
+ async function requireToken() {
614
+ const token = await opts.getAccessToken();
615
+ if (!token) {
616
+ // Reject before fetch so callers get a deterministic 401 with no
617
+ // network round-trip (matches the projects / users namespaces).
618
+ throw new BrassError('Not signed in', { status: 401 });
619
+ }
620
+ return token;
621
+ }
622
+ // Open one stream LINE: the per-stream sibling of the document open.
623
+ // Joins the line over the same HTTP join (`?include=snapshot&branch=
624
+ // <lineId>`), imports its (usually much smaller) snapshot, and wires
625
+ // the same op transport on the line's own topic. Deliberately lean
626
+ // next to `finalise`: no presence, no branches fetch, no thumbnails /
627
+ // checkpoints / commands, and no park cache; a stream's extra cost
628
+ // over its content is one join + one subscribe, which is what keeps
629
+ // per-stream opens proportional to what an app touches.
630
+ async function openStreamLine(docId, lineId) {
631
+ const token = await requireToken();
632
+ const { status, parsed, parseFailed } = await fetchSnapshot(docId, { branch: lineId }, token);
633
+ if (status < 200 || status >= 300) {
634
+ throw brassErrorFromBody(status, parseFailed ? undefined : parsed);
635
+ }
636
+ if (!isSnapshotResponse(parsed)) {
637
+ throw new BrassError('Malformed document snapshot response', {
638
+ status: 500,
639
+ });
640
+ }
641
+ const doc = await materialise(parsed);
642
+ // A stream line materializes at a historical seq the same way the
643
+ // default line does, so the parent's version preview can bring this
644
+ // stream to a point in time (`doc.stream(name)._previewAt(seq)`). The
645
+ // line's own timeline carries no checkpoints, so its at-version response
646
+ // resolves no `stream_seqs` (only the doc-level preview does).
647
+ doc.setCheckoutFetcher(makeCheckoutFetcher());
648
+ // Transport cursors, the same shape `finalise` keeps for the default
649
+ // line (see the comments there); scoped per line because each stream
650
+ // has its own seq space.
651
+ let currentSeq = Math.max(parsed.tail_ops.reduce((acc, entry) => (entry.seq > acc ? entry.seq : acc), 0), typeof parsed.current_seq === 'number' ? parsed.current_seq : 0);
652
+ let coveredSeq = typeof parsed.current_seq === 'number' ? parsed.current_seq : 0;
653
+ const coverageParked = new Set();
654
+ const advanceCoverage = (seq) => {
655
+ if (seq <= coveredSeq)
656
+ return;
657
+ coverageParked.add(seq);
658
+ while (coverageParked.has(coveredSeq + 1)) {
659
+ coverageParked.delete(coveredSeq + 1);
660
+ coveredSeq += 1;
661
+ }
662
+ };
663
+ for (const entry of parsed.tail_ops)
664
+ advanceCoverage(entry.seq);
665
+ // Reconnect-across-compaction recovery: re-import the line's join.
666
+ // Loro imports are idempotent, so re-applying over live state is a
667
+ // clean merge.
668
+ const resyncFromSnapshot = () => {
669
+ void (async () => {
670
+ try {
671
+ const t = await requireToken();
672
+ const res = await fetchSnapshot(docId, { branch: lineId }, t);
673
+ if (res.status < 200 || res.status >= 300)
674
+ return;
675
+ if (!isSnapshotResponse(res.parsed))
676
+ return;
677
+ const snap = res.parsed.snapshot;
678
+ if (snap !== null) {
679
+ const bytes = snap.kind === 'inline'
680
+ ? base64ToBytes(snap.data_b64)
681
+ : await fetchPresignedSnapshot(snap.url);
682
+ doc._applySnapshot(bytes);
683
+ }
684
+ for (const entry of res.parsed.tail_ops) {
685
+ doc.applyRemoteOp(base64ToBytes(entry.op_blob_b64));
686
+ if (entry.seq > currentSeq)
687
+ currentSeq = entry.seq;
688
+ advanceCoverage(entry.seq);
689
+ }
690
+ const joined = res.parsed.current_seq;
691
+ if (typeof joined === 'number') {
692
+ if (joined > currentSeq)
693
+ currentSeq = joined;
694
+ if (joined > coveredSeq) {
695
+ coveredSeq = joined;
696
+ for (const seq of coverageParked) {
697
+ if (seq <= coveredSeq)
698
+ coverageParked.delete(seq);
699
+ }
700
+ }
701
+ }
702
+ }
703
+ catch {
704
+ // Best-effort; the next doc_init retries.
705
+ }
706
+ })();
707
+ };
708
+ // Write an op the socket cannot carry. The op stays one commit and
709
+ // one seq; only the transport differs, and the response carries the
710
+ // seq an `op_ack` would have.
711
+ const postOversizedOp = async (opBlobB64, clientOpId) => {
712
+ const token = await requireToken();
713
+ const { status, parsed: opResult } = await postDocumentOp(docId, lineId, opBlobB64, clientOpId, token);
714
+ if (status < 200 || status >= 300) {
715
+ return { ok: false, message: opWriteFailureMessage(status, opResult) };
716
+ }
717
+ const seq = opResult?.seq;
718
+ if (typeof seq !== 'number') {
719
+ return { ok: false, message: 'The server did not report a version for this change.' };
720
+ }
721
+ return { ok: true, seq };
722
+ };
723
+ if (opts.wsClient) {
724
+ wireDocTransport({
725
+ doc,
726
+ docId,
727
+ branchId: lineId,
728
+ wsClient: opts.wsClient,
729
+ postOversizedOp,
730
+ newClientOpId: opts.newClientOpId ?? defaultClientOpIdMinter,
731
+ getCurrentSeq: () => currentSeq,
732
+ setCurrentSeq: (next) => {
733
+ if (next > currentSeq)
734
+ currentSeq = next;
735
+ },
736
+ getCoveredSeq: () => coveredSeq,
737
+ advanceCoveredSeq: advanceCoverage,
738
+ resyncFromSnapshot,
739
+ skipPresence: true,
740
+ parkable: false,
741
+ });
742
+ }
743
+ return doc;
744
+ }
745
+ async function finalise(docId, snapshot, options) {
746
+ const { status, parsed, parseFailed } = snapshot;
747
+ if (status < 200 || status >= 300) {
748
+ throw brassErrorFromBody(status, parseFailed ? undefined : parsed);
749
+ }
750
+ if (!isSnapshotResponse(parsed)) {
751
+ throw new BrassError('Malformed document snapshot response', { status: 500 });
752
+ }
753
+ const doc = await materialise(parsed, options?.documentOptions, options?.onSnapshotProgress);
754
+ // Populate `doc.status` from the join before the first `doc_status` frame
755
+ // (a stub opened mid-hydration returns `snapshot: null` + a `pending`
756
+ // status).
757
+ if (parsed.status !== undefined)
758
+ doc._emitStatus(parsed.status);
759
+ // Seed `doc.driveStatus` from the join before the first
760
+ // `doc_drive_status` frame.
761
+ if (parsed.drive !== undefined) {
762
+ doc._emitDriveStatus(toDriveStatus(parsed.drive));
763
+ }
764
+ // Highest seq seen; later `doc_op` / `op_ack` frames advance it. Kept in
765
+ // a closure, not on the Document: it is a transport concern, not CRDT
766
+ // state.
767
+ let currentSeq = Math.max(parsed.tail_ops.reduce((acc, entry) => (entry.seq > acc ? entry.seq : acc), 0), typeof parsed.current_seq === 'number' ? parsed.current_seq : 0);
768
+ // Contiguous-coverage floor: every op with seq <= coveredSeq is applied
769
+ // (or never existed). Separate from `currentSeq` (max seq SEEN) because
770
+ // out-of-order fanout can inflate a max-seen cursor past a hole, which
771
+ // then can't tell "caught up" from "missed ops since compacted into the
772
+ // snapshot". The floor never overestimates, so the `doc_init` resync
773
+ // check always detects such a hole (at worst a transient out-of-order
774
+ // arrival costs one idempotent re-fetch). Seeded from the consistent-read
775
+ // join's `current_seq`.
776
+ let coveredSeq = typeof parsed.current_seq === 'number' ? parsed.current_seq : 0;
777
+ // Seqs applied above the floor, parked until the gap below fills.
778
+ const coverageParked = new Set();
779
+ const drainCoverage = () => {
780
+ while (coverageParked.has(coveredSeq + 1)) {
781
+ coverageParked.delete(coveredSeq + 1);
782
+ coveredSeq += 1;
783
+ }
784
+ };
785
+ const advanceCoverage = (seq) => {
786
+ if (seq <= coveredSeq)
787
+ return;
788
+ coverageParked.add(seq);
789
+ drainCoverage();
790
+ };
791
+ // Mark everything at or below `floor` covered (a full consistent join was
792
+ // imported). Also steps the floor past any abandoned server-side seq
793
+ // (allocated, never written) that `advanceCoverage` would stall under.
794
+ const raiseCoverage = (floor) => {
795
+ if (floor > coveredSeq)
796
+ coveredSeq = floor;
797
+ for (const seq of coverageParked) {
798
+ if (seq <= coveredSeq)
799
+ coverageParked.delete(seq);
800
+ }
801
+ drainCoverage();
802
+ };
803
+ for (const entry of parsed.tail_ops)
804
+ advanceCoverage(entry.seq);
805
+ // Re-fetch the join snapshot and import it into the live doc. Shared by
806
+ // the authoritative reload and the subscribe-time reconcile; returns the
807
+ // refreshed work-status (so reconcile tells pending / failed / succeeded
808
+ // apart) and a failure `reason` naming the broken step. `_applySnapshot`
809
+ // clears the undo stack; the doc was held read-only (empty) while
810
+ // pending, so this is a clean merge. Idempotent: re-importing the same
811
+ // snapshot / ops are no-ops in Loro.
812
+ const refetchAndImport = async () => {
813
+ const endPerf = perfStart('doc: content reload (fetch + import)');
814
+ const token = await requireToken();
815
+ // Refetch the branch this doc was opened on, not the default. `parsed`
816
+ // is the opened-branch join, so its `branch_id` is authoritative;
817
+ // dropping it would import the default branch's snapshot + tail into a
818
+ // doc open on another branch and silently merge the wrong CRDT state.
819
+ const result = await fetchSnapshot(docId, { branch: parsed.branch_id }, token);
820
+ if (result.status < 200 || result.status >= 300) {
821
+ endPerf({ ok: false, joinStatus: result.status });
822
+ return { ok: false, reason: `join fetch returned ${result.status}` };
823
+ }
824
+ if (!isSnapshotResponse(result.parsed)) {
825
+ endPerf({ ok: false, joinStatus: result.status });
826
+ return { ok: false, reason: 'malformed join response' };
827
+ }
828
+ const snap = result.parsed.snapshot;
829
+ let snapshotBytes = null;
830
+ if (snap !== null && snap.kind === 'inline') {
831
+ snapshotBytes = base64ToBytes(snap.data_b64);
832
+ }
833
+ else if (snap !== null && snap.kind === 'presigned') {
834
+ // Stream through the same progress sink the initial open uses, so the
835
+ // session surfaces "Downloading... NN%" instead of sitting on the
836
+ // last server phase.
837
+ try {
838
+ snapshotBytes = await fetchPresignedSnapshot(snap.url, options?.onSnapshotProgress);
839
+ }
840
+ catch (err) {
841
+ const status = err instanceof BrassError ? err.status ?? 0 : 0;
842
+ endPerf({ ok: false, snapshotStatus: status });
843
+ return {
844
+ ok: false,
845
+ reason: status > 0
846
+ ? `presigned snapshot fetch returned ${status}`
847
+ : `presigned snapshot fetch failed: ${err instanceof Error ? err.message : String(err)}`,
848
+ };
849
+ }
850
+ }
851
+ if (snapshotBytes)
852
+ doc._applySnapshot(snapshotBytes);
853
+ const sortedTail = [...result.parsed.tail_ops].sort((a, b) => a.seq - b.seq);
854
+ let maxTailSeq = 0;
855
+ for (const entry of sortedTail) {
856
+ doc.applyRemoteOp(base64ToBytes(entry.op_blob_b64));
857
+ if (entry.seq > currentSeq)
858
+ currentSeq = entry.seq;
859
+ if (entry.seq > maxTailSeq)
860
+ maxTailSeq = entry.seq;
861
+ }
862
+ // Raise both cursors so the floor reflects the import (this clears a
863
+ // detected missed-compaction hole).
864
+ const fetchedSeq = typeof result.parsed.current_seq === 'number'
865
+ ? result.parsed.current_seq
866
+ : maxTailSeq;
867
+ if (fetchedSeq > currentSeq)
868
+ currentSeq = fetchedSeq;
869
+ raiseCoverage(fetchedSeq);
870
+ endPerf({
871
+ ok: true,
872
+ snapshotBytes: snapshotBytes?.byteLength ?? 0,
873
+ tailOps: sortedTail.length,
874
+ });
875
+ return { ok: true, status: result.parsed.status };
876
+ };
877
+ // Fetch just the durability-handshake ground truth from the HTTP join:
878
+ // the persisted snapshot version vector and the durable tail op blobs.
879
+ // Unlike `refetchAndImport` this downloads no snapshot blob and imports
880
+ // nothing (the handshake only needs the frontier to diff against), so it
881
+ // is one cheap JSON round-trip.
882
+ const refetchDurableFrontier = async () => {
883
+ const token = await requireToken();
884
+ // Same-branch as `refetchAndImport`: the durability handshake must diff
885
+ // against the opened branch's frontier, not the default branch's.
886
+ const result = await fetchSnapshot(docId, { branch: parsed.branch_id }, token);
887
+ if (result.status < 200 || result.status >= 300)
888
+ return null;
889
+ if (!isSnapshotResponse(result.parsed))
890
+ return null;
891
+ const tailBlobs = result.parsed.tail_ops.map((entry) => base64ToBytes(entry.op_blob_b64));
892
+ const out = {
893
+ tailBlobs,
894
+ };
895
+ if (typeof result.parsed.snapshot_version_vector === 'string') {
896
+ out.snapshotVersionVector = result.parsed.snapshot_version_vector;
897
+ }
898
+ return out;
899
+ };
900
+ let resyncInFlight = false;
901
+ const resyncFromSnapshot = () => {
902
+ if (resyncInFlight)
903
+ return;
904
+ resyncInFlight = true;
905
+ void (async () => {
906
+ try {
907
+ const outcome = await refetchAndImport();
908
+ if (!outcome.ok) {
909
+ console.error('Brass: document resync after reconnect failed; state may lag until the next reconnect or reload');
910
+ }
911
+ }
912
+ catch (err) {
913
+ console.error('Brass: document resync after reconnect failed; state may lag until the next reconnect or reload', err);
914
+ }
915
+ finally {
916
+ resyncInFlight = false;
917
+ }
918
+ })();
919
+ };
920
+ // When a doc opened mid-load finishes, pull the now-available content
921
+ // into the live doc so it appears without the app reopening. Only ever
922
+ // called on the server's authoritative `reload` hint, so it never has to
923
+ // tell success from failure: a failure carries no `reload`, so no racy
924
+ // read and no loop against a failed hydration.
925
+ //
926
+ // A failed fetch here is transient (network, dropped presigned download)
927
+ // and is retried with backoff: a silent give-up would strand the doc
928
+ // empty-but-editable, reading as "the model never loaded" and invisible
929
+ // in every log. On exhaustion the failure is reported and the terminal
930
+ // status is an honest failure, not the server's success copy.
931
+ let reloadInFlight = false;
932
+ const RELOAD_ATTEMPTS = 5;
933
+ const RELOAD_BACKOFF_MS = 1000;
934
+ const reloadAfterHydration = (finalStatus) => {
935
+ if (reloadInFlight)
936
+ return;
937
+ reloadInFlight = true;
938
+ void (async () => {
939
+ let lastError = null;
940
+ for (let attempt = 1; attempt <= RELOAD_ATTEMPTS; attempt += 1) {
941
+ try {
942
+ const outcome = await refetchAndImport();
943
+ if (outcome.ok) {
944
+ reloadInFlight = false;
945
+ // Emit the terminal status AFTER the import settles, so an app
946
+ // re-reading on `onStatus` sees the content present and (with
947
+ // `readOnly` now false) clears its loading overlay.
948
+ doc._emitStatus(finalStatus);
949
+ return;
950
+ }
951
+ lastError = new Error(outcome.reason);
952
+ }
953
+ catch (err) {
954
+ lastError = err instanceof Error ? err : new Error(String(err));
955
+ }
956
+ // A retrying attempt is exceptional, so this warn is quiet in
957
+ // normal operation and decisive in a bug report; without it a
958
+ // failure here is undiagnosable from any log.
959
+ console.warn(`[Brass] document content reload attempt ${attempt}/${RELOAD_ATTEMPTS} failed (doc ${docId}): ${lastError.message}`);
960
+ if (attempt < RELOAD_ATTEMPTS) {
961
+ await new Promise((r) => setTimeout(r, RELOAD_BACKOFF_MS * attempt));
962
+ }
963
+ }
964
+ reloadInFlight = false;
965
+ const error = new BrassError(`Document content reload failed after ${RELOAD_ATTEMPTS} attempts: ${lastError?.message ?? 'unknown error'}`, { status: 0 });
966
+ if (opts.reportError)
967
+ opts.reportError(error);
968
+ else
969
+ console.error('[Brass]', error);
970
+ doc._emitStatus({
971
+ message: 'The model could not be loaded. Reload to try again.',
972
+ pending: false,
973
+ });
974
+ })();
975
+ };
976
+ // Reconcile a pending open once the live subscription exists. The
977
+ // terminal `doc_status` frame is best-effort: if hydration finishes
978
+ // before this client's SUB# row exists, the fan-out finds no subscriber
979
+ // and drops the frame, stranding the app on the pending overlay. `doc_init`
980
+ // is posted only after the SUB# row is written, so one re-fetch here
981
+ // closes the gap for a completion that landed before the row existed.
982
+ // Only a pending open pays the extra fetch.
983
+ let reconcileInFlight = false;
984
+ const reconcileStatusOnInit = () => {
985
+ const current = doc.status;
986
+ if (!current || !current.pending)
987
+ return;
988
+ if (reconcileInFlight || reloadInFlight)
989
+ return;
990
+ reconcileInFlight = true;
991
+ void (async () => {
992
+ try {
993
+ const outcome = await refetchAndImport();
994
+ if (!outcome.ok)
995
+ return;
996
+ // Still pending: leave the overlay up; subscribed now, so the live
997
+ // fan-out delivers the terminal frame when the work finishes.
998
+ if (outcome.status?.pending)
999
+ return;
1000
+ // Terminal. A present status is a failure (server message,
1001
+ // pending:false); an absent status is success. On success retain
1002
+ // the last server message and flip pending so the overlay clears.
1003
+ doc._emitStatus(outcome.status ?? { message: current.message, pending: false });
1004
+ }
1005
+ catch {
1006
+ // Best-effort; the live fan-out remains the primary path when
1007
+ // the work finishes after this subscribe.
1008
+ }
1009
+ finally {
1010
+ reconcileInFlight = false;
1011
+ }
1012
+ })();
1013
+ };
1014
+ // Write an op the socket cannot carry. The op stays one commit and one
1015
+ // seq; only the transport differs, and the response carries the seq an
1016
+ // `op_ack` would have. Targets the branch this doc was opened on, the
1017
+ // same branch the transport's topic is scoped to.
1018
+ const postOversizedOp = async (opBlobB64, clientOpId) => {
1019
+ const token = await requireToken();
1020
+ const { status, parsed: opResult } = await postDocumentOp(docId, parsed.branch_id, opBlobB64, clientOpId, token);
1021
+ if (status < 200 || status >= 300) {
1022
+ return { ok: false, message: opWriteFailureMessage(status, opResult) };
1023
+ }
1024
+ const seq = opResult?.seq;
1025
+ if (typeof seq !== 'number') {
1026
+ return {
1027
+ ok: false,
1028
+ message: 'The server did not report a version for this change.',
1029
+ };
1030
+ }
1031
+ return { ok: true, seq };
1032
+ };
1033
+ if (opts.wsClient) {
1034
+ wireDocTransport({
1035
+ doc,
1036
+ docId,
1037
+ // Scope the subscription to the branch the server materialized so
1038
+ // edits land on the opened branch rather than the default.
1039
+ branchId: parsed.branch_id,
1040
+ // The park cache keys on the requested branch, not the resolved one,
1041
+ // so take/park stay symmetric (see WireDocTransportArgs).
1042
+ ...(options?.branch !== undefined
1043
+ ? { requestedBranchId: options.branch }
1044
+ : {}),
1045
+ ...(options?.stream !== undefined ? { requestedStream: options.stream } : {}),
1046
+ wsClient: opts.wsClient,
1047
+ postOversizedOp,
1048
+ newClientOpId: opts.newClientOpId ?? defaultClientOpIdMinter,
1049
+ getCurrentSeq: () => currentSeq,
1050
+ setCurrentSeq: (next) => {
1051
+ if (next > currentSeq)
1052
+ currentSeq = next;
1053
+ },
1054
+ getCoveredSeq: () => coveredSeq,
1055
+ advanceCoveredSeq: advanceCoverage,
1056
+ resyncFromSnapshot,
1057
+ refetchDurableFrontier,
1058
+ reloadAfterHydration,
1059
+ reconcileStatusOnInit,
1060
+ });
1061
+ }
1062
+ // `doc._checkoutVersion(v)` calls back here to materialise historical
1063
+ // state, leaving the live subscription untouched.
1064
+ doc.setCheckoutFetcher(makeCheckoutFetcher());
1065
+ // Wire the branch provider + populate the initial list, so the
1066
+ // toolbar's branch chip can name the branch on the next subscribe
1067
+ // tick. Branch management is hosted; this list feeds the SDK's own
1068
+ // chrome, not an app method.
1069
+ if (opts.branchProvider) {
1070
+ doc.setBranchProvider(opts.branchProvider);
1071
+ void doc._refreshBranches().catch(() => {
1072
+ // Best-effort: a failure leaves the chip unnamed until a hosted
1073
+ // branch action refreshes the list.
1074
+ });
1075
+ }
1076
+ // `doc.useCanvas(canvas)` captures + uploads after each local commit; the
1077
+ // doc handles debounce + readOnly gating internally.
1078
+ if (opts.thumbnailUploader) {
1079
+ const uploader = opts.thumbnailUploader;
1080
+ doc.setThumbnailUploader(async (image, width, height) => {
1081
+ await uploader(doc, image, width, height);
1082
+ });
1083
+ }
1084
+ if (opts.checkpointCreator) {
1085
+ const creator = opts.checkpointCreator;
1086
+ doc.setCheckpointCreator(() => creator(doc));
1087
+ }
1088
+ if (opts.drivePusher) {
1089
+ const pusher = opts.drivePusher;
1090
+ doc.setDrivePusher((input) => pusher(doc, input));
1091
+ }
1092
+ // `doc.resolveDrive()` opens the hosted resolution surface and, when it
1093
+ // mints a new mirror doc (take-source / keep-both), re-opens onto it via
1094
+ // the same `openSiblingDoc` path the `history` fork uses.
1095
+ const hostedForResolve = opts.hostedUiProvider;
1096
+ const resolveSurface = hostedForResolve?.driveResolve;
1097
+ if (hostedForResolve && resolveSurface) {
1098
+ doc.setDriveResolver(async () => {
1099
+ const docId = doc.id;
1100
+ if (docId === null)
1101
+ return null;
1102
+ const result = await resolveSurface(docId);
1103
+ if (result?.newDocId && hostedForResolve.openSiblingDoc) {
1104
+ await hostedForResolve.openSiblingDoc(result.newDocId, {});
1105
+ }
1106
+ return result;
1107
+ });
1108
+ }
1109
+ if (opts.sourceBlobFetcher) {
1110
+ const fetcher = opts.sourceBlobFetcher;
1111
+ doc.setSourceBlobFetcher(() => fetcher(doc));
1112
+ }
1113
+ doc.setOpenedStreamName(options?.stream ?? DEFAULT_STREAM_NAME);
1114
+ // Streams: manifest list/create over HTTP plus the per-line open
1115
+ // (join + WS subscribe). Bound to the BRANCH this Document opened on,
1116
+ // which is not the line it materialized when the open named a stream: a
1117
+ // manifest belongs to a branch and a line owns none, so binding to the
1118
+ // line would leave `doc.stream(name)` resolving against nothing.
1119
+ if (opts.streamProvider) {
1120
+ const provider = opts.streamProvider;
1121
+ const openedBranchId = parsed.stream_branch_id ?? parsed.branch_id;
1122
+ doc.setStreamRuntime({
1123
+ list: async () => (await provider.list(docId, openedBranchId)).streams,
1124
+ create: (name) => provider.create(docId, openedBranchId, name),
1125
+ openLine: (lineId) => openStreamLine(docId, lineId),
1126
+ });
1127
+ }
1128
+ // Hosted-UI provider backing the doc's built-in `history` / `details` /
1129
+ // `share` / `delete` commands.
1130
+ if (opts.hostedUiProvider) {
1131
+ doc.setHostedUiProvider(opts.hostedUiProvider);
1132
+ const refreshName = opts.hostedUiProvider.refreshName;
1133
+ if (refreshName && docId) {
1134
+ void refreshName(docId).then((name) => {
1135
+ if (name !== null)
1136
+ doc._setName(name);
1137
+ });
1138
+ }
1139
+ }
1140
+ if (opts.reportError)
1141
+ doc.setErrorReporter(opts.reportError);
1142
+ return doc;
1143
+ }
1144
+ // Open at a historical version: read-only, no transport. The at-version
1145
+ // route returns the same snapshot + tail_ops envelope the join uses, so
1146
+ // `materialise` builds it; `readOnly` + the effective version thread
1147
+ // through `documentOptions` and `wireDocTransport` is skipped (a frozen
1148
+ // point has no live op stream).
1149
+ async function openAtVersion(docId, version, options, token) {
1150
+ const { status, parsed, parseFailed } = await fetchAtVersion(docId, version, options.branch, token);
1151
+ if (status < 200 || status >= 300) {
1152
+ throw brassErrorFromBody(status, parseFailed ? undefined : parsed);
1153
+ }
1154
+ if (!isAtVersionResponse(parsed)) {
1155
+ throw new BrassError('Malformed document at-version response', {
1156
+ status: 500,
1157
+ });
1158
+ }
1159
+ return materialise({
1160
+ doc_id: docId,
1161
+ branch_id: parsed.branch_id,
1162
+ snapshot: parsed.snapshot,
1163
+ tail_ops: parsed.tail_ops,
1164
+ }, {
1165
+ ...options.documentOptions,
1166
+ readOnly: true,
1167
+ version: parsed.effective_version,
1168
+ });
1169
+ }
1170
+ // `GET /links/{token}/view?include=snapshot`: the anonymous guest read.
1171
+ // Sends NO Authorization header (the token is the credential), so it must
1172
+ // not go through the auth-bearing `fetchSnapshot`. The server returns the
1173
+ // same snapshot + tail_ops envelope, member-stripped and read-only.
1174
+ async function fetchLinkView(token, branch) {
1175
+ const search = new URLSearchParams();
1176
+ search.set('include', 'snapshot');
1177
+ if (branch !== undefined)
1178
+ search.set('branch', branch);
1179
+ const url = `${baseUrl}/links/${encodeURIComponent(token)}/view?${search.toString()}`;
1180
+ const response = await fetchWithRetry(() => fetch(url, { method: 'GET' }));
1181
+ const text = await response.text();
1182
+ let parsed = undefined;
1183
+ let parseFailed = false;
1184
+ if (text) {
1185
+ try {
1186
+ parsed = JSON.parse(text);
1187
+ }
1188
+ catch {
1189
+ parseFailed = true;
1190
+ }
1191
+ }
1192
+ return { status: response.status, parsed, parseFailed };
1193
+ }
1194
+ // `GET /links/{token}/streams`: the anonymous stream manifest, the guest
1195
+ // counterpart of the signed-in streams list. No Authorization header (the
1196
+ // token is the credential). Returns the manifest entries the guest's
1197
+ // `doc.stream(name)` resolves against.
1198
+ async function fetchLinkStreamManifest(token) {
1199
+ const url = `${baseUrl}/links/${encodeURIComponent(token)}/streams`;
1200
+ const response = await fetchWithRetry(() => fetch(url, { method: 'GET' }));
1201
+ const text = await response.text();
1202
+ if (response.status < 200 || response.status >= 300) {
1203
+ let parsed = undefined;
1204
+ try {
1205
+ parsed = text ? JSON.parse(text) : undefined;
1206
+ }
1207
+ catch {
1208
+ parsed = undefined;
1209
+ }
1210
+ throw brassErrorFromBody(response.status, parsed);
1211
+ }
1212
+ const body = (text ? JSON.parse(text) : {});
1213
+ const streams = Array.isArray(body.streams) ? body.streams : [];
1214
+ // `created_at` / `created_by` are stripped from the guest manifest (a
1215
+ // guest never learns who a doc's members are); default them so the info
1216
+ // shape stays uniform. Apps only need `name`.
1217
+ return streams.flatMap((s) => typeof s.name === 'string' &&
1218
+ typeof s.format === 'string' &&
1219
+ typeof s.line_id === 'string'
1220
+ ? [
1221
+ {
1222
+ name: s.name,
1223
+ format: s.format,
1224
+ line_id: s.line_id,
1225
+ created_at: '',
1226
+ created_by: '',
1227
+ },
1228
+ ]
1229
+ : []);
1230
+ }
1231
+ const openByLink = async (token, options) => {
1232
+ const { status, parsed, parseFailed } = await fetchLinkView(token, options?.branch);
1233
+ if (status < 200 || status >= 300) {
1234
+ throw brassErrorFromBody(status, parseFailed ? undefined : parsed);
1235
+ }
1236
+ const b = parsed;
1237
+ if (typeof b.doc_id !== 'string' ||
1238
+ typeof b.branch_id !== 'string' ||
1239
+ !Array.isArray(b.tail_ops)) {
1240
+ throw new BrassError('Malformed link view response', { status: 500 });
1241
+ }
1242
+ // The link's grant decides read vs write. An `editor` link is anonymous
1243
+ // EDIT: a live Document (not read-only) whose local mutations ship over
1244
+ // the same token-authenticated socket, with the server admitting the
1245
+ // op-write because the guest's SUB# row carries `role: editor`. A
1246
+ // `viewer` link (the default) is read-only: mutate throws and only
1247
+ // inbound frames apply.
1248
+ const canEdit = b.link_role === 'editor';
1249
+ const doc = await materialise({
1250
+ doc_id: b.doc_id,
1251
+ branch_id: b.branch_id,
1252
+ snapshot: b.snapshot,
1253
+ tail_ops: b.tail_ops,
1254
+ }, { ...options?.documentOptions, readOnly: !canEdit }, options?.onSnapshotProgress);
1255
+ // The view carries the document's display name; set it so the guest
1256
+ // sees the title (the signed-in open gets this from `finalise`).
1257
+ if (typeof b.name === 'string')
1258
+ doc._setName(b.name);
1259
+ wireGuestTransport(doc, b.doc_id, b.branch_id, canEdit);
1260
+ // Streams: a guest resolves `doc.stream(name)` through the token
1261
+ // routes. `list` hits the anonymous manifest; `openLine` joins the
1262
+ // line read/write exactly like the default stream above, over the same
1263
+ // token-authenticated socket. `create` is refused (a guest cannot mint
1264
+ // a line). Absent when there is no WS client (a snapshot-only guest),
1265
+ // matching the default stream having no live transport there.
1266
+ doc.setStreamRuntime({
1267
+ list: () => fetchLinkStreamManifest(token),
1268
+ openLine: async (lineId) => {
1269
+ const view = await fetchLinkView(token, lineId);
1270
+ if (view.status < 200 || view.status >= 300) {
1271
+ throw brassErrorFromBody(view.status, view.parseFailed ? undefined : view.parsed);
1272
+ }
1273
+ const lb = view.parsed;
1274
+ if (typeof lb.doc_id !== 'string' ||
1275
+ typeof lb.branch_id !== 'string' ||
1276
+ !Array.isArray(lb.tail_ops)) {
1277
+ throw new BrassError('Malformed link stream view response', {
1278
+ status: 500,
1279
+ });
1280
+ }
1281
+ const inner = await materialise({
1282
+ doc_id: lb.doc_id,
1283
+ branch_id: lb.branch_id,
1284
+ snapshot: lb.snapshot,
1285
+ tail_ops: lb.tail_ops,
1286
+ }, { readOnly: !canEdit });
1287
+ wireGuestTransport(inner, lb.doc_id, lb.branch_id, canEdit);
1288
+ return inner;
1289
+ },
1290
+ create: () => {
1291
+ throw new BrassError('A shared-link guest cannot create streams.', {
1292
+ status: 403,
1293
+ });
1294
+ },
1295
+ });
1296
+ return doc;
1297
+ };
1298
+ // Wire a guest Document (the default stream or a stream line) to the
1299
+ // token-authenticated socket. Inbound frames (`doc_init` / `doc_op`)
1300
+ // apply for both link kinds; an edit link ALSO wires the outbound path
1301
+ // (`op_ack` resolves the outstanding-op tracker, `setLocalOpHook` ships
1302
+ // each local commit as an `op` frame). Best-effort (no resync / durability
1303
+ // handshake, which need a signed-in token); a guest that misses ops
1304
+ // re-opens. Patches `close` to detach, mirroring `wireDocTransport`, so
1305
+ // `Document` stays free of WS knowledge. A no-op with no WS client (a
1306
+ // snapshot-only guest).
1307
+ function wireGuestTransport(doc, docId, lineId, canEdit) {
1308
+ const wsClient = opts.wsClient;
1309
+ if (!wsClient)
1310
+ return;
1311
+ const topic = `doc:${docId}:${lineId}`;
1312
+ const unsub = wsClient.subscribe(topic, (frame) => {
1313
+ if (isDocInitFrame(frame)) {
1314
+ for (const entry of frame.tail_ops) {
1315
+ if (entry.op_blob_b64 === undefined)
1316
+ continue;
1317
+ doc.applyRemoteOp(base64ToBytes(entry.op_blob_b64));
1318
+ }
1319
+ }
1320
+ else if (isDocOpFrame(frame)) {
1321
+ doc.applyRemoteOp(base64ToBytes(frame.op_blob));
1322
+ }
1323
+ else if (isDocGoneFrame(frame)) {
1324
+ // The server fans `doc_gone` to EVERY subscriber on the branched
1325
+ // content topic, guest-link connections included (`fanoutDocGone` /
1326
+ // `fanoutBranchGone`), on a soft-delete / move of the opened doc.
1327
+ // Without this branch the guest keeps showing removed content and
1328
+ // `doc.onGone(...)` never fires; for an edit link the next local op
1329
+ // would ship onto the evicted SUB# row and bounce as a misleading
1330
+ // `forbidden`. Mark the doc gone (mapping the reason like the
1331
+ // signed-in transport); the guest path has no resend / reverify
1332
+ // timers to drain.
1333
+ doc._markGone(goneReasonOf(frame));
1334
+ }
1335
+ else if (canEdit && isOpAckFrame(frame)) {
1336
+ doc.resolveOutstandingOp(frame.client_op_id);
1337
+ }
1338
+ else if (canEdit && isOpRejectedFrame(frame)) {
1339
+ // A guest edit the server terminally rejected. No resend tracker here
1340
+ // (guest transport is best-effort), so just fail the op on the doc so
1341
+ // "could not be saved" surfaces instead of a stuck saving count.
1342
+ doc._failOutstandingOp(frame.client_op_id, opRejectedMessage(frame));
1343
+ }
1344
+ });
1345
+ if (canEdit) {
1346
+ const minter = opts.newClientOpId ?? defaultClientOpIdMinter;
1347
+ doc.setLocalOpHook((opBlob) => {
1348
+ const clientOpId = minter();
1349
+ wsClient.send({
1350
+ type: 'op',
1351
+ topic,
1352
+ op_blob: bytesToBase64(opBlob),
1353
+ client_op_id: clientOpId,
1354
+ });
1355
+ return clientOpId;
1356
+ });
1357
+ }
1358
+ const originalClose = doc.close.bind(doc);
1359
+ doc.close = () => {
1360
+ try {
1361
+ unsub();
1362
+ }
1363
+ catch {
1364
+ // Already detached; closing the Document is what matters.
1365
+ }
1366
+ originalClose();
1367
+ };
1368
+ }
1369
+ // Strict open: a 404 propagates as `BrassError`. A `version` option
1370
+ // diverts to the read-only at-version path.
1371
+ const open = async (docId, options) => {
1372
+ // Cache hit: return the parked Document. Its subscription stayed alive
1373
+ // while parked, and the UndoController + outstanding-op map survived, so
1374
+ // undo works as if the user never switched away.
1375
+ const parked = takeParkedDocument(docId, options);
1376
+ if (parked) {
1377
+ parked._resume();
1378
+ return parked;
1379
+ }
1380
+ const endTotal = perfStart('open: openDocumentById total');
1381
+ const token = await requireToken();
1382
+ if (options?.version !== undefined) {
1383
+ const atVersion = await openAtVersion(docId, options.version, options, token);
1384
+ endTotal({ docId, path: 'at-version' });
1385
+ return atVersion;
1386
+ }
1387
+ const snapshot = await fetchSnapshot(docId, options, token);
1388
+ const opened = await finalise(docId, snapshot, options);
1389
+ endTotal({ docId });
1390
+ return opened;
1391
+ };
1392
+ // Create a doc and return it fully editable. Two round trips (POST to
1393
+ // create + obtain the doc_id, then `open(docId)`); they aren't fused
1394
+ // because the create response is metadata-only and the open path already
1395
+ // handles the empty-snapshot case via `Document.create()`. This is the
1396
+ // low-level path (`Brass.createDocument`): the server places the doc in
1397
+ // the caller's single organization; a caller with none is routed to the
1398
+ // hosted acquire-organization surface and the create retries; a multi-org
1399
+ // caller rejects with a 409 (no hosted picker here, that is the session's
1400
+ // `create()`).
1401
+ const create = async (options) => {
1402
+ const args = { name: options?.name ?? 'Untitled' };
1403
+ if (options?.app_metadata !== undefined)
1404
+ args.app_metadata = options.app_metadata;
1405
+ let result = await createDocumentId(args);
1406
+ if (result.kind === 'needs-organization') {
1407
+ const acquired = await opts.hostedUiProvider?.acquireOrganization?.();
1408
+ if (!acquired || typeof acquired.organization_id !== 'string') {
1409
+ throw new BrassError('No organization available', { status: 404 });
1410
+ }
1411
+ result = await createDocumentId({
1412
+ ...args,
1413
+ destination: { organization_id: acquired.organization_id },
1414
+ });
1415
+ }
1416
+ if (result.kind === 'needs-destination') {
1417
+ throw new BrassError('Choose a destination organization', { status: 409 });
1418
+ }
1419
+ if (result.kind !== 'created') {
1420
+ throw new BrassError('Malformed createDocument response', { status: 500 });
1421
+ }
1422
+ const openOptions = options?.documentOptions
1423
+ ? { documentOptions: options.documentOptions }
1424
+ : undefined;
1425
+ return open(result.docId, openOptions);
1426
+ };
1427
+ return { open, create, createDocumentId, openByLink };
1428
+ }
1429
+ // Per-process parking cache for opened Documents. `doc.close()` does NOT
1430
+ // free the LoroDoc / UndoController; it parks the Document keyed on
1431
+ // (docId, branchId), and the next `openDocumentById` for that key returns it
1432
+ // with its undo stack intact, so "switch branches and come back" preserves
1433
+ // per-branch undo without app-side bookkeeping.
1434
+ //
1435
+ // LRU eviction past `PARK_CACHE_CAPACITY`; the evicted Document gets its full
1436
+ // teardown (WS detach, presence leave, LoroDoc free). Version-pinned
1437
+ // (read-only) opens bypass the cache: they are transient with no editable
1438
+ // state to keep. Module-scoped so it persists across factory invocations in
1439
+ // one process; keys are app-supplied so no cross-app leak within a tab.
1440
+ const PARK_CACHE_CAPACITY = 4;
1441
+ const parkedDocs = new Map();
1442
+ function parkCacheKey(docId, branchId, streamName) {
1443
+ // Skip caching when the branch is unknown: the next open() may resolve to
1444
+ // a different branch (the server default), and returning the wrong branch
1445
+ // is worse than a lost undo stack.
1446
+ if (!branchId)
1447
+ return null;
1448
+ // The stream is part of the identity of what was opened: a Document
1449
+ // opened at `geometry` holds different state from one opened at the same
1450
+ // branch's default, so handing either back for the other would swap the
1451
+ // caller's document out from under it.
1452
+ return `${docId}/${branchId}/${streamName ?? DEFAULT_STREAM_NAME}`;
1453
+ }
1454
+ function parkDocument(entry) {
1455
+ parkedDocs.set(entry.key, entry);
1456
+ // Map iteration is insertion order, so evicting the front entry is LRU
1457
+ // (a newly parked entry is the most-recent; a hit removes the entry
1458
+ // entirely, so there's no bump-on-hit).
1459
+ while (parkedDocs.size > PARK_CACHE_CAPACITY) {
1460
+ const oldestKey = parkedDocs.keys().next().value;
1461
+ if (oldestKey === undefined)
1462
+ break;
1463
+ const oldest = parkedDocs.get(oldestKey);
1464
+ parkedDocs.delete(oldestKey);
1465
+ try {
1466
+ oldest?.teardown();
1467
+ }
1468
+ catch {
1469
+ // Teardown errors must not strand the cache; the evicted Doc is gone
1470
+ // from the user's perspective either way.
1471
+ }
1472
+ }
1473
+ }
1474
+ function takeParkedDocument(docId, options) {
1475
+ if (options?.version !== undefined)
1476
+ return null;
1477
+ const key = parkCacheKey(docId, options?.branch, options?.stream);
1478
+ if (!key)
1479
+ return null;
1480
+ const entry = parkedDocs.get(key);
1481
+ if (!entry)
1482
+ return null;
1483
+ parkedDocs.delete(key);
1484
+ return entry.doc;
1485
+ }
1486
+ // Test-only: drop the parking cache. Used by unit tests that need
1487
+ // a clean slate between cases.
1488
+ export function _resetParkCacheForTesting() {
1489
+ for (const entry of parkedDocs.values()) {
1490
+ try {
1491
+ entry.teardown();
1492
+ }
1493
+ catch {
1494
+ // ignore
1495
+ }
1496
+ }
1497
+ parkedDocs.clear();
1498
+ }
1499
+ // Wire the WS transport onto a materialised Document. Three pieces:
1500
+ // 1. WS subscribe on the doc topic; the server sends `doc_op` (other
1501
+ // clients' edits) and `op_ack` (our ops), topic-scoped so the
1502
+ // `BrassWsClient` multiplexer routes them to our callback.
1503
+ // 2. Local-op send hook: `subscribeLocalUpdates` fires per-commit; the
1504
+ // hook mints a `client_op_id`, records it on the outstanding-op map (so
1505
+ // the server echo reads as self-originated), and sends the frame.
1506
+ // 3. Teardown: `close()` clears the hook and detaches the subscription via
1507
+ // the closure-held unsubscribe handle.
1508
+ // How often a long-lived connected tab re-fetches the durable frontier and
1509
+ // re-runs the durability handshake. The handshake also fires on `doc_init`
1510
+ // (reconnect) and `doc_ops_dropped`, but both are event-driven: a tab
1511
+ // connected for days that missed its best-effort drop notification would not
1512
+ // self-heal until it reconnects. This periodic re-verify closes that window.
1513
+ // Cheap (one JSON round-trip, no snapshot download). Exported for the
1514
+ // fake-timer test.
1515
+ export const PERIODIC_REVERIFY_INTERVAL_MS = 5 * 60_000;
1516
+ // How long a hole below the coverage floor may stand before the transport
1517
+ // re-imports the join to fill it. An op arriving ahead of its predecessor
1518
+ // opens a hole that closes within a round trip; one still open after this
1519
+ // delay is an op the server will not send again. Exported for the
1520
+ // fake-timer test.
1521
+ export const MISSED_OP_RESYNC_DELAY_MS = 2_000;
1522
+ // A mutation carrying many records commits as it goes, so the only write
1523
+ // that reaches this is one record nothing can split.
1524
+ const TOO_LARGE_MESSAGE = 'This record is too large to save. A single record must fit in one ' +
1525
+ 'update; store the bulk of it as a file instead, or split it across ' +
1526
+ 'several records.';
1527
+ function wireDocTransport(args) {
1528
+ const topic = `doc:${args.docId}:${args.branchId}`;
1529
+ // Per-op retry tracker. The op-write endpoint sometimes returns
1530
+ // `{"message": "Internal server error"}` without reaching the handler
1531
+ // (burst input against a cold-started server giving up before scale-up).
1532
+ // That frame has no `type`, so the multiplexer drops it,
1533
+ // leaving the op stuck in `Document._outstandingByClientId` and the
1534
+ // actor's lamport stream with a gap that blocks other subscribers' Loro
1535
+ // imports from converging (Loro requires per-actor causal contiguity).
1536
+ //
1537
+ // Recovery is retry-forever while the doc is open: a capped backoff plus an
1538
+ // immediate resend of every outstanding op on each reconnect (`doc_init`).
1539
+ // Re-sends are idempotent (a duplicate seq is a Loro no-op) and loss
1540
+ // windows sit inside the server's 15-minute discard window, so the moment a
1541
+ // retry lands the dependent chain converges everywhere. `doc.savingCount`
1542
+ // stays above zero until the `op_ack` lands (the "Saving…" surface); there
1543
+ // is no give-up to surface because the SDK keeps trying.
1544
+ const outstandingResends = new Map();
1545
+ // Ops this client is writing over HTTP, and the highest seq a catch-up
1546
+ // prompt named while one was in flight.
1547
+ //
1548
+ // The server tells a document's subscribers about an op the socket could
1549
+ // not carry by prompting them to catch up, and the writer is one of those
1550
+ // subscribers: the prompt names the seq of the op this client just wrote,
1551
+ // and arrives while the response that would resolve it is still in
1552
+ // flight. Answered immediately, the writer re-imports a whole snapshot
1553
+ // for an op it already applied, and `_applySnapshot` drops its undo and
1554
+ // redo stacks with it. So a prompt that lands mid-write is held and
1555
+ // re-judged by the same predicate once the write settles, where the
1556
+ // client's own seq is covered and the prompt is answered only if
1557
+ // something else moved the head.
1558
+ let httpOpWritesInFlight = 0;
1559
+ let heldCatchUpSeq = 0;
1560
+ function settleHttpOpWrite() {
1561
+ httpOpWritesInFlight -= 1;
1562
+ if (httpOpWritesInFlight > 0)
1563
+ return;
1564
+ const seq = heldCatchUpSeq;
1565
+ heldCatchUpSeq = 0;
1566
+ if (seq > 0 && args.getCoveredSeq() < seq)
1567
+ args.resyncFromSnapshot?.();
1568
+ }
1569
+ // Backoff: 1.5s, ~1.6x per attempt, ceiling 30s. The ceiling keeps a doc
1570
+ // on a dead connection from hammering; the reconnect-driven resend below
1571
+ // fires the instant `doc_init` arrives, so the ceiling never delays
1572
+ // recovery past the next reconnect.
1573
+ const RESEND_BASE_DELAY_MS = 1500;
1574
+ const RESEND_MAX_DELAY_MS = 30_000;
1575
+ function resendDelayMs(attempts) {
1576
+ const grown = RESEND_BASE_DELAY_MS * Math.pow(1.6, attempts - 1);
1577
+ return Math.min(grown, RESEND_MAX_DELAY_MS);
1578
+ }
1579
+ function scheduleResend(clientOpId) {
1580
+ const entry = outstandingResends.get(clientOpId);
1581
+ if (!entry)
1582
+ return;
1583
+ entry.timer = setTimeout(() => {
1584
+ const current = outstandingResends.get(clientOpId);
1585
+ if (!current)
1586
+ return;
1587
+ current.attempts += 1;
1588
+ args.wsClient.send(current.frame);
1589
+ scheduleResend(clientOpId);
1590
+ }, resendDelayMs(entry.attempts));
1591
+ }
1592
+ // Resend every outstanding op now and restart its backoff. Called on
1593
+ // reconnect (`doc_init`): a dropped-and-restored connection is the most
1594
+ // common reason an op went unacked, and the backoff may have grown to its
1595
+ // ceiling while the socket was down, so re-asserting immediately is the
1596
+ // fast recovery path.
1597
+ function resendAllOutstanding() {
1598
+ for (const [clientOpId, entry] of outstandingResends) {
1599
+ clearTimeout(entry.timer);
1600
+ entry.attempts += 1;
1601
+ args.wsClient.send(entry.frame);
1602
+ scheduleResend(clientOpId);
1603
+ }
1604
+ }
1605
+ // Durability handshake, run on every `doc_init`. The persisted version
1606
+ // vector + durable tail ops define the durable frontier; if the local doc
1607
+ // holds CRDT knowledge beyond it (an op acked then discarded by the
1608
+ // aged-orphan escape), resync-push the missing slice. Import is idempotent,
1609
+ // so what the server has no-ops and only the holes fill. The loop guard +
1610
+ // compute-and-push live on the Document (`maybeResyncPush`) so the
1611
+ // `doc_ops_dropped` path shares them; here we decode the vector and
1612
+ // delegate, skipping a missing vector (nothing to verify against).
1613
+ function maybeResyncPush(snapshotVersionVectorB64, durableTailBlobs) {
1614
+ if (!snapshotVersionVectorB64)
1615
+ return;
1616
+ args.doc.maybeResyncPush(base64ToBytes(snapshotVersionVectorB64), durableTailBlobs);
1617
+ }
1618
+ // Re-fetch the durable frontier from the HTTP join and run the handshake.
1619
+ // Shared by the `doc_ops_dropped` prompt path and the periodic re-verify:
1620
+ // both need the post-discard frontier, which only a fresh fetch carries,
1621
+ // not the `doc_init` frame in hand. Cheap (version-vector-and-tail JSON,
1622
+ // no snapshot blob).
1623
+ //
1624
+ // Coalesced, not just guarded: a request arriving mid-fetch sets
1625
+ // `reverifyAgain` so exactly one more fetch runs after this one settles. A
1626
+ // plain skip-if-in-flight would drop a `doc_ops_dropped` that landed while
1627
+ // a tick read the pre-discard frontier (the discard seals the new snapshot
1628
+ // then fans out), deferring the heal to the next tick or reconnect.
1629
+ let reverifyInFlight = false;
1630
+ let reverifyAgain = false;
1631
+ // Armed after the subscription is wired, cleared on `doc_gone` and teardown.
1632
+ let reverifyTimer = null;
1633
+ async function reverifyDurability() {
1634
+ if (reverifyInFlight) {
1635
+ reverifyAgain = true;
1636
+ return;
1637
+ }
1638
+ reverifyInFlight = true;
1639
+ try {
1640
+ do {
1641
+ reverifyAgain = false;
1642
+ try {
1643
+ const frontier = await args.refetchDurableFrontier?.();
1644
+ if (frontier && frontier.snapshotVersionVector) {
1645
+ args.doc.maybeResyncPush(base64ToBytes(frontier.snapshotVersionVector), frontier.tailBlobs);
1646
+ }
1647
+ }
1648
+ catch {
1649
+ // Best-effort: a failed re-fetch defers the check to the next tick
1650
+ // or reconnect's `doc_init` handshake. Swallowed so the timer's
1651
+ // fire-and-forget never surfaces an unhandled rejection and one
1652
+ // failed tick does not wedge the loop guard.
1653
+ }
1654
+ } while (reverifyAgain);
1655
+ }
1656
+ finally {
1657
+ reverifyInFlight = false;
1658
+ }
1659
+ }
1660
+ // A coverage floor below the highest seq seen records ops this client
1661
+ // applied nothing for, and the server posts each op once, so nothing
1662
+ // re-sends them. Pull the join instead, after a delay long enough for an
1663
+ // out-of-order arrival to close the hole itself.
1664
+ let gapTimer = null;
1665
+ function scheduleGapCheck() {
1666
+ if (gapTimer !== null)
1667
+ return;
1668
+ if (args.getCoveredSeq() >= args.getCurrentSeq())
1669
+ return;
1670
+ gapTimer = setTimeout(() => {
1671
+ gapTimer = null;
1672
+ if (args.getCoveredSeq() >= args.getCurrentSeq())
1673
+ return;
1674
+ args.resyncFromSnapshot?.();
1675
+ }, MISSED_OP_RESYNC_DELAY_MS);
1676
+ gapTimer.unref?.();
1677
+ }
1678
+ // 1. Subscribe to the doc topic. Frame routing:
1679
+ // - `doc_init`: bridge the join-vs-fanout race. Ops committed between
1680
+ // the HTTP join (T0) and the WS subscribe (T1) miss the HTTP tail and
1681
+ // can race the SUB# row so the first `doc_op` fanout may arrive BEFORE
1682
+ // doc_init (separate server invocations, no wire ordering). tail_ops
1683
+ // apply unconditionally: filtering on `seq > currentSeq` would mask
1684
+ // tail_ops a racing `doc_op` inflated the cursor past, leaving lamport
1685
+ // gaps Loro cannot converge across (re-applying is an idempotent
1686
+ // no-op).
1687
+ // - `doc_op`: apply, advance cursor.
1688
+ // - `op_ack`: resolve the outstanding-op tracker, advance cursor, cancel
1689
+ // that op's resend timer.
1690
+ // - everything else: handled by `BrassWsClient`.
1691
+ const unsub = args.wsClient.subscribe(topic, (frame) => {
1692
+ if (isDocInitFrame(frame)) {
1693
+ // Resync check BEFORE applying. The tail covers (snapshot_seq,
1694
+ // current_seq]; if the coverage floor sits below the tail's lower edge
1695
+ // (or below current_seq when the tail is empty), ops in between were
1696
+ // compacted into the snapshot while disconnected and exist nowhere in
1697
+ // this tail or future fanout, so re-import the join's snapshot. The
1698
+ // initial subscribe never trips this (the floor was just seeded), so
1699
+ // the cost is paid only on a reconnect that crossed a compaction.
1700
+ const minTailSeq = frame.tail_ops.reduce((acc, entry) => (acc === null || entry.seq < acc ? entry.seq : acc), null);
1701
+ const snapshotFloor = minTailSeq !== null ? minTailSeq - 1 : frame.current_seq;
1702
+ const needsResync = args.getCoveredSeq() < snapshotFloor;
1703
+ const tailBlobs = [];
1704
+ for (const entry of frame.tail_ops) {
1705
+ // An op with no inline bytes is deliberately left uncovered: it
1706
+ // has not been applied, so marking it covered would retire the
1707
+ // very resync that fetches it.
1708
+ if (entry.op_blob_b64 === undefined)
1709
+ continue;
1710
+ const blob = base64ToBytes(entry.op_blob_b64);
1711
+ tailBlobs.push(blob);
1712
+ args.doc.applyRemoteOp(blob);
1713
+ if (entry.seq > args.getCurrentSeq())
1714
+ args.setCurrentSeq(entry.seq);
1715
+ args.advanceCoveredSeq(entry.seq);
1716
+ }
1717
+ if (frame.current_seq > args.getCurrentSeq()) {
1718
+ args.setCurrentSeq(frame.current_seq);
1719
+ }
1720
+ if (needsResync) {
1721
+ if (httpOpWritesInFlight > 0) {
1722
+ if (frame.current_seq > heldCatchUpSeq)
1723
+ heldCatchUpSeq = frame.current_seq;
1724
+ }
1725
+ else {
1726
+ args.resyncFromSnapshot?.();
1727
+ }
1728
+ }
1729
+ // `doc_init` is the reconnect signal the retry-forever recovery hangs
1730
+ // off: re-assert every outstanding op and run the durability handshake
1731
+ // now, so a client holding work the server lost re-asserts it on
1732
+ // reconnect rather than waiting out the per-op backoff.
1733
+ resendAllOutstanding();
1734
+ // Tolerate a non-string vector (a future retyping) as absent: the
1735
+ // handshake is skipped, but the frame still delivers.
1736
+ maybeResyncPush(typeof frame.snapshot_version_vector === 'string'
1737
+ ? frame.snapshot_version_vector
1738
+ : undefined, tailBlobs);
1739
+ // Posted after the SUB# row exists, so this is the safe point to
1740
+ // reconcile a pending open whose terminal status frame was dropped.
1741
+ args.reconcileStatusOnInit?.();
1742
+ return;
1743
+ }
1744
+ if (isDocOpFrame(frame)) {
1745
+ // Apply unconditionally. A `seq <= currentSeq` short-circuit looks safe
1746
+ // (import is idempotent) but permanently drops ops on out-of-order
1747
+ // fanout: seq=14 before seq=13 inflates the cursor past 13, after which
1748
+ // seq=13 is skipped forever, leaving a lamport gap Loro cannot converge
1749
+ // across.
1750
+ const bytes = base64ToBytes(frame.op_blob);
1751
+ args.doc.applyRemoteOp(bytes);
1752
+ if (frame.seq > args.getCurrentSeq())
1753
+ args.setCurrentSeq(frame.seq);
1754
+ args.advanceCoveredSeq(frame.seq);
1755
+ scheduleGapCheck();
1756
+ return;
1757
+ }
1758
+ if (isOpAckFrame(frame)) {
1759
+ args.doc.resolveOutstandingOp(frame.client_op_id);
1760
+ args.setCurrentSeq(frame.seq);
1761
+ args.advanceCoveredSeq(frame.seq);
1762
+ // An ack for a local op sits above any remote op this client never
1763
+ // received, so the user's own next edit is a second chance to notice.
1764
+ scheduleGapCheck();
1765
+ const pending = outstandingResends.get(frame.client_op_id);
1766
+ if (pending) {
1767
+ clearTimeout(pending.timer);
1768
+ outstandingResends.delete(frame.client_op_id);
1769
+ }
1770
+ return;
1771
+ }
1772
+ if (isOpRejectedFrame(frame)) {
1773
+ // Terminal, not transient: cancel this op's resend (retrying would only
1774
+ // draw the same rejection forever and pin `savingCount`) and fail it on
1775
+ // the Document so the app hears "could not be saved" instead of a silent
1776
+ // wedge. Scoped to this one op; the topic subscription stays live.
1777
+ const pending = outstandingResends.get(frame.client_op_id);
1778
+ if (pending) {
1779
+ clearTimeout(pending.timer);
1780
+ outstandingResends.delete(frame.client_op_id);
1781
+ }
1782
+ args.doc._failOutstandingOp(frame.client_op_id, opRejectedMessage(frame));
1783
+ return;
1784
+ }
1785
+ if (isDocGoneFrame(frame)) {
1786
+ // Cancel every resend timer FIRST so a doc that was saving when delete
1787
+ // landed stops re-trying ops the server will only reject as `forbidden`
1788
+ // (the SUB# row was evicted at fanout). Draining before the `_markGone`
1789
+ // listeners keeps the lifecycle observably clean.
1790
+ for (const pending of outstandingResends.values()) {
1791
+ clearTimeout(pending.timer);
1792
+ }
1793
+ outstandingResends.clear();
1794
+ // Stop re-verifying durability against an evicted doc: a resync push
1795
+ // would only bounce as `forbidden`, and a gap pull would only 404.
1796
+ if (reverifyTimer !== null) {
1797
+ clearInterval(reverifyTimer);
1798
+ reverifyTimer = null;
1799
+ }
1800
+ if (gapTimer !== null) {
1801
+ clearTimeout(gapTimer);
1802
+ gapTimer = null;
1803
+ }
1804
+ args.doc._markGone(goneReasonOf(frame));
1805
+ return;
1806
+ }
1807
+ if (isDocStatusFrame(frame)) {
1808
+ // The validator gates only on `pending`; default `message` here so a
1809
+ // message-less status still carries a valid `DocumentWorkStatus`
1810
+ // (`message` is typed non-null and rendered verbatim).
1811
+ const status = {
1812
+ ...frame.status,
1813
+ message: typeof frame.status.message === 'string' ? frame.status.message : '',
1814
+ };
1815
+ // `reload` is the server's authoritative "succeeded, content ready"
1816
+ // hint: pull the content in and emit the terminal status only AFTER the
1817
+ // import lands, so an app re-reading on `onStatus` sees content present.
1818
+ // A failure frame has no `reload`, so emit immediately and fetch
1819
+ // nothing (no racy read, no loop).
1820
+ if (frame.reload && args.reloadAfterHydration) {
1821
+ args.reloadAfterHydration(status);
1822
+ }
1823
+ else {
1824
+ args.doc._emitStatus(status);
1825
+ }
1826
+ return;
1827
+ }
1828
+ if (isDocMetaFrame(frame)) {
1829
+ // `_setName` de-dupes, so a re-emit of the current name is a no-op.
1830
+ args.doc._setName(frame.name);
1831
+ return;
1832
+ }
1833
+ if (isDocDriveStatusFrame(frame)) {
1834
+ // The signal de-dupes a no-op re-emit, so forwarding every frame is safe.
1835
+ args.doc._emitDriveStatus(toDriveStatus(frame.drive));
1836
+ return;
1837
+ }
1838
+ if (isDocOpsDroppedFrame(frame)) {
1839
+ // Prompt durability re-check: the discard sealed a new snapshot, so
1840
+ // re-fetch the moved frontier and run the handshake now instead of at
1841
+ // the author's next reconnect. The handshake self-selects: the author
1842
+ // finds local ahead and resync-pushes under a new seq, others no-op.
1843
+ // Covered by the `doc_init` handshake / periodic re-verify if missed.
1844
+ void reverifyDurability();
1845
+ return;
1846
+ }
1847
+ // Unknown frame: ignore, so server-side schema growth doesn't break older
1848
+ // SDK builds.
1849
+ });
1850
+ // Arm the periodic durability re-verify (the long-lived-tab backstop).
1851
+ // `unref` so the interval never keeps a Node host alive (no-op in the
1852
+ // browser); cleared on `doc_gone` and teardown either way.
1853
+ reverifyTimer = setInterval(() => {
1854
+ void reverifyDurability();
1855
+ }, PERIODIC_REVERIFY_INTERVAL_MS);
1856
+ reverifyTimer.unref?.();
1857
+ // 2. Install the local-op hook, called by `subscribeLocalUpdates` after
1858
+ // every successful `commit()`. Each frame is registered in the resend
1859
+ // tracker so a silently-dropped server response triggers a retry.
1860
+ args.doc.setLocalOpHook((opBlob) => {
1861
+ const clientOpId = args.newClientOpId();
1862
+ const opBlobB64 = bytesToBase64(opBlob);
1863
+ const sendFrame = {
1864
+ type: 'op',
1865
+ topic,
1866
+ op_blob: opBlobB64,
1867
+ client_op_id: clientOpId,
1868
+ };
1869
+ // A frame past the transport's cap is not delivered and takes the
1870
+ // connection down with it, so it must never reach the socket OR the
1871
+ // resend tracker: re-sending it on each reconnect is what turns one
1872
+ // bulk mutation into a reconnect loop that persists nothing and churns
1873
+ // presence. Route it to the HTTP op write instead, which shares the
1874
+ // durable path and tells other subscribers to catch up.
1875
+ if (opFrameExceedsWsLimit(sendFrame)) {
1876
+ void writeOversizedOp(opBlobB64, clientOpId);
1877
+ return clientOpId;
1878
+ }
1879
+ args.wsClient.send(sendFrame);
1880
+ outstandingResends.set(clientOpId, {
1881
+ frame: sendFrame,
1882
+ attempts: 1,
1883
+ // Placeholder, overwritten immediately below.
1884
+ timer: setTimeout(() => undefined, 0),
1885
+ });
1886
+ scheduleResend(clientOpId);
1887
+ return clientOpId;
1888
+ });
1889
+ async function writeOversizedOp(opBlobB64, clientOpId) {
1890
+ // Counted before the first await, so a catch-up prompt for this op
1891
+ // cannot arrive between the frame going out and the count going up.
1892
+ httpOpWritesInFlight += 1;
1893
+ try {
1894
+ await writeOversizedOpInner(opBlobB64, clientOpId);
1895
+ }
1896
+ finally {
1897
+ settleHttpOpWrite();
1898
+ }
1899
+ }
1900
+ async function writeOversizedOpInner(opBlobB64, clientOpId) {
1901
+ // Yield before any outcome resolves the op. The hook's caller records
1902
+ // the outstanding op AFTER the hook returns, and an async function body
1903
+ // runs synchronously up to its first await, so an exit taken inline
1904
+ // would find nothing tracked and leave the entry behind with
1905
+ // `savingCount` stuck above zero.
1906
+ await Promise.resolve();
1907
+ // The size is known here, so refuse without spending a round trip on a
1908
+ // blob that can only come back 400.
1909
+ if (opBlobExceedsDurableLimit(opBlobB64)) {
1910
+ args.doc._failOutstandingOp(clientOpId, TOO_LARGE_MESSAGE);
1911
+ return;
1912
+ }
1913
+ if (!args.postOversizedOp) {
1914
+ args.doc._failOutstandingOp(clientOpId, TOO_LARGE_MESSAGE);
1915
+ return;
1916
+ }
1917
+ let result;
1918
+ try {
1919
+ result = await args.postOversizedOp(opBlobB64, clientOpId);
1920
+ }
1921
+ catch (err) {
1922
+ result = {
1923
+ ok: false,
1924
+ message: err instanceof Error ? err.message : String(err),
1925
+ };
1926
+ }
1927
+ if (!result.ok) {
1928
+ // Terminal for this op, the same way `op_rejected` is: the local
1929
+ // commit stays applied but never persists, so the app hears about it
1930
+ // instead of watching `savingCount` sit above zero forever.
1931
+ args.doc._failOutstandingOp(clientOpId, result.message);
1932
+ return;
1933
+ }
1934
+ // Same bookkeeping an `op_ack` does: resolve the outstanding entry and
1935
+ // advance both cursors past the seq the server allocated.
1936
+ args.doc.resolveOutstandingOp(clientOpId);
1937
+ args.setCurrentSeq(result.seq);
1938
+ args.advanceCoveredSeq(result.seq);
1939
+ }
1940
+ // 2b. Expose the live-head-seq cursor so `Brass.documents.setThumbnail` can
1941
+ // anchor a screenshot to the version it depicts.
1942
+ args.doc.setCurrentSeqProvider(args.getCurrentSeq);
1943
+ // 3. Attach the presence controller. It subscribes to the sibling
1944
+ // `presence:doc:<docId>:<branchId>` topic on the same shared client, so
1945
+ // presence and ops are independent fanout channels on one connection,
1946
+ // both scoped to the opened branch. Exposed as `doc.presence`.
1947
+ // Presence is document-level (one roster per open doc), so a stream
1948
+ // line's transport skips it.
1949
+ if (args.skipPresence !== true) {
1950
+ args.doc.presence = createPresenceController(args.wsClient, args.docId, args.branchId);
1951
+ }
1952
+ // 4. Tear-down hook. Patch `Document.close` once (rather than have Document
1953
+ // accept a teardown callback) so `Document` stays free of WS knowledge:
1954
+ // the first close detaches the subscription, drops the hook, tears down
1955
+ // presence, then runs the original close.
1956
+ const originalClose = args.doc.close.bind(args.doc);
1957
+ // Set once `fullTeardown` has run (via the unparkable close path OR a park
1958
+ // cache eviction). Guards both `fullTeardown` (so its intermediate steps
1959
+ // don't run twice) and the patched close below (so a double-close of an
1960
+ // already-evicted, already-closed Document does not re-park it, which would
1961
+ // hand a later open a closed Document whose every method throws).
1962
+ let tornDown = false;
1963
+ // Captured so it can run from either the patched close (unparkable case) or
1964
+ // the park cache's eviction.
1965
+ const fullTeardown = () => {
1966
+ if (tornDown)
1967
+ return;
1968
+ tornDown = true;
1969
+ try {
1970
+ unsub();
1971
+ }
1972
+ catch {
1973
+ // unsub failure is non-fatal (already-detached); closing the Document
1974
+ // below is what matters.
1975
+ }
1976
+ try {
1977
+ args.doc.presence?.leave();
1978
+ }
1979
+ catch {
1980
+ // Best-effort; never block the Document close below.
1981
+ }
1982
+ args.doc.setLocalOpHook(null);
1983
+ args.doc.setCurrentSeqProvider(null);
1984
+ if (reverifyTimer !== null) {
1985
+ clearInterval(reverifyTimer);
1986
+ reverifyTimer = null;
1987
+ }
1988
+ if (gapTimer !== null) {
1989
+ clearTimeout(gapTimer);
1990
+ gapTimer = null;
1991
+ }
1992
+ for (const entry of outstandingResends.values()) {
1993
+ clearTimeout(entry.timer);
1994
+ }
1995
+ outstandingResends.clear();
1996
+ originalClose();
1997
+ };
1998
+ args.doc.close = () => {
1999
+ // Already fully torn down (an LRU eviction closed this Document while it
2000
+ // was parked): a second close must not re-park a closed Document.
2001
+ if (tornDown)
2002
+ return;
2003
+ // Read-only previews bypass parking: no undo stack to preserve, and a
2004
+ // stale preview would shadow a later live-head open. Same for a doc with
2005
+ // no stable branchId to key on, and for a stream line's internal doc
2006
+ // (its lifecycle is the parent Document's). Keying on the REQUESTED branch
2007
+ // (not the resolved one) keeps park and take symmetric; a default-branch
2008
+ // open has no requested branch and so skips the cache.
2009
+ const cacheKey = args.doc.readOnly || args.parkable === false
2010
+ ? null
2011
+ : parkCacheKey(args.docId, args.requestedBranchId, args.requestedStream);
2012
+ if (cacheKey === null) {
2013
+ fullTeardown();
2014
+ return;
2015
+ }
2016
+ args.doc._suspend();
2017
+ parkDocument({ doc: args.doc, teardown: fullTeardown, key: cacheKey });
2018
+ };
2019
+ }
2020
+ /**
2021
+ * Fetch a presigned snapshot, streaming the body so download progress
2022
+ * can be reported as bytes arrive. The total is the decompressed size
2023
+ * from the object's `x-amz-meta-raw-bytes` metadata header (snapshots
2024
+ * are stored gzip-compressed; the browser decompresses transparently,
2025
+ * so the streamed chunk sizes sum to the decompressed total). Falls
2026
+ * back to a non-streamed read when the body stream or progress sink is
2027
+ * unavailable.
2028
+ */
2029
+ async function fetchPresignedSnapshot(url, onProgress) {
2030
+ const response = await fetch(url, { method: 'GET' });
2031
+ if (!response.ok) {
2032
+ throw new BrassError('Presigned snapshot fetch failed', {
2033
+ status: response.status,
2034
+ });
2035
+ }
2036
+ const rawHeader = response.headers.get('x-amz-meta-raw-bytes');
2037
+ const total = rawHeader !== null && Number.isFinite(Number(rawHeader))
2038
+ ? Number(rawHeader)
2039
+ : null;
2040
+ if (!onProgress || !response.body) {
2041
+ const buf = await response.arrayBuffer();
2042
+ onProgress?.(buf.byteLength, total);
2043
+ return new Uint8Array(buf);
2044
+ }
2045
+ const reader = response.body.getReader();
2046
+ const chunks = [];
2047
+ let loaded = 0;
2048
+ onProgress(0, total);
2049
+ for (;;) {
2050
+ const { done, value } = await reader.read();
2051
+ if (done)
2052
+ break;
2053
+ if (value) {
2054
+ chunks.push(value);
2055
+ loaded += value.byteLength;
2056
+ onProgress(loaded, total);
2057
+ }
2058
+ }
2059
+ const out = new Uint8Array(loaded);
2060
+ let offset = 0;
2061
+ for (const chunk of chunks) {
2062
+ out.set(chunk, offset);
2063
+ offset += chunk.byteLength;
2064
+ }
2065
+ return out;
2066
+ }
2067
+ // A snapshot delivery `kind` this build does not implement means the server is
2068
+ // serving a document in a shape newer than this pinned SDK can read. Surface a
2069
+ // legible upgrade prompt (rendered verbatim through `session.error`), never an
2070
+ // empty document: treating an unreadable snapshot as "no snapshot" would let
2071
+ // the user edit and overwrite content that is actually there.
2072
+ function snapshotKindTooNewError() {
2073
+ return new BrassError('This document needs a newer version of this app. Update to the latest @brass-build/client to open it.', { status: 426 });
2074
+ }
2075
+ async function materialise(body, documentOptions, onSnapshotProgress) {
2076
+ // Stamp the server-minted doc_id onto the Document (via DocumentOptions, so
2077
+ // it threads through the LoroDoc constructor without changing the static
2078
+ // factory signatures) so callers can read `doc.id` to correlate.
2079
+ const optionsWithId = {
2080
+ ...documentOptions,
2081
+ docId: body.doc_id,
2082
+ branchId: body.branch_id,
2083
+ };
2084
+ let doc;
2085
+ const snap = body.snapshot;
2086
+ if (snap === null) {
2087
+ doc = Document.create(optionsWithId);
2088
+ }
2089
+ else if (snap.kind === 'inline') {
2090
+ const endDecode = perfStart('open: base64 decode (inline snapshot)');
2091
+ const bytes = base64ToBytes(snap.data_b64);
2092
+ endDecode({ base64Chars: snap.data_b64.length, snapshotBytes: bytes.byteLength });
2093
+ const endImport = perfStart('open: loro import (inline snapshot)');
2094
+ doc = Document.openFromSnapshot(bytes, optionsWithId);
2095
+ endImport({ snapshotBytes: bytes.byteLength });
2096
+ }
2097
+ else if (snap.kind === 'presigned') {
2098
+ const endPresigned = perfStart('open: presigned snapshot fetch');
2099
+ const bytes = await fetchPresignedSnapshot(snap.url, onSnapshotProgress);
2100
+ endPresigned({ snapshotBytes: bytes.byteLength });
2101
+ const endImport = perfStart('open: loro import (presigned snapshot)');
2102
+ doc = Document.openFromSnapshot(bytes, optionsWithId);
2103
+ endImport({ snapshotBytes: bytes.byteLength });
2104
+ }
2105
+ else {
2106
+ throw snapshotKindTooNewError();
2107
+ }
2108
+ // Apply tail ops in seq order. The server delivers them sorted; we sort
2109
+ // defensively (Loro dedupes by lamport, so out-of-order is safe) rather
2110
+ // than let a misbehaving server produce divergent state.
2111
+ const endTail = perfStart('open: apply tail ops');
2112
+ const tail = [...body.tail_ops].sort((a, b) => a.seq - b.seq);
2113
+ for (const entry of tail) {
2114
+ doc.applyRemoteOp(base64ToBytes(entry.op_blob_b64));
2115
+ }
2116
+ endTail({ count: tail.length });
2117
+ // `doc.id` is non-null at runtime (stamped above); the cast closes the type
2118
+ // gap without weakening the class's `string | null` signature.
2119
+ return doc;
2120
+ }
2121
+ //# sourceMappingURL=open-document.js.map