@brass-build/client 0.1.0 → 0.3.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 (213) hide show
  1. package/AGENTS.md +72 -63
  2. package/CHANGELOG.md +97 -0
  3. package/README.md +325 -235
  4. package/dist/auth-flow.d.ts +10 -1
  5. package/dist/auth-flow.d.ts.map +1 -1
  6. package/dist/auth-flow.js +64 -20
  7. package/dist/auth-flow.js.map +1 -1
  8. package/dist/client.d.ts +4 -0
  9. package/dist/client.d.ts.map +1 -1
  10. package/dist/client.js +245 -76
  11. package/dist/client.js.map +1 -1
  12. package/dist/document-checkpoints.d.ts +1 -0
  13. package/dist/document-checkpoints.d.ts.map +1 -1
  14. package/dist/document-checkpoints.js +27 -0
  15. package/dist/document-checkpoints.js.map +1 -1
  16. package/dist/document-engine.d.ts +4 -0
  17. package/dist/document-engine.d.ts.map +1 -0
  18. package/dist/document-engine.js +9 -0
  19. package/dist/document-engine.js.map +1 -0
  20. package/dist/document-signals.d.ts +31 -1
  21. package/dist/document-signals.d.ts.map +1 -1
  22. package/dist/document-signals.js +59 -0
  23. package/dist/document-signals.js.map +1 -1
  24. package/dist/document-streams.d.ts +8 -1
  25. package/dist/document-streams.d.ts.map +1 -1
  26. package/dist/document-streams.js +79 -8
  27. package/dist/document-streams.js.map +1 -1
  28. package/dist/document-thumbnail-capture.d.ts +20 -0
  29. package/dist/document-thumbnail-capture.d.ts.map +1 -1
  30. package/dist/document-thumbnail-capture.js +247 -9
  31. package/dist/document-thumbnail-capture.js.map +1 -1
  32. package/dist/documents-namespace.d.ts.map +1 -1
  33. package/dist/documents-namespace.js +22 -3
  34. package/dist/documents-namespace.js.map +1 -1
  35. package/dist/documents.d.ts +49 -16
  36. package/dist/documents.d.ts.map +1 -1
  37. package/dist/documents.js +981 -237
  38. package/dist/documents.js.map +1 -1
  39. package/dist/embed-protocol.d.ts +3 -0
  40. package/dist/embed-protocol.d.ts.map +1 -0
  41. package/dist/embed-protocol.js +26 -0
  42. package/dist/embed-protocol.js.map +1 -0
  43. package/dist/emitter.d.ts.map +1 -1
  44. package/dist/emitter.js +8 -1
  45. package/dist/emitter.js.map +1 -1
  46. package/dist/export-guest.d.ts.map +1 -1
  47. package/dist/export-guest.js +14 -6
  48. package/dist/export-guest.js.map +1 -1
  49. package/dist/fetch-deadline.d.ts +22 -0
  50. package/dist/fetch-deadline.d.ts.map +1 -0
  51. package/dist/fetch-deadline.js +96 -0
  52. package/dist/fetch-deadline.js.map +1 -0
  53. package/dist/fetch-retry.d.ts +1 -0
  54. package/dist/fetch-retry.d.ts.map +1 -1
  55. package/dist/fetch-retry.js +12 -5
  56. package/dist/fetch-retry.js.map +1 -1
  57. package/dist/handle-types.d.ts +150 -0
  58. package/dist/handle-types.d.ts.map +1 -0
  59. package/dist/handle-types.js +8 -0
  60. package/dist/handle-types.js.map +1 -0
  61. package/dist/handles.d.ts +48 -48
  62. package/dist/handles.d.ts.map +1 -1
  63. package/dist/handles.js +3122 -501
  64. package/dist/handles.js.map +1 -1
  65. package/dist/hosted-runtime.d.ts.map +1 -1
  66. package/dist/hosted-runtime.js +8 -12
  67. package/dist/hosted-runtime.js.map +1 -1
  68. package/dist/hosted-ui.d.ts +0 -1
  69. package/dist/hosted-ui.d.ts.map +1 -1
  70. package/dist/hosted-ui.js +29 -24
  71. package/dist/hosted-ui.js.map +1 -1
  72. package/dist/importer.d.ts.map +1 -1
  73. package/dist/importer.js +204 -8
  74. package/dist/importer.js.map +1 -1
  75. package/dist/index.d.ts +10 -10
  76. package/dist/index.d.ts.map +1 -1
  77. package/dist/index.js +11 -2
  78. package/dist/index.js.map +1 -1
  79. package/dist/internal.d.ts +2 -1
  80. package/dist/internal.d.ts.map +1 -1
  81. package/dist/internal.js +1 -0
  82. package/dist/internal.js.map +1 -1
  83. package/dist/join-coverage.d.ts +12 -0
  84. package/dist/join-coverage.d.ts.map +1 -0
  85. package/dist/join-coverage.js +37 -0
  86. package/dist/join-coverage.js.map +1 -0
  87. package/dist/jwt.d.ts.map +1 -1
  88. package/dist/jwt.js +5 -1
  89. package/dist/jwt.js.map +1 -1
  90. package/dist/launch-context.js +2 -2
  91. package/dist/launch-context.js.map +1 -1
  92. package/dist/mutate-batching.d.ts +25 -2
  93. package/dist/mutate-batching.d.ts.map +1 -1
  94. package/dist/mutate-batching.js +57 -10
  95. package/dist/mutate-batching.js.map +1 -1
  96. package/dist/open-document.d.ts +30 -1
  97. package/dist/open-document.d.ts.map +1 -1
  98. package/dist/open-document.js +1905 -322
  99. package/dist/open-document.js.map +1 -1
  100. package/dist/presence.d.ts +28 -0
  101. package/dist/presence.d.ts.map +1 -1
  102. package/dist/presence.js +149 -7
  103. package/dist/presence.js.map +1 -1
  104. package/dist/record-paging.d.ts +335 -71
  105. package/dist/record-paging.d.ts.map +1 -1
  106. package/dist/record-paging.js +590 -127
  107. package/dist/record-paging.js.map +1 -1
  108. package/dist/selection-persistence.d.ts.map +1 -1
  109. package/dist/selection-persistence.js +11 -1
  110. package/dist/selection-persistence.js.map +1 -1
  111. package/dist/serializer.d.ts +30 -1
  112. package/dist/serializer.d.ts.map +1 -1
  113. package/dist/session-persistence.d.ts.map +1 -1
  114. package/dist/session-persistence.js +9 -8
  115. package/dist/session-persistence.js.map +1 -1
  116. package/dist/session-toolbar.d.ts +0 -1
  117. package/dist/session-toolbar.d.ts.map +1 -1
  118. package/dist/session-toolbar.js +356 -83
  119. package/dist/session-toolbar.js.map +1 -1
  120. package/dist/session.d.ts.map +1 -1
  121. package/dist/session.js +87 -18
  122. package/dist/session.js.map +1 -1
  123. package/dist/tail-ops.d.ts +14 -4
  124. package/dist/tail-ops.d.ts.map +1 -1
  125. package/dist/tail-ops.js +75 -15
  126. package/dist/tail-ops.js.map +1 -1
  127. package/dist/topic-lifecycle.d.ts +4 -0
  128. package/dist/topic-lifecycle.d.ts.map +1 -1
  129. package/dist/topic-lifecycle.js +25 -1
  130. package/dist/topic-lifecycle.js.map +1 -1
  131. package/dist/types.d.ts +47 -1
  132. package/dist/types.d.ts.map +1 -1
  133. package/dist/undo.d.ts +2 -3
  134. package/dist/undo.d.ts.map +1 -1
  135. package/dist/undo.js +11 -0
  136. package/dist/undo.js.map +1 -1
  137. package/dist/unload-guard.d.ts +5 -0
  138. package/dist/unload-guard.d.ts.map +1 -0
  139. package/dist/unload-guard.js +88 -0
  140. package/dist/unload-guard.js.map +1 -0
  141. package/dist/version-bar.d.ts +4 -3
  142. package/dist/version-bar.d.ts.map +1 -1
  143. package/dist/version-bar.js +162 -20
  144. package/dist/version-bar.js.map +1 -1
  145. package/dist/version.d.ts +2 -2
  146. package/dist/version.js +1 -1
  147. package/dist/vite.d.ts +18 -2
  148. package/dist/vite.d.ts.map +1 -1
  149. package/dist/vite.js +133 -8
  150. package/dist/vite.js.map +1 -1
  151. package/dist/when-saved.d.ts +2 -0
  152. package/dist/when-saved.d.ts.map +1 -0
  153. package/dist/when-saved.js +70 -0
  154. package/dist/when-saved.js.map +1 -0
  155. package/dist/ws-client.d.ts +27 -3
  156. package/dist/ws-client.d.ts.map +1 -1
  157. package/dist/ws-client.js +408 -89
  158. package/dist/ws-client.js.map +1 -1
  159. package/dist/ws-frame-limit.d.ts.map +1 -1
  160. package/dist/ws-frame-limit.js +23 -17
  161. package/dist/ws-frame-limit.js.map +1 -1
  162. package/docs/app-manifest.md +7 -8
  163. package/docs/custom-chrome.md +21 -22
  164. package/docs/hosted-ui-advanced.md +14 -17
  165. package/docs/imported-documents.md +53 -197
  166. package/docs/presence.md +45 -21
  167. package/docs/schemas.md +12 -17
  168. package/docs/streams.md +55 -57
  169. package/package.json +4 -3
  170. package/src/auth-flow.ts +90 -23
  171. package/src/client.ts +262 -80
  172. package/src/document-checkpoints.ts +30 -0
  173. package/src/document-engine.ts +15 -0
  174. package/src/document-signals.ts +120 -8
  175. package/src/document-streams.ts +95 -9
  176. package/src/document-thumbnail-capture.ts +253 -9
  177. package/src/documents-namespace.ts +38 -13
  178. package/src/documents.ts +1103 -290
  179. package/src/embed-protocol.ts +25 -0
  180. package/src/emitter.ts +8 -1
  181. package/src/export-guest.ts +15 -9
  182. package/src/fetch-deadline.ts +119 -0
  183. package/src/fetch-retry.ts +14 -5
  184. package/src/handle-types.ts +303 -0
  185. package/src/handles.ts +3407 -683
  186. package/src/hosted-runtime.ts +8 -12
  187. package/src/hosted-ui.ts +28 -32
  188. package/src/importer.ts +216 -8
  189. package/src/index.ts +29 -18
  190. package/src/internal.ts +7 -0
  191. package/src/join-coverage.ts +42 -0
  192. package/src/jwt.ts +8 -1
  193. package/src/launch-context.ts +2 -2
  194. package/src/mutate-batching.ts +60 -11
  195. package/src/open-document.ts +2441 -502
  196. package/src/presence.ts +210 -11
  197. package/src/record-paging.ts +686 -137
  198. package/src/selection-persistence.ts +10 -1
  199. package/src/serializer.ts +33 -1
  200. package/src/session-persistence.ts +9 -8
  201. package/src/session-toolbar.ts +387 -89
  202. package/src/session.ts +91 -17
  203. package/src/tail-ops.ts +76 -10
  204. package/src/topic-lifecycle.ts +31 -1
  205. package/src/types.ts +56 -4
  206. package/src/undo.ts +13 -5
  207. package/src/unload-guard.ts +91 -0
  208. package/src/version-bar.ts +183 -26
  209. package/src/version.ts +1 -1
  210. package/src/vite.ts +175 -10
  211. package/src/when-saved.ts +66 -0
  212. package/src/ws-client.ts +461 -92
  213. package/src/ws-frame-limit.ts +23 -17
@@ -11,22 +11,42 @@ import {
11
11
  type DocumentOptions,
12
12
  type DocumentWorkStatus,
13
13
  type DocumentDriveStatus,
14
+ type DrivePublishTarget,
14
15
  type DocumentDriveTrackingState,
15
16
  type DriveMirrorDirection,
16
17
  type HostedUiProvider,
18
+ type ReadOnlyReason,
19
+ type ResyncPushOutcome,
17
20
  } from './documents.js';
18
21
  import { createPresenceController } from './presence.js';
19
22
  import type { WsTopicCallback } from './ws-client.js';
20
23
  import { applyAppAssertionHeader, applyClientVersionHeader } from './api-headers.js';
21
24
  import { brassErrorFromBody } from './api-fetch.js';
22
25
  import { fetchWithRetry, RETRY_IDEMPOTENT_WRITE } from './fetch-retry.js';
26
+ import {
27
+ deadlineForUpload,
28
+ fetchWithDeadline,
29
+ OP_WRITE_TIMEOUT_MS,
30
+ } from './fetch-deadline.js';
31
+ import type { DocSyncDiagnostics } from './types.js';
32
+
33
+ // Why a client is reporting its sync health. Mirrors the server's
34
+ // `CLIENT_REPORT_REASONS`; the published SDK cannot import the workspace
35
+ // contracts package, so the values are restated here and the server refuses
36
+ // any it does not recognise.
37
+ type ClientReportReason =
38
+ | 'op_stuck'
39
+ | 'reconnected_unsaved'
40
+ | 'divergence_healed';
23
41
  import { BrassError } from './errors.js';
24
42
  import { perfStart } from './perf.js';
25
- import { resolveTailOps, type WireTailOp } from './tail-ops.js';
43
+ import { joinCoverageFloor, snapshotSeqOf } from './join-coverage.js';
44
+ import { declaredTailBytes, resolveTailOps, type WireTailOp } from './tail-ops.js';
26
45
  import {
27
46
  opBlobExceedsDurableLimit,
28
- opFrameExceedsWsLimit,
29
47
  opBlobNeedsStaging,
48
+ opFrameExceedsWsLimit,
49
+ type OpFrame,
30
50
  } from './ws-frame-limit.js';
31
51
 
