@brass-build/client 0.4.0 → 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 (327) hide show
  1. package/AGENTS.md +142 -41
  2. package/CHANGELOG.md +93 -4
  3. package/README.md +666 -179
  4. package/dist/acting-organization.d.ts.map +1 -1
  5. package/dist/acting-organization.js +6 -11
  6. package/dist/acting-organization.js.map +1 -1
  7. package/dist/api-fetch.d.ts +8 -0
  8. package/dist/api-fetch.d.ts.map +1 -1
  9. package/dist/api-fetch.js +35 -8
  10. package/dist/api-fetch.js.map +1 -1
  11. package/dist/batched-get.d.ts +11 -0
  12. package/dist/batched-get.d.ts.map +1 -0
  13. package/dist/batched-get.js +92 -0
  14. package/dist/batched-get.js.map +1 -0
  15. package/dist/call-service.d.ts +4 -0
  16. package/dist/call-service.d.ts.map +1 -1
  17. package/dist/call-service.js +66 -3
  18. package/dist/call-service.js.map +1 -1
  19. package/dist/client.d.ts +51 -2
  20. package/dist/client.d.ts.map +1 -1
  21. package/dist/client.js +982 -124
  22. package/dist/client.js.map +1 -1
  23. package/dist/document-checkpoints.d.ts +25 -2
  24. package/dist/document-checkpoints.d.ts.map +1 -1
  25. package/dist/document-checkpoints.js +258 -61
  26. package/dist/document-checkpoints.js.map +1 -1
  27. package/dist/document-commands.d.ts.map +1 -1
  28. package/dist/document-commands.js +5 -3
  29. package/dist/document-commands.js.map +1 -1
  30. package/dist/document-contents.d.ts +10 -0
  31. package/dist/document-contents.d.ts.map +1 -1
  32. package/dist/document-contents.js +23 -1
  33. package/dist/document-contents.js.map +1 -1
  34. package/dist/document-set-subscription.d.ts +56 -0
  35. package/dist/document-set-subscription.d.ts.map +1 -0
  36. package/dist/document-set-subscription.js +240 -0
  37. package/dist/document-set-subscription.js.map +1 -0
  38. package/dist/document-signals.d.ts +6 -0
  39. package/dist/document-signals.d.ts.map +1 -1
  40. package/dist/document-signals.js +31 -1
  41. package/dist/document-signals.js.map +1 -1
  42. package/dist/document-streams.d.ts +36 -2
  43. package/dist/document-streams.d.ts.map +1 -1
  44. package/dist/document-streams.js +69 -18
  45. package/dist/document-streams.js.map +1 -1
  46. package/dist/document-thumbnail-capture.d.ts +12 -2
  47. package/dist/document-thumbnail-capture.d.ts.map +1 -1
  48. package/dist/document-thumbnail-capture.js +103 -27
  49. package/dist/document-thumbnail-capture.js.map +1 -1
  50. package/dist/documents-namespace.d.ts +92 -10
  51. package/dist/documents-namespace.d.ts.map +1 -1
  52. package/dist/documents-namespace.js +285 -39
  53. package/dist/documents-namespace.js.map +1 -1
  54. package/dist/documents.d.ts +173 -8
  55. package/dist/documents.d.ts.map +1 -1
  56. package/dist/documents.js +1778 -310
  57. package/dist/documents.js.map +1 -1
  58. package/dist/errors.d.ts +3 -3
  59. package/dist/errors.d.ts.map +1 -1
  60. package/dist/errors.js.map +1 -1
  61. package/dist/fetch-retry.d.ts +1 -0
  62. package/dist/fetch-retry.d.ts.map +1 -1
  63. package/dist/fetch-retry.js +2 -0
  64. package/dist/fetch-retry.js.map +1 -1
  65. package/dist/file-choice.d.ts +2 -0
  66. package/dist/file-choice.d.ts.map +1 -0
  67. package/dist/file-choice.js +27 -0
  68. package/dist/file-choice.js.map +1 -0
  69. package/dist/guest-link.d.ts +18 -1
  70. package/dist/guest-link.d.ts.map +1 -1
  71. package/dist/guest-link.js +329 -1
  72. package/dist/guest-link.js.map +1 -1
  73. package/dist/handle-types.d.ts +67 -5
  74. package/dist/handle-types.d.ts.map +1 -1
  75. package/dist/handles.d.ts +15 -19
  76. package/dist/handles.d.ts.map +1 -1
  77. package/dist/handles.js +231 -76
  78. package/dist/handles.js.map +1 -1
  79. package/dist/hosted-ui.d.ts +61 -0
  80. package/dist/hosted-ui.d.ts.map +1 -1
  81. package/dist/hosted-ui.js +94 -42
  82. package/dist/hosted-ui.js.map +1 -1
  83. package/dist/import-contract.d.ts +74 -0
  84. package/dist/import-contract.d.ts.map +1 -0
  85. package/dist/import-contract.js +163 -0
  86. package/dist/import-contract.js.map +1 -0
  87. package/dist/import-guest.d.ts +9 -0
  88. package/dist/import-guest.d.ts.map +1 -0
  89. package/dist/import-guest.js +155 -0
  90. package/dist/import-guest.js.map +1 -0
  91. package/dist/import-report.d.ts +17 -0
  92. package/dist/import-report.d.ts.map +1 -0
  93. package/dist/import-report.js +7 -0
  94. package/dist/import-report.js.map +1 -0
  95. package/dist/importer-wait.d.ts +2 -0
  96. package/dist/importer-wait.d.ts.map +1 -0
  97. package/dist/importer-wait.js +5 -0
  98. package/dist/importer-wait.js.map +1 -0
  99. package/dist/importer.d.ts +18 -4
  100. package/dist/importer.d.ts.map +1 -1
  101. package/dist/importer.js +322 -105
  102. package/dist/importer.js.map +1 -1
  103. package/dist/in-development.d.ts +2 -0
  104. package/dist/in-development.d.ts.map +1 -0
  105. package/dist/in-development.js +10 -0
  106. package/dist/in-development.js.map +1 -0
  107. package/dist/index.d.ts +16 -8
  108. package/dist/index.d.ts.map +1 -1
  109. package/dist/index.js +24 -8
  110. package/dist/index.js.map +1 -1
  111. package/dist/init-keys.d.ts +1 -1
  112. package/dist/init-keys.d.ts.map +1 -1
  113. package/dist/init-keys.js +6 -1
  114. package/dist/init-keys.js.map +1 -1
  115. package/dist/input-documents.d.ts +67 -0
  116. package/dist/input-documents.d.ts.map +1 -0
  117. package/dist/input-documents.js +693 -0
  118. package/dist/input-documents.js.map +1 -0
  119. package/dist/inputs-status.d.ts +80 -0
  120. package/dist/inputs-status.d.ts.map +1 -0
  121. package/dist/inputs-status.js +460 -0
  122. package/dist/inputs-status.js.map +1 -0
  123. package/dist/inputs.d.ts +2 -0
  124. package/dist/inputs.d.ts.map +1 -0
  125. package/dist/inputs.js +793 -0
  126. package/dist/inputs.js.map +1 -0
  127. package/dist/internal.d.ts +6 -1
  128. package/dist/internal.d.ts.map +1 -1
  129. package/dist/internal.js +4 -1
  130. package/dist/internal.js.map +1 -1
  131. package/dist/join-content.d.ts +33 -0
  132. package/dist/join-content.d.ts.map +1 -0
  133. package/dist/join-content.js +114 -0
  134. package/dist/join-content.js.map +1 -0
  135. package/dist/join-stamp.d.ts +3 -0
  136. package/dist/join-stamp.d.ts.map +1 -0
  137. package/dist/join-stamp.js +12 -0
  138. package/dist/join-stamp.js.map +1 -0
  139. package/dist/manifest-drift.d.ts +9 -0
  140. package/dist/manifest-drift.d.ts.map +1 -0
  141. package/dist/manifest-drift.js +46 -0
  142. package/dist/manifest-drift.js.map +1 -0
  143. package/dist/open-document.d.ts +109 -2
  144. package/dist/open-document.d.ts.map +1 -1
  145. package/dist/open-document.js +1822 -811
  146. package/dist/open-document.js.map +1 -1
  147. package/dist/page-restore.d.ts +12 -0
  148. package/dist/page-restore.d.ts.map +1 -0
  149. package/dist/page-restore.js +23 -0
  150. package/dist/page-restore.js.map +1 -0
  151. package/dist/placement.d.ts +13 -0
  152. package/dist/placement.d.ts.map +1 -0
  153. package/dist/placement.js +34 -0
  154. package/dist/placement.js.map +1 -0
  155. package/dist/presence.d.ts +1 -0
  156. package/dist/presence.d.ts.map +1 -1
  157. package/dist/presence.js +16 -4
  158. package/dist/presence.js.map +1 -1
  159. package/dist/read-only-document.d.ts +87 -0
  160. package/dist/read-only-document.d.ts.map +1 -0
  161. package/dist/read-only-document.js +4 -0
  162. package/dist/read-only-document.js.map +1 -0
  163. package/dist/record-paging.d.ts +63 -6
  164. package/dist/record-paging.d.ts.map +1 -1
  165. package/dist/record-paging.js +122 -29
  166. package/dist/record-paging.js.map +1 -1
  167. package/dist/references.d.ts +40 -0
  168. package/dist/references.d.ts.map +1 -0
  169. package/dist/references.js +135 -0
  170. package/dist/references.js.map +1 -0
  171. package/dist/request-lane.d.ts +5 -0
  172. package/dist/request-lane.d.ts.map +1 -0
  173. package/dist/request-lane.js +48 -0
  174. package/dist/request-lane.js.map +1 -0
  175. package/dist/sealed-arrow.d.ts +170 -0
  176. package/dist/sealed-arrow.d.ts.map +1 -0
  177. package/dist/sealed-arrow.js +769 -0
  178. package/dist/sealed-arrow.js.map +1 -0
  179. package/dist/sealed-cache.d.ts +21 -0
  180. package/dist/sealed-cache.d.ts.map +1 -0
  181. package/dist/sealed-cache.js +247 -0
  182. package/dist/sealed-cache.js.map +1 -0
  183. package/dist/sealed-encode.d.ts +87 -0
  184. package/dist/sealed-encode.d.ts.map +1 -0
  185. package/dist/sealed-encode.js +876 -0
  186. package/dist/sealed-encode.js.map +1 -0
  187. package/dist/sealed-handles.d.ts +45 -0
  188. package/dist/sealed-handles.d.ts.map +1 -0
  189. package/dist/sealed-handles.js +408 -0
  190. package/dist/sealed-handles.js.map +1 -0
  191. package/dist/sealed-store.d.ts +242 -0
  192. package/dist/sealed-store.d.ts.map +1 -0
  193. package/dist/sealed-store.js +1155 -0
  194. package/dist/sealed-store.js.map +1 -0
  195. package/dist/serializer.d.ts +6 -15
  196. package/dist/serializer.d.ts.map +1 -1
  197. package/dist/session-toolbar.d.ts +20 -5
  198. package/dist/session-toolbar.d.ts.map +1 -1
  199. package/dist/session-toolbar.js +419 -67
  200. package/dist/session-toolbar.js.map +1 -1
  201. package/dist/session.d.ts +38 -1
  202. package/dist/session.d.ts.map +1 -1
  203. package/dist/session.js +562 -79
  204. package/dist/session.js.map +1 -1
  205. package/dist/sha256.d.ts +3 -0
  206. package/dist/sha256.d.ts.map +1 -0
  207. package/dist/sha256.js +74 -0
  208. package/dist/sha256.js.map +1 -0
  209. package/dist/stable-handles.d.ts +19 -0
  210. package/dist/stable-handles.d.ts.map +1 -0
  211. package/dist/stable-handles.js +193 -0
  212. package/dist/stable-handles.js.map +1 -0
  213. package/dist/staged-seal.d.ts +2 -0
  214. package/dist/staged-seal.d.ts.map +1 -0
  215. package/dist/staged-seal.js +76 -0
  216. package/dist/staged-seal.js.map +1 -0
  217. package/dist/subscriber-delivery.d.ts +8 -0
  218. package/dist/subscriber-delivery.d.ts.map +1 -0
  219. package/dist/subscriber-delivery.js +31 -0
  220. package/dist/subscriber-delivery.js.map +1 -0
  221. package/dist/tail-op-import.d.ts +10 -0
  222. package/dist/tail-op-import.d.ts.map +1 -0
  223. package/dist/tail-op-import.js +33 -0
  224. package/dist/tail-op-import.js.map +1 -0
  225. package/dist/topic-lifecycle.d.ts +6 -0
  226. package/dist/topic-lifecycle.d.ts.map +1 -1
  227. package/dist/topic-lifecycle.js +32 -2
  228. package/dist/topic-lifecycle.js.map +1 -1
  229. package/dist/trial-replace-dialog.d.ts +10 -0
  230. package/dist/trial-replace-dialog.d.ts.map +1 -0
  231. package/dist/trial-replace-dialog.js +153 -0
  232. package/dist/trial-replace-dialog.js.map +1 -0
  233. package/dist/types.d.ts +8 -1
  234. package/dist/types.d.ts.map +1 -1
  235. package/dist/undo.d.ts +60 -5
  236. package/dist/undo.d.ts.map +1 -1
  237. package/dist/undo.js +344 -75
  238. package/dist/undo.js.map +1 -1
  239. package/dist/version-bar.d.ts +1 -0
  240. package/dist/version-bar.d.ts.map +1 -1
  241. package/dist/version-bar.js +2 -1
  242. package/dist/version-bar.js.map +1 -1
  243. package/dist/version.d.ts +2 -2
  244. package/dist/version.js +1 -1
  245. package/dist/ws-client.d.ts +21 -0
  246. package/dist/ws-client.d.ts.map +1 -1
  247. package/dist/ws-client.js +192 -8
  248. package/dist/ws-client.js.map +1 -1
  249. package/dist/ws-frame-limit.d.ts +1 -0
  250. package/dist/ws-frame-limit.d.ts.map +1 -1
  251. package/dist/ws-frame-limit.js.map +1 -1
  252. package/docs/app-manifest.md +121 -17
  253. package/docs/custom-chrome.md +24 -16
  254. package/docs/hosted-ui-advanced.md +38 -6
  255. package/docs/imported-documents.md +104 -51
  256. package/docs/presence.md +16 -4
  257. package/docs/schemas.md +8 -5
  258. package/docs/services.md +6 -6
  259. package/docs/streams.md +139 -150
  260. package/docs/text-fields.md +26 -19
  261. package/package.json +5 -2
  262. package/src/acting-organization.ts +6 -12
  263. package/src/api-fetch.ts +53 -3
  264. package/src/auth-flow.ts +1 -1
  265. package/src/batched-get.ts +117 -0
  266. package/src/call-service.ts +78 -3
  267. package/src/client.ts +1092 -121
  268. package/src/document-checkpoints.ts +307 -65
  269. package/src/document-commands.ts +5 -5
  270. package/src/document-contents.ts +27 -1
  271. package/src/document-set-subscription.ts +274 -0
  272. package/src/document-signals.ts +47 -5
  273. package/src/document-streams.ts +85 -16
  274. package/src/document-thumbnail-capture.ts +118 -32
  275. package/src/documents-namespace.ts +539 -84
  276. package/src/documents.ts +2017 -348
  277. package/src/errors.ts +17 -6
  278. package/src/fetch-retry.ts +5 -0
  279. package/src/file-choice.ts +26 -0
  280. package/src/guest-link.ts +362 -1
  281. package/src/handle-types.ts +73 -47
  282. package/src/handles.ts +253 -83
  283. package/src/hosted-ui.ts +211 -50
  284. package/src/import-contract.ts +227 -0
  285. package/src/import-guest.ts +193 -0
  286. package/src/import-report.ts +24 -0
  287. package/src/importer-wait.ts +4 -0
  288. package/src/importer.ts +386 -131
  289. package/src/in-development.ts +10 -0
  290. package/src/index.ts +76 -16
  291. package/src/init-keys.ts +6 -1
  292. package/src/input-documents.ts +819 -0
  293. package/src/inputs-status.ts +598 -0
  294. package/src/inputs.ts +1090 -0
  295. package/src/internal.ts +7 -0
  296. package/src/join-content.ts +135 -0
  297. package/src/join-stamp.ts +12 -0
  298. package/src/manifest-drift.ts +54 -0
  299. package/src/open-document.ts +2346 -930
  300. package/src/page-restore.ts +38 -0
  301. package/src/placement.ts +54 -0
  302. package/src/presence.ts +21 -5
  303. package/src/read-only-document.ts +133 -0
  304. package/src/record-paging.ts +147 -25
  305. package/src/references.ts +207 -0
  306. package/src/request-lane.ts +59 -0
  307. package/src/sealed-arrow.ts +843 -0
  308. package/src/sealed-cache.ts +267 -0
  309. package/src/sealed-encode.ts +1015 -0
  310. package/src/sealed-handles.ts +486 -0
  311. package/src/sealed-store.ts +1330 -0
  312. package/src/serializer.ts +8 -20
  313. package/src/session-toolbar.ts +463 -72
  314. package/src/session.ts +740 -99
  315. package/src/sha256.ts +65 -0
  316. package/src/stable-handles.ts +235 -0
  317. package/src/staged-seal.ts +90 -0
  318. package/src/subscriber-delivery.ts +37 -0
  319. package/src/tail-op-import.ts +30 -0
  320. package/src/topic-lifecycle.ts +38 -2
  321. package/src/trial-replace-dialog.ts +163 -0
  322. package/src/types.ts +6 -6
  323. package/src/undo.ts +386 -73
  324. package/src/version-bar.ts +5 -1
  325. package/src/version.ts +1 -1
  326. package/src/ws-client.ts +208 -16
  327. package/src/ws-frame-limit.ts +1 -0
@@ -4,6 +4,7 @@
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
6
 
