@abloatai/ablo 0.9.1 → 0.9.2

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.
package/docs/identity.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # Identity & Sync Groups
2
2
 
3
3
  This is the doc the Quickstart skips: **who is connecting, and which slice
4
- of shared state do they get?** If you've wired `<AbloProvider schema={schema}>`
4
+ of shared state do they get?** If you've wired `<AbloProvider client={ablo}>`
5
5
  and wondered where org / team / user actually come from — start here.
6
6
 
7
7
  ## Ablo does not do auth
@@ -37,9 +37,9 @@ runnable place, so the concepts below have code to attach to.
37
37
 
38
38
  The entire declaration surface is: `identityRoles` (who may see what), and on
39
39
  each model `scope` / `parent` / `grants` (which group a row fans out on), plus an
40
- optional `syncGroups` prop (narrowing). Read the three blocks first — a human
41
- gets their `org` / `team` scope, an agent gets one `deck` — then the sections
42
- after explain each.
40
+ optional `scope` setting on the client (narrowing). Read the three blocks first —
41
+ a human gets their `org` / `team` scope, an agent gets one `deck` — then the
42
+ sections after explain each.
43
43
 
44
44
  ```ts
45
45
  // 1. src/ablo/schema.ts — map identity → groups, and anchor each model to a group
@@ -74,23 +74,28 @@ export const schema = defineSchema(
74
74
  ```
75
75
 
76
76
  ```tsx
77
- // 2. app/providers.tsx — a HUMAN gets their full org / team scope
78
- <AbloProvider schema={schema} userId={user.id} teamIds={user.teamIds}>
77
+ // 2. app/providers.tsx — a HUMAN gets their full org / team scope.
78
+ // teamIds is set on the client you build (Ablo({ schema, teamIds: user.teamIds })),
79
+ // not passed to the provider; the provider just takes that client.
80
+ <AbloProvider client={ablo} userId={user.id}>
79
81
  {children}
80
82
  </AbloProvider>
81
83
  ```
82
84
 
83
- ```tsx
85
+ ```ts
84
86
  // 3. an AGENT run inherits its user, narrowed to the entities in play.
85
87
  // Pass the MODEL form — { decks: id } — not a hand-built `deck:<id>` string;
86
- // the engine derives the group from each model's `scope`.
87
- <AbloProvider
88
- schema={schema}
89
- userId={user.id} // ceiling: the triggering user
90
- scope={{ decks: deckId }} // floor: just the deck it's working on
91
- >
88
+ // the engine derives the group from each model's `scope`. The narrowing lives
89
+ // on the client you build, then the provider mounts it.
90
+ const ablo = Ablo({
91
+ schema,
92
+ authEndpoint: '/api/ablo-session',
93
+ scope: { decks: deckId }, // floor: just the deck it's working on
94
+ });
95
+
96
+ <AbloProvider client={ablo} userId={user.id /* ceiling: the triggering user */}>
92
97
  {children}
93
- </AbloProvider>
98
+ </AbloProvider>;
94
99
  ```
95
100
 
96
101
  That's the whole surface. The rest of this doc is the *why* behind each line.
@@ -126,8 +131,8 @@ That's why you never write per-user scope code, but you always pass an agent's
126
131
  scope at the call site. A user's org/team/user don't change per request, so
127
132
  their scope is a **rule the schema derives automatically**. An agent's reach
128
133
  depends on *what it's working on*, which is only knowable at dispatch — so you
129
- pass its `syncGroups` **at the call site, in code**. The schema's only job for
130
- entities is to declare *that* a model is
134
+ set its `scope` on the client **at the dispatch site, in code**. The schema's
135
+ only job for entities is to declare *that* a model is
131
136
  entity-scopable and *what its group is named* (`scope: 'deck'` → `deck:{id}`);
132
137
  it never declares *which* entities a given agent gets. (A human can opt into the
133
138
  same runtime narrowing — a page scoped to one deck — but by default a human's
@@ -298,45 +303,62 @@ server, never by the browser.**
298
303
 
299
304
  ## Wiring the provider
300
305
 
301
- The provider props carry the identity your server resolved. In a Next.js app,
302
- resolve the user in a Server Component and pass it down:
306
+ The identity your server resolved is carried by the client you build and the
307
+ `userId` prop. In a Next.js app, resolve the user in a Server Component and pass
308
+ it down. Build the client once (the schema, `teamIds`, and any `scope` narrowing
309
+ live here), then hand it to the provider:
310
+
311
+ ```ts
312
+ // lib/ablo.ts
313
+ import Ablo from '@abloatai/ablo';
314
+ import { schema } from '@/ablo/schema';
315
+
316
+ // Build the client from the identity your server already resolved.
317
+ // teamIds → team sync groups via identityRoles.
318
+ export function makeAblo(user: { teamIds: string[] }) {
319
+ return Ablo({
320
+ schema,
321
+ authEndpoint: '/api/ablo-session',
322
+ teamIds: user.teamIds,
323
+ });
324
+ }
325
+ ```
303
326
 
304
327
  ```tsx
