@brass-build/client 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. package/AGENTS.md +72 -63
  2. package/CHANGELOG.md +86 -0
  3. package/README.md +317 -231
  4. package/dist/auth-flow.d.ts +10 -1
  5. package/dist/auth-flow.d.ts.map +1 -1
  6. package/dist/auth-flow.js +64 -20
  7. package/dist/auth-flow.js.map +1 -1
  8. package/dist/client.d.ts +3 -0
  9. package/dist/client.d.ts.map +1 -1
  10. package/dist/client.js +217 -75
  11. package/dist/client.js.map +1 -1
  12. package/dist/document-engine.d.ts +4 -0
  13. package/dist/document-engine.d.ts.map +1 -0
  14. package/dist/document-engine.js +9 -0
  15. package/dist/document-engine.js.map +1 -0
  16. package/dist/document-streams.d.ts +8 -1
  17. package/dist/document-streams.d.ts.map +1 -1
  18. package/dist/document-streams.js +79 -8
  19. package/dist/document-streams.js.map +1 -1
  20. package/dist/documents-namespace.d.ts.map +1 -1
  21. package/dist/documents-namespace.js +19 -3
  22. package/dist/documents-namespace.js.map +1 -1
  23. package/dist/documents.d.ts +43 -14
  24. package/dist/documents.d.ts.map +1 -1
  25. package/dist/documents.js +871 -223
  26. package/dist/documents.js.map +1 -1
  27. package/dist/embed-protocol.d.ts +3 -0
  28. package/dist/embed-protocol.d.ts.map +1 -0
  29. package/dist/embed-protocol.js +26 -0
  30. package/dist/embed-protocol.js.map +1 -0
  31. package/dist/emitter.d.ts.map +1 -1
  32. package/dist/emitter.js +8 -1
  33. package/dist/emitter.js.map +1 -1
  34. package/dist/export-guest.d.ts.map +1 -1
  35. package/dist/export-guest.js +14 -6
  36. package/dist/export-guest.js.map +1 -1
  37. package/dist/fetch-deadline.d.ts +22 -0
  38. package/dist/fetch-deadline.d.ts.map +1 -0
  39. package/dist/fetch-deadline.js +96 -0
  40. package/dist/fetch-deadline.js.map +1 -0
  41. package/dist/fetch-retry.d.ts +1 -0
  42. package/dist/fetch-retry.d.ts.map +1 -1
  43. package/dist/fetch-retry.js +12 -5
  44. package/dist/fetch-retry.js.map +1 -1
  45. package/dist/handle-types.d.ts +150 -0
  46. package/dist/handle-types.d.ts.map +1 -0
  47. package/dist/handle-types.js +8 -0
  48. package/dist/handle-types.js.map +1 -0
  49. package/dist/handles.d.ts +48 -48
  50. package/dist/handles.d.ts.map +1 -1
  51. package/dist/handles.js +3122 -501
  52. package/dist/handles.js.map +1 -1
  53. package/dist/hosted-runtime.d.ts.map +1 -1
  54. package/dist/hosted-runtime.js +8 -12
  55. package/dist/hosted-runtime.js.map +1 -1
  56. package/dist/hosted-ui.d.ts +0 -1
  57. package/dist/hosted-ui.d.ts.map +1 -1
  58. package/dist/hosted-ui.js +29 -24
  59. package/dist/hosted-ui.js.map +1 -1
  60. package/dist/importer.d.ts.map +1 -1
  61. package/dist/importer.js +253 -8
  62. package/dist/importer.js.map +1 -1
  63. package/dist/index.d.ts +10 -10
  64. package/dist/index.d.ts.map +1 -1
  65. package/dist/index.js +11 -2
  66. package/dist/index.js.map +1 -1
  67. package/dist/internal.d.ts +1 -1
  68. package/dist/internal.d.ts.map +1 -1
  69. package/dist/internal.js.map +1 -1
  70. package/dist/join-coverage.d.ts +12 -0
  71. package/dist/join-coverage.d.ts.map +1 -0
  72. package/dist/join-coverage.js +37 -0
  73. package/dist/join-coverage.js.map +1 -0
  74. package/dist/jwt.d.ts.map +1 -1
  75. package/dist/jwt.js +5 -1
  76. package/dist/jwt.js.map +1 -1
  77. package/dist/launch-context.js +2 -2
  78. package/dist/launch-context.js.map +1 -1
  79. package/dist/mutate-batching.d.ts +25 -2
  80. package/dist/mutate-batching.d.ts.map +1 -1
  81. package/dist/mutate-batching.js +57 -10
  82. package/dist/mutate-batching.js.map +1 -1
  83. package/dist/open-document.d.ts +15 -1
  84. package/dist/open-document.d.ts.map +1 -1
  85. package/dist/open-document.js +1851 -321
  86. package/dist/open-document.js.map +1 -1
  87. package/dist/presence.d.ts +28 -0
  88. package/dist/presence.d.ts.map +1 -1
  89. package/dist/presence.js +149 -7
  90. package/dist/presence.js.map +1 -1
  91. package/dist/record-paging.d.ts +336 -71
  92. package/dist/record-paging.d.ts.map +1 -1
  93. package/dist/record-paging.js +591 -127
  94. package/dist/record-paging.js.map +1 -1
  95. package/dist/selection-persistence.d.ts.map +1 -1
  96. package/dist/selection-persistence.js +11 -1
  97. package/dist/selection-persistence.js.map +1 -1
  98. package/dist/serializer.d.ts +30 -1
  99. package/dist/serializer.d.ts.map +1 -1
  100. package/dist/session-persistence.d.ts.map +1 -1
  101. package/dist/session-persistence.js +9 -8
  102. package/dist/session-persistence.js.map +1 -1
  103. package/dist/session-toolbar.d.ts +0 -1
  104. package/dist/session-toolbar.d.ts.map +1 -1
  105. package/dist/session-toolbar.js +226 -75
  106. package/dist/session-toolbar.js.map +1 -1
  107. package/dist/session.d.ts.map +1 -1
  108. package/dist/session.js +87 -18
  109. package/dist/session.js.map +1 -1
  110. package/dist/tail-ops.d.ts +14 -4
  111. package/dist/tail-ops.d.ts.map +1 -1
  112. package/dist/tail-ops.js +75 -15
  113. package/dist/tail-ops.js.map +1 -1
  114. package/dist/topic-lifecycle.d.ts +4 -0
  115. package/dist/topic-lifecycle.d.ts.map +1 -1
  116. package/dist/topic-lifecycle.js +25 -1
  117. package/dist/topic-lifecycle.js.map +1 -1
  118. package/dist/types.d.ts +47 -1
  119. package/dist/types.d.ts.map +1 -1
  120. package/dist/undo.d.ts +2 -3
  121. package/dist/undo.d.ts.map +1 -1
  122. package/dist/undo.js +11 -0
  123. package/dist/undo.js.map +1 -1
  124. package/dist/unload-guard.d.ts +5 -0
  125. package/dist/unload-guard.d.ts.map +1 -0
  126. package/dist/unload-guard.js +88 -0
  127. package/dist/unload-guard.js.map +1 -0
  128. package/dist/version-bar.d.ts +4 -3
  129. package/dist/version-bar.d.ts.map +1 -1
  130. package/dist/version-bar.js +140 -19
  131. package/dist/version-bar.js.map +1 -1
  132. package/dist/version.d.ts +2 -2
  133. package/dist/version.js +1 -1
  134. package/dist/vite.d.ts +18 -2
  135. package/dist/vite.d.ts.map +1 -1
  136. package/dist/vite.js +133 -8
  137. package/dist/vite.js.map +1 -1
  138. package/dist/ws-client.d.ts +23 -3
  139. package/dist/ws-client.d.ts.map +1 -1
  140. package/dist/ws-client.js +339 -89
  141. package/dist/ws-client.js.map +1 -1
  142. package/dist/ws-frame-limit.d.ts.map +1 -1
  143. package/dist/ws-frame-limit.js +19 -12
  144. package/dist/ws-frame-limit.js.map +1 -1
  145. package/docs/app-manifest.md +7 -8
  146. package/docs/custom-chrome.md +17 -20
  147. package/docs/hosted-ui-advanced.md +14 -17
  148. package/docs/imported-documents.md +53 -197
  149. package/docs/presence.md +45 -21
  150. package/docs/schemas.md +12 -17
  151. package/docs/streams.md +55 -57
  152. package/package.json +4 -3
  153. package/src/auth-flow.ts +90 -23
  154. package/src/client.ts +225 -78
  155. package/src/document-engine.ts +15 -0
  156. package/src/document-signals.ts +7 -7
  157. package/src/document-streams.ts +95 -9
  158. package/src/documents-namespace.ts +18 -3
  159. package/src/documents.ts +983 -277
  160. package/src/embed-protocol.ts +25 -0
  161. package/src/emitter.ts +8 -1
  162. package/src/export-guest.ts +15 -9
  163. package/src/fetch-deadline.ts +119 -0
  164. package/src/fetch-retry.ts +14 -5
  165. package/src/handle-types.ts +303 -0
  166. package/src/handles.ts +3406 -683
  167. package/src/hosted-runtime.ts +8 -12
  168. package/src/hosted-ui.ts +28 -32
  169. package/src/importer.ts +261 -8
  170. package/src/index.ts +29 -18
  171. package/src/internal.ts +1 -0
  172. package/src/join-coverage.ts +42 -0
  173. package/src/jwt.ts +8 -1
  174. package/src/launch-context.ts +2 -2
  175. package/src/mutate-batching.ts +60 -11
  176. package/src/open-document.ts +2355 -515
  177. package/src/presence.ts +210 -11
  178. package/src/record-paging.ts +687 -137
  179. package/src/selection-persistence.ts +10 -1
  180. package/src/serializer.ts +33 -1
  181. package/src/session-persistence.ts +9 -8
  182. package/src/session-toolbar.ts +249 -80
  183. package/src/session.ts +91 -17
  184. package/src/tail-ops.ts +76 -10
  185. package/src/topic-lifecycle.ts +31 -1
  186. package/src/types.ts +56 -4
  187. package/src/undo.ts +13 -5
  188. package/src/unload-guard.ts +91 -0
  189. package/src/version-bar.ts +163 -25
  190. package/src/version.ts +1 -1
  191. package/src/vite.ts +175 -10
  192. package/src/ws-client.ts +392 -92
  193. package/src/ws-frame-limit.ts +19 -12
