@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/src/sha256.ts ADDED
@@ -0,0 +1,65 @@
1
+ // SHA-256 over a string's UTF-8 bytes, synchronous and dependency-free, so
2
+ // the browser, the SDK and the Lambdas compute the same digest without
3
+ // `node:crypto` or an async `crypto.subtle` call on a path that must be
4
+ // synchronous (a status keyed by a reference set is computed inside a
5
+ // subscription callback).
6
+
7
+ const K = new Uint32Array([
8
+ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
9
+ 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
10
+ 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
11
+ 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
12
+ 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
13
+ 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
14
+ 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
15
+ 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
16
+ ]);
17
+
18
+ const rotr = (x: number, n: number): number => (x >>> n) | (x << (32 - n));
19
+
20
+ /** Lower-case hex SHA-256 of `input`'s UTF-8 encoding. */
21
+ export function sha256Hex(input: string): string {
22
+ const bytes = new TextEncoder().encode(input);
23
+ const bitLen = bytes.length * 8;
24
+ const padded = new Uint8Array((((bytes.length + 9 + 63) >> 6) << 6));
25
+ padded.set(bytes);
26
+ padded[bytes.length] = 0x80;
27
+ const view = new DataView(padded.buffer);
28
+ // Messages here are far below 2^32 bits, so the high word stays zero.
29
+ view.setUint32(padded.length - 4, bitLen >>> 0);
30
+ view.setUint32(padded.length - 8, Math.floor(bitLen / 0x100000000));
31
+
32
+ const h = new Uint32Array([
33
+ 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
34
+ ]);
35
+ const w = new Uint32Array(64);
36
+ for (let off = 0; off < padded.length; off += 64) {
37
+ for (let i = 0; i < 16; i++) w[i] = view.getUint32(off + i * 4);
38
+ for (let i = 16; i < 64; i++) {
39
+ const a = w[i - 15]!;
40
+ const b = w[i - 2]!;
41
+ const s0 = rotr(a, 7) ^ rotr(a, 18) ^ (a >>> 3);
42
+ const s1 = rotr(b, 17) ^ rotr(b, 19) ^ (b >>> 10);
43
+ w[i] = (w[i - 16]! + s0 + w[i - 7]! + s1) >>> 0;
44
+ }
45
+ let a = h[0]!, b = h[1]!, c = h[2]!, d = h[3]!;
46
+ let e = h[4]!, f = h[5]!, g = h[6]!, hh = h[7]!;
47
+ for (let i = 0; i < 64; i++) {
48
+ const s1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
49
+ const ch = (e & f) ^ (~e & g);
50
+ const t1 = (hh + s1 + ch + K[i]! + w[i]!) >>> 0;
51
+ const s0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
52
+ const maj = (a & b) ^ (a & c) ^ (b & c);
53
+ const t2 = (s0 + maj) >>> 0;
54
+ hh = g; g = f; f = e; e = (d + t1) >>> 0;
55
+ d = c; c = b; b = a; a = (t1 + t2) >>> 0;
56
+ }
57
+ h[0] = (h[0]! + a) >>> 0; h[1] = (h[1]! + b) >>> 0;
58
+ h[2] = (h[2]! + c) >>> 0; h[3] = (h[3]! + d) >>> 0;
59
+ h[4] = (h[4]! + e) >>> 0; h[5] = (h[5]! + f) >>> 0;
60
+ h[6] = (h[6]! + g) >>> 0; h[7] = (h[7]! + hh) >>> 0;
61
+ }
62
+ let out = '';
63
+ for (const word of h) out += word.toString(16).padStart(8, '0');
64
+ return out;
65
+ }
@@ -0,0 +1,235 @@
1
+ // The handles a Document hands an app.
2
+ //
3
+ // A document's storage can change under an open copy, from frozen content to
4
+ // a CRDT when it is edited and back when a frozen copy reloads onto a reseal.
5
+ // Each handle here passes every call to the handle the current storage
6
+ // provides for the same container, and owns its subscriptions, so a swap
7
+ // moves them in one place and the handles behind it never learn of it.
8
+
9
+ import type {
10
+ HandleUnsubscribe,
11
+ ListHandle,
12
+ ListSubscriber,
13
+ MapHandle,
14
+ MapSubscriber,
15
+ OrderedMapHandle,
16
+ TextHandle,
17
+ } from './handle-types.js';
18
+ import { hideOwnFields } from './handles.js';
19
+
20
+ type Call = (...args: unknown[]) => unknown;
21
+ type Backing = Record<string, Call>;
22
+ type Kind = 'map' | 'list' | 'text';
23
+
24
+ interface Scope {
25
+ generation: number;
26
+ // Every handle holding a subscription, which a swap has to move.
27
+ readonly subscribed: Set<StableHandle>;
28
+ readonly report: (err: unknown) => void;
29
+ }
30
+
31
+ interface Subscription {
32
+ cb: Call;
33
+ settled: boolean;
34
+ off: HandleUnsubscribe;
35
+ }
36
+
37
+ /** A map subscriber whose first fire also reports the keys it was handed
38
+ * before and the new container no longer holds, which is what a swap owes
39
+ * a subscriber that moves with it. */
40
+ function reportingLeftKeys<T>(cb: MapSubscriber<T>, before: readonly string[]): MapSubscriber<T> {
41
+ let first = true;
42
+ return (change, origin) => {
43
+ if (first) {
44
+ first = false;
45
+ const left = before.filter((key) => !change.set.has(key));
46
+ if (left.length > 0) change = { ...change, deleted: [...change.deleted, ...left] };
47
+ }
48
+ cb(change, origin);
49
+ };
50
+ }
51
+
52
+ /** The list counterpart: the first fire also removes the items handed before. */
53
+ function replacingHeldItems<T>(cb: ListSubscriber<T>, held: number): ListSubscriber<T> {
54
+ let first = true;
55
+ return (change) => {
56
+ if (first) {
57
+ first = false;
58
+ if (held > 0) {
59
+ const [head, ...rest] = change.splices;
60
+ change =
61
+ head !== undefined && head.at === 0
62
+ ? { splices: [{ ...head, removed: head.removed + held }, ...rest] }
63
+ : { splices: [{ at: 0, removed: held, inserted: [] }, ...change.splices] };
64
+ }
65
+ }
66
+ cb(change);
67
+ };
68
+ }
69
+
70
+ class StableHandle {
71
+ private _backing: Backing;
72
+ private _at: number;
73
+ private readonly _subs = new Set<Subscription>();
74
+ // Keyed by the scoped handle the backing hands out, so whether two calls
75
+ // return the same one is the backing's decision.
76
+ private readonly _children = new WeakMap<object, StableHandle>();
77
+
78
+ constructor(
79
+ private readonly _scope: Scope,
80
+ private readonly _kind: Kind,
81
+ private readonly _resolve: () => unknown,
82
+ backing: unknown = _resolve(),
83
+ ) {
84
+ this._backing = backing as Backing;
85
+ this._at = _scope.generation;
86
+ hideOwnFields(this);
87
+ }
88
+
89
+ private get _h(): Backing {
90
+ if (this._at !== this._scope.generation) {
91
+ this._backing = this._resolve() as Backing;
92
+ this._at = this._scope.generation;
93
+ }
94
+ return this._backing;
95
+ }
96
+
97
+ _call(method: string, args: unknown[]): unknown {
98
+ return this._h[method]!(...args);
99
+ }
100
+
101
+ map(key: string): StableHandle {
102
+ return this._child('map', key);
103
+ }
104
+
105
+ list(key: string): StableHandle {
106
+ return this._child('list', key);
107
+ }
108
+
109
+ private _child(kind: Kind, key: string): StableHandle {
110
+ const backing = this._call(kind, [key]) as object;
111
+ let child = this._children.get(backing);
112
+ if (child === undefined) {
113
+ child = new StableHandle(this._scope, kind, () => this._call(kind, [key]), backing);
114
+ this._children.set(backing, child);
115
+ }
116
+ return child;
117
+ }
118
+
119
+ subscribe(cb: Call): HandleUnsubscribe {
120
+ return this._add(cb, false);
121
+ }
122
+
123
+ onSettled(cb: Call): HandleUnsubscribe {
124
+ return this._add(cb, true);
125
+ }
126
+
127
+ private _add(cb: Call, settled: boolean): HandleUnsubscribe {
128
+ const sub: Subscription = { cb, settled, off: () => {} };
129
+ sub.off = this._call(settled ? 'onSettled' : 'subscribe', [cb]) as HandleUnsubscribe;
130
+ this._subs.add(sub);
131
+ this._scope.subscribed.add(this);
132
+ return () => {
133
+ sub.off();
134
+ this._subs.delete(sub);
135
+ if (this._subs.size === 0) this._scope.subscribed.delete(this);
136
+ };
137
+ }
138
+
139
+ // Take every subscription off the current handle, noting what its
140
+ // subscribers were handed; the returned call registers them with the handle
141
+ // the storage provides by then, as a whole-container delivery.
142
+ _detach(): () => void {
143
+ const held = this._kind === 'text' ? null : this._call(this._kind === 'map' ? 'keys' : 'length', []);
144
+ const moved = (cb: Call): Call =>
145
+ this._kind === 'map'
146
+ ? (reportingLeftKeys(cb as MapSubscriber<unknown>, held as string[]) as Call)
147
+ : this._kind === 'list'
148
+ ? (replacingHeldItems(cb as ListSubscriber<unknown>, held as number) as Call)
149
+ : cb;
150
+ for (const sub of this._subs) sub.off();
151
+ return () => {
152
+ // Subscribers first, so each `onSettled` fires after the delivery it
153
+ // reports.
154
+ for (const settled of [false, true]) {
155
+ for (const sub of this._subs) {
156
+ if (sub.settled !== settled) continue;
157
+ const cb = settled ? sub.cb : moved(sub.cb);
158
+ try {
159
+ sub.off = this._call(settled ? 'onSettled' : 'subscribe', [cb]) as HandleUnsubscribe;
160
+ } catch (err) {
161
+ sub.off = () => {};
162
+ this._scope.report(err);
163
+ }
164
+ }
165
+ }
166
+ };
167
+ }
168
+ }
169
+
170
+ type Passed = Exclude<
171
+ keyof MapHandle | keyof ListHandle | keyof OrderedMapHandle | keyof TextHandle,
172
+ 'subscribe' | 'onSettled' | 'map' | 'list'
173
+ >;
174
+
175
+ // Every other call passes straight through. A record, so a method the handle
176
+ // interfaces gain does not compile until it is listed here.
177
+ const PASSED: Record<Passed | 'read' | '_pageIndexRebuilds', true> = {
178
+ get: true,
179
+ has: true,
180
+ keys: true,
181
+ entries: true,
182
+ values: true,
183
+ length: true,
184
+ read: true,
185
+ ordered: true,
186
+ mutate: true,
187
+ insert: true,
188
+ move: true,
189
+ update: true,
190
+ _pageIndexRebuilds: true,
191
+ };
192
+ for (const method of Object.keys(PASSED)) {
193
+ Object.defineProperty(StableHandle.prototype, method, {
194
+ value(this: StableHandle, ...args: unknown[]): unknown {
195
+ return this._call(method, args);
196
+ },
197
+ });
198
+ }
199
+
200
+ /** The handles one Document has handed out, one per root. */
201
+ export class StableHandles {
202
+ private _scope: Scope;
203
+ private readonly _roots = new Map<string, StableHandle>();
204
+
205
+ constructor(private readonly _report: (err: unknown) => void) {
206
+ this._scope = { generation: 0, subscribed: new Set(), report: _report };
207
+ }
208
+
209
+ /** The handle under `cacheKey`, made over what `resolve` returns. `resolve`
210
+ * is asked again after every `swap`. */
211
+ root<H>(cacheKey: string, kind: Kind, resolve: () => H): H {
212
+ let handle = this._roots.get(cacheKey);
213
+ if (handle === undefined) {
214
+ handle = new StableHandle(this._scope, kind, resolve);
215
+ this._roots.set(cacheKey, handle);
216
+ }
217
+ return handle as unknown as H;
218
+ }
219
+
220
+ /** Change the storage behind every handle: `change` runs while nothing is
221
+ * subscribed to the old storage, and every subscription then moves to the
222
+ * new one. */
223
+ swap(change: () => void): void {
224
+ const moves = Array.from(this._scope.subscribed, (handle) => handle._detach());
225
+ change();
226
+ this._scope.generation++;
227
+ for (const move of moves) move();
228
+ }
229
+
230
+ /** Forget every handle. One an app still holds stays on the storage it had. */
231
+ clear(): void {
232
+ this._roots.clear();
233
+ this._scope = { generation: 0, subscribed: new Set(), report: this._report };
234
+ }
235
+ }
@@ -0,0 +1,90 @@
1
+ // Waiting on a staged upload's seal. The platform answers `202 { seal_id }`
2
+ // once the upload is queued and seals it afterwards; the caller resolves or
3
+ // rejects on the outcome, exactly as it did on the synchronous answer.
4
+
5
+ import { brassErrorFromBody } from './api-fetch.js';
6
+ import { BrassError } from './errors.js';
7
+
8
+ /** @internal */
9
+ export type SealStatus =
10
+ | { state: 'pending' }
11
+ | { state: 'stored'; body: unknown }
12
+ | {
13
+ state: 'refused';
14
+ status: number;
15
+ error: string;
16
+ code?: string;
17
+ import_failed?: { message: string };
18
+ };
19
+
20
+ /** @internal A seal the platform refused. `importFailed` is the failure it
21
+ * recorded on the document when the seal completed an import. */
22
+ export class SealRefusedError extends BrassError {
23
+ readonly importFailed?: string;
24
+
25
+ constructor(refused: Extract<SealStatus, { state: 'refused' }>) {
26
+ const base = brassErrorFromBody(refused.status, {
27
+ error: refused.error,
28
+ ...(refused.code !== undefined ? { code: refused.code } : {}),
29
+ });
30
+ super(base.message, {
31
+ ...(base.status !== undefined ? { status: base.status } : {}),
32
+ ...(base.code !== undefined ? { code: base.code } : {}),
33
+ });
34
+ if (refused.import_failed !== undefined) {
35
+ this.importFailed = refused.import_failed.message;
36
+ }
37
+ }
38
+ }
39
+
40
+ // The worker may take both of the queue's deliveries before the DLQ drain
41
+ // records a refusal, so the wait outlasts them.
42
+ const SEAL_WAIT_MS = 40 * 60 * 1000;
43
+ const SEAL_POLL_MS = [250, 500, 1000, 1000, 2000];
44
+ const MAX_FAILED_READS = 10;
45
+
46
+ function retryable(status: number | undefined): boolean {
47
+ return status === undefined || status === 429 || status >= 500;
48
+ }
49
+
50
+ function sealIdOf(accepted: unknown): string | null {
51
+ if (typeof accepted !== 'object' || accepted === null) return null;
52
+ const id = (accepted as { seal_id?: unknown }).seal_id;
53
+ return typeof id === 'string' && id !== '' ? id : null;
54
+ }
55
+
56
+ /** @internal Resolve with the stored outcome's body once the seal `accepted`
57
+ * names has landed, or throw its refusal. A body naming no seal is a server
58
+ * that sealed synchronously, and is the answer itself. */
59
+ export async function awaitSeal<T>(
60
+ accepted: unknown,
61
+ read: (sealId: string) => Promise<SealStatus>,
62
+ ): Promise<T> {
63
+ const sealId = sealIdOf(accepted);
64
+ if (sealId === null) return accepted as T;
65
+ const deadline = Date.now() + SEAL_WAIT_MS;
66
+ let failedReads = 0;
67
+ for (let attempt = 0; ; attempt += 1) {
68
+ let status: SealStatus | null = null;
69
+ try {
70
+ status = await read(sealId);
71
+ failedReads = 0;
72
+ } catch (err) {
73
+ // A read that failed in transit says nothing about the seal, which goes
74
+ // on regardless; a refusal of the read itself, or a run of failures,
75
+ // ends the wait.
76
+ failedReads += 1;
77
+ if (err instanceof BrassError && !retryable(err.status)) throw err;
78
+ if (failedReads >= MAX_FAILED_READS) throw err;
79
+ }
80
+ if (status?.state === 'stored') return status.body as T;
81
+ if (status?.state === 'refused') throw new SealRefusedError(status);
82
+ if (Date.now() >= deadline) {
83
+ throw new BrassError('The upload is taking too long to store. Try again.', {
84
+ status: 504,
85
+ });
86
+ }
87
+ const delay = SEAL_POLL_MS[Math.min(attempt, SEAL_POLL_MS.length - 1)]!;
88
+ await new Promise((resolve) => setTimeout(resolve, delay));
89
+ }
90
+ }
@@ -0,0 +1,37 @@
1
+ // What every handle kind shares about handing data to an app's callback.
2
+
3
+ import type { Splice } from './handle-types.js';
4
+
5
+ // The natural apply is `rows.splice(at, removed, ...inserted)`, and a spread
6
+ // passes every item as a call argument, which overflows the engine's stack
7
+ // somewhere past tens of thousands of items. A large insert is therefore
8
+ // delivered as consecutive splices, which applied in order make the same edit.
9
+ const SPLICE_MAX_INSERTED = 10_000;
10
+
11
+ export function pushBoundedSplice<E>(out: Splice<E>[], splice: Splice<E>): void {
12
+ const { at, removed, inserted } = splice;
13
+ if (inserted.length <= SPLICE_MAX_INSERTED) {
14
+ out.push(splice);
15
+ return;
16
+ }
17
+ for (let i = 0; i < inserted.length; i += SPLICE_MAX_INSERTED) {
18
+ out.push({
19
+ at: at + i,
20
+ removed: i === 0 ? removed : 0,
21
+ inserted: inserted.slice(i, i + SPLICE_MAX_INSERTED),
22
+ });
23
+ }
24
+ }
25
+
26
+ /** The error `Brass.onError` receives when an app's own callback threw while
27
+ * being handed a document or a change to one. The app usually shows an empty
28
+ * or stale view after that, so the sentence is one it can show the reader;
29
+ * the throw rides as `cause`. */
30
+ export function appCallbackError(err: unknown): Error {
31
+ const detail = err instanceof Error ? err.message : String(err);
32
+ const report: Error & { cause?: unknown } = new Error(
33
+ `The app could not display a change: ${detail}`,
34
+ );
35
+ report.cause = err;
36
+ return report;
37
+ }
@@ -0,0 +1,30 @@
1
+ import { LoroDoc } from './wasm-runtime.js';
2
+
3
+ // An op whose dependencies are missing still imports here (as pending), so a
4
+ // refusal means the bytes are not an update anything could apply.
5
+ function importsIntoEmptyDoc(blob: Uint8Array): boolean {
6
+ const probe = new LoroDoc();
7
+ try {
8
+ probe.import(blob);
9
+ return true;
10
+ } catch {
11
+ return false;
12
+ } finally {
13
+ probe.free();
14
+ }
15
+ }
16
+
17
+ /**
18
+ * Import one op from a join's tail or a live delivery. Bytes that do not decode
19
+ * even into an empty document can never be applied by any peer, and compaction
20
+ * seals past them, so they are skipped: one such op in a tail must not fail
21
+ * every open until that seal. An op that fails only against `engine`'s state
22
+ * throws.
23
+ */
24
+ export function importTailOp(engine: LoroDoc, blob: Uint8Array): void {
25
+ try {
26
+ engine.import(blob);
27
+ } catch (err) {
28
+ if (importsIntoEmptyDoc(blob)) throw err;
29
+ }
30
+ }
@@ -21,6 +21,14 @@
21
21
  // that: it describes the frame the server could not process, not the
