@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
|
@@ -22,7 +22,8 @@
|
|
|
22
22
|
*/
|
|
23
23
|
import { createContext, createElement, useContext } from 'react';
|
|
24
24
|
import { AbloProvider, } from './AbloProvider.js';
|
|
25
|
-
import { useAbloImpl, } from './useAblo.js';
|
|
25
|
+
import { useAbloImpl, useAbloClientImpl, } from './useAblo.js';
|
|
26
|
+
import { usePresenceImpl, } from './usePresence.js';
|
|
26
27
|
/**
|
|
27
28
|
* Bind the react surface to one schema. The schema value is taken for
|
|
28
29
|
* inference — write `createAbloReact(schema)`, never a hand-spelled type
|
|
@@ -44,5 +45,14 @@ export function createAbloReact(schema) {
|
|
|
44
45
|
const bound = useContext(BoundClientContext);
|
|
45
46
|
return useAbloImpl(bound, modelOrSelect, id, options);
|
|
46
47
|
}
|
|
47
|
-
|
|
48
|
+
function useBoundPresence(modelOrSelect, recordId) {
|
|
49
|
+
const bound = useContext(BoundClientContext);
|
|
50
|
+
const engine = useAbloClientImpl(bound);
|
|
51
|
+
return usePresenceImpl(engine, modelOrSelect, recordId);
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
AbloProvider: BoundAbloProvider,
|
|
55
|
+
useAblo: useBoundAblo,
|
|
56
|
+
usePresence: useBoundPresence,
|
|
57
|
+
};
|
|
48
58
|
}
|
package/dist/react/useAblo.d.ts
CHANGED
|
@@ -93,4 +93,6 @@ export declare function useAblo<R extends SchemaRecord = DefaultModels, T = Reco
|
|
|
93
93
|
* legacy mount migrates.
|
|
94
94
|
*/
|
|
95
95
|
export declare function useAbloImpl<R extends SchemaRecord, T = Record<string, unknown>, C = unknown>(boundClient: Ablo<R> | null, modelOrSelect?: ModelOperations<T, C> | ModelClientSelector<R, T, C> | AbloSelector<R, T>, id?: string, options?: UseAbloModelOptions<T>): Ablo<R> | null | UseAbloModelResult<T> | T | undefined;
|
|
96
|
+
/** @internal Resolve the bound or legacy provider client through one rebind seam. */
|
|
97
|
+
export declare function useAbloClientImpl<R extends SchemaRecord>(boundClient: Ablo<R> | null): Ablo<R> | null;
|
|
96
98
|
export {};
|
package/dist/react/useAblo.js
CHANGED
|
@@ -70,11 +70,7 @@ export function useAblo(modelOrSelect, id, options) {
|
|
|
70
70
|
* legacy mount migrates.
|
|
71
71
|
*/
|
|
72
72
|
export function useAbloImpl(boundClient, modelOrSelect, id, options) {
|
|
73
|
-
const
|
|
74
|
-
// The bound client wins — it is already `Ablo<R>`, no rebinding. The
|
|
75
|
-
// fallback is the ONE remaining schema rebind in the SDK; it retires with
|
|
76
|
-
// the last legacy provider mount (docs/plans/typed-react-binding.md).
|
|
77
|
-
const engine = boundClient ?? (ctx?.engine ? rebindEngine(ctx.engine) : null);
|
|
73
|
+
const engine = useAbloClientImpl(boundClient);
|
|
78
74
|
const initial = options?.initial;
|
|
79
75
|
const isSelectorOnly = typeof modelOrSelect === 'function' && id === undefined;
|
|
80
76
|
const modelClient = typeof modelOrSelect === 'function' && id !== undefined
|
|
@@ -118,3 +114,12 @@ export function useAbloImpl(boundClient, modelOrSelect, id, options) {
|
|
|
118
114
|
return modelResult;
|
|
119
115
|
return engine;
|
|
120
116
|
}
|
|
117
|
+
/** @internal Resolve the bound or legacy provider client through one rebind seam. */
|
|
118
|
+
export function useAbloClientImpl(boundClient) {
|
|
119
|
+
const ctx = useContext(AbloInternalContext);
|
|
120
|
+
// The bound client wins — it is already `Ablo<R>`, no rebinding. The
|
|
121
|
+
// fallback is the ONE remaining schema rebind in the SDK; it retires with
|
|
122
|
+
// the last legacy provider mount (docs/plans/typed-react-binding.md).
|
|
123
|
+
const engine = boundClient ?? (ctx?.engine ? rebindEngine(ctx.engine) : null);
|
|
124
|
+
return engine;
|
|
125
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { PresenceSession } from '@abloatai/transaction/presence';
|
|
2
|
+
import { type ModelOperations } from '../local/client/createModelOperations.js';
|
|
3
|
+
import type { AbloClient as Ablo } from '../client.js';
|
|
4
|
+
import type { SchemaRecord } from '@abloatai/transaction/schema/schema';
|
|
5
|
+
import type { ResolveSchema } from '@abloatai/transaction/types/global';
|
|
6
|
+
type DefaultModels = ResolveSchema extends {
|
|
7
|
+
models: infer M;
|
|
8
|
+
} ? M extends SchemaRecord ? M : SchemaRecord : SchemaRecord;
|
|
9
|
+
export type PresenceModelSelector<R extends SchemaRecord, T, C> = (ablo: Ablo<R>) => ModelOperations<T, C>;
|
|
10
|
+
/**
|
|
11
|
+
* Declare that this component is reading one row and return every live session
|
|
12
|
+
* reading or otherwise acting on that row. Ablo owns the lease, refresh,
|
|
13
|
+
* reconnect, and cleanup mechanics for the component's lifetime.
|
|
14
|
+
*/
|
|
15
|
+
export declare function usePresence<T, C>(modelClient: ModelOperations<T, C>, recordId: string): readonly PresenceSession[];
|
|
16
|
+
export declare function usePresence<R extends SchemaRecord = DefaultModels, T = Record<string, unknown>, C = unknown>(select: PresenceModelSelector<R, T, C>, recordId: string): readonly PresenceSession[];
|
|
17
|
+
/** @internal Shared by the global hook and schema-bound React factory. */
|
|
18
|
+
export declare function usePresenceImpl<R extends SchemaRecord, T, C>(engine: Ablo<R> | null, modelOrSelect: ModelOperations<T, C> | PresenceModelSelector<R, T, C>, recordId: string): readonly PresenceSession[];
|
|
19
|
+
export {};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { useEffect, useState } from 'react';
|
|
3
|
+
import { AbloValidationError } from '@abloatai/transaction/errors';
|
|
4
|
+
import { getModelClientMeta, } from '../local/client/createModelOperations.js';
|
|
5
|
+
import { useAbloClientImpl } from './useAblo.js';
|
|
6
|
+
export function usePresence(modelOrSelect, recordId) {
|
|
7
|
+
const engine = useAbloClientImpl(null);
|
|
8
|
+
return usePresenceImpl(engine, modelOrSelect, recordId);
|
|
9
|
+
}
|
|
10
|
+
/** @internal Shared by the global hook and schema-bound React factory. */
|
|
11
|
+
export function usePresenceImpl(engine, modelOrSelect, recordId) {
|
|
12
|
+
if (recordId.length === 0) {
|
|
13
|
+
throw new AbloValidationError('usePresence requires a non-empty record id.', { code: 'invalid_request', param: 'recordId' });
|
|
14
|
+
}
|
|
15
|
+
const modelClient = typeof modelOrSelect === 'function'
|
|
16
|
+
? engine
|
|
17
|
+
? modelOrSelect(engine)
|
|
18
|
+
: null
|
|
19
|
+
: modelOrSelect;
|
|
20
|
+
const presence = modelClient ? getModelClientMeta(modelClient)?.presence : undefined;
|
|
21
|
+
if (modelClient !== null && presence === undefined) {
|
|
22
|
+
throw new AbloValidationError('usePresence requires a model from the reactive Ablo client.', { code: 'invalid_request', param: 'modelClient' });
|
|
23
|
+
}
|
|
24
|
+
const [, render] = useState(0);
|
|
25
|
+
useEffect(() => presence?.subscribe(() => { render((version) => version + 1); }), [presence]);
|
|
26
|
+
useEffect(() => presence?.read(recordId), [presence, recordId]);
|
|
27
|
+
return presence?.get(recordId) ?? [];
|
|
28
|
+
}
|
package/dist/react.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ 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 { usePresence, type PresenceModelSelector, } from './react/usePresence.js';
|
|
6
7
|
export { useMutationFailureListener, type MutationFailurePayload, } from './react/useMutationFailureListener.js';
|
|
7
8
|
export { AbloProvider, usePeers, useSync, useSyncStore, type AbloProviderProps, type GroupScope, } from './react/AbloProvider.js';
|
|
8
9
|
export { ClientSideSuspense, type ClientSideSuspenseProps, } from './react/ClientSideSuspense.js';
|
package/dist/react.js
CHANGED
|
@@ -3,6 +3,7 @@ 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 } from './react/useSyncStatus.js';
|
|
6
|
+
export { usePresence, } from './react/usePresence.js';
|
|
6
7
|
export { useMutationFailureListener, } from './react/useMutationFailureListener.js';
|
|
7
8
|
export { AbloProvider, usePeers, useSync, useSyncStore, } from './react/AbloProvider.js';
|
|
8
9
|
export { ClientSideSuspense, } from './react/ClientSideSuspense.js';
|
package/dist/surface.d.ts
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* tuple, so it is the one list of model-verb names a generated summary can
|
|
20
20
|
* describe.
|
|
21
21
|
*/
|
|
22
|
-
export declare const PUBLIC_MODEL_VERBS: readonly ["get", "read", "list", "listAll", "local", "presence", "create", "update", "delete", "claim", "onChange"];
|
|
22
|
+
export declare const PUBLIC_MODEL_VERBS: readonly ["get", "read", "list", "listAll", "local", "presence", "events", "create", "update", "delete", "claim", "onChange"];
|
|
23
23
|
/**
|
|
24
24
|
* The option keys accepted by `local.list` and `onChange`, matching the
|
|
25
25
|
* keys of {@link LocalReadOptions}. Note that the lifecycle filter is named
|
package/dist/surface.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@abloatai/humans",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.63.0",
|
|
4
4
|
"description": "The optional human-facing local-state package for Ablo: presence, live queries, and React bindings.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -84,7 +84,7 @@
|
|
|
84
84
|
"directory": "packages/humans"
|
|
85
85
|
},
|
|
86
86
|
"dependencies": {
|
|
87
|
-
"@abloatai/transaction": "^0.
|
|
87
|
+
"@abloatai/transaction": "^0.63.0",
|
|
88
88
|
"mobx": "^6.13.7",
|
|
89
89
|
"uuid": "^11.1.0",
|
|
90
90
|
"zod": "^4.4.3"
|
package/src/index.ts
CHANGED
|
@@ -9,6 +9,7 @@ export type {
|
|
|
9
9
|
} from './Ablo.js';
|
|
10
10
|
export { humans, type HumansSurface } from './humans.js';
|
|
11
11
|
export type { AbloClient } from './client.js';
|
|
12
|
+
export type { CollaborationEventContext } from '@abloatai/transaction/collaboration';
|
|
12
13
|
export type {
|
|
13
14
|
AbloPlugin,
|
|
14
15
|
MergedSurface,
|
|
@@ -1257,7 +1257,7 @@ export class InstanceCache {
|
|
|
1257
1257
|
}
|
|
1258
1258
|
|
|
1259
1259
|
private startGC(): void {
|
|
1260
|
-
if (this.gcTimer) return;
|
|
1260
|
+
if (this.gcTimer || this.config.gcInterval <= 0) return;
|
|
1261
1261
|
this.gcTimer = setInterval(() => this.gc(), this.config.gcInterval);
|
|
1262
1262
|
// Don't hold a headless Node process open just for pool GC — without
|
|
1263
1263
|
// this, an agent that never calls disconnect() can never exit. No-op in
|
|
@@ -144,6 +144,13 @@ import type {
|
|
|
144
144
|
} from '@abloatai/transaction/transport/http';
|
|
145
145
|
import type { ParticipantKind } from '@abloatai/transaction/types/participant';
|
|
146
146
|
import type { PresenceSession } from '@abloatai/transaction/presence';
|
|
147
|
+
import type {
|
|
148
|
+
CollaborationEventContext,
|
|
149
|
+
ModelEventEnvelope,
|
|
150
|
+
ModelEventInput,
|
|
151
|
+
ModelEventTarget,
|
|
152
|
+
} from '@abloatai/transaction/collaboration';
|
|
153
|
+
import { modelEventInputSchema } from '@abloatai/transaction/collaboration';
|
|
147
154
|
import {
|
|
148
155
|
capturePointRead,
|
|
149
156
|
prepareReadSet,
|
|
@@ -152,10 +159,16 @@ import {
|
|
|
152
159
|
|
|
153
160
|
const ignoreSeparatelyObservedMutationFailure = (): undefined => undefined;
|
|
154
161
|
const ignoreBestEffortClaimReleaseFailure = (): undefined => undefined;
|
|
162
|
+
const ignoreBestEffortScopeFailure = (): undefined => undefined;
|
|
155
163
|
|
|
156
164
|
export interface ModelClientMeta {
|
|
157
165
|
readonly key: string;
|
|
158
166
|
readonly typename: string;
|
|
167
|
+
readonly presence?: {
|
|
168
|
+
get(recordId: string): readonly PresenceSession[];
|
|
169
|
+
subscribe(listener: () => void): () => void;
|
|
170
|
+
read(recordId: string): () => void;
|
|
171
|
+
};
|
|
159
172
|
}
|
|
160
173
|
|
|
161
174
|
const modelClientMeta = new WeakMap<object, ModelClientMeta>();
|
|
@@ -180,6 +193,13 @@ type EntityHalf = Pick<ModelTarget, 'model' | 'id'>;
|
|
|
180
193
|
export interface ModelCollaboration {
|
|
181
194
|
/** Session projections already held by this client's one presence store. */
|
|
182
195
|
presence(model: string, recordId?: string): readonly PresenceSession[];
|
|
196
|
+
/** Subscribe once to the connection-owned presence projection. */
|
|
197
|
+
onPresenceChange(listener: () => void): () => void;
|
|
198
|
+
/** Start one session-owned read activity and return its cleanup. */
|
|
199
|
+
startReadPresence(target: EntityHalf): () => void;
|
|
200
|
+
modelEventTarget(recordId: string): ModelEventTarget;
|
|
201
|
+
sendModelEvent(input: ModelEventInput): void;
|
|
202
|
+
onModelEvent(listener: (event: ModelEventEnvelope) => void): () => void;
|
|
183
203
|
/** Exact point evidence from the HTTP read boundary (stamp captured before data). */
|
|
184
204
|
readPoint(model: string, id: string): Promise<{ data: unknown; stamp: number }>;
|
|
185
205
|
/**
|
|
@@ -281,6 +301,8 @@ export interface ModelCollaboration {
|
|
|
281
301
|
* test doubles can omit it.
|
|
282
302
|
*/
|
|
283
303
|
enterScope?(scope: Record<string, string>): void | Promise<void>;
|
|
304
|
+
/** Release read interest previously acquired through {@link enterScope}. */
|
|
305
|
+
leaveScope?(scope: Record<string, string>): void | Promise<void>;
|
|
284
306
|
/**
|
|
285
307
|
* Pins a scope's sync group(s) — write intent: a row this client holds an
|
|
286
308
|
* active claim on stays subscribed regardless of navigation. Same
|
|
@@ -342,6 +364,9 @@ interface ReactiveModelSurface<T, Fields = T> {
|
|
|
342
364
|
/** Sessions currently active on this model, optionally narrowed to one record. */
|
|
343
365
|
presence(recordId?: string): readonly PresenceSession[];
|
|
344
366
|
|
|
367
|
+
/** Lossy, model-record-addressed application events such as cursor or selection. */
|
|
368
|
+
events: ModelEvents;
|
|
369
|
+
|
|
345
370
|
/**
|
|
346
371
|
* Claim a row so other writers wait or are rejected until you're done, and
|
|
347
372
|
* inspect or manage that coordination through the same namespace. Call it to
|
|
@@ -377,6 +402,50 @@ interface ReactiveModelSurface<T, Fields = T> {
|
|
|
377
402
|
): () => void;
|
|
378
403
|
}
|
|
379
404
|
|
|
405
|
+
export interface ModelEvents {
|
|
406
|
+
send(
|
|
407
|
+
recordId: string,
|
|
408
|
+
event: string,
|
|
409
|
+
payload: Readonly<Record<string, unknown>>,
|
|
410
|
+
): void;
|
|
411
|
+
subscribe(
|
|
412
|
+
recordId: string,
|
|
413
|
+
event: string,
|
|
414
|
+
handler: (
|
|
415
|
+
payload: Readonly<Record<string, unknown>>,
|
|
416
|
+
context: CollaborationEventContext,
|
|
417
|
+
) => void,
|
|
418
|
+
): () => void;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function subscribeInModelScope(
|
|
422
|
+
collaboration: ModelCollaboration,
|
|
423
|
+
scope: Record<string, string>,
|
|
424
|
+
subscribe: () => () => void,
|
|
425
|
+
): () => void {
|
|
426
|
+
let stopped = false;
|
|
427
|
+
let entered = false;
|
|
428
|
+
let unsubscribe: (() => void) | null = null;
|
|
429
|
+
|
|
430
|
+
void Promise.resolve(collaboration.enterScope?.(scope))
|
|
431
|
+
.then(() => {
|
|
432
|
+
entered = true;
|
|
433
|
+
if (stopped) {
|
|
434
|
+
void collaboration.leaveScope?.(scope);
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
unsubscribe = subscribe();
|
|
438
|
+
})
|
|
439
|
+
.catch(ignoreBestEffortScopeFailure);
|
|
440
|
+
|
|
441
|
+
return () => {
|
|
442
|
+
if (stopped) return;
|
|
443
|
+
stopped = true;
|
|
444
|
+
unsubscribe?.();
|
|
445
|
+
if (entered) void collaboration.leaveScope?.(scope);
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
|
|
380
449
|
/**
|
|
381
450
|
* Everything reachable as `ablo.<model>` on a reactive client.
|
|
382
451
|
*
|
|
@@ -1411,6 +1480,52 @@ export function createModelOperations<T, C>(
|
|
|
1411
1480
|
|
|
1412
1481
|
presence: (recordId?: string) => collaboration?.presence(registeredModelName, recordId) ?? [],
|
|
1413
1482
|
|
|
1483
|
+
events: {
|
|
1484
|
+
send(recordId, event, payload) {
|
|
1485
|
+
if (!collaboration) return;
|
|
1486
|
+
const target = collaboration.modelEventTarget(recordId);
|
|
1487
|
+
const parsed = modelEventInputSchema.safeParse({ target, event, payload });
|
|
1488
|
+
if (!parsed.success) {
|
|
1489
|
+
throw new AbloValidationError('Invalid model event.', {
|
|
1490
|
+
code: 'invalid_request',
|
|
1491
|
+
param: 'event',
|
|
1492
|
+
cause: parsed.error,
|
|
1493
|
+
});
|
|
1494
|
+
}
|
|
1495
|
+
const scope = { [schemaKey]: recordId };
|
|
1496
|
+
void Promise.resolve(collaboration.enterScope?.(scope))
|
|
1497
|
+
.then(() => { collaboration.sendModelEvent(parsed.data); })
|
|
1498
|
+
.finally(() => { void collaboration.leaveScope?.(scope); });
|
|
1499
|
+
},
|
|
1500
|
+
subscribe(recordId, event, handler) {
|
|
1501
|
+
if (!collaboration) return () => undefined;
|
|
1502
|
+
const target = collaboration.modelEventTarget(recordId);
|
|
1503
|
+
const parsed = modelEventInputSchema.safeParse({ target, event, payload: {} });
|
|
1504
|
+
if (!parsed.success) {
|
|
1505
|
+
throw new AbloValidationError('Invalid model event subscription.', {
|
|
1506
|
+
code: 'invalid_request',
|
|
1507
|
+
param: 'event',
|
|
1508
|
+
cause: parsed.error,
|
|
1509
|
+
});
|
|
1510
|
+
}
|
|
1511
|
+
const scope = { [schemaKey]: recordId };
|
|
1512
|
+
return subscribeInModelScope(collaboration, scope, () =>
|
|
1513
|
+
collaboration.onModelEvent((incoming) => {
|
|
1514
|
+
if (
|
|
1515
|
+
incoming.target.model !== target.model ||
|
|
1516
|
+
incoming.target.id !== target.id ||
|
|
1517
|
+
incoming.target.syncGroup !== target.syncGroup ||
|
|
1518
|
+
incoming.event !== parsed.data.event
|
|
1519
|
+
) return;
|
|
1520
|
+
handler(incoming.payload, {
|
|
1521
|
+
sender: incoming.sender,
|
|
1522
|
+
sentAt: incoming.sentAt,
|
|
1523
|
+
});
|
|
1524
|
+
}),
|
|
1525
|
+
);
|
|
1526
|
+
},
|
|
1527
|
+
},
|
|
1528
|
+
|
|
1414
1529
|
get,
|
|
1415
1530
|
read,
|
|
1416
1531
|
|
|
@@ -1666,6 +1781,23 @@ export function createModelOperations<T, C>(
|
|
|
1666
1781
|
modelClientMeta.set(operations, {
|
|
1667
1782
|
key: schemaKey,
|
|
1668
1783
|
typename: registeredModelName,
|
|
1784
|
+
...(collaboration
|
|
1785
|
+
? {
|
|
1786
|
+
presence: {
|
|
1787
|
+
get: (recordId: string) => collaboration.presence(registeredModelName, recordId),
|
|
1788
|
+
subscribe: (listener: () => void) => collaboration.onPresenceChange(listener),
|
|
1789
|
+
read: (recordId: string) => {
|
|
1790
|
+
const scope = { [schemaKey]: recordId };
|
|
1791
|
+
return subscribeInModelScope(collaboration, scope, () =>
|
|
1792
|
+
collaboration.startReadPresence({
|
|
1793
|
+
model: registeredModelName,
|
|
1794
|
+
id: recordId,
|
|
1795
|
+
}),
|
|
1796
|
+
);
|
|
1797
|
+
},
|
|
1798
|
+
},
|
|
1799
|
+
}
|
|
1800
|
+
: {}),
|
|
1669
1801
|
});
|
|
1670
1802
|
|
|
1671
1803
|
return operations;
|
|
@@ -78,6 +78,7 @@ import {
|
|
|
78
78
|
prepareReadSet,
|
|
79
79
|
} from '@abloatai/transaction/internal/read-set';
|
|
80
80
|
import { contextOnChange } from '../sync/contextOnChange.js';
|
|
81
|
+
import { resolveScopeGroups } from '../sync/scopeGroups.js';
|
|
81
82
|
import type { ReadDependency } from '@abloatai/transaction/coordination';
|
|
82
83
|
import type { CapturedRow } from '@abloatai/transaction/transport/http';
|
|
83
84
|
|
|
@@ -575,6 +576,20 @@ export function buildReactiveEngine<const S extends SchemaRecord>(
|
|
|
575
576
|
hydration,
|
|
576
577
|
{
|
|
577
578
|
presence: (model, recordId) => presenceStream.forModel(model, recordId),
|
|
579
|
+
onPresenceChange: (listener) => presenceStream.onChange(listener),
|
|
580
|
+
startReadPresence: (target) => presenceStream.startRead(target),
|
|
581
|
+
modelEventTarget: (recordId) => {
|
|
582
|
+
const syncGroup = resolveScopeGroups({ [schemaKey]: recordId }, schema)[0];
|
|
583
|
+
if (syncGroup === undefined) {
|
|
584
|
+
throw new AbloValidationError('A model event requires a record scope.', {
|
|
585
|
+
code: 'invalid_request',
|
|
586
|
+
param: 'recordId',
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
return { model: registeredModelName, id: recordId, syncGroup };
|
|
590
|
+
},
|
|
591
|
+
sendModelEvent: (input) => { transport.sendModelEvent(input); },
|
|
592
|
+
onModelEvent: (listener) => transport.subscribe('model_event', listener),
|
|
578
593
|
createClaim: (claimOptions) => publicClaims.create(claimOptions),
|
|
579
594
|
// Lazily referenced: `commits` is declared below this loop, and this
|
|
580
595
|
// only runs when someone actually writes a batch.
|
|
@@ -617,6 +632,7 @@ export function buildReactiveEngine<const S extends SchemaRecord>(
|
|
|
617
632
|
// stay fire-and-forget. It's soft either way — the store swallows
|
|
618
633
|
// reconcile errors so read interest never makes a read reject or stall.
|
|
619
634
|
enterScope: (scope) => store.enterScope(scope),
|
|
635
|
+
leaveScope: (scope) => store.leaveScope(scope),
|
|
620
636
|
pinScope: (scope) => store.pinScope(scope),
|
|
621
637
|
},
|
|
622
638
|
readSetContext,
|
|
@@ -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
|
};
|