@abloatai/humans 0.59.2 → 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 (96) hide show
  1. package/README.md +1 -1
  2. package/dist/Ablo.d.ts +2 -10
  3. package/dist/Ablo.js +0 -1
  4. package/dist/client.d.ts +1 -48
  5. package/dist/humans.d.ts +1 -1
  6. package/dist/local/BaseSyncedStore.d.ts +5 -5
  7. package/dist/local/BaseSyncedStore.js +7 -7
  8. package/dist/local/Database.d.ts +2 -2
  9. package/dist/local/LazyReferenceCollection.d.ts +1 -1
  10. package/dist/local/Model.js +46 -56
  11. package/dist/local/NetworkMonitor.js +2 -0
  12. package/dist/local/RuntimeContext.js +2 -0
  13. package/dist/local/SyncClient.d.ts +12 -36
  14. package/dist/local/SyncClient.js +50 -108
  15. package/dist/local/client/createModelOperations.d.ts +3 -27
  16. package/dist/local/client/createModelOperations.js +20 -21
  17. package/dist/local/client/options.d.ts +14 -39
  18. package/dist/local/client/reactiveEngine.d.ts +3 -9
  19. package/dist/local/client/reactiveEngine.js +6 -151
  20. package/dist/local/client/storeLifecycle.js +5 -1
  21. package/dist/local/fileUploads.d.ts +27 -0
  22. package/dist/local/fileUploads.js +55 -0
  23. package/dist/local/storeContract.d.ts +5 -5
  24. package/dist/local/stores/syncAction.d.ts +4 -4
  25. package/dist/local/sync/contextOnChange.js +1 -1
  26. package/dist/local/sync/createClaimStream.js +1 -1
  27. package/dist/local/sync/credentialLifecycle.d.ts +4 -5
  28. package/dist/local/sync/credentialLifecycle.js +4 -5
  29. package/dist/local/sync/deltaPipeline.js +12 -6
  30. package/dist/local/sync/schemas.d.ts +10 -10
  31. package/dist/local/sync/scopeGroups.d.ts +11 -0
  32. package/dist/local/sync/scopeGroups.js +75 -0
  33. package/dist/local/sync/wsFrameHandlers.d.ts +1 -1
  34. package/dist/local/transactions/localMutation.js +3 -3
  35. package/dist/local/transactions/mutations/MutationQueue.d.ts +4 -5
  36. package/dist/local/transactions/mutations/MutationQueue.js +25 -51
  37. package/dist/local/transactions/mutations/batchProcessing.js +23 -10
  38. package/dist/local/transactions/mutations/commitPayload.d.ts +9 -2
  39. package/dist/local/transactions/mutations/commitTransport.js +3 -1
  40. package/dist/local/transactions/mutations/executionSelection.d.ts +0 -1
  41. package/dist/local/transactions/mutations/executionSelection.js +9 -17
  42. package/dist/local/transactions/mutations/failureHandling.js +9 -0
  43. package/dist/local/transactions/mutations/localMutation.js +3 -3
  44. package/dist/local/transactions/mutations/queueCoalescing.js +8 -0
  45. package/dist/local/transactions/mutations/replayValidation.d.ts +15 -15
  46. package/dist/react/AbloProvider.d.ts +11 -86
  47. package/dist/react/AbloProvider.js +11 -163
  48. package/dist/react/ClientSideSuspense.d.ts +1 -1
  49. package/dist/react/DefaultFallback.d.ts +1 -1
  50. package/dist/react/createAbloReact.js +1 -1
  51. package/dist/react/useErrorListener.js +1 -1
  52. package/dist/react/useMutationFailureListener.js +1 -1
  53. package/dist/react.d.ts +1 -1
  54. package/dist/react.js +1 -1
  55. package/dist/surface.d.ts +3 -3
  56. package/dist/surface.js +1 -4
  57. package/package.json +3 -3
  58. package/src/Ablo.ts +5 -17
  59. package/src/client.ts +0 -51
  60. package/src/local/BaseSyncedStore.ts +14 -14
  61. package/src/local/Model.ts +45 -55
  62. package/src/local/NetworkMonitor.ts +2 -0
  63. package/src/local/RuntimeContext.ts +2 -0
  64. package/src/local/SyncClient.ts +73 -140
  65. package/src/local/client/createModelOperations.ts +30 -64
  66. package/src/local/client/options.ts +20 -43
  67. package/src/local/client/reactiveEngine.ts +7 -179
  68. package/src/local/client/storeLifecycle.ts +6 -1
  69. package/src/local/fileUploads.ts +97 -0
  70. package/src/local/storeContract.ts +5 -5
  71. package/src/local/sync/contextOnChange.ts +1 -1
  72. package/src/local/sync/createClaimStream.ts +1 -1
  73. package/src/local/sync/credentialLifecycle.ts +4 -5
  74. package/src/local/sync/deltaPipeline.ts +10 -6
  75. package/src/local/sync/scopeGroups.ts +91 -0
  76. package/src/local/sync/wsFrameHandlers.ts +0 -1
  77. package/src/local/transactions/localMutation.ts +3 -3
  78. package/src/local/transactions/mutations/MutationQueue.ts +24 -53
  79. package/src/local/transactions/mutations/batchProcessing.ts +25 -10
  80. package/src/local/transactions/mutations/commitPayload.ts +11 -1
  81. package/src/local/transactions/mutations/commitTransport.ts +2 -2
  82. package/src/local/transactions/mutations/executionSelection.ts +9 -15
  83. package/src/local/transactions/mutations/failureHandling.ts +10 -0
  84. package/src/local/transactions/mutations/localMutation.ts +3 -3
  85. package/src/local/transactions/mutations/queueCoalescing.ts +6 -0
  86. package/src/react/AbloProvider.tsx +17 -249
  87. package/src/react/useErrorListener.ts +1 -1
  88. package/src/react/useMutationFailureListener.ts +1 -1
  89. package/src/react.ts +1 -5
  90. package/src/surface.ts +1 -4
  91. package/dist/local/sync/participants.d.ts +0 -132
  92. package/dist/local/sync/participants.js +0 -342
  93. package/dist/local/transactions/mutations/pendingDrain.d.ts +0 -33
  94. package/dist/local/transactions/mutations/pendingDrain.js +0 -117
  95. package/src/local/sync/participants.ts +0 -564
  96. package/src/local/transactions/mutations/pendingDrain.ts +0 -169
