@abloatai/ablo 0.5.1 → 0.7.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 (129) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/README.md +248 -124
  3. package/dist/BaseSyncedStore.d.ts +3 -3
  4. package/dist/BaseSyncedStore.js +3 -3
  5. package/dist/api/index.d.ts +3 -3
  6. package/dist/api/index.js +1 -1
  7. package/dist/client/Ablo.d.ts +91 -93
  8. package/dist/client/Ablo.js +122 -60
  9. package/dist/client/ApiClient.d.ts +14 -14
  10. package/dist/client/ApiClient.js +81 -55
  11. package/dist/client/createInternalComponents.d.ts +2 -3
  12. package/dist/client/createInternalComponents.js +2 -3
  13. package/dist/client/createModelProxy.d.ts +116 -90
  14. package/dist/client/createModelProxy.js +128 -128
  15. package/dist/client/index.d.ts +6 -7
  16. package/dist/client/index.js +4 -5
  17. package/dist/client/validateAbloOptions.js +5 -5
  18. package/dist/coordination/index.d.ts +6 -0
  19. package/dist/coordination/index.js +6 -0
  20. package/dist/coordination/schema.d.ts +329 -0
  21. package/dist/coordination/schema.js +209 -0
  22. package/dist/core/QueryView.d.ts +4 -1
  23. package/dist/core/QueryView.js +1 -1
  24. package/dist/core/index.d.ts +2 -0
  25. package/dist/core/index.js +7 -0
  26. package/dist/core/query-utils.d.ts +7 -10
  27. package/dist/core/query-utils.js +2 -3
  28. package/dist/errorCodes.d.ts +264 -0
  29. package/dist/errorCodes.js +251 -0
  30. package/dist/errors.d.ts +59 -14
  31. package/dist/errors.js +73 -12
  32. package/dist/index.d.ts +11 -9
  33. package/dist/index.js +8 -12
  34. package/dist/interfaces/index.d.ts +2 -10
  35. package/dist/mutators/Transaction.d.ts +2 -2
  36. package/dist/mutators/Transaction.js +2 -2
  37. package/dist/mutators/mutateActions.d.ts +44 -0
  38. package/dist/{react/useMutate.js → mutators/mutateActions.js} +11 -28
  39. package/dist/mutators/readerActions.d.ts +32 -0
  40. package/dist/{react/useReader.js → mutators/readerActions.js} +2 -18
  41. package/dist/policy/index.d.ts +1 -1
  42. package/dist/policy/index.js +1 -1
  43. package/dist/policy/types.d.ts +31 -0
  44. package/dist/policy/types.js +15 -0
  45. package/dist/query/types.d.ts +1 -1
  46. package/dist/react/AbloProvider.d.ts +13 -1
  47. package/dist/react/AbloProvider.js +14 -6
  48. package/dist/react/context.d.ts +4 -4
  49. package/dist/react/index.d.ts +4 -5
  50. package/dist/react/index.js +3 -7
  51. package/dist/react/useAblo.d.ts +14 -14
  52. package/dist/react/useAblo.js +26 -26
  53. package/dist/react/useIntent.d.ts +2 -2
  54. package/dist/react/useIntent.js +2 -2
  55. package/dist/react/useMutators.d.ts +1 -1
  56. package/dist/react/usePresence.d.ts +3 -3
  57. package/dist/react/usePresence.js +4 -4
  58. package/dist/react/useUndoScope.d.ts +1 -1
  59. package/dist/schema/ddl.d.ts +62 -0
  60. package/dist/schema/ddl.js +317 -0
  61. package/dist/schema/diff.d.ts +167 -0
  62. package/dist/schema/diff.js +280 -0
  63. package/dist/schema/field.d.ts +16 -19
  64. package/dist/schema/field.js +30 -17
  65. package/dist/schema/generate.d.ts +19 -0
  66. package/dist/schema/generate.js +87 -0
  67. package/dist/schema/index.d.ts +9 -3
  68. package/dist/schema/index.js +14 -2
  69. package/dist/schema/model.d.ts +87 -25
  70. package/dist/schema/model.js +33 -3
  71. package/dist/schema/relation.d.ts +17 -0
  72. package/dist/schema/roles.d.ts +148 -0
  73. package/dist/schema/roles.js +149 -0
  74. package/dist/schema/schema.d.ts +10 -69
  75. package/dist/schema/schema.js +58 -24
  76. package/dist/schema/select.d.ts +25 -0
  77. package/dist/schema/select.js +55 -0
  78. package/dist/schema/serialize.d.ts +96 -0
  79. package/dist/schema/serialize.js +231 -0
  80. package/dist/schema/sugar.d.ts +20 -3
  81. package/dist/schema/sugar.js +5 -1
  82. package/dist/schema/tenancy.d.ts +66 -0
  83. package/dist/schema/tenancy.js +58 -0
  84. package/dist/sync/HydrationCoordinator.d.ts +2 -0
  85. package/dist/sync/HydrationCoordinator.js +23 -17
  86. package/dist/sync/SyncWebSocket.d.ts +17 -0
  87. package/dist/sync/SyncWebSocket.js +46 -1
  88. package/dist/sync/awaitIntentGrant.d.ts +26 -0
  89. package/dist/sync/awaitIntentGrant.js +60 -0
  90. package/dist/sync/createIntentStream.d.ts +2 -1
  91. package/dist/sync/createIntentStream.js +89 -5
  92. package/dist/sync/createPresenceStream.js +1 -1
  93. package/dist/sync/participants.d.ts +2 -2
  94. package/dist/sync/participants.js +9 -18
  95. package/dist/types/global.d.ts +43 -52
  96. package/dist/types/global.js +16 -18
  97. package/dist/types/streams.d.ts +90 -42
  98. package/docs/api-keys.md +44 -0
  99. package/docs/api.md +72 -173
  100. package/docs/audit.md +5 -5
  101. package/docs/cli.md +212 -0
  102. package/docs/client-behavior.md +42 -43
  103. package/docs/coordination.md +343 -0
  104. package/docs/data-sources.md +16 -16
  105. package/docs/examples/agent-human.md +30 -32
  106. package/docs/examples/ai-sdk-tool.md +32 -33
  107. package/docs/examples/existing-python-backend.md +38 -36
  108. package/docs/examples/nextjs.md +24 -25
  109. package/docs/examples/scoped-agent.md +78 -0
  110. package/docs/examples/server-agent.md +20 -61
  111. package/docs/guarantees.md +34 -56
  112. package/docs/identity.md +529 -0
  113. package/docs/index.md +18 -24
  114. package/docs/integration-guide.md +130 -144
  115. package/docs/interaction-model.md +32 -95
  116. package/docs/mcp/claude-code.md +3 -3
  117. package/docs/mcp/cursor.md +1 -1
  118. package/docs/mcp/windsurf.md +1 -1
  119. package/docs/mcp.md +11 -26
  120. package/docs/quickstart.md +43 -49
  121. package/docs/react.md +74 -24
  122. package/docs/roadmap.md +17 -7
  123. package/llms.txt +34 -39
  124. package/package.json +8 -1
  125. package/dist/react/useMutate.d.ts +0 -83
  126. package/dist/react/useQuery.d.ts +0 -123
  127. package/dist/react/useQuery.js +0 -145
  128. package/dist/react/useReader.d.ts +0 -69
  129. package/docs/capabilities.md +0 -163
