@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.
Files changed (121) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/README.md +95 -57
  3. package/dist/BaseSyncedStore.d.ts +1 -1
  4. package/dist/BaseSyncedStore.js +8 -4
  5. package/dist/SyncEngineContext.d.ts +2 -1
  6. package/dist/SyncEngineContext.js +5 -3
  7. package/dist/agent/session.js +3 -2
  8. package/dist/auth/index.js +39 -11
  9. package/dist/client/Ablo.d.ts +112 -3
  10. package/dist/client/Ablo.js +144 -10
  11. package/dist/client/ApiClient.d.ts +32 -0
  12. package/dist/client/ApiClient.js +76 -44
  13. package/dist/client/auth.d.ts +11 -1
  14. package/dist/client/auth.js +21 -2
  15. package/dist/client/createModelProxy.d.ts +120 -53
  16. package/dist/client/createModelProxy.js +66 -31
  17. package/dist/client/identity.js +14 -0
  18. package/dist/client/registerDataSource.d.ts +19 -0
  19. package/dist/client/registerDataSource.js +57 -0
  20. package/dist/client/validateAbloOptions.d.ts +2 -1
  21. package/dist/client/validateAbloOptions.js +8 -7
  22. package/dist/coordination/index.d.ts +6 -0
  23. package/dist/coordination/index.js +6 -0
  24. package/dist/coordination/schema.d.ts +329 -0
  25. package/dist/coordination/schema.js +209 -0
  26. package/dist/core/QueryView.d.ts +4 -1
  27. package/dist/core/QueryView.js +1 -1
  28. package/dist/core/query-utils.d.ts +7 -10
  29. package/dist/core/query-utils.js +2 -3
  30. package/dist/errorCodes.d.ts +286 -0
  31. package/dist/errorCodes.js +284 -0
  32. package/dist/errors.d.ts +103 -7
  33. package/dist/errors.js +192 -41
  34. package/dist/index.d.ts +11 -6
  35. package/dist/index.js +10 -6
  36. package/dist/keys/index.d.ts +61 -0
  37. package/dist/keys/index.js +151 -0
  38. package/dist/policy/index.d.ts +1 -1
  39. package/dist/policy/index.js +1 -1
  40. package/dist/policy/types.d.ts +31 -0
  41. package/dist/policy/types.js +15 -0
  42. package/dist/query/client.js +19 -8
  43. package/dist/react/AbloProvider.d.ts +37 -0
  44. package/dist/react/AbloProvider.js +107 -4
  45. package/dist/react/ClientSideSuspense.d.ts +1 -1
  46. package/dist/react/DefaultFallback.d.ts +1 -1
  47. package/dist/react/SyncGroupProvider.d.ts +1 -1
  48. package/dist/react/index.d.ts +3 -2
  49. package/dist/react/index.js +3 -2
  50. package/dist/react/useAblo.d.ts +4 -4
  51. package/dist/react/useAblo.js +10 -5
  52. package/dist/react/useReactive.js +16 -3
  53. package/dist/schema/ddl.d.ts +62 -0
  54. package/dist/schema/ddl.js +317 -0
  55. package/dist/schema/diff.d.ts +6 -0
  56. package/dist/schema/diff.js +21 -3
  57. package/dist/schema/field.d.ts +16 -19
  58. package/dist/schema/field.js +30 -17
  59. package/dist/schema/index.d.ts +7 -4
  60. package/dist/schema/index.js +9 -3
  61. package/dist/schema/model.d.ts +87 -25
  62. package/dist/schema/model.js +33 -3
  63. package/dist/schema/relation.d.ts +17 -0
  64. package/dist/schema/roles.d.ts +148 -0
  65. package/dist/schema/roles.js +149 -0
  66. package/dist/schema/schema.d.ts +2 -112
  67. package/dist/schema/schema.js +50 -62
  68. package/dist/schema/select.d.ts +25 -0
  69. package/dist/schema/select.js +55 -0
  70. package/dist/schema/serialize.d.ts +16 -12
  71. package/dist/schema/serialize.js +16 -12
  72. package/dist/schema/sugar.d.ts +20 -3
  73. package/dist/schema/sugar.js +5 -1
  74. package/dist/schema/tenancy.d.ts +66 -0
  75. package/dist/schema/tenancy.js +58 -0
  76. package/dist/sync/BootstrapHelper.js +46 -27
  77. package/dist/sync/ConnectionManager.d.ts +3 -1
  78. package/dist/sync/ConnectionManager.js +37 -1
  79. package/dist/sync/HydrationCoordinator.d.ts +2 -0
  80. package/dist/sync/HydrationCoordinator.js +26 -19
  81. package/dist/sync/NetworkProbe.d.ts +8 -0
  82. package/dist/sync/NetworkProbe.js +24 -2
  83. package/dist/sync/SyncWebSocket.d.ts +1 -1
  84. package/dist/sync/SyncWebSocket.js +43 -53
  85. package/dist/sync/createIntentStream.d.ts +2 -1
  86. package/dist/sync/createIntentStream.js +46 -1
  87. package/dist/sync/participants.js +10 -16
  88. package/dist/transactions/TransactionQueue.js +13 -1
  89. package/dist/types/streams.d.ts +53 -33
  90. package/docs/api-keys.md +47 -3
  91. package/docs/api.md +103 -57
  92. package/docs/audit.md +16 -9
  93. package/docs/cli.md +222 -0
  94. package/docs/client-behavior.md +35 -21
  95. package/docs/coordination.md +74 -36
  96. package/docs/data-sources.md +23 -21
  97. package/docs/examples/agent-human.md +72 -28
  98. package/docs/examples/ai-sdk-tool.md +14 -11
  99. package/docs/examples/existing-python-backend.md +30 -19
  100. package/docs/examples/nextjs.md +21 -8
  101. package/docs/examples/scoped-agent.md +93 -0
  102. package/docs/examples/server-agent.md +27 -5
  103. package/docs/guarantees.md +29 -17
  104. package/docs/identity.md +198 -121
  105. package/docs/index.md +35 -18
  106. package/docs/integration-guide.md +79 -83
  107. package/docs/interaction-model.md +40 -25
  108. package/docs/mcp/claude-code.md +9 -17
  109. package/docs/mcp/cursor.md +6 -24
  110. package/docs/mcp/windsurf.md +6 -19
  111. package/docs/mcp.md +103 -26
  112. package/docs/quickstart.md +31 -39
  113. package/docs/react.md +18 -14
  114. package/docs/roadmap.md +15 -3
  115. package/docs/schema-contract.md +109 -0
  116. package/examples/README.md +8 -4
  117. package/examples/data-source/README.md +6 -2
  118. package/examples/data-source/run.ts +4 -3
  119. package/examples/quickstart.ts +1 -1
  120. package/llms.txt +27 -16
  121. package/package.json +13 -1
