@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/errors.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  // The single error type every `@brass-build/client` call rejects with.
2
2
  //
3
- // One catch type, two mutually-exclusive discriminators: branch on
4
- // `.status` for the HTTP surface, `.code` for the hosted-UI launchers (an
5
- // HTTP rejection has no `code`, a launcher rejection no `status`).
3
+ // One catch type: `.status` carries an HTTP rejection's status, and `.code`
4
+ // names a hosted-UI launcher's failure or the code an HTTP refusal carried.
5
+ // An HTTP refusal carries the platform's code whatever it is, so a code the
6
+ // platform adds after this SDK was built still reaches the app. The codes
7
+ // below are the ones an app is expected to act on.
6
8
  // - `iframe_unavailable`: this browser / app context can't host the
7
9
  // iframe (an app surfaces this as "open in a new tab").
8
10
  // - `embed_error`: the embed page cannot proceed (e.g. not signed in).
@@ -10,17 +12,26 @@
10
12
  // - `app_not_installed`: the server rejected the app's action because
11
13
  // the target organization has no install of it; `organizationId`
12
14
  // names the org so the install-consent flow can be offered.
15
+ // - `app_not_granted`: the reader can open the document, but nobody has
16
+ // handed it to this app. Opening it through `session.open()` does.
17
+ // - `not_imported`: the document's file has not been imported: another
18
+ // app imports it, or this app had not called `registerImporter` when
19
+ // the open stopped waiting for it. Show that the file has not been
20
+ // imported yet; a document that has it as an input lists it as changed
21
+ // once it has.
13
22
  export type BrassErrorCode =
14
23
  | 'not_in_browser'
15
24
  | 'iframe_unavailable'
16
25
  | 'embed_error'
17
26
  | 'app_not_installed'
18
- | 'schema_conflict';
27
+ | 'schema_conflict'
28
+ | 'app_not_granted'
29
+ | 'not_imported';
19
30
 
