@abloatai/humans 0.57.0 → 0.59.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 (130) hide show
  1. package/dist/Ablo.d.ts +8 -18
  2. package/dist/client.d.ts +6 -19
  3. package/dist/core.d.ts +1 -1
  4. package/dist/core.js +1 -1
  5. package/dist/local/Database.d.ts +1 -1
  6. package/dist/local/Model.js +10 -1
  7. package/dist/local/SyncClient.js +5 -0
  8. package/dist/local/client/createInternalComponents.d.ts +1 -1
  9. package/dist/local/client/createInternalComponents.js +1 -1
  10. package/dist/local/client/{createModelProxy.d.ts → createModelOperations.d.ts} +11 -10
  11. package/dist/local/client/{createModelProxy.js → createModelOperations.js} +240 -313
  12. package/dist/local/client/options.d.ts +2 -2
  13. package/dist/local/client/reactiveEngine.js +34 -45
  14. package/dist/local/client/resourceTypes.d.ts +3 -3
  15. package/dist/local/client/resourceTypes.js +1 -1
  16. package/dist/local/client/schemaConfig.js +1 -0
  17. package/dist/local/client/wsMutationExecutor.d.ts +4 -4
  18. package/dist/local/client/wsMutationExecutor.js +3 -5
  19. package/dist/local/coordination/ClaimLog.d.ts +3 -3
  20. package/dist/local/coordination/ClaimLog.js +2 -2
  21. package/dist/local/interfaces/index.d.ts +10 -7
  22. package/dist/local/mutators/mutateActions.d.ts +3 -2
  23. package/dist/local/query/types.d.ts +2 -2
  24. package/dist/local/stores/syncAction.d.ts +1 -1
  25. package/dist/local/stores/syncAction.js +1 -1
  26. package/dist/local/sync/BootstrapFetcher.js +4 -4
  27. package/dist/local/sync/ConnectionManager.d.ts +1 -1
  28. package/dist/local/sync/ConnectionManager.js +1 -1
  29. package/dist/local/sync/OnDemandLoader.d.ts +1 -1
  30. package/dist/local/sync/OnDemandLoader.js +1 -1
  31. package/dist/local/sync/SyncWebSocket.d.ts +3 -3
  32. package/dist/local/sync/SyncWebSocket.js +2 -2
  33. package/dist/local/sync/commitFrames.d.ts +1 -1
  34. package/dist/local/sync/commitFrames.js +1 -1
  35. package/dist/local/sync/contextOnChange.d.ts +8 -0
  36. package/dist/local/sync/contextOnChange.js +65 -0
  37. package/dist/local/sync/createClaimStream.d.ts +1 -1
  38. package/dist/local/sync/createClaimStream.js +47 -28
  39. package/dist/local/sync/credentialLifecycle.d.ts +1 -1
  40. package/dist/local/sync/credentialLifecycle.js +1 -1
  41. package/dist/local/sync/participants.d.ts +1 -1
  42. package/dist/local/sync/schemaDrift.d.ts +15 -1
  43. package/dist/local/sync/schemaDrift.js +30 -13
  44. package/dist/local/sync/schemas.d.ts +2 -2
  45. package/dist/local/sync/schemas.js +1 -1
  46. package/dist/local/sync/wsFrameHandlers.d.ts +1 -1
  47. package/dist/local/sync/wsFrameHandlers.js +1 -1
  48. package/dist/local/transactions/databaseCommitOutbox.d.ts +2 -2
  49. package/dist/local/transactions/mutations/MutationQueue.d.ts +4 -19
  50. package/dist/local/transactions/mutations/MutationQueue.js +5 -70
  51. package/dist/local/transactions/mutations/batchProcessing.d.ts +2 -13
  52. package/dist/local/transactions/mutations/batchProcessing.js +0 -20
  53. package/dist/local/transactions/mutations/commitApi.d.ts +2 -3
  54. package/dist/local/transactions/mutations/commitApi.js +0 -4
  55. package/dist/local/transactions/mutations/commitLane.d.ts +3 -8
  56. package/dist/local/transactions/mutations/commitLane.js +0 -11
  57. package/dist/local/transactions/mutations/commitPayload.d.ts +2 -4
  58. package/dist/local/transactions/mutations/commitPayload.js +2 -6
  59. package/dist/local/transactions/mutations/commitTransport.d.ts +3 -4
  60. package/dist/local/transactions/mutations/commitTransport.js +2 -5
  61. package/dist/local/transactions/mutations/durableCommitRestore.d.ts +1 -1
  62. package/dist/local/transactions/mutations/durableCommitRestore.js +1 -4
  63. package/dist/local/transactions/mutations/durableWriteStore.d.ts +5 -5
  64. package/dist/local/transactions/mutations/durableWriteStore.js +3 -3
  65. package/dist/local/transactions/mutations/failurePolicy.d.ts +2 -2
  66. package/dist/local/transactions/mutations/mutationPersistence.d.ts +1 -1
  67. package/dist/local/transactions/mutations/pendingDrain.d.ts +2 -2
  68. package/dist/local/transactions/mutations/replayValidation.d.ts +0 -20
  69. package/dist/local/transactions/mutations/replayValidation.js +1 -3
  70. package/dist/local/transactions/persistedTransaction.d.ts +0 -2
  71. package/dist/plugin.d.ts +2 -2
  72. package/dist/presenceStream.d.ts +1 -1
  73. package/dist/react/createAbloReact.d.ts +1 -1
  74. package/dist/react/useAblo.d.ts +2 -2
  75. package/dist/react/useAblo.js +1 -1
  76. package/dist/surface.d.ts +1 -1
  77. package/dist/surface.js +1 -2
  78. package/package.json +2 -2
  79. package/src/Ablo.ts +9 -27
  80. package/src/client.ts +6 -22
  81. package/src/core.ts +1 -1
  82. package/src/local/Database.ts +1 -1
  83. package/src/local/Model.ts +12 -1
  84. package/src/local/SyncClient.ts +5 -0
  85. package/src/local/client/createInternalComponents.ts +2 -2
  86. package/src/local/client/{createModelProxy.ts → createModelOperations.ts} +292 -367
  87. package/src/local/client/options.ts +2 -2
  88. package/src/local/client/reactiveEngine.ts +63 -55
  89. package/src/local/client/resourceTypes.ts +4 -4
  90. package/src/local/client/schemaConfig.ts +3 -0
  91. package/src/local/client/wsMutationExecutor.ts +3 -9
  92. package/src/local/coordination/ClaimLog.ts +3 -3
  93. package/src/local/interfaces/index.ts +7 -7
  94. package/src/local/mutators/mutateActions.ts +3 -2
  95. package/src/local/query/types.ts +2 -2
  96. package/src/local/stores/syncAction.ts +1 -1
  97. package/src/local/sync/BootstrapFetcher.ts +5 -4
  98. package/src/local/sync/ConnectionManager.ts +1 -1
  99. package/src/local/sync/OnDemandLoader.ts +1 -1
  100. package/src/local/sync/SyncWebSocket.ts +3 -3
  101. package/src/local/sync/commitFrames.ts +1 -2
  102. package/src/local/sync/contextOnChange.ts +92 -0
  103. package/src/local/sync/createClaimStream.ts +77 -39
  104. package/src/local/sync/credentialLifecycle.ts +1 -1
  105. package/src/local/sync/participants.ts +1 -1
  106. package/src/local/sync/schemaDrift.ts +32 -16
  107. package/src/local/sync/schemas.ts +1 -1
  108. package/src/local/sync/wsFrameHandlers.ts +1 -1
  109. package/src/local/transactions/databaseCommitOutbox.ts +2 -2
  110. package/src/local/transactions/mutations/MutationQueue.ts +8 -95
  111. package/src/local/transactions/mutations/batchProcessing.ts +2 -38
  112. package/src/local/transactions/mutations/commitApi.ts +3 -7
  113. package/src/local/transactions/mutations/commitLane.ts +3 -17
  114. package/src/local/transactions/mutations/commitPayload.ts +3 -11
  115. package/src/local/transactions/mutations/commitTransport.ts +3 -7
  116. package/src/local/transactions/mutations/durableCommitRestore.ts +2 -5
  117. package/src/local/transactions/mutations/durableWriteStore.ts +5 -5
  118. package/src/local/transactions/mutations/failurePolicy.ts +2 -2
  119. package/src/local/transactions/mutations/mutationPersistence.ts +1 -1
  120. package/src/local/transactions/mutations/pendingDrain.ts +2 -2
  121. package/src/local/transactions/mutations/replayValidation.ts +1 -3
  122. package/src/local/transactions/persistedTransaction.ts +0 -3
  123. package/src/plugin.ts +2 -2
  124. package/src/presenceStream.ts +2 -2
  125. package/src/react/createAbloReact.ts +1 -1
  126. package/src/react/useAblo.ts +2 -2
  127. package/src/surface.ts +2 -3
  128. package/dist/local/sync/createSnapshot.d.ts +0 -29
  129. package/dist/local/sync/createSnapshot.js +0 -116
  130. package/src/local/sync/createSnapshot.ts +0 -160
