@abloatai/humans 0.60.0 → 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/dist/local/BaseSyncedStore.js +5 -5
- 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 +5 -29
- package/dist/local/SyncClient.js +26 -99
- package/dist/local/client/createModelOperations.js +6 -4
- package/dist/local/fileUploads.d.ts +27 -0
- package/dist/local/fileUploads.js +55 -0
- package/dist/local/stores/syncAction.d.ts +1 -1
- package/dist/local/sync/contextOnChange.js +1 -1
- package/dist/local/sync/createClaimStream.js +1 -1
- package/dist/local/sync/deltaPipeline.js +12 -6
- package/dist/local/sync/schemas.d.ts +2 -2
- package/dist/local/transactions/localMutation.js +3 -3
- package/dist/local/transactions/mutations/MutationQueue.d.ts +1 -2
- 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/react/useErrorListener.js +1 -1
- package/dist/react/useMutationFailureListener.js +1 -1
- package/package.json +3 -4
- package/src/local/BaseSyncedStore.ts +5 -5
- 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/createModelOperations.ts +9 -6
- package/src/local/fileUploads.ts +97 -0
- package/src/local/sync/contextOnChange.ts +1 -1
- package/src/local/sync/createClaimStream.ts +1 -1
- package/src/local/sync/deltaPipeline.ts +10 -6
- 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/useErrorListener.ts +1 -1
- package/src/react/useMutationFailureListener.ts +1 -1
- package/dist/local/transactions/mutations/pendingDrain.d.ts +0 -33
- package/dist/local/transactions/mutations/pendingDrain.js +0 -117
- package/src/local/transactions/mutations/pendingDrain.ts +0 -169
package/src/local/SyncClient.ts
CHANGED
|
@@ -18,7 +18,7 @@ import { deepEqual, snapshotJsonValue } from '@abloatai/transaction/utils/json';
|
|
|
18
18
|
import { LoadStrategy } from '@abloatai/transaction/types';
|
|
19
19
|
import { globalRuntime } from './context.js';
|
|
20
20
|
import type { RuntimeContext } from './RuntimeContext.js';
|
|
21
|
-
import {
|
|
21
|
+
import { AbloError, AbloValidationError } from '@abloatai/transaction/errors';
|
|
22
22
|
import { EventEmitter } from 'events';
|
|
23
23
|
import { NetworkMonitor } from './NetworkMonitor.js';
|
|
24
24
|
import {
|
|
@@ -51,9 +51,16 @@ import {
|
|
|
51
51
|
type SyncState,
|
|
52
52
|
} from './syncClientTypes.js';
|
|
53
53
|
import type { BootstrapSnapshot } from './syncClientTypes.js';
|
|
54
|
+
import {
|
|
55
|
+
batchUploadFiles,
|
|
56
|
+
uploadFile,
|
|
57
|
+
type BatchFileUploadOptions,
|
|
58
|
+
type FileUploadContext,
|
|
59
|
+
type FileUploadOptions,
|
|
60
|
+
} from './fileUploads.js';
|
|
54
61
|
|
|
55
62
|
export type { BootstrapSnapshot, RehydrationStats } from './syncClientTypes.js';
|
|
56
|
-
|
|
63
|
+
const ignoreSeparatelyObservedMutationFailure = (): undefined => undefined;
|
|
57
64
|
export class SyncClient extends EventEmitter {
|
|
58
65
|
private objectPool: InstanceCache;
|
|
59
66
|
private database: Database;
|
|
@@ -1057,7 +1064,7 @@ export class SyncClient extends EventEmitter {
|
|
|
1057
1064
|
if (capturedChanges === undefined) return;
|
|
1058
1065
|
|
|
1059
1066
|
this.objectPool.upsert(model, ModelScope.live);
|
|
1060
|
-
this.stageMutation('update', model, capturedChanges);
|
|
1067
|
+
void this.stageMutation('update', model, capturedChanges);
|
|
1061
1068
|
this.notifyObservers({
|
|
1062
1069
|
type: 'update',
|
|
1063
1070
|
modelType: model.getModelName(),
|
|
@@ -1080,128 +1087,30 @@ export class SyncClient extends EventEmitter {
|
|
|
1080
1087
|
return this.mutate('delete', model, () => this.objectPool.remove(model.id), options);
|
|
1081
1088
|
}
|
|
1082
1089
|
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
* the {@link MutationQueue}, and a model is built from the server's
|
|
1086
|
-
* response and added to the pool.
|
|
1087
|
-
*/
|
|
1088
|
-
async uploadFile(
|
|
1089
|
-
file: File,
|
|
1090
|
-
options: {
|
|
1091
|
-
id: string;
|
|
1092
|
-
attachableType: string;
|
|
1093
|
-
attachableId: string;
|
|
1094
|
-
metadata?: Record<string, unknown>;
|
|
1095
|
-
}
|
|
1096
|
-
): Promise<Model | null> {
|
|
1097
|
-
if (!this.userId || !this.organizationId) {
|
|
1098
|
-
throw new AbloAuthenticationError('Authentication required for file uploads', {
|
|
1099
|
-
code: 'file_upload_auth_required',
|
|
1100
|
-
});
|
|
1101
|
-
}
|
|
1102
|
-
|
|
1103
|
-
try {
|
|
1104
|
-
// Use MutationQueue to handle the upload mutation
|
|
1105
|
-
const result = await this.mutationQueue.uploadAttachment(
|
|
1106
|
-
file,
|
|
1107
|
-
{
|
|
1108
|
-
id: options.id,
|
|
1109
|
-
attachableType: options.attachableType,
|
|
1110
|
-
attachableId: options.attachableId,
|
|
1111
|
-
metadata: options.metadata,
|
|
1112
|
-
},
|
|
1113
|
-
{
|
|
1114
|
-
userId: this.userId,
|
|
1115
|
-
organizationId: this.organizationId,
|
|
1116
|
-
}
|
|
1117
|
-
);
|
|
1118
|
-
|
|
1119
|
-
if (result) {
|
|
1120
|
-
// Create model from response using ModelRegistry (generic — no concrete class import)
|
|
1121
|
-
const model = this.objectPool.createFromData({
|
|
1122
|
-
id: options.id,
|
|
1123
|
-
...result,
|
|
1124
|
-
});
|
|
1125
|
-
|
|
1126
|
-
if (model) {
|
|
1127
|
-
this.objectPool.add(model, ModelScope.live);
|
|
1128
|
-
this.notifyObservers({
|
|
1129
|
-
type: 'create',
|
|
1130
|
-
modelType: model.getModelName(),
|
|
1131
|
-
model,
|
|
1132
|
-
});
|
|
1133
|
-
return model;
|
|
1134
|
-
}
|
|
1135
|
-
}
|
|
1136
|
-
|
|
1137
|
-
return null;
|
|
1138
|
-
} catch (error) {
|
|
1139
|
-
this.runtime.observability.captureMutationFailure({
|
|
1140
|
-
context: 'file-upload',
|
|
1141
|
-
error: error instanceof Error ? error : new Error(String(error)),
|
|
1142
|
-
});
|
|
1143
|
-
throw error;
|
|
1144
|
-
}
|
|
1145
|
-
}
|
|
1146
|
-
|
|
1147
|
-
/**
|
|
1148
|
-
* Batch upload files — single GraphQL call + parallel S3 PUTs.
|
|
1149
|
-
*
|
|
1150
|
-
* Returns the raw `Model[]` built by the object pool (typename is
|
|
1151
|
-
* determined by the payload the server returns — currently always
|
|
1152
|
-
* `Attachment`). The SDK has no knowledge of app-specific model classes,
|
|
1153
|
-
* so it cannot honestly claim a narrower return type; consumers that
|
|
1154
|
-
* need an `Attachment[]` project through their own typed accessor
|
|
1155
|
-
* (e.g. `store.query.attachments.findMany({ where: { id: IN ids } })`)
|
|
1156
|
-
* after the upload resolves.
|
|
1157
|
-
*/
|
|
1158
|
-
async batchUploadFiles(
|
|
1159
|
-
files: File[],
|
|
1160
|
-
options: {
|
|
1161
|
-
ids: string[];
|
|
1162
|
-
attachableType: string;
|
|
1163
|
-
attachableId: string;
|
|
1164
|
-
metadata?: Record<string, unknown>;
|
|
1165
|
-
}
|
|
1166
|
-
): Promise<Model[]> {
|
|
1167
|
-
if (!this.userId || !this.organizationId) {
|
|
1168
|
-
throw new AbloAuthenticationError('Authentication required for file uploads', {
|
|
1169
|
-
code: 'file_upload_auth_required',
|
|
1170
|
-
});
|
|
1171
|
-
}
|
|
1172
|
-
|
|
1173
|
-
const items = options.ids.map((id) => ({
|
|
1174
|
-
id,
|
|
1175
|
-
attachableType: options.attachableType,
|
|
1176
|
-
attachableId: options.attachableId,
|
|
1177
|
-
metadata: options.metadata,
|
|
1178
|
-
}));
|
|
1179
|
-
|
|
1180
|
-
const results = await this.mutationQueue.batchUploadAttachments(files, items, {
|
|
1090
|
+
private fileUploadContext(): FileUploadContext {
|
|
1091
|
+
return {
|
|
1181
1092
|
userId: this.userId,
|
|
1182
1093
|
organizationId: this.organizationId,
|
|
1183
|
-
|
|
1094
|
+
mutationQueue: this.mutationQueue,
|
|
1095
|
+
objectPool: this.objectPool,
|
|
1096
|
+
observability: this.runtime.observability,
|
|
1097
|
+
notifyCreated: (model) => {
|
|
1098
|
+
this.notifyObservers({ type: 'create', modelType: model.getModelName(), model });
|
|
1099
|
+
},
|
|
1100
|
+
};
|
|
1101
|
+
}
|
|
1184
1102
|
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
if (model) {
|
|
1189
|
-
this.objectPool.add(model, ModelScope.live);
|
|
1190
|
-
this.notifyObservers({
|
|
1191
|
-
type: 'create',
|
|
1192
|
-
modelType: model.getModelName(),
|
|
1193
|
-
model,
|
|
1194
|
-
});
|
|
1195
|
-
models.push(model);
|
|
1196
|
-
}
|
|
1197
|
-
}
|
|
1103
|
+
uploadFile(file: File, options: FileUploadOptions): Promise<Model | null> {
|
|
1104
|
+
return uploadFile(this.fileUploadContext(), file, options);
|
|
1105
|
+
}
|
|
1198
1106
|
|
|
1199
|
-
|
|
1107
|
+
batchUploadFiles(files: File[], options: BatchFileUploadOptions): Promise<Model[]> {
|
|
1108
|
+
return batchUploadFiles(this.fileUploadContext(), files, options);
|
|
1200
1109
|
}
|
|
1201
1110
|
|
|
1202
1111
|
/** Archive model (ARCHIVE) - works offline */
|
|
1203
|
-
archive(model: Model): void {
|
|
1204
|
-
this.mutate('archive', model, () => { this.objectPool.updateScope(model.id, ModelScope.archived); });
|
|
1112
|
+
archive(model: Model): Promise<void> | undefined {
|
|
1113
|
+
return this.mutate('archive', model, () => { this.objectPool.updateScope(model.id, ModelScope.archived); });
|
|
1205
1114
|
}
|
|
1206
1115
|
|
|
1207
1116
|
/**
|
|
@@ -1243,7 +1152,7 @@ export class SyncClient extends EventEmitter {
|
|
|
1243
1152
|
// Most internal callers intentionally use fire-and-forget writes. Observe
|
|
1244
1153
|
// their rejection without replacing the exact promise returned to model
|
|
1245
1154
|
// operations that need authoritative per-transaction confirmation.
|
|
1246
|
-
void confirmation.catch(
|
|
1155
|
+
void confirmation.catch(ignoreSeparatelyObservedMutationFailure);
|
|
1247
1156
|
const pending = staging.then(() => undefined).catch((error: Error) => {
|
|
1248
1157
|
this.runtime.observability.captureMutationFailure({
|
|
1249
1158
|
context: `stage-mutation-${type}`,
|
|
@@ -1495,14 +1404,11 @@ export class SyncClient extends EventEmitter {
|
|
|
1495
1404
|
markConnected(): void {
|
|
1496
1405
|
this.setConnectionState('connected');
|
|
1497
1406
|
// Browser online state may have marked the client connected before the
|
|
1498
|
-
// WebSocket itself was ready.
|
|
1499
|
-
//
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
error: error instanceof Error ? error : new Error(String(error)),
|
|
1504
|
-
});
|
|
1505
|
-
});
|
|
1407
|
+
// WebSocket itself was ready. Kick the durable lanes through the staging
|
|
1408
|
+
// barrier: a model mutation enters the in-memory store before its journal
|
|
1409
|
+
// row finishes saving, so a direct reconnect drain can otherwise try to
|
|
1410
|
+
// seal a source record that does not exist yet. The pending drain also
|
|
1411
|
+
// starts the atomic commit lane, so one ordered entry point covers both.
|
|
1506
1412
|
void this.processPendingMutations();
|
|
1507
1413
|
}
|
|
1508
1414
|
|
|
@@ -149,6 +149,9 @@ import {
|
|
|
149
149
|
type ReadSetContext,
|
|
150
150
|
} from '@abloatai/transaction/internal/read-set';
|
|
151
151
|
|
|
152
|
+
const ignoreSeparatelyObservedMutationFailure = (): undefined => undefined;
|
|
153
|
+
const ignoreBestEffortClaimReleaseFailure = (): undefined => undefined;
|
|
154
|
+
|
|
152
155
|
export interface ModelClientMeta {
|
|
153
156
|
readonly key: string;
|
|
154
157
|
readonly typename: string;
|
|
@@ -490,7 +493,7 @@ export function createModelOperations<T, C>(
|
|
|
490
493
|
// await does not create an unhandled-rejection process error. Returning
|
|
491
494
|
// the original promise preserves normal rejection for callers that do
|
|
492
495
|
// await or attach their own catch handler.
|
|
493
|
-
void confirmation.catch(
|
|
496
|
+
void confirmation.catch(ignoreSeparatelyObservedMutationFailure);
|
|
494
497
|
return confirmation;
|
|
495
498
|
};
|
|
496
499
|
};
|
|
@@ -660,9 +663,9 @@ export function createModelOperations<T, C>(
|
|
|
660
663
|
// This runs after authoritative confirmation. A best-effort abandon frame
|
|
661
664
|
// cannot turn a committed write into an apparent failure; the server has
|
|
662
665
|
// already fulfilled the participant's claims as part of that commit.
|
|
663
|
-
await releaseClaimsForEntity(entityId).catch(
|
|
666
|
+
await releaseClaimsForEntity(entityId).catch(ignoreBestEffortClaimReleaseFailure);
|
|
664
667
|
if (explicit && !explicitWasLocal) {
|
|
665
|
-
await explicit.release?.().catch(
|
|
668
|
+
await explicit.release?.().catch(ignoreBestEffortClaimReleaseFailure);
|
|
666
669
|
}
|
|
667
670
|
};
|
|
668
671
|
|
|
@@ -1376,7 +1379,7 @@ export function createModelOperations<T, C>(
|
|
|
1376
1379
|
await waitForMutation(model, confirmation);
|
|
1377
1380
|
return modelAsRow<T>(model);
|
|
1378
1381
|
} finally {
|
|
1379
|
-
await autoLease?.release?.().catch(
|
|
1382
|
+
await autoLease?.release?.().catch(ignoreBestEffortClaimReleaseFailure);
|
|
1380
1383
|
}
|
|
1381
1384
|
});
|
|
1382
1385
|
|
|
@@ -1501,7 +1504,7 @@ export function createModelOperations<T, C>(
|
|
|
1501
1504
|
const confirmation = syncClient.update(
|
|
1502
1505
|
model,
|
|
1503
1506
|
effective,
|
|
1504
|
-
patch
|
|
1507
|
+
patch,
|
|
1505
1508
|
);
|
|
1506
1509
|
await waitForMutation(model, confirmation);
|
|
1507
1510
|
return modelAsRow<T>(model);
|
|
@@ -1562,7 +1565,7 @@ export function createModelOperations<T, C>(
|
|
|
1562
1565
|
const confirmation = syncClient.update(
|
|
1563
1566
|
model,
|
|
1564
1567
|
effective,
|
|
1565
|
-
params.data
|
|
1568
|
+
params.data,
|
|
1566
1569
|
);
|
|
1567
1570
|
await waitForMutation(model, confirmation);
|
|
1568
1571
|
const updated = modelAsRow<T>(model);
|
|
@@ -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
|
+
}
|
|
@@ -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
|
);
|
|
@@ -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 {
|
|
@@ -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))); },
|