@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
@@ -3,7 +3,7 @@
3
3
  * `ablo.<model>`.
4
4
  *
5
5
  * Each schema model gets one {@link ModelOperations}: the async server reads
6
- * `get` and `list`, with the same point lookup restricted to the local graph under
6
+ * `read` and `list`, with the same point lookup restricted to the local graph under
7
7
  * `local`, the writes `create`, `update`, and `delete`, the coordination
8
8
  * namespace `claim` (callable as `claim({ id })`, plus `claim.state`,
9
9
  * `claim.queue`, `claim.release`, and `claim.reorder`), `join`, and `onChange`.
@@ -21,12 +21,11 @@ import {
21
21
  reconcileFunctionalUpdate,
22
22
  type ModelUpdater,
23
23
  type FunctionalUpdateOptions,
24
- } from '@abloatai/transaction/resources/functionalUpdate';
24
+ } from '@abloatai/transaction/client/resources/functionalUpdate';
25
25
  import type { MutationOptions } from '../interfaces/index.js';
26
26
  import {
27
27
  claimDescription,
28
28
  type ReadDependency,
29
- type TrackDependency,
30
29
  } from '@abloatai/transaction/coordination/schema';
31
30
  import { Model, modelAsRow } from '../Model.js';
32
31
  import { toMs } from '@abloatai/transaction/utils/duration';
@@ -36,32 +35,32 @@ import {
36
35
  resolveHeartbeatOptions,
37
36
  resolveHeartbeatPlan,
38
37
  startClaimHeartbeatLoop,
39
- } from '@abloatai/transaction/coordination/claimHeartbeatLoop';
38
+ } from '@abloatai/transaction/claims';
40
39
  import {
41
40
  assertWriteOptions,
42
41
  assertWriteTarget,
43
- } from '@abloatai/transaction/resources/writeOptionsSchema';
42
+ } from '@abloatai/transaction/client/resources/writeOptionsSchema';
44
43
  import {
45
44
  createModelId,
46
45
  resolveCreatedRows,
47
46
  resolveCreateId,
48
- } from '@abloatai/transaction/resources/modelCreate';
47
+ } from '@abloatai/transaction/client/resources/modelCreate';
49
48
  import type {
50
49
  CommitCreateOptions,
51
50
  CommitReceipt,
52
- } from '@abloatai/transaction/resources/httpResources';
53
- import type { HttpModelMutationParams } from '@abloatai/transaction/transport/httpClient';
51
+ } from '@abloatai/transaction/client/resources/httpResources';
52
+ import type { HttpModelMutationParams } from '@abloatai/transaction/transport/http';
54
53
  import {
55
54
  collectModelList,
56
55
  modelList,
57
56
  type ModelList,
58
- } from '@abloatai/transaction/resources/httpResources';
57
+ } from '@abloatai/transaction/client/resources/httpResources';
59
58
  import { subTarget } from '@abloatai/transaction/coordination';
60
59
  // A named claim-meta crossing (see `claim-meta-crossings-are-enumerated` in
61
- // .dependency-cruiser.cjs): the reactive proxy's self-claim targets are
60
+ // .dependency-cruiser.cjs): the reactive model surface's self-claim targets are
62
61
  // decodes that build a public claim, so their `meta` converts wire→declared
63
62
  // here like the other enumerated crossings.
64
- import { declaredMeta } from '@abloatai/transaction/coordination/claimMeta';
63
+ import { declaredMeta } from '@abloatai/transaction/claims';
65
64
  import type { ModelTarget } from '@abloatai/transaction/coordination/schema';
66
65
  import type { ModelRegistry } from '../ModelRegistry.js';
67
66
  import type { InstanceCache } from '../InstanceCache.js';
@@ -78,24 +77,24 @@ import type {
78
77
  HeldLease,
79
78
  ClaimWaitOptions,
80
79
  ClaimTarget,
81
- Snapshot,
82
80
  } from '@abloatai/transaction/types/streams';
81
+ import {
82
+ bindClaimLifetime,
83
+ claimLifetimeOf,
84
+ } from '@abloatai/transaction/claims/lifetime';
83
85
 
84
86
  // The request contract — every option and parameter shape a caller passes to a
85
87
  // read, a write, or a claim — lives in the confirmation core (ADR 0016). This
86
88
  // factory binds it to reactive model instances; the shapes themselves are
87
- // transport- and consumer-agnostic. Re-exported so `./createModelProxy` stays a
89
+ // transport- and consumer-agnostic. Re-exported so `./createModelOperations` stays a
88
90
  // working import path for the whole surface.
89
91
  export type {
90
92
  ModelListScope,
91
- ModelTrackParams,
92
- ModelTrackResult,
93
93
  LocalReadOptions,
94
94
  LocalCountOptions,
95
95
  ServerReadOptions,
96
96
  ListAllOptions,
97
- ServerGetOptions,
98
- ServerRetrieveOptions,
97
+ ServerPointReadOptions,
99
98
  ClaimTargetOptions,
100
99
  ClaimParams,
101
100
  ClaimContentionOptions,
@@ -109,12 +108,12 @@ export type {
109
108
  ClaimReadApi,
110
109
  AwaitedClaimMethod,
111
110
  ClaimApi,
112
- ModelRetrieveParams,
111
+ ModelReadParams,
113
112
  ModelCreateParams,
114
113
  ModelUpdateParams,
115
114
  ModelDeleteParams,
116
115
  JoinOptions,
117
- } from '@abloatai/transaction/resources/modelOperations';
116
+ } from '@abloatai/transaction/client/resources/modelOperations';
118
117
  export type { Claim, ClaimHeartbeat, ClaimHeartbeatOptions, HeldClaim, HeldLease };
119
118
 
120
119
  import type {
@@ -133,28 +132,23 @@ import type {
133
132
  ModelCreateManyParams,
134
133
  ModelCreateParams,
135
134
  ModelDeleteParams,
136
- ModelRetrieveParams,
137
- ModelTrackParams,
138
- ModelTrackResult,
135
+ ModelReadParams,
139
136
  ModelUpdateParams,
140
137
  ServerReadOptions,
141
138
  ListAllOptions,
142
- } from '@abloatai/transaction/resources/modelOperations';
139
+ } from '@abloatai/transaction/client/resources/modelOperations';
143
140
  import {
144
141
  claimQueueView,
145
142
  resolveClaimContentionOptions,
146
- } from '@abloatai/transaction/resources/modelOperations';
143
+ } from '@abloatai/transaction/client/resources/modelOperations';
147
144
  import type {
148
145
  CapturedRow,
149
146
  HttpModelClient,
150
- } from '@abloatai/transaction/transport/httpClient';
147
+ } from '@abloatai/transaction/transport/http';
151
148
  import type { ParticipantKind } from '@abloatai/transaction/types/participant';
