@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
package/CHANGELOG.md CHANGED
@@ -1,5 +1,37 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.0
4
+
5
+ A callable `claim` coordination namespace and bring-your-own-database support
6
+ via a new `databaseUrl` option.
7
+
8
+ ### Minor Changes
9
+
10
+ - **Callable `claim` coordination namespace.** Taking a claim and inspecting its
11
+ state now live under one accessor: `claim(id, work)` acquires a claim and runs
12
+ `work` while it's held, and `claim.state(id)`, `claim.queue(id)`,
13
+ `claim.release(id)`, and `claim.reorder(id, order)` cover the surrounding
14
+ lifecycle. The README leads with the problem (who is allowed to act, and in
15
+ what order) and the Quick Start now demonstrates `claim` directly.
16
+
17
+ - **Bring-your-own-database via `databaseUrl`.** Point a project at your own
18
+ Postgres with `Ablo({ schema, apiKey, databaseUrl })`. Ablo writes synced rows
19
+ back into your database, so your data stays canonical. Server-side only;
20
+ defaults to `process.env.DATABASE_URL`. See the data-sources guide for setup
21
+ and role requirements.
22
+
23
+ ### Breaking
24
+
25
+ - The flat coordination methods `claimState`, `queue`, `release`, and `reorder`
26
+ are removed in favor of the `claim` namespace above.
27
+
28
+ ```diff
29
+ - await ablo.task.claimState(id)
30
+ - await ablo.task.release(id)
31
+ + await ablo.task.claim.state(id)
32
+ + await ablo.task.claim.release(id)
33
+ ```
34
+
3
35
  ## 0.7.0
4
36
 
5
37
  ### Minor Changes
