@abloatai/humans 0.59.2 → 0.61.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/README.md +1 -1
- package/dist/Ablo.d.ts +2 -10
- package/dist/Ablo.js +0 -1
- package/dist/client.d.ts +1 -48
- package/dist/humans.d.ts +1 -1
- package/dist/local/BaseSyncedStore.d.ts +5 -5
- package/dist/local/BaseSyncedStore.js +7 -7
- package/dist/local/Database.d.ts +2 -2
- package/dist/local/LazyReferenceCollection.d.ts +1 -1
- package/dist/local/Model.js +46 -56
- package/dist/local/NetworkMonitor.js +2 -0
- package/dist/local/RuntimeContext.js +2 -0
- package/dist/local/SyncClient.d.ts +12 -36
- package/dist/local/SyncClient.js +50 -108
- package/dist/local/client/createModelOperations.d.ts +3 -27
- package/dist/local/client/createModelOperations.js +20 -21
- package/dist/local/client/options.d.ts +14 -39
- package/dist/local/client/reactiveEngine.d.ts +3 -9
- package/dist/local/client/reactiveEngine.js +6 -151
- package/dist/local/client/storeLifecycle.js +5 -1
- package/dist/local/fileUploads.d.ts +27 -0
- package/dist/local/fileUploads.js +55 -0
- package/dist/local/storeContract.d.ts +5 -5
- package/dist/local/stores/syncAction.d.ts +4 -4
- package/dist/local/sync/contextOnChange.js +1 -1
- package/dist/local/sync/createClaimStream.js +1 -1
- package/dist/local/sync/credentialLifecycle.d.ts +4 -5
- package/dist/local/sync/credentialLifecycle.js +4 -5
- package/dist/local/sync/deltaPipeline.js +12 -6
- package/dist/local/sync/schemas.d.ts +10 -10
- package/dist/local/sync/scopeGroups.d.ts +11 -0
- package/dist/local/sync/scopeGroups.js +75 -0
- package/dist/local/sync/wsFrameHandlers.d.ts +1 -1
- package/dist/local/transactions/localMutation.js +3 -3
- package/dist/local/transactions/mutations/MutationQueue.d.ts +4 -5
- package/dist/local/transactions/mutations/MutationQueue.js +25 -51
- package/dist/local/transactions/mutations/batchProcessing.js +23 -10
- package/dist/local/transactions/mutations/commitPayload.d.ts +9 -2
- package/dist/local/transactions/mutations/commitTransport.js +3 -1
- package/dist/local/transactions/mutations/executionSelection.d.ts +0 -1
- package/dist/local/transactions/mutations/executionSelection.js +9 -17
- package/dist/local/transactions/mutations/failureHandling.js +9 -0
- package/dist/local/transactions/mutations/localMutation.js +3 -3
- package/dist/local/transactions/mutations/queueCoalescing.js +8 -0
- package/dist/local/transactions/mutations/replayValidation.d.ts +15 -15
- package/dist/react/AbloProvider.d.ts +11 -86
- package/dist/react/AbloProvider.js +11 -163
- package/dist/react/ClientSideSuspense.d.ts +1 -1
- package/dist/react/DefaultFallback.d.ts +1 -1
- package/dist/react/createAbloReact.js +1 -1
- package/dist/react/useErrorListener.js +1 -1
- package/dist/react/useMutationFailureListener.js +1 -1
- package/dist/react.d.ts +1 -1
- package/dist/react.js +1 -1
- package/dist/surface.d.ts +3 -3
- package/dist/surface.js +1 -4
- package/package.json +3 -3
- package/src/Ablo.ts +5 -17
- package/src/client.ts +0 -51
- package/src/local/BaseSyncedStore.ts +14 -14
- package/src/local/Model.ts +45 -55
- package/src/local/NetworkMonitor.ts +2 -0
- package/src/local/RuntimeContext.ts +2 -0
- package/src/local/SyncClient.ts +73 -140
- package/src/local/client/createModelOperations.ts +30 -64
- package/src/local/client/options.ts +20 -43
- package/src/local/client/reactiveEngine.ts +7 -179
- package/src/local/client/storeLifecycle.ts +6 -1
- package/src/local/fileUploads.ts +97 -0
- package/src/local/storeContract.ts +5 -5
- package/src/local/sync/contextOnChange.ts +1 -1
- package/src/local/sync/createClaimStream.ts +1 -1
- package/src/local/sync/credentialLifecycle.ts +4 -5
- package/src/local/sync/deltaPipeline.ts +10 -6
- package/src/local/sync/scopeGroups.ts +91 -0
- package/src/local/sync/wsFrameHandlers.ts +0 -1
- package/src/local/transactions/localMutation.ts +3 -3
- package/src/local/transactions/mutations/MutationQueue.ts +24 -53
- package/src/local/transactions/mutations/batchProcessing.ts +25 -10
- package/src/local/transactions/mutations/commitPayload.ts +11 -1
- package/src/local/transactions/mutations/commitTransport.ts +2 -2
- package/src/local/transactions/mutations/executionSelection.ts +9 -15
- package/src/local/transactions/mutations/failureHandling.ts +10 -0
- package/src/local/transactions/mutations/localMutation.ts +3 -3
- package/src/local/transactions/mutations/queueCoalescing.ts +6 -0
- package/src/react/AbloProvider.tsx +17 -249
- package/src/react/useErrorListener.ts +1 -1
- package/src/react/useMutationFailureListener.ts +1 -1
- package/src/react.ts +1 -5
- package/src/surface.ts +1 -4
- package/dist/local/sync/participants.d.ts +0 -132
- package/dist/local/sync/participants.js +0 -342
- package/dist/local/transactions/mutations/pendingDrain.d.ts +0 -33
- package/dist/local/transactions/mutations/pendingDrain.js +0 -117
- package/src/local/sync/participants.ts +0 -564
- package/src/local/transactions/mutations/pendingDrain.ts +0 -169
package/dist/local/SyncClient.js
CHANGED
|
@@ -14,7 +14,7 @@ import { deepEqual, snapshotJsonValue } from '@abloatai/transaction/utils/json';
|
|
|
14
14
|
// ModelRegistry instance accessed via this.objectPool.registry
|
|
15
15
|
import { LoadStrategy } from '@abloatai/transaction/types';
|
|
16
16
|
import { globalRuntime } from './context.js';
|
|
17
|
-
import {
|
|
17
|
+
import { AbloError, AbloValidationError } from '@abloatai/transaction/errors';
|
|
18
18
|
import { EventEmitter } from 'events';
|
|
19
19
|
import { NetworkMonitor } from './NetworkMonitor.js';
|
|
20
20
|
import { MutationQueue, } from './transactions/mutations/MutationQueue.js';
|
|
@@ -25,6 +25,8 @@ import { createLocalMutationPort } from './transactions/localMutation.js';
|
|
|
25
25
|
import { createReconnectDrain } from './transactions/reconnectDrain.js';
|
|
26
26
|
import { DatabaseCommitOutboxStore } from './transactions/databaseCommitOutbox.js';
|
|
27
27
|
import { toEpochMs, } from './syncClientTypes.js';
|
|
28
|
+
import { batchUploadFiles, uploadFile, } from './fileUploads.js';
|
|
29
|
+
const ignoreSeparatelyObservedMutationFailure = () => undefined;
|
|
28
30
|
export class SyncClient extends EventEmitter {
|
|
29
31
|
runtime;
|
|
30
32
|
objectPool;
|
|
@@ -758,7 +760,7 @@ export class SyncClient extends EventEmitter {
|
|
|
758
760
|
* capturing first ensures those changes are never lost. The captured set is
|
|
759
761
|
* frozen and handed to {@link queueMutation}.
|
|
760
762
|
*/
|
|
761
|
-
mutate(type, model, poolAction, writeOptions) {
|
|
763
|
+
mutate(type, model, poolAction, writeOptions, capturedChangesOverride) {
|
|
762
764
|
// No-op UPDATE guard (O(1)). An update with no dirty fields would travel
|
|
763
765
|
// to the server, get dropped by `coalesceOperations` Rule 4 (empty input),
|
|
764
766
|
// and — if it was the only op — come back as `lastSyncId: 0`. That trips
|
|
@@ -772,21 +774,28 @@ export class SyncClient extends EventEmitter {
|
|
|
772
774
|
// is false → we fall through to the normal path rather than risk dropping a
|
|
773
775
|
// real write. Only a genuine Model with an empty dirty-set is skipped.
|
|
774
776
|
const hasChanges = model.hasChanges;
|
|
775
|
-
if (type === 'update' &&
|
|
776
|
-
|
|
777
|
+
if (type === 'update' &&
|
|
778
|
+
hasChanges === false &&
|
|
779
|
+
capturedChangesOverride === undefined) {
|
|
780
|
+
return Promise.resolve();
|
|
777
781
|
}
|
|
778
782
|
// Capture changes before the pool action runs. Pool operations —
|
|
779
783
|
// upsert in particular — can clear the model's local changes, so
|
|
780
784
|
// capturing first ensures they are never lost.
|
|
781
|
-
const capturedChanges =
|
|
785
|
+
const capturedChanges = capturedChangesOverride !== undefined
|
|
786
|
+
? Object.freeze({ ...capturedChangesOverride })
|
|
787
|
+
: type === 'update' || type === 'create'
|
|
788
|
+
? this.captureModelChanges(model)
|
|
789
|
+
: undefined;
|
|
782
790
|
poolAction();
|
|
783
|
-
this.stageMutation(type, model, capturedChanges, writeOptions);
|
|
791
|
+
const confirmation = this.stageMutation(type, model, capturedChanges, writeOptions);
|
|
784
792
|
this.notifyObservers({
|
|
785
793
|
type,
|
|
786
794
|
modelType: model.getModelName(),
|
|
787
795
|
model: type !== 'delete' ? model : undefined,
|
|
788
796
|
modelId: model.id,
|
|
789
797
|
});
|
|
798
|
+
return confirmation;
|
|
790
799
|
// QueryProcessor uses `models:changed` to invalidate caches. Coalesce
|
|
791
800
|
// to one event per microtask: a paste of 100 rows should re-run
|
|
792
801
|
// affected queries ONCE, not 100×.
|
|
@@ -821,11 +830,11 @@ export class SyncClient extends EventEmitter {
|
|
|
821
830
|
}
|
|
822
831
|
/** Add new model (CREATE) - works offline */
|
|
823
832
|
add(model, options) {
|
|
824
|
-
this.mutate('create', model, () => { this.objectPool.add(model, ModelScope.live); }, options);
|
|
833
|
+
return this.mutate('create', model, () => { this.objectPool.add(model, ModelScope.live); }, options);
|
|
825
834
|
}
|
|
826
835
|
/** Update existing model (UPDATE) - works offline */
|
|
827
|
-
update(model, options) {
|
|
828
|
-
this.mutate('update', model, () => { this.objectPool.upsert(model, ModelScope.live); }, options);
|
|
836
|
+
update(model, options, capturedChanges) {
|
|
837
|
+
return this.mutate('update', model, () => { this.objectPool.upsert(model, ModelScope.live); }, options, capturedChanges);
|
|
829
838
|
}
|
|
830
839
|
/**
|
|
831
840
|
* Update existing model with pre-computed changes.
|
|
@@ -849,7 +858,7 @@ export class SyncClient extends EventEmitter {
|
|
|
849
858
|
if (capturedChanges === undefined)
|
|
850
859
|
return;
|
|
851
860
|
this.objectPool.upsert(model, ModelScope.live);
|
|
852
|
-
this.stageMutation('update', model, capturedChanges);
|
|
861
|
+
void this.stageMutation('update', model, capturedChanges);
|
|
853
862
|
this.notifyObservers({
|
|
854
863
|
type: 'update',
|
|
855
864
|
modelType: model.getModelName(),
|
|
@@ -867,101 +876,29 @@ export class SyncClient extends EventEmitter {
|
|
|
867
876
|
delete(model, options) {
|
|
868
877
|
// Clear pending mutations first to prevent "not found" errors on fast delete
|
|
869
878
|
this.mutationQueue.cancelTransactionsForModel(model.id);
|
|
870
|
-
this.mutate('delete', model, () => this.objectPool.remove(model.id), options);
|
|
879
|
+
return this.mutate('delete', model, () => this.objectPool.remove(model.id), options);
|
|
871
880
|
}
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
* the {@link MutationQueue}, and a model is built from the server's
|
|
875
|
-
* response and added to the pool.
|
|
876
|
-
*/
|
|
877
|
-
async uploadFile(file, options) {
|
|
878
|
-
if (!this.userId || !this.organizationId) {
|
|
879
|
-
throw new AbloAuthenticationError('Authentication required for file uploads', {
|
|
880
|
-
code: 'file_upload_auth_required',
|
|
881
|
-
});
|
|
882
|
-
}
|
|
883
|
-
try {
|
|
884
|
-
// Use MutationQueue to handle the upload mutation
|
|
885
|
-
const result = await this.mutationQueue.uploadAttachment(file, {
|
|
886
|
-
id: options.id,
|
|
887
|
-
attachableType: options.attachableType,
|
|
888
|
-
attachableId: options.attachableId,
|
|
889
|
-
metadata: options.metadata,
|
|
890
|
-
}, {
|
|
891
|
-
userId: this.userId,
|
|
892
|
-
organizationId: this.organizationId,
|
|
893
|
-
});
|
|
894
|
-
if (result) {
|
|
895
|
-
// Create model from response using ModelRegistry (generic — no concrete class import)
|
|
896
|
-
const model = this.objectPool.createFromData({
|
|
897
|
-
id: options.id,
|
|
898
|
-
...result,
|
|
899
|
-
});
|
|
900
|
-
if (model) {
|
|
901
|
-
this.objectPool.add(model, ModelScope.live);
|
|
902
|
-
this.notifyObservers({
|
|
903
|
-
type: 'create',
|
|
904
|
-
modelType: model.getModelName(),
|
|
905
|
-
model,
|
|
906
|
-
});
|
|
907
|
-
return model;
|
|
908
|
-
}
|
|
909
|
-
}
|
|
910
|
-
return null;
|
|
911
|
-
}
|
|
912
|
-
catch (error) {
|
|
913
|
-
this.runtime.observability.captureMutationFailure({
|
|
914
|
-
context: 'file-upload',
|
|
915
|
-
error: error instanceof Error ? error : new Error(String(error)),
|
|
916
|
-
});
|
|
917
|
-
throw error;
|
|
918
|
-
}
|
|
919
|
-
}
|
|
920
|
-
/**
|
|
921
|
-
* Batch upload files — single GraphQL call + parallel S3 PUTs.
|
|
922
|
-
*
|
|
923
|
-
* Returns the raw `Model[]` built by the object pool (typename is
|
|
924
|
-
* determined by the payload the server returns — currently always
|
|
925
|
-
* `Attachment`). The SDK has no knowledge of app-specific model classes,
|
|
926
|
-
* so it cannot honestly claim a narrower return type; consumers that
|
|
927
|
-
* need an `Attachment[]` project through their own typed accessor
|
|
928
|
-
* (e.g. `store.query.attachments.findMany({ where: { id: IN ids } })`)
|
|
929
|
-
* after the upload resolves.
|
|
930
|
-
*/
|
|
931
|
-
async batchUploadFiles(files, options) {
|
|
932
|
-
if (!this.userId || !this.organizationId) {
|
|
933
|
-
throw new AbloAuthenticationError('Authentication required for file uploads', {
|
|
934
|
-
code: 'file_upload_auth_required',
|
|
935
|
-
});
|
|
936
|
-
}
|
|
937
|
-
const items = options.ids.map((id) => ({
|
|
938
|
-
id,
|
|
939
|
-
attachableType: options.attachableType,
|
|
940
|
-
attachableId: options.attachableId,
|
|
941
|
-
metadata: options.metadata,
|
|
942
|
-
}));
|
|
943
|
-
const results = await this.mutationQueue.batchUploadAttachments(files, items, {
|
|
881
|
+
fileUploadContext() {
|
|
882
|
+
return {
|
|
944
883
|
userId: this.userId,
|
|
945
884
|
organizationId: this.organizationId,
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
}
|
|
960
|
-
return models;
|
|
885
|
+
mutationQueue: this.mutationQueue,
|
|
886
|
+
objectPool: this.objectPool,
|
|
887
|
+
observability: this.runtime.observability,
|
|
888
|
+
notifyCreated: (model) => {
|
|
889
|
+
this.notifyObservers({ type: 'create', modelType: model.getModelName(), model });
|
|
890
|
+
},
|
|
891
|
+
};
|
|
892
|
+
}
|
|
893
|
+
uploadFile(file, options) {
|
|
894
|
+
return uploadFile(this.fileUploadContext(), file, options);
|
|
895
|
+
}
|
|
896
|
+
batchUploadFiles(files, options) {
|
|
897
|
+
return batchUploadFiles(this.fileUploadContext(), files, options);
|
|
961
898
|
}
|
|
962
899
|
/** Archive model (ARCHIVE) - works offline */
|
|
963
900
|
archive(model) {
|
|
964
|
-
this.mutate('archive', model, () => { this.objectPool.updateScope(model.id, ModelScope.archived); });
|
|
901
|
+
return this.mutate('archive', model, () => { this.objectPool.updateScope(model.id, ModelScope.archived); });
|
|
965
902
|
}
|
|
966
903
|
/**
|
|
967
904
|
* Append a mutation to the pending queue and schedule its sync work.
|
|
@@ -979,13 +916,20 @@ export class SyncClient extends EventEmitter {
|
|
|
979
916
|
/** Stage one mutation through the queue, which owns durability and execution. */
|
|
980
917
|
stageMutation(type, model, capturedChanges, writeOptions) {
|
|
981
918
|
if (this.isDisposed)
|
|
982
|
-
return;
|
|
919
|
+
return Promise.resolve();
|
|
983
920
|
if (!this.userId || !this.organizationId) {
|
|
984
921
|
this.mutationQueue.deferMutation(type, model, capturedChanges, writeOptions);
|
|
985
922
|
return;
|
|
986
923
|
}
|
|
987
924
|
const context = { userId: this.userId, organizationId: this.organizationId };
|
|
988
925
|
const staging = this.mutationQueue.enqueueModelMutation(type, model, context, capturedChanges, writeOptions);
|
|
926
|
+
const confirmation = staging.then(async (transaction) => {
|
|
927
|
+
await transaction.confirmation;
|
|
928
|
+
});
|
|
929
|
+
// Most internal callers intentionally use fire-and-forget writes. Observe
|
|
930
|
+
// their rejection without replacing the exact promise returned to model
|
|
931
|
+
// operations that need authoritative per-transaction confirmation.
|
|
932
|
+
void confirmation.catch(ignoreSeparatelyObservedMutationFailure);
|
|
989
933
|
const pending = staging.then(() => undefined).catch((error) => {
|
|
990
934
|
this.runtime.observability.captureMutationFailure({
|
|
991
935
|
context: `stage-mutation-${type}`,
|
|
@@ -996,6 +940,7 @@ export class SyncClient extends EventEmitter {
|
|
|
996
940
|
});
|
|
997
941
|
this.pendingStages.add(pending);
|
|
998
942
|
void pending.finally(() => this.pendingStages.delete(pending));
|
|
943
|
+
return confirmation;
|
|
999
944
|
}
|
|
1000
945
|
scheduleSync() {
|
|
1001
946
|
if (!this.runtime.onlineStatus.isOnline() || this.isDisposed)
|
|
@@ -1210,14 +1155,11 @@ export class SyncClient extends EventEmitter {
|
|
|
1210
1155
|
markConnected() {
|
|
1211
1156
|
this.setConnectionState('connected');
|
|
1212
1157
|
// Browser online state may have marked the client connected before the
|
|
1213
|
-
// WebSocket itself was ready.
|
|
1214
|
-
//
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
error: error instanceof Error ? error : new Error(String(error)),
|
|
1219
|
-
});
|
|
1220
|
-
});
|
|
1158
|
+
// WebSocket itself was ready. Kick the durable lanes through the staging
|
|
1159
|
+
// barrier: a model mutation enters the in-memory store before its journal
|
|
1160
|
+
// row finishes saving, so a direct reconnect drain can otherwise try to
|
|
1161
|
+
// seal a source record that does not exist yet. The pending drain also
|
|
1162
|
+
// starts the atomic commit lane, so one ordered entry point covers both.
|
|
1221
1163
|
void this.processPendingMutations();
|
|
1222
1164
|
}
|
|
1223
1165
|
drainPendingConfirmations() {
|
|
@@ -6,7 +6,7 @@
|
|
|
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
|
-
* `claim.queue`, `claim.release`, and `claim.reorder`),
|
|
9
|
+
* `claim.queue`, `claim.release`, and `claim.reorder`), and `onChange`.
|
|
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
|
*/
|
|
@@ -16,11 +16,10 @@ import type { ModelRegistry } from '../ModelRegistry.js';
|
|
|
16
16
|
import type { InstanceCache } from '../InstanceCache.js';
|
|
17
17
|
import type { SyncClient } from '../SyncClient.js';
|
|
18
18
|
import type { OnDemandLoader } from '../sync/OnDemandLoader.js';
|
|
19
|
-
import type { JoinedParticipant } from '../sync/participants.js';
|
|
20
19
|
import type { Duration, Claim, ClaimHeartbeat, ClaimHeartbeatOptions, HeldClaim, HeldLease, ClaimWaitOptions } from '@abloatai/transaction/types/streams';
|
|
21
|
-
export type { ModelListScope, LocalReadOptions, LocalCountOptions, ServerReadOptions, ListAllOptions, ServerPointReadOptions, ClaimTargetOptions, ClaimParams, ClaimContentionOptions, ClaimAttemptEvent, ClaimQueueView, ClaimSkipOptions, ClaimSkipParams, ClaimLookupParams, ClaimReorderParams, ClaimOptions, ClaimReadApi, AwaitedClaimMethod, ClaimApi, ModelReadParams, ModelCreateParams, ModelUpdateParams, ModelDeleteParams,
|
|
20
|
+
export type { ModelListScope, LocalReadOptions, LocalCountOptions, ServerReadOptions, ListAllOptions, ServerPointReadOptions, ClaimTargetOptions, ClaimParams, ClaimContentionOptions, ClaimAttemptEvent, ClaimQueueView, ClaimSkipOptions, ClaimSkipParams, ClaimLookupParams, ClaimReorderParams, ClaimOptions, ClaimReadApi, AwaitedClaimMethod, ClaimApi, ModelReadParams, ModelCreateParams, ModelUpdateParams, ModelDeleteParams, } from '@abloatai/transaction/client/resources/modelOperations';
|
|
22
21
|
export type { Claim, ClaimHeartbeat, ClaimHeartbeatOptions, HeldClaim, HeldLease };
|
|
23
|
-
import type { ClaimApi, ClaimAttemptEvent,
|
|
22
|
+
import type { ClaimApi, ClaimAttemptEvent, LocalCountOptions, LocalReadOptions } from '@abloatai/transaction/client/resources/modelOperations';
|
|
24
23
|
import type { HttpModelClient } from '@abloatai/transaction/transport/http';
|
|
25
24
|
import type { ParticipantKind } from '@abloatai/transaction/types/participant';
|
|
26
25
|
import { type ReadSetContext } from '@abloatai/transaction/internal/read-set';
|
|
@@ -142,13 +141,6 @@ export interface ModelCollaboration {
|
|
|
142
141
|
* fire-and-forget, best-effort semantics as `enterScope`.
|
|
143
142
|
*/
|
|
144
143
|
pinScope?(scope: Record<string, string>): void | Promise<void>;
|
|
145
|
-
/**
|
|
146
|
-
* Opens a presence and claim subscription on this model's sync group(s) and
|
|
147
|
-
* returns the live participant handle. Backs `ablo.<model>.join(ids)`.
|
|
148
|
-
* WebSocket only, since presence needs a live socket; it is absent on other
|
|
149
|
-
* client constructions, where the surface throws a clear error.
|
|
150
|
-
*/
|
|
151
|
-
createJoin?(modelKey: string, ids: string | readonly string[], options?: JoinOptions): Promise<JoinedParticipant>;
|
|
152
144
|
}
|
|
153
145
|
/**
|
|
154
146
|
* The synchronous, local-only reads — reached as `ablo.<model>.local.*`.
|
|
@@ -222,22 +214,6 @@ interface ReactiveModelSurface<T, Fields = T> {
|
|
|
222
214
|
* ```
|
|
223
215
|
*/
|
|
224
216
|
claim: ClaimApi<T, Fields>;
|
|
225
|
-
/**
|
|
226
|
-
* Joins the sync group(s) for one or more rows of this model and returns a
|
|
227
|
-
* live participant handle — presence (`.peers`), the scoped claim stream
|
|
228
|
-
* (`.claims`), and `.leave()` / `await using` disposal. This is a presence
|
|
229
|
-
* subscription: it reports who else is here and what they hold, not row
|
|
230
|
-
* values changing — for the latter, use `onChange`.
|
|
231
|
-
*
|
|
232
|
-
* WebSocket only: presence needs a live socket, so this is absent on HTTP
|
|
233
|
-
* clients and throws on any non-WebSocket construction.
|
|
234
|
-
*
|
|
235
|
-
* ```ts
|
|
236
|
-
* await using participant = await ablo.sections.join(sectionIds, { ttl: '5m' });
|
|
237
|
-
* participant.peers; // who else is here
|
|
238
|
-
* ```
|
|
239
|
-
*/
|
|
240
|
-
join(ids: string | readonly string[], options?: JoinOptions): Promise<JoinedParticipant>;
|
|
241
217
|
/** Subscribe to changes; the callback runs on every change. */
|
|
242
218
|
onChange(callback: (entities: T[]) => void, options?: LocalReadOptions<T>): () => void;
|
|
243
219
|
}
|
|
@@ -6,7 +6,7 @@
|
|
|
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
|
-
* `claim.queue`, `claim.release`, and `claim.reorder`),
|
|
9
|
+
* `claim.queue`, `claim.release`, and `claim.reorder`), and `onChange`.
|
|
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
|
*/
|
|
@@ -31,6 +31,8 @@ import { ModelScope } from '@abloatai/transaction/types';
|
|
|
31
31
|
import { bindClaimLifetime, claimLifetimeOf, } from '@abloatai/transaction/claims/lifetime';
|
|
32
32
|
import { claimQueueView, resolveClaimContentionOptions, } from '@abloatai/transaction/client/resources/modelOperations';
|
|
33
33
|
import { capturePointRead, prepareReadSet, } from '@abloatai/transaction/internal/read-set';
|
|
34
|
+
const ignoreSeparatelyObservedMutationFailure = () => undefined;
|
|
35
|
+
const ignoreBestEffortClaimReleaseFailure = () => undefined;
|
|
34
36
|
const modelClientMeta = new WeakMap();
|
|
35
37
|
export function getModelClientMeta(modelClient) {
|
|
36
38
|
if (typeof modelClient !== 'object' || modelClient === null)
|
|
@@ -113,7 +115,7 @@ hydration, collaboration, readSetContext) {
|
|
|
113
115
|
// await does not create an unhandled-rejection process error. Returning
|
|
114
116
|
// the original promise preserves normal rejection for callers that do
|
|
115
117
|
// await or attach their own catch handler.
|
|
116
|
-
void confirmation.catch(
|
|
118
|
+
void confirmation.catch(ignoreSeparatelyObservedMutationFailure);
|
|
117
119
|
return confirmation;
|
|
118
120
|
};
|
|
119
121
|
};
|
|
@@ -130,7 +132,7 @@ hydration, collaboration, readSetContext) {
|
|
|
130
132
|
const rows = await hydration.fetch(schemaKey, options);
|
|
131
133
|
return rows.map((row) => modelAsRow(row));
|
|
132
134
|
};
|
|
133
|
-
const waitForMutation = async (model) => {
|
|
135
|
+
const waitForMutation = async (model, exactConfirmation) => {
|
|
134
136
|
// Model writes are optimistic locally, but their promise has one stable
|
|
135
137
|
// meaning: authoritative confirmation. Callers that do not need the
|
|
136
138
|
// barrier can keep using the row immediately and leave the promise to the
|
|
@@ -142,7 +144,10 @@ hydration, collaboration, readSetContext) {
|
|
|
142
144
|
// coalescer and producing one SQL transaction per delta.
|
|
143
145
|
await Promise.resolve();
|
|
144
146
|
await syncClient.syncNow();
|
|
145
|
-
|
|
147
|
+
if (exactConfirmation)
|
|
148
|
+
await exactConfirmation;
|
|
149
|
+
else
|
|
150
|
+
await syncClient.waitForConfirmation(model.getModelName(), model.id);
|
|
146
151
|
};
|
|
147
152
|
const activeClaims = new Map();
|
|
148
153
|
const claimIdsByEntity = new Map();
|
|
@@ -223,9 +228,9 @@ hydration, collaboration, readSetContext) {
|
|
|
223
228
|
// This runs after authoritative confirmation. A best-effort abandon frame
|
|
224
229
|
// cannot turn a committed write into an apparent failure; the server has
|
|
225
230
|
// already fulfilled the participant's claims as part of that commit.
|
|
226
|
-
await releaseClaimsForEntity(entityId).catch(
|
|
231
|
+
await releaseClaimsForEntity(entityId).catch(ignoreBestEffortClaimReleaseFailure);
|
|
227
232
|
if (explicit && !explicitWasLocal) {
|
|
228
|
-
await explicit.release?.().catch(
|
|
233
|
+
await explicit.release?.().catch(ignoreBestEffortClaimReleaseFailure);
|
|
229
234
|
}
|
|
230
235
|
};
|
|
231
236
|
const takeClaim = async (params) => {
|
|
@@ -811,12 +816,12 @@ hydration, collaboration, readSetContext) {
|
|
|
811
816
|
}
|
|
812
817
|
: {}),
|
|
813
818
|
};
|
|
814
|
-
syncClient.add(model, effective);
|
|
815
|
-
await waitForMutation(model);
|
|
819
|
+
const confirmation = syncClient.add(model, effective);
|
|
820
|
+
await waitForMutation(model, confirmation);
|
|
816
821
|
return modelAsRow(model);
|
|
817
822
|
}
|
|
818
823
|
finally {
|
|
819
|
-
await autoLease?.release?.().catch(
|
|
824
|
+
await autoLease?.release?.().catch(ignoreBestEffortClaimReleaseFailure);
|
|
820
825
|
}
|
|
821
826
|
});
|
|
822
827
|
function createRows(params) {
|
|
@@ -888,8 +893,8 @@ hydration, collaboration, readSetContext) {
|
|
|
888
893
|
: {}),
|
|
889
894
|
};
|
|
890
895
|
model.applyChanges(patch);
|
|
891
|
-
syncClient.update(model, effective);
|
|
892
|
-
await waitForMutation(model);
|
|
896
|
+
const confirmation = syncClient.update(model, effective, patch);
|
|
897
|
+
await waitForMutation(model, confirmation);
|
|
893
898
|
return modelAsRow(model);
|
|
894
899
|
},
|
|
895
900
|
});
|
|
@@ -939,8 +944,8 @@ hydration, collaboration, readSetContext) {
|
|
|
939
944
|
// the server. (`updateFromData` is the hydration path and would discard
|
|
940
945
|
// the tracking, producing an empty `input: {}` no-op mutation.)
|
|
941
946
|
model.applyChanges(params.data);
|
|
942
|
-
syncClient.update(model, effective);
|
|
943
|
-
await waitForMutation(model);
|
|
947
|
+
const confirmation = syncClient.update(model, effective, params.data);
|
|
948
|
+
await waitForMutation(model, confirmation);
|
|
944
949
|
const updated = modelAsRow(model);
|
|
945
950
|
await settleClaimsAfterWrite(id, handle);
|
|
946
951
|
return updated;
|
|
@@ -997,19 +1002,13 @@ hydration, collaboration, readSetContext) {
|
|
|
997
1002
|
...opts,
|
|
998
1003
|
...(selected ? { claimRef: { id: selected.id } } : {}),
|
|
999
1004
|
};
|
|
1000
|
-
syncClient.delete(model, effective);
|
|
1001
|
-
await waitForMutation(model);
|
|
1005
|
+
const confirmation = syncClient.delete(model, effective);
|
|
1006
|
+
await waitForMutation(model, confirmation);
|
|
1002
1007
|
await settleClaimsAfterWrite(id, handle);
|
|
1003
1008
|
}),
|
|
1004
1009
|
// `claim` is a callable namespace (take a claim) carrying the coordination
|
|
1005
1010
|
// readers (`claim.state` / `claim.queue` / `claim.release` / `claim.reorder`).
|
|
1006
1011
|
claim: claimApi,
|
|
1007
|
-
join: guard((ids, options) => {
|
|
1008
|
-
if (!collaboration?.createJoin) {
|
|
1009
|
-
throw new AbloValidationError(`Model "${schemaKey}" was built without a WebSocket runtime, so join() is unavailable here. Presence needs a live socket — use the standard Ablo({ schema, apiKey }) client (not the HTTP transport).`, { code: 'model_join_not_configured' });
|
|
1010
|
-
}
|
|
1011
|
-
return collaboration.createJoin(schemaKey, ids, options);
|
|
1012
|
-
}),
|
|
1013
1012
|
onChange(callback, options) {
|
|
1014
1013
|
return autorun(() => {
|
|
1015
1014
|
callback(local.list(options));
|
|
@@ -17,6 +17,7 @@ import type { CommitOutboxScope } from '@abloatai/transaction/commit';
|
|
|
17
17
|
*/
|
|
18
18
|
export type { CredentialProvider } from '@abloatai/transaction/auth/apiKey';
|
|
19
19
|
import type { CredentialProvider } from '@abloatai/transaction/auth/apiKey';
|
|
20
|
+
import type { SessionCredential, SessionEndpoint, SessionProvider } from '@abloatai/transaction/sessions';
|
|
20
21
|
import type { AbloPlugin } from '../../plugin.js';
|
|
21
22
|
import type { ParticipantKind } from '@abloatai/transaction/types/participant';
|
|
22
23
|
/**
|
|
@@ -56,18 +57,19 @@ export interface AbloOptions<S extends SchemaRecord = SchemaRecord> {
|
|
|
56
57
|
* `ABLO_API_KEY` environment variable, so you usually pass nothing. A
|
|
57
58
|
* long-lived key needs no refresh; the client uses it as-is.
|
|
58
59
|
*
|
|
59
|
-
* - **An async resolver**
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
* the same renewal machinery as the endpoint form.
|
|
63
|
-
*
|
|
64
|
-
* The endpoint and resolver forms share one contract: return a token; return
|
|
65
|
-
* `null` when the login itself is gone (terminal — the client signs out and
|
|
66
|
-
* fails `ready()` with `session_expired`); or throw on a transient failure, which
|
|
67
|
-
* backs off and retries without signing out. The endpoint form maps HTTP onto
|
|
68
|
-
* this for you: only a structured `401 session_expired` means signed out.
|
|
60
|
+
* - **An async resolver** for advanced process-owned key rotation, such as a
|
|
61
|
+
* vault or workload-identity exchange. Scoped actor renewal belongs in
|
|
62
|
+
* `session` instead.
|
|
69
63
|
*/
|
|
70
64
|
apiKey?: string | CredentialProvider | null | undefined;
|
|
65
|
+
/**
|
|
66
|
+
* Scoped actor identity. Pass a session returned by `sessions.create()` for
|
|
67
|
+
* bounded work, a provider that re-mints it for a long-lived client, or
|
|
68
|
+
* `{ endpoint: '/api/ablo-session' }` in a browser. Endpoint responses use
|
|
69
|
+
* the canonical credential protocol; only a structured `401
|
|
70
|
+
* session_expired` ends the underlying login.
|
|
71
|
+
*/
|
|
72
|
+
session?: SessionCredential | SessionProvider | SessionEndpoint | null | undefined;
|
|
71
73
|
/**
|
|
72
74
|
* Pins this client to one Ablo project. During `ready()` the server resolves
|
|
73
75
|
* the API key's actual project and the client refuses to start when it differs.
|
|
@@ -84,30 +86,6 @@ export interface AbloOptions<S extends SchemaRecord = SchemaRecord> {
|
|
|
84
86
|
* environment could reach this process.
|
|
85
87
|
*/
|
|
86
88
|
branchId?: string | null | undefined;
|
|
87
|
-
/**
|
|
88
|
-
* The session-mint endpoint — the browser-side auth field, and the named
|
|
89
|
-
* endpoint for the route that mints the signed-in user's short-lived token:
|
|
90
|
-
*
|
|
91
|
-
* ```ts
|
|
92
|
-
* const ablo = Ablo({ schema, authEndpoint: '/api/ablo-session' });
|
|
93
|
-
* ```
|
|
94
|
-
*
|
|
95
|
-
* The client owns the whole exchange: it POSTs the route (same-origin, cookies
|
|
96
|
-
* included), validates the canonical auth response contract, keeps it fresh
|
|
97
|
-
* ahead of expiry, and re-mints when the server reports the token stale. Only
|
|
98
|
-
* a structured `401 session_expired` response means signed out. It also
|
|
99
|
-
* accepts an async resolver `() => Promise<string | null>` when the exchange
|
|
100
|
-
* needs custom headers or a body — the same contract as the resolver form of
|
|
101
|
-
* `apiKey`.
|
|
102
|
-
*
|
|
103
|
-
* Mutually exclusive with `apiKey`: a server holds a key, a browser holds a mint
|
|
104
|
-
* route, and passing both is a validation error.
|
|
105
|
-
*/
|
|
106
|
-
authEndpoint?: string | CredentialProvider | null | undefined;
|
|
107
|
-
/** Timeout for a session-mint request. @default 10000 */
|
|
108
|
-
authTimeoutMs?: number | undefined;
|
|
109
|
-
/** Explicit opt-in for a cross-origin session-mint endpoint. */
|
|
110
|
-
allowCrossOriginAuthEndpoint?: boolean | undefined;
|
|
111
89
|
/**
|
|
112
90
|
* Local persistence mode. Pass `indexeddb` only when you want offline
|
|
113
91
|
* queueing and a reload-surviving browser cache.
|
|
@@ -222,13 +200,10 @@ export interface InternalAbloOptions<S extends SchemaRecord = SchemaRecord> {
|
|
|
222
200
|
* the original available as `cause`.
|
|
223
201
|
*/
|
|
224
202
|
apiKey?: string | CredentialProvider | null | undefined;
|
|
203
|
+
/** A scoped session, or a provider that re-mints it for a long-lived client. */
|
|
204
|
+
session?: SessionCredential | SessionProvider | SessionEndpoint | null | undefined;
|
|
225
205
|
/** Expected project assertion; see {@link AbloOptions.projectId}. */
|
|
226
206
|
projectId?: string | null | undefined;
|
|
227
|
-
/**
|
|
228
|
-
* Session-mint endpoint (string or async resolver) — see
|
|
229
|
-
* {@link AbloOptions.authEndpoint}. Mutually exclusive with `apiKey`.
|
|
230
|
-
*/
|
|
231
|
-
authEndpoint?: string | CredentialProvider | null | undefined;
|
|
232
207
|
/**
|
|
233
208
|
* A bearer auth token, sent as `Authorization: Bearer <token>` on every request.
|
|
234
209
|
*
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
* The reactive engine assembly (ADR 0016). `Ablo({ ... })` resolves auth and
|
|
3
3
|
* capabilities; `humans().init` constructs the store cluster; the lifecycle
|
|
4
4
|
* — first mint, identity, ready() — lives in `./storeLifecycle.ts`. What
|
|
5
|
-
* remains here is assembly around those parts: the claim
|
|
6
|
-
*
|
|
7
|
-
* commit
|
|
5
|
+
* remains here is assembly around those parts: the claim and presence streams,
|
|
6
|
+
* options validation, the typed model proxies, and the
|
|
7
|
+
* commit and claim resources — composed into the reactive client.
|
|
8
8
|
*
|
|
9
9
|
* Extracted from the factory so the composition root stays a root: resolve,
|
|
10
10
|
* dispatch, return. The remaining assembly converts to decoration of a
|
|
@@ -43,11 +43,5 @@ export interface ReactiveEngineInputs<S extends SchemaRecord> extends ClientPrel
|
|
|
43
43
|
* assembles around it and constructs none of it.
|
|
44
44
|
*/
|
|
45
45
|
cluster: StoreCluster;
|
|
46
|
-
/**
|
|
47
|
-
* Constructs a sibling client (`ablo.agents.create(...)` mints a scoped key
|
|
48
|
-
* and builds a second engine with it). Injected by the factory — a direct
|
|
49
|
-
* import back into it would close a runtime cycle.
|
|
50
|
-
*/
|
|
51
|
-
createSibling: (options: AbloOptions<S>) => Ablo<S>;
|
|
52
46
|
}
|
|
53
47
|
export declare function buildReactiveEngine<const S extends SchemaRecord>(inputs: ReactiveEngineInputs<S>): Ablo<S>;
|