@abloatai/humans 0.59.2 → 0.61.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/README.md +1 -1
- package/dist/Ablo.d.ts +2 -10
- package/dist/Ablo.js +0 -1
- package/dist/client.d.ts +1 -48
- package/dist/humans.d.ts +1 -1
- package/dist/local/BaseSyncedStore.d.ts +5 -5
- package/dist/local/BaseSyncedStore.js +7 -7
- package/dist/local/Database.d.ts +2 -2
- package/dist/local/LazyReferenceCollection.d.ts +1 -1
- 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 +12 -36
- package/dist/local/SyncClient.js +50 -108
- package/dist/local/client/createModelOperations.d.ts +3 -27
- package/dist/local/client/createModelOperations.js +20 -21
- package/dist/local/client/options.d.ts +14 -39
- package/dist/local/client/reactiveEngine.d.ts +3 -9
- package/dist/local/client/reactiveEngine.js +6 -151
- package/dist/local/client/storeLifecycle.js +5 -1
- package/dist/local/fileUploads.d.ts +27 -0
- package/dist/local/fileUploads.js +55 -0
- package/dist/local/storeContract.d.ts +5 -5
- package/dist/local/stores/syncAction.d.ts +4 -4
- package/dist/local/sync/contextOnChange.js +1 -1
- package/dist/local/sync/createClaimStream.js +1 -1
- package/dist/local/sync/credentialLifecycle.d.ts +4 -5
- package/dist/local/sync/credentialLifecycle.js +4 -5
- package/dist/local/sync/deltaPipeline.js +12 -6
- package/dist/local/sync/schemas.d.ts +10 -10
- package/dist/local/sync/scopeGroups.d.ts +11 -0
- package/dist/local/sync/scopeGroups.js +75 -0
- package/dist/local/sync/wsFrameHandlers.d.ts +1 -1
- 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 +9 -2
- 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 +15 -15
- package/dist/react/AbloProvider.d.ts +11 -86
- package/dist/react/AbloProvider.js +11 -163
- package/dist/react/ClientSideSuspense.d.ts +1 -1
- package/dist/react/DefaultFallback.d.ts +1 -1
- package/dist/react/createAbloReact.js +1 -1
- package/dist/react/useErrorListener.js +1 -1
- package/dist/react/useMutationFailureListener.js +1 -1
- package/dist/react.d.ts +1 -1
- package/dist/react.js +1 -1
- package/dist/surface.d.ts +3 -3
- package/dist/surface.js +1 -4
- package/package.json +3 -3
- package/src/Ablo.ts +5 -17
- package/src/client.ts +0 -51
- package/src/local/BaseSyncedStore.ts +14 -14
- 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 +73 -140
- package/src/local/client/createModelOperations.ts +30 -64
- package/src/local/client/options.ts +20 -43
- package/src/local/client/reactiveEngine.ts +7 -179
- package/src/local/client/storeLifecycle.ts +6 -1
- package/src/local/fileUploads.ts +97 -0
- package/src/local/storeContract.ts +5 -5
- package/src/local/sync/contextOnChange.ts +1 -1
- package/src/local/sync/createClaimStream.ts +1 -1
- package/src/local/sync/credentialLifecycle.ts +4 -5
- package/src/local/sync/deltaPipeline.ts +10 -6
- package/src/local/sync/scopeGroups.ts +91 -0
- package/src/local/sync/wsFrameHandlers.ts +0 -1
- 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/react/AbloProvider.tsx +17 -249
- package/src/react/useErrorListener.ts +1 -1
- package/src/react/useMutationFailureListener.ts +1 -1
- package/src/react.ts +1 -5
- package/src/surface.ts +1 -4
- package/dist/local/sync/participants.d.ts +0 -132
- package/dist/local/sync/participants.js +0 -342
- package/dist/local/transactions/mutations/pendingDrain.d.ts +0 -33
- package/dist/local/transactions/mutations/pendingDrain.js +0 -117
- package/src/local/sync/participants.ts +0 -564
- package/src/local/transactions/mutations/pendingDrain.ts +0 -169
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/** File-upload behavior owned beneath the SyncClient boundary. */
|
|
2
|
+
|
|
3
|
+
import { AbloAuthenticationError } from '@abloatai/transaction/errors';
|
|
4
|
+
import type { RuntimeContext } from './RuntimeContext.js';
|
|
5
|
+
import { Model } from './Model.js';
|
|
6
|
+
import { InstanceCache, ModelScope } from './InstanceCache.js';
|
|
7
|
+
import type { MutationQueue } from './transactions/mutations/MutationQueue.js';
|
|
8
|
+
|
|
9
|
+
export interface FileUploadOptions {
|
|
10
|
+
readonly id: string;
|
|
11
|
+
readonly attachableType: string;
|
|
12
|
+
readonly attachableId: string;
|
|
13
|
+
readonly metadata?: Record<string, unknown>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface BatchFileUploadOptions {
|
|
17
|
+
readonly ids: string[];
|
|
18
|
+
readonly attachableType: string;
|
|
19
|
+
readonly attachableId: string;
|
|
20
|
+
readonly metadata?: Record<string, unknown>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface FileUploadContext {
|
|
24
|
+
readonly userId: string | null;
|
|
25
|
+
readonly organizationId: string | null;
|
|
26
|
+
readonly mutationQueue: MutationQueue;
|
|
27
|
+
readonly objectPool: InstanceCache;
|
|
28
|
+
readonly observability: RuntimeContext['observability'];
|
|
29
|
+
readonly notifyCreated: (model: Model) => void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function authenticatedContext(context: FileUploadContext): {
|
|
33
|
+
readonly userId: string;
|
|
34
|
+
readonly organizationId: string;
|
|
35
|
+
} {
|
|
36
|
+
if (!context.userId || !context.organizationId) {
|
|
37
|
+
throw new AbloAuthenticationError('Authentication required for file uploads', {
|
|
38
|
+
code: 'file_upload_auth_required',
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
return { userId: context.userId, organizationId: context.organizationId };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function acceptUploadedModel(
|
|
45
|
+
context: FileUploadContext,
|
|
46
|
+
data: Record<string, unknown>,
|
|
47
|
+
): Model | null {
|
|
48
|
+
const model = context.objectPool.createFromData(data);
|
|
49
|
+
if (!model) return null;
|
|
50
|
+
context.objectPool.add(model, ModelScope.live);
|
|
51
|
+
context.notifyCreated(model);
|
|
52
|
+
return model;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function uploadFile(
|
|
56
|
+
context: FileUploadContext,
|
|
57
|
+
file: File,
|
|
58
|
+
options: FileUploadOptions,
|
|
59
|
+
): Promise<Model | null> {
|
|
60
|
+
const identity = authenticatedContext(context);
|
|
61
|
+
try {
|
|
62
|
+
const result = await context.mutationQueue.uploadAttachment(file, {
|
|
63
|
+
id: options.id,
|
|
64
|
+
attachableType: options.attachableType,
|
|
65
|
+
attachableId: options.attachableId,
|
|
66
|
+
metadata: options.metadata,
|
|
67
|
+
}, identity);
|
|
68
|
+
return result
|
|
69
|
+
? acceptUploadedModel(context, { id: options.id, ...result })
|
|
70
|
+
: null;
|
|
71
|
+
} catch (error) {
|
|
72
|
+
context.observability.captureMutationFailure({
|
|
73
|
+
context: 'file-upload',
|
|
74
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
75
|
+
});
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function batchUploadFiles(
|
|
81
|
+
context: FileUploadContext,
|
|
82
|
+
files: File[],
|
|
83
|
+
options: BatchFileUploadOptions,
|
|
84
|
+
): Promise<Model[]> {
|
|
85
|
+
const identity = authenticatedContext(context);
|
|
86
|
+
const items = options.ids.map((id) => ({
|
|
87
|
+
id,
|
|
88
|
+
attachableType: options.attachableType,
|
|
89
|
+
attachableId: options.attachableId,
|
|
90
|
+
metadata: options.metadata,
|
|
91
|
+
}));
|
|
92
|
+
const results = await context.mutationQueue.batchUploadAttachments(files, items, identity);
|
|
93
|
+
return results.flatMap((result) => {
|
|
94
|
+
const model = acceptUploadedModel(context, { ...result });
|
|
95
|
+
return model ? [model] : [];
|
|
96
|
+
});
|
|
97
|
+
}
|
|
@@ -14,7 +14,7 @@ import type { Model } from './Model.js';
|
|
|
14
14
|
import type { ModelScope } from '@abloatai/transaction/types';
|
|
15
15
|
import type { QueryView, QueryViewOptions } from './views/QueryView.js';
|
|
16
16
|
import type { ViewRegistry } from './views/ViewRegistry.js';
|
|
17
|
-
import type {
|
|
17
|
+
import type { GroupScope } from './sync/scopeGroups.js';
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
20
|
* A snapshot of the client's synchronization state, shaped for binding to UI.
|
|
@@ -132,10 +132,10 @@ export interface SyncStoreContract {
|
|
|
132
132
|
* read subscriptions and write claims always agree on which group they refer
|
|
133
133
|
* to. These are optional and do nothing until the connection is open.
|
|
134
134
|
*/
|
|
135
|
-
enterScope?(scope:
|
|
136
|
-
leaveScope?(scope:
|
|
137
|
-
pinScope?(scope:
|
|
138
|
-
unpinScope?(scope:
|
|
135
|
+
enterScope?(scope: GroupScope, opts?: { hydrate?: boolean }): Promise<void>;
|
|
136
|
+
leaveScope?(scope: GroupScope): Promise<void>;
|
|
137
|
+
pinScope?(scope: GroupScope): Promise<void>;
|
|
138
|
+
unpinScope?(scope: GroupScope): Promise<void>;
|
|
139
139
|
/**
|
|
140
140
|
* The full reactive {@link SyncStatus} record. The `useSyncStatus()` hook
|
|
141
141
|
* reads its fields — `state`, `progress`, `pendingChanges`, `isSessionError`,
|
|
@@ -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);
|
|
@@ -468,7 +468,7 @@ export function createClaimStream(
|
|
|
468
468
|
}
|
|
469
469
|
ownClaims.clear();
|
|
470
470
|
for (const claimId of [...pendingHeartbeats.keys()]) {
|
|
471
|
-
settleHeartbeat(claimId, ({ reject }) => reject(error));
|
|
471
|
+
settleHeartbeat(claimId, ({ reject }) => { reject(error); });
|
|
472
472
|
}
|
|
473
473
|
}),
|
|
474
474
|
);
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* stay unchanged.
|
|
2
|
+
* The shared session subsystem owns renewal for both browser and agent
|
|
3
|
+
* sessions. This local boundary keeps the reactive store pointed downward at
|
|
4
|
+
* that one lifecycle implementation.
|
|
6
5
|
*/
|
|
7
6
|
|
|
8
7
|
export {
|
|
@@ -15,4 +14,4 @@ export {
|
|
|
15
14
|
type CredentialRefreshResult,
|
|
16
15
|
type CredentialRefresher,
|
|
17
16
|
type CredentialLifecycleContext,
|
|
18
|
-
} from '@abloatai/transaction/
|
|
17
|
+
} from '@abloatai/transaction/sessions';
|
|
@@ -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 {
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { ClaimTarget } from '@abloatai/transaction/types/streams';
|
|
2
|
+
import type { Schema } from '@abloatai/transaction/schema/schema';
|
|
3
|
+
import { scopeKindOf, type ModelDef } from '@abloatai/transaction/schema/model';
|
|
4
|
+
|
|
5
|
+
/** A schema-shaped selector used to narrow connection groups and presence reads. */
|
|
6
|
+
export type GroupScope =
|
|
7
|
+
| ClaimTarget
|
|
8
|
+
| readonly ClaimTarget[]
|
|
9
|
+
| string
|
|
10
|
+
| readonly string[]
|
|
11
|
+
| { readonly syncGroup: string }
|
|
12
|
+
| { readonly syncGroups: readonly string[] }
|
|
13
|
+
| Record<string, string | readonly string[] | undefined>;
|
|
14
|
+
|
|
15
|
+
/** Resolve an application-shaped scope into the wire groups owned by the schema. */
|
|
16
|
+
export function resolveScopeGroups(
|
|
17
|
+
scope: GroupScope | undefined,
|
|
18
|
+
schema?: Schema,
|
|
19
|
+
): string[] {
|
|
20
|
+
if (!scope) return [];
|
|
21
|
+
if (typeof scope === 'string') return [scope];
|
|
22
|
+
if (Array.isArray(scope)) {
|
|
23
|
+
const groups: string[] = [];
|
|
24
|
+
for (const entry of scope as readonly unknown[]) {
|
|
25
|
+
if (typeof entry === 'string') groups.push(entry);
|
|
26
|
+
else if (isEntityScope(entry)) groups.push(groupFromEntityRef(entry, schema));
|
|
27
|
+
}
|
|
28
|
+
return groups;
|
|
29
|
+
}
|
|
30
|
+
const direct = scope as { syncGroup?: unknown; syncGroups?: unknown };
|
|
31
|
+
if (isEntityScope(scope)) return [groupFromEntityRef(scope, schema)];
|
|
32
|
+
if (typeof direct.syncGroup === 'string') return [direct.syncGroup];
|
|
33
|
+
if (Array.isArray(direct.syncGroups)) {
|
|
34
|
+
return direct.syncGroups.filter((group): group is string => typeof group === 'string');
|
|
35
|
+
}
|
|
36
|
+
const groups: string[] = [];
|
|
37
|
+
for (const [key, value] of Object.entries(scope) as [string, unknown][]) {
|
|
38
|
+
if (value === undefined) continue;
|
|
39
|
+
if (Array.isArray(value)) {
|
|
40
|
+
for (const id of value as unknown[]) {
|
|
41
|
+
if (typeof id === 'string') groups.push(groupFromSchemaKey(key, id, schema));
|
|
42
|
+
}
|
|
43
|
+
} else if (typeof value === 'string') {
|
|
44
|
+
groups.push(groupFromSchemaKey(key, value, schema));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return groups;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function groupFromEntityRef(ref: ClaimTarget, schema?: Schema): string {
|
|
51
|
+
const match = findModelForEntityRef(ref, schema);
|
|
52
|
+
const kind = match
|
|
53
|
+
? groupKindForModel(match.def, match.key)
|
|
54
|
+
: ref.type.toLowerCase();
|
|
55
|
+
return `${kind}:${ref.id}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function groupFromSchemaKey(schemaKey: string, id: string, schema?: Schema): string {
|
|
59
|
+
const def = schema?.models[schemaKey];
|
|
60
|
+
const kind = def ? groupKindForModel(def, schemaKey) : schemaKey.toLowerCase();
|
|
61
|
+
return `${kind}:${id}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function groupKindForModel(def: ModelDef, key: string): string {
|
|
65
|
+
return scopeKindOf(def, key) ?? (def.typename ?? key).toLowerCase();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function findModelForEntityRef(
|
|
69
|
+
ref: ClaimTarget,
|
|
70
|
+
schema?: Schema,
|
|
71
|
+
): { key: string; def: ModelDef } | null {
|
|
72
|
+
if (!schema?.models) return null;
|
|
73
|
+
const wanted = ref.type.toLowerCase();
|
|
74
|
+
for (const [key, def] of Object.entries(schema.models) as [string, ModelDef][]) {
|
|
75
|
+
const typename = def.typename ?? key;
|
|
76
|
+
if (typename.toLowerCase() === wanted || key.toLowerCase() === wanted) {
|
|
77
|
+
return { key, def };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function isEntityScope(scope: unknown): scope is ClaimTarget {
|
|
84
|
+
return (
|
|
85
|
+
typeof scope === 'object' &&
|
|
86
|
+
scope !== null &&
|
|
87
|
+
!Array.isArray(scope) &&
|
|
88
|
+
typeof (scope as { type?: unknown }).type === 'string' &&
|
|
89
|
+
typeof (scope as { id?: unknown }).id === 'string'
|
|
90
|
+
);
|
|
91
|
+
}
|
|
@@ -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
|
}
|
|
@@ -12,6 +12,12 @@ export interface QueueCoalescingContext {
|
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
export function enqueueTransaction(ctx: QueueCoalescingContext, transaction: QueuedMutation): void {
|
|
15
|
+
// Only the pending state may cross into the execution owner. A late timer,
|
|
16
|
+
// reconnect callback, or stale staging callback must not resurrect a row
|
|
17
|
+
// that is already executing or terminal, and repeated triggers must not put
|
|
18
|
+
// the same source mutation into the queue twice.
|
|
19
|
+
if (transaction.status !== 'pending') return;
|
|
20
|
+
if (ctx.executionQueue.some((candidate) => candidate.id === transaction.id)) return;
|
|
15
21
|
ctx.ensureDerivedFields(transaction);
|
|
16
22
|
const modelKey = `${transaction.modelName}:${transaction.modelId}`;
|
|
17
23
|
if (transaction.type === 'update' && transaction.attempts === 0 && !transaction.commitEnvelope) {
|