@@ -25,7 +25,7 @@
25
25
  import type {
26
26
  WsTransport,
27
27
  PresenceUpdate,
28
- } from '@abloatai/transaction/transport/wsTransport';
28
+ } from '@abloatai/transaction/transport/websocket';
29
29
  import type {
30
30
  ClaimOptions,
31
31
  ClaimTarget,
@@ -52,10 +52,15 @@ import {
52
52
  streamTarget,
53
53
  wireTarget,
54
54
  type ClaimTargetDetails,
55
- } from '@abloatai/transaction/coordination/locator';
56
- import { declaredMeta } from '@abloatai/transaction/coordination/claimMeta';
55
+ } from '@abloatai/transaction/claims';
56
+ import {
57
+ bindClaimLifetime,
58
+ createClaimLifetime,
59
+ type ClaimLifetime,
60
+ } from '@abloatai/transaction/claims/lifetime';
61
+ import { declaredMeta } from '@abloatai/transaction/claims';
57
62
  import { AbloClaimedError, AbloConnectionError } from '@abloatai/transaction/errors';
58
- import { resolveHeartbeatOptions } from '@abloatai/transaction/coordination/claimHeartbeatLoop';
63
+ import { resolveHeartbeatOptions } from '@abloatai/transaction/claims';
59
64
  import { noopLogger, type Logger } from '@abloatai/transaction/logger';
60
65
 
61
66
  /**
@@ -114,13 +119,11 @@ export interface AttachableClaimStream extends ClaimStream {
114
119
  }
115
120
 
116
121
  /**
117
- * What this participant needs to remember about a claim it holds, so it can
118
- * re-announce it after a reconnect.
122
+ * What this participant needs to remember about one open grant.
119
123
  *
120
124
  * The sub-entity locator is taken from {@link ClaimTargetDetails} rather than
121
- * listed again: a member this record forgot would be a member the re-announced
122
- * claim silently lost, which is a narrower claim than the one the holder
123
- * believes it has.
125
+ * listed again: a member this record forgot would be a member release or loss
126
+ * bookkeeping silently narrowed relative to the handle the caller holds.
124
127
  */
