@brass-build/client 0.4.1 → 0.5.0

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