22
22
  // subscription it arrived on, so every live phase keeps its state and hands
23
23
  // the error to the topic's callbacks, which hold the frame that drew it.
24
+ // While the subscribe is unacked the subscribe may be the frame that drew it,
25
+ // so the client also sends that again after a backoff (a duplicate subscribe
26
+ // is idempotent on the server).
27
+ //
28
+ // A `server_ended` event (the server evicted every subscriber on the topic
29
+ // and deleted their rows) returns any phase to `idle` and detaches the
30
+ // topic's callbacks, so whoever subscribes next sends a subscribe of its own
31
+ // rather than attaching to a subscription the server no longer holds.
24
32
  //
25
33
  // Invariant: every transition into `tornDown` emits `send_unsubscribe`,
26
34
  // so no server-side subscription row can outlive the client-side state
@@ -49,10 +57,17 @@ export type TopicEvent =
49
57
  | { type: 'socket_open' }
50
58
  | { type: 'socket_closed' }
51
59
  | { type: 'subscribed_ack' }
60
+ | { type: 'server_ended' }
61
+ // The server reports it holds no subscription row for an established topic
62
+ // while the socket stays up (a presence row reaped as stale).
63
+ | { type: 'server_dropped' }
52
64
  | { type: 'error'; reason: string };
53
65
 
