@abloatai/ablo 0.63.0 → 0.64.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,70 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.64.0
4
+
5
+ React reads the same core objects as ordinary Ablo code. Read connection state with `ablo.status` outside React and `useAblo(ablo => ablo.status)` inside React; its type is `Ablo.Status`. Presence reads through `useAblo(ablo => ablo.presence.others)` or `ablo.presence.forModel(model, id)` now react to activity changes. The unregistered-schema selector also preserves core properties and methods.
6
+
7
+ This is a breaking API cleanup for 0.64, with no compatibility aliases:
8
+
9
+ - Replace `useSyncStatus()` with `useAblo(ablo => ablo.status)`, and `SyncStatusSnapshot` with `Ablo.Status`. Replace the client's raw `syncStatus` with `status`; raw transport details remain internal to the store.
10
+ - Replace `useSync()` with `useAblo()`. The client is available during startup; await `client.ready()` before operations that require initialization.
11
+ - Replace `usePeers()` with `useAblo(ablo => ablo.presence.others)`. Replace scoped peer reads with `useAblo(ablo => ablo.presence.forModel(model, id))`; this returns sessions, including the current session when it has activity on that record. Filter by session ID when you specifically need other sessions.
12
+ - Replace `useMutationFailureListener(listener)` with `useEffect(() => ablo?.onMutationFailure(listener), [ablo, listener])`; the payload type is `Ablo.MutationFailure`.
13
+ - Handle provider startup errors with `<AbloProvider onError={...}>` instead of `useErrorListener()`.
14
+ - Read application identity from your authentication client or application context instead of `useCurrentUserId()`. The informational `AbloProvider.userId` prop is removed; authenticated Ablo authority still comes from the client's session.
15
+ - `GroupScope` leaves the React entry point with `usePeers`; presence filtering uses model and record arguments.
16
+ - `useSyncStore`, `SyncStoreContract`, and `useReactive` are no longer React entry-point exports. Use the core client; framework integration internals remain under the humans runtime boundary.
17
+ - `ClientSideSuspense` and `DefaultFallback` are no longer public components. Supply your own UI through the provider's `fallback` prop or render from `ablo.status`.
18
+
19
+ The React entry point explicitly exports its supported bindings instead of forwarding all implementation exports through a wildcard. Existing model operations, claims, and reading-activity lifetimes remain on their core owners.
20
+
21
+ React type annotations now follow their owners. The entry point has eight runtime exports (`Ablo`, `humans`, `AbloProvider`, `createAbloReact`, `useAblo`, `usePresence`, `useMutators`, `useUndoScope`) and no standalone type exports:
22
+
23
+ | Removed React type export | Replacement |
24
+ | --------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
25
+ | `AbloOptions` | `Ablo.Options` |
26
+ | `AbloReads` | `Ablo.Reads` |
27
+ | `HumansSurface` | Infer the plugin result; low-level plugin authors can still import the type from `@abloatai/humans` |
28
+ | `AbloProviderProps` | `AbloProvider.Props` |
29
+ | `AbloReactBinding` | Infer the binding, or `ReturnType<typeof createAbloReact<Models>>` |
30
+ | `PresenceModelSelector` | Infer an inline selector against the bound schema |
31
+ | `UseAbloModelOptions` | `useAblo.Options<Row>` |
32
+ | `UseAbloModelResult` | `useAblo.Result<Row>` |
33
+ | `UseAbloHydratedModelResult` | `useAblo.Result<Row>`; handle optional data after local removal, even with `initial` |
34
+ | `InvokerFor` | `typeof mutations.model.operation` |
35
+ | `MutatorInvokers` | `useMutators.Result<typeof definitions>` |
36
+ | `UseMutatorsOptions` | `useMutators.Options<typeof schema>` |
37
+ | `UseUndoScopeResult` | `useUndoScope.Result<typeof schema>` |
38
+ | `ResolveSchema` | Existing core `Ablo.ResolveSchema`, when using module registration |
39
+ | `ResolveModelKey` | `keyof typeof schema.models` |
40
+ | `DefaultSyncShape`, `ResolveUserMeta`, `ResolveClaimMeta` | Internal registration details; use schema inference and the core presence/claim types |
41
+ | `MutateActions` | `Ablo.Mutator.Transaction<typeof schema>['mutate'][ModelKey]` |
42
+ | `ReaderActions` | `Ablo.Mutator.Transaction<typeof schema>['read'][ModelKey]` |
43
+ | `ReaderFindOptions` | Infer read options; advanced callers can import the existing type from `@abloatai/ablo/client` |
44
+
45
+ `ModelScope` is no longer re-exported by React; its existing advanced owner is `@abloatai/ablo/client`. Ordinary model reads use their `state` option.
46
+
47
+ `createAbloReact(schema)` now specializes only types and returns the existing module-level provider and hooks. It creates no React context or component identity. Hooks always read the nearest AbloProvider, including when bindings for different schemas are nested; do not call a binding's hooks below a provider for another schema. Recreating a binding no longer remounts its children. Keep the application binding at module scope for clear ownership.
48
+
49
+ Selectors now preserve equal snapshot identity, detach nested row data, and subscribe to claim events. Subscriptions survive suspended renders and catch changes between render and subscription. Initial rows match server rendering during hydration, then yield to local data; a later removal no longer restores the seed. Local cache absence remains distinct from server absence.
50
+
51
+ ## 0.63.1
52
+
53
+ React workspaces can render immediately while collaboration connects.
54
+ `useSyncStatus()` now works during provider startup, passthrough children retain
55
+ their state when the provider becomes ready, and replacing a client moves account
56
+ scope and reactive subscriptions to the new client.
57
+
58
+ The runnable account multiplayer reference combines authenticated people and
59
+ agents, account isolation, presence and claims. Its walkthrough covers client
60
+ disposal, permissions, ownership loss and cleanup, with browser tests for
61
+ competing agents, account switching and data catch-up after reconnect.
62
+
63
+ The accompanying sync-server update registers HTTP agent presence, preserves
64
+ manually held claims across writes, and reports repeated releases accurately.
65
+ It also adds organization isolation to claim activity evidence. Deploy the
66
+ server update and its database migration to receive these server-side fixes.
67
+
3
68
  ## 0.63.0
