@abloatai/humans 0.59.0 → 0.59.2
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/humans.d.ts +1 -1
- package/dist/local/BaseSyncedStore.d.ts +1 -3
- package/dist/local/BaseSyncedStore.js +2 -7
- package/dist/local/Database.d.ts +2 -2
- package/dist/local/LazyReferenceCollection.d.ts +1 -1
- package/dist/local/Model.js +2 -2
- package/dist/local/SyncClient.d.ts +4 -4
- package/dist/local/SyncClient.js +20 -1
- package/dist/local/interfaces/index.d.ts +1 -1
- package/dist/local/stores/syncAction.d.ts +4 -4
- package/dist/local/sync/deltaPipeline.d.ts +11 -3
- package/dist/local/sync/deltaPipeline.js +27 -80
- package/dist/local/sync/schemas.d.ts +10 -10
- package/dist/local/transactions/mutations/MutationQueue.d.ts +3 -3
- package/dist/local/transactions/mutations/batchProcessing.js +5 -1
- package/dist/local/transactions/mutations/commitPayload.d.ts +3 -1
- package/dist/local/transactions/mutations/commitPayload.js +14 -0
- package/dist/local/transactions/mutations/failureHandling.js +9 -81
- package/dist/local/transactions/mutations/failureReporting.d.ts +10 -0
- package/dist/local/transactions/mutations/failureReporting.js +67 -0
- package/dist/local/transactions/mutations/pendingDrain.js +5 -1
- package/dist/local/transactions/mutations/replayValidation.d.ts +51 -15
- package/dist/local/transactions/mutations/replayValidation.js +2 -0
- package/dist/react/AbloProvider.js +1 -1
- 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/surface.d.ts +3 -3
- package/package.json +2 -2
- package/src/local/BaseSyncedStore.ts +2 -8
- package/src/local/Model.ts +2 -2
- package/src/local/SyncClient.ts +22 -1
- package/src/local/interfaces/index.ts +1 -1
- package/src/local/sync/SyncWebSocket.ts +1 -1
- package/src/local/sync/deltaPipeline.ts +26 -82
- package/src/local/transactions/mutations/batchProcessing.ts +5 -1
- package/src/local/transactions/mutations/commitPayload.ts +17 -0
- package/src/local/transactions/mutations/failureHandling.ts +73 -132
- package/src/local/transactions/mutations/failureReporting.ts +93 -0
- package/src/local/transactions/mutations/pendingDrain.ts +5 -1
- package/src/local/transactions/mutations/replayValidation.ts +2 -0
|
@@ -2,8 +2,8 @@ import type { RuntimeContext } from '../../RuntimeContext.js';
|
|
|
2
2
|
import type { MutationQueueConfig } from './MutationQueue.js';
|
|
3
3
|
import type { QueuedMutation } from './commitPayload.js';
|
|
4
4
|
import type { MutationStore } from './MutationStore.js';
|
|
5
|
-
import { AbloError } from '@abloatai/transaction/errors';
|
|
6
5
|
import { extractStatusCode } from './commitPayload.js';
|
|
6
|
+
import { reportPermanentMutationFailure } from './failureReporting.js';
|
|
7
7
|
|
|
8
8
|
export interface FailureHandlingContext {
|
|
9
9
|
readonly runtime: RuntimeContext;
|
|
@@ -13,7 +13,11 @@ export interface FailureHandlingContext {
|
|
|
13
13
|
>;
|
|
14
14
|
readonly store: MutationStore;
|
|
15
15
|
readonly isPermanentError: (error: Error) => boolean;
|
|
16
|
-
readonly rollbackOptimistic: (
|
|
16
|
+
readonly rollbackOptimistic: (
|
|
17
|
+
transaction: QueuedMutation,
|
|
18
|
+
reason: string,
|
|
19
|
+
error?: Error,
|
|
20
|
+
) => Promise<void>;
|
|
17
21
|
readonly enqueue: (transaction: QueuedMutation) => void;
|
|
18
22
|
readonly getLastPermanentErrorSignature: () => string | undefined;
|
|
19
23
|
readonly setLastPermanentErrorSignature: (signature: string) => void;
|
|
@@ -35,138 +39,75 @@ export function transientRetryDelayMs(
|
|
|
35
39
|
return Math.floor(Math.random() * ceiling);
|
|
36
40
|
}
|
|
37
41
|
|
|
38
|
-
export async function handleFailure(
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
// A `create` whose id already exists is the benign idempotency case:
|
|
65
|
-
// "this row is already there." It's the least alarming permanent
|
|
66
|
-
// error, so it doesn't warrant a `warn` — `info` keeps it visible
|
|
67
|
-
// without crying wolf. Everything else (FK violation, auth expiry,
|
|
68
|
-
// server 500) stays at `warn`.
|
|
69
|
-
const isBenignIdempotent =
|
|
70
|
-
transaction.type === 'create' &&
|
|
71
|
-
(abloErr?.code === 'unique_violation' ||
|
|
72
|
-
abloErr?.type === 'AbloIdempotencyError');
|
|
73
|
-
|
|
74
|
-
// Demote exact repeats (same write rejected for the same reason on
|
|
75
|
-
// each reconnect replay) to `debug` so the loop logs once.
|
|
76
|
-
const sig = `${details.type}:${details.model}:${details.modelId}:${details.errorCode ?? details.errorType}`;
|
|
77
|
-
const isRepeat = sig === ctx.getLastPermanentErrorSignature();
|
|
78
|
-
ctx.setLastPermanentErrorSignature(sig);
|
|
79
|
-
|
|
80
|
-
const logger = ctx.runtime.logger;
|
|
81
|
-
|
|
82
|
-
// Two registers from one call site, split by log level (the default
|
|
83
|
-
// logger is gated at `warn`, so `debug` stays hidden unless
|
|
84
|
-
// ABLO_LOG_LEVEL=debug is set to inspect the engine):
|
|
85
|
-
// - the default-visible line speaks the application developer's
|
|
86
|
-
// language: their verb (such as `update`), their model, the typed
|
|
87
|
-
// error's own message, and the wire `code` for searching. It uses
|
|
88
|
-
// no engine jargon and prints no JSON dump, which would alarm
|
|
89
|
-
// without helping.
|
|
90
|
-
// - the forensic `details` ride a companion `debug` line for anyone
|
|
91
|
-
// debugging the engine internals.
|
|
92
|
-
const revertNote = ctx.config.enableOptimistic
|
|
93
|
-
? ' The local change was reverted.'
|
|
94
|
-
: '';
|
|
95
|
-
const reason = abloErr?.message ? ` — ${abloErr.message}` : '';
|
|
96
|
-
const code = abloErr?.code ? ` (code: ${abloErr.code})` : '';
|
|
97
|
-
const requestRef = abloErr?.requestId
|
|
98
|
-
? ` [request_id: ${abloErr.requestId}]`
|
|
99
|
-
: '';
|
|
100
|
-
// An optimistic write resolves before the server answers, so a later
|
|
101
|
-
// rejection has no caller left to return to and this log is the only
|
|
102
|
-
// place it appears. That reads to an application developer as their own
|
|
103
|
-
// save silently failing — the write showed, then vanished — and sends
|
|
104
|
-
// them into their editor instead of here. Name the subscription that
|
|
105
|
-
// hands them the same typed error, so the application can say what
|
|
106
|
-
// happened rather than only the console.
|
|
107
|
-
const channelNote = ctx.config.enableOptimistic
|
|
108
|
-
? ' To surface this in your app, subscribe with `ablo.onMutationFailure(…)`.'
|
|
109
|
-
: '';
|
|
110
|
-
const headline = `Your ${transaction.type} to "${transaction.modelName}" was not saved${reason}${code}${requestRef}.${revertNote}${channelNote}`;
|
|
111
|
-
|
|
112
|
-
if (isRepeat) {
|
|
113
|
-
// Same write rejected for the same reason on each reconnect replay —
|
|
114
|
-
// log the forensics once, stay quiet after.
|
|
115
|
-
logger.debug('write rejected again (same reason)', details);
|
|
116
|
-
} else if (isBenignIdempotent) {
|
|
117
|
-
// Already-exists on a `create` is expected on replay, not a problem.
|
|
118
|
-
logger.info(`Your ${transaction.type} to "${transaction.modelName}" was skipped — this row already exists.`);
|
|
119
|
-
logger.debug('idempotent skip — details', details);
|
|
120
|
-
} else {
|
|
121
|
-
logger.warn(headline);
|
|
122
|
-
logger.debug('write rejection — details', details);
|
|
123
|
-
}
|
|
124
|
-
} catch {}
|
|
125
|
-
|
|
126
|
-
// Mark as failed immediately and rollback
|
|
127
|
-
ctx.store.updateStatus(transaction.id, 'failed');
|
|
128
|
-
|
|
129
|
-
if (ctx.config.enableOptimistic) {
|
|
130
|
-
await ctx.rollbackOptimistic(transaction, 'permanent_error', error);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
ctx.emit('transaction:failed', { transaction, error, permanent: true });
|
|
134
|
-
// The id-suffixed event is what the awaited model-write promise listens
|
|
135
|
-
// on through `waitForConfirmation` — without it a permanently
|
|
136
|
-
// rejected write left the caller's promise hanging forever.
|
|
137
|
-
ctx.emit(`transaction:failed:${transaction.id}`, { error });
|
|
138
|
-
return;
|
|
42
|
+
export async function handleFailure(
|
|
43
|
+
ctx: FailureHandlingContext,
|
|
44
|
+
transaction: QueuedMutation,
|
|
45
|
+
error: Error,
|
|
46
|
+
): Promise<void> {
|
|
47
|
+
transaction.attempts++;
|
|
48
|
+
|
|
49
|
+
// Check whether this is a permanent error that should not be retried.
|
|
50
|
+
if (ctx.isPermanentError(error)) {
|
|
51
|
+
reportPermanentMutationFailure(
|
|
52
|
+
{
|
|
53
|
+
runtime: ctx.runtime,
|
|
54
|
+
enableOptimistic: ctx.config.enableOptimistic,
|
|
55
|
+
getLastPermanentErrorSignature: ctx.getLastPermanentErrorSignature,
|
|
56
|
+
setLastPermanentErrorSignature: ctx.setLastPermanentErrorSignature,
|
|
57
|
+
},
|
|
58
|
+
transaction,
|
|
59
|
+
error,
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
// Mark as failed immediately and rollback
|
|
63
|
+
ctx.store.updateStatus(transaction.id, 'failed');
|
|
64
|
+
|
|
65
|
+
if (ctx.config.enableOptimistic) {
|
|
66
|
+
await ctx.rollbackOptimistic(transaction, 'permanent_error', error);
|
|
139
67
|
}
|
|
140
68
|
|
|
141
|
-
transaction
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
// (429/503) use a longer base than other transient errors. The re-enqueue
|
|
149
|
-
// is scheduled rather than awaited, so one backing-off transaction cannot
|
|
150
|
-
// stall unrelated commits.
|
|
151
|
-
const delay = transientRetryDelayMs(error, transaction.attempts, ctx.config.retryBackoff);
|
|
152
|
-
|
|
153
|
-
ctx.store.updateStatus(transaction.id, 'pending');
|
|
154
|
-
setTimeout(() => {
|
|
155
|
-
// The queue may have shut down or the tx may have been settled
|
|
156
|
-
// (e.g. delta-confirmed) while we backed off.
|
|
157
|
-
if (ctx.store.get(transaction.id)?.status !== 'pending') return;
|
|
158
|
-
ctx.enqueue(transaction);
|
|
159
|
-
}, delay);
|
|
160
|
-
} else {
|
|
161
|
-
// Mark as failed and rollback
|
|
162
|
-
ctx.store.updateStatus(transaction.id, 'failed');
|
|
163
|
-
|
|
164
|
-
if (ctx.config.enableOptimistic) {
|
|
165
|
-
await ctx.rollbackOptimistic(transaction, 'max_retries_exhausted', error);
|
|
166
|
-
}
|
|
69
|
+
ctx.emit('transaction:failed', { transaction, error, permanent: true });
|
|
70
|
+
// The id-suffixed event is what the awaited model-write promise listens
|
|
71
|
+
// on through `waitForConfirmation` — without it a permanently
|
|
72
|
+
// rejected write left the caller's promise hanging forever.
|
|
73
|
+
ctx.emit(`transaction:failed:${transaction.id}`, { error });
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
167
76
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
77
|
+
transaction.firstTransientFailureAt ??= Date.now();
|
|
78
|
+
const insideAvailabilityWindow =
|
|
79
|
+
Date.now() - transaction.firstTransientFailureAt <
|
|
80
|
+
ctx.config.availabilityRetryWindowMs;
|
|
81
|
+
|
|
82
|
+
if (transaction.attempts < ctx.config.maxRetries || insideAvailabilityWindow) {
|
|
83
|
+
// Exponential backoff with full jitter on every transient retry:
|
|
84
|
+
// `sleep = random(0, min(cap, base * 2^attempt))`. Throttling responses
|
|
85
|
+
// (429/503) use a longer base than other transient errors. The re-enqueue
|
|
86
|
+
// is scheduled rather than awaited, so one backing-off transaction cannot
|
|
87
|
+
// stall unrelated commits.
|
|
88
|
+
const delay = transientRetryDelayMs(
|
|
89
|
+
error,
|
|
90
|
+
transaction.attempts,
|
|
91
|
+
ctx.config.retryBackoff,
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
ctx.store.updateStatus(transaction.id, 'pending');
|
|
95
|
+
setTimeout(() => {
|
|
96
|
+
// The queue may have shut down or the tx may have been settled
|
|
97
|
+
// (e.g. delta-confirmed) while we backed off.
|
|
98
|
+
if (ctx.store.get(transaction.id)?.status !== 'pending') return;
|
|
99
|
+
ctx.enqueue(transaction);
|
|
100
|
+
}, delay);
|
|
101
|
+
} else {
|
|
102
|
+
// Mark as failed and rollback
|
|
103
|
+
ctx.store.updateStatus(transaction.id, 'failed');
|
|
104
|
+
|
|
105
|
+
if (ctx.config.enableOptimistic) {
|
|
106
|
+
await ctx.rollbackOptimistic(transaction, 'max_retries_exhausted', error);
|
|
171
107
|
}
|
|
108
|
+
|
|
109
|
+
ctx.emit('transaction:failed', { transaction, error });
|
|
110
|
+
// Settle `waitForConfirmation` waiters (see the permanent branch above).
|
|
111
|
+
ctx.emit(`transaction:failed:${transaction.id}`, { error });
|
|
172
112
|
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { AbloError } from '@abloatai/transaction/errors';
|
|
2
|
+
import type { RuntimeContext } from '../../RuntimeContext.js';
|
|
3
|
+
import type { QueuedMutation } from './commitPayload.js';
|
|
4
|
+
|
|
5
|
+
const EXPECTED_COORDINATION_CODES = new Set([
|
|
6
|
+
'stale_context',
|
|
7
|
+
'claim_conflict',
|
|
8
|
+
'claim_queued',
|
|
9
|
+
'claim_lost',
|
|
10
|
+
'entity_claimed',
|
|
11
|
+
'model_claimed',
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
export interface PermanentFailureReportingContext {
|
|
15
|
+
readonly runtime: RuntimeContext;
|
|
16
|
+
readonly enableOptimistic: boolean;
|
|
17
|
+
readonly getLastPermanentErrorSignature: () => string | undefined;
|
|
18
|
+
readonly setLastPermanentErrorSignature: (signature: string) => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Report a terminal rejection at a severity that matches its meaning. */
|
|
22
|
+
export function reportPermanentMutationFailure(
|
|
23
|
+
ctx: PermanentFailureReportingContext,
|
|
24
|
+
transaction: QueuedMutation,
|
|
25
|
+
error: Error,
|
|
26
|
+
): void {
|
|
27
|
+
try {
|
|
28
|
+
const abloError = error instanceof AbloError ? error : undefined;
|
|
29
|
+
const details = {
|
|
30
|
+
txId: transaction.id.slice(0, 8),
|
|
31
|
+
type: transaction.type,
|
|
32
|
+
model: transaction.modelName,
|
|
33
|
+
modelId: transaction.modelId.slice(0, 12),
|
|
34
|
+
errorType: abloError?.type ?? error.name,
|
|
35
|
+
errorCode: abloError?.code,
|
|
36
|
+
httpStatus: abloError?.httpStatus,
|
|
37
|
+
requestId: abloError?.requestId,
|
|
38
|
+
message: error.message,
|
|
39
|
+
inputKeys: transaction.data ? Object.keys(transaction.data) : undefined,
|
|
40
|
+
};
|
|
41
|
+
const signature = `${details.type}:${details.model}:${details.modelId}:${details.errorCode ?? details.errorType}`;
|
|
42
|
+
const isRepeat = signature === ctx.getLastPermanentErrorSignature();
|
|
43
|
+
ctx.setLastPermanentErrorSignature(signature);
|
|
44
|
+
|
|
45
|
+
const logger = ctx.runtime.logger;
|
|
46
|
+
if (isRepeat) {
|
|
47
|
+
logger.debug('write rejected again (same reason)', details);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const isBenignIdempotent =
|
|
52
|
+
transaction.type === 'create' &&
|
|
53
|
+
(abloError?.code === 'unique_violation' ||
|
|
54
|
+
abloError?.type === 'AbloIdempotencyError');
|
|
55
|
+
if (isBenignIdempotent) {
|
|
56
|
+
logger.info(
|
|
57
|
+
`Your ${transaction.type} to "${transaction.modelName}" was skipped — this row already exists.`,
|
|
58
|
+
);
|
|
59
|
+
logger.debug('idempotent skip — details', details);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (abloError?.code && EXPECTED_COORDINATION_CODES.has(abloError.code)) {
|
|
64
|
+
const reverted = ctx.enableOptimistic
|
|
65
|
+
? ' The local edit was reverted.'
|
|
66
|
+
: '';
|
|
67
|
+
const explanation =
|
|
68
|
+
abloError.code === 'stale_context'
|
|
69
|
+
? 'it changed elsewhere before this save completed'
|
|
70
|
+
: 'another participant currently owns the conflicting work';
|
|
71
|
+
logger.info(
|
|
72
|
+
`Your ${transaction.type} to "${transaction.modelName}" was not saved because ${explanation}.${reverted}`,
|
|
73
|
+
);
|
|
74
|
+
logger.debug('coordination rejection — details', details);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const reason = abloError?.message ? ` — ${abloError.message}` : '';
|
|
79
|
+
const code = abloError?.code ? ` (code: ${abloError.code})` : '';
|
|
80
|
+
const requestReference = abloError?.requestId
|
|
81
|
+
? ` [request_id: ${abloError.requestId}]`
|
|
82
|
+
: '';
|
|
83
|
+
const reverted = ctx.enableOptimistic
|
|
84
|
+
? ' The local change was reverted.'
|
|
85
|
+
: '';
|
|
86
|
+
logger.warn(
|
|
87
|
+
`Your ${transaction.type} to "${transaction.modelName}" was not saved${reason}${code}${requestReference}.${reverted}`,
|
|
88
|
+
);
|
|
89
|
+
logger.debug('write rejection — details', details);
|
|
90
|
+
} catch {
|
|
91
|
+
// Diagnostics must never interfere with rollback and promise settlement.
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -4,7 +4,7 @@ import type { MutationStore } from './MutationStore.js';
|
|
|
4
4
|
import type { OptimisticUpdateEntry } from './localMutation.js';
|
|
5
5
|
import type { MutationCommitResult } from '@abloatai/transaction/commit';
|
|
6
6
|
import type { DurableCommitEnvelope } from '@abloatai/transaction/commit';
|
|
7
|
-
import { applyWriteOptions, TX_TYPE_TO_MUTATION_OP } from './commitPayload.js';
|
|
7
|
+
import { applyWriteOptions, collectQueuedReads, TX_TYPE_TO_MUTATION_OP } from './commitPayload.js';
|
|
8
8
|
|
|
9
9
|
export interface PendingDrainContext {
|
|
10
10
|
readonly runtime: RuntimeContext;
|
|
@@ -81,6 +81,7 @@ export async function drainPendingConfirmations(ctx: PendingDrainContext): Promi
|
|
|
81
81
|
origin: 'model_batch',
|
|
82
82
|
operations: projectedOperations,
|
|
83
83
|
sourceMutationIds: ctx.sourceMutationIdsFor(batch),
|
|
84
|
+
commitOptions: { reads: collectQueuedReads(batch) },
|
|
84
85
|
createdAt: Math.min(...batch.map((transaction) => transaction.createdAt)),
|
|
85
86
|
sealedAt: batch[0]?.commitEnvelope?.sealedAt ?? Date.now(),
|
|
86
87
|
sequence: batch[0]?.commitEnvelope?.sequence,
|
|
@@ -89,6 +90,9 @@ export async function drainPendingConfirmations(ctx: PendingDrainContext): Promi
|
|
|
89
90
|
const result = ctx.parseMutationCommitResult(
|
|
90
91
|
await ctx.dispatchCommitBounded(durableEnvelope.operations, {
|
|
91
92
|
idempotencyKey,
|
|
93
|
+
...(durableEnvelope.commitOptions.reads !== undefined
|
|
94
|
+
? { reads: durableEnvelope.commitOptions.reads }
|
|
95
|
+
: {}),
|
|
92
96
|
}),
|
|
93
97
|
);
|
|
94
98
|
await ctx.persistDurableCommitAcceptance(durableEnvelope, result);
|
|
@@ -24,11 +24,13 @@ import {
|
|
|
24
24
|
commitEnvelopeMemberSchema,
|
|
25
25
|
commitOutboxScopeSchema,
|
|
26
26
|
} from '@abloatai/transaction/commit';
|
|
27
|
+
import { readDependencySchema } from '@abloatai/transaction/coordination/schema';
|
|
27
28
|
|
|
28
29
|
/** The subset of a write's options that is stored with each transaction or queued mutation. */
|
|
29
30
|
const persistedWriteOptionsSchema = z
|
|
30
31
|
.object({
|
|
31
32
|
readAt: z.number().nullable().optional(),
|
|
33
|
+
reads: z.array(readDependencySchema).nullable().optional(),
|
|
32
34
|
idempotencyKey: z.string().optional(),
|
|
33
35
|
label: z.string().optional(),
|
|
34
36
|
// Aligned with the `WriteOptions` type: a claimed write persisted locally
|