@abloatai/humans 0.62.0 → 0.63.1

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 (47) 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/AbloProvider.d.ts +2 -18
  17. package/dist/react/AbloProvider.js +11 -27
  18. package/dist/react/createAbloReact.d.ts +4 -0
  19. package/dist/react/createAbloReact.js +12 -2
  20. package/dist/react/useAblo.d.ts +3 -1
  21. package/dist/react/useAblo.js +13 -11
  22. package/dist/react/usePresence.d.ts +19 -0
  23. package/dist/react/usePresence.js +28 -0
  24. package/dist/react/useSyncStatus.js +14 -3
  25. package/dist/react.d.ts +1 -0
  26. package/dist/react.js +1 -0
  27. package/dist/surface.d.ts +1 -1
  28. package/dist/surface.js +1 -0
  29. package/dist/useReactive.js +1 -3
  30. package/package.json +2 -2
  31. package/src/index.ts +1 -0
  32. package/src/local/InstanceCache.ts +1 -1
  33. package/src/local/client/createModelOperations.ts +132 -0
  34. package/src/local/client/reactiveEngine.ts +16 -0
  35. package/src/local/transactions/mutations/MutationQueue.ts +26 -2
  36. package/src/local/transactions/mutations/deltaConfirmation.ts +3 -0
  37. package/src/local/transactions/mutations/failureHandling.ts +2 -1
  38. package/src/presence/index.ts +32 -3
  39. package/src/presence/readActivity.ts +149 -0
  40. package/src/react/AbloProvider.tsx +14 -54
  41. package/src/react/createAbloReact.ts +25 -1
  42. package/src/react/useAblo.ts +18 -13
  43. package/src/react/usePresence.ts +88 -0
  44. package/src/react/useSyncStatus.ts +14 -3
  45. package/src/react.ts +4 -0
  46. package/src/surface.ts +1 -0
  47. package/src/useReactive.ts +1 -3
