@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
@@ -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
  }
@@ -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 =
@@ -278,3 +273,16 @@ export function useAbloImpl<
278
273
  if (modelOrSelect) return modelResult;
279
274
  return engine;
280
275
  }
276
+
277
+ /** @internal Resolve the bound or legacy provider client through one rebind seam. */
278
+ export function useAbloClientImpl<R extends SchemaRecord>(
279
+ boundClient: Ablo<R> | null,
280
+ ): Ablo<R> | null {
281
+ const ctx = useContext(AbloInternalContext);
282
+ // The bound client wins — it is already `Ablo<R>`, no rebinding. The
283
+ // fallback is the ONE remaining schema rebind in the SDK; it retires with
284
+ // the last legacy provider mount (docs/plans/typed-react-binding.md).
285
+ const engine: Ablo<R> | null =
286
+ boundClient ?? (ctx?.engine ? rebindEngine<R>(ctx.engine) : null);
287
+ return engine;
288
+ }
@@ -0,0 +1,88 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useState } from 'react';
4
+ import type { PresenceSession } from '@abloatai/transaction/presence';
5
+ import { AbloValidationError } from '@abloatai/transaction/errors';
6
+ import {
7
+ getModelClientMeta,
8
+ type ModelOperations,
9
+ } from '../local/client/createModelOperations.js';
10
+ import type { AbloClient as Ablo } from '../client.js';
11
+ import type { SchemaRecord } from '@abloatai/transaction/schema/schema';
12
+ import type { ResolveSchema } from '@abloatai/transaction/types/global';
13
+ import { useAbloClientImpl } from './useAblo.js';
14
+
15
+ type DefaultModels = ResolveSchema extends { models: infer M }
16
+ ? M extends SchemaRecord
17
+ ? M
18
+ : SchemaRecord
19
+ : SchemaRecord;
20
+
21
+ export type PresenceModelSelector<R extends SchemaRecord, T, C> =
22
+ (ablo: Ablo<R>) => ModelOperations<T, C>;
23
+
24
+ /**
25
+ * Declare that this component is reading one row and return every live session
26
+ * reading or otherwise acting on that row. Ablo owns the lease, refresh,
27
+ * reconnect, and cleanup mechanics for the component's lifetime.
28
+ */
29
+ export function usePresence<T, C>(
30
+ modelClient: ModelOperations<T, C>,
31
+ recordId: string,
32
+ ): readonly PresenceSession[];
33
+ export function usePresence<
34
+ R extends SchemaRecord = DefaultModels,
35
+ T = Record<string, unknown>,
36
+ C = unknown,
37
+ >(
38
+ select: PresenceModelSelector<R, T, C>,
39
+ recordId: string,
40
+ ): readonly PresenceSession[];
41
+ export function usePresence<
42
+ R extends SchemaRecord = DefaultModels,
43
+ T = Record<string, unknown>,
44
+ C = unknown,
45
+ >(
46
+ modelOrSelect: ModelOperations<T, C> | PresenceModelSelector<R, T, C>,
47
+ recordId: string,
48
+ ): readonly PresenceSession[] {
49
+ const engine = useAbloClientImpl<R>(null);
50
+ return usePresenceImpl(engine, modelOrSelect, recordId);
51
+ }
52
+
53
+ /** @internal Shared by the global hook and schema-bound React factory. */
54
+ export function usePresenceImpl<R extends SchemaRecord, T, C>(
55
+ engine: Ablo<R> | null,
56
+ modelOrSelect: ModelOperations<T, C> | PresenceModelSelector<R, T, C>,
57
+ recordId: string,
58
+ ): readonly PresenceSession[] {
59
+ if (recordId.length === 0) {
60
+ throw new AbloValidationError(
61
+ 'usePresence requires a non-empty record id.',
62
+ { code: 'invalid_request', param: 'recordId' },
63
+ );
64
+ }
65
+ const modelClient = typeof modelOrSelect === 'function'
66
+ ? engine
67
+ ? modelOrSelect(engine)
68
+ : null
69
+ : modelOrSelect;
70
+ const presence = modelClient ? getModelClientMeta(modelClient)?.presence : undefined;
71
+ if (modelClient !== null && presence === undefined) {
72
+ throw new AbloValidationError(
73
+ 'usePresence requires a model from the reactive Ablo client.',
74
+ { code: 'invalid_request', param: 'modelClient' },
75
+ );
76
+ }
77
+
78
+ const [, render] = useState(0);
79
+
80
+ useEffect(
81
+ () => presence?.subscribe(() => { render((version) => version + 1); }),
82
+ [presence],
83
+ );
84
+
85
+ useEffect(() => presence?.read(recordId), [presence, recordId]);
86
+
87
+ return presence?.get(recordId) ?? [];
88
+ }
package/src/react.ts CHANGED
@@ -3,6 +3,10 @@ export { useReactive } from './useReactive.js';
3
3
  export { useCurrentUserId } from './react/useCurrentUserId.js';
