@brass-build/client 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (323) hide show
  1. package/AGENTS.md +124 -45
  2. package/CHANGELOG.md +80 -4
  3. package/README.md +666 -179
  4. package/dist/api-fetch.d.ts +8 -0
  5. package/dist/api-fetch.d.ts.map +1 -1
  6. package/dist/api-fetch.js +35 -8
  7. package/dist/api-fetch.js.map +1 -1
  8. package/dist/batched-get.d.ts +11 -0
  9. package/dist/batched-get.d.ts.map +1 -0
  10. package/dist/batched-get.js +92 -0
  11. package/dist/batched-get.js.map +1 -0
  12. package/dist/call-service.d.ts +4 -0
  13. package/dist/call-service.d.ts.map +1 -1
  14. package/dist/call-service.js +66 -3
  15. package/dist/call-service.js.map +1 -1
  16. package/dist/client.d.ts +48 -2
  17. package/dist/client.d.ts.map +1 -1
  18. package/dist/client.js +918 -115
  19. package/dist/client.js.map +1 -1
  20. package/dist/document-checkpoints.d.ts +25 -2
  21. package/dist/document-checkpoints.d.ts.map +1 -1
  22. package/dist/document-checkpoints.js +258 -61
  23. package/dist/document-checkpoints.js.map +1 -1
  24. package/dist/document-commands.d.ts.map +1 -1
  25. package/dist/document-commands.js +5 -3
  26. package/dist/document-commands.js.map +1 -1
  27. package/dist/document-contents.d.ts +10 -0
  28. package/dist/document-contents.d.ts.map +1 -1
  29. package/dist/document-contents.js +23 -1
  30. package/dist/document-contents.js.map +1 -1
  31. package/dist/document-set-subscription.d.ts +56 -0
  32. package/dist/document-set-subscription.d.ts.map +1 -0
  33. package/dist/document-set-subscription.js +240 -0
  34. package/dist/document-set-subscription.js.map +1 -0
  35. package/dist/document-signals.d.ts +6 -0
  36. package/dist/document-signals.d.ts.map +1 -1
  37. package/dist/document-signals.js +31 -1
  38. package/dist/document-signals.js.map +1 -1
  39. package/dist/document-streams.d.ts +36 -2
  40. package/dist/document-streams.d.ts.map +1 -1
  41. package/dist/document-streams.js +69 -18
  42. package/dist/document-streams.js.map +1 -1
  43. package/dist/document-thumbnail-capture.d.ts +12 -2
  44. package/dist/document-thumbnail-capture.d.ts.map +1 -1
  45. package/dist/document-thumbnail-capture.js +103 -27
  46. package/dist/document-thumbnail-capture.js.map +1 -1
  47. package/dist/documents-namespace.d.ts +92 -10
  48. package/dist/documents-namespace.d.ts.map +1 -1
  49. package/dist/documents-namespace.js +285 -39
  50. package/dist/documents-namespace.js.map +1 -1
  51. package/dist/documents.d.ts +173 -8
  52. package/dist/documents.d.ts.map +1 -1
  53. package/dist/documents.js +1778 -310
  54. package/dist/documents.js.map +1 -1
  55. package/dist/errors.d.ts +3 -3
  56. package/dist/errors.d.ts.map +1 -1
  57. package/dist/errors.js.map +1 -1
  58. package/dist/fetch-retry.d.ts +1 -0
  59. package/dist/fetch-retry.d.ts.map +1 -1
  60. package/dist/fetch-retry.js +2 -0
  61. package/dist/fetch-retry.js.map +1 -1
  62. package/dist/file-choice.d.ts +2 -0
  63. package/dist/file-choice.d.ts.map +1 -0
  64. package/dist/file-choice.js +27 -0
  65. package/dist/file-choice.js.map +1 -0
  66. package/dist/guest-link.d.ts +18 -1
  67. package/dist/guest-link.d.ts.map +1 -1
  68. package/dist/guest-link.js +329 -1
  69. package/dist/guest-link.js.map +1 -1
  70. package/dist/handle-types.d.ts +67 -5
  71. package/dist/handle-types.d.ts.map +1 -1
  72. package/dist/handles.d.ts +15 -19
  73. package/dist/handles.d.ts.map +1 -1
  74. package/dist/handles.js +231 -76
  75. package/dist/handles.js.map +1 -1
  76. package/dist/hosted-ui.d.ts +61 -0
  77. package/dist/hosted-ui.d.ts.map +1 -1
  78. package/dist/hosted-ui.js +94 -42
  79. package/dist/hosted-ui.js.map +1 -1
  80. package/dist/import-contract.d.ts +74 -0
  81. package/dist/import-contract.d.ts.map +1 -0
  82. package/dist/import-contract.js +163 -0
  83. package/dist/import-contract.js.map +1 -0
  84. package/dist/import-guest.d.ts +9 -0
  85. package/dist/import-guest.d.ts.map +1 -0
  86. package/dist/import-guest.js +155 -0
  87. package/dist/import-guest.js.map +1 -0
  88. package/dist/import-report.d.ts +17 -0
  89. package/dist/import-report.d.ts.map +1 -0
  90. package/dist/import-report.js +7 -0
  91. package/dist/import-report.js.map +1 -0
  92. package/dist/importer-wait.d.ts +2 -0
  93. package/dist/importer-wait.d.ts.map +1 -0
  94. package/dist/importer-wait.js +5 -0
  95. package/dist/importer-wait.js.map +1 -0
  96. package/dist/importer.d.ts +18 -4
  97. package/dist/importer.d.ts.map +1 -1
  98. package/dist/importer.js +322 -105
  99. package/dist/importer.js.map +1 -1
  100. package/dist/in-development.d.ts +2 -0
  101. package/dist/in-development.d.ts.map +1 -0
  102. package/dist/in-development.js +10 -0
  103. package/dist/in-development.js.map +1 -0
  104. package/dist/index.d.ts +16 -8
  105. package/dist/index.d.ts.map +1 -1
  106. package/dist/index.js +24 -8
  107. package/dist/index.js.map +1 -1
  108. package/dist/init-keys.d.ts +1 -1
  109. package/dist/init-keys.d.ts.map +1 -1
  110. package/dist/init-keys.js +6 -1
  111. package/dist/init-keys.js.map +1 -1
  112. package/dist/input-documents.d.ts +67 -0
  113. package/dist/input-documents.d.ts.map +1 -0
  114. package/dist/input-documents.js +693 -0
  115. package/dist/input-documents.js.map +1 -0
  116. package/dist/inputs-status.d.ts +80 -0
  117. package/dist/inputs-status.d.ts.map +1 -0
  118. package/dist/inputs-status.js +460 -0
  119. package/dist/inputs-status.js.map +1 -0
  120. package/dist/inputs.d.ts +2 -0
  121. package/dist/inputs.d.ts.map +1 -0
  122. package/dist/inputs.js +793 -0
  123. package/dist/inputs.js.map +1 -0
  124. package/dist/internal.d.ts +6 -1
  125. package/dist/internal.d.ts.map +1 -1
  126. package/dist/internal.js +4 -1
  127. package/dist/internal.js.map +1 -1
  128. package/dist/join-content.d.ts +33 -0
  129. package/dist/join-content.d.ts.map +1 -0
  130. package/dist/join-content.js +114 -0
  131. package/dist/join-content.js.map +1 -0
  132. package/dist/join-stamp.d.ts +3 -0
  133. package/dist/join-stamp.d.ts.map +1 -0
  134. package/dist/join-stamp.js +12 -0
  135. package/dist/join-stamp.js.map +1 -0
  136. package/dist/manifest-drift.d.ts +9 -0
  137. package/dist/manifest-drift.d.ts.map +1 -0
  138. package/dist/manifest-drift.js +46 -0
  139. package/dist/manifest-drift.js.map +1 -0
  140. package/dist/open-document.d.ts +109 -2
  141. package/dist/open-document.d.ts.map +1 -1
  142. package/dist/open-document.js +1822 -811
  143. package/dist/open-document.js.map +1 -1
  144. package/dist/page-restore.d.ts +12 -0
  145. package/dist/page-restore.d.ts.map +1 -0
  146. package/dist/page-restore.js +23 -0
  147. package/dist/page-restore.js.map +1 -0
  148. package/dist/placement.d.ts +13 -0
  149. package/dist/placement.d.ts.map +1 -0
  150. package/dist/placement.js +34 -0
  151. package/dist/placement.js.map +1 -0
  152. package/dist/presence.d.ts +1 -0
  153. package/dist/presence.d.ts.map +1 -1
  154. package/dist/presence.js +16 -4
  155. package/dist/presence.js.map +1 -1
  156. package/dist/read-only-document.d.ts +87 -0
  157. package/dist/read-only-document.d.ts.map +1 -0
  158. package/dist/read-only-document.js +4 -0
  159. package/dist/read-only-document.js.map +1 -0
  160. package/dist/record-paging.d.ts +63 -6
  161. package/dist/record-paging.d.ts.map +1 -1
  162. package/dist/record-paging.js +122 -29
  163. package/dist/record-paging.js.map +1 -1
  164. package/dist/references.d.ts +40 -0
  165. package/dist/references.d.ts.map +1 -0
  166. package/dist/references.js +135 -0
  167. package/dist/references.js.map +1 -0
  168. package/dist/request-lane.d.ts +5 -0
  169. package/dist/request-lane.d.ts.map +1 -0
  170. package/dist/request-lane.js +48 -0
  171. package/dist/request-lane.js.map +1 -0
  172. package/dist/sealed-arrow.d.ts +170 -0
  173. package/dist/sealed-arrow.d.ts.map +1 -0
  174. package/dist/sealed-arrow.js +769 -0
  175. package/dist/sealed-arrow.js.map +1 -0
  176. package/dist/sealed-cache.d.ts +21 -0
  177. package/dist/sealed-cache.d.ts.map +1 -0
  178. package/dist/sealed-cache.js +247 -0
  179. package/dist/sealed-cache.js.map +1 -0
  180. package/dist/sealed-encode.d.ts +87 -0
  181. package/dist/sealed-encode.d.ts.map +1 -0
  182. package/dist/sealed-encode.js +876 -0
  183. package/dist/sealed-encode.js.map +1 -0
  184. package/dist/sealed-handles.d.ts +45 -0
  185. package/dist/sealed-handles.d.ts.map +1 -0
  186. package/dist/sealed-handles.js +408 -0
  187. package/dist/sealed-handles.js.map +1 -0
  188. package/dist/sealed-store.d.ts +242 -0
  189. package/dist/sealed-store.d.ts.map +1 -0
  190. package/dist/sealed-store.js +1155 -0
  191. package/dist/sealed-store.js.map +1 -0
  192. package/dist/serializer.d.ts +6 -15
  193. package/dist/serializer.d.ts.map +1 -1
  194. package/dist/session-toolbar.d.ts +20 -5
  195. package/dist/session-toolbar.d.ts.map +1 -1
  196. package/dist/session-toolbar.js +419 -67
  197. package/dist/session-toolbar.js.map +1 -1
  198. package/dist/session.d.ts +38 -1
  199. package/dist/session.d.ts.map +1 -1
  200. package/dist/session.js +562 -79
  201. package/dist/session.js.map +1 -1
  202. package/dist/sha256.d.ts +3 -0
  203. package/dist/sha256.d.ts.map +1 -0
  204. package/dist/sha256.js +74 -0
  205. package/dist/sha256.js.map +1 -0
  206. package/dist/stable-handles.d.ts +19 -0
  207. package/dist/stable-handles.d.ts.map +1 -0
  208. package/dist/stable-handles.js +193 -0
  209. package/dist/stable-handles.js.map +1 -0
  210. package/dist/staged-seal.d.ts +2 -0
  211. package/dist/staged-seal.d.ts.map +1 -0
  212. package/dist/staged-seal.js +76 -0
  213. package/dist/staged-seal.js.map +1 -0
  214. package/dist/subscriber-delivery.d.ts +8 -0
  215. package/dist/subscriber-delivery.d.ts.map +1 -0
  216. package/dist/subscriber-delivery.js +31 -0
  217. package/dist/subscriber-delivery.js.map +1 -0
  218. package/dist/tail-op-import.d.ts +10 -0
  219. package/dist/tail-op-import.d.ts.map +1 -0
  220. package/dist/tail-op-import.js +33 -0
  221. package/dist/tail-op-import.js.map +1 -0
  222. package/dist/topic-lifecycle.d.ts +6 -0
  223. package/dist/topic-lifecycle.d.ts.map +1 -1
  224. package/dist/topic-lifecycle.js +32 -2
  225. package/dist/topic-lifecycle.js.map +1 -1
  226. package/dist/trial-replace-dialog.d.ts +10 -0
  227. package/dist/trial-replace-dialog.d.ts.map +1 -0
  228. package/dist/trial-replace-dialog.js +153 -0
  229. package/dist/trial-replace-dialog.js.map +1 -0
  230. package/dist/types.d.ts +8 -1
  231. package/dist/types.d.ts.map +1 -1
  232. package/dist/undo.d.ts +60 -5
  233. package/dist/undo.d.ts.map +1 -1
  234. package/dist/undo.js +344 -75
  235. package/dist/undo.js.map +1 -1
  236. package/dist/version-bar.d.ts +1 -0
  237. package/dist/version-bar.d.ts.map +1 -1
  238. package/dist/version-bar.js +2 -1
  239. package/dist/version-bar.js.map +1 -1
  240. package/dist/version.d.ts +2 -2
  241. package/dist/version.js +1 -1
  242. package/dist/ws-client.d.ts +21 -0
  243. package/dist/ws-client.d.ts.map +1 -1
  244. package/dist/ws-client.js +192 -8
  245. package/dist/ws-client.js.map +1 -1
  246. package/dist/ws-frame-limit.d.ts +1 -0
  247. package/dist/ws-frame-limit.d.ts.map +1 -1
  248. package/dist/ws-frame-limit.js.map +1 -1
  249. package/docs/app-manifest.md +121 -17
  250. package/docs/custom-chrome.md +24 -16
  251. package/docs/hosted-ui-advanced.md +38 -6
  252. package/docs/imported-documents.md +104 -51
  253. package/docs/presence.md +16 -4
  254. package/docs/schemas.md +8 -5
  255. package/docs/services.md +6 -6
  256. package/docs/streams.md +139 -150
  257. package/docs/text-fields.md +26 -19
  258. package/package.json +5 -2
  259. package/src/api-fetch.ts +53 -3
  260. package/src/auth-flow.ts +1 -1
  261. package/src/batched-get.ts +117 -0
  262. package/src/call-service.ts +78 -3
  263. package/src/client.ts +1022 -112
  264. package/src/document-checkpoints.ts +307 -65
  265. package/src/document-commands.ts +5 -5
  266. package/src/document-contents.ts +27 -1
  267. package/src/document-set-subscription.ts +274 -0
  268. package/src/document-signals.ts +47 -5
  269. package/src/document-streams.ts +85 -16
  270. package/src/document-thumbnail-capture.ts +118 -32
  271. package/src/documents-namespace.ts +539 -84
  272. package/src/documents.ts +2017 -348
  273. package/src/errors.ts +17 -6
  274. package/src/fetch-retry.ts +5 -0
  275. package/src/file-choice.ts +26 -0
  276. package/src/guest-link.ts +362 -1
  277. package/src/handle-types.ts +73 -47
  278. package/src/handles.ts +253 -83
  279. package/src/hosted-ui.ts +211 -50
  280. package/src/import-contract.ts +227 -0
  281. package/src/import-guest.ts +193 -0
  282. package/src/import-report.ts +24 -0
  283. package/src/importer-wait.ts +4 -0
  284. package/src/importer.ts +386 -131
  285. package/src/in-development.ts +10 -0
  286. package/src/index.ts +76 -16
  287. package/src/init-keys.ts +6 -1
  288. package/src/input-documents.ts +819 -0
  289. package/src/inputs-status.ts +598 -0
  290. package/src/inputs.ts +1090 -0
  291. package/src/internal.ts +7 -0
  292. package/src/join-content.ts +135 -0
  293. package/src/join-stamp.ts +12 -0
  294. package/src/manifest-drift.ts +54 -0
  295. package/src/open-document.ts +2346 -930
  296. package/src/page-restore.ts +38 -0
  297. package/src/placement.ts +54 -0
  298. package/src/presence.ts +21 -5
  299. package/src/read-only-document.ts +133 -0
  300. package/src/record-paging.ts +147 -25
  301. package/src/references.ts +207 -0
  302. package/src/request-lane.ts +59 -0
  303. package/src/sealed-arrow.ts +843 -0
  304. package/src/sealed-cache.ts +267 -0
  305. package/src/sealed-encode.ts +1015 -0
  306. package/src/sealed-handles.ts +486 -0
  307. package/src/sealed-store.ts +1330 -0
  308. package/src/serializer.ts +8 -20
  309. package/src/session-toolbar.ts +463 -72
  310. package/src/session.ts +740 -99
  311. package/src/sha256.ts +65 -0
  312. package/src/stable-handles.ts +235 -0
  313. package/src/staged-seal.ts +90 -0
  314. package/src/subscriber-delivery.ts +37 -0
  315. package/src/tail-op-import.ts +30 -0
  316. package/src/topic-lifecycle.ts +38 -2
  317. package/src/trial-replace-dialog.ts +163 -0
  318. package/src/types.ts +6 -6
  319. package/src/undo.ts +386 -73
  320. package/src/version-bar.ts +5 -1
  321. package/src/version.ts +1 -1
  322. package/src/ws-client.ts +208 -16
  323. package/src/ws-frame-limit.ts +1 -0
