@brass-build/client 0.1.0 → 0.2.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 (193) hide show
  1. package/AGENTS.md +72 -63
  2. package/CHANGELOG.md +86 -0
  3. package/README.md +317 -231
  4. package/dist/auth-flow.d.ts +10 -1
  5. package/dist/auth-flow.d.ts.map +1 -1
  6. package/dist/auth-flow.js +64 -20
  7. package/dist/auth-flow.js.map +1 -1
  8. package/dist/client.d.ts +3 -0
  9. package/dist/client.d.ts.map +1 -1
  10. package/dist/client.js +217 -75
  11. package/dist/client.js.map +1 -1
  12. package/dist/document-engine.d.ts +4 -0
  13. package/dist/document-engine.d.ts.map +1 -0
  14. package/dist/document-engine.js +9 -0
  15. package/dist/document-engine.js.map +1 -0
  16. package/dist/document-streams.d.ts +8 -1
  17. package/dist/document-streams.d.ts.map +1 -1
  18. package/dist/document-streams.js +79 -8
  19. package/dist/document-streams.js.map +1 -1
  20. package/dist/documents-namespace.d.ts.map +1 -1
  21. package/dist/documents-namespace.js +19 -3
  22. package/dist/documents-namespace.js.map +1 -1
  23. package/dist/documents.d.ts +43 -14
  24. package/dist/documents.d.ts.map +1 -1
  25. package/dist/documents.js +871 -223
  26. package/dist/documents.js.map +1 -1
  27. package/dist/embed-protocol.d.ts +3 -0
  28. package/dist/embed-protocol.d.ts.map +1 -0
  29. package/dist/embed-protocol.js +26 -0
  30. package/dist/embed-protocol.js.map +1 -0
  31. package/dist/emitter.d.ts.map +1 -1
  32. package/dist/emitter.js +8 -1
  33. package/dist/emitter.js.map +1 -1
  34. package/dist/export-guest.d.ts.map +1 -1
  35. package/dist/export-guest.js +14 -6
  36. package/dist/export-guest.js.map +1 -1
  37. package/dist/fetch-deadline.d.ts +22 -0
  38. package/dist/fetch-deadline.d.ts.map +1 -0
  39. package/dist/fetch-deadline.js +96 -0
  40. package/dist/fetch-deadline.js.map +1 -0
  41. package/dist/fetch-retry.d.ts +1 -0
  42. package/dist/fetch-retry.d.ts.map +1 -1
  43. package/dist/fetch-retry.js +12 -5
  44. package/dist/fetch-retry.js.map +1 -1
  45. package/dist/handle-types.d.ts +150 -0
  46. package/dist/handle-types.d.ts.map +1 -0
  47. package/dist/handle-types.js +8 -0
  48. package/dist/handle-types.js.map +1 -0
  49. package/dist/handles.d.ts +48 -48
  50. package/dist/handles.d.ts.map +1 -1
  51. package/dist/handles.js +3122 -501
  52. package/dist/handles.js.map +1 -1
  53. package/dist/hosted-runtime.d.ts.map +1 -1
  54. package/dist/hosted-runtime.js +8 -12
  55. package/dist/hosted-runtime.js.map +1 -1
  56. package/dist/hosted-ui.d.ts +0 -1
  57. package/dist/hosted-ui.d.ts.map +1 -1
  58. package/dist/hosted-ui.js +29 -24
  59. package/dist/hosted-ui.js.map +1 -1
  60. package/dist/importer.d.ts.map +1 -1
  61. package/dist/importer.js +253 -8
  62. package/dist/importer.js.map +1 -1
  63. package/dist/index.d.ts +10 -10
  64. package/dist/index.d.ts.map +1 -1
  65. package/dist/index.js +11 -2
  66. package/dist/index.js.map +1 -1
  67. package/dist/internal.d.ts +1 -1
  68. package/dist/internal.d.ts.map +1 -1
  69. package/dist/internal.js.map +1 -1
  70. package/dist/join-coverage.d.ts +12 -0
  71. package/dist/join-coverage.d.ts.map +1 -0
  72. package/dist/join-coverage.js +37 -0
  73. package/dist/join-coverage.js.map +1 -0
  74. package/dist/jwt.d.ts.map +1 -1
  75. package/dist/jwt.js +5 -1
  76. package/dist/jwt.js.map +1 -1
  77. package/dist/launch-context.js +2 -2
  78. package/dist/launch-context.js.map +1 -1
  79. package/dist/mutate-batching.d.ts +25 -2
  80. package/dist/mutate-batching.d.ts.map +1 -1
  81. package/dist/mutate-batching.js +57 -10
  82. package/dist/mutate-batching.js.map +1 -1
  83. package/dist/open-document.d.ts +15 -1
  84. package/dist/open-document.d.ts.map +1 -1
  85. package/dist/open-document.js +1851 -321
  86. package/dist/open-document.js.map +1 -1
  87. package/dist/presence.d.ts +28 -0
  88. package/dist/presence.d.ts.map +1 -1
  89. package/dist/presence.js +149 -7
  90. package/dist/presence.js.map +1 -1
  91. package/dist/record-paging.d.ts +336 -71
  92. package/dist/record-paging.d.ts.map +1 -1
  93. package/dist/record-paging.js +591 -127
  94. package/dist/record-paging.js.map +1 -1
  95. package/dist/selection-persistence.d.ts.map +1 -1
  96. package/dist/selection-persistence.js +11 -1
  97. package/dist/selection-persistence.js.map +1 -1
  98. package/dist/serializer.d.ts +30 -1
  99. package/dist/serializer.d.ts.map +1 -1
  100. package/dist/session-persistence.d.ts.map +1 -1
  101. package/dist/session-persistence.js +9 -8
  102. package/dist/session-persistence.js.map +1 -1
  103. package/dist/session-toolbar.d.ts +0 -1
  104. package/dist/session-toolbar.d.ts.map +1 -1
  105. package/dist/session-toolbar.js +226 -75
  106. package/dist/session-toolbar.js.map +1 -1
  107. package/dist/session.d.ts.map +1 -1
  108. package/dist/session.js +87 -18
  109. package/dist/session.js.map +1 -1
  110. package/dist/tail-ops.d.ts +14 -4
  111. package/dist/tail-ops.d.ts.map +1 -1
  112. package/dist/tail-ops.js +75 -15
  113. package/dist/tail-ops.js.map +1 -1
  114. package/dist/topic-lifecycle.d.ts +4 -0
  115. package/dist/topic-lifecycle.d.ts.map +1 -1
  116. package/dist/topic-lifecycle.js +25 -1
  117. package/dist/topic-lifecycle.js.map +1 -1
  118. package/dist/types.d.ts +47 -1
  119. package/dist/types.d.ts.map +1 -1
  120. package/dist/undo.d.ts +2 -3
  121. package/dist/undo.d.ts.map +1 -1
  122. package/dist/undo.js +11 -0
  123. package/dist/undo.js.map +1 -1
  124. package/dist/unload-guard.d.ts +5 -0
  125. package/dist/unload-guard.d.ts.map +1 -0
  126. package/dist/unload-guard.js +88 -0
  127. package/dist/unload-guard.js.map +1 -0
  128. package/dist/version-bar.d.ts +4 -3
  129. package/dist/version-bar.d.ts.map +1 -1
  130. package/dist/version-bar.js +140 -19
  131. package/dist/version-bar.js.map +1 -1
  132. package/dist/version.d.ts +2 -2
  133. package/dist/version.js +1 -1
  134. package/dist/vite.d.ts +18 -2
  135. package/dist/vite.d.ts.map +1 -1
  136. package/dist/vite.js +133 -8
  137. package/dist/vite.js.map +1 -1
  138. package/dist/ws-client.d.ts +23 -3
  139. package/dist/ws-client.d.ts.map +1 -1
  140. package/dist/ws-client.js +339 -89
  141. package/dist/ws-client.js.map +1 -1
  142. package/dist/ws-frame-limit.d.ts.map +1 -1
  143. package/dist/ws-frame-limit.js +19 -12
  144. package/dist/ws-frame-limit.js.map +1 -1
  145. package/docs/app-manifest.md +7 -8
  146. package/docs/custom-chrome.md +17 -20
  147. package/docs/hosted-ui-advanced.md +14 -17
  148. package/docs/imported-documents.md +53 -197
  149. package/docs/presence.md +45 -21
  150. package/docs/schemas.md +12 -17
  151. package/docs/streams.md +55 -57
  152. package/package.json +4 -3
  153. package/src/auth-flow.ts +90 -23
  154. package/src/client.ts +225 -78
  155. package/src/document-engine.ts +15 -0
  156. package/src/document-signals.ts +7 -7
  157. package/src/document-streams.ts +95 -9
  158. package/src/documents-namespace.ts +18 -3
  159. package/src/documents.ts +983 -277
  160. package/src/embed-protocol.ts +25 -0
  161. package/src/emitter.ts +8 -1
  162. package/src/export-guest.ts +15 -9
  163. package/src/fetch-deadline.ts +119 -0
  164. package/src/fetch-retry.ts +14 -5
  165. package/src/handle-types.ts +303 -0
  166. package/src/handles.ts +3406 -683
  167. package/src/hosted-runtime.ts +8 -12
  168. package/src/hosted-ui.ts +28 -32
  169. package/src/importer.ts +261 -8
  170. package/src/index.ts +29 -18
  171. package/src/internal.ts +1 -0
  172. package/src/join-coverage.ts +42 -0
  173. package/src/jwt.ts +8 -1
  174. package/src/launch-context.ts +2 -2
  175. package/src/mutate-batching.ts +60 -11
  176. package/src/open-document.ts +2355 -515
  177. package/src/presence.ts +210 -11
  178. package/src/record-paging.ts +687 -137
  179. package/src/selection-persistence.ts +10 -1
  180. package/src/serializer.ts +33 -1
  181. package/src/session-persistence.ts +9 -8
  182. package/src/session-toolbar.ts +249 -80
  183. package/src/session.ts +91 -17
  184. package/src/tail-ops.ts +76 -10
  185. package/src/topic-lifecycle.ts +31 -1
  186. package/src/types.ts +56 -4
  187. package/src/undo.ts +13 -5
  188. package/src/unload-guard.ts +91 -0
  189. package/src/version-bar.ts +163 -25
  190. package/src/version.ts +1 -1
  191. package/src/vite.ts +175 -10
  192. package/src/ws-client.ts +392 -92
  193. package/src/ws-frame-limit.ts +19 -12
