@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
@@ -4,10 +4,30 @@
4
4
  // `Brass.createDocument`. Access-granting is hosted-only (the share
5
5
  // dialog), not a programmatic method here.
6
6
 
7
+ import { awaitSeal, type SealStatus } from './staged-seal.js';
8
+ import type { ImportReport } from './import-report.js';
9
+ import type { DocumentWorkStatus } from './document-signals.js';
10
+ import type { InputReference } from './hosted-ui.js';
7
11
  import type { Document } from './documents.js';
8
- import type { ImportClaimResponse } from './importer.js';
12
+ import type { CheckpointAnchor } from './document-checkpoints.js';
13
+ import type { ImportClaimResponse, ImportFailedResponse } from './importer.js';
9
14
  import { createApiFetch } from './api-fetch.js';
15
+ import {
16
+ DEFAULT_IMPORT_STREAM,
17
+ encodeImportStreams,
18
+ encodeStreamBundle,
19
+ type ImportContent,
20
+ } from './import-contract.js';
21
+ import type { PackedRecord } from './record-paging.js';
10
22
  import { BrassError } from './errors.js';
23
+ import { createRequestLane } from './request-lane.js';
24
+ import { createBatchedGet } from './batched-get.js';
25
+ import { parseProvisionalInputsAnswer, type ProvisionalInputsAnswer, type ProvisionalInputsPost } from './inputs.js';
26
+ import {
27
+ createDocumentList,
28
+ type DocumentList,
29
+ type DocumentSetSocket,
30
+ } from './document-set-subscription.js';
11
31
 
12
32
  export type DocumentRole = 'owner' | 'editor' | 'viewer';
13
33
 
@@ -49,37 +69,17 @@ export interface DocumentSummary {
49
69
  thumbnail?: DocumentThumbnail;
50
70
  }
51
71
 
52
- // One byte stream of the original file behind an opaque-blob document. A
53
- // source file is a set of one or more streams: the primary stream (what
54
- // the file's own name names) plus any sidecar streams uploaded with it (a
55
- // glTF's `.bin` buffer, a shapefile's `.dbf`). Each stream carries its own
56
- // short-lived download URL, so an app fetches exactly the streams it
57
- // needs, in parallel.
58
- export interface DocumentSourceStream {
59
- // The stream's name within the file set (the primary stream's is the
60
- // file's own name).
61
- name: string;
62
- url: string;
63
- content_type: string;
64
- byte_size: number;
65
- }
66
-
67
72
  // The original file behind an opaque-blob document, returned by
68
73
  // `doc.sourceBlob()`. `url` is a short-lived presigned GET the app
69
74
  // fetches the raw bytes from (the bytes are not inlined: a real source
70
75
  // file routinely exceeds the API response cap); the descriptor fields let
71
76
  // the app decide whether it handles this format before downloading.
72
- // `streams` lists every stream of the file set, primary first (its entry
73
- // mirrors the top-level fields): a single-stream file has exactly one
74
- // entry, so an app that iterates `streams` handles single- and
75
- // multi-stream files with one code path.
76
77
  export interface DocumentSourceBlob {
77
78
  url: string;
78
79
  filename: string;
79
80
  content_type: string;
80
81
  byte_size: number;
81
82
  expires_in: number;
82
- streams: DocumentSourceStream[];
83
83
  }
84
84
 
