@abloatai/ablo 0.6.0 → 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 (74) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +64 -35
  3. package/dist/BaseSyncedStore.d.ts +1 -1
  4. package/dist/BaseSyncedStore.js +1 -1
  5. package/dist/client/Ablo.d.ts +1 -0
  6. package/dist/client/Ablo.js +1 -0
  7. package/dist/client/createModelProxy.d.ts +26 -3
  8. package/dist/client/createModelProxy.js +4 -1
  9. package/dist/client/validateAbloOptions.js +2 -2
  10. package/dist/coordination/index.d.ts +6 -0
  11. package/dist/coordination/index.js +6 -0
  12. package/dist/coordination/schema.d.ts +329 -0
  13. package/dist/coordination/schema.js +209 -0
  14. package/dist/core/QueryView.d.ts +4 -1
  15. package/dist/core/QueryView.js +1 -1
  16. package/dist/core/query-utils.d.ts +7 -10
  17. package/dist/core/query-utils.js +2 -3
  18. package/dist/errorCodes.d.ts +264 -0
  19. package/dist/errorCodes.js +251 -0
  20. package/dist/errors.d.ts +51 -6
  21. package/dist/errors.js +56 -3
  22. package/dist/index.d.ts +3 -2
  23. package/dist/index.js +2 -2
  24. package/dist/policy/index.d.ts +1 -1
  25. package/dist/policy/index.js +1 -1
  26. package/dist/policy/types.d.ts +31 -0
  27. package/dist/policy/types.js +15 -0
  28. package/dist/react/AbloProvider.d.ts +12 -0
  29. package/dist/react/AbloProvider.js +11 -3
  30. package/dist/schema/ddl.d.ts +62 -0
  31. package/dist/schema/ddl.js +317 -0
  32. package/dist/schema/diff.d.ts +6 -0
  33. package/dist/schema/diff.js +21 -3
  34. package/dist/schema/field.d.ts +16 -19
  35. package/dist/schema/field.js +30 -17
  36. package/dist/schema/index.d.ts +7 -4
  37. package/dist/schema/index.js +9 -3
  38. package/dist/schema/model.d.ts +87 -25
  39. package/dist/schema/model.js +33 -3
  40. package/dist/schema/relation.d.ts +17 -0
  41. package/dist/schema/roles.d.ts +148 -0
  42. package/dist/schema/roles.js +149 -0
  43. package/dist/schema/schema.d.ts +2 -112
  44. package/dist/schema/schema.js +50 -62
  45. package/dist/schema/select.d.ts +25 -0
  46. package/dist/schema/select.js +55 -0
  47. package/dist/schema/serialize.d.ts +13 -9
  48. package/dist/schema/serialize.js +14 -10
  49. package/dist/schema/sugar.d.ts +20 -3
  50. package/dist/schema/sugar.js +5 -1
  51. package/dist/schema/tenancy.d.ts +66 -0
  52. package/dist/schema/tenancy.js +58 -0
  53. package/dist/sync/HydrationCoordinator.d.ts +2 -0
  54. package/dist/sync/HydrationCoordinator.js +23 -17
  55. package/dist/sync/createIntentStream.d.ts +2 -1
  56. package/dist/sync/createIntentStream.js +46 -1
  57. package/dist/sync/participants.js +5 -14
  58. package/dist/types/streams.d.ts +53 -33
  59. package/docs/api-keys.md +44 -0
  60. package/docs/api.md +11 -22
  61. package/docs/cli.md +212 -0
  62. package/docs/client-behavior.md +1 -1
  63. package/docs/coordination.md +61 -12
  64. package/docs/data-sources.md +2 -2
  65. package/docs/examples/existing-python-backend.md +3 -3
  66. package/docs/examples/scoped-agent.md +78 -0
  67. package/docs/guarantees.md +5 -2
  68. package/docs/identity.md +139 -68
  69. package/docs/index.md +6 -0
  70. package/docs/integration-guide.md +31 -35
  71. package/docs/interaction-model.md +3 -0
  72. package/docs/react.md +3 -3
  73. package/docs/roadmap.md +14 -2
  74. package/package.json +8 -1
package/docs/identity.md CHANGED
@@ -62,7 +62,7 @@ their scope is a **rule the schema derives automatically** — you never write
62
62
  per-user scope code. An agent's reach depends on *what it's working on*, which is
63
63
  only knowable at dispatch — so you pass its `syncGroups` **at the call site, in
64
64
  code**. The schema's only job for entities is to declare *that* a model is
65
- entity-scopable and *what its group is named* (`entity: 'deck'` → `deck:{id}`);
65
+ entity-scopable and *what its group is named* (`scope: 'deck'` → `deck:{id}`);
66
66
  it never declares *which* entities a given agent gets. (A human can opt into the
67
67
  same runtime narrowing — a page scoped to one deck — but by default a human's
68
68
  scope is fully schema-derived.)
