@brass-build/client 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +142 -41
- package/CHANGELOG.md +93 -4
- package/README.md +666 -179
- package/dist/acting-organization.d.ts.map +1 -1
- package/dist/acting-organization.js +6 -11
- package/dist/acting-organization.js.map +1 -1
- package/dist/api-fetch.d.ts +8 -0
- package/dist/api-fetch.d.ts.map +1 -1
- package/dist/api-fetch.js +35 -8
- package/dist/api-fetch.js.map +1 -1
- package/dist/batched-get.d.ts +11 -0
- package/dist/batched-get.d.ts.map +1 -0
- package/dist/batched-get.js +92 -0
- package/dist/batched-get.js.map +1 -0
- package/dist/call-service.d.ts +4 -0
- package/dist/call-service.d.ts.map +1 -1
- package/dist/call-service.js +66 -3
- package/dist/call-service.js.map +1 -1
- package/dist/client.d.ts +51 -2
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +982 -124
- package/dist/client.js.map +1 -1
- package/dist/document-checkpoints.d.ts +25 -2
- package/dist/document-checkpoints.d.ts.map +1 -1
- package/dist/document-checkpoints.js +258 -61
- package/dist/document-checkpoints.js.map +1 -1
- package/dist/document-commands.d.ts.map +1 -1
- package/dist/document-commands.js +5 -3
- package/dist/document-commands.js.map +1 -1
- package/dist/document-contents.d.ts +10 -0
- package/dist/document-contents.d.ts.map +1 -1
- package/dist/document-contents.js +23 -1
- package/dist/document-contents.js.map +1 -1
- package/dist/document-set-subscription.d.ts +56 -0
- package/dist/document-set-subscription.d.ts.map +1 -0
- package/dist/document-set-subscription.js +240 -0
- package/dist/document-set-subscription.js.map +1 -0
- package/dist/document-signals.d.ts +6 -0
- package/dist/document-signals.d.ts.map +1 -1
- package/dist/document-signals.js +31 -1
- package/dist/document-signals.js.map +1 -1
- package/dist/document-streams.d.ts +36 -2
- package/dist/document-streams.d.ts.map +1 -1
- package/dist/document-streams.js +69 -18
- package/dist/document-streams.js.map +1 -1
- package/dist/document-thumbnail-capture.d.ts +12 -2
- package/dist/document-thumbnail-capture.d.ts.map +1 -1
- package/dist/document-thumbnail-capture.js +103 -27
- package/dist/document-thumbnail-capture.js.map +1 -1
- package/dist/documents-namespace.d.ts +92 -10
- package/dist/documents-namespace.d.ts.map +1 -1
- package/dist/documents-namespace.js +285 -39
- package/dist/documents-namespace.js.map +1 -1
- package/dist/documents.d.ts +173 -8
- package/dist/documents.d.ts.map +1 -1
- package/dist/documents.js +1778 -310
- package/dist/documents.js.map +1 -1
- package/dist/errors.d.ts +3 -3
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/fetch-retry.d.ts +1 -0
- package/dist/fetch-retry.d.ts.map +1 -1
- package/dist/fetch-retry.js +2 -0
- package/dist/fetch-retry.js.map +1 -1
- package/dist/file-choice.d.ts +2 -0
- package/dist/file-choice.d.ts.map +1 -0
- package/dist/file-choice.js +27 -0
- package/dist/file-choice.js.map +1 -0
- package/dist/guest-link.d.ts +18 -1
- package/dist/guest-link.d.ts.map +1 -1
- package/dist/guest-link.js +329 -1
- package/dist/guest-link.js.map +1 -1
- package/dist/handle-types.d.ts +67 -5
- package/dist/handle-types.d.ts.map +1 -1
- package/dist/handles.d.ts +15 -19
- package/dist/handles.d.ts.map +1 -1
- package/dist/handles.js +231 -76
- package/dist/handles.js.map +1 -1
- package/dist/hosted-ui.d.ts +61 -0
- package/dist/hosted-ui.d.ts.map +1 -1
- package/dist/hosted-ui.js +94 -42
- package/dist/hosted-ui.js.map +1 -1
- package/dist/import-contract.d.ts +74 -0
- package/dist/import-contract.d.ts.map +1 -0
- package/dist/import-contract.js +163 -0
- package/dist/import-contract.js.map +1 -0
- package/dist/import-guest.d.ts +9 -0
- package/dist/import-guest.d.ts.map +1 -0
- package/dist/import-guest.js +155 -0
- package/dist/import-guest.js.map +1 -0
- package/dist/import-report.d.ts +17 -0
- package/dist/import-report.d.ts.map +1 -0
- package/dist/import-report.js +7 -0
- package/dist/import-report.js.map +1 -0
- package/dist/importer-wait.d.ts +2 -0
- package/dist/importer-wait.d.ts.map +1 -0
- package/dist/importer-wait.js +5 -0
- package/dist/importer-wait.js.map +1 -0
- package/dist/importer.d.ts +18 -4
- package/dist/importer.d.ts.map +1 -1
- package/dist/importer.js +322 -105
- package/dist/importer.js.map +1 -1
- package/dist/in-development.d.ts +2 -0
- package/dist/in-development.d.ts.map +1 -0
- package/dist/in-development.js +10 -0
- package/dist/in-development.js.map +1 -0
- package/dist/index.d.ts +16 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +24 -8
- package/dist/index.js.map +1 -1
- package/dist/init-keys.d.ts +1 -1
- package/dist/init-keys.d.ts.map +1 -1
- package/dist/init-keys.js +6 -1
- package/dist/init-keys.js.map +1 -1
- package/dist/input-documents.d.ts +67 -0
- package/dist/input-documents.d.ts.map +1 -0
- package/dist/input-documents.js +693 -0
- package/dist/input-documents.js.map +1 -0
- package/dist/inputs-status.d.ts +80 -0
- package/dist/inputs-status.d.ts.map +1 -0
- package/dist/inputs-status.js +460 -0
- package/dist/inputs-status.js.map +1 -0
- package/dist/inputs.d.ts +2 -0
- package/dist/inputs.d.ts.map +1 -0
- package/dist/inputs.js +793 -0
- package/dist/inputs.js.map +1 -0
- package/dist/internal.d.ts +6 -1
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +4 -1
- package/dist/internal.js.map +1 -1
- package/dist/join-content.d.ts +33 -0
- package/dist/join-content.d.ts.map +1 -0
- package/dist/join-content.js +114 -0
- package/dist/join-content.js.map +1 -0
- package/dist/join-stamp.d.ts +3 -0
- package/dist/join-stamp.d.ts.map +1 -0
- package/dist/join-stamp.js +12 -0
- package/dist/join-stamp.js.map +1 -0
- package/dist/manifest-drift.d.ts +9 -0
- package/dist/manifest-drift.d.ts.map +1 -0
- package/dist/manifest-drift.js +46 -0
- package/dist/manifest-drift.js.map +1 -0
- package/dist/open-document.d.ts +109 -2
- package/dist/open-document.d.ts.map +1 -1
- package/dist/open-document.js +1822 -811
- package/dist/open-document.js.map +1 -1
- package/dist/page-restore.d.ts +12 -0
- package/dist/page-restore.d.ts.map +1 -0
- package/dist/page-restore.js +23 -0
- package/dist/page-restore.js.map +1 -0
- package/dist/placement.d.ts +13 -0
- package/dist/placement.d.ts.map +1 -0
- package/dist/placement.js +34 -0
- package/dist/placement.js.map +1 -0
- package/dist/presence.d.ts +1 -0
- package/dist/presence.d.ts.map +1 -1
- package/dist/presence.js +16 -4
- package/dist/presence.js.map +1 -1
- package/dist/read-only-document.d.ts +87 -0
- package/dist/read-only-document.d.ts.map +1 -0
- package/dist/read-only-document.js +4 -0
- package/dist/read-only-document.js.map +1 -0
- package/dist/record-paging.d.ts +63 -6
- package/dist/record-paging.d.ts.map +1 -1
- package/dist/record-paging.js +122 -29
- package/dist/record-paging.js.map +1 -1
- package/dist/references.d.ts +40 -0
- package/dist/references.d.ts.map +1 -0
- package/dist/references.js +135 -0
- package/dist/references.js.map +1 -0
- package/dist/request-lane.d.ts +5 -0
- package/dist/request-lane.d.ts.map +1 -0
- package/dist/request-lane.js +48 -0
- package/dist/request-lane.js.map +1 -0
- package/dist/sealed-arrow.d.ts +170 -0
- package/dist/sealed-arrow.d.ts.map +1 -0
- package/dist/sealed-arrow.js +769 -0
- package/dist/sealed-arrow.js.map +1 -0
- package/dist/sealed-cache.d.ts +21 -0
- package/dist/sealed-cache.d.ts.map +1 -0
- package/dist/sealed-cache.js +247 -0
- package/dist/sealed-cache.js.map +1 -0
- package/dist/sealed-encode.d.ts +87 -0
- package/dist/sealed-encode.d.ts.map +1 -0
- package/dist/sealed-encode.js +876 -0
- package/dist/sealed-encode.js.map +1 -0
- package/dist/sealed-handles.d.ts +45 -0
- package/dist/sealed-handles.d.ts.map +1 -0
- package/dist/sealed-handles.js +408 -0
- package/dist/sealed-handles.js.map +1 -0
- package/dist/sealed-store.d.ts +242 -0
- package/dist/sealed-store.d.ts.map +1 -0
- package/dist/sealed-store.js +1155 -0
- package/dist/sealed-store.js.map +1 -0
- package/dist/serializer.d.ts +6 -15
- package/dist/serializer.d.ts.map +1 -1
- package/dist/session-toolbar.d.ts +20 -5
- package/dist/session-toolbar.d.ts.map +1 -1
- package/dist/session-toolbar.js +419 -67
- package/dist/session-toolbar.js.map +1 -1
- package/dist/session.d.ts +38 -1
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +562 -79
- package/dist/session.js.map +1 -1
- package/dist/sha256.d.ts +3 -0
- package/dist/sha256.d.ts.map +1 -0
- package/dist/sha256.js +74 -0
- package/dist/sha256.js.map +1 -0
- package/dist/stable-handles.d.ts +19 -0
- package/dist/stable-handles.d.ts.map +1 -0
- package/dist/stable-handles.js +193 -0
- package/dist/stable-handles.js.map +1 -0
- package/dist/staged-seal.d.ts +2 -0
- package/dist/staged-seal.d.ts.map +1 -0
- package/dist/staged-seal.js +76 -0
- package/dist/staged-seal.js.map +1 -0
- package/dist/subscriber-delivery.d.ts +8 -0
- package/dist/subscriber-delivery.d.ts.map +1 -0
- package/dist/subscriber-delivery.js +31 -0
- package/dist/subscriber-delivery.js.map +1 -0
- package/dist/tail-op-import.d.ts +10 -0
- package/dist/tail-op-import.d.ts.map +1 -0
- package/dist/tail-op-import.js +33 -0
- package/dist/tail-op-import.js.map +1 -0
- package/dist/topic-lifecycle.d.ts +6 -0
- package/dist/topic-lifecycle.d.ts.map +1 -1
- package/dist/topic-lifecycle.js +32 -2
- package/dist/topic-lifecycle.js.map +1 -1
- package/dist/trial-replace-dialog.d.ts +10 -0
- package/dist/trial-replace-dialog.d.ts.map +1 -0
- package/dist/trial-replace-dialog.js +153 -0
- package/dist/trial-replace-dialog.js.map +1 -0
- package/dist/types.d.ts +8 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/undo.d.ts +60 -5
- package/dist/undo.d.ts.map +1 -1
- package/dist/undo.js +344 -75
- package/dist/undo.js.map +1 -1
- package/dist/version-bar.d.ts +1 -0
- package/dist/version-bar.d.ts.map +1 -1
- package/dist/version-bar.js +2 -1
- package/dist/version-bar.js.map +1 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/dist/ws-client.d.ts +21 -0
- package/dist/ws-client.d.ts.map +1 -1
- package/dist/ws-client.js +192 -8
- package/dist/ws-client.js.map +1 -1
- package/dist/ws-frame-limit.d.ts +1 -0
- package/dist/ws-frame-limit.d.ts.map +1 -1
- package/dist/ws-frame-limit.js.map +1 -1
- package/docs/app-manifest.md +121 -17
- package/docs/custom-chrome.md +24 -16
- package/docs/hosted-ui-advanced.md +38 -6
- package/docs/imported-documents.md +104 -51
- package/docs/presence.md +16 -4
- package/docs/schemas.md +8 -5
- package/docs/services.md +6 -6
- package/docs/streams.md +139 -150
- package/docs/text-fields.md +26 -19
- package/package.json +5 -2
- package/src/acting-organization.ts +6 -12
- package/src/api-fetch.ts +53 -3
- package/src/auth-flow.ts +1 -1
- package/src/batched-get.ts +117 -0
- package/src/call-service.ts +78 -3
- package/src/client.ts +1092 -121
- package/src/document-checkpoints.ts +307 -65
- package/src/document-commands.ts +5 -5
- package/src/document-contents.ts +27 -1
- package/src/document-set-subscription.ts +274 -0
- package/src/document-signals.ts +47 -5
- package/src/document-streams.ts +85 -16
- package/src/document-thumbnail-capture.ts +118 -32
- package/src/documents-namespace.ts +539 -84
- package/src/documents.ts +2017 -348
- package/src/errors.ts +17 -6
- package/src/fetch-retry.ts +5 -0
- package/src/file-choice.ts +26 -0
- package/src/guest-link.ts +362 -1
- package/src/handle-types.ts +73 -47
- package/src/handles.ts +253 -83
- package/src/hosted-ui.ts +211 -50
- package/src/import-contract.ts +227 -0
- package/src/import-guest.ts +193 -0
- package/src/import-report.ts +24 -0
- package/src/importer-wait.ts +4 -0
- package/src/importer.ts +386 -131
- package/src/in-development.ts +10 -0
- package/src/index.ts +76 -16
- package/src/init-keys.ts +6 -1
- package/src/input-documents.ts +819 -0
- package/src/inputs-status.ts +598 -0
- package/src/inputs.ts +1090 -0
- package/src/internal.ts +7 -0
- package/src/join-content.ts +135 -0
- package/src/join-stamp.ts +12 -0
- package/src/manifest-drift.ts +54 -0
- package/src/open-document.ts +2346 -930
- package/src/page-restore.ts +38 -0
- package/src/placement.ts +54 -0
- package/src/presence.ts +21 -5
- package/src/read-only-document.ts +133 -0
- package/src/record-paging.ts +147 -25
- package/src/references.ts +207 -0
- package/src/request-lane.ts +59 -0
- package/src/sealed-arrow.ts +843 -0
- package/src/sealed-cache.ts +267 -0
- package/src/sealed-encode.ts +1015 -0
- package/src/sealed-handles.ts +486 -0
- package/src/sealed-store.ts +1330 -0
- package/src/serializer.ts +8 -20
- package/src/session-toolbar.ts +463 -72
- package/src/session.ts +740 -99
- package/src/sha256.ts +65 -0
- package/src/stable-handles.ts +235 -0
- package/src/staged-seal.ts +90 -0
- package/src/subscriber-delivery.ts +37 -0
- package/src/tail-op-import.ts +30 -0
- package/src/topic-lifecycle.ts +38 -2
- package/src/trial-replace-dialog.ts +163 -0
- package/src/types.ts +6 -6
- package/src/undo.ts +386 -73
- package/src/version-bar.ts +5 -1
- package/src/version.ts +1 -1
- package/src/ws-client.ts +208 -16
- package/src/ws-frame-limit.ts +1 -0
|
@@ -0,0 +1,598 @@
|
|
|
1
|
+
// The status a document reports for the keys it records references under:
|
|
2
|
+
// fetched from the platform, kept current by the nudges its topic carries,
|
|
3
|
+
// and laid over what the document's own reserved roots hold, so a local
|
|
4
|
+
// change to the references or the read marks (an edit, an Undo, a Redo)
|
|
5
|
+
// shows at once rather than after the platform has projected it.
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
applyInputStatusDelta,
|
|
9
|
+
decodeInputsRoots,
|
|
10
|
+
inputReadMarkAfter,
|
|
11
|
+
inputReferenceId,
|
|
12
|
+
inputStampFromUpdatedAt,
|
|
13
|
+
parseInputStatusDelta,
|
|
14
|
+
parseInputStatusPage,
|
|
15
|
+
INPUT_STATUS_CURSOR_PARAM,
|
|
16
|
+
INPUT_STATUS_LINE_PARAM,
|
|
17
|
+
INPUT_STATUS_SINCE_PARAM,
|
|
18
|
+
type InputFileChange,
|
|
19
|
+
type InputReadMark,
|
|
20
|
+
type InputReferenceWire as WireReference,
|
|
21
|
+
type InputsKeyState,
|
|
22
|
+
type InputStatusWire as WireStatus,
|
|
23
|
+
type InputStatusChangeWire as WireChange,
|
|
24
|
+
} from './inputs.js';
|
|
25
|
+
import type { InputReference } from './hosted-ui.js';
|
|
26
|
+
|
|
27
|
+
export type { InputFileChange, InputReference };
|
|
28
|
+
|
|
29
|
+
/** One input document as it is now. */
|
|
30
|
+
export interface InputStatusDocument {
|
|
31
|
+
doc_id: string;
|
|
32
|
+
name: string;
|
|
33
|
+
/** The document's family, absent on a document that has none. */
|
|
34
|
+
family?: string;
|
|
35
|
+
updated_at: string;
|
|
36
|
+
/** The key's references that include this input, nearest first. */
|
|
37
|
+
from: InputReference[];
|
|
38
|
+
/** @internal Present while the app that imports the file has not. */
|
|
39
|
+
awaits_import?: true;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** @internal One input the document has not read at its current version. */
|
|
43
|
+
export interface InputStatusChange {
|
|
44
|
+
doc_id: string;
|
|
45
|
+
change: InputFileChange;
|
|
46
|
+
/** The key's references that include the file, nearest first. Empty for a
|
|
47
|
+
* removed file no reference includes any more. */
|
|
48
|
+
from: InputReference[];
|
|
49
|
+
/** On a removed file, the version it left the key at. */
|
|
50
|
+
updated_at?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** One key's status, as `subscribeInputDocuments` hands it to `status`. */
|
|
54
|
+
export interface InputStatus {
|
|
55
|
+
key: string;
|
|
56
|
+
/** The references recorded under the key. Pass them back to
|
|
57
|
+
* `Brass.chooseInputs` as `selected`. */
|
|
58
|
+
refs: InputReference[];
|
|
59
|
+
/** The inputs this reader can open. */
|
|
60
|
+
inputs: InputStatusDocument[];
|
|
61
|
+
/** @internal */
|
|
62
|
+
changed: InputStatusChange[];
|
|
63
|
+
/** Inputs this document has read before that this reader can no longer
|
|
64
|
+
* open. */
|
|
65
|
+
hidden_count: number;
|
|
66
|
+
/** True when the document's keys together reach more inputs than a
|
|
67
|
+
* document tracks, so some inputs are left out. */
|
|
68
|
+
truncated: boolean;
|
|
69
|
+
/** False while the list of inputs may not be complete. Through
|
|
70
|
+
* `subscribeInputDocuments`, also false until every handler call has
|
|
71
|
+
* finished and the set has stopped changing. A result
|
|
72
|
+
* computed over every input belongs on a status that reads true. */
|
|
73
|
+
settled: boolean;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* How a document reaches the status routes. Each answers the body the server
|
|
78
|
+
* sent, or null for a 404.
|
|
79
|
+
*/
|
|
80
|
+
export interface InputsStatusRuntime {
|
|
81
|
+
readKey(key: string, query: Record<string, string>): Promise<unknown>;
|
|
82
|
+
/** Set when the document cannot record references: what `dependsOn`
|
|
83
|
+
* throws when it is given any. */
|
|
84
|
+
refusesReferences?: string;
|
|
85
|
+
/** Opens an input file, as `Brass.openDocumentById` does. */
|
|
86
|
+
open?: (docId: string, options?: import('./open-document.js').OpenDocumentByIdOptions) =>
|
|
87
|
+
Promise<import('./open-document.js').OpenedDocument>;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* @internal The runtime of a document created while signed out: every key reads as one
|
|
92
|
+
* with no references and no files. The sign-in that keeps the document returns
|
|
93
|
+
* to a new page load, which opens it by id with the status routes.
|
|
94
|
+
*/
|
|
95
|
+
export const TEMPORARY_INPUTS_RUNTIME: InputsStatusRuntime = {
|
|
96
|
+
readKey: () => Promise.resolve(null),
|
|
97
|
+
refusesReferences: 'Sign in to keep your work before choosing the files it depends on.',
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/** The plain values of the document's two reserved roots. */
|
|
101
|
+
export interface InputsLocalRoots {
|
|
102
|
+
refs: unknown;
|
|
103
|
+
read: unknown;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** What a hub needs from the document it serves. */
|
|
107
|
+
export interface InputStatusHost {
|
|
108
|
+
runtime: InputsStatusRuntime;
|
|
109
|
+
/** The line the document holds, named on every request. */
|
|
110
|
+
lineId(): string | null;
|
|
111
|
+
/** The reserved roots as the document holds them, or null for a document
|
|
112
|
+
* with no engine (a frozen copy), which reports the platform's status. */
|
|
113
|
+
localRoots(): InputsLocalRoots | null;
|
|
114
|
+
/** Calls `cb` synchronously whenever either reserved root changes. */
|
|
115
|
+
watchLocalRoots(cb: () => void): () => void;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** How many times a paged read restarts when the status moves under it. */
|
|
119
|
+
const FULL_READ_ATTEMPTS = 3;
|
|
120
|
+
|
|
121
|
+
/** The wait before re-reading after a failed read, doubling to a ceiling. */
|
|
122
|
+
const RETRY_FIRST_MS = 1_000;
|
|
123
|
+
const RETRY_CEILING_MS = 30_000;
|
|
124
|
+
|
|
125
|
+
type Listener<S> = (status: S) => void;
|
|
126
|
+
|
|
127
|
+
// A changed entry the platform reported, with the read mark the document held
|
|
128
|
+
// for it when the report arrived. Once the platform stops reporting an entry
|
|
129
|
+
// the document's own mark acknowledged, the entry is kept with that mark
|
|
130
|
+
// (`ackMark`), so an Undo of the mark brings it back before the platform has
|
|
131
|
+
// heard of the Undo.
|
|
132
|
+
interface Reported {
|
|
133
|
+
change: WireChange;
|
|
134
|
+
markAtReport: InputReadMark | undefined;
|
|
135
|
+
ackMark?: InputReadMark;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
abstract class Tracker<W, S> {
|
|
139
|
+
protected wire: W | null = null;
|
|
140
|
+
protected readonly listeners = new Set<Listener<S>>();
|
|
141
|
+
// Listeners added after a status landed, still owed their first call. A
|
|
142
|
+
// status emitted before that call is due reaches them through `emit`,
|
|
143
|
+
// which is what keeps a later status from being followed by an earlier one.
|
|
144
|
+
private readonly owedFirst = new Set<Listener<S>>();
|
|
145
|
+
private readonly failureListeners = new Set<(err: unknown) => void>();
|
|
146
|
+
private lastEmitted: string | null = null;
|
|
147
|
+
private running = false;
|
|
148
|
+
private again = false;
|
|
149
|
+
private retryMs = RETRY_FIRST_MS;
|
|
150
|
+
private retryTimer: ReturnType<typeof setTimeout> | null = null;
|
|
151
|
+
protected disposed = false;
|
|
152
|
+
|
|
153
|
+
constructor(protected readonly host: InputStatusHost) {}
|
|
154
|
+
|
|
155
|
+
add(cb: Listener<S>): void {
|
|
156
|
+
this.listeners.add(cb);
|
|
157
|
+
if (this.wire !== null) {
|
|
158
|
+
this.owedFirst.add(cb);
|
|
159
|
+
queueMicrotask(() => {
|
|
160
|
+
if (!this.owedFirst.delete(cb)) return;
|
|
161
|
+
if (!this.disposed && this.listeners.has(cb)) call(cb, this.compute());
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Hears every read that fails, until the returned function is called. */
|
|
167
|
+
onFailure(cb: (err: unknown) => void): () => void {
|
|
168
|
+
this.failureListeners.add(cb);
|
|
169
|
+
return () => this.failureListeners.delete(cb);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
remove(cb: Listener<S>): boolean {
|
|
173
|
+
this.listeners.delete(cb);
|
|
174
|
+
this.owedFirst.delete(cb);
|
|
175
|
+
if (this.listeners.size === 0) this.dispose();
|
|
176
|
+
return this.disposed;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
dispose(): void {
|
|
180
|
+
this.disposed = true;
|
|
181
|
+
this.listeners.clear();
|
|
182
|
+
this.owedFirst.clear();
|
|
183
|
+
this.failureListeners.clear();
|
|
184
|
+
if (this.retryTimer !== null) clearTimeout(this.retryTimer);
|
|
185
|
+
this.retryTimer = null;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Fetch whatever moved, coalescing with a read already out. */
|
|
189
|
+
pump(): void {
|
|
190
|
+
if (this.disposed) return;
|
|
191
|
+
if (this.running) {
|
|
192
|
+
this.again = true;
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
this.running = true;
|
|
196
|
+
void (async () => {
|
|
197
|
+
try {
|
|
198
|
+
do {
|
|
199
|
+
this.again = false;
|
|
200
|
+
try {
|
|
201
|
+
const next = await this.fetch();
|
|
202
|
+
if (this.disposed) return;
|
|
203
|
+
this.retryMs = RETRY_FIRST_MS;
|
|
204
|
+
if (next !== undefined) this.accept(next);
|
|
205
|
+
} catch (err) {
|
|
206
|
+
if (!this.disposed) {
|
|
207
|
+
if (this.failureListeners.size === 0) {
|
|
208
|
+
console.error('Brass: reading the input status failed', err);
|
|
209
|
+
}
|
|
210
|
+
for (const cb of Array.from(this.failureListeners)) cb(err);
|
|
211
|
+
this.scheduleRetry();
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
} while (this.again && !this.disposed);
|
|
215
|
+
} finally {
|
|
216
|
+
this.running = false;
|
|
217
|
+
}
|
|
218
|
+
})();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// A failed read is read again after a wait that doubles to a ceiling, so a
|
|
222
|
+
// status the platform could not answer reaches the app once it can.
|
|
223
|
+
private scheduleRetry(): void {
|
|
224
|
+
if (this.retryTimer !== null) return;
|
|
225
|
+
const wait = this.retryMs;
|
|
226
|
+
this.retryMs = Math.min(this.retryMs * 2, RETRY_CEILING_MS);
|
|
227
|
+
this.retryTimer = setTimeout(() => {
|
|
228
|
+
this.retryTimer = null;
|
|
229
|
+
this.pump();
|
|
230
|
+
}, wait);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** The reserved roots moved: report the status they now imply. */
|
|
234
|
+
localChanged(): void {
|
|
235
|
+
if (this.wire !== null && !this.disposed) this.emit();
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
protected accept(next: W): void {
|
|
239
|
+
this.wire = next;
|
|
240
|
+
this.remember();
|
|
241
|
+
this.emit();
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
protected emit(): void {
|
|
245
|
+
const status = this.compute();
|
|
246
|
+
const serialized = JSON.stringify(status);
|
|
247
|
+
if (serialized === this.lastEmitted) return;
|
|
248
|
+
this.lastEmitted = serialized;
|
|
249
|
+
for (const cb of this.listeners) {
|
|
250
|
+
this.owedFirst.delete(cb);
|
|
251
|
+
call(cb, status);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
protected query(extra: Record<string, string> = {}): Record<string, string> {
|
|
256
|
+
const line = this.host.lineId();
|
|
257
|
+
return line === null ? extra : { [INPUT_STATUS_LINE_PARAM]: line, ...extra };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
protected local(): Map<string, InputsKeyState> | null {
|
|
261
|
+
const roots = this.host.localRoots();
|
|
262
|
+
return roots === null ? null : decodeInputsRoots(roots.refs, roots.read);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Undefined when nothing moved. */
|
|
266
|
+
protected abstract fetch(): Promise<W | undefined>;
|
|
267
|
+
protected abstract remember(): void;
|
|
268
|
+
protected abstract compute(): S;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function call<S>(cb: Listener<S>, status: S): void {
|
|
272
|
+
try {
|
|
273
|
+
cb(status);
|
|
274
|
+
} catch (err) {
|
|
275
|
+
console.error('Brass: a subscribeInputStatus listener threw', err);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function emptyStatus(key: string): WireStatus {
|
|
280
|
+
return { key, refs: [], files: [], changed: [], hidden_count: 0, truncated: false, rev: 0 };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
class KeyTracker extends Tracker<WireStatus, InputStatus> {
|
|
284
|
+
private readonly reported = new Map<string, Reported>();
|
|
285
|
+
// A key the platform holds no row for yet is read whole on every nudge.
|
|
286
|
+
private missing = false;
|
|
287
|
+
private nudgedRev = -1;
|
|
288
|
+
|
|
289
|
+
constructor(
|
|
290
|
+
host: InputStatusHost,
|
|
291
|
+
readonly key: string,
|
|
292
|
+
) {
|
|
293
|
+
super(host);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
nudge(rev: number | null): void {
|
|
297
|
+
if (rev !== null) {
|
|
298
|
+
if (this.wire !== null && !this.missing && rev <= this.wire.rev) return;
|
|
299
|
+
this.nudgedRev = Math.max(this.nudgedRev, rev);
|
|
300
|
+
} else {
|
|
301
|
+
// A nudge naming no revision (a resubscribe) says nudges may have been
|
|
302
|
+
// missed, so no revision already seen can answer it: ask the server.
|
|
303
|
+
this.nudgedRev = -1;
|
|
304
|
+
}
|
|
305
|
+
this.pump();
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
protected async fetch(): Promise<WireStatus | undefined> {
|
|
309
|
+
if (this.wire === null || this.missing) return this.readWhole();
|
|
310
|
+
if (this.nudgedRev !== -1 && this.nudgedRev <= this.wire.rev) return undefined;
|
|
311
|
+
const raw = await this.host.runtime.readKey(
|
|
312
|
+
this.key,
|
|
313
|
+
this.query({ [INPUT_STATUS_SINCE_PARAM]: String(this.wire.rev) }),
|
|
314
|
+
);
|
|
315
|
+
if (raw === null) return this.readWhole();
|
|
316
|
+
const delta = parseInputStatusDelta(raw);
|
|
317
|
+
if (delta === null) throw new Error('Malformed input status delta');
|
|
318
|
+
if ('resync' in delta) return this.readWhole();
|
|
319
|
+
// A projection settling clears `stale` without moving the revision.
|
|
320
|
+
if (delta.rev === this.wire.rev) {
|
|
321
|
+
if ((delta.stale === true) === (this.wire.stale === true)) return undefined;
|
|
322
|
+
const next: WireStatus = { ...this.wire };
|
|
323
|
+
if (delta.stale === true) next.stale = true;
|
|
324
|
+
else delete next.stale;
|
|
325
|
+
return next;
|
|
326
|
+
}
|
|
327
|
+
return applyInputStatusDelta(this.wire, delta) ?? this.readWhole();
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Pages the whole status. A page answering a resync means the status moved
|
|
331
|
+
// between pages, and the read starts again.
|
|
332
|
+
private async readWhole(): Promise<WireStatus | undefined> {
|
|
333
|
+
for (let attempt = 0; attempt < FULL_READ_ATTEMPTS; attempt++) {
|
|
334
|
+
if (this.disposed) return undefined;
|
|
335
|
+
const first = await this.host.runtime.readKey(this.key, this.query());
|
|
336
|
+
if (first === null) {
|
|
337
|
+
this.missing = true;
|
|
338
|
+
return this.wire !== null && this.wire.rev === 0 ? undefined : emptyStatus(this.key);
|
|
339
|
+
}
|
|
340
|
+
const page = parseInputStatusPage(first);
|
|
341
|
+
if (page === null) throw new Error('Malformed input status');
|
|
342
|
+
const files = [...page.files];
|
|
343
|
+
const changed = [...page.changed];
|
|
344
|
+
let cursor = page.cursor;
|
|
345
|
+
let consistent = true;
|
|
346
|
+
while (cursor !== null) {
|
|
347
|
+
if (this.disposed) return undefined;
|
|
348
|
+
const raw = await this.host.runtime.readKey(
|
|
349
|
+
this.key,
|
|
350
|
+
this.query({ [INPUT_STATUS_CURSOR_PARAM]: cursor }),
|
|
351
|
+
);
|
|
352
|
+
const next = raw === null ? null : parseInputStatusPage(raw);
|
|
353
|
+
if (next === null || next.rev !== page.rev) {
|
|
354
|
+
consistent = false;
|
|
355
|
+
break;
|
|
356
|
+
}
|
|
357
|
+
files.push(...next.files);
|
|
358
|
+
changed.push(...next.changed);
|
|
359
|
+
cursor = next.cursor;
|
|
360
|
+
}
|
|
361
|
+
if (!consistent) continue;
|
|
362
|
+
this.missing = false;
|
|
363
|
+
const { cursor: _cursor, ...status } = page;
|
|
364
|
+
return { ...status, files, changed };
|
|
365
|
+
}
|
|
366
|
+
return undefined;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
protected remember(): void {
|
|
370
|
+
const wire = this.wire!;
|
|
371
|
+
const read = this.local()?.get(this.key)?.read ?? {};
|
|
372
|
+
const stamps = stampsOf(wire);
|
|
373
|
+
retire(this.reported, new Set(wire.changed.map((c) => c.doc_id)), (entry) => read[entry.change.doc_id], stamps, stamps);
|
|
374
|
+
for (const change of wire.changed) {
|
|
375
|
+
this.reported.set(change.doc_id, { change, markAtReport: read[change.doc_id] });
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
protected compute(): InputStatus {
|
|
380
|
+
const wire = this.wire!;
|
|
381
|
+
const local = this.local();
|
|
382
|
+
const state = local?.get(this.key);
|
|
383
|
+
const refs = local === null ? wire.refs : (state?.refs ?? []);
|
|
384
|
+
const resolve = referenceResolver([...refs, ...wire.refs]);
|
|
385
|
+
const stampOf = stampsOf(wire);
|
|
386
|
+
|
|
387
|
+
const listed = new Set(wire.changed.map((c) => c.doc_id));
|
|
388
|
+
const candidates: Reported[] = wire.changed.map(
|
|
389
|
+
(change) => this.reported.get(change.doc_id) ?? { change, markAtReport: undefined },
|
|
390
|
+
);
|
|
391
|
+
if (local !== null) {
|
|
392
|
+
for (const [docId, entry] of this.reported) {
|
|
393
|
+
if (!listed.has(docId)) candidates.push(entry);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
const changed: InputStatusChange[] = [];
|
|
397
|
+
// A key this copy holds no references under has no input files, whatever
|
|
398
|
+
// the platform last reported for it.
|
|
399
|
+
if (local !== null && refs.length === 0) candidates.length = 0;
|
|
400
|
+
for (const entry of candidates) {
|
|
401
|
+
if (local !== null && !shown(entry, listed.has(entry.change.doc_id), state?.read[entry.change.doc_id], stampOf, stampOf)) {
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
changed.push(toChange(entry.change, resolve));
|
|
405
|
+
}
|
|
406
|
+
changed.sort(byDocId);
|
|
407
|
+
return {
|
|
408
|
+
key: this.key,
|
|
409
|
+
refs,
|
|
410
|
+
inputs: wire.files.map((f) => ({
|
|
411
|
+
doc_id: f.doc_id,
|
|
412
|
+
name: f.name,
|
|
413
|
+
...(f.schema_type !== undefined ? { family: f.schema_type } : {}),
|
|
414
|
+
updated_at: f.updated_at,
|
|
415
|
+
from: resolve(f.from),
|
|
416
|
+
...(f.awaits_import === true ? { awaits_import: true as const } : {}),
|
|
417
|
+
})),
|
|
418
|
+
changed,
|
|
419
|
+
hidden_count: wire.hidden_count,
|
|
420
|
+
truncated: wire.truncated,
|
|
421
|
+
settled: wire.arriving !== true && (local === null || sameReferences(refs, wire.refs)),
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Whether the platform's status was resolved from the references this copy
|
|
427
|
+
// holds. Until a change to them reaches the platform, `files` and `changed`
|
|
428
|
+
// describe the references it replaced.
|
|
429
|
+
function sameReferences(a: readonly WireReference[], b: readonly WireReference[]): boolean {
|
|
430
|
+
const ids = new Set(a.map(inputReferenceId));
|
|
431
|
+
const other = new Set(b.map(inputReferenceId));
|
|
432
|
+
if (ids.size !== other.size) return false;
|
|
433
|
+
for (const id of ids) if (!other.has(id)) return false;
|
|
434
|
+
return true;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function stampsOf(wire: WireStatus): Map<string, number | null> {
|
|
438
|
+
return new Map(wire.files.map((f) => [f.doc_id, inputStampFromUpdatedAt(f.updated_at)]));
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Called with a fresh report: an entry it no longer lists is kept only when
|
|
442
|
+
// the document's own mark acknowledged it, recording that mark; any other
|
|
443
|
+
// entry the platform dropped is forgotten.
|
|
444
|
+
function retire<K>(
|
|
445
|
+
reported: Map<K, Reported>,
|
|
446
|
+
listed: ReadonlySet<K>,
|
|
447
|
+
markOf: (entry: Reported) => InputReadMark | undefined,
|
|
448
|
+
stampOf: ReadonlyMap<string, number | null>,
|
|
449
|
+
files: ReadonlyMap<string, unknown>,
|
|
450
|
+
): void {
|
|
451
|
+
for (const [id, entry] of reported) {
|
|
452
|
+
if (listed.has(id)) continue;
|
|
453
|
+
if (leftTheKey(entry, files)) {
|
|
454
|
+
reported.delete(id);
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
const mark = markOf(entry);
|
|
458
|
+
if (entry.ackMark === undefined && mark !== undefined && acknowledged(entry.change, mark, entry.markAtReport, stampOf)) {
|
|
459
|
+
entry.ackMark = mark;
|
|
460
|
+
} else if (entry.ackMark === undefined) {
|
|
461
|
+
reported.delete(id);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// Whether the key's current files contradict a change the platform stopped
|
|
467
|
+
// listing, so an Undo of its mark has nothing to bring back: a file it no
|
|
468
|
+
// longer lists among the key's files has left the key, and a removed file it
|
|
469
|
+
// lists again (an Undo that restored the reference with the mark) is back.
|
|
470
|
+
function leftTheKey(entry: Reported, files: ReadonlyMap<string, unknown>): boolean {
|
|
471
|
+
return (entry.change.change === 'removed') === files.has(entry.change.doc_id);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// Whether an entry belongs in `changed`. One the platform lists is shown
|
|
475
|
+
// unless the document's mark acknowledges it; one it has stopped listing is
|
|
476
|
+
// shown only while the document holds an earlier mark than the one that
|
|
477
|
+
// acknowledged it, which is what an Undo of that mark leaves.
|
|
478
|
+
function shown(
|
|
479
|
+
entry: Reported,
|
|
480
|
+
listed: boolean,
|
|
481
|
+
mark: InputReadMark | undefined,
|
|
482
|
+
stampOf: ReadonlyMap<string, number | null>,
|
|
483
|
+
files: ReadonlyMap<string, unknown>,
|
|
484
|
+
): boolean {
|
|
485
|
+
if (listed) return !acknowledged(entry.change, mark, entry.markAtReport, stampOf);
|
|
486
|
+
if (entry.ackMark === undefined) return false;
|
|
487
|
+
if (leftTheKey(entry, files)) return false;
|
|
488
|
+
return mark === undefined || inputReadMarkAfter(entry.ackMark, mark);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// Whether the document's read mark for a changed file acknowledges it. A file
|
|
492
|
+
// whose current version is known is acknowledged by a mark covering that
|
|
493
|
+
// version, the rule the platform applies, so a mark written from a copy read
|
|
494
|
+
// before the file last moved leaves it changed. Otherwise the mark has to have
|
|
495
|
+
// moved past the one held when the platform reported the file.
|
|
496
|
+
function acknowledged(
|
|
497
|
+
change: WireChange,
|
|
498
|
+
mark: InputReadMark | undefined,
|
|
499
|
+
markAtReport: InputReadMark | undefined,
|
|
500
|
+
stampOf: ReadonlyMap<string, number | null>,
|
|
501
|
+
): boolean {
|
|
502
|
+
if (mark === undefined) return false;
|
|
503
|
+
const moved = markAtReport === undefined || inputReadMarkAfter(mark, markAtReport);
|
|
504
|
+
if (change.change === 'removed') return moved || (mark.removed && !stampOf.has(change.doc_id));
|
|
505
|
+
const stamp = stampOf.get(change.doc_id);
|
|
506
|
+
if (typeof stamp !== 'number') return moved;
|
|
507
|
+
return !mark.removed && mark.stamp >= stamp;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function toChange(
|
|
511
|
+
change: WireChange,
|
|
512
|
+
resolve: (ids: readonly string[]) => WireReference[],
|
|
513
|
+
): InputStatusChange {
|
|
514
|
+
return {
|
|
515
|
+
doc_id: change.doc_id,
|
|
516
|
+
change: change.change,
|
|
517
|
+
from: resolve(change.from),
|
|
518
|
+
...(change.updated_at !== undefined ? { updated_at: change.updated_at } : {}),
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function referenceResolver(refs: readonly WireReference[]): (ids: readonly string[]) => WireReference[] {
|
|
523
|
+
const byId = new Map<string, WireReference>();
|
|
524
|
+
for (const ref of refs) {
|
|
525
|
+
const id = inputReferenceId(ref);
|
|
526
|
+
if (!byId.has(id)) byId.set(id, ref);
|
|
527
|
+
}
|
|
528
|
+
return (ids) => ids.flatMap((id) => byId.get(id) ?? []);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function byDocId(a: { doc_id: string }, b: { doc_id: string }): number {
|
|
532
|
+
return a.doc_id < b.doc_id ? -1 : a.doc_id > b.doc_id ? 1 : 0;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Every status subscription one document holds: a tracker per key, and the
|
|
537
|
+
* one watch on the reserved roots they share.
|
|
538
|
+
*/
|
|
539
|
+
export class InputStatusHub {
|
|
540
|
+
private readonly keys = new Map<string, KeyTracker>();
|
|
541
|
+
private stopWatch: (() => void) | null = null;
|
|
542
|
+
|
|
543
|
+
constructor(private readonly host: InputStatusHost) {}
|
|
544
|
+
|
|
545
|
+
subscribeKey(
|
|
546
|
+
key: string,
|
|
547
|
+
cb: Listener<InputStatus>,
|
|
548
|
+
onFailure?: (err: unknown) => void,
|
|
549
|
+
): () => void {
|
|
550
|
+
let tracker = this.keys.get(key);
|
|
551
|
+
const fresh = tracker === undefined;
|
|
552
|
+
if (tracker === undefined) {
|
|
553
|
+
tracker = new KeyTracker(this.host, key);
|
|
554
|
+
this.keys.set(key, tracker);
|
|
555
|
+
}
|
|
556
|
+
const held = tracker;
|
|
557
|
+
held.add(cb);
|
|
558
|
+
const stopFailure = onFailure === undefined ? null : held.onFailure(onFailure);
|
|
559
|
+
this.watch();
|
|
560
|
+
if (fresh) held.pump();
|
|
561
|
+
return () => {
|
|
562
|
+
stopFailure?.();
|
|
563
|
+
if (held.remove(cb) && this.keys.get(key) === held) {
|
|
564
|
+
this.keys.delete(key);
|
|
565
|
+
this.unwatchIfIdle();
|
|
566
|
+
}
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/** A nudge for `key` at `rev`, or (both null) a resubscribe that may have
|
|
571
|
+
* missed nudges for any key. */
|
|
572
|
+
nudged(key: string | null, rev: number | null): void {
|
|
573
|
+
if (key === null) {
|
|
574
|
+
for (const tracker of this.keys.values()) tracker.nudge(null);
|
|
575
|
+
} else {
|
|
576
|
+
this.keys.get(key)?.nudge(rev);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
dispose(): void {
|
|
581
|
+
for (const tracker of this.keys.values()) tracker.dispose();
|
|
582
|
+
this.keys.clear();
|
|
583
|
+
this.stopWatch?.();
|
|
584
|
+
this.stopWatch = null;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
private watch(): void {
|
|
588
|
+
this.stopWatch ??= this.host.watchLocalRoots(() => {
|
|
589
|
+
for (const tracker of this.keys.values()) tracker.localChanged();
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
private unwatchIfIdle(): void {
|
|
594
|
+
if (this.keys.size > 0) return;
|
|
595
|
+
this.stopWatch?.();
|
|
596
|
+
this.stopWatch = null;
|
|
597
|
+
}
|
|
598
|
+
}
|