package/src/undo.ts CHANGED
@@ -1,27 +1,31 @@
1
1
  // Per-Document undo/redo, backed by Loro's UndoManager: undo is
2
- // local-peer-scoped (reverts only this peer's ops), group bracketing collapses
3
- // same-stroke edits into one entry, and remote-import bookkeeping keeps the
4
- // redo stack honest across other peers' edits.
2
+ // local-peer-scoped (reverts only this peer's ops), and the controller groups
3
+ // Loro's per-commit stack items into the steps a reader undoes. Every stream
4
+ // of one document is its own LoroDoc with its own controller, and an
5
+ // `UndoGroup` makes them one stack: they share the step clock, so a step is
6
+ // every commit between two seals in whichever streams took one, and the group
7
+ // replays the newest step in each stream that holds a part of it.
5
8
  import { UndoManager, type LoroDoc } from './wasm-runtime.js';
6
9
 
7
10
  import type { UndoControllerOptions } from './types.js';
8
11
 
9
- // Loro's time-based merge is permanently disabled. The undo boundary here is
10
- // the checkpoint group bracket (`sealStep`), and the two are incompatible: a
11
- // nonzero merge interval merges adjacent changes by *timestamp*, and that
12
- // merge reaches ACROSS a `groupEnd()` / `groupStart()` seal, collapsing two
13
- // separately-sealed steps into one. That silently violates the "every
14
- // checkpoint is an undo boundary" contract (one Ctrl-Z reverts more than the
15
- // last operation and the rendered state jumps past where the user expected).
16
- // Same-stroke collapsing is still handled, by the open group not the clock:
17
- // every commit between two seals brackets into one entry. Apps tune
18
- // granularity via `setAutoCheckpointIntervalMs` and `createCheckpoint`.
12
+ // Loro's time-based merge is permanently disabled. It merges adjacent changes
13
+ // by timestamp, across a `sealStep` boundary, so one Ctrl-Z would revert more
14
+ // than the last step. Every commit is its own Loro stack item, and the step a
15
+ // commit belongs to is the tag `sealStep` advances.
19
16
  const UNDO_MERGE_INTERVAL_MS = 0;