4
69
 
5
70
  Reactive model clients now expose live awareness beneath the model namespace.
package/dist/react.d.ts CHANGED
@@ -1,4 +1,4 @@
1
+ /** Core client and the explicitly supported React bindings. */
1
2
  export { Ablo, humans } from '@abloatai/humans';
2
- export type { AbloOptions, AbloReads, HumansSurface } from '@abloatai/humans';
3
- export * from '@abloatai/humans/react';
3
+ export { AbloProvider, createAbloReact, useAblo, usePresence, useMutators, useUndoScope, } from '@abloatai/humans/react';
4
4
  //# sourceMappingURL=react.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"react.d.ts","sourceRoot":"","sources":["../src/react.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAC;AAChD,YAAY,EAAE,WAAW,EAAE,SAAS,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAC9E,cAAc,wBAAwB,CAAC"}
1
+ {"version":3,"file":"react.d.ts","sourceRoot":"","sources":["../src/react.ts"],"names":[],"mappings":"AAAA,+DAA+D;AAC/D,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAC;AAChD,OAAO,EACL,YAAY,EACZ,eAAe,EACf,OAAO,EACP,WAAW,EACX,WAAW,EACX,YAAY,GACb,MAAM,wBAAwB,CAAC"}
package/dist/react.js CHANGED
@@ -1,3 +1,4 @@
1
+ /** Core client and the explicitly supported React bindings. */
1
2
  export { Ablo, humans } from '@abloatai/humans';
2
- export * from '@abloatai/humans/react';
3
+ export { AbloProvider, createAbloReact, useAblo, usePresence, useMutators, useUndoScope, } from '@abloatai/humans/react';
3
4
  //# sourceMappingURL=react.js.map
package/dist/react.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"react.js","sourceRoot":"","sources":["../src/react.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAC;AAEhD,cAAc,wBAAwB,CAAC"}
1
+ {"version":3,"file":"react.js","sourceRoot":"","sources":["../src/react.ts"],"names":[],"mappings":"AAAA,+DAA+D;AAC/D,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAC;AAChD,OAAO,EACL,YAAY,EACZ,eAAe,EACf,OAAO,EACP,WAAW,EACX,WAAW,EACX,YAAY,GACb,MAAM,wBAAwB,CAAC"}
@@ -34,6 +34,30 @@ result rather than reasoning against state that has since moved.
34
34
  The important boundary is explicit: a plain update does not claim a row and
35
35
  does not carry a stale premise. It is intentionally last-write-wins.
36
36
 
37
+ ## Claim permissions
38
+
39
+ Claims coordinate authority already granted by a session; they do not grant it.
40
+ The `can` API has no separate `claim` operation.
41
+
42
+ | Operation | Required model permission | Authority received |
43
+ |---|---|---|
44
+ | Read / list | `read` | Read authorized rows |
45
+ | Acquire a row or field claim | `update` or `delete` | Coordinate the target, with the corresponding mutation authority |
46
+ | Update through a claim | `update` | Update authorized rows; the claim guards its selected target |
47
+ | Delete through a claim | `delete` | Delete authorized rows |
48
+
49
+ For example, `can: { conversations: ['read', 'update'] }` permits updates to
50
+ **all fields on authorized conversation rows**, not just `executionOwner`.
51
+ Selecting `fields: fields => fields.executionOwner` narrows coordination,
52
+ not the session's write permission. Omit `delete` and `create` when unnecessary.
53
+ Use a subject rule and server-verified membership to restrict rows. If a worker
54
+ must only change execution state, put that state in a separately authorized
55
+ model or keep mutations behind an application endpoint that validates the patch.
56
+
57
+ For a process that outlives its starting function, see the runnable
58
+ [account multiplayer ownership lifecycle](./examples/account-multiplayer.md).
59
+ It handles contention, failed initialization, ownership loss and shared cleanup.
60
+
37
61
  ## Explicit read dependencies
