@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
|
@@ -7,10 +7,12 @@
|
|
|
7
7
|
* testable in isolation and the constructor doesn't carry it.
|
|
8
8
|
*
|
|
9
9
|
* Each schema model gets one `ModelOperations<T, CreateInput>` —
|
|
10
|
-
* exposes `retrieve
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* `
|
|
10
|
+
* exposes the async server reads `retrieve` / `list`, the synchronous
|
|
11
|
+
* local-graph snapshots `get` / `getAll` / `getCount`, the writes
|
|
12
|
+
* `create` / `update` / `delete`, the coordination namespace `claim`
|
|
13
|
+
* (`claim(id, work)` plus `claim.state` / `claim.queue` / `claim.release` /
|
|
14
|
+
* `claim.reorder`), and `onChange`. The factory returns a plain object; the
|
|
15
|
+
* client assembles the `ablo.<model>` lookup table from these.
|
|
14
16
|
*/
|
|
15
17
|
import type { MutationOptions } from '../interfaces/index.js';
|
|
16
18
|
import type { ModelRegistry } from '../ModelRegistry.js';
|
|
@@ -48,7 +50,7 @@ export interface ModelLoadOptions<T> {
|
|
|
48
50
|
* - tuple form: `[['name', 'ILIKE', '%Goldman%']]` for operators
|
|
49
51
|
*
|
|
50
52
|
* See `LoadWhere<T>` in `query/types.ts`. For OR semantics, run two
|
|
51
|
-
* `
|
|
53
|
+
* `list()` calls and union — the wire protocol is AND-only.
|
|
52
54
|
*/
|
|
53
55
|
where?: LoadWhere<T>;
|
|
54
56
|
orderBy?: {
|
|
@@ -68,6 +70,9 @@ export interface ModelLoadOptions<T> {
|
|
|
68
70
|
*/
|
|
69
71
|
expand?: readonly string[];
|
|
70
72
|
}
|
|
73
|
+
/** Options for the single-row async server read `retrieve(id)`. A subset of
|
|
74
|
+
* {@link ModelLoadOptions} — `where`/`limit`/`orderBy` are fixed by the id. */
|
|
75
|
+
export type ModelRetrieveOptions = Pick<ModelLoadOptions<unknown>, 'type' | 'expand'>;
|
|
71
76
|
export interface IntentLeaseHandle {
|
|
72
77
|
readonly id: string;
|
|
73
78
|
release(): Promise<void>;
|
|
@@ -173,62 +178,37 @@ export interface ClaimOptions {
|
|
|
173
178
|
* verb — there is no method chaining on the claim.
|
|
174
179
|
*/
|
|
175
180
|
export type ClaimedRow<T> = T & AsyncDisposable;
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
* Mirrors `stripe.customers.list({...})`.
|
|
190
|
-
*/
|
|
191
|
-
list(options?: ModelListOptions<T>): T[];
|
|
192
|
-
/** Count entities matching a filter (synchronous, from local pool). */
|
|
193
|
-
count(options?: ModelCountOptions<T>): number;
|
|
194
|
-
/**
|
|
195
|
-
* Create a new entity — **optimistic, offline-first**. Resolves once
|
|
196
|
-
* the mutation is queued locally, not when the server confirms.
|
|
197
|
-
* Server rejection rolls back automatically; watch `sync.syncStatus`.
|
|
198
|
-
*/
|
|
199
|
-
create(data: CreateInput, options?: MutationOptions): Promise<T>;
|
|
200
|
-
/** Update an entity by id — optimistic, offline-first (see `create`). */
|
|
201
|
-
update(id: string, data: Partial<T>, options?: MutationOptions): Promise<T>;
|
|
202
|
-
/** Delete an entity by id — optimistic, offline-first (see `create`). */
|
|
203
|
-
delete(id: string, options?: MutationOptions): Promise<void>;
|
|
181
|
+
/**
|
|
182
|
+
* The coordination surface for a model, exposed as a callable namespace.
|
|
183
|
+
*
|
|
184
|
+
* Calling it takes a claim — either the callback form (`claim(id, work)`, which
|
|
185
|
+
* releases when `work` settles) or the bare lease form (`claim(id)`). Its
|
|
186
|
+
* members read and steer the same coordination plane: `state` (current holder),
|
|
187
|
+
* `queue` (the wait line), `release` (drop a held claim), `reorder` (re-rank the
|
|
188
|
+
* line). All four stay on the ephemeral coordination plane — never persisted,
|
|
189
|
+
* never a `SyncDelta`.
|
|
190
|
+
*/
|
|
191
|
+
export interface ClaimApi<T> {
|
|
192
|
+
/** Take a claim and get the held row back (bare lease form). */
|
|
193
|
+
(id: string, options?: ClaimOptions): Promise<ClaimedRow<T>>;
|
|
204
194
|
/**
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
* work; it releases when the callback returns or throws. The `await using`
|
|
208
|
-
* form is also available for wider lexical scopes.
|
|
209
|
-
*
|
|
210
|
-
* ```ts
|
|
211
|
-
* await ablo.weatherReports.claim('report_stockholm', async (report) => {
|
|
212
|
-
* const weather = await getWeather(report.location);
|
|
213
|
-
* await ablo.weatherReports.update(report.id, { forecast: weather });
|
|
214
|
-
* });
|
|
215
|
-
* ```
|
|
195
|
+
* Take a claim, run `work` with the held row, release when it settles. The
|
|
196
|
+
* preferred form for ordinary held work.
|
|
216
197
|
*/
|
|
217
|
-
|
|
218
|
-
claim<R>(id: string, work: (row: ClaimedRow<T>) => Promise<R> | R, options?: ClaimOptions): Promise<R>;
|
|
198
|
+
<R>(id: string, work: (row: ClaimedRow<T>) => Promise<R> | R, options?: ClaimOptions): Promise<R>;
|
|
219
199
|
/**
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
*
|
|
200
|
+
* Who's coordinating on a row — the current holder (who, phase, until when),
|
|
201
|
+
* or `null` when free. Synchronous and reactive; for observers/UI. Never
|
|
202
|
+
* blocks.
|
|
223
203
|
*/
|
|
224
|
-
|
|
204
|
+
state(id: string): Intent | null;
|
|
225
205
|
/**
|
|
226
206
|
* The wait queue on a row — who's lined up behind the holder and what each
|
|
227
207
|
* intends. Reactive snapshot (synced from the server, like `activity`);
|
|
228
208
|
* returns a Stripe-style list envelope, FIFO order, empty when no one waits.
|
|
229
209
|
*
|
|
230
210
|
* ```ts
|
|
231
|
-
* const { data } = ablo.decks.queue('deck_1');
|
|
211
|
+
* const { data } = ablo.decks.claim.queue('deck_1');
|
|
232
212
|
* // → [{ heldBy: 'agent:summarizer', action: 'editing', position: 0 }, …]
|
|
233
213
|
* ```
|
|
234
214
|
*/
|
|
@@ -237,28 +217,92 @@ export interface ModelOperations<T, CreateInput> {
|
|
|
237
217
|
readonly data: readonly Intent[];
|
|
238
218
|
};
|
|
239
219
|
/**
|
|
240
|
-
* Re-rank the wait queue on a record — move waiters to the front in the
|
|
241
|
-
*
|
|
220
|
+
* Re-rank the wait queue on a record — move waiters to the front in the given
|
|
221
|
+
* order. Pass the `Intent[]` from `claim.queue(id).data`, reordered. A
|
|
242
222
|
* privileged operation: the server gates it (the caller needs the
|
|
243
223
|
* `intent.reorder` capability), so it's fire-and-forget — the new order
|
|
244
|
-
* arrives reactively through `queue(id)`.
|
|
224
|
+
* arrives reactively through `claim.queue(id)`.
|
|
245
225
|
*
|
|
246
226
|
* ```ts
|
|
247
|
-
* const { data } = ablo.decks.queue('deck_1');
|
|
248
|
-
* ablo.decks.reorder('deck_1', [data[2], data[0], data[1]]); // promote #2
|
|
227
|
+
* const { data } = ablo.decks.claim.queue('deck_1');
|
|
228
|
+
* ablo.decks.claim.reorder('deck_1', [data[2], data[0], data[1]]); // promote #2
|
|
249
229
|
* ```
|
|
250
230
|
*/
|
|
251
231
|
reorder(id: string, order: readonly Intent[]): void;
|
|
252
232
|
/** Release a claim you hold early. Usually implicit (scope exit). */
|
|
253
233
|
release(id: string): Promise<void>;
|
|
254
|
-
|
|
255
|
-
|
|
234
|
+
}
|
|
235
|
+
export interface ModelOperations<T, CreateInput> {
|
|
256
236
|
/**
|
|
257
|
-
*
|
|
258
|
-
*
|
|
259
|
-
*
|
|
260
|
-
*
|
|
237
|
+
* Read a single entity by id from the **server** — async. Resolves through
|
|
238
|
+
* the 3-tier lookup (local pool → IndexedDB → network `POST /sync/query`)
|
|
239
|
+
* and lands the row in the local graph. Resolves to `undefined` when no
|
|
240
|
+
* such row exists.
|
|
241
|
+
*
|
|
242
|
+
* This is the default "get me this entity" read and the one hosted /
|
|
243
|
+
* stateless callers want, since their local graph starts empty. For a
|
|
244
|
+
* synchronous read of an already-warm graph (a React selector) use
|
|
245
|
+
* `get(id)`.
|
|
246
|
+
*
|
|
247
|
+
* Mirrors `stripe.customers.retrieve(id)` — network-backed.
|
|
248
|
+
*/
|
|
249
|
+
retrieve(id: string, options?: ModelRetrieveOptions): Promise<T | undefined>;
|
|
250
|
+
/**
|
|
251
|
+
* List entities matching a filter from the **server** — async. Same 3-tier
|
|
252
|
+
* lookup + graph hydration as `retrieve`; single-flight deduped. Returns the
|
|
253
|
+
* matched rows.
|
|
254
|
+
*
|
|
255
|
+
* Mirrors `stripe.customers.list({...})` — network-backed. For a synchronous
|
|
256
|
+
* read of the local graph use `getAll(...)`.
|
|
257
|
+
*/
|
|
258
|
+
list(options?: ModelLoadOptions<T>): Promise<T[]>;
|
|
259
|
+
/**
|
|
260
|
+
* Synchronous snapshot of a single entity from the **local graph** — no
|
|
261
|
+
* network. Returns `undefined` when the row isn't resident (cold hosted
|
|
262
|
+
* client, or a `lazy` model not yet loaded). Pairs with reactive selectors:
|
|
263
|
+
* `useAblo((ablo) => ablo.<model>.get(id))`.
|
|
264
|
+
*/
|
|
265
|
+
get(id: string): T | undefined;
|
|
266
|
+
/**
|
|
267
|
+
* Synchronous snapshot of a filtered collection from the **local graph** —
|
|
268
|
+
* no network round-trip. Empty until `retrieve`/`list`/bootstrap has warmed
|
|
269
|
+
* the graph.
|
|
261
270
|
*/
|
|
262
|
-
|
|
271
|
+
getAll(options?: ModelListOptions<T>): T[];
|
|
272
|
+
/** Count entities in the **local graph** (synchronous, no network). */
|
|
273
|
+
getCount(options?: ModelCountOptions<T>): number;
|
|
274
|
+
/**
|
|
275
|
+
* Create a new entity — **optimistic, offline-first**. Resolves once
|
|
276
|
+
* the mutation is queued locally, not when the server confirms.
|
|
277
|
+
* Server rejection rolls back automatically; watch `sync.syncStatus`.
|
|
278
|
+
*/
|
|
279
|
+
create(data: CreateInput, options?: MutationOptions): Promise<T>;
|
|
280
|
+
/** Update an entity by id — optimistic, offline-first (see `create`). */
|
|
281
|
+
update(id: string, data: Partial<T>, options?: MutationOptions): Promise<T>;
|
|
282
|
+
/** Delete an entity by id — optimistic, offline-first (see `create`). */
|
|
283
|
+
delete(id: string, options?: MutationOptions): Promise<void>;
|
|
284
|
+
/**
|
|
285
|
+
* Claim a row so other writers wait or are rejected until you're done, and
|
|
286
|
+
* inspect or manage that coordination through the same namespace. Call it to
|
|
287
|
+
* take a claim (the callback form releases when the callback returns or
|
|
288
|
+
* throws); reach for its members to observe and steer the wait line:
|
|
289
|
+
*
|
|
290
|
+
* - `claim.state(id)` — who holds the row now, or `null` when free
|
|
291
|
+
* - `claim.queue(id)` — who's lined up behind the holder
|
|
292
|
+
* - `claim.release(id)` — drop a claim early (usually implicit on scope exit)
|
|
293
|
+
* - `claim.reorder(id, order)` — re-rank the wait line
|
|
294
|
+
*
|
|
295
|
+
* ```ts
|
|
296
|
+
* await ablo.weatherReports.claim('report_stockholm', async (report) => {
|
|
297
|
+
* const weather = await getWeather(report.location);
|
|
298
|
+
* await ablo.weatherReports.update(report.id, { forecast: weather });
|
|
299
|
+
* });
|
|
300
|
+
*
|
|
301
|
+
* const holder = ablo.weatherReports.claim.state('report_stockholm');
|
|
302
|
+
* ```
|
|
303
|
+
*/
|
|
304
|
+
claim: ClaimApi<T>;
|
|
305
|
+
/** Listen for changes (callback called on every change). */
|
|
306
|
+
onChange(callback: (entities: T[]) => void, options?: ModelListOptions<T>): () => void;
|
|
263
307
|
}
|
|
264
308
|
export declare function createModelProxy<T, C>(schemaKey: string, registeredModelName: string, objectPool: ObjectPool, syncClient: SyncClient, registry: ModelRegistry, hydration: HydrationCoordinator, collaboration?: ModelCollaboration<T>): ModelOperations<T, C>;
|
|
@@ -7,13 +7,15 @@
|
|
|
7
7
|
* testable in isolation and the constructor doesn't carry it.
|
|
8
8
|
*
|
|
9
9
|
* Each schema model gets one `ModelOperations<T, CreateInput>` —
|
|
10
|
-
* exposes `retrieve
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* `
|
|
10
|
+
* exposes the async server reads `retrieve` / `list`, the synchronous
|
|
11
|
+
* local-graph snapshots `get` / `getAll` / `getCount`, the writes
|
|
12
|
+
* `create` / `update` / `delete`, the coordination namespace `claim`
|
|
13
|
+
* (`claim(id, work)` plus `claim.state` / `claim.queue` / `claim.release` /
|
|
14
|
+
* `claim.reorder`), and `onChange`. The factory returns a plain object; the
|
|
15
|
+
* client assembles the `ablo.<model>` lookup table from these.
|
|
14
16
|
*/
|
|
15
17
|
import { autorun } from 'mobx';
|
|
16
|
-
import { AbloClaimedError, AbloValidationError } from '../errors.js';
|
|
18
|
+
import { AbloClaimedError, AbloValidationError, toAbloError, } from '../errors.js';
|
|
17
19
|
import { Model, modelAsRow } from '../Model.js';
|
|
18
20
|
import { ModelScope } from '../types/index.js';
|
|
19
21
|
const modelClientMeta = new WeakMap();
|
|
@@ -28,6 +30,22 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
|
|
|
28
30
|
throw new AbloValidationError(`Ablo: schema model "${schemaKey}" resolved to "${registeredModelName}", ` +
|
|
29
31
|
'but no matching constructor was registered.', { code: 'model_not_registered' });
|
|
30
32
|
}
|
|
33
|
+
// Last-line guarantee for the public surface: any rejection from a lower
|
|
34
|
+
// layer (transport timeout, IndexedDB failure, a third-party throw) is
|
|
35
|
+
// coerced to an AbloError before it reaches the consumer. The SDK's
|
|
36
|
+
// contract is that callers only ever catch tagged errors — `instanceof
|
|
37
|
+
// AbloError` / `e.type` always hold. Internal helpers stay unwrapped; only
|
|
38
|
+
// the methods exposed on `operations` are guarded.
|
|
39
|
+
const guard = (fn) => {
|
|
40
|
+
return async (...args) => {
|
|
41
|
+
try {
|
|
42
|
+
return await fn(...args);
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
throw toAbloError(err);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
};
|
|
31
49
|
const load = async (options) => {
|
|
32
50
|
const rows = await hydration.fetch(schemaKey, options);
|
|
33
51
|
// The coordinator returns Model instances. ModelOperations is
|
|
@@ -129,11 +147,39 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
|
|
|
129
147
|
}
|
|
130
148
|
return takeClaim(id, a);
|
|
131
149
|
}
|
|
150
|
+
// `claim` is a callable namespace: invoke it to take a claim, reach its
|
|
151
|
+
// members to read/steer the coordination plane. Attach the readers to the
|
|
152
|
+
// guarded callable so `ablo.<model>.claim(...)` and `ablo.<model>.claim.state(...)`
|
|
153
|
+
// are the same object.
|
|
154
|
+
const claimApi = Object.assign(guard(claim), {
|
|
155
|
+
state(id) {
|
|
156
|
+
return collaboration?.observe({ model: schemaKey, id }) ?? null;
|
|
157
|
+
},
|
|
158
|
+
queue(id) {
|
|
159
|
+
return {
|
|
160
|
+
object: 'list',
|
|
161
|
+
data: collaboration?.queue({ model: schemaKey, id }) ?? [],
|
|
162
|
+
};
|
|
163
|
+
},
|
|
164
|
+
reorder(id, order) {
|
|
165
|
+
collaboration?.reorder({ model: schemaKey, id }, order);
|
|
166
|
+
},
|
|
167
|
+
release: guard((id) => releaseClaim(id)),
|
|
168
|
+
});
|
|
132
169
|
const operations = {
|
|
133
|
-
retrieve(id) {
|
|
170
|
+
retrieve: guard(async (id, options) => {
|
|
171
|
+
const rows = await load({
|
|
172
|
+
...options,
|
|
173
|
+
where: { id },
|
|
174
|
+
limit: 1,
|
|
175
|
+
});
|
|
176
|
+
return rows[0];
|
|
177
|
+
}),
|
|
178
|
+
list: guard(load),
|
|
179
|
+
get(id) {
|
|
134
180
|
return objectPool.get(id);
|
|
135
181
|
},
|
|
136
|
-
|
|
182
|
+
getAll(options) {
|
|
137
183
|
const all = objectPool.getByType(ModelClass, (options?.state ?? ModelScope.live));
|
|
138
184
|
let result = all;
|
|
139
185
|
if (options?.where) {
|
|
@@ -166,10 +212,10 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
|
|
|
166
212
|
result = result.slice(0, options.limit);
|
|
167
213
|
return result;
|
|
168
214
|
},
|
|
169
|
-
|
|
170
|
-
return this.
|
|
215
|
+
getCount(options) {
|
|
216
|
+
return this.getAll(options).length;
|
|
171
217
|
},
|
|
172
|
-
async
|
|
218
|
+
create: guard(async (data, options) => {
|
|
173
219
|
// TODO(options-persistence): stash `options` alongside the
|
|
174
220
|
// queued transaction so idempotencyKey survives offline flush.
|
|
175
221
|
const model = new ModelClass({
|
|
@@ -181,8 +227,8 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
|
|
|
181
227
|
syncClient.add(model, options);
|
|
182
228
|
await waitForMutation(model, options);
|
|
183
229
|
return modelAsRow(model);
|
|
184
|
-
},
|
|
185
|
-
async
|
|
230
|
+
}),
|
|
231
|
+
update: guard(async (id, data, options) => {
|
|
186
232
|
const model = objectPool.get(id);
|
|
187
233
|
if (!model)
|
|
188
234
|
throw new AbloValidationError(`Entity not found: ${registeredModelName}/${id}`, { code: 'entity_not_found' });
|
|
@@ -202,37 +248,23 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
|
|
|
202
248
|
syncClient.update(model, effective);
|
|
203
249
|
await waitForMutation(model, effective);
|
|
204
250
|
return modelAsRow(model);
|
|
205
|
-
},
|
|
206
|
-
async
|
|
251
|
+
}),
|
|
252
|
+
delete: guard(async (id, options) => {
|
|
207
253
|
const model = objectPool.get(id);
|
|
208
254
|
if (!model)
|
|
209
255
|
throw new AbloValidationError(`Entity not found: ${registeredModelName}/${id}`, { code: 'entity_not_found' });
|
|
210
256
|
syncClient.delete(model, options);
|
|
211
257
|
await waitForMutation(model, options);
|
|
212
|
-
},
|
|
213
|
-
claim
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
},
|
|
217
|
-
queue(id) {
|
|
218
|
-
return {
|
|
219
|
-
object: 'list',
|
|
220
|
-
data: collaboration?.queue({ model: schemaKey, id }) ?? [],
|
|
221
|
-
};
|
|
222
|
-
},
|
|
223
|
-
reorder(id, order) {
|
|
224
|
-
collaboration?.reorder({ model: schemaKey, id }, order);
|
|
225
|
-
},
|
|
226
|
-
release(id) {
|
|
227
|
-
return releaseClaim(id);
|
|
228
|
-
},
|
|
258
|
+
}),
|
|
259
|
+
// `claim` is a callable namespace (take a claim) carrying the coordination
|
|
260
|
+
// readers (`claim.state` / `claim.queue` / `claim.release` / `claim.reorder`).
|
|
261
|
+
claim: claimApi,
|
|
229
262
|
onChange(callback, options) {
|
|
230
263
|
return autorun(() => {
|
|
231
|
-
const entities = this.
|
|
264
|
+
const entities = this.getAll(options);
|
|
232
265
|
callback(entities);
|
|
233
266
|
});
|
|
234
267
|
},
|
|
235
|
-
load,
|
|
236
268
|
};
|
|
237
269
|
modelClientMeta.set(operations, {
|
|
238
270
|
key: schemaKey,
|
package/dist/client/identity.js
CHANGED
|
@@ -44,6 +44,20 @@ export async function resolveParticipantIdentity(input) {
|
|
|
44
44
|
// Branch 2: self-derived (capability token present, identity unknown)
|
|
45
45
|
if (!internalOptions.organizationId ||
|
|
46
46
|
(kind === 'agent' ? !options.agentId : !options.user?.id)) {
|
|
47
|
+
// Fail fast on the missing-credential case. We're here because there's no
|
|
48
|
+
// apiKey (Branch 1) and the identity isn't caller-supplied (Branch 3), so
|
|
49
|
+
// `initialCapToken` is the only thing that can authenticate the
|
|
50
|
+
// `/auth/identity` call. When it's absent — the common cause being
|
|
51
|
+
// `getToken()` resolving to `null` (no/expired session, see
|
|
52
|
+
// `getSyncCapabilityToken`) — the request can only come back as the server's
|
|
53
|
+
// opaque `identity_resolve_failed: no_matching_provider`. Surface the real
|
|
54
|
+
// condition locally instead: `session_expired` is the registered,
|
|
55
|
+
// re-authenticate-able code, and we never make a doomed round-trip.
|
|
56
|
+
if (!initialCapToken) {
|
|
57
|
+
throw new AbloAuthenticationError('No auth token available to resolve identity — the session token is ' +
|
|
58
|
+
'missing or expired. Ensure `getToken()` returns a valid token, or ' +
|
|
59
|
+
'pass `apiKey` / `capabilityToken`.', { code: 'session_expired' });
|
|
60
|
+
}
|
|
47
61
|
const baseUrl = options.bootstrapBaseUrl ?? `${url.replace(/^ws/, 'http')}/api`;
|
|
48
62
|
const identity = await resolveIdentity({
|
|
49
63
|
baseUrl,
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export interface RegisterDataSourceInput {
|
|
2
|
+
/** HTTP API base, e.g. `https://api.abloatai.com/api` (from resolveBootstrapBaseUrl). */
|
|
3
|
+
readonly baseUrl: string;
|
|
4
|
+
/** Secret key (`sk_…`) used to authenticate + derive the org. */
|
|
5
|
+
readonly apiKey: string | null;
|
|
6
|
+
/** The customer's own Postgres connection string. */
|
|
7
|
+
readonly databaseUrl: string;
|
|
8
|
+
/** Optional Postgres schema (defaults server-side to `public`). */
|
|
9
|
+
readonly schema?: string;
|
|
10
|
+
/** Custom fetch (tests/proxies/odd runtimes). */
|
|
11
|
+
readonly fetchImpl?: typeof fetch;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* POST the connection string to the self-serve datasource route. Resolves on
|
|
15
|
+
* success (the org is now a dedicated tenant pointed at this DB); throws an
|
|
16
|
+
* `AbloError` with `datasource_registration_failed` otherwise so `ready()`
|
|
17
|
+
* surfaces it instead of silently bootstrapping against the wrong store.
|
|
18
|
+
*/
|
|
19
|
+
export declare function registerDataSource(input: RegisterDataSourceInput): Promise<void>;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Self-serve data-source registration.
|
|
3
|
+
*
|
|
4
|
+
* When a client is constructed with `databaseUrl`, the SDK registers that
|
|
5
|
+
* connection string as the project's own-Postgres data store BEFORE bootstrap,
|
|
6
|
+
* so the server resolves the org's data plane to the customer's DB and writes
|
|
7
|
+
* synced rows back into it (dedicated/BYO tenant).
|
|
8
|
+
*
|
|
9
|
+
* The org is derived server-side from the API key — the caller never sends an
|
|
10
|
+
* organization id. The connection string is sent once over TLS and is never
|
|
11
|
+
* echoed back (the server stores it as a secret and returns only a safe
|
|
12
|
+
* `datasource` projection: host, database, schema).
|
|
13
|
+
*/
|
|
14
|
+
import { AbloError } from '../errors.js';
|
|
15
|
+
/**
|
|
16
|
+
* POST the connection string to the self-serve datasource route. Resolves on
|
|
17
|
+
* success (the org is now a dedicated tenant pointed at this DB); throws an
|
|
18
|
+
* `AbloError` with `datasource_registration_failed` otherwise so `ready()`
|
|
19
|
+
* surfaces it instead of silently bootstrapping against the wrong store.
|
|
20
|
+
*/
|
|
21
|
+
export async function registerDataSource(input) {
|
|
22
|
+
if (!input.apiKey) {
|
|
23
|
+
throw new AbloError('databaseUrl requires an apiKey to register the data source (the org is derived from the key).', { code: 'datasource_registration_failed' });
|
|
24
|
+
}
|
|
25
|
+
const doFetch = input.fetchImpl ?? fetch;
|
|
26
|
+
const endpoint = `${input.baseUrl.replace(/\/+$/, '')}/v1/datasource`;
|
|
27
|
+
let response;
|
|
28
|
+
try {
|
|
29
|
+
response = await doFetch(endpoint, {
|
|
30
|
+
method: 'POST',
|
|
31
|
+
headers: {
|
|
32
|
+
'content-type': 'application/json',
|
|
33
|
+
authorization: `Bearer ${input.apiKey}`,
|
|
34
|
+
},
|
|
35
|
+
body: JSON.stringify({
|
|
36
|
+
connectionString: input.databaseUrl,
|
|
37
|
+
...(input.schema ? { schema: input.schema } : {}),
|
|
38
|
+
}),
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
catch (cause) {
|
|
42
|
+
throw new AbloError('Could not reach the Ablo API to register databaseUrl.', {
|
|
43
|
+
code: 'datasource_registration_failed',
|
|
44
|
+
cause,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
if (!response.ok) {
|
|
48
|
+
let detail = '';
|
|
49
|
+
try {
|
|
50
|
+
detail = (await response.text()).slice(0, 500);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// ignore body read failures — the status alone is enough to fail loud
|
|
54
|
+
}
|
|
55
|
+
throw new AbloError(`databaseUrl registration failed (HTTP ${response.status}). ${detail}`, { code: 'datasource_registration_failed', httpStatus: response.status });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* because the error messages reference URLs and would mislead if a
|
|
11
11
|
* URL was actually present.
|
|
12
12
|
*/
|
|
13
|
+
import { AbloError } from '../errors.js';
|
|
13
14
|
/**
|
|
14
15
|
* Minimal subset of `AbloOptions` the validator actually inspects.
|
|
15
16
|
* Defined here as its own interface so the validator doesn't pull
|
|
@@ -39,4 +40,4 @@ export interface ValidateAbloOptionsInput {
|
|
|
39
40
|
readonly configuredApiKey: unknown;
|
|
40
41
|
readonly configuredAuthToken: unknown;
|
|
41
42
|
}
|
|
42
|
-
export declare function validateAbloOptions(input: ValidateAbloOptionsInput):
|
|
43
|
+
export declare function validateAbloOptions(input: ValidateAbloOptionsInput): AbloError | null;
|
|
@@ -10,12 +10,13 @@
|
|
|
10
10
|
* because the error messages reference URLs and would mislead if a
|
|
11
11
|
* URL was actually present.
|
|
12
12
|
*/
|
|
13
|
+
import { AbloValidationError } from '../errors.js';
|
|
13
14
|
export function validateAbloOptions(input) {
|
|
14
15
|
const { options, url, configuredApiKey, configuredAuthToken } = input;
|
|
15
16
|
const kind = options.kind ?? 'user';
|
|
16
17
|
if (!url) {
|
|
17
|
-
return new
|
|
18
|
-
`Ablo({ baseURL: 'wss://sync.abloatai.com', schema, user })
|
|
18
|
+
return new AbloValidationError('Ablo: `url` is required. Pass the sync server URL, e.g. ' +
|
|
19
|
+
`Ablo({ baseURL: 'wss://sync.abloatai.com', schema, user })`, { code: 'base_url_missing' });
|
|
19
20
|
}
|
|
20
21
|
// Schema is optional for the model-first API:
|
|
21
22
|
// Ablo({ apiKey }).model('clauses').retrieve(...)
|
|
@@ -26,18 +27,18 @@ export function validateAbloOptions(input) {
|
|
|
26
27
|
kind === 'user' &&
|
|
27
28
|
options.user &&
|
|
28
29
|
!options.user.id) {
|
|
29
|
-
return new
|
|
30
|
+
return new AbloValidationError('Ablo: `user.id` must be a non-empty string when `user` is provided.', { code: 'invalid_options', param: 'user.id' });
|
|
30
31
|
}
|
|
31
32
|
if (!configuredApiKey && !configuredAuthToken && kind === 'agent' && !options.agentId) {
|
|
32
|
-
return new
|
|
33
|
+
return new AbloValidationError('Ablo: provide either `apiKey` or `agentId` for `kind: "agent"`. ' +
|
|
33
34
|
'Hosted-cloud consumers pass `apiKey` and the server derives the ' +
|
|
34
35
|
'agent identity from its scope; self-hosted passes `agentId` + ' +
|
|
35
|
-
'`capabilityToken` directly.');
|
|
36
|
+
'`capabilityToken` directly.', { code: 'invalid_options', param: 'agentId' });
|
|
36
37
|
}
|
|
37
38
|
if (!configuredApiKey && !configuredAuthToken && kind === 'agent' && !options.capabilityToken) {
|
|
38
|
-
return new
|
|
39
|
+
return new AbloValidationError('Ablo: provide either `apiKey` (hosted cloud — SDK exchanges internally) ' +
|
|
39
40
|
'or `capabilityToken` (self-hosted — your auth layer mints + hands in). ' +
|
|
40
|
-
'See https://abloatai.com/docs/api-keys for the full pattern.');
|
|
41
|
+
'See https://abloatai.com/docs/api-keys for the full pattern.', { code: 'invalid_options', param: 'capabilityToken' });
|
|
41
42
|
}
|
|
42
43
|
return null;
|
|
43
44
|
}
|
package/dist/errorCodes.d.ts
CHANGED
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
* code, a changed HTTP status, an envelope field. Emitted in `errors.json`
|
|
37
37
|
* and on the `Ablo-Version` response header so a consumer can detect drift.
|
|
38
38
|
*/
|
|
39
|
-
export declare const ERROR_CONTRACT_VERSION = "2026-05-
|
|
39
|
+
export declare const ERROR_CONTRACT_VERSION = "2026-05-29";
|
|
40
40
|
/** Coarse grouping for metrics dashboards and docs sectioning. */
|
|
41
41
|
export type ErrorCategory = 'auth' | 'permission' | 'capability' | 'claim' | 'conflict' | 'validation' | 'not_found' | 'tenant' | 'schema' | 'intent' | 'bootstrap' | 'transport' | 'rate_limit' | 'server' | 'client';
|
|
42
42
|
/** One registry entry. `httpStatus` is present only for `surface: 'wire'`
|
|
@@ -69,10 +69,25 @@ export declare const ERROR_CODES: {
|
|
|
69
69
|
readonly capability_id_missing: ErrorCodeSpec;
|
|
70
70
|
readonly exchange_failed: ErrorCodeSpec;
|
|
71
71
|
readonly identity_resolve_failed: ErrorCodeSpec;
|
|
72
|
+
readonly auth_no_credentials: ErrorCodeSpec;
|
|
73
|
+
readonly identity_missing_organization: ErrorCodeSpec;
|
|
72
74
|
readonly session_expired: ErrorCodeSpec;
|
|
75
|
+
readonly jwt_invalid: ErrorCodeSpec;
|
|
76
|
+
readonly jwt_malformed: ErrorCodeSpec;
|
|
77
|
+
readonly jwt_missing_issuer: ErrorCodeSpec;
|
|
78
|
+
readonly jwt_issuer_untrusted: ErrorCodeSpec;
|
|
79
|
+
readonly jwt_signature_invalid: ErrorCodeSpec;
|
|
80
|
+
readonly jwt_audience_mismatch: ErrorCodeSpec;
|
|
81
|
+
readonly jwt_missing_subject: ErrorCodeSpec;
|
|
82
|
+
readonly jwt_missing_organization: ErrorCodeSpec;
|
|
83
|
+
readonly jwt_expired: ErrorCodeSpec;
|
|
84
|
+
readonly jwt_org_membership_denied: ErrorCodeSpec;
|
|
73
85
|
readonly file_upload_auth_required: ErrorCodeSpec;
|
|
74
86
|
readonly browser_apikey_blocked: ErrorCodeSpec;
|
|
87
|
+
readonly browser_database_url_blocked: ErrorCodeSpec;
|
|
88
|
+
readonly datasource_registration_failed: ErrorCodeSpec;
|
|
75
89
|
readonly capability_scope_denied: ErrorCodeSpec;
|
|
90
|
+
readonly issuer_register_forbidden: ErrorCodeSpec;
|
|
76
91
|
readonly capability_invalid: ErrorCodeSpec;
|
|
77
92
|
readonly byo_role_cannot_enforce_rls: ErrorCodeSpec;
|
|
78
93
|
readonly byo_role_unreadable: ErrorCodeSpec;
|
|
@@ -92,6 +107,7 @@ export declare const ERROR_CODES: {
|
|
|
92
107
|
readonly commit_operation_required: ErrorCodeSpec;
|
|
93
108
|
readonly commit_operation_model_required: ErrorCodeSpec;
|
|
94
109
|
readonly commit_operations_ambiguous: ErrorCodeSpec;
|
|
110
|
+
readonly commit_too_many_operations: ErrorCodeSpec;
|
|
95
111
|
readonly model_required_field_missing: ErrorCodeSpec;
|
|
96
112
|
readonly model_identifier_missing: ErrorCodeSpec;
|
|
97
113
|
readonly snapshot_reserved_key: ErrorCodeSpec;
|
|
@@ -103,6 +119,11 @@ export declare const ERROR_CODES: {
|
|
|
103
119
|
readonly model_not_found: ErrorCodeSpec;
|
|
104
120
|
readonly mutate_update_entity_not_found: ErrorCodeSpec;
|
|
105
121
|
readonly task_id_missing: ErrorCodeSpec;
|
|
122
|
+
readonly not_null_violation: ErrorCodeSpec;
|
|
123
|
+
readonly foreign_key_violation: ErrorCodeSpec;
|
|
124
|
+
readonly unique_violation: ErrorCodeSpec;
|
|
125
|
+
readonly check_violation: ErrorCodeSpec;
|
|
126
|
+
readonly constraint_violation: ErrorCodeSpec;
|
|
106
127
|
readonly server_execute_unknown_model: ErrorCodeSpec;
|
|
107
128
|
readonly mutate_create_unknown_model: ErrorCodeSpec;
|
|
108
129
|
readonly tenant_model_columns_unknown: ErrorCodeSpec;
|
|
@@ -155,6 +176,7 @@ export declare const ERROR_CODES: {
|
|
|
155
176
|
readonly quota_lookup_failed: ErrorCodeSpec;
|
|
156
177
|
readonly turn_open_failed: ErrorCodeSpec;
|
|
157
178
|
readonly turn_close_failed: ErrorCodeSpec;
|
|
179
|
+
readonly invalid_options: ErrorCodeSpec;
|
|
158
180
|
readonly no_ablo_provider: ErrorCodeSpec;
|
|
159
181
|
readonly no_sync_group_provider: ErrorCodeSpec;
|
|
160
182
|
readonly sync_context_missing_provider: ErrorCodeSpec;
|