@abloatai/transaction 0.43.0 → 0.45.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/ai-sdk/modelTools.d.ts +2 -4
- package/dist/ai-sdk/modelTools.d.ts.map +1 -1
- package/dist/ai-sdk/modelTools.js.map +1 -1
- package/dist/ai-sdk/updateTool.d.ts +2 -4
- package/dist/ai-sdk/updateTool.d.ts.map +1 -1
- package/dist/ai-sdk/updateTool.js.map +1 -1
- package/dist/branches.d.ts +4 -4
- package/dist/coordination/awaitClaimGrant.d.ts +10 -0
- package/dist/coordination/awaitClaimGrant.d.ts.map +1 -1
- package/dist/coordination/awaitClaimGrant.js +58 -45
- package/dist/coordination/awaitClaimGrant.js.map +1 -1
- package/dist/errors.d.ts +7 -4
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +14 -6
- package/dist/errors.js.map +1 -1
- package/dist/resources/httpResources.d.ts +4 -4
- package/dist/resources/httpResources.d.ts.map +1 -1
- package/dist/resources/modelOperations.d.ts +113 -21
- package/dist/resources/modelOperations.d.ts.map +1 -1
- package/dist/resources/modelOperations.js +43 -1
- package/dist/resources/modelOperations.js.map +1 -1
- package/dist/transport/httpTransport.d.ts.map +1 -1
- package/dist/transport/httpTransport.js +49 -15
- package/dist/transport/httpTransport.js.map +1 -1
- package/dist/transport/wsFrameHandlers.d.ts.map +1 -1
- package/dist/transport/wsFrameHandlers.js +12 -0
- package/dist/transport/wsFrameHandlers.js.map +1 -1
- package/dist/wire/commit.d.ts +6 -0
- package/dist/wire/commit.d.ts.map +1 -1
- package/dist/wire/commit.js +2 -0
- package/dist/wire/commit.js.map +1 -1
- package/package.json +1 -1
- package/src/ai-sdk/modelTools.ts +2 -1
- package/src/ai-sdk/updateTool.ts +2 -1
- package/src/coordination/awaitClaimGrant.ts +86 -74
- package/src/errors.ts +17 -5
- package/src/resources/httpResources.ts +5 -1
- package/src/resources/modelOperations.ts +169 -19
- package/src/transport/httpTransport.ts +73 -19
- package/src/transport/wsFrameHandlers.ts +14 -0
- package/src/wire/commit.ts +2 -0
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
import {
|
|
17
17
|
AbloClaimedError,
|
|
18
|
+
AbloError,
|
|
18
19
|
AbloValidationError,
|
|
19
20
|
CapabilityError,
|
|
20
21
|
formatClaimedErrorMessage,
|
|
@@ -88,6 +89,10 @@ export function awaitClaimGrant(
|
|
|
88
89
|
signal?: AbortSignal;
|
|
89
90
|
/** Where grant transitions are logged. Defaults to silent. */
|
|
90
91
|
logger?: Logger;
|
|
92
|
+
/** Request-scoped lifecycle hooks; observer failures never alter admission. */
|
|
93
|
+
onQueued?: (info: { claimId: string; position: number }) => void;
|
|
94
|
+
onGranted?: (info: ClaimGrantInfo & { claimId: string }) => void;
|
|
95
|
+
onFailed?: (error: AbloError) => void;
|
|
91
96
|
},
|
|
92
97
|
): Promise<ClaimGrantInfo> {
|
|
93
98
|
const logger = options?.logger ?? noopLogger;
|
|
@@ -99,6 +104,19 @@ export function awaitClaimGrant(
|
|
|
99
104
|
for (const u of unsubs) u();
|
|
100
105
|
fn();
|
|
101
106
|
};
|
|
107
|
+
const observe = (fn: (() => void) | undefined): void => {
|
|
108
|
+
try {
|
|
109
|
+
fn?.();
|
|
110
|
+
} catch {
|
|
111
|
+
// Admission is authoritative; telemetry/UI callbacks are not.
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
const fail = (error: AbloError): void => {
|
|
115
|
+
settle(() => {
|
|
116
|
+
observe(() => options?.onFailed?.(error));
|
|
117
|
+
reject(error);
|
|
118
|
+
});
|
|
119
|
+
};
|
|
102
120
|
|
|
103
121
|
// The target was free → `claim_acquired` (immediate); it was contended,
|
|
104
122
|
// we waited in line, and reached the head → `claim_granted`. Either frame
|
|
@@ -110,11 +128,13 @@ export function awaitClaimGrant(
|
|
|
110
128
|
const fenceToken = readFenceToken(p);
|
|
111
129
|
const readAt = readWatermark(p);
|
|
112
130
|
settle(() => {
|
|
113
|
-
|
|
131
|
+
const info: ClaimGrantInfo = {
|
|
114
132
|
waited: false,
|
|
115
133
|
...(fenceToken !== undefined ? { fenceToken } : {}),
|
|
116
134
|
...(readAt !== undefined ? { readAt } : {}),
|
|
117
|
-
}
|
|
135
|
+
};
|
|
136
|
+
observe(() => options?.onGranted?.({ claimId, ...info }));
|
|
137
|
+
resolve(info);
|
|
118
138
|
});
|
|
119
139
|
}
|
|
120
140
|
}),
|
|
@@ -128,34 +148,33 @@ export function awaitClaimGrant(
|
|
|
128
148
|
const fenceToken = readFenceToken(p);
|
|
129
149
|
const readAt = readWatermark(p);
|
|
130
150
|
settle(() => {
|
|
131
|
-
|
|
151
|
+
const info: ClaimGrantInfo = {
|
|
132
152
|
waited: true,
|
|
133
153
|
...(fenceToken !== undefined ? { fenceToken } : {}),
|
|
134
154
|
...(readAt !== undefined ? { readAt } : {}),
|
|
135
|
-
}
|
|
155
|
+
};
|
|
156
|
+
observe(() => options?.onGranted?.({ claimId, ...info }));
|
|
157
|
+
resolve(info);
|
|
136
158
|
});
|
|
137
159
|
}
|
|
138
160
|
}),
|
|
139
161
|
);
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
}),
|
|
157
|
-
);
|
|
158
|
-
}
|
|
162
|
+
unsubs.push(
|
|
163
|
+
transport.subscribe('claim_queued', (p) => {
|
|
164
|
+
if (p?.claimId !== claimId) return;
|
|
165
|
+
const position = typeof p.position === 'number' ? p.position : 0;
|
|
166
|
+
observe(() => options?.onQueued?.({ claimId, position }));
|
|
167
|
+
const max = options?.maxQueueDepth;
|
|
168
|
+
if (max !== undefined && position >= max) {
|
|
169
|
+
fail(
|
|
170
|
+
new AbloClaimedError(
|
|
171
|
+
`Claim queue for ${claimId} is ${position} deep (max ${max}).`,
|
|
172
|
+
{ code: 'queue_too_deep' },
|
|
173
|
+
),
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
}),
|
|
177
|
+
);
|
|
159
178
|
unsubs.push(
|
|
160
179
|
transport.subscribe('claim_rejected', (p) => {
|
|
161
180
|
const rejection = p as ClaimRejection;
|
|
@@ -168,57 +187,53 @@ export function awaitClaimGrant(
|
|
|
168
187
|
: claimId;
|
|
169
188
|
if (rejection.reason === 'capability_denied') {
|
|
170
189
|
settle(() => {
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
`This credential may not claim ${target}.`,
|
|
176
|
-
),
|
|
190
|
+
const error = new CapabilityError(
|
|
191
|
+
'capability_scope_denied',
|
|
192
|
+
rejection.policyReason ??
|
|
193
|
+
`This credential may not claim ${target}.`,
|
|
177
194
|
);
|
|
195
|
+
observe(() => options?.onFailed?.(error));
|
|
196
|
+
reject(error);
|
|
178
197
|
});
|
|
179
198
|
return;
|
|
180
199
|
}
|
|
181
200
|
if (rejection.reason === 'invalid_target') {
|
|
182
201
|
settle(() => {
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
{ code: 'invalid_body' },
|
|
187
|
-
),
|
|
202
|
+
const error = new AbloValidationError(
|
|
203
|
+
rejection.policyReason ?? `Invalid claim target ${target}.`,
|
|
204
|
+
{ code: 'invalid_body' },
|
|
188
205
|
);
|
|
206
|
+
observe(() => options?.onFailed?.(error));
|
|
207
|
+
reject(error);
|
|
189
208
|
});
|
|
190
209
|
return;
|
|
191
210
|
}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
),
|
|
209
|
-
); },
|
|
211
|
+
fail(
|
|
212
|
+
new AbloClaimedError(
|
|
213
|
+
formatClaimedErrorMessage({
|
|
214
|
+
targetLabel: target,
|
|
215
|
+
heldBy: rejection.heldBy,
|
|
216
|
+
claim: rejection.heldByClaim,
|
|
217
|
+
policyReason: rejection.policyReason,
|
|
218
|
+
fallback: `Claim rejected for ${target}.`,
|
|
219
|
+
}),
|
|
220
|
+
{
|
|
221
|
+
code: rejection.reason === 'conflict'
|
|
222
|
+
? 'claim_conflict'
|
|
223
|
+
: 'claim_lease_unavailable',
|
|
224
|
+
claims: rejection.heldByClaim ? [rejection.heldByClaim] : undefined,
|
|
225
|
+
},
|
|
226
|
+
),
|
|
210
227
|
);
|
|
211
228
|
}),
|
|
212
229
|
);
|
|
213
230
|
unsubs.push(
|
|
214
231
|
transport.subscribe('claim_lost', (p) => {
|
|
215
232
|
if (p?.claimId === claimId) {
|
|
216
|
-
|
|
217
|
-
{
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
}),
|
|
221
|
-
); },
|
|
233
|
+
fail(
|
|
234
|
+
new AbloClaimedError(`Claim lost while queued for ${claimId}.`, {
|
|
235
|
+
code: 'claim_lost',
|
|
236
|
+
}),
|
|
222
237
|
);
|
|
223
238
|
}
|
|
224
239
|
}),
|
|
@@ -226,15 +241,14 @@ export function awaitClaimGrant(
|
|
|
226
241
|
|
|
227
242
|
if (options?.signal) {
|
|
228
243
|
const signal = options.signal;
|
|
229
|
-
const abort = (): void =>
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
});
|
|
244
|
+
const abort = (): void => {
|
|
245
|
+
fail(
|
|
246
|
+
new AbloClaimedError(
|
|
247
|
+
`The wait for claim ${claimId} was aborted before the grant arrived.`,
|
|
248
|
+
{ code: 'claim_wait_aborted' },
|
|
249
|
+
),
|
|
250
|
+
);
|
|
251
|
+
};
|
|
238
252
|
if (signal.aborted) {
|
|
239
253
|
abort();
|
|
240
254
|
return;
|
|
@@ -245,13 +259,11 @@ export function awaitClaimGrant(
|
|
|
245
259
|
|
|
246
260
|
if (options?.timeoutMs && options.timeoutMs > 0) {
|
|
247
261
|
timer = setTimeout(() => {
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
),
|
|
254
|
-
); },
|
|
262
|
+
fail(
|
|
263
|
+
new AbloClaimedError(
|
|
264
|
+
`Timed out waiting for the queue grant on claim ${claimId}.`,
|
|
265
|
+
{ code: 'grant_timeout' },
|
|
266
|
+
),
|
|
255
267
|
);
|
|
256
268
|
}, options.timeoutMs);
|
|
257
269
|
}
|
package/src/errors.ts
CHANGED
|
@@ -60,9 +60,9 @@ export class AbloError extends Error {
|
|
|
60
60
|
readonly code?: string;
|
|
61
61
|
/** HTTP status code, when the error originated from an HTTP response. */
|
|
62
62
|
readonly httpStatus?: number;
|
|
63
|
-
/** A correlation id for tracing
|
|
64
|
-
*
|
|
65
|
-
* requests. */
|
|
63
|
+
/** A correlation id for tracing work through the server, returned in an HTTP
|
|
64
|
+
* `x-request-id` header or a live commit's rejection frame. Include it in
|
|
65
|
+
* support requests. */
|
|
66
66
|
readonly requestId?: string;
|
|
67
67
|
/** The specific input that caused the error, as a model or field path such as
|
|
68
68
|
* `'dataroomMember.grants.subject'`, so tooling can point at the exact
|
|
@@ -502,8 +502,17 @@ export class CapabilityError extends AbloPermissionError {
|
|
|
502
502
|
code: 'capability_scope_denied' | 'capability_invalid',
|
|
503
503
|
message: string,
|
|
504
504
|
requiredCapability?: RequiredCapability,
|
|
505
|
+
options?: {
|
|
506
|
+
requestId?: string;
|
|
507
|
+
details?: Readonly<Record<string, unknown>>;
|
|
508
|
+
},
|
|
505
509
|
) {
|
|
506
|
-
super(`${code}: ${message}`, {
|
|
510
|
+
super(`${code}: ${message}`, {
|
|
511
|
+
code,
|
|
512
|
+
httpStatus: 403,
|
|
513
|
+
...(options?.requestId !== undefined ? { requestId: options.requestId } : {}),
|
|
514
|
+
...(options?.details !== undefined ? { details: options.details } : {}),
|
|
515
|
+
});
|
|
507
516
|
this.name = 'CapabilityError';
|
|
508
517
|
if (requiredCapability !== undefined) {
|
|
509
518
|
this.requiredCapability = requiredCapability;
|
|
@@ -751,7 +760,10 @@ export function errorFromWire(
|
|
|
751
760
|
// A scoped credential was denied — route through CapabilityError so callers
|
|
752
761
|
// can read `.requiredCapability` to attenuate-and-retry.
|
|
753
762
|
if (code === 'capability_scope_denied' || code === 'capability_invalid') {
|
|
754
|
-
return new CapabilityError(code, message, requiredCapability
|
|
763
|
+
return new CapabilityError(code, message, requiredCapability, {
|
|
764
|
+
...(requestId !== undefined ? { requestId } : {}),
|
|
765
|
+
...(details !== undefined ? { details } : {}),
|
|
766
|
+
});
|
|
755
767
|
}
|
|
756
768
|
// Claim enforcement (rides 409): the target entity is held by another
|
|
757
769
|
// participant, or a lease this participant held is gone (`claim_lost` —
|
|
@@ -51,7 +51,9 @@ import type {
|
|
|
51
51
|
import type { ModelUpdater, ContentionOptions } from './functionalUpdate.js';
|
|
52
52
|
import type {
|
|
53
53
|
ClaimOptions,
|
|
54
|
+
ClaimAttemptEvent,
|
|
54
55
|
ClaimParams,
|
|
56
|
+
ClaimSkipParams,
|
|
55
57
|
ClaimReadApi,
|
|
56
58
|
AwaitedClaimMethod,
|
|
57
59
|
ModelTrackParams,
|
|
@@ -147,6 +149,8 @@ export interface ClaimCreateOptions {
|
|
|
147
149
|
* waiting if the queue is already `>= maxQueueDepth` when we join.
|
|
148
150
|
*/
|
|
149
151
|
readonly maxQueueDepth?: number;
|
|
152
|
+
/** Request-scoped queued / granted / skipped / failed status events. */
|
|
153
|
+
readonly onStatus?: (event: ClaimAttemptEvent) => void;
|
|
150
154
|
}
|
|
151
155
|
|
|
152
156
|
export interface CommitOperationInput {
|
|
@@ -301,7 +305,7 @@ export type HttpClaimApi<
|
|
|
301
305
|
// The try-claim first: `queue: false` resolves `null` on a held target —
|
|
302
306
|
// an expected outcome, not an error — while the queued default always
|
|
303
307
|
// resolves a held claim or rejects with a queue error.
|
|
304
|
-
((params:
|
|
308
|
+
((params: ClaimSkipParams<Fields>) => Promise<HeldClaim<T> | null>) &
|
|
305
309
|
((params: ClaimParams<Fields>) => Promise<HeldClaim<T>>) & {
|
|
306
310
|
[K in keyof ClaimReadApi<T>]: AwaitedClaimMethod<ClaimReadApi<T>[K]>;
|
|
307
311
|
};
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
import type { ModelScope } from '../types/index.js';
|
|
14
14
|
import type { ResolveClaimMeta } from '../types/global.js';
|
|
15
|
-
import type {
|
|
15
|
+
import type { AbloError } from '../errors.js';
|
|
16
16
|
import type { StaleNotification, TrackDependency } from '../coordination/schema.js';
|
|
17
17
|
import type { FieldRef, FieldSelector } from '../schema/fieldRef.js';
|
|
18
18
|
import type { BaseModelFields } from '../schema/schema.js';
|
|
@@ -29,6 +29,112 @@ import type {
|
|
|
29
29
|
import type { MutationOptions } from './mutationOptions.js';
|
|
30
30
|
import type { LoadWhere } from './where.js';
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* One authoritative status transition for a claim attempt. This is scoped
|
|
34
|
+
* to the request that supplied the callback, unlike `claim.state` / `queue`,
|
|
35
|
+
* which are reactive snapshots of everyone on the row.
|
|
36
|
+
*/
|
|
37
|
+
export type ClaimAttemptEvent =
|
|
38
|
+
| {
|
|
39
|
+
readonly type: 'queued';
|
|
40
|
+
readonly claimId: string;
|
|
41
|
+
/** Zero-based place in line (`0` means next behind the holder). */
|
|
42
|
+
readonly position: number;
|
|
43
|
+
/** Human-readable count of waiters ahead, including the current holder. */
|
|
44
|
+
readonly ahead: number;
|
|
45
|
+
}
|
|
46
|
+
| {
|
|
47
|
+
readonly type: 'granted';
|
|
48
|
+
readonly claimId: string;
|
|
49
|
+
/** True when this request waited in line before it was granted. */
|
|
50
|
+
readonly waited: boolean;
|
|
51
|
+
}
|
|
52
|
+
| {
|
|
53
|
+
readonly type: 'skipped';
|
|
54
|
+
/** The typed contention error behind the expected `null` result. */
|
|
55
|
+
readonly error: AbloError;
|
|
56
|
+
}
|
|
57
|
+
| {
|
|
58
|
+
readonly type: 'failed';
|
|
59
|
+
/** The same typed Ablo error the claim promise rejects with. */
|
|
60
|
+
readonly error: AbloError;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export interface ClaimContentionOptions {
|
|
64
|
+
/**
|
|
65
|
+
* `wait` (default) joins the FIFO line. `skip` resolves the model try-claim
|
|
66
|
+
* as `null` when another participant holds the target.
|
|
67
|
+
*/
|
|
68
|
+
readonly mode?: 'wait' | 'skip';
|
|
69
|
+
/** Fail instead of joining at or beyond this zero-based queue depth. */
|
|
70
|
+
readonly maxDepth?: number;
|
|
71
|
+
/** Maximum queue wait in milliseconds. */
|
|
72
|
+
readonly timeoutMs?: number;
|
|
73
|
+
/** Abort the pending wait. Ignored after the grant. */
|
|
74
|
+
readonly signal?: AbortSignal;
|
|
75
|
+
/**
|
|
76
|
+
* Request-scoped attempt statuses. The callback is observational:
|
|
77
|
+
* throwing from it never changes whether the claim is granted or skipped.
|
|
78
|
+
*/
|
|
79
|
+
readonly onStatus?: (event: ClaimAttemptEvent) => void;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface ResolvedClaimContentionOptions {
|
|
83
|
+
readonly wait: boolean;
|
|
84
|
+
readonly maxDepth?: number;
|
|
85
|
+
readonly timeoutMs?: number;
|
|
86
|
+
readonly signal?: AbortSignal;
|
|
87
|
+
readonly onStatus?: (event: ClaimAttemptEvent) => void;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** One compatibility boundary for structured contention and legacy queue options. */
|
|
91
|
+
export function resolveClaimContentionOptions(options: {
|
|
92
|
+
readonly queue?: boolean;
|
|
93
|
+
readonly contention?: ClaimContentionOptions;
|
|
94
|
+
readonly maxQueueDepth?: number;
|
|
95
|
+
readonly waitTimeoutMs?: number;
|
|
96
|
+
readonly signal?: AbortSignal;
|
|
97
|
+
}): ResolvedClaimContentionOptions {
|
|
98
|
+
const structured = options.contention;
|
|
99
|
+
const maxDepth = structured?.maxDepth ?? options.maxQueueDepth;
|
|
100
|
+
const timeoutMs = structured?.timeoutMs ?? options.waitTimeoutMs;
|
|
101
|
+
const signal = structured?.signal ?? options.signal;
|
|
102
|
+
return {
|
|
103
|
+
wait: structured
|
|
104
|
+
? structured.mode !== 'skip'
|
|
105
|
+
: options.queue !== false,
|
|
106
|
+
...(maxDepth !== undefined ? { maxDepth } : {}),
|
|
107
|
+
...(timeoutMs !== undefined ? { timeoutMs } : {}),
|
|
108
|
+
...(signal !== undefined ? { signal } : {}),
|
|
109
|
+
...(structured?.onStatus !== undefined
|
|
110
|
+
? { onStatus: structured.onStatus }
|
|
111
|
+
: {}),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Emit a status without letting an observer alter the claim attempt. */
|
|
116
|
+
export function emitClaimStatus(
|
|
117
|
+
listener: ((event: ClaimAttemptEvent) => void) | undefined,
|
|
118
|
+
event: ClaimAttemptEvent,
|
|
119
|
+
): void {
|
|
120
|
+
try {
|
|
121
|
+
listener?.(event);
|
|
122
|
+
} catch {
|
|
123
|
+
// The claim attempt is authoritative; telemetry/UI callbacks are not.
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Separate expected skipped work from an actual failed claim attempt. */
|
|
128
|
+
export function claimAttemptFailure(
|
|
129
|
+
wait: boolean,
|
|
130
|
+
error: AbloError,
|
|
131
|
+
): ClaimAttemptEvent {
|
|
132
|
+
const code = error.code;
|
|
133
|
+
return !wait && (code === 'claim_conflict' || code === 'entity_claimed')
|
|
134
|
+
? { type: 'skipped', error }
|
|
135
|
+
: { type: 'failed', error };
|
|
136
|
+
}
|
|
137
|
+
|
|
32
138
|
/**
|
|
33
139
|
* A lifecycle filter, accepted either as the enum or as its bare string. The
|
|
34
140
|
* string arm is a template projection of the enum rather than a second list, so
|
|
@@ -142,8 +248,7 @@ export type ClaimField<T> = FieldRef<
|
|
|
142
248
|
* - **what you claim** — `fields`, selected from the model's Zod shape;
|
|
143
249
|
* narrowed below the row;
|
|
144
250
|
* - **what others see** — `description` / `meta`, the presence half;
|
|
145
|
-
* - **how you wait** — `queue
|
|
146
|
-
* `signal`, admission to the line;
|
|
251
|
+
* - **how you wait** — structured `queue`, admission to the line;
|
|
147
252
|
* - **how long you hold** — `ttl` / `heartbeat`, the lease.
|
|
148
253
|
*/
|
|
149
254
|
export interface ClaimTargetOptions<T = Record<string, unknown>> {
|
|
@@ -175,18 +280,20 @@ export interface ClaimTargetOptions<T = Record<string, unknown>> {
|
|
|
175
280
|
*/
|
|
176
281
|
meta?: ResolveClaimMeta;
|
|
177
282
|
|
|
178
|
-
// ── How you
|
|
283
|
+
// ── How you handle contention ──────────────────────────────────────────
|
|
179
284
|
|
|
180
285
|
/**
|
|
181
|
-
* Behavior under contention.
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
* {
|
|
185
|
-
* distributed work ("if someone else has this job, skip it"), where waiting
|
|
186
|
-
* would mean double-processing.
|
|
286
|
+
* Behavior under contention. Prefer the structured form so the decision,
|
|
287
|
+
* bounds, cancellation, and request-scoped notifications stay together:
|
|
288
|
+
* `contention: { mode: 'wait', maxDepth, timeoutMs, signal, onStatus }`.
|
|
289
|
+
* `{ mode: 'skip' }` is claim-or-skip dedup.
|
|
187
290
|
*/
|
|
291
|
+
contention?: ClaimContentionOptions;
|
|
292
|
+
/** Concise compatibility shorthand: `true` waits and `false` skips. */
|
|
188
293
|
queue?: boolean;
|
|
189
294
|
/**
|
|
295
|
+
* @deprecated Prefer `contention: { maxDepth }`.
|
|
296
|
+
*
|
|
190
297
|
* Backpressure: queue, but not behind too many others. If the server reports a
|
|
191
298
|
* position at or beyond `maxQueueDepth` when the client joins the line, it
|
|
192
299
|
* rejects with {@link AbloClaimedError} (`queue_too_deep`) instead of waiting.
|
|
@@ -194,6 +301,8 @@ export interface ClaimTargetOptions<T = Record<string, unknown>> {
|
|
|
194
301
|
*/
|
|
195
302
|
maxQueueDepth?: number;
|
|
196
303
|
/**
|
|
304
|
+
* @deprecated Prefer `contention: { timeoutMs }`.
|
|
305
|
+
*
|
|
197
306
|
* Cap on how long a queued claim waits for its grant before rejecting with
|
|
198
307
|
* {@link AbloClaimedError} (`grant_timeout`). Omit to wait as long as the
|
|
199
308
|
* line takes. Same meaning on both transports; on the stateless HTTP client
|
|
@@ -201,6 +310,8 @@ export interface ClaimTargetOptions<T = Record<string, unknown>> {
|
|
|
201
310
|
*/
|
|
202
311
|
waitTimeoutMs?: number;
|
|
203
312
|
/**
|
|
313
|
+
* @deprecated Prefer `contention: { signal }`.
|
|
314
|
+
*
|
|
204
315
|
* Abort a pending wait from outside — the same signal that cancels
|
|
205
316
|
* everything else in the program, so a cancelled agent task or an unmounted
|
|
206
317
|
* component takes its queued claim with it. Rejects with
|
|
@@ -234,6 +345,14 @@ export interface ClaimParams<T = Record<string, unknown>>
|
|
|
234
345
|
readonly id: string;
|
|
235
346
|
}
|
|
236
347
|
|
|
348
|
+
/** The two fail-fast spellings, kept as one overload discriminator. */
|
|
349
|
+
export type ClaimSkipParams<T = Record<string, unknown>> =
|
|
350
|
+
ClaimParams<T> & {
|
|
351
|
+
readonly queue: false;
|
|
352
|
+
} | ClaimParams<T> & {
|
|
353
|
+
readonly contention: ClaimContentionOptions & { readonly mode: 'skip' };
|
|
354
|
+
};
|
|
355
|
+
|
|
237
356
|
export interface ClaimLookupParams<T = Record<string, unknown>> {
|
|
238
357
|
readonly id: string;
|
|
239
358
|
}
|
|
@@ -243,6 +362,32 @@ export interface ClaimReorderParams<T = Record<string, unknown>>
|
|
|
243
362
|
readonly order: readonly Claim[];
|
|
244
363
|
}
|
|
245
364
|
|
|
365
|
+
/**
|
|
366
|
+
* One wait-line snapshot. `data` preserves the standard list-envelope shape;
|
|
367
|
+
* the named aliases make coordination code read without unpacking conventions.
|
|
368
|
+
*/
|
|
369
|
+
export interface ClaimQueueView<M = ResolveClaimMeta> {
|
|
370
|
+
readonly object: 'list';
|
|
371
|
+
readonly data: readonly Claim<Record<string, unknown>, M>[];
|
|
372
|
+
/** The same ordered array as `data`, named for what it contains. */
|
|
373
|
+
readonly waiting: readonly Claim<Record<string, unknown>, M>[];
|
|
374
|
+
readonly size: number;
|
|
375
|
+
/** The next participant to receive the lease, or `null` when the line is empty. */
|
|
376
|
+
readonly next: Claim<Record<string, unknown>, M> | null;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
export function claimQueueView<M = ResolveClaimMeta>(
|
|
380
|
+
waiting: readonly Claim<Record<string, unknown>, M>[],
|
|
381
|
+
): ClaimQueueView<M> {
|
|
382
|
+
return {
|
|
383
|
+
object: 'list',
|
|
384
|
+
data: waiting,
|
|
385
|
+
waiting,
|
|
386
|
+
size: waiting.length,
|
|
387
|
+
next: waiting[0] ?? null,
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
246
391
|
/**
|
|
247
392
|
* A claim handle: the held entity data plus an explicit release hook.
|
|
248
393
|
*
|
|
@@ -272,6 +417,13 @@ export type { Claim, ClaimHeartbeat, ClaimHeartbeatOptions, HeldClaim, HeldLease
|
|
|
272
417
|
|
|
273
418
|
export type ClaimOptions<T = Record<string, unknown>> = ClaimTargetOptions<T>;
|
|
274
419
|
|
|
420
|
+
export type ClaimSkipOptions<T = Record<string, unknown>> =
|
|
421
|
+
ClaimOptions<T> & {
|
|
422
|
+
readonly queue: false;
|
|
423
|
+
} | ClaimOptions<T> & {
|
|
424
|
+
readonly contention: ClaimContentionOptions & { readonly mode: 'skip' };
|
|
425
|
+
};
|
|
426
|
+
|
|
275
427
|
/**
|
|
276
428
|
* The coordination surface for a model, exposed as a callable namespace.
|
|
277
429
|
*
|
|
@@ -346,10 +498,7 @@ export interface ClaimReadApi<T = Record<string, unknown>> {
|
|
|
346
498
|
*/
|
|
347
499
|
queue<M = ResolveClaimMeta>(
|
|
348
500
|
params: ClaimLookupParams<T>,
|
|
349
|
-
):
|
|
350
|
-
readonly object: 'list';
|
|
351
|
-
readonly data: readonly Claim<Record<string, unknown>, M>[];
|
|
352
|
-
};
|
|
501
|
+
): ClaimQueueView<M>;
|
|
353
502
|
|
|
354
503
|
/**
|
|
355
504
|
* Re-rank the wait line. Advanced and permission-gated.
|
|
@@ -376,14 +525,15 @@ export interface ClaimApi<
|
|
|
376
525
|
Fields = T,
|
|
377
526
|
> extends ClaimReadApi<T> {
|
|
378
527
|
/**
|
|
379
|
-
* The try-claim: `
|
|
380
|
-
* not an error — it resolves
|
|
528
|
+
* The try-claim: `contention: { mode: 'skip' }` (or `queue: false`)
|
|
529
|
+
* treats a held target as an expected outcome, not an error — it resolves
|
|
530
|
+
* `null`, so claim-or-skip dedup reads
|
|
381
531
|
* `if (!claim) return` with no try/catch. Who holds it, and why, stays
|
|
382
532
|
* readable through `claim.state({ id })`. (A write to a row someone else
|
|
383
533
|
* holds still rejects with `entity_claimed` — a failed write is an error;
|
|
384
|
-
* a
|
|
534
|
+
* a skipped try is not.)
|
|
385
535
|
*/
|
|
386
|
-
(params:
|
|
536
|
+
(params: ClaimSkipParams<Fields>): Promise<HeldClaim<T> | null>;
|
|
387
537
|
/**
|
|
388
538
|
* Takes a claim and returns an explicit held-work handle — a {@link HeldClaim}.
|
|
389
539
|
* `data`, `release`, `revoke`, and the async disposer are always present (this
|
|
@@ -392,7 +542,7 @@ export interface ClaimApi<
|
|
|
392
542
|
*/
|
|
393
543
|
(params: ClaimParams<Fields>): Promise<HeldClaim<T>>;
|
|
394
544
|
/** The row-free try-claim — `null` when the key is already held. */
|
|
395
|
-
(id: string, opts:
|
|
545
|
+
(id: string, opts: ClaimSkipOptions<Fields>): Promise<HeldLease | null>;
|
|
396
546
|
/**
|
|
397
547
|
* Takes a claim by id alone, for a row that lives only in the customer's own
|
|
398
548
|
* database — Ablo has never seen it, so there is nothing to re-read. Returns a
|