@abloatai/ablo 0.21.0 → 0.22.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,42 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.22.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Expose the functional `update(id, current => next)` overload on the stateless
8
+ HTTP client type (`HttpModelClient` / `AbloHttpClient`).
9
+
10
+ 0.22.0 wired the functional update at runtime on every transport and added the
11
+ overload to `ModelOperations` (WebSocket) and `ModelClient`, but the
12
+ `Ablo({ transport: 'http' })` client resolves its models to `HttpModelClient`,
13
+ whose `update` type still declared only the `update({ id, data })` form. So
14
+ server-side agents — the primary callers — saw a type error on
15
+ `update(id, fn)` even though it worked. Add the overload to that type.
16
+
17
+ ## 0.22.0
18
+
19
+ ### Minor Changes
20
+
21
+ - Add the functional update form: `ablo.<model>.update(id, current => next)`.
22
+
23
+ The `setState(prev => next)` of the data layer. Pass a function of the latest
24
+ row and the SDK owns everything that used to be the caller's problem under
25
+ contention: it reads the freshest row, runs your updater, writes it as a
26
+ compare-and-swap against the row's watermark, and re-reads + re-runs on any
27
+ concurrent write. No claim, no per-participant identity, and no
28
+ `stale_context` / `claim_*` codes ever surface — correctness rides on the
29
+ watermark, so concurrent writers reconcile instead of silently clobbering. The
30
+ write either lands or throws a single `AbloContentionError` once its reconcile
31
+ budget is spent.
32
+
33
+ Identical guarantee on both transports (HTTP and WebSocket share one reconcile
34
+ loop). Return `null`/`undefined` from the updater to skip the write. Tune with
35
+ `{ retries, signal }`. Exports: `AbloContentionError`, `ModelUpdater`,
36
+ `ContentionOptions`, `DEFAULT_CONTENTION_RETRIES`.
37
+
38
+ The classic `update({ id, data })` form is unchanged.
39
+
3
40
  ## 0.21.0
4
41
 
5
42
  ### Minor Changes
package/dist/cli.cjs CHANGED
@@ -276808,6 +276808,12 @@ var ERROR_CODES = {
276808
276808
  model_watch_not_configured: client("claim", "watch() opens a presence/claim subscription and needs a live WebSocket, so it is unavailable on the HTTP transport and on model proxies built without a socket. Use the standard Ablo({ schema, apiKey }) client (default WebSocket transport)."),
276809
276809
  // ── stale context / idempotency (409) ──────────────────────────────
276810
276810
  stale_context: wire("conflict", 409, true, "The write carried a readAt watermark that is now stale; re-read and retry."),
276811
+ // Raised by the functional `update(id, current => next)` form once its
276812
+ // internal reconcile budget is exhausted — the row stayed continuously
276813
+ // contended. Client-side: the SDK already retried; the caller decides whether
276814
+ // to back off, raise `retries`, or move the row to the WebSocket transport.
276815
+ contention_exhausted: client("conflict", "A functional update could not land after exhausting its reconcile budget; the row stayed continuously contended."),
276816
+ update_aborted: client("conflict", "The functional update reconcile loop was aborted via its AbortSignal before the write landed."),
276811
276817
  idempotency_conflict: wire("conflict", 409, false, "The same Idempotency-Key was reused with a different request body."),
276812
276818
  idempotency_key_too_long: wire("validation", 400, false, "The supplied Idempotency-Key exceeds the maximum length."),
276813
276819
  // ── validation (400 / 422) ─────────────────────────────────────────
@@ -28,6 +28,7 @@ import type { SyncStoreContract } from '../react/context.js';
28
28
  import type { SyncWebSocket } from '../sync/SyncWebSocket.js';
29
29
  import type { SyncGroupInput } from '../schema/roles.js';
30
30
  import { type SyncStatus } from '../BaseSyncedStore.js';
31
+ import type { ModelUpdater, ContentionOptions } from './functionalUpdate.js';
31
32
  import type { ClaimStream, ClaimWaitOptions, PresenceStream, Snapshot } from '../types/streams.js';
32
33
  import type { Claim, HeldClaim, Duration } from '../types/streams.js';
33
34
  import { type AbloApi, type AbloApiClientOptions, type AbloApiClaims } from './ApiClient.js';
@@ -579,6 +580,17 @@ export interface ModelClient<T = Record<string, unknown>> {
579
580
  readonly id: string;
580
581
  readonly data: Record<string, unknown>;
581
582
  }): Promise<CommitReceipt>;
583
+ /**
584
+ * Update under contention with a function of the latest state —
585
+ * `update(id, current => next)`, the `setState(prev => next)` of the data
586
+ * layer. The SDK reads the freshest row, runs your updater, writes it as a
587
+ * compare-and-swap against the row's watermark, and re-reads + re-runs on any
588
+ * concurrent write. No claim, no identity, no conflict codes surface: the
589
+ * write either lands or throws {@link AbloContentionError} once its reconcile
590
+ * budget is spent. Return `null`/`undefined` from the updater to skip the
591
+ * write (resolves to `undefined`).
592
+ */
593
+ update(id: string, updater: ModelUpdater<T>, options?: ContentionOptions): Promise<CommitReceipt | undefined>;
582
594
  delete(params: ModelMutationOptions & {
583
595
  readonly id: string;
584
596
  }): Promise<CommitReceipt>;
@@ -37,6 +37,7 @@ import { createPresenceStream } from '../sync/createPresenceStream.js';
37
37
  import { createClaimStream } from '../sync/createClaimStream.js';
38
38
  import { awaitClaimGrant } from '../sync/awaitClaimGrant.js';
39
39
  import { createSnapshot } from '../sync/createSnapshot.js';
40
+ import { reconcileFunctionalUpdate } from './functionalUpdate.js';
40
41
  import { createParticipantManager } from '../sync/participants.js';
41
42
  import { createProtocolClient, } from './ApiClient.js';
42
43
  // Value import is cycle-safe: httpClient.js only value-imports ApiClient.js,
@@ -1529,13 +1530,33 @@ export function Ablo(options) {
1529
1530
  };
1530
1531
  }