7
+ import { awaitSeal, type SealStatus } from './staged-seal.js';
7
8
  import {
8
9
  DEFAULT_STREAM_NAME,
9
10
  Document,
@@ -15,15 +16,24 @@ import {
15
16
  type DocumentDriveTrackingState,
16
17
  type DriveMirrorDirection,
17
18
  type HostedUiProvider,
18
- type ReadOnlyReason,
19
+ type ForcedReadOnlyReason,
19
20
  type ResyncPushOutcome,
21
+ type CheckoutFetcher,
20
22
  } from './documents.js';
21
23
  import { ensureWasmReady } from './wasm-runtime.js';
22
24
  import { createPresenceController } from './presence.js';
23
25
  import type { WsTopicCallback } from './ws-client.js';
24
26
  import { applyAppAssertionHeader, applyClientVersionHeader } from './api-headers.js';
25
27
  import { brassErrorFromBody } from './api-fetch.js';
26
- import { fetchWithRetry, RETRY_IDEMPOTENT_WRITE } from './fetch-retry.js';
28
+ import type { SelectDocumentResult } from './hosted-ui.js';
29
+ import type { ImportReport } from './import-report.js';
30
+ import type { ImportClaimResponse, ImportFailedResponse } from './importer.js';
31
+ import { randomToken, type DocumentSourceBlob } from './documents-namespace.js';
32
+ import {
33
+ fetchWithRetry,
34
+ RETRY_IDEMPOTENT_WRITE,
35
+ RETRY_WRITE_503,
36
+ } from './fetch-retry.js';
27
37
  import {
28
38
  deadlineForUpload,
29
39
  fetchWithDeadline,
@@ -41,8 +51,21 @@ type ClientReportReason =
41
51
  | 'divergence_healed';
42
52
  import { BrassError } from './errors.js';
43
53
  import { perfStart } from './perf.js';
54
+ import { SealedContent } from './sealed-store.js';
44
55
  import { joinCoverageFloor, snapshotSeqOf } from './join-coverage.js';
56
+ import { parseInputStatusNudge } from './inputs.js';
57
+ import { TEMPORARY_INPUTS_RUNTIME, type InputsStatusRuntime } from './inputs-status.js';
58
+ import { setJoinStamp } from './join-stamp.js';
59
+ import { storeClaimSecret } from './guest-link.js';
60
+ import {
61
+ base64ToBytes,
62
+ decodeJoinContent,
63
+ joinEncoding,
64
+ type ContentFetch,
65
+ type JoinContent,
66
+ } from './join-content.js';
45
67
  import { declaredTailBytes, resolveTailOps, type WireTailOp } from './tail-ops.js';
68
+ import { SEALED_ACCEPT } from './sealed-store.js';
46
69
  import {
47
70
  opBlobExceedsDurableLimit,
48
71
  opBlobNeedsStaging,
@@ -66,8 +89,7 @@ export interface OpenDocumentByIdOptions {
66
89
  * join a document this app's declared schema cannot read, so that a
67
90
  * bookmark or a reload does not silently open one; this says the refusal
68
91
  * has already been answered. Set by the client after a person answers its
69
- * dialog, or for every open when the app passes
70
- * `openIncompatibleDocuments` at init.
92
+ * dialog.
71
93
  * @internal
72
94
  */
73
95
  acceptSchemaConflict?: boolean;
@@ -93,6 +115,19 @@ export interface OpenDocumentByIdOptions {
93
115
  * @internal
94
116
  */
95
117
  version?: number;
118
+ /**
119
+ * Open a live copy with nothing on it that writes: it follows other
120
+ * people's edits, and `mutate` throws. `false` opens to write whatever
121
+ * `documentOptions.readOnly` says. Unset, `documentOptions.readOnly`
122
+ * decides.
123
+ */
124
+ readOnly?: boolean;
125
+ /**
126
+ * Open a read-only copy that changes only when the app asks: it reports
127
+ * that the document moved (`onChanged`) and holds what it loaded
128
+ * until `reload()`. Implies `readOnly`.
129
+ */
130
+ frozen?: boolean;
96
131
  /**
97
132
  * SDK-internal load-progress sink. The session threads it through to
98
133
  * surface a progress state before the Document exists; apps read that via
@@ -103,7 +138,12 @@ export interface OpenDocumentByIdOptions {
103
138
  * the wire, because the two are paced by different things and a document
104
139
  * can spend the whole of the first in what looks like silence: `tail` is
105
140
  * the spilled ops the join fetches, `snapshot` the snapshot itself, and
106
- * absent means `snapshot`.
141
+ * absent means `snapshot`. `preparing` reports no bytes: the platform is
142
+ * making the document editable, and the open is waiting for it. `importing`
143
+ * reports no bytes either: a reader's open is waiting for work that replaces
144
+ * the content (a new version of the file being read), and `message` is the
145
+ * platform's own words for it. Answering `false` to either says nobody wants
146
+ * this open any more, and the open rejects rather than going on waiting.
107
147
  *
108
148
  * `total` is the whole download in both phases. The tail declares it from
109
149
  * each spilled op's own size plus the snapshot still to come, so the
@@ -122,8 +162,9 @@ export interface OpenDocumentByIdOptions {
122
162
  export type LoadProgress = (
123
163
  loaded: number,
124
164
  total: number | null,
125
- phase?: 'snapshot' | 'tail',
126
- ) => void;
165
+ phase?: 'snapshot' | 'tail' | 'preparing' | 'importing',
166
+ message?: string,
167
+ ) => void | boolean;
127
168
 
128
169
  // `Brass.createDocument(options?)` creates a new document and returns it
129
170
  // fully editable. `name` defaults to "Untitled". Placement is resolved from
@@ -166,6 +207,136 @@ export type CreateDocumentResult =
166
207
  | { kind: 'needs-destination' }
167
208
  | { kind: 'needs-organization' };
168
209
 
210
+ // What `POST /anonymous-documents` minted: the document, the editor link
211
+ // token that is its only credential, when the platform removes it, and the
212
+ // secret that claims it on a sign-in in this browser.
213
+ export interface MintedAnonymousDocument {
214
+ docId: string;
215
+ linkToken: string;
216
+ expiresAt: Date;
217
+ claimSecret?: string;
218
+ }
219
+
220
+ // The file types a visitor can start a document from, as an `<input
221
+ // accept>` value (`''` when there are none), and the largest file taken.
222
+ export interface AnonymousUploadAccept {
223
+ accept: string;
224
+ maxBytes: number;
225
+ }
226
+
227
+ // The app's upload-types read: whether the app starts new documents at all,
228
+ // and either the file types a visitor can start one from or the app's
229
+ // refusal of signed-out use.
230
+ export type AnonymousUploadTypes =
231
+ | { offersNew: boolean; accept: AnonymousUploadAccept }
232
+ | { offersNew: boolean; refusal: BrassError };
233
+
234
+ export function acceptFromUploadTypes(body: unknown): AnonymousUploadAccept {
235
+ const wire = body as
236
+ | {
237
+ import_types?: { extensions?: unknown; content_types?: unknown }[];
238
+ max_bytes?: unknown;
239
+ }
240
+ | undefined;
241
+ const tokens = new Set<string>();
242
+ for (const type of Array.isArray(wire?.import_types) ? wire.import_types : []) {
243
+ for (const list of [type.extensions, type.content_types]) {
244
+ if (!Array.isArray(list)) continue;
245
+ for (const token of list) if (typeof token === 'string' && token !== '') tokens.add(token);
246
+ }
247
+ }
248
+ return {
249
+ accept: [...tokens].join(','),
250
+ maxBytes: typeof wire?.max_bytes === 'number' ? wire.max_bytes : Number.POSITIVE_INFINITY,
251
+ };
252
+ }
253
+
254
+ function parseMintedAnonymousDocument(parsed: unknown): MintedAnonymousDocument {
255
+ const minted = parsed as {
256
+ doc_id?: unknown;
257
+ link_token?: unknown;
258
+ claim_secret?: unknown;
259
+ expires_at?: unknown;
260
+ } | undefined;
261
+ const expiresAt =
262
+ typeof minted?.expires_at === 'string' ? new Date(minted.expires_at) : null;
263
+ if (
264
+ typeof minted?.doc_id !== 'string' ||
265
+ typeof minted.link_token !== 'string' ||
266
+ expiresAt === null ||
267
+ Number.isNaN(expiresAt.getTime())
268
+ ) {
269
+ throw new BrassError('Malformed anonymous document response', { status: 500 });
270
+ }
271
+ return {
272
+ docId: minted.doc_id,
273
+ linkToken: minted.link_token,
274
+ expiresAt,
275
+ ...(typeof minted.claim_secret === 'string' ? { claimSecret: minted.claim_secret } : {}),
276
+ };
277
+ }
278
+
279
+ async function readJsonBody(response: Response): Promise<unknown> {
280
+ const text = await response.text();
281
+ try {
282
+ return text ? JSON.parse(text) : undefined;
283
+ } catch {
284
+ return undefined;
285
+ }
286
+ }
287
+
288
+ // PUT a file to a presigned URL. Through XHR where there is one, which is
289
+ // the browser API that reports upload progress.
290
+ function putFile(
291
+ url: string,
292
+ file: File,
293
+ onProgress?: (loaded: number, total: number) => void,
294
+ ): Promise<void> {
295
+ const failed = (status: number) =>
296
+ new BrassError('The file could not be uploaded.', { status: status || 503 });
297
+ if (typeof XMLHttpRequest === 'undefined') {
298
+ return fetch(url, { method: 'PUT', body: file }).then((r) => {
299
+ if (!r.ok) throw failed(r.status);
300
+ });
301
+ }
302
+ return new Promise((resolve, reject) => {
303
+ const xhr = new XMLHttpRequest();
304
+ xhr.open('PUT', url);
305
+ xhr.upload.onprogress = (e) => {
306
+ if (e.lengthComputable) onProgress?.(e.loaded, e.total);
307
+ };
308
+ xhr.onload = () => (xhr.status >= 200 && xhr.status < 300 ? resolve() : reject(failed(xhr.status)));
309
+ xhr.onerror = () => reject(failed(0));
310
+ xhr.send(file);
311
+ });
312
+ }
313
+
314
+ export interface GuestImportCalls {
315
+ claimImport(): Promise<ImportClaimResponse>;
316
+ stageImport(bundle: Uint8Array): Promise<string>;
317
+ completeImport(report: ImportReport | undefined, uploadKey: string): Promise<unknown>;
318
+ failImport(message: string): Promise<ImportFailedResponse>;
319
+ sourceBlob(): Promise<DocumentSourceBlob>;
320
+ }
321
+
322
+ export interface ClaimAnonymousDocumentArgs {
323
+ linkToken: string;
324
+ claimSecret: string;
325
+ // The hosted picker's opaque destination, relayed verbatim like a create's.
326
+ destination?: unknown;
327
+ // The name the person settled on in the destination dialog.
328
+ name?: string;
329
+ }
330
+
331
+ // The claim's outcome, classified the way a create's is so the same
332
+ // placement loop runs over it. `record` is the claimed document as the
333
+ // server answered it, in the picker's shape, so the claim can be stashed as
334
+ // a selection; null when the answer did not carry the fields.
335
+ export type ClaimDocumentResult =
336
+ | { kind: 'claimed'; record: SelectDocumentResult | null }
337
+ | { kind: 'needs-destination' }
338
+ | { kind: 'needs-organization' };
339
+
169
340
  // Wire-protocol-pinned response shape from
170
341
  // `GET /documents/{docId}?include=snapshot`.
171
342
  export interface DocumentSnapshotResponse {
@@ -180,9 +351,13 @@ export interface DocumentSnapshotResponse {
180
351
  stream_branch_id?: string;
181
352
  // `inline` carries base64 bytes; `presigned` is a signed URL the
182
353
  // client GETs directly; `null` means no snapshot yet (empty new doc).
354
+ // `sealed` is SEALED CONTENT rather than a snapshot: the document's
355
+ // records as they were produced, which this reader decodes with no CRDT
356
+ // engine. Always a URL, never inline.
183
357
  snapshot:
184
358
  | { kind: 'inline'; data_b64: string }
185
359
  | { kind: 'presigned'; url: string }
360
+ | { kind: 'sealed'; url: string }
186
361
  | null;
187
362
  // Tail ops since the last snapshot, seq ascending, each a base64 Loro op
188
363
  // blob. Applied in order; `applyRemoteOp` is idempotent.
@@ -202,6 +377,14 @@ export interface DocumentSnapshotResponse {
202
377
  // ground truth). The handshake runs off the `doc_init` frame's copy; this
203
378
  // is carried for wire parallelism. Absent until the branch is compacted.
204
379
  snapshot_version_vector?: string;
380
+ // The seq the line's current import was sealed at (`importSeqMoved`).
381
+ import_seq?: number;
382
+ // True while the document is its stored file and no import has run on it:
383
+ // an importer claiming it will replace this (empty) content, so a client
384
+ // with one holds the document before the claim answers.
385
+ awaits_import?: boolean;
386
+ // Why `can_edit` is false when the grant itself would allow writing.
387
+ read_only_reason?: string;
205
388
  // Whether the caller may write to this document, as the server's own
206
389
  // write gate answers it. `false` opens the Document read-only, so
207
390
  // `doc.readOnly` covers a view-only grant at bind time rather than
@@ -243,6 +426,7 @@ export interface DrivePublishTargetWire {
243
426
  source_unreadable?: unknown;
244
427
  publishing?: unknown;
245
428
  publish_failed?: unknown;
429
+ unpublishable?: unknown;
246
430
  }
247
431
 
248
432
  // Wire shape of the `drive` block carried on the join response and the
@@ -266,6 +450,9 @@ export interface DocumentDriveSummaryWire {
266
450
  // Optional: a frame from an older server omits it, treated as not
267
451
  // publishing (the `canPush` affordance then behaves as before).
268
452
  publishing?: boolean;
453
+ // The server's publish decision: something a publish would change now.
454
+ publishable?: boolean;
455
+ keeps_prior_versions?: boolean;
269
456
  source_unreadable?: string;
270
457
  }
271
458
 
@@ -277,9 +464,12 @@ export interface DocumentAtVersionResponse {
277
464
  effective_version: number;
278
465
  requested_version: number;
279
466
  branch_id: string;
467
+ // `sealed` when the version is the head of a document still stored as a
468
+ // record set (see `DocumentSnapshotResponse`).
280
469
  snapshot:
281
470
  | { kind: 'inline'; data_b64: string }
282
471
  | { kind: 'presigned'; url: string }
472
+ | { kind: 'sealed'; url: string }
283
473
  | null;
284
474
  tail_ops: Array<{ seq: number; op_blob_b64: string }>;
285
475
  current_seq: number;
@@ -294,6 +484,8 @@ export interface OpenDocumentWsClient {
294
484
  options?: {
295
485
  ownsFrameRetry?: boolean;
296
486
  onEnded?: (info: { reason: string }) => void;
487
+ onSubscribed?: (info: { resubscribed: boolean; currentSeq?: number }) => void;
488
+ notice?: boolean;
297
489
  },
298
490
  ): () => void;
299
491
  send(frame: unknown): void;
@@ -306,6 +498,14 @@ export interface OpenDocumentWsClient {
306
498
  // Whether a socket is open right now. Absent reads as connected, which is
307
499
  // what a transport-only test stub is.
308
500
  readonly connected?: boolean;
501
+ // Whether any socket has opened in this page load. Absent reads as opened,
502
+ // for the same reason.
503
+ readonly everOpened?: boolean;
504
+ }
505
+
506
+ export interface LinkCredential {
507
+ accessToken: string;
508
+ appAssertion: string;
309
509
  }
310
510
 
311
511
  export interface OpenDocumentFactoryOptions {
@@ -315,11 +515,24 @@ export interface OpenDocumentFactoryOptions {
315
515
  // Forwarded as `X-Brass-App-Assertion` on the Brass-API fetches only; the
316
516
  // presigned snapshot downloads carry no Brass headers.
317
517
  getAppAssertion?: () => Promise<string | null>;
518
+ // The person's own credential for a link open, held when they signed in on
519
+ // a link page. An app that opens public links only for people signed in
520
+ // to it refuses the open without one.
521
+ getLinkCredential?: () => LinkCredential | null | Promise<LinkCredential | null>;
522
+ // How a link open proceeds when the server answers that the signed-in
523
+ // person opens the document as themselves. Defaults to this factory's own
524
+ // `open`.
525
+ openAsMember?: (docId: string, options?: OpenDocumentByIdOptions) => Promise<OpenedDocument>;
318
526
  // The shared WS client. When provided (production), the factory wires a
319
527
  // doc-topic subscription + local-op send hook so the Document is editable.
320
528
  // When absent (tests / snapshot reads), the local-op hook is inert: state
321
529
  // still mutates locally but nothing leaves the process.
322
530
  wsClient?: OpenDocumentWsClient;
531
+ // The socket a document opened through `token` uses. A link admits a socket
532
+ // to its one document only, so a link's socket carries nothing else and the
533
+ // page's own socket keeps the signed-in person's credential. Falls back to
534
+ // `wsClient`.
535
+ linkWsClient?: (token: string) => OpenDocumentWsClient | undefined;
323
536
  // ID minter, exposed for deterministic test clientOpIds. Defaults to
324
537
  // `crypto.randomUUID()`, falling back to a 32-hex Math.random() string.
325
538
  // Correlation is SDK-side only (the server never reads the value), so
@@ -337,10 +550,12 @@ export interface OpenDocumentFactoryOptions {
337
550
  image: Blob,
338
551
  width: number,
339
552
  height: number,
553
+ version?: number,
340
554
  ) => Promise<unknown>;
341
555
  // Routes `doc.createCheckpoint(...)`. When absent (tests), it throws.
342
556
  checkpointCreator?: (
343
557
  doc: Document,
558
+ anchor: import('./document-checkpoints.js').CheckpointAnchor,
344
559
  ) => Promise<import('./documents-namespace.js').DocumentCheckpoint>;
345
560
  // Routes `doc.pushToDrive(...)`. When absent (tests / offline), it throws.
346
561
  drivePusher?: (
@@ -352,6 +567,18 @@ export interface OpenDocumentFactoryOptions {
352
567
  sourceBlobFetcher?: (
353
568
  doc: Document,
354
569
  ) => Promise<import('./documents-namespace.js').DocumentSourceBlob>;
570
+ // Routes `subscribeInputStatus` / `inputStatus` for a document or one of
571
+ // its stream lines. When absent (tests / offline), those methods throw.
572
+ inputsRuntime?: (docId: string) => import('./inputs-status.js').InputsStatusRuntime;
573
+ // Posts a key's provisional entry once the op carrying a `dependsOn` /
574
+ // `markRead` change is acknowledged. When absent, nothing is posted and the
575
+ // platform reads the key from the document when it compacts.
576
+ /** @internal */
577
+ provisionalInputsPoster?: (
578
+ docId: string,
579
+ post: import('./inputs.js').ProvisionalInputsPost,
580
+ opts?: { superseded?: () => boolean },
581
+ ) => Promise<import('./inputs.js').ProvisionalInputsAnswer | null>;
355
582
  // Streams manifest plumbing (`GET` / `POST /documents/{docId}/streams`).
356
583
  // When supplied, every returned Document exposes `doc.stream(name)` /
357
584
  // `doc.stream(name)` / `doc.listStreams()`; the factory owns the
@@ -409,6 +636,16 @@ const WORK_READY_STATUS: DocumentWorkStatus = {
409
636
  blocking: false,
410
637
  };
411
638
 
639
+ // How long an edit waits on a client that has never opened a socket before
640
+ // the document tells the reader it is not saved. Timed from the first waiting
641
+ // edit rather than counted in failed handshakes, because a network that
642
+ // swallows the upgrade can hold one attempt open with no failure to count.
643
+ // The socket starts connecting when the document opens, so by the time this
644
+ // expires it has had at least this long, which is several times a slow
645
+ // mobile handshake, and a network that refuses upgrades outright has failed
646
+ // four or five of them under the reconnect backoff (1s, 2s, 4s).
647
+ export const NEVER_OPENED_NOTICE_MS = 10_000;
648
+
412
649
  const CONTENT_PULL_ATTEMPTS = 5;
413
650
  const CONTENT_PULL_BACKOFF_MS = 1000;
414
651
 
@@ -499,6 +736,35 @@ export interface DocumentFactory {
499
736
  createDocumentId: (args: CreateDocumentArgs) => Promise<CreateDocumentResult>;
500
737
  // Read-only guest open from a public-link token (no auth, no live sub).
501
738
  openByLink: OpenDocumentByLinkFn;
739
+ // Mint a document with no account: `POST /anonymous-documents`, no
740
+ // Authorization header. The returned link token opens it through
741
+ // `openByLink` read-write until it expires.
742
+ mintAnonymousDocument: (args: {
743
+ name?: string;
744
+ }) => Promise<MintedAnonymousDocument>;
745
+ // Whether this app starts new documents, and the file types a visitor
746
+ // without an account can start one from:
747
+ // `GET /anonymous-documents/upload-types`.
748
+ anonymousUploadTypes: () => Promise<AnonymousUploadTypes>;
749
+ // Mint a document with no account from a file: stage it through a
750
+ // presigned PUT, then `POST /anonymous-documents/from-upload`. The file's
751
+ // import runs after this resolves, and the open reports it as pending.
752
+ // The app-run import calls for a document a visitor started from a file,
753
+ // through the member import routes with its edit link as the credential.
754
+ guestImports: (token: string, docId: string) => GuestImportCalls;
755
+ mintAnonymousDocumentFromFile: (args: {
756
+ file: File;
757
+ name?: string;
758
+ onUploadProgress?: (loaded: number, total: number) => void;
759
+ }) => Promise<MintedAnonymousDocument>;
760
+ // Move a document minted without an account into the caller's
761
+ // organization: `POST /documents/{docId}/claim`. Classified like
762
+ // `createDocumentId` so the caller can place it through the same hosted
763
+ // surfaces.
764
+ claimAnonymousDocument: (
765
+ docId: string,
766
+ args: ClaimAnonymousDocumentArgs,
767
+ ) => Promise<ClaimDocumentResult>;
502
768
  }
503
769
 
504
770
  // Wire shapes on the `doc:` topic. Frame names match the server's exactly, so
@@ -592,6 +858,7 @@ interface DocInitFrame {
592
858
  // frontier and resync-push anything local holds beyond it. Absent on
593
859
  // never-compacted docs; the handshake is skipped (nothing to verify).
594
860
  snapshot_version_vector?: string;
861
+ import_seq?: number;
595
862
  }
596
863
 
597
864
  // Server-pushed "the document moved past you" prompt, naming the seq to
@@ -678,8 +945,7 @@ interface DocOpsDroppedFrame {
678
945
  }
679
946
 
680
947
  // Map the snake_case wire `drive` block to the public camelCase
681
- // `DocumentDriveStatus`. `canPush` is derived once here (unbroken, two-way,
682
- // and ahead/diverged) so apps don't re-derive it.
948
+ // `DocumentDriveStatus`. `canPush` is the server's `publishable`.
683
949
  function toDriveStatus(wire: DocumentDriveSummaryWire): DocumentDriveStatus {
684
950
  const state = wire.tracking_state;
685
951
  // Only an explicit `import_only` restricts publishing; a frame omitting the
@@ -691,22 +957,14 @@ function toDriveStatus(wire: DocumentDriveSummaryWire): DocumentDriveStatus {
691
957
  driveType: wire.drive_type,
692
958
  driveName: wire.drive_name,
693
959
  state,
694
- // `broken` gates the Publish affordance, so read it explicitly rather
695
- // than by truthiness: a frame that OMITS it (a future partial drive
696
- // update) must not be treated as unbroken and offered a push the server
697
- // would reject. Require an explicit `false` to enable, and an explicit
698
- // `true` to flag broken. Behaviour is unchanged for today's frames, which
699
- // always carry the boolean. Whether the file is written server-side or
700
- // from the app's own serializer is a REQUEST SHAPE the push path resolves
701
- // for itself, so it says nothing about whether this document can publish.
702
- canPush:
703
- wire.broken === false &&
704
- mirrorDirection === 'two_way' &&
705
- (state === 'ahead' || state === 'diverged'),
960
+ // Only an explicit `true` offers a publish, so a frame that omits the
961
+ // field never offers one the server would refuse.
962
+ canPush: wire.publishable === true,
706
963
  label: wire.tracking_label,
707
964
  detail: wire.tracking_detail,
708
965
  broken: wire.broken === true,
709
966
  pushReplacesRemote: wire.push_replaces_remote,
967
+ keepsPriorVersions: wire.keeps_prior_versions === true,
710
968
  mirrorDirection,
711
969
  ref: wire.tracking_ref,
712
970
  refUpdatedAt: wire.tracking_ref_updated_at,
@@ -738,6 +996,7 @@ function toPublishTarget(
738
996
  detail: typeof wire.detail === 'string' ? wire.detail : '',
739
997
  publishing: wire.publishing === true,
740
998
  publishFailed: wire.publish_failed === true,
999
+ unpublishable: wire.unpublishable === true,
741
1000
  };
742
1001
  }
743
1002
 
@@ -797,6 +1056,7 @@ function parseDocInitFrame(frame: unknown): DocInitFrame | null {
797
1056
  ...(typeof f.snapshot_version_vector === 'string'
798
1057
  ? { snapshot_version_vector: f.snapshot_version_vector }
799
1058
  : {}),
1059
+ ...(typeof f.import_seq === 'number' ? { import_seq: f.import_seq } : {}),
800
1060
  };
801
1061
  }
802
1062
 
@@ -861,7 +1121,15 @@ type OversizedOpResult =
861
1121
  // Set when the refusal was about the writer rather than the op, which
862
1122
  // is the same distinction the socket's `op_rejected` reasons draw:
863
1123
  // every later op meets it too, so the Document turns read-only.
864
- readOnly?: ReadOnlyReason;
1124
+ readOnly?: ForcedReadOnlyReason;
1125
+ // A new version's import replaced the line this op was built on: the
1126
+ // copy reopens rather than failing the op (`OP_REJECTED_MOVED`).
1127
+ moved?: boolean;
1128
+ // The document still holds only its uploaded file, and it takes no
1129
+ // edits until that file is imported (`OP_REJECTED_NOT_IMPORTED`).
1130
+ notImported?: boolean;
1131
+ // No transport this writer has can carry the op (a 413).
1132
+ tooLarge?: boolean;
865
1133
  };
866
1134
 
867
1135
  // Whether re-sending the same op is the right move. Two statuses refuse to
@@ -886,6 +1154,25 @@ function isCapacityRefusal(status: number, parsed: unknown): boolean {
886
1154
  return (parsed as { code?: unknown } | undefined | null)?.code === 'at_capacity';
887
1155
  }
888
1156
 
1157
+ // The refusal of an op built on a version of the line that a new version's
1158
+ // import has since replaced. It is about the copy rather than the op: no
1159
+ // resend or heal can place an op naming the replaced version's containers, so
1160
+ // the copy reopens, as it does on `doc_gone` `moved`.
1161
+ const OP_REJECTED_MOVED = 'moved';
1162
+
1163
+ // The document still holds only its uploaded file. Every op meets the same
1164
+ // refusal until the import lands, so a smaller or later push cannot place
1165
+ // the refused work and the heal is not armed: the copy locks and reloads.
1166
+ const OP_REJECTED_NOT_IMPORTED = 'not_imported';
1167
+
1168
+ function isNotImportedRefusal(parsed: unknown): boolean {
1169
+ return (parsed as { code?: unknown } | undefined | null)?.code === OP_REJECTED_NOT_IMPORTED;
1170
+ }
1171
+
1172
+ function isMovedRefusal(parsed: unknown): boolean {
1173
+ return (parsed as { code?: unknown } | undefined | null)?.code === OP_REJECTED_MOVED;
1174
+ }
1175
+
889
1176
  // A failure uploading an oversized op's blob to storage. It carries its own
890
1177
  // type because staging runs strictly BEFORE the op write, so what a replay
891
1178
  // means differs from a throw out of the write itself: nothing durable exists
@@ -931,7 +1218,7 @@ function opRejectedMessage(frame: OpRejectedFrame): string {
931
1218
  if (typeof frame.message === 'string' && frame.message !== '') {
932
1219
  return frame.message;
933
1220
  }
934
- return 'A change could not be saved and will not be retried. Reload the document to continue.';
1221
+ return 'A change could not be saved and will not be retried. Reload to continue.';
935
1222
  }
936
1223
 
937
1224
  // The one rejection reason that describes the DOCUMENT rather than the op:
@@ -961,14 +1248,14 @@ function isCapacityRejection(frame: OpRejectedFrame): boolean {
961
1248
  // object answers for the names it inherits, so a reason reading `constructor`
962
1249
  // or `toString` would resolve a function and lock the document, which is the
963
1250
  // opposite of what an unclassified refusal means.
964
- const READ_ONLY_REJECTION_REASONS = new Map<string, ReadOnlyReason>([
1251
+ const READ_ONLY_REJECTION_REASONS = new Map<string, ForcedReadOnlyReason>([
965
1252
  ['role_denied', 'grant'],
966
1253
  ['scope_denied', 'app'],
967
1254
  ]);
968
1255
 
969
1256
  function readOnlyReasonForRejection(
970
1257
  frame: OpRejectedFrame,
971
- ): ReadOnlyReason | null {
1258
+ ): ForcedReadOnlyReason | null {
972
1259
  if (typeof frame.reason !== 'string') return null;
973
1260
  return READ_ONLY_REJECTION_REASONS.get(frame.reason) ?? null;
974
1261
  }
@@ -1098,16 +1385,6 @@ function bytesToBase64(bytes: Uint8Array): string {
1098
1385
  return btoa(bin);
1099
1386
  }
1100
1387
 
1101
- // Uses `atob` (present in modern browsers, Node 20+, and jsdom) rather than
1102
- // Node-only `Buffer`, which would force a browser-bundle polyfill.
1103
- function base64ToBytes(b64: string): Uint8Array {
1104
- const bin = atob(b64);
1105
- const bytes = new Uint8Array(bin.length);
1106
- for (let i = 0; i < bin.length; i++) {
1107
- bytes[i] = bin.charCodeAt(i);
1108
- }
1109
- return bytes;
1110
- }
1111
1388
 
1112
1389
  function isSnapshotResponse(body: unknown): body is DocumentSnapshotResponse {
1113
1390
  if (typeof body !== 'object' || body === null) return false;
@@ -1124,16 +1401,171 @@ function isSnapshotResponse(body: unknown): body is DocumentSnapshotResponse {
1124
1401
  return true;
1125
1402
  }
1126
1403
 
1404
+ /**
1405
+ * The body a join answered with, or the error it is.
1406
+ *
1407
+ * Every open starts here: check the status, then check the shape. Both
1408
+ * failures are open-time and neither is recoverable by the caller, so they
1409
+ * throw rather than returning, and the shape check is what stops a response
1410
+ * the server changed out from under a pinned SDK being read as an empty
1411
+ * document. `describe` names the response in the message, since a reader
1412
+ * debugging one wants to know which route answered.
1413
+ */
1414
+ function unwrapJoinResponse<T>(
1415
+ result: { status: number; parsed: unknown; parseFailed: boolean },
1416
+ isShape: (body: unknown) => body is T,
1417
+ describe: string,
1418
+ ): T {
1419
+ const { status, parsed, parseFailed } = result;
1420
+ if (status < 200 || status >= 300) {
1421
+ throw brassErrorFromBody(status, parseFailed ? undefined : parsed);
1422
+ }
1423
+ if (!isShape(parsed)) {
1424
+ throw new BrassError(`Malformed document ${describe} response`, {
1425
+ status: 500,
1426
+ });
1427
+ }
1428
+ return parsed;
1429
+ }
1430
+
1127
1431
  // Fold the join's write verdict into the Document options, so a reader the
1128
1432
  // server will not accept writes from opens read-only instead of learning it
1129
1433
  // from the first refused op. Only ever ADDS the restriction: a caller that
1130
1434
  // asked for a read-only Document keeps it whatever the grant says.
1435
+ /** The longest one pause between joins of a converting document. The wait
1436
+ * itself has no bound, because a slow conversion is still a healthy one:
1437
+ * each join is answered with what is left of it, and a conversion that
1438
+ * cannot finish ends as `at_capacity`, which stops the wait. */
1439
+ const CONVERSION_MAX_DELAY_MS = 60_000;
1440
+ /** The shortest pause, so a server answering without holding the join is not
1441
+ * asked again in a tight loop. */
1442
+ const CONVERSION_MIN_DELAY_MS = 250;
1443
+ const CONVERSION_POLL_MIN_MS = 1_000;
1444
+ const CONVERSION_POLL_MAX_MS = 5_000;
1445
+ /** Joins in a row that may fail outright while a conversion is awaited. */
1446
+ const CONVERSION_JOIN_FAILURES = 5;
1447
+ // How often a reader's open waiting on an import reads the work status, as
1448
+ // the backstop for a terminal frame it did not hear.
1449
+ const IMPORT_WAIT_POLL_MIN_MS = 2_000;
1450
+ const IMPORT_WAIT_POLL_MAX_MS = 15_000;
1451
+ const OPEN_SUPERSEDED_MESSAGE = 'This open was replaced by another';
1452
+ // How long an open waits on the platform's work (a conversion, an import)
1453
+ // before it rejects. Above the longest the platform itself takes to end that
1454
+ // work either way: an import is two runs of the hydration worker (one retry)
1455
+ // before the platform marks it failed, about 32 minutes, and a conversion is
1456
+ // three compaction runs before it is marked full, about 18. A wait past this
1457
+ // is work the platform has stopped reporting on.
1458
+ // Exported for the fake-timer tests.
1459
+ export const OPEN_WAIT_CEILING_MS = 40 * 60 * 1000;
1460
+ const OPEN_WAIT_EXPIRED_MESSAGE = 'This is taking too long to open. Reload to try again.';
1461
+ const IMPORT_FAILED_MESSAGE = 'This file could not be imported';
1462
+ /** How long the server holds a writable join for a conversion, less a margin,
1463
+ * so a join sent this long before the conversion is expected is held until
1464
+ * it lands. */
1465
+ const CONVERSION_HOLD_MS = 10_000;
1466
+
1467
+ /** When to send the next held join, given how long the conversion is
1468
+ * expected to take from now. */
1469
+ function heldJoinDelay(retryAfterMs: number): number {
1470
+ return Math.min(
1471
+ Math.max(CONVERSION_MIN_DELAY_MS, retryAfterMs - CONVERSION_HOLD_MS),
1472
+ CONVERSION_MAX_DELAY_MS,
1473
+ );
1474
+ }
1475
+
1476
+ /** Whether a response is the join's retryable refusal for a conversion,
1477
+ * which names its own wait, so the caller paces it rather than a backoff. */
1478
+ async function namesConversionWait(response: Response): Promise<boolean> {
1479
+ if (response.status !== 503) return false;
1480
+ try {
1481
+ const body = (await response.clone().json()) as { retry_after_ms?: unknown };
1482
+ return typeof body.retry_after_ms === 'number';
1483
+ } catch {
1484
+ return false;
1485
+ }
1486
+ }
1487
+
1488
+ type JoinAnswer = { status: number; parsed: unknown; parseFailed: boolean };
1489
+
1490
+ /**
1491
+ * Work a join reports that the open waits out before it resolves: pending work
1492
+ * that locks readers, such as the platform importing the file or a new
1493
+ * version of it.
1494
+ */
1495
+ function pendingImportWork(
1496
+ join: JoinAnswer,
1497
+ ): { status: DocumentWorkStatus; topic: string } | null {
1498
+ if (join.status !== 200 || join.parseFailed) return null;
1499
+ const parsed = join.parsed as {
1500
+ doc_id?: unknown;
1501
+ branch_id?: unknown;
1502
+ status?: unknown;
1503
+ } | null;
1504
+ if (parsed === null || typeof parsed !== 'object') return null;
1505
+ if (typeof parsed.doc_id !== 'string' || typeof parsed.branch_id !== 'string') return null;
1506
+ const status = parseWorkStatus(parsed.status);
1507
+ if (status === null || !status.pending || status.blocking === false) return null;
1508
+ return { status, topic: `doc:${parsed.doc_id}:${parsed.branch_id}` };
1509
+ }
1510
+
1511
+ /**
1512
+ * The error an open rejects with when the file's import failed and the
1513
+ * document holds nothing from it, or `null`.
1514
+ */
1515
+ function failedImportOf(join: JoinAnswer): BrassError | null {
1516
+ if (join.status !== 200 || join.parseFailed) return null;
1517
+ const parsed = join.parsed as {
1518
+ current_seq?: unknown;
1519
+ awaits_import?: unknown;
1520
+ status?: unknown;
1521
+ } | null;
1522
+ if (parsed === null || typeof parsed !== 'object') return null;
1523
+ const status = parseWorkStatus(parsed.status);
1524
+ if (status === null || status.pending || status.blocking !== true) return null;
1525
+ // An import lands at seq 1 or later, so seq 0 is a document no import has
1526
+ // filled.
1527
+ const empty =
1528
+ parsed.awaits_import === true ||
1529
+ typeof parsed.current_seq !== 'number' ||
1530
+ parsed.current_seq <= 0;
1531
+ if (!empty) return null;
1532
+ return new BrassError(status.message !== '' ? status.message : IMPORT_FAILED_MESSAGE, {
1533
+ status: 0,
1534
+ });
1535
+ }
1536
+
1537
+ /** Why a writable join refused, or `null` for one that served or failed some
1538
+ * other way. */
1539
+ function joinRefusalOf(join: {
1540
+ status: number;
1541
+ parsed: unknown;
1542
+ }):
1543
+ | { kind: 'converting'; retryAfterMs?: number }
1544
+ | { kind: 'at_capacity'; error?: string }
1545
+ | null {
1546
+ const body =
1547
+ typeof join.parsed === 'object' && join.parsed !== null
1548
+ ? (join.parsed as { retry_after_ms?: unknown; code?: unknown; error?: unknown })
1549
+ : {};
1550
+ if (join.status === 503 && typeof body.retry_after_ms === 'number' && body.retry_after_ms >= 0) {
1551
+ return { kind: 'converting', retryAfterMs: body.retry_after_ms };
1552
+ }
1553
+ if (join.status === 507 && body.code === 'at_capacity') {
1554
+ return typeof body.error === 'string' ? { kind: 'at_capacity', error: body.error } : { kind: 'at_capacity' };
1555
+ }
1556
+ return null;
1557
+ }
1558
+
1131
1559
  function readOnlyForGrant(
1132
1560
  body: DocumentSnapshotResponse,
1133
1561
  options: DocumentOptions | undefined,
1134
1562
  ): DocumentOptions | undefined {
1135
1563
  if (body.can_edit !== false) return options;
1136
- return { ...options, readOnly: true, readOnlyReason: 'grant' };
1564
+ return {
1565
+ ...options,
1566
+ readOnly: true,
1567
+ readOnlyReason: body.read_only_reason === 'not_imported' ? 'not_imported' : 'grant',
1568
+ };
1137
1569
  }
1138
1570
 
1139
1571
  function isAtVersionResponse(
@@ -1166,11 +1598,156 @@ export function createOpenDocument(
1166
1598
  // fourth one from silently dropping it.
1167
1599
  const consented = new Set<string>();
1168
1600
 
1601
+ // What a document is opened with: the signed-in session, a share link, or
1602
+ // the edit link of a document started without an account. Every document
1603
+ // this factory returns is built by `materialiseAs`, which wires what the
1604
+ // credential reaches, so an open path cannot leave a capability out.
1605
+ type OpenCredential =
1606
+ | { kind: 'session' }
1607
+ | { kind: 'link'; token: string }
1608
+ | { kind: 'temporary'; token: string };
1609
+ const SESSION: OpenCredential = { kind: 'session' };
1610
+
1611
+ function inputsRuntimeFor(credential: OpenCredential, docId: string): InputsStatusRuntime | null {
1612
+ switch (credential.kind) {
1613
+ case 'session':
1614
+ return opts.inputsRuntime ? opts.inputsRuntime(docId) : null;
1615
+ case 'link':
1616
+ return {
1617
+ readKey: (key, query) =>
1618
+ getWithLink(
1619
+ credential.token,
1620
+ `/documents/${encodeURIComponent(docId)}/inputs/${encodeURIComponent(key)}`,
1621
+ query,
1622
+ ),
1623
+ };
1624
+ case 'temporary':
1625
+ return TEMPORARY_INPUTS_RUNTIME;
1626
+ }
1627
+ }
1628
+
1629
+ // The join, the op write and the work-status read, through whichever
1630
+ // credential the document was opened with.
1631
+ // `rejoin` marks a read for a document this page already has open, which a
1632
+ // link meters apart from opens.
1633
+ async function joinAs(
1634
+ credential: OpenCredential,
1635
+ docId: string,
1636
+ options: OpenDocumentByIdOptions | undefined,
1637
+ { rejoin = true }: { rejoin?: boolean } = {},
1638
+ ): Promise<JoinAnswer> {
1639
+ return credential.kind === 'session'
1640
+ ? fetchSnapshot(docId, options, await requireToken())
1641
+ : fetchLinkView(credential.token, options ?? {}, undefined, { rejoin });
1642
+ }
1643
+
1644
+ async function postOpAs(
1645
+ credential: OpenCredential,
1646
+ docId: string,
1647
+ lineId: string,
1648
+ opBlobB64: string,
1649
+ clientOpId: string,
1650
+ base: { base_import_seq?: number },
1651
+ ): Promise<{ status: number; parsed: unknown }> {
1652
+ if (credential.kind === 'session') {
1653
+ return postDocumentOp(docId, lineId, opBlobB64, clientOpId, await requireToken(), base);
1654
+ }
1655
+ return postLinkOp(credential.token, docId, lineId, opBlobB64, clientOpId, base);
1656
+ }
1657
+
1658
+ async function readStatusAs(credential: OpenCredential, docId: string): Promise<unknown> {
1659
+ const headers: Record<string, string> = {};
1660
+ let url: string;
1661
+ if (credential.kind === 'session') {
1662
+ headers['authorization'] = `Bearer ${await requireToken()}`;
1663
+ applyClientVersionHeader(headers);
1664
+ await applyAppAssertionHeader(headers, opts.getAppAssertion);
1665
+ url = `${baseUrl}/documents/${encodeURIComponent(docId)}`;
1666
+ } else {
1667
+ Object.assign(headers, await linkViewHeaders());
1668
+ url = `${baseUrl}/links/${encodeURIComponent(credential.token)}/view?app_id=${encodeURIComponent(opts.appId)}&rejoin=1`;
1669
+ }
1670
+ const response = await fetch(url, { method: 'GET', headers });
1671
+ if (!response.ok) {
1672
+ throw new BrassError(`document read returned ${response.status}`, { status: response.status });
1673
+ }
1674
+ return response.json();
1675
+ }
1676
+
1677
+ function wsClientFor(credential: OpenCredential): OpenDocumentWsClient | undefined {
1678
+ return credential.kind === 'session' ? opts.wsClient : (opts.linkWsClient?.(credential.token) ?? opts.wsClient);
1679
+ }
1680
+
1681
+ async function materialiseAs(
1682
+ credential: OpenCredential,
1683
+ ...args: Parameters<typeof materialise>
1684
+ ): Promise<OpenedDocument> {
1685
+ const doc = await materialise(...args);
1686
+ doc._setInputsRuntime(inputsRuntimeFor(credential, args[0].doc_id));
1687
+ return doc;
1688
+ }
1689
+
1690
+ // A join. One that will write and takes nothing else waits while the
1691
+ // platform converts the document: the join is refused with how long is
1692
+ // left, and asked again then, for as long as that takes. The open's
1693
+ // progress sink hears `preparing` on each wait, and its `false` ends the
1694
+ // wait with a rejection.
1169
1695
  async function fetchSnapshot(
1170
1696
  docId: string,
1171
1697
  options: OpenDocumentByIdOptions | undefined,
1172
1698
  token: string,
1173
- ): Promise<{ status: number; parsed: unknown; parseFailed: boolean }> {
1699
+ ): Promise<JoinAnswer> {
1700
+ if (opensReadOnly(options)) {
1701
+ return joinOnce(docId, options, token);
1702
+ }
1703
+ // The first join spends the token the caller holds; a re-join after a
1704
+ // long wait asks for a current one.
1705
+ let held: string | null = token;
1706
+ return waitWhileConverting(async () => {
1707
+ const t = held ?? (await requireToken());
1708
+ held = null;
1709
+ return joinOnce(docId, options, t);
1710
+ }, options?.onSnapshotProgress);
1711
+ }
1712
+
1713
+ async function waitWhileConverting(
1714
+ join: () => Promise<JoinAnswer>,
1715
+ progress: LoadProgress | undefined,
1716
+ ): Promise<JoinAnswer> {
1717
+ const deadline = Date.now() + OPEN_WAIT_CEILING_MS;
1718
+ let answer = await join();
1719
+ let failures = 0;
1720
+ for (;;) {
1721
+ const refused = joinRefusalOf(answer);
1722
+ if (refused?.kind !== 'converting') return answer;
1723
+ if (progress?.(0, null, 'preparing') === false) {
1724
+ throw new BrassError(OPEN_SUPERSEDED_MESSAGE, { status: 0 });
1725
+ }
1726
+ if (Date.now() >= deadline) throw new BrassError(OPEN_WAIT_EXPIRED_MESSAGE, { status: 0 });
1727
+ let delay =
1728
+ refused.retryAfterMs === undefined ? CONVERSION_POLL_MIN_MS : heldJoinDelay(refused.retryAfterMs);
1729
+ for (;;) {
1730
+ await new Promise((resolve) => setTimeout(resolve, delay));
1731
+ try {
1732
+ answer = await join();
1733
+ failures = 0;
1734
+ break;
1735
+ } catch (err) {
1736
+ // The document is known to exist and to be converting, so a join
1737
+ // that fails outright is the connection rather than the answer.
1738
+ failures += 1;
1739
+ if (failures >= CONVERSION_JOIN_FAILURES) throw err;
1740
+ delay = Math.min(CONVERSION_POLL_MIN_MS * 2 ** (failures - 1), CONVERSION_POLL_MAX_MS);
1741
+ }
1742
+ }
1743
+ }
1744
+ }
1745
+
1746
+ async function joinOnce(
1747
+ docId: string,
1748
+ options: OpenDocumentByIdOptions | undefined,
1749
+ token: string,
1750
+ ): Promise<JoinAnswer> {
1174
1751
  const search = new URLSearchParams();
1175
1752
  search.set('include', 'snapshot');
1176
1753
  if (options?.filter !== undefined) {
@@ -1184,16 +1761,46 @@ export function createOpenDocument(
1184
1761
  }
1185
1762
  if (options?.acceptSchemaConflict === true) consented.add(docId);
1186
1763
  if (consented.has(docId)) search.set('accept_schema_conflict', '1');
1187
- const url = `${baseUrl}/documents/${encodeURIComponent(docId)}?${search.toString()}`;
1188
- const headers: Record<string, string> = { authorization: `Bearer ${token}` };
1189
- applyClientVersionHeader(headers);
1190
- await applyAppAssertionHeader(headers, opts.getAppAssertion);
1191
- const endFetch = perfStart('open: GET /documents (http + read body)');
1764
+ // A caller that will not write asks for the document as it is stored. It
1765
+ // matters on a document the platform holds as a record set: serving one
1766
+ // to a reader is the point of storing one, and serving it to a writer
1767
+ // means converting it to a CRDT first, which the server does and this
1768
+ // reader has no reason to wait for.
1769
+ if (opensReadOnly(options)) search.set('intent', 'read');
1770
+ return sendJoin(`/documents/${encodeURIComponent(docId)}`, search, {
1771
+ headers: await memberHeaders(token),
1772
+ perf: 'open: GET /documents (http + read body)',
1773
+ ...(options?.onSnapshotProgress !== undefined ? { progress: options.onSnapshotProgress } : {}),
1774
+ });
1775
+ }
1776
+
1777
+ // Every join's request: the stored frames this build decodes (a client that
1778
+ // does not name them is served the CRDT), the answer parsed, and its
1779
+ // spilled tail ops resolved. A reader holding a CRDT names the frames too:
1780
+ // the line as stored is the cheapest answer to serve it, and `applyJoin`
1781
+ // takes either form in place.
1782
+ async function sendJoin(
1783
+ path: string,
1784
+ search: URLSearchParams,
1785
+ {
1786
+ headers,
1787
+ perf,
1788
+ progress,
1789
+ }: {
1790
+ headers?: Record<string, string>;
1791
+ perf?: string;
1792
+ progress?: LoadProgress;
1793
+ },
1794
+ ): Promise<JoinAnswer> {
1795
+ search.set('accept', SEALED_ACCEPT);
1796
+ const qs = search.toString();
1797
+ const url = `${baseUrl}${path}${qs ? `?${qs}` : ''}`;
1798
+ const endFetch = perf !== undefined ? perfStart(perf) : null;
1192
1799
  // Idempotent read: ride out a transient 5xx / 429 instead of failing the
1193
1800
  // open on something that clears on its own.
1194
1801
  //
1195
1802
  // Longer than the default handful of attempts because of what a 503 means
1196
- // on this route: the document is intact and its storage is busy. A
1803
+ // on a join: the document is intact and its storage is busy. A
1197
1804
  // document's own writes contend with each other, so the one most likely
1198
1805
  // to answer 503 is one that was just written hard, which is exactly the
1199
1806
  // moment a person reloads to see their work. That pressure clears over
@@ -1207,15 +1814,18 @@ export function createOpenDocument(
1207
1814
  // answered the first, with the reader watching a loading surface
1208
1815
  // throughout.
1209
1816
  const response = await fetchWithRetry(
1210
- () =>
1211
- fetch(url, {
1212
- method: 'GET',
1213
- headers,
1214
- }),
1215
- { maxAttempts: 7, baseDelayMs: 700, maxNetworkAttempts: 3 },
1817
+ () => fetch(url, { method: 'GET', ...(headers !== undefined ? { headers } : {}) }),
1818
+ {
1819
+ maxAttempts: 7,
1820
+ baseDelayMs: 700,
1821
+ maxNetworkAttempts: 3,
1822
+ // A conversion's refusal says how long to wait, and the caller waits
1823
+ // that long rather than this backoff's second or two.
1824
+ isFinal: namesConversionWait,
1825
+ },
1216
1826
  );
1217
1827
  const text = await response.text();
1218
- endFetch({ status: response.status, responseChars: text.length });
1828
+ endFetch?.({ status: response.status, responseChars: text.length });
1219
1829
  let parsed: unknown = undefined;
1220
1830
  let parseFailed = false;
1221
1831
  if (text) {
@@ -1225,44 +1835,34 @@ export function createOpenDocument(
1225
1835
  parseFailed = true;
1226
1836
  }
1227
1837
  }
1228
- await resolveParsedTailOps(parsed, options?.onSnapshotProgress);
1838
+ // A tail op whose bytes spilled to storage arrives as a signed URL, which
1839
+ // carries its own signature, so a guest resolves it as a member does.
1840
+ await resolveParsedTailOps(parsed, progress);
1229
1841
  return { status: response.status, parsed, parseFailed };
1230
1842
  }
1231
1843
 
1844
+ async function memberHeaders(token: string): Promise<Record<string, string>> {
1845
+ const headers: Record<string, string> = { authorization: `Bearer ${token}` };
1846
+ applyClientVersionHeader(headers);
1847
+ await applyAppAssertionHeader(headers, opts.getAppAssertion);
1848
+ return headers;
1849
+ }
1850
+
1232
1851
  // `GET /documents/{docId}/at/{version}`: read-only materialization at a
1233
- // historical version. Same auth + parse shape as `fetchSnapshot`.
1852
+ // historical version.
1234
1853
  async function fetchAtVersion(
1235
1854
  docId: string,
1236
1855
  version: number,
1237
1856
  branch: string | undefined,
1238
1857
  token: string,
1239
- ): Promise<{ status: number; parsed: unknown; parseFailed: boolean }> {
1858
+ ): Promise<JoinAnswer> {
1240
1859
  const search = new URLSearchParams();
1241
1860
  if (branch !== undefined) search.set('branch', branch);
1242
- const qs = search.toString();
1243
- const url =
1244
- `${baseUrl}/documents/${encodeURIComponent(docId)}` +
1245
- `/at/${encodeURIComponent(String(version))}` +
1246
- (qs ? `?${qs}` : '');
1247
- const headers: Record<string, string> = { authorization: `Bearer ${token}` };
1248
- applyClientVersionHeader(headers);
1249
- await applyAppAssertionHeader(headers, opts.getAppAssertion);
1250
- const response = await fetch(url, {
1251
- method: 'GET',
1252
- headers,
1253
- });
1254
- const text = await response.text();
1255
- let parsed: unknown = undefined;
1256
- let parseFailed = false;
1257
- if (text) {
1258
- try {
1259
- parsed = JSON.parse(text);
1260
- } catch {
1261
- parseFailed = true;
1262
- }
1263
- }
1264
- await resolveParsedTailOps(parsed);
1265
- return { status: response.status, parsed, parseFailed };
1861
+ return sendJoin(
1862
+ `/documents/${encodeURIComponent(docId)}/at/${encodeURIComponent(String(version))}`,
1863
+ search,
1864
+ { headers: await memberHeaders(token) },
1865
+ );
1266
1866
  }
1267
1867
 
1268
1868
  // The historical-materialise fetcher `doc._checkoutVersion(v)` calls back
@@ -1271,16 +1871,7 @@ export function createOpenDocument(
1271
1871
  // `stream_seqs` in the response (present only on a default/branch preview
1272
1872
  // that resolved a checkpoint, absent on a line's own preview) rides
1273
1873
  // through so the parent can preview every opened stream at the same point.
1274
- function makeCheckoutFetcher(): (
1275
- id: string,
1276
- version: number,
1277
- branchId: string,
1278
- ) => Promise<{
1279
- snapshotBytes: Uint8Array | null;
1280
- tailOpBlobs: Uint8Array[];
1281
- effectiveVersion: number;
1282
- streamSeqs?: Record<string, number>;
1283
- }> {
1874
+ function makeCheckoutFetcher(): CheckoutFetcher {
1284
1875
  return async (id, version, branchId) => {
1285
1876
  const t = await requireToken();
1286
1877
  const result = await fetchAtVersion(id, version, branchId, t);
@@ -1295,26 +1886,9 @@ export function createOpenDocument(
1295
1886
  status: 500,
1296
1887
  });
1297
1888
  }
1298
- let snapshotBytes: Uint8Array | null = null;
1299
- const snap = result.parsed.snapshot;
1300
- if (snap !== null) {
1301
- if (snap.kind === 'inline') {
1302
- snapshotBytes = base64ToBytes(snap.data_b64);
1303
- } else if (snap.kind === 'presigned') {
1304
- const snapResponse = await fetch(snap.url, { method: 'GET' });
1305
- if (!snapResponse.ok) {
1306
- throw new BrassError('Presigned snapshot fetch failed', {
1307
- status: snapResponse.status,
1308
- });
1309
- }
1310
- const buf = await snapResponse.arrayBuffer();
1311
- snapshotBytes = new Uint8Array(buf);
1312
- } else {
1313
- // A delivery kind this build does not implement; refuse rather than
1314
- // preview an empty version (same rationale as `materialise`).
1315
- throw snapshotKindTooNewError();
1316
- }
1317
- }
1889
+ const content = await decodeJoinContent(result.parsed.snapshot, (url) =>
1890
+ fetchPresignedSnapshot(url),
1891
+ );
1318
1892
  const tailOpBlobs = result.parsed.tail_ops.map((entry) =>
1319
1893
  base64ToBytes(entry.op_blob_b64),
1320
1894
  );
@@ -1328,7 +1902,7 @@ export function createOpenDocument(
1328
1902
  if (Object.keys(parsed).length > 0) streamSeqs = parsed;
1329
1903
  }
1330
1904
  return {
1331
- snapshotBytes,
1905
+ content,
1332
1906
  tailOpBlobs,
1333
1907
  effectiveVersion: result.parsed.effective_version,
1334
1908
  ...(streamSeqs ? { streamSeqs } : {}),
@@ -1352,8 +1926,8 @@ export function createOpenDocument(
1352
1926
  if (typeof serverMessage === 'string' && serverMessage.length > 0) {
1353
1927
  return serverMessage;
1354
1928
  }
1355
- if (status === 403) return 'You do not have permission to edit this document.';
1356
- if (status === 404) return 'This document is no longer available.';
1929
+ if (status === 403) return 'You do not have permission to make changes here.';
1930
+ if (status === 404) return 'This is no longer available.';
1357
1931
  return `This change could not be saved (${status}).`;
1358
1932
  }
1359
1933
 
@@ -1364,27 +1938,30 @@ export function createOpenDocument(
1364
1938
  // outstanding tracker and re-asserts it, and a blind replay would write a
1365
1939
  // second stored copy of the same edit.
1366
1940
  // Mint a presigned PUT and upload the blob to it, returning the staging
1367
- // key the op write names. The PUT carries no Brass headers: the URL is
1941
+ // key the op write names. A share link mints on the same route with the
1942
+ // link as the credential. The PUT carries no Brass headers: the URL is
1368
1943
  // signed, and an extra header falls outside that signature.
1369
1944
  async function stageOpBlob(
1370
1945
  docId: string,
1371
1946
  opBlobB64: string,
1372
- token: string,
1947
+ credential: { bearer: string } | { link: string },
1373
1948
  ): Promise<string> {
1374
1949
  // Everything in here happens before the op write, so every failure it
1375
1950
  // can take is tagged as one: what the tag buys the writer is the right
1376
1951
  // to send the op again, which a failure of the write itself does not
1377
1952
  // carry.
1378
1953
  try {
1379
- const headers: Record<string, string> = {
1380
- authorization: `Bearer ${token}`,
1381
- 'content-type': 'application/json',
1382
- };
1383
- applyClientVersionHeader(headers);
1384
- await applyAppAssertionHeader(headers, opts.getAppAssertion);
1954
+ const headers: Record<string, string> = { 'content-type': 'application/json' };
1955
+ if ('link' in credential) {
1956
+ headers['authorization'] = `Link ${credential.link}`;
1957
+ } else {
1958
+ headers['authorization'] = `Bearer ${credential.bearer}`;
1959
+ applyClientVersionHeader(headers);
1960
+ await applyAppAssertionHeader(headers, opts.getAppAssertion);
1961
+ }
1385
1962
  const mint = await fetchWithDeadline(
1386
1963
  `${baseUrl}/documents/${encodeURIComponent(docId)}/ops/upload`,
1387
- { method: 'POST', headers, body: '{}' },
1964
+ { method: 'POST', headers, body: JSON.stringify({ byte_size: opBlobB64.length }) },
1388
1965
  OP_WRITE_TIMEOUT_MS,
1389
1966
  );
1390
1967
  let minted: unknown = undefined;
@@ -1427,6 +2004,7 @@ export function createOpenDocument(
1427
2004
  opBlobB64: string,
1428
2005
  clientOpId: string,
1429
2006
  token: string,
2007
+ base: { base_import_seq?: number } = {},
1430
2008
  ): Promise<{ status: number; parsed: unknown }> {
1431
2009
  const headers: Record<string, string> = {
1432
2010
  authorization: `Bearer ${token}`,
@@ -1439,7 +2017,7 @@ export function createOpenDocument(
1439
2017
  // through a request body.
1440
2018
  let stagedKey: string | undefined;
1441
2019
  if (opBlobNeedsStaging(opBlobB64)) {
1442
- stagedKey = await stageOpBlob(docId, opBlobB64, token);
2020
+ stagedKey = await stageOpBlob(docId, opBlobB64, { bearer: token });
1443
2021
  }
1444
2022
  const endFetch = perfStart('op: POST /documents/{id}/ops');
1445
2023
  const response = await fetchWithDeadline(
@@ -1453,6 +2031,7 @@ export function createOpenDocument(
1453
2031
  : { upload_key: stagedKey }),
1454
2032
  branch_id: branchId,
1455
2033
  client_op_id: clientOpId,
2034
+ ...base,
1456
2035
  }),
1457
2036
  },
1458
2037
  // An op under the staging threshold travels inline, so this request
@@ -1617,24 +2196,27 @@ export function createOpenDocument(
1617
2196
  async function openStreamLine(
1618
2197
  docId: string,
1619
2198
  lineId: string,
2199
+ // The parent document's open, whose read intent every line of it shares.
2200
+ parentOptions: OpenDocumentByIdOptions | undefined,
2201
+ credential: OpenCredential = SESSION,
1620
2202
  ): Promise<Document> {
1621
- const token = await requireToken();
1622
- const { status, parsed, parseFailed } = await fetchSnapshot(
1623
- docId,
1624
- { branch: lineId },
1625
- token,
1626
- );
1627
- if (status < 200 || status >= 300) {
1628
- throw brassErrorFromBody(status, parseFailed ? undefined : parsed);
1629
- }
1630
- if (!isSnapshotResponse(parsed)) {
1631
- throw new BrassError('Malformed document snapshot response', {
1632
- status: 500,
1633
- });
1634
- }
2203
+ const lineOptions = rejoinOptions(lineId, parentOptions);
2204
+ const result = await joinAs(credential, docId, lineOptions, { rejoin: false });
2205
+ const parsed = unwrapJoinResponse(result, isSnapshotResponse, 'snapshot');
1635
2206
  // A stream's write authority is the document's, so the line opens
1636
- // read-only for the same reader the document opens read-only for.
1637
- const doc = await materialise(parsed, readOnlyForGrant(parsed, undefined));
2207
+ // read-only for the same reader the document opens read-only for, and for
2208
+ // a caller who asked for a read-only document.
2209
+ const asked = parentOptions?.documentOptions;
2210
+ const doc = await materialiseAs(
2211
+ credential,
2212
+ parsed,
2213
+ readOnlyForGrant(
2214
+ parsed,
2215
+ asked?.readOnly === true
2216
+ ? { readOnly: true, readOnlyReason: asked.readOnlyReason ?? 'snapshot' }
2217
+ : undefined,
2218
+ ),
2219
+ );
1638
2220
  // A stream line materializes at a historical seq the same way the
1639
2221
  // default line does, so the parent's version preview can bring this
1640
2222
  // stream to a point in time (`doc.stream(name)._previewAt(seq)`). The
@@ -1697,24 +2279,15 @@ export function createOpenDocument(
1697
2279
  do {
1698
2280
  lineResyncAgain = false;
1699
2281
  try {
1700
- const t = await requireToken();
1701
- const res = await fetchSnapshot(docId, { branch: lineId }, t);
2282
+ const res = await joinAs(credential, docId, lineOptions);
1702
2283
  if (res.status < 200 || res.status >= 300) continue;
1703
2284
  if (!isSnapshotResponse(res.parsed)) continue;
1704
- const snap = res.parsed.snapshot;
1705
- if (snap !== null) {
1706
- const bytes =
1707
- snap.kind === 'inline'
1708
- ? base64ToBytes(snap.data_b64)
1709
- : await fetchPresignedSnapshot(snap.url);
1710
- doc._applySnapshot(bytes);
1711
- }
1712
- const seqs: number[] = [];
1713
- for (const entry of res.parsed.tail_ops) {
1714
- doc.applyRemoteOp(base64ToBytes(entry.op_blob_b64));
1715
- if (entry.seq > currentSeq) currentSeq = entry.seq;
1716
- seqs.push(entry.seq);
1717
- }
2285
+ const applied = await applyJoin(doc, res.parsed, (url) =>
2286
+ fetchPresignedSnapshot(url),
2287
+ );
2288
+ if (applied.kind !== 'applied') return;
2289
+ const seqs = applied.tailSeqs;
2290
+ for (const seq of seqs) if (seq > currentSeq) currentSeq = seq;
1718
2291
  const joined = res.parsed.current_seq;
1719
2292
  if (typeof joined === 'number' && joined > currentSeq) {
1720
2293
  currentSeq = joined;
@@ -1750,13 +2323,13 @@ export function createOpenDocument(
1750
2323
  opBlobB64: string,
1751
2324
  clientOpId: string,
1752
2325
  ): Promise<OversizedOpResult> => {
1753
- const token = await requireToken();
1754
- const { status, parsed: opResult } = await postDocumentOp(
2326
+ const { status, parsed: opResult } = await postOpAs(
2327
+ credential,
1755
2328
  docId,
1756
2329
  lineId,
1757
2330
  opBlobB64,
1758
2331
  clientOpId,
1759
- token,
2332
+ baseImportSeqField(doc),
1760
2333
  );
1761
2334
  if (status < 200 || status >= 300) {
1762
2335
  return {
@@ -1764,6 +2337,9 @@ export function createOpenDocument(
1764
2337
  message: opWriteFailureMessage(status, opResult),
1765
2338
  retryable: opWriteIsRetryable(status),
1766
2339
  atCapacity: isCapacityRefusal(status, opResult),
2340
+ ...(isMovedRefusal(opResult) ? { moved: true } : {}),
2341
+ ...(isNotImportedRefusal(opResult) ? { notImported: true } : {}),
2342
+ ...(status === 413 ? { tooLarge: true } : {}),
1767
2343
  // The route answers 403 when the caller's grant carries no write
1768
2344
  // access, which is the HTTP face of the socket's `role_denied`.
1769
2345
  ...(status === 403 ? { readOnly: 'grant' as const } : {}),
@@ -1780,13 +2356,33 @@ export function createOpenDocument(
1780
2356
  return { ok: true, seq };
1781
2357
  };
1782
2358
 
1783
- if (opts.wsClient) {
2359
+ // The durability handshake's ground truth for this line, read from the
2360
+ // line's own join so the diff is against this stream's frontier.
2361
+ const refetchDurableFrontier = async (): Promise<{
2362
+ snapshotVersionVector?: string;
2363
+ tailBlobs: Uint8Array[];
2364
+ } | null> => {
2365
+ const res = await joinAs(credential, docId, lineOptions);
2366
+ if (res.status < 200 || res.status >= 300) return null;
2367
+ if (!isSnapshotResponse(res.parsed)) return null;
2368
+ const out: { snapshotVersionVector?: string; tailBlobs: Uint8Array[] } = {
2369
+ tailBlobs: res.parsed.tail_ops.map((entry) => base64ToBytes(entry.op_blob_b64)),
2370
+ };
2371
+ if (typeof res.parsed.snapshot_version_vector === 'string') {
2372
+ out.snapshotVersionVector = res.parsed.snapshot_version_vector;
2373
+ }
2374
+ return out;
2375
+ };
2376
+
2377
+ const lineWsClient = wsClientFor(credential);
2378
+ if (lineWsClient) {
1784
2379
  wireDocTransport({
1785
2380
  doc,
1786
2381
  docId,
1787
2382
  branchId: lineId,
1788
- wsClient: opts.wsClient,
2383
+ wsClient: lineWsClient,
1789
2384
  postOversizedOp,
2385
+ refetchDurableFrontier,
1790
2386
  newClientOpId: opts.newClientOpId ?? defaultClientOpIdMinter,
1791
2387
  getCurrentSeq: () => currentSeq,
1792
2388
  setCurrentSeq: (next) => {
@@ -1803,42 +2399,465 @@ export function createOpenDocument(
1803
2399
  return doc;
1804
2400
  }
1805
2401
 
1806
- async function finalise(
1807
- docId: string,
2402
+ /**
2403
+ * A snapshot-only open: the ordinary join, materialised read-only, with no
2404
+ * transport wired.
2405
+ *
2406
+ * It reads the JOIN rather than the at-version route even though the two
2407
+ * envelopes match, because the join is what serves a record set as itself.
2408
+ * The at-version route materialises a LoroDoc to replay a tail onto, so it
2409
+ * converts a record set before answering; a reader asking for the document
2410
+ * as it stands has no tail to replay and would pay that conversion for
2411
+ * nothing.
2412
+ *
2413
+ * `readOnly` is what the caller asked for rather than a fact about their
2414
+ * grant, which is why it carries its own reason: an app that disables its
2415
+ * editor on `doc.readOnly` is right to, and the message a refused `mutate`
2416
+ * throws should say the caller opened a snapshot rather than blame a
2417
+ * permission they may well hold.
2418
+ */
2419
+ async function openSnapshotOnly(
1808
2420
  snapshot: { status: number; parsed: unknown; parseFailed: boolean },
1809
- options: OpenDocumentByIdOptions | undefined,
2421
+ options: OpenDocumentByIdOptions,
1810
2422
  ): Promise<OpenedDocument> {
1811
- const { status, parsed, parseFailed } = snapshot;
1812
- if (status < 200 || status >= 300) {
1813
- throw brassErrorFromBody(status, parseFailed ? undefined : parsed);
1814
- }
1815
- if (!isSnapshotResponse(parsed)) {
1816
- throw new BrassError('Malformed document snapshot response', { status: 500 });
1817
- }
1818
- const doc = await materialise(
2423
+ const parsed = unwrapJoinResponse(snapshot, isSnapshotResponse, 'snapshot');
2424
+ return openParsedSnapshot(parsed, options);
2425
+ }
2426
+
2427
+ async function openParsedSnapshot(
2428
+ parsed: DocumentSnapshotResponse,
2429
+ options: OpenDocumentByIdOptions,
2430
+ ): Promise<OpenedDocument> {
2431
+ // The grant's own reason wins where there is one. A viewer who asks for a
2432
+ // snapshot cannot edit either way, and telling them to reopen without
2433
+ // asking for one would be advice that does not work: the refusal has to
2434
+ // name the limit they actually have.
2435
+ const byGrant = readOnlyForGrant(parsed, options.documentOptions);
2436
+ const doc = await materialiseAs(
2437
+ SESSION,
1819
2438
  parsed,
1820
- readOnlyForGrant(parsed, options?.documentOptions),
1821
- options?.onSnapshotProgress,
2439
+ {
2440
+ ...byGrant,
2441
+ readOnly: true,
2442
+ readOnlyReason: byGrant?.readOnlyReason ?? 'snapshot',
2443
+ },
2444
+ options.onSnapshotProgress,
2445
+ { emptyAsFrozen: true },
1822
2446
  );
1823
- // Populate `doc.status` from the join before the first `doc_status` frame
1824
- // (a stub opened mid-hydration returns `snapshot: null` + a `pending`
1825
- // status).
1826
- if (parsed.status !== undefined) doc._emitStatus(parsed.status);
1827
- // Whether this app is the one to serialize the document, which is what
1828
- // decides whether a push carries bytes. Answered by the server for the
1829
- // caller's own app and only on the join, since the fanout frame is one
1830
- // payload for every subscriber and has no caller to answer for.
1831
- doc._setExportRunsHere(parsed.calling_app_exports === true);
1832
- // Seed `doc.driveStatus` from the join before the first
1833
- // `doc_drive_status` frame.
1834
- if (parsed.drive !== undefined) {
1835
- doc._emitDriveStatus(toDriveStatus(parsed.drive));
1836
- } else {
1837
- // Only for a document that mirrors nothing: a mirror publishes through
1838
- // its link, and `_emitDriveStatus` clears any targets anyway.
1839
- doc._setPublishTarget(toPublishTarget(parsed.drive_publish_target));
1840
- }
1841
- // Highest seq seen; later `doc_op` / `op_ack` frames advance it. Kept in
2447
+ // What one load brought: the point it was taken at, fixed until the next
2448
+ // load. A live open's provider is the transport's cursor and a
2449
+ // version-pinned one answers from its checkout, so this is what makes
2450
+ // `doc.currentSeq` mean the same thing on all three: the version the
2451
+ // reader is looking at. The change notice compares the document's head
2452
+ // against it.
2453
+ const takeLoad = (load: DocumentSnapshotResponse): void => {
2454
+ const takenAt = load.current_seq;
2455
+ if (typeof takenAt === 'number') doc.setCurrentSeqProvider(() => takenAt);
2456
+ // Work the join reported over the content it served: work that does not
2457
+ // lock readers (a push), or a new version's import that failed and left
2458
+ // the content in place. A join carries a status only while work is in
2459
+ // flight, so one with none after a pending load is the work having ended.
2460
+ if (load.status !== undefined) doc._emitStatus(load.status);
2461
+ else if (doc._workStatusNow?.pending === true) doc._emitStatus(WORK_READY_STATUS, { contentLoaded: true });
2462
+ doc._setImportReport(importReportOf(load));
2463
+ setJoinStamp(doc, (load as { updated_at?: unknown }).updated_at);
2464
+ setJoinedName(doc, load);
2465
+ watchFrom(load);
2466
+ };
2467
+ // An import is stored frozen, so a frozen open of a file nobody has
2468
+ // imported yet runs the app's importer the way an editable open does.
2469
+ doc._awaitsImport = parsed.awaits_import === true;
2470
+ if (opts.sourceBlobFetcher) {
2471
+ const fetcher = opts.sourceBlobFetcher;
2472
+ doc.setSourceBlobFetcher(() => fetcher(doc));
2473
+ }
2474
+ // A document's other streams open as frozen copies of their own lines,
2475
+ // read the same way and watched on their own topics.
2476
+ doc.setOpenedStreamName(options.stream ?? DEFAULT_STREAM_NAME);
2477
+ if (opts.streamProvider) {
2478
+ const provider = opts.streamProvider;
2479
+ const docId = parsed.doc_id;
2480
+ const openedBranchId = parsed.stream_branch_id ?? parsed.branch_id;
2481
+ doc.setStreamRuntime({
2482
+ list: async () => (await provider.list(docId, openedBranchId)).streams,
2483
+ create: () => Promise.reject(new Error('A read-only copy cannot create a stream.')),
2484
+ openLine: (lineId) => openFrozen(docId, frozenLineOptions(lineId)),
2485
+ });
2486
+ }
2487
+ const ws = opts.wsClient;
2488
+ const watchFrom = (load: DocumentSnapshotResponse): void => {
2489
+ if (!ws) return;
2490
+ const topic = `doc:${load.doc_id}:${load.branch_id}`;
2491
+ const at = typeof load.current_seq === 'number' ? load.current_seq : 0;
2492
+ // The copy reports the work that was in flight when it loaded, through to
2493
+ // its outcome, and nothing that starts afterwards: later work describes a
2494
+ // version of the document this copy does not hold.
2495
+ let following = load.status?.pending === true;
2496
+ doc._setChangeNotice((fire) =>
2497
+ ws.subscribe(
2498
+ topic,
2499
+ (frame) => {
2500
+ if (following && isDocStatusFrame(frame)) {
2501
+ const status = normalizeWorkStatus(frame.status);
2502
+ if (!status.pending) following = false;
2503
+ doc._emitStatus(status);
2504
+ doc._settleChangeNotice();
2505
+ }
2506
+ if (frameMovesDocument(frame, at)) fire();
2507
+ },
2508
+ {
2509
+ notice: true,
2510
+ onSubscribed: ({ currentSeq }) => {
2511
+ if (currentSeq !== undefined && currentSeq > at) fire();
2512
+ // Work that ends between the join and this subscribe sent its
2513
+ // frame to nobody, and may or may not have moved the seq, so a
2514
+ // copy taken mid-work asks the row whether it is still running.
2515
+ if (load.status?.pending !== true) return;
2516
+ void readFrozenWorkStatus(load.doc_id).then((now) => {
2517
+ if (now === 'unread' || now?.pending === true) return;
2518
+ doc._emitStatus(now ?? { pending: false, message: '' });
2519
+ fire();
2520
+ });
2521
+ },
2522
+ },
2523
+ ),
2524
+ );
2525
+ };
2526
+ takeLoad(parsed);
2527
+ // A frozen copy loads again only when the app asks. The join keeps the
2528
+ // open's read intent and names the stored frames whatever the copy holds
2529
+ // now, and the result applies in place (`applyJoin`) under the same
2530
+ // handles, switching between frozen content and a CRDT when the line has.
2531
+ const openedId = parsed.doc_id;
2532
+ //
2533
+ // A reload follows the open's rule: it waits out an import in flight and
2534
+ // loads what the import landed, and rejects (keeping what the copy holds)
2535
+ // when the import failed with nothing landed or the file is one only
2536
+ // another app imports. The open's progress sink belongs to the open, so
2537
+ // the reload's wait reports to none.
2538
+ const { onSnapshotProgress: _openProgress, ...reloadOptions } = options;
2539
+ doc._setFrozenReloader(async () => {
2540
+ const join = (): Promise<JoinAnswer> =>
2541
+ paceFrozen(async () => fetchSnapshot(openedId, reloadOptions, await requireToken()));
2542
+ const snapshot = await joinPastWork(openedId, reloadOptions, await join(), join);
2543
+ const load = unwrapJoinResponse(snapshot, isSnapshotResponse, 'snapshot');
2544
+ const applied = await applyJoin(doc, load, (url) => fetchPresignedSnapshot(url));
2545
+ if (applied.kind !== 'reloaded') takeLoad(load);
2546
+ });
2547
+ return doc;
2548
+ }
2549
+
2550
+ // The row's work status as it is now, without a snapshot, or 'unread' when
2551
+ // the read fails.
2552
+ async function readFrozenWorkStatus(docId: string): Promise<DocumentWorkStatus | null | 'unread'> {
2553
+ const now = await readWorkStatus(docId);
2554
+ return now === 'gone' ? 'unread' : now;
2555
+ }
2556
+
2557
+ // As `readFrozenWorkStatus`, with 'gone' for a document the caller can no
2558
+ // longer read (403 / 404).
2559
+ async function readWorkStatus(docId: string): Promise<DocumentWorkStatus | null | 'unread' | 'gone'> {
2560
+ try {
2561
+ const token = await requireToken();
2562
+ const headers: Record<string, string> = { authorization: `Bearer ${token}` };
2563
+ applyClientVersionHeader(headers);
2564
+ await applyAppAssertionHeader(headers, opts.getAppAssertion);
2565
+ const response = await fetch(`${baseUrl}/documents/${encodeURIComponent(docId)}`, {
2566
+ method: 'GET',
2567
+ headers,
2568
+ });
2569
+ if (response.status === 403 || response.status === 404) return 'gone';
2570
+ if (!response.ok) return 'unread';
2571
+ const body = (await response.json()) as { status?: unknown };
2572
+ return parseWorkStatus(body.status);
2573
+ } catch {
2574
+ return 'unread';
2575
+ }
2576
+ }
2577
+
2578
+ // An open joins past an import the join reports (`pendingImportWork`): it
2579
+ // waits for the import to end and joins again, so it resolves with what the
2580
+ // import landed, and rejects when the import failed with nothing landed.
2581
+ async function joinPastWork(
2582
+ docId: string,
2583
+ options: OpenDocumentByIdOptions | undefined,
2584
+ first: JoinAnswer,
2585
+ join: () => Promise<JoinAnswer>,
2586
+ ): Promise<JoinAnswer> {
2587
+ const deadline = Date.now() + OPEN_WAIT_CEILING_MS;
2588
+ let answer = first;
2589
+ for (;;) {
2590
+ const work = pendingImportWork(answer);
2591
+ if (work === null) {
2592
+ const failed = failedImportOf(answer);
2593
+ if (failed !== null) throw failed;
2594
+ return answer;
2595
+ }
2596
+ await waitForWorkToEnd(docId, work.topic, work.status, deadline, options?.onSnapshotProgress);
2597
+ answer = await join();
2598
+ }
2599
+ }
2600
+
2601
+ // Resolves once the work ends: a terminal `doc_status` frame on the
2602
+ // document's topic, or a status read that finds it ended (on subscribe, and
2603
+ // on a backing-off poll for a frame that never arrives). Rejects when the
2604
+ // progress sink says a newer open superseded this one, and at `deadline`.
2605
+ async function waitForWorkToEnd(
2606
+ docId: string,
2607
+ topic: string,
2608
+ status: DocumentWorkStatus,
2609
+ deadline: number,
2610
+ progress: LoadProgress | undefined,
2611
+ ): Promise<void> {
2612
+ const superseded = (): boolean => progress?.(0, null, 'importing', status.message) === false;
2613
+ if (superseded()) throw new BrassError(OPEN_SUPERSEDED_MESSAGE, { status: 0 });
2614
+ const ended = (now: DocumentWorkStatus): boolean => !now.pending || now.blocking === false;
2615
+ await new Promise<void>((resolve, reject) => {
2616
+ let settled = false;
2617
+ let timer: ReturnType<typeof setTimeout> | null = null;
2618
+ let unsubscribe: (() => void) | null = null;
2619
+ const expiry = setTimeout(
2620
+ () => end(new BrassError(OPEN_WAIT_EXPIRED_MESSAGE, { status: 0 })),
2621
+ Math.max(0, deadline - Date.now()),
2622
+ );
2623
+ const end = (err?: Error): void => {
2624
+ if (settled) return;
2625
+ settled = true;
2626
+ if (timer !== null) clearTimeout(timer);
2627
+ clearTimeout(expiry);
2628
+ unsubscribe?.();
2629
+ if (err) reject(err);
2630
+ else resolve();
2631
+ };
2632
+ const check = async (): Promise<void> => {
2633
+ const now = await readWorkStatus(docId);
2634
+ if (now === 'unread') return;
2635
+ // A document the caller lost is left to the next join to report.
2636
+ if (now === 'gone' || now === null || ended(now)) end();
2637
+ };
2638
+ let delay = IMPORT_WAIT_POLL_MIN_MS;
2639
+ const poll = (): void => {
2640
+ timer = setTimeout(() => {
2641
+ timer = null;
2642
+ if (settled) return;
2643
+ if (superseded()) {
2644
+ end(new BrassError(OPEN_SUPERSEDED_MESSAGE, { status: 0 }));
2645
+ return;
2646
+ }
2647
+ void check().finally(() => {
2648
+ if (settled) return;
2649
+ delay = Math.min(delay * 2, IMPORT_WAIT_POLL_MAX_MS);
2650
+ poll();
2651
+ });
2652
+ }, delay);
2653
+ };
2654
+ if (opts.wsClient) {
2655
+ unsubscribe = opts.wsClient.subscribe(
2656
+ topic,
2657
+ (frame) => {
2658
+ if (isDocStatusFrame(frame) && ended(normalizeWorkStatus(frame.status))) end();
2659
+ },
2660
+ { notice: true, onSubscribed: () => void check() },
2661
+ );
2662
+ }
2663
+ poll();
2664
+ });
2665
+ }
2666
+
2667
+ // A frozen open: a snapshot that watches for the document moving once a
2668
+ // listener asks it to. A set is opened as a loop over its ids, so joins
2669
+ // are paced here, which is what keeps such a loop fast without letting it
2670
+ // flood the api. The pace covers the join alone: the content download
2671
+ // goes to storage rather than the api, and holding a lane through it
2672
+ // leaves the next join waiting on bytes the api is not serving.
2673
+ async function openFrozen(docId: string, options: OpenDocumentByIdOptions): Promise<OpenedDocument> {
2674
+ const join = (): Promise<JoinAnswer> =>
2675
+ paceFrozen(async () => fetchSnapshot(docId, options, await requireToken()));
2676
+ // The wait sits outside the pace, which covers a join and not the time
2677
+ // between two of them.
2678
+ const snapshot = await joinPastWork(docId, options, await join(), join);
2679
+ return openSnapshotOnly(snapshot, options);
2680
+ }
2681
+ const paceFrozen = createPacer(FROZEN_OPEN_LANES);
2682
+
2683
+ // What a signed-in open reaches beyond the transport.
2684
+ function wireSessionCapabilities(
2685
+ doc: OpenedDocument,
2686
+ docId: string,
2687
+ parsed: DocumentSnapshotResponse,
2688
+ options: OpenDocumentByIdOptions | undefined,
2689
+ ): void {
2690
+ // `doc._checkoutVersion(v)` calls back here to materialise historical
2691
+ // state, leaving the live subscription untouched.
2692
+ doc.setCheckoutFetcher(makeCheckoutFetcher());
2693
+ // Wire the branch provider + populate the initial list, so the
2694
+ // toolbar's branch chip can name the branch on the next subscribe
2695
+ // tick. Branch management is hosted; this list feeds the SDK's own
2696
+ // chrome, not an app method.
2697
+ if (opts.branchProvider) {
2698
+ doc.setBranchProvider(opts.branchProvider);
2699
+ void doc._refreshBranches().catch(() => {
2700
+ // Best-effort: a failure leaves the chip unnamed until a hosted
2701
+ // branch action refreshes the list.
2702
+ });
2703
+ }
2704
+ // `doc.useCanvas(canvas)` captures + uploads after each local commit; the
2705
+ // doc handles debounce + readOnly gating internally.
2706
+ if (opts.thumbnailUploader) {
2707
+ const uploader = opts.thumbnailUploader;
2708
+ doc.setThumbnailUploader(async (image, width, height, version) => {
2709
+ await uploader(doc, image, width, height, version);
2710
+ });
2711
+ }
2712
+ doc._setThumbnailVersion(parsed.thumbnail?.version ?? null);
2713
+ if (opts.checkpointCreator) {
2714
+ const creator = opts.checkpointCreator;
2715
+ doc.setCheckpointCreator((anchor) => creator(doc, anchor));
2716
+ }
2717
+ if (opts.drivePusher) {
2718
+ const pusher = opts.drivePusher;
2719
+ doc.setDrivePusher((input) => pusher(doc, input));
2720
+ }
2721
+ // `doc.resolveDrive()` opens the hosted resolution surface and, when it
2722
+ // names a document to switch onto, re-opens onto it via the same
2723
+ // `openSiblingDoc` path the `history` fork uses.
2724
+ const hostedForResolve = opts.hostedUiProvider;
2725
+ const resolveSurface = hostedForResolve?.driveResolve;
2726
+ if (hostedForResolve && resolveSurface) {
2727
+ doc.setDriveResolver(async () => {
2728
+ const docId = doc.id;
2729
+ if (docId === null) return null;
2730
+ const result = await resolveSurface(docId);
2731
+ if (result?.newDocId && hostedForResolve.openSiblingDoc) {
2732
+ await hostedForResolve.openSiblingDoc(result.newDocId, {});
2733
+ }
2734
+ return result;
2735
+ });
2736
+ }
2737
+ if (opts.sourceBlobFetcher) {
2738
+ const fetcher = opts.sourceBlobFetcher;
2739
+ doc.setSourceBlobFetcher(() => fetcher(doc));
2740
+ }
2741
+ setJoinStamp(doc, (parsed as { updated_at?: unknown }).updated_at);
2742
+ setJoinedName(doc, parsed);
2743
+ const poster = opts.provisionalInputsPoster;
2744
+ if (poster) doc._setProvisionalInputsPoster((post, o) => poster(docId, post, o));
2745
+ doc.setOpenedStreamName(options?.stream ?? DEFAULT_STREAM_NAME);
2746
+ // Streams: manifest list/create over HTTP plus the per-line open
2747
+ // (join + WS subscribe). Bound to the BRANCH this Document opened on,
2748
+ // which is not the line it materialized when the open named a stream: a
2749
+ // manifest belongs to a branch and a line owns none, so binding to the
2750
+ // line would leave `doc.stream(name)` resolving against nothing.
2751
+ if (opts.streamProvider) {
2752
+ const provider = opts.streamProvider;
2753
+ const openedBranchId = parsed.stream_branch_id ?? parsed.branch_id;
2754
+ doc.setStreamRuntime({
2755
+ list: async () =>
2756
+ (await provider.list(docId, openedBranchId)).streams,
2757
+ create: (name) => provider.create(docId, openedBranchId, name),
2758
+ openLine: (lineId) => openStreamLine(docId, lineId, options),
2759
+ });
2760
+ }
2761
+ // Hosted-UI provider backing the doc's built-in `history` / `details` /
2762
+ // `share` / `delete` commands.
2763
+ if (opts.hostedUiProvider) {
2764
+ doc.setHostedUiProvider(opts.hostedUiProvider);
2765
+ const refreshName = opts.hostedUiProvider.refreshName;
2766
+ if (refreshName && docId) {
2767
+ void refreshName(docId).then((name) => {
2768
+ if (name !== null) doc._setName(name);
2769
+ });
2770
+ }
2771
+ }
2772
+ }
2773
+
2774
+ // What a share link's open reaches beyond the transport: the routes that
2775
+ // take the link as the credential, and the link's own stream manifest.
2776
+ function wireLinkCapabilities(
2777
+ doc: OpenedDocument,
2778
+ docId: string,
2779
+ parsed: DocumentSnapshotResponse,
2780
+ credential: Exclude<OpenCredential, { kind: 'session' }>,
2781
+ options: OpenDocumentByIdOptions | undefined,
2782
+ writes: boolean,
2783
+ ): void {
2784
+ const { token } = credential;
2785
+ setJoinStamp(doc, (parsed as { updated_at?: unknown }).updated_at);
2786
+ setJoinedName(doc, parsed);
2787
+ doc.setOpenedStreamName(options?.stream ?? DEFAULT_STREAM_NAME);
2788
+ if (writes) {
2789
+ doc.setCheckpointCreator(async (anchor) => {
2790
+ const version = anchor?.version ?? doc.currentSeq;
2791
+ if (version === null) {
2792
+ throw new BrassError('createCheckpoint: the document has no resolved version yet', { status: 400 });
2793
+ }
2794
+ const res = await postWithLink<{
2795
+ checkpoint: import('./documents-namespace.js').DocumentCheckpoint;
2796
+ }>(token, `/documents/${encodeURIComponent(docId)}/checkpoints`, {
2797
+ version,
2798
+ ...(doc.branchId !== null ? { branch_id: doc.branchId } : {}),
2799
+ });
2800
+ return res.checkpoint;
2801
+ });
2802
+ }
2803
+ doc.setStreamRuntime({
2804
+ list: () => fetchLinkStreamManifest(token),
2805
+ openLine: (lineId) => openStreamLine(docId, lineId, options, credential),
2806
+ create: async (name) => {
2807
+ if (!writes) {
2808
+ throw new BrassError('A shared-link guest cannot create streams.', { status: 403 });
2809
+ }
2810
+ const info = await postWithLink<{ name: string; format: string; line_id: string }>(
2811
+ token,
2812
+ `/documents/${encodeURIComponent(docId)}/streams`,
2813
+ { name, branch_id: parsed.branch_id },
2814
+ );
2815
+ return { name: info.name, format: info.format, line_id: info.line_id, created_at: '', created_by: '' };
2816
+ },
2817
+ });
2818
+ }
2819
+
2820
+ async function finalise(
2821
+ docId: string,
2822
+ snapshot: { status: number; parsed: unknown; parseFailed: boolean },
2823
+ options: OpenDocumentByIdOptions | undefined,
2824
+ credential: OpenCredential = SESSION,
2825
+ ): Promise<OpenedDocument> {
2826
+ const parsed = unwrapJoinResponse(snapshot, isSnapshotResponse, 'snapshot');
2827
+ // A caller who asked for a read-only document is told so in the words for
2828
+ // that, not a share link's.
2829
+ const asked = options?.documentOptions;
2830
+ const doc = await materialiseAs(
2831
+ credential,
2832
+ parsed,
2833
+ readOnlyForGrant(
2834
+ parsed,
2835
+ asked?.readOnly === true && asked.readOnlyReason === undefined
2836
+ ? { ...asked, readOnlyReason: 'snapshot' }
2837
+ : asked,
2838
+ ),
2839
+ options?.onSnapshotProgress,
2840
+ );
2841
+ // Populate `doc.status` from the join before the first `doc_status` frame.
2842
+ if (parsed.status !== undefined) doc._emitStatus(parsed.status);
2843
+ doc._setImportReport(importReportOf(parsed));
2844
+ setJoinStamp(doc, (parsed as { updated_at?: unknown }).updated_at);
2845
+ doc._awaitsImport = parsed.awaits_import === true;
2846
+ // Whether this app is the one to serialize the document, which is what
2847
+ // decides whether a push carries bytes. Answered by the server for the
2848
+ // caller's own app and only on the join, since the fanout frame is one
2849
+ // payload for every subscriber and has no caller to answer for.
2850
+ doc._setExportRunsHere(parsed.calling_app_exports === true);
2851
+ // Seed `doc.driveStatus` from the join before the first
2852
+ // `doc_drive_status` frame.
2853
+ if (parsed.drive !== undefined) {
2854
+ doc._emitDriveStatus(toDriveStatus(parsed.drive));
2855
+ } else {
2856
+ // Only for a document that mirrors nothing: a mirror publishes through
2857
+ // its link, and `_emitDriveStatus` clears any targets anyway.
2858
+ doc._setPublishTarget(toPublishTarget(parsed.drive_publish_target));
2859
+ }
2860
+ // Highest seq seen; later `doc_op` / `op_ack` frames advance it. Kept in
1842
2861
  // a closure, not on the Document: it is a transport concern, not CRDT
1843
2862
  // state.
1844
2863
  let currentSeq = Math.max(
@@ -1909,12 +2928,13 @@ export function createOpenDocument(
1909
2928
  | { ok: false; reason: string }
1910
2929
  > => {
1911
2930
  const endPerf = perfStart('doc: content reload (fetch + import)');
1912
- const token = await requireToken();
1913
2931
  // Refetch the branch this doc was opened on, not the default. `parsed`
1914
2932
  // is the opened-branch join, so its `branch_id` is authoritative;
1915
2933
  // dropping it would import the default branch's snapshot + tail into a
1916
2934
  // doc open on another branch and silently merge the wrong CRDT state.
1917
- const result = await fetchSnapshot(docId, { branch: parsed.branch_id }, token);
2935
+ // The open's read intent too: a reader who opened a record set as one
2936
+ // re-joins as one, rather than as a writer whose join converts it.
2937
+ const result = await joinAs(credential, docId, rejoinOptions(parsed.branch_id, options));
1918
2938
  if (result.status < 200 || result.status >= 300) {
1919
2939
  endPerf({ ok: false, joinStatus: result.status });
1920
2940
  return { ok: false, reason: `join fetch returned ${result.status}` };
@@ -1923,40 +2943,35 @@ export function createOpenDocument(
1923
2943
  endPerf({ ok: false, joinStatus: result.status });
1924
2944
  return { ok: false, reason: 'malformed join response' };
1925
2945
  }
1926
- const snap = result.parsed.snapshot;
1927
- let snapshotBytes: Uint8Array | null = null;
1928
- if (snap !== null && snap.kind === 'inline') {
1929
- snapshotBytes = base64ToBytes(snap.data_b64);
1930
- } else if (snap !== null && snap.kind === 'presigned') {
2946
+ doc._awaitsImport = result.parsed.awaits_import === true;
2947
+ let applied: JoinApplied;
2948
+ try {
1931
2949
  // Stream through the same progress sink the initial open uses, so the
1932
2950
  // session surfaces "Downloading... NN%" instead of sitting on the
1933
2951
  // last server phase.
1934
- try {
1935
- snapshotBytes = await fetchPresignedSnapshot(
1936
- snap.url,
1937
- options?.onSnapshotProgress,
1938
- );
1939
- } catch (err) {
1940
- const status = err instanceof BrassError ? err.status ?? 0 : 0;
1941
- endPerf({ ok: false, snapshotStatus: status });
1942
- return {
1943
- ok: false,
1944
- reason:
1945
- status > 0
1946
- ? `presigned snapshot fetch returned ${status}`
1947
- : `presigned snapshot fetch failed: ${err instanceof Error ? err.message : String(err)}`,
1948
- };
1949
- }
2952
+ applied = await applyJoin(doc, result.parsed, (url) =>
2953
+ fetchPresignedSnapshot(url, options?.onSnapshotProgress),
2954
+ );
2955
+ } catch (err) {
2956
+ const status = err instanceof BrassError ? err.status ?? 0 : 0;
2957
+ endPerf({ ok: false, snapshotStatus: status });
2958
+ return {
2959
+ ok: false,
2960
+ reason:
2961
+ status > 0
2962
+ ? `snapshot read returned ${status}`
2963
+ : `snapshot read failed: ${err instanceof Error ? err.message : String(err)}`,
2964
+ };
1950
2965
  }
1951
- if (snapshotBytes) doc._applySnapshot(snapshotBytes);
1952
- const sortedTail = [...result.parsed.tail_ops].sort(
1953
- (a, b) => a.seq - b.seq,
1954
- );
2966
+ if (applied.kind !== 'applied') {
2967
+ endPerf({ ok: true, joinStatus: result.status, reloaded: applied.kind === 'reloaded' });
2968
+ return { ok: true, status: result.parsed.status };
2969
+ }
2970
+ const tailSeqs = applied.tailSeqs;
1955
2971
  let maxTailSeq = 0;
1956
- for (const entry of sortedTail) {
1957
- doc.applyRemoteOp(base64ToBytes(entry.op_blob_b64));
1958
- if (entry.seq > currentSeq) currentSeq = entry.seq;
1959
- if (entry.seq > maxTailSeq) maxTailSeq = entry.seq;
2972
+ for (const seq of tailSeqs) {
2973
+ if (seq > currentSeq) currentSeq = seq;
2974
+ if (seq > maxTailSeq) maxTailSeq = seq;
1960
2975
  }
1961
2976
  const fetchedSeq =
1962
2977
  typeof result.parsed.current_seq === 'number'
@@ -1977,23 +2992,17 @@ export function createOpenDocument(
1977
2992
  // under it. That resolves at the next compaction, which folds the tail
1978
2993
  // past the hole and raises `snapshot_seq` over it; until then the gap
1979
2994
  // pull's own budget is what stops the reader asking forever.
1980
- const tailSeqs = sortedTail.map((entry) => entry.seq);
1981
- // A snapshot only counts as a floor once its bytes are in: a shape this
1982
- // pin does not decode leaves `snapshot_seq` describing content the doc
1983
- // never received, and the tail alone is then what it holds.
1984
- if (snapshotBytes !== null || result.parsed.snapshot === null) {
1985
- raiseCoverage(
1986
- joinCoverageFloor(
1987
- snapshotSeqOf(result.parsed.snapshot_seq, tailSeqs, fetchedSeq),
1988
- tailSeqs,
1989
- ),
1990
- );
1991
- }
2995
+ raiseCoverage(
2996
+ joinCoverageFloor(
2997
+ snapshotSeqOf(result.parsed.snapshot_seq, tailSeqs, fetchedSeq),
2998
+ tailSeqs,
2999
+ ),
3000
+ );
1992
3001
  for (const seq of tailSeqs) advanceCoverage(seq);
1993
3002
  endPerf({
1994
3003
  ok: true,
1995
- snapshotBytes: snapshotBytes?.byteLength ?? 0,
1996
- tailOps: sortedTail.length,
3004
+ snapshotBytes: applied.content.kind === 'crdt' ? applied.content.bytes.byteLength : 0,
3005
+ tailOps: tailSeqs.length,
1997
3006
  });
1998
3007
  return { ok: true, status: result.parsed.status };
1999
3008
  };
@@ -2007,10 +3016,9 @@ export function createOpenDocument(
2007
3016
  snapshotVersionVector?: string;
2008
3017
  tailBlobs: Uint8Array[];
2009
3018
  } | null> => {
2010
- const token = await requireToken();
2011
3019
  // Same-branch as `refetchAndImport`: the durability handshake must diff
2012
3020
  // against the opened branch's frontier, not the default branch's.
2013
- const result = await fetchSnapshot(docId, { branch: parsed.branch_id }, token);
3021
+ const result = await joinAs(credential, docId, rejoinOptions(parsed.branch_id, options));
2014
3022
  if (result.status < 200 || result.status >= 300) return null;
2015
3023
  if (!isSnapshotResponse(result.parsed)) return null;
2016
3024
  const tailBlobs = result.parsed.tail_ops.map((entry) =>
@@ -2113,6 +3121,47 @@ export function createOpenDocument(
2113
3121
  })();
2114
3122
  };
2115
3123
 
3124
+ // The row's work status as it is now, read without a snapshot, for a
3125
+ // frame whose claim about the row needs checking.
3126
+ const readWorkStatus = async (): Promise<DocumentWorkStatus | null> => {
3127
+ const body = (await readStatusAs(credential, docId)) as { status?: unknown };
3128
+ return parseWorkStatus(body.status);
3129
+ };
3130
+
3131
+ // A `pending` frame that would take a settled document back to loading is
3132
+ // checked against the row before it locks anything. A frame is the
3133
+ // stream's view of the row at the time of that write, delivered after the
3134
+ // stream's own lag, while the reconcile above reads the row directly: a
3135
+ // client that reconciles between a hydration's last phase write and the
3136
+ // stream delivering it has already imported the content when that
3137
+ // phase's frame arrives, and applying it locks a document whose work is
3138
+ // done. An importer that ran off the reconcile's own ready status then
3139
+ // writes into that lock and is refused. Progress inside a run the
3140
+ // document already shows as pending applies without a read; only the
3141
+ // regression from settled to pending pays one, and that one read answers
3142
+ // for every frame that arrives while it is out. A read that fails applies
3143
+ // the frame, since locking is the side that loses nothing.
3144
+ let pendingVerifyInFlight = false;
3145
+ const verifyPendingStatus = (status: DocumentWorkStatus): void => {
3146
+ if (pendingVerifyInFlight) return;
3147
+ pendingVerifyInFlight = true;
3148
+ void (async () => {
3149
+ let current: DocumentWorkStatus | null | 'unread' = 'unread';
3150
+ try {
3151
+ current = await readWorkStatus();
3152
+ } catch {
3153
+ // Unread: the frame stands.
3154
+ } finally {
3155
+ pendingVerifyInFlight = false;
3156
+ }
3157
+ if (current === 'unread') {
3158
+ doc._emitStatus(status);
3159
+ } else if (current !== null && current.pending) {
3160
+ doc._emitStatus(current);
3161
+ }
3162
+ })();
3163
+ };
3164
+
2116
3165
  // Evidence the document moved on the server after a give-up: one more
2117
3166
  // pull, which on success imports the content and releases the lock the
2118
3167
  // failed terminal status left in place.
@@ -2122,15 +3171,15 @@ export function createOpenDocument(
2122
3171
  reloadAfterHydration(WORK_READY_STATUS);
2123
3172
  };
2124
3173
 
2125
- // Reconcile a pending open once the live subscription exists. The
2126
- // terminal `doc_status` frame is best-effort: if hydration finishes
2127
- // before this client's subscription record exists, the fan-out finds no subscriber
2128
- // and drops the frame, stranding the app on the pending overlay. `doc_init`
2129
- // is posted only after the subscription record is written, so one re-fetch here
2130
- // closes the gap for a completion that landed before the row existed.
2131
- // Only a pending open pays the extra fetch.
3174
+ // Reconcile pending work once the live subscription exists (a resubscribe
3175
+ // while work runs). The terminal `doc_status` frame is best-effort: if the
3176
+ // work finishes while this client has no subscription record, the fan-out
3177
+ // finds no subscriber and drops the frame, stranding the app on the pending
3178
+ // overlay. `doc_init` is posted only after the subscription record is
3179
+ // written, so one re-fetch here closes the gap. Only a pending document
3180
+ // pays the extra fetch.
2132
3181
  const reconcileStatusOnInit = (): void => {
2133
- const current = doc.status;
3182
+ const current = doc._workStatusNow;
2134
3183
  if (!current || !current.pending) return;
2135
3184
  if (reconcileInFlight || reloadInFlight) return;
2136
3185
  reconcileInFlight = true;
@@ -2171,13 +3220,13 @@ export function createOpenDocument(
2171
3220
  opBlobB64: string,
2172
3221
  clientOpId: string,
2173
3222
  ): Promise<OversizedOpResult> => {
2174
- const token = await requireToken();
2175
- const { status, parsed: opResult } = await postDocumentOp(
3223
+ const { status, parsed: opResult } = await postOpAs(
3224
+ credential,
2176
3225
  docId,
2177
3226
  parsed.branch_id,
2178
3227
  opBlobB64,
2179
3228
  clientOpId,
2180
- token,
3229
+ baseImportSeqField(doc),
2181
3230
  );
2182
3231
  if (status < 200 || status >= 300) {
2183
3232
  return {
@@ -2185,6 +3234,9 @@ export function createOpenDocument(
2185
3234
  message: opWriteFailureMessage(status, opResult),
2186
3235
  retryable: opWriteIsRetryable(status),
2187
3236
  atCapacity: isCapacityRefusal(status, opResult),
3237
+ ...(isMovedRefusal(opResult) ? { moved: true } : {}),
3238
+ ...(isNotImportedRefusal(opResult) ? { notImported: true } : {}),
3239
+ ...(status === 413 ? { tooLarge: true } : {}),
2188
3240
  // The route answers 403 when the caller's grant carries no write
2189
3241
  // access, which is the HTTP face of the socket's `role_denied`.
2190
3242
  ...(status === 403 ? { readOnly: 'grant' as const } : {}),
@@ -2201,7 +3253,8 @@ export function createOpenDocument(
2201
3253
  return { ok: true, seq };
2202
3254
  };
2203
3255
 
2204
- if (opts.wsClient) {
3256
+ const docWsClient = wsClientFor(credential);
3257
+ if (docWsClient) {
2205
3258
  wireDocTransport({
2206
3259
  doc,
2207
3260
  docId,
@@ -2218,7 +3271,10 @@ export function createOpenDocument(
2218
3271
  ? { documentOptions: options.documentOptions }
2219
3272
  : {}),
2220
3273
  ...(opts.identity !== undefined ? { identity: opts.identity } : {}),
2221
- wsClient: opts.wsClient,
3274
+ // A link's open is parked under nobody, and a frozen copy hears
3275
+ // whole-document notices rather than joining the room.
3276
+ ...(credential.kind === 'session' ? {} : { parkable: false, skipPresence: options?.frozen === true }),
3277
+ wsClient: docWsClient,
2222
3278
  postOversizedOp,
2223
3279
  newClientOpId: opts.newClientOpId ?? defaultClientOpIdMinter,
2224
3280
  getCurrentSeq: () => currentSeq,
@@ -2231,87 +3287,12 @@ export function createOpenDocument(
2231
3287
  refetchDurableFrontier,
2232
3288
  reloadAfterHydration,
2233
3289
  reconcileStatusOnInit,
3290
+ verifyPendingStatus,
2234
3291
  onDocumentMoved: retryContentPull,
2235
3292
  });
2236
3293
  }
2237
- // `doc._checkoutVersion(v)` calls back here to materialise historical
2238
- // state, leaving the live subscription untouched.
2239
- doc.setCheckoutFetcher(makeCheckoutFetcher());
2240
- // Wire the branch provider + populate the initial list, so the
2241
- // toolbar's branch chip can name the branch on the next subscribe
2242
- // tick. Branch management is hosted; this list feeds the SDK's own
2243
- // chrome, not an app method.
2244
- if (opts.branchProvider) {
2245
- doc.setBranchProvider(opts.branchProvider);
2246
- void doc._refreshBranches().catch(() => {
2247
- // Best-effort: a failure leaves the chip unnamed until a hosted
2248
- // branch action refreshes the list.
2249
- });
2250
- }
2251
- // `doc.useCanvas(canvas)` captures + uploads after each local commit; the
2252
- // doc handles debounce + readOnly gating internally.
2253
- if (opts.thumbnailUploader) {
2254
- const uploader = opts.thumbnailUploader;
2255
- doc.setThumbnailUploader(async (image, width, height) => {
2256
- await uploader(doc, image, width, height);
2257
- });
2258
- }
2259
- doc._setThumbnailVersion(parsed.thumbnail?.version ?? null);
2260
- if (opts.checkpointCreator) {
2261
- const creator = opts.checkpointCreator;
2262
- doc.setCheckpointCreator(() => creator(doc));
2263
- }
2264
- if (opts.drivePusher) {
2265
- const pusher = opts.drivePusher;
2266
- doc.setDrivePusher((input) => pusher(doc, input));
2267
- }
2268
- // `doc.resolveDrive()` opens the hosted resolution surface and, when it
2269
- // mints a new mirror doc (take-source / keep-both), re-opens onto it via
2270
- // the same `openSiblingDoc` path the `history` fork uses.
2271
- const hostedForResolve = opts.hostedUiProvider;
2272
- const resolveSurface = hostedForResolve?.driveResolve;
2273
- if (hostedForResolve && resolveSurface) {
2274
- doc.setDriveResolver(async () => {
2275
- const docId = doc.id;
2276
- if (docId === null) return null;
2277
- const result = await resolveSurface(docId);
2278
- if (result?.newDocId && hostedForResolve.openSiblingDoc) {
2279
- await hostedForResolve.openSiblingDoc(result.newDocId, {});
2280
- }
2281
- return result;
2282
- });
2283
- }
2284
- if (opts.sourceBlobFetcher) {
2285
- const fetcher = opts.sourceBlobFetcher;
2286
- doc.setSourceBlobFetcher(() => fetcher(doc));
2287
- }
2288
- doc.setOpenedStreamName(options?.stream ?? DEFAULT_STREAM_NAME);
2289
- // Streams: manifest list/create over HTTP plus the per-line open
2290
- // (join + WS subscribe). Bound to the BRANCH this Document opened on,
2291
- // which is not the line it materialized when the open named a stream: a
2292
- // manifest belongs to a branch and a line owns none, so binding to the
2293
- // line would leave `doc.stream(name)` resolving against nothing.
2294
- if (opts.streamProvider) {
2295
- const provider = opts.streamProvider;
2296
- const openedBranchId = parsed.stream_branch_id ?? parsed.branch_id;
2297
- doc.setStreamRuntime({
2298
- list: async () =>
2299
- (await provider.list(docId, openedBranchId)).streams,
2300
- create: (name) => provider.create(docId, openedBranchId, name),
2301
- openLine: (lineId) => openStreamLine(docId, lineId),
2302
- });
2303
- }
2304
- // Hosted-UI provider backing the doc's built-in `history` / `details` /
2305
- // `share` / `delete` commands.
2306
- if (opts.hostedUiProvider) {
2307
- doc.setHostedUiProvider(opts.hostedUiProvider);
2308
- const refreshName = opts.hostedUiProvider.refreshName;
2309
- if (refreshName && docId) {
2310
- void refreshName(docId).then((name) => {
2311
- if (name !== null) doc._setName(name);
2312
- });
2313
- }
2314
- }
3294
+ if (credential.kind === 'session') wireSessionCapabilities(doc, docId, parsed, options);
3295
+ else wireLinkCapabilities(doc, docId, parsed, credential, options, doc.readOnly !== true);
2315
3296
  if (opts.reportError) doc.setErrorReporter(opts.reportError);
2316
3297
  // No socket on a document the caller can write to: the write path reaches
2317
3298
  // no server, so the document reports the first attempt rather than
@@ -2332,21 +3313,13 @@ export function createOpenDocument(
2332
3313
  options: OpenDocumentByIdOptions,
2333
3314
  token: string,
2334
3315
  ): Promise<OpenedDocument> {
2335
- const { status, parsed, parseFailed } = await fetchAtVersion(
2336
- docId,
2337
- version,
2338
- options.branch,
2339
- token,
3316
+ const parsed = unwrapJoinResponse(
3317
+ await fetchAtVersion(docId, version, options.branch, token),
3318
+ isAtVersionResponse,
3319
+ 'at-version',
2340
3320
  );
2341
- if (status < 200 || status >= 300) {
2342
- throw brassErrorFromBody(status, parseFailed ? undefined : parsed);
2343
- }
2344
- if (!isAtVersionResponse(parsed)) {
2345
- throw new BrassError('Malformed document at-version response', {
2346
- status: 500,
2347
- });
2348
- }
2349
- return materialise(
3321
+ return materialiseAs(
3322
+ SESSION,
2350
3323
  {
2351
3324
  doc_id: docId,
2352
3325
  branch_id: parsed.branch_id,
@@ -2367,29 +3340,50 @@ export function createOpenDocument(
2367
3340
  // same snapshot + tail_ops envelope, member-stripped and read-only.
2368
3341
  async function fetchLinkView(
2369
3342
  token: string,
2370
- branch: string | undefined,
2371
- ): Promise<{ status: number; parsed: unknown; parseFailed: boolean }> {
3343
+ // The line, and whether this guest will write. A view link's reader never
3344
+ // does, and an edit link's may, which is the same question the
3345
+ // authenticated join asks with `intent=read` and decides the same thing:
3346
+ // whether a document the platform stores frozen is served as it stands or
3347
+ // converted first.
3348
+ join: OpenDocumentByIdOptions,
3349
+ progress?: LoadProgress,
3350
+ { rejoin = false }: { rejoin?: boolean } = {},
3351
+ ): Promise<JoinAnswer> {
3352
+ const once = (): Promise<JoinAnswer> => linkViewOnce(token, join, rejoin);
3353
+ // An edit link's guest waits out a conversion as a member's editable open
3354
+ // does.
3355
+ return opensReadOnly(join) ? once() : waitWhileConverting(once, progress);
3356
+ }
3357
+
3358
+ async function linkViewOnce(
3359
+ token: string,
3360
+ join: OpenDocumentByIdOptions,
3361
+ rejoin: boolean,
3362
+ ): Promise<JoinAnswer> {
2372
3363
  const search = new URLSearchParams();
2373
3364
  search.set('include', 'snapshot');
2374
- if (branch !== undefined) search.set('branch', branch);
2375
- const url = `${baseUrl}/links/${encodeURIComponent(token)}/view?${search.toString()}`;
2376
- const response = await fetchWithRetry(() => fetch(url, { method: 'GET' }));
2377
- const text = await response.text();
2378
- let parsed: unknown = undefined;
2379
- let parseFailed = false;
2380
- if (text) {
2381
- try {
2382
- parsed = JSON.parse(text);
2383
- } catch {
2384
- parseFailed = true;
2385
- }
3365
+ search.set('app_id', opts.appId);
3366
+ if (rejoin) search.set('rejoin', '1');
3367
+ const headers = await linkViewHeaders();
3368
+ // A person signed in here who reaches the document on their own opens it
3369
+ // as themselves, with their own role and identity.
3370
+ if (!rejoin && headers['authorization'] !== undefined) search.set('as_member', '1');
3371
+ if (join.branch !== undefined) search.set('branch', join.branch);
3372
+ if (opensReadOnly(join)) search.set('intent', 'read');
3373
+ return sendJoin(`/links/${encodeURIComponent(token)}/view`, search, { headers });
3374
+ }
3375
+
3376
+ // A link open carries no credential unless the person signed in on this
3377
+ // link page, in which case it carries theirs.
3378
+ async function linkViewHeaders(): Promise<Record<string, string>> {
3379
+ const headers: Record<string, string> = {};
3380
+ applyClientVersionHeader(headers);
3381
+ const credential = (await opts.getLinkCredential?.()) ?? null;
3382
+ if (credential !== null) {
3383
+ headers['authorization'] = `Bearer ${credential.accessToken}`;
3384
+ headers['x-brass-app-assertion'] = credential.appAssertion;
2386
3385
  }
2387
- // A tail op whose bytes spilled to storage arrives as a signed URL, and
2388
- // a guest join carries them exactly as a signed-in one does. The URL
2389
- // carries its own signature, so an anonymous reader fetches it with the
2390
- // same call. Resolving here is what lets `materialise` see one shape.
2391
- await resolveParsedTailOps(parsed);
2392
- return { status: response.status, parsed, parseFailed };
3386
+ return headers;
2393
3387
  }
2394
3388
 
2395
3389
  // `GET /links/{token}/streams`: the anonymous stream manifest, the guest
@@ -2400,7 +3394,9 @@ export function createOpenDocument(
2400
3394
  token: string,
2401
3395
  ): Promise<import('./documents-namespace.js').DocumentStreamInfo[]> {
2402
3396
  const url = `${baseUrl}/links/${encodeURIComponent(token)}/streams`;
2403
- const response = await fetchWithRetry(() => fetch(url, { method: 'GET' }));
3397
+ const headers: Record<string, string> = {};
3398
+ applyClientVersionHeader(headers);
3399
+ const response = await fetchWithRetry(() => fetch(url, { method: 'GET', headers }));
2404
3400
  const text = await response.text();
2405
3401
  if (response.status < 200 || response.status >= 300) {
2406
3402
  let parsed: unknown = undefined;
@@ -2439,140 +3435,72 @@ export function createOpenDocument(
2439
3435
  );
2440
3436
  }
2441
3437
 
2442
- const openByLink: OpenDocumentByLinkFn = async (token, options) => {
2443
- const { status, parsed, parseFailed } = await fetchLinkView(
3438
+ const openByLink: OpenDocumentByLinkFn = async (token, asked) => {
3439
+ const options = withReadIntent(asked);
3440
+ const view = await fetchLinkView(
2444
3441
  token,
2445
- options?.branch,
3442
+ rejoinOptions(options?.branch, options),
3443
+ options?.onSnapshotProgress,
2446
3444
  );
2447
- if (status < 200 || status >= 300) {
2448
- throw brassErrorFromBody(status, parseFailed ? undefined : parsed);
3445
+ if (view.status < 200 || view.status >= 300) {
3446
+ throw brassErrorFromBody(view.status, view.parseFailed ? undefined : view.parsed);
2449
3447
  }
2450
- const b = parsed as {
3448
+ const b = view.parsed as {
2451
3449
  doc_id?: unknown;
2452
- branch_id?: unknown;
2453
- snapshot?: unknown;
2454
- tail_ops?: unknown;
2455
- current_seq?: unknown;
2456
- name?: unknown;
3450
+ opens_as_member?: unknown;
2457
3451
  link_role?: unknown;
2458
3452
  status?: unknown;
3453
+ anonymous?: unknown;
3454
+ expires_at?: unknown;
3455
+ awaits_import?: unknown;
3456
+ can_edit?: unknown;
3457
+ read_only_reason?: unknown;
2459
3458
  };
2460
- if (
2461
- typeof b.doc_id !== 'string' ||
2462
- typeof b.branch_id !== 'string' ||
2463
- !Array.isArray(b.tail_ops)
2464
- ) {
3459
+ if (typeof b.doc_id !== 'string') {
2465
3460
  throw new BrassError('Malformed link view response', { status: 500 });
2466
3461
  }
2467
- // The link's grant decides read vs write. An `editor` link is anonymous
2468
- // EDIT: a live Document (not read-only) whose local mutations ship over
2469
- // the same token-authenticated socket, with the server admitting the
2470
- // op-write because the guest's subscription record carries `role: editor`. A
2471
- // `viewer` link (the default) is read-only: mutate throws and only
2472
- // inbound frames apply.
2473
- const canEdit = b.link_role === 'editor';
2474
- const doc = await materialise(
2475
- {
2476
- doc_id: b.doc_id,
2477
- branch_id: b.branch_id,
2478
- snapshot: b.snapshot as DocumentSnapshotResponse['snapshot'],
2479
- tail_ops: b.tail_ops as DocumentSnapshotResponse['tail_ops'],
2480
- },
2481
- { ...options?.documentOptions, readOnly: !canEdit },
2482
- options?.onSnapshotProgress,
2483
- );
2484
- // The view carries the document's display name; set it so the guest
2485
- // sees the title (the signed-in open gets this from `finalise`).
2486
- if (typeof b.name === 'string') doc._setName(b.name);
2487
- // A link opened while the platform is still preparing the document
2488
- // carries the same opaque work status the signed-in join does, and the
2489
- // guest's own topic delivers the phases after it. Seeded before the
2490
- // transport so the app renders the wait rather than an empty document.
2491
- const linkStatus = parseWorkStatus(b.status);
2492
- if (linkStatus) doc._emitStatus(linkStatus);
2493
- // An edit link's writes fail the same ways a member's do (a shed the
2494
- // budget outlived, a link revoked mid-edit), and the app hears those
2495
- // through the same channel it hears a member's, so the reporter is
2496
- // wired here as `finalise` wires it for a signed-in open.
2497
- if (opts.reportError) doc.setErrorReporter(opts.reportError);
2498
-
2499
- wireGuestTransport(doc, token, b.doc_id, b.branch_id, canEdit);
2500
-
2501
- // Streams: a guest resolves `doc.stream(name)` through the token
2502
- // routes. `list` hits the anonymous manifest; `openLine` joins the
2503
- // line read/write exactly like the default stream above, over the same
2504
- // token-authenticated socket. `create` is refused (a guest cannot mint
2505
- // a line). Absent when there is no WS client (a snapshot-only guest),
2506
- // matching the default stream having no live transport there.
2507
- doc.setStreamRuntime({
2508
- list: () => fetchLinkStreamManifest(token),
2509
- openLine: async (lineId) => {
2510
- const view = await fetchLinkView(token, lineId);
2511
- if (view.status < 200 || view.status >= 300) {
2512
- throw brassErrorFromBody(
2513
- view.status,
2514
- view.parseFailed ? undefined : view.parsed,
2515
- );
2516
- }
2517
- const lb = view.parsed as {
2518
- doc_id?: unknown;
2519
- branch_id?: unknown;
2520
- snapshot?: unknown;
2521
- tail_ops?: unknown;
2522
- };
2523
- if (
2524
- typeof lb.doc_id !== 'string' ||
2525
- typeof lb.branch_id !== 'string' ||
2526
- !Array.isArray(lb.tail_ops)
2527
- ) {
2528
- throw new BrassError('Malformed link stream view response', {
2529
- status: 500,
2530
- });
2531
- }
2532
- const inner = await materialise(
2533
- {
2534
- doc_id: lb.doc_id,
2535
- branch_id: lb.branch_id,
2536
- snapshot: lb.snapshot as DocumentSnapshotResponse['snapshot'],
2537
- tail_ops: lb.tail_ops as DocumentSnapshotResponse['tail_ops'],
2538
- },
2539
- { readOnly: !canEdit },
2540
- );
2541
- if (opts.reportError) inner.setErrorReporter(opts.reportError);
2542
- wireGuestTransport(inner, token, lb.doc_id, lb.branch_id, canEdit);
2543
- return inner;
2544
- },
2545
- create: () => {
2546
- throw new BrassError('A shared-link guest cannot create streams.', {
2547
- status: 403,
2548
- });
2549
- },
2550
- });
3462
+ if (b.opens_as_member === true) {
3463
+ return (opts.openAsMember ?? open)(b.doc_id, asked);
3464
+ }
3465
+ // The link's grant decides read vs write; the view answers `can_edit`
3466
+ // as the member join does, so a view link reads as a viewer's grant.
3467
+ const canEdit = b.link_role === 'editor' && b.can_edit !== false;
3468
+ const askedToRead = canEdit && opensReadOnly(options);
3469
+ const linkOptions: OpenDocumentByIdOptions = {
3470
+ ...options,
3471
+ documentOptions: askedToRead
3472
+ ? { ...options?.documentOptions, readOnly: true, readOnlyReason: 'snapshot' }
3473
+ : canEdit
3474
+ ? { ...options?.documentOptions }
3475
+ : { ...options?.documentOptions, readOnly: true, readOnlyReason: 'link' },
3476
+ };
3477
+ // A document minted without an account names when the platform removes
3478
+ // it; every other link carries neither key.
3479
+ const expiresAt = anonymousExpiry(b);
3480
+ const credential: OpenCredential =
3481
+ expiresAt !== null ? { kind: 'temporary', token } : { kind: 'link', token };
3482
+ const doc = await finalise(b.doc_id, view, linkOptions, credential);
3483
+ if (expiresAt !== null) {
3484
+ doc._setExpiresAt(expiresAt);
3485
+ // A document the visitor started from a file is imported by this app
3486
+ // in this page, so it reads its file through the link as a member's
3487
+ // import reads it through the document.
3488
+ const imports = guestImports(token, b.doc_id);
3489
+ doc.setSourceBlobFetcher(() => imports.sourceBlob());
3490
+ doc._awaitsImport = canEdit && b.awaits_import === true;
3491
+ }
2551
3492
  return doc;
2552
3493
  };
2553
3494
 
2554
- // Write a guest's op over HTTP: `POST /links/{token}/ops`, the anonymous
2555
- // counterpart of `POST /documents/{docId}/ops`. Sends NO Authorization
2556
- // header (the token is the credential) and names the line the guest
2557
- // opened, which is the branch its socket topic is scoped to.
2558
- async function postLinkOp(
2559
- token: string,
2560
- lineId: string,
2561
- opBlobB64: string,
2562
- clientOpId: string,
2563
- ): Promise<{ status: number; parsed: unknown }> {
2564
- const response = await fetch(
2565
- `${baseUrl}/links/${encodeURIComponent(token)}/ops`,
2566
- {
2567
- method: 'POST',
2568
- headers: { 'content-type': 'application/json' },
2569
- body: JSON.stringify({
2570
- op_blob: opBlobB64,
2571
- branch_id: lineId,
2572
- client_op_id: clientOpId,
2573
- }),
2574
- },
2575
- );
3495
+ // A read through a route a member's open reads, with the link as the
3496
+ // credential. A 404 answers null, as the session's reads of the same
3497
+ // routes do.
3498
+ async function getWithLink(token: string, path: string, query: Record<string, string>): Promise<unknown> {
3499
+ const search = new URLSearchParams(query).toString();
3500
+ const response = await fetch(`${baseUrl}${search ? `${path}?${search}` : path}`, {
3501
+ headers: { authorization: `Link ${token}` },
3502
+ });
3503
+ if (response.status === 404) return null;
2576
3504
  const text = await response.text();
2577
3505
  let parsed: unknown = undefined;
2578
3506
  try {
@@ -2580,344 +3508,89 @@ export function createOpenDocument(
2580
3508
  } catch {
2581
3509
  parsed = undefined;
2582
3510
  }
2583
- return { status: response.status, parsed };
2584
- }
2585
-
2586
- // Wire a guest Document (the default stream or a stream line) to the
2587
- // token-authenticated socket. Inbound frames (`doc_init` / `doc_op`)
2588
- // apply for both link kinds; an edit link ALSO wires the outbound path
2589
- // (`op_ack` resolves the outstanding-op tracker, `setLocalOpHook` ships
2590
- // each local commit). Best-effort on the read side (no resync / durability
2591
- // handshake, which need a signed-in token); a guest that misses ops
2592
- // re-opens. The WRITE side answers a refusal the way the signed-in
2593
- // transport does, because a guest editor's edit is the same person's work
2594
- // as a member's and the same two things can happen to it: a frame the
2595
- // socket cannot carry takes the HTTP route, and a capacity shed is
2596
- // re-asserted on the shared backoff and reported once the budget is out.
2597
- // Patches `close` to detach, mirroring `wireDocTransport`, so `Document`
2598
- // stays free of WS knowledge. A no-op with no WS client (a snapshot-only
2599
- // guest).
2600
- function wireGuestTransport(
2601
- doc: Document,
2602
- token: string,
2603
- docId: string,
2604
- lineId: string,
2605
- canEdit: boolean,
2606
- ): void {
2607
- const wsClient = opts.wsClient;
2608
- if (!wsClient) {
2609
- // No socket: a write would change local state and reach no server, so
2610
- // the document reports the first one instead of discarding it quietly.
2611
- if (canEdit) doc.markTransportUnavailable();
2612
- return;
2613
- }
2614
- const topic = `doc:${docId}:${lineId}`;
2615
-
2616
- // Ops on the socket awaiting an answer, keyed by `client_op_id`. An
2617
- // `unavailable` shed names no op, so re-asserting one needs the frames
2618
- // in hand. Scoped to what the socket carried: an op that took the HTTP
2619
- // write must never reach the socket, and that write re-asserts itself.
2620
- const outstanding = new Map<string, OpFrame>();
2621
- let shedRounds = 0;
2622
- let shedTimer: ReturnType<typeof setTimeout> | null = null;
2623
-
2624
- function clearShedRecovery(): void {
2625
- if (shedTimer !== null) {
2626
- clearTimeout(shedTimer);
2627
- shedTimer = null;
2628
- }
2629
- shedRounds = 0;
2630
- }
2631
-
2632
- // Every outstanding op is dead: drop the tracker entries and tell the
2633
- // Document, which stops counting them in `savingCount` and reports each
2634
- // through the error channel the app reads. The ids are taken first
2635
- // because `_failOutstandingOp` re-enters app code.
2636
- function failAllOutstanding(message: string): void {
2637
- const clientOpIds = [...outstanding.keys()];
2638
- outstanding.clear();
2639
- for (const clientOpId of clientOpIds) {
2640
- doc._failOutstandingOp(clientOpId, message);
2641
- }
3511
+ if (response.status < 200 || response.status >= 300) {
3512
+ throw brassErrorFromBody(response.status, parsed);
2642
3513
  }
3514
+ return parsed;
3515
+ }
2643
3516
 
2644
- // Put every op still awaiting an answer back on the wire. The client
2645
- // DROPS an op frame it cannot send right now rather than buffering one
2646
- // (`ownsFrameRetry` below), so this is the only thing that carries a
2647
- // shed or reconnect-orphaned op across the window, and it is also what
2648
- // keeps a frame from being written twice: a copy the client held would
2649
- // flush on the `subscribed` ack while this re-asserts on the `doc_init`
2650
- // behind it, and the server allocates a fresh seq per send.
2651
- const resendAllOutstanding = (): void => {
2652
- for (const frame of outstanding.values()) wsClient.send(frame);
2653
- };
2654
-
2655
- const onServerUnavailable = (): void => {
2656
- if (outstanding.size === 0) return;
2657
- if (shedTimer !== null) return;
2658
- shedRounds += 1;
2659
- if (shedRounds > UNAVAILABLE_MAX_ROUNDS) {
2660
- clearShedRecovery();
2661
- failAllOutstanding(UNAVAILABLE_GIVE_UP_MESSAGE);
2662
- return;
2663
- }
2664
- shedTimer = setTimeout(() => {
2665
- shedTimer = null;
2666
- resendAllOutstanding();
2667
- }, unavailableDelayMs(shedRounds));
2668
- (shedTimer as { unref?: () => void }).unref?.();
2669
- };
2670
-
2671
- const unsub = wsClient.subscribe(
2672
- topic,
2673
- (frame) => {
2674
- const init = parseDocInitFrame(frame);
2675
- if (init) {
2676
- for (const entry of init.tail_ops) {
2677
- if (entry.op_blob_b64 === undefined) continue;
2678
- doc.applyRemoteOp(base64ToBytes(entry.op_blob_b64));
2679
- }
2680
- // `doc_init` follows every `subscribed` ack, so this is the point
2681
- // the topic can carry frames again. A fresh connection also returns
2682
- // the shed budget to full: this re-assert is the round a pending
2683
- // one would have run.
2684
- clearShedRecovery();
2685
- resendAllOutstanding();
2686
- return;
2687
- }
2688
- if (isDocOpFrame(frame)) {
2689
- doc.applyRemoteOp(base64ToBytes(frame.op_blob));
2690
- } else if (isDocGoneFrame(frame)) {
2691
- // The server fans `doc_gone` to EVERY subscriber on the branched
2692
- // content topic, guest-link connections included, on a soft-delete /
2693
- // move of the opened doc.
2694
- // Without this branch the guest keeps showing removed content and
2695
- // `doc.onGone(...)` never fires; for an edit link the next local op
2696
- // would ship onto the evicted subscription record and bounce as a misleading
2697
- // `forbidden`. Drop the shed recovery first so nothing re-asserts an
2698
- // op onto an evicted subscription record, then mark the doc gone (mapping the
2699
- // reason like the signed-in transport).
2700
- clearShedRecovery();
2701
- outstanding.clear();
2702
- doc._markGone(goneReasonOf(frame));
2703
- } else if (isDocStatusFrame(frame)) {
2704
- // A guest can open a document whose inbound work is still running,
2705
- // and hydration writes a SNAPSHOT rather than ops, so nothing on the
2706
- // op stream would ever fill the view in. Same split as the signed-in
2707
- // transport: the `reload` hint pulls the content and emits the
2708
- // terminal status after the import, and anything else renders.
2709
- const status = normalizeWorkStatus(frame.status);
2710
- if (frame.reload) pullGuestContent(doc, token, lineId, status);
2711
- else doc._emitStatus(status);
2712
- } else if (isDocMetaFrame(frame)) {
2713
- // `_setName` de-dupes, so a re-emit of the current name is a no-op.
2714
- doc._setName(frame.name);
2715
- } else if (canEdit && isOpAckFrame(frame)) {
2716
- outstanding.delete(frame.client_op_id);
2717
- doc.resolveOutstandingOp(frame.client_op_id);
2718
- // The server is durably accepting writes again, so the shed budget
2719
- // starts over. A round already scheduled still runs: the ops it
2720
- // re-asserts are the ones this ack did not cover.
2721
- shedRounds = 0;
2722
- } else if (canEdit && isServerUnavailableFrame(frame)) {
2723
- onServerUnavailable();
2724
- } else if (canEdit && isOpRejectedFrame(frame)) {
2725
- // Terminal, not transient: stop re-asserting this op (retrying
2726
- // would only draw the same rejection) and fail it on the Document
2727
- // so "could not be saved" surfaces instead of a stuck saving count.
2728
- // A refusal naming the writer (an edit link downgraded or revoked
2729
- // while the guest had it open) turns the Document read-only, the
2730
- // same way it does for a signed-in reader.
2731
- outstanding.delete(frame.client_op_id);
2732
- if (isCapacityRejection(frame)) doc._markAtCapacity();
2733
- const readOnly = readOnlyReasonForRejection(frame);
2734
- if (readOnly !== null) doc._markReadOnly(readOnly);
2735
- doc._failOutstandingOp(frame.client_op_id, opRejectedMessage(frame));
2736
- }
3517
+ // A write through a route a member's open writes, with the link as the
3518
+ // credential. Resolves the parsed body, or rejects with the server's
3519
+ // refusal.
3520
+ async function postWithLink<T>(token: string, path: string, body: unknown): Promise<T> {
3521
+ const response = await fetch(`${baseUrl}${path}`, {
3522
+ method: 'POST',
3523
+ headers: {
3524
+ 'content-type': 'application/json',
3525
+ authorization: `Link ${token}`,
2737
3526
  },
2738
- // The guest owns its outstanding ops: `resendAllOutstanding` on the
2739
- // `doc_init` behind every `subscribed` ack IS this topic's
2740
- // re-assertion, so the client drops an op frame it cannot send now
2741
- // rather than buffering a second copy of it.
2742
- { ownsFrameRetry: true },
2743
- );
2744
-
2745
- // An op the socket cannot carry, written over HTTP on the same policy
2746
- // the signed-in path runs: refuse locally what no write can hold, and
2747
- // re-assert a capacity refusal on the shared backoff before reporting it.
2748
- async function writeOversizedGuestOp(
2749
- opBlobB64: string,
2750
- clientOpId: string,
2751
- ): Promise<void> {
2752
- // Yield before any outcome resolves the op. The hook's caller records
2753
- // the outstanding op AFTER the hook returns, and an async function body
2754
- // runs synchronously up to its first await, so an exit taken inline
2755
- // would find nothing tracked and leave the entry behind with
2756
- // `savingCount` stuck above zero.
2757
- await Promise.resolve();
2758
-
2759
- // Both sizes are known here, so refuse without spending a round trip
2760
- // on a blob that can only come back 400.
2761
- if (opBlobExceedsDurableLimit(opBlobB64)) {
2762
- doc._failOutstandingOp(clientOpId, TOO_LARGE_MESSAGE);
2763
- return;
2764
- }
2765
- if (opBlobNeedsStaging(opBlobB64)) {
2766
- doc._failOutstandingOp(clientOpId, LINK_OP_TOO_LARGE_MESSAGE);
2767
- return;
2768
- }
2769
-
2770
- let status = 0;
2771
- let parsed: unknown = undefined;
2772
- for (let round = 0; round <= UNAVAILABLE_MAX_ROUNDS; round += 1) {
2773
- if (round > 0) {
2774
- await new Promise((resolve) => {
2775
- const timer = setTimeout(resolve, unavailableDelayMs(round));
2776
- (timer as { unref?: () => void }).unref?.();
2777
- });
2778
- }
2779
- try {
2780
- ({ status, parsed } = await postLinkOp(
2781
- token,
2782
- lineId,
2783
- opBlobB64,
2784
- clientOpId,
2785
- ));
2786
- } catch (err) {
2787
- // A throw leaves it unknown whether the handler ran, and this op
2788
- // carries no idempotency the server dedupes on, so replaying it
2789
- // could double-apply.
2790
- doc._failOutstandingOp(
2791
- clientOpId,
2792
- err instanceof Error ? err.message : String(err),
2793
- );
2794
- return;
2795
- }
2796
- if (status >= 200 && status < 300) break;
2797
- if (!opWriteIsRetryable(status)) break;
2798
- }
2799
- if (status < 200 || status >= 300) {
2800
- doc._failOutstandingOp(
2801
- clientOpId,
2802
- opWriteIsRetryable(status)
2803
- ? UNAVAILABLE_GIVE_UP_MESSAGE
2804
- : opWriteFailureMessage(status, parsed),
2805
- );
2806
- return;
2807
- }
2808
- doc.resolveOutstandingOp(clientOpId);
3527
+ body: JSON.stringify(body),
3528
+ });
3529
+ const text = await response.text();
3530
+ let parsed: unknown = undefined;
3531
+ try {
3532
+ parsed = text ? JSON.parse(text) : undefined;
3533
+ } catch {
3534
+ parsed = undefined;
2809
3535
  }
2810
-
2811
- if (canEdit) {
2812
- const minter = opts.newClientOpId ?? defaultClientOpIdMinter;
2813
- doc.setLocalOpHook((opBlob) => {
2814
- const clientOpId = minter();
2815
- const opBlobB64 = bytesToBase64(opBlob);
2816
- const sendFrame: OpFrame = {
2817
- type: 'op',
2818
- topic,
2819
- op_blob: opBlobB64,
2820
- client_op_id: clientOpId,
2821
- };
2822
- // A frame past the transport's cap is not delivered and takes the
2823
- // connection down with it, so it must never reach the socket: a
2824
- // guest's document, presence and every other op ride that one
2825
- // connection. `mutate` batches to a budget well above what a frame
2826
- // carries, so an ordinary bulk edit arrives here.
2827
- if (opFrameExceedsWsLimit(sendFrame)) {
2828
- void writeOversizedGuestOp(opBlobB64, clientOpId);
2829
- return clientOpId;
2830
- }
2831
- outstanding.set(clientOpId, sendFrame);
2832
- wsClient.send(sendFrame);
2833
- return clientOpId;
2834
- });
3536
+ if (response.status < 200 || response.status >= 300) {
3537
+ throw brassErrorFromBody(response.status, parsed);
2835
3538
  }
2836
- const originalClose = doc.close.bind(doc);
2837
- (doc as Document & { close: () => void }).close = () => {
2838
- clearShedRecovery();
2839
- try {
2840
- unsub();
2841
- } catch {
2842
- // Already detached; closing the Document is what matters.
2843
- }
2844
- originalClose();
2845
- };
2846
- }
2847
-
2848
- // Guest documents with a content pull running, so a repeated terminal frame
2849
- // does not stack a second pull on one document.
2850
- const guestPullInFlight = new WeakSet<Document>();
2851
-
2852
- function pullGuestContent(
2853
- doc: Document,
2854
- token: string,
2855
- lineId: string,
2856
- finalStatus: DocumentWorkStatus,
2857
- ): void {
2858
- if (guestPullInFlight.has(doc)) return;
2859
- guestPullInFlight.add(doc);
2860
- void (async () => {
2861
- await pullContentWithRetry({
2862
- doc,
2863
- docId: doc.id ?? '',
2864
- finalStatus,
2865
- fetchAndImport: () => importLinkView(doc, token, lineId),
2866
- ...(opts.reportError ? { reportError: opts.reportError } : {}),
2867
- });
2868
- guestPullInFlight.delete(doc);
2869
- })();
3539
+ return parsed as T;
2870
3540
  }
2871
3541
 
2872
- // Re-read the link view and import it into the live guest Document: the
2873
- // anonymous counterpart of the signed-in `refetchAndImport`, over the same
2874
- // envelope. Named on the line the guest opened, which is the line its topic
2875
- // is scoped to.
2876
- async function importLinkView(
2877
- doc: Document,
3542
+ // Write a guest's op over HTTP: `POST /links/{token}/ops`, the anonymous
3543
+ // counterpart of `POST /documents/{docId}/ops`. Sends NO Authorization
3544
+ // header (the token is the credential) and names the line the guest
3545
+ // opened, which is the branch its socket topic is scoped to. An op past
3546
+ // what a request body carries is staged first, as a member's is.
3547
+ async function postLinkOp(
2878
3548
  token: string,
3549
+ docId: string,
2879
3550
  lineId: string,
2880
- ): Promise<{ ok: true } | { ok: false; reason: string }> {
2881
- const result = await fetchLinkView(token, lineId);
2882
- if (result.status < 200 || result.status >= 300) {
2883
- return { ok: false, reason: `link view returned ${result.status}` };
2884
- }
2885
- const b = result.parsed as {
2886
- snapshot?: unknown;
2887
- tail_ops?: unknown;
2888
- };
2889
- if (!Array.isArray(b.tail_ops)) {
2890
- return { ok: false, reason: 'malformed link view response' };
2891
- }
2892
- const snap = b.snapshot as DocumentSnapshotResponse['snapshot'];
2893
- let snapshotBytes: Uint8Array | null = null;
2894
- if (snap !== null && snap !== undefined && snap.kind === 'inline') {
2895
- snapshotBytes = base64ToBytes(snap.data_b64);
2896
- } else if (snap !== null && snap !== undefined && snap.kind === 'presigned') {
2897
- try {
2898
- snapshotBytes = await fetchPresignedSnapshot(snap.url);
2899
- } catch (err) {
2900
- const status = err instanceof BrassError ? err.status ?? 0 : 0;
2901
- return {
2902
- ok: false,
2903
- reason:
2904
- status > 0
2905
- ? `presigned snapshot fetch returned ${status}`
2906
- : `presigned snapshot fetch failed: ${err instanceof Error ? err.message : String(err)}`,
2907
- };
2908
- }
2909
- }
2910
- if (snapshotBytes) doc._applySnapshot(snapshotBytes);
2911
- const tail = b.tail_ops as DocumentSnapshotResponse['tail_ops'];
2912
- for (const entry of [...tail].sort((x, y) => x.seq - y.seq)) {
2913
- doc.applyRemoteOp(base64ToBytes(entry.op_blob_b64));
3551
+ opBlobB64: string,
3552
+ clientOpId: string,
3553
+ base: { base_import_seq?: number } = {},
3554
+ ): Promise<{ status: number; parsed: unknown }> {
3555
+ const stagedKey = opBlobNeedsStaging(opBlobB64)
3556
+ ? await stageOpBlob(docId, opBlobB64, { link: token })
3557
+ : undefined;
3558
+ const headers: Record<string, string> = { 'content-type': 'application/json' };
3559
+ applyClientVersionHeader(headers);
3560
+ const response = await fetch(
3561
+ `${baseUrl}/links/${encodeURIComponent(token)}/ops`,
3562
+ {
3563
+ method: 'POST',
3564
+ headers,
3565
+ body: JSON.stringify({
3566
+ ...(stagedKey === undefined ? { op_blob: opBlobB64 } : { upload_key: stagedKey }),
3567
+ branch_id: lineId,
3568
+ client_op_id: clientOpId,
3569
+ ...base,
3570
+ }),
3571
+ },
3572
+ );
3573
+ const text = await response.text();
3574
+ let parsed: unknown = undefined;
3575
+ try {
3576
+ parsed = text ? JSON.parse(text) : undefined;
3577
+ } catch {
3578
+ parsed = undefined;
2914
3579
  }
2915
- return { ok: true };
3580
+ return { status: response.status, parsed };
2916
3581
  }
2917
3582
 
3583
+
3584
+
2918
3585
  // Strict open: a 404 propagates as `BrassError`. A `version` option
2919
3586
  // diverts to the read-only at-version path.
2920
- const open: OpenDocumentByIdFn = async (docId, options) => {
3587
+ const open: OpenDocumentByIdFn = async (docId, asked) => {
3588
+ if (asked?.frozen === true && asked.readOnly === false) {
3589
+ throw new TypeError('openDocumentById: a frozen copy is read-only, so `readOnly: false` cannot apply');
3590
+ }
3591
+ const requested = withReadIntent(asked);
3592
+ const options = requested?.frozen === true ? frozenOptions(requested) : requested;
3593
+ if (requested?.frozen === true) return openFrozen(docId, options!);
2921
3594
  // Cache hit: return the parked Document. Its subscription stayed alive
2922
3595
  // while parked, and the UndoController + outstanding-op map survived, so
2923
3596
  // undo works as if the user never switched away.
@@ -2938,12 +3611,47 @@ export function createOpenDocument(
2938
3611
  endTotal({ docId, path: 'at-version' });
2939
3612
  return atVersion;
2940
3613
  }
2941
- const snapshot = await fetchSnapshot(docId, options, token);
3614
+ // An editable open waits until the document takes edits. One that never
3615
+ // will is refused terminally, and is then read as a reader would read it.
3616
+ // An import the platform runs holds every open until it lands, the file's
3617
+ // first import or a new version's, so the open resolves with its content.
3618
+ let snapshot = await fetchSnapshot(docId, options, token);
3619
+ snapshot = await joinPastWork(docId, options, snapshot, async () =>
3620
+ fetchSnapshot(docId, options, await requireToken()),
3621
+ );
3622
+ const refused = joinRefusalOf(snapshot);
3623
+ if (refused?.kind === 'at_capacity') {
3624
+ const held = await openTooLarge(docId, options, refused.error);
3625
+ endTotal({ docId, path: 'read-only:at_capacity' });
3626
+ return held;
3627
+ }
2942
3628
  const opened = await finalise(docId, snapshot, options);
2943
3629
  endTotal({ docId });
2944
3630
  return opened;
2945
3631
  };
2946
3632
 
3633
+ // A writable open of a document too large to edit, joined again as a
3634
+ // reader and shown read-only.
3635
+ async function openTooLarge(
3636
+ docId: string,
3637
+ options: OpenDocumentByIdOptions | undefined,
3638
+ error: string | undefined,
3639
+ ): Promise<OpenedDocument> {
3640
+ const readOptions: OpenDocumentByIdOptions = {
3641
+ ...options,
3642
+ documentOptions: {
3643
+ ...options?.documentOptions,
3644
+ readOnly: true,
3645
+ readOnlyReason: 'too_large',
3646
+ },
3647
+ };
3648
+ const snapshot = await fetchSnapshot(docId, readOptions, await requireToken());
3649
+ const doc = await finalise(docId, snapshot, readOptions);
3650
+ doc._markAtCapacity();
3651
+ if (error !== undefined) doc._emitStatus({ message: error, pending: false, blocking: false });
3652
+ return doc;
3653
+ }
3654
+
2947
3655
  // Create a doc and return it fully editable. Two round trips (POST to
2948
3656
  // create + obtain the doc_id, then `open(docId)`); they aren't fused
2949
3657
  // because the create response is metadata-only and the open path already
@@ -2980,7 +3688,290 @@ export function createOpenDocument(
2980
3688
  return open(result.docId, openOptions);
2981
3689
  };
2982
3690
 
2983
- return { open, create, createDocumentId, openByLink };
3691
+ // The mint's answer is the claim secret's only copy, so it is kept as it
3692
+ // arrives, in the browser that made the document.
3693
+ const rememberClaimSecret = (minted: MintedAnonymousDocument): MintedAnonymousDocument => {
3694
+ if (minted.claimSecret !== undefined) {
3695
+ storeClaimSecret(opts.appId, minted.linkToken, minted.claimSecret, minted.expiresAt);
3696
+ }
3697
+ return minted;
3698
+ };
3699
+
3700
+ const mintAnonymousDocument: DocumentFactory['mintAnonymousDocument'] =
3701
+ async (args) => {
3702
+ const body: Record<string, unknown> = { app_id: opts.appId };
3703
+ if (args.name !== undefined) body['name'] = args.name;
3704
+ const headers: Record<string, string> = {
3705
+ 'content-type': 'application/json',
3706
+ };
3707
+ applyClientVersionHeader(headers);
3708
+ // No idempotency key on this route, so a replay could mint twice;
3709
+ // only the gateway's own 503 (the handler never ran) is replayed.
3710
+ const response = await fetchWithRetry(
3711
+ () =>
3712
+ fetch(`${baseUrl}/anonymous-documents`, {
3713
+ method: 'POST',
3714
+ headers,
3715
+ body: JSON.stringify(body),
3716
+ }),
3717
+ { policy: RETRY_WRITE_503, baseDelayMs: 250 },
3718
+ );
3719
+ const text = await response.text();
3720
+ let parsed: unknown = undefined;
3721
+ try {
3722
+ parsed = text ? JSON.parse(text) : undefined;
3723
+ } catch {
3724
+ parsed = undefined;
3725
+ }
3726
+ if (response.status < 200 || response.status >= 300) {
3727
+ throw brassErrorFromBody(response.status, parsed);
3728
+ }
3729
+ return rememberClaimSecret(parseMintedAnonymousDocument(parsed));
3730
+ };
3731
+
3732
+ // The unauthenticated JSON calls a visitor's file-started document makes.
3733
+ const postAnonymous = async (
3734
+ path: string,
3735
+ body: Record<string, unknown>,
3736
+ ): Promise<unknown> => {
3737
+ const headers: Record<string, string> = { 'content-type': 'application/json' };
3738
+ applyClientVersionHeader(headers);
3739
+ const response = await fetchWithRetry(
3740
+ () =>
3741
+ fetch(`${baseUrl}${path}`, {
3742
+ method: 'POST',
3743
+ headers,
3744
+ body: JSON.stringify(body),
3745
+ }),
3746
+ { policy: RETRY_WRITE_503, baseDelayMs: 250 },
3747
+ );
3748
+ const parsed = await readJsonBody(response);
3749
+ if (response.status < 200 || response.status >= 300) {
3750
+ throw brassErrorFromBody(response.status, parsed);
3751
+ }
3752
+ return parsed;
3753
+ };
3754
+
3755
+ const anonymousUploadTypes: DocumentFactory['anonymousUploadTypes'] = async () => {
3756
+ const headers: Record<string, string> = {};
3757
+ applyClientVersionHeader(headers);
3758
+ // `refusal=body` answers an app's refusal of signed-out use in a 200 body,
3759
+ // which keeps an expected answer out of the visitor's console.
3760
+ const response = await fetchWithRetry(
3761
+ () =>
3762
+ fetch(
3763
+ `${baseUrl}/anonymous-documents/upload-types?app_id=${encodeURIComponent(opts.appId)}&refusal=body`,
3764
+ { headers },
3765
+ ),
3766
+ { policy: RETRY_WRITE_503, baseDelayMs: 250 },
3767
+ );
3768
+ const parsed = await readJsonBody(response);
3769
+ if (response.status < 200 || response.status >= 300) {
3770
+ throw brassErrorFromBody(response.status, parsed);
3771
+ }
3772
+ const body = parsed as { refusal?: unknown; offers_new?: unknown } | null;
3773
+ const offersNew = body?.offers_new !== false;
3774
+ if (body?.refusal !== undefined) {
3775
+ return { offersNew, refusal: brassErrorFromBody(409, body.refusal) };
3776
+ }
3777
+ return { offersNew, accept: acceptFromUploadTypes(parsed) };
3778
+ };
3779
+
3780
+ const mintAnonymousDocumentFromFile: DocumentFactory['mintAnonymousDocumentFromFile'] =
3781
+ async (args) => {
3782
+ const minted = (await postAnonymous('/anonymous-documents/upload-url', {
3783
+ app_id: opts.appId,
3784
+ file_name: args.file.name,
3785
+ byte_size: args.file.size,
3786
+ })) as { upload_url?: unknown; upload_key?: unknown } | undefined;
3787
+ if (typeof minted?.upload_url !== 'string' || typeof minted.upload_key !== 'string') {
3788
+ throw new BrassError('Malformed upload response', { status: 500 });
3789
+ }
3790
+ await putFile(minted.upload_url, args.file, args.onUploadProgress);
3791
+ const body: Record<string, unknown> = {
3792
+ app_id: opts.appId,
3793
+ upload_key: minted.upload_key,
3794
+ file_name: args.file.name,
3795
+ };
3796
+ if (args.name !== undefined) body['name'] = args.name;
3797
+ return rememberClaimSecret(
3798
+ parseMintedAnonymousDocument(
3799
+ await postAnonymous('/anonymous-documents/from-upload', body),
3800
+ ),
3801
+ );
3802
+ };
3803
+
3804
+ // One client id per page, which is what holds a claim across the calls
3805
+ // of one import (the signed-in namespace keeps its own).
3806
+ const guestImportClientId = randomToken();
3807
+ const guestImports: DocumentFactory['guestImports'] = (token, docId) => {
3808
+ const base = `${baseUrl}/documents/${encodeURIComponent(docId)}`;
3809
+ const call = async <T>(method: 'GET' | 'POST', path: string, body?: unknown): Promise<T> => {
3810
+ const headers: Record<string, string> = { authorization: `Link ${token}` };
3811
+ if (body !== undefined) headers['content-type'] = 'application/json';
3812
+ applyClientVersionHeader(headers);
3813
+ const response = await fetchWithRetry(
3814
+ () =>
3815
+ fetch(`${base}${path}`, {
3816
+ method,
3817
+ headers,
3818
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
3819
+ }),
3820
+ { policy: RETRY_WRITE_503, baseDelayMs: 250 },
3821
+ );
3822
+ const parsed = await readJsonBody(response);
3823
+ if (response.status < 200 || response.status >= 300) {
3824
+ throw brassErrorFromBody(response.status, parsed);
3825
+ }
3826
+ return parsed as T;
3827
+ };
3828
+ return {
3829
+ claimImport: () =>
3830
+ call<ImportClaimResponse>('POST', '/import-claim', {
3831
+ client_id: guestImportClientId,
3832
+ stages: true,
3833
+ }),
3834
+ async stageImport(bundle) {
3835
+ const staged = await call<{ upload_key: string; url: string }>(
3836
+ 'POST',
3837
+ '/import-complete/upload',
3838
+ { byte_size: bundle.byteLength },
3839
+ );
3840
+ const put = await fetch(staged.url, {
3841
+ method: 'PUT',
3842
+ body: bundle.buffer.slice(
3843
+ bundle.byteOffset,
3844
+ bundle.byteOffset + bundle.byteLength,
3845
+ ) as ArrayBuffer,
3846
+ });
3847
+ if (!put.ok) {
3848
+ throw new BrassError('Uploading the import failed', { status: put.status });
3849
+ }
3850
+ return staged.upload_key;
3851
+ },
3852
+ completeImport: async (report, uploadKey) =>
3853
+ awaitSeal(
3854
+ await call<unknown>('POST', '/import-complete', {
3855
+ client_id: guestImportClientId,
3856
+ ...(report !== undefined ? { report } : {}),
3857
+ upload_key: uploadKey,
3858
+ }),
3859
+ (sealId) => call<SealStatus>('GET', `/seals/${encodeURIComponent(sealId)}`),
3860
+ ),
3861
+ failImport: (message) =>
3862
+ call<ImportFailedResponse>('POST', '/import-failed', {
3863
+ client_id: guestImportClientId,
3864
+ message,
3865
+ }),
3866
+ sourceBlob: () => call<DocumentSourceBlob>('GET', '/source-blob'),
3867
+ };
3868
+ };
3869
+
3870
+ const claimAnonymousDocument: DocumentFactory['claimAnonymousDocument'] =
3871
+ async (docId, args) => {
3872
+ const token = await requireToken();
3873
+ const body: Record<string, unknown> = {
3874
+ link_token: args.linkToken,
3875
+ claim_secret: args.claimSecret,
3876
+ };
3877
+ if (args.destination !== undefined) body['destination'] = args.destination;
3878
+ if (args.name !== undefined) body['name'] = args.name;
3879
+ const headers: Record<string, string> = {
3880
+ authorization: `Bearer ${token}`,
3881
+ 'content-type': 'application/json',
3882
+ };
3883
+ applyClientVersionHeader(headers);
3884
+ await applyAppAssertionHeader(headers, opts.getAppAssertion);
3885
+ const response = await fetchWithRetry(
3886
+ () =>
3887
+ fetch(`${baseUrl}/documents/${encodeURIComponent(docId)}/claim`, {
3888
+ method: 'POST',
3889
+ headers,
3890
+ body: JSON.stringify(body),
3891
+ }),
3892
+ { policy: RETRY_WRITE_503, baseDelayMs: 250 },
3893
+ );
3894
+ const text = await response.text();
3895
+ let parsed: unknown = undefined;
3896
+ let parseFailed = false;
3897
+ if (text) {
3898
+ try {
3899
+ parsed = JSON.parse(text);
3900
+ } catch {
3901
+ parseFailed = true;
3902
+ }
3903
+ }
3904
+ if (response.status >= 200 && response.status < 300) {
3905
+ return { kind: 'claimed', record: selectionFromDetail(parsed) };
3906
+ }
3907
+ if (response.status === 409 && !parseFailed) {
3908
+ const code = (parsed as { code?: unknown })?.code;
3909
+ if (code === 'destination_required') return { kind: 'needs-destination' };
3910
+ if (code === 'no_organization') return { kind: 'needs-organization' };
3911
+ }
3912
+ throw brassErrorFromBody(response.status, parseFailed ? undefined : parsed);
3913
+ };
3914
+
3915
+ return {
3916
+ open,
3917
+ create,
3918
+ createDocumentId,
3919
+ openByLink,
3920
+ mintAnonymousDocument,
3921
+ anonymousUploadTypes,
3922
+ mintAnonymousDocumentFromFile,
3923
+ guestImports,
3924
+ claimAnonymousDocument,
3925
+ };
3926
+ }
3927
+
3928
+ // The claimed document in the picker's shape, read off the claim's
3929
+ // `DocumentDetail` answer; null when a field the shape needs is missing.
3930
+ function selectionFromDetail(parsed: unknown): SelectDocumentResult | null {
3931
+ const d = parsed as Record<string, unknown> | undefined | null;
3932
+ if (!d || typeof d !== 'object') return null;
3933
+ const str = (key: string): string | null =>
3934
+ typeof d[key] === 'string' ? (d[key] as string) : null;
3935
+ const docId = str('doc_id');
3936
+ const organizationId = str('organization_id');
3937
+ const name = str('name');
3938
+ const createdAt = str('created_at');
3939
+ const updatedAt = str('updated_at');
3940
+ const role = d['role'];
3941
+ const projectId = d['project_id'];
3942
+ if (
3943
+ docId === null ||
3944
+ organizationId === null ||
3945
+ name === null ||
3946
+ createdAt === null ||
3947
+ updatedAt === null ||
3948
+ (role !== 'owner' && role !== 'editor' && role !== 'viewer') ||
3949
+ (projectId !== null && typeof projectId !== 'string')
3950
+ ) {
3951
+ return null;
3952
+ }
3953
+ return {
3954
+ doc_id: docId,
3955
+ organization_id: organizationId,
3956
+ project_id: projectId ?? null,
3957
+ role,
3958
+ name,
3959
+ created_at: createdAt,
3960
+ updated_at: updatedAt,
3961
+ };
3962
+ }
3963
+
3964
+ // The expiry a link view carries for a document minted without an account,
3965
+ // or null for every other link.
3966
+ function anonymousExpiry(body: {
3967
+ anonymous?: unknown;
3968
+ expires_at?: unknown;
3969
+ }): Date | null {
3970
+ if (body.anonymous !== true || typeof body.expires_at !== 'string') {
3971
+ return null;
3972
+ }
3973
+ const at = new Date(body.expires_at);
3974
+ return Number.isNaN(at.getTime()) ? null : at;
2984
3975
  }
2985
3976
 
2986
3977
  interface WireDocTransportArgs {
@@ -3035,6 +4026,11 @@ interface WireDocTransportArgs {
3035
4026
  // is the safe point to reconcile a pending open whose terminal
3036
4027
  // `doc_status` frame may have been dropped. Absent in transport-only tests.
3037
4028
  reconcileStatusOnInit?: () => void;
4029
+ // Invoked on a `doc_status` frame that would lock a document not currently
4030
+ // pending: checks the row before applying it, since such a frame can be
4031
+ // the stream's late delivery of a phase the reconcile already read past.
4032
+ // Absent in transport-only tests, where the frame applies directly.
4033
+ verifyPendingStatus?: (status: DocumentWorkStatus) => void;
3038
4034
  // Invoked on every frame carrying server-side movement (`doc_init`, a
3039
4035
  // remote op). Evidence the document has content to pull for a client whose
3040
4036
  // content pull gave up. Absent in transport-only tests.
@@ -3173,7 +4169,13 @@ function takeParkedDocument(
3173
4169
  options: OpenDocumentByIdOptions | undefined,
3174
4170
  identity: string | null,
3175
4171
  ): Document | null {
4172
+ // A parked Document is a live, writable session with its subscription still
4173
+ // alive. Neither pinned open may take one: it would hand back a document
4174
+ // that receives ops and accepts writes, which is the opposite of what both
4175
+ // asked for. Parking itself already refuses them (`!doc.readOnly`), so this
4176
+ // is the other half of that.
3176
4177
  if (options?.version !== undefined) return null;
4178
+ if (options?.frozen === true) return null;
3177
4179
  const key = parkCacheKey(docId, options?.branch, options?.stream);
3178
4180
  if (!key) return null;
3179
4181
  const entry = parkedDocs.get(key);
@@ -3211,6 +4213,24 @@ export function clearParkedDocuments(): void {
3211
4213
  // a clean slate between cases.
3212
4214
  export function _resetParkCacheForTesting(): void {
3213
4215
  clearParkedDocuments();
4216
+ subscriptionEndedAt.clear();
4217
+ }
4218
+
4219
+ // When each doc topic last had its subscription ended by the server, so a
4220
+ // copy re-opened after one ends for good on a second inside the window. The
4221
+ // re-open asks the HTTP join, and a socket gate that keeps refusing what the
4222
+ // join admits would otherwise re-open the document in a loop.
4223
+ const SUBSCRIPTION_ENDED_REOPEN_WINDOW_MS = 60_000;
4224
+ const subscriptionEndedAt = new Map<string, number>();
4225
+
4226
+ function endedReasonFor(topic: string): 'moved' | 'deleted' {
4227
+ const now = Date.now();
4228
+ for (const [key, at] of subscriptionEndedAt) {
4229
+ if (now - at >= SUBSCRIPTION_ENDED_REOPEN_WINDOW_MS) subscriptionEndedAt.delete(key);
4230
+ }
4231
+ const recent = subscriptionEndedAt.has(topic);
4232
+ subscriptionEndedAt.set(topic, now);
4233
+ return recent ? 'deleted' : 'moved';
3214
4234
  }
3215
4235
 
3216
4236
  // Wire the WS transport onto a materialised Document. Three pieces:
@@ -3268,6 +4288,17 @@ export const RESEND_MAX_DELAY_MS = 30_000;
3268
4288
  // Exported for the fake-timer tests.
3269
4289
  export const RESEND_FORCE_CEILING_MS = 60_000;
3270
4290
 
4291
+ // How long one op may go unacked on the socket before the reader is told,
4292
+ // through `status`, that their changes are not saved. A server that answers
4293
+ // an op anonymously every time (an error frame naming no op) draws a resend
4294
+ // per backoff step, or a forced one per ceiling window, forever, while
4295
+ // `savingCount` stays above zero with nothing on screen saying why. Three
4296
+ // ceiling windows is past any loss a resend recovers from, since a lost op
4297
+ // is answered on its first forced resend. The notice changes nothing about
4298
+ // how the op is resent, and clears itself once nothing is waiting.
4299
+ // Exported for the fake-timer tests.
4300
+ export const UNANSWERED_OP_NOTICE_MS = 3 * RESEND_FORCE_CEILING_MS;
4301
+
3271
4302
  // Server-shed recovery policy, driven by `unavailable` error frames. The
3272
4303
  // frame names no op, so a round re-asserts every outstanding op at once and
3273
4304
  // any further frame arriving before that round runs is part of the same shed
@@ -3285,16 +4316,7 @@ const UNAVAILABLE_BASE_DELAY_MS = 1000;
3285
4316
  const UNAVAILABLE_MAX_DELAY_MS = 30_000;
3286
4317
  const UNAVAILABLE_GIVE_UP_MESSAGE =
3287
4318
  'The server could not store this change, and re-sending it did not ' +
3288
- 'help. Reload the document to continue from the last saved state.';
3289
-
3290
- // Reported for every op left behind when the server ends this document's
3291
- // subscription (it refused the subscribe, or evicted it again after the one
3292
- // silent re-subscribe a connection grants). The ops can never be acked on a
3293
- // subscription that is over, and a fresh one arrives only with a fresh open,
3294
- // so the app's cue is to reload.
3295
- const SUBSCRIPTION_ENDED_MESSAGE =
3296
- 'The live connection to this document ended, so this change was not ' +
3297
- 'saved. Reload the document to continue from the last saved state.';
4319
+ 'help. Reload to continue from the last saved state.';
3298
4320
 
3299
4321
  /** How long the Nth (1-based) recovery round waits before re-asserting. */
3300
4322
  function unavailableDelayMs(round: number): number {
@@ -3311,16 +4333,38 @@ const TOO_LARGE_MESSAGE =
3311
4333
  'update; store the bulk of it as a file instead, or split it across ' +
3312
4334
  'several records.';
3313
4335
 
3314
- // What a guest hears for an op no request body can carry. A signed-in
3315
- // editor stages such an op to storage first; that route is behind the
3316
- // sign-in gate, so the anonymous tier writes ops that fit one request.
3317
- const LINK_OP_TOO_LARGE_MESSAGE =
3318
- 'This change is too large to save over a shared link. Sign in to the ' +
3319
- 'document to make a change this size.';
3320
4336
 
3321
4337
  function wireDocTransport(args: WireDocTransportArgs): void {
3322
4338
  const topic = `doc:${args.docId}:${args.branchId}`;
3323
4339
 
4340
+ // A remote op over frozen content means the document is now a CRDT. The
4341
+ // re-join keeps the open's read intent and is served that CRDT, which
4342
+ // `applyJoin` swaps in under the same handles. A run that leaves the
4343
+ // document on frozen content (the join failed, or the document is showing
4344
+ // a version) falls back to the reload. Ops arriving during the run ask the
4345
+ // resync for another pass rather than starting a second upgrade.
4346
+ const resync = args.resyncFromSnapshot;
4347
+ if (resync !== undefined) {
4348
+ let upgradeRun: Promise<void> | null = null;
4349
+ args.doc._setStorageUpgrader(() => {
4350
+ if (upgradeRun !== null) {
4351
+ void Promise.resolve(resync()).catch(() => {});
4352
+ return;
4353
+ }
4354
+ upgradeRun = (async () => {
4355
+ try {
4356
+ await resync();
4357
+ } catch {
4358
+ // Judged by what the document holds afterwards.
4359
+ }
4360
+ const doc = args.doc;
4361
+ if (doc._holdsRecordSet() && !doc._isClosed && doc._goneReason === null) doc._reopen();
4362
+ })().finally(() => {
4363
+ upgradeRun = null;
4364
+ });
4365
+ });
4366
+ }
4367
+
3324
4368
  // Per-op retry tracker. The op-write endpoint can answer with an untyped
3325
4369
  // error frame instead of reaching the handler. That frame has no `type`, so
3326
4370
  // the multiplexer drops it,
@@ -3356,7 +4400,7 @@ function wireDocTransport(args: WireDocTransportArgs): void {
3356
4400
  const outstandingResends = new Map<
3357
4401
  string,
3358
4402
  {
3359
- frame: { type: 'op'; topic: string; op_blob: string; client_op_id: string };
4403
+ frame: OpFrame;
3360
4404
  attempts: number;
3361
4405
  timer: ReturnType<typeof setTimeout>;
3362
4406
  // When this op's current timer was armed, compared against the last
@@ -3374,6 +4418,9 @@ function wireDocTransport(args: WireDocTransportArgs): void {
3374
4418
  // neither queueing nor forgetting an op has to scan the queue, which
3375
4419
  // would be quadratic over a mutation that queues thousands.
3376
4420
  queued: boolean;
4421
+ // When this op was first put on the socket, or 0 before then. See
4422
+ // `UNANSWERED_OP_NOTICE_MS`.
4423
+ firstDispatchedAt: number;
3377
4424
  }
3378
4425
  >();
3379
4426
 
@@ -3422,6 +4469,7 @@ function wireDocTransport(args: WireDocTransportArgs): void {
3422
4469
  entry.queued = false;
3423
4470
  entry.inFlight = size;
3424
4471
  entry.dispatchedAt = Date.now();
4472
+ if (entry.firstDispatchedAt === 0) entry.firstDispatchedAt = entry.dispatchedAt;
3425
4473
  inFlightBytes += size;
3426
4474
  args.wsClient.send(entry.frame);
3427
4475
  scheduleResend(clientOpId);
@@ -3598,6 +4646,12 @@ function wireDocTransport(args: WireDocTransportArgs): void {
3598
4646
  entry.timer = setTimeout(() => {
3599
4647
  const current = outstandingResends.get(clientOpId);
3600
4648
  if (!current) return;
4649
+ if (
4650
+ current.firstDispatchedAt !== 0 &&
4651
+ Date.now() - current.firstDispatchedAt > UNANSWERED_OP_NOTICE_MS
4652
+ ) {
4653
+ args.doc._showConnectionNotice('unanswered');
4654
+ }
3601
4655
  const forced = forceResendDue(clientOpId, current);
3602
4656
  if (lastAckAt >= current.armedAt && !forced) {
3603
4657
  scheduleResend(clientOpId);
@@ -3720,6 +4774,25 @@ function wireDocTransport(args: WireDocTransportArgs): void {
3720
4774
 
3721
4775
  args.doc._setDiagnosticsProvider(collectDiagnostics);
3722
4776
 
4777
+ // A network that refuses WebSocket upgrades opens the document over HTTP
4778
+ // and then carries none of its edits: they apply locally and queue, and the
4779
+ // reconnect loop reports only after its full budget (about five minutes),
4780
+ // so a reload inside that window loses them with nothing said. When edits
4781
+ // have waited `NEVER_OPENED_NOTICE_MS` on a client that has never opened a
4782
+ // socket, the document says so on `status`. Never-opened rather than
4783
+ // currently-closed, because a connection that has worked recovers through
4784
+ // the resend machinery and a drop there is ordinarily brief.
4785
+ let neverOpenedTimer: ReturnType<typeof setTimeout> | null = null;
4786
+ function armNeverOpenedNotice(): void {
4787
+ if (neverOpenedTimer !== null || args.wsClient.everOpened !== false) return;
4788
+ neverOpenedTimer = setTimeout(() => {
4789
+ neverOpenedTimer = null;
4790
+ if (args.wsClient.everOpened !== false || outstandingResends.size === 0) return;
4791
+ args.doc._showConnectionNotice();
4792
+ }, NEVER_OPENED_NOTICE_MS);
4793
+ (neverOpenedTimer as { unref?: () => void }).unref?.();
4794
+ }
4795
+
3723
4796
  // Resend every outstanding op now and restart its backoff. Called on
3724
4797
  // reconnect (`doc_init`): a dropped-and-restored connection is the most
3725
4798
  // common reason an op went unacked, and the backoff may have grown to its
@@ -3782,15 +4855,18 @@ function wireDocTransport(args: WireDocTransportArgs): void {
3782
4855
  // and never apply, and its own escape for an op nothing can apply is to
3783
4856
  // discard it. Most refusals do not change that: the handshake pushes
3784
4857
  // whatever slice the durable frontier is missing and splits it under the
3785
- // frame cap, so it heals the dependents even where the op itself is
3786
- // irreducibly too large to re-send.
4858
+ // frame cap, so it heals the dependents of an op a transient refusal
4859
+ // dropped.
3787
4860
  //
3788
4861
  // `healable: false` is for the refusal where pushing again is asking the
3789
4862
  // same question. A full document refuses every op whatever its size, so
3790
4863
  // the handshake's own chunks come back refused, and the counter reset
3791
4864
  // below turns that into a loop rather than a bounded retry. What recovers
3792
4865
  // that document is the periodic re-verify, once the server can take a
3793
- // write at all.
4866
+ // write at all. An op too large for any transport this writer has is the
4867
+ // same: a split runs between changes, never inside one, so the slice the
4868
+ // handshake would push still carries the whole op, and every edit after it
4869
+ // waits on an op the server will never hold.
3794
4870
  // A refusal the heal cannot work around leaves the writer holding a change
3795
4871
  // that reached no database and no other reader, so the writer is the only
3796
4872
  // copy of the document that shows it. Left editable it reads as applied: the
@@ -3864,7 +4940,28 @@ function wireDocTransport(args: WireDocTransportArgs): void {
3864
4940
  } catch {
3865
4941
  // Told or not, the copy is resolved.
3866
4942
  }
3867
- args.doc.reload();
4943
+ args.doc._reopen();
4944
+ }
4945
+
4946
+ function markGone(reason: 'moved' | 'deleted'): void {
4947
+ // Cancel every resend timer FIRST so a doc that was saving when delete
4948
+ // landed stops re-trying ops the server will only reject as `forbidden`
4949
+ // (the subscription record was evicted at fanout). Draining before the `_markGone`
4950
+ // listeners keeps the lifecycle observably clean.
4951
+ forgetAllOps();
4952
+ clearUnavailableRecovery();
4953
+ // Stop re-verifying durability against an evicted doc: a resync push
4954
+ // would only bounce as `forbidden`, and a gap pull would only 404.
4955
+ if (reverifyTimer !== null) {
4956
+ clearInterval(reverifyTimer);
4957
+ reverifyTimer = null;
4958
+ }
4959
+ clearDivergenceHeal();
4960
+ if (gapTimer !== null) {
4961
+ clearTimeout(gapTimer);
4962
+ gapTimer = null;
4963
+ }
4964
+ args.doc._markGone(reason);
3868
4965
  }
3869
4966
 
3870
4967
  function failOpTerminally(
@@ -3914,6 +5011,25 @@ function wireDocTransport(args: WireDocTransportArgs): void {
3914
5011
  (unavailableTimer as { unref?: () => void }).unref?.();
3915
5012
  }
3916
5013
 
5014
+ // The ops the durability handshake leaves to this transport: those still
5015
+ // queued behind the in-flight budget, and those first sent too recently for
5016
+ // their resend timer to have doubted them. An op past its first resend step
5017
+ // is fair game for the handshake, since the resend path does not promise it
5018
+ // arrives.
5019
+ function deliveringOpBlobs(): Uint8Array[] {
5020
+ const now = Date.now();
5021
+ const blobs: Uint8Array[] = [];
5022
+ for (const entry of outstandingResends.values()) {
5023
+ if (
5024
+ entry.firstDispatchedAt === 0 ||
5025
+ now - entry.firstDispatchedAt < RESEND_BASE_DELAY_MS
5026
+ ) {
5027
+ blobs.push(base64ToBytes(entry.frame.op_blob));
5028
+ }
5029
+ }
5030
+ return blobs;
5031
+ }
5032
+
3917
5033
  // Durability handshake, run on every `doc_init`. The persisted version
3918
5034
  // vector + durable tail ops define the durable frontier; if the local doc
3919
5035
  // holds CRDT knowledge beyond it (an op acked then discarded by the
@@ -3930,6 +5046,7 @@ function wireDocTransport(args: WireDocTransportArgs): void {
3930
5046
  const outcome = args.doc.maybeResyncPush(
3931
5047
  base64ToBytes(snapshotVersionVectorB64),
3932
5048
  durableTailBlobs,
5049
+ deliveringOpBlobs(),
3933
5050
  );
3934
5051
  // Reported here as well as from the re-verify, or the record holds only
3935
5052
  // the heals that took the slower route and an operator reading it cannot
@@ -3970,6 +5087,7 @@ function wireDocTransport(args: WireDocTransportArgs): void {
3970
5087
  outcome = args.doc.maybeResyncPush(
3971
5088
  base64ToBytes(frontier.snapshotVersionVector),
3972
5089
  frontier.tailBlobs,
5090
+ deliveringOpBlobs(),
3973
5091
  );
3974
5092
  // The handshake having work to do means the server's copy was
3975
5093
  // behind this client's, which is the state a lost op leaves and
@@ -4098,46 +5216,30 @@ function wireDocTransport(args: WireDocTransportArgs): void {
4098
5216
  gapPullRounds = 0;
4099
5217
  return;
4100
5218
  }
4101
- pullJoin();
5219
+ // The head can be this client's own op still being written over HTTP,
5220
+ // so the judgement waits for that write as a catch-up prompt does.
5221
+ resyncPastSeq(awaited);
4102
5222
  }, delay);
4103
5223
  (gapTimer as { unref?: () => void }).unref?.();
4104
5224
  }
4105
5225
 
4106
- // The server ended this subscription: it refused the subscribe (a revoked
4107
- // grant), or evicted it again after the one silent re-subscribe a
4108
- // connection grants. The client detaches this transport from the topic as
4109
- // it reports that, so no ack, no fanout and no reconnect reaches it again.
5226
+ // The server ended this subscription: it refused the subscribe (access
5227
+ // that ended between the HTTP join and the subscribe, which no eviction can
5228
+ // reach because no subscription row exists yet), or evicted it again after
5229
+ // the one silent re-subscribe a connection grants. The client detaches this
5230
+ // transport from the topic as it reports that, so no ack, no fanout and no
5231
+ // reconnect reaches it again.
4110
5232
  //
4111
- // Everything the transport is waiting on is therefore dead. The ops it
4112
- // holds can never be acked, and re-sending them puts frames on a
4113
- // subscription the server has refused while `savingCount` climbs and the
4114
- // app hears nothing, so they are failed here and every later edit fails
4115
- // the same way: the app reads one actionable error per unsaved change and
4116
- // its cue to reload, rather than a document that looks like it is saving
4117
- // forever. The ids are taken before the reports because
4118
- // `_failOutstandingOp` re-enters app code.
5233
+ // That is the same fact a `doc_gone` reports, so the copy ends the same
5234
+ // way: `moved`, which a session answers by re-opening the id, so the
5235
+ // current gate re-admits a reader who still reaches the document and
5236
+ // refuses one who does not. A second end inside the window is `deleted`,
5237
+ // which ends the copy without the re-open.
4119
5238
  let subscriptionEnded = false;
4120
5239
  function endSubscription(): void {
4121
5240
  if (subscriptionEnded) return;
4122
5241
  subscriptionEnded = true;
4123
- const clientOpIds = [...outstandingResends.keys()];
4124
- forgetAllOps();
4125
- clearUnavailableRecovery();
4126
- // The heal and the gap pull both work through this transport, so they
4127
- // are stopped rather than armed: what they would push or fetch has no
4128
- // subscription left to travel on.
4129
- clearDivergenceHeal();
4130
- if (reverifyTimer !== null) {
4131
- clearInterval(reverifyTimer);
4132
- reverifyTimer = null;
4133
- }
4134
- if (gapTimer !== null) {
4135
- clearTimeout(gapTimer);
4136
- gapTimer = null;
4137
- }
4138
- for (const clientOpId of clientOpIds) {
4139
- args.doc._failOutstandingOp(clientOpId, SUBSCRIPTION_ENDED_MESSAGE);
4140
- }
5242
+ markGone(endedReasonFor(topic));
4141
5243
  }
4142
5244
 
4143
5245
  // 1. Subscribe to the doc topic. Frame routing:
@@ -4161,12 +5263,21 @@ function wireDocTransport(args: WireDocTransportArgs): void {
4161
5263
  (frame) => {
4162
5264
  const init = parseDocInitFrame(frame);
4163
5265
  if (init) {
5266
+ // A new version replaced the line while this copy was away (the
5267
+ // `doc_gone` naming it went to a socket that was down). Its tail is
5268
+ // the new version's, and nothing this copy holds or would re-assert
5269
+ // applies to it, so it reopens before applying or resending anything.
5270
+ if (importSeqMoved(args.doc, init.import_seq)) {
5271
+ markGone('moved');
5272
+ return;
5273
+ }
4164
5274
  // Recorded as the frame arrives rather than after the tail applies.
4165
5275
  // Importing a tail op can throw, and the client swallows it, so a
4166
5276
  // latch written further down would stay behind and read the NEXT
4167
5277
  // reconnect as the opening subscribe, suppressing the report this
4168
5278
  // exists to make.
4169
5279
  const reconnects = args.wsClient.reconnectCount ?? 0;
5280
+ const wasSubscribed = docInitReconnectCount !== null;
4170
5281
  const socketDropped =
4171
5282
  docInitReconnectCount !== null && reconnects > docInitReconnectCount;
4172
5283
  docInitReconnectCount = reconnects;
@@ -4255,6 +5366,9 @@ function wireDocTransport(args: WireDocTransportArgs): void {
4255
5366
  reportSyncHealth('reconnected_unsaved');
4256
5367
  }
4257
5368
  resendAllOutstanding();
5369
+ // A nudge posted while the topic was down is gone, so every status
5370
+ // this document holds asks what moved since its revision.
5371
+ if (wasSubscribed) args.doc._inputStatusNudged(null, null);
4258
5372
  // The handshake needs the WHOLE durable tail, and this frame carries
4259
5373
  // it only when every entry came inline. A spilled op is named here
4260
5374
  // with its bytes withheld, and the HTTP join the reader already ran
@@ -4328,7 +5442,7 @@ function wireDocTransport(args: WireDocTransportArgs): void {
4328
5442
  // Evidence the connection is working through its queue, which is what
4329
5443
  // the resend timers read to tell a slow ack from a silent server.
4330
5444
  lastAckAt = Date.now();
4331
- args.doc.resolveOutstandingOp(frame.client_op_id);
5445
+ args.doc.resolveOutstandingOp(frame.client_op_id, frame.seq);
4332
5446
  args.setCurrentSeq(frame.seq);
4333
5447
  args.advanceCoveredSeq(frame.seq);
4334
5448
  // An ack for a local op sits above any remote op this client never
@@ -4351,6 +5465,13 @@ function wireDocTransport(args: WireDocTransportArgs): void {
4351
5465
  // the Document so the app hears "could not be saved" instead of a silent
4352
5466
  // wedge. Scoped to this one op; the topic subscription stays live.
4353
5467
  forgetOp(frame.client_op_id);
5468
+ // A new version replaced the line under this copy. Not healable and
5469
+ // not this op's alone: every op it holds names the replaced version,
5470
+ // so the copy reopens, which drops them all.
5471
+ if (frame.reason === OP_REJECTED_MOVED) {
5472
+ markGone('moved');
5473
+ return;
5474
+ }
4354
5475
  // The flip is the one moment the refusal is worth reporting: every op
4355
5476
  // in flight draws the same sentence, and a bulk write has as many of
4356
5477
  // those as it has unacked writes.
@@ -4376,30 +5497,13 @@ function wireDocTransport(args: WireDocTransportArgs): void {
4376
5497
  // slice under the frame cap, which is what reaches the dependents of
4377
5498
  // an op that is itself too large to re-send.
4378
5499
  failOpTerminally(frame.client_op_id, opRejectedMessage(frame), {
4379
- healable: !atCapacity,
5500
+ healable: !atCapacity && frame.reason !== OP_REJECTED_NOT_IMPORTED,
4380
5501
  report: !atCapacity || firstCapacityRefusal,
4381
5502
  });
4382
5503
  return;
4383
5504
  }
4384
5505
  if (isDocGoneFrame(frame)) {
4385
- // Cancel every resend timer FIRST so a doc that was saving when delete
4386
- // landed stops re-trying ops the server will only reject as `forbidden`
4387
- // (the subscription record was evicted at fanout). Draining before the `_markGone`
4388
- // listeners keeps the lifecycle observably clean.
4389
- forgetAllOps();
4390
- clearUnavailableRecovery();
4391
- // Stop re-verifying durability against an evicted doc: a resync push
4392
- // would only bounce as `forbidden`, and a gap pull would only 404.
4393
- if (reverifyTimer !== null) {
4394
- clearInterval(reverifyTimer);
4395
- reverifyTimer = null;
4396
- }
4397
- clearDivergenceHeal();
4398
- if (gapTimer !== null) {
4399
- clearTimeout(gapTimer);
4400
- gapTimer = null;
4401
- }
4402
- args.doc._markGone(goneReasonOf(frame));
5506
+ markGone(goneReasonOf(frame));
4403
5507
  return;
4404
5508
  }
4405
5509
  if (isDocStatusFrame(frame)) {
@@ -4411,6 +5515,13 @@ function wireDocTransport(args: WireDocTransportArgs): void {
4411
5515
  // nothing (no racy read, no loop).
4412
5516
  if (frame.reload && args.reloadAfterHydration) {
4413
5517
  args.reloadAfterHydration(status);
5518
+ } else if (
5519
+ status.pending &&
5520
+ status.blocking !== false &&
5521
+ args.doc._workStatusNow?.pending !== true &&
5522
+ args.verifyPendingStatus
5523
+ ) {
5524
+ args.verifyPendingStatus(status);
4414
5525
  } else {
4415
5526
  args.doc._emitStatus(status);
4416
5527
  }
@@ -4421,6 +5532,13 @@ function wireDocTransport(args: WireDocTransportArgs): void {
4421
5532
  args.doc._setName(frame.name);
4422
5533
  return;
4423
5534
  }
5535
+ const nudge = parseInputStatusNudge(frame);
5536
+ if (nudge !== null) {
5537
+ // The frame names the key and its new revision alone; the status
5538
+ // fetches what moved.
5539
+ args.doc._inputStatusNudged(nudge.key, nudge.rev);
5540
+ return;
5541
+ }
4424
5542
  if (isDocDriveStatusFrame(frame)) {
4425
5543
  // The signal de-dupes a no-op re-emit, so forwarding every frame is safe.
4426
5544
  args.doc._emitDriveStatus(toDriveStatus(frame.drive));
@@ -4469,33 +5587,14 @@ function wireDocTransport(args: WireDocTransportArgs): void {
4469
5587
  // tracker so a silently-dropped server response triggers a retry.
4470
5588
  args.doc.setLocalOpHook((opBlob) => {
4471
5589
  const clientOpId = args.newClientOpId();
4472
- // Nothing carries an op once the subscription is over, so it is failed
4473
- // instead of tracked: an entry nothing can ack pins `savingCount` and
4474
- // its resends reach a topic the server refused. The caller records the
4475
- // entry AFTER this hook returns, so the failure waits a microtask for
4476
- // there to be one to fail.
4477
- //
4478
- // Taken before the op is timed, because this path fails the op through
4479
- // the Document directly rather than through `failOpTerminally`, so a
4480
- // timing entry written here is one nothing ever drops.
4481
- if (subscriptionEnded) {
4482
- void Promise.resolve().then(() => {
4483
- args.doc._failOutstandingOp(clientOpId, SUBSCRIPTION_ENDED_MESSAGE);
4484
- });
4485
- return clientOpId;
4486
- }
4487
5590
  opStartedAt.set(clientOpId, Date.now());
4488
5591
  const opBlobB64 = bytesToBase64(opBlob);
4489
- const sendFrame: {
4490
- type: 'op';
4491
- topic: string;
4492
- op_blob: string;
4493
- client_op_id: string;
4494
- } = {
5592
+ const sendFrame: OpFrame = {
4495
5593
  type: 'op',
4496
5594
  topic,
4497
5595
  op_blob: opBlobB64,
4498
5596
  client_op_id: clientOpId,
5597
+ ...baseImportSeqField(args.doc),
4499
5598
  };
4500
5599
 
4501
5600
  // A frame past the transport's cap is not delivered and takes the
@@ -4519,8 +5618,10 @@ function wireDocTransport(args: WireDocTransportArgs): void {
4519
5618
  dispatchedAt: 0,
4520
5619
  inFlight: 0,
4521
5620
  queued: false,
5621
+ firstDispatchedAt: 0,
4522
5622
  });
4523
5623
  dispatchOp(clientOpId);
5624
+ armNeverOpenedNotice();
4524
5625
  return clientOpId;
4525
5626
  });
4526
5627
 
@@ -4552,12 +5653,12 @@ function wireDocTransport(args: WireDocTransportArgs): void {
4552
5653
  // The size is known here, so refuse without spending a round trip on a
4553
5654
  // blob that can only come back 400.
4554
5655
  if (opBlobExceedsDurableLimit(opBlobB64)) {
4555
- failOpTerminally(clientOpId, TOO_LARGE_MESSAGE);
5656
+ failOpTerminally(clientOpId, TOO_LARGE_MESSAGE, { healable: false });
4556
5657
  return;
4557
5658
  }
4558
5659
 
4559
5660
  if (!args.postOversizedOp) {
4560
- failOpTerminally(clientOpId, TOO_LARGE_MESSAGE);
5661
+ failOpTerminally(clientOpId, TOO_LARGE_MESSAGE, { healable: false });
4561
5662
  return;
4562
5663
  }
4563
5664
  // An oversized op is deliberately kept out of `outstandingResends` (a
@@ -4596,6 +5697,11 @@ function wireDocTransport(args: WireDocTransportArgs): void {
4596
5697
  opStartedAt.delete(clientOpId);
4597
5698
  return;
4598
5699
  }
5700
+ if (!result.ok && result.moved === true) {
5701
+ opStartedAt.delete(clientOpId);
5702
+ markGone('moved');
5703
+ return;
5704
+ }
4599
5705
  if (!result.ok) {
4600
5706
  // Terminal for this op, the same way `op_rejected` is: the local
4601
5707
  // commit stays applied but never persists, so the app hears about it
@@ -4609,7 +5715,10 @@ function wireDocTransport(args: WireDocTransportArgs): void {
4609
5715
  clientOpId,
4610
5716
  result.retryable ? UNAVAILABLE_GIVE_UP_MESSAGE : result.message,
4611
5717
  {
4612
- healable: result.atCapacity !== true,
5718
+ healable:
5719
+ result.atCapacity !== true &&
5720
+ result.notImported !== true &&
5721
+ result.tooLarge !== true,
4613
5722
  report: result.atCapacity !== true || firstCapacityRefusal,
4614
5723
  },
4615
5724
  );
@@ -4618,7 +5727,7 @@ function wireDocTransport(args: WireDocTransportArgs): void {
4618
5727
  // Same bookkeeping an `op_ack` does: resolve the outstanding entry and
4619
5728
  // advance both cursors past the seq the server allocated.
4620
5729
  opStartedAt.delete(clientOpId);
4621
- args.doc.resolveOutstandingOp(clientOpId);
5730
+ args.doc.resolveOutstandingOp(clientOpId, result.seq);
4622
5731
  args.setCurrentSeq(result.seq);
4623
5732
  args.advanceCoveredSeq(result.seq);
4624
5733
  }
@@ -4674,6 +5783,10 @@ function wireDocTransport(args: WireDocTransportArgs): void {
4674
5783
  clearInterval(reverifyTimer);
4675
5784
  reverifyTimer = null;
4676
5785
  }
5786
+ if (neverOpenedTimer !== null) {
5787
+ clearTimeout(neverOpenedTimer);
5788
+ neverOpenedTimer = null;
5789
+ }
4677
5790
  clearDivergenceHeal();
4678
5791
  if (gapTimer !== null) {
4679
5792
  clearTimeout(gapTimer);
@@ -4710,7 +5823,7 @@ function wireDocTransport(args: WireDocTransportArgs): void {
4710
5823
  const parkable =
4711
5824
  !args.doc.readOnly &&
4712
5825
  args.parkable !== false &&
4713
- args.doc.status?.pending !== true &&
5826
+ args.doc._workStatusNow?.pending !== true &&
4714
5827
  args.doc._goneReason === null;
4715
5828
  const cacheKey = parkable
4716
5829
  ? parkCacheKey(args.docId, args.requestedBranchId, args.requestedStream)
@@ -4786,18 +5899,237 @@ async function fetchPresignedSnapshot(
4786
5899
  // legible upgrade prompt (rendered verbatim through `session.error`), never an
4787
5900
  // empty document: treating an unreadable snapshot as "no snapshot" would let
4788
5901
  // the user edit and overwrite content that is actually there.
4789
- function snapshotKindTooNewError(): BrassError {
4790
- return new BrassError(
4791
- 'This document needs a newer version of this app. Update to the latest @brass-build/client to open it.',
4792
- { status: 426 },
4793
- );
5902
+ /** Whether the server names a head above the seq its snapshot was sealed at,
5903
+ * which is state the payload has not delivered. Read beside `tail_ops`, so a
5904
+ * response that dropped its tail entries for budget is still seen as having
5905
+ * one rather than as a document that stops at its base. A server that sends
5906
+ * neither seq answers by its entries alone, which is all it ever had. */
5907
+ function coversMoreThanSnapshot(
5908
+ body: Pick<DocumentSnapshotResponse, 'current_seq' | 'snapshot_seq'>,
5909
+ ): boolean {
5910
+ const { current_seq: current, snapshot_seq: sealed } = body;
5911
+ return current !== undefined && sealed !== undefined && current > sealed;
5912
+ }
5913
+
5914
+ /** The open with its top-level `readOnly` folded into `documentOptions`,
5915
+ * which is where every path below reads the intent from. The top-level
5916
+ * value wins over `documentOptions.readOnly` when both are set. */
5917
+ function withReadIntent(
5918
+ options: OpenDocumentByIdOptions | undefined,
5919
+ ): OpenDocumentByIdOptions | undefined {
5920
+ if (options?.readOnly === undefined) return options;
5921
+ const { readOnly, ...rest } = options;
5922
+ const { readOnly: _alias, ...documentOptions } = rest.documentOptions ?? {};
5923
+ return {
5924
+ ...rest,
5925
+ documentOptions: readOnly ? { ...documentOptions, readOnly: true } : documentOptions,
5926
+ };
5927
+ }
5928
+
5929
+ /** Whether this open will never write: the caller asked for a snapshot, or
5930
+ * for a read-only Document. The grant is not consulted, because a caller who
5931
+ * may edit and says nothing is one that may edit. */
5932
+ function opensReadOnly(options: OpenDocumentByIdOptions | undefined): boolean {
5933
+ return options?.frozen === true || options?.documentOptions?.readOnly === true;
5934
+ }
5935
+
5936
+ /** The options every re-join of `branch` sends, a member's and a guest's
5937
+ * alike: the line it names, and the read intent of the open it serves, so a
5938
+ * document opened read-only is re-joined read-only and one opened to write
5939
+ * is re-joined to write. */
5940
+ function rejoinOptions(
5941
+ branch: string | undefined,
5942
+ open: OpenDocumentByIdOptions | undefined,
5943
+ ): OpenDocumentByIdOptions {
5944
+ const base = branch !== undefined ? { branch } : {};
5945
+ return opensReadOnly(open) ? { ...base, documentOptions: { readOnly: true } } : base;
5946
+ }
5947
+
5948
+ /** What re-joining an open document did to it. */
5949
+ type JoinApplied =
5950
+ /** The join's content and tail were imported into the document. */
5951
+ | { kind: 'applied'; content: JoinContent; tailSeqs: number[] }
5952
+ /** The document holds the frozen content the join serves. */
5953
+ | { kind: 'unchanged' }
5954
+ /** The join serves content the document cannot take in place, so it was
5955
+ * reloaded. */
5956
+ | { kind: 'reloaded' };
5957
+
5958
+ /**
5959
+ * Apply a re-join to an open document: the reload, a line's resync and a
5960
+ * guest's pull all end here.
5961
+ *
5962
+ * CRDT content over a CRDT document merges in place. Over frozen content it is
5963
+ * a storage upgrade (`Document._upgradeStorage`), and frozen content sealed at
5964
+ * another seq replaces the frozen content; both keep the app's handles and
5965
+ * their subscribers. Frozen content served to a CRDT document, or content the
5966
+ * document cannot take in place, reloads it.
5967
+ */
5968
+ // The seal of frozen content that holds nothing yet: no join's seq matches it.
5969
+ const NO_SEAL = -1;
5970
+
5971
+ // The `import_seq` each CRDT copy was opened at. A new version's import
5972
+ // replaces a line's content with a CRDT authored by the same pinned peer as
5973
+ // the one it replaces, so merging the new content into an old copy dedupes it
5974
+ // away and leaves the old copy's ops pointing at containers the line no
5975
+ // longer has. A copy that sees the line under a different import reopens
5976
+ // instead, and names its base on every op so the server refuses one that
5977
+ // raced the import (`moved`).
5978
+ const openedImportSeq = new WeakMap<Document, number>();
5979
+
5980
+ function recordImportSeq(doc: Document, importSeq: unknown): void {
5981
+ if (typeof importSeq === 'number') openedImportSeq.set(doc, importSeq);
5982
+ }
5983
+
5984
+ function importSeqMoved(doc: Document, importSeq: unknown): boolean {
5985
+ const base = openedImportSeq.get(doc);
5986
+ return base !== undefined && typeof importSeq === 'number' && importSeq !== base;
5987
+ }
5988
+
5989
+ function baseImportSeqField(doc: Document): { base_import_seq?: number } {
5990
+ const base = openedImportSeq.get(doc);
5991
+ return base === undefined ? {} : { base_import_seq: base };
5992
+ }
5993
+
5994
+ async function applyJoin(
5995
+ doc: Document,
5996
+ body: {
5997
+ snapshot: unknown;
5998
+ tail_ops: Array<{ seq: number; op_blob_b64: string }>;
5999
+ snapshot_seq?: number;
6000
+ current_seq?: number;
6001
+ import_seq?: number;
6002
+ },
6003
+ fetchBody: ContentFetch,
6004
+ ): Promise<JoinApplied> {
6005
+ // Decided off the wire's own statement of the encoding, so content the
6006
+ // document will not keep is never downloaded.
6007
+ const encoding = joinEncoding(body.snapshot);
6008
+ // Frozen content replaces a reader's CRDT rather than merging into it, so a
6009
+ // new version's import is taken in place below.
6010
+ const replacesCrdt = encoding === 'frozen' && !doc._holdsRecordSet();
6011
+ if (!replacesCrdt && importSeqMoved(doc, body.import_seq)) {
6012
+ doc._reopen();
6013
+ return { kind: 'reloaded' };
6014
+ }
6015
+ if (doc._holdsRecordSet()) {
6016
+ const tail = [...body.tail_ops].sort((a, b) => a.seq - b.seq);
6017
+ const tailSeqs = tail.map((entry) => entry.seq);
6018
+ if (encoding === 'frozen') {
6019
+ const held = doc._frozenSealSeq;
6020
+ const sameSeal =
6021
+ held === null || body.snapshot_seq === undefined || held === body.snapshot_seq;
6022
+ if (tail.length === 0 && sameSeal) return { kind: 'unchanged' };
6023
+ if (tail.length === 0 && !coversMoreThanSnapshot(body)) {
6024
+ const content = await decodeJoinContent(body.snapshot, fetchBody);
6025
+ if (
6026
+ content.kind === 'frozen' &&
6027
+ doc._replaceSealedContent(content.content, body.snapshot_seq ?? null)
6028
+ ) {
6029
+ return { kind: 'applied', content, tailSeqs };
6030
+ }
6031
+ }
6032
+ doc._reopen();
6033
+ return { kind: 'reloaded' };
6034
+ }
6035
+ const content = await decodeJoinContent(body.snapshot, fetchBody);
6036
+ await ensureWasmReady();
6037
+ const upgraded = doc._upgradeStorage(
6038
+ content.kind === 'crdt' ? content.bytes : null,
6039
+ tail.map((entry) => base64ToBytes(entry.op_blob_b64)),
6040
+ );
6041
+ if (!upgraded) {
6042
+ doc._reopen();
6043
+ return { kind: 'reloaded' };
6044
+ }
6045
+ return { kind: 'applied', content, tailSeqs };
6046
+ }
6047
+ // A line a reader holds as a CRDT and the server now stores frozen (a new
6048
+ // version of its file was imported over it) is taken as stored, in place.
6049
+ // An editable open asks as a writer and is never served frozen content.
6050
+ if (encoding === 'frozen') {
6051
+ if (
6052
+ doc._openMode !== 'edit' &&
6053
+ body.tail_ops.length === 0 &&
6054
+ !coversMoreThanSnapshot(body)
6055
+ ) {
6056
+ const content = await decodeJoinContent(body.snapshot, fetchBody);
6057
+ if (
6058
+ content.kind === 'frozen' &&
6059
+ doc._downgradeStorage(content.content, body.snapshot_seq ?? null)
6060
+ ) {
6061
+ openedImportSeq.delete(doc);
6062
+ return { kind: 'applied', content, tailSeqs: [] };
6063
+ }
6064
+ }
6065
+ doc._reopen();
6066
+ return { kind: 'reloaded' };
6067
+ }
6068
+ const content = await decodeJoinContent(body.snapshot, fetchBody);
6069
+ if (content.kind === 'crdt') doc._applySnapshot(content.bytes);
6070
+ const tail = [...body.tail_ops].sort((a, b) => a.seq - b.seq);
6071
+ for (const entry of tail) doc.applyRemoteOp(base64ToBytes(entry.op_blob_b64));
6072
+ return { kind: 'applied', content, tailSeqs: tail.map((entry) => entry.seq) };
4794
6073
  }
4795
6074
 
4796
6075
  async function materialise(
4797
6076
  body: DocumentSnapshotResponse,
4798
6077
  documentOptions?: DocumentOptions,
4799
6078
  onSnapshotProgress?: (loaded: number, total: number | null) => void,
6079
+ // A frozen copy of a document whose import has not landed yet starts as
6080
+ // empty frozen content sealed at nothing, so the reload that follows the
6081
+ // import asks for the content as stored and swaps it in place, where an
6082
+ // empty CRDT would ask the server to convert the line for it.
6083
+ { emptyAsFrozen = false }: { emptyAsFrozen?: boolean } = {},
4800
6084
  ): Promise<OpenedDocument> {
6085
+ const endFetch = perfStart('open: snapshot fetch + decode');
6086
+ const content = await decodeJoinContent(body.snapshot, (url) =>
6087
+ fetchPresignedSnapshot(url, onSnapshotProgress),
6088
+ );
6089
+ endFetch({ kind: content.kind });
6090
+ if (content.kind === 'none' && emptyAsFrozen && body.tail_ops.length === 0) {
6091
+ return Document.openFromSealedContent(SealedContent.fromJSON({}), {
6092
+ ...documentOptions,
6093
+ readOnly: true,
6094
+ readOnlyReason: documentOptions?.readOnlyReason ?? 'snapshot',
6095
+ docId: body.doc_id,
6096
+ branchId: body.branch_id,
6097
+ frozenSeal: NO_SEAL,
6098
+ }) as OpenedDocument;
6099
+ }
6100
+ // Frozen content is read with no CRDT engine: no engine is built and the
6101
+ // WASM is not waited on.
6102
+ //
6103
+ // It is served to a reader and never to a writer. Converting it to a CRDT
6104
+ // is the SERVER's, because the conversion's output depends on the paging
6105
+ // constants, the mutation batching budget and loro's encoding, and two
6106
+ // clients on different builds would produce documents that merge into each
6107
+ // other with no error and no agreement. The tail is empty for the same
6108
+ // reason: the conversion is what makes a branch writable, so the ops it
6109
+ // takes are authored after it and against the converted document.
6110
+ if (content.kind === 'frozen') {
6111
+ // Frozen content with state above it is a document the server should not
6112
+ // have been able to build, and the content alone is the document as it
6113
+ // stood before those ops. Refused rather than read, because nothing here
6114
+ // would say so.
6115
+ if (body.tail_ops.length > 0 || coversMoreThanSnapshot(body)) {
6116
+ throw new BrassError(
6117
+ 'This could not be opened: its stored records do not ' +
6118
+ 'account for every change made to it. Reopen it in a moment.',
6119
+ { status: 500 },
6120
+ );
6121
+ }
6122
+ return Document.openFromSealedContent(content.content, {
6123
+ ...documentOptions,
6124
+ // A property of the content, which holds no op log for an edit to be
6125
+ // written into.
6126
+ readOnly: true,
6127
+ readOnlyReason: documentOptions?.readOnlyReason ?? 'snapshot',
6128
+ docId: body.doc_id,
6129
+ branchId: body.branch_id,
6130
+ ...(body.snapshot_seq !== undefined ? { frozenSeal: body.snapshot_seq } : {}),
6131
+ }) as OpenedDocument;
6132
+ }
4801
6133
  // Constructing the document is the first thing in this package that runs the
4802
6134
  // WASM, and `Brass.init` only starts the fetch, so this is where it is
4803
6135
  // waited on. Usually it resolved during the auth round-trip and this costs
@@ -4812,25 +6144,12 @@ async function materialise(
4812
6144
  branchId: body.branch_id,
4813
6145
  };
4814
6146
  let doc: Document;
4815
- const snap = body.snapshot;
4816
- if (snap === null) {
6147
+ if (content.kind === 'none') {
4817
6148
  doc = Document.create(optionsWithId);
4818
- } else if (snap.kind === 'inline') {
4819
- const endDecode = perfStart('open: base64 decode (inline snapshot)');
4820
- const bytes = base64ToBytes(snap.data_b64);
4821
- endDecode({ base64Chars: snap.data_b64.length, snapshotBytes: bytes.byteLength });
4822
- const endImport = perfStart('open: loro import (inline snapshot)');
4823
- doc = Document.openFromSnapshot(bytes, optionsWithId);
4824
- endImport({ snapshotBytes: bytes.byteLength });
4825
- } else if (snap.kind === 'presigned') {
4826
- const endPresigned = perfStart('open: presigned snapshot fetch');
4827
- const bytes = await fetchPresignedSnapshot(snap.url, onSnapshotProgress);
4828
- endPresigned({ snapshotBytes: bytes.byteLength });
4829
- const endImport = perfStart('open: loro import (presigned snapshot)');
4830
- doc = Document.openFromSnapshot(bytes, optionsWithId);
4831
- endImport({ snapshotBytes: bytes.byteLength });
4832
6149
  } else {
4833
- throw snapshotKindTooNewError();
6150
+ const endImport = perfStart('open: loro import');
6151
+ doc = Document.openFromSnapshot(content.bytes, optionsWithId);
6152
+ endImport({ snapshotBytes: content.bytes.byteLength });
4834
6153
  }
4835
6154
 
4836
6155
  // Apply tail ops in seq order. The server delivers them sorted; we sort
@@ -4842,8 +6161,105 @@ async function materialise(
4842
6161
  doc.applyRemoteOp(base64ToBytes(entry.op_blob_b64));
4843
6162
  }
4844
6163
  endTail({ count: tail.length });
6164
+ recordImportSeq(doc, body.import_seq);
4845
6165
 
4846
6166
  // `doc.id` is non-null at runtime (stamped above); the cast closes the type
4847
6167
  // gap without weakening the class's `string | null` signature.
4848
6168
  return doc as OpenedDocument;
4849
6169
  }
6170
+
6171
+ /** How many frozen opens run at once. */
6172
+ export const FROZEN_OPEN_LANES = 8;
6173
+
6174
+ /** Runs at most `lanes` of the tasks handed to it at once, in the order given. */
6175
+ export function createPacer(lanes: number): <T>(task: () => Promise<T>) => Promise<T> {
6176
+ let running = 0;
6177
+ const waiting: Array<() => void> = [];
6178
+ return (task) =>
6179
+ new Promise((resolve, reject) => {
6180
+ const start = (): void => {
6181
+ running += 1;
6182
+ task()
6183
+ .then(resolve, reject)
6184
+ .finally(() => {
6185
+ running -= 1;
6186
+ waiting.shift()?.();
6187
+ });
6188
+ };
6189
+ if (running < lanes) start();
6190
+ else waiting.push(start);
6191
+ });
6192
+ }
6193
+
6194
+ function frozenOptions(options: OpenDocumentByIdOptions): OpenDocumentByIdOptions {
6195
+ // Reading as data: a schema this app did not declare is read rather than
6196
+ // refused, since nothing written through this copy can misread it.
6197
+ return {
6198
+ ...options,
6199
+ acceptSchemaConflict: true,
6200
+ documentOptions: { ...options.documentOptions, readOnly: true },
6201
+ };
6202
+ }
6203
+
6204
+ // A stream line opened for a frozen copy: the same reading as the copy
6205
+ // itself, of the line its manifest names.
6206
+ function frozenLineOptions(lineId: string): OpenDocumentByIdOptions {
6207
+ return {
6208
+ branch: lineId,
6209
+ frozen: true,
6210
+ acceptSchemaConflict: true,
6211
+ documentOptions: { readOnly: true },
6212
+ };
6213
+ }
6214
+
6215
+ function importReportOf(parsed: DocumentSnapshotResponse): ImportReport | null {
6216
+ const report = (parsed as { import_report?: unknown }).import_report;
6217
+ return report !== null && typeof report === 'object' && Array.isArray((report as ImportReport).findings)
6218
+ ? (report as ImportReport)
6219
+ : null;
6220
+ }
6221
+
6222
+ /**
6223
+ * Whether a frame on a document's topic says the document moved past the
6224
+ * point a frozen copy was taken at: a later op, work that locked the
6225
+ * document finishing (an import landing or failing), or the document going.
6226
+ */
6227
+ export function frameMovesDocument(frame: unknown, takenAt: number): boolean {
6228
+ if (frame === null || typeof frame !== 'object') return false;
6229
+ const f = frame as {
6230
+ type?: unknown;
6231
+ seq?: unknown;
6232
+ current_seq?: unknown;
6233
+ reload?: unknown;
6234
+ status?: { pending?: unknown; blocking?: unknown };
6235
+ };
6236
+ switch (f.type) {
6237
+ // The server fans a `doc_op` out to every subscriber but the writer's
6238
+ // connection, so an edit made through another copy on this page's socket
6239
+ // reaches the topic only as that copy's `op_ack`.
6240
+ case 'doc_op':
6241
+ case 'op_ack':
6242
+ return typeof f.seq === 'number' && f.seq > takenAt;
6243
+ case 'doc_init':
6244
+ case 'doc_catch_up':
6245
+ return typeof f.current_seq === 'number' && f.current_seq > takenAt;
6246
+ case 'doc_status':
6247
+ // `reload` is work that replaced the content (an import landing); a
6248
+ // terminal status still blocking is work that failed.
6249
+ return (
6250
+ f.reload === true ||
6251
+ (f.status?.pending === false && f.status.blocking !== false)
6252
+ );
6253
+ case 'doc_gone':
6254
+ return true;
6255
+ default:
6256
+ return false;
6257
+ }
6258
+ }
6259
+
6260
+ // The document's name as the join that opened it read it, so a copy opened by
6261
+ // id carries it from its first frame as a session open does.
6262
+ function setJoinedName(doc: Document, parsed: unknown): void {
6263
+ const name = (parsed as { name?: unknown }).name;
6264
+ if (typeof name === 'string') doc._setName(name);
6265
+ }