@brass-build/client 0.2.0 → 0.4.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 +26 -5
- package/CHANGELOG.md +61 -2
- package/README.md +247 -87
- package/dist/acting-organization.d.ts +131 -0
- package/dist/acting-organization.d.ts.map +1 -0
- package/dist/acting-organization.js +278 -0
- package/dist/acting-organization.js.map +1 -0
- package/dist/api-headers.js +7 -7
- package/dist/api-headers.js.map +1 -1
- package/dist/call-service.d.ts +74 -0
- package/dist/call-service.d.ts.map +1 -0
- package/dist/call-service.js +253 -0
- package/dist/call-service.js.map +1 -0
- package/dist/client.d.ts +29 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +583 -12
- 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-contents.d.ts +15 -0
- package/dist/document-contents.d.ts.map +1 -0
- package/dist/document-contents.js +88 -0
- package/dist/document-contents.js.map +1 -0
- 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-streams.d.ts +4 -2
- package/dist/document-streams.d.ts.map +1 -1
- package/dist/document-streams.js +6 -0
- package/dist/document-streams.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 +51 -3
- package/dist/documents.d.ts.map +1 -1
- package/dist/documents.js +415 -36
- package/dist/documents.js.map +1 -1
- package/dist/export-guest.d.ts.map +1 -1
- package/dist/export-guest.js +5 -0
- package/dist/export-guest.js.map +1 -1
- package/dist/handle-types.d.ts +23 -0
- package/dist/handle-types.d.ts.map +1 -1
- package/dist/handles.d.ts +1 -1
- package/dist/handles.d.ts.map +1 -1
- package/dist/handles.js +3 -3
- package/dist/handles.js.map +1 -1
- package/dist/hosted-ui.d.ts +10 -0
- package/dist/hosted-ui.d.ts.map +1 -1
- package/dist/hosted-ui.js +115 -30
- package/dist/hosted-ui.js.map +1 -1
- package/dist/importer.d.ts.map +1 -1
- package/dist/importer.js +28 -66
- package/dist/importer.js.map +1 -1
- package/dist/index.d.ts +8 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +25 -1
- package/dist/index.js.map +1 -1
- package/dist/internal.d.ts +2 -0
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +2 -0
- package/dist/internal.js.map +1 -1
- package/dist/mutate-batching.js +2 -2
- package/dist/mutate-batching.js.map +1 -1
- package/dist/open-document.d.ts +16 -1
- package/dist/open-document.d.ts.map +1 -1
- package/dist/open-document.js +123 -35
- package/dist/open-document.js.map +1 -1
- package/dist/presence.d.ts +10 -0
- package/dist/presence.d.ts.map +1 -1
- package/dist/presence.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 +9 -0
- package/dist/session-toolbar.d.ts.map +1 -1
- package/dist/session-toolbar.js +191 -21
- package/dist/session-toolbar.js.map +1 -1
- package/dist/tail-ops.js +2 -2
- package/dist/tail-ops.js.map +1 -1
- package/dist/types.d.ts +0 -42
- package/dist/types.d.ts.map +1 -1
- package/dist/undo.d.ts +1 -1
- package/dist/undo.d.ts.map +1 -1
- package/dist/undo.js +1 -1
- package/dist/undo.js.map +1 -1
- package/dist/version-bar.d.ts.map +1 -1
- package/dist/version-bar.js +75 -4
- package/dist/version-bar.js.map +1 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/dist/wasm-ready-gate.d.ts +13 -0
- package/dist/wasm-ready-gate.d.ts.map +1 -0
- package/dist/wasm-ready-gate.js +28 -0
- package/dist/wasm-ready-gate.js.map +1 -0
- package/dist/wasm-runtime.browser.d.ts +11 -0
- package/dist/wasm-runtime.browser.d.ts.map +1 -0
- package/dist/wasm-runtime.browser.js +44 -0
- package/dist/wasm-runtime.browser.js.map +1 -0
- package/dist/wasm-runtime.d.ts +6 -0
- package/dist/wasm-runtime.d.ts.map +1 -0
- package/dist/wasm-runtime.js +20 -0
- package/dist/wasm-runtime.js.map +1 -0
- 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/app-manifest.md +335 -5
- package/docs/custom-chrome.md +7 -2
- package/docs/presence.md +12 -0
- package/docs/services.md +265 -0
- package/docs/text-fields.md +191 -0
- package/package.json +4 -5
- package/src/acting-organization.ts +396 -0
- package/src/api-headers.ts +7 -7
- package/src/call-service.ts +354 -0
- package/src/client.ts +655 -15
- package/src/document-checkpoints.ts +30 -0
- package/src/document-contents.ts +104 -0
- package/src/document-signals.ts +113 -1
- package/src/document-streams.ts +15 -1
- package/src/document-thumbnail-capture.ts +253 -9
- package/src/documents-namespace.ts +20 -10
- package/src/documents.ts +460 -33
- package/src/export-guest.ts +5 -0
- package/src/handle-types.ts +35 -0
- package/src/handles.ts +7 -6
- package/src/hosted-ui.ts +155 -29
- package/src/importer.ts +28 -62
- package/src/index.ts +43 -4
- package/src/internal.ts +10 -0
- package/src/mutate-batching.ts +2 -2
- package/src/open-document.ts +179 -39
- package/src/presence.ts +6 -0
- package/src/record-paging.ts +13 -14
- package/src/serializer.ts +3 -3
- package/src/session-toolbar.ts +219 -22
- package/src/tail-ops.ts +2 -2
- package/src/types.ts +6 -4
- package/src/undo.ts +1 -1
- package/src/version-bar.ts +77 -7
- package/src/version.ts +1 -1
- package/src/wasm-ready-gate.ts +30 -0
- package/src/wasm-runtime.browser.ts +48 -0
- package/src/wasm-runtime.ts +21 -0
- package/src/when-saved.ts +66 -0
- package/src/ws-client.ts +70 -1
- package/src/ws-frame-limit.ts +9 -10
- package/dist/vite.d.ts +0 -31
- package/dist/vite.d.ts.map +0 -1
- package/dist/vite.js +0 -161
- package/dist/vite.js.map +0 -1
- package/src/vite.ts +0 -214
package/src/internal.ts
CHANGED
|
@@ -21,4 +21,14 @@ export {
|
|
|
21
21
|
type TokenSet,
|
|
22
22
|
} from './auth-flow.js';
|
|
23
23
|
export { decodeIdToken } from './jwt.js';
|
|
24
|
+
export {
|
|
25
|
+
readDocumentContents,
|
|
26
|
+
type DocumentContents,
|
|
27
|
+
} from './document-contents.js';
|
|
28
|
+
export {
|
|
29
|
+
fetchWithRetry,
|
|
30
|
+
RETRY_READ,
|
|
31
|
+
type RetryOptions,
|
|
32
|
+
type RetryPolicy,
|
|
33
|
+
} from './fetch-retry.js';
|
|
24
34
|
export { BRASS_INIT_ARG_KEYS, type BrassInitArgKey } from './init-keys.js';
|
package/src/mutate-batching.ts
CHANGED
|
@@ -17,8 +17,8 @@
|
|
|
17
17
|
//
|
|
18
18
|
// The budget answers to STORAGE, not to the socket: an op past the frame cap
|
|
19
19
|
// takes the HTTP write, which carries about 190x a frame. What storage meters
|
|
20
|
-
// is op COUNT
|
|
21
|
-
//
|
|
20
|
+
// is op COUNT: each op costs the same bookkeeping whatever its size, and
|
|
21
|
+
// takes a place in the join's tail budget. Measured on a
|
|
22
22
|
// 500,000-element write, 48 KB produces 526 ops and 512 KB produces 158, past
|
|
23
23
|
// which the budget stops binding for that shape, while encode time stays flat
|
|
24
24
|
// throughout: a smaller budget spends op count and buys nothing.
|
package/src/open-document.ts
CHANGED
|
@@ -11,12 +11,14 @@ 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,
|
|
17
18
|
type ReadOnlyReason,
|
|
18
19
|
type ResyncPushOutcome,
|
|
19
20
|
} from './documents.js';
|
|
21
|
+
import { ensureWasmReady } from './wasm-runtime.js';
|
|
20
22
|
import { createPresenceController } from './presence.js';
|
|
21
23
|
import type { WsTopicCallback } from './ws-client.js';
|
|
22
24
|
import { applyAppAssertionHeader, applyClientVersionHeader } from './api-headers.js';
|
|
@@ -214,6 +216,33 @@ export interface DocumentSnapshotResponse {
|
|
|
214
216
|
// Drive provenance + tracking state, present only on drive-linked docs.
|
|
215
217
|
// Seeds `doc.driveStatus`; live changes stream in via `doc_drive_status`.
|
|
216
218
|
drive?: DocumentDriveSummaryWire;
|
|
219
|
+
// Whether this document's export runs in the app this client acts as.
|
|
220
|
+
// Server-answered per caller, so it says only that this app is the one to
|
|
221
|
+
// serialize, never which app is if it is not. Absent for a caller carrying
|
|
222
|
+
// no app assertion.
|
|
223
|
+
calling_app_exports?: boolean;
|
|
224
|
+
// The version of the document's current picture, absent when it has none.
|
|
225
|
+
// Only the version is read (a picture behind the head is what makes this
|
|
226
|
+
// open seed one), so the shape is deliberately narrow: the full wire record
|
|
227
|
+
// is the thumbnail routes' surface, not the join's.
|
|
228
|
+
thumbnail?: { version?: number } | null;
|
|
229
|
+
// Where a document that mirrors nothing can be published, present only
|
|
230
|
+
// while it mirrors nothing and its project is linked to a drive that
|
|
231
|
+
// takes a new file. The hosted drive badge offers the publish off this.
|
|
232
|
+
drive_publish_target?: DrivePublishTargetWire;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Wire shape of a publish target on the join response. Snake_case,
|
|
236
|
+
// mirroring the server's `DocumentDrivePublishTarget` (CONTRACT.md "Where
|
|
237
|
+
// a document with no drive item can publish").
|
|
238
|
+
export interface DrivePublishTargetWire {
|
|
239
|
+
drive_type?: unknown;
|
|
240
|
+
drive_name?: unknown;
|
|
241
|
+
container_name?: unknown;
|
|
242
|
+
detail?: unknown;
|
|
243
|
+
source_unreadable?: unknown;
|
|
244
|
+
publishing?: unknown;
|
|
245
|
+
publish_failed?: unknown;
|
|
217
246
|
}
|
|
218
247
|
|
|
219
248
|
// Wire shape of the `drive` block carried on the join response and the
|
|
@@ -237,6 +266,7 @@ export interface DocumentDriveSummaryWire {
|
|
|
237
266
|
// Optional: a frame from an older server omits it, treated as not
|
|
238
267
|
// publishing (the `canPush` affordance then behaves as before).
|
|
239
268
|
publishing?: boolean;
|
|
269
|
+
source_unreadable?: string;
|
|
240
270
|
}
|
|
241
271
|
|
|
242
272
|
// Wire-protocol-pinned response shape from
|
|
@@ -343,10 +373,10 @@ export interface OpenDocumentFactoryOptions {
|
|
|
343
373
|
// Backs the Document's built-in 'history' / 'manage' / 'share'
|
|
344
374
|
// commands. When absent (tests), those commands render disabled.
|
|
345
375
|
hostedUiProvider?: HostedUiProvider;
|
|
346
|
-
// The organization this
|
|
347
|
-
//
|
|
348
|
-
//
|
|
349
|
-
|
|
376
|
+
// The organization this app is already acting in, read through a getter
|
|
377
|
+
// because `init` resolves it after the factory's options are written and
|
|
378
|
+
// because it moves as the reader works. See `createDocumentId`.
|
|
379
|
+
preferredOrganizationId?: () => string | null;
|
|
350
380
|
// Who is signed in, as the park cache's ownership stamp (see
|
|
351
381
|
// `ParkedDocEntry.identity`). Read at park and at take, so a Document only
|
|
352
382
|
// ever comes back to the person it was opened for. Absent (tests, the
|
|
@@ -620,6 +650,21 @@ interface DocDriveStatusFrame {
|
|
|
620
650
|
drive: DocumentDriveSummaryWire;
|
|
621
651
|
}
|
|
622
652
|
|
|
653
|
+
// Server-pushed publish state for a document that mirrors nothing on a drive
|
|
654
|
+
// yet. Wire shape pinned in CONTRACT.md "Server-pushed `doc_drive_publish`".
|
|
655
|
+
// The first publish creates the artifact, so until it lands the document has
|
|
656
|
+
// no drive summary and `doc_drive_status` carries nothing about the push it
|
|
657
|
+
// started.
|
|
658
|
+
interface DocDrivePublishFrame {
|
|
659
|
+
type: 'doc_drive_publish';
|
|
660
|
+
topic: string;
|
|
661
|
+
publish: {
|
|
662
|
+
publishing?: unknown;
|
|
663
|
+
failed?: unknown;
|
|
664
|
+
detail?: unknown;
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
|
|
623
668
|
// Server-pushed frame fired when the platform discards ops it cannot apply.
|
|
624
669
|
// The prompt half of the durability
|
|
625
670
|
// handshake: the SDK re-checks durability against ground truth now rather
|
|
@@ -651,7 +696,9 @@ function toDriveStatus(wire: DocumentDriveSummaryWire): DocumentDriveStatus {
|
|
|
651
696
|
// update) must not be treated as unbroken and offered a push the server
|
|
652
697
|
// would reject. Require an explicit `false` to enable, and an explicit
|
|
653
698
|
// `true` to flag broken. Behaviour is unchanged for today's frames, which
|
|
654
|
-
// always carry the boolean.
|
|
699
|
+
// always carry the boolean. Whether the file is written server-side or
|
|
700
|
+
// from the app's own serializer is a REQUEST SHAPE the push path resolves
|
|
701
|
+
// for itself, so it says nothing about whether this document can publish.
|
|
655
702
|
canPush:
|
|
656
703
|
wire.broken === false &&
|
|
657
704
|
mirrorDirection === 'two_way' &&
|
|
@@ -666,6 +713,31 @@ function toDriveStatus(wire: DocumentDriveSummaryWire): DocumentDriveStatus {
|
|
|
666
713
|
itemName: wire.item_name,
|
|
667
714
|
itemUrl: wire.item_url,
|
|
668
715
|
publishing: wire.publishing === true,
|
|
716
|
+
// Spread rather than assigned, because `exactOptionalPropertyTypes`
|
|
717
|
+
// separates an absent optional field from one holding `undefined`.
|
|
718
|
+
...(wire.source_unreadable !== undefined
|
|
719
|
+
? { sourceUnreadable: wire.source_unreadable }
|
|
720
|
+
: {}),
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// Map the wire target to the public shape, dropping one that does not carry
|
|
725
|
+
// what a publish needs. A malformed target is skipped rather than failing
|
|
726
|
+
// the open: the document is readable whatever its publish offer says.
|
|
727
|
+
function toPublishTarget(
|
|
728
|
+
wire: DrivePublishTargetWire | undefined,
|
|
729
|
+
): DrivePublishTarget | null {
|
|
730
|
+
if (typeof wire !== 'object' || wire === null) return null;
|
|
731
|
+
const driveName = wire.drive_name;
|
|
732
|
+
if (typeof driveName !== 'string' || driveName === '') return null;
|
|
733
|
+
return {
|
|
734
|
+
driveName,
|
|
735
|
+
driveType: typeof wire.drive_type === 'string' ? wire.drive_type : '',
|
|
736
|
+
containerName:
|
|
737
|
+
typeof wire.container_name === 'string' ? wire.container_name : '',
|
|
738
|
+
detail: typeof wire.detail === 'string' ? wire.detail : '',
|
|
739
|
+
publishing: wire.publishing === true,
|
|
740
|
+
publishFailed: wire.publish_failed === true,
|
|
669
741
|
};
|
|
670
742
|
}
|
|
671
743
|
|
|
@@ -969,6 +1041,15 @@ function isDocMetaFrame(frame: unknown): frame is DocMetaFrame {
|
|
|
969
1041
|
);
|
|
970
1042
|
}
|
|
971
1043
|
|
|
1044
|
+
function isDocDrivePublishFrame(frame: unknown): frame is DocDrivePublishFrame {
|
|
1045
|
+
if (typeof frame !== 'object' || frame === null) return false;
|
|
1046
|
+
const f = frame as Record<string, unknown>;
|
|
1047
|
+
if (f.type !== 'doc_drive_publish' || typeof f.topic !== 'string') {
|
|
1048
|
+
return false;
|
|
1049
|
+
}
|
|
1050
|
+
return typeof f.publish === 'object' && f.publish !== null;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
972
1053
|
function isDocDriveStatusFrame(frame: unknown): frame is DocDriveStatusFrame {
|
|
973
1054
|
if (typeof frame !== 'object' || frame === null) return false;
|
|
974
1055
|
const f = frame as Record<string, unknown>;
|
|
@@ -1112,16 +1193,16 @@ export function createOpenDocument(
|
|
|
1112
1193
|
// open on something that clears on its own.
|
|
1113
1194
|
//
|
|
1114
1195
|
// Longer than the default handful of attempts because of what a 503 means
|
|
1115
|
-
// on this route: the document is intact and its storage is busy.
|
|
1116
|
-
//
|
|
1117
|
-
// answer 503 is one that was just written hard, which is exactly the
|
|
1196
|
+
// on this route: the document is intact and its storage is busy. A
|
|
1197
|
+
// document's own writes contend with each other, so the one most likely
|
|
1198
|
+
// to answer 503 is one that was just written hard, which is exactly the
|
|
1118
1199
|
// moment a person reloads to see their work. That pressure clears over
|
|
1119
1200
|
// tens of seconds, not the one second the default budget spans, and the
|
|
1120
1201
|
// cost of giving up early is telling someone their document will not open
|
|
1121
1202
|
// when waiting would have opened it.
|
|
1122
1203
|
//
|
|
1123
1204
|
// A thrown request gets a short budget of its own: it never reached the
|
|
1124
|
-
// API, so
|
|
1205
|
+
// API, so busy storage is not what is wrong, and a blocked origin
|
|
1125
1206
|
// or a host that does not resolve answers the seventh attempt as it
|
|
1126
1207
|
// answered the first, with the reader watching a loading surface
|
|
1127
1208
|
// throughout.
|
|
@@ -1264,7 +1345,7 @@ export function createOpenDocument(
|
|
|
1264
1345
|
// piped through `open` to materialise the editable Document.
|
|
1265
1346
|
// What the app hears when an op the socket could not carry also failed
|
|
1266
1347
|
// over HTTP. The server's message is preferred where it has one, since it
|
|
1267
|
-
// names the actual refusal (too large
|
|
1348
|
+
// names the actual refusal (too large to store inline, a revoked
|
|
1268
1349
|
// role); the status-shaped fallbacks cover a response that carries none.
|
|
1269
1350
|
function opWriteFailureMessage(status: number, parsed: unknown): string {
|
|
1270
1351
|
const serverMessage = (parsed as { error?: unknown } | undefined)?.error;
|
|
@@ -1276,12 +1357,12 @@ export function createOpenDocument(
|
|
|
1276
1357
|
return `This change could not be saved (${status}).`;
|
|
1277
1358
|
}
|
|
1278
1359
|
|
|
1279
|
-
// Write one op over HTTP. The socket carries an op only up to
|
|
1280
|
-
//
|
|
1281
|
-
//
|
|
1360
|
+
// Write one op over HTTP. The socket carries an op only up to its own
|
|
1361
|
+
// message cap; past it the message is not delivered and the connection is
|
|
1362
|
+
// closed, so `wireDocTransport` sends the large ones here.
|
|
1282
1363
|
// Not retried on a transient failure: the caller holds the op in its
|
|
1283
1364
|
// outstanding tracker and re-asserts it, and a blind replay would write a
|
|
1284
|
-
// second
|
|
1365
|
+
// second stored copy of the same edit.
|
|
1285
1366
|
// Mint a presigned PUT and upload the blob to it, returning the staging
|
|
1286
1367
|
// key the op write names. The PUT carries no Brass headers: the URL is
|
|
1287
1368
|
// signed, and an extra header falls outside that signature.
|
|
@@ -1353,9 +1434,9 @@ export function createOpenDocument(
|
|
|
1353
1434
|
};
|
|
1354
1435
|
applyClientVersionHeader(headers);
|
|
1355
1436
|
await applyAppAssertionHeader(headers, opts.getAppAssertion);
|
|
1356
|
-
// Past
|
|
1357
|
-
//
|
|
1358
|
-
//
|
|
1437
|
+
// Past the size an op can carry inline, the blob is uploaded first and
|
|
1438
|
+
// the write names what was staged, so a multi-megabyte op never travels
|
|
1439
|
+
// through a request body.
|
|
1359
1440
|
let stagedKey: string | undefined;
|
|
1360
1441
|
if (opBlobNeedsStaging(opBlobB64)) {
|
|
1361
1442
|
stagedKey = await stageOpBlob(docId, opBlobB64, token);
|
|
@@ -1445,32 +1526,36 @@ export function createOpenDocument(
|
|
|
1445
1526
|
// a needed destination / organization before showing a loading state; any
|
|
1446
1527
|
// other non-2xx throws.
|
|
1447
1528
|
//
|
|
1448
|
-
// A create that names no placement of its own
|
|
1449
|
-
//
|
|
1450
|
-
//
|
|
1451
|
-
//
|
|
1452
|
-
//
|
|
1529
|
+
// A create that names no placement of its own lands in the organization the
|
|
1530
|
+
// app is already acting in: the one a launch named, the one a document the
|
|
1531
|
+
// reader just closed lives in, or the one they chose in the account menu's
|
|
1532
|
+
// switch. Without it a member of several organizations is asked to name a
|
|
1533
|
+
// destination they have already answered, once per create. An explicit
|
|
1534
|
+
// `siblingOf` / `destination` outranks it: placing a document beside the
|
|
1535
|
+
// open one is the more specific answer.
|
|
1453
1536
|
const createDocumentId = async (
|
|
1454
1537
|
args: CreateDocumentArgs,
|
|
1455
1538
|
): Promise<CreateDocumentResult> => {
|
|
1456
|
-
const
|
|
1539
|
+
const preferred =
|
|
1457
1540
|
args.siblingOf === undefined && args.destination === undefined
|
|
1458
|
-
? (opts.
|
|
1541
|
+
? (opts.preferredOrganizationId?.() ?? null)
|
|
1459
1542
|
: null;
|
|
1460
|
-
if (
|
|
1543
|
+
if (preferred === null) return postAndClassify(args);
|
|
1461
1544
|
try {
|
|
1462
1545
|
return await postAndClassify({
|
|
1463
1546
|
...args,
|
|
1464
|
-
destination: { organization_id:
|
|
1547
|
+
destination: { organization_id: preferred },
|
|
1465
1548
|
});
|
|
1466
1549
|
} catch (err) {
|
|
1467
|
-
//
|
|
1468
|
-
//
|
|
1469
|
-
//
|
|
1470
|
-
//
|
|
1471
|
-
//
|
|
1472
|
-
//
|
|
1473
|
-
//
|
|
1550
|
+
// Every source of this value is a preference rather than an answer: a
|
|
1551
|
+
// URL parameter, a document open in an organization reached through a
|
|
1552
|
+
// shared project, a choice made before the person left. Each can name
|
|
1553
|
+
// an organization the caller no longer has, which the server answers
|
|
1554
|
+
// with a plain 404. Drop it and let the server resolve the destination
|
|
1555
|
+
// on its own, which routes a multi-organization caller to the picker. A
|
|
1556
|
+
// coded rejection (`app_not_installed`) belongs to the caller's own
|
|
1557
|
+
// consent flow and passes through untouched, as does the 404 for an
|
|
1558
|
+
// organization that exists but denies the app.
|
|
1474
1559
|
if (err instanceof BrassError && err.status === 404 && !err.code) {
|
|
1475
1560
|
return postAndClassify(args);
|
|
1476
1561
|
}
|
|
@@ -1712,6 +1797,7 @@ export function createOpenDocument(
|
|
|
1712
1797
|
resyncFromSnapshot,
|
|
1713
1798
|
skipPresence: true,
|
|
1714
1799
|
parkable: false,
|
|
1800
|
+
capacityReloadsDocument: false,
|
|
1715
1801
|
});
|
|
1716
1802
|
}
|
|
1717
1803
|
return doc;
|
|
@@ -1738,10 +1824,19 @@ export function createOpenDocument(
|
|
|
1738
1824
|
// (a stub opened mid-hydration returns `snapshot: null` + a `pending`
|
|
1739
1825
|
// status).
|
|
1740
1826
|
if (parsed.status !== undefined) doc._emitStatus(parsed.status);
|
|
1827
|
+
// Whether this app is the one to serialize the document, which is what
|
|
1828
|
+
// decides whether a push carries bytes. Answered by the server for the
|
|
1829
|
+
// caller's own app and only on the join, since the fanout frame is one
|
|
1830
|
+
// payload for every subscriber and has no caller to answer for.
|
|
1831
|
+
doc._setExportRunsHere(parsed.calling_app_exports === true);
|
|
1741
1832
|
// Seed `doc.driveStatus` from the join before the first
|
|
1742
1833
|
// `doc_drive_status` frame.
|
|
1743
1834
|
if (parsed.drive !== undefined) {
|
|
1744
1835
|
doc._emitDriveStatus(toDriveStatus(parsed.drive));
|
|
1836
|
+
} else {
|
|
1837
|
+
// Only for a document that mirrors nothing: a mirror publishes through
|
|
1838
|
+
// its link, and `_emitDriveStatus` clears any targets anyway.
|
|
1839
|
+
doc._setPublishTarget(toPublishTarget(parsed.drive_publish_target));
|
|
1745
1840
|
}
|
|
1746
1841
|
// Highest seq seen; later `doc_op` / `op_ack` frames advance it. Kept in
|
|
1747
1842
|
// a closure, not on the Document: it is a transport concern, not CRDT
|
|
@@ -2161,6 +2256,7 @@ export function createOpenDocument(
|
|
|
2161
2256
|
await uploader(doc, image, width, height);
|
|
2162
2257
|
});
|
|
2163
2258
|
}
|
|
2259
|
+
doc._setThumbnailVersion(parsed.thumbnail?.version ?? null);
|
|
2164
2260
|
if (opts.checkpointCreator) {
|
|
2165
2261
|
const creator = opts.checkpointCreator;
|
|
2166
2262
|
doc.setCheckpointCreator(() => creator(doc));
|
|
@@ -2217,6 +2313,11 @@ export function createOpenDocument(
|
|
|
2217
2313
|
}
|
|
2218
2314
|
}
|
|
2219
2315
|
if (opts.reportError) doc.setErrorReporter(opts.reportError);
|
|
2316
|
+
// No socket on a document the caller can write to: the write path reaches
|
|
2317
|
+
// no server, so the document reports the first attempt rather than
|
|
2318
|
+
// discarding it. Outside tests this is `wsBaseUrl` left unset, which
|
|
2319
|
+
// naming any other base URL does.
|
|
2320
|
+
if (!opts.wsClient && !doc.readOnly) doc.markTransportUnavailable();
|
|
2220
2321
|
return doc;
|
|
2221
2322
|
}
|
|
2222
2323
|
|
|
@@ -2504,7 +2605,12 @@ export function createOpenDocument(
|
|
|
2504
2605
|
canEdit: boolean,
|
|
2505
2606
|
): void {
|
|
2506
2607
|
const wsClient = opts.wsClient;
|
|
2507
|
-
if (!wsClient)
|
|
2608
|
+
if (!wsClient) {
|
|
2609
|
+
// No socket: a write would change local state and reach no server, so
|
|
2610
|
+
// the document reports the first one instead of discarding it quietly.
|
|
2611
|
+
if (canEdit) doc.markTransportUnavailable();
|
|
2612
|
+
return;
|
|
2613
|
+
}
|
|
2508
2614
|
const topic = `doc:${docId}:${lineId}`;
|
|
2509
2615
|
|
|
2510
2616
|
// Ops on the socket awaiting an answer, keyed by `client_op_id`. An
|
|
@@ -2842,11 +2948,12 @@ export function createOpenDocument(
|
|
|
2842
2948
|
// create + obtain the doc_id, then `open(docId)`); they aren't fused
|
|
2843
2949
|
// because the create response is metadata-only and the open path already
|
|
2844
2950
|
// handles the empty-snapshot case via `Document.create()`. This is the
|
|
2845
|
-
// low-level path (`Brass.createDocument`): the
|
|
2846
|
-
// the
|
|
2847
|
-
//
|
|
2848
|
-
//
|
|
2849
|
-
//
|
|
2951
|
+
// low-level path (`Brass.createDocument`): the doc lands in the
|
|
2952
|
+
// organization the app is acting in, and otherwise in the caller's single
|
|
2953
|
+
// organization; a caller with none is routed to the hosted
|
|
2954
|
+
// acquire-organization surface and the create retries; a multi-org caller
|
|
2955
|
+
// whose app is acting nowhere rejects with a 409 (no hosted picker here,
|
|
2956
|
+
// that is the session's `create()`).
|
|
2850
2957
|
const create: CreateDocumentFn = async (options) => {
|
|
2851
2958
|
const args: CreateDocumentArgs = { name: options?.name ?? 'Untitled' };
|
|
2852
2959
|
if (options?.app_metadata !== undefined) args.app_metadata = options.app_metadata;
|
|
@@ -2941,6 +3048,13 @@ interface WireDocTransportArgs {
|
|
|
2941
3048
|
// by the parent Document (closed on parent close), so it bypasses the
|
|
2942
3049
|
// park cache and `close()` tears down immediately.
|
|
2943
3050
|
parkable?: boolean;
|
|
3051
|
+
// True for the session-owned top-level document: an unhealable capacity
|
|
3052
|
+
// refusal tells the user and reloads, because the session reopens the id
|
|
3053
|
+
// and rebinds the app to the platform's copy. False for a stream line,
|
|
3054
|
+
// which has no such owner and is only one part of a document: it marks
|
|
3055
|
+
// itself read-only at capacity and surfaces `stream.atCapacity` instead
|
|
3056
|
+
// of alerting the reader and reloading a document the stream is part of.
|
|
3057
|
+
capacityReloadsDocument?: boolean;
|
|
2944
3058
|
// The branch the caller REQUESTED (`options.branch`), undefined for a
|
|
2945
3059
|
// default-branch open. Distinct from `branchId`, which is the branch the
|
|
2946
3060
|
// server RESOLVED. The park cache keys on this (symmetric with
|
|
@@ -3719,6 +3833,18 @@ function wireDocTransport(args: WireDocTransportArgs): void {
|
|
|
3719
3833
|
clearTimeout(gapTimer);
|
|
3720
3834
|
gapTimer = null;
|
|
3721
3835
|
}
|
|
3836
|
+
// A stream line reaches this only at capacity, and it has no owner to
|
|
3837
|
+
// reopen it: the session reopens the top-level id, but a stream is one
|
|
3838
|
+
// part of a document nothing reloads on its behalf. So it stops the
|
|
3839
|
+
// divergence (above) and marks itself read-only at capacity, which
|
|
3840
|
+
// `stream.readOnly` / `stream.atCapacity` already surface to the app,
|
|
3841
|
+
// rather than alerting the reader and reloading a document it is part
|
|
3842
|
+
// of. The diverged edit stays visible until the app reopens the stream;
|
|
3843
|
+
// it reached no other reader, so nothing is lost by leaving it.
|
|
3844
|
+
if (args.capacityReloadsDocument === false) {
|
|
3845
|
+
args.doc._markReadOnly('capacity');
|
|
3846
|
+
return;
|
|
3847
|
+
}
|
|
3722
3848
|
args.doc._markReadOnly('unsaved');
|
|
3723
3849
|
// The user is told by the SDK, not by each app, because an app that
|
|
3724
3850
|
// renders nothing is exactly the case this has to cover: a document that
|
|
@@ -4300,6 +4426,15 @@ function wireDocTransport(args: WireDocTransportArgs): void {
|
|
|
4300
4426
|
args.doc._emitDriveStatus(toDriveStatus(frame.drive));
|
|
4301
4427
|
return;
|
|
4302
4428
|
}
|
|
4429
|
+
if (isDocDrivePublishFrame(frame)) {
|
|
4430
|
+
args.doc._emitPublishState({
|
|
4431
|
+
publishing: frame.publish.publishing === true,
|
|
4432
|
+
failed: frame.publish.failed === true,
|
|
4433
|
+
detail:
|
|
4434
|
+
typeof frame.publish.detail === 'string' ? frame.publish.detail : null,
|
|
4435
|
+
});
|
|
4436
|
+
return;
|
|
4437
|
+
}
|
|
4303
4438
|
if (isDocOpsDroppedFrame(frame)) {
|
|
4304
4439
|
// Prompt durability re-check: the discard sealed a new snapshot, so
|
|
4305
4440
|
// re-fetch the moved frontier and run the handshake now instead of at
|
|
@@ -4663,6 +4798,11 @@ async function materialise(
|
|
|
4663
4798
|
documentOptions?: DocumentOptions,
|
|
4664
4799
|
onSnapshotProgress?: (loaded: number, total: number | null) => void,
|
|
4665
4800
|
): Promise<OpenedDocument> {
|
|
4801
|
+
// Constructing the document is the first thing in this package that runs the
|
|
4802
|
+
// WASM, and `Brass.init` only starts the fetch, so this is where it is
|
|
4803
|
+
// waited on. Usually it resolved during the auth round-trip and this costs
|
|
4804
|
+
// nothing.
|
|
4805
|
+
await ensureWasmReady();
|
|
4666
4806
|
// Stamp the server-minted doc_id onto the Document (via DocumentOptions, so
|
|
4667
4807
|
// it threads through the LoroDoc constructor without changing the static
|
|
4668
4808
|
// factory signatures) so callers can read `doc.id` to correlate.
|
package/src/presence.ts
CHANGED
|
@@ -78,6 +78,12 @@ export interface PresenceState {
|
|
|
78
78
|
/** Entity ids the user has selected. Use to highlight remote
|
|
79
79
|
* selections so collaborators see what each other is working on. */
|
|
80
80
|
selection?: string[];
|
|
81
|
+
/** Where the user's caret sits in a text container. `key` names the
|
|
82
|
+
* container (`doc.text(key)`), and both offsets count UTF-16 code
|
|
83
|
+
* units into its value: `anchor` where the selection began, `head`
|
|
84
|
+
* where it is now, equal for a plain caret. Use it to draw remote
|
|
85
|
+
* carets in prose, the way `selection` highlights picked entities. */
|
|
86
|
+
textCaret?: { key: string; anchor: number; head: number };
|
|
81
87
|
}
|
|
82
88
|
|
|
83
89
|
// A live participant on a presence topic. Keyed on `connectionId` (one
|
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;
|