@brass-build/client 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (327) hide show
  1. package/AGENTS.md +142 -41
  2. package/CHANGELOG.md +93 -4
  3. package/README.md +666 -179
  4. package/dist/acting-organization.d.ts.map +1 -1
  5. package/dist/acting-organization.js +6 -11
  6. package/dist/acting-organization.js.map +1 -1
  7. package/dist/api-fetch.d.ts +8 -0
  8. package/dist/api-fetch.d.ts.map +1 -1
  9. package/dist/api-fetch.js +35 -8
  10. package/dist/api-fetch.js.map +1 -1
  11. package/dist/batched-get.d.ts +11 -0
  12. package/dist/batched-get.d.ts.map +1 -0
  13. package/dist/batched-get.js +92 -0
  14. package/dist/batched-get.js.map +1 -0
  15. package/dist/call-service.d.ts +4 -0
  16. package/dist/call-service.d.ts.map +1 -1
  17. package/dist/call-service.js +66 -3
  18. package/dist/call-service.js.map +1 -1
  19. package/dist/client.d.ts +51 -2
  20. package/dist/client.d.ts.map +1 -1
  21. package/dist/client.js +982 -124
  22. package/dist/client.js.map +1 -1
  23. package/dist/document-checkpoints.d.ts +25 -2
  24. package/dist/document-checkpoints.d.ts.map +1 -1
  25. package/dist/document-checkpoints.js +258 -61
  26. package/dist/document-checkpoints.js.map +1 -1
  27. package/dist/document-commands.d.ts.map +1 -1
  28. package/dist/document-commands.js +5 -3
  29. package/dist/document-commands.js.map +1 -1
  30. package/dist/document-contents.d.ts +10 -0
  31. package/dist/document-contents.d.ts.map +1 -1
  32. package/dist/document-contents.js +23 -1
  33. package/dist/document-contents.js.map +1 -1
  34. package/dist/document-set-subscription.d.ts +56 -0
  35. package/dist/document-set-subscription.d.ts.map +1 -0
  36. package/dist/document-set-subscription.js +240 -0
  37. package/dist/document-set-subscription.js.map +1 -0
  38. package/dist/document-signals.d.ts +6 -0
  39. package/dist/document-signals.d.ts.map +1 -1
  40. package/dist/document-signals.js +31 -1
  41. package/dist/document-signals.js.map +1 -1
  42. package/dist/document-streams.d.ts +36 -2
  43. package/dist/document-streams.d.ts.map +1 -1
  44. package/dist/document-streams.js +69 -18
  45. package/dist/document-streams.js.map +1 -1
  46. package/dist/document-thumbnail-capture.d.ts +12 -2
  47. package/dist/document-thumbnail-capture.d.ts.map +1 -1
  48. package/dist/document-thumbnail-capture.js +103 -27
  49. package/dist/document-thumbnail-capture.js.map +1 -1
  50. package/dist/documents-namespace.d.ts +92 -10
  51. package/dist/documents-namespace.d.ts.map +1 -1
  52. package/dist/documents-namespace.js +285 -39
  53. package/dist/documents-namespace.js.map +1 -1
  54. package/dist/documents.d.ts +173 -8
  55. package/dist/documents.d.ts.map +1 -1
  56. package/dist/documents.js +1778 -310
  57. package/dist/documents.js.map +1 -1
  58. package/dist/errors.d.ts +3 -3
  59. package/dist/errors.d.ts.map +1 -1
  60. package/dist/errors.js.map +1 -1
  61. package/dist/fetch-retry.d.ts +1 -0
  62. package/dist/fetch-retry.d.ts.map +1 -1
  63. package/dist/fetch-retry.js +2 -0
  64. package/dist/fetch-retry.js.map +1 -1
  65. package/dist/file-choice.d.ts +2 -0
  66. package/dist/file-choice.d.ts.map +1 -0
  67. package/dist/file-choice.js +27 -0
  68. package/dist/file-choice.js.map +1 -0
  69. package/dist/guest-link.d.ts +18 -1
  70. package/dist/guest-link.d.ts.map +1 -1
  71. package/dist/guest-link.js +329 -1
  72. package/dist/guest-link.js.map +1 -1
  73. package/dist/handle-types.d.ts +67 -5
  74. package/dist/handle-types.d.ts.map +1 -1
  75. package/dist/handles.d.ts +15 -19
  76. package/dist/handles.d.ts.map +1 -1
  77. package/dist/handles.js +231 -76
  78. package/dist/handles.js.map +1 -1
  79. package/dist/hosted-ui.d.ts +61 -0
  80. package/dist/hosted-ui.d.ts.map +1 -1
  81. package/dist/hosted-ui.js +94 -42
  82. package/dist/hosted-ui.js.map +1 -1
  83. package/dist/import-contract.d.ts +74 -0
  84. package/dist/import-contract.d.ts.map +1 -0
  85. package/dist/import-contract.js +163 -0
  86. package/dist/import-contract.js.map +1 -0
  87. package/dist/import-guest.d.ts +9 -0
  88. package/dist/import-guest.d.ts.map +1 -0
  89. package/dist/import-guest.js +155 -0
  90. package/dist/import-guest.js.map +1 -0
  91. package/dist/import-report.d.ts +17 -0
  92. package/dist/import-report.d.ts.map +1 -0
  93. package/dist/import-report.js +7 -0
  94. package/dist/import-report.js.map +1 -0
  95. package/dist/importer-wait.d.ts +2 -0
  96. package/dist/importer-wait.d.ts.map +1 -0
  97. package/dist/importer-wait.js +5 -0
  98. package/dist/importer-wait.js.map +1 -0
  99. package/dist/importer.d.ts +18 -4
  100. package/dist/importer.d.ts.map +1 -1
  101. package/dist/importer.js +322 -105
  102. package/dist/importer.js.map +1 -1
  103. package/dist/in-development.d.ts +2 -0
  104. package/dist/in-development.d.ts.map +1 -0
  105. package/dist/in-development.js +10 -0
  106. package/dist/in-development.js.map +1 -0
  107. package/dist/index.d.ts +16 -8
  108. package/dist/index.d.ts.map +1 -1
  109. package/dist/index.js +24 -8
  110. package/dist/index.js.map +1 -1
  111. package/dist/init-keys.d.ts +1 -1
  112. package/dist/init-keys.d.ts.map +1 -1
  113. package/dist/init-keys.js +6 -1
  114. package/dist/init-keys.js.map +1 -1
  115. package/dist/input-documents.d.ts +67 -0
  116. package/dist/input-documents.d.ts.map +1 -0
  117. package/dist/input-documents.js +693 -0
  118. package/dist/input-documents.js.map +1 -0
  119. package/dist/inputs-status.d.ts +80 -0
  120. package/dist/inputs-status.d.ts.map +1 -0
  121. package/dist/inputs-status.js +460 -0
  122. package/dist/inputs-status.js.map +1 -0
  123. package/dist/inputs.d.ts +2 -0
  124. package/dist/inputs.d.ts.map +1 -0
  125. package/dist/inputs.js +793 -0
  126. package/dist/inputs.js.map +1 -0
  127. package/dist/internal.d.ts +6 -1
  128. package/dist/internal.d.ts.map +1 -1
  129. package/dist/internal.js +4 -1
  130. package/dist/internal.js.map +1 -1
  131. package/dist/join-content.d.ts +33 -0
  132. package/dist/join-content.d.ts.map +1 -0
  133. package/dist/join-content.js +114 -0
  134. package/dist/join-content.js.map +1 -0
  135. package/dist/join-stamp.d.ts +3 -0
  136. package/dist/join-stamp.d.ts.map +1 -0
  137. package/dist/join-stamp.js +12 -0
  138. package/dist/join-stamp.js.map +1 -0
  139. package/dist/manifest-drift.d.ts +9 -0
  140. package/dist/manifest-drift.d.ts.map +1 -0
  141. package/dist/manifest-drift.js +46 -0
  142. package/dist/manifest-drift.js.map +1 -0
  143. package/dist/open-document.d.ts +109 -2
  144. package/dist/open-document.d.ts.map +1 -1
  145. package/dist/open-document.js +1822 -811
  146. package/dist/open-document.js.map +1 -1
  147. package/dist/page-restore.d.ts +12 -0
  148. package/dist/page-restore.d.ts.map +1 -0
  149. package/dist/page-restore.js +23 -0
  150. package/dist/page-restore.js.map +1 -0
  151. package/dist/placement.d.ts +13 -0
  152. package/dist/placement.d.ts.map +1 -0
  153. package/dist/placement.js +34 -0
  154. package/dist/placement.js.map +1 -0
  155. package/dist/presence.d.ts +1 -0
  156. package/dist/presence.d.ts.map +1 -1
  157. package/dist/presence.js +16 -4
  158. package/dist/presence.js.map +1 -1
  159. package/dist/read-only-document.d.ts +87 -0
  160. package/dist/read-only-document.d.ts.map +1 -0
  161. package/dist/read-only-document.js +4 -0
  162. package/dist/read-only-document.js.map +1 -0
  163. package/dist/record-paging.d.ts +63 -6
  164. package/dist/record-paging.d.ts.map +1 -1
  165. package/dist/record-paging.js +122 -29
  166. package/dist/record-paging.js.map +1 -1
  167. package/dist/references.d.ts +40 -0
  168. package/dist/references.d.ts.map +1 -0
  169. package/dist/references.js +135 -0
  170. package/dist/references.js.map +1 -0
  171. package/dist/request-lane.d.ts +5 -0
  172. package/dist/request-lane.d.ts.map +1 -0
  173. package/dist/request-lane.js +48 -0
  174. package/dist/request-lane.js.map +1 -0
  175. package/dist/sealed-arrow.d.ts +170 -0
  176. package/dist/sealed-arrow.d.ts.map +1 -0
  177. package/dist/sealed-arrow.js +769 -0
  178. package/dist/sealed-arrow.js.map +1 -0
  179. package/dist/sealed-cache.d.ts +21 -0
  180. package/dist/sealed-cache.d.ts.map +1 -0
  181. package/dist/sealed-cache.js +247 -0
  182. package/dist/sealed-cache.js.map +1 -0
  183. package/dist/sealed-encode.d.ts +87 -0
  184. package/dist/sealed-encode.d.ts.map +1 -0
  185. package/dist/sealed-encode.js +876 -0
  186. package/dist/sealed-encode.js.map +1 -0
  187. package/dist/sealed-handles.d.ts +45 -0
  188. package/dist/sealed-handles.d.ts.map +1 -0
  189. package/dist/sealed-handles.js +408 -0
  190. package/dist/sealed-handles.js.map +1 -0
  191. package/dist/sealed-store.d.ts +242 -0
  192. package/dist/sealed-store.d.ts.map +1 -0
  193. package/dist/sealed-store.js +1155 -0
  194. package/dist/sealed-store.js.map +1 -0
  195. package/dist/serializer.d.ts +6 -15
  196. package/dist/serializer.d.ts.map +1 -1
  197. package/dist/session-toolbar.d.ts +20 -5
  198. package/dist/session-toolbar.d.ts.map +1 -1
  199. package/dist/session-toolbar.js +419 -67
  200. package/dist/session-toolbar.js.map +1 -1
  201. package/dist/session.d.ts +38 -1
  202. package/dist/session.d.ts.map +1 -1
  203. package/dist/session.js +562 -79
  204. package/dist/session.js.map +1 -1
  205. package/dist/sha256.d.ts +3 -0
  206. package/dist/sha256.d.ts.map +1 -0
  207. package/dist/sha256.js +74 -0
  208. package/dist/sha256.js.map +1 -0
  209. package/dist/stable-handles.d.ts +19 -0
  210. package/dist/stable-handles.d.ts.map +1 -0
  211. package/dist/stable-handles.js +193 -0
  212. package/dist/stable-handles.js.map +1 -0
  213. package/dist/staged-seal.d.ts +2 -0
  214. package/dist/staged-seal.d.ts.map +1 -0
  215. package/dist/staged-seal.js +76 -0
  216. package/dist/staged-seal.js.map +1 -0
  217. package/dist/subscriber-delivery.d.ts +8 -0
  218. package/dist/subscriber-delivery.d.ts.map +1 -0
  219. package/dist/subscriber-delivery.js +31 -0
  220. package/dist/subscriber-delivery.js.map +1 -0
  221. package/dist/tail-op-import.d.ts +10 -0
  222. package/dist/tail-op-import.d.ts.map +1 -0
  223. package/dist/tail-op-import.js +33 -0
  224. package/dist/tail-op-import.js.map +1 -0
  225. package/dist/topic-lifecycle.d.ts +6 -0
  226. package/dist/topic-lifecycle.d.ts.map +1 -1
  227. package/dist/topic-lifecycle.js +32 -2
  228. package/dist/topic-lifecycle.js.map +1 -1
  229. package/dist/trial-replace-dialog.d.ts +10 -0
  230. package/dist/trial-replace-dialog.d.ts.map +1 -0
  231. package/dist/trial-replace-dialog.js +153 -0
  232. package/dist/trial-replace-dialog.js.map +1 -0
  233. package/dist/types.d.ts +8 -1
  234. package/dist/types.d.ts.map +1 -1
  235. package/dist/undo.d.ts +60 -5
  236. package/dist/undo.d.ts.map +1 -1
  237. package/dist/undo.js +344 -75
  238. package/dist/undo.js.map +1 -1
  239. package/dist/version-bar.d.ts +1 -0
  240. package/dist/version-bar.d.ts.map +1 -1
  241. package/dist/version-bar.js +2 -1
  242. package/dist/version-bar.js.map +1 -1
  243. package/dist/version.d.ts +2 -2
  244. package/dist/version.js +1 -1
  245. package/dist/ws-client.d.ts +21 -0
  246. package/dist/ws-client.d.ts.map +1 -1
  247. package/dist/ws-client.js +192 -8
  248. package/dist/ws-client.js.map +1 -1
  249. package/dist/ws-frame-limit.d.ts +1 -0
  250. package/dist/ws-frame-limit.d.ts.map +1 -1
  251. package/dist/ws-frame-limit.js.map +1 -1
  252. package/docs/app-manifest.md +121 -17
  253. package/docs/custom-chrome.md +24 -16
  254. package/docs/hosted-ui-advanced.md +38 -6
  255. package/docs/imported-documents.md +104 -51
  256. package/docs/presence.md +16 -4
  257. package/docs/schemas.md +8 -5
  258. package/docs/services.md +6 -6
  259. package/docs/streams.md +139 -150
  260. package/docs/text-fields.md +26 -19
  261. package/package.json +5 -2
  262. package/src/acting-organization.ts +6 -12
  263. package/src/api-fetch.ts +53 -3
  264. package/src/auth-flow.ts +1 -1
  265. package/src/batched-get.ts +117 -0
  266. package/src/call-service.ts +78 -3
  267. package/src/client.ts +1092 -121
  268. package/src/document-checkpoints.ts +307 -65
  269. package/src/document-commands.ts +5 -5
  270. package/src/document-contents.ts +27 -1
  271. package/src/document-set-subscription.ts +274 -0
  272. package/src/document-signals.ts +47 -5
  273. package/src/document-streams.ts +85 -16
  274. package/src/document-thumbnail-capture.ts +118 -32
  275. package/src/documents-namespace.ts +539 -84
  276. package/src/documents.ts +2017 -348
  277. package/src/errors.ts +17 -6
  278. package/src/fetch-retry.ts +5 -0
  279. package/src/file-choice.ts +26 -0
  280. package/src/guest-link.ts +362 -1
  281. package/src/handle-types.ts +73 -47
  282. package/src/handles.ts +253 -83
  283. package/src/hosted-ui.ts +211 -50
  284. package/src/import-contract.ts +227 -0
  285. package/src/import-guest.ts +193 -0
  286. package/src/import-report.ts +24 -0
  287. package/src/importer-wait.ts +4 -0
  288. package/src/importer.ts +386 -131
  289. package/src/in-development.ts +10 -0
  290. package/src/index.ts +76 -16
  291. package/src/init-keys.ts +6 -1
  292. package/src/input-documents.ts +819 -0
  293. package/src/inputs-status.ts +598 -0
  294. package/src/inputs.ts +1090 -0
  295. package/src/internal.ts +7 -0
  296. package/src/join-content.ts +135 -0
  297. package/src/join-stamp.ts +12 -0
  298. package/src/manifest-drift.ts +54 -0
  299. package/src/open-document.ts +2346 -930
  300. package/src/page-restore.ts +38 -0
  301. package/src/placement.ts +54 -0
  302. package/src/presence.ts +21 -5
  303. package/src/read-only-document.ts +133 -0
  304. package/src/record-paging.ts +147 -25
  305. package/src/references.ts +207 -0
  306. package/src/request-lane.ts +59 -0
  307. package/src/sealed-arrow.ts +843 -0
  308. package/src/sealed-cache.ts +267 -0
  309. package/src/sealed-encode.ts +1015 -0
  310. package/src/sealed-handles.ts +486 -0
  311. package/src/sealed-store.ts +1330 -0
  312. package/src/serializer.ts +8 -20
  313. package/src/session-toolbar.ts +463 -72
  314. package/src/session.ts +740 -99
  315. package/src/sha256.ts +65 -0
  316. package/src/stable-handles.ts +235 -0
  317. package/src/staged-seal.ts +90 -0
  318. package/src/subscriber-delivery.ts +37 -0
  319. package/src/tail-op-import.ts +30 -0
  320. package/src/topic-lifecycle.ts +38 -2
  321. package/src/trial-replace-dialog.ts +163 -0
  322. package/src/types.ts +6 -6
  323. package/src/undo.ts +386 -73
  324. package/src/version-bar.ts +5 -1
  325. package/src/version.ts +1 -1
  326. package/src/ws-client.ts +208 -16
  327. package/src/ws-frame-limit.ts +1 -0
