@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`.
|
|
@@ -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,15 +35,32 @@ import {
|
|
|
36
35
|
resolveHeartbeatOptions,
|
|
37
36
|
resolveHeartbeatPlan,
|
|
38
37
|
startClaimHeartbeatLoop,
|
|
39
|
-
} from '@abloatai/transaction/
|
|
40
|
-
import {
|
|
41
|
-
|
|
38
|
+
} from '@abloatai/transaction/claims';
|
|
39
|
+
import {
|
|
40
|
+
assertWriteOptions,
|
|
41
|
+
assertWriteTarget,
|
|
42
|
+
} from '@abloatai/transaction/client/resources/writeOptionsSchema';
|
|
43
|
+
import {
|
|
44
|
+
createModelId,
|
|
45
|
+
resolveCreatedRows,
|
|
46
|
+
resolveCreateId,
|
|
47
|
+
} from '@abloatai/transaction/client/resources/modelCreate';
|
|
48
|
+
import type {
|
|
49
|
+
CommitCreateOptions,
|
|
50
|
+
CommitReceipt,
|
|
51
|
+
} from '@abloatai/transaction/client/resources/httpResources';
|
|
52
|
+
import type { HttpModelMutationParams } from '@abloatai/transaction/transport/http';
|
|
53
|
+
import {
|
|
54
|
+
collectModelList,
|
|
55
|
+
modelList,
|
|
56
|
+
type ModelList,
|
|
57
|
+
} from '@abloatai/transaction/client/resources/httpResources';
|
|
42
58
|
import { subTarget } from '@abloatai/transaction/coordination';
|
|
43
59
|
// A named claim-meta crossing (see `claim-meta-crossings-are-enumerated` in
|
|
44
|
-
// .dependency-cruiser.cjs): the reactive
|
|
60
|
+
// .dependency-cruiser.cjs): the reactive model surface's self-claim targets are
|
|
45
61
|
// decodes that build a public claim, so their `meta` converts wire→declared
|
|
46
62
|
// here like the other enumerated crossings.
|
|
47
|
-
import { declaredMeta } from '@abloatai/transaction/
|
|
63
|
+
import { declaredMeta } from '@abloatai/transaction/claims';
|
|
48
64
|
import type { ModelTarget } from '@abloatai/transaction/coordination/schema';
|
|
49
65
|
import type { ModelRegistry } from '../ModelRegistry.js';
|
|
50
66
|
import type { InstanceCache } from '../InstanceCache.js';
|
|
@@ -61,23 +77,24 @@ import type {
|
|
|
61
77
|
HeldLease,
|
|
62
78
|
ClaimWaitOptions,
|
|
63
79
|
ClaimTarget,
|
|
64
|
-
Snapshot,
|
|
65
80
|
} from '@abloatai/transaction/types/streams';
|
|
81
|
+
import {
|
|
82
|
+
bindClaimLifetime,
|
|
83
|
+
claimLifetimeOf,
|
|
84
|
+
} from '@abloatai/transaction/claims/lifetime';
|
|
66
85
|
|
|
67
86
|
// The request contract — every option and parameter shape a caller passes to a
|
|
68
87
|
// read, a write, or a claim — lives in the confirmation core (ADR 0016). This
|
|
69
88
|
// factory binds it to reactive model instances; the shapes themselves are
|
|
70
|
-
// transport- and consumer-agnostic. Re-exported so `./
|
|
89
|
+
// transport- and consumer-agnostic. Re-exported so `./createModelOperations` stays a
|
|
71
90
|
// working import path for the whole surface.
|
|
72
91
|
export type {
|
|
73
92
|
ModelListScope,
|
|
74
|
-
ModelTrackParams,
|
|
75
|
-
ModelTrackResult,
|
|
76
93
|
LocalReadOptions,
|
|
77
94
|
LocalCountOptions,
|
|
78
95
|
ServerReadOptions,
|
|
79
|
-
|
|
80
|
-
|
|
96
|
+
ListAllOptions,
|
|
97
|
+
ServerPointReadOptions,
|
|
81
98
|
ClaimTargetOptions,
|
|
82
99
|
ClaimParams,
|
|
83
100
|
ClaimContentionOptions,
|
|
@@ -91,12 +108,12 @@ export type {
|
|
|
91
108
|
ClaimReadApi,
|
|
92
109
|
AwaitedClaimMethod,
|
|
93
110
|
ClaimApi,
|
|
94
|
-
|
|
111
|
+
ModelReadParams,
|
|
95
112
|
ModelCreateParams,
|
|
96
113
|
ModelUpdateParams,
|
|
97
114
|
ModelDeleteParams,
|
|
98
115
|
JoinOptions,
|
|
99
|
-
} from '@abloatai/transaction/resources/modelOperations';
|
|
116
|
+
} from '@abloatai/transaction/client/resources/modelOperations';
|
|
100
117
|
export type { Claim, ClaimHeartbeat, ClaimHeartbeatOptions, HeldClaim, HeldLease };
|
|
101
118
|
|
|
102
119
|
import type {
|
|
@@ -112,29 +129,26 @@ import type {
|
|
|
112
129
|
JoinOptions,
|
|
113
130
|
LocalCountOptions,
|
|
114
131
|
LocalReadOptions,
|
|
132
|
+
ModelCreateManyParams,
|
|
115
133
|
ModelCreateParams,
|
|
116
134
|
ModelDeleteParams,
|
|
117
|
-
|
|
118
|
-
ModelTrackParams,
|
|
119
|
-
ModelTrackResult,
|
|
135
|
+
ModelReadParams,
|
|
120
136
|
ModelUpdateParams,
|
|
121
137
|
ServerReadOptions,
|
|
122
|
-
|
|
138
|
+
ListAllOptions,
|
|
139
|
+
} from '@abloatai/transaction/client/resources/modelOperations';
|
|
123
140
|
import {
|
|
124
141
|
claimQueueView,
|
|
125
142
|
resolveClaimContentionOptions,
|
|
126
|
-
} from '@abloatai/transaction/resources/modelOperations';
|
|
143
|
+
} from '@abloatai/transaction/client/resources/modelOperations';
|
|
127
144
|
import type {
|
|
128
145
|
CapturedRow,
|
|
129
146
|
HttpModelClient,
|
|
130
|
-
} from '@abloatai/transaction/transport/
|
|
147
|
+
} from '@abloatai/transaction/transport/http';
|
|
131
148
|
import type { ParticipantKind } from '@abloatai/transaction/types/participant';
|
|
132
149
|
import {
|
|
133
|
-
abortReadSetCommit,
|
|
134
150
|
capturePointRead,
|
|
135
|
-
consumeReadSet,
|
|
136
151
|
prepareReadSet,
|
|
137
|
-
type PreparedReadSet,
|
|
138
152
|
type ReadSetContext,
|
|
139
153
|
} from '@abloatai/transaction/internal/read-set';
|
|
140
154
|
|
|
@@ -165,6 +179,17 @@ type EntityHalf = Pick<ModelTarget, 'model' | 'id'>;
|
|
|
165
179
|
export interface ModelCollaboration {
|
|
166
180
|
/** Exact point evidence from the HTTP read boundary (stamp captured before data). */
|
|
167
181
|
readPoint(model: string, id: string): Promise<{ data: unknown; stamp: number }>;
|
|
182
|
+
/**
|
|
183
|
+
* The batch commit lane, for a create handed a list of rows.
|
|
184
|
+
*
|
|
185
|
+
* A reactive client's single writes go through the mutation queue and land
|
|
186
|
+
* optimistically, because a rejected write rolls one row back. A batch
|
|
187
|
+
* cannot: it is atomic, so applying the rows one at a time would paint a
|
|
188
|
+
* half-written state the server may then decline whole. It goes down the
|
|
189
|
+
* same commit door the stateless client uses and the rows arrive on the
|
|
190
|
+
* ordinary stream, the way a teammate's would.
|
|
191
|
+
*/
|
|
192
|
+
commitBatch(options: CommitCreateOptions): Promise<CommitReceipt>;
|
|
168
193
|
createClaim(options: {
|
|
169
194
|
/**
|
|
170
195
|
* The locator, in the spelling the SDK surface and the HTTP routes use.
|
|
@@ -191,7 +216,8 @@ export interface ModelCollaboration {
|
|
|
191
216
|
/** Request-scoped queued / granted / skipped / failed status events. */
|
|
192
217
|
onStatus?: (event: ClaimAttemptEvent) => void;
|
|
193
218
|
}): Promise<Claim>;
|
|
194
|
-
|
|
219
|
+
/** Current applied/acked watermark, captured after a claim's fresh read. */
|
|
220
|
+
currentReadAt(): number;
|
|
195
221
|
/**
|
|
196
222
|
* Current coordination state on a target — who (if anyone) holds it.
|
|
197
223
|
* Synchronous reactive snapshot read off the presence/claim stream;
|
|
@@ -262,7 +288,7 @@ export interface ModelCollaboration {
|
|
|
262
288
|
* Opens a presence and claim subscription on this model's sync group(s) and
|
|
263
289
|
* returns the live participant handle. Backs `ablo.<model>.join(ids)`.
|
|
264
290
|
* WebSocket only, since presence needs a live socket; it is absent on other
|
|
265
|
-
* client constructions, where the
|
|
291
|
+
* client constructions, where the surface throws a clear error.
|
|
266
292
|
*/
|
|
267
293
|
createJoin?(
|
|
268
294
|
modelKey: string,
|
|
@@ -277,7 +303,7 @@ export interface ModelCollaboration {
|
|
|
277
303
|
*
|
|
278
304
|
* Every verb mirrors its asynchronous sibling on the base surface, and the one
|
|
279
305
|
* word in front is the whole difference. It is a narrowing, not a claim about
|
|
280
|
-
* the other side: `
|
|
306
|
+
* the other side: `read` consults the local graph and then the network,
|
|
281
307
|
* while `local.get` is restricted to what is already resident — which is
|
|
282
308
|
* also why it can return a value instead of a promise. There is nothing to
|
|
283
309
|
* await.
|
|
@@ -394,7 +420,7 @@ export type ModelOperations<T, CreateInput> = Omit<
|
|
|
394
420
|
> &
|
|
395
421
|
ReactiveModelSurface<T, CreateInput>;
|
|
396
422
|
|
|
397
|
-
export function
|
|
423
|
+
export function createModelOperations<T, C>(
|
|
398
424
|
schemaKey: string,
|
|
399
425
|
registeredModelName: string,
|
|
400
426
|
objectPool: Pick<InstanceCache, 'get' | 'getByType' | 'getOfType'>,
|
|
@@ -402,7 +428,6 @@ export function createModelProxy<T, C>(
|
|
|
402
428
|
SyncClient,
|
|
403
429
|
| 'add'
|
|
404
430
|
| 'delete'
|
|
405
|
-
| 'getMutationQueue'
|
|
406
431
|
| 'getOrganizationId'
|
|
407
432
|
| 'syncNow'
|
|
408
433
|
| 'update'
|
|
@@ -536,27 +561,66 @@ export function createModelProxy<T, C>(
|
|
|
536
561
|
await syncClient.waitForConfirmation(model.getModelName(), model.id);
|
|
537
562
|
};
|
|
538
563
|
|
|
539
|
-
// Claims this
|
|
540
|
-
//
|
|
541
|
-
//
|
|
542
|
-
//
|
|
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.
|
|
543
569
|
//
|
|
544
570
|
// `target`, `description`, and `expiresAt` are kept alongside the lease so
|
|
545
571
|
// `claim.state` can synthesize a self-claim: the server excludes a holder's
|
|
546
|
-
// own presence frames, so this
|
|
572
|
+
// own presence frames, so this surface is the only place that knows the client
|
|
547
573
|
// holds the row. `expiresAt` is the client's best estimate from the requested
|
|
548
574
|
// TTL (a real epoch-millisecond expiry, not a fabricated watermark), defaulting
|
|
549
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
|
+
|
|
550
586
|
const activeClaims = new Map<
|
|
551
587
|
string,
|
|
552
|
-
|
|
553
|
-
lease: Claim;
|
|
554
|
-
snapshot: Snapshot;
|
|
555
|
-
target: ClaimTarget;
|
|
556
|
-
description: string;
|
|
557
|
-
expiresAt: number;
|
|
558
|
-
}
|
|
588
|
+
ActiveClaim
|
|
559
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
|
+
};
|
|
560
624
|
|
|
561
625
|
// Server keepalive lease window — the same `LEASE_TTL_MS` the wire protocol
|
|
562
626
|
// declares, so the client's estimate and the server's lease cannot drift.
|
|
@@ -576,12 +640,11 @@ export function createModelProxy<T, C>(
|
|
|
576
640
|
| ModelCreateParams<T, C>
|
|
577
641
|
| ModelUpdateParams<T, C>
|
|
578
642
|
| ModelDeleteParams<T, C>,
|
|
579
|
-
):
|
|
643
|
+
): MutationOptions => {
|
|
580
644
|
const prepared = prepareReadSet(
|
|
581
645
|
readSetContext,
|
|
582
646
|
readSetClientIdentity,
|
|
583
|
-
|
|
584
|
-
params.onStale,
|
|
647
|
+
undefined,
|
|
585
648
|
params.idempotencyKey,
|
|
586
649
|
params.reads,
|
|
587
650
|
);
|
|
@@ -592,44 +655,47 @@ export function createModelProxy<T, C>(
|
|
|
592
655
|
? { idempotencyKey: params.idempotencyKey }
|
|
593
656
|
: {}),
|
|
594
657
|
...(params.label !== undefined ? { label: params.label } : {}),
|
|
595
|
-
...(prepared.readAt !== undefined
|
|
596
|
-
? { readAt: prepared.readAt }
|
|
597
|
-
: params.readAt !== undefined
|
|
598
|
-
? { readAt: params.readAt }
|
|
599
|
-
: {}),
|
|
600
|
-
...(prepared.onStale !== undefined
|
|
601
|
-
? { onStale: prepared.onStale }
|
|
602
|
-
: params.onStale !== undefined
|
|
603
|
-
? { onStale: params.onStale }
|
|
604
|
-
: {}),
|
|
605
|
-
...(params.fenceToken !== undefined ? { fenceToken: params.fenceToken } : {}),
|
|
606
|
-
...(params.claimRef !== undefined ? { claimRef: params.claimRef } : {}),
|
|
607
658
|
...(prepared.reads !== undefined
|
|
608
659
|
? { reads: prepared.reads === null ? null : [...prepared.reads] }
|
|
609
660
|
: params.reads !== undefined
|
|
610
661
|
? { reads: params.reads }
|
|
611
662
|
: {}),
|
|
612
|
-
...(params.track !== undefined ? { track: params.track } : {}),
|
|
613
663
|
};
|
|
614
664
|
// The write-options schema — the runtime twin of the compile-time params.
|
|
615
|
-
// Catches plain-JavaScript callers
|
|
616
|
-
//
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
} catch (error) {
|
|
620
|
-
abortReadSetCommit(readSetContext, prepared.automaticCommit);
|
|
621
|
-
throw error;
|
|
622
|
-
}
|
|
623
|
-
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;
|
|
624
669
|
};
|
|
625
670
|
|
|
626
|
-
const releaseClaim = async (
|
|
627
|
-
const held =
|
|
671
|
+
const releaseClaim = async (claimId: string): Promise<void> => {
|
|
672
|
+
const held = removeActiveClaim(claimId);
|
|
628
673
|
if (!held) return;
|
|
629
|
-
activeClaims.delete(id);
|
|
630
674
|
await held.lease.release?.();
|
|
631
675
|
};
|
|
632
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
|
+
|
|
633
699
|
const takeClaim = async (
|
|
634
700
|
params: ClaimParams<C>,
|
|
635
701
|
): Promise<HeldClaim<T> | null> => {
|
|
@@ -640,11 +706,6 @@ export function createModelProxy<T, C>(
|
|
|
640
706
|
);
|
|
641
707
|
}
|
|
642
708
|
const { id, ...options } = params;
|
|
643
|
-
// Read the local snapshot only to decide whether a post-grant re-read may
|
|
644
|
-
// be needed. Admission itself always goes to the server: a local presence
|
|
645
|
-
// snapshot may be stale or incomplete across instances.
|
|
646
|
-
const held = collaboration.state({ model: wireModel, id });
|
|
647
|
-
const contended = !!held && held.heldBy !== collaboration.selfParticipantId;
|
|
648
709
|
const contention = resolveClaimContentionOptions(options);
|
|
649
710
|
const failFast = !contention.wait;
|
|
650
711
|
|
|
@@ -708,25 +769,26 @@ export function createModelProxy<T, C>(
|
|
|
708
769
|
throw normalized;
|
|
709
770
|
}
|
|
710
771
|
|
|
711
|
-
//
|
|
712
|
-
//
|
|
713
|
-
//
|
|
714
|
-
//
|
|
715
|
-
//
|
|
716
|
-
//
|
|
717
|
-
//
|
|
718
|
-
//
|
|
719
|
-
//
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
}
|
|
728
|
-
|
|
729
|
-
|
|
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();
|
|
730
792
|
const description = claimDescription(options);
|
|
731
793
|
// The self-claim's `ClaimTarget` mirrors what a peer's `claim.state` would
|
|
732
794
|
// report (`state` maps `held.target.model` to `type`), so a holder and a
|
|
@@ -744,13 +806,15 @@ export function createModelProxy<T, C>(
|
|
|
744
806
|
const ttlMs =
|
|
745
807
|
options.ttl !== undefined ? toMs(options.ttl) : DEFAULT_LEASE_TTL_MS;
|
|
746
808
|
const expiresAt = Date.now() + ttlMs;
|
|
747
|
-
|
|
809
|
+
const active: ActiveClaim = {
|
|
810
|
+
entityId: id,
|
|
748
811
|
lease,
|
|
749
|
-
|
|
812
|
+
readAt,
|
|
750
813
|
target: selfTarget,
|
|
751
814
|
description,
|
|
752
815
|
expiresAt,
|
|
753
|
-
}
|
|
816
|
+
};
|
|
817
|
+
addActiveClaim(lease.id, active);
|
|
754
818
|
const { meta: targetMeta, ...targetNarrowed } = subTarget(options, schemaKey);
|
|
755
819
|
const target = {
|
|
756
820
|
type: schemaKey,
|
|
@@ -779,7 +843,7 @@ export function createModelProxy<T, C>(
|
|
|
779
843
|
ttl: resolved.ttl ?? options.ttl,
|
|
780
844
|
...(resolved.details !== undefined ? { details: resolved.details } : {}),
|
|
781
845
|
});
|
|
782
|
-
const held = activeClaims.get(id);
|
|
846
|
+
const held = activeClaims.get(lease.id);
|
|
783
847
|
if (held) held.expiresAt = beat.expiresAt;
|
|
784
848
|
plan.onBeat?.(beat);
|
|
785
849
|
return beat;
|
|
@@ -795,14 +859,19 @@ export function createModelProxy<T, C>(
|
|
|
795
859
|
})
|
|
796
860
|
: undefined;
|
|
797
861
|
|
|
862
|
+
active.stopHeartbeat = stopHeartbeatLoop;
|
|
863
|
+
const lifetime = claimLifetimeOf(lease);
|
|
864
|
+
lifetime?.onEnd(() => {
|
|
865
|
+
removeActiveClaim(lease.id);
|
|
866
|
+
});
|
|
867
|
+
|
|
798
868
|
const release = () => {
|
|
799
|
-
|
|
800
|
-
return releaseClaim(id);
|
|
869
|
+
return releaseClaim(lease.id);
|
|
801
870
|
};
|
|
802
|
-
|
|
871
|
+
const handle = {
|
|
803
872
|
object: 'claim',
|
|
804
873
|
id: lease.id,
|
|
805
|
-
readAt
|
|
874
|
+
readAt,
|
|
806
875
|
// The fencing token the server minted for this grant, forwarded from the
|
|
807
876
|
// lease so writes taken under this handle carry it (Option B).
|
|
808
877
|
...(lease.fenceToken !== undefined ? { fenceToken: lease.fenceToken } : {}),
|
|
@@ -815,7 +884,8 @@ export function createModelProxy<T, C>(
|
|
|
815
884
|
},
|
|
816
885
|
heartbeat,
|
|
817
886
|
[Symbol.asyncDispose]: release,
|
|
818
|
-
}
|
|
887
|
+
} satisfies HeldClaim<T>;
|
|
888
|
+
return lifetime ? bindClaimLifetime(handle, lifetime) : handle;
|
|
819
889
|
};
|
|
820
890
|
|
|
821
891
|
// The row-free sibling of `takeClaim`: locks a key by id alone, for a row that
|
|
@@ -878,11 +948,10 @@ export function createModelProxy<T, C>(
|
|
|
878
948
|
throw normalized;
|
|
879
949
|
}
|
|
880
950
|
|
|
881
|
-
// A
|
|
882
|
-
//
|
|
883
|
-
//
|
|
884
|
-
|
|
885
|
-
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();
|
|
886
955
|
const description = claimDescription(options);
|
|
887
956
|
const selfTarget: ClaimTarget = {
|
|
888
957
|
type: wireModel,
|
|
@@ -892,13 +961,15 @@ export function createModelProxy<T, C>(
|
|
|
892
961
|
const ttlMs =
|
|
893
962
|
options.ttl !== undefined ? toMs(options.ttl) : DEFAULT_LEASE_TTL_MS;
|
|
894
963
|
const expiresAt = Date.now() + ttlMs;
|
|
895
|
-
|
|
964
|
+
const active: ActiveClaim = {
|
|
965
|
+
entityId: id,
|
|
896
966
|
lease,
|
|
897
|
-
|
|
967
|
+
readAt,
|
|
898
968
|
target: selfTarget,
|
|
899
969
|
description,
|
|
900
970
|
expiresAt,
|
|
901
|
-
}
|
|
971
|
+
};
|
|
972
|
+
addActiveClaim(lease.id, active);
|
|
902
973
|
const target = {
|
|
903
974
|
type: schemaKey,
|
|
904
975
|
id,
|
|
@@ -923,7 +994,7 @@ export function createModelProxy<T, C>(
|
|
|
923
994
|
ttl: resolved.ttl ?? options.ttl,
|
|
924
995
|
...(resolved.details !== undefined ? { details: resolved.details } : {}),
|
|
925
996
|
});
|
|
926
|
-
const held = activeClaims.get(id);
|
|
997
|
+
const held = activeClaims.get(lease.id);
|
|
927
998
|
if (held) held.expiresAt = beat.expiresAt;
|
|
928
999
|
plan.onBeat?.(beat);
|
|
929
1000
|
return beat;
|
|
@@ -937,14 +1008,19 @@ export function createModelProxy<T, C>(
|
|
|
937
1008
|
})
|
|
938
1009
|
: undefined;
|
|
939
1010
|
|
|
1011
|
+
active.stopHeartbeat = stopHeartbeatLoop;
|
|
1012
|
+
const lifetime = claimLifetimeOf(lease);
|
|
1013
|
+
lifetime?.onEnd(() => {
|
|
1014
|
+
removeActiveClaim(lease.id);
|
|
1015
|
+
});
|
|
1016
|
+
|
|
940
1017
|
const release = () => {
|
|
941
|
-
|
|
942
|
-
return releaseClaim(id);
|
|
1018
|
+
return releaseClaim(lease.id);
|
|
943
1019
|
};
|
|
944
|
-
|
|
1020
|
+
const handle = {
|
|
945
1021
|
object: 'claim',
|
|
946
1022
|
id: lease.id,
|
|
947
|
-
readAt
|
|
1023
|
+
readAt,
|
|
948
1024
|
// Forward the grant's fencing token so writes under this row-free lease
|
|
949
1025
|
// carry it (Option B), exactly as the row-bearing claim does.
|
|
950
1026
|
...(lease.fenceToken !== undefined ? { fenceToken: lease.fenceToken } : {}),
|
|
@@ -956,7 +1032,8 @@ export function createModelProxy<T, C>(
|
|
|
956
1032
|
},
|
|
957
1033
|
heartbeat,
|
|
958
1034
|
[Symbol.asyncDispose]: release,
|
|
959
|
-
};
|
|
1035
|
+
} satisfies HeldLease;
|
|
1036
|
+
return lifetime ? bindClaimLifetime(handle, lifetime) : handle;
|
|
960
1037
|
};
|
|
961
1038
|
|
|
962
1039
|
// `claim` overloads on its first argument: an options object claims a synced
|
|
@@ -1005,10 +1082,8 @@ export function createModelProxy<T, C>(
|
|
|
1005
1082
|
* they do it through here so the two answers cannot describe the same
|
|
1006
1083
|
* holding differently.
|
|
1007
1084
|
*/
|
|
1008
|
-
const
|
|
1009
|
-
|
|
1010
|
-
if (!own) return null;
|
|
1011
|
-
return {
|
|
1085
|
+
const ownClaimStates = (id: string): Claim[] =>
|
|
1086
|
+
claimsForEntity(id).map((own) => ({
|
|
1012
1087
|
object: 'claim',
|
|
1013
1088
|
id: own.lease.id,
|
|
1014
1089
|
status: 'active',
|
|
@@ -1020,8 +1095,7 @@ export function createModelProxy<T, C>(
|
|
|
1020
1095
|
// Symmetric with the peer projection: a holder reading its own claim
|
|
1021
1096
|
// sees the same `meta` an observer does.
|
|
1022
1097
|
...(own.target.meta !== undefined ? { meta: own.target.meta } : {}),
|
|
1023
|
-
};
|
|
1024
|
-
};
|
|
1098
|
+
}));
|
|
1025
1099
|
|
|
1026
1100
|
const claimReaders = {
|
|
1027
1101
|
state(params: ClaimLookupParams<T>): Claim | null {
|
|
@@ -1035,7 +1109,7 @@ export function createModelProxy<T, C>(
|
|
|
1035
1109
|
// holds. Synthesize the active claim from the stored lease so the holder
|
|
1036
1110
|
// sees its own claim, honoring the documented contract on `claim.state`.
|
|
1037
1111
|
return (
|
|
1038
|
-
|
|
1112
|
+
ownClaimStates(params.id)[0] ??
|
|
1039
1113
|
collaboration?.state({ model: wireModel, id: params.id }) ??
|
|
1040
1114
|
null
|
|
1041
1115
|
);
|
|
@@ -1050,13 +1124,13 @@ export function createModelProxy<T, C>(
|
|
|
1050
1124
|
*/
|
|
1051
1125
|
list(params: ClaimLookupParams<T>): { readonly object: 'list'; readonly data: readonly Claim[] } {
|
|
1052
1126
|
void collaboration?.enterScope?.({ [schemaKey]: params.id });
|
|
1053
|
-
const own =
|
|
1127
|
+
const own = ownClaimStates(params.id);
|
|
1054
1128
|
const peers = collaboration?.holders({ model: wireModel, id: params.id }) ?? [];
|
|
1055
1129
|
return {
|
|
1056
1130
|
object: 'list',
|
|
1057
1131
|
// Own claim first: the server excludes a holder's own presence frames,
|
|
1058
1132
|
// so it is never among `peers` and the two never duplicate.
|
|
1059
|
-
data:
|
|
1133
|
+
data: [...own, ...peers],
|
|
1060
1134
|
};
|
|
1061
1135
|
},
|
|
1062
1136
|
|
|
@@ -1071,7 +1145,9 @@ export function createModelProxy<T, C>(
|
|
|
1071
1145
|
},
|
|
1072
1146
|
|
|
1073
1147
|
release: guard((params: ClaimLookupParams<T> | Claim<T>): Promise<void> =>
|
|
1074
|
-
|
|
1148
|
+
isClaimHandle(params)
|
|
1149
|
+
? releaseClaim(params.id)
|
|
1150
|
+
: releaseClaimsForEntity(params.id),
|
|
1075
1151
|
),
|
|
1076
1152
|
};
|
|
1077
1153
|
|
|
@@ -1130,8 +1206,10 @@ export function createModelProxy<T, C>(
|
|
|
1130
1206
|
},
|
|
1131
1207
|
};
|
|
1132
1208
|
|
|
1133
|
-
const
|
|
1134
|
-
|
|
1209
|
+
const pointRead = async (
|
|
1210
|
+
params: ModelReadParams,
|
|
1211
|
+
capture: boolean,
|
|
1212
|
+
): Promise<T | undefined> => {
|
|
1135
1213
|
// Read-interest enrolment: authoritative point reads enter the same
|
|
1136
1214
|
// entity scope as the claim stream, while remaining settled reads.
|
|
1137
1215
|
void collaboration?.enterScope?.({ [schemaKey]: params.id });
|
|
@@ -1140,77 +1218,125 @@ export function createModelProxy<T, C>(
|
|
|
1140
1218
|
where: [['id', params.id]],
|
|
1141
1219
|
limit: 1,
|
|
1142
1220
|
});
|
|
1143
|
-
if (
|
|
1144
|
-
const
|
|
1145
|
-
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;
|
|
1146
1225
|
capturePointRead(
|
|
1147
1226
|
readSetContext,
|
|
1148
1227
|
readSetClientIdentity,
|
|
1149
1228
|
wireModel,
|
|
1150
1229
|
params.id,
|
|
1151
1230
|
data,
|
|
1152
|
-
|
|
1231
|
+
result.stamp,
|
|
1153
1232
|
);
|
|
1154
|
-
return data
|
|
1233
|
+
return data;
|
|
1155
1234
|
}
|
|
1156
|
-
return rows[0]
|
|
1157
|
-
|
|
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>,
|
|
1158
1245
|
);
|
|
1159
1246
|
|
|
1160
1247
|
const list = guard(async (
|
|
1161
1248
|
options?: ServerReadOptions<T>,
|
|
1162
|
-
): Promise<ModelList<
|
|
1163
|
-
const registry = readSetContext?.getStore();
|
|
1249
|
+
): Promise<ModelList<T>> => {
|
|
1164
1250
|
const rows = await load(options);
|
|
1165
1251
|
// This transport loads a working set rather than pages, so there is no
|
|
1166
1252
|
// cursor to hand back. `limit` can still cut the set short, and a full
|
|
1167
1253
|
// count is exactly the case where the caller cannot tell: report it rather
|
|
1168
1254
|
// than claim completeness this read cannot vouch for.
|
|
1169
|
-
|
|
1255
|
+
return modelList<T>(rows, {
|
|
1170
1256
|
hasMore: options?.limit !== undefined && rows.length >= options.limit,
|
|
1171
1257
|
nextCursor: null,
|
|
1172
1258
|
});
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
);
|
|
1188
|
-
}
|
|
1189
|
-
capturePointRead(
|
|
1190
|
-
readSetContext,
|
|
1191
|
-
readSetClientIdentity,
|
|
1192
|
-
wireModel,
|
|
1193
|
-
id,
|
|
1194
|
-
row,
|
|
1195
|
-
stamp,
|
|
1259
|
+
});
|
|
1260
|
+
|
|
1261
|
+
/**
|
|
1262
|
+
* Creates many rows as one atomic commit, and returns them in caller order.
|
|
1263
|
+
*/
|
|
1264
|
+
const createManyRows = async (
|
|
1265
|
+
params: HttpModelMutationParams<ModelCreateManyParams<C>>,
|
|
1266
|
+
): Promise<T[]> => {
|
|
1267
|
+
if (params.data.length === 0) return [];
|
|
1268
|
+
if (!collaboration) {
|
|
1269
|
+
throw new AbloValidationError(
|
|
1270
|
+
`Model "${schemaKey}" was built without the collaboration runtime, so a batch ` +
|
|
1271
|
+
`create is unavailable here. Use the standard Ablo({ schema, apiKey }) client.`,
|
|
1272
|
+
{ code: 'model_claim_not_configured' },
|
|
1196
1273
|
);
|
|
1197
1274
|
}
|
|
1198
|
-
return page;
|
|
1199
|
-
});
|
|
1200
1275
|
|
|
1201
|
-
|
|
1202
|
-
|
|
1276
|
+
const prepared = prepareReadSet(
|
|
1277
|
+
readSetContext,
|
|
1278
|
+
readSetClientIdentity,
|
|
1279
|
+
undefined,
|
|
1280
|
+
params.idempotencyKey,
|
|
1281
|
+
params.reads,
|
|
1282
|
+
);
|
|
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
|
+
});
|
|
1203
1301
|
|
|
1204
|
-
|
|
1205
|
-
|
|
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 }
|
|
1309
|
+
: {}),
|
|
1310
|
+
...(prepared.reads
|
|
1311
|
+
? { reads: [...prepared.reads] }
|
|
1312
|
+
: {}),
|
|
1313
|
+
});
|
|
1206
1314
|
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
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;
|
|
1325
|
+
};
|
|
1210
1326
|
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1327
|
+
// `create` takes one row or a list of them. The list form is atomic and
|
|
1328
|
+
// is therefore NOT applied optimistically: see `createManyRows`.
|
|
1329
|
+
const createImpl = guardWrite(async (
|
|
1330
|
+
params:
|
|
1331
|
+
| HttpModelMutationParams<ModelCreateParams<T, C>>
|
|
1332
|
+
| HttpModelMutationParams<ModelCreateManyParams<C>>,
|
|
1333
|
+
): Promise<T | T[]> => {
|
|
1334
|
+
if (Array.isArray(params.data)) {
|
|
1335
|
+
return createManyRows(params as HttpModelMutationParams<ModelCreateManyParams<C>>);
|
|
1336
|
+
}
|
|
1337
|
+
const single = params as ModelCreateParams<T, C>;
|
|
1338
|
+
const id = resolveCreateId(single.id, single.data) ?? Model.generateId();
|
|
1339
|
+
const claim = single.claim;
|
|
1214
1340
|
let autoLease: Claim | undefined;
|
|
1215
1341
|
if (claim && !isClaimHandle(claim)) {
|
|
1216
1342
|
if (!collaboration) {
|
|
@@ -1256,12 +1382,9 @@ export function createModelProxy<T, C>(
|
|
|
1256
1382
|
createdAt: new Date(),
|
|
1257
1383
|
updatedAt: new Date(),
|
|
1258
1384
|
});
|
|
1259
|
-
let prepared: PreparedReadSet | undefined;
|
|
1260
1385
|
try {
|
|
1261
|
-
const resolved = preparedMutation(params);
|
|
1262
|
-
prepared = resolved.prepared;
|
|
1263
1386
|
const effective: MutationOptions = {
|
|
1264
|
-
...
|
|
1387
|
+
...preparedMutation(single),
|
|
1265
1388
|
...(autoLease
|
|
1266
1389
|
? {
|
|
1267
1390
|
claimRef: { id: autoLease.id },
|
|
@@ -1281,21 +1404,46 @@ export function createModelProxy<T, C>(
|
|
|
1281
1404
|
};
|
|
1282
1405
|
syncClient.add(model, effective);
|
|
1283
1406
|
await waitForMutation(model);
|
|
1284
|
-
consumeReadSet(
|
|
1285
|
-
readSetContext,
|
|
1286
|
-
readSetClientIdentity,
|
|
1287
|
-
prepared.consumed,
|
|
1288
|
-
prepared.automaticCommit,
|
|
1289
|
-
);
|
|
1290
1407
|
return modelAsRow<T>(model);
|
|
1291
|
-
} catch (error) {
|
|
1292
|
-
abortReadSetCommit(readSetContext, prepared?.automaticCommit ?? false);
|
|
1293
|
-
throw error;
|
|
1294
1408
|
} finally {
|
|
1295
1409
|
await autoLease?.release?.().catch(() => {});
|
|
1296
1410
|
}
|
|
1411
|
+
});
|
|
1412
|
+
|
|
1413
|
+
// Two public signatures over one implementation. A property arrow would
|
|
1414
|
+
// collapse them to their union, and a single create would start
|
|
1415
|
+
// resolving to `T | T[]` for every caller.
|
|
1416
|
+
function createRows(
|
|
1417
|
+
params: HttpModelMutationParams<ModelCreateParams<T, C>>,
|
|
1418
|
+
): Promise<T>;
|
|
1419
|
+
function createRows(
|
|
1420
|
+
params: HttpModelMutationParams<ModelCreateManyParams<C>>,
|
|
1421
|
+
): Promise<T[]>;
|
|
1422
|
+
function createRows(
|
|
1423
|
+
params:
|
|
1424
|
+
| HttpModelMutationParams<ModelCreateParams<T, C>>
|
|
1425
|
+
| HttpModelMutationParams<ModelCreateManyParams<C>>,
|
|
1426
|
+
): Promise<T | T[]> {
|
|
1427
|
+
return createImpl(params);
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
const operations: ModelOperations<T, C> = {
|
|
1431
|
+
local,
|
|
1432
|
+
|
|
1433
|
+
get,
|
|
1434
|
+
read,
|
|
1435
|
+
|
|
1436
|
+
// No automatic scope enrolment on bulk `list`: that would subscribe to an
|
|
1437
|
+
// unbounded set of rows' entity groups.
|
|
1438
|
+
list,
|
|
1439
|
+
listAll: guard(async (options: ListAllOptions<T> = {}) => {
|
|
1440
|
+
const { maxPages, signal, ...readOptions } = options;
|
|
1441
|
+
signal?.throwIfAborted();
|
|
1442
|
+
return collectModelList(await list(readOptions), { maxPages, signal });
|
|
1297
1443
|
}),
|
|
1298
1444
|
|
|
1445
|
+
create: createRows,
|
|
1446
|
+
|
|
1299
1447
|
// `update` is overloaded — classic `update({ id, data })` + functional
|
|
1300
1448
|
// `update(id, current => next)`. The IIFE keeps the shared error-guard
|
|
1301
1449
|
// wrapping while exposing the two public signatures (a plain `guard(...)`
|
|
@@ -1367,39 +1515,30 @@ export function createModelProxy<T, C>(
|
|
|
1367
1515
|
readSetContext,
|
|
1368
1516
|
readSetClientIdentity,
|
|
1369
1517
|
undefined,
|
|
1370
|
-
'reject',
|
|
1371
1518
|
undefined,
|
|
1372
1519
|
contention?.reads,
|
|
1373
1520
|
);
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
await waitForMutation(model);
|
|
1388
|
-
consumeReadSet(
|
|
1389
|
-
readSetContext,
|
|
1390
|
-
readSetClientIdentity,
|
|
1391
|
-
prepared.consumed,
|
|
1392
|
-
prepared.automaticCommit,
|
|
1393
|
-
);
|
|
1394
|
-
return modelAsRow<T>(model);
|
|
1395
|
-
} catch (error) {
|
|
1396
|
-
abortReadSetCommit(readSetContext, prepared.automaticCommit);
|
|
1397
|
-
throw error;
|
|
1398
|
-
}
|
|
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);
|
|
1399
1534
|
},
|
|
1400
1535
|
});
|
|
1401
1536
|
}
|
|
1402
1537
|
const params = arg;
|
|
1538
|
+
// Named before anything reads it. Without this the row lookup below
|
|
1539
|
+
// reports `Entity not found: Model/undefined`, which sends the reader
|
|
1540
|
+
// looking for a missing row rather than at the unaddressed write.
|
|
1541
|
+
assertWriteTarget('update', registeredModelName, params.id);
|
|
1403
1542
|
const autoClaim =
|
|
1404
1543
|
params.claim && !isClaimHandle(params.claim) ? params.claim : null;
|
|
1405
1544
|
if (autoClaim) {
|
|
@@ -1425,57 +1564,32 @@ export function createModelProxy<T, C>(
|
|
|
1425
1564
|
`Entity not found: ${registeredModelName}/${id}`,
|
|
1426
1565
|
{ code: 'entity_not_found' },
|
|
1427
1566
|
);
|
|
1428
|
-
|
|
1429
|
-
// watermark + lease so it's stale-rejected and attributed to the claim.
|
|
1430
|
-
const claimed = activeClaims.get(id);
|
|
1431
|
-
const resolved = preparedMutation(params);
|
|
1432
|
-
const opts = resolved.options;
|
|
1567
|
+
const opts = preparedMutation(params);
|
|
1433
1568
|
const handle = isClaimHandle(params.claim) ? params.claim : undefined;
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
}
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
...(handle?.readAt !== undefined
|
|
1449
|
-
? {
|
|
1450
|
-
readAt: handle.readAt,
|
|
1451
|
-
onStale: 'reject' as const,
|
|
1452
|
-
...(handle.fenceToken !== undefined
|
|
1453
|
-
? { fenceToken: handle.fenceToken }
|
|
1454
|
-
: {}),
|
|
1455
|
-
}
|
|
1456
|
-
: {}),
|
|
1457
|
-
...opts,
|
|
1458
|
-
...(handle ? { claimRef: { id: handle.id } } : {}),
|
|
1459
|
-
};
|
|
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
|
+
};
|
|
1460
1583
|
// Local user update: `applyChanges` keeps change tracking on so the
|
|
1461
1584
|
// edited fields land in `modifiedProperties` and are actually sent to
|
|
1462
1585
|
// the server. (`updateFromData` is the hydration path and would discard
|
|
1463
1586
|
// the tracking, producing an empty `input: {}` no-op mutation.)
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
readSetClientIdentity,
|
|
1471
|
-
resolved.prepared.consumed,
|
|
1472
|
-
resolved.prepared.automaticCommit,
|
|
1473
|
-
);
|
|
1474
|
-
return modelAsRow<T>(model);
|
|
1475
|
-
} catch (error) {
|
|
1476
|
-
abortReadSetCommit(readSetContext, resolved.prepared.automaticCommit);
|
|
1477
|
-
throw error;
|
|
1478
|
-
}
|
|
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;
|
|
1479
1593
|
},
|
|
1480
1594
|
);
|
|
1481
1595
|
function update(params: ModelUpdateParams<T, C>): Promise<T>;
|
|
@@ -1495,6 +1609,9 @@ export function createModelProxy<T, C>(
|
|
|
1495
1609
|
})(),
|
|
1496
1610
|
|
|
1497
1611
|
delete: guardWrite(async (params: ModelDeleteParams<T, C>): Promise<void> => {
|
|
1612
|
+
// Before the idempotent "ensure absent" below can read this as a row that
|
|
1613
|
+
// is simply not here. An unaddressed delete is a mistake, not an absence.
|
|
1614
|
+
assertWriteTarget('delete', registeredModelName, params.id);
|
|
1498
1615
|
const autoClaim =
|
|
1499
1616
|
params.claim && !isClaimHandle(params.claim) ? params.claim : null;
|
|
1500
1617
|
if (autoClaim) {
|
|
@@ -1523,74 +1640,33 @@ export function createModelProxy<T, C>(
|
|
|
1523
1640
|
// no-op success rather than an `entity_not_found` error. This matches the
|
|
1524
1641
|
// HTTP client and makes delete safe to retry or race (two actors deleting
|
|
1525
1642
|
// the same row).
|
|
1526
|
-
if (!model)
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
const handle = isClaimHandle(params.claim) ? params.claim : undefined;
|
|
1531
|
-
const effective: MutationOptions | undefined = claimed
|
|
1532
|
-
? {
|
|
1533
|
-
readAt: claimed.lease.readAt ?? claimed.snapshot.stamp,
|
|
1534
|
-
onStale: 'reject',
|
|
1535
|
-
claimRef: { id: claimed.lease.id },
|
|
1536
|
-
...(claimed.lease.fenceToken !== undefined
|
|
1537
|
-
? { fenceToken: claimed.lease.fenceToken }
|
|
1538
|
-
: {}),
|
|
1539
|
-
...opts,
|
|
1540
|
-
}
|
|
1541
|
-
: {
|
|
1542
|
-
...(handle?.readAt !== undefined
|
|
1543
|
-
? {
|
|
1544
|
-
readAt: handle.readAt,
|
|
1545
|
-
onStale: 'reject' as const,
|
|
1546
|
-
...(handle.fenceToken !== undefined
|
|
1547
|
-
? { fenceToken: handle.fenceToken }
|
|
1548
|
-
: {}),
|
|
1549
|
-
}
|
|
1550
|
-
: {}),
|
|
1551
|
-
...opts,
|
|
1552
|
-
...(handle ? { claimRef: { id: handle.id } } : {}),
|
|
1553
|
-
};
|
|
1554
|
-
try {
|
|
1555
|
-
syncClient.delete(model, effective);
|
|
1556
|
-
await waitForMutation(model);
|
|
1557
|
-
consumeReadSet(
|
|
1558
|
-
readSetContext,
|
|
1559
|
-
readSetClientIdentity,
|
|
1560
|
-
resolved.prepared.consumed,
|
|
1561
|
-
resolved.prepared.automaticCommit,
|
|
1562
|
-
);
|
|
1563
|
-
} catch (error) {
|
|
1564
|
-
abortReadSetCommit(readSetContext, resolved.prepared.automaticCommit);
|
|
1565
|
-
throw error;
|
|
1643
|
+
if (!model) {
|
|
1644
|
+
const handle = isClaimHandle(params.claim) ? params.claim : undefined;
|
|
1645
|
+
await settleClaimsAfterWrite(id, handle);
|
|
1646
|
+
return;
|
|
1566
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);
|
|
1567
1664
|
}),
|
|
1568
1665
|
|
|
1569
1666
|
// `claim` is a callable namespace (take a claim) carrying the coordination
|
|
1570
1667
|
// readers (`claim.state` / `claim.queue` / `claim.release` / `claim.reorder`).
|
|
1571
1668
|
claim: claimApi,
|
|
1572
1669
|
|
|
1573
|
-
track: guard(async (params: ModelTrackParams): Promise<ModelTrackResult> => {
|
|
1574
|
-
const dep: TrackDependency = {
|
|
1575
|
-
model: wireModel,
|
|
1576
|
-
id: params.id,
|
|
1577
|
-
...(params.readAt !== undefined ? { readAt: params.readAt } : {}),
|
|
1578
|
-
};
|
|
1579
|
-
// A track carries no write, so it rides the commit lane as a zero-operation
|
|
1580
|
-
// commit: the queue tolerates disconnects and de-dupes replays, and the
|
|
1581
|
-
// server's track-only path registers the premise and reports anything
|
|
1582
|
-
// that already fired. Reuse the same lane the batch `commits.create` door
|
|
1583
|
-
// uses rather than opening a bespoke transport.
|
|
1584
|
-
const clientTxId =
|
|
1585
|
-
typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
|
|
1586
|
-
? crypto.randomUUID()
|
|
1587
|
-
: `tx_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
|
1588
|
-
const queue = syncClient.getMutationQueue();
|
|
1589
|
-
await queue.enqueueCommit(clientTxId, [], { track: [dep] });
|
|
1590
|
-
const { notifications } = await queue.waitForCommitReceipt(clientTxId);
|
|
1591
|
-
return notifications && notifications.length > 0 ? { notifications } : {};
|
|
1592
|
-
}),
|
|
1593
|
-
|
|
1594
1670
|
join: guard(
|
|
1595
1671
|
(
|
|
1596
1672
|
ids: string | readonly string[],
|