@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
@@ -0,0 +1,38 @@
1
+ // A page the browser restores from its back-forward cache resumes with the
2
+ // state it was frozen in, and nothing it held can be trusted to still be true.
3
+ // Two things go stale with no event to say so: a link this page was a guest
4
+ // on can have been claimed or revoked while it was frozen, and the person can
5
+ // have signed in or out in the page they navigated to. Either way the page
6
+ // shows someone the wrong document under the wrong identity, so it loads
7
+ // again, which asks both questions afresh.
8
+
9
+ export interface RestoredPageState {
10
+ /** The link token this page is a guest on, or null. */
11
+ guestLinkToken: string | null;
12
+ /** Whether this page resolved a signed-in person. */
13
+ signedIn: boolean;
14
+ /** Whether this browser's stored hint says a session exists for the app. */
15
+ sessionHinted: boolean;
16
+ }
17
+
18
+ // Whether a guest link still answers. `null` when the question could not be
19
+ // asked (no network), which keeps the page rather than reloading it offline.
20
+ export type LinkStillOpen = (token: string) => Promise<boolean | null>;
21
+
22
+ export async function restoredPageIsStale(
23
+ state: RestoredPageState,
24
+ linkStillOpen: LinkStillOpen,
25
+ ): Promise<boolean> {
26
+ if (state.guestLinkToken !== null) {
27
+ return (await linkStillOpen(state.guestLinkToken)) === false;
28
+ }
29
+ return state.signedIn !== state.sessionHinted;
30
+ }
31
+
32
+ // Calls `onRestore` for every show that comes out of the back-forward cache.
33
+ export function onPageRestored(onRestore: () => void): void {
34
+ if (typeof window === 'undefined' || typeof window.addEventListener !== 'function') return;
35
+ window.addEventListener('pageshow', (event) => {
36
+ if ((event as { persisted?: boolean }).persisted === true) onRestore();
37
+ });
38
+ }
@@ -0,0 +1,54 @@
1
+ // The loop that places a document through the hosted surfaces: run an
2
+ // attempt, open the surface each `needs-*` outcome names, and retry with
3
+ // what it returned. Shared by the session's create and the client's claim of
4
+ // a document created without an account, so the two cannot drift on which
5
+ // surface answers which refusal.
6
+
7
+ import type {
8
+ AcquireOrganizationResult,
9
+ CreateDestinationResult,
10
+ } from './hosted-ui.js';
11
+
12
+ export interface PlacementSurfaces {
13
+ acquireOrganization(): Promise<AcquireOrganizationResult | null>;
14
+ selectCreateDestination(options?: {
15
+ name?: string;
16
+ }): Promise<CreateDestinationResult | null>;
17
+ }
18
+
19
+ // Run `attempt` until the server places the document. `needs-organization`
20
+ // opens the acquire surface and retries with `{ organization_id }`;
21
+ // `needs-destination` opens the destination dialog (pre-filled with `name`)
22
+ // and retries with its opaque result, relayed verbatim. Resolves null when
23
+ // the person dismisses a surface. `onDestination` hears the dialog's result,
24
+ // which also carries the name the person settled on.
25
+ export async function placeThroughHostedSurfaces<R extends { kind: string }>(
26
+ surfaces: PlacementSurfaces,
27
+ attempt: (destination: unknown) => Promise<R>,
28
+ name: string | undefined,
29
+ onDestination?: (chosen: CreateDestinationResult) => void,
30
+ ): Promise<Exclude<R, { kind: 'needs-organization' | 'needs-destination' }> | null> {
31
+ let destination: unknown = undefined;
32
+ for (;;) {
33
+ const result = await attempt(destination);
34
+ if (result.kind === 'needs-organization') {
35
+ const acquired = await surfaces.acquireOrganization();
36
+ if (!acquired) return null;
37
+ destination = { organization_id: acquired.organization_id };
38
+ continue;
39
+ }
40
+ if (result.kind === 'needs-destination') {
41
+ const chosen = await surfaces.selectCreateDestination(
42
+ name !== undefined ? { name } : undefined,
43
+ );
44
+ if (chosen === null) return null;
45
+ onDestination?.(chosen);
46
+ destination = chosen;
47
+ continue;
48
+ }
49
+ return result as Exclude<
50
+ R,
51
+ { kind: 'needs-organization' | 'needs-destination' }
52
+ >;
53
+ }
54
+ }
package/src/presence.ts CHANGED
@@ -23,7 +23,7 @@ export const PRESENCE_PUBLISH_INTERVAL_MS = 100;
23
23
  */