@@ -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
@@ -477,7 +476,7 @@ export declare class MutationQueue extends EventEmitter {
477
476
  awaitingDeltaCount: number;
478
477
  awaitingDeltaTransactions: {
479
478
  id: string;
480
- type: "archive" | "create" | "delete" | "unarchive" | "update";
479
+ type: "update" | "create" | "delete" | "archive" | "unarchive";
481
480
  modelName: string;
482
481
  modelId: string;
483
482
  syncIdNeeded: number | undefined;
@@ -486,13 +485,13 @@ export declare class MutationQueue extends EventEmitter {
486
485
  }[];
487
486
  pendingTransactions: {
488
487
  id: string;
489
- type: "archive" | "create" | "delete" | "unarchive" | "update";
488
+ type: "update" | "create" | "delete" | "archive" | "unarchive";
490
489
  modelName: string;
491
490
  modelId: string;
492
491
  }[];
493
492
  executingTransactions: {
494
493
  id: string;
495
- type: "archive" | "create" | "delete" | "unarchive" | "update";
494
+ type: "update" | "create" | "delete" | "archive" | "unarchive";
496
495
  modelName: string;
497
496
  modelId: string;
498
497
  }[];
@@ -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. */
@@ -122,7 +129,7 @@ export declare const stripModelSuffix: (modelName: string) => string;
122
129
  * foreign-key ordering because the row already exists, so they all share the
123
130
  * configured default non-create priority.
124
131
  */
125
- export declare const computePriorityScore: (type: QueuedMutation['type'], modelName: string, runtime?: RuntimeContext) => number;
132
+ export declare const computePriorityScore: (type: QueuedMutation["type"], modelName: string, runtime?: RuntimeContext) => number;
126
133
  export declare const TX_TYPE_TO_MUTATION_OP: Record<QueuedMutation['type'], MutationOperationType>;
127
134
  export declare function hasStaleWriteOptions(options?: WriteOptions): boolean;
128
135
  /** Options whose identity/audit semantics forbid merging two caller writes. */
@@ -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) {
@@ -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
- archive: "archive";
30
+ update: "update";
31
31
  create: "create";
32
32
  delete: "delete";
33
+ archive: "archive";
33
34
  unarchive: "unarchive";
34
- update: "update";
35
35
  }>;
36
36
  modelName: z.ZodString;
37
37
  modelId: z.ZodString;
@@ -95,10 +95,10 @@ export declare function deserializePersistedTransaction(row: unknown, runtime?:
95
95
  export declare const persistedMutationSchema: z.ZodObject<{
96
96
  mutationId: z.ZodOptional<z.ZodString>;
97
97
  type: z.ZodEnum<{
98
- archive: "archive";
98
+ update: "update";
99
99
  create: "create";
100
100
  delete: "delete";
101
- update: "update";
101
+ archive: "archive";
102
102
  }>;
103
103
  modelData: z.ZodRecord<z.ZodString, z.ZodUnknown>;
104
104
  modelName: z.ZodString;
@@ -133,14 +133,15 @@ export declare const PENDING_MUTATION_RECORD_PREFIX = "pending-mutation:";
133
133
  export declare const PENDING_MUTATION_REPLAY_WINDOW_MS: number;
134
134
  /** Scope-less records written by the first aggregate-journal release. */
135
135
  export declare const legacyPendingMutationRecordSchema: z.ZodObject<{
136
+ storageVersion: z.ZodLiteral<1>;
136
137
  id: z.ZodString;
137
138
  type: z.ZodLiteral<"pending_mutation">;
138
139
  mutation: z.ZodObject<{
139
140
  type: z.ZodEnum<{
140
- archive: "archive";
141
+ update: "update";
141
142
  create: "create";
142
143
  delete: "delete";
143
- update: "update";
144
+ archive: "archive";
144
145
  }>;
145
146
  modelData: z.ZodRecord<z.ZodString, z.ZodUnknown>;
146
147
  modelName: z.ZodString;
@@ -167,17 +168,22 @@ export declare const legacyPendingMutationRecordSchema: z.ZodObject<{
167
168
  mutationId: z.ZodString;
168
169
  }, z.core.$loose>;
169
170
  timestamp: z.ZodNumber;
170
- storageVersion: z.ZodLiteral<1>;
171
171
  }, z.core.$strict>;
172
172
  export declare const pendingMutationRecordSchema: z.ZodObject<{
173
+ storageVersion: z.ZodLiteral<2>;
174
+ scope: z.ZodObject<{
175
+ organizationId: z.ZodString;
176
+ participantId: z.ZodString;
177
+ namespace: z.ZodString;
178
+ }, z.core.$strict>;
173
179
  id: z.ZodString;
174
180
  type: z.ZodLiteral<"pending_mutation">;
175
181
  mutation: z.ZodObject<{
176
182
  type: z.ZodEnum<{
177
- archive: "archive";
183
+ update: "update";
178
184
  create: "create";
179
185
  delete: "delete";
180
- update: "update";
186
+ archive: "archive";
181
187
  }>;
182
188
  modelData: z.ZodRecord<z.ZodString, z.ZodUnknown>;
183
189
  modelName: z.ZodString;
@@ -204,12 +210,6 @@ export declare const pendingMutationRecordSchema: z.ZodObject<{
204
210
  mutationId: z.ZodString;
205
211
  }, z.core.$loose>;
206
212
  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>;
213
213
  }, z.core.$strict>;
214
214
  export type PendingMutationRecord = z.infer<typeof pendingMutationRecordSchema>;
215
215
  export declare function pendingMutationRecordId(mutationId: string): string;
@@ -1,8 +1,8 @@
1
1
  import { type ReactNode } from 'react';
2
2
  import type { SchemaRecord } from '@abloatai/transaction/schema/schema';
3
3
  import type { AbloClient as Ablo } from '../client.js';
4
- import type { Claim, Duration, Peer } from '@abloatai/transaction/types/streams';
5
- import type { EngineParticipant, ParticipantScope, ParticipantStatus } from '../local/sync/participants.js';
4
+ import type { Peer } from '@abloatai/transaction/types/streams';
5
+ import type { GroupScope } from '../local/sync/scopeGroups.js';
6
6
  import { type SyncStoreContract } from './context.js';
7
7
  /**
8
8
  * Ablo umbrella provider — owns the sync engine, multiplayer, and
@@ -13,9 +13,8 @@ import { type SyncStoreContract } from './context.js';
13
13
  *
14
14
  * - **One component, one import.** Consumers write the provider
15
15
  * once at the root; nothing else needs to plumb the engine.
16
- * - **Multiplayer is default.** React consumers are always browsers doing
17
- * multiplayer UI, so `useJoin()` / `useAblo()` are always
18
- * available. No opt-in prop.
16
+ * - **Multiplayer is default.** React consumers share the client's scoped
17
+ * groups, presence stream, and model surface without another join step.
19
18
  * - **Declarative props for app glue.** `preventUnsavedChanges`,
20
19
  * `onSessionExpired`, `postBootstrap`, `resolveUsers` — each
21
20
  * absorbs a class of integration code that previously lived in
@@ -35,7 +34,7 @@ import { type SyncStoreContract } from './context.js';
35
34
  * // Build once at module scope — a new instance per render tears down the socket.
36
35
  * // The endpoint string points at your session-mint route (`ablo init`
37
36
  * // scaffolds it); the SDK fetches it and keeps the token fresh.
38
- * const ablo = Ablo({ schema, authEndpoint: '/api/ablo-session' });
37
+ * const ablo = Ablo({ schema, session: { endpoint: '/api/ablo-session' } });
39
38
  *
40
39
  * <AbloProvider client={ablo}>
41
40
  * <App />
@@ -108,82 +107,11 @@ export interface AbloProviderProps<R extends SchemaRecord = SchemaRecord> {
108
107
  children: ReactNode;
109
108
  }
110
109
  export declare function AbloProvider<R extends SchemaRecord = SchemaRecord>(props: AbloProviderProps<R>): React.ReactElement;
111
- export type { EngineParticipant, ParticipantScope, ParticipantStatus };
112
- /**
113
- * Options for `useJoin`. The hook reuses the engine's single
114
- * WebSocket and opens a scoped claim on it when `scope` is provided:
115
- * one TCP connection, N logical sub-syncgroup participants.
116
- */
117
- export interface UseJoinOptions {
118
- readonly scope?: ParticipantScope;
119
- /**
120
- * Lease TTL for the participant claim, as a compact duration (`'5m'`) or a
121
- * number of seconds. The same dial and the same spelling as
122
- * `ablo.<model>.join(ids, { ttl })` and every other lease in the SDK.
123
- */
124
- readonly ttl?: Duration;
125
- /**
126
- * @deprecated Use `ttl`. Removed in 0.37.0.
127
- *
128
- * The same rename as on `ParticipantJoinOptions`: one lease, and the seconds
129
- * spelling was the one that misled, because it accepted duration strings too.
130
- */
131
- readonly ttlSeconds?: number | string | null;
132
- /** Tear down + don't re-join while true. */
133
- readonly paused?: boolean;
134
- /**
135
- * Acquire a write-claim CLAIM on the scope, in addition to read interest.
136
- *
137
- * Default `false`: opening a scope subscribes the connection to its deltas
138
- * (read interest, via `update_subscription`) but does NOT claim it — a
139
- * viewer is not a claimant. Set `true` when the participant intends to
140
- * WRITE (editing a report, an agent staking work): the claim is sent so peers
141
- * observe it, and the scope is pinned so it stays subscribed (never warms)
142
- * for as long as the claim is held.
143
- */
144
- readonly claim?: boolean;
145
- /**
146
- * Backfill the scope's CURRENT state into the pool on enter, in addition to
147
- * tailing live changes.
148
- *
149
- * Default `false`: entering a scope subscribes to its FUTURE deltas only — if
150
- * the scope's rows aren't already loaded, the view is empty until something
151
- * changes. Set `true` when opening an entity that may not be loaded yet (a
152
- * deep-linked report, a never-opened ledger) so its current rows are fetched and
153
- * injected once, then kept fresh by the live tail. The fetch is single-flight
154
- * and runs once per group; a failure soft-fails (the live tail still flows).
155
- */
156
- readonly hydrate?: boolean;
157
- }
158
- export interface UseJoinReturn {
159
- readonly participant: EngineParticipant | null;
160
- /** Everyone else on the engine's sync groups (`participant.presence.others`), bridged to React. */
161
- readonly peers: readonly Peer[];
162
- /** Active claim claims by peers (`participant.claims.others`), bridged to React. */
163
- readonly claims: readonly Claim[];
164
- readonly status: ParticipantStatus;
165
- readonly error: Error | null;
166
- }
167
- /**
168
- * Join multiplayer for a given scope. Returns the participant and its
169
- * lifecycle status. Auto-cleans up on unmount or when `paused`
170
- * flips to true.
171
- *
172
- * `useJoin` is the React form of `ablo.<model>.join` — scope-level
173
- * read-interest + presence; returns the reactive participant facade
174
- * (peers/claims/status).
175
- *
176
- * The returned `participant` is an `EngineParticipant` — `.presence`
177
- * + `.claims` only — backed by the engine's existing socket. For
178
- * headless-bot patterns (a separate identity in the same browser
179
- * tab), construct a second `Ablo({ kind: 'agent', ... })` directly.
180
- */
181
- export declare function useJoin(opts: UseJoinOptions): UseJoinReturn;
110
+ export type { GroupScope };
182
111
  /**
183
112
  * Read-only presence: the OTHER participants currently visible to this
184
- * connection, bridged to React. Unlike {@link useJoin}, this does
185
- * NOT enter/leave a scope (no `update_subscription`, no warm-TTL churn) —
186
- * it is a pure reader of the engine's already-flowing presence stream.
113
+ * connection, bridged to React. This is a pure reader of the engine's
114
+ * already-flowing presence stream; it does not mutate connection groups.
187
115
  *
188
116
  * Pass `scope` to narrow to the peers on that scope's sync group(s); omit
189
117
  * it to get everyone on the engine's groups. Membership is driven entirely
@@ -191,18 +119,15 @@ export declare function useJoin(opts: UseJoinOptions): UseJoinReturn;
191
119
  * cursor/collaboration traffic), so reading it never affects what the
192
120
  * connection is subscribed to and can't deadlock against a gated channel.
193
121
  *
194
- * Use this to answer "is anyone else here?" e.g. suppressing live-cursor
195
- * broadcasts while alone — when some OTHER mount already owns the scope's
196
- * read interest (scope `leave` is not reference-counted, so a second
197
- * `useJoin` on the same scope would warm-drop the owner's
198
- * subscription on unmount).
122
+ * Use this to answer "is anyone else here?", for example to suppress
123
+ * live-cursor broadcasts while alone.
199
124
  *
200
125
  * ```ts
201
126
  * const peers = usePeers({ reports: reportId });
202
127
  * const alone = !peers.some((p) => p.participantKind === 'user');
203
128
  * ```
204
129
  */
205
- export declare function usePeers(scope?: ParticipantScope): readonly Peer[];
130
+ export declare function usePeers(scope?: GroupScope): readonly Peer[];
206
131
  /**
207
132
  * Returns the raw `SyncEngine` proxy. Typically you want the typed
208
133
  * hooks (`useQuery`, `useOne`, `useMutate`) — this is for rare cases