@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
@@ -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
  */
@@ -5,10 +5,8 @@
5
5
  * everyone else's, and watch the wait queue when a claim is contended.
6
6
  *
7
7
  * The stream is built directly on the sync WebSocket and shares that one
8
- * connection. It learns about other participants' claims from the same
9
- * `presence_update` frames the {@link createPresenceStream} presence stream
10
- * consumes — the server piggybacks each participant's `activeClaims` on every
11
- * presence frame — and sends its own claims as `claim_begin` and
8
+ * connection. It learns about other sessions' claims from the normalized
9
+ * presence projection and sends its own claims as `claim_begin` and
12
10
  * `claim_abandon` frames.
13
11
  *
14
12
  * Wire frames:
@@ -16,12 +14,12 @@
16
14
  * entityId, description, field?, estimatedMs? }`.
17
15
  * • Outbound `claim_abandon` — release it: `{ claimId, entityType?,
18
16
  * entityId? }`.
19
- * • Inbound, via presence — `event.activeClaims`, each stamped with
20
- * `declaredAt` and `expiresAt`.
17
+ * • Inbound, via presence — authoritative `claim` activities.
21
18
  * • Inbound `claim_rejected` — the server refused the claim, with conflict
22
19
  * metadata.
23
20
  */
24
21
  import type { WsTransport } from '@abloatai/transaction/transport/websocket';
22
+ import type { PresenceSession } from '@abloatai/transaction/presence';
25
23
  import type { ClaimOptions, Claim, ClaimStream, PresenceTarget } from '@abloatai/transaction/types/streams';
26
24
  import { type Logger } from '@abloatai/transaction/logger';
27
25
  /**
@@ -34,11 +32,13 @@ import { type Logger } from '@abloatai/transaction/logger';
34
32
  */
35
33
  export type ClaimTransport = Pick<WsTransport, 'subscribe' | 'isConnected' | 'send'>;
36
34
  export interface ClaimStreamConfig {
37
- /** Identity used to filter our own active claims out of `others`. */
38
- participantId: string;
39
35
  /** Where the coordination trace is logged. Defaults to silent. */
40
36
  logger?: Logger;
41
37
  }
38
+ export interface ClaimPresenceSource {
39
+ readonly others: readonly PresenceSession[];
40
+ onChange(listener: () => void): () => void;
41
+ }
42
42
  export interface AttachableClaimStream extends ClaimStream {
43
43
  /**
44
44
  * Mints the local handle and sends its `claim_begin` frame. The handle is a
@@ -54,16 +54,6 @@ export interface AttachableClaimStream extends ClaimStream {
54
54
  */
55
55
  claim(target: PresenceTarget, opts?: ClaimOptions, claimId?: string): Claim;
56
56
  attach(transport: ClaimTransport): void;
57
- /**
58
- * Seeds the participant identity once the host resolves it. The stream can
59
- * be built before identity is known — a hosted client learns who it is
60
- * from its credential's scope during connect — and until then the
61
- * construction-time id (possibly empty) would let the participant's own
62
- * claims into `others`. Idempotent; later frames filter on the new id.
63
- */
64
- setParticipant(participant: {
65
- id: string;
66
- }): void;
67
57
  dispose(): void;
68
58
  }
69
- export declare function createClaimStream(config: ClaimStreamConfig, transport?: ClaimTransport | null): AttachableClaimStream;
59
+ export declare function createClaimStream(config: ClaimStreamConfig, transport?: ClaimTransport | null, presence?: ClaimPresenceSource | null): AttachableClaimStream;
@@ -5,10 +5,8 @@
5
5
  * everyone else's, and watch the wait queue when a claim is contended.
6
6
  *
7
7
  * The stream is built directly on the sync WebSocket and shares that one
8
- * connection. It learns about other participants' claims from the same
9
- * `presence_update` frames the {@link createPresenceStream} presence stream
10
- * consumes — the server piggybacks each participant's `activeClaims` on every
11
- * presence frame — and sends its own claims as `claim_begin` and
8
+ * connection. It learns about other sessions' claims from the normalized
9
+ * presence projection and sends its own claims as `claim_begin` and
12
10
  * `claim_abandon` frames.
13
11
  *
14
12
  * Wire frames:
@@ -16,8 +14,7 @@
16
14
  * entityId, description, field?, estimatedMs? }`.