@@ -85,35 +85,38 @@ the next two sections applies to both.
85
85
 
86
86
  ## Declare it, end to end
87
87
 
88
- The entire declaration surface is three things: `identityRoles` (who may see
89
- what), `syncGroupFormat` (which group a row fans out on), and an optional
90
- `syncGroups` prop (narrowing). Here they are in one runnable place — the sections
91
- after this explain each.
88
+ The entire declaration surface is: `identityRoles` (who may see what), and on
89
+ each model `scope` / `parent` / `grants` (which group a row fans out on), plus an
90
+ optional `syncGroups` prop (narrowing). Here they are in one runnable place — the
91
+ sections after this explain each.
92
92
 
93
93
  ```ts
94
- // 1. src/ablo.schema.ts — map identity → groups, and anchor each model to a group
95
- import { defineSchema, identityRole, model, z } from '@abloatai/ablo/schema';
94
+ // 1. src/ablo/schema.ts — map identity → groups, and anchor each model to a group
95
+ import { defineSchema, identityRole, relation, model, z } from '@abloatai/ablo/schema';
96
96
 
97
97
  export const schema = defineSchema(
98
98
  {
99
- conversations: model(
100
- { title: z.string(), createdBy: z.string() },
101
- {},
102
- { orgScoped: true, syncGroupFormat: 'conversation:{id}' },
103
- ),
99
+ // A scope root: its rows form the group `deck:<id>` (kind from `scope`).
104
100
  decks: model(
105
101
  { title: z.string(), status: z.enum(['draft', 'published']) },
106
102
  {},
107
- { orgScoped: true, syncGroupFormat: 'deck:{id}' },
103
+ { orgScoped: true, scope: 'deck' },
104
+ ),
105
+ // A child: it has no group of its own; it inherits its deck's group via the
106
+ // `parent` edge. A write to a slide reaches everyone viewing the deck.
107
+ slides: model(
108
+ { deckId: z.string() },
109
+ { deck: relation.belongsTo('decks', 'deckId', { parent: true }) },
110
+ { orgScoped: true },
108
111
  ),
109
112
  },
110
113
  {
111
- // Each role is pure data: a `template` and the identity `source` field to
112
- // read. No closures — so the schema stays JSON-serializable end to end.
114
+ // Each role is pure data: a `kind` (the group prefix) and the identity
115
+ // `source` field to read. No closures — so the schema stays JSON-serializable.
113
116
  identityRoles: [
114
- identityRole({ kind: 'tenant', template: 'org:{id}', source: 'organizationId' }),
115
- identityRole({ kind: 'participant', template: 'user:{id}', source: 'userId' }),
116
- identityRole({ kind: 'membership', template: 'team:{id}', source: 'teamIds', multi: true }),
117
+ identityRole({ kind: 'org', source: 'organizationId' }),
118
+ identityRole({ kind: 'user', source: 'userId' }),
119
+ identityRole({ kind: 'team', source: 'teamIds', multi: true }),
117
120
  ],
118
121
  },
119
122
  );
@@ -127,11 +130,13 @@ export const schema = defineSchema(
127
130
  ```
128
131
 
129
132
  ```tsx
130
- // 3. an AGENT run inherits its user, narrowed to the entities in play
133
+ // 3. an AGENT run inherits its user, narrowed to the entities in play.
134
+ // Pass the MODEL form — { decks: id } — not a hand-built `deck:<id>` string;
135
+ // the engine derives the group from each model's `scope`.
131
136
  <AbloProvider
132
137
  schema={schema}
133
- userId={user.id} // ceiling: the triggering user
134
- syncGroups={[`conversation:${conversationId}`, `deck:${deckId}`]} // floor: just its work
138
+ userId={user.id} // ceiling: the triggering user
139
+ scope={{ decks: deckId }} // floor: just the deck it's working on
135
140
  >
136
141
  {children}
137
142
  </AbloProvider>
@@ -148,15 +153,15 @@ row's sync group is in the participant's allowed set.
148
153
 
149
154
  ### Half 1 — `identityRoles`: identity → allowed groups
150
155
 
151
- Declared once, on the schema, via the `identityRole({ kind, template, source })`
152
- factory. Each role is **pure data**: a `template` with a single `{id}`
153
- placeholder, and the `source` — the identity field to read. The engine reads
154
- `source` off the identity *you* supply and substitutes each value into the
155
- `template` to build the participant's allowed set. There is no hardcoded `org:` /
156
- `user:` anywhere in the engine — the templates and sources are entirely yours.
156
+ Declared once, on the schema, via the `identityRole({ kind, source })` factory.
157
+ Each role is **pure data**: a `kind` (the group's prefix `org`, `user`, `team`)
158
+ and the `source` — the identity field to read. The engine reads `source` off the
159
+ identity *you* supply and mints `<kind>:<value>` for each value, building the
160
+ participant's allowed set. There is no hardcoded `org:` / `user:` anywhere in the
161
+ engine — the kinds and sources are entirely yours.
157
162
 
158
163
  ```ts
