@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
|
@@ -36,7 +36,6 @@ export type {
|
|
|
36
36
|
SyncCapabilities,
|
|
37
37
|
BootstrapHint,
|
|
38
38
|
BootstrapDataEvent,
|
|
39
|
-
PresenceUpdate,
|
|
40
39
|
CoreSyncEventMap,
|
|
41
40
|
DefaultCollaborationEvents,
|
|
42
41
|
EventMap,
|
|
@@ -129,14 +128,10 @@ export class SyncWebSocket<
|
|
|
129
128
|
|
|
130
129
|
/**
|
|
131
130
|
* The open ritual, run by the transport between its `connected` emit and the
|
|
132
|
-
* heartbeat start:
|
|
131
|
+
* heartbeat start: tell the server where we left off,
|
|
133
132
|
* request the deltas we missed, and start the catch-up poll.
|
|
134
133
|
*/
|
|
135
134
|
protected override onOpened(): void {
|
|
136
|
-
// Send presence update with timezone (server sets presence to "online" on connect,
|
|
137
|
-
// this improves localTime accuracy by providing the user's actual timezone)
|
|
138
|
-
this.sendPresenceUpdate('online');
|
|
139
|
-
|
|
140
135
|
// Immediately request incremental sync based on our stored cursor.
|
|
141
136
|
// `requestIncrementalSync` is async — a bare call inside try/catch is a
|
|
142
137
|
// rejection hole (the catch never sees it); route failures through
|
|
@@ -323,42 +318,6 @@ export class SyncWebSocket<
|
|
|
323
318
|
this.sendAck(syncId);
|
|
324
319
|
}
|
|
325
320
|
|
|
326
|
-
/**
|
|
327
|
-
* Send presence update to server.
|
|
328
|
-
* Use this for:
|
|
329
|
-
* - Updating timezone (improves localTime accuracy shown to other users)
|
|
330
|
-
* - Manual status changes (away, custom status)
|
|
331
|
-
*
|
|
332
|
-
* Note: "online" status is automatically set by server on WebSocket connect,
|
|
333
|
-
* and "offline" is set on disconnect. You don't need to call this for basic online/offline.
|
|
334
|
-
*
|
|
335
|
-
* @param status - "online", "away", or custom status string
|
|
336
|
-
* @param customStatus - Optional custom status message
|
|
337
|
-
*/
|
|
338
|
-
sendPresenceUpdate(
|
|
339
|
-
status: 'online' | 'away' | 'offline' = 'online',
|
|
340
|
-
customStatus?: string
|
|
341
|
-
): void {
|
|
342
|
-
if (!this.isConnected()) return;
|
|
343
|
-
|
|
344
|
-
const timezone = (() => {
|
|
345
|
-
try {
|
|
346
|
-
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
347
|
-
} catch {
|
|
348
|
-
return 'UTC';
|
|
349
|
-
}
|
|
350
|
-
})();
|
|
351
|
-
|
|
352
|
-
this.send({
|
|
353
|
-
type: 'presence_update',
|
|
354
|
-
payload: {
|
|
355
|
-
status,
|
|
356
|
-
timezone,
|
|
357
|
-
...(customStatus ? { customStatus } : {}),
|
|
358
|
-
},
|
|
359
|
-
});
|
|
360
|
-
}
|
|
361
|
-
|
|
362
321
|
/**
|
|
363
322
|
* Stop the periodic catchup interval
|
|
364
323
|
*/
|
|
@@ -54,7 +54,7 @@ export function contextOnChange(
|
|
|
54
54
|
// already advanced this exact row in the pool.
|
|
55
55
|
for (const read of rowReads) {
|
|
56
56
|
const resident = pool.peek(read.id);
|
|
57
|
-
if (
|
|
57
|
+
if (resident?.getModelName().toLowerCase() !== read.model.toLowerCase()) {
|
|
58
58
|
continue;
|
|
59
59
|
}
|
|
60
60
|
const observed = pool.watermarks.of(resident);
|
|
@@ -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,16 +14,15 @@
|
|
|
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
|
*/
|
|
24
21
|
|
|
25
22
|
import type {
|
|
26
23
|
WsTransport,
|
|
27
|
-
PresenceUpdate,
|
|
28
24
|
} from '@abloatai/transaction/transport/websocket';
|
|
25
|
+
import type { PresenceSession } from '@abloatai/transaction/presence';
|
|
29
26
|
import type {
|
|
30
27
|
ClaimOptions,
|
|
31
28
|
ClaimTarget,
|
|
@@ -79,12 +76,15 @@ function claimLabel(type: string, id: string, field?: string): string {
|
|
|
79
76
|
}
|
|
80
77
|
|
|
81
78
|
export interface ClaimStreamConfig {
|
|
82
|
-
/** Identity used to filter our own active claims out of `others`. */
|
|
83
|
-
participantId: string;
|
|
84
79
|
/** Where the coordination trace is logged. Defaults to silent. */
|
|
85
80
|
logger?: Logger;
|
|
86
81
|
}
|
|
87
82
|
|
|
83
|
+
export interface ClaimPresenceSource {
|
|
84
|
+
readonly others: readonly PresenceSession[];
|
|
85
|
+
onChange(listener: () => void): () => void;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
88
|
/**
|
|
89
89
|
* How long a heartbeat waits for its `claim_heartbeat_ack` before giving up
|
|
90
90
|
* as transient (the auto-heartbeat loop's next tick retries). Comfortably
|
|
@@ -107,14 +107,6 @@ export interface AttachableClaimStream extends ClaimStream {
|
|
|
107
107
|
*/
|
|
108
108
|
claim(target: PresenceTarget, opts?: ClaimOptions, claimId?: string): Claim;
|
|
109
109
|
attach(transport: ClaimTransport): void;
|
|
110
|
-
/**
|
|
111
|
-
* Seeds the participant identity once the host resolves it. The stream can
|
|
112
|
-
* be built before identity is known — a hosted client learns who it is
|
|
113
|
-
* from its credential's scope during connect — and until then the
|
|
114
|
-
* construction-time id (possibly empty) would let the participant's own
|
|
115
|
-
* claims into `others`. Idempotent; later frames filter on the new id.
|
|
116
|
-
*/
|
|
117
|
-
setParticipant(participant: { id: string }): void;
|
|
118
110
|
dispose(): void;
|
|
119
111
|
}
|
|
120
112
|
|
|
@@ -144,10 +136,8 @@ interface OwnGrant {
|
|
|
144
136
|
export function createClaimStream(
|
|
145
137
|
config: ClaimStreamConfig,
|
|
146
138
|
transport: ClaimTransport | null = null,
|
|
139
|
+
presence: ClaimPresenceSource | null = null,
|
|
147
140
|
): AttachableClaimStream {
|
|
148
|
-
// Mutable: the host seeds the resolved identity via `setParticipant` once
|
|
149
|
-
// it is known; the own-claim filter always reads the current value.
|
|
150
|
-
let participantId = config.participantId;
|
|
151
141
|
const logger = config.logger ?? noopLogger;
|
|
152
142
|
|
|
153
143
|
// ── State: others' open claims, keyed by claimId ───────────────
|
|
@@ -247,54 +237,7 @@ export function createClaimStream(
|
|
|
247
237
|
if (attached) return;
|
|
248
238
|
attached = t;
|
|
249
239
|
|
|
250
|
-
//
|
|
251
|
-
// active-claim set. Prune previous claims by holder, then
|
|
252
|
-
// re-add from the frame — the frame is authoritative for that
|
|
253
|
-
// participant's open claims at that moment.
|
|
254
|
-
unsubs.push(
|
|
255
|
-
t.subscribe('presence_update', (event: PresenceUpdate) => {
|
|
256
|
-
if (!event.userId) return;
|
|
257
|
-
if (event.userId === participantId) return;
|
|
258
|
-
|
|
259
|
-
let mutated = false;
|
|
260
|
-
|
|
261
|
-
if (event.kind === 'leave') {
|
|
262
|
-
for (const [id, claim] of activeByClaimId) {
|
|
263
|
-
if (claim.heldBy === event.userId) {
|
|
264
|
-
activeByClaimId.delete(id);
|
|
265
|
-
mutated = true;
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
if (mutated) notifyListeners();
|
|
269
|
-
return;
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
for (const [id, claim] of activeByClaimId) {
|
|
273
|
-
if (claim.heldBy === event.userId) {
|
|
274
|
-
activeByClaimId.delete(id);
|
|
275
|
-
mutated = true;
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
for (const claim of event.activeClaims ?? []) {
|
|
279
|
-
// Terminal-status entries (committed / expired / canceled) are
|
|
280
|
-
// one-shot "this claim ended" signals. The holder sweep above
|
|
281
|
-
// already removed the prior active entry; skipping the re-add
|
|
282
|
-
// drops it from `others`, which is what resolves a contender's
|
|
283
|
-
// `settled()`. Absent status means active (wire back-compat).
|
|
284
|
-
if (claim.status && claim.status !== 'active') continue;
|
|
285
|
-
observeForeignClaim(
|
|
286
|
-
event.userId,
|
|
287
|
-
claim,
|
|
288
|
-
event.participantKind,
|
|
289
|
-
event.isAgent,
|
|
290
|
-
);
|
|
291
|
-
mutated = true;
|
|
292
|
-
}
|
|
293
|
-
if (mutated) notifyListeners();
|
|
294
|
-
}),
|
|
295
|
-
);
|
|
296
|
-
|
|
297
|
-
// (2) Server-side rejection frames.
|
|
240
|
+
// Server-side rejection frames.
|
|
298
241
|
unsubs.push(
|
|
299
242
|
t.subscribe('claim_rejected', (rejection) => {
|
|
300
243
|
if (!rejection.claimId) return;
|
|
@@ -468,7 +411,7 @@ export function createClaimStream(
|
|
|
468
411
|
}
|
|
469
412
|
ownClaims.clear();
|
|
470
413
|
for (const claimId of [...pendingHeartbeats.keys()]) {
|
|
471
|
-
settleHeartbeat(claimId, ({ reject }) => reject(error));
|
|
414
|
+
settleHeartbeat(claimId, ({ reject }) => { reject(error); });
|
|
472
415
|
}
|
|
473
416
|
}),
|
|
474
417
|
);
|
|
@@ -476,6 +419,46 @@ export function createClaimStream(
|
|
|
476
419
|
|
|
477
420
|
if (transport) attach(transport);
|
|
478
421
|
|
|
422
|
+
const refreshPresenceClaims = (): void => {
|
|
423
|
+
if (presence === null) return;
|
|
424
|
+
activeByClaimId.clear();
|
|
425
|
+
for (const session of presence.others) {
|
|
426
|
+
for (const activity of session.activities) {
|
|
427
|
+
if (
|
|
428
|
+
activity.operation !== 'claim'
|
|
429
|
+
|| activity.source !== 'claim'
|
|
430
|
+
|| activity.target.id === undefined
|
|
431
|
+
) continue;
|
|
432
|
+
const claimId = activity.id.startsWith('claim:')
|
|
433
|
+
? activity.id.slice('claim:'.length)
|
|
434
|
+
: activity.id;
|
|
435
|
+
observeForeignClaim(
|
|
436
|
+
session.participant.id,
|
|
437
|
+
{
|
|
438
|
+
claimId,
|
|
439
|
+
entityType: activity.target.model,
|
|
440
|
+
entityId: activity.target.id,
|
|
441
|
+
...(activity.target.field !== undefined
|
|
442
|
+
? { field: activity.target.field }
|
|
443
|
+
: {}),
|
|
444
|
+
...(activity.target.fields !== undefined
|
|
445
|
+
? { fields: activity.target.fields }
|
|
446
|
+
: {}),
|
|
447
|
+
description: 'claim',
|
|
448
|
+
declaredAt: Date.parse(activity.startedAt),
|
|
449
|
+
expiresAt: Date.parse(activity.expiresAt),
|
|
450
|
+
},
|
|
451
|
+
session.participant.kind,
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
notifyListeners();
|
|
456
|
+
};
|
|
457
|
+
if (presence !== null) {
|
|
458
|
+
refreshPresenceClaims();
|
|
459
|
+
unsubs.push(presence.onChange(refreshPresenceClaims));
|
|
460
|
+
}
|
|
461
|
+
|
|
479
462
|
// ── Outbound ────────────────────────────────────────────────────
|
|
480
463
|
function sendBegin(claimId: string, claim: OwnClaim): void {
|
|
481
464
|
if (!attached?.isConnected()) return;
|
|
@@ -705,9 +688,6 @@ export function createClaimStream(
|
|
|
705
688
|
);
|
|
706
689
|
},
|
|
707
690
|
attach,
|
|
708
|
-
setParticipant(participant: { id: string }): void {
|
|
709
|
-
participantId = participant.id;
|
|
710
|
-
},
|
|
711
691
|
dispose(): void {
|
|
712
692
|
for (const off of unsubs) off();
|
|
713
693
|
unsubs.length = 0;
|
|
@@ -173,7 +173,9 @@ export function deduplicateDeltas(deltas: SyncDelta[]): SyncDelta[] {
|
|
|
173
173
|
|
|
174
174
|
let strictlyOrdered = true;
|
|
175
175
|
for (let index = 1; index < deltas.length; index += 1) {
|
|
176
|
-
|
|
176
|
+
const previous = deltas[index - 1];
|
|
177
|
+
const current = deltas[index];
|
|
178
|
+
if (!previous || !current || previous.id >= current.id) {
|
|
177
179
|
strictlyOrdered = false;
|
|
178
180
|
break;
|
|
179
181
|
}
|
|
@@ -400,10 +402,12 @@ export function sliceApplyChanges<T extends { readonly transactionId?: string }>
|
|
|
400
402
|
while (index < changes.length) {
|
|
401
403
|
// The indivisible unit starting here: one transaction's run, or a single
|
|
402
404
|
// untransacted change.
|
|
403
|
-
const
|
|
405
|
+
const change = changes[index];
|
|
406
|
+
if (!change) break;
|
|
407
|
+
const transactionId = change.transactionId;
|
|
404
408
|
let end = index + 1;
|
|
405
409
|
if (transactionId !== undefined) {
|
|
406
|
-
while (
|
|
410
|
+
while (changes[end]?.transactionId === transactionId) end += 1;
|
|
407
411
|
}
|
|
408
412
|
const groupSize = end - index;
|
|
409
413
|
if (current.length > 0 && current.length + groupSize > maxDeltas) {
|
|
@@ -459,9 +463,10 @@ async function flushDeltaBatchInner(
|
|
|
459
463
|
if (customDeltas.length > 0) {
|
|
460
464
|
runInAction(() => {
|
|
461
465
|
for (const delta of customDeltas) {
|
|
466
|
+
if (delta.data === null) continue;
|
|
462
467
|
const data = typeof delta.data === 'string'
|
|
463
468
|
? (JSON.parse(delta.data) as Record<string, unknown>)
|
|
464
|
-
:
|
|
469
|
+
: delta.data;
|
|
465
470
|
|
|
466
471
|
// 'C' (Covering) is treated identically to 'I' here — the client
|
|
467
472
|
// gained permission to see the entity, so we insert it into the
|
|
@@ -529,7 +534,7 @@ async function flushDeltaBatchInner(
|
|
|
529
534
|
// slice. Slices stay the atomicity unit; the budget only decides where
|
|
530
535
|
// the loop breathes.
|
|
531
536
|
let sliceStartedAt = performance.now();
|
|
532
|
-
for (
|
|
537
|
+
for (const [index, slice] of slices.entries()) {
|
|
533
538
|
if (index > 0 && performance.now() - sliceStartedAt > APPLY_YIELD_BUDGET_MS) {
|
|
534
539
|
pipelineDebug.phase = `apply-yield-${index}`;
|
|
535
540
|
pipelineDebug.applyYields += 1;
|
|
@@ -538,7 +543,6 @@ async function flushDeltaBatchInner(
|
|
|
538
543
|
}
|
|
539
544
|
pipelineDebug.phase = `apply-slice-${index}`;
|
|
540
545
|
pipelineDebug.applySlices += 1;
|
|
541
|
-
const slice = slices[index]!;
|
|
542
546
|
if (hasApplyPlugins) {
|
|
543
547
|
runStage(stagePlugins, 'apply', { changes: slice });
|
|
544
548
|
} else {
|
|
@@ -9,7 +9,6 @@ import type { SyncStatus } from '../storeContract.js';
|
|
|
9
9
|
import type {
|
|
10
10
|
BootstrapHint,
|
|
11
11
|
BootstrapDataEvent,
|
|
12
|
-
PresenceUpdate,
|
|
13
12
|
SyncWebSocket,
|
|
14
13
|
EventMap,
|
|
15
14
|
} from './SyncWebSocket.js';
|
|
@@ -31,7 +30,6 @@ export interface SocketEventHost<TCollaboration extends EventMap<TCollaboration>
|
|
|
31
30
|
applyDeltaFrame(deltas: SyncDelta[]): void;
|
|
32
31
|
handleBootstrapRequired(hint: BootstrapHint): void;
|
|
33
32
|
handleBootstrapData(data: BootstrapDataEvent): void;
|
|
34
|
-
handlePresenceUpdate(data: PresenceUpdate): void;
|
|
35
33
|
performCredentialRefresh(): Promise<'refreshed' | 'session_error' | 'network_error'>;
|
|
36
34
|
handleTerminalSessionError(error: Error): void;
|
|
37
35
|
nudgeReconnect(): void;
|
|
@@ -93,11 +91,6 @@ export function wireSocketEvents<TCollaboration extends EventMap<TCollaboration>
|
|
|
93
91
|
deps.handleBootstrapData(data);
|
|
94
92
|
});
|
|
95
93
|
|
|
96
|
-
const onPresenceUpdate = deps.syncWebSocket.subscribe('presence_update', (...args) => {
|
|
97
|
-
const data = args[0];
|
|
98
|
-
deps.handlePresenceUpdate(data);
|
|
99
|
-
});
|
|
100
|
-
|
|
101
94
|
// Error events
|
|
102
95
|
const onError = deps.syncWebSocket.subscribe('error', (error: Error) => {
|
|
103
96
|
if (error.message === 'Network is offline' || error.message === 'WebSocket connection failed') {
|
|
@@ -189,7 +182,7 @@ export function wireSocketEvents<TCollaboration extends EventMap<TCollaboration>
|
|
|
189
182
|
deps.disposers.push(
|
|
190
183
|
onConnected, onDisconnected, onReconnecting,
|
|
191
184
|
onDelta, onDeltaBatch, onBootstrapRequired,
|
|
192
|
-
onBootstrapData,
|
|
185
|
+
onBootstrapData,
|
|
193
186
|
onError, onSessionError, onHandshakeFailed, onReconnectFailed,
|
|
194
187
|
() => { deps.areaOfInterest.dispose(); },
|
|
195
188
|
);
|
|
@@ -35,11 +35,11 @@ export function createLocalMutationPort(
|
|
|
35
35
|
return {
|
|
36
36
|
updates,
|
|
37
37
|
applyCreate: (model, transaction) =>
|
|
38
|
-
track('optimistic:create', model, transaction),
|
|
38
|
+
{ track('optimistic:create', model, transaction); },
|
|
39
39
|
applyUpdate: (model, transaction) =>
|
|
40
|
-
track('optimistic:update', model, transaction),
|
|
40
|
+
{ track('optimistic:update', model, transaction); },
|
|
41
41
|
applyDelete: (model, transaction) =>
|
|
42
|
-
track('optimistic:delete', model, transaction),
|
|
42
|
+
{ track('optimistic:delete', model, transaction); },
|
|
43
43
|
rollback: (transaction, reason, error) => {
|
|
44
44
|
const optimistic = updates.get(transaction.id);
|
|
45
45
|
if (!optimistic) return Promise.resolve();
|
|
@@ -108,12 +108,8 @@ import { enqueueTransaction, type QueueCoalescingContext } from './queueCoalesci
|
|
|
108
108
|
import { processBatch, type BatchProcessingContext } from './batchProcessing.js';
|
|
109
109
|
import { handleFailure, type FailureHandlingContext } from './failureHandling.js';
|
|
110
110
|
import { handleConflict as resolveConflict, isPermanentError as classifyPermanentError, isDefinitiveRejection as classifyDefinitiveRejection, type ConflictResolutionContext } from './failurePolicy.js';
|
|
111
|
-
import { takeNextExecutionBatch as selectExecutionBatch
|
|
111
|
+
import { takeNextExecutionBatch as selectExecutionBatch } from './executionSelection.js';
|
|
112
112
|
import { scheduleProcessing as scheduleProcessingExternal, type ProcessingSchedulerContext } from './processingScheduler.js';
|
|
113
|
-
import {
|
|
114
|
-
drainPendingConfirmations,
|
|
115
|
-
type PendingDrainContext,
|
|
116
|
-
} from './pendingDrain.js';
|
|
117
113
|
import { restoreDurableCommits as restoreDurableCommitsExternal, type DurableCommitRestoreContext } from './durableCommitRestore.js';
|
|
118
114
|
|
|
119
115
|
// The queue is split across sibling modules (`commitPayload`,
|
|
@@ -244,6 +240,7 @@ export class MutationQueue extends EventEmitter {
|
|
|
244
240
|
}[] = [];
|
|
245
241
|
private persistenceStageScheduled = false;
|
|
246
242
|
private pendingDrainPromise: Promise<void> | null = null;
|
|
243
|
+
private modelProcessingPromise: Promise<void> | null = null;
|
|
247
244
|
|
|
248
245
|
private executionQueue: QueuedMutation[] = [];
|
|
249
246
|
private isProcessing = false;
|
|
@@ -496,33 +493,6 @@ export class MutationQueue extends EventEmitter {
|
|
|
496
493
|
};
|
|
497
494
|
}
|
|
498
495
|
|
|
499
|
-
private get pendingDrainContext(): PendingDrainContext {
|
|
500
|
-
return {
|
|
501
|
-
runtime: this.runtime,
|
|
502
|
-
config: { deltaConfirmationTimeout: this.config.deltaConfirmationTimeout },
|
|
503
|
-
store: this.store,
|
|
504
|
-
executionQueue: this.executionQueue,
|
|
505
|
-
optimisticUpdates: this.localMutationPort.updates,
|
|
506
|
-
assertDurableReplayOpen: () => { this.assertDurableReplayOpen(); },
|
|
507
|
-
processCommitLane: () => this.processCommitLane(),
|
|
508
|
-
takePendingDrainBatch: (pending) => this.takePendingDrainBatch(pending),
|
|
509
|
-
ensureCommitEnvelope: (batch) => this.ensureCommitEnvelope(batch),
|
|
510
|
-
ensureDerivedFields: (transaction) => { this.ensureDerivedFields(transaction); },
|
|
511
|
-
sourceMutationIdsFor: (batch) => this.sourceMutationIdsFor(batch),
|
|
512
|
-
sealDurableCommit: (input) => this.sealDurableCommit(input),
|
|
513
|
-
assertEnvelopeInsideReplayWindow: (envelope) => { this.assertEnvelopeInsideReplayWindow(envelope); },
|
|
514
|
-
parseMutationCommitResult: (value) => this.parseMutationCommitResult(value),
|
|
515
|
-
dispatchCommitBounded: (...args) => this.dispatchCommitBounded(...args),
|
|
516
|
-
persistDurableCommitAcceptance: (envelope, result) => this.persistDurableCommitAcceptance(envelope, result),
|
|
517
|
-
removeDurableCommit: (idempotencyKey) => this.removeDurableCommit(idempotencyKey),
|
|
518
|
-
scheduleReplicationLagTimeout: (transactionId, clientTxId, correlationId) => { this.scheduleReplicationLagTimeout(transactionId, clientTxId, correlationId); },
|
|
519
|
-
scheduleDeltaConfirmationTimeout: (transaction, timeoutMs) => { this.scheduleDeltaConfirmationTimeout(transaction, timeoutMs); },
|
|
520
|
-
enqueue: (transaction) => { this.enqueue(transaction); },
|
|
521
|
-
recentDeltaCorrelations: this.recentDeltaCorrelations,
|
|
522
|
-
emit: (event, payload) => this.emit(event, payload),
|
|
523
|
-
};
|
|
524
|
-
}
|
|
525
|
-
|
|
526
496
|
private get durableCommitRestoreContext(): DurableCommitRestoreContext {
|
|
527
497
|
return {
|
|
528
498
|
config: this.config,
|
|
@@ -1064,10 +1034,6 @@ export class MutationQueue extends EventEmitter {
|
|
|
1064
1034
|
return selected.batch;
|
|
1065
1035
|
}
|
|
1066
1036
|
|
|
1067
|
-
private takePendingDrainBatch(pending: QueuedMutation[]): QueuedMutation[] {
|
|
1068
|
-
return selectPendingDrainBatch(pending, this.config.maxBatchSize);
|
|
1069
|
-
}
|
|
1070
|
-
|
|
1071
1037
|
/**
|
|
1072
1038
|
* Resolvers for per-transaction `confirmation` promises. Populated in
|
|
1073
1039
|
* `attachConfirmation` at staging time, consumed by the constructor-time
|
|
@@ -1361,22 +1327,15 @@ export class MutationQueue extends EventEmitter {
|
|
|
1361
1327
|
}
|
|
1362
1328
|
|
|
1363
1329
|
private async drainPendingInternal(): Promise<void> {
|
|
1364
|
-
//
|
|
1365
|
-
//
|
|
1366
|
-
//
|
|
1367
|
-
//
|
|
1368
|
-
//
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
if (this.isProcessing) return;
|
|
1374
|
-
this.isProcessing = true;
|
|
1375
|
-
try {
|
|
1376
|
-
await drainPendingConfirmations(this.pendingDrainContext);
|
|
1377
|
-
} finally {
|
|
1378
|
-
this.isProcessing = false;
|
|
1379
|
-
if (this.executionQueue.length > 0) this.scheduleProcessing(true);
|
|
1330
|
+
// Explicit flushes and reconnects are merely another trigger for the one
|
|
1331
|
+
// model-mutation execution lane. A second sealing implementation can race
|
|
1332
|
+
// the scheduled lane, consume its journal sources, and later dispatch the
|
|
1333
|
+
// same transaction again. Move every staged row to the owned queue, then
|
|
1334
|
+
// drive the normal lane until the queue has handed off all current work.
|
|
1335
|
+
this.commitCreatedTransactions();
|
|
1336
|
+
await this.processCommitLane();
|
|
1337
|
+
while (this.executionQueue.length > 0 || this.modelProcessingPromise) {
|
|
1338
|
+
await this.processBatch();
|
|
1380
1339
|
}
|
|
1381
1340
|
}
|
|
1382
1341
|
async create(
|
|
@@ -1445,7 +1404,19 @@ export class MutationQueue extends EventEmitter {
|
|
|
1445
1404
|
}
|
|
1446
1405
|
|
|
1447
1406
|
private async processBatch(): Promise<void> {
|
|
1448
|
-
|
|
1407
|
+
if (this.modelProcessingPromise) {
|
|
1408
|
+
await this.modelProcessingPromise;
|
|
1409
|
+
if (this.executionQueue.length > 0) await this.processBatch();
|
|
1410
|
+
return;
|
|
1411
|
+
}
|
|
1412
|
+
const processing = processBatch(this.batchProcessingContext);
|
|
1413
|
+
const tracked = processing.finally(() => {
|
|
1414
|
+
if (this.modelProcessingPromise === tracked) {
|
|
1415
|
+
this.modelProcessingPromise = null;
|
|
1416
|
+
}
|
|
1417
|
+
});
|
|
1418
|
+
this.modelProcessingPromise = tracked;
|
|
1419
|
+
await tracked;
|
|
1449
1420
|
}
|
|
1450
1421
|
|
|
1451
1422
|
private rememberDeltaCorrelation(correlationId: string, syncId: number): void {
|
|
@@ -133,16 +133,31 @@ export async function processBatch(ctx: BatchProcessingContext): Promise<void> {
|
|
|
133
133
|
if (batchOps.length > 0) {
|
|
134
134
|
let dispatchStarted = false;
|
|
135
135
|
try {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
136
|
+
let durableEnvelope = batch[0]?.durableEnvelope;
|
|
137
|
+
if (durableEnvelope) {
|
|
138
|
+
const mismatched = batch.some(
|
|
139
|
+
(transaction) =>
|
|
140
|
+
transaction.durableEnvelope?.idempotencyKey !==
|
|
141
|
+
durableEnvelope?.idempotencyKey,
|
|
142
|
+
);
|
|
143
|
+
if (mismatched || durableEnvelope.idempotencyKey !== commitIdempotencyKey) {
|
|
144
|
+
throw new Error('Cannot replay a model batch with inconsistent durable envelopes');
|
|
145
|
+
}
|
|
146
|
+
} else {
|
|
147
|
+
durableEnvelope = await ctx.sealDurableCommit({
|
|
148
|
+
idempotencyKey: commitIdempotencyKey,
|
|
149
|
+
origin: 'model_batch',
|
|
150
|
+
operations: batchOps.map(({ op }) => op),
|
|
151
|
+
sourceMutationIds: ctx.sourceMutationIdsFor(batch),
|
|
152
|
+
commitOptions: { reads: collectQueuedReads(batch) },
|
|
153
|
+
createdAt: Math.min(...batch.map((transaction) => transaction.createdAt)),
|
|
154
|
+
sealedAt: batch[0]?.commitEnvelope?.sealedAt ?? Date.now(),
|
|
155
|
+
sequence: batch[0]?.commitEnvelope?.sequence,
|
|
156
|
+
});
|
|
157
|
+
for (const transaction of batch) {
|
|
158
|
+
transaction.durableEnvelope = durableEnvelope;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
146
161
|
const operations = durableEnvelope.operations;
|
|
147
162
|
|
|
148
163
|
// Capture lastSyncId from the server response for threshold-based
|
|
@@ -14,7 +14,10 @@ import type { RuntimeContext } from '../../RuntimeContext.js';
|
|
|
14
14
|
import { MutationOperationType } from '@abloatai/transaction/types';
|
|
15
15
|
import { snapshotJsonValue } from '@abloatai/transaction/utils/json';
|
|
16
16
|
import type { MutationOptions, WriteOptions } from '../../interfaces/index.js';
|
|
17
|
-
import type {
|
|
17
|
+
import type {
|
|
18
|
+
CommitEnvelopeMember,
|
|
19
|
+
DurableCommitEnvelope,
|
|
20
|
+
} from '@abloatai/transaction/commit';
|
|
18
21
|
|
|
19
22
|
export interface UserContext {
|
|
20
23
|
userId: string;
|
|
@@ -123,6 +126,13 @@ export interface QueuedMutation {
|
|
|
123
126
|
* re-batching its operations under a fresh key.
|
|
124
127
|
*/
|
|
125
128
|
commitEnvelope?: CommitEnvelopeMember;
|
|
129
|
+
/**
|
|
130
|
+
* The exact durable request produced by the first successful local seal.
|
|
131
|
+
* Runtime retries dispatch this object directly. Asking the outbox to seal
|
|
132
|
+
* again is both unnecessary and unsafe after a concurrent authoritative
|
|
133
|
+
* completion has begun cleaning up the stored envelope.
|
|
134
|
+
*/
|
|
135
|
+
durableEnvelope?: DurableCommitEnvelope;
|
|
126
136
|
/** Pending-mutation journal entries atomically consumed by this envelope. */
|
|
127
137
|
sourceMutationIds?: string[];
|
|
128
138
|
/** Completed locally without a server operation; no sync echo will arrive. */
|
|
@@ -158,10 +158,10 @@ export function dispatchCommitBounded(
|
|
|
158
158
|
const timeoutMs = ctx.config.commitDispatchTimeoutMs;
|
|
159
159
|
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return dispatched;
|
|
160
160
|
return new Promise((resolve, reject) => {
|
|
161
|
-
const timer = setTimeout(() => reject(new AbloConnectionError(
|
|
161
|
+
const timer = setTimeout(() => { reject(new AbloConnectionError(
|
|
162
162
|
'The mutation transport did not acknowledge the commit in time; its outcome remains pending and is safe to retry.',
|
|
163
163
|
{ code: 'commit_no_result' },
|
|
164
|
-
)), timeoutMs);
|
|
164
|
+
)); }, timeoutMs);
|
|
165
165
|
dispatched.then(
|
|
166
166
|
(value) => { clearTimeout(timer); resolve(value); },
|
|
167
167
|
(error) => { clearTimeout(timer); reject(error instanceof Error ? error : new Error(String(error))); },
|
|
@@ -4,8 +4,12 @@ export function takeNextExecutionBatch(
|
|
|
4
4
|
executionQueue: QueuedMutation[],
|
|
5
5
|
maxBatchSize: number,
|
|
6
6
|
): { batch: QueuedMutation[]; remaining: QueuedMutation[] } {
|
|
7
|
+
// Cancellation, delta confirmation, and failure settlement can all make a
|
|
8
|
+
// queued reference terminal before its scheduler callback runs. Terminal or
|
|
9
|
+
// currently executing rows have no authority to cross the dispatch boundary.
|
|
10
|
+
const pendingQueue = executionQueue.filter((tx) => tx.status === 'pending');
|
|
7
11
|
const retryGroups = new Map<string, Map<string, QueuedMutation>>();
|
|
8
|
-
for (const tx of
|
|
12
|
+
for (const tx of pendingQueue) {
|
|
9
13
|
const envelope = tx.commitEnvelope;
|
|
10
14
|
if (!envelope) continue;
|
|
11
15
|
const group = retryGroups.get(envelope.idempotencyKey) ?? new Map<string, QueuedMutation>();
|
|
@@ -16,27 +20,17 @@ export function takeNextExecutionBatch(
|
|
|
16
20
|
const members = [...byId.values()];
|
|
17
21
|
const expectedCount = members[0]?.commitEnvelope?.operationCount;
|
|
18
22
|
if (expectedCount === undefined || members.length !== expectedCount) continue;
|
|
19
|
-
const remaining =
|
|
23
|
+
const remaining = pendingQueue.filter((tx) => tx.commitEnvelope?.idempotencyKey !== idempotencyKey);
|
|
20
24
|
members.sort((a, b) => (a.commitEnvelope?.operationIndex ?? 0) - (b.commitEnvelope?.operationIndex ?? 0));
|
|
21
25
|
return { batch: members, remaining };
|
|
22
26
|
}
|
|
23
|
-
const fresh =
|
|
27
|
+
const fresh = pendingQueue.filter((tx) => !tx.commitEnvelope);
|
|
24
28
|
const firstFresh = fresh[0];
|
|
25
|
-
if (!firstFresh) return { batch: [], remaining:
|
|
29
|
+
if (!firstFresh) return { batch: [], remaining: pendingQueue };
|
|
26
30
|
const explicitIndex = fresh.findIndex((tx) => typeof tx.writeOptions?.idempotencyKey === 'string');
|
|
27
31
|
const selected = explicitIndex === 0
|
|
28
32
|
? [firstFresh]
|
|
29
33
|
: fresh.slice(0, Math.min(maxBatchSize, explicitIndex > 0 ? explicitIndex : fresh.length));
|
|
30
34
|
const selectedIds = new Set(selected.map((tx) => tx.id));
|
|
31
|
-
return { batch: selected, remaining:
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export function takePendingDrainBatch(pending: QueuedMutation[], maxBatchSize: number): QueuedMutation[] {
|
|
35
|
-
const first = pending[0];
|
|
36
|
-
if (!first) return [];
|
|
37
|
-
const envelope = first.commitEnvelope;
|
|
38
|
-
if (envelope) return pending.filter((tx) => tx.commitEnvelope?.idempotencyKey === envelope.idempotencyKey);
|
|
39
|
-
if (typeof first.writeOptions?.idempotencyKey === 'string') 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));
|
|
35
|
+
return { batch: selected, remaining: pendingQueue.filter((tx) => !selectedIds.has(tx.id)) };
|
|
42
36
|
}
|
|
@@ -44,6 +44,16 @@ export async function handleFailure(
|
|
|
44
44
|
transaction: QueuedMutation,
|
|
45
45
|
error: Error,
|
|
46
46
|
): Promise<void> {
|
|
47
|
+
// The dispatch owner may lose its acknowledgement while an authoritative
|
|
48
|
+
// delta concurrently completes the same transaction. Completion is
|
|
49
|
+
// terminal: a late catch path must not turn that row back into `pending`
|
|
50
|
+
// and schedule a second seal after its durable sources were cleaned up.
|
|
51
|
+
if (
|
|
52
|
+
transaction.status === 'completed' ||
|
|
53
|
+
transaction.status === 'failed' ||
|
|
54
|
+
transaction.status === 'rolled_back' ||
|
|
55
|
+
transaction.status === 'awaiting_delta'
|
|
56
|
+
) return;
|
|
47
57
|
transaction.attempts++;
|
|
48
58
|
|
|
49
59
|
// Check whether this is a permanent error that should not be retried.
|
|
@@ -43,9 +43,9 @@ export function createLocalMutationPort(emitter: OptimisticEmitter): LocalMutati
|
|
|
43
43
|
const updates = new Map<string, OptimisticUpdateEntry>();
|
|
44
44
|
return {
|
|
45
45
|
updates,
|
|
46
|
-
applyCreate: (model, transaction) => applyOptimisticCreate(updates, emitter, model, transaction),
|
|
47
|
-
applyUpdate: (model, transaction) => applyOptimisticUpdate(updates, emitter, model, transaction),
|
|
48
|
-
applyDelete: (model, transaction) => applyOptimisticDelete(updates, emitter, model, transaction),
|
|
46
|
+
applyCreate: (model, transaction) => { applyOptimisticCreate(updates, emitter, model, transaction); },
|
|
47
|
+
applyUpdate: (model, transaction) => { applyOptimisticUpdate(updates, emitter, model, transaction); },
|
|
48
|
+
applyDelete: (model, transaction) => { applyOptimisticDelete(updates, emitter, model, transaction); },
|
|
49
49
|
rollback: (transaction, reason, error) => rollbackOptimistic(updates, emitter, transaction, reason, error),
|
|
50
50
|
};
|
|
51
51
|
}
|