@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
@@ -0,0 +1,25 @@
1
+ // The `__brass_embed` postMessage envelope, shared by the SDK's three bridge
2
+ // endpoints: the hosted-UI launcher (`hosted-ui.ts`) and the version bar
3
+ // (`version-bar.ts`), where the SDK is the host, and the export guest
4
+ // (`export-guest.ts`), where the SDK is the iframe.
5
+ //
6
+ // Both sides drop any `message` whose `data[EMBED_ENVELOPE]` is not a version
7
+ // they speak, so unrelated postMessage traffic never reaches a handshake.
8
+ //
9
+ // The SDK's half of every bridge freezes into each app's published bundle,
10
+ // so it speaks exactly the version it was built with: it announces this
11
+ // version on first contact and accepts only this version inbound. Staying
12
+ // compatible across a bump is entirely the central (dashboard-served) side's
13
+ // job: that side accepts every version it has ever shipped and replies at the
14
+ // one the frozen counterpart announced. Two rules follow, and together they
15
+ // are the bridge's compatibility invariant:
16
+ //
17
+ // - the central side never drops a version while a published SDK still
18
+ // announces it, so an app pinned to an older client keeps working;
19
+ // - this constant rises only after a dashboard build that accepts the new
20
+ // version is deployed, because the SDK's first message (the launcher's
21
+ // `init`, the guest's `ready`) is what the central side negotiates from.
22
+ // A bump that lands first is dropped by the deployed dashboard, and every
23
+ // hosted dialog in an app carrying it waits out its timeout.
24
+ export const EMBED_ENVELOPE = '__brass_embed';
25
+ export const EMBED_PROTOCOL_VERSION = 1;
package/src/emitter.ts CHANGED
@@ -113,7 +113,14 @@ export function createEmitter<T>(opts: EmitterOptions<T> = {}): Emitter<T> {
113
113
  retained = value;
114
114
  hasValue = true;
115
115
  }
116
- for (const cb of listeners) dispatch(cb, value);
116
+ // Over a snapshot, because a listener may detach or clear the rest
117
+ // while the fanout is running: the session's own `onGone` handler
118
+ // closes the document, which clears its listener set, and a `Set`
119
+ // never visits an entry removed before its turn. Iterating live there
120
+ // silently drops every listener registered after the session's, which
121
+ // is all of the app's.
122
+ const receiving = Array.from(listeners);
123
+ for (const cb of receiving) dispatch(cb, value);
117
124
  if (oneShot) listeners.clear();
118
125
  },
119
126
  clear() {
@@ -15,10 +15,8 @@
15
15
  import { Document } from './documents.js';
16
16
  import type { DocumentStreamInfo } from './documents-namespace.js';
17
17
  import type { StreamRuntime } from './document-streams.js';
18
- import type { DocumentSerializer } from './serializer.js';
19
-
20
- const ENVELOPE = '__brass_embed';
21
- const PROTOCOL_VERSION = 1;
18
+ import type { DocumentSerializer, StoredDocument } from './serializer.js';
19
+ import { EMBED_ENVELOPE, EMBED_PROTOCOL_VERSION } from './embed-protocol.js';
22
20
 
23
21
  // The URL flag Brass appends when it frames an app purely to serialize.
24
22
  // A normal app load never carries it, so a real user visit is untouched.
@@ -102,7 +100,7 @@ export function startExportGuest(): ExportGuestController {
102
100
  }, STREAM_REQUEST_TIMEOUT_MS);
103
101
  pendingStreamFetches.set(requestId, { resolve, reject, timer });
104
102
  window.parent.postMessage(
105
- { [ENVELOPE]: PROTOCOL_VERSION, type, requestId, ...extra },
103
+ { [EMBED_ENVELOPE]: EMBED_PROTOCOL_VERSION, type, requestId, ...extra },
106
104
  hostOrigin,
107
105
  );
108
106
  });
