@abloatai/transaction 0.59.2 → 0.61.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 (275) hide show
  1. package/dist/auth/apiKey.d.ts +10 -11
  2. package/dist/auth/apiKey.d.ts.map +1 -1
  3. package/dist/auth/apiKey.js +15 -29
  4. package/dist/auth/apiKey.js.map +1 -1
  5. package/dist/auth/browserCredentialSafety.js +1 -1
  6. package/dist/auth/browserCredentialSafety.js.map +1 -1
  7. package/dist/auth/capability.d.ts +15 -15
  8. package/dist/auth/capabilityLifecycle.d.ts +3 -3
  9. package/dist/auth/capabilityLifecycle.d.ts.map +1 -1
  10. package/dist/auth/capabilityLifecycle.js +2 -1
  11. package/dist/auth/capabilityLifecycle.js.map +1 -1
  12. package/dist/auth/credentialEndpoint.js +1 -1
  13. package/dist/auth/credentialEndpoint.js.map +1 -1
  14. package/dist/auth/credentialResult.d.ts +2 -1
  15. package/dist/auth/credentialResult.d.ts.map +1 -1
  16. package/dist/auth/credentialResult.js.map +1 -1
  17. package/dist/auth/credentialSource.js.map +1 -1
  18. package/dist/auth/identity.js.map +1 -1
  19. package/dist/auth/schemas.d.ts +5 -5
  20. package/dist/auth/sessionIssuanceProtocol.d.ts +8 -8
  21. package/dist/auth/sessionIssuanceProtocol.d.ts.map +1 -1
  22. package/dist/branches.d.ts +38 -38
  23. package/dist/claims/admission.d.ts +4 -0
  24. package/dist/claims/admission.d.ts.map +1 -0
  25. package/dist/claims/admission.js +32 -0
  26. package/dist/claims/admission.js.map +1 -0
  27. package/dist/claims/awaitGrant.d.ts.map +1 -1
  28. package/dist/claims/awaitGrant.js +4 -38
  29. package/dist/claims/awaitGrant.js.map +1 -1
  30. package/dist/claims/conflict.d.ts +55 -0
  31. package/dist/claims/conflict.d.ts.map +1 -0
  32. package/dist/claims/conflict.js +71 -0
  33. package/dist/claims/conflict.js.map +1 -0
  34. package/dist/claims/contract.d.ts +39 -27
  35. package/dist/claims/contract.d.ts.map +1 -1
  36. package/dist/claims/contract.js +2 -0
  37. package/dist/claims/contract.js.map +1 -1
  38. package/dist/claims/eventContract.d.ts +18 -18
  39. package/dist/claims/events.d.ts +1 -1
  40. package/dist/claims/events.d.ts.map +1 -1
  41. package/dist/claims/index.d.ts +2 -0
  42. package/dist/claims/index.d.ts.map +1 -1
  43. package/dist/claims/index.js +1 -0
  44. package/dist/claims/index.js.map +1 -1
  45. package/dist/claims/routes.d.ts +9 -9
  46. package/dist/claims/routes.d.ts.map +1 -1
  47. package/dist/claims/trace.d.ts.map +1 -1
  48. package/dist/claims/trace.js.map +1 -1
  49. package/dist/client/ablo.d.ts +11 -8
  50. package/dist/client/ablo.d.ts.map +1 -1
  51. package/dist/client/ablo.js +5 -6
  52. package/dist/client/ablo.js.map +1 -1
  53. package/dist/client/resources/httpResources.d.ts +1 -169
  54. package/dist/client/resources/httpResources.d.ts.map +1 -1
  55. package/dist/client/resources/httpResources.js +1 -1
  56. package/dist/client/resources/httpResources.js.map +1 -1
  57. package/dist/client/resources/modelOperations.d.ts +6 -15
  58. package/dist/client/resources/modelOperations.d.ts.map +1 -1
  59. package/dist/client/resources/where.d.ts +12 -12
  60. package/dist/client/surface.d.ts +1 -1
  61. package/dist/client/surface.d.ts.map +1 -1
  62. package/dist/client/surface.js +2 -4
  63. package/dist/client/surface.js.map +1 -1
  64. package/dist/commit/confirmation/commitEnvelope.d.ts +7 -7
  65. package/dist/commit/confirmation/httpCommitEnvelope.d.ts +1 -1
  66. package/dist/commit/confirmation/pendingWrite.d.ts +5 -5
  67. package/dist/commit/contract.d.ts +249 -249
  68. package/dist/commit/contract.d.ts.map +1 -1
  69. package/dist/coordination/index.d.ts +2 -2
  70. package/dist/coordination/index.d.ts.map +1 -1
  71. package/dist/coordination/index.js +4 -5
  72. package/dist/coordination/index.js.map +1 -1
  73. package/dist/coordination/schema.d.ts +64 -101
  74. package/dist/coordination/schema.d.ts.map +1 -1
  75. package/dist/coordination/schema.js +14 -69
  76. package/dist/coordination/schema.js.map +1 -1
  77. package/dist/environment.d.ts +3 -3
  78. package/dist/environment.d.ts.map +1 -1
  79. package/dist/environment.js.map +1 -1
  80. package/dist/errorCodes.d.ts +4 -5
  81. package/dist/errorCodes.d.ts.map +1 -1
  82. package/dist/errorCodes.js +15 -9
  83. package/dist/errorCodes.js.map +1 -1
  84. package/dist/errorObservation.d.ts +14 -14
  85. package/dist/errors.d.ts +17 -11
  86. package/dist/errors.d.ts.map +1 -1
  87. package/dist/errors.js +101 -5
  88. package/dist/errors.js.map +1 -1
  89. package/dist/keys/index.d.ts +1 -1
  90. package/dist/keys/index.d.ts.map +1 -1
  91. package/dist/keys/index.js.map +1 -1
  92. package/dist/observation/contract.d.ts +34 -34
  93. package/dist/observation/feedContract.d.ts +86 -86
  94. package/dist/observation/persistence/syncDeltaRow.d.ts +23 -23
  95. package/dist/observation/persistence/syncDeltaRow.d.ts.map +1 -1
  96. package/dist/pricing.d.ts +12 -12
  97. package/dist/schema/audit.js.map +1 -1
  98. package/dist/schema/ddl.js.map +1 -1
  99. package/dist/schema/ddlLock.js.map +1 -1
  100. package/dist/schema/deployment/contracts.d.ts +64 -64
  101. package/dist/schema/deployment/index.js.map +1 -1
  102. package/dist/schema/deployment/reconcile.js.map +1 -1
  103. package/dist/schema/diff.js.map +1 -1
  104. package/dist/schema/field.d.ts +1 -1
  105. package/dist/schema/field.d.ts.map +1 -1
  106. package/dist/schema/loadStrategy.d.ts +2 -2
  107. package/dist/schema/loadStrategy.d.ts.map +1 -1
  108. package/dist/schema/openapi.js.map +1 -1
  109. package/dist/schema/relation.d.ts +3 -3
  110. package/dist/schema/relation.d.ts.map +1 -1
  111. package/dist/schema/residency.d.ts +1 -1
  112. package/dist/schema/roles.d.ts +1 -1
  113. package/dist/schema/roles.d.ts.map +1 -1
  114. package/dist/schema/roles.js.map +1 -1
  115. package/dist/schema/schema.d.ts +1 -1
  116. package/dist/schema/schema.d.ts.map +1 -1
  117. package/dist/schema/serialize.js.map +1 -1
  118. package/dist/schema/sugar.d.ts.map +1 -1
  119. package/dist/schema/tenancy.d.ts +2 -2
  120. package/dist/server/storageMode.d.ts +1 -1
  121. package/dist/sessions/client.d.ts +29 -0
  122. package/dist/sessions/client.d.ts.map +1 -0
  123. package/dist/sessions/client.js +69 -0
  124. package/dist/sessions/client.js.map +1 -0
  125. package/dist/sessions/contract.d.ts +97 -0
  126. package/dist/sessions/contract.d.ts.map +1 -0
  127. package/dist/sessions/contract.js +5 -0
  128. package/dist/sessions/contract.js.map +1 -0
  129. package/dist/{auth/sessionMint.d.ts → sessions/create.d.ts} +4 -4
  130. package/dist/sessions/create.d.ts.map +1 -0
  131. package/dist/{auth/sessionMint.js → sessions/create.js} +11 -10
  132. package/dist/sessions/create.js.map +1 -0
  133. package/dist/sessions/handler.d.ts +23 -0
  134. package/dist/sessions/handler.d.ts.map +1 -0
  135. package/dist/sessions/handler.js +55 -0
  136. package/dist/sessions/handler.js.map +1 -0
  137. package/dist/sessions/index.d.ts +14 -0
  138. package/dist/sessions/index.d.ts.map +1 -0
  139. package/dist/sessions/index.js +14 -0
  140. package/dist/sessions/index.js.map +1 -0
  141. package/dist/{transport/connection/credentialLifecycle.d.ts → sessions/lifecycle.d.ts} +6 -6
  142. package/dist/sessions/lifecycle.d.ts.map +1 -0
  143. package/dist/{transport/connection/credentialLifecycle.js → sessions/lifecycle.js} +5 -5
  144. package/dist/sessions/lifecycle.js.map +1 -0
  145. package/dist/sessions/source.d.ts +24 -0
  146. package/dist/sessions/source.d.ts.map +1 -0
  147. package/dist/sessions/source.js +56 -0
  148. package/dist/sessions/source.js.map +1 -0
  149. package/dist/source/adapters/adapterProfile.d.ts +6 -6
  150. package/dist/source/adapters/conformance.js.map +1 -1
  151. package/dist/source/adapters/contract.d.ts +14 -14
  152. package/dist/source/adapters/kysely.js.map +1 -1
  153. package/dist/source/adapters/memory.js.map +1 -1
  154. package/dist/source/adapters/prisma.js.map +1 -1
  155. package/dist/source/connector/client.d.ts.map +1 -1
  156. package/dist/source/connector/client.js.map +1 -1
  157. package/dist/source/connector/protocol.d.ts.map +1 -1
  158. package/dist/source/delivery/pushQueue.d.ts.map +1 -1
  159. package/dist/source/delivery/pushQueue.js.map +1 -1
  160. package/dist/source/endpoint/signing.d.ts +4 -4
  161. package/dist/source/endpoint/signing.d.ts.map +1 -1
  162. package/dist/source/outbox/contract.d.ts +4 -4
  163. package/dist/testing/fixtures/httpResponses.d.ts +2 -2
  164. package/dist/testing/fixtures/httpResponses.d.ts.map +1 -1
  165. package/dist/testing/fixtures/httpResponses.js.map +1 -1
  166. package/dist/transport/connection/index.d.ts +0 -1
  167. package/dist/transport/connection/index.d.ts.map +1 -1
  168. package/dist/transport/connection/index.js +0 -1
  169. package/dist/transport/connection/index.js.map +1 -1
  170. package/dist/transport/connection/manager.d.ts +1 -1
  171. package/dist/transport/connection/manager.d.ts.map +1 -1
  172. package/dist/transport/connection/manager.js.map +1 -1
  173. package/dist/transport/connection/networkProbe.d.ts +5 -18
  174. package/dist/transport/connection/networkProbe.d.ts.map +1 -1
  175. package/dist/transport/connection/networkProbe.js.map +1 -1
  176. package/dist/transport/http/claimWait.d.ts +9 -0
  177. package/dist/transport/http/claimWait.d.ts.map +1 -0
  178. package/dist/transport/http/claimWait.js +74 -0
  179. package/dist/transport/http/claimWait.js.map +1 -0
  180. package/dist/transport/http/client.d.ts +11 -19
  181. package/dist/transport/http/client.d.ts.map +1 -1
  182. package/dist/transport/http/client.js +15 -18
  183. package/dist/transport/http/client.js.map +1 -1
  184. package/dist/transport/http/contract.d.ts +57 -0
  185. package/dist/transport/http/contract.d.ts.map +1 -0
  186. package/dist/transport/http/contract.js +3 -0
  187. package/dist/transport/http/contract.js.map +1 -0
  188. package/dist/transport/http/index.d.ts +1 -0
  189. package/dist/transport/http/index.d.ts.map +1 -1
  190. package/dist/transport/http/index.js +1 -0
  191. package/dist/transport/http/index.js.map +1 -1
  192. package/dist/transport/http/options.d.ts +4 -1
  193. package/dist/transport/http/options.d.ts.map +1 -1
  194. package/dist/transport/http/transport.d.ts +1 -84
  195. package/dist/transport/http/transport.d.ts.map +1 -1
  196. package/dist/transport/http/transport.js +16 -134
  197. package/dist/transport/http/transport.js.map +1 -1
  198. package/dist/transport/websocket/commitFrames.d.ts +1 -2
  199. package/dist/transport/websocket/commitFrames.d.ts.map +1 -1
  200. package/dist/transport/websocket/commitFrames.js.map +1 -1
  201. package/dist/transport/websocket/frameHandlers.d.ts +0 -14
  202. package/dist/transport/websocket/frameHandlers.d.ts.map +1 -1
  203. package/dist/transport/websocket/frameHandlers.js +4 -71
  204. package/dist/transport/websocket/frameHandlers.js.map +1 -1
  205. package/dist/transport/websocket/heartbeat.d.ts.map +1 -1
  206. package/dist/transport/websocket/heartbeat.js.map +1 -1
  207. package/dist/transport/websocket/session.d.ts.map +1 -1
  208. package/dist/transport/websocket/session.js +137 -64
  209. package/dist/transport/websocket/session.js.map +1 -1
  210. package/dist/transport/websocket/sessionContract.d.ts +4 -15
  211. package/dist/transport/websocket/sessionContract.d.ts.map +1 -1
  212. package/dist/transport/websocket/transport.d.ts +6 -42
  213. package/dist/transport/websocket/transport.d.ts.map +1 -1
  214. package/dist/transport/websocket/transport.js +14 -101
  215. package/dist/transport/websocket/transport.js.map +1 -1
  216. package/dist/wire/accountResponses.d.ts +31 -31
  217. package/dist/wire/auth.d.ts +3 -3
  218. package/dist/wire/bootstrapReason.d.ts +2 -2
  219. package/dist/wire/dataSourceResponses.d.ts +2 -2
  220. package/dist/wire/dataSourceResponses.d.ts.map +1 -1
  221. package/dist/wire/frames.d.ts +8 -8
  222. package/dist/wire/inboundFrames.d.ts +60 -63
  223. package/dist/wire/inboundFrames.d.ts.map +1 -1
  224. package/dist/wire/inboundFrames.js +5 -6
  225. package/dist/wire/inboundFrames.js.map +1 -1
  226. package/dist/wire/listEnvelope.js.map +1 -1
  227. package/dist/wire/modelResponses.d.ts +2 -2
  228. package/dist/wire/modelShape.d.ts +4 -4
  229. package/dist/wire/pricing.d.ts +3 -3
  230. package/dist/wire/protocolVersion.d.ts.map +1 -1
  231. package/package.json +6 -1
  232. package/src/auth/apiKey.ts +25 -44
  233. package/src/auth/browserCredentialSafety.ts +1 -1
  234. package/src/auth/capabilityLifecycle.ts +3 -2
  235. package/src/auth/credentialEndpoint.ts +1 -1
  236. package/src/auth/credentialResult.ts +2 -0
  237. package/src/claims/admission.ts +49 -0
  238. package/src/claims/awaitGrant.ts +3 -52
  239. package/src/claims/conflict.ts +103 -0
  240. package/src/claims/contract.ts +2 -0
  241. package/src/claims/events.ts +1 -2
  242. package/src/claims/index.ts +11 -0
  243. package/src/client/ablo.ts +15 -10
  244. package/src/client/resources/httpResources.ts +2 -174
  245. package/src/client/resources/modelOperations.ts +0 -10
  246. package/src/client/surface.ts +2 -4
  247. package/src/coordination/index.ts +4 -9
  248. package/src/coordination/schema.ts +18 -81
  249. package/src/errorCodes.ts +21 -13
  250. package/src/errors.ts +116 -4
  251. package/src/sessions/client.ts +121 -0
  252. package/src/sessions/contract.ts +116 -0
  253. package/src/{auth/sessionMint.ts → sessions/create.ts} +13 -12
  254. package/src/sessions/handler.ts +96 -0
  255. package/src/sessions/index.ts +13 -0
  256. package/src/{transport/connection/credentialLifecycle.ts → sessions/lifecycle.ts} +5 -5
  257. package/src/sessions/source.ts +94 -0
  258. package/src/transport/connection/index.ts +0 -1
  259. package/src/transport/connection/manager.ts +1 -1
  260. package/src/transport/http/claimWait.ts +108 -0
  261. package/src/transport/http/client.ts +27 -36
  262. package/src/transport/http/contract.ts +71 -0
  263. package/src/transport/http/index.ts +1 -0
  264. package/src/transport/http/options.ts +4 -1
  265. package/src/transport/http/transport.ts +22 -275
  266. package/src/transport/websocket/commitFrames.ts +0 -2
  267. package/src/transport/websocket/frameHandlers.ts +7 -102
  268. package/src/transport/websocket/session.ts +162 -73
  269. package/src/transport/websocket/sessionContract.ts +4 -13
  270. package/src/transport/websocket/transport.ts +15 -137
  271. package/src/wire/inboundFrames.ts +5 -6
  272. package/dist/auth/sessionMint.d.ts.map +0 -1
  273. package/dist/auth/sessionMint.js.map +0 -1
  274. package/dist/transport/connection/credentialLifecycle.d.ts.map +0 -1
  275. package/dist/transport/connection/credentialLifecycle.js.map +0 -1
