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