@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.
Files changed (41) hide show
  1. package/dist/ai-sdk/modelTools.d.ts +2 -4
  2. package/dist/ai-sdk/modelTools.d.ts.map +1 -1
  3. package/dist/ai-sdk/modelTools.js.map +1 -1
  4. package/dist/ai-sdk/updateTool.d.ts +2 -4
  5. package/dist/ai-sdk/updateTool.d.ts.map +1 -1
  6. package/dist/ai-sdk/updateTool.js.map +1 -1
  7. package/dist/branches.d.ts +4 -4
  8. package/dist/coordination/awaitClaimGrant.d.ts +10 -0
  9. package/dist/coordination/awaitClaimGrant.d.ts.map +1 -1
  10. package/dist/coordination/awaitClaimGrant.js +58 -45
  11. package/dist/coordination/awaitClaimGrant.js.map +1 -1
  12. package/dist/errors.d.ts +7 -4
  13. package/dist/errors.d.ts.map +1 -1
  14. package/dist/errors.js +14 -6
  15. package/dist/errors.js.map +1 -1
  16. package/dist/resources/httpResources.d.ts +4 -4
  17. package/dist/resources/httpResources.d.ts.map +1 -1
  18. package/dist/resources/modelOperations.d.ts +113 -21
  19. package/dist/resources/modelOperations.d.ts.map +1 -1
  20. package/dist/resources/modelOperations.js +43 -1
  21. package/dist/resources/modelOperations.js.map +1 -1
  22. package/dist/transport/httpTransport.d.ts.map +1 -1
  23. package/dist/transport/httpTransport.js +49 -15
  24. package/dist/transport/httpTransport.js.map +1 -1
  25. package/dist/transport/wsFrameHandlers.d.ts.map +1 -1
  26. package/dist/transport/wsFrameHandlers.js +12 -0
  27. package/dist/transport/wsFrameHandlers.js.map +1 -1
  28. package/dist/wire/commit.d.ts +6 -0
  29. package/dist/wire/commit.d.ts.map +1 -1
  30. package/dist/wire/commit.js +2 -0
  31. package/dist/wire/commit.js.map +1 -1
  32. package/package.json +1 -1
  33. package/src/ai-sdk/modelTools.ts +2 -1
  34. package/src/ai-sdk/updateTool.ts +2 -1
  35. package/src/coordination/awaitClaimGrant.ts +86 -74
  36. package/src/errors.ts +17 -5
  37. package/src/resources/httpResources.ts +5 -1
  38. package/src/resources/modelOperations.ts +169 -19
  39. package/src/transport/httpTransport.ts +73 -19
  40. package/src/transport/wsFrameHandlers.ts +14 -0
  41. 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
- resolve({
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
- resolve({
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
- if (options?.maxQueueDepth !== undefined) {
141
- const max = options.maxQueueDepth;
142
- unsubs.push(
143
- transport.subscribe('claim_queued', (p) => {
144
- if (p?.claimId !== claimId) return;
145
- const position = typeof p.position === 'number' ? p.position : 0;
146
- if (position >= max) {
147
- settle(() =>
148
- { reject(
149
- new AbloClaimedError(
150
- `Claim queue for ${claimId} is ${position} deep (max ${max}).`,
151
- { code: 'queue_too_deep' },
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
- reject(
172
- new CapabilityError(
173
- 'capability_scope_denied',
174
- rejection.policyReason ??
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
- reject(
184
- new AbloValidationError(
185
- rejection.policyReason ?? `Invalid claim target ${target}.`,
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
- settle(() =>
193
- { reject(
194
- new AbloClaimedError(
195
- formatClaimedErrorMessage({
196
- targetLabel: target,
197
- heldBy: rejection.heldBy,
198
- claim: rejection.heldByClaim,
199
- policyReason: rejection.policyReason,
200
- fallback: `Claim rejected for ${target}.`,
201
- }),
202
- {
203
- code: rejection.reason === 'conflict'
204
- ? 'claim_conflict'
205
- : 'claim_lease_unavailable',
206
- claims: rejection.heldByClaim ? [rejection.heldByClaim] : undefined,
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
- settle(() =>
217
- { reject(
218
- new AbloClaimedError(`Claim lost while queued for ${claimId}.`, {
219
- code: 'claim_lost',
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
- settle(() => {
231
- reject(
232
- new AbloClaimedError(
233
- `The wait for claim ${claimId} was aborted before the grant arrived.`,
234
- { code: 'claim_wait_aborted' },
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
- settle(() =>
249
- { reject(
250
- new AbloClaimedError(
251
- `Timed out waiting for the queue grant on claim ${claimId}.`,
252
- { code: 'grant_timeout' },
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 a request through the server, present when the
64
- * server returned one on the `x-request-id` header. Include it in support
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}`, { code });
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: ClaimParams<Fields> & { queue: false }) => Promise<HeldClaim<T> | null>) &
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 { AbloClaimedError } from '../errors.js';
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` / `maxQueueDepth` / `waitTimeoutMs` /
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 wait admission to the line ───────────────────────────────
283
+ // ── How you handle contention ──────────────────────────────────────────
179
284
 
180
285
  /**
181
- * Behavior under contention. `true` (the default) queues behind the current
182
- * holder and resolves once the row is yours. `false` is fail-fast: if another
183
- * participant already holds the row, it rejects immediately with
184
- * {@link AbloClaimedError} instead of waiting. Use `false` to deduplicate
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: `queue: false` treats a held target as an expected outcome,
380
- * not an error — it resolves `null`, so claim-or-skip dedup reads
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 declined try is not.)
534
+ * a skipped try is not.)
385
535
  */
386
- (params: ClaimParams<Fields> & { queue: false }): Promise<HeldClaim<T> | null>;
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: ClaimOptions<Fields> & { queue: false }): Promise<HeldLease | null>;
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