@brass-build/client 0.1.0 → 0.3.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 (213) hide show
  1. package/AGENTS.md +72 -63
  2. package/CHANGELOG.md +97 -0
  3. package/README.md +325 -235
  4. package/dist/auth-flow.d.ts +10 -1
  5. package/dist/auth-flow.d.ts.map +1 -1
  6. package/dist/auth-flow.js +64 -20
  7. package/dist/auth-flow.js.map +1 -1
  8. package/dist/client.d.ts +4 -0
  9. package/dist/client.d.ts.map +1 -1
  10. package/dist/client.js +245 -76
  11. package/dist/client.js.map +1 -1
  12. package/dist/document-checkpoints.d.ts +1 -0
  13. package/dist/document-checkpoints.d.ts.map +1 -1
  14. package/dist/document-checkpoints.js +27 -0
  15. package/dist/document-checkpoints.js.map +1 -1
  16. package/dist/document-engine.d.ts +4 -0
  17. package/dist/document-engine.d.ts.map +1 -0
  18. package/dist/document-engine.js +9 -0
  19. package/dist/document-engine.js.map +1 -0
  20. package/dist/document-signals.d.ts +31 -1
  21. package/dist/document-signals.d.ts.map +1 -1
  22. package/dist/document-signals.js +59 -0
  23. package/dist/document-signals.js.map +1 -1
  24. package/dist/document-streams.d.ts +8 -1
  25. package/dist/document-streams.d.ts.map +1 -1
  26. package/dist/document-streams.js +79 -8
  27. package/dist/document-streams.js.map +1 -1
  28. package/dist/document-thumbnail-capture.d.ts +20 -0
  29. package/dist/document-thumbnail-capture.d.ts.map +1 -1
  30. package/dist/document-thumbnail-capture.js +247 -9
  31. package/dist/document-thumbnail-capture.js.map +1 -1
  32. package/dist/documents-namespace.d.ts.map +1 -1
  33. package/dist/documents-namespace.js +22 -3
  34. package/dist/documents-namespace.js.map +1 -1
  35. package/dist/documents.d.ts +49 -16
  36. package/dist/documents.d.ts.map +1 -1
  37. package/dist/documents.js +981 -237
  38. package/dist/documents.js.map +1 -1
  39. package/dist/embed-protocol.d.ts +3 -0
  40. package/dist/embed-protocol.d.ts.map +1 -0
  41. package/dist/embed-protocol.js +26 -0
  42. package/dist/embed-protocol.js.map +1 -0
  43. package/dist/emitter.d.ts.map +1 -1
  44. package/dist/emitter.js +8 -1
  45. package/dist/emitter.js.map +1 -1
  46. package/dist/export-guest.d.ts.map +1 -1
  47. package/dist/export-guest.js +14 -6
  48. package/dist/export-guest.js.map +1 -1
  49. package/dist/fetch-deadline.d.ts +22 -0
  50. package/dist/fetch-deadline.d.ts.map +1 -0
  51. package/dist/fetch-deadline.js +96 -0
  52. package/dist/fetch-deadline.js.map +1 -0
  53. package/dist/fetch-retry.d.ts +1 -0
  54. package/dist/fetch-retry.d.ts.map +1 -1
  55. package/dist/fetch-retry.js +12 -5
  56. package/dist/fetch-retry.js.map +1 -1
  57. package/dist/handle-types.d.ts +150 -0
  58. package/dist/handle-types.d.ts.map +1 -0
  59. package/dist/handle-types.js +8 -0
  60. package/dist/handle-types.js.map +1 -0
  61. package/dist/handles.d.ts +48 -48
  62. package/dist/handles.d.ts.map +1 -1
  63. package/dist/handles.js +3122 -501
  64. package/dist/handles.js.map +1 -1
  65. package/dist/hosted-runtime.d.ts.map +1 -1
  66. package/dist/hosted-runtime.js +8 -12
  67. package/dist/hosted-runtime.js.map +1 -1
  68. package/dist/hosted-ui.d.ts +0 -1
  69. package/dist/hosted-ui.d.ts.map +1 -1
  70. package/dist/hosted-ui.js +29 -24
  71. package/dist/hosted-ui.js.map +1 -1
  72. package/dist/importer.d.ts.map +1 -1
  73. package/dist/importer.js +204 -8
  74. package/dist/importer.js.map +1 -1
  75. package/dist/index.d.ts +10 -10
  76. package/dist/index.d.ts.map +1 -1
  77. package/dist/index.js +11 -2
  78. package/dist/index.js.map +1 -1
  79. package/dist/internal.d.ts +2 -1
  80. package/dist/internal.d.ts.map +1 -1
  81. package/dist/internal.js +1 -0
  82. package/dist/internal.js.map +1 -1
  83. package/dist/join-coverage.d.ts +12 -0
  84. package/dist/join-coverage.d.ts.map +1 -0
  85. package/dist/join-coverage.js +37 -0
  86. package/dist/join-coverage.js.map +1 -0
  87. package/dist/jwt.d.ts.map +1 -1
  88. package/dist/jwt.js +5 -1
  89. package/dist/jwt.js.map +1 -1
  90. package/dist/launch-context.js +2 -2
  91. package/dist/launch-context.js.map +1 -1
  92. package/dist/mutate-batching.d.ts +25 -2
  93. package/dist/mutate-batching.d.ts.map +1 -1
  94. package/dist/mutate-batching.js +57 -10
  95. package/dist/mutate-batching.js.map +1 -1
  96. package/dist/open-document.d.ts +30 -1
  97. package/dist/open-document.d.ts.map +1 -1
  98. package/dist/open-document.js +1905 -322
  99. package/dist/open-document.js.map +1 -1
  100. package/dist/presence.d.ts +28 -0
  101. package/dist/presence.d.ts.map +1 -1
  102. package/dist/presence.js +149 -7
  103. package/dist/presence.js.map +1 -1
  104. package/dist/record-paging.d.ts +335 -71
  105. package/dist/record-paging.d.ts.map +1 -1
  106. package/dist/record-paging.js +590 -127
  107. package/dist/record-paging.js.map +1 -1
  108. package/dist/selection-persistence.d.ts.map +1 -1
  109. package/dist/selection-persistence.js +11 -1
  110. package/dist/selection-persistence.js.map +1 -1
  111. package/dist/serializer.d.ts +30 -1
  112. package/dist/serializer.d.ts.map +1 -1
  113. package/dist/session-persistence.d.ts.map +1 -1
  114. package/dist/session-persistence.js +9 -8
  115. package/dist/session-persistence.js.map +1 -1
  116. package/dist/session-toolbar.d.ts +0 -1
  117. package/dist/session-toolbar.d.ts.map +1 -1
  118. package/dist/session-toolbar.js +356 -83
  119. package/dist/session-toolbar.js.map +1 -1
  120. package/dist/session.d.ts.map +1 -1
  121. package/dist/session.js +87 -18
  122. package/dist/session.js.map +1 -1
  123. package/dist/tail-ops.d.ts +14 -4
  124. package/dist/tail-ops.d.ts.map +1 -1
  125. package/dist/tail-ops.js +75 -15
  126. package/dist/tail-ops.js.map +1 -1
  127. package/dist/topic-lifecycle.d.ts +4 -0
  128. package/dist/topic-lifecycle.d.ts.map +1 -1
  129. package/dist/topic-lifecycle.js +25 -1
  130. package/dist/topic-lifecycle.js.map +1 -1
  131. package/dist/types.d.ts +47 -1
  132. package/dist/types.d.ts.map +1 -1
  133. package/dist/undo.d.ts +2 -3
  134. package/dist/undo.d.ts.map +1 -1
  135. package/dist/undo.js +11 -0
  136. package/dist/undo.js.map +1 -1
  137. package/dist/unload-guard.d.ts +5 -0
  138. package/dist/unload-guard.d.ts.map +1 -0
  139. package/dist/unload-guard.js +88 -0
  140. package/dist/unload-guard.js.map +1 -0
  141. package/dist/version-bar.d.ts +4 -3
  142. package/dist/version-bar.d.ts.map +1 -1
  143. package/dist/version-bar.js +162 -20
  144. package/dist/version-bar.js.map +1 -1
  145. package/dist/version.d.ts +2 -2
  146. package/dist/version.js +1 -1
  147. package/dist/vite.d.ts +18 -2
  148. package/dist/vite.d.ts.map +1 -1
  149. package/dist/vite.js +133 -8
  150. package/dist/vite.js.map +1 -1
  151. package/dist/when-saved.d.ts +2 -0
  152. package/dist/when-saved.d.ts.map +1 -0
  153. package/dist/when-saved.js +70 -0
  154. package/dist/when-saved.js.map +1 -0
  155. package/dist/ws-client.d.ts +27 -3
  156. package/dist/ws-client.d.ts.map +1 -1
  157. package/dist/ws-client.js +408 -89
  158. package/dist/ws-client.js.map +1 -1
  159. package/dist/ws-frame-limit.d.ts.map +1 -1
  160. package/dist/ws-frame-limit.js +23 -17
  161. package/dist/ws-frame-limit.js.map +1 -1
  162. package/docs/app-manifest.md +7 -8
  163. package/docs/custom-chrome.md +21 -22
  164. package/docs/hosted-ui-advanced.md +14 -17
  165. package/docs/imported-documents.md +53 -197
  166. package/docs/presence.md +45 -21
  167. package/docs/schemas.md +12 -17
  168. package/docs/streams.md +55 -57
  169. package/package.json +4 -3
  170. package/src/auth-flow.ts +90 -23
  171. package/src/client.ts +262 -80
  172. package/src/document-checkpoints.ts +30 -0
  173. package/src/document-engine.ts +15 -0
  174. package/src/document-signals.ts +120 -8
  175. package/src/document-streams.ts +95 -9
  176. package/src/document-thumbnail-capture.ts +253 -9
  177. package/src/documents-namespace.ts +38 -13
  178. package/src/documents.ts +1103 -290
  179. package/src/embed-protocol.ts +25 -0
  180. package/src/emitter.ts +8 -1
  181. package/src/export-guest.ts +15 -9
  182. package/src/fetch-deadline.ts +119 -0
  183. package/src/fetch-retry.ts +14 -5
  184. package/src/handle-types.ts +303 -0
  185. package/src/handles.ts +3407 -683
  186. package/src/hosted-runtime.ts +8 -12
  187. package/src/hosted-ui.ts +28 -32
  188. package/src/importer.ts +216 -8
  189. package/src/index.ts +29 -18
  190. package/src/internal.ts +7 -0
  191. package/src/join-coverage.ts +42 -0
  192. package/src/jwt.ts +8 -1
  193. package/src/launch-context.ts +2 -2
  194. package/src/mutate-batching.ts +60 -11
  195. package/src/open-document.ts +2441 -502
  196. package/src/presence.ts +210 -11
  197. package/src/record-paging.ts +686 -137
  198. package/src/selection-persistence.ts +10 -1
  199. package/src/serializer.ts +33 -1
  200. package/src/session-persistence.ts +9 -8
  201. package/src/session-toolbar.ts +387 -89
  202. package/src/session.ts +91 -17
  203. package/src/tail-ops.ts +76 -10
  204. package/src/topic-lifecycle.ts +31 -1
  205. package/src/types.ts +56 -4
  206. package/src/undo.ts +13 -5
  207. package/src/unload-guard.ts +91 -0
  208. package/src/version-bar.ts +183 -26
  209. package/src/version.ts +1 -1
  210. package/src/vite.ts +175 -10
  211. package/src/when-saved.ts +66 -0
  212. package/src/ws-client.ts +461 -92
  213. package/src/ws-frame-limit.ts +23 -17
