@abloatai/ablo 0.62.0 → 0.63.1

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,43 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.63.1
4
+
5
+ React workspaces can render immediately while collaboration connects.
6
+ `useSyncStatus()` now works during provider startup, passthrough children retain
7
+ their state when the provider becomes ready, and replacing a client moves account
8
+ scope and reactive subscriptions to the new client.
9
+
10
+ The runnable account multiplayer reference combines authenticated people and
11
+ agents, account isolation, presence and claims. Its walkthrough covers client
12
+ disposal, permissions, ownership loss and cleanup, with browser tests for
13
+ competing agents, account switching and data catch-up after reconnect.
14
+
15
+ The accompanying sync-server update registers HTTP agent presence, preserves
16
+ manually held claims across writes, and reports repeated releases accurately.
17
+ It also adds organization isolation to claim activity evidence. Deploy the
18
+ server update and its database migration to receive these server-side fixes.
19
+
20
+ ## 0.63.0
21
+
22
+ Reactive model clients now expose live awareness beneath the model namespace.
23
+ `usePresence((ablo) => ablo.chats, chatId)` owns the mounted view's read
24
+ activity, refresh, reconnect announcement, and cleanup, and returns the human
25
+ and agent sessions active on that record. Multiple tabs remain distinct
26
+ authenticated sessions.
27
+
28
+ Transient collaboration now uses `ablo.<model>.events`. Applications can send
29
+ and subscribe to cursor, selection, and similar signals by record id without
30
+ putting routing or caller-authored identity in the payload. Ablo derives the
31
+ record sync group, routes only to other connections in that group, and supplies
32
+ the authenticated participant, presence session, and server timestamp to the
33
+ receiver.
34
+
35
+ Model events are deliberately lossy: disconnected sends are dropped and
36
+ events are not replayed after reconnect. Persist state that must recover as
37
+ ordinary model data, and throttle high-frequency pointer updates in the
38
+ application. The legacy store-level collaboration-event API remains available
39
+ and now receives optional authenticated context from updated servers.
40
+
3
41
  ## 0.62.0
4
42
 
5
43
  Every live client now exposes one session-owned `ablo.presence` projection.
package/docs/api.md CHANGED
@@ -66,6 +66,9 @@ Each schema model becomes a typed model on the client:
66
66
  - `ablo.weatherReports.update({ id, data, ...options })` updates a row.
67
67
  - `ablo.weatherReports.delete({ id, ...options })` deletes a row.
68
68
  - `ablo.weatherReports.claim({ id, description })` acquires a durable write lease; the HTTP form is awaited.
69
+ - `ablo.weatherReports.presence(id)` reads the live session projection for one row on a reactive client.
70
+ - `ablo.weatherReports.events.send(id, name, payload)` sends a transient row-scoped event on a reactive client.
71
+ - `ablo.weatherReports.events.subscribe(id, name, handler)` subscribes to that transient event and returns a disposer.
69
72
 
70
73
  `local.` narrows a query to what has already synced. `get({ id })`, `read({ id })`, and
71
74
  `list({ where })` answer from the local graph and fall back to IndexedDB and
@@ -91,11 +94,48 @@ fallback removed — nothing to await, so they return a value.
91
94
  | `claim.queue({ id })` | `Promise<ClaimQueueView>` on HTTP | You need the durable wait line. |
92
95
  | `claim.release({ id })` | `Promise<void>` on HTTP | You need to release a claim early. |
93
96
  | `claim.reorder({ id, order })` | `Promise<void>` on HTTP | A privileged coordinator needs to reorder the wait line. |
97
+ | `presence(id?)` | `readonly PresenceSession[]` on reactive clients | You need the sessions currently active on a model or row. |
98
+ | `events.send(id, name, payload)` | `void` on reactive clients | You need to send a cursor, selection, or other transient signal. |
99
+ | `events.subscribe(id, name, handler)` | `() => void` on reactive clients | You need transient signals for one row until cleanup. |
94
100
 
95
101
  `get`, `read`, `list`, `create`, `update`, `delete`, and `claim` go
96
102
  through the server. The `local` reads work off the rows a session has already
97
103
  synced, so a cheap re-read needs no round-trip.
98
104
 
