@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`.
|
|
@@ -21,12 +21,11 @@ import {
|
|
|
21
21
|
reconcileFunctionalUpdate,
|
|
22
22
|
type ModelUpdater,
|
|
23
23
|
type FunctionalUpdateOptions,
|
|
24
|
-
} from '@abloatai/transaction/resources/functionalUpdate';
|
|
24
|
+
} from '@abloatai/transaction/client/resources/functionalUpdate';
|
|
25
25
|
import type { MutationOptions } from '../interfaces/index.js';
|
|
26
26
|
import {
|
|
27
27
|
claimDescription,
|
|
28
28
|
type ReadDependency,
|
|
29
|
-
type TrackDependency,
|
|
30
29
|
} from '@abloatai/transaction/coordination/schema';
|
|
31
30
|
import { Model, modelAsRow } from '../Model.js';
|
|
32
31
|
import { toMs } from '@abloatai/transaction/utils/duration';
|
|
@@ -36,32 +35,32 @@ import {
|
|
|
36
35
|
resolveHeartbeatOptions,
|
|
37
36
|
resolveHeartbeatPlan,
|
|
38
37
|
startClaimHeartbeatLoop,
|
|
39
|
-
} from '@abloatai/transaction/
|
|
38
|
+
} from '@abloatai/transaction/claims';
|
|
40
39
|
import {
|
|
41
40
|
assertWriteOptions,
|
|
42
41
|
assertWriteTarget,
|
|
43
|
-
} from '@abloatai/transaction/resources/writeOptionsSchema';
|
|
42
|
+
} from '@abloatai/transaction/client/resources/writeOptionsSchema';
|
|
44
43
|
import {
|
|
45
44
|
createModelId,
|
|
46
45
|
resolveCreatedRows,
|
|
47
46
|
resolveCreateId,
|
|
48
|
-
} from '@abloatai/transaction/resources/modelCreate';
|
|
47
|
+
} from '@abloatai/transaction/client/resources/modelCreate';
|
|
49
48
|
import type {
|
|
50
49
|
CommitCreateOptions,
|
|
51
50
|
CommitReceipt,
|
|
52
|
-
} from '@abloatai/transaction/resources/httpResources';
|
|
53
|
-
import type { HttpModelMutationParams } from '@abloatai/transaction/transport/
|
|
51
|
+
} from '@abloatai/transaction/client/resources/httpResources';
|
|
52
|
+
import type { HttpModelMutationParams } from '@abloatai/transaction/transport/http';
|
|
54
53
|
import {
|
|
55
54
|
collectModelList,
|
|
56
55
|
modelList,
|
|
57
56
|
type ModelList,
|
|
58
|
-
} from '@abloatai/transaction/resources/httpResources';
|
|
57
|
+
} from '@abloatai/transaction/client/resources/httpResources';
|
|
59
58
|
import { subTarget } from '@abloatai/transaction/coordination';
|
|
60
59
|
// A named claim-meta crossing (see `claim-meta-crossings-are-enumerated` in
|
|
61
|
-
// .dependency-cruiser.cjs): the reactive
|
|
60
|
+
// .dependency-cruiser.cjs): the reactive model surface's self-claim targets are
|
|
62
61
|
// decodes that build a public claim, so their `meta` converts wire→declared
|
|
63
62
|
// here like the other enumerated crossings.
|
|
64
|
-
import { declaredMeta } from '@abloatai/transaction/
|
|
63
|
+
import { declaredMeta } from '@abloatai/transaction/claims';
|
|
65
64
|
import type { ModelTarget } from '@abloatai/transaction/coordination/schema';
|
|
66
65
|
import type { ModelRegistry } from '../ModelRegistry.js';
|
|
67
66
|
import type { InstanceCache } from '../InstanceCache.js';
|
|
@@ -78,24 +77,24 @@ import type {
|
|
|
78
77
|
HeldLease,
|
|
79
78
|
ClaimWaitOptions,
|
|
80
79
|
ClaimTarget,
|
|
81
|
-
Snapshot,
|
|
82
80
|
} from '@abloatai/transaction/types/streams';
|
|
81
|
+
import {
|
|
82
|
+
bindClaimLifetime,
|
|
83
|
+
claimLifetimeOf,
|
|
84
|
+
} from '@abloatai/transaction/claims/lifetime';
|
|
83
85
|
|
|
84
86
|
// The request contract — every option and parameter shape a caller passes to a
|
|
85
87
|
// read, a write, or a claim — lives in the confirmation core (ADR 0016). This
|
|
86
88
|
// factory binds it to reactive model instances; the shapes themselves are
|
|
87
|
-
// transport- and consumer-agnostic. Re-exported so `./
|
|
89
|
+
// transport- and consumer-agnostic. Re-exported so `./createModelOperations` stays a
|
|
88
90
|
// working import path for the whole surface.
|
|
89
91
|
export type {
|
|
90
92
|
ModelListScope,
|
|
91
|
-
ModelTrackParams,
|
|
92
|
-
ModelTrackResult,
|
|
93
93
|
LocalReadOptions,
|
|
94
94
|
LocalCountOptions,
|
|
95
95
|
ServerReadOptions,
|
|
96
96
|
ListAllOptions,
|
|
97
|
-
|
|
98
|
-
ServerRetrieveOptions,
|
|
97
|
+
ServerPointReadOptions,
|
|
99
98
|
ClaimTargetOptions,
|
|
100
99
|
ClaimParams,
|
|
101
100
|
ClaimContentionOptions,
|
|
@@ -109,12 +108,12 @@ export type {
|
|
|
109
108
|
ClaimReadApi,
|
|
110
109
|
AwaitedClaimMethod,
|
|
111
110
|
ClaimApi,
|
|
112
|
-
|
|
111
|
+
ModelReadParams,
|
|
113
112
|
ModelCreateParams,
|
|
114
113
|
ModelUpdateParams,
|
|
115
114
|
ModelDeleteParams,
|
|
116
115
|
JoinOptions,
|
|
117
|
-
} from '@abloatai/transaction/resources/modelOperations';
|
|
116
|
+
} from '@abloatai/transaction/client/resources/modelOperations';
|
|
118
117
|
export type { Claim, ClaimHeartbeat, ClaimHeartbeatOptions, HeldClaim, HeldLease };
|
|
119
118
|
|
|
120
119
|
import type {
|
|
@@ -133,28 +132,23 @@ import type {
|
|
|
133
132
|
ModelCreateManyParams,
|
|
134
133
|
ModelCreateParams,
|
|
135
134
|
ModelDeleteParams,
|
|
136
|
-
|
|
137
|
-
ModelTrackParams,
|
|
138
|
-
ModelTrackResult,
|
|
135
|
+
ModelReadParams,
|
|
139
136
|
ModelUpdateParams,
|
|
140
137
|
ServerReadOptions,
|
|
141
138
|
ListAllOptions,
|
|
142
|
-
} from '@abloatai/transaction/resources/modelOperations';
|
|
139
|
+
} from '@abloatai/transaction/client/resources/modelOperations';
|
|
143
140
|
import {
|
|
144
141
|
claimQueueView,
|
|
145
142
|
resolveClaimContentionOptions,
|
|
146
|
-
} from '@abloatai/transaction/resources/modelOperations';
|
|
143
|
+
} from '@abloatai/transaction/client/resources/modelOperations';
|
|
147
144
|
import type {
|
|
148
145
|
CapturedRow,
|
|
149
146
|
HttpModelClient,
|
|
150
|
-
} from '@abloatai/transaction/transport/
|
|
147
|
+
} from '@abloatai/transaction/transport/http';
|
|
151
148
|
import type { ParticipantKind } from '@abloatai/transaction/types/participant';
|
|
152
149
|
import {
|
|
153
|
-
abortReadSetCommit,
|
|
154
150
|
capturePointRead,
|
|
155
|
-
consumeReadSet,
|
|
156
151
|
prepareReadSet,
|
|
157
|
-
type PreparedReadSet,
|
|
158
152
|
type ReadSetContext,
|
|
159
153
|
} from '@abloatai/transaction/internal/read-set';
|
|
160
154
|
|
|
@@ -222,7 +216,8 @@ export interface ModelCollaboration {
|
|
|
222
216
|
/** Request-scoped queued / granted / skipped / failed status events. */
|
|
223
217
|
onStatus?: (event: ClaimAttemptEvent) => void;
|
|
224
218
|
}): Promise<Claim>;
|
|
225
|
-
|
|
219
|
+
/** Current applied/acked watermark, captured after a claim's fresh read. */
|
|
220
|
+
currentReadAt(): number;
|
|
226
221
|
/**
|
|
227
222
|
* Current coordination state on a target — who (if anyone) holds it.
|
|
228
223
|
* Synchronous reactive snapshot read off the presence/claim stream;
|
|
@@ -293,7 +288,7 @@ export interface ModelCollaboration {
|
|
|
293
288
|
* Opens a presence and claim subscription on this model's sync group(s) and
|
|
294
289
|
* returns the live participant handle. Backs `ablo.<model>.join(ids)`.
|
|
295
290
|
* WebSocket only, since presence needs a live socket; it is absent on other
|
|
296
|
-
* client constructions, where the
|
|
291
|
+
* client constructions, where the surface throws a clear error.
|
|
297
292
|
*/
|
|
298
293
|
createJoin?(
|
|
299
294
|
modelKey: string,
|
|
@@ -308,7 +303,7 @@ export interface ModelCollaboration {
|
|
|
308
303
|
*
|
|
309
304
|
* Every verb mirrors its asynchronous sibling on the base surface, and the one
|
|
310
305
|
* word in front is the whole difference. It is a narrowing, not a claim about
|
|
311
|
-
* the other side: `
|
|
306
|
+
* the other side: `read` consults the local graph and then the network,
|
|
312
307
|
* while `local.get` is restricted to what is already resident — which is
|
|
313
308
|
* also why it can return a value instead of a promise. There is nothing to
|
|
314
309
|
* await.
|
|
@@ -425,7 +420,7 @@ export type ModelOperations<T, CreateInput> = Omit<
|
|
|
425
420
|
> &
|
|
426
421
|
ReactiveModelSurface<T, CreateInput>;
|
|
427
422
|
|
|
428
|
-
export function
|
|
423
|
+
export function createModelOperations<T, C>(
|
|
429
424
|
schemaKey: string,
|
|
430
425
|
registeredModelName: string,
|
|
431
426
|
objectPool: Pick<InstanceCache, 'get' | 'getByType' | 'getOfType'>,
|
|
@@ -433,7 +428,6 @@ export function createModelProxy<T, C>(
|
|
|
433
428
|
SyncClient,
|
|
434
429
|
| 'add'
|
|
435
430
|
| 'delete'
|
|
436
|
-
| 'getMutationQueue'
|
|
437
431
|
| 'getOrganizationId'
|
|
438
432
|
| 'syncNow'
|
|
439
433
|
| 'update'
|
|
@@ -567,27 +561,66 @@ export function createModelProxy<T, C>(
|
|
|
567
561
|
await syncClient.waitForConfirmation(model.getModelName(), model.id);
|
|
568
562
|
};
|
|
569
563
|
|
|
570
|
-
// Claims this
|
|
571
|
-
//
|
|
572
|
-
//
|
|
573
|
-
//
|
|
564
|
+
// Claims this model surface currently holds, keyed by the exact grant id.
|
|
565
|
+
// Several disjoint sub-row claims may coexist on one entity, so entity id is
|
|
566
|
+
// an index, never the identity of a lease. The old Map<entityId, claim>
|
|
567
|
+
// silently overwrote the first handle when the same participant claimed a
|
|
568
|
+
// second field and then released/guarded whichever happened to be last.
|
|
574
569
|
//
|
|
575
570
|
// `target`, `description`, and `expiresAt` are kept alongside the lease so
|
|
576
571
|
// `claim.state` can synthesize a self-claim: the server excludes a holder's
|
|
577
|
-
// own presence frames, so this
|
|
572
|
+
// own presence frames, so this surface is the only place that knows the client
|
|
578
573
|
// holds the row. `expiresAt` is the client's best estimate from the requested
|
|
579
574
|
// TTL (a real epoch-millisecond expiry, not a fabricated watermark), defaulting
|
|
580
575
|
// to the server's keepalive lease window when no TTL was requested.
|
|
576
|
+
interface ActiveClaim {
|
|
577
|
+
readonly entityId: string;
|
|
578
|
+
readonly lease: Claim;
|
|
579
|
+
readonly readAt: number;
|
|
580
|
+
readonly target: ClaimTarget;
|
|
581
|
+
readonly description: string;
|
|
582
|
+
expiresAt: number;
|
|
583
|
+
stopHeartbeat?: () => void;
|
|
584
|
+
}
|
|
585
|
+
|
|
581
586
|
const activeClaims = new Map<
|
|
582
587
|
string,
|
|
583
|
-
|
|
584
|
-
lease: Claim;
|
|
585
|
-
snapshot: Snapshot;
|
|
586
|
-
target: ClaimTarget;
|
|
587
|
-
description: string;
|
|
588
|
-
expiresAt: number;
|
|
589
|
-
}
|
|
588
|
+
ActiveClaim
|
|
590
589
|
>();
|
|
590
|
+
const claimIdsByEntity = new Map<string, Set<string>>();
|
|
591
|
+
|
|
592
|
+
const claimsForEntity = (entityId: string): ActiveClaim[] =>
|
|
593
|
+
[...(claimIdsByEntity.get(entityId) ?? [])].flatMap((claimId) => {
|
|
594
|
+
const held = activeClaims.get(claimId);
|
|
595
|
+
return held ? [held] : [];
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
const addActiveClaim = (claimId: string, held: ActiveClaim): void => {
|
|
599
|
+
activeClaims.set(claimId, held);
|
|
600
|
+
const ids = claimIdsByEntity.get(held.entityId) ?? new Set<string>();
|
|
601
|
+
ids.add(claimId);
|
|
602
|
+
claimIdsByEntity.set(held.entityId, ids);
|
|
603
|
+
};
|
|
604
|
+
|
|
605
|
+
const removeActiveClaim = (claimId: string): ActiveClaim | undefined => {
|
|
606
|
+
const held = activeClaims.get(claimId);
|
|
607
|
+
if (!held) return undefined;
|
|
608
|
+
activeClaims.delete(claimId);
|
|
609
|
+
const ids = claimIdsByEntity.get(held.entityId);
|
|
610
|
+
ids?.delete(claimId);
|
|
611
|
+
if (ids?.size === 0) claimIdsByEntity.delete(held.entityId);
|
|
612
|
+
held.stopHeartbeat?.();
|
|
613
|
+
return held;
|
|
614
|
+
};
|
|
615
|
+
|
|
616
|
+
const implicitClaimForEntity = (entityId: string): ActiveClaim | undefined => {
|
|
617
|
+
const held = claimsForEntity(entityId);
|
|
618
|
+
if (held.length <= 1) return held[0];
|
|
619
|
+
throw new AbloValidationError(
|
|
620
|
+
`${registeredModelName}/${entityId} has ${held.length} local claims on different parts of the row. Pass the exact claim handle to the write so Ablo can use the intended grant.`,
|
|
621
|
+
{ code: 'write_options_invalid', param: 'claim' },
|
|
622
|
+
);
|
|
623
|
+
};
|
|
591
624
|
|
|
592
625
|
// Server keepalive lease window — the same `LEASE_TTL_MS` the wire protocol
|
|
593
626
|
// declares, so the client's estimate and the server's lease cannot drift.
|
|
@@ -607,12 +640,11 @@ export function createModelProxy<T, C>(
|
|
|
607
640
|
| ModelCreateParams<T, C>
|
|
608
641
|
| ModelUpdateParams<T, C>
|
|
609
642
|
| ModelDeleteParams<T, C>,
|
|
610
|
-
):
|
|
643
|
+
): MutationOptions => {
|
|
611
644
|
const prepared = prepareReadSet(
|
|
612
645
|
readSetContext,
|
|
613
646
|
readSetClientIdentity,
|
|
614
|
-
|
|
615
|
-
params.onStale,
|
|
647
|
+
undefined,
|
|
616
648
|
params.idempotencyKey,
|
|
617
649
|
params.reads,
|
|
618
650
|
);
|
|
@@ -623,44 +655,47 @@ export function createModelProxy<T, C>(
|
|
|
623
655
|
? { idempotencyKey: params.idempotencyKey }
|
|
624
656
|
: {}),
|
|
625
657
|
...(params.label !== undefined ? { label: params.label } : {}),
|
|
626
|
-
...(prepared.readAt !== undefined
|
|
627
|
-
? { readAt: prepared.readAt }
|
|
628
|
-
: params.readAt !== undefined
|
|
629
|
-
? { readAt: params.readAt }
|
|
630
|
-
: {}),
|
|
631
|
-
...(prepared.onStale !== undefined
|
|
632
|
-
? { onStale: prepared.onStale }
|
|
633
|
-
: params.onStale !== undefined
|
|
634
|
-
? { onStale: params.onStale }
|
|
635
|
-
: {}),
|
|
636
|
-
...(params.fenceToken !== undefined ? { fenceToken: params.fenceToken } : {}),
|
|
637
|
-
...(params.claimRef !== undefined ? { claimRef: params.claimRef } : {}),
|
|
638
658
|
...(prepared.reads !== undefined
|
|
639
659
|
? { reads: prepared.reads === null ? null : [...prepared.reads] }
|
|
640
660
|
: params.reads !== undefined
|
|
641
661
|
? { reads: params.reads }
|
|
642
662
|
: {}),
|
|
643
|
-
...(params.track !== undefined ? { track: params.track } : {}),
|
|
644
663
|
};
|
|
645
664
|
// The write-options schema — the runtime twin of the compile-time params.
|
|
646
|
-
// Catches plain-JavaScript callers
|
|
647
|
-
//
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
} catch (error) {
|
|
651
|
-
abortReadSetCommit(readSetContext, prepared.automaticCommit);
|
|
652
|
-
throw error;
|
|
653
|
-
}
|
|
654
|
-
return { options: rest, prepared };
|
|
665
|
+
// Catches plain-JavaScript callers at the call site with a typed error
|
|
666
|
+
// instead of a silent no-op or a server 400.
|
|
667
|
+
assertWriteOptions(rest, `${schemaKey} write`);
|
|
668
|
+
return rest;
|
|
655
669
|
};
|
|
656
670
|
|
|
657
|
-
const releaseClaim = async (
|
|
658
|
-
const held =
|
|
671
|
+
const releaseClaim = async (claimId: string): Promise<void> => {
|
|
672
|
+
const held = removeActiveClaim(claimId);
|
|
659
673
|
if (!held) return;
|
|
660
|
-
activeClaims.delete(id);
|
|
661
674
|
await held.lease.release?.();
|
|
662
675
|
};
|
|
663
676
|
|
|
677
|
+
const releaseClaimsForEntity = async (entityId: string): Promise<void> => {
|
|
678
|
+
const ids = [...(claimIdsByEntity.get(entityId) ?? [])];
|
|
679
|
+
await Promise.all(ids.map((claimId) => releaseClaim(claimId)));
|
|
680
|
+
};
|
|
681
|
+
|
|
682
|
+
const settleClaimsAfterWrite = async (
|
|
683
|
+
entityId: string,
|
|
684
|
+
explicit?: Pick<Claim<T>, 'id' | 'release'>,
|
|
685
|
+
): Promise<void> => {
|
|
686
|
+
const explicitWasLocal = explicit ? activeClaims.has(explicit.id) : false;
|
|
687
|
+
// The server fulfills every claim this participant holds on the written
|
|
688
|
+
// entity. Mirror that terminal transition locally so claim.state/list and
|
|
689
|
+
// heartbeat bookkeeping do not describe leases the commit already ended.
|
|
690
|
+
// This runs after authoritative confirmation. A best-effort abandon frame
|
|
691
|
+
// cannot turn a committed write into an apparent failure; the server has
|
|
692
|
+
// already fulfilled the participant's claims as part of that commit.
|
|
693
|
+
await releaseClaimsForEntity(entityId).catch(() => undefined);
|
|
694
|
+
if (explicit && !explicitWasLocal) {
|
|
695
|
+
await explicit.release?.().catch(() => undefined);
|
|
696
|
+
}
|
|
697
|
+
};
|
|
698
|
+
|
|
664
699
|
const takeClaim = async (
|
|
665
700
|
params: ClaimParams<C>,
|
|
666
701
|
): Promise<HeldClaim<T> | null> => {
|
|
@@ -671,11 +706,6 @@ export function createModelProxy<T, C>(
|
|
|
671
706
|
);
|
|
672
707
|
}
|
|
673
708
|
const { id, ...options } = params;
|
|
674
|
-
// Read the local snapshot only to decide whether a post-grant re-read may
|
|
675
|
-
// be needed. Admission itself always goes to the server: a local presence
|
|
676
|
-
// snapshot may be stale or incomplete across instances.
|
|
677
|
-
const held = collaboration.state({ model: wireModel, id });
|
|
678
|
-
const contended = !!held && held.heldBy !== collaboration.selfParticipantId;
|
|
679
709
|
const contention = resolveClaimContentionOptions(options);
|
|
680
710
|
const failFast = !contention.wait;
|
|
681
711
|
|
|
@@ -739,25 +769,26 @@ export function createModelProxy<T, C>(
|
|
|
739
769
|
throw normalized;
|
|
740
770
|
}
|
|
741
771
|
|
|
742
|
-
//
|
|
743
|
-
//
|
|
744
|
-
//
|
|
745
|
-
//
|
|
746
|
-
//
|
|
747
|
-
//
|
|
748
|
-
//
|
|
749
|
-
//
|
|
750
|
-
//
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
}
|
|
759
|
-
|
|
760
|
-
|
|
772
|
+
// Re-read on grant, never on contention. Holding the lease is the moment
|
|
773
|
+
// this snapshot becomes the premise an expensive step is about to be spent
|
|
774
|
+
// against, so it is read then — whether or not the claim queued, and
|
|
775
|
+
// whether or not a local presence snapshot saw a holder.
|
|
776
|
+
//
|
|
777
|
+
// Gating this on contention assumed only a prior holder could have moved
|
|
778
|
+
// the row, which holds while every writer passes the chokepoint and the
|
|
779
|
+
// default policy refuses a non-holder's write. It does not hold for a row
|
|
780
|
+
// fed by the WAL: a write that landed straight in the customer's database
|
|
781
|
+
// never met a claim, so "nobody contended" was never evidence the row had
|
|
782
|
+
// not changed. The HTTP claim has always read unconditionally here.
|
|
783
|
+
//
|
|
784
|
+
// `type: 'complete'` forces the round-trip: the hydration ledger would
|
|
785
|
+
// otherwise serve the local row for an already-hydrated id, and a peer's
|
|
786
|
+
// final write may not have fanned out yet — the exact stale-snapshot race
|
|
787
|
+
// this closes.
|
|
788
|
+
await load({ where: [['id', id]], type: 'complete' });
|
|
789
|
+
model = ownRowOrThrow(id) ?? model;
|
|
790
|
+
|
|
791
|
+
const readAt = lease.readAt ?? collaboration.currentReadAt();
|
|
761
792
|
const description = claimDescription(options);
|
|
762
793
|
// The self-claim's `ClaimTarget` mirrors what a peer's `claim.state` would
|
|
763
794
|
// report (`state` maps `held.target.model` to `type`), so a holder and a
|
|
@@ -775,13 +806,15 @@ export function createModelProxy<T, C>(
|
|
|
775
806
|
const ttlMs =
|
|
776
807
|
options.ttl !== undefined ? toMs(options.ttl) : DEFAULT_LEASE_TTL_MS;
|
|
777
808
|
const expiresAt = Date.now() + ttlMs;
|
|
778
|
-
|
|
809
|
+
const active: ActiveClaim = {
|
|
810
|
+
entityId: id,
|
|
779
811
|
lease,
|
|
780
|
-
|
|
812
|
+
readAt,
|
|
781
813
|
target: selfTarget,
|
|
782
814
|
description,
|
|
783
815
|
expiresAt,
|
|
784
|
-
}
|
|
816
|
+
};
|
|
817
|
+
addActiveClaim(lease.id, active);
|
|
785
818
|
const { meta: targetMeta, ...targetNarrowed } = subTarget(options, schemaKey);
|
|
786
819
|
const target = {
|
|
787
820
|
type: schemaKey,
|
|
@@ -810,7 +843,7 @@ export function createModelProxy<T, C>(
|
|
|
810
843
|
ttl: resolved.ttl ?? options.ttl,
|
|
811
844
|
...(resolved.details !== undefined ? { details: resolved.details } : {}),
|
|
812
845
|
});
|
|
813
|
-
const held = activeClaims.get(id);
|
|
846
|
+
const held = activeClaims.get(lease.id);
|
|
814
847
|
if (held) held.expiresAt = beat.expiresAt;
|
|
815
848
|
plan.onBeat?.(beat);
|
|
816
849
|
return beat;
|
|
@@ -826,14 +859,19 @@ export function createModelProxy<T, C>(
|
|
|
826
859
|
})
|
|
827
860
|
: undefined;
|
|
828
861
|
|
|
862
|
+
active.stopHeartbeat = stopHeartbeatLoop;
|
|
863
|
+
const lifetime = claimLifetimeOf(lease);
|
|
864
|
+
lifetime?.onEnd(() => {
|
|
865
|
+
removeActiveClaim(lease.id);
|
|
866
|
+
});
|
|
867
|
+
|
|
829
868
|
const release = () => {
|
|
830
|
-
|
|
831
|
-
return releaseClaim(id);
|
|
869
|
+
return releaseClaim(lease.id);
|
|
832
870
|
};
|
|
833
|
-
|
|
871
|
+
const handle = {
|
|
834
872
|
object: 'claim',
|
|
835
873
|
id: lease.id,
|
|
836
|
-
readAt
|
|
874
|
+
readAt,
|
|
837
875
|
// The fencing token the server minted for this grant, forwarded from the
|
|
838
876
|
// lease so writes taken under this handle carry it (Option B).
|
|
839
877
|
...(lease.fenceToken !== undefined ? { fenceToken: lease.fenceToken } : {}),
|
|
@@ -846,7 +884,8 @@ export function createModelProxy<T, C>(
|
|
|
846
884
|
},
|
|
847
885
|
heartbeat,
|
|
848
886
|
[Symbol.asyncDispose]: release,
|
|
849
|
-
}
|
|
887
|
+
} satisfies HeldClaim<T>;
|
|
888
|
+
return lifetime ? bindClaimLifetime(handle, lifetime) : handle;
|
|
850
889
|
};
|
|
851
890
|
|
|
852
891
|
// The row-free sibling of `takeClaim`: locks a key by id alone, for a row that
|
|
@@ -909,11 +948,10 @@ export function createModelProxy<T, C>(
|
|
|
909
948
|
throw normalized;
|
|
910
949
|
}
|
|
911
950
|
|
|
912
|
-
// A
|
|
913
|
-
//
|
|
914
|
-
//
|
|
915
|
-
|
|
916
|
-
const snapshot = collaboration.createSnapshot(schemaKey, id);
|
|
951
|
+
// A row-free claim still captures the engine's current read floor. It gives
|
|
952
|
+
// a later write under this lease a real `readAt` without constructing the
|
|
953
|
+
// legacy row snapshot object (there is no row to put in one).
|
|
954
|
+
const readAt = lease.readAt ?? collaboration.currentReadAt();
|
|
917
955
|
const description = claimDescription(options);
|
|
918
956
|
const selfTarget: ClaimTarget = {
|
|
919
957
|
type: wireModel,
|
|
@@ -923,13 +961,15 @@ export function createModelProxy<T, C>(
|
|
|
923
961
|
const ttlMs =
|
|
924
962
|
options.ttl !== undefined ? toMs(options.ttl) : DEFAULT_LEASE_TTL_MS;
|
|
925
963
|
const expiresAt = Date.now() + ttlMs;
|
|
926
|
-
|
|
964
|
+
const active: ActiveClaim = {
|
|
965
|
+
entityId: id,
|
|
927
966
|
lease,
|
|
928
|
-
|
|
967
|
+
readAt,
|
|
929
968
|
target: selfTarget,
|
|
930
969
|
description,
|
|
931
970
|
expiresAt,
|
|
932
|
-
}
|
|
971
|
+
};
|
|
972
|
+
addActiveClaim(lease.id, active);
|
|
933
973
|
const target = {
|
|
934
974
|
type: schemaKey,
|
|
935
975
|
id,
|
|
@@ -954,7 +994,7 @@ export function createModelProxy<T, C>(
|
|
|
954
994
|
ttl: resolved.ttl ?? options.ttl,
|
|
955
995
|
...(resolved.details !== undefined ? { details: resolved.details } : {}),
|
|
956
996
|
});
|
|
957
|
-
const held = activeClaims.get(id);
|
|
997
|
+
const held = activeClaims.get(lease.id);
|
|
958
998
|
if (held) held.expiresAt = beat.expiresAt;
|
|
959
999
|
plan.onBeat?.(beat);
|
|
960
1000
|
return beat;
|
|
@@ -968,14 +1008,19 @@ export function createModelProxy<T, C>(
|
|
|
968
1008
|
})
|
|
969
1009
|
: undefined;
|
|
970
1010
|
|
|
1011
|
+
active.stopHeartbeat = stopHeartbeatLoop;
|
|
1012
|
+
const lifetime = claimLifetimeOf(lease);
|
|
1013
|
+
lifetime?.onEnd(() => {
|
|
1014
|
+
removeActiveClaim(lease.id);
|
|
1015
|
+
});
|
|
1016
|
+
|
|
971
1017
|
const release = () => {
|
|
972
|
-
|
|
973
|
-
return releaseClaim(id);
|
|
1018
|
+
return releaseClaim(lease.id);
|
|
974
1019
|
};
|
|
975
|
-
|
|
1020
|
+
const handle = {
|
|
976
1021
|
object: 'claim',
|
|
977
1022
|
id: lease.id,
|
|
978
|
-
readAt
|
|
1023
|
+
readAt,
|
|
979
1024
|
// Forward the grant's fencing token so writes under this row-free lease
|
|
980
1025
|
// carry it (Option B), exactly as the row-bearing claim does.
|
|
981
1026
|
...(lease.fenceToken !== undefined ? { fenceToken: lease.fenceToken } : {}),
|
|
@@ -987,7 +1032,8 @@ export function createModelProxy<T, C>(
|
|
|
987
1032
|
},
|
|
988
1033
|
heartbeat,
|
|
989
1034
|
[Symbol.asyncDispose]: release,
|
|
990
|
-
};
|
|
1035
|
+
} satisfies HeldLease;
|
|
1036
|
+
return lifetime ? bindClaimLifetime(handle, lifetime) : handle;
|
|
991
1037
|
};
|
|
992
1038
|
|
|
993
1039
|
// `claim` overloads on its first argument: an options object claims a synced
|
|
@@ -1036,10 +1082,8 @@ export function createModelProxy<T, C>(
|
|
|
1036
1082
|
* they do it through here so the two answers cannot describe the same
|
|
1037
1083
|
* holding differently.
|
|
1038
1084
|
*/
|
|
1039
|
-
const
|
|
1040
|
-
|
|
1041
|
-
if (!own) return null;
|
|
1042
|
-
return {
|
|
1085
|
+
const ownClaimStates = (id: string): Claim[] =>
|
|
1086
|
+
claimsForEntity(id).map((own) => ({
|
|
1043
1087
|
object: 'claim',
|
|
1044
1088
|
id: own.lease.id,
|
|
1045
1089
|
status: 'active',
|
|
@@ -1051,8 +1095,7 @@ export function createModelProxy<T, C>(
|
|
|
1051
1095
|
// Symmetric with the peer projection: a holder reading its own claim
|
|
1052
1096
|
// sees the same `meta` an observer does.
|
|
1053
1097
|
...(own.target.meta !== undefined ? { meta: own.target.meta } : {}),
|
|
1054
|
-
};
|
|
1055
|
-
};
|
|
1098
|
+
}));
|
|
1056
1099
|
|
|
1057
1100
|
const claimReaders = {
|
|
1058
1101
|
state(params: ClaimLookupParams<T>): Claim | null {
|
|
@@ -1066,7 +1109,7 @@ export function createModelProxy<T, C>(
|
|
|
1066
1109
|
// holds. Synthesize the active claim from the stored lease so the holder
|
|
1067
1110
|
// sees its own claim, honoring the documented contract on `claim.state`.
|
|
1068
1111
|
return (
|
|
1069
|
-
|
|
1112
|
+
ownClaimStates(params.id)[0] ??
|
|
1070
1113
|
collaboration?.state({ model: wireModel, id: params.id }) ??
|
|
1071
1114
|
null
|
|
1072
1115
|
);
|
|
@@ -1081,13 +1124,13 @@ export function createModelProxy<T, C>(
|
|
|
1081
1124
|
*/
|
|
1082
1125
|
list(params: ClaimLookupParams<T>): { readonly object: 'list'; readonly data: readonly Claim[] } {
|
|
1083
1126
|
void collaboration?.enterScope?.({ [schemaKey]: params.id });
|
|
1084
|
-
const own =
|
|
1127
|
+
const own = ownClaimStates(params.id);
|
|
1085
1128
|
const peers = collaboration?.holders({ model: wireModel, id: params.id }) ?? [];
|
|
1086
1129
|
return {
|
|
1087
1130
|
object: 'list',
|
|
1088
1131
|
// Own claim first: the server excludes a holder's own presence frames,
|
|
1089
1132
|
// so it is never among `peers` and the two never duplicate.
|
|
1090
|
-
data:
|
|
1133
|
+
data: [...own, ...peers],
|
|
1091
1134
|
};
|
|
1092
1135
|
},
|
|
1093
1136
|
|
|
@@ -1102,7 +1145,9 @@ export function createModelProxy<T, C>(
|
|
|
1102
1145
|
},
|
|
1103
1146
|
|
|
1104
1147
|
release: guard((params: ClaimLookupParams<T> | Claim<T>): Promise<void> =>
|
|
1105
|
-
|
|
1148
|
+
isClaimHandle(params)
|
|
1149
|
+
? releaseClaim(params.id)
|
|
1150
|
+
: releaseClaimsForEntity(params.id),
|
|
1106
1151
|
),
|
|
1107
1152
|
};
|
|
1108
1153
|
|
|
@@ -1161,8 +1206,10 @@ export function createModelProxy<T, C>(
|
|
|
1161
1206
|
},
|
|
1162
1207
|
};
|
|
1163
1208
|
|
|
1164
|
-
const
|
|
1165
|
-
|
|
1209
|
+
const pointRead = async (
|
|
1210
|
+
params: ModelReadParams,
|
|
1211
|
+
capture: boolean,
|
|
1212
|
+
): Promise<T | undefined> => {
|
|
1166
1213
|
// Read-interest enrolment: authoritative point reads enter the same
|
|
1167
1214
|
// entity scope as the claim stream, while remaining settled reads.
|
|
1168
1215
|
void collaboration?.enterScope?.({ [schemaKey]: params.id });
|
|
@@ -1171,62 +1218,44 @@ export function createModelProxy<T, C>(
|
|
|
1171
1218
|
where: [['id', params.id]],
|
|
1172
1219
|
limit: 1,
|
|
1173
1220
|
});
|
|
1174
|
-
if (
|
|
1175
|
-
const
|
|
1176
|
-
const data = (
|
|
1221
|
+
if (collaboration) {
|
|
1222
|
+
const result = await collaboration.readPoint(schemaKey, params.id);
|
|
1223
|
+
const data = (result.data ?? undefined) as T | undefined;
|
|
1224
|
+
if (!capture) return data;
|
|
1177
1225
|
capturePointRead(
|
|
1178
1226
|
readSetContext,
|
|
1179
1227
|
readSetClientIdentity,
|
|
1180
1228
|
wireModel,
|
|
1181
1229
|
params.id,
|
|
1182
1230
|
data,
|
|
1183
|
-
|
|
1231
|
+
result.stamp,
|
|
1184
1232
|
);
|
|
1185
|
-
return data
|
|
1233
|
+
return data;
|
|
1186
1234
|
}
|
|
1187
|
-
return rows[0]
|
|
1188
|
-
|
|
1235
|
+
return rows[0];
|
|
1236
|
+
};
|
|
1237
|
+
|
|
1238
|
+
const get = guard(
|
|
1239
|
+
async (params: ModelReadParams): Promise<T | undefined> => pointRead(params, false),
|
|
1240
|
+
);
|
|
1241
|
+
|
|
1242
|
+
const read = guard(
|
|
1243
|
+
async (params: ModelReadParams): Promise<CapturedRow<T> | undefined> =>
|
|
1244
|
+
pointRead(params, true) as Promise<CapturedRow<T> | undefined>,
|
|
1189
1245
|
);
|
|
1190
1246
|
|
|
1191
1247
|
const list = guard(async (
|
|
1192
1248
|
options?: ServerReadOptions<T>,
|
|
1193
|
-
): Promise<ModelList<
|
|
1194
|
-
const registry = readSetContext?.getStore();
|
|
1249
|
+
): Promise<ModelList<T>> => {
|
|
1195
1250
|
const rows = await load(options);
|
|
1196
1251
|
// This transport loads a working set rather than pages, so there is no
|
|
1197
1252
|
// cursor to hand back. `limit` can still cut the set short, and a full
|
|
1198
1253
|
// count is exactly the case where the caller cannot tell: report it rather
|
|
1199
1254
|
// than claim completeness this read cannot vouch for.
|
|
1200
|
-
|
|
1255
|
+
return modelList<T>(rows, {
|
|
1201
1256
|
hasMore: options?.limit !== undefined && rows.length >= options.limit,
|
|
1202
1257
|
nextCursor: null,
|
|
1203
1258
|
});
|
|
1204
|
-
if (!registry) return page;
|
|
1205
|
-
for (const row of rows) {
|
|
1206
|
-
const stamp = hydration.getReadEvidence?.(row as object);
|
|
1207
|
-
if (stamp === undefined) {
|
|
1208
|
-
// Local-first/lazy rows remain valid reads, but cannot later be used as
|
|
1209
|
-
// guarded dependencies. `prepareReadSet` rejects them if supplied in
|
|
1210
|
-
// `reads`; authoritative complete reads carry evidence here.
|
|
1211
|
-
continue;
|
|
1212
|
-
}
|
|
1213
|
-
const id = (row as { id?: unknown }).id;
|
|
1214
|
-
if (typeof id !== 'string' || id.length === 0) {
|
|
1215
|
-
throw new AbloValidationError(
|
|
1216
|
-
`${registeredModelName}.list returned a row without an id.`,
|
|
1217
|
-
{ code: 'commit_no_result' },
|
|
1218
|
-
);
|
|
1219
|
-
}
|
|
1220
|
-
capturePointRead(
|
|
1221
|
-
readSetContext,
|
|
1222
|
-
readSetClientIdentity,
|
|
1223
|
-
wireModel,
|
|
1224
|
-
id,
|
|
1225
|
-
row,
|
|
1226
|
-
stamp,
|
|
1227
|
-
);
|
|
1228
|
-
}
|
|
1229
|
-
return page;
|
|
1230
1259
|
});
|
|
1231
1260
|
|
|
1232
1261
|
/**
|
|
@@ -1248,64 +1277,51 @@ export function createModelProxy<T, C>(
|
|
|
1248
1277
|
readSetContext,
|
|
1249
1278
|
readSetClientIdentity,
|
|
1250
1279
|
undefined,
|
|
1251
|
-
undefined,
|
|
1252
1280
|
params.idempotencyKey,
|
|
1253
1281
|
params.reads,
|
|
1254
1282
|
);
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
const
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
});
|
|
1283
|
+
const organizationId = syncClient.getOrganizationId() ?? undefined;
|
|
1284
|
+
const ids: string[] = [];
|
|
1285
|
+
const operations = params.data.map((row) => {
|
|
1286
|
+
const fields = row as Record<string, unknown>;
|
|
1287
|
+
const id =
|
|
1288
|
+
resolveCreateId(undefined, fields) ??
|
|
1289
|
+
createModelId(
|
|
1290
|
+
registeredModelName,
|
|
1291
|
+
params.idempotencyKey ? `${params.idempotencyKey}:${ids.length}` : null,
|
|
1292
|
+
);
|
|
1293
|
+
ids.push(id);
|
|
1294
|
+
return {
|
|
1295
|
+
action: 'create' as const,
|
|
1296
|
+
model: registeredModelName,
|
|
1297
|
+
data: { organizationId: fields.organizationId ?? organizationId, ...fields, id },
|
|
1298
|
+
id,
|
|
1299
|
+
};
|
|
1300
|
+
});
|
|
1274
1301
|
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
: {}),
|
|
1283
|
-
...(prepared.reads
|
|
1284
|
-
? { reads: [...prepared.reads] }
|
|
1302
|
+
const receipt = await collaboration.commitBatch({
|
|
1303
|
+
operations,
|
|
1304
|
+
wait: 'confirmed',
|
|
1305
|
+
...(prepared.idempotencyKey
|
|
1306
|
+
? { idempotencyKey: prepared.idempotencyKey }
|
|
1307
|
+
: params.idempotencyKey
|
|
1308
|
+
? { idempotencyKey: params.idempotencyKey }
|
|
1285
1309
|
: {}),
|
|
1286
|
-
|
|
1287
|
-
|
|
1310
|
+
...(prepared.reads
|
|
1311
|
+
? { reads: [...prepared.reads] }
|
|
1312
|
+
: {}),
|
|
1313
|
+
});
|
|
1288
1314
|
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
readSetContext,
|
|
1300
|
-
readSetClientIdentity,
|
|
1301
|
-
prepared.consumed,
|
|
1302
|
-
prepared.automaticCommit,
|
|
1303
|
-
);
|
|
1304
|
-
return rows;
|
|
1305
|
-
} catch (error) {
|
|
1306
|
-
abortReadSetCommit(readSetContext, prepared.automaticCommit);
|
|
1307
|
-
throw error;
|
|
1308
|
-
}
|
|
1315
|
+
const rows = await resolveCreatedRows<T>({
|
|
1316
|
+
modelName: registeredModelName,
|
|
1317
|
+
ids,
|
|
1318
|
+
operationResults: receipt.operationResults,
|
|
1319
|
+
readRow: async (id) => {
|
|
1320
|
+
const read = await collaboration.readPoint(registeredModelName, id);
|
|
1321
|
+
return read.data as T | undefined;
|
|
1322
|
+
},
|
|
1323
|
+
});
|
|
1324
|
+
return rows;
|
|
1309
1325
|
};
|
|
1310
1326
|
|
|
1311
1327
|
// `create` takes one row or a list of them. The list form is atomic and
|
|
@@ -1366,12 +1382,9 @@ export function createModelProxy<T, C>(
|
|
|
1366
1382
|
createdAt: new Date(),
|
|
1367
1383
|
updatedAt: new Date(),
|
|
1368
1384
|
});
|
|
1369
|
-
let prepared: PreparedReadSet | undefined;
|
|
1370
1385
|
try {
|
|
1371
|
-
const resolved = preparedMutation(single);
|
|
1372
|
-
prepared = resolved.prepared;
|
|
1373
1386
|
const effective: MutationOptions = {
|
|
1374
|
-
...
|
|
1387
|
+
...preparedMutation(single),
|
|
1375
1388
|
...(autoLease
|
|
1376
1389
|
? {
|
|
1377
1390
|
claimRef: { id: autoLease.id },
|
|
@@ -1391,16 +1404,7 @@ export function createModelProxy<T, C>(
|
|
|
1391
1404
|
};
|
|
1392
1405
|
syncClient.add(model, effective);
|
|
1393
1406
|
await waitForMutation(model);
|
|
1394
|
-
consumeReadSet(
|
|
1395
|
-
readSetContext,
|
|
1396
|
-
readSetClientIdentity,
|
|
1397
|
-
prepared.consumed,
|
|
1398
|
-
prepared.automaticCommit,
|
|
1399
|
-
);
|
|
1400
1407
|
return modelAsRow<T>(model);
|
|
1401
|
-
} catch (error) {
|
|
1402
|
-
abortReadSetCommit(readSetContext, prepared?.automaticCommit ?? false);
|
|
1403
|
-
throw error;
|
|
1404
1408
|
} finally {
|
|
1405
1409
|
await autoLease?.release?.().catch(() => {});
|
|
1406
1410
|
}
|
|
@@ -1427,7 +1431,7 @@ export function createModelProxy<T, C>(
|
|
|
1427
1431
|
local,
|
|
1428
1432
|
|
|
1429
1433
|
get,
|
|
1430
|
-
|
|
1434
|
+
read,
|
|
1431
1435
|
|
|
1432
1436
|
// No automatic scope enrolment on bulk `list`: that would subscribe to an
|
|
1433
1437
|
// unbounded set of rows' entity groups.
|
|
@@ -1511,35 +1515,22 @@ export function createModelProxy<T, C>(
|
|
|
1511
1515
|
readSetContext,
|
|
1512
1516
|
readSetClientIdentity,
|
|
1513
1517
|
undefined,
|
|
1514
|
-
'reject',
|
|
1515
1518
|
undefined,
|
|
1516
1519
|
contention?.reads,
|
|
1517
1520
|
);
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
await waitForMutation(model);
|
|
1532
|
-
consumeReadSet(
|
|
1533
|
-
readSetContext,
|
|
1534
|
-
readSetClientIdentity,
|
|
1535
|
-
prepared.consumed,
|
|
1536
|
-
prepared.automaticCommit,
|
|
1537
|
-
);
|
|
1538
|
-
return modelAsRow<T>(model);
|
|
1539
|
-
} catch (error) {
|
|
1540
|
-
abortReadSetCommit(readSetContext, prepared.automaticCommit);
|
|
1541
|
-
throw error;
|
|
1542
|
-
}
|
|
1521
|
+
const effective: MutationOptions = {
|
|
1522
|
+
readAt: prepared.readAt ?? readAt,
|
|
1523
|
+
...(prepared.idempotencyKey
|
|
1524
|
+
? { idempotencyKey: prepared.idempotencyKey }
|
|
1525
|
+
: {}),
|
|
1526
|
+
...(prepared.reads !== undefined
|
|
1527
|
+
? { reads: prepared.reads === null ? null : [...prepared.reads] }
|
|
1528
|
+
: {}),
|
|
1529
|
+
};
|
|
1530
|
+
model.applyChanges(patch);
|
|
1531
|
+
syncClient.update(model, effective);
|
|
1532
|
+
await waitForMutation(model);
|
|
1533
|
+
return modelAsRow<T>(model);
|
|
1543
1534
|
},
|
|
1544
1535
|
});
|
|
1545
1536
|
}
|
|
@@ -1573,57 +1564,32 @@ export function createModelProxy<T, C>(
|
|
|
1573
1564
|
`Entity not found: ${registeredModelName}/${id}`,
|
|
1574
1565
|
{ code: 'entity_not_found' },
|
|
1575
1566
|
);
|
|
1576
|
-
|
|
1577
|
-
// watermark + lease so it's stale-rejected and attributed to the claim.
|
|
1578
|
-
const claimed = activeClaims.get(id);
|
|
1579
|
-
const resolved = preparedMutation(params);
|
|
1580
|
-
const opts = resolved.options;
|
|
1567
|
+
const opts = preparedMutation(params);
|
|
1581
1568
|
const handle = isClaimHandle(params.claim) ? params.claim : undefined;
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
}
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
...(handle?.readAt !== undefined
|
|
1597
|
-
? {
|
|
1598
|
-
readAt: handle.readAt,
|
|
1599
|
-
onStale: 'reject' as const,
|
|
1600
|
-
...(handle.fenceToken !== undefined
|
|
1601
|
-
? { fenceToken: handle.fenceToken }
|
|
1602
|
-
: {}),
|
|
1603
|
-
}
|
|
1604
|
-
: {}),
|
|
1605
|
-
...opts,
|
|
1606
|
-
...(handle ? { claimRef: { id: handle.id } } : {}),
|
|
1607
|
-
};
|
|
1569
|
+
// An exact carried handle always wins. Without one, the convenience
|
|
1570
|
+
// path may use the sole local claim on this row; several disjoint local
|
|
1571
|
+
// claims are intentionally ambiguous and require the caller to pass one.
|
|
1572
|
+
const claimed = handle ? activeClaims.get(handle.id) : implicitClaimForEntity(id);
|
|
1573
|
+
const selected = handle ?? claimed?.lease;
|
|
1574
|
+
const selectedReadAt = handle?.readAt ?? claimed?.readAt;
|
|
1575
|
+
const effective: MutationOptions = {
|
|
1576
|
+
...(selectedReadAt !== undefined ? { readAt: selectedReadAt } : {}),
|
|
1577
|
+
...(selected?.fenceToken !== undefined
|
|
1578
|
+
? { fenceToken: selected.fenceToken }
|
|
1579
|
+
: {}),
|
|
1580
|
+
...opts,
|
|
1581
|
+
...(selected ? { claimRef: { id: selected.id } } : {}),
|
|
1582
|
+
};
|
|
1608
1583
|
// Local user update: `applyChanges` keeps change tracking on so the
|
|
1609
1584
|
// edited fields land in `modifiedProperties` and are actually sent to
|
|
1610
1585
|
// the server. (`updateFromData` is the hydration path and would discard
|
|
1611
1586
|
// the tracking, producing an empty `input: {}` no-op mutation.)
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
readSetClientIdentity,
|
|
1619
|
-
resolved.prepared.consumed,
|
|
1620
|
-
resolved.prepared.automaticCommit,
|
|
1621
|
-
);
|
|
1622
|
-
return modelAsRow<T>(model);
|
|
1623
|
-
} catch (error) {
|
|
1624
|
-
abortReadSetCommit(readSetContext, resolved.prepared.automaticCommit);
|
|
1625
|
-
throw error;
|
|
1626
|
-
}
|
|
1587
|
+
model.applyChanges(params.data);
|
|
1588
|
+
syncClient.update(model, effective);
|
|
1589
|
+
await waitForMutation(model);
|
|
1590
|
+
const updated = modelAsRow<T>(model);
|
|
1591
|
+
await settleClaimsAfterWrite(id, handle);
|
|
1592
|
+
return updated;
|
|
1627
1593
|
},
|
|
1628
1594
|
);
|
|
1629
1595
|
function update(params: ModelUpdateParams<T, C>): Promise<T>;
|
|
@@ -1674,74 +1640,33 @@ export function createModelProxy<T, C>(
|
|
|
1674
1640
|
// no-op success rather than an `entity_not_found` error. This matches the
|
|
1675
1641
|
// HTTP client and makes delete safe to retry or race (two actors deleting
|
|
1676
1642
|
// the same row).
|
|
1677
|
-
if (!model)
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
const handle = isClaimHandle(params.claim) ? params.claim : undefined;
|
|
1682
|
-
const effective: MutationOptions | undefined = claimed
|
|
1683
|
-
? {
|
|
1684
|
-
readAt: claimed.lease.readAt ?? claimed.snapshot.stamp,
|
|
1685
|
-
onStale: 'reject',
|
|
1686
|
-
claimRef: { id: claimed.lease.id },
|
|
1687
|
-
...(claimed.lease.fenceToken !== undefined
|
|
1688
|
-
? { fenceToken: claimed.lease.fenceToken }
|
|
1689
|
-
: {}),
|
|
1690
|
-
...opts,
|
|
1691
|
-
}
|
|
1692
|
-
: {
|
|
1693
|
-
...(handle?.readAt !== undefined
|
|
1694
|
-
? {
|
|
1695
|
-
readAt: handle.readAt,
|
|
1696
|
-
onStale: 'reject' as const,
|
|
1697
|
-
...(handle.fenceToken !== undefined
|
|
1698
|
-
? { fenceToken: handle.fenceToken }
|
|
1699
|
-
: {}),
|
|
1700
|
-
}
|
|
1701
|
-
: {}),
|
|
1702
|
-
...opts,
|
|
1703
|
-
...(handle ? { claimRef: { id: handle.id } } : {}),
|
|
1704
|
-
};
|
|
1705
|
-
try {
|
|
1706
|
-
syncClient.delete(model, effective);
|
|
1707
|
-
await waitForMutation(model);
|
|
1708
|
-
consumeReadSet(
|
|
1709
|
-
readSetContext,
|
|
1710
|
-
readSetClientIdentity,
|
|
1711
|
-
resolved.prepared.consumed,
|
|
1712
|
-
resolved.prepared.automaticCommit,
|
|
1713
|
-
);
|
|
1714
|
-
} catch (error) {
|
|
1715
|
-
abortReadSetCommit(readSetContext, resolved.prepared.automaticCommit);
|
|
1716
|
-
throw error;
|
|
1643
|
+
if (!model) {
|
|
1644
|
+
const handle = isClaimHandle(params.claim) ? params.claim : undefined;
|
|
1645
|
+
await settleClaimsAfterWrite(id, handle);
|
|
1646
|
+
return;
|
|
1717
1647
|
}
|
|
1648
|
+
const opts = preparedMutation(params);
|
|
1649
|
+
const handle = isClaimHandle(params.claim) ? params.claim : undefined;
|
|
1650
|
+
const claimed = handle ? activeClaims.get(handle.id) : implicitClaimForEntity(id);
|
|
1651
|
+
const selected = handle ?? claimed?.lease;
|
|
1652
|
+
const selectedReadAt = handle?.readAt ?? claimed?.readAt;
|
|
1653
|
+
const effective: MutationOptions = {
|
|
1654
|
+
...(selectedReadAt !== undefined ? { readAt: selectedReadAt } : {}),
|
|
1655
|
+
...(selected?.fenceToken !== undefined
|
|
1656
|
+
? { fenceToken: selected.fenceToken }
|
|
1657
|
+
: {}),
|
|
1658
|
+
...opts,
|
|
1659
|
+
...(selected ? { claimRef: { id: selected.id } } : {}),
|
|
1660
|
+
};
|
|
1661
|
+
syncClient.delete(model, effective);
|
|
1662
|
+
await waitForMutation(model);
|
|
1663
|
+
await settleClaimsAfterWrite(id, handle);
|
|
1718
1664
|
}),
|
|
1719
1665
|
|
|
1720
1666
|
// `claim` is a callable namespace (take a claim) carrying the coordination
|
|
1721
1667
|
// readers (`claim.state` / `claim.queue` / `claim.release` / `claim.reorder`).
|
|
1722
1668
|
claim: claimApi,
|
|
1723
1669
|
|
|
1724
|
-
track: guard(async (params: ModelTrackParams): Promise<ModelTrackResult> => {
|
|
1725
|
-
const dep: TrackDependency = {
|
|
1726
|
-
model: wireModel,
|
|
1727
|
-
id: params.id,
|
|
1728
|
-
...(params.readAt !== undefined ? { readAt: params.readAt } : {}),
|
|
1729
|
-
};
|
|
1730
|
-
// A track carries no write, so it rides the commit lane as a zero-operation
|
|
1731
|
-
// commit: the queue tolerates disconnects and de-dupes replays, and the
|
|
1732
|
-
// server's track-only path registers the premise and reports anything
|
|
1733
|
-
// that already fired. Reuse the same lane the batch `commits.create` door
|
|
1734
|
-
// uses rather than opening a bespoke transport.
|
|
1735
|
-
const clientTxId =
|
|
1736
|
-
typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
|
|
1737
|
-
? crypto.randomUUID()
|
|
1738
|
-
: `tx_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
|
1739
|
-
const queue = syncClient.getMutationQueue();
|
|
1740
|
-
await queue.enqueueCommit(clientTxId, [], { track: [dep] });
|
|
1741
|
-
const { notifications } = await queue.waitForCommitReceipt(clientTxId);
|
|
1742
|
-
return notifications && notifications.length > 0 ? { notifications } : {};
|
|
1743
|
-
}),
|
|
1744
|
-
|
|
1745
1670
|
join: guard(
|
|
1746
1671
|
(
|
|
1747
1672
|
ids: string | readonly string[],
|