@abloatai/transaction 0.59.1 → 0.59.2

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/capability.d.ts +15 -15
  2. package/dist/auth/capabilityLifecycle.d.ts +2 -2
  3. package/dist/auth/credentialEndpoint.js.map +1 -1
  4. package/dist/auth/credentialSource.js.map +1 -1
  5. package/dist/auth/identity.js.map +1 -1
  6. package/dist/auth/schemas.d.ts +5 -5
  7. package/dist/auth/sessionIssuanceProtocol.d.ts +8 -8
  8. package/dist/auth/sessionIssuanceProtocol.d.ts.map +1 -1
  9. package/dist/branches.d.ts +38 -38
  10. package/dist/claims/contract.d.ts +27 -27
  11. package/dist/claims/eventContract.d.ts +18 -18
  12. package/dist/claims/routes.d.ts +9 -9
  13. package/dist/claims/routes.d.ts.map +1 -1
  14. package/dist/claims/trace.d.ts.map +1 -1
  15. package/dist/claims/trace.js.map +1 -1
  16. package/dist/client/ablo.d.ts +6 -5
  17. package/dist/client/ablo.d.ts.map +1 -1
  18. package/dist/client/ablo.js +0 -8
  19. package/dist/client/ablo.js.map +1 -1
  20. package/dist/client/resources/httpResources.js.map +1 -1
  21. package/dist/client/resources/modelOperations.d.ts +6 -6
  22. package/dist/client/resources/modelOperations.d.ts.map +1 -1
  23. package/dist/client/resources/where.d.ts +12 -12
  24. package/dist/client/surface.d.ts +2 -2
  25. package/dist/client/surface.d.ts.map +1 -1
  26. package/dist/client/surface.js +7 -0
  27. package/dist/client/surface.js.map +1 -1
  28. package/dist/commit/confirmation/commitEnvelope.d.ts +7 -7
  29. package/dist/commit/confirmation/httpCommitEnvelope.d.ts +1 -1
  30. package/dist/commit/confirmation/pendingWrite.d.ts +5 -5
  31. package/dist/commit/contract.d.ts +249 -249
  32. package/dist/commit/contract.d.ts.map +1 -1
  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/schema.d.ts +54 -54
  38. package/dist/coordination/schema.js.map +1 -1
  39. package/dist/environment.d.ts +3 -3
  40. package/dist/environment.d.ts.map +1 -1
  41. package/dist/environment.js.map +1 -1
  42. package/dist/errorCodes.d.ts +6 -5
  43. package/dist/errorCodes.d.ts.map +1 -1
  44. package/dist/errorCodes.js +2 -1
  45. package/dist/errorCodes.js.map +1 -1
  46. package/dist/errorObservation.d.ts +14 -14
  47. package/dist/errors.d.ts +11 -11
  48. package/dist/errors.d.ts.map +1 -1
  49. package/dist/errors.js +16 -1
  50. package/dist/errors.js.map +1 -1
  51. package/dist/index.d.ts +1 -1
  52. package/dist/index.d.ts.map +1 -1
  53. package/dist/index.js.map +1 -1
  54. package/dist/keys/index.d.ts +1 -1
  55. package/dist/keys/index.d.ts.map +1 -1
  56. package/dist/keys/index.js.map +1 -1
  57. package/dist/observation/contract.d.ts +34 -34
  58. package/dist/observation/feedContract.d.ts +86 -86
  59. package/dist/observation/persistence/syncDeltaRow.d.ts +23 -23
  60. package/dist/observation/persistence/syncDeltaRow.d.ts.map +1 -1
  61. package/dist/pricing.d.ts +12 -12
  62. package/dist/schema/audit.js.map +1 -1
  63. package/dist/schema/ddl.js.map +1 -1
  64. package/dist/schema/ddlLock.js.map +1 -1
  65. package/dist/schema/deployment/contracts.d.ts +64 -64
  66. package/dist/schema/deployment/index.js.map +1 -1
  67. package/dist/schema/deployment/reconcile.js.map +1 -1
  68. package/dist/schema/diff.js.map +1 -1
  69. package/dist/schema/field.d.ts +1 -1
  70. package/dist/schema/field.d.ts.map +1 -1
  71. package/dist/schema/loadStrategy.d.ts +2 -2
  72. package/dist/schema/loadStrategy.d.ts.map +1 -1
  73. package/dist/schema/openapi.js.map +1 -1
  74. package/dist/schema/relation.d.ts +3 -3
  75. package/dist/schema/relation.d.ts.map +1 -1
  76. package/dist/schema/residency.d.ts +1 -1
  77. package/dist/schema/roles.d.ts +1 -1
  78. package/dist/schema/roles.d.ts.map +1 -1
  79. package/dist/schema/roles.js.map +1 -1
  80. package/dist/schema/schema.d.ts +1 -1
  81. package/dist/schema/schema.d.ts.map +1 -1
  82. package/dist/schema/serialize.js.map +1 -1
  83. package/dist/schema/sugar.d.ts.map +1 -1
  84. package/dist/schema/tenancy.d.ts +2 -2
  85. package/dist/server/storageMode.d.ts +1 -1
  86. package/dist/source/adapters/adapterProfile.d.ts +6 -6
  87. package/dist/source/adapters/conformance.js.map +1 -1
  88. package/dist/source/adapters/contract.d.ts +14 -14
  89. package/dist/source/adapters/kysely.js.map +1 -1
  90. package/dist/source/adapters/memory.js.map +1 -1
  91. package/dist/source/adapters/prisma.js.map +1 -1
  92. package/dist/source/connector/client.d.ts.map +1 -1
  93. package/dist/source/connector/client.js.map +1 -1
  94. package/dist/source/connector/protocol.d.ts.map +1 -1
  95. package/dist/source/delivery/pushQueue.d.ts.map +1 -1
  96. package/dist/source/delivery/pushQueue.js.map +1 -1
  97. package/dist/source/endpoint/signing.d.ts +4 -4
  98. package/dist/source/endpoint/signing.d.ts.map +1 -1
  99. package/dist/source/outbox/contract.d.ts +4 -4
  100. package/dist/testing/fixtures/httpResponses.d.ts +2 -2
  101. package/dist/testing/fixtures/httpResponses.d.ts.map +1 -1
  102. package/dist/testing/fixtures/httpResponses.js.map +1 -1
  103. package/dist/transport/connection/credentialLifecycle.d.ts.map +1 -1
  104. package/dist/transport/connection/credentialLifecycle.js.map +1 -1
  105. package/dist/transport/connection/manager.d.ts.map +1 -1
  106. package/dist/transport/connection/manager.js.map +1 -1
  107. package/dist/transport/connection/networkProbe.d.ts +18 -5
  108. package/dist/transport/connection/networkProbe.d.ts.map +1 -1
  109. package/dist/transport/connection/networkProbe.js.map +1 -1
  110. package/dist/transport/http/client.d.ts +37 -1
  111. package/dist/transport/http/client.d.ts.map +1 -1
  112. package/dist/transport/http/client.js +117 -2
  113. package/dist/transport/http/client.js.map +1 -1
  114. package/dist/transport/http/transport.d.ts +21 -0
  115. package/dist/transport/http/transport.d.ts.map +1 -1
  116. package/dist/transport/http/transport.js +93 -5
  117. package/dist/transport/http/transport.js.map +1 -1
  118. package/dist/transport/websocket/commitFrames.d.ts +4 -3
  119. package/dist/transport/websocket/commitFrames.d.ts.map +1 -1
  120. package/dist/transport/websocket/commitFrames.js +1 -0
  121. package/dist/transport/websocket/commitFrames.js.map +1 -1
  122. package/dist/transport/websocket/contextSubscription.d.ts +6 -0
  123. package/dist/transport/websocket/contextSubscription.d.ts.map +1 -0
  124. package/dist/transport/websocket/contextSubscription.js +45 -0
  125. package/dist/transport/websocket/contextSubscription.js.map +1 -0
  126. package/dist/transport/websocket/heartbeat.d.ts.map +1 -1
  127. package/dist/transport/websocket/heartbeat.js.map +1 -1
  128. package/dist/transport/websocket/session.d.ts +4 -0
  129. package/dist/transport/websocket/session.d.ts.map +1 -0
  130. package/dist/transport/websocket/session.js +385 -0
  131. package/dist/transport/websocket/session.js.map +1 -0
  132. package/dist/transport/websocket/sessionContract.d.ts +89 -0
  133. package/dist/transport/websocket/sessionContract.d.ts.map +1 -0
  134. package/dist/transport/websocket/sessionContract.js +2 -0
  135. package/dist/transport/websocket/sessionContract.js.map +1 -0
  136. package/dist/transport/websocket/transport.d.ts +22 -0
  137. package/dist/transport/websocket/transport.d.ts.map +1 -1
  138. package/dist/transport/websocket/transport.js +63 -1
  139. package/dist/transport/websocket/transport.js.map +1 -1
  140. package/dist/wire/accountResponses.d.ts +31 -31
  141. package/dist/wire/auth.d.ts +3 -3
  142. package/dist/wire/bootstrapReason.d.ts +2 -2
  143. package/dist/wire/dataSourceResponses.d.ts +2 -2
  144. package/dist/wire/dataSourceResponses.d.ts.map +1 -1
  145. package/dist/wire/frames.d.ts +8 -8
  146. package/dist/wire/inboundFrames.d.ts +56 -56
  147. package/dist/wire/inboundFrames.d.ts.map +1 -1
  148. package/dist/wire/listEnvelope.js.map +1 -1
  149. package/dist/wire/modelResponses.d.ts +2 -2
  150. package/dist/wire/modelShape.d.ts +4 -4
  151. package/dist/wire/pricing.d.ts +3 -3
  152. package/dist/wire/protocolVersion.d.ts.map +1 -1
  153. package/package.json +2 -1
  154. package/src/client/ablo.ts +10 -5
  155. package/src/client/surface.ts +8 -1
  156. package/src/commit/recordRuntime.ts +4 -2
  157. package/src/errorCodes.ts +5 -1
  158. package/src/errors.ts +18 -1
  159. package/src/index.ts +1 -0
  160. package/src/transport/http/client.ts +171 -4
  161. package/src/transport/http/transport.ts +126 -4
  162. package/src/transport/websocket/commitFrames.ts +5 -3
  163. package/src/transport/websocket/contextSubscription.ts +55 -0
  164. package/src/transport/websocket/session.ts +467 -0
  165. package/src/transport/websocket/sessionContract.ts +115 -0
  166. package/src/transport/websocket/transport.ts +72 -0
