@abloatai/transaction 0.60.0 → 0.61.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/dist/claims/admission.d.ts +4 -0
  2. package/dist/claims/admission.d.ts.map +1 -0
  3. package/dist/claims/admission.js +32 -0
  4. package/dist/claims/admission.js.map +1 -0
  5. package/dist/claims/awaitGrant.d.ts.map +1 -1
  6. package/dist/claims/awaitGrant.js +4 -38
  7. package/dist/claims/awaitGrant.js.map +1 -1
  8. package/dist/claims/conflict.d.ts +55 -0
  9. package/dist/claims/conflict.d.ts.map +1 -0
  10. package/dist/claims/conflict.js +71 -0
  11. package/dist/claims/conflict.js.map +1 -0
  12. package/dist/claims/contract.d.ts +12 -0
  13. package/dist/claims/contract.d.ts.map +1 -1
  14. package/dist/claims/contract.js +2 -0
  15. package/dist/claims/contract.js.map +1 -1
  16. package/dist/claims/index.d.ts +2 -0
  17. package/dist/claims/index.d.ts.map +1 -1
  18. package/dist/claims/index.js +1 -0
  19. package/dist/claims/index.js.map +1 -1
  20. package/dist/coordination/index.d.ts +2 -2
  21. package/dist/coordination/index.d.ts.map +1 -1
  22. package/dist/coordination/index.js +1 -1
  23. package/dist/coordination/index.js.map +1 -1
  24. package/dist/coordination/schema.d.ts +7 -0
  25. package/dist/coordination/schema.d.ts.map +1 -1
  26. package/dist/coordination/schema.js +7 -0
  27. package/dist/coordination/schema.js.map +1 -1
  28. package/dist/errorCodes.d.ts.map +1 -1
  29. package/dist/errorCodes.js +12 -1
  30. package/dist/errorCodes.js.map +1 -1
  31. package/dist/errors.d.ts +6 -0
  32. package/dist/errors.d.ts.map +1 -1
  33. package/dist/errors.js +101 -5
  34. package/dist/errors.js.map +1 -1
  35. package/dist/transport/http/claimWait.d.ts +9 -0
  36. package/dist/transport/http/claimWait.d.ts.map +1 -0
  37. package/dist/transport/http/claimWait.js +74 -0
  38. package/dist/transport/http/claimWait.js.map +1 -0
  39. package/dist/transport/http/client.d.ts.map +1 -1
  40. package/dist/transport/http/client.js +3 -4
  41. package/dist/transport/http/client.js.map +1 -1
  42. package/dist/transport/http/contract.d.ts +57 -0
  43. package/dist/transport/http/contract.d.ts.map +1 -0
  44. package/dist/transport/http/contract.js +3 -0
  45. package/dist/transport/http/contract.js.map +1 -0
  46. package/dist/transport/http/index.d.ts +1 -0
  47. package/dist/transport/http/index.d.ts.map +1 -1
  48. package/dist/transport/http/index.js +1 -0
  49. package/dist/transport/http/index.js.map +1 -1
  50. package/dist/transport/http/transport.d.ts +1 -78
  51. package/dist/transport/http/transport.d.ts.map +1 -1
  52. package/dist/transport/http/transport.js +7 -83
  53. package/dist/transport/http/transport.js.map +1 -1
  54. package/dist/transport/websocket/frameHandlers.d.ts.map +1 -1
  55. package/dist/transport/websocket/frameHandlers.js +3 -0
  56. package/dist/transport/websocket/frameHandlers.js.map +1 -1
  57. package/dist/transport/websocket/session.d.ts.map +1 -1
  58. package/dist/transport/websocket/session.js +69 -36
  59. package/dist/transport/websocket/session.js.map +1 -1
  60. package/dist/transport/websocket/sessionContract.d.ts +1 -1
  61. package/dist/transport/websocket/sessionContract.d.ts.map +1 -1
  62. package/dist/transport/websocket/transport.d.ts +2 -1
  63. package/dist/transport/websocket/transport.d.ts.map +1 -1
  64. package/dist/transport/websocket/transport.js.map +1 -1
  65. package/dist/wire/inboundFrames.d.ts +7 -0
  66. package/dist/wire/inboundFrames.d.ts.map +1 -1
  67. package/dist/wire/inboundFrames.js +5 -1
  68. package/dist/wire/inboundFrames.js.map +1 -1
  69. package/package.json +1 -1
  70. package/src/claims/admission.ts +49 -0
  71. package/src/claims/awaitGrant.ts +3 -52
  72. package/src/claims/conflict.ts +103 -0
  73. package/src/claims/contract.ts +2 -0
  74. package/src/claims/index.ts +11 -0
  75. package/src/coordination/index.ts +2 -0
  76. package/src/coordination/schema.ts +11 -0
  77. package/src/errorCodes.ts +18 -1
  78. package/src/errors.ts +116 -4
  79. package/src/transport/http/claimWait.ts +108 -0
  80. package/src/transport/http/client.ts +6 -5
  81. package/src/transport/http/contract.ts +71 -0
  82. package/src/transport/http/index.ts +1 -0
  83. package/src/transport/http/transport.ts +11 -204
  84. package/src/transport/websocket/frameHandlers.ts +7 -0
  85. package/src/transport/websocket/session.ts +77 -43
  86. package/src/transport/websocket/sessionContract.ts +1 -1
  87. package/src/transport/websocket/transport.ts +2 -0
  88. package/src/wire/inboundFrames.ts +5 -0
