@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.
- package/dist/Ablo.d.ts +8 -18
- package/dist/client.d.ts +6 -19
- package/dist/core.d.ts +1 -1
- package/dist/core.js +1 -1
- package/dist/local/Database.d.ts +1 -1
- package/dist/local/Model.js +10 -1
- package/dist/local/SyncClient.js +5 -0
- package/dist/local/client/createInternalComponents.d.ts +1 -1
- package/dist/local/client/createInternalComponents.js +1 -1
- package/dist/local/client/{createModelProxy.d.ts → createModelOperations.d.ts} +11 -10
- package/dist/local/client/{createModelProxy.js → createModelOperations.js} +240 -313
- package/dist/local/client/options.d.ts +2 -2
- package/dist/local/client/reactiveEngine.js +34 -45
- package/dist/local/client/resourceTypes.d.ts +3 -3
- package/dist/local/client/resourceTypes.js +1 -1
- package/dist/local/client/schemaConfig.js +1 -0
- package/dist/local/client/wsMutationExecutor.d.ts +4 -4
- package/dist/local/client/wsMutationExecutor.js +3 -5
- package/dist/local/coordination/ClaimLog.d.ts +3 -3
- package/dist/local/coordination/ClaimLog.js +2 -2
- package/dist/local/interfaces/index.d.ts +10 -7
- package/dist/local/mutators/mutateActions.d.ts +3 -2
- package/dist/local/query/types.d.ts +2 -2
- package/dist/local/stores/syncAction.d.ts +1 -1
- package/dist/local/stores/syncAction.js +1 -1
- package/dist/local/sync/BootstrapFetcher.js +4 -4
- package/dist/local/sync/ConnectionManager.d.ts +1 -1
- package/dist/local/sync/ConnectionManager.js +1 -1
- package/dist/local/sync/OnDemandLoader.d.ts +1 -1
- package/dist/local/sync/OnDemandLoader.js +1 -1
- package/dist/local/sync/SyncWebSocket.d.ts +3 -3
- package/dist/local/sync/SyncWebSocket.js +2 -2
- package/dist/local/sync/commitFrames.d.ts +1 -1
- package/dist/local/sync/commitFrames.js +1 -1
- package/dist/local/sync/contextOnChange.d.ts +8 -0
- package/dist/local/sync/contextOnChange.js +65 -0
- package/dist/local/sync/createClaimStream.d.ts +1 -1
- package/dist/local/sync/createClaimStream.js +47 -28
- package/dist/local/sync/credentialLifecycle.d.ts +1 -1
- package/dist/local/sync/credentialLifecycle.js +1 -1
- package/dist/local/sync/participants.d.ts +1 -1
- package/dist/local/sync/schemaDrift.d.ts +15 -1
- package/dist/local/sync/schemaDrift.js +30 -13
- package/dist/local/sync/schemas.d.ts +2 -2
- package/dist/local/sync/schemas.js +1 -1
- package/dist/local/sync/wsFrameHandlers.d.ts +1 -1
- package/dist/local/sync/wsFrameHandlers.js +1 -1
- package/dist/local/transactions/databaseCommitOutbox.d.ts +2 -2
- package/dist/local/transactions/mutations/MutationQueue.d.ts +4 -19
- package/dist/local/transactions/mutations/MutationQueue.js +5 -70
- package/dist/local/transactions/mutations/batchProcessing.d.ts +2 -13
- package/dist/local/transactions/mutations/batchProcessing.js +0 -20
- package/dist/local/transactions/mutations/commitApi.d.ts +2 -3
- package/dist/local/transactions/mutations/commitApi.js +0 -4
- package/dist/local/transactions/mutations/commitLane.d.ts +3 -8
- package/dist/local/transactions/mutations/commitLane.js +0 -11
- package/dist/local/transactions/mutations/commitPayload.d.ts +2 -4
- package/dist/local/transactions/mutations/commitPayload.js +2 -6
- package/dist/local/transactions/mutations/commitTransport.d.ts +3 -4
- package/dist/local/transactions/mutations/commitTransport.js +2 -5
- package/dist/local/transactions/mutations/durableCommitRestore.d.ts +1 -1
- package/dist/local/transactions/mutations/durableCommitRestore.js +1 -4
- package/dist/local/transactions/mutations/durableWriteStore.d.ts +5 -5
- package/dist/local/transactions/mutations/durableWriteStore.js +3 -3
- package/dist/local/transactions/mutations/failurePolicy.d.ts +2 -2
- package/dist/local/transactions/mutations/mutationPersistence.d.ts +1 -1
- package/dist/local/transactions/mutations/pendingDrain.d.ts +2 -2
- package/dist/local/transactions/mutations/replayValidation.d.ts +0 -20
- package/dist/local/transactions/mutations/replayValidation.js +1 -3
- package/dist/local/transactions/persistedTransaction.d.ts +0 -2
- package/dist/plugin.d.ts +2 -2
- package/dist/presenceStream.d.ts +1 -1
- package/dist/react/createAbloReact.d.ts +1 -1
- package/dist/react/useAblo.d.ts +2 -2
- package/dist/react/useAblo.js +1 -1
- package/dist/surface.d.ts +1 -1
- package/dist/surface.js +1 -2
- package/package.json +2 -2
- package/src/Ablo.ts +9 -27
- package/src/client.ts +6 -22
- package/src/core.ts +1 -1
- package/src/local/Database.ts +1 -1
- package/src/local/Model.ts +12 -1
- package/src/local/SyncClient.ts +5 -0
- package/src/local/client/createInternalComponents.ts +2 -2
- package/src/local/client/{createModelProxy.ts → createModelOperations.ts} +292 -367
- package/src/local/client/options.ts +2 -2
- package/src/local/client/reactiveEngine.ts +63 -55
- package/src/local/client/resourceTypes.ts +4 -4
- package/src/local/client/schemaConfig.ts +3 -0
- package/src/local/client/wsMutationExecutor.ts +3 -9
- package/src/local/coordination/ClaimLog.ts +3 -3
- package/src/local/interfaces/index.ts +7 -7
- package/src/local/mutators/mutateActions.ts +3 -2
- package/src/local/query/types.ts +2 -2
- package/src/local/stores/syncAction.ts +1 -1
- package/src/local/sync/BootstrapFetcher.ts +5 -4
- package/src/local/sync/ConnectionManager.ts +1 -1
- package/src/local/sync/OnDemandLoader.ts +1 -1
- package/src/local/sync/SyncWebSocket.ts +3 -3
- package/src/local/sync/commitFrames.ts +1 -2
- package/src/local/sync/contextOnChange.ts +92 -0
- package/src/local/sync/createClaimStream.ts +77 -39
- package/src/local/sync/credentialLifecycle.ts +1 -1
- package/src/local/sync/participants.ts +1 -1
- package/src/local/sync/schemaDrift.ts +32 -16
- package/src/local/sync/schemas.ts +1 -1
- package/src/local/sync/wsFrameHandlers.ts +1 -1
- package/src/local/transactions/databaseCommitOutbox.ts +2 -2
- package/src/local/transactions/mutations/MutationQueue.ts +8 -95
- package/src/local/transactions/mutations/batchProcessing.ts +2 -38
- package/src/local/transactions/mutations/commitApi.ts +3 -7
- package/src/local/transactions/mutations/commitLane.ts +3 -17
- package/src/local/transactions/mutations/commitPayload.ts +3 -11
- package/src/local/transactions/mutations/commitTransport.ts +3 -7
- package/src/local/transactions/mutations/durableCommitRestore.ts +2 -5
- package/src/local/transactions/mutations/durableWriteStore.ts +5 -5
- package/src/local/transactions/mutations/failurePolicy.ts +2 -2
- package/src/local/transactions/mutations/mutationPersistence.ts +1 -1
- package/src/local/transactions/mutations/pendingDrain.ts +2 -2
- package/src/local/transactions/mutations/replayValidation.ts +1 -3
- package/src/local/transactions/persistedTransaction.ts +0 -3
- package/src/plugin.ts +2 -2
- package/src/presenceStream.ts +2 -2
- package/src/react/createAbloReact.ts +1 -1
- package/src/react/useAblo.ts +2 -2
- package/src/surface.ts +2 -3
- package/dist/local/sync/createSnapshot.d.ts +0 -29
- package/dist/local/sync/createSnapshot.js +0 -116
- 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
|
-
* `
|
|
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/
|
|
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
|
|
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/
|
|
29
|
+
import { declaredMeta } from '@abloatai/transaction/claims';
|
|
30
30
|
import { ModelScope } from '@abloatai/transaction/types';
|
|
31
|
-
import {
|
|
32
|
-
import {
|
|
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
|
|
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,
|
|
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
|
|
198
|
-
//
|
|
199
|
-
|
|
200
|
-
|
|
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 (
|
|
209
|
-
const held =
|
|
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
|
-
//
|
|
281
|
-
//
|
|
282
|
-
//
|
|
283
|
-
//
|
|
284
|
-
//
|
|
285
|
-
//
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
//
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
}
|
|
297
|
-
|
|
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
|
-
|
|
326
|
+
const active = {
|
|
327
|
+
entityId: id,
|
|
315
328
|
lease,
|
|
316
|
-
|
|
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
|
-
|
|
361
|
-
return releaseClaim(id);
|
|
379
|
+
return releaseClaim(lease.id);
|
|
362
380
|
};
|
|
363
|
-
|
|
381
|
+
const handle = {
|
|
364
382
|
object: 'claim',
|
|
365
383
|
id: lease.id,
|
|
366
|
-
readAt
|
|
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
|
|
432
|
-
//
|
|
433
|
-
//
|
|
434
|
-
|
|
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
|
-
|
|
462
|
+
const active = {
|
|
463
|
+
entityId: id,
|
|
445
464
|
lease,
|
|
446
|
-
|
|
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
|
-
|
|
485
|
-
return releaseClaim(id);
|
|
509
|
+
return releaseClaim(lease.id);
|
|
486
510
|
};
|
|
487
|
-
|
|
511
|
+
const handle = {
|
|
488
512
|
object: 'claim',
|
|
489
513
|
id: lease.id,
|
|
490
|
-
readAt
|
|
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
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
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 (
|
|
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 =
|
|
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:
|
|
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) =>
|
|
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
|
|
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 (
|
|
653
|
-
const
|
|
654
|
-
const data = (
|
|
655
|
-
|
|
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
|
-
|
|
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,
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
const
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
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
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
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
|
-
...
|
|
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
|
-
|
|
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,
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
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
|
-
|
|
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
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
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
|
-
};
|
|
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
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
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' });
|