38
62
 
39
63
  Pass the exact rows that produced a decision on the write:
@@ -5,18 +5,19 @@
5
5
  Serving many customers from one backend has two shapes, and the first question
6
6
  is whether isolating them is a security boundary or a routing convenience.
7
7
 
8
- **One Ablo organization per customer** is the hard boundary. Every row carries
9
- the organization, and the engine compares it on every read and every write,
10
- below your code. Choose it when one customer reading another's rows would be an
11
- incident.
8
+ **Use a model `subject` rule for account isolation inside one project.** Bind
9
+ `accountId` to the `account` group, mint sessions only after verifying membership,
10
+ and use those scoped sessions for server operations too. The complete
11
+ [account multiplayer walkthrough](./examples/account-multiplayer.md) shows this
12
+ composition and its verification path.
12
13
 
13
- **One organization, customers as rows told apart by sync groups** is delivery
14
- and read routing. It is declarative, it depends on every model being covered,
15
- and it is not enforced on every path. Choose it when cross-customer reads are
16
- tolerable or intentional, not when they are a breach.
14
+ A separate Ablo organization per customer is another tenant boundary. It is not
15
+ required merely because your application has accounts.
17
16
 
18
- The rest of this page is the second shape. Read *Where the boundary is enforced*
19
- before you rely on it.
17
+ **Sync-group routing alone is not subject authorization.** The older routing-only
18
+ example below illustrates delivery configuration; add subject rules before using
19
+ that pattern as a customer security boundary. The limitations below describe
20
+ models without subject rules, not subject-protected models.
20
21
 
