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