@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
package/src/inputs.ts
ADDED
|
@@ -0,0 +1,1090 @@
|
|
|
1
|
+
// The shape of a document that references other documents: the references a
|
|
2
|
+
// key holds, the reserved CRDT roots they live in, the status the platform
|
|
3
|
+
// reports for a key, and the frames and bodies that carry it. This is the one
|
|
4
|
+
// definition; the platform's `@brass-build/contracts` re-exports it.
|
|
5
|
+
//
|
|
6
|
+
// The document's CRDT is the source of truth for each key's references and
|
|
7
|
+
// read marks. Nothing read out of the roots is authority: a reference's
|
|
8
|
+
// `organization_id` is a hint for resolving it and grants nothing.
|
|
9
|
+
|
|
10
|
+
import { sha256Hex } from './sha256.js';
|
|
11
|
+
|
|
12
|
+
// The narrowing a reference was chosen under, as `InputReferenceWire.accept`.
|
|
13
|
+
interface SelectionAccept {
|
|
14
|
+
names?: readonly string[];
|
|
15
|
+
extensions?: readonly string[];
|
|
16
|
+
families?: readonly string[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const DEFAULT_STREAM_NAME = 'default';
|
|
20
|
+
|
|
21
|
+
const NAME_TOKEN_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
|
22
|
+
|
|
23
|
+
function isNameToken(value: string): boolean {
|
|
24
|
+
return NAME_TOKEN_PATTERN.test(value);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// A stored reference may carry its families as `types`, which reads the same.
|
|
28
|
+
function parseSelectionAccept(value: unknown): SelectionAccept | null {
|
|
29
|
+
if (!value || typeof value !== 'object') return null;
|
|
30
|
+
const raw = value as Record<string, unknown>;
|
|
31
|
+
const list = (v: unknown): readonly string[] | null => {
|
|
32
|
+
if (!Array.isArray(v)) return null;
|
|
33
|
+
const items = v.filter((x): x is string => typeof x === 'string' && x !== '');
|
|
34
|
+
return items.length > 0 ? items : null;
|
|
35
|
+
};
|
|
36
|
+
const names = list(raw['names']);
|
|
37
|
+
const extensions = list(raw['extensions']);
|
|
38
|
+
const families = list(raw['families']) ?? list(raw['types']);
|
|
39
|
+
if (!names && !extensions && !families) return null;
|
|
40
|
+
return {
|
|
41
|
+
...(names ? { names } : {}),
|
|
42
|
+
...(extensions ? { extensions } : {}),
|
|
43
|
+
...(families ? { families } : {}),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function documentTopic(docId: string, branchId: string): string {
|
|
48
|
+
return `doc:${docId}:${branchId}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function parseDocTopic(topic: string): { docId: string; branchId: string } | null {
|
|
52
|
+
const m = /^doc:(doc_[A-Za-z0-9_-]{32}):(b_[A-Za-z0-9_-]{32})$/.exec(topic);
|
|
53
|
+
return m && m[1] && m[2] ? { docId: m[1], branchId: m[2] } : null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// --- Identifiers -----------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
const DOC_ID_PATTERN = /^doc_[A-Za-z0-9_-]{32}$/;
|
|
59
|
+
const LINE_ID_PATTERN = /^b_[A-Za-z0-9_-]{32}$/;
|
|
60
|
+
// Organization and project ids are opaque here; the shape only has to keep
|
|
61
|
+
// them safe inside a key or an identity string.
|
|
62
|
+
const ENTITY_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
|
|
63
|
+
|
|
64
|
+
/** @internal */
|
|
65
|
+
export function isInputDocId(value: unknown): value is string {
|
|
66
|
+
return typeof value === 'string' && DOC_ID_PATTERN.test(value);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** @internal */
|
|
70
|
+
export function isInputLineId(value: unknown): value is string {
|
|
71
|
+
return typeof value === 'string' && LINE_ID_PATTERN.test(value);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// --- Keys ------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
/** @internal The longest key an app may record references under. */
|
|
77
|
+
export const INPUT_KEY_MAX_CHARS = 128;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* @internal
|
|
81
|
+
* An app key: a letter or digit, then letters, digits and `._:@/+=~-`. `#`
|
|
82
|
+
* is excluded because the key is the tail of a row's sort key, and so is
|
|
83
|
+
* whitespace, which an app writing `stamp:${id}` never produces on purpose.
|
|
84
|
+
*/
|
|
85
|
+
export const INPUT_KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:@/+=~-]{0,127}$/;
|
|
86
|
+
|
|
87
|
+
/** @internal Keys under this prefix are the platform's own, refused from an app. */
|
|
88
|
+
export const INPUT_KEY_RESERVED_PREFIX = 'brass:';
|
|
89
|
+
|
|
90
|
+
/** @internal */
|
|
91
|
+
export function isInputKey(value: unknown): value is string {
|
|
92
|
+
return (
|
|
93
|
+
typeof value === 'string' &&
|
|
94
|
+
INPUT_KEY_PATTERN.test(value) &&
|
|
95
|
+
!value.toLowerCase().startsWith(INPUT_KEY_RESERVED_PREFIX)
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// --- References ------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
/** @internal How much a reference covers. */
|
|
102
|
+
export type InputReferenceExtent = 'organization' | 'project' | 'document';
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* @internal
|
|
106
|
+
* One reference a key holds: a file, a folder, a project or an organization,
|
|
107
|
+
* as `Brass.chooseInputs` returned it, optionally naming one stream of a
|
|
108
|
+
* document. `accept` is the narrowing it was chosen under, and two references
|
|
109
|
+
* to one place under different narrowings are different references.
|
|
110
|
+
*/
|
|
111
|
+
export interface InputReferenceWire {
|
|
112
|
+
organization_id: string;
|
|
113
|
+
extent: InputReferenceExtent;
|
|
114
|
+
project_id?: string;
|
|
115
|
+
doc_id?: string;
|
|
116
|
+
/** A stream of the referenced document. Absent means its default stream. */
|
|
117
|
+
stream?: string;
|
|
118
|
+
/** The name the picker showed. Display only, and not part of the identity. */
|
|
119
|
+
label?: string;
|
|
120
|
+
accept?: SelectionAccept;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** @internal */
|
|
124
|
+
export const INPUT_REFERENCE_LABEL_MAX_CHARS = 256;
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* @internal
|
|
128
|
+
* Reads a reference off an untrusted value: the wire, or an entry a peer
|
|
129
|
+
* wrote into the reserved roots. Null when it is not one.
|
|
130
|
+
*/
|
|
131
|
+
export function parseInputReference(raw: unknown): InputReferenceWire | null {
|
|
132
|
+
if (!isRecord(raw)) return null;
|
|
133
|
+
const extent = raw['extent'];
|
|
134
|
+
const organizationId = raw['organization_id'];
|
|
135
|
+
if (typeof organizationId !== 'string' || !ENTITY_ID_PATTERN.test(organizationId)) return null;
|
|
136
|
+
const ref: InputReferenceWire = { organization_id: organizationId, extent: 'organization' };
|
|
137
|
+
if (extent === 'document') {
|
|
138
|
+
if (!isInputDocId(raw['doc_id'])) return null;
|
|
139
|
+
ref.extent = 'document';
|
|
140
|
+
ref.doc_id = raw['doc_id'];
|
|
141
|
+
const stream = raw['stream'];
|
|
142
|
+
if (stream !== undefined) {
|
|
143
|
+
if (typeof stream !== 'string' || !isNameToken(stream)) return null;
|
|
144
|
+
if (stream !== DEFAULT_STREAM_NAME) ref.stream = stream;
|
|
145
|
+
}
|
|
146
|
+
} else if (extent === 'project') {
|
|
147
|
+
const projectId = raw['project_id'];
|
|
148
|
+
if (typeof projectId !== 'string' || !ENTITY_ID_PATTERN.test(projectId)) return null;
|
|
149
|
+
if (raw['stream'] !== undefined) return null;
|
|
150
|
+
ref.extent = 'project';
|
|
151
|
+
ref.project_id = projectId;
|
|
152
|
+
} else if (extent === 'organization') {
|
|
153
|
+
if (raw['stream'] !== undefined) return null;
|
|
154
|
+
} else {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
// A project reference may also say which project a document sits in; the
|
|
158
|
+
// identity reads only the field its extent names.
|
|
159
|
+
if (ref.extent === 'document' && typeof raw['project_id'] === 'string') {
|
|
160
|
+
if (!ENTITY_ID_PATTERN.test(raw['project_id'])) return null;
|
|
161
|
+
ref.project_id = raw['project_id'];
|
|
162
|
+
}
|
|
163
|
+
const label = raw['label'];
|
|
164
|
+
if (label !== undefined) {
|
|
165
|
+
if (typeof label !== 'string' || label.length > INPUT_REFERENCE_LABEL_MAX_CHARS) return null;
|
|
166
|
+
ref.label = label;
|
|
167
|
+
}
|
|
168
|
+
if (raw['accept'] !== undefined) {
|
|
169
|
+
if (!isRecord(raw['accept'])) return null;
|
|
170
|
+
// A narrowing with no clause narrows nothing, so it is the same
|
|
171
|
+
// reference as one carrying none.
|
|
172
|
+
const accept = parseSelectionAccept(raw['accept']);
|
|
173
|
+
if (accept !== null) ref.accept = accept;
|
|
174
|
+
}
|
|
175
|
+
return ref;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function isEmptyAccept(accept: SelectionAccept): boolean {
|
|
179
|
+
return !accept.families?.length && !accept.names?.length && !accept.extensions?.length;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// The narrowing as one canonical string: families and extensions compared
|
|
183
|
+
// case-insensitively, extensions without their dot, names case-insensitively
|
|
184
|
+
// (the match is), each list sorted and de-duplicated.
|
|
185
|
+
function canonicalAccept(accept: SelectionAccept): string {
|
|
186
|
+
const norm = (xs: readonly string[] | undefined, strip = false) =>
|
|
187
|
+
[...new Set((xs ?? []).map((x) => (strip ? x.replace(/^\./, '') : x).toLowerCase()))].sort();
|
|
188
|
+
return JSON.stringify({
|
|
189
|
+
t: norm(accept.families),
|
|
190
|
+
e: norm(accept.extensions, true),
|
|
191
|
+
n: norm(accept.names),
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* @internal
|
|
197
|
+
* The reference's stable identity: the key it is stored under in the
|
|
198
|
+
* references root, and what the platform's rows name it by.
|
|
199
|
+
*
|
|
200
|
+
* `d:<doc_id>`, `d:<doc_id>/<stream>`, `p:<project_id>` or
|
|
201
|
+
* `o:<organization_id>`, followed by `~<16 hex>` when the reference carries a
|
|
202
|
+
* narrowing. The label is not part of it, so renaming a choice does not make
|
|
203
|
+
* it a different reference.
|
|
204
|
+
*/
|
|
205
|
+
export function inputReferenceId(ref: InputReferenceWire): string {
|
|
206
|
+
let id: string;
|
|
207
|
+
if (ref.extent === 'document') {
|
|
208
|
+
id = `d:${ref.doc_id ?? ''}`;
|
|
209
|
+
if (ref.stream !== undefined && ref.stream !== DEFAULT_STREAM_NAME) id += `/${ref.stream}`;
|
|
210
|
+
} else if (ref.extent === 'project') {
|
|
211
|
+
id = `p:${ref.project_id ?? ''}`;
|
|
212
|
+
} else {
|
|
213
|
+
id = `o:${ref.organization_id}`;
|
|
214
|
+
}
|
|
215
|
+
if (ref.accept && !isEmptyAccept(ref.accept)) {
|
|
216
|
+
id += `~${sha256Hex(canonicalAccept(ref.accept)).slice(0, 16)}`;
|
|
217
|
+
}
|
|
218
|
+
return id;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const INPUT_REFERENCE_ID_PATTERN =
|
|
222
|
+
/^(?:d:doc_[A-Za-z0-9_-]{32}(?:\/[a-z0-9][a-z0-9._-]{0,63})?|[po]:[A-Za-z0-9_-]{1,64})(?:~[0-9a-f]{16})?$/;
|
|
223
|
+
|
|
224
|
+
/** @internal */
|
|
225
|
+
export function isInputReferenceId(value: unknown): value is string {
|
|
226
|
+
return typeof value === 'string' && INPUT_REFERENCE_ID_PATTERN.test(value);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* @internal
|
|
231
|
+
* The hash of a reference set: what keys with identical references share one
|
|
232
|
+
* resolution under. Order and duplicates do not move it, and neither does a
|
|
233
|
+
* label.
|
|
234
|
+
*/
|
|
235
|
+
export function inputReferenceSetHash(refs: readonly InputReferenceWire[]): string {
|
|
236
|
+
const ids = [...new Set(refs.map(inputReferenceId))].sort();
|
|
237
|
+
return sha256Hex(`inputs-v1\n${ids.join('\n')}`);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const SET_HASH_PATTERN = /^[0-9a-f]{64}$/;
|
|
241
|
+
|
|
242
|
+
/** @internal */
|
|
243
|
+
export function isInputReferenceSetHash(value: unknown): value is string {
|
|
244
|
+
return typeof value === 'string' && SET_HASH_PATTERN.test(value);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// --- Limits ----------------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* @internal
|
|
251
|
+
* The most distinct documents a document's keys report across all of them.
|
|
252
|
+
* Past it the status says `truncated`; it is never a refusal.
|
|
253
|
+
*/
|
|
254
|
+
export const INPUT_DOCS_MAX = 2000;
|
|
255
|
+
|
|
256
|
+
/** @internal The most references one key may hold in a provisional body. */
|
|
257
|
+
export const INPUT_REFS_MAX_PER_KEY = 2000;
|
|
258
|
+
|
|
259
|
+
/** @internal The most read marks one key may carry in a provisional body. */
|
|
260
|
+
export const INPUT_READ_MARKS_MAX_PER_KEY = INPUT_DOCS_MAX;
|
|
261
|
+
|
|
262
|
+
/** @internal The longest version stamp a read mark or a file may carry. */
|
|
263
|
+
export const INPUT_STAMP_MAX_CHARS = 64;
|
|
264
|
+
|
|
265
|
+
// --- The reserved CRDT roots -------------------------------------------------
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* @internal
|
|
269
|
+
* The document roots holding every key's references and read marks. Both are
|
|
270
|
+
* flat maps at the root: a root container's id is fixed by its name, so every
|
|
271
|
+
* peer writes into the same container. A container created under a map entry
|
|
272
|
+
* is not: two peers creating one concurrently make two, and the entry keeps
|
|
273
|
+
* one of them, dropping the other's writes. So nothing here nests.
|
|
274
|
+
*
|
|
275
|
+
* The names sit outside both the `brass:` record-store key space and any name
|
|
276
|
+
* an app passes to `doc.map`, so record paging and the frozen-to-CRDT
|
|
277
|
+
* conversion, which walk app roots, never reach them.
|
|
278
|
+
*
|
|
279
|
+
* `INPUTS_REFS_ROOT`: `<key>␟<referenceId>` -> the reference.
|
|
280
|
+
* `INPUTS_READ_ROOT`: `<key>␟<docId>␟<stamp>␟<kind>` -> true.
|
|
281
|
+
*/
|
|
282
|
+
export const INPUTS_REFS_ROOT = '__brass_input_refs';
|
|
283
|
+
/** @internal */
|
|
284
|
+
export const INPUTS_READ_ROOT = '__brass_input_read';
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* @internal
|
|
288
|
+
* Joins the parts of an entry key: U+241F SYMBOL FOR UNIT SEPARATOR, which no
|
|
289
|
+
* app key, reference id, doc id or stamp can contain.
|
|
290
|
+
*/
|
|
291
|
+
export const INPUTS_ENTRY_SEPARATOR = '␟';
|
|
292
|
+
const SEP = INPUTS_ENTRY_SEPARATOR;
|
|
293
|
+
|
|
294
|
+
/** @internal */
|
|
295
|
+
export function inputRefEntryKey(key: string, refId: string): string {
|
|
296
|
+
return `${key}${SEP}${refId}`;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** @internal */
|
|
300
|
+
export function parseInputRefEntryKey(entryKey: string): { key: string; refId: string } | null {
|
|
301
|
+
const parts = entryKey.split(SEP);
|
|
302
|
+
if (parts.length !== 2) return null;
|
|
303
|
+
const [key, refId] = parts;
|
|
304
|
+
return isInputKey(key) && isInputReferenceId(refId) ? { key, refId } : null;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** @internal Digits a stamp is zero-padded to in an entry key, so the keys sort by stamp. */
|
|
308
|
+
export const INPUT_STAMP_DIGITS = 16;
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* @internal
|
|
312
|
+
* A version of a document, as a read mark records it: the milliseconds since
|
|
313
|
+
* the epoch of the `updated_at` the reader read the document at.
|
|
314
|
+
*/
|
|
315
|
+
export function inputStampFromUpdatedAt(updatedAt: string): number | null {
|
|
316
|
+
const ms = Date.parse(updatedAt);
|
|
317
|
+
return Number.isSafeInteger(ms) && ms >= 0 && ms < 10 ** INPUT_STAMP_DIGITS ? ms : null;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function isStamp(value: unknown): value is number {
|
|
321
|
+
return (
|
|
322
|
+
typeof value === 'number' &&
|
|
323
|
+
Number.isSafeInteger(value) &&
|
|
324
|
+
value >= 0 &&
|
|
325
|
+
value < 10 ** INPUT_STAMP_DIGITS
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* @internal
|
|
331
|
+
* What a reader acknowledged for one input document: that it read the
|
|
332
|
+
* document at `stamp`, or (`removed`) that it was told the document stopped
|
|
333
|
+
* being an input, `stamp` then being the last version it knew.
|
|
334
|
+
*
|
|
335
|
+
* Marks for one document order by stamp, and at one stamp a read follows a
|
|
336
|
+
* removal, so a document that returns unchanged after its removal was
|
|
337
|
+
* acknowledged is reported again and its next read settles it.
|
|
338
|
+
*/
|
|
339
|
+
export interface InputReadMark {
|
|
340
|
+
stamp: number;
|
|
341
|
+
removed: boolean;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const READ_KIND = { removed: '0', read: '1' } as const;
|
|
345
|
+
|
|
346
|
+
/** @internal */
|
|
347
|
+
export function inputReadEntryKey(key: string, docId: string, mark: InputReadMark): string {
|
|
348
|
+
const stamp = String(mark.stamp).padStart(INPUT_STAMP_DIGITS, '0');
|
|
349
|
+
const kind = mark.removed ? READ_KIND.removed : READ_KIND.read;
|
|
350
|
+
return `${key}${SEP}${docId}${SEP}${stamp}${SEP}${kind}`;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** @internal The prefix every read mark of one key shares, or of one document under it. */
|
|
354
|
+
export function inputReadEntryPrefix(key: string, docId?: string): string {
|
|
355
|
+
return docId === undefined ? `${key}${SEP}` : `${key}${SEP}${docId}${SEP}`;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** @internal */
|
|
359
|
+
export function parseInputReadEntryKey(
|
|
360
|
+
entryKey: string,
|
|
361
|
+
): { key: string; docId: string; mark: InputReadMark } | null {
|
|
362
|
+
const parts = entryKey.split(SEP);
|
|
363
|
+
if (parts.length !== 4) return null;
|
|
364
|
+
const [key, docId, stamp, kind] = parts as [string, string, string, string];
|
|
365
|
+
if (!isInputKey(key) || !isInputDocId(docId)) return null;
|
|
366
|
+
if (!new RegExp(`^[0-9]{${INPUT_STAMP_DIGITS}}$`).test(stamp)) return null;
|
|
367
|
+
if (kind !== READ_KIND.removed && kind !== READ_KIND.read) return null;
|
|
368
|
+
return { key, docId, mark: { stamp: Number(stamp), removed: kind === READ_KIND.removed } };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* @internal
|
|
373
|
+
* The mark that acknowledges a file leaving a key, given the latest mark the
|
|
374
|
+
* reader holds for it and the version it left at (`updatedAt` on the removed
|
|
375
|
+
* entry; null when the entry carries none). The mark sits at that version, so
|
|
376
|
+
* a read of the file returning at it follows the removal. When the version
|
|
377
|
+
* did not move past the reader's last read, the removal has to sit one stamp
|
|
378
|
+
* past that read to follow it, and `inputReadMarkFor` lifts a later read of
|
|
379
|
+
* the returning file to the removal's stamp. A removal already acknowledged
|
|
380
|
+
* keeps its mark.
|
|
381
|
+
*/
|
|
382
|
+
export function inputRemovalMark(
|
|
383
|
+
latest: InputReadMark | null,
|
|
384
|
+
updatedAt: string | null,
|
|
385
|
+
): InputReadMark {
|
|
386
|
+
if (latest?.removed === true) return latest;
|
|
387
|
+
const leftAt = updatedAt === null ? null : inputStampFromUpdatedAt(updatedAt);
|
|
388
|
+
const floor = latest === null ? 0 : latest.stamp + 1;
|
|
389
|
+
return { stamp: Math.max(leftAt ?? 0, floor), removed: true };
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* @internal
|
|
394
|
+
* The mark that records reading a file at `stamp`, given the latest mark the
|
|
395
|
+
* reader holds for it: at `stamp`, or at a removal's stamp when that removal
|
|
396
|
+
* sits past it, which is the one stamp a read can take to follow it.
|
|
397
|
+
*/
|
|
398
|
+
export function inputReadMarkFor(latest: InputReadMark | null, stamp: number): InputReadMark {
|
|
399
|
+
if (latest?.removed === true && latest.stamp > stamp) return { stamp: latest.stamp, removed: false };
|
|
400
|
+
return { stamp, removed: false };
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** @internal Whether `a` is a later acknowledgement than `b`. */
|
|
404
|
+
export function inputReadMarkAfter(a: InputReadMark, b: InputReadMark): boolean {
|
|
405
|
+
if (a.stamp !== b.stamp) return a.stamp > b.stamp;
|
|
406
|
+
return !a.removed && b.removed;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** @internal */
|
|
410
|
+
export function parseInputReadMark(raw: unknown): InputReadMark | null {
|
|
411
|
+
if (!isRecord(raw) || !isStamp(raw['stamp']) || typeof raw['removed'] !== 'boolean') return null;
|
|
412
|
+
return { stamp: raw['stamp'], removed: raw['removed'] };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** @internal One key's references and read marks, as decoded off the two roots. */
|
|
416
|
+
export interface InputsKeyState {
|
|
417
|
+
/** Sorted by reference id, so two peers' decodes compare equal. */
|
|
418
|
+
refs: InputReferenceWire[];
|
|
419
|
+
/** The latest mark per document. */
|
|
420
|
+
read: Record<string, InputReadMark>;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* @internal
|
|
425
|
+
* Every key holding references, from the two roots' plain-object forms.
|
|
426
|
+
* Tolerant, since peers write the roots: an entry whose key does not parse,
|
|
427
|
+
* whose value is not a reference, or whose reference id does not match the
|
|
428
|
+
* one re-derived from its value is dropped rather than failing the read. A
|
|
429
|
+
* read mark is kept only when present (`true`), and only the latest per
|
|
430
|
+
* document counts. Keys with no references are absent, along with their
|
|
431
|
+
* marks.
|
|
432
|
+
*/
|
|
433
|
+
export function decodeInputsRoots(refsRoot: unknown, readRoot: unknown): Map<string, InputsKeyState> {
|
|
434
|
+
const refsByKey = new Map<string, Map<string, InputReferenceWire>>();
|
|
435
|
+
if (isRecord(refsRoot)) {
|
|
436
|
+
for (const [entryKey, value] of Object.entries(refsRoot)) {
|
|
437
|
+
const parsed = parseInputRefEntryKey(entryKey);
|
|
438
|
+
const ref = parseInputReference(value);
|
|
439
|
+
if (parsed === null || ref === null || inputReferenceId(ref) !== parsed.refId) continue;
|
|
440
|
+
const held = refsByKey.get(parsed.key) ?? new Map<string, InputReferenceWire>();
|
|
441
|
+
held.set(parsed.refId, ref);
|
|
442
|
+
refsByKey.set(parsed.key, held);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
const out = new Map<string, InputsKeyState>();
|
|
446
|
+
for (const key of [...refsByKey.keys()].sort()) {
|
|
447
|
+
const held = refsByKey.get(key)!;
|
|
448
|
+
const refs = [...held.keys()].sort().map((id) => held.get(id)!);
|
|
449
|
+
out.set(key, { refs, read: {} });
|
|
450
|
+
}
|
|
451
|
+
if (isRecord(readRoot)) {
|
|
452
|
+
for (const [entryKey, value] of Object.entries(readRoot)) {
|
|
453
|
+
if (value !== true) continue;
|
|
454
|
+
const parsed = parseInputReadEntryKey(entryKey);
|
|
455
|
+
const state = parsed ? out.get(parsed.key) : undefined;
|
|
456
|
+
if (!parsed || !state) continue;
|
|
457
|
+
const prior = state.read[parsed.docId];
|
|
458
|
+
if (!prior || inputReadMarkAfter(parsed.mark, prior)) state.read[parsed.docId] = parsed.mark;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
return out;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* @internal
|
|
466
|
+
* The writes one root takes: entries to set and entry keys to delete. Every
|
|
467
|
+
* write is per entry, so a concurrent writer's other entries survive.
|
|
468
|
+
*/
|
|
469
|
+
export interface InputsRootWrites<V> {
|
|
470
|
+
set: Record<string, V>;
|
|
471
|
+
delete: string[];
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* @internal
|
|
476
|
+
* The writes that make `key`'s references `next` (null clears the key), given
|
|
477
|
+
* the refs root as this writer sees it. Only the difference is written:
|
|
478
|
+
* entries this writer did not see are not touched, so a reference another
|
|
479
|
+
* peer adds concurrently survives. Throws on a reference that would not
|
|
480
|
+
* decode.
|
|
481
|
+
*/
|
|
482
|
+
export function inputRefsWrites(
|
|
483
|
+
refsRoot: Readonly<Record<string, unknown>>,
|
|
484
|
+
key: string,
|
|
485
|
+
next: readonly InputReferenceWire[] | null,
|
|
486
|
+
): InputsRootWrites<InputReferenceWire> {
|
|
487
|
+
if (!isInputKey(key)) throw new TypeError('Not a valid input key');
|
|
488
|
+
const wanted = new Map<string, InputReferenceWire>();
|
|
489
|
+
for (const ref of next ?? []) {
|
|
490
|
+
const parsed = parseInputReference(ref);
|
|
491
|
+
if (parsed === null) throw new TypeError('Not a valid input reference');
|
|
492
|
+
wanted.set(inputRefEntryKey(key, inputReferenceId(parsed)), parsed);
|
|
493
|
+
}
|
|
494
|
+
const writes: InputsRootWrites<InputReferenceWire> = { set: {}, delete: [] };
|
|
495
|
+
for (const entryKey of Object.keys(refsRoot).sort()) {
|
|
496
|
+
if (parseInputRefEntryKey(entryKey)?.key === key && !wanted.has(entryKey)) {
|
|
497
|
+
writes.delete.push(entryKey);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
for (const entryKey of [...wanted.keys()].sort()) {
|
|
501
|
+
const ref = wanted.get(entryKey)!;
|
|
502
|
+
if (!sameJson(refsRoot[entryKey], ref)) writes.set[entryKey] = ref;
|
|
503
|
+
}
|
|
504
|
+
return writes;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* @internal
|
|
509
|
+
* The writes that record `mark` for `docId` under `key`, given the read root
|
|
510
|
+
* as this writer sees it: the mark's entry, and the deletion of every earlier
|
|
511
|
+
* mark for that document. A later mark already present is left in place, and
|
|
512
|
+
* still wins.
|
|
513
|
+
*/
|
|
514
|
+
export function inputReadMarkWrites(
|
|
515
|
+
readRoot: Readonly<Record<string, unknown>>,
|
|
516
|
+
key: string,
|
|
517
|
+
docId: string,
|
|
518
|
+
mark: InputReadMark,
|
|
519
|
+
): InputsRootWrites<true> {
|
|
520
|
+
if (!isInputKey(key) || !isInputDocId(docId) || parseInputReadMark(mark) === null) {
|
|
521
|
+
throw new TypeError('Not a valid read mark');
|
|
522
|
+
}
|
|
523
|
+
const own = inputReadEntryKey(key, docId, mark);
|
|
524
|
+
const writes: InputsRootWrites<true> = { set: {}, delete: [] };
|
|
525
|
+
for (const entryKey of Object.keys(readRoot).sort()) {
|
|
526
|
+
const parsed = parseInputReadEntryKey(entryKey);
|
|
527
|
+
if (parsed?.key === key && parsed.docId === docId && inputReadMarkAfter(mark, parsed.mark)) {
|
|
528
|
+
writes.delete.push(entryKey);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
if (readRoot[own] !== true) writes.set[own] = true;
|
|
532
|
+
return writes;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/** @internal The writes that clear every read mark under `key`, as when the key is cleared. */
|
|
536
|
+
export function inputReadClearWrites(
|
|
537
|
+
readRoot: Readonly<Record<string, unknown>>,
|
|
538
|
+
key: string,
|
|
539
|
+
): InputsRootWrites<true> {
|
|
540
|
+
const prefix = inputReadEntryPrefix(key);
|
|
541
|
+
return { set: {}, delete: Object.keys(readRoot).filter((k) => k.startsWith(prefix)).sort() };
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function sameJson(a: unknown, b: unknown): boolean {
|
|
545
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// --- Status (GET) -----------------------------------------------------------
|
|
549
|
+
|
|
550
|
+
export type InputFileChange = 'added' | 'modified' | 'removed';
|
|
551
|
+
|
|
552
|
+
/** @internal */
|
|
553
|
+
export const INPUT_FILE_CHANGES: readonly InputFileChange[] = ['added', 'modified', 'removed'];
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* @internal
|
|
557
|
+
* One input file as it is now. `name` is the document's name; `from` names
|
|
558
|
+
* the key's references that include it by `inputReferenceId`, nearest first;
|
|
559
|
+
* `ref` is a reference to this one file.
|
|
560
|
+
*/
|
|
561
|
+
export interface InputStatusFileWire {
|
|
562
|
+
doc_id: string;
|
|
563
|
+
name: string;
|
|
564
|
+
/** The file's family, absent on a document that has none. */
|
|
565
|
+
schema_type?: string;
|
|
566
|
+
updated_at: string;
|
|
567
|
+
from: string[];
|
|
568
|
+
ref: InputReferenceWire;
|
|
569
|
+
/** Present while an app imports the file and has not yet: the file is
|
|
570
|
+
* stored or on its way, and no import of it ran or failed. A reader that
|
|
571
|
+
* is not that app is refused `not_imported` while it is present. */
|
|
572
|
+
awaits_import?: true;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* @internal
|
|
577
|
+
* One file the reader has not read at its current version. `from` is as on a
|
|
578
|
+
* file; a removed file's names the references that included it, and is empty
|
|
579
|
+
* when none still exists. `key` is present in the keyless status.
|
|
580
|
+
*
|
|
581
|
+
* `updated_at` is present on a `removed` entry: the version the file left the
|
|
582
|
+
* key at, which is the latest version either the platform or this reader's
|
|
583
|
+
* read mark knew. A reader acknowledges the removal with a mark at that
|
|
584
|
+
* version (`inputRemovalMark`), so a file that returns at it is reported
|
|
585
|
+
* again and its next read settles it.
|
|
586
|
+
*/
|
|
587
|
+
export interface InputStatusChangeWire {
|
|
588
|
+
doc_id: string;
|
|
589
|
+
change: InputFileChange;
|
|
590
|
+
from: string[];
|
|
591
|
+
key?: string;
|
|
592
|
+
updated_at?: string;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/** @internal One key's status, as `GET` answers it and the SDK hands an app. */
|
|
596
|
+
export interface InputStatusWire {
|
|
597
|
+
key: string;
|
|
598
|
+
refs: InputReferenceWire[];
|
|
599
|
+
files: InputStatusFileWire[];
|
|
600
|
+
changed: InputStatusChangeWire[];
|
|
601
|
+
/** Input files this reader cannot reach. They are counted, never reported removed. */
|
|
602
|
+
hidden_count: number;
|
|
603
|
+
/** The document's keys reached more than `INPUT_DOCS_MAX` documents. */
|
|
604
|
+
truncated: boolean;
|
|
605
|
+
/** Moves every time anything in this status moves. */
|
|
606
|
+
rev: number;
|
|
607
|
+
/**
|
|
608
|
+
* Present, and true, when the platform cannot vouch that the references
|
|
609
|
+
* and read marks above reflect the document: ops are sequenced past the
|
|
610
|
+
* last projection and the projection is blocked (the branch refuses
|
|
611
|
+
* writes) or did not finish, or the key's row holds a provisional write
|
|
612
|
+
* the projection has already passed. The answer is the last one the
|
|
613
|
+
* platform recorded rather than a wrong one, and it clears once a
|
|
614
|
+
* projection completes.
|
|
615
|
+
*/
|
|
616
|
+
stale?: boolean;
|
|
617
|
+
/**
|
|
618
|
+
* Present, and true, while a place one of the key's references names is
|
|
619
|
+
* still receiving its files: a drive connected there has not finished
|
|
620
|
+
* listing them. `files` and `changed` hold the ones that have arrived.
|
|
621
|
+
*/
|
|
622
|
+
arriving?: boolean;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* @internal
|
|
627
|
+
* One page of the keyless status: a run of the document's keys in key order,
|
|
628
|
+
* and each one's changed files. `hidden_count` and `truncated` cover the
|
|
629
|
+
* page; `truncated` is also set when the page's changed entries were cut at
|
|
630
|
+
* `INPUT_KEYS_CHANGED_MAX`. `cursor` resumes after the page's last key, or is
|
|
631
|
+
* null on the last page.
|
|
632
|
+
*/
|
|
633
|
+
export interface InputStatusAll {
|
|
634
|
+
keys: string[];
|
|
635
|
+
changed: Array<InputStatusChangeWire & { key: string }>;
|
|
636
|
+
hidden_count: number;
|
|
637
|
+
truncated: boolean;
|
|
638
|
+
rev: number;
|
|
639
|
+
/** As on `InputStatusWire`, for any of the document's keys on the line. */
|
|
640
|
+
stale?: boolean;
|
|
641
|
+
cursor: string | null;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* @internal
|
|
646
|
+
* What the keyless status answers to a cursor pinned to a revision the
|
|
647
|
+
* document's keys have since moved past. The reader starts again.
|
|
648
|
+
*/
|
|
649
|
+
export interface InputStatusAllResync {
|
|
650
|
+
rev: number;
|
|
651
|
+
resync: true;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/** @internal The most keys one page of the keyless status carries. */
|
|
655
|
+
export const INPUT_KEYS_PAGE_SIZE = 50;
|
|
656
|
+
/** @internal The most changed entries one page of the keyless status carries. */
|
|
657
|
+
export const INPUT_KEYS_CHANGED_MAX = INPUT_DOCS_MAX;
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* @internal
|
|
661
|
+
* One page of a key's full status. A page covers a range of document ids:
|
|
662
|
+
* `files` and `changed` hold the entries inside it, in ascending doc id
|
|
663
|
+
* order, and `cursor` resumes after it, or is null on the last page.
|
|
664
|
+
*/
|
|
665
|
+
export interface InputStatusPage extends InputStatusWire {
|
|
666
|
+
cursor: string | null;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/** @internal Query parameters on `GET` of a key's status. */
|
|
670
|
+
export const INPUT_STATUS_SINCE_PARAM = 'since_rev';
|
|
671
|
+
/** @internal */
|
|
672
|
+
export const INPUT_STATUS_CURSOR_PARAM = 'cursor';
|
|
673
|
+
/**
|
|
674
|
+
* @internal
|
|
675
|
+
* The line whose keys a `GET` reads: a branch or a stream of the document,
|
|
676
|
+
* named as a document join names it. Absent reads the document's default
|
|
677
|
+
* branch.
|
|
678
|
+
*/
|
|
679
|
+
export const INPUT_STATUS_LINE_PARAM = 'branch';
|
|
680
|
+
/** @internal The most documents one page or one delta carries. */
|
|
681
|
+
export const INPUT_STATUS_PAGE_SIZE = 500;
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* @internal
|
|
685
|
+
* A page cursor: the revision the first page answered at and the last doc id
|
|
686
|
+
* the previous page held. Pinning the revision is what keeps a paged read
|
|
687
|
+
* consistent; a server whose status moved since answers a resync, and the
|
|
688
|
+
* reader starts again.
|
|
689
|
+
*/
|
|
690
|
+
export function encodeInputStatusCursor(cursor: { rev: number; after: string }): string {
|
|
691
|
+
return `${cursor.rev}.${cursor.after}`;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* @internal
|
|
696
|
+
* A keyless status cursor: `encodeInputStatusCursor` over the revision the
|
|
697
|
+
* first page answered at and the last key the previous page held.
|
|
698
|
+
*/
|
|
699
|
+
export function decodeInputKeysCursor(raw: unknown): { rev: number; after: string } | null {
|
|
700
|
+
if (typeof raw !== 'string') return null;
|
|
701
|
+
const m = /^(0|[1-9][0-9]{0,14})\.(.+)$/s.exec(raw);
|
|
702
|
+
if (!m || !m[1] || !m[2] || !isInputKey(m[2])) return null;
|
|
703
|
+
return { rev: Number(m[1]), after: m[2] };
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/** @internal */
|
|
707
|
+
export function decodeInputStatusCursor(raw: unknown): { rev: number; after: string } | null {
|
|
708
|
+
if (typeof raw !== 'string') return null;
|
|
709
|
+
const m = /^(0|[1-9][0-9]{0,14})\.(doc_[A-Za-z0-9_-]{32})$/.exec(raw);
|
|
710
|
+
if (!m || !m[1] || !m[2]) return null;
|
|
711
|
+
return { rev: Number(m[1]), after: m[2] };
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* @internal
|
|
716
|
+
* What `GET ?since_rev=<n>` answers when it can: what moved between `since_rev`
|
|
717
|
+
* and `rev`. Upserts replace the entry with the same doc id; removes name doc
|
|
718
|
+
* ids. `refs` is present only when the key's references moved.
|
|
719
|
+
*/
|
|
720
|
+
export interface InputStatusDelta {
|
|
721
|
+
key: string;
|
|
722
|
+
since_rev: number;
|
|
723
|
+
rev: number;
|
|
724
|
+
refs?: InputReferenceWire[];
|
|
725
|
+
files: { upsert: InputStatusFileWire[]; remove: string[] };
|
|
726
|
+
changed: { upsert: InputStatusChangeWire[]; remove: string[] };
|
|
727
|
+
hidden_count: number;
|
|
728
|
+
truncated: boolean;
|
|
729
|
+
/** As on `InputStatusWire`; the status a delta moves to carries it or drops it. */
|
|
730
|
+
stale?: boolean;
|
|
731
|
+
/** As on `InputStatusWire`, carried or dropped the same way. */
|
|
732
|
+
arriving?: boolean;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* @internal
|
|
737
|
+
* What `GET ?since_rev=<n>` answers when it cannot describe the gap (the
|
|
738
|
+
* revision is older than what the server keeps, or the delta would exceed a
|
|
739
|
+
* page). The reader pages the full status.
|
|
740
|
+
*/
|
|
741
|
+
export interface InputStatusResync {
|
|
742
|
+
key: string;
|
|
743
|
+
rev: number;
|
|
744
|
+
resync: true;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/**
|
|
748
|
+
* @internal
|
|
749
|
+
* The status a delta moves `status` to, or null when the delta does not start
|
|
750
|
+
* at `status.rev` (a gap, which the reader closes by fetching again).
|
|
751
|
+
*/
|
|
752
|
+
export function applyInputStatusDelta(
|
|
753
|
+
status: InputStatusWire,
|
|
754
|
+
delta: InputStatusDelta,
|
|
755
|
+
): InputStatusWire | null {
|
|
756
|
+
if (delta.key !== status.key || delta.since_rev !== status.rev) return null;
|
|
757
|
+
return {
|
|
758
|
+
key: status.key,
|
|
759
|
+
refs: delta.refs ?? status.refs,
|
|
760
|
+
files: applyUpserts(status.files, delta.files),
|
|
761
|
+
changed: applyUpserts(status.changed, delta.changed),
|
|
762
|
+
hidden_count: delta.hidden_count,
|
|
763
|
+
truncated: delta.truncated,
|
|
764
|
+
rev: delta.rev,
|
|
765
|
+
...(delta.stale === true ? { stale: true } : {}),
|
|
766
|
+
...(delta.arriving === true ? { arriving: true } : {}),
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
function applyUpserts<T extends { doc_id: string }>(
|
|
771
|
+
current: readonly T[],
|
|
772
|
+
diff: { upsert: readonly T[]; remove: readonly string[] },
|
|
773
|
+
): T[] {
|
|
774
|
+
const byId = new Map(current.map((e) => [e.doc_id, e]));
|
|
775
|
+
for (const id of diff.remove) byId.delete(id);
|
|
776
|
+
for (const e of diff.upsert) byId.set(e.doc_id, e);
|
|
777
|
+
return [...byId.values()].sort((a, b) => (a.doc_id < b.doc_id ? -1 : a.doc_id > b.doc_id ? 1 : 0));
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/** @internal Reads a key's status (or a page of one) off the wire. Null when malformed. */
|
|
781
|
+
export function parseInputStatus(raw: unknown): InputStatusWire | null {
|
|
782
|
+
if (!isRecord(raw) || !isInputKey(raw['key'])) return null;
|
|
783
|
+
const refs = parseList(raw['refs'], parseInputReference);
|
|
784
|
+
const files = parseList(raw['files'], parseInputStatusFile);
|
|
785
|
+
const changed = parseList(raw['changed'], parseInputStatusChange);
|
|
786
|
+
const counts = parseCounts(raw);
|
|
787
|
+
const stale = parseStale(raw);
|
|
788
|
+
const arriving = parseArriving(raw);
|
|
789
|
+
if (refs === null || files === null || changed === null || counts === null || stale === null || arriving === null) {
|
|
790
|
+
return null;
|
|
791
|
+
}
|
|
792
|
+
return { key: raw['key'], refs, files, changed, ...counts, ...stale, ...arriving };
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/** @internal */
|
|
796
|
+
export function parseInputStatusPage(raw: unknown): InputStatusPage | null {
|
|
797
|
+
const status = parseInputStatus(raw);
|
|
798
|
+
if (status === null || !isRecord(raw)) return null;
|
|
799
|
+
const cursor = raw['cursor'];
|
|
800
|
+
if (cursor !== null && decodeInputStatusCursor(cursor) === null) return null;
|
|
801
|
+
return { ...status, cursor: cursor as string | null };
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/** @internal Reads a keyless status page, or the resync a moved cursor draws. Null when malformed. */
|
|
805
|
+
export function parseInputStatusAll(raw: unknown): InputStatusAll | InputStatusAllResync | null {
|
|
806
|
+
if (!isRecord(raw)) return null;
|
|
807
|
+
if (raw['resync'] === true) return isRev(raw['rev']) ? { rev: raw['rev'], resync: true } : null;
|
|
808
|
+
const cursor = raw['cursor'] ?? null;
|
|
809
|
+
if (cursor !== null && decodeInputKeysCursor(cursor) === null) return null;
|
|
810
|
+
const keys = raw['keys'];
|
|
811
|
+
if (!Array.isArray(keys) || !keys.every(isInputKey)) return null;
|
|
812
|
+
const changed = parseList(raw['changed'], parseInputStatusChange);
|
|
813
|
+
const counts = parseCounts(raw);
|
|
814
|
+
const stale = parseStale(raw);
|
|
815
|
+
if (changed === null || counts === null || stale === null) return null;
|
|
816
|
+
const keyed: Array<InputStatusChangeWire & { key: string }> = [];
|
|
817
|
+
for (const c of changed) {
|
|
818
|
+
if (c.key === undefined) return null;
|
|
819
|
+
keyed.push({ ...c, key: c.key });
|
|
820
|
+
}
|
|
821
|
+
return { keys: [...keys], changed: keyed, ...counts, ...stale, cursor: cursor as string | null };
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/** @internal Reads a `since_rev` answer: a delta, a resync, or null when malformed. */
|
|
825
|
+
export function parseInputStatusDelta(raw: unknown): InputStatusDelta | InputStatusResync | null {
|
|
826
|
+
if (!isRecord(raw) || !isInputKey(raw['key'])) return null;
|
|
827
|
+
const rev = raw['rev'];
|
|
828
|
+
if (!isRev(rev)) return null;
|
|
829
|
+
if (raw['resync'] === true) return { key: raw['key'], rev, resync: true };
|
|
830
|
+
const since = raw['since_rev'];
|
|
831
|
+
if (!isRev(since) || since > rev) return null;
|
|
832
|
+
const files = parseDiff(raw['files'], parseInputStatusFile);
|
|
833
|
+
const changed = parseDiff(raw['changed'], parseInputStatusChange);
|
|
834
|
+
const counts = parseCounts(raw);
|
|
835
|
+
const stale = parseStale(raw);
|
|
836
|
+
const arriving = parseArriving(raw);
|
|
837
|
+
if (files === null || changed === null || counts === null || stale === null || arriving === null) return null;
|
|
838
|
+
const delta: InputStatusDelta = {
|
|
839
|
+
key: raw['key'],
|
|
840
|
+
since_rev: since,
|
|
841
|
+
rev,
|
|
842
|
+
files,
|
|
843
|
+
changed,
|
|
844
|
+
hidden_count: counts.hidden_count,
|
|
845
|
+
truncated: counts.truncated,
|
|
846
|
+
...stale,
|
|
847
|
+
...arriving,
|
|
848
|
+
};
|
|
849
|
+
if (raw['refs'] !== undefined) {
|
|
850
|
+
const refs = parseList(raw['refs'], parseInputReference);
|
|
851
|
+
if (refs === null) return null;
|
|
852
|
+
delta.refs = refs;
|
|
853
|
+
}
|
|
854
|
+
return delta;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
function parseInputStatusFile(raw: unknown): InputStatusFileWire | null {
|
|
858
|
+
if (!isRecord(raw) || !isInputDocId(raw['doc_id'])) return null;
|
|
859
|
+
const updatedAt = raw['updated_at'];
|
|
860
|
+
if (typeof updatedAt !== 'string' || updatedAt.length > INPUT_STAMP_MAX_CHARS) return null;
|
|
861
|
+
const name = raw['name'];
|
|
862
|
+
if (typeof name !== 'string') return null;
|
|
863
|
+
const from = parseFrom(raw['from']);
|
|
864
|
+
const ref = parseInputReference(raw['ref']);
|
|
865
|
+
if (from === null || ref === null) return null;
|
|
866
|
+
const schemaType = raw['schema_type'];
|
|
867
|
+
if (schemaType !== undefined && typeof schemaType !== 'string') return null;
|
|
868
|
+
const awaitsImport = raw['awaits_import'];
|
|
869
|
+
if (awaitsImport !== undefined && awaitsImport !== true) return null;
|
|
870
|
+
return {
|
|
871
|
+
doc_id: raw['doc_id'],
|
|
872
|
+
name,
|
|
873
|
+
...(schemaType !== undefined ? { schema_type: schemaType } : {}),
|
|
874
|
+
updated_at: updatedAt,
|
|
875
|
+
from,
|
|
876
|
+
ref,
|
|
877
|
+
...(awaitsImport === true ? { awaits_import: true as const } : {}),
|
|
878
|
+
};
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
function parseInputStatusChange(raw: unknown): InputStatusChangeWire | null {
|
|
882
|
+
if (!isRecord(raw) || !isInputDocId(raw['doc_id'])) return null;
|
|
883
|
+
const change = raw['change'];
|
|
884
|
+
if (!INPUT_FILE_CHANGES.includes(change as InputFileChange)) return null;
|
|
885
|
+
const from = parseFrom(raw['from']);
|
|
886
|
+
if (from === null) return null;
|
|
887
|
+
const out: InputStatusChangeWire = { doc_id: raw['doc_id'], change: change as InputFileChange, from };
|
|
888
|
+
if (raw['key'] !== undefined) {
|
|
889
|
+
if (!isInputKey(raw['key'])) return null;
|
|
890
|
+
out.key = raw['key'];
|
|
891
|
+
}
|
|
892
|
+
if (raw['updated_at'] !== undefined) {
|
|
893
|
+
const at = raw['updated_at'];
|
|
894
|
+
if (typeof at !== 'string' || at.length > INPUT_STAMP_MAX_CHARS) return null;
|
|
895
|
+
out.updated_at = at;
|
|
896
|
+
}
|
|
897
|
+
return out;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
function parseFrom(raw: unknown): string[] | null {
|
|
901
|
+
return Array.isArray(raw) && raw.every(isInputReferenceId) ? [...raw] : null;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
function parseCounts(
|
|
905
|
+
raw: Record<string, unknown>,
|
|
906
|
+
): { hidden_count: number; truncated: boolean; rev: number } | null {
|
|
907
|
+
const hidden = raw['hidden_count'];
|
|
908
|
+
const truncated = raw['truncated'];
|
|
909
|
+
const rev = raw['rev'];
|
|
910
|
+
if (!isRev(hidden) || typeof truncated !== 'boolean' || !isRev(rev)) return null;
|
|
911
|
+
return { hidden_count: hidden, truncated, rev };
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
// `stale` is optional on the wire and carried only when true.
|
|
915
|
+
function parseStale(raw: Record<string, unknown>): { stale?: true } | null {
|
|
916
|
+
const stale = raw['stale'];
|
|
917
|
+
if (stale === undefined || stale === false) return {};
|
|
918
|
+
return stale === true ? { stale: true } : null;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
// `arriving` is optional on the wire and carried only when true.
|
|
922
|
+
function parseArriving(raw: Record<string, unknown>): { arriving?: true } | null {
|
|
923
|
+
const arriving = raw['arriving'];
|
|
924
|
+
if (arriving === undefined || arriving === false) return {};
|
|
925
|
+
return arriving === true ? { arriving: true } : null;
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
function parseDiff<T>(
|
|
929
|
+
raw: unknown,
|
|
930
|
+
parse: (v: unknown) => T | null,
|
|
931
|
+
): { upsert: T[]; remove: string[] } | null {
|
|
932
|
+
if (!isRecord(raw)) return null;
|
|
933
|
+
const upsert = parseList(raw['upsert'], parse);
|
|
934
|
+
const remove = raw['remove'];
|
|
935
|
+
if (upsert === null || !Array.isArray(remove) || !remove.every(isInputDocId)) return null;
|
|
936
|
+
return { upsert, remove: [...remove] };
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
function parseList<T>(raw: unknown, parse: (v: unknown) => T | null): T[] | null {
|
|
940
|
+
if (!Array.isArray(raw)) return null;
|
|
941
|
+
const out: T[] = [];
|
|
942
|
+
for (const entry of raw) {
|
|
943
|
+
const parsed = parse(entry);
|
|
944
|
+
if (parsed === null) return null;
|
|
945
|
+
out.push(parsed);
|
|
946
|
+
}
|
|
947
|
+
return out;
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
function isRev(value: unknown): value is number {
|
|
951
|
+
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
// --- Live nudge (WS) --------------------------------------------------------
|
|
955
|
+
|
|
956
|
+
/** @internal */
|
|
957
|
+
export const INPUT_STATUS_FRAME_TYPE = 'doc_input_status';
|
|
958
|
+
|
|
959
|
+
/**
|
|
960
|
+
* @internal
|
|
961
|
+
* Says a key's status moved to `rev`; the reader fetches the delta from the
|
|
962
|
+
* revision it holds. Posted on the branched topic of the line the key is
|
|
963
|
+
* recorded on, since an editable open routes frames by exact topic.
|
|
964
|
+
*/
|
|
965
|
+
export interface InputStatusNudgeFrame {
|
|
966
|
+
type: typeof INPUT_STATUS_FRAME_TYPE;
|
|
967
|
+
topic: string;
|
|
968
|
+
key: string;
|
|
969
|
+
rev: number;
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
/** @internal */
|
|
973
|
+
export function inputStatusNudgeFrame(
|
|
974
|
+
docId: string,
|
|
975
|
+
lineId: string,
|
|
976
|
+
key: string,
|
|
977
|
+
rev: number,
|
|
978
|
+
): InputStatusNudgeFrame {
|
|
979
|
+
return { type: INPUT_STATUS_FRAME_TYPE, topic: documentTopic(docId, lineId), key, rev };
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
/** @internal Reads a nudge frame. Null for any other frame or a malformed one. */
|
|
983
|
+
export function parseInputStatusNudge(
|
|
984
|
+
raw: unknown,
|
|
985
|
+
): { docId: string; lineId: string; key: string; rev: number } | null {
|
|
986
|
+
if (!isRecord(raw) || raw['type'] !== INPUT_STATUS_FRAME_TYPE) return null;
|
|
987
|
+
const topic = typeof raw['topic'] === 'string' ? parseDocTopic(raw['topic']) : null;
|
|
988
|
+
if (topic === null || !isInputKey(raw['key']) || !isRev(raw['rev'])) return null;
|
|
989
|
+
return { docId: topic.docId, lineId: topic.branchId, key: raw['key'], rev: raw['rev'] };
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
// --- Provisional write (POST) -----------------------------------------------
|
|
993
|
+
|
|
994
|
+
/**
|
|
995
|
+
* @internal
|
|
996
|
+
* What the SDK posts, to the key's own path, once the op carrying a key's
|
|
997
|
+
* change is acknowledged: the key's references and the latest read mark per
|
|
998
|
+
* document as the client's copy holds them after that op. `op_seq` names that
|
|
999
|
+
* op, which has to have landed on `branch_id` and been written by the posting
|
|
1000
|
+
* user; it is what orders the provisional row against the projection
|
|
1001
|
+
* compaction makes, and the server writes the row only when it is past both
|
|
1002
|
+
* the line's `projected_through` and the row's own `seq`. An empty `refs`
|
|
1003
|
+
* clears the key.
|
|
1004
|
+
*/
|
|
1005
|
+
export interface ProvisionalInputsBody {
|
|
1006
|
+
branch_id: string;
|
|
1007
|
+
op_seq: number;
|
|
1008
|
+
refs: InputReferenceWire[];
|
|
1009
|
+
read: Record<string, InputReadMark>;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
/** @internal A provisional post: the key, which names the route's path, and its body. */
|
|
1013
|
+
export interface ProvisionalInputsPost {
|
|
1014
|
+
key: string;
|
|
1015
|
+
body: ProvisionalInputsBody;
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
/**
|
|
1019
|
+
* @internal
|
|
1020
|
+
* What a provisional post answers. `written` is false when the row already
|
|
1021
|
+
* reflects this op or a later one, which is the ordinary outcome of a late or
|
|
1022
|
+
* repeated post rather than a refusal; `rev` is the row's revision either way.
|
|
1023
|
+
*/
|
|
1024
|
+
export interface ProvisionalInputsAnswer {
|
|
1025
|
+
key: string;
|
|
1026
|
+
rev: number;
|
|
1027
|
+
written: boolean;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
/** @internal */
|
|
1031
|
+
export function parseProvisionalInputsAnswer(raw: unknown): ProvisionalInputsAnswer | null {
|
|
1032
|
+
if (!isRecord(raw) || !isInputKey(raw['key']) || !isRev(raw['rev'])) return null;
|
|
1033
|
+
if (typeof raw['written'] !== 'boolean') return null;
|
|
1034
|
+
return { key: raw['key'], rev: raw['rev'], written: raw['written'] };
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
/** @internal */
|
|
1038
|
+
export type InputsParseResult<T> = { ok: true; value: T } | { ok: false; error: string };
|
|
1039
|
+
|
|
1040
|
+
/**
|
|
1041
|
+
* @internal
|
|
1042
|
+
* Reads a provisional body for document `docId`. Refuses a reference to any
|
|
1043
|
+
* stream of `docId` itself, and references that repeat one identity.
|
|
1044
|
+
*/
|
|
1045
|
+
export function parseProvisionalInputsBody(
|
|
1046
|
+
raw: unknown,
|
|
1047
|
+
opts: { docId: string },
|
|
1048
|
+
): InputsParseResult<ProvisionalInputsBody> {
|
|
1049
|
+
if (!isRecord(raw)) return fail('Body must be an object');
|
|
1050
|
+
const { branch_id: lineId, op_seq: opSeq } = raw;
|
|
1051
|
+
if (!isInputLineId(lineId)) return fail('branch_id is not a valid line id');
|
|
1052
|
+
if (typeof opSeq !== 'number' || !Number.isSafeInteger(opSeq) || opSeq < 1) {
|
|
1053
|
+
return fail('op_seq must be a positive integer');
|
|
1054
|
+
}
|
|
1055
|
+
const refsRaw = raw['refs'];
|
|
1056
|
+
if (!Array.isArray(refsRaw)) return fail('refs must be an array');
|
|
1057
|
+
if (refsRaw.length > INPUT_REFS_MAX_PER_KEY) return fail('Too many references');
|
|
1058
|
+
const refs: InputReferenceWire[] = [];
|
|
1059
|
+
const seen = new Set<string>();
|
|
1060
|
+
for (const entry of refsRaw) {
|
|
1061
|
+
const ref = parseInputReference(entry);
|
|
1062
|
+
if (ref === null) return fail('refs holds an invalid reference');
|
|
1063
|
+
if (ref.extent === 'document' && ref.doc_id === opts.docId) {
|
|
1064
|
+
return fail('A document cannot reference itself');
|
|
1065
|
+
}
|
|
1066
|
+
const id = inputReferenceId(ref);
|
|
1067
|
+
if (seen.has(id)) return fail('refs repeats a reference');
|
|
1068
|
+
seen.add(id);
|
|
1069
|
+
refs.push(ref);
|
|
1070
|
+
}
|
|
1071
|
+
const readRaw = raw['read'];
|
|
1072
|
+
if (!isRecord(readRaw)) return fail('read must be an object');
|
|
1073
|
+
const docIds = Object.keys(readRaw);
|
|
1074
|
+
if (docIds.length > INPUT_READ_MARKS_MAX_PER_KEY) return fail('Too many read marks');
|
|
1075
|
+
const read: Record<string, InputReadMark> = {};
|
|
1076
|
+
for (const docId of docIds) {
|
|
1077
|
+
const mark = parseInputReadMark(readRaw[docId]);
|
|
1078
|
+
if (!isInputDocId(docId) || mark === null) return fail('read holds an invalid mark');
|
|
1079
|
+
read[docId] = mark;
|
|
1080
|
+
}
|
|
1081
|
+
return { ok: true, value: { branch_id: lineId, op_seq: opSeq, refs, read } };
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
function fail(error: string): { ok: false; error: string } {
|
|
1085
|
+
return { ok: false, error };
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
1089
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
1090
|
+
}
|