@@ -3,23 +3,41 @@
3
3
  // burst of local edits settles. Both also seal the undo step, unifying
4
4
  // "checkpoint" and "undo boundary": a deliberate save, an auto-seal on
5
5
  // idle, and a modeler's pointerUp all produce the same Ctrl-Z boundary.
6
+ //
7
+ // The undo seal happens on every call. One writer serves both paths and
8
+ // writes a marker per call; calls made while a write is in flight share the
9
+ // next one, anchored at the state when it writes, which holds every call it
10
+ // covers. So an app can call `createCheckpoint` at the end of every user
11
+ // action without waiting on the network or handling a refusal.
6
12
 
7
13
  import type { DocumentCheckpoint } from './documents-namespace.js';
14
+ import type { CapturedFrame } from './document-thumbnail-capture.js';
8
15
 
9
- export type CheckpointCreator = () => Promise<DocumentCheckpoint>;
16
+ // Where a marker is written: the server seq it marks.
17
+ export interface CheckpointAnchor {
18
+ version: number;
19
+ }
20
+
21
+ export type CheckpointCreator = (
22
+ anchor: CheckpointAnchor,
23
+ ) => Promise<DocumentCheckpoint>;
10
24
 
11
25
  // Default auto-checkpoint debounce: sealed this long after the last
