@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,819 @@
1
+ // `subscribeInputDocuments`: calls an app's handlers with the input documents
2
+ // of a key, each as a handle the app may open in any mode. `loaded` gets every
3
+ // input wherever the document is open; `changed` gets the inputs changed since
4
+ // the document last read them, where it can be edited, and records each as
5
+ // read once its handler resolves; `removed` gets each input that left.
6
+
7
+ import type { InputReference } from './hosted-ui.js';
8
+ import { inputStampFromUpdatedAt } from './inputs.js';
9
+ import { inDevelopment } from './in-development.js';
10
+ import { importerWaitRejections } from './importer-wait.js';
11
+ import type { InputStatus, InputStatusChange } from './inputs-status.js';
12
+ import type { OpenDocumentByIdOptions, OpenedDocument } from './open-document.js';
13
+ import type { OpenModeOptions, ReadOnlyDocument } from './read-only-document.js';
14
+ import type { MarkReadHandle, MarkReadOptions, MarkReadStatus } from './references.js';
15
+
16
+ /** `input.open`, typed by what the open asks for, as `Brass.openDocumentById` is. */
17
+ export interface InputDocumentOpen {
18
+ (options: OpenDocumentByIdOptions & { frozen: true; readOnly?: true }): Promise<ReadOnlyDocument>;
19
+ (options: OpenDocumentByIdOptions & { readOnly: true }): Promise<ReadOnlyDocument>;
20
+ (options?: OpenDocumentByIdOptions & { frozen?: false }): Promise<OpenedDocument>;
21
+ (
22
+ options: Omit<OpenDocumentByIdOptions, 'frozen' | 'readOnly'> & OpenModeOptions,
23
+ ): Promise<OpenedDocument | ReadOnlyDocument>;
24
+ }
25
+
26
+ /** An input document for the app to work on. */
27
+ export interface InputDocument {
28
+ readonly doc_id: string;
29
+ readonly name: string;
30
+ /** The input's family, absent on a document that has none. */
31
+ readonly family?: string;
32
+ /** The version of the input this call is for. */
33
+ readonly updated_at: string;
34
+ /** The key's references that include this input, nearest first. */
35
+ readonly from: InputReference[];
36
+ /** Opens the input, or returns the document already opened with the same
37
+ * options (a frozen one loaded at this version). Opening with other options
38
+ * closes that one. What `open` returns closes when the handler resolves,
39
+ * unless the app passed `keepDocumentsOpen`. */
40
+ readonly open: InputDocumentOpen;
41
+ /** Closes what `open` returned for this input. */
42
+ close(): void;
43
+ }
44
+
45
+ /** What `subscribeInputDocuments` calls. */
46
+ export interface InputDocumentHandlers {
47
+ /** Every input each time the app subscribes, then each new version,
48
+ * wherever the document is open. Records nothing. */
49
+ loaded?(input: InputDocument): void | Promise<void>;
50
+ /** Each input added or edited since this document last read it, where the
51
+ * document can be edited. Brass records the input as read when the
52
+ * handler resolves. */
53
+ changed?(input: InputDocument): void | Promise<void>;
54
+ /** Each input no longer among the key's inputs, or deleted: one `loaded`
55
+ * was given, wherever the document is open, and one this document read
56
+ * before, where it can be edited. */
57
+ removed?(docId: string): void | Promise<void>;
58
+ /** The key's status, whenever it changes or its `settled` flips. */
59
+ status?(status: InputStatus): void;
60
+ /** Once every known input's handler has finished for its current
61
+ * version, resolved or rejected, and the inputs have stopped changing:
62
+ * after the first load, and after each later batch
63
+ * of additions, edits or removals. */
64
+ settled?(status: InputStatus): void;
65
+ }
66
+
67
+ /** How `subscribeInputDocuments` runs the handlers. */
68
+ export interface InputDocumentOptions {
69
+ /** Keep each document `open` returns until its input is removed,
70
+ * `input.close()` is called, or the app unsubscribes. The next `open` of
71
+ * the input returns the same document. */
72
+ readonly keepDocumentsOpen?: boolean;
73
+ /** The most handlers that run at once. Brass paces them by default. */
74
+ readonly concurrency?: number;
75
+ }
76
+
77
+ /** @internal What the follower needs from its document. */
78
+ export interface InputDocumentsHost {
79
+ subscribeStatus(key: string, cb: (status: InputStatus) => void): () => void;
80
+ canWrite(): boolean;
81
+ markRead(status: MarkReadStatus, options: MarkReadOptions): void;
82
+ open: ((docId: string, options?: OpenDocumentByIdOptions) => Promise<OpenedDocument>) | null;
83
+ }
84
+
85
+ /** A failed file waits this long before its first retry at the same
86
+ * version, doubling up to the ceiling. A new version is tried at once. A file
87
+ * refused `not_imported` is tried again when its import lands instead. */
88
+ const RETRY_FIRST_MS = 2_000;
89
+ const RETRY_CEILING_MS = 5 * 60_000;
90
+
91
+ /** `settled` waits for this long with no call starting or finishing and no
92
+ * change to the set, so a burst of arrivals reaches it once. */
93
+ const QUIET_MS = 2_000;
94
+
95
+ /** The handlers that run at once when the app sets no `concurrency`. Each
96
+ * handler usually opens its input, and a few hundred opens started together
97
+ * are refused as throttled, for this app and every other caller. */
98
+ const DEFAULT_CONCURRENCY = 8;
99
+
100
+ /** A call pending this long is reported in development builds: the input
101
+ * gets no further call until it settles, and it keeps its concurrency slot. */
102
+ const LONG_PENDING_MS = 60_000;
103
+
104
+ type Copy = MarkReadHandle & { close(): void; reload?(): Promise<void> };
105
+
106
+ /** A copy `file.open` returned: the copy itself, and the handle the app got,
107
+ * which warns when it is used after the copy closed. */
108
+ interface Opened {
109
+ options: string;
110
+ copy: Copy;
111
+ handle: Copy;
112
+ closedBy: string | null;
113
+ }
114
+
115
+ type Kind = 'loaded' | 'changed' | 'removed';
116
+
117
+ /** On the input a shared follower hands out: `open` on behalf of one
118
+ * subscription, so a copy another subscription is using in the same call is
119
+ * not replaced. */
120
+ const OPEN_FOR = Symbol('openFor');
121
+ type OpenFor = (owner: object) => InputDocumentOpen;
122
+
123
+ interface Work {
124
+ docId: string;
125
+ kind: Kind;
126
+ /** What `done` is compared against, so work at a version already handled is
127
+ * not given to a handler again. */
128
+ version: string;
129
+ from: InputReference[];
130
+ /** The status entry this work reads, for `changed` and for a removal this
131
+ * document records. */
132
+ entry: InputStatusChange | null;
133
+ }
134
+
135
+ interface Running {
136
+ work: Work;
137
+ abort: AbortController;
138
+ pendingTimer: ReturnType<typeof setTimeout> | null;
139
+ }
140
+
141
+ /** Options that open the same copy compare equal. */
142
+ const optionsKey = (options: OpenDocumentByIdOptions | undefined): string =>
143
+ JSON.stringify(Object.entries(options ?? {}).filter(([, v]) => v !== undefined).sort(([a], [b]) => (a < b ? -1 : 1)));
144
+
145
+ const failKeyOf = (w: Work): string => `${w.kind}:${w.docId}@${w.version}`;
146
+
147
+ const newer = (version: string, than: string | null): boolean => {
148
+ if (than === null) return true;
149
+ const a = inputStampFromUpdatedAt(version);
150
+ const b = inputStampFromUpdatedAt(than);
151
+ return a !== null && b !== null ? a > b : version !== than;
152
+ };
153
+
154
+ /** Whether a copy was loaded at a later version than the call it was opened for. */
155
+ const openedAhead = (copyAt: string | null, callAt: string): boolean => {
156
+ if (copyAt === null) return false;
157
+ const a = inputStampFromUpdatedAt(copyAt);
158
+ const b = inputStampFromUpdatedAt(callAt);
159
+ return a !== null && b !== null && a > b;
160
+ };
161
+
162
+ /** @internal */
163
+ export function followInputDocuments(
164
+ host: InputDocumentsHost,
165
+ key: string,
166
+ handlers: InputDocumentHandlers,
167
+ options: InputDocumentOptions = {},
168
+ ): () => void {
169
+ checkOptions(options);
170
+ return follow(host, key, handlers, () => options).stop;
171
+ }
172
+
173
+ const checkOptions = (options: InputDocumentOptions): void => {
174
+ if (options.concurrency !== undefined && !(options.concurrency >= 1)) {
175
+ throw new TypeError('subscribeInputDocuments: concurrency must be at least 1');
176
+ }
177
+ };
178
+
179
+ interface Follower {
180
+ stop(): void;
181
+ /** Runs again the inputs whose open stopped waiting for `registerImporter`. */
182
+ importerRegistered(): void;
183
+ /** Gives `joined` the key's status, and `loaded` every input again where a
184
+ * handler set has not had it at its current version. */
185
+ join(joined: InputDocumentHandlers): void;
186
+ }
187
+
188
+ /** Handlers read at each call, so a property may come and go. */
189
+ type LiveHandlers = { [K in keyof InputDocumentHandlers]?: InputDocumentHandlers[K] | undefined };
190
+
191
+ function follow(
192
+ host: InputDocumentsHost,
193
+ key: string,
194
+ handlers: LiveHandlers,
195
+ options: () => InputDocumentOptions,
196
+ ): Follower {
197
+ const has = (name: keyof InputDocumentHandlers): boolean => typeof handlers[name] === 'function';
198
+ // With none of the file handlers, the list alone decides `settled`.
199
+ const followsChanges = (): boolean => has('changed') || has('removed');
200
+ let stopped = false;
201
+ let latest: InputStatus | null = null;
202
+ // One call per file at a time; the file's next work is taken up once it
203
+ // settles.
204
+ const running = new Map<string, Running>();
205
+ // `<kind>:<doc_id>@<version>` -> the failed attempts at that version and
206
+ // when the next may run, or that it waits for the file's import. A call
207
+ // that failed has finished, so it does not hold `settled`.
208
+ const failed = new Map<
209
+ string,
210
+ { attempts: number; retryAt: number; awaitsImport?: true; awaitsImporter?: true }
211
+ >();
212
+ let retryTimer: ReturnType<typeof setTimeout> | null = null;
213
+ let quietTimer: ReturnType<typeof setTimeout> | null = null;
214
+ // `<kind>:<doc_id>` -> the versions it was last handled at. For `changed`
215
+ // this stands in for the read record until the status catches up with it.
216
+ const done = new Map<string, string[]>();
217
+ // Files `loaded` was given this session, so their removal reaches `removed`.
218
+ const seen = new Set<string>();
219
+ // The copies open per file.
220
+ const copies = new Map<string, Opened>();
221
+ // Moves when a handler set joins, so a `loaded` call that started before
222
+ // it does not count as given to the newcomer.
223
+ let joins = 0;
224
+
225
+ const isDone = (w: Work): boolean => {
226
+ const versions = done.get(`${w.kind}:${w.docId}`);
227
+ if (versions === undefined) return false;
228
+ if (versions.includes(w.version)) return true;
229
+ const stamp = inputStampFromUpdatedAt(w.version);
230
+ return (
231
+ stamp !== null &&
232
+ versions.some((v) => {
233
+ const at = inputStampFromUpdatedAt(v);
234
+ return at !== null && stamp <= at;
235
+ })
236
+ );
237
+ };
238
+
239
+ const closeCopy = (docId: string, by: string): void => {
240
+ const opened = copies.get(docId);
241
+ if (opened === undefined) return;
242
+ copies.delete(docId);
243
+ opened.closedBy = by;
244
+ try {
245
+ opened.copy.close();
246
+ } catch {
247
+ // Already closed by the app.
248
+ }
249
+ };
250
+
251
+ // A handle that warns, once, when it is used after its copy closed.
252
+ const guard = (docId: string, copy: Copy, opened: () => Opened): Copy => {
253
+ let warned = false;
254
+ const bound = new Map<PropertyKey, unknown>();
255
+ return new Proxy(copy, {
256
+ get(target, prop) {
257
+ const by = opened().closedBy;
258
+ if (by !== null && !warned && typeof prop === 'string' && prop !== 'then' && prop !== 'close') {
259
+ warned = true;
260
+ console.warn(`[brass] input ${docId}: the document open returned was closed ${by}, so it no longer follows the input.`);
261
+ }
262
+ if (prop === 'close') {
263
+ return () => {
264
+ if (copies.get(docId) === opened()) closeCopy(docId, 'by close()');
265
+ else target.close();
266
+ };
267
+ }
268
+ const value = Reflect.get(target, prop, target) as unknown;
269
+ if (typeof value !== 'function') return value;
270
+ let fn = bound.get(prop);
271
+ if (fn === undefined) {
272
+ fn = (value as (...args: unknown[]) => unknown).bind(target);
273
+ bound.set(prop, fn);
274
+ }
275
+ return fn;
276
+ },
277
+ });
278
+ };
279
+
280
+ // The work a status asks for, before subtracting what is done or running.
281
+ const workOf = (status: InputStatus): Work[] => {
282
+ const out: Work[] = [];
283
+ const listed = new Set<string>();
284
+ if (has('loaded')) {
285
+ for (const f of status.inputs) {
286
+ listed.add(f.doc_id);
287
+ out.push({ docId: f.doc_id, kind: 'loaded', version: f.updated_at, from: f.from, entry: null });
288
+ }
289
+ } else {
290
+ for (const f of status.inputs) listed.add(f.doc_id);
291
+ }
292
+ const removing = new Set<string>();
293
+ if (followsChanges()) {
294
+ const writer = host.canWrite();
295
+ for (const c of status.changed) {
296
+ if (c.change === 'removed') {
297
+ // A copy that cannot record the removal still reports a file
298
+ // `loaded` was given, below.
299
+ if (!writer && seen.has(c.doc_id)) continue;
300
+ removing.add(c.doc_id);
301
+ out.push({ docId: c.doc_id, kind: 'removed', version: `removed:${c.updated_at ?? ''}`, from: c.from, entry: c });
302
+ } else if (has('changed')) {
303
+ const version = status.inputs.find((f) => f.doc_id === c.doc_id)?.updated_at ?? '';
304
+ out.push({ docId: c.doc_id, kind: 'changed', version, from: c.from, entry: c });
305
+ }
306
+ }
307
+ }
308
+ for (const docId of seen) {
309
+ if (!listed.has(docId) && !removing.has(docId)) {
310
+ out.push({ docId, kind: 'removed', version: 'gone', from: [], entry: null });
311
+ }
312
+ }
313
+ return out;
314
+ };
315
+
316
+ // `loaded` and a removal of a file it was given run on any copy; work that
317
+ // records a read runs where the document can be written.
318
+ const canRun = (w: Work): boolean => !stopped && (w.entry === null || host.canWrite());
319
+
320
+ const scheduleRetry = (): void => {
321
+ if (stopped) return;
322
+ let next = Infinity;
323
+ for (const f of failed.values()) next = Math.min(next, f.retryAt);
324
+ if (next === Infinity) return;
325
+ if (retryTimer !== null) clearTimeout(retryTimer);
326
+ retryTimer = setTimeout(() => {
327
+ retryTimer = null;
328
+ if (latest !== null) start(latest);
329
+ }, Math.max(0, next - Date.now()));
330
+ };
331
+
332
+ // Whether the status lists the file as not imported yet.
333
+ const awaitsImport = (status: InputStatus, docId: string): boolean =>
334
+ status.inputs.find((f) => f.doc_id === docId)?.awaits_import === true;
335
+
336
+ const fail = (failKey: string, untilImported: boolean, untilImporter = false): void => {
337
+ const attempts = (failed.get(failKey)?.attempts ?? 0) + 1;
338
+ if (untilImporter) {
339
+ failed.set(failKey, { attempts, retryAt: Infinity, awaitsImporter: true });
340
+ return;
341
+ }
342
+ if (untilImported) {
343
+ failed.set(failKey, { attempts, retryAt: Infinity, awaitsImport: true });
344
+ return;
345
+ }
346
+ const wait = Math.min(RETRY_FIRST_MS * 2 ** (attempts - 1), RETRY_CEILING_MS);
347
+ failed.set(failKey, { attempts, retryAt: Date.now() + wait });
348
+ scheduleRetry();
349
+ };
350
+
351
+ const forget = (docId: string): void => {
352
+ seen.delete(docId);
353
+ done.delete(`loaded:${docId}`);
354
+ done.delete(`changed:${docId}`);
355
+ closeCopy(docId, 'because the input was removed');
356
+ };
357
+
358
+ // `retry` is a further call at a version whose call already finished: it
359
+ // changes what `settled` reports only if it resolves.
360
+ const run = (status: InputStatus, work: Work, failKey: string, retry: boolean): void => {
361
+ const { docId, kind } = work;
362
+ const abort = new AbortController();
363
+ const joinsAtStart = joins;
364
+ // Reported whether or not a newer version abandoned the call, since an
365
+ // abandoned call still holds the input and its slot.
366
+ const pendingTimer = inDevelopment()
367
+ ? setTimeout(() => {
368
+ console.warn(
369
+ `[brass] subscribeInputDocuments("${key}"): the ${kind} handler for input ${docId} has not settled after ${LONG_PENDING_MS / 1000}s. The input gets no other call until it resolves or rejects.`,
370
+ );
371
+ }, LONG_PENDING_MS)
372
+ : null;
373
+ running.set(docId, { work, abort, pendingTimer });
374
+ if (!retry) touch();
375
+ let resolved = false;
376
+ // The copies this call opened, which it records the read at, and which
377
+ // subscriptions each was handed to.
378
+ const openedHere: Opened[] = [];
379
+ const users = new Map<Opened, Set<object>>();
380
+ // Copies opened for this call alone, beside one another subscription uses.
381
+ const ownCopies: Opened[] = [];
382
+ const use = (opened: Opened, owner: object): Opened => {
383
+ if (!openedHere.includes(opened)) openedHere.push(opened);
384
+ let by = users.get(opened);
385
+ if (by === undefined) users.set(opened, (by = new Set()));
386
+ by.add(owner);
387
+ return opened;
388
+ };
389
+ let opening: { options: string; opened: Promise<Opened> } | null = null;
390
+ let settled = false;
391
+ let result: unknown;
392
+ let callAt = work.version;
393
+ try {
394
+ if (kind === 'removed') {
395
+ result = handlers.removed?.(docId);
396
+ } else {
397
+ const handler = kind === 'loaded' ? handlers.loaded : handlers.changed;
398
+ const file = status.inputs.find((f) => f.doc_id === docId);
399
+ const updatedAt = file?.updated_at ?? work.version;
400
+ callAt = updatedAt;
401
+ if (kind === 'loaded') seen.add(docId);
402
+ const refuseLate = (): void => {
403
+ if (stopped) throw new Error('open: the subscription ended');
404
+ if (settled) throw new Error(`open: call it before the ${kind} handler resolves`);
405
+ };
406
+ const openFor: OpenFor = (owner) => (async (openOptions?: OpenDocumentByIdOptions) => {
407
+ const openById = host.open;
408
+ if (openById === null) throw new Error('open: this document cannot open its inputs');
409
+ const wanted = optionsKey(openOptions);
410
+ // A concurrent open with the same options shares the one in flight;
411
+ // one with other options waits for it, then replaces it.
412
+ while (opening !== null) {
413
+ const pending = opening;
414
+ if (pending.options === wanted) return use(await pending.opened, owner).handle;
415
+ await pending.opened.catch(() => undefined);
416
+ }
417
+ const existing = [copies.get(docId), ...ownCopies].find((o) => o !== undefined && o.options === wanted);
418
+ if (existing !== undefined) {
419
+ if (newer(updatedAt, existing.copy.updatedAt)) await existing.copy.reload?.();
420
+ return use(existing, owner).handle;
421
+ }
422
+ refuseLate();
423
+ const entry = {
424
+ options: wanted,
425
+ opened: (async (): Promise<Opened> => {
426
+ const copy = (await openById(docId, openOptions)) as unknown as Copy;
427
+ if (settled || stopped) {
428
+ copy.close();
429
+ refuseLate();
430
+ }
431
+ const opened: Opened = { options: wanted, copy, handle: copy, closedBy: null };
432
+ opened.handle = guard(docId, copy, () => opened);
433
+ const current = copies.get(docId);
434
+ const sharedWith = current === undefined ? [] : Array.from(users.get(current) ?? []);
435
+ if (sharedWith.some((u) => u !== owner)) {
436
+ ownCopies.push(opened);
437
+ } else {
438
+ // A copy opened with other options replaces the one open before.
439
+ closeCopy(docId, 'when the input was opened with other options');
440
+ copies.set(docId, opened);
441
+ }
442
+ return use(opened, owner);
443
+ })(),
444
+ };
445
+ opening = entry;
446
+ try {
447
+ return (await entry.opened).handle;
448
+ } finally {
449
+ if (opening === entry) opening = null;
450
+ }
451
+ }) as InputDocumentOpen;
452
+ // The owner a direct handler's opens are made for.
453
+ const sole = {};
454
+ const input: InputDocument & { [OPEN_FOR]: OpenFor } = {
455
+ doc_id: docId,
456
+ name: file?.name ?? '',
457
+ ...(file?.family !== undefined ? { family: file.family } : {}),
458
+ updated_at: updatedAt,
459
+ from: work.from,
460
+ open: openFor(sole),
461
+ close: () => closeCopy(docId, 'by input.close()'),
462
+ [OPEN_FOR]: openFor,
463
+ };
464
+ result = handler?.(input);
465
+ }
466
+ } catch (err) {
467
+ result = Promise.reject(err);
468
+ }
469
+ void Promise.resolve(result)
470
+ .then(
471
+ () => {
472
+ if (stopped || abort.signal.aborted) return;
473
+ if (work.entry === null) {
474
+ if (kind === 'removed') forget(docId);
475
+ else if (joins === joinsAtStart) done.set(`loaded:${docId}`, [work.version]);
476
+ failed.delete(failKey);
477
+ resolved = true;
478
+ return;
479
+ }
480
+ if (!host.canWrite()) return;
481
+ try {
482
+ if (kind === 'removed') {
483
+ host.markRead({ key, changed: [work.entry], inputs: [] }, { files: [], undoable: false });
484
+ forget(docId);
485
+ done.set(`removed:${docId}`, [work.version]);
486
+ } else {
487
+ const read = openedHere.map((o) => o.copy);
488
+ // Recorded at the call's version when a copy is ahead of it, so
489
+ // the newer version gets a call of its own.
490
+ const ahead = read.some((c) => openedAhead(c.updatedAt, callAt));
491
+ const files = ahead || read.length === 0 ? [{ doc_id: docId }] : read;
492
+ host.markRead(status, { files, undoable: false });
493
+ const versions = ahead ? [] : read.map((c) => c.updatedAt).filter((v): v is string => v !== null);
494
+ done.set(`changed:${docId}`, [work.version, ...versions]);
495
+ }
496
+ failed.delete(failKey);
497
+ resolved = true;
498
+ } catch {
499
+ // The document closed or stopped being writable while the handler
500
+ // ran, or refused the record; the file stays changed and waits
501
+ // like a rejected one, so a refusal cannot spin.
502
+ fail(failKey, false);
503
+ }
504
+ },
505
+ (err: unknown) => {
506
+ // Work abandoned on purpose is not a failure: its replacement, if
507
+ // any, is already listed.
508
+ if (stopped || abort.signal.aborted) return;
509
+ // A file only another app imports is called again when the status
510
+ // reports its import landed, rather than on a timer.
511
+ const notImported = (err as { code?: unknown } | null)?.code === 'not_imported';
512
+ // This app imports the file and has not registered its importer:
513
+ // the import waits for the registration rather than for the status.
514
+ const untilImporter = typeof err === 'object' && err !== null && importerWaitRejections.has(err);
515
+ fail(
516
+ failKey,
517
+ notImported && kind !== 'removed' && awaitsImport(latest ?? status, docId),
518
+ untilImporter && kind !== 'removed',
519
+ );
520
+ if (!notImported) {
521
+ console.error(`[brass] input ${kind} handler for ${docId} failed`, err);
522
+ }
523
+ },
524
+ )
525
+ .finally(() => {
526
+ settled = true;
527
+ if (pendingTimer !== null) clearTimeout(pendingTimer);
528
+ for (const o of ownCopies) {
529
+ o.closedBy = `when the ${kind} handler resolved`;
530
+ try {
531
+ o.copy.close();
532
+ } catch {
533
+ // Already closed by the app.
534
+ }
535
+ }
536
+ if (options().keepDocumentsOpen !== true) {
537
+ for (const o of openedHere) {
538
+ if (copies.get(docId) === o) closeCopy(docId, `when the ${kind} handler resolved (pass keepDocumentsOpen to keep it)`);
539
+ }
540
+ }
541
+ running.delete(docId);
542
+ if (!stopped && !abort.signal.aborted && (!retry || resolved)) touch();
543
+ if (latest !== null) start(latest);
544
+ });
545
+ };
546
+
547
+ // Abandons running work the status no longer asks for: the file left, or a
548
+ // newer version of it is listed. Its result is not recorded.
549
+ const abandon = (status: InputStatus): void => {
550
+ if (running.size === 0) return;
551
+ const versions = new Map(status.inputs.map((f) => [f.doc_id, f.updated_at]));
552
+ for (const { work, abort } of running.values()) {
553
+ if (work.kind === 'removed') continue;
554
+ const now = versions.get(work.docId);
555
+ if (now === undefined || now !== work.version) abort.abort();
556
+ }
557
+ };
558
+
559
+ const start = (status: InputStatus): void => {
560
+ const now = Date.now();
561
+ for (const work of workOf(status)) {
562
+ if (running.size >= (options().concurrency ?? DEFAULT_CONCURRENCY)) return;
563
+ if (!canRun(work) || running.has(work.docId) || isDone(work)) continue;
564
+ const failKey = failKeyOf(work);
565
+ const f = failed.get(failKey);
566
+ if (f !== undefined) {
567
+ // An input refused for want of this page's importer runs again when
568
+ // the importer registers or when another client's import lands.
569
+ const waiting =
570
+ f.awaitsImporter === true || f.awaitsImport === true
571
+ ? awaitsImport(status, work.docId)
572
+ : now < f.retryAt;
573
+ if (waiting) continue;
574
+ }
575
+ run(status, work, failKey, f !== undefined);
576
+ }
577
+ };
578
+
579
+ // Settled once the file list is complete, every call it asks for has
580
+ // finished (resolved or rejected), and nothing has started, finished or
581
+ // changed for `QUIET_MS`. Work not yet called, or called and still running
582
+ // for the first time at its version, holds it; so does a change no writer
583
+ // has read yet.
584
+ let reportedSettled = false;
585
+ let delivering = false;
586
+ let lastShaped: InputStatus | null = null;
587
+ const outstanding = (status: InputStatus): boolean =>
588
+ workOf(status).some((w) => !isDone(w) && !failed.has(failKeyOf(w)));
589
+ const emit = (status: InputStatus): InputStatus => {
590
+ const reported = { ...status, settled: reportedSettled };
591
+ handlers.status?.(reported);
592
+ return reported;
593
+ };
594
+ const quiet = (): void => {
595
+ quietTimer = null;
596
+ if (stopped || latest === null || !latest.settled || outstanding(latest)) return;
597
+ reportedSettled = true;
598
+ const reported = emit(latest);
599
+ handlers.settled?.(reported);
600
+ };
601
+ // Something started, finished or changed: `settled` reads false until the
602
+ // set has been quiet again.
603
+ function touch(): void {
604
+ if (stopped) return;
605
+ if (quietTimer !== null) clearTimeout(quietTimer);
606
+ quietTimer = setTimeout(quiet, QUIET_MS);
607
+ if (!reportedSettled) return;
608
+ reportedSettled = false;
609
+ if (!delivering && latest !== null) emit(latest);
610
+ }
611
+ // What a status asks of the follower.
612
+ const shapeOf = (status: InputStatus): string =>
613
+ JSON.stringify([
614
+ status.settled,
615
+ status.inputs.map((f) => [f.doc_id, f.updated_at, f.awaits_import === true]),
616
+ workOf(status).filter((w) => !isDone(w)).map(failKeyOf),
617
+ ]);
618
+
619
+ const stopStatus = host.subscribeStatus(key, (status) => {
620
+ latest = status;
621
+ const first = lastShaped === null;
622
+ delivering = true;
623
+ try {
624
+ abandon(status);
625
+ start(status);
626
+ // Both read against what is done now, so a change this document has
627
+ // just read compares equal to the status that drops it.
628
+ const changed = !first && shapeOf(lastShaped!) !== shapeOf(status);
629
+ lastShaped = status;
630
+ if (changed) touch();
631
+ } finally {
632
+ delivering = false;
633
+ }
634
+ if (stopped) return;
635
+ // A first status that starts no call has nothing to wait out.
636
+ const settleNow = first && quietTimer === null && status.settled && !outstanding(status);
637
+ if (settleNow) reportedSettled = true;
638
+ const reported = emit(status);
639
+ if (settleNow) handlers.settled?.(reported);
640
+ });
641
+
642
+ const stop = (): void => {
643
+ if (stopped) return;
644
+ stopped = true;
645
+ if (retryTimer !== null) clearTimeout(retryTimer);
646
+ if (quietTimer !== null) clearTimeout(quietTimer);
647
+ for (const { abort, pendingTimer } of running.values()) {
648
+ abort.abort();
649
+ if (pendingTimer !== null) clearTimeout(pendingTimer);
650
+ }
651
+ for (const docId of copies.keys()) closeCopy(docId, 'when the app unsubscribed');
652
+ stopStatus();
653
+ };
654
+
655
+ const join = (joined: InputDocumentHandlers): void => {
656
+ if (stopped || latest === null) return;
657
+ const reported = { ...latest, settled: reportedSettled };
658
+ joined.status?.(reported);
659
+ if (typeof joined.loaded === 'function') {
660
+ joins++;
661
+ for (const k of done.keys()) if (k.startsWith('loaded:')) done.delete(k);
662
+ }
663
+ const quietBefore = quietTimer;
664
+ start(latest);
665
+ // Nothing started for the newcomer, so the set is as settled as it was.
666
+ if (reportedSettled && quietTimer === quietBefore) joined.settled?.(reported);
667
+ };
668
+
669
+ const importerRegistered = (): void => {
670
+ if (stopped) return;
671
+ let woke = false;
672
+ for (const [failKey, f] of failed) {
673
+ if (f.awaitsImporter !== true) continue;
674
+ failed.set(failKey, { attempts: f.attempts, retryAt: 0 });
675
+ woke = true;
676
+ }
677
+ if (woke && latest !== null) start(latest);
678
+ };
679
+
680
+ return { stop, join, importerRegistered };
681
+ }
682
+
683
+ /** One subscription's handlers and options, and what its handlers resolved. */
684
+ interface HandlerSet {
685
+ readonly handlers: InputDocumentHandlers;
686
+ readonly options: InputDocumentOptions;
687
+ /** `<kind>:<doc_id>` -> the version this set's handler resolved at. A
688
+ * removal is kept only until every set has taken it. */
689
+ readonly given: Map<string, string>;
690
+ }
691
+
692
+ interface Shared {
693
+ readonly sets: Set<HandlerSet>;
694
+ readonly follower: Follower;
695
+ }
696
+
697
+ type FileHandler = 'loaded' | 'changed' | 'removed';
698
+
699
+ // The input as one subscription sees it: its `open` is that subscription's.
700
+ const inputFor = (input: InputDocument, set: HandlerSet): InputDocument => {
701
+ const openFor = (input as InputDocument & { [OPEN_FOR]?: OpenFor })[OPEN_FOR];
702
+ return openFor === undefined ? input : { ...input, open: openFor(set) };
703
+ };
704
+
705
+ // Calls `name` on every set that has not resolved it at `version`, and
706
+ // rejects with the first rejection once all settle, so a retry reaches only
707
+ // the sets that failed.
708
+ async function callEach(
709
+ sets: Set<HandlerSet>,
710
+ name: FileHandler,
711
+ docId: string,
712
+ version: string,
713
+ call: (set: HandlerSet) => void | Promise<void>,
714
+ ): Promise<void> {
715
+ const given = `${name}:${docId}`;
716
+ const due = Array.from(sets).filter((s) => typeof s.handlers[name] === 'function' && s.given.get(given) !== version);
717
+ const results = await Promise.allSettled(
718
+ due.map(async (set) => {
719
+ await call(set);
720
+ set.given.set(given, version);
721
+ if (name === 'removed') {
722
+ set.given.delete(`loaded:${docId}`);
723
+ set.given.delete(`changed:${docId}`);
724
+ } else {
725
+ set.given.delete(`removed:${docId}`);
726
+ }
727
+ }),
728
+ );
729
+ const failure = results.find((r): r is PromiseRejectedResult => r.status === 'rejected');
730
+ if (failure !== undefined) throw failure.reason;
731
+ if (name === 'removed') for (const set of due) set.given.delete(given);
732
+ }
733
+
734
+ /** @internal The `subscribeInputDocuments` followers of one document: one
735
+ * per key, whatever number of subscriptions share it. Each input's work runs
736
+ * once and every subscription's handlers are called for it; a `changed`
737
+ * input is recorded as read once all of them resolve. */
738
+ export class InputFollowers {
739
+ private readonly byKey = new Map<string, Shared>();
740
+
741
+ subscribe(
742
+ host: InputDocumentsHost,
743
+ key: string,
744
+ handlers: InputDocumentHandlers,
745
+ options: InputDocumentOptions = {},
746
+ ): () => void {
747
+ checkOptions(options);
748
+ const set: HandlerSet = { handlers, options, given: new Map() };
749
+ let shared = this.byKey.get(key);
750
+ if (shared === undefined) {
751
+ const sets = new Set<HandlerSet>([set]);
752
+ const has = (name: keyof InputDocumentHandlers): boolean =>
753
+ Array.from(sets).some((s) => typeof s.handlers[name] === 'function');
754
+ // Called on a snapshot of the sets, skipping one that left during an
755
+ // earlier set's handler.
756
+ const each = (fn: (s: HandlerSet) => void): void => {
757
+ for (const s of Array.from(sets)) if (sets.has(s)) fn(s);
758
+ };
759
+ // The tightest `concurrency` any subscription asks for, and documents
760
+ // kept open if any asks.
761
+ const merged = (): InputDocumentOptions => {
762
+ let concurrency: number | undefined;
763
+ let keepDocumentsOpen = false;
764
+ for (const s of sets) {
765
+ const c = s.options.concurrency;
766
+ if (c !== undefined) concurrency = concurrency === undefined ? c : Math.min(concurrency, c);
767
+ if (s.options.keepDocumentsOpen === true) keepDocumentsOpen = true;
768
+ }
769
+ return { ...(concurrency !== undefined ? { concurrency } : {}), keepDocumentsOpen };
770
+ };
771
+ const combined: LiveHandlers = {
772
+ get loaded() {
773
+ if (!has('loaded')) return undefined;
774
+ return (input: InputDocument) =>
775
+ callEach(sets, 'loaded', input.doc_id, input.updated_at, (s) => s.handlers.loaded!(inputFor(input, s)));
776
+ },
777
+ get changed() {
778
+ if (!has('changed')) return undefined;
779
+ return (input: InputDocument) =>
780
+ callEach(sets, 'changed', input.doc_id, input.updated_at, (s) => s.handlers.changed!(inputFor(input, s)));
781
+ },
782
+ get removed() {
783
+ if (!has('removed')) return undefined;
784
+ return (docId: string) => callEach(sets, 'removed', docId, '', (s) => s.handlers.removed!(docId));
785
+ },
786
+ status: (status) => each((s) => s.handlers.status?.(status)),
787
+ settled: (status) => each((s) => s.handlers.settled?.(status)),
788
+ };
789
+ shared = { sets, follower: follow(host, key, combined, merged) };
790
+ this.byKey.set(key, shared);
791
+ } else {
792
+ shared.sets.add(set);
793
+ shared.follower.join(handlers);
794
+ }
795
+ const owner = shared;
796
+ let left = false;
797
+ return () => {
798
+ if (left) return;
799
+ left = true;
800
+ owner.sets.delete(set);
801
+ if (owner.sets.size > 0) return;
802
+ if (this.byKey.get(key) === owner) this.byKey.delete(key);
803
+ owner.follower.stop();
804
+ };
805
+ }
806
+
807
+ /** Runs again, in every follower, the inputs whose open stopped waiting
808
+ * for `registerImporter`. */
809
+ importerRegistered(): void {
810
+ for (const shared of this.byKey.values()) shared.follower.importerRegistered();
811
+ }
812
+
813
+ /** Stops every follower, as the document closes or parks. */
814
+ stopAll(): void {
815
+ const all = [...this.byKey.values()];
816
+ this.byKey.clear();
817
+ for (const shared of all) shared.follower.stop();
818
+ }
819
+ }