20
17
 
21
18
  // Cap on retained undo steps (Loro's default is 100). 200 at typical AEC
22
19
  // entity sizes is a few hundred KB, well below anything that matters.
23
20
  const DEFAULT_MAX_UNDO_STEPS = 200;
24
21
 
22
+ // Loro caps stack ITEMS, and a step is as many items as it has commits (a
23
+ // typed word is one per keystroke). The controller caps STEPS itself (see
24
+ // `_floor`), and Loro's item cap is a memory bound behind it. When that one
25
+ // binds, Loro drops the oldest items, and the oldest step reverts only the
26
+ // part of it that is left.
27
+ const ITEMS_PER_STEP_CAP = 64;
28
+
25
29
  // Commit origin the paged marker (`meta.paged:<root>`) is stamped with, and
26
30
  // the one origin the undo stack ignores.
27
31
  //
@@ -33,101 +37,410 @@ const DEFAULT_MAX_UNDO_STEPS = 200;
33
37
  // excluded alongside it.
34
38
  export const PAGING_COMMIT_ORIGIN = 'brass.paging';
35
39
 
40
+ // Commit origin of a write the app asked to keep off the reader's Undo stack
41
+ // (`mutate(fn, { undoable: false })`, `markRead(..., { undoable: false })`):
42
+ // an automatic pass the reader did not ask for, which their Undo must not
43
+ // revert along with what they did.
44
+ export const NON_UNDOABLE_COMMIT_ORIGIN = 'brass.auto';
45
+
36
46
  // Thin wrapper around Loro's UndoManager exposing only what the Document