305
- // app/providers.tsx — 'use client'
328
+ // app/providers.tsx
329
+ 'use client';
330
+
331
+ import { useMemo } from 'react';
306
332
  import { AbloProvider } from '@abloatai/ablo/react';
307
- import { schema } from '@/ablo/schema';
333
+ import { makeAblo } from '@/lib/ablo';
308
334
 
309
335
  export function Providers({
310
336
  children,
311
- user, // { id, teamIds } — resolved server-side from YOUR auth
337
+ user, // { id, teamIds } — resolved server-side from YOUR auth
312
338
  }: {
313
339
  children: React.ReactNode;
314
340
  user: { id: string; teamIds: string[] };
315
341
  }) {
342
+ const ablo = useMemo(() => makeAblo(user), [user.id]);
316
343
  return (
317
- <AbloProvider
318
- schema={schema}
319
- userId={user.id}
320
- teamIds={user.teamIds}
321
- fallback={<AppSkeleton />}
322
- >
344
+ <AbloProvider client={ablo} userId={user.id} fallback={<AppSkeleton />}>
323
345
  {children}
324
346
  </AbloProvider>
325
347
  );
326
348
  }
327
349
  ```
328
350
 
329
- What each identity-related prop does — and just as importantly, does *not* do:
351
+ What carries identity — and just as importantly, what does *not* set the boundary:
330
352
 
331
- | Prop | Purpose |
353
+ | Where | Purpose |
332
354
  | ------------ | ------------------------------------------------------------------------------------------------ |
333
- | `userId` | App-level participant id, used for app-owned fields and read by your `identityRole` `source`. **Not** the security boundary — the server enforces scope from the authenticated request. |
334
- | `teamIds` | Team ids expanded into team sync groups via your `identityRoles`. |
335
- | `syncGroups` | Optional. **Narrows** the subscription to a subset of what auth already allows — it can never widen it. Use it to scope a page to one entity (e.g. `['deck:abc123']`). |
355
+ | `userId` prop | App-level participant id, used for app-owned fields and read by your `identityRole` `source`. **Not** the security boundary — the server enforces scope from the authenticated request. |
356
+ | `teamIds` (on the client) | Team ids expanded into team sync groups via your `identityRoles`. |
357
+ | `scope` (on the client) | Optional. **Narrows** the subscription to a subset of what auth already allows — it can never widen it. Use it to scope a page to one entity (e.g. `{ decks: 'abc123' }`). |
336
358
 
337
359
  Because the server is the boundary, a client that changes `userId` to another
338
360
  user's id does not gain their data — the server resolves and enforces the real
339
- identity on the connection. The props are how your app *tells* Ablo who it
361
+ identity on the connection. These are how your app *tells* Ablo who it
340
362
  already authenticated, not how it *proves* it.
341
363
 
342
364
  ## Agents are participants too
@@ -374,16 +396,19 @@ decks: model({ /* … */ }, {}, { orgScoped: true, scope: 'deck' }),
374
396
  Then a run subscribes only to the entity groups for the rows it works on — a
375
397
  subset of what its user could see:
376
398
 
377
- ```tsx
378
- // agent run triggered by `user`, working on one document + one deck
379
- <AbloProvider
380
- schema={schema}
381
- // identity inherited from the triggering user (the ceiling)
382
- userId={user.id}
399
+ ```ts
400
+ // agent run triggered by `user`, working on one document + one deck.
401
+ // The narrowing lives on the client; the provider just mounts it.
402
+ const ablo = Ablo({
403
+ schema,
404
+ authEndpoint: '/api/ablo-session',
383
405
  // authority narrowed to just the entities in play (the floor).
384
406
  // Model form — keyed by model, resolved to groups via each model's `scope`.
385
- scope={{ documents: documentId, decks: deckId }}
386
- >
407
+ scope: { documents: documentId, decks: deckId },
408
+ });
409
+
410
+ // identity inherited from the triggering user (the ceiling)
411
+ <AbloProvider client={ablo} userId={user.id}>
387
412
  ```