21
22
  ```ts
22
23
  // 1. src/ablo/schema.ts — your customer table is a scope root.
@@ -91,8 +92,7 @@ organization, project, and branch, and all three are compared on every read and
91
92
  every write, from the credential rather than the request. A client cannot reach
92
93
  past them by asking. This is the boundary that holds unconditionally.
93
94
 
94
- **Sync groups are a cut inside your account, and they are not applied
95
- everywhere.** They decide which changes are delivered and which rows a
95
+ **Without a subject rule, sync groups are a routing cut inside your account.** They decide which changes are delivered and which rows a
96
96
  log-served read returns. That is routing. It is not a universal authorization
97
97
  boundary, and the gaps are specific:
98
98
 
@@ -0,0 +1,164 @@
1
+ # Account multiplayer
2
+
3
+ > Assemble account isolation, two humans, one agent, presence and reconnect in one runnable app.
4
+
5
+ The maintained reference lives in
6
+ [`examples/account-multiplayer`](https://github.com/Abloatai/ablo/tree/main/examples/account-multiplayer).
7
+ Start at `src/index.ts`, then follow its owned `accounts`, `agent` and `workspace`
8
+ boundaries. Its README contains the install, isolated-branch setup and test commands.
9
+
10
+ ## One account, one authorization rule
11
+
12
+ “People in this account can see its chats” requires both a row rule and a
13
+ server-verified session grant:
14
+
15
+ ```ts
16
+ const schema = defineSchema({
17
+ conversations: model({
18
+ accountId: z.string().min(1),
19
+ title: z.string(),
20
+ executionOwner: z.string().nullable(),
21
+ executionState: z.enum(['idle', 'generating']),
22
+ }, { subject: { field: 'accountId', group: 'account' } }),
23
+ });
24
+ ```
25
+
26
+ The subject rule maps the row's `accountId` to the required `account:<id>`
27
+ membership. This is authorization, distinct from optional group routing. Push
28
+ the schema to your isolated branch before using it. When connecting your own
29
+ database, apply the subject policies through the supported connection setup too.
30
+
31
+ ```ts
32
+ const POST = sessions.handler({
33
+ authenticate: request => authenticateApplicationCookie(request),
34
+ async grant({ principal, request }) {
35
+ const member = await verifyRequestedAccountMembership(principal, request);
36
+ if (!member) return null;
37
+ return {
38
+ user: { id: member.user.id },
39
+ groups: [syncGroup('account', member.accountId)],
40
+ can: { conversations: ['read'] },
41
+ };
42
+ },
43
+ });
44
+ ```
45
+
46
+ Your application authenticates the person and verifies membership on every
47
+ mint. The subject rule authorizes rows; `groups` proves membership; `can`
48
+ authorizes operations. Synchronization delivers the authorized data. A list
49
+ filter helps select a view but does not establish any of these permissions.
50
+ The provider's `userId` prop is informational, not an authentication mechanism.
51
+
52
+ ## Browser reads and server writes share the scope
53
+
54
+ The browser creates an application-owned React client with the account-specific
55
+ session endpoint. It receives only read authority. The server uses its secret key
56
+ only to mint credentials, then creates a scoped client for the actual operation:
57
+
58
+ ```ts
59
+ const session = await sessions.create({
60
+ user: { id: member.user.id },
61
+ groups: [syncGroup('account', member.accountId)],
62
+ can: { conversations: ['read', 'create', 'update'] },
63
+ });
64
+ const client = Ablo({ schema, session, transport: 'http' });
65
+ try {
66
+ await client.conversations.create({ data: {
67
+ accountId: member.accountId,
68
+ title: 'New chat', executionOwner: null, executionState: 'idle',
69
+ } });
70
+ } finally {
71
+ await client.dispose();
72
+ }
73
+ ```
74
+
75
+ Resolve `member` on the server for this request. Do not accept a submitted account
76
+ ID as proof of membership. Do not reuse a privileged singleton for account writes.
77
+
78
+ On account switching, unmount the old account tree, dispose its client, and create
79
+ a new client pointed at the newly authorized account endpoint. The example keys
80
+ the component by account and creates each client in an effect, so Strict Mode
81
+ cleanup cannot dispose an instance that a later setup reuses. See [React](../react.md)
82
+ for both singleton and component-owned patterns and startup status.
83
+
84
+ ## Long-running ownership
85
+
86
+ The example's `agent/index.ts` owns account-scoped run receipts;
87
+ `agent/execution.ts` owns the typed claim and writes. Its
88
+ `agent/lifetime.ts` owns a process handle with `done` and `stop()`; the handle can
89
+ outlive the function that started it. Server shutdown and normal stream completion
90
+ join the same completion promise.
91
+
92
+ The agent session carries account membership and `read`/`update` authority. See
93
+ [claim permissions](../coordination.md#claim-permissions): selecting claim fields
94
+ does not limit which fields that session can update.
95
+
96
+ The lifecycle follows these rules:
97
+
98
+ 1. Acquire with `contention: { mode: 'skip' }`; a null grant means another agent
99
+ owns the task. Dispose the unused client.
100
+ 2. Install heartbeat loss handling during acquisition. Loss aborts application
101
+ execution; every later model write also passes the held claim so the server
102
+ rejects stale ownership.
103
+ 3. Put initialization and the first write inside the cleanup boundary.
104
+ 4. Make execution cooperate with its abort signal. Shutdown aborts execution and
105
+ waits for it to settle before releasing ownership. Cancellation cannot undo
106
+ an external side effect already performed.
107
+ 5. Release in `finally`, and dispose in the release's own `finally`. A failed
108
+ release must never prevent client disposal. Failed release falls back to TTL
109
+ expiry; report the failure rather than presenting immediate release as certain.
110
+
111
+ The long-running agent takes an id-only lease with `claim(id, options)`, then
112
+ reads the row before each mutation and passes `reads: [current]` alongside the
113
+ claim. The lease supplies exclusion and fencing; each read supplies fresh
114
+ conflict evidence. The object-form `claim({ id, ... })` also captures a snapshot,
115
+ whose write guard remains fixed at acquisition even after your own writes.
116
+
117
+ The example claims both `executionOwner` and `executionState`, writes with the
118
+ claim, simulates generating, then stays idle while still holding ownership.
119
+ The `executionOwner` row value is historical metadata after release; the UI uses
120
+ `useAblo(client => client.conversations, id).claimed` for reactive ownership.
121
+ Both the row form and `useAblo(client => client.conversations.claim.state({ id }))`
122
+ subscribe to claim events. Applications decide how to recover
123
+ an interrupted execution-state field; a lease is not proof of ongoing generation.
124
+
125
+ Text buffering, tool execution and queued-message scheduling belong to the
126
+ application. They are deliberately outside this reference's coordination owner.
127
+
128
+ ## Presence semantics
129
+
130
+ `usePresence(client => client.conversations, id)` declares a read lease while the
131
+ chat is mounted and returns sessions, including the current connection. Two tabs
132
+ can represent one person. Count people by participant identity; use
133
+ `presenceSessionId` when displaying sessions. Human and agent identities have a
134
+ `participant.kind` as well as an ID.
135
+
136
+ A read lease means “viewing”; a claim means “owns this chat”; application execution
137
+ state means “generating.” None substitutes for the others. Navigation releases
138
+ the previous read lease. Reconnect reannounces active reads. A disconnected
139
+ participant can remain visible until lease expiry.
140
+
141
+ ## What proves the composition
142
+
143
+ The reference's tests cover membership, contention, initialization failure, first
144
+ write failure, ownership loss, duplicate cleanup, release failure and shutdown
145
+ during acquisition. Its Playwright scenario signs in two humans and an outsider,
146
+ checks forbidden account session/write requests and observes multiple tabs. Two
147
+ distinct agent identities contend on the exact conversation created by the test:
148
+ one executes and one skips, then another acquires after release. Bob renames the
149
+ chat while Alice is offline; reconnect must deliver that title to Alice. Selectors
150
+ use the conversation ID, so existing rows cannot change which chat is tested.
151
+
152
+ CI runs the browser scenario twice against the same isolated credential, alongside
153
+ the three subject-authorization journey suites. The existing journey harness
154
+ mints the key in temporary Postgres, starts real Redis and the sync server, pushes
155
+ the reference schema, and passes the credential only to the app's server process.
156
+ Missing infrastructure fails the lane. Run it from the monorepo root with
157
+ `npm run test:multiplayer --workspace=@ablo/sync-server`. This verifies the
158
+ checked-out server, not a deployed fleet; the browser suite can separately target
159
+ a deployed isolated branch.
160
+
161
+ The sync server's `subject-authorization` journeys test authorization beneath the
162
+ UI across hosted SQL/RLS, log-fold and endpoint paths, including direct-ID access,
163
+ lists, writes and claims. Run those alongside the browser test for the deployment
164
+ plane you use. A filtered UI hiding another account's row is not an isolation test.
package/docs/groups.md CHANGED
@@ -46,7 +46,7 @@ question you have.
46
46
  ablo.records.onChange((docs) => render(docs));
47
47
 
48
48
  // In React: who else is visible on this client's scoped groups?
49
- const peers = usePeers({ records: documentId });
49
+ const peers = useAblo(ablo => ablo.presence.forModel('records', documentId)) ?? [];
50
50
 
51
51
  // Stop this write if the thing I read moved while I composed it.
52
52
  const record = await ablo.records.read({ id: 's-1' });
@@ -57,10 +57,10 @@ await ablo.blocks.update({ id, data, reads: [record] });
57
57
  | Question | Channel | Arrives |
58
58
  | --- | --- | --- |
59
59
  | What do the rows say right now? | `onChange` | As deltas land, on the socket |
60
- | Who else is working here? | `usePeers` over the session/client groups | As participants connect, disconnect, or change activity |
60
+ | Who else is working here? | `useAblo(ablo => ablo.presence.others)` over the session/client groups | As participants connect, disconnect, or change activity |
61
61
  | Did the premise for **this** write move? | `reads` on the write | On that write's receipt, before it applies |
62
62
 
63
- `onChange` and `usePeers` use the reactive client's socket. `reads` rides the
63
+ `onChange` and `useAblo(ablo => ablo.presence.others)` use the reactive client's socket. `reads` rides the
64
64
  commit, so it reaches a socketless actor over HTTP too. The row returned by
65
65
  `read` privately carries its model, id, and
66
66
  watermark; passing that row in `reads` is enough to protect a later write. Ablo
@@ -183,6 +183,6 @@ them coarse everywhere else.
183
183
  [`concurrency-convention.md`](./concurrency-convention.md) (§4 and §5).
184
184
  - **The mechanics**, the three coordination blocks underneath, are
185
185
  [`coordination.md`](./coordination.md).
186
- - **Presence** is read with `usePeers`; active exclusions remain on the
186
+ - **Presence** is read with `useAblo(ablo => ablo.presence.others)`; active exclusions remain on the
187
187
  `claim` namespace. See [`react.md`](./react.md) and
188
188
  [`coordination.md`](./coordination.md).
package/docs/identity.md CHANGED
@@ -6,6 +6,9 @@ This is the doc the Quickstart skips: **who is connecting, and which slice
6
6
  of shared state do they get?** If you've wired `<AbloProvider client={ablo}>`
7
7
  and wondered where org / team / user actually come from — start here.
8
8
 
9
+ For account-scoped humans and agents together, start with the maintained
10
+ [account multiplayer walkthrough](./examples/account-multiplayer.md).
11
+
9
12
  ## Ablo does not do auth
10
13
 
11
14
  Ablo is not an identity provider. It has no login, no password store, no
@@ -582,7 +585,7 @@ an agent pointed at the entities it's working on. You **never hand-write**
582
585
  So an agent's reachable set **accretes** as it works — no extra subscribe call.
583
586
 
584
587
  3. **Presence follows those same groups.** A reactive client announces one
585
- participant on its connection; `usePeers(scope)` filters the roster already
588
+ participant on its connection; `ablo.presence.forModel(model, id)` filters the roster already
586
589
  visible through the session and connection groups. There is no second
587
590
  membership lease or participant handle. Use `claim` separately when work needs
588
591
  exclusion. See [React](./react.md#usepeers-read-only-presence).
package/docs/index.md CHANGED
@@ -77,3 +77,6 @@ Ablo is designed to be implemented by agents as well as people. Use
77
77
  index, or connect an assistant to the [documentation MCP server](./mcp.md). The
78
78
  coordination MCP package also ships its agent-facing skill as
79
79
  `@abloatai/mcp/skill.md`.
80
+
81
+ For a complete human-and-agent application, follow the
82
+ [account multiplayer reference](./examples/account-multiplayer.md).
@@ -24,6 +24,9 @@ Three things hold no matter which actor is writing:
24
24
  credential scoped to just what that run can touch, verified per request and
25
25
  revocable instantly. (See the Agents section below for the actual calls.)
26
26
 
27
+ For account-scoped humans and agents together, start with the maintained
28
+ [account multiplayer walkthrough](./examples/account-multiplayer.md).
29
+
27
30
  ## The integration in one diagram
28
31
 
29
32
  The normal integration is one client:
package/docs/react.md CHANGED
@@ -16,6 +16,54 @@ The React bindings ship with the main package — no extra install.
16
16
  import { useAblo } from '@abloatai/ablo/react';
17
17
  ```