152
149
  import {
153
- abortReadSetCommit,
154
150
  capturePointRead,
155
- consumeReadSet,
156
151
  prepareReadSet,
157
- type PreparedReadSet,
158
152
  type ReadSetContext,
159
153
  } from '@abloatai/transaction/internal/read-set';
160
154
 
@@ -222,7 +216,8 @@ export interface ModelCollaboration {
222
216
  /** Request-scoped queued / granted / skipped / failed status events. */
223
217
  onStatus?: (event: ClaimAttemptEvent) => void;
224
218
  }): Promise<Claim>;
225
- createSnapshot(modelKey: string, id: string): Snapshot;
219
+ /** Current applied/acked watermark, captured after a claim's fresh read. */
220
+ currentReadAt(): number;
226
221
  /**
227
222
  * Current coordination state on a target — who (if anyone) holds it.
228
223
  * Synchronous reactive snapshot read off the presence/claim stream;
@@ -293,7 +288,7 @@ export interface ModelCollaboration {
293
288
  * Opens a presence and claim subscription on this model's sync group(s) and
294
289
  * returns the live participant handle. Backs `ablo.<model>.join(ids)`.
295
290
  * WebSocket only, since presence needs a live socket; it is absent on other
296
- * client constructions, where the proxy throws a clear error.
291
+ * client constructions, where the surface throws a clear error.
297
292
  */
