@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,270 @@
1
+ // The record-store paging codec: how a large record-graph document packs its
2
+ // `instances` map into page containers, and how to undo that back to a flat
3
+ // `key -> { type, params }` lookup.
4
+ //
5
+ // It lives in the SDK (not in `formats/core`, where the record store lives)
6
+ // because the published `@brass-build/client` ships unbundled and can only
7
+ // import published packages and its own relative files; `formats/core` is
8
+ // neither. The substrate imports these helpers back from here, so the codec is
9
+ // defined ONCE and the SDK's paged read / edit cannot drift from how the store
10
+ // is written. Deliberately loro-free (fflate, a stable hash, JSON), so it
11
+ // stays a pure value codec on both sides.
12
+ //
13
+ // Wire recap (full rationale in `formats/core/src/record-store.ts`): the root
14
+ // `instances` LoroMap is EITHER unpaged (key -> `{ type, params }`, params a
15
+ // plain value) or, above `UNPAGED_MAX_RECORDS` records, PAGED: keyed
16
+ // `p0,p1,...` -> a `{ type: '__page__', params: <string> }` container whose
17
+ // params is a gzipped latin1 string of a JSON array of `[key, tag, body]`
18
+ // triples. `meta.page_count` carries the count; `instance_order` lists record
19
+ // keys in order in both shapes. A record's page is a hash of its key, so it is
20
+ // resolved with no stored index.
21
+
22
+ import { gzipSync, gunzipSync, strToU8, strFromU8 } from 'fflate';
23
+
24
+ // Gzip backend: native `node:zlib` when the runtime provides it, fflate
25
+ // otherwise. The page codec must stay a pure isomorphic value codec (the
26
+ // published SDK runs in browsers, where fflate is the only option), but
27
+ // fflate's JS inflate is several times slower than zlib's native one on a
28
+ // server-grade page walk, and a server's constrained cores amplify that. The
29
+ // module is reached through `process.getBuiltinModule` (Node 22+) via a
30
+ // structural `globalThis` read, so no `node:` import ever appears in the
31
+ // browser bundle and the SDK compiles without Node types. Both backends
32
+ // speak standard gzip, so pages written by one decode under the other.
33
+ interface GzipBackend {
34
+ gzipSync(bytes: Uint8Array): Uint8Array;
35
+ gunzipSync(bytes: Uint8Array): Uint8Array;
36
+ }
37
+
38
+ let nativeZlib: GzipBackend | null | undefined;
39
+
40
+ function zlibBackend(): GzipBackend | null {
41
+ if (nativeZlib === undefined) {
42
+ try {
43
+ const proc = (
44
+ globalThis as {
45
+ process?: { getBuiltinModule?: (id: string) => unknown };
46
+ }
47
+ ).process;
48
+ nativeZlib =
49
+ (proc?.getBuiltinModule?.('node:zlib') as GzipBackend | undefined) ??
50
+ null;
51
+ } catch {
52
+ nativeZlib = null;
53
+ }
54
+ }
55
+ return nativeZlib;
56
+ }
57
+
58
+ function gzip(bytes: Uint8Array): Uint8Array {
59
+ const zlib = zlibBackend();
60
+ return zlib ? zlib.gzipSync(bytes) : gzipSync(bytes);
61
+ }
62
+
63
+ function gunzip(bytes: Uint8Array): Uint8Array {
64
+ const zlib = zlibBackend();
65
+ return zlib ? zlib.gunzipSync(bytes) : gunzipSync(bytes);
66
+ }
67
+
68
+ /** Field names inside a stored record container: `type` is the tag, `params`
69
+ * the body. Verbatim so the substrate and SDK name the same fields. */
70
+ export const TAG_FIELD = 'type';
71
+ export const BODY_FIELD = 'params';
72
+
73
+ /** Root container keys the record store occupies on a document. */
74
+ export const META_KEY = 'meta';
75
+ export const RECORDS_KEY = 'instances';
76
+ export const ORDER_KEY = 'instance_order';
77
+
78
+ /** `meta` key holding a paged store's page count; absent (or 0) means
79
+ * unpaged. The read path detects pages by tag; only an in-place edit needs
80
+ * the count, to hash a key to its page. */
81
+ export const PAGE_COUNT_KEY = 'page_count';
82
+
83
+ /** `meta` key holding the paging codec's scheme number, stamped on a paged
84
+ * write. The scheme fixes the page-key hash (`pageKeyFor`), the `p<n>` key
85
+ * format, and the packed-record layout together: a reader that hashes a key
86
+ * with a different scheme than the store was written under targets the wrong
87
+ * page on an in-place edit, silently writing a record into a page it does not
88
+ * belong to. The stamp lets a pinned reader detect a store written under a
89
+ * scheme it does not implement and refuse the edit instead of corrupting it.
90
+ * Absent means the current scheme (paged stores written before the stamp
91
+ * existed used scheme 1's hash and layout). */
92
+ export const PAGING_SCHEME_KEY = 'paging_scheme';
93
+
94
+ /** The paging scheme this build writes and can edit. Bump ONLY alongside a
95
+ * change to `pageKeyFor`'s hash, the `p<n>` key format, or the packed-record
96
+ * layout, so an older pinned reader trips `isPagingSchemeEditable` and fails
97
+ * loud rather than editing a store it would mis-hash. */
98
+ export const CURRENT_PAGING_SCHEME = 1;
99
+
100
+ /** Whether a store stamped with `scheme` can be edited in place by this build.
101
+ * Absent / non-numeric / `0` is a scheme-1 store (the stamp postdates it), so
102
+ * editable; any scheme at or below the current one is editable; a higher
103
+ * scheme is not (this reader would hash into the wrong page). Reads stay
104
+ * best-effort and are not gated here; only an in-place edit can persist the
105
+ * mis-hash, so the edit path is where the guard belongs. */
106
+ export function isPagingSchemeEditable(scheme: unknown): boolean {
107
+ if (typeof scheme !== 'number' || !Number.isFinite(scheme) || scheme <= 0) {
108
+ return true;
109
+ }
110
+ return scheme <= CURRENT_PAGING_SCHEME;
111
+ }
112
+
113
+ /** Above this many records the store is written paged; at or below it it keeps
114
+ * the one-container-per-record shape. */
115
+ export const UNPAGED_MAX_RECORDS = 50_000;
116
+
117
+ // Target records per page and a page-count ceiling so even the 2M-record cap
118
+ // stays out of the slow zone. Mirrored from the substrate's write path so
119
+ // `pageCountFor` agrees with how the store was written.
120
+ const TARGET_PAGE_SIZE = 64;
121
+ const MAX_PAGES = 50_000;
122
+
123
+ /** The tag a page container carries in place of a record tag, so a reader
124
+ * tells a page (body = record triples) from a single record (body = one
125
+ * plain value). */
126
+ export const PAGE_TAG = '__page__';
127
+
128
+ /** A record packed inside a page body: `[key, tag, body]`. The tuple form
129
+ * keeps a paged snapshot compact. */
130
+ export type PackedRecord<B> = [key: string, tag: string, body: B];
131
+
132
+ /** Encode a page's triples as its stored body: JSON-serialised, gzipped, held
133
+ * as a latin1 string. A STRING body avoids loro's superlinear per-value
134
+ * conversion of a deeply nested triple array; gzipping keeps a heavy file in
135
+ * memory + export budget. (Full rationale in the substrate's `encodePageBody`.) */
136
+ export function encodePageBody<B>(triples: PackedRecord<B>[]): string {
137
+ return encodePageBodyFromEncoded(triples.map((t) => JSON.stringify(t)));
138
+ }
139
+
140
+ /** Encode one record's `[key, tag, body]` triple as the JSON string it
141
+ * occupies inside a page body. Split from page assembly so a large write can
142
+ * stringify each record as it buckets and release the parsed body before the
143
+ * gzip loop, rather than holding the whole parsed model live through it (see
144
+ * `writeRecordStoreConsuming` for why that release keeps the encode fast). */
145
+ export function encodeRecordForPage<B>(
146
+ key: string,
147
+ tag: string,
148
+ body: B,
149
+ ): string {
150
+ return JSON.stringify([key, tag, body] as PackedRecord<B>);
151
+ }
152
+
153
+ /** Assemble a page body from records already JSON-encoded by
154
+ * `encodeRecordForPage`: wrap in a JSON array, gzip, hold as a latin1 string.
155
+ * Byte-identical to `encodePageBody` over the same triples, so the wire shape
156
+ * is unchanged; the split exists only so the writer frees each parsed body
157
+ * first. */
158
+ export function encodePageBodyFromEncoded(encoded: string[]): string {
159
+ return strFromU8(gzip(strToU8('[' + encoded.join(',') + ']')), true);
160
+ }
161
+
162
+ /** Decode a page body back to its record triples. Returns [] for anything that
163
+ * does not decompress to a JSON array, so a reader tolerates a corrupt or
164
+ * stray page rather than throwing. */
165
+ export function decodePageBody(body: unknown): PackedRecord<unknown>[] {
166
+ if (typeof body !== 'string') return [];
167
+ try {
168
+ const json = strFromU8(gunzip(strToU8(body, true)));
169
+ const parsed = JSON.parse(json);
170
+ return Array.isArray(parsed) ? (parsed as PackedRecord<unknown>[]) : [];
171
+ } catch {
172
+ return [];
173
+ }
174
+ }
175
+
176
+ /** The page count for a record population: 0 (unpaged) at or below
177
+ * `UNPAGED_MAX_RECORDS`, else ~`TARGET_PAGE_SIZE` records per page, capped at
178
+ * `MAX_PAGES`. */
179
+ export function pageCountFor(recordCount: number): number {
180
+ if (recordCount <= UNPAGED_MAX_RECORDS) return 0;
181
+ return Math.min(Math.ceil(recordCount / TARGET_PAGE_SIZE), MAX_PAGES);
182
+ }
183
+
184
+ /** FNV-1a (32-bit) assigning a record key to a page. Stable and
185
+ * engine-independent: the same key resolves to the same page on write, read,
186
+ * and edit across processes, which is what lets the page be found with no
187
+ * stored index. */
188
+ function hashKey(key: string): number {
189
+ let h = 0x811c9dc5;
190
+ for (let i = 0; i < key.length; i += 1) {
191
+ h ^= key.charCodeAt(i);
192
+ h = Math.imul(h, 0x01000193);
193
+ }
194
+ return h >>> 0;
195
+ }
196
+
197
+ /** The page key a record key lives in, for a store of `pageCount` pages. */
198
+ export function pageKeyFor(key: string, pageCount: number): string {
199
+ return `p${hashKey(key) % pageCount}`;
200
+ }
201
+
202
+ /** A stored container's tag (`type`), or null for any value not in the
203
+ * `{ type, params }` shape, so a reader skips a corrupt entry. A page carries
204
+ * `PAGE_TAG`. */
205
+ export function peekRecordTag(value: unknown): string | null {
206
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
207
+ const tag = (value as Record<string, unknown>)[TAG_FIELD];
208
+ return typeof tag === 'string' ? tag : null;
209
+ }
210
+
211
+ /** Decode a stored container (`{ type, params }`) to its tag + raw body,
212
+ * or null when it is not in the record shape. */
213
+ export function decodeRecordValue(
214
+ value: unknown,
215
+ ): { tag: string; body: unknown } | null {
216
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
217
+ const o = value as Record<string, unknown>;
218
+ const tag = o[TAG_FIELD];
219
+ if (typeof tag !== 'string' || !(BODY_FIELD in o)) return null;
220
+ return { tag, body: o[BODY_FIELD] };
221
+ }
222
+
223
+ /** Whether a `meta.page_count` value denotes a PAGED store. The writer sets
224
+ * it to a positive count only when it pages, and never on an unpaged write,
225
+ * so a positive finite number is the authoritative "is paged" signal. */
226
+ export function isPagedByPageCount(pageCount: unknown): boolean {
227
+ return typeof pageCount === 'number' && Number.isFinite(pageCount) && pageCount > 0;
228
+ }
229
+
230
+ /** Flatten a possibly-PAGED `instances` lookup to the flat
231
+ * `key -> { type, params }` lookup a reader consumes. An unpaged lookup is
232
+ * returned unchanged (no allocation). The single place paging is undone on the
233
+ * read side.
234
+ *
235
+ * `paged` is the AUTHORITATIVE page state, read from `meta.page_count` by the
236
+ * caller (`isPagedByPageCount`). Page interpretation MUST be gated on it, not
237
+ * on whether some entry is `PAGE_TAG`-tagged: a genuine record whose codec tag
238
+ * happens to equal `PAGE_TAG` in an UNPAGED store is a real record, not a
239
+ * page. Treating it as a page decodes its plain body as `decodePageBody`
240
+ * (which returns `[]` for a non-page body), silently DROPPING the record, and
241
+ * a crafted page-shaped body could forge records into the lookup. */
242
+ export function flattenPagedLookup(
243
+ lookup: Record<string, unknown>,
244
+ paged: boolean,
245
+ ): Record<string, unknown> {
246
+ if (!paged) return lookup;
247
+
248
+ const flat: Record<string, unknown> = {};
249
+ for (const [key, value] of Object.entries(lookup)) {
250
+ if (peekRecordTag(value) !== PAGE_TAG) {
251
+ flat[key] = value; // tolerate a stray non-page entry by its own key
252
+ continue;
253
+ }
254
+ const body = decodePageBody(decodeRecordValue(value)?.body);
255
+ for (const packed of body) {
256
+ if (
257
+ Array.isArray(packed) &&
258
+ packed.length >= 3 &&
259
+ typeof packed[0] === 'string' &&
260
+ typeof packed[1] === 'string'
261
+ ) {
262
+ flat[packed[0]] = {
263
+ [TAG_FIELD]: packed[1],
264
+ [BODY_FIELD]: packed[2],
265
+ };
266
+ }
267
+ }
268
+ }
269
+ return flat;
270
+ }
@@ -0,0 +1,176 @@
1
+ // The prompt shown when the platform refuses to open a document this app's
2
+ // declared schema cannot read.
3
+ //
4
+ // SDK-rendered rather than a hosted iframe: it asks one question about a
5
+ // document already named on this page, needs no session of its own, and has
6
+ // to appear on a cold boot from a bookmark, which is the moment an iframe
7
+ // round-trip is least welcome. The hosted dialogs exist for surfaces that
8
+ // read the person's whole account; this reads nothing.
9
+ //
10
+ // Both strings are the server's own, rendered verbatim. A client compiles
11
+ // into an app's bundle and freezes there, so a word this file holds itself
12
+ // can only be corrected by every app republishing, while a word it is handed
13
+ // reaches every app the moment the platform deploys. The fallback below is
14
+ // for a platform that predates the title, not a second opinion about wording.
15
+
16
+ export interface SchemaConflictChoice {
17
+ openAnyway: boolean;
18
+ }
19
+
20
+ const STYLE = `
21
+ /* A dialog opened with showModal(), so the browser holds the keyboard inside
22
+ the prompt. The UA box styles for a dialog (content-sized, centered,
23
+ bordered, padded, max-width capped) are all wrong for a full-viewport
24
+ backdrop, so each is zeroed, and the UA backdrop is cleared because this
25
+ element paints the dim itself. */
26
+ .brass-sc-backdrop {
27
+ position: fixed;
28
+ inset: 0;
29
+ margin: 0;
30
+ border: 0;
31
+ width: auto;
32
+ height: auto;
33
+ max-width: none;
34
+ max-height: none;
35
+ z-index: 2147483646;
36
+ display: flex;
37
+ align-items: center;
38
+ justify-content: center;
39
+ padding: 1rem;
40
+ background: rgba(20, 18, 13, 0.45);
41
+ font: 15px/1.5 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
42
+ }
43
+ .brass-sc-backdrop::backdrop {
44
+ background: transparent;
45
+ }
46
+ .brass-sc-panel {
47
+ background: Canvas;
48
+ color: CanvasText;
49
+ color-scheme: light dark;
50
+ max-width: 26rem;
51
+ width: 100%;
52
+ padding: 1.25rem;
53
+ border-radius: 0.75rem;
54
+ box-shadow: 0 16px 40px rgba(0, 0, 0, 0.28);
55
+ }
56
+ .brass-sc-title {
57
+ margin: 0 0 0.5rem;
58
+ font-size: 1.0625rem;
59
+ font-weight: 600;
60
+ }
61
+ .brass-sc-body {
62
+ margin: 0 0 1.25rem;
63
+ }
64
+ .brass-sc-actions {
65
+ display: flex;
66
+ flex-wrap: wrap;
67
+ justify-content: flex-end;
68
+ gap: 0.5rem;
69
+ }
70
+ .brass-sc-actions button {
71
+ font: inherit;
72
+ min-height: 2.5rem;
73
+ padding: 0 0.875rem;
74
+ border-radius: 0.375rem;
75
+ border: 1px solid rgba(128, 128, 128, 0.4);
76
+ background: transparent;
77
+ color: inherit;
78
+ cursor: pointer;
79
+ }
80
+ .brass-sc-actions button.brass-sc-go {
81
+ border-color: transparent;
82
+ background: CanvasText;
83
+ color: Canvas;
84
+ }
85
+ `;
86
+
87
+ /**
88
+ * Ask whether to open the document anyway. Resolves `openAnyway: false` on
89
+ * Close, on Escape, and when there is no DOM to ask in: every one of those is
90
+ * an open nobody agreed to, and the refusal stands.
91
+ */
92
+ export function askSchemaConflict(
93
+ message: string,
94
+ title?: string,
95
+ ): Promise<SchemaConflictChoice> {
96
+ if (typeof document === 'undefined') {
97
+ return Promise.resolve({ openAnyway: false });
98
+ }
99
+ return new Promise<SchemaConflictChoice>((resolve) => {
100
+ const host = document.createElement('div');
101
+ const shadow = host.attachShadow({ mode: 'open' });
102
+ const style = document.createElement('style');
103
+ style.textContent = STYLE;
104
+
105
+ const backdrop = document.createElement('dialog');
106
+ backdrop.className = 'brass-sc-backdrop';
107
+ const panel = document.createElement('div');
108
+ panel.className = 'brass-sc-panel';
109
+ panel.setAttribute('role', 'alertdialog');
110
+ panel.setAttribute('aria-modal', 'true');
111
+ panel.setAttribute('aria-labelledby', 'brass-sc-title');
112
+
113
+ const titleEl = document.createElement('h2');
114
+ titleEl.className = 'brass-sc-title';
115
+ titleEl.id = 'brass-sc-title';
116
+ titleEl.textContent = title ?? 'This app may not read this file correctly';
117
+
118
+ const body = document.createElement('p');
119
+ body.className = 'brass-sc-body';
120
+ body.textContent = message;
121
+
122
+ const actions = document.createElement('div');
123
+ actions.className = 'brass-sc-actions';
124
+ const close = document.createElement('button');
125
+ close.type = 'button';
126
+ close.textContent = 'Close';
127
+ const go = document.createElement('button');
128
+ go.type = 'button';
129
+ go.className = 'brass-sc-go';
130
+ go.textContent = 'Open anyway';
131
+
132
+ // Where the reader was when the prompt opened. Answering it detaches the
133
+ // prompt rather than closing it in place, so putting them back is this
134
+ // function's job.
135
+ const opener =
136
+ document.activeElement instanceof HTMLElement ? document.activeElement : null;
137
+
138
+ let settled = false;
139
+ const settle = (openAnyway: boolean): void => {
140
+ if (settled) return;
141
+ settled = true;
142
+ document.removeEventListener('keydown', onKey, true);
143
+ if (backdrop.open) backdrop.close();
144
+ host.remove();
145
+ if (opener?.isConnected) opener.focus();
146
+ resolve({ openAnyway });
147
+ };
148
+ function onKey(event: KeyboardEvent): void {
149
+ if (event.key === 'Escape') settle(false);
150
+ }
151
+
152
+ close.addEventListener('click', () => settle(false));
153
+ go.addEventListener('click', () => settle(true));
154
+ document.addEventListener('keydown', onKey, true);
155
+ // An open dialog answers Escape itself, which would close it without
156
+ // resolving. Route it into the one answer path; `onKey` above normally
157
+ // gets there first.
158
+ backdrop.addEventListener('cancel', (event) => {
159
+ event.preventDefault();
160
+ settle(false);
161
+ });
162
+
163
+ actions.append(close, go);
164
+ panel.append(titleEl, body, actions);
165
+ backdrop.append(panel);
166
+ shadow.append(style, backdrop);
167
+ document.body.append(host);
168
+ // The call that holds the keyboard inside the prompt: without it Tab keeps
169
+ // walking the app behind it. Guarded so a browser without the top layer
170
+ // still gets the prompt, non-modal, rather than none.
171
+ if (typeof backdrop.showModal === 'function') backdrop.showModal();
172
+ // Close is the safe answer, so it takes focus: Enter on a dialog nobody
173
+ // read declines rather than opening a document the platform refused.
174
+ close.focus();
175
+ });
176
+ }
@@ -0,0 +1,98 @@
1
+ // Per-app sessionStorage layer that stashes the last value returned by
2
+ // `Brass.selectDocument()`; the session's boot restore reads it back as
3
+ // the bare-URL fallback.
4
+ //
5
+ // Keys are namespaced per-app id so integrations on one origin don't
6
+ // collide. Cleared on sign-out (`clearAll`); session storage also drops on
7
+ // tab close.
8
+
9
+ import type { SelectDocumentResult } from './hosted-ui.js';
10
+
11
+ const KEY_PREFIX = '__brass_';
12
+ const DOC_KEY = `${KEY_PREFIX}last_doc_`;
13
+
14
+ // Single per-page-load warn: `storeLastDocument` runs on every successful pick,
15
+ // so without the latch a server-rendered host would flood its logs.
16
+ let warnedUnavailable = false;
17
+
18
+ function getStorage(): Storage | null {
19
+ if (typeof sessionStorage === 'undefined') return null;
20
+ try {
21
+ return sessionStorage;
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
26
+
27
+ function warnOnceUnavailable(): void {
28
+ if (warnedUnavailable) return;
29
+ warnedUnavailable = true;
30
+ console.warn(
31
+ 'Brass: sessionStorage is unavailable; selection persistence is disabled.',
32
+ );
33
+ }
34
+
35
+ function docKey(appId: string): string {
36
+ return `${DOC_KEY}${appId}`;
37
+ }
38
+
39
+ function isSelectDocumentResult(value: unknown): value is SelectDocumentResult {
40
+ if (!value || typeof value !== 'object') return false;
41
+ const v = value as Record<string, unknown>;
42
+ return (
43
+ typeof v['doc_id'] === 'string' &&
44
+ // `project_id: string | null` (loose docs have no project).
45
+ (typeof v['project_id'] === 'string' || v['project_id'] === null) &&
46
+ typeof v['organization_id'] === 'string' &&
47
+ typeof v['role'] === 'string' &&
48
+ typeof v['name'] === 'string' &&
49
+ typeof v['created_at'] === 'string' &&
50
+ typeof v['updated_at'] === 'string'
51
+ );
52
+ }
53
+
54
+ export function storeLastDocument(
55
+ appId: string,
56
+ value: SelectDocumentResult,
57
+ ): void {
58
+ const storage = getStorage();
59
+ if (!storage) {
60
+ warnOnceUnavailable();
61
+ return;
62
+ }
63
+ try {
64
+ storage.setItem(docKey(appId), JSON.stringify(value));
65
+ } catch {
66
+ // Quota / disabled storage; drop silently so a recall-on-reload
67
+ // niceness never breaks the launcher path that just succeeded.
68
+ }
69
+ }
70
+
71
+ export function readLastDocument(
72
+ appId: string,
73
+ ): SelectDocumentResult | null {
74
+ const storage = getStorage();
75
+ if (!storage) return null;
76
+ const raw = storage.getItem(docKey(appId));
77
+ if (raw === null) return null;
78
+ let parsed: unknown;
79
+ try {
80
+ parsed = JSON.parse(raw);
81
+ } catch {
82
+ return null;
83
+ }
84
+ // A value written by an older SDK version may not match the current
85
+ // shape; ignore it rather than hand the caller a half-typed object.
86
+ if (!isSelectDocumentResult(parsed)) return null;
87
+ return parsed;
88
+ }
89
+
90
+ export function clearAll(appId: string): void {
91
+ const storage = getStorage();
92
+ if (!storage) return;
93
+ try {
94
+ storage.removeItem(docKey(appId));
95
+ } catch {
96
+ // See `storeLastDocument`.
97
+ }
98
+ }
@@ -0,0 +1,15 @@
1
+ import type { Document } from './documents.js';
2
+
3
+ // A per-app serializer that turns an app-owned (blob) document's records
4
+ // back into the source file's bytes: the inverse of the parse the app ran
5
+ // on `doc.sourceBlob()` when it opened the file. Register one at boot with
6
+ // `Brass.registerSerializer`.
7
+ //
8
+ // The platform invokes it, never the app: on a download from a hosted Brass
9
+ // surface, the platform asks the running app to serialise, then handles
10
+ // staging, naming, and delivery. Producing the bytes is the serializer's
11
+ // whole job. An app with no registered serializer cannot export its blob
12
+ // documents (the platform falls back to the stale-original passthrough).
13
+ export type DocumentSerializer = (
14
+ doc: Document,
15
+ ) => Uint8Array | Promise<Uint8Array>;
@@ -0,0 +1,69 @@
1
+ // Per-app sessionStorage layer for the incognito / third-party-cookie
2
+ // fallback session token: the opaque `sid` from a `brass_code` exchange, so
3
+ // a hard reload in a cookie-blocked browser (incognito, Safari ITP) keeps
4
+ // the user signed in. In a normal browser the cross-site cookie carries the
5
+ // session and nothing is written here.
6
+ //
7
+ // The value is no more sensitive than the cookie it mirrors: an opaque
8
+ // pointer, useless off an allowlisted Origin, gated by `approved_apps`
9
+ // server-side, same 30-day TTL. `sessionStorage` (per-tab, dropped on tab
10
+ // close) matches the cookie-channel UX. Keyed per-app id.
11
+
12
+ const KEY_PREFIX = '__brass_session_';
13
+
14
+ let warnedUnavailable = false;
15
+
16
+ function getStorage(): Storage | null {
17
+ if (typeof sessionStorage === 'undefined') return null;
18
+ try {
19
+ return sessionStorage;
20
+ } catch {
21
+ return null;
22
+ }
23
+ }
24
+
25
+ function warnOnceUnavailable(): void {
26
+ if (warnedUnavailable) return;
27
+ warnedUnavailable = true;
28
+ console.warn(
29
+ 'Brass: sessionStorage is unavailable; the incognito session-token fallback will not survive reloads.',
30
+ );
31
+ }
32
+
33
+ function key(appId: string): string {
34
+ return `${KEY_PREFIX}${appId}`;
35
+ }
36
+
37
+ export function storeSessionToken(appId: string, token: string): void {
38
+ const storage = getStorage();
39
+ if (!storage) {
40
+ warnOnceUnavailable();
41
+ return;
42
+ }
43
+ try {
44
+ storage.setItem(key(appId), token);
45
+ } catch {
46
+ // Quota / disabled storage; drop rather than fail a /refresh that
47
+ // already succeeded (reload-survival is a niceness).
48
+ }
49
+ }
50
+
51
+ export function readSessionToken(appId: string): string | null {
52
+ const storage = getStorage();
53
+ if (!storage) return null;
54
+ try {
55
+ return storage.getItem(key(appId));
56
+ } catch {
57
+ return null;
58
+ }
59
+ }
60
+
61
+ export function clearSessionToken(appId: string): void {
62
+ const storage = getStorage();
63
+ if (!storage) return;
64
+ try {
65
+ storage.removeItem(key(appId));
66
+ } catch {
67
+ // See `storeSessionToken`.
68
+ }
69
+ }