@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 +38 -0
- package/docs/api.md +40 -0
- package/docs/coordination.md +24 -0
- package/docs/customer-organizations.md +12 -12
- package/docs/examples/account-multiplayer.md +164 -0
- package/docs/identity.md +3 -0
- package/docs/index.md +3 -0
- package/docs/integration-guide.md +14 -0
- package/docs/options.md +17 -1
- package/docs/react.md +168 -17
- package/package.json +3 -3
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
|
package/docs/coordination.md
CHANGED
|
@@ -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
|
-
**
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
14
|
-
|
|
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
|
-
|
|
19
|
-
|
|
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
|
-
**
|
|
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
|
-
|
|
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 `
|
|
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.
|
|
53
|
-
|
|
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(
|
|
101
|
-
|
|
102
|
-
|
|
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
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
|
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.
|
|
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.
|
|
149
|
-
"@abloatai/transaction": "^0.
|
|
148
|
+
"@abloatai/humans": "^0.63.1",
|
|
149
|
+
"@abloatai/transaction": "^0.63.1",
|
|
150
150
|
"zod": "^4.4.3"
|
|
151
151
|
},
|
|
152
152
|
"peerDependencies": {
|