@abloatai/humans 0.60.0 → 0.62.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/Ablo.d.ts +4 -4
- package/dist/Ablo.js +2 -1
- package/dist/client.d.ts +7 -12
- package/dist/humans.d.ts +2 -2
- package/dist/humans.js +2 -6
- package/dist/local/BaseSyncedStore.d.ts +2 -4
- package/dist/local/BaseSyncedStore.js +5 -8
- package/dist/local/Model.js +46 -56
- package/dist/local/NetworkMonitor.js +2 -0
- package/dist/local/RuntimeContext.js +2 -0
- package/dist/local/SyncClient.d.ts +8 -32
- package/dist/local/SyncClient.js +26 -99
- package/dist/local/client/clientPrelude.d.ts +2 -0
- package/dist/local/client/clientPrelude.js +13 -1
- package/dist/local/client/createInternalComponents.js +2 -0
- package/dist/local/client/createModelOperations.d.ts +5 -0
- package/dist/local/client/createModelOperations.js +7 -4
- package/dist/local/client/reactiveEngine.d.ts +2 -2
- package/dist/local/client/reactiveEngine.js +9 -11
- package/dist/local/fileUploads.d.ts +27 -0
- package/dist/local/fileUploads.js +55 -0
- package/dist/local/query/client.d.ts +3 -0
- package/dist/local/query/client.js +1 -1
- package/dist/local/stores/syncAction.d.ts +1 -1
- package/dist/local/sync/BootstrapFetcher.d.ts +2 -0
- package/dist/local/sync/BootstrapFetcher.js +4 -4
- package/dist/local/sync/OnDemandLoader.d.ts +2 -0
- package/dist/local/sync/OnDemandLoader.js +1 -0
- package/dist/local/sync/SyncWebSocket.d.ts +2 -15
- package/dist/local/sync/SyncWebSocket.js +1 -36
- package/dist/local/sync/contextOnChange.js +1 -1
- package/dist/local/sync/createClaimStream.d.ts +9 -19
- package/dist/local/sync/createClaimStream.js +41 -56
- package/dist/local/sync/deltaPipeline.js +12 -6
- package/dist/local/sync/schemas.d.ts +2 -2
- package/dist/local/sync/socketEventWiring.d.ts +1 -2
- package/dist/local/sync/socketEventWiring.js +1 -5
- package/dist/local/transactions/localMutation.js +3 -3
- package/dist/local/transactions/mutations/MutationQueue.d.ts +4 -5
- package/dist/local/transactions/mutations/MutationQueue.js +25 -51
- package/dist/local/transactions/mutations/batchProcessing.js +23 -10
- package/dist/local/transactions/mutations/commitPayload.d.ts +8 -1
- package/dist/local/transactions/mutations/commitTransport.js +3 -1
- package/dist/local/transactions/mutations/executionSelection.d.ts +0 -1
- package/dist/local/transactions/mutations/executionSelection.js +9 -17
- package/dist/local/transactions/mutations/failureHandling.js +9 -0
- package/dist/local/transactions/mutations/localMutation.js +3 -3
- package/dist/local/transactions/mutations/queueCoalescing.js +8 -0
- package/dist/local/transactions/mutations/replayValidation.d.ts +4 -4
- package/dist/presence/index.d.ts +15 -0
- package/dist/presence/index.js +49 -0
- package/dist/react/AbloProvider.d.ts +3 -3
- package/dist/react/AbloProvider.js +4 -3
- package/dist/react/useErrorListener.js +1 -1
- package/dist/react/useMutationFailureListener.js +1 -1
- package/dist/surface.d.ts +1 -1
- package/dist/surface.js +1 -0
- package/package.json +3 -4
- package/src/Ablo.ts +15 -6
- package/src/client.ts +8 -13
- package/src/humans.ts +3 -10
- package/src/local/BaseSyncedStore.ts +5 -11
- package/src/local/Model.ts +45 -55
- package/src/local/NetworkMonitor.ts +2 -0
- package/src/local/RuntimeContext.ts +2 -0
- package/src/local/SyncClient.ts +33 -127
- package/src/local/client/clientPrelude.ts +20 -0
- package/src/local/client/createInternalComponents.ts +2 -0
- package/src/local/client/createModelOperations.ts +17 -6
- package/src/local/client/reactiveEngine.ts +19 -14
- package/src/local/fileUploads.ts +97 -0
- package/src/local/query/client.ts +4 -0
- package/src/local/sync/BootstrapFetcher.ts +13 -4
- package/src/local/sync/OnDemandLoader.ts +3 -0
- package/src/local/sync/SyncWebSocket.ts +1 -42
- package/src/local/sync/contextOnChange.ts +1 -1
- package/src/local/sync/createClaimStream.ts +52 -72
- package/src/local/sync/deltaPipeline.ts +10 -6
- package/src/local/sync/socketEventWiring.ts +1 -8
- package/src/local/transactions/localMutation.ts +3 -3
- package/src/local/transactions/mutations/MutationQueue.ts +24 -53
- package/src/local/transactions/mutations/batchProcessing.ts +25 -10
- package/src/local/transactions/mutations/commitPayload.ts +11 -1
- package/src/local/transactions/mutations/commitTransport.ts +2 -2
- package/src/local/transactions/mutations/executionSelection.ts +9 -15
- package/src/local/transactions/mutations/failureHandling.ts +10 -0
- package/src/local/transactions/mutations/localMutation.ts +3 -3
- package/src/local/transactions/mutations/queueCoalescing.ts +6 -0
- package/src/presence/index.ts +72 -0
- package/src/react/AbloProvider.tsx +14 -9
- package/src/react/useErrorListener.ts +1 -1
- package/src/react/useMutationFailureListener.ts +1 -1
- package/src/surface.ts +1 -0
- package/dist/local/transactions/mutations/pendingDrain.d.ts +0 -33
- package/dist/local/transactions/mutations/pendingDrain.js +0 -117
- package/dist/presenceStream.d.ts +0 -69
- package/dist/presenceStream.js +0 -200
- package/src/local/transactions/mutations/pendingDrain.ts +0 -169
- package/src/presenceStream.ts +0 -279
|
@@ -5,10 +5,8 @@
|
|
|
5
5
|
* everyone else's, and watch the wait queue when a claim is contended.
|
|
6
6
|
*
|
|
7
7
|
* The stream is built directly on the sync WebSocket and shares that one
|
|
8
|
-
* connection. It learns about other
|
|
9
|
-
*
|
|
10
|
-
* consumes — the server piggybacks each participant's `activeClaims` on every
|
|
11
|
-
* presence frame — and sends its own claims as `claim_begin` and
|
|
8
|
+
* connection. It learns about other sessions' claims from the normalized
|
|
9
|
+
* presence projection and sends its own claims as `claim_begin` and
|
|
12
10
|
* `claim_abandon` frames.
|
|
13
11
|
*
|
|
14
12
|
* Wire frames:
|
|
@@ -16,8 +14,7 @@
|
|
|
16
14
|
* entityId, description, field?, estimatedMs? }`.
|
|
17
15
|
* • Outbound `claim_abandon` — release it: `{ claimId, entityType?,
|
|
18
16
|
* entityId? }`.
|
|
19
|
-
* • Inbound, via presence — `
|
|
20
|
-
* `declaredAt` and `expiresAt`.
|
|
17
|
+
* • Inbound, via presence — authoritative `claim` activities.
|
|
21
18
|
* • Inbound `claim_rejected` — the server refused the claim, with conflict
|
|
22
19
|
* metadata.
|
|
23
20
|
*/
|
|
@@ -40,10 +37,7 @@ function claimLabel(type, id, field) {
|
|
|
40
37
|
* above a round trip, comfortably below the ttl/3 beat cadence.
|
|
41
38
|
*/
|
|
42
39
|
const HEARTBEAT_ACK_TIMEOUT_MS = 10_000;
|
|
43
|
-
export function createClaimStream(config, transport = null) {
|
|
44
|
-
// Mutable: the host seeds the resolved identity via `setParticipant` once
|
|
45
|
-
// it is known; the own-claim filter always reads the current value.
|
|
46
|
-
let participantId = config.participantId;
|
|
40
|
+
export function createClaimStream(config, transport = null, presence = null) {
|
|
47
41
|
const logger = config.logger ?? noopLogger;
|
|
48
42
|
// ── State: others' open claims, keyed by claimId ───────────────
|
|
49
43
|
const activeByClaimId = new Map();
|
|
@@ -113,48 +107,7 @@ export function createClaimStream(config, transport = null) {
|
|
|
113
107
|
if (attached)
|
|
114
108
|
return;
|
|
115
109
|
attached = t;
|
|
116
|
-
//
|
|
117
|
-
// active-claim set. Prune previous claims by holder, then
|
|
118
|
-
// re-add from the frame — the frame is authoritative for that
|
|
119
|
-
// participant's open claims at that moment.
|
|
120
|
-
unsubs.push(t.subscribe('presence_update', (event) => {
|
|
121
|
-
if (!event.userId)
|
|
122
|
-
return;
|
|
123
|
-
if (event.userId === participantId)
|
|
124
|
-
return;
|
|
125
|
-
let mutated = false;
|
|
126
|
-
if (event.kind === 'leave') {
|
|
127
|
-
for (const [id, claim] of activeByClaimId) {
|
|
128
|
-
if (claim.heldBy === event.userId) {
|
|
129
|
-
activeByClaimId.delete(id);
|
|
130
|
-
mutated = true;
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
if (mutated)
|
|
134
|
-
notifyListeners();
|
|
135
|
-
return;
|
|
136
|
-
}
|
|
137
|
-
for (const [id, claim] of activeByClaimId) {
|
|
138
|
-
if (claim.heldBy === event.userId) {
|
|
139
|
-
activeByClaimId.delete(id);
|
|
140
|
-
mutated = true;
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
for (const claim of event.activeClaims ?? []) {
|
|
144
|
-
// Terminal-status entries (committed / expired / canceled) are
|
|
145
|
-
// one-shot "this claim ended" signals. The holder sweep above
|
|
146
|
-
// already removed the prior active entry; skipping the re-add
|
|
147
|
-
// drops it from `others`, which is what resolves a contender's
|
|
148
|
-
// `settled()`. Absent status means active (wire back-compat).
|
|
149
|
-
if (claim.status && claim.status !== 'active')
|
|
150
|
-
continue;
|
|
151
|
-
observeForeignClaim(event.userId, claim, event.participantKind, event.isAgent);
|
|
152
|
-
mutated = true;
|
|
153
|
-
}
|
|
154
|
-
if (mutated)
|
|
155
|
-
notifyListeners();
|
|
156
|
-
}));
|
|
157
|
-
// (2) Server-side rejection frames.
|
|
110
|
+
// Server-side rejection frames.
|
|
158
111
|
unsubs.push(t.subscribe('claim_rejected', (rejection) => {
|
|
159
112
|
if (!rejection.claimId)
|
|
160
113
|
return;
|
|
@@ -284,12 +237,47 @@ export function createClaimStream(config, transport = null) {
|
|
|
284
237
|
}
|
|
285
238
|
ownClaims.clear();
|
|
286
239
|
for (const claimId of [...pendingHeartbeats.keys()]) {
|
|
287
|
-
settleHeartbeat(claimId, ({ reject }) => reject(error));
|
|
240
|
+
settleHeartbeat(claimId, ({ reject }) => { reject(error); });
|
|
288
241
|
}
|
|
289
242
|
}));
|
|
290
243
|
}
|
|
291
244
|
if (transport)
|
|
292
245
|
attach(transport);
|
|
246
|
+
const refreshPresenceClaims = () => {
|
|
247
|
+
if (presence === null)
|
|
248
|
+
return;
|
|
249
|
+
activeByClaimId.clear();
|
|
250
|
+
for (const session of presence.others) {
|
|
251
|
+
for (const activity of session.activities) {
|
|
252
|
+
if (activity.operation !== 'claim'
|
|
253
|
+
|| activity.source !== 'claim'
|
|
254
|
+
|| activity.target.id === undefined)
|
|
255
|
+
continue;
|
|
256
|
+
const claimId = activity.id.startsWith('claim:')
|
|
257
|
+
? activity.id.slice('claim:'.length)
|
|
258
|
+
: activity.id;
|
|
259
|
+
observeForeignClaim(session.participant.id, {
|
|
260
|
+
claimId,
|
|
261
|
+
entityType: activity.target.model,
|
|
262
|
+
entityId: activity.target.id,
|
|
263
|
+
...(activity.target.field !== undefined
|
|
264
|
+
? { field: activity.target.field }
|
|
265
|
+
: {}),
|
|
266
|
+
...(activity.target.fields !== undefined
|
|
267
|
+
? { fields: activity.target.fields }
|
|
268
|
+
: {}),
|
|
269
|
+
description: 'claim',
|
|
270
|
+
declaredAt: Date.parse(activity.startedAt),
|
|
271
|
+
expiresAt: Date.parse(activity.expiresAt),
|
|
272
|
+
}, session.participant.kind);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
notifyListeners();
|
|
276
|
+
};
|
|
277
|
+
if (presence !== null) {
|
|
278
|
+
refreshPresenceClaims();
|
|
279
|
+
unsubs.push(presence.onChange(refreshPresenceClaims));
|
|
280
|
+
}
|
|
293
281
|
// ── Outbound ────────────────────────────────────────────────────
|
|
294
282
|
function sendBegin(claimId, claim) {
|
|
295
283
|
if (!attached?.isConnected())
|
|
@@ -475,9 +463,6 @@ export function createClaimStream(config, transport = null) {
|
|
|
475
463
|
}, () => claimsSnapshot);
|
|
476
464
|
},
|
|
477
465
|
attach,
|
|
478
|
-
setParticipant(participant) {
|
|
479
|
-
participantId = participant.id;
|
|
480
|
-
},
|
|
481
466
|
dispose() {
|
|
482
467
|
for (const off of unsubs)
|
|
483
468
|
off();
|
|
@@ -67,7 +67,9 @@ export function deduplicateDeltas(deltas) {
|
|
|
67
67
|
return deltas;
|
|
68
68
|
let strictlyOrdered = true;
|
|
69
69
|
for (let index = 1; index < deltas.length; index += 1) {
|
|
70
|
-
|
|
70
|
+
const previous = deltas[index - 1];
|
|
71
|
+
const current = deltas[index];
|
|
72
|
+
if (!previous || !current || previous.id >= current.id) {
|
|
71
73
|
strictlyOrdered = false;
|
|
72
74
|
break;
|
|
73
75
|
}
|
|
@@ -277,10 +279,13 @@ export function sliceApplyChanges(changes, maxDeltas) {
|
|
|
277
279
|
while (index < changes.length) {
|
|
278
280
|
// The indivisible unit starting here: one transaction's run, or a single
|
|
279
281
|
// untransacted change.
|
|
280
|
-
const
|
|
282
|
+
const change = changes[index];
|
|
283
|
+
if (!change)
|
|
284
|
+
break;
|
|
285
|
+
const transactionId = change.transactionId;
|
|
281
286
|
let end = index + 1;
|
|
282
287
|
if (transactionId !== undefined) {
|
|
283
|
-
while (
|
|
288
|
+
while (changes[end]?.transactionId === transactionId)
|
|
284
289
|
end += 1;
|
|
285
290
|
}
|
|
286
291
|
const groupSize = end - index;
|
|
@@ -327,9 +332,11 @@ async function flushDeltaBatchInner(ctx, queuedDeltas) {
|
|
|
327
332
|
if (customDeltas.length > 0) {
|
|
328
333
|
runInAction(() => {
|
|
329
334
|
for (const delta of customDeltas) {
|
|
335
|
+
if (delta.data === null)
|
|
336
|
+
continue;
|
|
330
337
|
const data = typeof delta.data === 'string'
|
|
331
338
|
? JSON.parse(delta.data)
|
|
332
|
-
:
|
|
339
|
+
: delta.data;
|
|
333
340
|
// 'C' (Covering) is treated identically to 'I' here — the client
|
|
334
341
|
// gained permission to see the entity, so we insert it into the
|
|
335
342
|
// pool as if newly created.
|
|
@@ -395,7 +402,7 @@ async function flushDeltaBatchInner(ctx, queuedDeltas) {
|
|
|
395
402
|
// slice. Slices stay the atomicity unit; the budget only decides where
|
|
396
403
|
// the loop breathes.
|
|
397
404
|
let sliceStartedAt = performance.now();
|
|
398
|
-
for (
|
|
405
|
+
for (const [index, slice] of slices.entries()) {
|
|
399
406
|
if (index > 0 && performance.now() - sliceStartedAt > APPLY_YIELD_BUDGET_MS) {
|
|
400
407
|
pipelineDebug.phase = `apply-yield-${index}`;
|
|
401
408
|
pipelineDebug.applyYields += 1;
|
|
@@ -404,7 +411,6 @@ async function flushDeltaBatchInner(ctx, queuedDeltas) {
|
|
|
404
411
|
}
|
|
405
412
|
pipelineDebug.phase = `apply-slice-${index}`;
|
|
406
413
|
pipelineDebug.applySlices += 1;
|
|
407
|
-
const slice = slices[index];
|
|
408
414
|
if (hasApplyPlugins) {
|
|
409
415
|
runStage(stagePlugins, 'apply', { changes: slice });
|
|
410
416
|
}
|
|
@@ -20,10 +20,10 @@ export declare const ServerDeltaSchema: z.ZodObject<{
|
|
|
20
20
|
id: z.ZodNumber;
|
|
21
21
|
data: z.ZodNullable<z.ZodUnion<readonly [z.ZodRecord<z.ZodString, z.ZodUnknown>, z.ZodString]>>;
|
|
22
22
|
actionType: z.ZodEnum<{
|
|
23
|
-
A: "A";
|
|
24
23
|
I: "I";
|
|
25
24
|
U: "U";
|
|
26
25
|
D: "D";
|
|
26
|
+
A: "A";
|
|
27
27
|
V: "V";
|
|
28
28
|
C: "C";
|
|
29
29
|
G: "G";
|
|
@@ -44,10 +44,10 @@ export declare const BootstrapResponseSchema: z.ZodObject<{
|
|
|
44
44
|
id: z.ZodNumber;
|
|
45
45
|
data: z.ZodNullable<z.ZodUnion<readonly [z.ZodRecord<z.ZodString, z.ZodUnknown>, z.ZodString]>>;
|
|
46
46
|
actionType: z.ZodEnum<{
|
|
47
|
-
A: "A";
|
|
48
47
|
I: "I";
|
|
49
48
|
U: "U";
|
|
50
49
|
D: "D";
|
|
50
|
+
A: "A";
|
|
51
51
|
V: "V";
|
|
52
52
|
C: "C";
|
|
53
53
|
G: "G";
|
|
@@ -5,7 +5,7 @@ import type { InstanceCache } from '../InstanceCache.js';
|
|
|
5
5
|
import type { ConnectionManager } from './ConnectionManager.js';
|
|
6
6
|
import type { SubscriptionManager } from './SubscriptionManager.js';
|
|
7
7
|
import type { SyncStatus } from '../storeContract.js';
|
|
8
|
-
import type { BootstrapHint, BootstrapDataEvent,
|
|
8
|
+
import type { BootstrapHint, BootstrapDataEvent, SyncWebSocket, EventMap } from './SyncWebSocket.js';
|
|
9
9
|
import type { SyncDelta } from './SyncWebSocket.js';
|
|
10
10
|
export interface SocketEventHost<TCollaboration extends EventMap<TCollaboration>> {
|
|
11
11
|
syncWebSocket: SyncWebSocket<TCollaboration>;
|
|
@@ -23,7 +23,6 @@ export interface SocketEventHost<TCollaboration extends EventMap<TCollaboration>
|
|
|
23
23
|
applyDeltaFrame(deltas: SyncDelta[]): void;
|
|
24
24
|
handleBootstrapRequired(hint: BootstrapHint): void;
|
|
25
25
|
handleBootstrapData(data: BootstrapDataEvent): void;
|
|
26
|
-
handlePresenceUpdate(data: PresenceUpdate): void;
|
|
27
26
|
performCredentialRefresh(): Promise<'refreshed' | 'session_error' | 'network_error'>;
|
|
28
27
|
handleTerminalSessionError(error: Error): void;
|
|
29
28
|
nudgeReconnect(): void;
|
|
@@ -43,10 +43,6 @@ export function wireSocketEvents(deps) {
|
|
|
43
43
|
const data = args[0];
|
|
44
44
|
deps.handleBootstrapData(data);
|
|
45
45
|
});
|
|
46
|
-
const onPresenceUpdate = deps.syncWebSocket.subscribe('presence_update', (...args) => {
|
|
47
|
-
const data = args[0];
|
|
48
|
-
deps.handlePresenceUpdate(data);
|
|
49
|
-
});
|
|
50
46
|
// Error events
|
|
51
47
|
const onError = deps.syncWebSocket.subscribe('error', (error) => {
|
|
52
48
|
if (error.message === 'Network is offline' || error.message === 'WebSocket connection failed') {
|
|
@@ -126,5 +122,5 @@ export function wireSocketEvents(deps) {
|
|
|
126
122
|
deps.runtime.logger.debug('[BaseSyncedStore] WebSocket reconnection gave up', { attempts });
|
|
127
123
|
deps.updateSyncStatus({ state: 'reconnecting' });
|
|
128
124
|
});
|
|
129
|
-
deps.disposers.push(onConnected, onDisconnected, onReconnecting, onDelta, onDeltaBatch, onBootstrapRequired, onBootstrapData,
|
|
125
|
+
deps.disposers.push(onConnected, onDisconnected, onReconnecting, onDelta, onDeltaBatch, onBootstrapRequired, onBootstrapData, onError, onSessionError, onHandshakeFailed, onReconnectFailed, () => { deps.areaOfInterest.dispose(); });
|
|
130
126
|
}
|
|
@@ -16,9 +16,9 @@ export function createLocalMutationPort(emit) {
|
|
|
16
16
|
};
|
|
17
17
|
return {
|
|
18
18
|
updates,
|
|
19
|
-
applyCreate: (model, transaction) => track('optimistic:create', model, transaction),
|
|
20
|
-
applyUpdate: (model, transaction) => track('optimistic:update', model, transaction),
|
|
21
|
-
applyDelete: (model, transaction) => track('optimistic:delete', model, transaction),
|
|
19
|
+
applyCreate: (model, transaction) => { track('optimistic:create', model, transaction); },
|
|
20
|
+
applyUpdate: (model, transaction) => { track('optimistic:update', model, transaction); },
|
|
21
|
+
applyDelete: (model, transaction) => { track('optimistic:delete', model, transaction); },
|
|
22
22
|
rollback: (transaction, reason, error) => {
|
|
23
23
|
const optimistic = updates.get(transaction.id);
|
|
24
24
|
if (!optimistic)
|
|
@@ -111,6 +111,7 @@ export declare class MutationQueue extends EventEmitter {
|
|
|
111
111
|
private pendingPersistenceStages;
|
|
112
112
|
private persistenceStageScheduled;
|
|
113
113
|
private pendingDrainPromise;
|
|
114
|
+
private modelProcessingPromise;
|
|
114
115
|
private executionQueue;
|
|
115
116
|
private isProcessing;
|
|
116
117
|
private processTimer?;
|
|
@@ -153,7 +154,6 @@ export declare class MutationQueue extends EventEmitter {
|
|
|
153
154
|
private get failureHandlingContext();
|
|
154
155
|
private get conflictResolutionContext();
|
|
155
156
|
private get processingSchedulerContext();
|
|
156
|
-
private get pendingDrainContext();
|
|
157
157
|
private get durableCommitRestoreContext();
|
|
158
158
|
private get persistenceContext();
|
|
159
159
|
private nextCommitSequence;
|
|
@@ -225,7 +225,6 @@ export declare class MutationQueue extends EventEmitter {
|
|
|
225
225
|
*/
|
|
226
226
|
private scheduleReplicationLagTimeout;
|
|
227
227
|
private takeNextExecutionBatch;
|
|
228
|
-
private takePendingDrainBatch;
|
|
229
228
|
/**
|
|
230
229
|
* Resolvers for per-transaction `confirmation` promises. Populated in
|
|
231
230
|
* `attachConfirmation` at staging time, consumed by the constructor-time
|
|
@@ -477,7 +476,7 @@ export declare class MutationQueue extends EventEmitter {
|
|
|
477
476
|
awaitingDeltaCount: number;
|
|
478
477
|
awaitingDeltaTransactions: {
|
|
479
478
|
id: string;
|
|
480
|
-
type: "
|
|
479
|
+
type: "create" | "update" | "delete" | "archive" | "unarchive";
|
|
481
480
|
modelName: string;
|
|
482
481
|
modelId: string;
|
|
483
482
|
syncIdNeeded: number | undefined;
|
|
@@ -486,13 +485,13 @@ export declare class MutationQueue extends EventEmitter {
|
|
|
486
485
|
}[];
|
|
487
486
|
pendingTransactions: {
|
|
488
487
|
id: string;
|
|
489
|
-
type: "
|
|
488
|
+
type: "create" | "update" | "delete" | "archive" | "unarchive";
|
|
490
489
|
modelName: string;
|
|
491
490
|
modelId: string;
|
|
492
491
|
}[];
|
|
493
492
|
executingTransactions: {
|
|
494
493
|
id: string;
|
|
495
|
-
type: "
|
|
494
|
+
type: "create" | "update" | "delete" | "archive" | "unarchive";
|
|
496
495
|
modelName: string;
|
|
497
496
|
modelId: string;
|
|
498
497
|
}[];
|
|
@@ -34,9 +34,8 @@ import { enqueueTransaction } from './queueCoalescing.js';
|
|
|
34
34
|
import { processBatch } from './batchProcessing.js';
|
|
35
35
|
import { handleFailure } from './failureHandling.js';
|
|
36
36
|
import { handleConflict as resolveConflict, isPermanentError as classifyPermanentError, isDefinitiveRejection as classifyDefinitiveRejection } from './failurePolicy.js';
|
|
37
|
-
import { takeNextExecutionBatch as selectExecutionBatch
|
|
37
|
+
import { takeNextExecutionBatch as selectExecutionBatch } from './executionSelection.js';
|
|
38
38
|
import { scheduleProcessing as scheduleProcessingExternal } from './processingScheduler.js';
|
|
39
|
-
import { drainPendingConfirmations, } from './pendingDrain.js';
|
|
40
39
|
import { restoreDurableCommits as restoreDurableCommitsExternal } from './durableCommitRestore.js';
|
|
41
40
|
export class MutationQueue extends EventEmitter {
|
|
42
41
|
// Keep one hour of clock/network margin inside the server's 24-hour ledger.
|
|
@@ -70,6 +69,7 @@ export class MutationQueue extends EventEmitter {
|
|
|
70
69
|
pendingPersistenceStages = [];
|
|
71
70
|
persistenceStageScheduled = false;
|
|
72
71
|
pendingDrainPromise = null;
|
|
72
|
+
modelProcessingPromise = null;
|
|
73
73
|
executionQueue = [];
|
|
74
74
|
isProcessing = false;
|
|
75
75
|
processTimer;
|
|
@@ -303,32 +303,6 @@ export class MutationQueue extends EventEmitter {
|
|
|
303
303
|
logger: this.runtime.logger,
|
|
304
304
|
};
|
|
305
305
|
}
|
|
306
|
-
get pendingDrainContext() {
|
|
307
|
-
return {
|
|
308
|
-
runtime: this.runtime,
|
|
309
|
-
config: { deltaConfirmationTimeout: this.config.deltaConfirmationTimeout },
|
|
310
|
-
store: this.store,
|
|
311
|
-
executionQueue: this.executionQueue,
|
|
312
|
-
optimisticUpdates: this.localMutationPort.updates,
|
|
313
|
-
assertDurableReplayOpen: () => { this.assertDurableReplayOpen(); },
|
|
314
|
-
processCommitLane: () => this.processCommitLane(),
|
|
315
|
-
takePendingDrainBatch: (pending) => this.takePendingDrainBatch(pending),
|
|
316
|
-
ensureCommitEnvelope: (batch) => this.ensureCommitEnvelope(batch),
|
|
317
|
-
ensureDerivedFields: (transaction) => { this.ensureDerivedFields(transaction); },
|
|
318
|
-
sourceMutationIdsFor: (batch) => this.sourceMutationIdsFor(batch),
|
|
319
|
-
sealDurableCommit: (input) => this.sealDurableCommit(input),
|
|
320
|
-
assertEnvelopeInsideReplayWindow: (envelope) => { this.assertEnvelopeInsideReplayWindow(envelope); },
|
|
321
|
-
parseMutationCommitResult: (value) => this.parseMutationCommitResult(value),
|
|
322
|
-
dispatchCommitBounded: (...args) => this.dispatchCommitBounded(...args),
|
|
323
|
-
persistDurableCommitAcceptance: (envelope, result) => this.persistDurableCommitAcceptance(envelope, result),
|
|
324
|
-
removeDurableCommit: (idempotencyKey) => this.removeDurableCommit(idempotencyKey),
|
|
325
|
-
scheduleReplicationLagTimeout: (transactionId, clientTxId, correlationId) => { this.scheduleReplicationLagTimeout(transactionId, clientTxId, correlationId); },
|
|
326
|
-
scheduleDeltaConfirmationTimeout: (transaction, timeoutMs) => { this.scheduleDeltaConfirmationTimeout(transaction, timeoutMs); },
|
|
327
|
-
enqueue: (transaction) => { this.enqueue(transaction); },
|
|
328
|
-
recentDeltaCorrelations: this.recentDeltaCorrelations,
|
|
329
|
-
emit: (event, payload) => this.emit(event, payload),
|
|
330
|
-
};
|
|
331
|
-
}
|
|
332
306
|
get durableCommitRestoreContext() {
|
|
333
307
|
return {
|
|
334
308
|
config: this.config,
|
|
@@ -738,9 +712,6 @@ export class MutationQueue extends EventEmitter {
|
|
|
738
712
|
this.executionQueue = selected.remaining;
|
|
739
713
|
return selected.batch;
|
|
740
714
|
}
|
|
741
|
-
takePendingDrainBatch(pending) {
|
|
742
|
-
return selectPendingDrainBatch(pending, this.config.maxBatchSize);
|
|
743
|
-
}
|
|
744
715
|
/**
|
|
745
716
|
* Resolvers for per-transaction `confirmation` promises. Populated in
|
|
746
717
|
* `attachConfirmation` at staging time, consumed by the constructor-time
|
|
@@ -1004,25 +975,15 @@ export class MutationQueue extends EventEmitter {
|
|
|
1004
975
|
return this.pendingDrainPromise;
|
|
1005
976
|
}
|
|
1006
977
|
async drainPendingInternal() {
|
|
1007
|
-
//
|
|
1008
|
-
//
|
|
1009
|
-
//
|
|
1010
|
-
//
|
|
1011
|
-
//
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
if (this.isProcessing)
|
|
1017
|
-
return;
|
|
1018
|
-
this.isProcessing = true;
|
|
1019
|
-
try {
|
|
1020
|
-
await drainPendingConfirmations(this.pendingDrainContext);
|
|
1021
|
-
}
|
|
1022
|
-
finally {
|
|
1023
|
-
this.isProcessing = false;
|
|
1024
|
-
if (this.executionQueue.length > 0)
|
|
1025
|
-
this.scheduleProcessing(true);
|
|
978
|
+
// Explicit flushes and reconnects are merely another trigger for the one
|
|
979
|
+
// model-mutation execution lane. A second sealing implementation can race
|
|
980
|
+
// the scheduled lane, consume its journal sources, and later dispatch the
|
|
981
|
+
// same transaction again. Move every staged row to the owned queue, then
|
|
982
|
+
// drive the normal lane until the queue has handed off all current work.
|
|
983
|
+
this.commitCreatedTransactions();
|
|
984
|
+
await this.processCommitLane();
|
|
985
|
+
while (this.executionQueue.length > 0 || this.modelProcessingPromise) {
|
|
986
|
+
await this.processBatch();
|
|
1026
987
|
}
|
|
1027
988
|
}
|
|
1028
989
|
async create(model, context, writeOptions, sourceMutationId) {
|
|
@@ -1055,7 +1016,20 @@ export class MutationQueue extends EventEmitter {
|
|
|
1055
1016
|
scheduleProcessingExternal(this.processingSchedulerContext, immediate);
|
|
1056
1017
|
}
|
|
1057
1018
|
async processBatch() {
|
|
1058
|
-
|
|
1019
|
+
if (this.modelProcessingPromise) {
|
|
1020
|
+
await this.modelProcessingPromise;
|
|
1021
|
+
if (this.executionQueue.length > 0)
|
|
1022
|
+
await this.processBatch();
|
|
1023
|
+
return;
|
|
1024
|
+
}
|
|
1025
|
+
const processing = processBatch(this.batchProcessingContext);
|
|
1026
|
+
const tracked = processing.finally(() => {
|
|
1027
|
+
if (this.modelProcessingPromise === tracked) {
|
|
1028
|
+
this.modelProcessingPromise = null;
|
|
1029
|
+
}
|
|
1030
|
+
});
|
|
1031
|
+
this.modelProcessingPromise = tracked;
|
|
1032
|
+
await tracked;
|
|
1059
1033
|
}
|
|
1060
1034
|
rememberDeltaCorrelation(correlationId, syncId) {
|
|
1061
1035
|
// Refresh insertion order when a replay repeats the same correlation id.
|
|
@@ -64,16 +64,29 @@ export async function processBatch(ctx) {
|
|
|
64
64
|
if (batchOps.length > 0) {
|
|
65
65
|
let dispatchStarted = false;
|
|
66
66
|
try {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
67
|
+
let durableEnvelope = batch[0]?.durableEnvelope;
|
|
68
|
+
if (durableEnvelope) {
|
|
69
|
+
const mismatched = batch.some((transaction) => transaction.durableEnvelope?.idempotencyKey !==
|
|
70
|
+
durableEnvelope?.idempotencyKey);
|
|
71
|
+
if (mismatched || durableEnvelope.idempotencyKey !== commitIdempotencyKey) {
|
|
72
|
+
throw new Error('Cannot replay a model batch with inconsistent durable envelopes');
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
durableEnvelope = await ctx.sealDurableCommit({
|
|
77
|
+
idempotencyKey: commitIdempotencyKey,
|
|
78
|
+
origin: 'model_batch',
|
|
79
|
+
operations: batchOps.map(({ op }) => op),
|
|
80
|
+
sourceMutationIds: ctx.sourceMutationIdsFor(batch),
|
|
81
|
+
commitOptions: { reads: collectQueuedReads(batch) },
|
|
82
|
+
createdAt: Math.min(...batch.map((transaction) => transaction.createdAt)),
|
|
83
|
+
sealedAt: batch[0]?.commitEnvelope?.sealedAt ?? Date.now(),
|
|
84
|
+
sequence: batch[0]?.commitEnvelope?.sequence,
|
|
85
|
+
});
|
|
86
|
+
for (const transaction of batch) {
|
|
87
|
+
transaction.durableEnvelope = durableEnvelope;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
77
90
|
const operations = durableEnvelope.operations;
|
|
78
91
|
// Capture lastSyncId from the server response for threshold-based
|
|
79
92
|
// confirmation.
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
import type { RuntimeContext } from '../../RuntimeContext.js';
|
|
12
12
|
import { MutationOperationType } from '@abloatai/transaction/types';
|
|
13
13
|
import type { MutationOptions, WriteOptions } from '../../interfaces/index.js';
|
|
14
|
-
import type { CommitEnvelopeMember } from '@abloatai/transaction/commit';
|
|
14
|
+
import type { CommitEnvelopeMember, DurableCommitEnvelope } from '@abloatai/transaction/commit';
|
|
15
15
|
export interface UserContext {
|
|
16
16
|
userId: string;
|
|
17
17
|
organizationId: string;
|
|
@@ -69,6 +69,13 @@ export interface QueuedMutation {
|
|
|
69
69
|
* re-batching its operations under a fresh key.
|
|
70
70
|
*/
|
|
71
71
|
commitEnvelope?: CommitEnvelopeMember;
|
|
72
|
+
/**
|
|
73
|
+
* The exact durable request produced by the first successful local seal.
|
|
74
|
+
* Runtime retries dispatch this object directly. Asking the outbox to seal
|
|
75
|
+
* again is both unnecessary and unsafe after a concurrent authoritative
|
|
76
|
+
* completion has begun cleaning up the stored envelope.
|
|
77
|
+
*/
|
|
78
|
+
durableEnvelope?: DurableCommitEnvelope;
|
|
72
79
|
/** Pending-mutation journal entries atomically consumed by this envelope. */
|
|
73
80
|
sourceMutationIds?: string[];
|
|
74
81
|
/** Completed locally without a server operation; no sync echo will arrive. */
|
|
@@ -95,7 +95,9 @@ export function dispatchCommitBounded(ctx, ...args) {
|
|
|
95
95
|
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0)
|
|
96
96
|
return dispatched;
|
|
97
97
|
return new Promise((resolve, reject) => {
|
|
98
|
-
const timer = setTimeout(() =>
|
|
98
|
+
const timer = setTimeout(() => {
|
|
99
|
+
reject(new AbloConnectionError('The mutation transport did not acknowledge the commit in time; its outcome remains pending and is safe to retry.', { code: 'commit_no_result' }));
|
|
100
|
+
}, timeoutMs);
|
|
99
101
|
dispatched.then((value) => { clearTimeout(timer); resolve(value); }, (error) => { clearTimeout(timer); reject(error instanceof Error ? error : new Error(String(error))); });
|
|
100
102
|
});
|
|
101
103
|
}
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
export function takeNextExecutionBatch(executionQueue, maxBatchSize) {
|
|
2
|
+
// Cancellation, delta confirmation, and failure settlement can all make a
|
|
3
|
+
// queued reference terminal before its scheduler callback runs. Terminal or
|
|
4
|
+
// currently executing rows have no authority to cross the dispatch boundary.
|
|
5
|
+
const pendingQueue = executionQueue.filter((tx) => tx.status === 'pending');
|
|
2
6
|
const retryGroups = new Map();
|
|
3
|
-
for (const tx of
|
|
7
|
+
for (const tx of pendingQueue) {
|
|
4
8
|
const envelope = tx.commitEnvelope;
|
|
5
9
|
if (!envelope)
|
|
6
10
|
continue;
|
|
@@ -13,30 +17,18 @@ export function takeNextExecutionBatch(executionQueue, maxBatchSize) {
|
|
|
13
17
|
const expectedCount = members[0]?.commitEnvelope?.operationCount;
|
|
14
18
|
if (expectedCount === undefined || members.length !== expectedCount)
|
|
15
19
|
continue;
|
|
16
|
-
const remaining =
|
|
20
|
+
const remaining = pendingQueue.filter((tx) => tx.commitEnvelope?.idempotencyKey !== idempotencyKey);
|
|
17
21
|
members.sort((a, b) => (a.commitEnvelope?.operationIndex ?? 0) - (b.commitEnvelope?.operationIndex ?? 0));
|
|
18
22
|
return { batch: members, remaining };
|
|
19
23
|
}
|
|
20
|
-
const fresh =
|
|
24
|
+
const fresh = pendingQueue.filter((tx) => !tx.commitEnvelope);
|
|
21
25
|
const firstFresh = fresh[0];
|
|
22
26
|
if (!firstFresh)
|
|
23
|
-
return { batch: [], remaining:
|
|
27
|
+
return { batch: [], remaining: pendingQueue };
|
|
24
28
|
const explicitIndex = fresh.findIndex((tx) => typeof tx.writeOptions?.idempotencyKey === 'string');
|
|
25
29
|
const selected = explicitIndex === 0
|
|
26
30
|
? [firstFresh]
|
|
27
31
|
: fresh.slice(0, Math.min(maxBatchSize, explicitIndex > 0 ? explicitIndex : fresh.length));
|
|
28
32
|
const selectedIds = new Set(selected.map((tx) => tx.id));
|
|
29
|
-
return { batch: selected, remaining:
|
|
30
|
-
}
|
|
31
|
-
export function takePendingDrainBatch(pending, maxBatchSize) {
|
|
32
|
-
const first = pending[0];
|
|
33
|
-
if (!first)
|
|
34
|
-
return [];
|
|
35
|
-
const envelope = first.commitEnvelope;
|
|
36
|
-
if (envelope)
|
|
37
|
-
return pending.filter((tx) => tx.commitEnvelope?.idempotencyKey === envelope.idempotencyKey);
|
|
38
|
-
if (typeof first.writeOptions?.idempotencyKey === 'string')
|
|
39
|
-
return [first];
|
|
40
|
-
const explicitIndex = pending.findIndex((tx) => typeof tx.writeOptions?.idempotencyKey === 'string');
|
|
41
|
-
return pending.slice(0, Math.min(maxBatchSize, explicitIndex > 0 ? explicitIndex : pending.length));
|
|
33
|
+
return { batch: selected, remaining: pendingQueue.filter((tx) => !selectedIds.has(tx.id)) };
|
|
42
34
|
}
|
|
@@ -13,6 +13,15 @@ export function transientRetryDelayMs(error, attempt, retryBackoff) {
|
|
|
13
13
|
return Math.floor(Math.random() * ceiling);
|
|
14
14
|
}
|
|
15
15
|
export async function handleFailure(ctx, transaction, error) {
|
|
16
|
+
// The dispatch owner may lose its acknowledgement while an authoritative
|
|
17
|
+
// delta concurrently completes the same transaction. Completion is
|
|
18
|
+
// terminal: a late catch path must not turn that row back into `pending`
|
|
19
|
+
// and schedule a second seal after its durable sources were cleaned up.
|
|
20
|
+
if (transaction.status === 'completed' ||
|
|
21
|
+
transaction.status === 'failed' ||
|
|
22
|
+
transaction.status === 'rolled_back' ||
|
|
23
|
+
transaction.status === 'awaiting_delta')
|
|
24
|
+
return;
|
|
16
25
|
transaction.attempts++;
|
|
17
26
|
// Check whether this is a permanent error that should not be retried.
|
|
18
27
|
if (ctx.isPermanentError(error)) {
|
|
@@ -12,9 +12,9 @@ export function createLocalMutationPort(emitter) {
|
|
|
12
12
|
const updates = new Map();
|
|
13
13
|
return {
|
|
14
14
|
updates,
|
|
15
|
-
applyCreate: (model, transaction) => applyOptimisticCreate(updates, emitter, model, transaction),
|
|
16
|
-
applyUpdate: (model, transaction) => applyOptimisticUpdate(updates, emitter, model, transaction),
|
|
17
|
-
applyDelete: (model, transaction) => applyOptimisticDelete(updates, emitter, model, transaction),
|
|
15
|
+
applyCreate: (model, transaction) => { applyOptimisticCreate(updates, emitter, model, transaction); },
|
|
16
|
+
applyUpdate: (model, transaction) => { applyOptimisticUpdate(updates, emitter, model, transaction); },
|
|
17
|
+
applyDelete: (model, transaction) => { applyOptimisticDelete(updates, emitter, model, transaction); },
|
|
18
18
|
rollback: (transaction, reason, error) => rollbackOptimistic(updates, emitter, transaction, reason, error),
|
|
19
19
|
};
|
|
20
20
|
}
|
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import { mergeUpdateData } from './coalesceRules.js';
|
|
2
2
|
import { hasCommitCoalescingBarrier } from './commitPayload.js';
|
|
3
3
|
export function enqueueTransaction(ctx, transaction) {
|
|
4
|
+
// Only the pending state may cross into the execution owner. A late timer,
|
|
5
|
+
// reconnect callback, or stale staging callback must not resurrect a row
|
|
6
|
+
// that is already executing or terminal, and repeated triggers must not put
|
|
7
|
+
// the same source mutation into the queue twice.
|
|
8
|
+
if (transaction.status !== 'pending')
|
|
9
|
+
return;
|
|
10
|
+
if (ctx.executionQueue.some((candidate) => candidate.id === transaction.id))
|
|
11
|
+
return;
|
|
4
12
|
ctx.ensureDerivedFields(transaction);
|
|
5
13
|
const modelKey = `${transaction.modelName}:${transaction.modelId}`;
|
|
6
14
|
if (transaction.type === 'update' && transaction.attempts === 0 && !transaction.commitEnvelope) {
|