@@ -1,32 +1,28 @@
1
1
  // Runtime config resolution for Brass-hosted bundles.
2
2
  //
3
- // A bundle served by app hosting (`https://{slug}.onbrass.app` and non-prod
4
- // equivalents under `.onbrass.app`) gets its identity and environment from
5
- // the serving slot, not from code: the api writes
6
- // `/.well-known/brass-runtime.json` into the served prefix (see CONTRACT.md
7
- // "App hosting"), and `Brass.init()` reads it back same-origin and adopts
3
+ // A bundle served by app hosting gets its identity and environment from the
4
+ // serving slot, not from code: the platform stamps a runtime descriptor into
5
+ // the served prefix, and `Brass.init()` reads it back same-origin and adopts
8
6
  // it wholesale. It is authoritative on a hosted origin, so the same artifact
9
7
  // picks up each environment's config from whichever origin serves it and a
10
8
  // bundle that hardcodes one environment can't carry it onto another's slot.
11
9
  // `isBrassHostedHost` recognizes the default `*.onbrass.app` slots by
12
10
  // hostname; a custom serving domain (a vanity URL) is not recognizable that
13
11
  // way, so the SDK also reads the descriptor whenever a bundle relies on the
14
- // slot for its environment (no `env` / `*BaseUrl` passed to `Brass.init`),
15
- // on any host. A present descriptor is authoritative and overrides even a
12
+ // slot for its environment (no `*BaseUrl` passed to `Brass.init`), on any
13
+ // host. A present descriptor is authoritative and overrides even a
16
14
  // baked app id. A developer on their own hosting passes explicit origins and