package/README.md CHANGED
@@ -5,35 +5,40 @@
5
5
  [![types](https://img.shields.io/badge/types-included-blue.svg)](#)
6
6
  [![runtime](https://img.shields.io/badge/node-%E2%89%A522-brightgreen.svg)](#keys--runtime)
7
7
 
8
- Ablo is a typed sync engine for shared app state the kind that humans,
9
- server code, and AI agents all edit at once.
8
+ **Let people and AI agents work on the same data without overwriting each other.**
10
9
 
11
- Reach for it when those edits need to show up everywhere in real time, not
12
- silently overwrite each other, expose who's working on what, and leave a record
13
- of who changed what.
10
+ When an agent and a person change the same thing at once, work gets lost: one
11
+ edit silently clobbers another, or the agent acts on data that already moved.
12
+ Ablo gives them one shared, typed write path so people, server actions, and
13
+ agents can all work on the same rows without working blind.
14
+
15
+ The core idea is a **claim**. An agent's work is rarely one instant write; it
16
+ reads something, thinks, calls an LLM or tool, then writes back. While that is
17
+ happening, the row can change underneath it. So before slow work starts, the
18
+ agent claims the row. If someone else is already working on it, `claim` waits,
19
+ re-reads the fresh row, then hands it over. No stale overwrite, no separate
20
+ agent mutation path.
21
+
22
+ Under the hood, you define a Zod schema once and get typed model clients for
23
+ every actor:
14
24
 
15
25
  ```txt
16
26
  schema -> ablo.<model>.create/retrieve/update/claim(...)
17
27
  ```
18
28
 
19
- ## Why Ablo
20
-
21
- - **Real-time by default.** Every `create` / `update` / `delete` fans out
22
- confirmed deltas to all subscribers humans and agents — with no separate
23
- "multiplayer mode" to switch on.
24
- - **No silent clobbers.** Writes are guarded against stale reads, and `claim`
25
- holds a row across a slow read → LLM → write gap so concurrent edits queue
26
- instead of overwriting.
27
- - **Built for agents.** See who's mid-edit (`claimState` / `queue`), coordinate a
28
- fair line, and ship an `llms.txt` so coding agents integrate from the real API.
29
- - **Typed end to end.** Your Zod schema produces typed model proxies
30
- (`ablo.<model>.update(...)`), optimistic local reads, and reactive React hooks.
31
- - **Bring your own auth and database.** Ablo scopes realtime data to *sync
32
- groups* from your existing identity, and can leave your database as the source
33
- of truth via a Data Source.
34
-
35
- **Built for:** collaborative editors, AI agent workflows, internal tools, and any
36
- app where multiple actors mutate shared state and everyone must see it live.
29
+ The schema is the public contract. It gives you typed model methods, realtime
30
+ fanout, React selectors, agent writes, and the HTTP/Data Source shape for
31
+ non-JavaScript services. Every confirmed change shows up everywhere, and active
32
+ claims are visible while the work is still in progress.
33
+
34
+ [Get started ↓](#quick-start) · point your coding agent at the shipped `llms.txt`
35
+
36
+ It works with the auth and database you already have: realtime data is scoped to
37
+ *sync groups* from your own identity, and your database can stay the source of
38
+ truth via a Data Source.
39
+
40
+ **Built for** collaborative editors, AI agent workflows, and internal tools
41
+ anywhere people and agents change shared state and everyone has to see it live.
37
42
 
38
43
  ## Set up
39
44
 
@@ -83,12 +88,16 @@ const created = await ablo.weatherReports.create({
83
88
  status: 'pending',
84
89
  });
85
90
 
86
- const updated = await ablo.weatherReports.update(created.id, {
87
- status: 'ready',
88
- forecast: 'Light rain, 13C',
91
+ // An agent claims the row, does its slow work, then writes back. While the
92
+ // claim is held nobody else can overwrite it; anyone else who tries waits in
93
+ // line and re-reads the result. This is the whole point of Ablo.
94
+ await ablo.weatherReports.claim(created.id, async (report) => {
95
+ const forecast = await fetchForecast(report.location); // slow: API or LLM call
96
+ await ablo.weatherReports.update(report.id, { status: 'ready', forecast });
89
97
  });
90
98
 
91
- console.log({ id: updated.id, status: updated.status });
99
+ const ready = ablo.weatherReports.get(created.id);
100
+ console.log({ id: ready.id, status: ready.status });
92
101
 
93
102
  await ablo.dispose();
94
103
  ```
@@ -99,31 +108,30 @@ Expected output:
99
108
  { id: '...', status: 'ready' }
100
109
  ```
101
110
 
102
- Pass `schema` to get typed models like `ablo.weatherReports.update(...)`.
103
-
104
111
  ## Reading
105
112
 
106
- `retrieve(id)` returns one row from the local cache synchronous, no round-trip.
107
- `list(...)` filters and sorts what's already synced; it's also synchronous, and
108
- reactive under `useAblo`/`subscribe`. `load(...)` fetches from the server when a
109
- row may not be local yet.
113
+ Two ways to read, depending on whether you can wait. `get(id)` / `getAll({ where })`
114
+ / `getCount({ where })` are instant they read what's already local and re-render
115
+ on their own when it changes, so they're what your UI uses. `retrieve(id)` /
116
+ `list({ where })` go ask the server and return a `Promise`, for when you need the
117
+ authoritative answer right now.
110
118
 
111
119
  ```ts
112
- ablo.weatherReports.retrieve('report_stockholm');
120
+ ablo.weatherReports.get('report_stockholm');
113
121
 
114
- const pending = ablo.weatherReports.list({
122
+ const pending = ablo.weatherReports.getAll({
115
123
  where: { status: 'pending' },
116
124
  orderBy: { location: 'asc' },
117
125
  limit: 20,
118
126
  });
119
127
 
120
- const ready = await ablo.weatherReports.load({
128
+ const ready = await ablo.weatherReports.list({
121
129
  where: { status: 'ready' },
122
130
  type: 'complete',
123
131
  });
124
132
  ```
125
133
 
126
- An array value in `where` means `IN`. On `load`, `type: 'complete'` waits for
134
+ An array value in `where` means `IN`. On `list`, `type: 'complete'` waits for
127
135
  the server; `'unknown'` returns what's local now and refreshes in the background.
128
136
 
129
137
  ## Writing
@@ -163,8 +171,8 @@ returns or throws.
163
171
  See who's mid-edit before you act — decide to wait, or skip:
164
172
 
165
173
  ```ts
166
- ablo.weatherReports.claimState('report_stockholm');
167
- ablo.weatherReports.queue('report_stockholm');
174
+ ablo.weatherReports.claim.state('report_stockholm');
175
+ ablo.weatherReports.claim.queue('report_stockholm');
168
176
 
169
177
  await ablo.weatherReports.claim(id, async (report) => {
170
178
  /* do the held work */
@@ -175,7 +183,7 @@ await ablo.weatherReports.claim(id, async (report) => {
175
183
  }, { maxQueueDepth: 2 });
176
184
  ```
177
185
 
178
- `claimState` returns the holder (or `null`); `queue` returns the line waiting
186
+ `claim.state` returns the holder (or `null`); `claim.queue` returns the line waiting
179
187
  behind it. `wait: false` skips rather than waiting when the row is held;
180
188
  `maxQueueDepth: 2` bails when two or more are already ahead.
181
189
 
@@ -195,8 +203,8 @@ try {
195
203
  > Prefer the callback form for ordinary held work. Manual scoped claims are
196
204
  > available for wider lifetimes, but callback claims are the docs default.
197
205
 
198
- See [Coordination](./docs/coordination.md) for the full `claim` / `claimState` /
199
- `queue` / `release` reference.
206
+ See [Coordination](./docs/coordination.md) for the full `claim` / `claim.state` /
207
+ `claim.queue` / `claim.release` reference.
200
208
 
201
209
  ## React
202
210
 
@@ -217,7 +225,7 @@ function App() {
217
225
  }
218
226
 
219
227
  function Report({ id }: { id: string }) {
220
- const report = useAblo((ablo) => ablo.weatherReports.retrieve(id));
228
+ const report = useAblo((ablo) => ablo.weatherReports.get(id));
221
229
  const ablo = useAblo();
222
230
 
223
231
  if (!report) return null;
@@ -277,7 +285,7 @@ each other's changes in real time — that's the default, not a feature you turn
277
285
 
278
286
  - `ablo.<model>.create/update/delete` fan out confirmed deltas to subscribers.
279
287
  - `useAblo(...)` gives React clients the live row, kept current automatically.
280
- - `ablo.<model>.claim(id)` / `claimState(id)` / `queue(id)` let humans and agents coordinate (and observe) active work on a row — and the line waiting behind it — before a write lands.
288
+ - `ablo.<model>.claim(id)` / `claim.state(id)` / `queue(id)` let humans and agents coordinate (and observe) active work on a row — and the line waiting behind it — before a write lands.
281
289
 
282
290
  Always write through Ablo — either the SDK model methods
283
291
  (`ablo.<model>.create/update/delete`) or the HTTP write endpoint below. If you
@@ -372,10 +380,11 @@ contract; there are no retry or timeout knobs to tune.
372
380
  ## Production Reference
373
381
 
374
382
  - [Identity & Sync Groups](./docs/identity.md) — bring your own auth; tell Ablo who's connecting and how org / team / user map to sync-group scope.
383
+ - [Schema Contract](./docs/schema-contract.md) — one schema becomes typed model clients, React reads, agent writes, Data Source shape, and schema push.
375
384
  - [Guarantees](./docs/guarantees.md) — confirmed writes, stale-write protection, claim coordination, and agent lifecycle.
376
385
  - [Integration Guide](./docs/integration-guide.md) — pick the backing mode and integrate React, Data Source, multiplayer, and agents.
377
386
  - [React](./docs/react.md) — `<AbloProvider>`, `useAblo`, presence, status, and bootstrap gating.
378
- - [Coordination](./docs/coordination.md) — `claim` / `claimState` / `queue` / `release` reference: hold a row across slow agent work, and observe the line waiting behind it.
387
+ - [Coordination](./docs/coordination.md) — `claim` / `claim.state` / `claim.queue` / `claim.release` reference: hold a row across slow agent work, and observe the line waiting behind it.
379
388
  - [Client Behavior](./docs/client-behavior.md) — options, errors, retries, timeouts, and public imports.
380
389
  - [Connect Your Database](./docs/data-sources.md) — keep canonical rows in your app database without giving Ablo database credentials.
381
390
  - [Existing Python Backend](./docs/examples/existing-python-backend.md) — migrate existing Python endpoints to multiplayer and agent-safe writes gradually.
@@ -12,7 +12,7 @@
12
12
  * pull generic methods into this base class.
13
13
  */
14
14
  import { makeObservable, observable, computed, runInAction } from 'mobx';
15
- import { AbloConnectionError, AbloValidationError } from './errors.js';
15
+ import { AbloConnectionError, AbloValidationError, toAbloError } from './errors.js';
16
16
  import { ConnectionManager } from './sync/ConnectionManager.js';
17
17
  import { PropertyType } from './types/index.js';
18
18
  import { SyncWebSocket, } from './sync/SyncWebSocket.js';
@@ -447,14 +447,18 @@ export class BaseSyncedStore {
447
447
  }
448
448
  }
449
449
  }
450
- throw lastError || new Error('Bootstrap failed after all retry attempts');
450
+ throw lastError
451
+ ? toAbloError(lastError)
452
+ : new AbloConnectionError('Bootstrap failed after all retry attempts', {
453
+ code: 'bootstrap_fetch_timeout',
454
+ });
451
455
  }
452
456
  /** Create a timeout promise for bootstrap attempts */
453
457
  createBootstrapTimeout(attempt) {
454
458
  const timeoutMs = BOOTSTRAP_CONFIG.OVERALL_TIMEOUT_MS + (attempt - 1) * 3_000;
455
459
  return new Promise((_, reject) => {
456
460
  setTimeout(() => {
457
- reject(new Error(`Bootstrap timed out after ${timeoutMs}ms (attempt ${attempt})`));
461
+ reject(new AbloConnectionError(`Bootstrap timed out after ${timeoutMs}ms (attempt ${attempt})`, { code: 'bootstrap_fetch_timeout' }));
458
462
  }, timeoutMs);
459
463
  });
460
464
  }
@@ -33,7 +33,8 @@ export declare const noopObservability: SyncObservabilityProvider;
33
33
  export declare const noopAnalytics: SyncAnalytics;
34
34
  /** Browser-native online status provider */
35
35
  export declare const browserOnlineStatus: OnlineStatusProvider;
36
- /** Permissive session error detector — treats 401/403 as session errors */
36
+ /** Session error detector — delegates to SyncSessionError so detection is
37
+ * code-aware (only genuine session/JWT expiry counts), not a blunt 401/403. */
37
38
  export declare const defaultSessionErrorDetector: SessionErrorDetector;
38
39
  /**
39
40
  * Fallback config used when the context is read before
@@ -4,6 +4,7 @@
4
4
  * All SDK classes receive this context at construction time.
5
5
  * It bundles every injectable dependency so constructors stay clean.
6
6
  */
7
+ import { SyncSessionError } from './errors.js';
7
8
  // ─────────────────────────────────────────────
8
9
  // No-op defaults for optional dependencies
9
10
  // ─────────────────────────────────────────────
@@ -45,7 +46,8 @@ export const browserOnlineStatus = {
45
46
  return typeof navigator !== 'undefined' ? navigator.onLine : true;
46
47
  },
47
48
  };
48
- /** Permissive session error detector — treats 401/403 as session errors */
49
+ /** Session error detector — delegates to SyncSessionError so detection is
50
+ * code-aware (only genuine session/JWT expiry counts), not a blunt 401/403. */
49
51
  export const defaultSessionErrorDetector = {
50
52
  isSessionError(error) {
51
53
  if (error && typeof error === 'object' && 'isSessionError' in error) {
@@ -53,8 +55,8 @@ export const defaultSessionErrorDetector = {
53
55
  }
54
56
  return false;
55
57
  },
56
- isSessionErrorResponse(status) {
57
- return status === 401 || status === 403;
58
+ isSessionErrorResponse(status, body) {
59
+ return SyncSessionError.isSessionErrorResponse(status, body);
58
60
  },
59
61
  };
60
62
  /**
@@ -20,6 +20,7 @@
20
20
  * The helper itself imports nothing app-specific. Open-source-clean.
21
21
  */
22
22
  import { Ablo } from '../client/Ablo.js';
23
+ import { AbloConnectionError } from '../errors.js';
23
24
  /**
24
25
  * Returns a session whose `getAgent` method handles cache, mint,
25
26
  * sync_groups alignment, and lifecycle. Call `disposeAll()` from
@@ -113,8 +114,8 @@ export function createAgentSession(options) {
113
114
  causeMsg,
114
115
  err,
115
116
  });
116
- throw new Error(`ws bootstrap ${wsUrl} failed: ${e.message ?? 'bootstrap failed'}` +
117
- (code ? ` (${code})` : ''));
117
+ throw new AbloConnectionError(`ws bootstrap ${wsUrl} failed: ${e.message ?? 'bootstrap failed'}` +
118
+ (code ? ` (${code})` : ''), { code: 'bootstrap_fetch_timeout', cause: err });
118
119
  }
119
120
  cacheByKey.set(key, { agent, expiresAtMs: minted.expiresAtMs });
120
121
  return agent;
@@ -11,7 +11,25 @@
11
11
  * SDKs hide their internal auth-handshake — the apiKey is the only
12
12
  * credential the consumer touches.
13
13
  */
14
- import { AbloAuthenticationError } from '../errors.js';
14
+ import { AbloAuthenticationError, translateHttpError } from '../errors.js';
15
+ /**
16
+ * Whether an HTTP error body carries a code `translateHttpError` can read —
17
+ * a top-level `code`, a nested `error.code`, or a string `error`. When it
18
+ * doesn't (empty body, non-JSON, or a non-Ablo proxy 401), the caller falls
19
+ * back to its own default code rather than emitting a code-less error.
20
+ */
21
+ function hasWireCode(body) {
22
+ if (typeof body !== 'object' || body === null)
23
+ return false;
24
+ const b = body;
25
+ if (typeof b.code === 'string')
26
+ return true;
27
+ if (typeof b.error === 'string')
28
+ return true;
29
+ return (typeof b.error === 'object' &&
30
+ b.error !== null &&
31
+ typeof b.error.code === 'string');
32
+ }
15
33
  export async function exchangeApiKey(options) {
16
34
  if (!options.apiKey) {
17
35
  throw new AbloAuthenticationError('apiKey is required for capability exchange', { code: 'apikey_missing' });
@@ -59,11 +77,16 @@ export async function exchangeApiKey(options) {
59
77
  catch {
60
78
  // ignore — server returned non-JSON error
61
79
  }
62
- const errBody = body;
63
- throw new AbloAuthenticationError(`apiKey exchange rejected (${response.status}): ${errBody?.reason ?? response.statusText}`, {
64
- code: errBody?.error ?? 'exchange_failed',
65
- httpStatus: response.status,
66
- });
80
+ // Route through the canonical wire-error translator so the server's
81
+ // envelope (`code` + `message` + `doc_url`) propagates verbatim and maps to
82
+ // the right AbloError subclass — instead of the legacy `error`/`reason`
83
+ // shape this used to read (which the server no longer emits, collapsing
84
+ // every failure to a generic code with an empty message). Fall back to
85
+ // `exchange_failed` only when the body carried no recognizable code.
86
+ const requestId = response.headers.get('x-request-id') ?? undefined;
87
+ throw hasWireCode(body)
88
+ ? translateHttpError(response.status, body, requestId)
89
+ : new AbloAuthenticationError(`apiKey exchange rejected (${response.status})`, { code: 'exchange_failed', httpStatus: response.status });
67
90
  }
68
91
  const raw = (await response.json());
69
92
  if (!isCapabilityExchangeResponse(raw)) {
@@ -130,11 +153,16 @@ export async function resolveIdentity(options) {
130
153
  catch {
131
154
  // ignore non-JSON auth errors
132
155
  }
133
- const errBody = body;
134
- throw new AbloAuthenticationError(`identity resolve rejected (${response.status}): ${errBody?.reason ?? response.statusText}`, {
135
- code: errBody?.error ?? 'identity_resolve_failed',
136
- httpStatus: response.status,
137
- });
156
+ // Canonical envelope translation (see `exchangeApiKey` above). This is what
157
+ // surfaces the sync-server's precise auth diagnosis e.g.
158
+ // `jwt_issuer_untrusted` with its full message — to the SDK consumer,
159
+ // instead of collapsing every 401 to `identity_resolve_failed` with an
160
+ // empty reason because the old parser looked for `error`/`reason` keys the
161
+ // server doesn't emit.
162
+ const requestId = response.headers.get('x-request-id') ?? undefined;
163
+ throw hasWireCode(body)
164
+ ? translateHttpError(response.status, body, requestId)
165
+ : new AbloAuthenticationError(`identity resolve rejected (${response.status})`, { code: 'identity_resolve_failed', httpStatus: response.status });
138
166
  }
139
167
  return (await response.json());
140
168
  }
@@ -88,6 +88,21 @@ export interface AbloOptions<S extends SchemaRecord = SchemaRecord> {
88
88
  * usually don't pass this explicitly server-side.
89
89
  */
90
90
  apiKey?: string | ApiKeySetter | null | undefined;
91
+ /**
92
+ * Connection string to YOUR OWN Postgres. When set, Ablo registers this
93
+ * database as your project's data store and writes synced rows back into it
94
+ * (dedicated/BYO tenant), so your data stays canonical in your DB while Ablo
95
+ * runs the sync/coordination plane. Defaults to `process.env['DATABASE_URL']`.
96
+ *
97
+ * SERVER-ONLY: this carries credentials, so it is never sent from the browser
98
+ * — constructing a client with `databaseUrl` and `dangerouslyAllowBrowser`
99
+ * throws. Provide Ablo a NON-superuser, non-`BYPASSRLS` role: the server runs
100
+ * the tenant plane with row-level security forced, and rejects a privileged
101
+ * role that couldn't enforce isolation.
102
+ *
103
+ * Omit it to use Ablo-managed storage (the hosted default).
104
+ */
105
+ databaseUrl?: string | null | undefined;
91
106
  /**
92
107
  * Local persistence mode. Pass `indexeddb` only when you want offline
93
108
  * queueing and a reload-surviving browser cache.
@@ -112,8 +127,8 @@ export interface AbloOptions<S extends SchemaRecord = SchemaRecord> {
112
127
  defaultQuery?: Record<string, string | undefined> | undefined;
113
128
  /**
114
129
  * Client-side use is disabled by default because private API keys should
115
- * not ship to browsers. Set this only when using a publishable/browser-safe
116
- * key or a controlled server proxy.
130
+ * not ship to browsers. Set this only when the browser holds a minted
131
+ * session token (`ek_`/`rk_`) or you route through a controlled server proxy.
117
132
  */
118
133
  dangerouslyAllowBrowser?: boolean | undefined;
119
134
  }
@@ -168,7 +183,7 @@ export interface InternalAbloOptions<S extends SchemaRecord = SchemaRecord> {
168
183
  * Client-side use of this SDK is disabled by default — your apiKey
169
184
  * would ship to every visitor's network tab. Only set this to
170
185
  * `true` if you've understood the risk and have appropriate
171
- * mitigations (a publishable key, a server-side proxy, etc).
186
+ * mitigations (a minted session token, a server-side proxy, etc).
172
187
  */
173
188
  dangerouslyAllowBrowser?: boolean | undefined;
174
189
  /**
@@ -459,6 +474,72 @@ export interface ModelClient<T = Record<string, unknown>> {
459
474
  update(id: string, data: Record<string, unknown>, options?: ModelMutationOptions): Promise<CommitReceipt>;
460
475
  delete(id: string, options?: ModelMutationOptions): Promise<CommitReceipt>;
461
476
  }
477
+ /** A single data operation a scoped **agent** session may perform on a model. */
478
+ export type SessionOperation = 'read' | 'create' | 'update' | 'delete';
479
+ /** Mint params for an **end-user** session — full data authority within the
480
+ * org (the Stripe `ephemeralKeys.create` / Supabase session shape). Mints an
481
+ * `ek_` token. `user.id` is your end user's external IdP id (becomes the
482
+ * session's `participantId`); Ablo does not model your users, so it's an
483
+ * honest string at the trust boundary. */
484
+ export interface CreateUserSessionParams {
485
+ /** Your end user. `id` becomes the token's `participantId`. */
486
+ user: {
487
+ id: string;
488
+ };
489
+ /** Sync groups this session may subscribe to. Omit to inherit the key's scope. */
490
+ syncGroups?: readonly string[];
491
+ /** Token lifetime in seconds. Defaults to 900 (15m, the Stripe ephemeral default). */
492
+ ttlSeconds?: number;
493
+ /** Opaque identity blob echoed back to the client as `ablo.user`. */
494
+ userMeta?: Record<string, unknown>;
495
+ agent?: never;
496
+ can?: never;
497
+ }
498
+ /** Mint params for a scoped **agent** session — mints a restricted `rk_` token
499
+ * gated to exactly the operations named in `can`. `can` is typed off your
500
+ * schema (no magic `'task.update'` strings): `{ Task: ['update'], Deck: ['read'] }`
501
+ * — the SDK serializes each entry to the wire allowlist (`task.update`). */
502
+ export interface CreateAgentSessionParams<S extends SchemaRecord> {
503
+ /** Your agent. `id` becomes the token's `participantId`. */
504
+ agent: {
505
+ id: string;
506
+ };
507
+ /** Per-model operation allowlist, typed against the schema's model names. */
508
+ can: {
509
+ [M in keyof S & string]?: readonly SessionOperation[];
510
+ };
511
+ /** Sync groups this session may subscribe to. Omit to inherit the key's scope. */
512
+ syncGroups?: readonly string[];
513
+ /** Token lifetime in seconds. Defaults to 900 (15m, the Stripe ephemeral default). */
514
+ ttlSeconds?: number;
515
+ /** Opaque identity blob echoed back to the client as `ablo.agent`. */
516
+ userMeta?: Record<string, unknown>;
517
+ user?: never;
518
+ }
519
+ /** Params for {@link Ablo.sessions}.create — a discriminated union: pass
520
+ * `{ user }` for a full-authority end-user session (`ek_`) or `{ agent, can }`
521
+ * for a scoped agent session (`rk_`). */
522
+ export type CreateSessionParams<S extends SchemaRecord> = CreateUserSessionParams | CreateAgentSessionParams<S>;
523
+ /** A minted end-user session token — the Stripe ephemeral-key / Supabase
524
+ * session resource. `token` is the secret the browser presents as its bearer. */
525
+ export interface AbloSession {
526
+ object: 'session';
527
+ /** Stable id of the minted credential (for revocation). */
528
+ id: string;
529
+ /** The short-lived `rk_` session token. Hand this to the user's browser. */
530
+ token: string;
531
+ /** ISO-8601 expiry. */
532
+ expiresAt: string;
533
+ organizationId: string;
534
+ scope: {
535
+ organizationId: string;
536
+ syncGroups: readonly string[];
537
+ operations: readonly string[];
538
+ participantKind: 'user' | 'agent' | 'system';
539
+ participantId: string;
540
+ };
541
+ userMeta: Record<string, unknown>;
542
+ }
462
543
  /** The typed sync engine client — one property per model in the schema */
463
544
  export type Ablo<S extends SchemaRecord> = {
464
545
  readonly [K in keyof S & string]: ModelOperations<InferModel<Schema<S>, K>, InferCreate<Schema<S>, K>>;
@@ -502,6 +583,31 @@ export type Ablo<S extends SchemaRecord> = {
502
583
  waitForFlush(timeoutMs?: number): Promise<void>;
503
584
  /** Disconnect and clean up */
504
585
  dispose(): Promise<void>;
586
+ /**
587
+ * Replace the bearer auth token used for the WebSocket upgrade and HTTP
588
+ * requests, WITHOUT tearing down the engine. Use to push a refreshed
589
+ * short-lived token (e.g. a 15m JWT) before it expires — `<AbloProvider>`'s
590
+ * `getToken` refresh loop calls this. Reuses the same rotation path as the
591
+ * internal capability-token refresh; safe to call before `ready()`.
592
+ */
593
+ setAuthToken(token: string): void;
594
+ /**
595
+ * Mint a short-lived, scoped **session token** for one end user — the
596
+ * Stripe `ephemeralKeys.create` / Supabase session shape. Call this on YOUR
597
+ * BACKEND (where the `sk_` secret key lives), then hand the returned
598
+ * `token` to that user's browser (typically via an authEndpoint the client
599
+ * fetches). The browser presents it as the bearer; the sync-server verifies
600
+ * the scoped `rk_` token via `apiKeyProvider`.
601
+ *
602
+ * The browser must NEVER see the `sk_` key — only the per-user session token.
603
+ *
604
+ * Pass `{ user: { id } }` for a full-authority end-user session (mints `ek_`),
605
+ * or `{ agent: { id }, can: { Task: ['update'] } }` for a scoped agent
606
+ * session (mints `rk_`); `can` is typed against your schema's model names.
607
+ */
608
+ sessions: {
609
+ create(params: CreateSessionParams<S>): Promise<AbloSession>;
610
+ };
505
611
  /**
506
612
  * Destroy every IndexedDB database owned by this engine. Disconnects
507
613
  * the WebSocket, releases timers, and deletes all `ablo_*` / `ablo-*`
@@ -766,6 +872,8 @@ export declare namespace Ablo {
766
872
  type CapabilityRecord = import('./ApiClient.js').CapabilityRecord;
767
873
  type CapabilityResource = import('./ApiClient.js').CapabilityResource;
768
874
  type CapabilityRevocation = import('./ApiClient.js').CapabilityRevocation;
875
+ type CapabilityRotateOptions = import('./ApiClient.js').CapabilityRotateOptions;
876
+ type RotatedCapability = import('./ApiClient.js').RotatedCapability;
769
877
  type Task = import('./ApiClient.js').Task;
770
878
  type TaskCreateOptions = import('./ApiClient.js').TaskCreateOptions;
771
879
  type TaskCloseOptions = import('./ApiClient.js').TaskCloseOptions;