20
31
  export class BrassError extends Error {
21
32
  override name = 'BrassError';
22
33
  readonly status?: number;
23
- readonly code?: BrassErrorCode;
34
+ readonly code?: BrassErrorCode | (string & {});
24
35
  readonly organizationId?: string;
25
36
  /** Server-authored heading for a prompt this error drives, when the
26
37
  * platform sends one. A client freezes into an app's bundle, so a string
@@ -31,7 +42,7 @@ export class BrassError extends Error {
31
42
  message: string,
32
43
  init?: {
33
44
  status?: number;
34
- code?: BrassErrorCode;
45
+ code?: BrassErrorCode | (string & {});
35
46
  organizationId?: string;
36
47
  title?: string;
37
48
  },
@@ -72,6 +72,10 @@ export interface RetryOptions {
72
72
  // Invoked before each replay with the 1-based attempt that just failed and
73
73
  // the response (absent on a network throw). For diagnostics only.
74
74
  onRetry?: (attempt: number, response: Response | undefined) => void;
75
+ // A response in the replay set that this answers true for is returned
76
+ // rather than replayed: a refusal that names its own wait, which the caller
77
+ // paces instead of this backoff.
78
+ isFinal?: (response: Response) => Promise<boolean>;
75
79
  // Injected for tests; defaults to the real timer.
76
80
  sleep?: (ms: number) => Promise<void>;
77
81
  }
@@ -112,6 +116,7 @@ export async function fetchWithRetry(
112
116
  if (response && !policy.replayStatuses.has(response.status)) {
113
117
  return response;
114
118
  }
119
+ if (response && options.isFinal && (await options.isFinal(response))) return response;
115
120
  if (attempt === maxAttempts) {
116
121
  if (response) return response;
117
122
  throw lastError;
@@ -0,0 +1,26 @@
1
+ // The browser's own file dialog, for a visitor with no account: there is
2
+ // nothing of theirs to browse, so which file is the whole question.
3
+ //
4
+ // `input.click()` needs the user activation of the press that called it,
5
+ // which lasts seconds rather than a task, so a short read may come first.
6
+ // Resolves null when the dialog is dismissed (`cancel`, which every current
7
+ // engine fires on an `<input type=file>`).
8
+
9
+ export function chooseFile(accept: string): Promise<File | null> {
10
+ return new Promise((resolve) => {
11
+ const input = document.createElement('input');
12
+ input.type = 'file';
13
+ if (accept !== '') input.accept = accept;
14
+ input.style.display = 'none';
15
+ const finish = (file: File | null): void => {
16
+ input.remove();
17
+ resolve(file);
18
+ };
19
+ input.addEventListener('change', () => finish(input.files?.[0] ?? null), { once: true });
20
+ input.addEventListener('cancel', () => finish(null), { once: true });
21
+ // Attached, because a detached input does not open its dialog in every
22
+ // engine.
23
+ document.body.append(input);
24
+ input.click();
25
+ });
26
+ }
package/src/guest-link.ts CHANGED
@@ -6,9 +6,370 @@
6
6
  // Pure + search-string-in so it stays testable, mirroring
7
7
  // `isExportMode(search)`.
8
8
 
9
- export const GUEST_LINK_PARAM = 'brass_link';
9
+ const GUEST_LINK_PARAM = 'brass_link';
10
10
 
11
11
  export function readGuestLinkToken(search: string): string | null {
12
12
  const value = new URLSearchParams(search).get(GUEST_LINK_PARAM);
13
13
  return value && value.trim() !== '' ? value : null;
14
14
  }
15
+
16
+ // Pin the token into the page URL (`replaceState`, never a history entry):
17
+ // a document created while signed out is reached again only through its
18
+ // token, so a reload restores it from here, and `signIn()` carries this URL
19
+ // as its return address.
20
+ export function writeGuestLinkToken(token: string): void {
21
+ if (typeof window === 'undefined') return;
22
+ try {
23
+ const url = new URL(window.location.href);
24
+ url.searchParams.set(GUEST_LINK_PARAM, token);
25
+ const next = url.toString();
26
+ if (next === window.location.href) return;
27
+ window.history.replaceState(window.history.state, '', next);
28
+ } catch {
29
+ // History API unavailable or URL exotic: leave the URL alone.
30
+ }
31
+ }
32
+
33
+ export function clearGuestLinkToken(): void {
34
+ if (typeof window === 'undefined') return;
35
+ try {
36
+ const url = new URL(window.location.href);
37
+ if (!url.searchParams.has(GUEST_LINK_PARAM)) return;
38
+ url.searchParams.delete(GUEST_LINK_PARAM);
39
+ window.history.replaceState(window.history.state, '', url.toString());
40
+ } catch {
41
+ // See `writeGuestLinkToken`.
42
+ }
43
+ }
44
+
45
+ // The last document a visitor created without an account, per app. The URL
46
+ // is that document's only handle, so a visitor who closes the tab and comes
47
+ // back to the app's root would have no way back to it; the app's signed-out
48
+ // gate offers it from here. localStorage, since the return is in a new tab.
49
+ // The expiry is stored beside the token so the gate can say when the work
50
+ // is lost without a request, and an entry past it reads as absent.
51
+ //
52
+ // The token is a credential to an editable document, which is why the
53
+ // session hands an app the expiry alone and opens the document itself.
54
+ const UNSAVED_KEY_PREFIX = 'brass:unsaved:';
55
+
56
+ export interface UnsavedWorkEntry {
57
+ token: string;
58
+ expiresAt: Date;
59
+ }
60
+
61
+ function unsavedStorage(): Storage | null {
62
+ if (typeof localStorage === 'undefined') return null;
63
+ try {
64
+ return localStorage;
65
+ } catch {
66
+ return null;
67
+ }
68
+ }
69
+
70
+ function unsavedKey(appId: string): string {
71
+ return `${UNSAVED_KEY_PREFIX}${appId}`;
72
+ }
73
+
74
+ export function storeUnsavedWork(
75
+ appId: string,
76
+ token: string,
77
+ expiresAt: Date,
78
+ ): void {
79
+ const storage = unsavedStorage();
80
+ if (!storage) return;
81
+ try {
82
+ storage.setItem(
83
+ unsavedKey(appId),
84
+ JSON.stringify({ token, expires_at: expiresAt.toISOString() }),
85
+ );
86
+ } catch {
87
+ // Quota / disabled storage. The way back is a niceness; the open that
88
+ // just succeeded must not fail on it.
89
+ }
90
+ }
91
+
92
+ export function readUnsavedWork(
93
+ appId: string,
94
+ now: number = Date.now(),
95
+ ): UnsavedWorkEntry | null {
96
+ const storage = unsavedStorage();
97
+ if (!storage) return null;
98
+ let raw: string | null;
99
+ try {
100
+ raw = storage.getItem(unsavedKey(appId));
101
+ } catch {
102
+ return null;
103
+ }
104
+ if (raw === null) return null;
105
+ let parsed: unknown;
106
+ try {
107
+ parsed = JSON.parse(raw);
108
+ } catch {
109
+ return null;
110
+ }
111
+ const entry = parsed as { token?: unknown; expires_at?: unknown } | null;
112
+ const expiresAt =
113
+ typeof entry?.expires_at === 'string' ? new Date(entry.expires_at) : null;
114
+ if (
115
+ typeof entry?.token !== 'string' ||
116
+ entry.token === '' ||
117
+ expiresAt === null ||
118
+ Number.isNaN(expiresAt.getTime())
119
+ ) {
120
+ return null;
121
+ }
122
+ // The platform removes the document at the expiry, so an entry past it
123
+ // names nothing and is dropped here rather than offered.
124
+ if (expiresAt.getTime() <= now) {
125
+ clearUnsavedWork(appId);
126
+ return null;
127
+ }
128
+ return { token: entry.token, expiresAt };
129
+ }
130
+
131
+ export function clearUnsavedWork(appId: string): void {
132
+ const storage = unsavedStorage();
133
+ if (!storage) return;
134
+ try {
135
+ storage.removeItem(unsavedKey(appId));
136
+ } catch {
137
+ // See `storeUnsavedWork`.
138
+ }
139
+ }
140
+
141
+ // Whether this browser has edited a document it made without an account. New
142
+ // and Upload ask before replacing one, so the marker outlives a reload; it
143
+ // carries the document's expiry so an entry past it is dropped on read.
144
+ const EDITED_KEY_PREFIX = 'brass:trial-edited:';
145
+
146
+ export function markTrialEdited(token: string, expiresAt: Date): void {
147
+ const storage = unsavedStorage();
148
+ if (!storage) return;
149
+ try {
150
+ storage.setItem(`${EDITED_KEY_PREFIX}${token}`, expiresAt.toISOString());
151
+ } catch {
152
+ // See `storeUnsavedWork`.
153
+ }
154
+ }
155
+
156
+ export function trialWasEdited(token: string, now: number = Date.now()): boolean {
157
+ const storage = unsavedStorage();
158
+ if (!storage) return false;
159
+ let raw: string | null;
160
+ try {
161
+ raw = storage.getItem(`${EDITED_KEY_PREFIX}${token}`);
162
+ } catch {
163
+ return false;
164
+ }
165
+ if (raw === null) return false;
166
+ const expiresAt = Date.parse(raw);
167
+ if (Number.isNaN(expiresAt) || expiresAt <= now) {
168
+ try {
169
+ storage.removeItem(`${EDITED_KEY_PREFIX}${token}`);
170
+ } catch {
171
+ // See `storeUnsavedWork`.
172
+ }
173
+ return false;
174
+ }
175
+ return true;
176
+ }
177
+
178
+ // The claim secret the mint handed this browser, one per document, keyed by
179
+ // the app and the document's link token. Only the browser that made the
180
+ // document has one, so a sign-in here keeps the document and a sign-in
181
+ // wherever its link was shared does not. Written at the mint alone, so opening
182
+ // a shared link leaves it alone. Every mint prunes the secrets whose document
183
+ // has expired, since nothing else removes the secret of a document nobody
184
+ // came back to.
185
+ const CLAIM_KEY_PREFIX = 'brass:claim:';
186
+
187
+ function claimKey(appId: string, token: string): string {
188
+ return `${CLAIM_KEY_PREFIX}${appId}:${token}`;
189
+ }
190
+
191
+ // Collected before any removal, since removing a key renumbers the rest.
192
+ function claimKeys(storage: Storage, prefix: string): string[] {
193
+ const keys: string[] = [];
194
+ try {
195
+ for (let i = 0; i < storage.length; i++) {
196
+ const key = storage.key(i);
197
+ if (key !== null && key.startsWith(prefix)) keys.push(key);
198
+ }
199
+ } catch {
200
+ // See `storeUnsavedWork`.
201
+ }
202
+ return keys;
203
+ }
204
+
205
+ function readClaimEntry(
206
+ storage: Storage,
207
+ key: string,
208
+ now: number,
209
+ ): string | null {
210
+ let entry: { secret?: unknown; expires_at?: unknown } | null;
211
+ try {
212
+ entry = JSON.parse(storage.getItem(key) ?? 'null') as typeof entry;
213
+ } catch {
214
+ entry = null;
215
+ }
216
+ const expiresAt =
217
+ typeof entry?.expires_at === 'string' ? Date.parse(entry.expires_at) : NaN;
218
+ if (typeof entry?.secret !== 'string' || Number.isNaN(expiresAt) || expiresAt <= now) {
219
+ try {
220
+ storage.removeItem(key);
221
+ } catch {
222
+ // See `storeUnsavedWork`.
223
+ }
224
+ return null;
225
+ }
226
+ return entry.secret;
227
+ }
228
+
229
+ function pruneClaimSecrets(storage: Storage, now: number): void {
230
+ for (const key of claimKeys(storage, CLAIM_KEY_PREFIX)) readClaimEntry(storage, key, now);
231
+ }
232
+
233
+ export function storeClaimSecret(
234
+ appId: string,
235
+ token: string,
236
+ secret: string,
237
+ expiresAt: Date,
238
+ now: number = Date.now(),
239
+ ): void {
240
+ const storage = unsavedStorage();
241
+ if (!storage) return;
242
+ pruneClaimSecrets(storage, now);
243
+ try {
244
+ storage.setItem(
245
+ claimKey(appId, token),
246
+ JSON.stringify({ secret, expires_at: expiresAt.toISOString() }),
247
+ );
248
+ } catch {
249
+ // See `storeUnsavedWork`.
250
+ }
251
+ }
252
+
253
+ export function readClaimSecret(
254
+ appId: string,
255
+ token: string,
256
+ now: number = Date.now(),
257
+ ): string | null {
258
+ const storage = unsavedStorage();
259
+ if (!storage) return null;
260
+ return readClaimEntry(storage, claimKey(appId, token), now);
261
+ }
262
+
263
+ // One document's secret, or with no token every secret this browser holds
264
+ // for the app.
265
+ export function clearClaimSecret(appId: string, token?: string): void {
266
+ const storage = unsavedStorage();
267
+ if (!storage) return;
268
+ const keys =
269
+ token === undefined
270
+ ? claimKeys(storage, `${CLAIM_KEY_PREFIX}${appId}:`)
271
+ : [claimKey(appId, token)];
272
+ for (const key of keys) {
273
+ try {
274
+ storage.removeItem(key);
275
+ } catch {
276
+ // See `storeUnsavedWork`.
277
+ }
278
+ }
279
+ }
280
+
281
+ // That this tab started a sign-in from a guest page, and on which token. Per
282
+ // tab (sessionStorage), written by `signIn()` and taken once on the return,
283
+ // so a URL carrying `brass_code` that this tab did not ask for claims nothing.
284
+ const SIGN_IN_INTENT_PREFIX = 'brass:claim-intent:';
285
+
286
+ function tabStorage(): Storage | null {
287
+ if (typeof sessionStorage === 'undefined') return null;
288
+ try {
289
+ return sessionStorage;
290
+ } catch {
291
+ return null;
292
+ }
293
+ }
294
+
295
+ export function markClaimIntent(appId: string, token: string): void {
296
+ try {
297
+ tabStorage()?.setItem(`${SIGN_IN_INTENT_PREFIX}${appId}`, token);
298
+ } catch {
299
+ // See `storeUnsavedWork`.
300
+ }
301
+ }
302
+
303
+ export function takeClaimIntent(appId: string): string | null {
304
+ const storage = tabStorage();
305
+ if (!storage) return null;
306
+ try {
307
+ const token = storage.getItem(`${SIGN_IN_INTENT_PREFIX}${appId}`);
308
+ storage.removeItem(`${SIGN_IN_INTENT_PREFIX}${appId}`);
309
+ return token;
310
+ } catch {
311
+ return null;
312
+ }
313
+ }
314
+
315
+ // The documents this browser claimed, per app, by the mint link each claim
316
+ // revoked. Reading that link again answers 410 for everyone, and a page that
317
+ // holds no session cannot ask the platform where it leads, so this is how a
318
+ // signed-out page on it knows to offer sign-in to the document instead. It
319
+ // confers no access (the open after sign-in is checked like any other), so a
320
+ // sign-out keeps it. Entries lapse after `CLAIMED_LINK_TTL_MS`.
321
+ const CLAIMED_KEY_PREFIX = 'brass:claimed:';
322
+ const CLAIMED_LINK_TTL_MS = 30 * 24 * 60 * 60 * 1000;
323
+
324
+ function claimedKey(appId: string, token: string): string {
325
+ return `${CLAIMED_KEY_PREFIX}${appId}:${token}`;
326
+ }
327
+
328
+ function readClaimedEntry(storage: Storage, key: string, now: number): string | null {
329
+ let entry: { doc_id?: unknown; claimed_at?: unknown } | null;
330
+ try {
331
+ entry = JSON.parse(storage.getItem(key) ?? 'null') as typeof entry;
332
+ } catch {
333
+ entry = null;
334
+ }
335
+ const claimedAt = typeof entry?.claimed_at === 'number' ? entry.claimed_at : NaN;
336
+ if (
337
+ typeof entry?.doc_id !== 'string' ||
338
+ Number.isNaN(claimedAt) ||
339
+ claimedAt + CLAIMED_LINK_TTL_MS <= now
340
+ ) {
341
+ try {
342
+ storage.removeItem(key);
343
+ } catch {
344
+ // See `storeUnsavedWork`.
345
+ }
346
+ return null;
347
+ }
348
+ return entry.doc_id;
349
+ }
350
+
351
+ export function storeClaimedLink(
352
+ appId: string,
353
+ token: string,
354
+ docId: string,
355
+ now: number = Date.now(),
356
+ ): void {
357
+ const storage = unsavedStorage();
358
+ if (!storage) return;
359
+ for (const key of claimKeys(storage, CLAIMED_KEY_PREFIX)) readClaimedEntry(storage, key, now);
360
+ try {
361
+ storage.setItem(claimedKey(appId, token), JSON.stringify({ doc_id: docId, claimed_at: now }));
362
+ } catch {
363
+ // See `storeUnsavedWork`.
364
+ }
365
+ }
366
+
367
+ export function readClaimedLink(
368
+ appId: string,
369
+ token: string,
370
+ now: number = Date.now(),
371
+ ): string | null {
372
+ const storage = unsavedStorage();
373
+ if (!storage) return null;
374
+ return readClaimedEntry(storage, claimedKey(appId, token), now);
375
+ }
@@ -90,8 +90,9 @@ export interface Splice<E> {
90
90
  }
91
91
 
92
92
  /**
93
- * What changed in a list. Fires once on registration carrying the whole list
94
- * as a single splice, then on every change carrying only what moved.
93
+ * What changed in a list. Fires once on registration carrying the whole list,
94
+ * then on every change carrying only what moved. No splice inserts more than
95
+ * 10,000 elements, so a spread apply stays inside the engine's argument limit.
95
96
  *
96
97
  * A registration fire and a resync are not special cases a subscriber has to
97
98
  * recognise: both arrive as splices that replace what it was last told, so
@@ -137,28 +138,47 @@ export interface HandleOwner {
137
138
  // Stamps the next commit with the mutate origin tag and commits, so the
138
139
  // change's `origin.source` reads `'local'`.
139
140
  commitMutate(): void;
141
+ // Runs a handle's `mutate` body, so every commit it takes carries the
142
+ // origin `options` asks for and the reference writes queued inside it
143
+ // commit after its last one.
144
+ runMutate(options: MutateOptions | undefined, fn: () => void): void;
140
145
  // Classify a Loro event by what produced it. Derived from the commit's
141
146
  // `by` + origin tag, so it survives microtask-deferred delivery.
142
147
  originOf(event: unknown): ChangeSource;
148
+ // Report an app subscriber that threw. The handle carries on delivering to
149
+ // the others; this is how the app hears that its own callback failed.
150
+ reportSubscriberThrow(err: unknown): void;
151
+ }
152
+
153
+ /** Options every `mutate` takes. */
154
+ export interface MutateOptions {
155
+ /**
156
+ * `false` keeps the write off the reader's Undo stack, for a pass the app
157
+ * runs on its own rather than one the reader asked for. Defaults to true.
158
+ */
159
+ undoable?: boolean;
143
160
  }
144
161
 
145
162
  // Public handle interfaces. Concrete classes below.
146
163
 
147
164
  export interface MapHandle<T = unknown> {
148
- // Per-entry read. Returns undefined when missing.
165
+ /** Per-entry read. Returns undefined when missing. */
149
166
  get(entryKey: string): T | undefined;
150
- // Keys currently in the map.
167
+ /** Keys currently in the map. */
151
168
  keys(): string[];
152
- // Whether an entry exists at `entryKey`.
169
+ /** Whether an entry exists at `entryKey`. */
153
170
  has(entryKey: string): boolean;
154
- // Mutate through a typed JS-proxy, scoped to this container; same
155
- // ergonomics as `doc.mutate(d => { d.entities[id].field = ... })`.
156
- mutate(fn: (draft: Record<string, T>) => void): void;
157
- // ONE channel for every change whatever produced it (local / remote / undo
158
- // / redo / checkout): the projection reconciles by value. The callback gets
159
- // `change` and optional `origin` a render ignores. Fires once on
160
- // registration with the whole map as `change.set`, then on every change
161
- // (each later fire proportional to what that change wrote).
171
+ /** Every `[key, value]`, as a walk. Each step hands over one entry, so a
172
+ * caller that does not keep them never holds the whole map at once. */
173
+ entries(): IterableIterator<[string, T]>;
174
+ /** Mutate through a typed JS-proxy, scoped to this container; same
175
+ * ergonomics as `doc.mutate(d => { d.entities[id].field = ... })`. */
176
+ mutate(fn: (draft: Record<string, T>) => void, options?: MutateOptions): void;
177
+ /** ONE channel for every change whatever produced it (local / remote / undo
178
+ * / redo / checkout): the projection reconciles by value. The callback gets
179
+ * `change` and optional `origin` a render ignores. Fires once on
180
+ * registration with the whole map as `change.set`, then on every change
181
+ * (each later fire proportional to what that change wrote). */
162
182
  subscribe(cb: MapSubscriber<T>): HandleUnsubscribe;
163
183
  /**
164
184
  * Report each time this container has been delivered whole: on
@@ -175,10 +195,10 @@ export interface MapHandle<T = unknown> {
175
195
  */
176
196
  onSettled(cb: () => void): HandleUnsubscribe;
177
197
 
178
- // Child handle for `entries[entryKey]` when that entry is itself a map /
179
- // list. Reads see empty until the container exists; the first mutate
180
- // creates it, and a subscription taken before that delivers the container
181
- // whole on whichever write creates it.
198
+ /** Child handle for `entries[entryKey]` when that entry is itself a map /
199
+ * list. Reads see empty until the container exists; the first mutate
200
+ * creates it, and a subscription taken before that delivers the container
201
+ * whole on whichever write creates it. */
182
202
  map<U>(entryKey: string): MapHandle<U>;
183
203
  list<U>(entryKey: string): ListHandle<U>;
184
204
  }
@@ -202,12 +222,16 @@ export interface ListDraft<T> extends Array<T> {
202
222
 
203
223
  export interface ListHandle<T = unknown> {
204
224
  length(): number;
205
- mutate(fn: (draft: ListDraft<T>) => void): void;
206
- // Fires once on registration carrying the whole list as one splice, then on
207
- // every change carrying only what moved, so a subscriber applies splices to
208
- // what it already holds and never re-reads. A keyed collection belongs in a
209
- // map (or `orderedMap`), which reconciles by key; a list is for ordering
210
- // keys and small positional data.
225
+ /** The element at `index`, or undefined outside the list. */
226
+ get(index: number): T | undefined;
227
+ /** Every element in order, as a walk. */
228
+ values(): IterableIterator<T>;
229
+ mutate(fn: (draft: ListDraft<T>) => void, options?: MutateOptions): void;
230
+ /** Fires once on registration carrying the whole list, then on
231
+ * every change carrying only what moved, so a subscriber applies splices to
232
+ * what it already holds and never re-reads. A keyed collection belongs in a
233
+ * map (or `orderedMap`), which reconciles by key; a list is for ordering
234
+ * keys and small positional data. */
211
235
  subscribe(cb: ListSubscriber<T>): HandleUnsubscribe;
212
236
  /**
213
237
  * Report each time this container has been delivered whole: on
@@ -255,36 +279,38 @@ export type OrderedMapPosition =
255
279
  | { index: number };
256
280
 
257
281
  export interface OrderedMapHandle<T = unknown> {
258
- // Per-entry read. Returns undefined when missing.
282
+ /** Per-entry read. Returns undefined when missing. */
259
283
  get(entryKey: string): T | undefined;
260
- // Whether an entry exists in the map.
284
+ /** Whether an entry exists in the map. */
261
285
  has(entryKey: string): boolean;
262
- // Keys in order-list order, filtered to keys still present in the map.
286
+ /** Keys in order-list order, filtered to keys still present in the map. */
263
287
  keys(): string[];
264
- // `[key, value]` records walking the order list, so an app reading in
265
- // source order doesn't re-walk the order list by hand.
288
+ /** Every `[key, value]` in order, as a walk. */
289
+ entries(): IterableIterator<[string, T]>;
290
+ /** @deprecated Use `entries()`, which walks the same records in the same
291
+ * order without building the array. */
266
292
  ordered(): { key: string; value: T }[];
267
- // Mutate the map; the sibling order list reconciles in the same commit
268
- // (new keys appended in insertion order, deleted keys pruned). Drive it
269
- // like `MapHandle.mutate`: `m[key] = record`, `delete m[key]`,
270
- // `m[key].field = ...`.
271
- mutate(fn: (draft: Record<string, T>) => void): void;
272
- // Write `value` under `entryKey` and put that key at `at`, both in ONE
273
- // commit (one undo step) like every other structural edit here. A key the
274
- // map already holds is written and repositioned rather than named twice.
293
+ /** Mutate the map; the sibling order list reconciles in the same commit
294
+ * (new keys appended in insertion order, deleted keys pruned). Drive it
295
+ * like `MapHandle.mutate`: `m[key] = record`, `delete m[key]`,
296
+ * `m[key].field = ...`. */
297
+ mutate(fn: (draft: Record<string, T>) => void, options?: MutateOptions): void;
298
+ /** Write `value` under `entryKey` and put that key at `at`, both in ONE
299
+ * commit (one undo step) like every other structural edit here. A key the
300
+ * map already holds is written and repositioned rather than named twice. */
275
301
  insert(entryKey: string, value: T, at: OrderedMapPosition): void;
276
- // Reposition a key the map already holds, writing the order list alone. A
277
- // key the map does not hold does nothing, so a reorder racing a peer's
278
- // delete of the same key leaves the delete standing.
302
+ /** Reposition a key the map already holds, writing the order list alone. A
303
+ * key the map does not hold does nothing, so a reorder racing a peer's
304
+ * delete of the same key leaves the delete standing. */
279
305
  move(entryKey: string, to: OrderedMapPosition): void;
280
- // Fires once on registration (the whole map as `change.set` in order),
281
- // then on every change. A field edit carries only `set` / `deleted`; a
282
- // change that moves the order also carries `change.order` (live keys in
283
- // order) so the app re-sequences the views it holds. A move the map does
284
- // not see (an edit of the sibling order list, a merge that resequences
285
- // keys already present) arrives as an `order` of its own with an empty
286
- // `set`. Content stays proportional to what the change wrote; re-sequencing
287
- // happens only when the order moves, and at most once per change.
306
+ /** Fires once on registration (the whole map as `change.set` in order),
307
+ * then on every change. A field edit carries only `set` / `deleted`; a
308
+ * change that moves the order also carries `change.order` (live keys in
309
+ * order) so the app re-sequences the views it holds. A move the map does
310
+ * not see (an edit of the sibling order list, a merge that resequences
311
+ * keys already present) arrives as an `order` of its own with an empty
312
+ * `set`. Content stays proportional to what the change wrote; re-sequencing
313
+ * happens only when the order moves, and at most once per change. */
288
314
  subscribe(cb: MapSubscriber<T>): HandleUnsubscribe;
289
315
  /**
290
316
  * Report each time this container has been delivered whole: on