4
4
  export { useErrorListener } from './react/useErrorListener.js';
5
5
  export { useSyncStatus, type SyncStatusSnapshot } from './react/useSyncStatus.js';
6
+ export {
7
+ usePresence,
8
+ type PresenceModelSelector,
9
+ } from './react/usePresence.js';
6
10
  export {
7
11
  useMutationFailureListener,
8
12
  type MutationFailurePayload,
package/src/surface.ts CHANGED
@@ -37,6 +37,8 @@ export const PUBLIC_MODEL_VERBS = [
37
37
  'list',
38
38
  'listAll',
39
39
  'local',
40
+ 'presence',
41
+ 'events',
40
42
  'create',
41
43
  'update',
42
44
  'delete',
@@ -1,69 +0,0 @@
1
- /**
2
- * Creates a {@link PresenceStream} over a live sync connection. Presence is the
3
- * lightweight, ephemeral "who's here and what are they doing" view: each
4
- * participant broadcasts a status and an activity, and sees everyone else's.
5
- * The stream is built directly on the sync WebSocket and adds no second
6
- * connection. It is the sibling of {@link createClaimStream}, which reuses the
7
- * same presence frames.
8
- *
9
- * There are two ways to construct it:
10
- *
11
- * 1. Direct — pass an already-open `transport`, for example an agent worker
12
- * or a test.
13
- * 2. Deferred — construct without a transport and call `attach(transport)`
14
- * once the connection is ready. The returned stream object is stable from
15
- * construction, so callers can hold the reference and let attachment
16
- * happen later.
17
- *
18
- * Wire frames:
19
- * • Outbound `presence_update` — `{ status, activity? }`. The server stamps
20
- * `userId`, `kind`, `timestamp`, and `isAgent`, then broadcasts to the
21
- * other participants on the same sync groups.
22
- * • Inbound — the same frame, with `kind` one of `enter`, `update`, or
23
- * `leave`.
24
- */
25
- import type { WsTransport } from '@abloatai/transaction/transport/websocket';
26
- import type { PresenceStream } from '@abloatai/transaction/types/streams';
27
- import type { ParticipantKind } from '@abloatai/transaction/types/participant';
28
- /**
29
- * The wire capability the presence stream actually uses: subscribe to typed
30
- * inbound frames, check liveness, and send outbound frames. The duplex
31
- * `WsTransport` satisfies it — the same port shape the claim stream depends
32
- * on — so the stream can attach to whatever connection the host built,
33
- * without naming the engine's subclass.
34
- */
35
- export type PresenceTransport = Pick<WsTransport, 'subscribe' | 'isConnected' | 'send'>;
36
- export interface PresenceStreamConfig {
37
- /** Identity used to filter our own echoed frames out of `others`. */
38
- participantId: string;
39
- /** Optional human label for the self entry. */
40
- label?: string;
41
- /** Sync groups the participant is broadcasting on. Used for the
42
- * initial `self` entry and for `othersIn(...)` filtering. */
43
- syncGroups: readonly string[];
44
- /** Marks `self` as an agent. Server is the source of truth for
45
- * peers' `isAgent`, but `self` is local — caller decides. */
46
- isAgent?: boolean;
47
- }
48
- /** PresenceStream extended with engine-lifecycle hooks. */
49
- export interface AttachablePresenceStream extends PresenceStream {
50
- /** Wire the stream to a now-ready transport. Calls before this are
51
- * buffered (self mutations only — no wire send). Idempotent. */
52
- attach(transport: PresenceTransport): void;
53
- /**
54
- * Seeds the participant identity once the host resolves it. The stream can
55
- * be built before identity is known — a hosted client learns who it is
56
- * from its credential's scope during connect — and until then the
57
- * construction-time values (possibly empty) would leave the `self` entry
58
- * blank and let the participant's own echoed frames into `others`. Updates
59
- * the `self` entry in place, so held references see the resolved identity.
60
- */
61
- setParticipant(participant: {
62
- id: string;
63
- kind?: ParticipantKind;
64
- syncGroups?: readonly string[];
65
- }): void;
66
- /** Tear down listeners. Stream object stays usable as a no-op. */
67
- dispose(): void;
68
- }
69
- export declare function createPresenceStream(config: PresenceStreamConfig, transport?: PresenceTransport | null): AttachablePresenceStream;
@@ -1,200 +0,0 @@
1
- /**
2
- * Creates a {@link PresenceStream} over a live sync connection. Presence is the
3
- * lightweight, ephemeral "who's here and what are they doing" view: each
4
- * participant broadcasts a status and an activity, and sees everyone else's.
5
- * The stream is built directly on the sync WebSocket and adds no second
6
- * connection. It is the sibling of {@link createClaimStream}, which reuses the
7
- * same presence frames.
8
- *
9
- * There are two ways to construct it:
10
- *
11
- * 1. Direct — pass an already-open `transport`, for example an agent worker
12
- * or a test.
13
- * 2. Deferred — construct without a transport and call `attach(transport)`
14
- * once the connection is ready. The returned stream object is stable from
15
- * construction, so callers can hold the reference and let attachment
16
- * happen later.
17
- *
18
- * Wire frames:
19
- * • Outbound `presence_update` — `{ status, activity? }`. The server stamps
20
- * `userId`, `kind`, `timestamp`, and `isAgent`, then broadcasts to the
21
- * other participants on the same sync groups.
22
- * • Inbound — the same frame, with `kind` one of `enter`, `update`, or
23
- * `leave`.
24
- */
25
- import { asyncIteratorFrom } from '@abloatai/transaction/utils/asyncIterator';
26
- import { participantKindFromWire } from '@abloatai/transaction/coordination/schema';
27
- import { isTargetTuple, subTarget, wireTarget } from '@abloatai/transaction/coordination';
28
- export function createPresenceStream(config, transport = null) {
29
- const { label, syncGroups, isAgent = false } = config;
30
- // Mutable: the host seeds the resolved identity via `setParticipant` once
31
- // it is known; the own-echo filter always reads the current value.
32
- let participantId = config.participantId;
33
- // ── Self ─────────────────────────────────────────────────────────
34
- const self = {
35
- participantKind: isAgent ? 'agent' : 'user',
36
- participantId,
37
- label,
38
- syncGroups: [...syncGroups],
39
- activity: { entityType: 'Unknown', entityId: '', action: 'idle' },
40
- lastActive: new Date().toISOString(),
41
- };
42
- // ── Others ───────────────────────────────────────────────────────
43
- const othersById = new Map();
44
- let othersSnapshot = Object.freeze([]);
45
- const listeners = new Set();
46
- const notifyListeners = () => {
47
- othersSnapshot = Object.freeze(Array.from(othersById.values()));
48
- for (const l of listeners) {
49
- try {
50
- l();
51
- }
52
- catch {
53
- /* one bad listener doesn't break the others */
54
- }
55
- }
56
- };
57
- // ── Wire wiring ──────────────────────────────────────────────────
58
- let attached = null;
59
- const unsubs = [];
60
- function attach(t) {
61
- if (attached)
62
- return; // idempotent
63
- attached = t;
64
- // Reconnect: clear roster (Hub sends fresh snapshot), re-announce
65
- // own activity (peers don't auto-learn about us across reconnects).
66
- unsubs.push(t.subscribe('connected', () => {
67
- if (othersById.size > 0) {
68
- othersById.clear();
69
- othersSnapshot = Object.freeze([]);
70
- notifyListeners();
71
- }
72
- if (self.activity.entityId)
73
- sendUpdate(self.activity);
74
- }));
75
- // Inbound presence frames arrive in the wire vocabulary
76
- // (userId / isAgent / timestamp); translate them into the shape this
77
- // stream exposes (participantId / participantKind / lastActive).
78
- unsubs.push(t.subscribe('presence_update', (event) => {
79
- if (event.userId === participantId)
80
- return; // own echo
81
- if (!event.userId)
82
- return;
83
- switch (event.kind) {
84
- case 'leave':
85
- if (othersById.delete(event.userId))
86
- notifyListeners();
87
- return;
88
- // No `undefined` arm: every site that builds a presence frame stamps
89
- // `kind`, and the schema now says so, so an unlabelled frame is not a
90
- // shape the transport can hand us.
91
- case 'enter':
92
- case 'update': {
93
- const entry = {
94
- participantKind: participantKindFromWire(event.participantKind, event.isAgent),
95
- participantId: event.userId,
96
- syncGroups: event.syncGroups ?? [],
97
- activity: event.activity
98
- ? {
99
- ...wireTarget(event.activity),
100
- ...subTarget(event.activity),
101
- action: event.activity.action,
102
- ...(event.activity.detail !== undefined
103
- ? { detail: event.activity.detail }
104
- : {}),
105
- }
106
- : { entityType: 'Unknown', entityId: '', action: event.status },
107
- lastActive: event.timestamp
108
- ? new Date(event.timestamp).toISOString()
109
- : new Date().toISOString(),
110
- };
111
- othersById.set(event.userId, entry);
112
- notifyListeners();
113
- return;
114
- }
115
- }
116
- }));
117
- // If self was already mutated before attach, broadcast it now.
118
- if (self.activity.entityId)
119
- sendUpdate(self.activity);
120
- }
121
- if (transport)
122
- attach(transport);
123
- // ── Outbound ────────────────────────────────────────────────────
124
- // Do not include `isAgent` in the payload. The server derives it
125
- // authoritatively from the connection's identity, and letting a client
126
- // self-declare it once caused human sessions to broadcast as agents to peers.
127
- function sendUpdate(activity) {
128
- if (!attached?.isConnected())
129
- return; // no-op until connected
130
- attached.send({
131
- type: 'presence_update',
132
- payload: { status: 'online', activity },
133
- });
134
- }
135
- function doUpdate(activity) {
136
- self.activity = activity;
137
- self.lastActive = new Date().toISOString();
138
- sendUpdate(activity);
139
- }
140
- function resolveTarget(target) {
141
- if (isTargetTuple(target)) {
142
- return { entityType: target[0], entityId: target[1], action: 'unknown' };
143
- }
144
- return {
145
- ...wireTarget(target),
146
- ...subTarget(target),
147
- action: 'unknown',
148
- };
149
- }
150
- const withVerb = (action) => (target, detail) => {
151
- doUpdate({ ...resolveTarget(target), action, detail });
152
- };
153
- return {
154
- self,
155
- update: doUpdate,
156
- editing: withVerb('editing'),
157
- reading: withVerb('reading'),
158
- viewing: withVerb('viewing'),
159
- idle: () => {
160
- doUpdate({ entityType: 'Unknown', entityId: '', action: 'idle' });
161
- },
162
- get others() {
163
- return othersSnapshot;
164
- },
165
- othersIn: (syncGroup) => othersSnapshot.filter((e) => e.syncGroups.includes(syncGroup)),
166
- onChange: (listener) => {
167
- listeners.add(listener);
168
- return () => {
169
- listeners.delete(listener);
170
- };
171
- },
172
- [Symbol.asyncIterator]() {
173
- return asyncIteratorFrom((onChange) => {
174
- listeners.add(onChange);
175
- return () => {
176
- listeners.delete(onChange);
177
- };
178
- }, () => othersSnapshot);
179
- },
180
- attach,
181
- setParticipant(participant) {
182
- participantId = participant.id;
183
- const writable = self;
184
- writable.participantId = participant.id;
185
- if (participant.kind)
186
- writable.participantKind = participant.kind;
187
- if (participant.syncGroups)
188
- writable.syncGroups = [...participant.syncGroups];
189
- },
190
- dispose() {
191
- for (const off of unsubs)
192
- off();
193
- unsubs.length = 0;
194
- listeners.clear();
195
- othersById.clear();
196
- othersSnapshot = Object.freeze([]);
197
- attached = null;
198
- },
199
- };
200
- }