@abloatai/ablo 0.7.0 → 0.8.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 +32 -0
- package/README.md +54 -45
- package/dist/BaseSyncedStore.js +7 -3
- package/dist/SyncEngineContext.d.ts +2 -1
- package/dist/SyncEngineContext.js +5 -3
- package/dist/agent/session.js +3 -2
- package/dist/auth/index.js +39 -11
- package/dist/client/Ablo.d.ts +111 -3
- package/dist/client/Ablo.js +143 -10
- package/dist/client/ApiClient.d.ts +32 -0
- package/dist/client/ApiClient.js +76 -44
- package/dist/client/auth.d.ts +11 -1
- package/dist/client/auth.js +21 -2
- package/dist/client/createModelProxy.d.ts +107 -63
- package/dist/client/createModelProxy.js +65 -33
- package/dist/client/identity.js +14 -0
- package/dist/client/registerDataSource.d.ts +19 -0
- package/dist/client/registerDataSource.js +57 -0
- package/dist/client/validateAbloOptions.d.ts +2 -1
- package/dist/client/validateAbloOptions.js +8 -7
- package/dist/errorCodes.d.ts +23 -1
- package/dist/errorCodes.js +34 -1
- package/dist/errors.d.ts +52 -1
- package/dist/errors.js +140 -42
- package/dist/index.d.ts +9 -5
- package/dist/index.js +9 -5
- package/dist/keys/index.d.ts +61 -0
- package/dist/keys/index.js +151 -0
- package/dist/query/client.js +19 -8
- package/dist/react/AbloProvider.d.ts +25 -0
- package/dist/react/AbloProvider.js +97 -2
- package/dist/react/ClientSideSuspense.d.ts +1 -1
- package/dist/react/DefaultFallback.d.ts +1 -1
- package/dist/react/SyncGroupProvider.d.ts +1 -1
- package/dist/react/index.d.ts +3 -2
- package/dist/react/index.js +3 -2
- package/dist/react/useAblo.d.ts +4 -4
- package/dist/react/useAblo.js +10 -5
- package/dist/react/useReactive.js +16 -3
- package/dist/schema/serialize.d.ts +3 -3
- package/dist/schema/serialize.js +2 -2
- package/dist/sync/BootstrapHelper.js +46 -27
- package/dist/sync/ConnectionManager.d.ts +3 -1
- package/dist/sync/ConnectionManager.js +37 -1
- package/dist/sync/HydrationCoordinator.js +3 -2
- package/dist/sync/NetworkProbe.d.ts +8 -0
- package/dist/sync/NetworkProbe.js +24 -2
- package/dist/sync/SyncWebSocket.d.ts +1 -1
- package/dist/sync/SyncWebSocket.js +43 -53
- package/dist/sync/participants.js +5 -2
- package/dist/transactions/TransactionQueue.js +13 -1
- package/docs/api-keys.md +5 -5
- package/docs/api.md +101 -44
- package/docs/audit.md +16 -9
- package/docs/cli.md +27 -17
- package/docs/client-behavior.md +34 -20
- package/docs/coordination.md +40 -51
- package/docs/data-sources.md +21 -19
- package/docs/examples/agent-human.md +72 -28
- package/docs/examples/ai-sdk-tool.md +14 -11
- package/docs/examples/existing-python-backend.md +27 -16
- package/docs/examples/nextjs.md +21 -8
- package/docs/examples/scoped-agent.md +42 -27
- package/docs/examples/server-agent.md +27 -5
- package/docs/guarantees.md +26 -17
- package/docs/identity.md +65 -59
- package/docs/index.md +30 -19
- package/docs/integration-guide.md +52 -52
- package/docs/interaction-model.md +38 -26
- package/docs/mcp/claude-code.md +9 -17
- package/docs/mcp/cursor.md +6 -24
- package/docs/mcp/windsurf.md +6 -19
- package/docs/mcp.md +103 -26
- package/docs/quickstart.md +31 -39
- package/docs/react.md +15 -11
- package/docs/roadmap.md +13 -13
- package/docs/schema-contract.md +109 -0
- package/examples/README.md +8 -4
- package/examples/data-source/README.md +6 -2
- package/examples/data-source/run.ts +4 -3
- package/examples/quickstart.ts +1 -1
- package/llms.txt +27 -16
- package/package.json +6 -1
|
@@ -27,6 +27,14 @@ export interface ProbeResult {
|
|
|
27
27
|
reachable: boolean;
|
|
28
28
|
/** Whether the session cookie is still valid (null if server unreachable) */
|
|
29
29
|
sessionValid: boolean | null;
|
|
30
|
+
/**
|
|
31
|
+
* Reachable, but a NON-retryable auth/config failure that is NOT a session
|
|
32
|
+
* expiry (e.g. `api_key_required`, `jwt_issuer_untrusted`). The session is
|
|
33
|
+
* fine — the data-plane rejected the credential TYPE — so neither
|
|
34
|
+
* reconnecting nor re-authenticating will help. The manager stops instead of
|
|
35
|
+
* looping. Distinct from `sessionValid: false` (genuine expiry → sign in).
|
|
36
|
+
*/
|
|
37
|
+
authBlocked?: boolean;
|
|
30
38
|
/** Round-trip time in ms (null if failed) */
|
|
31
39
|
latencyMs: number | null;
|
|
32
40
|
}
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
* @see https://developer.mozilla.org/en-US/docs/Web/API/Navigator/onLine
|
|
23
23
|
*/
|
|
24
24
|
import { getContext } from '../context.js';
|
|
25
|
-
import { SyncSessionError } from '../errors.js';
|
|
25
|
+
import { SyncSessionError, isRetryableCode } from '../errors.js';
|
|
26
26
|
const PROBE_TIMEOUT_MS = 4000;
|
|
27
27
|
/**
|
|
28
28
|
* Derive the probe URL from a sync-server base URL. Accepts `ws://`,
|
|
@@ -72,7 +72,17 @@ export async function probeNetwork(baseUrl) {
|
|
|
72
72
|
headers: { 'Cache-Control': 'no-cache' },
|
|
73
73
|
});
|
|
74
74
|
const latencyMs = Math.round(performance.now() - start);
|
|
75
|
-
|
|
75
|
+
// The probe is a HEAD (no body), but the sync-server sets `X-Auth-Failure:
|
|
76
|
+
// <code>` on every auth rejection — feed that to the code-aware detector so
|
|
77
|
+
// only a genuine session/JWT EXPIRY marks the session invalid. A non-expiry
|
|
78
|
+
// auth failure (e.g. api_key_required, jwt_issuer_untrusted) leaves
|
|
79
|
+
// sessionValid alone — the user IS logged in; signing them out wouldn't fix
|
|
80
|
+
// a credential-type/config problem and just bounces them to /signin.
|
|
81
|
+
const authFailure = response.headers.get('x-auth-failure');
|
|
82
|
+
const failureBody = authFailure
|
|
83
|
+
? JSON.stringify({ code: authFailure })
|
|
84
|
+
: undefined;
|
|
85
|
+
if (SyncSessionError.isSessionErrorResponse(response.status, failureBody)) {
|
|
76
86
|
// Server reachable but session expired/invalid
|
|
77
87
|
getContext().logger.info('[NetworkProbe] Server reachable, session expired', {
|
|
78
88
|
status: response.status,
|
|
@@ -80,6 +90,18 @@ export async function probeNetwork(baseUrl) {
|
|
|
80
90
|
});
|
|
81
91
|
return { reachable: true, sessionValid: false, latencyMs };
|
|
82
92
|
}
|
|
93
|
+
// Reachable, but a NON-retryable auth/config failure that is NOT a session
|
|
94
|
+
// expiry (api_key_required, jwt_issuer_untrusted, …). Re-auth won't fix it
|
|
95
|
+
// and retrying won't either — signal authBlocked so the manager STOPS
|
|
96
|
+
// rather than reconnect-looping or signing the user out.
|
|
97
|
+
if (authFailure && !isRetryableCode(authFailure)) {
|
|
98
|
+
getContext().logger.warn('[NetworkProbe] Reachable but auth-blocked (non-retryable, non-expiry)', {
|
|
99
|
+
status: response.status,
|
|
100
|
+
code: authFailure,
|
|
101
|
+
latencyMs,
|
|
102
|
+
});
|
|
103
|
+
return { reachable: true, sessionValid: true, authBlocked: true, latencyMs };
|
|
104
|
+
}
|
|
83
105
|
// 2xx (including 204) means reachable + session valid.
|
|
84
106
|
// 3xx/4xx (non-auth) still prove connectivity even though the probe
|
|
85
107
|
// expected 204; log a warning so misconfigurations surface instead of
|
|
@@ -271,7 +271,7 @@ export interface CoreSyncEventMap {
|
|
|
271
271
|
/**
|
|
272
272
|
* Per-entity wait-queue snapshot: `{ target, queue: Intent[] }` with each
|
|
273
273
|
* entry `status: 'queued'` + `position`. Broadcast to entity peers on every
|
|
274
|
-
* queue mutation — powers the reactive `ablo.<model>.queue(id)` read.
|
|
274
|
+
* queue mutation — powers the reactive `ablo.<model>.claim.queue(id)` read.
|
|
275
275
|
*/
|
|
276
276
|
intent_queue: [Record<string, unknown>];
|
|
277
277
|
intent_acquired: [Record<string, unknown>];
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import { EventEmitter } from 'events';
|
|
11
11
|
import { getContext } from '../context.js';
|
|
12
12
|
import { flushOfflineQueueOnce } from './OfflineFlush.js';
|
|
13
|
-
import {
|
|
13
|
+
import { AbloConnectionError, AbloError, CapabilityError, SyncSessionError, errorFromWire, toAbloError, } from '../errors.js';
|
|
14
14
|
// ---------------------------------------------------------------------------
|
|
15
15
|
// Ablo-specific collaboration events moved to apps/web/src/lib/sync/collaboration-events.ts
|
|
16
16
|
// Consumers pass their own event types as TCollaboration generic parameter.
|
|
@@ -214,7 +214,7 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
214
214
|
const errorMessage = error instanceof Error ? error.message : 'Failed to create WebSocket';
|
|
215
215
|
getContext().observability.captureWebSocketError({ context: 'create-websocket', error: errorMessage });
|
|
216
216
|
this.isConnecting = false;
|
|
217
|
-
this.emit('error', new
|
|
217
|
+
this.emit('error', new AbloConnectionError(errorMessage, { cause: error }));
|
|
218
218
|
this.scheduleReconnect();
|
|
219
219
|
}
|
|
220
220
|
}
|
|
@@ -360,38 +360,19 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
360
360
|
else {
|
|
361
361
|
errorMessage = 'mutation failed on server';
|
|
362
362
|
}
|
|
363
|
-
//
|
|
364
|
-
//
|
|
365
|
-
//
|
|
366
|
-
//
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
errorCode
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
// pre-commit lease check (`intent_conflict`, the code that
|
|
377
|
-
// reaches clients in practice) and `executeCommit`'s deeper
|
|
378
|
-
// guard (`entity_claimed`). Both mean "claimed", so both route
|
|
379
|
-
// through the typed AbloClaimedError, letting callers
|
|
380
|
-
// `instanceof AbloClaimedError` (or read `e.type` across worker
|
|
381
|
-
// boundaries) and wait/bypass — symmetric with the
|
|
382
|
-
// CapabilityError branch above, and with the HTTP commit path
|
|
383
|
-
// (`translateHttpError`).
|
|
384
|
-
pending.reject(new AbloClaimedError(errorMessage, {
|
|
385
|
-
code: errorCode === 'intent_conflict' ? 'claim_conflict' : errorCode,
|
|
386
|
-
httpStatus: 409,
|
|
387
|
-
}));
|
|
388
|
-
}
|
|
389
|
-
else {
|
|
390
|
-
const rejection = new Error(errorMessage);
|
|
391
|
-
if (errorCode)
|
|
392
|
-
rejection.code = errorCode;
|
|
393
|
-
pending.reject(rejection);
|
|
394
|
-
}
|
|
363
|
+
// Build the proper typed AbloError from the wire code via the
|
|
364
|
+
// shared factory — the same code→class mapping the HTTP commit
|
|
365
|
+
// path uses (`translateHttpError`). This keeps rejected commits
|
|
366
|
+
// inside the typed hierarchy (capability denials →
|
|
367
|
+
// CapabilityError with `.requiredCapability`; foreign-claim
|
|
368
|
+
// conflicts → AbloClaimedError; everything else → the subclass
|
|
369
|
+
// its registry `httpStatus` implies) instead of a hand-rolled
|
|
370
|
+
// `new Error`, so callers can `instanceof`/`e.type` it and
|
|
371
|
+
// downstream retry logic can read the contract's retryability.
|
|
372
|
+
pending.reject(errorFromWire(errorMessage, {
|
|
373
|
+
code: errorCode,
|
|
374
|
+
requiredCapability,
|
|
375
|
+
}));
|
|
395
376
|
}
|
|
396
377
|
break;
|
|
397
378
|
}
|
|
@@ -438,11 +419,10 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
438
419
|
pending.reject(new CapabilityError(code, msg, requiredCapability));
|
|
439
420
|
}
|
|
440
421
|
else {
|
|
441
|
-
//
|
|
442
|
-
//
|
|
443
|
-
//
|
|
444
|
-
|
|
445
|
-
pending.reject(rejection);
|
|
422
|
+
// Route through the shared factory so a failed claim_ack is a
|
|
423
|
+
// typed AbloError (registry code → right subclass), symmetric
|
|
424
|
+
// with the commit `mutation_result` path — never a bare Error.
|
|
425
|
+
pending.reject(errorFromWire(msg, { code }));
|
|
446
426
|
}
|
|
447
427
|
}
|
|
448
428
|
break;
|
|
@@ -539,12 +519,12 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
539
519
|
// Check if we're offline first
|
|
540
520
|
if (!getContext().onlineStatus.isOnline()) {
|
|
541
521
|
getContext().observability.breadcrumb('WebSocket error: Network is offline', 'sync.websocket', 'warning');
|
|
542
|
-
this.emit('error', new
|
|
522
|
+
this.emit('error', new AbloConnectionError('Network is offline', { code: 'bootstrap_offline' }));
|
|
543
523
|
return;
|
|
544
524
|
}
|
|
545
525
|
// After session error, suppress Sentry capture — the root cause is already reported.
|
|
546
526
|
// Still emit so SyncedStore can update UI state.
|
|
547
|
-
const error = new
|
|
527
|
+
const error = new AbloConnectionError(`WebSocket connection failed`);
|
|
548
528
|
if (!this._sessionErrorDetected) {
|
|
549
529
|
getContext().observability.captureWebSocketError({
|
|
550
530
|
context: 'connection-error',
|
|
@@ -578,12 +558,16 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
578
558
|
if (this.pendingMutations.size > 0) {
|
|
579
559
|
for (const pending of this.pendingMutations.values()) {
|
|
580
560
|
clearTimeout(pending.timeout);
|
|
581
|
-
|
|
561
|
+
// AbloConnectionError → `isPermanentError` treats it as transient,
|
|
562
|
+
// so TransactionQueue retries the commit on reconnect rather than
|
|
563
|
+
// rolling it back. `diagnostics` is preserved as a property (the
|
|
564
|
+
// queue's failure log walks the cause chain for it).
|
|
565
|
+
pending.reject(Object.assign(new AbloConnectionError(`WebSocket closed while commit was in flight (code=${event.code}` +
|
|
582
566
|
(event.reason ? ` reason=${event.reason}` : '') +
|
|
583
567
|
(this.lastForceCloseReason
|
|
584
568
|
? ` forceCloseReason=${this.lastForceCloseReason}`
|
|
585
569
|
: '') +
|
|
586
|
-
')'), { diagnostics: this.getConnectionDiagnostics() }));
|
|
570
|
+
')', { code: 'commit_no_result' }), { diagnostics: this.getConnectionDiagnostics() }));
|
|
587
571
|
}
|
|
588
572
|
this.pendingMutations.clear();
|
|
589
573
|
}
|
|
@@ -594,7 +578,7 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
594
578
|
if (this.pendingClaims.size > 0) {
|
|
595
579
|
for (const pending of this.pendingClaims.values()) {
|
|
596
580
|
clearTimeout(pending.timeout);
|
|
597
|
-
pending.reject(new
|
|
581
|
+
pending.reject(new AbloConnectionError(`WebSocket closed while claim was in flight (code=${event.code})`));
|
|
598
582
|
}
|
|
599
583
|
this.pendingClaims.clear();
|
|
600
584
|
}
|
|
@@ -762,7 +746,7 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
762
746
|
return new Promise((resolve, reject) => {
|
|
763
747
|
const timeout = setTimeout(() => {
|
|
764
748
|
this.pendingMutations.delete(clientTxId);
|
|
765
|
-
reject(new
|
|
749
|
+
reject(new AbloConnectionError(`commit timed out after ${timeoutMs}ms (clientTxId=${clientTxId})`, { code: 'commit_no_result' }));
|
|
766
750
|
}, timeoutMs);
|
|
767
751
|
this.pendingMutations.set(clientTxId, { resolve, reject, timeout });
|
|
768
752
|
try {
|
|
@@ -778,9 +762,7 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
778
762
|
catch (error) {
|
|
779
763
|
clearTimeout(timeout);
|
|
780
764
|
this.pendingMutations.delete(clientTxId);
|
|
781
|
-
reject(error
|
|
782
|
-
? error
|
|
783
|
-
: new Error(String(error)));
|
|
765
|
+
reject(toAbloError(error));
|
|
784
766
|
}
|
|
785
767
|
});
|
|
786
768
|
}
|
|
@@ -826,7 +808,9 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
826
808
|
return new Promise((resolve, reject) => {
|
|
827
809
|
const timeout = setTimeout(() => {
|
|
828
810
|
this.pendingClaims.delete(claimId);
|
|
829
|
-
reject(new
|
|
811
|
+
reject(new AbloConnectionError(`claim timed out after ${timeoutMs}ms (claimId=${claimId})`, {
|
|
812
|
+
code: 'wait_for_timeout',
|
|
813
|
+
}));
|
|
830
814
|
}, timeoutMs);
|
|
831
815
|
this.pendingClaims.set(claimId, { resolve, reject, timeout });
|
|
832
816
|
try {
|
|
@@ -843,7 +827,7 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
843
827
|
catch (error) {
|
|
844
828
|
clearTimeout(timeout);
|
|
845
829
|
this.pendingClaims.delete(claimId);
|
|
846
|
-
reject(
|
|
830
|
+
reject(toAbloError(error));
|
|
847
831
|
}
|
|
848
832
|
});
|
|
849
833
|
}
|
|
@@ -864,7 +848,10 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
864
848
|
if (pending) {
|
|
865
849
|
clearTimeout(pending.timeout);
|
|
866
850
|
this.pendingClaims.delete(claimId);
|
|
867
|
-
pending.reject(new
|
|
851
|
+
pending.reject(new AbloError(`claim ${claimId} released before ack`, {
|
|
852
|
+
code: 'intent_wait_aborted',
|
|
853
|
+
httpStatus: 409,
|
|
854
|
+
}));
|
|
868
855
|
}
|
|
869
856
|
if (this.ws?.readyState !== WebSocket.OPEN)
|
|
870
857
|
return;
|
|
@@ -1172,8 +1159,11 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
1172
1159
|
else {
|
|
1173
1160
|
detail = 'never_connected';
|
|
1174
1161
|
}
|
|
1175
|
-
|
|
1176
|
-
|
|
1162
|
+
// Typed so it lands in the AbloError hierarchy AND `isPermanentError`
|
|
1163
|
+
// sees a transient transport failure (retry on reconnect, don't roll
|
|
1164
|
+
// back). `diagnostics` stays a property — the queue's failure log walks
|
|
1165
|
+
// the cause chain for it.
|
|
1166
|
+
const err = Object.assign(new AbloConnectionError(`SyncWebSocket not connected — cannot send ${action} (${detail})`, { code: 'ws_not_ready' }), { diagnostics: d });
|
|
1177
1167
|
return err;
|
|
1178
1168
|
}
|
|
1179
1169
|
/** Returns the sync groups this connection is subscribed to. */
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { scopeKindOf } from '../schema/model.js';
|
|
2
|
+
import { AbloConnectionError, AbloValidationError } from '../errors.js';
|
|
2
3
|
export function createParticipantManager(config) {
|
|
3
4
|
return {
|
|
4
5
|
async join(input, overrides) {
|
|
@@ -10,7 +11,7 @@ export function createParticipantManager(config) {
|
|
|
10
11
|
await config.ready();
|
|
11
12
|
const transport = config.getTransport();
|
|
12
13
|
if (!transport) {
|
|
13
|
-
throw new
|
|
14
|
+
throw new AbloConnectionError('Ablo participant join failed: WebSocket is not connected', { code: 'ws_not_ready' });
|
|
14
15
|
}
|
|
15
16
|
const claimId = createParticipantClaimId();
|
|
16
17
|
if (syncGroups.length > 0) {
|
|
@@ -154,7 +155,9 @@ function createJoinedParticipant(args) {
|
|
|
154
155
|
const requireTarget = (target) => {
|
|
155
156
|
const resolved = target ? targetToEntityRef(target) : currentTarget;
|
|
156
157
|
if (!resolved) {
|
|
157
|
-
throw new
|
|
158
|
+
throw new AbloValidationError('Participant action requires a structured target', {
|
|
159
|
+
code: 'invalid_request',
|
|
160
|
+
});
|
|
158
161
|
}
|
|
159
162
|
return resolved;
|
|
160
163
|
};
|
|
@@ -12,7 +12,7 @@ import { getContext } from '../context.js';
|
|
|
12
12
|
import { getActiveRegistry } from '../ModelRegistry.js';
|
|
13
13
|
import { MutationOperationType } from '../types/index.js';
|
|
14
14
|
import { handleMutationError } from './mutation-error-handler.js';
|
|
15
|
-
import { AbloError, AbloConnectionError } from '../errors.js';
|
|
15
|
+
import { AbloError, AbloConnectionError, errorCodeSpec } from '../errors.js';
|
|
16
16
|
/**
|
|
17
17
|
* Framework-internal keys added by `Model.toJSON()` that must never
|
|
18
18
|
* reach the wire. The server treats each top-level key as a target
|
|
@@ -1482,6 +1482,18 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1482
1482
|
if (error instanceof AbloConnectionError) {
|
|
1483
1483
|
return false;
|
|
1484
1484
|
}
|
|
1485
|
+
// Registry-driven retryability is authoritative when the error carries a
|
|
1486
|
+
// known wire code: the error contract (errorCodes.ts) decides whether the
|
|
1487
|
+
// same request can succeed on retry, not message string-matching. This is
|
|
1488
|
+
// why rejected commits must arrive as typed AbloErrors (see
|
|
1489
|
+
// `errorFromWire`) — a bare `Error` has no code and falls through to the
|
|
1490
|
+
// heuristics below. Unknown / forward-compat codes (`errorCodeSpec`
|
|
1491
|
+
// returns undefined) also fall through, preserving the safe default.
|
|
1492
|
+
if (error instanceof AbloError && error.code) {
|
|
1493
|
+
const spec = errorCodeSpec(error.code);
|
|
1494
|
+
if (spec)
|
|
1495
|
+
return !spec.retryable;
|
|
1496
|
+
}
|
|
1485
1497
|
const message = error?.message?.toLowerCase() || '';
|
|
1486
1498
|
// Network/connection errors are transient - retry these
|
|
1487
1499
|
const isNetworkError = message.includes('failed to fetch') ||
|
package/docs/api-keys.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# API Keys
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Authenticate a server-side client — a route handler, worker, or CLI — by passing an API key when you create the client.
|
|
4
4
|
|
|
5
5
|
```ts
|
|
6
6
|
import Ablo from '@abloatai/ablo';
|
|
@@ -10,11 +10,11 @@ const ablo = Ablo({ apiKey: process.env.ABLO_API_KEY });
|
|
|
10
10
|
|
|
11
11
|
The key identifies the Ablo account. Application code does not pass an organization id; Ablo derives scope from the credential.
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
"Trusted" means the runtime can hold a secret: a backend or other server-side environment a browser can't read. Browser and app clients use the same `@abloatai/ablo` import but authenticate differently — they never carry a secret key.
|
|
14
14
|
|
|
15
15
|
## Server-Side API Keys
|
|
16
16
|
|
|
17
|
-
Use API keys from trusted runtimes:
|
|
17
|
+
Use API keys from trusted (server-side) runtimes:
|
|
18
18
|
|
|
19
19
|
- backend route handlers
|
|
20
20
|
- workers and agents
|
|
@@ -48,8 +48,8 @@ restricted to exactly those grants:
|
|
|
48
48
|
high-risk, org-wide grant: because schema is shared, a push affects the live
|
|
49
49
|
table shape. A full-authority key has it implicitly; a *restricted* key (such
|
|
50
50
|
as a sandbox key) needs it granted explicitly.
|
|
51
|
-
- `sandbox:<id>` —
|
|
52
|
-
comes from
|
|
51
|
+
- `sandbox:<id>` — identifies which sandbox the key belongs to. (The key's data
|
|
52
|
+
isolation comes from that sandbox binding, not from this scope string.)
|
|
53
53
|
|
|
54
54
|
A key minted from the default **Test mode** sandbox carries `schema:push`, so
|
|
55
55
|
`ablo dev` works out of the box. Keys from other sandboxes are **data-only** by
|
package/docs/api.md
CHANGED
|
@@ -1,10 +1,21 @@
|
|
|
1
1
|
# API
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
This is the per-method reference for reading and writing rows that stay in
|
|
4
|
+
sync across sessions. You declare your models once, then call the same
|
|
5
|
+
`ablo.<model>` methods from React, a server action, or an agent — and every
|
|
6
|
+
confirmed write streams to everyone watching. When two writers touch the same
|
|
7
|
+
row, you can optionally `claim` it so they serialize instead of clobbering
|
|
8
|
+
each other.
|
|
9
|
+
|
|
10
|
+
Two things to know before the method list. **Reads come in two flavors:**
|
|
11
|
+
`retrieve(id)` / `list({ where })` are async and hit the server (use them when
|
|
12
|
+
the row may not be local yet); `get(id)` / `getAll({ where })` / `getCount({ where })`
|
|
13
|
+
are synchronous reads off the local graph (use them in render, after data has
|
|
14
|
+
synced). **Claims don't lock.** If another writer holds the row, `claim` waits
|
|
15
|
+
for them, re-reads the fresh row, then hands it to you — so two writers
|
|
16
|
+
serialize instead of clobbering.
|
|
7
17
|
|
|
18
|
+
Start with the schema client:
|
|
8
19
|
|
|
9
20
|
```ts
|
|
10
21
|
import Ablo from '@abloatai/ablo';
|
|
@@ -20,39 +31,46 @@ const schema = defineSchema({
|
|
|
20
31
|
const ablo = Ablo({ schema, apiKey: process.env.ABLO_API_KEY });
|
|
21
32
|
|
|
22
33
|
await ablo.ready();
|
|
23
|
-
const
|
|
34
|
+
const report = await ablo.weatherReports.retrieve('report_stockholm');
|
|
24
35
|
if (!report) throw new Error('Row not found');
|
|
25
36
|
|
|
26
37
|
await ablo.weatherReports.update('report_stockholm', { status: 'ready' }, { wait: 'confirmed' });
|
|
27
38
|
```
|
|
28
39
|
|
|
40
|
+
For end-to-end app setup across React, existing backends, Data Source, and
|
|
41
|
+
agents, read the [Integration Guide](./integration-guide.md).
|
|
42
|
+
|
|
29
43
|
## Model Methods
|
|
30
44
|
|
|
31
45
|
Each schema model becomes a typed model on the client:
|
|
32
46
|
|
|
33
|
-
- `ablo.weatherReports.
|
|
34
|
-
- `ablo.weatherReports.
|
|
47
|
+
- `ablo.weatherReports.retrieve(id)` reads one row asynchronously (server read).
|
|
48
|
+
- `ablo.weatherReports.list({ where })` reads a collection asynchronously (server read).
|
|
49
|
+
- `ablo.weatherReports.get(id)` reads one row synchronously from the local graph.
|
|
35
50
|
- `ablo.weatherReports.create(data)` creates a row.
|
|
36
51
|
- `ablo.weatherReports.update(id, data, options?)` updates a row.
|
|
37
52
|
- `ablo.weatherReports.delete(id, options?)` deletes a row.
|
|
38
53
|
|
|
39
|
-
`
|
|
40
|
-
|
|
41
|
-
|
|
54
|
+
`retrieve`/`list` and `get`/`getAll`/`getCount` are not aliases. Use
|
|
55
|
+
`retrieve(id)` or `list({ where })` when the row may not be local yet — they
|
|
56
|
+
hydrate pool → IndexedDB → network. Use `get(id)` / `getAll({ where })` /
|
|
57
|
+
`getCount({ where })` for a cheap synchronous snapshot of what is already in
|
|
58
|
+
the local graph.
|
|
42
59
|
|
|
43
60
|
| Method | Returns | Use when |
|
|
44
61
|
|---|---|---|
|
|
45
|
-
| `
|
|
46
|
-
| `
|
|
47
|
-
| `
|
|
48
|
-
| `
|
|
62
|
+
| `retrieve(id)` | `Promise<T \| undefined>` | You need one row, hydrating from local store and server. |
|
|
63
|
+
| `list({ where })` | `Promise<T[]>` | You need to hydrate a collection from local store and server. |
|
|
64
|
+
| `get(id)` | `T \| undefined` | You want a synchronous snapshot of one local row. |
|
|
65
|
+
| `getAll(options?)` | `T[]` | You want a synchronous snapshot of a local collection. |
|
|
66
|
+
| `getCount(options?)` | `number` | You want a synchronous count of local rows. |
|
|
49
67
|
| `create(data, options?)` | `Promise<T>` | You want to create through the schema model. |
|
|
50
68
|
| `update(id, data, options?)` | `Promise<T>` | You want to update through the schema model. |
|
|
51
69
|
| `delete(id, options?)` | `Promise<void>` | You want to delete through the schema model. |
|
|
52
70
|
|
|
53
|
-
`
|
|
54
|
-
server. `
|
|
55
|
-
session has already
|
|
71
|
+
`retrieve`, `list`, `create`, `update`, and `delete` are the main path — they go
|
|
72
|
+
through the server. `get` / `getAll` / `getCount` are **synchronous reads**
|
|
73
|
+
off the rows a session has already synced, so a cheap re-read needs no round-trip.
|
|
56
74
|
|
|
57
75
|
## Protected Writes
|
|
58
76
|
|
|
@@ -80,23 +98,26 @@ Protected write options:
|
|
|
80
98
|
|
|
81
99
|
## Claims
|
|
82
100
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
101
|
+
Before anyone writes a row, they can claim it so other people and agents see
|
|
102
|
+
who is editing it in real time. Claims don't lock. If another writer holds the
|
|
103
|
+
row, `claim` waits for them, re-reads the fresh row, then hands it to you — so
|
|
104
|
+
two writers serialize instead of clobbering. A claim is temporary: it expires
|
|
105
|
+
on its own if the holder stops, and is never saved as a row.
|
|
87
106
|
|
|
88
|
-
|
|
89
|
-
`ablo.<model>.claim(id,
|
|
90
|
-
|
|
91
|
-
|
|
107
|
+
You coordinate a row with calls on its model, beside `create`/`update`/`retrieve`:
|
|
108
|
+
`ablo.<model>.claim(id, work)` takes the claim and runs your work,
|
|
109
|
+
`ablo.<model>.claim.state(id)` reads who currently holds it (synchronous, never
|
|
110
|
+
blocks), and `ablo.<model>.claim.release(id)` releases it early. The full
|
|
111
|
+
coordination surface is `claim.state(id)` / `claim.queue(id)` /
|
|
112
|
+
`claim.release(id)` / `claim.reorder(id, order)` hanging off `claim`.
|
|
92
113
|
|
|
93
114
|
### The Claim State Object
|
|
94
115
|
|
|
95
116
|
| Field | Type | Description |
|
|
96
117
|
|---|---|---|
|
|
97
|
-
| `object` | `'
|
|
118
|
+
| `object` | `'intent'` | String representing the object's type. |
|
|
98
119
|
| `id` | string | Unique identifier for the claim. |
|
|
99
|
-
| `status` | `'active' \| 'committed' \| 'expired' \| 'canceled'` | The whole lifecycle, in one field. |
|
|
120
|
+
| `status` | `'active' \| 'queued' \| 'committed' \| 'expired' \| 'canceled'` | The whole lifecycle, in one field. `active` is the holder; `queued` is a waiter in the FIFO line behind it. |
|
|
100
121
|
| `target` | `{ type, id, field? }` | What is being coordinated. |
|
|
101
122
|
| `action` | string | Human-readable phase — `'editing'`, `'writing'`, `'reviewing'`. |
|
|
102
123
|
| `heldBy` | string | Participant id holding the claim. |
|
|
@@ -105,7 +126,7 @@ early. Claims are **advisory** — they serialize on contention rather than lock
|
|
|
105
126
|
|
|
106
127
|
```json
|
|
107
128
|
{
|
|
108
|
-
"object": "
|
|
129
|
+
"object": "intent",
|
|
109
130
|
"id": "claim_3MtwBwLkdIwHu7ix",
|
|
110
131
|
"status": "active",
|
|
111
132
|
"target": { "type": "weatherReports", "id": "report_stockholm", "field": "status" },
|
|
@@ -128,21 +149,24 @@ early. Claims are **advisory** — they serialize on contention rather than lock
|
|
|
128
149
|
(release w/o write) (TTL; holder died)
|
|
129
150
|
```
|
|
130
151
|
|
|
131
|
-
A target is free when `ablo.<model>.
|
|
132
|
-
states drop out of the live stream, so a present claim is active
|
|
152
|
+
A target is free when `ablo.<model>.claim.state(id)` is `null`. Terminal
|
|
153
|
+
states drop out of the live stream, so a present claim is either `active` (the
|
|
154
|
+
holder) or `queued` (waiting in the FIFO line behind the holder; see
|
|
155
|
+
`claim.queue(id)`).
|
|
133
156
|
|
|
134
157
|
### Reading and claiming
|
|
135
158
|
|
|
136
|
-
`
|
|
137
|
-
returns the live claim state object (or `null`). `claim(id,
|
|
138
|
-
it
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
`ifClaimed: '
|
|
159
|
+
`claim.state(id)` is the read side for observers: synchronous, never blocks, and
|
|
160
|
+
returns the live claim state object (or `null`). `claim(id, work)` is the write
|
|
161
|
+
side: it takes the claim and returns the row. Claims don't lock — if someone else
|
|
162
|
+
already holds the row, `claim` waits for them to finish, re-reads the fresh row,
|
|
163
|
+
then hands it to you, so you always proceed from current state. Default reads
|
|
164
|
+
return the row even while someone is mid-edit; if a server read should not
|
|
165
|
+
return a row while it's claimed, pass `ifClaimed: 'wait'` to wait for the claim
|
|
166
|
+
to clear, or `ifClaimed: 'fail'` to error out instead.
|
|
143
167
|
|
|
144
168
|
```ts
|
|
145
|
-
const claim = ablo.weatherReports.
|
|
169
|
+
const claim = ablo.weatherReports.claim.state('report_stockholm');
|
|
146
170
|
if (claim) {
|
|
147
171
|
claim.heldBy;
|
|
148
172
|
claim.action;
|
|
@@ -155,19 +179,52 @@ const updated = await ablo.weatherReports.claim(
|
|
|
155
179
|
);
|
|
156
180
|
```
|
|
157
181
|
|
|
158
|
-
Writes go through the normal
|
|
159
|
-
a claim on `id`, that `update`
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
182
|
+
Writes go through the normal `ablo.<model>.update(id, data)`. While you hold
|
|
183
|
+
a claim on `id`, that `update` rejects with `AbloStaleContextError` if the row
|
|
184
|
+
changed underneath you since you took the claim, so you re-read before retrying.
|
|
185
|
+
The callback form releases the claim automatically when the callback returns or
|
|
186
|
+
throws; call `ablo.weatherReports.claim.release(id)` if you claimed manually and
|
|
163
187
|
need to release early.
|
|
164
188
|
|
|
165
189
|
## Agent
|
|
166
190
|
|
|
167
191
|
Most agents should import the same schema as the app and call
|
|
168
|
-
`ablo.<model>.
|
|
192
|
+
`ablo.<model>.list(...)`, `ablo.<model>.claim(...)`, and
|
|
169
193
|
`ablo.<model>.update(...)`.
|
|
170
194
|
|
|
195
|
+
## HTTP API
|
|
196
|
+
|
|
197
|
+
The SDK is a convenience wrapper over a model-scoped HTTP surface — the same
|
|
198
|
+
noun (`model`) and verbs as `ablo.<model>.…`. Non-JS callers (or curl) use it
|
|
199
|
+
directly. The table below shows the shape with `{model}` as a placeholder; the
|
|
200
|
+
[OpenAPI spec](./openapi.json) expands it into one **typed** path per model
|
|
201
|
+
(`/v1/models/task`, `/v1/models/deck`, …, generated from your schema) so each
|
|
202
|
+
endpoint documents that model's real field contract instead of a generic blob.
|
|
203
|
+
|
|
204
|
+
| SDK call | HTTP |
|
|
205
|
+
|---|---|
|
|
206
|
+
| `ablo.<model>.create(data)` | `POST /v1/models/{model}` |
|
|
207
|
+
| `ablo.<model>.list({ where })` | `GET /v1/models/{model}` |
|
|
208
|
+
| `ablo.<model>.retrieve(id)` | `GET /v1/models/{model}/{id}` |
|
|
209
|
+
| `ablo.<model>.update(id, data)` | `PATCH /v1/models/{model}/{id}` |
|
|
210
|
+
| `ablo.<model>.delete(id)` | `DELETE /v1/models/{model}/{id}` |
|
|
211
|
+
| `ablo.<model>.claim(id)` | `POST /v1/models/{model}/{id}/claim` |
|
|
212
|
+
| (release a claim) | `DELETE /v1/models/{model}/{id}/claim` |
|
|
213
|
+
|
|
214
|
+
Auth is a bearer API key: `Authorization: Bearer sk_…`. Mutations take an
|
|
215
|
+
`Idempotency-Key` header — derive it from the business event, not a random
|
|
216
|
+
value, so a retry never double-writes. Writes return a `CommitReceipt`; a
|
|
217
|
+
rejected write carries an error `code` (e.g. `stale_context`, `intent_conflict`)
|
|
218
|
+
to act on. `GET /v1/models/{model}` is cursor-paginated (`limit`, `order`,
|
|
219
|
+
`order_by`, `starting_after`) and returns `{ data, has_more, next_cursor }`.
|
|
220
|
+
|
|
221
|
+
`POST /v1/commits` remains the path for **atomic multi-op** writes (several
|
|
222
|
+
operations across rows/models that must commit together) — the per-model routes
|
|
223
|
+
above are the one-record path. Both run the identical guarded-write engine.
|
|
224
|
+
|
|
225
|
+
The [coordination MCP server](./mcp.md) (`@ablo/mcp`) is this same surface
|
|
226
|
+
rendered as agent tools.
|
|
227
|
+
|
|
171
228
|
## Errors
|
|
172
229
|
|
|
173
230
|
All SDK errors extend `AbloError` and expose a stable `type` string.
|