@abloatai/ablo 0.6.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 +77 -0
- package/README.md +95 -57
- package/dist/BaseSyncedStore.d.ts +1 -1
- package/dist/BaseSyncedStore.js +8 -4
- 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 +112 -3
- package/dist/client/Ablo.js +144 -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 +120 -53
- package/dist/client/createModelProxy.js +66 -31
- 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/coordination/index.d.ts +6 -0
- package/dist/coordination/index.js +6 -0
- package/dist/coordination/schema.d.ts +329 -0
- package/dist/coordination/schema.js +209 -0
- package/dist/core/QueryView.d.ts +4 -1
- package/dist/core/QueryView.js +1 -1
- package/dist/core/query-utils.d.ts +7 -10
- package/dist/core/query-utils.js +2 -3
- package/dist/errorCodes.d.ts +286 -0
- package/dist/errorCodes.js +284 -0
- package/dist/errors.d.ts +103 -7
- package/dist/errors.js +192 -41
- package/dist/index.d.ts +11 -6
- package/dist/index.js +10 -6
- package/dist/keys/index.d.ts +61 -0
- package/dist/keys/index.js +151 -0
- package/dist/policy/index.d.ts +1 -1
- package/dist/policy/index.js +1 -1
- package/dist/policy/types.d.ts +31 -0
- package/dist/policy/types.js +15 -0
- package/dist/query/client.js +19 -8
- package/dist/react/AbloProvider.d.ts +37 -0
- package/dist/react/AbloProvider.js +107 -4
- 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/ddl.d.ts +62 -0
- package/dist/schema/ddl.js +317 -0
- package/dist/schema/diff.d.ts +6 -0
- package/dist/schema/diff.js +21 -3
- package/dist/schema/field.d.ts +16 -19
- package/dist/schema/field.js +30 -17
- package/dist/schema/index.d.ts +7 -4
- package/dist/schema/index.js +9 -3
- package/dist/schema/model.d.ts +87 -25
- package/dist/schema/model.js +33 -3
- package/dist/schema/relation.d.ts +17 -0
- package/dist/schema/roles.d.ts +148 -0
- package/dist/schema/roles.js +149 -0
- package/dist/schema/schema.d.ts +2 -112
- package/dist/schema/schema.js +50 -62
- package/dist/schema/select.d.ts +25 -0
- package/dist/schema/select.js +55 -0
- package/dist/schema/serialize.d.ts +16 -12
- package/dist/schema/serialize.js +16 -12
- package/dist/schema/sugar.d.ts +20 -3
- package/dist/schema/sugar.js +5 -1
- package/dist/schema/tenancy.d.ts +66 -0
- package/dist/schema/tenancy.js +58 -0
- 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.d.ts +2 -0
- package/dist/sync/HydrationCoordinator.js +26 -19
- 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/createIntentStream.d.ts +2 -1
- package/dist/sync/createIntentStream.js +46 -1
- package/dist/sync/participants.js +10 -16
- package/dist/transactions/TransactionQueue.js +13 -1
- package/dist/types/streams.d.ts +53 -33
- package/docs/api-keys.md +47 -3
- package/docs/api.md +103 -57
- package/docs/audit.md +16 -9
- package/docs/cli.md +222 -0
- package/docs/client-behavior.md +35 -21
- package/docs/coordination.md +74 -36
- package/docs/data-sources.md +23 -21
- package/docs/examples/agent-human.md +72 -28
- package/docs/examples/ai-sdk-tool.md +14 -11
- package/docs/examples/existing-python-backend.md +30 -19
- package/docs/examples/nextjs.md +21 -8
- package/docs/examples/scoped-agent.md +93 -0
- package/docs/examples/server-agent.md +27 -5
- package/docs/guarantees.md +29 -17
- package/docs/identity.md +198 -121
- package/docs/index.md +35 -18
- package/docs/integration-guide.md +79 -83
- package/docs/interaction-model.md +40 -25
- 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 +18 -14
- package/docs/roadmap.md +15 -3
- 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 +13 -1
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
* Wire contract (apps/sync-server/src/hub/types.ts):
|
|
12
12
|
* • Outbound: `{ type: 'intent_begin', payload: { intentId,
|
|
13
13
|
* entityType, entityId, action, field?, estimatedMs? } }`
|
|
14
|
-
* • Outbound: `{ type: 'intent_abandon', payload: { intentId
|
|
14
|
+
* • Outbound: `{ type: 'intent_abandon', payload: { intentId,
|
|
15
|
+
* entityType?, entityId? } }`
|
|
15
16
|
* • Inbound (via presence): `event.activeIntents: IntentClaim[]`
|
|
16
17
|
* stamped with `declaredAt`, `expiresAt`.
|
|
17
18
|
* • Inbound: `intent_rejected` event with conflict metadata.
|
|
@@ -38,6 +39,7 @@ export function createIntentStream(config, transport = null) {
|
|
|
38
39
|
// ── Subscribers ──────────────────────────────────────────────────
|
|
39
40
|
const listeners = new Set();
|
|
40
41
|
const rejectionListeners = new Set();
|
|
42
|
+
const lostListeners = new Set();
|
|
41
43
|
const notifyListeners = () => {
|
|
42
44
|
intentsSnapshot = Object.freeze(Array.from(activeByIntentId.values()));
|
|
43
45
|
for (const l of listeners) {
|
|
@@ -131,6 +133,24 @@ export function createIntentStream(config, transport = null) {
|
|
|
131
133
|
}
|
|
132
134
|
}
|
|
133
135
|
}));
|
|
136
|
+
// (2a) Server-side LOSS frames — you held it, then lost it (preempted /
|
|
137
|
+
// expired). Distinct from a rejection (a claim the server refused).
|
|
138
|
+
unsubs.push(t.subscribe('intent_lost', (payload) => {
|
|
139
|
+
const lost = payload;
|
|
140
|
+
if (!lost.intentId)
|
|
141
|
+
return;
|
|
142
|
+
// Drop the lost own-claim so reconnect doesn't re-announce a lease we
|
|
143
|
+
// no longer hold.
|
|
144
|
+
ownIntents.delete(lost.intentId);
|
|
145
|
+
for (const l of lostListeners) {
|
|
146
|
+
try {
|
|
147
|
+
l(lost);
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
/* isolate */
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}));
|
|
134
154
|
// (2b) Per-entity wait-queue snapshots. The server fans the full line
|
|
135
155
|
// out on every queue mutation; we replace our cached line for that
|
|
136
156
|
// entity and notify so `queue(target)` reads reactively.
|
|
@@ -178,6 +198,20 @@ export function createIntentStream(config, transport = null) {
|
|
|
178
198
|
},
|
|
179
199
|
});
|
|
180
200
|
}
|
|
201
|
+
function sendReorder(entityType, entityId, order) {
|
|
202
|
+
if (!attached?.isConnected())
|
|
203
|
+
return;
|
|
204
|
+
attached.send({
|
|
205
|
+
type: 'intent_reorder',
|
|
206
|
+
payload: {
|
|
207
|
+
entityType,
|
|
208
|
+
entityId,
|
|
209
|
+
// The wire shape identifies a waiter by heldBy + intentId; map the
|
|
210
|
+
// ergonomic `Intent[]` (what `queueFor` returns) down to that.
|
|
211
|
+
order: order.map((i) => ({ heldBy: i.heldBy, intentId: i.id })),
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
}
|
|
181
215
|
function sendAbandon(intentId, intent) {
|
|
182
216
|
if (!attached?.isConnected())
|
|
183
217
|
return;
|
|
@@ -252,6 +286,10 @@ export function createIntentStream(config, transport = null) {
|
|
|
252
286
|
const ref = resolveTarget(target);
|
|
253
287
|
return queueByEntity.get(entityKey(ref.type, ref.id)) ?? EMPTY_QUEUE;
|
|
254
288
|
},
|
|
289
|
+
reorder(target, order) {
|
|
290
|
+
const ref = resolveTarget(target);
|
|
291
|
+
sendReorder(ref.type, ref.id, order);
|
|
292
|
+
},
|
|
255
293
|
onChange: (listener) => {
|
|
256
294
|
listeners.add(listener);
|
|
257
295
|
return () => {
|
|
@@ -264,6 +302,12 @@ export function createIntentStream(config, transport = null) {
|
|
|
264
302
|
rejectionListeners.delete(listener);
|
|
265
303
|
};
|
|
266
304
|
},
|
|
305
|
+
onLost: (listener) => {
|
|
306
|
+
lostListeners.add(listener);
|
|
307
|
+
return () => {
|
|
308
|
+
lostListeners.delete(listener);
|
|
309
|
+
};
|
|
310
|
+
},
|
|
267
311
|
[Symbol.asyncIterator]() {
|
|
268
312
|
return asyncIteratorFrom((onChange) => {
|
|
269
313
|
listeners.add(onChange);
|
|
@@ -279,6 +323,7 @@ export function createIntentStream(config, transport = null) {
|
|
|
279
323
|
unsubs.length = 0;
|
|
280
324
|
listeners.clear();
|
|
281
325
|
rejectionListeners.clear();
|
|
326
|
+
lostListeners.clear();
|
|
282
327
|
activeByIntentId.clear();
|
|
283
328
|
ownIntents.clear();
|
|
284
329
|
queueByEntity.clear();
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { scopeKindOf } from '../schema/model.js';
|
|
2
|
+
import { AbloConnectionError, AbloValidationError } from '../errors.js';
|
|
1
3
|
export function createParticipantManager(config) {
|
|
2
4
|
return {
|
|
3
5
|
async join(input, overrides) {
|
|
@@ -9,7 +11,7 @@ export function createParticipantManager(config) {
|
|
|
9
11
|
await config.ready();
|
|
10
12
|
const transport = config.getTransport();
|
|
11
13
|
if (!transport) {
|
|
12
|
-
throw new
|
|
14
|
+
throw new AbloConnectionError('Ablo participant join failed: WebSocket is not connected', { code: 'ws_not_ready' });
|
|
13
15
|
}
|
|
14
16
|
const claimId = createParticipantClaimId();
|
|
15
17
|
if (syncGroups.length > 0) {
|
|
@@ -74,17 +76,13 @@ export function resolveParticipantSyncGroups(scope, schema) {
|
|
|
74
76
|
}
|
|
75
77
|
export function syncGroupFromEntityRef(ref, schema) {
|
|
76
78
|
const match = findModelForEntityRef(ref, schema);
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
}
|
|
80
|
-
return `${ref.type.toLowerCase()}:${ref.id}`;
|
|
79
|
+
const kind = match ? scopeKindOf(match.def, match.key) : undefined;
|
|
80
|
+
return `${kind ?? ref.type.toLowerCase()}:${ref.id}`;
|
|
81
81
|
}
|
|
82
82
|
function syncGroupFromSchemaKey(schemaKey, id, schema) {
|
|
83
83
|
const def = schema?.models?.[schemaKey];
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
}
|
|
87
|
-
return `${schemaKey}:${id}`;
|
|
84
|
+
const kind = def ? scopeKindOf(def, schemaKey) : undefined;
|
|
85
|
+
return `${kind ?? schemaKey}:${id}`;
|
|
88
86
|
}
|
|
89
87
|
function findModelForEntityRef(ref, schema) {
|
|
90
88
|
if (!schema?.models)
|
|
@@ -98,12 +96,6 @@ function findModelForEntityRef(ref, schema) {
|
|
|
98
96
|
}
|
|
99
97
|
return null;
|
|
100
98
|
}
|
|
101
|
-
function renderSyncGroupFormat(format, values) {
|
|
102
|
-
return format.replace(/\{([a-zA-Z_][a-zA-Z0-9_]*)\}/g, (_match, key) => {
|
|
103
|
-
const value = values[key];
|
|
104
|
-
return value === undefined ? `{${key}}` : value;
|
|
105
|
-
});
|
|
106
|
-
}
|
|
107
99
|
export function parseParticipantTtlSeconds(value) {
|
|
108
100
|
if (typeof value === 'number' && Number.isFinite(value))
|
|
109
101
|
return value;
|
|
@@ -163,7 +155,9 @@ function createJoinedParticipant(args) {
|
|
|
163
155
|
const requireTarget = (target) => {
|
|
164
156
|
const resolved = target ? targetToEntityRef(target) : currentTarget;
|
|
165
157
|
if (!resolved) {
|
|
166
|
-
throw new
|
|
158
|
+
throw new AbloValidationError('Participant action requires a structured target', {
|
|
159
|
+
code: 'invalid_request',
|
|
160
|
+
});
|
|
167
161
|
}
|
|
168
162
|
return resolved;
|
|
169
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/dist/types/streams.d.ts
CHANGED
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
* the shared coordination substrate.
|
|
10
10
|
*/
|
|
11
11
|
import type { InferModel, Schema } from '../schema/schema.js';
|
|
12
|
+
import type { TargetRange, OnStaleMode, IntentClaim, PresenceKind } from '../coordination/schema.js';
|
|
13
|
+
export type { TargetRange, OnStaleMode, IntentClaim, PresenceKind };
|
|
12
14
|
/**
|
|
13
15
|
* Any JSON-serializable value. Used where the SDK accepts free-form
|
|
14
16
|
* metadata that will be persisted / transported as JSON — avoids
|
|
@@ -113,13 +115,6 @@ export interface ContextChange {
|
|
|
113
115
|
* snapshot. Defaults to `'reject'` when `readAt` is provided without
|
|
114
116
|
* `onStale`.
|
|
115
117
|
*/
|
|
116
|
-
export type OnStaleMode = 'reject' | 'flag' | 'merge' | 'force';
|
|
117
|
-
export interface TargetRange {
|
|
118
|
-
readonly startLine: number;
|
|
119
|
-
readonly endLine: number;
|
|
120
|
-
readonly startColumn?: number;
|
|
121
|
-
readonly endColumn?: number;
|
|
122
|
-
}
|
|
123
118
|
/**
|
|
124
119
|
* A pointer to one entity, optionally narrowed to a structured
|
|
125
120
|
* subtarget. `type` and `id` are customer schema vocabulary; `path`,
|
|
@@ -304,32 +299,6 @@ export interface Peer {
|
|
|
304
299
|
/** Pending-mutation intents this participant has declared. */
|
|
305
300
|
readonly activeIntents?: ReadonlyArray<IntentClaim>;
|
|
306
301
|
}
|
|
307
|
-
/**
|
|
308
|
-
* Pending-mutation intent on the wire. Declared via `intent_begin`,
|
|
309
|
-
* cleared on `intent_abandon` / commit / disconnect / TTL expiry.
|
|
310
|
-
* Server stamps `declaredAt` and `expiresAt` (ms epoch). The SDK's
|
|
311
|
-
* `IntentStream.others` exposes a richer `ActiveIntent` view (defined
|
|
312
|
-
* below) that adds `heldBy` so callers know which participant owns it.
|
|
313
|
-
*/
|
|
314
|
-
export interface IntentClaim {
|
|
315
|
-
readonly intentId: string;
|
|
316
|
-
readonly entityType: string;
|
|
317
|
-
readonly entityId: string;
|
|
318
|
-
readonly path?: string;
|
|
319
|
-
readonly range?: TargetRange;
|
|
320
|
-
readonly action: string;
|
|
321
|
-
readonly field?: string;
|
|
322
|
-
readonly meta?: Record<string, unknown>;
|
|
323
|
-
readonly declaredAt: number;
|
|
324
|
-
readonly expiresAt: number;
|
|
325
|
-
}
|
|
326
|
-
/**
|
|
327
|
-
* Transition type carried on every presence frame from the server.
|
|
328
|
-
* - `'enter'` — first frame the receiver sees for this peer.
|
|
329
|
-
* - `'update'` — activity / intent change on an already-known peer.
|
|
330
|
-
* - `'leave'` — peer departed (explicit disconnect or TTL expiry).
|
|
331
|
-
*/
|
|
332
|
-
export type PresenceKind = 'enter' | 'update' | 'leave';
|
|
333
302
|
/** Outbound `presence_update` payload. */
|
|
334
303
|
export interface PresenceUpdatePayload {
|
|
335
304
|
readonly status: 'online' | 'away' | 'offline' | (string & {});
|
|
@@ -411,6 +380,15 @@ export interface IntentStream {
|
|
|
411
380
|
* `subscribe(...)` for change notifications.
|
|
412
381
|
*/
|
|
413
382
|
queueFor(target: PresenceTarget): readonly Intent[];
|
|
383
|
+
/**
|
|
384
|
+
* Re-rank the wait queue on a target — move the listed waiters to the front
|
|
385
|
+
* in the given order; unlisted waiters keep their relative FIFO order behind
|
|
386
|
+
* them. Pass the `Intent[]` from `queueFor(target)` in the order you want
|
|
387
|
+
* (each `Intent` carries its `heldBy` + `id`). Privileged: the server gates
|
|
388
|
+
* it (a participant lacking the `intent.reorder` capability is denied), so
|
|
389
|
+
* this is fire-and-forget — the new order arrives reactively via `queueFor`.
|
|
390
|
+
*/
|
|
391
|
+
reorder(target: PresenceTarget, order: readonly Intent[]): void;
|
|
414
392
|
/**
|
|
415
393
|
* Framework-agnostic reactivity. Same contract as
|
|
416
394
|
* `PresenceStream.subscribe` — register a listener fired on every
|
|
@@ -435,6 +413,23 @@ export interface IntentStream {
|
|
|
435
413
|
* Returns an unsubscribe fn.
|
|
436
414
|
*/
|
|
437
415
|
onRejected(listener: (rejection: IntentRejection) => void): () => void;
|
|
416
|
+
/**
|
|
417
|
+
* Observe LOSING an intent you held — distinct from `onRejected` (a claim the
|
|
418
|
+
* server refused). Fires on the server's `intent_lost` frame, carrying why:
|
|
419
|
+
* `'preempted'` (a privileged participant evicted you) or `'expired'` (your
|
|
420
|
+
* TTL lapsed). Lets a holder react — re-plan vs re-claim — instead of
|
|
421
|
+
* silently discovering the lease gone via presence.
|
|
422
|
+
*
|
|
423
|
+
* ```ts
|
|
424
|
+
* participant.intents.onLost((lost) => {
|
|
425
|
+
* if (lost.reason === 'preempted') replanAgainst(lost.target);
|
|
426
|
+
* else reclaim(lost.target);
|
|
427
|
+
* });
|
|
428
|
+
* ```
|
|
429
|
+
*
|
|
430
|
+
* Returns an unsubscribe fn.
|
|
431
|
+
*/
|
|
432
|
+
onLost(listener: (lost: IntentLost) => void): () => void;
|
|
438
433
|
/**
|
|
439
434
|
* Async-iterable view of everyone else's open intents. Each
|
|
440
435
|
* iteration yields the current snapshot on every mutation.
|
|
@@ -473,6 +468,31 @@ export interface IntentRejection {
|
|
|
473
468
|
/** When the existing claim expires (ms since epoch). */
|
|
474
469
|
readonly heldByExpiresAt: number;
|
|
475
470
|
}
|
|
471
|
+
/**
|
|
472
|
+
* You LOST an intent you were HOLDING — distinct from `IntentRejection` (a
|
|
473
|
+
* claim the server refused you). Delivered via `onLost`.
|
|
474
|
+
*/
|
|
475
|
+
export interface IntentLost {
|
|
476
|
+
/** The held claim's id that you just lost. */
|
|
477
|
+
readonly intentId: string;
|
|
478
|
+
/**
|
|
479
|
+
* How you lost it. `'preempted'`: a privileged participant (one holding the
|
|
480
|
+
* `intent.preempt` capability) evicted you and took the lease — its work now
|
|
481
|
+
* supersedes yours, so re-plan against the new holder rather than blindly
|
|
482
|
+
* re-claiming. `'expired'`: your TTL lapsed without finishing — re-claim if
|
|
483
|
+
* you still need it.
|
|
484
|
+
*/
|
|
485
|
+
readonly reason: 'expired' | 'preempted';
|
|
486
|
+
/** The target you no longer hold. */
|
|
487
|
+
readonly target: {
|
|
488
|
+
readonly entityType: string;
|
|
489
|
+
readonly entityId: string;
|
|
490
|
+
readonly path?: string;
|
|
491
|
+
readonly range?: TargetRange;
|
|
492
|
+
readonly field?: string;
|
|
493
|
+
readonly meta?: Record<string, unknown>;
|
|
494
|
+
};
|
|
495
|
+
}
|
|
476
496
|
export interface IntentDeclaration {
|
|
477
497
|
readonly target: EntityRef;
|
|
478
498
|
/** Human-readable reason — "rewriting title" / "restyling chart". */
|
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
|
|
@@ -22,3 +22,47 @@ Use API keys from trusted runtimes:
|
|
|
22
22
|
- webhooks
|
|
23
23
|
|
|
24
24
|
Never ship a secret API key to a browser bundle.
|
|
25
|
+
|
|
26
|
+
## Test mode and sandboxes
|
|
27
|
+
|
|
28
|
+
Test and live keys are the same shape; the prefix names the environment:
|
|
29
|
+
|
|
30
|
+
- `sk_test_…` — a key bound to a **sandbox**. Its reads and writes are isolated
|
|
31
|
+
to that sandbox and are invisible to live keys (and to other sandboxes).
|
|
32
|
+
- `sk_live_…` — a key against your live data.
|
|
33
|
+
|
|
34
|
+
Every org has a default **Test mode** sandbox, plus any number of additional
|
|
35
|
+
sandboxes you create. **Data is isolated per sandbox; the schema is shared
|
|
36
|
+
across the whole org.** A schema you push from a test key defines the same
|
|
37
|
+
models your live keys see — only the rows differ. This mirrors how Stripe
|
|
38
|
+
separates test and live data while keeping the API shape identical.
|
|
39
|
+
|
|
40
|
+
## Scopes
|
|
41
|
+
|
|
42
|
+
Keys carry scopes following the principle of least privilege — each key gets
|
|
43
|
+
only what its job needs. A secret key with **no scopes** has full org authority
|
|
44
|
+
(the default for a `sk_live_` backend key); a key with a non-empty scope set is
|
|
45
|
+
restricted to exactly those grants:
|
|
46
|
+
|
|
47
|
+
- `schema:push` — author the org schema (`ablo schema push`, `ablo dev`). A
|
|
48
|
+
high-risk, org-wide grant: because schema is shared, a push affects the live
|
|
49
|
+
table shape. A full-authority key has it implicitly; a *restricted* key (such
|
|
50
|
+
as a sandbox key) needs it granted explicitly.
|
|
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
|
+
|
|
54
|
+
A key minted from the default **Test mode** sandbox carries `schema:push`, so
|
|
55
|
+
`ablo dev` works out of the box. Keys from other sandboxes are **data-only** by
|
|
56
|
+
default — enable "schema authoring" when minting if you want that key to push
|
|
57
|
+
schema too. Hand data-only keys to embedded apps and CI agents; reserve
|
|
58
|
+
schema-authoring keys for the developer running `ablo dev`.
|
|
59
|
+
|
|
60
|
+
### `ablo dev`
|
|
61
|
+
|
|
62
|
+
```sh
|
|
63
|
+
ABLO_API_KEY=sk_test_… npx ablo dev
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Pushes your `ablo/schema.ts` to the test sandbox, prints the one line you need
|
|
67
|
+
in `.env.local`, and re-pushes on every save. It refuses `sk_live_` keys so a
|
|
68
|
+
tight save loop can never churn production data.
|
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,47 +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
|
-
`list` and `
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
const readyReports = ablo.weatherReports.list({
|
|
57
|
-
where: { status: 'ready' },
|
|
58
|
-
filter: (report) => !report.location.startsWith('[archived]'),
|
|
59
|
-
orderBy: { updatedAt: 'desc' },
|
|
60
|
-
limit: 20,
|
|
61
|
-
scope: 'live', // 'live' | 'archived' | 'all'
|
|
62
|
-
});
|
|
63
|
-
```
|
|
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.
|
|
64
74
|
|
|
65
75
|
## Protected Writes
|
|
66
76
|
|
|
@@ -88,23 +98,26 @@ Protected write options:
|
|
|
88
98
|
|
|
89
99
|
## Claims
|
|
90
100
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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.
|
|
95
106
|
|
|
96
|
-
|
|
97
|
-
`ablo.<model>.claim(id,
|
|
98
|
-
|
|
99
|
-
|
|
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`.
|
|
100
113
|
|
|
101
114
|
### The Claim State Object
|
|
102
115
|
|
|
103
116
|
| Field | Type | Description |
|
|
104
117
|
|---|---|---|
|
|
105
|
-
| `object` | `'
|
|
118
|
+
| `object` | `'intent'` | String representing the object's type. |
|
|
106
119
|
| `id` | string | Unique identifier for the claim. |
|
|
107
|
-
| `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. |
|
|
108
121
|
| `target` | `{ type, id, field? }` | What is being coordinated. |
|
|
109
122
|
| `action` | string | Human-readable phase — `'editing'`, `'writing'`, `'reviewing'`. |
|
|
110
123
|
| `heldBy` | string | Participant id holding the claim. |
|
|
@@ -113,7 +126,7 @@ early. Claims are **advisory** — they serialize on contention rather than lock
|
|
|
113
126
|
|
|
114
127
|
```json
|
|
115
128
|
{
|
|
116
|
-
"object": "
|
|
129
|
+
"object": "intent",
|
|
117
130
|
"id": "claim_3MtwBwLkdIwHu7ix",
|
|
118
131
|
"status": "active",
|
|
119
132
|
"target": { "type": "weatherReports", "id": "report_stockholm", "field": "status" },
|
|
@@ -136,49 +149,82 @@ early. Claims are **advisory** — they serialize on contention rather than lock
|
|
|
136
149
|
(release w/o write) (TTL; holder died)
|
|
137
150
|
```
|
|
138
151
|
|
|
139
|
-
A target is free when `ablo.<model>.
|
|
140
|
-
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)`).
|
|
141
156
|
|
|
142
157
|
### Reading and claiming
|
|
143
158
|
|
|
144
|
-
`
|
|
145
|
-
returns the live claim state object (or `null`). `claim(id,
|
|
146
|
-
it
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
`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.
|
|
151
167
|
|
|
152
168
|
```ts
|
|
153
|
-
|
|
154
|
-
const claim = ablo.weatherReports.claimState('report_stockholm');
|
|
169
|
+
const claim = ablo.weatherReports.claim.state('report_stockholm');
|
|
155
170
|
if (claim) {
|
|
156
|
-
claim.heldBy;
|
|
157
|
-
claim.action;
|
|
171
|
+
claim.heldBy;
|
|
172
|
+
claim.action;
|
|
158
173
|
}
|
|
159
174
|
|
|
160
|
-
// Write side — claim for the duration of the callback.
|
|
161
175
|
const updated = await ablo.weatherReports.claim(
|
|
162
176
|
'report_stockholm',
|
|
163
177
|
async (report) => ablo.weatherReports.update(report.id, { status: 'ready' }),
|
|
164
178
|
{ action: 'editing', ttl: '2m' },
|
|
165
179
|
);
|
|
166
|
-
updated.status; // 'ready'
|
|
167
180
|
```
|
|
168
181
|
|
|
169
|
-
Writes go through the normal
|
|
170
|
-
a claim on `id`, that `update`
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
|
174
187
|
need to release early.
|
|
175
188
|
|
|
176
189
|
## Agent
|
|
177
190
|
|
|
178
191
|
Most agents should import the same schema as the app and call
|
|
179
|
-
`ablo.<model>.
|
|
192
|
+
`ablo.<model>.list(...)`, `ablo.<model>.claim(...)`, and
|
|
180
193
|
`ablo.<model>.update(...)`.
|
|
181
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
|
+
|
|
182
228
|
## Errors
|
|
183
229
|
|
|
184
230
|
All SDK errors extend `AbloError` and expose a stable `type` string.
|