@abloatai/humans 0.59.2 → 0.61.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/README.md +1 -1
  2. package/dist/Ablo.d.ts +2 -10
  3. package/dist/Ablo.js +0 -1
  4. package/dist/client.d.ts +1 -48
  5. package/dist/humans.d.ts +1 -1
  6. package/dist/local/BaseSyncedStore.d.ts +5 -5
  7. package/dist/local/BaseSyncedStore.js +7 -7
  8. package/dist/local/Database.d.ts +2 -2
  9. package/dist/local/LazyReferenceCollection.d.ts +1 -1
  10. package/dist/local/Model.js +46 -56
  11. package/dist/local/NetworkMonitor.js +2 -0
  12. package/dist/local/RuntimeContext.js +2 -0
  13. package/dist/local/SyncClient.d.ts +12 -36
  14. package/dist/local/SyncClient.js +50 -108
  15. package/dist/local/client/createModelOperations.d.ts +3 -27
  16. package/dist/local/client/createModelOperations.js +20 -21
  17. package/dist/local/client/options.d.ts +14 -39
  18. package/dist/local/client/reactiveEngine.d.ts +3 -9
  19. package/dist/local/client/reactiveEngine.js +6 -151
  20. package/dist/local/client/storeLifecycle.js +5 -1
  21. package/dist/local/fileUploads.d.ts +27 -0
  22. package/dist/local/fileUploads.js +55 -0
  23. package/dist/local/storeContract.d.ts +5 -5
  24. package/dist/local/stores/syncAction.d.ts +4 -4
  25. package/dist/local/sync/contextOnChange.js +1 -1
  26. package/dist/local/sync/createClaimStream.js +1 -1
  27. package/dist/local/sync/credentialLifecycle.d.ts +4 -5
  28. package/dist/local/sync/credentialLifecycle.js +4 -5
  29. package/dist/local/sync/deltaPipeline.js +12 -6
  30. package/dist/local/sync/schemas.d.ts +10 -10
  31. package/dist/local/sync/scopeGroups.d.ts +11 -0
  32. package/dist/local/sync/scopeGroups.js +75 -0
  33. package/dist/local/sync/wsFrameHandlers.d.ts +1 -1
  34. package/dist/local/transactions/localMutation.js +3 -3
  35. package/dist/local/transactions/mutations/MutationQueue.d.ts +4 -5
  36. package/dist/local/transactions/mutations/MutationQueue.js +25 -51
  37. package/dist/local/transactions/mutations/batchProcessing.js +23 -10
  38. package/dist/local/transactions/mutations/commitPayload.d.ts +9 -2
  39. package/dist/local/transactions/mutations/commitTransport.js +3 -1
  40. package/dist/local/transactions/mutations/executionSelection.d.ts +0 -1
  41. package/dist/local/transactions/mutations/executionSelection.js +9 -17
  42. package/dist/local/transactions/mutations/failureHandling.js +9 -0
  43. package/dist/local/transactions/mutations/localMutation.js +3 -3
  44. package/dist/local/transactions/mutations/queueCoalescing.js +8 -0
  45. package/dist/local/transactions/mutations/replayValidation.d.ts +15 -15
  46. package/dist/react/AbloProvider.d.ts +11 -86
  47. package/dist/react/AbloProvider.js +11 -163
  48. package/dist/react/ClientSideSuspense.d.ts +1 -1
  49. package/dist/react/DefaultFallback.d.ts +1 -1
  50. package/dist/react/createAbloReact.js +1 -1
  51. package/dist/react/useErrorListener.js +1 -1
  52. package/dist/react/useMutationFailureListener.js +1 -1
  53. package/dist/react.d.ts +1 -1
  54. package/dist/react.js +1 -1
  55. package/dist/surface.d.ts +3 -3
  56. package/dist/surface.js +1 -4
  57. package/package.json +3 -3
  58. package/src/Ablo.ts +5 -17
  59. package/src/client.ts +0 -51
  60. package/src/local/BaseSyncedStore.ts +14 -14
  61. package/src/local/Model.ts +45 -55
  62. package/src/local/NetworkMonitor.ts +2 -0
  63. package/src/local/RuntimeContext.ts +2 -0
  64. package/src/local/SyncClient.ts +73 -140
  65. package/src/local/client/createModelOperations.ts +30 -64
  66. package/src/local/client/options.ts +20 -43
  67. package/src/local/client/reactiveEngine.ts +7 -179
  68. package/src/local/client/storeLifecycle.ts +6 -1
  69. package/src/local/fileUploads.ts +97 -0
  70. package/src/local/storeContract.ts +5 -5
  71. package/src/local/sync/contextOnChange.ts +1 -1
  72. package/src/local/sync/createClaimStream.ts +1 -1
  73. package/src/local/sync/credentialLifecycle.ts +4 -5
  74. package/src/local/sync/deltaPipeline.ts +10 -6
  75. package/src/local/sync/scopeGroups.ts +91 -0
  76. package/src/local/sync/wsFrameHandlers.ts +0 -1
  77. package/src/local/transactions/localMutation.ts +3 -3
  78. package/src/local/transactions/mutations/MutationQueue.ts +24 -53
  79. package/src/local/transactions/mutations/batchProcessing.ts +25 -10
  80. package/src/local/transactions/mutations/commitPayload.ts +11 -1
  81. package/src/local/transactions/mutations/commitTransport.ts +2 -2
  82. package/src/local/transactions/mutations/executionSelection.ts +9 -15
  83. package/src/local/transactions/mutations/failureHandling.ts +10 -0
  84. package/src/local/transactions/mutations/localMutation.ts +3 -3
  85. package/src/local/transactions/mutations/queueCoalescing.ts +6 -0
  86. package/src/react/AbloProvider.tsx +17 -249
  87. package/src/react/useErrorListener.ts +1 -1
  88. package/src/react/useMutationFailureListener.ts +1 -1
  89. package/src/react.ts +1 -5
  90. package/src/surface.ts +1 -4
  91. package/dist/local/sync/participants.d.ts +0 -132
  92. package/dist/local/sync/participants.js +0 -342
  93. package/dist/local/transactions/mutations/pendingDrain.d.ts +0 -33
  94. package/dist/local/transactions/mutations/pendingDrain.js +0 -117
  95. package/src/local/sync/participants.ts +0 -564
  96. package/src/local/transactions/mutations/pendingDrain.ts +0 -169
