@abloatai/humans 0.56.0 → 0.58.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 +1 -1
  7. package/dist/local/SyncClient.d.ts +3 -34
  8. package/dist/local/SyncClient.js +6 -16
  9. package/dist/local/client/createInternalComponents.d.ts +1 -1
  10. package/dist/local/client/createInternalComponents.js +1 -1
  11. package/dist/local/client/{createModelProxy.d.ts → createModelOperations.d.ts} +22 -9
  12. package/dist/local/client/{createModelProxy.js → createModelOperations.js} +341 -335
  13. package/dist/local/client/options.d.ts +2 -2
  14. package/dist/local/client/reactiveEngine.js +38 -45
  15. package/dist/local/client/resourceTypes.d.ts +3 -3
  16. package/dist/local/client/resourceTypes.js +1 -1
  17. package/dist/local/client/wsMutationExecutor.d.ts +4 -4
  18. package/dist/local/client/wsMutationExecutor.js +4 -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 +5 -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/ConnectionManager.d.ts +1 -1
  27. package/dist/local/sync/ConnectionManager.js +1 -1
  28. package/dist/local/sync/OnDemandLoader.d.ts +1 -1
  29. package/dist/local/sync/OnDemandLoader.js +1 -1
  30. package/dist/local/sync/SyncWebSocket.d.ts +3 -3
  31. package/dist/local/sync/SyncWebSocket.js +2 -2
  32. package/dist/local/sync/commitFrames.d.ts +1 -1
  33. package/dist/local/sync/commitFrames.js +1 -1
  34. package/dist/local/sync/contextOnChange.d.ts +8 -0
  35. package/dist/local/sync/contextOnChange.js +65 -0
  36. package/dist/local/sync/createClaimStream.d.ts +1 -1
  37. package/dist/local/sync/createClaimStream.js +47 -28
  38. package/dist/local/sync/credentialLifecycle.d.ts +1 -1
  39. package/dist/local/sync/credentialLifecycle.js +1 -1
  40. package/dist/local/sync/participants.d.ts +1 -1
  41. package/dist/local/sync/schemas.d.ts +2 -2
  42. package/dist/local/sync/schemas.js +1 -1
  43. package/dist/local/sync/wsFrameHandlers.d.ts +1 -1
  44. package/dist/local/sync/wsFrameHandlers.js +1 -1
  45. package/dist/local/syncClientTypes.d.ts +41 -0
  46. package/dist/local/syncClientTypes.js +11 -0
  47. package/dist/local/transactions/databaseCommitOutbox.d.ts +2 -2
  48. package/dist/local/transactions/mutations/MutationQueue.d.ts +5 -18
  49. package/dist/local/transactions/mutations/MutationQueue.js +57 -137
  50. package/dist/local/transactions/mutations/batchProcessing.d.ts +2 -13
  51. package/dist/local/transactions/mutations/batchProcessing.js +0 -20
  52. package/dist/local/transactions/mutations/commitApi.d.ts +2 -3
  53. package/dist/local/transactions/mutations/commitApi.js +0 -4
  54. package/dist/local/transactions/mutations/commitLane.d.ts +6 -8
  55. package/dist/local/transactions/mutations/commitLane.js +12 -14
  56. package/dist/local/transactions/mutations/commitPayload.d.ts +2 -4
  57. package/dist/local/transactions/mutations/commitPayload.js +2 -6
  58. package/dist/local/transactions/mutations/commitTransport.d.ts +3 -4
  59. package/dist/local/transactions/mutations/commitTransport.js +2 -5
  60. package/dist/local/transactions/mutations/durableCommitRestore.d.ts +1 -1
  61. package/dist/local/transactions/mutations/durableCommitRestore.js +1 -4
  62. package/dist/local/transactions/mutations/durableWriteStore.d.ts +5 -5
  63. package/dist/local/transactions/mutations/durableWriteStore.js +3 -3
  64. package/dist/local/transactions/mutations/failurePolicy.d.ts +2 -2
  65. package/dist/local/transactions/mutations/mutationInput.d.ts +40 -0
  66. package/dist/local/transactions/mutations/mutationInput.js +53 -0
  67. package/dist/local/transactions/mutations/mutationPersistence.d.ts +1 -1
  68. package/dist/local/transactions/mutations/pendingDrain.d.ts +2 -2
  69. package/dist/local/transactions/mutations/replayValidation.d.ts +0 -20
  70. package/dist/local/transactions/mutations/replayValidation.js +1 -3
  71. package/dist/local/transactions/persistedTransaction.d.ts +0 -2
  72. package/dist/plugin.d.ts +2 -2
  73. package/dist/presenceStream.d.ts +1 -1
  74. package/dist/react/createAbloReact.d.ts +1 -1
  75. package/dist/react/useAblo.d.ts +2 -2
  76. package/dist/react/useAblo.js +1 -1
  77. package/dist/surface.d.ts +1 -1
  78. package/dist/surface.js +2 -2
  79. package/package.json +2 -2
  80. package/src/Ablo.ts +9 -27
  81. package/src/client.ts +6 -22
  82. package/src/core.ts +1 -1
  83. package/src/local/Database.ts +1 -2
  84. package/src/local/Model.ts +1 -1
  85. package/src/local/SyncClient.ts +17 -72
  86. package/src/local/client/createInternalComponents.ts +2 -2
  87. package/src/local/client/{createModelProxy.ts → createModelOperations.ts} +396 -320
  88. package/src/local/client/options.ts +2 -2
  89. package/src/local/client/reactiveEngine.ts +67 -55
  90. package/src/local/client/resourceTypes.ts +4 -4
  91. package/src/local/client/wsMutationExecutor.ts +4 -9
  92. package/src/local/coordination/ClaimLog.ts +3 -3
  93. package/src/local/interfaces/index.ts +5 -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/ConnectionManager.ts +1 -1
  98. package/src/local/sync/OnDemandLoader.ts +1 -1
  99. package/src/local/sync/SyncWebSocket.ts +3 -3
  100. package/src/local/sync/commitFrames.ts +1 -2
  101. package/src/local/sync/contextOnChange.ts +92 -0
  102. package/src/local/sync/createClaimStream.ts +77 -39
  103. package/src/local/sync/credentialLifecycle.ts +1 -1
  104. package/src/local/sync/participants.ts +1 -1
  105. package/src/local/sync/schemas.ts +1 -1
  106. package/src/local/sync/wsFrameHandlers.ts +1 -1
  107. package/src/local/syncClientTypes.ts +59 -0
  108. package/src/local/transactions/databaseCommitOutbox.ts +2 -2
  109. package/src/local/transactions/mutations/MutationQueue.ts +69 -183
  110. package/src/local/transactions/mutations/batchProcessing.ts +2 -38
  111. package/src/local/transactions/mutations/commitApi.ts +3 -7
  112. package/src/local/transactions/mutations/commitLane.ts +24 -20
  113. package/src/local/transactions/mutations/commitPayload.ts +3 -11
  114. package/src/local/transactions/mutations/commitTransport.ts +3 -7
  115. package/src/local/transactions/mutations/durableCommitRestore.ts +2 -5
  116. package/src/local/transactions/mutations/durableWriteStore.ts +5 -5
  117. package/src/local/transactions/mutations/failurePolicy.ts +2 -2
  118. package/src/local/transactions/mutations/mutationInput.ts +69 -0
  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 +3 -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,15 +35,32 @@ import {
36
35
  resolveHeartbeatOptions,
37
36
  resolveHeartbeatPlan,
38
37
  startClaimHeartbeatLoop,
39
- } from '@abloatai/transaction/coordination/claimHeartbeatLoop';
40
- import { assertWriteOptions } from '@abloatai/transaction/resources/writeOptionsSchema';
41
- import { modelList, type ModelList } from '@abloatai/transaction/resources/httpResources';
38
+ } from '@abloatai/transaction/claims';
39
+ import {
40
+ assertWriteOptions,
41
+ assertWriteTarget,
42
+ } from '@abloatai/transaction/client/resources/writeOptionsSchema';
43
+ import {
44
+ createModelId,
45
+ resolveCreatedRows,
46
+ resolveCreateId,
47
+ } from '@abloatai/transaction/client/resources/modelCreate';
48
+ import type {
49
+ CommitCreateOptions,
50
+ CommitReceipt,
51
+ } from '@abloatai/transaction/client/resources/httpResources';
52
+ import type { HttpModelMutationParams } from '@abloatai/transaction/transport/http';
53
+ import {
54
+ collectModelList,
55
+ modelList,
56
+ type ModelList,
57
+ } from '@abloatai/transaction/client/resources/httpResources';
42
58
  import { subTarget } from '@abloatai/transaction/coordination';
