@abloatai/ablo 0.8.0 → 0.9.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 (162) hide show
  1. package/CHANGELOG.md +40 -1
  2. package/README.md +32 -27
  3. package/dist/BaseSyncedStore.d.ts +73 -0
  4. package/dist/BaseSyncedStore.js +172 -2
  5. package/dist/Model.d.ts +42 -0
  6. package/dist/Model.js +103 -44
  7. package/dist/agent/session.js +3 -3
  8. package/dist/ai-sdk/coordination-context.js +4 -0
  9. package/dist/ai-sdk/index.d.ts +56 -47
  10. package/dist/ai-sdk/index.js +56 -47
  11. package/dist/ai-sdk/intent-broadcast.d.ts +5 -0
  12. package/dist/ai-sdk/intent-broadcast.js +11 -4
  13. package/dist/ai-sdk/wrap.d.ts +14 -11
  14. package/dist/ai-sdk/wrap.js +11 -13
  15. package/dist/auth/credentialSource.d.ts +34 -0
  16. package/dist/auth/credentialSource.js +63 -0
  17. package/dist/auth/index.d.ts +2 -22
  18. package/dist/auth/index.js +4 -42
  19. package/dist/auth/schemas.d.ts +35 -0
  20. package/dist/auth/schemas.js +53 -0
  21. package/dist/client/Ablo.d.ts +160 -42
  22. package/dist/client/Ablo.js +145 -75
  23. package/dist/client/ApiClient.d.ts +20 -4
  24. package/dist/client/ApiClient.js +166 -28
  25. package/dist/client/auth.d.ts +14 -5
  26. package/dist/client/auth.js +60 -7
  27. package/dist/client/createInternalComponents.d.ts +2 -0
  28. package/dist/client/createInternalComponents.js +8 -1
  29. package/dist/client/createModelProxy.d.ts +130 -66
  30. package/dist/client/createModelProxy.js +152 -49
  31. package/dist/client/httpClient.d.ts +71 -0
  32. package/dist/client/httpClient.js +69 -0
  33. package/dist/client/identity.d.ts +2 -6
  34. package/dist/client/identity.js +49 -11
  35. package/dist/client/index.d.ts +1 -0
  36. package/dist/client/index.js +1 -0
  37. package/dist/client/registerDataSource.d.ts +3 -3
  38. package/dist/client/registerDataSource.js +11 -9
  39. package/dist/client/validateAbloOptions.js +1 -1
  40. package/dist/core/DatabaseManager.js +30 -2
  41. package/dist/core/openIDBWithTimeout.d.ts +36 -0
  42. package/dist/core/openIDBWithTimeout.js +88 -1
  43. package/dist/errorCodes.d.ts +70 -1
  44. package/dist/errorCodes.js +108 -9
  45. package/dist/errors.d.ts +2 -2
  46. package/dist/errors.js +72 -22
  47. package/dist/index.d.ts +17 -8
  48. package/dist/index.js +15 -6
  49. package/dist/keys/index.d.ts +16 -1
  50. package/dist/keys/index.js +26 -6
  51. package/dist/mutators/UndoManager.d.ts +86 -50
  52. package/dist/mutators/UndoManager.js +129 -22
  53. package/dist/mutators/inverseOp.d.ts +129 -0
  54. package/dist/mutators/inverseOp.js +74 -0
  55. package/dist/mutators/readerActions.d.ts +1 -1
  56. package/dist/mutators/undoApply.d.ts +42 -0
  57. package/dist/mutators/undoApply.js +143 -0
  58. package/dist/query/client.d.ts +10 -9
  59. package/dist/query/client.js +3 -6
  60. package/dist/react/AbloProvider.d.ts +23 -126
  61. package/dist/react/AbloProvider.js +62 -199
  62. package/dist/react/useAblo.d.ts +2 -2
  63. package/dist/react/useCurrentUserId.d.ts +1 -1
  64. package/dist/react/useCurrentUserId.js +1 -1
  65. package/dist/react/useMutators.js +19 -12
  66. package/dist/schema/ddl.d.ts +26 -3
  67. package/dist/schema/ddl.js +152 -4
  68. package/dist/schema/index.d.ts +4 -0
  69. package/dist/schema/index.js +12 -0
  70. package/dist/schema/model.d.ts +11 -0
  71. package/dist/schema/model.js +2 -0
  72. package/dist/schema/openapi.d.ts +28 -0
  73. package/dist/schema/openapi.js +118 -0
  74. package/dist/schema/plane.d.ts +23 -0
  75. package/dist/schema/plane.js +19 -0
  76. package/dist/schema/relation.d.ts +20 -0
  77. package/dist/schema/serialize.d.ts +4 -0
  78. package/dist/schema/serialize.js +4 -0
  79. package/dist/schema/sync-delta-row.d.ts +157 -0
  80. package/dist/schema/sync-delta-row.js +102 -0
  81. package/dist/schema/sync-delta-wire.d.ts +180 -0
  82. package/dist/schema/sync-delta-wire.js +102 -0
  83. package/dist/server/adapter.d.ts +156 -0
  84. package/dist/server/adapter.js +19 -0
  85. package/dist/server/commit.d.ts +82 -0
  86. package/dist/server/commit.js +1 -0
  87. package/dist/server/index.d.ts +14 -0
  88. package/dist/server/index.js +1 -0
  89. package/dist/server/next.d.ts +51 -0
  90. package/dist/server/next.js +47 -0
  91. package/dist/server/read-config.d.ts +60 -0
  92. package/dist/server/read-config.js +8 -0
  93. package/dist/server/storage-mode.d.ts +17 -0
  94. package/dist/server/storage-mode.js +12 -0
  95. package/dist/source/adapter.d.ts +59 -0
  96. package/dist/source/adapter.js +19 -0
  97. package/dist/source/adapters/drizzle.d.ts +34 -0
  98. package/dist/source/adapters/drizzle.js +147 -0
  99. package/dist/source/adapters/memory.d.ts +12 -0
  100. package/dist/source/adapters/memory.js +114 -0
  101. package/dist/source/adapters/prisma.d.ts +57 -0
  102. package/dist/source/adapters/prisma.js +199 -0
  103. package/dist/source/conformance.d.ts +32 -0
  104. package/dist/source/conformance.js +134 -0
  105. package/dist/source/contract.d.ts +143 -0
  106. package/dist/source/contract.js +98 -0
  107. package/dist/source/index.d.ts +61 -10
  108. package/dist/source/index.js +98 -0
  109. package/dist/source/next.d.ts +33 -0
  110. package/dist/source/next.js +26 -0
  111. package/dist/sync/BootstrapHelper.d.ts +10 -0
  112. package/dist/sync/BootstrapHelper.js +10 -15
  113. package/dist/sync/ConnectionManager.d.ts +55 -1
  114. package/dist/sync/ConnectionManager.js +155 -16
  115. package/dist/sync/HydrationCoordinator.d.ts +93 -17
  116. package/dist/sync/HydrationCoordinator.js +238 -39
  117. package/dist/sync/NetworkProbe.d.ts +58 -24
  118. package/dist/sync/NetworkProbe.js +118 -42
  119. package/dist/sync/SyncWebSocket.d.ts +45 -70
  120. package/dist/sync/SyncWebSocket.js +70 -36
  121. package/dist/sync/createIntentStream.js +10 -1
  122. package/dist/types/streams.d.ts +9 -0
  123. package/dist/utils/mobx-setup.js +1 -0
  124. package/dist/webhooks/events.d.ts +38 -0
  125. package/dist/webhooks/events.js +40 -0
  126. package/dist/webhooks/index.d.ts +10 -0
  127. package/dist/webhooks/index.js +10 -0
  128. package/dist/wire/errorEnvelope.d.ts +34 -0
  129. package/dist/wire/errorEnvelope.js +86 -0
  130. package/dist/wire/frames.d.ts +119 -0
  131. package/dist/wire/frames.js +1 -0
  132. package/dist/wire/index.d.ts +24 -0
  133. package/dist/wire/index.js +21 -0
  134. package/dist/wire/listEnvelope.d.ts +45 -0
  135. package/dist/wire/listEnvelope.js +17 -0
  136. package/docs/api.md +47 -44
  137. package/docs/cli.md +44 -44
  138. package/docs/client-behavior.md +30 -30
  139. package/docs/coordination.md +33 -36
  140. package/docs/data-sources.md +35 -15
  141. package/docs/examples/agent-human.md +45 -43
  142. package/docs/examples/ai-sdk-tool.md +20 -16
  143. package/docs/examples/existing-python-backend.md +16 -12
  144. package/docs/examples/nextjs.md +14 -12
  145. package/docs/examples/scoped-agent.md +1 -1
  146. package/docs/examples/server-agent.md +24 -21
  147. package/docs/guarantees.md +15 -13
  148. package/docs/index.md +1 -1
  149. package/docs/integration-guide.md +30 -30
  150. package/docs/interaction-model.md +19 -23
  151. package/docs/mcp/claude-code.md +3 -3
  152. package/docs/mcp/cursor.md +1 -1
  153. package/docs/mcp/windsurf.md +2 -2
  154. package/docs/mcp.md +6 -6
  155. package/docs/quickstart.md +41 -31
  156. package/docs/react.md +13 -9
  157. package/docs/schema-contract.md +12 -10
  158. package/docs/the-loop.md +21 -0
  159. package/examples/data-source/README.md +4 -5
  160. package/examples/data-source/customer-server.ts +27 -25
  161. package/llms.txt +28 -5
  162. package/package.json +43 -3
