@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
|
@@ -13,12 +13,17 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import type { ParticipantKind } from '@abloatai/transaction/types/participant';
|
|
16
|
+
import { AbloValidationError } from '@abloatai/transaction/errors';
|
|
16
17
|
import type { Logger } from '@abloatai/transaction/logger';
|
|
17
18
|
import type { SchemaRecord } from '@abloatai/transaction/schema/schema';
|
|
18
19
|
import {
|
|
19
20
|
createAuthCredentialSource,
|
|
20
21
|
type AuthCredentialSource,
|
|
21
22
|
} from '@abloatai/transaction/auth/credentialSource';
|
|
23
|
+
import {
|
|
24
|
+
createPresenceSessionSource,
|
|
25
|
+
type PresenceSessionSource,
|
|
26
|
+
} from '@abloatai/transaction/presence';
|
|
22
27
|
import {
|
|
23
28
|
assertBrowserSafety,
|
|
24
29
|
readProcessEnv,
|
|
@@ -47,6 +52,7 @@ export interface ClientPrelude<S extends SchemaRecord> {
|
|
|
47
52
|
*/
|
|
48
53
|
readonly credentialResolver: CredentialProvider | null;
|
|
49
54
|
readonly authCredentials: AuthCredentialSource;
|
|
55
|
+
readonly presenceSession: PresenceSessionSource;
|
|
50
56
|
readonly logger: Logger;
|
|
51
57
|
readonly url: string;
|
|
52
58
|
/**
|
|
@@ -69,6 +75,17 @@ export interface ClientPrelude<S extends SchemaRecord> {
|
|
|
69
75
|
export function resolveClientPrelude<S extends SchemaRecord>(
|
|
70
76
|
options: AbloOptions<S>,
|
|
71
77
|
): ClientPrelude<S> {
|
|
78
|
+
// This package owns the reactive materialiser, not transport selection.
|
|
79
|
+
// TypeScript can miss excess properties on generic calls and object spreads,
|
|
80
|
+
// so reject a misplaced selector before resolving any credentials or
|
|
81
|
+
// constructing local state. The core `Ablo` client owns `transport`.
|
|
82
|
+
if ('transport' in options) {
|
|
83
|
+
throw new AbloValidationError(
|
|
84
|
+
"The reactive client does not accept `transport`. Import `Ablo` from " +
|
|
85
|
+
"'@abloatai/ablo' when selecting HTTP or WebSocket transport.",
|
|
86
|
+
{ code: 'invalid_options', param: 'transport' },
|
|
87
|
+
);
|
|
88
|
+
}
|
|
72
89
|
const env = readProcessEnv();
|
|
73
90
|
const internalOptions = {
|
|
74
91
|
...options,
|
|
@@ -79,9 +96,11 @@ export function resolveClientPrelude<S extends SchemaRecord>(
|
|
|
79
96
|
const configuredApiKey = resolveApiKey(authInput);
|
|
80
97
|
const configuredAuthToken = resolveAuthToken(authInput);
|
|
81
98
|
const credentialResolver = resolveCredentialResolver(configuredApiKey);
|
|
99
|
+
const presenceSession = createPresenceSessionSource();
|
|
82
100
|
const authCredentials = createAuthCredentialSource(
|
|
83
101
|
// eslint-disable-next-line @typescript-eslint/no-deprecated -- load-bearing on the self-hosted path; server-internal cap-mint (Phase 3) not shipped
|
|
84
102
|
internalOptions.capabilityToken ?? configuredAuthToken,
|
|
103
|
+
presenceSession,
|
|
85
104
|
);
|
|
86
105
|
rejectRemovedDatabaseUrlOption(options);
|
|
87
106
|
assertBrowserSafety({
|
|
@@ -108,6 +127,7 @@ export function resolveClientPrelude<S extends SchemaRecord>(
|
|
|
108
127
|
configuredAuthToken,
|
|
109
128
|
credentialResolver,
|
|
110
129
|
authCredentials,
|
|
130
|
+
presenceSession,
|
|
111
131
|
logger,
|
|
112
132
|
url: resolveBaseURL(authInput),
|
|
113
133
|
participantId,
|
|
@@ -90,6 +90,7 @@ export function createInternalComponents<S extends SchemaRecord>(
|
|
|
90
90
|
syncGroups: options.syncGroups,
|
|
91
91
|
instantModels: deriveInstantModels(schema),
|
|
92
92
|
getAuthToken: auth?.getAuthToken,
|
|
93
|
+
presenceSession: auth?.presenceSession,
|
|
93
94
|
runtime,
|
|
94
95
|
});
|
|
95
96
|
|
|
@@ -120,6 +121,7 @@ export function createInternalComponents<S extends SchemaRecord>(
|
|
|
120
121
|
schema,
|
|
121
122
|
baseUrl: bootstrapBaseUrl,
|
|
122
123
|
getAuthToken: auth?.getAuthToken,
|
|
124
|
+
presenceSession: auth?.presenceSession,
|
|
123
125
|
runtime,
|
|
124
126
|
// The one canonical log position; the loader reads its floor when a query
|
|
125
127
|
// leaves so a late answer cannot overwrite a row the pool already knows to
|
|
@@ -143,6 +143,7 @@ import type {
|
|
|
143
143
|
HttpModelClient,
|
|
144
144
|
} from '@abloatai/transaction/transport/http';
|
|
145
145
|
import type { ParticipantKind } from '@abloatai/transaction/types/participant';
|
|
146
|
+
import type { PresenceSession } from '@abloatai/transaction/presence';
|
|
146
147
|
import {
|
|
147
148
|
capturePointRead,
|
|
148
149
|
prepareReadSet,
|
|
@@ -177,6 +178,8 @@ type EntityHalf = Pick<ModelTarget, 'model' | 'id'>;
|
|
|
177
178
|
// `ModelCollaboration<Item>` and `ModelCollaboration<Invoice>` the same type
|
|
178
179
|
// while reading as though they differed.
|
|
179
180
|
export interface ModelCollaboration {
|
|
181
|
+
/** Session projections already held by this client's one presence store. */
|
|
182
|
+
presence(model: string, recordId?: string): readonly PresenceSession[];
|
|
180
183
|
/** Exact point evidence from the HTTP read boundary (stamp captured before data). */
|
|
181
184
|
readPoint(model: string, id: string): Promise<{ data: unknown; stamp: number }>;
|
|
182
185
|
/**
|
|
@@ -336,6 +339,9 @@ interface ReactiveModelSurface<T, Fields = T> {
|
|
|
336
339
|
/** The synchronous local-graph reads. */
|
|
337
340
|
local: LocalReads<T>;
|
|
338
341
|
|
|
342
|
+
/** Sessions currently active on this model, optionally narrowed to one record. */
|
|
343
|
+
presence(recordId?: string): readonly PresenceSession[];
|
|
344
|
+
|
|
339
345
|
/**
|
|
340
346
|
* Claim a row so other writers wait or are rejected until you're done, and
|
|
341
347
|
* inspect or manage that coordination through the same namespace. Call it to
|
|
@@ -1403,6 +1409,8 @@ export function createModelOperations<T, C>(
|
|
|
1403
1409
|
const operations: ModelOperations<T, C> = {
|
|
1404
1410
|
local,
|
|
1405
1411
|
|
|
1412
|
+
presence: (recordId?: string) => collaboration?.presence(registeredModelName, recordId) ?? [],
|
|
1413
|
+
|
|
1406
1414
|
get,
|
|
1407
1415
|
read,
|
|
1408
1416
|
|
|
@@ -39,7 +39,10 @@ import {
|
|
|
39
39
|
bindClaimLifetime,
|
|
40
40
|
claimLifetimeOf,
|
|
41
41
|
} from '@abloatai/transaction/claims/lifetime';
|
|
42
|
-
import
|
|
42
|
+
import {
|
|
43
|
+
attachPresenceToClient,
|
|
44
|
+
type AttachablePresence,
|
|
45
|
+
} from '../../presence/index.js';
|
|
43
46
|
import type { ClaimWaitOptions } from '@abloatai/transaction/types/streams';
|
|
44
47
|
import type { Claim } from '@abloatai/transaction/types/streams';
|
|
45
48
|
import { resolveApiKeyValue, resolveBootstrapBaseUrl } from '@abloatai/transaction/auth/apiKey';
|
|
@@ -96,7 +99,7 @@ export interface ReactiveEngineInputs<S extends SchemaRecord> extends ClientPrel
|
|
|
96
99
|
transport: SyncWebSocket;
|
|
97
100
|
/** The humans() plugin's contribution — built by its `init`, already
|
|
98
101
|
* attached to the connection the context carried. */
|
|
99
|
-
presence:
|
|
102
|
+
presence: AttachablePresence;
|
|
100
103
|
/**
|
|
101
104
|
* The store cluster `humans().init` constructed from the widened context:
|
|
102
105
|
* this client's runtime, the component graph, and the store. The engine
|
|
@@ -183,7 +186,11 @@ export function buildReactiveEngine<const S extends SchemaRecord>(
|
|
|
183
186
|
// filter own echoes by participant id, seeded in `ready()` alongside the
|
|
184
187
|
// locals above.
|
|
185
188
|
const presenceStream = presence;
|
|
186
|
-
const claimStream = createClaimStream(
|
|
189
|
+
const claimStream = createClaimStream(
|
|
190
|
+
{ logger },
|
|
191
|
+
transport,
|
|
192
|
+
presenceStream,
|
|
193
|
+
);
|
|
187
194
|
|
|
188
195
|
// 6. Validate options up front — fail loudly on obviously wrong inputs so
|
|
189
196
|
// strangers don't get silent empty results. Validation errors are written
|
|
@@ -239,17 +246,11 @@ export function buildReactiveEngine<const S extends SchemaRecord>(
|
|
|
239
246
|
kind,
|
|
240
247
|
logger,
|
|
241
248
|
validationError: _validationError,
|
|
242
|
-
onIdentityResolved: ({ userId, participantKind, accountScope,
|
|
249
|
+
onIdentityResolved: ({ userId, participantKind, accountScope, authority }) => {
|
|
243
250
|
selfParticipantId = userId;
|
|
244
251
|
selfParticipantKind = participantKind;
|
|
245
252
|
_resolvedOrganizationId = accountScope;
|
|
246
253
|
_resolvedIdentity = authority;
|
|
247
|
-
presenceStream.setParticipant({
|
|
248
|
-
id: userId,
|
|
249
|
-
kind: participantKind,
|
|
250
|
-
syncGroups: [...syncGroups],
|
|
251
|
-
});
|
|
252
|
-
claimStream.setParticipant({ id: userId });
|
|
253
254
|
},
|
|
254
255
|
});
|
|
255
256
|
const ready = lifecycle.ready;
|
|
@@ -573,6 +574,7 @@ export function buildReactiveEngine<const S extends SchemaRecord>(
|
|
|
573
574
|
modelRegistry,
|
|
574
575
|
hydration,
|
|
575
576
|
{
|
|
577
|
+
presence: (model, recordId) => presenceStream.forModel(model, recordId),
|
|
576
578
|
createClaim: (claimOptions) => publicClaims.create(claimOptions),
|
|
577
579
|
// Lazily referenced: `commits` is declared below this loop, and this
|
|
578
580
|
// only runs when someone actually writes a batch.
|
|
@@ -831,6 +833,11 @@ export function buildReactiveEngine<const S extends SchemaRecord>(
|
|
|
831
833
|
return store.syncStatus;
|
|
832
834
|
},
|
|
833
835
|
|
|
836
|
+
// The humans capability owns the connection-backed presence projection.
|
|
837
|
+
// Keep it on the base client as well as in the plugin surface so the
|
|
838
|
+
// concrete AbloClient contract and runtime object agree before layering.
|
|
839
|
+
presence: presenceStream,
|
|
840
|
+
|
|
834
841
|
schema,
|
|
835
842
|
|
|
836
843
|
// ── Internal accessors for framework integration ─────────────────
|
|
@@ -848,10 +855,6 @@ export function buildReactiveEngine<const S extends SchemaRecord>(
|
|
|
848
855
|
/** The SyncWebSocket — for collaboration events (selection, cursors). */
|
|
849
856
|
get _ws() { return store.getSyncWebSocket(); },
|
|
850
857
|
|
|
851
|
-
/** Presence livestream — same socket as entity sync, no second
|
|
852
|
-
* connection. Stable reference across the engine's lifetime. */
|
|
853
|
-
presence: presenceStream,
|
|
854
|
-
|
|
855
858
|
/** Claim livestream — same socket. Stable reference. */
|
|
856
859
|
claims: publicClaims,
|
|
857
860
|
|
|
@@ -859,6 +862,8 @@ export function buildReactiveEngine<const S extends SchemaRecord>(
|
|
|
859
862
|
|
|
860
863
|
} as Ablo<S>;
|
|
861
864
|
|
|
865
|
+
attachPresenceToClient(engine, presenceStream);
|
|
866
|
+
|
|
862
867
|
Object.defineProperty(engine, kReadEvidence, {
|
|
863
868
|
value: {
|
|
864
869
|
context: cluster.readSetContext,
|
|
@@ -20,6 +20,7 @@ import { classifyRecovery, type RecoveryClass } from '@abloatai/transaction/erro
|
|
|
20
20
|
import { withAuthHeaders, type AuthTokenGetter } from '@abloatai/transaction/auth/credentialSource';
|
|
21
21
|
import { globalRuntime } from '../context.js';
|
|
22
22
|
import type { RuntimeContext } from '../RuntimeContext.js';
|
|
23
|
+
import type { PresenceSessionSource } from '@abloatai/transaction/presence';
|
|
23
24
|
|
|
24
25
|
// ── Response validation ─────────────────────────────────────────────────
|
|
25
26
|
//
|
|
@@ -78,6 +79,8 @@ export interface PostQueryOptions {
|
|
|
78
79
|
* effect without rebuilding the client.
|
|
79
80
|
*/
|
|
80
81
|
capabilityToken?: string;
|
|
82
|
+
/** Server-bound attribution shared with the owning WebSocket. */
|
|
83
|
+
presenceSession?: PresenceSessionSource;
|
|
81
84
|
|
|
82
85
|
/**
|
|
83
86
|
* An optional hook that tries to recover from a rejected credential. When a
|
|
@@ -128,6 +131,7 @@ export async function postQuery(
|
|
|
128
131
|
options.getAuthToken,
|
|
129
132
|
{ 'Content-Type': 'application/json' },
|
|
130
133
|
options.capabilityToken,
|
|
134
|
+
options.presenceSession,
|
|
131
135
|
);
|
|
132
136
|
const response = await fetch(url, {
|
|
133
137
|
method: 'POST',
|
|
@@ -97,6 +97,8 @@ export interface BootstrapOptions {
|
|
|
97
97
|
* {@link BootstrapFetcher.setAuthToken}.
|
|
98
98
|
*/
|
|
99
99
|
getAuthToken?: AuthTokenGetter;
|
|
100
|
+
/** Server-bound attribution shared with the owning WebSocket. */
|
|
101
|
+
presenceSession?: import('@abloatai/transaction/presence').PresenceSessionSource;
|
|
100
102
|
/** The owning client's runtime. Defaults to the module-global bridge. */
|
|
101
103
|
runtime?: RuntimeContext;
|
|
102
104
|
}
|
|
@@ -186,13 +188,14 @@ function classifyRequestFailure(
|
|
|
186
188
|
}
|
|
187
189
|
|
|
188
190
|
export class BootstrapFetcher {
|
|
189
|
-
private options: Required<Omit<BootstrapOptions, 'baseUrl' | 'instantModels' | 'organizationId' | 'cacheScope' | 'getAuthToken' | 'runtime'>> & {
|
|
191
|
+
private options: Required<Omit<BootstrapOptions, 'baseUrl' | 'instantModels' | 'organizationId' | 'cacheScope' | 'getAuthToken' | 'presenceSession' | 'runtime'>> & {
|
|
190
192
|
baseUrl: string;
|
|
191
193
|
instantModels?: string[];
|
|
192
194
|
cacheScope: string | null;
|
|
193
195
|
organizationId?: string;
|
|
194
196
|
authToken?: string;
|
|
195
197
|
getAuthToken?: AuthTokenGetter;
|
|
198
|
+
presenceSession?: import('@abloatai/transaction/presence').PresenceSessionSource;
|
|
196
199
|
runtime?: RuntimeContext;
|
|
197
200
|
};
|
|
198
201
|
|
|
@@ -311,7 +314,12 @@ export class BootstrapFetcher {
|
|
|
311
314
|
try {
|
|
312
315
|
const res = await fetch(`${this.options.baseUrl}/schema`, {
|
|
313
316
|
method: 'GET',
|
|
314
|
-
headers: withAuthHeaders(
|
|
317
|
+
headers: withAuthHeaders(
|
|
318
|
+
this.options.getAuthToken,
|
|
319
|
+
{},
|
|
320
|
+
this.options.authToken,
|
|
321
|
+
this.options.presenceSession,
|
|
322
|
+
),
|
|
315
323
|
});
|
|
316
324
|
if (!res.ok) throw new Error(`schema read-back ${res.status}`);
|
|
317
325
|
const body = (await res.json()) as { models?: unknown };
|
|
@@ -768,6 +776,7 @@ export class BootstrapFetcher {
|
|
|
768
776
|
this.options.getAuthToken,
|
|
769
777
|
{ 'Content-Type': 'application/json' },
|
|
770
778
|
this.options.authToken,
|
|
779
|
+
this.options.presenceSession,
|
|
771
780
|
);
|
|
772
781
|
|
|
773
782
|
const controller = new AbortController();
|
|
@@ -977,7 +986,7 @@ export class BootstrapFetcher {
|
|
|
977
986
|
'Content-Type': 'application/json',
|
|
978
987
|
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
|
979
988
|
Pragma: 'no-cache',
|
|
980
|
-
}, this.options.authToken),
|
|
989
|
+
}, this.options.authToken, this.options.presenceSession),
|
|
981
990
|
signal: controller.signal,
|
|
982
991
|
cache: 'no-store', // Force browser to not cache
|
|
983
992
|
});
|
|
@@ -1049,7 +1058,7 @@ export class BootstrapFetcher {
|
|
|
1049
1058
|
method: 'GET',
|
|
1050
1059
|
headers: withAuthHeaders(this.options.getAuthToken, {
|
|
1051
1060
|
'Content-Type': 'application/json',
|
|
1052
|
-
}, this.options.authToken),
|
|
1061
|
+
}, this.options.authToken, this.options.presenceSession),
|
|
1053
1062
|
signal: controller.signal,
|
|
1054
1063
|
});
|
|
1055
1064
|
} catch (error) {
|
|
@@ -41,6 +41,7 @@ import type { LoadWhere, Query, WhereClause, WhereOp } from '../query/types.js';
|
|
|
41
41
|
import { normalizeWhere } from '@abloatai/transaction/client/resources/where';
|
|
42
42
|
import type { Schema } from '@abloatai/transaction/schema/schema';
|
|
43
43
|
import type { LogPositionPort } from '../logPosition.js';
|
|
44
|
+
import type { PresenceSessionSource } from '@abloatai/transaction/presence';
|
|
44
45
|
|
|
45
46
|
export interface OnDemandLoaderOptions {
|
|
46
47
|
readonly objectPool: InstanceCache;
|
|
@@ -58,6 +59,7 @@ export interface OnDemandLoaderOptions {
|
|
|
58
59
|
* propagate without re-instantiating the coordinator.
|
|
59
60
|
*/
|
|
60
61
|
readonly getAuthToken?: () => string | null;
|
|
62
|
+
readonly presenceSession?: PresenceSessionSource;
|
|
61
63
|
/** @deprecated Use `getAuthToken`. */
|
|
62
64
|
readonly getCapabilityToken?: () => string | null;
|
|
63
65
|
/** The owning client's runtime. Defaults to the module-global bridge. */
|
|
@@ -647,6 +649,7 @@ export class OnDemandLoader {
|
|
|
647
649
|
getAuthToken: this.authTokenProvider ?? undefined,
|
|
648
650
|
recoverCredential: this.credentialRecovery ?? undefined,
|
|
649
651
|
runtime: this.opts.runtime,
|
|
652
|
+
presenceSession: this.opts.presenceSession,
|
|
650
653
|
},
|
|
651
654
|
{ queries: [query] },
|
|
652
655
|
);
|
|
@@ -36,7 +36,6 @@ export type {
|
|
|
36
36
|
SyncCapabilities,
|
|
37
37
|
BootstrapHint,
|
|
38
38
|
BootstrapDataEvent,
|
|
39
|
-
PresenceUpdate,
|
|
40
39
|
CoreSyncEventMap,
|
|
41
40
|
DefaultCollaborationEvents,
|
|
42
41
|
EventMap,
|
|
@@ -129,14 +128,10 @@ export class SyncWebSocket<
|
|
|
129
128
|
|
|
130
129
|
/**
|
|
131
130
|
* The open ritual, run by the transport between its `connected` emit and the
|
|
132
|
-
* heartbeat start:
|
|
131
|
+
* heartbeat start: tell the server where we left off,
|
|
133
132
|
* request the deltas we missed, and start the catch-up poll.
|
|
134
133
|
*/
|
|
135
134
|
protected override onOpened(): void {
|
|
136
|
-
// Send presence update with timezone (server sets presence to "online" on connect,
|
|
137
|
-
// this improves localTime accuracy by providing the user's actual timezone)
|
|
138
|
-
this.sendPresenceUpdate('online');
|
|
139
|
-
|
|
140
135
|
// Immediately request incremental sync based on our stored cursor.
|
|
141
136
|
// `requestIncrementalSync` is async — a bare call inside try/catch is a
|
|
142
137
|
// rejection hole (the catch never sees it); route failures through
|
|
@@ -323,42 +318,6 @@ export class SyncWebSocket<
|
|
|
323
318
|
this.sendAck(syncId);
|
|
324
319
|
}
|
|
325
320
|
|
|
326
|
-
/**
|
|
327
|
-
* Send presence update to server.
|
|
328
|
-
* Use this for:
|
|
329
|
-
* - Updating timezone (improves localTime accuracy shown to other users)
|
|
330
|
-
* - Manual status changes (away, custom status)
|
|
331
|
-
*
|
|
332
|
-
* Note: "online" status is automatically set by server on WebSocket connect,
|
|
333
|
-
* and "offline" is set on disconnect. You don't need to call this for basic online/offline.
|
|
334
|
-
*
|
|
335
|
-
* @param status - "online", "away", or custom status string
|
|
336
|
-
* @param customStatus - Optional custom status message
|
|
337
|
-
*/
|
|
338
|
-
sendPresenceUpdate(
|
|
339
|
-
status: 'online' | 'away' | 'offline' = 'online',
|
|
340
|
-
customStatus?: string
|
|
341
|
-
): void {
|
|
342
|
-
if (!this.isConnected()) return;
|
|
343
|
-
|
|
344
|
-
const timezone = (() => {
|
|
345
|
-
try {
|
|
346
|
-
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
347
|
-
} catch {
|
|
348
|
-
return 'UTC';
|
|
349
|
-
}
|
|
350
|
-
})();
|
|
351
|
-
|
|
352
|
-
this.send({
|
|
353
|
-
type: 'presence_update',
|
|
354
|
-
payload: {
|
|
355
|
-
status,
|
|
356
|
-
timezone,
|
|
357
|
-
...(customStatus ? { customStatus } : {}),
|
|
358
|
-
},
|
|
359
|
-
});
|
|
360
|
-
}
|
|
361
|
-
|
|
362
321
|
/**
|
|
363
322
|
* Stop the periodic catchup interval
|
|
364
323
|
*/
|
|
@@ -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,16 +14,15 @@
|
|
|
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
|
|
|
25
22
|
import type {
|
|
26
23
|
WsTransport,
|
|
27
|
-
PresenceUpdate,
|
|
28
24
|
} from '@abloatai/transaction/transport/websocket';
|
|
25
|
+
import type { PresenceSession } from '@abloatai/transaction/presence';
|
|
29
26
|
import type {
|
|
30
27
|
ClaimOptions,
|
|
31
28
|
ClaimTarget,
|
|
@@ -79,12 +76,15 @@ function claimLabel(type: string, id: string, field?: string): string {
|
|
|
79
76
|
}
|
|
80
77
|
|
|
81
78
|
export interface ClaimStreamConfig {
|
|
82
|
-
/** Identity used to filter our own active claims out of `others`. */
|
|
83
|
-
participantId: string;
|
|
84
79
|
/** Where the coordination trace is logged. Defaults to silent. */
|
|
85
80
|
logger?: Logger;
|
|
86
81
|
}
|
|
87
82
|
|
|
83
|
+
export interface ClaimPresenceSource {
|
|
84
|
+
readonly others: readonly PresenceSession[];
|
|
85
|
+
onChange(listener: () => void): () => void;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
88
|
/**
|
|
89
89
|
* How long a heartbeat waits for its `claim_heartbeat_ack` before giving up
|
|
90
90
|
* as transient (the auto-heartbeat loop's next tick retries). Comfortably
|
|
@@ -107,14 +107,6 @@ export interface AttachableClaimStream extends ClaimStream {
|
|
|
107
107
|
*/
|
|
108
108
|
claim(target: PresenceTarget, opts?: ClaimOptions, claimId?: string): Claim;
|
|
109
109
|
attach(transport: ClaimTransport): void;
|
|
110
|
-
/**
|
|
111
|
-
* Seeds the participant identity once the host resolves it. The stream can
|
|
112
|
-
* be built before identity is known — a hosted client learns who it is
|
|
113
|
-
* from its credential's scope during connect — and until then the
|
|
114
|
-
* construction-time id (possibly empty) would let the participant's own
|
|
115
|
-
* claims into `others`. Idempotent; later frames filter on the new id.
|
|
116
|
-
*/
|
|
117
|
-
setParticipant(participant: { id: string }): void;
|
|
118
110
|
dispose(): void;
|
|
119
111
|
}
|
|
120
112
|
|
|
@@ -144,10 +136,8 @@ interface OwnGrant {
|
|
|
144
136
|
export function createClaimStream(
|
|
145
137
|
config: ClaimStreamConfig,
|
|
146
138
|
transport: ClaimTransport | null = null,
|
|
139
|
+
presence: ClaimPresenceSource | null = null,
|
|
147
140
|
): AttachableClaimStream {
|
|
148
|
-
// Mutable: the host seeds the resolved identity via `setParticipant` once
|
|
149
|
-
// it is known; the own-claim filter always reads the current value.
|
|
150
|
-
let participantId = config.participantId;
|
|
151
141
|
const logger = config.logger ?? noopLogger;
|
|
152
142
|
|
|
153
143
|
// ── State: others' open claims, keyed by claimId ───────────────
|
|
@@ -247,54 +237,7 @@ export function createClaimStream(
|
|
|
247
237
|
if (attached) return;
|
|
248
238
|
attached = t;
|
|
249
239
|
|
|
250
|
-
//
|
|
251
|
-
// active-claim set. Prune previous claims by holder, then
|
|
252
|
-
// re-add from the frame — the frame is authoritative for that
|
|
253
|
-
// participant's open claims at that moment.
|
|
254
|
-
unsubs.push(
|
|
255
|
-
t.subscribe('presence_update', (event: PresenceUpdate) => {
|
|
256
|
-
if (!event.userId) return;
|
|
257
|
-
if (event.userId === participantId) return;
|
|
258
|
-
|
|
259
|
-
let mutated = false;
|
|
260
|
-
|
|
261
|
-
if (event.kind === 'leave') {
|
|
262
|
-
for (const [id, claim] of activeByClaimId) {
|
|
263
|
-
if (claim.heldBy === event.userId) {
|
|
264
|
-
activeByClaimId.delete(id);
|
|
265
|
-
mutated = true;
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
if (mutated) notifyListeners();
|
|
269
|
-
return;
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
for (const [id, claim] of activeByClaimId) {
|
|
273
|
-
if (claim.heldBy === event.userId) {
|
|
274
|
-
activeByClaimId.delete(id);
|
|
275
|
-
mutated = true;
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
for (const claim of event.activeClaims ?? []) {
|
|
279
|
-
// Terminal-status entries (committed / expired / canceled) are
|
|
280
|
-
// one-shot "this claim ended" signals. The holder sweep above
|
|
281
|
-
// already removed the prior active entry; skipping the re-add
|
|
282
|
-
// drops it from `others`, which is what resolves a contender's
|
|
283
|
-
// `settled()`. Absent status means active (wire back-compat).
|
|
284
|
-
if (claim.status && claim.status !== 'active') continue;
|
|
285
|
-
observeForeignClaim(
|
|
286
|
-
event.userId,
|
|
287
|
-
claim,
|
|
288
|
-
event.participantKind,
|
|
289
|
-
event.isAgent,
|
|
290
|
-
);
|
|
291
|
-
mutated = true;
|
|
292
|
-
}
|
|
293
|
-
if (mutated) notifyListeners();
|
|
294
|
-
}),
|
|
295
|
-
);
|
|
296
|
-
|
|
297
|
-
// (2) Server-side rejection frames.
|
|
240
|
+
// Server-side rejection frames.
|
|
298
241
|
unsubs.push(
|
|
299
242
|
t.subscribe('claim_rejected', (rejection) => {
|
|
300
243
|
if (!rejection.claimId) return;
|
|
@@ -476,6 +419,46 @@ export function createClaimStream(
|
|
|
476
419
|
|
|
477
420
|
if (transport) attach(transport);
|
|
478
421
|
|
|
422
|
+
const refreshPresenceClaims = (): void => {
|
|
423
|
+
if (presence === null) return;
|
|
424
|
+
activeByClaimId.clear();
|
|
425
|
+
for (const session of presence.others) {
|
|
426
|
+
for (const activity of session.activities) {
|
|
427
|
+
if (
|
|
428
|
+
activity.operation !== 'claim'
|
|
429
|
+
|| activity.source !== 'claim'
|
|
430
|
+
|| activity.target.id === undefined
|
|
431
|
+
) continue;
|
|
432
|
+
const claimId = activity.id.startsWith('claim:')
|
|
433
|
+
? activity.id.slice('claim:'.length)
|
|
434
|
+
: activity.id;
|
|
435
|
+
observeForeignClaim(
|
|
436
|
+
session.participant.id,
|
|
437
|
+
{
|
|
438
|
+
claimId,
|
|
439
|
+
entityType: activity.target.model,
|
|
440
|
+
entityId: activity.target.id,
|
|
441
|
+
...(activity.target.field !== undefined
|
|
442
|
+
? { field: activity.target.field }
|
|
443
|
+
: {}),
|
|
444
|
+
...(activity.target.fields !== undefined
|
|
445
|
+
? { fields: activity.target.fields }
|
|
446
|
+
: {}),
|
|
447
|
+
description: 'claim',
|
|
448
|
+
declaredAt: Date.parse(activity.startedAt),
|
|
449
|
+
expiresAt: Date.parse(activity.expiresAt),
|
|
450
|
+
},
|
|
451
|
+
session.participant.kind,
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
notifyListeners();
|
|
456
|
+
};
|
|
457
|
+
if (presence !== null) {
|
|
458
|
+
refreshPresenceClaims();
|
|
459
|
+
unsubs.push(presence.onChange(refreshPresenceClaims));
|
|
460
|
+
}
|
|
461
|
+
|
|
479
462
|
// ── Outbound ────────────────────────────────────────────────────
|
|
480
463
|
function sendBegin(claimId: string, claim: OwnClaim): void {
|
|
481
464
|
if (!attached?.isConnected()) return;
|
|
@@ -705,9 +688,6 @@ export function createClaimStream(
|
|
|
705
688
|
);
|
|
706
689
|
},
|
|
707
690
|
attach,
|
|
708
|
-
setParticipant(participant: { id: string }): void {
|
|
709
|
-
participantId = participant.id;
|
|
710
|
-
},
|
|
711
691
|
dispose(): void {
|
|
712
692
|
for (const off of unsubs) off();
|
|
713
693
|
unsubs.length = 0;
|
|
@@ -9,7 +9,6 @@ import type { SyncStatus } from '../storeContract.js';
|
|
|
9
9
|
import type {
|
|
10
10
|
BootstrapHint,
|
|
11
11
|
BootstrapDataEvent,
|
|
12
|
-
PresenceUpdate,
|
|
13
12
|
SyncWebSocket,
|
|
14
13
|
EventMap,
|
|
15
14
|
} from './SyncWebSocket.js';
|
|
@@ -31,7 +30,6 @@ export interface SocketEventHost<TCollaboration extends EventMap<TCollaboration>
|
|
|
31
30
|
applyDeltaFrame(deltas: SyncDelta[]): void;
|
|
32
31
|
handleBootstrapRequired(hint: BootstrapHint): void;
|
|
33
32
|
handleBootstrapData(data: BootstrapDataEvent): void;
|
|
34
|
-
handlePresenceUpdate(data: PresenceUpdate): void;
|
|
35
33
|
performCredentialRefresh(): Promise<'refreshed' | 'session_error' | 'network_error'>;
|
|
36
34
|
handleTerminalSessionError(error: Error): void;
|
|
37
35
|
nudgeReconnect(): void;
|
|
@@ -93,11 +91,6 @@ export function wireSocketEvents<TCollaboration extends EventMap<TCollaboration>
|
|
|
93
91
|
deps.handleBootstrapData(data);
|
|
94
92
|
});
|
|
95
93
|
|
|
96
|
-
const onPresenceUpdate = deps.syncWebSocket.subscribe('presence_update', (...args) => {
|
|
97
|
-
const data = args[0];
|
|
98
|
-
deps.handlePresenceUpdate(data);
|
|
99
|
-
});
|
|
100
|
-
|
|
101
94
|
// Error events
|
|
102
95
|
const onError = deps.syncWebSocket.subscribe('error', (error: Error) => {
|
|
103
96
|
if (error.message === 'Network is offline' || error.message === 'WebSocket connection failed') {
|
|
@@ -189,7 +182,7 @@ export function wireSocketEvents<TCollaboration extends EventMap<TCollaboration>
|
|
|
189
182
|
deps.disposers.push(
|
|
190
183
|
onConnected, onDisconnected, onReconnecting,
|
|
191
184
|
onDelta, onDeltaBatch, onBootstrapRequired,
|
|
192
|
-
onBootstrapData,
|
|
185
|
+
onBootstrapData,
|
|
193
186
|
onError, onSessionError, onHandshakeFailed, onReconnectFailed,
|
|
194
187
|
() => { deps.areaOfInterest.dispose(); },
|
|
195
188
|
);
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createPresenceProjection,
|
|
3
|
+
type PresenceProjection,
|
|
4
|
+
type PresenceProjectionEvents,
|
|
5
|
+
type PresenceView,
|
|
6
|
+
} from '@abloatai/transaction/presence';
|
|
7
|
+
|
|
8
|
+
/** Reactive-client presence backed by the client's existing live connection. */
|
|
9
|
+
export interface ReactivePresence extends PresenceView {
|
|
10
|
+
forModel(model: string, recordId?: string): ReturnType<PresenceProjection['forModel']>;
|
|
11
|
+
onChange(listener: () => void): () => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Lifecycle hooks kept inside the humans composition boundary. */
|
|
15
|
+
export interface AttachablePresence extends ReactivePresence {
|
|
16
|
+
attach(transport: PresenceProjectionEvents): void;
|
|
17
|
+
dispose(): void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const clientPresence = new WeakMap<object, ReactivePresence>();
|
|
21
|
+
|
|
22
|
+
/** Framework bridge that does not consume a string key on the model namespace. */
|
|
23
|
+
export function attachPresenceToClient(client: object, presence: ReactivePresence): void {
|
|
24
|
+
clientPresence.set(client, presence);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function presenceOfClient(client: object): ReactivePresence {
|
|
28
|
+
const presence = clientPresence.get(client);
|
|
29
|
+
if (presence === undefined) throw new Error('presence is not attached to this client');
|
|
30
|
+
return presence;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function createPresence(
|
|
34
|
+
transport: PresenceProjectionEvents | null = null,
|
|
35
|
+
): AttachablePresence {
|
|
36
|
+
let projection: PresenceProjection | null = null;
|
|
37
|
+
const listeners = new Set<() => void>();
|
|
38
|
+
let unsubscribe: (() => void) | null = null;
|
|
39
|
+
|
|
40
|
+
const notify = (): void => {
|
|
41
|
+
for (const listener of listeners) listener();
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const attach = (events: PresenceProjectionEvents): void => {
|
|
45
|
+
if (projection !== null) return;
|
|
46
|
+
projection = createPresenceProjection(events);
|
|
47
|
+
unsubscribe = projection.subscribe(notify);
|
|
48
|
+
notify();
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
if (transport !== null) attach(transport);
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
get active() { return projection?.active ?? []; },
|
|
55
|
+
get others() { return projection?.others ?? []; },
|
|
56
|
+
onChange(listener) {
|
|
57
|
+
listeners.add(listener);
|
|
58
|
+
return () => { listeners.delete(listener); };
|
|
59
|
+
},
|
|
60
|
+
attach,
|
|
61
|
+
forModel(model, recordId) {
|
|
62
|
+
return projection?.forModel(model, recordId) ?? [];
|
|
63
|
+
},
|
|
64
|
+
dispose() {
|
|
65
|
+
unsubscribe?.();
|
|
66
|
+
unsubscribe = null;
|
|
67
|
+
projection?.dispose();
|
|
68
|
+
projection = null;
|
|
69
|
+
listeners.clear();
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|