@abloatai/ablo 0.18.0 → 0.19.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 (56) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/dist/ObjectPool.d.ts +14 -1
  3. package/dist/ObjectPool.js +8 -1
  4. package/dist/SyncClient.js +7 -1
  5. package/dist/SyncEngineContext.js +2 -0
  6. package/dist/ai-sdk/coordination-context.d.ts +2 -1
  7. package/dist/ai-sdk/coordination-context.js +3 -3
  8. package/dist/ai-sdk/index.d.ts +3 -4
  9. package/dist/ai-sdk/index.js +2 -3
  10. package/dist/ai-sdk/wrap.d.ts +13 -18
  11. package/dist/ai-sdk/wrap.js +2 -6
  12. package/dist/cli.cjs +253 -160
  13. package/dist/client/Ablo.d.ts +83 -22
  14. package/dist/client/Ablo.js +103 -30
  15. package/dist/client/ApiClient.d.ts +2 -2
  16. package/dist/client/ApiClient.js +27 -32
  17. package/dist/client/auth.d.ts +26 -0
  18. package/dist/client/auth.js +208 -0
  19. package/dist/client/createModelProxy.d.ts +16 -11
  20. package/dist/client/createModelProxy.js +15 -15
  21. package/dist/client/sessionMint.d.ts +10 -0
  22. package/dist/client/sessionMint.js +8 -1
  23. package/dist/coordination/schema.d.ts +10 -10
  24. package/dist/coordination/schema.js +7 -8
  25. package/dist/coordination/trace.d.ts +91 -0
  26. package/dist/coordination/trace.js +147 -0
  27. package/dist/errorCodes.d.ts +2 -0
  28. package/dist/errorCodes.js +2 -0
  29. package/dist/errors.d.ts +1 -2
  30. package/dist/errors.js +6 -9
  31. package/dist/index.d.ts +5 -1
  32. package/dist/index.js +7 -0
  33. package/dist/interfaces/index.d.ts +45 -2
  34. package/dist/policy/types.d.ts +18 -9
  35. package/dist/policy/types.js +26 -16
  36. package/dist/react/AbloProvider.d.ts +2 -2
  37. package/dist/schema/ddl.d.ts +36 -0
  38. package/dist/schema/ddl.js +66 -0
  39. package/dist/schema/index.d.ts +1 -1
  40. package/dist/schema/index.js +1 -1
  41. package/dist/sync/HydrationCoordinator.js +7 -3
  42. package/dist/sync/SyncWebSocket.d.ts +11 -2
  43. package/dist/sync/SyncWebSocket.js +67 -3
  44. package/dist/sync/createClaimStream.d.ts +9 -2
  45. package/dist/sync/createClaimStream.js +9 -7
  46. package/dist/sync/participants.d.ts +12 -11
  47. package/dist/sync/participants.js +5 -5
  48. package/dist/transactions/TransactionQueue.d.ts +1 -0
  49. package/dist/transactions/TransactionQueue.js +40 -3
  50. package/dist/types/streams.d.ts +57 -135
  51. package/docs/coordination.md +16 -0
  52. package/docs/debugging.md +194 -0
  53. package/docs/index.md +1 -0
  54. package/package.json +1 -1
  55. package/dist/ai-sdk/claim-broadcast.d.ts +0 -83
  56. 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 is not jsonb. The column was not provisioned as jsonb (often a pre-existing column adopted by `ablo push`); storing the value would silently corrupt it to "[object Object]". Run `ablo migrate` to alter the column to jsonb.'),
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,6 +72,10 @@ 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';
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';
@@ -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 */
@@ -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);
@@ -1,7 +1,7 @@
1
1
  import { type ReactNode } from 'react';
2
2
  import type { SchemaRecord } from '../schema/schema.js';
3
3
  import { Ablo } from '../client/Ablo.js';