1531
1532
  function model(name) {
1532
- return {
1533
- retrieve(params) {
1534
- return retrieveModel(name, params.id, params);
1535
- },
1536
- async create(params) {
1537
- const id = params.id ?? createModelId();
1538
- await applyClaimedPolicy({ model: name, id }, params);
1533
+ function updateModel(arg, updater, contention) {
1534
+ if (typeof arg === 'string') {
1535
+ const id = arg;
1536
+ if (typeof updater !== 'function') {
1537
+ throw new AbloValidationError(`${name}.update('${id}', updater): the second argument must be an updater ` +
1538
+ `function (current) => next. To write a fixed value, use update({ id, data }).`, { code: 'write_options_invalid' });
1539
+ }
1540
+ return reconcileFunctionalUpdate(updater, contention, {
1541
+ model: name,
1542
+ id,
1543
+ readFresh: async () => {
1544
+ const read = await retrieveModel(name, id, {});
1545
+ return { data: read.data, stamp: read.stamp };
1546
+ },
1547
+ writeNext: (patch, readAt) => commits.create({
1548
+ readAt,
1549
+ onStale: 'reject',
1550
+ wait: 'confirmed',
1551
+ operations: [
1552
+ { action: 'update', model: name, id, data: patch },
1553
+ ],
1554
+ }),
1555
+ });
1556
+ }
1557
+ const params = arg;
1558
+ return (async () => {
1559
+ await applyClaimedPolicy({ model: name, id: params.id }, params);
1539
1560
  return commits.create({
1540
1561
  claimRef: params.claimRef,
1541
1562
  idempotencyKey: params.idempotencyKey,
@@ -1544,17 +1565,18 @@ export function Ablo(options) {
1544
1565
  ...(isClaimHandleValue(params.claim) ? { claim: params.claim } : {}),
1545
1566
  wait: params.wait,
1546
1567
  operations: [
1547
- {
1548
- action: 'create',
1549
- model: name,
1550
- id,
1551
- data: params.data,
1552
- },
1568
+ { action: 'update', model: name, id: params.id, data: params.data },
1553
1569
  ],
1554
1570
  });
1571
+ })();
1572
+ }
1573
+ return {
1574
+ retrieve(params) {
1575
+ return retrieveModel(name, params.id, params);
1555
1576
  },
1556
- async update(params) {
1557
- await applyClaimedPolicy({ model: name, id: params.id }, params);
1577
+ async create(params) {
1578
+ const id = params.id ?? createModelId();
1579
+ await applyClaimedPolicy({ model: name, id }, params);
1558
1580
  return commits.create({
1559
1581
  claimRef: params.claimRef,
1560
1582
  idempotencyKey: params.idempotencyKey,
@@ -1564,14 +1586,15 @@ export function Ablo(options) {
1564
1586
  wait: params.wait,
1565
1587
  operations: [
1566
1588
  {
1567
- action: 'update',
1589
+ action: 'create',
1568
1590
  model: name,
1569
- id: params.id,
1591
+ id,
1570
1592
  data: params.data,
1571
1593
  },
1572
1594
  ],
1573
1595
  });
1574
1596
  },
1597
+ update: updateModel,
1575
1598
  async delete(params) {
1576
1599
  await applyClaimedPolicy({ model: name, id: params.id }, params);
1577
1600
  return commits.create({
@@ -6,6 +6,7 @@
6
6
  * nouns directly to HTTP routes on sync-server.
7
7
  */
8
8
  import { AbloClaimedError, AbloAuthenticationError, AbloConnectionError, AbloValidationError, claimedError, translateHttpError, } from '../errors.js';
9
+ import { reconcileFunctionalUpdate, } from './functionalUpdate.js';
9
10
  import { assertBrowserSafety, readProcessEnv, resolveApiKey, resolveApiKeyValue, resolveAuthToken, resolveBaseURL, resolveBootstrapBaseUrl, resolveDatabaseUrl, warnIfCliKeyMismatch, warnIfDatabaseUrlEnvIgnored, warnIfDatabaseUrlDeprecated, } from './auth.js';
10
11
  import { registerDataSource } from './registerDataSource.js';
11
12
  import { toSeconds } from '../utils/duration.js';
@@ -686,6 +687,37 @@ export function createProtocolClient(options) {
686
687
  await releaseClaim({ id }).catch(() => { });
687
688
  }
688
689
  };
690
+ function updateModel(arg, updater, contention) {
691
+ // Functional form: update(id, current => next). The SDK owns the
692
+ // read-fresh → compute → compare-and-swap → reconcile loop; correctness
693
+ // rides on the row's watermark (readAt + onStale:'reject'), so no claim
694
+ // or per-participant identity is needed and contention never clobbers.
695
+ if (typeof arg === 'string') {
696
+ const id = arg;
697
+ if (typeof updater !== 'function') {
698
+ throw new AbloValidationError(`${name}.update('${id}', updater): the second argument must be an updater ` +
699
+ `function (current) => next. To write a fixed value, use update({ id, data }).`, { code: 'write_options_invalid' });
700
+ }
701
+ return reconcileFunctionalUpdate(updater, contention, {
702
+ model: name,
703
+ id,
704
+ readFresh: async () => {
705
+ const read = await retrieveModel(name, { id });
706
+ return { data: read.data, stamp: read.stamp };
707
+ },
708
+ writeNext: (patch, readAt) => mutateModel('update', name, id, patch, {
709
+ readAt,
710
+ onStale: 'reject',
711
+ wait: 'confirmed',
712
+ }),
713
+ });
714
+ }
715
+ const params = arg;
716
+ return withMutationClaim(params.id, params, async (options) => {
717
+ await applyClaimedPolicy({ model: name, id: params.id }, options);
718
+ return mutateModel('update', name, params.id, params.data, options);
719
+ });
720
+ }
689
721
  return {
690
722
  claim,
691
723
  retrieve(params) {
@@ -701,12 +733,7 @@ export function createProtocolClient(options) {
701
733
  return mutateModel('create', name, id, params.data, options);
702
734
  });
703
735
  },
704
- async update(params) {
705
- return withMutationClaim(params.id, params, async (options) => {
706
- await applyClaimedPolicy({ model: name, id: params.id }, options);
707
- return mutateModel('update', name, params.id, params.data, options);
708
- });
709
- },
736
+ update: updateModel,
710
737
  async delete(params) {
711
738
  return withMutationClaim(params.id, params, async (options) => {
712
739
  await applyClaimedPolicy({ model: name, id: params.id }, options);
@@ -14,6 +14,7 @@
14
14
  * `claim.reorder`), and `onChange`. The factory returns a plain object; the
15
15
  * client assembles the `ablo.<model>` lookup table from these.
16
16
  */
17
+ import { type ModelUpdater, type ContentionOptions } from './functionalUpdate.js';
17
18
  import type { MutationOptions } from '../interfaces/index.js';
18
19
  import type { ModelRegistry } from '../ModelRegistry.js';
19
20
  import type { ObjectPool } from '../ObjectPool.js';
@@ -402,6 +403,17 @@ export interface ModelOperations<T, CreateInput> {
402
403
  create(params: ModelCreateParams<T, CreateInput>): Promise<T>;
403
404
  /** Update an entity by id — optimistic, offline-first (see `create`). */
404
405
  update(params: ModelUpdateParams<T>): Promise<T>;
406
+ /**
407
+ * Update under contention with a function of the latest state —
408
+ * `update(id, current => next)`, the `setState(prev => next)` of the data
409
+ * layer. The SDK reads the freshest row, runs your updater, writes it as a
410
+ * compare-and-swap, and re-reads + re-runs on any concurrent write. Nothing
411
+ * about claims, identity, or conflict codes surfaces: the write either lands
412
+ * or throws {@link AbloContentionError} once its reconcile budget is spent.
413
+ * Return `null`/`undefined` from the updater to skip the write. Resolves to
414
+ * the reconciled row (or `undefined` when the updater opted out).
415
+ */
416
+ update(id: string, updater: ModelUpdater<T>, options?: ContentionOptions): Promise<T | undefined>;
405
417
  /** Delete an entity by id — optimistic, offline-first (see `create`). */
406
418
  delete(params: ModelDeleteParams<T>): Promise<void>;
407
419
  /**
@@ -17,6 +17,7 @@
17
17
  import { autorun } from 'mobx';
18
18
  import { AbloClaimedError, AbloValidationError, formatClaimedErrorMessage, toAbloError, } from '../errors.js';
19
19
  import { descriptionFromMeta } from '../coordination/schema.js';
20
+ import { reconcileFunctionalUpdate, } from './functionalUpdate.js';
20
21
  import { Model, modelAsRow } from '../Model.js';
21
22
  import { toMs } from '../utils/duration.js';
22
23
  import { assertWriteOptions } from './writeOptionsSchema.js';
@@ -414,57 +415,116 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
414
415
  await autoLease?.release?.().catch(() => { });
415
416
  }
416
417
  }),
417
- update: guard(async (params) => {
418
- const autoClaim = params.claim && !isClaimHandle(params.claim) ? params.claim : null;
419
- if (autoClaim) {
420
- const handle = await takeClaim({ ...autoClaim, id: params.id });
421
- try {
422
- return await operations.update({ ...params, claim: handle });
418
+ // `update` is overloaded — classic `update({ id, data })` + functional
419
+ // `update(id, current => next)`. The IIFE keeps the shared error-guard
420
+ // wrapping while exposing the two public signatures (a plain `guard(...)`
421
+ // would collapse them to one).
422
+ update: (() => {
423
+ const updateImpl = guard(async (arg, updater, contention) => {
424
+ // Functional form: update(id, current => next). Same guarantee as the
425
+ // HTTP client (shared reconcile loop), implemented with this transport's
426
+ // own read-fresh + confirmed compare-and-swap. A forced server round-trip
427
+ // gets the latest row + watermark; the write is stale-guarded by it, so
428
+ // concurrent writers reconcile instead of clobbering — no claim needed.
429
+ if (typeof arg === 'string') {
430
+ const id = arg;
431
+ if (typeof updater !== 'function') {
432
+ throw new AbloValidationError(`${registeredModelName}.update('${id}', updater): the second argument must ` +
433
+ `be an updater function (current) => next. To write a fixed value, use ` +
434
+ `update({ id, data }).`, { code: 'write_options_invalid' });
435
+ }
436
+ if (!collaboration) {
437
+ throw new AbloValidationError(`${registeredModelName}.update(id, updater) needs the collaboration runtime ` +
438
+ `(a live WebSocket) to read the row's watermark for its compare-and-swap. ` +
439
+ `Use the standard Ablo({ schema, apiKey }) client.`, { code: 'model_claim_not_configured' });
440
+ }
441
+ return reconcileFunctionalUpdate(updater, contention, {
442
+ model: registeredModelName,
443
+ id,
444
+ readFresh: async () => {
445
+ // `type: 'complete'` forces the round-trip — the hydration ledger
446
+ // would otherwise serve a possibly-stale local row for a hydrated id.
447
+ await load({ where: [['id', id]], type: 'complete' });
448
+ const fresh = objectPool.get(id);
449
+ const snapshot = collaboration.createSnapshot(schemaKey, id);
450
+ return {
451
+ data: fresh ? modelAsRow(fresh) : undefined,
452
+ stamp: snapshot.stamp,
453
+ };
454
+ },
455
+ writeNext: async (patch, readAt) => {
456
+ const model = objectPool.get(id);
457
+ if (!model) {
458
+ throw new AbloValidationError(`Entity not found: ${registeredModelName}/${id}`, { code: 'entity_not_found' });
459
+ }
460
+ const effective = {
461
+ wait: 'confirmed',
462
+ readAt,
463
+ onStale: 'reject',
464
+ };
465
+ model.applyChanges(patch);
466
+ syncClient.update(model, effective);
467
+ await waitForMutation(model, effective);
468
+ return modelAsRow(model);
469
+ },
470
+ });
423
471
  }
424
- finally {
425
- await handle.release?.();
472
+ const params = arg;
473
+ const autoClaim = params.claim && !isClaimHandle(params.claim) ? params.claim : null;
474
+ if (autoClaim) {
475
+ const handle = await takeClaim({ ...autoClaim, id: params.id });
476
+ try {
477
+ return await operations.update({ ...params, claim: handle });
478
+ }
479
+ finally {
480
+ await handle.release?.();
481
+ }
426
482
  }
483
+ const { id } = params;
484
+ const model = objectPool.get(id);
485
+ if (!model)
486
+ throw new AbloValidationError(`Entity not found: ${registeredModelName}/${id}`, { code: 'entity_not_found' });
487
+ // If we hold a claim on this row, guard the write with its snapshot
488
+ // watermark + lease so it's stale-rejected and attributed to the claim.
489
+ const claimed = activeClaims.get(id);
490
+ const opts = mutationOptions(params);
491
+ const handle = isClaimHandle(params.claim) ? params.claim : undefined;
492
+ const effective = claimed
493
+ ? {
494
+ wait: 'confirmed',
495
+ readAt: claimed.snapshot.stamp,
496
+ onStale: 'reject',
497
+ claimRef: { id: claimed.lease.id },
498
+ ...opts,
499
+ }
500
+ : {
501
+ // A carried handle engages the same stale guard as a claim this
502
+ // proxy took itself — the watermark rides on the handle, so it
503
+ // works across clients (HTTP-minted handles included).
504
+ ...(handle?.readAt !== undefined
505
+ ? {
506
+ wait: 'confirmed',
507
+ readAt: handle.readAt,
508
+ onStale: 'reject',
509
+ }
510
+ : {}),
511
+ ...opts,
512
+ ...(handle ? { claim: { id: handle.id } } : {}),
513
+ };
514
+ // Local user update: `applyChanges` keeps change tracking ON so
515
+ // the edited fields land in `modifiedProperties` and actually get
516
+ // sent to the server. (`updateFromData` is the hydration path and
517
+ // would discard the tracking → empty `input: {}` no-op mutation.)
518
+ model.applyChanges(params.data);
519
+ syncClient.update(model, effective);
520
+ await waitForMutation(model, effective);
521
+ return modelAsRow(model);
522
+ });
523
+ function update(arg, updater, contention) {
524
+ return updateImpl(arg, updater, contention);
427
525
  }
428
- const { id } = params;
429
- const model = objectPool.get(id);
430
- if (!model)
431
- throw new AbloValidationError(`Entity not found: ${registeredModelName}/${id}`, { code: 'entity_not_found' });
432
- // If we hold a claim on this row, guard the write with its snapshot
433
- // watermark + lease so it's stale-rejected and attributed to the claim.
434
- const claimed = activeClaims.get(id);
435
- const opts = mutationOptions(params);
436
- const handle = isClaimHandle(params.claim) ? params.claim : undefined;
437
- const effective = claimed
438
- ? {
439
- wait: 'confirmed',
440
- readAt: claimed.snapshot.stamp,
441
- onStale: 'reject',
442
- claimRef: { id: claimed.lease.id },
443
- ...opts,
444
- }
445
- : {
446
- // A carried handle engages the same stale guard as a claim this
447
- // proxy took itself — the watermark rides on the handle, so it
448
- // works across clients (HTTP-minted handles included).
449
- ...(handle?.readAt !== undefined
450
- ? {
451
- wait: 'confirmed',
452
- readAt: handle.readAt,
453
- onStale: 'reject',
454
- }
455
- : {}),
456
- ...opts,
457
- ...(handle ? { claim: { id: handle.id } } : {}),
458
- };
459
- // Local user update: `applyChanges` keeps change tracking ON so
460
- // the edited fields land in `modifiedProperties` and actually get
461
- // sent to the server. (`updateFromData` is the hydration path and
462
- // would discard the tracking → empty `input: {}` no-op mutation.)
463
- model.applyChanges(params.data);
464
- syncClient.update(model, effective);
465
- await waitForMutation(model, effective);
466
- return modelAsRow(model);
467
- }),
526
+ return update;
527
+ })(),
468
528
  delete: guard(async (params) => {
469
529
  const autoClaim = params.claim && !isClaimHandle(params.claim) ? params.claim : null;
470
530
  if (autoClaim) {
@@ -0,0 +1,77 @@
1
+ /**
2
+ * The functional update — `ablo.<model>.update(id, current => next)`.
3
+ *
4
+ * This is the "just works under contention" surface. The developer expresses
5
+ * ONLY their intent — "given the latest row, here is the next state" — and the
6
+ * SDK owns everything else: read the fresh row + its watermark, run the updater,
7
+ * write it as a compare-and-swap against that watermark, and on any concurrent
8
+ * write re-read → recompute → retry. No claim, no identity, no transport
9
+ * awareness, and no `stale_context` / `claim_*` error codes ever reach the
10
+ * caller. The write either lands or, at the extreme, throws a single
11
+ * {@link AbloContentionError} after the reconcile budget is spent.
12
+ *
13
+ * Correctness comes from the `readAt` watermark + `onStale: 'reject'`
14
+ * (optimistic concurrency / compare-and-swap), NOT from participant identity —
15
+ * which is why it is immune to the shared-credential silent-clobber footgun and
16
+ * behaves identically on both transports. The HTTP and WebSocket clients inject
17
+ * the same two thunks ({@link ReconcileTransport}); the loop below is shared, so
18
+ * the guarantee can never drift between them — only the mechanism differs.
19
+ *
20
+ * The mental model is React's `setState(prev => next)`: pass a function of the
21
+ * current state, the runtime owns reconciliation.
22
+ */
23
+ import { AbloContentionError } from '../errors.js';
24
+ /**
25
+ * The functional form of an update: given the freshly-read row, return the
26
+ * fields to write. Return `null` / `undefined` to make NO write — a no-op the
27
+ * caller decided on after seeing the latest state (e.g. "already done").
28
+ */
29
+ export type ModelUpdater<T> = (current: T) => Partial<T> | null | undefined | Promise<Partial<T> | null | undefined>;
30
+ /** Tuning for the functional update's internal reconcile loop. */
31
+ export interface ContentionOptions {
32
+ /**
33
+ * Max reconcile rounds under contention before throwing
34
+ * {@link AbloContentionError}. Each round re-reads the latest row and re-runs
35
+ * your updater. Defaults to {@link DEFAULT_CONTENTION_RETRIES}.
36
+ */
37
+ readonly retries?: number;
38
+ /** Abort the reconcile loop (e.g. the request was cancelled). */
39
+ readonly signal?: AbortSignal;
40
+ }
41
+ /** Reconcile rounds before a hot row is declared permanently contended. */
42
+ export declare const DEFAULT_CONTENTION_RETRIES = 16;
43
+ /**
44
+ * Does this thrown error mean "another writer moved the row — re-read and
45
+ * retry" rather than a genuine failure to surface? These are the optimistic-
46
+ * concurrency signals the functional update reconciles against:
47
+ * - `stale_context` — our `readAt` watermark was overtaken by a concurrent write
48
+ * - `claim_lost` — a holder preempted us (e.g. a human under `humansOverwrite`)
49
+ * - `claim_queued` — a holder is actively editing the row right now
50
+ */
51
+ export declare function isReconcilableConflict(err: unknown): boolean;
52
+ /**
53
+ * Transport-specific read/write the shared loop drives. Each client injects its
54
+ * own pair — that's the ONLY thing that differs between HTTP and WebSocket.
55
+ */
56
+ export interface ReconcileTransport<T, R> {
57
+ readonly model: string;
58
+ readonly id: string;
59
+ /** Read the latest row + its watermark from the authoritative store. */
60
+ readFresh: () => Promise<{
61
+ readonly data: T | null | undefined;
62
+ readonly stamp: number;
63
+ }>;
64
+ /**
65
+ * Write the computed patch as a compare-and-swap against `readAt`. MUST throw
66
+ * a reconcilable conflict (`stale_context` / `claim_*`) when the watermark was
67
+ * overtaken — that rejection is what drives the next reconcile round.
68
+ */
69
+ writeNext: (patch: Partial<T>, readAt: number) => Promise<R>;
70
+ }
71
+ /**
72
+ * Run the read-fresh → compute → compare-and-swap → reconcile loop. Shared by
73
+ * both transports so the guarantee is provably identical. Returns the write's
74
+ * result, or `undefined` when the updater opted out of writing.
75
+ */
76
+ export declare function reconcileFunctionalUpdate<T, R>(updater: ModelUpdater<T>, options: ContentionOptions | undefined, transport: ReconcileTransport<T, R>): Promise<R | undefined>;
77
+ export { AbloContentionError };
@@ -0,0 +1,87 @@
1
+ /**
2
+ * The functional update — `ablo.<model>.update(id, current => next)`.
3
+ *
4
+ * This is the "just works under contention" surface. The developer expresses
5
+ * ONLY their intent — "given the latest row, here is the next state" — and the
6
+ * SDK owns everything else: read the fresh row + its watermark, run the updater,
7
+ * write it as a compare-and-swap against that watermark, and on any concurrent
8
+ * write re-read → recompute → retry. No claim, no identity, no transport
9
+ * awareness, and no `stale_context` / `claim_*` error codes ever reach the
10
+ * caller. The write either lands or, at the extreme, throws a single
11
+ * {@link AbloContentionError} after the reconcile budget is spent.
12
+ *
13
+ * Correctness comes from the `readAt` watermark + `onStale: 'reject'`
14
+ * (optimistic concurrency / compare-and-swap), NOT from participant identity —
15
+ * which is why it is immune to the shared-credential silent-clobber footgun and
16
+ * behaves identically on both transports. The HTTP and WebSocket clients inject
17
+ * the same two thunks ({@link ReconcileTransport}); the loop below is shared, so
18
+ * the guarantee can never drift between them — only the mechanism differs.
19
+ *
20
+ * The mental model is React's `setState(prev => next)`: pass a function of the
21
+ * current state, the runtime owns reconciliation.
22
+ */
23
+ import { AbloError, AbloNotFoundError, AbloStaleContextError, AbloClaimedError, AbloContentionError, } from '../errors.js';
24
+ /** Reconcile rounds before a hot row is declared permanently contended. */
25
+ export const DEFAULT_CONTENTION_RETRIES = 16;
26
+ /**
27
+ * Does this thrown error mean "another writer moved the row — re-read and
28
+ * retry" rather than a genuine failure to surface? These are the optimistic-
29
+ * concurrency signals the functional update reconciles against:
30
+ * - `stale_context` — our `readAt` watermark was overtaken by a concurrent write
31
+ * - `claim_lost` — a holder preempted us (e.g. a human under `humansOverwrite`)
32
+ * - `claim_queued` — a holder is actively editing the row right now
33
+ */
34
+ export function isReconcilableConflict(err) {
35
+ if (err instanceof AbloStaleContextError)
36
+ return true;
37
+ if (err instanceof AbloClaimedError) {
38
+ return err.code === 'claim_lost' || err.code === 'claim_queued';
39
+ }
40
+ return false;
41
+ }
42
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
43
+ /**
44
+ * Jittered backoff so N reconcilers retrying at once don't lock-step straight
45
+ * back into the same collision. Bounded; grows mildly with the attempt.
46
+ */
47
+ function backoffMs(attempt) {
48
+ return 60 + attempt * 40 + Math.floor(Math.random() * 60);
49
+ }
50
+ /**
51
+ * Run the read-fresh → compute → compare-and-swap → reconcile loop. Shared by
52
+ * both transports so the guarantee is provably identical. Returns the write's
53
+ * result, or `undefined` when the updater opted out of writing.
54
+ */
55
+ export async function reconcileFunctionalUpdate(updater, options, transport) {
56
+ const retries = options?.retries ?? DEFAULT_CONTENTION_RETRIES;
57
+ let lastConflict;
58
+ for (let attempt = 0; attempt <= retries; attempt++) {
59
+ if (options?.signal?.aborted) {
60
+ throw new AbloError(`Update of ${transport.model}/${transport.id} was aborted before it landed.`, { code: 'update_aborted' });
61
+ }
62
+ const { data, stamp } = await transport.readFresh();
63
+ if (data == null) {
64
+ throw new AbloNotFoundError(`Cannot update ${transport.model}/${transport.id}: it does not exist (or is ` +
65
+ `outside this credential's scope).`, [transport.id]);
66
+ }
67
+ const patch = await updater(data);
68
+ if (patch == null)
69
+ return undefined; // updater opted out after reading fresh
70
+ try {
71
+ return await transport.writeNext(patch, stamp);
72
+ }
73
+ catch (err) {
74
+ if (!isReconcilableConflict(err))
75
+ throw err; // genuine failure — surface it
76
+ lastConflict = err;
77
+ if (attempt < retries)
78
+ await sleep(backoffMs(attempt));
79
+ }
80
+ }
81
+ throw new AbloContentionError(transport.model, transport.id, retries + 1, {
82
+ cause: lastConflict,
83
+ });
84
+ }
85
+ // Re-exported so call sites import the loop + its terminal error from one place;
86
+ // the class itself lives with the rest of the hierarchy in `errors.ts`.
87
+ export { AbloContentionError };
@@ -26,6 +26,7 @@ import { type AbloApiClientOptions } from './ApiClient.js';
26
26
  import type { CommitReceipt, CommitResource, HttpClaimApi, ModelRead, ModelReadOptions, CreateSessionParams, AbloSession } from './Ablo.js';
27
27
  import type { ModelCreateParams, ModelDeleteParams, ServerReadOptions, ModelRetrieveParams, ModelUpdateParams } from './createModelProxy.js';
28
28
  import type { Schema, SchemaRecord, InferModel, InferCreate } from '../schema/schema.js';
29
+ import type { ModelUpdater, ContentionOptions } from './functionalUpdate.js';
29
30
  export interface AbloHttpClientOptions<S extends SchemaRecord> extends Omit<AbloApiClientOptions, 'schema'> {
30
31
  /** The schema — used for TYPING only (typed model proxies); never sent or used at runtime. */
31
32
  readonly schema: Schema<S>;
@@ -48,6 +49,14 @@ export interface HttpModelClient<T, C = T> {
48
49
  list(options?: ServerReadOptions<T>): Promise<T[]>;
49
50
  create(params: ModelCreateParams<T, C>): Promise<CommitReceipt>;
50
51
  update(params: ModelUpdateParams<C>): Promise<CommitReceipt>;
52
+ /**
53
+ * Functional update under contention — `update(id, current => next)`, the
54
+ * `setState(prev => next)` of the data layer. The SDK reads the freshest row,
55
+ * runs your updater, writes it as a compare-and-swap, and re-reads + re-runs on
56
+ * any concurrent write. No claim, no identity, no conflict codes: the write
57
+ * lands or throws `AbloContentionError`. Return `null`/`undefined` to skip.
58
+ */
59
+ update(id: string, updater: ModelUpdater<T>, options?: ContentionOptions): Promise<CommitReceipt | undefined>;
51
60
  delete(params: ModelDeleteParams<T>): Promise<CommitReceipt>;
52
61
  claim: HttpClaimApi<T>;
53
62
  }
@@ -159,6 +159,8 @@ export declare const ERROR_CODES: {
159
159
  readonly model_claim_not_configured: ErrorCodeSpec;
160
160
  readonly model_watch_not_configured: ErrorCodeSpec;
161
161
  readonly stale_context: ErrorCodeSpec;
162
+ readonly contention_exhausted: ErrorCodeSpec;
163
+ readonly update_aborted: ErrorCodeSpec;
162
164
  readonly idempotency_conflict: ErrorCodeSpec;
163
165
  readonly idempotency_key_too_long: ErrorCodeSpec;
164
166
  readonly write_options_invalid: ErrorCodeSpec;
@@ -163,6 +163,12 @@ export const ERROR_CODES = {
163
163
  model_watch_not_configured: client('claim', 'watch() opens a presence/claim subscription and needs a live WebSocket, so it is unavailable on the HTTP transport and on model proxies built without a socket. Use the standard Ablo({ schema, apiKey }) client (default WebSocket transport).'),
164
164
  // ── stale context / idempotency (409) ──────────────────────────────
165
165
  stale_context: wire('conflict', 409, true, 'The write carried a readAt watermark that is now stale; re-read and retry.'),
166
+ // Raised by the functional `update(id, current => next)` form once its
167
+ // internal reconcile budget is exhausted — the row stayed continuously
168
+ // contended. Client-side: the SDK already retried; the caller decides whether
169
+ // to back off, raise `retries`, or move the row to the WebSocket transport.
170
+ contention_exhausted: client('conflict', 'A functional update could not land after exhausting its reconcile budget; the row stayed continuously contended.'),
171
+ update_aborted: client('conflict', 'The functional update reconcile loop was aborted via its AbortSignal before the write landed.'),
166
172
  idempotency_conflict: wire('conflict', 409, false, 'The same Idempotency-Key was reused with a different request body.'),
167
173
  idempotency_key_too_long: wire('validation', 400, false, 'The supplied Idempotency-Key exceeds the maximum length.'),
168
174
  // ── validation (400 / 422) ─────────────────────────────────────────
package/dist/errors.d.ts CHANGED
@@ -180,6 +180,29 @@ export declare class AbloStaleContextError extends AbloError {
180
180
  }>;
181
181
  });
182
182
  }
183
+ /**
184
+ * The functional `update(id, current => next)` form exhausted its reconcile
185
+ * budget — the row stayed continuously contended (a hot row under sustained
186
+ * concurrent writes), so no attempt could land a compare-and-swap.
187
+ *
188
+ * This is the ONLY coordination concept the functional update ever surfaces, and
189
+ * only at the extreme: the SDK has already read-fresh → recomputed → retried on
190
+ * every stale/claim conflict on the caller's behalf. Catch it to back off and
191
+ * retry later, raise the `retries` budget, or move that row to the WebSocket
192
+ * transport (which parks writers in a fair FIFO queue instead of racing). The
193
+ * last underlying conflict that drove the final retry is on `.cause`.
194
+ */
195
+ export declare class AbloContentionError extends AbloError {
196
+ readonly type: "AbloContentionError";
197
+ /** The contended model + row that could not be written. */
198
+ readonly model: string;
199
+ readonly id: string;
200
+ /** How many reconcile rounds were attempted before giving up. */
201
+ readonly attempts: number;
202
+ constructor(model: string, id: string, attempts: number, options?: {
203
+ cause?: unknown;
204
+ });
205
+ }
183
206
  export interface ClaimContext {
184
207
  readonly id?: string;
185
208
  readonly claimId?: string;
package/dist/errors.js CHANGED
@@ -196,6 +196,37 @@ export class AbloStaleContextError extends AbloError {
196
196
  this.conflicts = options.conflicts;
197
197
  }
198
198
  }
199
+ /**
200
+ * The functional `update(id, current => next)` form exhausted its reconcile
201
+ * budget — the row stayed continuously contended (a hot row under sustained
202
+ * concurrent writes), so no attempt could land a compare-and-swap.
203
+ *
204
+ * This is the ONLY coordination concept the functional update ever surfaces, and
205
+ * only at the extreme: the SDK has already read-fresh → recomputed → retried on
206
+ * every stale/claim conflict on the caller's behalf. Catch it to back off and
207
+ * retry later, raise the `retries` budget, or move that row to the WebSocket
208
+ * transport (which parks writers in a fair FIFO queue instead of racing). The
209
+ * last underlying conflict that drove the final retry is on `.cause`.
210
+ */
211
+ export class AbloContentionError extends AbloError {
212
+ type = 'AbloContentionError';
213
+ /** The contended model + row that could not be written. */
214
+ model;
215
+ id;
216
+ /** How many reconcile rounds were attempted before giving up. */
217
+ attempts;
218
+ constructor(model, id, attempts, options) {
219
+ super(`Could not update ${model}/${id} after ${attempts} attempts — the row stayed ` +
220
+ `continuously contended, so nothing was written. Retry later, raise \`retries\`, ` +
221
+ `or use the WebSocket transport for a fair FIFO queue.`, {
222
+ code: 'contention_exhausted',
223
+ ...(options?.cause !== undefined ? { cause: options.cause } : {}),
224
+ });
225
+ this.model = model;
226
+ this.id = id;
227
+ this.attempts = attempts;
228
+ }
229
+ }
199
230
  function claimReason(claim) {
200
231
  if (!claim)
201
232
  return undefined;
package/dist/index.d.ts CHANGED
@@ -49,6 +49,8 @@
49
49
  */
50
50
  export { Ablo } from './client/Ablo.js';
51
51
  export type { MutationExecutor } from './interfaces/index.js';
52
+ export type { ModelUpdater, ContentionOptions } from './client/functionalUpdate.js';
53
+ export { DEFAULT_CONTENTION_RETRIES } from './client/functionalUpdate.js';
52
54
  export type { HttpClaimApi, InternalAbloOptions } from './client/Ablo.js';
53
55
  export { type AbloHttpClientOptions, type AbloHttpClient, type HttpModelClient, } from './client/httpClient.js';
54
56
  export { ABLO_DEFAULT_BASE_URL, ABLO_HOSTED_API_DOMAIN, ABLO_HOSTED_HTTP_BASE_URL, normalizeAbloHostedBaseUrl, } from './client/auth.js';
@@ -59,7 +61,7 @@ export default Ablo;
59
61
  export { dataSource, abloSource, sourceEventForOperation, signAbloSourceRequest, verifyAbloSourceRequest, } from './source/index.js';
60
62
  export { createSourceConnector, type SourceConnector, type SourceConnectorOptions, type ConnectorStatus, } from './source/connector.js';
61
63
  export { defaultPolicy, capabilityPreemptPolicy, interpretConflictAxis } from './policy/index.js';
62
- export { SyncSessionError, AbloError, AbloAuthenticationError, AbloPermissionError, AbloRateLimitError, AbloIdempotencyError, AbloConnectionError, AbloValidationError, AbloNotFoundError, AbloServerError, AbloStaleContextError, AbloClaimedError, CapabilityError, translateHttpError, hasWireCode, errorFromWire, toAbloError, ERROR_CODES, ERROR_CONTRACT_VERSION, errorCodeSpec, isRetryableCode, classifyRecovery, recoveryClassSchema, RECOVERY_CLASSES, } from './errors.js';
64
+ export { SyncSessionError, AbloError, AbloAuthenticationError, AbloPermissionError, AbloRateLimitError, AbloIdempotencyError, AbloConnectionError, AbloValidationError, AbloNotFoundError, AbloServerError, AbloStaleContextError, AbloClaimedError, AbloContentionError, CapabilityError, translateHttpError, hasWireCode, errorFromWire, toAbloError, ERROR_CODES, ERROR_CONTRACT_VERSION, errorCodeSpec, isRetryableCode, classifyRecovery, recoveryClassSchema, RECOVERY_CLASSES, } from './errors.js';
63
65
  export type { CommitReceipt, RequiredCapability } from './errors.js';
64
66
  export type { ErrorCode, WireErrorCode, ErrorCategory, ErrorCodeSpec, RecoveryClass } from './errors.js';
65
67
  export { errorEnvelope, statusForType } from './wire/errorEnvelope.js';
package/dist/index.js CHANGED
@@ -55,6 +55,7 @@
55
55
  // `import Ablo from '@abloatai/ablo'` works; named export so
56
56
  // `import { Ablo }` also compiles.
57
57
  export { Ablo } from './client/Ablo.js';
58
+ export { DEFAULT_CONTENTION_RETRIES } from './client/functionalUpdate.js';
58
59
  export { ABLO_DEFAULT_BASE_URL, ABLO_HOSTED_API_DOMAIN, ABLO_HOSTED_HTTP_BASE_URL, normalizeAbloHostedBaseUrl, } from './client/auth.js';
59
60
  // Participant types live under `Ablo.Participant.*` —
60
61
  // `Ablo.Participant.Joined`, `Ablo.Participant.Manager`,
@@ -84,7 +85,7 @@ export { defaultPolicy, capabilityPreemptPolicy, interpretConflictAxis } from '.
84
85
  // Typed error hierarchy — Stripe-style. One import gets every class
85
86
  // consumers need to discriminate failures (`e instanceof AbloX` or
86
87
  // `e.type === 'AbloX'`) plus the HTTP-response translator.
87
- export { SyncSessionError, AbloError, AbloAuthenticationError, AbloPermissionError, AbloRateLimitError, AbloIdempotencyError, AbloConnectionError, AbloValidationError, AbloNotFoundError, AbloServerError, AbloStaleContextError, AbloClaimedError, CapabilityError, translateHttpError, hasWireCode, errorFromWire, toAbloError, ERROR_CODES, ERROR_CONTRACT_VERSION, errorCodeSpec, isRetryableCode, classifyRecovery, recoveryClassSchema, RECOVERY_CLASSES, } from './errors.js';
88
+ export { SyncSessionError, AbloError, AbloAuthenticationError, AbloPermissionError, AbloRateLimitError, AbloIdempotencyError, AbloConnectionError, AbloValidationError, AbloNotFoundError, AbloServerError, AbloStaleContextError, AbloClaimedError, AbloContentionError, CapabilityError, translateHttpError, hasWireCode, errorFromWire, toAbloError, ERROR_CODES, ERROR_CONTRACT_VERSION, errorCodeSpec, isRetryableCode, classifyRecovery, recoveryClassSchema, RECOVERY_CLASSES, } from './errors.js';
88
89
  // Canonical wire-egress contract (dependency-free): the error envelope shape +
89
90
  // the AbloError-subclass→HTTP-status table. Re-exported so server consumers
90
91
  // (e.g. apps/sync-server, which keeps its own self-contained copy) can assert
@@ -7,9 +7,25 @@
7
7
 
8
8
  Coordinate long-running work on a row so humans and agents don't clobber each
9
9
  other. Most writes need none of this — a plain `ablo.<model>.update({ id, data })`
10
- is **last-write-wins** by default. For lost-update detection, take a claim or pass
11
- `readAt` / `onStale` yourself. Reach for `claim` only when you'll **hold a row
12
- across a slow gap** (read LLM call write).
10
+ is **last-write-wins** by default.
11
+
12
+ > **Read-modify-write under contention? Use the functional update it owns all
13
+ > of this for you.** When the new value is computed from the current one (the
14
+ > shape that races), pass a function instead of data:
15
+ >
16
+ > ```ts
17
+ > await ablo.documents.update(id, (current) => ({ content: revise(current.content) }));
18
+ > ```
19
+ >
20
+ > The SDK reads the freshest row, runs your updater, writes it as a
21
+ > compare-and-swap, and re-reads + re-runs on any concurrent write — no claim, no
22
+ > per-agent identity, no `stale_context` / `claim_*` codes, no retry loop, and no
23
+ > way to silently clobber. See [Functional update](#functional-update).
24
+ > The rest of this page is the **low-level** machinery the functional form is
25
+ > built on — reach for it directly only when you need to **hold a row across a
26
+ > slow gap with side effects** (e.g. presence badges, multi-row handles), or want
27
+ > explicit FIFO ordering. For lost-update detection on a single read-modify-write,
28
+ > prefer the functional form over hand-rolling `claim` / `readAt` / `onStale`.
13
29
 
14
30
  Claims don't lock. If another writer holds the row, `claim` waits for them,
15
31
  re-reads the fresh row, then hands it to you — so two writers serialize instead
@@ -19,13 +35,34 @@ Reads stay open: reading a claimed row is allowed unless the caller explicitly
19
35
  asks for claimed gating. A claim carries a TTL so a crashed holder is
20
36
  auto-released and the queue advances.
21
37
 
38
+ > **Transport: WebSocket queues, HTTP reconciles.** The "blocks until promoted"
39
+ > behaviour above needs a live socket to wake the waiter, so it applies to the
40
+ > **WebSocket client only**. On the **stateless HTTP client**
41
+ > (`Ablo({ transport: 'http' })` — the transport server-side agents use) there is
42
+ > nothing to park a waiter, so a contended `claim` does **not** wait: it acquires
43
+ > immediately, or rejects right away with `AbloClaimedError('claim_queued')`
44
+ > (`queue: true`, the default) / `AbloClaimedError('entity_claimed')`
45
+ > (`queue: false`). And because short claim→write→release cycles rarely overlap,
46
+ > concurrent HTTP agents usually all acquire, then collide at **write** time — the
47
+ > first write wins and the rest get `AbloStaleContextError('stale_context')`.
48
+ > The coordination pattern over HTTP is therefore a **reconcile loop**, not a
49
+ > queue: catch `claim_queued` / `claim_lost` / `stale_context`, re-read fresh,
50
+ > regenerate, retry. See [Errors](#errors) and the loop sketch there.
51
+
22
52
  This reference opens with [the model](#the-model--three-layers-one-decision) — the
23
53
  one answer to "how do two agents not clobber each other" — then covers the
24
54
  [claim state object](#the-claim-state-object), the SDK [methods](#methods)
25
55
  (`claim` · `claim.state` · `claim.queue` · `claim.release` · [writing under a
26
56
  claim](#writing-under-a-claim)), and the [errors](#errors) you can catch.
27
57
 
28
- > **Before anything else: one identity per agent.** Coordination excludes
58
+ > **Before anything else: one identity per agent (for the low-level claim
59
+ > path).** The [functional update](#functional-update) does
60
+ > **not** need this — its safety comes from the row watermark (compare-and-swap),
61
+ > not from participant identity, so it's correct even when many workers share one
62
+ > `sk_`. The rule below applies when you take **explicit `claim`s** for FIFO
63
+ > exclusion or presence.
64
+ >
65
+ > Coordination excludes
29
66
  > *participants*, and a participant **is the key**, not the client object. The
30
67
  > server derives identity from the credential's scope — so **N clients sharing
31
68
  > one `sk_`/`ek_` are one participant**: they all see the same `heldBy`, never
@@ -453,6 +490,7 @@ inspect the `code`.
453
490
  | error | `code` | thrown when | carries |
454
491
  |---|---|---|---|
455
492
  | `AbloClaimedError` | `claim_lost` | A held/queued claim was taken away (holder TTL lapse on disconnect, or revoke) while you were holding or waiting. | `claims?` |
493
+ | `AbloClaimedError` | `claim_queued` | **HTTP transport only.** A contended `claim` (default `queue: true`) could not block-wait for the lease (no socket), so it rejected immediately instead of queueing. Retryable — re-attempt the claim. | `claims?` |
456
494
  | `AbloClaimedError` | `grant_timeout` | The optional `timeoutMs` elapsed while you were still queued for a grant. | `claims?` |
457
495
  | `AbloClaimedError` | `queue_too_deep` | `claim` was passed `maxQueueDepth` and the wait line was already that deep when you tried to join — fail-fast instead of waiting. | `claims?` |
458
496
  | `AbloClaimedError` | `claim_conflict` | An `update`/`delete` targets a row another participant holds — the server's pre-commit check rejected it. | — |
@@ -479,3 +517,138 @@ try {
479
517
  } else throw err;
480
518
  }
481
519
  ```
520
+
521
+ ### The reconcile loop, by hand (rarely needed)
522
+
523
+ This is the loop the [functional update](#functional-update) runs for you. Write
524
+ it yourself only when one attempt isn't a pure re-read-and-recompute — e.g. you
525
+ must hold an explicit `claim` for a presence badge across the gap, or coordinate
526
+ several rows in one handle. For a single read-modify-write, use the functional
527
+ form instead of this.
528
+
529
+ ```ts
530
+ const RETRYABLE = (e: unknown) =>
531
+ e instanceof AbloStaleContextError || // row moved under our write
532
+ (e instanceof AbloClaimedError &&
533
+ (e.code === 'claim_queued' || // someone holds it right now
534
+ e.code === 'claim_lost')); // a human preempted us
535
+
536
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
537
+ try {
538
+ await using claim = await ablo.reports.claim({ id }); // acquire or claim_queued
539
+ const fresh = claim.data; // read at the lease moment
540
+ const next = await generate(fresh); // slow gap
541
+ await ablo.reports.update({ id, data: next, claim }); // first writer wins
542
+ break; // landed
543
+ } catch (err) {
544
+ if (!RETRYABLE(err) || attempt === MAX_ATTEMPTS) throw err;
545
+ await sleep(80 + attempt * 40 + Math.random() * 60); // jitter: don't lock-step
546
+ }
547
+ }
548
+ ```
549
+
550
+ The loop, not a queue, is the coordination mechanism over HTTP. On the WebSocket
551
+ client the same code works but rarely loops, because `claim` blocks in the FIFO
552
+ line instead of throwing `claim_queued`.
553
+
554
+ ---
555
+
556
+ ## Functional update
557
+
558
+ The read-modify-write surface that owns the loop above so you never write it.
559
+ Pass a **function of the current state** instead of fixed `data` — the
560
+ `setState(prev => next)` of the data layer:
561
+
562
+ ```ts
563
+ const row = await ablo.documents.update(documentId, (current) => ({
564
+ content: revise(current.content), // "given the latest, here is the next"
565
+ }));
566
+ ```
567
+
568
+ What the SDK does on every call, on **both transports** (one shared loop, so the
569
+ guarantee can't drift): read the freshest row + its watermark → run your updater
570
+ → write it as a **compare-and-swap** against that watermark (`readAt` +
571
+ `onStale: 'reject'`) → on any concurrent write, re-read and re-run. Correctness
572
+ comes from the watermark, **not** from participant identity — so it's immune to
573
+ the shared-credential clobber footgun and needs no `claim` and no per-agent `rk_`.
574
+
575
+ Nothing about claims, identity, or conflict codes surfaces. The call either
576
+ returns the reconciled row (a `CommitReceipt` on the HTTP client; the row on the
577
+ WebSocket client) or, at the extreme, throws **one** error:
578
+
579
+ ```ts
580
+ import { AbloContentionError } from '@abloatai/ablo';
581
+
582
+ try {
583
+ await ablo.documents.update(id, (cur) => ({ content: revise(cur.content) }), {
584
+ retries: 16, // reconcile rounds before giving up (default 16)
585
+ signal: req.signal, // optional: abort the loop if the request is cancelled
586
+ });
587
+ } catch (err) {
588
+ if (err instanceof AbloContentionError) {
589
+ // The row stayed continuously contended past the budget — nothing was
590
+ // written. err.attempts, err.cause (the last conflict). Back off, raise
591
+ // `retries`, or move the row to the WebSocket transport (fair FIFO queue).
592
+ }
593
+ }
594
+ ```
595
+
596
+ Return `null` / `undefined` from the updater to **skip the write** after seeing
597
+ fresh state (the call resolves to `undefined`). A missing row throws
598
+ `AbloNotFoundError`; a genuine failure (validation, constraint, permission)
599
+ propagates immediately without retrying.
600
+
601
+ ### How `create` and `delete` relate
602
+
603
+ They don't get a functional form — and shouldn't. The functional form exists
604
+ because `update` is the only verb whose **next state is a function of the current
605
+ state**, which is the shape that races. The other two aren't read-modify-write:
606
+
607
+ | Verb | Functional form? | Why | Its "just works" property |
608
+ | --- | --- | --- | --- |
609
+ | `update` | **yes** — `update(id, current => next)` | next value depends on the current one (lost-update hazard) | compare-and-swap + reconcile |
610
+ | `create` | no — `create({ data, id? })` | no prior state to read; the hazard is *id collision*, a terminal `unique_violation`, not a lost update | **idempotency** — stable id / `idempotencyKey` makes a retried create safe |
611
+ | `delete` | no — `delete({ id })` | no resulting state to compute; "make it not exist" is unchanged by concurrent edits, and delete is idempotent | naturally idempotent |
612
+
613
+ The same reason React has `setState(prev => next)` but no functional mount /
614
+ unmount. A *conditional* delete ("only if unchanged since I read it") is the one
615
+ niche case — express it with an explicit `claim` / `readAt` on `delete({ id })`,
616
+ not a function.
617
+
618
+ ---
619
+
620
+ ## Observability — the `ClaimLog`
621
+
622
+ Coordination you can't see is coordination you can't debug. Pass an
623
+ `observability` provider to `Ablo({ ... })` and the client reports every claim
624
+ lifecycle event and stale-write collision it sees. The batteries-included
625
+ provider is `ClaimLog`:
626
+
627
+ ```ts
628
+ import Ablo, { ClaimLog } from '@abloatai/ablo';
629
+
630
+ const log = new ClaimLog();
631
+ const ablo = Ablo({ schema, apiKey, observability: log });
632
+ // …run your agents…
633
+
634
+ log.entries // full ordered timeline, one readable line each
635
+ log.collisions() // just the collisions: rejected/lost claims + stale writes
636
+ log.toString() // pretty, greppable timeline to print
637
+ log.onChange(fn) // reactive subscribe → drive a live activity feed / useSyncExternalStore
638
+ ```
639
+
640
+ `collisions()` is the eval primitive — `expect(log.collisions()).toHaveLength(0)`
641
+ asserts "no one stepped on anyone." Each line names the row it touched, e.g.
642
+ `conflict: tx … — 1 row(s) changed underneath: reports/r1`.
643
+
644
+ `ClaimLog` implements the full `SyncObservabilityProvider`, so it drops straight
645
+ into the `observability` slot; spread `noopObservability` if you only want to
646
+ override a few hooks (e.g. forward to Sentry/OTel). Exports: `ClaimLog`,
647
+ `formatClaim`, `formatConflict`, `noopObservability`, and the types `ClaimEvent`,
648
+ `ConflictEvent`, `ClaimLogEntry`, `SyncObservabilityProvider`.
649
+
650
+ > **Both transports, from 0.21.0.** Observability fires on the WebSocket **and**
651
+ > the stateless HTTP transport (claim acquired + coordination-conflict
652
+ > rejections, on every write door). Before 0.21.0 only WebSocket emitted, so a
653
+ > `ClaimLog` on an HTTP client — e.g. a headless server-agent eval — stayed
654
+ > silent even though coordination still worked.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abloatai/ablo",
3
- "version": "0.21.0",
3
+ "version": "0.22.1",
4
4
  "description": "The Collaboration Layer For AI Agents",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",