@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
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Claim log — the easy way to SEE agents colliding.
3
+ *
4
+ * The engine emits the claim lifecycle (`acquired → queued → granted → lost /
5
+ * rejected / expired`) and the notify-instead-of-abort stale-write collision
6
+ * through two seams: human `logger` lines (visible with `new Ablo({ debug: true })`)
7
+ * and structured `observability.captureClaim` / `captureConflict` calls.
8
+ *
9
+ * This is the third, evals-shaped path: hand a {@link ClaimLog} to
10
+ * `Ablo({ observability })`, run your scenario, then read back an ordered list
11
+ * you can print for eyeballing or `collisions()` for assertions.
12
+ */
13
+ import type { SyncObservabilityProvider, ClaimEvent, ConflictEvent } from '../interfaces/index.js';
14
+ /** A claim state change as one quiet, greppable line. */
15
+ export declare function formatClaim(e: ClaimEvent): string;
16
+ /** A notify-instead-of-abort stale write as one readable line. */
17
+ export declare function formatConflict(e: ConflictEvent): string;
18
+ /** One ordered entry in a {@link ClaimLog}. */
19
+ export interface ClaimLogEntry {
20
+ /** Monotonic order index — deterministic, clock-free, eval-friendly. */
21
+ readonly seq: number;
22
+ /** The same one-line text the console and breadcrumb seams emit. */
23
+ readonly line: string;
24
+ /** A collision worth flagging: a rejected/lost claim or a stale write. */
25
+ readonly collision: boolean;
26
+ readonly claim?: ClaimEvent;
27
+ readonly conflict?: ConflictEvent;
28
+ }
29
+ /**
30
+ * Collects the claim lifecycle + stale-write collisions into an ordered list.
31
+ *
32
+ * @example
33
+ * ```ts
34
+ * const log = new ClaimLog();
35
+ * const ablo = new Ablo({ schema, apiKey, observability: log });
36
+ * // …run the agents…
37
+ * console.log(`${log}`); // pretty timeline for eyeballing
38
+ * expect(log.collisions()).toHaveLength(0); // assert no one stepped on anyone
39
+ * ```
40
+ *
41
+ * Implements the full {@link SyncObservabilityProvider} (every method it does not
42
+ * care about is an inert no-op), so it drops straight into the `observability`
43
+ * slot with no adapter.
44
+ */
45
+ export declare class ClaimLog implements SyncObservabilityProvider {
46
+ private readonly max;
47
+ private rows;
48
+ private seq;
49
+ private readonly listeners;
50
+ /** Cap the buffer so a long-running session can't grow unbounded. */
51
+ constructor(max?: number);
52
+ captureClaim(claim: ClaimEvent): void;
53
+ captureConflict(conflict: ConflictEvent): void;
54
+ /**
55
+ * Fire `listener` every time an event lands. Returns an unsubscribe fn. Same
56
+ * contract as `ablo.claims.onChange` — drop it into `useSyncExternalStore` in
57
+ * React or `autorun` in MobX to render a live activity feed.
58
+ *
59
+ * @example
60
+ * ```tsx
61
+ * const log = useMemo(() => new ClaimLog(), []);
62
+ * const entries = useSyncExternalStore(log.onChange, () => log.entries);
63
+ * return <ul>{entries.map((e) => <li key={e.seq}>{e.line}</li>)}</ul>;
64
+ * ```
65
+ */
66
+ readonly onChange: (listener: () => void) => (() => void);
67
+ /** The full ordered list. Stable reference until the next event lands. */
68
+ get entries(): readonly ClaimLogEntry[];
69
+ /** Only the collisions: rejected/lost claims + stale writes. */
70
+ collisions(): readonly ClaimLogEntry[];
71
+ /** Drop everything — useful between scenarios in one process. */
72
+ clear(): void;
73
+ /** Printable: one event per line, collisions marked. */
74
+ toString(): string;
75
+ private add;
76
+ private notify;
77
+ setContext(): void;
78
+ setConnectionState(): void;
79
+ breadcrumb(): void;
80
+ captureRollback(): void;
81
+ captureTransactionFailure(): void;
82
+ captureBootstrapFailure(): void;
83
+ captureReconciliation(): void;
84
+ captureDeltaRetryExhausted(): void;
85
+ captureWebSocketError(): void;
86
+ captureOfflineFlushFailure(): void;
87
+ captureSelfHealing(): void;
88
+ captureCommitZeroSyncId(): void;
89
+ startSpan<T>(_name: string, _op: string, fn: () => T): T;
90
+ startSpanAsync<T>(_name: string, _op: string, fn: () => Promise<T>): Promise<T>;
91
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Claim log — the easy way to SEE agents colliding.
3
+ *
4
+ * The engine emits the claim lifecycle (`acquired → queued → granted → lost /
5
+ * rejected / expired`) and the notify-instead-of-abort stale-write collision
6
+ * through two seams: human `logger` lines (visible with `new Ablo({ debug: true })`)
7
+ * and structured `observability.captureClaim` / `captureConflict` calls.
8
+ *
9
+ * This is the third, evals-shaped path: hand a {@link ClaimLog} to
10
+ * `Ablo({ observability })`, run your scenario, then read back an ordered list
11
+ * you can print for eyeballing or `collisions()` for assertions.
12
+ */
13
+ // ─────────────────────────────────────────────
14
+ // Formatters — one readable line per event. Shared by the WS logger seam and
15
+ // the log so console output and log output never drift.
16
+ // ─────────────────────────────────────────────
17
+ /** A claim state change as one quiet, greppable line. */
18
+ export function formatClaim(e) {
19
+ const target = e.model && e.id ? `${e.model}/${e.id}` : e.claimId ?? 'unknown target';
20
+ const scope = e.field ? `#${e.field}` : '';
21
+ const pos = e.position !== undefined ? ` [pos ${e.position}]` : '';
22
+ const actor = e.actor
23
+ ? `${e.actor}${e.participantKind ? ` (${e.participantKind})` : ''}`
24
+ : '';
25
+ // `rejected`/`lost` name the BLOCKING holder; the rest name us (or no one).
26
+ const by = actor
27
+ ? e.phase === 'rejected' || e.phase === 'lost'
28
+ ? ` — held by ${actor}`
29
+ : ` — by ${actor}`
30
+ : '';
31
+ const why = e.reason ? `: ${e.reason}` : '';
32
+ return `claim ${e.phase}: ${target}${scope}${pos}${by}${why}`;
33
+ }
34
+ /** A notify-instead-of-abort stale write as one readable line. */
35
+ export function formatConflict(e) {
36
+ const rows = e.rows.map((r) => `${r.model}/${r.id}(${r.fields.join(',')})`).join(', ');
37
+ return `conflict: tx ${e.clientTxId} — ${e.rows.length} row(s) changed underneath${rows ? `: ${rows}` : ''}`;
38
+ }
39
+ /**
40
+ * Collects the claim lifecycle + stale-write collisions into an ordered list.
41
+ *
42
+ * @example
43
+ * ```ts
44
+ * const log = new ClaimLog();
45
+ * const ablo = new Ablo({ schema, apiKey, observability: log });
46
+ * // …run the agents…
47
+ * console.log(`${log}`); // pretty timeline for eyeballing
48
+ * expect(log.collisions()).toHaveLength(0); // assert no one stepped on anyone
49
+ * ```
50
+ *
51
+ * Implements the full {@link SyncObservabilityProvider} (every method it does not
52
+ * care about is an inert no-op), so it drops straight into the `observability`
53
+ * slot with no adapter.
54
+ */
55
+ export class ClaimLog {
56
+ max;
57
+ // Immutable list: a NEW array reference on every change. This is what lets
58
+ // `useSyncExternalStore(log.onChange, () => log.entries)` detect updates —
59
+ // it compares snapshots by reference, so an in-place push would never render.
60
+ rows = [];
61
+ seq = 0;
62
+ listeners = new Set();
63
+ /** Cap the buffer so a long-running session can't grow unbounded. */
64
+ constructor(max = 1_000) {
65
+ this.max = max;
66
+ }
67
+ // —— the two seams we record ——
68
+ captureClaim(claim) {
69
+ this.add({
70
+ line: formatClaim(claim),
71
+ collision: claim.phase === 'rejected' || claim.phase === 'lost',
72
+ claim,
73
+ });
74
+ }
75
+ captureConflict(conflict) {
76
+ this.add({ line: formatConflict(conflict), collision: true, conflict });
77
+ }
78
+ // —— reactivity ——
79
+ /**
80
+ * Fire `listener` every time an event lands. Returns an unsubscribe fn. Same
81
+ * contract as `ablo.claims.onChange` — drop it into `useSyncExternalStore` in
82
+ * React or `autorun` in MobX to render a live activity feed.
83
+ *
84
+ * @example
85
+ * ```tsx
86
+ * const log = useMemo(() => new ClaimLog(), []);
87
+ * const entries = useSyncExternalStore(log.onChange, () => log.entries);
88
+ * return <ul>{entries.map((e) => <li key={e.seq}>{e.line}</li>)}</ul>;
89
+ * ```
90
+ */
91
+ onChange = (listener) => {
92
+ this.listeners.add(listener);
93
+ return () => this.listeners.delete(listener);
94
+ };
95
+ // —— inspection ——
96
+ /** The full ordered list. Stable reference until the next event lands. */
97
+ get entries() {
98
+ return this.rows;
99
+ }
100
+ /** Only the collisions: rejected/lost claims + stale writes. */
101
+ collisions() {
102
+ return this.rows.filter((e) => e.collision);
103
+ }
104
+ /** Drop everything — useful between scenarios in one process. */
105
+ clear() {
106
+ this.rows = [];
107
+ this.seq = 0;
108
+ this.notify();
109
+ }
110
+ /** Printable: one event per line, collisions marked. */
111
+ toString() {
112
+ if (this.rows.length === 0)
113
+ return 'claim log: (empty)';
114
+ const w = String(this.rows.length).length;
115
+ return this.rows
116
+ .map((e) => `${String(e.seq).padStart(w)} ${e.collision ? '⚠ ' : ' '}${e.line}`)
117
+ .join('\n');
118
+ }
119
+ add(entry) {
120
+ const next = [...this.rows, { seq: this.seq++, ...entry }];
121
+ this.rows = next.length > this.max ? next.slice(next.length - this.max) : next;
122
+ this.notify();
123
+ }
124
+ notify() {
125
+ for (const listener of this.listeners)
126
+ listener();
127
+ }
128
+ // —— inert provider surface (this log only cares about claims) ——
129
+ setContext() { }
130
+ setConnectionState() { }
131
+ breadcrumb() { }
132
+ captureRollback() { }
133
+ captureTransactionFailure() { }
134
+ captureBootstrapFailure() { }
135
+ captureReconciliation() { }
136
+ captureDeltaRetryExhausted() { }
137
+ captureWebSocketError() { }
138
+ captureOfflineFlushFailure() { }
139
+ captureSelfHealing() { }
140
+ captureCommitZeroSyncId() { }
141
+ startSpan(_name, _op, fn) {
142
+ return fn();
143
+ }
144
+ startSpanAsync(_name, _op, fn) {
145
+ return fn();
146
+ }
147
+ }
@@ -135,6 +135,7 @@ export declare const ERROR_CODES: {
135
135
  readonly browser_database_url_blocked: ErrorCodeSpec;
136
136
  readonly datasource_registration_failed: ErrorCodeSpec;
137
137
  readonly datasource_connection_unsupported: ErrorCodeSpec;
138
+ readonly datasource_direct_deprecated: ErrorCodeSpec;
138
139
  readonly capability_scope_denied: ErrorCodeSpec;
139
140
  readonly issuer_register_forbidden: ErrorCodeSpec;
140
141
  readonly capability_invalid: ErrorCodeSpec;
@@ -188,6 +189,7 @@ export declare const ERROR_CODES: {
188
189
  readonly unique_violation: ErrorCodeSpec;
189
190
  readonly check_violation: ErrorCodeSpec;
190
191
  readonly constraint_violation: ErrorCodeSpec;
192
+ readonly column_type_mismatch: ErrorCodeSpec;
191
193
  readonly server_execute_unknown_model: ErrorCodeSpec;
192
194
  readonly mutate_create_unknown_model: ErrorCodeSpec;
193
195
  readonly tenant_model_columns_unknown: ErrorCodeSpec;
@@ -125,6 +125,7 @@ export const ERROR_CODES = {
125
125
  browser_database_url_blocked: client('auth', 'A database connection string must not be used from a browser context — it carries DB credentials.'),
126
126
  datasource_registration_failed: client('auth', 'Failed to register the provided databaseUrl as a datasource.'),
127
127
  datasource_connection_unsupported: wire('validation', 400, false, 'This deployment cannot register a direct (connection string) datasource — use the signed endpoint kind.'),
128
+ datasource_direct_deprecated: wire('validation', 410, false, 'The direct (connection string) datasource is deprecated. Register a signed Data Source endpoint instead — your app owns the write and your credentials never leave it.'),
128
129
  // ── permission / capability (403) ──────────────────────────────────
129
130
  capability_scope_denied: wire('capability', 403, false, "The connection's resolved scope does not cover the attempted action."),
130
131
  issuer_register_forbidden: wire('permission', 403, false, 'Registering a trusted issuer requires a secret (sk_) API key.'),
@@ -201,6 +202,7 @@ export const ERROR_CODES = {
201
202
  unique_violation: wire('conflict', 409, false, 'A value violates a uniqueness constraint.'),
202
203
  check_violation: wire('validation', 400, false, 'A value violates a database check constraint.'),
203
204
  constraint_violation: wire('validation', 400, false, 'A database integrity constraint was violated.'),
205
+ column_type_mismatch: wire('validation', 400, false, 'A structured (JSON) value was written to a column whose database type cannot hold it. Ablo adapts a json field to either a jsonb column (native) or a text column (serialized) — but a scalar column (integer, boolean, uuid, timestamp, …) cannot store a JSON object or array. Use a jsonb or text column for this field. Ablo adapts to your column; it does not alter your schema.'),
204
206
  // ── tenant / unknown model (400) ───────────────────────────────────
205
207
  server_execute_unknown_model: wire('tenant', 400, false, 'Wrote to a model the server does not know. The server keeps its own copy of the schema — run `ablo push` (or keep `ablo dev` running) to upload `ablo/schema.ts` before writing to new or changed models.'),
206
208
  mutate_create_unknown_model: wire('tenant', 400, false, 'Created a model the server does not know. Run `ablo push` (or keep `ablo dev` running) to upload `ablo/schema.ts` first — the server keeps its own copy of the schema.'),
package/dist/errors.d.ts CHANGED
@@ -185,8 +185,7 @@ export interface ClaimContext {
185
185
  readonly claimId?: string;
186
186
  readonly actor?: string;
187
187
  readonly participantKind?: ParticipantKind;
188
- /** Human-readable phase the holder is in (`'editing'`). Matches the public
189
- * claim surface; the wire summary carries the same value as `action`. */
188
+ /** Human-readable phase the holder is in (`'editing'`). */
190
189
  readonly reason?: string;
191
190
  readonly description?: string;
192
191
  readonly field?: string;
package/dist/errors.js CHANGED
@@ -196,13 +196,10 @@ export class AbloStaleContextError extends AbloError {
196
196
  this.conflicts = options.conflicts;
197
197
  }
198
198
  }
199
- function claimAction(claim) {
199
+ function claimReason(claim) {
200
200
  if (!claim)
201
201
  return undefined;
202
- // The public `ClaimContext` exposes the phase as `reason`; the wire
203
- // `WireClaimSummary` projection still carries it under `action`. Read both.
204
- const c = claim;
205
- return c.reason ?? c.action;
202
+ return claim.reason;
206
203
  }
207
204
  function claimDescription(claim) {
208
205
  if (!claim)
@@ -229,20 +226,20 @@ function secondsUntil(ms, now = Date.now()) {
229
226
  }
230
227
  export function formatClaimedErrorMessage(args) {
231
228
  const holder = claimActor(args.claim, args.heldBy);
232
- const action = claimAction(args.claim);
229
+ const reason = claimReason(args.claim);
233
230
  const description = claimDescription(args.claim);
234
231
  const expiresIn = secondsUntil(claimExpiresAt(args.claim));
235
- if (!holder && !action && !description) {
232
+ if (!holder && !reason && !description) {
236
233
  return args.fallback ?? `Model row is claimed: ${args.targetLabel}.`;
237
234
  }
238
235
  const actor = holder ?? 'another participant';
239
- const actionPart = action ? ` (${action})` : '';
236
+ const reasonPart = reason ? ` (${reason})` : '';
240
237
  const descriptionPart = description ? `: ${description}` : '';
241
238
  const expiresPart = expiresIn !== undefined ? ` - expires in ${expiresIn}s` : '';
242
239
  const policyPart = args.policyReason
243
240
  ? ` Policy reason: ${args.policyReason}.`
244
241
  : '';
245
- return `Claimed by ${actor}${actionPart}${descriptionPart}${expiresPart} on ${args.targetLabel}.${policyPart}`;
242
+ return `Claimed by ${actor}${reasonPart}${descriptionPart}${expiresPart} on ${args.targetLabel}.${policyPart}`;
246
243
  }
247
244
  /**
248
245
  * The target entity is currently claimed by another participant and the caller
package/dist/index.d.ts CHANGED
@@ -52,7 +52,7 @@ export type { MutationExecutor } from './interfaces/index.js';
52
52
  export type { HttpClaimApi, InternalAbloOptions } from './client/Ablo.js';
53
53
  export { type AbloHttpClientOptions, type AbloHttpClient, type HttpModelClient, } from './client/httpClient.js';
54
54
  export { ABLO_DEFAULT_BASE_URL, ABLO_HOSTED_API_DOMAIN, ABLO_HOSTED_HTTP_BASE_URL, normalizeAbloHostedBaseUrl, } from './client/auth.js';
55
- export type { AbloOptions, LocalCountOptions, LocalReadOptions, ModelListScope, ServerReadOptions, ModelRetrieveParams, ModelCreateParams, ModelUpdateParams, ModelDeleteParams, ClaimOptions, ClaimParams, ClaimLookupParams, ClaimReorderParams, ClaimHandle, ModelOperations, } from './client/Ablo.js';
55
+ export type { AbloOptions, LocalCountOptions, LocalReadOptions, ModelListScope, ServerReadOptions, ModelRetrieveParams, ModelCreateParams, ModelUpdateParams, ModelDeleteParams, ClaimOptions, ClaimParams, ClaimLookupParams, ClaimReorderParams, Claim, ModelOperations, } from './client/Ablo.js';
56
56
  export type { AbloPersistence } from './client/persistence.js';
57
57
  import { Ablo } from './client/Ablo.js';
58
58
  export default Ablo;
@@ -72,9 +72,14 @@ export type { WriteOptionsInput } from './client/writeOptionsSchema.js';
72
72
  export type { WriteOptions, MutationOptions } from './interfaces/index.js';
73
73
  export { staleNotificationSchema, readDependencySchema } from './coordination/schema.js';
74
74
  export type { StaleNotification, ReadDependency } from './coordination/schema.js';
75
+ export { ClaimLog, formatClaim, formatConflict } from './coordination/trace.js';
76
+ export type { ClaimLogEntry } from './coordination/trace.js';
77
+ export type { ClaimEvent, ConflictEvent, SyncObservabilityProvider, } from './interfaces/index.js';
78
+ export { noopObservability } from './SyncEngineContext.js';
75
79
  export { IDBOpenTimeoutError, isStorageOpenTimeout } from './core/openIDBWithTimeout.js';
76
80
  export { PUBLIC_MODEL_VERBS, PUBLIC_LIST_OPTION_KEYS, PUBLIC_ABLO_OPTION_KEYS, } from './surface.js';
77
81
  export type { ModelVerb, ListOptionKey, AbloOptionKey } from './surface.js';
78
82
  export type { Register, DefaultSyncShape } from './types/global.js';
79
83
  export { defineMutators } from './mutators/defineMutators.js';
80
84
  export { createTransaction, type Transaction } from './mutators/Transaction.js';
85
+ export { deepEqual, stableStringify } from './utils/json.js';
package/dist/index.js CHANGED
@@ -104,6 +104,13 @@ export { writeOptionsSchema, onStaleModeSchema, assertWriteOptions, } from './cl
104
104
  // agent can self-heal rather than discard work (see coordination/schema.ts and
105
105
  // docs/coordination.md → "Notify, do not abort").
106
106
  export { staleNotificationSchema, readDependencySchema } from './coordination/schema.js';
107
+ // Claim log — collect claim events + stale-write collisions into an ordered list
108
+ // you can print for eyeballing or collisions() for evals assertions. Hand
109
+ // `new ClaimLog()` to `Ablo({ observability })`.
110
+ export { ClaimLog, formatClaim, formatConflict } from './coordination/trace.js';
111
+ // Spread this to provide a custom `observability` that overrides only the hooks
112
+ // you care about (e.g. captureClaim) and no-ops the rest.
113
+ export { noopObservability } from './SyncEngineContext.js';
107
114
  // Storage-wedge detection — lets app shells render a recovery screen when the
108
115
  // IndexedDB backing store is stuck (see core/openIDBWithTimeout.ts).
109
116
  export { IDBOpenTimeoutError, isStorageOpenTimeout } from './core/openIDBWithTimeout.js';
@@ -127,3 +134,9 @@ export { defineMutators } from './mutators/defineMutators.js';
127
134
  export { createTransaction } from './mutators/Transaction.js';
128
135
  // Undo runtime is intentionally not part of the public root surface. App code
129
136
  // uses `useUndoScope` from `@abloatai/ablo/react`.
137
+ // JSON comparison helpers. A `field.json()` value backed by a Postgres `jsonb`
138
+ // column round-trips with reordered object keys (jsonb doesn't preserve key
139
+ // order), so a naive `JSON.stringify(a) === JSON.stringify(b)` guard misfires
140
+ // when an app reconciles an Ablo row against external editor state. Use these
141
+ // (key-order-insensitive) instead. See `utils/json.ts` for the full rationale.
142
+ export { deepEqual, stableStringify } from './utils/json.js';
@@ -5,7 +5,7 @@
5
5
  * Consumers implement them to wire in their own logging, observability,
6
6
  * GraphQL client, session handling, and analytics.
7
7
  */
8
- import type { StaleNotification, ReadDependency } from '../coordination/schema.js';
8
+ import type { StaleNotification, ReadDependency, ParticipantKind } from '../coordination/schema.js';
9
9
  export interface SyncLogger {
10
10
  debug(message: string, ...args: unknown[]): void;
11
11
  info(message: string, ...args: unknown[]): void;
@@ -15,7 +15,7 @@ export interface SyncLogger {
15
15
  /** Breadcrumb severity levels */
16
16
  export type BreadcrumbLevel = 'debug' | 'info' | 'warning' | 'error';
17
17
  /** Breadcrumb categories for sync engine lifecycle events */
18
- export type SyncBreadcrumbCategory = 'sync.bootstrap' | 'sync.transaction' | 'sync.websocket' | 'sync.offline' | 'sync.database' | 'sync.conflict' | 'sync.groups';
18
+ export type SyncBreadcrumbCategory = 'sync.bootstrap' | 'sync.transaction' | 'sync.websocket' | 'sync.offline' | 'sync.database' | 'sync.conflict' | 'sync.coordination' | 'sync.groups';
19
19
  export interface RollbackDetails {
20
20
  transactionType: string;
21
21
  modelName: string;
@@ -71,6 +71,45 @@ export interface CommitZeroSyncIdDetails {
71
71
  export interface OfflineFlushFailureDetails {
72
72
  error: string;
73
73
  }
74
+ /**
75
+ * One thing that happened to a claim. `phase` is the past-tense state it just
76
+ * entered — the trail you follow to see WHY two participants collided on a row:
77
+ * who asked, who waited behind whom, who was turned away, whose lease lapsed.
78
+ * Each phase mirrors a `claim_*` wire frame.
79
+ */
80
+ export interface ClaimEvent {
81
+ phase: 'acquired' | 'queued' | 'granted' | 'lost' | 'rejected' | 'expired';
82
+ /** Server claim id, when the frame carries one. */
83
+ claimId?: string;
84
+ /** The claimed row + optional field scope. */
85
+ model?: string;
86
+ id?: string;
87
+ field?: string;
88
+ /** Participant that owns or blocks the lease (on `rejected`, the holder). */
89
+ actor?: string;
90
+ participantKind?: ParticipantKind;
91
+ /** FIFO position when `queued`. */
92
+ position?: number;
93
+ /** Rejection or policy reason, when the server supplied one. */
94
+ reason?: string;
95
+ }
96
+ /**
97
+ * A committed `onStale: 'notify'` write whose premise moved — the in-flight twin
98
+ * of a claim collision. The commit SUCCEEDED, but the guarded ops weren't written
99
+ * because the row changed since the caller's `readAt`; the engine handed back the
100
+ * live value so the actor can self-heal. Records WHICH rows and fields collided.
101
+ */
102
+ export interface ConflictEvent {
103
+ /** The client idempotency key whose write was notified. */
104
+ clientTxId: string;
105
+ /** The conflicted rows + the fields that collided. */
106
+ rows: ReadonlyArray<{
107
+ model: string;
108
+ id: string;
109
+ fields: readonly string[];
110
+ writtenBy?: ParticipantKind;
111
+ }>;
112
+ }
74
113
  /** Span attributes for performance monitoring */
75
114
  export interface SpanAttributes {
76
115
  [key: string]: string | number | boolean | undefined;
@@ -102,6 +141,10 @@ export interface SyncObservabilityProvider {
102
141
  captureOfflineFlushFailure(details: OfflineFlushFailureDetails): void;
103
142
  /** Capture self-healing event */
104
143
  captureSelfHealing(details: SelfHealingDetails): void;
144
+ /** Capture a claim state change (acquired / queued / granted / lost / rejected / expired) */
145
+ captureClaim(event: ClaimEvent): void;
146
+ /** Capture a notify-instead-of-abort stale-write collision */
147
+ captureConflict(event: ConflictEvent): void;
105
148
  /** Capture commit returning lastSyncId: 0 */
106
149
  captureCommitZeroSyncId(details: CommitZeroSyncIdDetails): void;
107
150
  /** Wrap a synchronous function in a performance span */
@@ -24,6 +24,7 @@
24
24
  */
25
25
  import type { SyncStoreContract } from '../react/context.js';
26
26
  import type { InverseOp } from './inverseOp.js';
27
+ import { deepEqual } from '../utils/json.js';
27
28
  /**
28
29
  * How undo/redo handles a field a collaborator changed after your op:
29
30
  * - `skip-stale` (default): leave it — your change is already superseded, so
@@ -33,8 +34,12 @@ import type { InverseOp } from './inverseOp.js';
33
34
  */
34
35
  export type UndoConflictPolicy = 'skip-stale' | 'last-writer-wins';
35
36
  export declare const DEFAULT_UNDO_CONFLICT_POLICY: UndoConflictPolicy;
36
- /** Structural equality for JSON-shaped values (scalars, arrays, plain objects). */
37
- export declare function deepEqual(a: unknown, b: unknown): boolean;
37
+ /**
38
+ * Structural equality for JSON-shaped values (scalars, arrays, plain objects).
39
+ * Re-exported from the shared `utils/json` helper (key order is ignored) so the
40
+ * undo path and app authors share one implementation.
41
+ */
42
+ export { deepEqual };
38
43
  /**
39
44
  * Filter the ops to apply so they don't clobber concurrent collaborator edits.
40
45
  * See the module docblock. `last-writer-wins` returns the ops unchanged.
@@ -22,42 +22,14 @@
22
22
  * With no collaborator, the live value always equals what you set, so nothing
23
23
  * is dropped — single-user undo is byte-for-byte unchanged.
24
24
  */
25
+ import { deepEqual } from '../utils/json.js';
25
26
  export const DEFAULT_UNDO_CONFLICT_POLICY = 'skip-stale';
26
- /** Structural equality for JSON-shaped values (scalars, arrays, plain objects). */
27
- export function deepEqual(a, b) {
28
- if (a === b)
29
- return true;
30
- if (a === null || b === null || typeof a !== 'object' || typeof b !== 'object') {
31
- return false;
32
- }
33
- const aArr = Array.isArray(a);
34
- if (aArr !== Array.isArray(b))
35
- return false;
36
- if (aArr) {
37
- const av = a;
38
- const bv = b;
39
- if (av.length !== bv.length)
40
- return false;
41
- for (let i = 0; i < av.length; i++) {
42
- if (!deepEqual(av[i], bv[i]))
43
- return false;
44
- }
45
- return true;
46
- }
47
- const ao = a;
48
- const bo = b;
49
- const ak = Object.keys(ao);
50
- const bk = Object.keys(bo);
51
- if (ak.length !== bk.length)
52
- return false;
53
- for (const k of ak) {
54
- if (!Object.prototype.hasOwnProperty.call(bo, k))
55
- return false;
56
- if (!deepEqual(ao[k], bo[k]))
57
- return false;
58
- }
59
- return true;
60
- }
27
+ /**
28
+ * Structural equality for JSON-shaped values (scalars, arrays, plain objects).
29
+ * Re-exported from the shared `utils/json` helper (key order is ignored) so the
30
+ * undo path and app authors share one implementation.
31
+ */
32
+ export { deepEqual };
61
33
  /**
62
34
  * Map `id → { field: establishedValue }` from the paired ops. Only update-family
63
35
  * ops carry per-field values worth comparing.
@@ -126,20 +126,28 @@ export type ConflictPolicy = (conflict: Conflict) => ConflictDecision | Promise<
126
126
  * default, not an opt-in.**
127
127
  *
128
128
  * A `claim_held` conflict resolves by the COMMITTER's kind. This default is, by
129
- * construction, identical to `coordination(humansOverwrite(), agentsReject())`
130
- * applied to every model — so a developer who wants different behaviour just
131
- * declares the conflict axis on that model (`humansReject()`, `humansNotify()`,
132
- * …) and it wins. The default and the override speak the same vocabulary; the
129
+ * construction, identical to `coordination(humansOverwrite())` applied to every
130
+ * model — so a developer who wants different behaviour just declares the conflict
131
+ * axis on that model (`humansReject()`, `humansNotify()`, `systemOverwrite()`, …)
132
+ * and it wins. The default and the override speak the same vocabulary; the
133
133
  * default is the one you get for free.
134
134
  *
135
135
  * • `user` → `allow` — a human is never blocked by a claim (a claim is a
136
136
  * coordination hint among agents, not a lock on people)
137
- * • `system` → `allow` — automation principals at the root of a trust chain
138
- * behave like humans here (matches `mayBypassClaims`)
139
137
  * • `agent` → `reject` — an agent yields to a foreign claim (the no-bypass
140
138
  * invariant; the ONLY sanctioned agent override is the
141
139
  * privileged `claim.preempt` capability seam, see
142
140
  * {@link capabilityPreemptPolicy})
141
+ * • `system` → `reject` — automation / backend (`sk_`) writers SERIALIZE
142
+ * through claims by default like agents do, so a server
143
+ * job can't silently steamroll a held row. Opt a model
144
+ * in with `systemOverwrite()` when that's wanted.
145
+ *
146
+ * Scope note: only `user` is allowed by default — that is the exact Law 7
147
+ * decision ("a human is never blocked"). `system` is deliberately NOT a
148
+ * free-bypass: `sk_` keys are full-access backend credentials, and a foundational
149
+ * contract (claim serialization / the retry-storm guard) depends on them
150
+ * respecting claims unless a model says otherwise.
143
151
  *
144
152
  * `stale_context` conflicts honor the committer's declared `onStale` intent:
145
153
  *
@@ -186,9 +194,10 @@ export interface ConflictAxis {
186
194
  * synchronous (no I/O), so it runs on either side of the schema-agnostic
187
195
  * boundary. Looks up the committer's kind and maps the declared `OnStaleMode`:
188
196
  *
189
- * - undefined → the engine default (`defaultPolicy`: Law 7 — a human/system
190
- * committer is allowed (never blocked), an agent rejects on a
191
- * `claim_held`; on a stale write, honor `onStale: 'notify'`)
197
+ * - undefined → the engine default (`defaultPolicy`: Law 7 — a human (`user`)
198
+ * committer is allowed (never blocked); `agent` and `system`
199
+ * reject on a `claim_held`; on a stale write, honor
200
+ * `onStale: 'notify'`)
192
201
  * - `overwrite` → `allow` — the write wins / the committer is never blocked
193
202
  * - `reject` → `reject` — the committer yields
194
203
  * - `notify` → on `stale_context`: notify + hold (re-read & re-apply);
@@ -11,20 +11,28 @@
11
11
  * default, not an opt-in.**
12
12
  *
13
13
  * A `claim_held` conflict resolves by the COMMITTER's kind. This default is, by
14
- * construction, identical to `coordination(humansOverwrite(), agentsReject())`
15
- * applied to every model — so a developer who wants different behaviour just
16
- * declares the conflict axis on that model (`humansReject()`, `humansNotify()`,
17
- * …) and it wins. The default and the override speak the same vocabulary; the
14
+ * construction, identical to `coordination(humansOverwrite())` applied to every
15
+ * model — so a developer who wants different behaviour just declares the conflict
16
+ * axis on that model (`humansReject()`, `humansNotify()`, `systemOverwrite()`, …)
17
+ * and it wins. The default and the override speak the same vocabulary; the
18
18
  * default is the one you get for free.
19
19
  *
20
20
  * • `user` → `allow` — a human is never blocked by a claim (a claim is a
21
21
  * coordination hint among agents, not a lock on people)
22
- * • `system` → `allow` — automation principals at the root of a trust chain
23
- * behave like humans here (matches `mayBypassClaims`)
24
22
  * • `agent` → `reject` — an agent yields to a foreign claim (the no-bypass
25
23
  * invariant; the ONLY sanctioned agent override is the
26
24
  * privileged `claim.preempt` capability seam, see
27
25
  * {@link capabilityPreemptPolicy})
26
+ * • `system` → `reject` — automation / backend (`sk_`) writers SERIALIZE
27
+ * through claims by default like agents do, so a server
28
+ * job can't silently steamroll a held row. Opt a model
29
+ * in with `systemOverwrite()` when that's wanted.
30
+ *
31
+ * Scope note: only `user` is allowed by default — that is the exact Law 7
32
+ * decision ("a human is never blocked"). `system` is deliberately NOT a
33
+ * free-bypass: `sk_` keys are full-access backend credentials, and a foundational
34
+ * contract (claim serialization / the retry-storm guard) depends on them
35
+ * respecting claims unless a model says otherwise.
28
36
  *
29
37
  * `stale_context` conflicts honor the committer's declared `onStale` intent:
30
38
  *
@@ -41,13 +49,14 @@
41
49
  // `ConflictPolicy` everywhere it's used as a policy.
42
50
  export const defaultPolicy = ((conflict) => {
43
51
  if (conflict.kind === 'claim_held') {
44
- // Law 7 universal default: principals (human / system) are never blocked;
45
- // agents yield. Keeping `agent reject` here is what makes the no-bypass
46
- // invariant hold even on the registry/default resolution path (which, unlike
47
- // the declared-axis path, has no separate agent-degradation guard).
48
- return conflict.committer.kind === 'agent'
49
- ? { action: 'reject', reason: 'claim_conflict' }
50
- : { action: 'allow', note: 'principal:not-blocked' };
52
+ // Law 7 universal default: a human (`user`) is never blocked; agents AND
53
+ // system actors yield. Keeping every non-`user` kind on `reject` here is
54
+ // what makes the no-bypass invariant hold even on the registry/default
55
+ // resolution path (which, unlike the declared-axis path, has no separate
56
+ // agent-degradation guard).
57
+ return conflict.committer.kind === 'user'
58
+ ? { action: 'allow', note: 'principal:not-blocked' }
59
+ : { action: 'reject', reason: 'claim_conflict' };
51
60
  }
52
61
  return conflict.requestedMode === 'notify'
53
62
  ? { action: 'notify', reason: 'stale_notify_hold' }
@@ -73,9 +82,10 @@ export const capabilityPreemptPolicy = (conflict) => {
73
82
  * synchronous (no I/O), so it runs on either side of the schema-agnostic
74
83
  * boundary. Looks up the committer's kind and maps the declared `OnStaleMode`:
75
84
  *
76
- * - undefined → the engine default (`defaultPolicy`: Law 7 — a human/system
77
- * committer is allowed (never blocked), an agent rejects on a
78
- * `claim_held`; on a stale write, honor `onStale: 'notify'`)
85
+ * - undefined → the engine default (`defaultPolicy`: Law 7 — a human (`user`)
86
+ * committer is allowed (never blocked); `agent` and `system`
87
+ * reject on a `claim_held`; on a stale write, honor
88
+ * `onStale: 'notify'`)
79
89
  * - `overwrite` → `allow` — the write wins / the committer is never blocked
80
90
  * - `reject` → `reject` — the committer yields
81
91
  * - `notify` → on `stale_context`: notify + hold (re-read & re-apply);