17
15
  // never triggers the fetch.
18
16
 
19
17
  export const HOSTED_RUNTIME_PATH = '/.well-known/brass-runtime.json';
20
18
 
21
19
  // The window global a Brass serving slot stamps into the served `index.html`
22
- // (an inline script the unpack worker injects). This is the primary,
20
+ // as an inline script. This is the primary,
23
21
  // fetch-free path: the HOST decides whether a bundle is on a Brass slot by
24
22
  // whether the served page carries this global, so the SDK never guesses from
25
23
  // the hostname (a custom serving domain is indistinguishable from
26
- // self-hosting by name) nor makes a request that 404s off a slot. Must match
27
- // `HOSTED_RUNTIME_GLOBAL` in infra/lambda/lib/hosting-runtime.ts (the
28
- // published package cannot import the workspace module, so the name is
29
- // duplicated by necessity).
24
+ // self-hosting by name) nor makes a request that 404s off a slot. The name is
25
+ // part of the serving contract, so it is fixed rather than configurable.
30
26
  const HOSTED_RUNTIME_GLOBAL = '__brassRuntime';
31
27
 
32
28
  // The apex app hosting serves bundles under. Every hosting origin is some
package/src/hosted-ui.ts CHANGED
@@ -27,12 +27,8 @@
27
27
  // page learns the host origin from `init` and targets outbound at it (never
28
28
  // `*`).
29
29
 
30
- // Both sides ignore any `message` whose `data[ENVELOPE]` is not this exact
31
- // value, so unrelated postMessage traffic never reaches the handshake.
32
30
  import { BrassError } from './errors.js';
33
-
34
- const ENVELOPE = '__brass_embed';
35
- const PROTOCOL_VERSION = 1;
31
+ import { EMBED_ENVELOPE, EMBED_PROTOCOL_VERSION } from './embed-protocol.js';
36
32
 
37
33
  // No `ready` within this window means the iframe is unusable (CSP, origin
38
34
  // unreachable, bridge threw). What the window is worth is set by what the
