@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.
Files changed (56) hide show
  1. package/dist/Ablo.d.ts +4 -4
  2. package/dist/Ablo.js +2 -1
  3. package/dist/client.d.ts +7 -12
  4. package/dist/humans.d.ts +2 -2
  5. package/dist/humans.js +2 -6
  6. package/dist/local/BaseSyncedStore.d.ts +2 -4
  7. package/dist/local/BaseSyncedStore.js +0 -3
  8. package/dist/local/SyncClient.d.ts +3 -3
  9. package/dist/local/client/clientPrelude.d.ts +2 -0
  10. package/dist/local/client/clientPrelude.js +13 -1
  11. package/dist/local/client/createInternalComponents.js +2 -0
  12. package/dist/local/client/createModelOperations.d.ts +5 -0
  13. package/dist/local/client/createModelOperations.js +1 -0
  14. package/dist/local/client/reactiveEngine.d.ts +2 -2
  15. package/dist/local/client/reactiveEngine.js +9 -11
  16. package/dist/local/query/client.d.ts +3 -0
  17. package/dist/local/query/client.js +1 -1
  18. package/dist/local/sync/BootstrapFetcher.d.ts +2 -0
  19. package/dist/local/sync/BootstrapFetcher.js +4 -4
  20. package/dist/local/sync/OnDemandLoader.d.ts +2 -0
  21. package/dist/local/sync/OnDemandLoader.js +1 -0
  22. package/dist/local/sync/SyncWebSocket.d.ts +2 -15
  23. package/dist/local/sync/SyncWebSocket.js +1 -36
  24. package/dist/local/sync/createClaimStream.d.ts +9 -19
  25. package/dist/local/sync/createClaimStream.js +40 -55
  26. package/dist/local/sync/socketEventWiring.d.ts +1 -2
  27. package/dist/local/sync/socketEventWiring.js +1 -5
  28. package/dist/local/transactions/mutations/MutationQueue.d.ts +3 -3
  29. package/dist/local/transactions/mutations/replayValidation.d.ts +4 -4
  30. package/dist/presence/index.d.ts +15 -0
  31. package/dist/presence/index.js +49 -0
  32. package/dist/react/AbloProvider.d.ts +3 -3
  33. package/dist/react/AbloProvider.js +4 -3
  34. package/dist/surface.d.ts +1 -1
  35. package/dist/surface.js +1 -0
  36. package/package.json +2 -2
  37. package/src/Ablo.ts +15 -6
  38. package/src/client.ts +8 -13
  39. package/src/humans.ts +3 -10
  40. package/src/local/BaseSyncedStore.ts +0 -6
  41. package/src/local/client/clientPrelude.ts +20 -0
  42. package/src/local/client/createInternalComponents.ts +2 -0
  43. package/src/local/client/createModelOperations.ts +8 -0
  44. package/src/local/client/reactiveEngine.ts +19 -14
  45. package/src/local/query/client.ts +4 -0
  46. package/src/local/sync/BootstrapFetcher.ts +13 -4
  47. package/src/local/sync/OnDemandLoader.ts +3 -0
  48. package/src/local/sync/SyncWebSocket.ts +1 -42
  49. package/src/local/sync/createClaimStream.ts +51 -71
  50. package/src/local/sync/socketEventWiring.ts +1 -8
  51. package/src/presence/index.ts +72 -0
  52. package/src/react/AbloProvider.tsx +14 -9
  53. package/src/surface.ts +1 -0
  54. package/dist/presenceStream.d.ts +0 -69
  55. package/dist/presenceStream.js +0 -200
  56. 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/transaction`.
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 PresenceStream = _Streams.PresenceStream;
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
- * A real-time presence livestream — who else is connected on this engine's
204
- * sync groups, what they're doing, and a write surface for announcing this
205
- * user's own activity. It rides the engine's existing WebSocket; opening a
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: PresenceStream;
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 AttachablePresenceStream } from './presenceStream.js';
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: AttachablePresenceStream;
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 { createPresenceStream } from './presenceStream.js';
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: createPresenceStream({
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
  },
@@ -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 PresenceUpdate, type EventMap, type DefaultCollaborationEvents, type SyncWebSocketEventMap } from './sync/SyncWebSocket.js';
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, PresenceUpdate, };
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++; });
@@ -392,7 +392,7 @@ export declare class SyncClient extends EventEmitter {
392
392
  awaitingDeltaCount: number;
393
393
  awaitingDeltaTransactions: {
394
394
  id: string;
395
- type: "update" | "create" | "delete" | "archive" | "unarchive";
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: "update" | "create" | "delete" | "archive" | "unarchive";
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: "update" | "create" | "delete" | "archive" | "unarchive";
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,6 +22,7 @@ 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';
25
26
  import { type ReadSetContext } from '@abloatai/transaction/internal/read-set';
26
27
  export interface ModelClientMeta {
27
28
  readonly key: string;
@@ -34,6 +35,8 @@ export declare function getModelClientMeta(modelClient: unknown): ModelClientMet
34
35
  */
35
36
  type EntityHalf = Pick<ModelTarget, 'model' | 'id'>;
36
37
  export interface ModelCollaboration {
38
+ /** Session projections already held by this client's one presence store. */
39
+ presence(model: string, recordId?: string): readonly PresenceSession[];
37
40
  /** Exact point evidence from the HTTP read boundary (stamp captured before data). */
38
41
  readPoint(model: string, id: string): Promise<{
39
42
  data: unknown;
@@ -187,6 +190,8 @@ export interface LocalReads<T> {
187
190
  interface ReactiveModelSurface<T, Fields = T> {
188
191
  /** The synchronous local-graph reads. */
189
192
  local: LocalReads<T>;
193
+ /** Sessions currently active on this model, optionally narrowed to one record. */
194
+ presence(recordId?: string): readonly PresenceSession[];
190
195
  /**
191
196
  * Claim a row so other writers wait or are rejected until you're done, and
192
197
  * inspect or manage that coordination through the same namespace. Call it to
@@ -829,6 +829,7 @@ hydration, collaboration, readSetContext) {
829
829
  }
830
830
  const operations = {
831
831
  local,
832
+ presence: (recordId) => collaboration?.presence(registeredModelName, recordId) ?? [],
832
833
  get,
833
834
  read,
834
835
  // No automatic scope enrolment on bulk `list`: that would subscribe to an
@@ -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 { AttachablePresenceStream } from '../../presenceStream.js';
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: AttachablePresenceStream;
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';
@@ -80,7 +81,7 @@ export function buildReactiveEngine(inputs) {
80
81
  // filter own echoes by participant id, seeded in `ready()` alongside the
81
82
  // locals above.
82
83
  const presenceStream = presence;
83
- const claimStream = createClaimStream({ participantId, logger }, transport);
84
+ const claimStream = createClaimStream({ logger }, transport, presenceStream);
84
85
  // 6. Validate options up front — fail loudly on obviously wrong inputs so
85
86
  // strangers don't get silent empty results. Validation errors are written
86
87
  // into `store.syncStatus` (the single source of truth).
@@ -131,17 +132,11 @@ export function buildReactiveEngine(inputs) {
131
132
  kind,
132
133
  logger,
133
134
  validationError: _validationError,
134
- onIdentityResolved: ({ userId, participantKind, accountScope, syncGroups, authority }) => {
135
+ onIdentityResolved: ({ userId, participantKind, accountScope, authority }) => {
135
136
  selfParticipantId = userId;
136
137
  selfParticipantKind = participantKind;
137
138
  _resolvedOrganizationId = accountScope;
138
139
  _resolvedIdentity = authority;
139
- presenceStream.setParticipant({
140
- id: userId,
141
- kind: participantKind,
142
- syncGroups: [...syncGroups],
143
- });
144
- claimStream.setParticipant({ id: userId });
145
140
  },
146
141
  });
147
142
  const ready = lifecycle.ready;
@@ -398,6 +393,7 @@ export function buildReactiveEngine(inputs) {
398
393
  for (const [schemaKey, modelDef] of Object.entries(schema.models)) {
399
394
  const registeredModelName = modelDef.typename ?? schemaKey;
400
395
  modelProxies[schemaKey] = createModelOperations(schemaKey, registeredModelName, objectPool, syncClient, modelRegistry, hydration, {
396
+ presence: (model, recordId) => presenceStream.forModel(model, recordId),
401
397
  createClaim: (claimOptions) => publicClaims.create(claimOptions),
402
398
  // Lazily referenced: `commits` is declared below this loop, and this
403
399
  // only runs when someone actually writes a batch.
@@ -599,6 +595,10 @@ export function buildReactiveEngine(inputs) {
599
595
  get syncStatus() {
600
596
  return store.syncStatus;
601
597
  },
598
+ // The humans capability owns the connection-backed presence projection.
599
+ // Keep it on the base client as well as in the plugin surface so the
600
+ // concrete AbloClient contract and runtime object agree before layering.
601
+ presence: presenceStream,
602
602
  schema,
603
603
  // ── Internal accessors for framework integration ─────────────────
604
604
  // These expose internal components for consumers that need direct
@@ -611,13 +611,11 @@ export function buildReactiveEngine(inputs) {
611
611
  get _pool() { return objectPool; },
612
612
  /** The SyncWebSocket — for collaboration events (selection, cursors). */
613
613
  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
614
  /** Claim livestream — same socket. Stable reference. */
618
615
  claims: publicClaims,
619
616
  commits,
620
617
  };
618
+ attachPresenceToClient(engine, presenceStream);
621
619
  Object.defineProperty(engine, kReadEvidence, {
622
620
  value: {
623
621
  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
  }
@@ -172,7 +172,7 @@ export class BootstrapFetcher {
172
172
  try {
173
173
  const res = await fetch(`${this.options.baseUrl}/schema`, {
174
174
  method: 'GET',
175
- headers: withAuthHeaders(this.options.getAuthToken, {}, this.options.authToken),
175
+ headers: withAuthHeaders(this.options.getAuthToken, {}, this.options.authToken, this.options.presenceSession),
176
176
  });
177
177
  if (!res.ok)
178
178
  throw new Error(`schema read-back ${res.status}`);
@@ -570,7 +570,7 @@ export class BootstrapFetcher {
570
570
  // conditional revalidation (If-None-Match) implement it at their own
571
571
  // level where they own the cache-key namespace. The 304 branch below
572
572
  // remains defensively in place for when a caller enables revalidation.
573
- const headers = withAuthHeaders(this.options.getAuthToken, { 'Content-Type': 'application/json' }, this.options.authToken);
573
+ const headers = withAuthHeaders(this.options.getAuthToken, { 'Content-Type': 'application/json' }, this.options.authToken, this.options.presenceSession);
574
574
  const controller = new AbortController();
575
575
  this.activeControllers.set(controller, 'bootstrap');
576
576
  try {
@@ -738,7 +738,7 @@ export class BootstrapFetcher {
738
738
  'Content-Type': 'application/json',
739
739
  'Cache-Control': 'no-cache, no-store, must-revalidate',
740
740
  Pragma: 'no-cache',
741
- }, this.options.authToken),
741
+ }, this.options.authToken, this.options.presenceSession),
742
742
  signal: controller.signal,
743
743
  cache: 'no-store', // Force browser to not cache
744
744
  });
@@ -797,7 +797,7 @@ export class BootstrapFetcher {
797
797
  method: 'GET',
798
798
  headers: withAuthHeaders(this.options.getAuthToken, {
799
799
  'Content-Type': 'application/json',
800
- }, this.options.authToken),
800
+ }, this.options.authToken, this.options.presenceSession),
801
801
  signal: controller.signal,
802
802
  });
803
803
  }
@@ -37,6 +37,7 @@ import type { LoadWhere, WhereClause } from '../query/types.js';
37
37
  import { normalizeWhere } from '@abloatai/transaction/client/resources/where';
38
38
  import type { Schema } from '@abloatai/transaction/schema/schema';
39
39
  import type { LogPositionPort } from '../logPosition.js';
40
+ import type { PresenceSessionSource } from '@abloatai/transaction/presence';
40
41
  export interface OnDemandLoaderOptions {
41
42
  readonly objectPool: InstanceCache;
42
43
  /**
@@ -53,6 +54,7 @@ export interface OnDemandLoaderOptions {
53
54
  * propagate without re-instantiating the coordinator.
54
55
  */
55
56
  readonly getAuthToken?: () => string | null;
57
+ readonly presenceSession?: PresenceSessionSource;
56
58
  /** @deprecated Use `getAuthToken`. */
57
59
  readonly getCapabilityToken?: () => string | null;
58
60
  /** The owning client's runtime. Defaults to the module-global bridge. */
@@ -452,6 +452,7 @@ export class OnDemandLoader {
452
452
  getAuthToken: this.authTokenProvider ?? undefined,
453
453
  recoverCredential: this.credentialRecovery ?? undefined,
454
454
  runtime: this.opts.runtime,
455
+ presenceSession: this.opts.presenceSession,
455
456
  }, { queries: [query] });
456
457
  const rows = Array.isArray(result.results[0]) ? result.results[0] : [];
457
458
  const evidence = result.evidence?.[0] ?? [];
@@ -12,7 +12,7 @@
12
12
  import { type ClientSyncDelta } from '@abloatai/transaction/observation';
13
13
  import { WsTransport, type WsTransportOptions, type EventMap, type DefaultCollaborationEvents } from '@abloatai/transaction/transport/websocket';
14
14
  export type { CommitAck } from './commitFrames.js';
15
- export type { SyncCapabilities, BootstrapHint, BootstrapDataEvent, PresenceUpdate, CoreSyncEventMap, DefaultCollaborationEvents, EventMap, SyncWebSocketEventMap, } from '@abloatai/transaction/transport/websocket';
15
+ export type { SyncCapabilities, BootstrapHint, BootstrapDataEvent, CoreSyncEventMap, DefaultCollaborationEvents, EventMap, SyncWebSocketEventMap, } from '@abloatai/transaction/transport/websocket';
16
16
  /**
17
17
  * The wire delta the client receives. It is inferred from the canonical
18
18
  * `clientSyncDeltaSchema` so the client and server share one contract rather
@@ -67,7 +67,7 @@ export declare class SyncWebSocket<TCollaboration extends EventMap<TCollaboratio
67
67
  protected resumeCursor(): string;
68
68
  /**
69
69
  * The open ritual, run by the transport between its `connected` emit and the
70
- * heartbeat start: announce presence, tell the server where we left off,
70
+ * heartbeat start: tell the server where we left off,
71
71
  * request the deltas we missed, and start the catch-up poll.
72
72
  */
73
73
  protected onOpened(): void;
@@ -121,19 +121,6 @@ export declare class SyncWebSocket<TCollaboration extends EventMap<TCollaboratio
121
121
  * Public wrapper for sending ack from outside the class
122
122
  */
123
123
  acknowledge(syncId: number): void;
124
- /**
125
- * Send presence update to server.
126
- * Use this for:
127
- * - Updating timezone (improves localTime accuracy shown to other users)
128
- * - Manual status changes (away, custom status)
129
- *
130
- * Note: "online" status is automatically set by server on WebSocket connect,
131
- * and "offline" is set on disconnect. You don't need to call this for basic online/offline.
132
- *
133
- * @param status - "online", "away", or custom status string
134
- * @param customStatus - Optional custom status message
135
- */
136
- sendPresenceUpdate(status?: 'online' | 'away' | 'offline', customStatus?: string): void;
137
124
  /**
138
125
  * Stop the periodic catchup interval
139
126
  */
@@ -53,13 +53,10 @@ export class SyncWebSocket extends WsTransport {
53
53
  }
54
54
  /**
55
55
  * The open ritual, run by the transport between its `connected` emit and the
56
- * heartbeat start: announce presence, tell the server where we left off,
56
+ * heartbeat start: tell the server where we left off,
57
57
  * request the deltas we missed, and start the catch-up poll.
58
58
  */
59
59
  onOpened() {
60
- // Send presence update with timezone (server sets presence to "online" on connect,
61
- // this improves localTime accuracy by providing the user's actual timezone)
62
- this.sendPresenceUpdate('online');
63
60
  // Immediately request incremental sync based on our stored cursor.
64
61
  // `requestIncrementalSync` is async — a bare call inside try/catch is a
65
62
  // rejection hole (the catch never sees it); route failures through
@@ -224,38 +221,6 @@ export class SyncWebSocket extends WsTransport {
224
221
  acknowledge(syncId) {
225
222
  this.sendAck(syncId);
226
223
  }
227
- /**
228
- * Send presence update to server.
229
- * Use this for:
230
- * - Updating timezone (improves localTime accuracy shown to other users)
231
- * - Manual status changes (away, custom status)
232
- *
233
- * Note: "online" status is automatically set by server on WebSocket connect,
234
- * and "offline" is set on disconnect. You don't need to call this for basic online/offline.
235
- *
236
- * @param status - "online", "away", or custom status string
237
- * @param customStatus - Optional custom status message
238
- */
239
- sendPresenceUpdate(status = 'online', customStatus) {
240
- if (!this.isConnected())
241
- return;
242
- const timezone = (() => {
243
- try {
244
- return Intl.DateTimeFormat().resolvedOptions().timeZone;
245
- }
246
- catch {
247
- return 'UTC';
248
- }
249
- })();
250
- this.send({
251
- type: 'presence_update',
252
- payload: {
253
- status,
254
- timezone,
255
- ...(customStatus ? { customStatus } : {}),
256
- },
257
- });
258
- }
259
224
  /**
260
225
  * Stop the periodic catchup interval
261
226
  */