@abloatai/ablo 0.20.2 → 0.22.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/CHANGELOG.md +33 -0
- package/dist/cli.cjs +6 -0
- package/dist/client/Ablo.d.ts +12 -0
- package/dist/client/Ablo.js +40 -17
- package/dist/client/ApiClient.d.ts +7 -0
- package/dist/client/ApiClient.js +110 -20
- package/dist/client/auth.js +7 -1
- package/dist/client/createModelProxy.d.ts +12 -0
- package/dist/client/createModelProxy.js +108 -48
- package/dist/client/functionalUpdate.d.ts +77 -0
- package/dist/client/functionalUpdate.js +87 -0
- package/dist/errorCodes.d.ts +2 -0
- package/dist/errorCodes.js +6 -0
- package/dist/errors.d.ts +23 -0
- package/dist/errors.js +31 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +2 -1
- package/dist/sync/SyncWebSocket.js +30 -0
- package/docs/coordination.md +177 -4
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,38 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.22.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- Add the functional update form: `ablo.<model>.update(id, current => next)`.
|
|
8
|
+
|
|
9
|
+
The `setState(prev => next)` of the data layer. Pass a function of the latest
|
|
10
|
+
row and the SDK owns everything that used to be the caller's problem under
|
|
11
|
+
contention: it reads the freshest row, runs your updater, writes it as a
|
|
12
|
+
compare-and-swap against the row's watermark, and re-reads + re-runs on any
|
|
13
|
+
concurrent write. No claim, no per-participant identity, and no
|
|
14
|
+
`stale_context` / `claim_*` codes ever surface — correctness rides on the
|
|
15
|
+
watermark, so concurrent writers reconcile instead of silently clobbering. The
|
|
16
|
+
write either lands or throws a single `AbloContentionError` once its reconcile
|
|
17
|
+
budget is spent.
|
|
18
|
+
|
|
19
|
+
Identical guarantee on both transports (HTTP and WebSocket share one reconcile
|
|
20
|
+
loop). Return `null`/`undefined` from the updater to skip the write. Tune with
|
|
21
|
+
`{ retries, signal }`. Exports: `AbloContentionError`, `ModelUpdater`,
|
|
22
|
+
`ContentionOptions`, `DEFAULT_CONTENTION_RETRIES`.
|
|
23
|
+
|
|
24
|
+
The classic `update({ id, data })` form is unchanged.
|
|
25
|
+
|
|
26
|
+
## 0.21.0
|
|
27
|
+
|
|
28
|
+
### Minor Changes
|
|
29
|
+
|
|
30
|
+
- Coordination observability now fires on BOTH transports. Previously `captureClaim`/`captureConflict` were emitted only by the WebSocket transport, so a `ClaimLog` (or any `observability` provider) handed to a stateless HTTP client — the transport server-side agents use via `Ablo({ transport: 'http' })` — stayed empty, and even on WebSocket a hard commit rejection went unrecorded. Fixed:
|
|
31
|
+
- **HTTP transport now emits.** `Ablo({ transport: 'http', observability })` records `claim` acquisition (`captureClaim`) and coordination-conflict rejections (`captureConflict`, code `stale_context` / `claim_conflict` / `entity_claimed`) on BOTH HTTP write doors (`commits.create` and per-model `ablo.<model>.update/create/delete`). The conflict names the collided rows — from the server's `conflicts` detail when present, otherwise the ops the write attempted. `observability` is now a documented option on the HTTP client.
|
|
32
|
+
- **WebSocket rejections now recorded.** A commit rejected by the conflict policy (`mutation_result` `success: false` with a coordination code) now calls `captureConflict`, mirroring the existing notify-on-success path. So `ClaimLog.collisions()` no longer silently misses rejected writes.
|
|
33
|
+
|
|
34
|
+
Net effect: a `ClaimLog` behaves identically regardless of transport — `entries`, `collisions()`, and `onChange` reflect the real coordination timeline for headless agent evals and live activity feeds alike.
|
|
35
|
+
|
|
3
36
|
## 0.20.2
|
|
4
37
|
|
|
5
38
|
### Patch 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) ─────────────────────────────────────────
|
package/dist/client/Ablo.d.ts
CHANGED
|
@@ -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>;
|
package/dist/client/Ablo.js
CHANGED
|
@@ -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
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
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
|
|
1557
|
-
|
|
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: '
|
|
1589
|
+
action: 'create',
|
|
1568
1590
|
model: name,
|
|
1569
|
-
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({
|
|
@@ -9,9 +9,16 @@ import type { AbloOptions, CommitResource, ClaimCreateOptions, ClaimWaitOptions,
|
|
|
9
9
|
import type { SchemaRecord } from '../schema/schema.js';
|
|
10
10
|
import type { Duration } from '../utils/duration.js';
|
|
11
11
|
import type { Claim } from '../types/streams.js';
|
|
12
|
+
import type { SyncObservabilityProvider } from '../interfaces/index.js';
|
|
12
13
|
export type AbloApiClientOptions = Omit<AbloOptions, 'schema'> & {
|
|
13
14
|
readonly schema?: null | undefined;
|
|
14
15
|
readonly bootstrapBaseUrl?: string | undefined;
|
|
16
|
+
/**
|
|
17
|
+
* Observability provider forwarded from `Ablo({ observability })`. The HTTP
|
|
18
|
+
* transport emits the same claim/conflict seams as the WS transport so a
|
|
19
|
+
* `ClaimLog` works identically for headless (server-agent) evals.
|
|
20
|
+
*/
|
|
21
|
+
readonly observability?: SyncObservabilityProvider;
|
|
15
22
|
};
|
|
16
23
|
export interface AbloApiClaims {
|
|
17
24
|
create(options: ClaimCreateOptions): Promise<Claim>;
|
package/dist/client/ApiClient.js
CHANGED
|
@@ -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';
|
|
@@ -28,6 +29,38 @@ export function createProtocolClient(options) {
|
|
|
28
29
|
databaseUrl: configuredDatabaseUrl,
|
|
29
30
|
dangerouslyAllowBrowser: options.dangerouslyAllowBrowser,
|
|
30
31
|
});
|
|
32
|
+
// Observability seam for the STATELESS HTTP transport. The WS transport emits
|
|
33
|
+
// claim/conflict events from SyncWebSocket; the HTTP path (server-side agents,
|
|
34
|
+
// `transport: 'http'`) emitted nothing, so a `ClaimLog` handed to a headless
|
|
35
|
+
// agent eval stayed empty. Mirror the two WS seams here: claim acquired +
|
|
36
|
+
// coordination-conflict rejection. No-op when no provider is configured.
|
|
37
|
+
const observability = options.observability;
|
|
38
|
+
// Shared by the two HTTP write doors (`commits.create` + per-model
|
|
39
|
+
// `mutateModel`): a rejected write whose code is a coordination conflict is
|
|
40
|
+
// the collision ClaimLog exists to surface. Prefer the server's `conflicts`
|
|
41
|
+
// detail (carried on the typed error / envelope); fall back to the rows the
|
|
42
|
+
// caller tried to write so the collision always names a target. Inert without
|
|
43
|
+
// a provider or for non-conflict errors. Never throws (capture is best-effort).
|
|
44
|
+
const recordCoordinationConflict = (error, clientTxId, fallbackRows) => {
|
|
45
|
+
if (!observability)
|
|
46
|
+
return;
|
|
47
|
+
const code = error?.code;
|
|
48
|
+
const isConflict = code === 'stale_context' ||
|
|
49
|
+
code === 'claim_conflict' ||
|
|
50
|
+
code === 'entity_claimed' ||
|
|
51
|
+
(typeof code === 'string' && code.startsWith('policy:'));
|
|
52
|
+
if (!isConflict)
|
|
53
|
+
return;
|
|
54
|
+
const rawConflicts = error?.conflicts;
|
|
55
|
+
const rows = Array.isArray(rawConflicts) && rawConflicts.length > 0
|
|
56
|
+
? rawConflicts.map((r) => ({
|
|
57
|
+
model: typeof r.model === 'string' ? r.model : 'unknown',
|
|
58
|
+
id: typeof r.id === 'string' ? r.id : 'unknown',
|
|
59
|
+
fields: [],
|
|
60
|
+
}))
|
|
61
|
+
: fallbackRows.map((r) => ({ model: r.model, id: r.id, fields: [] }));
|
|
62
|
+
observability.captureConflict({ clientTxId, rows });
|
|
63
|
+
};
|
|
31
64
|
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
32
65
|
if (typeof fetchImpl !== 'function') {
|
|
33
66
|
throw new AbloConnectionError('Ablo API client requires a fetch implementation. Pass `fetch` in Ablo({ ... }) for this runtime.', { code: 'fetch_unavailable' });
|
|
@@ -256,16 +289,29 @@ export function createProtocolClient(options) {
|
|
|
256
289
|
readAt: commitOptions.readAt ?? claim?.readAt ?? null,
|
|
257
290
|
onStale: commitOptions.onStale ?? (claim?.readAt !== undefined ? 'reject' : null),
|
|
258
291
|
});
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
clientTxId,
|
|
292
|
+
let body;
|
|
293
|
+
try {
|
|
294
|
+
body = await requestJson('/v1/commits', {
|
|
295
|
+
method: 'POST',
|
|
264
296
|
idempotencyKey: clientTxId,
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
297
|
+
body: JSON.stringify({
|
|
298
|
+
clientTxId,
|
|
299
|
+
idempotencyKey: clientTxId,
|
|
300
|
+
claim: normalizeClaimId(commitOptions.claimRef) ?? claim?.id,
|
|
301
|
+
operations,
|
|
302
|
+
}),
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
catch (error) {
|
|
306
|
+
// Coordination collision over HTTP — surface it to observability on the
|
|
307
|
+
// same footing as the WS transport, then rethrow unchanged. Fall back to
|
|
308
|
+
// the ops we tried to write so the collision always names a row.
|
|
309
|
+
recordCoordinationConflict(error, clientTxId, operations.map((o) => ({
|
|
310
|
+
model: typeof o.model === 'string' ? o.model : 'unknown',
|
|
311
|
+
id: typeof o.id === 'string' ? o.id : 'unknown',
|
|
312
|
+
})));
|
|
313
|
+
throw error;
|
|
314
|
+
}
|
|
269
315
|
// `requestJson` throws via `translateHttpError` on any non-2xx,
|
|
270
316
|
// so reaching here implies success. Narrow `status` to the
|
|
271
317
|
// `CommitWait`-compatible subset; `'rejected'` only appears on
|
|
@@ -511,11 +557,21 @@ export function createProtocolClient(options) {
|
|
|
511
557
|
requestBody.id = id;
|
|
512
558
|
if (data !== undefined)
|
|
513
559
|
requestBody.data = data;
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
560
|
+
let body;
|
|
561
|
+
try {
|
|
562
|
+
body = await requestJson(path, {
|
|
563
|
+
method,
|
|
564
|
+
idempotencyKey: clientTxId,
|
|
565
|
+
body: JSON.stringify(requestBody),
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
catch (error) {
|
|
569
|
+
// Per-model write door (`ablo.<model>.update/create/delete`) — the path
|
|
570
|
+
// the demo editor + server agents actually take. Capture coordination
|
|
571
|
+
// collisions here too; this single row IS the fallback target.
|
|
572
|
+
recordCoordinationConflict(error, clientTxId, [{ model: modelName, id }]);
|
|
573
|
+
throw error;
|
|
574
|
+
}
|
|
519
575
|
// `requestJson` throws via `translateHttpError` on any non-2xx, so reaching
|
|
520
576
|
// here implies success. Narrow `status` to the `CommitWait`-compatible
|
|
521
577
|
// subset; `'rejected'` only appears on a thrown rejection body.
|
|
@@ -563,6 +619,14 @@ export function createProtocolClient(options) {
|
|
|
563
619
|
const releaseClaim = (params) => requestJson(claimPath(isClaimHandle(params) ? params.target.id : params.id), { method: 'DELETE' }).then(() => undefined);
|
|
564
620
|
async function claimImpl(params) {
|
|
565
621
|
const claimId = await acquireClaim(params);
|
|
622
|
+
observability?.captureClaim({
|
|
623
|
+
phase: 'acquired',
|
|
624
|
+
claimId,
|
|
625
|
+
model: name,
|
|
626
|
+
id: params.id,
|
|
627
|
+
...(params.field ? { field: params.field } : {}),
|
|
628
|
+
reason: params.reason ?? 'editing',
|
|
629
|
+
});
|
|
566
630
|
const { data, stamp } = await retrieveModel(name, { id: params.id });
|
|
567
631
|
const release = () => releaseClaim(params);
|
|
568
632
|
return {
|
|
@@ -623,6 +687,37 @@ export function createProtocolClient(options) {
|
|
|
623
687
|
await releaseClaim({ id }).catch(() => { });
|
|
624
688
|
}
|
|
625
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
|
+
}
|
|
626
721
|
return {
|
|
627
722
|
claim,
|
|
628
723
|
retrieve(params) {
|
|
@@ -638,12 +733,7 @@ export function createProtocolClient(options) {
|
|
|
638
733
|
return mutateModel('create', name, id, params.data, options);
|
|
639
734
|
});
|
|
640
735
|
},
|
|
641
|
-
|
|
642
|
-
return withMutationClaim(params.id, params, async (options) => {
|
|
643
|
-
await applyClaimedPolicy({ model: name, id: params.id }, options);
|
|
644
|
-
return mutateModel('update', name, params.id, params.data, options);
|
|
645
|
-
});
|
|
646
|
-
},
|
|
736
|
+
update: updateModel,
|
|
647
737
|
async delete(params) {
|
|
648
738
|
return withMutationClaim(params.id, params, async (options) => {
|
|
649
739
|
await applyClaimedPolicy({ model: name, id: params.id }, options);
|
package/dist/client/auth.js
CHANGED
|
@@ -96,7 +96,13 @@ function activeProjectSlug(value) {
|
|
|
96
96
|
return typeof slug === 'string' && slug.length > 0 ? slug : undefined;
|
|
97
97
|
}
|
|
98
98
|
function importNodeBuiltin(specifier) {
|
|
99
|
-
|
|
99
|
+
// Node-only runtime import (CLI credential snapshot). The call is dead in
|
|
100
|
+
// browser/edge builds — guarded by `process.versions.node` and `window`
|
|
101
|
+
// checks in the callers — but bundlers still try to resolve the dynamic
|
|
102
|
+
// `import()` and Turbopack fails the build on the unanalyzable specifier.
|
|
103
|
+
// The magic comments tell each bundler to emit the import verbatim and never
|
|
104
|
+
// resolve it. Keep both so webpack and Turbopack consumers are covered.
|
|
105
|
+
return import(/* webpackIgnore: true */ /* turbopackIgnore: true */ specifier);
|
|
100
106
|
}
|
|
101
107
|
async function readJsonIfPresent(path) {
|
|
102
108
|
try {
|
|
@@ -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
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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
|
-
|
|
425
|
-
|
|
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
|
-
|
|
429
|
-
|
|
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 };
|
package/dist/errorCodes.d.ts
CHANGED
|
@@ -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;
|
package/dist/errorCodes.js
CHANGED
|
@@ -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
|
|
@@ -404,6 +404,36 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
404
404
|
else {
|
|
405
405
|
errorMessage = 'mutation failed on server';
|
|
406
406
|
}
|
|
407
|
+
// Coordination collision: a stale-context rejection (the write's
|
|
408
|
+
// readAt premise moved underneath) or a foreign-claim conflict is
|
|
409
|
+
// exactly the collision ClaimLog exists to surface. The notify
|
|
410
|
+
// path (success + notifications) emits captureConflict above; a
|
|
411
|
+
// HARD rejection must too — otherwise observability.collisions()
|
|
412
|
+
// silently misses every rejected write. The conflicted rows ride
|
|
413
|
+
// along on the typed error's `conflicts` detail (see
|
|
414
|
+
// AbloStaleContextError.toJSON / errorEnvelope).
|
|
415
|
+
if (errorCode === 'stale_context' ||
|
|
416
|
+
errorCode === 'claim_conflict' ||
|
|
417
|
+
errorCode === 'entity_claimed' ||
|
|
418
|
+
errorCode?.startsWith('policy:') === true) {
|
|
419
|
+
const rawConflicts = error != null &&
|
|
420
|
+
typeof error === 'object' &&
|
|
421
|
+
Array.isArray(error.conflicts)
|
|
422
|
+
? error
|
|
423
|
+
.conflicts
|
|
424
|
+
: [];
|
|
425
|
+
const conflictEvent = {
|
|
426
|
+
clientTxId: typeof clientTxId === 'string' ? clientTxId : '',
|
|
427
|
+
rows: rawConflicts.map((r) => ({
|
|
428
|
+
model: typeof r.model === 'string' ? r.model : 'unknown',
|
|
429
|
+
id: typeof r.id === 'string' ? r.id : 'unknown',
|
|
430
|
+
fields: [],
|
|
431
|
+
})),
|
|
432
|
+
};
|
|
433
|
+
const ctx = getContext();
|
|
434
|
+
ctx.observability.breadcrumb(formatConflict(conflictEvent), 'sync.coordination', 'warning');
|
|
435
|
+
ctx.observability.captureConflict(conflictEvent);
|
|
436
|
+
}
|
|
407
437
|
// Build the proper typed AbloError from the wire code via the
|
|
408
438
|
// shared factory — the same code→class mapping the HTTP commit
|
|
409
439
|
// path uses (`translateHttpError`). This keeps rejected commits
|
package/docs/coordination.md
CHANGED
|
@@ -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.
|
|
11
|
-
|
|
12
|
-
|
|
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
|
|
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.
|