@brass-build/client 0.1.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 (258) hide show
  1. package/AGENTS.md +255 -0
  2. package/CHANGELOG.md +15 -0
  3. package/LICENSE +21 -0
  4. package/README.md +836 -0
  5. package/dist/api-fetch.d.ts +16 -0
  6. package/dist/api-fetch.d.ts.map +1 -0
  7. package/dist/api-fetch.js +97 -0
  8. package/dist/api-fetch.js.map +1 -0
  9. package/dist/api-headers.d.ts +3 -0
  10. package/dist/api-headers.d.ts.map +1 -0
  11. package/dist/api-headers.js +25 -0
  12. package/dist/api-headers.js.map +1 -0
  13. package/dist/auth-flow.d.ts +23 -0
  14. package/dist/auth-flow.d.ts.map +1 -0
  15. package/dist/auth-flow.js +76 -0
  16. package/dist/auth-flow.js.map +1 -0
  17. package/dist/client.d.ts +106 -0
  18. package/dist/client.d.ts.map +1 -0
  19. package/dist/client.js +1278 -0
  20. package/dist/client.js.map +1 -0
  21. package/dist/doc-url-sync.d.ts +19 -0
  22. package/dist/doc-url-sync.d.ts.map +1 -0
  23. package/dist/doc-url-sync.js +181 -0
  24. package/dist/doc-url-sync.js.map +1 -0
  25. package/dist/document-branches.d.ts +32 -0
  26. package/dist/document-branches.d.ts.map +1 -0
  27. package/dist/document-branches.js +89 -0
  28. package/dist/document-branches.js.map +1 -0
  29. package/dist/document-checkpoints.d.ts +24 -0
  30. package/dist/document-checkpoints.d.ts.map +1 -0
  31. package/dist/document-checkpoints.js +115 -0
  32. package/dist/document-checkpoints.js.map +1 -0
  33. package/dist/document-commands.d.ts +93 -0
  34. package/dist/document-commands.d.ts.map +1 -0
  35. package/dist/document-commands.js +423 -0
  36. package/dist/document-commands.js.map +1 -0
  37. package/dist/document-signals.d.ts +53 -0
  38. package/dist/document-signals.d.ts.map +1 -0
  39. package/dist/document-signals.js +114 -0
  40. package/dist/document-signals.js.map +1 -0
  41. package/dist/document-streams.d.ts +22 -0
  42. package/dist/document-streams.d.ts.map +1 -0
  43. package/dist/document-streams.js +98 -0
  44. package/dist/document-streams.js.map +1 -0
  45. package/dist/document-thumbnail-capture.d.ts +27 -0
  46. package/dist/document-thumbnail-capture.d.ts.map +1 -0
  47. package/dist/document-thumbnail-capture.js +160 -0
  48. package/dist/document-thumbnail-capture.js.map +1 -0
  49. package/dist/documents-namespace.d.ts +134 -0
  50. package/dist/documents-namespace.d.ts.map +1 -0
  51. package/dist/documents-namespace.js +190 -0
  52. package/dist/documents-namespace.js.map +1 -0
  53. package/dist/documents.d.ts +146 -0
  54. package/dist/documents.d.ts.map +1 -0
  55. package/dist/documents.js +2045 -0
  56. package/dist/documents.js.map +1 -0
  57. package/dist/emitter.d.ts +15 -0
  58. package/dist/emitter.d.ts.map +1 -0
  59. package/dist/emitter.js +90 -0
  60. package/dist/emitter.js.map +1 -0
  61. package/dist/errors.d.ts +18 -0
  62. package/dist/errors.d.ts.map +1 -0
  63. package/dist/errors.js +22 -0
  64. package/dist/errors.js.map +1 -0
  65. package/dist/export-guest.d.ts +9 -0
  66. package/dist/export-guest.d.ts.map +1 -0
  67. package/dist/export-guest.js +225 -0
  68. package/dist/export-guest.js.map +1 -0
  69. package/dist/fetch-retry.d.ts +16 -0
  70. package/dist/fetch-retry.d.ts.map +1 -0
  71. package/dist/fetch-retry.js +90 -0
  72. package/dist/fetch-retry.js.map +1 -0
  73. package/dist/guest-link.d.ts +3 -0
  74. package/dist/guest-link.d.ts.map +1 -0
  75. package/dist/guest-link.js +13 -0
  76. package/dist/guest-link.js.map +1 -0
  77. package/dist/handles.d.ts +57 -0
  78. package/dist/handles.d.ts.map +1 -0
  79. package/dist/handles.js +1375 -0
  80. package/dist/handles.js.map +1 -0
  81. package/dist/hosted-runtime.d.ts +33 -0
  82. package/dist/hosted-runtime.d.ts.map +1 -0
  83. package/dist/hosted-runtime.js +99 -0
  84. package/dist/hosted-runtime.js.map +1 -0
  85. package/dist/hosted-ui.d.ts +84 -0
  86. package/dist/hosted-ui.d.ts.map +1 -0
  87. package/dist/hosted-ui.js +673 -0
  88. package/dist/hosted-ui.js.map +1 -0
  89. package/dist/importer.d.ts +22 -0
  90. package/dist/importer.d.ts.map +1 -0
  91. package/dist/importer.js +54 -0
  92. package/dist/importer.js.map +1 -0
  93. package/dist/index.d.ts +54 -0
  94. package/dist/index.d.ts.map +1 -0
  95. package/dist/index.js +132 -0
  96. package/dist/index.js.map +1 -0
  97. package/dist/init-keys.d.ts +3 -0
  98. package/dist/init-keys.d.ts.map +1 -0
  99. package/dist/init-keys.js +13 -0
  100. package/dist/init-keys.js.map +1 -0
  101. package/dist/internal.d.ts +6 -0
  102. package/dist/internal.d.ts.map +1 -0
  103. package/dist/internal.js +17 -0
  104. package/dist/internal.js.map +1 -0
  105. package/dist/jwt.d.ts +3 -0
  106. package/dist/jwt.d.ts.map +1 -0
  107. package/dist/jwt.js +22 -0
  108. package/dist/jwt.js.map +1 -0
  109. package/dist/launch-context.d.ts +4 -0
  110. package/dist/launch-context.d.ts.map +1 -0
  111. package/dist/launch-context.js +44 -0
  112. package/dist/launch-context.js.map +1 -0
  113. package/dist/login-hint.d.ts +4 -0
  114. package/dist/login-hint.d.ts.map +1 -0
  115. package/dist/login-hint.js +63 -0
  116. package/dist/login-hint.js.map +1 -0
  117. package/dist/mutate-batching.d.ts +14 -0
  118. package/dist/mutate-batching.d.ts.map +1 -0
  119. package/dist/mutate-batching.js +65 -0
  120. package/dist/mutate-batching.js.map +1 -0
  121. package/dist/open-document.d.ts +127 -0
  122. package/dist/open-document.d.ts.map +1 -0
  123. package/dist/open-document.js +2121 -0
  124. package/dist/open-document.js.map +1 -0
  125. package/dist/page-title.d.ts +3 -0
  126. package/dist/page-title.d.ts.map +1 -0
  127. package/dist/page-title.js +32 -0
  128. package/dist/page-title.js.map +1 -0
  129. package/dist/perf.d.ts +9 -0
  130. package/dist/perf.d.ts.map +1 -0
  131. package/dist/perf.js +39 -0
  132. package/dist/perf.js.map +1 -0
  133. package/dist/presence.d.ts +62 -0
  134. package/dist/presence.d.ts.map +1 -0
  135. package/dist/presence.js +331 -0
  136. package/dist/presence.js.map +1 -0
  137. package/dist/record-paging.d.ts +99 -0
  138. package/dist/record-paging.d.ts.map +1 -0
  139. package/dist/record-paging.js +220 -0
  140. package/dist/record-paging.js.map +1 -0
  141. package/dist/schema-conflict-dialog.d.ts +10 -0
  142. package/dist/schema-conflict-dialog.d.ts.map +1 -0
  143. package/dist/schema-conflict-dialog.js +164 -0
  144. package/dist/schema-conflict-dialog.js.map +1 -0
  145. package/dist/selection-persistence.d.ts +5 -0
  146. package/dist/selection-persistence.d.ts.map +1 -0
  147. package/dist/selection-persistence.js +90 -0
  148. package/dist/selection-persistence.js.map +1 -0
  149. package/dist/serializer.d.ts +3 -0
  150. package/dist/serializer.d.ts.map +1 -0
  151. package/dist/serializer.js +2 -0
  152. package/dist/serializer.js.map +1 -0
  153. package/dist/session-persistence.d.ts +4 -0
  154. package/dist/session-persistence.d.ts.map +1 -0
  155. package/dist/session-persistence.js +68 -0
  156. package/dist/session-persistence.js.map +1 -0
  157. package/dist/session-toolbar.d.ts +62 -0
  158. package/dist/session-toolbar.d.ts.map +1 -0
  159. package/dist/session-toolbar.js +1205 -0
  160. package/dist/session-toolbar.js.map +1 -0
  161. package/dist/session.d.ts +69 -0
  162. package/dist/session.d.ts.map +1 -0
  163. package/dist/session.js +926 -0
  164. package/dist/session.js.map +1 -0
  165. package/dist/tail-ops.d.ts +28 -0
  166. package/dist/tail-ops.d.ts.map +1 -0
  167. package/dist/tail-ops.js +46 -0
  168. package/dist/tail-ops.js.map +1 -0
  169. package/dist/topic-lifecycle.d.ts +41 -0
  170. package/dist/topic-lifecycle.d.ts.map +1 -0
  171. package/dist/topic-lifecycle.js +169 -0
  172. package/dist/topic-lifecycle.js.map +1 -0
  173. package/dist/types.d.ts +22 -0
  174. package/dist/types.d.ts.map +1 -0
  175. package/dist/types.js +2 -0
  176. package/dist/types.js.map +1 -0
  177. package/dist/undo.d.ts +18 -0
  178. package/dist/undo.d.ts.map +1 -0
  179. package/dist/undo.js +110 -0
  180. package/dist/undo.js.map +1 -0
  181. package/dist/version-bar.d.ts +22 -0
  182. package/dist/version-bar.d.ts.map +1 -0
  183. package/dist/version-bar.js +169 -0
  184. package/dist/version-bar.js.map +1 -0
  185. package/dist/version.d.ts +3 -0
  186. package/dist/version.d.ts.map +1 -0
  187. package/dist/version.js +8 -0
  188. package/dist/version.js.map +1 -0
  189. package/dist/vite.d.ts +15 -0
  190. package/dist/vite.d.ts.map +1 -0
  191. package/dist/vite.js +36 -0
  192. package/dist/vite.js.map +1 -0
  193. package/dist/ws-client.d.ts +53 -0
  194. package/dist/ws-client.d.ts.map +1 -0
  195. package/dist/ws-client.js +604 -0
  196. package/dist/ws-client.js.map +1 -0
  197. package/dist/ws-frame-limit.d.ts +21 -0
  198. package/dist/ws-frame-limit.d.ts.map +1 -0
  199. package/dist/ws-frame-limit.js +40 -0
  200. package/dist/ws-frame-limit.js.map +1 -0
  201. package/docs/app-manifest.md +65 -0
  202. package/docs/custom-chrome.md +147 -0
  203. package/docs/hosted-ui-advanced.md +77 -0
  204. package/docs/imported-documents.md +261 -0
  205. package/docs/presence.md +129 -0
  206. package/docs/schemas.md +135 -0
  207. package/docs/streams.md +248 -0
  208. package/package.json +60 -0
  209. package/scripts/prepare.mjs +50 -0
  210. package/src/api-fetch.ts +127 -0
  211. package/src/api-headers.ts +28 -0
  212. package/src/auth-flow.ts +125 -0
  213. package/src/client.ts +1457 -0
  214. package/src/doc-url-sync.ts +183 -0
  215. package/src/document-branches.ts +124 -0
  216. package/src/document-checkpoints.ts +135 -0
  217. package/src/document-commands.ts +651 -0
  218. package/src/document-signals.ts +234 -0
  219. package/src/document-streams.ts +132 -0
  220. package/src/document-thumbnail-capture.ts +191 -0
  221. package/src/documents-namespace.ts +516 -0
  222. package/src/documents.ts +2426 -0
  223. package/src/emitter.ts +129 -0
  224. package/src/errors.ts +45 -0
  225. package/src/export-guest.ts +282 -0
  226. package/src/fetch-retry.ts +119 -0
  227. package/src/guest-link.ts +14 -0
  228. package/src/handles.ts +1731 -0
  229. package/src/hosted-runtime.ts +109 -0
  230. package/src/hosted-ui.ts +1020 -0
  231. package/src/importer.ts +89 -0
  232. package/src/index.ts +267 -0
  233. package/src/init-keys.ts +15 -0
  234. package/src/internal.ts +23 -0
  235. package/src/jwt.ts +18 -0
  236. package/src/launch-context.ts +43 -0
  237. package/src/login-hint.ts +60 -0
  238. package/src/mutate-batching.ts +63 -0
  239. package/src/open-document.ts +2869 -0
  240. package/src/page-title.ts +33 -0
  241. package/src/perf.ts +46 -0
  242. package/src/presence.ts +471 -0
  243. package/src/record-paging.ts +270 -0
  244. package/src/schema-conflict-dialog.ts +176 -0
  245. package/src/selection-persistence.ts +98 -0
  246. package/src/serializer.ts +15 -0
  247. package/src/session-persistence.ts +69 -0
  248. package/src/session-toolbar.ts +1457 -0
  249. package/src/session.ts +1262 -0
  250. package/src/tail-ops.ts +63 -0
  251. package/src/topic-lifecycle.ts +217 -0
  252. package/src/types.ts +75 -0
  253. package/src/undo.ts +125 -0
  254. package/src/version-bar.ts +221 -0
  255. package/src/version.ts +8 -0
  256. package/src/vite.ts +49 -0
  257. package/src/ws-client.ts +678 -0
  258. package/src/ws-frame-limit.ts +52 -0
