@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.
Files changed (323) hide show
  1. package/AGENTS.md +124 -45
  2. package/CHANGELOG.md +80 -4
  3. package/README.md +666 -179
  4. package/dist/api-fetch.d.ts +8 -0
  5. package/dist/api-fetch.d.ts.map +1 -1
  6. package/dist/api-fetch.js +35 -8
  7. package/dist/api-fetch.js.map +1 -1
  8. package/dist/batched-get.d.ts +11 -0
  9. package/dist/batched-get.d.ts.map +1 -0
  10. package/dist/batched-get.js +92 -0
  11. package/dist/batched-get.js.map +1 -0
  12. package/dist/call-service.d.ts +4 -0
  13. package/dist/call-service.d.ts.map +1 -1
  14. package/dist/call-service.js +66 -3
  15. package/dist/call-service.js.map +1 -1
  16. package/dist/client.d.ts +48 -2
  17. package/dist/client.d.ts.map +1 -1
  18. package/dist/client.js +918 -115
  19. package/dist/client.js.map +1 -1
  20. package/dist/document-checkpoints.d.ts +25 -2
  21. package/dist/document-checkpoints.d.ts.map +1 -1
  22. package/dist/document-checkpoints.js +258 -61
  23. package/dist/document-checkpoints.js.map +1 -1
  24. package/dist/document-commands.d.ts.map +1 -1
  25. package/dist/document-commands.js +5 -3
  26. package/dist/document-commands.js.map +1 -1
  27. package/dist/document-contents.d.ts +10 -0
  28. package/dist/document-contents.d.ts.map +1 -1
  29. package/dist/document-contents.js +23 -1
  30. package/dist/document-contents.js.map +1 -1
  31. package/dist/document-set-subscription.d.ts +56 -0
  32. package/dist/document-set-subscription.d.ts.map +1 -0
  33. package/dist/document-set-subscription.js +240 -0
  34. package/dist/document-set-subscription.js.map +1 -0
  35. package/dist/document-signals.d.ts +6 -0
  36. package/dist/document-signals.d.ts.map +1 -1
  37. package/dist/document-signals.js +31 -1
  38. package/dist/document-signals.js.map +1 -1
  39. package/dist/document-streams.d.ts +36 -2
  40. package/dist/document-streams.d.ts.map +1 -1
  41. package/dist/document-streams.js +69 -18
  42. package/dist/document-streams.js.map +1 -1
  43. package/dist/document-thumbnail-capture.d.ts +12 -2
  44. package/dist/document-thumbnail-capture.d.ts.map +1 -1
  45. package/dist/document-thumbnail-capture.js +103 -27
  46. package/dist/document-thumbnail-capture.js.map +1 -1
  47. package/dist/documents-namespace.d.ts +92 -10
  48. package/dist/documents-namespace.d.ts.map +1 -1
  49. package/dist/documents-namespace.js +285 -39
  50. package/dist/documents-namespace.js.map +1 -1
  51. package/dist/documents.d.ts +173 -8
  52. package/dist/documents.d.ts.map +1 -1
  53. package/dist/documents.js +1778 -310
  54. package/dist/documents.js.map +1 -1
  55. package/dist/errors.d.ts +3 -3
  56. package/dist/errors.d.ts.map +1 -1
  57. package/dist/errors.js.map +1 -1
  58. package/dist/fetch-retry.d.ts +1 -0
  59. package/dist/fetch-retry.d.ts.map +1 -1
  60. package/dist/fetch-retry.js +2 -0
  61. package/dist/fetch-retry.js.map +1 -1
  62. package/dist/file-choice.d.ts +2 -0
  63. package/dist/file-choice.d.ts.map +1 -0
  64. package/dist/file-choice.js +27 -0
  65. package/dist/file-choice.js.map +1 -0
  66. package/dist/guest-link.d.ts +18 -1
  67. package/dist/guest-link.d.ts.map +1 -1
  68. package/dist/guest-link.js +329 -1
  69. package/dist/guest-link.js.map +1 -1
  70. package/dist/handle-types.d.ts +67 -5
  71. package/dist/handle-types.d.ts.map +1 -1
  72. package/dist/handles.d.ts +15 -19
  73. package/dist/handles.d.ts.map +1 -1
  74. package/dist/handles.js +231 -76
  75. package/dist/handles.js.map +1 -1
  76. package/dist/hosted-ui.d.ts +61 -0
  77. package/dist/hosted-ui.d.ts.map +1 -1
  78. package/dist/hosted-ui.js +94 -42
  79. package/dist/hosted-ui.js.map +1 -1
  80. package/dist/import-contract.d.ts +74 -0
  81. package/dist/import-contract.d.ts.map +1 -0
  82. package/dist/import-contract.js +163 -0
  83. package/dist/import-contract.js.map +1 -0
  84. package/dist/import-guest.d.ts +9 -0
  85. package/dist/import-guest.d.ts.map +1 -0
  86. package/dist/import-guest.js +155 -0
  87. package/dist/import-guest.js.map +1 -0
  88. package/dist/import-report.d.ts +17 -0
  89. package/dist/import-report.d.ts.map +1 -0
  90. package/dist/import-report.js +7 -0
  91. package/dist/import-report.js.map +1 -0
  92. package/dist/importer-wait.d.ts +2 -0
  93. package/dist/importer-wait.d.ts.map +1 -0
  94. package/dist/importer-wait.js +5 -0
  95. package/dist/importer-wait.js.map +1 -0
  96. package/dist/importer.d.ts +18 -4
  97. package/dist/importer.d.ts.map +1 -1
  98. package/dist/importer.js +322 -105
  99. package/dist/importer.js.map +1 -1
  100. package/dist/in-development.d.ts +2 -0
  101. package/dist/in-development.d.ts.map +1 -0
  102. package/dist/in-development.js +10 -0
  103. package/dist/in-development.js.map +1 -0
  104. package/dist/index.d.ts +16 -8
  105. package/dist/index.d.ts.map +1 -1
  106. package/dist/index.js +24 -8
  107. package/dist/index.js.map +1 -1
  108. package/dist/init-keys.d.ts +1 -1
  109. package/dist/init-keys.d.ts.map +1 -1
  110. package/dist/init-keys.js +6 -1
  111. package/dist/init-keys.js.map +1 -1
  112. package/dist/input-documents.d.ts +67 -0
  113. package/dist/input-documents.d.ts.map +1 -0
  114. package/dist/input-documents.js +693 -0
  115. package/dist/input-documents.js.map +1 -0
  116. package/dist/inputs-status.d.ts +80 -0
  117. package/dist/inputs-status.d.ts.map +1 -0
  118. package/dist/inputs-status.js +460 -0
  119. package/dist/inputs-status.js.map +1 -0
  120. package/dist/inputs.d.ts +2 -0
  121. package/dist/inputs.d.ts.map +1 -0
  122. package/dist/inputs.js +793 -0
  123. package/dist/inputs.js.map +1 -0
  124. package/dist/internal.d.ts +6 -1
  125. package/dist/internal.d.ts.map +1 -1
  126. package/dist/internal.js +4 -1
  127. package/dist/internal.js.map +1 -1
  128. package/dist/join-content.d.ts +33 -0
  129. package/dist/join-content.d.ts.map +1 -0
  130. package/dist/join-content.js +114 -0
  131. package/dist/join-content.js.map +1 -0
  132. package/dist/join-stamp.d.ts +3 -0
  133. package/dist/join-stamp.d.ts.map +1 -0
  134. package/dist/join-stamp.js +12 -0
  135. package/dist/join-stamp.js.map +1 -0
  136. package/dist/manifest-drift.d.ts +9 -0
  137. package/dist/manifest-drift.d.ts.map +1 -0
  138. package/dist/manifest-drift.js +46 -0
  139. package/dist/manifest-drift.js.map +1 -0
  140. package/dist/open-document.d.ts +109 -2
  141. package/dist/open-document.d.ts.map +1 -1
  142. package/dist/open-document.js +1822 -811
  143. package/dist/open-document.js.map +1 -1
  144. package/dist/page-restore.d.ts +12 -0
  145. package/dist/page-restore.d.ts.map +1 -0
  146. package/dist/page-restore.js +23 -0
  147. package/dist/page-restore.js.map +1 -0
  148. package/dist/placement.d.ts +13 -0
  149. package/dist/placement.d.ts.map +1 -0
  150. package/dist/placement.js +34 -0
  151. package/dist/placement.js.map +1 -0
  152. package/dist/presence.d.ts +1 -0
  153. package/dist/presence.d.ts.map +1 -1
  154. package/dist/presence.js +16 -4
  155. package/dist/presence.js.map +1 -1
  156. package/dist/read-only-document.d.ts +87 -0
  157. package/dist/read-only-document.d.ts.map +1 -0
  158. package/dist/read-only-document.js +4 -0
  159. package/dist/read-only-document.js.map +1 -0
  160. package/dist/record-paging.d.ts +63 -6
  161. package/dist/record-paging.d.ts.map +1 -1
  162. package/dist/record-paging.js +122 -29
  163. package/dist/record-paging.js.map +1 -1
  164. package/dist/references.d.ts +40 -0
  165. package/dist/references.d.ts.map +1 -0
  166. package/dist/references.js +135 -0
  167. package/dist/references.js.map +1 -0
  168. package/dist/request-lane.d.ts +5 -0
  169. package/dist/request-lane.d.ts.map +1 -0
  170. package/dist/request-lane.js +48 -0
  171. package/dist/request-lane.js.map +1 -0
  172. package/dist/sealed-arrow.d.ts +170 -0
  173. package/dist/sealed-arrow.d.ts.map +1 -0
  174. package/dist/sealed-arrow.js +769 -0
  175. package/dist/sealed-arrow.js.map +1 -0
  176. package/dist/sealed-cache.d.ts +21 -0
  177. package/dist/sealed-cache.d.ts.map +1 -0
  178. package/dist/sealed-cache.js +247 -0
  179. package/dist/sealed-cache.js.map +1 -0
  180. package/dist/sealed-encode.d.ts +87 -0
  181. package/dist/sealed-encode.d.ts.map +1 -0
  182. package/dist/sealed-encode.js +876 -0
  183. package/dist/sealed-encode.js.map +1 -0
  184. package/dist/sealed-handles.d.ts +45 -0
  185. package/dist/sealed-handles.d.ts.map +1 -0
  186. package/dist/sealed-handles.js +408 -0
  187. package/dist/sealed-handles.js.map +1 -0
  188. package/dist/sealed-store.d.ts +242 -0
  189. package/dist/sealed-store.d.ts.map +1 -0
  190. package/dist/sealed-store.js +1155 -0
  191. package/dist/sealed-store.js.map +1 -0
  192. package/dist/serializer.d.ts +6 -15
  193. package/dist/serializer.d.ts.map +1 -1
  194. package/dist/session-toolbar.d.ts +20 -5
  195. package/dist/session-toolbar.d.ts.map +1 -1
  196. package/dist/session-toolbar.js +419 -67
  197. package/dist/session-toolbar.js.map +1 -1
  198. package/dist/session.d.ts +38 -1
  199. package/dist/session.d.ts.map +1 -1
  200. package/dist/session.js +562 -79
  201. package/dist/session.js.map +1 -1
  202. package/dist/sha256.d.ts +3 -0
  203. package/dist/sha256.d.ts.map +1 -0
  204. package/dist/sha256.js +74 -0
  205. package/dist/sha256.js.map +1 -0
  206. package/dist/stable-handles.d.ts +19 -0
  207. package/dist/stable-handles.d.ts.map +1 -0
  208. package/dist/stable-handles.js +193 -0
  209. package/dist/stable-handles.js.map +1 -0
  210. package/dist/staged-seal.d.ts +2 -0
  211. package/dist/staged-seal.d.ts.map +1 -0
  212. package/dist/staged-seal.js +76 -0
  213. package/dist/staged-seal.js.map +1 -0
  214. package/dist/subscriber-delivery.d.ts +8 -0
  215. package/dist/subscriber-delivery.d.ts.map +1 -0
  216. package/dist/subscriber-delivery.js +31 -0
  217. package/dist/subscriber-delivery.js.map +1 -0
  218. package/dist/tail-op-import.d.ts +10 -0
  219. package/dist/tail-op-import.d.ts.map +1 -0
  220. package/dist/tail-op-import.js +33 -0
  221. package/dist/tail-op-import.js.map +1 -0
  222. package/dist/topic-lifecycle.d.ts +6 -0
  223. package/dist/topic-lifecycle.d.ts.map +1 -1
  224. package/dist/topic-lifecycle.js +32 -2
  225. package/dist/topic-lifecycle.js.map +1 -1
  226. package/dist/trial-replace-dialog.d.ts +10 -0
  227. package/dist/trial-replace-dialog.d.ts.map +1 -0
  228. package/dist/trial-replace-dialog.js +153 -0
  229. package/dist/trial-replace-dialog.js.map +1 -0
  230. package/dist/types.d.ts +8 -1
  231. package/dist/types.d.ts.map +1 -1
  232. package/dist/undo.d.ts +60 -5
  233. package/dist/undo.d.ts.map +1 -1
  234. package/dist/undo.js +344 -75
  235. package/dist/undo.js.map +1 -1
  236. package/dist/version-bar.d.ts +1 -0
  237. package/dist/version-bar.d.ts.map +1 -1
  238. package/dist/version-bar.js +2 -1
  239. package/dist/version-bar.js.map +1 -1
  240. package/dist/version.d.ts +2 -2
  241. package/dist/version.js +1 -1
  242. package/dist/ws-client.d.ts +21 -0
  243. package/dist/ws-client.d.ts.map +1 -1
  244. package/dist/ws-client.js +192 -8
  245. package/dist/ws-client.js.map +1 -1
  246. package/dist/ws-frame-limit.d.ts +1 -0
  247. package/dist/ws-frame-limit.d.ts.map +1 -1
  248. package/dist/ws-frame-limit.js.map +1 -1
  249. package/docs/app-manifest.md +121 -17
  250. package/docs/custom-chrome.md +24 -16
  251. package/docs/hosted-ui-advanced.md +38 -6
  252. package/docs/imported-documents.md +104 -51
  253. package/docs/presence.md +16 -4
  254. package/docs/schemas.md +8 -5
  255. package/docs/services.md +6 -6
  256. package/docs/streams.md +139 -150
  257. package/docs/text-fields.md +26 -19
  258. package/package.json +5 -2
  259. package/src/api-fetch.ts +53 -3
  260. package/src/auth-flow.ts +1 -1
  261. package/src/batched-get.ts +117 -0
  262. package/src/call-service.ts +78 -3
  263. package/src/client.ts +1022 -112
  264. package/src/document-checkpoints.ts +307 -65
  265. package/src/document-commands.ts +5 -5
  266. package/src/document-contents.ts +27 -1
  267. package/src/document-set-subscription.ts +274 -0
  268. package/src/document-signals.ts +47 -5
  269. package/src/document-streams.ts +85 -16
  270. package/src/document-thumbnail-capture.ts +118 -32
  271. package/src/documents-namespace.ts +539 -84
  272. package/src/documents.ts +2017 -348
  273. package/src/errors.ts +17 -6
  274. package/src/fetch-retry.ts +5 -0
  275. package/src/file-choice.ts +26 -0
  276. package/src/guest-link.ts +362 -1
  277. package/src/handle-types.ts +73 -47
  278. package/src/handles.ts +253 -83
  279. package/src/hosted-ui.ts +211 -50
  280. package/src/import-contract.ts +227 -0
  281. package/src/import-guest.ts +193 -0
  282. package/src/import-report.ts +24 -0
  283. package/src/importer-wait.ts +4 -0
  284. package/src/importer.ts +386 -131
  285. package/src/in-development.ts +10 -0
  286. package/src/index.ts +76 -16
  287. package/src/init-keys.ts +6 -1
  288. package/src/input-documents.ts +819 -0
  289. package/src/inputs-status.ts +598 -0
  290. package/src/inputs.ts +1090 -0
  291. package/src/internal.ts +7 -0
  292. package/src/join-content.ts +135 -0
  293. package/src/join-stamp.ts +12 -0
  294. package/src/manifest-drift.ts +54 -0
  295. package/src/open-document.ts +2346 -930
  296. package/src/page-restore.ts +38 -0
  297. package/src/placement.ts +54 -0
  298. package/src/presence.ts +21 -5
  299. package/src/read-only-document.ts +133 -0
  300. package/src/record-paging.ts +147 -25
  301. package/src/references.ts +207 -0
  302. package/src/request-lane.ts +59 -0
  303. package/src/sealed-arrow.ts +843 -0
  304. package/src/sealed-cache.ts +267 -0
  305. package/src/sealed-encode.ts +1015 -0
  306. package/src/sealed-handles.ts +486 -0
  307. package/src/sealed-store.ts +1330 -0
  308. package/src/serializer.ts +8 -20
  309. package/src/session-toolbar.ts +463 -72
  310. package/src/session.ts +740 -99
  311. package/src/sha256.ts +65 -0
  312. package/src/stable-handles.ts +235 -0
  313. package/src/staged-seal.ts +90 -0
  314. package/src/subscriber-delivery.ts +37 -0
  315. package/src/tail-op-import.ts +30 -0
  316. package/src/topic-lifecycle.ts +38 -2
  317. package/src/trial-replace-dialog.ts +163 -0
  318. package/src/types.ts +6 -6
  319. package/src/undo.ts +386 -73
  320. package/src/version-bar.ts +5 -1
  321. package/src/version.ts +1 -1
  322. package/src/ws-client.ts +208 -16
  323. 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
+ }