125
128
  type OwnClaim = ClaimTargetDetails & {
126
129
  readonly entityType: string;
@@ -133,6 +136,11 @@ type OwnClaim = ClaimTargetDetails & {
133
136
  readonly queue?: boolean;
134
137
  };
135
138
 
139
+ interface OwnGrant {
140
+ readonly claim: OwnClaim;
141
+ readonly lifetime: ClaimLifetime;
142
+ }
143
+
136
144
  export function createClaimStream(
137
145
  config: ClaimStreamConfig,
138
146
  transport: ClaimTransport | null = null,
@@ -146,8 +154,8 @@ export function createClaimStream(
146
154
  const activeByClaimId = new Map<string, Claim>();
147
155
  let claimsSnapshot: readonly Claim[] = Object.freeze([]);
148
156
 
149
- // ── State: our own open claims (for re-announce on reconnect) ───
150
- const ownClaims = new Map<string, OwnClaim>();
157
+ // ── State: our own open grants, keyed by the exact claim id ─────
158
+ const ownClaims = new Map<string, OwnGrant>();
151
159
 
152
160
  // ── State: per-entity wait queues, from `claim_queue` frames ────
153
161
  // Keyed `type:id`; the value is the FIFO line of queued claims. Powers
@@ -290,7 +298,8 @@ export function createClaimStream(
290
298
  unsubs.push(
291
299
  t.subscribe('claim_rejected', (rejection) => {
292
300
  if (!rejection.claimId) return;
293
- if (ownClaims.has(rejection.claimId)) {
301
+ const own = ownClaims.get(rejection.claimId);
302
+ if (own) {
294
303
  const tgt = rejection.target
295
304
  ? claimLabel(rejection.target.entityType, rejection.target.entityId, rejection.target.field)
296
305
  : rejection.claimId;
@@ -299,9 +308,17 @@ export function createClaimStream(
299
308
  { claimId: rejection.claimId, reason: rejection.reason },
300
309
  );
301
310
  }
302
- // Drop the rejected own-claim so reconnect doesn't re-announce
303
- // a claim the server already rejected (would just spam both
304
- // sides with conflicts).
311
+ own?.lifetime.end(
312
+ new AbloClaimedError(
313
+ rejection.message ?? `Claim ${rejection.claimId} was rejected.`,
314
+ {
315
+ code:
316
+ rejection.reason === 'conflict'
317
+ ? 'claim_conflict'
318
+ : 'claim_lease_unavailable',
319
+ },
320
+ ),
321
+ );
305
322
  ownClaims.delete(rejection.claimId);
306
323
  // A holder on another server may have claimed before this client joined
307
324
  // the row group, so its one-shot presence frame was missed. A conflict
@@ -336,15 +353,20 @@ export function createClaimStream(
336
353
  // The frame is validated by the transport's dispatcher against the same
337
354
  // schema this parameter's type comes from, so it arrives proven.
338
355
  t.subscribe('claim_lost', (lost) => {
339
- if (ownClaims.has(lost.claimId)) {
340
- const c = ownClaims.get(lost.claimId);
356
+ const own = ownClaims.get(lost.claimId);
357
+ if (own) {
358
+ const c = own.claim;
341
359
  logger.info(
342
- `claim: lost ${c ? claimLabel(c.entityType, c.entityId, c.field) : lost.claimId} (preempted or expired)`,
360
+ `claim: lost ${claimLabel(c.entityType, c.entityId, c.field)} (preempted or expired)`,
343
361
  { claimId: lost.claimId },
344
362
  );
345
363
  }
346
- // Drop the lost own-claim so reconnect doesn't re-announce a lease we
347
- // no longer hold.
364
+ own?.lifetime.end(
365
+ new AbloClaimedError(
366
+ `Claim ${lost.claimId} was lost because it ${lost.reason === 'expired' ? 'expired' : 'was preempted'}. Re-acquire it before continuing.`,
367
+ { code: 'claim_lost' },
368
+ ),
369
+ );
348
370
  ownClaims.delete(lost.claimId);
349
371
  for (const l of lostListeners) {
350
372
  try {
@@ -418,25 +440,35 @@ export function createClaimStream(
418
440
  });
419
441
  return;
420
442
  }
421
- const c = ownClaims.get(ack.claimId);
422
- reject(
423
- new AbloClaimedError(
424
- `The lease behind ${c ? claimLabel(c.entityType, c.entityId, c.field) : `claim ${ack.claimId}`} is no longer held — it expired or was granted onward while this participant was working. Re-acquire the claim and retry; a write attempted under the old lease is rejected by its \`readAt\` guard.`,
425
- { code: 'claim_lost' },
426
- ),
443
+ const own = ownClaims.get(ack.claimId);
444
+ const c = own?.claim;
445
+ const error = new AbloClaimedError(
446
+ `The lease behind ${c ? claimLabel(c.entityType, c.entityId, c.field) : `claim ${ack.claimId}`} is no longer held — it expired or was granted onward while this participant was working. Re-acquire the claim and retry; a write attempted under the old lease is rejected by its \`readAt\` guard.`,
447
+ { code: 'claim_lost' },
427
448
  );
449
+ own?.lifetime.end(error);
450
+ ownClaims.delete(ack.claimId);
451
+ reject(error);
428
452
  });
429
453
  }),
430
454
  );
431
455
 
432
- // (3) On reconnect, re-announce every open self-claim the
433
- // server's claim state is in-memory and is lost across
434
- // restarts. Without this, peers would see our claims vanish
435
- // whenever the connection blipped.
456
+ // (3) A granted handle belongs to this connection's exact grant. The server
457
+ // releases the participant's claims when its last socket leaves; trying
458
+ // to recreate them on `connected` silently swapped the lease beneath an
459
+ // old `data` / `readAt` / fenceToken tuple. End them instead. A caller
460
+ // reconnects, re-reads, and takes a fresh handle.
436
461
  unsubs.push(
437
- t.subscribe('connected', () => {
438
- for (const [claimId, claim] of ownClaims) {
439
- sendBegin(claimId, claim);
462
+ t.subscribe('disconnected', () => {
463
+ const error = new AbloConnectionError(
464
+ 'The connection holding this claim ended. Reconnect and acquire a fresh claim before continuing.',
465
+ );
466
+ for (const { lifetime } of ownClaims.values()) {
467
+ lifetime.end(error);
468
+ }
469
+ ownClaims.clear();
470
+ for (const claimId of [...pendingHeartbeats.keys()]) {
471
+ settleHeartbeat(claimId, ({ reject }) => reject(error));
440
472
  }
441
473
  }),
442
474
  );
@@ -480,8 +512,8 @@ export function createClaimStream(
480
512
 
481
513
  /**
482
514
  * Send one heartbeat and await its ack. Rejects with
483
- * {@link AbloConnectionError} (transient the auto-heartbeat loop retries
484
- * on its next tick) when the socket is down or the ack times out, and with
515
+ * {@link AbloConnectionError} when the socket is down or the ack times out,
516
+ * and with
485
517
  * {@link AbloClaimedError} (definitive) when the server answers that the
486
518
  * lease is no longer ours.
487
519
  */
@@ -493,7 +525,7 @@ export function createClaimStream(
493
525
  if (!attached?.isConnected()) {
494
526
  return Promise.reject(
495
527
  new AbloConnectionError(
496
- `The heartbeat for ${claimLabel(claim.entityType, claim.entityId, claim.field)} was skipped because the connection is down. The keepalive renews held leases automatically on reconnect; the next beat retries.`,
528
+ `The heartbeat for ${claimLabel(claim.entityType, claim.entityId, claim.field)} was skipped because the connection is down. This connection's grant has ended; reconnect and acquire a fresh claim.`,
497
529
  ),
498
530
  );
499
531
  }
@@ -567,7 +599,8 @@ export function createClaimStream(
567
599
  estimatedMs,
568
600
  queue: args.queue,
569
601
  };
570
- ownClaims.set(claimId, claim);
602
+ const lifetime = createClaimLifetime();
603
+ ownClaims.set(claimId, { claim, lifetime });
571
604
  sendBegin(claimId, claim);
572
605
  // Coordination trace (info): the creator can see their human/agent claims.
573
606
  logger.info(
@@ -580,6 +613,7 @@ export function createClaimStream(
580
613
  const revoke = () => {
581
614
  if (revoked) return;
582
615
  revoked = true;
616
+ lifetime.end();
583
617
  ownClaims.delete(claimId);
584
618
  sendAbandon(claimId, claim);
585
619
  logger.info(
@@ -588,7 +622,7 @@ export function createClaimStream(
588
622
  );
589
623
  };
590
624
 
591
- return {
625
+ return bindClaimLifetime({
592
626
  object: 'claim',
593
627
  id: claimId,
594
628
  status: 'active',
@@ -607,7 +641,7 @@ export function createClaimStream(
607
641
  [Symbol.asyncDispose]: async () => {
608
642
  revoke();
609
643
  },
610
- };
644
+ }, lifetime);
611
645
  }
612
646
 
613
647
  function resolveTarget(target: PresenceTarget): ClaimTarget {
@@ -686,6 +720,10 @@ export function createClaimStream(
686
720
  );
687
721
  });
688
722
  }
723
+ const error = new AbloConnectionError(
724
+ 'The claim stream was disposed. Acquire a fresh claim before continuing.',
725
+ );
726
+ for (const { lifetime } of ownClaims.values()) lifetime.end(error);
689
727
  listeners.clear();
690
728
  rejectionListeners.clear();
691
729
  lostListeners.clear();
@@ -15,4 +15,4 @@ export {
15
15
  type CredentialRefreshResult,
16
16
  type CredentialRefresher,
17
17
  type CredentialLifecycleContext,
18
- } from '@abloatai/transaction/transport/credentialLifecycle';
18
+ } from '@abloatai/transaction/transport/connection';
@@ -1,4 +1,4 @@
1
- import type { JoinOptions } from '@abloatai/transaction/resources/modelOperations';
1
+ import type { JoinOptions } from '@abloatai/transaction/client/resources/modelOperations';
2
2
  import type { SyncWebSocket } from './SyncWebSocket.js';
3
3
  import type { Schema, SchemaRecord } from '@abloatai/transaction/schema/schema';
4
4
  import { scopeKindOf, type ModelDef } from '@abloatai/transaction/schema/model';
@@ -15,13 +15,18 @@
15
15
  * Pure and transport-free; the BootstrapFetcher owns fetching the surface.
16
16
  */
17
17
 
18
+ import { reconcileClientToActive } from '@abloatai/transaction/schema';
19
+
18
20
  /** One model as the server's schema read-back reports it. */
19
21
  export interface ServerSchemaModel {
20
22
  readonly key: string;
21
23
  /** Per-model content hash; absent on servers older than this check. */
22
24
  readonly hash?: string;
25
+ readonly fields?: Readonly<Record<string, { readonly type: string; readonly isOptional: boolean }>>;
23
26
  }
24
27
 
28
+ export interface SchemaFieldDrift { readonly model: string; readonly field: string; readonly direction: 'client_only' | 'active_only' | 'changed'; readonly detail: string; }
29
+
25
30
  export type SchemaDriftFinding =
26
31
  /** Every model this client declares exists server-side with matching content
27
32
  * (the server may know more — that's an additive lead, not drift). */
@@ -35,6 +40,7 @@ export type SchemaDriftFinding =
35
40
  readonly kind: 'changed';
36
41
  readonly models: readonly string[];
37
42
  readonly unpushed: readonly string[];
43
+ readonly fields?: readonly SchemaFieldDrift[];
38
44
  }
39
45
  /** The server surface carries no per-model hashes (older server) — the
40
46
  * caller falls back to the whole-hash comparison. */
@@ -43,6 +49,7 @@ export type SchemaDriftFinding =
43
49
  export function classifySchemaDrift(
44
50
  clientModels: Readonly<Record<string, string>>,
45
51
  serverModels: readonly ServerSchemaModel[],
52
+ clientShapes: Readonly<Record<string, Readonly<Record<string, { readonly type: string; readonly isOptional: boolean }>>>> = {},
46
53
  ): SchemaDriftFinding {
47
54
  if (serverModels.length > 0 && serverModels.every((m) => !m.hash)) {
48
55
  return { kind: 'unknown' };
@@ -50,12 +57,24 @@ export function classifySchemaDrift(
50
57
  const server = new Map(serverModels.map((m) => [m.key, m.hash]));
51
58
  const unpushed: string[] = [];
52
59
  const changed: string[] = [];
60
+ const fields: SchemaFieldDrift[] = [];
53
61
  for (const [key, hash] of Object.entries(clientModels)) {
54
62
  const serverHash = server.get(key);
55
63
  if (serverHash === undefined) unpushed.push(key);
56
- else if (serverHash !== hash) changed.push(key);
64
+ else if (serverHash !== hash) {
65
+ changed.push(key);
66
+ const clientFields = clientShapes[key];
67
+ const activeFields = serverModels.find((model) => model.key === key)?.fields;
68
+ if (clientFields && activeFields) for (const field of new Set([...Object.keys(clientFields), ...Object.keys(activeFields)])) {
69
+ const client = clientFields[field];
70
+ const active = activeFields[field];
71
+ if (!active) fields.push({ model: key, field, direction: 'client_only', detail: 'present in this build but absent from the active schema' });
72
+ else if (!client) fields.push({ model: key, field, direction: 'active_only', detail: 'present in the active schema but absent from this build' });
73
+ else if (client.type !== active.type || client.isOptional !== active.isOptional) fields.push({ model: key, field, direction: 'changed', detail: `${client.type}${client.isOptional ? ' optional' : ' required'} in this build and ${active.type}${active.isOptional ? ' optional' : ' required'} in the active schema` });
74
+ }
75
+ }
57
76
  }
58
- if (changed.length > 0) return { kind: 'changed', models: changed, unpushed };
77
+ if (changed.length > 0) return { kind: 'changed', models: changed, unpushed, ...(fields.length ? { fields } : {}) };
59
78
  if (unpushed.length > 0) return { kind: 'unpushed', models: unpushed };
60
79
  return { kind: 'aligned' };
61
80
  }
@@ -69,18 +88,15 @@ export function describeSchemaDrift(
69
88
  finding: Extract<SchemaDriftFinding, { kind: 'unpushed' | 'changed' }>,
70
89
  serverLabel: string,
71
90
  ): string {
72
- if (finding.kind === 'unpushed') {
73
- return (
74
- `Ablo: This build declares models the server at ${serverLabel} doesn't have yet ` +
75
- `(${finding.models.join(', ')}). Writes to them will be declined until the schema is ` +
76
- `pushed run \`ablo push\` (and \`ablo status\` to confirm it targets this server).`
77
- );
78
- }
79
- const alsoUnpushed = finding.unpushed.length > 0 ? ` (${finding.unpushed.join(', ')} not pushed yet)` : '';
80
- return (
81
- `Ablo: These models differ between this build and the server at ${serverLabel}: ` +
82
- `${finding.models.join(', ')}${alsoUnpushed}. Reads and writes touching what changed may be ` +
83
- `declined — \`ablo status\` shows the deployed shape; pushing your schema or deploying a ` +
84
- `current build aligns them.`
85
- );
91
+ const findings = finding.kind === 'unpushed'
92
+ ? reconcileClientToActive([], finding.models, serverLabel)
93
+ : reconcileClientToActive(finding.models, finding.unpushed, serverLabel, finding.fields ?? []);
94
+ const changed = findings.filter(({ code }) => code === 'model_changed').map(({ model }) => model).filter(Boolean);
95
+ const unpushed = findings.filter(({ code }) => code === 'model_unpushed').map(({ model }) => model).filter(Boolean);
96
+ const summary = [
97
+ ...findings.filter(({ field }) => field !== undefined).map(({ message }) => message),
98
+ ...(changed.length ? [`Models ${changed.join(', ')} differ between this build and the active schema at ${serverLabel}.`] : []),
99
+ ...(unpushed.length ? [`Models ${unpushed.join(', ')} are declared by this build but are not active at ${serverLabel}.`] : []),
100
+ ];
101
+ return `Ablo: ${summary.join(' ')} ${findings.map(({ action }) => action).filter((value, index, all) => all.indexOf(value) === index).join(' ')}`;
86
102
  }
@@ -10,7 +10,7 @@ import { z } from 'zod';
10
10
  import { globalRuntime } from "../context.js";
11
11
  import type { RuntimeContext } from "../RuntimeContext.js";
12
12
  import { AbloValidationError } from "@abloatai/transaction/errors";
13
- import { syncDeltaWireCoreSchema } from '@abloatai/transaction/wire/delta';
13
+ import { syncDeltaWireCoreSchema } from '@abloatai/transaction/observation';
14
14
 
15
15
  // ─── Server Delta Schema ─────────────────────────────────────────────────────
16
16
 
@@ -17,4 +17,4 @@ export {
17
17
  type WsInboundFrame,
18
18
  type WsSession,
19
19
  type WsFrameHandler,
20
- } from '@abloatai/transaction/transport/wsFrameHandlers';
20
+ } from '@abloatai/transaction/transport/websocket';
@@ -1,7 +1,7 @@
1
1
  import type {
2
2
  DurableWriteStore,
3
- } from '@abloatai/transaction/durableWrites';
4
- import type { PendingWrite } from '@abloatai/transaction/transactions/confirmation/pendingWrite';
3
+ } from '@abloatai/transaction/commit';
4
+ import type { PendingWrite } from '@abloatai/transaction/commit';
5
5
 
6
6
  export interface CommitOutboxDatabase {
7
7
  sealTransactionRecord(
@@ -27,11 +27,11 @@ import {
27
27
  type LogPositionPort,
28
28
  } from '../../logPosition.js';
29
29
  import type { WriteOptions } from '../../interfaces/index.js';
30
- import type { OnStaleMode, StaleNotification, ReadDependency, TrackDependency } from '@abloatai/transaction/coordination/schema';
30
+ import type { ReadDependency } from '@abloatai/transaction/coordination/schema';
31
31
  import {
32
32
  type CommitOperationResult,
33
33
  type MutationCommitResult,
34
- } from '@abloatai/transaction/wire/commit';
34
+ } from '@abloatai/transaction/commit';
35
35
  import {
36
36
  computePriorityScore,
37
37
  normalizeModelKey,
@@ -74,9 +74,8 @@ import {
74
74
  createCommitEnvelopeMember,
75
75
  type DurableCommitEnvelope,
76
76
  type CommitOutboxScope,
77
- } from '@abloatai/transaction/transactions/confirmation/commitEnvelope';
77
+ } from '@abloatai/transaction/commit';
78
78
  import type { DurableWriteStore } from './durableWriteStore.js';
79
- import { stableStringify } from '@abloatai/transaction/utils/json';
80
79
  import {
81
80
  createLocalMutationPort,
82
81
  type LocalMutationPort,
@@ -108,7 +107,7 @@ import {
108
107
  import { enqueueTransaction, type QueueCoalescingContext } from './queueCoalescing.js';
109
108
  import { processBatch, type BatchProcessingContext } from './batchProcessing.js';
110
109
  import { handleFailure, type FailureHandlingContext } from './failureHandling.js';
111
- import { handleConflict as resolveConflict, isPermanentError as classifyPermanentError, isDefinitiveRejection as classifyDefinitiveRejection, type ConflictPolicyContext } from './failurePolicy.js';
110
+ import { handleConflict as resolveConflict, isPermanentError as classifyPermanentError, isDefinitiveRejection as classifyDefinitiveRejection, type ConflictResolutionContext } from './failurePolicy.js';
112
111
  import { takeNextExecutionBatch as selectExecutionBatch, takePendingDrainBatch as selectPendingDrainBatch } from './executionSelection.js';
113
112
  import { scheduleProcessing as scheduleProcessingExternal, type ProcessingSchedulerContext } from './processingScheduler.js';
114
113
  import {
@@ -314,7 +313,6 @@ export class MutationQueue extends EventEmitter {
314
313
  retryBackoff: this.config.retryBackoff,
315
314
  },
316
315
  commitLane: this.commitLane,
317
- commitNotifications: this.commitNotifications,
318
316
  commitMissingIds: this.commitMissingIds,
319
317
  commitProcessing: this.commitProcessing,
320
318
  setCommitProcessing: (value) => { this.commitProcessing = value; },
@@ -325,7 +323,6 @@ export class MutationQueue extends EventEmitter {
325
323
  await this.dispatchCommitBounded(envelope.operations, {
326
324
  idempotencyKey: envelope.idempotencyKey,
327
325
  ...(envelope.commitOptions.reads ? { reads: envelope.commitOptions.reads } : {}),
328
- ...(envelope.commitOptions.track ? { track: envelope.commitOptions.track } : {}),
329
326
  }),
330
327
  ),
331
328
  persistDurableCommitAcceptance: (envelope, result) => this.persistDurableCommitAcceptance(envelope, result),
@@ -350,7 +347,6 @@ export class MutationQueue extends EventEmitter {
350
347
  private get commitReceiptContext(): CommitReceiptContext {
351
348
  return {
352
349
  commitStore: this.commitStore,
353
- commitNotifications: this.commitNotifications,
354
350
  commitMissingIds: this.commitMissingIds,
355
351
  replicationLagErrors: this.replicationLagErrors,
356
352
  on: (event, listener) => this.on(event, listener),
@@ -437,7 +433,6 @@ export class MutationQueue extends EventEmitter {
437
433
  store: this.store,
438
434
  enqueue: (transaction) => { this.enqueue(transaction); },
439
435
  optimisticUpdates: this.localMutationPort.updates,
440
- commitNotifications: this.commitNotifications,
441
436
  commitMissingIds: this.commitMissingIds,
442
437
  sourceMutationIdsFor: (batch) => this.sourceMutationIdsFor(batch),
443
438
  dispatchCommitBounded: (...args) => this.dispatchCommitBounded(...args),
@@ -447,8 +442,6 @@ export class MutationQueue extends EventEmitter {
447
442
  assertEnvelopeInsideReplayWindow: (envelope) => { this.assertEnvelopeInsideReplayWindow(envelope); },
448
443
  sealDurableCommit: (input) => this.sealDurableCommit(input),
449
444
  noteAck: (syncId) => { this.noteAck(syncId); },
450
- classifyReceiptNotifications: (operations, notifications) => this.classifyReceiptNotifications(operations, notifications),
451
- receiptTargetKey: (modelName, modelId) => this.receiptTargetKey(modelName, modelId),
452
445
  scheduleReplicationLagTimeout: (transactionId, clientTxId, correlationId) => { this.scheduleReplicationLagTimeout(transactionId, clientTxId, correlationId); },
453
446
  scheduleDeltaConfirmationTimeout: (transaction, timeoutMs) => { this.scheduleDeltaConfirmationTimeout(transaction, timeoutMs); },
454
447
  clearReplicationLagState: (transactionId) => { this.clearReplicationLagState(transactionId); },
@@ -479,7 +472,7 @@ export class MutationQueue extends EventEmitter {
479
472
  };
480
473
  }
481
474
 
482
- private get conflictPolicyContext(): ConflictPolicyContext {
475
+ private get conflictResolutionContext(): ConflictResolutionContext {
483
476
  return {
484
477
  config: this.config,
485
478
  store: this.store,
@@ -623,80 +616,7 @@ export class MutationQueue extends EventEmitter {
623
616
  return entityKey(modelName, modelId);
624
617
  }
625
618
 
626
- /** Collision-safe receipt target identity across models sharing a row id. */
627
- private receiptTargetKey(modelName: string, modelId: string): string {
628
- return stableStringify([normalizeModelKey(modelName), modelId]);
629
- }
630
-
631
- /**
632
- * Relates stale notifications back to write targets without assuming the
633
- * server's canonical model name uses the same spelling as the public schema
634
- * key (`Item` versus `items`). Exact `(model,id)` wins; a globally unique id
635
- * is the compatibility fallback. An ambiguous same-id cross-model mismatch
636
- * is deliberately left unclassified, so it cannot falsely settle a queued
637
- * write. A notification with no write-target id (or an explicit group) is a
638
- * declared-read conflict and holds the whole batch.
639
- */
640
- private classifyReceiptNotifications(
641
- operations: readonly { model: string; id: string }[],
642
- notifications: readonly StaleNotification[],
643
- ): {
644
- holdsEntireBatch: boolean;
645
- heldTargets: Set<string>;
646
- notificationsByTarget: Map<string, StaleNotification[]>;
647
- } {
648
- const targets = operations.map((operation) => ({
649
- id: operation.id,
650
- key: this.receiptTargetKey(operation.model, operation.id),
651
- }));
652
- const heldTargets = new Set<string>();
653
- const notificationsByTarget = new Map<string, StaleNotification[]>();
654
- let holdsEntireBatch = false;
655
-
656
- for (const notification of notifications) {
657
- // Scope is decided before any target matching. A group premise fires over
658
- // the WHOLE batch by convention, and its `target` now names the row that
659
- // actually moved — which may well be a row this batch is writing, so
660
- // matching first would misread a batch-wide hold as a per-row one.
661
- if (notification.scope === 'group') {
662
- holdsEntireBatch = true;
663
- continue;
664
- }
665
- const candidates = targets.filter(
666
- (target) => target.id === notification.target.id,
667
- );
668
- const notificationKey = this.receiptTargetKey(
669
- notification.target.model,
670
- notification.target.id,
671
- );
672
- const exactTargets = candidates.filter(
673
- (target) => target.key === notificationKey,
674
- );
675
- const candidateKeys = new Set(
676
- (exactTargets.length > 0 ? exactTargets : candidates).map(
677
- (target) => target.key,
678
- ),
679
- );
680
-
681
- if (candidates.length === 0) {
682
- holdsEntireBatch = true;
683
- continue;
684
- }
685
- if (candidateKeys.size !== 1) {
686
- // Same id across multiple differently named models with no exact match:
687
- // the id-only compatibility fallback is ambiguous. Await the echo.
688
- continue;
689
- }
690
- const [targetKey] = candidateKeys;
691
- if (!targetKey) continue;
692
- heldTargets.add(targetKey);
693
- const targetNotifications = notificationsByTarget.get(targetKey) ?? [];
694
- targetNotifications.push(notification);
695
- notificationsByTarget.set(targetKey, targetNotifications);
696
- }
697
-
698
- return { holdsEntireBatch, heldTargets, notificationsByTarget };
699
- }
619
+ /* Stale receipt classification was removed; stale premises now reject. */
700
620
 
701
621
  private resolveConfirmation(transaction: QueuedMutation): void {
702
622
  const resolver = this.confirmationResolvers.get(transaction.id);
@@ -807,11 +727,6 @@ export class MutationQueue extends EventEmitter {
807
727
 
808
728
  private readonly localMutationPort: LocalMutationPort;
809
729
 
810
- // Stale-context notifications, keyed by transaction id. When the server
811
- // accepts a commit but reports that an operation's premise had moved,
812
- // the notification lands here from the commit acknowledgement and is drained
813
- // by `waitForCommitReceipt`, so the receipt can carry it back to the caller.
814
- private commitNotifications = new Map<string, StaleNotification[]>();
815
730
  /** Zero-row targets returned on a successful atomic commit receipt. */
816
731
  private commitMissingIds = new Map<string, string[]>();
817
732
 
@@ -1674,7 +1589,7 @@ export class MutationQueue extends EventEmitter {
1674
1589
  async enqueueCommit(
1675
1590
  clientTxId: string,
1676
1591
  operations: CommitTransaction['operations'],
1677
- options: { reads?: ReadDependency[] | null; track?: TrackDependency[] | null } = {},
1592
+ options: { reads?: ReadDependency[] | null } = {},
1678
1593
  ): Promise<void> {
1679
1594
  return enqueueCommit(this.commitApiContext, clientTxId, operations, options);
1680
1595
  }
@@ -1687,7 +1602,6 @@ export class MutationQueue extends EventEmitter {
1687
1602
  clientTxId: string,
1688
1603
  ): Promise<{
1689
1604
  lastSyncId: number;
1690
- notifications?: StaleNotification[];
1691
1605
  missingIds?: string[];
1692
1606
  operationResults?: CommitOperationResult[];
1693
1607
  }> {
@@ -1712,7 +1626,7 @@ export class MutationQueue extends EventEmitter {
1712
1626
  }
1713
1627
 
1714
1628
  async handleConflict(transaction: QueuedMutation, serverData: MutationInput): Promise<void> {
1715
- await resolveConflict(this.conflictPolicyContext, transaction, serverData);
1629
+ await resolveConflict(this.conflictResolutionContext, transaction, serverData);
1716
1630
  }
1717
1631
 
1718
1632
  private applyOptimisticCreate(model: LocalModel, transaction: QueuedMutation): void {
@@ -2013,7 +1927,6 @@ export class MutationQueue extends EventEmitter {
2013
1927
  this.recentDeltaCorrelations.clear();
2014
1928
  this.commitLane = [];
2015
1929
  this.commitStore.clear();
2016
- this.commitNotifications.clear();
2017
1930
  this.commitMissingIds.clear();
2018
1931
 
2019
1932
  // Clear event listeners
@@ -3,10 +3,9 @@ import type { MutationQueueConfig } from './MutationQueue.js';
3
3
  import type { MutationInput, QueuedMutation } from './commitPayload.js';
4
4
  import type { MutationStore } from './MutationStore.js';
5
5
  import type { OptimisticUpdateEntry } from './localMutation.js';
6
- import type { StaleNotification } from '@abloatai/transaction/coordination/schema';
7
6
  import type { DeltaConfirmationTracker } from './deltaConfirmation.js';
8
- import type { MutationCommitResult } from '@abloatai/transaction/wire/commit';
9
- import type { DurableCommitEnvelope } from '@abloatai/transaction/transactions/confirmation/commitEnvelope';
7
+ import type { MutationCommitResult } from '@abloatai/transaction/commit';
8
+ import type { DurableCommitEnvelope } from '@abloatai/transaction/commit';
10
9
  import { AbloError, AbloNotFoundError } from '@abloatai/transaction/errors';
11
10
  import { applyWriteOptions, normalizeModelKey, TX_TYPE_TO_MUTATION_OP, type WriteOperationFields } from './commitPayload.js';
12
11
  import type { MutationOperationType } from '@abloatai/transaction/types';
@@ -30,7 +29,6 @@ export interface BatchProcessingContext {
30
29
  readonly store: MutationStore;
31
30
  readonly enqueue: (transaction: QueuedMutation) => void;
32
31
  readonly optimisticUpdates: Map<string, OptimisticUpdateEntry>;
33
- readonly commitNotifications: Map<string, StaleNotification[]>;
34
32
  readonly commitMissingIds: Map<string, string[]>;
35
33
  readonly sourceMutationIdsFor: (batch: readonly QueuedMutation[]) => string[];
36
34
  readonly dispatchCommitBounded: (...args: Parameters<import('../../interfaces/index.js').MutationExecutor['commit']>) => ReturnType<import('../../interfaces/index.js').MutationExecutor['commit']>;
@@ -40,11 +38,6 @@ export interface BatchProcessingContext {
40
38
  readonly assertEnvelopeInsideReplayWindow: (envelope: DurableCommitEnvelope) => void;
41
39
  readonly sealDurableCommit: (input: Parameters<typeof import('./commitTransport.js').sealDurableCommit>[1]) => Promise<DurableCommitEnvelope>;
42
40
  readonly noteAck: (syncId: number | undefined) => void;
43
- readonly classifyReceiptNotifications: (
44
- operations: readonly { model: string; id: string }[],
45
- notifications: readonly StaleNotification[],
46
- ) => { holdsEntireBatch: boolean; heldTargets: Set<string>; notificationsByTarget: Map<string, StaleNotification[]> };
47
- readonly receiptTargetKey: (modelName: string, modelId: string) => string;
48
41
  readonly scheduleReplicationLagTimeout: (transactionId: string, clientTxId?: string, correlationId?: string) => void;
49
42
  readonly scheduleDeltaConfirmationTimeout: (transaction: QueuedMutation, timeoutMs: number) => void;
50
43
  readonly clearReplicationLagState: (transactionId: string) => void;
@@ -170,24 +163,6 @@ export async function processBatch(ctx: BatchProcessingContext): Promise<void> {
170
163
  );
171
164
  const lastSyncId = result.lastSyncId;
172
165
 
173
- const notifications = result.notifications;
174
- const {
175
- holdsEntireBatch,
176
- heldTargets,
177
- notificationsByTarget,
178
- } = ctx.classifyReceiptNotifications(
179
- batchOps.map(({ op }) => ({ model: op.model, id: op.id })),
180
- notifications ?? [],
181
- );
182
- for (const { tx } of batchOps) {
183
- const txTarget = ctx.receiptTargetKey(tx.modelKey, tx.modelId);
184
- const txNotifs = holdsEntireBatch
185
- ? notifications
186
- : notificationsByTarget.get(txTarget);
187
- if (txNotifs && txNotifs.length > 0) {
188
- ctx.commitNotifications.set(tx.id, txNotifs);
189
- }
190
- }
191
166
  const missingIds = new Set(result.missingIds ?? []);
192
167
  const settlingBatchOps: typeof batchOps = [];
193
168
  for (const entry of batchOps) {
@@ -202,17 +177,6 @@ export async function processBatch(ctx: BatchProcessingContext): Promise<void> {
202
177
  );
203
178
  continue;
204
179
  }
205
- if (
206
- holdsEntireBatch ||
207
- heldTargets.has(ctx.receiptTargetKey(tx.modelKey, tx.modelId))
208
- ) {
209
- await ctx.rollbackOptimistic(tx, 'conflict_server_wins');
210
- ctx.store.updateStatus(tx.id, 'completed');
211
- ctx.emit('transaction:completed', tx);
212
- ctx.emit(`transaction:completed:${tx.id}`, tx);
213
- ctx.optimisticUpdates.delete(tx.id);
214
- continue;
215
- }
216
180
  settlingBatchOps.push(entry);
217
181
  }
218
182