@abloatai/humans 0.61.0 → 0.62.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/dist/Ablo.d.ts +4 -4
- package/dist/Ablo.js +2 -1
- package/dist/client.d.ts +7 -12
- package/dist/humans.d.ts +2 -2
- package/dist/humans.js +2 -6
- package/dist/local/BaseSyncedStore.d.ts +2 -4
- package/dist/local/BaseSyncedStore.js +0 -3
- package/dist/local/SyncClient.d.ts +3 -3
- package/dist/local/client/clientPrelude.d.ts +2 -0
- package/dist/local/client/clientPrelude.js +13 -1
- package/dist/local/client/createInternalComponents.js +2 -0
- package/dist/local/client/createModelOperations.d.ts +5 -0
- package/dist/local/client/createModelOperations.js +1 -0
- package/dist/local/client/reactiveEngine.d.ts +2 -2
- package/dist/local/client/reactiveEngine.js +9 -11
- package/dist/local/query/client.d.ts +3 -0
- package/dist/local/query/client.js +1 -1
- package/dist/local/sync/BootstrapFetcher.d.ts +2 -0
- package/dist/local/sync/BootstrapFetcher.js +4 -4
- package/dist/local/sync/OnDemandLoader.d.ts +2 -0
- package/dist/local/sync/OnDemandLoader.js +1 -0
- package/dist/local/sync/SyncWebSocket.d.ts +2 -15
- package/dist/local/sync/SyncWebSocket.js +1 -36
- package/dist/local/sync/createClaimStream.d.ts +9 -19
- package/dist/local/sync/createClaimStream.js +40 -55
- package/dist/local/sync/socketEventWiring.d.ts +1 -2
- package/dist/local/sync/socketEventWiring.js +1 -5
- package/dist/local/transactions/mutations/MutationQueue.d.ts +3 -3
- package/dist/local/transactions/mutations/replayValidation.d.ts +4 -4
- package/dist/presence/index.d.ts +15 -0
- package/dist/presence/index.js +49 -0
- package/dist/react/AbloProvider.d.ts +3 -3
- package/dist/react/AbloProvider.js +4 -3
- package/dist/surface.d.ts +1 -1
- package/dist/surface.js +1 -0
- package/package.json +2 -2
- package/src/Ablo.ts +15 -6
- package/src/client.ts +8 -13
- package/src/humans.ts +3 -10
- package/src/local/BaseSyncedStore.ts +0 -6
- package/src/local/client/clientPrelude.ts +20 -0
- package/src/local/client/createInternalComponents.ts +2 -0
- package/src/local/client/createModelOperations.ts +8 -0
- package/src/local/client/reactiveEngine.ts +19 -14
- package/src/local/query/client.ts +4 -0
- package/src/local/sync/BootstrapFetcher.ts +13 -4
- package/src/local/sync/OnDemandLoader.ts +3 -0
- package/src/local/sync/SyncWebSocket.ts +1 -42
- package/src/local/sync/createClaimStream.ts +51 -71
- package/src/local/sync/socketEventWiring.ts +1 -8
- package/src/presence/index.ts +72 -0
- package/src/react/AbloProvider.tsx +14 -9
- package/src/surface.ts +1 -0
- package/dist/presenceStream.d.ts +0 -69
- package/dist/presenceStream.js +0 -200
- package/src/presenceStream.ts +0 -279
|
@@ -5,10 +5,8 @@
|
|
|
5
5
|
* everyone else's, and watch the wait queue when a claim is contended.
|
|
6
6
|
*
|
|
7
7
|
* The stream is built directly on the sync WebSocket and shares that one
|
|
8
|
-
* connection. It learns about other
|
|
9
|
-
*
|
|
10
|
-
* consumes — the server piggybacks each participant's `activeClaims` on every
|
|
11
|
-
* presence frame — and sends its own claims as `claim_begin` and
|
|
8
|
+
* connection. It learns about other sessions' claims from the normalized
|
|
9
|
+
* presence projection and sends its own claims as `claim_begin` and
|
|
12
10
|
* `claim_abandon` frames.
|
|
13
11
|
*
|
|
14
12
|
* Wire frames:
|
|
@@ -16,12 +14,12 @@
|
|
|
16
14
|
* entityId, description, field?, estimatedMs? }`.
|
|
17
15
|
* • Outbound `claim_abandon` — release it: `{ claimId, entityType?,
|
|
18
16
|
* entityId? }`.
|
|
19
|
-
* • Inbound, via presence — `
|
|
20
|
-
* `declaredAt` and `expiresAt`.
|
|
17
|
+
* • Inbound, via presence — authoritative `claim` activities.
|
|
21
18
|
* • Inbound `claim_rejected` — the server refused the claim, with conflict
|
|
22
19
|
* metadata.
|
|
23
20
|
*/
|
|
24
21
|
import type { WsTransport } from '@abloatai/transaction/transport/websocket';
|
|
22
|
+
import type { PresenceSession } from '@abloatai/transaction/presence';
|
|
25
23
|
import type { ClaimOptions, Claim, ClaimStream, PresenceTarget } from '@abloatai/transaction/types/streams';
|
|
26
24
|
import { type Logger } from '@abloatai/transaction/logger';
|
|
27
25
|
/**
|
|
@@ -34,11 +32,13 @@ import { type Logger } from '@abloatai/transaction/logger';
|
|
|
34
32
|
*/
|
|
35
33
|
export type ClaimTransport = Pick<WsTransport, 'subscribe' | 'isConnected' | 'send'>;
|
|
36
34
|
export interface ClaimStreamConfig {
|
|
37
|
-
/** Identity used to filter our own active claims out of `others`. */
|
|
38
|
-
participantId: string;
|
|
39
35
|
/** Where the coordination trace is logged. Defaults to silent. */
|
|
40
36
|
logger?: Logger;
|
|
41
37
|
}
|
|
38
|
+
export interface ClaimPresenceSource {
|
|
39
|
+
readonly others: readonly PresenceSession[];
|
|
40
|
+
onChange(listener: () => void): () => void;
|
|
41
|
+
}
|
|
42
42
|
export interface AttachableClaimStream extends ClaimStream {
|
|
43
43
|
/**
|
|
44
44
|
* Mints the local handle and sends its `claim_begin` frame. The handle is a
|
|
@@ -54,16 +54,6 @@ export interface AttachableClaimStream extends ClaimStream {
|
|
|
54
54
|
*/
|
|
55
55
|
claim(target: PresenceTarget, opts?: ClaimOptions, claimId?: string): Claim;
|
|
56
56
|
attach(transport: ClaimTransport): void;
|
|
57
|
-
/**
|
|
58
|
-
* Seeds the participant identity once the host resolves it. The stream can
|
|
59
|
-
* be built before identity is known — a hosted client learns who it is
|
|
60
|
-
* from its credential's scope during connect — and until then the
|
|
61
|
-
* construction-time id (possibly empty) would let the participant's own
|
|
62
|
-
* claims into `others`. Idempotent; later frames filter on the new id.
|
|
63
|
-
*/
|
|
64
|
-
setParticipant(participant: {
|
|
65
|
-
id: string;
|
|
66
|
-
}): void;
|
|
67
57
|
dispose(): void;
|
|
68
58
|
}
|
|
69
|
-
export declare function createClaimStream(config: ClaimStreamConfig, transport?: ClaimTransport | null): AttachableClaimStream;
|
|
59
|
+
export declare function createClaimStream(config: ClaimStreamConfig, transport?: ClaimTransport | null, presence?: ClaimPresenceSource | null): AttachableClaimStream;
|
|
@@ -5,10 +5,8 @@
|
|
|
5
5
|
* everyone else's, and watch the wait queue when a claim is contended.
|
|
6
6
|
*
|
|
7
7
|
* The stream is built directly on the sync WebSocket and shares that one
|
|
8
|
-
* connection. It learns about other
|
|
9
|
-
*
|
|
10
|
-
* consumes — the server piggybacks each participant's `activeClaims` on every
|
|
11
|
-
* presence frame — and sends its own claims as `claim_begin` and
|
|
8
|
+
* connection. It learns about other sessions' claims from the normalized
|
|
9
|
+
* presence projection and sends its own claims as `claim_begin` and
|
|
12
10
|
* `claim_abandon` frames.
|
|
13
11
|
*
|
|
14
12
|
* Wire frames:
|
|
@@ -16,8 +14,7 @@
|
|
|
16
14
|
* entityId, description, field?, estimatedMs? }`.
|
|
17
15
|
* • Outbound `claim_abandon` — release it: `{ claimId, entityType?,
|
|
18
16
|
* entityId? }`.
|
|
19
|
-
* • Inbound, via presence — `
|
|
20
|
-
* `declaredAt` and `expiresAt`.
|
|
17
|
+
* • Inbound, via presence — authoritative `claim` activities.
|
|
21
18
|
* • Inbound `claim_rejected` — the server refused the claim, with conflict
|
|
22
19
|
* metadata.
|
|
23
20
|
*/
|
|
@@ -40,10 +37,7 @@ function claimLabel(type, id, field) {
|
|
|
40
37
|
* above a round trip, comfortably below the ttl/3 beat cadence.
|
|
41
38
|
*/
|
|
42
39
|
const HEARTBEAT_ACK_TIMEOUT_MS = 10_000;
|
|
43
|
-
export function createClaimStream(config, transport = null) {
|
|
44
|
-
// Mutable: the host seeds the resolved identity via `setParticipant` once
|
|
45
|
-
// it is known; the own-claim filter always reads the current value.
|
|
46
|
-
let participantId = config.participantId;
|
|
40
|
+
export function createClaimStream(config, transport = null, presence = null) {
|
|
47
41
|
const logger = config.logger ?? noopLogger;
|
|
48
42
|
// ── State: others' open claims, keyed by claimId ───────────────
|
|
49
43
|
const activeByClaimId = new Map();
|
|
@@ -113,48 +107,7 @@ export function createClaimStream(config, transport = null) {
|
|
|
113
107
|
if (attached)
|
|
114
108
|
return;
|
|
115
109
|
attached = t;
|
|
116
|
-
//
|
|
117
|
-
// active-claim set. Prune previous claims by holder, then
|
|
118
|
-
// re-add from the frame — the frame is authoritative for that
|
|
119
|
-
// participant's open claims at that moment.
|
|
120
|
-
unsubs.push(t.subscribe('presence_update', (event) => {
|
|
121
|
-
if (!event.userId)
|
|
122
|
-
return;
|
|
123
|
-
if (event.userId === participantId)
|
|
124
|
-
return;
|
|
125
|
-
let mutated = false;
|
|
126
|
-
if (event.kind === 'leave') {
|
|
127
|
-
for (const [id, claim] of activeByClaimId) {
|
|
128
|
-
if (claim.heldBy === event.userId) {
|
|
129
|
-
activeByClaimId.delete(id);
|
|
130
|
-
mutated = true;
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
if (mutated)
|
|
134
|
-
notifyListeners();
|
|
135
|
-
return;
|
|
136
|
-
}
|
|
137
|
-
for (const [id, claim] of activeByClaimId) {
|
|
138
|
-
if (claim.heldBy === event.userId) {
|
|
139
|
-
activeByClaimId.delete(id);
|
|
140
|
-
mutated = true;
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
for (const claim of event.activeClaims ?? []) {
|
|
144
|
-
// Terminal-status entries (committed / expired / canceled) are
|
|
145
|
-
// one-shot "this claim ended" signals. The holder sweep above
|
|
146
|
-
// already removed the prior active entry; skipping the re-add
|
|
147
|
-
// drops it from `others`, which is what resolves a contender's
|
|
148
|
-
// `settled()`. Absent status means active (wire back-compat).
|
|
149
|
-
if (claim.status && claim.status !== 'active')
|
|
150
|
-
continue;
|
|
151
|
-
observeForeignClaim(event.userId, claim, event.participantKind, event.isAgent);
|
|
152
|
-
mutated = true;
|
|
153
|
-
}
|
|
154
|
-
if (mutated)
|
|
155
|
-
notifyListeners();
|
|
156
|
-
}));
|
|
157
|
-
// (2) Server-side rejection frames.
|
|
110
|
+
// Server-side rejection frames.
|
|
158
111
|
unsubs.push(t.subscribe('claim_rejected', (rejection) => {
|
|
159
112
|
if (!rejection.claimId)
|
|
160
113
|
return;
|
|
@@ -290,6 +243,41 @@ export function createClaimStream(config, transport = null) {
|
|
|
290
243
|
}
|
|
291
244
|
if (transport)
|
|
292
245
|
attach(transport);
|
|
246
|
+
const refreshPresenceClaims = () => {
|
|
247
|
+
if (presence === null)
|
|
248
|
+
return;
|
|
249
|
+
activeByClaimId.clear();
|
|
250
|
+
for (const session of presence.others) {
|
|
251
|
+
for (const activity of session.activities) {
|
|
252
|
+
if (activity.operation !== 'claim'
|
|
253
|
+
|| activity.source !== 'claim'
|
|
254
|
+
|| activity.target.id === undefined)
|
|
255
|
+
continue;
|
|
256
|
+
const claimId = activity.id.startsWith('claim:')
|
|
257
|
+
? activity.id.slice('claim:'.length)
|
|
258
|
+
: activity.id;
|
|
259
|
+
observeForeignClaim(session.participant.id, {
|
|
260
|
+
claimId,
|
|
261
|
+
entityType: activity.target.model,
|
|
262
|
+
entityId: activity.target.id,
|
|
263
|
+
...(activity.target.field !== undefined
|
|
264
|
+
? { field: activity.target.field }
|
|
265
|
+
: {}),
|
|
266
|
+
...(activity.target.fields !== undefined
|
|
267
|
+
? { fields: activity.target.fields }
|
|
268
|
+
: {}),
|
|
269
|
+
description: 'claim',
|
|
270
|
+
declaredAt: Date.parse(activity.startedAt),
|
|
271
|
+
expiresAt: Date.parse(activity.expiresAt),
|
|
272
|
+
}, session.participant.kind);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
notifyListeners();
|
|
276
|
+
};
|
|
277
|
+
if (presence !== null) {
|
|
278
|
+
refreshPresenceClaims();
|
|
279
|
+
unsubs.push(presence.onChange(refreshPresenceClaims));
|
|
280
|
+
}
|
|
293
281
|
// ── Outbound ────────────────────────────────────────────────────
|
|
294
282
|
function sendBegin(claimId, claim) {
|
|
295
283
|
if (!attached?.isConnected())
|
|
@@ -475,9 +463,6 @@ export function createClaimStream(config, transport = null) {
|
|
|
475
463
|
}, () => claimsSnapshot);
|
|
476
464
|
},
|
|
477
465
|
attach,
|
|
478
|
-
setParticipant(participant) {
|
|
479
|
-
participantId = participant.id;
|
|
480
|
-
},
|
|
481
466
|
dispose() {
|
|
482
467
|
for (const off of unsubs)
|
|
483
468
|
off();
|
|
@@ -5,7 +5,7 @@ import type { InstanceCache } from '../InstanceCache.js';
|
|
|
5
5
|
import type { ConnectionManager } from './ConnectionManager.js';
|
|
6
6
|
import type { SubscriptionManager } from './SubscriptionManager.js';
|
|
7
7
|
import type { SyncStatus } from '../storeContract.js';
|
|
8
|
-
import type { BootstrapHint, BootstrapDataEvent,
|
|
8
|
+
import type { BootstrapHint, BootstrapDataEvent, SyncWebSocket, EventMap } from './SyncWebSocket.js';
|
|
9
9
|
import type { SyncDelta } from './SyncWebSocket.js';
|
|
10
10
|
export interface SocketEventHost<TCollaboration extends EventMap<TCollaboration>> {
|
|
11
11
|
syncWebSocket: SyncWebSocket<TCollaboration>;
|
|
@@ -23,7 +23,6 @@ export interface SocketEventHost<TCollaboration extends EventMap<TCollaboration>
|
|
|
23
23
|
applyDeltaFrame(deltas: SyncDelta[]): void;
|
|
24
24
|
handleBootstrapRequired(hint: BootstrapHint): void;
|
|
25
25
|
handleBootstrapData(data: BootstrapDataEvent): void;
|
|
26
|
-
handlePresenceUpdate(data: PresenceUpdate): void;
|
|
27
26
|
performCredentialRefresh(): Promise<'refreshed' | 'session_error' | 'network_error'>;
|
|
28
27
|
handleTerminalSessionError(error: Error): void;
|
|
29
28
|
nudgeReconnect(): void;
|
|
@@ -43,10 +43,6 @@ export function wireSocketEvents(deps) {
|
|
|
43
43
|
const data = args[0];
|
|
44
44
|
deps.handleBootstrapData(data);
|
|
45
45
|
});
|
|
46
|
-
const onPresenceUpdate = deps.syncWebSocket.subscribe('presence_update', (...args) => {
|
|
47
|
-
const data = args[0];
|
|
48
|
-
deps.handlePresenceUpdate(data);
|
|
49
|
-
});
|
|
50
46
|
// Error events
|
|
51
47
|
const onError = deps.syncWebSocket.subscribe('error', (error) => {
|
|
52
48
|
if (error.message === 'Network is offline' || error.message === 'WebSocket connection failed') {
|
|
@@ -126,5 +122,5 @@ export function wireSocketEvents(deps) {
|
|
|
126
122
|
deps.runtime.logger.debug('[BaseSyncedStore] WebSocket reconnection gave up', { attempts });
|
|
127
123
|
deps.updateSyncStatus({ state: 'reconnecting' });
|
|
128
124
|
});
|
|
129
|
-
deps.disposers.push(onConnected, onDisconnected, onReconnecting, onDelta, onDeltaBatch, onBootstrapRequired, onBootstrapData,
|
|
125
|
+
deps.disposers.push(onConnected, onDisconnected, onReconnecting, onDelta, onDeltaBatch, onBootstrapRequired, onBootstrapData, onError, onSessionError, onHandshakeFailed, onReconnectFailed, () => { deps.areaOfInterest.dispose(); });
|
|
130
126
|
}
|
|
@@ -476,7 +476,7 @@ export declare class MutationQueue extends EventEmitter {
|
|
|
476
476
|
awaitingDeltaCount: number;
|
|
477
477
|
awaitingDeltaTransactions: {
|
|
478
478
|
id: string;
|
|
479
|
-
type: "
|
|
479
|
+
type: "create" | "update" | "delete" | "archive" | "unarchive";
|
|
480
480
|
modelName: string;
|
|
481
481
|
modelId: string;
|
|
482
482
|
syncIdNeeded: number | undefined;
|
|
@@ -485,13 +485,13 @@ export declare class MutationQueue extends EventEmitter {
|
|
|
485
485
|
}[];
|
|
486
486
|
pendingTransactions: {
|
|
487
487
|
id: string;
|
|
488
|
-
type: "
|
|
488
|
+
type: "create" | "update" | "delete" | "archive" | "unarchive";
|
|
489
489
|
modelName: string;
|
|
490
490
|
modelId: string;
|
|
491
491
|
}[];
|
|
492
492
|
executingTransactions: {
|
|
493
493
|
id: string;
|
|
494
|
-
type: "
|
|
494
|
+
type: "create" | "update" | "delete" | "archive" | "unarchive";
|
|
495
495
|
modelName: string;
|
|
496
496
|
modelId: string;
|
|
497
497
|
}[];
|
|
@@ -27,8 +27,8 @@ import type { RuntimeContext } from '../../RuntimeContext.js';
|
|
|
27
27
|
export declare const persistedTransactionSchema: z.ZodObject<{
|
|
28
28
|
id: z.ZodString;
|
|
29
29
|
type: z.ZodEnum<{
|
|
30
|
-
update: "update";
|
|
31
30
|
create: "create";
|
|
31
|
+
update: "update";
|
|
32
32
|
delete: "delete";
|
|
33
33
|
archive: "archive";
|
|
34
34
|
unarchive: "unarchive";
|
|
@@ -95,8 +95,8 @@ export declare function deserializePersistedTransaction(row: unknown, runtime?:
|
|
|
95
95
|
export declare const persistedMutationSchema: z.ZodObject<{
|
|
96
96
|
mutationId: z.ZodOptional<z.ZodString>;
|
|
97
97
|
type: z.ZodEnum<{
|
|
98
|
-
update: "update";
|
|
99
98
|
create: "create";
|
|
99
|
+
update: "update";
|
|
100
100
|
delete: "delete";
|
|
101
101
|
archive: "archive";
|
|
102
102
|
}>;
|
|
@@ -138,8 +138,8 @@ export declare const legacyPendingMutationRecordSchema: z.ZodObject<{
|
|
|
138
138
|
type: z.ZodLiteral<"pending_mutation">;
|
|
139
139
|
mutation: z.ZodObject<{
|
|
140
140
|
type: z.ZodEnum<{
|
|
141
|
-
update: "update";
|
|
142
141
|
create: "create";
|
|
142
|
+
update: "update";
|
|
143
143
|
delete: "delete";
|
|
144
144
|
archive: "archive";
|
|
145
145
|
}>;
|
|
@@ -180,8 +180,8 @@ export declare const pendingMutationRecordSchema: z.ZodObject<{
|
|
|
180
180
|
type: z.ZodLiteral<"pending_mutation">;
|
|
181
181
|
mutation: z.ZodObject<{
|
|
182
182
|
type: z.ZodEnum<{
|
|
183
|
-
update: "update";
|
|
184
183
|
create: "create";
|
|
184
|
+
update: "update";
|
|
185
185
|
delete: "delete";
|
|
186
186
|
archive: "archive";
|
|
187
187
|
}>;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type PresenceProjection, type PresenceProjectionEvents, type PresenceView } from '@abloatai/transaction/presence';
|
|
2
|
+
/** Reactive-client presence backed by the client's existing live connection. */
|
|
3
|
+
export interface ReactivePresence extends PresenceView {
|
|
4
|
+
forModel(model: string, recordId?: string): ReturnType<PresenceProjection['forModel']>;
|
|
5
|
+
onChange(listener: () => void): () => void;
|
|
6
|
+
}
|
|
7
|
+
/** Lifecycle hooks kept inside the humans composition boundary. */
|
|
8
|
+
export interface AttachablePresence extends ReactivePresence {
|
|
9
|
+
attach(transport: PresenceProjectionEvents): void;
|
|
10
|
+
dispose(): void;
|
|
11
|
+
}
|
|
12
|
+
/** Framework bridge that does not consume a string key on the model namespace. */
|
|
13
|
+
export declare function attachPresenceToClient(client: object, presence: ReactivePresence): void;
|
|
14
|
+
export declare function presenceOfClient(client: object): ReactivePresence;
|
|
15
|
+
export declare function createPresence(transport?: PresenceProjectionEvents | null): AttachablePresence;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { createPresenceProjection, } from '@abloatai/transaction/presence';
|
|
2
|
+
const clientPresence = new WeakMap();
|
|
3
|
+
/** Framework bridge that does not consume a string key on the model namespace. */
|
|
4
|
+
export function attachPresenceToClient(client, presence) {
|
|
5
|
+
clientPresence.set(client, presence);
|
|
6
|
+
}
|
|
7
|
+
export function presenceOfClient(client) {
|
|
8
|
+
const presence = clientPresence.get(client);
|
|
9
|
+
if (presence === undefined)
|
|
10
|
+
throw new Error('presence is not attached to this client');
|
|
11
|
+
return presence;
|
|
12
|
+
}
|
|
13
|
+
export function createPresence(transport = null) {
|
|
14
|
+
let projection = null;
|
|
15
|
+
const listeners = new Set();
|
|
16
|
+
let unsubscribe = null;
|
|
17
|
+
const notify = () => {
|
|
18
|
+
for (const listener of listeners)
|
|
19
|
+
listener();
|
|
20
|
+
};
|
|
21
|
+
const attach = (events) => {
|
|
22
|
+
if (projection !== null)
|
|
23
|
+
return;
|
|
24
|
+
projection = createPresenceProjection(events);
|
|
25
|
+
unsubscribe = projection.subscribe(notify);
|
|
26
|
+
notify();
|
|
27
|
+
};
|
|
28
|
+
if (transport !== null)
|
|
29
|
+
attach(transport);
|
|
30
|
+
return {
|
|
31
|
+
get active() { return projection?.active ?? []; },
|
|
32
|
+
get others() { return projection?.others ?? []; },
|
|
33
|
+
onChange(listener) {
|
|
34
|
+
listeners.add(listener);
|
|
35
|
+
return () => { listeners.delete(listener); };
|
|
36
|
+
},
|
|
37
|
+
attach,
|
|
38
|
+
forModel(model, recordId) {
|
|
39
|
+
return projection?.forModel(model, recordId) ?? [];
|
|
40
|
+
},
|
|
41
|
+
dispose() {
|
|
42
|
+
unsubscribe?.();
|
|
43
|
+
unsubscribe = null;
|
|
44
|
+
projection?.dispose();
|
|
45
|
+
projection = null;
|
|
46
|
+
listeners.clear();
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type ReactNode } from 'react';
|
|
2
2
|
import type { SchemaRecord } from '@abloatai/transaction/schema/schema';
|
|
3
3
|
import type { AbloClient as Ablo } from '../client.js';
|
|
4
|
-
import type {
|
|
4
|
+
import type { PresenceSession } from '@abloatai/transaction/presence';
|
|
5
5
|
import type { GroupScope } from '../local/sync/scopeGroups.js';
|
|
6
6
|
import { type SyncStoreContract } from './context.js';
|
|
7
7
|
/**
|
|
@@ -109,7 +109,7 @@ export interface AbloProviderProps<R extends SchemaRecord = SchemaRecord> {
|
|
|
109
109
|
export declare function AbloProvider<R extends SchemaRecord = SchemaRecord>(props: AbloProviderProps<R>): React.ReactElement;
|
|
110
110
|
export type { GroupScope };
|
|
111
111
|
/**
|
|
112
|
-
* Read-only presence: the
|
|
112
|
+
* Read-only presence: the other sessions currently visible to this
|
|
113
113
|
* connection, bridged to React. This is a pure reader of the engine's
|
|
114
114
|
* already-flowing presence stream; it does not mutate connection groups.
|
|
115
115
|
*
|
|
@@ -127,7 +127,7 @@ export type { GroupScope };
|
|
|
127
127
|
* const alone = !peers.some((p) => p.participantKind === 'user');
|
|
128
128
|
* ```
|
|
129
129
|
*/
|
|
130
|
-
export declare function usePeers(scope?: GroupScope): readonly
|
|
130
|
+
export declare function usePeers(scope?: GroupScope): readonly PresenceSession[];
|
|
131
131
|
/**
|
|
132
132
|
* Returns the raw `SyncEngine` proxy. Typically you want the typed
|
|
133
133
|
* hooks (`useQuery`, `useOne`, `useMutate`) — this is for rare cases
|
|
@@ -7,6 +7,7 @@ import { AbloInternalContext } from './internalContext.js';
|
|
|
7
7
|
import { AbloValidationError } from '@abloatai/transaction/errors';
|
|
8
8
|
import { useSyncStatus } from './useSyncStatus.js';
|
|
9
9
|
import { DefaultFallback } from './DefaultFallback.js';
|
|
10
|
+
import { presenceOfClient } from '../presence/index.js';
|
|
10
11
|
// ── Implementation ───────────────────────────────────────────────────
|
|
11
12
|
/**
|
|
12
13
|
* Lightweight event emitter for provider-level errors. Lives on the
|
|
@@ -201,7 +202,7 @@ function BootstrapGate({ fallback, children, }) {
|
|
|
201
202
|
}
|
|
202
203
|
const EMPTY_PRESENCE = Object.freeze([]);
|
|
203
204
|
/**
|
|
204
|
-
* Read-only presence: the
|
|
205
|
+
* Read-only presence: the other sessions currently visible to this
|
|
205
206
|
* connection, bridged to React. This is a pure reader of the engine's
|
|
206
207
|
* already-flowing presence stream; it does not mutate connection groups.
|
|
207
208
|
*
|
|
@@ -232,10 +233,10 @@ export function usePeers(scope) {
|
|
|
232
233
|
setPeers(EMPTY_PRESENCE);
|
|
233
234
|
return;
|
|
234
235
|
}
|
|
235
|
-
const presence = engine
|
|
236
|
+
const presence = presenceOfClient(engine);
|
|
236
237
|
const compute = () => groups.length === 0
|
|
237
238
|
? presence.others
|
|
238
|
-
: presence.others.filter((
|
|
239
|
+
: presence.others.filter((session) => session.activities.some(({ target }) => target.id !== undefined && groups.includes(`${target.model.toLowerCase()}:${target.id}`)));
|
|
239
240
|
// Plain useState + onChange — presence changes on connect/disconnect/activity
|
|
240
241
|
// only (never on cursor traffic, a separate channel), so this fires
|
|
241
242
|
// rarely; a frame of stale presence is harmless.
|
package/dist/surface.d.ts
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* tuple, so it is the one list of model-verb names a generated summary can
|
|
20
20
|
* describe.
|
|
21
21
|
*/
|
|
22
|
-
export declare const PUBLIC_MODEL_VERBS: readonly ["get", "read", "list", "listAll", "local", "create", "update", "delete", "claim", "onChange"];
|
|
22
|
+
export declare const PUBLIC_MODEL_VERBS: readonly ["get", "read", "list", "listAll", "local", "presence", "create", "update", "delete", "claim", "onChange"];
|
|
23
23
|
/**
|
|
24
24
|
* The option keys accepted by `local.list` and `onChange`, matching the
|
|
25
25
|
* keys of {@link LocalReadOptions}. Note that the lifecycle filter is named
|
package/dist/surface.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@abloatai/humans",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.62.0",
|
|
4
4
|
"description": "The optional human-facing local-state package for Ablo: presence, live queries, and React bindings.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -84,7 +84,7 @@
|
|
|
84
84
|
"directory": "packages/humans"
|
|
85
85
|
},
|
|
86
86
|
"dependencies": {
|
|
87
|
-
"@abloatai/transaction": "^0.
|
|
87
|
+
"@abloatai/transaction": "^0.62.0",
|
|
88
88
|
"mobx": "^6.13.7",
|
|
89
89
|
"uuid": "^11.1.0",
|
|
90
90
|
"zod": "^4.4.3"
|
package/src/Ablo.ts
CHANGED
|
@@ -99,7 +99,7 @@ export type Ablo<S extends SchemaRecord> = AbloClient<S>;
|
|
|
99
99
|
* const ablo = Ablo({ schema, session: { endpoint: '/api/ablo-session' } });
|
|
100
100
|
* ```
|
|
101
101
|
*
|
|
102
|
-
* Server-side agents, workers, and services use `@abloatai/
|
|
102
|
+
* Server-side agents, workers, and services use `@abloatai/ablo`.
|
|
103
103
|
*/
|
|
104
104
|
export function Ablo<
|
|
105
105
|
const S extends SchemaRecord,
|
|
@@ -117,7 +117,15 @@ export function Ablo<const S extends SchemaRecord>(
|
|
|
117
117
|
// resolver, the base URL, the logger, and this participant's identity —
|
|
118
118
|
// and fails on a misconfiguration before anything is constructed.
|
|
119
119
|
const prelude = resolveClientPrelude(options);
|
|
120
|
-
const {
|
|
120
|
+
const {
|
|
121
|
+
internalOptions,
|
|
122
|
+
authCredentials,
|
|
123
|
+
presenceSession,
|
|
124
|
+
logger,
|
|
125
|
+
url,
|
|
126
|
+
participantId,
|
|
127
|
+
kind,
|
|
128
|
+
} = prelude;
|
|
121
129
|
|
|
122
130
|
// 2. The connection, built here in the composition root — before the plugin
|
|
123
131
|
// list resolves, so `PluginContext.transport` carries the instance a
|
|
@@ -134,6 +142,7 @@ export function Ablo<const S extends SchemaRecord>(
|
|
|
134
142
|
baseUrl: url,
|
|
135
143
|
kind,
|
|
136
144
|
getAuthToken: authCredentials.getAuthToken,
|
|
145
|
+
presenceSession,
|
|
137
146
|
collaborationEvents: [...(internalOptions.collaborationEvents ?? [])],
|
|
138
147
|
syncGroups: [...(internalOptions.syncGroups ?? [])],
|
|
139
148
|
deferConnect: true,
|
|
@@ -223,7 +232,7 @@ export function Ablo<const S extends SchemaRecord>(
|
|
|
223
232
|
//
|
|
224
233
|
// One default import, with types hung underneath via namespace dots:
|
|
225
234
|
// `import { Ablo } from "@abloatai/humans"` gets the factory, its return type, and
|
|
226
|
-
// every type a typical consumer references (`Ablo.
|
|
235
|
+
// every type a typical consumer references (`Ablo.PresenceSession`,
|
|
227
236
|
// and so on) — all purely type-level, with zero runtime cost.
|
|
228
237
|
//
|
|
229
238
|
// The types still live in their canonical homes (`types/streams`, `principal`,
|
|
@@ -272,10 +281,10 @@ export namespace Ablo {
|
|
|
272
281
|
export type Duration = _Streams.Duration;
|
|
273
282
|
|
|
274
283
|
// ── Real-time multiplayer (flat — heterogeneous cluster) ──────────
|
|
275
|
-
export type
|
|
284
|
+
export type Presence = import('./presence/index.js').ReactivePresence;
|
|
285
|
+
export type PresenceSession = import('@abloatai/transaction/presence').PresenceSession;
|
|
286
|
+
export type PresenceActivity = import('@abloatai/transaction/presence').PresenceActivity;
|
|
276
287
|
export type ClaimStream = _Streams.ClaimStream;
|
|
277
|
-
export type Peer = _Streams.Peer;
|
|
278
|
-
export type Activity = _Streams.Activity;
|
|
279
288
|
export type Claim = _Streams.Claim;
|
|
280
289
|
export type ClaimRejection = _Streams.ClaimRejection;
|
|
281
290
|
export type ClaimLost = _Streams.ClaimLost;
|
package/src/client.ts
CHANGED
|
@@ -18,7 +18,6 @@ import type {
|
|
|
18
18
|
InferCreate,
|
|
19
19
|
InferRow,
|
|
20
20
|
} from '@abloatai/transaction/schema/schema';
|
|
21
|
-
import type { PresenceStream } from '@abloatai/transaction/types/streams';
|
|
22
21
|
import type { InstanceCache } from './local/InstanceCache.js';
|
|
23
22
|
import type { SyncStoreContract } from './react/context.js';
|
|
24
23
|
import type { SyncWebSocket, CoreSyncEventMap } from './local/sync/SyncWebSocket.js';
|
|
@@ -31,6 +30,7 @@ import type {
|
|
|
31
30
|
import type { EffectiveAuthority } from '@abloatai/transaction/auth';
|
|
32
31
|
import type { ReadDependency } from '@abloatai/transaction/coordination';
|
|
33
32
|
import type { CapturedRow } from '@abloatai/transaction/transport/http';
|
|
33
|
+
import type { ReactivePresence } from './presence/index.js';
|
|
34
34
|
export type { LocalReadOptions } from './local/client/resourceTypes.js';
|
|
35
35
|
|
|
36
36
|
/** The typed sync engine client — one property per model in the schema */
|
|
@@ -232,20 +232,15 @@ export type AbloClient<S extends SchemaRecord> = {
|
|
|
232
232
|
*/
|
|
233
233
|
readonly syncStatus: SyncStatus;
|
|
234
234
|
|
|
235
|
-
/** The underlying schema */
|
|
236
|
-
readonly schema: Schema<S>;
|
|
237
|
-
|
|
238
235
|
/**
|
|
239
|
-
*
|
|
240
|
-
*
|
|
241
|
-
*
|
|
242
|
-
* participant for presence does not open a second socket. See
|
|
243
|
-
* {@link PresenceStream}.
|
|
244
|
-
*
|
|
245
|
-
* The reference is stable for the engine's lifetime — the underlying connection
|
|
246
|
-
* is rotated on `dispose()`, but this object stays the same.
|
|
236
|
+
* Session-owned live activity projected from this client's existing
|
|
237
|
+
* connection. Use `active` for every visible activity, `others` to exclude
|
|
238
|
+
* this session, and `forModel(model, id?)` for a model-native view.
|
|
247
239
|
*/
|
|
248
|
-
readonly presence:
|
|
240
|
+
readonly presence: ReactivePresence;
|
|
241
|
+
|
|
242
|
+
/** The underlying schema */
|
|
243
|
+
readonly schema: Schema<S>;
|
|
249
244
|
|
|
250
245
|
/**
|
|
251
246
|
* @internal The supported coordination API is `ablo.<model>.claim`. This
|
package/src/humans.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import type { AbloPlugin, PluginContext, AppliedChange } from './plugin.js';
|
|
9
9
|
import { AbloValidationError } from '@abloatai/transaction/errors';
|
|
10
|
-
import {
|
|
10
|
+
import { createPresence, type AttachablePresence } from './presence/index.js';
|
|
11
11
|
import {
|
|
12
12
|
buildStoreCluster,
|
|
13
13
|
kStoreCluster,
|
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
} from './local/client/storeCluster.js';
|
|
17
17
|
|
|
18
18
|
export interface HumansSurface {
|
|
19
|
-
readonly presence:
|
|
19
|
+
readonly presence: AttachablePresence;
|
|
20
20
|
readonly [kStoreCluster]?: StoreCluster;
|
|
21
21
|
}
|
|
22
22
|
|
|
@@ -45,14 +45,7 @@ export function humans() {
|
|
|
45
45
|
applyChanges = (changes) => { cluster.store.applyChangesToPool(changes); };
|
|
46
46
|
}
|
|
47
47
|
return {
|
|
48
|
-
presence:
|
|
49
|
-
{
|
|
50
|
-
participantId: context.participant?.id ?? '',
|
|
51
|
-
syncGroups: [...(context.syncGroups ?? [])],
|
|
52
|
-
isAgent: context.participant?.kind === 'agent',
|
|
53
|
-
},
|
|
54
|
-
context.transport ?? null,
|
|
55
|
-
),
|
|
48
|
+
presence: createPresence(context.transport ?? null),
|
|
56
49
|
...(cluster ? { [kStoreCluster]: cluster } : {}),
|
|
57
50
|
};
|
|
58
51
|
},
|
|
@@ -35,7 +35,6 @@ import {
|
|
|
35
35
|
type GroupRemovedPayload,
|
|
36
36
|
type BootstrapHint,
|
|
37
37
|
type BootstrapDataEvent,
|
|
38
|
-
type PresenceUpdate,
|
|
39
38
|
type EventMap,
|
|
40
39
|
type DefaultCollaborationEvents,
|
|
41
40
|
type SyncWebSocketEventMap,
|
|
@@ -238,7 +237,6 @@ export type {
|
|
|
238
237
|
GroupRemovedPayload,
|
|
239
238
|
BootstrapHint,
|
|
240
239
|
BootstrapDataEvent,
|
|
241
|
-
PresenceUpdate,
|
|
242
240
|
};
|
|
243
241
|
|
|
244
242
|
// deriveSyncPlanFromSchema derives a sync plan from a schema and is
|
|
@@ -1500,7 +1498,6 @@ export class BaseSyncedStore<
|
|
|
1500
1498
|
applyDeltaFrame: (deltas) => { this.applyDeltaFrame(deltas); },
|
|
1501
1499
|
handleBootstrapRequired: (hint) => { this.handleBootstrapRequired(hint); },
|
|
1502
1500
|
handleBootstrapData: (data) => { this.handleBootstrapData(data); },
|
|
1503
|
-
handlePresenceUpdate: (data) => { this.handlePresenceUpdate(data); },
|
|
1504
1501
|
performCredentialRefresh: () => this.performCredentialRefresh(),
|
|
1505
1502
|
handleTerminalSessionError: (error) => { this.terminalSessionLifecycle.start(error); },
|
|
1506
1503
|
nudgeReconnect: () => { this.nudgeReconnect(); },
|
|
@@ -1935,9 +1932,6 @@ export class BaseSyncedStore<
|
|
|
1935
1932
|
this.updateSyncStatus({ state: 'syncing' });
|
|
1936
1933
|
}
|
|
1937
1934
|
|
|
1938
|
-
/** Handle presence_update event. Override in subclass. */
|
|
1939
|
-
protected handlePresenceUpdate(_data: PresenceUpdate): void {}
|
|
1940
|
-
|
|
1941
1935
|
// ── Pending changes tracking ─────────────────────────────────────────────
|
|
1942
1936
|
|
|
1943
1937
|
protected incrementPendingChanges(): void {
|