@abloatai/ablo 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +54 -45
  3. package/dist/BaseSyncedStore.js +7 -3
  4. package/dist/SyncEngineContext.d.ts +2 -1
  5. package/dist/SyncEngineContext.js +5 -3
  6. package/dist/agent/session.js +3 -2
  7. package/dist/auth/index.js +39 -11
  8. package/dist/client/Ablo.d.ts +111 -3
  9. package/dist/client/Ablo.js +143 -10
  10. package/dist/client/ApiClient.d.ts +32 -0
  11. package/dist/client/ApiClient.js +76 -44
  12. package/dist/client/auth.d.ts +11 -1
  13. package/dist/client/auth.js +21 -2
  14. package/dist/client/createModelProxy.d.ts +107 -63
  15. package/dist/client/createModelProxy.js +65 -33
  16. package/dist/client/identity.js +14 -0
  17. package/dist/client/registerDataSource.d.ts +19 -0
  18. package/dist/client/registerDataSource.js +57 -0
  19. package/dist/client/validateAbloOptions.d.ts +2 -1
  20. package/dist/client/validateAbloOptions.js +8 -7
  21. package/dist/errorCodes.d.ts +23 -1
  22. package/dist/errorCodes.js +34 -1
  23. package/dist/errors.d.ts +52 -1
  24. package/dist/errors.js +140 -42
  25. package/dist/index.d.ts +9 -5
  26. package/dist/index.js +9 -5
  27. package/dist/keys/index.d.ts +61 -0
  28. package/dist/keys/index.js +151 -0
  29. package/dist/query/client.js +19 -8
  30. package/dist/react/AbloProvider.d.ts +25 -0
  31. package/dist/react/AbloProvider.js +97 -2
  32. package/dist/react/ClientSideSuspense.d.ts +1 -1
  33. package/dist/react/DefaultFallback.d.ts +1 -1
  34. package/dist/react/SyncGroupProvider.d.ts +1 -1
  35. package/dist/react/index.d.ts +3 -2
  36. package/dist/react/index.js +3 -2
  37. package/dist/react/useAblo.d.ts +4 -4
  38. package/dist/react/useAblo.js +10 -5
  39. package/dist/react/useReactive.js +16 -3
  40. package/dist/schema/serialize.d.ts +3 -3
  41. package/dist/schema/serialize.js +2 -2
  42. package/dist/sync/BootstrapHelper.js +46 -27
  43. package/dist/sync/ConnectionManager.d.ts +3 -1
  44. package/dist/sync/ConnectionManager.js +37 -1
  45. package/dist/sync/HydrationCoordinator.js +3 -2
  46. package/dist/sync/NetworkProbe.d.ts +8 -0
  47. package/dist/sync/NetworkProbe.js +24 -2
  48. package/dist/sync/SyncWebSocket.d.ts +1 -1
  49. package/dist/sync/SyncWebSocket.js +43 -53
  50. package/dist/sync/participants.js +5 -2
  51. package/dist/transactions/TransactionQueue.js +13 -1
  52. package/docs/api-keys.md +5 -5
  53. package/docs/api.md +101 -44
  54. package/docs/audit.md +16 -9
  55. package/docs/cli.md +27 -17
  56. package/docs/client-behavior.md +34 -20
  57. package/docs/coordination.md +40 -51
  58. package/docs/data-sources.md +21 -19
  59. package/docs/examples/agent-human.md +72 -28
  60. package/docs/examples/ai-sdk-tool.md +14 -11
  61. package/docs/examples/existing-python-backend.md +27 -16
  62. package/docs/examples/nextjs.md +21 -8
  63. package/docs/examples/scoped-agent.md +42 -27
  64. package/docs/examples/server-agent.md +27 -5
  65. package/docs/guarantees.md +26 -17
  66. package/docs/identity.md +65 -59
  67. package/docs/index.md +30 -19
  68. package/docs/integration-guide.md +52 -52
  69. package/docs/interaction-model.md +38 -26
  70. package/docs/mcp/claude-code.md +9 -17
  71. package/docs/mcp/cursor.md +6 -24
  72. package/docs/mcp/windsurf.md +6 -19
  73. package/docs/mcp.md +103 -26
  74. package/docs/quickstart.md +31 -39
  75. package/docs/react.md +15 -11
  76. package/docs/roadmap.md +13 -13
  77. package/docs/schema-contract.md +109 -0
  78. package/examples/README.md +8 -4
  79. package/examples/data-source/README.md +6 -2
  80. package/examples/data-source/run.ts +4 -3
  81. package/examples/quickstart.ts +1 -1
  82. package/llms.txt +27 -16
  83. package/package.json +6 -1
