@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
package/src/importer.ts
CHANGED
|
@@ -1,42 +1,70 @@
|
|
|
1
|
+
import { SealRefusedError } from './staged-seal.js';
|
|
1
2
|
import type { Document } from './documents.js';
|
|
3
|
+
import type { DocumentWorkStatus } from './document-signals.js';
|
|
2
4
|
import type { DocumentSourceBlob } from './documents-namespace.js';
|
|
5
|
+
import type { ImportReport } from './import-report.js';
|
|
6
|
+
import {
|
|
7
|
+
ImportResultError,
|
|
8
|
+
encodeImportStreams,
|
|
9
|
+
encodeStreamBundle,
|
|
10
|
+
readImportResult,
|
|
11
|
+
type DocumentImporter,
|
|
12
|
+
type ImportFile,
|
|
13
|
+
type ImportResult,
|
|
14
|
+
} from './import-contract.js';
|
|
3
15
|
import { BrassError } from './errors.js';
|
|
4
|
-
import {
|
|
16
|
+
import { claimHolderOf, type ClaimHolder } from './api-fetch.js';
|
|
5
17
|
|
|
6
|
-
// The
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
export type DocumentImporter = (
|
|
17
|
-
source: DocumentSourceBlob,
|
|
18
|
-
doc: Document,
|
|
19
|
-
) => void | Promise<void>;
|
|
18
|
+
// The browser tab's runner for an app's importer (`Brass.registerImporter`):
|
|
19
|
+
// the importer itself is a leaf (`import-contract.ts`), and this is what runs
|
|
20
|
+
// it here. On each open it asks the platform for the import claim, and only
|
|
21
|
+
// the winning client reads the stored files, runs the importer, encodes what
|
|
22
|
+
// it returns, and hands the encoded streams to the platform to seal as the
|
|
23
|
+
// document's first content in one step. So the import is neither an edit nor
|
|
24
|
+
// something a reader sees part-way, and every other open client receives it
|
|
25
|
+
// whole.
|
|
26
|
+
|
|
27
|
+
export type { DocumentImporter, ImportFile, ImportResult };
|
|
20
28
|
|
|
21
29
|
// Wire shape of `POST /documents/:docId/import-claim`'s 200 responses.
|
|
22
30
|
// `claimed` marks the CAS win; `already_imported` carries the stamped
|
|
23
|
-
// provenance of a finished import
|
|
24
|
-
//
|
|
31
|
+
// provenance of a finished import; `import_failed` is an earlier import of
|
|
32
|
+
// this same file that failed, with the message the document shows. A pending
|
|
33
|
+
// claim by another client arrives as a 409 `BrassError` instead.
|
|
25
34
|
export interface ImportClaimResponse {
|
|
26
35
|
claimed?: boolean;
|
|
27
36
|
already_imported?: boolean;
|
|
28
37
|
schema_ref?: { app_id: string; schema_hash: string };
|
|
38
|
+
import_failed?: boolean;
|
|
39
|
+
message?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Wire shape of `POST /documents/:docId/import-failed`'s 200 response.
|
|
43
|
+
export interface ImportFailedResponse {
|
|
44
|
+
import_failed: true;
|
|
45
|
+
message: string;
|
|
29
46
|
}
|
|
30
47
|
|
|
31
48
|
export interface ImportRunnerDeps {
|
|
32
49
|
claimImport(docId: string): Promise<ImportClaimResponse>;
|
|
33
|
-
|
|
50
|
+
// Stages the import's encoded streams as one bundle, answering the key the
|
|
51
|
+
// completion names it by.
|
|
52
|
+
stageImport(docId: string, bundle: Uint8Array): Promise<string>;
|
|
53
|
+
completeImport(
|
|
54
|
+
docId: string,
|
|
55
|
+
report: ImportReport | undefined,
|
|
56
|
+
uploadKey: string,
|
|
57
|
+
): Promise<unknown>;
|
|
58
|
+
// Records that the importer could not read the file, releasing the claim.
|
|
59
|
+
failImport(docId: string, message: string): Promise<ImportFailedResponse>;
|
|
34
60
|
reportError(error: Error): void;
|
|
35
61
|
}
|
|
36
62
|
|
|
37
63
|
export interface ImportRunner {
|
|
38
64
|
setImporter(importer: DocumentImporter): void;
|
|
39
|
-
|
|
65
|
+
// `reloadInPlace` is for a frozen copy, which takes the sealed content
|
|
66
|
+
// under the same handles rather than being reopened.
|
|
67
|
+
attempt(doc: Document, options?: { reloadInPlace?: boolean }): Promise<void>;
|
|
40
68
|
}
|
|
41
69
|
|
|
42
70
|
// The claim holder can close its tab mid-import and let its claim expire, so
|
|
@@ -55,6 +83,18 @@ const PENDING_RETRY_INTERVAL_MS = 120_000;
|
|
|
55
83
|
// Enough tail attempts to outlast a holder refreshing to its own bound, and
|
|
56
84
|
// far inside the per-caller claim budget.
|
|
57
85
|
const PENDING_RETRY_TAIL = 18;
|
|
86
|
+
// The whole wait on another client's claim, which is what the schedule above
|
|
87
|
+
// spans; a holder that keeps refreshing is waited for this long.
|
|
88
|
+
const PENDING_WAIT_MAX_MS =
|
|
89
|
+
PENDING_RETRY_DELAYS_MS.reduce((a, b) => a + b, 0) +
|
|
90
|
+
PENDING_RETRY_TAIL * PENDING_RETRY_INTERVAL_MS;
|
|
91
|
+
|
|
92
|
+
// A holder refreshes its claim every `CLAIM_REFRESH_MS`, so one that has not
|
|
93
|
+
// for this long has missed a refresh and is taken to have stopped. The reader
|
|
94
|
+
// asks again the moment the stopped holder's claim lapses, which is when the
|
|
95
|
+
// platform lets it take the claim over.
|
|
96
|
+
export const STALLED_HOLDER_MS = 2 * 60_000;
|
|
97
|
+
const LAPSE_MARGIN_MS = 2_000;
|
|
58
98
|
|
|
59
99
|
// A claim expires while a long import is still parsing or still waiting for
|
|
60
100
|
// its writes to be acked, and the completion needs it, so the runner
|
|
@@ -63,6 +103,12 @@ const PENDING_RETRY_TAIL = 18;
|
|
|
63
103
|
const CLAIM_REFRESH_MS = 60_000;
|
|
64
104
|
const CLAIM_REFRESH_MAX = 30;
|
|
65
105
|
|
|
106
|
+
// How long an importer may run before its import is recorded as failed, as a
|
|
107
|
+
// thrown importer's is. Inside the claim's refreshed lifetime, so the failure
|
|
108
|
+
// is recorded while this client still holds the claim, and far past what a
|
|
109
|
+
// file at the platform's upload cap takes to parse.
|
|
110
|
+
export const IMPORTER_RUN_CEILING_MS = 20 * 60_000;
|
|
111
|
+
|
|
66
112
|
// The status the platform refuses an import the app cannot run with: its
|
|
67
113
|
// `imports` do not claim the file, it declares no schema, or the document
|
|
68
114
|
// holds a half-written import.
|
|
@@ -95,13 +141,63 @@ export function createImportRunner(deps: ImportRunnerDeps): ImportRunner {
|
|
|
95
141
|
// still say "someone is watching this" when nothing is, and the reopened
|
|
96
142
|
// document would never attempt its import again for the life of the page.
|
|
97
143
|
const waiting = new WeakMap<Document, () => void>();
|
|
98
|
-
// Retries already scheduled against another client's live claim
|
|
144
|
+
// Retries already scheduled against another client's live claim, and when
|
|
145
|
+
// the wait on it began.
|
|
99
146
|
const pendingRetries = new WeakMap<Document, number>();
|
|
147
|
+
const pendingSince = new WeakMap<Document, number>();
|
|
148
|
+
// Documents whose last answer named a holder that had stopped refreshing.
|
|
149
|
+
const stalledBefore = new WeakSet<Document>();
|
|
150
|
+
// Documents this runner is holding read-only while an import is in flight
|
|
151
|
+
// on them, in this client or in another. The import becomes the document's
|
|
152
|
+
// content only while nothing else has been written to it, so an edit made
|
|
153
|
+
// first would leave the file unimportable.
|
|
154
|
+
const importLocks = new WeakSet<Document>();
|
|
155
|
+
const reloadsInPlace = new WeakSet<Document>();
|
|
156
|
+
// The import attempt in flight for each document id, from its claim to its
|
|
157
|
+
// outcome, across every Document this client holds for that id.
|
|
158
|
+
const inFlight = new Map<string, Promise<void>>();
|
|
159
|
+
// Frozen copies that reload when their hold is released, because what they
|
|
160
|
+
// loaded is no longer what the document holds: a copy taken before its file
|
|
161
|
+
// was stored learns what that work left, and a copy of a file another
|
|
162
|
+
// client imported takes that import.
|
|
163
|
+
const reloadOnRelease = new WeakSet<Document>();
|
|
164
|
+
|
|
165
|
+
function lock(doc: Document, message: string): void {
|
|
166
|
+
importLocks.add(doc);
|
|
167
|
+
doc._importHold = { message, pending: true, blocking: true };
|
|
168
|
+
doc._emitStatus(doc._importHold);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Release the hold onto a failed import rather than onto "Ready": the
|
|
172
|
+
// document holds nothing the file had in it.
|
|
173
|
+
function settleFailed(doc: Document, message: string): void {
|
|
174
|
+
reloadOnRelease.delete(doc);
|
|
175
|
+
importLocks.delete(doc);
|
|
176
|
+
doc._importHold = null;
|
|
177
|
+
doc._emitStatus({ message, pending: false, blocking: true });
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function unlock(doc: Document): void {
|
|
181
|
+
if (reloadOnRelease.delete(doc)) {
|
|
182
|
+
importLocks.delete(doc);
|
|
183
|
+
doc._awaitsImport = false;
|
|
184
|
+
doc._importHold = null;
|
|
185
|
+
doc.reload().catch((err: unknown) => deps.reportError(asError(err)));
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (!importLocks.delete(doc)) return;
|
|
189
|
+
doc._importHold = null;
|
|
190
|
+
doc._emitStatus(
|
|
191
|
+
{ message: 'Ready', pending: false, blocking: false },
|
|
192
|
+
{ contentLoaded: true },
|
|
193
|
+
);
|
|
194
|
+
}
|
|
100
195
|
const runner: ImportRunner = {
|
|
101
196
|
setImporter(fn: DocumentImporter): void {
|
|
102
197
|
importer = fn;
|
|
103
198
|
},
|
|
104
|
-
async attempt(doc: Document): Promise<void> {
|
|
199
|
+
async attempt(doc: Document, options?: { reloadInPlace?: boolean }): Promise<void> {
|
|
200
|
+
if (options?.reloadInPlace === true) reloadsInPlace.add(doc);
|
|
105
201
|
if (importer === null || doc.id === null) return;
|
|
106
202
|
if (attempted.has(doc) && attempted.get(doc) === doc._openGeneration) {
|
|
107
203
|
return;
|
|
@@ -116,21 +212,37 @@ export function createImportRunner(deps: ImportRunnerDeps): ImportRunner {
|
|
|
116
212
|
// at each caller.
|
|
117
213
|
if (!doc._isLive) return;
|
|
118
214
|
// A document with server-side work in flight has no source file to
|
|
119
|
-
// read yet
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
if (doc.
|
|
215
|
+
// read yet (a trial document opened through its link while the
|
|
216
|
+
// visitor's file is stored), so claiming now asks to import bytes that
|
|
217
|
+
// have not arrived: the platform refuses, and this document's one
|
|
218
|
+
// attempt is spent on a document that was never eligible. Wait for the
|
|
219
|
+
// work the server reports, then attempt.
|
|
220
|
+
if (doc._workStatusNow?.pending === true && !importLocks.has(doc)) {
|
|
125
221
|
// Any listener from an earlier attempt goes first, so the document is
|
|
126
222
|
// watched exactly once whether or not the previous one survived.
|
|
127
223
|
waiting.get(doc)?.();
|
|
128
224
|
waiting.delete(doc);
|
|
225
|
+
// A frozen copy holds through the end of that work: the copy is still
|
|
226
|
+
// the empty one it loaded, so the settled status the work reports
|
|
227
|
+
// would read as an empty file until the claim answers. The hold is
|
|
228
|
+
// what the document emits in place of that status, which is how the
|
|
229
|
+
// listener tells the work ending from its progress.
|
|
230
|
+
const hold: DocumentWorkStatus | null = reloadsInPlace.has(doc)
|
|
231
|
+
? { message: 'Importing...', pending: true, blocking: true }
|
|
232
|
+
: null;
|
|
233
|
+
if (hold !== null) {
|
|
234
|
+
doc._importHold = hold;
|
|
235
|
+
doc._awaitsImport = true;
|
|
236
|
+
}
|
|
129
237
|
try {
|
|
130
238
|
const off = doc.onStatus((status) => {
|
|
131
|
-
if (status.pending) return;
|
|
239
|
+
if (status.pending && status !== hold) return;
|
|
132
240
|
waiting.get(doc)?.();
|
|
133
241
|
waiting.delete(doc);
|
|
242
|
+
if (hold !== null) {
|
|
243
|
+
importLocks.add(doc);
|
|
244
|
+
reloadOnRelease.add(doc);
|
|
245
|
+
}
|
|
134
246
|
void runner.attempt(doc);
|
|
135
247
|
});
|
|
136
248
|
waiting.set(doc, off);
|
|
@@ -144,108 +256,213 @@ export function createImportRunner(deps: ImportRunnerDeps): ImportRunner {
|
|
|
144
256
|
// rather than leave it to fire against a document already past it.
|
|
145
257
|
waiting.get(doc)?.();
|
|
146
258
|
waiting.delete(doc);
|
|
259
|
+
// Another Document for this id is already importing it in this client.
|
|
260
|
+
// Every claim this client makes carries one client id, so the platform
|
|
261
|
+
// would hand this one the same claim and the file would be imported
|
|
262
|
+
// twice. It waits for that attempt to settle and then asks for itself,
|
|
263
|
+
// which answers from whatever that attempt left.
|
|
264
|
+
const running = inFlight.get(doc.id);
|
|
265
|
+
if (running !== undefined) {
|
|
266
|
+
if (doc._awaitsImport) lock(doc, 'Importing...');
|
|
267
|
+
await running;
|
|
268
|
+
return runner.attempt(doc);
|
|
269
|
+
}
|
|
147
270
|
attempted.set(doc, doc._openGeneration);
|
|
148
271
|
const run = importer;
|
|
149
272
|
const docId = doc.id;
|
|
150
|
-
let
|
|
273
|
+
let settle: () => void = () => undefined;
|
|
274
|
+
const work = new Promise<void>((resolve) => (settle = resolve));
|
|
275
|
+
inFlight.set(docId, work);
|
|
151
276
|
try {
|
|
152
|
-
claim
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
277
|
+
// Held before the claim goes out, and synchronously, so the app never
|
|
278
|
+
// sees the empty document as editable while the answer is in flight.
|
|
279
|
+
if (doc._awaitsImport) lock(doc, 'Importing...');
|
|
280
|
+
doc._importAbandoned = null;
|
|
281
|
+
let claim: ImportClaimResponse;
|
|
282
|
+
try {
|
|
283
|
+
claim = await deps.claimImport(docId);
|
|
284
|
+
} catch (err) {
|
|
285
|
+
const status = err instanceof BrassError ? err.status : undefined;
|
|
286
|
+
if (status === 409) {
|
|
287
|
+
const holder = claimHolderOf(err);
|
|
288
|
+
// A holder that stopped refreshing and still held the claim after
|
|
289
|
+
// it should have lapsed is not coming back for it.
|
|
290
|
+
if (holder !== null && stalled(holder) && stalledBefore.has(doc)) {
|
|
291
|
+
stalledBefore.delete(doc);
|
|
292
|
+
giveUp(doc, STALLED_HOLDER_MESSAGE);
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
lock(doc, 'Importing...');
|
|
296
|
+
schedulePendingRetry(doc, holder);
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
// Refused for rate, not eligibility: the file is still waiting, so
|
|
300
|
+
// the document stays held and the claim is asked again.
|
|
301
|
+
if (status === 429 && doc._awaitsImport) {
|
|
302
|
+
deps.reportError(asError(err));
|
|
303
|
+
lock(doc, 'Importing...');
|
|
304
|
+
schedulePendingRetry(doc);
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
unlock(doc);
|
|
308
|
+
// A refusal names what the developer has to change, so it reaches
|
|
309
|
+
// the app's error handler; the rest of the 4xx surface (nothing
|
|
310
|
+
// stored to import, read-only access) is a no-op.
|
|
311
|
+
if (
|
|
312
|
+
status !== undefined &&
|
|
313
|
+
status < 500 &&
|
|
314
|
+
status !== CLAIM_REFUSED_STATUS
|
|
315
|
+
) {
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
deps.reportError(asError(err));
|
|
157
319
|
return;
|
|
158
320
|
}
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
// stored to import, read-only access) is a no-op.
|
|
162
|
-
if (
|
|
163
|
-
status !== undefined &&
|
|
164
|
-
status < 500 &&
|
|
165
|
-
status !== CLAIM_REFUSED_STATUS
|
|
166
|
-
) {
|
|
321
|
+
if (claim.import_failed === true) {
|
|
322
|
+
settleFailed(doc, claim.message ?? 'This file could not be imported.');
|
|
167
323
|
return;
|
|
168
324
|
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
});
|
|
189
|
-
let saved = false;
|
|
325
|
+
if (claim.claimed !== true) {
|
|
326
|
+
// Imported elsewhere, or nothing to import: either way the lock has
|
|
327
|
+
// nothing left to hold, since the reload an import's completion sent
|
|
328
|
+
// carries its content. A frozen copy hears that reload as a change
|
|
329
|
+
// notice only, so it loads the content itself.
|
|
330
|
+
if (reloadsInPlace.has(doc) && claim.already_imported === true) reloadOnRelease.add(doc);
|
|
331
|
+
unlock(doc);
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
// This client won the claim: run the importer over the stored files,
|
|
335
|
+
// encode what it returns, and hand the platform the encoded streams to
|
|
336
|
+
// seal as the document's content. An importer that cannot read the
|
|
337
|
+
// file is recorded on the document, so later opens show the failure
|
|
338
|
+
// instead of running it again, and so is a seal refused for what the
|
|
339
|
+
// importer produced. Any other throw (the file unreachable, the
|
|
340
|
+
// document changed under the seal) leaves the file for a later open
|
|
341
|
+
// to import.
|
|
342
|
+
lock(doc, 'Importing...');
|
|
343
|
+
let failure: Error | null = null;
|
|
190
344
|
try {
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
345
|
+
const source = await doc.sourceBlob();
|
|
346
|
+
lock(doc, `Importing ${source.filename}...`);
|
|
347
|
+
const stopRefresh = startClaimRefresh(docId, doc);
|
|
348
|
+
try {
|
|
349
|
+
let result: ImportResult | null = null;
|
|
350
|
+
try {
|
|
351
|
+
result = readImportResult(await withinCeiling(run(importFileOf(source))));
|
|
352
|
+
} catch (err) {
|
|
353
|
+
if (unreadSources.has(err as object)) throw err;
|
|
354
|
+
failure = asError(err);
|
|
355
|
+
}
|
|
356
|
+
if (failure === null && result === null) {
|
|
357
|
+
failure = new Error(
|
|
358
|
+
'The importer returned no content, so this file was not ' +
|
|
359
|
+
'imported. Return { streams } from the importer.',
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
if (failure === null && result !== null) {
|
|
363
|
+
failure = await seal(doc, docId, result);
|
|
364
|
+
}
|
|
365
|
+
} finally {
|
|
366
|
+
stopRefresh();
|
|
367
|
+
}
|
|
368
|
+
} catch (err) {
|
|
369
|
+
deps.reportError(asError(err));
|
|
370
|
+
// A seal the platform refused for the file's content was recorded
|
|
371
|
+
// on the document as a failed import, as the importer's own
|
|
372
|
+
// failure is.
|
|
373
|
+
if (err instanceof SealRefusedError && err.importFailed !== undefined) {
|
|
374
|
+
settleFailed(doc, err.importFailed);
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
unlock(doc);
|
|
378
|
+
return;
|
|
201
379
|
}
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
// the records are stored. The completion stamps the document with the
|
|
205
|
-
// app's schema at the version it lands on, so running it over writes
|
|
206
|
-
// that never reached the server records an import of records the
|
|
207
|
-
// document does not hold. Abandoning instead leaves the claim to
|
|
208
|
-
// expire, and a client that is still open imports the file properly.
|
|
209
|
-
if (!saved) return;
|
|
210
|
-
// An importer that returns without writing anything must not stamp the
|
|
211
|
-
// document imported: the stamp is permanent, a later open reads
|
|
212
|
-
// `already_imported`, and the file's records then have no path into a
|
|
213
|
-
// document that holds none. `try { parse() } catch { return }` is an
|
|
214
|
-
// ordinary shape, so this is reachable from app code that looks
|
|
215
|
-
// correct. Both signals are required before skipping, so an import
|
|
216
|
-
// that DID write is completed either way and never runs twice.
|
|
217
|
-
if (!wroteRecords && isEmptyDocument(doc)) {
|
|
218
|
-
deps.reportError(
|
|
219
|
-
new Error(
|
|
220
|
-
'The importer wrote no records, so this file was not imported. ' +
|
|
221
|
-
'Reload to try again.',
|
|
222
|
-
),
|
|
223
|
-
);
|
|
380
|
+
if (failure === null) {
|
|
381
|
+
unlock(doc);
|
|
224
382
|
return;
|
|
225
383
|
}
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
384
|
+
deps.reportError(failure);
|
|
385
|
+
try {
|
|
386
|
+
const recorded = await deps.failImport(docId, failure.message);
|
|
387
|
+
settleFailed(doc, recorded.message);
|
|
388
|
+
} catch (err) {
|
|
389
|
+
deps.reportError(asError(err));
|
|
390
|
+
unlock(doc);
|
|
391
|
+
}
|
|
392
|
+
} finally {
|
|
393
|
+
if (inFlight.get(docId) === work) inFlight.delete(docId);
|
|
394
|
+
settle();
|
|
229
395
|
}
|
|
230
396
|
},
|
|
231
397
|
};
|
|
232
398
|
|
|
233
|
-
|
|
399
|
+
// Seal the streams as the document's first content, answering the failure
|
|
400
|
+
// to record when the result holds nothing. The platform refuses a document
|
|
401
|
+
// something was already written to, and that refusal reaches the app's
|
|
402
|
+
// error handler like any other failed import.
|
|
403
|
+
async function seal(
|
|
404
|
+
doc: Document,
|
|
405
|
+
docId: string,
|
|
406
|
+
result: ImportResult,
|
|
407
|
+
): Promise<Error | null> {
|
|
408
|
+
const streams = await encodeImportStreams(result);
|
|
409
|
+
// An import that holds nothing must not stamp the document imported: the
|
|
410
|
+
// stamp is permanent, and the file's content would then have no path
|
|
411
|
+
// into a document that holds none.
|
|
412
|
+
if (streams.length === 0) {
|
|
413
|
+
return new Error('The importer returned no records, so this file was not imported.');
|
|
414
|
+
}
|
|
415
|
+
if (!doc._isLive) return null;
|
|
416
|
+
const key = await deps.stageImport(docId, encodeStreamBundle(streams));
|
|
417
|
+
await deps.completeImport(docId, result.report, key);
|
|
418
|
+
// Every open client reloads onto the sealed content, this one included;
|
|
419
|
+
// reloading here shows it without waiting for that frame. The copy stays
|
|
420
|
+
// held as importing until the reloaded one replaces it: released first,
|
|
421
|
+
// it would report Ready on a document that holds none of the file.
|
|
422
|
+
doc._awaitsImport = false;
|
|
423
|
+
if (reloadsInPlace.has(doc)) {
|
|
424
|
+
reloadOnRelease.delete(doc);
|
|
425
|
+
await doc.reload();
|
|
426
|
+
unlock(doc);
|
|
427
|
+
return null;
|
|
428
|
+
}
|
|
429
|
+
importLocks.delete(doc);
|
|
430
|
+
doc._reopen();
|
|
431
|
+
return null;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Release a document whose import this client stopped waiting for, saying
|
|
435
|
+
// why: the reader is looking at an empty document, so the reason is what
|
|
436
|
+
// keeps them from concluding the file was empty.
|
|
437
|
+
function giveUp(doc: Document, message: string): void {
|
|
438
|
+
unlock(doc);
|
|
439
|
+
doc._importAbandoned = message;
|
|
440
|
+
deps.reportError(new Error(message));
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function schedulePendingRetry(doc: Document, holder: ClaimHolder | null = null): void {
|
|
234
444
|
const taken = pendingRetries.get(doc) ?? 0;
|
|
235
|
-
const
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
//
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
445
|
+
const now = Date.now();
|
|
446
|
+
const since = pendingSince.get(doc) ?? now;
|
|
447
|
+
pendingSince.set(doc, since);
|
|
448
|
+
let delay = PENDING_RETRY_DELAYS_MS[taken] ?? PENDING_RETRY_INTERVAL_MS;
|
|
449
|
+
if (holder !== null && !holder.sealing) {
|
|
450
|
+
// Ask again as soon as a holder that stops now would read as stopped,
|
|
451
|
+
// or the moment a stopped one's claim lapses.
|
|
452
|
+
if (stalled(holder)) {
|
|
453
|
+
stalledBefore.add(doc);
|
|
454
|
+
delay = holder.expiresInMs + LAPSE_MARGIN_MS;
|
|
455
|
+
} else {
|
|
456
|
+
stalledBefore.delete(doc);
|
|
457
|
+
delay = Math.min(delay, STALLED_HOLDER_MS - holder.refreshedMsAgo + LAPSE_MARGIN_MS);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
if (now + delay - since > PENDING_WAIT_MAX_MS) {
|
|
461
|
+
// Out of time with the file still unimported.
|
|
462
|
+
giveUp(
|
|
463
|
+
doc,
|
|
464
|
+
'Could not import this file: another window has been importing it ' +
|
|
465
|
+
'for longer than expected. Reload to try again.',
|
|
249
466
|
);
|
|
250
467
|
return;
|
|
251
468
|
}
|
|
@@ -283,26 +500,64 @@ export function createImportRunner(deps: ImportRunnerDeps): ImportRunner {
|
|
|
283
500
|
return runner;
|
|
284
501
|
}
|
|
285
502
|
|
|
503
|
+
// An importer's run, rejected once it passes `IMPORTER_RUN_CEILING_MS`.
|
|
504
|
+
function withinCeiling<T>(run: T | PromiseLike<T>): Promise<T> {
|
|
505
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
506
|
+
const ceiling = new Promise<never>((_, reject) => {
|
|
507
|
+
timer = setTimeout(
|
|
508
|
+
() =>
|
|
509
|
+
reject(
|
|
510
|
+
new Error(
|
|
511
|
+
`The importer did not finish within ${IMPORTER_RUN_CEILING_MS / 60_000} minutes, ` +
|
|
512
|
+
'so this file was not imported.',
|
|
513
|
+
),
|
|
514
|
+
),
|
|
515
|
+
IMPORTER_RUN_CEILING_MS,
|
|
516
|
+
);
|
|
517
|
+
});
|
|
518
|
+
return Promise.race([Promise.resolve(run), ceiling]).finally(() => clearTimeout(timer));
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const STALLED_HOLDER_MESSAGE =
|
|
522
|
+
'Could not import this file: the window that was importing it stopped ' +
|
|
523
|
+
'responding. Reload to try again.';
|
|
524
|
+
|
|
525
|
+
function stalled(holder: ClaimHolder): boolean {
|
|
526
|
+
return !holder.sealing && holder.refreshedMsAgo >= STALLED_HOLDER_MS;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Errors thrown reading the stored file. They say the file could not be
|
|
530
|
+
// reached, not that the importer could not read it, so they are not recorded
|
|
531
|
+
// as a failed import.
|
|
532
|
+
const unreadSources = new WeakSet<object>();
|
|
533
|
+
|
|
286
534
|
function asError(err: unknown): Error {
|
|
287
535
|
return err instanceof Error ? err : new Error(String(err));
|
|
288
536
|
}
|
|
289
537
|
|
|
290
|
-
//
|
|
291
|
-
//
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
538
|
+
// The file an importer is given, fetched when the importer asks for its
|
|
539
|
+
// bytes.
|
|
540
|
+
function importFileOf(source: DocumentSourceBlob): ImportFile {
|
|
541
|
+
return {
|
|
542
|
+
name: source.filename,
|
|
543
|
+
content_type: source.content_type,
|
|
544
|
+
byte_size: source.byte_size,
|
|
545
|
+
url: source.url,
|
|
546
|
+
async bytes(): Promise<Uint8Array> {
|
|
547
|
+
try {
|
|
548
|
+
const response = await fetch(source.url);
|
|
549
|
+
if (!response.ok) {
|
|
550
|
+
throw new BrassError(`Reading ${source.filename} failed`, {
|
|
551
|
+
status: response.status,
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
555
|
+
} catch (err) {
|
|
556
|
+
if (err !== null && typeof err === 'object') unreadSources.add(err);
|
|
557
|
+
throw err;
|
|
558
|
+
}
|
|
559
|
+
},
|
|
560
|
+
};
|
|
308
561
|
}
|
|
562
|
+
|
|
563
|
+
export { ImportResultError };
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
declare const process: { env: { NODE_ENV?: string } } | undefined;
|
|
2
|
+
|
|
3
|
+
// Bundlers replace `process.env.NODE_ENV`; with none, nothing is reported.
|
|
4
|
+
export const inDevelopment = (): boolean => {
|
|
5
|
+
try {
|
|
6
|
+
return process!.env.NODE_ENV !== 'production';
|
|
7
|
+
} catch {
|
|
8
|
+
return false;
|
|
9
|
+
}
|
|
10
|
+
};
|