18
18
 
19
+ React uses the same objects and operations as core Ablo. Read `ablo.status`
20
+ with `useAblo(ablo => ablo.status)`, select `ablo.presence.others` for other
21
+ sessions, and call ordinary model methods from event handlers. React owns the
22
+ subscription or component lifetime; the client owns the behavior.
23
+
24
+ For mutation failures, subscribe to the core event in an effect:
25
+
26
+ ```tsx
27
+ const ablo = useAblo();
28
+ useEffect(() => ablo?.onMutationFailure(reportFailure), [ablo, reportFailure]);
29
+ ```
30
+
31
+ `reportFailure` receives `Ablo.MutationFailure`. Read application identity from
32
+ your auth provider; Ablo's authenticated authority comes from its session.
33
+
34
+ ## Types follow their owners
35
+
36
+ Most calls infer their types from your schema. For wrapper components or explicit
37
+ annotations, use the name you already imported:
38
+
39
+ ```tsx
40
+ import { Ablo, AbloProvider, useAblo } from '@abloatai/ablo/react';
41
+ import { schema } from './schema';
42
+
43
+ type Models = (typeof schema)['models'];
44
+ type Chat = Ablo.Schema.InferRow<typeof schema, 'chats'>;
45
+
46
+ function Provider(props: AbloProvider.Props<Models>) {
47
+ return <AbloProvider {...props} />;
48
+ }
49
+
50
+ function ChatView({ result }: { result: useAblo.Result<Chat> }) {
51
+ return <p>{result.data?.title}</p>;
52
+ }
53
+ ```
54
+
55
+ The React annotation types are `AbloProvider.Props`, `useAblo.Options`,
56
+ `useAblo.Result`, `useMutators.Options`, `useMutators.Result` and
57
+ `useUndoScope.Result`. Core types remain under `Ablo`, including `Ablo.Options`,
58
+ `Ablo.Reads`, `Ablo.Status` and `Ablo.PresenceSession`. Import annotation owners
59
+ from the SDK entry point; functions destructured from an app binding infer their
60
+ call types but do not create new TypeScript namespaces in the app module.
61
+
62
+ `createAbloReact(schema)` specializes types and returns the existing provider and
63
+ hooks. Define your binding at module scope. It creates no client, React context
64
+ or component identity. Hooks always read the nearest provider: use the binding
65
+ that matches that provider's client schema.
66
+
19
67
  ## Building the client
