@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.
Files changed (52) hide show
  1. package/dist/local/BaseSyncedStore.js +5 -5
  2. package/dist/local/Model.js +46 -56
  3. package/dist/local/NetworkMonitor.js +2 -0
  4. package/dist/local/RuntimeContext.js +2 -0
  5. package/dist/local/SyncClient.d.ts +5 -29
  6. package/dist/local/SyncClient.js +26 -99
  7. package/dist/local/client/createModelOperations.js +6 -4
  8. package/dist/local/fileUploads.d.ts +27 -0
  9. package/dist/local/fileUploads.js +55 -0
  10. package/dist/local/stores/syncAction.d.ts +1 -1
  11. package/dist/local/sync/contextOnChange.js +1 -1
  12. package/dist/local/sync/createClaimStream.js +1 -1
  13. package/dist/local/sync/deltaPipeline.js +12 -6
  14. package/dist/local/sync/schemas.d.ts +2 -2
  15. package/dist/local/transactions/localMutation.js +3 -3
  16. package/dist/local/transactions/mutations/MutationQueue.d.ts +1 -2
  17. package/dist/local/transactions/mutations/MutationQueue.js +25 -51
  18. package/dist/local/transactions/mutations/batchProcessing.js +23 -10
  19. package/dist/local/transactions/mutations/commitPayload.d.ts +8 -1
  20. package/dist/local/transactions/mutations/commitTransport.js +3 -1
  21. package/dist/local/transactions/mutations/executionSelection.d.ts +0 -1
  22. package/dist/local/transactions/mutations/executionSelection.js +9 -17
  23. package/dist/local/transactions/mutations/failureHandling.js +9 -0
  24. package/dist/local/transactions/mutations/localMutation.js +3 -3
  25. package/dist/local/transactions/mutations/queueCoalescing.js +8 -0
  26. package/dist/react/useErrorListener.js +1 -1
  27. package/dist/react/useMutationFailureListener.js +1 -1
  28. package/package.json +3 -4
  29. package/src/local/BaseSyncedStore.ts +5 -5
  30. package/src/local/Model.ts +45 -55
  31. package/src/local/NetworkMonitor.ts +2 -0
  32. package/src/local/RuntimeContext.ts +2 -0
  33. package/src/local/SyncClient.ts +33 -127
  34. package/src/local/client/createModelOperations.ts +9 -6
  35. package/src/local/fileUploads.ts +97 -0
  36. package/src/local/sync/contextOnChange.ts +1 -1
  37. package/src/local/sync/createClaimStream.ts +1 -1
  38. package/src/local/sync/deltaPipeline.ts +10 -6
  39. package/src/local/transactions/localMutation.ts +3 -3
  40. package/src/local/transactions/mutations/MutationQueue.ts +24 -53
  41. package/src/local/transactions/mutations/batchProcessing.ts +25 -10
  42. package/src/local/transactions/mutations/commitPayload.ts +11 -1
  43. package/src/local/transactions/mutations/commitTransport.ts +2 -2
  44. package/src/local/transactions/mutations/executionSelection.ts +9 -15
  45. package/src/local/transactions/mutations/failureHandling.ts +10 -0
  46. package/src/local/transactions/mutations/localMutation.ts +3 -3
  47. package/src/local/transactions/mutations/queueCoalescing.ts +6 -0
  48. package/src/react/useErrorListener.ts +1 -1
  49. package/src/react/useMutationFailureListener.ts +1 -1
  50. package/dist/local/transactions/mutations/pendingDrain.d.ts +0 -33
  51. package/dist/local/transactions/mutations/pendingDrain.js +0 -117
  52. 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, takePendingDrainBatch as selectPendingDrainBatch } from './executionSelection.js';
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
- // The normal batch scheduler and the explicit/reconnect drain are two
1008
- // ways to drive the same durable queue. They must never seal the same
1009
- // staged source records concurrently: the first seal consumes those
1010
- // records, so the second would correctly reject them as already claimed.
1011
- //
1012
- // `isProcessing` is acquired synchronously before either path awaits,
1013
- // making it the queue-wide execution lock. If the normal lane already
1014
- // owns it, that lane will finish the pending work; callers waiting on a
1015
- // specific confirmation remain attached to the exact transaction.
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
- await processBatch(this.batchProcessingContext);
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
- const durableEnvelope = await ctx.sealDurableCommit({
68
- idempotencyKey: commitIdempotencyKey,
69
- origin: 'model_batch',
70
- operations: batchOps.map(({ op }) => op),
71
- sourceMutationIds: ctx.sourceMutationIdsFor(batch),
72
- commitOptions: { reads: collectQueuedReads(batch) },
73
- createdAt: Math.min(...batch.map((transaction) => transaction.createdAt)),
74
- sealedAt: batch[0]?.commitEnvelope?.sealedAt ?? Date.now(),
75
- sequence: batch[0]?.commitEnvelope?.sequence,
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(() => 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' })), timeoutMs);
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
  }
@@ -3,4 +3,3 @@ export declare function takeNextExecutionBatch(executionQueue: QueuedMutation[],
3
3
  batch: QueuedMutation[];
4
4
  remaining: QueuedMutation[];
5
5
  };
6
- export declare function takePendingDrainBatch(pending: QueuedMutation[], maxBatchSize: number): QueuedMutation[];
@@ -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 executionQueue) {
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 = executionQueue.filter((tx) => tx.commitEnvelope?.idempotencyKey !== idempotencyKey);
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 = executionQueue.filter((tx) => !tx.commitEnvelope);
24
+ const fresh = pendingQueue.filter((tx) => !tx.commitEnvelope);
21
25
  const firstFresh = fresh[0];
22
26
  if (!firstFresh)
23
- return { batch: [], remaining: executionQueue };
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: executionQueue.filter((tx) => !selectedIds.has(tx.id)) };
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.60.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.60.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
 
@@ -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?.get(key);
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
- if (properties) {
467
- const json = this.toJSON();
468
- for (const [propName, metadata] of properties) {
469
- // Check required fields
470
- if (!metadata.nullable && !metadata.optional) {
471
- const value = json[propName];
472
- if (value == null || value === '') {
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
- // Run custom validation rules
478
- const rules = this.validationRules[propName];
479
- if (rules) {
480
- const value = json[propName];
481
- for (const rule of rules) {
482
- const error = rule(value);
483
- if (error) errors.push(error);
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
- if (properties) {
820
- const self = this as Record<string, unknown>;
821
- for (const [propName, metadata] of properties) {
822
- // Skip certain types
823
- if (metadata.type === 'ephemeralProperty') continue;
824
- if (metadata.type === 'referenceModel') continue;
825
- if (metadata.type === 'referenceCollection') continue;
826
-
827
- const value = self[propName];
828
- if (value !== undefined) {
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
- if (properties) {
950
- const self = this as Record<string, unknown>;
951
- for (const [propName, metadata] of properties) {
952
- if (metadata.type === 'referenceCollection') {
953
- const collection = self[propName] as Disposable | undefined;
954
- if (collection?.dispose) {
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
- if (properties) {
987
- const json = this.toJSON();
988
- for (const [propName] of properties) {
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
- if (properties) {
1038
- for (const [propName, metadata] of properties) {
1039
- if (
1040
- metadata.type === 'ephemeralProperty' ||
1041
- metadata.type === 'referenceModel' ||
1042
- metadata.type === 'referenceCollection'
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
  };