@abloatai/ablo 0.18.0 → 0.20.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 (61) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/dist/Model.d.ts +22 -0
  3. package/dist/Model.js +45 -0
  4. package/dist/ObjectPool.d.ts +14 -1
  5. package/dist/ObjectPool.js +8 -1
  6. package/dist/SyncClient.js +7 -1
  7. package/dist/SyncEngineContext.js +2 -0
  8. package/dist/ai-sdk/coordination-context.d.ts +2 -1
  9. package/dist/ai-sdk/coordination-context.js +3 -3
  10. package/dist/ai-sdk/index.d.ts +3 -4
  11. package/dist/ai-sdk/index.js +2 -3
  12. package/dist/ai-sdk/wrap.d.ts +13 -18
  13. package/dist/ai-sdk/wrap.js +2 -6
  14. package/dist/cli.cjs +253 -160
  15. package/dist/client/Ablo.d.ts +83 -22
  16. package/dist/client/Ablo.js +116 -32
  17. package/dist/client/ApiClient.d.ts +2 -2
  18. package/dist/client/ApiClient.js +27 -32
  19. package/dist/client/auth.d.ts +26 -0
  20. package/dist/client/auth.js +208 -0
  21. package/dist/client/createModelProxy.d.ts +16 -11
  22. package/dist/client/createModelProxy.js +15 -15
  23. package/dist/client/sessionMint.d.ts +10 -0
  24. package/dist/client/sessionMint.js +8 -1
  25. package/dist/coordination/schema.d.ts +10 -10
  26. package/dist/coordination/schema.js +7 -8
  27. package/dist/coordination/trace.d.ts +91 -0
  28. package/dist/coordination/trace.js +147 -0
  29. package/dist/errorCodes.d.ts +2 -0
  30. package/dist/errorCodes.js +2 -0
  31. package/dist/errors.d.ts +1 -2
  32. package/dist/errors.js +6 -9
  33. package/dist/index.d.ts +6 -1
  34. package/dist/index.js +13 -0
  35. package/dist/interfaces/index.d.ts +45 -2
  36. package/dist/mutators/undoApply.d.ts +7 -2
  37. package/dist/mutators/undoApply.js +7 -35
  38. package/dist/policy/types.d.ts +18 -9
  39. package/dist/policy/types.js +26 -16
  40. package/dist/react/AbloProvider.d.ts +2 -2
  41. package/dist/react/useAblo.js +12 -2
  42. package/dist/schema/sugar.js +10 -2
  43. package/dist/server/read-config.d.ts +7 -0
  44. package/dist/sync/HydrationCoordinator.js +7 -3
  45. package/dist/sync/SyncWebSocket.d.ts +11 -2
  46. package/dist/sync/SyncWebSocket.js +67 -3
  47. package/dist/sync/createClaimStream.d.ts +9 -2
  48. package/dist/sync/createClaimStream.js +9 -7
  49. package/dist/sync/participants.d.ts +12 -11
  50. package/dist/sync/participants.js +5 -5
  51. package/dist/transactions/TransactionQueue.d.ts +1 -0
  52. package/dist/transactions/TransactionQueue.js +40 -3
  53. package/dist/types/streams.d.ts +57 -135
  54. package/dist/utils/json.d.ts +39 -0
  55. package/dist/utils/json.js +88 -0
  56. package/docs/coordination.md +16 -0
  57. package/docs/debugging.md +194 -0
  58. package/docs/index.md +1 -0
  59. package/package.json +1 -1
  60. package/dist/ai-sdk/claim-broadcast.d.ts +0 -83
  61. package/dist/ai-sdk/claim-broadcast.js +0 -79