20
68
 
21
69
  You build the Ablo client once — that's where the schema, the session endpoint,
@@ -49,8 +97,9 @@ react-redux's typed hooks.
49
97
 
50
98
  ## AbloProvider
51
99
 
52
- Mount it once near the root of your tree. It owns the connection, the local
53
- pool, and the engine lifecycle; everything below it reads with `useAblo`.
100
+ Mount it once near the root of your tree. **The application owns the client
101
+ and must dispose it.** The provider starts readiness and binds React to the
102
+ client; unmounting the provider does not dispose a shared client.
54
103
 
55
104
  ```tsx
56
105
  'use client';
@@ -89,6 +138,73 @@ identity comes from, and why the API key never reaches the browser, is the whole
89
138
  of [Identity & Sync Groups](./identity.md) — read that if it isn't obvious how
90
139
  org / team / user map to what a participant can see.
91
140
 
141
+ ## Client lifetime and account switching
142
+
143
+ For an application-owned singleton, create it once as above. Your application
144
+ session owner calls `await ablo.dispose()` on logout or before replacing that
145
+ client. Provider remounts can reuse it. Never share a browser singleton across
146
+ server requests. When changing accounts, remove the old account UI and create a
147
+ fresh client whose session endpoint grants the newly verified membership.
148
+ A query filter and the provider's `userId` prop do not change authorization.
149
+
150
+ For a component-owned client, create and dispose the instance in the same effect.
151
+ React Strict Mode can replay setup and cleanup, so each setup creates a fresh
152
+ instance rather than reusing one that cleanup already disposed:
153
+
154
+ ```tsx
155
+ const createClient = (accountId: string) => Ablo({
156
+ schema,
157
+ persistence: 'memory',
158
+ session: { endpoint: `/api/accounts/${encodeURIComponent(accountId)}/ablo-session` },
159
+ });
160
+
161
+ function AccountWorkspace({ accountId }: { accountId: string }) {
162
+ const [owned, setOwned] = useState<{
163
+ accountId: string;
164
+ client: ReturnType<typeof createClient>;
165
+ } | null>(null);
166
+
167
+ useEffect(() => {
168
+ const client = createClient(accountId);
169
+ setOwned({ accountId, client });
170
+ return () => { void client.dispose().catch(reportError); };
171
+ }, [accountId]);
172
+
173
+ // Never render an old account's client under the new account's heading.
174
+ if (!owned || owned.accountId !== accountId) return <AppSkeleton />;
175
+ return <AbloProvider key={accountId} client={owned.client}><Workspace /></AbloProvider>;
176
+ }
177
+ ```
178
+
179
+ Import `useEffect` and `useState` from React. The endpoint must verify membership
180
+ for the requested account on every mint; the URL itself grants no access.
181
+ See the [account multiplayer walkthrough](./examples/account-multiplayer.md)
182
+ for the complete ownership boundary and runnable component.
183
+
184
+ ## Render immediately with connection status
185
+
186
+ `useAblo(ablo => ablo.status)` works during provider startup, in passthrough children and in
187
+ custom fallbacks. It observes the client's status before row scope is available.
188
+ It still requires a provider. Data hooks that require authenticated scope must
189
+ wait for readiness.
190
+
191
+ ```tsx
192
+ import { useAblo } from '@abloatai/ablo/react';
193
+
194
+ function ConnectionIndicator() {
195
+ const status = useAblo(ablo => ablo.status);
196
+ return <span role="status">{
197
+ status?.name === 'initial' || status?.name === 'connecting'
198
+ ? 'Connecting…' : status?.name
199
+ }</span>;
200
+ }
201
+
202
+ <AbloProvider client={ablo} fallback="passthrough">
203
+ <ConnectionIndicator />
204
+ <ExistingWorkspace />
205
+ </AbloProvider>
206
+ ```
207
+
92
208
  ## useAblo: model client
93
209
 
94
210
  ```tsx