@@ -267,14 +263,6 @@ export interface HostedUiLauncherOptions {
267
263
  // state instead of parking the remote-eviction error surface. Signalled
268
264
  // pre-flight, so the note beats the server's `doc_gone` frame.
269
265
  onLocalDelete?: (docId: string, active: boolean) => void;
270
- // The SDK's fallback platform-session pointer (`sid`), non-null only when
271
- // this browser blocked the cross-site `brass_auth` cookie and the SDK is
272
- // running in code / session-token mode. Read at dialog-open time and posted
273
- // in the bridge `init` (to the pinned dashboard origin only) so the embed
274
- // page can refresh the dashboard's own tokens when its framed cookie is
275
- // likewise blocked. Null in a normal browser, where the embed's cookie
276
- // carries its session and no hand-off is needed.
277
- getSessionToken?: () => string | null;
278
266
  // The `sub` of the user this app is currently acting as, read at
279
267
  // dialog-open time and posted in the bridge `init`. The embed page
280
268
  // authenticates from the dashboard origin's own platform-session cookie,
@@ -314,11 +302,6 @@ function redirectToStandalone(
314
302
  ): void {
315
303
  const url = new URL(`${baseUrl}/embed/${surface}`);
316
304
  for (const [key, value] of Object.entries(params)) {
317
- // `sessionToken` is an in-iframe-only credential hand-off (posted over the
318
- // bridge to the pinned dashboard origin); never let it reach a URL. On the
319
- // redirect fallback the standalone page is top-level and first-party, so
320
- // its own cookie carries the session and no hand-off is needed.
321
- if (key === 'sessionToken') continue;
322
305
  if (value !== undefined && value !== null) {
323
306
  url.searchParams.set(key, String(value));
324
307
  }
@@ -666,13 +649,13 @@ function openEmbed(
666
649
  if (event.origin !== dashboardOrigin) return;
667
650
  if (event.source !== iframe.contentWindow) return;
668
651
  const data = event.data as Record<string, unknown> | null;
669
- if (!data || data[ENVELOPE] !== PROTOCOL_VERSION) return;
652
+ if (!data || data[EMBED_ENVELOPE] !== EMBED_PROTOCOL_VERSION) return;
670
653
 
671
654
  switch (data['type']) {
672
655
  case 'ready': {
673
656
  if (readyTimer !== undefined) clearTimeout(readyTimer);
674
657
  iframe.contentWindow?.postMessage(
675
- { [ENVELOPE]: PROTOCOL_VERSION, type: 'init', surface, params },
658
+ { [EMBED_ENVELOPE]: EMBED_PROTOCOL_VERSION, type: 'init', surface, params },
676
659
  dashboardOrigin,
677
660
  );
678
661
  // Embed page mounted; fade the loader out (delayed removal so the
@@ -710,7 +693,7 @@ function openEmbed(
710
693
  const docId = typeof params['docId'] === 'string' ? params['docId'] : '';
711
694
  const reply = (outcome: ExportSerializeOutcome): void => {
712
695
  iframe.contentWindow?.postMessage(
713
- { [ENVELOPE]: PROTOCOL_VERSION, type: 'export-result', requestId, ...outcome },
696
+ { [EMBED_ENVELOPE]: EMBED_PROTOCOL_VERSION, type: 'export-result', requestId, ...outcome },
714
697
  dashboardOrigin,
715
698
  );
716
699
  };
@@ -833,6 +816,23 @@ function openEmbed(
833
816
  });
834
817
  }
835
818
 
819
+ // Warm the connection to the origin the embed pages come from. An app talks to
820
+ // the api and auth origins, never this one, so the first hosted surface a
821
+ // reader opens otherwise pays DNS, TCP and TLS before the iframe's document
822
+ // request can even start. Idempotent, since an app may build more than one
823
+ // launcher.
824
+ function preconnectToDashboard(origin: string): void {
825
+ if (typeof document === 'undefined') return;
826
+ const links = document.head.querySelectorAll('link[rel="preconnect"]');
827
+ for (const link of links) {
828
+ if (link.getAttribute('href') === origin) return;
829
+ }
830
+ const link = document.createElement('link');
831
+ link.rel = 'preconnect';
832
+ link.href = origin;
833
+ document.head.appendChild(link);
834
+ }
835
+
836
836
  export function createHostedUiLauncher(
837
837
  opts: HostedUiLauncherOptions,
838
838
  ): HostedUiLauncher {
@@ -840,6 +840,7 @@ export function createHostedUiLauncher(
840
840
  // Parsed once for the postMessage origin checks; a malformed
841
841
  // `dashboardBaseUrl` throws here, at construction, not mid-handshake.
842
842
  const dashboardOrigin = new URL(baseUrl).origin;
843
+ preconnectToDashboard(dashboardOrigin);
843
844
  // Watch for activations from here on, so a surface opened later knows which
844
845
  // control the reader pressed to get there.
845
846
  trackHostedUiActivations();
@@ -847,21 +848,16 @@ export function createHostedUiLauncher(
847
848
  // would put a dark embed inside a light host on a dark-OS user.
848
849
  const theme: HostedUiTheme = opts.theme ?? 'light';
849
850
 
850
- // Merge the SDK's fallback session pointer into a surface's bridge params
851
- // when this browser blocked the cross-site cookie (so the embed page can
852
- // refresh the dashboard's tokens without its own framed cookie). Read at
853
- // open time, not launcher-construction time, since the sid is populated by
854
- // the first fallback refresh, which may land after the launcher is built.
855
- // Every embed surface authenticates the same way, so this is cross-cutting
856
- // rather than per-surface like `appId`.
857
- const withSession = (
851
+ // Name the user this app is acting as in every surface's bridge params.
852
+ // Read at open time, not launcher-construction time, since the app may
853
+ // authenticate after the launcher is built. Every embed surface checks the
854
+ // same way, so this is cross-cutting rather than per-surface like `appId`.
855
+ const withActingSub = (
858
856
  params: Record<string, unknown>,
859
857
  ): Record<string, unknown> => {
860
- const sid = opts.getSessionToken?.();
861
858
  const actingSub = opts.getActingSub?.();
862
859
  return {
863
860
  ...params,
864
- ...(sid ? { sessionToken: sid } : {}),
865
861
  ...(actingSub ? { actingSub } : {}),
866
862
  };
867
863
  };
@@ -880,7 +876,7 @@ export function createHostedUiLauncher(
880
876
  baseUrl,
881
877
  dashboardOrigin,
882
878
  surface,
883
- withSession(params),
879
+ withActingSub(params),
884
880
  unavailable,
885
881
  theme,
886
882
  exportDoc ? opts.serializeForExport : undefined,
package/src/importer.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { Document } from './documents.js';
2
2
  import type { DocumentSourceBlob } from './documents-namespace.js';
3
3
  import { BrassError } from './errors.js';
4
+ import { whenSaved } from './when-saved.js';
4
5
 
5
6
  // The app's import half for a file Brass does not parse: given the stored
6
7
  // source file and the open document, parse the bytes and write the records
@@ -38,6 +39,35 @@ export interface ImportRunner {
38
39
  attempt(doc: Document): Promise<void>;
39
40
  }
40
41
 
42
+ // The claim holder can close its tab mid-import and let its claim expire, so
43
+ // a client that lost the race asks again instead of spending this document's
44
+ // one attempt on it.
45
+ //
46
+ // A holder EXTENDS its claim while it runs (`startClaimRefresh`), so the
47
+ // expiry a loser is waiting out is not the platform's base TTL but
48
+ // `last-refresh + TTL`, up to `CLAIM_REFRESH_MAX` refreshes away. A schedule
49
+ // sized against the base TTL therefore gives up while the claim is still
50
+ // live, and what it leaves behind is a reader sitting on an empty document
51
+ // with no further attempt coming and nothing said. So the tail keeps asking,
52
+ // at an interval, for longer than a holder can hold.
53
+ const PENDING_RETRY_DELAYS_MS = [15_000, 45_000, 135_000];
54
+ const PENDING_RETRY_INTERVAL_MS = 120_000;
55
+ // Enough tail attempts to outlast a holder refreshing to its own bound, and
56
+ // far inside the per-caller claim budget.
57
+ const PENDING_RETRY_TAIL = 18;
58
+
59
+ // A claim expires while a long import is still parsing or still waiting for
60
+ // its writes to be acked, and the completion needs it, so the runner
61
+ // re-claims on this interval while its own import runs. Bounded so a
62
+ // document whose writes never drain stops re-claiming.
63
+ const CLAIM_REFRESH_MS = 60_000;
64
+ const CLAIM_REFRESH_MAX = 30;
65
+
66
+ // The status the platform refuses an import the app cannot run with: its
67
+ // `imports` do not claim the file, it declares no schema, or the document
68
+ // holds a half-written import.
69
+ const CLAIM_REFUSED_STATUS = 422;
70
+
41
71
  // Runs the import protocol for one opened document: claim, and on the
42
72
  // win fetch + parse + complete. Separated from the client so the
43
73
  // protocol is testable against mocked HTTP deps.
@@ -46,12 +76,63 @@ export function createImportRunner(deps: ImportRunnerDeps): ImportRunner {
46
76
  // One attempt per Document instance; a reopen mints a new instance,
47
77
  // whose claim resolves `already_imported` as a no-op.
48
78
  const attempted = new WeakSet<Document>();
49
- return {
79
+ // Documents waiting on server-side work, holding the detach for the status
80
+ // listener that is watching it. Kept apart from `attempted` so the wait does
81
+ // not consume the attempt, and holding the DETACH rather than a bare mark so
82
+ // a second `attempt` call replaces the listener instead of stacking one.
83
+ //
84
+ // Replacing rather than returning early is what survives a park. An app's
85
+ // `close()` parks the Document, which drops every status listener, and the
86
+ // park cache hands the SAME instance back on reopen; a mark alone would
87
+ // still say "someone is watching this" when nothing is, and the reopened
88
+ // document would never attempt its import again for the life of the page.
89
+ const waiting = new WeakMap<Document, () => void>();
90
+ // Retries already scheduled against another client's live claim.
91
+ const pendingRetries = new WeakMap<Document, number>();
92
+ const runner: ImportRunner = {
50
93
  setImporter(fn: DocumentImporter): void {
51
94
  importer = fn;
52
95
  },
53
96
  async attempt(doc: Document): Promise<void> {
54
97
  if (importer === null || doc.id === null || attempted.has(doc)) return;
98
+ // Claiming an import binds the document to THIS client for the claim's
99
+ // lifetime, and only this client can complete it. A document that is
100
+ // closed or parked can no longer read its source or write records, so a
101
+ // claim taken for one is a claim nothing will ever complete: every other
102
+ // open client is refused until the platform's claim expires. The retry
103
+ // timer below and the resumed-open path both reach here with a document
104
+ // that may have gone since, so the gate belongs at the entry rather than
105
+ // at each caller.
106
+ if (!doc._isLive) return;
107
+ // A document with server-side work in flight has no source file to
108
+ // read yet. A drive-linked file is fetched from the drive and stored
109
+ // by a worker AFTER the open returns, so claiming now asks to import
110
+ // bytes that have not arrived: the platform refuses, and this
111
+ // document's one attempt is spent on a document that was never
112
+ // eligible. Wait for the work the server reports, then attempt.
113
+ if (doc.status?.pending === true) {
114
+ // Any listener from an earlier attempt goes first, so the document is
115
+ // watched exactly once whether or not the previous one survived.
116
+ waiting.get(doc)?.();
117
+ waiting.delete(doc);
118
+ try {
119
+ const off = doc.onStatus((status) => {
120
+ if (status.pending) return;
121
+ waiting.get(doc)?.();
122
+ waiting.delete(doc);
123
+ void runner.attempt(doc);
124
+ });
125
+ waiting.set(doc, off);
126
+ } catch {
127
+ // The document closed while attaching; nothing left to import
128
+ // into, and a reopen mints a fresh instance that attempts again.
129
+ }
130
+ return;
131
+ }
132
+ // The work finished, so nothing is left to wait for; drop the listener
133
+ // rather than leave it to fire against a document already past it.
134
+ waiting.get(doc)?.();
135
+ waiting.delete(doc);
55
136
  attempted.add(doc);
56
137
  const run = importer;
57
138
  const docId = doc.id;
@@ -59,10 +140,19 @@ export function createImportRunner(deps: ImportRunnerDeps): ImportRunner {
59
140
  try {
60
141
  claim = await deps.claimImport(docId);
61
142
  } catch (err) {
62
- // The common no-op outcomes arrive as 4xx: not a stored source
63
- // file, read-only access, another client's claim in flight. Only
64
- // a server fault surfaces.
65
- if (err instanceof BrassError && err.status !== undefined && err.status < 500) {
143
+ const status = err instanceof BrassError ? err.status : undefined;
144
+ if (status === 409) {
145
+ schedulePendingRetry(doc);
146
+ return;
147
+ }
148
+ // A refusal names what the developer has to change, so it reaches
149
+ // the app's error handler; the rest of the 4xx surface (nothing
150
+ // stored to import, read-only access) is a no-op.
151
+ if (
152
+ status !== undefined &&
153
+ status < 500 &&
154
+ status !== CLAIM_REFUSED_STATUS
155
+ ) {
66
156
  return;
67
157
  }
68
158
  deps.reportError(asError(err));
@@ -71,19 +161,137 @@ export function createImportRunner(deps: ImportRunnerDeps): ImportRunner {
71
161
  if (claim.claimed !== true) return;
72
162
  // This client won the claim: run the importer, then report
73
163
  // completion so the platform stamps the document with the app's
74
- // schema. A throw leaves the claim to expire, so another client
75
- // can retry rather than the document staying half-claimed.
164
+ // schema. A throw skips the completion and leaves the claim to
165
+ // expire; the platform decides what a later claim may do with a
166
+ // document the failed run already wrote records into.
76
167
  try {
77
168
  const source = await doc.sourceBlob();
78
- await run(source, doc);
169
+ const stopRefresh = startClaimRefresh(docId, doc);
170
+ // Whether the importer put anything on the wire. A commit fires the
171
+ // document's subscribers synchronously and raises `savingCount` as it
172
+ // does, so a run that wrote is seen here even when its ops ack before
173
+ // it returns.
174
+ let wroteRecords = false;
175
+ const stopWatchingWrites = doc.subscribe(() => {
176
+ if (doc.savingCount > 0) wroteRecords = true;
177
+ });
178
+ let saved = false;
179
+ try {
180
+ await run(source, doc);
181
+ // The importer's ops are still in flight when it returns, and
182
+ // the completion stamps the version the import produced from
183
+ // the head it lands on. Completing before they are acked stamps
184
+ // a version below the records' own, which reads as a document
185
+ // edited since it was uploaded from then on.
186
+ saved = await whenSaved(doc);
187
+ } finally {
188
+ stopRefresh();
189
+ stopWatchingWrites();
190
+ }
191
+ // The wait ends on the document going, and on the acks stopping, as
192
+ // well as on the writes draining, and only the last of those means
193
+ // the records are stored. The completion stamps the document with the
194
+ // app's schema at the version it lands on, so running it over writes
195
+ // that never reached the server records an import of records the
196
+ // document does not hold. Abandoning instead leaves the claim to
197
+ // expire, and a client that is still open imports the file properly.
198
+ if (!saved) return;
199
+ // An importer that returns without writing anything must not stamp the
200
+ // document imported: the stamp is permanent, a later open reads
201
+ // `already_imported`, and the file's records then have no path into a
202
+ // document that holds none. `try { parse() } catch { return }` is an
203
+ // ordinary shape, so this is reachable from app code that looks
204
+ // correct. Both signals are required before skipping, so an import
205
+ // that DID write is completed either way and never runs twice.
206
+ if (!wroteRecords && isEmptyDocument(doc)) {
207
+ deps.reportError(
208
+ new Error(
209
+ 'The importer wrote no records, so this file was not imported. ' +
210
+ 'Reload to try again.',
211
+ ),
212
+ );
213
+ return;
214
+ }
79
215
  await deps.completeImport(docId);
80
216
  } catch (err) {
81
217
  deps.reportError(asError(err));
82
218
  }
83
219
  },
84
220
  };
221
+
222
+ function schedulePendingRetry(doc: Document): void {
223
+ const taken = pendingRetries.get(doc) ?? 0;
224
+ const delay =
225
+ PENDING_RETRY_DELAYS_MS[taken] ??
226
+ (taken < PENDING_RETRY_DELAYS_MS.length + PENDING_RETRY_TAIL
227
+ ? PENDING_RETRY_INTERVAL_MS
228
+ : undefined);
229
+ if (delay === undefined) {
230
+ // Out of attempts with the file still unimported. The reader is looking
231
+ // at an empty document, so say so rather than leaving them to conclude
232
+ // the file was empty.
233
+ deps.reportError(
234
+ new Error(
235
+ 'Could not import this file: another window has been importing it ' +
236
+ 'for longer than expected. Reload to try again.',
237
+ ),
238
+ );
239
+ return;
240
+ }
241
+ pendingRetries.set(doc, taken + 1);
242
+ attempted.delete(doc);
243
+ setTimeout(() => {
244
+ void runner.attempt(doc);
245
+ }, delay);
246
+ }
247
+
248
+ function startClaimRefresh(docId: string, doc: Document): () => void {
249
+ let timer: ReturnType<typeof setTimeout> | undefined;
250
+ let left = CLAIM_REFRESH_MAX;
251
+ const tick = (): void => {
252
+ if (left <= 0) return;
253
+ // A renewal from a document that has gone tells the platform the holder
254
+ // is still working, when this client can no longer complete the import
255
+ // at all. Stopping here is what lets the claim expire on its own TTL so
256
+ // a client that IS open picks the file up, rather than being refused for
257
+ // the whole refresh budget.
258
+ if (!doc._isLive) return;
259
+ left -= 1;
260
+ // A failed refresh needs no handling: the completion reports
261
+ // whether the claim survived.
262
+ void deps.claimImport(docId).catch(() => undefined);
263
+ timer = setTimeout(tick, CLAIM_REFRESH_MS);
264
+ };
265
+ timer = setTimeout(tick, CLAIM_REFRESH_MS);
266
+ return () => {
267
+ clearTimeout(timer);
268
+ left = 0;
269
+ };
270
+ }
271
+
272
+ return runner;
85
273
  }
86
274
 
87
275
  function asError(err: unknown): Error {
88
276
  return err instanceof Error ? err : new Error(String(err));
89
277
  }
278
+
279
+ // Whether the document holds nothing at all, read only when no write was
280
+ // observed, so the walk is over an empty or near-empty document.
281
+ //
282
+ // A document reports its ROOTS whatever it holds (a fresh one reads
283
+ // `{ entities: {} }`), so emptiness is every root being empty rather than
284
+ // there being no roots.
285
+ function isEmptyDocument(doc: Document): boolean {
286
+ try {
287
+ return Object.values(doc.toJSON()).every(
288
+ (root) =>
289
+ root === null ||
290
+ typeof root !== 'object' ||
291
+ Object.keys(root as object).length === 0,
292
+ );
293
+ } catch {
294
+ // A document that cannot be read is not one to declare empty.
295
+ return false;
296
+ }
297
+ }
package/src/index.ts CHANGED
@@ -1,3 +1,12 @@
1
+ // The app-facing entry (`@brass-build/client`). A type reaches this barrel
2
+ // only when a member of `Brass`, `Document`, or `DocumentSession` names it
3
+ // in a signature, so what an app's autocomplete offers is what an app can
4
+ // call. The plumbing under those members (the session's dependency seam,
5
+ // the document factory, the join and at-version wire shapes) stays exported
6
+ // from its own module for this package's own use, and `./internal` carries
7
+ // the primitives Brass's own dashboard and console import.
8
+ // `__tests__/exports-contract.test.ts` pins both sets.
9
+
1
10
  import { BrassClient } from './client.js';
2
11
  import type {
3
12
  CreateDocumentOptions,
@@ -14,6 +23,7 @@ import type {
14
23
  ErrorListener,
15
24
  BrassInitOptions,
16
25
  BrassUser,
26
+ UndoControllerOptions,
17
27
  UserChangeListener,
18
28
  } from './types.js';
19
29
 
@@ -22,13 +32,11 @@ export type {
22
32
  ErrorListener,
23
33
  BrassInitOptions,
24
34
  BrassUser,
35
+ UndoControllerOptions,
25
36
  UserChangeListener,
26
37
  };
27
38
  export { BrassError, type BrassErrorCode } from './errors.js';
28
39
  export {
29
- type AcquireOrganizationResult,
30
- type HostedUiLauncher,
31
- type HostedUiLauncherOptions,
32
40
  type HostedUiTheme,
33
41
  type SelectDocumentResult,
34
42
  } from './hosted-ui.js';
@@ -52,7 +60,6 @@ export {
52
60
  type CommandGroup,
53
61
  type CommandSurface,
54
62
  type DocumentSession,
55
- type DocumentSessionDeps,
56
63
  type SessionOpenOptions,
57
64
  type SessionStatus,
58
65
  } from './session.js';
@@ -96,34 +103,38 @@ export type {
96
103
  ChangeSource,
97
104
  HandleUnsubscribe,
98
105
  ListHandle,
106
+ ListChange,
99
107
  ListSubscriber,
108
+ Splice,
100
109
  MapHandle,
101
110
  MapSubscriber,
102
111
  OrderedMapHandle,
103
- } from './handles.js';
104
- export type { UndoControllerOptions } from './undo.js';
105
- export type { DocumentSerializer } from './serializer.js';
112
+ OrderedMapPosition,
113
+ } from './handle-types.js';
114
+
115
+ export type {
116
+ DocumentSerializer,
117
+ StoredDocument,
118
+ StoredList,
119
+ StoredMap,
120
+ } from './serializer.js';
106
121
  export type { DocumentImporter } from './importer.js';
107
- export { DocumentStream, type StreamRuntime } from './document-streams.js';
122
+ export { DocumentStream } from './document-streams.js';
108
123
  export {
109
- type CreateDocumentFn,
110
124
  type CreateDocumentOptions,
111
- type DocumentAtVersionResponse,
112
- type DocumentDriveSummaryWire,
113
- type DocumentFactory,
114
- type DocumentSnapshotResponse,
115
- type OpenDocumentFactoryOptions,
116
- type OpenDocumentByIdFn,
117
125
  type OpenDocumentByIdOptions,
118
- type OpenDocumentWsClient,
119
126
  type OpenedDocument,
120
127
  } from './open-document.js';
128
+ export { PRESENCE_LAYER_MAX_BYTES } from './presence.js';
121
129
  export type {
122
130
  PresenceChangeListener,
123
131
  PresenceClient,
132
+ PresenceLayer,
124
133
  PresenceParticipant,
125
134
  PresenceSetInput,
126
135
  PresenceState,
136
+ PresenceStatus,
137
+ PresenceStatusListener,
127
138
  } from './presence.js';
128
139
 
129
140
  const client = new BrassClient();
@@ -169,8 +180,8 @@ export const Brass = {
169
180
  getAccessToken(): Promise<string | null> {
170
181
  return client.getAccessToken();
171
182
  },
172
- // Metadata read for a known id (`get`) plus thumbnails / branches /
173
- // pushToDrive. Create / move / list / grant access are hosted-only.
183
+ // Metadata read for a known id (`get`) plus thumbnails. Create / move /
184
+ // list / grant access are hosted-only.
174
185
  get documents(): DocumentsNamespace {
175
186
  return client.documents;
176
187
  },
package/src/internal.ts CHANGED
@@ -16,8 +16,15 @@ export {
16
16
  buildSignInUrl,
17
17
  refreshTokens,
18
18
  type RefreshCredential,
19
+ type RefreshOutcome,
19
20
  type RefreshResult,
20
21
  type TokenSet,
21
22
  } from './auth-flow.js';
22
23
  export { decodeIdToken } from './jwt.js';
24
+ export {
25
+ fetchWithRetry,
26
+ RETRY_READ,
27
+ type RetryOptions,
28
+ type RetryPolicy,
29
+ } from './fetch-retry.js';
23
30
  export { BRASS_INIT_ARG_KEYS, type BrassInitArgKey } from './init-keys.js';
@@ -0,0 +1,42 @@
1
+ // Which seqs a join response actually delivered.
2
+ //
3
+ // A join carries a snapshot sealed at `snapshot_seq`, the op rows above it,
4
+ // and `current_seq`, the branch's seq counter. The counter is NOT a delivery
5
+ // claim: the server allocates a seq on it before writing that op's row, so
6
+ // while someone is writing in bulk the counter names ops whose rows the
7
+ // tail query cannot see yet. A reader that treats the counter as covered
8
+ // records itself as holding ops it was never sent, and every later prompt
9
+ // for them short-circuits on that record.
10
+ //
11
+ // What a response can vouch for is the snapshot plus the unbroken run of
12
+ // tail seqs above it. Anything past the first gap is applied but not
13
+ // covered, so it parks until the gap fills.
14
+
15
+ /** The contiguous floor a join delivered: `snapshotSeq` plus its tail run. */
16
+ export function joinCoverageFloor(
17
+ snapshotSeq: number,
18
+ tailSeqs: readonly number[],
19
+ ): number {
20
+ const present = new Set(tailSeqs);
21
+ let floor = snapshotSeq;
22
+ while (present.has(floor + 1)) floor += 1;
23
+ return floor;
24
+ }
25
+
26
+ /**
27
+ * Where the snapshot in a join response ends, for a response that may not
28
+ * say. Our server always reports `snapshot_seq`; a response without it
29
+ * (an older server, a fixture) leaves only the tail's own lower edge, which
30
+ * is the true boundary except when the first op above the snapshot is one of
31
+ * the rows still in flight. With no tail at all there is nothing to infer
32
+ * from, and the counter is all that is on offer.
33
+ */
34
+ export function snapshotSeqOf(
35
+ reported: number | undefined,
36
+ tailSeqs: readonly number[],
37
+ currentSeq: number,
38
+ ): number {
39
+ if (typeof reported === 'number') return reported;
40
+ if (tailSeqs.length === 0) return currentSeq;
41
+ return Math.min(...tailSeqs) - 1;
42
+ }
package/src/jwt.ts CHANGED
@@ -6,7 +6,14 @@ export function decodeIdToken(idToken: string): BrassUser | null {
6
6
  const payloadPart = parts[1];
7
7
  if (!payloadPart) return null;
8
8
  try {
9
- const json = atob(payloadPart.replace(/-/g, '+').replace(/_/g, '/'));
9
+ // A JWT payload is UTF-8, and `atob` answers one latin1 character per
10
+ // byte, so a claim with any non-ASCII character (an accented name in
11
+ // `email`) needs the bytes decoded rather than the string used as is.
12
+ const bytes = Uint8Array.from(
13
+ atob(payloadPart.replace(/-/g, '+').replace(/_/g, '/')),
14
+ (ch) => ch.charCodeAt(0),
15
+ );
16
+ const json = new TextDecoder().decode(bytes);
10
17
  const claims = JSON.parse(json) as { sub?: unknown; email?: unknown };
11
18
  if (typeof claims.sub !== 'string') return null;
12
19
  const user: BrassUser = { sub: claims.sub };
@@ -10,8 +10,8 @@
10
10
  // collide with an app's own query string, and stripped from the address bar
11
11
  // on read so it does not linger in history or a referrer.
12
12
  //
13
- // The dashboard writes this parameter in `src/lib/open-app-url.ts`; the
14
- // published SDK cannot import the workspace, so the name is spelled in both
13
+ // The dashboard writes this parameter when it opens an app, and the published
14
+ // SDK cannot import the dashboard's source, so the name is spelled in both
15
15
  // places.
16
16
 
17
17
  export const LAUNCH_ORGANIZATION_PARAM = 'brass_org';