package/docs/cli.md CHANGED
@@ -12,12 +12,13 @@ npx ablo login # authorize in the browser
12
12
  npx ablo dev # push schema to the test sandbox + watch
13
13
  ```
14
14
 
15
- **Two setups, and they pick your commands.** If Ablo manages your Postgres
16
- the default, **hosted** path use `ablo dev` and `ablo push`. If you
17
- **bring your own database (BYO)**, use `ablo migrate` to apply changes to your
18
- own `DATABASE_URL` directly, and `ablo check` / `ablo pull` to adopt tables you
19
- already have. The commands below are tagged **Hosted** or **BYO** so you can
20
- tell which apply to you.
15
+ **Two setup styles, and they pick your commands.** If your app database is the
16
+ source of truth, expose a [Data Source endpoint](./data-sources.md) and keep DB
17
+ credentials in your app. If you explicitly want Ablo to open a Postgres
18
+ connection, use the **Direct Postgres connector** commands: `ablo migrate`
19
+ applies changes to your own `DATABASE_URL`, and `ablo check` / `ablo pull`
20
+ adopt tables you already have. Hosted sandbox commands are tagged **Hosted**;
21
+ direct-connector commands are tagged **Direct Postgres**.
21
22
 
22
23
  ## Authenticate
23
24
 
@@ -26,12 +27,12 @@ tell which apply to you.
26
27
  **test + live key pair** (90-day, restricted) and stores them locally. This
27
28
  mirrors `stripe login`.
28
29
 
29
- | Command | What it does |
30
- | --- | --- |
31
- | `ablo login` | Authorize in the browser; provisions + stores a test and a live key. |
32
- | `ablo logout` | Remove the stored keys. |
33
- | `ablo status` | Show the active org, mode, both keys (prefix + expiry), and server health. |
34
- | `ablo mode [test\|live]` | Switch the active mode. With no argument, prompts. |
30
+ | Command | What it does |
31
+ | ------------------------ | -------------------------------------------------------------------------- |
32
+ | `ablo login` | Authorize in the browser; provisions + stores a test and a live key. |
33
+ | `ablo logout` | Remove the stored keys. |
34
+ | `ablo status` | Show the active org, mode, both keys (prefix + expiry), and server health. |
35
+ | `ablo mode [test\|live]` | Switch the active mode. With no argument, prompts. |
35
36
 
36
37
  Keys are stored in `~/.config/ablo/config.json` (mode `0600`). In **CI**, don't
37
38
  log in — set `ABLO_API_KEY`, which always overrides the stored key.
@@ -48,18 +49,18 @@ either mode) defines the same models test and live see; only the rows differ.
48
49
 
49
50
  ## Commands
50
51
 
51
- | Command | What it does | Flags |
52
- | --- | --- | --- |
53
- | `ablo init` | Scaffold `ablo/` (`schema.ts`, client, optional Data Source / agent / component), write `.env`, install the SDK. Offers to log in at the end. | — |
54
- | `ablo login` / `logout` / `status` | Authentication & status (above). | — |
55
- | `ablo mode [test\|live]` | Switch active mode. | — |
56
- | `ablo dev` | **Hosted** — push the schema to your test sandbox, then watch `ablo/schema.ts` and re-push on save. | `--no-watch`, `--schema <path>`, `--export <name>`, `--url <url>` |
57
- | `ablo logs` | Tail your scope's commit activity (`stripe logs tail`). Follows by default. | `-n, --tail <N>`, `--since <dur\|ts>`, `--model`, `--op`, `--json`, `--no-follow`, `--mode test\|live` |
58
- | `ablo push` | **Hosted** — upload the schema to Ablo; the server diffs, migrates, and activates it. | `--force`, `--rename old:new`, `--backfill model.field=value`, `--schema`, `--export`, `--url` |
59
- | `ablo migrate` | **BYO** — apply the schema to your own `DATABASE_URL` (you run the DDL). | `--dry-run`, `--output <file>`, `--schema`, `--export` |
60
- | `ablo pull` | **BYO** — generate `defineSchema(...)` from your existing tables (read-only, like `prisma db pull`). | `--out <path>`, `--app-schema <name>`, `--import <pkg>`, `--force` |
61
- | `ablo check` | **BYO** — verify your *existing* tables fit the schema (read-only, no DDL). | `--schema <path>`, `--export <name>`, `--app-schema <name>` |
62
- | `ablo generate` | Emit TypeScript types from the schema. | `--out <path>`, `--schema`, `--export` |
52
+ | Command | What it does | Flags |
53
+ | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
54
+ | `ablo init` | Scaffold `ablo/` (`schema.ts`, client, optional Data Source / agent / component), write `.env`, install the SDK. Offers to log in at the end. | — |
55
+ | `ablo login` / `logout` / `status` | Authentication & status (above). | — |
56
+ | `ablo mode [test\|live]` | Switch active mode. | — |
57
+ | `ablo dev` | **Hosted** — push the schema to your test sandbox, then watch `ablo/schema.ts` and re-push on save. | `--no-watch`, `--schema <path>`, `--export <name>`, `--url <url>` |
58
+ | `ablo logs` | Tail your scope's commit activity (`stripe logs tail`). Follows by default. | `-n, --tail <N>`, `--since <dur\|ts>`, `--model`, `--op`, `--json`, `--no-follow`, `--mode test\|live` |
59
+ | `ablo push` | **Hosted** — upload the schema to Ablo; the server diffs, migrates, and activates it. | `--force`, `--rename old:new`, `--backfill model.field=value`, `--schema`, `--export`, `--url` |
60
+ | `ablo migrate` | **Direct Postgres** — apply the schema to your own `DATABASE_URL` (you run the DDL). | `--dry-run`, `--output <file>`, `--schema`, `--export` |
61
+ | `ablo pull` | **Direct Postgres** — generate `defineSchema(...)` from your existing tables (read-only, like `prisma db pull`). | `--out <path>`, `--app-schema <name>`, `--import <pkg>`, `--force` |
62
+ | `ablo check` | **Direct Postgres** — verify your _existing_ tables fit the schema (read-only, no DDL). | `--schema <path>`, `--export <name>`, `--app-schema <name>` |
63
+ | `ablo generate` | Emit TypeScript types from the schema. | `--out <path>`, `--schema`, `--export` |
63
64
 
64
65
  ## `ablo dev`
65
66
 
@@ -140,13 +141,12 @@ If a table can't carry `organization_id` (or has business logic Ablo shouldn't
140
141
  bypass), keep it behind a [Data Source endpoint](/data-sources) rather than
141
142
  reshaping it. `ablo check` is read-only; it never proposes a migration.
142
143
 
143
- ## `migrate` (BYO) vs `push` (Hosted)
144
+ ## `migrate` (Direct Postgres) vs `push` (Hosted)
144
145
 
145
- Same engine, two setups. If you **bring your own database (BYO)**, use
146
+ Same engine, two setups. If you use the **Direct Postgres connector**, use
146
147
  `ablo migrate` — it applies the schema to your own `DATABASE_URL`, and you run
147
- the DDL. If Ablo manages your Postgres (the **hosted** path), use `ablo push`
148
- (and `ablo dev`) — the server applies the change to Ablo-managed Postgres
149
- and version-gates connecting clients.
148
+ the DDL. If Ablo manages the sandbox/hosted store, use `ablo push` and
149
+ `ablo dev` — the server applies the change and version-gates connecting clients.
150
150
 
151
151
  ```bash
