@abloatai/humans 0.60.0 → 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/dist/local/BaseSyncedStore.js +5 -5
- 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 +5 -29
- package/dist/local/SyncClient.js +26 -99
- package/dist/local/client/createModelOperations.js +6 -4
- package/dist/local/fileUploads.d.ts +27 -0
- package/dist/local/fileUploads.js +55 -0
- package/dist/local/stores/syncAction.d.ts +1 -1
- package/dist/local/sync/contextOnChange.js +1 -1
- package/dist/local/sync/createClaimStream.js +1 -1
- package/dist/local/sync/deltaPipeline.js +12 -6
- package/dist/local/sync/schemas.d.ts +2 -2
- package/dist/local/transactions/localMutation.js +3 -3
- package/dist/local/transactions/mutations/MutationQueue.d.ts +1 -2
- 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/react/useErrorListener.js +1 -1
- package/dist/react/useMutationFailureListener.js +1 -1
- package/package.json +3 -4
- package/src/local/BaseSyncedStore.ts +5 -5
- 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/createModelOperations.ts +9 -6
- package/src/local/fileUploads.ts +97 -0
- package/src/local/sync/contextOnChange.ts +1 -1
- package/src/local/sync/createClaimStream.ts +1 -1
- package/src/local/sync/deltaPipeline.ts +10 -6
- 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/useErrorListener.ts +1 -1
- package/src/react/useMutationFailureListener.ts +1 -1
- package/dist/local/transactions/mutations/pendingDrain.d.ts +0 -33
- package/dist/local/transactions/mutations/pendingDrain.js +0 -117
- package/src/local/transactions/mutations/pendingDrain.ts +0 -169
|
@@ -1334,10 +1334,10 @@ export class BaseSyncedStore {
|
|
|
1334
1334
|
const isCreate = !this.objectPool.get(model.id);
|
|
1335
1335
|
if (isCreate) {
|
|
1336
1336
|
model.updatedAt = new Date();
|
|
1337
|
-
this.syncClient.add(model);
|
|
1337
|
+
await this.syncClient.add(model);
|
|
1338
1338
|
}
|
|
1339
1339
|
else {
|
|
1340
|
-
this.syncClient.update(model);
|
|
1340
|
+
await this.syncClient.update(model);
|
|
1341
1341
|
}
|
|
1342
1342
|
}
|
|
1343
1343
|
/** Save with an atomic server mutation (e.g., createSectionWithBlocks) */
|
|
@@ -1350,19 +1350,19 @@ export class BaseSyncedStore {
|
|
|
1350
1350
|
const model = rowAsModel(entity);
|
|
1351
1351
|
this.pendingDeletes.add(model.id);
|
|
1352
1352
|
// SyncClient.delete handles: pool remove, transaction queue
|
|
1353
|
-
this.syncClient.delete(model);
|
|
1353
|
+
await this.syncClient.delete(model);
|
|
1354
1354
|
}
|
|
1355
1355
|
/** Archive a model. Accepts schema-inferred entity shapes (see `save`). */
|
|
1356
1356
|
async archive(entity) {
|
|
1357
1357
|
const model = rowAsModel(entity);
|
|
1358
1358
|
model.archivedAt = new Date();
|
|
1359
|
-
this.syncClient.archive(model);
|
|
1359
|
+
await this.syncClient.archive(model);
|
|
1360
1360
|
}
|
|
1361
1361
|
/** Unarchive a model. Accepts schema-inferred entity shapes (see `save`). */
|
|
1362
1362
|
async unarchive(entity) {
|
|
1363
1363
|
const model = rowAsModel(entity);
|
|
1364
1364
|
model.archivedAt = null;
|
|
1365
|
-
this.syncClient.update(model);
|
|
1365
|
+
await this.syncClient.update(model);
|
|
1366
1366
|
}
|
|
1367
1367
|
// ── Query API ────────────────────────────────────────────────────────────
|
|
1368
1368
|
// `ablo.<model>.local.get` / `.local.list` is the read surface for
|
package/dist/local/Model.js
CHANGED
|
@@ -311,7 +311,7 @@ export class Model {
|
|
|
311
311
|
for (const key of keys) {
|
|
312
312
|
if (key === 'id')
|
|
313
313
|
continue;
|
|
314
|
-
const mod = modified
|
|
314
|
+
const mod = modified.get(key);
|
|
315
315
|
if (mod) {
|
|
316
316
|
out[key] = mod.old;
|
|
317
317
|
}
|
|
@@ -358,25 +358,23 @@ export class Model {
|
|
|
358
358
|
const errors = [];
|
|
359
359
|
const modelName = this.getModelName();
|
|
360
360
|
const properties = getActiveRegistry().getProperties(modelName);
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
errors.push(`${propName} is required`);
|
|
369
|
-
}
|
|
361
|
+
const json = this.toJSON();
|
|
362
|
+
for (const [propName, metadata] of properties) {
|
|
363
|
+
// Check required fields
|
|
364
|
+
if (!metadata.nullable && !metadata.optional) {
|
|
365
|
+
const value = json[propName];
|
|
366
|
+
if (value == null || value === '') {
|
|
367
|
+
errors.push(`${propName} is required`);
|
|
370
368
|
}
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
369
|
+
}
|
|
370
|
+
// Run custom validation rules
|
|
371
|
+
const rules = this.validationRules[propName];
|
|
372
|
+
if (rules) {
|
|
373
|
+
const value = json[propName];
|
|
374
|
+
for (const rule of rules) {
|
|
375
|
+
const error = rule(value);
|
|
376
|
+
if (error)
|
|
377
|
+
errors.push(error);
|
|
380
378
|
}
|
|
381
379
|
}
|
|
382
380
|
}
|
|
@@ -679,20 +677,18 @@ export class Model {
|
|
|
679
677
|
if (this.archivedAt !== undefined) {
|
|
680
678
|
result.archivedAt = this.archivedAt?.toISOString() ?? null;
|
|
681
679
|
}
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
result[propName] = value;
|
|
695
|
-
}
|
|
680
|
+
const self = this;
|
|
681
|
+
for (const [propName, metadata] of properties) {
|
|
682
|
+
// Skip certain types
|
|
683
|
+
if (metadata.type === 'ephemeralProperty')
|
|
684
|
+
continue;
|
|
685
|
+
if (metadata.type === 'referenceModel')
|
|
686
|
+
continue;
|
|
687
|
+
if (metadata.type === 'referenceCollection')
|
|
688
|
+
continue;
|
|
689
|
+
const value = self[propName];
|
|
690
|
+
if (value !== undefined) {
|
|
691
|
+
result[propName] = value;
|
|
696
692
|
}
|
|
697
693
|
}
|
|
698
694
|
return result;
|
|
@@ -793,14 +789,12 @@ export class Model {
|
|
|
793
789
|
if (hasActiveRegistry()) {
|
|
794
790
|
const modelName = this.getModelName();
|
|
795
791
|
const properties = getActiveRegistry().getProperties(modelName);
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
collection.dispose();
|
|
803
|
-
}
|
|
792
|
+
const self = this;
|
|
793
|
+
for (const [propName, metadata] of properties) {
|
|
794
|
+
if (metadata.type === 'referenceCollection') {
|
|
795
|
+
const collection = self[propName];
|
|
796
|
+
if (collection) {
|
|
797
|
+
collection.dispose();
|
|
804
798
|
}
|
|
805
799
|
}
|
|
806
800
|
}
|
|
@@ -825,11 +819,9 @@ export class Model {
|
|
|
825
819
|
const snapshot = {};
|
|
826
820
|
const modelName = this.getModelName();
|
|
827
821
|
const properties = getActiveRegistry().getProperties(modelName);
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
snapshot[propName] = json[propName];
|
|
832
|
-
}
|
|
822
|
+
const json = this.toJSON();
|
|
823
|
+
for (const [propName] of properties) {
|
|
824
|
+
snapshot[propName] = json[propName];
|
|
833
825
|
}
|
|
834
826
|
return snapshot;
|
|
835
827
|
}
|
|
@@ -874,17 +866,15 @@ export class Model {
|
|
|
874
866
|
snapshot.archivedAt = this.archivedAt;
|
|
875
867
|
const properties = getActiveRegistry().getProperties(this.getModelName());
|
|
876
868
|
const self = this;
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
continue;
|
|
883
|
-
}
|
|
884
|
-
// Reading through the observable getter is the point: it subscribes the
|
|
885
|
-
// enclosing MobX reaction to this field.
|
|
886
|
-
snapshot[propName] = self[propName];
|
|
869
|
+
for (const [propName, metadata] of properties) {
|
|
870
|
+
if (metadata.type === 'ephemeralProperty' ||
|
|
871
|
+
metadata.type === 'referenceModel' ||
|
|
872
|
+
metadata.type === 'referenceCollection') {
|
|
873
|
+
continue;
|
|
887
874
|
}
|
|
875
|
+
// Reading through the observable getter is the point: it subscribes the
|
|
876
|
+
// enclosing MobX reaction to this field.
|
|
877
|
+
snapshot[propName] = self[propName];
|
|
888
878
|
}
|
|
889
879
|
for (const name of this.getDerivedGetterNames()) {
|
|
890
880
|
Object.defineProperty(snapshot, name, {
|
|
@@ -12,6 +12,8 @@ export class NetworkMonitor extends EventEmitter {
|
|
|
12
12
|
// Only `navigator.onLine === false` means offline. Node 18+ exposes a global
|
|
13
13
|
// `navigator` with `onLine === undefined`, so the naive `navigator.onLine`
|
|
14
14
|
// would seed `false` (offline) on every server client — start optimistic.
|
|
15
|
+
// DOM types say `onLine` is boolean, but Node exposes it as undefined.
|
|
16
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-boolean-literal-compare
|
|
15
17
|
isOnline = !(typeof navigator !== 'undefined' && navigator.onLine === false);
|
|
16
18
|
lastOnlineCheck = new Date();
|
|
17
19
|
constructor(runtime = globalRuntime) {
|
|
@@ -47,6 +47,8 @@ export const browserOnlineStatus = {
|
|
|
47
47
|
// signal. Don't use `!navigator.onLine`: Node 18+ exposes a global
|
|
48
48
|
// `navigator` whose `onLine` is `undefined`, which `!` would read as offline —
|
|
49
49
|
// wedging every Node/server client (agents, worker, MCP) into a false offline.
|
|
50
|
+
// DOM types say `onLine` is boolean, but Node exposes it as undefined.
|
|
51
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-boolean-literal-compare
|
|
50
52
|
return !(typeof navigator !== 'undefined' && navigator.onLine === false);
|
|
51
53
|
},
|
|
52
54
|
};
|
|
@@ -21,6 +21,7 @@ import type { WriteOptions } from './interfaces/index.js';
|
|
|
21
21
|
import { LogPosition } from './logPosition.js';
|
|
22
22
|
import { type RehydrationStats, type SyncObserver, type SyncState } from './syncClientTypes.js';
|
|
23
23
|
import type { BootstrapSnapshot } from './syncClientTypes.js';
|
|
24
|
+
import { type BatchFileUploadOptions, type FileUploadOptions } from './fileUploads.js';
|
|
24
25
|
export type { BootstrapSnapshot, RehydrationStats } from './syncClientTypes.js';
|
|
25
26
|
export declare class SyncClient extends EventEmitter {
|
|
26
27
|
private readonly runtime;
|
|
@@ -201,36 +202,11 @@ export declare class SyncClient extends EventEmitter {
|
|
|
201
202
|
get gql(): import("./interfaces/index.js").MutationExecutor;
|
|
202
203
|
/** Delete model (DELETE) - works offline */
|
|
203
204
|
delete(model: Model, options?: WriteOptions): Promise<void> | undefined;
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
* response and added to the pool.
|
|
208
|
-
*/
|
|
209
|
-
uploadFile(file: File, options: {
|
|
210
|
-
id: string;
|
|
211
|
-
attachableType: string;
|
|
212
|
-
attachableId: string;
|
|
213
|
-
metadata?: Record<string, unknown>;
|
|
214
|
-
}): Promise<Model | null>;
|
|
215
|
-
/**
|
|
216
|
-
* Batch upload files — single GraphQL call + parallel S3 PUTs.
|
|
217
|
-
*
|
|
218
|
-
* Returns the raw `Model[]` built by the object pool (typename is
|
|
219
|
-
* determined by the payload the server returns — currently always
|
|
220
|
-
* `Attachment`). The SDK has no knowledge of app-specific model classes,
|
|
221
|
-
* so it cannot honestly claim a narrower return type; consumers that
|
|
222
|
-
* need an `Attachment[]` project through their own typed accessor
|
|
223
|
-
* (e.g. `store.query.attachments.findMany({ where: { id: IN ids } })`)
|
|
224
|
-
* after the upload resolves.
|
|
225
|
-
*/
|
|
226
|
-
batchUploadFiles(files: File[], options: {
|
|
227
|
-
ids: string[];
|
|
228
|
-
attachableType: string;
|
|
229
|
-
attachableId: string;
|
|
230
|
-
metadata?: Record<string, unknown>;
|
|
231
|
-
}): Promise<Model[]>;
|
|
205
|
+
private fileUploadContext;
|
|
206
|
+
uploadFile(file: File, options: FileUploadOptions): Promise<Model | null>;
|
|
207
|
+
batchUploadFiles(files: File[], options: BatchFileUploadOptions): Promise<Model[]>;
|
|
232
208
|
/** Archive model (ARCHIVE) - works offline */
|
|
233
|
-
archive(model: Model): void;
|
|
209
|
+
archive(model: Model): Promise<void> | undefined;
|
|
234
210
|
/**
|
|
235
211
|
* Append a mutation to the pending queue and schedule its sync work.
|
|
236
212
|
*
|
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;
|
|
@@ -856,7 +858,7 @@ export class SyncClient extends EventEmitter {
|
|
|
856
858
|
if (capturedChanges === undefined)
|
|
857
859
|
return;
|
|
858
860
|
this.objectPool.upsert(model, ModelScope.live);
|
|
859
|
-
this.stageMutation('update', model, capturedChanges);
|
|
861
|
+
void this.stageMutation('update', model, capturedChanges);
|
|
860
862
|
this.notifyObservers({
|
|
861
863
|
type: 'update',
|
|
862
864
|
modelType: model.getModelName(),
|
|
@@ -876,99 +878,27 @@ export class SyncClient extends EventEmitter {
|
|
|
876
878
|
this.mutationQueue.cancelTransactionsForModel(model.id);
|
|
877
879
|
return this.mutate('delete', model, () => this.objectPool.remove(model.id), options);
|
|
878
880
|
}
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
* the {@link MutationQueue}, and a model is built from the server's
|
|
882
|
-
* response and added to the pool.
|
|
883
|
-
*/
|
|
884
|
-
async uploadFile(file, options) {
|
|
885
|
-
if (!this.userId || !this.organizationId) {
|
|
886
|
-
throw new AbloAuthenticationError('Authentication required for file uploads', {
|
|
887
|
-
code: 'file_upload_auth_required',
|
|
888
|
-
});
|
|
889
|
-
}
|
|
890
|
-
try {
|
|
891
|
-
// Use MutationQueue to handle the upload mutation
|
|
892
|
-
const result = await this.mutationQueue.uploadAttachment(file, {
|
|
893
|
-
id: options.id,
|
|
894
|
-
attachableType: options.attachableType,
|
|
895
|
-
attachableId: options.attachableId,
|
|
896
|
-
metadata: options.metadata,
|
|
897
|
-
}, {
|
|
898
|
-
userId: this.userId,
|
|
899
|
-
organizationId: this.organizationId,
|
|
900
|
-
});
|
|
901
|
-
if (result) {
|
|
902
|
-
// Create model from response using ModelRegistry (generic — no concrete class import)
|
|
903
|
-
const model = this.objectPool.createFromData({
|
|
904
|
-
id: options.id,
|
|
905
|
-
...result,
|
|
906
|
-
});
|
|
907
|
-
if (model) {
|
|
908
|
-
this.objectPool.add(model, ModelScope.live);
|
|
909
|
-
this.notifyObservers({
|
|
910
|
-
type: 'create',
|
|
911
|
-
modelType: model.getModelName(),
|
|
912
|
-
model,
|
|
913
|
-
});
|
|
914
|
-
return model;
|
|
915
|
-
}
|
|
916
|
-
}
|
|
917
|
-
return null;
|
|
918
|
-
}
|
|
919
|
-
catch (error) {
|
|
920
|
-
this.runtime.observability.captureMutationFailure({
|
|
921
|
-
context: 'file-upload',
|
|
922
|
-
error: error instanceof Error ? error : new Error(String(error)),
|
|
923
|
-
});
|
|
924
|
-
throw error;
|
|
925
|
-
}
|
|
926
|
-
}
|
|
927
|
-
/**
|
|
928
|
-
* Batch upload files — single GraphQL call + parallel S3 PUTs.
|
|
929
|
-
*
|
|
930
|
-
* Returns the raw `Model[]` built by the object pool (typename is
|
|
931
|
-
* determined by the payload the server returns — currently always
|
|
932
|
-
* `Attachment`). The SDK has no knowledge of app-specific model classes,
|
|
933
|
-
* so it cannot honestly claim a narrower return type; consumers that
|
|
934
|
-
* need an `Attachment[]` project through their own typed accessor
|
|
935
|
-
* (e.g. `store.query.attachments.findMany({ where: { id: IN ids } })`)
|
|
936
|
-
* after the upload resolves.
|
|
937
|
-
*/
|
|
938
|
-
async batchUploadFiles(files, options) {
|
|
939
|
-
if (!this.userId || !this.organizationId) {
|
|
940
|
-
throw new AbloAuthenticationError('Authentication required for file uploads', {
|
|
941
|
-
code: 'file_upload_auth_required',
|
|
942
|
-
});
|
|
943
|
-
}
|
|
944
|
-
const items = options.ids.map((id) => ({
|
|
945
|
-
id,
|
|
946
|
-
attachableType: options.attachableType,
|
|
947
|
-
attachableId: options.attachableId,
|
|
948
|
-
metadata: options.metadata,
|
|
949
|
-
}));
|
|
950
|
-
const results = await this.mutationQueue.batchUploadAttachments(files, items, {
|
|
881
|
+
fileUploadContext() {
|
|
882
|
+
return {
|
|
951
883
|
userId: this.userId,
|
|
952
884
|
organizationId: this.organizationId,
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
}
|
|
967
|
-
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);
|
|
968
898
|
}
|
|
969
899
|
/** Archive model (ARCHIVE) - works offline */
|
|
970
900
|
archive(model) {
|
|
971
|
-
this.mutate('archive', model, () => { this.objectPool.updateScope(model.id, ModelScope.archived); });
|
|
901
|
+
return this.mutate('archive', model, () => { this.objectPool.updateScope(model.id, ModelScope.archived); });
|
|
972
902
|
}
|
|
973
903
|
/**
|
|
974
904
|
* Append a mutation to the pending queue and schedule its sync work.
|
|
@@ -999,7 +929,7 @@ export class SyncClient extends EventEmitter {
|
|
|
999
929
|
// Most internal callers intentionally use fire-and-forget writes. Observe
|
|
1000
930
|
// their rejection without replacing the exact promise returned to model
|
|
1001
931
|
// operations that need authoritative per-transaction confirmation.
|
|
1002
|
-
void confirmation.catch(
|
|
932
|
+
void confirmation.catch(ignoreSeparatelyObservedMutationFailure);
|
|
1003
933
|
const pending = staging.then(() => undefined).catch((error) => {
|
|
1004
934
|
this.runtime.observability.captureMutationFailure({
|
|
1005
935
|
context: `stage-mutation-${type}`,
|
|
@@ -1225,14 +1155,11 @@ export class SyncClient extends EventEmitter {
|
|
|
1225
1155
|
markConnected() {
|
|
1226
1156
|
this.setConnectionState('connected');
|
|
1227
1157
|
// Browser online state may have marked the client connected before the
|
|
1228
|
-
// WebSocket itself was ready.
|
|
1229
|
-
//
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
error: error instanceof Error ? error : new Error(String(error)),
|
|
1234
|
-
});
|
|
1235
|
-
});
|
|
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.
|
|
1236
1163
|
void this.processPendingMutations();
|
|
1237
1164
|
}
|
|
1238
1165
|
drainPendingConfirmations() {
|
|
@@ -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) {
|
|
@@ -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
|
+
}
|
|
@@ -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);
|
|
@@ -284,7 +284,7 @@ export function createClaimStream(config, transport = null) {
|
|
|
284
284
|
}
|
|
285
285
|
ownClaims.clear();
|
|
286
286
|
for (const claimId of [...pendingHeartbeats.keys()]) {
|
|
287
|
-
settleHeartbeat(claimId, ({ reject }) => reject(error));
|
|
287
|
+
settleHeartbeat(claimId, ({ reject }) => { reject(error); });
|
|
288
288
|
}
|
|
289
289
|
}));
|
|
290
290
|
}
|
|
@@ -67,7 +67,9 @@ export function deduplicateDeltas(deltas) {
|
|
|
67
67
|
return deltas;
|
|
68
68
|
let strictlyOrdered = true;
|
|
69
69
|
for (let index = 1; index < deltas.length; index += 1) {
|
|
70
|
-
|
|
70
|
+
const previous = deltas[index - 1];
|
|
71
|
+
const current = deltas[index];
|
|
72
|
+
if (!previous || !current || previous.id >= current.id) {
|
|
71
73
|
strictlyOrdered = false;
|
|
72
74
|
break;
|
|
73
75
|
}
|
|
@@ -277,10 +279,13 @@ export function sliceApplyChanges(changes, maxDeltas) {
|
|
|
277
279
|
while (index < changes.length) {
|
|
278
280
|
// The indivisible unit starting here: one transaction's run, or a single
|
|
279
281
|
// untransacted change.
|
|
280
|
-
const
|
|
282
|
+
const change = changes[index];
|
|
283
|
+
if (!change)
|
|
284
|
+
break;
|
|
285
|
+
const transactionId = change.transactionId;
|
|
281
286
|
let end = index + 1;
|
|
282
287
|
if (transactionId !== undefined) {
|
|
283
|
-
while (
|
|
288
|
+
while (changes[end]?.transactionId === transactionId)
|
|
284
289
|
end += 1;
|
|
285
290
|
}
|
|
286
291
|
const groupSize = end - index;
|
|
@@ -327,9 +332,11 @@ async function flushDeltaBatchInner(ctx, queuedDeltas) {
|
|
|
327
332
|
if (customDeltas.length > 0) {
|
|
328
333
|
runInAction(() => {
|
|
329
334
|
for (const delta of customDeltas) {
|
|
335
|
+
if (delta.data === null)
|
|
336
|
+
continue;
|
|
330
337
|
const data = typeof delta.data === 'string'
|
|
331
338
|
? JSON.parse(delta.data)
|
|
332
|
-
:
|
|
339
|
+
: delta.data;
|
|
333
340
|
// 'C' (Covering) is treated identically to 'I' here — the client
|
|
334
341
|
// gained permission to see the entity, so we insert it into the
|
|
335
342
|
// pool as if newly created.
|
|
@@ -395,7 +402,7 @@ async function flushDeltaBatchInner(ctx, queuedDeltas) {
|
|
|
395
402
|
// slice. Slices stay the atomicity unit; the budget only decides where
|
|
396
403
|
// the loop breathes.
|
|
397
404
|
let sliceStartedAt = performance.now();
|
|
398
|
-
for (
|
|
405
|
+
for (const [index, slice] of slices.entries()) {
|
|
399
406
|
if (index > 0 && performance.now() - sliceStartedAt > APPLY_YIELD_BUDGET_MS) {
|
|
400
407
|
pipelineDebug.phase = `apply-yield-${index}`;
|
|
401
408
|
pipelineDebug.applyYields += 1;
|
|
@@ -404,7 +411,6 @@ async function flushDeltaBatchInner(ctx, queuedDeltas) {
|
|
|
404
411
|
}
|
|
405
412
|
pipelineDebug.phase = `apply-slice-${index}`;
|
|
406
413
|
pipelineDebug.applySlices += 1;
|
|
407
|
-
const slice = slices[index];
|
|
408
414
|
if (hasApplyPlugins) {
|
|
409
415
|
runStage(stagePlugins, 'apply', { changes: slice });
|
|
410
416
|
}
|