159
- // src/ablo.schema.ts
164
+ // src/ablo/schema.ts
160
165
  import { defineSchema, identityRole, model, z } from '@abloatai/ablo/schema';
161
166
 
162
167
  export const schema = defineSchema(
@@ -168,10 +173,10 @@ export const schema = defineSchema(
168
173
  },
169
174
  {
170
175
  identityRoles: [
171
- identityRole({ kind: 'tenant', template: 'org:{id}', source: 'organizationId' }),
172
- identityRole({ kind: 'participant', template: 'user:{id}', source: 'userId' }),
173
- // `multi: true` reads an array field — one team group per id.
174
- identityRole({ kind: 'membership', template: 'team:{id}', source: 'teamIds', multi: true }),
176
+ identityRole({ kind: 'org', source: 'organizationId' }),
177
+ identityRole({ kind: 'user', source: 'userId' }),
178
+ // `multi: true` reads an array field — one `team:<id>` group per id.
179
+ identityRole({ kind: 'team', source: 'teamIds', multi: true }),
175
180
  ],
176
181
  },
177
182
  );
@@ -189,27 +194,66 @@ in-process and on a hosted server that only ever sees the compiled JSON.
189
194
 
190
195
  ### Half 2 — per-model scope: row → group
191
196
 
192
- On each model's options, you declare how its rows are tenanted and which
193
- sync-group label they fan out on.
197
+ You never write a sync-group string for a row. You declare a model's *place* in
198
+ the entity graph and the engine derives the groups its rows fan out on. Three
199
+ declarations, in order of how often you reach for them:
200
+
201
+ **`scope` — this model is a scope root.** Its rows form a group of their own.
202
+ The kind comes from the model's `typename` by default, or pass a string to set
203
+ it explicitly (use the string form when the wire kind differs from the typename,
204
+ e.g. typename `SlideDeck` but group `deck:<id>`):
194
205
 
195
206
  ```ts
196
- model(
197
- { /* fields */ },
198
- { /* relations */ },
207
+ decks: model({ title: z.string() }, {}, { orgScoped: true, scope: 'deck' });
208
+ // a deck row → group `deck:<id>`
209
+ ```
210
+
211
+ **`parent` — this row lives inside another entity.** Mark the `belongsTo` edge
212
+ to its owner; the row inherits that owner's group. This is the Zanzibar/ReBAC
213
+ *parent* relation — "access inherits from parent" — and it chains transitively
214
+ (a layer → its slide → its deck), so a write to any descendant reaches everyone
215
+ viewing the root. A *reference* (a provenance/template pointer, not ownership)
216
+ must **not** be marked `parent`, or the row would leak into an unrelated scope:
217
+
218
+ ```ts
219
+ slides: model(
220
+ { deckId: z.string(), sourceSlideId: z.string().optional() },
199
221
  {
200
- // Rows carry organization_id; bootstrap + fan-out filter on it.
201
- orgScoped: true,
222
+ deck: relation.belongsTo('decks', 'deckId', { parent: true }), // ownership inherit deck:<id>
223
+ sourceSlide: relation.belongsTo('slides', 'sourceSlideId'), // reference → NOT routed
224
+ },
225
+ { orgScoped: true },
226
+ );
227
+ ```
228
+
229
+ > **Declare the parent edge — don't infer it.** Optionality is not a proxy for
230
+ > ownership: many `parent` FKs are optional (a root folder, an inbox task), and
231
+ > some required FKs are mere references. Containment is a fact only you know, so
232
+ > it's declared, exactly as it is in OpenFGA/Zanzibar.
202
233
 