105
+ ### Live presence and model events
106
+
107
+ Presence and events belong to the reactive WebSocket client. They are not on
108
+ the stateless HTTP client used by server-side agents and workers.
109
+
110
+ Use presence for who is active on a row. In React, `usePresence` also owns the
111
+ mounted component's read activity and cleanup:
112
+
113
+ ```tsx
114
+ const viewers = usePresence((ablo) => ablo.chats, chatId);
115
+ ```
116
+
117
+ Use model events for short-lived UI detail that should not become a database
118
+ field:
119
+
120
+ ```ts
121
+ const stop = ablo.files.events.subscribe(fileId, 'cursor', (cursor, context) => {
122
+ renderCursor(context.sender.presenceSessionId, cursor);
123
+ });
124
+
125
+ ablo.files.events.send(fileId, 'cursor', { line: 12, column: 4 });
126
+ stop();
127
+ ```
128
+
129
+ The model and row determine the sync group. Ablo excludes the sending
130
+ connection, validates the wire envelope, and supplies authenticated
131
+ `context.sender` and `context.sentAt`; identity does not belong in the payload.
132
+ Event names are strings and payloads are objects. Payloads are not inferred
133
+ from the schema today.
134
+
135
+ Delivery is lossy: sending while disconnected is dropped, and events are not
136
+ replayed after reconnect. Cursor and live selection fit this contract. Persist
137
+ anything that must be recovered as ordinary model data.
138
+
99
139
  ## Atomic commits
100
140
 
101
141
  Use one `ablo.commits.create` when several Ablo model writes must all land or
@@ -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
+ The row form subscribes to claim events; a selector-only call to `claim.state`
122
+ is only a snapshot and can miss ownership changes. 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/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
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:
@@ -523,6 +526,8 @@ that guarantee as eventual completion with repair—not atomicity.
523
526
  | `persistence: 'indexeddb'` | Durable browser cache that survives reloads, for apps that need it. |
524
527
  | `durableWrites: { store, namespace? }` | Recover unacknowledged worker writes after a process restart. |
525
528
  | `claim` / `claim.state` / `claim.queue` | Show active work and coordinate before a write. |
529
+ | `usePresence` / `<model>.presence` | Show the human and agent sessions active on a row. |
530
+ | `<model>.events` | Send lossy row-scoped cursor, selection, and similar UI signals. |
526
531
  | `read` + `reads` | Reject writes based on stale state. |
527
532
  | `mutable`, `readOnly`, `field`, `indexed` | Advanced schema and read tuning. |
528
533
 
@@ -546,7 +551,16 @@ them.
546
551
  | `delete({ id, ...opts })` | Delete through the model client. |
547
552
  | `claim.state({ id })` | See who is currently working on a row (synchronous). |
548
553
  | `claim({ id, description?, ttl? })` | Acquire a disposable handle: wait for your turn, re-read, and hold the row. |
554
+ | `presence(id?)` | Read live human and agent sessions on a reactive client. |
555
+ | `events.send(id, name, payload)` | Send a transient row-scoped signal; disconnected sends are dropped. |
556
+ | `events.subscribe(id, name, handler)` | Listen until its returned disposer is called; events are not replayed. |
549
557
 
550
558
  Keep first integrations on the model methods above. Every mutation and
551
559
  server-read verb takes one options object; the synchronous `local.get(id)` stays
552
560
  positional.
561
+
562
+ Presence and events require the reactive WebSocket client. Server-side agents
563
+ using the stateless HTTP client still coordinate through reads, claims, and
564
+ writes, but they do not open a cursor/selection event channel. Event payloads
565
+ carry application data only; Ablo derives the model scope and supplies
566
+ authenticated participant context to receivers.
package/docs/options.md CHANGED
@@ -200,7 +200,23 @@ connection follows, but it cannot widen the authority granted by
200
200
 
201
201
  ## collaborationEvents
202
202
 
203
- Application-defined WebSocket event names accepted by `subscribe()`.
203
+ Legacy store-level WebSocket event names accepted by `subscribe()`. New React
204
+ and reactive-client code should prefer the record-scoped
205
+ `ablo.<model>.events.send(...)` and `.subscribe(...)` surface. A
206
+ collaboration-event handler receives the application's unchanged payload first
207
+ and optional server-authenticated context second:
208
+
209
+ ```ts
210
+ store.subscribe('document:cursor', (cursor, context) => {
211
+ context?.sender.presenceSessionId;
212
+ context?.sender.participant; // { id, kind }
213
+ context?.sentAt;
214
+ });
215
+ ```
216
+
217
+ The context is optional while connecting to a server version that predates
218
+ authenticated collaboration-event attribution. Never put caller-authored user
219
+ identity in the application payload.
204
220
 
205
221
  ## cursorStore
206
222
 