43
59
  // A named claim-meta crossing (see `claim-meta-crossings-are-enumerated` in
44
- // .dependency-cruiser.cjs): the reactive proxy's self-claim targets are
60
+ // .dependency-cruiser.cjs): the reactive model surface's self-claim targets are
45
61
  // decodes that build a public claim, so their `meta` converts wire→declared
46
62
  // here like the other enumerated crossings.
47
- import { declaredMeta } from '@abloatai/transaction/coordination/claimMeta';
63
+ import { declaredMeta } from '@abloatai/transaction/claims';
48
64
  import type { ModelTarget } from '@abloatai/transaction/coordination/schema';
49
65
  import type { ModelRegistry } from '../ModelRegistry.js';
50
66
  import type { InstanceCache } from '../InstanceCache.js';
@@ -61,23 +77,24 @@ import type {
61
77
  HeldLease,
62
78
  ClaimWaitOptions,
63
79
  ClaimTarget,
64
- Snapshot,
65
80
  } from '@abloatai/transaction/types/streams';
81
+ import {
82
+ bindClaimLifetime,
83
+ claimLifetimeOf,
84
+ } from '@abloatai/transaction/claims/lifetime';
66
85
 
67
86
  // The request contract — every option and parameter shape a caller passes to a
68
87
  // read, a write, or a claim — lives in the confirmation core (ADR 0016). This
69
88
  // factory binds it to reactive model instances; the shapes themselves are
70
- // transport- and consumer-agnostic. Re-exported so `./createModelProxy` stays a
89
+ // transport- and consumer-agnostic. Re-exported so `./createModelOperations` stays a
71
90
  // working import path for the whole surface.
72
91
  export type {
73
92
  ModelListScope,
74
- ModelTrackParams,
75
- ModelTrackResult,
76
93
  LocalReadOptions,
77
94
  LocalCountOptions,
78
95
  ServerReadOptions,
79
- ServerGetOptions,
80
- ServerRetrieveOptions,
96
+ ListAllOptions,
97
+ ServerPointReadOptions,
81
98
  ClaimTargetOptions,
82
99
  ClaimParams,
83
100
  ClaimContentionOptions,
@@ -91,12 +108,12 @@ export type {
91
108
  ClaimReadApi,
92
109
  AwaitedClaimMethod,
93
110
  ClaimApi,
94
- ModelRetrieveParams,
111
+ ModelReadParams,
95
112
  ModelCreateParams,
96
113
  ModelUpdateParams,
97
114
  ModelDeleteParams,
98
115
  JoinOptions,
99
- } from '@abloatai/transaction/resources/modelOperations';
116
+ } from '@abloatai/transaction/client/resources/modelOperations';
100
117
  export type { Claim, ClaimHeartbeat, ClaimHeartbeatOptions, HeldClaim, HeldLease };
101
118
 
102
119
  import type {
@@ -112,29 +129,26 @@ import type {
112
129
  JoinOptions,
113
130
  LocalCountOptions,
114
131
  LocalReadOptions,
132
+ ModelCreateManyParams,
115
133
  ModelCreateParams,
116
134
  ModelDeleteParams,
117
- ModelRetrieveParams,
118
- ModelTrackParams,
119
- ModelTrackResult,
135
+ ModelReadParams,
120
136
  ModelUpdateParams,
121
137
  ServerReadOptions,
122
- } from '@abloatai/transaction/resources/modelOperations';
138
+ ListAllOptions,
139
+ } from '@abloatai/transaction/client/resources/modelOperations';
123
140
  import {
124
141
  claimQueueView,
125
142
  resolveClaimContentionOptions,
126
- } from '@abloatai/transaction/resources/modelOperations';
143
+ } from '@abloatai/transaction/client/resources/modelOperations';
127
144
  import type {
128
145
  CapturedRow,
129
146
  HttpModelClient,
130
- } from '@abloatai/transaction/transport/httpClient';
147
+ } from '@abloatai/transaction/transport/http';
131
148
  import type { ParticipantKind } from '@abloatai/transaction/types/participant';