@@ -2,8 +2,15 @@ import {
2
2
  AbloClaimedError,
3
3
  AbloConnectionError,
4
4
  AbloSessionError,
5
- errorFromWire,
5
+ isAccessCredentialExpiryCloseReason,
6
6
  } from '../../errors.js';
7
+ import { claimAdmissionError } from '../../claims/admission.js';
8
+ import { classifyCredentialKind } from '../../auth/credentialKind.js';
9
+ import {
10
+ credentialToken,
11
+ type CredentialProviderResult,
12
+ } from '../../auth/credentialResult.js';
13
+ import { CredentialLifecycle } from '../../sessions/lifecycle.js';
7
14
  import {
8
15
  clientSyncDeltaSchema,
9
16
  type ClientSyncDelta,
@@ -21,7 +28,6 @@ import type {
21
28
  WebSocketObserveOptions,
22
29
  WebSocketObservedDelta,
23
30
  WebSocketPresence,
24
- WebSocketJoinInput,
25
31
  WebSocketSessionOptions,
26
32
  OpenCollaborationEvents,
27
33
  } from './sessionContract.js';
@@ -32,6 +38,10 @@ interface StoredPosition {
32
38
  }
33
39
 
34
40
  const MAX_BUFFERED_OBSERVATION_DELTAS = 1_024;
41
+ const ignoreObservedAcknowledgeFailure = (): undefined => undefined;
42
+ // Preserve the originating claim wait error when best-effort queue cleanup
43
+ // fails for the same disconnected transport.
44
+ const ignoreBestEffortClaimReleaseFailure = (): undefined => undefined;
35
45
 
36
46
  function parsePosition(value: string | null): StoredPosition {
37
47
  if (!value) return { lastSyncId: 0, cursor: null };
@@ -125,17 +135,29 @@ class WebSocketSession<TEvents extends EventMap<TEvents>>
125
135
  private closed = false;
126
136
  private currentToken: string;
127
137
  private acknowledgeLane: Promise<void> = Promise.resolve();
138
+ private terminalError: Error | null = null;
139
+ private readonly terminalListeners = new Set<(error: Error) => void>();
140
+ private readonly credentialLifecycle: CredentialLifecycle | null;
141
+ private pendingSessionError: Error | null = null;
128
142
 
129
143
  readonly presence: WebSocketPresence;
130
144
  readonly collaboration: WebSocketCollaboration<TEvents>;
131
145
 
132
- constructor(options: WebSocketSessionOptions, position: StoredPosition, token: string) {
146
+ constructor(
147
+ options: WebSocketSessionOptions,
148
+ position: StoredPosition,
149
+ credential: CredentialProviderResult,
150
+ ) {
133
151
  this.options = options;
152
+ const token = credentialToken(credential);
153
+ if (!token) {
154
+ throw new AbloSessionError('The WebSocket transport requires an authenticated credential.');
155
+ }
134
156
  this.currentToken = token;
135
157
  this.cursorKey = options.cursorKey ?? 'websocket';
136
158
  this.socket = new AgentWebSocket<TEvents>({
137
159
  baseUrl: options.baseUrl,
138
- kind: 'agent',
160
+ kind: classifyCredentialKind(token) === 'ephemeral' ? 'user' : 'agent',
139
161
  getAuthToken: () => this.currentToken,
140
162
  syncGroups: [...(options.syncGroups ?? [])],
141
163
  collaborationEvents: [...(options.collaborationEvents ?? [])],
@@ -148,23 +170,73 @@ class WebSocketSession<TEvents extends EventMap<TEvents>>
148
170
  this.collaboration = {
149
171
  send: (event, payload) => this.socket.sendCollaborationEvent(event, payload),
150
172
  } as WebSocketCollaboration<TEvents>;
151
- this.socket.subscribe('session_error', () => {
152
- void this.refreshExpiredCredential();
173
+ this.socket.subscribe('session_error', (error) => {
174
+ this.handleSessionError(error);
153
175
  });
176
+ this.credentialLifecycle = options.access.renewable
177
+ ? new CredentialLifecycle({
178
+ setAuthToken: (freshToken) => {
179
+ this.currentToken = freshToken;
180
+ },
181
+ nudgeReconnect: () => {
182
+ if (this.closed || this.terminalError) return;
183
+ this.socket.clearSessionError();
184
+ this.socket.resetReconnectAttempts();
185
+ this.socket.connect();
186
+ },
187
+ reportSessionExpired: () => {
188
+ this.failTerminal(this.pendingSessionError ?? new AbloSessionError(
189
+ 'The application session ended while renewing its Ablo session.',
190
+ ));
191
+ },
192
+ })
193
+ : null;
194
+ if (this.credentialLifecycle) {
195
+ this.credentialLifecycle.start(() => this.loadCredential(), { proactiveInNode: true });
196
+ this.credentialLifecycle.accept(credential);
197
+ }
154
198
  }
155
199
 
156
- private async refreshExpiredCredential(): Promise<void> {
157
- if (this.closed) return;
158
- try {
159
- const next = await this.options.getAuthToken();
160
- if (!next || next === this.currentToken) return;
161
- this.currentToken = next;
162
- this.socket.clearSessionError();
163
- this.socket.connect();
164
- } catch {
165
- // The terminal session_error remains the public signal. A later explicit
166
- // ready() call can retry after the caller repairs its credential source.
200
+ private async loadCredential(): Promise<CredentialProviderResult> {
201
+ return this.options.access.credential();
202
+ }
203
+
204
+ private handleSessionError(error: Error): void {
205
+ if (
206
+ !this.credentialLifecycle
207
+ || !isAccessCredentialExpiryCloseReason(error.message)
208
+ ) {
209
+ this.failTerminal(error);
210
+ return;
167
211
  }
212
+ this.pendingSessionError = error;
213
+ void this.credentialLifecycle
214
+ .recoverFromAuthRejection('access_credential_expiry')
215
+ .catch((recoveryError: unknown) => {
216
+ this.failTerminal(
217
+ recoveryError instanceof Error
218
+ ? recoveryError
219
+ : new AbloConnectionError('The WebSocket credential recovery failed.'),
220
+ );
221
+ });
222
+ }
223
+
224
+ private failTerminal(error: Error): void {
225
+ if (this.terminalError || this.closed) return;
226
+ this.terminalError = error;
227
+ this.socket.setSessionErrorDetected();
228
+ this.socket.disconnect();
229
+ for (const listener of this.terminalListeners) listener(error);
230
+ }
231
+
232
+ private onTerminal(listener: (error: Error) => void): () => void {
233
+ if (this.terminalError) {
234
+ const error = this.terminalError;
235
+ queueMicrotask(() => listener(error));
236
+ return () => undefined;
237
+ }
238
+ this.terminalListeners.add(listener);
239
+ return () => this.terminalListeners.delete(listener);
168
240
  }
169
241
 
170
242
  get connected(): boolean {
@@ -175,6 +247,7 @@ class WebSocketSession<TEvents extends EventMap<TEvents>>
175
247
  if (this.closed) {
176
248
  return Promise.reject(new AbloConnectionError('The WebSocket session is closed.'));
177
249
  }
250
+ if (this.terminalError) return Promise.reject(this.terminalError);
178
251
  if (this.socket.isConnected()) return Promise.resolve();
179
252
  if (this.readyPromise) return this.readyPromise;
180
253
  this.readyPromise = new Promise<void>((resolve, reject) => {
@@ -190,7 +263,7 @@ class WebSocketSession<TEvents extends EventMap<TEvents>>
190
263
  cleanup();
191
264
  resolve();
192
265
  });
193
- const failed = this.socket.subscribe('session_error', (error) => {
266
+ const failed = this.onTerminal((error) => {
194
267
  rejectConnection(error);
195
268
  });
196
269
  const mismatch = this.socket.subscribe('protocol_mismatch', () => {
@@ -223,14 +296,27 @@ class WebSocketSession<TEvents extends EventMap<TEvents>>
223
296
 
224
297
  async claim(input: WebSocketClaimInput) {
225
298
  await this.ready();
226
- const timeoutMs = input.timeoutMs ?? 15_000;
227
299
  return new Promise<import('../../coordination/schema.js').ClaimAcquired | import('../../coordination/schema.js').ClaimGranted>((resolve, reject) => {
228
300
  const cleanups: (() => void)[] = [];
301
+ let finished = false;
229
302
  const finish = (outcome: () => void): void => {
230
- clearTimeout(timeout);
303
+ if (finished) return;
304
+ finished = true;
305
+ if (timeout) clearTimeout(timeout);
231
306
  for (const cleanup of cleanups) cleanup();
232
307
  outcome();
233
308
  };
309
+ const abandonAndReject = (error: Error): void => {
310
+ if (finished) return;
311
+ finished = true;
312
+ if (timeout) clearTimeout(timeout);
313
+ for (const cleanup of cleanups) cleanup();
314
+ void this.release({
315
+ claimId: input.claimId,
316
+ entityType: input.entityType,
317
+ entityId: input.entityId,
318
+ }).catch(ignoreBestEffortClaimReleaseFailure).then(() => reject(error));
319
+ };
234
320
  const matches = (event: { claimId: string }): boolean => event.claimId === input.claimId;
235
321
  cleanups.push(this.socket.subscribe('claim_acquired', (event) => {
236
322
  if (matches(event)) finish(() => resolve(event));
@@ -242,27 +328,12 @@ class WebSocketSession<TEvents extends EventMap<TEvents>>
242
328
  if (!matches(event)) return;
243
329
  const refusal = input.onQueued?.(event);
244
330
  if (refusal) {
245
- this.release({
246
- claimId: input.claimId,
247
- entityType: input.entityType,
248
- entityId: input.entityId,
249
- });
250
- finish(() => reject(refusal));
331
+ abandonAndReject(refusal);
251
332
  }
252
333
  }));
253
334
  cleanups.push(this.socket.subscribe('claim_rejected', (event) => {
254
335
  if (!matches(event)) return;
255
- finish(() => reject(errorFromWire(
256
- event.message ?? `Claim ${event.claimId} was rejected.`,
257
- {
258
- code: event.reason === 'conflict' ? 'claim_conflict' : 'claim_rejected',
259
- details: {
260
- ...(event.target ? { target: event.target } : {}),
261
- ...(event.heldBy ? { heldBy: event.heldBy } : {}),
262
- ...(event.heldByClaimId ? { heldByClaimId: event.heldByClaimId } : {}),
263
- },
264
- },
265
- )));
336
+ finish(() => reject(claimAdmissionError(event)));
266
337
  }));
267
338
  cleanups.push(this.socket.subscribe('disconnected', () => {
268
339
  finish(() => reject(new AbloConnectionError(
@@ -270,29 +341,21 @@ class WebSocketSession<TEvents extends EventMap<TEvents>>
270
341
  )));
271
342
  }));
272
343
  const abort = (): void => {
273
- this.release({
274
- claimId: input.claimId,
275
- entityType: input.entityType,
276
- entityId: input.entityId,
277
- });
278
- finish(() => reject(new AbloClaimedError(
344
+ abandonAndReject(new AbloClaimedError(
279
345
  `The wait for claim ${input.claimId} was aborted.`,
280
346
  { code: 'claim_wait_aborted' },
281
- )));
347
+ ));
282
348
  };
283
349
  input.signal?.addEventListener('abort', abort, { once: true });
284
350
  cleanups.push(() => input.signal?.removeEventListener('abort', abort));
285
- const timeout = setTimeout(() => {
286
- this.release({
287
- claimId: input.claimId,
288
- entityType: input.entityType,
289
- entityId: input.entityId,
290
- });
291
- finish(() => reject(new AbloClaimedError(
292
- `claim timed out after ${timeoutMs}ms (claimId=${input.claimId})`,
293
- { code: 'grant_timeout' },
294
- )));
295
- }, timeoutMs);
351
+ const timeout = input.timeoutMs !== undefined
352
+ ? setTimeout(() => {
353
+ abandonAndReject(new AbloClaimedError(
354
+ `claim timed out after ${input.timeoutMs}ms (claimId=${input.claimId})`,
355
+ { code: 'grant_timeout' },
356
+ ));
357
+ }, input.timeoutMs)
358
+ : undefined;
296
359
  if (input.signal?.aborted) {
297
360
  abort();
298
361
  return;
@@ -307,23 +370,42 @@ class WebSocketSession<TEvents extends EventMap<TEvents>>
307
370
  });
308
371
  }
309
372
 
310
- release(input: { claimId: string; entityType?: string; entityId?: string }): void {
311
- this.socket.send({ type: 'claim_abandon', payload: input });
312
- }
313
-
314
- async join(input: WebSocketJoinInput) {
373
+ async release(input: {
374
+ claimId: string;
375
+ entityType?: string;
376
+ entityId?: string;
377
+ }): Promise<void> {
315
378
  await this.ready();
316
- return this.socket.sendClaim(input.claimId, input.syncGroups, {
317
- capabilityToken: input.capabilityToken,
318
- ttlSeconds: input.ttlSeconds,
319
- timeoutMs: input.timeoutMs,
379
+ const requestId = crypto.randomUUID();
380
+ await new Promise<void>((resolve, reject) => {
381
+ const finish = (outcome: () => void): void => {
382
+ clearTimeout(timeout);
383
+ offAck();
384
+ offDisconnect();
385
+ outcome();
386
+ };
387
+ const offAck = this.socket.subscribe('claim_abandon_ack', (event) => {
388
+ if (event.requestId !== requestId || event.claimId !== input.claimId) return;
389
+ finish(resolve);
390
+ });
391
+ const offDisconnect = this.socket.subscribe('disconnected', () => {
392
+ finish(() => reject(new AbloConnectionError(
393
+ `WebSocket closed before claim ${input.claimId} release was confirmed.`,
394
+ )));
395
+ });
396
+ const timeout = setTimeout(() => {
397
+ finish(() => reject(new AbloConnectionError(
398
+ `Timed out waiting for claim ${input.claimId} release confirmation.`,
399
+ { code: 'claim_lease_unavailable' },
400
+ )));
401
+ }, 15_000);
402
+ this.socket.send({
403
+ type: 'claim_abandon',
404
+ payload: { ...input, requestId },
405
+ });
320
406
  });
321
407
  }
322
408
 
323
- leave(claimId: string): void {
324
- this.socket.sendRelease(claimId);
325
- }
326
-
327
409
  subscribe<K extends keyof SyncWebSocketEventMap<TEvents>>(
328
410
  event: K,
329
411
  listener: (...args: SyncWebSocketEventMap<TEvents>[K]) => void,
@@ -357,7 +439,7 @@ class WebSocketSession<TEvents extends EventMap<TEvents>>
357
439
  wake?.();
358
440
  wake = undefined;
359
441
  });
360
- const offError = this.socket.subscribe('session_error', (error) => {
442
+ const offError = this.onTerminal((error) => {
361
443
  failure = error;
362
444
  wake?.();
363
445
  wake = undefined;
@@ -408,19 +490,21 @@ class WebSocketSession<TEvents extends EventMap<TEvents>>
408
490
  }
409
491
 
410
492
  async acknowledge(lastSyncId: number): Promise<void> {
411
- const attempt = this.acknowledgeLane.catch(() => undefined).then(async () => {
493
+ const attempt = this.acknowledgeLane.catch(ignoreObservedAcknowledgeFailure).then(async () => {
412
494
  const position = this.socket.positionAfter(lastSyncId);
413
495
  await this.options.cursorStore?.save(this.cursorKey, JSON.stringify(position));
414
496
  this.socket.markDurable(position);
415
497
  this.socket.acknowledge(position.lastSyncId);
416
498
  });
417
- this.acknowledgeLane = attempt.catch(() => undefined);
499
+ this.acknowledgeLane = attempt.catch(ignoreObservedAcknowledgeFailure);
418
500
  await attempt;
419
501
  }
420
502
 
421
503
  async close(): Promise<void> {
422
504
  if (this.closed) return;
423
505
  this.closed = true;
506
+ this.credentialLifecycle?.stop();
507
+ this.terminalListeners.clear();
424
508
  this.rejectReady?.(new AbloConnectionError('The WebSocket session was disposed.'));
425
509
  await this.acknowledgeLane;
426
510
  this.socket.disconnect();
@@ -439,11 +523,16 @@ export async function createWebSocketSession<
439
523
  ): Promise<AbloWebSocketSession<TEvents>> {
440
524
  const cursorKey = options.cursorKey ?? 'websocket';
441
525
  const stored = await options.cursorStore?.load(cursorKey) ?? null;
442
- const token = await options.getAuthToken();
526
+ const credential = await options.access.credential();
527
+ const token = credentialToken(credential);
443
528
  if (!token) {
444
529
  throw new AbloSessionError('The WebSocket transport requires an authenticated credential.');
445
530
  }
446
- const session = new WebSocketSession<TEvents>(options, parsePosition(stored), token);
531
+ const session = new WebSocketSession<TEvents>(
532
+ options,
533
+ parsePosition(stored),
534
+ credential,
535
+ );
447
536
  let rejectOpening: ((error: Error) => void) | undefined;
448
537
  const aborted = new Promise<never>((_resolve, reject) => {
449
538
  rejectOpening = reject;
@@ -8,6 +8,7 @@ import type {
8
8
  } from '../../coordination/schema.js';
9
9
  import type { ClientSyncDelta } from '../../observation/contract.js';
10
10
  import type { ObserveCursorStore } from '../../client/contract.js';
11
+ import type { SessionAccess } from '../../sessions/source.js';
11
12
  import type { CommitFrameOperation } from '../websocket/commitFrames.js';
12
13
  import type {
13
14
  CoreSyncEventMap,
@@ -18,7 +19,8 @@ import type {
18
19
 
19
20
  export interface WebSocketSessionOptions {
20
21
  readonly baseUrl?: string;
21
- readonly getAuthToken: () => string | undefined | Promise<string | undefined>;
22
+ /** Normalized once at the client boundary; transports do not infer policy. */
23
+ readonly access: SessionAccess;
22
24
  readonly syncGroups?: readonly string[];
23
25
  readonly collaborationEvents?: readonly string[];
24
26
  readonly cursorKey?: string;
@@ -44,15 +46,6 @@ export interface WebSocketObserveOptions {
44
46
  readonly signal?: AbortSignal;
45
47
  }
46
48
 
47
- /** A multiplexed participant-scope registration on the shared socket. */
48
- export interface WebSocketJoinInput {
49
- readonly claimId: string;
50
- readonly syncGroups: readonly string[];
51
- readonly capabilityToken?: string;
52
- readonly ttlSeconds?: number;
53
- readonly timeoutMs?: number;
54
- }
55
-
56
49
  /** A durable row/field claim carried over the same live protocol. */
57
50
  export type WebSocketClaimInput = ClaimBeginPayload & {
58
51
  readonly timeoutMs?: number;
@@ -94,9 +87,7 @@ export interface AbloWebSocketSession<
94
87
  readonly claimId: string;
95
88
  readonly entityType?: string;
96
89
  readonly entityId?: string;
97
- }): void;
98
- join(input: WebSocketJoinInput): Promise<{ syncGroups: string[]; ttlSeconds?: number }>;
99
- leave(claimId: string): void;
90
+ }): Promise<void>;
100
91
  subscribe<K extends keyof SyncWebSocketEventMap<TEvents>>(
101
92
  event: K,
102
93
  listener: (...args: SyncWebSocketEventMap<TEvents>[K]) => void,
@@ -22,28 +22,23 @@ import { EventEmitter } from 'events';
22
22
  import type { ParticipantKind } from '../../types/participant.js';
23
23
  import {
24
24
  AbloConnectionError,
25
- AbloError,
26
25
  AbloSessionError,
27
26
  AbloValidationError,
28
27
  toAbloError,
29
28
  } from '../../errors.js';
30
- import {
31
- participantClaimPayloadSchema,
32
- updateSubscriptionPayloadSchema,
33
- } from '../../coordination/schema.js';
29
+ import { updateSubscriptionPayloadSchema } from '../../coordination/schema.js';
34
30
  import type { BootstrapReason } from '../../wire/bootstrapReason.js';
35
31
  import type { ClientSyncDelta } from '../../observation/contract.js';
36
32
  import type {
37
33
  ClaimAcquired,
34
+ ClaimAbandonAckPayload,
38
35
  PresenceUpdate,
39
- ClaimExpired,
40
36
  ClaimGranted,
41
37
  ClaimHeartbeatAckPayload,
42
38
  ClaimLost,
43
39
  ClaimQueue,
44
40
  ClaimQueued,
45
41
  ClaimRejection,
46
- ParticipantClaimPayload,
47
42
  ReadDependency,
48
43
  WireClaim,
49
44
  } from '../../coordination/schema.js';
@@ -60,7 +55,6 @@ import {
60
55
  readWsInboundFrame,
61
56
  type WsSession,
62
57
  type PendingCommit,
63
- type PendingClaim,
64
58
  type PendingSubscription,
65
59
  } from './frameHandlers.js';
66
60
  import { HeartbeatController } from './heartbeat.js';
@@ -213,14 +207,6 @@ export interface CoreSyncEventMap {
213
207
  */
214
208
  handshake_failed: [CloseEvent];
215
209
  reconnect_failed: [{ attempts: number }];
216
- /**
217
- * Server-initiated notification that a previously-active claim's
218
- * TTL has expired. Consumers (e.g., the participant SDK) re-mint
219
- * a fresh capability and re-claim, OR accept the drop. The claim
220
- * is already inactive on the server side by the time this fires —
221
- * no client-side action needed unless re-claiming.
222
- */
223
- claim_expired: [ClaimExpired];
224
210
  /**
225
211
  * Server rejected an `claim_begin` because another participant
226
212
  * already holds an open claim on the same target (cooperative
@@ -243,6 +229,7 @@ export interface CoreSyncEventMap {
243
229
  */
244
230
  claim_queue: [ClaimQueue];
245
231
  claim_acquired: [ClaimAcquired];
232
+ claim_abandon_ack: [ClaimAbandonAckPayload];
246
233
  claim_queued: [ClaimQueued];
247
234
  claim_granted: [ClaimGranted];
248
235
  claim_lost: [ClaimLost];
@@ -370,6 +357,10 @@ export class WsTransport<
370
357
  * server rejected the upgrade" since browsers hide the HTTP status (e.g.
371
358
  * 401) behind the opaque 1006 close code. */
372
359
  private _everOpened = false;
360
+ /** True after any socket in this logical transport has opened. Unlike
361
+ * `_everOpened`, this survives socket replacement so a failed reconnect
362
+ * handshake is retried instead of being mistaken for a failed first login. */
363
+ private _hasConnected = false;
373
364
  /**
374
365
  * Diagnostic snapshot of the last connection lifecycle. Persisted across
375
366
  * the lifetime of the transport so that any subsequent "not connected"
@@ -405,14 +396,6 @@ export class WsTransport<
405
396
  */
406
397
  private pendingMutations = new Map<string, PendingCommit>();
407
398
 
408
- /**
409
- * In-flight `claim` requests keyed by claimId. Resolved when the matching
410
- * `claim_ack` arrives, or rejected on timeout or disconnect — the same
411
- * request/response pattern as `pendingMutations`, multiplexed over the one
412
- * connection.
413
- */
414
- private pendingClaims = new Map<string, PendingClaim>();
415
-
416
399
  /**
417
400
  * In-flight `update_subscription` frames awaiting `subscription_ack`.
418
401
  * A FIFO queue rather than a keyed Map because the wire ack carries no
@@ -483,7 +466,6 @@ export class WsTransport<
483
466
  logger: this.logger,
484
467
  observability: this.observability,
485
468
  pendingMutations: this.pendingMutations,
486
- pendingClaims: this.pendingClaims,
487
469
  shiftPendingSubscription: () => this.pendingSubscriptions.shift(),
488
470
  options: this.options,
489
471
  collaborationEventTypes: this.collaborationEventTypes,
@@ -731,6 +713,7 @@ export class WsTransport<
731
713
  this.isConnecting = false;
732
714
  this.reconnectAttempts = 0;
733
715
  this._everOpened = true;
716
+ this._hasConnected = true;
734
717
  this.lastOpenAt = Date.now();
735
718
  this.emit('connected');
736
719
 
@@ -861,22 +844,6 @@ export class WsTransport<
861
844
  this.pendingMutations.clear();
862
845
  }
863
846
 
864
- // Cancel in-flight claims — same rationale. Server-side
865
- // claims are bound to the connection; a reconnect will need
866
- // to re-claim. Higher-level retry belongs to whoever holds
867
- // the participant handle (typically the SDK's claim manager).
868
- if (this.pendingClaims.size > 0) {
869
- for (const pending of this.pendingClaims.values()) {
870
- clearTimeout(pending.timeout);
871
- pending.reject(
872
- new AbloConnectionError(
873
- `WebSocket closed while claim was in flight (code=${event.code})`,
874
- ),
875
- );
876
- }
877
- this.pendingClaims.clear();
878
- }
879
-
880
847
  // Cancel in-flight subscription updates — the reconnect handshake
881
848
  // re-sends `options.syncGroups` (the last acked interest) in the
882
849
  // upgrade URL, so a pending change that never acked is simply
@@ -946,7 +913,7 @@ export class WsTransport<
946
913
  // from a transient network issue and transition the UI accordingly.
947
914
  // Reconnecting blindly is what produced the infinite
948
915
  // "offline → reconnecting → offline" loop on stale cookies.
949
- if (!everOpened && !this.isManualClose) {
916
+ if (!everOpened && !this._hasConnected && !this.isManualClose) {
950
917
  this.observability.captureWebSocketError({
951
918
  context: 'handshake-failed-close',
952
919
  code: event.code,
@@ -1172,100 +1139,6 @@ export class WsTransport<
1172
1139
  this.ws.send(JSON.stringify(frame));
1173
1140
  }
1174
1141
 
1175
- /**
1176
- * Activates a participant claim on this connection. One connection can hold
1177
- * several concurrent claims at once, each scoped to a different set of sync
1178
- * groups, so the SDK reuses the existing connection instead of opening a
1179
- * separate socket per scope.
1180
- *
1181
- * Returns a promise that resolves with the server-canonicalized `syncGroups`
1182
- * and effective `ttlSeconds` once `claim_ack` arrives, or rejects with a typed
1183
- * error on a failed ack, a timeout, or a disconnect.
1184
- */
1185
- sendClaim(
1186
- claimId: string,
1187
- syncGroups: readonly string[],
1188
- options?: Pick<ParticipantClaimPayload, 'capabilityToken' | 'ttlSeconds'> & {
1189
- timeoutMs?: number;
1190
- },
1191
- ): Promise<{ syncGroups: string[]; ttlSeconds?: number }> {
1192
- if (this.ws?.readyState !== WebSocket.OPEN) {
1193
- return Promise.reject(this.notConnectedError('claim'));
1194
- }
1195
- // Checked against the schema the server ingests it with, for the same
1196
- // reason `updateSubscription` below is: the two frames name their scopes
1197
- // identically, so a group that would be refused there is refused here, at
1198
- // the call that asked for it, rather than coming back as a failed ack a
1199
- // round trip later with nothing to point at.
1200
- const payload = participantClaimPayloadSchema.safeParse({
1201
- claimId,
1202
- syncGroups: [...syncGroups],
1203
- capabilityToken: options?.capabilityToken,
1204
- ttlSeconds: options?.ttlSeconds,
1205
- });
1206
- if (!payload.success) {
1207
- return Promise.reject(
1208
- new AbloValidationError(
1209
- `join was given a sync group the protocol does not accept: ${payload.error.issues[0]?.message ?? 'unreadable'}. A group is 'default' or 'kind:id'.`,
1210
- { code: 'malformed_claim' },
1211
- ),
1212
- );
1213
- }
1214
- const timeoutMs = options?.timeoutMs ?? 15_000;
1215
- return new Promise((resolve, reject) => {
1216
- const timeout = setTimeout(() => {
1217
- this.pendingClaims.delete(claimId);
1218
- reject(
1219
- new AbloConnectionError(`claim timed out after ${timeoutMs}ms (claimId=${claimId})`, {
1220
- code: 'wait_for_timeout',
1221
- }),
1222
- );
1223
- }, timeoutMs);
1224
- this.pendingClaims.set(claimId, { resolve, reject, timeout });
1225
- try {
1226
- this.ws!.send(JSON.stringify({ type: 'claim', payload: payload.data }));
1227
- } catch (error) {
1228
- clearTimeout(timeout);
1229
- this.pendingClaims.delete(claimId);
1230
- reject(toAbloError(error));
1231
- }
1232
- });
1233
- }
1234
-
1235
- /**
1236
- * Drop a previously-active claim. Idempotent — `release` is
1237
- * fire-and-forget per the wire contract; the server accepts
1238
- * unknown claimIds silently so disconnect-time release storms
1239
- * never error. No ack is expected.
1240
- *
1241
- * If a claim's send promise is still pending (no claim_ack yet),
1242
- * we reject it locally — the user explicitly chose to release.
1243
- */
1244
- sendRelease(claimId: string): void {
1245
- // Cancel any in-flight claim that hadn't acked yet — the user
1246
- // changed their mind. Without this the timer would eventually
1247
- // reject; doing it now matches the user's claim immediately.
1248
- const pending = this.pendingClaims.get(claimId);
1249
- if (pending) {
1250
- clearTimeout(pending.timeout);
1251
- this.pendingClaims.delete(claimId);
1252
- pending.reject(
1253
- new AbloError(`claim ${claimId} released before ack`, {
1254
- code: 'claim_wait_aborted',
1255
- httpStatus: 409,
1256
- }),
1257
- );
1258
- }
1259
- if (this.ws?.readyState !== WebSocket.OPEN) return;
1260
- try {
1261
- this.ws.send(
1262
- JSON.stringify({ type: 'release', payload: { claimId } }),
1263
- );
1264
- } catch {
1265
- // Idempotent contract — silent failure is acceptable here.
1266
- }
1267
- }
1268
-
1269
1142
  /**
1270
1143
  * Moves this connection's read interest — replaces the connection-level sync
1271
1144
  * groups mid-session as the user opens and closes entities. This is the
@@ -1443,8 +1316,13 @@ export class WsTransport<
1443
1316
  }
1444
1317
 
1445
1318
  if (this.ws) {
1446
- this.ws.close(1000, 'Manual disconnect');
1319
+ // Detach before close: Node/undici may synchronously fire `onerror` from
1320
+ // close(). The handler's stale-socket guard then suppresses that expected
1321
+ // manual-close error instead of emitting EventEmitter's fatal unhandled
1322
+ // `error` event. `onclose` deliberately accepts a null owner below.
1323
+ const socket = this.ws;
1447
1324
  this.ws = null;
1325
+ socket.close(1000, 'Manual disconnect');
1448
1326
  }
1449
1327
  }
1450
1328