@abloatai/transaction 0.59.0 → 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 (173) 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.d.ts.map +1 -1
  67. package/dist/schema/deployment/index.js +52 -3
  68. package/dist/schema/deployment/index.js.map +1 -1
  69. package/dist/schema/deployment/reconcile.d.ts +2 -2
  70. package/dist/schema/deployment/reconcile.d.ts.map +1 -1
  71. package/dist/schema/deployment/reconcile.js +98 -6
  72. package/dist/schema/deployment/reconcile.js.map +1 -1
  73. package/dist/schema/diff.js.map +1 -1
  74. package/dist/schema/field.d.ts +1 -1
  75. package/dist/schema/field.d.ts.map +1 -1
  76. package/dist/schema/loadStrategy.d.ts +2 -2
  77. package/dist/schema/loadStrategy.d.ts.map +1 -1
  78. package/dist/schema/openapi.js.map +1 -1
  79. package/dist/schema/relation.d.ts +3 -3
  80. package/dist/schema/relation.d.ts.map +1 -1
  81. package/dist/schema/residency.d.ts +1 -1
  82. package/dist/schema/roles.d.ts +1 -1
  83. package/dist/schema/roles.d.ts.map +1 -1
  84. package/dist/schema/roles.js.map +1 -1
  85. package/dist/schema/schema.d.ts +1 -1
  86. package/dist/schema/schema.d.ts.map +1 -1
  87. package/dist/schema/serialize.js.map +1 -1
  88. package/dist/schema/sugar.d.ts.map +1 -1
  89. package/dist/schema/tenancy.d.ts +2 -2
  90. package/dist/server/storageMode.d.ts +1 -1
  91. package/dist/source/adapters/adapterProfile.d.ts +6 -6
  92. package/dist/source/adapters/conformance.js.map +1 -1
  93. package/dist/source/adapters/contract.d.ts +14 -14
  94. package/dist/source/adapters/kysely.js.map +1 -1
  95. package/dist/source/adapters/memory.js.map +1 -1
  96. package/dist/source/adapters/prisma.js.map +1 -1
  97. package/dist/source/connector/client.d.ts.map +1 -1
  98. package/dist/source/connector/client.js.map +1 -1
  99. package/dist/source/connector/protocol.d.ts.map +1 -1
  100. package/dist/source/delivery/pushQueue.d.ts.map +1 -1
  101. package/dist/source/delivery/pushQueue.js.map +1 -1
  102. package/dist/source/endpoint/signing.d.ts +4 -4
  103. package/dist/source/endpoint/signing.d.ts.map +1 -1
  104. package/dist/source/outbox/contract.d.ts +4 -4
  105. package/dist/testing/fixtures/httpResponses.d.ts +2 -2
  106. package/dist/testing/fixtures/httpResponses.d.ts.map +1 -1
  107. package/dist/testing/fixtures/httpResponses.js.map +1 -1
  108. package/dist/transport/connection/credentialLifecycle.d.ts.map +1 -1
  109. package/dist/transport/connection/credentialLifecycle.js.map +1 -1
  110. package/dist/transport/connection/manager.d.ts.map +1 -1
  111. package/dist/transport/connection/manager.js.map +1 -1
  112. package/dist/transport/connection/networkProbe.d.ts +18 -5
  113. package/dist/transport/connection/networkProbe.d.ts.map +1 -1
  114. package/dist/transport/connection/networkProbe.js.map +1 -1
  115. package/dist/transport/http/client.d.ts +37 -1
  116. package/dist/transport/http/client.d.ts.map +1 -1
  117. package/dist/transport/http/client.js +117 -2
  118. package/dist/transport/http/client.js.map +1 -1
  119. package/dist/transport/http/transport.d.ts +21 -0
  120. package/dist/transport/http/transport.d.ts.map +1 -1
  121. package/dist/transport/http/transport.js +93 -5
  122. package/dist/transport/http/transport.js.map +1 -1
  123. package/dist/transport/websocket/commitFrames.d.ts +4 -3
  124. package/dist/transport/websocket/commitFrames.d.ts.map +1 -1
  125. package/dist/transport/websocket/commitFrames.js +1 -0
  126. package/dist/transport/websocket/commitFrames.js.map +1 -1
  127. package/dist/transport/websocket/contextSubscription.d.ts +6 -0
  128. package/dist/transport/websocket/contextSubscription.d.ts.map +1 -0
  129. package/dist/transport/websocket/contextSubscription.js +45 -0
  130. package/dist/transport/websocket/contextSubscription.js.map +1 -0
  131. package/dist/transport/websocket/heartbeat.d.ts.map +1 -1
  132. package/dist/transport/websocket/heartbeat.js.map +1 -1
  133. package/dist/transport/websocket/session.d.ts +4 -0
  134. package/dist/transport/websocket/session.d.ts.map +1 -0
  135. package/dist/transport/websocket/session.js +385 -0
  136. package/dist/transport/websocket/session.js.map +1 -0
  137. package/dist/transport/websocket/sessionContract.d.ts +89 -0
  138. package/dist/transport/websocket/sessionContract.d.ts.map +1 -0
  139. package/dist/transport/websocket/sessionContract.js +2 -0
  140. package/dist/transport/websocket/sessionContract.js.map +1 -0
  141. package/dist/transport/websocket/transport.d.ts +22 -0
  142. package/dist/transport/websocket/transport.d.ts.map +1 -1
  143. package/dist/transport/websocket/transport.js +63 -1
  144. package/dist/transport/websocket/transport.js.map +1 -1
  145. package/dist/wire/accountResponses.d.ts +31 -31
  146. package/dist/wire/auth.d.ts +3 -3
  147. package/dist/wire/bootstrapReason.d.ts +2 -2
  148. package/dist/wire/dataSourceResponses.d.ts +2 -2
  149. package/dist/wire/dataSourceResponses.d.ts.map +1 -1
  150. package/dist/wire/frames.d.ts +8 -8
  151. package/dist/wire/inboundFrames.d.ts +56 -56
  152. package/dist/wire/inboundFrames.d.ts.map +1 -1
  153. package/dist/wire/listEnvelope.js.map +1 -1
  154. package/dist/wire/modelResponses.d.ts +2 -2
  155. package/dist/wire/modelShape.d.ts +4 -4
  156. package/dist/wire/pricing.d.ts +3 -3
  157. package/dist/wire/protocolVersion.d.ts.map +1 -1
  158. package/package.json +2 -1
  159. package/src/client/ablo.ts +10 -5
  160. package/src/client/surface.ts +8 -1
  161. package/src/commit/recordRuntime.ts +4 -2
  162. package/src/errorCodes.ts +5 -1
  163. package/src/errors.ts +18 -1
  164. package/src/index.ts +1 -0
  165. package/src/schema/deployment/index.ts +54 -3
  166. package/src/schema/deployment/reconcile.ts +122 -6
  167. package/src/transport/http/client.ts +171 -4
  168. package/src/transport/http/transport.ts +126 -4
  169. package/src/transport/websocket/commitFrames.ts +5 -3
  170. package/src/transport/websocket/contextSubscription.ts +55 -0
  171. package/src/transport/websocket/session.ts +467 -0
  172. package/src/transport/websocket/sessionContract.ts +115 -0
  173. package/src/transport/websocket/transport.ts +72 -0