@@ -0,0 +1,467 @@
1
+ import {
2
+ AbloClaimedError,
3
+ AbloConnectionError,
4
+ AbloSessionError,
5
+ errorFromWire,
6
+ } from '../../errors.js';
7
+ import {
8
+ clientSyncDeltaSchema,
9
+ type ClientSyncDelta,
10
+ } from '../../observation/contract.js';
11
+ import {
12
+ WsTransport,
13
+ type EventMap,
14
+ type SyncWebSocketEventMap,
15
+ } from './transport.js';
16
+ import type {
17
+ AbloWebSocketSession,
18
+ WebSocketClaimInput,
19
+ WebSocketCollaboration,
20
+ WebSocketCommitInput,
21
+ WebSocketObserveOptions,
22
+ WebSocketObservedDelta,
23
+ WebSocketPresence,
24
+ WebSocketJoinInput,
25
+ WebSocketSessionOptions,
26
+ OpenCollaborationEvents,
27
+ } from './sessionContract.js';
28
+
29
+ interface StoredPosition {
30
+ readonly lastSyncId: number;
31
+ readonly cursor: string | null;
32
+ }
33
+
34
+ const MAX_BUFFERED_OBSERVATION_DELTAS = 1_024;
35
+
36
+ function parsePosition(value: string | null): StoredPosition {
37
+ if (!value) return { lastSyncId: 0, cursor: null };
38
+ try {
39
+ const parsed = JSON.parse(value) as { lastSyncId?: unknown; cursor?: unknown };
40
+ if (
41
+ Number.isSafeInteger(parsed.lastSyncId)
42
+ && (parsed.lastSyncId as number) >= 0
43
+ && (parsed.cursor === null || typeof parsed.cursor === 'string')
44
+ ) {
45
+ return {
46
+ lastSyncId: parsed.lastSyncId as number,
47
+ cursor: parsed.cursor as string | null,
48
+ };
49
+ }
50
+ } catch {
51
+ // Pre-live stores may contain the HTTP feed cursor. It is a different
52
+ // protocol position, so failing closed at zero is safer than skipping.
53
+ }
54
+ return { lastSyncId: 0, cursor: null };
55
+ }
56
+
57
+ class AgentWebSocket<
58
+ TEvents extends EventMap<TEvents>,
59
+ > extends WsTransport<TEvents> {
60
+ private position: StoredPosition;
61
+
62
+ constructor(
63
+ options: ConstructorParameters<typeof WsTransport<TEvents>>[0],
64
+ position: StoredPosition,
65
+ ) {
66
+ super(options);
67
+ this.position = position;
68
+ }
69
+
70
+ protected override resumeCursor(): string {
71
+ return this.position.cursor ?? '';
72
+ }
73
+
74
+ protected override onOpened(): void {
75
+ this.updatePresence();
76
+ this.requestSync(this.position);
77
+ }
78
+
79
+ protected override handleDelta(raw: unknown): void {
80
+ const parsed = clientSyncDeltaSchema.safeParse(raw);
81
+ if (parsed.success) this.emit('delta', parsed.data);
82
+ }
83
+
84
+ protected override handleSyncResponse(raw: unknown): void {
85
+ if (typeof raw !== 'object' || raw === null) return;
86
+ const response = raw as {
87
+ deltas?: unknown;
88
+ newCursor?: unknown;
89
+ cursor?: unknown;
90
+ currentSyncId?: unknown;
91
+ };
92
+ if (Array.isArray(response.deltas)) {
93
+ for (const delta of response.deltas) this.handleDelta(delta);
94
+ }
95
+ this.emit('sync_response', response);
96
+ }
97
+
98
+ positionAfter(lastSyncId: number): StoredPosition {
99
+ return {
100
+ lastSyncId: Math.max(this.position.lastSyncId, lastSyncId),
101
+ cursor: this.position.cursor,
102
+ };
103
+ }
104
+
105
+ markDurable(position: StoredPosition): void {
106
+ this.position = position;
107
+ }
108
+
109
+ durablePosition(): StoredPosition {
110
+ return this.position;
111
+ }
112
+
113
+ requestFromDurablePosition(): void {
114
+ this.requestSync(this.position);
115
+ }
116
+ }
117
+
118
+ class WebSocketSession<TEvents extends EventMap<TEvents>>
119
+ implements AbloWebSocketSession<TEvents> {
120
+ private readonly socket: AgentWebSocket<TEvents>;
121
+ private readonly options: WebSocketSessionOptions;
122
+ private readonly cursorKey: string;
123
+ private readyPromise: Promise<void> | null = null;
124
+ private rejectReady: ((error: Error) => void) | null = null;
125
+ private closed = false;
126
+ private currentToken: string;
127
+ private acknowledgeLane: Promise<void> = Promise.resolve();
128
+
129
+ readonly presence: WebSocketPresence;
130
+ readonly collaboration: WebSocketCollaboration<TEvents>;
131
+
132
+ constructor(options: WebSocketSessionOptions, position: StoredPosition, token: string) {
133
+ this.options = options;
134
+ this.currentToken = token;
135
+ this.cursorKey = options.cursorKey ?? 'websocket';
136
+ this.socket = new AgentWebSocket<TEvents>({
137
+ baseUrl: options.baseUrl,
138
+ kind: 'agent',
139
+ getAuthToken: () => this.currentToken,
140
+ syncGroups: [...(options.syncGroups ?? [])],
141
+ collaborationEvents: [...(options.collaborationEvents ?? [])],
142
+ reconnectDelay: options.reconnectDelay,
143
+ maxReconnectDelay: options.maxReconnectDelay,
144
+ }, position);
145
+ this.presence = {
146
+ update: (input = {}) => this.socket.updatePresence(input),
147
+ };
148
+ this.collaboration = {
149
+ send: (event, payload) => this.socket.sendCollaborationEvent(event, payload),
150
+ } as WebSocketCollaboration<TEvents>;
151
+ this.socket.subscribe('session_error', () => {
152
+ void this.refreshExpiredCredential();
153
+ });
154
+ }
155
+
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.
167
+ }
168
+ }
169
+
170
+ get connected(): boolean {
171
+ return this.socket.isConnected();
172
+ }
173
+
174
+ ready(): Promise<void> {
175
+ if (this.closed) {
176
+ return Promise.reject(new AbloConnectionError('The WebSocket session is closed.'));
177
+ }
178
+ if (this.socket.isConnected()) return Promise.resolve();
179
+ if (this.readyPromise) return this.readyPromise;
180
+ this.readyPromise = new Promise<void>((resolve, reject) => {
181
+ const rejectConnection = (error: Error): void => {
182
+ cleanup();
183
+ reject(error);
184
+ };
185
+ this.rejectReady = rejectConnection;
186
+ const timeout = setTimeout(() => {
187
+ rejectConnection(new AbloConnectionError('The WebSocket connection timed out.'));
188
+ }, this.options.connectTimeoutMs ?? 15_000);
189
+ const connected = this.socket.subscribe('connected', () => {
190
+ cleanup();
191
+ resolve();
192
+ });
193
+ const failed = this.socket.subscribe('session_error', (error) => {
194
+ rejectConnection(error);
195
+ });
196
+ const mismatch = this.socket.subscribe('protocol_mismatch', () => {
197
+ rejectConnection(new AbloConnectionError('The server rejected this live protocol version.', {
198
+ code: 'protocol_version_unsupported',
199
+ }));
200
+ });
201
+ const cleanup = (): void => {
202
+ clearTimeout(timeout);
203
+ connected();
204
+ failed();
205
+ mismatch();
206
+ this.readyPromise = null;
207
+ this.rejectReady = null;
208
+ };
209
+ this.socket.connect();
210
+ });
211
+ return this.readyPromise;
212
+ }
213
+
214
+ async commit(input: WebSocketCommitInput) {
215
+ await this.ready();
216
+ return this.socket.sendCommitReceipt(
217
+ input.operations,
218
+ input.clientTxId,
219
+ input.timeoutMs,
220
+ input.reads,
221
+ );
222
+ }
223
+
224
+ async claim(input: WebSocketClaimInput) {
225
+ await this.ready();
226
+ const timeoutMs = input.timeoutMs ?? 15_000;
227
+ return new Promise<import('../../coordination/schema.js').ClaimAcquired | import('../../coordination/schema.js').ClaimGranted>((resolve, reject) => {
228
+ const cleanups: (() => void)[] = [];
229
+ const finish = (outcome: () => void): void => {
230
+ clearTimeout(timeout);
231
+ for (const cleanup of cleanups) cleanup();
232
+ outcome();
233
+ };
234
+ const matches = (event: { claimId: string }): boolean => event.claimId === input.claimId;
235
+ cleanups.push(this.socket.subscribe('claim_acquired', (event) => {
236
+ if (matches(event)) finish(() => resolve(event));
237
+ }));
238
+ cleanups.push(this.socket.subscribe('claim_granted', (event) => {
239
+ if (matches(event)) finish(() => resolve(event));
240
+ }));
241
+ cleanups.push(this.socket.subscribe('claim_queued', (event) => {
242
+ if (!matches(event)) return;
243
+ const refusal = input.onQueued?.(event);
244
+ if (refusal) {
245
+ this.release({
246
+ claimId: input.claimId,
247
+ entityType: input.entityType,
248
+ entityId: input.entityId,
249
+ });
250
+ finish(() => reject(refusal));
251
+ }
252
+ }));
253
+ cleanups.push(this.socket.subscribe('claim_rejected', (event) => {
254
+ 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
+ )));
266
+ }));
267
+ cleanups.push(this.socket.subscribe('disconnected', () => {
268
+ finish(() => reject(new AbloConnectionError(
269
+ `WebSocket closed while claim ${input.claimId} was pending.`,
270
+ )));
271
+ }));
272
+ const abort = (): void => {
273
+ this.release({
274
+ claimId: input.claimId,
275
+ entityType: input.entityType,
276
+ entityId: input.entityId,
277
+ });
278
+ finish(() => reject(new AbloClaimedError(
279
+ `The wait for claim ${input.claimId} was aborted.`,
280
+ { code: 'claim_wait_aborted' },
281
+ )));
282
+ };
283
+ input.signal?.addEventListener('abort', abort, { once: true });
284
+ 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);
296
+ if (input.signal?.aborted) {
297
+ abort();
298
+ return;
299
+ }
300
+ const {
301
+ timeoutMs: _timeoutMs,
302
+ signal: _signal,
303
+ onQueued: _onQueued,
304
+ ...payload
305
+ } = input;
306
+ this.socket.send({ type: 'claim_begin', payload });
307
+ });
308
+ }
309
+
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) {
315
+ await this.ready();
316
+ return this.socket.sendClaim(input.claimId, input.syncGroups, {
317
+ capabilityToken: input.capabilityToken,
318
+ ttlSeconds: input.ttlSeconds,
319
+ timeoutMs: input.timeoutMs,
320
+ });
321
+ }
322
+
323
+ leave(claimId: string): void {
324
+ this.socket.sendRelease(claimId);
325
+ }
326
+
327
+ subscribe<K extends keyof SyncWebSocketEventMap<TEvents>>(
328
+ event: K,
329
+ listener: (...args: SyncWebSocketEventMap<TEvents>[K]) => void,
330
+ ): () => void {
331
+ return this.socket.subscribe(event, listener);
332
+ }
333
+
334
+ async *observe(options: WebSocketObserveOptions = {}): AsyncIterable<WebSocketObservedDelta> {
335
+ await this.ready();
336
+ const queued: ClientSyncDelta[] = [];
337
+ const queuedIds = new Set<number>();
338
+ let wake: (() => void) | undefined;
339
+ let failure: unknown;
340
+ const offDelta = this.socket.subscribe('delta', (delta) => {
341
+ if (
342
+ failure
343
+ || delta.id <= this.socket.durablePosition().lastSyncId
344
+ || queuedIds.has(delta.id)
345
+ ) return;
346
+ if (queued.length >= MAX_BUFFERED_OBSERVATION_DELTAS) {
347
+ failure = new AbloConnectionError(
348
+ `WebSocket observation exceeded ${MAX_BUFFERED_OBSERVATION_DELTAS} uncheckpointed deltas; restart observation to resume from the durable cursor.`,
349
+ { code: 'observation_buffer_overflow' },
350
+ );
351
+ wake?.();
352
+ wake = undefined;
353
+ return;
354
+ }
355
+ queued.push(delta);
356
+ queuedIds.add(delta.id);
357
+ wake?.();
358
+ wake = undefined;
359
+ });
360
+ const offError = this.socket.subscribe('session_error', (error) => {
361
+ failure = error;
362
+ wake?.();
363
+ wake = undefined;
364
+ });
365
+ const abort = (): void => {
366
+ wake?.();
367
+ wake = undefined;
368
+ };
369
+ options.signal?.addEventListener('abort', abort, { once: true });
370
+ // Retain nothing while idle. Register first, then replay from the durable
371
+ // position so starting observation cannot miss a delta from the gap.
372
+ this.socket.requestFromDurablePosition();
373
+ try {
374
+ while (!this.closed && !options.signal?.aborted) {
375
+ if (failure) throw failure;
376
+ const delta = queued.shift();
377
+ if (!delta) {
378
+ await new Promise<void>((resolve) => { wake = resolve; });
379
+ continue;
380
+ }
381
+ queuedIds.delete(delta.id);
382
+ if (delta.id <= this.socket.durablePosition().lastSyncId) continue;
383
+ let checkpointed = false;
384
+ let checkpointing: Promise<void> | undefined;
385
+ yield {
386
+ ...delta,
387
+ checkpoint: async () => {
388
+ if (checkpointed) return;
389
+ checkpointing ??= this.acknowledge(delta.id).then(() => {
390
+ checkpointed = true;
391
+ }).finally(() => {
392
+ checkpointing = undefined;
393
+ });
394
+ await checkpointing;
395
+ },
396
+ };
397
+ }
398
+ } finally {
399
+ offDelta();
400
+ offError();
401
+ options.signal?.removeEventListener('abort', abort);
402
+ }
403
+ }
404
+
405
+ async updateSubscription(syncGroups: readonly string[], options?: { timeoutMs?: number }) {
406
+ await this.ready();
407
+ return this.socket.updateSubscription(syncGroups, options);
408
+ }
409
+
410
+ async acknowledge(lastSyncId: number): Promise<void> {
411
+ const attempt = this.acknowledgeLane.catch(() => undefined).then(async () => {
412
+ const position = this.socket.positionAfter(lastSyncId);
413
+ await this.options.cursorStore?.save(this.cursorKey, JSON.stringify(position));
414
+ this.socket.markDurable(position);
415
+ this.socket.acknowledge(position.lastSyncId);
416
+ });
417
+ this.acknowledgeLane = attempt.catch(() => undefined);
418
+ await attempt;
419
+ }
420
+
421
+ async close(): Promise<void> {
422
+ if (this.closed) return;
423
+ this.closed = true;
424
+ this.rejectReady?.(new AbloConnectionError('The WebSocket session was disposed.'));
425
+ await this.acknowledgeLane;
426
+ this.socket.disconnect();
427
+ }
428
+
429
+ [Symbol.asyncDispose](): Promise<void> {
430
+ return this.close();
431
+ }
432
+ }
433
+
434
+ export async function createWebSocketSession<
435
+ TEvents extends EventMap<TEvents> = OpenCollaborationEvents,
436
+ >(
437
+ options: WebSocketSessionOptions,
438
+ signal?: AbortSignal,
439
+ ): Promise<AbloWebSocketSession<TEvents>> {
440
+ const cursorKey = options.cursorKey ?? 'websocket';
441
+ const stored = await options.cursorStore?.load(cursorKey) ?? null;
442
+ const token = await options.getAuthToken();
443
+ if (!token) {
444
+ throw new AbloSessionError('The WebSocket transport requires an authenticated credential.');
445
+ }
446
+ const session = new WebSocketSession<TEvents>(options, parsePosition(stored), token);
447
+ let rejectOpening: ((error: Error) => void) | undefined;
448
+ const aborted = new Promise<never>((_resolve, reject) => {
449
+ rejectOpening = reject;
450
+ if (signal?.aborted) {
451
+ reject(new AbloConnectionError('WebSocket opening was disposed.'));
452
+ }
453
+ });
454
+ const abort = (): void => {
455
+ rejectOpening?.(new AbloConnectionError('WebSocket opening was disposed.'));
456
+ };
457
+ signal?.addEventListener('abort', abort, { once: true });
458
+ try {
459
+ await (signal ? Promise.race([session.ready(), aborted]) : session.ready());
460
+ return session;
461
+ } catch (error) {
462
+ await session.close();
463
+ throw error;
464
+ } finally {
465
+ signal?.removeEventListener('abort', abort);
466
+ }
467
+ }
@@ -0,0 +1,115 @@
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 { CommitFrameOperation } from '../websocket/commitFrames.js';
12
+ import type {
13
+ CoreSyncEventMap,
14
+ EventMap,
15
+ PresenceUpdate,
16
+ SyncWebSocketEventMap,
17
+ } from '../websocket/transport.js';
18
+
19
+ export interface WebSocketSessionOptions {
20
+ readonly baseUrl?: string;
21
+ readonly getAuthToken: () => string | undefined | Promise<string | undefined>;
22
+ readonly syncGroups?: readonly string[];
23
+ readonly collaborationEvents?: readonly string[];
24
+ readonly cursorKey?: string;
25
+ readonly cursorStore?: ObserveCursorStore;
26
+ readonly reconnectDelay?: number;
27
+ readonly maxReconnectDelay?: number;
28
+ readonly connectTimeoutMs?: number;
29
+ }
30
+
31
+ export interface WebSocketCommitInput {
32
+ readonly operations: readonly CommitFrameOperation[];
33
+ readonly clientTxId: string;
34
+ readonly reads?: readonly ReadDependency[] | null;
35
+ readonly timeoutMs?: number;
36
+ }
37
+
38
+ export interface WebSocketObservedDelta extends ClientSyncDelta {
39
+ /** Persist and acknowledge this delta as durably applied. */
40
+ checkpoint(): Promise<void>;
41
+ }
42
+
43
+ export interface WebSocketObserveOptions {
44
+ readonly signal?: AbortSignal;
45
+ }
46
+
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
+ /** A durable row/field claim carried over the same live protocol. */
57
+ export type WebSocketClaimInput = ClaimBeginPayload & {
58
+ readonly timeoutMs?: number;
59
+ readonly signal?: AbortSignal;
60
+ readonly onQueued?: (event: ClaimQueued) => Error | undefined;
61
+ };
62
+ export type WebSocketClaimGrant = ClaimAcquired | ClaimGranted;
63
+
64
+ export interface WebSocketPresence {
65
+ update(input?: {
66
+ readonly status?: 'online' | 'away' | 'offline';
67
+ readonly customStatus?: string;
68
+ readonly timezone?: string;
69
+ readonly activity?: Record<string, unknown>;
70
+ }): void;
71
+ }
72
+
73
+ export interface WebSocketCollaboration<TEvents extends EventMap<TEvents>> {
74
+ send<K extends string & keyof TEvents>(
75
+ event: K,
76
+ payload: TEvents[K] extends [infer P]
77
+ ? Omit<P & Record<string, unknown>, 'timestamp'>
78
+ : never,
79
+ ): void;
80
+ }
81
+
82
+ export type OpenCollaborationEvents = Record<string, [Record<string, unknown>]>;
83
+
84
+ export interface AbloWebSocketSession<
85
+ TEvents extends EventMap<TEvents> = OpenCollaborationEvents,
86
+ > extends AsyncDisposable {
87
+ readonly presence: WebSocketPresence;
88
+ readonly collaboration: WebSocketCollaboration<TEvents>;
89
+ readonly connected: boolean;
90
+ ready(): Promise<void>;
91
+ commit(input: WebSocketCommitInput): Promise<CommitReceiptWire>;
92
+ claim(input: WebSocketClaimInput): Promise<WebSocketClaimGrant>;
93
+ release(input: {
94
+ readonly claimId: string;
95
+ readonly entityType?: string;
96
+ readonly entityId?: string;
97
+ }): void;
98
+ join(input: WebSocketJoinInput): Promise<{ syncGroups: string[]; ttlSeconds?: number }>;
99
+ leave(claimId: string): void;
100
+ subscribe<K extends keyof SyncWebSocketEventMap<TEvents>>(
101
+ event: K,
102
+ listener: (...args: SyncWebSocketEventMap<TEvents>[K]) => void,
103
+ ): () => void;
104
+ observe(options?: WebSocketObserveOptions): AsyncIterable<WebSocketObservedDelta>;
105
+ updateSubscription(
106
+ syncGroups: readonly string[],
107
+ options?: { timeoutMs?: number },
108
+ ): Promise<{ syncGroups: string[] }>;
109
+ acknowledge(lastSyncId: number): Promise<void>;
110
+ close(): Promise<void>;
111
+ [Symbol.asyncDispose](): Promise<void>;
112
+ }
113
+
114
+ export type WebSocketCoreEvent = keyof CoreSyncEventMap;
115
+ export type WebSocketPresenceUpdate = PresenceUpdate;
@@ -203,6 +203,7 @@ export interface CoreSyncEventMap {
203
203
  presence_update: [PresenceUpdate];
204
204
  error: [Error];
205
205
  session_error: [Error];
206
+ protocol_mismatch: [CloseEvent];
206
207
  /**
207
208
  * The WebSocket `onclose` fired before `onopen` — the handshake itself
208
209
  * failed. The browser cannot expose the HTTP status (it shows as code
@@ -998,6 +999,77 @@ export class WsTransport<
998
999
  }
999
1000
  }
1000
1001
 
1002
+ /**
1003
+ * Ask the server for every durable delta after the supplied position.
1004
+ *
1005
+ * This is deliberately part of the carrier rather than the reactive
1006
+ * materialiser: browser stores and headless WebSocket clients resume the same
1007
+ * ordered protocol. The owner decides when a received position is durable
1008
+ * enough to acknowledge.
1009
+ */
1010
+ requestSync(position: {
1011
+ readonly cursor?: string | null;
1012
+ readonly lastSyncId: number;
1013
+ }): void {
1014
+ if (this.ws?.readyState !== WebSocket.OPEN) {
1015
+ throw this.notConnectedError('sync_request');
1016
+ }
1017
+ const capabilities = Object.entries(this.options.capabilities)
1018
+ .filter(([, enabled]) => enabled)
1019
+ .map(([name]) => name);
1020
+ this.send({
1021
+ type: 'sync_request',
1022
+ payload: {
1023
+ cursor: position.cursor ?? null,
1024
+ lastSyncId: position.lastSyncId,
1025
+ capabilities,
1026
+ protocolVersion: PROTOCOL_VERSION,
1027
+ },
1028
+ });
1029
+ }
1030
+
1031
+ /** Persist the server-side resume position after the caller durably applied it. */
1032
+ acknowledge(lastSyncId: number): void {
1033
+ if (!Number.isSafeInteger(lastSyncId) || lastSyncId < 0) {
1034
+ throw new AbloValidationError('acknowledge requires a non-negative safe integer.', {
1035
+ code: 'invalid_request',
1036
+ });
1037
+ }
1038
+ if (this.ws?.readyState !== WebSocket.OPEN) return;
1039
+ this.send({ type: 'ack', payload: { lastSyncId } });
1040
+ }
1041
+
1042
+ /** Announce this participant's current presence on the shared connection. */
1043
+ updatePresence(input: {
1044
+ readonly status?: 'online' | 'away' | 'offline';
1045
+ readonly customStatus?: string;
1046
+ readonly timezone?: string;
1047
+ readonly activity?: Record<string, unknown>;
1048
+ } = {}): void {
1049
+ if (this.ws?.readyState !== WebSocket.OPEN) {
1050
+ throw this.notConnectedError('presence_update');
1051
+ }
1052
+ let timezone = input.timezone;
1053
+ if (!timezone) {
1054
+ try {
1055
+ timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
1056
+ } catch {
1057
+ timezone = 'UTC';
1058
+ }
1059
+ }
1060
+ this.send({
1061
+ type: 'presence_update',
1062
+ payload: {
1063
+ status: input.status ?? 'online',
1064
+ timezone,
1065
+ ...(input.customStatus !== undefined
1066
+ ? { customStatus: input.customStatus }
1067
+ : {}),
1068
+ ...(input.activity !== undefined ? { activity: input.activity } : {}),
1069
+ },
1070
+ });
1071
+ }
1072
+
1001
1073
  /**
1002
1074
  * Sends a `commit` mutation request over the existing WebSocket and resolves
1003
1075
  * when the server's `mutation_result` frame comes back with the same