@abloatai/humans 0.60.0 → 0.62.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/Ablo.d.ts +4 -4
- package/dist/Ablo.js +2 -1
- package/dist/client.d.ts +7 -12
- package/dist/humans.d.ts +2 -2
- package/dist/humans.js +2 -6
- package/dist/local/BaseSyncedStore.d.ts +2 -4
- package/dist/local/BaseSyncedStore.js +5 -8
- 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 +8 -32
- package/dist/local/SyncClient.js +26 -99
- package/dist/local/client/clientPrelude.d.ts +2 -0
- package/dist/local/client/clientPrelude.js +13 -1
- package/dist/local/client/createInternalComponents.js +2 -0
- package/dist/local/client/createModelOperations.d.ts +5 -0
- package/dist/local/client/createModelOperations.js +7 -4
- package/dist/local/client/reactiveEngine.d.ts +2 -2
- package/dist/local/client/reactiveEngine.js +9 -11
- package/dist/local/fileUploads.d.ts +27 -0
- package/dist/local/fileUploads.js +55 -0
- package/dist/local/query/client.d.ts +3 -0
- package/dist/local/query/client.js +1 -1
- package/dist/local/stores/syncAction.d.ts +1 -1
- package/dist/local/sync/BootstrapFetcher.d.ts +2 -0
- package/dist/local/sync/BootstrapFetcher.js +4 -4
- package/dist/local/sync/OnDemandLoader.d.ts +2 -0
- package/dist/local/sync/OnDemandLoader.js +1 -0
- package/dist/local/sync/SyncWebSocket.d.ts +2 -15
- package/dist/local/sync/SyncWebSocket.js +1 -36
- package/dist/local/sync/contextOnChange.js +1 -1
- package/dist/local/sync/createClaimStream.d.ts +9 -19
- package/dist/local/sync/createClaimStream.js +41 -56
- package/dist/local/sync/deltaPipeline.js +12 -6
- package/dist/local/sync/schemas.d.ts +2 -2
- package/dist/local/sync/socketEventWiring.d.ts +1 -2
- package/dist/local/sync/socketEventWiring.js +1 -5
- 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 +8 -1
- 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 +4 -4
- package/dist/presence/index.d.ts +15 -0
- package/dist/presence/index.js +49 -0
- package/dist/react/AbloProvider.d.ts +3 -3
- package/dist/react/AbloProvider.js +4 -3
- package/dist/react/useErrorListener.js +1 -1
- package/dist/react/useMutationFailureListener.js +1 -1
- package/dist/surface.d.ts +1 -1
- package/dist/surface.js +1 -0
- package/package.json +3 -4
- package/src/Ablo.ts +15 -6
- package/src/client.ts +8 -13
- package/src/humans.ts +3 -10
- package/src/local/BaseSyncedStore.ts +5 -11
- 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 +33 -127
- package/src/local/client/clientPrelude.ts +20 -0
- package/src/local/client/createInternalComponents.ts +2 -0
- package/src/local/client/createModelOperations.ts +17 -6
- package/src/local/client/reactiveEngine.ts +19 -14
- package/src/local/fileUploads.ts +97 -0
- package/src/local/query/client.ts +4 -0
- package/src/local/sync/BootstrapFetcher.ts +13 -4
- package/src/local/sync/OnDemandLoader.ts +3 -0
- package/src/local/sync/SyncWebSocket.ts +1 -42
- package/src/local/sync/contextOnChange.ts +1 -1
- package/src/local/sync/createClaimStream.ts +52 -72
- package/src/local/sync/deltaPipeline.ts +10 -6
- package/src/local/sync/socketEventWiring.ts +1 -8
- 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/presence/index.ts +72 -0
- package/src/react/AbloProvider.tsx +14 -9
- package/src/react/useErrorListener.ts +1 -1
- package/src/react/useMutationFailureListener.ts +1 -1
- package/src/surface.ts +1 -0
- package/dist/local/transactions/mutations/pendingDrain.d.ts +0 -33
- package/dist/local/transactions/mutations/pendingDrain.js +0 -117
- package/dist/presenceStream.d.ts +0 -69
- package/dist/presenceStream.js +0 -200
- package/src/local/transactions/mutations/pendingDrain.ts +0 -169
- package/src/presenceStream.ts +0 -279
|
@@ -11,7 +11,9 @@
|
|
|
11
11
|
* `./Ablo.ts` calls this once and hands the result to whichever client the
|
|
12
12
|
* plugin list selected.
|
|
13
13
|
*/
|
|
14
|
+
import { AbloValidationError } from '@abloatai/transaction/errors';
|
|
14
15
|
import { createAuthCredentialSource, } from '@abloatai/transaction/auth/credentialSource';
|
|
16
|
+
import { createPresenceSessionSource, } from '@abloatai/transaction/presence';
|
|
15
17
|
import { assertBrowserSafety, readProcessEnv, rejectRemovedDatabaseUrlOption, resolveApiKey, resolveAuthToken, resolveBaseURL, resolveCredentialResolver, warnIfCliKeyMismatch, } from '@abloatai/transaction/auth/apiKey';
|
|
16
18
|
import { createConsoleLogger, resolveLogLevel } from './consoleLogger.js';
|
|
17
19
|
/**
|
|
@@ -22,6 +24,14 @@ import { createConsoleLogger, resolveLogLevel } from './consoleLogger.js';
|
|
|
22
24
|
* different project than the one being addressed (a warning, not a throw).
|
|
23
25
|
*/
|
|
24
26
|
export function resolveClientPrelude(options) {
|
|
27
|
+
// This package owns the reactive materialiser, not transport selection.
|
|
28
|
+
// TypeScript can miss excess properties on generic calls and object spreads,
|
|
29
|
+
// so reject a misplaced selector before resolving any credentials or
|
|
30
|
+
// constructing local state. The core `Ablo` client owns `transport`.
|
|
31
|
+
if ('transport' in options) {
|
|
32
|
+
throw new AbloValidationError("The reactive client does not accept `transport`. Import `Ablo` from " +
|
|
33
|
+
"'@abloatai/ablo' when selecting HTTP or WebSocket transport.", { code: 'invalid_options', param: 'transport' });
|
|
34
|
+
}
|
|
25
35
|
const env = readProcessEnv();
|
|
26
36
|
const internalOptions = {
|
|
27
37
|
...options,
|
|
@@ -32,9 +42,10 @@ export function resolveClientPrelude(options) {
|
|
|
32
42
|
const configuredApiKey = resolveApiKey(authInput);
|
|
33
43
|
const configuredAuthToken = resolveAuthToken(authInput);
|
|
34
44
|
const credentialResolver = resolveCredentialResolver(configuredApiKey);
|
|
45
|
+
const presenceSession = createPresenceSessionSource();
|
|
35
46
|
const authCredentials = createAuthCredentialSource(
|
|
36
47
|
// eslint-disable-next-line @typescript-eslint/no-deprecated -- load-bearing on the self-hosted path; server-internal cap-mint (Phase 3) not shipped
|
|
37
|
-
internalOptions.capabilityToken ?? configuredAuthToken);
|
|
48
|
+
internalOptions.capabilityToken ?? configuredAuthToken, presenceSession);
|
|
38
49
|
rejectRemovedDatabaseUrlOption(options);
|
|
39
50
|
assertBrowserSafety({
|
|
40
51
|
apiKey: configuredApiKey,
|
|
@@ -56,6 +67,7 @@ export function resolveClientPrelude(options) {
|
|
|
56
67
|
configuredAuthToken,
|
|
57
68
|
credentialResolver,
|
|
58
69
|
authCredentials,
|
|
70
|
+
presenceSession,
|
|
59
71
|
logger,
|
|
60
72
|
url: resolveBaseURL(authInput),
|
|
61
73
|
participantId,
|
|
@@ -39,6 +39,7 @@ export function createInternalComponents(input) {
|
|
|
39
39
|
syncGroups: options.syncGroups,
|
|
40
40
|
instantModels: deriveInstantModels(schema),
|
|
41
41
|
getAuthToken: auth?.getAuthToken,
|
|
42
|
+
presenceSession: auth?.presenceSession,
|
|
42
43
|
runtime,
|
|
43
44
|
});
|
|
44
45
|
const database = new Database(modelRegistry, bootstrapHelper, {
|
|
@@ -61,6 +62,7 @@ export function createInternalComponents(input) {
|
|
|
61
62
|
schema,
|
|
62
63
|
baseUrl: bootstrapBaseUrl,
|
|
63
64
|
getAuthToken: auth?.getAuthToken,
|
|
65
|
+
presenceSession: auth?.presenceSession,
|
|
64
66
|
runtime,
|
|
65
67
|
// The one canonical log position; the loader reads its floor when a query
|
|
66
68
|
// leaves so a late answer cannot overwrite a row the pool already knows to
|
|
@@ -22,6 +22,7 @@ export type { Claim, ClaimHeartbeat, ClaimHeartbeatOptions, HeldClaim, HeldLease
|
|
|
22
22
|
import type { ClaimApi, ClaimAttemptEvent, LocalCountOptions, LocalReadOptions } from '@abloatai/transaction/client/resources/modelOperations';
|
|
23
23
|
import type { HttpModelClient } from '@abloatai/transaction/transport/http';
|
|
24
24
|
import type { ParticipantKind } from '@abloatai/transaction/types/participant';
|
|
25
|
+
import type { PresenceSession } from '@abloatai/transaction/presence';
|
|
25
26
|
import { type ReadSetContext } from '@abloatai/transaction/internal/read-set';
|
|
26
27
|
export interface ModelClientMeta {
|
|
27
28
|
readonly key: string;
|
|
@@ -34,6 +35,8 @@ export declare function getModelClientMeta(modelClient: unknown): ModelClientMet
|
|
|
34
35
|
*/
|
|
35
36
|
type EntityHalf = Pick<ModelTarget, 'model' | 'id'>;
|
|
36
37
|
export interface ModelCollaboration {
|
|
38
|
+
/** Session projections already held by this client's one presence store. */
|
|
39
|
+
presence(model: string, recordId?: string): readonly PresenceSession[];
|
|
37
40
|
/** Exact point evidence from the HTTP read boundary (stamp captured before data). */
|
|
38
41
|
readPoint(model: string, id: string): Promise<{
|
|
39
42
|
data: unknown;
|
|
@@ -187,6 +190,8 @@ export interface LocalReads<T> {
|
|
|
187
190
|
interface ReactiveModelSurface<T, Fields = T> {
|
|
188
191
|
/** The synchronous local-graph reads. */
|
|
189
192
|
local: LocalReads<T>;
|
|
193
|
+
/** Sessions currently active on this model, optionally narrowed to one record. */
|
|
194
|
+
presence(recordId?: string): readonly PresenceSession[];
|
|
190
195
|
/**
|
|
191
196
|
* Claim a row so other writers wait or are rejected until you're done, and
|
|
192
197
|
* inspect or manage that coordination through the same namespace. Call it to
|
|
@@ -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
|
};
|
|
@@ -226,9 +228,9 @@ hydration, collaboration, readSetContext) {
|
|
|
226
228
|
// This runs after authoritative confirmation. A best-effort abandon frame
|
|
227
229
|
// cannot turn a committed write into an apparent failure; the server has
|
|
228
230
|
// already fulfilled the participant's claims as part of that commit.
|
|
229
|
-
await releaseClaimsForEntity(entityId).catch(
|
|
231
|
+
await releaseClaimsForEntity(entityId).catch(ignoreBestEffortClaimReleaseFailure);
|
|
230
232
|
if (explicit && !explicitWasLocal) {
|
|
231
|
-
await explicit.release?.().catch(
|
|
233
|
+
await explicit.release?.().catch(ignoreBestEffortClaimReleaseFailure);
|
|
232
234
|
}
|
|
233
235
|
};
|
|
234
236
|
const takeClaim = async (params) => {
|
|
@@ -819,7 +821,7 @@ hydration, collaboration, readSetContext) {
|
|
|
819
821
|
return modelAsRow(model);
|
|
820
822
|
}
|
|
821
823
|
finally {
|
|
822
|
-
await autoLease?.release?.().catch(
|
|
824
|
+
await autoLease?.release?.().catch(ignoreBestEffortClaimReleaseFailure);
|
|
823
825
|
}
|
|
824
826
|
});
|
|
825
827
|
function createRows(params) {
|
|
@@ -827,6 +829,7 @@ hydration, collaboration, readSetContext) {
|
|
|
827
829
|
}
|
|
828
830
|
const operations = {
|
|
829
831
|
local,
|
|
832
|
+
presence: (recordId) => collaboration?.presence(registeredModelName, recordId) ?? [],
|
|
830
833
|
get,
|
|
831
834
|
read,
|
|
832
835
|
// No automatic scope enrolment on bulk `list`: that would subscribe to an
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
import type { SchemaRecord } from '@abloatai/transaction/schema/schema';
|
|
15
15
|
import type { StoreCluster } from './storeCluster.js';
|
|
16
16
|
import type { SyncWebSocket } from '../sync/SyncWebSocket.js';
|
|
17
|
-
import type
|
|
17
|
+
import { type AttachablePresence } from '../../presence/index.js';
|
|
18
18
|
import type { AbloOptions } from './options.js';
|
|
19
19
|
import type { ClientPrelude } from './clientPrelude.js';
|
|
20
20
|
import type { AbloClient as Ablo } from '../../client.js';
|
|
@@ -36,7 +36,7 @@ export interface ReactiveEngineInputs<S extends SchemaRecord> extends ClientPrel
|
|
|
36
36
|
transport: SyncWebSocket;
|
|
37
37
|
/** The humans() plugin's contribution — built by its `init`, already
|
|
38
38
|
* attached to the connection the context carried. */
|
|
39
|
-
presence:
|
|
39
|
+
presence: AttachablePresence;
|
|
40
40
|
/**
|
|
41
41
|
* The store cluster `humans().init` constructed from the widened context:
|
|
42
42
|
* this client's runtime, the component graph, and the store. The engine
|
|
@@ -20,6 +20,7 @@ import { startStoreLifecycle } from './storeLifecycle.js';
|
|
|
20
20
|
import { createClaimStream } from '../sync/createClaimStream.js';
|
|
21
21
|
import { awaitClaimGrant } from '@abloatai/transaction/claims';
|
|
22
22
|
import { bindClaimLifetime, claimLifetimeOf, } from '@abloatai/transaction/claims/lifetime';
|
|
23
|
+
import { attachPresenceToClient, } from '../../presence/index.js';
|
|
23
24
|
import { resolveApiKeyValue, resolveBootstrapBaseUrl } from '@abloatai/transaction/auth/apiKey';
|
|
24
25
|
import { claimAttemptFailure, emitClaimStatus, } from '@abloatai/transaction/client/resources/modelOperations';
|
|
25
26
|
import { createModelOperations } from './createModelOperations.js';
|
|
@@ -80,7 +81,7 @@ export function buildReactiveEngine(inputs) {
|
|
|
80
81
|
// filter own echoes by participant id, seeded in `ready()` alongside the
|
|
81
82
|
// locals above.
|
|
82
83
|
const presenceStream = presence;
|
|
83
|
-
const claimStream = createClaimStream({
|
|
84
|
+
const claimStream = createClaimStream({ logger }, transport, presenceStream);
|
|
84
85
|
// 6. Validate options up front — fail loudly on obviously wrong inputs so
|
|
85
86
|
// strangers don't get silent empty results. Validation errors are written
|
|
86
87
|
// into `store.syncStatus` (the single source of truth).
|
|
@@ -131,17 +132,11 @@ export function buildReactiveEngine(inputs) {
|
|
|
131
132
|
kind,
|
|
132
133
|
logger,
|
|
133
134
|
validationError: _validationError,
|
|
134
|
-
onIdentityResolved: ({ userId, participantKind, accountScope,
|
|
135
|
+
onIdentityResolved: ({ userId, participantKind, accountScope, authority }) => {
|
|
135
136
|
selfParticipantId = userId;
|
|
136
137
|
selfParticipantKind = participantKind;
|
|
137
138
|
_resolvedOrganizationId = accountScope;
|
|
138
139
|
_resolvedIdentity = authority;
|
|
139
|
-
presenceStream.setParticipant({
|
|
140
|
-
id: userId,
|
|
141
|
-
kind: participantKind,
|
|
142
|
-
syncGroups: [...syncGroups],
|
|
143
|
-
});
|
|
144
|
-
claimStream.setParticipant({ id: userId });
|
|
145
140
|
},
|
|
146
141
|
});
|
|
147
142
|
const ready = lifecycle.ready;
|
|
@@ -398,6 +393,7 @@ export function buildReactiveEngine(inputs) {
|
|
|
398
393
|
for (const [schemaKey, modelDef] of Object.entries(schema.models)) {
|
|
399
394
|
const registeredModelName = modelDef.typename ?? schemaKey;
|
|
400
395
|
modelProxies[schemaKey] = createModelOperations(schemaKey, registeredModelName, objectPool, syncClient, modelRegistry, hydration, {
|
|
396
|
+
presence: (model, recordId) => presenceStream.forModel(model, recordId),
|
|
401
397
|
createClaim: (claimOptions) => publicClaims.create(claimOptions),
|
|
402
398
|
// Lazily referenced: `commits` is declared below this loop, and this
|
|
403
399
|
// only runs when someone actually writes a batch.
|
|
@@ -599,6 +595,10 @@ export function buildReactiveEngine(inputs) {
|
|
|
599
595
|
get syncStatus() {
|
|
600
596
|
return store.syncStatus;
|
|
601
597
|
},
|
|
598
|
+
// The humans capability owns the connection-backed presence projection.
|
|
599
|
+
// Keep it on the base client as well as in the plugin surface so the
|
|
600
|
+
// concrete AbloClient contract and runtime object agree before layering.
|
|
601
|
+
presence: presenceStream,
|
|
602
602
|
schema,
|
|
603
603
|
// ── Internal accessors for framework integration ─────────────────
|
|
604
604
|
// These expose internal components for consumers that need direct
|
|
@@ -611,13 +611,11 @@ export function buildReactiveEngine(inputs) {
|
|
|
611
611
|
get _pool() { return objectPool; },
|
|
612
612
|
/** The SyncWebSocket — for collaboration events (selection, cursors). */
|
|
613
613
|
get _ws() { return store.getSyncWebSocket(); },
|
|
614
|
-
/** Presence livestream — same socket as entity sync, no second
|
|
615
|
-
* connection. Stable reference across the engine's lifetime. */
|
|
616
|
-
presence: presenceStream,
|
|
617
614
|
/** Claim livestream — same socket. Stable reference. */
|
|
618
615
|
claims: publicClaims,
|
|
619
616
|
commits,
|
|
620
617
|
};
|
|
618
|
+
attachPresenceToClient(engine, presenceStream);
|
|
621
619
|
Object.defineProperty(engine, kReadEvidence, {
|
|
622
620
|
value: {
|
|
623
621
|
context: cluster.readSetContext,
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** File-upload behavior owned beneath the SyncClient boundary. */
|
|
2
|
+
import type { RuntimeContext } from './RuntimeContext.js';
|
|
3
|
+
import { Model } from './Model.js';
|
|
4
|
+
import { InstanceCache } from './InstanceCache.js';
|
|
5
|
+
import type { MutationQueue } from './transactions/mutations/MutationQueue.js';
|
|
6
|
+
export interface FileUploadOptions {
|
|
7
|
+
readonly id: string;
|
|
8
|
+
readonly attachableType: string;
|
|
9
|
+
readonly attachableId: string;
|
|
10
|
+
readonly metadata?: Record<string, unknown>;
|
|
11
|
+
}
|
|
12
|
+
export interface BatchFileUploadOptions {
|
|
13
|
+
readonly ids: string[];
|
|
14
|
+
readonly attachableType: string;
|
|
15
|
+
readonly attachableId: string;
|
|
16
|
+
readonly metadata?: Record<string, unknown>;
|
|
17
|
+
}
|
|
18
|
+
export interface FileUploadContext {
|
|
19
|
+
readonly userId: string | null;
|
|
20
|
+
readonly organizationId: string | null;
|
|
21
|
+
readonly mutationQueue: MutationQueue;
|
|
22
|
+
readonly objectPool: InstanceCache;
|
|
23
|
+
readonly observability: RuntimeContext['observability'];
|
|
24
|
+
readonly notifyCreated: (model: Model) => void;
|
|
25
|
+
}
|
|
26
|
+
export declare function uploadFile(context: FileUploadContext, file: File, options: FileUploadOptions): Promise<Model | null>;
|
|
27
|
+
export declare function batchUploadFiles(context: FileUploadContext, files: File[], options: BatchFileUploadOptions): Promise<Model[]>;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/** File-upload behavior owned beneath the SyncClient boundary. */
|
|
2
|
+
import { AbloAuthenticationError } from '@abloatai/transaction/errors';
|
|
3
|
+
import { Model } from './Model.js';
|
|
4
|
+
import { InstanceCache, ModelScope } from './InstanceCache.js';
|
|
5
|
+
function authenticatedContext(context) {
|
|
6
|
+
if (!context.userId || !context.organizationId) {
|
|
7
|
+
throw new AbloAuthenticationError('Authentication required for file uploads', {
|
|
8
|
+
code: 'file_upload_auth_required',
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
return { userId: context.userId, organizationId: context.organizationId };
|
|
12
|
+
}
|
|
13
|
+
function acceptUploadedModel(context, data) {
|
|
14
|
+
const model = context.objectPool.createFromData(data);
|
|
15
|
+
if (!model)
|
|
16
|
+
return null;
|
|
17
|
+
context.objectPool.add(model, ModelScope.live);
|
|
18
|
+
context.notifyCreated(model);
|
|
19
|
+
return model;
|
|
20
|
+
}
|
|
21
|
+
export async function uploadFile(context, file, options) {
|
|
22
|
+
const identity = authenticatedContext(context);
|
|
23
|
+
try {
|
|
24
|
+
const result = await context.mutationQueue.uploadAttachment(file, {
|
|
25
|
+
id: options.id,
|
|
26
|
+
attachableType: options.attachableType,
|
|
27
|
+
attachableId: options.attachableId,
|
|
28
|
+
metadata: options.metadata,
|
|
29
|
+
}, identity);
|
|
30
|
+
return result
|
|
31
|
+
? acceptUploadedModel(context, { id: options.id, ...result })
|
|
32
|
+
: null;
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
context.observability.captureMutationFailure({
|
|
36
|
+
context: 'file-upload',
|
|
37
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
38
|
+
});
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
export async function batchUploadFiles(context, files, options) {
|
|
43
|
+
const identity = authenticatedContext(context);
|
|
44
|
+
const items = options.ids.map((id) => ({
|
|
45
|
+
id,
|
|
46
|
+
attachableType: options.attachableType,
|
|
47
|
+
attachableId: options.attachableId,
|
|
48
|
+
metadata: options.metadata,
|
|
49
|
+
}));
|
|
50
|
+
const results = await context.mutationQueue.batchUploadAttachments(files, items, identity);
|
|
51
|
+
return results.flatMap((result) => {
|
|
52
|
+
const model = acceptUploadedModel(context, { ...result });
|
|
53
|
+
return model ? [model] : [];
|
|
54
|
+
});
|
|
55
|
+
}
|
|
@@ -15,6 +15,7 @@ import type { QueryBatch, QueryBatchResult } from './types.js';
|
|
|
15
15
|
import { type RecoveryClass } from '@abloatai/transaction/errorCodes';
|
|
16
16
|
import { type AuthTokenGetter } from '@abloatai/transaction/auth/credentialSource';
|
|
17
17
|
import type { RuntimeContext } from '../RuntimeContext.js';
|
|
18
|
+
import type { PresenceSessionSource } from '@abloatai/transaction/presence';
|
|
18
19
|
export interface PostQueryOptions {
|
|
19
20
|
/**
|
|
20
21
|
* Full base URL of the sync server including the `/api` prefix.
|
|
@@ -38,6 +39,8 @@ export interface PostQueryOptions {
|
|
|
38
39
|
* effect without rebuilding the client.
|
|
39
40
|
*/
|
|
40
41
|
capabilityToken?: string;
|
|
42
|
+
/** Server-bound attribution shared with the owning WebSocket. */
|
|
43
|
+
presenceSession?: PresenceSessionSource;
|
|
41
44
|
/**
|
|
42
45
|
* An optional hook that tries to recover from a rejected credential. When a
|
|
43
46
|
* query comes back with a 401, its {@link RecoveryClass} is passed here: a
|
|
@@ -74,7 +74,7 @@ export async function postQuery(options, batch) {
|
|
|
74
74
|
try {
|
|
75
75
|
// Recomputed per attempt: `withAuthHeaders` reads the live credential
|
|
76
76
|
// source, so a replay after recovery carries the freshly-minted key.
|
|
77
|
-
const headers = withAuthHeaders(options.getAuthToken, { 'Content-Type': 'application/json' }, options.capabilityToken);
|
|
77
|
+
const headers = withAuthHeaders(options.getAuthToken, { 'Content-Type': 'application/json' }, options.capabilityToken, options.presenceSession);
|
|
78
78
|
const response = await fetch(url, {
|
|
79
79
|
method: 'POST',
|
|
80
80
|
headers,
|
|
@@ -94,6 +94,8 @@ export interface BootstrapOptions {
|
|
|
94
94
|
* {@link BootstrapFetcher.setAuthToken}.
|
|
95
95
|
*/
|
|
96
96
|
getAuthToken?: AuthTokenGetter;
|
|
97
|
+
/** Server-bound attribution shared with the owning WebSocket. */
|
|
98
|
+
presenceSession?: import('@abloatai/transaction/presence').PresenceSessionSource;
|
|
97
99
|
/** The owning client's runtime. Defaults to the module-global bridge. */
|
|
98
100
|
runtime?: RuntimeContext;
|
|
99
101
|
}
|
|
@@ -172,7 +172,7 @@ export class BootstrapFetcher {
|
|
|
172
172
|
try {
|
|
173
173
|
const res = await fetch(`${this.options.baseUrl}/schema`, {
|
|
174
174
|
method: 'GET',
|
|
175
|
-
headers: withAuthHeaders(this.options.getAuthToken, {}, this.options.authToken),
|
|
175
|
+
headers: withAuthHeaders(this.options.getAuthToken, {}, this.options.authToken, this.options.presenceSession),
|
|
176
176
|
});
|
|
177
177
|
if (!res.ok)
|
|
178
178
|
throw new Error(`schema read-back ${res.status}`);
|
|
@@ -570,7 +570,7 @@ export class BootstrapFetcher {
|
|
|
570
570
|
// conditional revalidation (If-None-Match) implement it at their own
|
|
571
571
|
// level where they own the cache-key namespace. The 304 branch below
|
|
572
572
|
// remains defensively in place for when a caller enables revalidation.
|
|
573
|
-
const headers = withAuthHeaders(this.options.getAuthToken, { 'Content-Type': 'application/json' }, this.options.authToken);
|
|
573
|
+
const headers = withAuthHeaders(this.options.getAuthToken, { 'Content-Type': 'application/json' }, this.options.authToken, this.options.presenceSession);
|
|
574
574
|
const controller = new AbortController();
|
|
575
575
|
this.activeControllers.set(controller, 'bootstrap');
|
|
576
576
|
try {
|
|
@@ -738,7 +738,7 @@ export class BootstrapFetcher {
|
|
|
738
738
|
'Content-Type': 'application/json',
|
|
739
739
|
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
|
740
740
|
Pragma: 'no-cache',
|
|
741
|
-
}, this.options.authToken),
|
|
741
|
+
}, this.options.authToken, this.options.presenceSession),
|
|
742
742
|
signal: controller.signal,
|
|
743
743
|
cache: 'no-store', // Force browser to not cache
|
|
744
744
|
});
|
|
@@ -797,7 +797,7 @@ export class BootstrapFetcher {
|
|
|
797
797
|
method: 'GET',
|
|
798
798
|
headers: withAuthHeaders(this.options.getAuthToken, {
|
|
799
799
|
'Content-Type': 'application/json',
|
|
800
|
-
}, this.options.authToken),
|
|
800
|
+
}, this.options.authToken, this.options.presenceSession),
|
|
801
801
|
signal: controller.signal,
|
|
802
802
|
});
|
|
803
803
|
}
|
|
@@ -37,6 +37,7 @@ import type { LoadWhere, WhereClause } from '../query/types.js';
|
|
|
37
37
|
import { normalizeWhere } from '@abloatai/transaction/client/resources/where';
|
|
38
38
|
import type { Schema } from '@abloatai/transaction/schema/schema';
|
|
39
39
|
import type { LogPositionPort } from '../logPosition.js';
|
|
40
|
+
import type { PresenceSessionSource } from '@abloatai/transaction/presence';
|
|
40
41
|
export interface OnDemandLoaderOptions {
|
|
41
42
|
readonly objectPool: InstanceCache;
|
|
42
43
|
/**
|
|
@@ -53,6 +54,7 @@ export interface OnDemandLoaderOptions {
|
|
|
53
54
|
* propagate without re-instantiating the coordinator.
|
|
54
55
|
*/
|
|
55
56
|
readonly getAuthToken?: () => string | null;
|
|
57
|
+
readonly presenceSession?: PresenceSessionSource;
|
|
56
58
|
/** @deprecated Use `getAuthToken`. */
|
|
57
59
|
readonly getCapabilityToken?: () => string | null;
|
|
58
60
|
/** The owning client's runtime. Defaults to the module-global bridge. */
|
|
@@ -452,6 +452,7 @@ export class OnDemandLoader {
|
|
|
452
452
|
getAuthToken: this.authTokenProvider ?? undefined,
|
|
453
453
|
recoverCredential: this.credentialRecovery ?? undefined,
|
|
454
454
|
runtime: this.opts.runtime,
|
|
455
|
+
presenceSession: this.opts.presenceSession,
|
|
455
456
|
}, { queries: [query] });
|
|
456
457
|
const rows = Array.isArray(result.results[0]) ? result.results[0] : [];
|
|
457
458
|
const evidence = result.evidence?.[0] ?? [];
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import { type ClientSyncDelta } from '@abloatai/transaction/observation';
|
|
13
13
|
import { WsTransport, type WsTransportOptions, type EventMap, type DefaultCollaborationEvents } from '@abloatai/transaction/transport/websocket';
|
|
14
14
|
export type { CommitAck } from './commitFrames.js';
|
|
15
|
-
export type { SyncCapabilities, BootstrapHint, BootstrapDataEvent,
|
|
15
|
+
export type { SyncCapabilities, BootstrapHint, BootstrapDataEvent, CoreSyncEventMap, DefaultCollaborationEvents, EventMap, SyncWebSocketEventMap, } from '@abloatai/transaction/transport/websocket';
|
|
16
16
|
/**
|
|
17
17
|
* The wire delta the client receives. It is inferred from the canonical
|
|
18
18
|
* `clientSyncDeltaSchema` so the client and server share one contract rather
|
|
@@ -67,7 +67,7 @@ export declare class SyncWebSocket<TCollaboration extends EventMap<TCollaboratio
|
|
|
67
67
|
protected resumeCursor(): string;
|
|
68
68
|
/**
|
|
69
69
|
* The open ritual, run by the transport between its `connected` emit and the
|
|
70
|
-
* heartbeat start:
|
|
70
|
+
* heartbeat start: tell the server where we left off,
|
|
71
71
|
* request the deltas we missed, and start the catch-up poll.
|
|
72
72
|
*/
|
|
73
73
|
protected onOpened(): void;
|
|
@@ -121,19 +121,6 @@ export declare class SyncWebSocket<TCollaboration extends EventMap<TCollaboratio
|
|
|
121
121
|
* Public wrapper for sending ack from outside the class
|
|
122
122
|
*/
|
|
123
123
|
acknowledge(syncId: number): void;
|
|
124
|
-
/**
|
|
125
|
-
* Send presence update to server.
|
|
126
|
-
* Use this for:
|
|
127
|
-
* - Updating timezone (improves localTime accuracy shown to other users)
|
|
128
|
-
* - Manual status changes (away, custom status)
|
|
129
|
-
*
|
|
130
|
-
* Note: "online" status is automatically set by server on WebSocket connect,
|
|
131
|
-
* and "offline" is set on disconnect. You don't need to call this for basic online/offline.
|
|
132
|
-
*
|
|
133
|
-
* @param status - "online", "away", or custom status string
|
|
134
|
-
* @param customStatus - Optional custom status message
|
|
135
|
-
*/
|
|
136
|
-
sendPresenceUpdate(status?: 'online' | 'away' | 'offline', customStatus?: string): void;
|
|
137
124
|
/**
|
|
138
125
|
* Stop the periodic catchup interval
|
|
139
126
|
*/
|
|
@@ -53,13 +53,10 @@ export class SyncWebSocket extends WsTransport {
|
|
|
53
53
|
}
|
|
54
54
|
/**
|
|
55
55
|
* The open ritual, run by the transport between its `connected` emit and the
|
|
56
|
-
* heartbeat start:
|
|
56
|
+
* heartbeat start: tell the server where we left off,
|
|
57
57
|
* request the deltas we missed, and start the catch-up poll.
|
|
58
58
|
*/
|
|
59
59
|
onOpened() {
|
|
60
|
-
// Send presence update with timezone (server sets presence to "online" on connect,
|
|
61
|
-
// this improves localTime accuracy by providing the user's actual timezone)
|
|
62
|
-
this.sendPresenceUpdate('online');
|
|
63
60
|
// Immediately request incremental sync based on our stored cursor.
|
|
64
61
|
// `requestIncrementalSync` is async — a bare call inside try/catch is a
|
|
65
62
|
// rejection hole (the catch never sees it); route failures through
|
|
@@ -224,38 +221,6 @@ export class SyncWebSocket extends WsTransport {
|
|
|
224
221
|
acknowledge(syncId) {
|
|
225
222
|
this.sendAck(syncId);
|
|
226
223
|
}
|
|
227
|
-
/**
|
|
228
|
-
* Send presence update to server.
|
|
229
|
-
* Use this for:
|
|
230
|
-
* - Updating timezone (improves localTime accuracy shown to other users)
|
|
231
|
-
* - Manual status changes (away, custom status)
|
|
232
|
-
*
|
|
233
|
-
* Note: "online" status is automatically set by server on WebSocket connect,
|
|
234
|
-
* and "offline" is set on disconnect. You don't need to call this for basic online/offline.
|
|
235
|
-
*
|
|
236
|
-
* @param status - "online", "away", or custom status string
|
|
237
|
-
* @param customStatus - Optional custom status message
|
|
238
|
-
*/
|
|
239
|
-
sendPresenceUpdate(status = 'online', customStatus) {
|
|
240
|
-
if (!this.isConnected())
|
|
241
|
-
return;
|
|
242
|
-
const timezone = (() => {
|
|
243
|
-
try {
|
|
244
|
-
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
245
|
-
}
|
|
246
|
-
catch {
|
|
247
|
-
return 'UTC';
|
|
248
|
-
}
|
|
249
|
-
})();
|
|
250
|
-
this.send({
|
|
251
|
-
type: 'presence_update',
|
|
252
|
-
payload: {
|
|
253
|
-
status,
|
|
254
|
-
timezone,
|
|
255
|
-
...(customStatus ? { customStatus } : {}),
|
|
256
|
-
},
|
|
257
|
-
});
|
|
258
|
-
}
|
|
259
224
|
/**
|
|
260
225
|
* Stop the periodic catchup interval
|
|
261
226
|
*/
|
|
@@ -37,7 +37,7 @@ export function contextOnChange(transport, pool, reads, listener) {
|
|
|
37
37
|
// already advanced this exact row in the pool.
|
|
38
38
|
for (const read of rowReads) {
|
|
39
39
|
const resident = pool.peek(read.id);
|
|
40
|
-
if (
|
|
40
|
+
if (resident?.getModelName().toLowerCase() !== read.model.toLowerCase()) {
|
|
41
41
|
continue;
|
|
42
42
|
}
|
|
43
43
|
const observed = pool.watermarks.of(resident);
|
|
@@ -5,10 +5,8 @@
|
|
|
5
5
|
* everyone else's, and watch the wait queue when a claim is contended.
|
|
6
6
|
*
|
|
7
7
|
* The stream is built directly on the sync WebSocket and shares that one
|
|
8
|
-
* connection. It learns about other
|
|
9
|
-
*
|
|
10
|
-
* consumes — the server piggybacks each participant's `activeClaims` on every
|
|
11
|
-
* presence frame — and sends its own claims as `claim_begin` and
|
|
8
|
+
* connection. It learns about other sessions' claims from the normalized
|
|
9
|
+
* presence projection and sends its own claims as `claim_begin` and
|
|
12
10
|
* `claim_abandon` frames.
|
|
13
11
|
*
|
|
14
12
|
* Wire frames:
|
|
@@ -16,12 +14,12 @@
|
|
|
16
14
|
* entityId, description, field?, estimatedMs? }`.
|
|
17
15
|
* • Outbound `claim_abandon` — release it: `{ claimId, entityType?,
|
|
18
16
|
* entityId? }`.
|
|
19
|
-
* • Inbound, via presence — `
|
|
20
|
-
* `declaredAt` and `expiresAt`.
|
|
17
|
+
* • Inbound, via presence — authoritative `claim` activities.
|
|
21
18
|
* • Inbound `claim_rejected` — the server refused the claim, with conflict
|
|
22
19
|
* metadata.
|
|
23
20
|
*/
|
|
24
21
|
import type { WsTransport } from '@abloatai/transaction/transport/websocket';
|
|
22
|
+
import type { PresenceSession } from '@abloatai/transaction/presence';
|
|
25
23
|
import type { ClaimOptions, Claim, ClaimStream, PresenceTarget } from '@abloatai/transaction/types/streams';
|
|
26
24
|
import { type Logger } from '@abloatai/transaction/logger';
|
|
27
25
|
/**
|
|
@@ -34,11 +32,13 @@ import { type Logger } from '@abloatai/transaction/logger';
|
|
|
34
32
|
*/
|
|
35
33
|
export type ClaimTransport = Pick<WsTransport, 'subscribe' | 'isConnected' | 'send'>;
|
|
36
34
|
export interface ClaimStreamConfig {
|
|
37
|
-
/** Identity used to filter our own active claims out of `others`. */
|
|
38
|
-
participantId: string;
|
|
39
35
|
/** Where the coordination trace is logged. Defaults to silent. */
|
|
40
36
|
logger?: Logger;
|
|
41
37
|
}
|
|
38
|
+
export interface ClaimPresenceSource {
|
|
39
|
+
readonly others: readonly PresenceSession[];
|
|
40
|
+
onChange(listener: () => void): () => void;
|
|
41
|
+
}
|
|
42
42
|
export interface AttachableClaimStream extends ClaimStream {
|
|
43
43
|
/**
|
|
44
44
|
* Mints the local handle and sends its `claim_begin` frame. The handle is a
|
|
@@ -54,16 +54,6 @@ export interface AttachableClaimStream extends ClaimStream {
|
|
|
54
54
|
*/
|
|
55
55
|
claim(target: PresenceTarget, opts?: ClaimOptions, claimId?: string): Claim;
|
|
56
56
|
attach(transport: ClaimTransport): void;
|
|
57
|
-
/**
|
|
58
|
-
* Seeds the participant identity once the host resolves it. The stream can
|
|
59
|
-
* be built before identity is known — a hosted client learns who it is
|
|
60
|
-
* from its credential's scope during connect — and until then the
|
|
61
|
-
* construction-time id (possibly empty) would let the participant's own
|
|
62
|
-
* claims into `others`. Idempotent; later frames filter on the new id.
|
|
63
|
-
*/
|
|
64
|
-
setParticipant(participant: {
|
|
65
|
-
id: string;
|
|
66
|
-
}): void;
|
|
67
57
|
dispose(): void;
|
|
68
58
|
}
|
|
69
|
-
export declare function createClaimStream(config: ClaimStreamConfig, transport?: ClaimTransport | null): AttachableClaimStream;
|
|
59
|
+
export declare function createClaimStream(config: ClaimStreamConfig, transport?: ClaimTransport | null, presence?: ClaimPresenceSource | null): AttachableClaimStream;
|