@@ -27,6 +27,14 @@ export interface ProbeResult {
27
27
  reachable: boolean;
28
28
  /** Whether the session cookie is still valid (null if server unreachable) */
29
29
  sessionValid: boolean | null;
30
+ /**
31
+ * Reachable, but a NON-retryable auth/config failure that is NOT a session
32
+ * expiry (e.g. `api_key_required`, `jwt_issuer_untrusted`). The session is
33
+ * fine — the data-plane rejected the credential TYPE — so neither
34
+ * reconnecting nor re-authenticating will help. The manager stops instead of
35
+ * looping. Distinct from `sessionValid: false` (genuine expiry → sign in).
36
+ */
37
+ authBlocked?: boolean;
30
38
  /** Round-trip time in ms (null if failed) */
31
39
  latencyMs: number | null;
32
40
  }
@@ -22,7 +22,7 @@
22
22
  * @see https://developer.mozilla.org/en-US/docs/Web/API/Navigator/onLine
23
23
  */
24
24
  import { getContext } from '../context.js';
25
- import { SyncSessionError } from '../errors.js';
25
+ import { SyncSessionError, isRetryableCode } from '../errors.js';
26
26
  const PROBE_TIMEOUT_MS = 4000;
27
27
  /**
28
28
  * Derive the probe URL from a sync-server base URL. Accepts `ws://`,
@@ -72,7 +72,17 @@ export async function probeNetwork(baseUrl) {
72
72
  headers: { 'Cache-Control': 'no-cache' },
73
73
  });
74
74
  const latencyMs = Math.round(performance.now() - start);
75
- if (SyncSessionError.isSessionErrorResponse(response.status)) {
75
+ // The probe is a HEAD (no body), but the sync-server sets `X-Auth-Failure:
76
+ // <code>` on every auth rejection — feed that to the code-aware detector so
77
+ // only a genuine session/JWT EXPIRY marks the session invalid. A non-expiry
78
+ // auth failure (e.g. api_key_required, jwt_issuer_untrusted) leaves
79
+ // sessionValid alone — the user IS logged in; signing them out wouldn't fix
80
+ // a credential-type/config problem and just bounces them to /signin.
81
+ const authFailure = response.headers.get('x-auth-failure');
82
+ const failureBody = authFailure
83
+ ? JSON.stringify({ code: authFailure })
84
+ : undefined;
85
+ if (SyncSessionError.isSessionErrorResponse(response.status, failureBody)) {
76
86
  // Server reachable but session expired/invalid
77
87
  getContext().logger.info('[NetworkProbe] Server reachable, session expired', {
78
88
  status: response.status,
@@ -80,6 +90,18 @@ export async function probeNetwork(baseUrl) {
80
90
  });
81
91
  return { reachable: true, sessionValid: false, latencyMs };
82
92
  }
93
+ // Reachable, but a NON-retryable auth/config failure that is NOT a session
94
+ // expiry (api_key_required, jwt_issuer_untrusted, …). Re-auth won't fix it
95
+ // and retrying won't either — signal authBlocked so the manager STOPS
96
+ // rather than reconnect-looping or signing the user out.
97
+ if (authFailure && !isRetryableCode(authFailure)) {
98
+ getContext().logger.warn('[NetworkProbe] Reachable but auth-blocked (non-retryable, non-expiry)', {
99
+ status: response.status,
100
+ code: authFailure,
101
+ latencyMs,
102
+ });
103
+ return { reachable: true, sessionValid: true, authBlocked: true, latencyMs };
104
+ }
83
105
  // 2xx (including 204) means reachable + session valid.
84
106
  // 3xx/4xx (non-auth) still prove connectivity even though the probe
85
107
  // expected 204; log a warning so misconfigurations surface instead of
@@ -271,7 +271,7 @@ export interface CoreSyncEventMap {
271
271
  /**
272
272
  * Per-entity wait-queue snapshot: `{ target, queue: Intent[] }` with each
273
273
  * entry `status: 'queued'` + `position`. Broadcast to entity peers on every
274
- * queue mutation — powers the reactive `ablo.<model>.queue(id)` read.
274
+ * queue mutation — powers the reactive `ablo.<model>.claim.queue(id)` read.
275
275
  */
276
276
  intent_queue: [Record<string, unknown>];
277
277
  intent_acquired: [Record<string, unknown>];
@@ -10,7 +10,7 @@
10
10
  import { EventEmitter } from 'events';
11
11
  import { getContext } from '../context.js';
12
12
  import { flushOfflineQueueOnce } from './OfflineFlush.js';
13
- import { AbloClaimedError, CapabilityError, SyncSessionError, } from '../errors.js';
13
+ import { AbloConnectionError, AbloError, CapabilityError, SyncSessionError, errorFromWire, toAbloError, } from '../errors.js';
14
14
  // ---------------------------------------------------------------------------
15
15
  // Ablo-specific collaboration events moved to apps/web/src/lib/sync/collaboration-events.ts
16
16
  // Consumers pass their own event types as TCollaboration generic parameter.
