@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
package/src/local/SyncClient.ts
CHANGED
|
@@ -18,7 +18,7 @@ import { deepEqual, snapshotJsonValue } from '@abloatai/transaction/utils/json';
|
|
|
18
18
|
import { LoadStrategy } from '@abloatai/transaction/types';
|
|
19
19
|
import { globalRuntime } from './context.js';
|
|
20
20
|
import type { RuntimeContext } from './RuntimeContext.js';
|
|
21
|
-
import {
|
|
21
|
+
import { AbloError, AbloValidationError } from '@abloatai/transaction/errors';
|
|
22
22
|
import { EventEmitter } from 'events';
|
|
23
23
|
import { NetworkMonitor } from './NetworkMonitor.js';
|
|
24
24
|
import {
|
|
@@ -51,9 +51,16 @@ import {
|
|
|
51
51
|
type SyncState,
|
|
52
52
|
} from './syncClientTypes.js';
|
|
53
53
|
import type { BootstrapSnapshot } from './syncClientTypes.js';
|
|
54
|
+
import {
|
|
55
|
+
batchUploadFiles,
|
|
56
|
+
uploadFile,
|
|
57
|
+
type BatchFileUploadOptions,
|
|
58
|
+
type FileUploadContext,
|
|
59
|
+
type FileUploadOptions,
|
|
60
|
+
} from './fileUploads.js';
|
|
54
61
|
|
|
55
62
|
export type { BootstrapSnapshot, RehydrationStats } from './syncClientTypes.js';
|
|
56
|
-
|
|
63
|
+
const ignoreSeparatelyObservedMutationFailure = (): undefined => undefined;
|
|
57
64
|
export class SyncClient extends EventEmitter {
|
|
58
65
|
private objectPool: InstanceCache;
|
|
59
66
|
private database: Database;
|
|
@@ -1057,7 +1064,7 @@ export class SyncClient extends EventEmitter {
|
|
|
1057
1064
|
if (capturedChanges === undefined) return;
|
|
1058
1065
|
|
|
1059
1066
|
this.objectPool.upsert(model, ModelScope.live);
|
|
1060
|
-
this.stageMutation('update', model, capturedChanges);
|
|
1067
|
+
void this.stageMutation('update', model, capturedChanges);
|
|
1061
1068
|
this.notifyObservers({
|
|
1062
1069
|
type: 'update',
|
|
1063
1070
|
modelType: model.getModelName(),
|
|
@@ -1080,128 +1087,30 @@ export class SyncClient extends EventEmitter {
|
|
|
1080
1087
|
return this.mutate('delete', model, () => this.objectPool.remove(model.id), options);
|
|
1081
1088
|
}
|
|
1082
1089
|
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
* the {@link MutationQueue}, and a model is built from the server's
|
|
1086
|
-
* response and added to the pool.
|
|
1087
|
-
*/
|
|
1088
|
-
async uploadFile(
|
|
1089
|
-
file: File,
|
|
1090
|
-
options: {
|
|
1091
|
-
id: string;
|
|
1092
|
-
attachableType: string;
|
|
1093
|
-
attachableId: string;
|
|
1094
|
-
metadata?: Record<string, unknown>;
|
|
1095
|
-
}
|
|
1096
|
-
): Promise<Model | null> {
|
|
1097
|
-
if (!this.userId || !this.organizationId) {
|
|
1098
|
-
throw new AbloAuthenticationError('Authentication required for file uploads', {
|
|
1099
|
-
code: 'file_upload_auth_required',
|
|
1100
|
-
});
|
|
1101
|
-
}
|
|
1102
|
-
|
|
1103
|
-
try {
|
|
1104
|
-
// Use MutationQueue to handle the upload mutation
|
|
1105
|
-
const result = await this.mutationQueue.uploadAttachment(
|
|
1106
|
-
file,
|
|
1107
|
-
{
|
|
1108
|
-
id: options.id,
|
|
1109
|
-
attachableType: options.attachableType,
|
|
1110
|
-
attachableId: options.attachableId,
|
|
1111
|
-
metadata: options.metadata,
|
|
1112
|
-
},
|
|
1113
|
-
{
|
|
1114
|
-
userId: this.userId,
|
|
1115
|
-
organizationId: this.organizationId,
|
|
1116
|
-
}
|
|
1117
|
-
);
|
|
1118
|
-
|
|
1119
|
-
if (result) {
|
|
1120
|
-
// Create model from response using ModelRegistry (generic — no concrete class import)
|
|
1121
|
-
const model = this.objectPool.createFromData({
|
|
1122
|
-
id: options.id,
|
|
1123
|
-
...result,
|
|
1124
|
-
});
|
|
1125
|
-
|
|
1126
|
-
if (model) {
|
|
1127
|
-
this.objectPool.add(model, ModelScope.live);
|
|
1128
|
-
this.notifyObservers({
|
|
1129
|
-
type: 'create',
|
|
1130
|
-
modelType: model.getModelName(),
|
|
1131
|
-
model,
|
|
1132
|
-
});
|
|
1133
|
-
return model;
|
|
1134
|
-
}
|
|
1135
|
-
}
|
|
1136
|
-
|
|
1137
|
-
return null;
|
|
1138
|
-
} catch (error) {
|
|
1139
|
-
this.runtime.observability.captureMutationFailure({
|
|
1140
|
-
context: 'file-upload',
|
|
1141
|
-
error: error instanceof Error ? error : new Error(String(error)),
|
|
1142
|
-
});
|
|
1143
|
-
throw error;
|
|
1144
|
-
}
|
|
1145
|
-
}
|
|
1146
|
-
|
|
1147
|
-
/**
|
|
1148
|
-
* Batch upload files — single GraphQL call + parallel S3 PUTs.
|
|
1149
|
-
*
|
|
1150
|
-
* Returns the raw `Model[]` built by the object pool (typename is
|
|
1151
|
-
* determined by the payload the server returns — currently always
|
|
1152
|
-
* `Attachment`). The SDK has no knowledge of app-specific model classes,
|
|
1153
|
-
* so it cannot honestly claim a narrower return type; consumers that
|
|
1154
|
-
* need an `Attachment[]` project through their own typed accessor
|
|
1155
|
-
* (e.g. `store.query.attachments.findMany({ where: { id: IN ids } })`)
|
|
1156
|
-
* after the upload resolves.
|
|
1157
|
-
*/
|
|
1158
|
-
async batchUploadFiles(
|
|
1159
|
-
files: File[],
|
|
1160
|
-
options: {
|
|
1161
|
-
ids: string[];
|
|
1162
|
-
attachableType: string;
|
|
1163
|
-
attachableId: string;
|
|
1164
|
-
metadata?: Record<string, unknown>;
|
|
1165
|
-
}
|
|
1166
|
-
): Promise<Model[]> {
|
|
1167
|
-
if (!this.userId || !this.organizationId) {
|
|
1168
|
-
throw new AbloAuthenticationError('Authentication required for file uploads', {
|
|
1169
|
-
code: 'file_upload_auth_required',
|
|
1170
|
-
});
|
|
1171
|
-
}
|
|
1172
|
-
|
|
1173
|
-
const items = options.ids.map((id) => ({
|
|
1174
|
-
id,
|
|
1175
|
-
attachableType: options.attachableType,
|
|
1176
|
-
attachableId: options.attachableId,
|
|
1177
|
-
metadata: options.metadata,
|
|
1178
|
-
}));
|
|
1179
|
-
|
|
1180
|
-
const results = await this.mutationQueue.batchUploadAttachments(files, items, {
|
|
1090
|
+
private fileUploadContext(): FileUploadContext {
|
|
1091
|
+
return {
|
|
1181
1092
|
userId: this.userId,
|
|
1182
1093
|
organizationId: this.organizationId,
|
|
1183
|
-
|
|
1094
|
+
mutationQueue: this.mutationQueue,
|
|
1095
|
+
objectPool: this.objectPool,
|
|
1096
|
+
observability: this.runtime.observability,
|
|
1097
|
+
notifyCreated: (model) => {
|
|
1098
|
+
this.notifyObservers({ type: 'create', modelType: model.getModelName(), model });
|
|
1099
|
+
},
|
|
1100
|
+
};
|
|
1101
|
+
}
|
|
1184
1102
|
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
if (model) {
|
|
1189
|
-
this.objectPool.add(model, ModelScope.live);
|
|
1190
|
-
this.notifyObservers({
|
|
1191
|
-
type: 'create',
|
|
1192
|
-
modelType: model.getModelName(),
|
|
1193
|
-
model,
|
|
1194
|
-
});
|
|
1195
|
-
models.push(model);
|
|
1196
|
-
}
|
|
1197
|
-
}
|
|
1103
|
+
uploadFile(file: File, options: FileUploadOptions): Promise<Model | null> {
|
|
1104
|
+
return uploadFile(this.fileUploadContext(), file, options);
|
|
1105
|
+
}
|
|
1198
1106
|
|
|
1199
|
-
|
|
1107
|
+
batchUploadFiles(files: File[], options: BatchFileUploadOptions): Promise<Model[]> {
|
|
1108
|
+
return batchUploadFiles(this.fileUploadContext(), files, options);
|
|
1200
1109
|
}
|
|
1201
1110
|
|
|
1202
1111
|
/** Archive model (ARCHIVE) - works offline */
|
|
1203
|
-
archive(model: Model): void {
|
|
1204
|
-
this.mutate('archive', model, () => { this.objectPool.updateScope(model.id, ModelScope.archived); });
|
|
1112
|
+
archive(model: Model): Promise<void> | undefined {
|
|
1113
|
+
return this.mutate('archive', model, () => { this.objectPool.updateScope(model.id, ModelScope.archived); });
|
|
1205
1114
|
}
|
|
1206
1115
|
|
|
1207
1116
|
/**
|
|
@@ -1243,7 +1152,7 @@ export class SyncClient extends EventEmitter {
|
|
|
1243
1152
|
// Most internal callers intentionally use fire-and-forget writes. Observe
|
|
1244
1153
|
// their rejection without replacing the exact promise returned to model
|
|
1245
1154
|
// operations that need authoritative per-transaction confirmation.
|
|
1246
|
-
void confirmation.catch(
|
|
1155
|
+
void confirmation.catch(ignoreSeparatelyObservedMutationFailure);
|
|
1247
1156
|
const pending = staging.then(() => undefined).catch((error: Error) => {
|
|
1248
1157
|
this.runtime.observability.captureMutationFailure({
|
|
1249
1158
|
context: `stage-mutation-${type}`,
|
|
@@ -1495,14 +1404,11 @@ export class SyncClient extends EventEmitter {
|
|
|
1495
1404
|
markConnected(): void {
|
|
1496
1405
|
this.setConnectionState('connected');
|
|
1497
1406
|
// Browser online state may have marked the client connected before the
|
|
1498
|
-
// WebSocket itself was ready.
|
|
1499
|
-
//
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
error: error instanceof Error ? error : new Error(String(error)),
|
|
1504
|
-
});
|
|
1505
|
-
});
|
|
1407
|
+
// WebSocket itself was ready. Kick the durable lanes through the staging
|
|
1408
|
+
// barrier: a model mutation enters the in-memory store before its journal
|
|
1409
|
+
// row finishes saving, so a direct reconnect drain can otherwise try to
|
|
1410
|
+
// seal a source record that does not exist yet. The pending drain also
|
|
1411
|
+
// starts the atomic commit lane, so one ordered entry point covers both.
|
|
1506
1412
|
void this.processPendingMutations();
|
|
1507
1413
|
}
|
|
1508
1414
|
|
|
@@ -13,12 +13,17 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import type { ParticipantKind } from '@abloatai/transaction/types/participant';
|
|
16
|
+
import { AbloValidationError } from '@abloatai/transaction/errors';
|
|
16
17
|
import type { Logger } from '@abloatai/transaction/logger';
|
|
17
18
|
import type { SchemaRecord } from '@abloatai/transaction/schema/schema';
|
|
18
19
|
import {
|
|
19
20
|
createAuthCredentialSource,
|
|
20
21
|
type AuthCredentialSource,
|
|
21
22
|
} from '@abloatai/transaction/auth/credentialSource';
|
|
23
|
+
import {
|
|
24
|
+
createPresenceSessionSource,
|
|
25
|
+
type PresenceSessionSource,
|
|
26
|
+
} from '@abloatai/transaction/presence';
|
|
22
27
|
import {
|
|
23
28
|
assertBrowserSafety,
|
|
24
29
|
readProcessEnv,
|
|
@@ -47,6 +52,7 @@ export interface ClientPrelude<S extends SchemaRecord> {
|
|
|
47
52
|
*/
|
|
48
53
|
readonly credentialResolver: CredentialProvider | null;
|
|
49
54
|
readonly authCredentials: AuthCredentialSource;
|
|
55
|
+
readonly presenceSession: PresenceSessionSource;
|
|
50
56
|
readonly logger: Logger;
|
|
51
57
|
readonly url: string;
|
|
52
58
|
/**
|
|
@@ -69,6 +75,17 @@ export interface ClientPrelude<S extends SchemaRecord> {
|
|
|
69
75
|
export function resolveClientPrelude<S extends SchemaRecord>(
|
|
70
76
|
options: AbloOptions<S>,
|
|
71
77
|
): ClientPrelude<S> {
|
|
78
|
+
// This package owns the reactive materialiser, not transport selection.
|
|
79
|
+
// TypeScript can miss excess properties on generic calls and object spreads,
|
|
80
|
+
// so reject a misplaced selector before resolving any credentials or
|
|
81
|
+
// constructing local state. The core `Ablo` client owns `transport`.
|
|
82
|
+
if ('transport' in options) {
|
|
83
|
+
throw new AbloValidationError(
|
|
84
|
+
"The reactive client does not accept `transport`. Import `Ablo` from " +
|
|
85
|
+
"'@abloatai/ablo' when selecting HTTP or WebSocket transport.",
|
|
86
|
+
{ code: 'invalid_options', param: 'transport' },
|
|
87
|
+
);
|
|
88
|
+
}
|
|
72
89
|
const env = readProcessEnv();
|
|
73
90
|
const internalOptions = {
|
|
74
91
|
...options,
|
|
@@ -79,9 +96,11 @@ export function resolveClientPrelude<S extends SchemaRecord>(
|
|
|
79
96
|
const configuredApiKey = resolveApiKey(authInput);
|
|
80
97
|
const configuredAuthToken = resolveAuthToken(authInput);
|
|
81
98
|
const credentialResolver = resolveCredentialResolver(configuredApiKey);
|
|
99
|
+
const presenceSession = createPresenceSessionSource();
|
|
82
100
|
const authCredentials = createAuthCredentialSource(
|
|
83
101
|
// eslint-disable-next-line @typescript-eslint/no-deprecated -- load-bearing on the self-hosted path; server-internal cap-mint (Phase 3) not shipped
|
|
84
102
|
internalOptions.capabilityToken ?? configuredAuthToken,
|
|
103
|
+
presenceSession,
|
|
85
104
|
);
|
|
86
105
|
rejectRemovedDatabaseUrlOption(options);
|
|
87
106
|
assertBrowserSafety({
|
|
@@ -108,6 +127,7 @@ export function resolveClientPrelude<S extends SchemaRecord>(
|
|
|
108
127
|
configuredAuthToken,
|
|
109
128
|
credentialResolver,
|
|
110
129
|
authCredentials,
|
|
130
|
+
presenceSession,
|
|
111
131
|
logger,
|
|
112
132
|
url: resolveBaseURL(authInput),
|
|
113
133
|
participantId,
|
|
@@ -90,6 +90,7 @@ export function createInternalComponents<S extends SchemaRecord>(
|
|
|
90
90
|
syncGroups: options.syncGroups,
|
|
91
91
|
instantModels: deriveInstantModels(schema),
|
|
92
92
|
getAuthToken: auth?.getAuthToken,
|
|
93
|
+
presenceSession: auth?.presenceSession,
|
|
93
94
|
runtime,
|
|
94
95
|
});
|
|
95
96
|
|
|
@@ -120,6 +121,7 @@ export function createInternalComponents<S extends SchemaRecord>(
|
|
|
120
121
|
schema,
|
|
121
122
|
baseUrl: bootstrapBaseUrl,
|
|
122
123
|
getAuthToken: auth?.getAuthToken,
|
|
124
|
+
presenceSession: auth?.presenceSession,
|
|
123
125
|
runtime,
|
|
124
126
|
// The one canonical log position; the loader reads its floor when a query
|
|
125
127
|
// leaves so a late answer cannot overwrite a row the pool already knows to
|
|
@@ -143,12 +143,16 @@ import type {
|
|
|
143
143
|
HttpModelClient,
|
|
144
144
|
} from '@abloatai/transaction/transport/http';
|
|
145
145
|
import type { ParticipantKind } from '@abloatai/transaction/types/participant';
|
|
146
|
+
import type { PresenceSession } from '@abloatai/transaction/presence';
|
|
146
147
|
import {
|
|
147
148
|
capturePointRead,
|
|
148
149
|
prepareReadSet,
|
|
149
150
|
type ReadSetContext,
|
|
150
151
|
} from '@abloatai/transaction/internal/read-set';
|
|
151
152
|
|
|
153
|
+
const ignoreSeparatelyObservedMutationFailure = (): undefined => undefined;
|
|
154
|
+
const ignoreBestEffortClaimReleaseFailure = (): undefined => undefined;
|
|
155
|
+
|
|
152
156
|
export interface ModelClientMeta {
|
|
153
157
|
readonly key: string;
|
|
154
158
|
readonly typename: string;
|
|
@@ -174,6 +178,8 @@ type EntityHalf = Pick<ModelTarget, 'model' | 'id'>;
|
|
|
174
178
|
// `ModelCollaboration<Item>` and `ModelCollaboration<Invoice>` the same type
|
|
175
179
|
// while reading as though they differed.
|
|
176
180
|
export interface ModelCollaboration {
|
|
181
|
+
/** Session projections already held by this client's one presence store. */
|
|
182
|
+
presence(model: string, recordId?: string): readonly PresenceSession[];
|
|
177
183
|
/** Exact point evidence from the HTTP read boundary (stamp captured before data). */
|
|
178
184
|
readPoint(model: string, id: string): Promise<{ data: unknown; stamp: number }>;
|
|
179
185
|
/**
|
|
@@ -333,6 +339,9 @@ interface ReactiveModelSurface<T, Fields = T> {
|
|
|
333
339
|
/** The synchronous local-graph reads. */
|
|
334
340
|
local: LocalReads<T>;
|
|
335
341
|
|
|
342
|
+
/** Sessions currently active on this model, optionally narrowed to one record. */
|
|
343
|
+
presence(recordId?: string): readonly PresenceSession[];
|
|
344
|
+
|
|
336
345
|
/**
|
|
337
346
|
* Claim a row so other writers wait or are rejected until you're done, and
|
|
338
347
|
* inspect or manage that coordination through the same namespace. Call it to
|
|
@@ -490,7 +499,7 @@ export function createModelOperations<T, C>(
|
|
|
490
499
|
// await does not create an unhandled-rejection process error. Returning
|
|
491
500
|
// the original promise preserves normal rejection for callers that do
|
|
492
501
|
// await or attach their own catch handler.
|
|
493
|
-
void confirmation.catch(
|
|
502
|
+
void confirmation.catch(ignoreSeparatelyObservedMutationFailure);
|
|
494
503
|
return confirmation;
|
|
495
504
|
};
|
|
496
505
|
};
|
|
@@ -660,9 +669,9 @@ export function createModelOperations<T, C>(
|
|
|
660
669
|
// This runs after authoritative confirmation. A best-effort abandon frame
|
|
661
670
|
// cannot turn a committed write into an apparent failure; the server has
|
|
662
671
|
// already fulfilled the participant's claims as part of that commit.
|
|
663
|
-
await releaseClaimsForEntity(entityId).catch(
|
|
672
|
+
await releaseClaimsForEntity(entityId).catch(ignoreBestEffortClaimReleaseFailure);
|
|
664
673
|
if (explicit && !explicitWasLocal) {
|
|
665
|
-
await explicit.release?.().catch(
|
|
674
|
+
await explicit.release?.().catch(ignoreBestEffortClaimReleaseFailure);
|
|
666
675
|
}
|
|
667
676
|
};
|
|
668
677
|
|
|
@@ -1376,7 +1385,7 @@ export function createModelOperations<T, C>(
|
|
|
1376
1385
|
await waitForMutation(model, confirmation);
|
|
1377
1386
|
return modelAsRow<T>(model);
|
|
1378
1387
|
} finally {
|
|
1379
|
-
await autoLease?.release?.().catch(
|
|
1388
|
+
await autoLease?.release?.().catch(ignoreBestEffortClaimReleaseFailure);
|
|
1380
1389
|
}
|
|
1381
1390
|
});
|
|
1382
1391
|
|
|
@@ -1400,6 +1409,8 @@ export function createModelOperations<T, C>(
|
|
|
1400
1409
|
const operations: ModelOperations<T, C> = {
|
|
1401
1410
|
local,
|
|
1402
1411
|
|
|
1412
|
+
presence: (recordId?: string) => collaboration?.presence(registeredModelName, recordId) ?? [],
|
|
1413
|
+
|
|
1403
1414
|
get,
|
|
1404
1415
|
read,
|
|
1405
1416
|
|
|
@@ -1501,7 +1512,7 @@ export function createModelOperations<T, C>(
|
|
|
1501
1512
|
const confirmation = syncClient.update(
|
|
1502
1513
|
model,
|
|
1503
1514
|
effective,
|
|
1504
|
-
patch
|
|
1515
|
+
patch,
|
|
1505
1516
|
);
|
|
1506
1517
|
await waitForMutation(model, confirmation);
|
|
1507
1518
|
return modelAsRow<T>(model);
|
|
@@ -1562,7 +1573,7 @@ export function createModelOperations<T, C>(
|
|
|
1562
1573
|
const confirmation = syncClient.update(
|
|
1563
1574
|
model,
|
|
1564
1575
|
effective,
|
|
1565
|
-
params.data
|
|
1576
|
+
params.data,
|
|
1566
1577
|
);
|
|
1567
1578
|
await waitForMutation(model, confirmation);
|
|
1568
1579
|
const updated = modelAsRow<T>(model);
|
|
@@ -39,7 +39,10 @@ import {
|
|
|
39
39
|
bindClaimLifetime,
|
|
40
40
|
claimLifetimeOf,
|
|
41
41
|
} from '@abloatai/transaction/claims/lifetime';
|
|
42
|
-
import
|
|
42
|
+
import {
|
|
43
|
+
attachPresenceToClient,
|
|
44
|
+
type AttachablePresence,
|
|
45
|
+
} from '../../presence/index.js';
|
|
43
46
|
import type { ClaimWaitOptions } from '@abloatai/transaction/types/streams';
|
|
44
47
|
import type { Claim } from '@abloatai/transaction/types/streams';
|
|
45
48
|
import { resolveApiKeyValue, resolveBootstrapBaseUrl } from '@abloatai/transaction/auth/apiKey';
|
|
@@ -96,7 +99,7 @@ export interface ReactiveEngineInputs<S extends SchemaRecord> extends ClientPrel
|
|
|
96
99
|
transport: SyncWebSocket;
|
|
97
100
|
/** The humans() plugin's contribution — built by its `init`, already
|
|
98
101
|
* attached to the connection the context carried. */
|
|
99
|
-
presence:
|
|
102
|
+
presence: AttachablePresence;
|
|
100
103
|
/**
|
|
101
104
|
* The store cluster `humans().init` constructed from the widened context:
|
|
102
105
|
* this client's runtime, the component graph, and the store. The engine
|
|
@@ -183,7 +186,11 @@ export function buildReactiveEngine<const S extends SchemaRecord>(
|
|
|
183
186
|
// filter own echoes by participant id, seeded in `ready()` alongside the
|
|
184
187
|
// locals above.
|
|
185
188
|
const presenceStream = presence;
|
|
186
|
-
const claimStream = createClaimStream(
|
|
189
|
+
const claimStream = createClaimStream(
|
|
190
|
+
{ logger },
|
|
191
|
+
transport,
|
|
192
|
+
presenceStream,
|
|
193
|
+
);
|
|
187
194
|
|
|
188
195
|
// 6. Validate options up front — fail loudly on obviously wrong inputs so
|
|
189
196
|
// strangers don't get silent empty results. Validation errors are written
|
|
@@ -239,17 +246,11 @@ export function buildReactiveEngine<const S extends SchemaRecord>(
|
|
|
239
246
|
kind,
|
|
240
247
|
logger,
|
|
241
248
|
validationError: _validationError,
|
|
242
|
-
onIdentityResolved: ({ userId, participantKind, accountScope,
|
|
249
|
+
onIdentityResolved: ({ userId, participantKind, accountScope, authority }) => {
|
|
243
250
|
selfParticipantId = userId;
|
|
244
251
|
selfParticipantKind = participantKind;
|
|
245
252
|
_resolvedOrganizationId = accountScope;
|
|
246
253
|
_resolvedIdentity = authority;
|
|
247
|
-
presenceStream.setParticipant({
|
|
248
|
-
id: userId,
|
|
249
|
-
kind: participantKind,
|
|
250
|
-
syncGroups: [...syncGroups],
|
|
251
|
-
});
|
|
252
|
-
claimStream.setParticipant({ id: userId });
|
|
253
254
|
},
|
|
254
255
|
});
|
|
255
256
|
const ready = lifecycle.ready;
|
|
@@ -573,6 +574,7 @@ export function buildReactiveEngine<const S extends SchemaRecord>(
|
|
|
573
574
|
modelRegistry,
|
|
574
575
|
hydration,
|
|
575
576
|
{
|
|
577
|
+
presence: (model, recordId) => presenceStream.forModel(model, recordId),
|
|
576
578
|
createClaim: (claimOptions) => publicClaims.create(claimOptions),
|
|
577
579
|
// Lazily referenced: `commits` is declared below this loop, and this
|
|
578
580
|
// only runs when someone actually writes a batch.
|
|
@@ -831,6 +833,11 @@ export function buildReactiveEngine<const S extends SchemaRecord>(
|
|
|
831
833
|
return store.syncStatus;
|
|
832
834
|
},
|
|
833
835
|
|
|
836
|
+
// The humans capability owns the connection-backed presence projection.
|
|
837
|
+
// Keep it on the base client as well as in the plugin surface so the
|
|
838
|
+
// concrete AbloClient contract and runtime object agree before layering.
|
|
839
|
+
presence: presenceStream,
|
|
840
|
+
|
|
834
841
|
schema,
|
|
835
842
|
|
|
836
843
|
// ── Internal accessors for framework integration ─────────────────
|
|
@@ -848,10 +855,6 @@ export function buildReactiveEngine<const S extends SchemaRecord>(
|
|
|
848
855
|
/** The SyncWebSocket — for collaboration events (selection, cursors). */
|
|
849
856
|
get _ws() { return store.getSyncWebSocket(); },
|
|
850
857
|
|
|
851
|
-
/** Presence livestream — same socket as entity sync, no second
|
|
852
|
-
* connection. Stable reference across the engine's lifetime. */
|
|
853
|
-
presence: presenceStream,
|
|
854
|
-
|
|
855
858
|
/** Claim livestream — same socket. Stable reference. */
|
|
856
859
|
claims: publicClaims,
|
|
857
860
|
|
|
@@ -859,6 +862,8 @@ export function buildReactiveEngine<const S extends SchemaRecord>(
|
|
|
859
862
|
|
|
860
863
|
} as Ablo<S>;
|
|
861
864
|
|
|
865
|
+
attachPresenceToClient(engine, presenceStream);
|
|
866
|
+
|
|
862
867
|
Object.defineProperty(engine, kReadEvidence, {
|
|
863
868
|
value: {
|
|
864
869
|
context: cluster.readSetContext,
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/** File-upload behavior owned beneath the SyncClient boundary. */
|
|
2
|
+
|
|
3
|
+
import { AbloAuthenticationError } from '@abloatai/transaction/errors';
|
|
4
|
+
import type { RuntimeContext } from './RuntimeContext.js';
|
|
5
|
+
import { Model } from './Model.js';
|
|
6
|
+
import { InstanceCache, ModelScope } from './InstanceCache.js';
|
|
7
|
+
import type { MutationQueue } from './transactions/mutations/MutationQueue.js';
|
|
8
|
+
|
|
9
|
+
export interface FileUploadOptions {
|
|
10
|
+
readonly id: string;
|
|
11
|
+
readonly attachableType: string;
|
|
12
|
+
readonly attachableId: string;
|
|
13
|
+
readonly metadata?: Record<string, unknown>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface BatchFileUploadOptions {
|
|
17
|
+
readonly ids: string[];
|
|
18
|
+
readonly attachableType: string;
|
|
19
|
+
readonly attachableId: string;
|
|
20
|
+
readonly metadata?: Record<string, unknown>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface FileUploadContext {
|
|
24
|
+
readonly userId: string | null;
|
|
25
|
+
readonly organizationId: string | null;
|
|
26
|
+
readonly mutationQueue: MutationQueue;
|
|
27
|
+
readonly objectPool: InstanceCache;
|
|
28
|
+
readonly observability: RuntimeContext['observability'];
|
|
29
|
+
readonly notifyCreated: (model: Model) => void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function authenticatedContext(context: FileUploadContext): {
|
|
33
|
+
readonly userId: string;
|
|
34
|
+
readonly organizationId: string;
|
|
35
|
+
} {
|
|
36
|
+
if (!context.userId || !context.organizationId) {
|
|
37
|
+
throw new AbloAuthenticationError('Authentication required for file uploads', {
|
|
38
|
+
code: 'file_upload_auth_required',
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
return { userId: context.userId, organizationId: context.organizationId };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function acceptUploadedModel(
|
|
45
|
+
context: FileUploadContext,
|
|
46
|
+
data: Record<string, unknown>,
|
|
47
|
+
): Model | null {
|
|
48
|
+
const model = context.objectPool.createFromData(data);
|
|
49
|
+
if (!model) return null;
|
|
50
|
+
context.objectPool.add(model, ModelScope.live);
|
|
51
|
+
context.notifyCreated(model);
|
|
52
|
+
return model;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function uploadFile(
|
|
56
|
+
context: FileUploadContext,
|
|
57
|
+
file: File,
|
|
58
|
+
options: FileUploadOptions,
|
|
59
|
+
): Promise<Model | null> {
|
|
60
|
+
const identity = authenticatedContext(context);
|
|
61
|
+
try {
|
|
62
|
+
const result = await context.mutationQueue.uploadAttachment(file, {
|
|
63
|
+
id: options.id,
|
|
64
|
+
attachableType: options.attachableType,
|
|
65
|
+
attachableId: options.attachableId,
|
|
66
|
+
metadata: options.metadata,
|
|
67
|
+
}, identity);
|
|
68
|
+
return result
|
|
69
|
+
? acceptUploadedModel(context, { id: options.id, ...result })
|
|
70
|
+
: null;
|
|
71
|
+
} catch (error) {
|
|
72
|
+
context.observability.captureMutationFailure({
|
|
73
|
+
context: 'file-upload',
|
|
74
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
75
|
+
});
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function batchUploadFiles(
|
|
81
|
+
context: FileUploadContext,
|
|
82
|
+
files: File[],
|
|
83
|
+
options: BatchFileUploadOptions,
|
|
84
|
+
): Promise<Model[]> {
|
|
85
|
+
const identity = authenticatedContext(context);
|
|
86
|
+
const items = options.ids.map((id) => ({
|
|
87
|
+
id,
|
|
88
|
+
attachableType: options.attachableType,
|
|
89
|
+
attachableId: options.attachableId,
|
|
90
|
+
metadata: options.metadata,
|
|
91
|
+
}));
|
|
92
|
+
const results = await context.mutationQueue.batchUploadAttachments(files, items, identity);
|
|
93
|
+
return results.flatMap((result) => {
|
|
94
|
+
const model = acceptUploadedModel(context, { ...result });
|
|
95
|
+
return model ? [model] : [];
|
|
96
|
+
});
|
|
97
|
+
}
|
|
@@ -20,6 +20,7 @@ import { classifyRecovery, type RecoveryClass } from '@abloatai/transaction/erro
|
|
|
20
20
|
import { withAuthHeaders, type AuthTokenGetter } from '@abloatai/transaction/auth/credentialSource';
|
|
21
21
|
import { globalRuntime } from '../context.js';
|
|
22
22
|
import type { RuntimeContext } from '../RuntimeContext.js';
|
|
23
|
+
import type { PresenceSessionSource } from '@abloatai/transaction/presence';
|
|
23
24
|
|
|
24
25
|
// ── Response validation ─────────────────────────────────────────────────
|
|
25
26
|
//
|
|
@@ -78,6 +79,8 @@ export interface PostQueryOptions {
|
|
|
78
79
|
* effect without rebuilding the client.
|
|
79
80
|
*/
|
|
80
81
|
capabilityToken?: string;
|
|
82
|
+
/** Server-bound attribution shared with the owning WebSocket. */
|
|
83
|
+
presenceSession?: PresenceSessionSource;
|
|
81
84
|
|
|
82
85
|
/**
|
|
83
86
|
* An optional hook that tries to recover from a rejected credential. When a
|
|
@@ -128,6 +131,7 @@ export async function postQuery(
|
|
|
128
131
|
options.getAuthToken,
|
|
129
132
|
{ 'Content-Type': 'application/json' },
|
|
130
133
|
options.capabilityToken,
|
|
134
|
+
options.presenceSession,
|
|
131
135
|
);
|
|
132
136
|
const response = await fetch(url, {
|
|
133
137
|
method: 'POST',
|
|
@@ -97,6 +97,8 @@ export interface BootstrapOptions {
|
|
|
97
97
|
* {@link BootstrapFetcher.setAuthToken}.
|
|
98
98
|
*/
|
|
99
99
|
getAuthToken?: AuthTokenGetter;
|
|
100
|
+
/** Server-bound attribution shared with the owning WebSocket. */
|
|
101
|
+
presenceSession?: import('@abloatai/transaction/presence').PresenceSessionSource;
|
|
100
102
|
/** The owning client's runtime. Defaults to the module-global bridge. */
|
|
101
103
|
runtime?: RuntimeContext;
|
|
102
104
|
}
|
|
@@ -186,13 +188,14 @@ function classifyRequestFailure(
|
|
|
186
188
|
}
|
|
187
189
|
|
|
188
190
|
export class BootstrapFetcher {
|
|
189
|
-
private options: Required<Omit<BootstrapOptions, 'baseUrl' | 'instantModels' | 'organizationId' | 'cacheScope' | 'getAuthToken' | 'runtime'>> & {
|
|
191
|
+
private options: Required<Omit<BootstrapOptions, 'baseUrl' | 'instantModels' | 'organizationId' | 'cacheScope' | 'getAuthToken' | 'presenceSession' | 'runtime'>> & {
|
|
190
192
|
baseUrl: string;
|
|
191
193
|
instantModels?: string[];
|
|
192
194
|
cacheScope: string | null;
|
|
193
195
|
organizationId?: string;
|
|
194
196
|
authToken?: string;
|
|
195
197
|
getAuthToken?: AuthTokenGetter;
|
|
198
|
+
presenceSession?: import('@abloatai/transaction/presence').PresenceSessionSource;
|
|
196
199
|
runtime?: RuntimeContext;
|
|
197
200
|
};
|
|
198
201
|
|
|
@@ -311,7 +314,12 @@ export class BootstrapFetcher {
|
|
|
311
314
|
try {
|
|
312
315
|
const res = await fetch(`${this.options.baseUrl}/schema`, {
|
|
313
316
|
method: 'GET',
|
|
314
|
-
headers: withAuthHeaders(
|
|
317
|
+
headers: withAuthHeaders(
|
|
318
|
+
this.options.getAuthToken,
|
|
319
|
+
{},
|
|
320
|
+
this.options.authToken,
|
|
321
|
+
this.options.presenceSession,
|
|
322
|
+
),
|
|
315
323
|
});
|
|
316
324
|
if (!res.ok) throw new Error(`schema read-back ${res.status}`);
|
|
317
325
|
const body = (await res.json()) as { models?: unknown };
|
|
@@ -768,6 +776,7 @@ export class BootstrapFetcher {
|
|
|
768
776
|
this.options.getAuthToken,
|
|
769
777
|
{ 'Content-Type': 'application/json' },
|
|
770
778
|
this.options.authToken,
|
|
779
|
+
this.options.presenceSession,
|
|
771
780
|
);
|
|
772
781
|
|
|
773
782
|
const controller = new AbortController();
|
|
@@ -977,7 +986,7 @@ export class BootstrapFetcher {
|
|
|
977
986
|
'Content-Type': 'application/json',
|
|
978
987
|
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
|
979
988
|
Pragma: 'no-cache',
|
|
980
|
-
}, this.options.authToken),
|
|
989
|
+
}, this.options.authToken, this.options.presenceSession),
|
|
981
990
|
signal: controller.signal,
|
|
982
991
|
cache: 'no-store', // Force browser to not cache
|
|
983
992
|
});
|
|
@@ -1049,7 +1058,7 @@ export class BootstrapFetcher {
|
|
|
1049
1058
|
method: 'GET',
|
|
1050
1059
|
headers: withAuthHeaders(this.options.getAuthToken, {
|
|
1051
1060
|
'Content-Type': 'application/json',
|
|
1052
|
-
}, this.options.authToken),
|
|
1061
|
+
}, this.options.authToken, this.options.presenceSession),
|
|
1053
1062
|
signal: controller.signal,
|
|
1054
1063
|
});
|
|
1055
1064
|
} catch (error) {
|
|
@@ -41,6 +41,7 @@ import type { LoadWhere, Query, WhereClause, WhereOp } from '../query/types.js';
|
|
|
41
41
|
import { normalizeWhere } from '@abloatai/transaction/client/resources/where';
|
|
42
42
|
import type { Schema } from '@abloatai/transaction/schema/schema';
|
|
43
43
|
import type { LogPositionPort } from '../logPosition.js';
|
|
44
|
+
import type { PresenceSessionSource } from '@abloatai/transaction/presence';
|
|
44
45
|
|
|
45
46
|
export interface OnDemandLoaderOptions {
|
|
46
47
|
readonly objectPool: InstanceCache;
|
|
@@ -58,6 +59,7 @@ export interface OnDemandLoaderOptions {
|
|
|
58
59
|
* propagate without re-instantiating the coordinator.
|
|
59
60
|
*/
|
|
60
61
|
readonly getAuthToken?: () => string | null;
|
|
62
|
+
readonly presenceSession?: PresenceSessionSource;
|
|
61
63
|
/** @deprecated Use `getAuthToken`. */
|
|
62
64
|
readonly getCapabilityToken?: () => string | null;
|
|
63
65
|
/** The owning client's runtime. Defaults to the module-global bridge. */
|
|
@@ -647,6 +649,7 @@ export class OnDemandLoader {
|
|
|
647
649
|
getAuthToken: this.authTokenProvider ?? undefined,
|
|
648
650
|
recoverCredential: this.credentialRecovery ?? undefined,
|
|
649
651
|
runtime: this.opts.runtime,
|
|
652
|
+
presenceSession: this.opts.presenceSession,
|
|
650
653
|
},
|
|
651
654
|
{ queries: [query] },
|
|
652
655
|
);
|