@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,234 @@
1
+ // Document lifecycle signals: server-pushed listener-fanout surfaces a
2
+ // Document exposes, separate from the CRDT state it wraps. Pure subscriber
3
+ // registries with a load-bearing emit discipline:
4
+ //
5
+ // - `GoneSignal` fire-once-then-cache (a doc is evicted once)
6
+ // - `WorkStatusSignal` latest-retained, re-emit per phase
7
+ // - `DriveStatusSignal` latest-retained, fire-on-attach, dedup-on-emit
8
+
9
+ import { createEmitter } from './emitter.js';
10
+
11
+ // Reason a Document's underlying doc was evicted. Mirrors the `doc_gone`
12
+ // frame's `reason` (CONTRACT.md "Server-pushed `doc_gone`"). New literals land
13
+ // here as the platform grows transitions that evict open subscribers.
14
+ // `moved`: the doc changed organization or project, so its access gate
15
+ // changed and open subscribers re-establish under the new location.
16
+ export type DocumentGoneReason = 'deleted' | 'moved';
17
+
18
+ export type DocumentGoneListener = (reason: DocumentGoneReason) => void;
19
+ export type DocumentGoneUnsubscribe = () => void;
20
+
21
+ // Opaque status of slow server-side work (hydration, extraction, push).
22
+ // Mirrors the wire shape (CONTRACT.md "Server-pushed `doc_status`"). The app
23
+ // renders `message` verbatim, draws a bar from `progress`, and waits while
24
+ // `pending`. No kind / phase / error code: the server owns all wording and can
25
+ // change phases without any app change.
26
+ export interface DocumentWorkStatus {
27
+ message: string;
28
+ progress?: number;
29
+ pending: boolean;
30
+ // Whether this work locks editing. Inbound work (hydration, extraction)
31
+ // replaces content, so the doc is read-only until it lands
32
+ // (`blocking: true`); an outbound push leaves content untouched, so editing
33
+ // stays open (`blocking: false`). Optional; absence is treated as blocking.
34
+ // `doc.readOnly` already folds this in, so apps rarely read it directly.
35
+ blocking?: boolean;
36
+ }
37
+
38
+ export type DocumentStatusListener = (status: DocumentWorkStatus) => void;
39
+ export type DocumentStatusUnsubscribe = () => void;
40
+
41
+ // Coarse divergence state of a drive-linked doc vs its source. Mirrors the
42
+ // server's `tracking_state` (CONTRACT.md "Drive document tracking state"). The
43
+ // app reads `state` to decide which affordance to show, never to pick wording
44
+ // (that is server-authored `label` / `detail`).
45
+ export type DocumentDriveTrackingState =
46
+ | 'unhydrated'
47
+ | 'synced'
48
+ | 'ahead'
49
+ | 'behind'
50
+ | 'diverged';
51
+
52
+ // Which way the mirror flows. `two_way` links round-trip; `import_only` links
53
+ // are one-directional (edits stay in Brass, no publish-back path). An app
54
+ // reads this to decide whether to show a Publish affordance at all.
55
+ export type DriveMirrorDirection = 'two_way' | 'import_only';
56
+
57
+ // The drive sync state of an open document, the read-side companion to
58
+ // `doc.pushToDrive(...)`. Present only on drive-linked docs; `null` otherwise.
59
+ // Lets an app show "you have unpublished edits" without polling. Words are
60
+ // server-authored (`label` / `detail`, rendered verbatim); the app branches
61
+ // only on `state` / `canPush` to decide whether to show an affordance.
62
+ export interface DocumentDriveStatus {
63
+ // Adapter type token; an app may key an icon off it. The human label is
64
+ // `driveName`, so an app never maps the type to a vendor name itself.
65
+ driveType: string;
66
+ // The drive's user-facing name, server-authored and rendered verbatim.
67
+ driveName: string;
68
+ // The divergence state. `ahead` / `diverged` mean unpublished local edits.
69
+ state: DocumentDriveTrackingState;
70
+ // True when publishing would do something (local edits to save back and the
71
+ // link is not broken): the cue to surface a "Publish" affordance.
72
+ canPush: boolean;
73
+ // Server-authored short pill text (`null` when nothing worth surfacing).
74
+ label: string | null;
75
+ // Server-authored detail sentence for a tooltip (`null` when none).
76
+ detail: string | null;
77
+ // The link's backing connection was removed; the doc can no longer sync. The
78
+ // app surfaces a terminal "reconnect needed" state.
79
+ broken: boolean;
80
+ // True when a push overwrites the source wholesale instead of 3-way merging
81
+ // (the file-roundtrip path), so an app can warn that the source's newer
82
+ // version will be replaced, not merged.
83
+ pushReplacesRemote: boolean;
84
+ // Whether the link round-trips (`two_way`) or only imports (`import_only`).
85
+ // On `import_only`, `canPush` is always false; an app surfaces the class so
86
+ // a one-way import doesn't read as a live two-way mirror.
87
+ mirrorDirection: DriveMirrorDirection;
88
+ // Current external head ref, empty when the adapter could not produce one.
89
+ ref: string;
90
+ // ISO timestamp the ref was last refreshed, empty on a fresh stub.
91
+ refUpdatedAt: string;
92
+ // The source item's display name, for a "linked to <name>" tooltip.
93
+ itemName: string;
94
+ // Deep link to the source, so a link indicator can open it.
95
+ itemUrl: string;
96
+ // True while a push is in flight (submitted, running server-side, not yet
97
+ // confirmed). A push is asynchronous, so `state` stays `ahead` until it
98
+ // lands; an indicator reads this to hold a "Publishing..." state and hide
99
+ // the Publish affordance rather than re-deriving it. `detail` carries the
100
+ // server-authored progress line while it is `true`.
101
+ publishing: boolean;
102
+ }
103
+
104
+ export type DocumentDriveStatusListener = (
105
+ status: DocumentDriveStatus,
106
+ ) => void;
107
+ export type DocumentDriveStatusUnsubscribe = () => void;
108
+
109
+ // Each signal is a `createEmitter` composed with the retain / attach / dedup
110
+ // policy its wire event needs, wrapped in a small class only to keep the
111
+ // domain-named methods (`mark`, `emit`, `status`, `clearListeners`) the
112
+ // Document calls. The shared emit discipline (swallow-and-log, fire-on-attach
113
+ // in a microtask, retain the last value) lives in the primitive; the class
114
+ // picks the options.
115
+
116
+ // Fire-once-then-cache signal for `doc_gone`: listeners registered AFTER the
117
+ // event still fire exactly once on attach (the `Promise`-resolution pattern),
118
+ // so callback-wiring after the WS frame may have landed has no race. `mark` is
119
+ // idempotent (both `doc:<id>` and `presence:doc:<id>` fan out a frame, so the
120
+ // one-shot collapses them to a single listener fire).
121
+ export class GoneSignal {
122
+ private readonly _emitter = createEmitter<DocumentGoneReason>({
123
+ fireOnAttach: true,
124
+ retain: 'oneShot',
125
+ onError: (err) => {
126
+ // A consumer throw must not strand the others or break the WS router.
127
+ console.error('[Brass] onGone listener threw:', err);
128
+ },
129
+ });
130
+
131
+ get reason(): DocumentGoneReason | null {
132
+ return this._emitter.retained ?? null;
133
+ }
134
+
135
+ on(cb: DocumentGoneListener): DocumentGoneUnsubscribe {
136
+ return this._emitter.on(cb);
137
+ }
138
+
139
+ mark(reason: DocumentGoneReason): void {
140
+ this._emitter.emit(reason);
141
+ }
142
+
143
+ // Drop app-side listeners (suspend / close). The cached reason is NOT
144
+ // cleared: a `_markGone` that already fired stays terminal across a suspend.
145
+ clearListeners(): void {
146
+ this._emitter.clear();
147
+ }
148
+ }
149
+
150
+ // Latest-retained, re-emit-per-phase signal for `doc_status`. Not one-shot:
151
+ // phases arrive repeatedly until terminal (`pending: false`), and the latest
152
+ // is retained so a late subscriber still sees the final state.
153
+ export class WorkStatusSignal {
154
+ private readonly _emitter = createEmitter<DocumentWorkStatus>({
155
+ fireOnAttach: true,
156
+ retain: 'latest',
157
+ onError: (err) => {
158
+ console.error('[Brass] onStatus listener threw:', err);
159
+ },
160
+ });
161
+
162
+ get status(): DocumentWorkStatus | null {
163
+ return this._emitter.retained ?? null;
164
+ }
165
+
166
+ on(cb: DocumentStatusListener): DocumentStatusUnsubscribe {
167
+ return this._emitter.on(cb);
168
+ }
169
+
170
+ emit(status: DocumentWorkStatus): void {
171
+ this._emitter.emit(status);
172
+ }
173
+
174
+ clearListeners(): void {
175
+ this._emitter.clear();
176
+ }
177
+ }
178
+
179
+ // Latest-retained, fire-on-attach signal for `doc_drive_status`. Like
180
+ // `WorkStatusSignal`, but `emit` short-circuits when the new status
181
+ // deep-equals the retained one: drive status fans out on *every* METADATA row
182
+ // change (rename, thumbnail, edit), so the equality gate keeps `onDriveStatus`
183
+ // from re-firing when the tracking state didn't move.
184
+ export class DriveStatusSignal {
185
+ private readonly _emitter = createEmitter<DocumentDriveStatus>({
186
+ fireOnAttach: true,
187
+ retain: 'latest',
188
+ equals: driveStatusEqual,
189
+ onError: (err) => {
190
+ console.error('[Brass] onDriveStatus listener threw:', err);
191
+ },
192
+ });
193
+
194
+ get status(): DocumentDriveStatus | null {
195
+ return this._emitter.retained ?? null;
196
+ }
197
+
198
+ on(cb: DocumentDriveStatusListener): DocumentDriveStatusUnsubscribe {
199
+ return this._emitter.on(cb);
200
+ }
201
+
202
+ emit(status: DocumentDriveStatus): void {
203
+ this._emitter.emit(status);
204
+ }
205
+
206
+ clearListeners(): void {
207
+ this._emitter.clear();
208
+ }
209
+ }
210
+
211
+ // Field-by-field equality for the drive status. The shape is flat (all
212
+ // scalars), so a key compare suffices; explicit rather than JSON.stringify so
213
+ // a future non-scalar field forces a conscious update here.
214
+ function driveStatusEqual(
215
+ a: DocumentDriveStatus,
216
+ b: DocumentDriveStatus,
217
+ ): boolean {
218
+ return (
219
+ a.driveType === b.driveType &&
220
+ a.driveName === b.driveName &&
221
+ a.state === b.state &&
222
+ a.canPush === b.canPush &&
223
+ a.label === b.label &&
224
+ a.detail === b.detail &&
225
+ a.broken === b.broken &&
226
+ a.pushReplacesRemote === b.pushReplacesRemote &&
227
+ a.mirrorDirection === b.mirrorDirection &&
228
+ a.ref === b.ref &&
229
+ a.refUpdatedAt === b.refUpdatedAt &&
230
+ a.publishing === b.publishing &&
231
+ a.itemName === b.itemName &&
232
+ a.itemUrl === b.itemUrl
233
+ );
234
+ }
@@ -0,0 +1,132 @@
1
+ // Per-stream scope of a Document. A document is a container of streams:
2
+ // named, independently-loadable CRDT units. `doc.stream(name)` resolves
3
+ // one and returns this wrapper; only the streams an app touches are
4
+ // downloaded, imported, and subscribed, so open cost scales with use,
5
+ // not with the whole document.
6
+ //
7
+ // Each non-default stream is backed by its own internal Document wired
8
+ // to the stream's line (its own snapshot + op channel); the wrapper
9
+ // exposes the per-stream subset of the Document surface. The `default`
10
+ // stream wraps the parent Document itself, so `doc.stream('default')`
11
+ // and the doc-level handles read the same state.
12
+
13
+ import type {
14
+ Document,
15
+ DocumentMutator,
16
+ DocumentSubscriber,
17
+ DocumentUnsubscribe,
18
+ EntityProxy,
19
+ } from './documents.js';
20
+ import type { DocumentStreamInfo } from './documents-namespace.js';
21
+ import type { ListHandle, MapHandle, OrderedMapHandle } from './handles.js';
22
+
23
+ // The wiring `Document.stream()` runs on: list/create manifests over
24
+ // HTTP, and open a stream's line (join + WS subscribe) returning the
25
+ // internal Document that holds its state. Installed by the SDK open
26
+ // factory; absent on offline / test docs, where stream methods throw
27
+ // with a clear message.
28
+ export interface StreamRuntime {
29
+ list(): Promise<DocumentStreamInfo[]>;
30
+ create(name: string): Promise<DocumentStreamInfo>;
31
+ openLine(lineId: string): Promise<Document>;
32
+ }
33
+
34
+ export class DocumentStream {
35
+ // The stream's name within the document ('default' for the document's
36
+ // own default state).
37
+ readonly name: string;
38
+ /** @internal The Document holding this stream's state. For a
39
+ * non-default stream that is its own line's Document; for `default` it
40
+ * is the parent itself, which is why `_ownsInner` gates the close. */
41
+ readonly _inner: Document;
42
+ // True when this wrapper owns its inner Document (a non-default
43
+ // stream): closing the parent closes it. The default stream's inner IS
44
+ // the parent, which owns its own lifecycle.
45
+ private readonly _ownsInner: boolean;
46
+
47
+ /** @internal */
48
+ constructor(name: string, inner: Document, ownsInner: boolean) {
49
+ this.name = name;
50
+ this._inner = inner;
51
+ this._ownsInner = ownsInner;
52
+ }
53
+
54
+ // Typed container handles, scoped to this stream. Same surface and
55
+ // semantics as the doc-level `doc.map` / `doc.list` / `doc.orderedMap`.
56
+ map<T = unknown>(key: string): MapHandle<T> {
57
+ return this._inner.map<T>(key);
58
+ }
59
+
60
+ list<T = unknown>(key: string): ListHandle<T> {
61
+ return this._inner.list<T>(key);
62
+ }
63
+
64
+ orderedMap<T = unknown>(mapKey: string, orderKey: string): OrderedMapHandle<T> {
65
+ return this._inner.orderedMap<T>(mapKey, orderKey);
66
+ }
67
+
68
+ mutate<E extends object = EntityProxy>(
69
+ fn: (d: DocumentMutator<E>) => void,
70
+ ): void {
71
+ this._inner.mutate<E>(fn);
72
+ }
73
+
74
+ toJSON(): Record<string, unknown> {
75
+ return this._inner.toJSON();
76
+ }
77
+
78
+ subscribe(cb: DocumentSubscriber): DocumentUnsubscribe {
79
+ return this._inner.subscribe(cb);
80
+ }
81
+
82
+ // Per-stream undo/redo: each stream keeps its own local-edit stack
83
+ // (its edits are its own CRDT commits). The doc-level `doc.undo()`
84
+ // covers only the default stream.
85
+ undo(): boolean {
86
+ return this._inner.undo();
87
+ }
88
+
89
+ redo(): boolean {
90
+ return this._inner.redo();
91
+ }
92
+
93
+ // Seal the stream's open undo group, so the next local edit starts a
94
+ // fresh undo step. Local and synchronous (no server call). Call it
95
+ // when a user operation completes: commits group into one step until
96
+ // sealed (the same same-stroke collapsing the doc-level undo has), so
97
+ // an app that never seals gets one giant step per session. The
98
+ // cross-stream operation recipe in `docs/streams.md` is built on this.
99
+ sealUndoStep(): void {
100
+ this._inner._sealUndoStep();
101
+ }
102
+
103
+ /** @internal The stream's live head seq (advanced by its own op channel),
104
+ * or `null` before the transport resolves it. Internal for the same
105
+ * reason the document's is, and a stream has even less use for one: it
106
+ * carries no checkpoint machinery to anchor. */
107
+ get currentSeq(): number | null {
108
+ return this._inner.currentSeq;
109
+ }
110
+
111
+ /** @internal Preview this stream at a historical line seq, or return it
112
+ * to its live head when `seq` is undefined. Driven by the parent
113
+ * Document's version preview so a partitioned document previews
114
+ * consistently across streams. A no-op when the inner has no checkout
115
+ * fetcher (a guest line, which holds no token to fetch at-version), so
116
+ * such a stream stays live. */
117
+ async _previewAt(seq: number | undefined): Promise<void> {
118
+ const inner = this._inner;
119
+ if (seq !== undefined && inner._hasCheckoutFetcher()) {
120
+ await inner._checkoutVersion(seq);
121
+ } else if (inner._isCheckedOut()) {
122
+ inner.returnToHead();
123
+ }
124
+ }
125
+
126
+ /** @internal Close the stream's own transport + state. Called by the
127
+ * parent Document's close; a no-op for the default stream, whose inner
128
+ * is the parent itself. */
129
+ _close(): void {
130
+ if (this._ownsInner) this._inner.close();
131
+ }
132
+ }
@@ -0,0 +1,191 @@
1
+ // Document thumbnail auto-capture: screenshots a registered canvas after each
2
+ // local edit settles and uploads it as the thumbnail. One debounce timer, an
3
+ // injected uploader, and a one-shot WebGL diagnostic.
4
+
5
+ // A canvas the SDK screenshots for the thumbnail. A getter form is accepted
6
+ // for apps that build or rebuild the canvas lazily.
7
+ export type CanvasSource =
8
+ | HTMLCanvasElement
9
+ | (() => HTMLCanvasElement | null);
10
+
11
+ export type ThumbnailUploader = (
12
+ image: Blob,
13
+ width: number,
14
+ height: number,
15
+ ) => Promise<void>;
16
+
17
+ // Debounce window: a screenshot is taken this long after the last local edit,
18
+ // so a continuous drag uploads once when it settles, not once per frame.
19
+ const THUMBNAIL_CAPTURE_DEBOUNCE_MS = 1500;
20
+
21
+ // Longest-edge cap (device pixels) for an uploaded thumbnail: a generous
22
+ // product ceiling, an upper bound on the size any Brass surface sensibly
23
+ // paints a thumbnail at with retina headroom. The largest surface (the
24
+ // version-history timeline, ~200px) clears 2x DPR with room, while this still
25
+ // sheds the multi-megapixel frames a full-resolution 3D / map / CAD canvas
26
+ // would upload. Standalone (not derived from a CSS size) because the display
27
+ // sizes live in the frontend and this is a published SDK; a smaller
28
+ // per-surface derivative is a server-side concern.
29
+ const MAX_THUMBNAIL_CAPTURE_EDGE = 512;
30
+
31
+ // Cap the longest edge to `maxEdge`, preserving aspect ratio, never upscaling:
32
+ // a canvas within the cap is uploaded at native size (`scaled: false`), so the
33
+ // common small-canvas case skips the offscreen redraw. The returned dimensions
34
+ // are what the uploader reports, so they stay matched to the bytes.
35
+ export function fitWithinMaxEdge(
36
+ width: number,
37
+ height: number,
38
+ maxEdge: number,
39
+ ): { width: number; height: number; scaled: boolean } {
40
+ const longest = Math.max(width, height);
41
+ if (!Number.isFinite(longest) || longest <= 0 || longest <= maxEdge) {
42
+ return { width, height, scaled: false };
43
+ }
44
+ const ratio = maxEdge / longest;
45
+ return {
46
+ width: Math.max(1, Math.round(width * ratio)),
47
+ height: Math.max(1, Math.round(height * ratio)),
48
+ scaled: true,
49
+ };
50
+ }
51
+
52
+ export interface ThumbnailCaptureHost {
53
+ // True when a capture may run: the doc is live (not closed / suspended) and
54
+ // showing head (not a historical checkout), so a viewer or checkout never
55
+ // re-uploads a frame.
56
+ isCaptureAllowed(): boolean;
57
+ // True when the doc is still attached, re-checked after the async toBlob +
58
+ // upload boundary so a capture in flight when the doc closes does not upload.
59
+ isAttached(): boolean;
60
+ }
61
+
62
+ export class ThumbnailCapture {
63
+ // Canvas registered via `useCanvas`; `null` for the common non-canvas app.
64
+ private _canvas: CanvasSource | null = null;
65
+ private _timer: ReturnType<typeof setTimeout> | null = null;
66
+ // The canvas already warned about `preserveDrawingBuffer: false`, so the
67
+ // warn fires once per canvas; reset on `useCanvas` for a re-register.
68
+ private _warnedFor: HTMLCanvasElement | null = null;
69
+ // Injected upload hook (the Document owns no HTTP client). `null` on the
70
+ // offline / test path, where the canvas is tracked but never captured, so
71
+ // apps can register one early without hitting the server in tests.
72
+ private _uploader: ThumbnailUploader | null = null;
73
+
74
+ constructor(private readonly host: ThumbnailCaptureHost) {}
75
+
76
+ // Register a canvas (element or lazy getter) to screenshot as the thumbnail.
77
+ // Returns an unregister handle; re-registering replaces. The caller
78
+ // (Document) arms its Loro subscriber so local commits reach `schedule()`.
79
+ useCanvas(source: CanvasSource): () => void {
80
+ this._canvas = source;
81
+ // A re-register gets one more chance to surface the missing-buffer warn.
82
+ this._warnedFor = null;
83
+ return () => {
84
+ if (this._canvas === source) {
85
+ this._canvas = null;
86
+ this.cancel();
87
+ }
88
+ };
89
+ }
90
+
91
+ setUploader(uploader: ThumbnailUploader | null): void {
92
+ this._uploader = uploader;
93
+ }
94
+
95
+ // Called from the Document's local-commit dispatch; debounces so a burst of
96
+ // edits produces ONE upload when it settles.
97
+ schedule(): void {
98
+ if (this._canvas === null) return;
99
+ if (this._timer !== null) clearTimeout(this._timer);
100
+ this._timer = setTimeout(() => {
101
+ this._timer = null;
102
+ void this._capture();
103
+ }, THUMBNAIL_CAPTURE_DEBOUNCE_MS);
104
+ }
105
+
106
+ // Cancel a pending capture (canvas unregistered / suspend / close).
107
+ cancel(): void {
108
+ if (this._timer !== null) {
109
+ clearTimeout(this._timer);
110
+ this._timer = null;
111
+ }
112
+ }
113
+
114
+ private async _capture(): Promise<void> {
115
+ if (!this.host.isCaptureAllowed()) return;
116
+ if (this._canvas === null) return;
117
+ if (this._uploader === null) return;
118
+ const el =
119
+ typeof this._canvas === 'function' ? this._canvas() : this._canvas;
120
+ if (!el) return;
121
+ this._warnIfWebglContextWillProduceBlankCapture(el);
122
+ try {
123
+ const target = fitWithinMaxEdge(
124
+ el.width,
125
+ el.height,
126
+ MAX_THUMBNAIL_CAPTURE_EDGE,
127
+ );
128
+ const source = target.scaled ? this._downscale(el, target) : el;
129
+ const image = await new Promise<Blob>((resolve, reject) => {
130
+ source.toBlob((blob) => {
131
+ if (blob) resolve(blob);
132
+ else reject(new Error('thumbnail capture: canvas produced no image'));
133
+ }, 'image/png');
134
+ });
135
+ if (!this.host.isAttached()) return;
136
+ await this._uploader(image, target.width, target.height);
137
+ } catch {
138
+ // Best-effort: rate-limit rejection, lack of write access, or a
139
+ // transient failure is swallowed; the next local edit reschedules.
140
+ }
141
+ }
142
+
143
+ // Redraw an oversized canvas into an offscreen 2D canvas at the capped
144
+ // dimensions. `drawImage` reads the source's front buffer, so the WebGL
145
+ // `preserveDrawingBuffer` caveat above applies here too. If a 2D context
146
+ // can't be acquired, fall back to the full-resolution source: one oversized
147
+ // upload beats a missing thumbnail.
148
+ private _downscale(
149
+ el: HTMLCanvasElement,
150
+ target: { width: number; height: number },
151
+ ): HTMLCanvasElement {
152
+ const off = document.createElement('canvas');
153
+ off.width = target.width;
154
+ off.height = target.height;
155
+ const ctx = off.getContext('2d');
156
+ if (!ctx) return el;
157
+ ctx.drawImage(el, 0, 0, target.width, target.height);
158
+ return off;
159
+ }
160
+
161
+ // Warn once per canvas if its WebGL context lacks
162
+ // `preserveDrawingBuffer: true`: without it `toBlob` reads back a blank
163
+ // frame (the spec lets the browser discard the drawing buffer after
164
+ // compositing), so the upload silently stores a blank PNG with no
165
+ // diagnostic. Harmless on 2D / SVG canvases (no WebGL context to inspect).
166
+ private _warnIfWebglContextWillProduceBlankCapture(
167
+ el: HTMLCanvasElement,
168
+ ): void {
169
+ if (this._warnedFor === el) return;
170
+ if (typeof el.getContext !== 'function') return;
171
+ let gl: WebGLRenderingContext | WebGL2RenderingContext | null = null;
172
+ try {
173
+ gl =
174
+ (el.getContext('webgl2') as WebGL2RenderingContext | null) ??
175
+ (el.getContext('webgl') as WebGLRenderingContext | null);
176
+ } catch {
177
+ return;
178
+ }
179
+ if (!gl) return;
180
+ const attributes = gl.getContextAttributes();
181
+ if (attributes && attributes.preserveDrawingBuffer === false) {
182
+ this._warnedFor = el;
183
+ console.warn(
184
+ '@brass-build/client: useCanvas captured a blank thumbnail because ' +
185
+ 'the WebGL context was created with preserveDrawingBuffer: false. ' +
186
+ 'Pass preserveDrawingBuffer: true when creating the WebGL context ' +
187
+ '(e.g., new THREE.WebGLRenderer({ preserveDrawingBuffer: true })).',
188
+ );
189
+ }
190
+ }
191
+ }