@abloatai/humans 0.61.0 → 0.63.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/index.d.ts +1 -0
- package/dist/local/BaseSyncedStore.d.ts +2 -4
- package/dist/local/BaseSyncedStore.js +0 -3
- package/dist/local/InstanceCache.js +1 -1
- 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 +26 -0
- package/dist/local/client/createModelOperations.js +85 -0
- package/dist/local/client/reactiveEngine.d.ts +2 -2
- package/dist/local/client/reactiveEngine.js +25 -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 +6 -3
- package/dist/local/transactions/mutations/MutationQueue.js +36 -2
- package/dist/local/transactions/mutations/deltaConfirmation.d.ts +1 -0
- package/dist/local/transactions/mutations/deltaConfirmation.js +4 -0
- package/dist/local/transactions/mutations/failureHandling.d.ts +1 -0
- package/dist/local/transactions/mutations/failureHandling.js +1 -1
- package/dist/local/transactions/mutations/replayValidation.d.ts +4 -4
- package/dist/presence/index.d.ts +20 -0
- package/dist/presence/index.js +67 -0
- package/dist/presence/readActivity.d.ts +19 -0
- package/dist/presence/readActivity.js +122 -0
- package/dist/react/AbloProvider.d.ts +3 -3
- package/dist/react/AbloProvider.js +4 -3
- package/dist/react/createAbloReact.d.ts +4 -0
- package/dist/react/createAbloReact.js +12 -2
- package/dist/react/useAblo.d.ts +2 -0
- package/dist/react/useAblo.js +10 -5
- package/dist/react/usePresence.d.ts +19 -0
- package/dist/react/usePresence.js +28 -0
- package/dist/react.d.ts +1 -0
- package/dist/react.js +1 -0
- package/dist/surface.d.ts +1 -1
- package/dist/surface.js +2 -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/index.ts +1 -0
- package/src/local/BaseSyncedStore.ts +0 -6
- package/src/local/InstanceCache.ts +1 -1
- package/src/local/client/clientPrelude.ts +20 -0
- package/src/local/client/createInternalComponents.ts +2 -0
- package/src/local/client/createModelOperations.ts +140 -0
- package/src/local/client/reactiveEngine.ts +35 -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/local/transactions/mutations/MutationQueue.ts +26 -2
- package/src/local/transactions/mutations/deltaConfirmation.ts +3 -0
- package/src/local/transactions/mutations/failureHandling.ts +2 -1
- package/src/presence/index.ts +101 -0
- package/src/presence/readActivity.ts +149 -0
- package/src/react/AbloProvider.tsx +14 -9
- package/src/react/createAbloReact.ts +25 -1
- package/src/react/useAblo.ts +14 -6
- package/src/react/usePresence.ts +88 -0
- package/src/react.ts +4 -0
- package/src/surface.ts +2 -0
- package/dist/presenceStream.d.ts +0 -69
- package/dist/presenceStream.js +0 -200
- package/src/presenceStream.ts +0 -279
package/dist/Ablo.d.ts
CHANGED
|
@@ -57,7 +57,7 @@ export type Ablo<S extends SchemaRecord> = AbloClient<S>;
|
|
|
57
57
|
* const ablo = Ablo({ schema, session: { endpoint: '/api/ablo-session' } });
|
|
58
58
|
* ```
|
|
59
59
|
*
|
|
60
|
-
* Server-side agents, workers, and services use `@abloatai/
|
|
60
|
+
* Server-side agents, workers, and services use `@abloatai/ablo`.
|
|
61
61
|
*/
|
|
62
62
|
export declare function Ablo<const S extends SchemaRecord, const P extends readonly AbloPlugin[]>(options: AbloOptions<S> & {
|
|
63
63
|
plugins: P;
|
|
@@ -97,10 +97,10 @@ export declare namespace Ablo {
|
|
|
97
97
|
type ClaimTarget = _Streams.ClaimTarget;
|
|
98
98
|
type PresenceTarget = _Streams.PresenceTarget;
|
|
99
99
|
type Duration = _Streams.Duration;
|
|
100
|
-
type
|
|
100
|
+
type Presence = import('./presence/index.js').ReactivePresence;
|
|
101
|
+
type PresenceSession = import('@abloatai/transaction/presence').PresenceSession;
|
|
102
|
+
type PresenceActivity = import('@abloatai/transaction/presence').PresenceActivity;
|
|
101
103
|
type ClaimStream = _Streams.ClaimStream;
|
|
102
|
-
type Peer = _Streams.Peer;
|
|
103
|
-
type Activity = _Streams.Activity;
|
|
104
104
|
type Claim = _Streams.Claim;
|
|
105
105
|
type ClaimRejection = _Streams.ClaimRejection;
|
|
106
106
|
type ClaimLost = _Streams.ClaimLost;
|
package/dist/Ablo.js
CHANGED
|
@@ -39,7 +39,7 @@ export function Ablo(options) {
|
|
|
39
39
|
// resolver, the base URL, the logger, and this participant's identity —
|
|
40
40
|
// and fails on a misconfiguration before anything is constructed.
|
|
41
41
|
const prelude = resolveClientPrelude(options);
|
|
42
|
-
const { internalOptions, authCredentials, logger, url, participantId, kind } = prelude;
|
|
42
|
+
const { internalOptions, authCredentials, presenceSession, logger, url, participantId, kind, } = prelude;
|
|
43
43
|
// 2. The connection, built here in the composition root — before the plugin
|
|
44
44
|
// list resolves, so `PluginContext.transport` carries the instance a
|
|
45
45
|
// plugin holds for the client's lifetime. It holds no socket until
|
|
@@ -55,6 +55,7 @@ export function Ablo(options) {
|
|
|
55
55
|
baseUrl: url,
|
|
56
56
|
kind,
|
|
57
57
|
getAuthToken: authCredentials.getAuthToken,
|
|
58
|
+
presenceSession,
|
|
58
59
|
collaborationEvents: [...(internalOptions.collaborationEvents ?? [])],
|
|
59
60
|
syncGroups: [...(internalOptions.syncGroups ?? [])],
|
|
60
61
|
deferConnect: true,
|
package/dist/client.d.ts
CHANGED
|
@@ -11,7 +11,6 @@
|
|
|
11
11
|
* the factory back and creating a cycle.
|
|
12
12
|
*/
|
|
13
13
|
import type { Schema, SchemaRecord, Model, InferCreate, InferRow } from '@abloatai/transaction/schema/schema';
|
|
14
|
-
import type { PresenceStream } from '@abloatai/transaction/types/streams';
|
|
15
14
|
import type { InstanceCache } from './local/InstanceCache.js';
|
|
16
15
|
import type { SyncStoreContract } from './react/context.js';
|
|
17
16
|
import type { SyncWebSocket, CoreSyncEventMap } from './local/sync/SyncWebSocket.js';
|
|
@@ -21,6 +20,7 @@ import type { ClaimResource, CommitResource } from '@abloatai/transaction/client
|
|
|
21
20
|
import type { EffectiveAuthority } from '@abloatai/transaction/auth';
|
|
22
21
|
import type { ReadDependency } from '@abloatai/transaction/coordination';
|
|
23
22
|
import type { CapturedRow } from '@abloatai/transaction/transport/http';
|
|
23
|
+
import type { ReactivePresence } from './presence/index.js';
|
|
24
24
|
export type { LocalReadOptions } from './local/client/resourceTypes.js';
|
|
25
25
|
/** The typed sync engine client — one property per model in the schema */
|
|
26
26
|
export type AbloClient<S extends SchemaRecord> = {
|
|
@@ -197,19 +197,14 @@ export type AbloClient<S extends SchemaRecord> = {
|
|
|
197
197
|
* ```
|
|
198
198
|
*/
|
|
199
199
|
readonly syncStatus: SyncStatus;
|
|
200
|
-
/** The underlying schema */
|
|
201
|
-
readonly schema: Schema<S>;
|
|
202
200
|
/**
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
* participant for presence does not open a second socket. See
|
|
207
|
-
* {@link PresenceStream}.
|
|
208
|
-
*
|
|
209
|
-
* The reference is stable for the engine's lifetime — the underlying connection
|
|
210
|
-
* is rotated on `dispose()`, but this object stays the same.
|
|
201
|
+
* Session-owned live activity projected from this client's existing
|
|
202
|
+
* connection. Use `active` for every visible activity, `others` to exclude
|
|
203
|
+
* this session, and `forModel(model, id?)` for a model-native view.
|
|
211
204
|
*/
|
|
212
|
-
readonly presence:
|
|
205
|
+
readonly presence: ReactivePresence;
|
|
206
|
+
/** The underlying schema */
|
|
207
|
+
readonly schema: Schema<S>;
|
|
213
208
|
/**
|
|
214
209
|
* @internal The supported coordination API is `ablo.<model>.claim`. This
|
|
215
210
|
* accessor is the internal stream that surface is built on and is not part of
|
package/dist/humans.d.ts
CHANGED
|
@@ -6,10 +6,10 @@
|
|
|
6
6
|
* contracts.
|
|
7
7
|
*/
|
|
8
8
|
import type { PluginContext, AppliedChange } from './plugin.js';
|
|
9
|
-
import { type
|
|
9
|
+
import { type AttachablePresence } from './presence/index.js';
|
|
10
10
|
import { kStoreCluster, type InternalAbloOptions, type StoreCluster } from './local/client/storeCluster.js';
|
|
11
11
|
export interface HumansSurface {
|
|
12
|
-
readonly presence:
|
|
12
|
+
readonly presence: AttachablePresence;
|
|
13
13
|
readonly [kStoreCluster]?: StoreCluster;
|
|
14
14
|
}
|
|
15
15
|
export declare function humans(): {
|
package/dist/humans.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { AbloValidationError } from '@abloatai/transaction/errors';
|
|
2
|
-
import {
|
|
2
|
+
import { createPresence } from './presence/index.js';
|
|
3
3
|
import { buildStoreCluster, kStoreCluster, } from './local/client/storeCluster.js';
|
|
4
4
|
export function humans() {
|
|
5
5
|
let applyChanges = null;
|
|
@@ -22,11 +22,7 @@ export function humans() {
|
|
|
22
22
|
applyChanges = (changes) => { cluster.store.applyChangesToPool(changes); };
|
|
23
23
|
}
|
|
24
24
|
return {
|
|
25
|
-
presence:
|
|
26
|
-
participantId: context.participant?.id ?? '',
|
|
27
|
-
syncGroups: [...(context.syncGroups ?? [])],
|
|
28
|
-
isAgent: context.participant?.kind === 'agent',
|
|
29
|
-
}, context.transport ?? null),
|
|
25
|
+
presence: createPresence(context.transport ?? null),
|
|
30
26
|
...(cluster ? { [kStoreCluster]: cluster } : {}),
|
|
31
27
|
};
|
|
32
28
|
},
|
package/dist/index.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ export { Ablo } from './Ablo.js';
|
|
|
2
2
|
export type { AbloOptions, AbloReads, CredentialProvider, InternalAbloOptions, ModelClaim, ModelTarget, } from './Ablo.js';
|
|
3
3
|
export { humans, type HumansSurface } from './humans.js';
|
|
4
4
|
export type { AbloClient } from './client.js';
|
|
5
|
+
export type { CollaborationEventContext } from '@abloatai/transaction/collaboration';
|
|
5
6
|
export type { AbloPlugin, MergedSurface, PipelineStage, PluginById, TransportCapabilities, } from './plugin.js';
|
|
6
7
|
export { defineMutators, type MutatorDefs, type MutatorFn, } from './local/mutators/defineMutators.js';
|
|
7
8
|
export { createTransaction, type Transaction, type ReaderFindOptions, } from './local/mutators/Transaction.js';
|
|
@@ -18,7 +18,7 @@ import type { SyncClient } from './SyncClient.js';
|
|
|
18
18
|
import type { Database, BootstrapResult, BootstrapRequirements } from './Database.js';
|
|
19
19
|
import type { InstanceCache } from './InstanceCache.js';
|
|
20
20
|
import { ModelRegistry } from './ModelRegistry.js';
|
|
21
|
-
import { SyncWebSocket, type SyncDelta, type SyncGroupChangePayload, type GroupAddedPayload, type GroupRemovedPayload, type BootstrapHint, type BootstrapDataEvent, type
|
|
21
|
+
import { SyncWebSocket, type SyncDelta, type SyncGroupChangePayload, type GroupAddedPayload, type GroupRemovedPayload, type BootstrapHint, type BootstrapDataEvent, type EventMap, type DefaultCollaborationEvents, type SyncWebSocketEventMap } from './sync/SyncWebSocket.js';
|
|
22
22
|
import { QueryProcessor } from './query/QueryProcessor.js';
|
|
23
23
|
import { Model } from './Model.js';
|
|
24
24
|
import type { RuntimeContext } from './RuntimeContext.js';
|
|
@@ -153,7 +153,7 @@ export declare const BOOTSTRAP_CONFIG: {
|
|
|
153
153
|
readonly RETRY_DELAY_MS: 500;
|
|
154
154
|
};
|
|
155
155
|
export { ModelScope };
|
|
156
|
-
export type { SyncDelta, SyncGroupChangePayload, GroupAddedPayload, GroupRemovedPayload, BootstrapHint, BootstrapDataEvent,
|
|
156
|
+
export type { SyncDelta, SyncGroupChangePayload, GroupAddedPayload, GroupRemovedPayload, BootstrapHint, BootstrapDataEvent, };
|
|
157
157
|
export { deriveSyncPlanFromSchema } from './sync/syncPlan.js';
|
|
158
158
|
/**
|
|
159
159
|
* The abstract base class that application-specific sync stores extend. It
|
|
@@ -791,8 +791,6 @@ export declare class BaseSyncedStore<TCollaboration extends EventMap<TCollaborat
|
|
|
791
791
|
protected handleBootstrapRequired(_hint: BootstrapHint): void;
|
|
792
792
|
/** Handle bootstrap_data event. Override in subclass. */
|
|
793
793
|
protected handleBootstrapData(_data: BootstrapDataEvent): void;
|
|
794
|
-
/** Handle presence_update event. Override in subclass. */
|
|
795
|
-
protected handlePresenceUpdate(_data: PresenceUpdate): void;
|
|
796
794
|
protected incrementPendingChanges(): void;
|
|
797
795
|
protected decrementPendingChanges(): void;
|
|
798
796
|
protected updateSyncStatus(updates: Partial<SyncStatus>): void;
|
|
@@ -1105,7 +1105,6 @@ export class BaseSyncedStore {
|
|
|
1105
1105
|
applyDeltaFrame: (deltas) => { this.applyDeltaFrame(deltas); },
|
|
1106
1106
|
handleBootstrapRequired: (hint) => { this.handleBootstrapRequired(hint); },
|
|
1107
1107
|
handleBootstrapData: (data) => { this.handleBootstrapData(data); },
|
|
1108
|
-
handlePresenceUpdate: (data) => { this.handlePresenceUpdate(data); },
|
|
1109
1108
|
performCredentialRefresh: () => this.performCredentialRefresh(),
|
|
1110
1109
|
handleTerminalSessionError: (error) => { this.terminalSessionLifecycle.start(error); },
|
|
1111
1110
|
nudgeReconnect: () => { this.nudgeReconnect(); },
|
|
@@ -1466,8 +1465,6 @@ export class BaseSyncedStore {
|
|
|
1466
1465
|
handleBootstrapData(_data) {
|
|
1467
1466
|
this.updateSyncStatus({ state: 'syncing' });
|
|
1468
1467
|
}
|
|
1469
|
-
/** Handle presence_update event. Override in subclass. */
|
|
1470
|
-
handlePresenceUpdate(_data) { }
|
|
1471
1468
|
// ── Pending changes tracking ─────────────────────────────────────────────
|
|
1472
1469
|
incrementPendingChanges() {
|
|
1473
1470
|
runInAction(() => { this.syncStatus.pendingChanges++; });
|
|
@@ -1019,7 +1019,7 @@ export class InstanceCache {
|
|
|
1019
1019
|
});
|
|
1020
1020
|
}
|
|
1021
1021
|
startGC() {
|
|
1022
|
-
if (this.gcTimer)
|
|
1022
|
+
if (this.gcTimer || this.config.gcInterval <= 0)
|
|
1023
1023
|
return;
|
|
1024
1024
|
this.gcTimer = setInterval(() => this.gc(), this.config.gcInterval);
|
|
1025
1025
|
// Don't hold a headless Node process open just for pool GC — without
|
|
@@ -392,7 +392,7 @@ export declare class SyncClient extends EventEmitter {
|
|
|
392
392
|
awaitingDeltaCount: number;
|
|
393
393
|
awaitingDeltaTransactions: {
|
|
394
394
|
id: string;
|
|
395
|
-
type: "
|
|
395
|
+
type: "create" | "update" | "delete" | "archive" | "unarchive";
|
|
396
396
|
modelName: string;
|
|
397
397
|
modelId: string;
|
|
398
398
|
syncIdNeeded: number | undefined;
|
|
@@ -401,13 +401,13 @@ export declare class SyncClient extends EventEmitter {
|
|
|
401
401
|
}[];
|
|
402
402
|
pendingTransactions: {
|
|
403
403
|
id: string;
|
|
404
|
-
type: "
|
|
404
|
+
type: "create" | "update" | "delete" | "archive" | "unarchive";
|
|
405
405
|
modelName: string;
|
|
406
406
|
modelId: string;
|
|
407
407
|
}[];
|
|
408
408
|
executingTransactions: {
|
|
409
409
|
id: string;
|
|
410
|
-
type: "
|
|
410
|
+
type: "create" | "update" | "delete" | "archive" | "unarchive";
|
|
411
411
|
modelName: string;
|
|
412
412
|
modelId: string;
|
|
413
413
|
}[];
|
|
@@ -15,6 +15,7 @@ import type { ParticipantKind } from '@abloatai/transaction/types/participant';
|
|
|
15
15
|
import type { Logger } from '@abloatai/transaction/logger';
|
|
16
16
|
import type { SchemaRecord } from '@abloatai/transaction/schema/schema';
|
|
17
17
|
import { type AuthCredentialSource } from '@abloatai/transaction/auth/credentialSource';
|
|
18
|
+
import { type PresenceSessionSource } from '@abloatai/transaction/presence';
|
|
18
19
|
import { type CredentialProvider } from '@abloatai/transaction/auth/apiKey';
|
|
19
20
|
import type { AbloOptions, InternalAbloOptions } from './options.js';
|
|
20
21
|
/** What one pass over the options bag settles, for the builders downstream. */
|
|
@@ -31,6 +32,7 @@ export interface ClientPrelude<S extends SchemaRecord> {
|
|
|
31
32
|
*/
|
|
32
33
|
readonly credentialResolver: CredentialProvider | null;
|
|
33
34
|
readonly authCredentials: AuthCredentialSource;
|
|
35
|
+
readonly presenceSession: PresenceSessionSource;
|
|
34
36
|
readonly logger: Logger;
|
|
35
37
|
readonly url: string;
|
|
36
38
|
/**
|
|
@@ -11,7 +11,9 @@
|
|
|
11
11
|
* `./Ablo.ts` calls this once and hands the result to whichever client the
|
|
12
12
|
* plugin list selected.
|
|
13
13
|
*/
|
|
14
|
+
import { AbloValidationError } from '@abloatai/transaction/errors';
|
|
14
15
|
import { createAuthCredentialSource, } from '@abloatai/transaction/auth/credentialSource';
|
|
16
|
+
import { createPresenceSessionSource, } from '@abloatai/transaction/presence';
|
|
15
17
|
import { assertBrowserSafety, readProcessEnv, rejectRemovedDatabaseUrlOption, resolveApiKey, resolveAuthToken, resolveBaseURL, resolveCredentialResolver, warnIfCliKeyMismatch, } from '@abloatai/transaction/auth/apiKey';
|
|
16
18
|
import { createConsoleLogger, resolveLogLevel } from './consoleLogger.js';
|
|
17
19
|
/**
|
|
@@ -22,6 +24,14 @@ import { createConsoleLogger, resolveLogLevel } from './consoleLogger.js';
|
|
|
22
24
|
* different project than the one being addressed (a warning, not a throw).
|
|
23
25
|
*/
|
|
24
26
|
export function resolveClientPrelude(options) {
|
|
27
|
+
// This package owns the reactive materialiser, not transport selection.
|
|
28
|
+
// TypeScript can miss excess properties on generic calls and object spreads,
|
|
29
|
+
// so reject a misplaced selector before resolving any credentials or
|
|
30
|
+
// constructing local state. The core `Ablo` client owns `transport`.
|
|
31
|
+
if ('transport' in options) {
|
|
32
|
+
throw new AbloValidationError("The reactive client does not accept `transport`. Import `Ablo` from " +
|
|
33
|
+
"'@abloatai/ablo' when selecting HTTP or WebSocket transport.", { code: 'invalid_options', param: 'transport' });
|
|
34
|
+
}
|
|
25
35
|
const env = readProcessEnv();
|
|
26
36
|
const internalOptions = {
|
|
27
37
|
...options,
|
|
@@ -32,9 +42,10 @@ export function resolveClientPrelude(options) {
|
|
|
32
42
|
const configuredApiKey = resolveApiKey(authInput);
|
|
33
43
|
const configuredAuthToken = resolveAuthToken(authInput);
|
|
34
44
|
const credentialResolver = resolveCredentialResolver(configuredApiKey);
|
|
45
|
+
const presenceSession = createPresenceSessionSource();
|
|
35
46
|
const authCredentials = createAuthCredentialSource(
|
|
36
47
|
// eslint-disable-next-line @typescript-eslint/no-deprecated -- load-bearing on the self-hosted path; server-internal cap-mint (Phase 3) not shipped
|
|
37
|
-
internalOptions.capabilityToken ?? configuredAuthToken);
|
|
48
|
+
internalOptions.capabilityToken ?? configuredAuthToken, presenceSession);
|
|
38
49
|
rejectRemovedDatabaseUrlOption(options);
|
|
39
50
|
assertBrowserSafety({
|
|
40
51
|
apiKey: configuredApiKey,
|
|
@@ -56,6 +67,7 @@ export function resolveClientPrelude(options) {
|
|
|
56
67
|
configuredAuthToken,
|
|
57
68
|
credentialResolver,
|
|
58
69
|
authCredentials,
|
|
70
|
+
presenceSession,
|
|
59
71
|
logger,
|
|
60
72
|
url: resolveBaseURL(authInput),
|
|
61
73
|
participantId,
|
|
@@ -39,6 +39,7 @@ export function createInternalComponents(input) {
|
|
|
39
39
|
syncGroups: options.syncGroups,
|
|
40
40
|
instantModels: deriveInstantModels(schema),
|
|
41
41
|
getAuthToken: auth?.getAuthToken,
|
|
42
|
+
presenceSession: auth?.presenceSession,
|
|
42
43
|
runtime,
|
|
43
44
|
});
|
|
44
45
|
const database = new Database(modelRegistry, bootstrapHelper, {
|
|
@@ -61,6 +62,7 @@ export function createInternalComponents(input) {
|
|
|
61
62
|
schema,
|
|
62
63
|
baseUrl: bootstrapBaseUrl,
|
|
63
64
|
getAuthToken: auth?.getAuthToken,
|
|
65
|
+
presenceSession: auth?.presenceSession,
|
|
64
66
|
runtime,
|
|
65
67
|
// The one canonical log position; the loader reads its floor when a query
|
|
66
68
|
// leaves so a late answer cannot overwrite a row the pool already knows to
|
|
@@ -22,10 +22,17 @@ export type { Claim, ClaimHeartbeat, ClaimHeartbeatOptions, HeldClaim, HeldLease
|
|
|
22
22
|
import type { ClaimApi, ClaimAttemptEvent, LocalCountOptions, LocalReadOptions } from '@abloatai/transaction/client/resources/modelOperations';
|
|
23
23
|
import type { HttpModelClient } from '@abloatai/transaction/transport/http';
|
|
24
24
|
import type { ParticipantKind } from '@abloatai/transaction/types/participant';
|
|
25
|
+
import type { PresenceSession } from '@abloatai/transaction/presence';
|
|
26
|
+
import type { CollaborationEventContext, ModelEventEnvelope, ModelEventInput, ModelEventTarget } from '@abloatai/transaction/collaboration';
|
|
25
27
|
import { type ReadSetContext } from '@abloatai/transaction/internal/read-set';
|
|
26
28
|
export interface ModelClientMeta {
|
|
27
29
|
readonly key: string;
|
|
28
30
|
readonly typename: string;
|
|
31
|
+
readonly presence?: {
|
|
32
|
+
get(recordId: string): readonly PresenceSession[];
|
|
33
|
+
subscribe(listener: () => void): () => void;
|
|
34
|
+
read(recordId: string): () => void;
|
|
35
|
+
};
|
|
29
36
|
}
|
|
30
37
|
export declare function getModelClientMeta(modelClient: unknown): ModelClientMeta | undefined;
|
|
31
38
|
/**
|
|
@@ -34,6 +41,15 @@ export declare function getModelClientMeta(modelClient: unknown): ModelClientMet
|
|
|
34
41
|
*/
|
|
35
42
|
type EntityHalf = Pick<ModelTarget, 'model' | 'id'>;
|
|
36
43
|
export interface ModelCollaboration {
|
|
44
|
+
/** Session projections already held by this client's one presence store. */
|
|
45
|
+
presence(model: string, recordId?: string): readonly PresenceSession[];
|
|
46
|
+
/** Subscribe once to the connection-owned presence projection. */
|
|
47
|
+
onPresenceChange(listener: () => void): () => void;
|
|
48
|
+
/** Start one session-owned read activity and return its cleanup. */
|
|
49
|
+
startReadPresence(target: EntityHalf): () => void;
|
|
50
|
+
modelEventTarget(recordId: string): ModelEventTarget;
|
|
51
|
+
sendModelEvent(input: ModelEventInput): void;
|
|
52
|
+
onModelEvent(listener: (event: ModelEventEnvelope) => void): () => void;
|
|
37
53
|
/** Exact point evidence from the HTTP read boundary (stamp captured before data). */
|
|
38
54
|
readPoint(model: string, id: string): Promise<{
|
|
39
55
|
data: unknown;
|
|
@@ -135,6 +151,8 @@ export interface ModelCollaboration {
|
|
|
135
151
|
* test doubles can omit it.
|
|
136
152
|
*/
|
|
137
153
|
enterScope?(scope: Record<string, string>): void | Promise<void>;
|
|
154
|
+
/** Release read interest previously acquired through {@link enterScope}. */
|
|
155
|
+
leaveScope?(scope: Record<string, string>): void | Promise<void>;
|
|
138
156
|
/**
|
|
139
157
|
* Pins a scope's sync group(s) — write intent: a row this client holds an
|
|
140
158
|
* active claim on stays subscribed regardless of navigation. Same
|
|
@@ -187,6 +205,10 @@ export interface LocalReads<T> {
|
|
|
187
205
|
interface ReactiveModelSurface<T, Fields = T> {
|
|
188
206
|
/** The synchronous local-graph reads. */
|
|
189
207
|
local: LocalReads<T>;
|
|
208
|
+
/** Sessions currently active on this model, optionally narrowed to one record. */
|
|
209
|
+
presence(recordId?: string): readonly PresenceSession[];
|
|
210
|
+
/** Lossy, model-record-addressed application events such as cursor or selection. */
|
|
211
|
+
events: ModelEvents;
|
|
190
212
|
/**
|
|
191
213
|
* Claim a row so other writers wait or are rejected until you're done, and
|
|
192
214
|
* inspect or manage that coordination through the same namespace. Call it to
|
|
@@ -217,6 +239,10 @@ interface ReactiveModelSurface<T, Fields = T> {
|
|
|
217
239
|
/** Subscribe to changes; the callback runs on every change. */
|
|
218
240
|
onChange(callback: (entities: T[]) => void, options?: LocalReadOptions<T>): () => void;
|
|
219
241
|
}
|
|
242
|
+
export interface ModelEvents {
|
|
243
|
+
send(recordId: string, event: string, payload: Readonly<Record<string, unknown>>): void;
|
|
244
|
+
subscribe(recordId: string, event: string, handler: (payload: Readonly<Record<string, unknown>>, context: CollaborationEventContext) => void): () => void;
|
|
245
|
+
}
|
|
220
246
|
/**
|
|
221
247
|
* Everything reachable as `ablo.<model>` on a reactive client.
|
|
222
248
|
*
|
|
@@ -30,15 +30,40 @@ import { declaredMeta } from '@abloatai/transaction/claims';
|
|
|
30
30
|
import { ModelScope } from '@abloatai/transaction/types';
|
|
31
31
|
import { bindClaimLifetime, claimLifetimeOf, } from '@abloatai/transaction/claims/lifetime';
|
|
32
32
|
import { claimQueueView, resolveClaimContentionOptions, } from '@abloatai/transaction/client/resources/modelOperations';
|
|
33
|
+
import { modelEventInputSchema } from '@abloatai/transaction/collaboration';
|
|
33
34
|
import { capturePointRead, prepareReadSet, } from '@abloatai/transaction/internal/read-set';
|
|
34
35
|
const ignoreSeparatelyObservedMutationFailure = () => undefined;
|
|
35
36
|
const ignoreBestEffortClaimReleaseFailure = () => undefined;
|
|
37
|
+
const ignoreBestEffortScopeFailure = () => undefined;
|
|
36
38
|
const modelClientMeta = new WeakMap();
|
|
37
39
|
export function getModelClientMeta(modelClient) {
|
|
38
40
|
if (typeof modelClient !== 'object' || modelClient === null)
|
|
39
41
|
return undefined;
|
|
40
42
|
return modelClientMeta.get(modelClient);
|
|
41
43
|
}
|
|
44
|
+
function subscribeInModelScope(collaboration, scope, subscribe) {
|
|
45
|
+
let stopped = false;
|
|
46
|
+
let entered = false;
|
|
47
|
+
let unsubscribe = null;
|
|
48
|
+
void Promise.resolve(collaboration.enterScope?.(scope))
|
|
49
|
+
.then(() => {
|
|
50
|
+
entered = true;
|
|
51
|
+
if (stopped) {
|
|
52
|
+
void collaboration.leaveScope?.(scope);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
unsubscribe = subscribe();
|
|
56
|
+
})
|
|
57
|
+
.catch(ignoreBestEffortScopeFailure);
|
|
58
|
+
return () => {
|
|
59
|
+
if (stopped)
|
|
60
|
+
return;
|
|
61
|
+
stopped = true;
|
|
62
|
+
unsubscribe?.();
|
|
63
|
+
if (entered)
|
|
64
|
+
void collaboration.leaveScope?.(scope);
|
|
65
|
+
};
|
|
66
|
+
}
|
|
42
67
|
export function createModelOperations(schemaKey, registeredModelName, objectPool, syncClient, registry,
|
|
43
68
|
/**
|
|
44
69
|
* The one thing this factory asks of the loader: fetch rows for a model.
|
|
@@ -829,6 +854,51 @@ hydration, collaboration, readSetContext) {
|
|
|
829
854
|
}
|
|
830
855
|
const operations = {
|
|
831
856
|
local,
|
|
857
|
+
presence: (recordId) => collaboration?.presence(registeredModelName, recordId) ?? [],
|
|
858
|
+
events: {
|
|
859
|
+
send(recordId, event, payload) {
|
|
860
|
+
if (!collaboration)
|
|
861
|
+
return;
|
|
862
|
+
const target = collaboration.modelEventTarget(recordId);
|
|
863
|
+
const parsed = modelEventInputSchema.safeParse({ target, event, payload });
|
|
864
|
+
if (!parsed.success) {
|
|
865
|
+
throw new AbloValidationError('Invalid model event.', {
|
|
866
|
+
code: 'invalid_request',
|
|
867
|
+
param: 'event',
|
|
868
|
+
cause: parsed.error,
|
|
869
|
+
});
|
|
870
|
+
}
|
|
871
|
+
const scope = { [schemaKey]: recordId };
|
|
872
|
+
void Promise.resolve(collaboration.enterScope?.(scope))
|
|
873
|
+
.then(() => { collaboration.sendModelEvent(parsed.data); })
|
|
874
|
+
.finally(() => { void collaboration.leaveScope?.(scope); });
|
|
875
|
+
},
|
|
876
|
+
subscribe(recordId, event, handler) {
|
|
877
|
+
if (!collaboration)
|
|
878
|
+
return () => undefined;
|
|
879
|
+
const target = collaboration.modelEventTarget(recordId);
|
|
880
|
+
const parsed = modelEventInputSchema.safeParse({ target, event, payload: {} });
|
|
881
|
+
if (!parsed.success) {
|
|
882
|
+
throw new AbloValidationError('Invalid model event subscription.', {
|
|
883
|
+
code: 'invalid_request',
|
|
884
|
+
param: 'event',
|
|
885
|
+
cause: parsed.error,
|
|
886
|
+
});
|
|
887
|
+
}
|
|
888
|
+
const scope = { [schemaKey]: recordId };
|
|
889
|
+
return subscribeInModelScope(collaboration, scope, () => collaboration.onModelEvent((incoming) => {
|
|
890
|
+
if (incoming.target.model !== target.model ||
|
|
891
|
+
incoming.target.id !== target.id ||
|
|
892
|
+
incoming.target.syncGroup !== target.syncGroup ||
|
|
893
|
+
incoming.event !== parsed.data.event)
|
|
894
|
+
return;
|
|
895
|
+
handler(incoming.payload, {
|
|
896
|
+
sender: incoming.sender,
|
|
897
|
+
sentAt: incoming.sentAt,
|
|
898
|
+
});
|
|
899
|
+
}));
|
|
900
|
+
},
|
|
901
|
+
},
|
|
832
902
|
get,
|
|
833
903
|
read,
|
|
834
904
|
// No automatic scope enrolment on bulk `list`: that would subscribe to an
|
|
@@ -1018,6 +1088,21 @@ hydration, collaboration, readSetContext) {
|
|
|
1018
1088
|
modelClientMeta.set(operations, {
|
|
1019
1089
|
key: schemaKey,
|
|
1020
1090
|
typename: registeredModelName,
|
|
1091
|
+
...(collaboration
|
|
1092
|
+
? {
|
|
1093
|
+
presence: {
|
|
1094
|
+
get: (recordId) => collaboration.presence(registeredModelName, recordId),
|
|
1095
|
+
subscribe: (listener) => collaboration.onPresenceChange(listener),
|
|
1096
|
+
read: (recordId) => {
|
|
1097
|
+
const scope = { [schemaKey]: recordId };
|
|
1098
|
+
return subscribeInModelScope(collaboration, scope, () => collaboration.startReadPresence({
|
|
1099
|
+
model: registeredModelName,
|
|
1100
|
+
id: recordId,
|
|
1101
|
+
}));
|
|
1102
|
+
},
|
|
1103
|
+
},
|
|
1104
|
+
}
|
|
1105
|
+
: {}),
|
|
1021
1106
|
});
|
|
1022
1107
|
return operations;
|
|
1023
1108
|
}
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
import type { SchemaRecord } from '@abloatai/transaction/schema/schema';
|
|
15
15
|
import type { StoreCluster } from './storeCluster.js';
|
|
16
16
|
import type { SyncWebSocket } from '../sync/SyncWebSocket.js';
|
|
17
|
-
import type
|
|
17
|
+
import { type AttachablePresence } from '../../presence/index.js';
|
|
18
18
|
import type { AbloOptions } from './options.js';
|
|
19
19
|
import type { ClientPrelude } from './clientPrelude.js';
|
|
20
20
|
import type { AbloClient as Ablo } from '../../client.js';
|
|
@@ -36,7 +36,7 @@ export interface ReactiveEngineInputs<S extends SchemaRecord> extends ClientPrel
|
|
|
36
36
|
transport: SyncWebSocket;
|
|
37
37
|
/** The humans() plugin's contribution — built by its `init`, already
|
|
38
38
|
* attached to the connection the context carried. */
|
|
39
|
-
presence:
|
|
39
|
+
presence: AttachablePresence;
|
|
40
40
|
/**
|
|
41
41
|
* The store cluster `humans().init` constructed from the widened context:
|
|
42
42
|
* this client's runtime, the component graph, and the store. The engine
|
|
@@ -20,6 +20,7 @@ import { startStoreLifecycle } from './storeLifecycle.js';
|
|
|
20
20
|
import { createClaimStream } from '../sync/createClaimStream.js';
|
|
21
21
|
import { awaitClaimGrant } from '@abloatai/transaction/claims';
|
|
22
22
|
import { bindClaimLifetime, claimLifetimeOf, } from '@abloatai/transaction/claims/lifetime';
|
|
23
|
+
import { attachPresenceToClient, } from '../../presence/index.js';
|
|
23
24
|
import { resolveApiKeyValue, resolveBootstrapBaseUrl } from '@abloatai/transaction/auth/apiKey';
|
|
24
25
|
import { claimAttemptFailure, emitClaimStatus, } from '@abloatai/transaction/client/resources/modelOperations';
|
|
25
26
|
import { createModelOperations } from './createModelOperations.js';
|
|
@@ -28,6 +29,7 @@ import { modelReadResponseSchema, commitRecordSchema, commitRecordListSchema, co
|
|
|
28
29
|
import { translateHttpError, } from '@abloatai/transaction/errors';
|
|
29
30
|
import { kReadEvidence, prepareReadSet, } from '@abloatai/transaction/internal/read-set';
|
|
30
31
|
import { contextOnChange } from '../sync/contextOnChange.js';
|
|
32
|
+
import { resolveScopeGroups } from '../sync/scopeGroups.js';
|
|
31
33
|
export function buildReactiveEngine(inputs) {
|
|
32
34
|
const { options, internalOptions, url, logger, configuredApiKey, configuredAuthToken, credentialResolver, authCredentials, transport, participantId, kind, presence, cluster, } = inputs;
|
|
33
35
|
const schema = options.schema;
|
|
@@ -80,7 +82,7 @@ export function buildReactiveEngine(inputs) {
|
|
|
80
82
|
// filter own echoes by participant id, seeded in `ready()` alongside the
|
|
81
83
|
// locals above.
|
|
82
84
|
const presenceStream = presence;
|
|
83
|
-
const claimStream = createClaimStream({
|
|
85
|
+
const claimStream = createClaimStream({ logger }, transport, presenceStream);
|
|
84
86
|
// 6. Validate options up front — fail loudly on obviously wrong inputs so
|
|
85
87
|
// strangers don't get silent empty results. Validation errors are written
|
|
86
88
|
// into `store.syncStatus` (the single source of truth).
|
|
@@ -131,17 +133,11 @@ export function buildReactiveEngine(inputs) {
|
|
|
131
133
|
kind,
|
|
132
134
|
logger,
|
|
133
135
|
validationError: _validationError,
|
|
134
|
-
onIdentityResolved: ({ userId, participantKind, accountScope,
|
|
136
|
+
onIdentityResolved: ({ userId, participantKind, accountScope, authority }) => {
|
|
135
137
|
selfParticipantId = userId;
|
|
136
138
|
selfParticipantKind = participantKind;
|
|
137
139
|
_resolvedOrganizationId = accountScope;
|
|
138
140
|
_resolvedIdentity = authority;
|
|
139
|
-
presenceStream.setParticipant({
|
|
140
|
-
id: userId,
|
|
141
|
-
kind: participantKind,
|
|
142
|
-
syncGroups: [...syncGroups],
|
|
143
|
-
});
|
|
144
|
-
claimStream.setParticipant({ id: userId });
|
|
145
141
|
},
|
|
146
142
|
});
|
|
147
143
|
const ready = lifecycle.ready;
|
|
@@ -398,6 +394,21 @@ export function buildReactiveEngine(inputs) {
|
|
|
398
394
|
for (const [schemaKey, modelDef] of Object.entries(schema.models)) {
|
|
399
395
|
const registeredModelName = modelDef.typename ?? schemaKey;
|
|
400
396
|
modelProxies[schemaKey] = createModelOperations(schemaKey, registeredModelName, objectPool, syncClient, modelRegistry, hydration, {
|
|
397
|
+
presence: (model, recordId) => presenceStream.forModel(model, recordId),
|
|
398
|
+
onPresenceChange: (listener) => presenceStream.onChange(listener),
|
|
399
|
+
startReadPresence: (target) => presenceStream.startRead(target),
|
|
400
|
+
modelEventTarget: (recordId) => {
|
|
401
|
+
const syncGroup = resolveScopeGroups({ [schemaKey]: recordId }, schema)[0];
|
|
402
|
+
if (syncGroup === undefined) {
|
|
403
|
+
throw new AbloValidationError('A model event requires a record scope.', {
|
|
404
|
+
code: 'invalid_request',
|
|
405
|
+
param: 'recordId',
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
return { model: registeredModelName, id: recordId, syncGroup };
|
|
409
|
+
},
|
|
410
|
+
sendModelEvent: (input) => { transport.sendModelEvent(input); },
|
|
411
|
+
onModelEvent: (listener) => transport.subscribe('model_event', listener),
|
|
401
412
|
createClaim: (claimOptions) => publicClaims.create(claimOptions),
|
|
402
413
|
// Lazily referenced: `commits` is declared below this loop, and this
|
|
403
414
|
// only runs when someone actually writes a batch.
|
|
@@ -436,6 +447,7 @@ export function buildReactiveEngine(inputs) {
|
|
|
436
447
|
// stay fire-and-forget. It's soft either way — the store swallows
|
|
437
448
|
// reconcile errors so read interest never makes a read reject or stall.
|
|
438
449
|
enterScope: (scope) => store.enterScope(scope),
|
|
450
|
+
leaveScope: (scope) => store.leaveScope(scope),
|
|
439
451
|
pinScope: (scope) => store.pinScope(scope),
|
|
440
452
|
}, readSetContext);
|
|
441
453
|
}
|
|
@@ -599,6 +611,10 @@ export function buildReactiveEngine(inputs) {
|
|
|
599
611
|
get syncStatus() {
|
|
600
612
|
return store.syncStatus;
|
|
601
613
|
},
|
|
614
|
+
// The humans capability owns the connection-backed presence projection.
|
|
615
|
+
// Keep it on the base client as well as in the plugin surface so the
|
|
616
|
+
// concrete AbloClient contract and runtime object agree before layering.
|
|
617
|
+
presence: presenceStream,
|
|
602
618
|
schema,
|
|
603
619
|
// ── Internal accessors for framework integration ─────────────────
|
|
604
620
|
// These expose internal components for consumers that need direct
|
|
@@ -611,13 +627,11 @@ export function buildReactiveEngine(inputs) {
|
|
|
611
627
|
get _pool() { return objectPool; },
|
|
612
628
|
/** The SyncWebSocket — for collaboration events (selection, cursors). */
|
|
613
629
|
get _ws() { return store.getSyncWebSocket(); },
|
|
614
|
-
/** Presence livestream — same socket as entity sync, no second
|
|
615
|
-
* connection. Stable reference across the engine's lifetime. */
|
|
616
|
-
presence: presenceStream,
|
|
617
630
|
/** Claim livestream — same socket. Stable reference. */
|
|
618
631
|
claims: publicClaims,
|
|
619
632
|
commits,
|
|
620
633
|
};
|
|
634
|
+
attachPresenceToClient(engine, presenceStream);
|
|
621
635
|
Object.defineProperty(engine, kReadEvidence, {
|
|
622
636
|
value: {
|
|
623
637
|
context: cluster.readSetContext,
|
|
@@ -15,6 +15,7 @@ import type { QueryBatch, QueryBatchResult } from './types.js';
|
|
|
15
15
|
import { type RecoveryClass } from '@abloatai/transaction/errorCodes';
|
|
16
16
|
import { type AuthTokenGetter } from '@abloatai/transaction/auth/credentialSource';
|
|
17
17
|
import type { RuntimeContext } from '../RuntimeContext.js';
|
|
18
|
+
import type { PresenceSessionSource } from '@abloatai/transaction/presence';
|
|
18
19
|
export interface PostQueryOptions {
|
|
19
20
|
/**
|
|
20
21
|
* Full base URL of the sync server including the `/api` prefix.
|
|
@@ -38,6 +39,8 @@ export interface PostQueryOptions {
|
|
|
38
39
|
* effect without rebuilding the client.
|
|
39
40
|
*/
|
|
40
41
|
capabilityToken?: string;
|
|
42
|
+
/** Server-bound attribution shared with the owning WebSocket. */
|
|
43
|
+
presenceSession?: PresenceSessionSource;
|
|
41
44
|
/**
|
|
42
45
|
* An optional hook that tries to recover from a rejected credential. When a
|
|
43
46
|
* query comes back with a 401, its {@link RecoveryClass} is passed here: a
|
|
@@ -74,7 +74,7 @@ export async function postQuery(options, batch) {
|
|
|
74
74
|
try {
|
|
75
75
|
// Recomputed per attempt: `withAuthHeaders` reads the live credential
|
|
76
76
|
// source, so a replay after recovery carries the freshly-minted key.
|
|
77
|
-
const headers = withAuthHeaders(options.getAuthToken, { 'Content-Type': 'application/json' }, options.capabilityToken);
|
|
77
|
+
const headers = withAuthHeaders(options.getAuthToken, { 'Content-Type': 'application/json' }, options.capabilityToken, options.presenceSession);
|
|
78
78
|
const response = await fetch(url, {
|
|
79
79
|
method: 'POST',
|
|
80
80
|
headers,
|
|
@@ -94,6 +94,8 @@ export interface BootstrapOptions {
|
|
|
94
94
|
* {@link BootstrapFetcher.setAuthToken}.
|
|
95
95
|
*/
|
|
96
96
|
getAuthToken?: AuthTokenGetter;
|
|
97
|
+
/** Server-bound attribution shared with the owning WebSocket. */
|
|
98
|
+
presenceSession?: import('@abloatai/transaction/presence').PresenceSessionSource;
|
|
97
99
|
/** The owning client's runtime. Defaults to the module-global bridge. */
|
|
98
100
|
runtime?: RuntimeContext;
|
|
99
101
|
}
|