@abloatai/transaction 0.59.1 → 0.60.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 (166) 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/capabilityLifecycle.d.ts +1 -1
  8. package/dist/auth/capabilityLifecycle.d.ts.map +1 -1
  9. package/dist/auth/capabilityLifecycle.js +2 -1
  10. package/dist/auth/capabilityLifecycle.js.map +1 -1
  11. package/dist/auth/credentialEndpoint.js +1 -1
  12. package/dist/auth/credentialEndpoint.js.map +1 -1
  13. package/dist/auth/credentialResult.d.ts +2 -1
  14. package/dist/auth/credentialResult.d.ts.map +1 -1
  15. package/dist/auth/credentialResult.js.map +1 -1
  16. package/dist/claims/events.d.ts +1 -1
  17. package/dist/claims/events.d.ts.map +1 -1
  18. package/dist/client/ablo.d.ts +15 -11
  19. package/dist/client/ablo.d.ts.map +1 -1
  20. package/dist/client/ablo.js +5 -14
  21. package/dist/client/ablo.js.map +1 -1
  22. package/dist/client/resources/httpResources.d.ts +1 -169
  23. package/dist/client/resources/httpResources.d.ts.map +1 -1
  24. package/dist/client/resources/httpResources.js +1 -1
  25. package/dist/client/resources/httpResources.js.map +1 -1
  26. package/dist/client/resources/modelOperations.d.ts +0 -9
  27. package/dist/client/resources/modelOperations.d.ts.map +1 -1
  28. package/dist/client/surface.d.ts +2 -2
  29. package/dist/client/surface.d.ts.map +1 -1
  30. package/dist/client/surface.js +8 -3
  31. package/dist/client/surface.js.map +1 -1
  32. package/dist/commit/contract.d.ts +10 -10
  33. package/dist/commit/recordRuntime.d.ts +3 -2
  34. package/dist/commit/recordRuntime.d.ts.map +1 -1
  35. package/dist/commit/recordRuntime.js +1 -0
  36. package/dist/commit/recordRuntime.js.map +1 -1
  37. package/dist/coordination/index.d.ts +2 -2
  38. package/dist/coordination/index.d.ts.map +1 -1
  39. package/dist/coordination/index.js +3 -4
  40. package/dist/coordination/index.js.map +1 -1
  41. package/dist/coordination/schema.d.ts +3 -47
  42. package/dist/coordination/schema.d.ts.map +1 -1
  43. package/dist/coordination/schema.js +7 -69
  44. package/dist/coordination/schema.js.map +1 -1
  45. package/dist/errorCodes.d.ts +2 -2
  46. package/dist/errorCodes.d.ts.map +1 -1
  47. package/dist/errorCodes.js +5 -9
  48. package/dist/errorCodes.js.map +1 -1
  49. package/dist/errors.d.ts.map +1 -1
  50. package/dist/errors.js +16 -1
  51. package/dist/errors.js.map +1 -1
  52. package/dist/index.d.ts +1 -1
  53. package/dist/index.d.ts.map +1 -1
  54. package/dist/index.js.map +1 -1
  55. package/dist/sessions/client.d.ts +29 -0
  56. package/dist/sessions/client.d.ts.map +1 -0
  57. package/dist/sessions/client.js +69 -0
  58. package/dist/sessions/client.js.map +1 -0
  59. package/dist/sessions/contract.d.ts +97 -0
  60. package/dist/sessions/contract.d.ts.map +1 -0
  61. package/dist/sessions/contract.js +5 -0
  62. package/dist/sessions/contract.js.map +1 -0
  63. package/dist/{auth/sessionMint.d.ts → sessions/create.d.ts} +4 -4
  64. package/dist/sessions/create.d.ts.map +1 -0
  65. package/dist/{auth/sessionMint.js → sessions/create.js} +11 -10
  66. package/dist/sessions/create.js.map +1 -0
  67. package/dist/sessions/handler.d.ts +23 -0
  68. package/dist/sessions/handler.d.ts.map +1 -0
  69. package/dist/sessions/handler.js +55 -0
  70. package/dist/sessions/handler.js.map +1 -0
  71. package/dist/sessions/index.d.ts +14 -0
  72. package/dist/sessions/index.d.ts.map +1 -0
  73. package/dist/sessions/index.js +14 -0
  74. package/dist/sessions/index.js.map +1 -0
  75. package/dist/{transport/connection/credentialLifecycle.d.ts → sessions/lifecycle.d.ts} +6 -6
  76. package/dist/sessions/lifecycle.d.ts.map +1 -0
  77. package/dist/{transport/connection/credentialLifecycle.js → sessions/lifecycle.js} +5 -5
  78. package/dist/sessions/lifecycle.js.map +1 -0
  79. package/dist/sessions/source.d.ts +24 -0
  80. package/dist/sessions/source.d.ts.map +1 -0
  81. package/dist/sessions/source.js +56 -0
  82. package/dist/sessions/source.js.map +1 -0
  83. package/dist/transport/connection/index.d.ts +0 -1
  84. package/dist/transport/connection/index.d.ts.map +1 -1
  85. package/dist/transport/connection/index.js +0 -1
  86. package/dist/transport/connection/index.js.map +1 -1
  87. package/dist/transport/connection/manager.d.ts +1 -1
  88. package/dist/transport/connection/manager.d.ts.map +1 -1
  89. package/dist/transport/http/client.d.ts +44 -16
  90. package/dist/transport/http/client.d.ts.map +1 -1
  91. package/dist/transport/http/client.js +125 -12
  92. package/dist/transport/http/client.js.map +1 -1
  93. package/dist/transport/http/options.d.ts +4 -1
  94. package/dist/transport/http/options.d.ts.map +1 -1
  95. package/dist/transport/http/transport.d.ts +25 -10
  96. package/dist/transport/http/transport.d.ts.map +1 -1
  97. package/dist/transport/http/transport.js +102 -56
  98. package/dist/transport/http/transport.js.map +1 -1
  99. package/dist/transport/websocket/commitFrames.d.ts +5 -5
  100. package/dist/transport/websocket/commitFrames.d.ts.map +1 -1
  101. package/dist/transport/websocket/commitFrames.js +1 -0
  102. package/dist/transport/websocket/commitFrames.js.map +1 -1
  103. package/dist/transport/websocket/contextSubscription.d.ts +6 -0
  104. package/dist/transport/websocket/contextSubscription.d.ts.map +1 -0
  105. package/dist/transport/websocket/contextSubscription.js +45 -0
  106. package/dist/transport/websocket/contextSubscription.js.map +1 -0
  107. package/dist/transport/websocket/frameHandlers.d.ts +0 -14
  108. package/dist/transport/websocket/frameHandlers.d.ts.map +1 -1
  109. package/dist/transport/websocket/frameHandlers.js +1 -71
  110. package/dist/transport/websocket/frameHandlers.js.map +1 -1
  111. package/dist/transport/websocket/session.d.ts +4 -0
  112. package/dist/transport/websocket/session.d.ts.map +1 -0
  113. package/dist/transport/websocket/session.js +425 -0
  114. package/dist/transport/websocket/session.js.map +1 -0
  115. package/dist/transport/websocket/sessionContract.d.ts +78 -0
  116. package/dist/transport/websocket/sessionContract.d.ts.map +1 -0
  117. package/dist/transport/websocket/sessionContract.js +2 -0
  118. package/dist/transport/websocket/sessionContract.js.map +1 -0
  119. package/dist/transport/websocket/transport.d.ts +27 -42
  120. package/dist/transport/websocket/transport.d.ts.map +1 -1
  121. package/dist/transport/websocket/transport.js +77 -102
  122. package/dist/transport/websocket/transport.js.map +1 -1
  123. package/dist/wire/inboundFrames.d.ts +0 -10
  124. package/dist/wire/inboundFrames.d.ts.map +1 -1
  125. package/dist/wire/inboundFrames.js +1 -6
  126. package/dist/wire/inboundFrames.js.map +1 -1
  127. package/package.json +7 -1
  128. package/src/auth/apiKey.ts +25 -44
  129. package/src/auth/browserCredentialSafety.ts +1 -1
  130. package/src/auth/capabilityLifecycle.ts +3 -2
  131. package/src/auth/credentialEndpoint.ts +1 -1
  132. package/src/auth/credentialResult.ts +2 -0
  133. package/src/claims/events.ts +1 -2
  134. package/src/client/ablo.ts +23 -13
  135. package/src/client/resources/httpResources.ts +2 -174
  136. package/src/client/resources/modelOperations.ts +0 -10
  137. package/src/client/surface.ts +9 -4
  138. package/src/commit/recordRuntime.ts +4 -2
  139. package/src/coordination/index.ts +2 -9
  140. package/src/coordination/schema.ts +7 -81
  141. package/src/errorCodes.ts +8 -13
  142. package/src/errors.ts +18 -1
  143. package/src/index.ts +1 -0
  144. package/src/sessions/client.ts +121 -0
  145. package/src/sessions/contract.ts +116 -0
  146. package/src/{auth/sessionMint.ts → sessions/create.ts} +13 -12
  147. package/src/sessions/handler.ts +96 -0
  148. package/src/sessions/index.ts +13 -0
  149. package/src/{transport/connection/credentialLifecycle.ts → sessions/lifecycle.ts} +5 -5
  150. package/src/sessions/source.ts +94 -0
  151. package/src/transport/connection/index.ts +0 -1
  152. package/src/transport/connection/manager.ts +1 -1
  153. package/src/transport/http/client.ts +183 -26
  154. package/src/transport/http/options.ts +4 -1
  155. package/src/transport/http/transport.ts +140 -78
  156. package/src/transport/websocket/commitFrames.ts +5 -5
  157. package/src/transport/websocket/contextSubscription.ts +55 -0
  158. package/src/transport/websocket/frameHandlers.ts +0 -102
  159. package/src/transport/websocket/session.ts +522 -0
  160. package/src/transport/websocket/sessionContract.ts +106 -0
  161. package/src/transport/websocket/transport.ts +85 -137
  162. package/src/wire/inboundFrames.ts +0 -6
  163. package/dist/auth/sessionMint.d.ts.map +0 -1
  164. package/dist/auth/sessionMint.js.map +0 -1
  165. package/dist/transport/connection/credentialLifecycle.d.ts.map +0 -1
  166. package/dist/transport/connection/credentialLifecycle.js.map +0 -1