@@ -97,23 +213,40 @@ org / team / user map to what a participant can see.
97
213
  import { useAblo } from '@abloatai/ablo/react';
98
214
 
99
215
  export function ReportView({ report: serverReport }: { report: { id: string; location: string } }) {
100
- const report = useAblo((ablo) => ablo.weatherReports.local.get(serverReport.id)) ?? serverReport;
101
- const active = useAblo((ablo) => ablo.weatherReports.claim.state({ id: serverReport.id }));
102
- const claimed = Boolean(active);
216
+ const { data: report, claimed } = useAblo(
217
+ ablo => ablo.weatherReports,
218
+ serverReport.id,
219
+ { initial: serverReport },
220
+ );
103
221
 
104
- return <article>{report.location}</article>;
222
+ if (!report) return <p>This report is not in the local cache.</p>;
223
+ return <article>{report.location}{claimed && <span>Claimed</span>}</article>;
105
224
  }
106
225
  ```
107
226
 
108
- The hook:
227
+ The row form subscribes to both data and claim events. It returns `data`,
228
+ `claims`, and `claimed`, and accepts an initial server-rendered row.
229
+
230
+ For data-only reads, selectors such as
231
+ `useAblo(ablo => ablo.weatherReports.local.get(id))` track model fields.
232
+ Claim selectors subscribe too: `useAblo(ablo => ablo.weatherReports.claim.state({ id }))`
233
+ updates when ownership changes. Use the row form when you want data and claims together.
234
+
235
+ Selectors must be pure synchronous reads. Ablo copies selected rows, arrays and plain
236
+ records into frozen snapshots, including nested data and computed fields. Unchanged
237
+ data retains its snapshot identity across renders. Treat copied dates as read-only;
238
+ model API handles and other class instances retain their original identity. Read
239
+ data inside the selector to subscribe to it. Selector errors reach React error boundaries.
109
240
 
110
- 1. Uses the same `ablo.<model>.local.get(id)` / `.local.list()` methods you'd call anywhere
111
- else in the SDK the hook just makes them reactive.
112
- 2. Tracks the model fields read by the selector and re-renders when confirmed
113
- deltas arrive.
114
- 3. Lets Server Component data stay outside the hook: use `?? serverReport` when a
115
- parent already loaded the row.
116
- 4. Works for coordination state too, such as `ablo.weatherReports.claim.state({ id })`.
241
+ `initial` supplies the server HTML and first hydration render; React then reads the
242
+ current local row. Once a local row has appeared, removing it returns `undefined`
243
+ instead of restoring the seed, so `data` remains optional even with `initial`.
244
+
245
+ A local cache miss (`undefined` or an empty list) does not prove that the server has
246
+ no matching data. Local reads do not fetch rows, and connection status does not
247
+ indicate query completeness. Use your route loader and an awaited server read, such
248
+ as `await ablo.weatherReports.get({ id })`, to establish server results and handle
249
+ request errors before passing an initial row to React.
117
250
 
118
251
  Use the zero-argument form only when you need the full client for callbacks,
119
252
  effects, or writes:
@@ -165,7 +298,7 @@ await ablo.weatherReports.update({
165
298
  });
166
299
  ```