@@ -214,7 +214,7 @@ export class SyncWebSocket extends EventEmitter {
214
214
  const errorMessage = error instanceof Error ? error.message : 'Failed to create WebSocket';
215
215
  getContext().observability.captureWebSocketError({ context: 'create-websocket', error: errorMessage });
216
216
  this.isConnecting = false;
217
- this.emit('error', new Error(errorMessage));
217
+ this.emit('error', new AbloConnectionError(errorMessage, { cause: error }));
218
218
  this.scheduleReconnect();
219
219
  }
220
220
  }
@@ -360,38 +360,19 @@ export class SyncWebSocket extends EventEmitter {
360
360
  else {
361
361
  errorMessage = 'mutation failed on server';
362
362
  }
363
- // Capability denials route through the typed CapabilityError
364
- // so callers can `instanceof CapabilityError` and read
365
- // `.requiredCapability` to attenuate-and-retry without
366
- // string-matching the error code.
367
- if (errorCode === 'capability_scope_denied' ||
368
- errorCode === 'capability_invalid') {
369
- pending.reject(new CapabilityError(errorCode, errorMessage, requiredCapability));
370
- }
371
- else if (errorCode === 'intent_conflict' ||
372
- errorCode === 'claim_conflict' ||
373
- errorCode === 'entity_claimed') {
374
- // Claim enforcement: another participant holds a live claim on
375
- // a targeted entity. Two server layers reject this — the Hub's
376
- // pre-commit lease check (`intent_conflict`, the code that
377
- // reaches clients in practice) and `executeCommit`'s deeper
378
- // guard (`entity_claimed`). Both mean "claimed", so both route
379
- // through the typed AbloClaimedError, letting callers
380
- // `instanceof AbloClaimedError` (or read `e.type` across worker
381
- // boundaries) and wait/bypass — symmetric with the
382
- // CapabilityError branch above, and with the HTTP commit path
383
- // (`translateHttpError`).
384
- pending.reject(new AbloClaimedError(errorMessage, {
385
- code: errorCode === 'intent_conflict' ? 'claim_conflict' : errorCode,
386
- httpStatus: 409,
387
- }));
388
- }
389
- else {
390
- const rejection = new Error(errorMessage);
391
- if (errorCode)
392
- rejection.code = errorCode;
393
- pending.reject(rejection);
394
- }
363
+ // Build the proper typed AbloError from the wire code via the
364
+ // shared factory the same code→class mapping the HTTP commit
365
+ // path uses (`translateHttpError`). This keeps rejected commits
366
+ // inside the typed hierarchy (capability denials →
367
+ // CapabilityError with `.requiredCapability`; foreign-claim
368
+ // conflicts AbloClaimedError; everything else → the subclass
369
+ // its registry `httpStatus` implies) instead of a hand-rolled
370
+ // `new Error`, so callers can `instanceof`/`e.type` it and
371
+ // downstream retry logic can read the contract's retryability.
372
+ pending.reject(errorFromWire(errorMessage, {
373
+ code: errorCode,
374
+ requiredCapability,
375
+ }));
395
376
  }
396
377
  break;
397
378
  }
@@ -438,11 +419,10 @@ export class SyncWebSocket extends EventEmitter {
438
419
  pending.reject(new CapabilityError(code, msg, requiredCapability));
439
420
  }
440
421
  else {
441
- // Attach `code` as a property on the rejection so callers
442
- // can discriminate (`scope_conflict`, `malformed_claim`,
443
- // ...) without parsing the message.
444
- const rejection = Object.assign(new Error(`${code}: ${msg}`), { code });
445
- pending.reject(rejection);
422
+ // Route through the shared factory so a failed claim_ack is a
423
+ // typed AbloError (registry code → right subclass), symmetric
424
+ // with the commit `mutation_result` path — never a bare Error.
425
+ pending.reject(errorFromWire(msg, { code }));
446
426
  }
447
427
  }
448
428
  break;
