@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
package/dist/session.js CHANGED
@@ -6,8 +6,14 @@
6
6
  // registered canvas it also auto-captures the thumbnail. The app renders the
7
7
  // commands and draws from `session.document`. Every primitive the session
8
8
  // calls stays public, so an app can ignore the session and wire them directly.
9
+ import { placeThroughHostedSurfaces } from './placement.js';
9
10
  import { BrassError } from './errors.js';
10
11
  import { makeLiveCommand } from './document-commands.js';
12
+ import { appCallbackError } from './subscriber-delivery.js';
13
+ // Thrown through a guest open whose link leads its claimant to sign in: the
14
+ // open ends with no document and no error, on the signed-out surface.
15
+ class SignInToFollowLink extends Error {
16
+ }
11
17
  // Built-in orphan command labels (no active doc needed). Sentence case, ASCII
12
18
  // ellipsis, per the copy-style rules. Both stay bare: an app's own word for
13
19
  // what it holds is rarely "document" (a sketch, a model, a sheet), so a
@@ -15,6 +21,9 @@ import { makeLiveCommand } from './document-commands.js';
15
21
  const SESSION_LABELS = {
16
22
  new: 'New',
17
23
  open: 'Open...',
24
+ // Without an account there is nothing to browse, so Open takes a file from
25
+ // the reader's own machine.
26
+ upload: 'Upload...',
18
27
  };
