@abloatai/ablo 0.9.1 → 0.9.3

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 (79) hide show
  1. package/AGENTS.md +84 -0
  2. package/CHANGELOG.md +40 -0
  3. package/README.md +53 -27
  4. package/dist/BaseSyncedStore.d.ts +2 -36
  5. package/dist/BaseSyncedStore.js +11 -55
  6. package/dist/NetworkMonitor.js +4 -1
  7. package/dist/SyncClient.d.ts +22 -5
  8. package/dist/SyncClient.js +77 -0
  9. package/dist/SyncEngineContext.js +5 -1
  10. package/dist/agent/index.js +1 -1
  11. package/dist/api/index.d.ts +1 -1
  12. package/dist/auth/index.js +3 -1
  13. package/dist/cli.cjs +302645 -0
  14. package/dist/client/Ablo.d.ts +19 -52
  15. package/dist/client/Ablo.js +30 -106
  16. package/dist/client/ApiClient.d.ts +1 -113
  17. package/dist/client/ApiClient.js +39 -238
  18. package/dist/client/auth.js +32 -2
  19. package/dist/client/createInternalComponents.js +1 -1
  20. package/dist/client/createModelProxy.d.ts +9 -0
  21. package/dist/client/createModelProxy.js +34 -10
  22. package/dist/client/httpClient.d.ts +5 -6
  23. package/dist/client/httpClient.js +2 -3
  24. package/dist/client/index.d.ts +1 -1
  25. package/dist/client/persistence.d.ts +6 -1
  26. package/dist/client/persistence.js +1 -1
  27. package/dist/client/registerDataSource.d.ts +4 -4
  28. package/dist/client/registerDataSource.js +39 -31
  29. package/dist/client/writeOptionsSchema.d.ts +50 -0
  30. package/dist/client/writeOptionsSchema.js +57 -0
  31. package/dist/core/index.d.ts +18 -26
  32. package/dist/core/index.js +22 -46
  33. package/dist/errorCodes.d.ts +13 -0
  34. package/dist/errorCodes.js +19 -4
  35. package/dist/index.d.ts +3 -0
  36. package/dist/index.js +8 -1
  37. package/dist/interfaces/index.d.ts +14 -4
  38. package/dist/mutators/UndoManager.d.ts +48 -5
  39. package/dist/mutators/UndoManager.js +166 -1
  40. package/dist/react/AbloProvider.d.ts +18 -8
  41. package/dist/react/index.d.ts +1 -1
  42. package/dist/react/index.js +1 -1
  43. package/dist/react/useUndoScope.js +7 -0
  44. package/dist/schema/ddl.js +2 -1
  45. package/dist/schema/field.js +2 -1
  46. package/dist/schema/serialize.js +2 -1
  47. package/dist/server/commit.d.ts +4 -5
  48. package/dist/server/storage-mode.d.ts +7 -0
  49. package/dist/server/storage-mode.js +6 -0
  50. package/dist/source/adapters/drizzle.js +3 -2
  51. package/dist/source/adapters/kysely.d.ts +68 -0
  52. package/dist/source/adapters/kysely.js +210 -0
  53. package/dist/source/adapters/memory.js +2 -1
  54. package/dist/source/adapters/prisma.js +3 -2
  55. package/dist/source/index.js +2 -1
  56. package/dist/transactions/TransactionQueue.d.ts +6 -7
  57. package/dist/transactions/TransactionQueue.js +33 -9
  58. package/dist/types/streams.d.ts +2 -1
  59. package/dist/utils/duration.js +3 -2
  60. package/dist/wire/frames.d.ts +6 -8
  61. package/docs/api.md +1 -1
  62. package/docs/cli.md +17 -4
  63. package/docs/client-behavior.md +1 -1
  64. package/docs/data-sources.md +129 -125
  65. package/docs/examples/ai-sdk-tool.md +11 -5
  66. package/docs/examples/existing-python-backend.md +26 -4
  67. package/docs/examples/nextjs.md +3 -2
  68. package/docs/examples/scoped-agent.md +38 -11
  69. package/docs/guarantees.md +2 -2
  70. package/docs/identity.md +86 -59
  71. package/docs/index.md +2 -2
  72. package/docs/integration-guide.md +89 -61
  73. package/docs/mcp.md +1 -1
  74. package/docs/quickstart.md +84 -37
  75. package/docs/react.md +39 -28
  76. package/docs/schema-contract.md +2 -4
  77. package/llms-full.txt +360 -0
  78. package/llms.txt +30 -18
  79. package/package.json +23 -3
