@brass-build/client 0.3.0 → 0.4.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 (128) hide show
  1. package/AGENTS.md +26 -5
  2. package/CHANGELOG.md +50 -2
  3. package/README.md +233 -77
  4. package/dist/acting-organization.d.ts +131 -0
  5. package/dist/acting-organization.d.ts.map +1 -0
  6. package/dist/acting-organization.js +278 -0
  7. package/dist/acting-organization.js.map +1 -0
  8. package/dist/api-headers.js +7 -7
  9. package/dist/api-headers.js.map +1 -1
  10. package/dist/call-service.d.ts +74 -0
  11. package/dist/call-service.d.ts.map +1 -0
  12. package/dist/call-service.js +253 -0
  13. package/dist/call-service.js.map +1 -0
  14. package/dist/client.d.ts +28 -0
  15. package/dist/client.d.ts.map +1 -1
  16. package/dist/client.js +555 -11
  17. package/dist/client.js.map +1 -1
  18. package/dist/document-contents.d.ts +15 -0
  19. package/dist/document-contents.d.ts.map +1 -0
  20. package/dist/document-contents.js +88 -0
  21. package/dist/document-contents.js.map +1 -0
  22. package/dist/document-streams.d.ts +4 -2
  23. package/dist/document-streams.d.ts.map +1 -1
  24. package/dist/document-streams.js +6 -0
  25. package/dist/document-streams.js.map +1 -1
  26. package/dist/documents.d.ts +45 -1
  27. package/dist/documents.d.ts.map +1 -1
  28. package/dist/documents.js +305 -22
  29. package/dist/documents.js.map +1 -1
  30. package/dist/export-guest.d.ts.map +1 -1
  31. package/dist/export-guest.js +5 -0
  32. package/dist/export-guest.js.map +1 -1
  33. package/dist/handle-types.d.ts +23 -0
  34. package/dist/handle-types.d.ts.map +1 -1
  35. package/dist/handles.d.ts +1 -1
  36. package/dist/handles.d.ts.map +1 -1
  37. package/dist/handles.js +1 -1
  38. package/dist/handles.js.map +1 -1
  39. package/dist/hosted-ui.d.ts +10 -0
  40. package/dist/hosted-ui.d.ts.map +1 -1
  41. package/dist/hosted-ui.js +115 -30
  42. package/dist/hosted-ui.js.map +1 -1
  43. package/dist/importer.d.ts.map +1 -1
  44. package/dist/importer.js +17 -6
  45. package/dist/importer.js.map +1 -1
  46. package/dist/index.d.ts +8 -3
  47. package/dist/index.d.ts.map +1 -1
  48. package/dist/index.js +25 -1
  49. package/dist/index.js.map +1 -1
  50. package/dist/internal.d.ts +1 -0
  51. package/dist/internal.d.ts.map +1 -1
  52. package/dist/internal.js +1 -0
  53. package/dist/internal.js.map +1 -1
  54. package/dist/mutate-batching.js +2 -2
  55. package/dist/mutate-batching.js.map +1 -1
  56. package/dist/open-document.d.ts +1 -1
  57. package/dist/open-document.d.ts.map +1 -1
  58. package/dist/open-document.js +69 -34
  59. package/dist/open-document.js.map +1 -1
  60. package/dist/presence.d.ts +10 -0
  61. package/dist/presence.d.ts.map +1 -1
  62. package/dist/presence.js.map +1 -1
  63. package/dist/session-toolbar.d.ts +9 -0
  64. package/dist/session-toolbar.d.ts.map +1 -1
  65. package/dist/session-toolbar.js +60 -12
  66. package/dist/session-toolbar.js.map +1 -1
  67. package/dist/tail-ops.js +2 -2
  68. package/dist/tail-ops.js.map +1 -1
  69. package/dist/types.d.ts +0 -42
  70. package/dist/types.d.ts.map +1 -1
  71. package/dist/undo.d.ts +1 -1
  72. package/dist/undo.d.ts.map +1 -1
  73. package/dist/undo.js +1 -1
  74. package/dist/undo.js.map +1 -1
  75. package/dist/version-bar.d.ts.map +1 -1
  76. package/dist/version-bar.js +52 -2
  77. package/dist/version-bar.js.map +1 -1
  78. package/dist/version.d.ts +2 -2
  79. package/dist/version.js +1 -1
  80. package/dist/wasm-ready-gate.d.ts +13 -0
  81. package/dist/wasm-ready-gate.d.ts.map +1 -0
  82. package/dist/wasm-ready-gate.js +28 -0
  83. package/dist/wasm-ready-gate.js.map +1 -0
  84. package/dist/wasm-runtime.browser.d.ts +11 -0
  85. package/dist/wasm-runtime.browser.d.ts.map +1 -0
  86. package/dist/wasm-runtime.browser.js +44 -0
  87. package/dist/wasm-runtime.browser.js.map +1 -0
  88. package/dist/wasm-runtime.d.ts +6 -0
  89. package/dist/wasm-runtime.d.ts.map +1 -0
  90. package/dist/wasm-runtime.js +20 -0
  91. package/dist/wasm-runtime.js.map +1 -0
  92. package/docs/app-manifest.md +335 -5
  93. package/docs/custom-chrome.md +3 -0
  94. package/docs/presence.md +12 -0
  95. package/docs/services.md +265 -0
  96. package/docs/text-fields.md +191 -0
  97. package/package.json +4 -5
  98. package/src/acting-organization.ts +396 -0
  99. package/src/api-headers.ts +7 -7
  100. package/src/call-service.ts +354 -0
  101. package/src/client.ts +618 -13
  102. package/src/document-contents.ts +104 -0
  103. package/src/document-streams.ts +15 -1
  104. package/src/documents.ts +340 -20
  105. package/src/export-guest.ts +5 -0
  106. package/src/handle-types.ts +35 -0
  107. package/src/handles.ts +1 -1
  108. package/src/hosted-ui.ts +155 -29
  109. package/src/importer.ts +17 -6
  110. package/src/index.ts +43 -4
  111. package/src/internal.ts +4 -0
  112. package/src/mutate-batching.ts +2 -2
  113. package/src/open-document.ts +79 -38
  114. package/src/presence.ts +6 -0
  115. package/src/session-toolbar.ts +80 -12
  116. package/src/tail-ops.ts +2 -2
  117. package/src/types.ts +6 -4
  118. package/src/undo.ts +1 -1
  119. package/src/version-bar.ts +53 -2
  120. package/src/version.ts +1 -1
  121. package/src/wasm-ready-gate.ts +30 -0
  122. package/src/wasm-runtime.browser.ts +48 -0
  123. package/src/wasm-runtime.ts +21 -0
  124. package/dist/vite.d.ts +0 -31
  125. package/dist/vite.d.ts.map +0 -1
  126. package/dist/vite.js +0 -161
  127. package/dist/vite.js.map +0 -1
  128. package/src/vite.ts +0 -214
