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