@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.
- package/dist/index.d.ts +1 -0
- package/dist/local/InstanceCache.js +1 -1
- package/dist/local/client/createModelOperations.d.ts +21 -0
- package/dist/local/client/createModelOperations.js +84 -0
- package/dist/local/client/reactiveEngine.js +16 -0
- package/dist/local/transactions/mutations/MutationQueue.d.ts +3 -0
- package/dist/local/transactions/mutations/MutationQueue.js +36 -2
- package/dist/local/transactions/mutations/deltaConfirmation.d.ts +1 -0
- package/dist/local/transactions/mutations/deltaConfirmation.js +4 -0
- package/dist/local/transactions/mutations/failureHandling.d.ts +1 -0
- package/dist/local/transactions/mutations/failureHandling.js +1 -1
- package/dist/presence/index.d.ts +7 -2
- package/dist/presence/index.js +18 -0
- package/dist/presence/readActivity.d.ts +19 -0
- package/dist/presence/readActivity.js +122 -0
- package/dist/react/AbloProvider.d.ts +2 -18
- package/dist/react/AbloProvider.js +11 -27
- package/dist/react/createAbloReact.d.ts +4 -0
- package/dist/react/createAbloReact.js +12 -2
- package/dist/react/useAblo.d.ts +3 -1
- package/dist/react/useAblo.js +13 -11
- package/dist/react/usePresence.d.ts +19 -0
- package/dist/react/usePresence.js +28 -0
- package/dist/react/useSyncStatus.js +14 -3
- package/dist/react.d.ts +1 -0
- package/dist/react.js +1 -0
- package/dist/surface.d.ts +1 -1
- package/dist/surface.js +1 -0
- package/dist/useReactive.js +1 -3
- package/package.json +2 -2
- package/src/index.ts +1 -0
- package/src/local/InstanceCache.ts +1 -1
- package/src/local/client/createModelOperations.ts +132 -0
- package/src/local/client/reactiveEngine.ts +16 -0
- package/src/local/transactions/mutations/MutationQueue.ts +26 -2
- package/src/local/transactions/mutations/deltaConfirmation.ts +3 -0
- package/src/local/transactions/mutations/failureHandling.ts +2 -1
- package/src/presence/index.ts +32 -3
- package/src/presence/readActivity.ts +149 -0
- package/src/react/AbloProvider.tsx +14 -54
- package/src/react/createAbloReact.ts +25 -1
- package/src/react/useAblo.ts +18 -13
- package/src/react/usePresence.ts +88 -0
- package/src/react/useSyncStatus.ts +14 -3
- package/src/react.ts +4 -0
- package/src/surface.ts +1 -0
- 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
|
-
|
|
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;
|
package/src/presence/index.ts
CHANGED
|
@@ -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:
|
|
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:
|
|
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:
|
|
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
|
-
*
|
|
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 [
|
|
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
|
-
|
|
244
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/src/react/useAblo.ts
CHANGED
|
@@ -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
|
|
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
|
|
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.
|
|
246
|
-
// claims
|
|
247
|
-
//
|
|
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
|
+
}
|