@@ -53,7 +53,6 @@ import {
53
53
  DEFAULT_CLAIM_TTL_MS,
54
54
  type ClaimHeartbeatReply,
55
55
  type ClaimListResponse,
56
- type ClaimQueuedResponse,
57
56
  type ClaimRequest,
58
57
  type ClaimState,
59
58
  type ClaimTargetBody,
@@ -69,7 +68,7 @@ import {
69
68
  resolveHeartbeatPlan,
70
69
  startClaimHeartbeatLoop,
71
70
  } from '../../claims/heartbeat.js';
72
- import type { HttpClientConfig } from './options.js';
71
+ import type { HttpTransport, HttpTransportOptions } from './contract.js';
73
72
  import type {
74
73
  ClaimedOptions,
75
74
  CommitCreateOptions,
@@ -90,7 +89,7 @@ import type {
90
89
  } from '../../client/resources/httpResources.js';
91
90
  import type { CredentialProviderResult } from '../../auth/credentialResult.js';
92
91
  import { credentialToken } from '../../auth/credentialResult.js';
93
- import { createSessionAccess, type SessionAccess } from '../../sessions/index.js';
92
+ import { createSessionAccess } from '../../sessions/index.js';
94
93
  import { parseIdentityResolveResponse } from '../../auth/schemas.js';
95
94
  import type { EffectiveAuthority } from '../../auth/capability.js';
96
95
 
@@ -108,7 +107,6 @@ import type {
108
107
  ClaimReorderParams,
109
108
  ModelCreateManyParams,
110
109
  ServerReadOptions,
111
- ResolvedClaimContentionOptions,
112
110
  ClaimQueueView,
113
111
  } from '../../client/resources/modelOperations.js';
114
112
  import {
@@ -119,12 +117,6 @@ import {
119
117
  } from '../../client/resources/modelOperations.js';
120
118
  import type { Duration } from '../../utils/duration.js';
121
119
  import { claimDescription, partName } from '../../coordination/schema.js';
122
- import type {
123
- ClaimAcquired,
124
- ClaimBeginPayload,
125
- ClaimGranted,
126
- ClaimQueued,
127
- } from '../../coordination/schema.js';
128
120
  import type { BatchFence } from '../../claims/locator.js';
129
121
  import {
130
122
  subTarget,
@@ -141,7 +133,6 @@ import type {
141
133
  HeldClaim,
142
134
  HeldLease,
143
135
  } from '../../types/streams.js';
144
- import type { CoordinationObservability } from '../../observability.js';
145
136
  import {
146
137
  assertWriteOptions,
147
138
  assertWriteTarget,
@@ -169,86 +160,14 @@ import {
169
160
  type ExactHttpCommitRequest,
170
161
  } from '../../commit/httpRequest.js';
171
162
  import { resolveDurableWrites } from '../../commit/durableWrites.js';
172
- import { createHttpReadOnChange, type HttpReadOnChange } from './subscription.js';
163
+ import { createHttpReadOnChange } from './subscription.js';
164
+ import { awaitClaimGrantOverHttp } from './claimWait.js';
173
165
  import type { CommitFrameOperation } from '../websocket/commitFrames.js';
174
166
 
175
- /** @internal Private options for the schema-agnostic HTTP protocol transport. */
176
- export type HttpTransportOptions = Omit<HttpClientConfig, 'schema'> & {
177
- readonly bootstrapBaseUrl?: string | undefined;
178
- /**
179
- * The observability provider forwarded from `Ablo({ observability })`. The HTTP
180
- * transport emits the same claim and conflict events as the WebSocket transport,
181
- * so a `ClaimLog` works identically for headless server-agent evaluations.
182
- */
183
- readonly observability?: CoordinationObservability;
184
- /**
185
- * Per-request deadline in milliseconds for the stateless HTTP transport.
186
- * Every request this client issues is aborted after this long and surfaces
187
- * as a retryable connection error — without it a black-holed server hangs
188
- * a headless agent forever (browsers never time fetch out on their own).
189
- * Pass `0` to disable the deadline.
190
- *
191
- * @default 30_000
192
- */
193
- readonly timeoutMs?: number;
194
- /** @internal Routes writes through the selected duplex transport. */
195
- readonly dispatchCommit?: ((input: {
196
- readonly clientTxId: string;
197
- readonly operations: readonly CommitFrameOperation[];
198
- readonly reads?: readonly import('../../coordination/schema.js').ReadDependency[] | null;
199
- }) => Promise<CommitReceiptWire>) | undefined;
200
- /** @internal Routes held-claim acquisition through the shared socket. */
201
- readonly dispatchClaim?: ((input: ClaimBeginPayload & {
202
- readonly timeoutMs?: number;
203
- readonly signal?: AbortSignal;
204
- readonly onQueued?: (event: ClaimQueued) => Error | undefined;
205
- }) => Promise<ClaimAcquired | ClaimGranted>) | undefined;
206
- /** @internal Releases a socket-acquired claim on that same session. */
207
- readonly releaseDispatchedClaim?: ((input: {
208
- readonly claimId: string;
209
- readonly entityType: string;
210
- readonly entityId: string;
211
- }) => void) | undefined;
212
- };
213
-
214
167
  /** @internal Default per-request deadline for the private HTTP transport. */
215
168
  export const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
216
169
  const HTTP_CONFIRMATION_POLL_INTERVAL_MS = 250;
217
-
218
- /** @internal Private protocol surface wrapped by `AbloHttpClient`. */
219
- export interface HttpTransport {
220
- ready(): Promise<void>;
221
- waitForFlush(): Promise<void>;
222
- /** Drains scheduled commits and active requests. */
223
- dispose(): Promise<void>;
224
- purge(): Promise<void>;
225
- /** @internal Live check used only by `context().onChange`. */
226
- readonly onChange: HttpReadOnChange;
227
- readonly commits: CommitResource;
228
- /**
229
- * Claim-ticket operations keyed by `claimId` — the id a queued acquire
230
- * hands back on `AbloClaimedError('claim_queued')`. See
231
- * {@link HttpClaimsResource}.
232
- */
233
- readonly claims: HttpClaimsResource;
234
- readonly logs: HttpLogsResource;
235
- /** Server-confirmed authority of the active bearer, populated by `ready()`. */
236
- readonly identity: EffectiveAuthority | null;
237
- model<T = Record<string, unknown>, Fields = T>(
238
- name: string,
239
- ): HttpTransportModel<T, Fields>;
240
- /**
241
- * Resolve the active bearer credential this client authenticates with — the
242
- * same token its own requests carry in `Authorization`. Returns `null` when
243
- * no credential is configured. Async because the API key may be supplied as
244
- * an async setter. Use it to authenticate a side-band request to the same
245
- * server with the credential this client already holds — no re-mint.
246
- */
247
- getAuthToken(): Promise<string | null>;
248
- /** @internal One normalized source shared by HTTP bootstrap and live transport. */
249
- readonly access: SessionAccess;
250
- }
251
-
170
+ const ignoreBestEffortClaimReleaseFailure = (): undefined => undefined;
252
171
  type CommitResponse = CommitReceiptWire;
253
172
 
254
173
  /** @internal Constructed only by the typed HTTP facade. */
@@ -1169,118 +1088,6 @@ export function createHttpTransport(options: HttpTransportOptions): HttpTranspor
1169
1088
  },
1170
1089
  };
1171
1090
 
1172
- // How the stateless client waits its turn. The queued slot is real server
1173
- // state, so one heartbeat per tick does both jobs: it refreshes this
1174
- // waiter's slot and reports the line's answer — `queued` (still waiting) or
1175
- // `held` (granted). The first check comes quickly because most holds are a
1176
- // short claim→write→release; after that the cadence relaxes, with jitter so
1177
- // a fleet of waiters doesn't beat in step.
1178
- const GRANT_POLL_FIRST_MS = 250;
1179
- const GRANT_POLL_INTERVAL_MS = 1_000;
1180
- // An abort cuts the sleep short so the wait ends within a tick of the
1181
- // signal, not at the next scheduled beat.
1182
- const sleep = (ms: number, signal?: AbortSignal): Promise<void> =>
1183
- new Promise((resolve) => {
1184
- const done = (): void => {
1185
- clearTimeout(timer);
1186
- signal?.removeEventListener('abort', done);
1187
- resolve();
1188
- };
1189
- const timer = setTimeout(done, ms);
1190
- signal?.addEventListener('abort', done, { once: true });
1191
- });
1192
-
1193
- async function awaitGrantOverHttp(
1194
- targetLabel: string,
1195
- queued: ClaimQueuedResponse,
1196
- options: ResolvedClaimContentionOptions,
1197
- ): Promise<{ id: string; fenceToken?: number }> {
1198
- // The queued reply is a claim resource in its waiting state, so the
1199
- // handle is its `id` — same rule as the 201 and the poll.
1200
- const claimId = queued.id;
1201
- const { signal } = options;
1202
- // Leave the line before rejecting: an abandoned slot would otherwise sit
1203
- // in the queue until its TTL lapses, stalling every waiter behind it.
1204
- const rejectAndLeave = async (error: AbloClaimedError): Promise<never> => {
1205
- await claims.release({ claimId }).catch(() => {});
1206
- throw error;
1207
- };
1208
-
1209
- emitClaimStatus(options.onStatus, {
1210
- type: 'queued',
1211
- claimId,
1212
- position: queued.position,
1213
- ahead: queued.position + 1,
1214
- });
1215
-
1216
- if (options.maxDepth !== undefined && queued.position >= options.maxDepth) {
1217
- return rejectAndLeave(
1218
- new AbloClaimedError(
1219
- `Claim queue for ${targetLabel} is ${queued.position} deep (max ${options.maxDepth}).`,
1220
- { code: 'queue_too_deep' }
1221
- )
1222
- );
1223
- }
1224
-
1225
- const deadline =
1226
- options.timeoutMs !== undefined ? Date.now() + options.timeoutMs : undefined;
1227
- let delay = GRANT_POLL_FIRST_MS;
1228
- for (;;) {
1229
- if (signal?.aborted) {
1230
- return rejectAndLeave(
1231
- new AbloClaimedError(
1232
- `The wait for the claim on ${targetLabel} was aborted before the grant arrived.`,
1233
- { code: 'claim_wait_aborted' }
1234
- )
1235
- );
1236
- }
1237
- if (deadline !== undefined && Date.now() >= deadline) {
1238
- return rejectAndLeave(
1239
- new AbloClaimedError(
1240
- `Timed out after ${options.timeoutMs}ms waiting for the queue grant on ${targetLabel}.`,
1241
- { code: 'grant_timeout' }
1242
- )
1243
- );
1244
- }
1245
- await sleep(
1246
- deadline !== undefined ? Math.min(delay, Math.max(0, deadline - Date.now())) : delay,
1247
- signal
1248
- );
1249
- if (signal?.aborted) {
1250
- return rejectAndLeave(
1251
- new AbloClaimedError(
1252
- `The wait for the claim on ${targetLabel} was aborted before the grant arrived.`,
1253
- { code: 'claim_wait_aborted' }
1254
- )
1255
- );
1256
- }
1257
- delay = GRANT_POLL_INTERVAL_MS * (0.85 + Math.random() * 0.3);
1258
- // A lease that ended answers the beat with 409 `claim_lost`, which the
1259
- // wire error mapping raises as AbloClaimedError before this reads
1260
- // anything — the wait fails with the loss, as the socket wait does.
1261
- const beat = await claims.heartbeat({ claimId });
1262
- if (beat.status !== 'held') continue;
1263
- // Granted. The heartbeat ack does not carry the fence token — the claim
1264
- // state does, server-stamped at grant.
1265
- const state = await claims.retrieve({ claimId });
1266
- if (state.status !== 'active') {
1267
- return rejectAndLeave(
1268
- new AbloClaimedError(`Claim lost while queued for ${targetLabel}.`, {
1269
- code: 'claim_lost',
1270
- })
1271
- );
1272
- }
1273
- emitClaimStatus(options.onStatus, {
1274
- type: 'granted',
1275
- claimId,
1276
- waited: true,
1277
- });
1278
- return state.fenceToken !== undefined
1279
- ? { id: claimId, fenceToken: state.fenceToken }
1280
- : { id: claimId };
1281
- }
1282
- }
1283
-
1284
1091
  async function applyClaimedPolicy(
1285
1092
  target: Partial<ModelTarget>,
1286
1093
  options?: ClaimedOptions,
@@ -1734,7 +1541,8 @@ export function createHttpTransport(options: HttpTransportOptions): HttpTranspor
1734
1541
  // the manual ticket surface.)
1735
1542
  if (body.status === 'queued') {
1736
1543
  try {
1737
- return await awaitGrantOverHttp(
1544
+ return await awaitClaimGrantOverHttp(
1545
+ claims,
1738
1546
  `${name}/${params.id}`,
1739
1547
  body,
1740
1548
  contention,
@@ -1767,8 +1575,7 @@ export function createHttpTransport(options: HttpTransportOptions): HttpTranspor
1767
1575
  ): Promise<void> => {
1768
1576
  const entityId = isClaimHandle(params) ? params.target.id : params.id;
1769
1577
  if (claimId && options.releaseDispatchedClaim) {
1770
- options.releaseDispatchedClaim({ claimId, entityType: name, entityId });
1771
- return Promise.resolve();
1578
+ return options.releaseDispatchedClaim({ claimId, entityType: name, entityId });
1772
1579
  }
1773
1580
  return requestRaw(claimPath(entityId), {
1774
1581
  method: 'DELETE',
@@ -1896,7 +1703,7 @@ export function createHttpTransport(options: HttpTransportOptions): HttpTranspor
1896
1703
  description: claimDescription(params),
1897
1704
  release,
1898
1705
  revoke: () => {
1899
- void release().catch(() => {});
1706
+ void release().catch(ignoreBestEffortClaimReleaseFailure);
1900
1707
  },
1901
1708
  heartbeat,
1902
1709
  [Symbol.asyncDispose]: release,
@@ -1911,7 +1718,7 @@ export function createHttpTransport(options: HttpTransportOptions): HttpTranspor
1911
1718
  // this credential until the TTL lapses, behind no handle anyone can
1912
1719
  // release, and every other participant is refused for the duration.
1913
1720
  if (data === undefined) {
1914
- await release().catch(() => undefined);
1721
+ await release().catch(ignoreBestEffortClaimReleaseFailure);
1915
1722
  throw new AbloNotFoundError(
1916
1723
  `Cannot claim ${name}/${params.id}: it does not exist (or is outside this credential's scope). ` +
1917
1724
  `To hold a key before its row exists, claim it by id alone: claim('${params.id}') returns a lease without a snapshot.`,
@@ -2018,7 +1825,7 @@ export function createHttpTransport(options: HttpTransportOptions): HttpTranspor
2018
1825
  claim: undefined,
2019
1826
  });
2020
1827
  } finally {
2021
- await releaseClaim({ id }, claimId).catch(() => {});
1828
+ await releaseClaim({ id }, claimId).catch(ignoreBestEffortClaimReleaseFailure);
2022
1829
  }
2023
1830
  };
2024
1831
 
@@ -425,6 +425,13 @@ const validatedFrameHandlers: Record<
425
425
  session.emit('claim_acquired', payload);
426
426
  },
427
427
  ),
428
+ claim_abandon_ack: validating(
429
+ WS_INBOUND_FRAMES.claim_abandon_ack.payload,
430
+ 'claim_abandon_ack',
431
+ (session, payload) => {
432
+ session.emit('claim_abandon_ack', payload);
433
+ },
434
+ ),
428
435
  claim_queued: validating(
429
436
  WS_INBOUND_FRAMES.claim_queued.payload,
430
437
  'claim_queued',
@@ -2,9 +2,9 @@ import {
2
2
  AbloClaimedError,
3
3
  AbloConnectionError,
4
4
  AbloSessionError,
5
- errorFromWire,
6
5
  isAccessCredentialExpiryCloseReason,
7
6
  } from '../../errors.js';
7
+ import { claimAdmissionError } from '../../claims/admission.js';
8
8
  import { classifyCredentialKind } from '../../auth/credentialKind.js';
9
9
  import {
10
10
  credentialToken,
@@ -38,6 +38,10 @@ interface StoredPosition {
38
38
  }
39
39
 
40
40
  const MAX_BUFFERED_OBSERVATION_DELTAS = 1_024;
41
+ const ignoreObservedAcknowledgeFailure = (): undefined => undefined;
42
+ // Preserve the originating claim wait error when best-effort queue cleanup
43
+ // fails for the same disconnected transport.
44
+ const ignoreBestEffortClaimReleaseFailure = (): undefined => undefined;
41
45
 
42
46
  function parsePosition(value: string | null): StoredPosition {
43
47
  if (!value) return { lastSyncId: 0, cursor: null };
@@ -206,7 +210,15 @@ class WebSocketSession<TEvents extends EventMap<TEvents>>
206
210
  return;
207
211
  }
208
212
  this.pendingSessionError = error;
209
- void this.credentialLifecycle.recoverFromAuthRejection('access_credential_expiry');
213
+ void this.credentialLifecycle
214
+ .recoverFromAuthRejection('access_credential_expiry')
215
+ .catch((recoveryError: unknown) => {
216
+ this.failTerminal(
217
+ recoveryError instanceof Error
218
+ ? recoveryError
219
+ : new AbloConnectionError('The WebSocket credential recovery failed.'),
220
+ );
221
+ });
210
222
  }
211
223
 
212
224
  private failTerminal(error: Error): void {
@@ -284,14 +296,27 @@ class WebSocketSession<TEvents extends EventMap<TEvents>>
284
296
 
285
297
  async claim(input: WebSocketClaimInput) {
286
298
  await this.ready();
287
- const timeoutMs = input.timeoutMs ?? 15_000;
288
299
  return new Promise<import('../../coordination/schema.js').ClaimAcquired | import('../../coordination/schema.js').ClaimGranted>((resolve, reject) => {
289
300
  const cleanups: (() => void)[] = [];
301
+ let finished = false;
290
302
  const finish = (outcome: () => void): void => {
291
- clearTimeout(timeout);
303
+ if (finished) return;
304
+ finished = true;
305
+ if (timeout) clearTimeout(timeout);
292
306
  for (const cleanup of cleanups) cleanup();
293
307
  outcome();
294
308
  };
309
+ const abandonAndReject = (error: Error): void => {
310
+ if (finished) return;
311
+ finished = true;
312
+ if (timeout) clearTimeout(timeout);
313
+ for (const cleanup of cleanups) cleanup();
314
+ void this.release({
315
+ claimId: input.claimId,
316
+ entityType: input.entityType,
317
+ entityId: input.entityId,
318
+ }).catch(ignoreBestEffortClaimReleaseFailure).then(() => reject(error));
319
+ };
295
320
  const matches = (event: { claimId: string }): boolean => event.claimId === input.claimId;
296
321
  cleanups.push(this.socket.subscribe('claim_acquired', (event) => {
297
322
  if (matches(event)) finish(() => resolve(event));
@@ -303,27 +328,12 @@ class WebSocketSession<TEvents extends EventMap<TEvents>>
303
328
  if (!matches(event)) return;
304
329
  const refusal = input.onQueued?.(event);
305
330
  if (refusal) {
306
- this.release({
307
- claimId: input.claimId,
308
- entityType: input.entityType,
309
- entityId: input.entityId,
310
- });
311
- finish(() => reject(refusal));
331
+ abandonAndReject(refusal);
312
332
  }
313
333
  }));
314
334
  cleanups.push(this.socket.subscribe('claim_rejected', (event) => {
315
335
  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
- )));
336
+ finish(() => reject(claimAdmissionError(event)));
327
337
  }));
328
338
  cleanups.push(this.socket.subscribe('disconnected', () => {
329
339
  finish(() => reject(new AbloConnectionError(
@@ -331,29 +341,21 @@ class WebSocketSession<TEvents extends EventMap<TEvents>>
331
341
  )));
332
342
  }));
333
343
  const abort = (): void => {
334
- this.release({
335
- claimId: input.claimId,
336
- entityType: input.entityType,
337
- entityId: input.entityId,
338
- });
339
- finish(() => reject(new AbloClaimedError(
344
+ abandonAndReject(new AbloClaimedError(
340
345
  `The wait for claim ${input.claimId} was aborted.`,
341
346
  { code: 'claim_wait_aborted' },
342
- )));
347
+ ));
343
348
  };
344
349
  input.signal?.addEventListener('abort', abort, { once: true });
345
350
  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);
351
+ const timeout = input.timeoutMs !== undefined
352
+ ? setTimeout(() => {
353
+ abandonAndReject(new AbloClaimedError(
354
+ `claim timed out after ${input.timeoutMs}ms (claimId=${input.claimId})`,
355
+ { code: 'grant_timeout' },
356
+ ));
357
+ }, input.timeoutMs)
358
+ : undefined;
357
359
  if (input.signal?.aborted) {
358
360
  abort();
359
361
  return;
@@ -368,8 +370,40 @@ class WebSocketSession<TEvents extends EventMap<TEvents>>
368
370
  });
369
371
  }
370
372
 
371
- release(input: { claimId: string; entityType?: string; entityId?: string }): void {
372
- this.socket.send({ type: 'claim_abandon', payload: input });
373
+ async release(input: {
374
+ claimId: string;
375
+ entityType?: string;
376
+ entityId?: string;
377
+ }): Promise<void> {
378
+ await this.ready();
379
+ const requestId = crypto.randomUUID();
380
+ await new Promise<void>((resolve, reject) => {
381
+ const finish = (outcome: () => void): void => {
382
+ clearTimeout(timeout);
383
+ offAck();
384
+ offDisconnect();
385
+ outcome();
386
+ };
387
+ const offAck = this.socket.subscribe('claim_abandon_ack', (event) => {
388
+ if (event.requestId !== requestId || event.claimId !== input.claimId) return;
389
+ finish(resolve);
390
+ });
391
+ const offDisconnect = this.socket.subscribe('disconnected', () => {
392
+ finish(() => reject(new AbloConnectionError(
393
+ `WebSocket closed before claim ${input.claimId} release was confirmed.`,
394
+ )));
395
+ });
396
+ const timeout = setTimeout(() => {
397
+ finish(() => reject(new AbloConnectionError(
398
+ `Timed out waiting for claim ${input.claimId} release confirmation.`,
399
+ { code: 'claim_lease_unavailable' },
400
+ )));
401
+ }, 15_000);
402
+ this.socket.send({
403
+ type: 'claim_abandon',
404
+ payload: { ...input, requestId },
405
+ });
406
+ });
373
407
  }
374
408
 
375
409
  subscribe<K extends keyof SyncWebSocketEventMap<TEvents>>(
@@ -456,13 +490,13 @@ class WebSocketSession<TEvents extends EventMap<TEvents>>
456
490
  }
457
491
 
458
492
  async acknowledge(lastSyncId: number): Promise<void> {
459
- const attempt = this.acknowledgeLane.catch(() => undefined).then(async () => {
493
+ const attempt = this.acknowledgeLane.catch(ignoreObservedAcknowledgeFailure).then(async () => {
460
494
  const position = this.socket.positionAfter(lastSyncId);
461
495
  await this.options.cursorStore?.save(this.cursorKey, JSON.stringify(position));
462
496
  this.socket.markDurable(position);
463
497
  this.socket.acknowledge(position.lastSyncId);
464
498
  });
465
- this.acknowledgeLane = attempt.catch(() => undefined);
499
+ this.acknowledgeLane = attempt.catch(ignoreObservedAcknowledgeFailure);
466
500
  await attempt;
467
501
  }
468
502
 
@@ -87,7 +87,7 @@ export interface AbloWebSocketSession<
87
87
  readonly claimId: string;
88
88
  readonly entityType?: string;
89
89
  readonly entityId?: string;
90
- }): void;
90
+ }): Promise<void>;
91
91
  subscribe<K extends keyof SyncWebSocketEventMap<TEvents>>(
92
92
  event: K,
93
93
  listener: (...args: SyncWebSocketEventMap<TEvents>[K]) => void,
@@ -31,6 +31,7 @@ import type { BootstrapReason } from '../../wire/bootstrapReason.js';
31
31
  import type { ClientSyncDelta } from '../../observation/contract.js';
32
32
  import type {
33
33
  ClaimAcquired,
34
+ ClaimAbandonAckPayload,
34
35
  PresenceUpdate,
35
36
  ClaimGranted,
36
37
  ClaimHeartbeatAckPayload,
@@ -228,6 +229,7 @@ export interface CoreSyncEventMap {
228
229
  */
229
230
  claim_queue: [ClaimQueue];
230
231
  claim_acquired: [ClaimAcquired];
232
+ claim_abandon_ack: [ClaimAbandonAckPayload];
231
233
  claim_queued: [ClaimQueued];
232
234
  claim_granted: [ClaimGranted];
233
235
  claim_lost: [ClaimLost];
@@ -28,6 +28,7 @@ import { commitReceiptSchema } from '../commit/contract.js';
28
28
  import { clientSyncDeltaSchema } from '../observation/contract.js';
29
29
  import {
30
30
  claimAcquiredSchema,
31
+ claimAbandonAckPayloadSchema,
31
32
  claimGrantedSchema,
32
33
  claimHeartbeatAckPayloadSchema,
33
34
  claimLostSchema,
@@ -123,6 +124,10 @@ export const WS_INBOUND_FRAMES = {
123
124
  presence_update: { validation: 'schema', payload: presenceUpdateSchema },
124
125
  claim_rejected: { validation: 'schema', payload: claimRejectionSchema },
125
126
  claim_acquired: { validation: 'schema', payload: claimAcquiredSchema },
127
+ claim_abandon_ack: {
128
+ validation: 'schema',
129
+ payload: claimAbandonAckPayloadSchema,
130
+ },
126
131
  claim_queued: { validation: 'schema', payload: claimQueuedSchema },
127
132
  claim_granted: { validation: 'schema', payload: claimGrantedSchema },
128
133
  claim_lost: { validation: 'schema', payload: claimLostSchema },