24
24
  export const PRESENCE_LAYER_MAX_BYTES = 2048;
25
25
 
26
- const PRESENCE_TOPIC_PREFIX = 'presence:doc:';
26
+ export const PRESENCE_TOPIC_PREFIX = 'presence:doc:';
27
27
 
28
28
  const TEXT_ENCODER = new TextEncoder();
29
29
 
@@ -107,7 +107,8 @@ export interface PresenceParticipant {
107
107
 
108
108
  // The partial payload passed to `presence.set(...)`. Either layer may
109
109
  // be present; each is shallow-merged into the client's current self
110
- // payload, so `set({ cursor })` then `set({ camera })` keeps both.
110
+ // payload, so `set({ cursor })` then `set({ camera })` keeps both. A key
111
+ // set to `undefined` is removed from the layer.
111
112
  export interface PresenceSetInput {
112
113
  state?: Record<string, unknown>;
113
114
  ext?: Record<string, unknown>;
@@ -264,6 +265,19 @@ function splitPresencePayload(payload: unknown): {
264
265
  };
265
266
  }
266
267
 
268
+ // Shallow-merge `input` into a layer. A key set to `undefined` is removed, so
269
+ // the payload held here is exactly the one peers are sent.
270
+ function mergeLayer(
271
+ current: Record<string, unknown>,
272
+ input: Record<string, unknown>,
273
+ ): Record<string, unknown> {
274
+ const next = { ...current, ...input };
275
+ for (const [key, value] of Object.entries(input)) {
276
+ if (value === undefined) delete next[key];
277
+ }
278
+ return next;
279
+ }
280
+
267
281
  class PresenceController implements PresenceClient {
268
282
  private readonly _topic: string;
269
283
  private readonly _roster = new Map<string, PresenceParticipant>();
@@ -349,10 +363,10 @@ class PresenceController implements PresenceClient {
349
363
  set(input: PresenceSetInput): void {
350
364
  if (this._closed) return;
351
365
  if (input.state !== undefined) {
352
- this._selfState = { ...this._selfState, ...input.state };
366
+ this._selfState = mergeLayer(this._selfState, input.state);
353
367
  }
354
368
  if (input.ext !== undefined) {
355
- this._selfExt = { ...this._selfExt, ...input.ext };
369
+ this._selfExt = mergeLayer(this._selfExt, input.ext);
356
370
  }
357
371
  this._scheduleSend();
358
372
  }
@@ -639,7 +653,9 @@ class PresenceController implements PresenceClient {
639
653
  lastSent: Record<string, unknown> | null,
640
654
  withheld: PresenceLayer[],
641
655
  ): Record<string, unknown> | undefined {
642
- if (Object.keys(current).length === 0) return undefined;
656
+ // A layer emptied by removing its keys still has to reach peers, who
657
+ // otherwise keep the last one this connection published.
658
+ if (Object.keys(current).length === 0) return lastSent === null ? undefined : current;
643
659
  if (presenceLayerBytes(current) <= PRESENCE_LAYER_MAX_BYTES) return current;
644
660
  withheld.push(layer);
645
661
  return lastSent ?? undefined;
@@ -0,0 +1,133 @@
1
+ // The document types an open that cannot write returns, so writing through
2
+ // one is a type error rather than a thrown `mutate`.
3
+
4
+ import type {
5
+ ListHandle,
6
+ MapHandle,
7
+ OrderedMapHandle,
8
+ TextHandle,
9
+ } from './handle-types.js';
10
+ import type { OpenDocumentByIdOptions, OpenedDocument } from './open-document.js';
11
+ import type { DocumentUnsubscribe } from './documents.js';
12
+ import type { DocumentStream } from './document-streams.js';
13
+
14
+ /** A map on a `ReadOnlyDocument`: the reads of `MapHandle`, and no writes. */
15
+ export type ReadOnlyMapHandle<T = unknown> = Omit<MapHandle<T>, 'mutate' | 'map' | 'list'> & {
16
+ map<U = unknown>(entryKey: string): ReadOnlyMapHandle<U>;
17
+ list<U = unknown>(entryKey: string): ReadOnlyListHandle<U>;
18
+ };
19
+
20
+ /** A list on a `ReadOnlyDocument`: the reads of `ListHandle`, and no writes. */
21
+ export type ReadOnlyListHandle<T = unknown> = Omit<ListHandle<T>, 'mutate'>;
22
+
23
+ /** An ordered map on a `ReadOnlyDocument`, with no writes. */
24
+ export type ReadOnlyOrderedMapHandle<T = unknown> = Omit<
25
+ OrderedMapHandle<T>,
26
+ 'mutate' | 'insert' | 'move' | 'ordered'
27
+ >;
28
+
29
+ /** A text on a `ReadOnlyDocument`, with no writes. */
30
+ export type ReadOnlyTextHandle = Omit<TextHandle, 'update'>;
31
+
32
+ /** Members of a document that write to it. */
33
+ type WritingMembers =
34
+ | 'mutate'
35
+ | 'undo'
36
+ | 'redo'
37
+ | 'createCheckpoint'
38
+ | 'setAutoCheckpointIntervalMs'
39
+ | 'getOrCreateStream'
40
+ | 'stream'
41
+ | 'useCanvas'
42
+ | 'map'
43
+ | 'list'
44
+ | 'text'
45
+ | 'orderedMap'
46
+ | 'dependsOn'
47
+ | 'markRead';
48
+
49
+ /** Members that report writing, which a copy that cannot write never does. */
50
+ type WriterStateMembers = 'savingCount' | 'subscribeUndoState';
51
+
52
+ /** Members of a stream that write to it. */
53
+ type WritingStreamMembers =
54
+ | 'mutate'
55
+ | 'undo'
56
+ | 'redo'
57
+ | 'sealUndoStep'
58
+ | 'map'
59
+ | 'list'
60
+ | 'text'
61
+ | 'orderedMap'
62
+ | 'dependsOn'
63
+ | 'markRead';
64
+
65
+ /** Members whose meaning the update mode decides. */
66
+ type UpdateModeMembers = 'changed' | 'onChanged' | 'reload';
67
+
68
+ /** One stream of a `ReadOnlyDocument`. */
69
+ export type ReadOnlyStream = Omit<
70
+ DocumentStream,
71
+ WritingStreamMembers | 'savingCount' | UpdateModeMembers
72
+ > & {
73
+ /** On a frozen copy, called when this stream moves past what it last
74
+ * loaded, at once if it already has. Each stream is its own line, so each
75
+ * is watched on its own. */
76
+ onChanged(cb: () => void): DocumentUnsubscribe;
77
+ reload(): Promise<void>;
78
+ map<T = unknown>(key: string): ReadOnlyMapHandle<T>;
79
+ list<T = unknown>(key: string): ReadOnlyListHandle<T>;
80
+ text(key: string): ReadOnlyTextHandle;
81
+ orderedMap<T = unknown>(mapKey: string, orderKey: string): ReadOnlyOrderedMapHandle<T>;
82
+ };
83
+
84
+ /**
85
+ * A document opened with `readOnly: true` or `frozen: true`, with nothing on
86
+ * it that writes. A document the signed-in person may only view opens
87
+ * read-only whatever was asked, which `doc.readOnly` reports and a refused
88
+ * `mutate` throws on.
89
+ *
90
+ * A live copy follows every change as it is made. A frozen copy keeps what it
91
+ * loaded: `onChanged` reports the document moving past it, and `reload()`
92
+ * loads the document as it stands, under the same handles, whose subscribers
93
+ * are told what changed.
94
+ */
95
+ export type ReadOnlyDocument = Omit<
96
+ OpenedDocument,
97
+ WritingMembers | WriterStateMembers | UpdateModeMembers
98
+ > & {
99
+ readonly readOnly: true;
100
+ /** On a frozen copy, called when the document moves past what this copy
101
+ * last loaded (a content edit, an import landing or failing, the document
102
+ * being deleted), at once if it already has. Called once per load. A live
103
+ * copy never calls it. */
104
+ onChanged(cb: () => void): DocumentUnsubscribe;
105
+ /** On a frozen copy, loads the document as it stands now. A live copy
106
+ * already holds it, so this resolves at once. */
107
+ reload(): Promise<void>;
108
+ map<T = unknown>(key: string): ReadOnlyMapHandle<T>;
109
+ list<T = unknown>(key: string): ReadOnlyListHandle<T>;
110
+ text(key: string): ReadOnlyTextHandle;
111
+ orderedMap<T = unknown>(mapKey: string, orderKey: string): ReadOnlyOrderedMapHandle<T>;
112
+ stream(name: string): Promise<ReadOnlyStream>;
113
+ };
114
+
115
+ /** The modes an open or a reopen can ask for. A frozen copy is read-only, so
116
+ * `frozen: true` takes no `readOnly: false`. */
117
+ export type OpenModeOptions =
118
+ | { frozen?: false; readOnly?: boolean }
119
+ | { frozen: boolean; readOnly?: true };
120
+
121
+ /** `Brass.openDocumentById`, typed by what the open asks for. */
122
+ export interface OpenDocumentById {
123
+ (
124
+ docId: string,
125
+ options: OpenDocumentByIdOptions & { frozen: true; readOnly?: true },
126
+ ): Promise<ReadOnlyDocument>;
127
+ (docId: string, options: OpenDocumentByIdOptions & { readOnly: true }): Promise<ReadOnlyDocument>;
128
+ (docId: string, options?: OpenDocumentByIdOptions & { frozen?: false }): Promise<OpenedDocument>;
129
+ (
130
+ docId: string,
131
+ options: Omit<OpenDocumentByIdOptions, 'frozen' | 'readOnly'> & OpenModeOptions,
132
+ ): Promise<OpenedDocument | ReadOnlyDocument>;
133
+ }
@@ -79,8 +79,14 @@ function gunzip(bytes: Uint8Array): Uint8Array {
79
79
  export const TAG_FIELD = 'type';
80
80
  export const BODY_FIELD = 'params';
81
81
 
82
- /** Root container keys the record store occupies on a document. */
82
+ /** Root container keys the record store occupies on a document, and the one
83
+ * `meta` entry that is not paging bookkeeping. Fixed names rather than a
84
+ * document's configurable `rootKey`: a record store is one shape wherever it
85
+ * is written, so the encoding it is written in cannot change where a reader
86
+ * looks for it. */
83
87
  export const META_KEY = 'meta';
88
+ export const SCHEMA_VERSION_KEY = 'schema_version';
89
+ export const HEADER_KEY = 'header';
84
90
  export const RECORDS_KEY = 'instances';
85
91
  export const ORDER_KEY = 'instance_order';
86
92
 
@@ -172,9 +178,29 @@ export const PAGE_TAG = '__page__';
172
178
  * holds. */
173
179
  export const RESERVED_KEY_PREFIX = 'brass:';
174
180
 
175
- /** Whether `key` names one of the store's own entries rather than a record. */
176
- export function isReservedKey(key: string): boolean {
177
- return key.startsWith(RESERVED_KEY_PREFIX);
181
+ /** Whether `key` in the root map `rootKey` is a paged marker
182
+ * (`PAGED_ROOT_PREFIX`). `meta` is a root an app reads and writes like any
183
+ * other (an import keeps `schema_version` there), so the markers are hidden
184
+ * from every read of it and refused on write: an app that copies or clears
185
+ * `meta` generically would otherwise delete one, and every reader after that
186
+ * takes the unpaged path over a map holding pages, seeing the store's
187
+ * `brass:` keys in place of its records. A `paged:` key in any other root is
188
+ * an ordinary record key. */
189
+ export function isPagedMarkerEntry(rootKey: string | null, key: string): boolean {
190
+ return rootCarriesMarkers(rootKey) && key.startsWith(PAGED_ROOT_PREFIX);
191
+ }
192
+
193
+ /** Whether the root map `rootKey` is the one the paged markers are kept in,
194
+ * so a reader can skip the per-key test on every other root. */
195
+ export function rootCarriesMarkers(rootKey: string | null): boolean {
196
+ return rootKey === META_KEY;
197
+ }
198
+
199
+ /** Whether `key` names one of the platform's own entries rather than a record:
200
+ * the store's bookkeeping under `RESERVED_KEY_PREFIX` in any root, and, when
201
+ * the root is named, a paged marker in `meta`. */
202
+ export function isReservedKey(key: string, rootKey: string | null = null): boolean {
203
+ return key.startsWith(RESERVED_KEY_PREFIX) || isPagedMarkerEntry(rootKey, key);
178
204
  }
179
205
 
180
206
  /** The one key a reader cannot carry as an ordinary object property.
@@ -197,7 +223,10 @@ const UNCARRIABLE_RECORD_KEY = '__proto__';
197
223
  * is from the key alone. The alternative, escaping, would put the escape on
198
224
  * the read of every key of every map, paged or not, and a store that pages
199
225
  * later cannot escape what it already holds. */
200
- export function assertUsableRecordKey(key: string): void {
226
+ export function assertUsableRecordKey(
227
+ key: string,
228
+ rootKey: string | null = null,
229
+ ): void {
201
230
  if (key === UNCARRIABLE_RECORD_KEY) {
202
231
  throw new Error(
203
232
  `Key "${key}" is reserved: a record key cannot be ` +
@@ -205,6 +234,12 @@ export function assertUsableRecordKey(key: string): void {
205
234
  `rather than one of its entries.`,
206
235
  );
207
236
  }
237
+ if (isPagedMarkerEntry(rootKey, key)) {
238
+ throw new Error(
239
+ `Key "${key}" is reserved: a key in "${META_KEY}" cannot start with ` +
240
+ `"${PAGED_ROOT_PREFIX}", which marks how a collection is stored.`,
241
+ );
242
+ }
208
243
  if (!isReservedKey(key)) return;
209
244
  throw new Error(
210
245
  `Key "${key}" is reserved: a record key cannot start with ` +
@@ -257,11 +292,25 @@ export function repackRecord(
257
292
  ];
258
293
  }
259
294
 
260
- /** The stored value a page triple carries: the value itself for a whole-value
261
- * record, else the `{ type, params }` pair a typed one was split into. */
295
+ /**
296
+ * The stored value a tag and a body carry: the body itself when there is no
297
+ * tag, else the `{ type, params }` pair a typed record was split into.
298
+ *
299
+ * A null tag is not an absent field, it is the statement that this record was
300
+ * never split: a map holds whatever its writer assigned and an app's own value
301
+ * has no tag to take off it. Every reader that rebuilds a value from the two
302
+ * comes through here, because a reader that built the pair unconditionally
303
+ * would hand an app `{ type: null, params: 5 }` where the same record read out
304
+ * of a CRDT document is `5`, which is the encoding showing through.
305
+ */
306
+ export function recordValueOf(tag: string | null, body: unknown): unknown {
307
+ if (tag === null) return body;
308
+ return { [TAG_FIELD]: tag, [BODY_FIELD]: body };
309
+ }
310
+
311
+ /** The stored value a page triple carries. See `recordValueOf`. */
262
312
  export function unpackRecord(packed: PackedRecord<unknown>): unknown {
263
- if (packed[1] === null) return packed[2];
264
- return { [TAG_FIELD]: packed[1], [BODY_FIELD]: packed[2] };
313
+ return recordValueOf(packed[1], packed[2]);
265
314
  }
266
315
 
267
316
  /** The tag + body a page triple names, for a reader that walks the two
@@ -388,40 +437,120 @@ export function encodePageFromEncoded(
388
437
  encoded: string[],
389
438
  keys: string[],
390
439
  ): EncodedPage {
440
+ const bytes = encodePageBytes(encoded, keys);
441
+ return {
442
+ body: strFromU8(bytes.body, true),
443
+ keys: strFromU8(bytes.keys, true),
444
+ chars: bytes.chars,
445
+ };
446
+ }
447
+
448
+ /** A page's two stored strings as raw bytes, for a store that holds bytes
449
+ * rather than the latin1 strings a loro container needs. */
450
+ export interface EncodedPageBytes {
451
+ body: Uint8Array;
452
+ keys: Uint8Array;
453
+ chars: number;
454
+ }
455
+
456
+ /** Encode a page to the gzip bytes `encodePageFromEncoded` widens into
457
+ * strings. Byte-identical either way, so a page written into a container and
458
+ * a page written into a byte store hold the same compressed run. */
459
+ export function encodePageBytes(
460
+ encoded: string[],
461
+ keys: string[],
462
+ ): EncodedPageBytes {
391
463
  const body = '[' + encoded.join(',') + ']';
392
464
  return {
393
- body: gzipJson(body),
394
- keys: gzipJson(JSON.stringify(keys)),
465
+ body: gzipJsonBytes(body),
466
+ keys: gzipJsonBytes(JSON.stringify(keys)),
395
467
  chars: body.length,
396
468
  };
397
469
  }
398
470
 
399
- function gzipJson(json: string): string {
400
- return strFromU8(gzip(strToU8(json)), true);
471
+ /** Gzip a JSON string to raw bytes. The latin1 string a page container stores
472
+ * is these bytes widened; a store that holds bytes directly keeps them as
473
+ * they are, so one compressor serves both. */
474
+ export function gzipJsonBytes(json: string): Uint8Array {
475
+ return gzip(strToU8(json));
401
476
  }
402
477
 
403
- /** Parse one of a page's stored strings. Returns null for anything that does
478
+ /** Gunzip bytes holding UTF-8 text. */
479
+ export function gunzipText(bytes: Uint8Array): string {
480
+ return strFromU8(gunzip(bytes));
481
+ }
482
+
483
+ /** Parse gzipped JSON bytes as an array. Returns null for anything that does
404
484
  * not decompress to a JSON array, so a reader tolerates a corrupt or stray
405
485
  * page rather than throwing. */
406
- function decodePageField(value: unknown): unknown[] | null {
407
- if (typeof value !== 'string') return null;
486
+ export function decodeGzippedJsonArray(bytes: Uint8Array): unknown[] | null {
408
487
  try {
409
- const parsed = JSON.parse(strFromU8(gunzip(strToU8(value, true))));
488
+ const parsed: unknown = JSON.parse(strFromU8(gunzip(bytes)));
410
489
  return Array.isArray(parsed) ? (parsed as unknown[]) : null;
411
490
  } catch {
412
491
  return null;
413
492
  }
414
493
  }
415
494
 
495
+ /** Parse one of a page's stored strings. Returns null for anything that does
496
+ * not decompress to a JSON array, so a reader tolerates a corrupt or stray
497
+ * page rather than throwing. */
498
+ function decodePageField(value: unknown): unknown[] | null {
499
+ if (typeof value !== 'string') return null;
500
+ return decodeGzippedJsonArray(strToU8(value, true));
501
+ }
502
+
416
503
  /** The record keys a page holds, from the page's own `keys` string. Reading
417
504
  * this field alone is what makes learning a store's key locations cost the
418
505
  * keys rather than every record body. */
419
506
  export function decodePageKeys(keysField: unknown): string[] {
420
507
  const parsed = decodePageField(keysField);
421
508
  if (parsed === null) return [];
509
+ return keyStrings(parsed);
510
+ }
511
+
512
+ /** The record keys a page holds, from the gzipped bytes of its key list. The
513
+ * same answer `decodePageKeys` gives for a container-stored page. */
514
+ export function decodePageKeyBytes(keysBytes: Uint8Array): string[] {
515
+ const parsed = decodeGzippedJsonArray(keysBytes);
516
+ if (parsed === null) return [];
517
+ return keyStrings(parsed);
518
+ }
519
+
520
+ function keyStrings(parsed: unknown[]): string[] {
422
521
  return parsed.filter((k): k is string => typeof k === 'string');
423
522
  }
424
523
 
524
+ /** Zip a page's decoded key list and `[tag, body]` pairs back into triples. A
525
+ * key with no pair (or a pair with no key) is dropped: the two arrays are
526
+ * written together and index each other, so a length mismatch means a page
527
+ * nothing can read as records. */
528
+ function zipPageRecords(
529
+ keys: readonly string[],
530
+ pairs: readonly unknown[],
531
+ ): PackedRecord<unknown>[] {
532
+ const out: PackedRecord<unknown>[] = [];
533
+ for (let i = 0; i < keys.length && i < pairs.length; i += 1) {
534
+ const pair = pairs[i];
535
+ if (!Array.isArray(pair)) continue;
536
+ if (typeof pair[0] !== 'string' && pair[0] !== null) continue;
537
+ out.push([keys[i]!, pair[0], pair[1]]);
538
+ }
539
+ return out;
540
+ }
541
+
542
+ /** A page's records from the gzipped bytes of its two stored strings. */
543
+ export function decodePageRecordBytes(
544
+ keysBytes: Uint8Array,
545
+ bodyBytes: Uint8Array,
546
+ ): PackedRecord<unknown>[] {
547
+ const keys = decodePageKeyBytes(keysBytes);
548
+ if (keys.length === 0) return [];
549
+ const pairs = decodeGzippedJsonArray(bodyBytes);
550
+ if (pairs === null) return [];
551
+ return zipPageRecords(keys, pairs);
552
+ }
553
+
425
554
  /** A page's records, zipped back into `[key, tag, body]` triples from the two
426
555
  * strings the page stores. A key with no body (or a body with no key) is
427
556
  * dropped: the two arrays are written together and index each other, so a
@@ -433,14 +562,7 @@ export function decodePageRecords(page: unknown): PackedRecord<unknown>[] {
433
562
  if (keys.length === 0) return [];
434
563
  const pairs = decodePageField(fields[BODY_FIELD]);
435
564
  if (pairs === null) return [];
436
- const out: PackedRecord<unknown>[] = [];
437
- for (let i = 0; i < keys.length && i < pairs.length; i += 1) {
438
- const pair = pairs[i];
439
- if (!Array.isArray(pair)) continue;
440
- if (typeof pair[0] !== 'string' && pair[0] !== null) continue;
441
- out.push([keys[i]!, pair[0], pair[1]]);
442
- }
443
- return out;
565
+ return zipPageRecords(keys, pairs);
444
566
  }
445
567
 
446
568
  // How many bodies to weigh when estimating a population's record width. A