@@ -0,0 +1,522 @@
1
+ import {
2
+ AbloClaimedError,
3
+ AbloConnectionError,
4
+ AbloSessionError,
5
+ errorFromWire,
6
+ isAccessCredentialExpiryCloseReason,
7
+ } from '../../errors.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';
14
+ import {
15
+ clientSyncDeltaSchema,
16
+ type ClientSyncDelta,
17
+ } from '../../observation/contract.js';
18
+ import {
19
+ WsTransport,
20
+ type EventMap,
21
+ type SyncWebSocketEventMap,
22
+ } from './transport.js';
23
+ import type {
24
+ AbloWebSocketSession,
25
+ WebSocketClaimInput,
26
+ WebSocketCollaboration,
27
+ WebSocketCommitInput,
28
+ WebSocketObserveOptions,
29
+ WebSocketObservedDelta,
30
+ WebSocketPresence,
31
+ WebSocketSessionOptions,
32
+ OpenCollaborationEvents,
33
+ } from './sessionContract.js';
34
+
35
+ interface StoredPosition {
36
+ readonly lastSyncId: number;
37
+ readonly cursor: string | null;
38
+ }
39
+
40
+ const MAX_BUFFERED_OBSERVATION_DELTAS = 1_024;
41
+
42
+ function parsePosition(value: string | null): StoredPosition {
43
+ if (!value) return { lastSyncId: 0, cursor: null };
44
+ try {
45
+ const parsed = JSON.parse(value) as { lastSyncId?: unknown; cursor?: unknown };
46
+ if (
47
+ Number.isSafeInteger(parsed.lastSyncId)
48
+ && (parsed.lastSyncId as number) >= 0
49
+ && (parsed.cursor === null || typeof parsed.cursor === 'string')
50
+ ) {
51
+ return {
52
+ lastSyncId: parsed.lastSyncId as number,
53
+ cursor: parsed.cursor as string | null,
54
+ };
55
+ }
56
+ } catch {
57
+ // Pre-live stores may contain the HTTP feed cursor. It is a different
58
+ // protocol position, so failing closed at zero is safer than skipping.
59
+ }
60
+ return { lastSyncId: 0, cursor: null };
61
+ }
62
+
63
+ class AgentWebSocket<
64
+ TEvents extends EventMap<TEvents>,
65
+ > extends WsTransport<TEvents> {
66
+ private position: StoredPosition;
67
+
68
+ constructor(
69
+ options: ConstructorParameters<typeof WsTransport<TEvents>>[0],
70
+ position: StoredPosition,
71
+ ) {
72
+ super(options);
73
+ this.position = position;
74
+ }
75
+
76
+ protected override resumeCursor(): string {
77
+ return this.position.cursor ?? '';
78
+ }
79
+
80
+ protected override onOpened(): void {
81
+ this.updatePresence();
82
+ this.requestSync(this.position);
83
+ }
84
+
85
+ protected override handleDelta(raw: unknown): void {
86
+ const parsed = clientSyncDeltaSchema.safeParse(raw);
87
+ if (parsed.success) this.emit('delta', parsed.data);
88
+ }
89
+
90
+ protected override handleSyncResponse(raw: unknown): void {
91
+ if (typeof raw !== 'object' || raw === null) return;
92
+ const response = raw as {
93
+ deltas?: unknown;
94
+ newCursor?: unknown;
95
+ cursor?: unknown;
96
+ currentSyncId?: unknown;
97
+ };
98
+ if (Array.isArray(response.deltas)) {
99
+ for (const delta of response.deltas) this.handleDelta(delta);
100
+ }
101
+ this.emit('sync_response', response);
102
+ }
103
+
104
+ positionAfter(lastSyncId: number): StoredPosition {
105
+ return {
106
+ lastSyncId: Math.max(this.position.lastSyncId, lastSyncId),
107
+ cursor: this.position.cursor,
108
+ };
109
+ }
110
+
111
+ markDurable(position: StoredPosition): void {
112
+ this.position = position;
113
+ }
114
+
115
+ durablePosition(): StoredPosition {
116
+ return this.position;
117
+ }
118
+
119
+ requestFromDurablePosition(): void {
120
+ this.requestSync(this.position);
121
+ }
122
+ }
123
+
124
+ class WebSocketSession<TEvents extends EventMap<TEvents>>
125
+ implements AbloWebSocketSession<TEvents> {
126
+ private readonly socket: AgentWebSocket<TEvents>;
127
+ private readonly options: WebSocketSessionOptions;
128
+ private readonly cursorKey: string;
129
+ private readyPromise: Promise<void> | null = null;
130
+ private rejectReady: ((error: Error) => void) | null = null;
131
+ private closed = false;
132
+ private currentToken: string;
133
+ private acknowledgeLane: Promise<void> = Promise.resolve();
134
+ private terminalError: Error | null = null;
135
+ private readonly terminalListeners = new Set<(error: Error) => void>();
136
+ private readonly credentialLifecycle: CredentialLifecycle | null;
137
+ private pendingSessionError: Error | null = null;
138
+
139
+ readonly presence: WebSocketPresence;
140
+ readonly collaboration: WebSocketCollaboration<TEvents>;
141
+
142
+ constructor(
143
+ options: WebSocketSessionOptions,
144
+ position: StoredPosition,
145
+ credential: CredentialProviderResult,
146
+ ) {
147
+ this.options = options;
148
+ const token = credentialToken(credential);
149
+ if (!token) {
150
+ throw new AbloSessionError('The WebSocket transport requires an authenticated credential.');
151
+ }
152
+ this.currentToken = token;
153
+ this.cursorKey = options.cursorKey ?? 'websocket';
154
+ this.socket = new AgentWebSocket<TEvents>({
155
+ baseUrl: options.baseUrl,
156
+ kind: classifyCredentialKind(token) === 'ephemeral' ? 'user' : 'agent',
157
+ getAuthToken: () => this.currentToken,
158
+ syncGroups: [...(options.syncGroups ?? [])],
159
+ collaborationEvents: [...(options.collaborationEvents ?? [])],
160
+ reconnectDelay: options.reconnectDelay,
161
+ maxReconnectDelay: options.maxReconnectDelay,
162
+ }, position);
163
+ this.presence = {
164
+ update: (input = {}) => this.socket.updatePresence(input),
165
+ };
166
+ this.collaboration = {
167
+ send: (event, payload) => this.socket.sendCollaborationEvent(event, payload),
168
+ } as WebSocketCollaboration<TEvents>;
169
+ this.socket.subscribe('session_error', (error) => {
170
+ this.handleSessionError(error);
171
+ });
172
+ this.credentialLifecycle = options.access.renewable
173
+ ? new CredentialLifecycle({
174
+ setAuthToken: (freshToken) => {
175
+ this.currentToken = freshToken;
176
+ },
177
+ nudgeReconnect: () => {
178
+ if (this.closed || this.terminalError) return;
179
+ this.socket.clearSessionError();
180
+ this.socket.resetReconnectAttempts();
181
+ this.socket.connect();
182
+ },
183
+ reportSessionExpired: () => {
184
+ this.failTerminal(this.pendingSessionError ?? new AbloSessionError(
185
+ 'The application session ended while renewing its Ablo session.',
186
+ ));
187
+ },
188
+ })
189
+ : null;
190
+ if (this.credentialLifecycle) {
191
+ this.credentialLifecycle.start(() => this.loadCredential(), { proactiveInNode: true });
192
+ this.credentialLifecycle.accept(credential);
193
+ }
194
+ }
195
+
196
+ private async loadCredential(): Promise<CredentialProviderResult> {
197
+ return this.options.access.credential();
198
+ }
199
+
200
+ private handleSessionError(error: Error): void {
201
+ if (
202
+ !this.credentialLifecycle
203
+ || !isAccessCredentialExpiryCloseReason(error.message)
204
+ ) {
205
+ this.failTerminal(error);
206
+ return;
207
+ }
208
+ this.pendingSessionError = error;
209
+ void this.credentialLifecycle.recoverFromAuthRejection('access_credential_expiry');
210
+ }
211
+
212
+ private failTerminal(error: Error): void {
213
+ if (this.terminalError || this.closed) return;
214
+ this.terminalError = error;
215
+ this.socket.setSessionErrorDetected();
216
+ this.socket.disconnect();
217
+ for (const listener of this.terminalListeners) listener(error);
218
+ }
219
+
220
+ private onTerminal(listener: (error: Error) => void): () => void {
221
+ if (this.terminalError) {
222
+ const error = this.terminalError;
223
+ queueMicrotask(() => listener(error));
224
+ return () => undefined;
225
+ }
226
+ this.terminalListeners.add(listener);
227
+ return () => this.terminalListeners.delete(listener);
228
+ }
229
+
230
+ get connected(): boolean {
231
+ return this.socket.isConnected();
232
+ }
233
+
234
+ ready(): Promise<void> {
235
+ if (this.closed) {
236
+ return Promise.reject(new AbloConnectionError('The WebSocket session is closed.'));
237
+ }
238
+ if (this.terminalError) return Promise.reject(this.terminalError);
239
+ if (this.socket.isConnected()) return Promise.resolve();
240
+ if (this.readyPromise) return this.readyPromise;
241
+ this.readyPromise = new Promise<void>((resolve, reject) => {
242
+ const rejectConnection = (error: Error): void => {
243
+ cleanup();
244
+ reject(error);
245
+ };
246
+ this.rejectReady = rejectConnection;
247
+ const timeout = setTimeout(() => {
248
+ rejectConnection(new AbloConnectionError('The WebSocket connection timed out.'));
249
+ }, this.options.connectTimeoutMs ?? 15_000);
250
+ const connected = this.socket.subscribe('connected', () => {
251
+ cleanup();
252
+ resolve();
253
+ });
254
+ const failed = this.onTerminal((error) => {
255
+ rejectConnection(error);
256
+ });
257
+ const mismatch = this.socket.subscribe('protocol_mismatch', () => {
258
+ rejectConnection(new AbloConnectionError('The server rejected this live protocol version.', {
259
+ code: 'protocol_version_unsupported',
260
+ }));
261
+ });
262
+ const cleanup = (): void => {
263
+ clearTimeout(timeout);
264
+ connected();
265
+ failed();
266
+ mismatch();
267
+ this.readyPromise = null;
268
+ this.rejectReady = null;
269
+ };
270
+ this.socket.connect();
271
+ });
272
+ return this.readyPromise;
273
+ }
274
+
275
+ async commit(input: WebSocketCommitInput) {
276
+ await this.ready();
277
+ return this.socket.sendCommitReceipt(
278
+ input.operations,
279
+ input.clientTxId,
280
+ input.timeoutMs,
281
+ input.reads,
282
+ );
283
+ }
284
+
285
+ async claim(input: WebSocketClaimInput) {
286
+ await this.ready();
287
+ const timeoutMs = input.timeoutMs ?? 15_000;
288
+ return new Promise<import('../../coordination/schema.js').ClaimAcquired | import('../../coordination/schema.js').ClaimGranted>((resolve, reject) => {
289
+ const cleanups: (() => void)[] = [];
290
+ const finish = (outcome: () => void): void => {
291
+ clearTimeout(timeout);
292
+ for (const cleanup of cleanups) cleanup();
293
+ outcome();
294
+ };
295
+ const matches = (event: { claimId: string }): boolean => event.claimId === input.claimId;
296
+ cleanups.push(this.socket.subscribe('claim_acquired', (event) => {
297
+ if (matches(event)) finish(() => resolve(event));
298
+ }));
299
+ cleanups.push(this.socket.subscribe('claim_granted', (event) => {
300
+ if (matches(event)) finish(() => resolve(event));
301
+ }));
302
+ cleanups.push(this.socket.subscribe('claim_queued', (event) => {
303
+ if (!matches(event)) return;
304
+ const refusal = input.onQueued?.(event);
305
+ if (refusal) {
306
+ this.release({
307
+ claimId: input.claimId,
308
+ entityType: input.entityType,
309
+ entityId: input.entityId,
310
+ });
311
+ finish(() => reject(refusal));
312
+ }
313
+ }));
314
+ cleanups.push(this.socket.subscribe('claim_rejected', (event) => {
315
+ if (!matches(event)) return;
316
+ finish(() => reject(errorFromWire(
317
+ event.message ?? `Claim ${event.claimId} was rejected.`,
318
+ {
319
+ code: event.reason === 'conflict' ? 'claim_conflict' : 'claim_rejected',
320
+ details: {
321
+ ...(event.target ? { target: event.target } : {}),
322
+ ...(event.heldBy ? { heldBy: event.heldBy } : {}),
323
+ ...(event.heldByClaimId ? { heldByClaimId: event.heldByClaimId } : {}),
324
+ },
325
+ },
326
+ )));
327
+ }));
328
+ cleanups.push(this.socket.subscribe('disconnected', () => {
329
+ finish(() => reject(new AbloConnectionError(
330
+ `WebSocket closed while claim ${input.claimId} was pending.`,
331
+ )));
332
+ }));
333
+ const abort = (): void => {
334
+ this.release({
335
+ claimId: input.claimId,
336
+ entityType: input.entityType,
337
+ entityId: input.entityId,
338
+ });
339
+ finish(() => reject(new AbloClaimedError(
340
+ `The wait for claim ${input.claimId} was aborted.`,
341
+ { code: 'claim_wait_aborted' },
342
+ )));
343
+ };
344
+ input.signal?.addEventListener('abort', abort, { once: true });
345
+ cleanups.push(() => input.signal?.removeEventListener('abort', abort));
346
+ const timeout = setTimeout(() => {
347
+ this.release({
348
+ claimId: input.claimId,
349
+ entityType: input.entityType,
350
+ entityId: input.entityId,
351
+ });
352
+ finish(() => reject(new AbloClaimedError(
353
+ `claim timed out after ${timeoutMs}ms (claimId=${input.claimId})`,
354
+ { code: 'grant_timeout' },
355
+ )));
356
+ }, timeoutMs);
357
+ if (input.signal?.aborted) {
358
+ abort();
359
+ return;
360
+ }
361
+ const {
362
+ timeoutMs: _timeoutMs,
363
+ signal: _signal,
364
+ onQueued: _onQueued,
365
+ ...payload
366
+ } = input;
367
+ this.socket.send({ type: 'claim_begin', payload });
368
+ });
369
+ }
370
+
371
+ release(input: { claimId: string; entityType?: string; entityId?: string }): void {
372
+ this.socket.send({ type: 'claim_abandon', payload: input });
373
+ }
374
+
375
+ subscribe<K extends keyof SyncWebSocketEventMap<TEvents>>(
376
+ event: K,
377
+ listener: (...args: SyncWebSocketEventMap<TEvents>[K]) => void,
378
+ ): () => void {
379
+ return this.socket.subscribe(event, listener);
380
+ }
381
+
382
+ async *observe(options: WebSocketObserveOptions = {}): AsyncIterable<WebSocketObservedDelta> {
383
+ await this.ready();
384
+ const queued: ClientSyncDelta[] = [];
385
+ const queuedIds = new Set<number>();
386
+ let wake: (() => void) | undefined;
387
+ let failure: unknown;
388
+ const offDelta = this.socket.subscribe('delta', (delta) => {
389
+ if (
390
+ failure
391
+ || delta.id <= this.socket.durablePosition().lastSyncId
392
+ || queuedIds.has(delta.id)
393
+ ) return;
394
+ if (queued.length >= MAX_BUFFERED_OBSERVATION_DELTAS) {
395
+ failure = new AbloConnectionError(
396
+ `WebSocket observation exceeded ${MAX_BUFFERED_OBSERVATION_DELTAS} uncheckpointed deltas; restart observation to resume from the durable cursor.`,
397
+ { code: 'observation_buffer_overflow' },
398
+ );
399
+ wake?.();
400
+ wake = undefined;
401
+ return;
402
+ }
403
+ queued.push(delta);
404
+ queuedIds.add(delta.id);
405
+ wake?.();
406
+ wake = undefined;
407
+ });
408
+ const offError = this.onTerminal((error) => {
409
+ failure = error;
410
+ wake?.();
411
+ wake = undefined;
412
+ });
413
+ const abort = (): void => {
414
+ wake?.();
415
+ wake = undefined;
416
+ };
417
+ options.signal?.addEventListener('abort', abort, { once: true });
418
+ // Retain nothing while idle. Register first, then replay from the durable
419
+ // position so starting observation cannot miss a delta from the gap.
420
+ this.socket.requestFromDurablePosition();
421
+ try {
422
+ while (!this.closed && !options.signal?.aborted) {
423
+ if (failure) throw failure;
424
+ const delta = queued.shift();
425
+ if (!delta) {
426
+ await new Promise<void>((resolve) => { wake = resolve; });
427
+ continue;
428
+ }
429
+ queuedIds.delete(delta.id);
430
+ if (delta.id <= this.socket.durablePosition().lastSyncId) continue;
431
+ let checkpointed = false;
432
+ let checkpointing: Promise<void> | undefined;
433
+ yield {
434
+ ...delta,
435
+ checkpoint: async () => {
436
+ if (checkpointed) return;
437
+ checkpointing ??= this.acknowledge(delta.id).then(() => {
438
+ checkpointed = true;
439
+ }).finally(() => {
440
+ checkpointing = undefined;
441
+ });
442
+ await checkpointing;
443
+ },
444
+ };
445
+ }
446
+ } finally {
447
+ offDelta();
448
+ offError();
449
+ options.signal?.removeEventListener('abort', abort);
450
+ }
451
+ }
452
+
453
+ async updateSubscription(syncGroups: readonly string[], options?: { timeoutMs?: number }) {
454
+ await this.ready();
455
+ return this.socket.updateSubscription(syncGroups, options);
456
+ }
457
+
458
+ async acknowledge(lastSyncId: number): Promise<void> {
459
+ const attempt = this.acknowledgeLane.catch(() => undefined).then(async () => {
460
+ const position = this.socket.positionAfter(lastSyncId);
461
+ await this.options.cursorStore?.save(this.cursorKey, JSON.stringify(position));
462
+ this.socket.markDurable(position);
463
+ this.socket.acknowledge(position.lastSyncId);
464
+ });
465
+ this.acknowledgeLane = attempt.catch(() => undefined);
466
+ await attempt;
467
+ }
468
+
469
+ async close(): Promise<void> {
470
+ if (this.closed) return;
471
+ this.closed = true;
472
+ this.credentialLifecycle?.stop();
473
+ this.terminalListeners.clear();
474
+ this.rejectReady?.(new AbloConnectionError('The WebSocket session was disposed.'));
475
+ await this.acknowledgeLane;
476
+ this.socket.disconnect();
477
+ }
478
+
479
+ [Symbol.asyncDispose](): Promise<void> {
480
+ return this.close();
481
+ }
482
+ }
483
+
484
+ export async function createWebSocketSession<
485
+ TEvents extends EventMap<TEvents> = OpenCollaborationEvents,
486
+ >(
487
+ options: WebSocketSessionOptions,
488
+ signal?: AbortSignal,
489
+ ): Promise<AbloWebSocketSession<TEvents>> {
490
+ const cursorKey = options.cursorKey ?? 'websocket';
491
+ const stored = await options.cursorStore?.load(cursorKey) ?? null;
492
+ const credential = await options.access.credential();
493
+ const token = credentialToken(credential);
494
+ if (!token) {
495
+ throw new AbloSessionError('The WebSocket transport requires an authenticated credential.');
496
+ }
497
+ const session = new WebSocketSession<TEvents>(
498
+ options,
499
+ parsePosition(stored),
500
+ credential,
501
+ );
502
+ let rejectOpening: ((error: Error) => void) | undefined;
503
+ const aborted = new Promise<never>((_resolve, reject) => {
504
+ rejectOpening = reject;
505
+ if (signal?.aborted) {
506
+ reject(new AbloConnectionError('WebSocket opening was disposed.'));
507
+ }
508
+ });
509
+ const abort = (): void => {
510
+ rejectOpening?.(new AbloConnectionError('WebSocket opening was disposed.'));
511
+ };
512
+ signal?.addEventListener('abort', abort, { once: true });
513
+ try {
514
+ await (signal ? Promise.race([session.ready(), aborted]) : session.ready());
515
+ return session;
516
+ } catch (error) {
517
+ await session.close();
518
+ throw error;
519
+ } finally {
520
+ signal?.removeEventListener('abort', abort);
521
+ }
522
+ }
@@ -0,0 +1,106 @@
1
+ import type { CommitReceiptWire } from '../../commit/contract.js';
2
+ import type { ReadDependency } from '../../coordination/schema.js';
3
+ import type {
4
+ ClaimAcquired,
5
+ ClaimBeginPayload,
6
+ ClaimGranted,
7
+ ClaimQueued,
8
+ } from '../../coordination/schema.js';
9
+ import type { ClientSyncDelta } from '../../observation/contract.js';
10
+ import type { ObserveCursorStore } from '../../client/contract.js';
11
+ import type { SessionAccess } from '../../sessions/source.js';
12
+ import type { CommitFrameOperation } from '../websocket/commitFrames.js';
13
+ import type {
14
+ CoreSyncEventMap,
15
+ EventMap,
16
+ PresenceUpdate,
17
+ SyncWebSocketEventMap,
18
+ } from '../websocket/transport.js';
19
+
20
+ export interface WebSocketSessionOptions {
21
+ readonly baseUrl?: string;
22
+ /** Normalized once at the client boundary; transports do not infer policy. */
23
+ readonly access: SessionAccess;
24
+ readonly syncGroups?: readonly string[];
25
+ readonly collaborationEvents?: readonly string[];
26
+ readonly cursorKey?: string;
27
+ readonly cursorStore?: ObserveCursorStore;
28
+ readonly reconnectDelay?: number;
29
+ readonly maxReconnectDelay?: number;
30
+ readonly connectTimeoutMs?: number;
31
+ }
32
+
33
+ export interface WebSocketCommitInput {
34
+ readonly operations: readonly CommitFrameOperation[];
35
+ readonly clientTxId: string;
36
+ readonly reads?: readonly ReadDependency[] | null;
37
+ readonly timeoutMs?: number;
38
+ }
39
+
40
+ export interface WebSocketObservedDelta extends ClientSyncDelta {
41
+ /** Persist and acknowledge this delta as durably applied. */
42
+ checkpoint(): Promise<void>;
43
+ }
44
+
45
+ export interface WebSocketObserveOptions {
46
+ readonly signal?: AbortSignal;
47
+ }
48
+
49
+ /** A durable row/field claim carried over the same live protocol. */
50
+ export type WebSocketClaimInput = ClaimBeginPayload & {
51
+ readonly timeoutMs?: number;
52
+ readonly signal?: AbortSignal;
53
+ readonly onQueued?: (event: ClaimQueued) => Error | undefined;
54
+ };
55
+ export type WebSocketClaimGrant = ClaimAcquired | ClaimGranted;
56
+
57
+ export interface WebSocketPresence {
58
+ update(input?: {
59
+ readonly status?: 'online' | 'away' | 'offline';
60
+ readonly customStatus?: string;
61
+ readonly timezone?: string;
62
+ readonly activity?: Record<string, unknown>;
63
+ }): void;
64
+ }
65
+
66
+ export interface WebSocketCollaboration<TEvents extends EventMap<TEvents>> {
67
+ send<K extends string & keyof TEvents>(
68
+ event: K,
69
+ payload: TEvents[K] extends [infer P]
70
+ ? Omit<P & Record<string, unknown>, 'timestamp'>
71
+ : never,
72
+ ): void;
73
+ }
74
+
75
+ export type OpenCollaborationEvents = Record<string, [Record<string, unknown>]>;
76
+
77
+ export interface AbloWebSocketSession<
78
+ TEvents extends EventMap<TEvents> = OpenCollaborationEvents,
79
+ > extends AsyncDisposable {
80
+ readonly presence: WebSocketPresence;
81
+ readonly collaboration: WebSocketCollaboration<TEvents>;
82
+ readonly connected: boolean;
83
+ ready(): Promise<void>;
84
+ commit(input: WebSocketCommitInput): Promise<CommitReceiptWire>;
85
+ claim(input: WebSocketClaimInput): Promise<WebSocketClaimGrant>;
86
+ release(input: {
87
+ readonly claimId: string;
88
+ readonly entityType?: string;
89
+ readonly entityId?: string;
90
+ }): void;
91
+ subscribe<K extends keyof SyncWebSocketEventMap<TEvents>>(
92
+ event: K,
93
+ listener: (...args: SyncWebSocketEventMap<TEvents>[K]) => void,
94
+ ): () => void;
95
+ observe(options?: WebSocketObserveOptions): AsyncIterable<WebSocketObservedDelta>;
96
+ updateSubscription(
97
+ syncGroups: readonly string[],
98
+ options?: { timeoutMs?: number },
99
+ ): Promise<{ syncGroups: string[] }>;
100
+ acknowledge(lastSyncId: number): Promise<void>;
101
+ close(): Promise<void>;
102
+ [Symbol.asyncDispose](): Promise<void>;
103
+ }
104
+
105
+ export type WebSocketCoreEvent = keyof CoreSyncEventMap;
106
+ export type WebSocketPresenceUpdate = PresenceUpdate;