388
413
 
389
414
  As the run touches more entities its set **accretes** to cover them; it never
@@ -419,13 +444,13 @@ runs the [Coordinating long agent work](../README.md#coordinating-long-agent-wor
419
444
  `claim` loop is, to the scoping layer, that same participant — scoped to the row
420
445
  it claimed.
421
446
 
422
- ## Narrowing to specific entities — the `scope` prop
447
+ ## Narrowing to specific entities — the `scope` setting
423
448
 
424
449
  A human gets their full membership automatically (`identityRoles`). To narrow a
425
450
  session — a page on one deck, or an agent pointed at the entities it's working
426
- on — pass `scope`. You give it the **model and id(s)**; the engine builds the
427
- group string from the model's `scope` (Half 2), so you never hand-write
428
- `deck:<id>`.
451
+ on — set `scope` on the client you build (`Ablo({ schema, scope })`). You give it
452
+ the **model and id(s)**; the engine builds the group string from the model's
453
+ `scope` (Half 2), so you never hand-write `deck:<id>`.
429
454
 
430
455
  `scope` accepts four shapes, all resolved through the schema:
431
456
 
@@ -438,14 +463,16 @@ group string from the model's `scope` (Half 2), so you never hand-write
438
463
 
439
464
  ```tsx
440
465
  // a page on one deck
441
- <AbloProvider schema={schema} userId={user.id} scope={{ decks: deckId }} />
466
+ const ablo = Ablo({ schema, authEndpoint: '/api/ablo-session', scope: { decks: deckId } });
467
+ <AbloProvider client={ablo} userId={user.id} />;
442
468
 
443
469
  // an agent working across two decks and a document
444
- <AbloProvider
445
- schema={schema}
446
- userId={user.id}
447
- scope={{ decks: [deckA, deckB], documents: docId }}
448
- />
470
+ const ablo = Ablo({
471
+ schema,
472
+ authEndpoint: '/api/ablo-session',
473
+ scope: { decks: [deckA, deckB], documents: docId },
474
+ });
475
+ <AbloProvider client={ablo} userId={user.id} />;
449
476
  ```
450
477
 
451
478
  The key is the **model** (`decks`), the value is **which id(s)** — the `deck:`
@@ -453,8 +480,8 @@ prefix comes from that model's `scope: 'deck'`, never from a string you compose.
453
480
 
454
481
  > **`scope` means one thing: sync-group scope.** It appears in two places that
455
482
  > are the same concept — the model option `scope: 'deck'` (declares a scope root,
456
- > [Half 2](#half-2--per-model-scope-row--group)) and this `scope` prop (subscribe
457
- > to it). The lifecycle filter on [`list()`](./api.md#model-methods) is a separate
483
+ > [Half 2](#half-2--per-model-scope-row--group)) and this `scope` client setting
484
+ > (subscribe to it). The lifecycle filter on [`list()`](./api.md#model-methods) is a separate
458
485
  > axis and is named **`state`** (`'live' | 'archived' | 'all'`, GitHub's
459
486
  > open/closed/all), precisely so it doesn't share the word.
460
487
 
@@ -485,9 +512,9 @@ how to reason about it.
485
512
  the room/shape and the server signs off, as in
486
513
  [Pusher's channel authorization endpoint](https://pusher.com/docs/channels/server_api/authorizing-users/),
487
514
  [ElectricSQL **gatekeeper auth**](https://github.com/electric-sql/electric/blob/main/examples/gatekeeper-auth/README.md),
488
- and Liveblocks **access tokens**. Ablo's `syncGroups` prop is the *narrowing*
489
- half of this — but it can only ever shrink the server-derived set, never grow
490
- it.
515
+ and Liveblocks **access tokens**. Ablo's client `scope` setting is the
516
+ *narrowing* half of this — but it can only ever shrink the server-derived set,
517
+ never grow it.
491
518
 
492
519
  The best practices Ablo inherits from that lineage:
493
520
 
@@ -503,9 +530,9 @@ The best practices Ablo inherits from that lineage:
503
530
  the line precisely: [token parameters are trusted and usable for access
504
531
  control; client parameters are not](https://docs.powersync.com/usage/sync-rules/advanced-topics/client-parameters).
505
532
  In Ablo terms, the identity your server vouches for is the *trusted* claim that
506
- sets scope; the provider's `userId` / `scope` props are *untrusted client
507
- input* — convenient for app-owned fields and narrowing, but never the boundary.
508
- This is why changing `userId` in the browser grants nothing.
533
+ sets scope; the `userId` prop and the client's `scope` setting are *untrusted
534
+ client input* — convenient for app-owned fields and narrowing, but never the
535
+ boundary. This is why changing `userId` in the browser grants nothing.
509
536
 
510
537
  3. **Scope by a hierarchical naming convention, declared once.** Ablo's `kind:id`
511
538
  group naming (`org:…` / `team:…` from `identityRoles`, `deck:…` from a model's
@@ -170,17 +170,49 @@ export const ablo = Ablo({
170
170
  ```
171
171
 
172
172
  Browser apps should use the React provider or a scoped session token, not a
173
- server API key in the bundle.
173
+ server API key in the bundle. Build the client first, then hand it to the
174
+ provider — `AbloProvider` takes `{ client, userId?, onError?, fallback? }`, and
175
+ nothing else (`schema`, `teamIds`, `scope`, and `authEndpoint` all live on the
176
+ client now).
177
+
178
+ ```tsx
179
+ // src/ablo-client.ts
180
+ import Ablo from '@abloatai/ablo';
181
+ import { schema } from '@/ablo/schema';
182
+
183
+ // The browser never holds the API key. The client mints a short-lived token
184
+ // from your session route (see below) and refreshes it before expiry.
185
+ export const ablo = Ablo({ schema, authEndpoint: '/api/ablo-session' });
186
+ ```
174
187
 
175
188
  ```tsx
176
189
  // app/providers.tsx
177
190
  'use client';
178
191
 
179
192
  import { AbloProvider } from '@abloatai/ablo/react';
180
- import { schema } from '@/ablo/schema';
193
+ import { ablo } from '@/ablo-client';
181
194
 
182
195
  export function Providers({ children }: { children: React.ReactNode }) {
183
- return <AbloProvider schema={schema}>{children}</AbloProvider>;
196
+ return <AbloProvider client={ablo}>{children}</AbloProvider>;
197
+ }
198
+ ```
199
+
200
+ The session route mints the scoped token server-side, where the API key lives:
201
+
202
+ ```ts
203
+ // app/api/ablo-session/route.ts
204
+ import Ablo from '@abloatai/ablo';
205
+ import { schema } from '@/ablo/schema';
206
+ import { auth } from '@/auth';
207
+
208
+ export const runtime = 'nodejs';
209
+
210
+ const sync = Ablo({ schema, apiKey: process.env.ABLO_API_KEY });
211
+
212
+ export async function POST() {
213
+ const session = await auth(); // your own auth — returns the signed-in user
214
+ const { token } = await sync.sessions.create({ user: { id: session.userId } });
215
+ return Response.json({ token });
184
216
  }
185
217
  ```
186
218
 
@@ -214,10 +246,11 @@ refreshes before expiry.
214
246
  ## 3. Read State
215
247
 
216
248
  Reads come in two flavors, and you pick based on whether you can wait.
217
- `retrieve(id)` and `list({ where })` hit the server (and hydrate the local
218
- store) — they're async, so you `await` them. `get(id)`, `getAll({ where })`,
219
- and `getCount({ where })` read the already-synced local graph synchronously, so
220
- they're the ones you call in render.
249
+ `retrieve({ id })` and `list({ where })` hit the server (and hydrate the local
250
+ store) — they're async, so you `await` them. `get(id)` (positional),
251
+ `getAll({ where })`, and `getCount({ where })` read the already-synced local
252
+ graph synchronously, so they're the ones you call in render — and the ones you
253
+ use inside a `useAblo` selector, never the async `retrieve`/`list`.
221
254
 
222
255
  Use `retrieve` when the row may not be local yet — it fetches from the server
223
256
  and waits.
@@ -345,47 +378,41 @@ For the full Python shape, see
345
378
 
346
379
  ## 7. Data Source Endpoint
347
380
 
348
- Use a Data Source when your app database remains the source of truth.
381
+ Use a Data Source when your app database remains the source of truth. Wire the
382
+ route with `dataSourceNext` and an adapter — `prismaDataSource(prisma, schema)`
383
+ or `drizzleDataSource(db, schema)`. You don't hand-write `commit`; the adapter
384
+ owns transactional commit, idempotency, and reads.
349
385
 
350
386
  ```ts
351
387
  // app/api/ablo/source/route.ts
352
- import { dataSource } from '@abloatai/ablo';
388
+ import { dataSourceNext } from '@abloatai/ablo/source/next';
389
+ import { prismaDataSource } from '@abloatai/ablo/source';
353
390
  import { schema } from '@/ablo/schema';
354
- import { db } from '@/db';
355
-
356
- export const POST = dataSource({
357
- schema,
358
- apiKey: process.env.ABLO_API_KEY,
391
+ import { prisma } from '@/db';
359
392
 
360
- authorize() {
361
- return { db };
362
- },
393
+ export const runtime = 'nodejs';
363
394
 
364
- async commit({ operations, clientTxId, context }) {
365
- const rows = await context.auth.db.transaction(async (tx) => {
366
- await tx.idempotency.upsert({ key: clientTxId });
367
- return applyOperations(tx, operations);
368
- });
395
+ export const { POST } = dataSourceNext({
396
+ schema,
397
+ apiKey: process.env.ABLO_API_KEY!,
398
+ adapter: prismaDataSource(prisma, schema),
399
+ });
400
+ ```
369
401
 
370
- return { rows };
371
- },
402
+ With Drizzle, pass `drizzleDataSource(db, schema)` instead — the adapter takes
403
+ your Drizzle `db` and the Ablo `schema` (not your table objects):
372
404
 
373
- reports: {
374
- async load({ id, context }) {
375
- return context.auth.db.report.findUnique({ where: { id } });
376
- },
405
+ ```ts
406
+ import { drizzleDataSource } from '@abloatai/ablo/source/drizzle';
377
407
 
378
- async list({ query, context }) {
379
- return context.auth.db.report.findMany({
380
- take: query.limit ?? 100,
381
- });
382
- },
383
- },
408
+ export const { POST } = dataSourceNext({
409
+ schema,
410
+ apiKey: process.env.ABLO_API_KEY!,
411
+ adapter: drizzleDataSource(db, schema),
384
412
  });
385
413
  ```
386
414
 
387
- Ablo needs your Data Source endpoint and API key. External writes can be
388
- reported through an optional `events` handler on the same route. Your app
415
+ Ablo needs your Data Source endpoint and API key. Your app
389
416
  stores one Ablo credential:
390
417
 
391
418
  ```bash
@@ -404,16 +431,18 @@ which a human might touch the same row. Wrap that work in a claim. Claims don't
404
431
  lock. If another writer holds the row, `claim` waits for them, re-reads the
405
432
  fresh row, then hands it to you — so two writers serialize instead of clobbering.
406
433
 
407
- ```ts
408
- const report = await ablo.weatherReports.retrieve({ id: reportId });
409
- if (!report) return;
434
+ A claim is a disposable handle (`await using`), not a callback: read the fresh
435
+ row off `claim.data`, do your work, and the handle auto-releases when it leaves
436
+ scope.
410
437
 
438
+ ```ts
411
439
  await using claim = await ablo.weatherReports.claim({
412
440
  id: reportId,
413
- wait: false,
414
441
  action: 'forecasting',
415
442
  });
416
443
  const claimed = claim.data;
444
+ if (!claimed) return;
445
+
417
446
  await ablo.weatherReports.update({
418
447
  id: claimed.id,
419
448
  data: { status: 'ready', forecast: await getForecast(claimed) },
@@ -464,17 +493,19 @@ them.
464
493
 
465
494
  ## Method Cheatsheet
466
495
 
467
- | Method | Use it for |
468
- | ---------------------------- | --------------------------------------------------------------------------- |
469
- | `retrieve(id)` | Async read of one row from the server (await it). |
470
- | `list({ where })` | Async read of many rows from the server (await it). |
471
- | `get(id)` | Synchronous local read of one synced row (use in render). |
472
- | `getAll({ where })` | Synchronous local read of many synced rows. |
473
- | `getCount({ where })` | Synchronous local count of synced rows. |
474
- | `create(data, options?)` | Create through the model client. |
475
- | `update(id, data, options?)` | Update through the model client. |
476
- | `delete(id, options?)` | Delete through the model client. |
477
- | `claim.state({ id })` | See who is currently working on a row (synchronous). |
478
- | `claim(id, work, options?)` | Wait for your turn, re-read, and hold the row while `work` runs. |
479
-
480
- Keep first integrations on the model methods above.
496
+ | Method | Use it for |
497
+ | -------------------------------------- | -------------------------------------------------------------------------------- |
498
+ | `retrieve({ id })` | Async read of one row from the server (await it). |
499
+ | `list({ where })` | Async read of many rows from the server (await it). |
500
+ | `get(id)` | Synchronous local read of one synced row (positional id; use in render). |
501
+ | `getAll({ where })` | Synchronous local read of many synced rows. |
502
+ | `getCount({ where })` | Synchronous local count of synced rows. |
503
+ | `create({ data, id? })` | Create through the model client. |
504
+ | `update({ id, data, ...opts })` | Update through the model client. |
505
+ | `delete({ id, ...opts })` | Delete through the model client. |
506
+ | `claim.state({ id })` | See who is currently working on a row (synchronous). |
507
+ | `claim({ id, action?, ttl? })` | Acquire a disposable handle: wait for your turn, re-read, and hold the row. |
508
+
509
+ Keep first integrations on the model methods above. Every mutation and
510
+ server-read verb takes one options object; only the synchronous `get(id)` stays
511
+ positional.
package/docs/react.md CHANGED
@@ -14,6 +14,27 @@ The React bindings ship with the main package — no extra install.
14
14
  import { useAblo } from '@abloatai/ablo/react';
15
15
  ```
16
16
 
17
+ ## Building the client
18
+
19
+ You build the Ablo client once — that's where the schema, the session endpoint,
20
+ and connection config live — then hand it to the provider. The provider takes
21
+ the already-built `client`; it no longer takes `schema`, `url`, `apiKey`, etc.
22
+ as props. This mirrors Stripe's `<Elements stripe={stripePromise}>`: construct
23
+ the thing, then pass it.
24
+
25
+ ```ts
26
+ // lib/ablo.ts
27
+ import Ablo from '@abloatai/ablo';
28
+ import { schema } from '@/ablo/schema';
29
+
30
+ // The browser never holds your API key. It mints a short-lived session token
31
+ // from your own server route (see Identity below).
32
+ export const ablo = Ablo({
33
+ schema,
34
+ authEndpoint: '/api/ablo-session',
35
+ });
36
+ ```
37
+
17
38
  ## AbloProvider
18
39
 
19
40
  Mount it once near the root of your tree. It owns the connection, the local
@@ -23,48 +44,38 @@ pool, and the engine lifecycle; everything below it reads with `useAblo`.
23
44
  'use client';
24
45
 
25
46
  import { AbloProvider } from '@abloatai/ablo/react';
26
- import { schema } from '@/ablo/schema';
47
+ import { ablo } from '@/lib/ablo';
27
48
 
28
49
  export function Providers({
29
50
  children,
30
51
  user, // resolved server-side from YOUR auth
31
52
  }: {
32
53
  children: React.ReactNode;
33
- user: { id: string; teamIds: string[] };
54
+ user: { id: string };
34
55
  }) {
35
56
  return (
36
- <AbloProvider
37
- schema={schema}
38
- userId={user.id}
39
- teamIds={user.teamIds}
40
- fallback={<AppSkeleton />}
41
- >
57
+ <AbloProvider client={ablo} userId={user.id} fallback={<AppSkeleton />}>
42
58
  {children}
43
59
  </AbloProvider>
44
60
  );
45
61
  }
46
62
  ```
47
63
 
48
- `schema` is the only required prop. The rest are situational:
49
-
50
- | Prop | Default | Purpose |
51
- | ------------------- | ---------------------- | --------------------------------------------------------------------------------------------------------- |
52
- | `schema` | — | **Required.** From `defineSchema()`. Determines the typed hook surface. |
53
- | `userId` | resolved from auth | App participant id for app-owned fields and your `identityRoles.extract`. Not the security boundary. |
54
- | `teamIds` | resolved from auth | Team ids expanded into team sync groups via `identityRoles`. |
55
- | `syncGroups` | full allowed set | **Narrows** the subscription to a subset of what auth allows (e.g. `['deck:abc123']`). Never widens it. |
56
- | `url` | hosted endpoint | WebSocket URL of the sync server (`wss://…`). Hosted apps omit it. |
57
- | `apiKey` | session/cookie | Bootstrap auth. Browser apps **omit this** the key stays server-side. See Identity below. |
58
- | `fallback` | neutral spinner | Rendered during the *first* bootstrap only. Pass a branded skeleton, `null`, or `'passthrough'`. |
59
- | `bootstrapMode` | `'full'` | `'full'` loads existing rows before the app renders, so reads are populated on first paint; `'none'` skips that initial load and only streams changes as they happen.|
60
- | `persistence` | `'volatile'` | `'indexeddb'` opts into a durable browser cache that survives reloads. |
61
- | `onSessionExpired` | — | Fired after the engine has already purged on a rejected session — use for redirect-to-sign-in. |
62
- | `onError` | — | Engine / WebSocket / `postBootstrap` errors. Wire to Sentry / Datadog. |
63
-
64
- Where `userId` / `teamIds` / `syncGroups` come from, and why the API key never
65
- reaches the browser, is the whole of
66
- [Identity & Sync Groups](./identity.md) — read that if it isn't obvious how org
67
- / team / user map to what a participant can see.
64
+ `client` is the only required prop. The rest are situational:
65
+
66
+ | Prop | Default | Purpose |
67
+ | ----------- | ---------------- | --------------------------------------------------------------------------------------------------------- |
68
+ | `client` | — | **Required.** The `Ablo({ schema, authEndpoint })` instance. It carries the schema and connection config. |
69
+ | `userId` | resolved from auth | App participant id for app-owned fields and your `identityRoles`. Not the security boundary. |
70
+ | `fallback` | neutral spinner | Rendered during the *first* bootstrap only. Pass a branded skeleton, `null`, or `'passthrough'`. |
71
+ | `onError` | | Engine / WebSocket / bootstrap errors. Wire to Sentry / Datadog. |
72
+
73
+ Everything that used to be a provider prop`schema`, `url`, `apiKey`,
74
+ `teamIds`, `syncGroups`/`scope`, `persistence`, `bootstrapMode` now lives on
75
+ the `Ablo({ ... })` client you build before mounting the provider. Where the
76
+ identity comes from, and why the API key never reaches the browser, is the whole
77
+ of [Identity & Sync Groups](./identity.md) read that if it isn't obvious how
78
+ org / team / user map to what a participant can see.
68
79
 
69
80
  ## useAblo — model client
70
81
 
package/llms.txt CHANGED
@@ -6,6 +6,12 @@ Here is the problem it solves. Two writers touch `report_stockholm` at once. The
6
6
 
7
7
  Use AI SDK for the agent loop. Use Ablo when agent reads and writes must persist, coordinate with concurrent work, and leave an audit trail.
8
8
 
9
+ ## Start here
10
+
11
+ First action when integrating into an app: run `npx ablo init --yes --framework <nextjs|vite|remix|vanilla> --storage datasource`. Agents have no TTY — `--yes` is REQUIRED or it HANGS. It scaffolds `ablo/schema.ts`, the client, the Data Source endpoint, and (for Next.js) the browser provider + session route, all on the current API. Edit the generated files rather than hand-writing from this doc.
12
+
13
+ Then PUSH — this is the step everything depends on. The server keeps its OWN copy of the schema. After you create or change `ablo/schema.ts`, run `npx ablo push` (one-shot). Until you push, EVERY write to a new or changed model fails with `server_execute_unknown_model`. (Or keep `npx ablo dev` running — it pushes on save.)
14
+
9
15
  ## Use this API
10
16
 
11
17
  ```ts
@@ -23,15 +29,16 @@ const schema = defineSchema({
23
29
 
24
30
  const ablo = Ablo({ schema, apiKey: process.env.ABLO_API_KEY });
25
31
 
26
- const report = await ablo.weatherReports.retrieve('report_stockholm');
32
+ const report = await ablo.weatherReports.retrieve({ id: 'report_stockholm' });
27
33
  if (!report) throw new Error('Row not found');
28
34
 
29
- const updated = await ablo.weatherReports.claim('report_stockholm', async (report) => {
30
- return ablo.weatherReports.update(
31
- report.id,
32
- { status: 'ready', forecast: await getForecast(report) },
33
- { wait: 'confirmed' },
34
- );
35
+ // Claim the row (waits if someone else holds it), read the fresh copy off
36
+ // `claim.data`, write, then auto-release at the end of this scope (`await using`).
37
+ await using claim = await ablo.weatherReports.claim({ id: 'report_stockholm' });
38
+ const updated = await ablo.weatherReports.update({
39
+ id: claim.data.id,
40
+ data: { status: 'ready', forecast: await getForecast(claim.data) },
41
+ wait: 'confirmed',
35
42
  });
36
43
  ```
37
44
 
@@ -46,7 +53,7 @@ For full integrations, use `integration-guide` as the canonical doc. It covers
46
53
  the same model API across Ablo-managed state, Data Source-backed app databases,
47
54
  React selectors, multiplayer, and future agent workers.
48
55
 
49
- Reads come in two flavors, and you pick by whether you can wait. `retrieve(id)`
56
+ Reads come in two flavors, and you pick by whether you can wait. `retrieve({ id })`
50
57
  (one row) and `list({ where })` (many) are async — they hit the server and return
51
58
  a Promise, so await them. `get(id)`, `getAll({ where })`, and `getCount({ where })`
52
59
  are synchronous — they read the local graph and are reactive in render, so no
@@ -68,7 +75,7 @@ first integration path.
68
75
  Multiplayer is not a separate mode. When human UI, server actions, and agents use
69
76
  the same schema client and write through `ablo.<model>`, Ablo coordinates the
70
77
  shared model stream: confirmed deltas fan out to subscribers, active claims are
71
- visible through `claim.state(id)`, and stale writes can be rejected with `readAt`.
78
+ visible through `claim.state({ id })`, and stale writes can be rejected with `readAt`.
72
79
 
73
80
  If an app writes directly to its own database outside Ablo, that write bypasses
74
81
  coordination until the app reports it through Data Source events.
@@ -76,7 +83,7 @@ coordination until the app reports it through Data Source events.
76
83
  ## Nouns
77
84
 
78
85
  - `Model client` is the typed `ablo.<model>` object generated from schema.
79
- - `Claim` holds a model row while slow work runs; `claim.state(id)` observes it.
86
+ - `Claim` holds a model row while slow work runs; `claim.state({ id })` observes it.
80
87
  - `Commit` is the durable protocol write behind `ablo.<model>.update(...)`.
81
88
  - `Receipt` confirms the commit.
82
89
 
@@ -121,7 +128,7 @@ import { prisma } from '@/lib/prisma';
121
128
  export const { POST } = dataSourceNext({
122
129
  schema,
123
130
  apiKey: process.env.ABLO_API_KEY!,
124
- adapter: prismaDataSource(prisma, schema), // or drizzleDataSource(db, tables)
131
+ adapter: prismaDataSource(prisma, schema), // or drizzleDataSource(db, schema)
125
132
  });
126
133
  ```
127
134
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abloatai/ablo",
3
- "version": "0.9.1",
3
+ "version": "0.9.2",
4
4
  "description": "State control API for AI agents and collaborative apps.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -159,7 +159,7 @@
159
159
  },
160
160
  "peerDependencies": {
161
161
  "react": "^19.0.0",
162
- "drizzle-orm": ">=0.30.0"
162
+ "drizzle-orm": ">=0.44.0"
163
163
  },
164
164
  "peerDependenciesMeta": {
165
165
  "react": {