17
15
  * • Outbound `claim_abandon` — release it: `{ claimId, entityType?,
18
16
  * entityId? }`.
19
- * • Inbound, via presence — `event.activeClaims`, each stamped with
20
- * `declaredAt` and `expiresAt`.
17
+ * • Inbound, via presence — authoritative `claim` activities.
21
18
  * • Inbound `claim_rejected` — the server refused the claim, with conflict
22
19
  * metadata.
23
20
  */
@@ -40,10 +37,7 @@ function claimLabel(type, id, field) {
40
37
  * above a round trip, comfortably below the ttl/3 beat cadence.
41
38
  */
42
39
  const HEARTBEAT_ACK_TIMEOUT_MS = 10_000;
43
- export function createClaimStream(config, transport = null) {
44
- // Mutable: the host seeds the resolved identity via `setParticipant` once
45
- // it is known; the own-claim filter always reads the current value.
46
- let participantId = config.participantId;
40
+ export function createClaimStream(config, transport = null, presence = null) {
47
41
  const logger = config.logger ?? noopLogger;
48
42
  // ── State: others' open claims, keyed by claimId ───────────────
49
43
  const activeByClaimId = new Map();
@@ -113,48 +107,7 @@ export function createClaimStream(config, transport = null) {
113
107
  if (attached)
114
108
  return;
115
109
  attached = t;
116
- // (1) Inbound presence frames carry every participant's full
117
- // active-claim set. Prune previous claims by holder, then
118
- // re-add from the frame — the frame is authoritative for that
119
- // participant's open claims at that moment.
120
- unsubs.push(t.subscribe('presence_update', (event) => {
121
- if (!event.userId)
122
- return;
123
- if (event.userId === participantId)
124
- return;
125
- let mutated = false;
126
- if (event.kind === 'leave') {
127
- for (const [id, claim] of activeByClaimId) {
128
- if (claim.heldBy === event.userId) {
129
- activeByClaimId.delete(id);
130
- mutated = true;
131
- }
132
- }
133
- if (mutated)
134
- notifyListeners();
135
- return;
136
- }
137
- for (const [id, claim] of activeByClaimId) {
138
- if (claim.heldBy === event.userId) {
139
- activeByClaimId.delete(id);
140
- mutated = true;
141
- }
142
- }
143
- for (const claim of event.activeClaims ?? []) {
144
- // Terminal-status entries (committed / expired / canceled) are
145
- // one-shot "this claim ended" signals. The holder sweep above
146
- // already removed the prior active entry; skipping the re-add
147
- // drops it from `others`, which is what resolves a contender's
148
- // `settled()`. Absent status means active (wire back-compat).
149
- if (claim.status && claim.status !== 'active')
150
- continue;
151
- observeForeignClaim(event.userId, claim, event.participantKind, event.isAgent);
152
- mutated = true;
153
- }
154
- if (mutated)
155
- notifyListeners();
156
- }));
157
- // (2) Server-side rejection frames.
110
+ // Server-side rejection frames.
158
111
  unsubs.push(t.subscribe('claim_rejected', (rejection) => {
159
112
  if (!rejection.claimId)
160
113
  return;
@@ -290,6 +243,41 @@ export function createClaimStream(config, transport = null) {
290
243
  }
291
244
  if (transport)
292
245
  attach(transport);
246
+ const refreshPresenceClaims = () => {
247
+ if (presence === null)
248
+ return;
249
+ activeByClaimId.clear();
250
+ for (const session of presence.others) {
251
+ for (const activity of session.activities) {
252
+ if (activity.operation !== 'claim'
253
+ || activity.source !== 'claim'
254
+ || activity.target.id === undefined)
255
+ continue;
256
+ const claimId = activity.id.startsWith('claim:')
257
+ ? activity.id.slice('claim:'.length)
258
+ : activity.id;
259
+ observeForeignClaim(session.participant.id, {
260
+ claimId,
261
+ entityType: activity.target.model,
262
+ entityId: activity.target.id,
263
+ ...(activity.target.field !== undefined
264
+ ? { field: activity.target.field }
265
+ : {}),
266
+ ...(activity.target.fields !== undefined
267
+ ? { fields: activity.target.fields }
268
+ : {}),
269
+ description: 'claim',
270
+ declaredAt: Date.parse(activity.startedAt),
271
+ expiresAt: Date.parse(activity.expiresAt),
272
+ }, session.participant.kind);
273
+ }
274
+ }
275
+ notifyListeners();
276
+ };
277
+ if (presence !== null) {
278
+ refreshPresenceClaims();
279
+ unsubs.push(presence.onChange(refreshPresenceClaims));
280
+ }
293
281
  // ── Outbound ────────────────────────────────────────────────────
294
282
  function sendBegin(claimId, claim) {
295
283
  if (!attached?.isConnected())
@@ -475,9 +463,6 @@ export function createClaimStream(config, transport = null) {
475
463
  }, () => claimsSnapshot);
476
464
  },
477
465
  attach,
478
- setParticipant(participant) {
479
- participantId = participant.id;
480
- },
481
466
  dispose() {
482
467
  for (const off of unsubs)
483
468
  off();
@@ -5,7 +5,7 @@ import type { InstanceCache } from '../InstanceCache.js';
5
5
  import type { ConnectionManager } from './ConnectionManager.js';
6
6
  import type { SubscriptionManager } from './SubscriptionManager.js';
7
7
  import type { SyncStatus } from '../storeContract.js';
8
- import type { BootstrapHint, BootstrapDataEvent, PresenceUpdate, SyncWebSocket, EventMap } from './SyncWebSocket.js';
8
+ import type { BootstrapHint, BootstrapDataEvent, SyncWebSocket, EventMap } from './SyncWebSocket.js';
9
9
  import type { SyncDelta } from './SyncWebSocket.js';
10
10
  export interface SocketEventHost<TCollaboration extends EventMap<TCollaboration>> {
11
11
  syncWebSocket: SyncWebSocket<TCollaboration>;
@@ -23,7 +23,6 @@ export interface SocketEventHost<TCollaboration extends EventMap<TCollaboration>
23
23
  applyDeltaFrame(deltas: SyncDelta[]): void;
24
24
  handleBootstrapRequired(hint: BootstrapHint): void;
25
25
  handleBootstrapData(data: BootstrapDataEvent): void;
26
- handlePresenceUpdate(data: PresenceUpdate): void;
27
26
  performCredentialRefresh(): Promise<'refreshed' | 'session_error' | 'network_error'>;
28
27
  handleTerminalSessionError(error: Error): void;
29
28
  nudgeReconnect(): void;
@@ -43,10 +43,6 @@ export function wireSocketEvents(deps) {
43
43
  const data = args[0];
44
44
  deps.handleBootstrapData(data);
45
45
  });
46
- const onPresenceUpdate = deps.syncWebSocket.subscribe('presence_update', (...args) => {
47
- const data = args[0];
48
- deps.handlePresenceUpdate(data);
49
- });
50
46
  // Error events
51
47
  const onError = deps.syncWebSocket.subscribe('error', (error) => {
52
48
  if (error.message === 'Network is offline' || error.message === 'WebSocket connection failed') {
@@ -126,5 +122,5 @@ export function wireSocketEvents(deps) {
126
122
  deps.runtime.logger.debug('[BaseSyncedStore] WebSocket reconnection gave up', { attempts });
127
123
  deps.updateSyncStatus({ state: 'reconnecting' });
128
124
  });
129
- deps.disposers.push(onConnected, onDisconnected, onReconnecting, onDelta, onDeltaBatch, onBootstrapRequired, onBootstrapData, onPresenceUpdate, onError, onSessionError, onHandshakeFailed, onReconnectFailed, () => { deps.areaOfInterest.dispose(); });
125
+ deps.disposers.push(onConnected, onDisconnected, onReconnecting, onDelta, onDeltaBatch, onBootstrapRequired, onBootstrapData, onError, onSessionError, onHandshakeFailed, onReconnectFailed, () => { deps.areaOfInterest.dispose(); });
130
126
  }
@@ -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
@@ -476,7 +479,7 @@ export declare class MutationQueue extends EventEmitter {
476
479
  awaitingDeltaCount: number;
477
480
  awaitingDeltaTransactions: {
478
481
  id: string;
479
- type: "update" | "create" | "delete" | "archive" | "unarchive";
482
+ type: "create" | "update" | "delete" | "archive" | "unarchive";
480
483
  modelName: string;
481
484
  modelId: string;
482
485
  syncIdNeeded: number | undefined;
@@ -485,13 +488,13 @@ export declare class MutationQueue extends EventEmitter {
485
488
  }[];
486
489
  pendingTransactions: {
487
490
  id: string;
488
- type: "update" | "create" | "delete" | "archive" | "unarchive";
491
+ type: "create" | "update" | "delete" | "archive" | "unarchive";
489
492
  modelName: string;
490
493
  modelId: string;
491
494
  }[];
492
495
  executingTransactions: {
493
496
  id: string;
494
- type: "update" | "create" | "delete" | "archive" | "unarchive";
497
+ type: "create" | "update" | "delete" | "archive" | "unarchive";
495
498
  modelName: string;
496
499
  modelId: string;
497
500
  }[];
@@ -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')
@@ -27,8 +27,8 @@ import type { RuntimeContext } from '../../RuntimeContext.js';
27
27
  export declare const persistedTransactionSchema: z.ZodObject<{
28
28
  id: z.ZodString;
29
29
  type: z.ZodEnum<{
30
- update: "update";
31
30
  create: "create";
31
+ update: "update";
32
32
  delete: "delete";
33
33
  archive: "archive";
34
34
  unarchive: "unarchive";
@@ -95,8 +95,8 @@ export declare function deserializePersistedTransaction(row: unknown, runtime?:
95
95
  export declare const persistedMutationSchema: z.ZodObject<{
96
96
  mutationId: z.ZodOptional<z.ZodString>;
97
97
  type: z.ZodEnum<{
98
- update: "update";
99
98
  create: "create";
99
+ update: "update";
100
100
  delete: "delete";
101
101
  archive: "archive";
102
102
  }>;
@@ -138,8 +138,8 @@ export declare const legacyPendingMutationRecordSchema: z.ZodObject<{
138
138
  type: z.ZodLiteral<"pending_mutation">;
139
139
  mutation: z.ZodObject<{
140
140
  type: z.ZodEnum<{
141
- update: "update";
142
141
  create: "create";
142
+ update: "update";
143
143
  delete: "delete";
144
144
  archive: "archive";
145
145
  }>;
@@ -180,8 +180,8 @@ export declare const pendingMutationRecordSchema: z.ZodObject<{
180
180
  type: z.ZodLiteral<"pending_mutation">;
181
181
  mutation: z.ZodObject<{
182
182
  type: z.ZodEnum<{
183
- update: "update";
184
183
  create: "create";
184
+ update: "update";
185
185
  delete: "delete";
186
186
  archive: "archive";
187
187
  }>;