@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.
- package/CHANGELOG.md +37 -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 +103 -30
- 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 +5 -1
- package/dist/index.js +7 -0
- package/dist/interfaces/index.d.ts +45 -2
- 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/schema/ddl.d.ts +36 -0
- package/dist/schema/ddl.js +66 -0
- package/dist/schema/index.d.ts +1 -1
- package/dist/schema/index.js +1 -1
- 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/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
|
@@ -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
|
+
}
|
package/dist/errorCodes.d.ts
CHANGED
|
@@ -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;
|
package/dist/errorCodes.js
CHANGED
|
@@ -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'`).
|
|
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
|
|
199
|
+
function claimReason(claim) {
|
|
200
200
|
if (!claim)
|
|
201
201
|
return undefined;
|
|
202
|
-
|
|
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
|
|
229
|
+
const reason = claimReason(args.claim);
|
|
233
230
|
const description = claimDescription(args.claim);
|
|
234
231
|
const expiresIn = secondsUntil(claimExpiresAt(args.claim));
|
|
235
|
-
if (!holder && !
|
|
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
|
|
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}${
|
|
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,
|
|
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 */
|
package/dist/policy/types.d.ts
CHANGED
|
@@ -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()
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
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
|
|
190
|
-
* committer is allowed (never blocked)
|
|
191
|
-
* `claim_held`; on a stale write, honor
|
|
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);
|
package/dist/policy/types.js
CHANGED
|
@@ -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()
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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:
|
|
45
|
-
//
|
|
46
|
-
// invariant hold even on the registry/default
|
|
47
|
-
// the declared-axis path, has no separate
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
77
|
-
* committer is allowed (never blocked)
|
|
78
|
-
* `claim_held`; on a stale write, honor
|
|
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 {
|
|
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<
|
|
157
|
+
readonly claims: ReadonlyArray<Claim>;
|
|
158
158
|
readonly status: ParticipantStatus;
|
|
159
159
|
readonly error: Error | null;
|
|
160
160
|
}
|
package/dist/schema/ddl.d.ts
CHANGED
|
@@ -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.
|