package/AGENTS.md ADDED
@@ -0,0 +1,84 @@
1
+ # AGENTS.md
2
+
3
+ Ablo lets AI agents and humans safely edit the same typed data without clobbering each other. When two of them touch the same row, a "claim" makes one wait for the other instead of overwriting it. This file shows a coding assistant the one safe pattern: read a row, claim it, then write.
4
+
5
+ Claims don't lock. If another writer holds the row, `claim` waits for them and re-reads the fresh row before handing it to you — so two writers serialize instead of clobbering.
6
+
7
+ ## Start here — scaffold with `ablo init`
8
+
9
+ Don't hand-write the integration. Run the CLI; it generates the current-API schema, client, Data Source endpoint, and (for Next.js) the browser provider + session route:
10
+
11
+ - **Scaffold:** `npx ablo init --yes` — flag-driven, never prompts. Override defaults with `--framework <nextjs|vite|remix|vanilla>`, `--auth <apikey|…>`, `--no-agent`, `--no-pull`, `--no-install`, `--no-login`. (Plain `ablo init` needs a TTY and will **HANG** in an agent/CI run — always pass `--yes`.)
12
+ - **Auth:** set `ABLO_API_KEY` in the environment. Do **NOT** run `ablo login` — it opens a browser device flow and blocks an agent.
13
+ - **Provision your DB (Data Source mode):** `npx ablo migrate` creates the tables for your Ablo models plus the adapter's bookkeeping tables (`ablo_outbox`, `ablo_idempotency`). It does **not** touch your other tables — keep your own migrations (drizzle-kit / prisma migrate) for auth and anything not in the Ablo schema.
14
+ - **Adopt an existing DB schema:** `npx ablo pull prisma [path]` / `pull drizzle <module>` (lossless) or `pull` (live DB, lossy). Writes `ablo/schema.ts`.
15
+ - **Push your schema — REQUIRED before any write works.** The server keeps its OWN copy of the schema. After you create or edit `ablo/schema.ts`, run `npx ablo push` (one-shot) — or `npx ablo dev --no-watch`. **Skip this and every write to a new or changed model fails with `server_execute_unknown_model`.** (Plain `ablo dev` watches forever — never run it bare in an agent.)
16
+ - **Other long-running:** `npx ablo logs --no-follow` (default tails forever). `npx ablo mode test|live` ALWAYS pass the argument. `status`, `push`, `pull`, `check`, `generate` are one-shot — safe as-is.
17
+
18
+ The generated `ablo/data-source.ts` is the whole Data Source endpoint and needs no hand-editing: `dataSourceNext({ schema, apiKey, adapter: prismaDataSource(prisma, schema) })` (or `drizzleDataSource(db, schema)`). The adapter owns commit / idempotency / outbox.
19
+
20
+ ## Rule
21
+
22
+ Edit the generated files; teach this API only:
23
+
24
+ ```ts
25
+ const ablo = Ablo({ schema, apiKey: process.env.ABLO_API_KEY });
26
+ ```
27
+
28
+ The schema is the integration contract — it drives typed model clients, React selectors, server and agent writes, the Data Source shape, and schema push. Ablo owns only the models you declare; your auth and other non-synced tables stay in your own ORM schema, side by side in the same database. Don't create a parallel string-keyed write path for rows that belong to a schema model.
29
+
30
+ Every model verb takes ONE options object. The common loop:
31
+
32
+ 1. **Read** the row — `await ablo.<model>.retrieve({ id })` (async; from the server) or `await ablo.<model>.list({ where })` for many. In React render, read synchronously with `useAblo((a) => a.<model>.get(id))`.
33
+ 2. **See who's active** (optional) — `ablo.<model>.claim.state({ id })` (synchronous; never blocks).
34
+ 3. **Claim** the row before changing it — `await using claim = await ablo.<model>.claim({ id, action?, ttl? })`. If someone else holds it, this waits for them, then gives you the fresh row on `claim.data`. The claim auto-releases when it goes out of scope (`await using`).
35
+ 4. **Write** — `await ablo.<model>.update({ id: claim.data.id, data })`. Because you hold the claim, the write is rejected if the row changed underneath you.
36
+
37
+ Keep coding assistants on this schema-backed path.
38
+
39
+ ## Minimal example
40
+
41
+ ```ts
42
+ import Ablo from '@abloatai/ablo';
43
+ import { defineSchema, model, z } from '@abloatai/ablo/schema';
44
+
45
+ const schema = defineSchema({
46
+ weatherReports: model({
47
+ location: z.string(),
48
+ status: z.enum(['pending', 'ready']),
49
+ forecast: z.string().optional(),
50
+ }),
51
+ });
52
+
53
+ const ablo = Ablo({ schema, apiKey: process.env.ABLO_API_KEY });
54
+
55
+ const report = await ablo.weatherReports.retrieve({ id: 'report_stockholm' });
56
+ if (!report) throw new Error('Report not found');
57
+
58
+ // If someone else holds the row, claim waits for them and re-reads the fresh
59
+ // row before resolving. Auto-released at the end of this scope (`await using`).
60
+ await using claim = await ablo.weatherReports.claim({
61
+ id: 'report_stockholm',
62
+ action: 'forecasting',
63
+ ttl: '2m',
64
+ });
65
+ const claimed = claim.data;
66
+
67
+ // Because we hold the claim, update is rejected if the row changed underneath us.
68
+ await ablo.weatherReports.update({
69
+ id: claimed.id,
70
+ data: { status: 'ready', forecast: await getForecast(claimed.location) },
71
+ });
72
+ ```
73
+
74
+ ## Coordination surface
75
+
76
+ Claims live on a callable namespace beside `create` / `update` / `retrieve`. Every member takes an options object:
77
+
78
+ - `await using claim = await ablo.<model>.claim({ id })` — acquire the row (waits if held); read it via `claim.data`; auto-releases on scope exit (or call `claim.release()`).
79
+ - `ablo.<model>.claim.state({ id })` — who is currently working on the row (synchronous; never blocks).
80
+ - `ablo.<model>.claim.queue({ id })` — who is waiting behind the current holder.
81
+ - `ablo.<model>.claim.release({ id })` — release a claim early.
82
+ - `ablo.<model>.claim.reorder({ id, order })` — reorder the waiting queue.
83
+
84
+ Most users declare a schema and write through `ablo.<model>.update({ id, data })`.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,45 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.3
4
+
5
+ ### Patch Changes
6
+
7
+ - Onboarding: quickstart leads with your-own-database (Drizzle Data Source), drop Ablo-managed mode, add `ablo push` step; context7 library-claim config.
8
+
9
+ ## 0.9.2
10
+
11
+ ### Patch Changes
12
+
13
+ - Developer-onboarding overhaul so an LLM or a person gets a working integration on the first try.
14
+ - **`ablo init` scaffolds a project that builds and is current-API.** The Next.js scaffold now ships `app/providers.tsx` + an `app/api/ablo-session` route, uses `useAblo` (the removed `withSync` is gone), object-param verbs, and never bundles your `sk_` key into the browser. The webhook receiver moved off the `[...all]` catch-all.
15
+ - **Agent docs are accurate and ship.** `AGENTS.md`, `llms.txt`, and `llms-full.txt` are on the 0.9.x API (object-param `create`/`update`/`delete`/`retrieve`, disposable `await using claim`, `AbloProvider client` prop), lead with `ablo init`, and `AGENTS.md` now ships in the package.
16
+ - **`ablo push` is self-documenting.** Writing to a model the server hasn't seen now fails with an error that tells you to run `ablo push` (the `server_execute_unknown_model` / `unknown_model` messages), instead of a cryptic "unknown model."
17
+ - **`intents` is deprecated in favor of `claim`** everywhere the docs and the MCP scaffold/prompts teach or generate coordination; the public `ablo.intents` accessor is marked `@internal`.
18
+ - Docs say Node 24+, and the `drizzle-orm` peer floor is `>=0.44`.
19
+
20
+ - a88747a: Remove the `turn` primitive and the agent-work `tasks` resource from the client surface — the SDK is now purely `ablo.<model>` + `claim`.
21
+
22
+ **Breaking**
23
+ - `engine.beginTurn()`, the `Turn` handle interface, and the `Ablo.Turn` type are removed. `AbloApi.beginTurn` and the HTTP client's `beginTurn` are gone too.
24
+ - `CommitCreateOptions.causedByTaskId` is removed. (Lineage is no longer stamped from the client.)
25
+ - The engine no longer exposes a `protocol` accessor or a public `tasks` work-unit resource. `ablo.tasks` is, and always was, the schema `tasks` model proxy.
26
+ - The **`agent().run()` helper and the low-level agent/task type family are removed**: `AbloApi.agent(id, options)` and `AbloApi.tasks` (the `TaskResource`), plus the exported types `Agent`, `AgentOptions`, `AgentRunOptions`, `AgentRunResult`/`Done`/`Failed`/`Cancelled`, `AgentRunStatus`, `AgentRunContext`, `AgentModelClient`, `AgentModelReadOptions`, `AgentModelMutationOptions`, `AgentIntentOptions`, `AgentIntentInput`, `Task`, `TaskResource`, `TaskCreateOptions`, `TaskCloseOptions`, `TaskCloseResult` (and the `Ablo.*` namespace aliases for all of them). The `Ablo.Auth.Agent` principal constructor and the schema-backed `tasks` model are unaffected.
27
+
28
+ **Why**
29
+
30
+ `turn`/`agent_tasks` was a second coordination-and-attribution mechanism living alongside `claim`. It is redundant on the client:
31
+ - `claim` already serializes writers **and** carries the causal link — its `intent` id rides on every guarded write.
32
+ - The server stamps `actor` / `onBehalfOf` / `capabilityId` onto each delta from the auth context.
33
+ - Per-run token/cost is recorded in Langfuse, not the `agent_tasks` table.
34
+
35
+ So the only thing the client lost is the audit pane's "show everything this exact prompt produced" filter, which keyed off `caused_by_task_id`; new writes leave that column null.
36
+
37
+ **Migration**
38
+
39
+ Agents stop opening/closing tasks — just issue `ablo.<model>` writes (schema-backed) or `ablo.commits.create(...)` (schema-less) under a `claim`. Replace `Ablo({ apiKey }).agent(id, opts).run(prompt, handler)` with: mint a scoped credential via `sessions.create({ agent })`, then `claim` the row and `update` / `commits.create`.
40
+
41
+ The **server** `agent_tasks` table, the `caused_by_task_id` delta column, the `/api/sync/commit` wire field, and the `agent_actions_log` compliance hash-chain remain in place but **dormant** (client writes leave the field null) — they are load-bearing for the tamper-evident audit chain and historical-row audit JOINs, so they are intentionally NOT dropped. The dead `/v1/tasks` + `/api/agent/turn` route handlers ARE removed (zero live callers).
42
+
3
43
  ## 0.9.1