@@ -416,7 +416,7 @@ export abstract class Model {
416
416
  const original = this.getOriginalSnapshot();
417
417
  for (const key of keys) {
418
418
  if (key === 'id') continue;
419
- const mod = modified?.get(key);
419
+ const mod = modified.get(key);
420
420
  if (mod) {
421
421
  out[key] = mod.old;
422
422
  } else if (original && key in original) {
@@ -463,25 +463,23 @@ export abstract class Model {
463
463
  const modelName = this.getModelName();
464
464
  const properties = getActiveRegistry().getProperties(modelName);
465
465
 
466
- if (properties) {
467
- const json = this.toJSON();
468
- for (const [propName, metadata] of properties) {
469
- // Check required fields
470
- if (!metadata.nullable && !metadata.optional) {
471
- const value = json[propName];
472
- if (value == null || value === '') {
473
- errors.push(`${propName} is required`);
474
- }
466
+ const json = this.toJSON();
467
+ for (const [propName, metadata] of properties) {
468
+ // Check required fields
469
+ if (!metadata.nullable && !metadata.optional) {
470
+ const value = json[propName];
471
+ if (value == null || value === '') {
472
+ errors.push(`${propName} is required`);
475
473
  }
474
+ }
476
475
 
477
- // Run custom validation rules
478
- const rules = this.validationRules[propName];
479
- if (rules) {
480
- const value = json[propName];
481
- for (const rule of rules) {
482
- const error = rule(value);
483
- if (error) errors.push(error);
484
- }
476
+ // Run custom validation rules
477
+ const rules = this.validationRules[propName];
478
+ if (rules) {
479
+ const value = json[propName];
480
+ for (const rule of rules) {
481
+ const error = rule(value);
482
+ if (error) errors.push(error);
485
483
  }
486
484
  }
487
485
  }
@@ -816,18 +814,16 @@ export abstract class Model {
816
814
  result.archivedAt = this.archivedAt?.toISOString() ?? null;
817
815
  }
818
816
 
819
- if (properties) {
820
- const self = this as Record<string, unknown>;
821
- for (const [propName, metadata] of properties) {
822
- // Skip certain types
823
- if (metadata.type === 'ephemeralProperty') continue;
824
- if (metadata.type === 'referenceModel') continue;
825
- if (metadata.type === 'referenceCollection') continue;
826
-
827
- const value = self[propName];
828
- if (value !== undefined) {
829
- result[propName] = value;
830
- }
817
+ const self = this as Record<string, unknown>;
818
+ for (const [propName, metadata] of properties) {
819
+ // Skip certain types
820
+ if (metadata.type === 'ephemeralProperty') continue;
821
+ if (metadata.type === 'referenceModel') continue;
822
+ if (metadata.type === 'referenceCollection') continue;
823
+
824
+ const value = self[propName];
825
+ if (value !== undefined) {
826
+ result[propName] = value;
831
827
  }
832
828
  }
833
829
 
@@ -946,14 +942,12 @@ export abstract class Model {
946
942
  const modelName = this.getModelName();
947
943
  const properties = getActiveRegistry().getProperties(modelName);
948
944
 
949
- if (properties) {
950
- const self = this as Record<string, unknown>;
951
- for (const [propName, metadata] of properties) {
952
- if (metadata.type === 'referenceCollection') {
953
- const collection = self[propName] as Disposable | undefined;
954
- if (collection?.dispose) {
955
- collection.dispose();
956
- }
945
+ const self = this as Record<string, unknown>;
946
+ for (const [propName, metadata] of properties) {
947
+ if (metadata.type === 'referenceCollection') {
948
+ const collection = self[propName] as Disposable | undefined;
949
+ if (collection) {
950
+ collection.dispose();
957
951
  }
958
952
  }
959
953
  }
@@ -983,11 +977,9 @@ export abstract class Model {
983
977
  const modelName = this.getModelName();
984
978
  const properties = getActiveRegistry().getProperties(modelName);
985
979
 
986
- if (properties) {
987
- const json = this.toJSON();
988
- for (const [propName] of properties) {
989
- snapshot[propName] = json[propName];
990
- }
980
+ const json = this.toJSON();
981
+ for (const [propName] of properties) {
982
+ snapshot[propName] = json[propName];
991
983
  }
992
984
 
993
985
  return snapshot;
@@ -1034,19 +1026,17 @@ export abstract class Model {
1034
1026
 
1035
1027
  const properties = getActiveRegistry().getProperties(this.getModelName());
1036
1028
  const self = this as Record<string, unknown>;
1037
- if (properties) {
1038
- for (const [propName, metadata] of properties) {
1039
- if (
1040
- metadata.type === 'ephemeralProperty' ||
1041
- metadata.type === 'referenceModel' ||
1042
- metadata.type === 'referenceCollection'
1043
- ) {
1044
- continue;
1045
- }
1046
- // Reading through the observable getter is the point: it subscribes the
1047
- // enclosing MobX reaction to this field.
1048
- snapshot[propName] = self[propName];
1029
+ for (const [propName, metadata] of properties) {
1030
+ if (
1031
+ metadata.type === 'ephemeralProperty' ||
1032
+ metadata.type === 'referenceModel' ||
1033
+ metadata.type === 'referenceCollection'
1034
+ ) {
1035
+ continue;
1049
1036
  }
1037
+ // Reading through the observable getter is the point: it subscribes the
1038
+ // enclosing MobX reaction to this field.
1039
+ snapshot[propName] = self[propName];
1050
1040
  }
1051
1041
 
1052
1042
  for (const name of this.getDerivedGetterNames()) {
@@ -14,6 +14,8 @@ export class NetworkMonitor extends EventEmitter {
14
14
  // Only `navigator.onLine === false` means offline. Node 18+ exposes a global
15
15
  // `navigator` with `onLine === undefined`, so the naive `navigator.onLine`
16
16
  // would seed `false` (offline) on every server client — start optimistic.
17
+ // DOM types say `onLine` is boolean, but Node exposes it as undefined.
18
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-boolean-literal-compare
17
19
  private isOnline = !(typeof navigator !== 'undefined' && navigator.onLine === false);
18
20
  private lastOnlineCheck: Date = new Date();
19
21
 
@@ -106,6 +106,8 @@ export const browserOnlineStatus: OnlineStatusProvider = {
106
106
  // signal. Don't use `!navigator.onLine`: Node 18+ exposes a global
107
107
  // `navigator` whose `onLine` is `undefined`, which `!` would read as offline —
108
108
  // wedging every Node/server client (agents, worker, MCP) into a false offline.
109
+ // DOM types say `onLine` is boolean, but Node exposes it as undefined.
110
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-boolean-literal-compare
109
111
  return !(typeof navigator !== 'undefined' && navigator.onLine === false);
110
112
  },
111
113
  };
@@ -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 { AbloAuthenticationError, AbloError, AbloValidationError } from '@abloatai/transaction/errors';
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;
@@ -935,7 +942,8 @@ export class SyncClient extends EventEmitter {
935
942
  model: Model,
936
943
  poolAction: () => void,
937
944
  writeOptions?: WriteOptions,
938
- ): void {
945
+ capturedChangesOverride?: Record<string, unknown>,
946
+ ): Promise<void> | undefined {
939
947
  // No-op UPDATE guard (O(1)). An update with no dirty fields would travel
940
948
  // to the server, get dropped by `coalesceOperations` Rule 4 (empty input),
941
949
  // and — if it was the only op — come back as `lastSyncId: 0`. That trips
@@ -949,24 +957,32 @@ export class SyncClient extends EventEmitter {
949
957
  // is false → we fall through to the normal path rather than risk dropping a
950
958
  // real write. Only a genuine Model with an empty dirty-set is skipped.
951
959
  const hasChanges: unknown = model.hasChanges;
952
- if (type === 'update' && hasChanges === false) {
953
- return;
960
+ if (
961
+ type === 'update' &&
962
+ hasChanges === false &&
963
+ capturedChangesOverride === undefined
964
+ ) {
965
+ return Promise.resolve();
954
966
  }
955
967
 
956
968
  // Capture changes before the pool action runs. Pool operations —
957
969
  // upsert in particular — can clear the model's local changes, so
958
970
  // capturing first ensures they are never lost.
959
- const capturedChanges =
960
- type === 'update' || type === 'create' ? this.captureModelChanges(model) : undefined;
971
+ const capturedChanges = capturedChangesOverride !== undefined
972
+ ? Object.freeze({ ...capturedChangesOverride })
973
+ : type === 'update' || type === 'create'
974
+ ? this.captureModelChanges(model)
975
+ : undefined;
961
976
 
962
977
  poolAction();
963
- this.stageMutation(type, model, capturedChanges, writeOptions);
978
+ const confirmation = this.stageMutation(type, model, capturedChanges, writeOptions);
964
979
  this.notifyObservers({
965
980
  type,
966
981
  modelType: model.getModelName(),
967
982
  model: type !== 'delete' ? model : undefined,
968
983
  modelId: model.id,
969
984
  });
985
+ return confirmation;
970
986
 
971
987
  // QueryProcessor uses `models:changed` to invalidate caches. Coalesce
972
988
  // to one event per microtask: a paste of 100 rows should re-run
@@ -1004,13 +1020,23 @@ export class SyncClient extends EventEmitter {
1004
1020
  }
1005
1021
 
1006
1022
  /** Add new model (CREATE) - works offline */
1007
- add(model: Model, options?: WriteOptions): void {
1008
- this.mutate('create', model, () => { this.objectPool.add(model, ModelScope.live); }, options);
1023
+ add(model: Model, options?: WriteOptions): Promise<void> | undefined {
1024
+ return this.mutate('create', model, () => { this.objectPool.add(model, ModelScope.live); }, options);
1009
1025
  }
1010
1026
 
1011
1027
  /** Update existing model (UPDATE) - works offline */
1012
- update(model: Model, options?: WriteOptions): void {
1013
- this.mutate('update', model, () => { this.objectPool.upsert(model, ModelScope.live); }, options);
1028
+ update(
1029
+ model: Model,
1030
+ options?: WriteOptions,
1031
+ capturedChanges?: Record<string, unknown>,
1032
+ ): Promise<void> | undefined {
1033
+ return this.mutate(
1034
+ 'update',
1035
+ model,
1036
+ () => { this.objectPool.upsert(model, ModelScope.live); },
1037
+ options,
1038
+ capturedChanges,
1039
+ );
1014
1040
  }
1015
1041
 
1016
1042
  /**
@@ -1038,7 +1064,7 @@ export class SyncClient extends EventEmitter {
1038
1064
  if (capturedChanges === undefined) return;
1039
1065
 
1040
1066
  this.objectPool.upsert(model, ModelScope.live);
1041
- this.stageMutation('update', model, capturedChanges);
1067
+ void this.stageMutation('update', model, capturedChanges);
1042
1068
  this.notifyObservers({
1043
1069
  type: 'update',
1044
1070
  modelType: model.getModelName(),
@@ -1055,134 +1081,36 @@ export class SyncClient extends EventEmitter {
1055
1081
  }
1056
1082
 
1057
1083
  /** Delete model (DELETE) - works offline */
1058
- delete(model: Model, options?: WriteOptions): void {
1084
+ delete(model: Model, options?: WriteOptions): Promise<void> | undefined {
1059
1085
  // Clear pending mutations first to prevent "not found" errors on fast delete
1060
1086
  this.mutationQueue.cancelTransactionsForModel(model.id);
1061
- this.mutate('delete', model, () => this.objectPool.remove(model.id), options);
1087
+ return this.mutate('delete', model, () => this.objectPool.remove(model.id), options);
1062
1088
  }
1063
1089
 
1064
- /**
1065
- * Upload a file and create its attachment record. The upload runs through
1066
- * the {@link MutationQueue}, and a model is built from the server's
1067
- * response and added to the pool.
1068
- */
1069
- async uploadFile(
1070
- file: File,
1071
- options: {
1072
- id: string;
1073
- attachableType: string;
1074
- attachableId: string;
1075
- metadata?: Record<string, unknown>;
1076
- }
1077
- ): Promise<Model | null> {
1078
- if (!this.userId || !this.organizationId) {
1079
- throw new AbloAuthenticationError('Authentication required for file uploads', {
1080
- code: 'file_upload_auth_required',
1081
- });
1082
- }
1083
-
1084
- try {
1085
- // Use MutationQueue to handle the upload mutation
1086
- const result = await this.mutationQueue.uploadAttachment(
1087
- file,
1088
- {
1089
- id: options.id,
1090
- attachableType: options.attachableType,
1091
- attachableId: options.attachableId,
1092
- metadata: options.metadata,
1093
- },
1094
- {
1095
- userId: this.userId,
1096
- organizationId: this.organizationId,
1097
- }
1098
- );
1099
-
1100
- if (result) {
1101
- // Create model from response using ModelRegistry (generic — no concrete class import)
1102
- const model = this.objectPool.createFromData({
1103
- id: options.id,
1104
- ...result,
1105
- });
1106
-
1107
- if (model) {
1108
- this.objectPool.add(model, ModelScope.live);
1109
- this.notifyObservers({
1110
- type: 'create',
1111
- modelType: model.getModelName(),
1112
- model,
1113
- });
1114
- return model;
1115
- }
1116
- }
1117
-
1118
- return null;
1119
- } catch (error) {
1120
- this.runtime.observability.captureMutationFailure({
1121
- context: 'file-upload',
1122
- error: error instanceof Error ? error : new Error(String(error)),
1123
- });
1124
- throw error;
1125
- }
1126
- }
1127
-
1128
- /**
1129
- * Batch upload files — single GraphQL call + parallel S3 PUTs.
1130
- *
1131
- * Returns the raw `Model[]` built by the object pool (typename is
1132
- * determined by the payload the server returns — currently always
1133
- * `Attachment`). The SDK has no knowledge of app-specific model classes,
1134
- * so it cannot honestly claim a narrower return type; consumers that
1135
- * need an `Attachment[]` project through their own typed accessor
1136
- * (e.g. `store.query.attachments.findMany({ where: { id: IN ids } })`)
1137
- * after the upload resolves.
1138
- */
1139
- async batchUploadFiles(
1140
- files: File[],
1141
- options: {
1142
- ids: string[];
1143
- attachableType: string;
1144
- attachableId: string;
1145
- metadata?: Record<string, unknown>;
1146
- }
1147
- ): Promise<Model[]> {
1148
- if (!this.userId || !this.organizationId) {
1149
- throw new AbloAuthenticationError('Authentication required for file uploads', {
1150
- code: 'file_upload_auth_required',
1151
- });
1152
- }
1153
-
1154
- const items = options.ids.map((id) => ({
1155
- id,
1156
- attachableType: options.attachableType,
1157
- attachableId: options.attachableId,
1158
- metadata: options.metadata,
1159
- }));
1160
-
1161
- const results = await this.mutationQueue.batchUploadAttachments(files, items, {
1090
+ private fileUploadContext(): FileUploadContext {
1091
+ return {
1162
1092
  userId: this.userId,
1163
1093
  organizationId: this.organizationId,
1164
- });
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
+ }
1165
1102
 
1166
- const models: Model[] = [];
1167
- for (const result of results) {
1168
- const model = this.objectPool.createFromData({ ...result });
1169
- if (model) {
1170
- this.objectPool.add(model, ModelScope.live);
1171
- this.notifyObservers({
1172
- type: 'create',
1173
- modelType: model.getModelName(),
1174
- model,
1175
- });
1176
- models.push(model);
1177
- }
1178
- }
1103
+ uploadFile(file: File, options: FileUploadOptions): Promise<Model | null> {
1104
+ return uploadFile(this.fileUploadContext(), file, options);
1105
+ }
1179
1106
 
1180
- return models;
1107
+ batchUploadFiles(files: File[], options: BatchFileUploadOptions): Promise<Model[]> {
1108
+ return batchUploadFiles(this.fileUploadContext(), files, options);
1181
1109
  }
1182
1110
 
1183
1111
  /** Archive model (ARCHIVE) - works offline */
1184
- archive(model: Model): void {
1185
- 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); });
1186
1114
  }
1187
1115
 
1188
1116
  /**
@@ -1204,8 +1132,8 @@ export class SyncClient extends EventEmitter {
1204
1132
  model: Model,
1205
1133
  capturedChanges?: Record<string, unknown>,
1206
1134
  writeOptions?: WriteOptions,
1207
- ): void {
1208
- if (this.isDisposed) return;
1135
+ ): Promise<void> | undefined {
1136
+ if (this.isDisposed) return Promise.resolve();
1209
1137
  if (!this.userId || !this.organizationId) {
1210
1138
  this.mutationQueue.deferMutation(type, model, capturedChanges, writeOptions);
1211
1139
  return;
@@ -1218,6 +1146,13 @@ export class SyncClient extends EventEmitter {
1218
1146
  capturedChanges,
1219
1147
  writeOptions,
1220
1148
  );
1149
+ const confirmation = staging.then(async (transaction) => {
1150
+ await transaction.confirmation;
1151
+ });
1152
+ // Most internal callers intentionally use fire-and-forget writes. Observe
1153
+ // their rejection without replacing the exact promise returned to model
1154
+ // operations that need authoritative per-transaction confirmation.
1155
+ void confirmation.catch(ignoreSeparatelyObservedMutationFailure);
1221
1156
  const pending = staging.then(() => undefined).catch((error: Error) => {
1222
1157
  this.runtime.observability.captureMutationFailure({
1223
1158
  context: `stage-mutation-${type}`,
@@ -1228,6 +1163,7 @@ export class SyncClient extends EventEmitter {
1228
1163
  });
1229
1164
  this.pendingStages.add(pending);
1230
1165
  void pending.finally(() => this.pendingStages.delete(pending));
1166
+ return confirmation;
1231
1167
  }
1232
1168
 
1233
1169
  private scheduleSync(): void {
@@ -1468,14 +1404,11 @@ export class SyncClient extends EventEmitter {
1468
1404
  markConnected(): void {
1469
1405
  this.setConnectionState('connected');
1470
1406
  // Browser online state may have marked the client connected before the
1471
- // WebSocket itself was ready. Always kick both durable lanes on the real
1472
- // socket event, even when the high-level state did not change.
1473
- void this.drainPendingConfirmations().catch((error: unknown) => {
1474
- this.runtime.observability.captureMutationFailure({
1475
- context: 'restore-commit-outbox',
1476
- error: error instanceof Error ? error : new Error(String(error)),
1477
- });
1478
- });
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.
1479
1412
  void this.processPendingMutations();
1480
1413
  }
1481
1414