@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.
Files changed (83) 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/index.d.ts +1 -0
  7. package/dist/local/BaseSyncedStore.d.ts +2 -4
  8. package/dist/local/BaseSyncedStore.js +0 -3
  9. package/dist/local/InstanceCache.js +1 -1
  10. package/dist/local/SyncClient.d.ts +3 -3
  11. package/dist/local/client/clientPrelude.d.ts +2 -0
  12. package/dist/local/client/clientPrelude.js +13 -1
  13. package/dist/local/client/createInternalComponents.js +2 -0
  14. package/dist/local/client/createModelOperations.d.ts +26 -0
  15. package/dist/local/client/createModelOperations.js +85 -0
  16. package/dist/local/client/reactiveEngine.d.ts +2 -2
  17. package/dist/local/client/reactiveEngine.js +25 -11
  18. package/dist/local/query/client.d.ts +3 -0
  19. package/dist/local/query/client.js +1 -1
  20. package/dist/local/sync/BootstrapFetcher.d.ts +2 -0
  21. package/dist/local/sync/BootstrapFetcher.js +4 -4
  22. package/dist/local/sync/OnDemandLoader.d.ts +2 -0
  23. package/dist/local/sync/OnDemandLoader.js +1 -0
  24. package/dist/local/sync/SyncWebSocket.d.ts +2 -15
  25. package/dist/local/sync/SyncWebSocket.js +1 -36
  26. package/dist/local/sync/createClaimStream.d.ts +9 -19
  27. package/dist/local/sync/createClaimStream.js +40 -55
  28. package/dist/local/sync/socketEventWiring.d.ts +1 -2
  29. package/dist/local/sync/socketEventWiring.js +1 -5
  30. package/dist/local/transactions/mutations/MutationQueue.d.ts +6 -3
  31. package/dist/local/transactions/mutations/MutationQueue.js +36 -2
  32. package/dist/local/transactions/mutations/deltaConfirmation.d.ts +1 -0
  33. package/dist/local/transactions/mutations/deltaConfirmation.js +4 -0
  34. package/dist/local/transactions/mutations/failureHandling.d.ts +1 -0
  35. package/dist/local/transactions/mutations/failureHandling.js +1 -1
  36. package/dist/local/transactions/mutations/replayValidation.d.ts +4 -4
  37. package/dist/presence/index.d.ts +20 -0
  38. package/dist/presence/index.js +67 -0
  39. package/dist/presence/readActivity.d.ts +19 -0
  40. package/dist/presence/readActivity.js +122 -0
  41. package/dist/react/AbloProvider.d.ts +3 -3
  42. package/dist/react/AbloProvider.js +4 -3
  43. package/dist/react/createAbloReact.d.ts +4 -0
  44. package/dist/react/createAbloReact.js +12 -2
  45. package/dist/react/useAblo.d.ts +2 -0
  46. package/dist/react/useAblo.js +10 -5
  47. package/dist/react/usePresence.d.ts +19 -0
  48. package/dist/react/usePresence.js +28 -0
  49. package/dist/react.d.ts +1 -0
  50. package/dist/react.js +1 -0
  51. package/dist/surface.d.ts +1 -1
  52. package/dist/surface.js +2 -0
  53. package/package.json +2 -2
  54. package/src/Ablo.ts +15 -6
  55. package/src/client.ts +8 -13
  56. package/src/humans.ts +3 -10
  57. package/src/index.ts +1 -0
  58. package/src/local/BaseSyncedStore.ts +0 -6
  59. package/src/local/InstanceCache.ts +1 -1
  60. package/src/local/client/clientPrelude.ts +20 -0
  61. package/src/local/client/createInternalComponents.ts +2 -0
  62. package/src/local/client/createModelOperations.ts +140 -0
  63. package/src/local/client/reactiveEngine.ts +35 -14
  64. package/src/local/query/client.ts +4 -0
  65. package/src/local/sync/BootstrapFetcher.ts +13 -4
  66. package/src/local/sync/OnDemandLoader.ts +3 -0
  67. package/src/local/sync/SyncWebSocket.ts +1 -42
  68. package/src/local/sync/createClaimStream.ts +51 -71
  69. package/src/local/sync/socketEventWiring.ts +1 -8
  70. package/src/local/transactions/mutations/MutationQueue.ts +26 -2
  71. package/src/local/transactions/mutations/deltaConfirmation.ts +3 -0
  72. package/src/local/transactions/mutations/failureHandling.ts +2 -1
  73. package/src/presence/index.ts +101 -0
  74. package/src/presence/readActivity.ts +149 -0
  75. package/src/react/AbloProvider.tsx +14 -9
  76. package/src/react/createAbloReact.ts +25 -1
  77. package/src/react/useAblo.ts +14 -6
  78. package/src/react/usePresence.ts +88 -0
  79. package/src/react.ts +4 -0
  80. package/src/surface.ts +2 -0
  81. package/dist/presenceStream.d.ts +0 -69
  82. package/dist/presenceStream.js +0 -200
  83. package/src/presenceStream.ts +0 -279