203
- // Per-entity anchor. Lets a session narrow into ONE row's scope,
204
- // e.g. open a single deck: syncGroupFormat.replace('{id}', deckId).
205
- syncGroupFormat: 'deck:{id}',
234
+ **`grants` a membership edge.** On a join model (e.g. `dataroomMember`), it
235
+ says "this row grants a *subject* access to a *scope root*." Both are relation
236
+ names on the model. The server resolves it at connect time — for user `U`, it
237
+ finds the scope-root groups `U` is a member of and adds them to `U`'s allowed
238
+ set (Linear's `/sync/user_sync_groups`). Use this for sub-org sharing; plain
239
+ org membership is already covered by the `org:` identity role.
240
+
241
+ ```ts
242
+ dataroomMember: model(
243
+ { userId: z.string(), dataroomId: z.string() },
244
+ {
245
+ member: relation.belongsTo('users', 'userId'),
246
+ room: relation.belongsTo('datarooms', 'dataroomId'),
206
247
  },
248
+ { orgScoped: true, grants: { subject: 'member', scope: 'room' } },
207
249
  );
208
250
  ```
209
251
 
210
- For rows that don't carry `organization_id` directly but inherit tenancy
211
- through a foreign key, use `scopedVia` rather than `orgScoped: false` — the
212
- latter exposes the whole table cross-tenant. See
252
+ For the rare group keyed on a plain field rather than a relation (per-recipient
253
+ inbox fan-out, say), there's an `entityRoles: [entityRole({ kind, source })]`
254
+ escape hatch. For rows that inherit *tenancy* (not a sync group) through a
255
+ foreign key without carrying `organization_id`, use `scopedVia` rather than
256
+ `orgScoped: false` — the latter exposes the whole table cross-tenant. See
213
257
  `packages/sync-engine/src/schema/model.ts` for the full option set.
214
258
 
215
259
  ## How identity reaches Ablo — the proxy model
@@ -255,7 +299,7 @@ resolve the user in a Server Component and pass it down:
255
299
  ```tsx
256
300
  // app/providers.tsx — 'use client'
257
301
  import { AbloProvider } from '@abloatai/ablo/react';
258
- import { schema } from '@/ablo.schema';
302
+ import { schema } from '@/ablo/schema';
259
303
 
260
304
  export function Providers({
261
305
  children,
@@ -316,22 +360,23 @@ Mechanically, this is the per-model anchor from
316
360
  anchor on the models an agent operates on:
317
361
 
318
362
  ```ts
319
- // a conversation and the deck an agent edits each get a per-entity group
320
- conversations: model({ /* … */ }, {}, { syncGroupFormat: 'conversation:{id}' }),
321
- decks: model({ /* … */ }, {}, { syncGroupFormat: 'deck:{id}' }),
363
+ // each scope-root model an agent edits forms a per-entity group
364
+ documents: model({ /* … */ }, {}, { orgScoped: true, scope: 'document' }),
365
+ decks: model({ /* … */ }, {}, { orgScoped: true, scope: 'deck' }),
322
366
  ```
323
367
 
324
368
  Then a run subscribes only to the entity groups for the rows it works on — a
325
369
  subset of what its user could see:
326
370
 
327
371
  ```tsx
328
- // agent run triggered by `user`, working on one conversation + one deck
372
+ // agent run triggered by `user`, working on one document + one deck
329
373
  <AbloProvider
330
374
  schema={schema}
331
375
  // identity inherited from the triggering user (the ceiling)
332
376
  userId={user.id}
333
- // authority narrowed to just the entities in play (the floor)
334
- syncGroups={[`conversation:${conversationId}`, `deck:${deckId}`]}
377
+ // authority narrowed to just the entities in play (the floor).
378
+ // Model form — keyed by model, resolved to groups via each model's `scope`.
379
+ scope={{ documents: documentId, decks: deckId }}
335
380
  >
336
381
  ```
337
382
 
@@ -368,24 +413,50 @@ runs the [Coordinating long agent work](../README.md#coordinating-long-agent-wor
368
413
  `claim` loop is, to the scoping layer, that same participant — scoped to the row
369
414
  it claimed.
370
415
 
371
- ## Narrowing to a single entity
416
+ ## Narrowing to specific entities — the `scope` prop
372
417
 
373
- For a page that should only sync one record, combine a per-entity
374
- `syncGroupFormat` (Half 2) with the `syncGroups` prop:
418
+ A human gets their full membership automatically (`identityRoles`). To narrow a
419
+ session a page on one deck, or an agent pointed at the entities it's working
420
+ on — pass `scope`. You give it the **model and id(s)**; the engine builds the
421
+ group string from the model's `scope` (Half 2), so you never hand-write
422
+ `deck:<id>`.
375
423
 
376
- ```ts
377
- // schema: decks fan out on deck:{id}
378
- syncGroupFormat: 'deck:{id}'
379
- ```
424
+ `scope` accepts four shapes, all resolved through the schema:
425
+
426
+ | You pass | Resolves to | Use it for |
427
+ | --- | --- | --- |
428
+ | `{ decks: deckId }` | `deck:<deckId>` | one entity (a page, a focused agent) |
429
+ | `{ decks: [id1, id2] }` | `deck:<id1>`, `deck:<id2>` | several of one model |
430
+ | `{ decks: deckId, documents: docId }` | `deck:<deckId>`, `document:<docId>` | a mix across models |
431
+ | `[{ type: 'Deck', id: deckId }]` | `deck:<deckId>` | entity refs (e.g. from a list) |
380
432
 
381
433
  ```tsx
382
- // page provider: subscribe to just this deck (still inside what auth allows)
383
- <AbloProvider schema={schema} userId={user.id} syncGroups={[`deck:${deckId}`]}>
434
+ // a page on one deck
435
+ <AbloProvider schema={schema} userId={user.id} scope={{ decks: deckId }} />
436
+
437
+ // an agent working across two decks and a document
438
+ <AbloProvider
439
+ schema={schema}
440
+ userId={user.id}
441
+ scope={{ decks: [deckA, deckB], documents: docId }}
442
+ />
384
443
  ```
385
444
 
386
- The participant now receives deltas for that one deck instead of the whole org
387
- smaller bootstrap, less fan-out without weakening the server-enforced
388
- boundary.
445
+ The key is the **model** (`decks`), the value is **which id(s)** the `deck:`
446
+ prefix comes from that model's `scope: 'deck'`, never from a string you compose.
447
+
448
+ > **`scope` means one thing: sync-group scope.** It appears in two places that
449
+ > are the same concept — the model option `scope: 'deck'` (declares a scope root,
450
+ > [Half 2](#half-2--per-model-scope-row--group)) and this `scope` prop (subscribe
451
+ > to it). The lifecycle filter on [`list()`](./api.md#model-methods) is a separate
452
+ > axis and is named **`state`** (`'live' | 'archived' | 'all'`, GitHub's
453
+ > open/closed/all), precisely so it doesn't share the word.
454
+
455
+ > **`scope` requests, it never grants.** At connect, the server intersects your
456
+ > requested groups with what the identity is actually allowed (`requested ∩
457
+ > allowed`). So `scope` only ever *narrows* within a participant's ceiling — an
458
+ > agent can't reach a deck its capability doesn't already permit, no matter what
459
+ > it passes. Smaller bootstrap, less fan-out, same server-enforced boundary.
389
460
 
390
461
  ## How this compares — and the best practices it follows
391
462
 
@@ -426,13 +497,13 @@ The best practices Ablo inherits from that lineage:
426
497
  the line precisely: [token parameters are trusted and usable for access
427
498
  control; client parameters are not](https://docs.powersync.com/usage/sync-rules/advanced-topics/client-parameters).
428
499
  In Ablo terms, the identity your server vouches for is the *trusted* claim that
429
- sets scope; the provider's `userId` / `syncGroups` props are *untrusted client
500
+ sets scope; the provider's `userId` / `scope` props are *untrusted client
430
501
  input* — convenient for app-owned fields and narrowing, but never the boundary.
431
502
  This is why changing `userId` in the browser grants nothing.
432
503
 
433
- 3. **Scope by a hierarchical naming convention, declared once.** Ablo's
434
- `identityRoles` templates (`org:{id}`, `team:{id}`, `deck:{id}`) are the same
435
- idea as [Liveblocks' recommended room-id naming pattern](https://liveblocks.io/docs/authentication/access-token)
504
+ 3. **Scope by a hierarchical naming convention, declared once.** Ablo's `kind:id`
505
+ group naming (`org:…` / `team:…` from `identityRoles`, `deck:…` from a model's
506
+ `scope`) is the same idea as [Liveblocks' recommended room-id naming pattern](https://liveblocks.io/docs/authentication/access-token)
436
507
  (`org:*`, `org:group:*`) and [Ably's channel capabilities](https://ably.com/docs/auth/capabilities).
437
508
  Declaring the convention in one place — never composing scope strings in
438
509
  consumer code — is the practice all three enforce.
package/docs/index.md CHANGED
@@ -31,6 +31,7 @@ query-shaped sync).
31
31
  ## Start here
32
32
 
33
33
  - [Quickstart](./quickstart.md) — Make your first schema-backed write.
34
+ - [CLI & Migrations](./cli.md) — `init` / `migrate` / `schema push` / `generate`, the shared Zod→Postgres type map, and structured migration errors.
34
35
  - [Identity & Sync Groups](./identity.md) — Bring your own auth; tell Ablo who's connecting and how org / team / user map to sync-group scope.
35
36
  - [Integration Guide](./integration-guide.md) — Choose Ablo-managed state, Data Source, React, multiplayer, and agent patterns.
36
37
  - [Guarantees](./guarantees.md) — What confirmed writes, stale checks, and claims guarantee.
@@ -38,6 +39,7 @@ query-shaped sync).
38
39
  - [API Reference](./api.md) — Model-by-model method shape.
39
40
  - [Client Behavior](./client-behavior.md) — Options, errors, retries, timeouts, and imports.
40
41
  - [Connect Your Database](./data-sources.md) — Keep canonical rows in your app database without giving Ablo database credentials.
42
+ - [React](./react.md) — Provider, hooks, and reactive reads for React apps.
41
43
  - [API Keys](./api-keys.md) — Bearer tokens for the public API.
42
44
 
43
45
  ## API shape
@@ -65,12 +67,15 @@ query-shaped sync).
65
67
  - [Connect Your Database](./data-sources.md) — Where data lands when your app database is canonical.
66
68
  - [Receipt](./api.md#receipt) — Confirm what landed.
67
69
  - [Usage](./api.md#usage) — Metering and audit dimensions.
70
+ - [Audit Log](./audit.md) — Trace any confirmed write back to the human behind it.
71
+ - [MCP](./mcp.md) — Expose Ablo models to MCP clients (Claude, Cursor).
68
72
 
69
73
  ## Examples
70
74
 
71
75
  - [AI SDK Tool](./examples/ai-sdk-tool.md) — Put Ablo inside an AI SDK tool call.
72
76
  - [Existing Python Backend](./examples/existing-python-backend.md) — Add multiplayer and future agent writes without replacing a Python API server.
73
77
  - [Agent + Human](./examples/agent-human.md) — Yield when a human is editing the same report.
78
+ - [Agent Scoped to One Deck](./examples/scoped-agent.md) — Scope an agent to one entity with `scope` / `parent`; realtime for just that deck.
74
79
  - [Server Agent](./examples/server-agent.md) — Schema-backed worker.
75
80
  - [Next.js](./examples/nextjs.md) — App-router setup with React bindings.
76
81
 
@@ -83,3 +88,4 @@ query-shaped sync).
83
88
  - [README](../README.md) — product overview and first example.
84
89
  - [AGENTS.md](../AGENTS.md) — short installation guidance for coding assistants.
85
90
  - [Changelog](../CHANGELOG.md) — what shipped recently.
91
+ - [Roadmap](./roadmap.md) — what's planned next.
@@ -101,10 +101,10 @@ wait: 'confirmed' }), and add a smoke test for two concurrent writers.
101
101
 
102
102
  Start with fields and relations. Keep load strategies, indexing hints, and
103
103
  read-only/mutable shortcuts out of the first version unless you already need
104
- offline-heavy local cache behavior.
104
+ them.
105
105
 
106
106
  ```ts
107
- // src/ablo.schema.ts
107
+ // src/ablo/schema.ts
108
108
  import { defineSchema, model, z } from '@abloatai/ablo/schema';
109
109
 
110
110
  export const schema = defineSchema(
@@ -119,23 +119,15 @@ export const schema = defineSchema(
119
119
  }),
120
120
  },