152
152
  ablo migrate --dry-run # preview the exact SQL
@@ -158,15 +158,15 @@ ablo migrate --output schema.sql # write SQL to a file
158
158
 
159
159
  The one type map, shared by both paths (there is no second mapping):
160
160
 
161
- | Zod | Postgres |
162
- | --- | --- |
163
- | `z.string()` | `TEXT` |
164
- | `z.number()` | `DOUBLE PRECISION` — never `INTEGER`; a Zod number may be fractional, and truncating is silent data loss |
165
- | `z.boolean()` | `BOOLEAN` |
166
- | `z.date()` | `TIMESTAMPTZ` |
167
- | `z.enum([...])` | `TEXT` + a `CHECK (col IN (...))` constraint |
168
- | `z.object` / `z.array` / `z.record` / `z.union` / `z.custom` | `JSONB` |
169
- | `.optional()` / `.nullable()` | nullable column |
161
+ | Zod | Postgres |
162
+ | ------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------- |
163
+ | `z.string()` | `TEXT` |
164
+ | `z.number()` | `DOUBLE PRECISION` — never `INTEGER`; a Zod number may be fractional, and truncating is silent data loss |
165
+ | `z.boolean()` | `BOOLEAN` |
166
+ | `z.date()` | `TIMESTAMPTZ` |
167
+ | `z.enum([...])` | `TEXT` + a `CHECK (col IN (...))` constraint |
168
+ | `z.object` / `z.array` / `z.record` / `z.union` / `z.custom` | `JSONB` |
169
+ | `.optional()` / `.nullable()` | nullable column |
170
170
 
