@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.
Files changed (41) hide show
  1. package/dist/humans.d.ts +1 -1
  2. package/dist/local/BaseSyncedStore.d.ts +1 -3
  3. package/dist/local/BaseSyncedStore.js +2 -7
  4. package/dist/local/Database.d.ts +2 -2
  5. package/dist/local/LazyReferenceCollection.d.ts +1 -1
  6. package/dist/local/Model.js +2 -2
  7. package/dist/local/SyncClient.d.ts +4 -4
  8. package/dist/local/SyncClient.js +20 -1
  9. package/dist/local/interfaces/index.d.ts +1 -1
  10. package/dist/local/stores/syncAction.d.ts +4 -4
  11. package/dist/local/sync/deltaPipeline.d.ts +11 -3
  12. package/dist/local/sync/deltaPipeline.js +27 -80
  13. package/dist/local/sync/schemas.d.ts +10 -10
  14. package/dist/local/transactions/mutations/MutationQueue.d.ts +3 -3
  15. package/dist/local/transactions/mutations/batchProcessing.js +5 -1
  16. package/dist/local/transactions/mutations/commitPayload.d.ts +3 -1
  17. package/dist/local/transactions/mutations/commitPayload.js +14 -0
  18. package/dist/local/transactions/mutations/failureHandling.js +9 -81
  19. package/dist/local/transactions/mutations/failureReporting.d.ts +10 -0
  20. package/dist/local/transactions/mutations/failureReporting.js +67 -0
  21. package/dist/local/transactions/mutations/pendingDrain.js +5 -1
  22. package/dist/local/transactions/mutations/replayValidation.d.ts +51 -15
  23. package/dist/local/transactions/mutations/replayValidation.js +2 -0
  24. package/dist/react/AbloProvider.js +1 -1
  25. package/dist/react/ClientSideSuspense.d.ts +1 -1
  26. package/dist/react/DefaultFallback.d.ts +1 -1
  27. package/dist/react/createAbloReact.js +1 -1
  28. package/dist/surface.d.ts +3 -3
  29. package/package.json +2 -2
  30. package/src/local/BaseSyncedStore.ts +2 -8
  31. package/src/local/Model.ts +2 -2
  32. package/src/local/SyncClient.ts +22 -1
  33. package/src/local/interfaces/index.ts +1 -1
  34. package/src/local/sync/SyncWebSocket.ts +1 -1
  35. package/src/local/sync/deltaPipeline.ts +26 -82
  36. package/src/local/transactions/mutations/batchProcessing.ts +5 -1
  37. package/src/local/transactions/mutations/commitPayload.ts +17 -0
  38. package/src/local/transactions/mutations/failureHandling.ts +73 -132
  39. package/src/local/transactions/mutations/failureReporting.ts +93 -0
  40. package/src/local/transactions/mutations/pendingDrain.ts +5 -1
  41. 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
- update: "update";
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
- update: "update";
98
+ archive: "archive";
90
99
  create: "create";
91
100
  delete: "delete";
92
- archive: "archive";
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
- update: "update";
140
+ archive: "archive";
124
141
  create: "create";
125
142
  delete: "delete";
126
- archive: "archive";
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
- update: "update";
177
+ archive: "archive";
157
178
  create: "create";
158
179
  delete: "delete";
159
- archive: "archive";
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 { jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
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((AbloProvider), props));
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 ["get", "read", "list", "listAll", "local", "create", "update", "delete", "claim", "join", "onChange"];
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 ["where", "filter", "orderBy", "limit", "offset", "state"];
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 ["schema", "apiKey", "projectId", "branchId", "authEndpoint", "authTimeoutMs", "allowCrossOriginAuthEndpoint", "persistence", "durableWrites", "commitOutbox", "commitOutboxScope", "debug", "logLevel", "logger", "authToken", "baseURL", "fetch", "defaultHeaders", "defaultQuery", "dangerouslyAllowBrowser", "collaborationEvents", "plugins"];
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.0",
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.0",
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
- /** Get fields that represent meaningful state for deduplication. Override for model-specific fields. */
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(this.deltaPipelineContext, deltas);
1611
+ return deltaPipeline.deduplicateDeltas(deltas);
1618
1612
  }
1619
1613
 
1620
1614
  /** Process incoming delta with smart batching */
@@ -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 instanceof Map ? this.modifiedProperties : null;
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 (!(this.modifiedProperties instanceof Map) || this.modifiedProperties.size === 0) {
441
+ if (this.modifiedProperties.size === 0) {
442
442
  return;
443
443
  }
444
444
  const only = keys ? new Set(keys) : null;
@@ -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
@@ -319,7 +319,7 @@ export class SyncWebSocket<
319
319
  /**
320
320
  * Public wrapper for sending ack from outside the class
321
321
  */
322
- acknowledge(syncId: number): void {
322
+ override acknowledge(syncId: number): void {
323
323
  this.sendAck(syncId);
324
324
  }
325
325
 
@@ -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
- /** Builds a small signature of a delta's state fields, used to detect no-op duplicate deltas. */
163
- function extractStateSignature(
164
- ctx: DeltaPipelineContext,
165
- delta: SyncDelta,
166
- ): Record<string, unknown> | null {
167
- if (!delta.data || typeof delta.data !== 'object') return null;
168
-
169
- const data = typeof delta.data === 'string'
170
- ? (JSON.parse(delta.data) as Record<string, unknown>)
171
- : (delta.data);
172
-
173
- // Generic state fields subclasses can override getStateFields() for model-specific fields
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
- /** Deduplicate deltas to the same entity — keep meaningful state transitions only */
195
- export function deduplicateDeltas(ctx: DeltaPipelineContext, deltas: SyncDelta[]): SyncDelta[] {
196
- // The dominant live-publication shape is a frame of independent entity
197
- // creates. When every entity key occurs once, reconciliation cannot remove
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
- return result.sort((a, b) => a.id - b.id);
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', '');