4
- import type { ActiveClaim, Peer } from '../types/streams.js';
4
+ import type { Claim, Peer } from '../types/streams.js';
5
5
  import type { EngineParticipant, ParticipantScope, ParticipantStatus } from '../sync/participants.js';
6
6
  import { type SyncStoreContract } from './context.js';
7
7
  /**
@@ -154,7 +154,7 @@ export interface UseWatchReturn {
154
154
  /** Everyone else on the engine's sync groups (`participant.presence.others`), bridged to React. */
155
155
  readonly peers: ReadonlyArray<Peer>;
156
156
  /** Active claim claims by peers (`participant.claims.others`), bridged to React. */
157
- readonly claims: ReadonlyArray<ActiveClaim>;
157
+ readonly claims: ReadonlyArray<Claim>;
158
158
  readonly status: ParticipantStatus;
159
159
  readonly error: Error | null;
160
160
  }
@@ -65,6 +65,42 @@ export declare function snakeToCamel(identifier: string): string;
65
65
  /** Quote an identifier (defense-in-depth; inputs are already slug/snake). */
66
66
  export declare function q(identifier: string): string;
67
67
  export declare function sqlType(fieldType: ModelJSON['fields'][string]['type']): string;
68
+ /**
69
+ * Detect and repair `field.json()` columns that exist in the live database as a
70
+ * NON-jsonb type, and emit a salvaging in-place `ALTER … TYPE jsonb`.
71
+ *
72
+ * Why this is needed: provisioning uses `ADD COLUMN IF NOT EXISTS` and
73
+ * `CREATE TABLE IF NOT EXISTS` (idempotent by design). When `ablo push` adopts
74
+ * a table/column that PRE-EXISTS with a different type — e.g. a `content TEXT`
75
+ * column from a legacy table — the additive DDL is a no-op and the column
76
+ * silently stays `text`. The pure schema-to-schema differ
77
+ * ({@link generateMigrationPlan}) can't see this: the schema says `json` on
78
+ * both sides, so it emits no change. The drift is only visible by INTROSPECTING
79
+ * the live database and comparing to the declared type — the drizzle-kit
80
+ * `pull` discipline. A `json` value bound to a `text` column corrupts to the
81
+ * literal `"[object Object]"`, so leaving the drift unrepaired is silent data
82
+ * loss.
83
+ *
84
+ * The cast is SALVAGING, never aborting (Postgres won't auto-cast text→jsonb;
85
+ * invalid rows would otherwise fail the whole ALTER): a row that `IS JSON`
86
+ * parses to jsonb, anything else (including already-corrupted
87
+ * `"[object Object]"`) is wrapped as a jsonb string via `to_jsonb`, and NULL
88
+ * stays NULL. The `IS JSON` predicate requires Postgres 16+ (the fleet runs
89
+ * 17); on an older engine the ALTER fails LOUD with a structured
90
+ * `migration_failed` rather than silently — acceptable, since silent corruption
91
+ * is the thing we're eliminating. See
92
+ * https://echobind.com/post/safely-alter-postgres-columns-with-using.
93
+ *
94
+ * Pure + idempotent: emits a statement ONLY for a json field whose live column
95
+ * type is present and not already `jsonb`/`json`. A correctly-provisioned schema
96
+ * yields zero statements, so this is a no-op on every push after the first
97
+ * repair. Columns absent from `liveColumnTypes` are left to the additive
98
+ * provisioner (which adds them as jsonb).
99
+ *
100
+ * @param liveColumnTypes table name → (column name → information_schema
101
+ * `data_type`), as introspected from the target schema.
102
+ */
103
+ export declare function generateJsonColumnReconciliation(schema: SchemaJSON, liveColumnTypes: ReadonlyMap<string, ReadonlyMap<string, string>>, targetSchema: string): string[];
68
104
  /**
69
105
  * Build the additive, idempotent provisioning plan for an app. Pure — no DB
70
106
  * access.