@abloatai/humans 0.57.0 → 0.59.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/dist/Ablo.d.ts +8 -18
  2. package/dist/client.d.ts +6 -19
  3. package/dist/core.d.ts +1 -1
  4. package/dist/core.js +1 -1
  5. package/dist/local/Database.d.ts +1 -1
  6. package/dist/local/Model.js +10 -1
  7. package/dist/local/SyncClient.js +5 -0
  8. package/dist/local/client/createInternalComponents.d.ts +1 -1
  9. package/dist/local/client/createInternalComponents.js +1 -1
  10. package/dist/local/client/{createModelProxy.d.ts → createModelOperations.d.ts} +11 -10
  11. package/dist/local/client/{createModelProxy.js → createModelOperations.js} +240 -313
  12. package/dist/local/client/options.d.ts +2 -2
  13. package/dist/local/client/reactiveEngine.js +34 -45
  14. package/dist/local/client/resourceTypes.d.ts +3 -3
  15. package/dist/local/client/resourceTypes.js +1 -1
  16. package/dist/local/client/schemaConfig.js +1 -0
  17. package/dist/local/client/wsMutationExecutor.d.ts +4 -4
  18. package/dist/local/client/wsMutationExecutor.js +3 -5
  19. package/dist/local/coordination/ClaimLog.d.ts +3 -3
  20. package/dist/local/coordination/ClaimLog.js +2 -2
  21. package/dist/local/interfaces/index.d.ts +10 -7
  22. package/dist/local/mutators/mutateActions.d.ts +3 -2
  23. package/dist/local/query/types.d.ts +2 -2
  24. package/dist/local/stores/syncAction.d.ts +1 -1
  25. package/dist/local/stores/syncAction.js +1 -1
  26. package/dist/local/sync/BootstrapFetcher.js +4 -4
  27. package/dist/local/sync/ConnectionManager.d.ts +1 -1
  28. package/dist/local/sync/ConnectionManager.js +1 -1
  29. package/dist/local/sync/OnDemandLoader.d.ts +1 -1
  30. package/dist/local/sync/OnDemandLoader.js +1 -1
  31. package/dist/local/sync/SyncWebSocket.d.ts +3 -3
  32. package/dist/local/sync/SyncWebSocket.js +2 -2
  33. package/dist/local/sync/commitFrames.d.ts +1 -1
  34. package/dist/local/sync/commitFrames.js +1 -1
  35. package/dist/local/sync/contextOnChange.d.ts +8 -0
  36. package/dist/local/sync/contextOnChange.js +65 -0
  37. package/dist/local/sync/createClaimStream.d.ts +1 -1
  38. package/dist/local/sync/createClaimStream.js +47 -28
  39. package/dist/local/sync/credentialLifecycle.d.ts +1 -1
  40. package/dist/local/sync/credentialLifecycle.js +1 -1
  41. package/dist/local/sync/participants.d.ts +1 -1
  42. package/dist/local/sync/schemaDrift.d.ts +15 -1
  43. package/dist/local/sync/schemaDrift.js +30 -13
  44. package/dist/local/sync/schemas.d.ts +2 -2
  45. package/dist/local/sync/schemas.js +1 -1
  46. package/dist/local/sync/wsFrameHandlers.d.ts +1 -1
  47. package/dist/local/sync/wsFrameHandlers.js +1 -1
  48. package/dist/local/transactions/databaseCommitOutbox.d.ts +2 -2
  49. package/dist/local/transactions/mutations/MutationQueue.d.ts +4 -19
  50. package/dist/local/transactions/mutations/MutationQueue.js +5 -70
  51. package/dist/local/transactions/mutations/batchProcessing.d.ts +2 -13
  52. package/dist/local/transactions/mutations/batchProcessing.js +0 -20
  53. package/dist/local/transactions/mutations/commitApi.d.ts +2 -3
  54. package/dist/local/transactions/mutations/commitApi.js +0 -4
  55. package/dist/local/transactions/mutations/commitLane.d.ts +3 -8
  56. package/dist/local/transactions/mutations/commitLane.js +0 -11
  57. package/dist/local/transactions/mutations/commitPayload.d.ts +2 -4
  58. package/dist/local/transactions/mutations/commitPayload.js +2 -6
  59. package/dist/local/transactions/mutations/commitTransport.d.ts +3 -4
  60. package/dist/local/transactions/mutations/commitTransport.js +2 -5
  61. package/dist/local/transactions/mutations/durableCommitRestore.d.ts +1 -1
  62. package/dist/local/transactions/mutations/durableCommitRestore.js +1 -4
  63. package/dist/local/transactions/mutations/durableWriteStore.d.ts +5 -5
  64. package/dist/local/transactions/mutations/durableWriteStore.js +3 -3
  65. package/dist/local/transactions/mutations/failurePolicy.d.ts +2 -2
  66. package/dist/local/transactions/mutations/mutationPersistence.d.ts +1 -1
  67. package/dist/local/transactions/mutations/pendingDrain.d.ts +2 -2
  68. package/dist/local/transactions/mutations/replayValidation.d.ts +0 -20
  69. package/dist/local/transactions/mutations/replayValidation.js +1 -3
  70. package/dist/local/transactions/persistedTransaction.d.ts +0 -2
  71. package/dist/plugin.d.ts +2 -2
  72. package/dist/presenceStream.d.ts +1 -1
  73. package/dist/react/createAbloReact.d.ts +1 -1
  74. package/dist/react/useAblo.d.ts +2 -2
  75. package/dist/react/useAblo.js +1 -1
  76. package/dist/surface.d.ts +1 -1
  77. package/dist/surface.js +1 -2
  78. package/package.json +2 -2
  79. package/src/Ablo.ts +9 -27
  80. package/src/client.ts +6 -22
  81. package/src/core.ts +1 -1
  82. package/src/local/Database.ts +1 -1
  83. package/src/local/Model.ts +12 -1
  84. package/src/local/SyncClient.ts +5 -0
  85. package/src/local/client/createInternalComponents.ts +2 -2
  86. package/src/local/client/{createModelProxy.ts → createModelOperations.ts} +292 -367
  87. package/src/local/client/options.ts +2 -2
  88. package/src/local/client/reactiveEngine.ts +63 -55
  89. package/src/local/client/resourceTypes.ts +4 -4
  90. package/src/local/client/schemaConfig.ts +3 -0
  91. package/src/local/client/wsMutationExecutor.ts +3 -9
  92. package/src/local/coordination/ClaimLog.ts +3 -3
  93. package/src/local/interfaces/index.ts +7 -7
  94. package/src/local/mutators/mutateActions.ts +3 -2
  95. package/src/local/query/types.ts +2 -2
  96. package/src/local/stores/syncAction.ts +1 -1
  97. package/src/local/sync/BootstrapFetcher.ts +5 -4
  98. package/src/local/sync/ConnectionManager.ts +1 -1
  99. package/src/local/sync/OnDemandLoader.ts +1 -1
  100. package/src/local/sync/SyncWebSocket.ts +3 -3
  101. package/src/local/sync/commitFrames.ts +1 -2
  102. package/src/local/sync/contextOnChange.ts +92 -0
  103. package/src/local/sync/createClaimStream.ts +77 -39
  104. package/src/local/sync/credentialLifecycle.ts +1 -1
  105. package/src/local/sync/participants.ts +1 -1
  106. package/src/local/sync/schemaDrift.ts +32 -16
  107. package/src/local/sync/schemas.ts +1 -1
  108. package/src/local/sync/wsFrameHandlers.ts +1 -1
  109. package/src/local/transactions/databaseCommitOutbox.ts +2 -2
  110. package/src/local/transactions/mutations/MutationQueue.ts +8 -95
  111. package/src/local/transactions/mutations/batchProcessing.ts +2 -38
  112. package/src/local/transactions/mutations/commitApi.ts +3 -7
  113. package/src/local/transactions/mutations/commitLane.ts +3 -17
  114. package/src/local/transactions/mutations/commitPayload.ts +3 -11
  115. package/src/local/transactions/mutations/commitTransport.ts +3 -7
  116. package/src/local/transactions/mutations/durableCommitRestore.ts +2 -5
  117. package/src/local/transactions/mutations/durableWriteStore.ts +5 -5
  118. package/src/local/transactions/mutations/failurePolicy.ts +2 -2
  119. package/src/local/transactions/mutations/mutationPersistence.ts +1 -1
  120. package/src/local/transactions/mutations/pendingDrain.ts +2 -2
  121. package/src/local/transactions/mutations/replayValidation.ts +1 -3
  122. package/src/local/transactions/persistedTransaction.ts +0 -3
  123. package/src/plugin.ts +2 -2
  124. package/src/presenceStream.ts +2 -2
  125. package/src/react/createAbloReact.ts +1 -1
  126. package/src/react/useAblo.ts +2 -2
  127. package/src/surface.ts +2 -3
  128. package/dist/local/sync/createSnapshot.d.ts +0 -29
  129. package/dist/local/sync/createSnapshot.js +0 -116
  130. package/src/local/sync/createSnapshot.ts +0 -160
