@abloatai/humans 0.62.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 (40) hide show
  1. package/dist/index.d.ts +1 -0
  2. package/dist/local/InstanceCache.js +1 -1
  3. package/dist/local/client/createModelOperations.d.ts +21 -0
  4. package/dist/local/client/createModelOperations.js +84 -0
  5. package/dist/local/client/reactiveEngine.js +16 -0
  6. package/dist/local/transactions/mutations/MutationQueue.d.ts +3 -0
  7. package/dist/local/transactions/mutations/MutationQueue.js +36 -2
  8. package/dist/local/transactions/mutations/deltaConfirmation.d.ts +1 -0
  9. package/dist/local/transactions/mutations/deltaConfirmation.js +4 -0
  10. package/dist/local/transactions/mutations/failureHandling.d.ts +1 -0
  11. package/dist/local/transactions/mutations/failureHandling.js +1 -1
  12. package/dist/presence/index.d.ts +7 -2
  13. package/dist/presence/index.js +18 -0
  14. package/dist/presence/readActivity.d.ts +19 -0
  15. package/dist/presence/readActivity.js +122 -0
  16. package/dist/react/createAbloReact.d.ts +4 -0
  17. package/dist/react/createAbloReact.js +12 -2
  18. package/dist/react/useAblo.d.ts +2 -0
  19. package/dist/react/useAblo.js +10 -5
  20. package/dist/react/usePresence.d.ts +19 -0
  21. package/dist/react/usePresence.js +28 -0
  22. package/dist/react.d.ts +1 -0
  23. package/dist/react.js +1 -0
  24. package/dist/surface.d.ts +1 -1
  25. package/dist/surface.js +1 -0
  26. package/package.json +2 -2
  27. package/src/index.ts +1 -0
  28. package/src/local/InstanceCache.ts +1 -1
  29. package/src/local/client/createModelOperations.ts +132 -0
  30. package/src/local/client/reactiveEngine.ts +16 -0
  31. package/src/local/transactions/mutations/MutationQueue.ts +26 -2
  32. package/src/local/transactions/mutations/deltaConfirmation.ts +3 -0
  33. package/src/local/transactions/mutations/failureHandling.ts +2 -1
  34. package/src/presence/index.ts +32 -3
  35. package/src/presence/readActivity.ts +149 -0
  36. package/src/react/createAbloReact.ts +25 -1
  37. package/src/react/useAblo.ts +14 -6
  38. package/src/react/usePresence.ts +88 -0
  39. package/src/react.ts +4 -0
  40. package/src/surface.ts +1 -0
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';
@@ -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
@@ -23,10 +23,16 @@ import type { ClaimApi, ClaimAttemptEvent, LocalCountOptions, LocalReadOptions }
23
23
  import type { HttpModelClient } from '@abloatai/transaction/transport/http';
24
24
  import type { ParticipantKind } from '@abloatai/transaction/types/participant';
25
25
  import type { PresenceSession } from '@abloatai/transaction/presence';
26
+ import type { CollaborationEventContext, ModelEventEnvelope, ModelEventInput, ModelEventTarget } from '@abloatai/transaction/collaboration';
26
27
  import { type ReadSetContext } from '@abloatai/transaction/internal/read-set';
27
28
  export interface ModelClientMeta {
28
29
  readonly key: string;
29
30
  readonly typename: string;
31
+ readonly presence?: {
32
+ get(recordId: string): readonly PresenceSession[];
33
+ subscribe(listener: () => void): () => void;
34
+ read(recordId: string): () => void;
35
+ };
30
36
  }
31
37
  export declare function getModelClientMeta(modelClient: unknown): ModelClientMeta | undefined;
32
38
  /**
@@ -37,6 +43,13 @@ type EntityHalf = Pick<ModelTarget, 'model' | 'id'>;
37
43
  export interface ModelCollaboration {
38
44
  /** Session projections already held by this client's one presence store. */
39
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;
40
53
  /** Exact point evidence from the HTTP read boundary (stamp captured before data). */