12
26
  // local edit, so every app gets a version-history timeline without
13
- // writing checkpoint code. 60s balances "user reaches a natural pause"
14
- // against snapshot-blob churn. Override or disable (0) via
27
+ // writing checkpoint code. Override or disable (0) via
15
28
  // `doc.setAutoCheckpointIntervalMs(...)`.
16
29
  const AUTO_CHECKPOINT_DEFAULT_MS = 60_000;
17
30
 
31
+ // Retry delay after a failed write, doubling per consecutive failure up to
32
+ // the ceiling. Calls made meanwhile join the retried write.
33
+ export const RETRY_BASE_MS = 5_000;
34
+ const RETRY_CEILING_MS = 10 * 60_000;
35
+
18
36
  export interface CheckpointSchedulerHost {
19
37
  // Server identity; `null` on offline / test docs.
20
38
  docId(): string | null;
21
39
  // True when the doc is live (not closed / suspended). A manual
22
- // `createCheckpoint` throws "Document is closed" otherwise.
40
+ // `createCheckpoint` rejects with "Document is closed" otherwise.
23
41
  isAttached(): boolean;
24
42
  // True when an auto-checkpoint may fire: live AND at head (a
25
43
  // historical checkout must not seal markers).
@@ -29,21 +47,64 @@ export interface CheckpointSchedulerHost {
29
47
  currentSeq(): number | null;
30
48
  // Seal the current undo step. Safe to call when undo is absent.
31
49
  sealUndoStep(): void;
50
+ // True while an op this client committed locally is not acked yet.
51
+ hasUnsavedOps(): boolean;
32
52
  // Resolve `true` once every op this client has committed locally has been
33
53
  // acked; `false` when the document went, or the acks stopped arriving.
34
54
  whenLocalOpsSaved(): Promise<boolean>;
55
+ // Take a frame of the registered canvas for a marker; null when the
56
+ // document has no canvas. Upload one as the picture of `version`.
57
+ captureFrame(): Promise<CapturedFrame | null>;
58
+ uploadFrame(frame: CapturedFrame, version: number): void;
59
+ }
60
+
61
+ interface Waiter {
62
+ resolve: (checkpoint: DocumentCheckpoint) => void;
63
+ reject: (err: unknown) => void;
64
+ }
65
+
66
+ // A refusal the next attempt may not meet: a network failure, a timeout, a
67
+ // rate limit, or a server fault. Anything else (no edit access, a missing
68
+ // document) answers the same way however often it is asked.
69
+ function isRetryable(err: unknown): boolean {
70
+ const status = (err as { status?: unknown } | null)?.status;
71
+ if (typeof status !== 'number') return true;
72
+ return status === 408 || status === 429 || status >= 500;
73
+ }
74
+
75
+ function rejectAll(waiters: Waiter[], err: unknown): void {
76
+ for (const w of waiters) w.reject(err);
77
+ }
78
+
79
+ function resolveAll(waiters: Waiter[], checkpoint: DocumentCheckpoint): void {
80
+ for (const w of waiters) w.resolve(checkpoint);
35
81
  }
36
82
 
37
83
  export class CheckpointScheduler {
38
84
  // Injected checkpoint-create hook (the Document owns no HTTP client).
39
- // `null` on offline / test paths; a manual `create` then throws.
85
+ // `null` on offline / test paths; a manual `create` then rejects.
40
86
  private _creator: CheckpointCreator | null = null;
41
- // Debounce timer + bookkeeping. `_autoIntervalMs === 0` disables it;
42
- // `_lastSeq` skips a fire that would clobber a manual checkpoint at
43
- // the same seq.
87
+ // Debounce timer. `_autoIntervalMs === 0` disables it.
44
88
  private _autoTimer: ReturnType<typeof setTimeout> | null = null;
45
89
  private _autoIntervalMs: number = AUTO_CHECKPOINT_DEFAULT_MS;
46
- private _lastSeq: number | null = null;
90
+ // Calls waiting for the next marker, and whether the debounce wants one.
91
+ private _waiters: Waiter[] = [];
92
+ private _autoWanted = false;
93
+ // The frame the newest request took, which becomes the picture of the
94
+ // marker that covers it. Taken with the batch, like the waiters.
95
+ private _frame: Promise<CapturedFrame | null> | null = null;
96
+ // The newest marker written, so a write at the same seq resolves to it
97
+ // instead of writing again. The server Put is last-write-wins, so a
98
+ // second write there would only replace one entry with an identical one.
99
+ private _last: DocumentCheckpoint | null = null;
100
+ // Earliest time the next write may start, and the timer that starts it.
101
+ private _nextWriteAt = 0;
102
+ private _flushTimer: ReturnType<typeof setTimeout> | null = null;
103
+ private _flushing = false;
104
+ private _failures = 0;
105
+ // Bumped when the Document detaches, so a write in progress across a close
106
+ // stops touching the queue the close already settled.
107
+ private _epoch = 0;
47
108
 
48
109
  constructor(private readonly host: CheckpointSchedulerHost) {}
49
110
 
@@ -51,49 +112,44 @@ export class CheckpointScheduler {
51
112
  this._creator = creator;
52
113
  }
53
114
 
54
- // Seal a checkpoint anchored to `host.currentSeq()`.
55
- async create(): Promise<DocumentCheckpoint> {
115
+ // Seal the undo step now and resolve to the marker that covers this call.
116
+ create(): Promise<DocumentCheckpoint> {
56
117
  if (!this.host.isAttached()) {
57
- throw new Error('Document is closed');
118
+ return Promise.reject(new Error('Document is closed'));
58
119
  }
59
120
  if (this.host.docId() === null) {
60
- throw new Error('createCheckpoint: Document has no server identity');
121
+ return Promise.reject(
122
+ new Error('createCheckpoint: Document has no server identity'),
123
+ );
61
124
  }
62
125
  if (this._creator === null) {
63
- throw new Error(
64
- 'createCheckpoint: no creator attached. Opened via the SDK factory?',
126
+ return Promise.reject(
127
+ new Error(
128
+ 'createCheckpoint: no creator attached. Opened via the SDK factory?',
129
+ ),
65
130
  );
66
131
  }
67
- // Seal BEFORE the round-trip: the seal is synchronous, so mutates
68
- // arriving during the await start a fresh undo step rather than
69
- // folding into the pre-checkpoint one.
132
+ // Synchronous, so a mutate after this call starts the next undo step
133
+ // however long the marker takes.
70
134
  this.host.sealUndoStep();
71
- // The anchor is a SERVER seq, and an op the app just committed holds one
72
- // only once it is acked, so a checkpoint sealed with writes outstanding
73
- // sits below the work the caller is saving: an app that writes and then
74
- // saves a version (the file importer's one-undo-step seal is this shape)
75
- // marks a point its own edits are missing from, and restoring it loses
76
- // them. Waiting for the acks is what makes the marker describe the state
77
- // the caller has.
78
- const saved = await this.host.whenLocalOpsSaved();
79
- if (!this.host.isAttached()) {
80
- throw new Error('Document is closed');
81
- }
82
- // Sealing anyway is the failure this wait exists to prevent, and it fails
83
- // quietly: the marker looks like a saved version and restores to a state
84
- // missing the work the caller asked to save. Refusing hands the app a
85
- // state it can act on instead.
86
- if (!saved) {
87
- throw new Error(
88
- 'createCheckpoint: the document has changes that are not saved yet',
89
- );
135
+ // Nothing since the newest marker: it already restores this state.
136
+ const last = this._last;
137
+ if (
138
+ last !== null &&
139
+ !this.host.hasUnsavedOps() &&
140
+ last.version === this.host.currentSeq()
141
+ ) {
142
+ return Promise.resolve(last);
90
143
  }
91
- const result = await this._creator();
92
- // Record the seq so a pending auto-checkpoint at the same version
93
- // skips its fire. The server Put is unconditional (last-write-wins),
94
- // so without this an idle period right after a manual save would
95
- // replace the manual entry with an auto one.
96
- this._lastSeq = result.version;
144
+ this._frame = this.host.captureFrame();
145
+ const result = new Promise<DocumentCheckpoint>((resolve, reject) => {
146
+ this._waiters.push({ resolve, reject });
147
+ });
148
+ // Apps call this at the end of every action without awaiting it. A
149
+ // rejection still reaches a caller that awaits; one that does not is not
150
+ // reported as unhandled.
151
+ result.catch(() => {});
152
+ this._schedule();
97
153
  return result;
98
154
  }
99
155
 
@@ -124,11 +180,11 @@ export class CheckpointScheduler {
124
180
  }
125
181
  this._autoTimer = setTimeout(() => {
126
182
  this._autoTimer = null;
127
- void this._fireAuto();
183
+ this._fireAuto();
128
184
  }, this._autoIntervalMs);
129
185
  }
130
186
 
131
- // Cancel a pending auto-checkpoint (suspend / close).
187
+ // Cancel a pending auto-checkpoint.
132
188
  cancelAuto(): void {
133
189
  if (this._autoTimer !== null) {
134
190
  clearTimeout(this._autoTimer);
@@ -136,30 +192,216 @@ export class CheckpointScheduler {
136
192
  }
137
193
  }
138
194
 
139
- private async _fireAuto(): Promise<void> {
195
+ // The Document is closing or being parked. Calls still waiting get one
196
+ // marker now, outside the spacing, when every local op is acked; otherwise
197
+ // they reject, since no marker can cover writes the server does not have.
198
+ // Must run while the host still answers `currentSeq`.
199
+ detach(): void {
200
+ this.cancelAuto();
201
+ if (this._flushTimer !== null) {
202
+ clearTimeout(this._flushTimer);
203
+ this._flushTimer = null;
204
+ }
205
+ this._epoch += 1;
206
+ this._flushing = false;
207
+ this._autoWanted = false;
208
+ const batch = this._waiters;
209
+ this._waiters = [];
210
+ const frame = this._frame;
211
+ this._frame = null;
212
+ if (batch.length === 0) return;
213
+ const seq = this.host.currentSeq();
214
+ const creator = this._creator;
215
+ if (creator === null || seq === null || this.host.hasUnsavedOps()) {
216
+ rejectAll(batch, new Error('Document is closed'));
217
+ return;
218
+ }
219
+ if (this._last !== null && this._last.version === seq) {
220
+ resolveAll(batch, this._last);
221
+ return;
222
+ }
223
+ creator({ version: seq }).then(
224
+ (checkpoint) => {
225
+ this._last = checkpoint;
226
+ this._picture(frame, checkpoint.version);
227
+ resolveAll(batch, checkpoint);
228
+ },
229
+ (err: unknown) => rejectAll(batch, err),
230
+ );
231
+ }
232
+
233
+ private _fireAuto(): void {
140
234
  if (!this.host.isAutoAllowed()) return;
141
235
  if (this._creator === null) return;
142
- // Same anchor rule as `create`. The debounce usually outlasts the acks,
143
- // so this ordinarily resolves at once; what it covers is a burst whose
144
- // last ops are still draining when the timer fires. Best-effort here, so
145
- // a seal that cannot confirm the writes skips and the next local edit
146
- // reschedules it.
147
- if (!(await this.host.whenLocalOpsSaved())) return;
148
- if (!this.host.isAutoAllowed()) return;
149
- const seq = this.host.currentSeq();
150
- if (seq === null) return;
151
- // Already checkpointed at this exact version (manual seal a moment
152
- // ago).
153
- if (this._lastSeq === seq) return;
154
- // Seal undo before the write so the user gets the Ctrl-Z boundary at
155
- // their idle point even if the server write below fails.
236
+ // Seal undo now so the user gets the Ctrl-Z boundary at their idle
237
+ // point whether or not a marker is written. An edit to another stream
238
+ // of the document leaves `currentSeq` where it was, so the check below
239
+ // can skip the marker after such an edit, and the step still seals.
156
240
  this.host.sealUndoStep();
241
+ // Already marked at this exact version with nothing local outstanding:
242
+ // the newest marker already restores this state.
243
+ if (
244
+ !this.host.hasUnsavedOps() &&
245
+ this._last !== null &&
246
+ this._last.version === this.host.currentSeq()
247
+ ) {
248
+ return;
249
+ }
250
+ this._frame = this.host.captureFrame();
251
+ this._autoWanted = true;
252
+ this._schedule();
253
+ }
254
+
255
+ private _wanted(): boolean {
256
+ return this._waiters.length > 0 || this._autoWanted;
257
+ }
258
+
259
+ private _schedule(): void {
260
+ if (this._flushing || this._flushTimer !== null) return;
261
+ if (!this._wanted()) return;
262
+ const wait = this._nextWriteAt - Date.now();
263
+ if (wait <= 0) {
264
+ // Calls made in the same task still join: the write takes its batch
265
+ // only after awaiting the acks.
266
+ void this._flush();
267
+ return;
268
+ }
269
+ this._flushTimer = setTimeout(() => {
270
+ this._flushTimer = null;
271
+ void this._flush();
272
+ }, wait);
273
+ }
274
+
275
+ private _noteFailure(): void {
276
+ this._failures += 1;
277
+ const delay = Math.min(
278
+ RETRY_BASE_MS * 2 ** (this._failures - 1),
279
+ RETRY_CEILING_MS,
280
+ );
281
+ this._nextWriteAt = Date.now() + delay;
282
+ }
283
+
284
+ private async _flush(): Promise<void> {
285
+ if (!this._wanted()) return;
286
+ const epoch = this._epoch;
287
+ this._flushing = true;
157
288
  try {
158
- const result = await this._creator();
159
- this._lastSeq = result.version;
160
- } catch {
161
- // Best-effort: a rejection (rate limit, no write access, network)
162
- // is swallowed; the next local edit reschedules.
289
+ await this._write(epoch);
290
+ } finally {
291
+ if (epoch === this._epoch) {
292
+ this._flushing = false;
293
+ this._schedule();
294
+ }
163
295
  }
164
296
  }
297
+
298
+ private async _write(epoch: number): Promise<void> {
299
+ // The anchor is a SERVER seq, and an op the app just committed holds one
300
+ // only once it is acked, so a marker written with writes outstanding
301
+ // would sit below the work the calls it covers are saving, and restoring
302
+ // it would lose them. Wait for the acks, then confirm synchronously that
303
+ // nothing was committed between the wait ending and the anchor being
304
+ // read.
305
+ for (;;) {
306
+ const saved = await this.host.whenLocalOpsSaved();
307
+ if (epoch !== this._epoch) return;
308
+ if (!saved) {
309
+ // Writing anyway would produce a marker that looks like a saved
310
+ // version and restores to a state missing the work. The calls it
311
+ // would have covered are told instead; the debounce retries on the
312
+ // next local edit.
313
+ const batch = this._waiters;
314
+ this._waiters = [];
315
+ this._autoWanted = false;
316
+ this._frame = null;
317
+ this._noteFailure();
318
+ rejectAll(
319
+ batch,
320
+ new Error(
321
+ 'createCheckpoint: the document has changes that are not saved yet',
322
+ ),
323
+ );
324
+ return;
325
+ }
326
+ if (!this.host.hasUnsavedOps()) break;
327
+ }
328
+ const seq = this.host.currentSeq();
329
+ const creator = this._creator;
330
+ if (creator === null || seq === null) {
331
+ const batch = this._waiters;
332
+ this._waiters = [];
333
+ this._autoWanted = false;
334
+ this._frame = null;
335
+ rejectAll(
336
+ batch,
337
+ new Error(
338
+ creator === null
339
+ ? 'createCheckpoint: no creator attached'
340
+ : 'createCheckpoint: the document has no resolved version yet',
341
+ ),
342
+ );
343
+ return;
344
+ }
345
+ if (this._autoWanted && this._waiters.length === 0) {
346
+ // An auto marker alone is dropped once the document has moved into a
347
+ // history checkout.
348
+ if (!this.host.isAutoAllowed()) {
349
+ this._autoWanted = false;
350
+ return;
351
+ }
352
+ }
353
+ // Every call queued up to this point is covered by a marker at `seq`;
354
+ // a call after it waits for the next one.
355
+ const batch = this._waiters;
356
+ const autoOnly = batch.length === 0;
357
+ this._waiters = [];
358
+ this._autoWanted = false;
359
+ const frame = this._frame;
360
+ this._frame = null;
361
+ if (this._last !== null && this._last.version === seq) {
362
+ resolveAll(batch, this._last);
363
+ return;
364
+ }
365
+ let checkpoint: DocumentCheckpoint;
366
+ try {
367
+ checkpoint = await creator({ version: seq });
368
+ } catch (err) {
369
+ if (epoch !== this._epoch) {
370
+ rejectAll(batch, new Error('Document is closed'));
371
+ return;
372
+ }
373
+ this._noteFailure();
374
+ // An auto marker is best-effort, and the next local edit asks again.
375
+ if (autoOnly) return;
376
+ if (isRetryable(err)) {
377
+ // Still owed a marker: the next write covers these calls too, and
378
+ // takes this frame unless a newer call has taken one since.
379
+ this._waiters = batch.concat(this._waiters);
380
+ this._frame ??= frame;
381
+ } else {
382
+ rejectAll(batch, err);
383
+ }
384
+ return;
385
+ }
386
+ this._failures = 0;
387
+ if (this._last === null || checkpoint.version >= this._last.version) {
388
+ this._last = checkpoint;
389
+ }
390
+ this._picture(frame, checkpoint.version);
391
+ resolveAll(batch, checkpoint);
392
+ }
393
+
394
+ // Upload the frame the newest covered call took as the marker's picture,
395
+ // under the marker's own version, so the history pairs each entry with a
396
+ // picture of exactly that state. Best-effort: a marker without one keeps
397
+ // the picture the debounced capture leaves.
398
+ private _picture(
399
+ frame: Promise<CapturedFrame | null> | null,
400
+ version: number,
401
+ ): void {
402
+ if (frame === null) return;
403
+ void frame.then((taken) => {
404
+ if (taken !== null) this.host.uploadFrame(taken, version);
405
+ });
406
+ }
165
407
  }
@@ -52,8 +52,7 @@ export interface HostedUiProvider {
52
52
  ) => Promise<{ branch_id: string } | null>;
53
53
  // Open the hosted drive-divergence resolution surface
54
54
  // (`Document.resolveDrive()` routes through this). Resolves with the
55
- // outcome (a fresh mirror doc id, or `null` newDocId for an in-place
56
- // publish-over), or `null` when dismissed.
55
+ // outcome, or `null` when dismissed.
57
56
  driveResolve?: (docId: string) => Promise<DriveResolveResult | null>;
58
57
  // Open the hosted acquire-organization surface for a caller with zero
59
58
  // organizations; resolves with the organization they created or
@@ -385,7 +384,6 @@ export class CommandMenu {
385
384
  private _builtins(): readonly Command[] {
386
385
  if (this._builtinCommands !== null) return this._builtinCommands;
387
386
  const hasHostedUi = (): boolean => this._hostedUi !== null;
388
- const editable = (): boolean => !this.host.readOnly() && this.host.isLive();
389
387
  const list: Command[] = [
390
388
  this._buildCommand(
391
389
  'history',
@@ -482,7 +480,9 @@ export class CommandMenu {
482
480
  DOC_COMMAND_LABELS.undo,
483
481
  'edit',
484
482
  DOC_COMMAND_SURFACES.undo,
485
- () => editable() && this.host.canUndo(),
483
+ // `canUndo` carries the read-only gate per stream: the document can
484
+ // be read-only while a stream of it takes writes and holds the step.
485
+ () => this.host.isLive() && this.host.canUndo(),
486
486
  () => {
487
487
  this.host.undo();
488
488
  },
@@ -492,7 +492,7 @@ export class CommandMenu {
492
492
  DOC_COMMAND_LABELS.redo,
493
493
  'edit',
494
494
  DOC_COMMAND_SURFACES.redo,
495
- () => editable() && this.host.canRedo(),
495
+ () => this.host.isLive() && this.host.canRedo(),
496
496
  () => {
497
497
  this.host.redo();
498
498
  },
@@ -26,6 +26,9 @@
26
26
  // and not `ensureWasmReady`: its caller holds the bytes already, which means
27
27
  // something upstream has opened a document and readied the runtime.
28
28
  import { LoroDoc } from './wasm-runtime.js';
29
+ import type { JoinContent } from './join-content.js';
30
+ import { isReservedRootName } from './references.js';
31
+ import { importTailOp } from './tail-op-import.js';
29
32
  import {
30
33
  META_KEY,
31
34
  flattenPagedLookup,
@@ -53,10 +56,31 @@ export function readDocumentContents(
53
56
  ): DocumentContents {
54
57
  const doc = new LoroDoc();
55
58
  if (snapshot !== null) doc.import(snapshot);
56
- for (const op of ops) doc.import(op);
59
+ for (const op of ops) importTailOp(doc, op);
57
60
  return readRoots(doc);
58
61
  }
59
62
 
63
+ /**
64
+ * Read every root of a join's decoded content.
65
+ *
66
+ * Frozen content is read as it stands, with no CRDT engine: it carries no tail
67
+ * (the conversion that would give it one is the platform's). CRDT content and
68
+ * a document with no snapshot are materialised with `ops`, as
69
+ * `readDocumentContents` does.
70
+ */
71
+ export function readJoinContents(
72
+ content: JoinContent,
73
+ ops: readonly Uint8Array[],
74
+ ): DocumentContents {
75
+ if (content.kind === 'frozen') {
76
+ if (ops.length > 0) {
77
+ throw new Error('Frozen content arrived with changes above it, so it is not the whole document.');
78
+ }
79
+ return content.content.toJSON();
80
+ }
81
+ return readDocumentContents(content.kind === 'crdt' ? content.bytes : null, ops);
82
+ }
83
+
60
84
  // A root container id is `cid:root-<name>:<Type>`; the name arrives as the
61
85
  // key, so only the type suffix is read.
62
86
  function containerType(containerId: string): string {
@@ -68,6 +92,8 @@ function readRoots(doc: LoroDoc): DocumentContents {
68
92
  // Root ids only, so the enumeration converts nothing: each root below
69
93
  // decides its own read.
70
94
  for (const [key, containerId] of Object.entries(doc.getShallowValue())) {
95
+ // The document's references and read marks are not content.
96
+ if (isReservedRootName(key)) continue;
71
97
  switch (containerType(String(containerId))) {
72
98
  case 'Map':
73
99
  contents[key] = readMapRoot(doc, key);