@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.
- package/CHANGELOG.md +49 -0
- package/dist/Model.d.ts +22 -0
- package/dist/Model.js +45 -0
- package/dist/ObjectPool.d.ts +14 -1
- package/dist/ObjectPool.js +8 -1
- package/dist/SyncClient.js +7 -1
- package/dist/SyncEngineContext.js +2 -0
- package/dist/ai-sdk/coordination-context.d.ts +2 -1
- package/dist/ai-sdk/coordination-context.js +3 -3
- package/dist/ai-sdk/index.d.ts +3 -4
- package/dist/ai-sdk/index.js +2 -3
- package/dist/ai-sdk/wrap.d.ts +13 -18
- package/dist/ai-sdk/wrap.js +2 -6
- package/dist/cli.cjs +253 -160
- package/dist/client/Ablo.d.ts +83 -22
- package/dist/client/Ablo.js +116 -32
- package/dist/client/ApiClient.d.ts +2 -2
- package/dist/client/ApiClient.js +27 -32
- package/dist/client/auth.d.ts +26 -0
- package/dist/client/auth.js +208 -0
- package/dist/client/createModelProxy.d.ts +16 -11
- package/dist/client/createModelProxy.js +15 -15
- package/dist/client/sessionMint.d.ts +10 -0
- package/dist/client/sessionMint.js +8 -1
- package/dist/coordination/schema.d.ts +10 -10
- package/dist/coordination/schema.js +7 -8
- package/dist/coordination/trace.d.ts +91 -0
- package/dist/coordination/trace.js +147 -0
- package/dist/errorCodes.d.ts +2 -0
- package/dist/errorCodes.js +2 -0
- package/dist/errors.d.ts +1 -2
- package/dist/errors.js +6 -9
- package/dist/index.d.ts +6 -1
- package/dist/index.js +13 -0
- package/dist/interfaces/index.d.ts +45 -2
- package/dist/mutators/undoApply.d.ts +7 -2
- package/dist/mutators/undoApply.js +7 -35
- package/dist/policy/types.d.ts +18 -9
- package/dist/policy/types.js +26 -16
- package/dist/react/AbloProvider.d.ts +2 -2
- package/dist/react/useAblo.js +12 -2
- package/dist/schema/sugar.js +10 -2
- package/dist/server/read-config.d.ts +7 -0
- package/dist/sync/HydrationCoordinator.js +7 -3
- package/dist/sync/SyncWebSocket.d.ts +11 -2
- package/dist/sync/SyncWebSocket.js +67 -3
- package/dist/sync/createClaimStream.d.ts +9 -2
- package/dist/sync/createClaimStream.js +9 -7
- package/dist/sync/participants.d.ts +12 -11
- package/dist/sync/participants.js +5 -5
- package/dist/transactions/TransactionQueue.d.ts +1 -0
- package/dist/transactions/TransactionQueue.js +40 -3
- package/dist/types/streams.d.ts +57 -135
- package/dist/utils/json.d.ts +39 -0
- package/dist/utils/json.js +88 -0
- package/docs/coordination.md +16 -0
- package/docs/debugging.md +194 -0
- package/docs/index.md +1 -0
- package/package.json +1 -1
- package/dist/ai-sdk/claim-broadcast.d.ts +0 -83
- package/dist/ai-sdk/claim-broadcast.js +0 -79
package/dist/types/streams.d.ts
CHANGED
|
@@ -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
|
|
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 `
|
|
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 =
|
|
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<
|
|
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<
|
|
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
|
-
|
|
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:
|
|
576
|
-
/**
|
|
577
|
-
*
|
|
578
|
-
*
|
|
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
|
|
584
|
-
readonly participantKind: ParticipantKind;
|
|
474
|
+
/** Participant holding it. Absent on a handle you hold for yourself. */
|
|
475
|
+
readonly heldBy?: string;
|
|
585
476
|
/**
|
|
586
|
-
*
|
|
587
|
-
*
|
|
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
|
|
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
|
|
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
|
+
}
|
package/docs/coordination.md
CHANGED
|
@@ -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.
|