4
44
 
5
45
  ### Patch Changes
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  [![npm](https://img.shields.io/npm/v/@abloatai/ablo.svg)](https://www.npmjs.com/package/@abloatai/ablo)
4
4
  [![license](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](./LICENSE)
5
5
  [![types](https://img.shields.io/badge/types-included-blue.svg)](#)
6
- [![runtime](https://img.shields.io/badge/node-%E2%89%A522-brightgreen.svg)](#keys--runtime)
6
+ [![runtime](https://img.shields.io/badge/node-%E2%89%A524-brightgreen.svg)](#keys--runtime)
7
7
 
8
8
  **Let people and AI agents work on the same data without overwriting each other.**
9
9
 
@@ -33,27 +33,43 @@ claims are visible while the work is still in progress.
33
33
 
34
34
  [Get started ↓](#quick-start) · point your coding agent at the shipped `llms.txt`
35
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.
36
+ It works with the auth and database you already have. **Your database is the
37
+ system of record Ablo never hosts your data.** Ablo is the transaction layer
38
+ on top of it: realtime data is scoped to *sync groups* from your own identity,
39
+ and every committed row lives in your Postgres.
39
40
 
40
41
  **Built for** collaborative editors, AI agent workflows, and internal tools —
41
42
  anywhere people and agents change shared state and everyone has to see it live.
42
43
 
43
44
  ## Set up
44
45
 
46
+ The CLI takes you from nothing to a synced schema — it handles the account,
47
+ the key, and the env file. You bring one thing: a Postgres `DATABASE_URL`
48
+ (local, Neon, RDS — any will do; **your database is the system of record,
49
+ Ablo never hosts your data**).
50
+
45
51
  ```bash
46
52
  npm install @abloatai/ablo
53
+ npx ablo login # opens the browser: sign in (or sign up) → a sk_test_ key is saved locally
54
+ npx ablo init # scaffolds ablo/schema.ts (offers to log in if you skipped it)
55
+ npx ablo migrate # creates the synced tables in YOUR Postgres (reads DATABASE_URL)
56
+ npx ablo dev # pushes your schema (test mode), writes ABLO_API_KEY to .env.local, watches for changes
47
57
  ```
48
58
 
49
- **Keys & runtime.** Ablo needs Node 22+ and TypeScript 5+. Grab an `sk_test_*`
50
- key for a sandbox
51
- (`export ABLO_API_KEY=sk_test_...`); keep keys in trusted server runtimes only.
59
+ After `ablo dev`, the [Quick Start](#quick-start) below runs as-is
60
+ `ABLO_API_KEY` is already in `.env.local` (frameworks load it automatically;
61
+ plain Node: `node --env-file=.env.local app.ts`). `npx ablo status` shows
62
+ what's configured at any time.
63
+
64
+ **Keys & runtime.** Ablo needs Node 24+ and TypeScript 5+. Keys come in two of
65
+ *your* environments — `sk_test_` and `sk_live_`, like Stripe — and `ablo login`
66
+ mints both. Keep the key and the database URL in trusted server runtimes only.
52
67
  In the browser, `<AbloProvider>` authenticates with the signed-in user's
53
- session — never the raw key.
68
+ session — never the raw key, never the database URL. Prefer the connection
69
+ string never leaving your infrastructure? Expose a signed
70
+ [Data Source endpoint](./docs/data-sources.md) instead and omit `databaseUrl`.
54
71
 
55
- Then wire it by hand the [Quick Start](#quick-start) below is the shape to
56
- copy. For production (React, an existing backend, Data Source, agents), the
72
+ For production (React, an existing backend, Data Source, agents), the
57
73
  [Integration Guide](./docs/integration-guide.md) is the deeper map.
58
74
 
59
75
  **Prefer to let an agent wire it?** The package ships an `llms.txt` — a precise
@@ -78,7 +94,8 @@ const schema = defineSchema({
78
94
 
79
95
  const ablo = Ablo({
80
96
  schema,
81
- apiKey: process.env.ABLO_API_KEY,
97
+ apiKey: process.env.ABLO_API_KEY, // written to .env.local by `npx ablo dev`
98
+ databaseUrl: process.env.DATABASE_URL, // your Postgres — rows live here, never with Ablo
82
99
  });
83
100
 
84
101
  await ablo.ready();
@@ -99,7 +116,7 @@ const forecast = await fetchForecast(report.location); // slow: API or LLM call
99
116
  await ablo.weatherReports.update({ id: report.id, data: { status: 'ready', forecast } });
100
117
 
101
118
  const ready = ablo.weatherReports.get(created.id);
102
- console.log({ id: ready.id, status: ready.status });
119
+ console.log({ id: ready?.id, status: ready?.status });
103
120
 
104
121
  await ablo.dispose();
105
122
  ```
@@ -218,12 +235,16 @@ provider and read with hooks, from `@abloatai/ablo/react`. Wrap your tree once;
218
235
  everything inside is live.
219
236
 
220
237
  ```tsx
238
+ import Ablo from '@abloatai/ablo';
221
239
  import { AbloProvider, useAblo } from '@abloatai/ablo/react';
222
240
  import { schema } from './ablo/schema';
223
241
 
242
+ // Build the client once — it authenticates via your session route, no key in the browser.
243
+ const ablo = Ablo({ schema, authEndpoint: '/api/ablo-session' });
244
+
224
245
  function App() {
225
246
  return (
226
- <AbloProvider schema={schema}>
247
+ <AbloProvider client={ablo}>
227
248
  <Report id="report_stockholm" />
228
249
  </AbloProvider>
229
250
  );
@@ -250,8 +271,9 @@ method as the server example above.
250
271
  `<AbloProvider>` owns the connection — no API key in the browser. That's the
251
272
  whole loop: read with `useAblo(selector)`, write with `ablo.<model>`, and every
252
273
  other client (human or agent) on that row sees it in real time. See
253
- [React](./docs/react.md) for the full `<AbloProvider>` prop surface (`userId`,
254
- `teamIds`, `syncGroups`, `fallback`, `bootstrapMode`) and status hooks.
274
+ [React](./docs/react.md) for the `<AbloProvider>` prop surface (`client`,
275
+ `userId`, `fallback`, `onError`) — schema, scope, and team membership live on the
276
+ `Ablo({ … })` client you pass it — plus status hooks.
255
277
 
256
278
  ## Identity & Sync Groups
257
279
 
@@ -270,7 +292,10 @@ to sync-group strings.
270
292
  `userId` / `teamIds` come from your auth, resolved server-side:
271
293
 
272
294
  ```tsx
273
- <AbloProvider schema={schema} userId={user.id} teamIds={user.teamIds}>
295
+ // team membership is asserted server-side when the session route mints the token.
296
+ const ablo = Ablo({ schema, authEndpoint: '/api/ablo-session' });
297
+
298
+ <AbloProvider client={ablo} userId={user.id}>
274
299
  <App />
275
300
  </AbloProvider>
276
301
  ```
@@ -316,18 +341,18 @@ curl https://api.abloatai.com/v1/commits \
316
341
  { "object": "commit_receipt", "status": "confirmed", "serverTxId": "tx_…", "lastSyncId": 1042, "ops": 1 }
317
342
  ```
318
343
 
319
- ## Connect Your Database
344
+ ## Your Database
320
345
 
321
- Every schema model has a backing store. By default, Ablo stores rows for the
322
- models you declare, so `ablo.weatherReports.create({ data })` and `ablo.weatherReports.update({ id, data })`
323
- write to Ablo-managed state.
346
+ Every schema model is backed by **your own database** Ablo is the transaction
347
+ layer on top of it, never the home for your rows. Two ways to connect it:
324
348
 
325
- If your existing database stays the source of truth, connect it as a Data
326
- Source: Ablo sends signed commit requests to an endpoint you host, and your app
327
- writes its own database. Your `DATABASE_URL` stays in your appAblo only ever
328
- sees the API key.
349
+ | | How Ablo reaches your Postgres | Use when |
350
+ | --- | --- | --- |
351
+ | **Connection string** (default) | `databaseUrl` at init. Ablo registers the connection once (sent over TLS, stored sealed, never echoed back) and commits each write directly through a non-superuser role, behind row-level security. | You can hand over a scoped connection string. |
352
+ | **Signed endpoint** | Your app exposes one route built from an ORM adapter (`prismaDataSource` / `drizzleDataSource`); Ablo sends signed commit requests and your app writes its own database. | Database credentials must never leave your infrastructure. |
329
353
 
330
- See [Connect Your Database](./docs/data-sources.md) for the integration shape.
354
+ Same product, same truth either way: your database is the system of record. See
355
+ [Connect Your Database](./docs/data-sources.md) for both shapes.
331
356
 
332
357
  ## Configuration
333
358
 
@@ -337,6 +362,7 @@ See [Connect Your Database](./docs/data-sources.md) for the integration shape.
337
362
  | --- | --- | --- | --- |
338
363
  | `schema` | `Schema` | — (required) | Typed model proxies (`ablo.<model>.*`) |
339
364
  | `apiKey` | `string \| ApiKeySetter \| null` | `process.env.ABLO_API_KEY` | Server key — a string, or an async function for rotation |
365
+ | `databaseUrl` | `string \| null` | `process.env.DATABASE_URL` | Your Postgres, registered as the data plane. Server runtimes only — the SDK throws if it sees this in a browser. Omit it when your app exposes a signed [Data Source endpoint](./docs/data-sources.md) instead. |
340
366
  | `baseURL` | `string` | `wss://api.abloatai.com` | Point at a self-hosted or private API |
341
367
 
342
368
  Keep `apiKey` in trusted server runtimes. In the browser, `<AbloProvider>`
@@ -387,11 +413,11 @@ contract; there are no retry or timeout knobs to tune.
387
413
  - [Identity & Sync Groups](./docs/identity.md) — use your own authentication; tell Ablo who's connecting and how org / team / user map to sync-group scope.
388
414
  - [Schema Contract](./docs/schema-contract.md) — one schema becomes typed model clients, React reads, agent writes, Data Source shape, and schema push.
389
415
  - [Guarantees](./docs/guarantees.md) — confirmed writes, stale-write protection, claim coordination, and agent lifecycle.
390
- - [Integration Guide](./docs/integration-guide.md) — pick the backing mode and integrate React, Data Source, multiplayer, and agents.
416
+ - [Integration Guide](./docs/integration-guide.md) — integrate React, your database, multiplayer, and agents.
391
417
  - [React](./docs/react.md) — `<AbloProvider>`, `useAblo`, presence, status, and bootstrap gating.
392
418
  - [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.
393
419
  - [Client Behavior](./docs/client-behavior.md) — options, errors, retries, timeouts, and public imports.
394
- - [Connect Your Database](./docs/data-sources.md) — keep canonical rows in your app database without giving Ablo database credentials.
420
+ - [Connect Your Database](./docs/data-sources.md) — connect your Postgres by connection string (`databaseUrl`) or signed endpoint; your database is the system of record either way.
395
421
  - [Existing Python Backend](./docs/examples/existing-python-backend.md) — migrate existing Python endpoints to multiplayer and agent-safe writes gradually.
396
422
  - [AI SDK Tool](./docs/examples/ai-sdk-tool.md) — use Ablo inside an AI SDK tool call.
397
423
  - [Server Agent](./docs/examples/server-agent.md) — schema-backed worker.
@@ -21,7 +21,6 @@ import { QueryProcessor } from './core/QueryProcessor.js';
21
21
  import { Model } from './Model.js';
22
22
  import { ModelScope } from './ObjectPool.js';
23
23
  import type { Schema } from './schema/schema.js';
24
- import { type ReaderActions } from './mutators/readerActions.js';
25
24
  import type { LocalMutation } from './react/context.js';
26
25
  import type { AuthCredentialSource } from './auth/credentialSource.js';
27
26
  /** Constructor type for Model subclasses (accepts abstract classes) */
@@ -224,18 +223,6 @@ export declare function deriveSyncPlanFromSchema(schema: Schema): {
224
223
  enrichmentPlan: EnrichmentPlanEntry[];
225
224
  foreignKeyIndexes: ForeignKeyIndexSpec[];
226
225
  };
227
- /**
228
- * Schema-derived accessor namespace exposed on `store.query`. Each key is
229
- * a model name from the schema and resolves to a `ReaderActions<S, K>`
230
- * with `findById` / `findMany` / `findFirst` / `count`. Return types are
231
- * inferred from the schema (`InferModel<S, K>`) so callers don't need to
232
- * cast or pass class constructors.
233
- *
234
- * Prisma / Drizzle / Zero all use this shape: `store.query.<modelKey>.*`.
235
- */
236
- export type QueryNamespace<S extends Schema> = {
237
- readonly [K in keyof S['models'] & string]: ReaderActions<S, K>;
238
- };
239
226
  export declare class BaseSyncedStore<TCollaboration extends EventMap<TCollaboration> = DefaultCollaborationEvents, TSchema extends Schema = Schema> {
240
227
  syncStatus: SyncStatus;
241
228
  protected readonly syncClient: SyncClient;
@@ -244,13 +231,10 @@ export declare class BaseSyncedStore<TCollaboration extends EventMap<TCollaborat
244
231
  protected readonly modelRegistry: ModelRegistry;
245
232
  protected readonly auth?: AuthCredentialSource;
246
233
  /**
247
- * Schema the store was constructed with. Persisted so the `query`
248
- * accessor namespace can build typed per-model reader actions lazily
249
- * without callers having to pass the schema at every lookup site.
234
+ * Schema the store was constructed with. Used by the schema-typed
235
+ * `create(key, data)` factory and model self-healing.
250
236
  */
251
237
  protected readonly schema?: TSchema;
252
- /** Lazily-built `query.<modelKey>.*` accessor namespace. */
253
- private _queryProxy?;
254
238
  protected syncWebSocket: SyncWebSocket<TCollaboration> | null;
255
239
  private _syncServerUrl?;
256
240
  /**
@@ -694,24 +678,6 @@ export declare class BaseSyncedStore<TCollaboration extends EventMap<TCollaborat
694
678
  id: string;
695
679
  archivedAt?: Date | null;
696
680
  }>(entity: T): Promise<void>;
697
- /**
698
- * Schema-keyed accessor namespace — the primary type-safe lookup surface.
699
- *
700
- * ```ts
701
- * const chat = store.query.chats.retrieve(chatId); // Chat | undefined
702
- * const slides = store.query.slides.findMany({ where: { deckId } }); // Slide[]
703
- * ```
704
- *
705
- * Each `query.<modelKey>` is a `ReaderActions<Schema, K>` built lazily on
706
- * first access via `createReaderActions`. The returned types are inferred
707
- * from the schema (`InferModel<S, K>`), including `InferRelations` — so
708
- * `chat.messages`, `slide.layers`, etc. are typed without a cast.
709
- *
710
- * Throws if the store was constructed without a schema (class-based
711
- * subclasses that wire models via `modelRegistry.registerModel` directly
712
- * don't have access to schema-derived inference).
713
- */
714
- get query(): QueryNamespace<TSchema>;
715
681
  /** Retrieve a single entity by id. Synchronous pool read. */
716
682
  retrieve(_modelClass: ModelConstructor<Model>, id: string): Model | undefined;
717
683
  /** Find any entity by ID regardless of type */
@@ -22,7 +22,6 @@ import { getContext } from './context.js';
22
22
  import { SyncSessionError } from './errors.js';
23
23
  import { ModelScope } from './ObjectPool.js';
24
24
  import { LazyReferenceCollection } from './LazyReferenceCollection.js';
25
- import { createReaderActions } from './mutators/readerActions.js';
26
25
  /** Bootstrap timeout configuration */
27
26
  export const BOOTSTRAP_CONFIG = {
28
27
  OVERALL_TIMEOUT_MS: 15_000,
@@ -126,13 +125,10 @@ export class BaseSyncedStore {
126
125
  modelRegistry;
127
126
  auth;
128
127
  /**
129
- * Schema the store was constructed with. Persisted so the `query`
130
- * accessor namespace can build typed per-model reader actions lazily
131
- * without callers having to pass the schema at every lookup site.
128
+ * Schema the store was constructed with. Used by the schema-typed
129
+ * `create(key, data)` factory and model self-healing.
132
130
  */
133
131
  schema;
134
- /** Lazily-built `query.<modelKey>.*` accessor namespace. */
135
- _queryProxy;
136
132
  // ── Real-time sync ──
137
133
  syncWebSocket = null;
138
134
  _syncServerUrl;
@@ -421,8 +417,12 @@ export class BaseSyncedStore {
421
417
  * emit here, so undo is naturally local-only (you can't undo a teammate).
422
418
  */
423
419
  subscribeLocalMutations(handler) {
424
- return this.syncClient.subscribe('transaction:created', (data) => {
425
- const tx = data;
420
+ // Tap the TransactionQueue directly via `onLocalTransaction`. The previous
421
+ // `syncClient.subscribe('transaction:created', …)` route registered the
422
+ // handler on SyncClient's OWN emitter, which never fires that event (only
423
+ // the queue's emitter does) — so undo recorded nothing. See
424
+ // `SyncClient.onLocalTransaction` for the full rationale.
425
+ return this.syncClient.onLocalTransaction((tx) => {
426
426
  if (!tx || !tx.type || !tx.modelName || !tx.modelId)
427
427
  return;
428
428
  handler({
@@ -1785,53 +1785,9 @@ export class BaseSyncedStore {
1785
1785
  this.syncClient.update(model);
1786
1786
  }
1787
1787
  // ── Query API ────────────────────────────────────────────────────────────
1788
- /**
1789
- * Schema-keyed accessor namespace the primary type-safe lookup surface.
1790
- *
1791
- * ```ts
1792
- * const chat = store.query.chats.retrieve(chatId); // Chat | undefined
1793
- * const slides = store.query.slides.findMany({ where: { deckId } }); // Slide[]
1794
- * ```
1795
- *
1796
- * Each `query.<modelKey>` is a `ReaderActions<Schema, K>` built lazily on
1797
- * first access via `createReaderActions`. The returned types are inferred
1798
- * from the schema (`InferModel<S, K>`), including `InferRelations` — so
1799
- * `chat.messages`, `slide.layers`, etc. are typed without a cast.
1800
- *
1801
- * Throws if the store was constructed without a schema (class-based
1802
- * subclasses that wire models via `modelRegistry.registerModel` directly
1803
- * don't have access to schema-derived inference).
1804
- */
1805
- get query() {
1806
- if (!this.schema) {
1807
- throw new AbloValidationError('store.query requires a schema to be passed to the BaseSyncedStore constructor. ' +
1808
- 'Pass `{ schema }` in the dependencies argument.', { code: 'store_query_schema_missing' });
1809
- }
1810
- if (!this._queryProxy) {
1811
- const schema = this.schema;
1812
- // BaseSyncedStore satisfies SyncStoreContract structurally via
1813
- // `findById` / `queryByClass` / `save` / `delete`. Pass `this`
1814
- // directly — `createReaderActions` accepts the contract shape.
1815
- const store = this;
1816
- const cache = new Map();
1817
- this._queryProxy = new Proxy({}, {
1818
- get: (_target, prop) => {
1819
- if (typeof prop !== 'string')
1820
- return undefined;
1821
- const cached = cache.get(prop);
1822
- if (cached)
1823
- return cached;
1824
- if (!(prop in schema.models)) {
1825
- throw new AbloValidationError(`store.query: unknown model key "${prop}". Known keys: ${Object.keys(schema.models).join(', ')}`, { code: 'store_query_unknown_model' });
1826
- }
1827
- const actions = createReaderActions(schema, prop, store);
1828
- cache.set(prop, actions);
1829
- return actions;
1830
- },
1831
- });
1832
- }
1833
- return this._queryProxy;
1834
- }
1788
+ // `store.query.<model>.*` was DELETED — `ablo.<model>.get/getAll` is the
1789
+ // one read surface. Custom mutators still read transactionally through
1790
+ // `tx.<model>` (mutators/Transaction.ts), which owns `createReaderActions`.
1835
1791
  /** Retrieve a single entity by id. Synchronous pool read. */
1836
1792
  retrieve(_modelClass, id) {
1837
1793
  return this.objectPool.get(id);
@@ -9,7 +9,10 @@
9
9
  import { EventEmitter } from 'events';
10
10
  import { getContext } from './context.js';
11
11
  export class NetworkMonitor extends EventEmitter {
12
- isOnline = typeof navigator !== 'undefined' ? navigator.onLine : true;
12
+ // Only `navigator.onLine === false` means offline. Node 18+ exposes a global
13
+ // `navigator` with `onLine === undefined`, so the naive `navigator.onLine`
14
+ // would seed `false` (offline) on every server client — start optimistic.
15
+ isOnline = !(typeof navigator !== 'undefined' && navigator.onLine === false);
13
16
  lastOnlineCheck = new Date();
14
17
  constructor() {
15
18
  super();
@@ -13,7 +13,7 @@ import { EventEmitter } from 'events';
13
13
  import { TransactionQueue } from './transactions/TransactionQueue.js';
14
14
  import { type OptimisticEchoMetrics } from './transactions/OptimisticEchoTracker.js';
15
15
  import type { Database } from './Database.js';
16
- import type { MutationOptions } from './interfaces/index.js';
16
+ import type { WriteOptions } from './interfaces/index.js';
17
17
  interface SyncObserver {
18
18
  onSync?: (event: SyncEvent) => void;
19
19
  }
@@ -38,7 +38,6 @@ export interface RehydrationStats {
38
38
  healed: number;
39
39
  elapsedMs: number;
40
40
  }
41
- type MutationWriteOptions = Pick<MutationOptions, 'readAt' | 'onStale'>;
42
41
  export declare class SyncClient extends EventEmitter {
43
42
  private objectPool;
44
43
  private database;
@@ -114,6 +113,12 @@ export declare class SyncClient extends EventEmitter {
114
113
  * Initialize sync client with authentication
115
114
  */
116
115
  initialize(userId: string, organizationId: string): Promise<void>;
116
+ /**
117
+ * The organization this client writes under (set by `initialize`).
118
+ * Read by the model proxy so `create()` defaults `organizationId` the
119
+ * same way the mutator path does — `null` until identity is wired.
120
+ */
121
+ getOrganizationId(): string | null;
117
122
  /**
118
123
  * Self-healing helper for individual model records.
119
124
  *
@@ -172,9 +177,9 @@ export declare class SyncClient extends EventEmitter {
172
177
  */
173
178
  private captureModelChanges;
174
179
  /** Add new model (CREATE) - works offline */
175
- add(model: Model, options?: MutationWriteOptions): void;
180
+ add(model: Model, options?: WriteOptions): void;
176
181
  /** Update existing model (UPDATE) - works offline */
177
- update(model: Model, options?: MutationWriteOptions): void;
182
+ update(model: Model, options?: WriteOptions): void;
178
183
  /**
179
184
  * Update existing model with pre-computed changes.
180
185
  * Used by saveManyOptimized when incoming models have empty change-tracking
@@ -186,7 +191,7 @@ export declare class SyncClient extends EventEmitter {
186
191
  * but still need optimistic pool updates at the sync layer. */
187
192
  get gql(): import("./interfaces/index.js").MutationExecutor;
188
193
  /** Delete model (DELETE) - works offline */
189
- delete(model: Model, options?: MutationWriteOptions): void;
194
+ delete(model: Model, options?: WriteOptions): void;
190
195
  /**
191
196
  * Clear all pending mutations for a specific model
192
197
  * Called before deletion to prevent "layer not found" errors on the server
@@ -383,6 +388,18 @@ export declare class SyncClient extends EventEmitter {
383
388
  error: Error;
384
389
  permanent?: boolean;
385
390
  }) => void): () => void;
391
+ /**
392
+ * Subscribe to LOCAL transaction creation with the full {@link Transaction}
393
+ * payload (`type`, `modelName`, `modelId`, `data`, `previousData`). This is
394
+ * the feed `BaseSyncedStore.subscribeLocalMutations` taps for undo recording.
395
+ *
396
+ * MUST subscribe to the TransactionQueue's emitter directly — that is the
397
+ * ONLY emitter that fires `transaction:created`. SyncClient's own emitter
398
+ * (reached via `subscribe()`) never re-broadcasts it, so routing undo through
399
+ * `subscribe('transaction:created')` silently records nothing. Mirrors
400
+ * `onMutationFailure`, which taps the queue for the same reason.
401
+ */
402
+ onLocalTransaction(listener: (tx: import('./transactions/TransactionQueue.js').Transaction) => void): () => void;
386
403
  /**
387
404
  * Wait for the latest in-flight transaction for (modelName, modelId)
388
405
  * to be confirmed by the server, or reject if it's rolled back.
@@ -313,6 +313,14 @@ export class SyncClient extends EventEmitter {
313
313
  this.emit('sync:offline');
314
314
  }
315
315
  }
316
+ /**
317
+ * The organization this client writes under (set by `initialize`).
318
+ * Read by the model proxy so `create()` defaults `organizationId` the
319
+ * same way the mutator path does — `null` until identity is wired.
320
+ */
321
+ getOrganizationId() {
322
+ return this.organizationId;
323
+ }
316
324
  /**
317
325
  * Self-healing helper for individual model records.
318
326
  *
@@ -1298,6 +1306,75 @@ export class SyncClient extends EventEmitter {
1298
1306
  this.transactionQueue.on('transaction:failed', listener);
1299
1307
  return () => this.transactionQueue.off('transaction:failed', listener);
1300
1308
  }
1309
+ /**
1310
+ * Subscribe to LOCAL transaction creation with the full {@link Transaction}
1311
+ * payload (`type`, `modelName`, `modelId`, `data`, `previousData`). This is
1312
+ * the feed `BaseSyncedStore.subscribeLocalMutations` taps for undo recording.
1313
+ *
1314
+ * MUST subscribe to the TransactionQueue's emitter directly — that is the
1315
+ * ONLY emitter that fires `transaction:created`. SyncClient's own emitter
1316
+ * (reached via `subscribe()`) never re-broadcasts it, so routing undo through
1317
+ * `subscribe('transaction:created')` silently records nothing. Mirrors
1318
+ * `onMutationFailure`, which taps the queue for the same reason.
1319
+ */
1320
+ onLocalTransaction(listener) {
1321
+ this.transactionQueue.on('transaction:created', listener);
1322
+ // Commit-lane writes (`ablo.commits.create` — the agent/atomic door) ride
1323
+ // their own `commit:created` event: they have no optimistic pool apply,
1324
+ // so they must not feed the echo tracker's `transaction:created` path.
1325
+ // Enrich each operation with previous state captured from the pool HERE
1326
+ // (the queue is pool-free) and hand the synthesized transaction to the
1327
+ // same listener, so undo observes every write door — one stream.
1328
+ const onCommitCreated = (payload) => {
1329
+ const TYPE_BY_WIRE = {
1330
+ CREATE: 'create',
1331
+ UPDATE: 'update',
1332
+ DELETE: 'delete',
1333
+ ARCHIVE: 'archive',
1334
+ UNARCHIVE: 'unarchive',
1335
+ };
1336
+ payload.operations.forEach((op, index) => {
1337
+ const type = TYPE_BY_WIRE[op.type];
1338
+ if (!type || !op.id)
1339
+ return;
1340
+ const resident = this.objectPool.get(op.id);
1341
+ const snapshot = type === 'create' ? undefined : resident?.toJSON();
1342
+ // A DELETE of a row the local graph never saw is not invertible —
1343
+ // recording it would make undo "restore" an empty husk. Skip it.
1344
+ if (type === 'delete' && !snapshot)
1345
+ return;
1346
+ // UPDATE inverse must only revert the fields this op actually wrote;
1347
+ // handing undo the FULL row would clobber concurrent edits to
1348
+ // unrelated fields on revert.
1349
+ const previousData = type === 'update' && snapshot && op.input
1350
+ ? Object.fromEntries(Object.keys(op.input).map((key) => [key, snapshot[key]]))
1351
+ : snapshot ?? null;
1352
+ listener({
1353
+ id: `${payload.clientTxId}_op${index}`,
1354
+ type,
1355
+ modelName: op.model,
1356
+ modelId: op.id,
1357
+ modelKey: op.model,
1358
+ data: op.input ?? undefined,
1359
+ previousData,
1360
+ context: {
1361
+ userId: this.userId ?? '',
1362
+ organizationId: this.organizationId ?? '',
1363
+ },
1364
+ status: 'pending',
1365
+ createdAt: Date.now(),
1366
+ attempts: 0,
1367
+ priority: 'normal',
1368
+ priorityScore: 0,
1369
+ });
1370
+ });
1371
+ };
1372
+ this.transactionQueue.on('commit:created', onCommitCreated);
1373
+ return () => {
1374
+ this.transactionQueue.off('transaction:created', listener);
1375
+ this.transactionQueue.off('commit:created', onCommitCreated);
1376
+ };
1377
+ }
1301
1378
  /**
1302
1379
  * Wait for the latest in-flight transaction for (modelName, modelId)
1303
1380
  * to be confirmed by the server, or reject if it's rolled back.