@@ -1257,7 +1257,7 @@ export class InstanceCache {
1257
1257
  }
1258
1258
 
1259
1259
  private startGC(): void {
1260
- if (this.gcTimer) return;
1260
+ if (this.gcTimer || this.config.gcInterval <= 0) return;
1261
1261
  this.gcTimer = setInterval(() => this.gc(), this.config.gcInterval);
1262
1262
  // Don't hold a headless Node process open just for pool GC — without
1263
1263
  // this, an agent that never calls disconnect() can never exit. No-op in
@@ -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,14 @@ 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';
147
+ import type {
148
+ CollaborationEventContext,
149
+ ModelEventEnvelope,
150
+ ModelEventInput,
151
+ ModelEventTarget,
152
+ } from '@abloatai/transaction/collaboration';
153
+ import { modelEventInputSchema } from '@abloatai/transaction/collaboration';
146
154
  import {
147
155
  capturePointRead,
148
156
  prepareReadSet,
@@ -151,10 +159,16 @@ import {
151
159
 
152
160
  const ignoreSeparatelyObservedMutationFailure = (): undefined => undefined;
153
161
  const ignoreBestEffortClaimReleaseFailure = (): undefined => undefined;
162
+ const ignoreBestEffortScopeFailure = (): undefined => undefined;
154
163
 
155
164
  export interface ModelClientMeta {
156
165
  readonly key: string;
157
166
  readonly typename: string;
167
+ readonly presence?: {
168
+ get(recordId: string): readonly PresenceSession[];
169
+ subscribe(listener: () => void): () => void;
170
+ read(recordId: string): () => void;
171
+ };
158
172
  }
159
173
 
160
174
  const modelClientMeta = new WeakMap<object, ModelClientMeta>();
@@ -177,6 +191,15 @@ type EntityHalf = Pick<ModelTarget, 'model' | 'id'>;
177
191
  // `ModelCollaboration<Item>` and `ModelCollaboration<Invoice>` the same type
178
192
  // while reading as though they differed.
179
193
  export interface ModelCollaboration {
194
+ /** Session projections already held by this client's one presence store. */
195
+ presence(model: string, recordId?: string): readonly PresenceSession[];
196
+ /** Subscribe once to the connection-owned presence projection. */
197
+ onPresenceChange(listener: () => void): () => void;
198
+ /** Start one session-owned read activity and return its cleanup. */
199
+ startReadPresence(target: EntityHalf): () => void;
200
+ modelEventTarget(recordId: string): ModelEventTarget;
201
+ sendModelEvent(input: ModelEventInput): void;
202
+ onModelEvent(listener: (event: ModelEventEnvelope) => void): () => void;
180
203
  /** Exact point evidence from the HTTP read boundary (stamp captured before data). */
181
204
  readPoint(model: string, id: string): Promise<{ data: unknown; stamp: number }>;
182
205
  /**
@@ -278,6 +301,8 @@ export interface ModelCollaboration {
278
301
  * test doubles can omit it.
279
302
  */
280
303
  enterScope?(scope: Record<string, string>): void | Promise<void>;
304
+ /** Release read interest previously acquired through {@link enterScope}. */
305
+ leaveScope?(scope: Record<string, string>): void | Promise<void>;
281
306
  /**
282
307
  * Pins a scope's sync group(s) — write intent: a row this client holds an
283
308
  * active claim on stays subscribed regardless of navigation. Same
@@ -336,6 +361,12 @@ interface ReactiveModelSurface<T, Fields = T> {
336
361
  /** The synchronous local-graph reads. */
337
362
  local: LocalReads<T>;
338
363
 
364
+ /** Sessions currently active on this model, optionally narrowed to one record. */
365
+ presence(recordId?: string): readonly PresenceSession[];
366
+
367
+ /** Lossy, model-record-addressed application events such as cursor or selection. */
368
+ events: ModelEvents;
369
+
339
370
  /**
340
371
  * Claim a row so other writers wait or are rejected until you're done, and
341
372
  * inspect or manage that coordination through the same namespace. Call it to
@@ -371,6 +402,50 @@ interface ReactiveModelSurface<T, Fields = T> {
371
402
  ): () => void;
372
403
  }
373
404
 
405
+ export interface ModelEvents {
406
+ send(
407
+ recordId: string,
408
+ event: string,
409
+ payload: Readonly<Record<string, unknown>>,
410
+ ): void;
411
+ subscribe(
412
+ recordId: string,
413
+ event: string,
414
+ handler: (
415
+ payload: Readonly<Record<string, unknown>>,
416
+ context: CollaborationEventContext,
417
+ ) => void,
418
+ ): () => void;
419
+ }
420
+
421
+ function subscribeInModelScope(
422
+ collaboration: ModelCollaboration,
423
+ scope: Record<string, string>,
424
+ subscribe: () => () => void,
425
+ ): () => void {
426
+ let stopped = false;
427
+ let entered = false;
428
+ let unsubscribe: (() => void) | null = null;
429
+
430
+ void Promise.resolve(collaboration.enterScope?.(scope))
431
+ .then(() => {
432
+ entered = true;
433
+ if (stopped) {
434
+ void collaboration.leaveScope?.(scope);
435
+ return;
436
+ }
437
+ unsubscribe = subscribe();
438
+ })
439
+ .catch(ignoreBestEffortScopeFailure);
440
+
441
+ return () => {
442
+ if (stopped) return;
443
+ stopped = true;
444
+ unsubscribe?.();
445
+ if (entered) void collaboration.leaveScope?.(scope);
446
+ };
447
+ }
448
+
374
449
  /**
375
450
  * Everything reachable as `ablo.<model>` on a reactive client.
376
451
  *
@@ -1403,6 +1478,54 @@ export function createModelOperations<T, C>(
1403
1478
  const operations: ModelOperations<T, C> = {
1404
1479
  local,
1405
1480
 
1481
+ presence: (recordId?: string) => collaboration?.presence(registeredModelName, recordId) ?? [],
1482
+
1483
+ events: {
1484
+ send(recordId, event, payload) {
1485
+ if (!collaboration) return;
1486
+ const target = collaboration.modelEventTarget(recordId);
1487
+ const parsed = modelEventInputSchema.safeParse({ target, event, payload });
1488
+ if (!parsed.success) {
1489
+ throw new AbloValidationError('Invalid model event.', {
1490
+ code: 'invalid_request',
1491
+ param: 'event',
1492
+ cause: parsed.error,
1493
+ });
1494
+ }
1495
+ const scope = { [schemaKey]: recordId };
1496
+ void Promise.resolve(collaboration.enterScope?.(scope))
1497
+ .then(() => { collaboration.sendModelEvent(parsed.data); })
1498
+ .finally(() => { void collaboration.leaveScope?.(scope); });
1499
+ },
1500
+ subscribe(recordId, event, handler) {
1501
+ if (!collaboration) return () => undefined;
1502
+ const target = collaboration.modelEventTarget(recordId);
1503
+ const parsed = modelEventInputSchema.safeParse({ target, event, payload: {} });
1504
+ if (!parsed.success) {
1505
+ throw new AbloValidationError('Invalid model event subscription.', {
1506
+ code: 'invalid_request',
1507
+ param: 'event',
1508
+ cause: parsed.error,
1509
+ });
1510
+ }
1511
+ const scope = { [schemaKey]: recordId };
1512
+ return subscribeInModelScope(collaboration, scope, () =>
1513
+ collaboration.onModelEvent((incoming) => {
1514
+ if (
1515
+ incoming.target.model !== target.model ||
1516
+ incoming.target.id !== target.id ||
1517
+ incoming.target.syncGroup !== target.syncGroup ||
1518
+ incoming.event !== parsed.data.event
1519
+ ) return;
1520
+ handler(incoming.payload, {
1521
+ sender: incoming.sender,
1522
+ sentAt: incoming.sentAt,
1523
+ });
1524
+ }),
1525
+ );
1526
+ },
1527
+ },
1528
+
1406
1529
  get,
1407
1530
  read,
1408
1531
 
@@ -1658,6 +1781,23 @@ export function createModelOperations<T, C>(
1658
1781
  modelClientMeta.set(operations, {
1659
1782
  key: schemaKey,
1660
1783
  typename: registeredModelName,
1784
+ ...(collaboration
1785
+ ? {
1786
+ presence: {
1787
+ get: (recordId: string) => collaboration.presence(registeredModelName, recordId),
1788
+ subscribe: (listener: () => void) => collaboration.onPresenceChange(listener),
1789
+ read: (recordId: string) => {
1790
+ const scope = { [schemaKey]: recordId };
1791
+ return subscribeInModelScope(collaboration, scope, () =>
1792
+ collaboration.startReadPresence({
1793
+ model: registeredModelName,
1794
+ id: recordId,
1795
+ }),
1796
+ );
1797
+ },
1798
+ },
1799
+ }
1800
+ : {}),
1661
1801
  });
1662
1802
 
1663
1803
  return operations;
@@ -39,7 +39,10 @@ import {
39
39
  bindClaimLifetime,
40
40
  claimLifetimeOf,
41
41
  } from '@abloatai/transaction/claims/lifetime';
42
- import type { AttachablePresenceStream } from '../../presenceStream.js';
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';
@@ -75,6 +78,7 @@ import {
75
78
  prepareReadSet,
76
79
  } from '@abloatai/transaction/internal/read-set';
77
80
  import { contextOnChange } from '../sync/contextOnChange.js';
81
+ import { resolveScopeGroups } from '../sync/scopeGroups.js';
78
82
  import type { ReadDependency } from '@abloatai/transaction/coordination';
79
83
  import type { CapturedRow } from '@abloatai/transaction/transport/http';
80
84
 
@@ -96,7 +100,7 @@ export interface ReactiveEngineInputs<S extends SchemaRecord> extends ClientPrel
96
100
  transport: SyncWebSocket;
97
101
  /** The humans() plugin's contribution — built by its `init`, already
98
102
  * attached to the connection the context carried. */
99
- presence: AttachablePresenceStream;
103
+ presence: AttachablePresence;
100
104
  /**
101
105
  * The store cluster `humans().init` constructed from the widened context:
102
106
  * this client's runtime, the component graph, and the store. The engine
@@ -183,7 +187,11 @@ export function buildReactiveEngine<const S extends SchemaRecord>(
183
187
  // filter own echoes by participant id, seeded in `ready()` alongside the
184
188
  // locals above.
185
189
  const presenceStream = presence;
186
- const claimStream = createClaimStream({ participantId, logger }, transport);
190
+ const claimStream = createClaimStream(
191
+ { logger },
192
+ transport,
193
+ presenceStream,
194
+ );
187
195
 
188
196
  // 6. Validate options up front — fail loudly on obviously wrong inputs so
189
197
  // strangers don't get silent empty results. Validation errors are written
@@ -239,17 +247,11 @@ export function buildReactiveEngine<const S extends SchemaRecord>(
239
247
  kind,
240
248
  logger,
241
249
  validationError: _validationError,
242
- onIdentityResolved: ({ userId, participantKind, accountScope, syncGroups, authority }) => {
250
+ onIdentityResolved: ({ userId, participantKind, accountScope, authority }) => {
243
251
  selfParticipantId = userId;
244
252
  selfParticipantKind = participantKind;
245
253
  _resolvedOrganizationId = accountScope;
246
254
  _resolvedIdentity = authority;
247
- presenceStream.setParticipant({
248
- id: userId,
249
- kind: participantKind,
250
- syncGroups: [...syncGroups],
251
- });
252
- claimStream.setParticipant({ id: userId });
253
255
  },
254
256
  });
255
257
  const ready = lifecycle.ready;
@@ -573,6 +575,21 @@ export function buildReactiveEngine<const S extends SchemaRecord>(
573
575
  modelRegistry,
574
576
  hydration,
575
577
  {
578
+ presence: (model, recordId) => presenceStream.forModel(model, recordId),
579
+ onPresenceChange: (listener) => presenceStream.onChange(listener),
580
+ startReadPresence: (target) => presenceStream.startRead(target),
581
+ modelEventTarget: (recordId) => {
582
+ const syncGroup = resolveScopeGroups({ [schemaKey]: recordId }, schema)[0];
583
+ if (syncGroup === undefined) {
584
+ throw new AbloValidationError('A model event requires a record scope.', {
585
+ code: 'invalid_request',
586
+ param: 'recordId',
587
+ });
588
+ }
589
+ return { model: registeredModelName, id: recordId, syncGroup };
590
+ },
591
+ sendModelEvent: (input) => { transport.sendModelEvent(input); },
592
+ onModelEvent: (listener) => transport.subscribe('model_event', listener),
576
593
  createClaim: (claimOptions) => publicClaims.create(claimOptions),
577
594
  // Lazily referenced: `commits` is declared below this loop, and this
578
595
  // only runs when someone actually writes a batch.
@@ -615,6 +632,7 @@ export function buildReactiveEngine<const S extends SchemaRecord>(
615
632
  // stay fire-and-forget. It's soft either way — the store swallows
616
633
  // reconcile errors so read interest never makes a read reject or stall.
617
634
  enterScope: (scope) => store.enterScope(scope),
635
+ leaveScope: (scope) => store.leaveScope(scope),
618
636
  pinScope: (scope) => store.pinScope(scope),
619
637
  },
620
638
  readSetContext,
@@ -831,6 +849,11 @@ export function buildReactiveEngine<const S extends SchemaRecord>(
831
849
  return store.syncStatus;
832
850
  },
833
851
 
852
+ // The humans capability owns the connection-backed presence projection.
853
+ // Keep it on the base client as well as in the plugin surface so the
854
+ // concrete AbloClient contract and runtime object agree before layering.
855
+ presence: presenceStream,
856
+
834
857
  schema,
835
858
 
836
859
  // ── Internal accessors for framework integration ─────────────────
@@ -848,10 +871,6 @@ export function buildReactiveEngine<const S extends SchemaRecord>(
848
871
  /** The SyncWebSocket — for collaboration events (selection, cursors). */
849
872
  get _ws() { return store.getSyncWebSocket(); },
850
873
 
851
- /** Presence livestream — same socket as entity sync, no second
852
- * connection. Stable reference across the engine's lifetime. */
853
- presence: presenceStream,
854
-
855
874
  /** Claim livestream — same socket. Stable reference. */
856
875
  claims: publicClaims,
857
876
 
@@ -859,6 +878,8 @@ export function buildReactiveEngine<const S extends SchemaRecord>(
859
878
 
860
879
  } as Ablo<S>;
861
880
 
881
+ attachPresenceToClient(engine, presenceStream);
882
+
862
883
  Object.defineProperty(engine, kReadEvidence, {
863
884
  value: {
864
885
  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(this.options.getAuthToken, {}, this.options.authToken),
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: announce presence, tell the server where we left off,
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
  */