32
52
  export interface OpenDocumentByIdOptions {
@@ -73,22 +93,48 @@ export interface OpenDocumentByIdOptions {
73
93
  */
74
94
  version?: number;
75
95
  /**
76
- * SDK-internal snapshot-download progress sink (`total` is the decompressed
77
- * byte count from `x-amz-meta-raw-bytes`, or null). The session threads it
78
- * through to surface a "Downloading... NN%" state before the Document
79
- * exists; apps read that via `Brass.session`, never this.
96
+ * SDK-internal load-progress sink. The session threads it through to
97
+ * surface a progress state before the Document exists; apps read that via
98
+ * `Brass.session`, never this.
99
+ *
100
+ * Both phases count BYTES, so a reader watching a load sees one figure
101
+ * rather than two bars filling in turn. `phase` says which stretch is on
102
+ * the wire, because the two are paced by different things and a document
103
+ * can spend the whole of the first in what looks like silence: `tail` is
104
+ * the spilled ops the join fetches, `snapshot` the snapshot itself, and
105
+ * absent means `snapshot`.
106
+ *
107
+ * `total` is the whole download in both phases. The tail declares it from
108
+ * each spilled op's own size plus the snapshot still to come, so the
109
+ * percentage runs across the pair; the snapshot phase reports only its own
110
+ * (the decompressed size from `x-amz-meta-raw-bytes`, or null), and the
111
+ * session adds the tail back.
80
112
  * @internal
81
113
  */
82
- onSnapshotProgress?: (loaded: number, total: number | null) => void;
114
+ onSnapshotProgress?: LoadProgress;
83
115
  }
84
116
 
117
+ /**
118
+ * What a load reports as it runs. See `onSnapshotProgress` for the phases.
119
+ * @internal
120
+ */
121
+ export type LoadProgress = (
122
+ loaded: number,
123
+ total: number | null,
124
+ phase?: 'snapshot' | 'tail',
125
+ ) => void;
126
+
85
127
  // `Brass.createDocument(options?)` creates a new document and returns it
86
- // fully editable. `projectId` creates inside a project; `organizationId`
87
- // alone creates a loose doc in that org; both create a project-parented doc
88
- // in an explicit org. With neither and one org, the SDK picks it; with
89
- // multiple orgs it 409s. `name` defaults to "Untitled".
128
+ // fully editable. `name` defaults to "Untitled". Placement is resolved from
129
+ // context rather than named here (see `CreateDocumentArgs`): a caller who
130
+ // belongs to one organization lands there, and a caller who belongs to
131
+ // several is asked, so this shape carries no organization or project.
90
132
  export interface CreateDocumentOptions {
91
133
  name?: string;
134
+ // Snake_case because the SDK relays this object to `POST /documents`
135
+ // verbatim and `Brass.documents.get` hands it back under the same key, so
136
+ // the key an app writes is the key it reads. The camelCase sibling below
137
+ // never leaves the process, which is why the two conventions sit together.
92
138
  app_metadata?: Record<string, unknown>;
93
139
  // Forwarded to `Document.openFromSnapshot` / `Document.create`; overrides
94
140
  // the root-map key for a doc created against a non-default rootKey.
@@ -140,14 +186,28 @@ export interface DocumentSnapshotResponse {
140
186
  // Tail ops since the last snapshot, seq ascending, each a base64 Loro op
141
187
  // blob. Applied in order; `applyRemoteOp` is idempotent.
142
188
  tail_ops: Array<{ seq: number; op_blob_b64: string }>;
143
- // The branch's seq counter at join time. The consistent-read join means
144
- // snapshot + tail cover every op at or below it; the SDK seeds its
145
- // coverage cursor from it. Optional (older fixtures omit it).
189
+ // The branch's seq counter at join time: the highest seq the document
190
+ // has handed out. Not the same as the highest seq this response carries,
191
+ // because a seq is allocated before its op row is written, so a join read
192
+ // during a bulk write names seqs whose rows it could not see. Pair it with
193
+ // `snapshot_seq` to work out what was delivered. Optional (older fixtures
194
+ // omit it).
146
195
  current_seq?: number;
196
+ // The seq the snapshot was sealed at: everything at or below it is inside
197
+ // `snapshot`, and `tail_ops` carries the rows above it. Optional (older
198
+ // servers and fixtures omit it).
199
+ snapshot_seq?: number;
147
200
  // Base64 version vector of the persisted snapshot (durability-handshake
148
201
  // ground truth). The handshake runs off the `doc_init` frame's copy; this
149
202
  // is carried for wire parallelism. Absent until the branch is compacted.
150
203
  snapshot_version_vector?: string;
204
+ // Whether the caller may write to this document, as the server's own
205
+ // write gate answers it. `false` opens the Document read-only, so
206
+ // `doc.readOnly` covers a view-only grant at bind time rather than
207
+ // leaving the app to discover it from a refused op. Optional: a server
208
+ // that does not send it is one whose refusals the transport still
209
+ // catches, so its absence opens the document editable as before.
210
+ can_edit?: boolean;
151
211
  // Opaque status of slow server-side work (e.g. a drive stub hydrating on
152
212
  // first open, with `snapshot` then `null`). Surfaced on `doc.status`;
153
213
  // phases stream in via `doc_status` frames.
@@ -155,6 +215,33 @@ export interface DocumentSnapshotResponse {
155
215
  // Drive provenance + tracking state, present only on drive-linked docs.
156
216
  // Seeds `doc.driveStatus`; live changes stream in via `doc_drive_status`.
157
217
  drive?: DocumentDriveSummaryWire;
218
+ // Whether this document's export runs in the app this client acts as.
219
+ // Server-answered per caller, so it says only that this app is the one to
220
+ // serialize, never which app is if it is not. Absent for a caller carrying
221
+ // no app assertion.
222
+ calling_app_exports?: boolean;
223
+ // The version of the document's current picture, absent when it has none.
224
+ // Only the version is read (a picture behind the head is what makes this
225
+ // open seed one), so the shape is deliberately narrow: the full wire record
226
+ // is the thumbnail routes' surface, not the join's.
227
+ thumbnail?: { version?: number } | null;
228
+ // Where a document that mirrors nothing can be published, present only
229
+ // while it mirrors nothing and its project is linked to a drive that
230
+ // takes a new file. The hosted drive badge offers the publish off this.
231
+ drive_publish_target?: DrivePublishTargetWire;
232
+ }
233
+
234
+ // Wire shape of a publish target on the join response. Snake_case,
235
+ // mirroring the server's `DocumentDrivePublishTarget` (CONTRACT.md "Where
236
+ // a document with no drive item can publish").
237
+ export interface DrivePublishTargetWire {
238
+ drive_type?: unknown;
239
+ drive_name?: unknown;
240
+ container_name?: unknown;
241
+ detail?: unknown;
242
+ source_unreadable?: unknown;
243
+ publishing?: unknown;
244
+ publish_failed?: unknown;
158
245
  }
159
246
 
160
247
  // Wire shape of the `drive` block carried on the join response and the
@@ -178,6 +265,7 @@ export interface DocumentDriveSummaryWire {
178
265
  // Optional: a frame from an older server omits it, treated as not
179
266
  // publishing (the `canPush` affordance then behaves as before).
180
267
  publishing?: boolean;
268
+ source_unreadable?: string;
181
269
  }
182
270
 
183
271
  // Wire-protocol-pinned response shape from
@@ -199,8 +287,24 @@ export interface DocumentAtVersionResponse {
199
287
  // Structural subset of `BrassWsClient` the factory uses, so it can be
200
288
  // unit-tested against a mock.
201
289
  export interface OpenDocumentWsClient {
202
- subscribe(topic: string, callback: WsTopicCallback): () => void;
290
+ subscribe(
291
+ topic: string,
292
+ callback: WsTopicCallback,
293
+ options?: {
294
+ ownsFrameRetry?: boolean;
295
+ onEnded?: (info: { reason: string }) => void;
296
+ },
297
+ ): () => void;
203
298
  send(frame: unknown): void;
299
+ // Connection-level facts the transport reports but does not own. Optional
300
+ // so a transport-only test can supply a bare stub; absent reads as a
301
+ // client that has never reconnected on a visible page, which is what a
302
+ // test harness is.
303
+ readonly reconnectCount?: number;
304
+ readonly pageHidden?: boolean;
305
+ // Whether a socket is open right now. Absent reads as connected, which is
306
+ // what a transport-only test stub is.
307
+ readonly connected?: boolean;
204
308
  }
205
309
 
206
310
  export interface OpenDocumentFactoryOptions {
@@ -272,6 +376,12 @@ export interface OpenDocumentFactoryOptions {
272
376
  // through a getter because `init` resolves it after the factory's
273
377
  // options are written. See `createDocumentId`.
274
378
  launchOrganizationId?: () => string | null;
379
+ // Who is signed in, as the park cache's ownership stamp (see
380
+ // `ParkedDocEntry.identity`). Read at park and at take, so a Document only
381
+ // ever comes back to the person it was opened for. Absent (tests, the
382
+ // anonymous link-guest path) parks under a null identity, which is
383
+ // consistent with itself and so behaves as it always has.
384
+ identity?: () => string | null;
275
385
  // Async-error sink for built-in command failures; `null` falls back to
276
386
  // console.
277
387
  reportError?: (err: Error) => void;
@@ -287,6 +397,73 @@ export type OpenedDocument = Document & {
287
397
  readonly id: string;
288
398
  };
289
399
 
400
+ // The terminal frame the platform sends when it clears a document's work
401
+ // status: the work finished and the content is in. The SDK emits the same
402
+ // shape wherever it settles that transition off its own import (a
403
+ // subscribe-time reconcile, a retried pull), so the app cannot tell the two
404
+ // apart. `blocking: false` is what releases the load lock.
405
+ const WORK_READY_STATUS: DocumentWorkStatus = {
406
+ message: 'Ready',
407
+ pending: false,
408
+ blocking: false,
409
+ };
410
+
411
+ const CONTENT_PULL_ATTEMPTS = 5;
412
+ const CONTENT_PULL_BACKOFF_MS = 1000;
413
+
414
+ // Pull a document's content in after the platform reports its inbound work
415
+ // finished, retrying a transient fetch failure (a network blip, a dropped
416
+ // presigned download): the platform asserts the content exists, so a silent
417
+ // give-up would leave the doc empty behind a terminal status, reading as "the
418
+ // model never loaded" and invisible in every log. Emits `finalStatus` only
419
+ // once the import lands, so an app re-reading on `onStatus` sees the content.
420
+ // On exhaustion it reports the failure and emits a terminal failure status,
421
+ // which keeps the doc read-only. Returns whether the content is in.
422
+ async function pullContentWithRetry(args: {
423
+ doc: Document;
424
+ docId: string;
425
+ finalStatus: DocumentWorkStatus;
426
+ fetchAndImport: () => Promise<{ ok: true } | { ok: false; reason: string }>;
427
+ reportError?: (err: Error) => void;
428
+ }): Promise<boolean> {
429
+ let lastError: Error | null = null;
430
+ for (let attempt = 1; attempt <= CONTENT_PULL_ATTEMPTS; attempt += 1) {
431
+ try {
432
+ const outcome = await args.fetchAndImport();
433
+ if (outcome.ok) {
434
+ args.doc._emitStatus(args.finalStatus, { contentLoaded: true });
435
+ return true;
436
+ }
437
+ lastError = new Error(outcome.reason);
438
+ } catch (err) {
439
+ lastError = err instanceof Error ? err : new Error(String(err));
440
+ }
441
+ // A retrying attempt is exceptional, so this warn is quiet in normal
442
+ // operation and decisive in a bug report; without it a failure here is
443
+ // undiagnosable from any log.
444
+ console.warn(
445
+ `[Brass] document content reload attempt ${attempt}/${CONTENT_PULL_ATTEMPTS} failed (doc ${args.docId}): ${lastError.message}`,
446
+ );
447
+ if (attempt < CONTENT_PULL_ATTEMPTS) {
448
+ await new Promise((r) => setTimeout(r, CONTENT_PULL_BACKOFF_MS * attempt));
449
+ }
450
+ }
451
+ const error = new BrassError(
452
+ `Document content reload failed after ${CONTENT_PULL_ATTEMPTS} attempts: ${lastError?.message ?? 'unknown error'}`,
453
+ { status: 0 },
454
+ );
455
+ if (args.reportError) args.reportError(error);
456
+ else console.error('[Brass]', error);
457
+ args.doc._emitStatus({
458
+ message: 'The model could not be loaded. Reload to try again.',
459
+ pending: false,
460
+ // The content never arrived, so this terminal status still locks the
461
+ // doc: an edit here would write into a document holding nothing.
462
+ blocking: true,
463
+ });
464
+ return false;
465
+ }
466
+
290
467
  export type OpenDocumentByIdFn = (
291
468
  docId: string,
292
469
  options?: OpenDocumentByIdOptions,
@@ -323,9 +500,8 @@ export interface DocumentFactory {
323
500
  openByLink: OpenDocumentByLinkFn;
324
501
  }
325
502
 
326
- // Wire shapes on the `doc:` topic. Server schemas live in
327
- // `infra/lambda/ws/default.ts`; frame names match exactly so the CONTRACT.md
328
- // type tests stay synced.
503
+ // Wire shapes on the `doc:` topic. Frame names match the server's exactly, so
504
+ // the CONTRACT.md type tests stay synced.
329
505
  interface DocOpFrame {
330
506
  type: 'doc_op';
331
507
  topic: string;
@@ -360,29 +536,56 @@ interface OpRejectedFrame {
360
536
  // tail op whose bytes spilled to storage is fetched once, here. Every
361
537
  // reader downstream then sees the inline shape it already handles, and
362
538
  // none of the seq bookkeeping has to learn a second representation.
363
- async function resolveParsedTailOps(parsed: unknown): Promise<void> {
539
+ async function resolveParsedTailOps(
540
+ parsed: unknown,
541
+ onProgress?: LoadProgress,
542
+ ): Promise<void> {
364
543
  if (typeof parsed !== 'object' || parsed === null) return;
365
- const body = parsed as { tail_ops?: unknown };
544
+ const body = parsed as { tail_ops?: unknown; snapshot?: unknown };
366
545
  if (!Array.isArray(body.tail_ops)) return;
367
- body.tail_ops = await resolveTailOps(body.tail_ops as WireTailOp[]);
546
+ const tailOps = body.tail_ops as WireTailOp[];
547
+ // The whole download, stated before any of it starts. A presigned snapshot
548
+ // names its decompressed size and each spilled op names its blob's, and both
549
+ // are fetched over the network with a reader waiting, so the load reports one
550
+ // figure against their sum. An inline snapshot contributes nothing: its bytes
551
+ // came with this response.
552
+ const snapshot = body.snapshot as { raw_bytes?: unknown } | null | undefined;
553
+ const snapshotBytes =
554
+ typeof snapshot?.raw_bytes === 'number' ? snapshot.raw_bytes : 0;
555
+ const totalBytes = declaredTailBytes(tailOps) + snapshotBytes;
556
+ body.tail_ops = await resolveTailOps(
557
+ tailOps,
558
+ fetch,
559
+ onProgress === undefined
560
+ ? undefined
561
+ : (loadedBytes) =>
562
+ onProgress(loadedBytes, totalBytes > 0 ? totalBytes : null, 'tail'),
563
+ );
368
564
  }
369
565
 
370
- // Post-subscribe server frame mirroring the HTTP join payload. Shape pinned
371
- // in `infra/lib/document-snapshot.ts`; the SDK needs the tail_ops +
372
- // current_seq to cover the join-vs-fanout race (see `wireDocTransport`).
566
+ // Post-subscribe server frame mirroring the HTTP join payload. The SDK needs
567
+ // the tail_ops + current_seq to cover the join-vs-fanout race (see
568
+ // `wireDocTransport`).
569
+ // A tail entry as `parseDocInitFrame` normalises it: the seq every reader
570
+ // needs for the cursors, and the inline bytes when the entry carries them.
373
571
  interface DocInitTailOp {
374
572
  seq: number;
375
- // Absent when the op's blob spilled to storage. The server withholds
376
- // such a tail from this frame (see `isDocInitFrame`), so a reader that
377
- // finds one skips it and picks the op up on the HTTP re-import.
573
+ // Absent when the op's blob spilled to storage (the server withholds such
574
+ // a tail from this frame, since fetching one needs an await the frame
575
+ // reader does not have) or when the entry names its bytes in a shape this
576
+ // SDK does not recognise. A reader that finds one skips it and picks the
577
+ // op up on the HTTP re-import.
378
578
  op_blob_b64?: string;
379
- op_blob_url?: string;
380
579
  }
381
580
  interface DocInitFrame {
382
581
  type: 'doc_init';
383
582
  topic: string;
384
583
  tail_ops: DocInitTailOp[];
385
584
  current_seq: number;
585
+ // The seq the snapshot was sealed at, which is where this frame's tail
586
+ // begins. Absent from an older server's frame, and then inferred from the
587
+ // tail's own lower edge.
588
+ snapshot_seq?: number;
386
589
  // Base64 version vector of the persisted snapshot, stamped at seal time.
387
590
  // Drives the durability handshake: fold the tail ops in for the durable
388
591
  // frontier and resync-push anything local holds beyond it. Absent on
@@ -390,6 +593,19 @@ interface DocInitFrame {
390
593
  snapshot_version_vector?: string;
391
594
  }
392
595
 
596
+ // Server-pushed "the document moved past you" prompt, naming the seq to
597
+ // reach and carrying none of the content. The server sends it to every
598
+ // subscriber of a document on each op the socket could not carry, so it is
599
+ // the frequent frame of the two: it says nothing about the subscription it
600
+ // arrived on, and a reader that treats it as a (re)subscribe re-asserts
601
+ // every unacked op on somebody else's write. Wire shape pinned in
602
+ // CONTRACT.md "Server-pushed `doc_catch_up`".
603
+ interface DocCatchUpFrame {
604
+ type: 'doc_catch_up';
605
+ topic: string;
606
+ current_seq: number;
607
+ }
608
+
393
609
  // Server-pushed eviction frame. Carried on both the content and presence
394
610
  // topics; the SDK collapses the duplicate via `Document._markGone`'s
395
611
  // idempotency. Wire shape pinned in CONTRACT.md "Server-pushed `doc_gone`".
@@ -433,8 +649,23 @@ interface DocDriveStatusFrame {
433
649
  drive: DocumentDriveSummaryWire;
434
650
  }
435
651
 
436
- // Server-pushed frame fired when the compaction worker's aged-orphan escape
437
- // discards causally unapplyable ops. The prompt half of the durability
652
+ // Server-pushed publish state for a document that mirrors nothing on a drive
653
+ // yet. Wire shape pinned in CONTRACT.md "Server-pushed `doc_drive_publish`".
654
+ // The first publish creates the artifact, so until it lands the document has
655
+ // no drive summary and `doc_drive_status` carries nothing about the push it
656
+ // started.
657
+ interface DocDrivePublishFrame {
658
+ type: 'doc_drive_publish';
659
+ topic: string;
660
+ publish: {
661
+ publishing?: unknown;
662
+ failed?: unknown;
663
+ detail?: unknown;
664
+ };
665
+ }
666
+
667
+ // Server-pushed frame fired when the platform discards ops it cannot apply.
668
+ // The prompt half of the durability
438
669
  // handshake: the SDK re-checks durability against ground truth now rather
439
670
  // than waiting for the author's next reconnect, and resync-pushes anything
440
671
  // it holds. Best-effort and connected-only; the `doc_init` handshake is the
@@ -443,8 +674,6 @@ interface DocDriveStatusFrame {
443
674
  interface DocOpsDroppedFrame {
444
675
  type: 'doc_ops_dropped';
445
676
  topic: string;
446
- dropped_seqs: number[];
447
- actor: string;
448
677
  }
449
678
 
450
679
  // Map the snake_case wire `drive` block to the public camelCase
@@ -466,7 +695,9 @@ function toDriveStatus(wire: DocumentDriveSummaryWire): DocumentDriveStatus {
466
695
  // update) must not be treated as unbroken and offered a push the server
467
696
  // would reject. Require an explicit `false` to enable, and an explicit
468
697
  // `true` to flag broken. Behaviour is unchanged for today's frames, which
469
- // always carry the boolean.
698
+ // always carry the boolean. Whether the file is written server-side or
699
+ // from the app's own serializer is a REQUEST SHAPE the push path resolves
700
+ // for itself, so it says nothing about whether this document can publish.
470
701
  canPush:
471
702
  wire.broken === false &&
472
703
  mirrorDirection === 'two_way' &&
@@ -481,6 +712,31 @@ function toDriveStatus(wire: DocumentDriveSummaryWire): DocumentDriveStatus {
481
712
  itemName: wire.item_name,
482
713
  itemUrl: wire.item_url,
483
714
  publishing: wire.publishing === true,
715
+ // Spread rather than assigned, because `exactOptionalPropertyTypes`
716
+ // separates an absent optional field from one holding `undefined`.
717
+ ...(wire.source_unreadable !== undefined
718
+ ? { sourceUnreadable: wire.source_unreadable }
719
+ : {}),
720
+ };
721
+ }
722
+
723
+ // Map the wire target to the public shape, dropping one that does not carry
724
+ // what a publish needs. A malformed target is skipped rather than failing
725
+ // the open: the document is readable whatever its publish offer says.
726
+ function toPublishTarget(
727
+ wire: DrivePublishTargetWire | undefined,
728
+ ): DrivePublishTarget | null {
729
+ if (typeof wire !== 'object' || wire === null) return null;
730
+ const driveName = wire.drive_name;
731
+ if (typeof driveName !== 'string' || driveName === '') return null;
732
+ return {
733
+ driveName,
734
+ driveType: typeof wire.drive_type === 'string' ? wire.drive_type : '',
735
+ containerName:
736
+ typeof wire.container_name === 'string' ? wire.container_name : '',
737
+ detail: typeof wire.detail === 'string' ? wire.detail : '',
738
+ publishing: wire.publishing === true,
739
+ publishFailed: wire.publish_failed === true,
484
740
  };
485
741
  }
486
742
 
@@ -495,31 +751,62 @@ function isDocOpFrame(frame: unknown): frame is DocOpFrame {
495
751
  );
496
752
  }
497
753
 
498
- function isDocInitFrame(frame: unknown): frame is DocInitFrame {
499
- if (typeof frame !== 'object' || frame === null) return false;
754
+ // The frame in the shape its readers act on, or null when it carries nothing
755
+ // they can act on.
756
+ //
757
+ // Gated on the discriminant plus `current_seq`, with the tail degraded entry
758
+ // by entry. Most of what a `doc_init` does is carried by the frame itself
759
+ // rather than by any one entry: `current_seq` is the cursor, the tail's lower
760
+ // edge decides whether the reader re-imports the join, and the frame is the
761
+ // (re)subscribe signal that re-asserts every outstanding op and runs the
762
+ // durability handshake. So an entry whose shape this SDK does not recognise
763
+ // costs the one op it carries, which stays uncovered and is pulled over the
764
+ // HTTP join; refusing the frame over it would cost all of the above.
765
+ //
766
+ // `snapshot_version_vector` is likewise tolerated as absent when it is not a
767
+ // string: the handshake needs a vector it can decode, and skipping the
768
+ // handshake is a smaller loss than dropping the frame that drives it.
769
+ function parseDocInitFrame(frame: unknown): DocInitFrame | null {
770
+ if (typeof frame !== 'object' || frame === null) return null;
500
771
  const f = frame as Record<string, unknown>;
501
- if (f.type !== 'doc_init' || typeof f.topic !== 'string') return false;
502
- if (typeof f.current_seq !== 'number') return false;
503
- // `snapshot_version_vector` is an optional durability-handshake input, read
504
- // defensively at its use site. A present-but-non-string value (a future
505
- // server retyping it) is tolerated as absent rather than rejecting the whole
506
- // frame, which would silently break the reconnect / durability path.
507
- if (!Array.isArray(f.tail_ops)) return false;
508
- for (const entry of f.tail_ops) {
509
- if (typeof entry !== 'object' || entry === null) return false;
510
- const e = entry as Record<string, unknown>;
511
- if (typeof e.seq !== 'number') return false;
512
- // A tail op is normally inline here: the server withholds a tail
513
- // carrying a signed URL from this frame, because fetching one needs
514
- // an await this reader does not have, and sends `current_seq` alone
515
- // so the client re-imports the join over HTTP instead. Tolerating
516
- // the shape anyway keeps such a frame's `current_seq` usable rather
517
- // than dropping the whole frame and with it the resync trigger.
518
- if (typeof e.op_blob_b64 !== 'string' && typeof e.op_blob_url !== 'string') {
519
- return false;
772
+ if (f.type !== 'doc_init' || typeof f.topic !== 'string') return null;
773
+ if (typeof f.current_seq !== 'number') return null;
774
+ const tailOps: DocInitTailOp[] = [];
775
+ if (Array.isArray(f.tail_ops)) {
776
+ for (const entry of f.tail_ops) {
777
+ if (typeof entry !== 'object' || entry === null) continue;
778
+ const e = entry as Record<string, unknown>;
779
+ // The seq is what places an entry in the sequence the cursors and the
780
+ // resync trigger are computed from, so an entry without one is not an
781
+ // entry any reader here can use.
782
+ if (typeof e.seq !== 'number') continue;
783
+ tailOps.push(
784
+ typeof e.op_blob_b64 === 'string'
785
+ ? { seq: e.seq, op_blob_b64: e.op_blob_b64 }
786
+ : { seq: e.seq },
787
+ );
520
788
  }
521
789
  }
522
- return true;
790
+ return {
791
+ type: 'doc_init',
792
+ topic: f.topic,
793
+ current_seq: f.current_seq,
794
+ tail_ops: tailOps,
795
+ ...(typeof f.snapshot_seq === 'number' ? { snapshot_seq: f.snapshot_seq } : {}),
796
+ ...(typeof f.snapshot_version_vector === 'string'
797
+ ? { snapshot_version_vector: f.snapshot_version_vector }
798
+ : {}),
799
+ };
800
+ }
801
+
802
+ function isDocCatchUpFrame(frame: unknown): frame is DocCatchUpFrame {
803
+ if (typeof frame !== 'object' || frame === null) return false;
804
+ const f = frame as Record<string, unknown>;
805
+ return (
806
+ f.type === 'doc_catch_up' &&
807
+ typeof f.topic === 'string' &&
808
+ typeof f.current_seq === 'number'
809
+ );
523
810
  }
524
811
 
525
812
  function isOpAckFrame(frame: unknown): frame is OpAckFrame {
@@ -543,6 +830,98 @@ function isOpRejectedFrame(frame: unknown): frame is OpRejectedFrame {
543
830
  );
544
831
  }
545
832
 
833
+ // `{ type: 'error', topic, reason: 'unavailable' }`: the durable write of an
834
+ // op the server accepted and authorised did not go through, and the same op
835
+ // is expected to succeed on a later attempt. `BrassWsClient` leaves the
836
+ // subscription up and routes the frame here (see `isFrameScopedReason` in
837
+ // `topic-lifecycle.ts`). The frame names no `client_op_id`, so it identifies
838
+ // the shed batch and not one op within it.
839
+ /**
840
+ * What one HTTP attempt at an oversized op resolved to.
841
+ *
842
+ * `retryable` carries the same distinction the WS transport gets from an
843
+ * `unavailable` error frame: the server refused the write on capacity and
844
+ * nothing landed, so the op is still the client's to send. Without it, the
845
+ * two transports disagree about the same refusal, and an op that took the
846
+ * HTTP route because it was too large for the socket is the one that gets
847
+ * dropped.
848
+ */
849
+ type OversizedOpResult =
850
+ | { ok: true; seq: number }
851
+ // `atCapacity` and `readOnly` ride beside `retryable` rather than
852
+ // replacing it because the three answer different questions: whether to
853
+ // send this op again, whether the document will take a growing op at all,
854
+ // and whether this writer may write to it at all.
855
+ | {
856
+ ok: false;
857
+ message: string;
858
+ retryable: boolean;
859
+ atCapacity?: boolean;
860
+ // Set when the refusal was about the writer rather than the op, which
861
+ // is the same distinction the socket's `op_rejected` reasons draw:
862
+ // every later op meets it too, so the Document turns read-only.
863
+ readOnly?: ReadOnlyReason;
864
+ };
865
+
866
+ // Whether re-sending the same op is the right move. Two statuses refuse to
867
+ // write rather than answer about the op, and both leave the row untouched,
868
+ // so the op is still the client's to send: 503 (the store refused on
869
+ // capacity) and 429 (the caller is over the op-write cap, whose window
870
+ // resets). The WS transport reads its own rate refusal the same way, as an
871
+ // `unavailable` the shed recovery re-asserts, and the two transports carry
872
+ // the same ops, so a refusal cannot mean "later" on one and "never" on the
873
+ // other. Every other status is an answer about the op itself and repeats
874
+ // however many times it is sent.
875
+ function opWriteIsRetryable(status: number): boolean {
876
+ return status === 503 || status === 429;
877
+ }
878
+
879
+ // The HTTP transport's rendering of the same refusal `op_rejected` carries on
880
+ // the socket: the document is full. 507 is the status, and the body's `code`
881
+ // is checked beside it so the classification does not rest on a status a
882
+ // proxy in front of the api could rewrite.
883
+ function isCapacityRefusal(status: number, parsed: unknown): boolean {
884
+ if (status === 507) return true;
885
+ return (parsed as { code?: unknown } | undefined | null)?.code === 'at_capacity';
886
+ }
887
+
888
+ // A failure uploading an oversized op's blob to storage. It carries its own
889
+ // type because staging runs strictly BEFORE the op write, so what a replay
890
+ // means differs from a throw out of the write itself: nothing durable exists
891
+ // yet and the op is still the client's to send, where a throw mid-write
892
+ // leaves it unknown whether the row landed.
893
+ class OpStagingError extends Error {
894
+ override name = 'OpStagingError';
895
+ // The upstream status, or null for a request that drew no answer at all.
896
+ readonly status: number | null;
897
+ constructor(status: number | null, message: string) {
898
+ super(message);
899
+ this.status = status;
900
+ }
901
+ }
902
+
903
+ // Whether staging the blob again is the right move: a status the op write
904
+ // itself replays replays here for the same reason, and a request that drew
905
+ // no answer wrote nothing either.
906
+ function opStagingIsRetryable(err: OpStagingError): boolean {
907
+ return err.status === null || opWriteIsRetryable(err.status);
908
+ }
909
+
910
+ function asOpStagingError(err: unknown): OpStagingError {
911
+ if (err instanceof OpStagingError) return err;
912
+ const status = err instanceof BrassError ? err.status ?? null : null;
913
+ return new OpStagingError(
914
+ status,
915
+ err instanceof Error ? err.message : String(err),
916
+ );
917
+ }
918
+
919
+ function isServerUnavailableFrame(frame: unknown): boolean {
920
+ if (typeof frame !== 'object' || frame === null) return false;
921
+ const f = frame as Record<string, unknown>;
922
+ return f.type === 'error' && f.reason === 'unavailable';
923
+ }
924
+
546
925
  // The message surfaced through the error reporter for a terminal op rejection.
547
926
  // Prefers the server's copy; falls back to a generic, actionable line. The
548
927
  // local edit already applied but can never persist, so the app's cue is to
@@ -554,6 +933,45 @@ function opRejectedMessage(frame: OpRejectedFrame): string {
554
933
  return 'A change could not be saved and will not be retried. Reload the document to continue.';
555
934
  }
556
935
 
936
+ // The one rejection reason that describes the DOCUMENT rather than the op:
937
+ // every later write meets it too, so the app is told once about the document
938
+ // instead of once per refused edit. Matched on the reason string rather than
939
+ // on the message, which is copy and may be reworded.
940
+ const OP_REJECTED_AT_CAPACITY = 'at_capacity';
941
+
942
+ // Whether the document refused this write because of its size. The server
943
+ // distinguishes a branch that refuses everything from one that refuses only
944
+ // growth, and picks the wording accordingly; the client does not, because both
945
+ // leave refused work on this copy and nowhere else and the answer to that is
946
+ // the same one.
947
+ function isCapacityRejection(frame: OpRejectedFrame): boolean {
948
+ return frame.reason === OP_REJECTED_AT_CAPACITY;
949
+ }
950
+
951
+ // Which refusals describe the WRITER rather than the op. The server reads
952
+ // write authority once, when the connection subscribes, so a refusal on
953
+ // either of these grounds answers every later op the same way: the reader
954
+ // cannot write to this document at all, and the Document says so through
955
+ // `readOnly` instead of letting the app offer edits that keep failing. A
956
+ // refusal about the op itself (too large, a bad topic) leaves the document
957
+ // writable, because the next op may well be accepted.
958
+ //
959
+ // A Map rather than an object literal because the key is a wire string: an
960
+ // object answers for the names it inherits, so a reason reading `constructor`
961
+ // or `toString` would resolve a function and lock the document, which is the
962
+ // opposite of what an unclassified refusal means.
963
+ const READ_ONLY_REJECTION_REASONS = new Map<string, ReadOnlyReason>([
964
+ ['role_denied', 'grant'],
965
+ ['scope_denied', 'app'],
966
+ ]);
967
+
968
+ function readOnlyReasonForRejection(
969
+ frame: OpRejectedFrame,
970
+ ): ReadOnlyReason | null {
971
+ if (typeof frame.reason !== 'string') return null;
972
+ return READ_ONLY_REJECTION_REASONS.get(frame.reason) ?? null;
973
+ }
974
+
557
975
  function isDocGoneFrame(frame: unknown): frame is DocGoneFrame {
558
976
  if (typeof frame !== 'object' || frame === null) return false;
559
977
  const f = frame as Record<string, unknown>;
@@ -593,6 +1011,25 @@ function isDocStatusFrame(frame: unknown): frame is DocStatusFrame {
593
1011
  return typeof st.pending === 'boolean';
594
1012
  }
595
1013
 
1014
+ // `message` is typed non-null and rendered verbatim, while the wire gates
1015
+ // only on `pending`, so default it here.
1016
+ function normalizeWorkStatus(status: DocumentWorkStatus): DocumentWorkStatus {
1017
+ return {
1018
+ ...status,
1019
+ message: typeof status.message === 'string' ? status.message : '',
1020
+ };
1021
+ }
1022
+
1023
+ // The opaque work status a join response carries, or `null` when it carries
1024
+ // none. Gates on `pending` like `isDocStatusFrame`; the rest is display data
1025
+ // read defensively at the use site.
1026
+ function parseWorkStatus(value: unknown): DocumentWorkStatus | null {
1027
+ if (typeof value !== 'object' || value === null) return null;
1028
+ const status = value as DocumentWorkStatus;
1029
+ if (typeof status.pending !== 'boolean') return null;
1030
+ return normalizeWorkStatus(status);
1031
+ }
1032
+
596
1033
  function isDocMetaFrame(frame: unknown): frame is DocMetaFrame {
597
1034
  if (typeof frame !== 'object' || frame === null) return false;
598
1035
  const f = frame as Record<string, unknown>;
@@ -603,6 +1040,15 @@ function isDocMetaFrame(frame: unknown): frame is DocMetaFrame {
603
1040
  );
604
1041
  }
605
1042
 
1043
+ function isDocDrivePublishFrame(frame: unknown): frame is DocDrivePublishFrame {
1044
+ if (typeof frame !== 'object' || frame === null) return false;
1045
+ const f = frame as Record<string, unknown>;
1046
+ if (f.type !== 'doc_drive_publish' || typeof f.topic !== 'string') {
1047
+ return false;
1048
+ }
1049
+ return typeof f.publish === 'object' && f.publish !== null;
1050
+ }
1051
+
606
1052
  function isDocDriveStatusFrame(frame: unknown): frame is DocDriveStatusFrame {
607
1053
  if (typeof frame !== 'object' || frame === null) return false;
608
1054
  const f = frame as Record<string, unknown>;
@@ -625,12 +1071,10 @@ function isDocDriveStatusFrame(frame: unknown): frame is DocDriveStatusFrame {
625
1071
  function isDocOpsDroppedFrame(frame: unknown): frame is DocOpsDroppedFrame {
626
1072
  if (typeof frame !== 'object' || frame === null) return false;
627
1073
  const f = frame as Record<string, unknown>;
628
- return (
629
- f.type === 'doc_ops_dropped' &&
630
- typeof f.topic === 'string' &&
631
- Array.isArray(f.dropped_seqs) &&
632
- typeof f.actor === 'string'
633
- );
1074
+ // Gate on the discriminant alone. The handler reads no other field, so a
1075
+ // frame whose informational half changed shape (or went away) must still
1076
+ // prompt the durability re-check it is sent for.
1077
+ return f.type === 'doc_ops_dropped' && typeof f.topic === 'string';
634
1078
  }
635
1079
 
636
1080
  function defaultClientOpIdMinter(): string {
@@ -679,6 +1123,18 @@ function isSnapshotResponse(body: unknown): body is DocumentSnapshotResponse {
679
1123
  return true;
680
1124
  }
681
1125
 
1126
+ // Fold the join's write verdict into the Document options, so a reader the
1127
+ // server will not accept writes from opens read-only instead of learning it
1128
+ // from the first refused op. Only ever ADDS the restriction: a caller that
1129
+ // asked for a read-only Document keeps it whatever the grant says.
1130
+ function readOnlyForGrant(
1131
+ body: DocumentSnapshotResponse,
1132
+ options: DocumentOptions | undefined,
1133
+ ): DocumentOptions | undefined {
1134
+ if (body.can_edit !== false) return options;
1135
+ return { ...options, readOnly: true, readOnlyReason: 'grant' };
1136
+ }
1137
+
682
1138
  function isAtVersionResponse(
683
1139
  body: unknown,
684
1140
  ): body is DocumentAtVersionResponse {
@@ -732,14 +1188,30 @@ export function createOpenDocument(
732
1188
  applyClientVersionHeader(headers);
733
1189
  await applyAppAssertionHeader(headers, opts.getAppAssertion);
734
1190
  const endFetch = perfStart('open: GET /documents (http + read body)');
735
- // Idempotent read: ride out a cold-API transient 5xx / 429 (a 503
736
- // while the API scales into a concurrent open burst is the common
737
- // one) instead of failing the open on a blip that clears in a second.
738
- const response = await fetchWithRetry(() =>
739
- fetch(url, {
740
- method: 'GET',
741
- headers,
742
- }),
1191
+ // Idempotent read: ride out a transient 5xx / 429 instead of failing the
1192
+ // open on something that clears on its own.
1193
+ //
1194
+ // Longer than the default handful of attempts because of what a 503 means
1195
+ // on this route: the document is intact and its storage is busy. Every op
1196
+ // row a document has shares one partition, so the document most likely to
1197
+ // answer 503 is one that was just written hard, which is exactly the
1198
+ // moment a person reloads to see their work. That pressure clears over
1199
+ // tens of seconds, not the one second the default budget spans, and the
1200
+ // cost of giving up early is telling someone their document will not open
1201
+ // when waiting would have opened it.
1202
+ //
1203
+ // A thrown request gets a short budget of its own: it never reached the
1204
+ // API, so the busy partition is not what is wrong, and a blocked origin
1205
+ // or a host that does not resolve answers the seventh attempt as it
1206
+ // answered the first, with the reader watching a loading surface
1207
+ // throughout.
1208
+ const response = await fetchWithRetry(
1209
+ () =>
1210
+ fetch(url, {
1211
+ method: 'GET',
1212
+ headers,
1213
+ }),
1214
+ { maxAttempts: 7, baseDelayMs: 700, maxNetworkAttempts: 3 },
743
1215
  );
744
1216
  const text = await response.text();
745
1217
  endFetch({ status: response.status, responseChars: text.length });
@@ -752,7 +1224,7 @@ export function createOpenDocument(
752
1224
  parseFailed = true;
753
1225
  }
754
1226
  }
755
- await resolveParsedTailOps(parsed);
1227
+ await resolveParsedTailOps(parsed, options?.onSnapshotProgress);
756
1228
  return { status: response.status, parsed, parseFailed };
757
1229
  }
758
1230
 
@@ -898,37 +1370,54 @@ export function createOpenDocument(
898
1370
  opBlobB64: string,
899
1371
  token: string,
900
1372
  ): Promise<string> {
901
- const headers: Record<string, string> = {
902
- authorization: `Bearer ${token}`,
903
- 'content-type': 'application/json',
904
- };
905
- applyClientVersionHeader(headers);
906
- await applyAppAssertionHeader(headers, opts.getAppAssertion);
907
- const mint = await fetch(
908
- `${baseUrl}/documents/${encodeURIComponent(docId)}/ops/upload`,
909
- { method: 'POST', headers, body: '{}' },
910
- );
911
- if (!mint.ok) {
912
- const text = await mint.text();
913
- let parsed: unknown = undefined;
1373
+ // Everything in here happens before the op write, so every failure it
1374
+ // can take is tagged as one: what the tag buys the writer is the right
1375
+ // to send the op again, which a failure of the write itself does not
1376
+ // carry.
1377
+ try {
1378
+ const headers: Record<string, string> = {
1379
+ authorization: `Bearer ${token}`,
1380
+ 'content-type': 'application/json',
1381
+ };
1382
+ applyClientVersionHeader(headers);
1383
+ await applyAppAssertionHeader(headers, opts.getAppAssertion);
1384
+ const mint = await fetchWithDeadline(
1385
+ `${baseUrl}/documents/${encodeURIComponent(docId)}/ops/upload`,
1386
+ { method: 'POST', headers, body: '{}' },
1387
+ OP_WRITE_TIMEOUT_MS,
1388
+ );
1389
+ let minted: unknown = undefined;
914
1390
  try {
915
- parsed = text ? JSON.parse(text) : undefined;
1391
+ minted = mint.text ? JSON.parse(mint.text) : undefined;
916
1392
  } catch {
917
- parsed = undefined;
1393
+ minted = undefined;
918
1394
  }
919
- throw brassErrorFromBody(mint.status, parsed);
920
- }
921
- const body = (await mint.json()) as { upload_key?: unknown; url?: unknown };
922
- if (typeof body.upload_key !== 'string' || typeof body.url !== 'string') {
923
- throw new BrassError('Malformed op upload response', { status: 500 });
924
- }
925
- const endUpload = perfStart('op: PUT staged op blob');
926
- const put = await fetch(body.url, { method: 'PUT', body: opBlobB64 });
927
- endUpload({ status: put.status, opChars: opBlobB64.length });
928
- if (!put.ok) {
929
- throw new BrassError('Could not upload the update', { status: put.status });
1395
+ if (!mint.ok) {
1396
+ throw brassErrorFromBody(mint.status, minted);
1397
+ }
1398
+ const body = (minted ?? {}) as {
1399
+ upload_key?: unknown;
1400
+ url?: unknown;
1401
+ };
1402
+ if (typeof body.upload_key !== 'string' || typeof body.url !== 'string') {
1403
+ throw new BrassError('Malformed op upload response', { status: 500 });
1404
+ }
1405
+ const endUpload = perfStart('op: PUT staged op blob');
1406
+ const put = await fetchWithDeadline(
1407
+ body.url,
1408
+ { method: 'PUT', body: opBlobB64 },
1409
+ deadlineForUpload(opBlobB64.length),
1410
+ );
1411
+ endUpload({ status: put.status, opChars: opBlobB64.length });
1412
+ if (!put.ok) {
1413
+ throw new BrassError('Could not upload the update', {
1414
+ status: put.status,
1415
+ });
1416
+ }
1417
+ return body.upload_key;
1418
+ } catch (err) {
1419
+ throw asOpStagingError(err);
930
1420
  }
931
- return body.upload_key;
932
1421
  }
933
1422
 
934
1423
  async function postDocumentOp(
@@ -952,7 +1441,7 @@ export function createOpenDocument(
952
1441
  stagedKey = await stageOpBlob(docId, opBlobB64, token);
953
1442
  }
954
1443
  const endFetch = perfStart('op: POST /documents/{id}/ops');
955
- const response = await fetch(
1444
+ const response = await fetchWithDeadline(
956
1445
  `${baseUrl}/documents/${encodeURIComponent(docId)}/ops`,
957
1446
  {
958
1447
  method: 'POST',
@@ -965,12 +1454,14 @@ export function createOpenDocument(
965
1454
  client_op_id: clientOpId,
966
1455
  }),
967
1456
  },
1457
+ // An op under the staging threshold travels inline, so this request
1458
+ // carries the blob and its deadline has to allow for sending it.
1459
+ deadlineForUpload(stagedKey === undefined ? opBlobB64.length : 0),
968
1460
  );
969
- const text = await response.text();
970
1461
  endFetch({ status: response.status, opChars: opBlobB64.length });
971
1462
  let parsed: unknown = undefined;
972
1463
  try {
973
- parsed = text ? JSON.parse(text) : undefined;
1464
+ parsed = response.text ? JSON.parse(response.text) : undefined;
974
1465
  } catch {
975
1466
  parsed = undefined;
976
1467
  }
@@ -1136,7 +1627,9 @@ export function createOpenDocument(
1136
1627
  status: 500,
1137
1628
  });
1138
1629
  }
1139
- const doc = await materialise(parsed);
1630
+ // A stream's write authority is the document's, so the line opens
1631
+ // read-only for the same reader the document opens read-only for.
1632
+ const doc = await materialise(parsed, readOnlyForGrant(parsed, undefined));
1140
1633
  // A stream line materializes at a historical seq the same way the
1141
1634
  // default line does, so the parent's version preview can bring this
1142
1635
  // stream to a point in time (`doc.stream(name)._previewAt(seq)`). The
@@ -1154,8 +1647,15 @@ export function createOpenDocument(
1154
1647
  ),
1155
1648
  typeof parsed.current_seq === 'number' ? parsed.current_seq : 0,
1156
1649
  );
1157
- let coveredSeq =
1158
- typeof parsed.current_seq === 'number' ? parsed.current_seq : 0;
1650
+ const lineTailSeqs = parsed.tail_ops.map((entry) => entry.seq);
1651
+ let coveredSeq = joinCoverageFloor(
1652
+ snapshotSeqOf(
1653
+ parsed.snapshot_seq,
1654
+ lineTailSeqs,
1655
+ typeof parsed.current_seq === 'number' ? parsed.current_seq : 0,
1656
+ ),
1657
+ lineTailSeqs,
1658
+ );
1159
1659
  const coverageParked = new Set<number>();
1160
1660
  const advanceCoverage = (seq: number): void => {
1161
1661
  if (seq <= coveredSeq) return;
@@ -1165,45 +1665,77 @@ export function createOpenDocument(
1165
1665
  coveredSeq += 1;
1166
1666
  }
1167
1667
  };
1668
+ const raiseCoverage = (floor: number): void => {
1669
+ if (floor > coveredSeq) coveredSeq = floor;
1670
+ for (const seq of coverageParked) {
1671
+ if (seq <= coveredSeq) coverageParked.delete(seq);
1672
+ }
1673
+ while (coverageParked.has(coveredSeq + 1)) {
1674
+ coverageParked.delete(coveredSeq + 1);
1675
+ coveredSeq += 1;
1676
+ }
1677
+ };
1168
1678
  for (const entry of parsed.tail_ops) advanceCoverage(entry.seq);
1169
1679
 
1170
- // Reconnect-across-compaction recovery: re-import the line's join.
1171
- // Loro imports are idempotent, so re-applying over live state is a
1172
- // clean merge.
1173
- const resyncFromSnapshot = (): void => {
1174
- void (async () => {
1175
- try {
1176
- const t = await requireToken();
1177
- const res = await fetchSnapshot(docId, { branch: lineId }, t);
1178
- if (res.status < 200 || res.status >= 300) return;
1179
- if (!isSnapshotResponse(res.parsed)) return;
1180
- const snap = res.parsed.snapshot;
1181
- if (snap !== null) {
1182
- const bytes =
1183
- snap.kind === 'inline'
1184
- ? base64ToBytes(snap.data_b64)
1185
- : await fetchPresignedSnapshot(snap.url);
1186
- doc._applySnapshot(bytes);
1187
- }
1188
- for (const entry of res.parsed.tail_ops) {
1189
- doc.applyRemoteOp(base64ToBytes(entry.op_blob_b64));
1190
- if (entry.seq > currentSeq) currentSeq = entry.seq;
1191
- advanceCoverage(entry.seq);
1192
- }
1193
- const joined = res.parsed.current_seq;
1194
- if (typeof joined === 'number') {
1195
- if (joined > currentSeq) currentSeq = joined;
1196
- if (joined > coveredSeq) {
1197
- coveredSeq = joined;
1198
- for (const seq of coverageParked) {
1199
- if (seq <= coveredSeq) coverageParked.delete(seq);
1200
- }
1680
+ // Re-import the line's join: the reconnect-across-compaction recovery,
1681
+ // and the answer to a catch-up prompt. Loro imports are idempotent, so
1682
+ // re-applying over live state is a clean merge.
1683
+ //
1684
+ // Coalesced and delivery-honest for the same reasons the default line
1685
+ // is: a prompt during a fetch is the only notice of that op the line
1686
+ // gets, and a join's `current_seq` names seqs whose rows the tail query
1687
+ // could not see yet.
1688
+ let lineResyncRun: Promise<void> | null = null;
1689
+ let lineResyncAgain = false;
1690
+ const runLineResync = async (): Promise<void> => {
1691
+ try {
1692
+ do {
1693
+ lineResyncAgain = false;
1694
+ try {
1695
+ const t = await requireToken();
1696
+ const res = await fetchSnapshot(docId, { branch: lineId }, t);
1697
+ if (res.status < 200 || res.status >= 300) continue;
1698
+ if (!isSnapshotResponse(res.parsed)) continue;
1699
+ const snap = res.parsed.snapshot;
1700
+ if (snap !== null) {
1701
+ const bytes =
1702
+ snap.kind === 'inline'
1703
+ ? base64ToBytes(snap.data_b64)
1704
+ : await fetchPresignedSnapshot(snap.url);
1705
+ doc._applySnapshot(bytes);
1201
1706
  }
1707
+ const seqs: number[] = [];
1708
+ for (const entry of res.parsed.tail_ops) {
1709
+ doc.applyRemoteOp(base64ToBytes(entry.op_blob_b64));
1710
+ if (entry.seq > currentSeq) currentSeq = entry.seq;
1711
+ seqs.push(entry.seq);
1712
+ }
1713
+ const joined = res.parsed.current_seq;
1714
+ if (typeof joined === 'number' && joined > currentSeq) {
1715
+ currentSeq = joined;
1716
+ }
1717
+ raiseCoverage(
1718
+ joinCoverageFloor(
1719
+ snapshotSeqOf(res.parsed.snapshot_seq, seqs, currentSeq),
1720
+ seqs,
1721
+ ),
1722
+ );
1723
+ for (const seq of seqs) advanceCoverage(seq);
1724
+ } catch {
1725
+ // Best-effort; the next prompt or doc_init retries.
1202
1726
  }
1203
- } catch {
1204
- // Best-effort; the next doc_init retries.
1205
- }
1206
- })();
1727
+ } while (lineResyncAgain);
1728
+ } finally {
1729
+ lineResyncRun = null;
1730
+ }
1731
+ };
1732
+ const resyncFromSnapshot = (): Promise<void> => {
1733
+ if (lineResyncRun !== null) {
1734
+ lineResyncAgain = true;
1735
+ return lineResyncRun;
1736
+ }
1737
+ lineResyncRun = runLineResync();
1738
+ return lineResyncRun;
1207
1739
  };
1208
1740
 
1209
1741
  // Write an op the socket cannot carry. The op stays one commit and
@@ -1212,7 +1744,7 @@ export function createOpenDocument(
1212
1744
  const postOversizedOp = async (
1213
1745
  opBlobB64: string,
1214
1746
  clientOpId: string,
1215
- ): Promise<{ ok: true; seq: number } | { ok: false; message: string }> => {
1747
+ ): Promise<OversizedOpResult> => {
1216
1748
  const token = await requireToken();
1217
1749
  const { status, parsed: opResult } = await postDocumentOp(
1218
1750
  docId,
@@ -1222,11 +1754,23 @@ export function createOpenDocument(
1222
1754
  token,
1223
1755
  );
1224
1756
  if (status < 200 || status >= 300) {
1225
- return { ok: false, message: opWriteFailureMessage(status, opResult) };
1757
+ return {
1758
+ ok: false,
1759
+ message: opWriteFailureMessage(status, opResult),
1760
+ retryable: opWriteIsRetryable(status),
1761
+ atCapacity: isCapacityRefusal(status, opResult),
1762
+ // The route answers 403 when the caller's grant carries no write
1763
+ // access, which is the HTTP face of the socket's `role_denied`.
1764
+ ...(status === 403 ? { readOnly: 'grant' as const } : {}),
1765
+ };
1226
1766
  }
1227
1767
  const seq = (opResult as { seq?: unknown } | undefined)?.seq;
1228
1768
  if (typeof seq !== 'number') {
1229
- return { ok: false, message: 'The server did not report a version for this change.' };
1769
+ return {
1770
+ ok: false,
1771
+ message: 'The server did not report a version for this change.',
1772
+ retryable: false,
1773
+ };
1230
1774
  }
1231
1775
  return { ok: true, seq };
1232
1776
  };
@@ -1267,17 +1811,26 @@ export function createOpenDocument(
1267
1811
  }
1268
1812
  const doc = await materialise(
1269
1813
  parsed,
1270
- options?.documentOptions,
1814
+ readOnlyForGrant(parsed, options?.documentOptions),
1271
1815
  options?.onSnapshotProgress,
1272
1816
  );
1273
1817
  // Populate `doc.status` from the join before the first `doc_status` frame
1274
1818
  // (a stub opened mid-hydration returns `snapshot: null` + a `pending`
1275
1819
  // status).
1276
1820
  if (parsed.status !== undefined) doc._emitStatus(parsed.status);
1821
+ // Whether this app is the one to serialize the document, which is what
1822
+ // decides whether a push carries bytes. Answered by the server for the
1823
+ // caller's own app and only on the join, since the fanout frame is one
1824
+ // payload for every subscriber and has no caller to answer for.
1825
+ doc._setExportRunsHere(parsed.calling_app_exports === true);
1277
1826
  // Seed `doc.driveStatus` from the join before the first
1278
1827
  // `doc_drive_status` frame.
1279
1828
  if (parsed.drive !== undefined) {
1280
1829
  doc._emitDriveStatus(toDriveStatus(parsed.drive));
1830
+ } else {
1831
+ // Only for a document that mirrors nothing: a mirror publishes through
1832
+ // its link, and `_emitDriveStatus` clears any targets anyway.
1833
+ doc._setPublishTarget(toPublishTarget(parsed.drive_publish_target));
1281
1834
  }
1282
1835
  // Highest seq seen; later `doc_op` / `op_ack` frames advance it. Kept in
1283
1836
  // a closure, not on the Document: it is a transport concern, not CRDT
@@ -1296,10 +1849,21 @@ export function createOpenDocument(
1296
1849
  // then can't tell "caught up" from "missed ops since compacted into the
1297
1850
  // snapshot". The floor never overestimates, so the `doc_init` resync
1298
1851
  // check always detects such a hole (at worst a transient out-of-order
1299
- // arrival costs one idempotent re-fetch). Seeded from the consistent-read
1300
- // join's `current_seq`.
1301
- let coveredSeq =
1302
- typeof parsed.current_seq === 'number' ? parsed.current_seq : 0;
1852
+ // arrival costs one idempotent re-fetch).
1853
+ //
1854
+ // Seeded from what this join DELIVERED, which is its snapshot plus the
1855
+ // unbroken run of tail seqs above it, never from `current_seq`: a join
1856
+ // read while someone writes in bulk reports a counter above the rows it
1857
+ // could see, and seeding from the counter opens the document already
1858
+ // claiming ops it does not hold.
1859
+ let coveredSeq = joinCoverageFloor(
1860
+ snapshotSeqOf(
1861
+ parsed.snapshot_seq,
1862
+ parsed.tail_ops.map((entry) => entry.seq),
1863
+ typeof parsed.current_seq === 'number' ? parsed.current_seq : 0,
1864
+ ),
1865
+ parsed.tail_ops.map((entry) => entry.seq),
1866
+ );
1303
1867
  // Seqs applied above the floor, parked until the gap below fills.
1304
1868
  const coverageParked = new Set<number>();
1305
1869
  const drainCoverage = (): void => {
@@ -1313,9 +1877,11 @@ export function createOpenDocument(
1313
1877
  coverageParked.add(seq);
1314
1878
  drainCoverage();
1315
1879
  };
1316
- // Mark everything at or below `floor` covered (a full consistent join was
1317
- // imported). Also steps the floor past any abandoned server-side seq
1318
- // (allocated, never written) that `advanceCoverage` would stall under.
1880
+ // Mark everything at or below `floor` covered. The caller owes the
1881
+ // guarantee that goes with it: every seq under `floor` is applied, or
1882
+ // folded into a snapshot this doc imported. Coverage is what retires a
1883
+ // catch-up prompt, so a floor taken from anything looser than that is a
1884
+ // reader quietly deciding it holds ops nobody sent it.
1319
1885
  const raiseCoverage = (floor: number): void => {
1320
1886
  if (floor > coveredSeq) coveredSeq = floor;
1321
1887
  for (const seq of coverageParked) {
@@ -1386,14 +1952,38 @@ export function createOpenDocument(
1386
1952
  if (entry.seq > currentSeq) currentSeq = entry.seq;
1387
1953
  if (entry.seq > maxTailSeq) maxTailSeq = entry.seq;
1388
1954
  }
1389
- // Raise both cursors so the floor reflects the import (this clears a
1390
- // detected missed-compaction hole).
1391
1955
  const fetchedSeq =
1392
1956
  typeof result.parsed.current_seq === 'number'
1393
1957
  ? result.parsed.current_seq
1394
1958
  : maxTailSeq;
1395
1959
  if (fetchedSeq > currentSeq) currentSeq = fetchedSeq;
1396
- raiseCoverage(fetchedSeq);
1960
+ // Coverage moves to what this response DELIVERED, not to the head it
1961
+ // reported. The two differ exactly when it matters: the head is the
1962
+ // branch's seq counter, bumped when a seq is handed out rather than
1963
+ // when its row is written, so a join read during a bulk write names
1964
+ // ops the tail query could not see. Crediting the head marks those
1965
+ // seqs covered, and the prompts that would fetch them then short
1966
+ // circuit, which is a reader left permanently short of a write it
1967
+ // watched land.
1968
+ //
1969
+ // The cost is that a seq allocated and never written (a failed op
1970
+ // write leaves a hole rather than a duplicate) now stalls the floor
1971
+ // under it. That resolves at the next compaction, which folds the tail
1972
+ // past the hole and raises `snapshot_seq` over it; until then the gap
1973
+ // pull's own budget is what stops the reader asking forever.
1974
+ const tailSeqs = sortedTail.map((entry) => entry.seq);
1975
+ // A snapshot only counts as a floor once its bytes are in: a shape this
1976
+ // pin does not decode leaves `snapshot_seq` describing content the doc
1977
+ // never received, and the tail alone is then what it holds.
1978
+ if (snapshotBytes !== null || result.parsed.snapshot === null) {
1979
+ raiseCoverage(
1980
+ joinCoverageFloor(
1981
+ snapshotSeqOf(result.parsed.snapshot_seq, tailSeqs, fetchedSeq),
1982
+ tailSeqs,
1983
+ ),
1984
+ );
1985
+ }
1986
+ for (const seq of tailSeqs) advanceCoverage(seq);
1397
1987
  endPerf({
1398
1988
  ok: true,
1399
1989
  snapshotBytes: snapshotBytes?.byteLength ?? 0,
@@ -1429,27 +2019,55 @@ export function createOpenDocument(
1429
2019
  return out;
1430
2020
  };
1431
2021
 
1432
- let resyncInFlight = false;
1433
- const resyncFromSnapshot = (): void => {
1434
- if (resyncInFlight) return;
1435
- resyncInFlight = true;
1436
- void (async () => {
1437
- try {
1438
- const outcome = await refetchAndImport();
1439
- if (!outcome.ok) {
2022
+ // Coalesced, not skipped. A prompt that lands while a join is in flight
2023
+ // is a statement that the document has moved past what that join can
2024
+ // possibly carry, so dropping it loses the only notice this reader gets:
2025
+ // the server posts each prompt once, and a passive reader has no ack or
2026
+ // op of its own to notice the shortfall from. A bulk write is a run of
2027
+ // prompts (every op past the frame bound takes the HTTP route and
2028
+ // prompts each subscriber), and under a plain skip-if-in-flight the
2029
+ // whole run collapses into the one or two joins that happened not to be
2030
+ // busy, which is a reader stranded partway through a write it watched.
2031
+ //
2032
+ // `resyncAgain` is a flag rather than a queue because a join reads the
2033
+ // whole document: one more pass answers every prompt that arrived during
2034
+ // this one. The loop therefore runs at most one extra fetch after the
2035
+ // last prompt, and settles when the writer stops.
2036
+ //
2037
+ // The returned promise settles when the last pass this call is
2038
+ // responsible for has landed, so a caller deciding whether the reader is
2039
+ // still short reads the floor after the work rather than during it.
2040
+ let resyncRun: Promise<void> | null = null;
2041
+ let resyncAgain = false;
2042
+ const runResync = async (): Promise<void> => {
2043
+ try {
2044
+ do {
2045
+ resyncAgain = false;
2046
+ try {
2047
+ const outcome = await refetchAndImport();
2048
+ if (!outcome.ok) {
2049
+ console.error(
2050
+ 'Brass: document resync after reconnect failed; state may lag until the next reconnect or reload',
2051
+ );
2052
+ }
2053
+ } catch (err) {
1440
2054
  console.error(
1441
2055
  'Brass: document resync after reconnect failed; state may lag until the next reconnect or reload',
2056
+ err,
1442
2057
  );
1443
2058
  }
1444
- } catch (err) {
1445
- console.error(
1446
- 'Brass: document resync after reconnect failed; state may lag until the next reconnect or reload',
1447
- err,
1448
- );
1449
- } finally {
1450
- resyncInFlight = false;
1451
- }
1452
- })();
2059
+ } while (resyncAgain);
2060
+ } finally {
2061
+ resyncRun = null;
2062
+ }
2063
+ };
2064
+ const resyncFromSnapshot = (): Promise<void> => {
2065
+ if (resyncRun !== null) {
2066
+ resyncAgain = true;
2067
+ return resyncRun;
2068
+ }
2069
+ resyncRun = runResync();
2070
+ return resyncRun;
1453
2071
  };
1454
2072
 
1455
2073
  // When a doc opened mid-load finishes, pull the now-available content
@@ -1457,90 +2075,84 @@ export function createOpenDocument(
1457
2075
  // called on the server's authoritative `reload` hint, so it never has to
1458
2076
  // tell success from failure: a failure carries no `reload`, so no racy
1459
2077
  // read and no loop against a failed hydration.
1460
- //
1461
- // A failed fetch here is transient (network, dropped presigned download)
1462
- // and is retried with backoff: a silent give-up would strand the doc
1463
- // empty-but-editable, reading as "the model never loaded" and invisible
1464
- // in every log. On exhaustion the failure is reported and the terminal
1465
- // status is an honest failure, not the server's success copy.
1466
2078
  let reloadInFlight = false;
1467
- const RELOAD_ATTEMPTS = 5;
1468
- const RELOAD_BACKOFF_MS = 1000;
2079
+ let reconcileInFlight = false;
2080
+ // A terminal frame that lands while the subscribe-time reconcile is still
2081
+ // fetching waits for it: two concurrent imports each clear the undo stack
2082
+ // and race their terminal emits, and dropping the frame would leave a
2083
+ // reconcile that read the row too early sitting on the pending overlay.
2084
+ let deferredReload: DocumentWorkStatus | null = null;
2085
+ // Armed when the pull below gives up. The work is terminal server-side, so
2086
+ // no further status frame arrives, and later ops are causally dependent on
2087
+ // a snapshot this client never imported (Loro buffers them and the doc
2088
+ // stays empty), so the next frame on the topic drives one more attempt.
2089
+ let retryPullOnNextFrame = false;
1469
2090
  const reloadAfterHydration = (finalStatus: DocumentWorkStatus): void => {
1470
2091
  if (reloadInFlight) return;
2092
+ if (reconcileInFlight) {
2093
+ deferredReload = finalStatus;
2094
+ return;
2095
+ }
1471
2096
  reloadInFlight = true;
1472
2097
  void (async () => {
1473
- let lastError: Error | null = null;
1474
- for (let attempt = 1; attempt <= RELOAD_ATTEMPTS; attempt += 1) {
1475
- try {
1476
- const outcome = await refetchAndImport();
1477
- if (outcome.ok) {
1478
- reloadInFlight = false;
1479
- // Emit the terminal status AFTER the import settles, so an app
1480
- // re-reading on `onStatus` sees the content present and (with
1481
- // `readOnly` now false) clears its loading overlay.
1482
- doc._emitStatus(finalStatus);
1483
- return;
1484
- }
1485
- lastError = new Error(outcome.reason);
1486
- } catch (err) {
1487
- lastError = err instanceof Error ? err : new Error(String(err));
1488
- }
1489
- // A retrying attempt is exceptional, so this warn is quiet in
1490
- // normal operation and decisive in a bug report; without it a
1491
- // failure here is undiagnosable from any log.
1492
- console.warn(
1493
- `[Brass] document content reload attempt ${attempt}/${RELOAD_ATTEMPTS} failed (doc ${docId}): ${lastError.message}`,
1494
- );
1495
- if (attempt < RELOAD_ATTEMPTS) {
1496
- await new Promise((r) => setTimeout(r, RELOAD_BACKOFF_MS * attempt));
1497
- }
1498
- }
1499
- reloadInFlight = false;
1500
- const error = new BrassError(
1501
- `Document content reload failed after ${RELOAD_ATTEMPTS} attempts: ${lastError?.message ?? 'unknown error'}`,
1502
- { status: 0 },
1503
- );
1504
- if (opts.reportError) opts.reportError(error);
1505
- else console.error('[Brass]', error);
1506
- doc._emitStatus({
1507
- message: 'The model could not be loaded. Reload to try again.',
1508
- pending: false,
2098
+ const imported = await pullContentWithRetry({
2099
+ doc,
2100
+ docId,
2101
+ finalStatus,
2102
+ fetchAndImport: refetchAndImport,
2103
+ ...(opts.reportError ? { reportError: opts.reportError } : {}),
1509
2104
  });
2105
+ reloadInFlight = false;
2106
+ retryPullOnNextFrame = !imported;
1510
2107
  })();
1511
2108
  };
1512
2109
 
2110
+ // Evidence the document moved on the server after a give-up: one more
2111
+ // pull, which on success imports the content and releases the lock the
2112
+ // failed terminal status left in place.
2113
+ const retryContentPull = (): void => {
2114
+ if (!retryPullOnNextFrame) return;
2115
+ retryPullOnNextFrame = false;
2116
+ reloadAfterHydration(WORK_READY_STATUS);
2117
+ };
2118
+
1513
2119
  // Reconcile a pending open once the live subscription exists. The
1514
2120
  // terminal `doc_status` frame is best-effort: if hydration finishes
1515
- // before this client's SUB# row exists, the fan-out finds no subscriber
2121
+ // before this client's subscription record exists, the fan-out finds no subscriber
1516
2122
  // and drops the frame, stranding the app on the pending overlay. `doc_init`
1517
- // is posted only after the SUB# row is written, so one re-fetch here
2123
+ // is posted only after the subscription record is written, so one re-fetch here
1518
2124
  // closes the gap for a completion that landed before the row existed.
1519
2125
  // Only a pending open pays the extra fetch.
1520
- let reconcileInFlight = false;
1521
2126
  const reconcileStatusOnInit = (): void => {
1522
2127
  const current = doc.status;
1523
2128
  if (!current || !current.pending) return;
1524
2129
  if (reconcileInFlight || reloadInFlight) return;
1525
2130
  reconcileInFlight = true;
1526
2131
  void (async () => {
2132
+ let settled = false;
1527
2133
  try {
1528
2134
  const outcome = await refetchAndImport();
1529
2135
  if (!outcome.ok) return;
1530
2136
  // Still pending: leave the overlay up; subscribed now, so the live
1531
2137
  // fan-out delivers the terminal frame when the work finishes.
1532
2138
  if (outcome.status?.pending) return;
1533
- // Terminal. A present status is a failure (server message,
1534
- // pending:false); an absent status is success. On success retain
1535
- // the last server message and flip pending so the overlay clears.
1536
- doc._emitStatus(
1537
- outcome.status ?? { message: current.message, pending: false },
1538
- );
2139
+ settled = true;
2140
+ // Terminal. A status the server still holds is a failure (success
2141
+ // clears the row), and its absence is the success this import just
2142
+ // pulled the content for, reported as the same terminal frame the
2143
+ // platform sends on that transition.
2144
+ if (outcome.status) doc._emitStatus(outcome.status);
2145
+ else doc._emitStatus(WORK_READY_STATUS, { contentLoaded: true });
1539
2146
  } catch {
1540
2147
  // Best-effort; the live fan-out remains the primary path when
1541
2148
  // the work finishes after this subscribe.
1542
2149
  } finally {
1543
2150
  reconcileInFlight = false;
2151
+ const deferred = deferredReload;
2152
+ deferredReload = null;
2153
+ // A settled reconcile imported the content itself, so the held
2154
+ // frame has nothing left to pull.
2155
+ if (deferred && !settled) reloadAfterHydration(deferred);
1544
2156
  }
1545
2157
  })();
1546
2158
  };
@@ -1552,7 +2164,7 @@ export function createOpenDocument(
1552
2164
  const postOversizedOp = async (
1553
2165
  opBlobB64: string,
1554
2166
  clientOpId: string,
1555
- ): Promise<{ ok: true; seq: number } | { ok: false; message: string }> => {
2167
+ ): Promise<OversizedOpResult> => {
1556
2168
  const token = await requireToken();
1557
2169
  const { status, parsed: opResult } = await postDocumentOp(
1558
2170
  docId,
@@ -1562,13 +2174,22 @@ export function createOpenDocument(
1562
2174
  token,
1563
2175
  );
1564
2176
  if (status < 200 || status >= 300) {
1565
- return { ok: false, message: opWriteFailureMessage(status, opResult) };
2177
+ return {
2178
+ ok: false,
2179
+ message: opWriteFailureMessage(status, opResult),
2180
+ retryable: opWriteIsRetryable(status),
2181
+ atCapacity: isCapacityRefusal(status, opResult),
2182
+ // The route answers 403 when the caller's grant carries no write
2183
+ // access, which is the HTTP face of the socket's `role_denied`.
2184
+ ...(status === 403 ? { readOnly: 'grant' as const } : {}),
2185
+ };
1566
2186
  }
1567
2187
  const seq = (opResult as { seq?: unknown } | undefined)?.seq;
1568
2188
  if (typeof seq !== 'number') {
1569
2189
  return {
1570
2190
  ok: false,
1571
2191
  message: 'The server did not report a version for this change.',
2192
+ retryable: false,
1572
2193
  };
1573
2194
  }
1574
2195
  return { ok: true, seq };
@@ -1587,6 +2208,10 @@ export function createOpenDocument(
1587
2208
  ? { requestedBranchId: options.branch }
1588
2209
  : {}),
1589
2210
  ...(options?.stream !== undefined ? { requestedStream: options.stream } : {}),
2211
+ ...(options?.documentOptions !== undefined
2212
+ ? { documentOptions: options.documentOptions }
2213
+ : {}),
2214
+ ...(opts.identity !== undefined ? { identity: opts.identity } : {}),
1590
2215
  wsClient: opts.wsClient,
1591
2216
  postOversizedOp,
1592
2217
  newClientOpId: opts.newClientOpId ?? defaultClientOpIdMinter,
@@ -1600,6 +2225,7 @@ export function createOpenDocument(
1600
2225
  refetchDurableFrontier,
1601
2226
  reloadAfterHydration,
1602
2227
  reconcileStatusOnInit,
2228
+ onDocumentMoved: retryContentPull,
1603
2229
  });
1604
2230
  }
1605
2231
  // `doc._checkoutVersion(v)` calls back here to materialise historical
@@ -1624,6 +2250,7 @@ export function createOpenDocument(
1624
2250
  await uploader(doc, image, width, height);
1625
2251
  });
1626
2252
  }
2253
+ doc._setThumbnailVersion(parsed.thumbnail?.version ?? null);
1627
2254
  if (opts.checkpointCreator) {
1628
2255
  const creator = opts.checkpointCreator;
1629
2256
  doc.setCheckpointCreator(() => creator(doc));
@@ -1746,6 +2373,11 @@ export function createOpenDocument(
1746
2373
  parseFailed = true;
1747
2374
  }
1748
2375
  }
2376
+ // A tail op whose bytes spilled to storage arrives as a signed URL, and
2377
+ // a guest join carries them exactly as a signed-in one does. The URL
2378
+ // carries its own signature, so an anonymous reader fetches it with the
2379
+ // same call. Resolving here is what lets `materialise` see one shape.
2380
+ await resolveParsedTailOps(parsed);
1749
2381
  return { status: response.status, parsed, parseFailed };
1750
2382
  }
1751
2383
 
@@ -1812,6 +2444,7 @@ export function createOpenDocument(
1812
2444
  current_seq?: unknown;
1813
2445
  name?: unknown;
1814
2446
  link_role?: unknown;
2447
+ status?: unknown;
1815
2448
  };
1816
2449
  if (
1817
2450
  typeof b.doc_id !== 'string' ||
@@ -1823,7 +2456,7 @@ export function createOpenDocument(
1823
2456
  // The link's grant decides read vs write. An `editor` link is anonymous
1824
2457
  // EDIT: a live Document (not read-only) whose local mutations ship over
1825
2458
  // the same token-authenticated socket, with the server admitting the
1826
- // op-write because the guest's SUB# row carries `role: editor`. A
2459
+ // op-write because the guest's subscription record carries `role: editor`. A
1827
2460
  // `viewer` link (the default) is read-only: mutate throws and only
1828
2461
  // inbound frames apply.
1829
2462
  const canEdit = b.link_role === 'editor';
@@ -1840,8 +2473,19 @@ export function createOpenDocument(
1840
2473
  // The view carries the document's display name; set it so the guest
1841
2474
  // sees the title (the signed-in open gets this from `finalise`).
1842
2475
  if (typeof b.name === 'string') doc._setName(b.name);
2476
+ // A link opened while the platform is still preparing the document
2477
+ // carries the same opaque work status the signed-in join does, and the
2478
+ // guest's own topic delivers the phases after it. Seeded before the
2479
+ // transport so the app renders the wait rather than an empty document.
2480
+ const linkStatus = parseWorkStatus(b.status);
2481
+ if (linkStatus) doc._emitStatus(linkStatus);
2482
+ // An edit link's writes fail the same ways a member's do (a shed the
2483
+ // budget outlived, a link revoked mid-edit), and the app hears those
2484
+ // through the same channel it hears a member's, so the reporter is
2485
+ // wired here as `finalise` wires it for a signed-in open.
2486
+ if (opts.reportError) doc.setErrorReporter(opts.reportError);
1843
2487
 
1844
- wireGuestTransport(doc, b.doc_id, b.branch_id, canEdit);
2488
+ wireGuestTransport(doc, token, b.doc_id, b.branch_id, canEdit);
1845
2489
 
1846
2490
  // Streams: a guest resolves `doc.stream(name)` through the token
1847
2491
  // routes. `list` hits the anonymous manifest; `openLine` joins the
@@ -1883,7 +2527,8 @@ export function createOpenDocument(
1883
2527
  },
1884
2528
  { readOnly: !canEdit },
1885
2529
  );
1886
- wireGuestTransport(inner, lb.doc_id, lb.branch_id, canEdit);
2530
+ if (opts.reportError) inner.setErrorReporter(opts.reportError);
2531
+ wireGuestTransport(inner, token, lb.doc_id, lb.branch_id, canEdit);
1887
2532
  return inner;
1888
2533
  },
1889
2534
  create: () => {
@@ -1895,17 +2540,55 @@ export function createOpenDocument(
1895
2540
  return doc;
1896
2541
  };
1897
2542
 
2543
+ // Write a guest's op over HTTP: `POST /links/{token}/ops`, the anonymous
2544
+ // counterpart of `POST /documents/{docId}/ops`. Sends NO Authorization
2545
+ // header (the token is the credential) and names the line the guest
2546
+ // opened, which is the branch its socket topic is scoped to.
2547
+ async function postLinkOp(
2548
+ token: string,
2549
+ lineId: string,
2550
+ opBlobB64: string,
2551
+ clientOpId: string,
2552
+ ): Promise<{ status: number; parsed: unknown }> {
2553
+ const response = await fetch(
2554
+ `${baseUrl}/links/${encodeURIComponent(token)}/ops`,
2555
+ {
2556
+ method: 'POST',
2557
+ headers: { 'content-type': 'application/json' },
2558
+ body: JSON.stringify({
2559
+ op_blob: opBlobB64,
2560
+ branch_id: lineId,
2561
+ client_op_id: clientOpId,
2562
+ }),
2563
+ },
2564
+ );
2565
+ const text = await response.text();
2566
+ let parsed: unknown = undefined;
2567
+ try {
2568
+ parsed = text ? JSON.parse(text) : undefined;
2569
+ } catch {
2570
+ parsed = undefined;
2571
+ }
2572
+ return { status: response.status, parsed };
2573
+ }
2574
+
1898
2575
  // Wire a guest Document (the default stream or a stream line) to the
1899
2576
  // token-authenticated socket. Inbound frames (`doc_init` / `doc_op`)
1900
2577
  // apply for both link kinds; an edit link ALSO wires the outbound path
1901
2578
  // (`op_ack` resolves the outstanding-op tracker, `setLocalOpHook` ships
1902
- // each local commit as an `op` frame). Best-effort (no resync / durability
2579
+ // each local commit). Best-effort on the read side (no resync / durability
1903
2580
  // handshake, which need a signed-in token); a guest that misses ops
1904
- // re-opens. Patches `close` to detach, mirroring `wireDocTransport`, so
1905
- // `Document` stays free of WS knowledge. A no-op with no WS client (a
1906
- // snapshot-only guest).
2581
+ // re-opens. The WRITE side answers a refusal the way the signed-in
2582
+ // transport does, because a guest editor's edit is the same person's work
2583
+ // as a member's and the same two things can happen to it: a frame the
2584
+ // socket cannot carry takes the HTTP route, and a capacity shed is
2585
+ // re-asserted on the shared backoff and reported once the budget is out.
2586
+ // Patches `close` to detach, mirroring `wireDocTransport`, so `Document`
2587
+ // stays free of WS knowledge. A no-op with no WS client (a snapshot-only
2588
+ // guest).
1907
2589
  function wireGuestTransport(
1908
2590
  doc: Document,
2591
+ token: string,
1909
2592
  docId: string,
1910
2593
  lineId: string,
1911
2594
  canEdit: boolean,
@@ -1913,50 +2596,231 @@ export function createOpenDocument(
1913
2596
  const wsClient = opts.wsClient;
1914
2597
  if (!wsClient) return;
1915
2598
  const topic = `doc:${docId}:${lineId}`;
1916
- const unsub = wsClient.subscribe(topic, (frame) => {
1917
- if (isDocInitFrame(frame)) {
1918
- for (const entry of frame.tail_ops) {
1919
- if (entry.op_blob_b64 === undefined) continue;
1920
- doc.applyRemoteOp(base64ToBytes(entry.op_blob_b64));
1921
- }
1922
- } else if (isDocOpFrame(frame)) {
1923
- doc.applyRemoteOp(base64ToBytes(frame.op_blob));
1924
- } else if (isDocGoneFrame(frame)) {
1925
- // The server fans `doc_gone` to EVERY subscriber on the branched
1926
- // content topic, guest-link connections included (`fanoutDocGone` /
1927
- // `fanoutBranchGone`), on a soft-delete / move of the opened doc.
1928
- // Without this branch the guest keeps showing removed content and
1929
- // `doc.onGone(...)` never fires; for an edit link the next local op
1930
- // would ship onto the evicted SUB# row and bounce as a misleading
1931
- // `forbidden`. Mark the doc gone (mapping the reason like the
1932
- // signed-in transport); the guest path has no resend / reverify
1933
- // timers to drain.
1934
- doc._markGone(goneReasonOf(frame));
1935
- } else if (canEdit && isOpAckFrame(frame)) {
1936
- doc.resolveOutstandingOp(frame.client_op_id);
1937
- } else if (canEdit && isOpRejectedFrame(frame)) {
1938
- // A guest edit the server terminally rejected. No resend tracker here
1939
- // (guest transport is best-effort), so just fail the op on the doc so
1940
- // "could not be saved" surfaces instead of a stuck saving count.
1941
- doc._failOutstandingOp(frame.client_op_id, opRejectedMessage(frame));
2599
+
2600
+ // Ops on the socket awaiting an answer, keyed by `client_op_id`. An
2601
+ // `unavailable` shed names no op, so re-asserting one needs the frames
2602
+ // in hand. Scoped to what the socket carried: an op that took the HTTP
2603
+ // write must never reach the socket, and that write re-asserts itself.
2604
+ const outstanding = new Map<string, OpFrame>();
2605
+ let shedRounds = 0;
2606
+ let shedTimer: ReturnType<typeof setTimeout> | null = null;
2607
+
2608
+ function clearShedRecovery(): void {
2609
+ if (shedTimer !== null) {
2610
+ clearTimeout(shedTimer);
2611
+ shedTimer = null;
1942
2612
  }
1943
- });
1944
- if (canEdit) {
1945
- const minter = opts.newClientOpId ?? defaultClientOpIdMinter;
1946
- doc.setLocalOpHook((opBlob) => {
1947
- const clientOpId = minter();
1948
- wsClient.send({
1949
- type: 'op',
1950
- topic,
1951
- op_blob: bytesToBase64(opBlob),
1952
- client_op_id: clientOpId,
1953
- });
1954
- return clientOpId;
1955
- });
2613
+ shedRounds = 0;
1956
2614
  }
1957
- const originalClose = doc.close.bind(doc);
1958
- (doc as Document & { close: () => void }).close = () => {
1959
- try {
2615
+
2616
+ // Every outstanding op is dead: drop the tracker entries and tell the
2617
+ // Document, which stops counting them in `savingCount` and reports each
2618
+ // through the error channel the app reads. The ids are taken first
2619
+ // because `_failOutstandingOp` re-enters app code.
2620
+ function failAllOutstanding(message: string): void {
2621
+ const clientOpIds = [...outstanding.keys()];
2622
+ outstanding.clear();
2623
+ for (const clientOpId of clientOpIds) {
2624
+ doc._failOutstandingOp(clientOpId, message);
2625
+ }
2626
+ }
2627
+
2628
+ // Put every op still awaiting an answer back on the wire. The client
2629
+ // DROPS an op frame it cannot send right now rather than buffering one
2630
+ // (`ownsFrameRetry` below), so this is the only thing that carries a
2631
+ // shed or reconnect-orphaned op across the window, and it is also what
2632
+ // keeps a frame from being written twice: a copy the client held would
2633
+ // flush on the `subscribed` ack while this re-asserts on the `doc_init`
2634
+ // behind it, and the server allocates a fresh seq per send.
2635
+ const resendAllOutstanding = (): void => {
2636
+ for (const frame of outstanding.values()) wsClient.send(frame);
2637
+ };
2638
+
2639
+ const onServerUnavailable = (): void => {
2640
+ if (outstanding.size === 0) return;
2641
+ if (shedTimer !== null) return;
2642
+ shedRounds += 1;
2643
+ if (shedRounds > UNAVAILABLE_MAX_ROUNDS) {
2644
+ clearShedRecovery();
2645
+ failAllOutstanding(UNAVAILABLE_GIVE_UP_MESSAGE);
2646
+ return;
2647
+ }
2648
+ shedTimer = setTimeout(() => {
2649
+ shedTimer = null;
2650
+ resendAllOutstanding();
2651
+ }, unavailableDelayMs(shedRounds));
2652
+ (shedTimer as { unref?: () => void }).unref?.();
2653
+ };
2654
+
2655
+ const unsub = wsClient.subscribe(
2656
+ topic,
2657
+ (frame) => {
2658
+ const init = parseDocInitFrame(frame);
2659
+ if (init) {
2660
+ for (const entry of init.tail_ops) {
2661
+ if (entry.op_blob_b64 === undefined) continue;
2662
+ doc.applyRemoteOp(base64ToBytes(entry.op_blob_b64));
2663
+ }
2664
+ // `doc_init` follows every `subscribed` ack, so this is the point
2665
+ // the topic can carry frames again. A fresh connection also returns
2666
+ // the shed budget to full: this re-assert is the round a pending
2667
+ // one would have run.
2668
+ clearShedRecovery();
2669
+ resendAllOutstanding();
2670
+ return;
2671
+ }
2672
+ if (isDocOpFrame(frame)) {
2673
+ doc.applyRemoteOp(base64ToBytes(frame.op_blob));
2674
+ } else if (isDocGoneFrame(frame)) {
2675
+ // The server fans `doc_gone` to EVERY subscriber on the branched
2676
+ // content topic, guest-link connections included, on a soft-delete /
2677
+ // move of the opened doc.
2678
+ // Without this branch the guest keeps showing removed content and
2679
+ // `doc.onGone(...)` never fires; for an edit link the next local op
2680
+ // would ship onto the evicted subscription record and bounce as a misleading
2681
+ // `forbidden`. Drop the shed recovery first so nothing re-asserts an
2682
+ // op onto an evicted subscription record, then mark the doc gone (mapping the
2683
+ // reason like the signed-in transport).
2684
+ clearShedRecovery();
2685
+ outstanding.clear();
2686
+ doc._markGone(goneReasonOf(frame));
2687
+ } else if (isDocStatusFrame(frame)) {
2688
+ // A guest can open a document whose inbound work is still running,
2689
+ // and hydration writes a SNAPSHOT rather than ops, so nothing on the
2690
+ // op stream would ever fill the view in. Same split as the signed-in
2691
+ // transport: the `reload` hint pulls the content and emits the
2692
+ // terminal status after the import, and anything else renders.
2693
+ const status = normalizeWorkStatus(frame.status);
2694
+ if (frame.reload) pullGuestContent(doc, token, lineId, status);
2695
+ else doc._emitStatus(status);
2696
+ } else if (isDocMetaFrame(frame)) {
2697
+ // `_setName` de-dupes, so a re-emit of the current name is a no-op.
2698
+ doc._setName(frame.name);
2699
+ } else if (canEdit && isOpAckFrame(frame)) {
2700
+ outstanding.delete(frame.client_op_id);
2701
+ doc.resolveOutstandingOp(frame.client_op_id);
2702
+ // The server is durably accepting writes again, so the shed budget
2703
+ // starts over. A round already scheduled still runs: the ops it
2704
+ // re-asserts are the ones this ack did not cover.
2705
+ shedRounds = 0;
2706
+ } else if (canEdit && isServerUnavailableFrame(frame)) {
2707
+ onServerUnavailable();
2708
+ } else if (canEdit && isOpRejectedFrame(frame)) {
2709
+ // Terminal, not transient: stop re-asserting this op (retrying
2710
+ // would only draw the same rejection) and fail it on the Document
2711
+ // so "could not be saved" surfaces instead of a stuck saving count.
2712
+ // A refusal naming the writer (an edit link downgraded or revoked
2713
+ // while the guest had it open) turns the Document read-only, the
2714
+ // same way it does for a signed-in reader.
2715
+ outstanding.delete(frame.client_op_id);
2716
+ if (isCapacityRejection(frame)) doc._markAtCapacity();
2717
+ const readOnly = readOnlyReasonForRejection(frame);
2718
+ if (readOnly !== null) doc._markReadOnly(readOnly);
2719
+ doc._failOutstandingOp(frame.client_op_id, opRejectedMessage(frame));
2720
+ }
2721
+ },
2722
+ // The guest owns its outstanding ops: `resendAllOutstanding` on the
2723
+ // `doc_init` behind every `subscribed` ack IS this topic's
2724
+ // re-assertion, so the client drops an op frame it cannot send now
2725
+ // rather than buffering a second copy of it.
2726
+ { ownsFrameRetry: true },
2727
+ );
2728
+
2729
+ // An op the socket cannot carry, written over HTTP on the same policy
2730
+ // the signed-in path runs: refuse locally what no write can hold, and
2731
+ // re-assert a capacity refusal on the shared backoff before reporting it.
2732
+ async function writeOversizedGuestOp(
2733
+ opBlobB64: string,
2734
+ clientOpId: string,
2735
+ ): Promise<void> {
2736
+ // Yield before any outcome resolves the op. The hook's caller records
2737
+ // the outstanding op AFTER the hook returns, and an async function body
2738
+ // runs synchronously up to its first await, so an exit taken inline
2739
+ // would find nothing tracked and leave the entry behind with
2740
+ // `savingCount` stuck above zero.
2741
+ await Promise.resolve();
2742
+
2743
+ // Both sizes are known here, so refuse without spending a round trip
2744
+ // on a blob that can only come back 400.
2745
+ if (opBlobExceedsDurableLimit(opBlobB64)) {
2746
+ doc._failOutstandingOp(clientOpId, TOO_LARGE_MESSAGE);
2747
+ return;
2748
+ }
2749
+ if (opBlobNeedsStaging(opBlobB64)) {
2750
+ doc._failOutstandingOp(clientOpId, LINK_OP_TOO_LARGE_MESSAGE);
2751
+ return;
2752
+ }
2753
+
2754
+ let status = 0;
2755
+ let parsed: unknown = undefined;
2756
+ for (let round = 0; round <= UNAVAILABLE_MAX_ROUNDS; round += 1) {
2757
+ if (round > 0) {
2758
+ await new Promise((resolve) => {
2759
+ const timer = setTimeout(resolve, unavailableDelayMs(round));
2760
+ (timer as { unref?: () => void }).unref?.();
2761
+ });
2762
+ }
2763
+ try {
2764
+ ({ status, parsed } = await postLinkOp(
2765
+ token,
2766
+ lineId,
2767
+ opBlobB64,
2768
+ clientOpId,
2769
+ ));
2770
+ } catch (err) {
2771
+ // A throw leaves it unknown whether the handler ran, and this op
2772
+ // carries no idempotency the server dedupes on, so replaying it
2773
+ // could double-apply.
2774
+ doc._failOutstandingOp(
2775
+ clientOpId,
2776
+ err instanceof Error ? err.message : String(err),
2777
+ );
2778
+ return;
2779
+ }
2780
+ if (status >= 200 && status < 300) break;
2781
+ if (!opWriteIsRetryable(status)) break;
2782
+ }
2783
+ if (status < 200 || status >= 300) {
2784
+ doc._failOutstandingOp(
2785
+ clientOpId,
2786
+ opWriteIsRetryable(status)
2787
+ ? UNAVAILABLE_GIVE_UP_MESSAGE
2788
+ : opWriteFailureMessage(status, parsed),
2789
+ );
2790
+ return;
2791
+ }
2792
+ doc.resolveOutstandingOp(clientOpId);
2793
+ }
2794
+
2795
+ if (canEdit) {
2796
+ const minter = opts.newClientOpId ?? defaultClientOpIdMinter;
2797
+ doc.setLocalOpHook((opBlob) => {
2798
+ const clientOpId = minter();
2799
+ const opBlobB64 = bytesToBase64(opBlob);
2800
+ const sendFrame: OpFrame = {
2801
+ type: 'op',
2802
+ topic,
2803
+ op_blob: opBlobB64,
2804
+ client_op_id: clientOpId,
2805
+ };
2806
+ // A frame past the transport's cap is not delivered and takes the
2807
+ // connection down with it, so it must never reach the socket: a
2808
+ // guest's document, presence and every other op ride that one
2809
+ // connection. `mutate` batches to a budget well above what a frame
2810
+ // carries, so an ordinary bulk edit arrives here.
2811
+ if (opFrameExceedsWsLimit(sendFrame)) {
2812
+ void writeOversizedGuestOp(opBlobB64, clientOpId);
2813
+ return clientOpId;
2814
+ }
2815
+ outstanding.set(clientOpId, sendFrame);
2816
+ wsClient.send(sendFrame);
2817
+ return clientOpId;
2818
+ });
2819
+ }
2820
+ const originalClose = doc.close.bind(doc);
2821
+ (doc as Document & { close: () => void }).close = () => {
2822
+ clearShedRecovery();
2823
+ try {
1960
2824
  unsub();
1961
2825
  } catch {
1962
2826
  // Already detached; closing the Document is what matters.
@@ -1965,13 +2829,83 @@ export function createOpenDocument(
1965
2829
  };
1966
2830
  }
1967
2831
 
2832
+ // Guest documents with a content pull running, so a repeated terminal frame
2833
+ // does not stack a second pull on one document.
2834
+ const guestPullInFlight = new WeakSet<Document>();
2835
+
2836
+ function pullGuestContent(
2837
+ doc: Document,
2838
+ token: string,
2839
+ lineId: string,
2840
+ finalStatus: DocumentWorkStatus,
2841
+ ): void {
2842
+ if (guestPullInFlight.has(doc)) return;
2843
+ guestPullInFlight.add(doc);
2844
+ void (async () => {
2845
+ await pullContentWithRetry({
2846
+ doc,
2847
+ docId: doc.id ?? '',
2848
+ finalStatus,
2849
+ fetchAndImport: () => importLinkView(doc, token, lineId),
2850
+ ...(opts.reportError ? { reportError: opts.reportError } : {}),
2851
+ });
2852
+ guestPullInFlight.delete(doc);
2853
+ })();
2854
+ }
2855
+
2856
+ // Re-read the link view and import it into the live guest Document: the
2857
+ // anonymous counterpart of the signed-in `refetchAndImport`, over the same
2858
+ // envelope. Named on the line the guest opened, which is the line its topic
2859
+ // is scoped to.
2860
+ async function importLinkView(
2861
+ doc: Document,
2862
+ token: string,
2863
+ lineId: string,
2864
+ ): Promise<{ ok: true } | { ok: false; reason: string }> {
2865
+ const result = await fetchLinkView(token, lineId);
2866
+ if (result.status < 200 || result.status >= 300) {
2867
+ return { ok: false, reason: `link view returned ${result.status}` };
2868
+ }
2869
+ const b = result.parsed as {
2870
+ snapshot?: unknown;
2871
+ tail_ops?: unknown;
2872
+ };
2873
+ if (!Array.isArray(b.tail_ops)) {
2874
+ return { ok: false, reason: 'malformed link view response' };
2875
+ }
2876
+ const snap = b.snapshot as DocumentSnapshotResponse['snapshot'];
2877
+ let snapshotBytes: Uint8Array | null = null;
2878
+ if (snap !== null && snap !== undefined && snap.kind === 'inline') {
2879
+ snapshotBytes = base64ToBytes(snap.data_b64);
2880
+ } else if (snap !== null && snap !== undefined && snap.kind === 'presigned') {
2881
+ try {
2882
+ snapshotBytes = await fetchPresignedSnapshot(snap.url);
2883
+ } catch (err) {
2884
+ const status = err instanceof BrassError ? err.status ?? 0 : 0;
2885
+ return {
2886
+ ok: false,
2887
+ reason:
2888
+ status > 0
2889
+ ? `presigned snapshot fetch returned ${status}`
2890
+ : `presigned snapshot fetch failed: ${err instanceof Error ? err.message : String(err)}`,
2891
+ };
2892
+ }
2893
+ }
2894
+ if (snapshotBytes) doc._applySnapshot(snapshotBytes);
2895
+ const tail = b.tail_ops as DocumentSnapshotResponse['tail_ops'];
2896
+ for (const entry of [...tail].sort((x, y) => x.seq - y.seq)) {
2897
+ doc.applyRemoteOp(base64ToBytes(entry.op_blob_b64));
2898
+ }
2899
+ return { ok: true };
2900
+ }
2901
+
1968
2902
  // Strict open: a 404 propagates as `BrassError`. A `version` option
1969
2903
  // diverts to the read-only at-version path.
1970
2904
  const open: OpenDocumentByIdFn = async (docId, options) => {
1971
2905
  // Cache hit: return the parked Document. Its subscription stayed alive
1972
2906
  // while parked, and the UndoController + outstanding-op map survived, so
1973
2907
  // undo works as if the user never switched away.
1974
- const parked = takeParkedDocument(docId, options);
2908
+ const parked = takeParkedDocument(docId, options, opts.identity?.() ?? null);
1975
2909
  if (parked) {
1976
2910
  parked._resume();
1977
2911
  return parked as OpenedDocument;
@@ -2049,10 +2983,15 @@ interface WireDocTransportArgs {
2049
2983
  getCoveredSeq: () => number;
2050
2984
  advanceCoveredSeq: (seq: number) => void;
2051
2985
  // Re-import the HTTP join. Invoked when a `doc_init` tail starts above the
2052
- // coverage floor: ops missed while disconnected were compacted into the
2053
- // snapshot and the tail cannot bridge the hole. Optional only for
2054
- // transport-less paths.
2055
- resyncFromSnapshot?: () => void;
2986
+ // coverage floor (ops missed while disconnected were compacted into the
2987
+ // snapshot and the tail cannot bridge the hole) and on every catch-up
2988
+ // prompt. Optional only for transport-less paths.
2989
+ //
2990
+ // A returned promise lets the caller re-read the coverage floor once the
2991
+ // import has landed, which is how a reader that is still short asks
2992
+ // again. A void return keeps that caller correct, just later: it falls
2993
+ // back to whatever the next frame arms.
2994
+ resyncFromSnapshot?: () => void | Promise<void>;
2056
2995
  // Re-fetch the durable frontier (version vector + tail blobs) without
2057
2996
  // importing. Invoked by the `doc_ops_dropped` handler so the handshake
2058
2997
  // runs against fresh ground truth the instant the server discards ops, not
@@ -2063,22 +3002,26 @@ interface WireDocTransportArgs {
2063
3002
  tailBlobs: Uint8Array[];
2064
3003
  } | null>;
2065
3004
  // Write an op the socket cannot carry, over HTTP. Resolves to the
2066
- // server-allocated seq, or an error message the Document surfaces on the
2067
- // op. Absent in transport-only tests, where an oversized op is failed
2068
- // rather than rerouted.
3005
+ // server-allocated seq, or a failure the Document surfaces on the op.
3006
+ // Absent in transport-only tests, where an oversized op is failed rather
3007
+ // than rerouted.
2069
3008
  postOversizedOp?: (
2070
3009
  opBlobB64: string,
2071
3010
  clientOpId: string,
2072
- ) => Promise<{ ok: true; seq: number } | { ok: false; message: string }>;
3011
+ ) => Promise<OversizedOpResult>;
2073
3012
  // Invoked on a terminal `doc_status` frame: re-fetches once and, on
2074
3013
  // success, imports the now-available snapshot so content appears without
2075
3014
  // the app reopening; on failure leaves the status and does not loop.
2076
3015
  // Absent in transport-only tests.
2077
3016
  reloadAfterHydration?: (finalStatus: DocumentWorkStatus) => void;
2078
- // Invoked on `doc_init`. Posted only after the SUB# row is written, so it
3017
+ // Invoked on `doc_init`. Posted only after the subscription record is written, so it
2079
3018
  // is the safe point to reconcile a pending open whose terminal
2080
3019
  // `doc_status` frame may have been dropped. Absent in transport-only tests.
2081
3020
  reconcileStatusOnInit?: () => void;
3021
+ // Invoked on every frame carrying server-side movement (`doc_init`, a
3022
+ // remote op). Evidence the document has content to pull for a client whose
3023
+ // content pull gave up. Absent in transport-only tests.
3024
+ onDocumentMoved?: () => void;
2082
3025
  // True for a stream line's internal Document: presence is a
2083
3026
  // document-level surface (one roster per open doc, on the default
2084
3027
  // line's presence topic), so per-stream transports skip attaching a
@@ -2103,6 +3046,13 @@ interface WireDocTransportArgs {
2103
3046
  // open that named none. Part of the park key for the same reason the
2104
3047
  // branch is: it says which state the parked Document holds.
2105
3048
  requestedStream?: string;
3049
+ // The `documentOptions` this Document was built from, recorded on the park
3050
+ // entry so a reopen asking for different ones gets its own Document.
3051
+ documentOptions?: DocumentOptions;
3052
+ // Who this Document was opened for, recorded on the park entry (see
3053
+ // `ParkedDocEntry.identity`). Read at close rather than captured at open so
3054
+ // the stamp is the identity the page holds when the Document is parked.
3055
+ identity?: () => string | null;
2106
3056
  }
2107
3057
 
2108
3058
  // Per-process parking cache for opened Documents. `doc.close()` does NOT
@@ -2124,6 +3074,29 @@ interface ParkedDocEntry {
2124
3074
  // local-op hook, runs the underlying `Document.close()`.
2125
3075
  teardown: () => void;
2126
3076
  key: string;
3077
+ // The `documentOptions` this Document was built from. A reopen asking for
3078
+ // different ones (another root key, another undo depth) describes a
3079
+ // different Document, so it gets a fresh one rather than this state.
3080
+ optionsSig: string;
3081
+ // Who this Document was opened for (`identity`, the signed-in subject), so
3082
+ // a take by anyone else misses.
3083
+ //
3084
+ // A hit skips the join entirely, which is the whole point of parking and
3085
+ // also the reason the entry has to name its owner: the SDK reads the
3086
+ // person from the token set, and a `/refresh` resolves whichever platform
3087
+ // session the browser now holds, so the identity behind an open page can
3088
+ // become someone else's between a close and the next open. Without this,
3089
+ // the second person's open is answered out of memory, with the first
3090
+ // person's content and no request the server could refuse.
3091
+ identity: string | null;
3092
+ }
3093
+
3094
+ function documentOptionsSig(options: DocumentOptions | undefined): string {
3095
+ return JSON.stringify([
3096
+ options?.rootKey ?? null,
3097
+ options?.readOnly ?? null,
3098
+ options?.undo ?? null,
3099
+ ]);
2127
3100
  }
2128
3101
 
2129
3102
  const parkedDocs = new Map<string, ParkedDocEntry>();
@@ -2144,7 +3117,22 @@ function parkCacheKey(
2144
3117
  return `${docId}/${branchId}/${streamName ?? DEFAULT_STREAM_NAME}`;
2145
3118
  }
2146
3119
 
3120
+ function evictParked(entry: ParkedDocEntry | undefined): void {
3121
+ if (!entry) return;
3122
+ parkedDocs.delete(entry.key);
3123
+ try {
3124
+ entry.teardown();
3125
+ } catch {
3126
+ // Teardown errors must not strand the cache; the evicted Doc is gone
3127
+ // from the user's perspective either way.
3128
+ }
3129
+ }
3130
+
2147
3131
  function parkDocument(entry: ParkedDocEntry): void {
3132
+ // One Document per key: whatever sat here is unreachable the moment this
3133
+ // entry replaces it, so it is torn down rather than left holding a live
3134
+ // subscription nothing can close.
3135
+ evictParked(parkedDocs.get(entry.key));
2148
3136
  parkedDocs.set(entry.key, entry);
2149
3137
  // Map iteration is insertion order, so evicting the front entry is LRU
2150
3138
  // (a newly parked entry is the most-recent; a hit removes the entry
@@ -2152,43 +3140,55 @@ function parkDocument(entry: ParkedDocEntry): void {
2152
3140
  while (parkedDocs.size > PARK_CACHE_CAPACITY) {
2153
3141
  const oldestKey = parkedDocs.keys().next().value;
2154
3142
  if (oldestKey === undefined) break;
2155
- const oldest = parkedDocs.get(oldestKey);
2156
- parkedDocs.delete(oldestKey);
2157
- try {
2158
- oldest?.teardown();
2159
- } catch {
2160
- // Teardown errors must not strand the cache; the evicted Doc is gone
2161
- // from the user's perspective either way.
2162
- }
3143
+ evictParked(parkedDocs.get(oldestKey));
2163
3144
  }
2164
3145
  }
2165
3146
 
2166
3147
  function takeParkedDocument(
2167
3148
  docId: string,
2168
3149
  options: OpenDocumentByIdOptions | undefined,
3150
+ identity: string | null,
2169
3151
  ): Document | null {
2170
3152
  if (options?.version !== undefined) return null;
2171
3153
  const key = parkCacheKey(docId, options?.branch, options?.stream);
2172
3154
  if (!key) return null;
2173
3155
  const entry = parkedDocs.get(key);
2174
3156
  if (!entry) return null;
3157
+ // A Document the server evicted while it was parked describes a document
3158
+ // that is no longer there, one built from different `documentOptions` is
3159
+ // not the Document this caller asked for, and one opened for a different
3160
+ // person is not this caller's to read. All three take the fresh open, which
3161
+ // for the last is the request that establishes the new caller may have it;
3162
+ // the parked copy goes with its subscription.
3163
+ if (
3164
+ entry.doc._goneReason !== null ||
3165
+ entry.optionsSig !== documentOptionsSig(options?.documentOptions) ||
3166
+ entry.identity !== identity
3167
+ ) {
3168
+ evictParked(entry);
3169
+ return null;
3170
+ }
2175
3171
  parkedDocs.delete(key);
2176
3172
  return entry.doc;
2177
3173
  }
2178
3174
 
2179
- // Test-only: drop the parking cache. Used by unit tests that need
2180
- // a clean slate between cases.
2181
- export function _resetParkCacheForTesting(): void {
3175
+ /** Retire every parked Document. The client calls this as an identity leaves
3176
+ * the page, so what the previous person opened is torn down rather than left
3177
+ * resident for the rest of the tab's life.
3178
+ * @internal */
3179
+ export function clearParkedDocuments(): void {
2182
3180
  for (const entry of parkedDocs.values()) {
2183
- try {
2184
- entry.teardown();
2185
- } catch {
2186
- // ignore
2187
- }
3181
+ evictParked(entry);
2188
3182
  }
2189
3183
  parkedDocs.clear();
2190
3184
  }
2191
3185
 
3186
+ // Test-only: drop the parking cache. Used by unit tests that need
3187
+ // a clean slate between cases.
3188
+ export function _resetParkCacheForTesting(): void {
3189
+ clearParkedDocuments();
3190
+ }
3191
+
2192
3192
  // Wire the WS transport onto a materialised Document. Three pieces:
2193
3193
  // 1. WS subscribe on the doc topic; the server sends `doc_op` (other
2194
3194
  // clients' edits) and `op_ack` (our ops), topic-scoped so the
@@ -2215,6 +3215,71 @@ export const PERIODIC_REVERIFY_INTERVAL_MS = 5 * 60_000;
2215
3215
  // fake-timer test.
2216
3216
  export const MISSED_OP_RESYNC_DELAY_MS = 2_000;
2217
3217
 
3218
+ // Per-op resend backoff: 1.5s, ~1.6x per attempt, to a 30s ceiling. The
3219
+ // ceiling keeps a doc on a dead connection from hammering; the
3220
+ // reconnect-driven resend (`resendAllOutstanding`) fires the instant
3221
+ // `doc_init` arrives, so the ceiling never delays recovery past the next
3222
+ // reconnect. Exported for the fake-timer tests.
3223
+ export const RESEND_BASE_DELAY_MS = 1_500;
3224
+ export const RESEND_MAX_DELAY_MS = 30_000;
3225
+
3226
+ // How long the oldest op on the socket may go unacked before its resend
3227
+ // timer spends an attempt even though the connection is answering other
3228
+ // ops. See `scheduleResend` for what this bounds and why only the oldest op
3229
+ // reaches it.
3230
+ //
3231
+ // The value sits above every latency a working connection produces. Acks
3232
+ // measured on a deployed environment run 175 ms at p99 and 1.6 s at their
3233
+ // worst, and an op whose own backoff has grown to `RESEND_MAX_DELAY_MS` is
3234
+ // still well inside this, so a queue that is merely slow never reaches it and
3235
+ // the timer keeps re-arming free. Sitting above the backoff ceiling is also
3236
+ // what makes this value, rather than the backoff, the interval a forced
3237
+ // resend is paced at: the timer fires several times inside one window, and
3238
+ // the first expiry past the window is the one that spends.
3239
+ //
3240
+ // What a false positive costs bounds the risk of setting it at all: one
3241
+ // duplicate op per document per window, which is a Loro no-op on import. The
3242
+ // failure this replaces is unbounded in the other direction, an op the
3243
+ // server never received and nothing ever sends again.
3244
+ // Exported for the fake-timer tests.
3245
+ export const RESEND_FORCE_CEILING_MS = 60_000;
3246
+
3247
+ // Server-shed recovery policy, driven by `unavailable` error frames. The
3248
+ // frame names no op, so a round re-asserts every outstanding op at once and
3249
+ // any further frame arriving before that round runs is part of the same shed
3250
+ // batch: coalescing them is what keeps N shed ops from fanning out into N
3251
+ // rounds of N resends. Backoff is 1s doubling to a 30s ceiling, so the budget
3252
+ // spans roughly a minute and a half of sustained shedding. An `op_ack` (the
3253
+ // server taking writes again) or a reconnect returns the budget to full.
3254
+ //
3255
+ // Module-scoped because both transports answer a shed on it: the signed-in
3256
+ // one, the HTTP write for an op past the frame cap, and the guest-link one.
3257
+ // A shed costs an edit the same nothing whoever wrote it and whichever
3258
+ // transport carried it.
3259
+ const UNAVAILABLE_MAX_ROUNDS = 6;
3260
+ const UNAVAILABLE_BASE_DELAY_MS = 1000;
3261
+ const UNAVAILABLE_MAX_DELAY_MS = 30_000;
3262
+ const UNAVAILABLE_GIVE_UP_MESSAGE =
3263
+ 'The server could not store this change, and re-sending it did not ' +
3264
+ 'help. Reload the document to continue from the last saved state.';
3265
+
3266
+ // Reported for every op left behind when the server ends this document's
3267
+ // subscription (it refused the subscribe, or evicted it again after the one
3268
+ // silent re-subscribe a connection grants). The ops can never be acked on a
3269
+ // subscription that is over, and a fresh one arrives only with a fresh open,
3270
+ // so the app's cue is to reload.
3271
+ const SUBSCRIPTION_ENDED_MESSAGE =
3272
+ 'The live connection to this document ended, so this change was not ' +
3273
+ 'saved. Reload the document to continue from the last saved state.';
3274
+
3275
+ /** How long the Nth (1-based) recovery round waits before re-asserting. */
3276
+ function unavailableDelayMs(round: number): number {
3277
+ return Math.min(
3278
+ UNAVAILABLE_BASE_DELAY_MS * Math.pow(2, round - 1),
3279
+ UNAVAILABLE_MAX_DELAY_MS,
3280
+ );
3281
+ }
3282
+
2218
3283
  // A mutation carrying many records commits as it goes, so the only write
2219
3284
  // that reaches this is one record nothing can split.
2220
3285
  const TOO_LARGE_MESSAGE =
@@ -2222,13 +3287,19 @@ const TOO_LARGE_MESSAGE =
2222
3287
  'update; store the bulk of it as a file instead, or split it across ' +
2223
3288
  'several records.';
2224
3289
 
3290
+ // What a guest hears for an op no request body can carry. A signed-in
3291
+ // editor stages such an op to storage first; that route is behind the
3292
+ // sign-in gate, so the anonymous tier writes ops that fit one request.
3293
+ const LINK_OP_TOO_LARGE_MESSAGE =
3294
+ 'This change is too large to save over a shared link. Sign in to the ' +
3295
+ 'document to make a change this size.';
3296
+
2225
3297
  function wireDocTransport(args: WireDocTransportArgs): void {
2226
3298
  const topic = `doc:${args.docId}:${args.branchId}`;
2227
3299
 
2228
- // Per-op retry tracker. The op-write endpoint sometimes returns
2229
- // `{"message": "Internal server error"}` without reaching the handler
2230
- // (burst input against a cold-started server giving up before scale-up).
2231
- // That frame has no `type`, so the multiplexer drops it,
3300
+ // Per-op retry tracker. The op-write endpoint can answer with an untyped
3301
+ // error frame instead of reaching the handler. That frame has no `type`, so
3302
+ // the multiplexer drops it,
2232
3303
  // leaving the op stuck in `Document._outstandingByClientId` and the
2233
3304
  // actor's lamport stream with a gap that blocks other subscribers' Loro
2234
3305
  // imports from converging (Loro requires per-actor causal contiguity).
@@ -2240,14 +3311,157 @@ function wireDocTransport(args: WireDocTransportArgs): void {
2240
3311
  // retry lands the dependent chain converges everywhere. `doc.savingCount`
2241
3312
  // stays above zero until the `op_ack` lands (the "Saving…" surface); there
2242
3313
  // is no give-up to surface because the SDK keeps trying.
3314
+ // When each op this transport shipped was first committed, keyed the same
3315
+ // way the tracker is. The tracker itself carries no commit time (its
3316
+ // `dispatchedAt` restarts on every resend, which is the right basis for
3317
+ // deciding a resend and the wrong one for reporting how long a reader has
3318
+ // been waiting), and an op past the frame cap is never in the tracker at
3319
+ // all. Entries are dropped as their op settles, so the map's size is the
3320
+ // work outstanding on either transport.
3321
+ const opStartedAt = new Map<string, number>();
3322
+
3323
+ // Reconnects the socket had made when this transport last saw a
3324
+ // `doc_init`. The frame itself is not a reconnect signal: it rides every
3325
+ // `subscribed` ack, including the opening one and the silent re-subscribe
3326
+ // a connection grants after a mid-stream eviction, neither of which
3327
+ // involved the socket dropping. A report keyed off the frame therefore
3328
+ // fires when nothing is wrong, which is the fault this latch exists to
3329
+ // avoid, so it is keyed off the socket instead.
3330
+ let docInitReconnectCount: number | null = null;
3331
+
2243
3332
  const outstandingResends = new Map<
2244
3333
  string,
2245
3334
  {
2246
3335
  frame: { type: 'op'; topic: string; op_blob: string; client_op_id: string };
2247
3336
  attempts: number;
2248
3337
  timer: ReturnType<typeof setTimeout>;
3338
+ // When this op's current timer was armed, compared against the last
3339
+ // ack to tell a slow queue from a silent server. See `scheduleResend`.
3340
+ armedAt: number;
3341
+ // When this op was last put on the socket, or 0 while it is queued.
3342
+ // Time on the wire rather than time outstanding, so an op held back by
3343
+ // the in-flight budget is not charged for the wait. See
3344
+ // `scheduleResend`.
3345
+ dispatchedAt: number;
3346
+ // Bytes this op is charging to the in-flight budget, or 0 while it is
3347
+ // still queued. See `dispatchOp`.
3348
+ inFlight: number;
3349
+ // Whether this op is waiting in `sendQueue`. Held on the entry so
3350
+ // neither queueing nor forgetting an op has to scan the queue, which
3351
+ // would be quadratic over a mutation that queues thousands.
3352
+ queued: boolean;
2249
3353
  }
2250
3354
  >();
3355
+
3356
+ // How many bytes of op frames may be on the socket unacked at once.
3357
+ //
3358
+ // The transport drops the connection when a client pushes at it hard
3359
+ // enough, and it takes everything multiplexed onto that socket with it;
3360
+ // every op then goes unacked, the reconnect re-asserts them, and that
3361
+ // burst drops the connection again, so nothing is ever persisted.
3362
+ // Measured against one write, a client pushing 26 frames in 320 ms lost
3363
+ // the socket where one spreading 90 frames over 5.3 s did not, so what
3364
+ // this bounds is the RATE rather than any one frame's size.
3365
+ //
3366
+ // Which shape reaches it is worth knowing before tuning it. A bulk write
3367
+ // does not: `mutate` batches to a budget far above what a frame carries,
3368
+ // so its ops take the HTTP write and never enter this queue. What does is
3369
+ // an app issuing many separate mutations that each land under the frame
3370
+ // cap, a drag loop or a per-row import, where the op count is set by how
3371
+ // often the app calls rather than by how much it writes.
3372
+ //
3373
+ // A byte budget rather than a rate is what makes this self-tuning: sends
3374
+ // resume as acks land, so the client goes exactly as fast as the server
3375
+ // is answering and no faster, whatever that speed turns out to be. The
3376
+ // budget is generous enough to keep several ops in flight, so an ack's
3377
+ // round trip is not serialised into the write.
3378
+ const IN_FLIGHT_BUDGET_BYTES = 256 * 1024;
3379
+ // Ops committed but not yet on the socket, oldest first.
3380
+ const sendQueue: string[] = [];
3381
+ let inFlightBytes = 0;
3382
+
3383
+ /** Put an op on the socket if the budget allows, else queue it. */
3384
+ function dispatchOp(clientOpId: string): void {
3385
+ const entry = outstandingResends.get(clientOpId);
3386
+ if (!entry || entry.inFlight > 0) return;
3387
+ const size = entry.frame.op_blob.length;
3388
+ // An op larger than the whole budget goes on its own rather than
3389
+ // waiting for room that can never appear.
3390
+ if (inFlightBytes > 0 && inFlightBytes + size > IN_FLIGHT_BUDGET_BYTES) {
3391
+ if (!entry.queued) {
3392
+ entry.queued = true;
3393
+ entry.dispatchedAt = 0;
3394
+ sendQueue.push(clientOpId);
3395
+ }
3396
+ return;
3397
+ }
3398
+ entry.queued = false;
3399
+ entry.inFlight = size;
3400
+ entry.dispatchedAt = Date.now();
3401
+ inFlightBytes += size;
3402
+ args.wsClient.send(entry.frame);
3403
+ scheduleResend(clientOpId);
3404
+ }
3405
+
3406
+ /** The oldest op currently on the socket, which is the one a gap blocks
3407
+ * the rest of this actor's stream behind. Map order is commit order, and
3408
+ * an op waiting on the in-flight budget carries no timer, so the first
3409
+ * dispatched entry is that op. */
3410
+ function isOldestDispatchedOp(clientOpId: string): boolean {
3411
+ for (const [id, entry] of outstandingResends) {
3412
+ if (entry.dispatchedAt === 0) continue;
3413
+ return id === clientOpId;
3414
+ }
3415
+ return false;
3416
+ }
3417
+
3418
+ /** Send whatever the freed budget now has room for. */
3419
+ function drainSendQueue(): void {
3420
+ while (sendQueue.length > 0) {
3421
+ const next = sendQueue[0]!;
3422
+ const entry = outstandingResends.get(next);
3423
+ // An op acked, rejected or abandoned while queued leaves its id behind;
3424
+ // dropping it here is what keeps `forgetOp` off a queue scan.
3425
+ if (!entry || !entry.queued) {
3426
+ sendQueue.shift();
3427
+ continue;
3428
+ }
3429
+ const size = entry.frame.op_blob.length;
3430
+ if (inFlightBytes > 0 && inFlightBytes + size > IN_FLIGHT_BUDGET_BYTES) {
3431
+ return;
3432
+ }
3433
+ sendQueue.shift();
3434
+ entry.queued = false;
3435
+ dispatchOp(next);
3436
+ }
3437
+ }
3438
+
3439
+ /** Drop one op from the tracker, returning its budget. */
3440
+ function forgetOp(clientOpId: string): void {
3441
+ const entry = outstandingResends.get(clientOpId);
3442
+ if (!entry) return;
3443
+ clearTimeout(entry.timer);
3444
+ inFlightBytes -= entry.inFlight;
3445
+ entry.inFlight = 0;
3446
+ entry.queued = false;
3447
+ outstandingResends.delete(clientOpId);
3448
+ opStartedAt.delete(clientOpId);
3449
+ drainSendQueue();
3450
+ }
3451
+
3452
+ /** Drop every tracked op and reset the budget with them. */
3453
+ function forgetAllOps(): void {
3454
+ for (const [clientOpId, entry] of outstandingResends) {
3455
+ clearTimeout(entry.timer);
3456
+ // Only the ops this tracker holds. An op past the frame cap is
3457
+ // travelling over HTTP and is not in here, so clearing the whole
3458
+ // timing map would report it settled while its write is still going.
3459
+ opStartedAt.delete(clientOpId);
3460
+ }
3461
+ outstandingResends.clear();
3462
+ sendQueue.length = 0;
3463
+ inFlightBytes = 0;
3464
+ }
2251
3465
  // Ops this client is writing over HTTP, and the highest seq a catch-up
2252
3466
  // prompt named while one was in flight.
2253
3467
  //
@@ -2269,45 +3483,399 @@ function wireDocTransport(args: WireDocTransportArgs): void {
2269
3483
  if (httpOpWritesInFlight > 0) return;
2270
3484
  const seq = heldCatchUpSeq;
2271
3485
  heldCatchUpSeq = 0;
2272
- if (seq > 0 && args.getCoveredSeq() < seq) args.resyncFromSnapshot?.();
3486
+ if (seq > 0 && args.getCoveredSeq() < seq) pullJoin();
2273
3487
  }
2274
3488
 
2275
- // Backoff: 1.5s, ~1.6x per attempt, ceiling 30s. The ceiling keeps a doc
2276
- // on a dead connection from hammering; the reconnect-driven resend below
2277
- // fires the instant `doc_init` arrives, so the ceiling never delays
2278
- // recovery past the next reconnect.
2279
- const RESEND_BASE_DELAY_MS = 1500;
2280
- const RESEND_MAX_DELAY_MS = 30_000;
3489
+ // Pull the join, and ask again if it left the reader short.
3490
+ //
3491
+ // A join read while a peer writes in bulk is a torn cut: the branch's seq
3492
+ // counter has run ahead of the op rows, so the response reports a head it
3493
+ // could not deliver the tail for. The reader applies what arrived and its
3494
+ // floor lands below that head, and nothing re-sends the difference. So
3495
+ // whatever the reader still lacks after an import is a hole it has to ask
3496
+ // about itself, which is what a passive reader has instead of the `doc_op`
3497
+ // and `op_ack` frames a writing client arms its checks from.
3498
+ function pullJoin(): void {
3499
+ void Promise.resolve(args.resyncFromSnapshot?.()).then(
3500
+ () => {
3501
+ scheduleGapCheck();
3502
+ },
3503
+ () => {
3504
+ // `resyncFromSnapshot` reports its own failures and resolves; a
3505
+ // rejection is a caller that does not, and asking again is still the
3506
+ // right move.
3507
+ scheduleGapCheck();
3508
+ },
3509
+ );
3510
+ }
3511
+
3512
+ // Re-import the join for ops this client cannot bridge to from what it
3513
+ // holds, deferring the judgement while one of its own HTTP writes is still
3514
+ // in flight. Shared by the catch-up prompt and by the reconnect handshake,
3515
+ // which reaches the same conclusion off its tail.
3516
+ // Re-import when the durable frontier names an author whose changes never
3517
+ // came through the op stream. Separate from the coverage check because it
3518
+ // answers a different question: not "did I miss an op" but "did what I hold
3519
+ // stop being how this document is stored".
3520
+ function maybeResyncPastRewrite(vectorB64: string | undefined): void {
3521
+ if (!vectorB64) return;
3522
+ let unseen = false;
3523
+ try {
3524
+ unseen = args.doc._snapshotHasUnseenAuthor(base64ToBytes(vectorB64));
3525
+ } catch {
3526
+ return;
3527
+ }
3528
+ if (unseen) pullJoin();
3529
+ }
3530
+
3531
+ function resyncPastSeq(seq: number): void {
3532
+ if (httpOpWritesInFlight > 0) {
3533
+ if (seq > heldCatchUpSeq) heldCatchUpSeq = seq;
3534
+ return;
3535
+ }
3536
+ pullJoin();
3537
+ }
2281
3538
 
2282
3539
  function resendDelayMs(attempts: number): number {
2283
3540
  const grown = RESEND_BASE_DELAY_MS * Math.pow(1.6, attempts - 1);
2284
3541
  return Math.min(grown, RESEND_MAX_DELAY_MS);
2285
3542
  }
2286
3543
 
3544
+ // When this connection last proved it was working: any `op_ack`, from any
3545
+ // op on this topic.
3546
+ //
3547
+ // What this timer is for is a server that answered NOTHING, which is a
3548
+ // real case (an op frame can draw the opaque `{"message":"Internal server
3549
+ // error"}` reply the multiplexer drops). What it must not read as silence
3550
+ // is an ack that is merely behind, because with many ops in flight one
3551
+ // op's latency is the queue's depth times its service time, not evidence
3552
+ // about that op. Resending on that reading writes the op a second time,
3553
+ // which deepens the queue, which slows the next ack, which fires more
3554
+ // resends.
3555
+ //
3556
+ // Measured on a 500,000-element write: the same content went through in
3557
+ // 1,320 op writes on a run whose acks stayed under 375 ms, and in 3,600 on
3558
+ // a run whose acks reached 1,615 ms, just past this backoff's first step.
3559
+ // The extra writes are durable duplicates, so they inflate the document's
3560
+ // history, and every later compaction pays for them: the run that tripled
3561
+ // its ops left a tail compaction could not absorb inside its memory, and
3562
+ // the document stopped opening.
3563
+ //
3564
+ // So the timer fires on silence rather than on slowness: an ack that
3565
+ // arrived after this op's timer was armed re-arms it without spending an
3566
+ // attempt, within the bound `forceResendDue` puts on how long one op may
3567
+ // ride that evidence.
3568
+ let lastAckAt = 0;
3569
+
2287
3570
  function scheduleResend(clientOpId: string): void {
2288
3571
  const entry = outstandingResends.get(clientOpId);
2289
3572
  if (!entry) return;
3573
+ entry.armedAt = Date.now();
2290
3574
  entry.timer = setTimeout(() => {
2291
3575
  const current = outstandingResends.get(clientOpId);
2292
3576
  if (!current) return;
3577
+ const forced = forceResendDue(clientOpId, current);
3578
+ if (lastAckAt >= current.armedAt && !forced) {
3579
+ scheduleResend(clientOpId);
3580
+ return;
3581
+ }
3582
+ // A forced resend is the transport concluding this op was lost, which
3583
+ // is the one condition here the server cannot infer, so it is what the
3584
+ // report is pinned to.
3585
+ if (forced) reportSyncHealth('op_stuck');
2293
3586
  current.attempts += 1;
3587
+ current.dispatchedAt = Date.now();
2294
3588
  args.wsClient.send(current.frame);
2295
3589
  scheduleResend(clientOpId);
2296
3590
  }, resendDelayMs(entry.attempts));
2297
3591
  }
2298
3592
 
3593
+ // The one case where an answering connection is still evidence this op was
3594
+ // lost: it is the oldest op on the socket and it has been there longer than
3595
+ // any working connection takes to answer.
3596
+ //
3597
+ // The evidence `lastAckAt` carries is that the connection is working, not
3598
+ // that it received this op, so an app committing faster than the backoff
3599
+ // supplies that evidence continuously and a dropped op would re-arm free
3600
+ // forever. The server's copy of the document then stops at the gap (Loro
3601
+ // requires per-actor causal contiguity, so every later op is stored and
3602
+ // never applied), the tracker entry never reaches a terminal state, and the
3603
+ // app is told nothing.
3604
+ //
3605
+ // Ack ORDER cannot stand in for that evidence. Acks overtake heavily: 24
3606
+ // ops sent back to back on a deployed environment came back with 10 of 23
3607
+ // adjacent pairs out of send order, and the op sent first was acked 23rd. A
3608
+ // rule reading a later op's ack as proof this one was skipped re-sends ops
3609
+ // that are merely in flight, which is the write-it-twice failure the gate
3610
+ // above exists to prevent.
3611
+ //
3612
+ // Restricting this to the oldest dispatched op is what keeps the traffic
3613
+ // bounded and is sufficient. Bounded: one op per ceiling window however
3614
+ // many ops are outstanding, which matters because this resend hands the
3615
+ // frame straight to `wsClient.send` and so spends none of the in-flight
3616
+ // budget, and a simultaneous round of them is the burst that drops the
3617
+ // socket. Sufficient: the gap at the oldest op is what holds the rest of
3618
+ // this actor's stream unapplied, and an op behind it becomes the oldest in
3619
+ // its turn as the ops in front retire.
3620
+ function forceResendDue(
3621
+ clientOpId: string,
3622
+ entry: { dispatchedAt: number },
3623
+ ): boolean {
3624
+ if (entry.dispatchedAt === 0) return false;
3625
+ if (Date.now() - entry.dispatchedAt <= RESEND_FORCE_CEILING_MS) return false;
3626
+ return isOldestDispatchedOp(clientOpId);
3627
+ }
3628
+
3629
+ // What this transport knows about its own sync, for a reader whose save
3630
+ // is taking longer than it should. Everything here is closure state the
3631
+ // transport already keeps; collecting it costs a walk of the outstanding
3632
+ // ops, so it is safe to call from a render.
3633
+ function collectDiagnostics(): DocSyncDiagnostics {
3634
+ const now = Date.now();
3635
+ let oldestClientOpId: string | null = null;
3636
+ let oldestStartedAt = 0;
3637
+ // Insertion order is commit order, so the first entry is the oldest.
3638
+ for (const [clientOpId, startedAt] of opStartedAt) {
3639
+ oldestClientOpId = clientOpId;
3640
+ oldestStartedAt = startedAt;
3641
+ break;
3642
+ }
3643
+ return {
3644
+ unackedOps: opStartedAt.size,
3645
+ oldestUnackedMs:
3646
+ oldestClientOpId === null ? 0 : Math.max(0, now - oldestStartedAt),
3647
+ oldestClientOpId,
3648
+ reconnects: args.wsClient.reconnectCount ?? 0,
3649
+ lastAckAgeMs: lastAckAt === 0 ? null : Math.max(0, now - lastAckAt),
3650
+ inFlightBytes,
3651
+ httpOpsInFlight: httpOpWritesInFlight,
3652
+ healRounds: divergenceHealRound,
3653
+ hidden: args.wsClient.pageHidden ?? false,
3654
+ };
3655
+ }
3656
+
3657
+ // Push the same picture to the server, so an incident on a device with no
3658
+ // console attached leaves a record an operator can find. Sent on the
3659
+ // conditions the client can recognise and the server cannot, each of
3660
+ // which is already bounded by the machinery that detects it: a forced
3661
+ // resend fires at most once per ceiling window, a reconnect is one
3662
+ // socket, and a heal round is one push. The server drops the excess
3663
+ // anyway.
3664
+ //
3665
+ // Fire-and-forget by design. The frame carries no `client_op_id` of its
3666
+ // own and draws no reply, so it never joins the outstanding-op tracker
3667
+ // and a report cannot become one more thing a stuck document is waiting
3668
+ // on.
3669
+ function reportSyncHealth(reason: ClientReportReason): void {
3670
+ // Only worth sending while the socket can carry it. Held through an
3671
+ // outage, a report flushes on the re-subscribe describing state from
3672
+ // before the outage, and it arrives on the new connection ahead of the
3673
+ // accurate one the reconnect itself produces, so the stale figures are
3674
+ // what the server's one-per-connection budget accepts.
3675
+ if (args.wsClient.connected === false) return;
3676
+ const d = collectDiagnostics();
3677
+ args.wsClient.send({
3678
+ type: 'client_report',
3679
+ topic,
3680
+ client_report: {
3681
+ reason,
3682
+ unacked_ops: d.unackedOps,
3683
+ oldest_unacked_ms: d.oldestUnackedMs,
3684
+ ...(d.oldestClientOpId === null
3685
+ ? {}
3686
+ : { oldest_client_op_id: d.oldestClientOpId }),
3687
+ reconnects: d.reconnects,
3688
+ last_ack_age_ms: d.lastAckAgeMs,
3689
+ in_flight_bytes: d.inFlightBytes,
3690
+ http_ops_in_flight: d.httpOpsInFlight,
3691
+ heal_rounds: d.healRounds,
3692
+ hidden: d.hidden,
3693
+ },
3694
+ });
3695
+ }
3696
+
3697
+ args.doc._setDiagnosticsProvider(collectDiagnostics);
3698
+
2299
3699
  // Resend every outstanding op now and restart its backoff. Called on
2300
3700
  // reconnect (`doc_init`): a dropped-and-restored connection is the most
2301
3701
  // common reason an op went unacked, and the backoff may have grown to its
2302
3702
  // ceiling while the socket was down, so re-asserting immediately is the
2303
3703
  // fast recovery path.
2304
3704
  function resendAllOutstanding(): void {
3705
+ // Nothing is on the wire any more, whether this follows a reconnect or a
3706
+ // shed, so the budget starts empty and every op re-enters through it.
3707
+ // Sending them all at once is what turns one dropped connection into a
3708
+ // loop that never persists anything: the burst that lost the socket is
3709
+ // exactly the burst the reconnect would repeat.
3710
+ inFlightBytes = 0;
3711
+ sendQueue.length = 0;
3712
+ // Insertion order is the order the ops were committed in, so a round
3713
+ // re-asserts them oldest-first.
2305
3714
  for (const [clientOpId, entry] of outstandingResends) {
2306
3715
  clearTimeout(entry.timer);
2307
3716
  entry.attempts += 1;
2308
- args.wsClient.send(entry.frame);
2309
- scheduleResend(clientOpId);
3717
+ entry.inFlight = 0;
3718
+ entry.queued = false;
3719
+ dispatchOp(clientOpId);
3720
+ }
3721
+ }
3722
+
3723
+ let unavailableRounds = 0;
3724
+ let unavailableTimer: ReturnType<typeof setTimeout> | null = null;
3725
+
3726
+ function clearUnavailableRecovery(): void {
3727
+ if (unavailableTimer !== null) {
3728
+ clearTimeout(unavailableTimer);
3729
+ unavailableTimer = null;
3730
+ }
3731
+ unavailableRounds = 0;
3732
+ }
3733
+
3734
+ // Every outstanding op is dead: drop the tracker entries and tell the
3735
+ // Document, which stops counting them in `savingCount` and reports each
3736
+ // through the error channel the app reads (`Brass.onError`). The ids are
3737
+ // taken first because `_failOutstandingOp` re-enters app code.
3738
+ //
3739
+ // Dropping the ops is what opens the divergence, so the heal is armed here
3740
+ // rather than at the call site: the local doc keeps the abandoned ops and
3741
+ // keeps accepting edits that depend on them, and only the handshake can
3742
+ // hand the server the slice it needs to apply any of it.
3743
+ function failAllOutstanding(message: string): void {
3744
+ const clientOpIds = [...outstandingResends.keys()];
3745
+ forgetAllOps();
3746
+ for (const clientOpId of clientOpIds) {
3747
+ args.doc._failOutstandingOp(clientOpId, message);
3748
+ }
3749
+ if (clientOpIds.length > 0) {
3750
+ divergenceHealRound = 0;
3751
+ scheduleDivergenceHeal();
3752
+ }
3753
+ }
3754
+
3755
+ // One op is dead: report it and arm the heal, for the same reason
3756
+ // `failAllOutstanding` does. The op stays applied locally and every edit
3757
+ // made after it depends on it, so the server holds a stream it can store
3758
+ // and never apply, and its own escape for an op nothing can apply is to
3759
+ // discard it. Most refusals do not change that: the handshake pushes
3760
+ // whatever slice the durable frontier is missing and splits it under the
3761
+ // frame cap, so it heals the dependents even where the op itself is
3762
+ // irreducibly too large to re-send.
3763
+ //
3764
+ // `healable: false` is for the refusal where pushing again is asking the
3765
+ // same question. A full document refuses every op whatever its size, so
3766
+ // the handshake's own chunks come back refused, and the counter reset
3767
+ // below turns that into a loop rather than a bounded retry. What recovers
3768
+ // that document is the periodic re-verify, once the server can take a
3769
+ // write at all.
3770
+ // A refusal the heal cannot work around leaves the writer holding a change
3771
+ // that reached no database and no other reader, so the writer is the only
3772
+ // copy of the document that shows it. Left editable it reads as applied: the
3773
+ // element is on screen, the count includes it, every later edit builds on
3774
+ // it, and a reader who opens the document sees none of it. Nothing local
3775
+ // removes it either, because a CRDT merges rather than replaces and an undo
3776
+ // inverts an operation instead of discarding it, so the only state holding
3777
+ // exactly what is durable is one built from the server.
3778
+ //
3779
+ // Stopping the divergence and resolving it are separated, because they cost
3780
+ // different things and doing them in one silent step is what made a refusal
3781
+ // read as the platform losing work. Stopping is free and immediate:
3782
+ // `readOnly` turns true, which closes an app's existing edit gate, so the
3783
+ // copy cannot drift further. Resolving discards the refused work and swaps
3784
+ // the document under whoever was working in it, so the user is told first
3785
+ // and the reload follows their acknowledgement.
3786
+ //
3787
+ // The telling is the SDK's job rather than each app's. A copy left showing a
3788
+ // change nothing else has is the failure being closed, so an app that
3789
+ // renders no surface for this cannot be the reason a user stays on one.
3790
+ //
3791
+ // Once per document. The first refusal is what diverged this copy, so the
3792
+ // later ones describe the same state and have nothing to add.
3793
+ let lockedForRefusal = false;
3794
+ function lockForUnhealableRefusal(message: string): void {
3795
+ if (lockedForRefusal) return;
3796
+ lockedForRefusal = true;
3797
+ // Torn down in the order the `doc_gone` handler uses, and for the same
3798
+ // reason: every one of these keeps working against a copy that is now
3799
+ // read-only, and the resync push in particular would re-offer the refused
3800
+ // op this exists to stop.
3801
+ forgetAllOps();
3802
+ clearUnavailableRecovery();
3803
+ if (reverifyTimer !== null) {
3804
+ clearInterval(reverifyTimer);
3805
+ reverifyTimer = null;
3806
+ }
3807
+ clearDivergenceHeal();
3808
+ if (gapTimer !== null) {
3809
+ clearTimeout(gapTimer);
3810
+ gapTimer = null;
3811
+ }
3812
+ args.doc._markReadOnly('unsaved');
3813
+ // The user is told by the SDK, not by each app, because an app that
3814
+ // renders nothing is exactly the case this has to cover: a document that
3815
+ // reloaded and a document that reloaded for a reason look the same
3816
+ // otherwise. `alert` holds the main thread until it is acknowledged, so
3817
+ // the reason cannot be missed and the reload lands on a page the user
3818
+ // is looking at. A browser that suppresses the dialog returns from it
3819
+ // immediately, which reloads rather than skipping, and a caller outside
3820
+ // a browser (a worker, a test) has nobody to tell and does the same.
3821
+ //
3822
+ // The reload survives a dialog that throws, which is the one way telling
3823
+ // the user could cost them the fix: `alert` is a page-global an app is
3824
+ // free to replace, and the copy this leaves behind is the diverged one
3825
+ // the reload exists to discard.
3826
+ try {
3827
+ if (typeof alert === 'function') alert(message);
3828
+ } catch {
3829
+ // Told or not, the copy is resolved.
2310
3830
  }
3831
+ args.doc.reload();
3832
+ }
3833
+
3834
+ function failOpTerminally(
3835
+ clientOpId: string,
3836
+ message: string,
3837
+ opts: { healable?: boolean; report?: boolean } = {},
3838
+ ): void {
3839
+ // Read before `_failOutstandingOp`, which clears the id from the
3840
+ // resync-push set on its way out.
3841
+ const wasHealPush = args.doc._isResyncPushOp(clientOpId);
3842
+ opStartedAt.delete(clientOpId);
3843
+ // A refusal the heal cannot answer is about the document, so it answers
3844
+ // every op in flight the same way and is reported once, by the lock. Per
3845
+ // op, a bulk write's outstanding ops each produce an identical error, so
3846
+ // the reader watches the same message arrive dozens of times before the
3847
+ // one dialog that explains it.
3848
+ args.doc._failOutstandingOp(clientOpId, message, {
3849
+ report: opts.report ?? true,
3850
+ });
3851
+ if (opts.healable === false) {
3852
+ lockForUnhealableRefusal(message);
3853
+ return;
3854
+ }
3855
+ // Restart the backoff only for a failure the heal did not itself
3856
+ // produce. A refused heal chunk that zeroed the round count would keep
3857
+ // the bound from ever binding: each round pushes, each push is refused,
3858
+ // and each refusal buys another round. Leaving the count alone lets the
3859
+ // rounds run out the way an unanswered handshake already does.
3860
+ if (!wasHealPush) divergenceHealRound = 0;
3861
+ scheduleDivergenceHeal();
3862
+ }
3863
+
3864
+ function onServerUnavailable(): void {
3865
+ if (outstandingResends.size === 0) return;
3866
+ if (unavailableTimer !== null) return;
3867
+ unavailableRounds += 1;
3868
+ if (unavailableRounds > UNAVAILABLE_MAX_ROUNDS) {
3869
+ clearUnavailableRecovery();
3870
+ failAllOutstanding(UNAVAILABLE_GIVE_UP_MESSAGE);
3871
+ return;
3872
+ }
3873
+ const delay = unavailableDelayMs(unavailableRounds);
3874
+ unavailableTimer = setTimeout(() => {
3875
+ unavailableTimer = null;
3876
+ resendAllOutstanding();
3877
+ }, delay);
3878
+ (unavailableTimer as { unref?: () => void }).unref?.();
2311
3879
  }
2312
3880
 
2313
3881
  // Durability handshake, run on every `doc_init`. The persisted version
@@ -2323,10 +3891,14 @@ function wireDocTransport(args: WireDocTransportArgs): void {
2323
3891
  durableTailBlobs: Uint8Array[],
2324
3892
  ): void {
2325
3893
  if (!snapshotVersionVectorB64) return;
2326
- args.doc.maybeResyncPush(
3894
+ const outcome = args.doc.maybeResyncPush(
2327
3895
  base64ToBytes(snapshotVersionVectorB64),
2328
3896
  durableTailBlobs,
2329
3897
  );
3898
+ // Reported here as well as from the re-verify, or the record holds only
3899
+ // the heals that took the slower route and an operator reading it cannot
3900
+ // tell that the ordinary reconnect handshake is missing from the set.
3901
+ if (outcome === 'pushed') reportSyncHealth('divergence_healed');
2330
3902
  }
2331
3903
 
2332
3904
  // Re-fetch the durable frontier from the HTTP join and run the handshake.
@@ -2344,225 +3916,509 @@ function wireDocTransport(args: WireDocTransportArgs): void {
2344
3916
  let reverifyAgain = false;
2345
3917
  // Armed after the subscription is wired, cleared on `doc_gone` and teardown.
2346
3918
  let reverifyTimer: ReturnType<typeof setInterval> | null = null;
2347
- async function reverifyDurability(): Promise<void> {
3919
+ // The last run's verdict, so a caller healing a known divergence can tell a
3920
+ // finished handshake from one that fetched nothing.
3921
+ async function reverifyDurability(): Promise<ResyncPushOutcome | 'no-fetch'> {
2348
3922
  if (reverifyInFlight) {
2349
3923
  reverifyAgain = true;
2350
- return;
3924
+ return 'in-flight';
2351
3925
  }
2352
3926
  reverifyInFlight = true;
3927
+ let outcome: ResyncPushOutcome | 'no-fetch' = 'no-fetch';
2353
3928
  try {
2354
3929
  do {
2355
3930
  reverifyAgain = false;
2356
3931
  try {
2357
3932
  const frontier = await args.refetchDurableFrontier?.();
2358
3933
  if (frontier && frontier.snapshotVersionVector) {
2359
- args.doc.maybeResyncPush(
3934
+ outcome = args.doc.maybeResyncPush(
2360
3935
  base64ToBytes(frontier.snapshotVersionVector),
2361
3936
  frontier.tailBlobs,
2362
3937
  );
3938
+ // The handshake having work to do means the server's copy was
3939
+ // behind this client's, which is the state a lost op leaves and
3940
+ // which nothing on the server side reports.
3941
+ if (outcome === 'pushed') reportSyncHealth('divergence_healed');
2363
3942
  }
2364
3943
  } catch {
2365
3944
  // Best-effort: a failed re-fetch defers the check to the next tick
2366
3945
  // or reconnect's `doc_init` handshake. Swallowed so the timer's
2367
3946
  // fire-and-forget never surfaces an unhandled rejection and one
2368
- // failed tick does not wedge the loop guard.
3947
+ // failed tick does not wedge the loop guard. The `no-fetch` verdict
3948
+ // is what keeps a divergence heal asking.
3949
+ outcome = 'no-fetch';
2369
3950
  }
2370
3951
  } while (reverifyAgain);
2371
3952
  } finally {
2372
3953
  reverifyInFlight = false;
2373
3954
  }
3955
+ return outcome;
3956
+ }
3957
+
3958
+ // Divergence heal. Giving up on an op leaves the local doc holding CRDT
3959
+ // knowledge the server does not, and Loro requires per-actor causal
3960
+ // contiguity, so every op the app writes afterwards reaches the server as
3961
+ // causally unapplyable and stays that way until the missing slice arrives.
3962
+ // The server's own escape for an op nothing can apply is to discard it, so
3963
+ // the window between the give-up and the next handshake is a window where
3964
+ // the app writes into a document that keeps none of it.
3965
+ //
3966
+ // The periodic re-verify would eventually close that window, but on its own
3967
+ // schedule: a bulk write finishes and the reader reloads well inside one
3968
+ // tick. A give-up is the moment the client knows it has diverged, so it
3969
+ // drives the handshake itself, and keeps driving it on a backoff until one
3970
+ // run reports the durable frontier covering the local doc. The first delay
3971
+ // is not zero because the give-up follows sustained shedding, so the fetch
3972
+ // this schedules would join the burst it is recovering from.
3973
+ const DIVERGENCE_HEAL_BASE_DELAY_MS = 2_000;
3974
+ const DIVERGENCE_HEAL_MAX_DELAY_MS = 30_000;
3975
+ // Roughly four minutes of attempts, after which the periodic re-verify is
3976
+ // the remaining backstop and the tab has long since reported the failure.
3977
+ const DIVERGENCE_HEAL_MAX_ROUNDS = 12;
3978
+ let divergenceHealRound = 0;
3979
+ let divergenceHealTimer: ReturnType<typeof setTimeout> | null = null;
3980
+
3981
+ function scheduleDivergenceHeal(): void {
3982
+ if (divergenceHealTimer !== null) return;
3983
+ if (divergenceHealRound >= DIVERGENCE_HEAL_MAX_ROUNDS) return;
3984
+ divergenceHealRound += 1;
3985
+ const delay = Math.min(
3986
+ DIVERGENCE_HEAL_BASE_DELAY_MS * Math.pow(2, divergenceHealRound - 1),
3987
+ DIVERGENCE_HEAL_MAX_DELAY_MS,
3988
+ );
3989
+ divergenceHealTimer = setTimeout(() => {
3990
+ divergenceHealTimer = null;
3991
+ void reverifyDurability().then((outcome) => {
3992
+ // `covered` is the only verdict that proves the hole is closed. A
3993
+ // push that just went out is not yet durable, so the next round
3994
+ // re-checks it rather than assuming its acks land, and the backoff
3995
+ // it comes back on is what keeps a push settling from being polled.
3996
+ if (outcome !== 'covered') scheduleDivergenceHeal();
3997
+ else divergenceHealRound = 0;
3998
+ });
3999
+ }, delay);
4000
+ (divergenceHealTimer as { unref?: () => void }).unref?.();
4001
+ }
4002
+
4003
+ function clearDivergenceHeal(): void {
4004
+ if (divergenceHealTimer !== null) {
4005
+ clearTimeout(divergenceHealTimer);
4006
+ divergenceHealTimer = null;
4007
+ }
4008
+ divergenceHealRound = 0;
2374
4009
  }
2375
4010
 
2376
4011
  // A coverage floor below the highest seq seen records ops this client
2377
4012
  // applied nothing for, and the server posts each op once, so nothing
2378
4013
  // re-sends them. Pull the join instead, after a delay long enough for an
2379
4014
  // out-of-order arrival to close the hole itself.
4015
+ //
4016
+ // The delay is only meaningful against the hole this check was ARMED on,
4017
+ // which is why the seq it waits to reach is captured here rather than read
4018
+ // again when it fires. Holes are the ordinary state of a client that is
4019
+ // writing: acks overtake heavily, so the newest acked seq sits above the
4020
+ // contiguous floor almost continuously, and each such hole closes as the ack
4021
+ // below it lands. Asking on expiry whether ANY hole exists reads a
4022
+ // brand-new one, opened by ops sent since, as the original persisting, and a
4023
+ // client that keeps writing always has one. That answer costs a join
4024
+ // re-import, which discards the undo and redo stacks, so the reader loses
4025
+ // their history every couple of seconds while nothing is wrong.
4026
+ //
4027
+ // Rounds that move nothing back off and then stop, and any advance in the
4028
+ // floor starts the budget over. What separates the two cases is exactly
4029
+ // that: a hole left by a write still in flight fills as the rows land, so
4030
+ // each pull delivers more and earns the next one, while a seq the server
4031
+ // allocated and never wrote (a failed op write leaves a hole rather than a
4032
+ // duplicate) delivers nothing however often it is asked for. Without the
4033
+ // budget that second hole is a join re-import every couple of seconds for
4034
+ // the life of the tab, each one discarding the reader's undo history. It
4035
+ // clears at the next compaction, which folds the tail past the hole.
4036
+ const GAP_PULL_MAX_ROUNDS = 6;
4037
+ const GAP_PULL_MAX_DELAY_MS = 30_000;
2380
4038
  let gapTimer: ReturnType<typeof setTimeout> | null = null;
4039
+ let gapPullRounds = 0;
4040
+ let gapPullFloor = -1;
2381
4041
  function scheduleGapCheck(): void {
2382
4042
  if (gapTimer !== null) return;
2383
- if (args.getCoveredSeq() >= args.getCurrentSeq()) return;
4043
+ const awaited = args.getCurrentSeq();
4044
+ const floor = args.getCoveredSeq();
4045
+ if (floor >= awaited) {
4046
+ gapPullRounds = 0;
4047
+ return;
4048
+ }
4049
+ if (floor > gapPullFloor) gapPullRounds = 0;
4050
+ if (gapPullRounds >= GAP_PULL_MAX_ROUNDS) return;
4051
+ gapPullRounds += 1;
4052
+ gapPullFloor = floor;
4053
+ const delay = Math.min(
4054
+ MISSED_OP_RESYNC_DELAY_MS * Math.pow(2, gapPullRounds - 1),
4055
+ GAP_PULL_MAX_DELAY_MS,
4056
+ );
2384
4057
  gapTimer = setTimeout(() => {
2385
4058
  gapTimer = null;
2386
- if (args.getCoveredSeq() >= args.getCurrentSeq()) return;
2387
- args.resyncFromSnapshot?.();
2388
- }, MISSED_OP_RESYNC_DELAY_MS);
4059
+ // Covered up to where the hole was: it closed on its own, whatever has
4060
+ // been sent since. Anything newer arms its own check.
4061
+ if (args.getCoveredSeq() >= awaited) {
4062
+ gapPullRounds = 0;
4063
+ return;
4064
+ }
4065
+ pullJoin();
4066
+ }, delay);
2389
4067
  (gapTimer as { unref?: () => void }).unref?.();
2390
4068
  }
2391
4069
 
4070
+ // The server ended this subscription: it refused the subscribe (a revoked
4071
+ // grant), or evicted it again after the one silent re-subscribe a
4072
+ // connection grants. The client detaches this transport from the topic as
4073
+ // it reports that, so no ack, no fanout and no reconnect reaches it again.
4074
+ //
4075
+ // Everything the transport is waiting on is therefore dead. The ops it
4076
+ // holds can never be acked, and re-sending them puts frames on a
4077
+ // subscription the server has refused while `savingCount` climbs and the
4078
+ // app hears nothing, so they are failed here and every later edit fails
4079
+ // the same way: the app reads one actionable error per unsaved change and
4080
+ // its cue to reload, rather than a document that looks like it is saving
4081
+ // forever. The ids are taken before the reports because
4082
+ // `_failOutstandingOp` re-enters app code.
4083
+ let subscriptionEnded = false;
4084
+ function endSubscription(): void {
4085
+ if (subscriptionEnded) return;
4086
+ subscriptionEnded = true;
4087
+ const clientOpIds = [...outstandingResends.keys()];
4088
+ forgetAllOps();
4089
+ clearUnavailableRecovery();
4090
+ // The heal and the gap pull both work through this transport, so they
4091
+ // are stopped rather than armed: what they would push or fetch has no
4092
+ // subscription left to travel on.
4093
+ clearDivergenceHeal();
4094
+ if (reverifyTimer !== null) {
4095
+ clearInterval(reverifyTimer);
4096
+ reverifyTimer = null;
4097
+ }
4098
+ if (gapTimer !== null) {
4099
+ clearTimeout(gapTimer);
4100
+ gapTimer = null;
4101
+ }
4102
+ for (const clientOpId of clientOpIds) {
4103
+ args.doc._failOutstandingOp(clientOpId, SUBSCRIPTION_ENDED_MESSAGE);
4104
+ }
4105
+ }
4106
+
2392
4107
  // 1. Subscribe to the doc topic. Frame routing:
2393
4108
  // - `doc_init`: bridge the join-vs-fanout race. Ops committed between
2394
4109
  // the HTTP join (T0) and the WS subscribe (T1) miss the HTTP tail and
2395
- // can race the SUB# row so the first `doc_op` fanout may arrive BEFORE
4110
+ // can race the subscription record so the first `doc_op` fanout may arrive BEFORE
2396
4111
  // doc_init (separate server invocations, no wire ordering). tail_ops
2397
4112
  // apply unconditionally: filtering on `seq > currentSeq` would mask
2398
4113
  // tail_ops a racing `doc_op` inflated the cursor past, leaving lamport
2399
4114
  // gaps Loro cannot converge across (re-applying is an idempotent
2400
4115
  // no-op).
4116
+ // - `doc_catch_up`: the document moved past this client's coverage on
4117
+ // an op the socket could not carry; re-import the join and nothing
4118
+ // more (see the branch).
2401
4119
  // - `doc_op`: apply, advance cursor.
2402
4120
  // - `op_ack`: resolve the outstanding-op tracker, advance cursor, cancel
2403
4121
  // that op's resend timer.
2404
4122
  // - everything else: handled by `BrassWsClient`.
2405
- const unsub = args.wsClient.subscribe(topic, (frame) => {
2406
- if (isDocInitFrame(frame)) {
2407
- // Resync check BEFORE applying. The tail covers (snapshot_seq,
2408
- // current_seq]; if the coverage floor sits below the tail's lower edge
2409
- // (or below current_seq when the tail is empty), ops in between were
2410
- // compacted into the snapshot while disconnected and exist nowhere in
2411
- // this tail or future fanout, so re-import the join's snapshot. The
2412
- // initial subscribe never trips this (the floor was just seeded), so
2413
- // the cost is paid only on a reconnect that crossed a compaction.
2414
- const minTailSeq = frame.tail_ops.reduce<number | null>(
2415
- (acc, entry) => (acc === null || entry.seq < acc ? entry.seq : acc),
2416
- null,
2417
- );
2418
- const snapshotFloor =
2419
- minTailSeq !== null ? minTailSeq - 1 : frame.current_seq;
2420
- const needsResync = args.getCoveredSeq() < snapshotFloor;
2421
- const tailBlobs: Uint8Array[] = [];
2422
- for (const entry of frame.tail_ops) {
2423
- // An op with no inline bytes is deliberately left uncovered: it
2424
- // has not been applied, so marking it covered would retire the
2425
- // very resync that fetches it.
2426
- if (entry.op_blob_b64 === undefined) continue;
2427
- const blob = base64ToBytes(entry.op_blob_b64);
2428
- tailBlobs.push(blob);
2429
- args.doc.applyRemoteOp(blob);
2430
- if (entry.seq > args.getCurrentSeq()) args.setCurrentSeq(entry.seq);
2431
- args.advanceCoveredSeq(entry.seq);
2432
- }
2433
- if (frame.current_seq > args.getCurrentSeq()) {
2434
- args.setCurrentSeq(frame.current_seq);
2435
- }
2436
- if (needsResync) {
2437
- if (httpOpWritesInFlight > 0) {
2438
- if (frame.current_seq > heldCatchUpSeq) heldCatchUpSeq = frame.current_seq;
4123
+ const unsub = args.wsClient.subscribe(
4124
+ topic,
4125
+ (frame) => {
4126
+ const init = parseDocInitFrame(frame);
4127
+ if (init) {
4128
+ // Recorded as the frame arrives rather than after the tail applies.
4129
+ // Importing a tail op can throw, and the client swallows it, so a
4130
+ // latch written further down would stay behind and read the NEXT
4131
+ // reconnect as the opening subscribe, suppressing the report this
4132
+ // exists to make.
4133
+ const reconnects = args.wsClient.reconnectCount ?? 0;
4134
+ const socketDropped =
4135
+ docInitReconnectCount !== null && reconnects > docInitReconnectCount;
4136
+ docInitReconnectCount = reconnects;
4137
+ // Resync check BEFORE applying. The tail covers (snapshot_seq,
4138
+ // current_seq]; if the coverage floor sits below the tail's lower edge
4139
+ // (or below current_seq when the tail is empty), ops in between were
4140
+ // compacted into the snapshot while disconnected and exist nowhere in
4141
+ // this tail or future fanout, so re-import the join's snapshot. The
4142
+ // initial subscribe never trips this (the floor was just seeded), so
4143
+ // the cost is paid only on a reconnect that crossed a compaction.
4144
+ const snapshotFloor = snapshotSeqOf(
4145
+ init.snapshot_seq,
4146
+ init.tail_ops.map((entry) => entry.seq),
4147
+ init.current_seq,
4148
+ );
4149
+ const needsResync = args.getCoveredSeq() < snapshotFloor;
4150
+ const tailBlobs: Uint8Array[] = [];
4151
+ // Whether the frame's own entries can describe the durable frontier.
4152
+ // An entry the server withheld the bytes of still NAMES an op the
4153
+ // server holds, so a frontier folded from the inline entries alone
4154
+ // reports every withheld op as missing from the server. See the
4155
+ // handshake call below.
4156
+ let tailComplete = true;
4157
+ for (const entry of init.tail_ops) {
4158
+ // An op with no inline bytes is deliberately left uncovered: it
4159
+ // has not been applied, so marking it covered would retire the
4160
+ // very resync that fetches it.
4161
+ if (entry.op_blob_b64 === undefined) {
4162
+ tailComplete = false;
4163
+ continue;
4164
+ }
4165
+ const blob = base64ToBytes(entry.op_blob_b64);
4166
+ tailBlobs.push(blob);
4167
+ args.doc.applyRemoteOp(blob);
4168
+ if (entry.seq > args.getCurrentSeq()) args.setCurrentSeq(entry.seq);
4169
+ args.advanceCoveredSeq(entry.seq);
4170
+ }
4171
+ // The frame also has to ACCOUNT for every seq the head names, not
4172
+ // merely carry bytes for the entries it lists. The server drops an
4173
+ // oversized or spilled tail from this frame ENTIRELY rather than
4174
+ // listing it byte-less (`document.tail.dropped`), so an empty tail on
4175
+ // a branch whose head sits above its snapshot is not a branch with
4176
+ // nothing in flight: it is the whole tail withheld. Measured on a
4177
+ // real document: snapshot_seq 565, current_seq 1089, 524 ops dropped
4178
+ // from the frame and every one of them already imported by the HTTP
4179
+ // join, so a frontier folded from this frame alone sat 524 ops behind
4180
+ // the local doc and pushed them back on every open.
4181
+ if (
4182
+ typeof init.snapshot_seq === 'number' &&
4183
+ tailBlobs.length < init.current_seq - init.snapshot_seq
4184
+ ) {
4185
+ tailComplete = false;
4186
+ }
4187
+ if (init.current_seq > args.getCurrentSeq()) {
4188
+ args.setCurrentSeq(init.current_seq);
4189
+ }
4190
+ if (needsResync) {
4191
+ resyncPastSeq(init.current_seq);
2439
4192
  } else {
2440
- args.resyncFromSnapshot?.();
4193
+ // A frame that named a head it did not carry the tail for leaves
4194
+ // the reader short with nothing further coming: the server drops an
4195
+ // oversized or spilled tail from this frame and posts each op once.
4196
+ // For a reader that never writes, this and the catch-up prompt are
4197
+ // the only two places a gap gets noticed at all.
4198
+ scheduleGapCheck();
4199
+ }
4200
+ // `doc_init` is the reconnect signal the retry-forever recovery hangs
4201
+ // off: re-assert every outstanding op and run the durability handshake
4202
+ // now, so a client holding work the server lost re-asserts it on
4203
+ // reconnect rather than waiting out the per-op backoff. A fresh
4204
+ // connection also returns the shed budget to full: this re-assert is
4205
+ // the round the pending one would have run.
4206
+ clearUnavailableRecovery();
4207
+ // A reconnect, rather than the initial subscribe: the socket dropped
4208
+ // while this document held work, which is the condition the server
4209
+ // has no way to see. The first `doc_init` arrives on the opening
4210
+ // subscribe, and an app that writes as soon as it opens already has
4211
+ // ops outstanding by then, so reporting on every `doc_init` would
4212
+ // fire routinely on a healthy first open and train a reader of the
4213
+ // log to discount the event.
4214
+ //
4215
+ // Before the round rather than after, so the counters describe the
4216
+ // work the reconnect found rather than the work it has just put back
4217
+ // on the wire.
4218
+ if (socketDropped && outstandingResends.size > 0) {
4219
+ reportSyncHealth('reconnected_unsaved');
2441
4220
  }
4221
+ resendAllOutstanding();
4222
+ // The handshake needs the WHOLE durable tail, and this frame carries
4223
+ // it only when every entry came inline. A spilled op is named here
4224
+ // with its bytes withheld, and the HTTP join the reader already ran
4225
+ // resolves and imports it, so a frontier built from the inline
4226
+ // entries alone sits behind the local doc by exactly the ops that
4227
+ // spilled, and the handshake pushes the reader's copy of the server's
4228
+ // own tail back at it. On a branch at capacity every chunk is refused
4229
+ // and reported, which is an error on screen for someone who only
4230
+ // reopened the document; on any other branch it lands, writing a
4231
+ // second durable copy of every spilled op and growing the document
4232
+ // toward the ceiling on each open. `reverifyDurability` fetches
4233
+ // through `fetchSnapshot`, which resolves spilled entries, so the
4234
+ // frontier it diffs against is complete.
4235
+ if (tailComplete) {
4236
+ maybeResyncPush(init.snapshot_version_vector, tailBlobs);
4237
+ } else {
4238
+ void reverifyDurability();
4239
+ }
4240
+ // The tail has applied, so every author this client can hear from is
4241
+ // in its doc. An author left in the durable frontier is one whose
4242
+ // changes exist only inside the snapshot, which is the platform having
4243
+ // rewritten how the document stores what it stores. A client cannot
4244
+ // undo across that: its stack inverts the operations it made, and the
4245
+ // values those operations wrote have moved, so an undo would remove
4246
+ // nothing and report success. Re-import instead, which drops both
4247
+ // stacks and leaves the reader on fresh state with undo honestly
4248
+ // unavailable.
4249
+ //
4250
+ // Coverage does not catch this on its own, and the case it misses is
4251
+ // the ordinary one: the fold runs on a branch nobody has open, so the
4252
+ // client that returns to it missed no ops and has no gap to notice.
4253
+ if (!needsResync) maybeResyncPastRewrite(init.snapshot_version_vector);
4254
+ // Posted after the subscription record exists, so this is the safe point to
4255
+ // reconcile a pending open whose terminal status frame was dropped.
4256
+ args.reconcileStatusOnInit?.();
4257
+ args.onDocumentMoved?.();
4258
+ return;
2442
4259
  }
2443
- // `doc_init` is the reconnect signal the retry-forever recovery hangs
2444
- // off: re-assert every outstanding op and run the durability handshake
2445
- // now, so a client holding work the server lost re-asserts it on
2446
- // reconnect rather than waiting out the per-op backoff.
2447
- resendAllOutstanding();
2448
- // Tolerate a non-string vector (a future retyping) as absent: the
2449
- // handshake is skipped, but the frame still delivers.
2450
- maybeResyncPush(
2451
- typeof frame.snapshot_version_vector === 'string'
2452
- ? frame.snapshot_version_vector
2453
- : undefined,
2454
- tailBlobs,
2455
- );
2456
- // Posted after the SUB# row exists, so this is the safe point to
2457
- // reconcile a pending open whose terminal status frame was dropped.
2458
- args.reconcileStatusOnInit?.();
2459
- return;
2460
- }
2461
- if (isDocOpFrame(frame)) {
2462
- // Apply unconditionally. A `seq <= currentSeq` short-circuit looks safe
2463
- // (import is idempotent) but permanently drops ops on out-of-order
2464
- // fanout: seq=14 before seq=13 inflates the cursor past 13, after which
2465
- // seq=13 is skipped forever, leaving a lamport gap Loro cannot converge
2466
- // across.
2467
- const bytes = base64ToBytes(frame.op_blob);
2468
- args.doc.applyRemoteOp(bytes);
2469
- if (frame.seq > args.getCurrentSeq()) args.setCurrentSeq(frame.seq);
2470
- args.advanceCoveredSeq(frame.seq);
2471
- scheduleGapCheck();
2472
- return;
2473
- }
2474
- if (isOpAckFrame(frame)) {
2475
- args.doc.resolveOutstandingOp(frame.client_op_id);
2476
- args.setCurrentSeq(frame.seq);
2477
- args.advanceCoveredSeq(frame.seq);
2478
- // An ack for a local op sits above any remote op this client never
2479
- // received, so the user's own next edit is a second chance to notice.
2480
- scheduleGapCheck();
2481
- const pending = outstandingResends.get(frame.client_op_id);
2482
- if (pending) {
2483
- clearTimeout(pending.timer);
2484
- outstandingResends.delete(frame.client_op_id);
4260
+ if (isDocCatchUpFrame(frame)) {
4261
+ // The document moved past what this client can bridge to, so pull the
4262
+ // join. Nothing else: the prompt describes the document rather than
4263
+ // this subscription, and every subscriber gets one on each op the
4264
+ // socket could not carry. Re-asserting outstanding ops here writes a
4265
+ // second durable copy of each of them (the server allocates a fresh
4266
+ // seq per send) for every such op any writer makes, and cancelling
4267
+ // the shed recovery here parks a shed op on its per-op backoff
4268
+ // instead of the schedule the shed armed.
4269
+ if (frame.current_seq > args.getCurrentSeq()) {
4270
+ args.setCurrentSeq(frame.current_seq);
4271
+ }
4272
+ if (args.getCoveredSeq() < frame.current_seq) {
4273
+ resyncPastSeq(frame.current_seq);
4274
+ }
4275
+ return;
2485
4276
  }
2486
- return;
2487
- }
2488
- if (isOpRejectedFrame(frame)) {
2489
- // Terminal, not transient: cancel this op's resend (retrying would only
2490
- // draw the same rejection forever and pin `savingCount`) and fail it on
2491
- // the Document so the app hears "could not be saved" instead of a silent
2492
- // wedge. Scoped to this one op; the topic subscription stays live.
2493
- const pending = outstandingResends.get(frame.client_op_id);
2494
- if (pending) {
2495
- clearTimeout(pending.timer);
2496
- outstandingResends.delete(frame.client_op_id);
2497
- }
2498
- args.doc._failOutstandingOp(frame.client_op_id, opRejectedMessage(frame));
2499
- return;
2500
- }
2501
- if (isDocGoneFrame(frame)) {
2502
- // Cancel every resend timer FIRST so a doc that was saving when delete
2503
- // landed stops re-trying ops the server will only reject as `forbidden`
2504
- // (the SUB# row was evicted at fanout). Draining before the `_markGone`
2505
- // listeners keeps the lifecycle observably clean.
2506
- for (const pending of outstandingResends.values()) {
2507
- clearTimeout(pending.timer);
2508
- }
2509
- outstandingResends.clear();
2510
- // Stop re-verifying durability against an evicted doc: a resync push
2511
- // would only bounce as `forbidden`, and a gap pull would only 404.
2512
- if (reverifyTimer !== null) {
2513
- clearInterval(reverifyTimer);
2514
- reverifyTimer = null;
2515
- }
2516
- if (gapTimer !== null) {
2517
- clearTimeout(gapTimer);
2518
- gapTimer = null;
2519
- }
2520
- args.doc._markGone(goneReasonOf(frame));
2521
- return;
2522
- }
2523
- if (isDocStatusFrame(frame)) {
2524
- // The validator gates only on `pending`; default `message` here so a
2525
- // message-less status still carries a valid `DocumentWorkStatus`
2526
- // (`message` is typed non-null and rendered verbatim).
2527
- const status: DocumentWorkStatus = {
2528
- ...frame.status,
2529
- message:
2530
- typeof frame.status.message === 'string' ? frame.status.message : '',
2531
- };
2532
- // `reload` is the server's authoritative "succeeded, content ready"
2533
- // hint: pull the content in and emit the terminal status only AFTER the
2534
- // import lands, so an app re-reading on `onStatus` sees content present.
2535
- // A failure frame has no `reload`, so emit immediately and fetch
2536
- // nothing (no racy read, no loop).
2537
- if (frame.reload && args.reloadAfterHydration) {
2538
- args.reloadAfterHydration(status);
2539
- } else {
2540
- args.doc._emitStatus(status);
4277
+ if (isDocOpFrame(frame)) {
4278
+ // Apply unconditionally. A `seq <= currentSeq` short-circuit looks safe
4279
+ // (import is idempotent) but permanently drops ops on out-of-order
4280
+ // fanout: seq=14 before seq=13 inflates the cursor past 13, after which
4281
+ // seq=13 is skipped forever, leaving a lamport gap Loro cannot converge
4282
+ // across.
4283
+ const bytes = base64ToBytes(frame.op_blob);
4284
+ args.doc.applyRemoteOp(bytes);
4285
+ if (frame.seq > args.getCurrentSeq()) args.setCurrentSeq(frame.seq);
4286
+ args.advanceCoveredSeq(frame.seq);
4287
+ scheduleGapCheck();
4288
+ args.onDocumentMoved?.();
4289
+ return;
2541
4290
  }
2542
- return;
2543
- }
2544
- if (isDocMetaFrame(frame)) {
2545
- // `_setName` de-dupes, so a re-emit of the current name is a no-op.
2546
- args.doc._setName(frame.name);
2547
- return;
2548
- }
2549
- if (isDocDriveStatusFrame(frame)) {
2550
- // The signal de-dupes a no-op re-emit, so forwarding every frame is safe.
2551
- args.doc._emitDriveStatus(toDriveStatus(frame.drive));
2552
- return;
2553
- }
2554
- if (isDocOpsDroppedFrame(frame)) {
2555
- // Prompt durability re-check: the discard sealed a new snapshot, so
2556
- // re-fetch the moved frontier and run the handshake now instead of at
2557
- // the author's next reconnect. The handshake self-selects: the author
2558
- // finds local ahead and resync-pushes under a new seq, others no-op.
2559
- // Covered by the `doc_init` handshake / periodic re-verify if missed.
2560
- void reverifyDurability();
2561
- return;
2562
- }
2563
- // Unknown frame: ignore, so server-side schema growth doesn't break older
2564
- // SDK builds.
2565
- });
4291
+ if (isOpAckFrame(frame)) {
4292
+ // Evidence the connection is working through its queue, which is what
4293
+ // the resend timers read to tell a slow ack from a silent server.
4294
+ lastAckAt = Date.now();
4295
+ args.doc.resolveOutstandingOp(frame.client_op_id);
4296
+ args.setCurrentSeq(frame.seq);
4297
+ args.advanceCoveredSeq(frame.seq);
4298
+ // An ack for a local op sits above any remote op this client never
4299
+ // received, so the user's own next edit is a second chance to notice.
4300
+ scheduleGapCheck();
4301
+ forgetOp(frame.client_op_id);
4302
+ // The server is durably accepting writes again, so the shed budget
4303
+ // starts over. A round already scheduled still runs: the ops it
4304
+ // re-asserts are the ones this ack did not cover.
4305
+ unavailableRounds = 0;
4306
+ return;
4307
+ }
4308
+ if (isServerUnavailableFrame(frame)) {
4309
+ onServerUnavailable();
4310
+ return;
4311
+ }
4312
+ if (isOpRejectedFrame(frame)) {
4313
+ // Terminal, not transient: cancel this op's resend (retrying would only
4314
+ // draw the same rejection forever and pin `savingCount`) and fail it on
4315
+ // the Document so the app hears "could not be saved" instead of a silent
4316
+ // wedge. Scoped to this one op; the topic subscription stays live.
4317
+ forgetOp(frame.client_op_id);
4318
+ // The flip is the one moment the refusal is worth reporting: every op
4319
+ // in flight draws the same sentence, and a bulk write has as many of
4320
+ // those as it has unacked writes.
4321
+ const atCapacity = isCapacityRejection(frame);
4322
+ const firstCapacityRefusal = atCapacity && args.doc._markAtCapacity();
4323
+ // A refusal that names the writer rather than the op applies to
4324
+ // every op this reader will send, so it turns the whole Document
4325
+ // read-only before the failure is reported: the app's own
4326
+ // `doc.readOnly` gate then disables the editor in the same tick the
4327
+ // reader hears why, rather than letting them keep typing into a
4328
+ // surface nothing will save.
4329
+ const readOnly = readOnlyReasonForRejection(frame);
4330
+ if (readOnly !== null) args.doc._markReadOnly(readOnly);
4331
+ // NO capacity refusal is healable, whichever kind it is. The refused
4332
+ // work sits on this copy and nowhere else, and the heal is the only
4333
+ // thing that could place it: when it runs out of rounds it stops
4334
+ // silently, so a copy the handshake could not settle is one showing
4335
+ // work that a reload will not, with nothing said. Both kinds therefore
4336
+ // lock, tell and reload, which is what the server's own wording for
4337
+ // each of them already promises.
4338
+ //
4339
+ // The other refusals here stay healable: the handshake splits the
4340
+ // slice under the frame cap, which is what reaches the dependents of
4341
+ // an op that is itself too large to re-send.
4342
+ failOpTerminally(frame.client_op_id, opRejectedMessage(frame), {
4343
+ healable: !atCapacity,
4344
+ report: !atCapacity || firstCapacityRefusal,
4345
+ });
4346
+ return;
4347
+ }
4348
+ if (isDocGoneFrame(frame)) {
4349
+ // Cancel every resend timer FIRST so a doc that was saving when delete
4350
+ // landed stops re-trying ops the server will only reject as `forbidden`
4351
+ // (the subscription record was evicted at fanout). Draining before the `_markGone`
4352
+ // listeners keeps the lifecycle observably clean.
4353
+ forgetAllOps();
4354
+ clearUnavailableRecovery();
4355
+ // Stop re-verifying durability against an evicted doc: a resync push
4356
+ // would only bounce as `forbidden`, and a gap pull would only 404.
4357
+ if (reverifyTimer !== null) {
4358
+ clearInterval(reverifyTimer);
4359
+ reverifyTimer = null;
4360
+ }
4361
+ clearDivergenceHeal();
4362
+ if (gapTimer !== null) {
4363
+ clearTimeout(gapTimer);
4364
+ gapTimer = null;
4365
+ }
4366
+ args.doc._markGone(goneReasonOf(frame));
4367
+ return;
4368
+ }
4369
+ if (isDocStatusFrame(frame)) {
4370
+ const status = normalizeWorkStatus(frame.status);
4371
+ // `reload` is the server's authoritative "succeeded, content ready"
4372
+ // hint: pull the content in and emit the terminal status only AFTER the
4373
+ // import lands, so an app re-reading on `onStatus` sees content present.
4374
+ // A failure frame has no `reload`, so emit immediately and fetch
4375
+ // nothing (no racy read, no loop).
4376
+ if (frame.reload && args.reloadAfterHydration) {
4377
+ args.reloadAfterHydration(status);
4378
+ } else {
4379
+ args.doc._emitStatus(status);
4380
+ }
4381
+ return;
4382
+ }
4383
+ if (isDocMetaFrame(frame)) {
4384
+ // `_setName` de-dupes, so a re-emit of the current name is a no-op.
4385
+ args.doc._setName(frame.name);
4386
+ return;
4387
+ }
4388
+ if (isDocDriveStatusFrame(frame)) {
4389
+ // The signal de-dupes a no-op re-emit, so forwarding every frame is safe.
4390
+ args.doc._emitDriveStatus(toDriveStatus(frame.drive));
4391
+ return;
4392
+ }
4393
+ if (isDocDrivePublishFrame(frame)) {
4394
+ args.doc._emitPublishState({
4395
+ publishing: frame.publish.publishing === true,
4396
+ failed: frame.publish.failed === true,
4397
+ detail:
4398
+ typeof frame.publish.detail === 'string' ? frame.publish.detail : null,
4399
+ });
4400
+ return;
4401
+ }
4402
+ if (isDocOpsDroppedFrame(frame)) {
4403
+ // Prompt durability re-check: the discard sealed a new snapshot, so
4404
+ // re-fetch the moved frontier and run the handshake now instead of at
4405
+ // the author's next reconnect. The handshake self-selects: the author
4406
+ // finds local ahead and resync-pushes under a new seq, others no-op.
4407
+ // Covered by the `doc_init` handshake / periodic re-verify if missed.
4408
+ void reverifyDurability();
4409
+ return;
4410
+ }
4411
+ // Unknown frame: ignore, so server-side schema growth doesn't break
4412
+ // older SDK builds.
4413
+ },
4414
+ {
4415
+ // `resendAllOutstanding` on the `doc_init` that follows every
4416
+ // `subscribed` ack IS this topic's re-assertion, so the client drops an
4417
+ // op frame it cannot send now rather than buffering a second copy.
4418
+ ownsFrameRetry: true,
4419
+ onEnded: endSubscription,
4420
+ },
4421
+ );
2566
4422
 
2567
4423
  // Arm the periodic durability re-verify (the long-lived-tab backstop).
2568
4424
  // `unref` so the interval never keeps a Node host alive (no-op in the
@@ -2577,6 +4433,22 @@ function wireDocTransport(args: WireDocTransportArgs): void {
2577
4433
  // tracker so a silently-dropped server response triggers a retry.
2578
4434
  args.doc.setLocalOpHook((opBlob) => {
2579
4435
  const clientOpId = args.newClientOpId();
4436
+ // Nothing carries an op once the subscription is over, so it is failed
4437
+ // instead of tracked: an entry nothing can ack pins `savingCount` and
4438
+ // its resends reach a topic the server refused. The caller records the
4439
+ // entry AFTER this hook returns, so the failure waits a microtask for
4440
+ // there to be one to fail.
4441
+ //
4442
+ // Taken before the op is timed, because this path fails the op through
4443
+ // the Document directly rather than through `failOpTerminally`, so a
4444
+ // timing entry written here is one nothing ever drops.
4445
+ if (subscriptionEnded) {
4446
+ void Promise.resolve().then(() => {
4447
+ args.doc._failOutstandingOp(clientOpId, SUBSCRIPTION_ENDED_MESSAGE);
4448
+ });
4449
+ return clientOpId;
4450
+ }
4451
+ opStartedAt.set(clientOpId, Date.now());
2580
4452
  const opBlobB64 = bytesToBase64(opBlob);
2581
4453
  const sendFrame: {
2582
4454
  type: 'op';
@@ -2601,14 +4473,18 @@ function wireDocTransport(args: WireDocTransportArgs): void {
2601
4473
  return clientOpId;
2602
4474
  }
2603
4475
 
2604
- args.wsClient.send(sendFrame);
2605
4476
  outstandingResends.set(clientOpId, {
2606
4477
  frame: sendFrame,
2607
4478
  attempts: 1,
2608
- // Placeholder, overwritten immediately below.
4479
+ // Placeholders, overwritten by `dispatchOp` and `scheduleResend` once
4480
+ // the op is actually on the socket.
2609
4481
  timer: setTimeout(() => undefined, 0),
4482
+ armedAt: 0,
4483
+ dispatchedAt: 0,
4484
+ inFlight: 0,
4485
+ queued: false,
2610
4486
  });
2611
- scheduleResend(clientOpId);
4487
+ dispatchOp(clientOpId);
2612
4488
  return clientOpId;
2613
4489
  });
2614
4490
 
@@ -2640,32 +4516,72 @@ function wireDocTransport(args: WireDocTransportArgs): void {
2640
4516
  // The size is known here, so refuse without spending a round trip on a
2641
4517
  // blob that can only come back 400.
2642
4518
  if (opBlobExceedsDurableLimit(opBlobB64)) {
2643
- args.doc._failOutstandingOp(clientOpId, TOO_LARGE_MESSAGE);
4519
+ failOpTerminally(clientOpId, TOO_LARGE_MESSAGE);
2644
4520
  return;
2645
4521
  }
2646
4522
 
2647
4523
  if (!args.postOversizedOp) {
2648
- args.doc._failOutstandingOp(clientOpId, TOO_LARGE_MESSAGE);
4524
+ failOpTerminally(clientOpId, TOO_LARGE_MESSAGE);
2649
4525
  return;
2650
4526
  }
2651
- let result: Awaited<ReturnType<NonNullable<WireDocTransportArgs['postOversizedOp']>>>;
2652
- try {
2653
- result = await args.postOversizedOp(opBlobB64, clientOpId);
2654
- } catch (err) {
2655
- result = {
2656
- ok: false,
2657
- message: err instanceof Error ? err.message : String(err),
2658
- };
4527
+ // An oversized op is deliberately kept out of `outstandingResends` (a
4528
+ // frame past the cap must never reach the socket), so the shed recovery
4529
+ // that re-asserts the tracker cannot reach this one. It runs its own
4530
+ // round of the same policy instead, on the same budget and backoff, so a
4531
+ // capacity refusal costs an op the same nothing whichever transport
4532
+ // carried it.
4533
+ let result: OversizedOpResult | undefined;
4534
+ for (let round = 0; round <= UNAVAILABLE_MAX_ROUNDS; round += 1) {
4535
+ if (round > 0) {
4536
+ await new Promise((resolve) => {
4537
+ const timer = setTimeout(resolve, unavailableDelayMs(round));
4538
+ (timer as { unref?: () => void }).unref?.();
4539
+ });
4540
+ }
4541
+ try {
4542
+ result = await args.postOversizedOp(opBlobB64, clientOpId);
4543
+ } catch (err) {
4544
+ result = {
4545
+ ok: false,
4546
+ message: err instanceof Error ? err.message : String(err),
4547
+ // Staging the blob runs strictly before the op write, so a failure
4548
+ // there wrote nothing and the op is still the client's to send: it
4549
+ // is priced the way the write prices its own refusals. Any other
4550
+ // throw leaves it unknown whether the handler ran, and this op
4551
+ // carries no idempotency the server dedupes on, so replaying it
4552
+ // could double-apply.
4553
+ retryable:
4554
+ err instanceof OpStagingError && opStagingIsRetryable(err),
4555
+ };
4556
+ }
4557
+ if (result.ok || !result.retryable) break;
4558
+ }
4559
+ if (result === undefined) {
4560
+ opStartedAt.delete(clientOpId);
4561
+ return;
2659
4562
  }
2660
4563
  if (!result.ok) {
2661
4564
  // Terminal for this op, the same way `op_rejected` is: the local
2662
4565
  // commit stays applied but never persists, so the app hears about it
2663
- // instead of watching `savingCount` sit above zero forever.
2664
- args.doc._failOutstandingOp(clientOpId, result.message);
4566
+ // instead of watching `savingCount` sit above zero forever. A refusal
4567
+ // about the writer turns the Document read-only first, so the app's
4568
+ // own gate closes the editor as the reader is told why.
4569
+ const firstCapacityRefusal =
4570
+ result.atCapacity === true && args.doc._markAtCapacity();
4571
+ if (result.readOnly) args.doc._markReadOnly(result.readOnly);
4572
+ failOpTerminally(
4573
+ clientOpId,
4574
+ result.retryable ? UNAVAILABLE_GIVE_UP_MESSAGE : result.message,
4575
+ {
4576
+ healable: result.atCapacity !== true,
4577
+ report: result.atCapacity !== true || firstCapacityRefusal,
4578
+ },
4579
+ );
2665
4580
  return;
2666
4581
  }
2667
4582
  // Same bookkeeping an `op_ack` does: resolve the outstanding entry and
2668
4583
  // advance both cursors past the seq the server allocated.
4584
+ opStartedAt.delete(clientOpId);
2669
4585
  args.doc.resolveOutstandingOp(clientOpId);
2670
4586
  args.setCurrentSeq(result.seq);
2671
4587
  args.advanceCoveredSeq(result.seq);
@@ -2722,14 +4638,13 @@ function wireDocTransport(args: WireDocTransportArgs): void {
2722
4638
  clearInterval(reverifyTimer);
2723
4639
  reverifyTimer = null;
2724
4640
  }
4641
+ clearDivergenceHeal();
2725
4642
  if (gapTimer !== null) {
2726
4643
  clearTimeout(gapTimer);
2727
4644
  gapTimer = null;
2728
4645
  }
2729
- for (const entry of outstandingResends.values()) {
2730
- clearTimeout(entry.timer);
2731
- }
2732
- outstandingResends.clear();
4646
+ forgetAllOps();
4647
+ clearUnavailableRecovery();
2733
4648
  originalClose();
2734
4649
  };
2735
4650
  (args.doc as Document & { close: () => void }).close = () => {
@@ -2742,16 +4657,40 @@ function wireDocTransport(args: WireDocTransportArgs): void {
2742
4657
  // (its lifecycle is the parent Document's). Keying on the REQUESTED branch
2743
4658
  // (not the resolved one) keeps park and take symmetric; a default-branch
2744
4659
  // open has no requested branch and so skips the cache.
2745
- const cacheKey =
2746
- args.doc.readOnly || args.parkable === false
2747
- ? null
2748
- : parkCacheKey(args.docId, args.requestedBranchId, args.requestedStream);
4660
+ //
4661
+ // A doc with work still running skips it too: parking clears the status
4662
+ // listeners, and the SDK's own waiters on that work (the import runner
4663
+ // waits for the phase that produces the file it imports) are keyed to
4664
+ // this instance, so a reopen would hand back a Document whose wait can
4665
+ // never fire. A fresh open re-attaches everything.
4666
+ //
4667
+ // So does one the server has evicted. `takeParkedDocument` refuses such an
4668
+ // entry, so parking it buys nothing and costs everything parking keeps
4669
+ // alive: the topic subscription (which goes on delivering every op of a
4670
+ // document nobody is reading) and the LoroDoc's heap, until some later
4671
+ // open or eviction reaches that key. A move is the case that makes this
4672
+ // routine rather than terminal, because the session follows it by
4673
+ // reopening the same id, so the evicted copy is retired on every move.
4674
+ const parkable =
4675
+ !args.doc.readOnly &&
4676
+ args.parkable !== false &&
4677
+ args.doc.status?.pending !== true &&
4678
+ args.doc._goneReason === null;
4679
+ const cacheKey = parkable
4680
+ ? parkCacheKey(args.docId, args.requestedBranchId, args.requestedStream)
4681
+ : null;
2749
4682
  if (cacheKey === null) {
2750
4683
  fullTeardown();
2751
4684
  return;
2752
4685
  }
2753
4686
  args.doc._suspend();
2754
- parkDocument({ doc: args.doc, teardown: fullTeardown, key: cacheKey });
4687
+ parkDocument({
4688
+ doc: args.doc,
4689
+ teardown: fullTeardown,
4690
+ key: cacheKey,
4691
+ optionsSig: documentOptionsSig(args.documentOptions),
4692
+ identity: args.identity?.() ?? null,
4693
+ });
2755
4694
  };
2756
4695
  }
2757
4696