298
293
  createJoin?(
299
294
  modelKey: string,
@@ -308,7 +303,7 @@ export interface ModelCollaboration {
308
303
  *
309
304
  * Every verb mirrors its asynchronous sibling on the base surface, and the one
310
305
  * word in front is the whole difference. It is a narrowing, not a claim about
311
- * the other side: `get` consults the local graph and then the network,
306
+ * the other side: `read` consults the local graph and then the network,
312
307
  * while `local.get` is restricted to what is already resident — which is
313
308
  * also why it can return a value instead of a promise. There is nothing to
314
309
  * await.
@@ -425,7 +420,7 @@ export type ModelOperations<T, CreateInput> = Omit<
425
420
  > &
426
421
  ReactiveModelSurface<T, CreateInput>;
427
422
 
428
- export function createModelProxy<T, C>(
423
+ export function createModelOperations<T, C>(
429
424
  schemaKey: string,
430
425
  registeredModelName: string,
431
426
  objectPool: Pick<InstanceCache, 'get' | 'getByType' | 'getOfType'>,
@@ -433,7 +428,6 @@ export function createModelProxy<T, C>(
433
428
  SyncClient,
434
429
  | 'add'
435
430
  | 'delete'
436
- | 'getMutationQueue'
437
431
  | 'getOrganizationId'
438
432
  | 'syncNow'
439
433
  | 'update'
@@ -567,27 +561,66 @@ export function createModelProxy<T, C>(
567
561
  await syncClient.waitForConfirmation(model.getModelName(), model.id);
568
562
  };
569
563
 
570
- // Claims this proxy currently holds, keyed by entity id. Lets the flat
571
- // `release({ id })` and `update({ id, data })` find the lease and snapshot a
572
- // `claim({ id })` took, without a per-call handle. Released on dispose,
573
- // explicit release, or TTL expiry.
564
+ // Claims this model surface currently holds, keyed by the exact grant id.
565
+ // Several disjoint sub-row claims may coexist on one entity, so entity id is
566
+ // an index, never the identity of a lease. The old Map<entityId, claim>
567
+ // silently overwrote the first handle when the same participant claimed a
568
+ // second field and then released/guarded whichever happened to be last.
574
569
  //
575
570
  // `target`, `description`, and `expiresAt` are kept alongside the lease so
576
571
  // `claim.state` can synthesize a self-claim: the server excludes a holder's
577
- // own presence frames, so this proxy is the only place that knows the client
572
+ // own presence frames, so this surface is the only place that knows the client
578
573
  // holds the row. `expiresAt` is the client's best estimate from the requested
579
574
  // TTL (a real epoch-millisecond expiry, not a fabricated watermark), defaulting
580
575
  // to the server's keepalive lease window when no TTL was requested.
576
+ interface ActiveClaim {
577
+ readonly entityId: string;
578
+ readonly lease: Claim;
579
+ readonly readAt: number;
580
+ readonly target: ClaimTarget;
581
+ readonly description: string;
582
+ expiresAt: number;
583
+ stopHeartbeat?: () => void;
584
+ }
585
+
581
586
  const activeClaims = new Map<
582
587
  string,
583
- {
584
- lease: Claim;
585
- snapshot: Snapshot;
586
- target: ClaimTarget;
587
- description: string;
588
- expiresAt: number;
589
- }
588
+ ActiveClaim
590
589
  >();
590
+ const claimIdsByEntity = new Map<string, Set<string>>();
591
+
592
+ const claimsForEntity = (entityId: string): ActiveClaim[] =>
593
+ [...(claimIdsByEntity.get(entityId) ?? [])].flatMap((claimId) => {
594
+ const held = activeClaims.get(claimId);
595
+ return held ? [held] : [];
596
+ });
597
+
598
+ const addActiveClaim = (claimId: string, held: ActiveClaim): void => {
599
+ activeClaims.set(claimId, held);
600
+ const ids = claimIdsByEntity.get(held.entityId) ?? new Set<string>();
601
+ ids.add(claimId);
602
+ claimIdsByEntity.set(held.entityId, ids);
603
+ };
604
+
605
+ const removeActiveClaim = (claimId: string): ActiveClaim | undefined => {
606
+ const held = activeClaims.get(claimId);
607
+ if (!held) return undefined;
608
+ activeClaims.delete(claimId);
609
+ const ids = claimIdsByEntity.get(held.entityId);
610
+ ids?.delete(claimId);
611
+ if (ids?.size === 0) claimIdsByEntity.delete(held.entityId);
612
+ held.stopHeartbeat?.();
613
+ return held;
614
+ };
615
+
616
+ const implicitClaimForEntity = (entityId: string): ActiveClaim | undefined => {
617
+ const held = claimsForEntity(entityId);
618
+ if (held.length <= 1) return held[0];
619
+ throw new AbloValidationError(
620
+ `${registeredModelName}/${entityId} has ${held.length} local claims on different parts of the row. Pass the exact claim handle to the write so Ablo can use the intended grant.`,
621
+ { code: 'write_options_invalid', param: 'claim' },
622
+ );
623
+ };
591
624
 
592
625
  // Server keepalive lease window — the same `LEASE_TTL_MS` the wire protocol
593
626
  // declares, so the client's estimate and the server's lease cannot drift.
@@ -607,12 +640,11 @@ export function createModelProxy<T, C>(
607
640
  | ModelCreateParams<T, C>
608
641
  | ModelUpdateParams<T, C>
609
642
  | ModelDeleteParams<T, C>,
610
- ): { options: MutationOptions; prepared: PreparedReadSet } => {
643
+ ): MutationOptions => {
611
644
  const prepared = prepareReadSet(
612
645
  readSetContext,
613
646
  readSetClientIdentity,
614
- params.readAt,
615
- params.onStale,
647
+ undefined,
616
648
  params.idempotencyKey,
617
649
  params.reads,
618
650
  );
@@ -623,44 +655,47 @@ export function createModelProxy<T, C>(
623
655
  ? { idempotencyKey: params.idempotencyKey }
624
656
  : {}),
625
657
  ...(params.label !== undefined ? { label: params.label } : {}),
626
- ...(prepared.readAt !== undefined
627
- ? { readAt: prepared.readAt }
628
- : params.readAt !== undefined
629
- ? { readAt: params.readAt }
630
- : {}),
631
- ...(prepared.onStale !== undefined
632
- ? { onStale: prepared.onStale }
633
- : params.onStale !== undefined
634
- ? { onStale: params.onStale }
635
- : {}),
636
- ...(params.fenceToken !== undefined ? { fenceToken: params.fenceToken } : {}),
637
- ...(params.claimRef !== undefined ? { claimRef: params.claimRef } : {}),
638
658
  ...(prepared.reads !== undefined
639
659
  ? { reads: prepared.reads === null ? null : [...prepared.reads] }
640
660
  : params.reads !== undefined
641
661
  ? { reads: params.reads }
642
662
  : {}),
643
- ...(params.track !== undefined ? { track: params.track } : {}),
644
663
  };
645
664
  // The write-options schema — the runtime twin of the compile-time params.
646
- // Catches plain-JavaScript callers (for example `onStale: 'rejct'`) at the
647
- // call site with a typed error instead of a silent no-op or a server 400.
648
- try {
649
- assertWriteOptions(rest, `${schemaKey} write`);
650
- } catch (error) {
651
- abortReadSetCommit(readSetContext, prepared.automaticCommit);
652
- throw error;
653
- }
654
- return { options: rest, prepared };
665
+ // Catches plain-JavaScript callers at the call site with a typed error
666
+ // instead of a silent no-op or a server 400.
667
+ assertWriteOptions(rest, `${schemaKey} write`);
668
+ return rest;
655
669
  };
656
670
 
657
- const releaseClaim = async (id: string): Promise<void> => {
658
- const held = activeClaims.get(id);
671
+ const releaseClaim = async (claimId: string): Promise<void> => {
672
+ const held = removeActiveClaim(claimId);
659
673
  if (!held) return;
660
- activeClaims.delete(id);
661
674
  await held.lease.release?.();
662
675
  };
663
676
 
677
+ const releaseClaimsForEntity = async (entityId: string): Promise<void> => {
678
+ const ids = [...(claimIdsByEntity.get(entityId) ?? [])];
679
+ await Promise.all(ids.map((claimId) => releaseClaim(claimId)));
680
+ };
681
+
682
+ const settleClaimsAfterWrite = async (
683
+ entityId: string,
684
+ explicit?: Pick<Claim<T>, 'id' | 'release'>,
685
+ ): Promise<void> => {
686
+ const explicitWasLocal = explicit ? activeClaims.has(explicit.id) : false;
687
+ // The server fulfills every claim this participant holds on the written
688
+ // entity. Mirror that terminal transition locally so claim.state/list and
689
+ // heartbeat bookkeeping do not describe leases the commit already ended.
690
+ // This runs after authoritative confirmation. A best-effort abandon frame
691
+ // cannot turn a committed write into an apparent failure; the server has
692
+ // already fulfilled the participant's claims as part of that commit.
693
+ await releaseClaimsForEntity(entityId).catch(() => undefined);
694
+ if (explicit && !explicitWasLocal) {
695
+ await explicit.release?.().catch(() => undefined);
696
+ }
697
+ };
698
+
664
699
  const takeClaim = async (
665
700
  params: ClaimParams<C>,
666
701
  ): Promise<HeldClaim<T> | null> => {
@@ -671,11 +706,6 @@ export function createModelProxy<T, C>(
671
706
  );
672
707
  }
673
708
  const { id, ...options } = params;
674
- // Read the local snapshot only to decide whether a post-grant re-read may
675
- // be needed. Admission itself always goes to the server: a local presence
676
- // snapshot may be stale or incomplete across instances.
677
- const held = collaboration.state({ model: wireModel, id });
678
- const contended = !!held && held.heldBy !== collaboration.selfParticipantId;
679
709
  const contention = resolveClaimContentionOptions(options);
680
710
  const failFast = !contention.wait;
681
711
 
@@ -739,25 +769,26 @@ export function createModelProxy<T, C>(
739
769
  throw normalized;
740
770
  }
741
771
 
742
- // Only when the claim actually waited behind another holder can the row have
743
- // changed underneath us re-read so the claimed snapshot reflects what that
744
- // holder committed before releasing. Either of two signals suffices:
745
- // - `lease.waited` the server granted the claim after the client
746
- // provably queued behind a holder. Authoritative; it works even when the
747
- // local snapshot is blind, since claim fan-out is entity-scoped and a
748
- // broadly-subscribed client never observes peers' claims.
749
- // - `contended` the local snapshot saw a holder up front. Kept for the
750
- // no-queue paths, where no grant frame exists.
751
- if ((contended || lease.waited === true) && !failFast) {
752
- // `type: 'complete'` forces the round-trip: the hydration ledger would
753
- // otherwise serve the local row for an already-hydrated id, and the
754
- // holder's final write may not have fanned out yet — the exact
755
- // stale-snapshot race this re-read closes.
756
- await load({ where: [['id', id]], type: 'complete' });
757
- model = ownRowOrThrow(id) ?? model;
758
- }
759
-
760
- const snapshot = collaboration.createSnapshot(schemaKey, id);
772
+ // Re-read on grant, never on contention. Holding the lease is the moment
773
+ // this snapshot becomes the premise an expensive step is about to be spent
774
+ // against, so it is read then whether or not the claim queued, and
775
+ // whether or not a local presence snapshot saw a holder.
776
+ //
777
+ // Gating this on contention assumed only a prior holder could have moved
778
+ // the row, which holds while every writer passes the chokepoint and the
779
+ // default policy refuses a non-holder's write. It does not hold for a row
780
+ // fed by the WAL: a write that landed straight in the customer's database
781
+ // never met a claim, so "nobody contended" was never evidence the row had
782
+ // not changed. The HTTP claim has always read unconditionally here.
783
+ //
784
+ // `type: 'complete'` forces the round-trip: the hydration ledger would
785
+ // otherwise serve the local row for an already-hydrated id, and a peer's
786
+ // final write may not have fanned out yet — the exact stale-snapshot race
787
+ // this closes.
788
+ await load({ where: [['id', id]], type: 'complete' });
789
+ model = ownRowOrThrow(id) ?? model;
790
+
791
+ const readAt = lease.readAt ?? collaboration.currentReadAt();
761
792
  const description = claimDescription(options);
762
793
  // The self-claim's `ClaimTarget` mirrors what a peer's `claim.state` would
763
794
  // report (`state` maps `held.target.model` to `type`), so a holder and a
@@ -775,13 +806,15 @@ export function createModelProxy<T, C>(
775
806
  const ttlMs =
776
807
  options.ttl !== undefined ? toMs(options.ttl) : DEFAULT_LEASE_TTL_MS;
777
808
  const expiresAt = Date.now() + ttlMs;
778
- activeClaims.set(id, {
809
+ const active: ActiveClaim = {
810
+ entityId: id,
779
811
  lease,
780
- snapshot,
812
+ readAt,
781
813
  target: selfTarget,
782
814
  description,
783
815
  expiresAt,
784
- });
816
+ };
817
+ addActiveClaim(lease.id, active);
785
818
  const { meta: targetMeta, ...targetNarrowed } = subTarget(options, schemaKey);
786
819
  const target = {
787
820
  type: schemaKey,
@@ -810,7 +843,7 @@ export function createModelProxy<T, C>(
810
843
  ttl: resolved.ttl ?? options.ttl,
811
844
  ...(resolved.details !== undefined ? { details: resolved.details } : {}),
812
845
  });
813
- const held = activeClaims.get(id);
846
+ const held = activeClaims.get(lease.id);
814
847
  if (held) held.expiresAt = beat.expiresAt;
815
848
  plan.onBeat?.(beat);
816
849
  return beat;
@@ -826,14 +859,19 @@ export function createModelProxy<T, C>(
826
859
  })
827
860
  : undefined;
828
861
 
862
+ active.stopHeartbeat = stopHeartbeatLoop;
863
+ const lifetime = claimLifetimeOf(lease);
864
+ lifetime?.onEnd(() => {
865
+ removeActiveClaim(lease.id);
866
+ });
867
+
829
868
  const release = () => {
830
- stopHeartbeatLoop?.();
831
- return releaseClaim(id);
869
+ return releaseClaim(lease.id);
832
870
  };
833
- return {
871
+ const handle = {
834
872
  object: 'claim',
835
873
  id: lease.id,
836
- readAt: lease.readAt ?? snapshot.stamp,
874
+ readAt,
837
875
  // The fencing token the server minted for this grant, forwarded from the
838
876
  // lease so writes taken under this handle carry it (Option B).
839
877
  ...(lease.fenceToken !== undefined ? { fenceToken: lease.fenceToken } : {}),
@@ -846,7 +884,8 @@ export function createModelProxy<T, C>(
846
884
  },
847
885
  heartbeat,
848
886
  [Symbol.asyncDispose]: release,
849
- };
887
+ } satisfies HeldClaim<T>;
888
+ return lifetime ? bindClaimLifetime(handle, lifetime) : handle;
850
889
  };
851
890
 
852
891
  // The row-free sibling of `takeClaim`: locks a key by id alone, for a row that
@@ -909,11 +948,10 @@ export function createModelProxy<T, C>(
909
948
  throw normalized;
910
949
  }
911
950
 
912
- // A watermark-only snapshot: `createSnapshot` still reads the engine's
913
- // current `lastSyncId` even though the pool holds no row (the bucket is
914
- // empty). It costs nothing extra and gives a write taken under this lease a
915
- // real `readAt` to guard against changes since the lease was acquired.
916
- const snapshot = collaboration.createSnapshot(schemaKey, id);
951
+ // A row-free claim still captures the engine's current read floor. It gives
952
+ // a later write under this lease a real `readAt` without constructing the
953
+ // legacy row snapshot object (there is no row to put in one).
954
+ const readAt = lease.readAt ?? collaboration.currentReadAt();
917
955
  const description = claimDescription(options);
918
956
  const selfTarget: ClaimTarget = {
919
957
  type: wireModel,
@@ -923,13 +961,15 @@ export function createModelProxy<T, C>(
923
961
  const ttlMs =
924
962
  options.ttl !== undefined ? toMs(options.ttl) : DEFAULT_LEASE_TTL_MS;
925
963
  const expiresAt = Date.now() + ttlMs;
926
- activeClaims.set(id, {
964
+ const active: ActiveClaim = {
965
+ entityId: id,
927
966
  lease,
928
- snapshot,
967
+ readAt,
929
968
  target: selfTarget,
930
969
  description,
931
970
  expiresAt,
932
- });
971
+ };
972
+ addActiveClaim(lease.id, active);
933
973
  const target = {
934
974
  type: schemaKey,
935
975
  id,
@@ -954,7 +994,7 @@ export function createModelProxy<T, C>(
954
994
  ttl: resolved.ttl ?? options.ttl,
955
995
  ...(resolved.details !== undefined ? { details: resolved.details } : {}),
956
996
  });
957
- const held = activeClaims.get(id);
997
+ const held = activeClaims.get(lease.id);
958
998
  if (held) held.expiresAt = beat.expiresAt;
959
999
  plan.onBeat?.(beat);
960
1000
  return beat;
@@ -968,14 +1008,19 @@ export function createModelProxy<T, C>(
968
1008
  })
969
1009
  : undefined;
970
1010
 
1011
+ active.stopHeartbeat = stopHeartbeatLoop;
1012
+ const lifetime = claimLifetimeOf(lease);
1013
+ lifetime?.onEnd(() => {
1014
+ removeActiveClaim(lease.id);
1015
+ });
1016
+
971
1017
  const release = () => {
972
- stopHeartbeatLoop?.();
973
- return releaseClaim(id);
1018
+ return releaseClaim(lease.id);
974
1019
  };
975
- return {
1020
+ const handle = {
976
1021
  object: 'claim',
977
1022
  id: lease.id,
978
- readAt: snapshot.stamp,
1023
+ readAt,
979
1024
  // Forward the grant's fencing token so writes under this row-free lease
980
1025
  // carry it (Option B), exactly as the row-bearing claim does.
981
1026
  ...(lease.fenceToken !== undefined ? { fenceToken: lease.fenceToken } : {}),
@@ -987,7 +1032,8 @@ export function createModelProxy<T, C>(
987
1032
  },
988
1033
  heartbeat,
989
1034
  [Symbol.asyncDispose]: release,
990
- };
1035
+ } satisfies HeldLease;
1036
+ return lifetime ? bindClaimLifetime(handle, lifetime) : handle;
991
1037
  };
992
1038
 
993
1039
  // `claim` overloads on its first argument: an options object claims a synced
@@ -1036,10 +1082,8 @@ export function createModelProxy<T, C>(
1036
1082
  * they do it through here so the two answers cannot describe the same
1037
1083
  * holding differently.
1038
1084
  */
1039
- const ownClaimState = (id: string): Claim | null => {
1040
- const own = activeClaims.get(id);
1041
- if (!own) return null;
1042
- return {
1085
+ const ownClaimStates = (id: string): Claim[] =>
1086
+ claimsForEntity(id).map((own) => ({
1043
1087
  object: 'claim',
1044
1088
  id: own.lease.id,
1045
1089
  status: 'active',
@@ -1051,8 +1095,7 @@ export function createModelProxy<T, C>(
1051
1095
  // Symmetric with the peer projection: a holder reading its own claim
1052
1096
  // sees the same `meta` an observer does.
1053
1097
  ...(own.target.meta !== undefined ? { meta: own.target.meta } : {}),
1054
- };
1055
- };
1098
+ }));
1056
1099
 
1057
1100
  const claimReaders = {
1058
1101
  state(params: ClaimLookupParams<T>): Claim | null {
@@ -1066,7 +1109,7 @@ export function createModelProxy<T, C>(
1066
1109
  // holds. Synthesize the active claim from the stored lease so the holder
1067
1110
  // sees its own claim, honoring the documented contract on `claim.state`.
1068
1111
  return (
1069
- ownClaimState(params.id) ??
1112
+ ownClaimStates(params.id)[0] ??
1070
1113
  collaboration?.state({ model: wireModel, id: params.id }) ??
1071
1114
  null
1072
1115
  );
@@ -1081,13 +1124,13 @@ export function createModelProxy<T, C>(
1081
1124
  */
1082
1125
  list(params: ClaimLookupParams<T>): { readonly object: 'list'; readonly data: readonly Claim[] } {
1083
1126
  void collaboration?.enterScope?.({ [schemaKey]: params.id });
1084
- const own = ownClaimState(params.id);
1127
+ const own = ownClaimStates(params.id);
1085
1128
  const peers = collaboration?.holders({ model: wireModel, id: params.id }) ?? [];
1086
1129
  return {
1087
1130
  object: 'list',
1088
1131
  // Own claim first: the server excludes a holder's own presence frames,
1089
1132
  // so it is never among `peers` and the two never duplicate.
1090
- data: own ? [own, ...peers] : [...peers],
1133
+ data: [...own, ...peers],
1091
1134
  };
1092
1135
  },
1093
1136
 
@@ -1102,7 +1145,9 @@ export function createModelProxy<T, C>(
1102
1145
  },
1103
1146
 
1104
1147
  release: guard((params: ClaimLookupParams<T> | Claim<T>): Promise<void> =>
1105
- releaseClaim(isClaimHandle(params) ? params.target.id : params.id),
1148
+ isClaimHandle(params)
1149
+ ? releaseClaim(params.id)
1150
+ : releaseClaimsForEntity(params.id),
1106
1151
  ),
1107
1152
  };
1108
1153
 
@@ -1161,8 +1206,10 @@ export function createModelProxy<T, C>(
1161
1206
  },
1162
1207
  };
1163
1208
 
1164
- const get = guard(
1165
- async (params: ModelRetrieveParams): Promise<CapturedRow<T> | undefined> => {
1209
+ const pointRead = async (
1210
+ params: ModelReadParams,
1211
+ capture: boolean,
1212
+ ): Promise<T | undefined> => {
1166
1213
  // Read-interest enrolment: authoritative point reads enter the same
1167
1214
  // entity scope as the claim stream, while remaining settled reads.
1168
1215
  void collaboration?.enterScope?.({ [schemaKey]: params.id });
@@ -1171,62 +1218,44 @@ export function createModelProxy<T, C>(
1171
1218
  where: [['id', params.id]],
1172
1219
  limit: 1,
1173
1220
  });
1174
- if (readSetContext?.getStore() && collaboration) {
1175
- const read = await collaboration.readPoint(schemaKey, params.id);
1176
- const data = (read.data ?? undefined) as T | undefined;
1221
+ if (collaboration) {
1222
+ const result = await collaboration.readPoint(schemaKey, params.id);
1223
+ const data = (result.data ?? undefined) as T | undefined;
1224
+ if (!capture) return data;
1177
1225
  capturePointRead(
1178
1226
  readSetContext,
1179
1227
  readSetClientIdentity,
1180
1228
  wireModel,
1181
1229
  params.id,
1182
1230
  data,
1183
- read.stamp,
1231
+ result.stamp,
1184
1232
  );
1185
- return data as CapturedRow<T> | undefined;
1233
+ return data;
1186
1234
  }
1187
- return rows[0] as CapturedRow<T> | undefined;
1188
- },
1235
+ return rows[0];
1236
+ };
1237
+
1238
+ const get = guard(
1239
+ async (params: ModelReadParams): Promise<T | undefined> => pointRead(params, false),
1240
+ );
1241
+
1242
+ const read = guard(
1243
+ async (params: ModelReadParams): Promise<CapturedRow<T> | undefined> =>
1244
+ pointRead(params, true) as Promise<CapturedRow<T> | undefined>,
1189
1245
  );
1190
1246
 
1191
1247
  const list = guard(async (
1192
1248
  options?: ServerReadOptions<T>,
1193
- ): Promise<ModelList<CapturedRow<T>>> => {
1194
- const registry = readSetContext?.getStore();
1249
+ ): Promise<ModelList<T>> => {
1195
1250
  const rows = await load(options);
1196
1251
  // This transport loads a working set rather than pages, so there is no
1197
1252
  // cursor to hand back. `limit` can still cut the set short, and a full
1198
1253
  // count is exactly the case where the caller cannot tell: report it rather
1199
1254
  // than claim completeness this read cannot vouch for.
1200
- const page = modelList<CapturedRow<T>>(rows as CapturedRow<T>[], {
1255
+ return modelList<T>(rows, {
1201
1256
  hasMore: options?.limit !== undefined && rows.length >= options.limit,
1202
1257
  nextCursor: null,
1203
1258
  });
1204
- if (!registry) return page;
1205
- for (const row of rows) {
1206
- const stamp = hydration.getReadEvidence?.(row as object);
1207
- if (stamp === undefined) {
1208
- // Local-first/lazy rows remain valid reads, but cannot later be used as
1209
- // guarded dependencies. `prepareReadSet` rejects them if supplied in
1210
- // `reads`; authoritative complete reads carry evidence here.
1211
- continue;
1212
- }
1213
- const id = (row as { id?: unknown }).id;
1214
- if (typeof id !== 'string' || id.length === 0) {
1215
- throw new AbloValidationError(
1216
- `${registeredModelName}.list returned a row without an id.`,
1217
- { code: 'commit_no_result' },
1218
- );
1219
- }
1220
- capturePointRead(
1221
- readSetContext,
1222
- readSetClientIdentity,
1223
- wireModel,
1224
- id,
1225
- row,
1226
- stamp,
1227
- );
1228
- }
1229
- return page;
1230
1259
  });
1231
1260
 
1232
1261
  /**
@@ -1248,64 +1277,51 @@ export function createModelProxy<T, C>(
1248
1277
  readSetContext,
1249
1278
  readSetClientIdentity,
1250
1279
  undefined,
1251
- undefined,
1252
1280
  params.idempotencyKey,
1253
1281
  params.reads,
1254
1282
  );
1255
- try {
1256
- const organizationId = syncClient.getOrganizationId() ?? undefined;
1257
- const ids: string[] = [];
1258
- const operations = params.data.map((row) => {
1259
- const fields = row as Record<string, unknown>;
1260
- const id =
1261
- resolveCreateId(undefined, fields) ??
1262
- createModelId(
1263
- registeredModelName,
1264
- params.idempotencyKey ? `${params.idempotencyKey}:${ids.length}` : null,
1265
- );
1266
- ids.push(id);
1267
- return {
1268
- action: 'create' as const,
1269
- model: registeredModelName,
1270
- data: { organizationId: fields.organizationId ?? organizationId, ...fields, id },
1271
- id,
1272
- };
1273
- });
1283
+ const organizationId = syncClient.getOrganizationId() ?? undefined;
1284
+ const ids: string[] = [];
1285
+ const operations = params.data.map((row) => {
1286
+ const fields = row as Record<string, unknown>;
1287
+ const id =
1288
+ resolveCreateId(undefined, fields) ??
1289
+ createModelId(
1290
+ registeredModelName,
1291
+ params.idempotencyKey ? `${params.idempotencyKey}:${ids.length}` : null,
1292
+ );
1293
+ ids.push(id);
1294
+ return {
1295
+ action: 'create' as const,
1296
+ model: registeredModelName,
1297
+ data: { organizationId: fields.organizationId ?? organizationId, ...fields, id },
1298
+ id,
1299
+ };
1300
+ });
1274
1301
 
1275
- const receipt = await collaboration.commitBatch({
1276
- operations,
1277
- wait: 'confirmed',
1278
- ...(prepared.idempotencyKey
1279
- ? { idempotencyKey: prepared.idempotencyKey }
1280
- : params.idempotencyKey
1281
- ? { idempotencyKey: params.idempotencyKey }
1282
- : {}),
1283
- ...(prepared.reads
1284
- ? { reads: [...prepared.reads] }
1302
+ const receipt = await collaboration.commitBatch({
1303
+ operations,
1304
+ wait: 'confirmed',
1305
+ ...(prepared.idempotencyKey
1306
+ ? { idempotencyKey: prepared.idempotencyKey }
1307
+ : params.idempotencyKey
1308
+ ? { idempotencyKey: params.idempotencyKey }
1285
1309
  : {}),
1286
- ...(params.track ? { track: [...params.track] } : {}),
1287
- });
1310
+ ...(prepared.reads
1311
+ ? { reads: [...prepared.reads] }
1312
+ : {}),
1313
+ });
1288
1314
 
1289
- const rows = await resolveCreatedRows<T>({
1290
- modelName: registeredModelName,
1291
- ids,
1292
- operationResults: receipt.operationResults,
1293
- readRow: async (id) => {
1294
- const read = await collaboration.readPoint(registeredModelName, id);
1295
- return read.data as T | undefined;
1296
- },
1297
- });
1298
- consumeReadSet(
1299
- readSetContext,
1300
- readSetClientIdentity,
1301
- prepared.consumed,
1302
- prepared.automaticCommit,
1303
- );
1304
- return rows;
1305
- } catch (error) {
1306
- abortReadSetCommit(readSetContext, prepared.automaticCommit);
1307
- throw error;
1308
- }
1315
+ const rows = await resolveCreatedRows<T>({
1316
+ modelName: registeredModelName,
1317
+ ids,
1318
+ operationResults: receipt.operationResults,
1319
+ readRow: async (id) => {
1320
+ const read = await collaboration.readPoint(registeredModelName, id);
1321
+ return read.data as T | undefined;
1322
+ },
1323
+ });
1324
+ return rows;
1309
1325
  };
1310
1326
 
1311
1327
  // `create` takes one row or a list of them. The list form is atomic and
@@ -1366,12 +1382,9 @@ export function createModelProxy<T, C>(
1366
1382
  createdAt: new Date(),
1367
1383
  updatedAt: new Date(),
1368
1384
  });
1369
- let prepared: PreparedReadSet | undefined;
1370
1385
  try {
1371
- const resolved = preparedMutation(single);
1372
- prepared = resolved.prepared;
1373
1386
  const effective: MutationOptions = {
1374
- ...resolved.options,
1387
+ ...preparedMutation(single),
1375
1388
  ...(autoLease
1376
1389
  ? {
1377
1390
  claimRef: { id: autoLease.id },
@@ -1391,16 +1404,7 @@ export function createModelProxy<T, C>(
1391
1404
  };
1392
1405
  syncClient.add(model, effective);
1393
1406
  await waitForMutation(model);
1394
- consumeReadSet(
1395
- readSetContext,
1396
- readSetClientIdentity,
1397
- prepared.consumed,
1398
- prepared.automaticCommit,
1399
- );
1400
1407
  return modelAsRow<T>(model);
1401
- } catch (error) {
1402
- abortReadSetCommit(readSetContext, prepared?.automaticCommit ?? false);
1403
- throw error;
1404
1408
  } finally {
1405
1409
  await autoLease?.release?.().catch(() => {});
1406
1410
  }
@@ -1427,7 +1431,7 @@ export function createModelProxy<T, C>(
1427
1431
  local,
1428
1432
 
1429
1433
  get,
1430
- retrieve: get,
1434
+ read,
1431
1435
 
1432
1436
  // No automatic scope enrolment on bulk `list`: that would subscribe to an
1433
1437
  // unbounded set of rows' entity groups.
@@ -1511,35 +1515,22 @@ export function createModelProxy<T, C>(
1511
1515
  readSetContext,
1512
1516
  readSetClientIdentity,
1513
1517
  undefined,
1514
- 'reject',
1515
1518
  undefined,
1516
1519
  contention?.reads,
1517
1520
  );
1518
- try {
1519
- const effective: MutationOptions = {
1520
- readAt: prepared.readAt ?? readAt,
1521
- onStale: prepared.onStale ?? 'reject',
1522
- ...(prepared.idempotencyKey
1523
- ? { idempotencyKey: prepared.idempotencyKey }
1524
- : {}),
1525
- ...(prepared.reads !== undefined
1526
- ? { reads: prepared.reads === null ? null : [...prepared.reads] }
1527
- : {}),
1528
- };
1529
- model.applyChanges(patch);
1530
- syncClient.update(model, effective);
1531
- await waitForMutation(model);
1532
- consumeReadSet(
1533
- readSetContext,
1534
- readSetClientIdentity,
1535
- prepared.consumed,
1536
- prepared.automaticCommit,
1537
- );
1538
- return modelAsRow<T>(model);
1539
- } catch (error) {
1540
- abortReadSetCommit(readSetContext, prepared.automaticCommit);
1541
- throw error;
1542
- }
1521
+ const effective: MutationOptions = {
1522
+ readAt: prepared.readAt ?? readAt,
1523
+ ...(prepared.idempotencyKey
1524
+ ? { idempotencyKey: prepared.idempotencyKey }
1525
+ : {}),
1526
+ ...(prepared.reads !== undefined
1527
+ ? { reads: prepared.reads === null ? null : [...prepared.reads] }
1528
+ : {}),
1529
+ };
1530
+ model.applyChanges(patch);
1531
+ syncClient.update(model, effective);
1532
+ await waitForMutation(model);
1533
+ return modelAsRow<T>(model);
1543
1534
  },
1544
1535
  });
1545
1536
  }
@@ -1573,57 +1564,32 @@ export function createModelProxy<T, C>(
1573
1564
  `Entity not found: ${registeredModelName}/${id}`,
1574
1565
  { code: 'entity_not_found' },
1575
1566
  );
1576
- // If we hold a claim on this row, guard the write with its snapshot
1577
- // watermark + lease so it's stale-rejected and attributed to the claim.
1578
- const claimed = activeClaims.get(id);
1579
- const resolved = preparedMutation(params);
1580
- const opts = resolved.options;
1567
+ const opts = preparedMutation(params);
1581
1568
  const handle = isClaimHandle(params.claim) ? params.claim : undefined;
1582
- const effective: MutationOptions | undefined = claimed
1583
- ? {
1584
- readAt: claimed.lease.readAt ?? claimed.snapshot.stamp,
1585
- onStale: 'reject',
1586
- claimRef: { id: claimed.lease.id },
1587
- ...(claimed.lease.fenceToken !== undefined
1588
- ? { fenceToken: claimed.lease.fenceToken }
1589
- : {}),
1590
- ...opts,
1591
- }
1592
- : {
1593
- // A carried handle engages the same stale guard as a claim this
1594
- // proxy took itself the watermark rides on the handle, so it
1595
- // works across clients (HTTP-minted handles included).
1596
- ...(handle?.readAt !== undefined
1597
- ? {
1598
- readAt: handle.readAt,
1599
- onStale: 'reject' as const,
1600
- ...(handle.fenceToken !== undefined
1601
- ? { fenceToken: handle.fenceToken }
1602
- : {}),
1603
- }
1604
- : {}),
1605
- ...opts,
1606
- ...(handle ? { claimRef: { id: handle.id } } : {}),
1607
- };
1569
+ // An exact carried handle always wins. Without one, the convenience
1570
+ // path may use the sole local claim on this row; several disjoint local
1571
+ // claims are intentionally ambiguous and require the caller to pass one.
1572
+ const claimed = handle ? activeClaims.get(handle.id) : implicitClaimForEntity(id);
1573
+ const selected = handle ?? claimed?.lease;
1574
+ const selectedReadAt = handle?.readAt ?? claimed?.readAt;
1575
+ const effective: MutationOptions = {
1576
+ ...(selectedReadAt !== undefined ? { readAt: selectedReadAt } : {}),
1577
+ ...(selected?.fenceToken !== undefined
1578
+ ? { fenceToken: selected.fenceToken }
1579
+ : {}),
1580
+ ...opts,
1581
+ ...(selected ? { claimRef: { id: selected.id } } : {}),
1582
+ };
1608
1583
  // Local user update: `applyChanges` keeps change tracking on so the
1609
1584
  // edited fields land in `modifiedProperties` and are actually sent to
1610
1585
  // the server. (`updateFromData` is the hydration path and would discard
1611
1586
  // the tracking, producing an empty `input: {}` no-op mutation.)
1612
- try {
1613
- model.applyChanges(params.data);
1614
- syncClient.update(model, effective);
1615
- await waitForMutation(model);
1616
- consumeReadSet(
1617
- readSetContext,
1618
- readSetClientIdentity,
1619
- resolved.prepared.consumed,
1620
- resolved.prepared.automaticCommit,
1621
- );
1622
- return modelAsRow<T>(model);
1623
- } catch (error) {
1624
- abortReadSetCommit(readSetContext, resolved.prepared.automaticCommit);
1625
- throw error;
1626
- }
1587
+ model.applyChanges(params.data);
1588
+ syncClient.update(model, effective);
1589
+ await waitForMutation(model);
1590
+ const updated = modelAsRow<T>(model);
1591
+ await settleClaimsAfterWrite(id, handle);
1592
+ return updated;
1627
1593
  },
1628
1594
  );
1629
1595
  function update(params: ModelUpdateParams<T, C>): Promise<T>;
@@ -1674,74 +1640,33 @@ export function createModelProxy<T, C>(
1674
1640
  // no-op success rather than an `entity_not_found` error. This matches the
1675
1641
  // HTTP client and makes delete safe to retry or race (two actors deleting
1676
1642
  // the same row).
1677
- if (!model) return;
1678
- const claimed = activeClaims.get(id);
1679
- const resolved = preparedMutation(params);
1680
- const opts = resolved.options;
1681
- const handle = isClaimHandle(params.claim) ? params.claim : undefined;
1682
- const effective: MutationOptions | undefined = claimed
1683
- ? {
1684
- readAt: claimed.lease.readAt ?? claimed.snapshot.stamp,
1685
- onStale: 'reject',
1686
- claimRef: { id: claimed.lease.id },
1687
- ...(claimed.lease.fenceToken !== undefined
1688
- ? { fenceToken: claimed.lease.fenceToken }
1689
- : {}),
1690
- ...opts,
1691
- }
1692
- : {
1693
- ...(handle?.readAt !== undefined
1694
- ? {
1695
- readAt: handle.readAt,
1696
- onStale: 'reject' as const,
1697
- ...(handle.fenceToken !== undefined
1698
- ? { fenceToken: handle.fenceToken }
1699
- : {}),
1700
- }
1701
- : {}),
1702
- ...opts,
1703
- ...(handle ? { claimRef: { id: handle.id } } : {}),
1704
- };
1705
- try {
1706
- syncClient.delete(model, effective);
1707
- await waitForMutation(model);
1708
- consumeReadSet(
1709
- readSetContext,
1710
- readSetClientIdentity,
1711
- resolved.prepared.consumed,
1712
- resolved.prepared.automaticCommit,
1713
- );
1714
- } catch (error) {
1715
- abortReadSetCommit(readSetContext, resolved.prepared.automaticCommit);
1716
- throw error;
1643
+ if (!model) {
1644
+ const handle = isClaimHandle(params.claim) ? params.claim : undefined;
1645
+ await settleClaimsAfterWrite(id, handle);
1646
+ return;
1717
1647
  }
1648
+ const opts = preparedMutation(params);
1649
+ const handle = isClaimHandle(params.claim) ? params.claim : undefined;
1650
+ const claimed = handle ? activeClaims.get(handle.id) : implicitClaimForEntity(id);
1651
+ const selected = handle ?? claimed?.lease;
1652
+ const selectedReadAt = handle?.readAt ?? claimed?.readAt;
1653
+ const effective: MutationOptions = {
1654
+ ...(selectedReadAt !== undefined ? { readAt: selectedReadAt } : {}),
1655
+ ...(selected?.fenceToken !== undefined
1656
+ ? { fenceToken: selected.fenceToken }
1657
+ : {}),
1658
+ ...opts,
1659
+ ...(selected ? { claimRef: { id: selected.id } } : {}),
1660
+ };
1661
+ syncClient.delete(model, effective);
1662
+ await waitForMutation(model);
1663
+ await settleClaimsAfterWrite(id, handle);
1718
1664
  }),
1719
1665
 
1720
1666
  // `claim` is a callable namespace (take a claim) carrying the coordination
1721
1667
  // readers (`claim.state` / `claim.queue` / `claim.release` / `claim.reorder`).
1722
1668
  claim: claimApi,
1723
1669
 
1724
- track: guard(async (params: ModelTrackParams): Promise<ModelTrackResult> => {
1725
- const dep: TrackDependency = {
1726
- model: wireModel,
1727
- id: params.id,
1728
- ...(params.readAt !== undefined ? { readAt: params.readAt } : {}),
1729
- };
1730
- // A track carries no write, so it rides the commit lane as a zero-operation
1731
- // commit: the queue tolerates disconnects and de-dupes replays, and the
1732
- // server's track-only path registers the premise and reports anything
1733
- // that already fired. Reuse the same lane the batch `commits.create` door
1734
- // uses rather than opening a bespoke transport.
1735
- const clientTxId =
1736
- typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
1737
- ? crypto.randomUUID()
1738
- : `tx_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
1739
- const queue = syncClient.getMutationQueue();
1740
- await queue.enqueueCommit(clientTxId, [], { track: [dep] });
1741
- const { notifications } = await queue.waitForCommitReceipt(clientTxId);
1742
- return notifications && notifications.length > 0 ? { notifications } : {};
1743
- }),
1744
-
1745
1670
  join: guard(
1746
1671
  (
1747
1672
  ids: string | readonly string[],