@brass-build/client 0.4.0 → 0.5.0

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