@@ -3,7 +3,7 @@
3
3
  * `ablo.<model>`.
4
4
  *
5
5
  * Each schema model gets one {@link ModelOperations}: the async server reads
6
- * `get` and `list`, with the same point lookup restricted to the local graph under
6
+ * `read` and `list`, with the same point lookup restricted to the local graph under
7
7
  * `local`, the writes `create`, `update`, and `delete`, the coordination
8
8
  * namespace `claim` (callable as `claim({ id })`, plus `claim.state`,
9
9
  * `claim.queue`, `claim.release`, and `claim.reorder`), `join`, and `onChange`.
@@ -12,31 +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, assertWriteTarget, } from '@abloatai/transaction/resources/writeOptionsSchema';
22
- import { createModelId, resolveCreatedRows, resolveCreateId, } from '@abloatai/transaction/resources/modelCreate';
23
- import { collectModelList, 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';
24
24
  import { subTarget } from '@abloatai/transaction/coordination';
25
25
  // A named claim-meta crossing (see `claim-meta-crossings-are-enumerated` in
26
- // .dependency-cruiser.cjs): the reactive proxy's self-claim targets are
26
+ // .dependency-cruiser.cjs): the reactive model surface's self-claim targets are
27
27
  // decodes that build a public claim, so their `meta` converts wire→declared
28
28
  // here like the other enumerated crossings.
29
- import { declaredMeta } from '@abloatai/transaction/coordination/claimMeta';
29
+ import { declaredMeta } from '@abloatai/transaction/claims';
30
30
  import { ModelScope } from '@abloatai/transaction/types';
31
- import { claimQueueView, resolveClaimContentionOptions, } from '@abloatai/transaction/resources/modelOperations';
32
- 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';
33
34
  const modelClientMeta = new WeakMap();
34
35
  export function getModelClientMeta(modelClient) {
35
36
  if (typeof modelClient !== 'object' || modelClient === null)
36
37
  return undefined;
37
38
  return modelClientMeta.get(modelClient);
38
39
  }
39
- export function createModelProxy(schemaKey, registeredModelName, objectPool, syncClient, registry,
40
+ export function createModelOperations(schemaKey, registeredModelName, objectPool, syncClient, registry,
40
41
  /**
41
42
  * The one thing this factory asks of the loader: fetch rows for a model.
42
43
  *
@@ -143,18 +144,36 @@ hydration, collaboration, readSetContext) {
143
144
  await syncClient.syncNow();
144
145
  await syncClient.waitForConfirmation(model.getModelName(), model.id);
145
146
  };
146
- // Claims this proxy currently holds, keyed by entity id. Lets the flat
147
- // `release({ id })` and `update({ id, data })` find the lease and snapshot a
148
- // `claim({ id })` took, without a per-call handle. Released on dispose,
149
- // explicit release, or TTL expiry.
150
- //
151
- // `target`, `description`, and `expiresAt` are kept alongside the lease so
152
- // `claim.state` can synthesize a self-claim: the server excludes a holder's
153
- // own presence frames, so this proxy is the only place that knows the client
154
- // holds the row. `expiresAt` is the client's best estimate from the requested
155
- // TTL (a real epoch-millisecond expiry, not a fabricated watermark), defaulting
156
- // to the server's keepalive lease window when no TTL was requested.
157
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
+ };
158
177
  // Server keepalive lease window — the same `LEASE_TTL_MS` the wire protocol
159
178
  // declares, so the client's estimate and the server's lease cannot drift.
160
179
  // This is the fallback expiry estimate when a claim is taken without an
@@ -166,7 +185,7 @@ hydration, collaboration, readSetContext) {
166
185
  typeof value.id === 'string' &&
167
186
  typeof value.release === 'function';
168
187
  const preparedMutation = (params) => {
169
- const prepared = prepareReadSet(readSetContext, readSetClientIdentity, params.readAt, params.onStale, params.idempotencyKey, params.reads);
188
+ const prepared = prepareReadSet(readSetContext, readSetClientIdentity, undefined, params.idempotencyKey, params.reads);
170
189
  const rest = {
171
190
  ...(prepared.idempotencyKey !== undefined
172
191
  ? { idempotencyKey: prepared.idempotencyKey }
@@ -174,54 +193,46 @@ hydration, collaboration, readSetContext) {
174
193
  ? { idempotencyKey: params.idempotencyKey }
175
194
  : {}),
176
195
  ...(params.label !== undefined ? { label: params.label } : {}),
177
- ...(prepared.readAt !== undefined
178
- ? { readAt: prepared.readAt }
179
- : params.readAt !== undefined
180
- ? { readAt: params.readAt }
181
- : {}),
182
- ...(prepared.onStale !== undefined
183
- ? { onStale: prepared.onStale }
184
- : params.onStale !== undefined
185
- ? { onStale: params.onStale }
186
- : {}),
187
- ...(params.fenceToken !== undefined ? { fenceToken: params.fenceToken } : {}),
188
- ...(params.claimRef !== undefined ? { claimRef: params.claimRef } : {}),
189
196
  ...(prepared.reads !== undefined
190
197
  ? { reads: prepared.reads === null ? null : [...prepared.reads] }
191
198
  : params.reads !== undefined
192
199
  ? { reads: params.reads }
193
200
  : {}),
194
- ...(params.track !== undefined ? { track: params.track } : {}),
195
201
  };
196
202
  // The write-options schema — the runtime twin of the compile-time params.
197
- // Catches plain-JavaScript callers (for example `onStale: 'rejct'`) at the
198
- // call site with a typed error instead of a silent no-op or a server 400.
199
- try {
200
- assertWriteOptions(rest, `${schemaKey} write`);
201
- }
202
- catch (error) {
203
- abortReadSetCommit(readSetContext, prepared.automaticCommit);
204
- throw error;
205
- }
206
- 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;
207
207
  };
208
- const releaseClaim = async (id) => {
209
- const held = activeClaims.get(id);
208
+ const releaseClaim = async (claimId) => {
209
+ const held = removeActiveClaim(claimId);
210
210
  if (!held)
211
211
  return;
212
- activeClaims.delete(id);
213
212
  await held.lease.release?.();
214
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
+ };
215
231
  const takeClaim = async (params) => {
216
232
  if (!collaboration) {
217
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' });
218
234
  }
219
235
  const { id, ...options } = params;
220
- // Read the local snapshot only to decide whether a post-grant re-read may
221
- // be needed. Admission itself always goes to the server: a local presence
222
- // snapshot may be stale or incomplete across instances.
223
- const held = collaboration.state({ model: wireModel, id });
224
- const contended = !!held && held.heldBy !== collaboration.selfParticipantId;
225
236
  const contention = resolveClaimContentionOptions(options);
226
237
  const failFast = !contention.wait;
227
238
  // Ensure the row exists locally before claiming.
@@ -277,24 +288,25 @@ hydration, collaboration, readSetContext) {
277
288
  }
278
289
  throw normalized;
279
290
  }
280
- // Only when the claim actually waited behind another holder can the row have
281
- // changed underneath us re-read so the claimed snapshot reflects what that
282
- // holder committed before releasing. Either of two signals suffices:
283
- // - `lease.waited` the server granted the claim after the client
284
- // provably queued behind a holder. Authoritative; it works even when the
285
- // local snapshot is blind, since claim fan-out is entity-scoped and a
286
- // broadly-subscribed client never observes peers' claims.
287
- // - `contended` the local snapshot saw a holder up front. Kept for the
288
- // no-queue paths, where no grant frame exists.
289
- if ((contended || lease.waited === true) && !failFast) {
290
- // `type: 'complete'` forces the round-trip: the hydration ledger would
291
- // otherwise serve the local row for an already-hydrated id, and the
292
- // holder's final write may not have fanned out yet — the exact
293
- // stale-snapshot race this re-read closes.
294
- await load({ where: [['id', id]], type: 'complete' });
295
- model = ownRowOrThrow(id) ?? model;
296
- }
297
- 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();
298
310
  const description = claimDescription(options);
299
311
  // The self-claim's `ClaimTarget` mirrors what a peer's `claim.state` would
300
312
  // report (`state` maps `held.target.model` to `type`), so a holder and a
@@ -311,13 +323,15 @@ hydration, collaboration, readSetContext) {
311
323
  };
312
324
  const ttlMs = options.ttl !== undefined ? toMs(options.ttl) : DEFAULT_LEASE_TTL_MS;
313
325
  const expiresAt = Date.now() + ttlMs;
314
- activeClaims.set(id, {
326
+ const active = {
327
+ entityId: id,
315
328
  lease,
316
- snapshot,
329
+ readAt,
317
330
  target: selfTarget,
318
331
  description,
319
332
  expiresAt,
320
- });
333
+ };
334
+ addActiveClaim(lease.id, active);
321
335
  const { meta: targetMeta, ...targetNarrowed } = subTarget(options, schemaKey);
322
336
  const target = {
323
337
  type: schemaKey,
@@ -341,7 +355,7 @@ hydration, collaboration, readSetContext) {
341
355
  ttl: resolved.ttl ?? options.ttl,
342
356
  ...(resolved.details !== undefined ? { details: resolved.details } : {}),
343
357
  });
344
- const held = activeClaims.get(id);
358
+ const held = activeClaims.get(lease.id);
345
359
  if (held)
346
360
  held.expiresAt = beat.expiresAt;
347
361
  plan.onBeat?.(beat);
@@ -356,14 +370,18 @@ hydration, collaboration, readSetContext) {
356
370
  ...(plan.onLost ? { onLost: plan.onLost } : {}),
357
371
  })
358
372
  : undefined;
373
+ active.stopHeartbeat = stopHeartbeatLoop;
374
+ const lifetime = claimLifetimeOf(lease);
375
+ lifetime?.onEnd(() => {
376
+ removeActiveClaim(lease.id);
377
+ });
359
378
  const release = () => {
360
- stopHeartbeatLoop?.();
361
- return releaseClaim(id);
379
+ return releaseClaim(lease.id);
362
380
  };
363
- return {
381
+ const handle = {
364
382
  object: 'claim',
365
383
  id: lease.id,
366
- readAt: lease.readAt ?? snapshot.stamp,
384
+ readAt,
367
385
  // The fencing token the server minted for this grant, forwarded from the
368
386
  // lease so writes taken under this handle carry it (Option B).
369
387
  ...(lease.fenceToken !== undefined ? { fenceToken: lease.fenceToken } : {}),
@@ -377,6 +395,7 @@ hydration, collaboration, readSetContext) {
377
395
  heartbeat,
378
396
  [Symbol.asyncDispose]: release,
379
397
  };
398
+ return lifetime ? bindClaimLifetime(handle, lifetime) : handle;
380
399
  };
381
400
  // The row-free sibling of `takeClaim`: locks a key by id alone, for a row that
382
401
  // lives only in the customer's own database and was never synced into Ablo.
@@ -428,11 +447,10 @@ hydration, collaboration, readSetContext) {
428
447
  }
429
448
  throw normalized;
430
449
  }
431
- // A watermark-only snapshot: `createSnapshot` still reads the engine's
432
- // current `lastSyncId` even though the pool holds no row (the bucket is
433
- // empty). It costs nothing extra and gives a write taken under this lease a
434
- // real `readAt` to guard against changes since the lease was acquired.
435
- 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();
436
454
  const description = claimDescription(options);
437
455
  const selfTarget = {
438
456
  type: wireModel,
@@ -441,13 +459,15 @@ hydration, collaboration, readSetContext) {
441
459
  };
442
460
  const ttlMs = options.ttl !== undefined ? toMs(options.ttl) : DEFAULT_LEASE_TTL_MS;
443
461
  const expiresAt = Date.now() + ttlMs;
444
- activeClaims.set(id, {
462
+ const active = {
463
+ entityId: id,
445
464
  lease,
446
- snapshot,
465
+ readAt,
447
466
  target: selfTarget,
448
467
  description,
449
468
  expiresAt,
450
- });
469
+ };
470
+ addActiveClaim(lease.id, active);
451
471
  const target = {
452
472
  type: schemaKey,
453
473
  id,
@@ -467,7 +487,7 @@ hydration, collaboration, readSetContext) {
467
487
  ttl: resolved.ttl ?? options.ttl,
468
488
  ...(resolved.details !== undefined ? { details: resolved.details } : {}),
469
489
  });
470
- const held = activeClaims.get(id);
490
+ const held = activeClaims.get(lease.id);
471
491
  if (held)
472
492
  held.expiresAt = beat.expiresAt;
473
493
  plan.onBeat?.(beat);
@@ -480,14 +500,18 @@ hydration, collaboration, readSetContext) {
480
500
  ...(plan.onLost ? { onLost: plan.onLost } : {}),
481
501
  })
482
502
  : undefined;
503
+ active.stopHeartbeat = stopHeartbeatLoop;
504
+ const lifetime = claimLifetimeOf(lease);
505
+ lifetime?.onEnd(() => {
506
+ removeActiveClaim(lease.id);
507
+ });
483
508
  const release = () => {
484
- stopHeartbeatLoop?.();
485
- return releaseClaim(id);
509
+ return releaseClaim(lease.id);
486
510
  };
487
- return {
511
+ const handle = {
488
512
  object: 'claim',
489
513
  id: lease.id,
490
- readAt: snapshot.stamp,
514
+ readAt,
491
515
  // Forward the grant's fencing token so writes under this row-free lease
492
516
  // carry it (Option B), exactly as the row-bearing claim does.
493
517
  ...(lease.fenceToken !== undefined ? { fenceToken: lease.fenceToken } : {}),
@@ -500,6 +524,7 @@ hydration, collaboration, readSetContext) {
500
524
  heartbeat,
501
525
  [Symbol.asyncDispose]: release,
502
526
  };
527
+ return lifetime ? bindClaimLifetime(handle, lifetime) : handle;
503
528
  };
504
529
  // `claim` overloads on its first argument: an options object claims a synced
505
530
  // row and resolves to a HeldClaim (carrying `.data`); a bare id claims a key
@@ -534,24 +559,19 @@ hydration, collaboration, readSetContext) {
534
559
  * they do it through here so the two answers cannot describe the same
535
560
  * holding differently.
536
561
  */
537
- const ownClaimState = (id) => {
538
- const own = activeClaims.get(id);
539
- if (!own)
540
- return null;
541
- return {
542
- object: 'claim',
543
- id: own.lease.id,
544
- status: 'active',
545
- target: own.target,
546
- description: own.description,
547
- heldBy: collaboration?.selfParticipantId ?? '',
548
- participantKind: collaboration?.selfParticipantKind ?? 'user',
549
- expiresAt: own.expiresAt,
550
- // Symmetric with the peer projection: a holder reading its own claim
551
- // sees the same `meta` an observer does.
552
- ...(own.target.meta !== undefined ? { meta: own.target.meta } : {}),
553
- };
554
- };
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
+ }));
555
575
  const claimReaders = {
556
576
  state(params) {
557
577
  // Read interest: a passive observer of a row's claim state must enter that
@@ -563,7 +583,7 @@ hydration, collaboration, readSetContext) {
563
583
  // the client skips them, so `state` would return null for a row this client
564
584
  // holds. Synthesize the active claim from the stored lease so the holder
565
585
  // sees its own claim, honoring the documented contract on `claim.state`.
566
- return (ownClaimState(params.id) ??
586
+ return (ownClaimStates(params.id)[0] ??
567
587
  collaboration?.state({ model: wireModel, id: params.id }) ??
568
588
  null);
569
589
  },
@@ -576,13 +596,13 @@ hydration, collaboration, readSetContext) {
576
596
  */
577
597
  list(params) {
578
598
  void collaboration?.enterScope?.({ [schemaKey]: params.id });
579
- const own = ownClaimState(params.id);
599
+ const own = ownClaimStates(params.id);
580
600
  const peers = collaboration?.holders({ model: wireModel, id: params.id }) ?? [];
581
601
  return {
582
602
  object: 'list',
583
603
  // Own claim first: the server excludes a holder's own presence frames,
584
604
  // so it is never among `peers` and the two never duplicate.
585
- data: own ? [own, ...peers] : [...peers],
605
+ data: [...own, ...peers],
586
606
  };
587
607
  },
588
608
  queue(params) {
@@ -591,7 +611,9 @@ hydration, collaboration, readSetContext) {
591
611
  reorder(params) {
592
612
  collaboration?.reorder({ model: wireModel, id: params.id }, params.order);
593
613
  },
594
- 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)),
595
617
  };
