@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`.
@@ -12,30 +12,32 @@
12
12
  */
13
13
  import { autorun } from 'mobx';
14
14
  import { AbloClaimedError, AbloValidationError, toAbloError, } from '@abloatai/transaction/errors';
15
- import { reconcileFunctionalUpdate, } from '@abloatai/transaction/resources/functionalUpdate';
15
+ import { reconcileFunctionalUpdate, } from '@abloatai/transaction/client/resources/functionalUpdate';
16
16
  import { claimDescription, } from '@abloatai/transaction/coordination/schema';
17
17
  import { Model, modelAsRow } from '../Model.js';
18
18
  import { toMs } from '@abloatai/transaction/utils/duration';
19
19
  import { LEASE_TTL_MS } from '@abloatai/transaction/wire/protocol';
20
- import { heartbeatCadenceMs, resolveHeartbeatOptions, resolveHeartbeatPlan, startClaimHeartbeatLoop, } from '@abloatai/transaction/coordination/claimHeartbeatLoop';
21
- import { assertWriteOptions } from '@abloatai/transaction/resources/writeOptionsSchema';
22
- import { modelList } from '@abloatai/transaction/resources/httpResources';
20
+ import { heartbeatCadenceMs, resolveHeartbeatOptions, resolveHeartbeatPlan, startClaimHeartbeatLoop, } from '@abloatai/transaction/claims';
21
+ import { assertWriteOptions, assertWriteTarget, } from '@abloatai/transaction/client/resources/writeOptionsSchema';
22
+ import { createModelId, resolveCreatedRows, resolveCreateId, } from '@abloatai/transaction/client/resources/modelCreate';
23
+ import { collectModelList, modelList, } from '@abloatai/transaction/client/resources/httpResources';
23
24
  import { subTarget } from '@abloatai/transaction/coordination';
24
25
  // A named claim-meta crossing (see `claim-meta-crossings-are-enumerated` in
25
- // .dependency-cruiser.cjs): the reactive proxy's self-claim targets are
26
+ // .dependency-cruiser.cjs): the reactive model surface's self-claim targets are
26
27
  // decodes that build a public claim, so their `meta` converts wire→declared
27
28
  // here like the other enumerated crossings.
28
- import { declaredMeta } from '@abloatai/transaction/coordination/claimMeta';
29
+ import { declaredMeta } from '@abloatai/transaction/claims';
29
30
  import { ModelScope } from '@abloatai/transaction/types';
30
- import { claimQueueView, resolveClaimContentionOptions, } from '@abloatai/transaction/resources/modelOperations';
31
- import { abortReadSetCommit, capturePointRead, consumeReadSet, prepareReadSet, } from '@abloatai/transaction/internal/read-set';
31
+ import { bindClaimLifetime, claimLifetimeOf, } from '@abloatai/transaction/claims/lifetime';
32
+ import { claimQueueView, resolveClaimContentionOptions, } from '@abloatai/transaction/client/resources/modelOperations';
33
+ import { capturePointRead, prepareReadSet, } from '@abloatai/transaction/internal/read-set';
32
34
  const modelClientMeta = new WeakMap();
33
35
  export function getModelClientMeta(modelClient) {
34
36
  if (typeof modelClient !== 'object' || modelClient === null)
35
37
  return undefined;
36
38
  return modelClientMeta.get(modelClient);
37
39
  }
38
- export function createModelProxy(schemaKey, registeredModelName, objectPool, syncClient, registry,
40
+ export function createModelOperations(schemaKey, registeredModelName, objectPool, syncClient, registry,
39
41
  /**
40
42
  * The one thing this factory asks of the loader: fetch rows for a model.
41
43
  *
@@ -142,18 +144,36 @@ hydration, collaboration, readSetContext) {
142
144
  await syncClient.syncNow();
143
145
  await syncClient.waitForConfirmation(model.getModelName(), model.id);
144
146
  };
145
- // Claims this proxy currently holds, keyed by entity id. Lets the flat
146
- // `release({ id })` and `update({ id, data })` find the lease and snapshot a
147
- // `claim({ id })` took, without a per-call handle. Released on dispose,
148
- // explicit release, or TTL expiry.
149
- //
150
- // `target`, `description`, and `expiresAt` are kept alongside the lease so
151
- // `claim.state` can synthesize a self-claim: the server excludes a holder's
152
- // own presence frames, so this proxy is the only place that knows the client
153
- // holds the row. `expiresAt` is the client's best estimate from the requested
154
- // TTL (a real epoch-millisecond expiry, not a fabricated watermark), defaulting
155
- // to the server's keepalive lease window when no TTL was requested.
156
147
  const activeClaims = new Map();
148
+ const claimIdsByEntity = new Map();
149
+ const claimsForEntity = (entityId) => [...(claimIdsByEntity.get(entityId) ?? [])].flatMap((claimId) => {
150
+ const held = activeClaims.get(claimId);
151
+ return held ? [held] : [];
152
+ });
153
+ const addActiveClaim = (claimId, held) => {
154
+ activeClaims.set(claimId, held);
155
+ const ids = claimIdsByEntity.get(held.entityId) ?? new Set();
156
+ ids.add(claimId);
157
+ claimIdsByEntity.set(held.entityId, ids);
158
+ };
159
+ const removeActiveClaim = (claimId) => {
160
+ const held = activeClaims.get(claimId);
161
+ if (!held)
162
+ return undefined;
163
+ activeClaims.delete(claimId);
164
+ const ids = claimIdsByEntity.get(held.entityId);
165
+ ids?.delete(claimId);
166
+ if (ids?.size === 0)
167
+ claimIdsByEntity.delete(held.entityId);
168
+ held.stopHeartbeat?.();
169
+ return held;
170
+ };
171
+ const implicitClaimForEntity = (entityId) => {
172
+ const held = claimsForEntity(entityId);
173
+ if (held.length <= 1)
174
+ return held[0];
175
+ throw new AbloValidationError(`${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.`, { code: 'write_options_invalid', param: 'claim' });
176
+ };
157
177
  // Server keepalive lease window — the same `LEASE_TTL_MS` the wire protocol
158
178
  // declares, so the client's estimate and the server's lease cannot drift.
159
179
  // This is the fallback expiry estimate when a claim is taken without an
@@ -165,7 +185,7 @@ hydration, collaboration, readSetContext) {
165
185
  typeof value.id === 'string' &&
166
186
  typeof value.release === 'function';
167
187
  const preparedMutation = (params) => {
168
- const prepared = prepareReadSet(readSetContext, readSetClientIdentity, params.readAt, params.onStale, params.idempotencyKey, params.reads);
188
+ const prepared = prepareReadSet(readSetContext, readSetClientIdentity, undefined, params.idempotencyKey, params.reads);
169
189
  const rest = {
170
190
  ...(prepared.idempotencyKey !== undefined
171
191
  ? { idempotencyKey: prepared.idempotencyKey }
@@ -173,54 +193,46 @@ hydration, collaboration, readSetContext) {
173
193
  ? { idempotencyKey: params.idempotencyKey }
174
194
  : {}),
175
195
  ...(params.label !== undefined ? { label: params.label } : {}),
176
- ...(prepared.readAt !== undefined
177
- ? { readAt: prepared.readAt }
178
- : params.readAt !== undefined
179
- ? { readAt: params.readAt }
180
- : {}),
181
- ...(prepared.onStale !== undefined
182
- ? { onStale: prepared.onStale }
183
- : params.onStale !== undefined
184
- ? { onStale: params.onStale }
185
- : {}),
186
- ...(params.fenceToken !== undefined ? { fenceToken: params.fenceToken } : {}),
187
- ...(params.claimRef !== undefined ? { claimRef: params.claimRef } : {}),
188
196
  ...(prepared.reads !== undefined
189
197
  ? { reads: prepared.reads === null ? null : [...prepared.reads] }
190
198
  : params.reads !== undefined
191
199
  ? { reads: params.reads }
192
200
  : {}),
193
- ...(params.track !== undefined ? { track: params.track } : {}),
194
201
  };
195
202
  // The write-options schema — the runtime twin of the compile-time params.
196
- // Catches plain-JavaScript callers (for example `onStale: 'rejct'`) at the
197
- // call site with a typed error instead of a silent no-op or a server 400.
198
- try {
199
- assertWriteOptions(rest, `${schemaKey} write`);
200
- }
201
- catch (error) {
202
- abortReadSetCommit(readSetContext, prepared.automaticCommit);
203
- throw error;
204
- }
205
- return { options: rest, prepared };
203
+ // Catches plain-JavaScript callers at the call site with a typed error
204
+ // instead of a silent no-op or a server 400.
205
+ assertWriteOptions(rest, `${schemaKey} write`);
206
+ return rest;
206
207
  };
207
- const releaseClaim = async (id) => {
208
- const held = activeClaims.get(id);
208
+ const releaseClaim = async (claimId) => {
209
+ const held = removeActiveClaim(claimId);
209
210
  if (!held)
210
211
  return;
211
- activeClaims.delete(id);
212
212
  await held.lease.release?.();
213
213
  };
214
+ const releaseClaimsForEntity = async (entityId) => {
215
+ const ids = [...(claimIdsByEntity.get(entityId) ?? [])];
216
+ await Promise.all(ids.map((claimId) => releaseClaim(claimId)));
217
+ };
218
+ const settleClaimsAfterWrite = async (entityId, explicit) => {
219
+ const explicitWasLocal = explicit ? activeClaims.has(explicit.id) : false;
220
+ // The server fulfills every claim this participant holds on the written
221
+ // entity. Mirror that terminal transition locally so claim.state/list and
222
+ // heartbeat bookkeeping do not describe leases the commit already ended.
223
+ // This runs after authoritative confirmation. A best-effort abandon frame
224
+ // cannot turn a committed write into an apparent failure; the server has
225
+ // already fulfilled the participant's claims as part of that commit.
226
+ await releaseClaimsForEntity(entityId).catch(() => undefined);
227
+ if (explicit && !explicitWasLocal) {
228
+ await explicit.release?.().catch(() => undefined);
229
+ }
230
+ };
214
231
  const takeClaim = async (params) => {
215
232
  if (!collaboration) {
216
233
  throw new AbloValidationError(`Model "${schemaKey}" was built without the collaboration runtime, so claim() is unavailable here. Claiming needs no per-model config — use the standard Ablo({ schema, apiKey }) client and every model is claimable.`, { code: 'model_claim_not_configured' });
217
234
  }
218
235
  const { id, ...options } = params;
219
- // Read the local snapshot only to decide whether a post-grant re-read may
220
- // be needed. Admission itself always goes to the server: a local presence
221
- // snapshot may be stale or incomplete across instances.
222
- const held = collaboration.state({ model: wireModel, id });
223
- const contended = !!held && held.heldBy !== collaboration.selfParticipantId;
224
236
  const contention = resolveClaimContentionOptions(options);
225
237
  const failFast = !contention.wait;
226
238
  // Ensure the row exists locally before claiming.
@@ -276,24 +288,25 @@ hydration, collaboration, readSetContext) {
276
288
  }
277
289
  throw normalized;
278
290
  }
279
- // Only when the claim actually waited behind another holder can the row have
280
- // changed underneath us re-read so the claimed snapshot reflects what that
281
- // holder committed before releasing. Either of two signals suffices:
282
- // - `lease.waited` the server granted the claim after the client
283
- // provably queued behind a holder. Authoritative; it works even when the
284
- // local snapshot is blind, since claim fan-out is entity-scoped and a
285
- // broadly-subscribed client never observes peers' claims.
286
- // - `contended` the local snapshot saw a holder up front. Kept for the
287
- // no-queue paths, where no grant frame exists.
288
- if ((contended || lease.waited === true) && !failFast) {
289
- // `type: 'complete'` forces the round-trip: the hydration ledger would
290
- // otherwise serve the local row for an already-hydrated id, and the
291
- // holder's final write may not have fanned out yet — the exact
292
- // stale-snapshot race this re-read closes.
293
- await load({ where: [['id', id]], type: 'complete' });
294
- model = ownRowOrThrow(id) ?? model;
295
- }
296
- const snapshot = collaboration.createSnapshot(schemaKey, id);
291
+ // Re-read on grant, never on contention. Holding the lease is the moment
292
+ // this snapshot becomes the premise an expensive step is about to be spent
293
+ // against, so it is read then whether or not the claim queued, and
294
+ // whether or not a local presence snapshot saw a holder.
295
+ //
296
+ // Gating this on contention assumed only a prior holder could have moved
297
+ // the row, which holds while every writer passes the chokepoint and the
298
+ // default policy refuses a non-holder's write. It does not hold for a row
299
+ // fed by the WAL: a write that landed straight in the customer's database
300
+ // never met a claim, so "nobody contended" was never evidence the row had
301
+ // not changed. The HTTP claim has always read unconditionally here.
302
+ //
303
+ // `type: 'complete'` forces the round-trip: the hydration ledger would
304
+ // otherwise serve the local row for an already-hydrated id, and a peer's
305
+ // final write may not have fanned out yet — the exact stale-snapshot race
306
+ // this closes.
307
+ await load({ where: [['id', id]], type: 'complete' });
308
+ model = ownRowOrThrow(id) ?? model;
309
+ const readAt = lease.readAt ?? collaboration.currentReadAt();
297
310
  const description = claimDescription(options);
298
311
  // The self-claim's `ClaimTarget` mirrors what a peer's `claim.state` would
299
312
  // report (`state` maps `held.target.model` to `type`), so a holder and a
@@ -310,13 +323,15 @@ hydration, collaboration, readSetContext) {
310
323
  };
311
324
  const ttlMs = options.ttl !== undefined ? toMs(options.ttl) : DEFAULT_LEASE_TTL_MS;
312
325
  const expiresAt = Date.now() + ttlMs;
313
- activeClaims.set(id, {
326
+ const active = {
327
+ entityId: id,
314
328
  lease,
315
- snapshot,
329
+ readAt,
316
330
  target: selfTarget,
317
331
  description,
318
332
  expiresAt,
319
- });
333
+ };
334
+ addActiveClaim(lease.id, active);
320
335
  const { meta: targetMeta, ...targetNarrowed } = subTarget(options, schemaKey);
321
336
  const target = {
322
337
  type: schemaKey,
@@ -340,7 +355,7 @@ hydration, collaboration, readSetContext) {
340
355
  ttl: resolved.ttl ?? options.ttl,
341
356
  ...(resolved.details !== undefined ? { details: resolved.details } : {}),
342
357
  });
343
- const held = activeClaims.get(id);
358
+ const held = activeClaims.get(lease.id);
344
359
  if (held)
345
360
  held.expiresAt = beat.expiresAt;
346
361
  plan.onBeat?.(beat);
@@ -355,14 +370,18 @@ hydration, collaboration, readSetContext) {
355
370
  ...(plan.onLost ? { onLost: plan.onLost } : {}),
356
371
  })
357
372
  : undefined;
373
+ active.stopHeartbeat = stopHeartbeatLoop;
374
+ const lifetime = claimLifetimeOf(lease);
375
+ lifetime?.onEnd(() => {
376
+ removeActiveClaim(lease.id);
377
+ });
358
378
  const release = () => {
359
- stopHeartbeatLoop?.();
360
- return releaseClaim(id);
379
+ return releaseClaim(lease.id);
361
380
  };
362
- return {
381
+ const handle = {
363
382
  object: 'claim',
364
383
  id: lease.id,
365
- readAt: lease.readAt ?? snapshot.stamp,
384
+ readAt,
366
385
  // The fencing token the server minted for this grant, forwarded from the
367
386
  // lease so writes taken under this handle carry it (Option B).
368
387
  ...(lease.fenceToken !== undefined ? { fenceToken: lease.fenceToken } : {}),
@@ -376,6 +395,7 @@ hydration, collaboration, readSetContext) {
376
395
  heartbeat,
377
396
  [Symbol.asyncDispose]: release,
378
397
  };
398
+ return lifetime ? bindClaimLifetime(handle, lifetime) : handle;
379
399
  };
380
400
  // The row-free sibling of `takeClaim`: locks a key by id alone, for a row that
381
401
  // lives only in the customer's own database and was never synced into Ablo.
@@ -427,11 +447,10 @@ hydration, collaboration, readSetContext) {
427
447
  }
428
448
  throw normalized;
429
449
  }
430
- // A watermark-only snapshot: `createSnapshot` still reads the engine's
431
- // current `lastSyncId` even though the pool holds no row (the bucket is
432
- // empty). It costs nothing extra and gives a write taken under this lease a
433
- // real `readAt` to guard against changes since the lease was acquired.
434
- const snapshot = collaboration.createSnapshot(schemaKey, id);
450
+ // A row-free claim still captures the engine's current read floor. It gives
451
+ // a later write under this lease a real `readAt` without constructing the
452
+ // legacy row snapshot object (there is no row to put in one).
453
+ const readAt = lease.readAt ?? collaboration.currentReadAt();
435
454
  const description = claimDescription(options);
436
455
  const selfTarget = {
437
456
  type: wireModel,
@@ -440,13 +459,15 @@ hydration, collaboration, readSetContext) {
440
459
  };
441
460
  const ttlMs = options.ttl !== undefined ? toMs(options.ttl) : DEFAULT_LEASE_TTL_MS;
442
461
  const expiresAt = Date.now() + ttlMs;
443
- activeClaims.set(id, {
462
+ const active = {
463
+ entityId: id,
444
464
  lease,
445
- snapshot,
465
+ readAt,
446
466
  target: selfTarget,
447
467
  description,
448
468
  expiresAt,
449
- });
469
+ };
470
+ addActiveClaim(lease.id, active);
450
471
  const target = {
451
472
  type: schemaKey,
452
473
  id,
@@ -466,7 +487,7 @@ hydration, collaboration, readSetContext) {
466
487
  ttl: resolved.ttl ?? options.ttl,
467
488
  ...(resolved.details !== undefined ? { details: resolved.details } : {}),
468
489
  });
469
- const held = activeClaims.get(id);
490
+ const held = activeClaims.get(lease.id);
470
491
  if (held)
471
492
  held.expiresAt = beat.expiresAt;
472
493
  plan.onBeat?.(beat);
@@ -479,14 +500,18 @@ hydration, collaboration, readSetContext) {
479
500
  ...(plan.onLost ? { onLost: plan.onLost } : {}),
480
501
  })
481
502
  : undefined;
503
+ active.stopHeartbeat = stopHeartbeatLoop;
504
+ const lifetime = claimLifetimeOf(lease);
505
+ lifetime?.onEnd(() => {
506
+ removeActiveClaim(lease.id);
507
+ });
482
508
  const release = () => {
483
- stopHeartbeatLoop?.();
484
- return releaseClaim(id);
509
+ return releaseClaim(lease.id);
485
510
  };
486
- return {
511
+ const handle = {
487
512
  object: 'claim',
488
513
  id: lease.id,
489
- readAt: snapshot.stamp,
514
+ readAt,
490
515
  // Forward the grant's fencing token so writes under this row-free lease
491
516
  // carry it (Option B), exactly as the row-bearing claim does.
492
517
  ...(lease.fenceToken !== undefined ? { fenceToken: lease.fenceToken } : {}),
@@ -499,6 +524,7 @@ hydration, collaboration, readSetContext) {
499
524
  heartbeat,
500
525
  [Symbol.asyncDispose]: release,
501
526
  };
527
+ return lifetime ? bindClaimLifetime(handle, lifetime) : handle;
502
528
  };
503
529
  // `claim` overloads on its first argument: an options object claims a synced
504
530
  // row and resolves to a HeldClaim (carrying `.data`); a bare id claims a key
@@ -533,24 +559,19 @@ hydration, collaboration, readSetContext) {
533
559
  * they do it through here so the two answers cannot describe the same
534
560
  * holding differently.
535
561
  */
536
- const ownClaimState = (id) => {
537
- const own = activeClaims.get(id);
538
- if (!own)
539
- return null;
540
- return {
541
- object: 'claim',
542
- id: own.lease.id,
543
- status: 'active',
544
- target: own.target,
545
- description: own.description,
546
- heldBy: collaboration?.selfParticipantId ?? '',
547
- participantKind: collaboration?.selfParticipantKind ?? 'user',
548
- expiresAt: own.expiresAt,
549
- // Symmetric with the peer projection: a holder reading its own claim
550
- // sees the same `meta` an observer does.
551
- ...(own.target.meta !== undefined ? { meta: own.target.meta } : {}),
552
- };
553
- };
562
+ const ownClaimStates = (id) => claimsForEntity(id).map((own) => ({
563
+ object: 'claim',
564
+ id: own.lease.id,
565
+ status: 'active',
566
+ target: own.target,
567
+ description: own.description,
568
+ heldBy: collaboration?.selfParticipantId ?? '',
569
+ participantKind: collaboration?.selfParticipantKind ?? 'user',
570
+ expiresAt: own.expiresAt,
571
+ // Symmetric with the peer projection: a holder reading its own claim
572
+ // sees the same `meta` an observer does.
573
+ ...(own.target.meta !== undefined ? { meta: own.target.meta } : {}),
574
+ }));
554
575
  const claimReaders = {
555
576
  state(params) {
556
577
  // Read interest: a passive observer of a row's claim state must enter that
@@ -562,7 +583,7 @@ hydration, collaboration, readSetContext) {
562
583
  // the client skips them, so `state` would return null for a row this client
563
584
  // holds. Synthesize the active claim from the stored lease so the holder
564
585
  // sees its own claim, honoring the documented contract on `claim.state`.
565
- return (ownClaimState(params.id) ??
586
+ return (ownClaimStates(params.id)[0] ??
566
587
  collaboration?.state({ model: wireModel, id: params.id }) ??
567
588
  null);
568
589
  },
@@ -575,13 +596,13 @@ hydration, collaboration, readSetContext) {
575
596
  */
576
597
  list(params) {
577
598
  void collaboration?.enterScope?.({ [schemaKey]: params.id });
578
- const own = ownClaimState(params.id);
599
+ const own = ownClaimStates(params.id);
579
600
  const peers = collaboration?.holders({ model: wireModel, id: params.id }) ?? [];
580
601
  return {
581
602
  object: 'list',
582
603
  // Own claim first: the server excludes a holder's own presence frames,
583
604
  // so it is never among `peers` and the two never duplicate.
584
- data: own ? [own, ...peers] : [...peers],
605
+ data: [...own, ...peers],
585
606
  };
586
607
  },
587
608
  queue(params) {
@@ -590,7 +611,9 @@ hydration, collaboration, readSetContext) {
590
611
  reorder(params) {
591
612
  collaboration?.reorder({ model: wireModel, id: params.id }, params.order);
592
613
  },
593
- release: guard((params) => releaseClaim(isClaimHandle(params) ? params.target.id : params.id)),
614
+ release: guard((params) => isClaimHandle(params)
615
+ ? releaseClaim(params.id)
616
+ : releaseClaimsForEntity(params.id)),
594
617
  };
595
618
  // The one place the caller's `meta` parameter is applied — see the note on
596
619
  // `claimReaders`. Everything else about this object is checked structurally.
@@ -639,7 +662,7 @@ hydration, collaboration, readSetContext) {
639
662
  return local.list(options).length;
640
663
  },
641
664
  };
642
- const get = guard(async (params) => {
665
+ const pointRead = async (params, capture) => {
643
666
  // Read-interest enrolment: authoritative point reads enter the same
644
667
  // entity scope as the claim stream, while remaining settled reads.
645
668
  void collaboration?.enterScope?.({ [schemaKey]: params.id });
@@ -648,129 +671,170 @@ hydration, collaboration, readSetContext) {
648
671
  where: [['id', params.id]],
649
672
  limit: 1,
650
673
  });
651
- if (readSetContext?.getStore() && collaboration) {
652
- const read = await collaboration.readPoint(schemaKey, params.id);
653
- const data = (read.data ?? undefined);
654
- capturePointRead(readSetContext, readSetClientIdentity, wireModel, params.id, data, read.stamp);
674
+ if (collaboration) {
675
+ const result = await collaboration.readPoint(schemaKey, params.id);
676
+ const data = (result.data ?? undefined);
677
+ if (!capture)
678
+ return data;
679
+ capturePointRead(readSetContext, readSetClientIdentity, wireModel, params.id, data, result.stamp);
655
680
  return data;
656
681
  }
657
682
  return rows[0];
658
- });
683
+ };
684
+ const get = guard(async (params) => pointRead(params, false));
685
+ const read = guard(async (params) => pointRead(params, true));
659
686
  const list = guard(async (options) => {
660
- const registry = readSetContext?.getStore();
661
687
  const rows = await load(options);
662
688
  // This transport loads a working set rather than pages, so there is no
663
689
  // cursor to hand back. `limit` can still cut the set short, and a full
664
690
  // count is exactly the case where the caller cannot tell: report it rather
665
691
  // than claim completeness this read cannot vouch for.
666
- const page = modelList(rows, {
692
+ return modelList(rows, {
667
693
  hasMore: options?.limit !== undefined && rows.length >= options.limit,
668
694
  nextCursor: null,
669
695
  });
670
- if (!registry)
671
- return page;
672
- for (const row of rows) {
673
- const stamp = hydration.getReadEvidence?.(row);
674
- if (stamp === undefined) {
675
- // Local-first/lazy rows remain valid reads, but cannot later be used as
676
- // guarded dependencies. `prepareReadSet` rejects them if supplied in
677
- // `reads`; authoritative complete reads carry evidence here.
678
- continue;
679
- }
680
- const id = row.id;
681
- if (typeof id !== 'string' || id.length === 0) {
682
- throw new AbloValidationError(`${registeredModelName}.list returned a row without an id.`, { code: 'commit_no_result' });
696
+ });
697
+ /**
698
+ * Creates many rows as one atomic commit, and returns them in caller order.
699
+ */
700
+ const createManyRows = async (params) => {
701
+ if (params.data.length === 0)
702
+ return [];
703
+ if (!collaboration) {
704
+ throw new AbloValidationError(`Model "${schemaKey}" was built without the collaboration runtime, so a batch ` +
705
+ `create is unavailable here. Use the standard Ablo({ schema, apiKey }) client.`, { code: 'model_claim_not_configured' });
706
+ }
707
+ const prepared = prepareReadSet(readSetContext, readSetClientIdentity, undefined, params.idempotencyKey, params.reads);
708
+ const organizationId = syncClient.getOrganizationId() ?? undefined;
709
+ const ids = [];
710
+ const operations = params.data.map((row) => {
711
+ const fields = row;
712
+ const id = resolveCreateId(undefined, fields) ??
713
+ createModelId(registeredModelName, params.idempotencyKey ? `${params.idempotencyKey}:${ids.length}` : null);
714
+ ids.push(id);
715
+ return {
716
+ action: 'create',
717
+ model: registeredModelName,
718
+ data: { organizationId: fields.organizationId ?? organizationId, ...fields, id },
719
+ id,
720
+ };
721
+ });
722
+ const receipt = await collaboration.commitBatch({
723
+ operations,
724
+ wait: 'confirmed',
725
+ ...(prepared.idempotencyKey
726
+ ? { idempotencyKey: prepared.idempotencyKey }
727
+ : params.idempotencyKey
728
+ ? { idempotencyKey: params.idempotencyKey }
729
+ : {}),
730
+ ...(prepared.reads
731
+ ? { reads: [...prepared.reads] }
732
+ : {}),
733
+ });
734
+ const rows = await resolveCreatedRows({
735
+ modelName: registeredModelName,
736
+ ids,
737
+ operationResults: receipt.operationResults,
738
+ readRow: async (id) => {
739
+ const read = await collaboration.readPoint(registeredModelName, id);
740
+ return read.data;
741
+ },
742
+ });
743
+ return rows;
744
+ };
745
+ // `create` takes one row or a list of them. The list form is atomic and
746
+ // is therefore NOT applied optimistically: see `createManyRows`.
747
+ const createImpl = guardWrite(async (params) => {
748
+ if (Array.isArray(params.data)) {
749
+ return createManyRows(params);
750
+ }
751
+ const single = params;
752
+ const id = resolveCreateId(single.id, single.data) ?? Model.generateId();
753
+ const claim = single.claim;
754
+ let autoLease;
755
+ if (claim && !isClaimHandle(claim)) {
756
+ if (!collaboration) {
757
+ throw new AbloValidationError(`Model "${schemaKey}" was built without the collaboration runtime, so claim() is unavailable here. Claiming needs no per-model config — use the standard Ablo({ schema, apiKey }) client and every model is claimable.`, { code: 'model_claim_not_configured' });
683
758
  }
684
- capturePointRead(readSetContext, readSetClientIdentity, wireModel, id, row, stamp);
759
+ // Write intent: enter the new row's entity scope before acquiring the
760
+ // create-claim so the holder's claim presence broadcasts to everyone
761
+ // already in this entity group (closing the subscribe-versus-broadcast
762
+ // race — see `takeClaim`). Released with the lease in the `finally`
763
+ // below. Awaited for broadcast ordering; still best-effort.
764
+ await collaboration.pinScope?.({ [schemaKey]: id });
765
+ const contention = resolveClaimContentionOptions(claim);
766
+ autoLease = await collaboration.createClaim({
767
+ target: {
768
+ model: wireModel,
769
+ id,
770
+ ...subTarget(claim, schemaKey),
771
+ },
772
+ description: claimDescription(claim, 'creating'),
773
+ ttl: claim.ttl,
774
+ queue: contention.wait,
775
+ maxQueueDepth: contention.maxDepth,
776
+ waitTimeoutMs: contention.timeoutMs,
777
+ signal: contention.signal,
778
+ onStatus: contention.onStatus,
779
+ });
780
+ }
781
+ // Default `organizationId` from the client's identity, matching the other
782
+ // write path — without this, a caller that omits it would create an
783
+ // org-unscoped row on one write path but not the other. An explicit value
784
+ // in `data` still wins via the spread.
785
+ const orgDefault = params.data.organizationId ??
786
+ syncClient.getOrganizationId();
787
+ const model = new ModelClass({
788
+ id,
789
+ ...(orgDefault != null ? { organizationId: orgDefault } : {}),
790
+ ...params.data,
791
+ createdAt: new Date(),
792
+ updatedAt: new Date(),
793
+ });
794
+ try {
795
+ const effective = {
796
+ ...preparedMutation(single),
797
+ ...(autoLease
798
+ ? {
799
+ claimRef: { id: autoLease.id },
800
+ ...(autoLease.fenceToken !== undefined
801
+ ? { fenceToken: autoLease.fenceToken }
802
+ : {}),
803
+ }
804
+ : {}),
805
+ ...(isClaimHandle(claim)
806
+ ? {
807
+ claimRef: { id: claim.id },
808
+ ...(claim.fenceToken !== undefined
809
+ ? { fenceToken: claim.fenceToken }
810
+ : {}),
811
+ }
812
+ : {}),
813
+ };
814
+ syncClient.add(model, effective);
815
+ await waitForMutation(model);
816
+ return modelAsRow(model);
817
+ }
818
+ finally {
819
+ await autoLease?.release?.().catch(() => { });
685
820
  }
686
- return page;
687
821
  });
822
+ function createRows(params) {
823
+ return createImpl(params);
824
+ }
688
825
  const operations = {
689
826
  local,
690
827
  get,
691
- retrieve: get,
828
+ read,
692
829
  // No automatic scope enrolment on bulk `list`: that would subscribe to an
693
830
  // unbounded set of rows' entity groups.
694
831
  list,
695
- create: guardWrite(async (params) => {
696
- const id = params.id ?? Model.generateId();
697
- const claim = params.claim;
698
- let autoLease;
699
- if (claim && !isClaimHandle(claim)) {
700
- if (!collaboration) {
701
- throw new AbloValidationError(`Model "${schemaKey}" was built without the collaboration runtime, so claim() is unavailable here. Claiming needs no per-model config — use the standard Ablo({ schema, apiKey }) client and every model is claimable.`, { code: 'model_claim_not_configured' });
702
- }
703
- // Write intent: enter the new row's entity scope before acquiring the
704
- // create-claim so the holder's claim presence broadcasts to everyone
705
- // already in this entity group (closing the subscribe-versus-broadcast
706
- // race — see `takeClaim`). Released with the lease in the `finally`
707
- // below. Awaited for broadcast ordering; still best-effort.
708
- await collaboration.pinScope?.({ [schemaKey]: id });
709
- const contention = resolveClaimContentionOptions(claim);
710
- autoLease = await collaboration.createClaim({
711
- target: {
712
- model: wireModel,
713
- id,
714
- ...subTarget(claim, schemaKey),
715
- },
716
- description: claimDescription(claim, 'creating'),
717
- ttl: claim.ttl,
718
- queue: contention.wait,
719
- maxQueueDepth: contention.maxDepth,
720
- waitTimeoutMs: contention.timeoutMs,
721
- signal: contention.signal,
722
- onStatus: contention.onStatus,
723
- });
724
- }
725
- // Default `organizationId` from the client's identity, matching the other
726
- // write path — without this, a caller that omits it would create an
727
- // org-unscoped row on one write path but not the other. An explicit value
728
- // in `data` still wins via the spread.
729
- const orgDefault = params.data.organizationId ??
730
- syncClient.getOrganizationId();
731
- const model = new ModelClass({
732
- id,
733
- ...(orgDefault != null ? { organizationId: orgDefault } : {}),
734
- ...params.data,
735
- createdAt: new Date(),
736
- updatedAt: new Date(),
737
- });
738
- let prepared;
739
- try {
740
- const resolved = preparedMutation(params);
741
- prepared = resolved.prepared;
742
- const effective = {
743
- ...resolved.options,
744
- ...(autoLease
745
- ? {
746
- claimRef: { id: autoLease.id },
747
- ...(autoLease.fenceToken !== undefined
748
- ? { fenceToken: autoLease.fenceToken }
749
- : {}),
750
- }
751
- : {}),
752
- ...(isClaimHandle(claim)
753
- ? {
754
- claimRef: { id: claim.id },
755
- ...(claim.fenceToken !== undefined
756
- ? { fenceToken: claim.fenceToken }
757
- : {}),
758
- }
759
- : {}),
760
- };
761
- syncClient.add(model, effective);
762
- await waitForMutation(model);
763
- consumeReadSet(readSetContext, readSetClientIdentity, prepared.consumed, prepared.automaticCommit);
764
- return modelAsRow(model);
765
- }
766
- catch (error) {
767
- abortReadSetCommit(readSetContext, prepared?.automaticCommit ?? false);
768
- throw error;
769
- }
770
- finally {
771
- await autoLease?.release?.().catch(() => { });
772
- }
832
+ listAll: guard(async (options = {}) => {
833
+ const { maxPages, signal, ...readOptions } = options;
834
+ signal?.throwIfAborted();
835
+ return collectModelList(await list(readOptions), { maxPages, signal });
773
836
  }),
837
+ create: createRows,
774
838
  // `update` is overloaded — classic `update({ id, data })` + functional
775
839
  // `update(id, current => next)`. The IIFE keeps the shared error-guard
776
840
  // wrapping while exposing the two public signatures (a plain `guard(...)`
@@ -813,32 +877,28 @@ hydration, collaboration, readSetContext) {
813
877
  if (!model) {
814
878
  throw new AbloValidationError(`Entity not found: ${registeredModelName}/${id}`, { code: 'entity_not_found' });
815
879
  }
816
- const prepared = prepareReadSet(readSetContext, readSetClientIdentity, undefined, 'reject', undefined, contention?.reads);
817
- try {
818
- const effective = {
819
- readAt: prepared.readAt ?? readAt,
820
- onStale: prepared.onStale ?? 'reject',
821
- ...(prepared.idempotencyKey
822
- ? { idempotencyKey: prepared.idempotencyKey }
823
- : {}),
824
- ...(prepared.reads !== undefined
825
- ? { reads: prepared.reads === null ? null : [...prepared.reads] }
826
- : {}),
827
- };
828
- model.applyChanges(patch);
829
- syncClient.update(model, effective);
830
- await waitForMutation(model);
831
- consumeReadSet(readSetContext, readSetClientIdentity, prepared.consumed, prepared.automaticCommit);
832
- return modelAsRow(model);
833
- }
834
- catch (error) {
835
- abortReadSetCommit(readSetContext, prepared.automaticCommit);
836
- throw error;
837
- }
880
+ const prepared = prepareReadSet(readSetContext, readSetClientIdentity, undefined, undefined, contention?.reads);
881
+ const effective = {
882
+ readAt: prepared.readAt ?? readAt,
883
+ ...(prepared.idempotencyKey
884
+ ? { idempotencyKey: prepared.idempotencyKey }
885
+ : {}),
886
+ ...(prepared.reads !== undefined
887
+ ? { reads: prepared.reads === null ? null : [...prepared.reads] }
888
+ : {}),
889
+ };
890
+ model.applyChanges(patch);
891
+ syncClient.update(model, effective);
892
+ await waitForMutation(model);
893
+ return modelAsRow(model);
838
894
  },
839
895
  });
840
896
  }
841
897
  const params = arg;
898
+ // Named before anything reads it. Without this the row lookup below
899
+ // reports `Entity not found: Model/undefined`, which sends the reader
900
+ // looking for a missing row rather than at the unaddressed write.
901
+ assertWriteTarget('update', registeredModelName, params.id);
842
902
  const autoClaim = params.claim && !isClaimHandle(params.claim) ? params.claim : null;
843
903
  if (autoClaim) {
844
904
  const handle = await takeClaim({ ...autoClaim, id: params.id });
@@ -858,53 +918,32 @@ hydration, collaboration, readSetContext) {
858
918
  const model = ownRowOrThrow(id);
859
919
  if (!model)
860
920
  throw new AbloValidationError(`Entity not found: ${registeredModelName}/${id}`, { code: 'entity_not_found' });
861
- // If we hold a claim on this row, guard the write with its snapshot
862
- // watermark + lease so it's stale-rejected and attributed to the claim.
863
- const claimed = activeClaims.get(id);
864
- const resolved = preparedMutation(params);
865
- const opts = resolved.options;
921
+ const opts = preparedMutation(params);
866
922
  const handle = isClaimHandle(params.claim) ? params.claim : undefined;
867
- const effective = claimed
868
- ? {
869
- readAt: claimed.lease.readAt ?? claimed.snapshot.stamp,
870
- onStale: 'reject',
871
- claimRef: { id: claimed.lease.id },
872
- ...(claimed.lease.fenceToken !== undefined
873
- ? { fenceToken: claimed.lease.fenceToken }
874
- : {}),
875
- ...opts,
876
- }
877
- : {
878
- // A carried handle engages the same stale guard as a claim this
879
- // proxy took itself the watermark rides on the handle, so it
880
- // works across clients (HTTP-minted handles included).
881
- ...(handle?.readAt !== undefined
882
- ? {
883
- readAt: handle.readAt,
884
- onStale: 'reject',
885
- ...(handle.fenceToken !== undefined
886
- ? { fenceToken: handle.fenceToken }
887
- : {}),
888
- }
889
- : {}),
890
- ...opts,
891
- ...(handle ? { claimRef: { id: handle.id } } : {}),
892
- };
923
+ // An exact carried handle always wins. Without one, the convenience
924
+ // path may use the sole local claim on this row; several disjoint local
925
+ // claims are intentionally ambiguous and require the caller to pass one.
926
+ const claimed = handle ? activeClaims.get(handle.id) : implicitClaimForEntity(id);
927
+ const selected = handle ?? claimed?.lease;
928
+ const selectedReadAt = handle?.readAt ?? claimed?.readAt;
929
+ const effective = {
930
+ ...(selectedReadAt !== undefined ? { readAt: selectedReadAt } : {}),
931
+ ...(selected?.fenceToken !== undefined
932
+ ? { fenceToken: selected.fenceToken }
933
+ : {}),
934
+ ...opts,
935
+ ...(selected ? { claimRef: { id: selected.id } } : {}),
936
+ };
893
937
  // Local user update: `applyChanges` keeps change tracking on so the
894
938
  // edited fields land in `modifiedProperties` and are actually sent to
895
939
  // the server. (`updateFromData` is the hydration path and would discard
896
940
  // the tracking, producing an empty `input: {}` no-op mutation.)
897
- try {
898
- model.applyChanges(params.data);
899
- syncClient.update(model, effective);
900
- await waitForMutation(model);
901
- consumeReadSet(readSetContext, readSetClientIdentity, resolved.prepared.consumed, resolved.prepared.automaticCommit);
902
- return modelAsRow(model);
903
- }
904
- catch (error) {
905
- abortReadSetCommit(readSetContext, resolved.prepared.automaticCommit);
906
- throw error;
907
- }
941
+ model.applyChanges(params.data);
942
+ syncClient.update(model, effective);
943
+ await waitForMutation(model);
944
+ const updated = modelAsRow(model);
945
+ await settleClaimsAfterWrite(id, handle);
946
+ return updated;
908
947
  });
909
948
  function update(arg, updater, contention) {
910
949
  return updateImpl(arg, updater, contention);
@@ -912,6 +951,9 @@ hydration, collaboration, readSetContext) {
912
951
  return update;
913
952
  })(),
914
953
  delete: guardWrite(async (params) => {
954
+ // Before the idempotent "ensure absent" below can read this as a row that
955
+ // is simply not here. An unaddressed delete is a mistake, not an absence.
956
+ assertWriteTarget('delete', registeredModelName, params.id);
915
957
  const autoClaim = params.claim && !isClaimHandle(params.claim) ? params.claim : null;
916
958
  if (autoClaim) {
917
959
  const handle = await takeClaim({ ...autoClaim, id: params.id });
@@ -937,67 +979,31 @@ hydration, collaboration, readSetContext) {
937
979
  // no-op success rather than an `entity_not_found` error. This matches the
938
980
  // HTTP client and makes delete safe to retry or race (two actors deleting
939
981
  // the same row).
940
- if (!model)
982
+ if (!model) {
983
+ const handle = isClaimHandle(params.claim) ? params.claim : undefined;
984
+ await settleClaimsAfterWrite(id, handle);
941
985
  return;
942
- const claimed = activeClaims.get(id);
943
- const resolved = preparedMutation(params);
944
- const opts = resolved.options;
945
- const handle = isClaimHandle(params.claim) ? params.claim : undefined;
946
- const effective = claimed
947
- ? {
948
- readAt: claimed.lease.readAt ?? claimed.snapshot.stamp,
949
- onStale: 'reject',
950
- claimRef: { id: claimed.lease.id },
951
- ...(claimed.lease.fenceToken !== undefined
952
- ? { fenceToken: claimed.lease.fenceToken }
953
- : {}),
954
- ...opts,
955
- }
956
- : {
957
- ...(handle?.readAt !== undefined
958
- ? {
959
- readAt: handle.readAt,
960
- onStale: 'reject',
961
- ...(handle.fenceToken !== undefined
962
- ? { fenceToken: handle.fenceToken }
963
- : {}),
964
- }
965
- : {}),
966
- ...opts,
967
- ...(handle ? { claimRef: { id: handle.id } } : {}),
968
- };
969
- try {
970
- syncClient.delete(model, effective);
971
- await waitForMutation(model);
972
- consumeReadSet(readSetContext, readSetClientIdentity, resolved.prepared.consumed, resolved.prepared.automaticCommit);
973
- }
974
- catch (error) {
975
- abortReadSetCommit(readSetContext, resolved.prepared.automaticCommit);
976
- throw error;
977
986
  }
987
+ const opts = preparedMutation(params);
988
+ const handle = isClaimHandle(params.claim) ? params.claim : undefined;
989
+ const claimed = handle ? activeClaims.get(handle.id) : implicitClaimForEntity(id);
990
+ const selected = handle ?? claimed?.lease;
991
+ const selectedReadAt = handle?.readAt ?? claimed?.readAt;
992
+ const effective = {
993
+ ...(selectedReadAt !== undefined ? { readAt: selectedReadAt } : {}),
994
+ ...(selected?.fenceToken !== undefined
995
+ ? { fenceToken: selected.fenceToken }
996
+ : {}),
997
+ ...opts,
998
+ ...(selected ? { claimRef: { id: selected.id } } : {}),
999
+ };
1000
+ syncClient.delete(model, effective);
1001
+ await waitForMutation(model);
1002
+ await settleClaimsAfterWrite(id, handle);
978
1003
  }),
979
1004
  // `claim` is a callable namespace (take a claim) carrying the coordination
980
1005
  // readers (`claim.state` / `claim.queue` / `claim.release` / `claim.reorder`).
981
1006
  claim: claimApi,
982
- track: guard(async (params) => {
983
- const dep = {
984
- model: wireModel,
985
- id: params.id,
986
- ...(params.readAt !== undefined ? { readAt: params.readAt } : {}),
987
- };
988
- // A track carries no write, so it rides the commit lane as a zero-operation
989
- // commit: the queue tolerates disconnects and de-dupes replays, and the
990
- // server's track-only path registers the premise and reports anything
991
- // that already fired. Reuse the same lane the batch `commits.create` door
992
- // uses rather than opening a bespoke transport.
993
- const clientTxId = typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
994
- ? crypto.randomUUID()
995
- : `tx_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
996
- const queue = syncClient.getMutationQueue();
997
- await queue.enqueueCommit(clientTxId, [], { track: [dep] });
998
- const { notifications } = await queue.waitForCommitReceipt(clientTxId);
999
- return notifications && notifications.length > 0 ? { notifications } : {};
1000
- }),
1001
1007
  join: guard((ids, options) => {
1002
1008
  if (!collaboration?.createJoin) {
1003
1009
  throw new AbloValidationError(`Model "${schemaKey}" was built without a WebSocket runtime, so join() is unavailable here. Presence needs a live socket — use the standard Ablo({ schema, apiKey }) client (not the HTTP transport).`, { code: 'model_join_not_configured' });