@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.
- package/AGENTS.md +255 -0
- package/CHANGELOG.md +15 -0
- package/LICENSE +21 -0
- package/README.md +836 -0
- package/dist/api-fetch.d.ts +16 -0
- package/dist/api-fetch.d.ts.map +1 -0
- package/dist/api-fetch.js +97 -0
- package/dist/api-fetch.js.map +1 -0
- package/dist/api-headers.d.ts +3 -0
- package/dist/api-headers.d.ts.map +1 -0
- package/dist/api-headers.js +25 -0
- package/dist/api-headers.js.map +1 -0
- package/dist/auth-flow.d.ts +23 -0
- package/dist/auth-flow.d.ts.map +1 -0
- package/dist/auth-flow.js +76 -0
- package/dist/auth-flow.js.map +1 -0
- package/dist/client.d.ts +106 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +1278 -0
- package/dist/client.js.map +1 -0
- package/dist/doc-url-sync.d.ts +19 -0
- package/dist/doc-url-sync.d.ts.map +1 -0
- package/dist/doc-url-sync.js +181 -0
- package/dist/doc-url-sync.js.map +1 -0
- package/dist/document-branches.d.ts +32 -0
- package/dist/document-branches.d.ts.map +1 -0
- package/dist/document-branches.js +89 -0
- package/dist/document-branches.js.map +1 -0
- package/dist/document-checkpoints.d.ts +24 -0
- package/dist/document-checkpoints.d.ts.map +1 -0
- package/dist/document-checkpoints.js +115 -0
- package/dist/document-checkpoints.js.map +1 -0
- package/dist/document-commands.d.ts +93 -0
- package/dist/document-commands.d.ts.map +1 -0
- package/dist/document-commands.js +423 -0
- package/dist/document-commands.js.map +1 -0
- package/dist/document-signals.d.ts +53 -0
- package/dist/document-signals.d.ts.map +1 -0
- package/dist/document-signals.js +114 -0
- package/dist/document-signals.js.map +1 -0
- package/dist/document-streams.d.ts +22 -0
- package/dist/document-streams.d.ts.map +1 -0
- package/dist/document-streams.js +98 -0
- package/dist/document-streams.js.map +1 -0
- package/dist/document-thumbnail-capture.d.ts +27 -0
- package/dist/document-thumbnail-capture.d.ts.map +1 -0
- package/dist/document-thumbnail-capture.js +160 -0
- package/dist/document-thumbnail-capture.js.map +1 -0
- package/dist/documents-namespace.d.ts +134 -0
- package/dist/documents-namespace.d.ts.map +1 -0
- package/dist/documents-namespace.js +190 -0
- package/dist/documents-namespace.js.map +1 -0
- package/dist/documents.d.ts +146 -0
- package/dist/documents.d.ts.map +1 -0
- package/dist/documents.js +2045 -0
- package/dist/documents.js.map +1 -0
- package/dist/emitter.d.ts +15 -0
- package/dist/emitter.d.ts.map +1 -0
- package/dist/emitter.js +90 -0
- package/dist/emitter.js.map +1 -0
- package/dist/errors.d.ts +18 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +22 -0
- package/dist/errors.js.map +1 -0
- package/dist/export-guest.d.ts +9 -0
- package/dist/export-guest.d.ts.map +1 -0
- package/dist/export-guest.js +225 -0
- package/dist/export-guest.js.map +1 -0
- package/dist/fetch-retry.d.ts +16 -0
- package/dist/fetch-retry.d.ts.map +1 -0
- package/dist/fetch-retry.js +90 -0
- package/dist/fetch-retry.js.map +1 -0
- package/dist/guest-link.d.ts +3 -0
- package/dist/guest-link.d.ts.map +1 -0
- package/dist/guest-link.js +13 -0
- package/dist/guest-link.js.map +1 -0
- package/dist/handles.d.ts +57 -0
- package/dist/handles.d.ts.map +1 -0
- package/dist/handles.js +1375 -0
- package/dist/handles.js.map +1 -0
- package/dist/hosted-runtime.d.ts +33 -0
- package/dist/hosted-runtime.d.ts.map +1 -0
- package/dist/hosted-runtime.js +99 -0
- package/dist/hosted-runtime.js.map +1 -0
- package/dist/hosted-ui.d.ts +84 -0
- package/dist/hosted-ui.d.ts.map +1 -0
- package/dist/hosted-ui.js +673 -0
- package/dist/hosted-ui.js.map +1 -0
- package/dist/importer.d.ts +22 -0
- package/dist/importer.d.ts.map +1 -0
- package/dist/importer.js +54 -0
- package/dist/importer.js.map +1 -0
- package/dist/index.d.ts +54 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +132 -0
- package/dist/index.js.map +1 -0
- package/dist/init-keys.d.ts +3 -0
- package/dist/init-keys.d.ts.map +1 -0
- package/dist/init-keys.js +13 -0
- package/dist/init-keys.js.map +1 -0
- package/dist/internal.d.ts +6 -0
- package/dist/internal.d.ts.map +1 -0
- package/dist/internal.js +17 -0
- package/dist/internal.js.map +1 -0
- package/dist/jwt.d.ts +3 -0
- package/dist/jwt.d.ts.map +1 -0
- package/dist/jwt.js +22 -0
- package/dist/jwt.js.map +1 -0
- package/dist/launch-context.d.ts +4 -0
- package/dist/launch-context.d.ts.map +1 -0
- package/dist/launch-context.js +44 -0
- package/dist/launch-context.js.map +1 -0
- package/dist/login-hint.d.ts +4 -0
- package/dist/login-hint.d.ts.map +1 -0
- package/dist/login-hint.js +63 -0
- package/dist/login-hint.js.map +1 -0
- package/dist/mutate-batching.d.ts +14 -0
- package/dist/mutate-batching.d.ts.map +1 -0
- package/dist/mutate-batching.js +65 -0
- package/dist/mutate-batching.js.map +1 -0
- package/dist/open-document.d.ts +127 -0
- package/dist/open-document.d.ts.map +1 -0
- package/dist/open-document.js +2121 -0
- package/dist/open-document.js.map +1 -0
- package/dist/page-title.d.ts +3 -0
- package/dist/page-title.d.ts.map +1 -0
- package/dist/page-title.js +32 -0
- package/dist/page-title.js.map +1 -0
- package/dist/perf.d.ts +9 -0
- package/dist/perf.d.ts.map +1 -0
- package/dist/perf.js +39 -0
- package/dist/perf.js.map +1 -0
- package/dist/presence.d.ts +62 -0
- package/dist/presence.d.ts.map +1 -0
- package/dist/presence.js +331 -0
- package/dist/presence.js.map +1 -0
- package/dist/record-paging.d.ts +99 -0
- package/dist/record-paging.d.ts.map +1 -0
- package/dist/record-paging.js +220 -0
- package/dist/record-paging.js.map +1 -0
- package/dist/schema-conflict-dialog.d.ts +10 -0
- package/dist/schema-conflict-dialog.d.ts.map +1 -0
- package/dist/schema-conflict-dialog.js +164 -0
- package/dist/schema-conflict-dialog.js.map +1 -0
- package/dist/selection-persistence.d.ts +5 -0
- package/dist/selection-persistence.d.ts.map +1 -0
- package/dist/selection-persistence.js +90 -0
- package/dist/selection-persistence.js.map +1 -0
- package/dist/serializer.d.ts +3 -0
- package/dist/serializer.d.ts.map +1 -0
- package/dist/serializer.js +2 -0
- package/dist/serializer.js.map +1 -0
- package/dist/session-persistence.d.ts +4 -0
- package/dist/session-persistence.d.ts.map +1 -0
- package/dist/session-persistence.js +68 -0
- package/dist/session-persistence.js.map +1 -0
- package/dist/session-toolbar.d.ts +62 -0
- package/dist/session-toolbar.d.ts.map +1 -0
- package/dist/session-toolbar.js +1205 -0
- package/dist/session-toolbar.js.map +1 -0
- package/dist/session.d.ts +69 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +926 -0
- package/dist/session.js.map +1 -0
- package/dist/tail-ops.d.ts +28 -0
- package/dist/tail-ops.d.ts.map +1 -0
- package/dist/tail-ops.js +46 -0
- package/dist/tail-ops.js.map +1 -0
- package/dist/topic-lifecycle.d.ts +41 -0
- package/dist/topic-lifecycle.d.ts.map +1 -0
- package/dist/topic-lifecycle.js +169 -0
- package/dist/topic-lifecycle.js.map +1 -0
- package/dist/types.d.ts +22 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/undo.d.ts +18 -0
- package/dist/undo.d.ts.map +1 -0
- package/dist/undo.js +110 -0
- package/dist/undo.js.map +1 -0
- package/dist/version-bar.d.ts +22 -0
- package/dist/version-bar.d.ts.map +1 -0
- package/dist/version-bar.js +169 -0
- package/dist/version-bar.js.map +1 -0
- package/dist/version.d.ts +3 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +8 -0
- package/dist/version.js.map +1 -0
- package/dist/vite.d.ts +15 -0
- package/dist/vite.d.ts.map +1 -0
- package/dist/vite.js +36 -0
- package/dist/vite.js.map +1 -0
- package/dist/ws-client.d.ts +53 -0
- package/dist/ws-client.d.ts.map +1 -0
- package/dist/ws-client.js +604 -0
- package/dist/ws-client.js.map +1 -0
- package/dist/ws-frame-limit.d.ts +21 -0
- package/dist/ws-frame-limit.d.ts.map +1 -0
- package/dist/ws-frame-limit.js +40 -0
- package/dist/ws-frame-limit.js.map +1 -0
- package/docs/app-manifest.md +65 -0
- package/docs/custom-chrome.md +147 -0
- package/docs/hosted-ui-advanced.md +77 -0
- package/docs/imported-documents.md +261 -0
- package/docs/presence.md +129 -0
- package/docs/schemas.md +135 -0
- package/docs/streams.md +248 -0
- package/package.json +60 -0
- package/scripts/prepare.mjs +50 -0
- package/src/api-fetch.ts +127 -0
- package/src/api-headers.ts +28 -0
- package/src/auth-flow.ts +125 -0
- package/src/client.ts +1457 -0
- package/src/doc-url-sync.ts +183 -0
- package/src/document-branches.ts +124 -0
- package/src/document-checkpoints.ts +135 -0
- package/src/document-commands.ts +651 -0
- package/src/document-signals.ts +234 -0
- package/src/document-streams.ts +132 -0
- package/src/document-thumbnail-capture.ts +191 -0
- package/src/documents-namespace.ts +516 -0
- package/src/documents.ts +2426 -0
- package/src/emitter.ts +129 -0
- package/src/errors.ts +45 -0
- package/src/export-guest.ts +282 -0
- package/src/fetch-retry.ts +119 -0
- package/src/guest-link.ts +14 -0
- package/src/handles.ts +1731 -0
- package/src/hosted-runtime.ts +109 -0
- package/src/hosted-ui.ts +1020 -0
- package/src/importer.ts +89 -0
- package/src/index.ts +267 -0
- package/src/init-keys.ts +15 -0
- package/src/internal.ts +23 -0
- package/src/jwt.ts +18 -0
- package/src/launch-context.ts +43 -0
- package/src/login-hint.ts +60 -0
- package/src/mutate-batching.ts +63 -0
- package/src/open-document.ts +2869 -0
- package/src/page-title.ts +33 -0
- package/src/perf.ts +46 -0
- package/src/presence.ts +471 -0
- package/src/record-paging.ts +270 -0
- package/src/schema-conflict-dialog.ts +176 -0
- package/src/selection-persistence.ts +98 -0
- package/src/serializer.ts +15 -0
- package/src/session-persistence.ts +69 -0
- package/src/session-toolbar.ts +1457 -0
- package/src/session.ts +1262 -0
- package/src/tail-ops.ts +63 -0
- package/src/topic-lifecycle.ts +217 -0
- package/src/types.ts +75 -0
- package/src/undo.ts +125 -0
- package/src/version-bar.ts +221 -0
- package/src/version.ts +8 -0
- package/src/vite.ts +49 -0
- package/src/ws-client.ts +678 -0
- 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
|
+
}
|