@@ -0,0 +1,183 @@
1
+ // Sync of the active document selection (id, plus branch when not the
2
+ // default) to the page URL. `history.replaceState` pins the current
3
+ // selection in place (boot restore, direct open); `history.pushState`
4
+ // records a genuine navigation so Back / Forward step between the
5
+ // documents and branches a user opened. Restored from the URL on boot
6
+ // and on `popstate`.
7
+ //
8
+ // Query parameters, not path segments, because the host app owns its
9
+ // own routing: a query param coexists with whatever route it is on,
10
+ // whereas rewriting the path would collide with its router.
11
+ //
12
+ // All operations are best-effort and no-op outside a browser or when
13
+ // the History API is unavailable (safe from SSR / Node tests).
14
+
15
+ export const DEFAULT_DOC_URL_PARAM = 'doc';
16
+ export const DEFAULT_BRANCH_URL_PARAM = 'branch';
17
+
18
+ // One-shot params the hosted picker's redirect fallback returns the pick
19
+ // on. Distinct from the app-facing `doc` / `branch` sync params so the
20
+ // hand-off works even when the app has turned URL sync off (or renamed
21
+ // those params): the SDK consumes these on boot regardless of
22
+ // `syncDocToUrl`, opens the document, and strips them so they never
23
+ // linger in the app's URL. Named with a `brass_` prefix so they can't
24
+ // collide with an app's own query params.
25
+ export const PICK_DOC_PARAM = 'brass_pick';
26
+ export const PICK_BRANCH_PARAM = 'brass_pick_branch';
27
+
28
+ // The active selection as carried in the URL. `branchId` is null for
29
+ // the default branch, which is never written to the URL.
30
+ export interface UrlSelection {
31
+ docId: string;
32
+ branchId: string | null;
33
+ }
34
+
35
+ function getWindow(): (Window & typeof globalThis) | null {
36
+ if (typeof window === 'undefined') return null;
37
+ return window;
38
+ }
39
+
40
+ export function readDocIdFromUrl(param: string): string | null {
41
+ const w = getWindow();
42
+ if (!w) return null;
43
+ try {
44
+ const url = new URL(w.location.href);
45
+ const value = url.searchParams.get(param);
46
+ return value && value.length > 0 ? value : null;
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+
52
+ export function writeDocIdToUrl(docId: string, param: string): void {
53
+ const w = getWindow();
54
+ if (!w) return;
55
+ try {
56
+ const url = new URL(w.location.href);
57
+ if (url.searchParams.get(param) === docId) return;
58
+ url.searchParams.set(param, docId);
59
+ w.history.replaceState(w.history.state, '', url.toString());
60
+ } catch {
61
+ // History API unavailable or URL exotic: leave the URL alone.
62
+ }
63
+ }
64
+
65
+ export function clearDocIdFromUrl(param: string): void {
66
+ const w = getWindow();
67
+ if (!w) return;
68
+ try {
69
+ const url = new URL(w.location.href);
70
+ if (!url.searchParams.has(param)) return;
71
+ url.searchParams.delete(param);
72
+ w.history.replaceState(w.history.state, '', url.toString());
73
+ } catch {
74
+ // See `writeDocIdToUrl`.
75
+ }
76
+ }
77
+
78
+ // Read the active selection from the URL. Returns null when no document
79
+ // is named, so a branch param alone never resurrects a selection.
80
+ export function readUrlSelection(
81
+ docParam: string,
82
+ branchParam: string,
83
+ ): UrlSelection | null {
84
+ const w = getWindow();
85
+ if (!w) return null;
86
+ try {
87
+ const url = new URL(w.location.href);
88
+ const docId = url.searchParams.get(docParam);
89
+ if (!docId || docId.length === 0) return null;
90
+ const branch = url.searchParams.get(branchParam);
91
+ return { docId, branchId: branch && branch.length > 0 ? branch : null };
92
+ } catch {
93
+ return null;
94
+ }
95
+ }
96
+
97
+ // Write the active selection to the URL. `push` records a genuine
98
+ // navigation; otherwise the current entry is replaced in place. The
99
+ // branch param is set only for a non-default branch, so a default-branch
100
+ // selection reads back as a bare `?doc=`. A write whose resulting URL
101
+ // matches the current one is skipped, so a redundant push never plants
102
+ // a duplicate entry.
103
+ export function writeUrlSelection(
104
+ selection: UrlSelection,
105
+ docParam: string,
106
+ branchParam: string,
107
+ options: { push: boolean },
108
+ ): void {
109
+ const w = getWindow();
110
+ if (!w) return;
111
+ try {
112
+ const url = new URL(w.location.href);
113
+ url.searchParams.set(docParam, selection.docId);
114
+ if (selection.branchId !== null) {
115
+ url.searchParams.set(branchParam, selection.branchId);
116
+ } else {
117
+ url.searchParams.delete(branchParam);
118
+ }
119
+ const next = url.toString();
120
+ if (next === w.location.href) return;
121
+ if (options.push) {
122
+ w.history.pushState(w.history.state, '', next);
123
+ } else {
124
+ w.history.replaceState(w.history.state, '', next);
125
+ }
126
+ } catch {
127
+ // History API unavailable or URL exotic: leave the URL alone.
128
+ }
129
+ }
130
+
131
+ // Clear the active selection from the URL via `replaceState` (a clear
132
+ // is never a navigation, so it pushes no history entry).
133
+ export function clearUrlSelection(docParam: string, branchParam: string): void {
134
+ const w = getWindow();
135
+ if (!w) return;
136
+ try {
137
+ const url = new URL(w.location.href);
138
+ if (!url.searchParams.has(docParam) && !url.searchParams.has(branchParam)) {
139
+ return;
140
+ }
141
+ url.searchParams.delete(docParam);
142
+ url.searchParams.delete(branchParam);
143
+ w.history.replaceState(w.history.state, '', url.toString());
144
+ } catch {
145
+ // See `writeUrlSelection`.
146
+ }
147
+ }
148
+
149
+ // Read and CONSUME the hosted picker's one-shot pick from the URL: return
150
+ // the selection the redirect fallback returned, and strip both pick params
151
+ // in the same call (via `replaceState`, never a navigation) so a reload
152
+ // can't reopen the pick and the params don't leak into the app's URL.
153
+ // Returns null when no pick is present (the ordinary boot). Runs
154
+ // independently of `syncDocToUrl`, since the redirect needs to hand a pick
155
+ // back even to an app that manages its own selection.
156
+ export function takePickFromUrl(): UrlSelection | null {
157
+ const w = getWindow();
158
+ if (!w) return null;
159
+ try {
160
+ const url = new URL(w.location.href);
161
+ const docId = url.searchParams.get(PICK_DOC_PARAM);
162
+ if (!docId || docId.length === 0) return null;
163
+ const branch = url.searchParams.get(PICK_BRANCH_PARAM);
164
+ const branchId = branch && branch.length > 0 ? branch : null;
165
+ url.searchParams.delete(PICK_DOC_PARAM);
166
+ url.searchParams.delete(PICK_BRANCH_PARAM);
167
+ w.history.replaceState(w.history.state, '', url.toString());
168
+ return { docId, branchId };
169
+ } catch {
170
+ return null;
171
+ }
172
+ }
173
+
174
+ // Subscribe to Back / Forward navigation. No-op outside a browser.
175
+ // Only user-driven Back / Forward fires `popstate`; the SDK's own
176
+ // push / replace writes do not, so a restore on `popstate` cannot feed
177
+ // back into another history write.
178
+ export function subscribePopState(listener: () => void): () => void {
179
+ const w = getWindow();
180
+ if (!w) return () => {};
181
+ w.addEventListener('popstate', listener);
182
+ return () => w.removeEventListener('popstate', listener);
183
+ }
@@ -0,0 +1,124 @@
1
+ // Document branches (alternate states of the same design): the reactive
2
+ // list, the injected HTTP provider that lists / creates them, and the
3
+ // optimistic-append on create.
4
+
5
+ import type {
6
+ BranchSummary,
7
+ CreateBranchRequest,
8
+ } from './documents-namespace.js';
9
+
10
+ // Injected HTTP capability the controller's branch methods call through.
11
+ export interface BranchProvider {
12
+ list(docId: string): Promise<{
13
+ default_branch_id: string;
14
+ branches: BranchSummary[];
15
+ }>;
16
+ create(docId: string, input: CreateBranchRequest): Promise<BranchSummary>;
17
+ }
18
+
19
+ export interface BranchControllerHost {
20
+ // Server identity; `null` on the offline / test path.
21
+ docId(): string | null;
22
+ // The branch the Document is open on (a new branch forks from here).
23
+ // `null` on the offline / test path.
24
+ branchId(): string | null;
25
+ // True when the doc is live (not closed / suspended).
26
+ isActive(): boolean;
27
+ // Fire the Document's `branches` subscribe event so a branch picker
28
+ // re-reads inside the existing subscribe handler.
29
+ onBranchesChanged(branches: readonly BranchSummary[]): void;
30
+ }
31
+
32
+ export class BranchController {
33
+ // Reactive branches list. Set by the factory's initial fetch, then by
34
+ // `refresh` and `create`. Empty until that fetch resolves.
35
+ private _branches: readonly BranchSummary[] = [];
36
+ private _defaultBranchId: string | null = null;
37
+ // `null` on the offline / test path with no transport.
38
+ private _provider: BranchProvider | null = null;
39
+
40
+ constructor(private readonly host: BranchControllerHost) {}
41
+
42
+ get branches(): readonly BranchSummary[] {
43
+ return this._branches;
44
+ }
45
+
46
+ get defaultBranchId(): string | null {
47
+ return this._defaultBranchId;
48
+ }
49
+
50
+ // Whether a provider is attached. The file-menu's branches submenu
51
+ // reads this to decide whether to offer "New branch...".
52
+ get hasProvider(): boolean {
53
+ return this._provider !== null;
54
+ }
55
+
56
+ // Set the branches list and fire the `branches` subscribe event.
57
+ set(
58
+ branches: readonly BranchSummary[],
59
+ defaultBranchId: string | null,
60
+ ): void {
61
+ this._branches = branches;
62
+ this._defaultBranchId = defaultBranchId;
63
+ this.host.onBranchesChanged(branches);
64
+ }
65
+
66
+ setProvider(provider: BranchProvider | null): void {
67
+ this._provider = provider;
68
+ }
69
+
70
+ // Re-fetch the branches list from the server. Useful after an
71
+ // out-of-band change another peer made; not normally needed because
72
+ // `create` updates the list automatically.
73
+ async refresh(): Promise<void> {
74
+ if (!this.host.isActive()) return;
75
+ const docId = this.host.docId();
76
+ if (docId === null || this._provider === null) return;
77
+ const { default_branch_id, branches } = await this._provider.list(docId);
78
+ if (!this.host.isActive()) return;
79
+ this.set(branches, default_branch_id);
80
+ }
81
+
82
+ // Create a new branch under this document, forked from the branch the
83
+ // Document is open on. Updates `branches` on success (optimistic
84
+ // append; the next refresh reconciles any drift).
85
+ async create(input: {
86
+ name?: string;
87
+ from_version?: number;
88
+ }): Promise<BranchSummary> {
89
+ if (!this.host.isActive()) {
90
+ throw new Error('Document is closed');
91
+ }
92
+ const docId = this.host.docId();
93
+ const branchId = this.host.branchId();
94
+ if (docId === null) {
95
+ throw new Error('createBranch: Document has no server identity');
96
+ }
97
+ if (branchId === null) {
98
+ throw new Error('createBranch: Document has no branch');
99
+ }
100
+ if (this._provider === null) {
101
+ throw new Error(
102
+ 'createBranch: no provider attached. Opened via the SDK factory?',
103
+ );
104
+ }
105
+ const request: CreateBranchRequest = {
106
+ from_branch_id: branchId,
107
+ };
108
+ if (input.name !== undefined) request.name = input.name;
109
+ if (input.from_version !== undefined) {
110
+ request.from_version = input.from_version;
111
+ }
112
+ const branch = await this._provider.create(docId, request);
113
+ // Re-check liveness after the await, exactly as `refresh` does: a
114
+ // `close()` in flight nulls the host's Loro subscription and frees its
115
+ // doc, so firing the `branches` change (which re-attaches that
116
+ // subscription) would run against a freed document. The create already
117
+ // landed server-side, so still return the branch; just skip the
118
+ // reactive `set` a torn-down document can't consume.
119
+ if (!this.host.isActive()) return branch;
120
+ const next: BranchSummary[] = [...this._branches, branch];
121
+ this.set(next, this._defaultBranchId);
122
+ return branch;
123
+ }
124
+ }
@@ -0,0 +1,135 @@
1
+ // Document version-history checkpoints: app-driven `createCheckpoint`
2
+ // plus an implicit auto-checkpoint debounce that seals a marker after a
3
+ // burst of local edits settles. Both also seal the undo step, unifying
4
+ // "checkpoint" and "undo boundary": a deliberate save, an auto-seal on
5
+ // idle, and a modeler's pointerUp all produce the same Ctrl-Z boundary.
6
+
7
+ import type { DocumentCheckpoint } from './documents-namespace.js';
8
+
9
+ export type CheckpointCreator = () => Promise<DocumentCheckpoint>;
10
+
11
+ // Default auto-checkpoint debounce: sealed this long after the last
12
+ // 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
15
+ // `doc.setAutoCheckpointIntervalMs(...)`.
16
+ const AUTO_CHECKPOINT_DEFAULT_MS = 60_000;
17
+
18
+ export interface CheckpointSchedulerHost {
19
+ // Server identity; `null` on offline / test docs.
20
+ docId(): string | null;
21
+ // True when the doc is live (not closed / suspended). A manual
22
+ // `createCheckpoint` throws "Document is closed" otherwise.
23
+ isAttached(): boolean;
24
+ // True when an auto-checkpoint may fire: live AND at head (a
25
+ // historical checkout must not seal markers).
26
+ isAutoAllowed(): boolean;
27
+ // The version this Document shows (live-head seq or frozen checkout
28
+ // version). The checkpoint anchors here.
29
+ currentSeq(): number | null;
30
+ // Seal the current undo step. Safe to call when undo is absent.
31
+ sealUndoStep(): void;
32
+ }
33
+
34
+ export class CheckpointScheduler {
35
+ // Injected checkpoint-create hook (the Document owns no HTTP client).
36
+ // `null` on offline / test paths; a manual `create` then throws.
37
+ private _creator: CheckpointCreator | null = null;
38
+ // Debounce timer + bookkeeping. `_autoIntervalMs === 0` disables it;
39
+ // `_lastSeq` skips a fire that would clobber a manual checkpoint at
40
+ // the same seq.
41
+ private _autoTimer: ReturnType<typeof setTimeout> | null = null;
42
+ private _autoIntervalMs: number = AUTO_CHECKPOINT_DEFAULT_MS;
43
+ private _lastSeq: number | null = null;
44
+
45
+ constructor(private readonly host: CheckpointSchedulerHost) {}
46
+
47
+ setCreator(creator: CheckpointCreator | null): void {
48
+ this._creator = creator;
49
+ }
50
+
51
+ // Seal a checkpoint anchored to `host.currentSeq()`.
52
+ async create(): Promise<DocumentCheckpoint> {
53
+ if (!this.host.isAttached()) {
54
+ throw new Error('Document is closed');
55
+ }
56
+ if (this.host.docId() === null) {
57
+ throw new Error('createCheckpoint: Document has no server identity');
58
+ }
59
+ if (this._creator === null) {
60
+ throw new Error(
61
+ 'createCheckpoint: no creator attached. Opened via the SDK factory?',
62
+ );
63
+ }
64
+ // Seal BEFORE the round-trip: the seal is synchronous, so mutates
65
+ // arriving during the await start a fresh undo step rather than
66
+ // folding into the pre-checkpoint one.
67
+ this.host.sealUndoStep();
68
+ const result = await this._creator();
69
+ // Record the seq so a pending auto-checkpoint at the same version
70
+ // skips its fire. The server Put is unconditional (last-write-wins),
71
+ // so without this an idle period right after a manual save would
72
+ // replace the manual entry with an auto one.
73
+ this._lastSeq = result.version;
74
+ return result;
75
+ }
76
+
77
+ // Configure the auto-checkpoint debounce; `0` disables. Returns the
78
+ // prior value so a scoped override can restore.
79
+ setAutoIntervalMs(intervalMs: number): number {
80
+ if (!Number.isFinite(intervalMs) || intervalMs < 0) {
81
+ throw new Error('setAutoCheckpointIntervalMs: intervalMs must be >= 0');
82
+ }
83
+ const prev = this._autoIntervalMs;
84
+ this._autoIntervalMs = intervalMs;
85
+ if (intervalMs === 0 && this._autoTimer !== null) {
86
+ clearTimeout(this._autoTimer);
87
+ this._autoTimer = null;
88
+ }
89
+ return prev;
90
+ }
91
+
92
+ // Called from the Document's local-commit dispatch. Debounces so a
93
+ // burst of edits produces ONE marker when it settles. Skips when the
94
+ // interval is 0 or there is no creator; the fire's guard re-checks
95
+ // closed / suspended / checkout.
96
+ scheduleAuto(): void {
97
+ if (this._autoIntervalMs === 0) return;
98
+ if (this._creator === null) return;
99
+ if (this._autoTimer !== null) {
100
+ clearTimeout(this._autoTimer);
101
+ }
102
+ this._autoTimer = setTimeout(() => {
103
+ this._autoTimer = null;
104
+ void this._fireAuto();
105
+ }, this._autoIntervalMs);
106
+ }
107
+
108
+ // Cancel a pending auto-checkpoint (suspend / close).
109
+ cancelAuto(): void {
110
+ if (this._autoTimer !== null) {
111
+ clearTimeout(this._autoTimer);
112
+ this._autoTimer = null;
113
+ }
114
+ }
115
+
116
+ private async _fireAuto(): Promise<void> {
117
+ if (!this.host.isAutoAllowed()) return;
118
+ if (this._creator === null) return;
119
+ const seq = this.host.currentSeq();
120
+ if (seq === null) return;
121
+ // Already checkpointed at this exact version (manual seal a moment
122
+ // ago).
123
+ if (this._lastSeq === seq) return;
124
+ // Seal undo before the write so the user gets the Ctrl-Z boundary at
125
+ // their idle point even if the server write below fails.
126
+ this.host.sealUndoStep();
127
+ try {
128
+ const result = await this._creator();
129
+ this._lastSeq = result.version;
130
+ } catch {
131
+ // Best-effort: a rejection (rate limit, no write access, network)
132
+ // is swallowed; the next local edit reschedules.
133
+ }
134
+ }
135
+ }