@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.
Files changed (83) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +54 -45
  3. package/dist/BaseSyncedStore.js +7 -3
  4. package/dist/SyncEngineContext.d.ts +2 -1
  5. package/dist/SyncEngineContext.js +5 -3
  6. package/dist/agent/session.js +3 -2
  7. package/dist/auth/index.js +39 -11
  8. package/dist/client/Ablo.d.ts +111 -3
  9. package/dist/client/Ablo.js +143 -10
  10. package/dist/client/ApiClient.d.ts +32 -0
  11. package/dist/client/ApiClient.js +76 -44
  12. package/dist/client/auth.d.ts +11 -1
  13. package/dist/client/auth.js +21 -2
  14. package/dist/client/createModelProxy.d.ts +107 -63
  15. package/dist/client/createModelProxy.js +65 -33
  16. package/dist/client/identity.js +14 -0
  17. package/dist/client/registerDataSource.d.ts +19 -0
  18. package/dist/client/registerDataSource.js +57 -0
  19. package/dist/client/validateAbloOptions.d.ts +2 -1
  20. package/dist/client/validateAbloOptions.js +8 -7
  21. package/dist/errorCodes.d.ts +23 -1
  22. package/dist/errorCodes.js +34 -1
  23. package/dist/errors.d.ts +52 -1
  24. package/dist/errors.js +140 -42
  25. package/dist/index.d.ts +9 -5
  26. package/dist/index.js +9 -5
  27. package/dist/keys/index.d.ts +61 -0
  28. package/dist/keys/index.js +151 -0
  29. package/dist/query/client.js +19 -8
  30. package/dist/react/AbloProvider.d.ts +25 -0
  31. package/dist/react/AbloProvider.js +97 -2
  32. package/dist/react/ClientSideSuspense.d.ts +1 -1
  33. package/dist/react/DefaultFallback.d.ts +1 -1
  34. package/dist/react/SyncGroupProvider.d.ts +1 -1
  35. package/dist/react/index.d.ts +3 -2
  36. package/dist/react/index.js +3 -2
  37. package/dist/react/useAblo.d.ts +4 -4
  38. package/dist/react/useAblo.js +10 -5
  39. package/dist/react/useReactive.js +16 -3
  40. package/dist/schema/serialize.d.ts +3 -3
  41. package/dist/schema/serialize.js +2 -2
  42. package/dist/sync/BootstrapHelper.js +46 -27
  43. package/dist/sync/ConnectionManager.d.ts +3 -1
  44. package/dist/sync/ConnectionManager.js +37 -1
  45. package/dist/sync/HydrationCoordinator.js +3 -2
  46. package/dist/sync/NetworkProbe.d.ts +8 -0
  47. package/dist/sync/NetworkProbe.js +24 -2
  48. package/dist/sync/SyncWebSocket.d.ts +1 -1
  49. package/dist/sync/SyncWebSocket.js +43 -53
  50. package/dist/sync/participants.js +5 -2
  51. package/dist/transactions/TransactionQueue.js +13 -1
  52. package/docs/api-keys.md +5 -5
  53. package/docs/api.md +101 -44
  54. package/docs/audit.md +16 -9
  55. package/docs/cli.md +27 -17
  56. package/docs/client-behavior.md +34 -20
  57. package/docs/coordination.md +40 -51
  58. package/docs/data-sources.md +21 -19
  59. package/docs/examples/agent-human.md +72 -28
  60. package/docs/examples/ai-sdk-tool.md +14 -11
  61. package/docs/examples/existing-python-backend.md +27 -16
  62. package/docs/examples/nextjs.md +21 -8
  63. package/docs/examples/scoped-agent.md +42 -27
  64. package/docs/examples/server-agent.md +27 -5
  65. package/docs/guarantees.md +26 -17
  66. package/docs/identity.md +65 -59
  67. package/docs/index.md +30 -19
  68. package/docs/integration-guide.md +52 -52
  69. package/docs/interaction-model.md +38 -26
  70. package/docs/mcp/claude-code.md +9 -17
  71. package/docs/mcp/cursor.md +6 -24
  72. package/docs/mcp/windsurf.md +6 -19
  73. package/docs/mcp.md +103 -26
  74. package/docs/quickstart.md +31 -39
  75. package/docs/react.md +15 -11
  76. package/docs/roadmap.md +13 -13
  77. package/docs/schema-contract.md +109 -0
  78. package/examples/README.md +8 -4
  79. package/examples/data-source/README.md +6 -2
  80. package/examples/data-source/run.ts +4 -3
  81. package/examples/quickstart.ts +1 -1
  82. package/llms.txt +27 -16
  83. 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`, `list`, `count`, `create`, `update`, `delete`,
11
- * `claim`, `claimState`, `queue`, `release`, `subscribe`, and `load`.
12
- * The factory returns a plain object; the client assembles the
13
- * `ablo.<model>` lookup table from these.
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
- * `load()` calls and union — the wire protocol is AND-only.
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
- export interface ModelOperations<T, CreateInput> {
177
- /**
178
- * Retrieve a single entity by id from the local pool. Synchronous.
179
- * Returns `undefined` when the entity isn't loaded yet — use
180
- * `load({where: {id}})` if you want to lazy-hydrate from storage/network.
181
- *
182
- * Mirrors `stripe.customers.retrieve(id)`.
183
- */
184
- retrieve(id: string): T | undefined;
185
- /**
186
- * List entities matching a filter from the local pool. Synchronous.
187
- * No network round-trip use `load()` for hydration.
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
- * Claim a row so other writers wait or are rejected until you're done.
206
- * Reads stay open by default. Prefer the callback form for ordinary held
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
- claim(id: string, options?: ClaimOptions): Promise<ClaimedRow<T>>;
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
- * Read who's coordinating on a row — the current holder (who, phase,
221
- * until when), or `null` when free. Synchronous and reactive; for
222
- * observers/UI. Never blocks.
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
- claimState(id: string): Intent | null;
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
- * given order. Pass the `Intent[]` from `queue(id).data`, reordered. A
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
- /** Listen for changes (callback called on every change). */
255
- onChange(callback: (entities: T[]) => void, options?: ModelListOptions<T>): () => void;
234
+ }
235
+ export interface ModelOperations<T, CreateInput> {
256
236
  /**
257
- * Load matching rows into the local graph if they are not already
258
- * present. Single-flight: concurrent calls with the same args share
259
- * one in-flight request. Default `type: 'complete'` waits for the
260
- * server; `type: 'unknown'` returns local + refreshes async.
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
- load(options?: ModelLoadOptions<T>): Promise<T[]>;
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`, `list`, `count`, `create`, `update`, `delete`,
11
- * `claim`, `claimState`, `queue`, `release`, `subscribe`, and `load`.
12
- * The factory returns a plain object; the client assembles the
13
- * `ablo.<model>` lookup table from these.
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
- list(options) {
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
- count(options) {
170
- return this.list(options).length;
215
+ getCount(options) {
216
+ return this.getAll(options).length;
171
217
  },
172
- async create(data, options) {
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 update(id, data, options) {
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 delete(id, options) {
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
- claimState(id) {
215
- return collaboration?.observe({ model: schemaKey, id }) ?? null;
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.list(options);
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,
@@ -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): Error | null;
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 Error('Ablo: `url` is required. Pass the sync server URL, e.g. ' +
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 Error('Ablo: `user.id` must be a non-empty string when `user` is provided.');
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 Error('Ablo: provide either `apiKey` or `agentId` for `kind: "agent"`. ' +
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 Error('Ablo: provide either `apiKey` (hosted cloud — SDK exchanges internally) ' +
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
  }
@@ -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-28";
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;