@@ -37,9 +37,10 @@ export type CommitAck = CanonicalCommitAck;
37
37
  export interface CommitFrameOperation {
38
38
  readonly type: string;
39
39
  readonly model: string;
40
- readonly id: string;
41
- readonly input?: Record<string, unknown>;
42
- readonly transactionId?: string;
40
+ readonly id: string | null;
41
+ readonly input?: Record<string, unknown> | null;
42
+ readonly where?: Record<string, unknown> | null;
43
+ readonly transactionId?: string | null;
43
44
  readonly claimId?: string | null;
44
45
  readonly readAt?: number | null;
45
46
  readonly fenceToken?: number | null;
@@ -65,6 +66,7 @@ export function buildCommitFrame(
65
66
  model: op.model,
66
67
  id: op.id,
67
68
  input: op.input,
69
+ where: op.where,
68
70
  transactionId: op.transactionId,
69
71
  claimId: op.claimId,
70
72
  readAt: op.readAt,
@@ -0,0 +1,55 @@
1
+ import type { ReadDependency } from '../../coordination/schema.js';
2
+ import { AbloStaleContextError } from '../../errors.js';
3
+ import type { AbloWebSocketSession } from './sessionContract.js';
4
+
5
+ /** Watch captured reads over the client's already-open WebSocket. */
6
+ export function subscribeWebSocketReadChanges(
7
+ session: AbloWebSocketSession,
8
+ reads: readonly ReadDependency[],
9
+ listener: (error: AbloStaleContextError) => void,
10
+ ): () => void {
11
+ if (reads.length === 0) return () => undefined;
12
+ const watchedRows = new Map(
13
+ reads
14
+ .filter((read): read is Extract<ReadDependency, { model: string }> => 'model' in read)
15
+ .map((read) => [`${read.model.toLowerCase()}:${read.id}`, read.readAt]),
16
+ );
17
+ const watchedGroups = new Map<string, number>(
18
+ reads
19
+ .filter((read): read is Extract<ReadDependency, { group: string }> => 'group' in read)
20
+ .map((read) => [read.group, read.readAt]),
21
+ );
22
+ let stopped = false;
23
+ let unsubscribe = (): void => undefined;
24
+ unsubscribe = session.subscribe('delta', (delta) => {
25
+ if (stopped) return;
26
+ const readAt = watchedRows.get(`${delta.modelName.toLowerCase()}:${delta.modelId}`)
27
+ ?? delta.syncGroups.reduce<number | undefined>((latest, group) => {
28
+ const groupReadAt = watchedGroups.get(group);
29
+ return groupReadAt === undefined
30
+ ? latest
31
+ : Math.max(latest ?? groupReadAt, groupReadAt);
32
+ }, undefined);
33
+ if (readAt === undefined || delta.id <= readAt) return;
34
+ stopped = true;
35
+ unsubscribe();
36
+ listener(new AbloStaleContextError(
37
+ `Captured context changed: ${delta.modelName}/${delta.modelId}.`,
38
+ {
39
+ code: 'stale_context',
40
+ details: {
41
+ conflicts: [{
42
+ model: delta.modelName,
43
+ id: delta.modelId,
44
+ readAt,
45
+ current: delta.id,
46
+ }],
47
+ },
48
+ },
49
+ ));
50
+ });
51
+ return () => {
52
+ stopped = true;
53
+ unsubscribe();
54
+ };
55
+ }
@@ -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;