@@ -1,131 +1,24 @@
1
- # Imported files and formats
1
+ # Handling a file format
2
2
 
3
- Your app reads and edits an imported document through the ordinary
4
- handle surface, and Brass does the importing. A user uploads a file or
5
- opens a drive item, and the platform turns it into a document, so there
6
- is no client import call to make. How much of the file mapping you write
7
- yourself depends on whether the format already has an importer:
3
+ A user uploads a file or opens a drive item, and Brass turns it into a
4
+ document your app opens like any other. What your app writes depends on
5
+ whether the format already has an importer.
8
6
 
9
- - **A format with an importer**: the parsed records ARE the document
10
- (an installed app provides the importer). You read and edit the
11
- records through the ordinary handle surface, and Brass re-emits the
12
- original on export and drive-push through an installed app that
13
- serialises the format. Your app is an **editor**: declare the
14
- document's schema in `brass-app.json`, the contract peers evaluate
15
- compatibility against. The schema is also the record contract: its
16
- `description`s declare how each field is encoded, so an analysis app
17
- derives what it computes (a tree, geometry, quantities) from the
18
- records the schema describes. A large document spreads those records
19
- across the streams the schema names, so read them all (see [Reading a
20
- document spread across
21
- streams](./streams.md#reading-a-document-spread-across-streams)).
22
- - **Any other file**: the document is an opaque file (source
23
- `schema_type` is `brass:file`). Your app is the importer/exporter:
24
- it registers its parser with `Brass.registerImporter` (Brass runs it
25
- on the stored bytes and stamps your schema on the document) and the
26
- inverse with `Brass.registerSerializer`.
7
+ **A format someone already imports.** The parsed records are the
8
+ document, and Brass re-emits the original on export and drive-push
9
+ through an installed app that serialises the format. Your app is an
10
+ editor: declare the document's schema in `brass-app.json` so peers can
11
+ open it, and read the records through the ordinary handle surface. See
12
+ [`schemas.md`](./schemas.md).
27
13
 
28
- Documents your app creates itself have no source file. If your app
29
- only makes its own documents, it reads and edits records and declares a
30
- schema so peers can open them.
14
+ **Any other file.** The document is an opaque file (source `schema_type`
15
+ is `brass:file`) until your app parses it. Register the parse half with
16
+ `Brass.registerImporter` and the inverse with `Brass.registerSerializer`.
31
17
 
32
- ## The record store
18
+ ## Registering an importer
33
19
 
34
- The record store is the same for every format. It lives at the
35
- document root as two containers: `instances` (a map keyed by record id
36
- `'#23'` whose values are `{ type, params }` records) and
37
- `instance_order` (the same keys in file order, which the serializer
38
- walks to re-emit the file). Every record in `instances` must have its
39
- key in `instance_order`, or the serializer drops it.
40
-
41
- **Reach for both as one handle:
42
- `doc.orderedMap('instances', 'instance_order')`.** The ordered map
43
- keeps the order list in step for you: an add appends the key, a delete
44
- prunes it, both in one commit (one undo step). You never write
45
- `instance_order` by hand.
46
-
47
- ```ts
48
- const records = doc.orderedMap('instances', 'instance_order');
49
-
50
- records.read(); // { '#1': { type, params }, ... }
51
- records.get('#1'); // one record, or undefined
52
- records.ordered(); // [{ key, value }, ...] in file order
53
- records.keys(); // ['#1', '#2', ...] in file order
54
-
55
- records.mutate((m) => {
56
- m[newKey] = { type, params }; // add: appended to order
57
- delete m['#7']; // delete: pruned from order
58
- m['#3'].params = [...]; // field edit: order untouched
59
- });
60
- ```
61
-
62
- `type` is the format's record tag, and `params` is the record body, whose
63
- shape is format-specific. Values read back are detached copies.
64
-
65
- **Edit a record by reassigning its whole `params`.** The record is the
66
- unit of change: the value you read back is a detached copy, so
67
- mutating it element-wise does not persist. Build the new `params` and
68
- assign it. Because edits live in the canonical store, the original
69
- format re-emits faithfully on export.
70
-
71
- **A newly added record lands at the end of source order.** When the
72
- order is structural (a record must sit inside a section or a parent's
73
- child range to re-emit validly), don't lean on physical record order:
74
- have your serializer reconstruct the structure from the records' own
75
- declared section / parent on the way out.
76
-
77
- ## Cost by change: subscribe, don't re-read
78
-
79
- An imported model is commonly hundreds of thousands of records, so a
80
- per-change `read()` to rebuild your view is O(file) per keystroke. The
81
- subscription hands you a `change` on every fire: the first fire
82
- carries the whole store as `change.set` (build your view there), and
83
- every later fire names only the changed records (one path for your own
84
- edits, remote edits, and post-open hydration alike). Don't memoise the
85
- derivation on a record-count fingerprint: an in-place edit keeps the
86
- count, so the memo reads back stale. And keep view-only changes (pan,
87
- zoom, selection) off the content paint.
88
-
89
- ## How a format maps to records
90
-
91
- Map every format to records by the same recipe, which is what lets
92
- independently-built apps predict and share a format's records:
93
-
94
- 1. **One record per element the format enumerates as its own
95
- addressable item** (a STEP instance, an entity, a named element).
96
- Inline only anonymous leaf scalars that have no identity of their
97
- own. The record is the concurrent-edit unit: its whole `params`
98
- merges last-writer-wins, so keep `params` a small plain value and
99
- never pack several independently-edited values into one record.
100
- 2. **`type` is the format's own class tag, verbatim**: no renaming, no
101
- prefix.
102
- 3. **The key is the format's own stable id, verbatim.** When the
103
- format has no native id, synthesize one deterministically from the
104
- record's own content, never from its position in the file (a
105
- positional index is not stable across a re-import) and never at
106
- random.
107
- 4. **`params` is the object's own fields as a plain value**,
108
- reassigned whole to edit it.
109
- 5. **A relationship is its own record only when the format makes it an
110
- object**, and otherwise it stays a reference field, on the **child**
111
- pointing up to its parent. A mutable child-list on the parent is a
112
- concurrently-edited array, so two people adding different children
113
- collide.
114
- 6. **Decompose only what has a model.** Named objects shred into
115
- records; binary bulk is referenced by hash, not inlined; a file
116
- with no object model stays an opaque `brass:file` blob.
117
-
118
- To build against a format someone else imported, read its schema: it
119
- declares exactly how the importer encoded each field into `params`
120
- (value shapes, the reference convention) in its `description`s. Get it
121
- from the document's dashboard page (**Download brass-app.json**), and
122
- declare that copy in `brass-app.json` to route those documents to your
123
- app. See [`schemas.md`](./schemas.md).
124
-
125
- ## Registering your format's importer (`Brass.registerImporter`)
126
-
127
- For a file kept as-is, register your parse half once at boot, and Brass
128
- runs it when an import is needed:
20
+ Register your parse half once at boot, and Brass runs it when an import
21
+ is needed:
129
22
 
130
23
  ```ts
131
24
  Brass.registerImporter(async (src, doc) => {
@@ -140,89 +33,52 @@ Brass.registerImporter(async (src, doc) => {
140
33
  });
141
34
  ```
142
35
 
143
- The importer receives the stored source file (a short-lived download
144
- URL plus the file's name, type, and size, with the bytes not inlined)
145
- and the open document, parses the bytes, and writes the records
146
- through the ordinary handles. `DocumentImporter` types the callback.
36
+ The importer receives the stored source file (a short-lived download URL
37
+ plus the file's name, type, and size, with the bytes not inlined) and the
38
+ open document. `DocumentImporter` types the callback.
147
39
 
148
40
  Brass decides when an import is needed and runs your importer once. A
149
41
  document that already holds its records, or has no stored file, is left
150
42
  alone, and the records reach every open client through its normal
151
- subscription. Declare the `schema` of the records your
152
- importer writes in `brass-app.json`: importing publishes it and
153
- stamps it on the document, so the document routes and resolves its
154
- schema like any imported document.
43
+ subscription. Declare the `schema` of the records your importer writes in
44
+ `brass-app.json`: importing publishes it and stamps it on the document.
155
45
 
156
- Sealing the write with `doc.createCheckpoint()` at the end of the
157
- importer makes the import one undo step, so an undo reverts it whole
158
- instead of leaving a half-deleted model. An importer that throws
159
- surfaces through `Brass.onError`, and Brass retries the import later.
160
-
161
- **Spread a large format across streams.** When your format is large,
162
- keep the records a default view reads in the default stream, and move
163
- the bulk (geometry, an index most opens skip) onto streams you create. Name those streams in your schema's `streams`, and a reader
164
- merges them the same way it merges any format's:
165
-
166
- ```ts
167
- Brass.registerImporter(async (src, doc) => {
168
- const { structured, geometry } = parseMyFormat(await bytesOf(src));
169
- doc.orderedMap('instances', 'instance_order').mutate((m) => {
170
- for (const r of structured) m[r.id] = { type: r.type, params: r.body };
171
- });
172
- // A stream your schema names, so your serializer composes it back and
173
- // any app expecting it merges its records.
174
- const geom = await doc.getOrCreateStream('geometry');
175
- geom.orderedMap('instances', 'instance_order').mutate((m) => {
176
- for (const r of geometry) m[r.id] = { type: r.type, params: r.body };
177
- });
178
- await doc.createCheckpoint();
179
- });
180
- ```
46
+ Sealing the write with `doc.createCheckpoint()` makes the import one undo
47
+ step, so an undo reverts it whole instead of leaving a half-deleted
48
+ model. An importer that throws surfaces through `Brass.onError`. Brass
49
+ retries an import that wrote no records. Once records are written, it
50
+ refuses a later import of that document, because running the importer
51
+ again over them would write each of those records twice.
181
52
 
182
- Name `geometry` under `streams` in the same schema you ship in
183
- `brass-app.json`, so a reader can resolve its contract. A document can
184
- also hold streams your schema does not name (a review overlay, a
185
- computed cache), and your serializer composes only the streams it
186
- declares, so those stay in the document and out of the file. Nothing
187
- here is privileged: a document your importer spreads is
188
- indistinguishable from one the platform produced, and every app that
189
- declares the same streams reads it the same way. Your serializer
190
- reassembles it by reading those same streams back
191
- (`doc.stream('geometry')`).
53
+ Key each record by the format's own stable id. When the format has no
54
+ native id, derive one from the record's own content rather than from its
55
+ position in the file, which is not stable across a re-import.
192
56
 
193
- `doc.sourceBlob()` is the same source-file read as the importer's
194
- `src` argument, for code outside the importer that needs the original
195
- bytes (a "view original" affordance). It rejects with a `BrassError`
196
- for a document that has no stored original file.
57
+ ## Reading the source bytes
197
58
 
198
59
  A source file is a set of one or more byte streams: the primary stream
199
60
  (what the filename names) plus any sidecar streams uploaded with it (a
200
61
  glTF's `.bin` buffer, a shapefile's `.dbf`). `src.streams` lists every
201
62
  stream, primary first, each as `{ name, url, content_type, byte_size }`
202
63
  with its own download URL, so an app fetches exactly the streams it
203
- needs, in parallel. A single-stream file has exactly one entry (it
204
- mirrors the top-level fields), so code that iterates `streams` handles
205
- both cases in one path:
64
+ needs, in parallel. A single-stream file has exactly one entry, so code
65
+ that iterates `streams` handles both cases in one path:
206
66
 
207
67
  ```ts
208
68
  const byName = new Map(src.streams.map((s) => [s.name, s]));
209
69
  const buffer = byName.get('scene.bin'); // undefined when not part of the set
210
70
  ```
211
71
 
212
- **Choose the record as the smallest thing two people edit
213
- independently.** Concurrent edits to one record resolve
214
- last-writer-wins on its whole `params`, so a grid of
215
- independently-edited values is one record per cell (keyed by row and
216
- column), not one record per row. Reconstruct rows for display and for
217
- the serializer by walking the cell records. The same reasoning bans
218
- nesting a collaboratively-edited array inside one record.
72
+ `doc.sourceBlob()` is the same read for code outside the importer that
73
+ needs the original bytes (a "view original" affordance). It rejects with
74
+ a `BrassError` for a document that has no stored original file.
219
75
 
220
- ## Registering your format's serializer
76
+ ## Registering a serializer
221
77
 
222
78
  Your serializer rebuilds the file from your records, and the platform
223
79
  runs it for the download button and for drive publish. Write it as the
224
- inverse of your importer so that it produces the format the importer
225
- read. Register it once at boot, either before or after `Brass.init`:
80
+ inverse of your importer. Register it once at boot, either before or
81
+ after `Brass.init`:
226
82
 
227
83
  ```ts
228
84
  Brass.registerSerializer((doc) => {
@@ -232,24 +88,24 @@ Brass.registerSerializer((doc) => {
232
88
  });
233
89
  ```
234
90
 
235
- The serializer takes the open `Document` and returns the bytes
236
- (`Uint8Array`, or a promise of one). Producing the bytes is its only
237
- job, and `DocumentSerializer` types the callback. With it registered, your
238
- app only ever works with records, never with file I/O.
91
+ The serializer returns the bytes (`Uint8Array`, or a promise of one).
92
+ `DocumentSerializer` types the callback. It runs detached, with no
93
+ subscription feeding it, so the document it is handed reads its
94
+ containers whole: `StoredDocument` types that document, and its `map` /
95
+ `list` return a `StoredMap` / `StoredList`, which add `read()` to the
96
+ handle an app holds.
239
97
 
240
- Your app exports so that software outside Brass can read its work. The
241
- format therefore has to be one that software already opens. If your app
242
- imports a file, write that same format back out. If your app makes its
243
- documents from nothing, ask the people who use it which tool they need
244
- the download to open in, and write that tool's format. A format you
245
- invent yourself produces a file only your app can read, and apps inside
246
- Brass already share documents through the schema you declare.
98
+ Your app exports so that software outside Brass can read its work, so
99
+ write a format that software already opens. If your app imports a file,
100
+ write that same format back out. If your app makes its documents from
101
+ nothing, ask the people who use it which tool they need the download to
102
+ open in, and write that tool's format.
247
103
 
248
104
  ## Declaring what your app handles
249
105
 
250
- Your app declares what it reads and writes in its capability manifest
251
- at `/.well-known/brass-app.json`. `accepts` matches the uploaded files
252
- you import, `exports` names the format you write back out, and `schema`
106
+ Your app declares what it reads and writes in its capability manifest at
107
+ `/.well-known/brass-app.json`. `imports` matches the uploaded files you
108
+ import, `exports` names the format you write back out, and `schema`
253
109
  declares the records you work with. See
254
110
  [`app-manifest.md`](./app-manifest.md).
255
111
 
package/docs/presence.md CHANGED
@@ -1,11 +1,10 @@
1
1
  # Presence (live cursors, who's-here)
2
2
 
3
- `doc.presence` is the ephemeral "who is here right now" layer over an
4
- open document: the roster of other live participants plus a
5
- lightweight per-user `state` (cursor / camera / selection) and an
6
- app-namespaced `ext` blob. It is non-null on any open
7
- writable `Document`, and `null` otherwise. Presence is fully
8
- ephemeral: nothing survives a disconnect.
3
+ `doc.presence` is the "who is here right now" layer over an open
4
+ document: the roster of other live participants plus a small per-user
5
+ `state` (cursor / camera / selection) and an app-namespaced `ext` blob.
6
+ It is non-null on any open writable `Document`, and `null` otherwise.
7
+ Presence is fully ephemeral: nothing survives a disconnect.
9
8
 
10
9
  ```ts
11
10
  import type { PresenceParticipant } from '@brass-build/client';
@@ -24,12 +23,9 @@ doc.presence?.set({ ext: { myApp: { tool: 'select' } } });
24
23
  // canvas APIs without parsing) and their last `state`. Interpolate
25
24
  // between updates in your render loop.
26
25
  //
27
- // `on('change', ...)` fires once on subscribe with the current roster
28
- // when peers are already present (you opened a document others are
29
- // already in), then on every join, leave, or `state` / `ext` update.
30
- // It does not fire on subscribe when you are the only one here, and an
31
- // empty roster is the default paint anyway, so one listener covers both
32
- // the initial render and every change.
26
+ // `on('change', ...)` fires once on subscribe when peers are already
27
+ // present, then on every join, leave, or `state` / `ext` update, so one
28
+ // listener covers both the initial render and every change.
33
29
  const renderRoster = (roster: PresenceParticipant[]) => {
34
30
  for (const p of roster) renderAvatar(p.connectionId, p.color, p.state);
35
31
  };
@@ -79,16 +75,44 @@ indicator) goes under `ext` instead so it stays namespaced and out of
79
75
  the cross-app conventions. The `ext` block is otherwise free-form:
80
76
  `ext.myApp = { tool: 'select', chatBubble: 'hi' }`.
81
77
 
78
+ ## How much you can publish
79
+
80
+ Each layer is capped at 2,048 bytes of serialised JSON, exported as
81
+ `PRESENCE_LAYER_MAX_BYTES`. Brass measures the two layers separately, so a
82
+ large `ext` blob leaves `state` its full budget.
83
+
84
+ `selection` is the field that reaches the cap in ordinary use, because it
85
+ grows with what the user picked, and one marquee select crosses it.
86
+ Publish a bounded summary when a selection can get large, such as the
87
+ first few ids plus a count, or the selection's bounding box.
88
+
89
+ The SDK measures each layer before it sends and withholds one that is over
90
+ the cap, so peers keep the last value that fit rather than losing it. Read
91
+ `presence.status` for the current answer, or subscribe with `onStatus`:
92
+
93
+ ```ts
94
+ doc.presence?.onStatus((status) => {
95
+ if (status.oversize) {
96
+ showNotice(`Not sharing your ${status.withheld.join(' and ')}`);
97
+ } else {
98
+ clearNotice();
99
+ }
100
+ });
101
+ ```
102
+
103
+ `onStatus` fires on attach and then whenever the answer changes, so an app
104
+ publishing an oversized payload at 10 Hz hears about it once.
105
+
82
106
  ## Types
83
107
 
84
- `doc.presence` is typed `PresenceClient | null`; each `roster` entry
108
+ `doc.presence` is typed `PresenceClient | null`. Each `roster` entry
85
109
  is a `PresenceParticipant` with
86
110
  `{ connectionId, sub, displayName, avatarUrl, color, joinedAt, lastSeen,
87
111
  state, ext }`. `displayName`, `avatarUrl`, and `color` are always
88
- present as strings; `avatarUrl` is `''` when the user has no picture.
89
- Both types are exported from `@brass-build/client` (alongside
90
- `PresenceSetInput` and `PresenceChangeListener`) so you can annotate
91
- handlers and roster state without source-reading the SDK.
112
+ present as strings, and `avatarUrl` is `''` when the user has no
113
+ picture. Both types are exported from `@brass-build/client`, alongside
114
+ `PresenceSetInput` and `PresenceChangeListener`, for annotating your
115
+ handlers and roster state.
92
116
 
93
117
  ## Hand-rolling the header roster (custom shell)
94
118
 
@@ -107,7 +131,7 @@ const renderRoster = (roster: readonly PresenceParticipant[]): void => {
107
131
  const pip = document.createElement('span');
108
132
  pip.className = 'pip';
109
133
  pip.style.background = p.color;
110
- pip.title = p.displayName ?? p.sub ?? '';
134
+ pip.title = p.displayName;
111
135
  if (p.avatarUrl) {
112
136
  const img = document.createElement('img');
113
137
  img.src = p.avatarUrl;
@@ -115,7 +139,7 @@ const renderRoster = (roster: readonly PresenceParticipant[]): void => {
115
139
  img.style.cssText = 'width:100%;height:100%;object-fit:cover;border-radius:50%';
116
140
  pip.appendChild(img);
117
141
  } else {
118
- pip.textContent = (p.displayName ?? p.sub ?? '?').slice(0, 1).toUpperCase();
142
+ pip.textContent = p.displayName.slice(0, 1).toUpperCase();
119
143
  }
120
144
  return pip;
121
145
  }));
@@ -125,5 +149,5 @@ const renderRoster = (roster: readonly PresenceParticipant[]): void => {
125
149
  Drive it from the same `change` subscription as the in-surface remote
126
150
  cursors (see the README's "Presence" section). The listener fires on
127
151
  subscribe whenever peers are already present, so it paints the initial
128
- roster for you; render `doc.presence?.roster ?? []` yourself only if you
129
- want the empty state on screen before anyone joins.
152
+ roster for you. Render `doc.presence?.roster ?? []` yourself to put the
153
+ empty state on screen before anyone joins.
package/docs/schemas.md CHANGED
@@ -18,8 +18,9 @@ stream's root collections and records.
18
18
  other apps converge on, so it names the artifact rather than your app or
19
19
  your version: `gcode`, not `acme-cam` and not `gcode-2`. Lowercase
20
20
  `a-z 0-9 . _ -`, up to 64 characters, the same rule a stream name follows,
21
- so a namespaced `acme.roster` works and a URL does not. A declaration whose family is missing or not a word is
22
- not recorded, and your app's dashboard page says so.
21
+ so a namespaced `acme.roster` works and a URL does not. Brass does not
22
+ record a declaration whose family is missing or not a word, and reports
23
+ that on your app's page in the dashboard.
23
24
  - **`streams`** maps each stream name your app expects a document to hold
24
25
  to that stream's contract. `default` is the stream a document opens
25
26
  without asking for one, and takes an entry like any other name. Two
@@ -28,7 +29,7 @@ stream's root collections and records.
28
29
  is the record schema, so the keys are record ids. An ordered collection
29
30
  is an array, or an array of keys alongside the map.
30
31
  - **Foreign keys** ride as an `x-brass-ref` extension on the referring
31
- field, an object with a `to` naming the collection referred to.
32
+ field, an object whose `to` names the collection it points at.
32
33
  Standard validators ignore an `x-` keyword.
33
34
  - **The `x-brass-` prefix is reserved for Brass.** Give your own extensions
34
35
  their own prefix (`x-acme-`), so a name you choose keeps its meaning when
@@ -53,7 +54,7 @@ and an approximation reads as a different contract.
53
54
  Streams let one document load in parts, so an app pays for what it opens
54
55
  rather than the document's total size. `doc.getOrCreateStream(name)`
55
56
  opens the document's stream of that name and makes it when the document
56
- does not hold it yet; `doc.stream(name)` opens one the document already
57
+ does not hold it yet. `doc.stream(name)` opens one the document already
57
58
  holds and rejects any other name.
58
59
 
59
60
  ```ts
@@ -80,11 +81,9 @@ spread across several.
80
81
 
81
82
  An app opens a document when they share a family and **everything the app
82
83
  requires the document already guarantees.** That is the whole test, and
83
- it runs per stream, over the streams both sides name.
84
-
85
- It is deliberately loose. Your schema does not have to match a document's
86
- exactly, only ask no more of it, so an app opens documents written
87
- against shapes it was never built for.
84
+ it runs per stream, over the streams both sides name. Your schema does
85
+ not have to match a document's exactly, only ask no more of it, so an
86
+ app opens documents written against shapes it was never built for.
88
87
 
89
88
  An exporter composes the streams it declares, so an app exporting a
90
89
  document writes the parts it knows about and leaves the rest. Two apps
@@ -103,10 +102,10 @@ that can break anything.
103
102
  documents never promised it.
104
103
  - **Adding an optional property is safe** in both directions.
105
104
 
106
- Two habits follow. Keep records open (no `additionalProperties: false`)
107
- so your app preserves fields it does not recognise rather than dropping
108
- them on write, and handle a missing field gracefully, since Brass
109
- enforces nothing and a writer may not have written it.
105
+ Keep records open (no `additionalProperties: false`) so your app
106
+ preserves fields it does not recognise rather than dropping them on
107
+ write, and handle a missing field gracefully, since Brass enforces
108
+ nothing and a writer may not have written it.
110
109
 
111
110
  ## Adding a required property anyway
112
111
 
@@ -129,7 +128,3 @@ written before `site` existed, and every document it saves guarantees
129
128
  `site` as well. An app still on the old shape reads the result, because
130
129
  the document now guarantees more than that app expects. Leave `writes`
131
130
  out and one contract covers both sides, which is what most apps want.
132
-
133
- The two directions are the whole rule: an app opens a document that
134
- guarantees at least what the app expects, and writes one whose guarantees
135
- its own writes cover.