@brass-build/client 0.1.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 (258) hide show
  1. package/AGENTS.md +255 -0
  2. package/CHANGELOG.md +15 -0
  3. package/LICENSE +21 -0
  4. package/README.md +836 -0
  5. package/dist/api-fetch.d.ts +16 -0
  6. package/dist/api-fetch.d.ts.map +1 -0
  7. package/dist/api-fetch.js +97 -0
  8. package/dist/api-fetch.js.map +1 -0
  9. package/dist/api-headers.d.ts +3 -0
  10. package/dist/api-headers.d.ts.map +1 -0
  11. package/dist/api-headers.js +25 -0
  12. package/dist/api-headers.js.map +1 -0
  13. package/dist/auth-flow.d.ts +23 -0
  14. package/dist/auth-flow.d.ts.map +1 -0
  15. package/dist/auth-flow.js +76 -0
  16. package/dist/auth-flow.js.map +1 -0
  17. package/dist/client.d.ts +106 -0
  18. package/dist/client.d.ts.map +1 -0
  19. package/dist/client.js +1278 -0
  20. package/dist/client.js.map +1 -0
  21. package/dist/doc-url-sync.d.ts +19 -0
  22. package/dist/doc-url-sync.d.ts.map +1 -0
  23. package/dist/doc-url-sync.js +181 -0
  24. package/dist/doc-url-sync.js.map +1 -0
  25. package/dist/document-branches.d.ts +32 -0
  26. package/dist/document-branches.d.ts.map +1 -0
  27. package/dist/document-branches.js +89 -0
  28. package/dist/document-branches.js.map +1 -0
  29. package/dist/document-checkpoints.d.ts +24 -0
  30. package/dist/document-checkpoints.d.ts.map +1 -0
  31. package/dist/document-checkpoints.js +115 -0
  32. package/dist/document-checkpoints.js.map +1 -0
  33. package/dist/document-commands.d.ts +93 -0
  34. package/dist/document-commands.d.ts.map +1 -0
  35. package/dist/document-commands.js +423 -0
  36. package/dist/document-commands.js.map +1 -0
  37. package/dist/document-signals.d.ts +53 -0
  38. package/dist/document-signals.d.ts.map +1 -0
  39. package/dist/document-signals.js +114 -0
  40. package/dist/document-signals.js.map +1 -0
  41. package/dist/document-streams.d.ts +22 -0
  42. package/dist/document-streams.d.ts.map +1 -0
  43. package/dist/document-streams.js +98 -0
  44. package/dist/document-streams.js.map +1 -0
  45. package/dist/document-thumbnail-capture.d.ts +27 -0
  46. package/dist/document-thumbnail-capture.d.ts.map +1 -0
  47. package/dist/document-thumbnail-capture.js +160 -0
  48. package/dist/document-thumbnail-capture.js.map +1 -0
  49. package/dist/documents-namespace.d.ts +134 -0
  50. package/dist/documents-namespace.d.ts.map +1 -0
  51. package/dist/documents-namespace.js +190 -0
  52. package/dist/documents-namespace.js.map +1 -0
  53. package/dist/documents.d.ts +146 -0
  54. package/dist/documents.d.ts.map +1 -0
  55. package/dist/documents.js +2045 -0
  56. package/dist/documents.js.map +1 -0
  57. package/dist/emitter.d.ts +15 -0
  58. package/dist/emitter.d.ts.map +1 -0
  59. package/dist/emitter.js +90 -0
  60. package/dist/emitter.js.map +1 -0
  61. package/dist/errors.d.ts +18 -0
  62. package/dist/errors.d.ts.map +1 -0
  63. package/dist/errors.js +22 -0
  64. package/dist/errors.js.map +1 -0
  65. package/dist/export-guest.d.ts +9 -0
  66. package/dist/export-guest.d.ts.map +1 -0
  67. package/dist/export-guest.js +225 -0
  68. package/dist/export-guest.js.map +1 -0
  69. package/dist/fetch-retry.d.ts +16 -0
  70. package/dist/fetch-retry.d.ts.map +1 -0
  71. package/dist/fetch-retry.js +90 -0
  72. package/dist/fetch-retry.js.map +1 -0
  73. package/dist/guest-link.d.ts +3 -0
  74. package/dist/guest-link.d.ts.map +1 -0
  75. package/dist/guest-link.js +13 -0
  76. package/dist/guest-link.js.map +1 -0
  77. package/dist/handles.d.ts +57 -0
  78. package/dist/handles.d.ts.map +1 -0
  79. package/dist/handles.js +1375 -0
  80. package/dist/handles.js.map +1 -0
  81. package/dist/hosted-runtime.d.ts +33 -0
  82. package/dist/hosted-runtime.d.ts.map +1 -0
  83. package/dist/hosted-runtime.js +99 -0
  84. package/dist/hosted-runtime.js.map +1 -0
  85. package/dist/hosted-ui.d.ts +84 -0
  86. package/dist/hosted-ui.d.ts.map +1 -0
  87. package/dist/hosted-ui.js +673 -0
  88. package/dist/hosted-ui.js.map +1 -0
  89. package/dist/importer.d.ts +22 -0
  90. package/dist/importer.d.ts.map +1 -0
  91. package/dist/importer.js +54 -0
  92. package/dist/importer.js.map +1 -0
  93. package/dist/index.d.ts +54 -0
  94. package/dist/index.d.ts.map +1 -0
  95. package/dist/index.js +132 -0
  96. package/dist/index.js.map +1 -0
  97. package/dist/init-keys.d.ts +3 -0
  98. package/dist/init-keys.d.ts.map +1 -0
  99. package/dist/init-keys.js +13 -0
  100. package/dist/init-keys.js.map +1 -0
  101. package/dist/internal.d.ts +6 -0
  102. package/dist/internal.d.ts.map +1 -0
  103. package/dist/internal.js +17 -0
  104. package/dist/internal.js.map +1 -0
  105. package/dist/jwt.d.ts +3 -0
  106. package/dist/jwt.d.ts.map +1 -0
  107. package/dist/jwt.js +22 -0
  108. package/dist/jwt.js.map +1 -0
  109. package/dist/launch-context.d.ts +4 -0
  110. package/dist/launch-context.d.ts.map +1 -0
  111. package/dist/launch-context.js +44 -0
  112. package/dist/launch-context.js.map +1 -0
  113. package/dist/login-hint.d.ts +4 -0
  114. package/dist/login-hint.d.ts.map +1 -0
  115. package/dist/login-hint.js +63 -0
  116. package/dist/login-hint.js.map +1 -0
  117. package/dist/mutate-batching.d.ts +14 -0
  118. package/dist/mutate-batching.d.ts.map +1 -0
  119. package/dist/mutate-batching.js +65 -0
  120. package/dist/mutate-batching.js.map +1 -0
  121. package/dist/open-document.d.ts +127 -0
  122. package/dist/open-document.d.ts.map +1 -0
  123. package/dist/open-document.js +2121 -0
  124. package/dist/open-document.js.map +1 -0
  125. package/dist/page-title.d.ts +3 -0
  126. package/dist/page-title.d.ts.map +1 -0
  127. package/dist/page-title.js +32 -0
  128. package/dist/page-title.js.map +1 -0
  129. package/dist/perf.d.ts +9 -0
  130. package/dist/perf.d.ts.map +1 -0
  131. package/dist/perf.js +39 -0
  132. package/dist/perf.js.map +1 -0
  133. package/dist/presence.d.ts +62 -0
  134. package/dist/presence.d.ts.map +1 -0
  135. package/dist/presence.js +331 -0
  136. package/dist/presence.js.map +1 -0
  137. package/dist/record-paging.d.ts +99 -0
  138. package/dist/record-paging.d.ts.map +1 -0
  139. package/dist/record-paging.js +220 -0
  140. package/dist/record-paging.js.map +1 -0
  141. package/dist/schema-conflict-dialog.d.ts +10 -0
  142. package/dist/schema-conflict-dialog.d.ts.map +1 -0
  143. package/dist/schema-conflict-dialog.js +164 -0
  144. package/dist/schema-conflict-dialog.js.map +1 -0
  145. package/dist/selection-persistence.d.ts +5 -0
  146. package/dist/selection-persistence.d.ts.map +1 -0
  147. package/dist/selection-persistence.js +90 -0
  148. package/dist/selection-persistence.js.map +1 -0
  149. package/dist/serializer.d.ts +3 -0
  150. package/dist/serializer.d.ts.map +1 -0
  151. package/dist/serializer.js +2 -0
  152. package/dist/serializer.js.map +1 -0
  153. package/dist/session-persistence.d.ts +4 -0
  154. package/dist/session-persistence.d.ts.map +1 -0
  155. package/dist/session-persistence.js +68 -0
  156. package/dist/session-persistence.js.map +1 -0
  157. package/dist/session-toolbar.d.ts +62 -0
  158. package/dist/session-toolbar.d.ts.map +1 -0
  159. package/dist/session-toolbar.js +1205 -0
  160. package/dist/session-toolbar.js.map +1 -0
  161. package/dist/session.d.ts +69 -0
  162. package/dist/session.d.ts.map +1 -0
  163. package/dist/session.js +926 -0
  164. package/dist/session.js.map +1 -0
  165. package/dist/tail-ops.d.ts +28 -0
  166. package/dist/tail-ops.d.ts.map +1 -0
  167. package/dist/tail-ops.js +46 -0
  168. package/dist/tail-ops.js.map +1 -0
  169. package/dist/topic-lifecycle.d.ts +41 -0
  170. package/dist/topic-lifecycle.d.ts.map +1 -0
  171. package/dist/topic-lifecycle.js +169 -0
  172. package/dist/topic-lifecycle.js.map +1 -0
  173. package/dist/types.d.ts +22 -0
  174. package/dist/types.d.ts.map +1 -0
  175. package/dist/types.js +2 -0
  176. package/dist/types.js.map +1 -0
  177. package/dist/undo.d.ts +18 -0
  178. package/dist/undo.d.ts.map +1 -0
  179. package/dist/undo.js +110 -0
  180. package/dist/undo.js.map +1 -0
  181. package/dist/version-bar.d.ts +22 -0
  182. package/dist/version-bar.d.ts.map +1 -0
  183. package/dist/version-bar.js +169 -0
  184. package/dist/version-bar.js.map +1 -0
  185. package/dist/version.d.ts +3 -0
  186. package/dist/version.d.ts.map +1 -0
  187. package/dist/version.js +8 -0
  188. package/dist/version.js.map +1 -0
  189. package/dist/vite.d.ts +15 -0
  190. package/dist/vite.d.ts.map +1 -0
  191. package/dist/vite.js +36 -0
  192. package/dist/vite.js.map +1 -0
  193. package/dist/ws-client.d.ts +53 -0
  194. package/dist/ws-client.d.ts.map +1 -0
  195. package/dist/ws-client.js +604 -0
  196. package/dist/ws-client.js.map +1 -0
  197. package/dist/ws-frame-limit.d.ts +21 -0
  198. package/dist/ws-frame-limit.d.ts.map +1 -0
  199. package/dist/ws-frame-limit.js +40 -0
  200. package/dist/ws-frame-limit.js.map +1 -0
  201. package/docs/app-manifest.md +65 -0
  202. package/docs/custom-chrome.md +147 -0
  203. package/docs/hosted-ui-advanced.md +77 -0
  204. package/docs/imported-documents.md +261 -0
  205. package/docs/presence.md +129 -0
  206. package/docs/schemas.md +135 -0
  207. package/docs/streams.md +248 -0
  208. package/package.json +60 -0
  209. package/scripts/prepare.mjs +50 -0
  210. package/src/api-fetch.ts +127 -0
  211. package/src/api-headers.ts +28 -0
  212. package/src/auth-flow.ts +125 -0
  213. package/src/client.ts +1457 -0
  214. package/src/doc-url-sync.ts +183 -0
  215. package/src/document-branches.ts +124 -0
  216. package/src/document-checkpoints.ts +135 -0
  217. package/src/document-commands.ts +651 -0
  218. package/src/document-signals.ts +234 -0
  219. package/src/document-streams.ts +132 -0
  220. package/src/document-thumbnail-capture.ts +191 -0
  221. package/src/documents-namespace.ts +516 -0
  222. package/src/documents.ts +2426 -0
  223. package/src/emitter.ts +129 -0
  224. package/src/errors.ts +45 -0
  225. package/src/export-guest.ts +282 -0
  226. package/src/fetch-retry.ts +119 -0
  227. package/src/guest-link.ts +14 -0
  228. package/src/handles.ts +1731 -0
  229. package/src/hosted-runtime.ts +109 -0
  230. package/src/hosted-ui.ts +1020 -0
  231. package/src/importer.ts +89 -0
  232. package/src/index.ts +267 -0
  233. package/src/init-keys.ts +15 -0
  234. package/src/internal.ts +23 -0
  235. package/src/jwt.ts +18 -0
  236. package/src/launch-context.ts +43 -0
  237. package/src/login-hint.ts +60 -0
  238. package/src/mutate-batching.ts +63 -0
  239. package/src/open-document.ts +2869 -0
  240. package/src/page-title.ts +33 -0
  241. package/src/perf.ts +46 -0
  242. package/src/presence.ts +471 -0
  243. package/src/record-paging.ts +270 -0
  244. package/src/schema-conflict-dialog.ts +176 -0
  245. package/src/selection-persistence.ts +98 -0
  246. package/src/serializer.ts +15 -0
  247. package/src/session-persistence.ts +69 -0
  248. package/src/session-toolbar.ts +1457 -0
  249. package/src/session.ts +1262 -0
  250. package/src/tail-ops.ts +63 -0
  251. package/src/topic-lifecycle.ts +217 -0
  252. package/src/types.ts +75 -0
  253. package/src/undo.ts +125 -0
  254. package/src/version-bar.ts +221 -0
  255. package/src/version.ts +8 -0
  256. package/src/vite.ts +49 -0
  257. package/src/ws-client.ts +678 -0
  258. package/src/ws-frame-limit.ts +52 -0
