@brass-build/client 0.2.0 → 0.3.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/CHANGELOG.md +11 -0
- package/README.md +16 -12
- package/dist/client.d.ts +1 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +28 -1
- package/dist/client.js.map +1 -1
- package/dist/document-checkpoints.d.ts +1 -0
- package/dist/document-checkpoints.d.ts.map +1 -1
- package/dist/document-checkpoints.js +27 -0
- package/dist/document-checkpoints.js.map +1 -1
- package/dist/document-signals.d.ts +31 -1
- package/dist/document-signals.d.ts.map +1 -1
- package/dist/document-signals.js +59 -0
- package/dist/document-signals.js.map +1 -1
- package/dist/document-thumbnail-capture.d.ts +20 -0
- package/dist/document-thumbnail-capture.d.ts.map +1 -1
- package/dist/document-thumbnail-capture.js +247 -9
- package/dist/document-thumbnail-capture.js.map +1 -1
- package/dist/documents-namespace.d.ts.map +1 -1
- package/dist/documents-namespace.js +3 -0
- package/dist/documents-namespace.js.map +1 -1
- package/dist/documents.d.ts +6 -2
- package/dist/documents.d.ts.map +1 -1
- package/dist/documents.js +110 -14
- package/dist/documents.js.map +1 -1
- package/dist/handles.d.ts.map +1 -1
- package/dist/handles.js +2 -2
- package/dist/handles.js.map +1 -1
- package/dist/importer.d.ts.map +1 -1
- package/dist/importer.js +11 -60
- package/dist/importer.js.map +1 -1
- package/dist/internal.d.ts +1 -0
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +1 -0
- package/dist/internal.js.map +1 -1
- package/dist/open-document.d.ts +15 -0
- package/dist/open-document.d.ts.map +1 -1
- package/dist/open-document.js +54 -1
- package/dist/open-document.js.map +1 -1
- package/dist/record-paging.d.ts +13 -14
- package/dist/record-paging.d.ts.map +1 -1
- package/dist/record-paging.js +13 -14
- package/dist/record-paging.js.map +1 -1
- package/dist/serializer.d.ts +3 -3
- package/dist/session-toolbar.d.ts.map +1 -1
- package/dist/session-toolbar.js +131 -9
- package/dist/session-toolbar.js.map +1 -1
- package/dist/version-bar.d.ts.map +1 -1
- package/dist/version-bar.js +23 -2
- package/dist/version-bar.js.map +1 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/dist/when-saved.d.ts +2 -0
- package/dist/when-saved.d.ts.map +1 -0
- package/dist/when-saved.js +70 -0
- package/dist/when-saved.js.map +1 -0
- package/dist/ws-client.d.ts +4 -0
- package/dist/ws-client.d.ts.map +1 -1
- package/dist/ws-client.js +70 -1
- package/dist/ws-client.js.map +1 -1
- package/dist/ws-frame-limit.d.ts.map +1 -1
- package/dist/ws-frame-limit.js +9 -10
- package/dist/ws-frame-limit.js.map +1 -1
- package/docs/custom-chrome.md +4 -2
- package/package.json +1 -1
- package/src/client.ts +37 -2
- package/src/document-checkpoints.ts +30 -0
- package/src/document-signals.ts +113 -1
- package/src/document-thumbnail-capture.ts +253 -9
- package/src/documents-namespace.ts +20 -10
- package/src/documents.ts +120 -13
- package/src/handles.ts +6 -5
- package/src/importer.ts +11 -56
- package/src/internal.ts +6 -0
- package/src/open-document.ts +100 -1
- package/src/record-paging.ts +13 -14
- package/src/serializer.ts +3 -3
- package/src/session-toolbar.ts +139 -10
- package/src/version-bar.ts +24 -5
- package/src/version.ts +1 -1
- package/src/when-saved.ts +66 -0
- package/src/ws-client.ts +70 -1
- package/src/ws-frame-limit.ts +9 -10
package/src/documents.ts
CHANGED
|
@@ -57,6 +57,7 @@ import {
|
|
|
57
57
|
GoneSignal,
|
|
58
58
|
WorkStatusSignal,
|
|
59
59
|
DriveStatusSignal,
|
|
60
|
+
DrivePublishTargetSignal,
|
|
60
61
|
type DocumentGoneListener,
|
|
61
62
|
type DocumentGoneReason,
|
|
62
63
|
type DocumentGoneUnsubscribe,
|
|
@@ -68,6 +69,8 @@ import {
|
|
|
68
69
|
type DocumentDriveStatusUnsubscribe,
|
|
69
70
|
type DocumentDriveTrackingState,
|
|
70
71
|
type DriveMirrorDirection,
|
|
72
|
+
type DrivePublishTarget,
|
|
73
|
+
type DrivePublishState,
|
|
71
74
|
} from './document-signals.js';
|
|
72
75
|
import {
|
|
73
76
|
ThumbnailCapture,
|
|
@@ -78,6 +81,7 @@ import {
|
|
|
78
81
|
CheckpointScheduler,
|
|
79
82
|
type CheckpointCreator,
|
|
80
83
|
} from './document-checkpoints.js';
|
|
84
|
+
import { whenSaved } from './when-saved.js';
|
|
81
85
|
import {
|
|
82
86
|
BranchController,
|
|
83
87
|
type BranchProvider,
|
|
@@ -120,6 +124,7 @@ export type {
|
|
|
120
124
|
DocumentDriveStatusUnsubscribe,
|
|
121
125
|
DocumentDriveTrackingState,
|
|
122
126
|
DriveMirrorDirection,
|
|
127
|
+
DrivePublishTarget,
|
|
123
128
|
};
|
|
124
129
|
|
|
125
130
|
// The proxy callback receives an object with the root map (`entities`)
|
|
@@ -612,10 +617,25 @@ export class Document {
|
|
|
612
617
|
this._fireSubscribers({ kind: 'branches' });
|
|
613
618
|
},
|
|
614
619
|
});
|
|
620
|
+
// The version of the newest picture the server holds of this document, or
|
|
621
|
+
// `null` for none. Seeded from the join and moved by this instance's own
|
|
622
|
+
// uploads; a picture older than the document's head is what arms the seed
|
|
623
|
+
// trigger, so a placeholder taken before the content arrived is replaced
|
|
624
|
+
// once it does.
|
|
625
|
+
private _thumbnailVersion: number | null = null;
|
|
615
626
|
private readonly _thumbnails = new ThumbnailCapture({
|
|
627
|
+
// `_forcedReadOnly` and not the whole `readOnly`: the server refuses a
|
|
628
|
+
// thumbnail from a reader it will not take writes from, so a capture
|
|
629
|
+
// there costs a request and a warn to be told so. The load lock is the
|
|
630
|
+
// other half of `readOnly` and must NOT gate this, because a document
|
|
631
|
+
// still hydrating is exactly the one the seed watch is armed for, and
|
|
632
|
+
// the arm happens while that lock is still on.
|
|
616
633
|
isCaptureAllowed: (): boolean =>
|
|
617
|
-
this._isLive && this._checkout === null,
|
|
634
|
+
this._isLive && this._checkout === null && !this._forcedReadOnly,
|
|
618
635
|
isAttached: (): boolean => this._isLive,
|
|
636
|
+
onCaptured: (): void => {
|
|
637
|
+
this._thumbnailVersion = this.currentSeq ?? 0;
|
|
638
|
+
},
|
|
619
639
|
});
|
|
620
640
|
private readonly _checkpoints = new CheckpointScheduler({
|
|
621
641
|
docId: (): string | null => this._docId,
|
|
@@ -626,6 +646,7 @@ export class Document {
|
|
|
626
646
|
sealUndoStep: (): void => {
|
|
627
647
|
if (this._undo) this._undo.sealStep();
|
|
628
648
|
},
|
|
649
|
+
whenLocalOpsSaved: (): Promise<boolean> => whenSaved(this),
|
|
629
650
|
});
|
|
630
651
|
// Drive push hook. `null` on the offline / test path; `pushToDrive`
|
|
631
652
|
// then throws with a clear message rather than silently dropping.
|
|
@@ -738,6 +759,9 @@ export class Document {
|
|
|
738
759
|
// Drive sync state, latest-retained. `null` until the open factory
|
|
739
760
|
// seeds it (drive-linked docs only) and on docs with no drive link.
|
|
740
761
|
private readonly _driveStatus = new DriveStatusSignal();
|
|
762
|
+
// Where this document could be published when it mirrors nothing yet.
|
|
763
|
+
// Seeded from the open, emptied once it becomes a mirror.
|
|
764
|
+
private readonly _drivePublishTarget = new DrivePublishTargetSignal();
|
|
741
765
|
|
|
742
766
|
// Document-scoped presence. `null` until the WS transport is wired
|
|
743
767
|
// on, and on the offline / test path where there is no transport.
|
|
@@ -1145,6 +1169,11 @@ export class Document {
|
|
|
1145
1169
|
// reload/resync closure can't touch freed WASM. `_closed` only; a parked
|
|
1146
1170
|
// doc still imports.
|
|
1147
1171
|
if (this._closed) return;
|
|
1172
|
+
// Arm the seed BEFORE the import, because this is the moment the content
|
|
1173
|
+
// arrives and the Loro subscriber cannot be relied on to report it: it is
|
|
1174
|
+
// attached lazily, so a document whose content lands before the session has
|
|
1175
|
+
// bound its canvas has nobody subscribed and the import fires nothing.
|
|
1176
|
+
this._armSeedIfPictureIsStale();
|
|
1148
1177
|
this._doc.import(snapshot);
|
|
1149
1178
|
// The undo stack pre-snapshot referenced ops the new snapshot
|
|
1150
1179
|
// supersedes. Drop both stacks so undo doesn't try to revert
|
|
@@ -1651,6 +1680,23 @@ export class Document {
|
|
|
1651
1680
|
this._thumbnails.setUploader(uploader);
|
|
1652
1681
|
}
|
|
1653
1682
|
|
|
1683
|
+
// Tell the document which version the server's picture of it depicts, from
|
|
1684
|
+
// the join, and start a seed watch when that picture is behind the content.
|
|
1685
|
+
/** @internal */
|
|
1686
|
+
_setThumbnailVersion(version: number | null): void {
|
|
1687
|
+
this._thumbnailVersion = version;
|
|
1688
|
+
this._armSeedIfPictureIsStale();
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
// A picture at or above the head depicts what the document currently holds.
|
|
1692
|
+
private _armSeedIfPictureIsStale(): void {
|
|
1693
|
+
const head = this.currentSeq ?? 0;
|
|
1694
|
+
if (this._thumbnailVersion !== null && this._thumbnailVersion >= head) {
|
|
1695
|
+
return;
|
|
1696
|
+
}
|
|
1697
|
+
this._thumbnails.armSeed();
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1654
1700
|
// Install the checkpoint-create hook; `null` detaches.
|
|
1655
1701
|
/** @internal */
|
|
1656
1702
|
setCheckpointCreator(creator: CheckpointCreator | null): void {
|
|
@@ -1736,11 +1782,11 @@ export class Document {
|
|
|
1736
1782
|
return await this._driveResolver();
|
|
1737
1783
|
}
|
|
1738
1784
|
|
|
1739
|
-
// Fetch the
|
|
1740
|
-
//
|
|
1741
|
-
//
|
|
1742
|
-
//
|
|
1743
|
-
//
|
|
1785
|
+
// Fetch the file a document was uploaded as: a short-lived presigned GET
|
|
1786
|
+
// plus the file's name / content type / size. An app that knows the format
|
|
1787
|
+
// fetches the bytes from `url`, parses them, and writes the structured
|
|
1788
|
+
// content into this document through the normal handle surface
|
|
1789
|
+
// (`doc.map('instances').mutate(...)`):
|
|
1744
1790
|
//
|
|
1745
1791
|
// const src = await doc.sourceBlob();
|
|
1746
1792
|
// const bytes = new Uint8Array(await (await fetch(src.url)).arrayBuffer());
|
|
@@ -1752,9 +1798,8 @@ export class Document {
|
|
|
1752
1798
|
// each with its own URL, so a multi-stream format (a glTF with its
|
|
1753
1799
|
// `.bin` buffer) fetches only the streams it needs.
|
|
1754
1800
|
//
|
|
1755
|
-
// Rejects with a `BrassError` when the document
|
|
1756
|
-
//
|
|
1757
|
-
// reach it (both 404).
|
|
1801
|
+
// Rejects with a `BrassError` when the document holds no such file, or
|
|
1802
|
+
// the caller cannot reach it (both 404).
|
|
1758
1803
|
async sourceBlob(): Promise<DocumentSourceBlob> {
|
|
1759
1804
|
this._assertOpen();
|
|
1760
1805
|
if (this._docId === null) {
|
|
@@ -2450,9 +2495,9 @@ export class Document {
|
|
|
2450
2495
|
this._maybeFireUndoState();
|
|
2451
2496
|
// A local commit (mutate / undo / redo) schedules a debounced
|
|
2452
2497
|
// thumbnail capture, when the app has registered a canvas via
|
|
2453
|
-
// `useCanvas`.
|
|
2454
|
-
// a passive collaborator
|
|
2455
|
-
// someone else's edit.
|
|
2498
|
+
// `useCanvas`. The debounce is for the peer that drew the change, so
|
|
2499
|
+
// it is local-only: a passive collaborator re-uploading a screenshot
|
|
2500
|
+
// of someone else's edit is what a per-op capture would be.
|
|
2456
2501
|
if (isLocalCommit) {
|
|
2457
2502
|
this._thumbnails.schedule();
|
|
2458
2503
|
// Same reasoning for the implicit auto-checkpoint: only the
|
|
@@ -2460,6 +2505,17 @@ export class Document {
|
|
|
2460
2505
|
// N-way session would write N redundant CHECKPOINT# rows per
|
|
2461
2506
|
// idle period.
|
|
2462
2507
|
this._checkpoints.scheduleAuto();
|
|
2508
|
+
} else {
|
|
2509
|
+
// Content arriving with no local commit behind it is what an import
|
|
2510
|
+
// looks like from here, and it moves the head past whatever picture
|
|
2511
|
+
// the document has. This DOES reach a reader who drew none of it,
|
|
2512
|
+
// which the debounce above deliberately does not: an import runs on
|
|
2513
|
+
// whichever client claimed it, so gating the seed on local authorship
|
|
2514
|
+
// would leave the documents that most need a picture without one.
|
|
2515
|
+
// Every such reader captures the same document at the same version,
|
|
2516
|
+
// and the targeted snapshot each upload enqueues is idempotent on
|
|
2517
|
+
// that version, so the concurrent seeds resolve themselves.
|
|
2518
|
+
this._armSeedIfPictureIsStale();
|
|
2463
2519
|
}
|
|
2464
2520
|
});
|
|
2465
2521
|
}
|
|
@@ -2671,6 +2727,55 @@ export class Document {
|
|
|
2671
2727
|
_emitDriveStatus(status: DocumentDriveStatus): void {
|
|
2672
2728
|
if (this._closed) return;
|
|
2673
2729
|
this._driveStatus.emit(status);
|
|
2730
|
+
// A document that has become a mirror publishes through its link from
|
|
2731
|
+
// now on, so the create-an-artifact offer goes with the arrival of the
|
|
2732
|
+
// status that replaces it.
|
|
2733
|
+
this._drivePublishTarget.set(null);
|
|
2734
|
+
}
|
|
2735
|
+
|
|
2736
|
+
// Whether this document's export runs in THIS app, which is what decides
|
|
2737
|
+
// whether a push carries bytes. Not on `driveStatus`: it is a fact about
|
|
2738
|
+
// the caller rather than about the document, and it is all an app is told
|
|
2739
|
+
// about the exporter, never which app it is when it is not this one.
|
|
2740
|
+
private _exportsHere = false;
|
|
2741
|
+
|
|
2742
|
+
/** @internal */
|
|
2743
|
+
get _exportRunsHere(): boolean {
|
|
2744
|
+
return this._exportsHere;
|
|
2745
|
+
}
|
|
2746
|
+
|
|
2747
|
+
/** @internal */
|
|
2748
|
+
_setExportRunsHere(runsHere: boolean): void {
|
|
2749
|
+
this._exportsHere = runsHere;
|
|
2750
|
+
}
|
|
2751
|
+
|
|
2752
|
+
// Internal: where this document can be published while it mirrors nothing.
|
|
2753
|
+
/** @internal */
|
|
2754
|
+
get _publishTarget(): DrivePublishTarget | null {
|
|
2755
|
+
return this._drivePublishTarget.target;
|
|
2756
|
+
}
|
|
2757
|
+
|
|
2758
|
+
/** @internal */
|
|
2759
|
+
_setPublishTarget(target: DrivePublishTarget | null): void {
|
|
2760
|
+
if (this._closed) return;
|
|
2761
|
+
this._drivePublishTarget.set(target);
|
|
2762
|
+
}
|
|
2763
|
+
|
|
2764
|
+
// Internal: how the publish of a document that mirrors nothing is going.
|
|
2765
|
+
/** @internal */
|
|
2766
|
+
get _publishState(): DrivePublishState {
|
|
2767
|
+
return this._drivePublishTarget.state;
|
|
2768
|
+
}
|
|
2769
|
+
|
|
2770
|
+
/** @internal */
|
|
2771
|
+
_emitPublishState(state: DrivePublishState): void {
|
|
2772
|
+
if (this._closed) return;
|
|
2773
|
+
this._drivePublishTarget.setState(state);
|
|
2774
|
+
}
|
|
2775
|
+
|
|
2776
|
+
/** @internal */
|
|
2777
|
+
_onPublishState(cb: () => void): () => void {
|
|
2778
|
+
return this._drivePublishTarget.onState(cb);
|
|
2674
2779
|
}
|
|
2675
2780
|
|
|
2676
2781
|
// Internal: mark the doc evicted. Idempotent (multiple frames arrive
|
|
@@ -3019,6 +3124,7 @@ export class Document {
|
|
|
3019
3124
|
this._gone.clearListeners();
|
|
3020
3125
|
this._workStatus.clearListeners();
|
|
3021
3126
|
this._driveStatus.clearListeners();
|
|
3127
|
+
this._drivePublishTarget.clearListeners();
|
|
3022
3128
|
// Presence subscribes its OWN WS topic and keeps it alive across a park
|
|
3023
3129
|
// (resume does not re-create the controller), so drop its app-side
|
|
3024
3130
|
// change listeners here the same way as the sets above: a parked doc
|
|
@@ -3086,7 +3192,7 @@ export class Document {
|
|
|
3086
3192
|
this._undo.free();
|
|
3087
3193
|
this._undo = null;
|
|
3088
3194
|
}
|
|
3089
|
-
this._thumbnails.
|
|
3195
|
+
this._thumbnails.destroy();
|
|
3090
3196
|
this._checkpoints.cancelAuto();
|
|
3091
3197
|
this._localOpHook = null;
|
|
3092
3198
|
this._currentSeqProvider = null;
|
|
@@ -3102,6 +3208,7 @@ export class Document {
|
|
|
3102
3208
|
this._gone.clearListeners();
|
|
3103
3209
|
this._workStatus.clearListeners();
|
|
3104
3210
|
this._driveStatus.clearListeners();
|
|
3211
|
+
this._drivePublishTarget.clearListeners();
|
|
3105
3212
|
// Detach handle subscriptions before freeing the doc (below): a handle's
|
|
3106
3213
|
// per-container Loro subscription outlives the cache reference, and
|
|
3107
3214
|
// `_doc.free()` must not run with subscriptions still attached.
|
package/src/handles.ts
CHANGED
|
@@ -816,9 +816,10 @@ function wrapLoroListProxy(
|
|
|
816
816
|
// reading / editing `doc.map('instances')` works unchanged. Only a root
|
|
817
817
|
// the `meta` markers name reaches here.
|
|
818
818
|
|
|
819
|
-
// A flattened entry: whatever the map stores under one key. A
|
|
820
|
-
//
|
|
821
|
-
// which is any JSON value and which the paged path carries through
|
|
819
|
+
// A flattened entry: whatever the map stores under one key. A store of typed
|
|
820
|
+
// records holds `{ type, params }`; an app's map holds whatever the app
|
|
821
|
+
// assigns, which is any JSON value and which the paged path carries through
|
|
822
|
+
// unchanged.
|
|
822
823
|
// Deliberately not narrowed to an object: a write path that assumes fields is
|
|
823
824
|
// what turns `5` into an empty container and `'hello'` into one entry per
|
|
824
825
|
// character.
|
|
@@ -2034,8 +2035,8 @@ function rewritePage(
|
|
|
2034
2035
|
const page = plainEntry(m, pageKey);
|
|
2035
2036
|
if (page === undefined) return false;
|
|
2036
2037
|
// The RAW triples, not the memo's unpacked records: a triple carries either
|
|
2037
|
-
// a
|
|
2038
|
-
//
|
|
2038
|
+
// a tag beside a body or one whole value, and `repackRecord` needs the
|
|
2039
|
+
// original to keep each one in the shape its readers expect.
|
|
2039
2040
|
const held = decodePageRecords(page);
|
|
2040
2041
|
if (held.length === 0) return false;
|
|
2041
2042
|
const next = new Map(group);
|
package/src/importer.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Document } from './documents.js';
|
|
2
2
|
import type { DocumentSourceBlob } from './documents-namespace.js';
|
|
3
3
|
import { BrassError } from './errors.js';
|
|
4
|
+
import { whenSaved } from './when-saved.js';
|
|
4
5
|
|
|
5
6
|
// The app's import half for a file Brass does not parse: given the stored
|
|
6
7
|
// source file and the open document, parse the bytes and write the records
|
|
@@ -174,6 +175,7 @@ export function createImportRunner(deps: ImportRunnerDeps): ImportRunner {
|
|
|
174
175
|
const stopWatchingWrites = doc.subscribe(() => {
|
|
175
176
|
if (doc.savingCount > 0) wroteRecords = true;
|
|
176
177
|
});
|
|
178
|
+
let saved = false;
|
|
177
179
|
try {
|
|
178
180
|
await run(source, doc);
|
|
179
181
|
// The importer's ops are still in flight when it returns, and
|
|
@@ -181,19 +183,19 @@ export function createImportRunner(deps: ImportRunnerDeps): ImportRunner {
|
|
|
181
183
|
// the head it lands on. Completing before they are acked stamps
|
|
182
184
|
// a version below the records' own, which reads as a document
|
|
183
185
|
// edited since it was uploaded from then on.
|
|
184
|
-
await whenSaved(doc);
|
|
186
|
+
saved = await whenSaved(doc);
|
|
185
187
|
} finally {
|
|
186
188
|
stopRefresh();
|
|
187
189
|
stopWatchingWrites();
|
|
188
190
|
}
|
|
189
|
-
// The wait
|
|
190
|
-
// draining, and only
|
|
191
|
-
// completion stamps the document with the
|
|
192
|
-
// it lands on, so running it over writes
|
|
193
|
-
// records an import of records the
|
|
194
|
-
// instead leaves the claim to
|
|
195
|
-
// imports the file properly.
|
|
196
|
-
if (!
|
|
191
|
+
// The wait ends on the document going, and on the acks stopping, as
|
|
192
|
+
// well as on the writes draining, and only the last of those means
|
|
193
|
+
// the records are stored. The completion stamps the document with the
|
|
194
|
+
// app's schema at the version it lands on, so running it over writes
|
|
195
|
+
// that never reached the server records an import of records the
|
|
196
|
+
// document does not hold. Abandoning instead leaves the claim to
|
|
197
|
+
// expire, and a client that is still open imports the file properly.
|
|
198
|
+
if (!saved) return;
|
|
197
199
|
// An importer that returns without writing anything must not stamp the
|
|
198
200
|
// document imported: the stamp is permanent, a later open reads
|
|
199
201
|
// `already_imported`, and the file's records then have no path into a
|
|
@@ -270,53 +272,6 @@ export function createImportRunner(deps: ImportRunnerDeps): ImportRunner {
|
|
|
270
272
|
return runner;
|
|
271
273
|
}
|
|
272
274
|
|
|
273
|
-
// How often the wait below re-checks that the document is still live. A
|
|
274
|
-
// closing document drops its subscribers without firing them, so this is the
|
|
275
|
-
// only thing that ends the wait for one that goes mid-import; it runs solely
|
|
276
|
-
// while an import's writes are draining.
|
|
277
|
-
const SAVED_LIVENESS_POLL_MS = 1_000;
|
|
278
|
-
|
|
279
|
-
// Resolves once the document has no locally-issued op waiting for its
|
|
280
|
-
// server ack, or once the document is no longer live.
|
|
281
|
-
//
|
|
282
|
-
// The liveness half is not belt-and-braces. Closing a Document CLEARS its
|
|
283
|
-
// subscribers rather than firing them, so a wait that watched the ack count
|
|
284
|
-
// alone would never settle for a document closed with writes outstanding, and
|
|
285
|
-
// the caller's `finally` (which stops renewing the platform's import claim)
|
|
286
|
-
// would never run. Resolving here does not claim the writes landed: the
|
|
287
|
-
// completion that follows is what asserts that, and it fails against a
|
|
288
|
-
// document this client can no longer write.
|
|
289
|
-
function whenSaved(doc: Document): Promise<void> {
|
|
290
|
-
if (doc.savingCount === 0) return Promise.resolve();
|
|
291
|
-
return new Promise<void>((resolve) => {
|
|
292
|
-
let off: (() => void) | undefined;
|
|
293
|
-
let poll: ReturnType<typeof setInterval> | undefined;
|
|
294
|
-
const settle = (): void => {
|
|
295
|
-
off?.();
|
|
296
|
-
if (poll !== undefined) clearInterval(poll);
|
|
297
|
-
resolve();
|
|
298
|
-
};
|
|
299
|
-
try {
|
|
300
|
-
off = doc.subscribe(() => {
|
|
301
|
-
if (doc.savingCount > 0) return;
|
|
302
|
-
settle();
|
|
303
|
-
});
|
|
304
|
-
} catch {
|
|
305
|
-
// The document closed, so nothing more is going to be acked.
|
|
306
|
-
resolve();
|
|
307
|
-
return;
|
|
308
|
-
}
|
|
309
|
-
poll = setInterval(() => {
|
|
310
|
-
if (doc._isLive && doc.savingCount > 0) return;
|
|
311
|
-
settle();
|
|
312
|
-
}, SAVED_LIVENESS_POLL_MS);
|
|
313
|
-
// Node keeps the process alive for a pending interval; this one is
|
|
314
|
-
// bookkeeping for a wait that ends on its own, so it must not.
|
|
315
|
-
(poll as { unref?: () => void }).unref?.();
|
|
316
|
-
if (doc.savingCount === 0) settle();
|
|
317
|
-
});
|
|
318
|
-
}
|
|
319
|
-
|
|
320
275
|
function asError(err: unknown): Error {
|
|
321
276
|
return err instanceof Error ? err : new Error(String(err));
|
|
322
277
|
}
|
package/src/internal.ts
CHANGED
|
@@ -21,4 +21,10 @@ export {
|
|
|
21
21
|
type TokenSet,
|
|
22
22
|
} from './auth-flow.js';
|
|
23
23
|
export { decodeIdToken } from './jwt.js';
|
|
24
|
+
export {
|
|
25
|
+
fetchWithRetry,
|
|
26
|
+
RETRY_READ,
|
|
27
|
+
type RetryOptions,
|
|
28
|
+
type RetryPolicy,
|
|
29
|
+
} from './fetch-retry.js';
|
|
24
30
|
export { BRASS_INIT_ARG_KEYS, type BrassInitArgKey } from './init-keys.js';
|
package/src/open-document.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
type DocumentOptions,
|
|
12
12
|
type DocumentWorkStatus,
|
|
13
13
|
type DocumentDriveStatus,
|
|
14
|
+
type DrivePublishTarget,
|
|
14
15
|
type DocumentDriveTrackingState,
|
|
15
16
|
type DriveMirrorDirection,
|
|
16
17
|
type HostedUiProvider,
|
|
@@ -214,6 +215,33 @@ export interface DocumentSnapshotResponse {
|
|
|
214
215
|
// Drive provenance + tracking state, present only on drive-linked docs.
|
|
215
216
|
// Seeds `doc.driveStatus`; live changes stream in via `doc_drive_status`.
|
|
216
217
|
drive?: DocumentDriveSummaryWire;
|
|
218
|
+
// Whether this document's export runs in the app this client acts as.
|
|
219
|
+
// Server-answered per caller, so it says only that this app is the one to
|
|
220
|
+
// serialize, never which app is if it is not. Absent for a caller carrying
|
|
221
|
+
// no app assertion.
|
|
222
|
+
calling_app_exports?: boolean;
|
|
223
|
+
// The version of the document's current picture, absent when it has none.
|
|
224
|
+
// Only the version is read (a picture behind the head is what makes this
|
|
225
|
+
// open seed one), so the shape is deliberately narrow: the full wire record
|
|
226
|
+
// is the thumbnail routes' surface, not the join's.
|
|
227
|
+
thumbnail?: { version?: number } | null;
|
|
228
|
+
// Where a document that mirrors nothing can be published, present only
|
|
229
|
+
// while it mirrors nothing and its project is linked to a drive that
|
|
230
|
+
// takes a new file. The hosted drive badge offers the publish off this.
|
|
231
|
+
drive_publish_target?: DrivePublishTargetWire;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Wire shape of a publish target on the join response. Snake_case,
|
|
235
|
+
// mirroring the server's `DocumentDrivePublishTarget` (CONTRACT.md "Where
|
|
236
|
+
// a document with no drive item can publish").
|
|
237
|
+
export interface DrivePublishTargetWire {
|
|
238
|
+
drive_type?: unknown;
|
|
239
|
+
drive_name?: unknown;
|
|
240
|
+
container_name?: unknown;
|
|
241
|
+
detail?: unknown;
|
|
242
|
+
source_unreadable?: unknown;
|
|
243
|
+
publishing?: unknown;
|
|
244
|
+
publish_failed?: unknown;
|
|
217
245
|
}
|
|
218
246
|
|
|
219
247
|
// Wire shape of the `drive` block carried on the join response and the
|
|
@@ -237,6 +265,7 @@ export interface DocumentDriveSummaryWire {
|
|
|
237
265
|
// Optional: a frame from an older server omits it, treated as not
|
|
238
266
|
// publishing (the `canPush` affordance then behaves as before).
|
|
239
267
|
publishing?: boolean;
|
|
268
|
+
source_unreadable?: string;
|
|
240
269
|
}
|
|
241
270
|
|
|
242
271
|
// Wire-protocol-pinned response shape from
|
|
@@ -620,6 +649,21 @@ interface DocDriveStatusFrame {
|
|
|
620
649
|
drive: DocumentDriveSummaryWire;
|
|
621
650
|
}
|
|
622
651
|
|
|
652
|
+
// Server-pushed publish state for a document that mirrors nothing on a drive
|
|
653
|
+
// yet. Wire shape pinned in CONTRACT.md "Server-pushed `doc_drive_publish`".
|
|
654
|
+
// The first publish creates the artifact, so until it lands the document has
|
|
655
|
+
// no drive summary and `doc_drive_status` carries nothing about the push it
|
|
656
|
+
// started.
|
|
657
|
+
interface DocDrivePublishFrame {
|
|
658
|
+
type: 'doc_drive_publish';
|
|
659
|
+
topic: string;
|
|
660
|
+
publish: {
|
|
661
|
+
publishing?: unknown;
|
|
662
|
+
failed?: unknown;
|
|
663
|
+
detail?: unknown;
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
|
|
623
667
|
// Server-pushed frame fired when the platform discards ops it cannot apply.
|
|
624
668
|
// The prompt half of the durability
|
|
625
669
|
// handshake: the SDK re-checks durability against ground truth now rather
|
|
@@ -651,7 +695,9 @@ function toDriveStatus(wire: DocumentDriveSummaryWire): DocumentDriveStatus {
|
|
|
651
695
|
// update) must not be treated as unbroken and offered a push the server
|
|
652
696
|
// would reject. Require an explicit `false` to enable, and an explicit
|
|
653
697
|
// `true` to flag broken. Behaviour is unchanged for today's frames, which
|
|
654
|
-
// always carry the boolean.
|
|
698
|
+
// always carry the boolean. Whether the file is written server-side or
|
|
699
|
+
// from the app's own serializer is a REQUEST SHAPE the push path resolves
|
|
700
|
+
// for itself, so it says nothing about whether this document can publish.
|
|
655
701
|
canPush:
|
|
656
702
|
wire.broken === false &&
|
|
657
703
|
mirrorDirection === 'two_way' &&
|
|
@@ -666,6 +712,31 @@ function toDriveStatus(wire: DocumentDriveSummaryWire): DocumentDriveStatus {
|
|
|
666
712
|
itemName: wire.item_name,
|
|
667
713
|
itemUrl: wire.item_url,
|
|
668
714
|
publishing: wire.publishing === true,
|
|
715
|
+
// Spread rather than assigned, because `exactOptionalPropertyTypes`
|
|
716
|
+
// separates an absent optional field from one holding `undefined`.
|
|
717
|
+
...(wire.source_unreadable !== undefined
|
|
718
|
+
? { sourceUnreadable: wire.source_unreadable }
|
|
719
|
+
: {}),
|
|
720
|
+
};
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// Map the wire target to the public shape, dropping one that does not carry
|
|
724
|
+
// what a publish needs. A malformed target is skipped rather than failing
|
|
725
|
+
// the open: the document is readable whatever its publish offer says.
|
|
726
|
+
function toPublishTarget(
|
|
727
|
+
wire: DrivePublishTargetWire | undefined,
|
|
728
|
+
): DrivePublishTarget | null {
|
|
729
|
+
if (typeof wire !== 'object' || wire === null) return null;
|
|
730
|
+
const driveName = wire.drive_name;
|
|
731
|
+
if (typeof driveName !== 'string' || driveName === '') return null;
|
|
732
|
+
return {
|
|
733
|
+
driveName,
|
|
734
|
+
driveType: typeof wire.drive_type === 'string' ? wire.drive_type : '',
|
|
735
|
+
containerName:
|
|
736
|
+
typeof wire.container_name === 'string' ? wire.container_name : '',
|
|
737
|
+
detail: typeof wire.detail === 'string' ? wire.detail : '',
|
|
738
|
+
publishing: wire.publishing === true,
|
|
739
|
+
publishFailed: wire.publish_failed === true,
|
|
669
740
|
};
|
|
670
741
|
}
|
|
671
742
|
|
|
@@ -969,6 +1040,15 @@ function isDocMetaFrame(frame: unknown): frame is DocMetaFrame {
|
|
|
969
1040
|
);
|
|
970
1041
|
}
|
|
971
1042
|
|
|
1043
|
+
function isDocDrivePublishFrame(frame: unknown): frame is DocDrivePublishFrame {
|
|
1044
|
+
if (typeof frame !== 'object' || frame === null) return false;
|
|
1045
|
+
const f = frame as Record<string, unknown>;
|
|
1046
|
+
if (f.type !== 'doc_drive_publish' || typeof f.topic !== 'string') {
|
|
1047
|
+
return false;
|
|
1048
|
+
}
|
|
1049
|
+
return typeof f.publish === 'object' && f.publish !== null;
|
|
1050
|
+
}
|
|
1051
|
+
|
|
972
1052
|
function isDocDriveStatusFrame(frame: unknown): frame is DocDriveStatusFrame {
|
|
973
1053
|
if (typeof frame !== 'object' || frame === null) return false;
|
|
974
1054
|
const f = frame as Record<string, unknown>;
|
|
@@ -1738,10 +1818,19 @@ export function createOpenDocument(
|
|
|
1738
1818
|
// (a stub opened mid-hydration returns `snapshot: null` + a `pending`
|
|
1739
1819
|
// status).
|
|
1740
1820
|
if (parsed.status !== undefined) doc._emitStatus(parsed.status);
|
|
1821
|
+
// Whether this app is the one to serialize the document, which is what
|
|
1822
|
+
// decides whether a push carries bytes. Answered by the server for the
|
|
1823
|
+
// caller's own app and only on the join, since the fanout frame is one
|
|
1824
|
+
// payload for every subscriber and has no caller to answer for.
|
|
1825
|
+
doc._setExportRunsHere(parsed.calling_app_exports === true);
|
|
1741
1826
|
// Seed `doc.driveStatus` from the join before the first
|
|
1742
1827
|
// `doc_drive_status` frame.
|
|
1743
1828
|
if (parsed.drive !== undefined) {
|
|
1744
1829
|
doc._emitDriveStatus(toDriveStatus(parsed.drive));
|
|
1830
|
+
} else {
|
|
1831
|
+
// Only for a document that mirrors nothing: a mirror publishes through
|
|
1832
|
+
// its link, and `_emitDriveStatus` clears any targets anyway.
|
|
1833
|
+
doc._setPublishTarget(toPublishTarget(parsed.drive_publish_target));
|
|
1745
1834
|
}
|
|
1746
1835
|
// Highest seq seen; later `doc_op` / `op_ack` frames advance it. Kept in
|
|
1747
1836
|
// a closure, not on the Document: it is a transport concern, not CRDT
|
|
@@ -2161,6 +2250,7 @@ export function createOpenDocument(
|
|
|
2161
2250
|
await uploader(doc, image, width, height);
|
|
2162
2251
|
});
|
|
2163
2252
|
}
|
|
2253
|
+
doc._setThumbnailVersion(parsed.thumbnail?.version ?? null);
|
|
2164
2254
|
if (opts.checkpointCreator) {
|
|
2165
2255
|
const creator = opts.checkpointCreator;
|
|
2166
2256
|
doc.setCheckpointCreator(() => creator(doc));
|
|
@@ -4300,6 +4390,15 @@ function wireDocTransport(args: WireDocTransportArgs): void {
|
|
|
4300
4390
|
args.doc._emitDriveStatus(toDriveStatus(frame.drive));
|
|
4301
4391
|
return;
|
|
4302
4392
|
}
|
|
4393
|
+
if (isDocDrivePublishFrame(frame)) {
|
|
4394
|
+
args.doc._emitPublishState({
|
|
4395
|
+
publishing: frame.publish.publishing === true,
|
|
4396
|
+
failed: frame.publish.failed === true,
|
|
4397
|
+
detail:
|
|
4398
|
+
typeof frame.publish.detail === 'string' ? frame.publish.detail : null,
|
|
4399
|
+
});
|
|
4400
|
+
return;
|
|
4401
|
+
}
|
|
4303
4402
|
if (isDocOpsDroppedFrame(frame)) {
|
|
4304
4403
|
// Prompt durability re-check: the discard sealed a new snapshot, so
|
|
4305
4404
|
// re-fetch the moved frontier and run the handshake now instead of at
|
package/src/record-paging.ts
CHANGED
|
@@ -232,16 +232,15 @@ const TOMBSTONE_PREFIX = `${RESERVED_KEY_PREFIX}x:`;
|
|
|
232
232
|
|
|
233
233
|
/** Pack one stored value into a page triple. A `null` tag is what says the
|
|
234
234
|
* body IS the record: an app's map holds whatever the app assigned, which has
|
|
235
|
-
* no tag to split off. A
|
|
236
|
-
*
|
|
237
|
-
* its records. */
|
|
235
|
+
* no tag to split off. A typed record arrives already split, and its tag is a
|
|
236
|
+
* string, so the two forms cannot be confused whatever the tag says. */
|
|
238
237
|
export function packWholeValue<B>(key: string, value: B): PackedRecord<B> {
|
|
239
238
|
return [key, null, value];
|
|
240
239
|
}
|
|
241
240
|
|
|
242
241
|
/** Re-pack `value` in the same triple shape `packed` already uses, so a page
|
|
243
|
-
* rewritten in place keeps the form its readers expect: a
|
|
244
|
-
*
|
|
242
|
+
* rewritten in place keeps the form its readers expect: a typed record stays
|
|
243
|
+
* a tag beside a body, and an app's stays one whole value. A streaming
|
|
245
244
|
* read visits a page's triples without unpacking them, so a rewrite that
|
|
246
245
|
* changed the shape would hand it the wrong half of the record. */
|
|
247
246
|
export function repackRecord(
|
|
@@ -259,7 +258,7 @@ export function repackRecord(
|
|
|
259
258
|
}
|
|
260
259
|
|
|
261
260
|
/** The stored value a page triple carries: the value itself for a whole-value
|
|
262
|
-
* record, else the `{ type, params }` pair a
|
|
261
|
+
* record, else the `{ type, params }` pair a typed one was split into. */
|
|
263
262
|
export function unpackRecord(packed: PackedRecord<unknown>): unknown {
|
|
264
263
|
if (packed[1] === null) return packed[2];
|
|
265
264
|
return { [TAG_FIELD]: packed[1], [BODY_FIELD]: packed[2] };
|
|
@@ -269,12 +268,12 @@ export function unpackRecord(packed: PackedRecord<unknown>): unknown {
|
|
|
269
268
|
* separately, or null when the triple packs a whole value that is not a
|
|
270
269
|
* record.
|
|
271
270
|
*
|
|
272
|
-
* A page holds both forms: a
|
|
273
|
-
*
|
|
274
|
-
* one whole value. Both forms occur in ONE store, because an
|
|
275
|
-
* to a
|
|
276
|
-
* `packed[1]` for the tag would report every record those pages
|
|
277
|
-
* having no type at all. */
|
|
271
|
+
* A page holds both forms: a typed record is packed as the tag and body it
|
|
272
|
+
* was already split into, and a map an app writes packs whatever the app
|
|
273
|
+
* assigned as one whole value. Both forms occur in ONE store, because an
|
|
274
|
+
* app's bulk add to a store of typed records seals its own pages, so a reader
|
|
275
|
+
* that took `packed[1]` for the tag would report every record those pages
|
|
276
|
+
* hold as having no type at all. */
|
|
278
277
|
export function unpackRecordPair(
|
|
279
278
|
packed: PackedRecord<unknown>,
|
|
280
279
|
): { tag: string; body: unknown } | null {
|
|
@@ -454,7 +453,7 @@ const WIDTH_SAMPLE = 256;
|
|
|
454
453
|
/** Mean JSON bytes of a record body, over a stride sample of at most
|
|
455
454
|
* `WIDTH_SAMPLE` records, which is what `recordsPerPage` sizes a page
|
|
456
455
|
* against. `bodyOf` returns the value the caller will pack as the triple's
|
|
457
|
-
* body: a
|
|
456
|
+
* body: a typed record carries a tag beside a body, and an app's map packs
|
|
458
457
|
* its whole stored value (`packWholeValue`), so each caller weighs the bytes
|
|
459
458
|
* its own pages will hold.
|
|
460
459
|
*
|
|
@@ -673,7 +672,7 @@ export function applyRecordPatches(
|
|
|
673
672
|
* value and as the `(tag, body)` pair split out of it, and return all three
|
|
674
673
|
* after the fold.
|
|
675
674
|
*
|
|
676
|
-
* A patch names the record's own fields, which for a
|
|
675
|
+
* A patch names the record's own fields, which for a typed record are
|
|
677
676
|
* `type` and `params` together, so a reader that walks tag and body
|
|
678
677
|
* separately has to re-split the result. Both readers of a paged store
|
|
679
678
|
* (the flattening one and the streaming one) resolve patches through this
|
package/src/serializer.ts
CHANGED
|
@@ -22,9 +22,9 @@ export interface StoredList<T = unknown> extends ListHandle<T> {
|
|
|
22
22
|
/**
|
|
23
23
|
* The document as a serializer sees it: the same containers, readable whole.
|
|
24
24
|
*
|
|
25
|
-
* A `Document` satisfies this, which is what keeps a
|
|
26
|
-
*
|
|
27
|
-
* run, not what they are allowed to reach.
|
|
25
|
+
* A `Document` satisfies this, which is what keeps a serializer the platform
|
|
26
|
+
* ships and one an app registers on one surface. The difference between them
|
|
27
|
+
* is where they run, not what they are allowed to reach.
|
|
28
28
|
*/
|
|
29
29
|
export interface StoredDocument {
|
|
30
30
|
readonly id: string | null;
|