596
618
  // The one place the caller's `meta` parameter is applied — see the note on
597
619
  // `claimReaders`. Everything else about this object is checked structurally.
@@ -640,7 +662,7 @@ hydration, collaboration, readSetContext) {
640
662
  return local.list(options).length;
641
663
  },
642
664
  };
643
- const get = guard(async (params) => {
665
+ const pointRead = async (params, capture) => {
644
666
  // Read-interest enrolment: authoritative point reads enter the same
645
667
  // entity scope as the claim stream, while remaining settled reads.
646
668
  void collaboration?.enterScope?.({ [schemaKey]: params.id });
@@ -649,42 +671,28 @@ hydration, collaboration, readSetContext) {
649
671
  where: [['id', params.id]],
650
672
  limit: 1,
651
673
  });
652
- if (readSetContext?.getStore() && collaboration) {
653
- const read = await collaboration.readPoint(schemaKey, params.id);
654
- const data = (read.data ?? undefined);
655
- 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);
656
680
  return data;
657
681
  }
658
682
  return rows[0];
659
- });
683
+ };
684
+ const get = guard(async (params) => pointRead(params, false));
685
+ const read = guard(async (params) => pointRead(params, true));
660
686
  const list = guard(async (options) => {
661
- const registry = readSetContext?.getStore();
662
687
  const rows = await load(options);
663
688
  // This transport loads a working set rather than pages, so there is no
664
689
  // cursor to hand back. `limit` can still cut the set short, and a full
665
690
  // count is exactly the case where the caller cannot tell: report it rather
666
691
  // than claim completeness this read cannot vouch for.
667
- const page = modelList(rows, {
692
+ return modelList(rows, {
668
693
  hasMore: options?.limit !== undefined && rows.length >= options.limit,
669
694
  nextCursor: null,
670
695
  });
671
- if (!registry)
672
- return page;
673
- for (const row of rows) {
674
- const stamp = hydration.getReadEvidence?.(row);
675
- if (stamp === undefined) {
676
- // Local-first/lazy rows remain valid reads, but cannot later be used as
677
- // guarded dependencies. `prepareReadSet` rejects them if supplied in
678
- // `reads`; authoritative complete reads carry evidence here.
679
- continue;
680
- }
681
- const id = row.id;
682
- if (typeof id !== 'string' || id.length === 0) {
683
- throw new AbloValidationError(`${registeredModelName}.list returned a row without an id.`, { code: 'commit_no_result' });
684
- }
685
- capturePointRead(readSetContext, readSetClientIdentity, wireModel, id, row, stamp);
686
- }
687
- return page;
688
696
  });
689
697
  /**
690
698
  * Creates many rows as one atomic commit, and returns them in caller order.
@@ -696,51 +704,43 @@ hydration, collaboration, readSetContext) {
696
704
  throw new AbloValidationError(`Model "${schemaKey}" was built without the collaboration runtime, so a batch ` +
697
705
  `create is unavailable here. Use the standard Ablo({ schema, apiKey }) client.`, { code: 'model_claim_not_configured' });
698
706
  }
699
- const prepared = prepareReadSet(readSetContext, readSetClientIdentity, undefined, undefined, params.idempotencyKey, params.reads);
700
- try {
701
- const organizationId = syncClient.getOrganizationId() ?? undefined;
702
- const ids = [];
703
- const operations = params.data.map((row) => {
704
- const fields = row;
705
- const id = resolveCreateId(undefined, fields) ??
706
- createModelId(registeredModelName, params.idempotencyKey ? `${params.idempotencyKey}:${ids.length}` : null);
707
- ids.push(id);
708
- return {
709
- action: 'create',
710
- model: registeredModelName,
711
- data: { organizationId: fields.organizationId ?? organizationId, ...fields, id },
712
- id,
713
- };
714
- });
715
- const receipt = await collaboration.commitBatch({
716
- operations,
717
- wait: 'confirmed',
718
- ...(prepared.idempotencyKey
719
- ? { idempotencyKey: prepared.idempotencyKey }
720
- : params.idempotencyKey
721
- ? { idempotencyKey: params.idempotencyKey }
722
- : {}),
723
- ...(prepared.reads
724
- ? { reads: [...prepared.reads] }
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 }
725
729
  : {}),
726
- ...(params.track ? { track: [...params.track] } : {}),
727
- });
728
- const rows = await resolveCreatedRows({
729
- modelName: registeredModelName,
730
- ids,
731
- operationResults: receipt.operationResults,
732
- readRow: async (id) => {
733
- const read = await collaboration.readPoint(registeredModelName, id);
734
- return read.data;
735
- },
736
- });
737
- consumeReadSet(readSetContext, readSetClientIdentity, prepared.consumed, prepared.automaticCommit);
738
- return rows;
739
- }
740
- catch (error) {
741
- abortReadSetCommit(readSetContext, prepared.automaticCommit);
742
- throw error;
743
- }
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
744
  };
745
745
  // `create` takes one row or a list of them. The list form is atomic and
746
746
  // is therefore NOT applied optimistically: see `createManyRows`.
@@ -791,12 +791,9 @@ hydration, collaboration, readSetContext) {
791
791
  createdAt: new Date(),
792
792
  updatedAt: new Date(),
793
793
  });
794
- let prepared;
795
794
  try {
796
- const resolved = preparedMutation(single);
797
- prepared = resolved.prepared;
798
795
  const effective = {
799
- ...resolved.options,
796
+ ...preparedMutation(single),
800
797
  ...(autoLease
801
798
  ? {
802
799
  claimRef: { id: autoLease.id },
@@ -816,13 +813,8 @@ hydration, collaboration, readSetContext) {
816
813
  };
817
814
  syncClient.add(model, effective);
818
815
  await waitForMutation(model);
819
- consumeReadSet(readSetContext, readSetClientIdentity, prepared.consumed, prepared.automaticCommit);
820
816
  return modelAsRow(model);
821
817
  }
822
- catch (error) {
823
- abortReadSetCommit(readSetContext, prepared?.automaticCommit ?? false);
824
- throw error;
825
- }
826
818
  finally {
827
819
  await autoLease?.release?.().catch(() => { });
828
820
  }
@@ -833,7 +825,7 @@ hydration, collaboration, readSetContext) {
833
825
  const operations = {
834
826
  local,
835
827
  get,
836
- retrieve: get,
828
+ read,
837
829
  // No automatic scope enrolment on bulk `list`: that would subscribe to an
838
830
  // unbounded set of rows' entity groups.
839
831
  list,
@@ -885,28 +877,20 @@ hydration, collaboration, readSetContext) {
885
877
  if (!model) {
886
878
  throw new AbloValidationError(`Entity not found: ${registeredModelName}/${id}`, { code: 'entity_not_found' });
887
879
  }
888
- const prepared = prepareReadSet(readSetContext, readSetClientIdentity, undefined, 'reject', undefined, contention?.reads);
889
- try {
890
- const effective = {
891
- readAt: prepared.readAt ?? readAt,
892
- onStale: prepared.onStale ?? 'reject',
893
- ...(prepared.idempotencyKey
894
- ? { idempotencyKey: prepared.idempotencyKey }
895
- : {}),
896
- ...(prepared.reads !== undefined
897
- ? { reads: prepared.reads === null ? null : [...prepared.reads] }
898
- : {}),
899
- };
900
- model.applyChanges(patch);
901
- syncClient.update(model, effective);
902
- await waitForMutation(model);
903
- consumeReadSet(readSetContext, readSetClientIdentity, prepared.consumed, prepared.automaticCommit);
904
- return modelAsRow(model);
905
- }
906
- catch (error) {
907
- abortReadSetCommit(readSetContext, prepared.automaticCommit);
908
- throw error;
909
- }
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);
910
894
  },
911
895
  });
912
896
  }
@@ -934,53 +918,32 @@ hydration, collaboration, readSetContext) {
934
918
  const model = ownRowOrThrow(id);
935
919
  if (!model)
936
920
  throw new AbloValidationError(`Entity not found: ${registeredModelName}/${id}`, { code: 'entity_not_found' });
937
- // If we hold a claim on this row, guard the write with its snapshot
938
- // watermark + lease so it's stale-rejected and attributed to the claim.
939
- const claimed = activeClaims.get(id);
940
- const resolved = preparedMutation(params);
941
- const opts = resolved.options;
921
+ const opts = preparedMutation(params);
942
922
  const handle = isClaimHandle(params.claim) ? params.claim : undefined;
943
- const effective = claimed
944
- ? {
945
- readAt: claimed.lease.readAt ?? claimed.snapshot.stamp,
946
- onStale: 'reject',
947
- claimRef: { id: claimed.lease.id },
948
- ...(claimed.lease.fenceToken !== undefined
949
- ? { fenceToken: claimed.lease.fenceToken }
950
- : {}),
951
- ...opts,
952
- }
953
- : {
954
- // A carried handle engages the same stale guard as a claim this
955
- // proxy took itself the watermark rides on the handle, so it
956
- // works across clients (HTTP-minted handles included).
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
- };
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
+ };
969
937
  // Local user update: `applyChanges` keeps change tracking on so the
970
938
  // edited fields land in `modifiedProperties` and are actually sent to
971
939
  // the server. (`updateFromData` is the hydration path and would discard
972
940
  // the tracking, producing an empty `input: {}` no-op mutation.)
973
- try {
974
- model.applyChanges(params.data);
975
- syncClient.update(model, effective);
976
- await waitForMutation(model);
977
- consumeReadSet(readSetContext, readSetClientIdentity, resolved.prepared.consumed, resolved.prepared.automaticCommit);
978
- return modelAsRow(model);
979
- }
980
- catch (error) {
981
- abortReadSetCommit(readSetContext, resolved.prepared.automaticCommit);
982
- throw error;
983
- }
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;
984
947
  });
985
948
  function update(arg, updater, contention) {
986
949
  return updateImpl(arg, updater, contention);
@@ -1016,67 +979,31 @@ hydration, collaboration, readSetContext) {
1016
979
  // no-op success rather than an `entity_not_found` error. This matches the
1017
980
  // HTTP client and makes delete safe to retry or race (two actors deleting
1018
981
  // the same row).
1019
- if (!model)
982
+ if (!model) {
983
+ const handle = isClaimHandle(params.claim) ? params.claim : undefined;
984
+ await settleClaimsAfterWrite(id, handle);
1020
985
  return;
1021
- const claimed = activeClaims.get(id);
1022
- const resolved = preparedMutation(params);
1023
- const opts = resolved.options;
1024
- const handle = isClaimHandle(params.claim) ? params.claim : undefined;
1025
- const effective = claimed
1026
- ? {
1027
- readAt: claimed.lease.readAt ?? claimed.snapshot.stamp,
1028
- onStale: 'reject',
1029
- claimRef: { id: claimed.lease.id },
1030
- ...(claimed.lease.fenceToken !== undefined
1031
- ? { fenceToken: claimed.lease.fenceToken }
1032
- : {}),
1033
- ...opts,
1034
- }
1035
- : {
1036
- ...(handle?.readAt !== undefined
1037
- ? {
1038
- readAt: handle.readAt,
1039
- onStale: 'reject',
1040
- ...(handle.fenceToken !== undefined
1041
- ? { fenceToken: handle.fenceToken }
1042
- : {}),
1043
- }
1044
- : {}),
1045
- ...opts,
1046
- ...(handle ? { claimRef: { id: handle.id } } : {}),
1047
- };
1048
- try {
1049
- syncClient.delete(model, effective);
1050
- await waitForMutation(model);
1051
- consumeReadSet(readSetContext, readSetClientIdentity, resolved.prepared.consumed, resolved.prepared.automaticCommit);
1052
- }
1053
- catch (error) {
1054
- abortReadSetCommit(readSetContext, resolved.prepared.automaticCommit);
1055
- throw error;
1056
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);
1057
1003
  }),
1058
1004
  // `claim` is a callable namespace (take a claim) carrying the coordination
1059
1005
  // readers (`claim.state` / `claim.queue` / `claim.release` / `claim.reorder`).
1060
1006
  claim: claimApi,
1061
- track: guard(async (params) => {
1062
- const dep = {
1063
- model: wireModel,
1064
- id: params.id,
1065
- ...(params.readAt !== undefined ? { readAt: params.readAt } : {}),
1066
- };
1067
- // A track carries no write, so it rides the commit lane as a zero-operation
1068
- // commit: the queue tolerates disconnects and de-dupes replays, and the
1069
- // server's track-only path registers the premise and reports anything
1070
- // that already fired. Reuse the same lane the batch `commits.create` door
1071
- // uses rather than opening a bespoke transport.
1072
- const clientTxId = typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
1073
- ? crypto.randomUUID()
1074
- : `tx_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
1075
- const queue = syncClient.getMutationQueue();
1076
- await queue.enqueueCommit(clientTxId, [], { track: [dep] });
1077
- const { notifications } = await queue.waitForCommitReceipt(clientTxId);
1078
- return notifications && notifications.length > 0 ? { notifications } : {};
1079
- }),
1080
1007
  join: guard((ids, options) => {
1081
1008
  if (!collaboration?.createJoin) {
1082
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' });