@@ -0,0 +1,129 @@
1
+ # Presence (live cursors, who's-here)
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.
9
+
10
+ ```ts
11
+ import type { PresenceParticipant } from '@brass-build/client';
12
+
13
+ const doc = await Brass.openDocumentById('doc_abc');
14
+
15
+ // Publish your own cursor. `set` is a partial shallow-merge and is
16
+ // throttled to ~10 Hz internally, so call it as often as you like
17
+ // (e.g. on every pointermove).
18
+ doc.presence?.set({ state: { cursor: { x, y, z } } });
19
+ doc.presence?.set({ ext: { myApp: { tool: 'select' } } });
20
+
21
+ // React to everyone else. `roster` is the other participants; each
22
+ // carries a server-stamped `displayName` + `color` (CSS-ready string,
23
+ // e.g. `'#a4c8ff'`, assign directly to `style.background` or pass to
24
+ // canvas APIs without parsing) and their last `state`. Interpolate
25
+ // between updates in your render loop.
26
+ //
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.
33
+ const renderRoster = (roster: PresenceParticipant[]) => {
34
+ for (const p of roster) renderAvatar(p.connectionId, p.color, p.state);
35
+ };
36
+ const stop = doc.presence?.on('change', renderRoster);
37
+
38
+ // `doc.close()` tears presence down for you; call `leave()` directly
39
+ // only if you want to drop presence while keeping the doc open.
40
+ doc.presence?.leave();
41
+ ```
42
+
43
+ ## Coordinate convention
44
+
45
+ The `state` block uses a fixed coordinate convention so participants
46
+ stay legible across different apps over the same document: **meters,
47
+ right-handed, Z-up, document world space**. Map your engine's
48
+ coordinates into it before `set` and back out when rendering remote
49
+ participants. The `roster` is keyed on `connectionId`, so one user
50
+ in two tabs shows as two participants.
51
+
52
+ ## Reserved `state` sub-keys
53
+
54
+ App-set but reserved-by-convention so two apps subscribed to the
55
+ same document agree on what each key means. Pick from this set in
56
+ preference to inventing new ones:
57
+
58
+ ```ts
59
+ interface PresenceState {
60
+ // The user's primary pointer position in the document's world
61
+ // space. Use for "this is where my cursor is right now" in a 2D
62
+ // or 3D editor. Omit if your app has no cursor concept.
63
+ cursor?: { x: number; y: number; z: number };
64
+ // Where the user is looking from in 3D. Use to render a
65
+ // "Bob is viewing the south facade" gizmo at the camera's
66
+ // position. Omit in 2D apps; use `cursor` instead.
67
+ camera?: {
68
+ position: { x: number; y: number; z: number };
69
+ target?: { x: number; y: number; z: number };
70
+ };
71
+ // The entity ids the user has selected. Use to highlight remote
72
+ // selections so collaborators see what each other is working on.
73
+ selection?: string[];
74
+ }
75
+ ```
76
+
77
+ App-specific state (the current tool, a chat bubble, a typing
78
+ indicator) goes under `ext` instead so it stays namespaced and out of
79
+ the cross-app conventions. The `ext` block is otherwise free-form:
80
+ `ext.myApp = { tool: 'select', chatBubble: 'hi' }`.
81
+
82
+ ## Types
83
+
84
+ `doc.presence` is typed `PresenceClient | null`; each `roster` entry
85
+ is a `PresenceParticipant` with
86
+ `{ connectionId, sub, displayName, avatarUrl, color, joinedAt, lastSeen,
87
+ 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.
92
+
93
+ ## Hand-rolling the header roster (custom shell)
94
+
95
+ `Brass.mountToolbar` and `Brass.mountPresenceRoster` render the
96
+ who's-here roster of coloured initials for you, so prefer those. When
97
+ you build a fully custom shell and want the roster in your own element,
98
+ render it from `doc.presence.roster`, a small list of coloured
99
+ initials that renders the same way for every app:
100
+
101
+ ```ts
102
+ import type { PresenceParticipant } from '@brass-build/client';
103
+
104
+ const renderRoster = (roster: readonly PresenceParticipant[]): void => {
105
+ const el = document.getElementById('roster')!;
106
+ el.replaceChildren(...roster.map((p) => {
107
+ const pip = document.createElement('span');
108
+ pip.className = 'pip';
109
+ pip.style.background = p.color;
110
+ pip.title = p.displayName ?? p.sub ?? '';
111
+ if (p.avatarUrl) {
112
+ const img = document.createElement('img');
113
+ img.src = p.avatarUrl;
114
+ img.alt = '';
115
+ img.style.cssText = 'width:100%;height:100%;object-fit:cover;border-radius:50%';
116
+ pip.appendChild(img);
117
+ } else {
118
+ pip.textContent = (p.displayName ?? p.sub ?? '?').slice(0, 1).toUpperCase();
119
+ }
120
+ return pip;
121
+ }));
122
+ };
123
+ ```
124
+
125
+ Drive it from the same `change` subscription as the in-surface remote
126
+ cursors (see the README's "Presence" section). The listener fires on
127
+ 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.
@@ -0,0 +1,135 @@
1
+ # Schemas
2
+
3
+ A schema declares what your app expects a document to hold. Declaring one
4
+ in your `brass-app.json` routes matching documents to your app, and lets
5
+ any developer who can open one of those documents build against the same
6
+ shape.
7
+
8
+ ## Declaring a schema
9
+
10
+ A schema is a `family` and a `streams` map. Each entry is a plain
11
+ [JSON Schema](https://json-schema.org) (draft 2020-12) describing that
12
+ stream's root collections and records.
13
+
14
+ - **`family`** is a short word naming what the artifact is: the domain or
15
+ standard it projects. Use the word the standards world already uses when
16
+ there is one, and the domain's own word when there is not, so a CNC
17
+ toolpath is `gcode` and a shift schedule is `roster`. It is the identity
18
+ other apps converge on, so it names the artifact rather than your app or
19
+ your version: `gcode`, not `acme-cam` and not `gcode-2`. Lowercase
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.
23
+ - **`streams`** maps each stream name your app expects a document to hold
24
+ to that stream's contract. `default` is the stream a document opens
25
+ without asking for one, and takes an entry like any other name. Two
26
+ streams that carry the same shape carry the same body.
27
+ - **A collection of records** is an object whose `additionalProperties`
28
+ is the record schema, so the keys are record ids. An ordered collection
29
+ is an array, or an array of keys alongside the map.
30
+ - **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
+ Standard validators ignore an `x-` keyword.
33
+ - **The `x-brass-` prefix is reserved for Brass.** Give your own extensions
34
+ their own prefix (`x-acme-`), so a name you choose keeps its meaning when
35
+ Brass defines a keyword later. A keyword in a pulled body describes the
36
+ data rather than your app, so keep the body verbatim rather than dropping
37
+ keywords you do not recognise.
38
+ - **`description`** carries the conventions structure cannot: an
39
+ ordering, a unit, an invariant. Put it wherever the convention belongs.
40
+
41
+ Brass never keys on `$id`, and never enforces a schema against a
42
+ document. It validates that each body is well-formed JSON Schema when
43
+ your manifest is ingested, and nothing after that.
44
+
45
+ To work with documents that already exist, take their schema rather than
46
+ writing your own: open one in the dashboard and use **Download
47
+ brass-app.json**, or run `npx @brass-build/cli schema pull --doc <docId>`.
48
+ Keep it verbatim. Your copy is the contract your code is built against,
49
+ and an approximation reads as a different contract.
50
+
51
+ ## Creating a stream
52
+
53
+ Streams let one document load in parts, so an app pays for what it opens
54
+ rather than the document's total size. `doc.getOrCreateStream(name)`
55
+ 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
+ holds and rejects any other name.
58
+
59
+ ```ts
60
+ const stream = await doc.getOrCreateStream(name);
61
+ stream.map('records').mutate((m) => {
62
+ m[id] = record;
63
+ });
64
+ ```
65
+
66
+ `getOrCreateStream` is idempotent, and concurrent callers converge on one
67
+ stream, so an app reaches for the stream it expects on every open without
68
+ checking first. Only that call writes, so a read-only open reads through
69
+ `doc.stream` and handles the rejection when the document does not hold
70
+ the stream. Names follow the same rule a family does: `a-z 0-9 . _ -`, 1 to
71
+ 64 characters, starting with a letter or digit.
72
+
73
+ A separate stream suits records only some opens need to load, or that an
74
+ exporter should leave out. Name it in `streams` and its shape is
75
+ declared, so any app that expects it reads the same contract. See
76
+ [Streams](./streams.md) for partitioning, undo, and reading a document
77
+ spread across several.
78
+
79
+ ## What an app can open
80
+
81
+ An app opens a document when they share a family and **everything the app
82
+ 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.
88
+
89
+ An exporter composes the streams it declares, so an app exporting a
90
+ document writes the parts it knows about and leaves the rest. Two apps
91
+ can export the same document differently, each emitting what it
92
+ understands.
93
+
94
+ ## What breaks compatibility
95
+
96
+ Because the test is about `required`, that is the only part of a schema
97
+ that can break anything.
98
+
99
+ - **Removing a required property breaks other apps.** Your documents stop
100
+ guaranteeing that field, so apps requiring it stop opening them. Your
101
+ own app keeps working, so you will not see it.
102
+ - **Adding a required property narrows what you can open**, since older
103
+ documents never promised it.
104
+ - **Adding an optional property is safe** in both directions.
105
+
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.
110
+
111
+ ## Adding a required property anyway
112
+
113
+ Declare a `writes` map beside `streams` when the two sides differ. It
114
+ takes the same stream names and the same bodies, and it is what your app
115
+ guarantees when it writes, where `streams` is what it expects to read.
116
+
117
+ ```json
118
+ {
119
+ "family": "building",
120
+ "streams": { "default": { "type": "object", "required": ["walls"] } },
121
+ "writes": {
122
+ "default": { "type": "object", "required": ["walls", "site"] }
123
+ }
124
+ }
125
+ ```
126
+
127
+ That app opens every document guaranteeing `walls`, including ones
128
+ written before `site` existed, and every document it saves guarantees
129
+ `site` as well. An app still on the old shape reads the result, because
130
+ the document now guarantees more than that app expects. Leave `writes`
131
+ 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.
@@ -0,0 +1,248 @@
1
+ # Streams: partitioning a document for lazy loading
2
+
3
+ A document is a container of **streams**: named units of document
4
+ state that load independently. Opening a document costs only the
5
+ streams your app touches: snapshot download, CRDT import, and memory
6
+ all scale with what you use, not with the document's total size. The
7
+ motivating shape is a multi-discipline design: architectural,
8
+ plumbing, HVAC, and structural as four streams of one document, where
9
+ a plumbing tool opens one of them and pays a quarter of the cost.
10
+ Every stream is collaborative CRDT state.
11
+
12
+ Every document has a stream named **`default`**, and an open that names
13
+ no stream opens that one, so the doc-level handles (`doc.map(...)`,
14
+ `doc.orderedMap(...)`) read it. An unpartitioned document is just a
15
+ document whose content lives entirely there: nothing about streams needs
16
+ to be known or paid for until you partition. Name another stream on the
17
+ open (below) and the doc-level handles read that one instead.
18
+
19
+ ## Opening and editing a stream
20
+
21
+ ```ts
22
+ const stream = await doc.stream(name);
23
+ const records = stream.orderedMap('instances', 'instance_order');
24
+ records.mutate((m) => {
25
+ m[id] = { type, params };
26
+ });
27
+ records.subscribe((change) => render(change));
28
+ ```
29
+
30
+ `doc.stream(name)` resolves once per name: the first call downloads the
31
+ stream's snapshot and subscribes it live, and repeat calls return the same
32
+ open. It only reads, so a name the document does not hold rejects rather
33
+ than being created. `doc.getOrCreateStream(name)` is the call that makes
34
+ one. It is idempotent and concurrent callers converge on one stream, so an
35
+ app reaches for the stream it expects on every open without checking
36
+ first. A `DocumentStream` carries the document's own reading and writing
37
+ surface scoped to that stream: `map` / `list` / `orderedMap` handles,
38
+ `mutate`, `subscribe`, and `undo` / `redo`.
39
+
40
+ ## Opening at a stream
41
+
42
+ `Brass.openDocumentById(docId, { stream })` opens the document with that
43
+ stream as its own state, so `doc.map(...)` reads it and the rest of the
44
+ document is never downloaded. An app that works in one part of a large
45
+ document pays for that part alone:
46
+
47
+ ```ts
48
+ const doc = await Brass.openDocumentById(docId, { stream: 'hvac' });
49
+ doc.orderedMap('instances', 'instance_order').read(); // hvac's records
50
+ ```
51
+
52
+ `stream` defaults to `'default'`, the stream every document has, so an
53
+ open always names a stream and the usual one needs no argument.
54
+ `doc.stream(name)` resolves the name the document was opened at back to
55
+ the document itself, with no second join, and any other name to its own
56
+ line. So the loop below reads the same whichever stream you opened at:
57
+
58
+ ```ts
59
+ for (const info of await doc.listStreams()) {
60
+ const stream = await doc.stream(info.name);
61
+ summarize(stream.map('instances').read());
62
+ }
63
+ ```
64
+
65
+ `listStreams()` includes it like any other name, so a loop over the
66
+ manifest needs no separate step for the stream you opened at. Names are
67
+ `a-z 0-9 . _ -`, 1 to 64 chars, starting with a letter or digit, the same
68
+ rule a schema family follows. `default` is
69
+ one of them: every document already holds it, so `getOrCreateStream`
70
+ resolves it without a request, and a loop over the names your schema
71
+ declares needs no case for it. `getOrCreateStream` writes, so a read-only
72
+ open reads with `doc.stream` instead and shows a degraded view when the
73
+ document does not hold the stream.
74
+
75
+ ## Undo and editing model
76
+
77
+ Each stream is its own collaborative unit: concurrent edits merge per
78
+ stream, and each stream keeps its own local undo stack
79
+ (`stream.undo()` / `stream.redo()`). The doc-level `doc.undo()` covers
80
+ only the default stream.
81
+
82
+ A stream's local edits group into one undo step until the group is
83
+ **sealed** with `stream.sealUndoStep()`, the same same-stroke
84
+ collapsing doc-level undo gets from checkpoints, exposed directly
85
+ because streams carry no checkpoint machinery. Seal when a user
86
+ operation completes; an app that never seals gets one giant step per
87
+ session.
88
+
89
+ ### Undoing an operation that spans streams
90
+
91
+ When one user action writes to several streams (move a wall in
92
+ `architectural`, reroute its duct in `hvac`), keep an app-level
93
+ operation journal and undo each touched stream once per journal entry.
94
+ Two rules make the stacks line up:
95
+
96
+ 1. **Seal every touched stream when the operation completes.** The
97
+ seal is what makes the operation exactly one undo step in each
98
+ stream, however many mutates it took.
99
+ 2. **Route every local edit through the journal.** The alignment
100
+ argument is LIFO: if every operation is journaled and sealed, each
101
+ stream's stack top always belongs to the most recent journaled
102
+ operation that touched it. An edit that bypasses the journal
103
+ breaks that.
104
+
105
+ ```ts
106
+ type Op = { label: string; streams: string[] };
107
+ const undoJournal: Op[] = [];
108
+ const redoJournal: Op[] = [];
109
+
110
+ // Wrap every user operation: run the writes, then seal + journal.
111
+ async function operation(
112
+ label: string,
113
+ streams: string[],
114
+ run: (open: Map<string, DocumentStream>) => void,
115
+ ) {
116
+ const open = new Map(
117
+ await Promise.all(
118
+ streams.map(async (n) => [n, await doc.stream(n)] as const),
119
+ ),
120
+ );
121
+ run(open);
122
+ for (const s of open.values()) s.sealUndoStep();
123
+ undoJournal.push({ label, streams });
124
+ redoJournal.length = 0; // a fresh edit clears redo, as usual
125
+ }
126
+
127
+ await operation('Move wall', ['architectural', 'hvac'], (open) => {
128
+ open.get('architectural')!.map('instances').mutate((m) => {
129
+ /* move the wall */
130
+ });
131
+ open.get('hvac')!.map('instances').mutate((m) => {
132
+ /* reroute the attached duct */
133
+ });
134
+ });
135
+
136
+ async function undoOperation() {
137
+ const op = undoJournal.pop();
138
+ if (!op) return;
139
+ // Reverse application order, so dependent changes unwind last-first.
140
+ for (const name of [...op.streams].reverse()) {
141
+ (await doc.stream(name)).undo();
142
+ }
143
+ redoJournal.push(op);
144
+ }
145
+
146
+ async function redoOperation() {
147
+ const op = redoJournal.pop();
148
+ if (!op) return;
149
+ for (const name of op.streams) {
150
+ (await doc.stream(name)).redo();
151
+ }
152
+ undoJournal.push(op);
153
+ }
154
+ ```
155
+
156
+ Bind your keyboard shortcut and undo button to `undoOperation()`
157
+ rather than `doc.undo()` (which covers only the default stream).
158
+ Collaborators' edits do not disturb the alignment: undo stacks are
159
+ local-peer-scoped, and each stream's undo rebases your reverted step
160
+ over remote changes the same way single-document undo does.
161
+
162
+ Two properties to design around rather than against. The cross-stream
163
+ undo is **not atomic**: each stream's revert is its own commit, so a
164
+ collaborator can briefly observe one stream reverted before the other
165
+ (the same window any multi-stream operation has). And the journal is
166
+ **in-memory**: a reload starts it empty, exactly like single-document
167
+ undo. If an operation genuinely cannot tolerate the window, that is
168
+ the signal its data belongs in one stream: partition by load unit, and
169
+ keep records that must change together in the same stream.
170
+
171
+ ## When to partition
172
+
173
+ Opening a stream costs a request for its snapshot, and the first one on a
174
+ document also reads the stream manifest, so add a stream when it carries
175
+ real content that some opens can skip. Partition by **load unit** (a
176
+ discipline, a heavy layer, a dataset a specific tool reads), not by
177
+ concept. A handful of streams with megabytes each works best; dozens of
178
+ tiny streams make opening everything slower
179
+ than a monolith, and content every open needs belongs in the default
180
+ stream.
181
+
182
+ Branches and streams compose: creating a branch forks every stream, so a
183
+ branch is fully isolated from its source across all of them, exactly like an
184
+ unpartitioned document, and each forked stream keeps the shape its records
185
+ conform to. Branching from a historical version addresses the default
186
+ stream's timeline; the other streams fork at their current state. Deleting a
187
+ branch deletes its streams with it. Two branches that then diverge in shape
188
+ report different contracts, because a contract belongs to a stream on a
189
+ branch rather than to the document.
190
+
191
+ ## Reading a document spread across streams
192
+
193
+ Your schema names the streams it expects a document to be spread over,
194
+ under `streams`. A small document keeps every record in its default
195
+ stream; a large one moves the bulk (an IFC's geometry, an index most opens
196
+ skip) onto the streams the schema names, so an app that reads only part of
197
+ the content loads only that part. The declaration is the same either way,
198
+ because it says which streams your app expects, not what any one document
199
+ turned out to hold.
200
+
201
+ If your app follows references between records, read every stream the
202
+ DOCUMENT holds, because a referenced record may live in one the default
203
+ stream does not. `doc.listStreams()` is that list, so merge by id:
204
+
205
+ ```ts
206
+ const store: Record<string, { type: string; params: unknown }> = {
207
+ ...doc.orderedMap('instances', 'instance_order').read(),
208
+ };
209
+ for (const info of await doc.listStreams()) {
210
+ const stream = await doc.stream(info.name);
211
+ Object.assign(store, stream.orderedMap('instances', 'instance_order').read());
212
+ }
213
+ // store['#137'] resolves wherever #137 lives.
214
+ ```
215
+
216
+ The loop opens the stream the document was opened at without a second
217
+ join, so it reads the same for an unpartitioned document as for a split
218
+ one. Open only what you read: an app that needs just the default view's
219
+ records reads the default stream alone; one that resolves references
220
+ reads them all and pays for the streams it opens.
221
+
222
+ A document can hold streams your schema does not name (computed results,
223
+ review notes, a cache), so the merge above skips them and an exporter that
224
+ does not declare them writes nothing from them. Name a stream in your
225
+ schema to read it, and `doc.getOrCreateStream` makes it when the document
226
+ does not hold it yet.
227
+
228
+ ## Exporting across streams
229
+
230
+ A registered serializer receives the live document and opens streams
231
+ like any other code, so an exporter collects from wherever the content
232
+ lives:
233
+
234
+ ```ts
235
+ Brass.registerSerializer(async (doc) => {
236
+ const arch = await doc.stream('architectural');
237
+ const hvac = await doc.stream('hvac');
238
+ return encodeMyFormat(
239
+ arch.orderedMap('instances', 'instance_order').ordered(),
240
+ hvac.orderedMap('instances', 'instance_order').ordered(),
241
+ );
242
+ });
243
+ ```
244
+
245
+ Collect everything from the document itself. Brass may run your
246
+ serializer detached from your app (a dashboard download, or a drive
247
+ publish with your app not open), so open the streams you need rather
248
+ than reach for your app's UI state.
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "@brass-build/client",
3
+ "version": "0.1.0",
4
+ "description": "Client SDK for Brass",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js"
13
+ },
14
+ "./vite": {
15
+ "types": "./dist/vite.d.ts",
16
+ "import": "./dist/vite.js"
17
+ },
18
+ "./internal": {
19
+ "types": "./dist/internal.d.ts",
20
+ "import": "./dist/internal.js"
21
+ }
22
+ },
23
+ "files": [
24
+ "dist",
25
+ "src",
26
+ "!src/**/__tests__/**",
27
+ "scripts",
28
+ "README.md",
29
+ "AGENTS.md",
30
+ "CHANGELOG.md",
31
+ "docs",
32
+ "LICENSE"
33
+ ],
34
+ "sideEffects": false,
35
+ "scripts": {
36
+ "build": "tsc -b",
37
+ "type-check": "tsc -b",
38
+ "prepare": "node ./scripts/prepare.mjs",
39
+ "prepublishOnly": "tsc -b --force"
40
+ },
41
+ "dependencies": {
42
+ "fflate": "^0.8.3",
43
+ "loro-crdt": "^1.12.1"
44
+ },
45
+ "devDependencies": {
46
+ "typescript": "~6.0.0"
47
+ },
48
+ "engines": {
49
+ "node": "^20.19.0 || >=22.12.0"
50
+ },
51
+ "publishConfig": {
52
+ "access": "public"
53
+ },
54
+ "repository": {
55
+ "type": "git",
56
+ "url": "git+https://github.com/brass-build/client.git"
57
+ },
58
+ "homepage": "https://brass.build",
59
+ "license": "MIT"
60
+ }
@@ -0,0 +1,50 @@
1
+ // `prepare` runs on `npm install`, both for direct installs (`npm i`
2
+ // in `sdk/` itself) and for file-dep consumers (`"@brass-build/client":
3
+ // "file:../sdk"`). We want one script that:
4
+ //
5
+ // 1. Skips the build when `dist/index.js` already exists. A consumer
6
+ // that installs a prebuilt SDK should not have to re-run `tsc` on
7
+ // every `npm install`, and an environment without a resolvable
8
+ // `tsc` (a fresh sandbox, a CI runner that doesn't install the
9
+ // SDK's own devDeps when pulling it as a file dep) would otherwise
10
+ // fail the consumer's `npm install` outright.
11
+ // 2. Runs `tsc -b` from this directory when `dist/` is missing, so a
12
+ // direct `npm install` in `sdk/` still emits the bundle.
13
+ //
14
+ // `tsc` is resolved through `node_modules/.bin/` of this package, so
15
+ // it works whether the consumer has a global `tsc` or not, as long as
16
+ // the SDK's own devDeps installed (which they will under a plain
17
+ // `npm i` in `sdk/`). When this script runs from a file-dep consumer
18
+ // install, dist/ is already present in the file: target and we exit
19
+ // before invoking tsc.
20
+
21
+ import { existsSync } from 'node:fs';
22
+ import { spawnSync } from 'node:child_process';
23
+ import { fileURLToPath } from 'node:url';
24
+ import { dirname, join } from 'node:path';
25
+
26
+ const here = dirname(fileURLToPath(import.meta.url));
27
+ const sdkRoot = dirname(here);
28
+ const builtEntry = join(sdkRoot, 'dist', 'index.js');
29
+
30
+ if (existsSync(builtEntry)) {
31
+ process.exit(0);
32
+ }
33
+
34
+ const tscBin = process.platform === 'win32' ? 'tsc.cmd' : 'tsc';
35
+ const localTsc = join(sdkRoot, 'node_modules', '.bin', tscBin);
36
+ const command = existsSync(localTsc) ? localTsc : tscBin;
37
+
38
+ const result = spawnSync(command, ['-b'], { stdio: 'inherit', cwd: sdkRoot });
39
+ if (result.error || (result.status ?? 0) !== 0) {
40
+ // Don't fail the install: a consumer who pulled the SDK as a file
41
+ // dep without dist/ and without typescript available can still
42
+ // import `src/` via the `files` whitelist, and the SDK's primary
43
+ // consumption path is the prebuilt `dist/`. A loud warning here
44
+ // lets the operator notice; a hard exit would block the whole
45
+ // dependency install for a working setup.
46
+ console.warn(
47
+ '[@brass-build/client] prepare: tsc -b did not run cleanly; ' +
48
+ 'install is continuing without rebuilding dist/.',
49
+ );
50
+ }