@brass-build/client 0.4.1 → 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 +124 -45
- package/CHANGELOG.md +80 -4
- package/README.md +666 -179
- 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 +48 -2
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +918 -115
- 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/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 +1022 -112
- 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
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// A page the browser restores from its back-forward cache resumes with the
|
|
2
|
+
// state it was frozen in, and nothing it held can be trusted to still be true.
|
|
3
|
+
// Two things go stale with no event to say so: a link this page was a guest
|
|
4
|
+
// on can have been claimed or revoked while it was frozen, and the person can
|
|
5
|
+
// have signed in or out in the page they navigated to. Either way the page
|
|
6
|
+
// shows someone the wrong document under the wrong identity, so it loads
|
|
7
|
+
// again, which asks both questions afresh.
|
|
8
|
+
|
|
9
|
+
export interface RestoredPageState {
|
|
10
|
+
/** The link token this page is a guest on, or null. */
|
|
11
|
+
guestLinkToken: string | null;
|
|
12
|
+
/** Whether this page resolved a signed-in person. */
|
|
13
|
+
signedIn: boolean;
|
|
14
|
+
/** Whether this browser's stored hint says a session exists for the app. */
|
|
15
|
+
sessionHinted: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Whether a guest link still answers. `null` when the question could not be
|
|
19
|
+
// asked (no network), which keeps the page rather than reloading it offline.
|
|
20
|
+
export type LinkStillOpen = (token: string) => Promise<boolean | null>;
|
|
21
|
+
|
|
22
|
+
export async function restoredPageIsStale(
|
|
23
|
+
state: RestoredPageState,
|
|
24
|
+
linkStillOpen: LinkStillOpen,
|
|
25
|
+
): Promise<boolean> {
|
|
26
|
+
if (state.guestLinkToken !== null) {
|
|
27
|
+
return (await linkStillOpen(state.guestLinkToken)) === false;
|
|
28
|
+
}
|
|
29
|
+
return state.signedIn !== state.sessionHinted;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Calls `onRestore` for every show that comes out of the back-forward cache.
|
|
33
|
+
export function onPageRestored(onRestore: () => void): void {
|
|
34
|
+
if (typeof window === 'undefined' || typeof window.addEventListener !== 'function') return;
|
|
35
|
+
window.addEventListener('pageshow', (event) => {
|
|
36
|
+
if ((event as { persisted?: boolean }).persisted === true) onRestore();
|
|
37
|
+
});
|
|
38
|
+
}
|
package/src/placement.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// The loop that places a document through the hosted surfaces: run an
|
|
2
|
+
// attempt, open the surface each `needs-*` outcome names, and retry with
|
|
3
|
+
// what it returned. Shared by the session's create and the client's claim of
|
|
4
|
+
// a document created without an account, so the two cannot drift on which
|
|
5
|
+
// surface answers which refusal.
|
|
6
|
+
|
|
7
|
+
import type {
|
|
8
|
+
AcquireOrganizationResult,
|
|
9
|
+
CreateDestinationResult,
|
|
10
|
+
} from './hosted-ui.js';
|
|
11
|
+
|
|
12
|
+
export interface PlacementSurfaces {
|
|
13
|
+
acquireOrganization(): Promise<AcquireOrganizationResult | null>;
|
|
14
|
+
selectCreateDestination(options?: {
|
|
15
|
+
name?: string;
|
|
16
|
+
}): Promise<CreateDestinationResult | null>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Run `attempt` until the server places the document. `needs-organization`
|
|
20
|
+
// opens the acquire surface and retries with `{ organization_id }`;
|
|
21
|
+
// `needs-destination` opens the destination dialog (pre-filled with `name`)
|
|
22
|
+
// and retries with its opaque result, relayed verbatim. Resolves null when
|
|
23
|
+
// the person dismisses a surface. `onDestination` hears the dialog's result,
|
|
24
|
+
// which also carries the name the person settled on.
|
|
25
|
+
export async function placeThroughHostedSurfaces<R extends { kind: string }>(
|
|
26
|
+
surfaces: PlacementSurfaces,
|
|
27
|
+
attempt: (destination: unknown) => Promise<R>,
|
|
28
|
+
name: string | undefined,
|
|
29
|
+
onDestination?: (chosen: CreateDestinationResult) => void,
|
|
30
|
+
): Promise<Exclude<R, { kind: 'needs-organization' | 'needs-destination' }> | null> {
|
|
31
|
+
let destination: unknown = undefined;
|
|
32
|
+
for (;;) {
|
|
33
|
+
const result = await attempt(destination);
|
|
34
|
+
if (result.kind === 'needs-organization') {
|
|
35
|
+
const acquired = await surfaces.acquireOrganization();
|
|
36
|
+
if (!acquired) return null;
|
|
37
|
+
destination = { organization_id: acquired.organization_id };
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (result.kind === 'needs-destination') {
|
|
41
|
+
const chosen = await surfaces.selectCreateDestination(
|
|
42
|
+
name !== undefined ? { name } : undefined,
|
|
43
|
+
);
|
|
44
|
+
if (chosen === null) return null;
|
|
45
|
+
onDestination?.(chosen);
|
|
46
|
+
destination = chosen;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
return result as Exclude<
|
|
50
|
+
R,
|
|
51
|
+
{ kind: 'needs-organization' | 'needs-destination' }
|
|
52
|
+
>;
|
|
53
|
+
}
|
|
54
|
+
}
|
package/src/presence.ts
CHANGED
|
@@ -23,7 +23,7 @@ export const PRESENCE_PUBLISH_INTERVAL_MS = 100;
|
|
|
23
23
|
*/
|
|
24
24
|
export const PRESENCE_LAYER_MAX_BYTES = 2048;
|
|
25
25
|
|
|
26
|
-
const PRESENCE_TOPIC_PREFIX = 'presence:doc:';
|
|
26
|
+
export const PRESENCE_TOPIC_PREFIX = 'presence:doc:';
|
|
27
27
|
|
|
28
28
|
const TEXT_ENCODER = new TextEncoder();
|
|
29
29
|
|
|
@@ -107,7 +107,8 @@ export interface PresenceParticipant {
|
|
|
107
107
|
|
|
108
108
|
// The partial payload passed to `presence.set(...)`. Either layer may
|
|
109
109
|
// be present; each is shallow-merged into the client's current self
|
|
110
|
-
// payload, so `set({ cursor })` then `set({ camera })` keeps both.
|
|
110
|
+
// payload, so `set({ cursor })` then `set({ camera })` keeps both. A key
|
|
111
|
+
// set to `undefined` is removed from the layer.
|
|
111
112
|
export interface PresenceSetInput {
|
|
112
113
|
state?: Record<string, unknown>;
|
|
113
114
|
ext?: Record<string, unknown>;
|
|
@@ -264,6 +265,19 @@ function splitPresencePayload(payload: unknown): {
|
|
|
264
265
|
};
|
|
265
266
|
}
|
|
266
267
|
|
|
268
|
+
// Shallow-merge `input` into a layer. A key set to `undefined` is removed, so
|
|
269
|
+
// the payload held here is exactly the one peers are sent.
|
|
270
|
+
function mergeLayer(
|
|
271
|
+
current: Record<string, unknown>,
|
|
272
|
+
input: Record<string, unknown>,
|
|
273
|
+
): Record<string, unknown> {
|
|
274
|
+
const next = { ...current, ...input };
|
|
275
|
+
for (const [key, value] of Object.entries(input)) {
|
|
276
|
+
if (value === undefined) delete next[key];
|
|
277
|
+
}
|
|
278
|
+
return next;
|
|
279
|
+
}
|
|
280
|
+
|
|
267
281
|
class PresenceController implements PresenceClient {
|
|
268
282
|
private readonly _topic: string;
|
|
269
283
|
private readonly _roster = new Map<string, PresenceParticipant>();
|
|
@@ -349,10 +363,10 @@ class PresenceController implements PresenceClient {
|
|
|
349
363
|
set(input: PresenceSetInput): void {
|
|
350
364
|
if (this._closed) return;
|
|
351
365
|
if (input.state !== undefined) {
|
|
352
|
-
this._selfState =
|
|
366
|
+
this._selfState = mergeLayer(this._selfState, input.state);
|
|
353
367
|
}
|
|
354
368
|
if (input.ext !== undefined) {
|
|
355
|
-
this._selfExt =
|
|
369
|
+
this._selfExt = mergeLayer(this._selfExt, input.ext);
|
|
356
370
|
}
|
|
357
371
|
this._scheduleSend();
|
|
358
372
|
}
|
|
@@ -639,7 +653,9 @@ class PresenceController implements PresenceClient {
|
|
|
639
653
|
lastSent: Record<string, unknown> | null,
|
|
640
654
|
withheld: PresenceLayer[],
|
|
641
655
|
): Record<string, unknown> | undefined {
|
|
642
|
-
|
|
656
|
+
// A layer emptied by removing its keys still has to reach peers, who
|
|
657
|
+
// otherwise keep the last one this connection published.
|
|
658
|
+
if (Object.keys(current).length === 0) return lastSent === null ? undefined : current;
|
|
643
659
|
if (presenceLayerBytes(current) <= PRESENCE_LAYER_MAX_BYTES) return current;
|
|
644
660
|
withheld.push(layer);
|
|
645
661
|
return lastSent ?? undefined;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// The document types an open that cannot write returns, so writing through
|
|
2
|
+
// one is a type error rather than a thrown `mutate`.
|
|
3
|
+
|
|
4
|
+
import type {
|
|
5
|
+
ListHandle,
|
|
6
|
+
MapHandle,
|
|
7
|
+
OrderedMapHandle,
|
|
8
|
+
TextHandle,
|
|
9
|
+
} from './handle-types.js';
|
|
10
|
+
import type { OpenDocumentByIdOptions, OpenedDocument } from './open-document.js';
|
|
11
|
+
import type { DocumentUnsubscribe } from './documents.js';
|
|
12
|
+
import type { DocumentStream } from './document-streams.js';
|
|
13
|
+
|
|
14
|
+
/** A map on a `ReadOnlyDocument`: the reads of `MapHandle`, and no writes. */
|
|
15
|
+
export type ReadOnlyMapHandle<T = unknown> = Omit<MapHandle<T>, 'mutate' | 'map' | 'list'> & {
|
|
16
|
+
map<U = unknown>(entryKey: string): ReadOnlyMapHandle<U>;
|
|
17
|
+
list<U = unknown>(entryKey: string): ReadOnlyListHandle<U>;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** A list on a `ReadOnlyDocument`: the reads of `ListHandle`, and no writes. */
|
|
21
|
+
export type ReadOnlyListHandle<T = unknown> = Omit<ListHandle<T>, 'mutate'>;
|
|
22
|
+
|
|
23
|
+
/** An ordered map on a `ReadOnlyDocument`, with no writes. */
|
|
24
|
+
export type ReadOnlyOrderedMapHandle<T = unknown> = Omit<
|
|
25
|
+
OrderedMapHandle<T>,
|
|
26
|
+
'mutate' | 'insert' | 'move' | 'ordered'
|
|
27
|
+
>;
|
|
28
|
+
|
|
29
|
+
/** A text on a `ReadOnlyDocument`, with no writes. */
|
|
30
|
+
export type ReadOnlyTextHandle = Omit<TextHandle, 'update'>;
|
|
31
|
+
|
|
32
|
+
/** Members of a document that write to it. */
|
|
33
|
+
type WritingMembers =
|
|
34
|
+
| 'mutate'
|
|
35
|
+
| 'undo'
|
|
36
|
+
| 'redo'
|
|
37
|
+
| 'createCheckpoint'
|
|
38
|
+
| 'setAutoCheckpointIntervalMs'
|
|
39
|
+
| 'getOrCreateStream'
|
|
40
|
+
| 'stream'
|
|
41
|
+
| 'useCanvas'
|
|
42
|
+
| 'map'
|
|
43
|
+
| 'list'
|
|
44
|
+
| 'text'
|
|
45
|
+
| 'orderedMap'
|
|
46
|
+
| 'dependsOn'
|
|
47
|
+
| 'markRead';
|
|
48
|
+
|
|
49
|
+
/** Members that report writing, which a copy that cannot write never does. */
|
|
50
|
+
type WriterStateMembers = 'savingCount' | 'subscribeUndoState';
|
|
51
|
+
|
|
52
|
+
/** Members of a stream that write to it. */
|
|
53
|
+
type WritingStreamMembers =
|
|
54
|
+
| 'mutate'
|
|
55
|
+
| 'undo'
|
|
56
|
+
| 'redo'
|
|
57
|
+
| 'sealUndoStep'
|
|
58
|
+
| 'map'
|
|
59
|
+
| 'list'
|
|
60
|
+
| 'text'
|
|
61
|
+
| 'orderedMap'
|
|
62
|
+
| 'dependsOn'
|
|
63
|
+
| 'markRead';
|
|
64
|
+
|
|
65
|
+
/** Members whose meaning the update mode decides. */
|
|
66
|
+
type UpdateModeMembers = 'changed' | 'onChanged' | 'reload';
|
|
67
|
+
|
|
68
|
+
/** One stream of a `ReadOnlyDocument`. */
|
|
69
|
+
export type ReadOnlyStream = Omit<
|
|
70
|
+
DocumentStream,
|
|
71
|
+
WritingStreamMembers | 'savingCount' | UpdateModeMembers
|
|
72
|
+
> & {
|
|
73
|
+
/** On a frozen copy, called when this stream moves past what it last
|
|
74
|
+
* loaded, at once if it already has. Each stream is its own line, so each
|
|
75
|
+
* is watched on its own. */
|
|
76
|
+
onChanged(cb: () => void): DocumentUnsubscribe;
|
|
77
|
+
reload(): Promise<void>;
|
|
78
|
+
map<T = unknown>(key: string): ReadOnlyMapHandle<T>;
|
|
79
|
+
list<T = unknown>(key: string): ReadOnlyListHandle<T>;
|
|
80
|
+
text(key: string): ReadOnlyTextHandle;
|
|
81
|
+
orderedMap<T = unknown>(mapKey: string, orderKey: string): ReadOnlyOrderedMapHandle<T>;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* A document opened with `readOnly: true` or `frozen: true`, with nothing on
|
|
86
|
+
* it that writes. A document the signed-in person may only view opens
|
|
87
|
+
* read-only whatever was asked, which `doc.readOnly` reports and a refused
|
|
88
|
+
* `mutate` throws on.
|
|
89
|
+
*
|
|
90
|
+
* A live copy follows every change as it is made. A frozen copy keeps what it
|
|
91
|
+
* loaded: `onChanged` reports the document moving past it, and `reload()`
|
|
92
|
+
* loads the document as it stands, under the same handles, whose subscribers
|
|
93
|
+
* are told what changed.
|
|
94
|
+
*/
|
|
95
|
+
export type ReadOnlyDocument = Omit<
|
|
96
|
+
OpenedDocument,
|
|
97
|
+
WritingMembers | WriterStateMembers | UpdateModeMembers
|
|
98
|
+
> & {
|
|
99
|
+
readonly readOnly: true;
|
|
100
|
+
/** On a frozen copy, called when the document moves past what this copy
|
|
101
|
+
* last loaded (a content edit, an import landing or failing, the document
|
|
102
|
+
* being deleted), at once if it already has. Called once per load. A live
|
|
103
|
+
* copy never calls it. */
|
|
104
|
+
onChanged(cb: () => void): DocumentUnsubscribe;
|
|
105
|
+
/** On a frozen copy, loads the document as it stands now. A live copy
|
|
106
|
+
* already holds it, so this resolves at once. */
|
|
107
|
+
reload(): Promise<void>;
|
|
108
|
+
map<T = unknown>(key: string): ReadOnlyMapHandle<T>;
|
|
109
|
+
list<T = unknown>(key: string): ReadOnlyListHandle<T>;
|
|
110
|
+
text(key: string): ReadOnlyTextHandle;
|
|
111
|
+
orderedMap<T = unknown>(mapKey: string, orderKey: string): ReadOnlyOrderedMapHandle<T>;
|
|
112
|
+
stream(name: string): Promise<ReadOnlyStream>;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/** The modes an open or a reopen can ask for. A frozen copy is read-only, so
|
|
116
|
+
* `frozen: true` takes no `readOnly: false`. */
|
|
117
|
+
export type OpenModeOptions =
|
|
118
|
+
| { frozen?: false; readOnly?: boolean }
|
|
119
|
+
| { frozen: boolean; readOnly?: true };
|
|
120
|
+
|
|
121
|
+
/** `Brass.openDocumentById`, typed by what the open asks for. */
|
|
122
|
+
export interface OpenDocumentById {
|
|
123
|
+
(
|
|
124
|
+
docId: string,
|
|
125
|
+
options: OpenDocumentByIdOptions & { frozen: true; readOnly?: true },
|
|
126
|
+
): Promise<ReadOnlyDocument>;
|
|
127
|
+
(docId: string, options: OpenDocumentByIdOptions & { readOnly: true }): Promise<ReadOnlyDocument>;
|
|
128
|
+
(docId: string, options?: OpenDocumentByIdOptions & { frozen?: false }): Promise<OpenedDocument>;
|
|
129
|
+
(
|
|
130
|
+
docId: string,
|
|
131
|
+
options: Omit<OpenDocumentByIdOptions, 'frozen' | 'readOnly'> & OpenModeOptions,
|
|
132
|
+
): Promise<OpenedDocument | ReadOnlyDocument>;
|
|
133
|
+
}
|
package/src/record-paging.ts
CHANGED
|
@@ -79,8 +79,14 @@ function gunzip(bytes: Uint8Array): Uint8Array {
|
|
|
79
79
|
export const TAG_FIELD = 'type';
|
|
80
80
|
export const BODY_FIELD = 'params';
|
|
81
81
|
|
|
82
|
-
/** Root container keys the record store occupies on a document
|
|
82
|
+
/** Root container keys the record store occupies on a document, and the one
|
|
83
|
+
* `meta` entry that is not paging bookkeeping. Fixed names rather than a
|
|
84
|
+
* document's configurable `rootKey`: a record store is one shape wherever it
|
|
85
|
+
* is written, so the encoding it is written in cannot change where a reader
|
|
86
|
+
* looks for it. */
|
|
83
87
|
export const META_KEY = 'meta';
|
|
88
|
+
export const SCHEMA_VERSION_KEY = 'schema_version';
|
|
89
|
+
export const HEADER_KEY = 'header';
|
|
84
90
|
export const RECORDS_KEY = 'instances';
|
|
85
91
|
export const ORDER_KEY = 'instance_order';
|
|
86
92
|
|
|
@@ -172,9 +178,29 @@ export const PAGE_TAG = '__page__';
|
|
|
172
178
|
* holds. */
|
|
173
179
|
export const RESERVED_KEY_PREFIX = 'brass:';
|
|
174
180
|
|
|
175
|
-
/** Whether `key`
|
|
176
|
-
|
|
177
|
-
|
|
181
|
+
/** Whether `key` in the root map `rootKey` is a paged marker
|
|
182
|
+
* (`PAGED_ROOT_PREFIX`). `meta` is a root an app reads and writes like any
|
|
183
|
+
* other (an import keeps `schema_version` there), so the markers are hidden
|
|
184
|
+
* from every read of it and refused on write: an app that copies or clears
|
|
185
|
+
* `meta` generically would otherwise delete one, and every reader after that
|
|
186
|
+
* takes the unpaged path over a map holding pages, seeing the store's
|
|
187
|
+
* `brass:` keys in place of its records. A `paged:` key in any other root is
|
|
188
|
+
* an ordinary record key. */
|
|
189
|
+
export function isPagedMarkerEntry(rootKey: string | null, key: string): boolean {
|
|
190
|
+
return rootCarriesMarkers(rootKey) && key.startsWith(PAGED_ROOT_PREFIX);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Whether the root map `rootKey` is the one the paged markers are kept in,
|
|
194
|
+
* so a reader can skip the per-key test on every other root. */
|
|
195
|
+
export function rootCarriesMarkers(rootKey: string | null): boolean {
|
|
196
|
+
return rootKey === META_KEY;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Whether `key` names one of the platform's own entries rather than a record:
|
|
200
|
+
* the store's bookkeeping under `RESERVED_KEY_PREFIX` in any root, and, when
|
|
201
|
+
* the root is named, a paged marker in `meta`. */
|
|
202
|
+
export function isReservedKey(key: string, rootKey: string | null = null): boolean {
|
|
203
|
+
return key.startsWith(RESERVED_KEY_PREFIX) || isPagedMarkerEntry(rootKey, key);
|
|
178
204
|
}
|
|
179
205
|
|
|
180
206
|
/** The one key a reader cannot carry as an ordinary object property.
|
|
@@ -197,7 +223,10 @@ const UNCARRIABLE_RECORD_KEY = '__proto__';
|
|
|
197
223
|
* is from the key alone. The alternative, escaping, would put the escape on
|
|
198
224
|
* the read of every key of every map, paged or not, and a store that pages
|
|
199
225
|
* later cannot escape what it already holds. */
|
|
200
|
-
export function assertUsableRecordKey(
|
|
226
|
+
export function assertUsableRecordKey(
|
|
227
|
+
key: string,
|
|
228
|
+
rootKey: string | null = null,
|
|
229
|
+
): void {
|
|
201
230
|
if (key === UNCARRIABLE_RECORD_KEY) {
|
|
202
231
|
throw new Error(
|
|
203
232
|
`Key "${key}" is reserved: a record key cannot be ` +
|
|
@@ -205,6 +234,12 @@ export function assertUsableRecordKey(key: string): void {
|
|
|
205
234
|
`rather than one of its entries.`,
|
|
206
235
|
);
|
|
207
236
|
}
|
|
237
|
+
if (isPagedMarkerEntry(rootKey, key)) {
|
|
238
|
+
throw new Error(
|
|
239
|
+
`Key "${key}" is reserved: a key in "${META_KEY}" cannot start with ` +
|
|
240
|
+
`"${PAGED_ROOT_PREFIX}", which marks how a collection is stored.`,
|
|
241
|
+
);
|
|
242
|
+
}
|
|
208
243
|
if (!isReservedKey(key)) return;
|
|
209
244
|
throw new Error(
|
|
210
245
|
`Key "${key}" is reserved: a record key cannot start with ` +
|
|
@@ -257,11 +292,25 @@ export function repackRecord(
|
|
|
257
292
|
];
|
|
258
293
|
}
|
|
259
294
|
|
|
260
|
-
/**
|
|
261
|
-
*
|
|
295
|
+
/**
|
|
296
|
+
* The stored value a tag and a body carry: the body itself when there is no
|
|
297
|
+
* tag, else the `{ type, params }` pair a typed record was split into.
|
|
298
|
+
*
|
|
299
|
+
* A null tag is not an absent field, it is the statement that this record was
|
|
300
|
+
* never split: a map holds whatever its writer assigned and an app's own value
|
|
301
|
+
* has no tag to take off it. Every reader that rebuilds a value from the two
|
|
302
|
+
* comes through here, because a reader that built the pair unconditionally
|
|
303
|
+
* would hand an app `{ type: null, params: 5 }` where the same record read out
|
|
304
|
+
* of a CRDT document is `5`, which is the encoding showing through.
|
|
305
|
+
*/
|
|
306
|
+
export function recordValueOf(tag: string | null, body: unknown): unknown {
|
|
307
|
+
if (tag === null) return body;
|
|
308
|
+
return { [TAG_FIELD]: tag, [BODY_FIELD]: body };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** The stored value a page triple carries. See `recordValueOf`. */
|
|
262
312
|
export function unpackRecord(packed: PackedRecord<unknown>): unknown {
|
|
263
|
-
|
|
264
|
-
return { [TAG_FIELD]: packed[1], [BODY_FIELD]: packed[2] };
|
|
313
|
+
return recordValueOf(packed[1], packed[2]);
|
|
265
314
|
}
|
|
266
315
|
|
|
267
316
|
/** The tag + body a page triple names, for a reader that walks the two
|
|
@@ -388,40 +437,120 @@ export function encodePageFromEncoded(
|
|
|
388
437
|
encoded: string[],
|
|
389
438
|
keys: string[],
|
|
390
439
|
): EncodedPage {
|
|
440
|
+
const bytes = encodePageBytes(encoded, keys);
|
|
441
|
+
return {
|
|
442
|
+
body: strFromU8(bytes.body, true),
|
|
443
|
+
keys: strFromU8(bytes.keys, true),
|
|
444
|
+
chars: bytes.chars,
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** A page's two stored strings as raw bytes, for a store that holds bytes
|
|
449
|
+
* rather than the latin1 strings a loro container needs. */
|
|
450
|
+
export interface EncodedPageBytes {
|
|
451
|
+
body: Uint8Array;
|
|
452
|
+
keys: Uint8Array;
|
|
453
|
+
chars: number;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** Encode a page to the gzip bytes `encodePageFromEncoded` widens into
|
|
457
|
+
* strings. Byte-identical either way, so a page written into a container and
|
|
458
|
+
* a page written into a byte store hold the same compressed run. */
|
|
459
|
+
export function encodePageBytes(
|
|
460
|
+
encoded: string[],
|
|
461
|
+
keys: string[],
|
|
462
|
+
): EncodedPageBytes {
|
|
391
463
|
const body = '[' + encoded.join(',') + ']';
|
|
392
464
|
return {
|
|
393
|
-
body:
|
|
394
|
-
keys:
|
|
465
|
+
body: gzipJsonBytes(body),
|
|
466
|
+
keys: gzipJsonBytes(JSON.stringify(keys)),
|
|
395
467
|
chars: body.length,
|
|
396
468
|
};
|
|
397
469
|
}
|
|
398
470
|
|
|
399
|
-
|
|
400
|
-
|
|
471
|
+
/** Gzip a JSON string to raw bytes. The latin1 string a page container stores
|
|
472
|
+
* is these bytes widened; a store that holds bytes directly keeps them as
|
|
473
|
+
* they are, so one compressor serves both. */
|
|
474
|
+
export function gzipJsonBytes(json: string): Uint8Array {
|
|
475
|
+
return gzip(strToU8(json));
|
|
401
476
|
}
|
|
402
477
|
|
|
403
|
-
/**
|
|
478
|
+
/** Gunzip bytes holding UTF-8 text. */
|
|
479
|
+
export function gunzipText(bytes: Uint8Array): string {
|
|
480
|
+
return strFromU8(gunzip(bytes));
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/** Parse gzipped JSON bytes as an array. Returns null for anything that does
|
|
404
484
|
* not decompress to a JSON array, so a reader tolerates a corrupt or stray
|
|
405
485
|
* page rather than throwing. */
|
|
406
|
-
function
|
|
407
|
-
if (typeof value !== 'string') return null;
|
|
486
|
+
export function decodeGzippedJsonArray(bytes: Uint8Array): unknown[] | null {
|
|
408
487
|
try {
|
|
409
|
-
const parsed = JSON.parse(strFromU8(gunzip(
|
|
488
|
+
const parsed: unknown = JSON.parse(strFromU8(gunzip(bytes)));
|
|
410
489
|
return Array.isArray(parsed) ? (parsed as unknown[]) : null;
|
|
411
490
|
} catch {
|
|
412
491
|
return null;
|
|
413
492
|
}
|
|
414
493
|
}
|
|
415
494
|
|
|
495
|
+
/** Parse one of a page's stored strings. Returns null for anything that does
|
|
496
|
+
* not decompress to a JSON array, so a reader tolerates a corrupt or stray
|
|
497
|
+
* page rather than throwing. */
|
|
498
|
+
function decodePageField(value: unknown): unknown[] | null {
|
|
499
|
+
if (typeof value !== 'string') return null;
|
|
500
|
+
return decodeGzippedJsonArray(strToU8(value, true));
|
|
501
|
+
}
|
|
502
|
+
|
|
416
503
|
/** The record keys a page holds, from the page's own `keys` string. Reading
|
|
417
504
|
* this field alone is what makes learning a store's key locations cost the
|
|
418
505
|
* keys rather than every record body. */
|
|
419
506
|
export function decodePageKeys(keysField: unknown): string[] {
|
|
420
507
|
const parsed = decodePageField(keysField);
|
|
421
508
|
if (parsed === null) return [];
|
|
509
|
+
return keyStrings(parsed);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/** The record keys a page holds, from the gzipped bytes of its key list. The
|
|
513
|
+
* same answer `decodePageKeys` gives for a container-stored page. */
|
|
514
|
+
export function decodePageKeyBytes(keysBytes: Uint8Array): string[] {
|
|
515
|
+
const parsed = decodeGzippedJsonArray(keysBytes);
|
|
516
|
+
if (parsed === null) return [];
|
|
517
|
+
return keyStrings(parsed);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function keyStrings(parsed: unknown[]): string[] {
|
|
422
521
|
return parsed.filter((k): k is string => typeof k === 'string');
|
|
423
522
|
}
|
|
424
523
|
|
|
524
|
+
/** Zip a page's decoded key list and `[tag, body]` pairs back into triples. A
|
|
525
|
+
* key with no pair (or a pair with no key) is dropped: the two arrays are
|
|
526
|
+
* written together and index each other, so a length mismatch means a page
|
|
527
|
+
* nothing can read as records. */
|
|
528
|
+
function zipPageRecords(
|
|
529
|
+
keys: readonly string[],
|
|
530
|
+
pairs: readonly unknown[],
|
|
531
|
+
): PackedRecord<unknown>[] {
|
|
532
|
+
const out: PackedRecord<unknown>[] = [];
|
|
533
|
+
for (let i = 0; i < keys.length && i < pairs.length; i += 1) {
|
|
534
|
+
const pair = pairs[i];
|
|
535
|
+
if (!Array.isArray(pair)) continue;
|
|
536
|
+
if (typeof pair[0] !== 'string' && pair[0] !== null) continue;
|
|
537
|
+
out.push([keys[i]!, pair[0], pair[1]]);
|
|
538
|
+
}
|
|
539
|
+
return out;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/** A page's records from the gzipped bytes of its two stored strings. */
|
|
543
|
+
export function decodePageRecordBytes(
|
|
544
|
+
keysBytes: Uint8Array,
|
|
545
|
+
bodyBytes: Uint8Array,
|
|
546
|
+
): PackedRecord<unknown>[] {
|
|
547
|
+
const keys = decodePageKeyBytes(keysBytes);
|
|
548
|
+
if (keys.length === 0) return [];
|
|
549
|
+
const pairs = decodeGzippedJsonArray(bodyBytes);
|
|
550
|
+
if (pairs === null) return [];
|
|
551
|
+
return zipPageRecords(keys, pairs);
|
|
552
|
+
}
|
|
553
|
+
|
|
425
554
|
/** A page's records, zipped back into `[key, tag, body]` triples from the two
|
|
426
555
|
* strings the page stores. A key with no body (or a body with no key) is
|
|
427
556
|
* dropped: the two arrays are written together and index each other, so a
|
|
@@ -433,14 +562,7 @@ export function decodePageRecords(page: unknown): PackedRecord<unknown>[] {
|
|
|
433
562
|
if (keys.length === 0) return [];
|
|
434
563
|
const pairs = decodePageField(fields[BODY_FIELD]);
|
|
435
564
|
if (pairs === null) return [];
|
|
436
|
-
|
|
437
|
-
for (let i = 0; i < keys.length && i < pairs.length; i += 1) {
|
|
438
|
-
const pair = pairs[i];
|
|
439
|
-
if (!Array.isArray(pair)) continue;
|
|
440
|
-
if (typeof pair[0] !== 'string' && pair[0] !== null) continue;
|
|
441
|
-
out.push([keys[i]!, pair[0], pair[1]]);
|
|
442
|
-
}
|
|
443
|
-
return out;
|
|
565
|
+
return zipPageRecords(keys, pairs);
|
|
444
566
|
}
|
|
445
567
|
|
|
446
568
|
// How many bodies to weigh when estimating a population's record width. A
|