37
47
  // needs. Owned by the Document; not part of the public SDK surface.
48
+ //
49
+ // A step is every local commit between two `sealStep` calls, and the Document
50
+ // seals only at a checkpoint, so a step spans a peer's edits whenever two
51
+ // people work at once. Every commit is its own Loro stack item, tagged in
52
+ // `onPush` with the step it belongs to, and `undo` / `redo` replay every item
53
+ // at the top of the stack carrying the top item's tag, so a step reverts whole
54
+ // however many commits it took.
55
+ //
56
+ // Loro's own group cannot hold a step. A peer's edit made after seeing only
57
+ // some of a group's commits leaves the group impossible to undo fully (the
58
+ // commits the peer saw stay in place, and no later undo removes them), and
59
+ // commits ship as they are made, so any group of two or more commits can meet
60
+ // that in ordinary collaboration. `undo-concurrent-delivery.test.ts` pins the
61
+ // engine behaviour.
62
+ //
63
+ // One item per commit costs time on a long step: each Loro undo transforms the
64
+ // rest of the stack against the remote edits recorded on it, so a step's undo
65
+ // grows with its commit count times the remote edits interleaved with the
66
+ // stack (`scripts/bench/undo-long-step.mts`).
38
67
  export class UndoController {
39
68
  private readonly _mgr: UndoManager;
40
- // Whether an implicit group is open. The Document keeps one open between
41
- // checkpoints so `createCheckpoint` is THE undo boundary: every commit
42
- // between two checkpoints collapses into one step. `sealStep` closes and
43
- // reopens the group, the only way to force a boundary into Loro's stack
44
- // (Loro has no "force boundary here" primitive). This is a hard boundary
45
- // only because the merge interval is disabled (see above).
46
- private _groupOpen = false;
47
-
48
- constructor(loroDoc: LoroDoc, options?: UndoControllerOptions) {
69
+ private readonly _maxSteps: number;
70
+ // Holds the tag of the step new local commits join, shared by every stream
71
+ // of the document. `sealStep` advances it.
72
+ private _clock: UndoClock;
73
+ private readonly _hooks: UndoControllerHooks;
74
+ // How many items at the bottom of the redo stack belong to a history a
75
+ // fresh commit in ANOTHER stream has since replaced. Loro clears this
76
+ // stack itself on a commit to this stream only.
77
+ private _redoRetired = 0;
78
+ // While `undo` / `redo` replays a step, the tag the items it pushes onto
79
+ // the opposite stack carry, so the step stays one step in both directions.
80
+ private _replaying: number | null = null;
81
+ // The tag of the item Loro last popped, which the item its replay pushes
82
+ // onto the opposite stack inherits. Loro pops before it pushes.
83
+ private _popped: number | null = null;
84
+ // Set when a replay's Loro call applied an item of another step (see
85
+ // `replay`), and while the controller puts that item back.
86
+ private _foreign: number | null = null;
87
+ private _restoring = false;
88
+ // Whether the replay popped an item of the step it replays.
89
+ private _poppedOwn = false;
90
+ // Tags of the steps on the undo stack, oldest first. Tags only grow up the
91
+ // stack, so a step past the cap is retired by raising `_floor` to its tag:
92
+ // `undo` never replays an item tagged at or below the floor.
93
+ private _undoSteps: number[] = [];
94
+ private _floor = -1;
95
+ // The tag of every item on each Loro stack, bottom first. Once a stack is
96
+ // at Loro's item cap, Loro loses the value `onPush` attaches to each item
97
+ // pushed from then on (loro-crdt 1.13.9 through 1.16.4), so the tag a
98
+ // replay reads comes from here whenever Loro's own reads back empty.
99
+ private _undoTags: number[] = [];
100
+ private _redoTags: number[] = [];
101
+ private readonly _itemCap: number;
102
+
103
+ constructor(
104
+ loroDoc: LoroDoc,
105
+ options?: UndoControllerOptions,
106
+ clock: UndoClock = new UndoGroup(),
107
+ hooks: UndoControllerHooks = {},
108
+ ) {
109
+ this._clock = clock;
110
+ this._hooks = hooks;
111
+ const maxSteps = options?.maxUndoSteps ?? DEFAULT_MAX_UNDO_STEPS;
112
+ if (!Number.isInteger(maxSteps) || maxSteps < 1) {
113
+ throw new RangeError(`maxUndoSteps must be an integer of at least 1, got ${maxSteps}`);
114
+ }
115
+ this._maxSteps = maxSteps;
116
+ this._itemCap = maxSteps * ITEMS_PER_STEP_CAP;
49
117
  this._mgr = new UndoManager(loroDoc, {
50
118
  mergeInterval: UNDO_MERGE_INTERVAL_MS,
51
- maxUndoSteps: options?.maxUndoSteps ?? DEFAULT_MAX_UNDO_STEPS,
52
- excludeOriginPrefixes: [PAGING_COMMIT_ORIGIN],
119
+ maxUndoSteps: this._itemCap,
120
+ excludeOriginPrefixes: [PAGING_COMMIT_ORIGIN, NON_UNDOABLE_COMMIT_ORIGIN],
121
+ onPush: (isUndo) => {
122
+ const tag = this._popped ?? this._replaying ?? this._clock.step;
123
+ this._popped = null;
124
+ const ownStep = this._replaying === null || tag === this._replaying;
125
+ if (!ownStep && !this._restoring) this._foreign = tag;
126
+ if (isUndo) {
127
+ if (ownStep && !this._restoring) this.recordUndoStep(tag);
128
+ // A local commit clears Loro's redo stack; a redo's own push does not.
129
+ if (this._replaying === null) {
130
+ this._redoTags = [];
131
+ this._redoRetired = 0;
132
+ this._hooks.onFreshCommit?.();
133
+ }
134
+ }
135
+ const tags = isUndo ? this._undoTags : this._redoTags;
136
+ tags.push(tag);
137
+ if (tags.length > this._itemCap) {
138
+ const dropped = tags.length - this._itemCap;
139
+ tags.splice(0, dropped);
140
+ if (!isUndo) this._redoRetired = Math.max(0, this._redoRetired - dropped);
141
+ }
142
+ this._hooks.onStackChange?.();
143
+ return { value: tag, cursors: [] };
144
+ },
145
+ onPop: (isUndo, meta) => {
146
+ const mirrored = (isUndo ? this._undoTags : this._redoTags).pop();
147
+ const tag = typeof meta.value === 'number' ? meta.value : mirrored;
148
+ this._popped = tag ?? null;
149
+ if (tag === this._replaying && !this._restoring) this._poppedOwn = true;
150
+ if (!isUndo && this._redoRetired > this._redoTags.length) {
151
+ this._redoRetired = this._redoTags.length;
152
+ }
153
+ this._hooks.onStackChange?.();
154
+ },
53
155
  });
54
- // Open the implicit group so commits before the first `createCheckpoint`
55
- // still bracket as one undo step.
56
- this._mgr.groupStart();
57
- this._groupOpen = true;
58
156
  }
59
157
 
60
- // undo/redo run OUTSIDE the implicit group: Loro groups every commit while a
61
- // group is open, INCLUDING the one a `redo()` / `undo()` itself produces, so
62
- // running them inside would fold the reapplied step into the group and the
63
- // next sealed edit would merge with it (one Ctrl-Z reverts both). Closing
64
- // first also makes unsealed pending commits their own boundary.
65
- undo(): boolean {
66
- return this.outsideGroup(() => this._mgr.undo());
158
+ private topTag(dir: 'undo' | 'redo'): number | undefined {
159
+ const value = dir === 'undo' ? this._mgr.topUndoValue() : this._mgr.topRedoValue();
160
+ if (typeof value === 'number') return value;
161
+ const tags = dir === 'undo' ? this._undoTags : this._redoTags;
162
+ return tags[tags.length - 1];
67
163
  }
68
164
 
69
- redo(): boolean {
70
- return this.outsideGroup(() => this._mgr.redo());
165
+ private recordUndoStep(tag: number): void {
166
+ if (this._undoSteps[this._undoSteps.length - 1] === tag) return;
167
+ this._undoSteps.push(tag);
168
+ while (this._undoSteps.length > this._maxSteps) {
169
+ this._floor = Math.max(this._floor, this._undoSteps.shift()!);
170
+ }
71
171
  }
72
172
 
73
- private outsideGroup(op: () => boolean): boolean {
74
- if (this._groupOpen) {
75
- this._mgr.groupEnd();
76
- this._groupOpen = false;
77
- }
173
+ // `beforeEach` runs before every Loro undo / redo the step takes, since each
174
+ // one is its own commit (the Document tags each with its commit origin).
175
+ undo(beforeEach?: () => void): boolean {
176
+ return this.replay('undo', beforeEach);
177
+ }
178
+
179
+ redo(beforeEach?: () => void): boolean {
180
+ return this.replay('redo', beforeEach);
181
+ }
182
+
183
+ // Replay this stream's part of step `tag`, which the group chose as the
184
+ // document's newest (undo) or most recently undone (redo) step.
185
+ replayStep(dir: 'undo' | 'redo', tag: number, beforeEach?: () => void): boolean {
186
+ return this.replay(dir, beforeEach, tag);
187
+ }
188
+
189
+ // The tag of the step this stream would replay next, or `undefined` when
190
+ // it has none: below the floor for undo, and past the retired items for
191
+ // redo.
192
+ peek(dir: 'undo' | 'redo'): number | undefined {
193
+ const tag = this.topTag(dir);
194
+ if (typeof tag !== 'number') return undefined;
195
+ if (dir === 'undo') return tag > this._floor ? tag : undefined;
196
+ return this.hasLiveRedo() ? tag : undefined;
197
+ }
198
+
199
+ // Tags at or below this have left the undo stack (see `_floor`).
200
+ get floor(): number {
201
+ return this._floor;
202
+ }
203
+
204
+ // Move to another document's step clock. Called when a stream joins its
205
+ // document, before it has taken any local commit.
206
+ useClock(clock: UndoClock): void {
207
+ this._clock = clock;
208
+ }
209
+
210
+ // A fresh commit in another stream of the document: the steps on this
211
+ // stream's redo stack belong to the history that commit replaced.
212
+ retireRedo(): void {
213
+ this._redoRetired = this._redoTags.length;
214
+ }
215
+
216
+ private hasLiveRedo(): boolean {
217
+ return this._redoRetired === 0 || this._redoTags.length > this._redoRetired;
218
+ }
219
+
220
+ // Loro skips an item whose replay would change nothing visible (a peer
221
+ // deleted what it edited) and pops the next one in the same call, which can
222
+ // belong to an older step or sit below `_floor`. The controller puts such an
223
+ // item straight back through the opposite stack, under its own tag, so one
224
+ // call replays one step. A step whose every item Loro skipped has still left
225
+ // the stack, so it counts as replayed and the call returns `true`.
226
+ private replay(dir: 'undo' | 'redo', beforeEach?: () => void, chosen?: number): boolean {
227
+ const top = (): number | undefined => this.topTag(dir);
228
+ const tag = chosen ?? this.peek(dir);
229
+ this._poppedOwn = false;
78
230
  try {
79
- return op();
231
+ if (typeof tag === 'number' && top() === tag) {
232
+ this._replaying = tag;
233
+ while (top() === tag) {
234
+ beforeEach?.();
235
+ const moved = dir === 'undo' ? this._mgr.undo() : this._mgr.redo();
236
+ if (this._foreign !== null) {
237
+ this._restoring = true;
238
+ beforeEach?.();
239
+ if (dir === 'undo') this._mgr.redo();
240
+ else this._mgr.undo();
241
+ this._restoring = false;
242
+ this._foreign = null;
243
+ }
244
+ if (!moved) break;
245
+ }
246
+ if (dir === 'undo' && this._undoSteps[this._undoSteps.length - 1] === tag) {
247
+ this._undoSteps.pop();
248
+ }
249
+ }
80
250
  } finally {
81
- this._mgr.groupStart();
82
- this._groupOpen = true;
251
+ this._replaying = null;
252
+ this._restoring = false;
253
+ this._foreign = null;
254
+ this._popped = null;
255
+ // The next local commit starts a step of its own, so it never joins the
256
+ // step just replayed (an edit after a redo undoes on its own).
257
+ this._clock.step += 1;
83
258
  }
259
+ return this._poppedOwn;
84
260
  }
85
261
 
86
262
  canUndo(): boolean {
87
- return this._mgr.canUndo();
263
+ const tag = this.topTag('undo');
264
+ return tag !== undefined && tag > this._floor && this._mgr.canUndo();
88
265
  }
89
266
 
90
267
  canRedo(): boolean {
91
- return this._mgr.canRedo();
268
+ return this._mgr.canRedo() && this.hasLiveRedo();
92
269
  }
93
270
 
94
- // Seal the current undo step: close the implicit group (commits since the
95
- // previous seal collapse into one entry) and open a fresh one.
271
+ // Seal the current undo step: commits after this one join a new step.
96
272
  // `createCheckpoint` and the auto-checkpoint scheduler call this so every
97
- // checkpoint is a Ctrl-Z boundary. Idempotent: calling twice with no
98
- // intervening commit is safe (Loro collapses the empty group).
273
+ // checkpoint is a Ctrl-Z boundary. Idempotent: an empty step has no items.
99
274
  sealStep(): void {
100
- if (this._groupOpen) {
101
- this._mgr.groupEnd();
102
- this._groupOpen = false;
103
- }
104
- this._mgr.groupStart();
105
- this._groupOpen = true;
275
+ this._clock.step += 1;
106
276
  }
107
277
 
108
278
  // Drop both stacks. Called by `_applySnapshot`: the prior entries reference a
109
279
  // state the new snapshot superseded.
110
280
  clear(): void {
111
- // Close the open group first so `clear()` sees a clean state (defensive;
112
- // Loro's `clear` drops both stacks regardless).
113
- if (this._groupOpen) {
114
- this._mgr.groupEnd();
115
- this._groupOpen = false;
116
- }
117
281
  this._mgr.clear();
118
- this._mgr.groupStart();
119
- this._groupOpen = true;
282
+ this._undoSteps = [];
283
+ this._undoTags = [];
284
+ this._redoTags = [];
285
+ this._redoRetired = 0;
286
+ this._clock.step += 1;
120
287
  }
121
288
 
122
289
  free(): void {
123
- if (this._groupOpen) {
124
- try {
125
- this._mgr.groupEnd();
126
- } catch {
127
- // Best-effort; the underlying manager is about to free.
128
- }
129
- this._groupOpen = false;
130
- }
131
290
  this._mgr.free();
132
291
  }
133
292
  }
293
+
294
+ // The step clock a controller tags its commits with.
295
+ export interface UndoClock {
296
+ step: number;
297
+ }
298
+
299
+ export interface UndoControllerHooks {
300
+ // A local commit outside a replay landed on the undo stack.
301
+ onFreshCommit?: () => void;
302
+ // An item was pushed onto or popped off either stack. Runs inside Loro's
303
+ // callback, so it must not call back into the UndoManager.
304
+ onStackChange?: () => void;
305
+ }
306
+
307
+ // One stream's place in a document's undo group: the Document that holds the
308
+ // stream's state.
309
+ export interface UndoGroupMember {
310
+ /** @internal This stream's controller, or `null` when it has no CRDT. */
311
+ _undoController(): UndoController | null;
312
+ /** @internal Whether this stream may replay a step now: it takes writes
313
+ * and is not showing a version. */
314
+ _undoEligible(): boolean;
315
+ /** @internal True inside a `mutate` callback on this stream. */
316
+ _undoMutating(): boolean;
317
+ /** @internal Replay this stream's part of step `tag`. */
318
+ _replayUndoStep(dir: 'undo' | 'redo', tag: number): boolean;
319
+ }
320
+
321
+ export interface UndoGroupOwner {
322
+ // Whether the document refuses every replay now (closed, or showing a
323
+ // version), whatever each stream's own state.
324
+ blocked(): boolean;
325
+ // Re-read the document's undo state for `subscribeUndoState`.
326
+ notify(): void;
327
+ // A stream other than the owner's took a fresh undoable commit.
328
+ memberCommitted(): void;
329
+ }
330
+
331
+ // The undo stack of one document, across its streams. The step clock is
332
+ // shared, so tags only grow over time document-wide: the newest step is the
333
+ // highest tag on top of any stream's undo stack, and the most recently undone
334
+ // step is the lowest tag on top of any redo stack. Undo replays that tag in
335
+ // every stream whose top carries it, so a step spanning streams reverts whole.
336
+ export class UndoGroup implements UndoClock {
337
+ step = 0;
338
+ private readonly _members = new Set<UndoGroupMember>();
339
+ private _owner: { member: UndoGroupMember; hooks: UndoGroupOwner } | null = null;
340
+ private _notifyQueued = false;
341
+
342
+ setOwner(member: UndoGroupMember, hooks: UndoGroupOwner): void {
343
+ this._owner = { member, hooks };
344
+ this._members.add(member);
345
+ }
346
+
347
+ isOwner(member: UndoGroupMember): boolean {
348
+ return this._owner?.member === member;
349
+ }
350
+
351
+ add(member: UndoGroupMember): void {
352
+ this._members.add(member);
353
+ }
354
+
355
+ delete(member: UndoGroupMember): void {
356
+ this._members.delete(member);
357
+ }
358
+
359
+ seal(): void {
360
+ this.step += 1;
361
+ }
362
+
363
+ // A non-owner stream's stacks moved. Deferred, because the move is reported
364
+ // from inside the UndoManager, which the re-read calls back into.
365
+ changed(): void {
366
+ if (this._owner === null || this._notifyQueued) return;
367
+ this._notifyQueued = true;
368
+ queueMicrotask(() => {
369
+ this._notifyQueued = false;
370
+ this._owner?.hooks.notify();
371
+ });
372
+ }
373
+
374
+ // A fresh commit replaces the history every redo stack in the document
375
+ // undoes back into, not only the stack of the stream it landed in.
376
+ freshCommit(from: UndoGroupMember): void {
377
+ for (const member of this._members) {
378
+ if (member !== from) member._undoController()?.retireRedo();
379
+ }
380
+ if (this._owner !== null && this._owner.member !== from) {
381
+ this._owner.hooks.memberCommitted();
382
+ }
383
+ }
384
+
385
+ private eligible(): Array<{ member: UndoGroupMember; ctl: UndoController }> {
386
+ const out: Array<{ member: UndoGroupMember; ctl: UndoController }> = [];
387
+ if (this._owner?.hooks.blocked()) return out;
388
+ for (const member of this._members) {
389
+ const ctl = member._undoController();
390
+ if (ctl !== null && member._undoEligible()) out.push({ member, ctl });
391
+ }
392
+ return out;
393
+ }
394
+
395
+ // Each stream retires its oldest steps past its own cap, and a step one
396
+ // stream has retired is retired in every stream, so no Undo reverts part of
397
+ // one.
398
+ private floor(): number {
399
+ let floor = -1;
400
+ for (const member of this._members) {
401
+ const ctl = member._undoController();
402
+ if (ctl !== null) floor = Math.max(floor, ctl.floor);
403
+ }
404
+ return floor;
405
+ }
406
+
407
+ private next(dir: 'undo' | 'redo'): number | undefined {
408
+ let next: number | undefined;
409
+ for (const { ctl } of this.eligible()) {
410
+ const tag = ctl.peek(dir);
411
+ if (tag === undefined) continue;
412
+ if (next === undefined || (dir === 'undo' ? tag > next : tag < next)) next = tag;
413
+ }
414
+ if (dir === 'undo' && next !== undefined && next <= this.floor()) return undefined;
415
+ return next;
416
+ }
417
+
418
+ canUndo(): boolean {
419
+ const floor = this.floor();
420
+ return this.eligible().some(({ ctl }) => {
421
+ const tag = ctl.peek('undo');
422
+ return tag !== undefined && tag > floor && ctl.canUndo();
423
+ });
424
+ }
425
+
426
+ canRedo(): boolean {
427
+ return this.eligible().some(({ ctl }) => ctl.canRedo());
428
+ }
429
+
430
+ // True when a step was replayed. Refuses inside any stream's `mutate`
431
+ // callback, whose open transaction holds writes the replay would commit
432
+ // outside the op it ships.
433
+ replay(dir: 'undo' | 'redo'): boolean {
434
+ for (const member of this._members) {
435
+ if (member._undoMutating()) return false;
436
+ }
437
+ const tag = this.next(dir);
438
+ if (tag === undefined) return false;
439
+ let replayed = false;
440
+ for (const { member, ctl } of this.eligible()) {
441
+ if (ctl.peek(dir) !== tag) continue;
442
+ if (member._replayUndoStep(dir, tag)) replayed = true;
443
+ }
444
+ return replayed;
445
+ }
446
+ }
@@ -37,6 +37,9 @@ export interface VersionBarDeps {
37
37
  // The resolved dashboard/embed origin (no trailing slash); the iframe
38
38
  // loads `${dashboardBaseUrl}/embed/version-bar`.
39
39
  dashboardBaseUrl: string;
40
+ // The app this bar serves, sent as `appId` in the embed's init so the embed
41
+ // can check the framing page may host it. `undefined` before `Brass.init()`.
42
+ appId: string | undefined;
40
43
  // Pins the embed's light/dark theme (matches the other hosted surfaces).
41
44
  theme?: 'light' | 'dark' | 'auto';
42
45
  // Intents from the embed, routed to the live active document. The two
@@ -141,7 +144,7 @@ export function createVersionBarFrame(
141
144
  const url = new URL(`${base}/embed/version-bar`);
142
145
  url.searchParams.set('theme', theme);
143
146
  iframe.src = url.toString();
144
- iframe.title = 'Document version';
147
+ iframe.title = 'Version';
145
148
  Object.assign(iframe.style, {
146
149
  display: 'block',
147
150
  border: '0',
@@ -235,6 +238,7 @@ export function createVersionBarFrame(
235
238
  type: 'init',
236
239
  surface: 'version-bar',
237
240
  params: {
241
+ ...(deps.appId !== undefined ? { appId: deps.appId } : {}),
238
242
  docId: ctx.docId,
239
243
  ...(ctx.branch !== undefined ? { branch: ctx.branch } : {}),
240
244
  version: ctx.version,
package/src/version.ts CHANGED
@@ -3,6 +3,6 @@
3
3
  // can measure the version distribution of deployed apps and time
4
4
  // compatibility windows against it. A unit test pins this to
5
5
  // package.json's version; the release bump touches both together.
6
- export const SDK_VERSION = '0.4.1';
6
+ export const SDK_VERSION = '0.5.0';
7
7
 
8
8
  export const SDK_CLIENT_ID = `sdk/${SDK_VERSION}`;