@@ -27,6 +27,15 @@ export function resolveApiKey(input) {
27
27
  export function resolveAuthToken(input) {
28
28
  return input.options.authToken ?? null;
29
29
  }
30
+ /**
31
+ * Resolve the customer's own-Postgres connection string for write-back
32
+ * (dedicated/BYO tenant). Falls back to `DATABASE_URL` — the Prisma-style
33
+ * convention — so a server-side app that already exports it needs no extra
34
+ * config. Returns null for Ablo-managed storage (the hosted default).
35
+ */
36
+ export function resolveDatabaseUrl(input) {
37
+ return input.options.databaseUrl ?? input.env.DATABASE_URL ?? null;
38
+ }
30
39
  export const ABLO_DEFAULT_BASE_URL = 'wss://mesh.ablo.finance';
31
40
  export function resolveBaseURL(input) {
32
41
  return input.options.baseURL ?? ABLO_DEFAULT_BASE_URL;
@@ -35,11 +44,13 @@ export function resolveBaseURL(input) {
35
44
  * Browser guard — apiKey is server-side-only by default. Same check
36
45
  * Anthropic, OpenAI, and Stripe ship: shipping `sk_live_...` to a
37
46
  * browser exposes it in every visitor's network tab. Consumers opt
38
- * in explicitly when they have a publishable key or a server proxy.
47
+ * in explicitly when the browser holds a minted session token
48
+ * (`ek_`/`rk_`) or routes through a server proxy.
39
49
  */
40
50
  export function assertBrowserSafety(input) {
51
+ const inBrowser = typeof window !== 'undefined';
41
52
  if (!input.dangerouslyAllowBrowser &&
42
- typeof window !== 'undefined' &&
53
+ inBrowser &&
43
54
  typeof input.apiKey === 'string' &&
44
55
  input.apiKey.startsWith('sk_')) {
45
56
  throw new AbloAuthenticationError("It looks like you're running in a browser-like environment.\n\n" +
@@ -49,6 +60,14 @@ export function assertBrowserSafety(input) {
49
60
  '`dangerouslyAllowBrowser` option to `true`, e.g.,\n\n' +
50
61
  ' Ablo({ schema, apiKey, dangerouslyAllowBrowser: true });\n', { code: 'browser_apikey_blocked' });
51
62
  }
63
+ // `databaseUrl` carries DB credentials and is NEVER browser-safe, so
64
+ // `dangerouslyAllowBrowser` does not override it. Register your database from
65
+ // a server-side runtime.
66
+ if (inBrowser && typeof input.databaseUrl === 'string' && input.databaseUrl.length > 0) {
67
+ throw new AbloAuthenticationError('Ablo `databaseUrl` cannot be used in a browser-like environment — it ' +
68
+ 'carries your database credentials. Initialize the client with ' +
69
+ '`databaseUrl` from a server-side runtime only.', { code: 'browser_database_url_blocked' });
70
+ }
52
71
  }
53
72
  /**
54
73
  * Resolve an `ApiKeySetter` callable to its current string value.
@@ -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';
@@ -35,10 +37,12 @@ export interface ModelListOptions<T> {
35
37
  };
36
38
  limit?: number;
37
39
  offset?: number;
38
- /** Lifecycle scope. Defaults to live rows. */
39
- scope?: ModelListScope;
40
+ /** Lifecycle filter `live` (default), `archived`, or `all`. Named `state`
41
+ * (GitHub's open/closed/all precedent) so it doesn't collide with the
42
+ * sync-group `scope`. */
43
+ state?: ModelListScope;
40
44
  }
41
- export type ModelCountOptions<T> = Pick<ModelListOptions<T>, 'where' | 'filter' | 'scope'>;
45
+ export type ModelCountOptions<T> = Pick<ModelListOptions<T>, 'where' | 'filter' | 'state'>;
42
46
  export interface ModelLoadOptions<T> {
43
47
  /**
44
48
  * Filter for the lookup. Accepts:
@@ -46,7 +50,7 @@ export interface ModelLoadOptions<T> {
46
50
  * - tuple form: `[['name', 'ILIKE', '%Goldman%']]` for operators
47
51
  *
48
52
  * See `LoadWhere<T>` in `query/types.ts`. For OR semantics, run two
49
- * `load()` calls and union — the wire protocol is AND-only.
53
+ * `list()` calls and union — the wire protocol is AND-only.
50
54
  */
51
55
  where?: LoadWhere<T>;
52
56
  orderBy?: {
@@ -66,6 +70,9 @@ export interface ModelLoadOptions<T> {
66
70
  */
67
71
  expand?: readonly string[];
68
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'>;
69
76
  export interface IntentLeaseHandle {
70
77
  readonly id: string;
71
78
  release(): Promise<void>;
@@ -109,6 +116,14 @@ export interface ModelCollaboration<T> {
109
116
  model: string;
110
117
  id: string;
111
118
  }): readonly Intent[];
119
+ /**
120
+ * Re-rank the wait queue on a target (privileged — server-gated). `order` is
121
+ * the desired front-of-line ordering, taken from `queue(target)`.
122
+ */
123
+ reorder(target: {
124
+ model: string;
125
+ id: string;
126
+ }, order: readonly Intent[]): void;
112
127
  /**
113
128
  * Resolve once no participant holds an active intent on the target.
114
129
  * The contender's "wait until it's free" — delegates to the intent
@@ -163,24 +178,99 @@ export interface ClaimOptions {
163
178
  * verb — there is no method chaining on the claim.
164
179
  */
165
180
  export type ClaimedRow<T> = T & AsyncDisposable;
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>>;
194
+ /**
195
+ * Take a claim, run `work` with the held row, release when it settles. The
196
+ * preferred form for ordinary held work.
197
+ */
198
+ <R>(id: string, work: (row: ClaimedRow<T>) => Promise<R> | R, options?: ClaimOptions): Promise<R>;
199
+ /**
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.
203
+ */
204
+ state(id: string): Intent | null;
205
+ /**
206
+ * The wait queue on a row — who's lined up behind the holder and what each
207
+ * intends. Reactive snapshot (synced from the server, like `activity`);
208
+ * returns a Stripe-style list envelope, FIFO order, empty when no one waits.
209
+ *
210
+ * ```ts
211
+ * const { data } = ablo.decks.claim.queue('deck_1');
212
+ * // → [{ heldBy: 'agent:summarizer', action: 'editing', position: 0 }, …]
213
+ * ```
214
+ */
215
+ queue(id: string): {
216
+ readonly object: 'list';
217
+ readonly data: readonly Intent[];
218
+ };
219
+ /**
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
222
+ * privileged operation: the server gates it (the caller needs the
223
+ * `intent.reorder` capability), so it's fire-and-forget — the new order
224
+ * arrives reactively through `claim.queue(id)`.
225
+ *
226
+ * ```ts
227
+ * const { data } = ablo.decks.claim.queue('deck_1');
228
+ * ablo.decks.claim.reorder('deck_1', [data[2], data[0], data[1]]); // promote #2
229
+ * ```
230
+ */
231
+ reorder(id: string, order: readonly Intent[]): void;
232
+ /** Release a claim you hold early. Usually implicit (scope exit). */
233
+ release(id: string): Promise<void>;
234
+ }
166
235
  export interface ModelOperations<T, CreateInput> {
167
236
  /**
168
- * Retrieve a single entity by id from the local pool. Synchronous.
169
- * Returns `undefined` when the entity isn't loaded yet use
170
- * `load({where: {id}})` if you want to lazy-hydrate from storage/network.
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.
171
241
  *
172
- * Mirrors `stripe.customers.retrieve(id)`.
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.
173
248
  */
174
- retrieve(id: string): T | undefined;
249
+ retrieve(id: string, options?: ModelRetrieveOptions): Promise<T | undefined>;
175
250
  /**
176
- * List entities matching a filter from the local pool. Synchronous.
177
- * No network round-trip use `load()` for hydration.
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.
178
254
  *
179
- * Mirrors `stripe.customers.list({...})`.
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))`.
180
264
  */
181
- list(options?: ModelListOptions<T>): T[];
182
- /** Count entities matching a filter (synchronous, from local pool). */
183
- count(options?: ModelCountOptions<T>): number;
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.
270
+ */
271
+ getAll(options?: ModelListOptions<T>): T[];
272
+ /** Count entities in the **local graph** (synchronous, no network). */
273
+ getCount(options?: ModelCountOptions<T>): number;
184
274
  /**
185
275
  * Create a new entity — **optimistic, offline-first**. Resolves once
186
276
  * the mutation is queued locally, not when the server confirms.
@@ -192,50 +282,27 @@ export interface ModelOperations<T, CreateInput> {
192
282
  /** Delete an entity by id — optimistic, offline-first (see `create`). */
193
283
  delete(id: string, options?: MutationOptions): Promise<void>;
194
284
  /**
195
- * Claim a row so other writers wait or are rejected until you're done.
196
- * Reads stay open by default. Prefer the callback form for ordinary held
197
- * work; it releases when the callback returns or throws. The `await using`
198
- * form is also available for wider lexical scopes.
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
199
294
  *
200
295
  * ```ts
201
296
  * await ablo.weatherReports.claim('report_stockholm', async (report) => {
202
297
  * const weather = await getWeather(report.location);
203
298
  * await ablo.weatherReports.update(report.id, { forecast: weather });
204
299
  * });
205
- * ```
206
- */
207
- claim(id: string, options?: ClaimOptions): Promise<ClaimedRow<T>>;
208
- claim<R>(id: string, work: (row: ClaimedRow<T>) => Promise<R> | R, options?: ClaimOptions): Promise<R>;
209
- /**
210
- * Read who's coordinating on a row — the current holder (who, phase,
211
- * until when), or `null` when free. Synchronous and reactive; for
212
- * observers/UI. Never blocks.
213
- */
214
- claimState(id: string): Intent | null;
215
- /**
216
- * The wait queue on a row — who's lined up behind the holder and what each
217
- * intends. Reactive snapshot (synced from the server, like `activity`);
218
- * returns a Stripe-style list envelope, FIFO order, empty when no one waits.
219
300
  *
220
- * ```ts
221
- * const { data } = ablo.decks.queue('deck_1');
222
- * // → [{ heldBy: 'agent:summarizer', action: 'editing', position: 0 }, …]
301
+ * const holder = ablo.weatherReports.claim.state('report_stockholm');
223
302
  * ```
224
303
  */
225
- queue(id: string): {
226
- readonly object: 'list';
227
- readonly data: readonly Intent[];
228
- };
229
- /** Release a claim you hold early. Usually implicit (scope exit). */
230
- release(id: string): Promise<void>;
304
+ claim: ClaimApi<T>;
231
305
  /** Listen for changes (callback called on every change). */
232
306
  onChange(callback: (entities: T[]) => void, options?: ModelListOptions<T>): () => void;
233
- /**
234
- * Load matching rows into the local graph if they are not already
235
- * present. Single-flight: concurrent calls with the same args share
236
- * one in-flight request. Default `type: 'complete'` waits for the
237
- * server; `type: 'unknown'` returns local + refreshes async.
238
- */
239
- load(options?: ModelLoadOptions<T>): Promise<T[]>;
240
307
  }
241
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,12 +147,40 @@ 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) {
137
- const all = objectPool.getByType(ModelClass, (options?.scope ?? ModelScope.live));
182
+ getAll(options) {
183
+ const all = objectPool.getByType(ModelClass, (options?.state ?? ModelScope.live));
138
184
  let result = all;
139
185
  if (options?.where) {
140
186
  const where = 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,34 +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
- release(id) {
224
- return releaseClaim(id);
225
- },
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,
226
262
  onChange(callback, options) {
227
263
  return autorun(() => {
228
- const entities = this.list(options);
264
+ const entities = this.getAll(options);
229
265
  callback(entities);
230
266
  });
231
267
  },
232
- load,
233
268
  };
234
269
  modelClientMeta.set(operations, {
235
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.ablo.dev', 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://ablo.dev/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
  }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * `@abloatai/ablo/coordination` — the canonical wire schema for the three
3
+ * coordination layers (presence, pessimistic claims, optimistic stale-context).
4
+ * See `./schema.ts` for the model and the per-layer schemas.
5
+ */
6
+ export * from './schema.js';
@@ -0,0 +1,6 @@
1
+ /**
2
+ * `@abloatai/ablo/coordination` — the canonical wire schema for the three
3
+ * coordination layers (presence, pessimistic claims, optimistic stale-context).
4
+ * See `./schema.ts` for the model and the per-layer schemas.
5
+ */
6
+ export * from './schema.js';