171
171
  Each table also gets the platform columns (`id`, `organization_id`,
172
172
  `created_by`, `created_at`, `updated_at`), an `organization_id` index, and
@@ -214,9 +214,9 @@ migration can't leave clients gated against tables that don't match.
214
214
 
215
215
  ## Environment
216
216
 
217
- | Variable | Purpose | Default |
218
- | --- | --- | --- |
219
- | `ABLO_API_KEY` | Authenticate without `ablo login` (CI). Always overrides the stored key. | — |
220
- | `ABLO_API_URL` | Control-plane / API host (`push`, `dev`, `status`). | `https://api.abloatai.com` |
221
- | `ABLO_AUTH_URL` | Dashboard origin for `ablo login`'s device flow. | `https://abloatai.com` |
222
- | `ABLO_CONFIG_DIR` / `XDG_CONFIG_HOME` | Where the credential file lives. | `~/.config/ablo` |
217
+ | Variable | Purpose | Default |
218
+ | ------------------------------------- | ------------------------------------------------------------------------ | -------------------------- |
219
+ | `ABLO_API_KEY` | Authenticate without `ablo login` (CI). Always overrides the stored key. | — |
220
+ | `ABLO_API_URL` | Control-plane / API host (`push`, `dev`, `status`). | `https://api.abloatai.com` |
221
+ | `ABLO_AUTH_URL` | Dashboard origin for `ablo login`'s device flow. | `https://abloatai.com` |
222
+ | `ABLO_CONFIG_DIR` / `XDG_CONFIG_HOME` | Where the credential file lives. | `~/.config/ablo` |
@@ -47,12 +47,12 @@ Each schema model becomes a typed model:
47
47
  ```ts
48
48
  await ablo.ready();
49
49
 
50
- const report = await ablo.weatherReports.retrieve('report_stockholm');
50
+ const report = await ablo.weatherReports.retrieve({ id: 'report_stockholm' });
51
51
  const local = ablo.weatherReports.get('report_stockholm');
52
52
 
53
- await ablo.weatherReports.create({ location: 'Stockholm', status: 'pending' });
54
- await ablo.weatherReports.update('report_stockholm', { status: 'ready' }, { wait: 'confirmed' });
55
- await ablo.weatherReports.delete('report_stockholm', { wait: 'confirmed' });
53
+ await ablo.weatherReports.create({ data: { location: 'Stockholm', status: 'pending' } });
54
+ await ablo.weatherReports.update({ id: 'report_stockholm', data: { status: 'ready' }, wait: 'confirmed' });
55
+ await ablo.weatherReports.delete({ id: 'report_stockholm', wait: 'confirmed' });
56
56
  ```