@@ -1,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
@@ -38,6 +38,35 @@ export interface ImportRunner {
38
38
  attempt(doc: Document): Promise<void>;
39
39
  }
40
40
 
41
+ // The claim holder can close its tab mid-import and let its claim expire, so
42
+ // a client that lost the race asks again instead of spending this document's
43
+ // one attempt on it.
44
+ //
45
+ // A holder EXTENDS its claim while it runs (`startClaimRefresh`), so the
46
+ // expiry a loser is waiting out is not the platform's base TTL but
47
+ // `last-refresh + TTL`, up to `CLAIM_REFRESH_MAX` refreshes away. A schedule
48
+ // sized against the base TTL therefore gives up while the claim is still
49
+ // live, and what it leaves behind is a reader sitting on an empty document
50
+ // with no further attempt coming and nothing said. So the tail keeps asking,
51
+ // at an interval, for longer than a holder can hold.
52
+ const PENDING_RETRY_DELAYS_MS = [15_000, 45_000, 135_000];
53
+ const PENDING_RETRY_INTERVAL_MS = 120_000;
54
+ // Enough tail attempts to outlast a holder refreshing to its own bound, and
55
+ // far inside the per-caller claim budget.
56
+ const PENDING_RETRY_TAIL = 18;
57
+
58
+ // A claim expires while a long import is still parsing or still waiting for
59
+ // its writes to be acked, and the completion needs it, so the runner
60
+ // re-claims on this interval while its own import runs. Bounded so a
61
+ // document whose writes never drain stops re-claiming.
62
+ const CLAIM_REFRESH_MS = 60_000;
63
+ const CLAIM_REFRESH_MAX = 30;
64
+
65
+ // The status the platform refuses an import the app cannot run with: its
66
+ // `imports` do not claim the file, it declares no schema, or the document
67
+ // holds a half-written import.
68
+ const CLAIM_REFUSED_STATUS = 422;
69
+
41
70
  // Runs the import protocol for one opened document: claim, and on the