package/docs/react.md CHANGED
@@ -39,18 +39,19 @@ export const ablo = Ablo({
39
39
  // The typed binding: capture the schema once, and every component imports
40
40
  // born-typed hooks from this file — `useAblo()` takes no type arguments,
41
41
  // and a selector's `ablo` parameter knows your models.
42
- export const { AbloProvider, useAblo } = createAbloReact(schema);
42
+ export const { AbloProvider, useAblo, usePresence } = createAbloReact(schema);
43
43
  ```
44
44
 
45
- Import `AbloProvider` and `useAblo` from `lib/ablo` rather than from the
45
+ Import `AbloProvider`, `useAblo`, and `usePresence` from `lib/ablo` rather than from the
46
46
  package, and the schema generic never appears at a call site again — the
47
47
  same one-binding-file convention as tRPC's `createTRPCReact` or
48
48
  react-redux's typed hooks.
49
49
 
50
50
  ## AbloProvider
51
51
 
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`.
52
+ Mount it once near the root of your tree. **The application owns the client
53
+ and must dispose it.** The provider starts readiness and binds React to the
54
+ client; unmounting the provider does not dispose a shared client.
54
55
 
55
56
  ```tsx
56
57
  'use client';
@@ -89,6 +90,73 @@ identity comes from, and why the API key never reaches the browser, is the whole
89
90
  of [Identity & Sync Groups](./identity.md) — read that if it isn't obvious how
90
91
  org / team / user map to what a participant can see.
91
92
 
93
+ ## Client lifetime and account switching
94
+
95
+ For an application-owned singleton, create it once as above. Your application
96
+ session owner calls `await ablo.dispose()` on logout or before replacing that
97
+ client. Provider remounts can reuse it. Never share a browser singleton across
98
+ server requests. When changing accounts, remove the old account UI and create a
99
+ fresh client whose session endpoint grants the newly verified membership.
100
+ A query filter and the provider's `userId` prop do not change authorization.
101
+
102
+ For a component-owned client, create and dispose the instance in the same effect.
103
+ React Strict Mode can replay setup and cleanup, so each setup creates a fresh
104
+ instance rather than reusing one that cleanup already disposed:
105
+
106
+ ```tsx
107
+ const createClient = (accountId: string) => Ablo({
108
+ schema,
109
+ persistence: 'memory',
110
+ session: { endpoint: `/api/accounts/${encodeURIComponent(accountId)}/ablo-session` },
111
+ });
112
+
113
+ function AccountWorkspace({ accountId }: { accountId: string }) {
114
+ const [owned, setOwned] = useState<{
115
+ accountId: string;
116
+ client: ReturnType<typeof createClient>;
117
+ } | null>(null);
118
+
119
+ useEffect(() => {
120
+ const client = createClient(accountId);
121
+ setOwned({ accountId, client });
122
+ return () => { void client.dispose().catch(reportError); };
123
+ }, [accountId]);
124
+
125
+ // Never render an old account's client under the new account's heading.
126
+ if (!owned || owned.accountId !== accountId) return <AppSkeleton />;
127
+ return <AbloProvider key={accountId} client={owned.client}><Workspace /></AbloProvider>;
128
+ }
129
+ ```
130
+
131
+ Import `useEffect` and `useState` from React. The endpoint must verify membership
132
+ for the requested account on every mint; the URL itself grants no access.
133
+ See the [account multiplayer walkthrough](./examples/account-multiplayer.md)
134
+ for the complete ownership boundary and runnable component.
135
+
136
+ ## Render immediately with connection status
137
+
138
+ `useSyncStatus()` works during provider startup, in passthrough children and in
139
+ custom fallbacks. It observes the client's status before row scope is available.
140
+ It still requires a provider. Data hooks that require authenticated scope must
141
+ wait for readiness.
142
+
143
+ ```tsx
144
+ import { useSyncStatus } from '@abloatai/ablo/react';
145
+
146
+ function ConnectionIndicator() {
147
+ const status = useSyncStatus();
148
+ return <span role="status">{
149
+ status.name === 'initial' || status.name === 'connecting'
150
+ ? 'Connecting…' : status.name
151
+ }</span>;
152
+ }
153
+
154
+ <AbloProvider client={ablo} fallback="passthrough">
155
+ <ConnectionIndicator />
156
+ <ExistingWorkspace />
157
+ </AbloProvider>
158
+ ```
159
+
92
160
  ## useAblo: model client
93
161
 
94
162
  ```tsx
@@ -97,23 +165,23 @@ org / team / user map to what a participant can see.
97
165
  import { useAblo } from '@abloatai/ablo/react';
98
166
 
99
167
  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);
168
+ const { data: report, claimed } = useAblo(
169
+ ablo => ablo.weatherReports,
170
+ serverReport.id,
171
+ { initial: serverReport },
172
+ );
103
173
 
104
- return <article>{report.location}</article>;
174
+ return <article>{report.location}{claimed && <span>Claimed</span>}</article>;
105
175
  }
106
176
  ```
107
177
 
108
- The hook:
178
+ The row form subscribes to both data and claim events. It returns `data`,
179
+ `claims`, and `claimed`, and accepts an initial server-rendered row.
109
180
 
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 })`.
181
+ For data-only reads, selectors such as
182
+ `useAblo(ablo => ablo.weatherReports.local.get(id))` track model fields.
183
+ A selector-only `claim.state({ id })` read does not subscribe to the claim event
184
+ stream. Use the row form above when the UI displays ownership.
117
185
 