167
300
 
168
- For client event handlers, get the provider-owned client and call the same
301
+ For client event handlers, get the application-owned client and call the same
169
302
  model client:
170
303
 
171
304
  ```tsx
@@ -227,6 +360,15 @@ current session. Use `session.participant` for identity and inspect
227
360
  `session.activities` when the UI needs to distinguish reading from claiming or
228
361
  writing.
229
362
 
363
+ A presence session is a connection, not a unique person. Two tabs can share
364
+ `participant.id` while having different `presenceSessionId` values. For a people
365
+ count, deduplicate by both `participant.kind` and `participant.id`; retain the
366
+ sessions when displaying connection details. Reading declares attention; claiming
367
+ declares ownership. Neither proves that an agent is generating text. Label a held
368
+ claim “Agent owns this chat.” Drive “Generating” from application execution state.
369
+ On navigation, the hook releases its old read lease. After a lost connection,
370
+ remote presence may remain until its lease expires; disappearance is not immediate.
371
+
230
372
  ## Model events: cursors and selections
231
373
 
232
374
  Use the `events` namespace already attached to each model for transient UI
@@ -262,30 +404,28 @@ durable model fields when state must survive reconnects. The sending connection
262
404
  does not receive its own event. Coalesce or throttle pointer movement in the
263
405
  application; model events do not currently declare a per-event `maxHz`.
264
406
 
265
- ## usePeers: read-only presence
407
+ ## Presence: the same core read
266
408
 
267
- `usePeers` reads the presence stream already flowing for the client's scoped
268
- groups. It does not create a second membership or lease and does not change the
269
- connection's subscriptions.
409
+ Select `ablo.presence.others` for the other sessions visible to this client, or
410
+ filter by a model and record. These reads subscribe to changes without starting
411
+ an activity or claiming ownership.
270
412
 
271
413
  ```tsx
272
- 'use client';
273
-
274
- import { usePeers } from '@abloatai/ablo/react';
414
+ import { useAblo } from '@abloatai/ablo/react';
275
415
 
276
- export function CursorBroadcaster({ workspaceId }: { workspaceId: string }) {
277
- const peers = usePeers({ entryCollections: workspaceId });
278
- const alone = !peers.some((p) => p.participantKind === 'user');
279
- // suppress live-cursor broadcasts while alone
416
+ function Readers({ conversationId }: { conversationId: string }) {
417
+ const sessions = useAblo(ablo => ablo.presence.forModel('conversations', conversationId)) ?? [];
418
+ const people = new Set(sessions.filter(session => session.participant.kind === 'user')
419
+ .map(session => session.participant.id));
420
+ return <span>{people.size} people, {sessions.length} sessions</span>;
280
421
  }
281
422
  ```
282
423
 
283
- Pass a schema-shaped group scope to filter the visible peers, or omit it for
284
- everyone on the client's groups. Returns `ReadonlyArray<Peer>`, where each `Peer` carries
285
- `participantKind` (`'user' | 'agent' | 'system'`), `participantId`, optional
286
- `label`, `syncGroups`, `activity`, `lastActive`, and optional `activeClaims`.
287
- Use `ablo.<model>.claim` when the caller needs exclusion; reading presence does
288
- not claim anything.
424
+ `usePresence(ablo => ablo.conversations, conversationId)` additionally owns a
425
+ reading activity for the component's lifetime. Use it when mounting the component
426
+ should announce that this session is reading the record. Core code owns the same
427
+ lifecycle explicitly with `ablo.conversations.presence.read(conversationId)` and
428
+ its returned cleanup function.
289
429
 
290
430
  ## Next.js
291
431
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abloatai/ablo",
3
- "version": "0.63.0",
3
+ "version": "0.64.0",
4
4
  "description": "The public Ablo SDK for coordinated reads, commits, claims, observation, and reactive applications.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -145,8 +145,8 @@
145
145
  "directory": "packages/ablo"
146
146
  },
147
147
  "dependencies": {
148
- "@abloatai/humans": "^0.63.0",
149
- "@abloatai/transaction": "^0.63.0",
148
+ "@abloatai/humans": "^0.64.0",
149
+ "@abloatai/transaction": "^0.64.0",
150
150
  "zod": "^4.4.3"
151
151
  },
152
152
  "peerDependencies": {