57
57
 
58
58
  Call `retrieve`/`list` first — they fetch from the server and you `await` them.
@@ -73,10 +73,12 @@ through the same model client path. A human Server Action, a browser view, and a
73
73
  agent worker can all use `ablo.weatherReports`:
74
74
 
75
75
  ```ts
76
- const report = await ablo.weatherReports.retrieve(id);
76
+ const report = await ablo.weatherReports.retrieve({ id });
77
77
  const snap = ablo.snapshot({ weatherReports: id });
78
78
 
79
- await ablo.weatherReports.update(id, patch, {
79
+ await ablo.weatherReports.update({
80
+ id,
81
+ data: patch,
80
82
  readAt: snap.stamp,
81
83
  onStale: 'reject',
82
84
  wait: 'confirmed',
@@ -86,7 +88,7 @@ await ablo.weatherReports.update(id, patch, {
86
88
  Once the server accepts the write, every other connected client gets the new row
87
89
  automatically — no polling or manual refresh on your side. React clients that use
88
90
  `useAblo((ablo) => ablo.weatherReports.get(id))` receive the new row, and selectors
89
- such as `useAblo((ablo) => ablo.weatherReports.claim.state(id))`
91
+ such as `useAblo((ablo) => ablo.weatherReports.claim.state({ id }))`
90
92
  receive active claim state. There is
91
93
  no extra multiplayer setup beyond routing shared state through Ablo.
92
94
 
@@ -96,17 +98,15 @@ until the app reports it through Data Source events.
96
98
  ## Per-Write Options
97
99
 
98
100
  ```ts
99
- await ablo.weatherReports.update(
100
- 'report_stockholm',
101
- { status: 'ready' },
102
- {
103
- wait: 'confirmed',
104
- readAt: snap.stamp,
105
- onStale: 'reject',
106
- idempotencyKey: 'report_stockholm:mark-ready:v1',
107
- timeout: 20_000,
108
- },
109
- );
101
+ await ablo.weatherReports.update({
102
+ id: 'report_stockholm',
103
+ data: { status: 'ready' },
104
+ wait: 'confirmed',
105
+ readAt: snap.stamp,
106
+ onStale: 'reject',
107
+ idempotencyKey: 'report_stockholm:mark-ready:v1',
108
+ timeout: 20_000,
109
+ });
110
110
  ```
111
111
 
112
112
  | Option | Purpose |
@@ -121,27 +121,27 @@ await ablo.weatherReports.update(
121
121
 
122
122
  If your update involves a slow step — an API call, an LLM round-trip — and someone
123
123
  else might write the same record meanwhile, claiming the record stops you from
124
- overwriting their change. Check who holds the record with `claim.state(id)`, then
125
- take it with `claim(id, work)`:
124
+ overwriting their change. Check who holds the record with `claim.state({ id })`, then
125
+ take it with `claim({ id })`:
126
126
 
127
127
  ```ts
128
- const active = ablo.weatherReports.claim.state('report_stockholm');
128
+ const active = ablo.weatherReports.claim.state({ id: 'report_stockholm' });
129
129
 
130
130
  if (active) {
131
131
  return { status: 'claimed', active };
132
132
  }
133
133
 