42
71
  // win fetch + parse + complete. Separated from the client so the
43
72
  // protocol is testable against mocked HTTP deps.
@@ -46,12 +75,63 @@ export function createImportRunner(deps: ImportRunnerDeps): ImportRunner {
46
75
  // One attempt per Document instance; a reopen mints a new instance,
47
76
  // whose claim resolves `already_imported` as a no-op.
48
77
  const attempted = new WeakSet<Document>();
49
- return {
78
+ // Documents waiting on server-side work, holding the detach for the status
79
+ // listener that is watching it. Kept apart from `attempted` so the wait does
80
+ // not consume the attempt, and holding the DETACH rather than a bare mark so
81
+ // a second `attempt` call replaces the listener instead of stacking one.
82
+ //
83
+ // Replacing rather than returning early is what survives a park. An app's
84
+ // `close()` parks the Document, which drops every status listener, and the
85
+ // park cache hands the SAME instance back on reopen; a mark alone would
86
+ // still say "someone is watching this" when nothing is, and the reopened
87
+ // document would never attempt its import again for the life of the page.
88
+ const waiting = new WeakMap<Document, () => void>();
89
+ // Retries already scheduled against another client's live claim.
90
+ const pendingRetries = new WeakMap<Document, number>();
91
+ const runner: ImportRunner = {
50
92
  setImporter(fn: DocumentImporter): void {
51
93
  importer = fn;
52
94
  },
53
95
  async attempt(doc: Document): Promise<void> {
54
96
  if (importer === null || doc.id === null || attempted.has(doc)) return;
97
+ // Claiming an import binds the document to THIS client for the claim's
98
+ // lifetime, and only this client can complete it. A document that is
99
+ // closed or parked can no longer read its source or write records, so a
100
+ // claim taken for one is a claim nothing will ever complete: every other
101
+ // open client is refused until the platform's claim expires. The retry
102
+ // timer below and the resumed-open path both reach here with a document
103
+ // that may have gone since, so the gate belongs at the entry rather than
104
+ // at each caller.
105
+ if (!doc._isLive) return;
106
+ // A document with server-side work in flight has no source file to
107
+ // read yet. A drive-linked file is fetched from the drive and stored
108
+ // by a worker AFTER the open returns, so claiming now asks to import
109
+ // bytes that have not arrived: the platform refuses, and this
110
+ // document's one attempt is spent on a document that was never
111
+ // eligible. Wait for the work the server reports, then attempt.
112
+ if (doc.status?.pending === true) {
113
+ // Any listener from an earlier attempt goes first, so the document is
114
+ // watched exactly once whether or not the previous one survived.
115
+ waiting.get(doc)?.();
116
+ waiting.delete(doc);
117
+ try {
118
+ const off = doc.onStatus((status) => {
119
+ if (status.pending) return;
120
+ waiting.get(doc)?.();
121
+ waiting.delete(doc);
122
+ void runner.attempt(doc);
123
+ });
124
+ waiting.set(doc, off);
125
+ } catch {
126
+ // The document closed while attaching; nothing left to import
127
+ // into, and a reopen mints a fresh instance that attempts again.
128
+ }
129
+ return;
130
+ }
131
+ // The work finished, so nothing is left to wait for; drop the listener
132
+ // rather than leave it to fire against a document already past it.
133
+ waiting.get(doc)?.();
134
+ waiting.delete(doc);
55
135
  attempted.add(doc);
56
136
  const run = importer;
57
137
  const docId = doc.id;
@@ -59,10 +139,19 @@ export function createImportRunner(deps: ImportRunnerDeps): ImportRunner {
59
139
  try {
60
140
  claim = await deps.claimImport(docId);
61
141
  } 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) {
142
+ const status = err instanceof BrassError ? err.status : undefined;
143
+ if (status === 409) {
144
+ schedulePendingRetry(doc);
145
+ return;
146
+ }
147
+ // A refusal names what the developer has to change, so it reaches
148
+ // the app's error handler; the rest of the 4xx surface (nothing
149
+ // stored to import, read-only access) is a no-op.
150
+ if (
151
+ status !== undefined &&
152
+ status < 500 &&
153
+ status !== CLAIM_REFUSED_STATUS
154
+ ) {
66
155
  return;
67
156
  }
68
157
  deps.reportError(asError(err));
@@ -71,19 +160,183 @@ export function createImportRunner(deps: ImportRunnerDeps): ImportRunner {
71
160
  if (claim.claimed !== true) return;
72
161
  // This client won the claim: run the importer, then report
73
162
  // 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.
163
+ // schema. A throw skips the completion and leaves the claim to
164
+ // expire; the platform decides what a later claim may do with a
165
+ // document the failed run already wrote records into.
76
166
  try {
77
167
  const source = await doc.sourceBlob();
78
- await run(source, doc);
168
+ const stopRefresh = startClaimRefresh(docId, doc);
169
+ // Whether the importer put anything on the wire. A commit fires the
170
+ // document's subscribers synchronously and raises `savingCount` as it
171
+ // does, so a run that wrote is seen here even when its ops ack before
172
+ // it returns.
173
+ let wroteRecords = false;
174
+ const stopWatchingWrites = doc.subscribe(() => {
175
+ if (doc.savingCount > 0) wroteRecords = true;
176
+ });
177
+ try {
178
+ await run(source, doc);
179
+ // The importer's ops are still in flight when it returns, and
180
+ // the completion stamps the version the import produced from
181
+ // the head it lands on. Completing before they are acked stamps
182
+ // a version below the records' own, which reads as a document
183
+ // edited since it was uploaded from then on.
184
+ await whenSaved(doc);
185
+ } finally {
186
+ stopRefresh();
187
+ stopWatchingWrites();
188
+ }
189
+ // The wait above ends on the document going as well as on its writes
190
+ // draining, and only one of those means the records are stored. The
191
+ // completion stamps the document with the app's schema at the version
192
+ // it lands on, so running it over writes that never reached the server
193
+ // records an import of records the document does not hold. Abandoning
194
+ // instead leaves the claim to expire, and a client that is still open
195
+ // imports the file properly.
196
+ if (!doc._isLive) return;
197
+ // An importer that returns without writing anything must not stamp the
198
+ // document imported: the stamp is permanent, a later open reads
199
+ // `already_imported`, and the file's records then have no path into a
200
+ // document that holds none. `try { parse() } catch { return }` is an
201
+ // ordinary shape, so this is reachable from app code that looks
202
+ // correct. Both signals are required before skipping, so an import
203
+ // that DID write is completed either way and never runs twice.
204
+ if (!wroteRecords && isEmptyDocument(doc)) {
205
+ deps.reportError(
206
+ new Error(
207
+ 'The importer wrote no records, so this file was not imported. ' +
208
+ 'Reload to try again.',
209
+ ),
210
+ );
211
+ return;
212
+ }
79
213
  await deps.completeImport(docId);
80
214
  } catch (err) {
81
215
  deps.reportError(asError(err));
82
216
  }
83
217
  },
84
218
  };
219
+
220
+ function schedulePendingRetry(doc: Document): void {
221
+ const taken = pendingRetries.get(doc) ?? 0;
222
+ const delay =
223
+ PENDING_RETRY_DELAYS_MS[taken] ??
224
+ (taken < PENDING_RETRY_DELAYS_MS.length + PENDING_RETRY_TAIL
225
+ ? PENDING_RETRY_INTERVAL_MS
226
+ : undefined);
227
+ if (delay === undefined) {
228
+ // Out of attempts with the file still unimported. The reader is looking
229
+ // at an empty document, so say so rather than leaving them to conclude
230
+ // the file was empty.
231
+ deps.reportError(
232
+ new Error(
233
+ 'Could not import this file: another window has been importing it ' +
234
+ 'for longer than expected. Reload to try again.',
235
+ ),
236
+ );
237
+ return;
238
+ }
239
+ pendingRetries.set(doc, taken + 1);
240
+ attempted.delete(doc);
241
+ setTimeout(() => {
242
+ void runner.attempt(doc);
243
+ }, delay);
244
+ }
245
+
246
+ function startClaimRefresh(docId: string, doc: Document): () => void {
247
+ let timer: ReturnType<typeof setTimeout> | undefined;
248
+ let left = CLAIM_REFRESH_MAX;
249
+ const tick = (): void => {
250
+ if (left <= 0) return;
251
+ // A renewal from a document that has gone tells the platform the holder
252
+ // is still working, when this client can no longer complete the import
253
+ // at all. Stopping here is what lets the claim expire on its own TTL so
254
+ // a client that IS open picks the file up, rather than being refused for
255
+ // the whole refresh budget.
256
+ if (!doc._isLive) return;
257
+ left -= 1;
258
+ // A failed refresh needs no handling: the completion reports
259
+ // whether the claim survived.
260
+ void deps.claimImport(docId).catch(() => undefined);
261
+ timer = setTimeout(tick, CLAIM_REFRESH_MS);
262
+ };
263
+ timer = setTimeout(tick, CLAIM_REFRESH_MS);
264
+ return () => {
265
+ clearTimeout(timer);
266
+ left = 0;
267
+ };
268
+ }
269
+
270
+ return runner;
271
+ }
272
+
273
+ // How often the wait below re-checks that the document is still live. A
274
+ // closing document drops its subscribers without firing them, so this is the
275
+ // only thing that ends the wait for one that goes mid-import; it runs solely
276
+ // while an import's writes are draining.
277
+ const SAVED_LIVENESS_POLL_MS = 1_000;
278
+
279
+ // Resolves once the document has no locally-issued op waiting for its
280
+ // server ack, or once the document is no longer live.
281
+ //
282
+ // The liveness half is not belt-and-braces. Closing a Document CLEARS its
283
+ // subscribers rather than firing them, so a wait that watched the ack count
284
+ // alone would never settle for a document closed with writes outstanding, and
285
+ // the caller's `finally` (which stops renewing the platform's import claim)
286
+ // would never run. Resolving here does not claim the writes landed: the
287
+ // completion that follows is what asserts that, and it fails against a
288
+ // document this client can no longer write.
289
+ function whenSaved(doc: Document): Promise<void> {
290
+ if (doc.savingCount === 0) return Promise.resolve();
291
+ return new Promise<void>((resolve) => {
292
+ let off: (() => void) | undefined;
293
+ let poll: ReturnType<typeof setInterval> | undefined;
294
+ const settle = (): void => {
295
+ off?.();
296
+ if (poll !== undefined) clearInterval(poll);
297
+ resolve();
298
+ };
299
+ try {
300
+ off = doc.subscribe(() => {
301
+ if (doc.savingCount > 0) return;
302
+ settle();
303
+ });
304
+ } catch {
305
+ // The document closed, so nothing more is going to be acked.
306
+ resolve();
307
+ return;
308
+ }
309
+ poll = setInterval(() => {
310
+ if (doc._isLive && doc.savingCount > 0) return;
311
+ settle();
312
+ }, SAVED_LIVENESS_POLL_MS);
313
+ // Node keeps the process alive for a pending interval; this one is
314
+ // bookkeeping for a wait that ends on its own, so it must not.
315
+ (poll as { unref?: () => void }).unref?.();
316
+ if (doc.savingCount === 0) settle();
317
+ });
85
318
  }
86
319
 
87
320
  function asError(err: unknown): Error {
88
321
  return err instanceof Error ? err : new Error(String(err));
89
322
  }
323
+
324
+ // Whether the document holds nothing at all, read only when no write was
325
+ // observed, so the walk is over an empty or near-empty document.
326
+ //
327
+ // A document reports its ROOTS whatever it holds (a fresh one reads
328
+ // `{ entities: {} }`), so emptiness is every root being empty rather than
329
+ // there being no roots.
330
+ function isEmptyDocument(doc: Document): boolean {
331
+ try {
332
+ return Object.values(doc.toJSON()).every(
333
+ (root) =>
334
+ root === null ||
335
+ typeof root !== 'object' ||
336
+ Object.keys(root as object).length === 0,
337
+ );
338
+ } catch {
339
+ // A document that cannot be read is not one to declare empty.
340
+ return false;
341
+ }
342
+ }
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,6 +16,7 @@ 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';
@@ -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';