@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.
Files changed (52) hide show
  1. package/dist/local/BaseSyncedStore.js +5 -5
  2. package/dist/local/Model.js +46 -56
  3. package/dist/local/NetworkMonitor.js +2 -0
  4. package/dist/local/RuntimeContext.js +2 -0
  5. package/dist/local/SyncClient.d.ts +5 -29
  6. package/dist/local/SyncClient.js +26 -99
  7. package/dist/local/client/createModelOperations.js +6 -4
  8. package/dist/local/fileUploads.d.ts +27 -0
  9. package/dist/local/fileUploads.js +55 -0
  10. package/dist/local/stores/syncAction.d.ts +1 -1
  11. package/dist/local/sync/contextOnChange.js +1 -1
  12. package/dist/local/sync/createClaimStream.js +1 -1
  13. package/dist/local/sync/deltaPipeline.js +12 -6
  14. package/dist/local/sync/schemas.d.ts +2 -2
  15. package/dist/local/transactions/localMutation.js +3 -3
  16. package/dist/local/transactions/mutations/MutationQueue.d.ts +1 -2
  17. package/dist/local/transactions/mutations/MutationQueue.js +25 -51
  18. package/dist/local/transactions/mutations/batchProcessing.js +23 -10
  19. package/dist/local/transactions/mutations/commitPayload.d.ts +8 -1
  20. package/dist/local/transactions/mutations/commitTransport.js +3 -1
  21. package/dist/local/transactions/mutations/executionSelection.d.ts +0 -1
  22. package/dist/local/transactions/mutations/executionSelection.js +9 -17
  23. package/dist/local/transactions/mutations/failureHandling.js +9 -0
  24. package/dist/local/transactions/mutations/localMutation.js +3 -3
  25. package/dist/local/transactions/mutations/queueCoalescing.js +8 -0
  26. package/dist/react/useErrorListener.js +1 -1
  27. package/dist/react/useMutationFailureListener.js +1 -1
  28. package/package.json +3 -4
  29. package/src/local/BaseSyncedStore.ts +5 -5
  30. package/src/local/Model.ts +45 -55
  31. package/src/local/NetworkMonitor.ts +2 -0
  32. package/src/local/RuntimeContext.ts +2 -0
  33. package/src/local/SyncClient.ts +33 -127
  34. package/src/local/client/createModelOperations.ts +9 -6
  35. package/src/local/fileUploads.ts +97 -0
  36. package/src/local/sync/contextOnChange.ts +1 -1
  37. package/src/local/sync/createClaimStream.ts +1 -1
  38. package/src/local/sync/deltaPipeline.ts +10 -6
  39. package/src/local/transactions/localMutation.ts +3 -3
  40. package/src/local/transactions/mutations/MutationQueue.ts +24 -53
  41. package/src/local/transactions/mutations/batchProcessing.ts +25 -10
  42. package/src/local/transactions/mutations/commitPayload.ts +11 -1
  43. package/src/local/transactions/mutations/commitTransport.ts +2 -2
  44. package/src/local/transactions/mutations/executionSelection.ts +9 -15
  45. package/src/local/transactions/mutations/failureHandling.ts +10 -0
  46. package/src/local/transactions/mutations/localMutation.ts +3 -3
  47. package/src/local/transactions/mutations/queueCoalescing.ts +6 -0
  48. package/src/react/useErrorListener.ts +1 -1
  49. package/src/react/useMutationFailureListener.ts +1 -1
  50. package/dist/local/transactions/mutations/pendingDrain.d.ts +0 -33
  51. package/dist/local/transactions/mutations/pendingDrain.js +0 -117
  52. 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
@@ -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?.get(key);
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
- if (properties) {
362
- const json = this.toJSON();
363
- for (const [propName, metadata] of properties) {
364
- // Check required fields
365
- if (!metadata.nullable && !metadata.optional) {
366
- const value = json[propName];
367
- if (value == null || value === '') {
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
- // Run custom validation rules
372
- const rules = this.validationRules[propName];
373
- if (rules) {
374
- const value = json[propName];
375
- for (const rule of rules) {
376
- const error = rule(value);
377
- if (error)
378
- errors.push(error);
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
- if (properties) {
683
- const self = this;
684
- for (const [propName, metadata] of properties) {
685
- // Skip certain types
686
- if (metadata.type === 'ephemeralProperty')
687
- continue;
688
- if (metadata.type === 'referenceModel')
689
- continue;
690
- if (metadata.type === 'referenceCollection')
691
- continue;
692
- const value = self[propName];
693
- if (value !== undefined) {
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
- if (properties) {
797
- const self = this;
798
- for (const [propName, metadata] of properties) {
799
- if (metadata.type === 'referenceCollection') {
800
- const collection = self[propName];
801
- if (collection?.dispose) {
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
- if (properties) {
829
- const json = this.toJSON();
830
- for (const [propName] of properties) {
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
- if (properties) {
878
- for (const [propName, metadata] of properties) {
879
- if (metadata.type === 'ephemeralProperty' ||
880
- metadata.type === 'referenceModel' ||
881
- metadata.type === 'referenceCollection') {
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
- * Upload a file and create its attachment record. The upload runs through
206
- * the {@link MutationQueue}, and a model is built from the server's
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
  *
@@ -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 { AbloAuthenticationError, AbloError, AbloValidationError } from '@abloatai/transaction/errors';
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
- * Upload a file and create its attachment record. The upload runs through
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
- const models = [];
955
- for (const result of results) {
956
- const model = this.objectPool.createFromData({ ...result });
957
- if (model) {
958
- this.objectPool.add(model, ModelScope.live);
959
- this.notifyObservers({
960
- type: 'create',
961
- modelType: model.getModelName(),
962
- model,
963
- });
964
- models.push(model);
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(() => undefined);
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. Always kick both durable lanes on the real
1229
- // socket event, even when the high-level state did not change.
1230
- void this.drainPendingConfirmations().catch((error) => {
1231
- this.runtime.observability.captureMutationFailure({
1232
- context: 'restore-commit-outbox',
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(() => undefined);
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(() => undefined);
231
+ await releaseClaimsForEntity(entityId).catch(ignoreBestEffortClaimReleaseFailure);
230
232
  if (explicit && !explicitWasLocal) {
231
- await explicit.release?.().catch(() => undefined);
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
+ }
@@ -10,10 +10,10 @@ export declare const syncActionSchema: z.ZodObject<{
10
10
  modelName: z.ZodString;
11
11
  modelId: z.ZodString;
12
12
  action: z.ZodEnum<{
13
- A: "A";
14
13
  I: "I";
15
14
  U: "U";
16
15
  D: "D";
16
+ A: "A";
17
17
  V: "V";
18
18
  C: "C";
19
19
  G: "G";
@@ -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 (!resident || resident.getModelName().toLowerCase() !== read.model.toLowerCase()) {
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
- if (deltas[index - 1].id >= deltas[index].id) {
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 transactionId = changes[index].transactionId;
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 (end < changes.length && changes[end].transactionId === transactionId)
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
- : (delta.data);
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 (let index = 0; index < slices.length; index++) {
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
  }