118
186
  Use the zero-argument form only when you need the full client for callbacks,
119
187
  effects, or writes:
@@ -165,7 +233,7 @@ await ablo.weatherReports.update({
165
233
  });
166
234
  ```
167
235
 
168
- For client event handlers, get the provider-owned client and call the same
236
+ For client event handlers, get the application-owned client and call the same
169
237
  model client:
170
238
 
171
239
  ```tsx
@@ -188,6 +256,89 @@ imperative work after an event or effect.
188
256
 
189
257
  See [API reference](/docs/api) for the full options surface.
190
258
 
259
+ ## usePresence: viewers and active participants
260
+
261
+ `usePresence` declares that the mounted component is reading one model record
262
+ and returns the live sessions active on that record. Use the selector form with
263
+ the schema-bound hook:
264
+
265
+ ```tsx
266
+ import { usePresence } from '@/lib/ablo';
267
+
268
+ export function ChatView({ chatId }: { chatId: string }) {
269
+ const viewers = usePresence((ablo) => ablo.chats, chatId);
270
+
271
+ return viewers.map((session) => (
272
+ <Avatar
273
+ key={session.presenceSessionId}
274
+ participantId={session.participant.id}
275
+ kind={session.participant.kind}
276
+ />
277
+ ));
278
+ }
279
+ ```
280
+
281
+ The component chooses the model and record. Ablo owns the authenticated
282
+ session identity, read lease, refresh, reconnect re-announcement, and removal
283
+ on cleanup. Multiple tabs remain separate sessions, and human and agent
284
+ participants use the same result shape. Do not build a separate `chat:view`
285
+ event, heartbeat, or stale-viewer timer in the app.
286
+
287
+ If you already have the client, the direct model form is equivalent:
288
+
289
+ ```tsx
290
+ const viewers = usePresence(ablo.chats, chatId);
291
+ ```
292
+
293
+ The hook returns the complete matching session projection, including the
294
+ current session. Use `session.participant` for identity and inspect
295
+ `session.activities` when the UI needs to distinguish reading from claiming or
296
+ writing.
297
+
298
+ A presence session is a connection, not a unique person. Two tabs can share
299
+ `participant.id` while having different `presenceSessionId` values. For a people
300
+ count, deduplicate by both `participant.kind` and `participant.id`; retain the
301
+ sessions when displaying connection details. Reading declares attention; claiming
302
+ declares ownership. Neither proves that an agent is generating text. Label a held
303
+ claim “Agent owns this chat.” Drive “Generating” from application execution state.
304
+ On navigation, the hook releases its old read lease. After a lost connection,
305
+ remote presence may remain until its lease expires; disappearance is not immediate.
306
+
307
+ ## Model events: cursors and selections
308
+
309
+ Use the `events` namespace already attached to each model for transient UI
310
+ signals. The model and record choose the authorized sync group; the payload
311
+ does not need routing fields or caller-authored identity.
312
+
313
+ ```tsx
314
+ const ablo = useAblo();
315
+
316
+ useEffect(() => {
317
+ if (!ablo) return;
318
+ return ablo.slideDecks.events.subscribe(deckId, 'cursor', (cursor, context) => {
319
+ drawRemoteCursor(context.sender.presenceSessionId, cursor);
320
+ });
321
+ }, [ablo, deckId]);
322
+
323
+ function onPointerMove(x: number, y: number) {
324
+ ablo?.slideDecks.events.send(deckId, 'cursor', { slideId, x, y });
325
+ }
326
+ ```
327
+
328
+ The same shape works for code editors:
329
+
330
+ ```ts
331
+ ablo.files.events.send(fileId, 'selection', { anchor, head });
332
+ ```
333
+
334
+ Events are lossy and are not replayed after reconnect, which fits cursor and
335
+ live-selection updates. Ablo enters and leaves the record scope with each
336
+ subscription, routes only inside that scope, and delivers authenticated
337
+ `sender` and `sentAt` context separately from the application payload. Use
338
+ durable model fields when state must survive reconnects. The sending connection
339
+ does not receive its own event. Coalesce or throttle pointer movement in the
340
+ application; model events do not currently declare a per-event `maxHz`.
341
+
191
342
  ## usePeers: read-only presence
192
343
 
193
344
  `usePeers` reads the presence stream already flowing for the client's scoped
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abloatai/ablo",
3
- "version": "0.62.0",
3
+ "version": "0.63.1",
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.62.0",
149
- "@abloatai/transaction": "^0.62.0",
148
+ "@abloatai/humans": "^0.63.1",
149
+ "@abloatai/transaction": "^0.63.1",
150
150
  "zod": "^4.4.3"
151
151
  },
152
152
  "peerDependencies": {