@brass-build/client 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +142 -41
- package/CHANGELOG.md +93 -4
- package/README.md +666 -179
- package/dist/acting-organization.d.ts.map +1 -1
- package/dist/acting-organization.js +6 -11
- package/dist/acting-organization.js.map +1 -1
- package/dist/api-fetch.d.ts +8 -0
- package/dist/api-fetch.d.ts.map +1 -1
- package/dist/api-fetch.js +35 -8
- package/dist/api-fetch.js.map +1 -1
- package/dist/batched-get.d.ts +11 -0
- package/dist/batched-get.d.ts.map +1 -0
- package/dist/batched-get.js +92 -0
- package/dist/batched-get.js.map +1 -0
- package/dist/call-service.d.ts +4 -0
- package/dist/call-service.d.ts.map +1 -1
- package/dist/call-service.js +66 -3
- package/dist/call-service.js.map +1 -1
- package/dist/client.d.ts +51 -2
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +982 -124
- package/dist/client.js.map +1 -1
- package/dist/document-checkpoints.d.ts +25 -2
- package/dist/document-checkpoints.d.ts.map +1 -1
- package/dist/document-checkpoints.js +258 -61
- package/dist/document-checkpoints.js.map +1 -1
- package/dist/document-commands.d.ts.map +1 -1
- package/dist/document-commands.js +5 -3
- package/dist/document-commands.js.map +1 -1
- package/dist/document-contents.d.ts +10 -0
- package/dist/document-contents.d.ts.map +1 -1
- package/dist/document-contents.js +23 -1
- package/dist/document-contents.js.map +1 -1
- package/dist/document-set-subscription.d.ts +56 -0
- package/dist/document-set-subscription.d.ts.map +1 -0
- package/dist/document-set-subscription.js +240 -0
- package/dist/document-set-subscription.js.map +1 -0
- package/dist/document-signals.d.ts +6 -0
- package/dist/document-signals.d.ts.map +1 -1
- package/dist/document-signals.js +31 -1
- package/dist/document-signals.js.map +1 -1
- package/dist/document-streams.d.ts +36 -2
- package/dist/document-streams.d.ts.map +1 -1
- package/dist/document-streams.js +69 -18
- package/dist/document-streams.js.map +1 -1
- package/dist/document-thumbnail-capture.d.ts +12 -2
- package/dist/document-thumbnail-capture.d.ts.map +1 -1
- package/dist/document-thumbnail-capture.js +103 -27
- package/dist/document-thumbnail-capture.js.map +1 -1
- package/dist/documents-namespace.d.ts +92 -10
- package/dist/documents-namespace.d.ts.map +1 -1
- package/dist/documents-namespace.js +285 -39
- package/dist/documents-namespace.js.map +1 -1
- package/dist/documents.d.ts +173 -8
- package/dist/documents.d.ts.map +1 -1
- package/dist/documents.js +1778 -310
- package/dist/documents.js.map +1 -1
- package/dist/errors.d.ts +3 -3
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/fetch-retry.d.ts +1 -0
- package/dist/fetch-retry.d.ts.map +1 -1
- package/dist/fetch-retry.js +2 -0
- package/dist/fetch-retry.js.map +1 -1
- package/dist/file-choice.d.ts +2 -0
- package/dist/file-choice.d.ts.map +1 -0
- package/dist/file-choice.js +27 -0
- package/dist/file-choice.js.map +1 -0
- package/dist/guest-link.d.ts +18 -1
- package/dist/guest-link.d.ts.map +1 -1
- package/dist/guest-link.js +329 -1
- package/dist/guest-link.js.map +1 -1
- package/dist/handle-types.d.ts +67 -5
- package/dist/handle-types.d.ts.map +1 -1
- package/dist/handles.d.ts +15 -19
- package/dist/handles.d.ts.map +1 -1
- package/dist/handles.js +231 -76
- package/dist/handles.js.map +1 -1
- package/dist/hosted-ui.d.ts +61 -0
- package/dist/hosted-ui.d.ts.map +1 -1
- package/dist/hosted-ui.js +94 -42
- package/dist/hosted-ui.js.map +1 -1
- package/dist/import-contract.d.ts +74 -0
- package/dist/import-contract.d.ts.map +1 -0
- package/dist/import-contract.js +163 -0
- package/dist/import-contract.js.map +1 -0
- package/dist/import-guest.d.ts +9 -0
- package/dist/import-guest.d.ts.map +1 -0
- package/dist/import-guest.js +155 -0
- package/dist/import-guest.js.map +1 -0
- package/dist/import-report.d.ts +17 -0
- package/dist/import-report.d.ts.map +1 -0
- package/dist/import-report.js +7 -0
- package/dist/import-report.js.map +1 -0
- package/dist/importer-wait.d.ts +2 -0
- package/dist/importer-wait.d.ts.map +1 -0
- package/dist/importer-wait.js +5 -0
- package/dist/importer-wait.js.map +1 -0
- package/dist/importer.d.ts +18 -4
- package/dist/importer.d.ts.map +1 -1
- package/dist/importer.js +322 -105
- package/dist/importer.js.map +1 -1
- package/dist/in-development.d.ts +2 -0
- package/dist/in-development.d.ts.map +1 -0
- package/dist/in-development.js +10 -0
- package/dist/in-development.js.map +1 -0
- package/dist/index.d.ts +16 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +24 -8
- package/dist/index.js.map +1 -1
- package/dist/init-keys.d.ts +1 -1
- package/dist/init-keys.d.ts.map +1 -1
- package/dist/init-keys.js +6 -1
- package/dist/init-keys.js.map +1 -1
- package/dist/input-documents.d.ts +67 -0
- package/dist/input-documents.d.ts.map +1 -0
- package/dist/input-documents.js +693 -0
- package/dist/input-documents.js.map +1 -0
- package/dist/inputs-status.d.ts +80 -0
- package/dist/inputs-status.d.ts.map +1 -0
- package/dist/inputs-status.js +460 -0
- package/dist/inputs-status.js.map +1 -0
- package/dist/inputs.d.ts +2 -0
- package/dist/inputs.d.ts.map +1 -0
- package/dist/inputs.js +793 -0
- package/dist/inputs.js.map +1 -0
- package/dist/internal.d.ts +6 -1
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +4 -1
- package/dist/internal.js.map +1 -1
- package/dist/join-content.d.ts +33 -0
- package/dist/join-content.d.ts.map +1 -0
- package/dist/join-content.js +114 -0
- package/dist/join-content.js.map +1 -0
- package/dist/join-stamp.d.ts +3 -0
- package/dist/join-stamp.d.ts.map +1 -0
- package/dist/join-stamp.js +12 -0
- package/dist/join-stamp.js.map +1 -0
- package/dist/manifest-drift.d.ts +9 -0
- package/dist/manifest-drift.d.ts.map +1 -0
- package/dist/manifest-drift.js +46 -0
- package/dist/manifest-drift.js.map +1 -0
- package/dist/open-document.d.ts +109 -2
- package/dist/open-document.d.ts.map +1 -1
- package/dist/open-document.js +1822 -811
- package/dist/open-document.js.map +1 -1
- package/dist/page-restore.d.ts +12 -0
- package/dist/page-restore.d.ts.map +1 -0
- package/dist/page-restore.js +23 -0
- package/dist/page-restore.js.map +1 -0
- package/dist/placement.d.ts +13 -0
- package/dist/placement.d.ts.map +1 -0
- package/dist/placement.js +34 -0
- package/dist/placement.js.map +1 -0
- package/dist/presence.d.ts +1 -0
- package/dist/presence.d.ts.map +1 -1
- package/dist/presence.js +16 -4
- package/dist/presence.js.map +1 -1
- package/dist/read-only-document.d.ts +87 -0
- package/dist/read-only-document.d.ts.map +1 -0
- package/dist/read-only-document.js +4 -0
- package/dist/read-only-document.js.map +1 -0
- package/dist/record-paging.d.ts +63 -6
- package/dist/record-paging.d.ts.map +1 -1
- package/dist/record-paging.js +122 -29
- package/dist/record-paging.js.map +1 -1
- package/dist/references.d.ts +40 -0
- package/dist/references.d.ts.map +1 -0
- package/dist/references.js +135 -0
- package/dist/references.js.map +1 -0
- package/dist/request-lane.d.ts +5 -0
- package/dist/request-lane.d.ts.map +1 -0
- package/dist/request-lane.js +48 -0
- package/dist/request-lane.js.map +1 -0
- package/dist/sealed-arrow.d.ts +170 -0
- package/dist/sealed-arrow.d.ts.map +1 -0
- package/dist/sealed-arrow.js +769 -0
- package/dist/sealed-arrow.js.map +1 -0
- package/dist/sealed-cache.d.ts +21 -0
- package/dist/sealed-cache.d.ts.map +1 -0
- package/dist/sealed-cache.js +247 -0
- package/dist/sealed-cache.js.map +1 -0
- package/dist/sealed-encode.d.ts +87 -0
- package/dist/sealed-encode.d.ts.map +1 -0
- package/dist/sealed-encode.js +876 -0
- package/dist/sealed-encode.js.map +1 -0
- package/dist/sealed-handles.d.ts +45 -0
- package/dist/sealed-handles.d.ts.map +1 -0
- package/dist/sealed-handles.js +408 -0
- package/dist/sealed-handles.js.map +1 -0
- package/dist/sealed-store.d.ts +242 -0
- package/dist/sealed-store.d.ts.map +1 -0
- package/dist/sealed-store.js +1155 -0
- package/dist/sealed-store.js.map +1 -0
- package/dist/serializer.d.ts +6 -15
- package/dist/serializer.d.ts.map +1 -1
- package/dist/session-toolbar.d.ts +20 -5
- package/dist/session-toolbar.d.ts.map +1 -1
- package/dist/session-toolbar.js +419 -67
- package/dist/session-toolbar.js.map +1 -1
- package/dist/session.d.ts +38 -1
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +562 -79
- package/dist/session.js.map +1 -1
- package/dist/sha256.d.ts +3 -0
- package/dist/sha256.d.ts.map +1 -0
- package/dist/sha256.js +74 -0
- package/dist/sha256.js.map +1 -0
- package/dist/stable-handles.d.ts +19 -0
- package/dist/stable-handles.d.ts.map +1 -0
- package/dist/stable-handles.js +193 -0
- package/dist/stable-handles.js.map +1 -0
- package/dist/staged-seal.d.ts +2 -0
- package/dist/staged-seal.d.ts.map +1 -0
- package/dist/staged-seal.js +76 -0
- package/dist/staged-seal.js.map +1 -0
- package/dist/subscriber-delivery.d.ts +8 -0
- package/dist/subscriber-delivery.d.ts.map +1 -0
- package/dist/subscriber-delivery.js +31 -0
- package/dist/subscriber-delivery.js.map +1 -0
- package/dist/tail-op-import.d.ts +10 -0
- package/dist/tail-op-import.d.ts.map +1 -0
- package/dist/tail-op-import.js +33 -0
- package/dist/tail-op-import.js.map +1 -0
- package/dist/topic-lifecycle.d.ts +6 -0
- package/dist/topic-lifecycle.d.ts.map +1 -1
- package/dist/topic-lifecycle.js +32 -2
- package/dist/topic-lifecycle.js.map +1 -1
- package/dist/trial-replace-dialog.d.ts +10 -0
- package/dist/trial-replace-dialog.d.ts.map +1 -0
- package/dist/trial-replace-dialog.js +153 -0
- package/dist/trial-replace-dialog.js.map +1 -0
- package/dist/types.d.ts +8 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/undo.d.ts +60 -5
- package/dist/undo.d.ts.map +1 -1
- package/dist/undo.js +344 -75
- package/dist/undo.js.map +1 -1
- package/dist/version-bar.d.ts +1 -0
- package/dist/version-bar.d.ts.map +1 -1
- package/dist/version-bar.js +2 -1
- package/dist/version-bar.js.map +1 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/dist/ws-client.d.ts +21 -0
- package/dist/ws-client.d.ts.map +1 -1
- package/dist/ws-client.js +192 -8
- package/dist/ws-client.js.map +1 -1
- package/dist/ws-frame-limit.d.ts +1 -0
- package/dist/ws-frame-limit.d.ts.map +1 -1
- package/dist/ws-frame-limit.js.map +1 -1
- package/docs/app-manifest.md +121 -17
- package/docs/custom-chrome.md +24 -16
- package/docs/hosted-ui-advanced.md +38 -6
- package/docs/imported-documents.md +104 -51
- package/docs/presence.md +16 -4
- package/docs/schemas.md +8 -5
- package/docs/services.md +6 -6
- package/docs/streams.md +139 -150
- package/docs/text-fields.md +26 -19
- package/package.json +5 -2
- package/src/acting-organization.ts +6 -12
- package/src/api-fetch.ts +53 -3
- package/src/auth-flow.ts +1 -1
- package/src/batched-get.ts +117 -0
- package/src/call-service.ts +78 -3
- package/src/client.ts +1092 -121
- package/src/document-checkpoints.ts +307 -65
- package/src/document-commands.ts +5 -5
- package/src/document-contents.ts +27 -1
- package/src/document-set-subscription.ts +274 -0
- package/src/document-signals.ts +47 -5
- package/src/document-streams.ts +85 -16
- package/src/document-thumbnail-capture.ts +118 -32
- package/src/documents-namespace.ts +539 -84
- package/src/documents.ts +2017 -348
- package/src/errors.ts +17 -6
- package/src/fetch-retry.ts +5 -0
- package/src/file-choice.ts +26 -0
- package/src/guest-link.ts +362 -1
- package/src/handle-types.ts +73 -47
- package/src/handles.ts +253 -83
- package/src/hosted-ui.ts +211 -50
- package/src/import-contract.ts +227 -0
- package/src/import-guest.ts +193 -0
- package/src/import-report.ts +24 -0
- package/src/importer-wait.ts +4 -0
- package/src/importer.ts +386 -131
- package/src/in-development.ts +10 -0
- package/src/index.ts +76 -16
- package/src/init-keys.ts +6 -1
- package/src/input-documents.ts +819 -0
- package/src/inputs-status.ts +598 -0
- package/src/inputs.ts +1090 -0
- package/src/internal.ts +7 -0
- package/src/join-content.ts +135 -0
- package/src/join-stamp.ts +12 -0
- package/src/manifest-drift.ts +54 -0
- package/src/open-document.ts +2346 -930
- package/src/page-restore.ts +38 -0
- package/src/placement.ts +54 -0
- package/src/presence.ts +21 -5
- package/src/read-only-document.ts +133 -0
- package/src/record-paging.ts +147 -25
- package/src/references.ts +207 -0
- package/src/request-lane.ts +59 -0
- package/src/sealed-arrow.ts +843 -0
- package/src/sealed-cache.ts +267 -0
- package/src/sealed-encode.ts +1015 -0
- package/src/sealed-handles.ts +486 -0
- package/src/sealed-store.ts +1330 -0
- package/src/serializer.ts +8 -20
- package/src/session-toolbar.ts +463 -72
- package/src/session.ts +740 -99
- package/src/sha256.ts +65 -0
- package/src/stable-handles.ts +235 -0
- package/src/staged-seal.ts +90 -0
- package/src/subscriber-delivery.ts +37 -0
- package/src/tail-op-import.ts +30 -0
- package/src/topic-lifecycle.ts +38 -2
- package/src/trial-replace-dialog.ts +163 -0
- package/src/types.ts +6 -6
- package/src/undo.ts +386 -73
- package/src/version-bar.ts +5 -1
- package/src/version.ts +1 -1
- package/src/ws-client.ts +208 -16
- package/src/ws-frame-limit.ts +1 -0
package/README.md
CHANGED
|
@@ -28,8 +28,9 @@ npm install @brass-build/client
|
|
|
28
28
|
- [Document lifecycle](#document-lifecycle)
|
|
29
29
|
- [Editing documents](#editing-documents)
|
|
30
30
|
- [Presence](#presence-live-cursors-and-whos-here)
|
|
31
|
-
- [Streams](#streams-
|
|
31
|
+
- [Streams](#streams-loading-a-document-in-parts)
|
|
32
32
|
- [Imported files](#imported-files)
|
|
33
|
+
- [Referencing other documents](#referencing-other-documents)
|
|
33
34
|
- [Calling an outside service](#calling-an-outside-service)
|
|
34
35
|
- [Reference](#reference)
|
|
35
36
|
- [Guides](#guides)
|
|
@@ -46,7 +47,7 @@ integration.
|
|
|
46
47
|
Build a shared checklist on @brass-build/client (NPM).
|
|
47
48
|
```
|
|
48
49
|
|
|
49
|
-
**Or wire it by hand
|
|
50
|
+
**Or wire it by hand:**
|
|
50
51
|
|
|
51
52
|
```ts
|
|
52
53
|
import { Brass } from '@brass-build/client';
|
|
@@ -61,8 +62,7 @@ Brass.onError((err) => console.error('[Brass]', err.message));
|
|
|
61
62
|
Brass.onUserChange(renderAuthState);
|
|
62
63
|
signInButton.onclick = () => Brass.signIn();
|
|
63
64
|
|
|
64
|
-
//
|
|
65
|
-
// account menu, presence roster.
|
|
65
|
+
// Document title, file menu, undo / redo, account menu, presence roster.
|
|
66
66
|
Brass.mountToolbar(appBar);
|
|
67
67
|
|
|
68
68
|
// Rebind your editor to whichever document the session opens. Compare
|
|
@@ -89,8 +89,8 @@ Then build and publish:
|
|
|
89
89
|
|
|
90
90
|
```sh
|
|
91
91
|
npm run build
|
|
92
|
-
npx @brass-build/cli login #
|
|
93
|
-
npx @brass-build/cli publish ./dist
|
|
92
|
+
npx @brass-build/cli login # sign in
|
|
93
|
+
npx @brass-build/cli publish ./dist --name "My app"
|
|
94
94
|
```
|
|
95
95
|
|
|
96
96
|
The first publish creates the app, hosts it at
|
|
@@ -102,8 +102,9 @@ app id (the `brass_app_...` above).
|
|
|
102
102
|
To take the [quickstart](#quickstart) from source to a
|
|
103
103
|
signed-in, hosted app:
|
|
104
104
|
|
|
105
|
-
1. **Ship it.** `npx @brass-build/cli publish ./dist
|
|
106
|
-
hosts it, and returns its sign-in URL. See
|
|
105
|
+
1. **Ship it.** `npx @brass-build/cli publish ./dist --name "My app"`
|
|
106
|
+
creates the app, hosts it, and returns its sign-in URL. See
|
|
107
|
+
[Ship it](#ship-it).
|
|
107
108
|
2. **Pass an app id unless Brass hosts your bundle.** A published bundle
|
|
108
109
|
resolves its id from the slot it is served on, so it calls
|
|
109
110
|
`Brass.init()` with no argument. Pass the id everywhere else, in local
|
|
@@ -116,12 +117,13 @@ signed-in, hosted app:
|
|
|
116
117
|
(`Brass.init(import.meta.env.VITE_BRASS_APP_ID)`) and Brass falls back
|
|
117
118
|
to the serving slot. Set the variable for local development as well:
|
|
118
119
|
`localhost` has no slot to fall back to, and an absent id fails init.
|
|
119
|
-
3. **Allow your development origin.**
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
the app's
|
|
120
|
+
3. **Allow your development origin if needed.** When you create an app
|
|
121
|
+
interactively via the CLI or dashboard, it allows sign-in from
|
|
122
|
+
`localhost` and `127.0.0.1` on any port by default. If the app was
|
|
123
|
+
created non-interactively using an automated service token (such as
|
|
124
|
+
in CI), it only permits its deployed origin. In that case, add your local
|
|
125
|
+
dev URL (such as `http://localhost:5173/*`) to the app's allowed origins
|
|
126
|
+
in the dashboard before signing in locally.
|
|
125
127
|
|
|
126
128
|
**CSP.** If (and only if) your app sends a `Content-Security-Policy`,
|
|
127
129
|
extend it: `script-src` needs `'wasm-unsafe-eval'` for the SDK's
|
|
@@ -140,12 +142,12 @@ asset of about 3 MB, an order of magnitude larger than your JavaScript.
|
|
|
140
142
|
Serve it as `application/wasm`: browsers compile it while it streams,
|
|
141
143
|
and fall back to a slower path when the content type is something else.
|
|
142
144
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
`index.html
|
|
148
|
-
|
|
145
|
+
Off Brass hosting (on your own origin or a dev server),
|
|
146
|
+
`Brass.init('brass_app_...')` also requests
|
|
147
|
+
`/.well-known/brass-runtime.json`. Do not serve that file. Your server
|
|
148
|
+
answers with a 404, which the console logs in red, or a dev server answers
|
|
149
|
+
with its `index.html`. Either answer is fine, and the SDK carries on with
|
|
150
|
+
the app id you passed.
|
|
149
151
|
|
|
150
152
|
### Vite dev server
|
|
151
153
|
|
|
@@ -153,7 +155,7 @@ Vite pre-bundles your dependencies for its dev server, which copies this
|
|
|
153
155
|
package into `node_modules/.vite/deps/` without moving the engine's binary
|
|
154
156
|
alongside it. The request for the binary then resolves inside that
|
|
155
157
|
directory, finds nothing, and the dev server answers with `index.html`, so
|
|
156
|
-
the engine
|
|
158
|
+
the engine receives a page to compile and the app fails when it first
|
|
157
159
|
opens a document. Keep this package out of that step:
|
|
158
160
|
|
|
159
161
|
```js
|
|
@@ -179,9 +181,9 @@ covers when you need one). The second is a typed options object,
|
|
|
179
181
|
| `docUrlParam` | `'doc'` (the query param for the active doc id) |
|
|
180
182
|
| `branchUrlParam` | `'branch'` (the query param for a non-default branch) |
|
|
181
183
|
| `syncDocToTitle` | `true` (set the page title to `Doc name · App name` while a document is open) |
|
|
182
|
-
| `
|
|
184
|
+
| `open` | `{ readOnly: false }` (set `{ readOnly: true }` to open documents in read-only mode by default) |
|
|
183
185
|
|
|
184
|
-
`Brass.init` resolves once it knows who the
|
|
186
|
+
`Brass.init` resolves once it knows who the user is, which takes one
|
|
185
187
|
auth round-trip for someone who has signed in to your app before and no
|
|
186
188
|
request at all for a first-time visitor. Register `onError` and
|
|
187
189
|
`onUserChange` BEFORE calling `init`, so that validation failures fire
|
|
@@ -197,16 +199,18 @@ you. Run it with the `brass` CLI:
|
|
|
197
199
|
|
|
198
200
|
```sh
|
|
199
201
|
npm run build
|
|
200
|
-
npx @brass-build/cli publish ./dist
|
|
202
|
+
npx @brass-build/cli publish ./dist --name "My app"
|
|
201
203
|
```
|
|
202
204
|
|
|
203
|
-
The first publish creates the app
|
|
205
|
+
The first publish creates the app named by `--name`, enables hosting, uploads the build,
|
|
204
206
|
and writes the app id to `.brass/project.json`. Commit that file so
|
|
205
207
|
later publishes and your teammates target the same app.
|
|
206
208
|
|
|
207
|
-
Run `brass login` once first to authenticate
|
|
208
|
-
|
|
209
|
-
|
|
209
|
+
Run `brass login` once first to authenticate. It opens your browser,
|
|
210
|
+
and on a machine without one it prints a URL and a short code you
|
|
211
|
+
approve from any device. In a pipeline, set `BRASS_SERVICE_TOKEN`
|
|
212
|
+
instead. The CLI also pulls a document's schema into your
|
|
213
|
+
`brass-app.json`. See
|
|
210
214
|
[`@brass-build/cli`](https://www.npmjs.com/package/@brass-build/cli).
|
|
211
215
|
|
|
212
216
|
## Rendering auth state
|
|
@@ -220,16 +224,15 @@ flashes the sign-in screen at signed-in users on every reload. Render
|
|
|
220
224
|
the CTA only on `'signed-out'`, and render your working surface for
|
|
221
225
|
**both** `'signed-in'` and `'guest'`.
|
|
222
226
|
|
|
223
|
-
`'guest'`
|
|
224
|
-
|
|
225
|
-
opens
|
|
226
|
-
|
|
227
|
-
that read-only document on its own.
|
|
227
|
+
`'guest'` means the page has a document open without a signed-in user:
|
|
228
|
+
someone opened an "anyone with the link" view link, which the session
|
|
229
|
+
opens **read-only**, or a visitor made a document without an account
|
|
230
|
+
(below), which is editable. `Brass.isGuest` is `true` in both cases.
|
|
228
231
|
|
|
229
|
-
`Brass.onUserChange` is the single source for reacting to auth
|
|
230
|
-
changes. It
|
|
231
|
-
`
|
|
232
|
-
|
|
232
|
+
`Brass.onUserChange(cb)` is the single source for reacting to auth
|
|
233
|
+
changes. It calls `cb` once when `init` resolves, and again whenever
|
|
234
|
+
`Brass.user` or `Brass.authStatus` changes. A listener registered before
|
|
235
|
+
`init` does not fire on registration. One registered after does.
|
|
233
236
|
|
|
234
237
|
The [quickstart](#quickstart) sample gates on `'signed-out'` for exactly
|
|
235
238
|
this reason. A framework app (React, Vue, Svelte) paints its initial
|
|
@@ -245,9 +248,95 @@ Brass.onUserChange(() => { authStatus.value = Brass.authStatus; });
|
|
|
245
248
|
```html
|
|
246
249
|
<SplashOrNothing v-if="authStatus === 'unknown'" />
|
|
247
250
|
<SignInCard v-else-if="authStatus === 'signed-out'" />
|
|
248
|
-
<AppShell v-else /> <!-- signed-in or guest
|
|
251
|
+
<AppShell v-else /> <!-- signed-in or guest -->
|
|
249
252
|
```
|
|
250
253
|
|
|
254
|
+
### Try before signing in
|
|
255
|
+
|
|
256
|
+
A visitor who can try your app without an account sees it work on their own
|
|
257
|
+
file before deciding to sign up. What your app can offer
|
|
258
|
+
that visitor depends on what it needs from an account: a visitor belongs to
|
|
259
|
+
no organization, so they cannot pick files for `Brass.chooseInputs`, and your
|
|
260
|
+
app cannot call a service from its manifest on their behalf. The session's
|
|
261
|
+
New and Open commands work without an account, so give your signed-out screen
|
|
262
|
+
buttons that run them:
|
|
263
|
+
|
|
264
|
+
```ts
|
|
265
|
+
signInButton.onclick = () => Brass.signIn();
|
|
266
|
+
newButton.onclick = () => Brass.session.run('new');
|
|
267
|
+
uploadButton.onclick = () => Brass.session.run('open');
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
For visitors without an account, the `open` command triggers the browser's
|
|
271
|
+
native file picker to upload a local file directly, and its label in
|
|
272
|
+
`Brass.session.commands` changes from "Open" to "Upload". Render the
|
|
273
|
+
button label from that command in your `Brass.session.subscribe` callback
|
|
274
|
+
rather than hardcoding "Open":
|
|
275
|
+
|
|
276
|
+
```ts
|
|
277
|
+
Brass.session.subscribe((session) => {
|
|
278
|
+
uploadButton.textContent =
|
|
279
|
+
session.commands.find((c) => c.id === 'open')?.label ?? 'Open';
|
|
280
|
+
});
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
Always trigger `Brass.session.run('open')` directly from a
|
|
284
|
+
button click handler, because browsers only permit file dialogs to open in
|
|
285
|
+
response to a direct user action. The file dialog filters for the file types
|
|
286
|
+
supported by your app.
|
|
287
|
+
|
|
288
|
+
While the file uploads and imports, `Brass.session.status` reads
|
|
289
|
+
`'loading'` and `Brass.authStatus` still reads `'signed-out'`, so your
|
|
290
|
+
signed-out screen renders the loading state. When Brass refuses the file
|
|
291
|
+
or the document, `Brass.session.error` carries the reason.
|
|
292
|
+
|
|
293
|
+
A document created without an account saves and syncs like any other, and a
|
|
294
|
+
reload or return visit in the same browser reopens it. If you use
|
|
295
|
+
`Brass.mountToolbar()`, the toolbar displays an expiration notice
|
|
296
|
+
automatically. If your app builds custom chrome instead, check
|
|
297
|
+
`doc.expiresAt` (which contains a `Date` for temporary documents, and `null`
|
|
298
|
+
for persistent ones) and display the remaining time to the visitor.
|
|
299
|
+
|
|
300
|
+
Do not offer `Brass.chooseInputs` or `Brass.chooseInput` on a temporary
|
|
301
|
+
document, because passing the chosen references to `doc.dependsOn` throws
|
|
302
|
+
there. Only display
|
|
303
|
+
those controls when `Brass.authStatus === 'signed-in'`.
|
|
304
|
+
`doc.subscribeInputDocuments` works on a temporary document and reports no
|
|
305
|
+
inputs, so subscribe without checking the sign-in state.
|
|
306
|
+
|
|
307
|
+
To save a temporary document permanently to an account, prompt the visitor to
|
|
308
|
+
sign in using `Brass.signIn()` (or the sign-in button in
|
|
309
|
+
`Brass.mountToolbar()`). When a visitor signs in from the active document
|
|
310
|
+
page, Brass automatically transfers the temporary document into their workspace.
|
|
311
|
+
|
|
312
|
+
Because a temporary document cannot reference other files, a visitor who is
|
|
313
|
+
not signed in can do nothing in an app whose content comes from the files
|
|
314
|
+
people pick, or in one where every action calls a service. For such an app,
|
|
315
|
+
declare `"create": "signed-in"` and skip the rest of this section. An app that
|
|
316
|
+
uses a service for one feature can still offer the rest to a visitor.
|
|
317
|
+
|
|
318
|
+
To let visitors without an account use your app, declare it in your
|
|
319
|
+
[`brass-app.json`](./docs/app-manifest.md):
|
|
320
|
+
|
|
321
|
+
```json
|
|
322
|
+
{ "access": { "create": "anyone" } }
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
and publish your app with `--gate off` and `--visibility public`:
|
|
326
|
+
|
|
327
|
+
```sh
|
|
328
|
+
npx @brass-build/cli publish ./dist --gate off --visibility public
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
- `--gate off` allows visitors to load your application bundle before
|
|
332
|
+
signing in (by default, Brass requires sign-in before loading the app).
|
|
333
|
+
- `"create": "anyone"` and `--visibility public` allow visitors without an
|
|
334
|
+
account to create, upload and edit temporary documents.
|
|
335
|
+
|
|
336
|
+
A visitor who is not signed in can also open an organization's public links
|
|
337
|
+
in your app. To ask visitors to sign in before a public link opens, set
|
|
338
|
+
`"open": "signed-in"` under `access`.
|
|
339
|
+
|
|
251
340
|
## Default UI
|
|
252
341
|
|
|
253
342
|
`Brass.mountToolbar(container)` fills a container with the full
|
|
@@ -274,11 +363,10 @@ filling the rest:
|
|
|
274
363
|
</div>
|
|
275
364
|
```
|
|
276
365
|
|
|
277
|
-
**Mount it once when the user signs in, not when a
|
|
278
|
-
New and Open live in its file menu, so
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
options `{ presence?, account?, drive? }` (all default to `true`), and
|
|
366
|
+
**Mount it once when the user signs in or becomes a guest, not when a
|
|
367
|
+
document opens.** New and Open live in its file menu, so the bar has to
|
|
368
|
+
exist before any document does. It re-renders itself as documents open,
|
|
369
|
+
switch, and close. It takes options `{ presence?, account?, drive? }` (all default to `true`), and
|
|
282
370
|
the returned handle's `destroy()` removes it.
|
|
283
371
|
|
|
284
372
|
A multi-pane app that builds its own header takes the standard pieces
|
|
@@ -320,11 +408,11 @@ through the document's own `subscribe` (below), not here.
|
|
|
320
408
|
| `status` | Meaning | Render |
|
|
321
409
|
| ------------ | ---------------------------------------- | ------------------------------------ |
|
|
322
410
|
| `unknown` | `init` not settled; auth not yet known. | Nothing, or a splash. |
|
|
323
|
-
| `signed-out` | No signed-in user. | The sign-in CTA.
|
|
411
|
+
| `signed-out` | No signed-in user. | The sign-in CTA (and Try without an account, if your app offers it). |
|
|
324
412
|
| `empty` | Signed in, no document open. | "No document open. Use New or Open." |
|
|
325
413
|
| `loading` | An open / create is in flight. | A spinner or progress bar (below). |
|
|
326
414
|
| `ready` | A document is open (`session.document`). | Your editor. |
|
|
327
|
-
| `error` | A document couldn't be opened,
|
|
415
|
+
| `error` | A document couldn't be opened, the open one was deleted, or `init` was misconfigured (no app ID).| `session.error.message` + your recovery actions (below) |
|
|
328
416
|
|
|
329
417
|
Gate edit affordances on `ready`: a canvas that accepts input in the
|
|
330
418
|
`empty` state lets the user produce work with nowhere to save it.
|
|
@@ -356,12 +444,12 @@ indeterminate). The document binds only after its content downloads,
|
|
|
356
444
|
so the first `ready` render already has the real model to read
|
|
357
445
|
directly.
|
|
358
446
|
|
|
359
|
-
**Work on an open document.** Brass
|
|
447
|
+
**Work on an open document.** Brass imports a new version of a file, syncs a
|
|
360
448
|
document to its drive, and does other work while the document is open.
|
|
361
449
|
`doc.status` carries that work and reads `null` when there is none;
|
|
362
450
|
`doc.onStatus(cb)` fires on every phase. Render `status.message`
|
|
363
451
|
verbatim and read `status.pending` for whether the work is still
|
|
364
|
-
running. The message is prose written for the
|
|
452
|
+
running. The message is prose written for the user, so show it rather
|
|
365
453
|
than parsing it or inferring a phase from it. Both are readable
|
|
366
454
|
synchronously, so a fresh bind renders the current state without waiting
|
|
367
455
|
for a fire.
|
|
@@ -386,11 +474,12 @@ document, or a platform-chosen home from the empty state.
|
|
|
386
474
|
await Brass.session.create({ name: 'Scheme B' });
|
|
387
475
|
```
|
|
388
476
|
|
|
389
|
-
`session.
|
|
390
|
-
|
|
391
|
-
`
|
|
392
|
-
|
|
393
|
-
|
|
477
|
+
`session.open()` with no arguments opens the hosted document picker and
|
|
478
|
+
opens the selected document in the session, resolving to `null` if the user
|
|
479
|
+
cancels. Use `session.create()` and `session.open()` when your app provides its
|
|
480
|
+
own buttons to create or open documents (such as empty-state prompts or
|
|
481
|
+
keyboard shortcuts). For signed-out visitors, these commands behave as
|
|
482
|
+
described in [Try before signing in](#try-before-signing-in).
|
|
394
483
|
|
|
395
484
|
**Keyboard shortcuts** route through `session.run`, which drives the
|
|
396
485
|
same undo / redo commands the toolbar buttons use. `session.commands`
|
|
@@ -417,9 +506,103 @@ Code that runs outside the render loop awaits the bound document with
|
|
|
417
506
|
as `session.document`, immediately if one is already bound, and rejects
|
|
418
507
|
if the open fails or the user signs out before one binds.
|
|
419
508
|
|
|
420
|
-
The session's Open and Share commands
|
|
421
|
-
|
|
422
|
-
|
|
509
|
+
The session's Open and Share commands show the hosted dialogs for you,
|
|
510
|
+
so browsing, picking, and sharing an existing document take no code.
|
|
511
|
+
|
|
512
|
+
**Opening in read-only mode.** By default, the session opens documents for
|
|
513
|
+
editing. If your app only views or inspects documents, set
|
|
514
|
+
`open: { readOnly: true }` in `Brass.init`:
|
|
515
|
+
|
|
516
|
+
```ts
|
|
517
|
+
await Brass.init(appId, { open: { readOnly: true } });
|
|
518
|
+
```
|
|
519
|
+
|
|
520
|
+
With this option, the session opens every document in read-only mode (whether
|
|
521
|
+
opened from the toolbar, a direct link, or a page reload).
|
|
522
|
+
|
|
523
|
+
To choose the mode for one document instead, pass `{ readOnly: true }` or
|
|
524
|
+
`{ readOnly: false }` to `session.open`, which overrides the init option for
|
|
525
|
+
that open.
|
|
526
|
+
|
|
527
|
+
Because `session.document` uses the `Document` type in both modes and
|
|
528
|
+
mutating a read-only document throws an error, check `doc.readOnly` before
|
|
529
|
+
displaying editing tools or enabling write actions.
|
|
530
|
+
|
|
531
|
+
If your app edits certain document families and only views others, initialize
|
|
532
|
+
in read-only mode, inspect the document's family, and upgrade when appropriate:
|
|
533
|
+
|
|
534
|
+
```ts
|
|
535
|
+
const doc = Brass.session.document;
|
|
536
|
+
if (doc?.readOnly && doc.id) {
|
|
537
|
+
const { family } = await Brass.documents.get(doc.id);
|
|
538
|
+
if (family && canEditFamily(family)) await doc.reopen(); // reopens with write access
|
|
539
|
+
}
|
|
540
|
+
```
|
|
541
|
+
|
|
542
|
+
`session.open(docId, options)` and `Brass.openDocumentById(docId, options)`
|
|
543
|
+
support these modes:
|
|
544
|
+
|
|
545
|
+
- **Default (editable):** Opens the document with full editing capabilities.
|
|
546
|
+
`doc.readOnly` is `false` unless the user only has view access or the
|
|
547
|
+
document is too large to edit (`doc.atCapacity === true`).
|
|
548
|
+
- **`{ readOnly: true }`:** Opens the document in read-only mode while still
|
|
549
|
+
receiving other users' edits in real time. Returns a `ReadOnlyDocument`,
|
|
550
|
+
whose type exposes no mutation methods.
|
|
551
|
+
- **`{ frozen: true }` (supported on `Brass.openDocumentById`):** Opens a
|
|
552
|
+
static, read-only snapshot that disconnects from real-time sync. Use this for
|
|
553
|
+
batch processing, reference data, or background tasks that only need to read
|
|
554
|
+
data once without keeping an open WebSocket connection. Register
|
|
555
|
+
`doc.onChanged(cb)` to detect when newer changes are available on the
|
|
556
|
+
server, and call `await doc.reload()` to update the snapshot to the latest
|
|
557
|
+
version.
|
|
558
|
+
|
|
559
|
+
**Handling open errors and pending imports.** When opening a document backed by
|
|
560
|
+
an imported file, the open waits for any in-progress conversion to finish. If the
|
|
561
|
+
import fails or the file format cannot be opened, the call rejects with a
|
|
562
|
+
`BrassError`. Display `error.message` in your error UI rather than attempting
|
|
563
|
+
to model the import lifecycle. If `subscribeInputDocuments` is watching the file,
|
|
564
|
+
it will invoke your callback once the file becomes openable.
|
|
565
|
+
|
|
566
|
+
**Switching modes with `reopen()`.** Call `await doc.reopen(options)` to switch
|
|
567
|
+
an open document between editable and read-only modes:
|
|
568
|
+
|
|
569
|
+
```ts
|
|
570
|
+
// Upgrade a read-only document to editable
|
|
571
|
+
await doc.reopen();
|
|
572
|
+
|
|
573
|
+
// Switch to read-only
|
|
574
|
+
await doc.reopen({ readOnly: true });
|
|
575
|
+
```
|
|
576
|
+
|
|
577
|
+
Brass flushes any unsaved changes, closes the previous instance, and passes the
|
|
578
|
+
new document instance to `session.subscribe`. Reopening for editing rejects with
|
|
579
|
+
a 403 `BrassError` if the user only has view access.
|
|
580
|
+
|
|
581
|
+
**Closing documents.** Call `doc.close()` when your app is finished with a
|
|
582
|
+
document opened via `Brass.openDocumentById()`:
|
|
583
|
+
|
|
584
|
+
```ts
|
|
585
|
+
const doc = await Brass.openDocumentById(id, { frozen: true });
|
|
586
|
+
try {
|
|
587
|
+
for (const [key, value] of doc.map('elements').entries()) {
|
|
588
|
+
processElement(key, value);
|
|
589
|
+
}
|
|
590
|
+
} finally {
|
|
591
|
+
doc.close();
|
|
592
|
+
}
|
|
593
|
+
```
|
|
594
|
+
|
|
595
|
+
### Large imported files
|
|
596
|
+
|
|
597
|
+
If your application only views or inspects documents, open them in read-only mode
|
|
598
|
+
(`{ readOnly: true }` or `Brass.init(appId, { open: { readOnly: true } })`).
|
|
599
|
+
Read-only and frozen modes load significantly faster and require far less bandwidth
|
|
600
|
+
than editable sessions. Unchanged documents can reopen directly from the local
|
|
601
|
+
cache without downloading again.
|
|
602
|
+
|
|
603
|
+
When `doc.atCapacity` is `true`, the document exceeds the maximum size supported
|
|
604
|
+
for collaborative editing. Disable editing tools and bulk operations, and display
|
|
605
|
+
`doc.status.message` to inform the user.
|
|
423
606
|
|
|
424
607
|
## Editing documents
|
|
425
608
|
|
|
@@ -446,9 +629,12 @@ function bindEditor(doc: Document): void {
|
|
|
446
629
|
```
|
|
447
630
|
|
|
448
631
|
Compare the **`Document` instance**, never `document.id`. The session
|
|
449
|
-
|
|
632
|
+
provides a fresh instance on every open, and a version preview is a
|
|
450
633
|
new instance with the same id, so an id comparison would skip the
|
|
451
|
-
rebind and strand your editor on the previous state.
|
|
634
|
+
rebind and strand your editor on the previous state. Brass also
|
|
635
|
+
swaps in a fresh copy of the document when an import finishes or a
|
|
636
|
+
just-imported document becomes editable, passing it to `session.subscribe`
|
|
637
|
+
once its content is in place, so keep the current view on screen until then.
|
|
452
638
|
|
|
453
639
|
### Read and mutate
|
|
454
640
|
|
|
@@ -460,7 +646,7 @@ touches the slice it cares about instead of walking the whole
|
|
|
460
646
|
document.
|
|
461
647
|
|
|
462
648
|
The SDK exports each accessor's return type, for a signature or a field
|
|
463
|
-
that has to
|
|
649
|
+
that has to store one: `MapHandle<T>`, `OrderedMapHandle<T>`,
|
|
464
650
|
`ListHandle<T>` and `TextHandle`.
|
|
465
651
|
|
|
466
652
|
**Make an item the smallest thing two people edit independently.** Two
|
|
@@ -482,22 +668,39 @@ walls.mutate((m) => { delete m[id]; }); // remove
|
|
|
482
668
|
const one = walls.get(id); // Wall | undefined
|
|
483
669
|
```
|
|
484
670
|
|
|
671
|
+
Every handle reads the same way, whether the document opened editable or
|
|
672
|
+
read-only: `get`, `has`, `keys()` and `entries()` on a map or ordered map,
|
|
673
|
+
and `length()`, `get(index)` and `values()` on a list. `entries()` and
|
|
674
|
+
`values()` walk the container. Render from `subscribe`: after its initial
|
|
675
|
+
invocation, each subsequent callback delivers only what changed.
|
|
676
|
+
|
|
677
|
+
When your app reads a container once (such as for an export, summary count, or
|
|
678
|
+
comparison), walk it with `entries()` or `values()` instead of creating a subscription.
|
|
679
|
+
On large documents, creating a subscription incurs higher overhead because it constructs
|
|
680
|
+
change entries for every existing record upon initial delivery.
|
|
681
|
+
|
|
682
|
+
```ts
|
|
683
|
+
let total = 0;
|
|
684
|
+
for (const [, wall] of walls.entries()) total += wall.x;
|
|
685
|
+
```
|
|
686
|
+
|
|
485
687
|
The draft indexes as `Record<string, T>`, so a read is `T | undefined`
|
|
486
688
|
and the field edit checks it: under `noUncheckedIndexedAccess` that is
|
|
487
689
|
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
|
|
690
|
+
record between your render and your click. A record you keep that way is
|
|
489
691
|
live, so writing through it writes to the document.
|
|
490
692
|
|
|
491
693
|
Handles are cached per key (`doc.map('walls') === doc.map('walls')`),
|
|
492
694
|
so they compare stable across renders. `doc.list<T>(key)` is the same
|
|
493
|
-
surface for a root list; its mutate draft is
|
|
494
|
-
indexed access returns a live element, so find an index then edit it in
|
|
695
|
+
surface for a root list; its mutate draft is an array (with a `move`
|
|
696
|
+
method, below), and indexed access returns a live element, so find an index then edit it in
|
|
495
697
|
place:
|
|
496
698
|
|
|
497
699
|
```ts
|
|
498
700
|
doc.list<Col>('cols').mutate((l) => {
|
|
499
701
|
const i = l.findIndex((c) => c.id === colId);
|
|
500
|
-
|
|
702
|
+
const col = l[i];
|
|
703
|
+
if (col) col.name = newName; // or, when i >= 0, l.splice(i, 1) to remove it
|
|
501
704
|
});
|
|
502
705
|
```
|
|
503
706
|
|
|
@@ -513,17 +716,17 @@ insertion order and a delete prunes from the order, both in the same
|
|
|
513
716
|
commit as the map edit. A hand-rolled key goes wrong here because
|
|
514
717
|
nothing gives you a stable number to build one from: a count shifts as
|
|
515
718
|
entries are deleted, so a later entry can sort before an earlier one.
|
|
516
|
-
Read the collection in order with `
|
|
719
|
+
Read the collection in order with `entries()` or `keys()`.
|
|
517
720
|
|
|
518
721
|
```ts
|
|
519
722
|
const notes = doc.orderedMap<Note>('notes', 'note_order');
|
|
520
723
|
notes.mutate((m) => { m[id] = { text: '' }; }); // appends to the order
|
|
521
724
|
notes.mutate((m) => { const n = m[id]; if (n) n.text = 'hi'; }); // order untouched
|
|
522
|
-
for (const
|
|
725
|
+
for (const [key, value] of notes.entries()) renderRow(key, value);
|
|
523
726
|
```
|
|
524
727
|
|
|
525
728
|
`insert(key, value, at)` puts a new key somewhere other than the end,
|
|
526
|
-
and `move(key, to)` repositions one the collection already
|
|
729
|
+
and `move(key, to)` repositions one the collection already contains. Both
|
|
527
730
|
write the record and its place in the order in one commit, the way a
|
|
528
731
|
`mutate` add does.
|
|
529
732
|
|
|
@@ -534,11 +737,11 @@ notes.move(dragged, { index: 0 }); // send to the top
|
|
|
534
737
|
```
|
|
535
738
|
|
|
536
739
|
Name a neighbouring key where you have one. A key still anchors where
|
|
537
|
-
the
|
|
740
|
+
the user meant after a peer adds or removes a row between your render
|
|
538
741
|
and your write, where an index by then counts different rows.
|
|
539
742
|
`{ index: n }` counts the positions `keys()` reports and leaves the key
|
|
540
|
-
at `n`, or last when `n` is past the end. Two
|
|
541
|
-
spot both keep their record, and a
|
|
743
|
+
at `n`, or last when `n` is past the end. Two users inserting at one
|
|
744
|
+
spot both keep their record, and a user who repositions a record
|
|
542
745
|
another deletes leaves the delete standing.
|
|
543
746
|
|
|
544
747
|
A field edit leaves the order alone, so its `subscribe` fire carries
|
|
@@ -554,16 +757,15 @@ operates on the same state as the handles, so an app can mix the two.
|
|
|
554
757
|
Three mutation rules:
|
|
555
758
|
|
|
556
759
|
- **Edit a nested list by assigning a new array, or through its own
|
|
557
|
-
handle.** `m[id].tags = [...m[id].tags, x]` persists
|
|
558
|
-
|
|
559
|
-
handle of its own. Nested **maps** walked through the proxy are live.
|
|
760
|
+
handle.** `m[id].tags = [...m[id].tags, x]` persists, and you can give
|
|
761
|
+
a list you edit often its own handle with `parent.list(key)`. Nested **maps** walked through the proxy are live.
|
|
560
762
|
- **Don't write `undefined`.** Mutations JSON-serialise, so
|
|
561
763
|
`undefined` inside an array becomes `null` and `undefined` property
|
|
562
764
|
values are dropped. Use `null`, length checks, or `'key' in obj`
|
|
563
765
|
instead.
|
|
564
|
-
- **Keys are yours except for the `brass:` prefix.**
|
|
565
|
-
|
|
566
|
-
|
|
766
|
+
- **Keys are yours except for the `brass:` prefix.** Writing under a key
|
|
767
|
+
that begins with `brass:` throws. Keys under `brass:` never appear in
|
|
768
|
+
`keys()`, `entries()` or `get`, so iterate without filtering.
|
|
567
769
|
|
|
568
770
|
A document starts **empty**, and the user's first action is their
|
|
569
771
|
first undoable edit. Don't seed a baseline from the client: a faked
|
|
@@ -603,16 +805,16 @@ report.subscribe((value, origin) => {
|
|
|
603
805
|
|
|
604
806
|
`origin` says who wrote the change, the way a map subscriber's does. The
|
|
605
807
|
app's own write comes back through the subscriber too, and it is the one
|
|
606
|
-
fire that must not touch the field: the
|
|
808
|
+
fire that must not touch the field: the user is already looking at what
|
|
607
809
|
they typed. Every other fire has to paint without throwing away the caret
|
|
608
810
|
of whoever is typing, which is what `paintPeerEdit` above stands for.
|
|
609
811
|
|
|
610
812
|
**A text container is a top-level root of the document**, so take one per
|
|
611
813
|
document-shaped thing: a body, a section, a summary. Prose that belongs to
|
|
612
814
|
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
|
|
614
|
-
collection grows. A root per row opts out of that paging, and
|
|
615
|
-
|
|
815
|
+
is the merge unit and the record store's paging keeps the cost flat as the
|
|
816
|
+
collection grows. A root per row opts out of that paging, and each extra root
|
|
817
|
+
makes every container in the document cost more, so the cost climbs steeply
|
|
616
818
|
with the collection.
|
|
617
819
|
|
|
618
820
|
[`docs/text-fields.md`](./docs/text-fields.md) carries the caret-preserving
|
|
@@ -623,12 +825,16 @@ and the measurements behind the root-per-row rule.
|
|
|
623
825
|
|
|
624
826
|
`handle.subscribe(cb)` is one channel for every change, whatever
|
|
625
827
|
produced it: your own edit, a remote peer's, an undo, a move through
|
|
626
|
-
version history. It fires once
|
|
627
|
-
`
|
|
628
|
-
change wrote, so editing one record costs one entry whatever
|
|
629
|
-
document
|
|
630
|
-
|
|
631
|
-
the
|
|
828
|
+
version history. It fires once with the whole container as `change.set`
|
|
829
|
+
before `subscribe` returns, then on every change, and each fire carries
|
|
830
|
+
what that change wrote, so editing one record costs one entry whatever
|
|
831
|
+
the document contains. Subscribe to render and to follow changes. To read a
|
|
832
|
+
container once, walk it instead (see [Read and mutate](#read-and-mutate)).
|
|
833
|
+
Because the callback executes synchronously during `subscribe` before the method
|
|
834
|
+
returns, declare and initialize all variables referenced by the callback before
|
|
835
|
+
subscribing to avoid runtime `ReferenceError`s. Your own edits arrive here too,
|
|
836
|
+
so render from this single place: updating UI directly at the edit site often
|
|
837
|
+
leaves other views showing stale data.
|
|
632
838
|
|
|
633
839
|
```ts
|
|
634
840
|
const stop = walls.subscribe((change) => {
|
|
@@ -665,10 +871,14 @@ In a declarative framework, bind the input to a per-field ref (`v-model`,
|
|
|
665
871
|
`useState`) rather than to the document, and have your subscriber assign
|
|
666
872
|
that ref for every field except the focused one.
|
|
667
873
|
|
|
668
|
-
**
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
874
|
+
**Subscribers execute synchronously inside `mutate()`.** The updated document
|
|
875
|
+
state is guaranteed to be in place the moment `mutate()` returns, so subsequent reads
|
|
876
|
+
(such as allocating the next ID or validating against existing names) can use current
|
|
877
|
+
values immediately.
|
|
878
|
+
|
|
879
|
+
Similarly, any DOM updates rendered by your subscriber are applied before `mutate()`
|
|
880
|
+
returns. You can immediately focus, scroll to, or measure newly created elements
|
|
881
|
+
right after calling `mutate()`.
|
|
672
882
|
|
|
673
883
|
A `list` handle's subscriber receives splices, each carrying the
|
|
674
884
|
position an edit lands at, applied in the order they arrive:
|
|
@@ -682,8 +892,8 @@ const stop = steps.subscribe((change) => {
|
|
|
682
892
|
```
|
|
683
893
|
|
|
684
894
|
**A reorder arrives as a removal and an insertion, so pair them if the
|
|
685
|
-
item owns anything.** Rebuilding is correct and costs whatever the
|
|
686
|
-
|
|
895
|
+
item owns anything.** Rebuilding is correct and costs whatever state the
|
|
896
|
+
item carries: a DOM node loses its focus, its selection and its scroll
|
|
687
897
|
position, and a view built on it starts over. As you apply a splice you
|
|
688
898
|
know which of your own items it removed, so match them against what the
|
|
689
899
|
same change inserts and move the existing item rather than rebuilding it.
|
|
@@ -701,7 +911,9 @@ const stop = steps.subscribe((change) => {
|
|
|
701
911
|
An `orderedMap` fire also carries `change.order` (the live ids in
|
|
702
912
|
order) when the order moves. The optional second callback argument
|
|
703
913
|
`origin` (`{ source: 'local' | 'remote' | 'undo' | 'redo' }`) carries
|
|
704
|
-
attribution, for a render that highlights a peer's edit.
|
|
914
|
+
attribution, for a render that highlights a peer's edit. An Undo or Redo
|
|
915
|
+
can fire your subscriber several times, once per commit the step took. A
|
|
916
|
+
move through
|
|
705
917
|
version history swaps the whole container rather than writing into it, so
|
|
706
918
|
it arrives with no `origin`; `doc.subscribe` reports that move, with the
|
|
707
919
|
version it landed on.
|
|
@@ -718,13 +930,15 @@ walls.onSettled(() => fitViewTo(walls));
|
|
|
718
930
|
```
|
|
719
931
|
|
|
720
932
|
It fires on registration, and again each time a version checkout, a
|
|
721
|
-
return to head or a hydrate replaces the container, so a
|
|
933
|
+
return to head or a hydrate replaces the container, so a user who
|
|
722
934
|
moves through history gets the camera framed on the version they landed
|
|
723
935
|
on. Set a flag of your own on the first fire for work that should run
|
|
724
936
|
once.
|
|
725
937
|
|
|
726
938
|
It reports what this peer had converged to when the delivery began.
|
|
727
|
-
Later changes arrive through `subscribe`.
|
|
939
|
+
Later changes arrive through `subscribe`. A view built from several
|
|
940
|
+
streams is whole once the handle of every stream it reads has settled
|
|
941
|
+
(see [Knowing when every stream has arrived](./docs/streams.md#knowing-when-every-stream-has-arrived)).
|
|
728
942
|
|
|
729
943
|
`doc.subscribe(cb)` is the catch-all for events not tied to one
|
|
730
944
|
container (saving-count ticks, branch-list changes). Use it for status
|
|
@@ -735,9 +949,7 @@ surfaces, and render the editor from handles rather than from a
|
|
|
735
949
|
|
|
736
950
|
The `undo` and `redo` commands in the session's command list are fully
|
|
737
951
|
wired: the toolbar renders them, and keyboard shortcuts reach them
|
|
738
|
-
through `session.run`.
|
|
739
|
-
`undo()` only reverts commits this client made and a collaborator's
|
|
740
|
-
edits stay untouched. Outside the session, call `doc.undo()` /
|
|
952
|
+
through `session.run`. Outside the session, call `doc.undo()` /
|
|
741
953
|
`doc.redo()` directly and read the enabled state via
|
|
742
954
|
`doc.subscribeUndoState(cb)`.
|
|
743
955
|
|
|
@@ -746,30 +958,45 @@ boundary and a marker on the document's version-history timeline, so
|
|
|
746
958
|
checkpoint timing sets both granularities. Automatic checkpoints land 60
|
|
747
959
|
seconds after each burst of local edits settles. Tune that to your app's shape:
|
|
748
960
|
|
|
749
|
-
| Your app shape | What to do
|
|
750
|
-
| --------------------------------------- |
|
|
751
|
-
| Default / no opinion |
|
|
752
|
-
| Drag / brush / tool-stroke (3D, canvas) | `void doc.createCheckpoint()` on `pointerup`.
|
|
753
|
-
| Text editor
|
|
754
|
-
| Explicit-only | `doc.setAutoCheckpointIntervalMs(0)`
|
|
755
|
-
|
|
756
|
-
`createCheckpoint()`
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
961
|
+
| Your app shape | What to do | Result |
|
|
962
|
+
| --------------------------------------- | ---------------------------------------------------------------- | ----------------------------------------- |
|
|
963
|
+
| Default / no opinion | Only the end-of-action checkpoint below. | Plus one 60s after edits settle. |
|
|
964
|
+
| Drag / brush / tool-stroke (3D, canvas) | `void doc.createCheckpoint()` on `pointerup`. | One drag = one Ctrl-Z. |
|
|
965
|
+
| Text editor | `void doc.createCheckpoint()` in `beforeinput` (below). | One typing run or paste = one Ctrl-Z. |
|
|
966
|
+
| Explicit-only | `doc.setAutoCheckpointIntervalMs(0)` | Only deliberate `createCheckpoint` calls. |
|
|
967
|
+
|
|
968
|
+
Call `void doc.createCheckpoint()` at the end of every user action. It
|
|
969
|
+
ends the Ctrl-Z step at the call, so do not await it in the handler:
|
|
970
|
+
the next edit starts a new step either way. Its Promise resolves once
|
|
971
|
+
Brass has saved your changes, including edits the user made after the
|
|
972
|
+
call, and written the version-history marker. If you await it, catch the rejection it raises when Brass cannot write the
|
|
973
|
+
marker, for example because Brass could not save your changes.
|
|
974
|
+
|
|
975
|
+
In a text editor, typing has no end-of-action event, so decide in
|
|
976
|
+
`beforeinput` whether the coming edit joins the open step, and call
|
|
977
|
+
`void doc.createCheckpoint()` there when it does not. Make a paste, cut,
|
|
978
|
+
drop or line break a step of its own (`inputType` `insertFromPaste`,
|
|
979
|
+
`deleteByCut`, `insertFromDrop`, `insertLineBreak` / `insertParagraph`)
|
|
980
|
+
by starting a new step before it and before the edit after it. Also
|
|
981
|
+
start a new step when the user switches between typing and deleting
|
|
982
|
+
(`insert*` and `delete*`), when the edit replaces a selection, and when
|
|
983
|
+
it lands away from where the last edit left the caret. Do not shorten
|
|
984
|
+
the automatic interval to get this. A shorter interval still ends a step
|
|
985
|
+
only at a pause, so one Ctrl-Z undoes everything the user typed since the
|
|
986
|
+
last pause, a paste included.
|
|
761
987
|
|
|
762
988
|
The SDK renders version preview itself: while an earlier version is
|
|
763
989
|
showing, `mountToolbar` / `mountHeader` show the "viewing an earlier
|
|
764
990
|
version" banner and return-to-live control (mount `mountVersionPreview`
|
|
765
991
|
for a fully custom shell). You handle your own edit controls:
|
|
766
|
-
`doc.readOnly` flips `true` during a preview, for a
|
|
767
|
-
to the document is view-only, for a view-only share link,
|
|
768
|
-
document is loading or could not be loaded
|
|
769
|
-
|
|
992
|
+
`doc.readOnly` flips `true` during a preview, for a user whose access
|
|
993
|
+
to the document is view-only, for a view-only share link, while a
|
|
994
|
+
document is loading, is being imported, or could not be loaded, and for a
|
|
995
|
+
file too large to edit (see [Large imported files](#large-imported-files)).
|
|
996
|
+
Gate your custom tools on it, and `doc.mutate` throws while read-only.
|
|
770
997
|
|
|
771
998
|
Read it inside your `doc.subscribe` handler rather than once when you
|
|
772
|
-
bind the editor. It also flips when someone withdraws the
|
|
999
|
+
bind the editor. It also flips when someone withdraws the user's
|
|
773
1000
|
access while they have the document open, so re-reading is what
|
|
774
1001
|
disables your tools at the moment writing stops working. `Brass.onError`
|
|
775
1002
|
carries the message explaining what happened.
|
|
@@ -810,9 +1037,9 @@ flag.
|
|
|
810
1037
|
|
|
811
1038
|
`doc.presence` shows who else is in the document and what they are
|
|
812
1039
|
doing: a roster of the other live participants plus a small per-user
|
|
813
|
-
`state` (cursor, camera, selection). It is non-null on any open
|
|
814
|
-
|
|
815
|
-
disconnect. `Brass.session.presence` is the same client for the
|
|
1040
|
+
`state` (cursor, camera, selection). It is non-null on any live open,
|
|
1041
|
+
editable or `{ readOnly: true }`, `null` on a `{ frozen: true }` copy, and it is fully ephemeral: nothing
|
|
1042
|
+
survives a disconnect. `Brass.session.presence` is the same client for the
|
|
816
1043
|
session's open document, so use whichever handle you have.
|
|
817
1044
|
|
|
818
1045
|
Presence requires publishing your own state and rendering everyone
|
|
@@ -834,24 +1061,26 @@ Z-up; a 2D app passes `z: 0`), and an entity or text editor publishes
|
|
|
834
1061
|
publish a bounded summary (the first few ids plus a count, or the
|
|
835
1062
|
selection's bounding box) once a selection can get large, and subscribe
|
|
836
1063
|
with `presence.onStatus` to catch a layer Brass withholds as oversized.
|
|
837
|
-
|
|
1064
|
+
Set a key to `undefined` to remove it for every peer
|
|
1065
|
+
(`set({ state: { selection: undefined } })` when the user clears a
|
|
1066
|
+
selection). `Brass.mountToolbar` and `Brass.mountPresenceRoster` render the header
|
|
838
1067
|
roster for you. To render remote cursors, diff the roster by
|
|
839
1068
|
`connectionId` on the `change` event. The full recipe, coordinate
|
|
840
1069
|
conventions, and types are in [`docs/presence.md`](./docs/presence.md).
|
|
841
1070
|
|
|
842
|
-
## Streams (
|
|
1071
|
+
## Streams (loading a document in parts)
|
|
843
1072
|
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
streams
|
|
847
|
-
|
|
1073
|
+
A document is made of named streams, and each stream loads
|
|
1074
|
+
independently. When your app opens a document, it downloads only the
|
|
1075
|
+
streams it reads. Put a heavy part of a document (a terrain mesh, a
|
|
1076
|
+
point-cloud index) in its own stream, and only the opens that draw it
|
|
1077
|
+
download it. An importer can split a document the same way, so a
|
|
1078
|
+
viewer that draws a model opens only its geometry stream:
|
|
848
1079
|
|
|
849
1080
|
```ts
|
|
850
|
-
const
|
|
851
|
-
const records =
|
|
852
|
-
records.
|
|
853
|
-
m[id] = { type, params };
|
|
854
|
-
});
|
|
1081
|
+
const geometry = await doc.stream('geometry'); // downloads this stream only
|
|
1082
|
+
const records = geometry.orderedMap('instances', 'instance_order');
|
|
1083
|
+
records.subscribe((change) => draw(change.set));
|
|
855
1084
|
```
|
|
856
1085
|
|
|
857
1086
|
Which call you reach for depends on what you already know about the
|
|
@@ -862,26 +1091,27 @@ document:
|
|
|
862
1091
|
large document downloads only that part. It defaults to `'default'`,
|
|
863
1092
|
the stream every document has.
|
|
864
1093
|
- `doc.getOrCreateStream(name)` opens the document's stream of that name
|
|
865
|
-
and
|
|
866
|
-
and concurrent callers
|
|
867
|
-
|
|
868
|
-
- `doc.stream(name)` opens one the document already
|
|
869
|
-
any other name.
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
the
|
|
884
|
-
|
|
1094
|
+
and creates it if the document does not have it yet. It is idempotent,
|
|
1095
|
+
and concurrent callers get the same stream, so your app can call it on
|
|
1096
|
+
every open.
|
|
1097
|
+
- `doc.stream(name)` opens one the document already has and rejects
|
|
1098
|
+
any other name. Calling `doc.stream(name)` for the stream the document was
|
|
1099
|
+
opened with returns the document itself, allowing a single loop over
|
|
1100
|
+
`doc.listStreams()` to handle single-stream and multi-stream documents alike.
|
|
1101
|
+
|
|
1102
|
+
Each stream costs one extra round trip to open, so split a document by
|
|
1103
|
+
what each open loads, not by concept.
|
|
1104
|
+
|
|
1105
|
+
A schema names the streams it expects under `streams`. Read only the
|
|
1106
|
+
streams that contain the records your app needs, because Brass downloads
|
|
1107
|
+
and decodes each stream you read in full. If your app follows references
|
|
1108
|
+
into streams it does not otherwise read, read every stream the document
|
|
1109
|
+
has (`doc.listStreams()`). A document can have streams your schema does
|
|
1110
|
+
not name, and your app leaves those alone. Naming a stream allows auxiliary
|
|
1111
|
+
data (such as clash findings, review notes, or cache records) to travel
|
|
1112
|
+
with the document: they sync and version alongside it, while exporters
|
|
1113
|
+
compose only the streams they declare, leaving undeclared streams out of
|
|
1114
|
+
the exported file. See [`docs/streams.md`](./docs/streams.md) and
|
|
885
1115
|
[`docs/schemas.md`](./docs/schemas.md).
|
|
886
1116
|
|
|
887
1117
|
## Imported files
|
|
@@ -895,7 +1125,7 @@ What you build depends on whether an importer for the format already
|
|
|
895
1125
|
exists. Open a document of that type in the dashboard to see which case
|
|
896
1126
|
you are in:
|
|
897
1127
|
|
|
898
|
-
- **It
|
|
1128
|
+
- **It has records and a schema.** An importer for the format
|
|
899
1129
|
already exists (an installed app provides it). Adopt the document's
|
|
900
1130
|
schema (below) and read and edit the records through the ordinary
|
|
901
1131
|
handle surface
|
|
@@ -904,28 +1134,33 @@ you are in:
|
|
|
904
1134
|
serialises the format. A large
|
|
905
1135
|
document may spread its records across several streams; if your app
|
|
906
1136
|
follows references between records, read every stream the document
|
|
907
|
-
|
|
1137
|
+
has, see
|
|
908
1138
|
[streams](./docs/streams.md#reading-a-document-spread-across-streams).
|
|
909
|
-
- **It
|
|
910
|
-
format yet, so your app is its first importer, and your importer
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
1139
|
+
- **It has only the stored file, with no existing schema.** Nothing imports the
|
|
1140
|
+
format yet, so your app is its first importer, and your importer makes
|
|
1141
|
+
files of that format openable. Register both halves during application
|
|
1142
|
+
startup (unconditionally, not after user sign-in or after a document opens).
|
|
1143
|
+
Brass can load your application in a background headless frame specifically
|
|
1144
|
+
to run its importer, where no user signs in and no document is displayed.
|
|
914
1145
|
|
|
915
1146
|
```ts
|
|
916
|
-
Brass.registerImporter(async (
|
|
917
|
-
const
|
|
918
|
-
const
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
1147
|
+
Brass.registerImporter(async (file) => {
|
|
1148
|
+
const parsed = parseMyFormat(await file.bytes());
|
|
1149
|
+
const instances: Record<string, unknown> = {};
|
|
1150
|
+
for (const r of parsed) instances[r.id] = { type: r.type, params: r.body };
|
|
1151
|
+
return {
|
|
1152
|
+
streams: {
|
|
1153
|
+
default: { instances, instance_order: parsed.map((r) => r.id) },
|
|
1154
|
+
},
|
|
1155
|
+
};
|
|
922
1156
|
});
|
|
923
1157
|
Brass.registerSerializer((doc) => serializeMyFormat(doc));
|
|
924
1158
|
```
|
|
925
1159
|
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
1160
|
+
The importer takes the file and returns the document's content as JSON.
|
|
1161
|
+
Brass runs it when a document needs an import, and every client with
|
|
1162
|
+
the document open then receives the whole result together.
|
|
1163
|
+
Declare the `schema` of the records your importer returns in
|
|
929
1164
|
`brass-app.json`; importing publishes that schema and stamps it on
|
|
930
1165
|
the document, so the next app can adopt it like any imported
|
|
931
1166
|
document's. Declare the file match there too
|
|
@@ -939,10 +1174,13 @@ one that another app produced. Copy its schema from the document's page
|
|
|
939
1174
|
in the dashboard (**Download brass-app.json**, or
|
|
940
1175
|
`npx @brass-build/cli schema pull --doc <docId>`), keep it verbatim as
|
|
941
1176
|
`schema` in your `brass-app.json`, and ship that manifest with your
|
|
942
|
-
first deploy. The
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
1177
|
+
first deploy. The schema describes each record's keys and value shapes.
|
|
1178
|
+
Inspect records before writing code against them:
|
|
1179
|
+
`npx @brass-build/cli contents --doc <docId>` outputs the default stream as
|
|
1180
|
+
JSON (add `--stream <name>` to view another stream). The picker lists
|
|
1181
|
+
documents matching your declared schema, and Brass adds the matching family to
|
|
1182
|
+
your `opens` list so compatible documents route to your app and remain
|
|
1183
|
+
interoperable with other apps editing them.
|
|
946
1184
|
|
|
947
1185
|
Every app ships a capability manifest at `/.well-known/brass-app.json`
|
|
948
1186
|
in its bundle root, next to `index.html`. It declares the schema your
|
|
@@ -956,10 +1194,249 @@ goes, what each key does, and how to confirm your build carries it.
|
|
|
956
1194
|
record store an imported file becomes, and
|
|
957
1195
|
[`docs/schemas.md`](./docs/schemas.md) covers declaring its schema.
|
|
958
1196
|
|
|
1197
|
+
## Referencing other documents
|
|
1198
|
+
|
|
1199
|
+
Your app can reference other documents as inputs. For example, you can calculate the total
|
|
1200
|
+
floor area from multiple CAD models across several projects. Brass automatically
|
|
1201
|
+
tracks referenced documents and notifies your app when any of them change, even
|
|
1202
|
+
if they were edited while your document was closed.
|
|
1203
|
+
|
|
1204
|
+
### Choosing inputs
|
|
1205
|
+
|
|
1206
|
+
`Brass.chooseInputs(families, options?)` opens the hosted picker where the
|
|
1207
|
+
user selects files, folders, and projects. Pass the families to offer,
|
|
1208
|
+
such as `['ifc']` for IFC models. Each family must be listed in your manifest's
|
|
1209
|
+
`opens` or declared as an `inputs` schema `family`
|
|
1210
|
+
([`docs/app-manifest.md`](./docs/app-manifest.md)). The picker offers a
|
|
1211
|
+
document only when it satisfies all required fields of a declared schema, so
|
|
1212
|
+
require only the fields your code reads.
|
|
1213
|
+
|
|
1214
|
+
To find the families an organization's files belong to, run:
|
|
1215
|
+
|
|
1216
|
+
```sh
|
|
1217
|
+
npx @brass-build/cli contents --from organization:<id> --max-documents 0
|
|
1218
|
+
```
|
|
1219
|
+
|
|
1220
|
+
Files listed with no `family` have no registered importer and are not
|
|
1221
|
+
offered by the picker.
|
|
1222
|
+
|
|
1223
|
+
`Brass.chooseInputs` resolves with:
|
|
1224
|
+
- `InputReference[]`: The selected references. If the user deselects all inputs
|
|
1225
|
+
and confirms, it resolves with an empty array (`[]`).
|
|
1226
|
+
- `null`: The user dismissed or closed the picker without confirming. Keep the
|
|
1227
|
+
references your app already has.
|
|
1228
|
+
|
|
1229
|
+
A reference (`InputReference`) identifies a chosen file, folder, or project,
|
|
1230
|
+
and records the families and filter criteria under which it was selected. Pass
|
|
1231
|
+
current references as `selected` so the picker opens with them pre-selected.
|
|
1232
|
+
The picker expands zip archives into folders, allowing users to select an
|
|
1233
|
+
archive or files inside it without your app declaring a zip importer.
|
|
1234
|
+
|
|
1235
|
+
To pick a single file (such as a symbol library or reference standard), call
|
|
1236
|
+
`Brass.chooseInput(families, options?)`. It opens a single-file selection dialog
|
|
1237
|
+
and resolves with an `InputReference`, or `null` if the user closes the dialog.
|
|
1238
|
+
On `null`, keep your existing reference. Pass the current reference as
|
|
1239
|
+
`selected` on the next call to highlight that file. To let the user remove the
|
|
1240
|
+
reference, provide a clear button in your app UI and clear the reference yourself.
|
|
1241
|
+
|
|
1242
|
+
Both picker methods support these options:
|
|
1243
|
+
- `selected`: Current `InputReference[]` (or `InputReference` for `chooseInput`) to mark as selected.
|
|
1244
|
+
- `accept`: Narrow offered files by name or extension (`{ names?: string[], extensions?: string[] }`).
|
|
1245
|
+
- `prompt`: A custom instruction string displayed in the picker header.
|
|
1246
|
+
|
|
1247
|
+
Record chosen references in the document with `doc.dependsOn(key, refs)`,
|
|
1248
|
+
and clear a key with `doc.dependsOn(key, null)`. Assign a distinct key for each
|
|
1249
|
+
set of inputs (such as `'areas@v1'` or `'rates@v1'`), so independent parts of
|
|
1250
|
+
your app manage their references separately without conflicting. When your calculation logic
|
|
1251
|
+
or schema changes, switch to a new key (such as `'areas@v2'`), which reprocesses
|
|
1252
|
+
every input from scratch, and clear the deprecated key. A document cannot depend
|
|
1253
|
+
on itself.
|
|
1254
|
+
|
|
1255
|
+
Because `doc.dependsOn()` mutates the document, Undo reverts dependency changes
|
|
1256
|
+
alongside edits made in the same action. It throws an error on a read-only document,
|
|
1257
|
+
so verify `!doc.readOnly` before displaying picker buttons, or upgrade with
|
|
1258
|
+
`await doc.reopen()`.
|
|
1259
|
+
|
|
1260
|
+
To record references on a specific [stream](#streams-loading-a-document-in-parts),
|
|
1261
|
+
call `stream.dependsOn(key, refs)` and follow them with
|
|
1262
|
+
`stream.subscribeInputDocuments(key, handlers)`.
|
|
1263
|
+
|
|
1264
|
+
### Reacting to changes
|
|
1265
|
+
|
|
1266
|
+
A key's inputs are the documents its references include: each directly referenced
|
|
1267
|
+
document, and every document in a referenced folder or project that matches the
|
|
1268
|
+
families and `accept` criteria under which the reference was chosen. A reference
|
|
1269
|
+
to a project or folder stops at an archive inside it; to read an archive's contents,
|
|
1270
|
+
reference the archive itself.
|
|
1271
|
+
|
|
1272
|
+
`doc.subscribeInputDocuments(key, handlers, options)` calls your handlers with
|
|
1273
|
+
the key's inputs and returns an unsubscribe function. Pass the handlers that
|
|
1274
|
+
match where your app stores what it reads:
|
|
1275
|
+
|
|
1276
|
+
- `loaded(input)` runs for every input each time you subscribe, including after
|
|
1277
|
+
a page reload, and again for each new version. It runs for every user, including
|
|
1278
|
+
view-only users. Use it for state your app keeps in memory, such as a viewer
|
|
1279
|
+
that draws every model. To list or table the inputs, render `status.inputs` (below)
|
|
1280
|
+
instead, which opens none of them.
|
|
1281
|
+
- `changed(input)` runs for each input added or edited since your document last
|
|
1282
|
+
read it, including edits made while nobody had your document open. Newly picked
|
|
1283
|
+
inputs count as added and pass to `changed`. When the handler resolves, Brass
|
|
1284
|
+
records that your document has read the input at that version, so subsequent
|
|
1285
|
+
sessions and collaborators do not re-read it. Use it for results your app stores
|
|
1286
|
+
in your document. It runs only for users with edit access.
|
|
1287
|
+
- `removed(docId)` runs once for each input that is no longer among the key's
|
|
1288
|
+
inputs (deleted or moved out of every referenced folder and project). Every user
|
|
1289
|
+
runs it for inputs passed to `loaded`, and an editor also runs it for inputs your
|
|
1290
|
+
document read in an earlier session. Clean up stored or cached results here.
|
|
1291
|
+
|
|
1292
|
+
Pass both when your app does both, such as a viewer that draws each model from
|
|
1293
|
+
`loaded` and stores each model's clash count from `changed`. Brass never runs two
|
|
1294
|
+
handlers for one input at the same time.
|
|
1295
|
+
|
|
1296
|
+
When several subscriptions to one key on a document are live, each input's call
|
|
1297
|
+
runs the handlers of every subscription side by side, and Brass records a
|
|
1298
|
+
`changed` input as read only after every subscription's `changed` has resolved.
|
|
1299
|
+
If one rejects, Brass retries that subscription alone. In a framework component,
|
|
1300
|
+
return the unsubscribe function from your effect's cleanup. Otherwise the
|
|
1301
|
+
subscription outlives its component, keeps writing results, and delays Brass
|
|
1302
|
+
recording inputs as read.
|
|
1303
|
+
|
|
1304
|
+
`input` carries `doc_id`, `name`, `family` (absent on a document with none),
|
|
1305
|
+
`updated_at`, and `from` (the references that include it, nearest first). Call
|
|
1306
|
+
`input.open(options)` to read its content. It accepts the same options as
|
|
1307
|
+
`Brass.openDocumentById`:
|
|
1308
|
+
- From `changed`, open with `{ frozen: true }`, so the handler reads a snapshot
|
|
1309
|
+
without opening a real-time connection.
|
|
1310
|
+
- From `loaded`, open with `{ readOnly: true }`, so a viewer receives live updates
|
|
1311
|
+
without write overhead.
|
|
1312
|
+
|
|
1313
|
+
Calling `input.open` again in the same handler with matching options returns the
|
|
1314
|
+
same document instance; calling it with different options closes the previous
|
|
1315
|
+
copy and opens a new one.
|
|
1316
|
+
|
|
1317
|
+
Brass automatically closes the document returned by `input.open` when your handler
|
|
1318
|
+
resolves, and logs a warning if your app uses it after that. To keep it open
|
|
1319
|
+
between calls (such as in a viewer that keeps every model loaded and subscribed to),
|
|
1320
|
+
pass `{ keepDocumentsOpen: true }`. Subsequent `input.open` calls for that input,
|
|
1321
|
+
from `loaded` or `changed`, then return the same document, reloaded at the new
|
|
1322
|
+
version if frozen. Brass closes it when the input is removed or you unsubscribe.
|
|
1323
|
+
Call `input.close()` to close it earlier.
|
|
1324
|
+
|
|
1325
|
+
**Writing results with `{ undoable: false }`.** Write each input's result in
|
|
1326
|
+
`changed` using `mutate(draft, { undoable: false })`, so user Undo actions leave
|
|
1327
|
+
derived results in place, and delete it in `removed`. Compute each result from that
|
|
1328
|
+
input alone, and write deterministic results for the same input wherever it runs.
|
|
1329
|
+
|
|
1330
|
+
**Concurrency and retries.** Brass calls handlers for multiple inputs concurrently
|
|
1331
|
+
and automatically paces them. Brass never calls a handler for an input while an
|
|
1332
|
+
earlier call for it is still running. If an input is edited while your handler reads
|
|
1333
|
+
it, Brass calls the handler again with the new version and discards the earlier call.
|
|
1334
|
+
Leave the pace to Brass unless each handler spends something rationed, such as
|
|
1335
|
+
calls to a paid third-party API. To cap how many run at once for such a handler,
|
|
1336
|
+
pass `{ concurrency: n }`. A lower cap makes reading many inputs take longer.
|
|
1337
|
+
A handler that rejects leaves the input unread, and Brass retries with
|
|
1338
|
+
exponential backoff (2 seconds up to 5 minutes), so do not retry it yourself. If `input.open` rejects with `'not_imported'`,
|
|
1339
|
+
show `error.message` for that input and rethrow. Its import runs only when someone
|
|
1340
|
+
opens the file in the app that imports its type, so Brass does not retry it on the
|
|
1341
|
+
backoff and instead re-invokes your handler once that app has imported the file.
|
|
1342
|
+
|
|
1343
|
+
To read an input on demand (such as when its table row scrolls into view), keep its
|
|
1344
|
+
handler pending until then and call `input.open` before the handler resolves. A
|
|
1345
|
+
pending handler does not delay handlers for other inputs, and `status.settled` stays
|
|
1346
|
+
`false` until it resolves.
|
|
1347
|
+
|
|
1348
|
+
`handlers.status(status)` receives the key's status, and again whenever it changes:
|
|
1349
|
+
|
|
1350
|
+
- `status.refs` lists the key's references. Pass them back to `chooseInputs` as
|
|
1351
|
+
`selected`.
|
|
1352
|
+
- `status.inputs` lists the inputs this user can open, with the same fields as
|
|
1353
|
+
`input`. Render lists directly from this array without opening documents. To
|
|
1354
|
+
render the enclosing folders for an input, read `member_of.chain` from
|
|
1355
|
+
`Brass.documents.get(input.doc_id)`: the folders and archives above it, outermost
|
|
1356
|
+
first, ending with the folder or archive `member_of` names.
|
|
1357
|
+
- `status.hidden_count` counts inputs your document read before that this user
|
|
1358
|
+
cannot open. When greater than zero, notify the user that results include inputs
|
|
1359
|
+
they cannot see.
|
|
1360
|
+
- `status.truncated` is `true` when your document's keys together include more
|
|
1361
|
+
inputs than a document tracks. Notify the user that some inputs are omitted.
|
|
1362
|
+
- `status.settled` is `false` while the list of inputs may not be complete (such
|
|
1363
|
+
as right after `dependsOn`), and until every handler you passed has finished for
|
|
1364
|
+
every input and inputs have stopped changing. Display progress indicators while
|
|
1365
|
+
it is `false`. A rejected handler counts as finished, so `settled` can turn `true`
|
|
1366
|
+
with errors outstanding; store each error in your handler and show it in the
|
|
1367
|
+
input's row.
|
|
1368
|
+
|
|
1369
|
+
`handlers.settled(status)` runs each time `status.settled` turns `true`: after the
|
|
1370
|
+
first load, and again after each later batch of additions, edits, or removals. A
|
|
1371
|
+
burst of inputs arriving together (such as a drive's first listing) produces one
|
|
1372
|
+
call. Run work that reads every input's result together here (such as numbering
|
|
1373
|
+
doors across models, or combining results with a second key's inputs, such as a
|
|
1374
|
+
rate table), so a change to either updates every result. Because every user runs it,
|
|
1375
|
+
write to the document only when `!doc.readOnly`.
|
|
1376
|
+
|
|
1377
|
+
### Example
|
|
1378
|
+
|
|
1379
|
+
The user picks projects, and a report shows the floor area of each model
|
|
1380
|
+
in them. Each model's area appears as soon as its `changed` handler
|
|
1381
|
+
finishes, and the total updates with each one:
|
|
1382
|
+
|
|
1383
|
+
```ts
|
|
1384
|
+
import type { Document, InputReference } from '@brass-build/client';
|
|
1385
|
+
|
|
1386
|
+
const KEY = 'areas@v1';
|
|
1387
|
+
let report: Document | null = null;
|
|
1388
|
+
let refs: InputReference[] = [];
|
|
1389
|
+
let unbind: (() => void) | null = null;
|
|
1390
|
+
|
|
1391
|
+
// Called from `session.subscribe` whenever `session.document` changes.
|
|
1392
|
+
function bindReport(doc: Document | null) {
|
|
1393
|
+
unbind?.();
|
|
1394
|
+
unbind = null;
|
|
1395
|
+
report = doc;
|
|
1396
|
+
if (!doc) return;
|
|
1397
|
+
const areas = doc.map<number>('areas');
|
|
1398
|
+
const stopInputs = doc.subscribeInputDocuments(KEY, {
|
|
1399
|
+
async changed(input) {
|
|
1400
|
+
const model = await input.open({ frozen: true });
|
|
1401
|
+
const area = sumFloorArea(model);
|
|
1402
|
+
areas.mutate((draft) => { draft[input.doc_id] = area; }, { undoable: false });
|
|
1403
|
+
},
|
|
1404
|
+
removed(docId) {
|
|
1405
|
+
areas.mutate((draft) => { delete draft[docId]; }, { undoable: false });
|
|
1406
|
+
},
|
|
1407
|
+
status(status) {
|
|
1408
|
+
refs = status.refs;
|
|
1409
|
+
showProgress(!status.settled);
|
|
1410
|
+
},
|
|
1411
|
+
});
|
|
1412
|
+
const stopTotal = areas.subscribe(() => renderTotal(areas));
|
|
1413
|
+
unbind = () => { stopInputs(); stopTotal(); };
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
async function choose() {
|
|
1417
|
+
const doc = report;
|
|
1418
|
+
if (!doc || doc.readOnly) return;
|
|
1419
|
+
const picked = await Brass.chooseInputs(['ifc'], { selected: refs });
|
|
1420
|
+
// The session may have bound another document while the picker was open.
|
|
1421
|
+
if (picked !== null && report === doc) doc.dependsOn(KEY, picked);
|
|
1422
|
+
}
|
|
1423
|
+
```
|
|
1424
|
+
|
|
1425
|
+
Every `doc.reopen()` passes a new document instance to `session.subscribe`, so
|
|
1426
|
+
unsubscribe the old document's inputs before you subscribe the new one's,
|
|
1427
|
+
as `bindReport` does.
|
|
1428
|
+
|
|
1429
|
+
To reference a single library file (such as a symbol catalog), pass the library's
|
|
1430
|
+
family to `Brass.chooseInput` (`npx @brass-build/cli contents --from organization:<id> --max-documents 0`
|
|
1431
|
+
lists each file's `family`), and record the returned reference with
|
|
1432
|
+
`doc.dependsOn(key, [ref])`. When collaborators edit the library, Brass calls
|
|
1433
|
+
`changed` with it: open it with `{ frozen: true }` and update the placements that
|
|
1434
|
+
use it.
|
|
1435
|
+
|
|
959
1436
|
## Calling an outside service
|
|
960
1437
|
|
|
961
1438
|
Your app can call a paid or authenticated third-party API without ever
|
|
962
|
-
|
|
1439
|
+
having its credential. Declare the host in `brass-app.json`, and Brass adds
|
|
963
1440
|
the key to each request:
|
|
964
1441
|
|
|
965
1442
|
```json
|
|
@@ -1043,12 +1520,13 @@ above show them in use.
|
|
|
1043
1520
|
| `Brass.session` | `DocumentSession` (see above) |
|
|
1044
1521
|
| `Brass.mountToolbar(el, options?)` | `SessionToolbarHandle` (`{ tools, destroy }`) |
|
|
1045
1522
|
| `Brass.createDocument(options?)` | `Promise<OpenedDocument>` (`session.create` drives this) |
|
|
1046
|
-
| `Brass.openDocumentById(docId, options?)` | `Promise<OpenedDocument>` (the session's open
|
|
1047
|
-
| `Brass.
|
|
1523
|
+
| `Brass.openDocumentById(docId, options?)` | `Promise<OpenedDocument>` (the session's `open` drives this); `ReadOnlyDocument` for `readOnly: true` or `frozen: true` |
|
|
1524
|
+
| `Brass.chooseInputs(families, options?)` | `Promise<InputReference[] \| null>` (what this user has chosen for your app) |
|
|
1525
|
+
| `Brass.chooseInput(families, options?)` | `Promise<InputReference \| null>` (one file this user has chosen) |
|
|
1048
1526
|
| `Brass.shareDocument(docId)` | `Promise<void>` |
|
|
1049
1527
|
| `Brass.registerImporter(fn)` | `void` (see `docs/imported-documents.md`) |
|
|
1050
1528
|
| `Brass.registerSerializer(fn)` | `void` (see `docs/imported-documents.md`) |
|
|
1051
|
-
| `Brass.documents` | namespace getter (metadata read
|
|
1529
|
+
| `Brass.documents` | namespace getter (metadata read, thumbnails) |
|
|
1052
1530
|
|
|
1053
1531
|
The composable header mounts (`mountHeader`, `mountFileMenu`,
|
|
1054
1532
|
`mountDocumentTitle`, `mountUndoRedo`, `mountPresenceRoster`,
|
|
@@ -1062,21 +1540,30 @@ methods return `OpenedDocument`, so type app-level variables as
|
|
|
1062
1540
|
|
|
1063
1541
|
### Errors
|
|
1064
1542
|
|
|
1065
|
-
Every call rejects with
|
|
1066
|
-
`.status: number`, and
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1543
|
+
Every rejecting call rejects with a `BrassError`. HTTP calls carry
|
|
1544
|
+
`.status: number`, and hosted-UI launchers and API operations provide an actionable `.code`:
|
|
1545
|
+
|
|
1546
|
+
| Code | Where it occurs | What to do |
|
|
1547
|
+
| ---- | --------------- | ---------- |
|
|
1548
|
+
| `'embed_error'` | Hosted UI dialogs (`session.open()`, `shareDocument()`) | The dialog failed to load or communicate. Display `error.message`. |
|
|
1549
|
+
| `'not_in_browser'` | Hosted UI dialogs | Called in an environment without DOM/browser globals. Run only in browser contexts. |
|
|
1550
|
+
| `'iframe_unavailable'` | `chooseInputs()`, `chooseInput()` | The browser blocked the picker iframe (e.g. strict CSP or partitioned storage). Display `error.message` and keep existing references. |
|
|
1551
|
+
| `'app_not_granted'` | Opening a document via ID | Your app was not granted access to this document. Have the user pick the document through `session.open()` to grant access. |
|
|
1552
|
+
| `'not_imported'` | Opening a file whose type another app imports | The import runs only when someone opens the file in that app. Show `error.message` rather than a loading state, since nothing is in progress. In a `subscribeInputDocuments` handler, show it and rethrow, and Brass re-invokes the handler once that app has imported the file. |
|
|
1553
|
+
|
|
1554
|
+
Calls made without an active session reject with `status === 401` locally before
|
|
1555
|
+
reaching the network. Reopening an editable copy on a document where the user only has
|
|
1556
|
+
view access rejects with `status === 403`.
|
|
1070
1557
|
|
|
1071
1558
|
## Guides
|
|
1072
1559
|
|
|
1073
|
-
- [`AGENTS.md`](./AGENTS.md): instructions for AI coding agents, covering the review loop, the UX defaults a generated app should meet, and shipping it to a published URL.
|
|
1560
|
+
- [`AGENTS.md`](./AGENTS.md): instructions for AI coding agents, covering driving the sign-in, the review loop, the UX defaults a generated app should meet, and shipping it to a published URL.
|
|
1074
1561
|
- [`docs/app-manifest.md`](./docs/app-manifest.md): the `brass-app.json` capability manifest every app ships, where it goes in your bundle, and what each key declares.
|
|
1075
1562
|
- [`docs/imported-documents.md`](./docs/imported-documents.md): the record store an imported file becomes, `Brass.registerImporter`, `doc.sourceBlob()`, and `Brass.registerSerializer`.
|
|
1076
1563
|
- [`docs/schemas.md`](./docs/schemas.md): declaring a schema, creating a stream, what lets an app open a document, and what breaks that.
|
|
1077
|
-
- [`docs/streams.md`](./docs/streams.md):
|
|
1564
|
+
- [`docs/streams.md`](./docs/streams.md): splitting a document into streams that load independently, opening one, adding one, and reading a document spread across streams.
|
|
1078
1565
|
- [`docs/custom-chrome.md`](./docs/custom-chrome.md): composable header pieces, and rendering `session.commands` yourself for a fully custom shell.
|
|
1079
1566
|
- [`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
1567
|
- [`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.
|
|
1081
1568
|
- [`docs/presence.md`](./docs/presence.md): remote cursors, roster rendering, coordinate conventions, full types.
|
|
1082
|
-
- [`docs/hosted-ui-advanced.md`](./docs/hosted-ui-advanced.md): the hosted launchers (`Brass.
|
|
1569
|
+
- [`docs/hosted-ui-advanced.md`](./docs/hosted-ui-advanced.md): the hosted launchers (`Brass.session.open()`, `Brass.shareDocument`), their redirect behavior in browsers that block the iframe, theming, CSP.
|