41
54
  readPoint(model: string, id: string): Promise<{
42
55
  data: unknown;
@@ -138,6 +151,8 @@ export interface ModelCollaboration {
138
151
  * test doubles can omit it.
139
152
  */
140
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>;
141
156
  /**
142
157
  * Pins a scope's sync group(s) — write intent: a row this client holds an
143
158
  * active claim on stays subscribed regardless of navigation. Same
@@ -192,6 +207,8 @@ interface ReactiveModelSurface<T, Fields = T> {
192
207
  local: LocalReads<T>;
193
208
  /** Sessions currently active on this model, optionally narrowed to one record. */
194
209
  presence(recordId?: string): readonly PresenceSession[];
210
+ /** Lossy, model-record-addressed application events such as cursor or selection. */
211
+ events: ModelEvents;
195
212
  /**
196
213
  * Claim a row so other writers wait or are rejected until you're done, and
197
214
  * inspect or manage that coordination through the same namespace. Call it to
@@ -222,6 +239,10 @@ interface ReactiveModelSurface<T, Fields = T> {
222
239
  /** Subscribe to changes; the callback runs on every change. */
223
240
  onChange(callback: (entities: T[]) => void, options?: LocalReadOptions<T>): () => void;
224
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
+ }
225
246
  /**
226
247
  * Everything reachable as `ablo.<model>` on a reactive client.
227
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.
@@ -830,6 +855,50 @@ hydration, collaboration, readSetContext) {
830
855
  const operations = {
831
856
  local,
832
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
+ },
833
902
  get,
834
903
  read,
835
904
  // No automatic scope enrolment on bulk `list`: that would subscribe to an
@@ -1019,6 +1088,21 @@ hydration, collaboration, readSetContext) {
1019
1088
  modelClientMeta.set(operations, {
1020
1089
  key: schemaKey,
1021
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
+ : {}),
1022
1106
  });
1023
1107
  return operations;
1024
1108
  }
@@ -29,6 +29,7 @@ import { modelReadResponseSchema, commitRecordSchema, commitRecordListSchema, co
29
29
  import { translateHttpError, } from '@abloatai/transaction/errors';
30
30
  import { kReadEvidence, prepareReadSet, } from '@abloatai/transaction/internal/read-set';
31
31
  import { contextOnChange } from '../sync/contextOnChange.js';
32
+ import { resolveScopeGroups } from '../sync/scopeGroups.js';
32
33
  export function buildReactiveEngine(inputs) {
33
34
  const { options, internalOptions, url, logger, configuredApiKey, configuredAuthToken, credentialResolver, authCredentials, transport, participantId, kind, presence, cluster, } = inputs;
34
35
  const schema = options.schema;
@@ -394,6 +395,20 @@ export function buildReactiveEngine(inputs) {
394
395
  const registeredModelName = modelDef.typename ?? schemaKey;
395
396
  modelProxies[schemaKey] = createModelOperations(schemaKey, registeredModelName, objectPool, syncClient, modelRegistry, hydration, {
396
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),
397
412
  createClaim: (claimOptions) => publicClaims.create(claimOptions),
398
413
  // Lazily referenced: `commits` is declared below this loop, and this
399
414
  // only runs when someone actually writes a batch.
@@ -432,6 +447,7 @@ export function buildReactiveEngine(inputs) {
432
447
  // stay fire-and-forget. It's soft either way — the store swallows
433
448
  // reconcile errors so read interest never makes a read reject or stall.
434
449
  enterScope: (scope) => store.enterScope(scope),
450
+ leaveScope: (scope) => store.leaveScope(scope),
435
451
  pinScope: (scope) => store.pinScope(scope),
436
452
  }, readSetContext);
437
453
  }
@@ -116,6 +116,8 @@ export declare class MutationQueue extends EventEmitter {
116
116
  private isProcessing;
117
117
  private processTimer?;
118
118
  private processScheduled;
119
+ private disposed;
120
+ private retryTimers;
119
121
  private createdTransactions;
120
122
  private commitScheduled;
121
123
  private inFlightByModel;
@@ -343,6 +345,7 @@ export declare class MutationQueue extends EventEmitter {
343
345
  */
344
346
  onDeltaReceived(syncId: number, _transactionId?: string, correlationId?: string): void;
345
347
  private scheduleDeltaConfirmationTimeout;
348
+ private scheduleRetry;
346
349
  /**
347
350
  * Resolves once the given transaction is confirmed and rejects if it fails.
348
351
  * The confirming delta's timeout is handled by
@@ -74,6 +74,8 @@ export class MutationQueue extends EventEmitter {
74
74
  isProcessing = false;
75
75
  processTimer;
76
76
  processScheduled = false;
77
+ disposed = false;
78
+ retryTimers = new Set();
77
79
  // Staging area for transactions created in the same event-loop tick. Each one
78
80
  // lands here first, then a microtask commits them together.
79
81
  createdTransactions = [];
@@ -149,11 +151,14 @@ export class MutationQueue extends EventEmitter {
149
151
  isDefinitiveRejection: (error) => this.isDefinitiveRejection(error),
150
152
  isPermanentError: (error) => this.isPermanentError(error),
151
153
  scheduleRetry: (delayMs) => {
154
+ if (this.disposed)
155
+ return;
152
156
  if (this.commitRetryTimer !== null)
153
157
  clearTimeout(this.commitRetryTimer);
154
158
  this.commitRetryTimer = setTimeout(() => {
155
159
  this.commitRetryTimer = null;
156
- void this.processCommitLane();
160
+ if (!this.disposed)
161
+ void this.processCommitLane();
157
162
  }, delayMs);
158
163
  },
159
164
  emitCommitLifecycle: (event, payload) => { this.emitCommitLifecycle(event, payload); },
@@ -276,6 +281,7 @@ export class MutationQueue extends EventEmitter {
276
281
  isPermanentError: (error) => this.isPermanentError(error),
277
282
  rollbackOptimistic: (transaction, reason, error) => this.rollbackOptimistic(transaction, reason, error),
278
283
  enqueue: (transaction) => { this.enqueue(transaction); },
284
+ scheduleRetry: (callback, delayMs) => { this.scheduleRetry(callback, delayMs); },
279
285
  getLastPermanentErrorSignature: () => this.lastPermanentErrorSig,
280
286
  setLastPermanentErrorSignature: (signature) => { this.lastPermanentErrorSig = signature; },
281
287
  emit: (event, payload) => this.emit(event, payload),
@@ -655,6 +661,8 @@ export class MutationQueue extends EventEmitter {
655
661
  * optimistic state, or remove the durable replay envelope.
656
662
  */
657
663
  scheduleReplicationLagTimeout(transactionId, clientTxId = transactionId, correlationId) {
664
+ if (this.disposed)
665
+ return;
658
666
  const previous = this.replicationLagTimeouts.get(transactionId);
659
667
  if (previous)
660
668
  clearTimeout(previous);
@@ -846,6 +854,8 @@ export class MutationQueue extends EventEmitter {
846
854
  * {@link drainPending} resumes the work when the owner decides to drain.
847
855
  */
848
856
  setConnectionState(state) {
857
+ if (this.disposed)
858
+ return;
849
859
  if (state === 'connected') {
850
860
  if (this.commitOfflineGraceTimer !== null) {
851
861
  clearTimeout(this.commitOfflineGraceTimer);
@@ -1013,12 +1023,16 @@ export class MutationQueue extends EventEmitter {
1013
1023
  enqueueTransaction(this.queueCoalescingContext, transaction);
1014
1024
  }
1015
1025
  scheduleProcessing(immediate = false) {
1026
+ if (this.disposed)
1027
+ return;
1016
1028
  scheduleProcessingExternal(this.processingSchedulerContext, immediate);
1017
1029
  }
1018
1030
  async processBatch() {
1031
+ if (this.disposed)
1032
+ return;
1019
1033
  if (this.modelProcessingPromise) {
1020
1034
  await this.modelProcessingPromise;
1021
- if (this.executionQueue.length > 0)
1035
+ if (!this.disposed && this.executionQueue.length > 0)
1022
1036
  await this.processBatch();
1023
1037
  return;
1024
1038
  }
@@ -1103,8 +1117,20 @@ export class MutationQueue extends EventEmitter {
1103
1117
  // Schedule the retry-and-reconciliation wait for a transaction's confirming
1104
1118
  // delta; see {@link DeltaConfirmationTracker} in `./deltaConfirmation.js`.
1105
1119
  scheduleDeltaConfirmationTimeout(tx, timeoutMs) {
1120
+ if (this.disposed)
1121
+ return;
1106
1122
  this.deltaConfirmation.scheduleDeltaConfirmationTimeout(tx, timeoutMs);
1107
1123
  }
1124
+ scheduleRetry(callback, delayMs) {
1125
+ if (this.disposed)
1126
+ return;
1127
+ const timer = setTimeout(() => {
1128
+ this.retryTimers.delete(timer);
1129
+ if (!this.disposed)
1130
+ callback();
1131
+ }, delayMs);
1132
+ this.retryTimers.add(timer);
1133
+ }
1108
1134
  /**
1109
1135
  * Resolves once the given transaction is confirmed and rejects if it fails.
1110
1136
  * The confirming delta's timeout is handled by
@@ -1156,6 +1182,8 @@ export class MutationQueue extends EventEmitter {
1156
1182
  return enqueueCommit(this.commitApiContext, clientTxId, operations, options);
1157
1183
  }
1158
1184
  async processCommitLane() {
1185
+ if (this.disposed)
1186
+ return;
1159
1187
  await processCommitLane(this.commitLaneContext);
1160
1188
  }
1161
1189
  waitForCommitReceipt(clientTxId) {
@@ -1388,6 +1416,9 @@ export class MutationQueue extends EventEmitter {
1388
1416
  * clears all timers and stored transactions, and removes event listeners.
1389
1417
  */
1390
1418
  dispose() {
1419
+ if (this.disposed)
1420
+ return;
1421
+ this.disposed = true;
1391
1422
  // Cancel all active optimistic updates
1392
1423
  for (const [, optimistic] of this.localMutationPort.updates) {
1393
1424
  this.emit('optimistic:rollback', {
@@ -1419,6 +1450,9 @@ export class MutationQueue extends EventEmitter {
1419
1450
  clearTimeout(this.commitRetryTimer);
1420
1451
  this.commitRetryTimer = null;
1421
1452
  }
1453
+ for (const timer of this.retryTimers)
1454
+ clearTimeout(timer);
1455
+ this.retryTimers.clear();
1422
1456
  // Clear store
1423
1457
  this.store.clear();
1424
1458
  this.localMutationPort.updates.clear();
@@ -40,6 +40,7 @@ export declare class DeltaConfirmationTracker {
40
40
  private static readonly DELTA_MAX_TIMEOUT_MS;
41
41
  private deltaConfirmationTimeouts;
42
42
  private deltaConfirmationRetries;
43
+ private disposed;
43
44
  private readonly runtime;
44
45
  constructor(ctx: DeltaConfirmationContext);
45
46
  /** Applied-cursor alias, kept so the read sites below stay legible. */
@@ -20,6 +20,7 @@ export class DeltaConfirmationTracker {
20
20
  deltaConfirmationTimeouts = new Map();
21
21
  // Track retry attempts per transaction for exponential backoff
22
22
  deltaConfirmationRetries = new Map();
23
+ disposed = false;
23
24
  runtime;
24
25
  constructor(ctx) {
25
26
  this.ctx = ctx;
@@ -113,6 +114,8 @@ export class DeltaConfirmationTracker {
113
114
  // server has already confirmed. A rollback happens only on an explicit server
114
115
  // rejection, never on a timeout.
115
116
  scheduleDeltaConfirmationTimeout(tx, timeoutMs) {
117
+ if (this.disposed)
118
+ return;
116
119
  // Cancel any existing timeout for this transaction
117
120
  this.cancelDeltaConfirmationTimeout(tx.id);
118
121
  // Deliberately not an async callback: the body is fully synchronous, and
@@ -226,6 +229,7 @@ export class DeltaConfirmationTracker {
226
229
  * would keep the process alive and fire callbacks against a cleared store.
227
230
  */
228
231
  dispose() {
232
+ this.disposed = true;
229
233
  for (const timeoutHandle of this.deltaConfirmationTimeouts.values()) {
230
234
  clearTimeout(timeoutHandle);
231
235
  }
@@ -9,6 +9,7 @@ export interface FailureHandlingContext {
9
9
  readonly isPermanentError: (error: Error) => boolean;
10
10
  readonly rollbackOptimistic: (transaction: QueuedMutation, reason: string, error?: Error) => Promise<void>;
11
11
  readonly enqueue: (transaction: QueuedMutation) => void;
12
+ readonly scheduleRetry: (callback: () => void, delayMs: number) => void;
12
13
  readonly getLastPermanentErrorSignature: () => string | undefined;
13
14
  readonly setLastPermanentErrorSignature: (signature: string) => void;
14
15
  readonly emit: (event: string, payload: object) => boolean;
@@ -54,7 +54,7 @@ export async function handleFailure(ctx, transaction, error) {
54
54
  // stall unrelated commits.
55
55
  const delay = transientRetryDelayMs(error, transaction.attempts, ctx.config.retryBackoff);
56
56
  ctx.store.updateStatus(transaction.id, 'pending');
57
- setTimeout(() => {
57
+ ctx.scheduleRetry(() => {
58
58
  // The queue may have shut down or the tx may have been settled
59
59
  // (e.g. delta-confirmed) while we backed off.
60
60
  if (ctx.store.get(transaction.id)?.status !== 'pending')
@@ -1,4 +1,7 @@
1
1
  import { type PresenceProjection, type PresenceProjectionEvents, type PresenceView } from '@abloatai/transaction/presence';
2
+ import type { PresenceTarget } from '@abloatai/transaction/presence';
3
+ import { type ReadActivityTransport } from './readActivity.js';
4
+ type PresenceTransport = PresenceProjectionEvents & ReadActivityTransport;
2
5
  /** Reactive-client presence backed by the client's existing live connection. */
3
6
  export interface ReactivePresence extends PresenceView {
4
7
  forModel(model: string, recordId?: string): ReturnType<PresenceProjection['forModel']>;
@@ -6,10 +9,12 @@ export interface ReactivePresence extends PresenceView {
6
9
  }
7
10
  /** Lifecycle hooks kept inside the humans composition boundary. */
8
11
  export interface AttachablePresence extends ReactivePresence {
9
- attach(transport: PresenceProjectionEvents): void;
12
+ attach(transport: PresenceTransport): void;
13
+ startRead(target: PresenceTarget): () => void;
10
14
  dispose(): void;
11
15
  }
12
16
  /** Framework bridge that does not consume a string key on the model namespace. */
13
17
  export declare function attachPresenceToClient(client: object, presence: ReactivePresence): void;
14
18
  export declare function presenceOfClient(client: object): ReactivePresence;
15
- export declare function createPresence(transport?: PresenceProjectionEvents | null): AttachablePresence;
19
+ export declare function createPresence(transport?: PresenceTransport | null): AttachablePresence;
20
+ export {};
@@ -1,4 +1,5 @@
1
1
  import { createPresenceProjection, } from '@abloatai/transaction/presence';
2
+ import { startReadActivity, } from './readActivity.js';
2
3
  const clientPresence = new WeakMap();
3
4
  /** Framework bridge that does not consume a string key on the model namespace. */
4
5
  export function attachPresenceToClient(client, presence) {
@@ -12,7 +13,9 @@ export function presenceOfClient(client) {
12
13
  }
13
14
  export function createPresence(transport = null) {
14
15
  let projection = null;
16
+ let attachedTransport = null;
15
17
  const listeners = new Set();
18
+ const reads = new Set();
16
19
  let unsubscribe = null;
17
20
  const notify = () => {
18
21
  for (const listener of listeners)
@@ -21,6 +24,7 @@ export function createPresence(transport = null) {
21
24
  const attach = (events) => {
22
25
  if (projection !== null)
23
26
  return;
27
+ attachedTransport = events;
24
28
  projection = createPresenceProjection(events);
25
29
  unsubscribe = projection.subscribe(notify);
26
30
  notify();
@@ -35,14 +39,28 @@ export function createPresence(transport = null) {
35
39
  return () => { listeners.delete(listener); };
36
40
  },
37
41
  attach,
42
+ startRead(target) {
43
+ if (attachedTransport === null) {
44
+ throw new Error('presence is not attached to a duplex transport');
45
+ }
46
+ const lifetime = startReadActivity(attachedTransport, target, () => { reads.delete(lifetime); });
47
+ reads.add(lifetime);
48
+ return () => {
49
+ lifetime.stop();
50
+ };
51
+ },
38
52
  forModel(model, recordId) {
39
53
  return projection?.forModel(model, recordId) ?? [];
40
54
  },
41
55
  dispose() {
56
+ for (const read of reads)
57
+ read.dispose();
58
+ reads.clear();
42
59
  unsubscribe?.();
43
60
  unsubscribe = null;
44
61
  projection?.dispose();
45
62
  projection = null;
63
+ attachedTransport = null;
46
64
  listeners.clear();
47
65
  },
48
66
  };
@@ -0,0 +1,19 @@
1
+ import type { PresenceCommand, PresenceTarget } from '@abloatai/transaction/presence';
2
+ /** The connection slice needed to keep one session-owned read alive. */
3
+ export interface ReadActivityTransport {
4
+ isConnected(): boolean;
5
+ sendPresenceCommand(command: PresenceCommand): void;
6
+ subscribe(event: 'connected', listener: () => void): () => void;
7
+ }
8
+ export interface ReadActivityLifetime {
9
+ /** End the read and remove it from the server, reconnecting briefly if needed. */
10
+ stop(): void;
11
+ /** Tear down local resources when the owning client itself is disposed. */
12
+ dispose(): void;
13
+ }
14
+ /**
15
+ * Own the lease mechanics for one declared read. React only starts and stops
16
+ * this lifetime; command ids, refreshes, reconnect recovery, and offline
17
+ * cleanup remain inside the presence subsystem.
18
+ */
19
+ export declare function startReadActivity(transport: ReadActivityTransport, target: PresenceTarget, onFinished?: () => void): ReadActivityLifetime;
@@ -0,0 +1,122 @@
1
+ import { LEASE_TTL_MS } from '@abloatai/transaction/wire';
2
+ const READ_TTL_MS = LEASE_TTL_MS;
3
+ const READ_REFRESH_MS = READ_TTL_MS / 3;
4
+ let fallbackActivitySequence = 0;
5
+ const noop = () => undefined;
6
+ function readActivityId() {
7
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
8
+ return `read:${crypto.randomUUID()}`;
9
+ }
10
+ fallbackActivitySequence += 1;
11
+ return `read:${Date.now().toString(36)}:${fallbackActivitySequence.toString(36)}`;
12
+ }
13
+ function unref(timer) {
14
+ const candidate = timer;
15
+ if (typeof candidate === 'object' &&
16
+ candidate !== null &&
17
+ 'unref' in candidate &&
18
+ typeof candidate.unref === 'function') {
19
+ candidate.unref();
20
+ }
21
+ }
22
+ /**
23
+ * Own the lease mechanics for one declared read. React only starts and stops
24
+ * this lifetime; command ids, refreshes, reconnect recovery, and offline
25
+ * cleanup remain inside the presence subsystem.
26
+ */
27
+ export function startReadActivity(transport, target, onFinished = noop) {
28
+ const activityId = readActivityId();
29
+ let active = true;
30
+ let disposed = false;
31
+ let removeOnConnect = null;
32
+ let removalExpiry = null;
33
+ let finished = false;
34
+ const finish = () => {
35
+ if (finished)
36
+ return;
37
+ finished = true;
38
+ onFinished();
39
+ };
40
+ const send = (command) => {
41
+ if (!transport.isConnected())
42
+ return false;
43
+ try {
44
+ transport.sendPresenceCommand(command);
45
+ return true;
46
+ }
47
+ catch (error) {
48
+ // The socket can close between the state check and the synchronous send.
49
+ // Reconnect handling retries; a failure while still connected is real.
50
+ if (transport.isConnected())
51
+ throw error;
52
+ return false;
53
+ }
54
+ };
55
+ const upsert = () => {
56
+ if (!active || disposed)
57
+ return;
58
+ send({ type: 'read.upsert', activityId, target, ttlMs: READ_TTL_MS });
59
+ };
60
+ const unsubscribeConnected = transport.subscribe('connected', upsert);
61
+ upsert();
62
+ const refreshTimer = setInterval(() => {
63
+ if (!active || disposed)
64
+ return;
65
+ send({ type: 'read.refresh', activityId, ttlMs: READ_TTL_MS });
66
+ }, READ_REFRESH_MS);
67
+ unref(refreshTimer);
68
+ const clearResources = () => {
69
+ unsubscribeConnected();
70
+ clearInterval(refreshTimer);
71
+ removeOnConnect?.();
72
+ removeOnConnect = null;
73
+ if (removalExpiry !== null)
74
+ clearTimeout(removalExpiry);
75
+ removalExpiry = null;
76
+ };
77
+ const stop = () => {
78
+ if (!active || disposed)
79
+ return;
80
+ active = false;
81
+ unsubscribeConnected();
82
+ clearInterval(refreshTimer);
83
+ const remove = () => {
84
+ if (disposed)
85
+ return;
86
+ if (!send({ type: 'read.remove', activityId }))
87
+ return;
88
+ removeOnConnect?.();
89
+ removeOnConnect = null;
90
+ if (removalExpiry !== null)
91
+ clearTimeout(removalExpiry);
92
+ removalExpiry = null;
93
+ finish();
94
+ };
95
+ if (transport.isConnected()) {
96
+ remove();
97
+ return;
98
+ }
99
+ // If the component leaves offline, remove on the next reconnect so the
100
+ // resumed logical session cannot briefly show a departed viewer. Once the
101
+ // lease expires server-side, there is nothing left to remove.
102
+ removeOnConnect = transport.subscribe('connected', remove);
103
+ removalExpiry = setTimeout(() => {
104
+ removeOnConnect?.();
105
+ removeOnConnect = null;
106
+ removalExpiry = null;
107
+ finish();
108
+ }, READ_TTL_MS);
109
+ unref(removalExpiry);
110
+ };
111
+ return {
112
+ stop,
113
+ dispose() {
114
+ if (disposed)
115
+ return;
116
+ disposed = true;
117
+ active = false;
118
+ clearResources();
119
+ finish();
120
+ },
121
+ };
122
+ }
@@ -25,6 +25,8 @@ import { type AbloSelector, type ModelClientSelector, type UseAbloHydratedModelR
25
25
  import type { AbloClient as Ablo } from '../client.js';
26
26
  import type { ModelOperations } from '../local/client/createModelOperations.js';
27
27
  import type { Schema, SchemaRecord } from '@abloatai/transaction/schema/schema';
28
+ import { type PresenceModelSelector } from './usePresence.js';
29
+ import type { PresenceSession } from '@abloatai/transaction/presence';
28
30
  /** What a binding returns: the provider and the hook, with `S` fixed. */
29
31
  export interface AbloReactBinding<S extends SchemaRecord> {
30
32
  /** `AbloProvider` with its `client` prop typed `Ablo<S>` — same component,
@@ -40,6 +42,8 @@ export interface AbloReactBinding<S extends SchemaRecord> {
40
42
  }): UseAbloHydratedModelResult<T>;
41
43
  <T, C>(modelClientOrSelect: ModelOperations<T, C> | ModelClientSelector<S, T, C>, id: string, options?: UseAbloModelOptions<T>): UseAbloModelResult<T>;
42
44
  };
45
+ /** Declare and reactively read record presence with the same model clients. */
46
+ usePresence: <T, C>(modelOrSelect: ModelOperations<T, C> | PresenceModelSelector<S, T, C>, recordId: string) => readonly PresenceSession[];
43
47
  }
44
48
  /**
45
49
  * Bind the react surface to one schema. The schema value is taken for