@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
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { AbloError } from '@abloatai/transaction/errors';
|
|
2
|
+
const EXPECTED_COORDINATION_CODES = new Set([
|
|
3
|
+
'stale_context',
|
|
4
|
+
'claim_conflict',
|
|
5
|
+
'claim_queued',
|
|
6
|
+
'claim_lost',
|
|
7
|
+
'entity_claimed',
|
|
8
|
+
'model_claimed',
|
|
9
|
+
]);
|
|
10
|
+
/** Report a terminal rejection at a severity that matches its meaning. */
|
|
11
|
+
export function reportPermanentMutationFailure(ctx, transaction, error) {
|
|
12
|
+
try {
|
|
13
|
+
const abloError = error instanceof AbloError ? error : undefined;
|
|
14
|
+
const details = {
|
|
15
|
+
txId: transaction.id.slice(0, 8),
|
|
16
|
+
type: transaction.type,
|
|
17
|
+
model: transaction.modelName,
|
|
18
|
+
modelId: transaction.modelId.slice(0, 12),
|
|
19
|
+
errorType: abloError?.type ?? error.name,
|
|
20
|
+
errorCode: abloError?.code,
|
|
21
|
+
httpStatus: abloError?.httpStatus,
|
|
22
|
+
requestId: abloError?.requestId,
|
|
23
|
+
message: error.message,
|
|
24
|
+
inputKeys: transaction.data ? Object.keys(transaction.data) : undefined,
|
|
25
|
+
};
|
|
26
|
+
const signature = `${details.type}:${details.model}:${details.modelId}:${details.errorCode ?? details.errorType}`;
|
|
27
|
+
const isRepeat = signature === ctx.getLastPermanentErrorSignature();
|
|
28
|
+
ctx.setLastPermanentErrorSignature(signature);
|
|
29
|
+
const logger = ctx.runtime.logger;
|
|
30
|
+
if (isRepeat) {
|
|
31
|
+
logger.debug('write rejected again (same reason)', details);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const isBenignIdempotent = transaction.type === 'create' &&
|
|
35
|
+
(abloError?.code === 'unique_violation' ||
|
|
36
|
+
abloError?.type === 'AbloIdempotencyError');
|
|
37
|
+
if (isBenignIdempotent) {
|
|
38
|
+
logger.info(`Your ${transaction.type} to "${transaction.modelName}" was skipped — this row already exists.`);
|
|
39
|
+
logger.debug('idempotent skip — details', details);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (abloError?.code && EXPECTED_COORDINATION_CODES.has(abloError.code)) {
|
|
43
|
+
const reverted = ctx.enableOptimistic
|
|
44
|
+
? ' The local edit was reverted.'
|
|
45
|
+
: '';
|
|
46
|
+
const explanation = abloError.code === 'stale_context'
|
|
47
|
+
? 'it changed elsewhere before this save completed'
|
|
48
|
+
: 'another participant currently owns the conflicting work';
|
|
49
|
+
logger.info(`Your ${transaction.type} to "${transaction.modelName}" was not saved because ${explanation}.${reverted}`);
|
|
50
|
+
logger.debug('coordination rejection — details', details);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const reason = abloError?.message ? ` — ${abloError.message}` : '';
|
|
54
|
+
const code = abloError?.code ? ` (code: ${abloError.code})` : '';
|
|
55
|
+
const requestReference = abloError?.requestId
|
|
56
|
+
? ` [request_id: ${abloError.requestId}]`
|
|
57
|
+
: '';
|
|
58
|
+
const reverted = ctx.enableOptimistic
|
|
59
|
+
? ' The local change was reverted.'
|
|
60
|
+
: '';
|
|
61
|
+
logger.warn(`Your ${transaction.type} to "${transaction.modelName}" was not saved${reason}${code}${requestReference}.${reverted}`);
|
|
62
|
+
logger.debug('write rejection — details', details);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// Diagnostics must never interfere with rollback and promise settlement.
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { applyWriteOptions, TX_TYPE_TO_MUTATION_OP } from './commitPayload.js';
|
|
1
|
+
import { applyWriteOptions, collectQueuedReads, TX_TYPE_TO_MUTATION_OP } from './commitPayload.js';
|
|
2
2
|
export async function drainPendingConfirmations(ctx) {
|
|
3
3
|
ctx.assertDurableReplayOpen();
|
|
4
4
|
// Kick the commit lane too: atomic envelopes from `commits.create()` may
|
|
@@ -39,6 +39,7 @@ export async function drainPendingConfirmations(ctx) {
|
|
|
39
39
|
origin: 'model_batch',
|
|
40
40
|
operations: projectedOperations,
|
|
41
41
|
sourceMutationIds: ctx.sourceMutationIdsFor(batch),
|
|
42
|
+
commitOptions: { reads: collectQueuedReads(batch) },
|
|
42
43
|
createdAt: Math.min(...batch.map((transaction) => transaction.createdAt)),
|
|
43
44
|
sealedAt: batch[0]?.commitEnvelope?.sealedAt ?? Date.now(),
|
|
44
45
|
sequence: batch[0]?.commitEnvelope?.sequence,
|
|
@@ -46,6 +47,9 @@ export async function drainPendingConfirmations(ctx) {
|
|
|
46
47
|
ctx.assertEnvelopeInsideReplayWindow(durableEnvelope);
|
|
47
48
|
const result = ctx.parseMutationCommitResult(await ctx.dispatchCommitBounded(durableEnvelope.operations, {
|
|
48
49
|
idempotencyKey,
|
|
50
|
+
...(durableEnvelope.commitOptions.reads !== undefined
|
|
51
|
+
? { reads: durableEnvelope.commitOptions.reads }
|
|
52
|
+
: {}),
|
|
49
53
|
}));
|
|
50
54
|
await ctx.persistDurableCommitAcceptance(durableEnvelope, result);
|
|
51
55
|
if (result.status === 'queued') {
|
|
@@ -27,11 +27,11 @@ import type { RuntimeContext } from '../../RuntimeContext.js';
|
|
|
27
27
|
export declare const persistedTransactionSchema: z.ZodObject<{
|
|
28
28
|
id: z.ZodString;
|
|
29
29
|
type: z.ZodEnum<{
|
|
30
|
-
|
|
30
|
+
archive: "archive";
|
|
31
31
|
create: "create";
|
|
32
32
|
delete: "delete";
|
|
33
|
-
archive: "archive";
|
|
34
33
|
unarchive: "unarchive";
|
|
34
|
+
update: "update";
|
|
35
35
|
}>;
|
|
36
36
|
modelName: z.ZodString;
|
|
37
37
|
modelId: z.ZodString;
|
|
@@ -56,6 +56,15 @@ export declare const persistedTransactionSchema: z.ZodObject<{
|
|
|
56
56
|
sourceMutationIds: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
57
57
|
writeOptions: z.ZodOptional<z.ZodObject<{
|
|
58
58
|
readAt: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
|
|
59
|
+
reads: z.ZodOptional<z.ZodNullable<z.ZodArray<z.ZodUnion<readonly [z.ZodObject<{
|
|
60
|
+
model: z.ZodString;
|
|
61
|
+
id: z.ZodString;
|
|
62
|
+
fields: z.ZodOptional<z.ZodReadonly<z.ZodArray<z.ZodString>>>;
|
|
63
|
+
readAt: z.ZodNumber;
|
|
64
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
65
|
+
group: z.ZodTemplateLiteral<`${string}:${string}`>;
|
|
66
|
+
readAt: z.ZodNumber;
|
|
67
|
+
}, z.core.$strip>]>>>>;
|
|
59
68
|
idempotencyKey: z.ZodOptional<z.ZodString>;
|
|
60
69
|
label: z.ZodOptional<z.ZodString>;
|
|
61
70
|
fenceToken: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
|
|
@@ -86,10 +95,10 @@ export declare function deserializePersistedTransaction(row: unknown, runtime?:
|
|
|
86
95
|
export declare const persistedMutationSchema: z.ZodObject<{
|
|
87
96
|
mutationId: z.ZodOptional<z.ZodString>;
|
|
88
97
|
type: z.ZodEnum<{
|
|
89
|
-
|
|
98
|
+
archive: "archive";
|
|
90
99
|
create: "create";
|
|
91
100
|
delete: "delete";
|
|
92
|
-
|
|
101
|
+
update: "update";
|
|
93
102
|
}>;
|
|
94
103
|
modelData: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
|
95
104
|
modelName: z.ZodString;
|
|
@@ -97,6 +106,15 @@ export declare const persistedMutationSchema: z.ZodObject<{
|
|
|
97
106
|
capturedChanges: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
98
107
|
writeOptions: z.ZodOptional<z.ZodObject<{
|
|
99
108
|
readAt: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
|
|
109
|
+
reads: z.ZodOptional<z.ZodNullable<z.ZodArray<z.ZodUnion<readonly [z.ZodObject<{
|
|
110
|
+
model: z.ZodString;
|
|
111
|
+
id: z.ZodString;
|
|
112
|
+
fields: z.ZodOptional<z.ZodReadonly<z.ZodArray<z.ZodString>>>;
|
|
113
|
+
readAt: z.ZodNumber;
|
|
114
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
115
|
+
group: z.ZodTemplateLiteral<`${string}:${string}`>;
|
|
116
|
+
readAt: z.ZodNumber;
|
|
117
|
+
}, z.core.$strip>]>>>>;
|
|
100
118
|
idempotencyKey: z.ZodOptional<z.ZodString>;
|
|
101
119
|
label: z.ZodOptional<z.ZodString>;
|
|
102
120
|
fenceToken: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
|
|
@@ -115,15 +133,14 @@ export declare const PENDING_MUTATION_RECORD_PREFIX = "pending-mutation:";
|
|
|
115
133
|
export declare const PENDING_MUTATION_REPLAY_WINDOW_MS: number;
|
|
116
134
|
/** Scope-less records written by the first aggregate-journal release. */
|
|
117
135
|
export declare const legacyPendingMutationRecordSchema: z.ZodObject<{
|
|
118
|
-
storageVersion: z.ZodLiteral<1>;
|
|
119
136
|
id: z.ZodString;
|
|
120
137
|
type: z.ZodLiteral<"pending_mutation">;
|
|
121
138
|
mutation: z.ZodObject<{
|
|
122
139
|
type: z.ZodEnum<{
|
|
123
|
-
|
|
140
|
+
archive: "archive";
|
|
124
141
|
create: "create";
|
|
125
142
|
delete: "delete";
|
|
126
|
-
|
|
143
|
+
update: "update";
|
|
127
144
|
}>;
|
|
128
145
|
modelData: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
|
129
146
|
modelName: z.ZodString;
|
|
@@ -131,6 +148,15 @@ export declare const legacyPendingMutationRecordSchema: z.ZodObject<{
|
|
|
131
148
|
capturedChanges: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
132
149
|
writeOptions: z.ZodOptional<z.ZodObject<{
|
|
133
150
|
readAt: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
|
|
151
|
+
reads: z.ZodOptional<z.ZodNullable<z.ZodArray<z.ZodUnion<readonly [z.ZodObject<{
|
|
152
|
+
model: z.ZodString;
|
|
153
|
+
id: z.ZodString;
|
|
154
|
+
fields: z.ZodOptional<z.ZodReadonly<z.ZodArray<z.ZodString>>>;
|
|
155
|
+
readAt: z.ZodNumber;
|
|
156
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
157
|
+
group: z.ZodTemplateLiteral<`${string}:${string}`>;
|
|
158
|
+
readAt: z.ZodNumber;
|
|
159
|
+
}, z.core.$strip>]>>>>;
|
|
134
160
|
idempotencyKey: z.ZodOptional<z.ZodString>;
|
|
135
161
|
label: z.ZodOptional<z.ZodString>;
|
|
136
162
|
fenceToken: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
|
|
@@ -141,22 +167,17 @@ export declare const legacyPendingMutationRecordSchema: z.ZodObject<{
|
|
|
141
167
|
mutationId: z.ZodString;
|
|
142
168
|
}, z.core.$loose>;
|
|
143
169
|
timestamp: z.ZodNumber;
|
|
170
|
+
storageVersion: z.ZodLiteral<1>;
|
|
144
171
|
}, z.core.$strict>;
|
|
145
172
|
export declare const pendingMutationRecordSchema: z.ZodObject<{
|
|
146
|
-
storageVersion: z.ZodLiteral<2>;
|
|
147
|
-
scope: z.ZodObject<{
|
|
148
|
-
organizationId: z.ZodString;
|
|
149
|
-
participantId: z.ZodString;
|
|
150
|
-
namespace: z.ZodString;
|
|
151
|
-
}, z.core.$strict>;
|
|
152
173
|
id: z.ZodString;
|
|
153
174
|
type: z.ZodLiteral<"pending_mutation">;
|
|
154
175
|
mutation: z.ZodObject<{
|
|
155
176
|
type: z.ZodEnum<{
|
|
156
|
-
|
|
177
|
+
archive: "archive";
|
|
157
178
|
create: "create";
|
|
158
179
|
delete: "delete";
|
|
159
|
-
|
|
180
|
+
update: "update";
|
|
160
181
|
}>;
|
|
161
182
|
modelData: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
|
162
183
|
modelName: z.ZodString;
|
|
@@ -164,6 +185,15 @@ export declare const pendingMutationRecordSchema: z.ZodObject<{
|
|
|
164
185
|
capturedChanges: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
165
186
|
writeOptions: z.ZodOptional<z.ZodObject<{
|
|
166
187
|
readAt: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
|
|
188
|
+
reads: z.ZodOptional<z.ZodNullable<z.ZodArray<z.ZodUnion<readonly [z.ZodObject<{
|
|
189
|
+
model: z.ZodString;
|
|
190
|
+
id: z.ZodString;
|
|
191
|
+
fields: z.ZodOptional<z.ZodReadonly<z.ZodArray<z.ZodString>>>;
|
|
192
|
+
readAt: z.ZodNumber;
|
|
193
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
194
|
+
group: z.ZodTemplateLiteral<`${string}:${string}`>;
|
|
195
|
+
readAt: z.ZodNumber;
|
|
196
|
+
}, z.core.$strip>]>>>>;
|
|
167
197
|
idempotencyKey: z.ZodOptional<z.ZodString>;
|
|
168
198
|
label: z.ZodOptional<z.ZodString>;
|
|
169
199
|
fenceToken: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
|
|
@@ -174,6 +204,12 @@ export declare const pendingMutationRecordSchema: z.ZodObject<{
|
|
|
174
204
|
mutationId: z.ZodString;
|
|
175
205
|
}, z.core.$loose>;
|
|
176
206
|
timestamp: z.ZodNumber;
|
|
207
|
+
storageVersion: z.ZodLiteral<2>;
|
|
208
|
+
scope: z.ZodObject<{
|
|
209
|
+
organizationId: z.ZodString;
|
|
210
|
+
participantId: z.ZodString;
|
|
211
|
+
namespace: z.ZodString;
|
|
212
|
+
}, z.core.$strict>;
|
|
177
213
|
}, z.core.$strict>;
|
|
178
214
|
export type PendingMutationRecord = z.infer<typeof pendingMutationRecordSchema>;
|
|
179
215
|
export declare function pendingMutationRecordId(mutationId: string): string;
|
|
@@ -18,10 +18,12 @@ import { z } from 'zod';
|
|
|
18
18
|
import { computePriorityScore, normalizeModelKey } from './commitPayload.js';
|
|
19
19
|
import { globalRuntime } from '../../context.js';
|
|
20
20
|
import { commitEnvelopeMemberSchema, commitOutboxScopeSchema, } from '@abloatai/transaction/commit';
|
|
21
|
+
import { readDependencySchema } from '@abloatai/transaction/coordination/schema';
|
|
21
22
|
/** The subset of a write's options that is stored with each transaction or queued mutation. */
|
|
22
23
|
const persistedWriteOptionsSchema = z
|
|
23
24
|
.object({
|
|
24
25
|
readAt: z.number().nullable().optional(),
|
|
26
|
+
reads: z.array(readDependencySchema).nullable().optional(),
|
|
25
27
|
idempotencyKey: z.string().optional(),
|
|
26
28
|
label: z.string().optional(),
|
|
27
29
|
// Aligned with the `WriteOptions` type: a claimed write persisted locally
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
'use client';
|
|
2
|
-
import {
|
|
2
|
+
import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
|
|
3
3
|
import { useCallback, useContext, useEffect, useMemo, useRef, useState, createContext, } from 'react';
|
|
4
4
|
import { createParticipantClaimId, parseParticipantTtlSeconds, resolveParticipantSyncGroups, } from '../local/sync/participants.js';
|
|
5
5
|
import { SyncContext } from './context.js';
|
|
@@ -33,4 +33,4 @@ export interface ClientSideSuspenseProps {
|
|
|
33
33
|
/** What to render once the subtree is cleared to render. */
|
|
34
34
|
children: ReactNode;
|
|
35
35
|
}
|
|
36
|
-
export declare function ClientSideSuspense({ fallback, children }: ClientSideSuspenseProps): import("react").JSX.Element;
|
|
36
|
+
export declare function ClientSideSuspense({ fallback, children }: ClientSideSuspenseProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -21,4 +21,4 @@
|
|
|
21
21
|
* pass `fallback={null}`. Consumers who want to skip the gate entirely
|
|
22
22
|
* pass `fallback="passthrough"`.
|
|
23
23
|
*/
|
|
24
|
-
export declare function DefaultFallback(): import("react").JSX.Element;
|
|
24
|
+
export declare function DefaultFallback(): import("react/jsx-runtime").JSX.Element;
|
|
@@ -38,7 +38,7 @@ export function createAbloReact(schema) {
|
|
|
38
38
|
// implementation's internal-context fallback.
|
|
39
39
|
const BoundClientContext = createContext(null);
|
|
40
40
|
function BoundAbloProvider(props) {
|
|
41
|
-
return createElement(BoundClientContext.Provider, { value: props.client }, createElement(
|
|
41
|
+
return createElement(BoundClientContext.Provider, { value: props.client }, createElement(AbloProvider, props));
|
|
42
42
|
}
|
|
43
43
|
function useBoundAblo(modelOrSelect, id, options) {
|
|
44
44
|
const bound = useContext(BoundClientContext);
|
package/dist/surface.d.ts
CHANGED
|
@@ -19,18 +19,18 @@
|
|
|
19
19
|
* tuple, so it is the one list of model-verb names a generated summary can
|
|
20
20
|
* describe.
|
|
21
21
|
*/
|
|
22
|
-
export declare const PUBLIC_MODEL_VERBS: readonly [
|
|
22
|
+
export declare const PUBLIC_MODEL_VERBS: readonly ['get', 'read', 'list', 'listAll', 'local', 'create', 'update', 'delete', 'claim', 'join', 'onChange'];
|
|
23
23
|
/**
|
|
24
24
|
* The option keys accepted by `local.list` and `onChange`, matching the
|
|
25
25
|
* keys of {@link LocalReadOptions}. Note that the lifecycle filter is named
|
|
26
26
|
* `state`, not `scope`.
|
|
27
27
|
*/
|
|
28
|
-
export declare const PUBLIC_LIST_OPTION_KEYS: readonly [
|
|
28
|
+
export declare const PUBLIC_LIST_OPTION_KEYS: readonly ['where', 'filter', 'orderBy', 'limit', 'offset', 'state'];
|
|
29
29
|
/**
|
|
30
30
|
* The keys of the client constructor options, {@link AbloOptions}. Only
|
|
31
31
|
* `schema` is required; every other key is optional.
|
|
32
32
|
*/
|
|
33
|
-
export declare const PUBLIC_ABLO_OPTION_KEYS: readonly [
|
|
33
|
+
export declare const PUBLIC_ABLO_OPTION_KEYS: readonly ['schema', 'apiKey', 'projectId', 'branchId', 'authEndpoint', 'authTimeoutMs', 'allowCrossOriginAuthEndpoint', 'persistence', 'durableWrites', 'commitOutbox', 'commitOutboxScope', 'debug', 'logLevel', 'logger', 'authToken', 'baseURL', 'fetch', 'defaultHeaders', 'defaultQuery', 'dangerouslyAllowBrowser', 'collaborationEvents', 'plugins'];
|
|
34
34
|
export type ModelVerb = (typeof PUBLIC_MODEL_VERBS)[number];
|
|
35
35
|
export type ListOptionKey = (typeof PUBLIC_LIST_OPTION_KEYS)[number];
|
|
36
36
|
export type AbloOptionKey = (typeof PUBLIC_ABLO_OPTION_KEYS)[number];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@abloatai/humans",
|
|
3
|
-
"version": "0.59.
|
|
3
|
+
"version": "0.59.2",
|
|
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",
|
|
@@ -84,7 +84,7 @@
|
|
|
84
84
|
"directory": "packages/humans"
|
|
85
85
|
},
|
|
86
86
|
"dependencies": {
|
|
87
|
-
"@abloatai/transaction": "^0.59.
|
|
87
|
+
"@abloatai/transaction": "^0.59.2",
|
|
88
88
|
"mobx": "^6.13.7",
|
|
89
89
|
"uuid": "^11.1.0",
|
|
90
90
|
"zod": "^4.4.3"
|
|
@@ -1578,7 +1578,6 @@ export class BaseSyncedStore<
|
|
|
1578
1578
|
acknowledge: (syncId) => { this.syncWebSocket.acknowledge(syncId); },
|
|
1579
1579
|
get objectPool() { return store.objectPool; },
|
|
1580
1580
|
// Dynamic-dispatch hooks — protected override points on this class.
|
|
1581
|
-
getStateFields: (modelName) => this.getStateFields(modelName),
|
|
1582
1581
|
isCustomEntity: (modelName) => this.isCustomEntity(modelName),
|
|
1583
1582
|
createCustomEntity: (modelName, modelId, data) =>
|
|
1584
1583
|
this.createCustomEntity(modelName, modelId, data),
|
|
@@ -1607,14 +1606,9 @@ export class BaseSyncedStore<
|
|
|
1607
1606
|
);
|
|
1608
1607
|
}
|
|
1609
1608
|
|
|
1610
|
-
/**
|
|
1611
|
-
protected getStateFields(_modelName: string): string[] {
|
|
1612
|
-
return ['status', 'state', 'isActive'];
|
|
1613
|
-
}
|
|
1614
|
-
|
|
1615
|
-
/** Deduplicate deltas to the same entity — keep meaningful state transitions only */
|
|
1609
|
+
/** Deduplicate repeated delivery of the same positive sync id. */
|
|
1616
1610
|
protected deduplicateDeltas(deltas: SyncDelta[]): SyncDelta[] {
|
|
1617
|
-
return deltaPipeline.deduplicateDeltas(
|
|
1611
|
+
return deltaPipeline.deduplicateDeltas(deltas);
|
|
1618
1612
|
}
|
|
1619
1613
|
|
|
1620
1614
|
/** Process incoming delta with smart batching */
|
package/src/local/Model.ts
CHANGED
|
@@ -412,7 +412,7 @@ export abstract class Model {
|
|
|
412
412
|
opts?: { fallbackToLive?: boolean },
|
|
413
413
|
): ModelData {
|
|
414
414
|
const out: ModelData = {};
|
|
415
|
-
const modified = this.modifiedProperties
|
|
415
|
+
const modified = this.modifiedProperties;
|
|
416
416
|
const original = this.getOriginalSnapshot();
|
|
417
417
|
for (const key of keys) {
|
|
418
418
|
if (key === 'id') continue;
|
|
@@ -438,7 +438,7 @@ export abstract class Model {
|
|
|
438
438
|
* is never consumed. With no `keys`, consumes every tracked field.
|
|
439
439
|
*/
|
|
440
440
|
consumeModifiedFields(keys?: Iterable<string>): void {
|
|
441
|
-
if (
|
|
441
|
+
if (this.modifiedProperties.size === 0) {
|
|
442
442
|
return;
|
|
443
443
|
}
|
|
444
444
|
const only = keys ? new Set(keys) : null;
|
package/src/local/SyncClient.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { InstanceCache, ModelScope } from './InstanceCache.js';
|
|
|
13
13
|
import { Model } from './Model.js';
|
|
14
14
|
import type { ModelData } from '@abloatai/transaction/types/modelData';
|
|
15
15
|
import type { AppliedChange } from '../plugin.js';
|
|
16
|
-
import { snapshotJsonValue } from '@abloatai/transaction/utils/json';
|
|
16
|
+
import { deepEqual, snapshotJsonValue } from '@abloatai/transaction/utils/json';
|
|
17
17
|
// ModelRegistry instance accessed via this.objectPool.registry
|
|
18
18
|
import { LoadStrategy } from '@abloatai/transaction/types';
|
|
19
19
|
import { globalRuntime } from './context.js';
|
|
@@ -1938,6 +1938,27 @@ export class SyncClient extends EventEmitter {
|
|
|
1938
1938
|
// otherwise re-add it for the brief window before the matching delete
|
|
1939
1939
|
// confirmation lands.
|
|
1940
1940
|
if (this.echoTracker.consumeEcho(transactionId)) {
|
|
1941
|
+
// A direct assignment can re-enter change tracking while this
|
|
1942
|
+
// optimistic write is in flight. Leaving the acknowledged field dirty
|
|
1943
|
+
// makes conflict resolution preserve it over the next collaborator
|
|
1944
|
+
// delta, so peers appear desynchronized until refresh.
|
|
1945
|
+
//
|
|
1946
|
+
// Re-baseline only values this echo actually confirms. If the user has
|
|
1947
|
+
// edited the same field again since the write was sent, its current
|
|
1948
|
+
// dirty value differs from the echo and remains queued.
|
|
1949
|
+
if (resident && result.data) {
|
|
1950
|
+
const acknowledgedFields: string[] = [];
|
|
1951
|
+
for (const [field, change] of resident.modifiedProperties) {
|
|
1952
|
+
if (
|
|
1953
|
+
Object.prototype.hasOwnProperty.call(result.data, field) &&
|
|
1954
|
+
deepEqual(change.new, result.data[field])
|
|
1955
|
+
) {
|
|
1956
|
+
acknowledgedFields.push(field);
|
|
1957
|
+
}
|
|
1958
|
+
}
|
|
1959
|
+
resident.consumeModifiedFields(acknowledgedFields);
|
|
1960
|
+
resident.markAsSynced();
|
|
1961
|
+
}
|
|
1941
1962
|
continue;
|
|
1942
1963
|
}
|
|
1943
1964
|
|
|
@@ -242,7 +242,7 @@ import type { MutationOptions } from '@abloatai/transaction/client/resources/mut
|
|
|
242
242
|
*/
|
|
243
243
|
export type WriteOptions = Pick<
|
|
244
244
|
MutationOptions,
|
|
245
|
-
'readAt' | 'idempotencyKey' | 'label' | 'fenceToken' | 'claimRef'
|
|
245
|
+
'readAt' | 'reads' | 'idempotencyKey' | 'label' | 'fenceToken' | 'claimRef'
|
|
246
246
|
>;
|
|
247
247
|
|
|
248
248
|
/** A single mutation within a batch. Its `options` travel with it so the server
|
|
@@ -102,7 +102,6 @@ export interface DeltaPipelineContext {
|
|
|
102
102
|
};
|
|
103
103
|
|
|
104
104
|
// ── Dynamic-dispatch hooks back into the store (protected override points) ──
|
|
105
|
-
getStateFields(modelName: string): string[];
|
|
106
105
|
isCustomEntity(modelName: string): boolean;
|
|
107
106
|
createCustomEntity(modelName: string, modelId: string, data: Record<string, unknown>): Model | null;
|
|
108
107
|
deduplicateDeltas(deltas: SyncDelta[]): SyncDelta[];
|
|
@@ -159,91 +158,36 @@ export function handleGroupHandlerFailure(
|
|
|
159
158
|
}
|
|
160
159
|
}
|
|
161
160
|
|
|
162
|
-
/**
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
const fieldsToCheck = ctx.getStateFields(delta.modelName);
|
|
175
|
-
const signature: Record<string, unknown> = {
|
|
176
|
-
actionType: delta.actionType,
|
|
177
|
-
modelName: delta.modelName,
|
|
178
|
-
};
|
|
179
|
-
|
|
180
|
-
for (const field of fieldsToCheck) {
|
|
181
|
-
if (field in data) signature[field] = data[field];
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
return signature;
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
function isSameState(a: Record<string, unknown> | null, b: Record<string, unknown> | null): boolean {
|
|
188
|
-
if (!a || !b) return false;
|
|
189
|
-
const keys = Object.keys(a);
|
|
190
|
-
if (keys.length !== Object.keys(b).length) return false;
|
|
191
|
-
return keys.every((k) => a[k] === b[k]);
|
|
192
|
-
}
|
|
161
|
+
/**
|
|
162
|
+
* Deduplicate repeated delivery of the same log entry.
|
|
163
|
+
*
|
|
164
|
+
* A row may legitimately change several times in one receive frame. Those
|
|
165
|
+
* changes are ordered facts, even when a small subset of fields (such as
|
|
166
|
+
* `status`) happens to remain equal. Collapsing by entity or a partial state
|
|
167
|
+
* signature can therefore discard the newest row image while the cursor still
|
|
168
|
+
* advances past it. Only an identical positive sync id proves duplicate
|
|
169
|
+
* delivery; non-positive ids carry no usable log identity and stay untouched.
|
|
170
|
+
*/
|
|
171
|
+
export function deduplicateDeltas(deltas: SyncDelta[]): SyncDelta[] {
|
|
172
|
+
if (deltas.length < 2 || deltas.some((delta) => delta.id <= 0)) return deltas;
|
|
193
173
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
// or reorder anything: preserve the already commit-ordered input directly
|
|
199
|
-
// and avoid allocating a bucket array, state signature, and two sorts per
|
|
200
|
-
// delta. The first duplicate falls through to the full transition logic.
|
|
201
|
-
const uniqueEntities = new Set<string>();
|
|
202
|
-
let hasDuplicateEntity = false;
|
|
203
|
-
for (const delta of deltas) {
|
|
204
|
-
const key = `${delta.modelName}:${delta.modelId}`;
|
|
205
|
-
if (uniqueEntities.has(key)) {
|
|
206
|
-
hasDuplicateEntity = true;
|
|
174
|
+
let strictlyOrdered = true;
|
|
175
|
+
for (let index = 1; index < deltas.length; index += 1) {
|
|
176
|
+
if (deltas[index - 1]!.id >= deltas[index]!.id) {
|
|
177
|
+
strictlyOrdered = false;
|
|
207
178
|
break;
|
|
208
179
|
}
|
|
209
|
-
uniqueEntities.add(key);
|
|
210
|
-
}
|
|
211
|
-
if (!hasDuplicateEntity) return deltas;
|
|
212
|
-
|
|
213
|
-
const byEntity = new Map<string, SyncDelta[]>();
|
|
214
|
-
for (const d of deltas) {
|
|
215
|
-
const key = `${d.modelName}:${d.modelId}`;
|
|
216
|
-
if (!byEntity.has(key)) byEntity.set(key, []);
|
|
217
|
-
byEntity.get(key)!.push(d);
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
const result: SyncDelta[] = [];
|
|
221
|
-
for (const entityDeltas of byEntity.values()) {
|
|
222
|
-
const sorted = entityDeltas.sort((a, b) => a.id - b.id);
|
|
223
|
-
|
|
224
|
-
// DELETE wins — it's the final state
|
|
225
|
-
const del = sorted.find((d) => d.actionType === 'D');
|
|
226
|
-
if (del) { result.push(del); continue; }
|
|
227
|
-
|
|
228
|
-
// Keep deltas that represent different states
|
|
229
|
-
const unique: SyncDelta[] = [];
|
|
230
|
-
let prev: Record<string, unknown> | null = null;
|
|
231
|
-
for (const d of sorted) {
|
|
232
|
-
const sig = extractStateSignature(ctx, d);
|
|
233
|
-
if (!isSameState(prev, sig)) { unique.push(d); prev = sig; }
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
if (unique.length > 0) {
|
|
237
|
-
result.push(...unique);
|
|
238
|
-
} else {
|
|
239
|
-
// `sorted` is never empty (every byEntity bucket gets at least one
|
|
240
|
-
// delta pushed) — the guard only narrows the indexed access.
|
|
241
|
-
const last = sorted.at(-1);
|
|
242
|
-
if (last) result.push(last);
|
|
243
|
-
}
|
|
244
180
|
}
|
|
245
|
-
|
|
246
|
-
|
|
181
|
+
if (strictlyOrdered) return deltas;
|
|
182
|
+
|
|
183
|
+
const seen = new Set<number>();
|
|
184
|
+
return [...deltas]
|
|
185
|
+
.sort((a, b) => a.id - b.id)
|
|
186
|
+
.filter((delta) => {
|
|
187
|
+
if (seen.has(delta.id)) return false;
|
|
188
|
+
seen.add(delta.id);
|
|
189
|
+
return true;
|
|
190
|
+
});
|
|
247
191
|
}
|
|
248
192
|
|
|
249
193
|
/**
|
|
@@ -7,7 +7,7 @@ import type { DeltaConfirmationTracker } from './deltaConfirmation.js';
|
|
|
7
7
|
import type { MutationCommitResult } from '@abloatai/transaction/commit';
|
|
8
8
|
import type { DurableCommitEnvelope } from '@abloatai/transaction/commit';
|
|
9
9
|
import { AbloError, AbloNotFoundError } from '@abloatai/transaction/errors';
|
|
10
|
-
import { applyWriteOptions, normalizeModelKey, TX_TYPE_TO_MUTATION_OP, type WriteOperationFields } from './commitPayload.js';
|
|
10
|
+
import { applyWriteOptions, collectQueuedReads, normalizeModelKey, TX_TYPE_TO_MUTATION_OP, type WriteOperationFields } from './commitPayload.js';
|
|
11
11
|
import type { MutationOperationType } from '@abloatai/transaction/types';
|
|
12
12
|
|
|
13
13
|
export interface BatchProcessingContext {
|
|
@@ -138,6 +138,7 @@ export async function processBatch(ctx: BatchProcessingContext): Promise<void> {
|
|
|
138
138
|
origin: 'model_batch',
|
|
139
139
|
operations: batchOps.map(({ op }) => op),
|
|
140
140
|
sourceMutationIds: ctx.sourceMutationIdsFor(batch),
|
|
141
|
+
commitOptions: { reads: collectQueuedReads(batch) },
|
|
141
142
|
createdAt: Math.min(...batch.map((transaction) => transaction.createdAt)),
|
|
142
143
|
sealedAt: batch[0]?.commitEnvelope?.sealedAt ?? Date.now(),
|
|
143
144
|
sequence: batch[0]?.commitEnvelope?.sequence,
|
|
@@ -155,6 +156,9 @@ export async function processBatch(ctx: BatchProcessingContext): Promise<void> {
|
|
|
155
156
|
const result = ctx.parseMutationCommitResult(
|
|
156
157
|
await ctx.dispatchCommitBounded(operations, {
|
|
157
158
|
idempotencyKey: commitIdempotencyKey,
|
|
159
|
+
...(durableEnvelope.commitOptions.reads !== undefined
|
|
160
|
+
? { reads: durableEnvelope.commitOptions.reads }
|
|
161
|
+
: {}),
|
|
158
162
|
}),
|
|
159
163
|
);
|
|
160
164
|
await ctx.persistDurableCommitAcceptance(
|
|
@@ -160,6 +160,23 @@ export interface QueuedMutation {
|
|
|
160
160
|
confirmation?: Promise<void>;
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
+
/** Merge per-write premises into the one batch-level read set sent on wire. */
|
|
164
|
+
export function collectQueuedReads(
|
|
165
|
+
transactions: readonly QueuedMutation[],
|
|
166
|
+
): MutationOptions['reads'] | undefined {
|
|
167
|
+
const declared = transactions
|
|
168
|
+
.map((transaction) => transaction.writeOptions?.reads)
|
|
169
|
+
.filter((reads) => reads !== undefined);
|
|
170
|
+
if (declared.length === 0) return undefined;
|
|
171
|
+
|
|
172
|
+
const unique = new Map<string, NonNullable<MutationOptions['reads']>[number]>();
|
|
173
|
+
for (const dependency of declared.flatMap((reads) => reads ?? [])) {
|
|
174
|
+
unique.set(JSON.stringify(dependency), dependency);
|
|
175
|
+
}
|
|
176
|
+
const reads = [...unique.values()];
|
|
177
|
+
return reads.length > 0 ? reads : null;
|
|
178
|
+
}
|
|
179
|
+
|
|
163
180
|
export const normalizeModelKey = (modelName: string): string =>
|
|
164
181
|
modelName.replace('Model', '').toLowerCase();
|
|
165
182
|
export const stripModelSuffix = (modelName: string): string => modelName.replace('Model', '');
|