@abloatai/humans 0.44.0 → 0.46.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/Ablo.d.ts CHANGED
@@ -171,6 +171,9 @@ export declare namespace Ablo {
171
171
  type Held<T = Record<string, unknown>> = import('@abloatai/transaction/types/streams').HeldClaim<T>;
172
172
  type CreateOptions = import('./local/client/resourceTypes.js').ClaimCreateOptions;
173
173
  type WaitOptions = import('./local/client/resourceTypes.js').ClaimWaitOptions;
174
+ type ContentionOptions = import('@abloatai/transaction/resources/modelOperations').ClaimContentionOptions;
175
+ type AttemptEvent = import('@abloatai/transaction/resources/modelOperations').ClaimAttemptEvent;
176
+ type QueueView = import('@abloatai/transaction/resources/modelOperations').ClaimQueueView;
174
177
  type Client = import('./local/client/resourceTypes.js').ClaimResource;
175
178
  }
176
179
  namespace Model {
@@ -17,9 +17,9 @@ import type { SyncClient } from '../SyncClient.js';
17
17
  import type { OnDemandLoader } from '../sync/OnDemandLoader.js';
18
18
  import type { JoinedParticipant } from '../sync/participants.js';
19
19
  import type { Duration, Claim, ClaimHeartbeat, ClaimHeartbeatOptions, HeldClaim, HeldLease, ClaimWaitOptions, Snapshot } from '@abloatai/transaction/types/streams';
20
- export type { ModelListScope, ModelTrackParams, ModelTrackResult, LocalReadOptions, LocalCountOptions, ServerReadOptions, ServerGetOptions, ServerRetrieveOptions, ClaimTargetOptions, ClaimParams, ClaimLookupParams, ClaimReorderParams, ClaimOptions, ClaimReadApi, AwaitedClaimMethod, ClaimApi, ModelRetrieveParams, ModelCreateParams, ModelUpdateParams, ModelDeleteParams, JoinOptions, } from '@abloatai/transaction/resources/modelOperations';
20
+ export type { ModelListScope, ModelTrackParams, ModelTrackResult, LocalReadOptions, LocalCountOptions, ServerReadOptions, ServerGetOptions, ServerRetrieveOptions, ClaimTargetOptions, ClaimParams, ClaimContentionOptions, ClaimAttemptEvent, ClaimQueueView, ClaimSkipOptions, ClaimSkipParams, ClaimLookupParams, ClaimReorderParams, ClaimOptions, ClaimReadApi, AwaitedClaimMethod, ClaimApi, ModelRetrieveParams, ModelCreateParams, ModelUpdateParams, ModelDeleteParams, JoinOptions, } from '@abloatai/transaction/resources/modelOperations';
21
21
  export type { Claim, ClaimHeartbeat, ClaimHeartbeatOptions, HeldClaim, HeldLease };
22
- import type { ClaimApi, JoinOptions, LocalCountOptions, LocalReadOptions } from '@abloatai/transaction/resources/modelOperations';
22
+ import type { ClaimApi, ClaimAttemptEvent, JoinOptions, LocalCountOptions, LocalReadOptions } from '@abloatai/transaction/resources/modelOperations';
23
23
  import type { HttpModelClient } from '@abloatai/transaction/transport/httpClient';
24
24
  import type { ParticipantKind } from '@abloatai/transaction/types/participant';
25
25
  export interface ModelClientMeta {
@@ -56,6 +56,8 @@ export interface ModelCollaboration {
56
56
  waitTimeoutMs?: number;
57
57
  /** Abort the queued wait — rejects with `claim_wait_aborted`. */
58
58
  signal?: AbortSignal;
59
+ /** Request-scoped queued / granted / skipped / failed status events. */
60
+ onStatus?: (event: ClaimAttemptEvent) => void;
59
61
  }): Promise<Claim>;
60
62
  createSnapshot(modelKey: string, id: string): Snapshot;
61
63
  /**
@@ -26,6 +26,7 @@ import { subTarget } from '@abloatai/transaction/coordination';
26
26
  // here like the other enumerated crossings.
27
27
  import { declaredMeta } from '@abloatai/transaction/coordination/claimMeta';
28
28
  import { ModelScope } from '@abloatai/transaction/types';
29
+ import { claimQueueView, resolveClaimContentionOptions, } from '@abloatai/transaction/resources/modelOperations';
29
30
  const modelClientMeta = new WeakMap();
30
31
  export function getModelClientMeta(modelClient) {
31
32
  if (typeof modelClient !== 'object' || modelClient === null)
@@ -172,22 +173,13 @@ defaultWait) {
172
173
  throw new AbloValidationError(`Model "${schemaKey}" was built without the collaboration runtime, so claim() is unavailable here. Claiming needs no per-model config — use the standard Ablo({ schema, apiKey }) client and every model is claimable.`, { code: 'model_claim_not_configured' });
173
174
  }
174
175
  const { id, ...options } = params;
175
- // Is someone else already on this target? Read the local coordination
176
- // snapshot up front it decides whether a re-read is needed after the
177
- // claim (a free or already-held target cannot have changed underneath us).
176
+ // Read the local snapshot only to decide whether a post-grant re-read may
177
+ // be needed. Admission itself always goes to the server: a local presence
178
+ // snapshot may be stale or incomplete across instances.
178
179
  const held = collaboration.state({ model: wireModel, id });
179
180
  const contended = !!held && held.heldBy !== collaboration.selfParticipantId;
180
- const failFast = options.queue === false;
181
- // The try-claim (`queue: false`): a held target is an expected outcome,
182
- // not an error, so it resolves `null` — the caller reads `if (!claim)`
183
- // and moves on; who holds it stays readable via `claim.state`. Best-effort
184
- // at the client (a racing claim not yet synced into our snapshot slips
185
- // through here) — the commit-time claim guard is the authoritative
186
- // backstop that rejects the loser's first write. For work-distribution
187
- // dedup that's exactly right: don't wait (that would double-process), skip.
188
- if (failFast && contended) {
189
- return null;
190
- }
181
+ const contention = resolveClaimContentionOptions(options);
182
+ const failFast = !contention.wait;
191
183
  // Ensure the row exists locally before claiming.
192
184
  let model = ownRowOrThrow(id);
193
185
  if (!model) {
@@ -207,27 +199,40 @@ defaultWait) {
207
199
  await collaboration.pinScope?.({ [schemaKey]: id });
208
200
  // Acquire the lease. By default (`queue` is not false) this goes through the
209
201
  // server's fair FIFO queue: `queue: true` resolves only once the lease is
210
- // genuinely ours, blocking behind any current holder, with no check-then-act
211
- // gap because the server orders contenders. Fail-fast skips the queue: an
212
- // observed conflict was already rejected above, so this just records the lease.
213
- const lease = await collaboration.createClaim({
214
- target: {
215
- model: wireModel,
216
- id,
217
- // The whole sub-entity locator in one move — listing its members here
218
- // is what let `fields` die between the caller and the lease, so the
219
- // claim covered the whole row while the caller believed it named parts.
220
- ...subTarget(options, schemaKey),
221
- },
222
- description: claimDescription(options),
223
- ttl: options.ttl,
224
- queue: !failFast,
225
- maxQueueDepth: options.maxQueueDepth,
226
- // The one wait cap, declared once on ClaimTargetOptions — the socket
227
- // wait and the HTTP poll-wait both honor it as `grant_timeout`.
228
- waitTimeoutMs: options.waitTimeoutMs,
229
- signal: options.signal,
230
- });
202
+ // genuinely ours, blocking behind any current holder. Fail-fast skips the
203
+ // queue but still awaits the server: a conflict invisible in the local
204
+ // snapshot resolves `null`, never a speculative handle.
205
+ let lease;
206
+ try {
207
+ lease = await collaboration.createClaim({
208
+ target: {
209
+ model: wireModel,
210
+ id,
211
+ // The whole sub-entity locator in one move listing its members here
212
+ // is what let `fields` die between the caller and the lease, so the
213
+ // claim covered the whole row while the caller believed it named parts.
214
+ ...subTarget(options, schemaKey),
215
+ },
216
+ description: claimDescription(options),
217
+ ttl: options.ttl,
218
+ queue: contention.wait,
219
+ maxQueueDepth: contention.maxDepth,
220
+ // The one wait cap, declared once on ClaimTargetOptions — the socket
221
+ // wait and the HTTP poll-wait both honor it as `grant_timeout`.
222
+ waitTimeoutMs: contention.timeoutMs,
223
+ signal: contention.signal,
224
+ onStatus: contention.onStatus,
225
+ });
226
+ }
227
+ catch (err) {
228
+ const normalized = toAbloError(err);
229
+ if (failFast &&
230
+ normalized instanceof AbloClaimedError &&
231
+ normalized.code === 'claim_conflict') {
232
+ return null;
233
+ }
234
+ throw normalized;
235
+ }
231
236
  // Only when the claim actually waited behind another holder can the row have
232
237
  // changed underneath us — re-read so the claimed snapshot reflects what that
233
238
  // holder committed before releasing. Either of two signals suffices:
@@ -340,45 +345,45 @@ defaultWait) {
340
345
  if (!collaboration) {
341
346
  throw new AbloValidationError(`Model "${schemaKey}" was built without the collaboration runtime, so claim() is unavailable here. Claiming needs no per-model config — use the standard Ablo({ schema, apiKey }) client and every model is claimable.`, { code: 'model_claim_not_configured' });
342
347
  }
343
- // Is someone else already on this target? Read the local coordination
344
- // snapshot up front so a `queue: false` caller can reject before announcing
345
- // a claim the server would refuse.
346
- const held = collaboration.state({ model: wireModel, id });
347
- const contended = !!held && held.heldBy !== collaboration.selfParticipantId;
348
- const failFast = options.queue === false;
349
- // The try-claim (`queue: false`): resolve `null` if a holder is already
350
- // visible — an expected outcome, not an error. Best-effort at the client —
351
- // a row this participant never synced usually carries no local claim state
352
- // either, so a peer gets the deterministic `null` only once it has
353
- // observed the holder (entered the row's entity scope). The server's
354
- // queue is the backstop for the queuing path.
355
- if (failFast && contended) {
356
- return null;
357
- }
348
+ const contention = resolveClaimContentionOptions(options);
349
+ const failFast = !contention.wait;
358
350
  // Enter the entity scope before acquiring the lease so the holder's claim
359
351
  // presence broadcasts to everyone in this entity group — the same ordering
360
352
  // the row-bearing path relies on. No pool `load` and no `entity_not_found`
361
353
  // throw: the row lives only in the customer's database, so there is nothing
362
354
  // to hydrate here and nothing to re-read after the grant.
363
355
  await collaboration.pinScope?.({ [schemaKey]: id });
364
- const lease = await collaboration.createClaim({
365
- target: {
366
- model: wireModel,
367
- id,
368
- // The whole sub-entity locator in one move — listing its members here
369
- // is what let `fields` die between the caller and the lease, so the
370
- // claim covered the whole row while the caller believed it named parts.
371
- ...subTarget(options, schemaKey),
372
- },
373
- description: claimDescription(options),
374
- ttl: options.ttl,
375
- queue: !failFast,
376
- maxQueueDepth: options.maxQueueDepth,
377
- // The one wait cap, declared once on ClaimTargetOptions — the socket
378
- // wait and the HTTP poll-wait both honor it as `grant_timeout`.
379
- waitTimeoutMs: options.waitTimeoutMs,
380
- signal: options.signal,
381
- });
356
+ let lease;
357
+ try {
358
+ lease = await collaboration.createClaim({
359
+ target: {
360
+ model: wireModel,
361
+ id,
362
+ // The whole sub-entity locator in one move listing its members here
363
+ // is what let `fields` die between the caller and the lease, so the
364
+ // claim covered the whole row while the caller believed it named parts.
365
+ ...subTarget(options, schemaKey),
366
+ },
367
+ description: claimDescription(options),
368
+ ttl: options.ttl,
369
+ queue: contention.wait,
370
+ maxQueueDepth: contention.maxDepth,
371
+ // The one wait cap, declared once on ClaimTargetOptions — the socket
372
+ // wait and the HTTP poll-wait both honor it as `grant_timeout`.
373
+ waitTimeoutMs: contention.timeoutMs,
374
+ signal: contention.signal,
375
+ onStatus: contention.onStatus,
376
+ });
377
+ }
378
+ catch (err) {
379
+ const normalized = toAbloError(err);
380
+ if (failFast &&
381
+ normalized instanceof AbloClaimedError &&
382
+ normalized.code === 'claim_conflict') {
383
+ return null;
384
+ }
385
+ throw normalized;
386
+ }
382
387
  // A watermark-only snapshot: `createSnapshot` still reads the engine's
383
388
  // current `lastSyncId` even though the pool holds no row (the bucket is
384
389
  // empty). It costs nothing extra and gives a write taken under this lease a
@@ -537,10 +542,7 @@ defaultWait) {
537
542
  };
538
543
  },
539
544
  queue(params) {
540
- return {
541
- object: 'list',
542
- data: collaboration?.queue({ model: wireModel, id: params.id }) ?? [],
543
- };
545
+ return claimQueueView(collaboration?.queue({ model: wireModel, id: params.id }) ?? []);
544
546
  },
545
547
  reorder(params) {
546
548
  collaboration?.reorder({ model: wireModel, id: params.id }, params.order);
@@ -627,6 +629,7 @@ defaultWait) {
627
629
  // race — see `takeClaim`). Released with the lease in the `finally`
628
630
  // below. Awaited for broadcast ordering; still best-effort.
629
631
  await collaboration.pinScope?.({ [schemaKey]: id });
632
+ const contention = resolveClaimContentionOptions(claim);
630
633
  autoLease = await collaboration.createClaim({
631
634
  target: {
632
635
  model: wireModel,
@@ -635,8 +638,11 @@ defaultWait) {
635
638
  },
636
639
  description: claimDescription(claim, 'creating'),
637
640
  ttl: claim.ttl,
638
- queue: claim.queue !== false,
639
- maxQueueDepth: claim.maxQueueDepth,
641
+ queue: contention.wait,
642
+ maxQueueDepth: contention.maxDepth,
643
+ waitTimeoutMs: contention.timeoutMs,
644
+ signal: contention.signal,
645
+ onStatus: contention.onStatus,
640
646
  });
641
647
  }
642
648
  // Default `organizationId` from the client's identity, matching the other
@@ -724,7 +730,7 @@ defaultWait) {
724
730
  const autoClaim = params.claim && !isClaimHandle(params.claim) ? params.claim : null;
725
731
  if (autoClaim) {
726
732
  const handle = await takeClaim({ ...autoClaim, id: params.id });
727
- // A declined try-claim is `null` only on the standalone verb; a
733
+ // A skipped try-claim is `null` only on the standalone verb; a
728
734
  // write that could not take its claim is a failed write.
729
735
  if (!handle) {
730
736
  throw new AbloClaimedError(`${registeredModelName}/${params.id} is held by another participant, so this update's claim could not be taken.`, { code: 'entity_claimed' });
@@ -25,6 +25,7 @@ import { awaitClaimGrant } from '@abloatai/transaction/coordination/awaitClaimGr
25
25
  import { createSnapshot } from '../sync/createSnapshot.js';
26
26
  import { createParticipantManager } from '../sync/participants.js';
27
27
  import { resolveApiKeyValue, resolveBootstrapBaseUrl } from '@abloatai/transaction/auth/apiKey';
28
+ import { claimAttemptFailure, emitClaimStatus, } from '@abloatai/transaction/resources/modelOperations';
28
29
  import { createModelProxy } from './createModelProxy.js';
29
30
  import { assertWriteOptions } from '@abloatai/transaction/resources/writeOptionsSchema';
30
31
  export function buildReactiveEngine(inputs) {
@@ -257,8 +258,8 @@ export function buildReactiveEngine(inputs) {
257
258
  return Promise.resolve();
258
259
  };
259
260
  // The token is server-stamped and arrives on the grant frame, so prefer
260
- // the one `awaitClaimGrant` read there; fall back to any the local handle
261
- // already carried (immediate, non-queued grants).
261
+ // the one `awaitClaimGrant` read there; retain the handle fallback for
262
+ // wire-compatible transports that already stamped it locally.
262
263
  const resolvedFenceToken = fenceToken ?? claim.fenceToken;
263
264
  return {
264
265
  object: 'claim',
@@ -280,6 +281,35 @@ export function buildReactiveEngine(inputs) {
280
281
  const publicClaims = Object.assign(claimStream, {
281
282
  async create(claimOptions) {
282
283
  await ready();
284
+ // Subscribe before announcing the claim. A fast rejection can arrive
285
+ // in the same turn as `send` in tests and on a low-latency socket; if
286
+ // the listener is installed afterwards, that authoritative answer is
287
+ // lost and the locally minted handle looks like a grant.
288
+ const claimId = crypto.randomUUID();
289
+ const grant = awaitClaimGrant(transport, claimId, {
290
+ timeoutMs: claimOptions.waitTimeoutMs,
291
+ maxQueueDepth: claimOptions.maxQueueDepth,
292
+ signal: claimOptions.signal,
293
+ logger,
294
+ onQueued: ({ position }) => {
295
+ emitClaimStatus(claimOptions.onStatus, {
296
+ type: 'queued',
297
+ claimId,
298
+ position,
299
+ ahead: position + 1,
300
+ });
301
+ },
302
+ onGranted: ({ waited }) => {
303
+ emitClaimStatus(claimOptions.onStatus, {
304
+ type: 'granted',
305
+ claimId,
306
+ waited,
307
+ });
308
+ },
309
+ onFailed: (error) => {
310
+ emitClaimStatus(claimOptions.onStatus, claimAttemptFailure(claimOptions.queue !== false, error));
311
+ },
312
+ });
283
313
  const claim = claimStream.claim({
284
314
  ...streamTarget(claimOptions.target),
285
315
  ...subTarget(claimOptions.target),
@@ -287,32 +317,16 @@ export function buildReactiveEngine(inputs) {
287
317
  description: claimOptions.description,
288
318
  ttl: claimOptions.ttl,
289
319
  queue: claimOptions.queue,
320
+ }, claimId);
321
+ // A claim is ours only after the server says so. This applies equally
322
+ // to queued claims and try-claims (`queue: false`): the latter must
323
+ // observe `claim_rejected` instead of returning a phantom handle.
324
+ const { waited, fenceToken, readAt } = await grant.catch((err) => {
325
+ // Give up the local/reconnect record after any rejection, timeout,
326
+ // abort, or lost lease. For queued claims this also leaves the line.
327
+ claim.revoke?.();
328
+ throw err;
290
329
  });
291
- // With `queue`, the claim is only really *ours* once the server says
292
- // so (`claim_acquired` if the target was free, `claim_granted` once
293
- // we reach the head of the FIFO line). Block here on that grant so
294
- // callers — chiefly `ablo.<model>.claim` — get a handle that already
295
- // holds the lease, never a half-claimed one racing the queue.
296
- let waited = false;
297
- let fenceToken;
298
- let readAt;
299
- if (claimOptions.queue) {
300
- try {
301
- ({ waited, fenceToken, readAt } = await awaitClaimGrant(transport, claim.id, {
302
- timeoutMs: claimOptions.waitTimeoutMs,
303
- maxQueueDepth: claimOptions.maxQueueDepth,
304
- signal: claimOptions.signal,
305
- logger,
306
- }));
307
- }
308
- catch (err) {
309
- // Gave up waiting (queue too deep, timed out, or lost) — abandon
310
- // the queued claim so we don't leave a phantom entry in the
311
- // line that would block or mislead other claimers.
312
- claim.revoke?.();
313
- throw err;
314
- }
315
- }
316
330
  return wrapClaimHandle(claim, waited, fenceToken, readAt);
317
331
  },
318
332
  list(target) {
@@ -8,5 +8,5 @@
8
8
  * live participant handle.
9
9
  */
10
10
  export * from '@abloatai/transaction/resources/httpResources';
11
- export type { LocalCountOptions, LocalReadOptions, ModelListScope, ServerReadOptions, ModelRetrieveParams, ModelCreateParams, ModelUpdateParams, ModelDeleteParams, ClaimOptions, ClaimParams, ClaimLookupParams, ClaimReorderParams, Claim, ClaimHeartbeat, ClaimHeartbeatOptions, HeldClaim, HeldLease, } from '@abloatai/transaction/resources/modelOperations';
11
+ export type { LocalCountOptions, LocalReadOptions, ModelListScope, ServerReadOptions, ModelRetrieveParams, ModelCreateParams, ModelUpdateParams, ModelDeleteParams, ClaimOptions, ClaimParams, ClaimContentionOptions, ClaimAttemptEvent, ClaimQueueView, ClaimLookupParams, ClaimReorderParams, Claim, ClaimHeartbeat, ClaimHeartbeatOptions, HeldClaim, HeldLease, } from '@abloatai/transaction/resources/modelOperations';
12
12
  export type { ModelOperations } from './createModelProxy.js';
@@ -41,13 +41,18 @@ export interface ClaimStreamConfig {
41
41
  }
42
42
  export interface AttachableClaimStream extends ClaimStream {
43
43
  /**
44
- * Mints a lease directly: sends the `claim_begin` frame and returns a held
45
- * {@link Claim} that carries no row `data` (the resource layer reads the row
46
- * and stamps it). This is an internal entry point, not part of the public
44
+ * Mints the local handle and sends its `claim_begin` frame. The handle is a
45
+ * request until the resource layer observes the server's grant; it must never
46
+ * be returned to application code before that acknowledgement. This is an
47
+ * internal entry point, not part of the public
47
48
  * {@link ClaimStream}; application code takes a claim through
48
49
  * `ablo.<model>.claim({ id })`, which is built on this.
50
+ *
51
+ * `claimId` lets that resource layer subscribe for the acknowledgement before
52
+ * this method sends. Omitting it preserves the direct stream API's generated
53
+ * id for internal callers that do not await the grant.
49
54
  */
50
- claim(target: PresenceTarget, opts?: ClaimOptions): Claim;
55
+ claim(target: PresenceTarget, opts?: ClaimOptions, claimId?: string): Claim;
51
56
  attach(transport: ClaimTransport): void;
52
57
  /**
53
58
  * Seeds the participant identity once the host resolves it. The stream can
@@ -83,6 +83,28 @@ export function createClaimStream(config, transport = null) {
83
83
  }
84
84
  }
85
85
  };
86
+ const observeForeignClaim = (heldBy, claim, participantKind, isAgent) => {
87
+ const description = claim.description ??
88
+ descriptionFromMeta(claim.meta) ??
89
+ 'editing';
90
+ const { meta, ...details } = subTarget(claim);
91
+ activeByClaimId.set(claim.claimId, {
92
+ object: 'claim',
93
+ id: claim.claimId,
94
+ status: 'active',
95
+ heldBy,
96
+ participantKind: participantKindFromWire(participantKind, isAgent),
97
+ target: {
98
+ ...streamTarget(claim),
99
+ ...details,
100
+ ...(meta !== undefined ? { meta: declaredMeta(meta) } : {}),
101
+ },
102
+ description,
103
+ ttlSeconds: Math.max(0, Math.floor((claim.expiresAt - Date.now()) / 1000)),
104
+ createdAt: claim.declaredAt,
105
+ expiresAt: claim.expiresAt,
106
+ });
107
+ };
86
108
  // ── Wire wiring ──────────────────────────────────────────────────
87
109
  let attached = null;
88
110
  const unsubs = [];
@@ -125,30 +147,7 @@ export function createClaimStream(config, transport = null) {
125
147
  // `settled()`. Absent status means active (wire back-compat).
126
148
  if (claim.status && claim.status !== 'active')
127
149
  continue;
128
- // Resolve the always-present public field, tolerating a frame that
129
- // carries the value in `meta` rather than as an explicit description.
130
- const description = claim.description ??
131
- descriptionFromMeta(claim.meta) ??
132
- 'editing';
133
- // The frame is parsed permissively, on purpose; `declaredMeta` is where
134
- // that wire value becomes the shape the program declared.
135
- const { meta, ...details } = subTarget(claim);
136
- activeByClaimId.set(claim.claimId, {
137
- object: 'claim',
138
- id: claim.claimId,
139
- status: 'active',
140
- heldBy: event.userId,
141
- participantKind: participantKindFromWire(event.participantKind, event.isAgent),
142
- target: {
143
- ...streamTarget(claim),
144
- ...details,
145
- ...(meta !== undefined ? { meta: declaredMeta(meta) } : {}),
146
- },
147
- description,
148
- ttlSeconds: Math.max(0, Math.floor((claim.expiresAt - Date.now()) / 1000)),
149
- createdAt: claim.declaredAt,
150
- expiresAt: claim.expiresAt,
151
- });
150
+ observeForeignClaim(event.userId, claim, event.participantKind, event.isAgent);
152
151
  mutated = true;
153
152
  }
154
153
  if (mutated)
@@ -168,6 +167,16 @@ export function createClaimStream(config, transport = null) {
168
167
  // a claim the server already rejected (would just spam both
169
168
  // sides with conflicts).
170
169
  ownClaims.delete(rejection.claimId);
170
+ // A holder on another server may have claimed before this client joined
171
+ // the row group, so its one-shot presence frame was missed. A conflict
172
+ // reply carries the authoritative holder summary; seed the same local
173
+ // state immediately instead of continuing to report the row as free.
174
+ if (rejection.reason === 'conflict' &&
175
+ rejection.heldBy &&
176
+ rejection.heldByClaim) {
177
+ observeForeignClaim(rejection.heldBy, rejection.heldByClaim, rejection.heldByKind);
178
+ notifyListeners();
179
+ }
171
180
  for (const l of rejectionListeners) {
172
181
  try {
173
182
  l(rejection);
@@ -348,8 +357,8 @@ export function createClaimStream(config, transport = null) {
348
357
  // The locator half derives from `OwnClaim` rather than being restated: a
349
358
  // member spelled out here is a member that dies before `sendBegin`, which is
350
359
  // how `fields` used to be lost between `claim()` and the socket.
351
- function mintHandle(args) {
352
- const claimId = crypto.randomUUID();
360
+ function mintHandle(args, requestedClaimId) {
361
+ const claimId = requestedClaimId ?? crypto.randomUUID();
353
362
  const estimatedMs = args.ttl !== undefined ? toMs(args.ttl) : undefined;
354
363
  // The handle the caller reads back is a public claim, so its `meta` is the
355
364
  // declared shape; the `OwnClaim` below stays wire-typed, because that is
@@ -402,7 +411,7 @@ export function createClaimStream(config, transport = null) {
402
411
  return target;
403
412
  }
404
413
  return {
405
- claim(target, opts) {
414
+ claim(target, opts, claimId) {
406
415
  const resolved = resolveTarget(target);
407
416
  return mintHandle({
408
417
  ...wireTarget(resolved),
@@ -410,7 +419,7 @@ export function createClaimStream(config, transport = null) {
410
419
  description: claimDescription({ ...opts, meta: resolved.meta }),
411
420
  ttl: opts?.ttl,
412
421
  queue: opts?.queue,
413
- });
422
+ }, claimId);
414
423
  },
415
424
  get others() {
416
425
  return claimsSnapshot;
@@ -45,6 +45,15 @@ export interface MutationQueueConfig {
45
45
  maxBatchSize: number;
46
46
  batchDelay: number;
47
47
  maxRetries: number;
48
+ /**
49
+ * Minimum wall-clock window for retrying transient write failures with the
50
+ * same durable envelope and idempotency key. This absorbs managed-database
51
+ * promotion and brief regional network incidents without double-applying a
52
+ * write. Defaults to 120 seconds: the Aurora promotion drill recovered
53
+ * writes just beyond 60 seconds, so a one-minute boundary discarded exact
54
+ * envelopes at the instant the new writer became usable.
55
+ */
56
+ availabilityRetryWindowMs: number;
48
57
  conflictResolution: ConflictResolution;
49
58
  enablePersistence: boolean;
50
59
  enableOptimistic: boolean;
@@ -127,6 +136,7 @@ export declare class MutationQueue extends EventEmitter {
127
136
  private replicationLagTimeouts;
128
137
  private replicationLagErrors;
129
138
  private commitProcessing;
139
+ private commitRetryTimer;
130
140
  private lastCommitSequence;
131
141
  private durableReplayBlock;
132
142
  /** Browser-backed strict outbox; absent for standalone/in-memory consumers. */
@@ -418,6 +428,15 @@ export declare class MutationQueue extends EventEmitter {
418
428
  maxBatchSize: number;
419
429
  batchDelay: number;
420
430
  maxRetries: number;
431
+ /**
432
+ * Minimum wall-clock window for retrying transient write failures with the
433
+ * same durable envelope and idempotency key. This absorbs managed-database
434
+ * promotion and brief regional network incidents without double-applying a
435
+ * write. Defaults to 120 seconds: the Aurora promotion drill recovered
436
+ * writes just beyond 60 seconds, so a one-minute boundary discarded exact
437
+ * envelopes at the instant the new writer became usable.
438
+ */
439
+ availabilityRetryWindowMs: number;
421
440
  conflictResolution: ConflictResolution;
422
441
  enablePersistence: boolean;
423
442
  enableOptimistic: boolean;
@@ -104,6 +104,7 @@ export class MutationQueue extends EventEmitter {
104
104
  replicationLagTimeouts = new Map();
105
105
  replicationLagErrors = new Map();
106
106
  commitProcessing = false;
107
+ commitRetryTimer = null;
107
108
  lastCommitSequence = 0;
108
109
  durableReplayBlock = null;
109
110
  /** Browser-backed strict outbox; absent for standalone/in-memory consumers. */
@@ -125,7 +126,11 @@ export class MutationQueue extends EventEmitter {
125
126
  get commitLaneContext() {
126
127
  return {
127
128
  runtime: this.runtime,
128
- config: { maxRetries: this.config.maxRetries },
129
+ config: {
130
+ maxRetries: this.config.maxRetries,
131
+ availabilityRetryWindowMs: this.config.availabilityRetryWindowMs,
132
+ retryBackoff: this.config.retryBackoff,
133
+ },
129
134
  commitLane: this.commitLane,
130
135
  commitNotifications: this.commitNotifications,
131
136
  commitMissingIds: this.commitMissingIds,
@@ -147,6 +152,14 @@ export class MutationQueue extends EventEmitter {
147
152
  noteAck: (syncId) => this.noteAck(syncId),
148
153
  isDefinitiveRejection: (error) => this.isDefinitiveRejection(error),
149
154
  isPermanentError: (error) => this.isPermanentError(error),
155
+ scheduleRetry: (delayMs) => {
156
+ if (this.commitRetryTimer !== null)
157
+ clearTimeout(this.commitRetryTimer);
158
+ this.commitRetryTimer = setTimeout(() => {
159
+ this.commitRetryTimer = null;
160
+ void this.processCommitLane();
161
+ }, delayMs);
162
+ },
150
163
  emitCommitLifecycle: (event, payload) => this.emitCommitLifecycle(event, payload),
151
164
  };
152
165
  }
@@ -516,6 +529,7 @@ export class MutationQueue extends EventEmitter {
516
529
  maxBatchSize: 50, // send up to this many operations per commit
517
530
  batchDelay: 150, // milliseconds to wait for more operations before sending
518
531
  maxRetries: 3,
532
+ availabilityRetryWindowMs: 120_000,
519
533
  conflictResolution: {
520
534
  strategy: 'last-write-wins',
521
535
  },
@@ -1506,6 +1520,10 @@ export class MutationQueue extends EventEmitter {
1506
1520
  clearTimeout(this.commitOfflineGraceTimer);
1507
1521
  this.commitOfflineGraceTimer = null;
1508
1522
  }
1523
+ if (this.commitRetryTimer !== null) {
1524
+ clearTimeout(this.commitRetryTimer);
1525
+ this.commitRetryTimer = null;
1526
+ }
1509
1527
  // Clear store
1510
1528
  this.store.clear();
1511
1529
  this.localMutationPort.updates.clear();
@@ -21,6 +21,7 @@ export interface CommitTransaction {
21
21
  createdAt: number;
22
22
  attempts: number;
23
23
  transientAttempts?: number;
24
+ firstTransientFailureAt?: number;
24
25
  lastSyncId?: number;
25
26
  correlationId?: string;
26
27
  error?: Error;
@@ -34,6 +35,11 @@ export interface CommitLaneContext {
34
35
  readonly runtime: RuntimeContext;
35
36
  readonly config: {
36
37
  maxRetries: number;
38
+ availabilityRetryWindowMs: number;
39
+ retryBackoff: {
40
+ baseMs: number;
41
+ capMs: number;
42
+ };
37
43
  };
38
44
  readonly commitLane: CommitTransaction[];
39
45
  readonly commitNotifications: Map<string, StaleNotification[]>;
@@ -52,6 +58,7 @@ export interface CommitLaneContext {
52
58
  readonly noteAck: (syncId: number | undefined) => void;
53
59
  readonly isDefinitiveRejection: (error: Error) => boolean;
54
60
  readonly isPermanentError: (error: Error) => boolean;
61
+ readonly scheduleRetry: (delayMs: number) => void;
55
62
  readonly emitCommitLifecycle: (event: string, payload: object) => void;
56
63
  }
57
64
  export interface CommitReceiptContext {
@@ -1,4 +1,4 @@
1
- import { AbloConnectionError } from '@abloatai/transaction/errors';
1
+ import { transientRetryDelayMs } from './failureHandling.js';
2
2
  export function waitForCommitReceipt(ctx, clientTxId) {
3
3
  const drainNotifications = () => {
4
4
  const notifications = ctx.commitNotifications.get(clientTxId);
@@ -112,15 +112,18 @@ export async function processCommitLane(ctx) {
112
112
  const error = cause instanceof Error ? cause : new Error(String(cause));
113
113
  if (dispatchStarted && ctx.isDefinitiveRejection(error))
114
114
  await ctx.removeDurableCommit(tx.id);
115
- if (!(error instanceof AbloConnectionError))
116
- tx.transientAttempts = (tx.transientAttempts ?? 0) + 1;
117
- const exhausted = (tx.transientAttempts ?? 0) > ctx.config.maxRetries;
115
+ tx.transientAttempts = (tx.transientAttempts ?? 0) + 1;
116
+ tx.firstTransientFailureAt ??= Date.now();
117
+ const outsideAvailabilityWindow = Date.now() - tx.firstTransientFailureAt >= ctx.config.availabilityRetryWindowMs;
118
+ const exhausted = tx.transientAttempts > ctx.config.maxRetries && outsideAvailabilityWindow;
118
119
  if (!ctx.isPermanentError(error) && !exhausted) {
119
120
  tx.status = 'pending';
121
+ const delayMs = transientRetryDelayMs(error, tx.transientAttempts, ctx.config.retryBackoff);
120
122
  ctx.runtime.logger.debug('[MutationQueue] commit lane transient', {
121
123
  txId: tx.id.slice(0, 12), attempts: tx.attempts,
122
- transientAttempts: tx.transientAttempts ?? 0, message: error.message,
124
+ transientAttempts: tx.transientAttempts, delayMs, message: error.message,
123
125
  });
126
+ ctx.scheduleRetry(delayMs);
124
127
  break;
125
128
  }
126
129
  tx.status = 'failed';