@@ -539,12 +519,12 @@ export class SyncWebSocket extends EventEmitter {
539
519
  // Check if we're offline first
540
520
  if (!getContext().onlineStatus.isOnline()) {
541
521
  getContext().observability.breadcrumb('WebSocket error: Network is offline', 'sync.websocket', 'warning');
542
- this.emit('error', new Error('Network is offline'));
522
+ this.emit('error', new AbloConnectionError('Network is offline', { code: 'bootstrap_offline' }));
543
523
  return;
544
524
  }
545
525
  // After session error, suppress Sentry capture — the root cause is already reported.
546
526
  // Still emit so SyncedStore can update UI state.
547
- const error = new Error(`WebSocket connection failed`);
527
+ const error = new AbloConnectionError(`WebSocket connection failed`);
548
528
  if (!this._sessionErrorDetected) {
549
529
  getContext().observability.captureWebSocketError({
550
530
  context: 'connection-error',
@@ -578,12 +558,16 @@ export class SyncWebSocket extends EventEmitter {
578
558
  if (this.pendingMutations.size > 0) {
579
559
  for (const pending of this.pendingMutations.values()) {
580
560
  clearTimeout(pending.timeout);
581
- pending.reject(Object.assign(new Error(`WebSocket closed while commit was in flight (code=${event.code}` +
561
+ // AbloConnectionError `isPermanentError` treats it as transient,
562
+ // so TransactionQueue retries the commit on reconnect rather than
563
+ // rolling it back. `diagnostics` is preserved as a property (the
564
+ // queue's failure log walks the cause chain for it).
565
+ pending.reject(Object.assign(new AbloConnectionError(`WebSocket closed while commit was in flight (code=${event.code}` +
582
566
  (event.reason ? ` reason=${event.reason}` : '') +
583
567
  (this.lastForceCloseReason
584
568
  ? ` forceCloseReason=${this.lastForceCloseReason}`
585
569
  : '') +
586
- ')'), { diagnostics: this.getConnectionDiagnostics() }));
570
+ ')', { code: 'commit_no_result' }), { diagnostics: this.getConnectionDiagnostics() }));
587
571
  }
588
572
  this.pendingMutations.clear();
589
573
  }
@@ -594,7 +578,7 @@ export class SyncWebSocket extends EventEmitter {
594
578
  if (this.pendingClaims.size > 0) {
595
579
  for (const pending of this.pendingClaims.values()) {
596
580
  clearTimeout(pending.timeout);
597
- pending.reject(new Error(`WebSocket closed while claim was in flight (code=${event.code})`));
581
+ pending.reject(new AbloConnectionError(`WebSocket closed while claim was in flight (code=${event.code})`));
598
582
  }
599
583
  this.pendingClaims.clear();
600
584
  }
@@ -762,7 +746,7 @@ export class SyncWebSocket extends EventEmitter {
762
746
  return new Promise((resolve, reject) => {
763
747
  const timeout = setTimeout(() => {
764
748
  this.pendingMutations.delete(clientTxId);
765
- reject(new Error(`commit timed out after ${timeoutMs}ms (clientTxId=${clientTxId})`));
749
+ reject(new AbloConnectionError(`commit timed out after ${timeoutMs}ms (clientTxId=${clientTxId})`, { code: 'commit_no_result' }));
766
750
  }, timeoutMs);
767
751
  this.pendingMutations.set(clientTxId, { resolve, reject, timeout });
768
752
  try {
@@ -778,9 +762,7 @@ export class SyncWebSocket extends EventEmitter {
778
762
  catch (error) {
779
763
  clearTimeout(timeout);
780
764
  this.pendingMutations.delete(clientTxId);
781
- reject(error instanceof Error
782
- ? error
783
- : new Error(String(error)));
765
+ reject(toAbloError(error));
784
766
  }
785
767
  });
786
768
  }
@@ -826,7 +808,9 @@ export class SyncWebSocket extends EventEmitter {
826
808
  return new Promise((resolve, reject) => {
827
809
  const timeout = setTimeout(() => {
828
810
  this.pendingClaims.delete(claimId);
829
- reject(new Error(`claim timed out after ${timeoutMs}ms (claimId=${claimId})`));
811
+ reject(new AbloConnectionError(`claim timed out after ${timeoutMs}ms (claimId=${claimId})`, {
812
+ code: 'wait_for_timeout',
813
+ }));
830
814
  }, timeoutMs);
831
815
  this.pendingClaims.set(claimId, { resolve, reject, timeout });
832
816
  try {
@@ -843,7 +827,7 @@ export class SyncWebSocket extends EventEmitter {
843
827
  catch (error) {
844
828
  clearTimeout(timeout);
845
829
  this.pendingClaims.delete(claimId);
846
- reject(error instanceof Error ? error : new Error(String(error)));
830
+ reject(toAbloError(error));
847
831
  }
848
832
  });
849
833
  }
@@ -864,7 +848,10 @@ export class SyncWebSocket extends EventEmitter {
864
848
  if (pending) {
865
849
  clearTimeout(pending.timeout);
866
850
  this.pendingClaims.delete(claimId);
867
- pending.reject(new Error(`claim ${claimId} released before ack`));
851
+ pending.reject(new AbloError(`claim ${claimId} released before ack`, {
852
+ code: 'intent_wait_aborted',
853
+ httpStatus: 409,
854
+ }));
868
855
  }
869
856
  if (this.ws?.readyState !== WebSocket.OPEN)
870
857
  return;
@@ -1172,8 +1159,11 @@ export class SyncWebSocket extends EventEmitter {
1172
1159
  else {
1173
1160
  detail = 'never_connected';
1174
1161
  }
1175
- const err = new Error(`SyncWebSocket not connected cannot send ${action} (${detail})`);
1176
- err.diagnostics = d;
1162
+ // Typed so it lands in the AbloError hierarchy AND `isPermanentError`
1163
+ // sees a transient transport failure (retry on reconnect, don't roll
1164
+ // back). `diagnostics` stays a property — the queue's failure log walks
1165
+ // the cause chain for it.
1166
+ const err = Object.assign(new AbloConnectionError(`SyncWebSocket not connected — cannot send ${action} (${detail})`, { code: 'ws_not_ready' }), { diagnostics: d });
1177
1167
  return err;
1178
1168
  }
1179
1169
  /** Returns the sync groups this connection is subscribed to. */
@@ -1,4 +1,5 @@
1
1
  import { scopeKindOf } from '../schema/model.js';
2
+ import { AbloConnectionError, AbloValidationError } from '../errors.js';
2
3
  export function createParticipantManager(config) {
3
4
  return {
4
5
  async join(input, overrides) {
@@ -10,7 +11,7 @@ export function createParticipantManager(config) {
10
11
  await config.ready();
11
12
  const transport = config.getTransport();
12
13
  if (!transport) {
13
- throw new Error('Ablo participant join failed: WebSocket is not connected');
14
+ throw new AbloConnectionError('Ablo participant join failed: WebSocket is not connected', { code: 'ws_not_ready' });
14
15
  }
15
16
  const claimId = createParticipantClaimId();
16
17
  if (syncGroups.length > 0) {
@@ -154,7 +155,9 @@ function createJoinedParticipant(args) {
154
155
  const requireTarget = (target) => {
155
156
  const resolved = target ? targetToEntityRef(target) : currentTarget;
156
157
  if (!resolved) {
157
- throw new Error('Participant action requires a structured target');
158
+ throw new AbloValidationError('Participant action requires a structured target', {
159
+ code: 'invalid_request',
160
+ });
158
161
  }
159
162
  return resolved;
160
163
  };
@@ -12,7 +12,7 @@ import { getContext } from '../context.js';
12
12
  import { getActiveRegistry } from '../ModelRegistry.js';
13
13
  import { MutationOperationType } from '../types/index.js';
14
14
  import { handleMutationError } from './mutation-error-handler.js';
15
- import { AbloError, AbloConnectionError } from '../errors.js';
15
+ import { AbloError, AbloConnectionError, errorCodeSpec } from '../errors.js';
16
16
  /**
17
17
  * Framework-internal keys added by `Model.toJSON()` that must never
18
18
  * reach the wire. The server treats each top-level key as a target
@@ -1482,6 +1482,18 @@ export class TransactionQueue extends EventEmitter {
1482
1482
  if (error instanceof AbloConnectionError) {
1483
1483
  return false;
1484
1484
  }
1485
+ // Registry-driven retryability is authoritative when the error carries a
1486
+ // known wire code: the error contract (errorCodes.ts) decides whether the
1487
+ // same request can succeed on retry, not message string-matching. This is
1488
+ // why rejected commits must arrive as typed AbloErrors (see
1489
+ // `errorFromWire`) — a bare `Error` has no code and falls through to the
1490
+ // heuristics below. Unknown / forward-compat codes (`errorCodeSpec`
1491
+ // returns undefined) also fall through, preserving the safe default.
1492
+ if (error instanceof AbloError && error.code) {
1493
+ const spec = errorCodeSpec(error.code);
1494
+ if (spec)
1495
+ return !spec.retryable;
1496
+ }
1485
1497
  const message = error?.message?.toLowerCase() || '';
1486
1498
  // Network/connection errors are transient - retry these
1487
1499
  const isNetworkError = message.includes('failed to fetch') ||
package/docs/api-keys.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # API Keys
2
2
 
3
- Trusted runtimes authenticate with an API key.
3
+ Authenticate a server-side client — a route handler, worker, or CLI — by passing an API key when you create the client.
4
4
 
5
5
  ```ts
6
6
  import Ablo from '@abloatai/ablo';
@@ -10,11 +10,11 @@ const ablo = Ablo({ apiKey: process.env.ABLO_API_KEY });
10
10
 
11
11
  The key identifies the Ablo account. Application code does not pass an organization id; Ablo derives scope from the credential.
12
12
 
13
- Use the root `@abloatai/ablo` import with a schema for app clients.
13
+ "Trusted" means the runtime can hold a secret: a backend or other server-side environment a browser can't read. Browser and app clients use the same `@abloatai/ablo` import but authenticate differently they never carry a secret key.
14
14
 
15
15
  ## Server-Side API Keys
16
16
 
17
- Use API keys from trusted runtimes:
17
+ Use API keys from trusted (server-side) runtimes:
18
18
 
19
19
  - backend route handlers
20
20
  - workers and agents
@@ -48,8 +48,8 @@ restricted to exactly those grants:
48
48
  high-risk, org-wide grant: because schema is shared, a push affects the live
49
49
  table shape. A full-authority key has it implicitly; a *restricted* key (such
50
50
  as a sandbox key) needs it granted explicitly.
51
- - `sandbox:<id>` — marks the key as belonging to a sandbox (its data isolation
52
- comes from the sandbox binding, not this scope string).
51
+ - `sandbox:<id>` — identifies which sandbox the key belongs to. (The key's data
52
+ isolation comes from that sandbox binding, not from this scope string.)
53
53
 
54
54
  A key minted from the default **Test mode** sandbox carries `schema:push`, so
55
55
  `ablo dev` works out of the box. Keys from other sandboxes are **data-only** by
package/docs/api.md CHANGED
@@ -1,10 +1,21 @@
1
1
  # API
2
2
 
3
- Start with the schema client:
4
-
5
- For end-to-end app setup across React, existing backends, Data Source, and
6
- agents, read [Integration Guide](./integration-guide.md).
3
+ This is the per-method reference for reading and writing rows that stay in
4
+ sync across sessions. You declare your models once, then call the same
5
+ `ablo.<model>` methods from React, a server action, or an agent — and every
6
+ confirmed write streams to everyone watching. When two writers touch the same
7
+ row, you can optionally `claim` it so they serialize instead of clobbering
8
+ each other.
9
+
10
+ Two things to know before the method list. **Reads come in two flavors:**
11
+ `retrieve(id)` / `list({ where })` are async and hit the server (use them when
12
+ the row may not be local yet); `get(id)` / `getAll({ where })` / `getCount({ where })`
13
+ are synchronous reads off the local graph (use them in render, after data has
14
+ synced). **Claims don't lock.** If another writer holds the row, `claim` waits
15
+ for them, re-reads the fresh row, then hands it to you — so two writers
16
+ serialize instead of clobbering.
7
17
 
18
+ Start with the schema client:
8
19
 
9
20
  ```ts
10
21
  import Ablo from '@abloatai/ablo';
@@ -20,39 +31,46 @@ const schema = defineSchema({
20
31
  const ablo = Ablo({ schema, apiKey: process.env.ABLO_API_KEY });
21
32
 
22
33
  await ablo.ready();
23
- const [report] = await ablo.weatherReports.load({ where: { id: 'report_stockholm' } });
34
+ const report = await ablo.weatherReports.retrieve('report_stockholm');
24
35
  if (!report) throw new Error('Row not found');
25
36
 
26
37
  await ablo.weatherReports.update('report_stockholm', { status: 'ready' }, { wait: 'confirmed' });
27
38
  ```
28
39
 
40
+ For end-to-end app setup across React, existing backends, Data Source, and
41
+ agents, read the [Integration Guide](./integration-guide.md).
42
+
29
43
  ## Model Methods
30
44
 
31
45
  Each schema model becomes a typed model on the client:
32
46
 
33
- - `ablo.weatherReports.load({ where })` hydrates rows asynchronously.
34
- - `ablo.weatherReports.retrieve(id)` reads one already-loaded row synchronously.
47
+ - `ablo.weatherReports.retrieve(id)` reads one row asynchronously (server read).
48
+ - `ablo.weatherReports.list({ where })` reads a collection asynchronously (server read).
49
+ - `ablo.weatherReports.get(id)` reads one row synchronously from the local graph.
35
50
  - `ablo.weatherReports.create(data)` creates a row.
36
51
  - `ablo.weatherReports.update(id, data, options?)` updates a row.
37
52
  - `ablo.weatherReports.delete(id, options?)` deletes a row.
38
53
 
39
- `load` and `retrieve` are not aliases. Use `load` when the row may not be loaded
40
- yet. Use `retrieve` after `ready()` or `load()` when you want a cheap
41
- synchronous read.
54
+ `retrieve`/`list` and `get`/`getAll`/`getCount` are not aliases. Use
55
+ `retrieve(id)` or `list({ where })` when the row may not be local yet — they
56
+ hydrate pool → IndexedDB → network. Use `get(id)` / `getAll({ where })` /
57
+ `getCount({ where })` for a cheap synchronous snapshot of what is already in
58
+ the local graph.
42
59
 
43
60
  | Method | Returns | Use when |
44
61
  |---|---|---|
45
- | `load({ where })` | `Promise<T[]>` | You need to hydrate rows from local store and server. |
46
- | `retrieve(id)` | `T \| undefined` | You already loaded the row and want a synchronous read. |
47
- | `list(options?)` | `T[]` | You want a synchronous list of loaded rows. |
48
- | `count(options?)` | `number` | You want a synchronous count of loaded rows. |
62
+ | `retrieve(id)` | `Promise<T \| undefined>` | You need one row, hydrating from local store and server. |
63
+ | `list({ where })` | `Promise<T[]>` | You need to hydrate a collection from local store and server. |
64
+ | `get(id)` | `T \| undefined` | You want a synchronous snapshot of one local row. |
65
+ | `getAll(options?)` | `T[]` | You want a synchronous snapshot of a local collection. |
66
+ | `getCount(options?)` | `number` | You want a synchronous count of local rows. |
49
67
  | `create(data, options?)` | `Promise<T>` | You want to create through the schema model. |
50
68
  | `update(id, data, options?)` | `Promise<T>` | You want to update through the schema model. |
51
69
  | `delete(id, options?)` | `Promise<void>` | You want to delete through the schema model. |
52
70
 
53
- `load`, `create`, `update`, and `delete` are the main path — they go through the
54
- server. `retrieve` / `list` / `count` are **synchronous reads** off the rows a
55
- session has already loaded, so a cheap re-read needs no round-trip.
71
+ `retrieve`, `list`, `create`, `update`, and `delete` are the main path — they go
72
+ through the server. `get` / `getAll` / `getCount` are **synchronous reads**
73
+ off the rows a session has already synced, so a cheap re-read needs no round-trip.
56
74
 
57
75
  ## Protected Writes
58
76
 
@@ -80,23 +98,26 @@ Protected write options:
80
98
 
81
99
  ## Claims
82
100
 
83
- A claim tells humans and agents who is working on a target before the write
84
- lands. One self-describing object carries the lifecycle in a single `status`
85
- field. It lives on the coordination plane: ephemeral, TTL'd, broadcast to peers
86
- in real time, and never persisted as a row.
101
+ Before anyone writes a row, they can claim it so other people and agents see
102
+ who is editing it in real time. Claims don't lock. If another writer holds the
103
+ row, `claim` waits for them, re-reads the fresh row, then hands it to you — so
104
+ two writers serialize instead of clobbering. A claim is temporary: it expires
105
+ on its own if the holder stops, and is never saved as a row.
87
106
 
88
- Coordinate one through flat verbs on the model, beside `create`/`update`/`retrieve`:
89
- `ablo.<model>.claim(id, ...)` to claim a row, `ablo.<model>.claimState(id)` to read
90
- who holds it (synchronous; never blocks), and `ablo.<model>.release(id)` to release
91
- early. Claims are **advisory** they serialize on contention rather than locking.
107
+ You coordinate a row with calls on its model, beside `create`/`update`/`retrieve`:
108
+ `ablo.<model>.claim(id, work)` takes the claim and runs your work,
109
+ `ablo.<model>.claim.state(id)` reads who currently holds it (synchronous, never
110
+ blocks), and `ablo.<model>.claim.release(id)` releases it early. The full
111
+ coordination surface is `claim.state(id)` / `claim.queue(id)` /
112
+ `claim.release(id)` / `claim.reorder(id, order)` hanging off `claim`.
92
113
 
93
114
  ### The Claim State Object
94
115
 
95
116
  | Field | Type | Description |
96
117
  |---|---|---|
97
- | `object` | `'claim'` | String representing the object's type. |
118
+ | `object` | `'intent'` | String representing the object's type. |
98
119
  | `id` | string | Unique identifier for the claim. |
99
- | `status` | `'active' \| 'committed' \| 'expired' \| 'canceled'` | The whole lifecycle, in one field. |
120
+ | `status` | `'active' \| 'queued' \| 'committed' \| 'expired' \| 'canceled'` | The whole lifecycle, in one field. `active` is the holder; `queued` is a waiter in the FIFO line behind it. |
100
121
  | `target` | `{ type, id, field? }` | What is being coordinated. |
101
122
  | `action` | string | Human-readable phase — `'editing'`, `'writing'`, `'reviewing'`. |
102
123
  | `heldBy` | string | Participant id holding the claim. |
@@ -105,7 +126,7 @@ early. Claims are **advisory** — they serialize on contention rather than lock
105
126
 
106
127
  ```json
107
128
  {
108
- "object": "claim",
129
+ "object": "intent",
109
130
  "id": "claim_3MtwBwLkdIwHu7ix",
110
131
  "status": "active",
111
132
  "target": { "type": "weatherReports", "id": "report_stockholm", "field": "status" },
@@ -128,21 +149,24 @@ early. Claims are **advisory** — they serialize on contention rather than lock
128
149
  (release w/o write) (TTL; holder died)
129
150
  ```
130
151
 
131
- A target is free when `ablo.<model>.claimState(id)` is `null`. Terminal
132
- states drop out of the live stream, so a present claim is active.
152
+ A target is free when `ablo.<model>.claim.state(id)` is `null`. Terminal
153
+ states drop out of the live stream, so a present claim is either `active` (the
154
+ holder) or `queued` (waiting in the FIFO line behind the holder; see
155
+ `claim.queue(id)`).
133
156
 
134
157
  ### Reading and claiming
135
158
 
136
- `claimState(id)` is the read side for observers: synchronous, never blocks, and
137
- returns the live claim state object (or `null`). `claim(id, ...)` is the write side:
138
- it claims the row and returns the row. Because the claim is **advisory**, if
139
- someone else already holds the row, `claim` waits for them to finish, then
140
- re-reads the row before handing it back so you always proceed from fresh state.
141
- Default reads stay open; server/model reads can opt into `ifClaimed: 'wait'` or
142
- `ifClaimed: 'fail'` when they should not read through active work.
159
+ `claim.state(id)` is the read side for observers: synchronous, never blocks, and
160
+ returns the live claim state object (or `null`). `claim(id, work)` is the write
161
+ side: it takes the claim and returns the row. Claims don't lock if someone else
162
+ already holds the row, `claim` waits for them to finish, re-reads the fresh row,
163
+ then hands it to you, so you always proceed from current state. Default reads
164
+ return the row even while someone is mid-edit; if a server read should not
165
+ return a row while it's claimed, pass `ifClaimed: 'wait'` to wait for the claim
166
+ to clear, or `ifClaimed: 'fail'` to error out instead.
143
167
 
144
168
  ```ts
145
- const claim = ablo.weatherReports.claimState('report_stockholm');
169
+ const claim = ablo.weatherReports.claim.state('report_stockholm');
146
170
  if (claim) {
147
171
  claim.heldBy;
148
172
  claim.action;
@@ -155,19 +179,52 @@ const updated = await ablo.weatherReports.claim(
155
179
  );
156
180
  ```
157
181
 
158
- Writes go through the normal flat `ablo.<model>.update(id, data)`. While you hold
159
- a claim on `id`, that `update` is automatically stale-guarded: it rejects with
160
- `AbloStaleContextError` if the row advanced past your claim point, so you re-read
161
- before retrying. The callback form releases automatically when the callback
162
- returns or throws, or call `ablo.weatherReports.release(id)` if you claimed manually and
182
+ Writes go through the normal `ablo.<model>.update(id, data)`. While you hold
183
+ a claim on `id`, that `update` rejects with `AbloStaleContextError` if the row
184
+ changed underneath you since you took the claim, so you re-read before retrying.
185
+ The callback form releases the claim automatically when the callback returns or
186
+ throws; call `ablo.weatherReports.claim.release(id)` if you claimed manually and
163
187
  need to release early.
164
188
 
165
189
  ## Agent
166
190
 
167
191
  Most agents should import the same schema as the app and call
168
- `ablo.<model>.load(...)`, `ablo.<model>.claim(...)`, and
192
+ `ablo.<model>.list(...)`, `ablo.<model>.claim(...)`, and
169
193
  `ablo.<model>.update(...)`.
170
194
 
195
+ ## HTTP API
196
+
197
+ The SDK is a convenience wrapper over a model-scoped HTTP surface — the same
198
+ noun (`model`) and verbs as `ablo.<model>.…`. Non-JS callers (or curl) use it
199
+ directly. The table below shows the shape with `{model}` as a placeholder; the
200
+ [OpenAPI spec](./openapi.json) expands it into one **typed** path per model
201
+ (`/v1/models/task`, `/v1/models/deck`, …, generated from your schema) so each
202
+ endpoint documents that model's real field contract instead of a generic blob.
203
+
204
+ | SDK call | HTTP |
205
+ |---|---|
206
+ | `ablo.<model>.create(data)` | `POST /v1/models/{model}` |
207
+ | `ablo.<model>.list({ where })` | `GET /v1/models/{model}` |
208
+ | `ablo.<model>.retrieve(id)` | `GET /v1/models/{model}/{id}` |
209
+ | `ablo.<model>.update(id, data)` | `PATCH /v1/models/{model}/{id}` |
210
+ | `ablo.<model>.delete(id)` | `DELETE /v1/models/{model}/{id}` |
211
+ | `ablo.<model>.claim(id)` | `POST /v1/models/{model}/{id}/claim` |
212
+ | (release a claim) | `DELETE /v1/models/{model}/{id}/claim` |
213
+
214
+ Auth is a bearer API key: `Authorization: Bearer sk_…`. Mutations take an
215
+ `Idempotency-Key` header — derive it from the business event, not a random
216
+ value, so a retry never double-writes. Writes return a `CommitReceipt`; a
217
+ rejected write carries an error `code` (e.g. `stale_context`, `intent_conflict`)
218
+ to act on. `GET /v1/models/{model}` is cursor-paginated (`limit`, `order`,
219
+ `order_by`, `starting_after`) and returns `{ data, has_more, next_cursor }`.
220
+
221
+ `POST /v1/commits` remains the path for **atomic multi-op** writes (several
222
+ operations across rows/models that must commit together) — the per-model routes
223
+ above are the one-record path. Both run the identical guarded-write engine.
224
+
225
+ The [coordination MCP server](./mcp.md) (`@ablo/mcp`) is this same surface
226
+ rendered as agent tools.
227
+
171
228
  ## Errors
172
229
 
173
230
  All SDK errors extend `AbloError` and expose a stable `type` string.