package/docs/api.md CHANGED
@@ -11,67 +11,59 @@ import Ablo from '@abloatai/ablo';
11
11
  import { defineSchema, model, z } from '@abloatai/ablo/schema';
12
12
 
13
13
  const schema = defineSchema({
14
- tasks: model({
15
- title: z.string(),
16
- status: z.enum(['todo', 'doing', 'done']),
14
+ weatherReports: model({
15
+ location: z.string(),
16
+ status: z.enum(['pending', 'ready']),
17
17
  }),
18
18
  });
19
19
 
20
20
  const ablo = Ablo({ schema, apiKey: process.env.ABLO_API_KEY });
21
21
 
22
22
  await ablo.ready();
23
- const [task] = await ablo.tasks.load({ where: { id: 'task_123' } });
24
- if (!task) throw new Error('Task not found');
23
+ const [report] = await ablo.weatherReports.load({ where: { id: 'report_stockholm' } });
24
+ if (!report) throw new Error('Row not found');
25
25
 
26
- await ablo.tasks.update('task_123', { status: 'done' }, { wait: 'confirmed' });
26
+ await ablo.weatherReports.update('report_stockholm', { status: 'ready' }, { wait: 'confirmed' });
27
27
  ```
28
28
 
29
29
  ## Model Methods
30
30
 
31
- Each schema model becomes a typed resource on the client:
31
+ Each schema model becomes a typed model on the client:
32
32
 
33
- - `ablo.tasks.load({ where })` hydrates rows asynchronously.
34
- - `ablo.tasks.retrieve(id)` reads one already-loaded row synchronously.
35
- - `ablo.tasks.create(data)` creates a row.
36
- - `ablo.tasks.update(id, data, options?)` updates a row.
37
- - `ablo.tasks.delete(id, options?)` deletes a row.
33
+ - `ablo.weatherReports.load({ where })` hydrates rows asynchronously.
34
+ - `ablo.weatherReports.retrieve(id)` reads one already-loaded row synchronously.
35
+ - `ablo.weatherReports.create(data)` creates a row.
36
+ - `ablo.weatherReports.update(id, data, options?)` updates a row.
37
+ - `ablo.weatherReports.delete(id, options?)` deletes a row.
38
38
 
39
- `load` and `retrieve` are not aliases. Use `load` when the row may not be in the
40
- local pool yet. Use `retrieve` after `ready()` or `load()` when you want a cheap
41
- local read.
39
+ `load` and `retrieve` are not aliases. Use `load` when the row may not be loaded
40
+ yet. Use `retrieve` after `ready()` or `load()` when you want a cheap
41
+ synchronous read.
42
42
 
43
43
  | Method | Returns | Use when |
44
44
  |---|---|---|
45
45
  | `load({ where })` | `Promise<T[]>` | You need to hydrate rows from local store and server. |
46
- | `retrieve(id)` | `T \| undefined` | You already loaded the row and want a synchronous local read. |
47
- | `list(options?)` | `T[]` | You want a synchronous local list. |
48
- | `count(options?)` | `number` | You want a synchronous local count. |
46
+ | `retrieve(id)` | `T \| undefined` | You already loaded the row and want a synchronous read. |
47
+ | `list(options?)` | `T[]` | You want a synchronous list of loaded rows. |
48
+ | `count(options?)` | `number` | You want a synchronous count of loaded rows. |
49
49
  | `create(data, options?)` | `Promise<T>` | You want to create through the schema model. |
50
50
  | `update(id, data, options?)` | `Promise<T>` | You want to update through the schema model. |
51
51
  | `delete(id, options?)` | `Promise<void>` | You want to delete through the schema model. |
52
52
 
53
- `list` and `count` read the local pool. They default to live rows and accept:
54
-
55
- ```ts
56
- const activeDoneTasks = ablo.tasks.list({
57
- where: { status: 'done' },
58
- filter: (task) => !task.title.startsWith('[archived]'),
59
- orderBy: { updatedAt: 'desc' },
60
- limit: 20,
61
- scope: 'live', // 'live' | 'archived' | 'all'
62
- });
63
- ```
53
+ `load`, `create`, `update`, and `delete` are the main path they go through the
54
+ server. `retrieve` / `list` / `count` are **synchronous reads** off the rows a
55
+ session has already loaded, so a cheap re-read needs no round-trip.
64
56
 
65
57
  ## Protected Writes
66
58
 
67
59
  Use `snapshot` when a write should reject if the row changed mid-flight:
68
60
 
69
61
  ```ts
70
- const snap = ablo.snapshot({ tasks: 'task_123' });
62
+ const snap = ablo.snapshot({ weatherReports: 'report_stockholm' });
71
63
 
72
- await ablo.tasks.update(
73
- 'task_123',
74
- { status: 'done' },
64
+ await ablo.weatherReports.update(
65
+ 'report_stockholm',
66
+ { status: 'ready' },
75
67
  { readAt: snap.stamp, onStale: 'reject', wait: 'confirmed' },
76
68
  );
77
69
  ```
@@ -82,62 +74,43 @@ Protected write options:
82
74
  |---|---|
83
75
  | `readAt` | The state cursor the write was based on. |
84
76
  | `onStale` | Stale-state policy. Prefer `reject` for agent writes. |
85
- | `intent` | Active work claim associated with the write. |
86
77
  | `wait` | `queued` resolves after local queueing; `confirmed` waits for server acceptance. |
87
78
  | `idempotencyKey` | Stable key for retry-safe writes. The SDK generates one when omitted. |
88
79
  | `timeout` | Maximum time to wait for the write call. |
89
80
 
90
- ## Advanced Resource API
91
-
92
- Use `resource(name)` only when you intentionally need the raw protocol shape:
93
- generic server runtimes, MCP routes, batch tools, or code that has no schema.
94
-
95
- ```ts
96
- const tasks = ablo.resource<{ status: string }>('tasks');
97
-
98
- const { data, stamp, intents } = await tasks.retrieve('task_123', {
99
- ifBusy: 'return',
100
- });
101
- ```
102
-
103
- `stamp` is the state watermark. Pass it as `readAt` to reject stale writes.
81
+ ## Claims
104
82
 
105
- `intents` lists active work on the target. Set `ifBusy: 'wait'` to wait for
106
- that work to clear, or `ifBusy: 'fail'` to throw `AbloBusyError`.
83
+ A claim tells humans and agents who is working on a target before the write
84
+ lands. One self-describing object carries the lifecycle in a single `status`
85
+ field. It lives on the coordination plane: ephemeral, TTL'd, broadcast to peers
86
+ in real time, and never persisted as a row.
107
87
 
108
- ## Intent
88
+ Coordinate one through flat verbs on the model, beside `create`/`update`/`retrieve`:
89
+ `ablo.<model>.claim(id, ...)` to claim a row, `ablo.<model>.claimState(id)` to read
90
+ who holds it (synchronous; never blocks), and `ablo.<model>.release(id)` to release
91
+ early. Claims are **advisory** — they serialize on contention rather than locking.
109
92
 
110
- Intent is the coordination object — it tells humans and agents who is working on
111
- a target before the write lands. Like Stripe's `PaymentIntent`, one
112
- self-describing object carries the whole lifecycle in a single `status` field.
113
- It lives on the **coordination plane**: ephemeral, TTL'd, broadcast to peers in
114
- real time, and never persisted as a row.
115
-
116
- Read or open one through the model accessor — `ablo.<model>.intent(id)` — which
117
- sits beside `create`/`update`/`retrieve` and returns a handle **synchronously**,
118
- so you can inspect who holds a target without awaiting.
119
-
120
- ### The Intent object
93
+ ### The Claim State Object
121
94
 
122
95
  | Field | Type | Description |
123
96
  |---|---|---|
124
- | `object` | `'intent'` | String representing the object's type. Always `'intent'`. |
125
- | `id` | string | Unique identifier for the intent. |
97
+ | `object` | `'claim'` | String representing the object's type. |
98
+ | `id` | string | Unique identifier for the claim. |
126
99
  | `status` | `'active' \| 'committed' \| 'expired' \| 'canceled'` | The whole lifecycle, in one field. |
127
100
  | `target` | `{ type, id, field? }` | What is being coordinated. |
128
101
  | `action` | string | Human-readable phase — `'editing'`, `'writing'`, `'reviewing'`. |
129
- | `heldBy` | string | Participant id holding the intent. |
102
+ | `heldBy` | string | Participant id holding the claim. |
130
103
  | `participantKind` | `'human' \| 'agent'` | Whether a human session or an agent holds it. |
131
104
  | `expiresAt` | string | Ms-epoch at which the server auto-expires it if the holder doesn't finish. |
132
105
 
133
106
  ```json
134
107
  {
135
- "object": "intent",
136
- "id": "int_3MtwBwLkdIwHu7ix",
108
+ "object": "claim",
109
+ "id": "claim_3MtwBwLkdIwHu7ix",
137
110
  "status": "active",
138
- "target": { "type": "tasks", "id": "task_123", "field": "status" },
111
+ "target": { "type": "weatherReports", "id": "report_stockholm", "field": "status" },
139
112
  "action": "editing",
140
- "heldBy": "agent:task-writer",
113
+ "heldBy": "agent:report-writer",
141
114
  "participantKind": "agent",
142
115
  "expiresAt": "1716580000000"
143
116
  }
@@ -146,128 +119,54 @@ so you can inspect who holds a target without awaiting.
146
119
  ### Lifecycle
147
120
 
148
121
  ```
149
- claim() update() lands
122
+ claim(id) update(id) lands
150
123
  (free) ───────────▶ active ───────────────────────▶ committed
151
124
 
152
125
  ┌───────────┴───────────┐
153
126
  ▼ ▼
154
127
  canceled expired
155
- (finish w/o write) (TTL; holder died)
128
+ (release w/o write) (TTL; holder died)
156
129
  ```
157
130
 
158
- A target is free when `ablo.<model>.intent(id).current` is `null`. Terminal
159
- states drop out of the live stream a present intent is, by definition,
160
- `active`.
131
+ A target is free when `ablo.<model>.claimState(id)` is `null`. Terminal
132
+ states drop out of the live stream, so a present claim is active.
161
133
 
162
134
  ### Reading and claiming
163
135
 
164
- ```ts
165
- const task = ablo.tasks.intent('task_123');
136
+ `claimState(id)` is the read side for observers: synchronous, never blocks, and
137
+ returns the live claim state object (or `null`). `claim(id, ...)` is the write side:
138
+ it claims the row and returns the row. Because the claim is **advisory**, if
139
+ someone else already holds the row, `claim` waits for them to finish, then
140
+ re-reads the row before handing it back — so you always proceed from fresh state.
141
+ Default reads stay open; server/model reads can opt into `ifClaimed: 'wait'` or
142
+ `ifClaimed: 'fail'` when they should not read through active work.
166
143
 
167
- // Read side — who is working on this target right now?
168
- if (task.current) {
169
- task.current.heldBy; // 'agent:task-writer'
170
- task.current.action; // 'editing'
171
- await task.whenFree(); // wait until they finish, then continue
144
+ ```ts
145
+ const claim = ablo.weatherReports.claimState('report_stockholm');
146
+ if (claim) {
147
+ claim.heldBy;
148
+ claim.action;
172
149
  }
173
150
 
174
- // Write side claim, write, auto-release in one flow.
175
- await task.claim({ action: 'editing', field: 'status', ttl: '2m' });
176
- const updated = await task.update({ status: 'done' });
177
- updated.status; // 'done'
178
- ```
179
-
180
- `task.update(...)` carries the same stale-check as a plain update: it rejects
181
- with `AbloStaleContextError` if the row advanced past your claim point, so you
182
- re-read before retrying. The intent releases automatically when `update`
183
- resolves; call `task.finish()` if the work ends without a write.
184
-
185
- `task.whenFree({ timeout })` waits until the target is free. Pass `timeout` only
186
- when your product needs an upper bound.
187
-
188
- ### Cross-resource coordination
189
-
190
- For lower-level coordination that isn't scoped to a single model row, the
191
- top-level `ablo.intents` resource (`create`, `list`, `waitFor`) remains
192
- available. Most callers should prefer `ablo.<model>.intent(id)`.
193
-
194
- ## Advanced Commit API
195
-
196
- Most callers use `ablo.<model>.update(...)` or `resource.update(...)`. For atomic
197
- batches or custom runtimes, use `commits.create`.
198
-
199
- ```ts
200
- await ablo.commits.create({
201
- wait: 'confirmed',
202
- operations: [
203
- {
204
- action: 'update',
205
- resource: 'tasks',
206
- id: 'task_123',
207
- data: { status: 'done' },
208
- readAt: stamp,
209
- onStale: 'reject',
210
- },
211
- ],
212
- });
151
+ const updated = await ablo.weatherReports.claim(
152
+ 'report_stockholm',
153
+ async (report) => ablo.weatherReports.update(report.id, { status: 'ready' }),
154
+ { action: 'editing', ttl: '2m' },
155
+ );
213
156
  ```
214
157
 
215
- Every state change becomes a commit. Commits check authorization, stale state,
216
- intent conflicts, and idempotency.
158
+ Writes go through the normal flat `ablo.<model>.update(id, data)`. While you hold
159
+ a claim on `id`, that `update` is automatically stale-guarded: it rejects with
160
+ `AbloStaleContextError` if the row advanced past your claim point, so you re-read
161
+ before retrying. The callback form releases automatically when the callback
162
+ returns or throws, or call `ablo.weatherReports.release(id)` if you claimed manually and
163
+ need to release early.
217
164
 
218
165
  ## Agent
219
166
 
220
167
  Most agents should import the same schema as the app and call
221
- `ablo.<model>.load(...)` plus `ablo.<model>.update(...)`. The schema-less
222
- `agent.run(...)` wrapper exists for advanced server workers that cannot import
223
- the app schema; it creates the capability and task internally and returns
224
- `done`, `failed`, or `cancelled`.
225
-
226
- ## Data Source
227
-
228
- Use `dataSource(...)` only when the customer's app database remains canonical
229
- and Ablo should call a signed endpoint instead of storing customer rows itself.
230
-
231
- ```ts
232
- import { dataSource } from '@abloatai/ablo';
233
- import { schema } from './ablo.schema';
234
-
235
- export const POST = dataSource({
236
- schema,
237
- apiKey: process.env.ABLO_API_KEY,
238
- async commit({ operations, clientTxId, context }) {
239
- // Write operations to the customer's database transaction.
240
- return { rows: [] };
241
- },
242
- });
243
- ```
244
-
245
- The SDK still uses `ablo.<model>.update(...)`. The Data Source endpoint is a
246
- server-to-server storage adapter. See [Connect Your Database](./data-sources.md).
247
-
248
- ## Capability
249
-
250
- Capabilities are the lower-level permission boundary. Most apps should let
251
- `agent.run(...)` create and revoke them.
252
-
253
- ```ts
254
- const capability = await api.capabilities.create({
255
- participantKind: 'agent',
256
- participantId: 'agent:task-writer',
257
- // Strings derive from the schema's `identityRoles` templates
258
- // (see integration-guide.md §1) or a model's `syncGroupFormat`.
259
- syncGroups: ['org:acme', 'user:agent:task-writer'],
260
- operations: ['tasks.retrieve', 'tasks.update'],
261
- lease: '10m',
262
- });
263
- ```
264
-
265
- Use `lease` as a crash cleanup window. Normal agent runs still close when the
266
- handler returns, fails, or is cancelled.
267
-
268
- For the design rationale — why capabilities instead of static API keys,
269
- why the lease + signature + revocation triple, and how this maps to AWS
270
- STS / Vault / Auth0 Token Vault — see [Capabilities](./capabilities.md#why-capabilities-not-api-keys).
168
+ `ablo.<model>.load(...)`, `ablo.<model>.claim(...)`, and
169
+ `ablo.<model>.update(...)`.
271
170
 
272
171
  ## Errors
273
172
 
@@ -283,6 +182,6 @@ All SDK errors extend `AbloError` and expose a stable `type` string.
283
182
  | `AbloValidationError` | Invalid input. |
284
183
  | `AbloServerError` | Server-side 5xx. |
285
184
  | `AbloStaleContextError` | `readAt` no longer matches current state. |
286
- | `AbloBusyError` | Active intent conflict or busy wait timeout. |
185
+ | `AbloClaimedError` | Active claim conflict or claim wait timeout. |
287
186
 
288
187
  See [Client Behavior](./client-behavior.md) for retry and timeout guidance.
package/docs/audit.md CHANGED
@@ -12,11 +12,11 @@ tamper-evident, queryable, exportable.
12
12
  actorId: string,
13
13
  onBehalfOfKind: 'user' | 'agent' | 'system' | null,
14
14
  onBehalfOfId: string | null,
15
- capabilityId: string | null,
16
- capabilityLabel: string | null,
15
+ credentialId: string | null,
16
+ credentialLabel: string | null,
17
17
  delegationChainRoot: string | null, // always points at a human
18
- causedByTaskId: string | null,
19
- actionType: string, // e.g. 'task.update'
18
+ causedByRunId: string | null,
19
+ actionType: string, // e.g. 'weatherReport.update'
20
20
  modelName: string | null, // e.g. 'claude-opus-4-7'
21
21
  diffSummary: unknown,
22
22
  // tamper-evident
@@ -37,7 +37,7 @@ a thing in this system; every chain starts with a person.
37
37
  ```bash
38
38
  curl https://<your-app>/api/orgs/<slug>/audit/verify-chain?\
39
39
  principalKind=agent\
40
- &principalId=task-writer-v3
40
+ &principalId=weather-agent-v3
41
41
  ```
42
42
 
43
43
  Returns either:
package/docs/cli.md ADDED
@@ -0,0 +1,212 @@
1
+ # CLI
2
+
3
+ The `ablo` CLI gets you from an empty project to live-syncing data: scaffold a
4
+ schema, authenticate, push the schema, and watch it sync. Your
5
+ `defineSchema(...)` is the single source of truth — the CLI and the hosted
6
+ server lower it to **the same SQL** through one engine
7
+ (`generateProvisionPlan` / `generateMigrationPlan` in `@abloatai/ablo/schema`).
8
+
9
+ ```bash
10
+ npx ablo init # scaffold ablo/schema.ts + client
11
+ npx ablo login # authorize in the browser
12
+ npx ablo dev # push schema to the test sandbox + watch
13
+ ```
14
+
15
+ ## Authenticate
16
+
17
+ `ablo login` runs the OAuth 2.0 device flow: it opens your browser, you choose
18
+ **log in** or **create an account** and approve, and the CLI provisions a
19
+ **test + live key pair** (90-day, restricted) and stores them locally. This
20
+ mirrors `stripe login`.
21
+
22
+ | Command | What it does |
23
+ | --- | --- |
24
+ | `ablo login` | Authorize in the browser; provisions + stores a test and a live key. |
25
+ | `ablo logout` | Remove the stored keys. |
26
+ | `ablo status` | Show the active org, mode, both keys (prefix + expiry), and server health. |
27
+ | `ablo mode [test\|live]` | Switch the active mode. With no argument, prompts. |
28
+
29
+ Keys are stored in `~/.config/ablo/config.json` (mode `0600`). In **CI**, don't
30
+ log in — set `ABLO_API_KEY`, which always overrides the stored key.
31
+
32
+ ## Test vs live
33
+
34
+ Like Stripe, every account has a **test** mode and a **live** mode, and a key
35
+ belongs to one of them. Test keys are bound to an isolated sandbox: their reads
36
+ and writes never touch live data. Switch with `ablo mode`; `ablo dev` is always
37
+ test mode by design.
38
+
39
+ The schema, however, is **shared** across the org — pushing a schema (from
40
+ either mode) defines the same models test and live see; only the rows differ.
41
+
42
+ ## Commands
43
+
44
+ | Command | What it does | Flags |
45
+ | --- | --- | --- |
46
+ | `ablo init` | Scaffold `ablo/` (`schema.ts`, client, optional Data Source / agent / component), write `.env`, install the SDK. Offers to log in at the end. | — |
47
+ | `ablo login` / `logout` / `status` | Authentication & status (above). | — |
48
+ | `ablo mode [test\|live]` | Switch active mode. | — |
49
+ | `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>` |
50
+ | `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` |
51
+ | `ablo schema 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` |
52
+ | `ablo pull` | **BYO** — generate `defineSchema(...)` from your existing tables (read-only, like `prisma db pull`). | `--out <path>`, `--app-schema <name>`, `--import <pkg>`, `--force` |
53
+ | `ablo check` | **BYO** — verify your *existing* tables fit the schema (read-only, no DDL). | `--schema <path>`, `--export <name>`, `--app-schema <name>` |
54
+ | `ablo generate` | Emit TypeScript types from the schema. | `--out <path>`, `--schema`, `--export` |
55
+
56
+ ## `ablo dev`
57
+
58
+ The development loop. It pushes `ablo/schema.ts` to your **test sandbox**,
59
+ prints the env line your app needs, then watches the file and re-pushes on every
60
+ save (300 ms debounce). It refuses live keys so a tight save loop can never
61
+ churn production data.
62
+
63
+ ```bash
64
+ npx ablo dev # push + watch
65
+ npx ablo dev --no-watch # push once and exit
66
+ ```
67
+
68
+ ## `ablo logs`
69
+
70
+ Tail commit activity, like `stripe logs tail`. Scope comes from the key — a test
71
+ key streams only its sandbox's writes, a live key the org's — so you never pass
72
+ an org. Follows by default; `--no-follow` prints recent and exits.
73
+
74
+ ```bash
75
+ npx ablo logs # last 50, then stream
76
+ npx ablo logs -n 100 --model task # backfill 100, one model
77
+ npx ablo logs --since 15m --json # last 15m as NDJSON, then stream
78
+ ```
79
+
80
+ Each line is `time · op · model · id · actor`. `--json` emits one event per line
81
+ (NDJSON) for piping to `jq` or an agent.
82
+
83
+ ## `ablo pull`
84
+
85
+ Generate `defineSchema(...)` from the tables you already have — the inverse of
86
+ provisioning, and read-only (like `prisma db pull`). It introspects
87
+ `DATABASE_URL`, emits a model per adoptable table (one that has `id` +
88
+ `organization_id`), maps Postgres types back to Zod, and writes `ablo/schema.ts`.
89
+
90
+ ```bash
91
+ DATABASE_URL=postgres://… npx ablo pull
92
+ ```
93
+
94
+ It never touches the database, and won't overwrite an existing schema without
95
+ `--force`. Introspection is lossy — enum members, JSON shape, relations, and
96
+ defaults can't be recovered from columns — so treat the output as a starting
97
+ point: review it, then run `ablo check`.
98
+
99
+ ## `ablo check`
100
+
101
+ The BYO front door. Instead of migrating (DDL on your database), Ablo *adopts*
102
+ the tables you already have: `ablo check` introspects `DATABASE_URL`, compares it
103
+ to your `defineSchema(...)`, and reports — per model — whether the table is
104
+ adoptable. It never writes or alters anything.
105
+
106
+ A table is adoptable when it has a primary key `id` and (for org-scoped models)
107
+ an `organization_id` column — the tenancy marker the engine isolates on. Every
108
+ other table in your database is ignored.
109
+
110
+ **Why `organization_id`?** It's the one column that makes a table safe to
111
+ multiplayer-sync. Row-level security scopes every read and write by it (org A
112
+ can't see org B's rows), and the engine routes realtime deltas by `org:<id>`. A
113
+ table without a tenancy key has no isolation boundary, so Ablo excludes it
114
+ **by default** rather than risk exposing it across tenants. If your tenancy
115
+ column has a different name, keep that table behind a
116
+ [Data Source endpoint](/data-sources) for now.
117
+
118
+ ```bash
119
+ DATABASE_URL=postgres://… npx ablo check
120
+ ```
121
+
122
+ ```text
123
+ ✓ tasks → tasks (id, organization_id ok)
124
+ ✗ projects → projects
125
+ • missing "organization_id" — add it, or move this model behind a Data Source
126
+ 2 models · 1 ok · 1 error
127
+ 12 other tables in your database — ignored by Ablo
128
+ ```
129
+
130
+ If a table can't carry `organization_id` (or has business logic Ablo shouldn't
131
+ bypass), keep it behind a [Data Source endpoint](/data-sources) rather than
132
+ reshaping it. `ablo check` is read-only; it never proposes a migration.
133
+
134
+ ## `migrate` vs `schema push`
135
+
136
+ Two front doors to the same engine. Use `migrate` when your app owns the
137
+ database (it applies to `DATABASE_URL`); use `schema push` (and `dev`) on the
138
+ hosted path (the server applies to Ablo-managed Postgres and version-gates
139
+ connecting clients).
140
+
141
+ ```bash
142
+ ablo migrate --dry-run # preview the exact SQL
143
+ ablo migrate # apply to DATABASE_URL
144
+ ablo migrate --output schema.sql # write SQL to a file
145
+ ```
146
+
147
+ ## Zod → Postgres type mapping
148
+
149
+ The one type map, shared by both paths (there is no second mapping):
150
+
151
+ | Zod | Postgres |
152
+ | --- | --- |
153
+ | `z.string()` | `TEXT` |
154
+ | `z.number()` | `DOUBLE PRECISION` — never `INTEGER`; a Zod number may be fractional, and truncating is silent data loss |
155
+ | `z.boolean()` | `BOOLEAN` |
156
+ | `z.date()` | `TIMESTAMPTZ` |
157
+ | `z.enum([...])` | `TEXT` + a `CHECK (col IN (...))` constraint |
158
+ | `z.object` / `z.array` / `z.record` / `z.union` / `z.custom` | `JSONB` |
159
+ | `.optional()` / `.nullable()` | nullable column |
160
+
161
+ Each table also gets the platform columns (`id`, `organization_id`,
162
+ `created_by`, `created_at`, `updated_at`), an `organization_id` index, and
163
+ row-level security keyed on `current_setting('app.current_org_id')` for tenant
164
+ isolation.
165
+
166
+ `.default(...)` is **not** emitted as a SQL column default — Zod applies the
167
+ default at write time (`create`), in one place, so a DB default and a schema
168
+ default can't drift.
169
+
170
+ ## Structured errors
171
+
172
+ A failed migration aborts the whole transaction (nothing partial lands) and
173
+ reports the same `migration_failed` shape on both paths — naming the statement
174
+ that broke and the Postgres SQLSTATE, not just "migration failed".
175
+
176
+ `ablo migrate` (local) logs it:
177
+
178
+ ```txt
179
+ [migrate] migration plan failed {
180
+ code: 'migration_failed',
181
+ failedStatement: 'ALTER TABLE "public"."tasks" RENAME COLUMN a TO b;',
182
+ failedStatementIndex: 4,
183
+ pgCode: '42P01',
184
+ durationMs: 133
185
+ }
186
+ ```
187
+
188
+ `ablo schema push` (hosted) returns the canonical error envelope (HTTP 500),
189
+ which the SDK reconstructs as a typed `AbloServerError`:
190
+
191
+ ```json
192
+ {
193
+ "type": "AbloServerError",
194
+ "code": "migration_failed",
195
+ "message": "schema migration failed: relation \"...\" does not exist",
196
+ "doc_url": "https://docs.abloatai.com/errors#migration_failed",
197
+ "failedStatement": "ALTER TABLE ... RENAME COLUMN a TO b;",
198
+ "pgCode": "42P01"
199
+ }
200
+ ```
201
+
202
+ The pushed artifact is recorded `failed` and is never activated, so a broken
203
+ migration can't leave clients gated against tables that don't match.
204
+
205
+ ## Environment
206
+
207
+ | Variable | Purpose | Default |
208
+ | --- | --- | --- |
209
+ | `ABLO_API_KEY` | Authenticate without `ablo login` (CI). Always overrides the stored key. | — |
210
+ | `ABLO_API_URL` | Control-plane / API host (`schema push`, `dev`, `status`). | `https://api.abloatai.com` |
211
+ | `ABLO_AUTH_URL` | Dashboard origin for `ablo login`'s device flow. | `https://abloatai.com` |
212
+ | `ABLO_CONFIG_DIR` / `XDG_CONFIG_HOME` | Where the credential file lives. | `~/.config/ablo` |