@abloatai/humans 0.62.0 → 0.63.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/createAbloReact.d.ts +4 -0
- package/dist/react/createAbloReact.js +12 -2
- package/dist/react/useAblo.d.ts +2 -0
- package/dist/react/useAblo.js +10 -5
- package/dist/react/usePresence.d.ts +19 -0
- package/dist/react/usePresence.js +28 -0
- 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/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/createAbloReact.ts +25 -1
- package/src/react/useAblo.ts +14 -6
- package/src/react/usePresence.ts +88 -0
- package/src/react.ts +4 -0
- package/src/surface.ts +1 -0
package/dist/index.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ export { Ablo } from './Ablo.js';
|
|
|
2
2
|
export type { AbloOptions, AbloReads, CredentialProvider, InternalAbloOptions, ModelClaim, ModelTarget, } from './Ablo.js';
|
|
3
3
|
export { humans, type HumansSurface } from './humans.js';
|
|
4
4
|
export type { AbloClient } from './client.js';
|
|
5
|
+
export type { CollaborationEventContext } from '@abloatai/transaction/collaboration';
|
|
5
6
|
export type { AbloPlugin, MergedSurface, PipelineStage, PluginById, TransportCapabilities, } from './plugin.js';
|
|
6
7
|
export { defineMutators, type MutatorDefs, type MutatorFn, } from './local/mutators/defineMutators.js';
|
|
7
8
|
export { createTransaction, type Transaction, type ReaderFindOptions, } from './local/mutators/Transaction.js';
|
|
@@ -1019,7 +1019,7 @@ export class InstanceCache {
|
|
|
1019
1019
|
});
|
|
1020
1020
|
}
|
|
1021
1021
|
startGC() {
|
|
1022
|
-
if (this.gcTimer)
|
|
1022
|
+
if (this.gcTimer || this.config.gcInterval <= 0)
|
|
1023
1023
|
return;
|
|
1024
1024
|
this.gcTimer = setInterval(() => this.gc(), this.config.gcInterval);
|
|
1025
1025
|
// Don't hold a headless Node process open just for pool GC — without
|
|
@@ -23,10 +23,16 @@ import type { ClaimApi, ClaimAttemptEvent, LocalCountOptions, LocalReadOptions }
|
|
|
23
23
|
import type { HttpModelClient } from '@abloatai/transaction/transport/http';
|
|
24
24
|
import type { ParticipantKind } from '@abloatai/transaction/types/participant';
|
|
25
25
|
import type { PresenceSession } from '@abloatai/transaction/presence';
|
|
26
|
+
import type { CollaborationEventContext, ModelEventEnvelope, ModelEventInput, ModelEventTarget } from '@abloatai/transaction/collaboration';
|
|
26
27
|
import { type ReadSetContext } from '@abloatai/transaction/internal/read-set';
|
|
27
28
|
export interface ModelClientMeta {
|
|
28
29
|
readonly key: string;
|
|
29
30
|
readonly typename: string;
|
|
31
|
+
readonly presence?: {
|
|
32
|
+
get(recordId: string): readonly PresenceSession[];
|
|
33
|
+
subscribe(listener: () => void): () => void;
|
|
34
|
+
read(recordId: string): () => void;
|
|
35
|
+
};
|
|
30
36
|
}
|
|
31
37
|
export declare function getModelClientMeta(modelClient: unknown): ModelClientMeta | undefined;
|
|
32
38
|
/**
|
|
@@ -37,6 +43,13 @@ type EntityHalf = Pick<ModelTarget, 'model' | 'id'>;
|
|
|
37
43
|
export interface ModelCollaboration {
|
|
38
44
|
/** Session projections already held by this client's one presence store. */
|
|
39
45
|
presence(model: string, recordId?: string): readonly PresenceSession[];
|
|
46
|
+
/** Subscribe once to the connection-owned presence projection. */
|
|
47
|
+
onPresenceChange(listener: () => void): () => void;
|
|
48
|
+
/** Start one session-owned read activity and return its cleanup. */
|
|
49
|
+
startReadPresence(target: EntityHalf): () => void;
|
|
50
|
+
modelEventTarget(recordId: string): ModelEventTarget;
|
|
51
|
+
sendModelEvent(input: ModelEventInput): void;
|
|
52
|
+
onModelEvent(listener: (event: ModelEventEnvelope) => void): () => void;
|
|
40
53
|
/** Exact point evidence from the HTTP read boundary (stamp captured before data). */
|
|
41
54
|
readPoint(model: string, id: string): Promise<{
|
|
42
55
|
data: unknown;
|
|
@@ -138,6 +151,8 @@ export interface ModelCollaboration {
|
|
|
138
151
|
* test doubles can omit it.
|
|
139
152
|
*/
|
|
140
153
|
enterScope?(scope: Record<string, string>): void | Promise<void>;
|
|
154
|
+
/** Release read interest previously acquired through {@link enterScope}. */
|
|
155
|
+
leaveScope?(scope: Record<string, string>): void | Promise<void>;
|
|
141
156
|
/**
|
|
142
157
|
* Pins a scope's sync group(s) — write intent: a row this client holds an
|
|
143
158
|
* active claim on stays subscribed regardless of navigation. Same
|
|
@@ -192,6 +207,8 @@ interface ReactiveModelSurface<T, Fields = T> {
|
|
|
192
207
|
local: LocalReads<T>;
|
|
193
208
|
/** Sessions currently active on this model, optionally narrowed to one record. */
|
|
194
209
|
presence(recordId?: string): readonly PresenceSession[];
|
|
210
|
+
/** Lossy, model-record-addressed application events such as cursor or selection. */
|
|
211
|
+
events: ModelEvents;
|
|
195
212
|
/**
|
|
196
213
|
* Claim a row so other writers wait or are rejected until you're done, and
|
|
197
214
|
* inspect or manage that coordination through the same namespace. Call it to
|
|
@@ -222,6 +239,10 @@ interface ReactiveModelSurface<T, Fields = T> {
|
|
|
222
239
|
/** Subscribe to changes; the callback runs on every change. */
|
|
223
240
|
onChange(callback: (entities: T[]) => void, options?: LocalReadOptions<T>): () => void;
|
|
224
241
|
}
|
|
242
|
+
export interface ModelEvents {
|
|
243
|
+
send(recordId: string, event: string, payload: Readonly<Record<string, unknown>>): void;
|
|
244
|
+
subscribe(recordId: string, event: string, handler: (payload: Readonly<Record<string, unknown>>, context: CollaborationEventContext) => void): () => void;
|
|
245
|
+
}
|
|
225
246
|
/**
|
|
226
247
|
* Everything reachable as `ablo.<model>` on a reactive client.
|
|
227
248
|
*
|
|
@@ -30,15 +30,40 @@ import { declaredMeta } from '@abloatai/transaction/claims';
|
|
|
30
30
|
import { ModelScope } from '@abloatai/transaction/types';
|
|
31
31
|
import { bindClaimLifetime, claimLifetimeOf, } from '@abloatai/transaction/claims/lifetime';
|
|
32
32
|
import { claimQueueView, resolveClaimContentionOptions, } from '@abloatai/transaction/client/resources/modelOperations';
|
|
33
|
+
import { modelEventInputSchema } from '@abloatai/transaction/collaboration';
|
|
33
34
|
import { capturePointRead, prepareReadSet, } from '@abloatai/transaction/internal/read-set';
|
|
34
35
|
const ignoreSeparatelyObservedMutationFailure = () => undefined;
|
|
35
36
|
const ignoreBestEffortClaimReleaseFailure = () => undefined;
|
|
37
|
+
const ignoreBestEffortScopeFailure = () => undefined;
|
|
36
38
|
const modelClientMeta = new WeakMap();
|
|
37
39
|
export function getModelClientMeta(modelClient) {
|
|
38
40
|
if (typeof modelClient !== 'object' || modelClient === null)
|
|
39
41
|
return undefined;
|
|
40
42
|
return modelClientMeta.get(modelClient);
|
|
41
43
|
}
|
|
44
|
+
function subscribeInModelScope(collaboration, scope, subscribe) {
|
|
45
|
+
let stopped = false;
|
|
46
|
+
let entered = false;
|
|
47
|
+
let unsubscribe = null;
|
|
48
|
+
void Promise.resolve(collaboration.enterScope?.(scope))
|
|
49
|
+
.then(() => {
|
|
50
|
+
entered = true;
|
|
51
|
+
if (stopped) {
|
|
52
|
+
void collaboration.leaveScope?.(scope);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
unsubscribe = subscribe();
|
|
56
|
+
})
|
|
57
|
+
.catch(ignoreBestEffortScopeFailure);
|
|
58
|
+
return () => {
|
|
59
|
+
if (stopped)
|
|
60
|
+
return;
|
|
61
|
+
stopped = true;
|
|
62
|
+
unsubscribe?.();
|
|
63
|
+
if (entered)
|
|
64
|
+
void collaboration.leaveScope?.(scope);
|
|
65
|
+
};
|
|
66
|
+
}
|
|
42
67
|
export function createModelOperations(schemaKey, registeredModelName, objectPool, syncClient, registry,
|
|
43
68
|
/**
|
|
44
69
|
* The one thing this factory asks of the loader: fetch rows for a model.
|
|
@@ -830,6 +855,50 @@ hydration, collaboration, readSetContext) {
|
|
|
830
855
|
const operations = {
|
|
831
856
|
local,
|
|
832
857
|
presence: (recordId) => collaboration?.presence(registeredModelName, recordId) ?? [],
|
|
858
|
+
events: {
|
|
859
|
+
send(recordId, event, payload) {
|
|
860
|
+
if (!collaboration)
|
|
861
|
+
return;
|
|
862
|
+
const target = collaboration.modelEventTarget(recordId);
|
|
863
|
+
const parsed = modelEventInputSchema.safeParse({ target, event, payload });
|
|
864
|
+
if (!parsed.success) {
|
|
865
|
+
throw new AbloValidationError('Invalid model event.', {
|
|
866
|
+
code: 'invalid_request',
|
|
867
|
+
param: 'event',
|
|
868
|
+
cause: parsed.error,
|
|
869
|
+
});
|
|
870
|
+
}
|
|
871
|
+
const scope = { [schemaKey]: recordId };
|
|
872
|
+
void Promise.resolve(collaboration.enterScope?.(scope))
|
|
873
|
+
.then(() => { collaboration.sendModelEvent(parsed.data); })
|
|
874
|
+
.finally(() => { void collaboration.leaveScope?.(scope); });
|
|
875
|
+
},
|
|
876
|
+
subscribe(recordId, event, handler) {
|
|
877
|
+
if (!collaboration)
|
|
878
|
+
return () => undefined;
|
|
879
|
+
const target = collaboration.modelEventTarget(recordId);
|
|
880
|
+
const parsed = modelEventInputSchema.safeParse({ target, event, payload: {} });
|
|
881
|
+
if (!parsed.success) {
|
|
882
|
+
throw new AbloValidationError('Invalid model event subscription.', {
|
|
883
|
+
code: 'invalid_request',
|
|
884
|
+
param: 'event',
|
|
885
|
+
cause: parsed.error,
|
|
886
|
+
});
|
|
887
|
+
}
|
|
888
|
+
const scope = { [schemaKey]: recordId };
|
|
889
|
+
return subscribeInModelScope(collaboration, scope, () => collaboration.onModelEvent((incoming) => {
|
|
890
|
+
if (incoming.target.model !== target.model ||
|
|
891
|
+
incoming.target.id !== target.id ||
|
|
892
|
+
incoming.target.syncGroup !== target.syncGroup ||
|
|
893
|
+
incoming.event !== parsed.data.event)
|
|
894
|
+
return;
|
|
895
|
+
handler(incoming.payload, {
|
|
896
|
+
sender: incoming.sender,
|
|
897
|
+
sentAt: incoming.sentAt,
|
|
898
|
+
});
|
|
899
|
+
}));
|
|
900
|
+
},
|
|
901
|
+
},
|
|
833
902
|
get,
|
|
834
903
|
read,
|
|
835
904
|
// No automatic scope enrolment on bulk `list`: that would subscribe to an
|
|
@@ -1019,6 +1088,21 @@ hydration, collaboration, readSetContext) {
|
|
|
1019
1088
|
modelClientMeta.set(operations, {
|
|
1020
1089
|
key: schemaKey,
|
|
1021
1090
|
typename: registeredModelName,
|
|
1091
|
+
...(collaboration
|
|
1092
|
+
? {
|
|
1093
|
+
presence: {
|
|
1094
|
+
get: (recordId) => collaboration.presence(registeredModelName, recordId),
|
|
1095
|
+
subscribe: (listener) => collaboration.onPresenceChange(listener),
|
|
1096
|
+
read: (recordId) => {
|
|
1097
|
+
const scope = { [schemaKey]: recordId };
|
|
1098
|
+
return subscribeInModelScope(collaboration, scope, () => collaboration.startReadPresence({
|
|
1099
|
+
model: registeredModelName,
|
|
1100
|
+
id: recordId,
|
|
1101
|
+
}));
|
|
1102
|
+
},
|
|
1103
|
+
},
|
|
1104
|
+
}
|
|
1105
|
+
: {}),
|
|
1022
1106
|
});
|
|
1023
1107
|
return operations;
|
|
1024
1108
|
}
|
|
@@ -29,6 +29,7 @@ import { modelReadResponseSchema, commitRecordSchema, commitRecordListSchema, co
|
|
|
29
29
|
import { translateHttpError, } from '@abloatai/transaction/errors';
|
|
30
30
|
import { kReadEvidence, prepareReadSet, } from '@abloatai/transaction/internal/read-set';
|
|
31
31
|
import { contextOnChange } from '../sync/contextOnChange.js';
|
|
32
|
+
import { resolveScopeGroups } from '../sync/scopeGroups.js';
|
|
32
33
|
export function buildReactiveEngine(inputs) {
|
|
33
34
|
const { options, internalOptions, url, logger, configuredApiKey, configuredAuthToken, credentialResolver, authCredentials, transport, participantId, kind, presence, cluster, } = inputs;
|
|
34
35
|
const schema = options.schema;
|
|
@@ -394,6 +395,20 @@ export function buildReactiveEngine(inputs) {
|
|
|
394
395
|
const registeredModelName = modelDef.typename ?? schemaKey;
|
|
395
396
|
modelProxies[schemaKey] = createModelOperations(schemaKey, registeredModelName, objectPool, syncClient, modelRegistry, hydration, {
|
|
396
397
|
presence: (model, recordId) => presenceStream.forModel(model, recordId),
|
|
398
|
+
onPresenceChange: (listener) => presenceStream.onChange(listener),
|
|
399
|
+
startReadPresence: (target) => presenceStream.startRead(target),
|
|
400
|
+
modelEventTarget: (recordId) => {
|
|
401
|
+
const syncGroup = resolveScopeGroups({ [schemaKey]: recordId }, schema)[0];
|
|
402
|
+
if (syncGroup === undefined) {
|
|
403
|
+
throw new AbloValidationError('A model event requires a record scope.', {
|
|
404
|
+
code: 'invalid_request',
|
|
405
|
+
param: 'recordId',
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
return { model: registeredModelName, id: recordId, syncGroup };
|
|
409
|
+
},
|
|
410
|
+
sendModelEvent: (input) => { transport.sendModelEvent(input); },
|
|
411
|
+
onModelEvent: (listener) => transport.subscribe('model_event', listener),
|
|
397
412
|
createClaim: (claimOptions) => publicClaims.create(claimOptions),
|
|
398
413
|
// Lazily referenced: `commits` is declared below this loop, and this
|
|
399
414
|
// only runs when someone actually writes a batch.
|
|
@@ -432,6 +447,7 @@ export function buildReactiveEngine(inputs) {
|
|
|
432
447
|
// stay fire-and-forget. It's soft either way — the store swallows
|
|
433
448
|
// reconcile errors so read interest never makes a read reject or stall.
|
|
434
449
|
enterScope: (scope) => store.enterScope(scope),
|
|
450
|
+
leaveScope: (scope) => store.leaveScope(scope),
|
|
435
451
|
pinScope: (scope) => store.pinScope(scope),
|
|
436
452
|
}, readSetContext);
|
|
437
453
|
}
|
|
@@ -116,6 +116,8 @@ export declare class MutationQueue extends EventEmitter {
|
|
|
116
116
|
private isProcessing;
|
|
117
117
|
private processTimer?;
|
|
118
118
|
private processScheduled;
|
|
119
|
+
private disposed;
|
|
120
|
+
private retryTimers;
|
|
119
121
|
private createdTransactions;
|
|
120
122
|
private commitScheduled;
|
|
121
123
|
private inFlightByModel;
|
|
@@ -343,6 +345,7 @@ export declare class MutationQueue extends EventEmitter {
|
|
|
343
345
|
*/
|
|
344
346
|
onDeltaReceived(syncId: number, _transactionId?: string, correlationId?: string): void;
|
|
345
347
|
private scheduleDeltaConfirmationTimeout;
|
|
348
|
+
private scheduleRetry;
|
|
346
349
|
/**
|
|
347
350
|
* Resolves once the given transaction is confirmed and rejects if it fails.
|
|
348
351
|
* The confirming delta's timeout is handled by
|
|
@@ -74,6 +74,8 @@ export class MutationQueue extends EventEmitter {
|
|
|
74
74
|
isProcessing = false;
|
|
75
75
|
processTimer;
|
|
76
76
|
processScheduled = false;
|
|
77
|
+
disposed = false;
|
|
78
|
+
retryTimers = new Set();
|
|
77
79
|
// Staging area for transactions created in the same event-loop tick. Each one
|
|
78
80
|
// lands here first, then a microtask commits them together.
|
|
79
81
|
createdTransactions = [];
|
|
@@ -149,11 +151,14 @@ export class MutationQueue extends EventEmitter {
|
|
|
149
151
|
isDefinitiveRejection: (error) => this.isDefinitiveRejection(error),
|
|
150
152
|
isPermanentError: (error) => this.isPermanentError(error),
|
|
151
153
|
scheduleRetry: (delayMs) => {
|
|
154
|
+
if (this.disposed)
|
|
155
|
+
return;
|
|
152
156
|
if (this.commitRetryTimer !== null)
|
|
153
157
|
clearTimeout(this.commitRetryTimer);
|
|
154
158
|
this.commitRetryTimer = setTimeout(() => {
|
|
155
159
|
this.commitRetryTimer = null;
|
|
156
|
-
|
|
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
|
-
|
|
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')
|
package/dist/presence/index.d.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { type PresenceProjection, type PresenceProjectionEvents, type PresenceView } from '@abloatai/transaction/presence';
|
|
2
|
+
import type { PresenceTarget } from '@abloatai/transaction/presence';
|
|
3
|
+
import { type ReadActivityTransport } from './readActivity.js';
|
|
4
|
+
type PresenceTransport = PresenceProjectionEvents & ReadActivityTransport;
|
|
2
5
|
/** Reactive-client presence backed by the client's existing live connection. */
|
|
3
6
|
export interface ReactivePresence extends PresenceView {
|
|
4
7
|
forModel(model: string, recordId?: string): ReturnType<PresenceProjection['forModel']>;
|
|
@@ -6,10 +9,12 @@ export interface ReactivePresence extends PresenceView {
|
|
|
6
9
|
}
|
|
7
10
|
/** Lifecycle hooks kept inside the humans composition boundary. */
|
|
8
11
|
export interface AttachablePresence extends ReactivePresence {
|
|
9
|
-
attach(transport:
|
|
12
|
+
attach(transport: PresenceTransport): void;
|
|
13
|
+
startRead(target: PresenceTarget): () => void;
|
|
10
14
|
dispose(): void;
|
|
11
15
|
}
|
|
12
16
|
/** Framework bridge that does not consume a string key on the model namespace. */
|
|
13
17
|
export declare function attachPresenceToClient(client: object, presence: ReactivePresence): void;
|
|
14
18
|
export declare function presenceOfClient(client: object): ReactivePresence;
|
|
15
|
-
export declare function createPresence(transport?:
|
|
19
|
+
export declare function createPresence(transport?: PresenceTransport | null): AttachablePresence;
|
|
20
|
+
export {};
|
package/dist/presence/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createPresenceProjection, } from '@abloatai/transaction/presence';
|
|
2
|
+
import { startReadActivity, } from './readActivity.js';
|
|
2
3
|
const clientPresence = new WeakMap();
|
|
3
4
|
/** Framework bridge that does not consume a string key on the model namespace. */
|
|
4
5
|
export function attachPresenceToClient(client, presence) {
|
|
@@ -12,7 +13,9 @@ export function presenceOfClient(client) {
|
|
|
12
13
|
}
|
|
13
14
|
export function createPresence(transport = null) {
|
|
14
15
|
let projection = null;
|
|
16
|
+
let attachedTransport = null;
|
|
15
17
|
const listeners = new Set();
|
|
18
|
+
const reads = new Set();
|
|
16
19
|
let unsubscribe = null;
|
|
17
20
|
const notify = () => {
|
|
18
21
|
for (const listener of listeners)
|
|
@@ -21,6 +24,7 @@ export function createPresence(transport = null) {
|
|
|
21
24
|
const attach = (events) => {
|
|
22
25
|
if (projection !== null)
|
|
23
26
|
return;
|
|
27
|
+
attachedTransport = events;
|
|
24
28
|
projection = createPresenceProjection(events);
|
|
25
29
|
unsubscribe = projection.subscribe(notify);
|
|
26
30
|
notify();
|
|
@@ -35,14 +39,28 @@ export function createPresence(transport = null) {
|
|
|
35
39
|
return () => { listeners.delete(listener); };
|
|
36
40
|
},
|
|
37
41
|
attach,
|
|
42
|
+
startRead(target) {
|
|
43
|
+
if (attachedTransport === null) {
|
|
44
|
+
throw new Error('presence is not attached to a duplex transport');
|
|
45
|
+
}
|
|
46
|
+
const lifetime = startReadActivity(attachedTransport, target, () => { reads.delete(lifetime); });
|
|
47
|
+
reads.add(lifetime);
|
|
48
|
+
return () => {
|
|
49
|
+
lifetime.stop();
|
|
50
|
+
};
|
|
51
|
+
},
|
|
38
52
|
forModel(model, recordId) {
|
|
39
53
|
return projection?.forModel(model, recordId) ?? [];
|
|
40
54
|
},
|
|
41
55
|
dispose() {
|
|
56
|
+
for (const read of reads)
|
|
57
|
+
read.dispose();
|
|
58
|
+
reads.clear();
|
|
42
59
|
unsubscribe?.();
|
|
43
60
|
unsubscribe = null;
|
|
44
61
|
projection?.dispose();
|
|
45
62
|
projection = null;
|
|
63
|
+
attachedTransport = null;
|
|
46
64
|
listeners.clear();
|
|
47
65
|
},
|
|
48
66
|
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { PresenceCommand, PresenceTarget } from '@abloatai/transaction/presence';
|
|
2
|
+
/** The connection slice needed to keep one session-owned read alive. */
|
|
3
|
+
export interface ReadActivityTransport {
|
|
4
|
+
isConnected(): boolean;
|
|
5
|
+
sendPresenceCommand(command: PresenceCommand): void;
|
|
6
|
+
subscribe(event: 'connected', listener: () => void): () => void;
|
|
7
|
+
}
|
|
8
|
+
export interface ReadActivityLifetime {
|
|
9
|
+
/** End the read and remove it from the server, reconnecting briefly if needed. */
|
|
10
|
+
stop(): void;
|
|
11
|
+
/** Tear down local resources when the owning client itself is disposed. */
|
|
12
|
+
dispose(): void;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Own the lease mechanics for one declared read. React only starts and stops
|
|
16
|
+
* this lifetime; command ids, refreshes, reconnect recovery, and offline
|
|
17
|
+
* cleanup remain inside the presence subsystem.
|
|
18
|
+
*/
|
|
19
|
+
export declare function startReadActivity(transport: ReadActivityTransport, target: PresenceTarget, onFinished?: () => void): ReadActivityLifetime;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { LEASE_TTL_MS } from '@abloatai/transaction/wire';
|
|
2
|
+
const READ_TTL_MS = LEASE_TTL_MS;
|
|
3
|
+
const READ_REFRESH_MS = READ_TTL_MS / 3;
|
|
4
|
+
let fallbackActivitySequence = 0;
|
|
5
|
+
const noop = () => undefined;
|
|
6
|
+
function readActivityId() {
|
|
7
|
+
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
|
8
|
+
return `read:${crypto.randomUUID()}`;
|
|
9
|
+
}
|
|
10
|
+
fallbackActivitySequence += 1;
|
|
11
|
+
return `read:${Date.now().toString(36)}:${fallbackActivitySequence.toString(36)}`;
|
|
12
|
+
}
|
|
13
|
+
function unref(timer) {
|
|
14
|
+
const candidate = timer;
|
|
15
|
+
if (typeof candidate === 'object' &&
|
|
16
|
+
candidate !== null &&
|
|
17
|
+
'unref' in candidate &&
|
|
18
|
+
typeof candidate.unref === 'function') {
|
|
19
|
+
candidate.unref();
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Own the lease mechanics for one declared read. React only starts and stops
|
|
24
|
+
* this lifetime; command ids, refreshes, reconnect recovery, and offline
|
|
25
|
+
* cleanup remain inside the presence subsystem.
|
|
26
|
+
*/
|
|
27
|
+
export function startReadActivity(transport, target, onFinished = noop) {
|
|
28
|
+
const activityId = readActivityId();
|
|
29
|
+
let active = true;
|
|
30
|
+
let disposed = false;
|
|
31
|
+
let removeOnConnect = null;
|
|
32
|
+
let removalExpiry = null;
|
|
33
|
+
let finished = false;
|
|
34
|
+
const finish = () => {
|
|
35
|
+
if (finished)
|
|
36
|
+
return;
|
|
37
|
+
finished = true;
|
|
38
|
+
onFinished();
|
|
39
|
+
};
|
|
40
|
+
const send = (command) => {
|
|
41
|
+
if (!transport.isConnected())
|
|
42
|
+
return false;
|
|
43
|
+
try {
|
|
44
|
+
transport.sendPresenceCommand(command);
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
// The socket can close between the state check and the synchronous send.
|
|
49
|
+
// Reconnect handling retries; a failure while still connected is real.
|
|
50
|
+
if (transport.isConnected())
|
|
51
|
+
throw error;
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
const upsert = () => {
|
|
56
|
+
if (!active || disposed)
|
|
57
|
+
return;
|
|
58
|
+
send({ type: 'read.upsert', activityId, target, ttlMs: READ_TTL_MS });
|
|
59
|
+
};
|
|
60
|
+
const unsubscribeConnected = transport.subscribe('connected', upsert);
|
|
61
|
+
upsert();
|
|
62
|
+
const refreshTimer = setInterval(() => {
|
|
63
|
+
if (!active || disposed)
|
|
64
|
+
return;
|
|
65
|
+
send({ type: 'read.refresh', activityId, ttlMs: READ_TTL_MS });
|
|
66
|
+
}, READ_REFRESH_MS);
|
|
67
|
+
unref(refreshTimer);
|
|
68
|
+
const clearResources = () => {
|
|
69
|
+
unsubscribeConnected();
|
|
70
|
+
clearInterval(refreshTimer);
|
|
71
|
+
removeOnConnect?.();
|
|
72
|
+
removeOnConnect = null;
|
|
73
|
+
if (removalExpiry !== null)
|
|
74
|
+
clearTimeout(removalExpiry);
|
|
75
|
+
removalExpiry = null;
|
|
76
|
+
};
|
|
77
|
+
const stop = () => {
|
|
78
|
+
if (!active || disposed)
|
|
79
|
+
return;
|
|
80
|
+
active = false;
|
|
81
|
+
unsubscribeConnected();
|
|
82
|
+
clearInterval(refreshTimer);
|
|
83
|
+
const remove = () => {
|
|
84
|
+
if (disposed)
|
|
85
|
+
return;
|
|
86
|
+
if (!send({ type: 'read.remove', activityId }))
|
|
87
|
+
return;
|
|
88
|
+
removeOnConnect?.();
|
|
89
|
+
removeOnConnect = null;
|
|
90
|
+
if (removalExpiry !== null)
|
|
91
|
+
clearTimeout(removalExpiry);
|
|
92
|
+
removalExpiry = null;
|
|
93
|
+
finish();
|
|
94
|
+
};
|
|
95
|
+
if (transport.isConnected()) {
|
|
96
|
+
remove();
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
// If the component leaves offline, remove on the next reconnect so the
|
|
100
|
+
// resumed logical session cannot briefly show a departed viewer. Once the
|
|
101
|
+
// lease expires server-side, there is nothing left to remove.
|
|
102
|
+
removeOnConnect = transport.subscribe('connected', remove);
|
|
103
|
+
removalExpiry = setTimeout(() => {
|
|
104
|
+
removeOnConnect?.();
|
|
105
|
+
removeOnConnect = null;
|
|
106
|
+
removalExpiry = null;
|
|
107
|
+
finish();
|
|
108
|
+
}, READ_TTL_MS);
|
|
109
|
+
unref(removalExpiry);
|
|
110
|
+
};
|
|
111
|
+
return {
|
|
112
|
+
stop,
|
|
113
|
+
dispose() {
|
|
114
|
+
if (disposed)
|
|
115
|
+
return;
|
|
116
|
+
disposed = true;
|
|
117
|
+
active = false;
|
|
118
|
+
clearResources();
|
|
119
|
+
finish();
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
@@ -25,6 +25,8 @@ import { type AbloSelector, type ModelClientSelector, type UseAbloHydratedModelR
|
|
|
25
25
|
import type { AbloClient as Ablo } from '../client.js';
|
|
26
26
|
import type { ModelOperations } from '../local/client/createModelOperations.js';
|
|
27
27
|
import type { Schema, SchemaRecord } from '@abloatai/transaction/schema/schema';
|
|
28
|
+
import { type PresenceModelSelector } from './usePresence.js';
|
|
29
|
+
import type { PresenceSession } from '@abloatai/transaction/presence';
|
|
28
30
|
/** What a binding returns: the provider and the hook, with `S` fixed. */
|
|
29
31
|
export interface AbloReactBinding<S extends SchemaRecord> {
|
|
30
32
|
/** `AbloProvider` with its `client` prop typed `Ablo<S>` — same component,
|
|
@@ -40,6 +42,8 @@ export interface AbloReactBinding<S extends SchemaRecord> {
|
|
|
40
42
|
}): UseAbloHydratedModelResult<T>;
|
|
41
43
|
<T, C>(modelClientOrSelect: ModelOperations<T, C> | ModelClientSelector<S, T, C>, id: string, options?: UseAbloModelOptions<T>): UseAbloModelResult<T>;
|
|
42
44
|
};
|
|
45
|
+
/** Declare and reactively read record presence with the same model clients. */
|
|
46
|
+
usePresence: <T, C>(modelOrSelect: ModelOperations<T, C> | PresenceModelSelector<S, T, C>, recordId: string) => readonly PresenceSession[];
|
|
43
47
|
}
|
|
44
48
|
/**
|
|
45
49
|
* Bind the react surface to one schema. The schema value is taken for
|