@@ -246,6 +246,8 @@ export class MutationQueue extends EventEmitter {
246
246
  private isProcessing = false;
247
247
  private processTimer?: NodeJS.Timeout;
248
248
  private processScheduled = false;
249
+ private disposed = false;
250
+ private retryTimers = new Set<ReturnType<typeof setTimeout>>();
249
251
 
250
252
  // Staging area for transactions created in the same event-loop tick. Each one
251
253
  // lands here first, then a microtask commits them together.
@@ -331,10 +333,11 @@ export class MutationQueue extends EventEmitter {
331
333
  isDefinitiveRejection: (error) => this.isDefinitiveRejection(error),
332
334
  isPermanentError: (error) => this.isPermanentError(error),
333
335
  scheduleRetry: (delayMs) => {
336
+ if (this.disposed) return;
334
337
  if (this.commitRetryTimer !== null) clearTimeout(this.commitRetryTimer);
335
338
  this.commitRetryTimer = setTimeout(() => {
336
339
  this.commitRetryTimer = null;
337
- void this.processCommitLane();
340
+ if (!this.disposed) void this.processCommitLane();
338
341
  }, delayMs);
339
342
  },
340
343
  emitCommitLifecycle: (event, payload) => { this.emitCommitLifecycle(event, payload); },
@@ -463,6 +466,7 @@ export class MutationQueue extends EventEmitter {
463
466
  isPermanentError: (error) => this.isPermanentError(error),
464
467
  rollbackOptimistic: (transaction, reason, error) => this.rollbackOptimistic(transaction, reason, error),
465
468
  enqueue: (transaction) => { this.enqueue(transaction); },
469
+ scheduleRetry: (callback, delayMs) => { this.scheduleRetry(callback, delayMs); },
466
470
  getLastPermanentErrorSignature: () => this.lastPermanentErrorSig,
467
471
  setLastPermanentErrorSignature: (signature) => { this.lastPermanentErrorSig = signature; },
468
472
  emit: (event, payload) => this.emit(event, payload),
@@ -968,6 +972,7 @@ export class MutationQueue extends EventEmitter {
968
972
  clientTxId = transactionId,
969
973
  correlationId?: string,
970
974
  ): void {
975
+ if (this.disposed) return;
971
976
  const previous = this.replicationLagTimeouts.get(transactionId);
972
977
  if (previous) clearTimeout(previous);
973
978
  this.replicationLagErrors.delete(transactionId);
@@ -1182,6 +1187,7 @@ export class MutationQueue extends EventEmitter {
1182
1187
  * {@link drainPending} resumes the work when the owner decides to drain.
1183
1188
  */
1184
1189
  setConnectionState(state: 'connected' | 'disconnected'): void {
1190
+ if (this.disposed) return;
1185
1191
  if (state === 'connected') {
1186
1192
  if (this.commitOfflineGraceTimer !== null) {
1187
1193
  clearTimeout(this.commitOfflineGraceTimer);
@@ -1400,13 +1406,15 @@ export class MutationQueue extends EventEmitter {
1400
1406
  }
1401
1407
 
1402
1408
  private scheduleProcessing(immediate = false): void {
1409
+ if (this.disposed) return;
1403
1410
  scheduleProcessingExternal(this.processingSchedulerContext, immediate);
1404
1411
  }
1405
1412
 
1406
1413
  private async processBatch(): Promise<void> {
1414
+ if (this.disposed) return;
1407
1415
  if (this.modelProcessingPromise) {
1408
1416
  await this.modelProcessingPromise;
1409
- if (this.executionQueue.length > 0) await this.processBatch();
1417
+ if (!this.disposed && this.executionQueue.length > 0) await this.processBatch();
1410
1418
  return;
1411
1419
  }
1412
1420
  const processing = processBatch(this.batchProcessingContext);
@@ -1500,9 +1508,19 @@ export class MutationQueue extends EventEmitter {
1500
1508
  // Schedule the retry-and-reconciliation wait for a transaction's confirming
1501
1509
  // delta; see {@link DeltaConfirmationTracker} in `./deltaConfirmation.js`.
1502
1510
  private scheduleDeltaConfirmationTimeout(tx: QueuedMutation, timeoutMs: number): void {
1511
+ if (this.disposed) return;
1503
1512
  this.deltaConfirmation.scheduleDeltaConfirmationTimeout(tx, timeoutMs);
1504
1513
  }
1505
1514
 
1515
+ private scheduleRetry(callback: () => void, delayMs: number): void {
1516
+ if (this.disposed) return;
1517
+ const timer = setTimeout(() => {
1518
+ this.retryTimers.delete(timer);
1519
+ if (!this.disposed) callback();
1520
+ }, delayMs);
1521
+ this.retryTimers.add(timer);
1522
+ }
1523
+
1506
1524
  /**
1507
1525
  * Resolves once the given transaction is confirmed and rejects if it fails.
1508
1526
  * The confirming delta's timeout is handled by
@@ -1566,6 +1584,7 @@ export class MutationQueue extends EventEmitter {
1566
1584
  }
1567
1585
 
1568
1586
  private async processCommitLane(): Promise<void> {
1587
+ if (this.disposed) return;
1569
1588
  await processCommitLane(this.commitLaneContext);
1570
1589
  }
1571
1590
 
@@ -1854,6 +1873,9 @@ export class MutationQueue extends EventEmitter {
1854
1873
  * clears all timers and stored transactions, and removes event listeners.
1855
1874
  */
1856
1875
  dispose(): void {
1876
+ if (this.disposed) return;
1877
+ this.disposed = true;
1878
+
1857
1879
  // Cancel all active optimistic updates
1858
1880
  for (const [, optimistic] of this.localMutationPort.updates) {
1859
1881
  this.emit('optimistic:rollback', {
@@ -1888,6 +1910,8 @@ export class MutationQueue extends EventEmitter {
1888
1910
  clearTimeout(this.commitRetryTimer);
1889
1911
  this.commitRetryTimer = null;
1890
1912
  }
1913
+ for (const timer of this.retryTimers) clearTimeout(timer);
1914
+ this.retryTimers.clear();
1891
1915
 
1892
1916
  // Clear store
1893
1917
  this.store.clear();
@@ -49,6 +49,7 @@ export class DeltaConfirmationTracker {
49
49
 
50
50
  // Track retry attempts per transaction for exponential backoff
51
51
  private deltaConfirmationRetries = new Map<string, number>();
52
+ private disposed = false;
52
53
 
53
54
  private readonly runtime: RuntimeContext;
54
55
 
@@ -162,6 +163,7 @@ export class DeltaConfirmationTracker {
162
163
  // server has already confirmed. A rollback happens only on an explicit server
163
164
  // rejection, never on a timeout.
164
165
  scheduleDeltaConfirmationTimeout(tx: QueuedMutation, timeoutMs: number): void {
166
+ if (this.disposed) return;
165
167
  // Cancel any existing timeout for this transaction
166
168
  this.cancelDeltaConfirmationTimeout(tx.id);
167
169
 
@@ -289,6 +291,7 @@ export class DeltaConfirmationTracker {
289
291
  * would keep the process alive and fire callbacks against a cleared store.
290
292
  */
291
293
  dispose(): void {
294
+ this.disposed = true;
292
295
  for (const timeoutHandle of this.deltaConfirmationTimeouts.values()) {
293
296
  clearTimeout(timeoutHandle);
294
297
  }
@@ -19,6 +19,7 @@ export interface FailureHandlingContext {
19
19
  error?: Error,
20
20
  ) => Promise<void>;
21
21
  readonly enqueue: (transaction: QueuedMutation) => void;
22
+ readonly scheduleRetry: (callback: () => void, delayMs: number) => void;
22
23
  readonly getLastPermanentErrorSignature: () => string | undefined;
23
24
  readonly setLastPermanentErrorSignature: (signature: string) => void;
24
25
  readonly emit: (event: string, payload: object) => boolean;
@@ -102,7 +103,7 @@ export async function handleFailure(
102
103
  );
103
104
 
104
105
  ctx.store.updateStatus(transaction.id, 'pending');
105
- setTimeout(() => {
106
+ ctx.scheduleRetry(() => {
106
107
  // The queue may have shut down or the tx may have been settled
107
108
  // (e.g. delta-confirmed) while we backed off.
108
109
  if (ctx.store.get(transaction.id)?.status !== 'pending') return;
@@ -4,6 +4,14 @@ import {
4
4
  type PresenceProjectionEvents,
5
5
  type PresenceView,
6
6
  } from '@abloatai/transaction/presence';
7
+ import type { PresenceTarget } from '@abloatai/transaction/presence';
8
+ import {
9
+ startReadActivity,
10
+ type ReadActivityLifetime,
11
+ type ReadActivityTransport,
12
+ } from './readActivity.js';
13
+
14
+ type PresenceTransport = PresenceProjectionEvents & ReadActivityTransport;
7
15
 
8
16
  /** Reactive-client presence backed by the client's existing live connection. */
9
17
  export interface ReactivePresence extends PresenceView {
@@ -13,7 +21,8 @@ export interface ReactivePresence extends PresenceView {
13
21
 
14
22
  /** Lifecycle hooks kept inside the humans composition boundary. */
15
23
  export interface AttachablePresence extends ReactivePresence {
16
- attach(transport: PresenceProjectionEvents): void;
24
+ attach(transport: PresenceTransport): void;
25
+ startRead(target: PresenceTarget): () => void;
17
26
  dispose(): void;
18
27
  }
19
28
 
@@ -31,18 +40,21 @@ export function presenceOfClient(client: object): ReactivePresence {
31
40
  }
32
41
 
33
42
  export function createPresence(
34
- transport: PresenceProjectionEvents | null = null,
43
+ transport: PresenceTransport | null = null,
35
44
  ): AttachablePresence {
36
45
  let projection: PresenceProjection | null = null;
46
+ let attachedTransport: PresenceTransport | null = null;
37
47
  const listeners = new Set<() => void>();
48
+ const reads = new Set<ReadActivityLifetime>();
38
49
  let unsubscribe: (() => void) | null = null;
39
50
 
40
51
  const notify = (): void => {
41
52
  for (const listener of listeners) listener();
42
53
  };
43
54
 
44
- const attach = (events: PresenceProjectionEvents): void => {
55
+ const attach = (events: PresenceTransport): void => {
45
56
  if (projection !== null) return;
57
+ attachedTransport = events;
46
58
  projection = createPresenceProjection(events);
47
59
  unsubscribe = projection.subscribe(notify);
48
60
  notify();
@@ -58,14 +70,31 @@ export function createPresence(
58
70
  return () => { listeners.delete(listener); };
59
71
  },
60
72
  attach,
73
+ startRead(target) {
74
+ if (attachedTransport === null) {
75
+ throw new Error('presence is not attached to a duplex transport');
76
+ }
77
+ const lifetime = startReadActivity(
78
+ attachedTransport,
79
+ target,
80
+ () => { reads.delete(lifetime); },
81
+ );
82
+ reads.add(lifetime);
83
+ return () => {
84
+ lifetime.stop();
85
+ };
86
+ },
61
87
  forModel(model, recordId) {
62
88
  return projection?.forModel(model, recordId) ?? [];
63
89
  },
64
90
  dispose() {
91
+ for (const read of reads) read.dispose();
92
+ reads.clear();
65
93
  unsubscribe?.();
66
94
  unsubscribe = null;
67
95
  projection?.dispose();
68
96
  projection = null;
97
+ attachedTransport = null;
69
98
  listeners.clear();
70
99
  },
71
100
  };
@@ -0,0 +1,149 @@
1
+ import type {
2
+ PresenceCommand,
3
+ PresenceTarget,
4
+ } from '@abloatai/transaction/presence';
5
+ import { LEASE_TTL_MS } from '@abloatai/transaction/wire';
6
+
7
+ /** The connection slice needed to keep one session-owned read alive. */
8
+ export interface ReadActivityTransport {
9
+ isConnected(): boolean;
10
+ sendPresenceCommand(command: PresenceCommand): void;
11
+ subscribe(event: 'connected', listener: () => void): () => void;
12
+ }
13
+
14
+ export interface ReadActivityLifetime {
15
+ /** End the read and remove it from the server, reconnecting briefly if needed. */
16
+ stop(): void;
17
+ /** Tear down local resources when the owning client itself is disposed. */
18
+ dispose(): void;
19
+ }
20
+
21
+ const READ_TTL_MS = LEASE_TTL_MS;
22
+ const READ_REFRESH_MS = READ_TTL_MS / 3;
23
+ let fallbackActivitySequence = 0;
24
+ const noop = (): void => undefined;
25
+
26
+ function readActivityId(): string {
27
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
28
+ return `read:${crypto.randomUUID()}`;
29
+ }
30
+ fallbackActivitySequence += 1;
31
+ return `read:${Date.now().toString(36)}:${fallbackActivitySequence.toString(36)}`;
32
+ }
33
+
34
+ function unref(timer: ReturnType<typeof setTimeout>): void {
35
+ const candidate: unknown = timer;
36
+ if (
37
+ typeof candidate === 'object' &&
38
+ candidate !== null &&
39
+ 'unref' in candidate &&
40
+ typeof candidate.unref === 'function'
41
+ ) {
42
+ (candidate as { unref(): void }).unref();
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Own the lease mechanics for one declared read. React only starts and stops
48
+ * this lifetime; command ids, refreshes, reconnect recovery, and offline
49
+ * cleanup remain inside the presence subsystem.
50
+ */
51
+ export function startReadActivity(
52
+ transport: ReadActivityTransport,
53
+ target: PresenceTarget,
54
+ onFinished: () => void = noop,
55
+ ): ReadActivityLifetime {
56
+ const activityId = readActivityId();
57
+ let active = true;
58
+ let disposed = false;
59
+ let removeOnConnect: (() => void) | null = null;
60
+ let removalExpiry: ReturnType<typeof setTimeout> | null = null;
61
+ let finished = false;
62
+
63
+ const finish = (): void => {
64
+ if (finished) return;
65
+ finished = true;
66
+ onFinished();
67
+ };
68
+
69
+ const send = (command: PresenceCommand): boolean => {
70
+ if (!transport.isConnected()) return false;
71
+ try {
72
+ transport.sendPresenceCommand(command);
73
+ return true;
74
+ } catch (error) {
75
+ // The socket can close between the state check and the synchronous send.
76
+ // Reconnect handling retries; a failure while still connected is real.
77
+ if (transport.isConnected()) throw error;
78
+ return false;
79
+ }
80
+ };
81
+
82
+ const upsert = (): void => {
83
+ if (!active || disposed) return;
84
+ send({ type: 'read.upsert', activityId, target, ttlMs: READ_TTL_MS });
85
+ };
86
+
87
+ const unsubscribeConnected = transport.subscribe('connected', upsert);
88
+ upsert();
89
+
90
+ const refreshTimer = setInterval(() => {
91
+ if (!active || disposed) return;
92
+ send({ type: 'read.refresh', activityId, ttlMs: READ_TTL_MS });
93
+ }, READ_REFRESH_MS);
94
+ unref(refreshTimer);
95
+
96
+ const clearResources = (): void => {
97
+ unsubscribeConnected();
98
+ clearInterval(refreshTimer);
99
+ removeOnConnect?.();
100
+ removeOnConnect = null;
101
+ if (removalExpiry !== null) clearTimeout(removalExpiry);
102
+ removalExpiry = null;
103
+ };
104
+
105
+ const stop = (): void => {
106
+ if (!active || disposed) return;
107
+ active = false;
108
+ unsubscribeConnected();
109
+ clearInterval(refreshTimer);
110
+
111
+ const remove = (): void => {
112
+ if (disposed) return;
113
+ if (!send({ type: 'read.remove', activityId })) return;
114
+ removeOnConnect?.();
115
+ removeOnConnect = null;
116
+ if (removalExpiry !== null) clearTimeout(removalExpiry);
117
+ removalExpiry = null;
118
+ finish();
119
+ };
120
+
121
+ if (transport.isConnected()) {
122
+ remove();
123
+ return;
124
+ }
125
+
126
+ // If the component leaves offline, remove on the next reconnect so the
127
+ // resumed logical session cannot briefly show a departed viewer. Once the
128
+ // lease expires server-side, there is nothing left to remove.
129
+ removeOnConnect = transport.subscribe('connected', remove);
130
+ removalExpiry = setTimeout(() => {
131
+ removeOnConnect?.();
132
+ removeOnConnect = null;
133
+ removalExpiry = null;
134
+ finish();
135
+ }, READ_TTL_MS);
136
+ unref(removalExpiry);
137
+ };
138
+
139
+ return {
140
+ stop,
141
+ dispose() {
142
+ if (disposed) return;
143
+ disposed = true;
144
+ active = false;
145
+ clearResources();
146
+ finish();
147
+ },
148
+ };
149
+ }
@@ -22,24 +22,8 @@ import { useSyncStatus } from './useSyncStatus.js';
22
22
  import { DefaultFallback } from './DefaultFallback.js';
23
23
  import { presenceOfClient } from '../presence/index.js';
24
24
 
25
- /**
26
- * Ablo umbrella provider — owns the sync engine, multiplayer, and
27
- * the full lifecycle (Strict-Mode-safe singleton, `beforeunload`,
28
- * session-expiry handling, post-bootstrap hooks).
29
- *
30
- * Design goals:
31
- *
32
- * - **One component, one import.** Consumers write the provider
33
- * once at the root; nothing else needs to plumb the engine.
34
- * - **Multiplayer is default.** React consumers share the client's scoped
35
- * groups, presence stream, and model surface without another join step.
36
- * - **Declarative props for app glue.** `preventUnsavedChanges`,
37
- * `onSessionExpired`, `postBootstrap`, `resolveUsers` — each
38
- * absorbs a class of integration code that previously lived in
39
- * userland.
40
- * - **Singleton safety.** The engine lives in a ref and rotates
41
- * only when `userId` / account scope / `url` change. React
42
- * Strict Mode double-mount does not leak a second WebSocket.
25
+ /** Reactive binding over an application-owned client. Starts readiness,
26
+ * forwards errors and gates bootstrap; the application owns client disposal.
43
27
  */
44
28
 
45
29
  // ── Props ────────────────────────────────────────────────────────────
@@ -182,7 +166,7 @@ export function AbloProvider<R extends SchemaRecord = SchemaRecord>(
182
166
 
183
167
  // Account scope isn't a prop — read it from `_store.orgId` once `ready()`
184
168
  // resolves the identity from the client's auth.
185
- const [resolvedAccountScope, setResolvedAccountScope] = useState<string | null>(null);
169
+ const [resolvedScope, setResolvedScope] = useState<{ engine: typeof engine; account: string | null } | null>(null);
186
170
 
187
171
  // ── Error emitter (provider-instance scoped) ─────────────────────
188
172
  const errorEmitterRef = useRef<ReturnType<typeof createErrorEmitter> | null>(null);
@@ -240,9 +224,10 @@ export function AbloProvider<R extends SchemaRecord = SchemaRecord>(
240
224
  .ready()
241
225
  .then(() => {
242
226
  if (stale) return;
243
- setResolvedAccountScope(
244
- (engine._store as SyncStoreContract & { orgId?: string }).orgId ?? null,
245
- );
227
+ setResolvedScope({
228
+ engine,
229
+ account: (engine._store as SyncStoreContract & { orgId?: string }).orgId ?? null,
230
+ });
246
231
  })
247
232
  .catch((err) => {
248
233
  if (stale) return;
@@ -280,7 +265,7 @@ export function AbloProvider<R extends SchemaRecord = SchemaRecord>(
280
265
  // then, which drives the initial fallback below.
281
266
  const syncValue = useMemo(() => {
282
267
  const currentAccountScope =
283
- resolvedAccountScope ??
268
+ (resolvedScope?.engine === engine ? resolvedScope.account : null) ??
284
269
  (engine._store as SyncStoreContract & { orgId?: string }).orgId;
285
270
  if (!currentAccountScope) return null;
286
271
  return {
@@ -288,7 +273,7 @@ export function AbloProvider<R extends SchemaRecord = SchemaRecord>(
288
273
  organizationId: currentAccountScope,
289
274
  schema,
290
275
  };
291
- }, [engine, resolvedAccountScope, schema]);
276
+ }, [engine, resolvedScope, schema]);
292
277
 
293
278
  // ── Internal context (currentUserId + error subscription) ────────
294
279
 
@@ -301,44 +286,20 @@ export function AbloProvider<R extends SchemaRecord = SchemaRecord>(
301
286
 
302
287
  // ── Render ───────────────────────────────────────────────────────
303
288
  //
304
- // Two-phase gate (see `BootstrapGate` below for the latch logic):
305
- //
306
- // 1. Engine is null on first render (constructed in the effect
307
- // above, not in render). We render `fallback` directly — there
308
- // is no SyncContext to read status from, and by definition the
309
- // engine hasn't started bootstrapping.
310
- // 2. Engine exists. Mount SyncContext. `BootstrapGate` then reads
311
- // `useSyncStatus()` and shows `fallback` only during the very
312
- // first `connecting` transition; children render on every
313
- // subsequent state change, including reconnects and auth
314
- // failures (the app's own UI handles those).
315
- //
316
- // `fallback === 'passthrough'` short-circuits both branches — children
317
- // render immediately without any gate, restoring pre-gate behavior
318
- // for consumers who need debug helpers / error boundaries / analytics
319
- // to mount before the engine is ready.
320
-
289
+ // Keep the context tree stable during startup so passthrough children retain
290
+ // their component state when authenticated row scope becomes available.
321
291
  const passthrough = fallback === 'passthrough';
322
- const initialFallback = passthrough ? children : fallback;
323
-
324
- if (!syncValue) {
325
- return (
326
- <AbloInternalContext.Provider value={internalValue}>
327
- {initialFallback}
328
- </AbloInternalContext.Provider>
329
- );
330
- }
331
292
 
332
293
  return (
333
294
  <AbloInternalContext.Provider value={internalValue}>
334
295
  <SyncContext.Provider value={syncValue}>
335
296
  {passthrough ? (
336
297
  children
337
- ) : (
298
+ ) : syncValue ? (
338
299
  <BootstrapGate key={engineKey} fallback={fallback}>
339
300
  {children}
340
301
  </BootstrapGate>
341
- )}
302
+ ) : fallback}
342
303
  </SyncContext.Provider>
343
304
  </AbloInternalContext.Provider>
344
305
  );
@@ -352,8 +313,7 @@ export function AbloProvider<R extends SchemaRecord = SchemaRecord>(
352
313
  * re-show the fallback, because by then the app has already rendered
353
314
  * once and its own reconnect UI should take over.
354
315
  *
355
- * Re-keyed on `engineState.key` in the parent so engine rotations
356
- * (userId/org/url change) reset the latch — a new engine genuinely IS
316
+ * Re-keyed when the client instance changes so account rotations reset the latch — a new engine genuinely IS
357
317
  * a new "first bootstrap" cycle.
358
318
  */
359
319
  function BootstrapGate({
@@ -29,6 +29,7 @@ import {
29
29
  } from './AbloProvider.js';
30
30
  import {
31
31
  useAbloImpl,
32
+ useAbloClientImpl,
32
33
  type AbloSelector,
33
34
  type ModelClientSelector,
34
35
  type UseAbloHydratedModelResult,
@@ -38,6 +39,11 @@ import {
38
39
  import type { AbloClient as Ablo } from '../client.js';
39
40
  import type { ModelOperations } from '../local/client/createModelOperations.js';
40
41
  import type { Schema, SchemaRecord } from '@abloatai/transaction/schema/schema';
42
+ import {
43
+ usePresenceImpl,
44
+ type PresenceModelSelector,
45
+ } from './usePresence.js';
46
+ import type { PresenceSession } from '@abloatai/transaction/presence';
41
47
 
42
48
  /** What a binding returns: the provider and the hook, with `S` fixed. */
43
49
  export interface AbloReactBinding<S extends SchemaRecord> {
@@ -60,6 +66,11 @@ export interface AbloReactBinding<S extends SchemaRecord> {
60
66
  options?: UseAbloModelOptions<T>,
61
67
  ): UseAbloModelResult<T>;
62
68
  };
69
+ /** Declare and reactively read record presence with the same model clients. */
70
+ usePresence: <T, C>(
71
+ modelOrSelect: ModelOperations<T, C> | PresenceModelSelector<S, T, C>,
72
+ recordId: string,
73
+ ) => readonly PresenceSession[];
63
74
  }
64
75
 
65
76
  /**
@@ -112,5 +123,18 @@ export function createAbloReact<S extends SchemaRecord>(
112
123
  return useAbloImpl<S, T, C>(bound, modelOrSelect, id, options);
113
124
  }
114
125
 
115
- return { AbloProvider: BoundAbloProvider, useAblo: useBoundAblo };
126
+ function useBoundPresence<T, C>(
127
+ modelOrSelect: ModelOperations<T, C> | PresenceModelSelector<S, T, C>,
128
+ recordId: string,
129
+ ): readonly PresenceSession[] {
130
+ const bound = useContext(BoundClientContext);
131
+ const engine = useAbloClientImpl(bound);
132
+ return usePresenceImpl(engine, modelOrSelect, recordId);
133
+ }
134
+
135
+ return {
136
+ AbloProvider: BoundAbloProvider,
137
+ useAblo: useBoundAblo,
138
+ usePresence: useBoundPresence,
139
+ };
116
140
  }
@@ -144,7 +144,7 @@ function snapshotValue<T>(value: T): T {
144
144
  * // are typed as snapshot rows — data fields + computeds, no relation
145
145
  * // accessors — matching what the hook actually returns:
146
146
  * const doc = useAblo((ablo) => ablo.records.local.get(id)) ?? serverDoc;
147
- * const active = useAblo((ablo) => ablo.records.claim.state({ id }));
147
+ * const { claimed } = useAblo((ablo) => ablo.records, id);
148
148
  *
149
149
  * // Without the augmentation, pass the schema as a type argument:
150
150
  * const ablo = useAblo<(typeof schema)['models']>();
@@ -223,12 +223,7 @@ export function useAbloImpl<
223
223
  id?: string,
224
224
  options?: UseAbloModelOptions<T>,
225
225
  ): Ablo<R> | null | UseAbloModelResult<T> | T | undefined {
226
- const ctx = useContext(AbloInternalContext);
227
- // The bound client wins — it is already `Ablo<R>`, no rebinding. The
228
- // fallback is the ONE remaining schema rebind in the SDK; it retires with
229
- // the last legacy provider mount (docs/plans/typed-react-binding.md).
230
- const engine: Ablo<R> | null =
231
- boundClient ?? (ctx?.engine ? rebindEngine<R>(ctx.engine) : null);
226
+ const engine = useAbloClientImpl(boundClient);
232
227
  const initial = options?.initial;
233
228
  const isSelectorOnly = typeof modelOrSelect === 'function' && id === undefined;
234
229
  const modelClient: ModelOperations<T, C> | undefined =
@@ -242,12 +237,9 @@ export function useAbloImpl<
242
237
 
243
238
  // Claims arrive through an event emitter (engine.claims), not through MobX, so
244
239
  // the useReactive reactions below cannot track them; we bridge changes with a
245
- // setState bump instead. Only the model-row form (`id !== undefined`) reads
246
- // claims, so we subscribe only when `id` is set. The selector-only form never
247
- // reads claims, and subscribing it to the workspace-wide claim stream would
248
- // re-render and recompute it on every claim or presence change anywhere — a
249
- // real storm during AI editing or live collaboration — for a value that cannot
250
- // change.
240
+ // setState bump instead. Subscribe the model-row form (`id !== undefined`)
241
+ // to claims. Selector-only reads track MobX model data; callers displaying
242
+ // ownership use the row form's `claims` / `claimed` result.
251
243
  const [claimVersion, setClaimVersion] = useState(0);
252
244
  useEffect(() => {
253
245
  if (!engine || id === undefined) return;
@@ -278,3 +270,16 @@ export function useAbloImpl<
278
270
  if (modelOrSelect) return modelResult;
279
271
  return engine;
280
272
  }
273
+
274
+ /** @internal Resolve the bound or legacy provider client through one rebind seam. */
275
+ export function useAbloClientImpl<R extends SchemaRecord>(
276
+ boundClient: Ablo<R> | null,
277
+ ): Ablo<R> | null {
278
+ const ctx = useContext(AbloInternalContext);
279
+ // The bound client wins — it is already `Ablo<R>`, no rebinding. The
280
+ // fallback is the ONE remaining schema rebind in the SDK; it retires with
281
+ // the last legacy provider mount (docs/plans/typed-react-binding.md).
282
+ const engine: Ablo<R> | null =
283
+ boundClient ?? (ctx?.engine ? rebindEngine<R>(ctx.engine) : null);
284
+ return engine;
285
+ }