85
85
  export interface DocumentRecord extends DocumentSummary {
@@ -88,16 +88,37 @@ export interface DocumentRecord extends DocumentSummary {
88
88
  role: DocumentRole;
89
89
  members: DocumentMember[];
90
90
  app_metadata?: Record<string, unknown>;
91
- // Server-authored, opaque label for where the document lives, rendered
92
- // verbatim by the toolbar's location chip (the project's name when filed,
93
- // null when loose). The SDK never composes location from `project_id` /
94
- // `organization_id`, so the hierarchy above the document can evolve
95
- // (folders / teams) without a client change.
91
+ /** The name of the project the document is filed in, ready to show as
92
+ * is, or null when it is in no project. Render it verbatim rather than
93
+ * composing a location from `project_id`. */
96
94
  location_label: string | null;
97
95
  // Whether anything can produce a file from this document. The server
98
96
  // resolves who would write it, so a newly exportable format needs no
99
97
  // client change.
100
98
  downloadable: boolean;
99
+ /** The folder or archive this document sits in. Absent when it sits
100
+ * directly in its project, or loose. */
101
+ member_of?: DocumentContainer;
102
+ /** The document's family. A file carries its family before it is
103
+ * imported. Absent on a file no importer takes. */
104
+ family?: string;
105
+ }
106
+
107
+ /** The folder or archive a document sits in. `path` is the document's own
108
+ * path inside it. `chain` is every folder and archive above the document,
109
+ * outermost first, ending with the one `doc_id` names. */
110
+ export interface DocumentContainer {
111
+ doc_id: string;
112
+ path: string;
113
+ name: string;
114
+ kind: 'folder' | 'archive';
115
+ chain: DocumentContainerStep[];
116
+ }
117
+
118
+ export interface DocumentContainerStep {
119
+ doc_id: string;
120
+ name: string;
121
+ kind: 'folder' | 'archive';
101
122
  }
102
123
 
103
124
  // One stream of a document: a named, independently-loadable CRDT unit
@@ -106,9 +127,9 @@ export interface DocumentRecord extends DocumentSummary {
106
127
  // identifies the stream's underlying CRDT line; apps only need `name`.
107
128
  export interface DocumentStreamInfo {
108
129
  name: string;
109
- // The stream's format. `doc.stream()` opens `'crdt'` entries, the
110
- // editable collaborative unit, and rejects any other value loudly, so a
111
- // format the platform adds later cannot be half-opened by this SDK.
130
+ // `'crdt'` for every stream the platform writes today, frozen or not.
131
+ // `doc.stream()` refuses any other value, so a kind of stream added
132
+ // later is not opened as a line by this SDK.
112
133
  format: string;
113
134
  line_id: string;
114
135
  created_at: string;
@@ -195,8 +216,8 @@ export interface PushToDriveResult {
195
216
  // file-roundtrip path (a doc imported from a drive file). The doc stays
196
217
  // `ahead` until the drive's change-event webhook confirms the import,
197
218
  // so an app should say "submitted, waiting for the importer" rather
198
- // than "published", and expect `driveStatus` to stay `canPush` until
199
- // the confirmation lands. Absent / false means the push was
219
+ // than "published", and expect `driveStatus.publishing` to stay true
220
+ // (with `canPush` false) until the confirmation lands. Absent / false means the push was
200
221
  // synchronous and the baseline already advanced (the doc is `synced`).
201
222
  pending?: boolean;
202
223
  // Entity count the push uploaded.
@@ -220,16 +241,135 @@ export interface CreateBranchRequest {
220
241
  name?: string;
221
242
  }
222
243
 
244
+ /** @internal What `documents.setRecords` is handed: the whole content of a document,
245
+ * in the order it should hold it. Each record is what a document holds: a
246
+ * stable key, the tag the record store calls `type`, and the body it calls
247
+ * `params`, which is the same value an app assigns when it writes a record
248
+ * through a handle. */
249
+ export type DocumentRecordSetInput = DocumentRecordStoreInput | DocumentStreamsInput;
250
+
251
+ /** A record store: the records, in order, under a schema version. */
252
+ export interface DocumentRecordStoreInput {
253
+ records: Array<{ key: string; type: string; params: unknown }>;
254
+ /** The schema version stamped on the document, which is what a reader
255
+ * checks its own expectations against. */
256
+ schemaVersion: string;
257
+ /** A format's own preamble, for records that came out of a file that has
258
+ * one. Omit it for records an app computed. */
259
+ header?: Array<{ type: string; params: unknown }>;
260
+ }
261
+
262
+ /** Content by stream, in the shape an importer returns it: the document's
263
+ * own stream is `default`, and any other name is a stream an app opens with
264
+ * `doc.stream(name)`. */
265
+ export interface DocumentStreamsInput {
266
+ streams: Record<string, ImportContent>;
267
+ }
268
+
269
+ /**
270
+ * Which documents to list. The other fields narrow what `from` resolves to
271
+ * and are AND-ed; a query naming no references lists no documents.
272
+ *
273
+ * The query is over the document, not over what is inside it: name, type,
274
+ * project and date are answered from the listing itself, and a question
275
+ * about records is asked of each document once it is open.
276
+ */
277
+ export interface ListDocumentsQuery {
278
+ // The references to resolve: what `Brass.chooseInputs()` handed you,
279
+ // kept by your app. A folder reference keeps answering, so the platform
280
+ // resolves it against what is filed there now.
281
+ //
282
+ // Name them on every call. Your app's permissions are not a question it
283
+ // can ask: naming none answers with none, rather than with every document
284
+ // anyone has ever opened with it.
285
+ from?: readonly InputReference[];
286
+ // Type tokens, matched against a document's own type or the type of the
287
+ // file behind it, either of which names it: a file nobody has read yet
288
+ // already carries the family its importer will stamp, and opening it is
289
+ // what reads it.
290
+ types?: string[];
291
+ /** @internal */
292
+ projectIds?: string[];
293
+ /** @internal Case-insensitive substring of the document's name. */
294
+ name?: string;
295
+ /** @internal ISO-8601 bound on when the document last changed. */
296
+ updatedAfter?: string;
297
+ /** @internal */
298
+ updatedBefore?: string;
299
+ /** @internal At most this many, most recently worked on first. */
300
+ limit?: number;
301
+ }
302
+
303
+ export interface DocumentListing {
304
+ doc_id: string;
305
+ // Internal fields are optional, because the published types strip them
306
+ // and a value an app builds against those types carries neither.
307
+ /** @internal */
308
+ organization_id?: string;
309
+ project_id: string | null;
310
+ name: string;
311
+ created_at: string;
312
+ updated_at: string;
313
+ // When a person last acted on the document.
314
+ last_activity_at: string;
315
+ /** @internal */
316
+ owner_sub?: string;
317
+ // The document's family, and the type of the file behind it. Both say
318
+ // what the document IS: a file nothing has read yet already carries the
319
+ // family its importer will stamp, so neither answers whether the content
320
+ // has arrived. `doc.status` on the open document answers that.
321
+ family?: string;
322
+ file_type?: string;
323
+ thumbnail?: DocumentThumbnail;
324
+ // The folder or archive the document sits in, and its path inside it.
325
+ member_of?: { doc_id: string; path: string };
326
+ // Present while the platform has work in flight on the document (an import
327
+ // it is running), and after work that failed. Absent otherwise, including
328
+ // on a file nothing has opened yet.
329
+ status?: DocumentWorkStatus;
330
+ // Every entry of the listing's `from` that admits this document, nearest
331
+ // first: the document itself where it was named, then the folders or
332
+ // archives above it, nearest first, then its project, then its
333
+ // organization. The same objects the query was given, so they compare by
334
+ // identity.
335
+ from?: InputReference[];
336
+ }
337
+
338
+ type DocumentListingWire = Omit<DocumentListing, 'family' | 'from'> & {
339
+ schema_type?: string;
340
+ refs?: string[];
341
+ };
342
+
343
+ type DocumentRecordWire = Omit<DocumentRecord, 'family'> & { schema_type?: string };
344
+
345
+ function listingFromWire(byToken: Map<string, InputReference>) {
346
+ return ({ refs, schema_type, ...row }: DocumentListingWire): DocumentListing => ({
347
+ ...row,
348
+ ...(schema_type !== undefined ? { family: schema_type } : {}),
349
+ ...(refs !== undefined ? { from: refs.flatMap((token) => byToken.get(token) ?? []) } : {}),
350
+ });
351
+ }
352
+
223
353
  export interface DocumentsNamespace {
224
354
  // Metadata read. Returns organization_id, project_id (or null), role,
225
355
  // members, name. Closes the cold-URL-restore gap (an app whose URL
226
356
  // carries a doc_id can resolve project + organization + role from
227
357
  // this one call before opening the editable Document via
228
- // `Brass.openDocumentById`). Creating / moving / listing documents is
229
- // done through the hosted pickers + document-settings dialog, not
230
- // this namespace; the runtime create-and-open path is the top-level
358
+ // `Brass.openDocumentById`). Creating / moving documents is done
359
+ // through the hosted pickers + document-settings dialog, not this
360
+ // namespace; the runtime create-and-open path is the top-level
231
361
  // `Brass.createDocument` / `Brass.openDocumentById`.
232
362
  get(docId: string): Promise<DocumentRecord>;
363
+ /**
364
+ * The documents the references in `query.from` resolve to, narrowed by
365
+ * the rest of `query` and by what the signed-in person can see. Naming no
366
+ * references lists no documents. `onChanged`
367
+ * fires once when a document joins the set, leaves it, or changes.
368
+ *
369
+ * @internal The app-facing listing of a set is a key's input status
370
+ * (`status.files`).
371
+ */
372
+ list(query?: ListDocumentsQuery): Promise<DocumentList>;
233
373
  // Upload a preview image for a document. Brass cannot render an
234
374
  // app's document body, so an app that wants a thumbnail on its
235
375
  // document cards renders one itself (e.g. `canvas.toBlob`) and
@@ -243,6 +383,27 @@ export interface DocumentsNamespace {
243
383
  doc: Document,
244
384
  input: SetThumbnailInput,
245
385
  ): Promise<DocumentThumbnail>;
386
+ /**
387
+ * @internal
388
+ * Give a document its whole content at once, as a record set.
389
+ *
390
+ * The other way to fill a document is to write its records through the
391
+ * handles, one commit at a time, which is what builds a collaborative
392
+ * document: each record arrives as an edit, and the history of those edits
393
+ * is what makes it mergeable, undoable and versioned. A producer that
394
+ * already holds the whole set, an app that parsed a file it understands or
395
+ * computed a result, pays for that history and never uses it, and its
396
+ * readers pay again to get the records back out.
397
+ *
398
+ * So this hands the set over. The document then opens faster, holds less,
399
+ * and reads through exactly the same `Document`: an app reading it calls
400
+ * what it always calls.
401
+ *
402
+ * Takes a record store, or content by stream in the shape an importer
403
+ * returns. Legal only while the document has no content. Rejects otherwise,
404
+ * because a record set carries no history and so cannot be written over one.
405
+ */
406
+ setRecords(docId: string, input: DocumentRecordSetInput): Promise<void>;
246
407
  // Read a document's thumbnail. With no options, returns the
247
408
  // document's current card thumbnail; pass `version` for the
248
409
  // thumbnail at a point in history. Resolves `null` when no
@@ -258,7 +419,17 @@ export interface DocumentsNamespace {
258
419
  // which the toolbar branch command + hosted branch dialog drive), and the
259
420
  // hosted history modal; not exposed on `Brass.documents`.
260
421
  export interface DocumentsInternalNamespace {
261
- createCheckpoint(doc: Document): Promise<DocumentCheckpoint>;
422
+ // `setThumbnail` at an explicit version: the picture a checkpoint took,
423
+ // uploaded once the checkpoint's version is known.
424
+ setThumbnailAt(
425
+ doc: Document,
426
+ input: SetThumbnailInput,
427
+ version: number,
428
+ ): Promise<DocumentThumbnail>;
429
+ createCheckpoint(
430
+ doc: Document,
431
+ anchor?: CheckpointAnchor,
432
+ ): Promise<DocumentCheckpoint>;
262
433
  listBranches(
263
434
  docId: string,
264
435
  ): Promise<{ default_branch_id: string; branches: BranchSummary[] }>;
@@ -288,23 +459,104 @@ export interface DocumentsInternalNamespace {
288
459
  // file or the caller cannot reach it (404). The per-doc instance method
289
460
  // `doc.sourceBlob()` routes through this.
290
461
  sourceBlob(doc: Document): Promise<DocumentSourceBlob>;
462
+ // The input status behind `subscribeInputStatus`, one key's. Answers the
463
+ // response body as the server sent it, or null for a 404 (a key with no row
464
+ // yet, or a platform without the route).
465
+ readInputKeyStatus(docId: string, key: string, query: Record<string, string>): Promise<unknown>;
466
+ // Posts a key's provisional entry after the op carrying its change is
467
+ // acknowledged. Never rejects: the document already holds the change, and
468
+ // the platform projects it from there whatever this post answers.
469
+ /** @internal */
470
+ postProvisionalInputs(
471
+ docId: string,
472
+ post: ProvisionalInputsPost,
473
+ opts?: { superseded?: () => boolean },
474
+ ): Promise<ProvisionalInputsAnswer | null>;
291
475
  // The import-claim protocol behind `Brass.registerImporter`. The SDK
292
476
  // (never the app) claims the right to run the import of a stored
293
477
  // source file, and reports completion so the platform stamps the
294
478
  // document with the importing app's schema. Both reject with a
295
479
  // `BrassError` on the ineligible / claim-lost outcomes.
296
480
  claimImport(docId: string): Promise<ImportClaimResponse>;
481
+ // Stage an import's encoded streams, as one bundle, for `completeImport`
482
+ // to seal; answers the key the completion names it by.
483
+ stageImport(docId: string, bundle: Uint8Array): Promise<string>;
297
484
  completeImport(
298
485
  docId: string,
486
+ report: ImportReport | undefined,
487
+ uploadKey: string,
299
488
  ): Promise<{ schema_ref: { app_id: string; schema_hash: string } }>;
489
+ // Record that the claim holder's importer could not read the file.
490
+ failImport(docId: string, message: string): Promise<ImportFailedResponse>;
300
491
  }
301
492
 
302
493
  interface DocumentsFactoryOptions {
303
494
  apiBaseUrl: string;
304
495
  getAccessToken: () => Promise<string | null>;
305
496
  getAppAssertion?: () => Promise<string | null>;
497
+ getSocket?: () => DocumentSetSocket | undefined;
498
+ }
499
+
500
+ // A chosen reference as the `refs` query names it, and as a listing row names
501
+ // the reference it came from.
502
+ function refToken(ref: InputReference): string {
503
+ return ref.extent === 'organization'
504
+ ? `organization:${ref.organization_id}`
505
+ : ref.extent === 'project'
506
+ ? `project:${ref.project_id}`
507
+ : `document:${ref.doc_id}`;
508
+ }
509
+
510
+ // How long a provisional inputs post waits before trying again, or null to
511
+ // give up. A 409 names an op that has not landed on its line yet, which it
512
+ // does within moments; a 429 asks for fewer requests; a 5xx is a write that
513
+ // applied nothing (other writers held the line), jittered so a commit's posts
514
+ // do not meet again. Anything else is final.
515
+ const PROVISIONAL_NOT_LANDED_DELAYS_MS = [500, 1000, 2000, 4000];
516
+ const PROVISIONAL_RATE_LIMITED_DELAYS_MS = [5000, 15000];
517
+ const PROVISIONAL_UNAVAILABLE_DELAYS_MS = [1000, 3000, 9000];
518
+
519
+ export function provisionalRetryDelayMs(
520
+ status: number | undefined,
521
+ attempt: number,
522
+ random: () => number = Math.random,
523
+ ): number | null {
524
+ if (status !== undefined && status >= 500 && status <= 504) {
525
+ const base = PROVISIONAL_UNAVAILABLE_DELAYS_MS[attempt];
526
+ return base === undefined ? null : Math.round(base * (0.5 + random()));
527
+ }
528
+ const delays =
529
+ status === 409
530
+ ? PROVISIONAL_NOT_LANDED_DELAYS_MS
531
+ : status === 429
532
+ ? PROVISIONAL_RATE_LIMITED_DELAYS_MS
533
+ : [];
534
+ return delays[attempt] ?? null;
535
+ }
536
+
537
+ let inputsRouteMissingNoted = false;
538
+
539
+ // A platform without the provisional inputs route still records a key from
540
+ // the document when it compacts, so the post's absence delays the status
541
+ // rather than losing anything. Said once per page.
542
+ function noteInputsRouteMissing(): void {
543
+ if (inputsRouteMissingNoted) return;
544
+ inputsRouteMissingNoted = true;
545
+ console.info(
546
+ 'Brass: this platform does not take provisional input posts yet, so input status updates after the document compacts.',
547
+ );
306
548
  }
307
549
 
550
+ /** How many single `documents.get` requests one client has in flight at once. */
551
+ const GET_CONCURRENCY = 6;
552
+ /** How many batched `documents.get` requests (20 ids each) one client has in
553
+ * flight at once. */
554
+ const BATCH_CONCURRENCY = 5;
555
+ /** Attempts a queued `get` makes against a throttled API (about 9 s of
556
+ * backoff). The API's request budget is shared by every caller, so a get
557
+ * queued behind a burst can meet a refusal that clears within seconds. */
558
+ const GET_ATTEMPTS = 6;
559
+
308
560
  export function createDocumentsNamespace(
309
561
  opts: DocumentsFactoryOptions,
310
562
  ): DocumentsNamespace & DocumentsInternalNamespace {
@@ -314,11 +566,108 @@ export function createDocumentsNamespace(
314
566
  // Minted once per namespace, so re-claiming from this client refreshes
315
567
  // its own claim rather than taking a second one.
316
568
  const importClientId = randomToken();
569
+ // An app that shows something about each of several hundred documents
570
+ // calls `get` once per document, usually all at once. Sent one request each,
571
+ // those calls spend the API's request budget for this app's other calls
572
+ // (its opens) and every other caller's, so they are batched, and reads sent
573
+ // on their own queue.
574
+ const getLane = createRequestLane(GET_CONCURRENCY);
575
+ const singleGet = (docId: string): Promise<DocumentRecordWire> =>
576
+ getLane.run(docId, () =>
577
+ apiFetch<DocumentRecordWire>({
578
+ path: `/documents/${encodeURIComponent(docId)}`,
579
+ maxAttempts: GET_ATTEMPTS,
580
+ }),
581
+ );
582
+ const batchedGet = createBatchedGet<DocumentRecordWire>({
583
+ apiFetch,
584
+ lane: createRequestLane(BATCH_CONCURRENCY),
585
+ single: singleGet,
586
+ maxAttempts: GET_ATTEMPTS,
587
+ });
588
+ const readSeal =
589
+ (docId: string) =>
590
+ (sealId: string): Promise<SealStatus> =>
591
+ apiFetch<SealStatus>({
592
+ path: `/documents/${encodeURIComponent(docId)}/seals/${encodeURIComponent(sealId)}`,
593
+ });
317
594
 
318
- return {
595
+ const putThumbnail = async (
596
+ doc: Document,
597
+ input: SetThumbnailInput,
598
+ version: number | null,
599
+ ): Promise<DocumentThumbnail> => {
600
+ const docId = doc.id;
601
+ if (docId === null) {
602
+ throw new BrassError(
603
+ 'setThumbnail requires a Document opened from the server',
604
+ { status: 400 },
605
+ );
606
+ }
607
+ if (version === null) {
608
+ throw new BrassError(
609
+ 'setThumbnail: the document has no resolved version yet',
610
+ { status: 400 },
611
+ );
612
+ }
613
+ const branchId = doc.branchId;
614
+ const { data, contentType } = await encodeThumbnailImage(input);
615
+ const body: {
616
+ content_type: string;
617
+ data: string;
618
+ version: number;
619
+ branch_id?: string;
620
+ width?: number;
621
+ height?: number;
622
+ } = { content_type: contentType, data, version };
623
+ if (branchId !== null) body.branch_id = branchId;
624
+ if (input.width !== undefined) body.width = input.width;
625
+ if (input.height !== undefined) body.height = input.height;
626
+ const res = await apiFetch<{ thumbnail: DocumentThumbnail }>({
627
+ method: 'POST',
628
+ path: `/documents/${encodeURIComponent(docId)}/thumbnail`,
629
+ body,
630
+ });
631
+ return res.thumbnail;
632
+ };
633
+
634
+ const namespace: DocumentsNamespace & DocumentsInternalNamespace = {
319
635
  get(docId) {
320
- return apiFetch<DocumentRecord>({
321
- path: `/documents/${encodeURIComponent(docId)}`,
636
+ return batchedGet(docId).then(({ schema_type, ...record }) => ({
637
+ ...record,
638
+ ...(schema_type !== undefined ? { family: schema_type } : {}),
639
+ }));
640
+ },
641
+ async list(query) {
642
+ const params = new URLSearchParams();
643
+ if (query?.from?.length) {
644
+ params.set(
645
+ 'refs',
646
+ JSON.stringify(
647
+ query.from.map((ref) => ({
648
+ ref: refToken(ref),
649
+ ...(ref.accept !== undefined ? { accept: ref.accept } : {}),
650
+ })),
651
+ ),
652
+ );
653
+ }
654
+ if (query?.types?.length) params.set('type', query.types.join(','));
655
+ if (query?.projectIds?.length) params.set('project_id', query.projectIds.join(','));
656
+ if (query?.name) params.set('name', query.name);
657
+ if (query?.updatedAfter) params.set('updated_after', query.updatedAfter);
658
+ if (query?.updatedBefore) params.set('updated_before', query.updatedBefore);
659
+ if (query?.limit !== undefined) params.set('limit', String(query.limit));
660
+ const search = params.toString();
661
+ const result = await apiFetch<{ documents: DocumentListingWire[] }>({
662
+ path: search ? `/documents?${search}` : '/documents',
663
+ });
664
+ const byToken = new Map((query?.from ?? []).map((ref) => [refToken(ref), ref]));
665
+ const documents = result.documents.map(listingFromWire(byToken));
666
+ return createDocumentList({
667
+ documents,
668
+ query: query ?? {},
669
+ list: async (q) => (await namespace.list(q)).documents,
670
+ socket: opts.getSocket?.(),
322
671
  });
323
672
  },
324
673
  listBranches(docId) {
@@ -331,6 +680,72 @@ export function createDocumentsNamespace(
331
680
  path: `/documents/${encodeURIComponent(docId)}/streams?branch=${encodeURIComponent(branchId)}`,
332
681
  });
333
682
  },
683
+ async setRecords(docId, input) {
684
+ // Encoded on the client, by the encoder an importer's result goes
685
+ // through, and staged as one bundle, so the platform seals it exactly
686
+ // as it seals an import. What reaches the network is the stored form's
687
+ // tables rather than a JSON rendering of them, which is several times
688
+ // larger.
689
+ const { encodeSealedRecordsUpload } = await import('./sealed-encode.js');
690
+ const streams =
691
+ 'streams' in input
692
+ ? await encodeImportStreams({ streams: input.streams })
693
+ : [
694
+ {
695
+ name: DEFAULT_IMPORT_STREAM,
696
+ bytes: await encodeSealedRecordsUpload({
697
+ schemaVersion: input.schemaVersion,
698
+ header: (input.header ?? []).map((entry) => ({
699
+ tag: entry.type,
700
+ body: entry.params,
701
+ })),
702
+ records: input.records.map(
703
+ (record): PackedRecord<unknown> => [
704
+ record.key,
705
+ record.type,
706
+ record.params,
707
+ ],
708
+ ),
709
+ }),
710
+ },
711
+ ];
712
+ if (streams.length === 0) {
713
+ throw new BrassError('There is nothing to set: every stream is empty', {
714
+ status: 400,
715
+ });
716
+ }
717
+ const bytes = encodeStreamBundle(streams);
718
+ const staged = await apiFetch<{ upload_key: string; url: string }>({
719
+ method: 'POST',
720
+ path: `/documents/${encodeURIComponent(docId)}/records/upload`,
721
+ body: { byte_size: bytes.byteLength },
722
+ });
723
+ // A presigned PUT carries no Brass headers: the signature is the
724
+ // credential, and a stray header breaks it.
725
+ // The view's own buffer rather than the view: a `Uint8Array` over a
726
+ // larger buffer would send the whole of it, and `fetch` takes the
727
+ // buffer type directly.
728
+ const put = await fetch(staged.url, {
729
+ method: 'PUT',
730
+ body: bytes.buffer.slice(
731
+ bytes.byteOffset,
732
+ bytes.byteOffset + bytes.byteLength,
733
+ ) as ArrayBuffer,
734
+ });
735
+ if (!put.ok) {
736
+ throw new BrassError('Uploading the record set failed', {
737
+ status: put.status,
738
+ });
739
+ }
740
+ // The platform answers once the set is queued; the call resolves when
741
+ // it is stored, and rejects with the platform's refusal if it is not.
742
+ const accepted = await apiFetch<unknown>({
743
+ method: 'POST',
744
+ path: `/documents/${encodeURIComponent(docId)}/records`,
745
+ body: { upload_key: staged.upload_key },
746
+ });
747
+ await awaitSeal(accepted, readSeal(docId));
748
+ },
334
749
  createStream(docId, branchId, name) {
335
750
  return apiFetch<DocumentStreamInfo>({
336
751
  method: 'POST',
@@ -357,44 +772,15 @@ export function createDocumentsNamespace(
357
772
  });
358
773
  },
359
774
  async setThumbnail(doc, input) {
360
- const docId = doc.id;
361
- if (docId === null) {
362
- throw new BrassError(
363
- 'setThumbnail requires a Document opened from the server',
364
- { status: 400 },
365
- );
366
- }
367
775
  // Capture the depicted version synchronously, before the async
368
776
  // encode, so a concurrent editor's op landing mid-upload cannot
369
777
  // shift the anchor away from the state the app rendered.
370
- const version = doc.currentSeq;
371
- if (version === null) {
372
- throw new BrassError(
373
- 'setThumbnail: the document has no resolved version yet',
374
- { status: 400 },
375
- );
376
- }
377
- const branchId = doc.branchId;
378
- const { data, contentType } = await encodeThumbnailImage(input);
379
- const body: {
380
- content_type: string;
381
- data: string;
382
- version: number;
383
- branch_id?: string;
384
- width?: number;
385
- height?: number;
386
- } = { content_type: contentType, data, version };
387
- if (branchId !== null) body.branch_id = branchId;
388
- if (input.width !== undefined) body.width = input.width;
389
- if (input.height !== undefined) body.height = input.height;
390
- const res = await apiFetch<{ thumbnail: DocumentThumbnail }>({
391
- method: 'POST',
392
- path: `/documents/${encodeURIComponent(docId)}/thumbnail`,
393
- body,
394
- });
395
- return res.thumbnail;
778
+ return putThumbnail(doc, input, doc.currentSeq);
396
779
  },
397
- async createCheckpoint(doc) {
780
+ async setThumbnailAt(doc, input, version) {
781
+ return putThumbnail(doc, input, version);
782
+ },
783
+ async createCheckpoint(doc, anchor) {
398
784
  const docId = doc.id;
399
785
  if (docId === null) {
400
786
  throw new BrassError(
@@ -402,13 +788,11 @@ export function createDocumentsNamespace(
402
788
  { status: 400 },
403
789
  );
404
790
  }
405
- // Capture the depicted version synchronously, so a concurrent
406
- // editor's op landing mid-call cannot shift the anchor away
407
- // from the state the user is on. Mirrors the setThumbnail
408
- // version-capture pattern. This is the anchor the server pins
409
- // the checkpoint to; it is not an app-facing "seal a past
410
- // version" control.
411
- const version = doc.currentSeq;
791
+ // The scheduler passes the seq it read once every local op was acked,
792
+ // so the marker holds the state of the calls it covers. Without one,
793
+ // the depicted version is read here, synchronously, so an op landing
794
+ // mid-call cannot shift the anchor.
795
+ const version = anchor?.version ?? doc.currentSeq;
412
796
  if (version === null) {
413
797
  throw new BrassError(
414
798
  'createCheckpoint: the document has no resolved version yet',
@@ -463,18 +847,79 @@ export function createDocumentsNamespace(
463
847
  path: `/documents/${encodeURIComponent(docId)}/source-blob`,
464
848
  });
465
849
  },
850
+ async postProvisionalInputs(docId, { key, body }, opts = {}) {
851
+ const path = `/documents/${encodeURIComponent(docId)}/inputs/${encodeURIComponent(key)}`;
852
+ for (let attempt = 0; ; attempt += 1) {
853
+ let status: number | undefined;
854
+ try {
855
+ return parseProvisionalInputsAnswer(await apiFetch<unknown>({ method: 'POST', path, body }));
856
+ } catch (err) {
857
+ status = err instanceof BrassError ? err.status : undefined;
858
+ }
859
+ if (status === 404) {
860
+ noteInputsRouteMissing();
861
+ return null;
862
+ }
863
+ const delay = provisionalRetryDelayMs(status, attempt);
864
+ if (delay === null || opts.superseded?.() === true) return null;
865
+ await new Promise((resolve) => setTimeout(resolve, delay));
866
+ if (opts.superseded?.() === true) return null;
867
+ }
868
+ },
869
+ readInputKeyStatus(docId, key, query) {
870
+ const search = new URLSearchParams(query).toString();
871
+ const path = `/documents/${encodeURIComponent(docId)}/inputs/${encodeURIComponent(key)}`;
872
+ return orNullOn404(apiFetch<unknown>({ path: search ? `${path}?${search}` : path }));
873
+ },
466
874
  claimImport(docId) {
467
875
  return apiFetch<ImportClaimResponse>({
468
876
  method: 'POST',
469
877
  path: `/documents/${encodeURIComponent(docId)}/import-claim`,
470
- body: { client_id: importClientId },
878
+ body: { client_id: importClientId, stages: true },
471
879
  });
472
880
  },
473
- completeImport(docId) {
474
- return apiFetch<{ schema_ref: { app_id: string; schema_hash: string } }>({
881
+ async stageImport(docId, bundle) {
882
+ const staged = await apiFetch<{ upload_key: string; url: string }>({
883
+ method: 'POST',
884
+ path: `/documents/${encodeURIComponent(docId)}/import-complete/upload`,
885
+ body: { byte_size: bundle.byteLength },
886
+ });
887
+ // A presigned PUT carries no Brass headers: the signature is the
888
+ // credential, and a stray header breaks it.
889
+ const put = await fetch(staged.url, {
890
+ method: 'PUT',
891
+ body: bundle.buffer.slice(
892
+ bundle.byteOffset,
893
+ bundle.byteOffset + bundle.byteLength,
894
+ ) as ArrayBuffer,
895
+ });
896
+ if (!put.ok) {
897
+ throw new BrassError('Uploading the import failed', {
898
+ status: put.status,
899
+ });
900
+ }
901
+ return staged.upload_key;
902
+ },
903
+ async completeImport(docId, report, uploadKey) {
904
+ const accepted = await apiFetch<unknown>({
475
905
  method: 'POST',
476
906
  path: `/documents/${encodeURIComponent(docId)}/import-complete`,
477
- body: { client_id: importClientId },
907
+ body: {
908
+ client_id: importClientId,
909
+ ...(report !== undefined ? { report } : {}),
910
+ upload_key: uploadKey,
911
+ },
912
+ });
913
+ return awaitSeal<{ schema_ref: { app_id: string; schema_hash: string } }>(
914
+ accepted,
915
+ readSeal(docId),
916
+ );
917
+ },
918
+ failImport(docId, message) {
919
+ return apiFetch<ImportFailedResponse>({
920
+ method: 'POST',
921
+ path: `/documents/${encodeURIComponent(docId)}/import-failed`,
922
+ body: { client_id: importClientId, message },
478
923
  });
479
924
  },
480
925
  async getThumbnail(docId, options) {
@@ -492,9 +937,10 @@ export function createDocumentsNamespace(
492
937
  return res.thumbnail;
493
938
  },
494
939
  };
940
+ return namespace;
495
941
  }
496
942
 
497
- function randomToken(): string {
943
+ export function randomToken(): string {
498
944
  const g = globalThis as { crypto?: { randomUUID?: () => string } };
499
945
  if (g.crypto?.randomUUID) return g.crypto.randomUUID();
500
946
  let s = '';
@@ -539,3 +985,12 @@ function bytesToBase64(bytes: Uint8Array): string {
539
985
  }
540
986
  return btoa(binary);
541
987
  }
988
+
989
+ async function orNullOn404(request: Promise<unknown>): Promise<unknown> {
990
+ try {
991
+ return await request;
992
+ } catch (err) {
993
+ if (err instanceof BrassError && err.status === 404) return null;
994
+ throw err;
995
+ }
996
+ }