19
28
  function toError(err) {
20
29
  return err instanceof Error ? err : new Error(String(err));
@@ -22,14 +31,17 @@ function toError(err) {
22
31
  // Fallback copy for a failed open with no server message to render (a
23
32
  // network drop, no HTTP response). The HTTP case carries the server's own
24
33
  // message instead, so the platform evolves the wording without an app change.
25
- const OPEN_FAILED_MESSAGE = 'This document could not be opened';
34
+ const OPEN_FAILED_MESSAGE = 'This could not be opened';
35
+ // The session holds a live copy, so a frozen one is opened beside it.
36
+ const FROZEN_IN_SESSION = 'The session holds a live copy of its document. Open a frozen copy with Brass.openDocumentById';
37
+ const PREPARING_MESSAGE = 'Preparing for editing...';
26
38
  // Copy parked on `session.error` when the open document is evicted remotely
27
39
  // (a `doc_gone` the local user did not initiate). Matches the wording a
28
- // post-eviction op frame degrades to ("the document is no longer available"),
40
+ // post-eviction op frame degrades to ("this is no longer available"),
29
41
  // so a delete observed live and a fresh visit to the same deleted id read the
30
42
  // same. A reload re-opens the id, hits the server 404, and swaps in the
31
43
  // server-authored copy.
32
- const DOCUMENT_GONE_MESSAGE = 'This document is no longer available';
44
+ const DOCUMENT_GONE_MESSAGE = 'This is no longer available';
33
45
  // The error parked on `session.error` for a failed open, shaped so its
34
46
  // `.message` is always presentable copy an app can render verbatim. An HTTP
35
47
  // failure is returned as-is: its `.message` is the server's (non-disclosing,
@@ -66,6 +78,9 @@ export function createDocumentSession(deps) {
66
78
  // changes, so a per-commit `doc.subscribe` that doesn't move a command's
67
79
  // enabled state stays quiet.
68
80
  let lastSignature = null;
81
+ // Counts the documents bound, so a new copy of the same id (a reopen for
82
+ // editing, a reload) changes the signature and reaches subscribers.
83
+ let bindCount = 0;
69
84
  // Outstanding `whenReady()` promises, settled in `emit()`: resolve once a
70
85
  // document binds, reject if the user signs out before one ever does.
71
86
  const whenReadyPending = [];
@@ -95,17 +110,53 @@ export function createDocumentSession(deps) {
95
110
  // delete retracts its id, so a later remote delete of the same doc is not
96
111
  // misread as local.
97
112
  const localDeletes = new Set();
113
+ // Set for the length of an open started without an account (see `status`).
114
+ let accountFreeOpen = false;
115
+ // Documents the session opened read-only by request, as opposed to ones a
116
+ // reader's access made read-only. A reopen of one (a move, a reload) keeps
117
+ // that mode.
118
+ const readOnlyOpens = new WeakSet();
119
+ // The read intent of one open: the caller's ask where it made one, else the
120
+ // mode the app set at init.
121
+ function inMode(options, asked) {
122
+ const readOnly = asked ?? (deps.openReadOnly?.() === true ? true : undefined);
123
+ return readOnly === undefined ? options : { ...options, readOnly };
124
+ }
125
+ // Opens that asked to read, keyed by the open's own promise, so `activate`
126
+ // records the document it binds from one in `readOnlyOpens`.
127
+ const readOnlyAsks = new WeakSet();
128
+ function remember(opened, options) {
129
+ if (options.readOnly ?? options.documentOptions?.readOnly === true) {
130
+ readOnlyAsks.add(opened);
131
+ }
132
+ return opened;
133
+ }
134
+ function openById(docId, options, consent) {
135
+ const opened = consent === undefined
136
+ ? deps.openDocumentById(docId, options)
137
+ : deps.openDocumentById(docId, options, consent);
138
+ return remember(opened, options);
139
+ }
140
+ function openByLink(openGuest, token, options) {
141
+ return remember(openGuest(token, options), options);
142
+ }
98
143
  function status() {
99
144
  // 'unknown' before auth settles; the onUserChange handler re-fires
100
145
  // (start() / emit()) and flips this to its resolved value.
101
146
  if (deps.authStatus() === 'unknown')
102
147
  return 'unknown';
148
+ if (deps.initFailure?.())
149
+ return 'error';
103
150
  // A guest (opened via `?brass_link=`) is not signed in but still has a
104
151
  // read-only document to load, so it never reports 'signed-out' (which
105
152
  // would render the app's sign-in surface instead of the document).
106
153
  const isGuest = deps.guestLinkToken?.() != null;
107
- if (!deps.isSignedIn() && !isGuest)
154
+ // An open started without an account loads before there is a link to be
155
+ // a guest on (a pick sends its file first), and the visitor is watching
156
+ // that load, not the sign-in surface.
157
+ if (!deps.isSignedIn() && !isGuest && !(loading && accountFreeOpen)) {
108
158
  return 'signed-out';
159
+ }
109
160
  if (loading)
110
161
  return 'loading';
111
162
  if (document)
@@ -129,15 +180,25 @@ export function createDocumentSession(deps) {
129
180
  const load = loading ? `${loadingMessage ?? ''}:${loadingProgress ?? ''}` : '';
130
181
  // `openError` message joins the signature so a switch from one failed open
131
182
  // to a different one (same 'error' status, no bound doc) still re-fires.
132
- const err = openError ? `${openError.status ?? ''}:${openError.message}` : '';
183
+ const shown = openError ?? deps.initFailure?.() ?? null;
184
+ const err = shown ? `${shown.status ?? ''}:${shown.message}` : '';
133
185
  // The previewed version and the browsing flag are their own terms because
134
186
  // nothing else in this signature follows them: a preview -> preview step
135
187
  // holds the id, the name, the status and every command's enabled state
136
188
  // (edit affordances are already disabled by the first checkout), so
137
189
  // without these the step is a silent emit and the chrome rendering the
138
190
  // preview keeps showing the version the reader stepped away from.
139
- const ver = `${document?.version ?? ''}:${document?.versionBrowsing ? 1 : 0}`;
140
- return `${status()}|${document?.id ?? ''}|${documentName ?? ''}|${load}|${err}|${ver}|${cmds}`;
191
+ const ver = `${document?.previewVersion ?? ''}:${document?.versionBrowsing ? 1 : 0}`;
192
+ // The expiry moves once, to null, when the document is claimed; the
193
+ // chrome naming it has to hear that.
194
+ const expiry = document?.expiresAt?.getTime() ?? '';
195
+ return `${status()}|${bindCount}|${document?.id ?? ''}|${documentName ?? ''}|${load}|${err}|${ver}|${expiry}|${cmds}`;
196
+ }
197
+ // A listener that throws usually leaves the app showing nothing, so the
198
+ // app's `onError` hears about it as well as the console.
199
+ function reportListenerThrow(err) {
200
+ console.error('[Brass] session listener threw:', err);
201
+ deps.reportError(appCallbackError(err));
141
202
  }
142
203
  function emit() {
143
204
  const sig = signature();
@@ -157,7 +218,7 @@ export function createDocumentSession(deps) {
157
218
  }
158
219
  catch (err) {
159
220
  // A throwing listener must not strand the others.
160
- console.error('[Brass] session listener threw:', err);
221
+ reportListenerThrow(err);
161
222
  }
162
223
  }
163
224
  }
@@ -177,12 +238,14 @@ export function createDocumentSession(deps) {
177
238
  for (const { reject } of pending)
178
239
  reject(error);
179
240
  }
180
- else if (current === 'error' && openError) {
181
- // A user-initiated open failed terminally; reject rather than hang the
182
- // awaiter forever. Same error object the session surfaces on `.error`.
241
+ else if (current === 'error' && session.error) {
242
+ // A user-initiated open failed terminally, or init could not start;
243
+ // reject rather than hang the awaiter forever. Same error object the
244
+ // session surfaces on `.error`.
245
+ const failure = session.error;
183
246
  const pending = whenReadyPending.splice(0, whenReadyPending.length);
184
247
  for (const { reject } of pending)
185
- reject(openError);
248
+ reject(failure);
186
249
  }
187
250
  // No branch for 'empty': it is both the idle pre-open state (an awaiter
188
251
  // legitimately waits for a future open) and the post-close state. A close
@@ -211,10 +274,70 @@ export function createDocumentSession(deps) {
211
274
  notify: emit,
212
275
  });
213
276
  }
214
- const cmdNew = buildOrphanCommand('new', SESSION_LABELS.new, 'session', 'menu', deps.isSignedIn, async () => {
277
+ // New and Open start a document without an account where the app can mint
278
+ // one, so a visitor's own buttons and the menu run the same commands.
279
+ // A failed init leaves the page signed out with nothing either command
280
+ // could reach, so neither is offered.
281
+ const withoutAccount = () => {
282
+ if (deps.initFailure?.())
283
+ return false;
284
+ const status = deps.authStatus();
285
+ return status === 'signed-out' || status === 'guest';
286
+ };
287
+ // The server refuses every account-free route for an app that does not
288
+ // offer signed-out use, so New and Open are withdrawn once it says so
289
+ // rather than offered only to show that refusal. Read lazily, from the
290
+ // first check made while signed out, and settled for the page.
291
+ let signedOutRefusal = null;
292
+ let signedOutProbe = null;
293
+ const refusedWithoutAccount = () => {
294
+ if (signedOutProbe === null && deps.signedOutUseRefusal) {
295
+ const read = deps.signedOutUseRefusal;
296
+ signedOutProbe = (async () => {
297
+ try {
298
+ signedOutRefusal = await read();
299
+ }
300
+ catch {
301
+ signedOutRefusal = null;
302
+ }
303
+ if (signedOutRefusal !== null)
304
+ emit();
305
+ })();
306
+ }
307
+ return signedOutRefusal !== null;
308
+ };
309
+ const canStartWithoutAccount = () => withoutAccount() &&
310
+ !refusedWithoutAccount() &&
311
+ deps.mintAnonymousDocument !== undefined &&
312
+ deps.enterGuestMode !== undefined &&
313
+ deps.openDocumentByLink !== undefined;
314
+ const canPickWithoutAccount = () => canStartWithoutAccount() &&
315
+ deps.chooseAnonymousFile !== undefined &&
316
+ deps.mintAnonymousDocumentFromFile !== undefined;
317
+ // An app that creates no documents offers no New. Read lazily, from the
318
+ // first check, and settled for the page; until it answers, New is offered.
319
+ let offersNew = true;
320
+ let offersNewProbe = null;
321
+ const appOffersNew = () => {
322
+ if (offersNewProbe === null && deps.appOffersNew) {
323
+ const read = deps.appOffersNew;
324
+ offersNewProbe = (async () => {
325
+ try {
326
+ offersNew = await read();
327
+ }
328
+ catch {
329
+ offersNew = true;
330
+ }
331
+ if (!offersNew)
332
+ emit();
333
+ })();
334
+ }
335
+ return offersNew;
336
+ };
337
+ const cmdNew = buildOrphanCommand('new', SESSION_LABELS.new, 'session', 'menu', () => appOffersNew() && (deps.isSignedIn() || canStartWithoutAccount()), async () => {
215
338
  await create();
216
339
  });
217
- const cmdOpen = buildOrphanCommand('open', SESSION_LABELS.open, 'session', 'menu', deps.isSignedIn, async () => {
340
+ const cmdOpen = buildOrphanCommand('open', () => (withoutAccount() ? SESSION_LABELS.upload : SESSION_LABELS.open), 'session', 'menu', () => deps.isSignedIn() || canPickWithoutAccount(), async () => {
218
341
  await pick();
219
342
  });
220
343
  const orphanCommands = [cmdNew, cmdOpen];
@@ -249,6 +372,8 @@ export function createDocumentSession(deps) {
249
372
  sessionCanvasUnregister = null;
250
373
  }
251
374
  const previous = document;
375
+ if (next !== previous)
376
+ bindCount += 1;
252
377
  document = next;
253
378
  documentName = name;
254
379
  if (next === null) {
@@ -262,6 +387,17 @@ export function createDocumentSession(deps) {
262
387
  // A close failure (already-closed, freed WASM) is non-fatal.
263
388
  }
264
389
  }
390
+ if (next && next !== previous) {
391
+ // `reopen` on the bound copy goes through the session, so the new copy
392
+ // binds in its place. A copy the session no longer holds reopens the
393
+ // way the layer that opened it would.
394
+ const outside = next._reopenerFn;
395
+ next._setReopener((mode) => document === next
396
+ ? reopenBound(next, mode)
397
+ : outside !== null
398
+ ? outside(mode)
399
+ : Promise.reject(new BrassError('This document cannot be reopened', { status: 0 })));
400
+ }
265
401
  if (next) {
266
402
  // `subscribe` fires on every change including local mutations (the
267
403
  // session's listeners drive UI that re-renders on local edits). The
@@ -274,6 +410,8 @@ export function createDocumentSession(deps) {
274
410
  // the pinned branch settles once the default is known.
275
411
  if (event.kind === 'branches')
276
412
  syncUrlForActive('replace');
413
+ if (event.kind === 'change' && event.source !== 'remote')
414
+ noteTrialEdit(next);
277
415
  // Take the doc's name whenever it moves. A rename reaches the doc
278
416
  // from the server's `doc_meta` frame, which sets it directly, and
279
417
  // `documentName` is what the emit signature reads: without this the
@@ -312,15 +450,34 @@ export function createDocumentSession(deps) {
312
450
  // keeps its topic subscription and its LoroDoc until it is closed, so
313
451
  // every move would otherwise leave one resident for the life of the
314
452
  // page, taking a copy of every op the reopened document receives.
453
+ //
454
+ // A guest holds no access by id, so it re-opens through its link,
455
+ // as its first open did.
456
+ //
457
+ // A copy that is already read-only (a reload onto converted or
458
+ // sealed content, or one discarding a refused change) stays bound
459
+ // until the re-opened one replaces it, so the reader keeps seeing
460
+ // the document instead of a loading surface between two copies of
461
+ // it. Nothing can be written to it in the meantime. An editable one
462
+ // is detached as any other open detaches its predecessor.
315
463
  const branch = urlBranch(next);
316
464
  const evicted = next;
317
- document = null;
318
- documentName = null;
319
- void activate((onSnapshotProgress) => deps.openDocumentById(id, {
320
- ...withDefaults(),
321
- ...(branch ? { branch } : {}),
322
- onSnapshotProgress,
323
- }, 'skip'), null, 'skip', branch)
465
+ const keepBound = next.readOnly;
466
+ const mode = readOnlyOpens.has(next) ? { readOnly: true } : {};
467
+ const guestToken = deps.guestLinkToken?.();
468
+ const openGuest = deps.openDocumentByLink;
469
+ if (!keepBound) {
470
+ document = null;
471
+ documentName = null;
472
+ }
473
+ void activate((onSnapshotProgress) => guestToken && openGuest
474
+ ? openByLink(openGuest, guestToken, { ...mode, onSnapshotProgress })
475
+ : openById(id, {
476
+ ...withDefaults(),
477
+ ...(branch ? { branch } : {}),
478
+ ...mode,
479
+ onSnapshotProgress,
480
+ }, 'skip'), null, 'skip', branch, true, keepBound)
324
481
  .catch(() => markGoneRemote())
325
482
  .finally(() => closeQuietly(evicted));
326
483
  return;
@@ -426,9 +583,16 @@ export function createDocumentSession(deps) {
426
583
  // boot last-selected fallback, whose failure is a stale-persistence
427
584
  // self-heal that should settle silently on empty (the user asked for
428
585
  // nothing specific).
429
- surfaceOpenError = true) {
586
+ surfaceOpenError = true,
587
+ // Re-opening the document already bound: it stays bound, and the session
588
+ // stays 'ready', until the new copy replaces it. A failure detaches it as
589
+ // though it had been detached at the start.
590
+ keepBound = false,
591
+ // With `keepBound`: a failure leaves the bound document in place and only
592
+ // rejects the caller.
593
+ keepOnFailure = false) {
430
594
  const generation = ++openGeneration;
431
- loading = true;
595
+ loading = !keepBound;
432
596
  loadingMessage = null;
433
597
  loadingProgress = null;
434
598
  // A new open attempt supersedes any prior failure's error state.
@@ -439,11 +603,13 @@ export function createDocumentSession(deps) {
439
603
  // 'loading' until the new doc binds; a failed open restores the previous
440
604
  // binding. The doc-event/gone subscriptions stay attached (`setDocument`
441
605
  // re-points them, and the restore path watches the same doc).
442
- const previousDocument = document;
443
- const previousName = documentName;
606
+ let previousDocument = keepBound ? null : document;
607
+ const previousName = keepBound ? null : documentName;
444
608
  const previousSpecifiedBranch = activeSpecifiedBranch;
445
- document = null;
446
- documentName = null;
609
+ if (!keepBound) {
610
+ document = null;
611
+ documentName = null;
612
+ }
447
613
  // What the join's spilled ops have cost so far, and what the whole
448
614
  // download was declared to be. Carried across the two phases so the pair
449
615
  // reads as one figure. Per open, because a second open of the same
@@ -453,9 +619,19 @@ export function createDocumentSession(deps) {
453
619
  emit();
454
620
  // Surface snapshot-download progress. Ignored once a newer open supersedes
455
621
  // this generation, so a slow stale download can't repaint the new open.
456
- const onSnapshotProgress = (loaded, total, phase) => {
622
+ const onSnapshotProgress = (loaded, total, phase, message) => {
457
623
  if (generation !== openGeneration)
458
- return;
624
+ return false;
625
+ // The platform is making the document editable, or replacing a
626
+ // reader's content, and the open waits for it with nothing bound
627
+ // meanwhile. A replacement is described in the platform's own words.
628
+ if (phase === 'preparing' || phase === 'importing') {
629
+ loadingProgress = null;
630
+ loadingMessage =
631
+ phase === 'preparing' ? PREPARING_MESSAGE : message !== undefined && message !== '' ? message : 'Loading...';
632
+ emit();
633
+ return true;
634
+ }
459
635
  // One download, reported once. A document whose ops spilled to storage
460
636
  // fetches them from the join before the snapshot, so a load has two
461
637
  // stretches of bytes arriving over the network with a reader watching.
@@ -504,6 +680,7 @@ export function createDocumentSession(deps) {
504
680
  loadingMessage = `Downloading... ${(loaded / 1_000_000).toFixed(1)} MB`;
505
681
  }
506
682
  emit();
683
+ return true;
507
684
  };
508
685
  // Give up on this open. A warm swap (previous doc present) rebinds it and
509
686
  // only rejects the caller, so the working surface is never clobbered; with
@@ -512,6 +689,13 @@ export function createDocumentSession(deps) {
512
689
  // and read as the app's document-less home. `display` is what the app
513
690
  // renders; it defaults to the platform's own open-failure copy.
514
691
  function abandonOpen(err, display) {
692
+ if (keepOnFailure && generation === openGeneration) {
693
+ loading = false;
694
+ loadingMessage = null;
695
+ loadingProgress = null;
696
+ emit();
697
+ throw err;
698
+ }
515
699
  if (generation === openGeneration) {
516
700
  loading = false;
517
701
  loadingMessage = null;
@@ -519,7 +703,11 @@ export function createDocumentSession(deps) {
519
703
  // 'replace' restores the URL without planting an entry for an open
520
704
  // that never happened.
521
705
  activeSpecifiedBranch = previousSpecifiedBranch;
522
- if (surfaceOpenError && previousDocument === null) {
706
+ if (err instanceof SignInToFollowLink) {
707
+ // The URL names the document the sign-in returns to.
708
+ setDocument(previousDocument, previousName, 'skip');
709
+ }
710
+ else if (surfaceOpenError && previousDocument === null) {
523
711
  openError = display ?? toDisplayError(err);
524
712
  // Leave the URL pointing at the doc that failed ('skip', not the
525
713
  // 'replace' below that clears it for a null document): the error
@@ -538,13 +726,36 @@ export function createDocumentSession(deps) {
538
726
  }
539
727
  throw err;
540
728
  }
729
+ // A file the open sends before there is a document to read.
730
+ const onUploadProgress = (loaded, total) => {
731
+ if (generation !== openGeneration)
732
+ return;
733
+ loadingProgress = total > 0 ? Math.min(1, loaded / total) : null;
734
+ loadingMessage =
735
+ loadingProgress === null
736
+ ? 'Uploading...'
737
+ : `Uploading... ${Math.floor(loadingProgress * 100)}%`;
738
+ emit();
739
+ };
740
+ // A load that moves the page onto another document's link takes the
741
+ // outgoing document's credential with it, so that document closes then
742
+ // rather than after the bind, and a failed open has nothing to rebind.
743
+ const releasePrevious = () => {
744
+ closeQuietly(previousDocument);
745
+ previousDocument = null;
746
+ };
541
747
  let doc;
748
+ let askedToRead = false;
542
749
  try {
543
- doc = await load(onSnapshotProgress);
750
+ const opening = load(onSnapshotProgress, onUploadProgress, releasePrevious);
751
+ askedToRead = readOnlyAsks.has(opening);
752
+ doc = await opening;
544
753
  }
545
754
  catch (err) {
546
755
  abandonOpen(err);
547
756
  }
757
+ if (askedToRead)
758
+ readOnlyOpens.add(doc);
548
759
  if (generation !== openGeneration) {
549
760
  // A newer open / close superseded this one. Discard the orphan (so its
550
761
  // WS subscription doesn't leak) plus the doc this generation detached.
@@ -552,13 +763,14 @@ export function createDocumentSession(deps) {
552
763
  closeQuietly(previousDocument);
553
764
  return doc;
554
765
  }
555
- // A doc opened mid-preparation (drive-stub hydration, upload extraction)
556
- // arrives empty + read-only with a pending blocking status; the SDK
557
- // imports the content and emits the terminal status when the work
558
- // finishes. Hold the session in 'loading' (surfacing the server message)
559
- // and bind only on that terminal status, so the editor's first paint sees
560
- // the content. A pending push is non-blocking and binds immediately.
561
- const work = doc.status;
766
+ // An open by id resolves with the document's content or rejects, waiting
767
+ // out any import first. A trial document opened through its link can
768
+ // arrive empty while the visitor's file is imported, with a pending
769
+ // blocking status; hold the session in 'loading' (surfacing the server
770
+ // message) and bind only on the terminal status, so the editor's first
771
+ // paint sees the content. A pending push is non-blocking and binds
772
+ // immediately.
773
+ const work = doc._workStatusNow;
562
774
  if (work !== null && work.pending && work.blocking !== false) {
563
775
  loadingMessage = work.message;
564
776
  loadingProgress = work.progress ?? null;
@@ -600,7 +812,7 @@ export function createDocumentSession(deps) {
600
812
  // platform's own message is what the app renders.
601
813
  if (doc._contentLoadFailed) {
602
814
  closeQuietly(doc);
603
- const failure = new BrassError(doc.status?.message ?? OPEN_FAILED_MESSAGE, { status: 0 });
815
+ const failure = new BrassError(doc._workStatusNow?.message ?? OPEN_FAILED_MESSAGE, { status: 0 });
604
816
  abandonOpen(failure, failure);
605
817
  }
606
818
  loading = false;
@@ -637,14 +849,24 @@ export function createDocumentSession(deps) {
637
849
  return document;
638
850
  const openGuest = deps.openDocumentByLink;
639
851
  try {
640
- return await activate((onSnapshotProgress) => openGuest(guestToken, { onSnapshotProgress }), null, 'skip', null);
852
+ return await activate((onSnapshotProgress) => openByLink(openGuest, guestToken, inMode({ onSnapshotProgress }, undefined)).catch(async (err) => {
853
+ const claimed = await claimedDocumentOf(guestToken, err);
854
+ if (claimed === null)
855
+ throw err;
856
+ if (claimed === 'sign-in')
857
+ throw new SignInToFollowLink();
858
+ return openById(claimed.docId, inMode({ onSnapshotProgress }, undefined), 'skip');
859
+ }), null, 'skip', null);
641
860
  }
642
- catch {
861
+ catch (err) {
862
+ forgetUnsavedWorkIfGone(guestToken, err);
643
863
  return null;
644
864
  }
645
865
  }
646
- if (!deps.isSignedIn() || document)
866
+ if (document)
647
867
  return document;
868
+ if (!deps.isSignedIn())
869
+ return restoreUnsavedWork();
648
870
  // A pick handed back by the hosted picker's redirect fallback opens
649
871
  // before the ordinary URL / last-selection restore, and independently of
650
872
  // `syncDocToUrl`. `takePickSelection` strips the one-shot params as it
@@ -655,10 +877,10 @@ export function createDocumentSession(deps) {
655
877
  try {
656
878
  // 'replace': a boot restore pins the URL selection in place, not a
657
879
  // navigation, so it must not plant a history entry.
658
- return await activate((onSnapshotProgress) => deps.openDocumentById(sel.docId, {
880
+ return await activate((onSnapshotProgress) => openById(sel.docId, inMode({
659
881
  ...(sel.branchId ? { branch: sel.branchId } : {}),
660
882
  onSnapshotProgress,
661
- }, 'skip'), null, 'replace', sel.branchId);
883
+ }, undefined), 'skip'), null, 'replace', sel.branchId);
662
884
  }
663
885
  catch {
664
886
  // The user navigated to a specific `?doc=` and it could not be
@@ -673,7 +895,7 @@ export function createDocumentSession(deps) {
673
895
  const last = deps.lastSelectedDocument();
674
896
  if (last) {
675
897
  try {
676
- return await activate((onSnapshotProgress) => deps.openDocumentById(last.doc_id, { onSnapshotProgress }, 'skip'), last.name, 'replace', null,
898
+ return await activate((onSnapshotProgress) => openById(last.doc_id, inMode({ onSnapshotProgress }, undefined), 'skip'), last.name, 'replace', null,
677
899
  // A stale last-selection is a persistence self-heal, not a request
678
900
  // the user made; settle silently on empty rather than parking an
679
901
  // error.
@@ -685,6 +907,64 @@ export function createDocumentSession(deps) {
685
907
  }
686
908
  return null;
687
909
  }
910
+ // Whether a failed link open says the link itself is gone. The view route
911
+ // answers `404` for a token it does not know, `410` for one that expired
912
+ // or was revoked and `403` for one its organization's policy now refuses;
913
+ // anything else (no response, a shed request) says nothing about the link.
914
+ function linkGone(err) {
915
+ return (err instanceof BrassError &&
916
+ (err.status === 403 || err.status === 404 || err.status === 410));
917
+ }
918
+ // The document a revoked link leads this person to, when their own claim
919
+ // revoked it. Asked only on a 410, the one refusal a claim produces.
920
+ async function claimedDocumentOf(token, err) {
921
+ if (!(err instanceof BrassError) || err.status !== 410 || !deps.followClaimedLink) {
922
+ return null;
923
+ }
924
+ try {
925
+ return await deps.followClaimedLink(token);
926
+ }
927
+ catch {
928
+ return null;
929
+ }
930
+ }
931
+ // A stashed token the platform no longer answers names nothing to
932
+ // continue, so it is forgotten where the refusal is heard, whether the
933
+ // open was the boot of its URL or the restore below.
934
+ function forgetUnsavedWorkIfGone(token, err) {
935
+ if (!linkGone(err))
936
+ return;
937
+ if (deps.unsavedWork?.()?.token === token)
938
+ deps.clearUnsavedWork?.();
939
+ }
940
+ // A signed-out boot on a URL that names no document reopens the last
941
+ // document this browser made without an account on this app, the way a
942
+ // signed-in boot reopens the last document picked.
943
+ async function restoreUnsavedWork() {
944
+ if (deps.readUrlSelection())
945
+ return null;
946
+ const entry = deps.unsavedWork?.();
947
+ if (!entry || !deps.enterGuestMode || !deps.openDocumentByLink)
948
+ return null;
949
+ const openGuest = deps.openDocumentByLink;
950
+ const { token } = entry;
951
+ // The same path a reload of the document's URL takes: the token becomes
952
+ // the page's credential and the URL carries it, then the link opens.
953
+ deps.enterGuestMode(token);
954
+ try {
955
+ return await activate((onSnapshotProgress) => openByLink(openGuest, token, inMode({ ...withDefaults({}), onSnapshotProgress }, undefined)), null, 'skip');
956
+ }
957
+ catch (err) {
958
+ // The token did not open, so it is not this page's credential after
959
+ // all: the page is signed out again, and the stash is kept unless the
960
+ // link is gone for good, so the next boot tries it again.
961
+ deps.leaveGuestMode?.();
962
+ openError = null;
963
+ forgetUnsavedWorkIfGone(token, err);
964
+ emit();
965
+ return null;
966
+ }
967
+ }
688
968
  function start() {
689
969
  if (startPromise)
690
970
  return startPromise;
@@ -699,12 +979,111 @@ export function createDocumentSession(deps) {
699
979
  return startPromise;
700
980
  }
701
981
  function open(docId, options) {
702
- return activate((onSnapshotProgress) => deps.openDocumentById(docId, {
703
- ...withDefaults(options),
704
- onSnapshotProgress,
705
- }), null, 'push', options?.branch ?? null);
982
+ if (options?.frozen === true) {
983
+ return Promise.reject(new BrassError(FROZEN_IN_SESSION, { status: 0 }));
984
+ }
985
+ const asked = options?.readOnly ?? options?.documentOptions?.readOnly;
986
+ return activate((onSnapshotProgress) => openById(docId, inMode({ ...withDefaults(options), onSnapshotProgress }, asked)), null, 'push', options?.branch ?? null);
987
+ }
988
+ // A switch the hosted history or branch surfaces make from the bound
989
+ // document (another branch, a version, a fork) opens in the mode that
990
+ // document was opened in, so a copy opened to read stays one and a copy
991
+ // reopened to edit stays one.
992
+ function openSibling(docId, options) {
993
+ const current = document;
994
+ const readOnly = current === null || current.id !== docId
995
+ ? undefined
996
+ : readOnlyOpens.has(current)
997
+ ? true
998
+ : current._openMode === 'edit'
999
+ ? false
1000
+ : undefined;
1001
+ return open(docId, { ...options, ...(readOnly !== undefined ? { readOnly } : {}) });
1002
+ }
1003
+ // `reopen` on the bound document: the new copy replaces it the way a move's
1004
+ // reopen does, kept on screen until it binds and kept bound if the reopen
1005
+ // fails. The session holds a live copy, so a frozen one is the caller's to
1006
+ // open beside it.
1007
+ async function reopenBound(current, mode) {
1008
+ if (mode === 'frozen') {
1009
+ throw new BrassError(FROZEN_IN_SESSION, { status: 0 });
1010
+ }
1011
+ const id = current.id;
1012
+ if (id === null)
1013
+ throw new BrassError('No document is open', { status: 0 });
1014
+ const branch = urlBranch(current);
1015
+ const guestToken = deps.guestLinkToken?.();
1016
+ const openGuest = deps.openDocumentByLink;
1017
+ const readOnly = mode === 'read';
1018
+ const next = await activate((onSnapshotProgress) => guestToken && openGuest
1019
+ ? openByLink(openGuest, guestToken, {
1020
+ ...withDefaults(),
1021
+ readOnly,
1022
+ onSnapshotProgress,
1023
+ })
1024
+ : openById(id, {
1025
+ ...withDefaults(),
1026
+ ...(branch ? { branch } : {}),
1027
+ readOnly,
1028
+ onSnapshotProgress,
1029
+ }), documentName, 'skip', activeSpecifiedBranch, true, true, true);
1030
+ // A newer open, reopen or close took the session while this one joined,
1031
+ // and closed the copy it produced.
1032
+ if (document !== next) {
1033
+ throw new BrassError('The reopen was replaced by a later open of the session', {
1034
+ status: 0,
1035
+ });
1036
+ }
1037
+ return next;
1038
+ }
1039
+ // A create fails in two regimes, and only the later one reaches the
1040
+ // session. Once `activate` has the open, `abandonOpen` parks the failure on
1041
+ // `session.error` for a status-gated app to render. Everything before that
1042
+ // (minting the account-less document, resolving the destination) throws with
1043
+ // the returned promise as its only report, and the documented way to call
1044
+ // this from a button floats that promise, so the click reads as ignored.
1045
+ // Parking here under `abandonOpen`'s own condition gives both regimes one
1046
+ // surface. The generation check is what keeps a failure from clobbering a
1047
+ // newer open that started while this one was still resolving a destination.
1048
+ // The trial token this page last recorded an edit for, so a burst of
1049
+ // edits writes the marker once.
1050
+ let trialEditNoted = null;
1051
+ function noteTrialEdit(doc) {
1052
+ const token = deps.guestLinkToken?.();
1053
+ if (!token || doc.expiresAt === null || trialEditNoted === token)
1054
+ return;
1055
+ trialEditNoted = token;
1056
+ deps.markTrialEdited?.(token, doc.expiresAt);
1057
+ }
1058
+ // Whether New or Upload may replace the document on screen. A document
1059
+ // made without an account that this browser edited is kept only at its
1060
+ // link, so the visitor is asked first, and signing in is what keeps it.
1061
+ async function mayLeaveTrial() {
1062
+ const token = deps.guestLinkToken?.();
1063
+ const doc = document;
1064
+ if (!token || !doc || doc.expiresAt === null)
1065
+ return true;
1066
+ if (!deps.confirmReplaceTrial || !deps.trialEdited?.(token))
1067
+ return true;
1068
+ const answer = await deps.confirmReplaceTrial(doc.expiresAt);
1069
+ if (answer === 'sign-in')
1070
+ deps.signIn?.();
1071
+ return answer === 'leave';
706
1072
  }
707
1073
  async function create(options) {
1074
+ const generation = openGeneration;
1075
+ try {
1076
+ return await createUnguarded(options);
1077
+ }
1078
+ catch (err) {
1079
+ if (generation === openGeneration && document === null && openError === null) {
1080
+ openError = toDisplayError(err);
1081
+ emit();
1082
+ }
1083
+ throw err;
1084
+ }
1085
+ }
1086
+ async function createUnguarded(options) {
708
1087
  // Placement is expressed by context, never an org this layer names.
709
1088
  // Create-in-place: a New doc anchors on the active one via `sibling_of`,
710
1089
  // resolved server-side (its project, or loose in its org). With no active
@@ -723,29 +1102,45 @@ export function createDocumentSession(deps) {
723
1102
  // one. The server owns the single-vs-multi decision: a `needs-*` outcome
724
1103
  // is where we open the corresponding hosted surface and retry with the
725
1104
  // opaque destination it returns, relayed verbatim.
1105
+ // No account: the document is minted without one and opened through
1106
+ // its link token, which the URL then carries so a reload and a sign-in
1107
+ // both find it again.
1108
+ if (withoutAccount() &&
1109
+ deps.mintAnonymousDocument &&
1110
+ deps.enterGuestMode &&
1111
+ deps.openDocumentByLink) {
1112
+ if (!(await mayLeaveTrial()))
1113
+ return null;
1114
+ const minted = await deps.mintAnonymousDocument(options?.name !== undefined ? { name: options.name } : {});
1115
+ const enterGuestMode = deps.enterGuestMode;
1116
+ const openGuest = deps.openDocumentByLink;
1117
+ accountFreeOpen = true;
1118
+ try {
1119
+ return await activate((onSnapshotProgress, _onUploadProgress, releasePrevious) => {
1120
+ releasePrevious();
1121
+ enterGuestMode(minted.linkToken);
1122
+ return openGuest(minted.linkToken, {
1123
+ ...withDefaults(options?.documentOptions
1124
+ ? { documentOptions: options.documentOptions }
1125
+ : undefined),
1126
+ onSnapshotProgress,
1127
+ });
1128
+ }, options?.name ?? null, 'skip');
1129
+ }
1130
+ finally {
1131
+ accountFreeOpen = false;
1132
+ }
1133
+ }
726
1134
  let args = base;
727
1135
  let knownName = options?.name ?? null;
728
- let result;
729
- for (;;) {
730
- result = await deps.createDocumentId(args);
731
- if (result.kind === 'created')
732
- break;
733
- if (result.kind === 'needs-organization') {
734
- const acquired = await deps.acquireOrganization();
735
- if (!acquired)
736
- return null;
737
- args = { ...args, destination: { organization_id: acquired.organization_id } };
738
- continue;
739
- }
740
- // needs-destination
741
- const destination = await deps.selectCreateDestination(options?.name !== undefined ? { name: options.name } : undefined);
742
- if (destination === null)
743
- return null;
1136
+ const result = await placeThroughHostedSurfaces(deps, (destination) => deps.createDocumentId(destination === undefined ? args : { ...args, destination }), options?.name, (chosen) => {
744
1137
  // The dialog's final name wins on both the wire and the display: the
745
1138
  // user saw and could edit it.
746
- args = { ...args, destination, name: destination.name };
747
- knownName = destination.name ?? knownName;
748
- }
1139
+ args = { ...args, name: chosen.name };
1140
+ knownName = chosen.name;
1141
+ });
1142
+ if (result === null)
1143
+ return null;
749
1144
  const docId = result.docId;
750
1145
  const openOptions = options?.documentOptions
751
1146
  ? { documentOptions: options.documentOptions }
@@ -754,20 +1149,66 @@ export function createDocumentSession(deps) {
754
1149
  // with no name the server owns it (resolving the next free "Untitled N"),
755
1150
  // and a null `knownName` is what lets `resolveActiveDocRecord` adopt the
756
1151
  // stored name once the record binds.
757
- return activate((onSnapshotProgress) => deps.openDocumentById(docId, {
1152
+ return activate((onSnapshotProgress) => openById(docId, {
758
1153
  ...withDefaults(openOptions),
759
1154
  onSnapshotProgress,
760
1155
  }), knownName);
761
1156
  }
1157
+ // The upload runs inside the open, so the app renders its loading surface
1158
+ // with the upload's progress rather than an unchanged screen. A failure
1159
+ // lands on `session.error` the way a failed create's does.
1160
+ async function pickAnonymousFile(choose, mint, enterGuestMode, openGuest) {
1161
+ const generation = openGeneration;
1162
+ let file;
1163
+ try {
1164
+ file = await choose();
1165
+ }
1166
+ catch (err) {
1167
+ // Before `activate` there is no open to abandon, so the refusal is
1168
+ // parked here under `create`'s condition.
1169
+ if (generation === openGeneration && document === null && openError === null) {
1170
+ openError = toDisplayError(err);
1171
+ emit();
1172
+ }
1173
+ throw err;
1174
+ }
1175
+ if (file === null)
1176
+ return null;
1177
+ accountFreeOpen = true;
1178
+ try {
1179
+ return await activate(async (onSnapshotProgress, onUploadProgress, releasePrevious) => {
1180
+ const minted = await mint({ file, onUploadProgress });
1181
+ releasePrevious();
1182
+ enterGuestMode(minted.linkToken);
1183
+ return openGuest(minted.linkToken, {
1184
+ ...withDefaults(),
1185
+ onSnapshotProgress,
1186
+ });
1187
+ }, null, 'skip');
1188
+ }
1189
+ finally {
1190
+ accountFreeOpen = false;
1191
+ emit();
1192
+ }
1193
+ }
762
1194
  async function pick() {
1195
+ if (withoutAccount() &&
1196
+ deps.chooseAnonymousFile &&
1197
+ deps.mintAnonymousDocumentFromFile &&
1198
+ deps.enterGuestMode &&
1199
+ deps.openDocumentByLink) {
1200
+ if (!(await mayLeaveTrial()))
1201
+ return null;
1202
+ return pickAnonymousFile(deps.chooseAnonymousFile, deps.mintAnonymousDocumentFromFile, deps.enterGuestMode, deps.openDocumentByLink);
1203
+ }
763
1204
  const result = await deps.selectDocument();
764
1205
  if (!result)
765
1206
  return null;
766
- const opts = {
1207
+ const opts = inMode({
767
1208
  ...withDefaults(),
768
1209
  ...(result.branch_id ? { branch: result.branch_id } : {}),
769
- };
770
- return activate((onSnapshotProgress) => deps.openDocumentById(result.doc_id, { ...opts, onSnapshotProgress }), result.name, 'push', result.branch_id ?? null);
1210
+ }, undefined);
1211
+ return activate((onSnapshotProgress) => openById(result.doc_id, { ...opts, onSnapshotProgress }), result.name, 'push', result.branch_id ?? null);
771
1212
  }
772
1213
  // Merge `defaultDocumentOptions` into the caller's options: caller values
773
1214
  // win per-field, the default fills gaps, and nested `undo` is shallow-merged
@@ -849,11 +1290,11 @@ export function createDocumentSession(deps) {
849
1290
  urlBranch(current) === sel.branchId) {
850
1291
  return; // already showing this selection
851
1292
  }
852
- void activate((onSnapshotProgress) => deps.openDocumentById(sel.docId, {
1293
+ void activate((onSnapshotProgress) => openById(sel.docId, inMode({
853
1294
  ...withDefaults(),
854
1295
  ...(sel.branchId ? { branch: sel.branchId } : {}),
855
1296
  onSnapshotProgress,
856
- }, 'skip'), null, 'skip', sel.branchId).catch(() => {
1297
+ }, undefined), 'skip'), null, 'skip', sel.branchId).catch(() => {
857
1298
  // Back / Forward to a doc that can't be opened parks the 'error' state
858
1299
  // (or, warm, keeps the current doc); the URL the user navigated to is
859
1300
  // left as-is, so the caught rejection needs no cleanup here.
@@ -877,7 +1318,14 @@ export function createDocumentSession(deps) {
877
1318
  // (opened via `?brass_link=`) is never signed in, so the initial
878
1319
  // resolution arrives here with `signedIn === false`; it must boot the
879
1320
  // read-only link document rather than tear down to the sign-in surface.
1321
+ //
1322
+ // The first resolution to signed-out is the boot of a signed-out page, which
1323
+ // restores the unsaved work this browser made (`restoreUnsavedWork`); a
1324
+ // later sign-out lands on the sign-in surface.
1325
+ let authResolved = false;
880
1326
  deps.onUserChange((signedIn) => {
1327
+ const boot = !authResolved;
1328
+ authResolved = true;
881
1329
  if (signedIn || deps.guestLinkToken?.() != null) {
882
1330
  if (!document)
883
1331
  void start();
@@ -886,6 +1334,10 @@ export function createDocumentSession(deps) {
886
1334
  }
887
1335
  else {
888
1336
  teardownForSignOut();
1337
+ // Deferred: a session made after `init` settled hears this resolution
1338
+ // while it is still being built.
1339
+ if (boot)
1340
+ queueMicrotask(() => void start());
889
1341
  }
890
1342
  });
891
1343
  const session = {
@@ -899,7 +1351,17 @@ export function createDocumentSession(deps) {
899
1351
  return status();
900
1352
  },
901
1353
  get error() {
902
- return status() === 'error' ? openError : null;
1354
+ // Not gated on `status() === 'error'`. A signed-out visitor's failed
1355
+ // create is the case that gating loses: 'signed-out' outranks the error
1356
+ // in `status()` (the app must keep rendering its sign-in surface, which
1357
+ // is where the retry lives), so gating here left the try-before-signing-in
1358
+ // failure with no channel at all.
1359
+ // What makes ungating safe is that a parked error never coexists with a
1360
+ // bound document: `activate` clears it on entry and is the only path that
1361
+ // binds a non-null one, and `abandonOpen` parks only when there was no
1362
+ // document to fall back to. So this can never show a stale failure beside
1363
+ // a working document. A new write to `openError` has to hold that.
1364
+ return openError ?? deps.initFailure?.() ?? null;
903
1365
  },
904
1366
  get loadingMessage() {
905
1367
  return loading ? loadingMessage : null;
@@ -919,19 +1381,38 @@ export function createDocumentSession(deps) {
919
1381
  listener(session);
920
1382
  }
921
1383
  catch (err) {
922
- console.error('[Brass] session listener threw:', err);
1384
+ reportListenerThrow(err);
923
1385
  }
924
1386
  return () => {
925
1387
  listeners.delete(listener);
926
1388
  };
927
1389
  },
928
1390
  start,
929
- open,
1391
+ open: ((docId, options) => docId === undefined ? pick() : open(docId, options)),
930
1392
  create,
931
1393
  pick,
932
1394
  close,
933
1395
  async run(commandId) {
934
1396
  const command = allCommands().find((c) => c.id === commandId);
1397
+ // An app's own signed-out button may still run a withdrawn New or Open,
1398
+ // and the reader is owed the reason rather than a press that does
1399
+ // nothing.
1400
+ if (command &&
1401
+ command.group === 'session' &&
1402
+ !command.enabled &&
1403
+ withoutAccount() &&
1404
+ signedOutRefusal !== null) {
1405
+ // A parked error never sits beside a bound document, so a guest
1406
+ // reading one hears the refusal through `onError` instead.
1407
+ if (document === null) {
1408
+ openError = signedOutRefusal;
1409
+ emit();
1410
+ }
1411
+ else {
1412
+ deps.reportError(signedOutRefusal);
1413
+ }
1414
+ return;
1415
+ }
935
1416
  if (command)
936
1417
  await command.run();
937
1418
  },
@@ -970,8 +1451,9 @@ export function createDocumentSession(deps) {
970
1451
  }
971
1452
  // Already-terminal open failure: reject now rather than wait for a bind
972
1453
  // that isn't coming.
973
- if (status() === 'error' && openError) {
974
- return Promise.reject(openError);
1454
+ const failure = session.error;
1455
+ if (status() === 'error' && failure) {
1456
+ return Promise.reject(failure);
975
1457
  }
976
1458
  return new Promise((resolve, reject) => {
977
1459
  whenReadyPending.push({ resolve, reject });
@@ -989,6 +1471,7 @@ export function createDocumentSession(deps) {
989
1471
  _clearLocalDelete(docId) {
990
1472
  localDeletes.delete(docId);
991
1473
  },
1474
+ _openSibling: openSibling,
992
1475
  };
993
1476
  return session;
994
1477
  }