134
- await ablo.weatherReports.claim('report_stockholm', async (report) => {
135
- await ablo.weatherReports.update(report.id, { status: 'ready' });
136
- });
134
+ const handle = await ablo.weatherReports.claim({ id: 'report_stockholm' });
135
+ await ablo.weatherReports.update({ id: handle.data.id, data: { status: 'ready' } });
136
+ await handle.release();
137
137
  ```
138
138
 
139
- `claim.state(id)` returns the current holder (or nothing) without ever blocking.
140
- When you call `claim(id, work)`, the SDK queues other claimers behind you, re-reads
141
- the latest row, then runs your `work` — so you can't overwrite a change you didn't
142
- see. Options on the wait:
139
+ `claim.state({ id })` returns the current holder (or nothing) without ever blocking.
140
+ When you call `claim({ id })`, the SDK queues other claimers behind you, re-reads
141
+ the latest row, then hands you the fresh row — so you can't overwrite a change you didn't
142
+ see. Options on the claim:
143
143
 
144
- - default `claim` waits in the fair queue and re-reads before invoking `work`;
144
+ - default `claim` waits in the fair queue and re-reads before handing you the row;
145
145
  - `{ wait: false }` rejects with `AbloClaimedError` instead of queuing;
146
146
  - `{ maxQueueDepth }` rejects if the wait line is already too deep.
147
147
 
@@ -168,7 +168,7 @@ All SDK errors extend `AbloError` and carry a stable `type`.
168
168
  import { AbloClaimedError } from '@abloatai/ablo';
169
169
 
170
170
  try {
171
- await ablo.weatherReports.update('report_stockholm', { status: 'ready' }, { wait: 'confirmed' });
171
+ await ablo.weatherReports.update({ id: 'report_stockholm', data: { status: 'ready' }, wait: 'confirmed' });
172
172
  } catch (error) {
173
173
  if (error instanceof AbloClaimedError) {
174
174
  return { status: 'claimed' };
@@ -1,7 +1,7 @@
1
1
  # Coordination Reference
2
2
 
3
3
  Coordinate long-running work on a row so humans and agents don't clobber each
4
- other. Most writes need none of this — `ablo.<model>.update(id, )` is optimistic
4
+ other. Most writes need none of this — `ablo.<model>.update({ id, data })` is optimistic
5
5
  and the server rejects it if the row moved. Reach for `claim` only when you'll
6
6
  **hold a row across a slow gap** (read → LLM call → write).
7
7
 
@@ -98,8 +98,7 @@ parameters · returns · example**.
98
98
  ### `claim`
99
99
 
100
100
  ```ts
101
- ablo.<model>.claim(id, work, options?): Promise<R> // callback form
102
- ablo.<model>.claim(id, options?): Promise<ClaimedRow<T>>
101
+ ablo.<model>.claim({ id, ...options }): Promise<ClaimHandle<T>> // handle; AsyncDisposable, auto-releases with `await using`
103
102
  ```
104
103
 
105
104
  Claim a row so other writers serialize behind you until you're done; reads stay
@@ -120,38 +119,36 @@ so two claimers can't both think they won.
120
119
  | `options.wait` | `boolean` | no | `true` (default) queues and waits for the lease. `false` is fail-fast — if another participant holds the row, reject immediately with `AbloClaimedError('entity_claimed')` instead of queuing (claim-or-skip, for work dedup where waiting would double-process). |
121
120
  | `options.maxQueueDepth` | `number` | no | Backpressure: reject with `AbloClaimedError('queue_too_deep')` instead of joining a line already `>= maxQueueDepth` deep. Omit to wait however deep the queue is. |
122
121
  | `options.ttl` | `Duration` | no | Crash-cleanup floor. Rarely set — the lease renews while your connection is alive, so it only matters once you go silent. |
123
- | `work` | `(row) => …` | no | Callback form: hold the claim for the callback, release when it returns. |
124
122
 
125
123
  The high-level `claim` queues by default, so on contention you either get the row
126
124
  when your turn arrives or one of the [queue errors](#errors) (`claim_lost`,
127
125
  `grant_timeout`).
128
126
 
129
- **Returns** — with the callback form, returns whatever `work` returns and
130
- releases after the callback returns or throws. The manual form returns the
131
- claimed row (`ClaimedRow<T> = T & AsyncDisposable`): the row data plus a
132
- release hook for manual scopes.
127
+ **Returns** — a `ClaimHandle<T>` (an `AsyncDisposable`): `handle.data` is the
128
+ fresh row snapshot taken once the lease is yours, and `handle.release()` gives
129
+ the claim back. Bind it with `await using` so the claim auto-releases when the
130
+ scope exits.
133
131
 
134
132
  **Example**
135
133
 
136
134
  ```ts
137
- const forecast = await ablo.weatherReports.claim('report_stockholm', async (report) => {
138
- const weather = await weatherAgent.getWeather(report.location);
139
- await ablo.weatherReports.update(report.id, { forecast: weather });
140
- return weather;
141
- });
135
+ await using claim = await ablo.weatherReports.claim({ id: 'report_stockholm' });
136
+ const report = claim.data;
137
+ const weather = await weatherAgent.getWeather(report.location);
138
+ await ablo.weatherReports.update({ id: report.id, data: { forecast: weather } });
142
139
  ```
143
140
 
144
- The manual scoped form is still available for wider TS 5.2+ scopes, but ordinary
145
- held work should use the callback form above.
141
+ The claim releases when the `await using` scope exits on return or throw.
146
142
 
147
143
  ### Claim-gated reads
148
144
 
149
- `claim.state(id)` always returns immediately. Model reads such as
145
+ `claim.state({ id })` always returns immediately. Model reads such as
150
146
  `ablo.<model>.get(id)` are local reads and stay available while a claim is
151
147
  held. Server/model reads can choose a claimed policy:
152
148
 
153
149
  ```ts