package/AGENTS.md CHANGED
@@ -49,7 +49,13 @@ With an example in hand: the user adds it at their Brass dashboard
49
49
  (`https://app.brass.build`) and sends you back the document's link,
50
50
  and you read what that document holds. A resolved schema is your
51
51
  record contract: pull it and adopt it verbatim
52
- (`npx @brass-build/cli schema pull --doc <docId>`). A stored file with
52
+ (`npx @brass-build/cli schema pull --doc <docId>`). Read the records too,
53
+ before you write code against them
54
+ (`npx @brass-build/cli contents --doc <docId>` writes the whole document as
55
+ JSON): the schema names the fields and the data tells you what is in them.
56
+ Read all of it rather than the first few records, since a file's records are
57
+ grouped by type and its opening records carry a couple of the types it holds
58
+ and none of the rest. A stored file with
53
59
  no schema means your app is its first importer: register your parse
54
60
  half with `Brass.registerImporter`, and declare the schema of the
55
61
  records you write in `brass-app.json` so the next app can adopt it.
@@ -225,13 +231,16 @@ It inspects the credential and app state for this environment and ends
225
231
  in a `Next:` line naming the one command to run. Follow that line, then
226
232
  run `brass status` again, for the rest of this section.
227
233
 
228
- Two steps need the human's Brass account, and you cannot stand in for
229
- it. Drive both to completion rather than noting them and stopping:
234
+ These steps need the human's Brass account, and you cannot stand in for
235
+ them. Drive each to completion rather than noting it and stopping:
230
236
 
231
237
  - **Getting an app id.** `Brass.init` needs the `app_id` of a
232
238
  registered app. Run the first `brass publish` (below): it creates the
233
- app and writes its id to `.brass/project.json`, which you read back
234
- into `Brass.init('brass_app_...')`. A new app already allows sign-in
239
+ app and reports the id, which `--json` gives you as `app_id` for your
240
+ own parsing, and you pass that to `Brass.init('brass_app_...')`. The
241
+ publish also writes `.brass/project.json`, which is the CLI's own
242
+ state: commit it so re-runs target the same app, and read the id from
243
+ the publish result rather than out of that file. A new app already allows sign-in
235
244
  from `localhost`, so once you have the id, `Brass.signIn()` works in
236
245
  local development with no further setup. (Or the human creates the app
237
246
  on the dashboard and gives you the id.)
@@ -241,6 +250,15 @@ it. Drive both to completion rather than noting them and stopping:
241
250
  box it prints a URL and short code the human approves from any device.
242
251
  Running it and getting the human through approval is your step. For CI
243
252
  the human supplies `BRASS_SERVICE_TOKEN` instead.
253
+ - **A key for every service you declared.** An app that declares
254
+ `services` in `brass-app.json` calls those hosts through Brass, which
255
+ holds the credential, and the credential comes from the provider's own
256
+ console. `brass publish` names each host still waiting and the page
257
+ that takes its key, in the log and in `warnings` under `--json`. Give
258
+ the human that line as it stands: it carries the host and a link
259
+ straight to the page. Until they paste it, `Brass.callService` answers
260
+ `409` with `code: 'app_credential_not_set'` for that host and the app
261
+ cannot reach it.
244
262
 