121
121
  {
122
- // Identity-anchored sync-group roles. The server walks these to
123
- // build each participant's allowed subscription set from the
124
- // resolved identity context. Templates and extractors are fully
125
- // consumer-controlled no hardcoded `org:` / `user:` convention
126
- // anywhere in the engine. Omit `identityRoles` entirely if your
127
- // schema doesn't need identity-derived scoping.
122
+ // Identity-anchored sync-group roles. The server walks these to build each
123
+ // participant's allowed subscription set from the resolved identity context.
124
+ // `kind` is the group prefix; `source` is the identity field to read both
125
+ // consumer-controlled, no hardcoded `org:` / `user:` convention anywhere in
126
+ // the engine. Pure data (no closures), so the schema stays JSON-serializable.
127
+ // Omit `identityRoles` entirely if you don't need identity-derived scoping.
128
128
  identityRoles: [
129
- {
130
- kind: 'tenant',
131
- template: 'org:{id}',
132
- extract: (i) => (i.organizationId ? [String(i.organizationId)] : []),
133
- },
134
- {
135
- kind: 'participant',
136
- template: 'user:{id}',
137
- extract: (i) => (i.userId ? [String(i.userId)] : []),
138
- },
129
+ identityRole({ kind: 'org', source: 'organizationId' }),
130
+ identityRole({ kind: 'user', source: 'userId' }),
139
131
  ],
140
132
  }