154
- await ablo.model('weatherReports').retrieve('report_stockholm', {
150
+ await ablo.weatherReports.retrieve({
151
+ id: 'report_stockholm',
155
152
  ifClaimed: 'wait',
156
153
  claimedTimeout: 30_000,
157
154
  });
@@ -164,7 +161,7 @@ await ablo.model('weatherReports').retrieve('report_stockholm', {
164
161
  ### `claim.state`
165
162
 
166
163
  ```ts
167
- ablo.<model>.claim.state(id)
164
+ ablo.<model>.claim.state({ id })
168
165
  ```
169
166
 
170
167
  Read who's currently working on a row, for observers and UI. Synchronous and
@@ -182,7 +179,7 @@ is free.
182
179
  **Example**
183
180
 
184
181
  ```ts
185
- const who = ablo.weatherReports.claim.state('report_stockholm');
182
+ const who = ablo.weatherReports.claim.state({ id: 'report_stockholm' });
186
183
  if (who) console.log(`${who.heldBy} is ${who.action}`);
187
184
  ```
188
185
 
@@ -203,7 +200,7 @@ Returns the active claim state when the row is held, or `null` when it's free:
203
200
  ### `claim.queue`
204
201
 
205
202
  ```ts
206
- ablo.<model>.claim.queue(id)
203
+ ablo.<model>.claim.queue({ id })
207
204
  ```
208
205
 
209
206
  Read the **wait line** behind a row — the FIFO of claims queued behind the
@@ -226,7 +223,7 @@ the active holder; `[]` when no one is waiting.
226
223
  **Example**
227
224
 
228
225
  ```ts
229
- const { data: waiting } = ablo.weatherReports.claim.queue('report_stockholm');
226
+ const { data: waiting } = ablo.weatherReports.claim.queue({ id: 'report_stockholm' });
230
227
  console.log(`${waiting.length} ahead of you`);
231
228
  console.log(waiting.map((i) => i.heldBy));
232
229
  ```
@@ -234,11 +231,11 @@ console.log(waiting.map((i) => i.heldBy));
234
231
  ### `claim.release`
235
232
 
236
233
  ```ts
237
- ablo.<model>.claim.release(id): Promise<void>
234
+ ablo.<model>.claim.release({ id }): Promise<void>
238
235
  ```
239
236
 
240
- Release a claim you hold. Usually **implicit** — the callback returning releases
241
- for you, and TTL cleans up a crashed holder.
237
+ Release a claim you hold. Usually **implicit** — the `await using` scope exiting
238
+ releases for you, and TTL cleans up a crashed holder.
242
239
  Call this only to give a manually held claim back early (claimed, then decided
243
240
  not to write).
244
241
  Releasing **promotes the head of the queue**: the next waiter receives the claim.
@@ -254,28 +251,28 @@ Releasing **promotes the head of the queue**: the next waiter receives the claim
254
251
  **Example**
255
252
 
256
253
  ```ts
257
- const report = await ablo.weatherReports.claim('report_stockholm', { action: 'reviewing' });
254
+ const claim = await ablo.weatherReports.claim({ id: 'report_stockholm', action: 'reviewing' });
255
+ const report = claim.data;
258
256
  try {
259
257
  const ok = await reviewExternally(report);
260
258
  if (!ok) return; // abandon, no write
261
- await ablo.weatherReports.update(report.id, { status: 'ready' });
259
+ await ablo.weatherReports.update({ id: report.id, data: { status: 'ready' } });
262
260
  } finally {
263
- await ablo.weatherReports.claim.release(report.id);
261
+ await ablo.weatherReports.claim.release({ id: report.id });
264
262
  }
265
263
  ```
266
264
 
267
265
  ### Writing under a claim
268
266
 
269
267
  There is no separate "write" method on a claim — use the normal
270
- `ablo.<model>.update(id, data)`. While you hold a claim on `id`, that `update` is
268
+ `ablo.<model>.update({ id, data })`. While you hold a claim on `id`, that `update` is
271
269
  automatically stale-guarded against the snapshot the claim took (`readAt` =
272
270
  snapshot watermark, `onStale: 'reject'`) and attributed to the claim's lease, so
273
271
  it rejects with [`AbloStaleContextError`](#errors) if the row changed under you.
274
272
 
275
273
  ```ts
276
- await ablo.weatherReports.claim(id, async (report) => {
277
- await ablo.weatherReports.update(report.id, { status: 'ready' }); // guarded by the claim
278
- });
274
+ await using claim = await ablo.weatherReports.claim({ id });
275
+ await ablo.weatherReports.update({ id: claim.data.id, data: { status: 'ready' } }); // guarded by the claim
279
276
  ```
280
277
 
281
278
  Claims are **enforced server-side**: if you `update`/`delete` a row that *another*
@@ -286,7 +283,7 @@ on fresh data. You never conflict with your own claim, and reads are never gated
286
283
 
287
284
  ```ts
288
285
  try {
289
- await ablo.weatherReports.update(id, { status: 'ready' });
286
+ await ablo.weatherReports.update({ id, data: { status: 'ready' } });
290
287
  } catch (err) {
291
288
  if (err instanceof AbloClaimedError) {
292
289
  // someone else holds it — claim the row and retry from fresh state
@@ -318,10 +315,10 @@ that moved during your generation window — use it for selective regeneration
318
315
 
319
316
  ```ts
320
317
  try {
321
- await ablo.weatherReports.claim('report_stockholm', async (report) => {
322
- const weather = await weatherAgent.getWeather(report.location); // slow gap
323
- await ablo.weatherReports.update(report.id, { forecast: weather });
324
- });
318
+ await using claim = await ablo.weatherReports.claim({ id: 'report_stockholm' });
319
+ const report = claim.data;
320
+ const weather = await weatherAgent.getWeather(report.location); // slow gap
321
+ await ablo.weatherReports.update({ id: report.id, data: { forecast: weather } });
325
322
  } catch (err) {
326
323
  if (err instanceof AbloClaimedError && err.code === 'claim_lost') {
327
324
  // Our lease lapsed mid-flight (we stalled past the TTL). Re-claim and retry.
@@ -54,8 +54,8 @@ ABLO_API_KEY=sk_live_...
54
54
  The SDK call is the same in both modes:
55
55
 
56
56
  ```ts
57
- await ablo.weatherReports.create({ location: 'Stockholm', status: 'pending' });
58
- await ablo.weatherReports.update('report_stockholm', { status: 'ready' });
57
+ await ablo.weatherReports.create({ data: { location: 'Stockholm', status: 'pending' } });
58
+ await ablo.weatherReports.update({ id: 'report_stockholm', data: { status: 'ready' } });
59
59
  const report = ablo.weatherReports.get('report_stockholm');
60
60
  ```
61
61
 
@@ -96,13 +96,13 @@ The shape is the same as a production webhook integration:
96
96
  2. Store `ABLO_API_KEY` in your app.
97
97
  3. Verify signed HTTP calls before opening a database transaction.
98
98
  4. Keep your database credentials in your app.
99
- 5. Write an outbox row when data changes outside Ablo.
99
+ 5. Write an outbox row in the same transaction as every app-row change.
100
100
 
101
101
  ## Route
102
102
 
103
103
  ```ts
104
104
  // app/api/ablo/source/route.ts
105
- import { dataSource } from '@abloatai/ablo';
105
+ import { dataSource, sourceEventForOperation } from '@abloatai/ablo';
106
106
  import { schema } from '@/ablo/schema';
107
107
  import { db } from '@/db';
108
108
 
@@ -117,7 +117,23 @@ export const POST = dataSource({
117
117
  async commit({ operations, clientTxId, context }) {
118
118
  const rows = await context.auth.db.transaction(async (tx) => {
119
119
  await tx.idempotency.upsert({ key: clientTxId, operations });
120
- return applyOperations(tx, operations);
120
+ const changes = await applyOperations(tx, operations);
121
+ await tx.outbox.createMany({
122
+ data: changes.map(({ eventId, operation, entityId, data }) =>
123
+ sourceEventForOperation({
124
+ eventId,
125
+ operation,
126
+ entityId,
127
+ data,
128
+ ...(clientTxId ? { clientTxId } : {}),
129
+ ...(context.scope?.organizationId
130
+ ? { organizationId: context.scope.organizationId }
131
+ : {}),
132
+ occurredAt: Date.now(),
133
+ }),
134
+ ),
135
+ });
136
+ return changes.map(({ row }) => row);
121
137
  });
122
138
 
123
139
  return { rows };
@@ -140,11 +156,13 @@ export const POST = dataSource({
140
156
  Your app code still writes through the normal model API:
141
157
 
142
158
  ```ts
143
- await ablo.weatherReports.update(
144
- 'report_stockholm',
145
- { status: 'ready' },
146
- { wait: 'confirmed', readAt: snap.stamp, onStale: 'reject' },
147
- );
159
+ await ablo.weatherReports.update({
160
+ id: 'report_stockholm',
161
+ data: { status: 'ready' },
162
+ wait: 'confirmed',
163
+ readAt: snap.stamp,
164
+ onStale: 'reject',
165
+ });
148
166
  ```
149
167
 
150
168
  ## Commit Request
@@ -188,10 +206,12 @@ Return canonical rows:
188
206
  Use explicit `deltas` only when your source already computes canonical change
189
207
  events.
190
208
 
191
- ## External Writes
209
+ ## Outbox Events
192
210
 
193
- If your app changes data outside Ablo, return those changes from an `events`
194
- handler so connected humans and agents stay current:
211
+ Return your outbox feed from an `events` handler so connected humans and agents
212
+ stay current. Include SDK-origin events too. If Ablo already appended the commit
213
+ directly, `clientTxId` lets Ablo filter the echo; if the direct append failed,
214
+ the same outbox row repairs it on the next poll or push.
195
215
 
196
216
  ```ts
197
217
  export const POST = dataSource({
@@ -218,7 +238,6 @@ export const POST = dataSource({
218
238
  });
219
239
  ```
220
240
 
221
- `clientTxId` lets Ablo drop SDK echoes that already produced a realtime update.
222
241
  Events without `clientTxId` are treated as external writes.
223
242
 
224
243
  ## Production Checklist
@@ -230,7 +249,8 @@ Before using a customer-owned database in production:
230
249
  - Verify signatures before opening a database transaction.
231
250
  - Store `clientTxId` in an idempotency table before applying writes.
232
251
  - Return canonical rows after each commit.
233
- - Write outbox events in the same transaction as non-Ablo writes.
252
+ - Write outbox events in the same transaction as every app-row write, including
253
+ Data Source `commit` writes.
234
254
  - Dedupe outbox events by event `id`.
235
255
  - Monitor last success, last error, retry count, event lag, and cursor.
236
256