@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
|
@@ -20,10 +20,10 @@ export declare const ServerDeltaSchema: z.ZodObject<{
|
|
|
20
20
|
id: z.ZodNumber;
|
|
21
21
|
data: z.ZodNullable<z.ZodUnion<readonly [z.ZodRecord<z.ZodString, z.ZodUnknown>, z.ZodString]>>;
|
|
22
22
|
actionType: z.ZodEnum<{
|
|
23
|
-
A: "A";
|
|
24
23
|
I: "I";
|
|
25
24
|
U: "U";
|
|
26
25
|
D: "D";
|
|
26
|
+
A: "A";
|
|
27
27
|
V: "V";
|
|
28
28
|
C: "C";
|
|
29
29
|
G: "G";
|
|
@@ -44,10 +44,10 @@ export declare const BootstrapResponseSchema: z.ZodObject<{
|
|
|
44
44
|
id: z.ZodNumber;
|
|
45
45
|
data: z.ZodNullable<z.ZodUnion<readonly [z.ZodRecord<z.ZodString, z.ZodUnknown>, z.ZodString]>>;
|
|
46
46
|
actionType: z.ZodEnum<{
|
|
47
|
-
A: "A";
|
|
48
47
|
I: "I";
|
|
49
48
|
U: "U";
|
|
50
49
|
D: "D";
|
|
50
|
+
A: "A";
|
|
51
51
|
V: "V";
|
|
52
52
|
C: "C";
|
|
53
53
|
G: "G";
|
|
@@ -16,9 +16,9 @@ export function createLocalMutationPort(emit) {
|
|
|
16
16
|
};
|
|
17
17
|
return {
|
|
18
18
|
updates,
|
|
19
|
-
applyCreate: (model, transaction) => track('optimistic:create', model, transaction),
|
|
20
|
-
applyUpdate: (model, transaction) => track('optimistic:update', model, transaction),
|
|
21
|
-
applyDelete: (model, transaction) => track('optimistic:delete', model, transaction),
|
|
19
|
+
applyCreate: (model, transaction) => { track('optimistic:create', model, transaction); },
|
|
20
|
+
applyUpdate: (model, transaction) => { track('optimistic:update', model, transaction); },
|
|
21
|
+
applyDelete: (model, transaction) => { track('optimistic:delete', model, transaction); },
|
|
22
22
|
rollback: (transaction, reason, error) => {
|
|
23
23
|
const optimistic = updates.get(transaction.id);
|
|
24
24
|
if (!optimistic)
|
|
@@ -111,6 +111,7 @@ export declare class MutationQueue extends EventEmitter {
|
|
|
111
111
|
private pendingPersistenceStages;
|
|
112
112
|
private persistenceStageScheduled;
|
|
113
113
|
private pendingDrainPromise;
|
|
114
|
+
private modelProcessingPromise;
|
|
114
115
|
private executionQueue;
|
|
115
116
|
private isProcessing;
|
|
116
117
|
private processTimer?;
|
|
@@ -153,7 +154,6 @@ export declare class MutationQueue extends EventEmitter {
|
|
|
153
154
|
private get failureHandlingContext();
|
|
154
155
|
private get conflictResolutionContext();
|
|
155
156
|
private get processingSchedulerContext();
|
|
156
|
-
private get pendingDrainContext();
|
|
157
157
|
private get durableCommitRestoreContext();
|
|
158
158
|
private get persistenceContext();
|
|
159
159
|
private nextCommitSequence;
|
|
@@ -225,7 +225,6 @@ export declare class MutationQueue extends EventEmitter {
|
|
|
225
225
|
*/
|
|
226
226
|
private scheduleReplicationLagTimeout;
|
|
227
227
|
private takeNextExecutionBatch;
|
|
228
|
-
private takePendingDrainBatch;
|
|
229
228
|
/**
|
|
230
229
|
* Resolvers for per-transaction `confirmation` promises. Populated in
|
|
231
230
|
* `attachConfirmation` at staging time, consumed by the constructor-time
|
|
@@ -34,9 +34,8 @@ import { enqueueTransaction } from './queueCoalescing.js';
|
|
|
34
34
|
import { processBatch } from './batchProcessing.js';
|
|
35
35
|
import { handleFailure } from './failureHandling.js';
|
|
36
36
|
import { handleConflict as resolveConflict, isPermanentError as classifyPermanentError, isDefinitiveRejection as classifyDefinitiveRejection } from './failurePolicy.js';
|
|
37
|
-
import { takeNextExecutionBatch as selectExecutionBatch
|
|
37
|
+
import { takeNextExecutionBatch as selectExecutionBatch } from './executionSelection.js';
|
|
38
38
|
import { scheduleProcessing as scheduleProcessingExternal } from './processingScheduler.js';
|
|
39
|
-
import { drainPendingConfirmations, } from './pendingDrain.js';
|
|
40
39
|
import { restoreDurableCommits as restoreDurableCommitsExternal } from './durableCommitRestore.js';
|
|
41
40
|
export class MutationQueue extends EventEmitter {
|
|
42
41
|
// Keep one hour of clock/network margin inside the server's 24-hour ledger.
|
|
@@ -70,6 +69,7 @@ export class MutationQueue extends EventEmitter {
|
|
|
70
69
|
pendingPersistenceStages = [];
|
|
71
70
|
persistenceStageScheduled = false;
|
|
72
71
|
pendingDrainPromise = null;
|
|
72
|
+
modelProcessingPromise = null;
|
|
73
73
|
executionQueue = [];
|
|
74
74
|
isProcessing = false;
|
|
75
75
|
processTimer;
|
|
@@ -303,32 +303,6 @@ export class MutationQueue extends EventEmitter {
|
|
|
303
303
|
logger: this.runtime.logger,
|
|
304
304
|
};
|
|
305
305
|
}
|
|
306
|
-
get pendingDrainContext() {
|
|
307
|
-
return {
|
|
308
|
-
runtime: this.runtime,
|
|
309
|
-
config: { deltaConfirmationTimeout: this.config.deltaConfirmationTimeout },
|
|
310
|
-
store: this.store,
|
|
311
|
-
executionQueue: this.executionQueue,
|
|
312
|
-
optimisticUpdates: this.localMutationPort.updates,
|
|
313
|
-
assertDurableReplayOpen: () => { this.assertDurableReplayOpen(); },
|
|
314
|
-
processCommitLane: () => this.processCommitLane(),
|
|
315
|
-
takePendingDrainBatch: (pending) => this.takePendingDrainBatch(pending),
|
|
316
|
-
ensureCommitEnvelope: (batch) => this.ensureCommitEnvelope(batch),
|
|
317
|
-
ensureDerivedFields: (transaction) => { this.ensureDerivedFields(transaction); },
|
|
318
|
-
sourceMutationIdsFor: (batch) => this.sourceMutationIdsFor(batch),
|
|
319
|
-
sealDurableCommit: (input) => this.sealDurableCommit(input),
|
|
320
|
-
assertEnvelopeInsideReplayWindow: (envelope) => { this.assertEnvelopeInsideReplayWindow(envelope); },
|
|
321
|
-
parseMutationCommitResult: (value) => this.parseMutationCommitResult(value),
|
|
322
|
-
dispatchCommitBounded: (...args) => this.dispatchCommitBounded(...args),
|
|
323
|
-
persistDurableCommitAcceptance: (envelope, result) => this.persistDurableCommitAcceptance(envelope, result),
|
|
324
|
-
removeDurableCommit: (idempotencyKey) => this.removeDurableCommit(idempotencyKey),
|
|
325
|
-
scheduleReplicationLagTimeout: (transactionId, clientTxId, correlationId) => { this.scheduleReplicationLagTimeout(transactionId, clientTxId, correlationId); },
|
|
326
|
-
scheduleDeltaConfirmationTimeout: (transaction, timeoutMs) => { this.scheduleDeltaConfirmationTimeout(transaction, timeoutMs); },
|
|
327
|
-
enqueue: (transaction) => { this.enqueue(transaction); },
|
|
328
|
-
recentDeltaCorrelations: this.recentDeltaCorrelations,
|
|
329
|
-
emit: (event, payload) => this.emit(event, payload),
|
|
330
|
-
};
|
|
331
|
-
}
|
|
332
306
|
get durableCommitRestoreContext() {
|
|
333
307
|
return {
|
|
334
308
|
config: this.config,
|
|
@@ -738,9 +712,6 @@ export class MutationQueue extends EventEmitter {
|
|
|
738
712
|
this.executionQueue = selected.remaining;
|
|
739
713
|
return selected.batch;
|
|
740
714
|
}
|
|
741
|
-
takePendingDrainBatch(pending) {
|
|
742
|
-
return selectPendingDrainBatch(pending, this.config.maxBatchSize);
|
|
743
|
-
}
|
|
744
715
|
/**
|
|
745
716
|
* Resolvers for per-transaction `confirmation` promises. Populated in
|
|
746
717
|
* `attachConfirmation` at staging time, consumed by the constructor-time
|
|
@@ -1004,25 +975,15 @@ export class MutationQueue extends EventEmitter {
|
|
|
1004
975
|
return this.pendingDrainPromise;
|
|
1005
976
|
}
|
|
1006
977
|
async drainPendingInternal() {
|
|
1007
|
-
//
|
|
1008
|
-
//
|
|
1009
|
-
//
|
|
1010
|
-
//
|
|
1011
|
-
//
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
if (this.isProcessing)
|
|
1017
|
-
return;
|
|
1018
|
-
this.isProcessing = true;
|
|
1019
|
-
try {
|
|
1020
|
-
await drainPendingConfirmations(this.pendingDrainContext);
|
|
1021
|
-
}
|
|
1022
|
-
finally {
|
|
1023
|
-
this.isProcessing = false;
|
|
1024
|
-
if (this.executionQueue.length > 0)
|
|
1025
|
-
this.scheduleProcessing(true);
|
|
978
|
+
// Explicit flushes and reconnects are merely another trigger for the one
|
|
979
|
+
// model-mutation execution lane. A second sealing implementation can race
|
|
980
|
+
// the scheduled lane, consume its journal sources, and later dispatch the
|
|
981
|
+
// same transaction again. Move every staged row to the owned queue, then
|
|
982
|
+
// drive the normal lane until the queue has handed off all current work.
|
|
983
|
+
this.commitCreatedTransactions();
|
|
984
|
+
await this.processCommitLane();
|
|
985
|
+
while (this.executionQueue.length > 0 || this.modelProcessingPromise) {
|
|
986
|
+
await this.processBatch();
|
|
1026
987
|
}
|
|
1027
988
|
}
|
|
1028
989
|
async create(model, context, writeOptions, sourceMutationId) {
|
|
@@ -1055,7 +1016,20 @@ export class MutationQueue extends EventEmitter {
|
|
|
1055
1016
|
scheduleProcessingExternal(this.processingSchedulerContext, immediate);
|
|
1056
1017
|
}
|
|
1057
1018
|
async processBatch() {
|
|
1058
|
-
|
|
1019
|
+
if (this.modelProcessingPromise) {
|
|
1020
|
+
await this.modelProcessingPromise;
|
|
1021
|
+
if (this.executionQueue.length > 0)
|
|
1022
|
+
await this.processBatch();
|
|
1023
|
+
return;
|
|
1024
|
+
}
|
|
1025
|
+
const processing = processBatch(this.batchProcessingContext);
|
|
1026
|
+
const tracked = processing.finally(() => {
|
|
1027
|
+
if (this.modelProcessingPromise === tracked) {
|
|
1028
|
+
this.modelProcessingPromise = null;
|
|
1029
|
+
}
|
|
1030
|
+
});
|
|
1031
|
+
this.modelProcessingPromise = tracked;
|
|
1032
|
+
await tracked;
|
|
1059
1033
|
}
|
|
1060
1034
|
rememberDeltaCorrelation(correlationId, syncId) {
|
|
1061
1035
|
// Refresh insertion order when a replay repeats the same correlation id.
|
|
@@ -64,16 +64,29 @@ export async function processBatch(ctx) {
|
|
|
64
64
|
if (batchOps.length > 0) {
|
|
65
65
|
let dispatchStarted = false;
|
|
66
66
|
try {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
67
|
+
let durableEnvelope = batch[0]?.durableEnvelope;
|
|
68
|
+
if (durableEnvelope) {
|
|
69
|
+
const mismatched = batch.some((transaction) => transaction.durableEnvelope?.idempotencyKey !==
|
|
70
|
+
durableEnvelope?.idempotencyKey);
|
|
71
|
+
if (mismatched || durableEnvelope.idempotencyKey !== commitIdempotencyKey) {
|
|
72
|
+
throw new Error('Cannot replay a model batch with inconsistent durable envelopes');
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
durableEnvelope = await ctx.sealDurableCommit({
|
|
77
|
+
idempotencyKey: commitIdempotencyKey,
|
|
78
|
+
origin: 'model_batch',
|
|
79
|
+
operations: batchOps.map(({ op }) => op),
|
|
80
|
+
sourceMutationIds: ctx.sourceMutationIdsFor(batch),
|
|
81
|
+
commitOptions: { reads: collectQueuedReads(batch) },
|
|
82
|
+
createdAt: Math.min(...batch.map((transaction) => transaction.createdAt)),
|
|
83
|
+
sealedAt: batch[0]?.commitEnvelope?.sealedAt ?? Date.now(),
|
|
84
|
+
sequence: batch[0]?.commitEnvelope?.sequence,
|
|
85
|
+
});
|
|
86
|
+
for (const transaction of batch) {
|
|
87
|
+
transaction.durableEnvelope = durableEnvelope;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
77
90
|
const operations = durableEnvelope.operations;
|
|
78
91
|
// Capture lastSyncId from the server response for threshold-based
|
|
79
92
|
// confirmation.
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
import type { RuntimeContext } from '../../RuntimeContext.js';
|
|
12
12
|
import { MutationOperationType } from '@abloatai/transaction/types';
|
|
13
13
|
import type { MutationOptions, WriteOptions } from '../../interfaces/index.js';
|
|
14
|
-
import type { CommitEnvelopeMember } from '@abloatai/transaction/commit';
|
|
14
|
+
import type { CommitEnvelopeMember, DurableCommitEnvelope } from '@abloatai/transaction/commit';
|
|
15
15
|
export interface UserContext {
|
|
16
16
|
userId: string;
|
|
17
17
|
organizationId: string;
|
|
@@ -69,6 +69,13 @@ export interface QueuedMutation {
|
|
|
69
69
|
* re-batching its operations under a fresh key.
|
|
70
70
|
*/
|
|
71
71
|
commitEnvelope?: CommitEnvelopeMember;
|
|
72
|
+
/**
|
|
73
|
+
* The exact durable request produced by the first successful local seal.
|
|
74
|
+
* Runtime retries dispatch this object directly. Asking the outbox to seal
|
|
75
|
+
* again is both unnecessary and unsafe after a concurrent authoritative
|
|
76
|
+
* completion has begun cleaning up the stored envelope.
|
|
77
|
+
*/
|
|
78
|
+
durableEnvelope?: DurableCommitEnvelope;
|
|
72
79
|
/** Pending-mutation journal entries atomically consumed by this envelope. */
|
|
73
80
|
sourceMutationIds?: string[];
|
|
74
81
|
/** Completed locally without a server operation; no sync echo will arrive. */
|
|
@@ -95,7 +95,9 @@ export function dispatchCommitBounded(ctx, ...args) {
|
|
|
95
95
|
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0)
|
|
96
96
|
return dispatched;
|
|
97
97
|
return new Promise((resolve, reject) => {
|
|
98
|
-
const timer = setTimeout(() =>
|
|
98
|
+
const timer = setTimeout(() => {
|
|
99
|
+
reject(new AbloConnectionError('The mutation transport did not acknowledge the commit in time; its outcome remains pending and is safe to retry.', { code: 'commit_no_result' }));
|
|
100
|
+
}, timeoutMs);
|
|
99
101
|
dispatched.then((value) => { clearTimeout(timer); resolve(value); }, (error) => { clearTimeout(timer); reject(error instanceof Error ? error : new Error(String(error))); });
|
|
100
102
|
});
|
|
101
103
|
}
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
export function takeNextExecutionBatch(executionQueue, maxBatchSize) {
|
|
2
|
+
// Cancellation, delta confirmation, and failure settlement can all make a
|
|
3
|
+
// queued reference terminal before its scheduler callback runs. Terminal or
|
|
4
|
+
// currently executing rows have no authority to cross the dispatch boundary.
|
|
5
|
+
const pendingQueue = executionQueue.filter((tx) => tx.status === 'pending');
|
|
2
6
|
const retryGroups = new Map();
|
|
3
|
-
for (const tx of
|
|
7
|
+
for (const tx of pendingQueue) {
|
|
4
8
|
const envelope = tx.commitEnvelope;
|
|
5
9
|
if (!envelope)
|
|
6
10
|
continue;
|
|
@@ -13,30 +17,18 @@ export function takeNextExecutionBatch(executionQueue, maxBatchSize) {
|
|
|
13
17
|
const expectedCount = members[0]?.commitEnvelope?.operationCount;
|
|
14
18
|
if (expectedCount === undefined || members.length !== expectedCount)
|
|
15
19
|
continue;
|
|
16
|
-
const remaining =
|
|
20
|
+
const remaining = pendingQueue.filter((tx) => tx.commitEnvelope?.idempotencyKey !== idempotencyKey);
|
|
17
21
|
members.sort((a, b) => (a.commitEnvelope?.operationIndex ?? 0) - (b.commitEnvelope?.operationIndex ?? 0));
|
|
18
22
|
return { batch: members, remaining };
|
|
19
23
|
}
|
|
20
|
-
const fresh =
|
|
24
|
+
const fresh = pendingQueue.filter((tx) => !tx.commitEnvelope);
|
|
21
25
|
const firstFresh = fresh[0];
|
|
22
26
|
if (!firstFresh)
|
|
23
|
-
return { batch: [], remaining:
|
|
27
|
+
return { batch: [], remaining: pendingQueue };
|
|
24
28
|
const explicitIndex = fresh.findIndex((tx) => typeof tx.writeOptions?.idempotencyKey === 'string');
|
|
25
29
|
const selected = explicitIndex === 0
|
|
26
30
|
? [firstFresh]
|
|
27
31
|
: fresh.slice(0, Math.min(maxBatchSize, explicitIndex > 0 ? explicitIndex : fresh.length));
|
|
28
32
|
const selectedIds = new Set(selected.map((tx) => tx.id));
|
|
29
|
-
return { batch: selected, remaining:
|
|
30
|
-
}
|
|
31
|
-
export function takePendingDrainBatch(pending, maxBatchSize) {
|
|
32
|
-
const first = pending[0];
|
|
33
|
-
if (!first)
|
|
34
|
-
return [];
|
|
35
|
-
const envelope = first.commitEnvelope;
|
|
36
|
-
if (envelope)
|
|
37
|
-
return pending.filter((tx) => tx.commitEnvelope?.idempotencyKey === envelope.idempotencyKey);
|
|
38
|
-
if (typeof first.writeOptions?.idempotencyKey === 'string')
|
|
39
|
-
return [first];
|
|
40
|
-
const explicitIndex = pending.findIndex((tx) => typeof tx.writeOptions?.idempotencyKey === 'string');
|
|
41
|
-
return pending.slice(0, Math.min(maxBatchSize, explicitIndex > 0 ? explicitIndex : pending.length));
|
|
33
|
+
return { batch: selected, remaining: pendingQueue.filter((tx) => !selectedIds.has(tx.id)) };
|
|
42
34
|
}
|
|
@@ -13,6 +13,15 @@ export function transientRetryDelayMs(error, attempt, retryBackoff) {
|
|
|
13
13
|
return Math.floor(Math.random() * ceiling);
|
|
14
14
|
}
|
|
15
15
|
export async function handleFailure(ctx, transaction, error) {
|
|
16
|
+
// The dispatch owner may lose its acknowledgement while an authoritative
|
|
17
|
+
// delta concurrently completes the same transaction. Completion is
|
|
18
|
+
// terminal: a late catch path must not turn that row back into `pending`
|
|
19
|
+
// and schedule a second seal after its durable sources were cleaned up.
|
|
20
|
+
if (transaction.status === 'completed' ||
|
|
21
|
+
transaction.status === 'failed' ||
|
|
22
|
+
transaction.status === 'rolled_back' ||
|
|
23
|
+
transaction.status === 'awaiting_delta')
|
|
24
|
+
return;
|
|
16
25
|
transaction.attempts++;
|
|
17
26
|
// Check whether this is a permanent error that should not be retried.
|
|
18
27
|
if (ctx.isPermanentError(error)) {
|
|
@@ -12,9 +12,9 @@ export function createLocalMutationPort(emitter) {
|
|
|
12
12
|
const updates = new Map();
|
|
13
13
|
return {
|
|
14
14
|
updates,
|
|
15
|
-
applyCreate: (model, transaction) => applyOptimisticCreate(updates, emitter, model, transaction),
|
|
16
|
-
applyUpdate: (model, transaction) => applyOptimisticUpdate(updates, emitter, model, transaction),
|
|
17
|
-
applyDelete: (model, transaction) => applyOptimisticDelete(updates, emitter, model, transaction),
|
|
15
|
+
applyCreate: (model, transaction) => { applyOptimisticCreate(updates, emitter, model, transaction); },
|
|
16
|
+
applyUpdate: (model, transaction) => { applyOptimisticUpdate(updates, emitter, model, transaction); },
|
|
17
|
+
applyDelete: (model, transaction) => { applyOptimisticDelete(updates, emitter, model, transaction); },
|
|
18
18
|
rollback: (transaction, reason, error) => rollbackOptimistic(updates, emitter, transaction, reason, error),
|
|
19
19
|
};
|
|
20
20
|
}
|
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import { mergeUpdateData } from './coalesceRules.js';
|
|
2
2
|
import { hasCommitCoalescingBarrier } from './commitPayload.js';
|
|
3
3
|
export function enqueueTransaction(ctx, transaction) {
|
|
4
|
+
// Only the pending state may cross into the execution owner. A late timer,
|
|
5
|
+
// reconnect callback, or stale staging callback must not resurrect a row
|
|
6
|
+
// that is already executing or terminal, and repeated triggers must not put
|
|
7
|
+
// the same source mutation into the queue twice.
|
|
8
|
+
if (transaction.status !== 'pending')
|
|
9
|
+
return;
|
|
10
|
+
if (ctx.executionQueue.some((candidate) => candidate.id === transaction.id))
|
|
11
|
+
return;
|
|
4
12
|
ctx.ensureDerivedFields(transaction);
|
|
5
13
|
const modelKey = `${transaction.modelName}:${transaction.modelId}`;
|
|
6
14
|
if (transaction.type === 'update' && transaction.attempts === 0 && !transaction.commitEnvelope) {
|
|
@@ -10,5 +10,5 @@ export function useErrorListener(listener) {
|
|
|
10
10
|
}
|
|
11
11
|
const listenerRef = useRef(listener);
|
|
12
12
|
listenerRef.current = listener;
|
|
13
|
-
useEffect(() => context.subscribeError((error) => listenerRef.current(error)), [context]);
|
|
13
|
+
useEffect(() => context.subscribeError((error) => { listenerRef.current(error); }), [context]);
|
|
14
14
|
}
|
|
@@ -14,6 +14,6 @@ export function useMutationFailureListener(listener) {
|
|
|
14
14
|
const engine = context.engine;
|
|
15
15
|
if (!engine)
|
|
16
16
|
return;
|
|
17
|
-
return engine.onMutationFailure((payload) => listenerRef.current(payload));
|
|
17
|
+
return engine.onMutationFailure((payload) => { listenerRef.current(payload); });
|
|
18
18
|
}, [context, context.engine]);
|
|
19
19
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@abloatai/humans",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.61.0",
|
|
4
4
|
"description": "The optional human-facing local-state package for Ablo: presence, live queries, and React bindings.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -68,7 +68,7 @@
|
|
|
68
68
|
"test:property": "jest --testPathPatterns=__tests__/property",
|
|
69
69
|
"test:migrated-integration": "jest --testPathPatterns=__tests__/integration",
|
|
70
70
|
"test:e2e": "E2E_TEST=true jest --config jest.e2e.config.ts",
|
|
71
|
-
"lint:eslint": "eslint . --cache",
|
|
71
|
+
"lint:eslint": "eslint . --cache --suppressions-location eslint-suppressions.json",
|
|
72
72
|
"check:boundary": "node scripts/check-boundary.mjs",
|
|
73
73
|
"test:integration": "npm run build && node scripts/check-package-integration.mjs",
|
|
74
74
|
"lint:pkg": "publint"
|
|
@@ -84,7 +84,7 @@
|
|
|
84
84
|
"directory": "packages/humans"
|
|
85
85
|
},
|
|
86
86
|
"dependencies": {
|
|
87
|
-
"@abloatai/transaction": "^0.
|
|
87
|
+
"@abloatai/transaction": "^0.61.0",
|
|
88
88
|
"mobx": "^6.13.7",
|
|
89
89
|
"uuid": "^11.1.0",
|
|
90
90
|
"zod": "^4.4.3"
|
|
@@ -114,7 +114,6 @@
|
|
|
114
114
|
"react": "^19.2.8",
|
|
115
115
|
"react-dom": "^19.2.8",
|
|
116
116
|
"ts-jest": "^29.4.5",
|
|
117
|
-
"ts-node": "^10.9.2",
|
|
118
117
|
"typescript": "npm:@typescript/typescript6@^6.0.2",
|
|
119
118
|
"ws": "^8.18.3"
|
|
120
119
|
}
|
|
@@ -1759,9 +1759,9 @@ export class BaseSyncedStore<
|
|
|
1759
1759
|
const isCreate = !this.objectPool.get(model.id);
|
|
1760
1760
|
if (isCreate) {
|
|
1761
1761
|
model.updatedAt = new Date();
|
|
1762
|
-
this.syncClient.add(model);
|
|
1762
|
+
await this.syncClient.add(model);
|
|
1763
1763
|
} else {
|
|
1764
|
-
this.syncClient.update(model);
|
|
1764
|
+
await this.syncClient.update(model);
|
|
1765
1765
|
}
|
|
1766
1766
|
}
|
|
1767
1767
|
|
|
@@ -1779,21 +1779,21 @@ export class BaseSyncedStore<
|
|
|
1779
1779
|
const model = rowAsModel(entity);
|
|
1780
1780
|
this.pendingDeletes.add(model.id);
|
|
1781
1781
|
// SyncClient.delete handles: pool remove, transaction queue
|
|
1782
|
-
this.syncClient.delete(model);
|
|
1782
|
+
await this.syncClient.delete(model);
|
|
1783
1783
|
}
|
|
1784
1784
|
|
|
1785
1785
|
/** Archive a model. Accepts schema-inferred entity shapes (see `save`). */
|
|
1786
1786
|
async archive<T extends { id: string; archivedAt?: Date | null }>(entity: T): Promise<void> {
|
|
1787
1787
|
const model = rowAsModel(entity);
|
|
1788
1788
|
model.archivedAt = new Date();
|
|
1789
|
-
this.syncClient.archive(model);
|
|
1789
|
+
await this.syncClient.archive(model);
|
|
1790
1790
|
}
|
|
1791
1791
|
|
|
1792
1792
|
/** Unarchive a model. Accepts schema-inferred entity shapes (see `save`). */
|
|
1793
1793
|
async unarchive<T extends { id: string; archivedAt?: Date | null }>(entity: T): Promise<void> {
|
|
1794
1794
|
const model = rowAsModel(entity);
|
|
1795
1795
|
model.archivedAt = null;
|
|
1796
|
-
this.syncClient.update(model);
|
|
1796
|
+
await this.syncClient.update(model);
|
|
1797
1797
|
}
|
|
1798
1798
|
|
|
1799
1799
|
|
package/src/local/Model.ts
CHANGED
|
@@ -416,7 +416,7 @@ export abstract class Model {
|
|
|
416
416
|
const original = this.getOriginalSnapshot();
|
|
417
417
|
for (const key of keys) {
|
|
418
418
|
if (key === 'id') continue;
|
|
419
|
-
const mod = modified
|
|
419
|
+
const mod = modified.get(key);
|
|
420
420
|
if (mod) {
|
|
421
421
|
out[key] = mod.old;
|
|
422
422
|
} else if (original && key in original) {
|
|
@@ -463,25 +463,23 @@ export abstract class Model {
|
|
|
463
463
|
const modelName = this.getModelName();
|
|
464
464
|
const properties = getActiveRegistry().getProperties(modelName);
|
|
465
465
|
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
errors.push(`${propName} is required`);
|
|
474
|
-
}
|
|
466
|
+
const json = this.toJSON();
|
|
467
|
+
for (const [propName, metadata] of properties) {
|
|
468
|
+
// Check required fields
|
|
469
|
+
if (!metadata.nullable && !metadata.optional) {
|
|
470
|
+
const value = json[propName];
|
|
471
|
+
if (value == null || value === '') {
|
|
472
|
+
errors.push(`${propName} is required`);
|
|
475
473
|
}
|
|
474
|
+
}
|
|
476
475
|
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
}
|
|
476
|
+
// Run custom validation rules
|
|
477
|
+
const rules = this.validationRules[propName];
|
|
478
|
+
if (rules) {
|
|
479
|
+
const value = json[propName];
|
|
480
|
+
for (const rule of rules) {
|
|
481
|
+
const error = rule(value);
|
|
482
|
+
if (error) errors.push(error);
|
|
485
483
|
}
|
|
486
484
|
}
|
|
487
485
|
}
|
|
@@ -816,18 +814,16 @@ export abstract class Model {
|
|
|
816
814
|
result.archivedAt = this.archivedAt?.toISOString() ?? null;
|
|
817
815
|
}
|
|
818
816
|
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
result[propName] = value;
|
|
830
|
-
}
|
|
817
|
+
const self = this as Record<string, unknown>;
|
|
818
|
+
for (const [propName, metadata] of properties) {
|
|
819
|
+
// Skip certain types
|
|
820
|
+
if (metadata.type === 'ephemeralProperty') continue;
|
|
821
|
+
if (metadata.type === 'referenceModel') continue;
|
|
822
|
+
if (metadata.type === 'referenceCollection') continue;
|
|
823
|
+
|
|
824
|
+
const value = self[propName];
|
|
825
|
+
if (value !== undefined) {
|
|
826
|
+
result[propName] = value;
|
|
831
827
|
}
|
|
832
828
|
}
|
|
833
829
|
|
|
@@ -946,14 +942,12 @@ export abstract class Model {
|
|
|
946
942
|
const modelName = this.getModelName();
|
|
947
943
|
const properties = getActiveRegistry().getProperties(modelName);
|
|
948
944
|
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
collection.dispose();
|
|
956
|
-
}
|
|
945
|
+
const self = this as Record<string, unknown>;
|
|
946
|
+
for (const [propName, metadata] of properties) {
|
|
947
|
+
if (metadata.type === 'referenceCollection') {
|
|
948
|
+
const collection = self[propName] as Disposable | undefined;
|
|
949
|
+
if (collection) {
|
|
950
|
+
collection.dispose();
|
|
957
951
|
}
|
|
958
952
|
}
|
|
959
953
|
}
|
|
@@ -983,11 +977,9 @@ export abstract class Model {
|
|
|
983
977
|
const modelName = this.getModelName();
|
|
984
978
|
const properties = getActiveRegistry().getProperties(modelName);
|
|
985
979
|
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
snapshot[propName] = json[propName];
|
|
990
|
-
}
|
|
980
|
+
const json = this.toJSON();
|
|
981
|
+
for (const [propName] of properties) {
|
|
982
|
+
snapshot[propName] = json[propName];
|
|
991
983
|
}
|
|
992
984
|
|
|
993
985
|
return snapshot;
|
|
@@ -1034,19 +1026,17 @@ export abstract class Model {
|
|
|
1034
1026
|
|
|
1035
1027
|
const properties = getActiveRegistry().getProperties(this.getModelName());
|
|
1036
1028
|
const self = this as Record<string, unknown>;
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
continue;
|
|
1045
|
-
}
|
|
1046
|
-
// Reading through the observable getter is the point: it subscribes the
|
|
1047
|
-
// enclosing MobX reaction to this field.
|
|
1048
|
-
snapshot[propName] = self[propName];
|
|
1029
|
+
for (const [propName, metadata] of properties) {
|
|
1030
|
+
if (
|
|
1031
|
+
metadata.type === 'ephemeralProperty' ||
|
|
1032
|
+
metadata.type === 'referenceModel' ||
|
|
1033
|
+
metadata.type === 'referenceCollection'
|
|
1034
|
+
) {
|
|
1035
|
+
continue;
|
|
1049
1036
|
}
|
|
1037
|
+
// Reading through the observable getter is the point: it subscribes the
|
|
1038
|
+
// enclosing MobX reaction to this field.
|
|
1039
|
+
snapshot[propName] = self[propName];
|
|
1050
1040
|
}
|
|
1051
1041
|
|
|
1052
1042
|
for (const name of this.getDerivedGetterNames()) {
|
|
@@ -14,6 +14,8 @@ export class NetworkMonitor extends EventEmitter {
|
|
|
14
14
|
// Only `navigator.onLine === false` means offline. Node 18+ exposes a global
|
|
15
15
|
// `navigator` with `onLine === undefined`, so the naive `navigator.onLine`
|
|
16
16
|
// would seed `false` (offline) on every server client — start optimistic.
|
|
17
|
+
// DOM types say `onLine` is boolean, but Node exposes it as undefined.
|
|
18
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-boolean-literal-compare
|
|
17
19
|
private isOnline = !(typeof navigator !== 'undefined' && navigator.onLine === false);
|
|
18
20
|
private lastOnlineCheck: Date = new Date();
|
|
19
21
|
|
|
@@ -106,6 +106,8 @@ export const browserOnlineStatus: OnlineStatusProvider = {
|
|
|
106
106
|
// signal. Don't use `!navigator.onLine`: Node 18+ exposes a global
|
|
107
107
|
// `navigator` whose `onLine` is `undefined`, which `!` would read as offline —
|
|
108
108
|
// wedging every Node/server client (agents, worker, MCP) into a false offline.
|
|
109
|
+
// DOM types say `onLine` is boolean, but Node exposes it as undefined.
|
|
110
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-boolean-literal-compare
|
|
109
111
|
return !(typeof navigator !== 'undefined' && navigator.onLine === false);
|
|
110
112
|
},
|
|
111
113
|
};
|