@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 +65 -0
- package/dist/react.d.ts +2 -2
- package/dist/react.d.ts.map +1 -1
- package/dist/react.js +2 -1
- package/dist/react.js.map +1 -1
- package/docs/coordination.md +24 -0
- package/docs/customer-organizations.md +12 -12
- package/docs/examples/account-multiplayer.md +164 -0
- package/docs/groups.md +4 -4
- package/docs/identity.md +4 -1
- package/docs/index.md +3 -0
- package/docs/integration-guide.md +3 -0
- package/docs/react.md +172 -32
- package/package.json +3 -3
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
|
|
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
|
package/dist/react.d.ts.map
CHANGED
|
@@ -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,
|
|
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
|
|
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;
|
|
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"}
|
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
|
+
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 =
|
|
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? | `
|
|
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 `
|
|
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 `
|
|
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; `
|
|
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.
|
|
53
|
-
|
|
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(
|
|
101
|
-
|
|
102
|
-
|
|
216
|
+
const { data: report, claimed } = useAblo(
|
|
217
|
+
ablo => ablo.weatherReports,
|
|
218
|
+
serverReport.id,
|
|
219
|
+
{ initial: serverReport },
|
|
220
|
+
);
|
|
103
221
|
|
|
104
|
-
return <
|
|
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
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
|
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
|
-
##
|
|
407
|
+
## Presence: the same core read
|
|
266
408
|
|
|
267
|
-
`
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
273
|
-
|
|
274
|
-
import { usePeers } from '@abloatai/ablo/react';
|
|
414
|
+
import { useAblo } from '@abloatai/ablo/react';
|
|
275
415
|
|
|
276
|
-
|
|
277
|
-
const
|
|
278
|
-
const
|
|
279
|
-
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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.
|
|
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.
|
|
149
|
-
"@abloatai/transaction": "^0.
|
|
148
|
+
"@abloatai/humans": "^0.64.0",
|
|
149
|
+
"@abloatai/transaction": "^0.64.0",
|
|
150
150
|
"zod": "^4.4.3"
|
|
151
151
|
},
|
|
152
152
|
"peerDependencies": {
|