@abloatai/humans 0.56.0 → 0.57.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/local/Model.js +1 -1
- package/dist/local/SyncClient.d.ts +3 -34
- package/dist/local/SyncClient.js +1 -16
- package/dist/local/client/createModelProxy.d.ts +13 -1
- package/dist/local/client/createModelProxy.js +159 -80
- package/dist/local/client/reactiveEngine.js +5 -1
- package/dist/local/client/wsMutationExecutor.js +1 -0
- package/dist/local/syncClientTypes.d.ts +41 -0
- package/dist/local/syncClientTypes.js +11 -0
- package/dist/local/transactions/mutations/MutationQueue.d.ts +2 -0
- package/dist/local/transactions/mutations/MutationQueue.js +54 -69
- package/dist/local/transactions/mutations/commitLane.d.ts +4 -1
- package/dist/local/transactions/mutations/commitLane.js +12 -3
- package/dist/local/transactions/mutations/mutationInput.d.ts +40 -0
- package/dist/local/transactions/mutations/mutationInput.js +53 -0
- package/dist/surface.d.ts +1 -1
- package/dist/surface.js +1 -0
- package/package.json +2 -2
- package/src/local/Database.ts +0 -1
- package/src/local/Model.ts +1 -1
- package/src/local/SyncClient.ts +12 -72
- package/src/local/client/createModelProxy.ts +164 -13
- package/src/local/client/reactiveEngine.ts +5 -1
- package/src/local/client/wsMutationExecutor.ts +1 -0
- package/src/local/syncClientTypes.ts +59 -0
- package/src/local/transactions/mutations/MutationQueue.ts +61 -88
- package/src/local/transactions/mutations/commitLane.ts +23 -5
- package/src/local/transactions/mutations/mutationInput.ts +69 -0
- package/src/surface.ts +1 -0
package/dist/local/Model.js
CHANGED
|
@@ -936,7 +936,7 @@ export class Model {
|
|
|
936
936
|
throw new AbloValidationError('Model identifier (__typename, __class, or modelName) not found in data', { code: 'model_identifier_missing' });
|
|
937
937
|
}
|
|
938
938
|
// Try to get model class by identifier
|
|
939
|
-
|
|
939
|
+
const ModelClass = getActiveRegistry().getModelByName(modelIdentifier);
|
|
940
940
|
if (!ModelClass) {
|
|
941
941
|
throw new AbloValidationError(`Model class not found for: ${modelIdentifier}`, { code: 'model_class_not_registered' });
|
|
942
942
|
}
|
|
@@ -17,41 +17,11 @@ import { type CommitLatencySample } from './transactions/mutations/commitLatency
|
|
|
17
17
|
import { type UnconfirmedWritesMetrics } from './transactions/mutations/UnconfirmedWrites.js';
|
|
18
18
|
import type { DurableWriteStore } from './transactions/mutations/durableWriteStore.js';
|
|
19
19
|
import type { Database } from './Database.js';
|
|
20
|
-
import type { BootstrapData } from './sync/BootstrapFetcher.js';
|
|
21
20
|
import type { WriteOptions } from './interfaces/index.js';
|
|
22
21
|
import { LogPosition } from './logPosition.js';
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
}
|
|
26
|
-
interface SyncEvent {
|
|
27
|
-
type: 'create' | 'update' | 'delete' | 'archive' | 'rollback';
|
|
28
|
-
modelType: string;
|
|
29
|
-
model?: Model;
|
|
30
|
-
modelId?: string;
|
|
31
|
-
transactionType?: string;
|
|
32
|
-
}
|
|
33
|
-
interface SyncState {
|
|
34
|
-
connectionState: 'connected' | 'disconnected' | 'connecting';
|
|
35
|
-
pendingMutations: number;
|
|
36
|
-
lastSyncAt?: Date;
|
|
37
|
-
error?: Error;
|
|
38
|
-
}
|
|
39
|
-
export interface RehydrationStats {
|
|
40
|
-
added: number;
|
|
41
|
-
updated: number;
|
|
42
|
-
removed: number;
|
|
43
|
-
skipped: number;
|
|
44
|
-
healed: number;
|
|
45
|
-
elapsedMs: number;
|
|
46
|
-
}
|
|
47
|
-
/**
|
|
48
|
-
* The slice of a bootstrap answer the pool applies: its rows, the models whose
|
|
49
|
-
* server query failed, and the log position the snapshot was taken at — the
|
|
50
|
-
* position every row in it reflects. `lastSyncId` is optional only for callers
|
|
51
|
-
* applying rows with no snapshot position to speak of; the fetcher always
|
|
52
|
-
* names one.
|
|
53
|
-
*/
|
|
54
|
-
export type BootstrapSnapshot = Pick<BootstrapData, 'models' | 'failedModels'> & Partial<Pick<BootstrapData, 'lastSyncId'>>;
|
|
22
|
+
import { type RehydrationStats, type SyncObserver, type SyncState } from './syncClientTypes.js';
|
|
23
|
+
import type { BootstrapSnapshot } from './syncClientTypes.js';
|
|
24
|
+
export type { BootstrapSnapshot, RehydrationStats } from './syncClientTypes.js';
|
|
55
25
|
export declare class SyncClient extends EventEmitter {
|
|
56
26
|
private readonly runtime;
|
|
57
27
|
private objectPool;
|
|
@@ -533,4 +503,3 @@ export declare class SyncClient extends EventEmitter {
|
|
|
533
503
|
healed: number;
|
|
534
504
|
};
|
|
535
505
|
}
|
|
536
|
-
export {};
|
package/dist/local/SyncClient.js
CHANGED
|
@@ -24,22 +24,7 @@ import { LogPosition } from './logPosition.js';
|
|
|
24
24
|
import { createLocalMutationPort } from './transactions/localMutation.js';
|
|
25
25
|
import { createReconnectDrain } from './transactions/reconnectDrain.js';
|
|
26
26
|
import { DatabaseCommitOutboxStore } from './transactions/databaseCommitOutbox.js';
|
|
27
|
-
|
|
28
|
-
* Converts an untyped server `updatedAt` value — an ISO string, epoch number,
|
|
29
|
-
* or Date read off an untyped row — into epoch milliseconds for
|
|
30
|
-
* last-write-wins comparison. Falsy or non-date values become 0, matching the
|
|
31
|
-
* conflict resolver's rule that a missing timestamp sorts as the epoch.
|
|
32
|
-
*/
|
|
33
|
-
function toEpochMs(value) {
|
|
34
|
-
if (!value)
|
|
35
|
-
return 0;
|
|
36
|
-
if (value instanceof Date)
|
|
37
|
-
return value.getTime();
|
|
38
|
-
if (typeof value === 'string' || typeof value === 'number') {
|
|
39
|
-
return new Date(value).getTime();
|
|
40
|
-
}
|
|
41
|
-
return 0;
|
|
42
|
-
}
|
|
27
|
+
import { toEpochMs, } from './syncClientTypes.js';
|
|
43
28
|
export class SyncClient extends EventEmitter {
|
|
44
29
|
runtime;
|
|
45
30
|
objectPool;
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* The factory returns a plain object; the client assembles the `ablo.<model>`
|
|
11
11
|
* lookup table from one of these per model.
|
|
12
12
|
*/
|
|
13
|
+
import type { CommitCreateOptions, CommitReceipt } from '@abloatai/transaction/resources/httpResources';
|
|
13
14
|
import type { ModelTarget } from '@abloatai/transaction/coordination/schema';
|
|
14
15
|
import type { ModelRegistry } from '../ModelRegistry.js';
|
|
15
16
|
import type { InstanceCache } from '../InstanceCache.js';
|
|
@@ -17,7 +18,7 @@ import type { SyncClient } from '../SyncClient.js';
|
|
|
17
18
|
import type { OnDemandLoader } from '../sync/OnDemandLoader.js';
|
|
18
19
|
import type { JoinedParticipant } from '../sync/participants.js';
|
|
19
20
|
import type { Duration, Claim, ClaimHeartbeat, ClaimHeartbeatOptions, HeldClaim, HeldLease, ClaimWaitOptions, Snapshot } from '@abloatai/transaction/types/streams';
|
|
20
|
-
export type { ModelListScope, ModelTrackParams, ModelTrackResult, LocalReadOptions, LocalCountOptions, ServerReadOptions, ServerGetOptions, ServerRetrieveOptions, ClaimTargetOptions, ClaimParams, ClaimContentionOptions, ClaimAttemptEvent, ClaimQueueView, ClaimSkipOptions, ClaimSkipParams, ClaimLookupParams, ClaimReorderParams, ClaimOptions, ClaimReadApi, AwaitedClaimMethod, ClaimApi, ModelRetrieveParams, ModelCreateParams, ModelUpdateParams, ModelDeleteParams, JoinOptions, } from '@abloatai/transaction/resources/modelOperations';
|
|
21
|
+
export type { ModelListScope, ModelTrackParams, ModelTrackResult, LocalReadOptions, LocalCountOptions, ServerReadOptions, ListAllOptions, ServerGetOptions, ServerRetrieveOptions, ClaimTargetOptions, ClaimParams, ClaimContentionOptions, ClaimAttemptEvent, ClaimQueueView, ClaimSkipOptions, ClaimSkipParams, ClaimLookupParams, ClaimReorderParams, ClaimOptions, ClaimReadApi, AwaitedClaimMethod, ClaimApi, ModelRetrieveParams, ModelCreateParams, ModelUpdateParams, ModelDeleteParams, JoinOptions, } from '@abloatai/transaction/resources/modelOperations';
|
|
21
22
|
export type { Claim, ClaimHeartbeat, ClaimHeartbeatOptions, HeldClaim, HeldLease };
|
|
22
23
|
import type { ClaimApi, ClaimAttemptEvent, JoinOptions, LocalCountOptions, LocalReadOptions } from '@abloatai/transaction/resources/modelOperations';
|
|
23
24
|
import type { HttpModelClient } from '@abloatai/transaction/transport/httpClient';
|
|
@@ -39,6 +40,17 @@ export interface ModelCollaboration {
|
|
|
39
40
|
data: unknown;
|
|
40
41
|
stamp: number;
|
|
41
42
|
}>;
|
|
43
|
+
/**
|
|
44
|
+
* The batch commit lane, for a create handed a list of rows.
|
|
45
|
+
*
|
|
46
|
+
* A reactive client's single writes go through the mutation queue and land
|
|
47
|
+
* optimistically, because a rejected write rolls one row back. A batch
|
|
48
|
+
* cannot: it is atomic, so applying the rows one at a time would paint a
|
|
49
|
+
* half-written state the server may then decline whole. It goes down the
|
|
50
|
+
* same commit door the stateless client uses and the rows arrive on the
|
|
51
|
+
* ordinary stream, the way a teammate's would.
|
|
52
|
+
*/
|
|
53
|
+
commitBatch(options: CommitCreateOptions): Promise<CommitReceipt>;
|
|
42
54
|
createClaim(options: {
|
|
43
55
|
/**
|
|
44
56
|
* The locator, in the spelling the SDK surface and the HTTP routes use.
|
|
@@ -18,8 +18,9 @@ import { Model, modelAsRow } from '../Model.js';
|
|
|
18
18
|
import { toMs } from '@abloatai/transaction/utils/duration';
|
|
19
19
|
import { LEASE_TTL_MS } from '@abloatai/transaction/wire/protocol';
|
|
20
20
|
import { heartbeatCadenceMs, resolveHeartbeatOptions, resolveHeartbeatPlan, startClaimHeartbeatLoop, } from '@abloatai/transaction/coordination/claimHeartbeatLoop';
|
|
21
|
-
import { assertWriteOptions } from '@abloatai/transaction/resources/writeOptionsSchema';
|
|
22
|
-
import {
|
|
21
|
+
import { assertWriteOptions, assertWriteTarget, } from '@abloatai/transaction/resources/writeOptionsSchema';
|
|
22
|
+
import { createModelId, resolveCreatedRows, resolveCreateId, } from '@abloatai/transaction/resources/modelCreate';
|
|
23
|
+
import { collectModelList, modelList, } from '@abloatai/transaction/resources/httpResources';
|
|
23
24
|
import { subTarget } from '@abloatai/transaction/coordination';
|
|
24
25
|
// A named claim-meta crossing (see `claim-meta-crossings-are-enumerated` in
|
|
25
26
|
// .dependency-cruiser.cjs): the reactive proxy's self-claim targets are
|
|
@@ -685,6 +686,150 @@ hydration, collaboration, readSetContext) {
|
|
|
685
686
|
}
|
|
686
687
|
return page;
|
|
687
688
|
});
|
|
689
|
+
/**
|
|
690
|
+
* Creates many rows as one atomic commit, and returns them in caller order.
|
|
691
|
+
*/
|
|
692
|
+
const createManyRows = async (params) => {
|
|
693
|
+
if (params.data.length === 0)
|
|
694
|
+
return [];
|
|
695
|
+
if (!collaboration) {
|
|
696
|
+
throw new AbloValidationError(`Model "${schemaKey}" was built without the collaboration runtime, so a batch ` +
|
|
697
|
+
`create is unavailable here. Use the standard Ablo({ schema, apiKey }) client.`, { code: 'model_claim_not_configured' });
|
|
698
|
+
}
|
|
699
|
+
const prepared = prepareReadSet(readSetContext, readSetClientIdentity, undefined, undefined, params.idempotencyKey, params.reads);
|
|
700
|
+
try {
|
|
701
|
+
const organizationId = syncClient.getOrganizationId() ?? undefined;
|
|
702
|
+
const ids = [];
|
|
703
|
+
const operations = params.data.map((row) => {
|
|
704
|
+
const fields = row;
|
|
705
|
+
const id = resolveCreateId(undefined, fields) ??
|
|
706
|
+
createModelId(registeredModelName, params.idempotencyKey ? `${params.idempotencyKey}:${ids.length}` : null);
|
|
707
|
+
ids.push(id);
|
|
708
|
+
return {
|
|
709
|
+
action: 'create',
|
|
710
|
+
model: registeredModelName,
|
|
711
|
+
data: { organizationId: fields.organizationId ?? organizationId, ...fields, id },
|
|
712
|
+
id,
|
|
713
|
+
};
|
|
714
|
+
});
|
|
715
|
+
const receipt = await collaboration.commitBatch({
|
|
716
|
+
operations,
|
|
717
|
+
wait: 'confirmed',
|
|
718
|
+
...(prepared.idempotencyKey
|
|
719
|
+
? { idempotencyKey: prepared.idempotencyKey }
|
|
720
|
+
: params.idempotencyKey
|
|
721
|
+
? { idempotencyKey: params.idempotencyKey }
|
|
722
|
+
: {}),
|
|
723
|
+
...(prepared.reads
|
|
724
|
+
? { reads: [...prepared.reads] }
|
|
725
|
+
: {}),
|
|
726
|
+
...(params.track ? { track: [...params.track] } : {}),
|
|
727
|
+
});
|
|
728
|
+
const rows = await resolveCreatedRows({
|
|
729
|
+
modelName: registeredModelName,
|
|
730
|
+
ids,
|
|
731
|
+
operationResults: receipt.operationResults,
|
|
732
|
+
readRow: async (id) => {
|
|
733
|
+
const read = await collaboration.readPoint(registeredModelName, id);
|
|
734
|
+
return read.data;
|
|
735
|
+
},
|
|
736
|
+
});
|
|
737
|
+
consumeReadSet(readSetContext, readSetClientIdentity, prepared.consumed, prepared.automaticCommit);
|
|
738
|
+
return rows;
|
|
739
|
+
}
|
|
740
|
+
catch (error) {
|
|
741
|
+
abortReadSetCommit(readSetContext, prepared.automaticCommit);
|
|
742
|
+
throw error;
|
|
743
|
+
}
|
|
744
|
+
};
|
|
745
|
+
// `create` takes one row or a list of them. The list form is atomic and
|
|
746
|
+
// is therefore NOT applied optimistically: see `createManyRows`.
|
|
747
|
+
const createImpl = guardWrite(async (params) => {
|
|
748
|
+
if (Array.isArray(params.data)) {
|
|
749
|
+
return createManyRows(params);
|
|
750
|
+
}
|
|
751
|
+
const single = params;
|
|
752
|
+
const id = resolveCreateId(single.id, single.data) ?? Model.generateId();
|
|
753
|
+
const claim = single.claim;
|
|
754
|
+
let autoLease;
|
|
755
|
+
if (claim && !isClaimHandle(claim)) {
|
|
756
|
+
if (!collaboration) {
|
|
757
|
+
throw new AbloValidationError(`Model "${schemaKey}" was built without the collaboration runtime, so claim() is unavailable here. Claiming needs no per-model config — use the standard Ablo({ schema, apiKey }) client and every model is claimable.`, { code: 'model_claim_not_configured' });
|
|
758
|
+
}
|
|
759
|
+
// Write intent: enter the new row's entity scope before acquiring the
|
|
760
|
+
// create-claim so the holder's claim presence broadcasts to everyone
|
|
761
|
+
// already in this entity group (closing the subscribe-versus-broadcast
|
|
762
|
+
// race — see `takeClaim`). Released with the lease in the `finally`
|
|
763
|
+
// below. Awaited for broadcast ordering; still best-effort.
|
|
764
|
+
await collaboration.pinScope?.({ [schemaKey]: id });
|
|
765
|
+
const contention = resolveClaimContentionOptions(claim);
|
|
766
|
+
autoLease = await collaboration.createClaim({
|
|
767
|
+
target: {
|
|
768
|
+
model: wireModel,
|
|
769
|
+
id,
|
|
770
|
+
...subTarget(claim, schemaKey),
|
|
771
|
+
},
|
|
772
|
+
description: claimDescription(claim, 'creating'),
|
|
773
|
+
ttl: claim.ttl,
|
|
774
|
+
queue: contention.wait,
|
|
775
|
+
maxQueueDepth: contention.maxDepth,
|
|
776
|
+
waitTimeoutMs: contention.timeoutMs,
|
|
777
|
+
signal: contention.signal,
|
|
778
|
+
onStatus: contention.onStatus,
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
// Default `organizationId` from the client's identity, matching the other
|
|
782
|
+
// write path — without this, a caller that omits it would create an
|
|
783
|
+
// org-unscoped row on one write path but not the other. An explicit value
|
|
784
|
+
// in `data` still wins via the spread.
|
|
785
|
+
const orgDefault = params.data.organizationId ??
|
|
786
|
+
syncClient.getOrganizationId();
|
|
787
|
+
const model = new ModelClass({
|
|
788
|
+
id,
|
|
789
|
+
...(orgDefault != null ? { organizationId: orgDefault } : {}),
|
|
790
|
+
...params.data,
|
|
791
|
+
createdAt: new Date(),
|
|
792
|
+
updatedAt: new Date(),
|
|
793
|
+
});
|
|
794
|
+
let prepared;
|
|
795
|
+
try {
|
|
796
|
+
const resolved = preparedMutation(single);
|
|
797
|
+
prepared = resolved.prepared;
|
|
798
|
+
const effective = {
|
|
799
|
+
...resolved.options,
|
|
800
|
+
...(autoLease
|
|
801
|
+
? {
|
|
802
|
+
claimRef: { id: autoLease.id },
|
|
803
|
+
...(autoLease.fenceToken !== undefined
|
|
804
|
+
? { fenceToken: autoLease.fenceToken }
|
|
805
|
+
: {}),
|
|
806
|
+
}
|
|
807
|
+
: {}),
|
|
808
|
+
...(isClaimHandle(claim)
|
|
809
|
+
? {
|
|
810
|
+
claimRef: { id: claim.id },
|
|
811
|
+
...(claim.fenceToken !== undefined
|
|
812
|
+
? { fenceToken: claim.fenceToken }
|
|
813
|
+
: {}),
|
|
814
|
+
}
|
|
815
|
+
: {}),
|
|
816
|
+
};
|
|
817
|
+
syncClient.add(model, effective);
|
|
818
|
+
await waitForMutation(model);
|
|
819
|
+
consumeReadSet(readSetContext, readSetClientIdentity, prepared.consumed, prepared.automaticCommit);
|
|
820
|
+
return modelAsRow(model);
|
|
821
|
+
}
|
|
822
|
+
catch (error) {
|
|
823
|
+
abortReadSetCommit(readSetContext, prepared?.automaticCommit ?? false);
|
|
824
|
+
throw error;
|
|
825
|
+
}
|
|
826
|
+
finally {
|
|
827
|
+
await autoLease?.release?.().catch(() => { });
|
|
828
|
+
}
|
|
829
|
+
});
|
|
830
|
+
function createRows(params) {
|
|
831
|
+
return createImpl(params);
|
|
832
|
+
}
|
|
688
833
|
const operations = {
|
|
689
834
|
local,
|
|
690
835
|
get,
|
|
@@ -692,85 +837,12 @@ hydration, collaboration, readSetContext) {
|
|
|
692
837
|
// No automatic scope enrolment on bulk `list`: that would subscribe to an
|
|
693
838
|
// unbounded set of rows' entity groups.
|
|
694
839
|
list,
|
|
695
|
-
|
|
696
|
-
const
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
if (claim && !isClaimHandle(claim)) {
|
|
700
|
-
if (!collaboration) {
|
|
701
|
-
throw new AbloValidationError(`Model "${schemaKey}" was built without the collaboration runtime, so claim() is unavailable here. Claiming needs no per-model config — use the standard Ablo({ schema, apiKey }) client and every model is claimable.`, { code: 'model_claim_not_configured' });
|
|
702
|
-
}
|
|
703
|
-
// Write intent: enter the new row's entity scope before acquiring the
|
|
704
|
-
// create-claim so the holder's claim presence broadcasts to everyone
|
|
705
|
-
// already in this entity group (closing the subscribe-versus-broadcast
|
|
706
|
-
// race — see `takeClaim`). Released with the lease in the `finally`
|
|
707
|
-
// below. Awaited for broadcast ordering; still best-effort.
|
|
708
|
-
await collaboration.pinScope?.({ [schemaKey]: id });
|
|
709
|
-
const contention = resolveClaimContentionOptions(claim);
|
|
710
|
-
autoLease = await collaboration.createClaim({
|
|
711
|
-
target: {
|
|
712
|
-
model: wireModel,
|
|
713
|
-
id,
|
|
714
|
-
...subTarget(claim, schemaKey),
|
|
715
|
-
},
|
|
716
|
-
description: claimDescription(claim, 'creating'),
|
|
717
|
-
ttl: claim.ttl,
|
|
718
|
-
queue: contention.wait,
|
|
719
|
-
maxQueueDepth: contention.maxDepth,
|
|
720
|
-
waitTimeoutMs: contention.timeoutMs,
|
|
721
|
-
signal: contention.signal,
|
|
722
|
-
onStatus: contention.onStatus,
|
|
723
|
-
});
|
|
724
|
-
}
|
|
725
|
-
// Default `organizationId` from the client's identity, matching the other
|
|
726
|
-
// write path — without this, a caller that omits it would create an
|
|
727
|
-
// org-unscoped row on one write path but not the other. An explicit value
|
|
728
|
-
// in `data` still wins via the spread.
|
|
729
|
-
const orgDefault = params.data.organizationId ??
|
|
730
|
-
syncClient.getOrganizationId();
|
|
731
|
-
const model = new ModelClass({
|
|
732
|
-
id,
|
|
733
|
-
...(orgDefault != null ? { organizationId: orgDefault } : {}),
|
|
734
|
-
...params.data,
|
|
735
|
-
createdAt: new Date(),
|
|
736
|
-
updatedAt: new Date(),
|
|
737
|
-
});
|
|
738
|
-
let prepared;
|
|
739
|
-
try {
|
|
740
|
-
const resolved = preparedMutation(params);
|
|
741
|
-
prepared = resolved.prepared;
|
|
742
|
-
const effective = {
|
|
743
|
-
...resolved.options,
|
|
744
|
-
...(autoLease
|
|
745
|
-
? {
|
|
746
|
-
claimRef: { id: autoLease.id },
|
|
747
|
-
...(autoLease.fenceToken !== undefined
|
|
748
|
-
? { fenceToken: autoLease.fenceToken }
|
|
749
|
-
: {}),
|
|
750
|
-
}
|
|
751
|
-
: {}),
|
|
752
|
-
...(isClaimHandle(claim)
|
|
753
|
-
? {
|
|
754
|
-
claimRef: { id: claim.id },
|
|
755
|
-
...(claim.fenceToken !== undefined
|
|
756
|
-
? { fenceToken: claim.fenceToken }
|
|
757
|
-
: {}),
|
|
758
|
-
}
|
|
759
|
-
: {}),
|
|
760
|
-
};
|
|
761
|
-
syncClient.add(model, effective);
|
|
762
|
-
await waitForMutation(model);
|
|
763
|
-
consumeReadSet(readSetContext, readSetClientIdentity, prepared.consumed, prepared.automaticCommit);
|
|
764
|
-
return modelAsRow(model);
|
|
765
|
-
}
|
|
766
|
-
catch (error) {
|
|
767
|
-
abortReadSetCommit(readSetContext, prepared?.automaticCommit ?? false);
|
|
768
|
-
throw error;
|
|
769
|
-
}
|
|
770
|
-
finally {
|
|
771
|
-
await autoLease?.release?.().catch(() => { });
|
|
772
|
-
}
|
|
840
|
+
listAll: guard(async (options = {}) => {
|
|
841
|
+
const { maxPages, signal, ...readOptions } = options;
|
|
842
|
+
signal?.throwIfAborted();
|
|
843
|
+
return collectModelList(await list(readOptions), { maxPages, signal });
|
|
773
844
|
}),
|
|
845
|
+
create: createRows,
|
|
774
846
|
// `update` is overloaded — classic `update({ id, data })` + functional
|
|
775
847
|
// `update(id, current => next)`. The IIFE keeps the shared error-guard
|
|
776
848
|
// wrapping while exposing the two public signatures (a plain `guard(...)`
|
|
@@ -839,6 +911,10 @@ hydration, collaboration, readSetContext) {
|
|
|
839
911
|
});
|
|
840
912
|
}
|
|
841
913
|
const params = arg;
|
|
914
|
+
// Named before anything reads it. Without this the row lookup below
|
|
915
|
+
// reports `Entity not found: Model/undefined`, which sends the reader
|
|
916
|
+
// looking for a missing row rather than at the unaddressed write.
|
|
917
|
+
assertWriteTarget('update', registeredModelName, params.id);
|
|
842
918
|
const autoClaim = params.claim && !isClaimHandle(params.claim) ? params.claim : null;
|
|
843
919
|
if (autoClaim) {
|
|
844
920
|
const handle = await takeClaim({ ...autoClaim, id: params.id });
|
|
@@ -912,6 +988,9 @@ hydration, collaboration, readSetContext) {
|
|
|
912
988
|
return update;
|
|
913
989
|
})(),
|
|
914
990
|
delete: guardWrite(async (params) => {
|
|
991
|
+
// Before the idempotent "ensure absent" below can read this as a row that
|
|
992
|
+
// is simply not here. An unaddressed delete is a mistake, not an absence.
|
|
993
|
+
assertWriteTarget('delete', registeredModelName, params.id);
|
|
915
994
|
const autoClaim = params.claim && !isClaimHandle(params.claim) ? params.claim : null;
|
|
916
995
|
if (autoClaim) {
|
|
917
996
|
const handle = await takeClaim({ ...autoClaim, id: params.id });
|
|
@@ -408,6 +408,9 @@ export function buildReactiveEngine(inputs) {
|
|
|
408
408
|
const registeredModelName = modelDef.typename ?? schemaKey;
|
|
409
409
|
modelProxies[schemaKey] = createModelProxy(schemaKey, registeredModelName, objectPool, syncClient, modelRegistry, hydration, {
|
|
410
410
|
createClaim: (claimOptions) => publicClaims.create(claimOptions),
|
|
411
|
+
// Lazily referenced: `commits` is declared below this loop, and this
|
|
412
|
+
// only runs when someone actually writes a batch.
|
|
413
|
+
commitBatch: (commitOptions) => commits.create(commitOptions),
|
|
411
414
|
readPoint,
|
|
412
415
|
createSnapshot: (modelKey, id) => createSnapshot({
|
|
413
416
|
pool: objectPool,
|
|
@@ -502,13 +505,14 @@ export function buildReactiveEngine(inputs) {
|
|
|
502
505
|
if (wait === 'queued') {
|
|
503
506
|
return { id: clientTxId, status: 'queued' };
|
|
504
507
|
}
|
|
505
|
-
const { lastSyncId, notifications, missingIds } = await queue.waitForCommitReceipt(clientTxId);
|
|
508
|
+
const { lastSyncId, notifications, missingIds, operationResults } = await queue.waitForCommitReceipt(clientTxId);
|
|
506
509
|
return {
|
|
507
510
|
id: clientTxId,
|
|
508
511
|
status: 'confirmed',
|
|
509
512
|
lastSyncId,
|
|
510
513
|
...(notifications && notifications.length > 0 ? { notifications } : {}),
|
|
511
514
|
...(missingIds && missingIds.length > 0 ? { missingIds } : {}),
|
|
515
|
+
...(operationResults && operationResults.length > 0 ? { operationResults } : {}),
|
|
512
516
|
};
|
|
513
517
|
},
|
|
514
518
|
async get({ id }) {
|
|
@@ -50,6 +50,7 @@ export function createDefaultMutationExecutor(getWs, readSetContext) {
|
|
|
50
50
|
...(receipt.correlationId ? { correlationId: receipt.correlationId } : {}),
|
|
51
51
|
...(receipt.notifications ? { notifications: receipt.notifications } : {}),
|
|
52
52
|
...(receipt.missingIds ? { missingIds: receipt.missingIds } : {}),
|
|
53
|
+
...(receipt.operationResults ? { operationResults: receipt.operationResults } : {}),
|
|
53
54
|
});
|
|
54
55
|
}
|
|
55
56
|
if (!ws.sendCommit) {
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { Model } from './Model.js';
|
|
2
|
+
import type { BootstrapData } from './sync/BootstrapFetcher.js';
|
|
3
|
+
import type { QueuedMutation } from './transactions/mutations/MutationQueue.js';
|
|
4
|
+
import type { CommitTransaction } from './transactions/mutations/commitLane.js';
|
|
5
|
+
export interface SyncObserver {
|
|
6
|
+
onSync?: (event: SyncEvent) => void;
|
|
7
|
+
}
|
|
8
|
+
export interface SyncEvent {
|
|
9
|
+
type: 'create' | 'update' | 'delete' | 'archive' | 'rollback';
|
|
10
|
+
modelType: string;
|
|
11
|
+
model?: Model;
|
|
12
|
+
modelId?: string;
|
|
13
|
+
transactionType?: string;
|
|
14
|
+
}
|
|
15
|
+
export interface SyncState {
|
|
16
|
+
connectionState: 'connected' | 'disconnected' | 'connecting';
|
|
17
|
+
pendingMutations: number;
|
|
18
|
+
lastSyncAt?: Date;
|
|
19
|
+
error?: Error;
|
|
20
|
+
}
|
|
21
|
+
export interface RehydrationStats {
|
|
22
|
+
added: number;
|
|
23
|
+
updated: number;
|
|
24
|
+
removed: number;
|
|
25
|
+
skipped: number;
|
|
26
|
+
healed: number;
|
|
27
|
+
elapsedMs: number;
|
|
28
|
+
}
|
|
29
|
+
export type EventHandler = () => void;
|
|
30
|
+
/** The bootstrap fields applied to the local object pool. */
|
|
31
|
+
export type BootstrapSnapshot = Pick<BootstrapData, 'models' | 'failedModels'> & Partial<Pick<BootstrapData, 'lastSyncId'>>;
|
|
32
|
+
/** A completed queued mutation or explicit commit. */
|
|
33
|
+
export type CompletedTransaction = (Pick<QueuedMutation, 'id' | 'modelId' | 'syncIdNeededForCompletion'> & {
|
|
34
|
+
lastSyncId?: undefined;
|
|
35
|
+
operations?: undefined;
|
|
36
|
+
}) | (Pick<CommitTransaction, 'id' | 'lastSyncId' | 'operations'> & {
|
|
37
|
+
modelId?: undefined;
|
|
38
|
+
syncIdNeededForCompletion?: undefined;
|
|
39
|
+
});
|
|
40
|
+
/** Normalize an untyped server timestamp for last-write-wins comparison. */
|
|
41
|
+
export declare function toEpochMs(value: unknown): number;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Normalize an untyped server timestamp for last-write-wins comparison. */
|
|
2
|
+
export function toEpochMs(value) {
|
|
3
|
+
if (!value)
|
|
4
|
+
return 0;
|
|
5
|
+
if (value instanceof Date)
|
|
6
|
+
return value.getTime();
|
|
7
|
+
if (typeof value === 'string' || typeof value === 'number') {
|
|
8
|
+
return new Date(value).getTime();
|
|
9
|
+
}
|
|
10
|
+
return 0;
|
|
11
|
+
}
|
|
@@ -18,6 +18,7 @@ import type { RuntimeContext } from '../../RuntimeContext.js';
|
|
|
18
18
|
import { type LogPositionPort } from '../../logPosition.js';
|
|
19
19
|
import type { WriteOptions } from '../../interfaces/index.js';
|
|
20
20
|
import type { StaleNotification, ReadDependency, TrackDependency } from '@abloatai/transaction/coordination/schema';
|
|
21
|
+
import { type CommitOperationResult } from '@abloatai/transaction/wire/commit';
|
|
21
22
|
import { type MutationInput, type QueuedMutation, type UserContext } from './commitPayload.js';
|
|
22
23
|
import { type CommitOutboxScope } from '@abloatai/transaction/transactions/confirmation/commitEnvelope';
|
|
23
24
|
import type { DurableWriteStore } from './durableWriteStore.js';
|
|
@@ -373,6 +374,7 @@ export declare class MutationQueue extends EventEmitter {
|
|
|
373
374
|
lastSyncId: number;
|
|
374
375
|
notifications?: StaleNotification[];
|
|
375
376
|
missingIds?: string[];
|
|
377
|
+
operationResults?: CommitOperationResult[];
|
|
376
378
|
}>;
|
|
377
379
|
private isReorderPayload;
|
|
378
380
|
private isPermanentError;
|