141
133
  );
@@ -143,11 +135,15 @@ export const schema = defineSchema(
143
135
 
144
136
  ### Declaring scope on a model
145
137
 
146
- Per-row tenancy and per-entity sync-group anchors live on the
147
- `defineModel` (or `model(...)`) options. The two halves compose: the
148
- identity roles above produce a participant's _allowed_ set; the
149
- per-model options below define how rows are filtered server-side and
150
- which sync-group label each row fans out on.
138
+ > **Canonical reference: [Identity & Sync Groups](./identity.md).** This is the
139
+ > short version — `scope` (root), `parent` (containment), `grants` (membership),
140
+ > and the model-form `scope` prop are all covered in depth there. Read it once;
141
+ > this guide only shows the minimal shape inline.
142
+
143
+ Per-row tenancy and per-entity sync-group anchors live on the `model(...)`
144
+ options. The two halves compose: the identity roles above produce a
145
+ participant's _allowed_ set; the per-model options below define how rows are
146
+ filtered server-side and which sync-group each row fans out on.
151
147
 
152
148
  ```ts
153
149
  model(
@@ -159,9 +155,9 @@ model(
159
155
  // Rows carry organization_id and bootstrap filters on it.
160
156
  orgScoped: true,
161
157
 
162
- // Per-entity sync-group anchor. Lets a scoped session narrow into
163
- // one row's scope via `syncGroupFormat.replace('{id}', rowId)`.
164
- syncGroupFormat: 'matter:{id}',
158
+ // Scope root: rows form the group `matter:<id>`. Children point at it with
159
+ // `relation.belongsTo('matters', 'matterId', { parent: true })` to inherit.
160
+ scope: 'matter',
165
161
  }
166
162
  );
167
163
  ```
@@ -178,7 +174,7 @@ Trusted runtimes can use `ABLO_API_KEY`.
178
174
  ```ts
179
175
  // src/ablo.ts
180
176
  import Ablo from '@abloatai/ablo';
181
- import { schema } from './ablo.schema';
177
+ import { schema } from './ablo/schema';
182
178
 
183
179
  export const ablo = Ablo({
184
180
  schema,
@@ -194,7 +190,7 @@ server API key in the bundle.
194
190
  'use client';
195
191
 
196
192
  import { AbloProvider } from '@abloatai/ablo/react';
197
- import { schema } from '@/ablo.schema';
193
+ import { schema } from '@/ablo/schema';
198
194
 
199
195
  export function Providers({ children }: { children: React.ReactNode }) {
200
196
  return <AbloProvider schema={schema}>{children}</AbloProvider>;
@@ -239,7 +235,7 @@ const [report] = await ablo.weatherReports.load({ where: { id: 'report_stockholm
239
235
  if (!report) throw new Error('report not found');
240
236
  ```
241
237
 
242
- Use `retrieve`, `list`, and `count` for synchronous local reads after data has
238
+ Use `retrieve`, `list`, and `count` for synchronous reads after data has
243
239
  loaded.
244
240
 
245
241
  ```ts
@@ -362,7 +358,7 @@ Use a Data Source when your app database remains the source of truth.
362
358
  ```ts
363
359
  // app/api/ablo/source/route.ts
364
360
  import { dataSource } from '@abloatai/ablo';
365
- import { schema } from '@/ablo.schema';
361
+ import { schema } from '@/ablo/schema';
366
362
  import { db } from '@/db';
367
363
 
368
364
  export const POST = dataSource({
@@ -458,10 +454,10 @@ Keep agent writes on the same schema client surface as the app.
458
454
  | `/react` | Live React selectors, provider lifecycle, presence, sync status. |
459
455
  | `/testing` | Test harnesses and deterministic mocks. |
460
456
  | `Data Source` | Keep your app database canonical. |
461
- | `persistence: 'indexeddb'` | Durable browser cache and offline queueing for apps that need it. |
457
+ | `persistence: 'indexeddb'` | Durable browser cache that survives reloads, for apps that need it. |
462
458
  | `claim` / `claimState` / `queue` | Show active work and coordinate before a write. |
463
459
  | `snapshot` + `readAt` | Reject writes based on stale state. |
464
- | `mutable`, `readOnly`, `field`, `indexed` | Advanced schema and local-cache tuning. |
460
+ | `mutable`, `readOnly`, `field`, `indexed` | Advanced schema and read tuning. |
465
461
 
466
462
  The first integration should not need most of these. Start with schema and
467
463
  model methods, then add the optional pieces where the product actually needs
@@ -472,9 +468,9 @@ them.
472
468
  | Method | Use it for |
473
469
  | ---------------------------- | ---------------------------------------------------------------- |
474
470
  | `load({ where })` | Async hydration from backing store/server. |
475
- | `retrieve(id)` | Synchronous local read of one loaded row. |
476
- | `list(options?)` | Synchronous local collection read. |
477
- | `count(options?)` | Synchronous local count. |
471
+ | `retrieve(id)` | Synchronous read of one already-loaded row. |
472
+ | `list(options?)` | Synchronous collection read of loaded rows. |
473
+ | `count(options?)` | Synchronous count of loaded rows. |
478
474
  | `create(data, options?)` | Create through the model client. |
479
475
  | `update(id, data, options?)` | Update through the model client. |
480
476
  | `delete(id, options?)` | Delete through the model client. |
@@ -51,6 +51,9 @@ await ablo.weatherReports.claim(id, async (report) => {
51
51
 
52
52
  ## Coordination
53
53
 
54
+ > Loop view only. Full claim reference — methods, the claim-state object, the
55
+ > `queue`, errors — is [Coordination](./coordination.md).
56
+
54
57
  Claims broadcast across the org. Claim a row through the flat model verb, write
55
58
  through the normal `update`, and the claim releases when the callback returns:
56
59
 
package/docs/react.md CHANGED
@@ -23,7 +23,7 @@ pool, and the engine lifecycle; everything below it reads with `useAblo`.
23
23
  'use client';
24
24
 
25
25
  import { AbloProvider } from '@abloatai/ablo/react';
26
- import { schema } from '@/ablo.schema';
26
+ import { schema } from '@/ablo/schema';
27
27
 
28
28
  export function Providers({
29
29
  children,
@@ -57,7 +57,7 @@ export function Providers({
57
57
  | `apiKey` | session/cookie | Bootstrap auth. Browser apps **omit this** — the key stays server-side. See Identity below. |
58
58
  | `fallback` | neutral spinner | Rendered during the *first* bootstrap only. Pass a branded skeleton, `null`, or `'passthrough'`. |
59
59
  | `bootstrapMode` | `'full'` | `'full'` pulls the org's baseline before ready; `'none'` skips the baseline and processes live deltas only.|
60
- | `persistence` | `'volatile'` | `'indexeddb'` opts into an offline queue + reload-surviving cache. |
60
+ | `persistence` | `'volatile'` | `'indexeddb'` opts into a durable browser cache that survives reloads. |
61
61
  | `onSessionExpired` | — | Fired after the engine has already purged on a rejected session — use for redirect-to-sign-in. |
62
62
  | `onError` | — | Engine / WebSocket / `postBootstrap` errors. Wire to Sentry / Datadog. |
63
63
 
@@ -109,7 +109,7 @@ const reports = useAblo((ablo) =>
109
109
  ablo.weatherReports.list({
110
110
  where: { projectId },
111
111
  filter: (report) => report.status !== 'ready',
112
- scope: 'live',
112
+ state: 'live',
113
113
  }),
114
114
  );
115
115
  ```
package/docs/roadmap.md CHANGED
@@ -9,15 +9,27 @@ What is shipped, what is next, and what we will not build.
9
9
  - **MCP transport** — HTTP server at `/api/mcp`.
10
10
  - **TypeScript SDK** — `@abloatai/ablo`, with React bindings.
11
11
  - **Dashboard** — keys, audit, metrics, allowed origins.
12
+ - **Schema migrations** — declarative model schema changes. Pure
13
+ diff/classify/cast-safety/backfill planning engine (`diffSchema`,
14
+ `classifyMigration`, `classifyCast`, `isAutoApplicable`) ships in the SDK;
15
+ `ablo generate` emits typed clients from a pushed schema; the server
16
+ applies and activates migrations only on success.
17
+ - **Structured error contract** — versioned (`ERROR_CONTRACT_VERSION`),
18
+ drift-guarded code registry shared across the HTTP, WebSocket, and MCP
19
+ planes, with always-on request ids and an OpenAPI error envelope.
20
+ - **Relation-driven sync groups** — membership-routed fan-out via branded
21
+ `SyncGroup` + schema-declared identity roles (schema-JSON v2).
22
+ - **Intent coordination plane** — Stripe-shaped `Intent` with per-model
23
+ `intent(id)` handles for lease/await/commit coordination between agents.
12
24
 
13
25
  ## In flight
14
26
 
15
- - **Real-time presence** — see who else is viewing/editing a model.
27
+ - **Real-time presence** — see who else is viewing/editing a model
28
+ (coordination primitives landed; presence surface in progress).
16
29
  - **Cross-instance fan-out via Redis** — pub/sub deltas at scale.
17
30
 
18
31
  ## On deck
19
32
 
20
- - **Schema migrations** — declarative model schema changes.
21
33
  - **Field-level subscriptions** — subscribe to one path, not the whole row.
22
34
  - **Bulk import/export** — CSV/JSON round-trip with chain verification.
23
35
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abloatai/ablo",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "State control API for AI agents and collaborative apps.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -20,6 +20,11 @@
20
20
  "import": "./dist/schema/index.js",
21
21
  "default": "./dist/schema/index.js"
22
22
  },
23
+ "./coordination": {
24
+ "types": "./dist/coordination/index.d.ts",
25
+ "import": "./dist/coordination/index.js",
26
+ "default": "./dist/coordination/index.js"
27
+ },
23
28
  "./react": {
24
29
  "types": "./dist/react/index.d.ts",
25
30
  "import": "./dist/react/index.js",
@@ -71,6 +76,8 @@
71
76
  "build": "npm run clean && tsc -p tsconfig.build.json",
72
77
  "pack:check": "npm_config_cache=${TMPDIR:-/tmp}/ablo-npm-cache npm pack --dry-run",
73
78
  "lint:imports": "node scripts/check-js-extensions.mjs",
79
+ "generate:errors": "tsx scripts/generate-error-docs.mts",
80
+ "lint:errors": "tsx scripts/check-error-docs.mts",
74
81
  "lint:pkg": "publint",
75
82
  "prepublishOnly": "npm run build && npm run lint:pkg",
76
83
  "test": "jest",