245
263
  Once `brass status` reports a credential, publish:
246
264
 
@@ -257,6 +275,9 @@ done:
257
275
  - **signed in**: `brass status` confirms a credential.
258
276
  - **published**: `brass publish` reported a URL.
259
277
  - **opened**: you loaded that URL and confirmed sign-in works there.
278
+ - **keyed**: `brass publish` named no host as still waiting for a key. A
279
+ host it named is an open item like any other, so offer the link and
280
+ wait rather than reporting the integration done around it.
260
281
 
261
282
  An open item is the next thing to offer. When publishing is waiting only
262
283
  on the human's sign-in, say so and offer to walk them through
package/CHANGELOG.md CHANGED
@@ -6,6 +6,55 @@ and the package follows [semantic versioning](https://semver.org/spec/v2.0.0.htm
6
6
  While the version is below `1.0.0`, minor releases may introduce
7
7
  breaking changes.
8
8
 
9
+ ## 0.4.0
10
+
11
+ ### Added
12
+
13
+ - Call a third-party API without your app ever holding its credential.
14
+ Declare the host under `services` in `brass-app.json`, and
15
+ `Brass.callService(host, path, options)` adds the credential and returns the
16
+ service's response unchanged. `Brass.listServices()` reports which hosts
17
+ have a credential (`ready`) and which of the rest the person using your app
18
+ can connect (`connectable`), so you can show, offer or hide a feature before
19
+ anyone clicks it, and `Brass.connectService(host)` opens the dialog that
20
+ collects a missing one. See [`docs/services.md`](./docs/services.md).
21
+ - `doc.text(key)` stores text that several people edit at once, keeping every
22
+ edit rather than the last one written. Its subscriber gets the same `origin`
23
+ argument a map subscriber gets, and `PresenceState.textCaret` reports where
24
+ each person's caret is. See
25
+ [`docs/text-fields.md`](./docs/text-fields.md).
26
+ - `Brass.init` accepts `undefined` as the app id, so one bundle works both on
27
+ a Brass-hosted origin, which supplies the id, and on your own, which does
28
+ not: `Brass.init(import.meta.env.VITE_BRASS_APP_ID)`.
29
+
30
+ ### Removed
31
+
32
+ - The `@brass-build/client/vite` subpath and its `brassVite()` plugin. The SDK
33
+ loads its WebAssembly engine with no build-time configuration, so `vite
34
+ build` and webpack need nothing. Vite's dev server needs
35
+ `optimizeDeps: { exclude: ['@brass-build/client'] }` in its config. See the
36
+ README's "Modules and assets".
37
+
38
+ ### Fixed
39
+
40
+ - A document that cannot reach Brass reports the failure instead of accepting
41
+ edits it cannot save. The first write calls `Brass.onError` and sets
42
+ `doc.readOnly`, and a later `mutate` throws. Disable your editor on
43
+ `doc.readOnly`.
44
+ - Reopening a document whose import did not finish runs the import again.
45
+ - A `doc.stream(name)` open that fails closes its connection, instead of
46
+ leaving it open for the life of the page.
47
+ - A stream that reaches the platform's size ceiling goes read-only and reports
48
+ `stream.atCapacity`, rather than alerting the reader and reloading the whole
49
+ document a stream is only part of. The rest of the document keeps taking
50
+ writes; disable the stream's own bulk work on `stream.atCapacity`.
51
+ - Escape closes only the topmost hosted dialog. A toolbar command opened over
52
+ a picker used to report a dismissal to both.
53
+ - A hosted dialog on a slow connection no longer navigates the page away to
54
+ the standalone picker.
55
+ - A page whose signed-in user changes forgets the previous user's last
56
+ selection, instead of opening their document for the new one.
57
+
9
58
  ## 0.3.0
10
59
 
11
60
  ### Added
@@ -108,5 +157,4 @@ breaking changes.
108
157
  Initial public release. The SDK turns a document into a live,
109
158
  multi-user, persistent workspace: sign-in, the hosted file toolbar, and
110
159
  a subscribe-render-mutate loop per open document. Ships as an ESM-only
111
- package with a `./vite` subpath that configures the WASM runtime's
112
- bundler needs.
160
+ package.
package/README.md CHANGED
@@ -30,6 +30,7 @@ npm install @brass-build/client
30
30
  - [Presence](#presence-live-cursors-and-whos-here)
31
31
  - [Streams](#streams-partitioning-large-documents)
32
32
  - [Imported files](#imported-files)
33
+ - [Calling an outside service](#calling-an-outside-service)
33
34
  - [Reference](#reference)
34
35
  - [Guides](#guides)
35
36
 
@@ -45,37 +46,36 @@ integration.
45
46
  Build a shared checklist on @brass-build/client (NPM).
46
47
  ```
47
48
 
48
- **Or wire it by hand.**
49
+ **Or wire it by hand.** Six calls, and your own code around them:
49
50
 
50
51
  ```ts
51
52
  import { Brass } from '@brass-build/client';
52
- import type { BrassUser, Document } from '@brass-build/client';
53
+ import type { Document } from '@brass-build/client';
53
54
 
54
- // Register listeners before init so validation failures reach onError.
55
+ // Register before init, so a bad app id reaches your handler.
55
56
  Brass.onError((err) => console.error('[Brass]', err.message));
56
57
 
57
- // Swap your two shells as auth settles. Both start hidden, so nothing
58
- // paints before the first onUserChange fire.
59
- Brass.onUserChange((user: BrassUser | null) => {
60
- document.getElementById('signed-out')!.hidden = !!user;
61
- document.getElementById('signed-in')!.hidden = !user;
62
- });
63
- document.getElementById('sign-in')!.onclick = () => Brass.signIn();
58
+ // Fires when init resolves, then on every sign-in and sign-out. Show
59
+ // your app unless `Brass.authStatus` is 'signed-out': someone opening a
60
+ // share link has no user and still gets the document, read-only.
61
+ Brass.onUserChange(renderAuthState);
62
+ signInButton.onclick = () => Brass.signIn();
64
63
 
65
64
  // The whole file menu in one call: document title, undo / redo,
66
65
  // account menu, presence roster.
67
- Brass.mountToolbar(document.getElementById('app-bar')!);
66
+ Brass.mountToolbar(appBar);
68
67
 
69
- // Attach your editor to each document the session opens. Compare the
70
- // Document instance, never the id (a version preview reuses the id).
68
+ // Rebind your editor to whichever document the session opens. Compare
69
+ // the instance, not the id: a version preview reuses the id.
71
70
  let bound: Document | null = null;
72
71
  Brass.session.subscribe((session) => {
73
- if (session.document !== bound) {
74
- bound = session.document;
75
- if (bound) bindEditor(bound); // see Editing documents
76
- }
72
+ if (session.document === bound) return;
73
+ bound = session.document;
74
+ if (bound) bindEditor(bound); // see Editing documents
77
75
  });
78
76
 
77
+ // Reported by your first `brass publish` (as `app_id` under --json), or
78
+ // on your app's page in the dashboard.
79
79
  await Brass.init('brass_app_...');
80
80
  ```
81
81
 
@@ -85,8 +85,7 @@ to paint your list, and call `.mutate` from a click.
85
85
  [Document lifecycle](#document-lifecycle) and
86
86
  [Editing documents](#editing-documents) show the loop in full.
87
87
 
88
- Set up your bundler for the SDK's WebAssembly module
89
- ([Bundler setup](#bundler-setup)), then build and publish:
88
+ Then build and publish:
90
89
 
91
90
  ```sh
92
91
  npm run build
@@ -95,27 +94,34 @@ npx @brass-build/cli publish ./dist
95
94
  ```
96
95
 
97
96
  The first publish creates the app, hosts it at
98
- `https://<slug>.onbrass.app` where your users sign in, and writes the
99
- app id (the `brass_app_...` above) to `.brass/project.json`.
97
+ `https://<slug>.onbrass.app` where your users sign in, and reports the
98
+ app id (the `brass_app_...` above).
100
99
 
101
100
  ## Setup
102
101
 
103
102
  To take the [quickstart](#quickstart) from source to a
104
103
  signed-in, hosted app:
105
104
 
106
- 1. **Set up the bundler.** The SDK ships a WebAssembly module, and your
107
- bundler needs to know how to load it. See
108
- [Bundler setup](#bundler-setup).
109
- 2. **Ship it.** `npx @brass-build/cli publish ./dist` creates the app,
105
+ 1. **Ship it.** `npx @brass-build/cli publish ./dist` creates the app,
110
106
  hosts it, and returns its sign-in URL. See [Ship it](#ship-it).
111
- 3. **Pass an app id unless Brass hosts your bundle.** Brass resolves
112
- the id from its own hosting origins, so a published bundle calls
113
- `Brass.init()` with no argument. Pass the id everywhere else, in
114
- local development and on your own origin:
115
- `Brass.init('brass_app_...')`. Read it from `.brass/project.json`,
116
- which the first publish writes, or copy it from your app's page in
117
- the dashboard. A new app already allows sign-in from `localhost` /
118
- `127.0.0.1` on any port; add your own origin on that same page.
107
+ 2. **Pass an app id unless Brass hosts your bundle.** A published bundle
108
+ resolves its id from the slot it is served on, so it calls
109
+ `Brass.init()` with no argument. Pass the id everywhere else, in local
110
+ development and on your own origin: `Brass.init('brass_app_...')`.
111
+ `brass publish --json` reports it as `app_id`, or copy it from your
112
+ app's page in the dashboard.
113
+
114
+ One bundle covers both origins, because the id argument accepts
115
+ `undefined`. Pass whatever your build resolved to
116
+ (`Brass.init(import.meta.env.VITE_BRASS_APP_ID)`) and Brass falls back
117
+ to the serving slot. Set the variable for local development as well:
118
+ `localhost` has no slot to fall back to, and an absent id fails init.
119
+ 3. **Allow your development origin.** An app you create in the dashboard
120
+ already allows sign-in from `localhost` / `127.0.0.1` on any port. One
121
+ a first `brass publish` created for you carries only the origin it is
122
+ served from, so add `http://localhost:5173/*` on your app's page before
123
+ signing in locally. A missing entry answers that the origin is not on
124
+ the app's allowlist.
119
125
 
120
126
  **CSP.** If (and only if) your app sends a `Content-Security-Policy`,
121
127
  extend it: `script-src` needs `'wasm-unsafe-eval'` for the SDK's
@@ -124,35 +130,41 @@ picker and share dialog, and `img-src` needs
124
130
  `https://*.s3.<region>.amazonaws.com` if you render document thumbnails
125
131
  (they are presigned S3 URLs).
126
132
 
127
- ### Bundler setup
133
+ ### Modules and assets
128
134
 
129
135
  `@brass-build/client` ships as ES modules, loaded by a bundler (Vite,
130
- webpack) or Node's ESM loader with `import`.
131
-
132
- Without a WASM loader, `vite dev` fails with `"ESM integration proposal
133
- for Wasm" is not supported currently`. Install one plugin and add the
134
- SDK's own helper:
135
-
136
- ```sh
137
- npm install --save-dev vite-plugin-wasm
138
- ```
139
-
140
- ```ts
141
- // vite.config.ts
142
- import { defineConfig } from 'vite';
143
- import wasm from 'vite-plugin-wasm';
144
- import { brassVite } from '@brass-build/client/vite';
145
-
146
- export default defineConfig({
147
- plugins: [wasm(), brassVite()],
148
- build: { target: 'es2022' },
149
- });
136
+ webpack 5) or Node's ESM loader with `import`.
137
+
138
+ The SDK runs a WebAssembly engine, which your bundler emits as its own
139
+ asset of about 3 MB, an order of magnitude larger than your JavaScript.
140
+ Serve it as `application/wasm`: browsers compile it while it streams,
141
+ and fall back to a slower path when the content type is something else.
142
+
143
+ Boot also requests `/.well-known/brass-runtime.json`. Brass serves that
144
+ file from its own hosting to tell a bundle which app and environment it
145
+ is being served as. Your own origin has no reason to, so it answers
146
+ either a 404 (which the console logs in red) or, if it falls back to
147
+ `index.html` the way a dev server does, that page. Both are expected,
148
+ and the SDK carries on with the app id and origins you gave it.
149
+
150
+ ### Vite dev server
151
+
152
+ Vite pre-bundles your dependencies for its dev server, which copies this
153
+ package into `node_modules/.vite/deps/` without moving the engine's binary
154
+ alongside it. The request for the binary then resolves inside that
155
+ directory, finds nothing, and the dev server answers with `index.html`, so
156
+ the engine is handed a page to compile and the app fails when it first
157
+ opens a document. Keep this package out of that step:
158
+
159
+ ```js
160
+ // vite.config.js
161
+ export default {
162
+ optimizeDeps: { exclude: ['@brass-build/client'] },
163
+ };
150
164
  ```
151
165
 
152
- `brassVite()` configures Vite's dep-prebundling step so the SDK's WASM
153
- runtime loads on first `vite dev`, and it names the SDK's WebAssembly
154
- dependencies for you, so your config keeps working as they change.
155
- Webpack 5 loads them with no extra configuration.
166
+ `vite build` emits the binary as an asset and needs nothing, so this
167
+ setting is only for the dev server.
156
168
 
157
169
  ### Init
158
170
 
@@ -169,10 +181,12 @@ covers when you need one). The second is a typed options object,
169
181
  | `syncDocToTitle` | `true` (set the page title to `Doc name · App name` while a document is open) |
170
182
  | `openIncompatibleDocuments` | `false` (Brass refuses to open a document your schema cannot read, and asks the person whether to go ahead) |
171
183
 
172
- `Brass.init` resolves after one auth round-trip. Register `onError`
173
- and `onUserChange` BEFORE calling `init`, so that validation failures
174
- fire through your listener instead of dying as unhandled rejections. A
175
- bad `app_id` still resolves, with `Brass.user === null`.
184
+ `Brass.init` resolves once it knows who the reader is, which takes one
185
+ auth round-trip for someone who has signed in to your app before and no
186
+ request at all for a first-time visitor. Register `onError` and
187
+ `onUserChange` BEFORE calling `init`, so that validation failures fire
188
+ through your listener instead of dying as unhandled rejections. A bad
189
+ `app_id` still resolves, with `Brass.user === null`.
176
190
 
177
191
  ### Ship it
178
192
 
@@ -217,11 +231,10 @@ changes. It fires once when `init` resolves (with the real user or
217
231
  `null`), then on every sign-in and sign-out. A listener registered
218
232
  before `init` does not fire on registration. One registered after does.
219
233
 
220
- The plain-DOM sample in the [quickstart](#quickstart) gets this
221
- right with no extra code, because both shells start `hidden` and
222
- nothing shows until the first `onUserChange`. A framework app (React, Vue, Svelte) paints its
223
- initial state synchronously, before that first fire, so it renders the
224
- three states explicitly:
234
+ The [quickstart](#quickstart) sample gates on `'signed-out'` for exactly
235
+ this reason. A framework app (React, Vue, Svelte) paints its initial
236
+ state synchronously, before the first `onUserChange` fire, so it renders
237
+ the three states explicitly:
225
238
 
226
239
  ```ts
227
240
  // Vue
@@ -349,7 +362,20 @@ document to its drive, and does other work while the document is open.
349
362
  `doc.onStatus(cb)` fires on every phase. Render `status.message`
350
363
  verbatim and read `status.pending` for whether the work is still
351
364
  running. The message is prose written for the reader, so show it rather
352
- than parsing it or inferring a phase from it.
365
+ than parsing it or inferring a phase from it. Both are readable
366
+ synchronously, so a fresh bind renders the current state without waiting
367
+ for a fire.
368
+
369
+ `doc.savingCount` is the count of this app's own writes that have not yet
370
+ been acknowledged, which is what a "Saving..." indicator reads. It ticks
371
+ through `doc.subscribe`, alongside the rest of what is not tied to one
372
+ container:
373
+
374
+ ```ts
375
+ doc.subscribe(() => {
376
+ saving.textContent = doc.savingCount > 0 ? `Saving ${doc.savingCount}...` : 'Saved';
377
+ });
378
+ ```
353
379
 
354
380
  **Where new documents land.** The `new` command (and
355
381
  `session.create()`) places the document for you: a sibling of the open
@@ -360,6 +386,12 @@ document, or a platform-chosen home from the empty state.
360
386
  await Brass.session.create({ name: 'Scheme B' });
361
387
  ```
362
388
 
389
+ `session.pick()` is the same call for opening an existing document: it
390
+ raises the hosted picker and binds what the reader chooses, resolving
391
+ `null` if they cancel. Both back the toolbar's own New and Open, so reach
392
+ for them when your app renders those buttons itself (an empty state
393
+ offering somewhere to start, a shortcut).
394
+
363
395
  **Keyboard shortcuts** route through `session.run`, which drives the
364
396
  same undo / redo commands the toolbar buttons use. `session.commands`
365
397
  carries every command with its id, and
@@ -427,6 +459,10 @@ that container. A large document stays cheap because your editor
427
459
  touches the slice it cares about instead of walking the whole
428
460
  document.
429
461
 
462
+ The SDK exports each accessor's return type, for a signature or a field
463
+ that has to hold one: `MapHandle<T>`, `OrderedMapHandle<T>`,
464
+ `ListHandle<T>` and `TextHandle`.
465
+
430
466
  **Make an item the smallest thing two people edit independently.** Two
431
467
  people editing different items always keep both edits. Two editing the
432
468
  same item keep one of the two, not a blend, so a grid where people edit
@@ -440,12 +476,18 @@ type Wall = { type: 'wall'; x: number; z: number };
440
476
  const walls = doc.map<Wall>('walls');
441
477
 
442
478
  walls.mutate((m) => { m[id] = { type: 'wall', x: 0, z: 0 }; }); // add
443
- walls.mutate((m) => { m[id].x = 42; }); // field edit
444
- walls.mutate((m) => { delete m[id]; }); // remove
479
+ walls.mutate((m) => { const w = m[id]; if (w) w.x = 42; }); // field edit
480
+ walls.mutate((m) => { delete m[id]; }); // remove
445
481
 
446
482
  const one = walls.get(id); // Wall | undefined
447
483
  ```
448
484
 
485
+ The draft indexes as `Record<string, T>`, so a read is `T | undefined`
486
+ and the field edit checks it: under `noUncheckedIndexedAccess` that is
487
+ the only form that compiles, and either way a peer may have deleted the
488
+ record between your render and your click. A record you hold that way is
489
+ live, so writing through it writes to the document.
490
+
449
491
  Handles are cached per key (`doc.map('walls') === doc.map('walls')`),
450
492
  so they compare stable across renders. `doc.list<T>(key)` is the same
451
493
  surface for a root list; its mutate draft is a plain array proxy, and
@@ -476,7 +518,7 @@ Read the collection in order with `ordered()` or `keys()`.
476
518
  ```ts
477
519
  const notes = doc.orderedMap<Note>('notes', 'note_order');
478
520
  notes.mutate((m) => { m[id] = { text: '' }; }); // appends to the order
479
- notes.mutate((m) => { m[id].text = 'hi'; }); // field edit; order untouched
521
+ notes.mutate((m) => { const n = m[id]; if (n) n.text = 'hi'; }); // order untouched
480
522
  for (const { key, value } of notes.ordered()) renderRow(key, value);
481
523
  ```
482
524
 
@@ -529,6 +571,54 @@ first undoable edit. Don't seed a baseline from the client: a faked
529
571
  user sees content vanish they never wrote. Persist what the user asked
530
572
  for, and nothing else.
531
573
 
574
+ ### Prose several people edit
575
+
576
+ For prose several people edit at once (a description, a report, a comment
577
+ body), use `doc.text(key)` rather than storing the string in a map. A map
578
+ record is the merge unit, so two people editing one string keep one of the
579
+ two and the other's typing is gone. A text container merges inside the
580
+ string: `update(next)` diffs against what is stored and applies the
581
+ smallest edit that reaches it, so a peer's edit elsewhere in the same
582
+ paragraph survives.
583
+
584
+ Pass the whole new string every time. Diffing it is the handle's job, and
585
+ that is what makes a full-string assignment safe here where the same
586
+ assignment into a map is not.
587
+
588
+ ```ts
589
+ const report = doc.text('report');
590
+ let painted = report.read();
591
+ field.value = painted;
592
+
593
+ field.addEventListener('input', () => {
594
+ painted = field.value;
595
+ report.update(painted);
596
+ });
597
+
598
+ report.subscribe((value, origin) => {
599
+ if (origin?.source === 'local') { painted = value; return; }
600
+ paintPeerEdit(value); // see docs/text-fields.md
601
+ });
602
+ ```
603
+
604
+ `origin` says who wrote the change, the way a map subscriber's does. The
605
+ app's own write comes back through the subscriber too, and it is the one
606
+ fire that must not touch the field: the reader is already looking at what
607
+ they typed. Every other fire has to paint without throwing away the caret
608
+ of whoever is typing, which is what `paintPeerEdit` above stands for.
609
+
610
+ **A text container is a top-level root of the document**, so take one per
611
+ document-shaped thing: a body, a section, a summary. Prose that belongs to
612
+ each row of a repeated collection stays in the row's record, where the row
613
+ is the merge unit and the record store's paging holds the cost flat as the
614
+ collection grows. A root per row opts out of that paging, and loro prices a
615
+ container against how many the document holds, so the cost climbs steeply
616
+ with the collection.
617
+
618
+ [`docs/text-fields.md`](./docs/text-fields.md) carries the caret-preserving
619
+ paint, the IME window, what a keystroke costs against the field's length,
620
+ and the measurements behind the root-per-row rule.
621
+
532
622
  ### Subscribing
533
623
 
534
624
  `handle.subscribe(cb)` is one channel for every change, whatever
@@ -596,7 +686,7 @@ item owns anything.** Rebuilding is correct and costs whatever the item
596
686
  holds: a DOM node loses its focus, its selection and its scroll
597
687
  position, and a view built on it starts over. As you apply a splice you
598
688
  know which of your own items it removed, so match them against what the
599
- same change inserts and move the one you already have.
689
+ same change inserts and move the existing item rather than rebuilding it.
600
690
 
601
691
  ```ts
602
692
  const stop = steps.subscribe((change) => {
@@ -653,12 +743,12 @@ edits stay untouched. Outside the session, call `doc.undo()` /
653
743
 
654
744
  A **checkpoint** (`doc.createCheckpoint()`) is both the Ctrl-Z
655
745
  boundary and a marker on the document's version-history timeline, so
656
- checkpoint timing sets both granularities. Auto-seals fire 60 seconds
657
- after each burst of local edits settles. Tune that to your app's shape:
746
+ checkpoint timing sets both granularities. Automatic checkpoints land 60
747
+ seconds after each burst of local edits settles. Tune that to your app's shape:
658
748
 
659
749
  | Your app shape | What to do | Result |
660
750
  | --------------------------------------- | ------------------------------------------------ | ------------------------------------------ |
661
- | Default / no opinion | Nothing. | Auto-seal every 60s of idle. |
751
+ | Default / no opinion | Nothing. | A checkpoint every 60s of idle. |
662
752
  | Drag / brush / tool-stroke (3D, canvas) | `void doc.createCheckpoint()` on `pointerup`. | One drag = one Ctrl-Z. |
663
753
  | Text editor / fast bursts | `doc.setAutoCheckpointIntervalMs(2000)` at boot. | Each typing pause becomes a boundary. |
664
754
  | Explicit-only | `doc.setAutoCheckpointIntervalMs(0)` | Only deliberate `createCheckpoint` calls. |
@@ -666,8 +756,8 @@ after each burst of local edits settles. Tune that to your app's shape:
666
756
  `createCheckpoint()` waits for the edits your app has just made to save
667
757
  before it marks the point, so the marker describes the state you asked to
668
758
  save rather than one those edits are missing from. It rejects on a rate
669
- limit, and when those edits do not save. A missed seal is harmless, so
670
- call it fire-and-forget.
759
+ limit, and when those edits do not save. A missed checkpoint is harmless,
760
+ so call it fire-and-forget.
671
761
 
672
762
  The SDK renders version preview itself: while an earlier version is
673
763
  showing, `mountToolbar` / `mountHeader` show the "viewing an earlier
@@ -866,6 +956,67 @@ goes, what each key does, and how to confirm your build carries it.
866
956
  record store an imported file becomes, and
867
957
  [`docs/schemas.md`](./docs/schemas.md) covers declaring its schema.
868
958
 
959
+ ## Calling an outside service
960
+
961
+ Your app can call a paid or authenticated third-party API without ever
962
+ holding its credential. Declare the host in `brass-app.json`, and Brass adds
963
+ the key to each request:
964
+
965
+ ```json
966
+ {
967
+ "services": [{ "host": "api.anthropic.com", "headers": { "x-api-key": "{secret}" } }]
968
+ }
969
+ ```
970
+
971
+ Whoever owns the account enters the secret in Brass, either as one key of
972
+ yours that serves every install, or as a request for whoever runs your app to
973
+ connect their own account. `Brass.callService` returns the service's response unchanged:
974
+
975
+ ```js
976
+ const res = await Brass.callService('api.anthropic.com', 'v1/messages', {
977
+ body: JSON.stringify({
978
+ model: 'claude-sonnet-5',
979
+ max_tokens: 1024,
980
+ messages: [{ role: 'user', content: prompt }],
981
+ }),
982
+ headers: {
983
+ 'content-type': 'application/json',
984
+ 'anthropic-version': '2023-06-01',
985
+ },
986
+ });
987
+ const message = await res.json();
988
+ ```
989
+
990
+ `options.method` defaults to `'POST'`. The response must be text, under 5 MB,
991
+ and arrive within 25 seconds. Brass does not follow redirects: a `3xx` is
992
+ returned as `response_redirected`, with the service's `location` in the body.
993
+
994
+ `Brass.listServices()` tells you which hosts have a credential, so you can
995
+ show or hide a feature before anyone clicks it.
996
+ `Brass.connectService(host)` opens the dialog that collects a missing one:
997
+
998
+ ```js
999
+ const services = await Brass.listServices();
1000
+ const ai = services?.find((s) => s.host === 'api.anthropic.com');
1001
+ if (ai?.ready) showSummariseButton();
1002
+ else upgradeButton.onclick = () => Brass.connectService('api.anthropic.com');
1003
+ ```
1004
+
1005
+ When Brass refuses a call rather than sending it, the response body has a
1006
+ `code` for what to do about it:
1007
+
1008
+ ```js
1009
+ if (!res.ok) {
1010
+ const { code } = await res.json();
1011
+ if (code === 'credential_required') await Brass.connectService('api.anthropic.com');
1012
+ }
1013
+ ```
1014
+
1015
+ [`docs/services.md`](./docs/services.md) lists every code, and covers values
1016
+ only the user knows and services split across several addresses.
1017
+ [`docs/app-manifest.md`](./docs/app-manifest.md) covers declaring a host,
1018
+ including services that sign a request or exchange a credential for a token.
1019
+
869
1020
  ## Reference
870
1021
 
871
1022
  `Brass` is the top-level namespace your app calls. The package's
@@ -886,6 +1037,9 @@ above show them in use.
886
1037
  | `Brass.onUserChange(cb)` | `() => void` (unsubscribe) |
887
1038
  | `Brass.onError(cb)` | `() => void` (unsubscribe) |
888
1039
  | `Brass.getAccessToken()` | `Promise<string \| null>` |
1040
+ | `Brass.callService(host, path, options?)` | `Promise<Response>` |
1041
+ | `Brass.listServices()` | `Promise<AppServiceState[] \| null>` |
1042
+ | `Brass.connectService(host)` | `Promise<boolean>` |
889
1043
  | `Brass.session` | `DocumentSession` (see above) |
890
1044
  | `Brass.mountToolbar(el, options?)` | `SessionToolbarHandle` (`{ tools, destroy }`) |
891
1045
  | `Brass.createDocument(options?)` | `Promise<OpenedDocument>` (`session.create` drives this) |
@@ -922,5 +1076,7 @@ with `.status === 401` before it reaches the network.
922
1076
  - [`docs/schemas.md`](./docs/schemas.md): declaring a schema, creating a stream, what lets an app open a document, and what breaks that.
923
1077
  - [`docs/streams.md`](./docs/streams.md): partitioning a document for lazy loading, per-stream undo, and reading a document spread across streams.
924
1078
  - [`docs/custom-chrome.md`](./docs/custom-chrome.md): composable header pieces, and rendering `session.commands` yourself for a fully custom shell.
1079
+ - [`docs/text-fields.md`](./docs/text-fields.md): binding a field to `doc.text(key)`, painting a peer's edit without losing the caret, the IME window, what a keystroke costs, and where a text container belongs in a document.
1080
+ - [`docs/services.md`](./docs/services.md): calling a declared outside service, every refusal code, values only the user knows, and a service split across addresses.
925
1081
  - [`docs/presence.md`](./docs/presence.md): remote cursors, roster rendering, coordinate conventions, full types.
926
1082
  - [`docs/hosted-ui-advanced.md`](./docs/hosted-ui-advanced.md): the hosted launchers (`Brass.selectDocument`, `Brass.shareDocument`), their redirect behavior in browsers that block the iframe, theming, CSP.