@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.
- package/dist/claims/admission.d.ts +4 -0
- package/dist/claims/admission.d.ts.map +1 -0
- package/dist/claims/admission.js +32 -0
- package/dist/claims/admission.js.map +1 -0
- package/dist/claims/awaitGrant.d.ts.map +1 -1
- package/dist/claims/awaitGrant.js +4 -38
- package/dist/claims/awaitGrant.js.map +1 -1
- package/dist/claims/conflict.d.ts +55 -0
- package/dist/claims/conflict.d.ts.map +1 -0
- package/dist/claims/conflict.js +71 -0
- package/dist/claims/conflict.js.map +1 -0
- package/dist/claims/contract.d.ts +12 -0
- package/dist/claims/contract.d.ts.map +1 -1
- package/dist/claims/contract.js +2 -0
- package/dist/claims/contract.js.map +1 -1
- package/dist/claims/index.d.ts +2 -0
- package/dist/claims/index.d.ts.map +1 -1
- package/dist/claims/index.js +1 -0
- package/dist/claims/index.js.map +1 -1
- package/dist/coordination/index.d.ts +2 -2
- package/dist/coordination/index.d.ts.map +1 -1
- package/dist/coordination/index.js +1 -1
- package/dist/coordination/index.js.map +1 -1
- package/dist/coordination/schema.d.ts +7 -0
- package/dist/coordination/schema.d.ts.map +1 -1
- package/dist/coordination/schema.js +7 -0
- package/dist/coordination/schema.js.map +1 -1
- package/dist/errorCodes.d.ts.map +1 -1
- package/dist/errorCodes.js +12 -1
- package/dist/errorCodes.js.map +1 -1
- package/dist/errors.d.ts +6 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +101 -5
- package/dist/errors.js.map +1 -1
- package/dist/transport/http/claimWait.d.ts +9 -0
- package/dist/transport/http/claimWait.d.ts.map +1 -0
- package/dist/transport/http/claimWait.js +74 -0
- package/dist/transport/http/claimWait.js.map +1 -0
- package/dist/transport/http/client.d.ts.map +1 -1
- package/dist/transport/http/client.js +3 -4
- package/dist/transport/http/client.js.map +1 -1
- package/dist/transport/http/contract.d.ts +57 -0
- package/dist/transport/http/contract.d.ts.map +1 -0
- package/dist/transport/http/contract.js +3 -0
- package/dist/transport/http/contract.js.map +1 -0
- package/dist/transport/http/index.d.ts +1 -0
- package/dist/transport/http/index.d.ts.map +1 -1
- package/dist/transport/http/index.js +1 -0
- package/dist/transport/http/index.js.map +1 -1
- package/dist/transport/http/transport.d.ts +1 -78
- package/dist/transport/http/transport.d.ts.map +1 -1
- package/dist/transport/http/transport.js +7 -83
- package/dist/transport/http/transport.js.map +1 -1
- package/dist/transport/websocket/frameHandlers.d.ts.map +1 -1
- package/dist/transport/websocket/frameHandlers.js +3 -0
- package/dist/transport/websocket/frameHandlers.js.map +1 -1
- package/dist/transport/websocket/session.d.ts.map +1 -1
- package/dist/transport/websocket/session.js +69 -36
- package/dist/transport/websocket/session.js.map +1 -1
- package/dist/transport/websocket/sessionContract.d.ts +1 -1
- package/dist/transport/websocket/sessionContract.d.ts.map +1 -1
- package/dist/transport/websocket/transport.d.ts +2 -1
- package/dist/transport/websocket/transport.d.ts.map +1 -1
- package/dist/transport/websocket/transport.js.map +1 -1
- package/dist/wire/inboundFrames.d.ts +7 -0
- package/dist/wire/inboundFrames.d.ts.map +1 -1
- package/dist/wire/inboundFrames.js +5 -1
- package/dist/wire/inboundFrames.js.map +1 -1
- package/package.json +1 -1
- package/src/claims/admission.ts +49 -0
- package/src/claims/awaitGrant.ts +3 -52
- package/src/claims/conflict.ts +103 -0
- package/src/claims/contract.ts +2 -0
- package/src/claims/index.ts +11 -0
- package/src/coordination/index.ts +2 -0
- package/src/coordination/schema.ts +11 -0
- package/src/errorCodes.ts +18 -1
- package/src/errors.ts +116 -4
- package/src/transport/http/claimWait.ts +108 -0
- package/src/transport/http/client.ts +6 -5
- package/src/transport/http/contract.ts +71 -0
- package/src/transport/http/index.ts +1 -0
- package/src/transport/http/transport.ts +11 -204
- package/src/transport/websocket/frameHandlers.ts +7 -0
- package/src/transport/websocket/session.ts +77 -43
- package/src/transport/websocket/sessionContract.ts +1 -1
- package/src/transport/websocket/transport.ts +2 -0
- 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 {
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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: {
|
|
372
|
-
|
|
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(
|
|
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(
|
|
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 },
|