132
149
  import {
133
- abortReadSetCommit,
134
150
  capturePointRead,
135
- consumeReadSet,
136
151
  prepareReadSet,
137
- type PreparedReadSet,
138
152
  type ReadSetContext,
139
153
  } from '@abloatai/transaction/internal/read-set';
140
154
 
@@ -165,6 +179,17 @@ type EntityHalf = Pick<ModelTarget, 'model' | 'id'>;
165
179
  export interface ModelCollaboration {
166
180
  /** Exact point evidence from the HTTP read boundary (stamp captured before data). */
167
181
  readPoint(model: string, id: string): Promise<{ data: unknown; stamp: number }>;
182
+ /**
183
+ * The batch commit lane, for a create handed a list of rows.
184
+ *
185
+ * A reactive client's single writes go through the mutation queue and land
186
+ * optimistically, because a rejected write rolls one row back. A batch
187
+ * cannot: it is atomic, so applying the rows one at a time would paint a
188
+ * half-written state the server may then decline whole. It goes down the
189
+ * same commit door the stateless client uses and the rows arrive on the
190
+ * ordinary stream, the way a teammate's would.
191
+ */
192
+ commitBatch(options: CommitCreateOptions): Promise<CommitReceipt>;
168
193
  createClaim(options: {
169
194
  /**
170
195
  * The locator, in the spelling the SDK surface and the HTTP routes use.
@@ -191,7 +216,8 @@ export interface ModelCollaboration {
191
216
  /** Request-scoped queued / granted / skipped / failed status events. */
192
217
  onStatus?: (event: ClaimAttemptEvent) => void;
193
218
  }): Promise<Claim>;
194
- createSnapshot(modelKey: string, id: string): Snapshot;
219
+ /** Current applied/acked watermark, captured after a claim's fresh read. */
220
+ currentReadAt(): number;
195
221
  /**
196
222
  * Current coordination state on a target — who (if anyone) holds it.
197
223
  * Synchronous reactive snapshot read off the presence/claim stream;
@@ -262,7 +288,7 @@ export interface ModelCollaboration {
262
288
  * Opens a presence and claim subscription on this model's sync group(s) and
263
289
  * returns the live participant handle. Backs `ablo.<model>.join(ids)`.
264
290
  * WebSocket only, since presence needs a live socket; it is absent on other
265
- * client constructions, where the proxy throws a clear error.
291
+ * client constructions, where the surface throws a clear error.
266
292
  */
267
293
  createJoin?(
268
294
  modelKey: string,
@@ -277,7 +303,7 @@ export interface ModelCollaboration {
277
303
  *
278
304
  * Every verb mirrors its asynchronous sibling on the base surface, and the one
279
305
  * word in front is the whole difference. It is a narrowing, not a claim about
280
- * 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,
281
307
  * while `local.get` is restricted to what is already resident — which is
282
308
  * also why it can return a value instead of a promise. There is nothing to
283
309
  * await.
@@ -394,7 +420,7 @@ export type ModelOperations<T, CreateInput> = Omit<
394
420
  > &
395
421
  ReactiveModelSurface<T, CreateInput>;
396
422
 
397
- export function createModelProxy<T, C>(
423
+ export function createModelOperations<T, C>(
398
424
  schemaKey: string,
399
425
  registeredModelName: string,
400
426
  objectPool: Pick<InstanceCache, 'get' | 'getByType' | 'getOfType'>,
@@ -402,7 +428,6 @@ export function createModelProxy<T, C>(
402
428
  SyncClient,
403
429
  | 'add'
404
430
  | 'delete'
405
- | 'getMutationQueue'
406
431
  | 'getOrganizationId'
407
432
  | 'syncNow'
408
433
  | 'update'
@@ -536,27 +561,66 @@ export function createModelProxy<T, C>(
536
561
  await syncClient.waitForConfirmation(model.getModelName(), model.id);
537
562
  };
538
563
 
539
- // Claims this proxy currently holds, keyed by entity id. Lets the flat
540
- // `release({ id })` and `update({ id, data })` find the lease and snapshot a
541
- // `claim({ id })` took, without a per-call handle. Released on dispose,
542
- // 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.
543
569
  //
544
570
  // `target`, `description`, and `expiresAt` are kept alongside the lease so
545
571
  // `claim.state` can synthesize a self-claim: the server excludes a holder's
546
- // 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
547
573
  // holds the row. `expiresAt` is the client's best estimate from the requested
548
574
  // TTL (a real epoch-millisecond expiry, not a fabricated watermark), defaulting
549
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
+
550
586
  const activeClaims = new Map<
551
587
  string,
552
- {
553
- lease: Claim;
554
- snapshot: Snapshot;
555
- target: ClaimTarget;
556
- description: string;
557
- expiresAt: number;
558
- }
588
+ ActiveClaim
559
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
+ };
560
624
 
561
625
  // Server keepalive lease window — the same `LEASE_TTL_MS` the wire protocol
562
626
  // declares, so the client's estimate and the server's lease cannot drift.
@@ -576,12 +640,11 @@ export function createModelProxy<T, C>(
576
640
  | ModelCreateParams<T, C>
577
641
  | ModelUpdateParams<T, C>
578
642
  | ModelDeleteParams<T, C>,
579
- ): { options: MutationOptions; prepared: PreparedReadSet } => {
643
+ ): MutationOptions => {
580
644
  const prepared = prepareReadSet(
581
645
  readSetContext,
582
646
  readSetClientIdentity,
583
- params.readAt,
584
- params.onStale,
647
+ undefined,
585
648
  params.idempotencyKey,
586
649
  params.reads,
587
650
  );
@@ -592,44 +655,47 @@ export function createModelProxy<T, C>(
592
655
  ? { idempotencyKey: params.idempotencyKey }
593
656
  : {}),
594
657
  ...(params.label !== undefined ? { label: params.label } : {}),
595
- ...(prepared.readAt !== undefined
596
- ? { readAt: prepared.readAt }
597
- : params.readAt !== undefined
598
- ? { readAt: params.readAt }
599
- : {}),
600
- ...(prepared.onStale !== undefined
601
- ? { onStale: prepared.onStale }
602
- : params.onStale !== undefined
603
- ? { onStale: params.onStale }
604
- : {}),
605
- ...(params.fenceToken !== undefined ? { fenceToken: params.fenceToken } : {}),
606
- ...(params.claimRef !== undefined ? { claimRef: params.claimRef } : {}),
607
658
  ...(prepared.reads !== undefined
608
659
  ? { reads: prepared.reads === null ? null : [...prepared.reads] }
609
660
  : params.reads !== undefined
610
661
  ? { reads: params.reads }
611
662
  : {}),
612
- ...(params.track !== undefined ? { track: params.track } : {}),
613
663
  };
614
664
  // The write-options schema — the runtime twin of the compile-time params.
615
- // Catches plain-JavaScript callers (for example `onStale: 'rejct'`) at the
616
- // call site with a typed error instead of a silent no-op or a server 400.
617
- try {
618
- assertWriteOptions(rest, `${schemaKey} write`);
619
- } catch (error) {
620
- abortReadSetCommit(readSetContext, prepared.automaticCommit);
621
- throw error;
622
- }
623
- 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;
624
669
  };
625
670
 
626
- const releaseClaim = async (id: string): Promise<void> => {
627
- const held = activeClaims.get(id);
671
+ const releaseClaim = async (claimId: string): Promise<void> => {
672
+ const held = removeActiveClaim(claimId);
628
673
  if (!held) return;
629
- activeClaims.delete(id);
630
674
  await held.lease.release?.();
631
675
  };
632
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
+
633
699
  const takeClaim = async (
634
700
  params: ClaimParams<C>,
635
701
  ): Promise<HeldClaim<T> | null> => {
@@ -640,11 +706,6 @@ export function createModelProxy<T, C>(
640
706
  );
641
707
  }
642
708
  const { id, ...options } = params;
643
- // Read the local snapshot only to decide whether a post-grant re-read may
644
- // be needed. Admission itself always goes to the server: a local presence
645
- // snapshot may be stale or incomplete across instances.
646
- const held = collaboration.state({ model: wireModel, id });
647
- const contended = !!held && held.heldBy !== collaboration.selfParticipantId;
648
709
  const contention = resolveClaimContentionOptions(options);
649
710
  const failFast = !contention.wait;
650
711
 
@@ -708,25 +769,26 @@ export function createModelProxy<T, C>(
708
769
  throw normalized;
709
770
  }
710
771
 
711
- // Only when the claim actually waited behind another holder can the row have
712
- // changed underneath us re-read so the claimed snapshot reflects what that
713
- // holder committed before releasing. Either of two signals suffices:
714
- // - `lease.waited` the server granted the claim after the client
715
- // provably queued behind a holder. Authoritative; it works even when the
716
- // local snapshot is blind, since claim fan-out is entity-scoped and a
717
- // broadly-subscribed client never observes peers' claims.
718
- // - `contended` the local snapshot saw a holder up front. Kept for the
719
- // no-queue paths, where no grant frame exists.
720
- if ((contended || lease.waited === true) && !failFast) {
721
- // `type: 'complete'` forces the round-trip: the hydration ledger would
722
- // otherwise serve the local row for an already-hydrated id, and the
723
- // holder's final write may not have fanned out yet — the exact
724
- // stale-snapshot race this re-read closes.
725
- await load({ where: [['id', id]], type: 'complete' });
726
- model = ownRowOrThrow(id) ?? model;
727
- }
728
-
729
- 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();
730
792
  const description = claimDescription(options);
731
793
  // The self-claim's `ClaimTarget` mirrors what a peer's `claim.state` would
732
794
  // report (`state` maps `held.target.model` to `type`), so a holder and a
@@ -744,13 +806,15 @@ export function createModelProxy<T, C>(
744
806
  const ttlMs =
745
807
  options.ttl !== undefined ? toMs(options.ttl) : DEFAULT_LEASE_TTL_MS;
746
808
  const expiresAt = Date.now() + ttlMs;
747
- activeClaims.set(id, {
809
+ const active: ActiveClaim = {
810
+ entityId: id,
748
811
  lease,
749
- snapshot,
812
+ readAt,
750
813
  target: selfTarget,
751
814
  description,
752
815
  expiresAt,
753
- });
816
+ };
817
+ addActiveClaim(lease.id, active);
754
818
  const { meta: targetMeta, ...targetNarrowed } = subTarget(options, schemaKey);
755
819
  const target = {
756
820
  type: schemaKey,
@@ -779,7 +843,7 @@ export function createModelProxy<T, C>(
779
843
  ttl: resolved.ttl ?? options.ttl,
780
844
  ...(resolved.details !== undefined ? { details: resolved.details } : {}),
781
845
  });
782
- const held = activeClaims.get(id);
846
+ const held = activeClaims.get(lease.id);
783
847
  if (held) held.expiresAt = beat.expiresAt;
784
848
  plan.onBeat?.(beat);
785
849
  return beat;
@@ -795,14 +859,19 @@ export function createModelProxy<T, C>(
795
859
  })
796
860
  : undefined;
797
861
 
862
+ active.stopHeartbeat = stopHeartbeatLoop;
863
+ const lifetime = claimLifetimeOf(lease);
864
+ lifetime?.onEnd(() => {
865
+ removeActiveClaim(lease.id);
866
+ });
867
+
798
868
  const release = () => {
799
- stopHeartbeatLoop?.();
800
- return releaseClaim(id);
869
+ return releaseClaim(lease.id);
801
870
  };
802
- return {
871
+ const handle = {
803
872
  object: 'claim',
804
873
  id: lease.id,
805
- readAt: lease.readAt ?? snapshot.stamp,
874
+ readAt,
806
875
  // The fencing token the server minted for this grant, forwarded from the
807
876
  // lease so writes taken under this handle carry it (Option B).
808
877
  ...(lease.fenceToken !== undefined ? { fenceToken: lease.fenceToken } : {}),
@@ -815,7 +884,8 @@ export function createModelProxy<T, C>(
815
884
  },
816
885
  heartbeat,
817
886
  [Symbol.asyncDispose]: release,
818
- };
887
+ } satisfies HeldClaim<T>;
888
+ return lifetime ? bindClaimLifetime(handle, lifetime) : handle;
819
889
  };
820
890
 
821
891
  // The row-free sibling of `takeClaim`: locks a key by id alone, for a row that
@@ -878,11 +948,10 @@ export function createModelProxy<T, C>(
878
948
  throw normalized;
879
949
  }
880
950
 
881
- // A watermark-only snapshot: `createSnapshot` still reads the engine's
882
- // current `lastSyncId` even though the pool holds no row (the bucket is
883
- // empty). It costs nothing extra and gives a write taken under this lease a
884
- // real `readAt` to guard against changes since the lease was acquired.
885
- 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();
886
955
  const description = claimDescription(options);
887
956
  const selfTarget: ClaimTarget = {
888
957
  type: wireModel,
@@ -892,13 +961,15 @@ export function createModelProxy<T, C>(
892
961
  const ttlMs =
893
962
  options.ttl !== undefined ? toMs(options.ttl) : DEFAULT_LEASE_TTL_MS;
894
963
  const expiresAt = Date.now() + ttlMs;
895
- activeClaims.set(id, {
964
+ const active: ActiveClaim = {
965
+ entityId: id,
896
966
  lease,
897
- snapshot,
967
+ readAt,
898
968
  target: selfTarget,
899
969
  description,
900
970
  expiresAt,
901
- });
971
+ };
972
+ addActiveClaim(lease.id, active);
902
973
  const target = {
903
974
  type: schemaKey,
904
975
  id,
@@ -923,7 +994,7 @@ export function createModelProxy<T, C>(
923
994
  ttl: resolved.ttl ?? options.ttl,
924
995
  ...(resolved.details !== undefined ? { details: resolved.details } : {}),
925
996
  });
926
- const held = activeClaims.get(id);
997
+ const held = activeClaims.get(lease.id);
927
998
  if (held) held.expiresAt = beat.expiresAt;
928
999
  plan.onBeat?.(beat);
929
1000
  return beat;
@@ -937,14 +1008,19 @@ export function createModelProxy<T, C>(
937
1008
  })
938
1009
  : undefined;
939
1010
 
1011
+ active.stopHeartbeat = stopHeartbeatLoop;
1012
+ const lifetime = claimLifetimeOf(lease);
1013
+ lifetime?.onEnd(() => {
1014
+ removeActiveClaim(lease.id);
1015
+ });
1016
+
940
1017
  const release = () => {
941
- stopHeartbeatLoop?.();
942
- return releaseClaim(id);
1018
+ return releaseClaim(lease.id);
943
1019
  };
944
- return {
1020
+ const handle = {
945
1021
  object: 'claim',
946
1022
  id: lease.id,
947
- readAt: snapshot.stamp,
1023
+ readAt,
948
1024
  // Forward the grant's fencing token so writes under this row-free lease
949
1025
  // carry it (Option B), exactly as the row-bearing claim does.
950
1026
  ...(lease.fenceToken !== undefined ? { fenceToken: lease.fenceToken } : {}),
@@ -956,7 +1032,8 @@ export function createModelProxy<T, C>(
956
1032
  },
957
1033
  heartbeat,
958
1034
  [Symbol.asyncDispose]: release,
959
- };
1035
+ } satisfies HeldLease;
1036
+ return lifetime ? bindClaimLifetime(handle, lifetime) : handle;
960
1037
  };
961
1038
 
962
1039
  // `claim` overloads on its first argument: an options object claims a synced
@@ -1005,10 +1082,8 @@ export function createModelProxy<T, C>(
1005
1082
  * they do it through here so the two answers cannot describe the same
1006
1083
  * holding differently.
1007
1084
  */
1008
- const ownClaimState = (id: string): Claim | null => {
1009
- const own = activeClaims.get(id);
1010
- if (!own) return null;
1011
- return {
1085
+ const ownClaimStates = (id: string): Claim[] =>
1086
+ claimsForEntity(id).map((own) => ({
1012
1087
  object: 'claim',
1013
1088
  id: own.lease.id,
1014
1089
  status: 'active',
@@ -1020,8 +1095,7 @@ export function createModelProxy<T, C>(
1020
1095
  // Symmetric with the peer projection: a holder reading its own claim
1021
1096
  // sees the same `meta` an observer does.
1022
1097
  ...(own.target.meta !== undefined ? { meta: own.target.meta } : {}),
1023
- };
1024
- };
1098
+ }));
1025
1099
 
1026
1100
  const claimReaders = {
1027
1101
  state(params: ClaimLookupParams<T>): Claim | null {
@@ -1035,7 +1109,7 @@ export function createModelProxy<T, C>(
1035
1109
  // holds. Synthesize the active claim from the stored lease so the holder
1036
1110
  // sees its own claim, honoring the documented contract on `claim.state`.
1037
1111
  return (
1038
- ownClaimState(params.id) ??
1112
+ ownClaimStates(params.id)[0] ??
1039
1113
  collaboration?.state({ model: wireModel, id: params.id }) ??
1040
1114
  null
1041
1115
  );
@@ -1050,13 +1124,13 @@ export function createModelProxy<T, C>(
1050
1124
  */
1051
1125
  list(params: ClaimLookupParams<T>): { readonly object: 'list'; readonly data: readonly Claim[] } {
1052
1126
  void collaboration?.enterScope?.({ [schemaKey]: params.id });
1053
- const own = ownClaimState(params.id);
1127
+ const own = ownClaimStates(params.id);
1054
1128
  const peers = collaboration?.holders({ model: wireModel, id: params.id }) ?? [];
1055
1129
  return {
1056
1130
  object: 'list',
1057
1131
  // Own claim first: the server excludes a holder's own presence frames,
1058
1132
  // so it is never among `peers` and the two never duplicate.
1059
- data: own ? [own, ...peers] : [...peers],
1133
+ data: [...own, ...peers],
1060
1134
  };
1061
1135
  },
1062
1136
 
@@ -1071,7 +1145,9 @@ export function createModelProxy<T, C>(
1071
1145
  },
1072
1146
 
1073
1147
  release: guard((params: ClaimLookupParams<T> | Claim<T>): Promise<void> =>
1074
- releaseClaim(isClaimHandle(params) ? params.target.id : params.id),
1148
+ isClaimHandle(params)
1149
+ ? releaseClaim(params.id)
1150
+ : releaseClaimsForEntity(params.id),
1075
1151
  ),
1076
1152
  };
1077
1153
 
@@ -1130,8 +1206,10 @@ export function createModelProxy<T, C>(
1130
1206
  },
1131
1207
  };
1132
1208
 
1133
- const get = guard(
1134
- async (params: ModelRetrieveParams): Promise<CapturedRow<T> | undefined> => {
1209
+ const pointRead = async (
1210
+ params: ModelReadParams,
1211
+ capture: boolean,
1212
+ ): Promise<T | undefined> => {
1135
1213
  // Read-interest enrolment: authoritative point reads enter the same
1136
1214
  // entity scope as the claim stream, while remaining settled reads.
1137
1215
  void collaboration?.enterScope?.({ [schemaKey]: params.id });
@@ -1140,77 +1218,125 @@ export function createModelProxy<T, C>(
1140
1218
  where: [['id', params.id]],
1141
1219
  limit: 1,
1142
1220
  });
1143
- if (readSetContext?.getStore() && collaboration) {
1144
- const read = await collaboration.readPoint(schemaKey, params.id);
1145
- 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;
1146
1225
  capturePointRead(
1147
1226
  readSetContext,
1148
1227
  readSetClientIdentity,
1149
1228
  wireModel,
1150
1229
  params.id,
1151
1230
  data,
1152
- read.stamp,
1231
+ result.stamp,
1153
1232
  );
1154
- return data as CapturedRow<T> | undefined;
1233
+ return data;
1155
1234
  }
1156
- return rows[0] as CapturedRow<T> | undefined;
1157
- },
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>,
1158
1245
  );
1159
1246
 
1160
1247
  const list = guard(async (
1161
1248
  options?: ServerReadOptions<T>,
1162
- ): Promise<ModelList<CapturedRow<T>>> => {
1163
- const registry = readSetContext?.getStore();
1249
+ ): Promise<ModelList<T>> => {
1164
1250
  const rows = await load(options);
1165
1251
  // This transport loads a working set rather than pages, so there is no
1166
1252
  // cursor to hand back. `limit` can still cut the set short, and a full
1167
1253
  // count is exactly the case where the caller cannot tell: report it rather
1168
1254
  // than claim completeness this read cannot vouch for.
1169
- const page = modelList<CapturedRow<T>>(rows as CapturedRow<T>[], {
1255
+ return modelList<T>(rows, {
1170
1256
  hasMore: options?.limit !== undefined && rows.length >= options.limit,
1171
1257
  nextCursor: null,
1172
1258
  });
1173
- if (!registry) return page;
1174
- for (const row of rows) {
1175
- const stamp = hydration.getReadEvidence?.(row as object);
1176
- if (stamp === undefined) {
1177
- // Local-first/lazy rows remain valid reads, but cannot later be used as
1178
- // guarded dependencies. `prepareReadSet` rejects them if supplied in
1179
- // `reads`; authoritative complete reads carry evidence here.
1180
- continue;
1181
- }
1182
- const id = (row as { id?: unknown }).id;
1183
- if (typeof id !== 'string' || id.length === 0) {
1184
- throw new AbloValidationError(
1185
- `${registeredModelName}.list returned a row without an id.`,
1186
- { code: 'commit_no_result' },
1187
- );
1188
- }
1189
- capturePointRead(
1190
- readSetContext,
1191
- readSetClientIdentity,
1192
- wireModel,
1193
- id,
1194
- row,
1195
- stamp,
1259
+ });
1260
+
1261
+ /**
1262
+ * Creates many rows as one atomic commit, and returns them in caller order.
1263
+ */
1264
+ const createManyRows = async (
1265
+ params: HttpModelMutationParams<ModelCreateManyParams<C>>,
1266
+ ): Promise<T[]> => {
1267
+ if (params.data.length === 0) return [];
1268
+ if (!collaboration) {
1269
+ throw new AbloValidationError(
1270
+ `Model "${schemaKey}" was built without the collaboration runtime, so a batch ` +
1271
+ `create is unavailable here. Use the standard Ablo({ schema, apiKey }) client.`,
1272
+ { code: 'model_claim_not_configured' },
1196
1273
  );
1197
1274
  }
1198
- return page;
1199
- });
1200
1275
 
1201
- const operations: ModelOperations<T, C> = {
1202
- local,
1276
+ const prepared = prepareReadSet(
1277
+ readSetContext,
1278
+ readSetClientIdentity,
1279
+ undefined,
1280
+ params.idempotencyKey,
1281
+ params.reads,
1282
+ );
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
+ });
1203
1301
 
1204
- get,
1205
- retrieve: get,
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 }
1309
+ : {}),
1310
+ ...(prepared.reads
1311
+ ? { reads: [...prepared.reads] }
1312
+ : {}),
1313
+ });
1206
1314
 
1207
- // No automatic scope enrolment on bulk `list`: that would subscribe to an
1208
- // unbounded set of rows' entity groups.
1209
- list,
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;
1325
+ };
1210
1326
 
1211
- create: guardWrite(async (params: ModelCreateParams<T, C>): Promise<T> => {
1212
- const id = params.id ?? Model.generateId();
1213
- const claim = params.claim;
1327
+ // `create` takes one row or a list of them. The list form is atomic and
1328
+ // is therefore NOT applied optimistically: see `createManyRows`.
1329
+ const createImpl = guardWrite(async (
1330
+ params:
1331
+ | HttpModelMutationParams<ModelCreateParams<T, C>>
1332
+ | HttpModelMutationParams<ModelCreateManyParams<C>>,
1333
+ ): Promise<T | T[]> => {
1334
+ if (Array.isArray(params.data)) {
1335
+ return createManyRows(params as HttpModelMutationParams<ModelCreateManyParams<C>>);
1336
+ }
1337
+ const single = params as ModelCreateParams<T, C>;
1338
+ const id = resolveCreateId(single.id, single.data) ?? Model.generateId();
1339
+ const claim = single.claim;
1214
1340
  let autoLease: Claim | undefined;
1215
1341
  if (claim && !isClaimHandle(claim)) {
1216
1342
  if (!collaboration) {
@@ -1256,12 +1382,9 @@ export function createModelProxy<T, C>(
1256
1382
  createdAt: new Date(),
1257
1383
  updatedAt: new Date(),
1258
1384
  });
1259
- let prepared: PreparedReadSet | undefined;
1260
1385
  try {
1261
- const resolved = preparedMutation(params);
1262
- prepared = resolved.prepared;
1263
1386
  const effective: MutationOptions = {
1264
- ...resolved.options,
1387
+ ...preparedMutation(single),
1265
1388
  ...(autoLease
1266
1389
  ? {
1267
1390
  claimRef: { id: autoLease.id },
@@ -1281,21 +1404,46 @@ export function createModelProxy<T, C>(
1281
1404
  };
1282
1405
  syncClient.add(model, effective);
1283
1406
  await waitForMutation(model);
1284
- consumeReadSet(
1285
- readSetContext,
1286
- readSetClientIdentity,
1287
- prepared.consumed,
1288
- prepared.automaticCommit,
1289
- );
1290
1407
  return modelAsRow<T>(model);
1291
- } catch (error) {
1292
- abortReadSetCommit(readSetContext, prepared?.automaticCommit ?? false);
1293
- throw error;
1294
1408
  } finally {
1295
1409
  await autoLease?.release?.().catch(() => {});
1296
1410
  }
1411
+ });
1412
+
1413
+ // Two public signatures over one implementation. A property arrow would
1414
+ // collapse them to their union, and a single create would start
1415
+ // resolving to `T | T[]` for every caller.
1416
+ function createRows(
1417
+ params: HttpModelMutationParams<ModelCreateParams<T, C>>,
1418
+ ): Promise<T>;
1419
+ function createRows(
1420
+ params: HttpModelMutationParams<ModelCreateManyParams<C>>,
1421
+ ): Promise<T[]>;
1422
+ function createRows(
1423
+ params:
1424
+ | HttpModelMutationParams<ModelCreateParams<T, C>>
1425
+ | HttpModelMutationParams<ModelCreateManyParams<C>>,
1426
+ ): Promise<T | T[]> {
1427
+ return createImpl(params);
1428
+ }
1429
+
1430
+ const operations: ModelOperations<T, C> = {
1431
+ local,
1432
+
1433
+ get,
1434
+ read,
1435
+
1436
+ // No automatic scope enrolment on bulk `list`: that would subscribe to an
1437
+ // unbounded set of rows' entity groups.
1438
+ list,
1439
+ listAll: guard(async (options: ListAllOptions<T> = {}) => {
1440
+ const { maxPages, signal, ...readOptions } = options;
1441
+ signal?.throwIfAborted();
1442
+ return collectModelList(await list(readOptions), { maxPages, signal });
1297
1443
  }),
1298
1444
 
1445
+ create: createRows,
1446
+
1299
1447
  // `update` is overloaded — classic `update({ id, data })` + functional
1300
1448
  // `update(id, current => next)`. The IIFE keeps the shared error-guard
1301
1449
  // wrapping while exposing the two public signatures (a plain `guard(...)`
@@ -1367,39 +1515,30 @@ export function createModelProxy<T, C>(
1367
1515
  readSetContext,
1368
1516
  readSetClientIdentity,
1369
1517
  undefined,
1370
- 'reject',
1371
1518
  undefined,
1372
1519
  contention?.reads,
1373
1520
  );
1374
- try {
1375
- const effective: MutationOptions = {
1376
- readAt: prepared.readAt ?? readAt,
1377
- onStale: prepared.onStale ?? 'reject',
1378
- ...(prepared.idempotencyKey
1379
- ? { idempotencyKey: prepared.idempotencyKey }
1380
- : {}),
1381
- ...(prepared.reads !== undefined
1382
- ? { reads: prepared.reads === null ? null : [...prepared.reads] }
1383
- : {}),
1384
- };
1385
- model.applyChanges(patch);
1386
- syncClient.update(model, effective);
1387
- await waitForMutation(model);
1388
- consumeReadSet(
1389
- readSetContext,
1390
- readSetClientIdentity,
1391
- prepared.consumed,
1392
- prepared.automaticCommit,
1393
- );
1394
- return modelAsRow<T>(model);
1395
- } catch (error) {
1396
- abortReadSetCommit(readSetContext, prepared.automaticCommit);
1397
- throw error;
1398
- }
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);
1399
1534
  },
1400
1535
  });
1401
1536
  }
1402
1537
  const params = arg;
1538
+ // Named before anything reads it. Without this the row lookup below
1539
+ // reports `Entity not found: Model/undefined`, which sends the reader
1540
+ // looking for a missing row rather than at the unaddressed write.
1541
+ assertWriteTarget('update', registeredModelName, params.id);
1403
1542
  const autoClaim =
1404
1543
  params.claim && !isClaimHandle(params.claim) ? params.claim : null;
1405
1544
  if (autoClaim) {
@@ -1425,57 +1564,32 @@ export function createModelProxy<T, C>(
1425
1564
  `Entity not found: ${registeredModelName}/${id}`,
1426
1565
  { code: 'entity_not_found' },
1427
1566
  );
1428
- // If we hold a claim on this row, guard the write with its snapshot
1429
- // watermark + lease so it's stale-rejected and attributed to the claim.
1430
- const claimed = activeClaims.get(id);
1431
- const resolved = preparedMutation(params);
1432
- const opts = resolved.options;
1567
+ const opts = preparedMutation(params);
1433
1568
  const handle = isClaimHandle(params.claim) ? params.claim : undefined;
1434
- const effective: MutationOptions | undefined = claimed
1435
- ? {
1436
- readAt: claimed.lease.readAt ?? claimed.snapshot.stamp,
1437
- onStale: 'reject',
1438
- claimRef: { id: claimed.lease.id },
1439
- ...(claimed.lease.fenceToken !== undefined
1440
- ? { fenceToken: claimed.lease.fenceToken }
1441
- : {}),
1442
- ...opts,
1443
- }
1444
- : {
1445
- // A carried handle engages the same stale guard as a claim this
1446
- // proxy took itself the watermark rides on the handle, so it
1447
- // works across clients (HTTP-minted handles included).
1448
- ...(handle?.readAt !== undefined
1449
- ? {
1450
- readAt: handle.readAt,
1451
- onStale: 'reject' as const,
1452
- ...(handle.fenceToken !== undefined
1453
- ? { fenceToken: handle.fenceToken }
1454
- : {}),
1455
- }
1456
- : {}),
1457
- ...opts,
1458
- ...(handle ? { claimRef: { id: handle.id } } : {}),
1459
- };
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
+ };
1460
1583
  // Local user update: `applyChanges` keeps change tracking on so the
1461
1584
  // edited fields land in `modifiedProperties` and are actually sent to
1462
1585
  // the server. (`updateFromData` is the hydration path and would discard
1463
1586
  // the tracking, producing an empty `input: {}` no-op mutation.)
1464
- try {
1465
- model.applyChanges(params.data);
1466
- syncClient.update(model, effective);
1467
- await waitForMutation(model);
1468
- consumeReadSet(
1469
- readSetContext,
1470
- readSetClientIdentity,
1471
- resolved.prepared.consumed,
1472
- resolved.prepared.automaticCommit,
1473
- );
1474
- return modelAsRow<T>(model);
1475
- } catch (error) {
1476
- abortReadSetCommit(readSetContext, resolved.prepared.automaticCommit);
1477
- throw error;
1478
- }
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;
1479
1593
  },
1480
1594
  );
1481
1595
  function update(params: ModelUpdateParams<T, C>): Promise<T>;
@@ -1495,6 +1609,9 @@ export function createModelProxy<T, C>(
1495
1609
  })(),
1496
1610
 
1497
1611
  delete: guardWrite(async (params: ModelDeleteParams<T, C>): Promise<void> => {
1612
+ // Before the idempotent "ensure absent" below can read this as a row that
1613
+ // is simply not here. An unaddressed delete is a mistake, not an absence.
1614
+ assertWriteTarget('delete', registeredModelName, params.id);
1498
1615
  const autoClaim =
1499
1616
  params.claim && !isClaimHandle(params.claim) ? params.claim : null;
1500
1617
  if (autoClaim) {
@@ -1523,74 +1640,33 @@ export function createModelProxy<T, C>(
1523
1640
  // no-op success rather than an `entity_not_found` error. This matches the
1524
1641
  // HTTP client and makes delete safe to retry or race (two actors deleting
1525
1642
  // the same row).
1526
- if (!model) return;
1527
- const claimed = activeClaims.get(id);
1528
- const resolved = preparedMutation(params);
1529
- const opts = resolved.options;
1530
- const handle = isClaimHandle(params.claim) ? params.claim : undefined;
1531
- const effective: MutationOptions | undefined = claimed
1532
- ? {
1533
- readAt: claimed.lease.readAt ?? claimed.snapshot.stamp,
1534
- onStale: 'reject',
1535
- claimRef: { id: claimed.lease.id },
1536
- ...(claimed.lease.fenceToken !== undefined
1537
- ? { fenceToken: claimed.lease.fenceToken }
1538
- : {}),
1539
- ...opts,
1540
- }
1541
- : {
1542
- ...(handle?.readAt !== undefined
1543
- ? {
1544
- readAt: handle.readAt,
1545
- onStale: 'reject' as const,
1546
- ...(handle.fenceToken !== undefined
1547
- ? { fenceToken: handle.fenceToken }
1548
- : {}),
1549
- }
1550
- : {}),
1551
- ...opts,
1552
- ...(handle ? { claimRef: { id: handle.id } } : {}),
1553
- };
1554
- try {
1555
- syncClient.delete(model, effective);
1556
- await waitForMutation(model);
1557
- consumeReadSet(
1558
- readSetContext,
1559
- readSetClientIdentity,
1560
- resolved.prepared.consumed,
1561
- resolved.prepared.automaticCommit,
1562
- );
1563
- } catch (error) {
1564
- abortReadSetCommit(readSetContext, resolved.prepared.automaticCommit);
1565
- throw error;
1643
+ if (!model) {
1644
+ const handle = isClaimHandle(params.claim) ? params.claim : undefined;
1645
+ await settleClaimsAfterWrite(id, handle);
1646
+ return;
1566
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);
1567
1664
  }),
1568
1665
 
1569
1666
  // `claim` is a callable namespace (take a claim) carrying the coordination
1570
1667
  // readers (`claim.state` / `claim.queue` / `claim.release` / `claim.reorder`).
1571
1668
  claim: claimApi,
1572
1669
 
1573
- track: guard(async (params: ModelTrackParams): Promise<ModelTrackResult> => {
1574
- const dep: TrackDependency = {
1575
- model: wireModel,
1576
- id: params.id,
1577
- ...(params.readAt !== undefined ? { readAt: params.readAt } : {}),
1578
- };
1579
- // A track carries no write, so it rides the commit lane as a zero-operation
1580
- // commit: the queue tolerates disconnects and de-dupes replays, and the
1581
- // server's track-only path registers the premise and reports anything
1582
- // that already fired. Reuse the same lane the batch `commits.create` door
1583
- // uses rather than opening a bespoke transport.
1584
- const clientTxId =
1585
- typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
1586
- ? crypto.randomUUID()
1587
- : `tx_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
1588
- const queue = syncClient.getMutationQueue();
1589
- await queue.enqueueCommit(clientTxId, [], { track: [dep] });
1590
- const { notifications } = await queue.waitForCommitReceipt(clientTxId);
1591
- return notifications && notifications.length > 0 ? { notifications } : {};
1592
- }),
1593
-
1594
1670
  join: guard(
1595
1671
  (
1596
1672
  ids: string | readonly string[],