@abloatai/humans 0.55.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/Database.js +2 -2
- 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/options.d.ts +7 -4
- package/dist/local/client/reactiveEngine.js +5 -1
- package/dist/local/client/wsMutationExecutor.js +1 -0
- package/dist/local/logPosition.d.ts +13 -2
- package/dist/local/logPosition.js +17 -5
- package/dist/local/sync/SyncWebSocket.js +16 -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 +2 -3
- 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/options.ts +7 -4
- package/src/local/client/reactiveEngine.ts +5 -1
- package/src/local/client/wsMutationExecutor.ts +1 -0
- package/src/local/logPosition.ts +19 -6
- package/src/local/sync/SyncWebSocket.ts +15 -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/Database.js
CHANGED
|
@@ -13,7 +13,7 @@ import { globalRuntime } from './context.js';
|
|
|
13
13
|
import { AbloConnectionError, AbloValidationError } from '@abloatai/transaction/errors';
|
|
14
14
|
import { persistenceDatabaseNamesForDeletion, purgeIndexedDbPersistence, } from './stores/persistenceCleanup.js';
|
|
15
15
|
import { InMemoryObjectStore } from './adapters/inMemoryStorage.js';
|
|
16
|
-
import {
|
|
16
|
+
import { logPositionSnapshotSchema } from './logPosition.js';
|
|
17
17
|
import { highestPersistedPrefixSyncId } from './sync/persistedPrefix.js';
|
|
18
18
|
import { isAcceptedOutboxPromotion, isSameOutboxRecord, } from './transactions/persistedTransaction.js';
|
|
19
19
|
/**
|
|
@@ -320,7 +320,7 @@ export class Database {
|
|
|
320
320
|
// (a corrupted negative/float cursor would previously pass `|| 0`,
|
|
321
321
|
// which only catches falsy, and get sent to the server as the resume
|
|
322
322
|
// point). Invalid → 0 → full bootstrap, the safe degradation.
|
|
323
|
-
const metadataLastSyncId =
|
|
323
|
+
const metadataLastSyncId = logPositionSnapshotSchema.shape.persisted.safeParse(metadata?.lastSyncId).data ?? 0;
|
|
324
324
|
const dataAge = metadata?.updatedAt ? Date.now() - metadata.updatedAt.getTime() : Infinity;
|
|
325
325
|
// ── Cache-validity check ─────────────────────────────────────
|
|
326
326
|
//
|
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 });
|
|
@@ -71,14 +71,17 @@ export interface AbloOptions<S extends SchemaRecord = SchemaRecord> {
|
|
|
71
71
|
/**
|
|
72
72
|
* Pins this client to one Ablo project. During `ready()` the server resolves
|
|
73
73
|
* the API key's actual project and the client refuses to start when it differs.
|
|
74
|
-
* Defaults to `ABLO_PROJECT_ID
|
|
75
|
-
*
|
|
74
|
+
* Defaults to `ABLO_PROJECT_ID`. This is an assertion, never a routing
|
|
75
|
+
* selector — the key remains authoritative and already names its own project,
|
|
76
|
+
* so leave this unset unless one deployment can be handed keys for more than
|
|
77
|
+
* one project and you want the mismatch to fail loudly.
|
|
76
78
|
*/
|
|
77
79
|
projectId?: string | null | undefined;
|
|
78
80
|
/**
|
|
79
81
|
* Pins this client to one immutable Ablo branch. Defaults to
|
|
80
|
-
* `ABLO_BRANCH_ID
|
|
81
|
-
*
|
|
82
|
+
* `ABLO_BRANCH_ID`. Like `projectId`, this is a startup assertion that never
|
|
83
|
+
* selects a branch, and is worth setting only where a key for the wrong
|
|
84
|
+
* environment could reach this process.
|
|
82
85
|
*/
|
|
83
86
|
branchId?: string | null | undefined;
|
|
84
87
|
/**
|
|
@@ -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) {
|
|
@@ -1,10 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The three log positions a connected client owns.
|
|
3
|
+
*
|
|
4
|
+
* Each field is a {@link logPositionSchema}, the one position type, and the
|
|
5
|
+
* field name says who is claiming what: `applied` is what arrival processed,
|
|
6
|
+
* `persisted` is what local storage durably holds IN DELIVERED ORDER, and
|
|
7
|
+
* `acked` is what the server has been told. They are the same kind of number
|
|
8
|
+
* as the server's heads and cursors, and deliberately not comparable to them
|
|
9
|
+
* without saying which owner you mean. See the owner table on
|
|
10
|
+
* `@abloatai/transaction/syncLog/contract`.
|
|
11
|
+
*/
|
|
1
12
|
import { z } from 'zod';
|
|
2
|
-
export declare const
|
|
13
|
+
export declare const logPositionSnapshotSchema: z.ZodObject<{
|
|
3
14
|
persisted: z.ZodNumber;
|
|
4
15
|
applied: z.ZodNumber;
|
|
5
16
|
acked: z.ZodNumber;
|
|
6
17
|
}, z.core.$strip>;
|
|
7
|
-
export type LogPositionSnapshot = z.infer<typeof
|
|
18
|
+
export type LogPositionSnapshot = z.infer<typeof logPositionSnapshotSchema>;
|
|
8
19
|
export interface LogPositionPort {
|
|
9
20
|
readonly persisted: number;
|
|
10
21
|
readonly applied: number;
|
|
@@ -1,11 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The three log positions a connected client owns.
|
|
3
|
+
*
|
|
4
|
+
* Each field is a {@link logPositionSchema}, the one position type, and the
|
|
5
|
+
* field name says who is claiming what: `applied` is what arrival processed,
|
|
6
|
+
* `persisted` is what local storage durably holds IN DELIVERED ORDER, and
|
|
7
|
+
* `acked` is what the server has been told. They are the same kind of number
|
|
8
|
+
* as the server's heads and cursors, and deliberately not comparable to them
|
|
9
|
+
* without saying which owner you mean. See the owner table on
|
|
10
|
+
* `@abloatai/transaction/syncLog/contract`.
|
|
11
|
+
*/
|
|
1
12
|
import { z } from 'zod';
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
13
|
+
import { logPositionSchema } from '@abloatai/transaction/syncLog/contract';
|
|
14
|
+
export const logPositionSnapshotSchema = z.object({
|
|
15
|
+
persisted: logPositionSchema,
|
|
16
|
+
applied: logPositionSchema,
|
|
17
|
+
acked: logPositionSchema,
|
|
6
18
|
});
|
|
7
19
|
export function parseLogPosition(value) {
|
|
8
|
-
const result =
|
|
20
|
+
const result = logPositionSnapshotSchema.safeParse(value);
|
|
9
21
|
return result.success ? result.data : null;
|
|
10
22
|
}
|
|
11
23
|
const ZERO = { persisted: 0, applied: 0, acked: 0 };
|
|
@@ -396,6 +396,22 @@ export class SyncWebSocket extends WsTransport {
|
|
|
396
396
|
});
|
|
397
397
|
});
|
|
398
398
|
}
|
|
399
|
+
else if (serverHead > this.cursor.lastSyncId) {
|
|
400
|
+
// The other direction: we are behind the server head and the server
|
|
401
|
+
// sent nothing. That is not a stall, it is proof. An empty response
|
|
402
|
+
// means the server walked the log up to `currentSyncId` under this
|
|
403
|
+
// client's own project and capability scope and found nothing we are
|
|
404
|
+
// entitled to, and it measured that head through the settled barrier,
|
|
405
|
+
// so no lower id can still be in flight. Adopting it is therefore
|
|
406
|
+
// exact, not optimistic.
|
|
407
|
+
//
|
|
408
|
+
// Without this, a client on a plane whose head moves for reasons it
|
|
409
|
+
// cannot see — another project, another sync group, a model outside
|
|
410
|
+
// its allowlist — never converges. Its cursor sticks, every catch-up
|
|
411
|
+
// poll finds a gap, and each of those polls takes the plane's advisory
|
|
412
|
+
// lock to read the settled head. The cost lands on the write path.
|
|
413
|
+
this.cursor.lastSyncId = serverHead;
|
|
414
|
+
}
|
|
399
415
|
}
|
|
400
416
|
if (payload.requiresBootstrap) {
|
|
401
417
|
this.emit('bootstrap_required', payload.bootstrapHint);
|
|
@@ -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;
|