@@ -100,7 +100,7 @@ export interface ContextChange {
100
100
  * `range`, `field`, and `meta` are generic coordination hints for
101
101
  * products like code editors, document editors, and design tools.
102
102
  */
103
- export interface EntityRef {
103
+ export interface ClaimTarget {
104
104
  readonly type: string;
105
105
  readonly id: string;
106
106
  readonly path?: string;
@@ -110,11 +110,11 @@ export interface EntityRef {
110
110
  }
111
111
  /**
112
112
  * A pointer to one entity the participant is acting on. Either a
113
- * typed `EntityRef` (`{ type, id, ... }`), or a tuple
113
+ * typed `ClaimTarget` (`{ type, id, ... }`), or a tuple
114
114
  * `['Clause', 'cl_3']` for ergonomic inline use. The verb methods
115
115
  * below accept both.
116
116
  */
117
- export type PresenceTarget = EntityRef | readonly [type: string, id: string];
117
+ export type PresenceTarget = ClaimTarget | readonly [type: string, id: string];
118
118
  /**
119
119
  * Reactive livestream of what every multiplayer participant is doing.
120
120
  * Every participant gets one; it's always on, always current.
@@ -336,27 +336,12 @@ export interface ClaimOptions extends ClaimLeaseOptions {
336
336
  readonly queue?: boolean;
337
337
  }
338
338
  export interface ClaimStream {
339
- /**
340
- * Claim an exclusive claim on a target. Returns a handle — call
341
- * `.revoke()` to cancel, let it expire via TTL, or use `await using`
342
- * (TC39 explicit resource management) to auto-revoke on scope exit.
343
- *
344
- * Server rejects via `claim_rejected` when another participant
345
- * already holds a claim on the same target. Default `reason` is
346
- * `'editing'`; pass `{reason: 'writing'}` (or any string) to override.
347
- *
348
- * The frame ships on the open WS immediately. One method, one shape —
349
- * the verb shortcuts (`editing`, `writing`, `announce`) and the
350
- * scoped `claim(reason, opts)` overload were collapsed into this
351
- * single primitive.
352
- */
353
- claim(target: PresenceTarget, opts?: ClaimOptions): ClaimHandle;
354
339
  /**
355
340
  * Reactive view of every other participant's active claims.
356
341
  * Reads return the current snapshot; pair with `subscribe(...)`
357
342
  * below to get notified on change.
358
343
  */
359
- readonly others: ReadonlyArray<ActiveClaim>;
344
+ readonly others: ReadonlyArray<Claim>;
360
345
  /**
361
346
  * Reactive view of the wait queue on one target — the FIFO line of
362
347
  * `status: 'queued'` claims behind the current holder, each with its
@@ -425,7 +410,7 @@ export interface ClaimStream {
425
410
  * }
426
411
  * ```
427
412
  */
428
- [Symbol.asyncIterator](): AsyncIterableIterator<ReadonlyArray<ActiveClaim>>;
413
+ [Symbol.asyncIterator](): AsyncIterableIterator<ReadonlyArray<Claim>>;
429
414
  }
430
415
  /**
431
416
  * You LOST an claim you were HOLDING — distinct from `ClaimRejection` (a
@@ -452,95 +437,6 @@ export interface ClaimLost {
452
437
  readonly meta?: Record<string, unknown>;
453
438
  };
454
439
  }
455
- export interface ClaimDeclaration {
456
- readonly target: EntityRef;
457
- /** Human-readable reason — "rewriting title" / "restyling chart". */
458
- readonly reason: string;
459
- /**
460
- * Seconds remaining until the server auto-expires this claim. An OUTPUT
461
- * field carrying a concrete countdown, so it's a plain `number` — distinct
462
- * from the input `ttl: Duration` (`'3m'`) you pass when announcing. Computed
463
- * from `expiresAt - now`.
464
- */
465
- readonly ttlSeconds?: number;
466
- }
467
- /**
468
- * Handle returned from `announce(...)` / `analyzing(...)` / etc.
469
- *
470
- * Implements `Symbol.asyncDispose` so callers can write:
471
- *
472
- * ```ts
473
- * {
474
- * await using work = participant.claims.analyzing(clause, { ttl: '3m' });
475
- * // ... do the work; claim auto-revokes when the block exits
476
- * }
477
- * ```
478
- */
479
- /**
480
- * THE one claim handle. Returned by every claim door — the typed
481
- * `ablo.<model>.claim({ id })` (rich: `data`/`readAt`/`target` populated) and
482
- * the low-level `participant.claims.claim()` lease (minimal: `claimId` +
483
- * `revoke`/`release`). Row-level fields are optional precisely because the
484
- * low-level lease has no row snapshot; the model door fills them in.
485
- *
486
- * Implements `Symbol.asyncDispose` so callers can `await using claim = ...`
487
- * and have it auto-release on scope exit.
488
- */
489
- export interface ClaimHandle<T = Record<string, unknown>> extends AsyncDisposable {
490
- readonly object: 'claim';
491
- readonly claimId: string;
492
- /**
493
- * True when the grant came AFTER waiting in the server's FIFO line
494
- * (`claim_granted`) — the authoritative "the row may have changed under us"
495
- * signal. Absent for an immediate grant or a non-queued lease.
496
- */
497
- readonly waited?: boolean;
498
- /**
499
- * Sync watermark of the held snapshot (`data` was read at this stamp). Writes
500
- * carrying the handle use it as the `readAt` stale guard. Present for
501
- * model-scoped claims; absent for low-level leases.
502
- */
503
- readonly readAt?: number;
504
- readonly target: {
505
- readonly model: string;
506
- readonly id: string;
507
- readonly field?: string;
508
- readonly path?: string;
509
- readonly range?: TargetRange;
510
- readonly meta?: Record<string, unknown>;
511
- };
512
- /**
513
- * The human-readable phase this claim represents — `'editing'`, `'writing'`,
514
- * `'forecasting'`. The SAME word on every claim surface (inputs and outputs);
515
- * distinct from the CRUD operation (`CommitOperationInput.action`). Defaults
516
- * to `'editing'`. Serialized on the wire as `action`.
517
- */
518
- readonly reason: string;
519
- readonly description?: string;
520
- /** Row snapshot — populated by `ablo.<model>.claim`; absent on low-level leases. */
521
- readonly data?: T;
522
- release(): Promise<void>;
523
- revoke(): void;
524
- }
525
- export interface ActiveClaim extends ClaimDeclaration {
526
- readonly id: string;
527
- readonly heldBy: string;
528
- /**
529
- * Whether the holding participant is a user (session), an agent, or a
530
- * system actor. First-class field so UIs can style "agent editing X"
531
- * differently from "user editing X" without string-parsing `heldBy`.
532
- * Canonical `'user' | 'agent' | 'system'` — the presence/claim stream
533
- * derives the value from the boolean `isAgent` wire flag (so it produces
534
- * only `'user'`/`'agent'`), but the type stays the full union it shares
535
- * with the HTTP claim surface and lease store.
536
- */
537
- readonly participantKind: ParticipantKind;
538
- readonly description?: string;
539
- /** Epoch-ms the claim was announced. */
540
- readonly announcedAt: number;
541
- /** Epoch-ms the server auto-expires it. */
542
- readonly expiresAt: number;
543
- }
544
440
  /**
545
441
  * Every lifecycle state of a coordination claim, in one enum.
546
442
  * `active` = the current holder (the lock). `queued` = waiting in the FIFO
@@ -554,44 +450,70 @@ export interface ClaimWaitOptions {
554
450
  readonly pollInterval?: number;
555
451
  readonly signal?: AbortSignal;
556
452
  }
557
- /**
558
- * The coordination state of one entity. Self-describing on the wire via
559
- * `object: 'claim'`. Existence with `status: 'active'` *is* the lock;
560
- * the fields *are* the awareness ("agent X is editing this until Y").
561
- *
562
- * Deliberately omits a Stripe-style `next_action`: a contender's only
563
- * response is "wait until free, then re-read", and the runtime performs
564
- * that uniformly — `claim` serializes behind the holder via the server
565
- * FIFO queue (or low-level `claims.waitFor` to wait without claiming), and the
566
- * stale-context guard forces the re-read. Encoding a constant instruction
567
- * the engine always takes would be the kind of ceremony this object exists
568
- * to remove.
569
- */
570
- export interface Claim {
453
+ export interface Claim<T = Record<string, unknown>> {
571
454
  readonly object: 'claim';
455
+ /** This claim's id. */
572
456
  readonly id: string;
573
- readonly status: ClaimStatus;
457
+ /**
458
+ * Lifecycle state. `active` = the holder (the lock); `queued` = waiting in
459
+ * the FIFO line (carries `position`). Optional on shapes derived from a
460
+ * presence frame that don't carry it — a present entry is `active` by
461
+ * construction.
462
+ */
463
+ readonly status?: ClaimStatus;
574
464
  /** What is being coordinated. */
575
- readonly target: EntityRef;
576
- /** Human-readable phase — `'editing'`, `'writing'`, `'reviewing'`. The same
577
- * field on every claim surface; distinct from the CRUD operation. Serialized
578
- * on the wire as `action`. */
465
+ readonly target: ClaimTarget;
466
+ /**
467
+ * Human-readable phase `'editing'`, `'writing'`, `'reviewing'`. The same
468
+ * field on every claim surface; distinct from the CRUD operation. Defaults to
469
+ * `'editing'`.
470
+ */
579
471
  readonly reason: string;
580
472
  /** Peer-visible explanation of the work being performed. */
581
473
  readonly description?: string;
582
- /** Participant holding it. */
583
- readonly heldBy: string;
584
- readonly participantKind: ParticipantKind;
474
+ /** Participant holding it. Absent on a handle you hold for yourself. */
475
+ readonly heldBy?: string;
585
476
  /**
586
- * Epoch-ms the holder opened it. Optional until the lease wire carries
587
- * it derived shapes (e.g. mapped from a presence frame) may omit it.
477
+ * Whether the holder is a user (session), agent, or system actor — so UIs
478
+ * style "agent editing X" vs "user editing X" without string-parsing
479
+ * `heldBy`. Present on observed claims; absent on your own fresh handle.
588
480
  */
481
+ readonly participantKind?: ParticipantKind;
482
+ /** Epoch-ms the holder opened it. */
589
483
  readonly createdAt?: number;
590
484
  /** Epoch-ms the server auto-expires it if the holder doesn't finish. */
591
- readonly expiresAt: number;
485
+ readonly expiresAt?: number;
486
+ /**
487
+ * Seconds remaining until auto-expiry — an OUTPUT countdown (plain `number`),
488
+ * distinct from the input `ttl: Duration` (`'3m'`) you pass when claiming.
489
+ */
490
+ readonly ttlSeconds?: number;
592
491
  /**
593
492
  * 0-based place in the FIFO line — present only when `status: 'queued'`
594
- * (`0` = next in line behind the holder). Absent for the active holder.
493
+ * (`0` = next behind the holder). Absent for the active holder.
595
494
  */
596
495
  readonly position?: number;
496
+ /**
497
+ * True when the grant came AFTER waiting in the server's FIFO line
498
+ * (`claim_granted`) — the authoritative "the row may have changed under us"
499
+ * signal. Absent for an immediate (uncontended) grant.
500
+ */
501
+ readonly waited?: boolean;
502
+ /**
503
+ * Sync watermark of the held snapshot (`data` was read at this stamp). Writes
504
+ * carrying the claim use it as the `readAt` stale guard. Present on a claim
505
+ * you hold.
506
+ */
507
+ readonly readAt?: number;
508
+ /** Row snapshot under the lease — present on a claim you hold. */
509
+ readonly data?: T;
510
+ /**
511
+ * Release the lease. Present only on a claim you HOLD (returned by
512
+ * `ablo.<model>.claim`); absent on observed peer claims.
513
+ */
514
+ release?(): Promise<void>;
515
+ /** Synchronously abandon the lease. Present only on a claim you hold. */
516
+ revoke?(): void;
517
+ /** Auto-release on `await using` scope exit. Present only on a held claim. */
518
+ [Symbol.asyncDispose]?(): PromiseLike<void>;
597
519
  }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * json.ts — key-order-insensitive comparison helpers for JSON-shaped values.
3
+ *
4
+ * Why this exists as a first-class, exported util:
5
+ *
6
+ * A `field.json()` value may be backed by a Postgres `jsonb` column, and **jsonb
7
+ * does not preserve object key order** (it reorders keys by length, then
8
+ * bytewise, and drops insignificant whitespace — see
9
+ * https://www.postgresql.org/docs/current/datatype-json.html). So a document an
10
+ * app wrote as `{type,text}` streams back in a delta as `{text,type}`: the same
11
+ * value, a different serialization.
12
+ *
13
+ * That bites any app that reconciles an Ablo row against an *external* state
14
+ * container it doesn't control — a rich-text editor (Tiptap/ProseMirror/Slate),
15
+ * a `useState`, a form buffer. The natural guard, `JSON.stringify(remote) ===
16
+ * JSON.stringify(local)`, is silently wrong because the two sides serialize keys
17
+ * in different orders, so it never matches — and the app re-applies the remote
18
+ * value on every render, clobbering in-flight edits and fighting the cursor.
19
+ *
20
+ * The fix is to compare order-insensitively. `deepEqual` does structural
21
+ * equality directly; `stableStringify` produces a canonical string (recursively
22
+ * sorted keys) for when you need a stable cache key / dependency value. The SDK
23
+ * already uses `deepEqual` internally for store-level echo detection; this
24
+ * module makes the same guarantee available to app authors so they don't each
25
+ * reinvent it.
26
+ *
27
+ * (If you need byte-exact key order preserved end-to-end, store the field in a
28
+ * `text` column instead of `jsonb` — Ablo's adaptive codec serializes verbatim
29
+ * there, matching Postgres's `json` type behavior.)
30
+ */
31
+ /** Structural equality for JSON-shaped values (scalars, arrays, plain objects); key order is ignored. */
32
+ export declare function deepEqual(a: unknown, b: unknown): boolean;
33
+ /**
34
+ * Canonical JSON serialization: recursively sorts object keys so two values that
35
+ * differ only in key order (e.g. a jsonb round-trip) produce the same string.
36
+ * Use this when you need a comparable/cacheable string rather than a boolean —
37
+ * e.g. an echo guard or a `useEffect`/`useMemo` dependency.
38
+ */
39
+ export declare function stableStringify(value: unknown): string;
@@ -0,0 +1,88 @@
1
+ /**
2
+ * json.ts — key-order-insensitive comparison helpers for JSON-shaped values.
3
+ *
4
+ * Why this exists as a first-class, exported util:
5
+ *
6
+ * A `field.json()` value may be backed by a Postgres `jsonb` column, and **jsonb
7
+ * does not preserve object key order** (it reorders keys by length, then
8
+ * bytewise, and drops insignificant whitespace — see
9
+ * https://www.postgresql.org/docs/current/datatype-json.html). So a document an
10
+ * app wrote as `{type,text}` streams back in a delta as `{text,type}`: the same
11
+ * value, a different serialization.
12
+ *
13
+ * That bites any app that reconciles an Ablo row against an *external* state
14
+ * container it doesn't control — a rich-text editor (Tiptap/ProseMirror/Slate),
15
+ * a `useState`, a form buffer. The natural guard, `JSON.stringify(remote) ===
16
+ * JSON.stringify(local)`, is silently wrong because the two sides serialize keys
17
+ * in different orders, so it never matches — and the app re-applies the remote
18
+ * value on every render, clobbering in-flight edits and fighting the cursor.
19
+ *
20
+ * The fix is to compare order-insensitively. `deepEqual` does structural
21
+ * equality directly; `stableStringify` produces a canonical string (recursively
22
+ * sorted keys) for when you need a stable cache key / dependency value. The SDK
23
+ * already uses `deepEqual` internally for store-level echo detection; this
24
+ * module makes the same guarantee available to app authors so they don't each
25
+ * reinvent it.
26
+ *
27
+ * (If you need byte-exact key order preserved end-to-end, store the field in a
28
+ * `text` column instead of `jsonb` — Ablo's adaptive codec serializes verbatim
29
+ * there, matching Postgres's `json` type behavior.)
30
+ */
31
+ /** Structural equality for JSON-shaped values (scalars, arrays, plain objects); key order is ignored. */
32
+ export function deepEqual(a, b) {
33
+ if (a === b)
34
+ return true;
35
+ if (a === null || b === null || typeof a !== 'object' || typeof b !== 'object') {
36
+ return false;
37
+ }
38
+ const aArr = Array.isArray(a);
39
+ if (aArr !== Array.isArray(b))
40
+ return false;
41
+ if (aArr) {
42
+ const av = a;
43
+ const bv = b;
44
+ if (av.length !== bv.length)
45
+ return false;
46
+ for (let i = 0; i < av.length; i++) {
47
+ if (!deepEqual(av[i], bv[i]))
48
+ return false;
49
+ }
50
+ return true;
51
+ }
52
+ const ao = a;
53
+ const bo = b;
54
+ const ak = Object.keys(ao);
55
+ const bk = Object.keys(bo);
56
+ if (ak.length !== bk.length)
57
+ return false;
58
+ for (const k of ak) {
59
+ if (!Object.prototype.hasOwnProperty.call(bo, k))
60
+ return false;
61
+ if (!deepEqual(ao[k], bo[k]))
62
+ return false;
63
+ }
64
+ return true;
65
+ }
66
+ /**
67
+ * Canonical JSON serialization: recursively sorts object keys so two values that
68
+ * differ only in key order (e.g. a jsonb round-trip) produce the same string.
69
+ * Use this when you need a comparable/cacheable string rather than a boolean —
70
+ * e.g. an echo guard or a `useEffect`/`useMemo` dependency.
71
+ */
72
+ export function stableStringify(value) {
73
+ return JSON.stringify(sortKeysDeep(value));
74
+ }
75
+ function sortKeysDeep(value) {
76
+ if (Array.isArray(value))
77
+ return value.map(sortKeysDeep);
78
+ if (value && typeof value === 'object') {
79
+ const source = value;
80
+ return Object.keys(source)
81
+ .sort()
82
+ .reduce((acc, key) => {
83
+ acc[key] = sortKeysDeep(source[key]);
84
+ return acc;
85
+ }, {});
86
+ }
87
+ return value;
88
+ }
@@ -25,6 +25,22 @@ one answer to "how do two agents not clobber each other" — then covers the
25
25
  (`claim` · `claim.state` · `claim.queue` · `claim.release` · [writing under a
26
26
  claim](#writing-under-a-claim)), and the [errors](#errors) you can catch.
27
27
 
28
+ > **Before anything else: one identity per agent.** Coordination excludes
29
+ > *participants*, and a participant **is the key**, not the client object. The
30
+ > server derives identity from the credential's scope — so **N clients sharing
31
+ > one `sk_`/`ek_` are one participant**: they all see the same `heldBy`, never
32
+ > queue behind each other, and a "second" claimer silently re-takes the lease it
33
+ > already holds (no mutual exclusion). To get real per-agent exclusion, mint a
34
+ > **distinct scoped `rk_` per agent** and bind a client to it:
35
+ >
36
+ > ```ts
37
+ > const { token } = await ablo.sessions.create({ agent: { id: `agent-${i}` } }); // rk_
38
+ > const agent = Ablo({ schema, apiKey: token }); // this agent's own participant
39
+ > ```
40
+ >
41
+ > Now `agent-0` holds while `agent-1`/`agent-2` queue in FIFO order and drain in
42
+ > turn. See [sessions](./sessions.md#agent-sessions-rk_) for the minting flow.
43
+
28
44
  ---
29
45
 
30
46
  ## The model — three layers, one decision
@@ -0,0 +1,194 @@
1
+ # Debugging & Logs
2
+
3
+ By default the SDK is quiet — it logs only warnings and errors. When you're building a human + agent flow and want to *see* the coordination happen (who claimed what, who's waiting in line, who got preempted), turn on Ablo's diagnostic logging. Every line is prefixed `[Ablo]` so it's obvious which output is ours in a console full of other tools.
4
+
5
+ ## Turn it on
6
+
7
+ ```ts
8
+ import Ablo from '@abloatai/ablo';
9
+ import { schema } from './ablo/schema';
10
+
11
+ const ablo = Ablo({ schema, apiKey: process.env.ABLO_API_KEY, debug: true });
12
+ ```
13
+
14
+ `debug: true` is the simple switch. For finer control use `logLevel`, or set it without touching code via the `ABLO_LOG_LEVEL` environment variable.
15
+
16
+ ```ts
17
+ Ablo({ schema, apiKey }) // quiet — warnings + errors only (default)
18
+ Ablo({ schema, apiKey, debug: true }) // everything (coordination + lifecycle)
19
+ Ablo({ schema, apiKey, logLevel: 'info' }) // coordination + connection, no per-model noise
20
+ ```
21
+
22
+ ```bash
23
+ ABLO_LOG_LEVEL=debug npm run dev # same, from the environment
24
+ ```
25
+
26
+ ### Levels
27
+
28
+ | Level | What it shows |
29
+ |---|---|
30
+ | `silent` | nothing |
31
+ | `error` | failures only |
32
+ | `warn` | **default** — warnings + errors |
33
+ | `info` | the above + the **coordination trace** (claims, grants, queueing) + connection state |
34
+ | `debug` | the above + internal lifecycle (per-model registration, store hydration) — the full firehose |
35
+
36
+ Precedence: an explicit `logLevel` wins, then `debug: true` (⇒ `debug`), then `ABLO_LOG_LEVEL`, then the `warn` default. `debug: false` (or omitting it) just means "don't raise the level."
37
+
38
+ > For watching coordination, **`logLevel: 'info'` is the sweet spot** — you get the claim trace without the per-model registration chatter that `debug` adds.
39
+
40
+ ## What you'll see — the coordination trace
41
+
42
+ These lines (all at `info`) let you watch the human + agent handover you built:
43
+
44
+ ```
45
+ [Ablo] claim: requesting documents:doc_42 for "editing" (will queue if contended)
46
+ [Ablo] claim: queued for documents:doc_42 — position 2 of 3, waiting
47
+ [Ablo] claim: granted 7f3c… — your turn (waited in queue)
48
+ [Ablo] claim: rejected documents:doc_42 — held by agent_writer
49
+ [Ablo] claim: lost documents:doc_42 (preempted or expired)
50
+ [Ablo] claim: released documents:doc_42
51
+ ```
52
+
53
+ Read it as the lifecycle of one claim:
54
+
55
+ - **`requesting`** — your code (or an agent) called `ablo.<model>.claim(...)`. `(will queue if contended)` appears when you passed `{ queue: true }`.
56
+ - **`queued … position N of M`** — the row was held, so you're waiting in the FIFO line. This is the "an agent is waiting behind a claim" moment; it re-logs only when your position changes, so you can watch it advance.
57
+ - **`granted … your turn`** — you reached the head of the line; the lease is now yours and the row may have changed while you waited.
58
+ - **`rejected … held by <who>`** — your claim was refused because someone else holds it (and the model's policy didn't let you in).
59
+ - **`lost`** — you held the lease and it was taken (preempted by a higher-priority writer, or it expired).
60
+ - **`released`** — you (or `await using`'s scope exit) gave the lease back.
61
+
62
+ ## Where the logs run
63
+
64
+ The coordination trace and the proactive credential refresh run **in the browser** (and any client that holds a live socket) — that's where the human + agent activity is. Server-side code that mints credentials or does one-shot reads won't emit the trace; it has no live session to narrate.
65
+
66
+ ## Bring your own logger
67
+
68
+ Pass a `logger` to route Ablo's output into your own logging stack (Pino, Sentry breadcrumbs, etc.). A custom logger bypasses `debug`/`logLevel` entirely — you decide what to do with each level.
69
+
70
+ ```ts
71
+ Ablo({
72
+ schema,
73
+ apiKey,
74
+ logger: {
75
+ debug: (...a) => {},
76
+ info: (...a) => myLogger.info({ ablo: a }),
77
+ warn: (...a) => myLogger.warn({ ablo: a }),
78
+ error: (...a) => myLogger.error({ ablo: a }),
79
+ },
80
+ });
81
+ ```
82
+
83
+ ## Read the coordination in code — the activity log
84
+
85
+ The console trace above is for *you*, at a terminal. To put the same activity **inside your app** — an activity feed, a "who's editing" badge, a Sentry breadcrumb trail — read it programmatically. Same events, three layers; pick by audience:
86
+
87
+ | Layer | You get | Best for |
88
+ |---|---|---|
89
+ | `logger` (above) | `[Ablo]` text lines | watching a terminal |
90
+ | `observability` | typed `ClaimEvent` / `ConflictEvent` objects | dashboards, alerting (Sentry / Datadog / OTel) |
91
+ | `ClaimLog` | an ordered, **reactive** list of both | rendering an activity feed on a page |
92
+
93
+ ### The events
94
+
95
+ Every claim state change is a `ClaimEvent`; every notify-instead-of-abort stale write (a write that succeeded but whose premise had moved) is a `ConflictEvent`:
96
+
97
+ ```ts
98
+ interface ClaimEvent {
99
+ phase: 'acquired' | 'queued' | 'granted' | 'lost' | 'rejected' | 'expired';
100
+ model?: string; id?: string; field?: string; // the claimed row
101
+ actor?: string; participantKind?: 'user' | 'agent' | 'system';
102
+ position?: number; // FIFO position, when queued
103
+ reason?: string; // why, on rejected
104
+ claimId?: string;
105
+ }
106
+
107
+ interface ConflictEvent {
108
+ clientTxId: string;
109
+ rows: { model: string; id: string; fields: string[]; writtenBy?: 'user' | 'agent' | 'system' }[];
110
+ }
111
+ ```
112
+
113
+ `phase` is past-tense — the state the claim just entered — and maps one-to-one to what arrives on the wire.
114
+
115
+ ### Collect them — `ClaimLog`
116
+
117
+ `ClaimLog` records both into an ordered list. Hand it to `observability`, then read it back:
118
+
119
+ ```ts
120
+ import Ablo, { ClaimLog } from '@abloatai/ablo';
121
+
122
+ const log = new ClaimLog();
123
+ const ablo = Ablo({ schema, apiKey, observability: log });
124
+
125
+ // …run the agents…
126
+ console.log(`${log}`); // a printable, ⚠-marked timeline
127
+ log.entries; // ClaimLogEntry[] — every event, in order, with a `.line`
128
+ log.collisions(); // just the rejected/lost claims + stale writes
129
+ ```
130
+
131
+ It's also the simplest way to **assert** coordination in a test — no log scraping:
132
+
133
+ ```ts
134
+ expect(log.collisions()).toHaveLength(0); // no one stepped on anyone
135
+ ```
136
+
137
+ ### Show it on a page — reactive
138
+
139
+ `ClaimLog.onChange` fires on every event and returns an unsubscribe — the exact shape `useSyncExternalStore` wants, so a live feed is a few lines:
140
+
141
+ ```tsx
142
+ import { useSyncExternalStore } from 'react';
143
+ import { ClaimLog } from '@abloatai/ablo';
144
+
145
+ function ActivityFeed({ log }: { log: ClaimLog }) {
146
+ const entries = useSyncExternalStore(log.onChange, () => log.entries);
147
+ return (
148
+ <ul>
149
+ {entries.map((e) => (
150
+ <li key={e.seq} className={e.collision ? 'text-amber-600' : undefined}>{e.line}</li>
151
+ ))}
152
+ </ul>
153
+ );
154
+ }
155
+ ```
156
+
157
+ > `ClaimLog` lives in browser memory: it starts empty on load and shows events that arrive while mounted. For a feed that survives reload, persist `entries` yourself — but for a live coordination panel, the in-memory log is exactly right.
158
+
159
+ For **"who holds *this* row right now"** (a badge, not a feed), don't use `ClaimLog` — read the reactive claim state directly. It re-renders on change with no extra wiring:
160
+
161
+ ```tsx
162
+ const holder = useAblo((ablo) => ablo.documents.claim.state({ id })); // Claim | null
163
+ ```
164
+
165
+ See [React](./react.md) and [Coordination](./coordination.md) for the claim-read APIs.
166
+
167
+ ### Route to your own backend
168
+
169
+ `ClaimLog` is one implementation of the `observability` slot. To push events into Sentry, Datadog, or OpenTelemetry instead, spread `noopObservability` and override just the two coordination hooks:
170
+
171
+ ```ts
172
+ import Ablo, { noopObservability } from '@abloatai/ablo';
173
+
174
+ const ablo = Ablo({
175
+ schema, apiKey,
176
+ observability: {
177
+ ...noopObservability,
178
+ captureClaim: (e) => {
179
+ if (e.phase === 'rejected') Sentry.captureMessage(`claim blocked: ${e.model}/${e.id} by ${e.actor}`);
180
+ },
181
+ captureConflict: (e) => Sentry.captureMessage(`stale write tx ${e.clientTxId} on ${e.rows.length} row(s)`),
182
+ },
183
+ });
184
+ ```
185
+
186
+ ## Errors
187
+
188
+ Ablo's thrown errors are typed and self-describing — `String(err)` (or logging it) yields one clean line, never a stack dump:
189
+
190
+ ```
191
+ AbloValidationError [model_required_field_missing]: A required field was absent. (see https://docs.abloatai.com/errors#model_required_field_missing) [request_id: req_8Fk2aQ]
192
+ ```
193
+
194
+ Branch on `err.code` (stable) — never on the message (rewordable). See [Client Behavior](./client-behavior.md) for the full error model and which codes are safe to retry.
package/docs/index.md CHANGED
@@ -48,6 +48,7 @@ Three things stay true no matter how you use Ablo:
48
48
  - [Interaction Model](./interaction-model.md) — The schema, claim, update, confirmation loop.
49
49
  - [API Reference](./api.md) — Model-by-model method shape.
50
50
  - [Client Behavior](./client-behavior.md) — Options, errors, retries, timeouts, and imports.
51
+ - [Debugging & Logs](./debugging.md) — Turn on the `[Ablo]` coordination trace (`debug` / `logLevel`) to watch claims, queueing, and grants while you build — or read the same activity in code via `ClaimLog` to render an activity feed.
51
52
  - [Connect Your Database](./data-sources.md) — Keep canonical rows in your app database without giving Ablo database credentials.
52
53
  - [React](./react.md) — Provider, hooks, and reactive reads for React apps.
53
54
  - [API Keys](./api-keys.md) — Bearer tokens for the public API.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abloatai/ablo",
3
- "version": "0.18.0",
3
+ "version": "0.20.0",
4
4
  "description": "The Collaboration Layer For AI Agents",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",