54
66
  export type TopicEffect =
55
67
  | { kind: 'send_subscribe' }
68
+ // Send the subscribe again after the client's backoff: the server could
69
+ // not finish the one in flight.
70
+ | { kind: 'resend_subscribe_later' }
56
71
  | { kind: 'send_unsubscribe' }
57
72
  // Flush the frames held for the unacked subscribe, in order.
58
73
  | { kind: 'flush_held' }
@@ -87,6 +102,7 @@ export function isFrameScopedReason(reason: string): boolean {
87
102
  }
88
103
 
89
104
  const SEND_SUBSCRIBE: TopicEffect = { kind: 'send_subscribe' };
105
+ const RESEND_SUBSCRIBE_LATER: TopicEffect = { kind: 'resend_subscribe_later' };
90
106
  const FLUSH_HELD: TopicEffect = { kind: 'flush_held' };
91
107
  const DROP_HELD: TopicEffect = { kind: 'drop_held' };
92
108
 
@@ -126,6 +142,10 @@ function unsubscribed(): StepResult {
126
142
  }
127
143
 
128
144
  export function step(state: TopicState, event: TopicEvent): StepResult {
145
+ if (event.type === 'server_ended') {
146
+ if (state.phase === 'idle') return { state, effects: [] };
147
+ return { state: IDLE_TOPIC, effects: [DROP_HELD, { kind: 'drop_listeners' }] };
148
+ }
129
149
  switch (state.phase) {
130
150
  case 'idle':
131
151
  if (event.type === 'user_subscribe') {
@@ -145,7 +165,13 @@ export function step(state: TopicState, event: TopicEvent): StepResult {
145
165
  };
146
166
  case 'error':
147
167
  if (isFrameScopedReason(event.reason)) {
148
- return deliverError(state, event.reason);
168
+ return {
169
+ state,
170
+ effects: [
171
+ { kind: 'deliver_error', reason: event.reason },
172
+ RESEND_SUBSCRIBE_LATER,
173
+ ],
174
+ };
149
175
  }
150
176
  // Never established on this connection: the rejection is final.
151
177
  return tearDown(state, event.reason);
@@ -182,6 +208,10 @@ export function step(state: TopicState, event: TopicEvent): StepResult {
182
208
  return tearDown(state, event.reason);
183
209
  case 'subscribed_ack':
184
210
  return { state, effects: [FLUSH_HELD] };
211
+ // Subscribe again, holding frames until it is acked. It spends no
212
+ // retry: the report arrives once per heartbeat, so it cannot loop.
213
+ case 'server_dropped':
214
+ return { state: { ...state, phase: 'subscribing' }, effects: [SEND_SUBSCRIBE] };
185
215
  case 'socket_open':
186
216
  return reopened();
187
217
  case 'socket_closed':
@@ -197,7 +227,13 @@ export function step(state: TopicState, event: TopicEvent): StepResult {
197
227
  switch (event.type) {
198
228
  case 'error':
199
229
  if (isFrameScopedReason(event.reason)) {
200
- return deliverError(state, event.reason);
230
+ return {
231
+ state,
232
+ effects: [
233
+ { kind: 'deliver_error', reason: event.reason },
234
+ RESEND_SUBSCRIBE_LATER,
235
+ ],
236
+ };
201
237
  }
202
238
  // An eviction draws errors for EVERY frame in flight against the
203
239
  // reaped row; the trailing ones land while the retry's subscribe