@@ -145,7 +143,7 @@ export function startExportGuest(): ExportGuestController {
145
143
  | { ok: false; code: string; message: string },
146
144
  ): void {
147
145
  window.parent.postMessage(
148
- { [ENVELOPE]: PROTOCOL_VERSION, type: 'export-result', requestId, ...outcome },
146
+ { [EMBED_ENVELOPE]: EMBED_PROTOCOL_VERSION, type: 'export-result', requestId, ...outcome },
149
147
  hostOrigin,
150
148
  );
151
149
  }
@@ -168,7 +166,10 @@ export function startExportGuest(): ExportGuestController {
168
166
  : Document.create(identity);
169
167
  for (const op of tailOps) doc.applyRemoteOp(op);
170
168
  doc.setStreamRuntime(makeStreamRuntime(hostOrigin));
171
- const bytes = await serializer(doc);
169
+ // A serializer runs detached, with no subscription to have been fed by,
170
+ // and it needs the document once, so it is handed the containers whole
171
+ // (`StoredDocument`) rather than the fed surface an app holds.
172
+ const bytes = await serializer(doc as StoredDocument & Document);
172
173
  reply(hostOrigin, requestId, { ok: true, bytes });
173
174
  } catch (err) {
174
175
  reply(hostOrigin, requestId, {
@@ -180,8 +181,13 @@ export function startExportGuest(): ExportGuestController {
180
181
  }
181
182
 
182
183
  function onMessage(event: MessageEvent): void {
184
+ // Source pinning, the counterpart of the origin pins below and of the
185
+ // one the host side applies: `window.parent` is the framer this guest
186
+ // replies to, so a message from any other window is not part of this
187
+ // exchange whatever origin it claims.
188
+ if (event.source !== window.parent) return;
183
189
  const data = event.data as Record<string, unknown> | null;
184
- if (!data || data[ENVELOPE] !== PROTOCOL_VERSION) return;
190
+ if (!data || data[EMBED_ENVELOPE] !== EMBED_PROTOCOL_VERSION) return;
185
191
 
186
192
  // A reply to a proxied stream fetch. Accept only from the pinned framer
187
193
  // origin (set once the export request arrives), so a second frame cannot
@@ -262,7 +268,7 @@ export function startExportGuest(): ExportGuestController {
262
268
  window.addEventListener('message', onMessage);
263
269
  // Announce readiness. Posted with `'*'` because the host origin is not
264
270
  // known until its first message arrives; the ping carries no data.
265
- window.parent.postMessage({ [ENVELOPE]: PROTOCOL_VERSION, type: 'ready' }, '*');
271
+ window.parent.postMessage({ [EMBED_ENVELOPE]: EMBED_PROTOCOL_VERSION, type: 'ready' }, '*');
266
272
 
267
273
  return {
268
274
  setSerializer(next: DocumentSerializer): void {
@@ -0,0 +1,119 @@
1
+ // A deadline for a request whose caller has no other way to stop waiting.
2
+ //
3
+ // `fetch` has no timeout of its own, so a request that draws no answer stays
4
+ // pending for as long as the page is open. On the op-write path that pins the
5
+ // op's entry in the outstanding tracker, which holds `doc.savingCount` above
6
+ // zero, which is what an app renders as "Saving...". The reader is then shown
7
+ // a save in progress that nothing will ever resolve, and no error reaches
8
+ // `Brass.onError` because nothing failed.
9
+ //
10
+ // The deadline covers reading the body as well as getting a response, because
11
+ // `fetch` resolves as soon as the headers land: a server that answers and then
12
+ // stalls mid-body is the same hang, and clearing the timer on the response
13
+ // would leave exactly the case this exists for uncovered. The helper therefore
14
+ // reads the body itself rather than handing back a `Response` whose consumption
15
+ // nothing bounds.
16
+ //
17
+ // An abort is deliberately reported as a request that drew no answer at all
18
+ // (status `null`), because that is what it is: the write may or may not have
19
+ // reached the handler. Callers price that the same way they price a network
20
+ // throw.
21
+
22
+ // How long a request that carries no meaningful body may go unanswered.
23
+ //
24
+ // The value matches the WebSocket transport's forced-resend ceiling, because
25
+ // it answers the same question about the same op: whether a request still
26
+ // outstanding this long after it went out is coming back. It sits well above
27
+ // any latency a working connection produces.
28
+ //
29
+ // What a false positive costs is bounded. Staging is replayed, which writes
30
+ // nothing durable until the op write names the key. The op write itself is
31
+ // terminal for that op, which arms the durability handshake, and the
32
+ // handshake pushes whatever the durable frontier is actually missing rather
33
+ // than replaying the op blindly.
34
+ export const OP_WRITE_TIMEOUT_MS = 60_000;
35
+
36
+ // The uplink an upload is assumed to manage in the worst case, in bytes per
37
+ // millisecond (16 KB/s). A request carrying megabytes is not unanswered while
38
+ // its body is still going out, so a flat deadline would abort a working upload
39
+ // on a slow mobile connection and fail an op that was about to land. The
40
+ // allowance is generous on purpose: a deadline that fires early on real work is
41
+ // a worse failure than one that takes longer to notice a dead request, since
42
+ // the whole point is to distinguish silence from slowness.
43
+ const ASSUMED_MIN_UPLOAD_BYTES_PER_MS = 16;
44
+
45
+ /** Deadline for a request whose body is `byteLength` bytes of upload. */
46
+ export function deadlineForUpload(byteLength: number): number {
47
+ return (
48
+ OP_WRITE_TIMEOUT_MS +
49
+ Math.ceil(Math.max(0, byteLength) / ASSUMED_MIN_UPLOAD_BYTES_PER_MS)
50
+ );
51
+ }
52
+
53
+ export class FetchDeadlineError extends Error {
54
+ override name = 'FetchDeadlineError';
55
+ }
56
+
57
+ export interface DeadlineResponse {
58
+ ok: boolean;
59
+ status: number;
60
+ text: string;
61
+ }
62
+
63
+ /**
64
+ * Run a fetch under a deadline covering the response and its body, aborting
65
+ * the request when it expires.
66
+ *
67
+ * The timer is cleared on every exit, so a completed request leaves nothing
68
+ * pending. An environment whose `fetch` ignores `AbortSignal` still rejects,
69
+ * because the deadline is raced against the request rather than relying on the
70
+ * abort landing.
71
+ */
72
+ export async function fetchWithDeadline(
73
+ input: string,
74
+ init: RequestInit,
75
+ timeoutMs: number,
76
+ ): Promise<DeadlineResponse> {
77
+ const controller = new AbortController();
78
+ let expired = false;
79
+ let fire: (() => void) | undefined;
80
+ const timer = setTimeout(() => {
81
+ expired = true;
82
+ controller.abort();
83
+ fire?.();
84
+ }, timeoutMs);
85
+ // A long-lived deadline must not hold a Node process open on its own.
86
+ (timer as unknown as { unref?: () => void }).unref?.();
87
+
88
+ const expiry = new Promise<never>((_resolve, reject) => {
89
+ fire = () =>
90
+ reject(
91
+ new FetchDeadlineError(
92
+ `The request did not answer within ${Math.round(timeoutMs / 1000)}s.`,
93
+ ),
94
+ );
95
+ });
96
+
97
+ try {
98
+ return await Promise.race([
99
+ (async (): Promise<DeadlineResponse> => {
100
+ const response = await fetch(input, {
101
+ ...init,
102
+ signal: controller.signal,
103
+ });
104
+ const text = await response.text();
105
+ return { ok: response.ok, status: response.status, text };
106
+ })(),
107
+ expiry,
108
+ ]);
109
+ } catch (err) {
110
+ if (expired) {
111
+ throw new FetchDeadlineError(
112
+ `The request did not answer within ${Math.round(timeoutMs / 1000)}s.`,
113
+ );
114
+ }
115
+ throw err;
116
+ } finally {
117
+ clearTimeout(timer);
118
+ }
119
+ }
@@ -14,11 +14,13 @@
14
14
  //
15
15
  // - A GET is idempotent, so replaying it is always safe: retry the whole
16
16
  // transient 5xx / 429 set AND a thrown network error (`RETRY_READ`).
17
- // - A write is NOT idempotent, so only a 503 is safe to replay: the API
18
- // gateway returns a 503 BEFORE the request reaches the handler (a transient
19
- // throttle / scale-up / deploy-window blip), so the write never ran and
20
- // a replay cannot double-apply it. A 502 / 504 / 429 or a network throw
21
- // might mean the handler ran, so those stay hard failures (`RETRY_WRITE_503`).
17
+ // - A write is NOT idempotent, so only a 503 is safe to replay, and only on
18
+ // a route where a 503 means the write did not land: the gateway's own 503
19
+ // (a transient throttle / scale-up / deploy-window blip) is returned before
20
+ // the request reaches the handler, and a handler that answers 503 does so
21
+ // for a write it refused rather than applied. A 502 / 504 / 429 or a network
22
+ // throw might mean the handler ran, so those stay hard failures
23
+ // (`RETRY_WRITE_503`).
22
24
  // - A 500 is never retried under either policy: an unhandled server error
23
25
  // is usually deterministic, so a replay just doubles the cost without
24
26
  // changing the outcome.
@@ -62,6 +64,9 @@ export interface RetryOptions {
62
64
  policy?: RetryPolicy;
63
65
  // Total attempts including the first. Default 3 (so up to 2 replays).
64
66
  maxAttempts?: number;
67
+ // Attempts a thrown network error gets, when the policy replays throws.
68
+ // Defaults to `maxAttempts`.
69
+ maxNetworkAttempts?: number;
65
70
  // Base backoff; attempt N waits ~base * 2^(N-1) with jitter. Default 300.
66
71
  baseDelayMs?: number;
67
72
  // Invoked before each replay with the 1-based attempt that just failed and
@@ -86,10 +91,12 @@ export async function fetchWithRetry(
86
91
  ): Promise<Response> {
87
92
  const policy = options.policy ?? RETRY_READ;
88
93
  const maxAttempts = options.maxAttempts ?? 3;
94
+ const maxNetworkAttempts = options.maxNetworkAttempts ?? maxAttempts;
89
95
  const baseDelayMs = options.baseDelayMs ?? 300;
90
96
  const sleep = options.sleep ?? defaultSleep;
91
97
 
92
98
  let lastError: unknown;
99
+ let networkThrows = 0;
93
100
  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
94
101
  let response: Response | undefined;
95
102
  try {
@@ -99,6 +106,8 @@ export async function fetchWithRetry(
99
106
  // a write policy rethrows it rather than risk a double-apply.
100
107
  if (!policy.replayNetworkErrors) throw err;
101
108
  lastError = err;
109
+ networkThrows += 1;
110
+ if (networkThrows >= maxNetworkAttempts) throw err;
102
111
  }
103
112
  if (response && !policy.replayStatuses.has(response.status)) {
104
113
  return response;
@@ -0,0 +1,303 @@
1
+ // The handle surface's public types, kept apart from the `handles.ts` code
2
+ // that implements them against Loro. Every type the package entry re-exports
3
+ // pulls its declaring module into a consumer's typecheck graph, so declaring
4
+ // them here is what makes the public surface engine-neutral all the way down
5
+ // rather than only in the signatures an app calls.
6
+ // `public-types-engine-free.test.ts` holds that line.
7
+
8
+ // What changed on a map, the first argument to a map subscriber. Reconcile
9
+ // by it: upsert every entry in `set` (it carries the new value, so reconcile
10
+ // never calls `get`), drop every key in `deleted`. The first fire carries
11
+ // the whole map as `set`, so a subscriber is one loop with no
12
+ // initial-vs-update branch. Each later fire is proportional to what the change
13
+ // wrote, never to the map's size.
14
+ export interface Change<T = unknown> {
15
+ // Entries the change wrote, by key, with the value the map now holds for
16
+ // them. `set` names what a change WROTE, which is a wider answer than what
17
+ // differs: assigning a record the value it already holds names it, and above
18
+ // the size at which a large map stores its records in groups, a mutation
19
+ // that replaces every record of a group names every record in it. An edit of
20
+ // one record names that record at every size. So reconcile by value and the
21
+ // two readings agree; to attribute a change instead (highlight a peer's
22
+ // edit, mark a record dirty, recompute per record), compare the value
23
+ // against the one you hold and act on the entries that moved.
24
+ readonly set: ReadonlyMap<string, T>;
25
+ // Keys removed from the map.
26
+ readonly deleted: readonly string[];
27
+ // Live keys in order, present ONLY on an ordered-map fire whose order
28
+ // changed. Re-sequence the views you hold to it; absent on a plain map and
29
+ // on an order-preserving field edit, keeping re-sequencing off the
30
+ // per-keystroke path.
31
+ readonly order?: readonly string[];
32
+ }
33
+
34
+ // What produced a change: read-only metadata, the optional SECOND argument
35
+ // to a map subscriber. A render never needs it (the projection reconciles by
36
+ // value regardless of cause, which makes local / remote / undo one path);
37
+ // read it only to attribute a change, e.g. highlight a peer's edit
38
+ // (`source === 'remote'`). It says who wrote the change, and `set` says what
39
+ // the change wrote, so an attributing subscriber pairs it with the value
40
+ // comparison `set` describes.
41
+ //
42
+ // A move through version history is not among them: it swaps the whole
43
+ // container rather than writing into it, so it reaches a subscriber as a
44
+ // whole-container fire with no origin. `doc.subscribe` reports that move as
45
+ // `{ kind: 'history' }`, carrying the version it landed on.
46
+ export type ChangeSource = 'local' | 'remote' | 'undo' | 'redo';
47
+ export interface ChangeOrigin {
48
+ readonly source: ChangeSource;
49
+ }
50
+
51
+ // A map subscriber. Fires once on registration (the whole map as
52
+ // `change.set`), then on every change. Both fires are synchronous: the
53
+ // registration fire runs before `subscribe` returns, and a change fire runs
54
+ // inside the `mutate` that caused it. So a subscriber cannot reach a `const`
55
+ // its own registration fire initialises, and work it defers to a later tick
56
+ // runs after the mutation has finished.
57
+ export type MapSubscriber<T = unknown> = (
58
+ change: Change<T>,
59
+ origin?: ChangeOrigin,
60
+ ) => void;
61
+
62
+ // Internal (not exported): a map subscriber that also learns whether a fire is
63
+ // a whole-container catch-up (`resync: true`: registration, or a checkout /
64
+ // return-to-head / hydrate swapping the source) or an incremental delta
65
+ // (`false`). This is the "full re-sync vs incremental" distinction, ORTHOGONAL
66
+ // to `origin` (which is authorship: local / remote / undo / redo, all one path
67
+ // to a render). The ordered map needs it because a source swap can reorder
68
+ // `instance_order` with the same key set, which its key-set move test cannot
69
+ // see; keying off this explicit flag keeps that check gated to the rare
70
+ // re-sync fires without overloading `origin`. The public `subscribe` wraps an
71
+ // app `MapSubscriber` and drops the flag, so it never reaches an app.
72
+ export type InternalMapSubscriber<T> = (
73
+ change: Change<T>,
74
+ origin: ChangeOrigin | undefined,
75
+ resync: boolean,
76
+ ) => void;
77
+
78
+ /**
79
+ * One positional edit, applied in the order the change carries them.
80
+ *
81
+ * `at` indexes the sequence as the preceding splices in the same change left
82
+ * it, so applying them in order to what you already hold leaves you current.
83
+ * That is the whole contract: a subscriber never has to ask what the list
84
+ * holds, because every fire says how to get from its last state to this one.
85
+ */
86
+ export interface Splice<E> {
87
+ readonly at: number;
88
+ readonly removed: number;
89
+ readonly inserted: readonly E[];
90
+ }
91
+
92
+ /**
93
+ * What changed in a list. Fires once on registration carrying the whole list
94
+ * as a single splice, then on every change carrying only what moved.
95
+ *
96
+ * A registration fire and a resync are not special cases a subscriber has to
97
+ * recognise: both arrive as splices that replace what it was last told, so
98
+ * one apply loop is the entire subscriber.
99
+ */
100
+ export interface ListChange<T> {
101
+ readonly splices: readonly Splice<T>[];
102
+ }
103
+
104
+ export type ListSubscriber<T = unknown> = (change: ListChange<T>) => void;
105
+
106
+ // Internal (not exported): a list subscriber that also learns what produced
107
+ // the change, so the ordered map can stamp an `order` frame its order list's
108
+ // own move produced. Undefined on a whole-container catch-up (registration, or
109
+ // a checkout / return-to-head / hydrate swapping the source), which describes
110
+ // no authored transition, matching `MapSubscriber`'s optional origin.
111
+ export type InternalListSubscriber = (
112
+ change: ListChange<unknown>,
113
+ origin?: ChangeOrigin,
114
+ ) => void;
115
+
116
+ export type HandleUnsubscribe = () => void;
117
+
118
+ // Hooks the owning Document provides so handles participate in the SDK's
119
+ // commit + origin machinery. Defined here (not in documents.ts) so handles.ts
120
+ // has no compile-time dependency on the Document class.
121
+ export interface HandleOwner {
122
+ // Throws if the Document is closed, suspended, or read-only.
123
+ assertWritable(): void;
124
+ // Throws if the Document is closed. `close()` releases the CRDT state the
125
+ // handles read through, so a read taken after it reaches released memory and
126
+ // fails with whatever the engine says about that. An app holds a handle
127
+ // across a close by design (it is what a subscription is registered on), so
128
+ // this is an ordinary race: a callback, a frame, or a settled promise
129
+ // reading one tick after the close. The gate is what makes that answer
130
+ // `Document is closed` in the app's own vocabulary, like a write does.
131
+ //
132
+ // Closed only, never suspended: a parked Document keeps its state, and its
133
+ // reads answer from it.
134
+ assertReadable(): void;
135
+ // Lazily, idempotently attaches the local-updates hook before first mutate.
136
+ ensureLocalUpdatesSubscribed(): void;
137
+ // Stamps the next commit with the mutate origin tag and commits, so the
138
+ // change's `origin.source` reads `'local'`.
139
+ commitMutate(): void;
140
+ // Classify a Loro event by what produced it. Derived from the commit's
141
+ // `by` + origin tag, so it survives microtask-deferred delivery.
142
+ originOf(event: unknown): ChangeSource;
143
+ }
144
+
145
+ // Public handle interfaces. Concrete classes below.
146
+
147
+ export interface MapHandle<T = unknown> {
148
+ // Per-entry read. Returns undefined when missing.
149
+ get(entryKey: string): T | undefined;
150
+ // Keys currently in the map.
151
+ keys(): string[];
152
+ // Whether an entry exists at `entryKey`.
153
+ has(entryKey: string): boolean;
154
+ // Mutate through a typed JS-proxy, scoped to this container; same
155
+ // ergonomics as `doc.mutate(d => { d.entities[id].field = ... })`.
156
+ mutate(fn: (draft: Record<string, T>) => void): void;
157
+ // ONE channel for every change whatever produced it (local / remote / undo
158
+ // / redo / checkout): the projection reconciles by value. The callback gets
159
+ // `change` and optional `origin` a render ignores. Fires once on
160
+ // registration with the whole map as `change.set`, then on every change
161
+ // (each later fire proportional to what that change wrote).
162
+ subscribe(cb: MapSubscriber<T>): HandleUnsubscribe;
163
+ /**
164
+ * Report each time this container has been delivered whole: on
165
+ * registration, and again after a version checkout, a return to head or a
166
+ * hydrate replaces it.
167
+ *
168
+ * This is where the work that follows on the collection belongs rather than
169
+ * on any single change: frame the camera on what arrived, run the first pass
170
+ * over it. It repeats because that work is owed again each time the
171
+ * container is replaced.
172
+ *
173
+ * It reports that this peer holds what it had converged to when the delivery
174
+ * began, which is the only claim available: more always arrives afterwards.
175
+ */
176
+ onSettled(cb: () => void): HandleUnsubscribe;
177
+
178
+ // Child handle for `entries[entryKey]` when that entry is itself a map /
179
+ // list. Reads see empty until the container exists; the first mutate
180
+ // creates it, and a subscription taken before that delivers the container
181
+ // whole on whichever write creates it.
182
+ map<U>(entryKey: string): MapHandle<U>;
183
+ list<U>(entryKey: string): ListHandle<U>;
184
+ }
185
+
186
+ /**
187
+ * The array a list `mutate` hands its callback: an ordinary `T[]` plus
188
+ * `move`.
189
+ *
190
+ * `move` is here because a plain array has no way to say "this entry is now
191
+ * at that position", and the difference matters once two people edit at once.
192
+ * Spelled as a removal and an insertion, a reposition merges with another
193
+ * peer's reposition of the SAME entry by keeping BOTH insertions, so the entry
194
+ * ends up in the list twice and a reader has to guess which position was
195
+ * meant. `move` states the reposition, and the two merge to one entry.
196
+ */
197
+ export interface ListDraft<T> extends Array<T> {
198
+ /** Reposition the entry at `from` so it sits at `to`. Indices are clamped
199
+ * to the list, and a `from` outside it does nothing. */
200
+ move(from: number, to: number): void;
201
+ }
202
+
203
+ export interface ListHandle<T = unknown> {
204
+ length(): number;
205
+ mutate(fn: (draft: ListDraft<T>) => void): void;
206
+ // Fires once on registration carrying the whole list as one splice, then on
207
+ // every change carrying only what moved, so a subscriber applies splices to
208
+ // what it already holds and never re-reads. A keyed collection belongs in a
209
+ // map (or `orderedMap`), which reconciles by key; a list is for ordering
210
+ // keys and small positional data.
211
+ subscribe(cb: ListSubscriber<T>): HandleUnsubscribe;
212
+ /**
213
+ * Report each time this container has been delivered whole: on
214
+ * registration, and again after a version checkout, a return to head or a
215
+ * hydrate replaces it.
216
+ *
217
+ * This is where the work that follows on the collection belongs rather than
218
+ * on any single change: frame the camera on what arrived, run the first pass
219
+ * over it. It repeats because that work is owed again each time the
220
+ * container is replaced.
221
+ *
222
+ * It reports that this peer holds what it had converged to when the delivery
223
+ * began, which is the only claim available: more always arrives afterwards.
224
+ */
225
+ onSettled(cb: () => void): HandleUnsubscribe;
226
+ }
227
+
228
+ // An ordered map: a keyed map whose key insertion order is mirrored into a
229
+ // sibling list, kept in lockstep. The record store (`instances` +
230
+ // `instance_order`) is the motivating adopter, reached as
231
+ // `doc.orderedMap('instances', 'instance_order')`, but the primitive names no
232
+ // format: two ordinary root containers bound so an app cannot forget to
233
+ // update the order list on add / remove.
234
+ //
235
+ // Add appends to the order list, delete removes from it, both in ONE commit
236
+ // (one undo step). Reassigning a key's value (a `params` edit) leaves the
237
+ // order untouched and stays O(1): only structural add / delete touches it.
238
+ // `insert` and `move` place a key somewhere other than the end.
239
+
240
+ /**
241
+ * Where an ordered map puts a key.
242
+ *
243
+ * The two anchor forms name a neighbouring key, which is what a reader's
244
+ * gesture actually means (insert after the paragraph the caret is in, drop
245
+ * this step on that one) and what survives a peer editing the collection
246
+ * between the render and the write. `index` counts positions in `keys()`, for
247
+ * a surface that works in indices; anything at or past the end appends, and a
248
+ * negative index goes to the front.
249
+ *
250
+ * An anchor key the order list does not name puts the key at the end.
251
+ */
252
+ export type OrderedMapPosition =
253
+ | { before: string }
254
+ | { after: string }
255
+ | { index: number };
256
+
257
+ export interface OrderedMapHandle<T = unknown> {
258
+ // Per-entry read. Returns undefined when missing.
259
+ get(entryKey: string): T | undefined;
260
+ // Whether an entry exists in the map.
261
+ has(entryKey: string): boolean;
262
+ // Keys in order-list order, filtered to keys still present in the map.
263
+ keys(): string[];
264
+ // `[key, value]` records walking the order list, so an app reading in
265
+ // source order doesn't re-walk the order list by hand.
266
+ ordered(): { key: string; value: T }[];
267
+ // Mutate the map; the sibling order list reconciles in the same commit
268
+ // (new keys appended in insertion order, deleted keys pruned). Drive it
269
+ // like `MapHandle.mutate`: `m[key] = record`, `delete m[key]`,
270
+ // `m[key].field = ...`.
271
+ mutate(fn: (draft: Record<string, T>) => void): void;
272
+ // Write `value` under `entryKey` and put that key at `at`, both in ONE
273
+ // commit (one undo step) like every other structural edit here. A key the
274
+ // map already holds is written and repositioned rather than named twice.
275
+ insert(entryKey: string, value: T, at: OrderedMapPosition): void;
276
+ // Reposition a key the map already holds, writing the order list alone. A
277
+ // key the map does not hold does nothing, so a reorder racing a peer's
278
+ // delete of the same key leaves the delete standing.
279
+ move(entryKey: string, to: OrderedMapPosition): void;
280
+ // Fires once on registration (the whole map as `change.set` in order),
281
+ // then on every change. A field edit carries only `set` / `deleted`; a
282
+ // change that moves the order also carries `change.order` (live keys in
283
+ // order) so the app re-sequences the views it holds. A move the map does
284
+ // not see (an edit of the sibling order list, a merge that resequences
285
+ // keys already present) arrives as an `order` of its own with an empty
286
+ // `set`. Content stays proportional to what the change wrote; re-sequencing
287
+ // happens only when the order moves, and at most once per change.
288
+ subscribe(cb: MapSubscriber<T>): HandleUnsubscribe;
289
+ /**
290
+ * Report each time this container has been delivered whole: on
291
+ * registration, and again after a version checkout, a return to head or a
292
+ * hydrate replaces it.
293
+ *
294
+ * This is where the work that follows on the collection belongs rather than
295
+ * on any single change: frame the camera on what arrived, run the first pass
296
+ * over it. It repeats because that work is owed again each time the
297
+ * container is replaced.
298
+ *
299
+ * It reports that this peer holds what it had converged to when the delivery
300
+ * began, which is the only claim available: more always arrives afterwards.
301
+ */
302
+ onSettled(cb: () => void): HandleUnsubscribe;
303
+ }