@abloatai/humans 0.38.0 → 0.40.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 (34) hide show
  1. package/dist/core.d.ts +1 -1
  2. package/dist/core.js +1 -1
  3. package/dist/local/BaseSyncedStore.d.ts +9 -0
  4. package/dist/local/BaseSyncedStore.js +28 -1
  5. package/dist/local/Database.d.ts +20 -0
  6. package/dist/local/Database.js +83 -49
  7. package/dist/local/InstanceCache.d.ts +40 -8
  8. package/dist/local/InstanceCache.js +156 -83
  9. package/dist/local/Model.d.ts +28 -0
  10. package/dist/local/Model.js +102 -35
  11. package/dist/local/SyncClient.d.ts +1 -4
  12. package/dist/local/SyncClient.js +63 -62
  13. package/dist/local/client/reactiveEngine.js +18 -0
  14. package/dist/local/sync/SyncWebSocket.js +3 -6
  15. package/dist/local/sync/deltaPipeline.d.ts +32 -1
  16. package/dist/local/sync/deltaPipeline.js +116 -8
  17. package/dist/local/sync/drainProfile.d.ts +45 -0
  18. package/dist/local/sync/drainProfile.js +55 -0
  19. package/dist/local/transactions/mutations/MutationQueue.js +11 -3
  20. package/dist/local/utils/mobxSetup.d.ts +1 -0
  21. package/dist/local/utils/mobxSetup.js +5 -0
  22. package/package.json +2 -2
  23. package/src/core.ts +3 -0
  24. package/src/local/BaseSyncedStore.ts +37 -1
  25. package/src/local/Database.ts +87 -50
  26. package/src/local/InstanceCache.ts +162 -80
  27. package/src/local/Model.ts +117 -42
  28. package/src/local/SyncClient.ts +66 -63
  29. package/src/local/client/reactiveEngine.ts +18 -0
  30. package/src/local/sync/SyncWebSocket.ts +3 -6
  31. package/src/local/sync/deltaPipeline.ts +135 -9
  32. package/src/local/sync/drainProfile.ts +93 -0
  33. package/src/local/transactions/mutations/MutationQueue.ts +14 -4
  34. package/src/local/utils/mobxSetup.ts +5 -0
@@ -11,7 +11,7 @@ import { DEFER_MODEL_OBSERVABILITY, Model } from './Model.js';
11
11
  import { ModelRegistry } from './ModelRegistry.js';
12
12
  import { globalRuntime } from './context.js';
13
13
  import { AbloValidationError } from '@abloatai/transaction/errors';
14
- import { ModelScope } from '@abloatai/transaction/types';
14
+ import { ModelScope, PropertyType } from '@abloatai/transaction/types';
15
15
  import { ViewRegistry } from './views/ViewRegistry.js';
16
16
  import { QueryView } from './views/QueryView.js';
17
17
  // Re-exported so `import { ModelScope } from './InstanceCache.js'` resolves
@@ -21,13 +21,36 @@ export { ModelScope };
21
21
  * entity resolves to a single instance.
22
22
  */
23
23
  export class InstanceCache {
24
- // Single source of truth for all models (observable for reactivity)
25
- entries = observable.map();
26
- typeIndex = observable.map();
24
+ // Single source of truth for all models (observable for reactivity).
25
+ // Shallow on purpose, and the reactivity contract depends on it: readers
26
+ // track MEMBERSHIP (keys), and every entry-level change re-sets the whole
27
+ // entry (see `updateScope`) — in-place field writes on an entry do not
28
+ // notify. The deep default additionally converted every stored ModelEntry
29
+ // into an observable object, a per-add extendObservable/defineProperty
30
+ // pass that profiled as the single largest term of create-apply on the
31
+ // wire-ingestion path, bought nothing the contract uses, and meant
32
+ // `entries.get()` returned a converted wrapper rather than the object
33
+ // the insert stored.
34
+ entries = observable.map({}, { deep: false });
35
+ typeIndex = observable.map({}, { deep: false });
27
36
  // Non-observable access time tracking — kept outside observable.map so that
28
37
  // updating timestamps in get() during React render does NOT trigger MobX
29
38
  // reactions (which would cause infinite re-render loops).
39
+ //
40
+ // Every write goes through `touchAccess`, which moves the key to the map's
41
+ // back, so iteration order IS recency order (oldest first). Eviction relies
42
+ // on that: `evictOldestBatch` takes the first eligible keys instead of
43
+ // scanning every entry.
30
44
  accessTimes = new Map();
45
+ /**
46
+ * Record an access. The delete-then-set moves an existing key to the back
47
+ * of the map's insertion order, which is what keeps `accessTimes` iterable
48
+ * oldest-first for eviction.
49
+ */
50
+ touchAccess(id, at) {
51
+ this.accessTimes.delete(id);
52
+ this.accessTimes.set(id, at);
53
+ }
31
54
  // Deduplication tracking
32
55
  recentAdditions = new Map(); // "modelType:modelId" -> timestamp
33
56
  deltaHistory = new Map();
@@ -145,7 +168,7 @@ export class InstanceCache {
145
168
  if (model) {
146
169
  entry.model = model;
147
170
  if (id)
148
- this.accessTimes.set(id, Date.now());
171
+ this.touchAccess(id, Date.now());
149
172
  return model;
150
173
  }
151
174
  }
@@ -186,11 +209,42 @@ export class InstanceCache {
186
209
  return undefined;
187
210
  }
188
211
  // Update access time in non-observable map — prevents MobX reactions during render
189
- this.accessTimes.set(id, Date.now());
212
+ this.touchAccess(id, Date.now());
190
213
  this.metrics.hits++;
191
214
  model?.ensureObservable();
192
215
  return model ?? undefined;
193
216
  }
217
+ /**
218
+ * Ingestion-side lookup: resolve a row WITHOUT crossing the consumer
219
+ * boundary. Same resolution semantics as {@link get} — weak-ref revival,
220
+ * disposed rows filtered, access recency stamped — but the model is NOT
221
+ * activated. The delta-apply loop reads every row it updates, and reading
222
+ * through `get()` made the stream itself install per-field MobX
223
+ * instrumentation on rows no consumer ever observes; that activation is
224
+ * the dominant term of apply cost (measured 12.9 vs 2.9 µs/delta in
225
+ * `applyPool.bench.test.ts`). Consumer reads must keep using `get()`,
226
+ * which is what activates a deferred model.
227
+ */
228
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters
229
+ peek(id) {
230
+ const entry = this.entries.get(id);
231
+ if (!entry)
232
+ return undefined;
233
+ let model = entry.model;
234
+ if (!model && entry.weakRef) {
235
+ const restoredModel = entry.weakRef.deref();
236
+ if (!restoredModel)
237
+ return undefined;
238
+ model = restoredModel;
239
+ runInAction(() => {
240
+ entry.model = restoredModel;
241
+ });
242
+ }
243
+ if (model?.disposed)
244
+ return undefined;
245
+ this.touchAccess(id, Date.now());
246
+ return model ?? undefined;
247
+ }
194
248
  /**
195
249
  * Look a row up **within one model**.
196
250
  *
@@ -243,7 +297,7 @@ export class InstanceCache {
243
297
  runInAction(() => {
244
298
  this.entries.set(id, { ...existingEntry, scope });
245
299
  });
246
- this.accessTimes.set(id, Date.now());
300
+ this.touchAccess(id, Date.now());
247
301
  }
248
302
  this.metrics.duplicatesSkipped++;
249
303
  return;
@@ -295,7 +349,7 @@ export class InstanceCache {
295
349
  if (this.config.useWeakRefs && this.isLargeModel(model)) {
296
350
  entry.weakRef = new WeakRef(model);
297
351
  }
298
- this.accessTimes.set(id, Date.now());
352
+ this.touchAccess(id, Date.now());
299
353
  runInAction(() => {
300
354
  this.entries.set(id, entry);
301
355
  this.addToTypeIndex(id, model.getModelName());
@@ -324,7 +378,7 @@ export class InstanceCache {
324
378
  runInAction(() => {
325
379
  this.entries.set(id, { ...existingEntry, scope });
326
380
  });
327
- this.accessTimes.set(id, Date.now());
381
+ this.touchAccess(id, Date.now());
328
382
  }
329
383
  this.notifySubscribers(existingModel);
330
384
  // Notify views of the update
@@ -366,7 +420,7 @@ export class InstanceCache {
366
420
  if (existingEntry?.model && !existingEntry.model.disposed) {
367
421
  if (existingEntry.scope !== scope) {
368
422
  this.entries.set(id, { ...existingEntry, scope });
369
- this.accessTimes.set(id, now);
423
+ this.touchAccess(id, now);
370
424
  }
371
425
  this.metrics.duplicatesSkipped++;
372
426
  continue;
@@ -375,7 +429,7 @@ export class InstanceCache {
375
429
  model,
376
430
  scope,
377
431
  };
378
- this.accessTimes.set(id, now);
432
+ this.touchAccess(id, now);
379
433
  if (this.config.useWeakRefs && this.isLargeModel(model)) {
380
434
  entry.weakRef = new WeakRef(model);
381
435
  }
@@ -417,7 +471,7 @@ export class InstanceCache {
417
471
  }
418
472
  if (existingEntry.scope !== scope) {
419
473
  this.entries.set(id, { ...existingEntry, scope });
420
- this.accessTimes.set(id, Date.now());
474
+ this.touchAccess(id, Date.now());
421
475
  }
422
476
  this.notifySubscribers(existingEntry.model);
423
477
  // Notify views of the update
@@ -433,7 +487,7 @@ export class InstanceCache {
433
487
  this.evictOldest();
434
488
  }
435
489
  const entry = { model, scope };
436
- this.accessTimes.set(id, Date.now());
490
+ this.touchAccess(id, Date.now());
437
491
  if (this.config.useWeakRefs && this.isLargeModel(model)) {
438
492
  entry.weakRef = new WeakRef(model);
439
493
  }
@@ -574,6 +628,11 @@ export class InstanceCache {
574
628
  continue;
575
629
  const model = this.resolveModel(entry, id);
576
630
  if (model && !model.disposed) {
631
+ // Bulk reads are a consumer boundary: rows land cold from the wire
632
+ // (delta ingestion applies with `peek` and never activates), so
633
+ // every read that hands models out must activate them, exactly as
634
+ // `get()` does. Idempotent — an active row pays one boolean check.
635
+ model.ensureObservable();
577
636
  result.push(model);
578
637
  }
579
638
  }
@@ -599,6 +658,8 @@ export class InstanceCache {
599
658
  continue;
600
659
  const model = this.resolveModel(entry, id);
601
660
  if (model && !model.disposed) {
661
+ // Consumer boundary — activate, as in getByType.
662
+ model.ensureObservable();
602
663
  result.push(model);
603
664
  }
604
665
  }
@@ -632,7 +693,7 @@ export class InstanceCache {
632
693
  runInAction(() => {
633
694
  this.entries.set(id, { ...entry, scope });
634
695
  });
635
- this.accessTimes.set(id, Date.now());
696
+ this.touchAccess(id, Date.now());
636
697
  }
637
698
  }
638
699
  /**
@@ -784,7 +845,7 @@ export class InstanceCache {
784
845
  // Restore access times: clear then re-add preserved
785
846
  this.accessTimes.clear();
786
847
  for (const [id, time] of preservedAccessTimes) {
787
- this.accessTimes.set(id, time);
848
+ this.touchAccess(id, time);
788
849
  }
789
850
  // No cache to invalidate — typeIndex + entries are directly observable
790
851
  }
@@ -800,7 +861,7 @@ export class InstanceCache {
800
861
  if (!entry) {
801
862
  return false;
802
863
  }
803
- this.accessTimes.set(id, Date.now());
864
+ this.touchAccess(id, Date.now());
804
865
  return true;
805
866
  }
806
867
  getAllIds() {
@@ -884,7 +945,7 @@ export class InstanceCache {
884
945
  typeof model.hasObservedCollections === 'function' &&
885
946
  model.hasObservedCollections()) {
886
947
  // Model has active React observers - refresh access time and skip GC
887
- this.accessTimes.set(id, now);
948
+ this.touchAccess(id, now);
888
949
  skippedObserved++;
889
950
  continue;
890
951
  }
@@ -949,61 +1010,36 @@ export class InstanceCache {
949
1010
  this.evictOldestBatch(1);
950
1011
  }
951
1012
  /**
952
- * Free capacity for a whole incoming frame in one scan.
1013
+ * Free capacity for a whole incoming frame by taking the first eligible
1014
+ * keys of the recency-ordered `accessTimes` map.
953
1015
  *
954
- * Calling evictOldest once per model made a full 10k-entry cache scan
955
- * `incomingCount` times. A 400-row delta frame therefore performed four
956
- * million entry visits before constructing models. Keep only the `count`
957
- * oldest candidates in a bounded max-heap: a full sort paid
958
- * O(cache log cache) for every sustained publication frame even though it
959
- * consumed only the first few hundred entries. The heap preserves the same
960
- * LRU/observed-model contract at O(cache log count) time and O(count) space.
1016
+ * Every access moves its key to the back of that map (`touchAccess`), so
1017
+ * iterating from the front visits entries oldest-first the eviction
1018
+ * order at O(count) for a sustained publication frame. The previous
1019
+ * shape kept a bounded max-heap but still walked EVERY cache entry per
1020
+ * incoming frame, which at a 10k cap and ~1.3k-delta frames made the scan
1021
+ * itself a first-order term of the observer's apply cost.
1022
+ *
1023
+ * The observed-model contract is unchanged: a model React is observing is
1024
+ * skipped and stays in place, so it is reconsidered (and skipped again)
1025
+ * on later evictions until it is no longer observed.
961
1026
  */
962
1027
  evictOldestBatch(count) {
963
1028
  if (count <= 0)
964
1029
  return;
965
1030
  runInAction(() => {
966
- const oldest = [];
967
- const candidateAt = (index) => {
968
- const candidate = oldest[index];
969
- if (!candidate)
970
- throw new Error(`Missing eviction candidate at index ${index}`);
971
- return candidate;
972
- };
973
- const swap = (left, right) => {
974
- const leftValue = candidateAt(left);
975
- const rightValue = candidateAt(right);
976
- oldest[left] = rightValue;
977
- oldest[right] = leftValue;
978
- };
979
- const siftUp = (start) => {
980
- let index = start;
981
- while (index > 0) {
982
- const parent = Math.floor((index - 1) / 2);
983
- if (candidateAt(parent).accessedAt >= candidateAt(index).accessedAt)
984
- break;
985
- swap(parent, index);
986
- index = parent;
987
- }
988
- };
989
- const siftDown = () => {
990
- let index = 0;
991
- for (;;) {
992
- const left = index * 2 + 1;
993
- if (left >= oldest.length)
994
- return;
995
- const right = left + 1;
996
- const larger = right < oldest.length &&
997
- candidateAt(right).accessedAt > candidateAt(left).accessedAt
998
- ? right
999
- : left;
1000
- if (candidateAt(index).accessedAt >= candidateAt(larger).accessedAt)
1001
- return;
1002
- swap(index, larger);
1003
- index = larger;
1031
+ const toEvict = [];
1032
+ const staleAccessKeys = [];
1033
+ for (const id of this.accessTimes.keys()) {
1034
+ if (toEvict.length >= count)
1035
+ break;
1036
+ const entry = this.entries.get(id);
1037
+ if (!entry) {
1038
+ // remove()/clear() delete from both maps, so a stale key means a
1039
+ // divergence clean it up rather than let it linger.
1040
+ staleAccessKeys.push(id);
1041
+ continue;
1004
1042
  }
1005
- };
1006
- for (const [id, entry] of this.entries) {
1007
1043
  // Skip models that are being observed by React - they must stay alive
1008
1044
  const model = entry.model ?? entry.weakRef?.deref();
1009
1045
  if (model &&
@@ -1011,33 +1047,67 @@ export class InstanceCache {
1011
1047
  model.hasObservedCollections()) {
1012
1048
  continue;
1013
1049
  }
1014
- const candidate = {
1015
- id,
1016
- accessedAt: this.accessTimes.get(id) ?? 0,
1017
- };
1018
- if (oldest.length < count) {
1019
- oldest.push(candidate);
1020
- siftUp(oldest.length - 1);
1021
- }
1022
- else if (candidate.accessedAt < candidateAt(0).accessedAt) {
1023
- oldest[0] = candidate;
1024
- siftDown();
1050
+ toEvict.push(id);
1051
+ }
1052
+ for (const id of staleAccessKeys)
1053
+ this.accessTimes.delete(id);
1054
+ // Safety net for an entry that never received an access stamp: it is
1055
+ // invisible to the recency map, so fall back to the entry scan the old
1056
+ // implementation always paid. Every add path stamps `accessTimes`, so
1057
+ // this loop finds nothing and costs nothing in the normal case (it only
1058
+ // runs at all when the recency map came up short).
1059
+ if (toEvict.length < count) {
1060
+ for (const [id, entry] of this.entries) {
1061
+ if (toEvict.length >= count)
1062
+ break;
1063
+ if (this.accessTimes.has(id))
1064
+ continue;
1065
+ const model = entry.model ?? entry.weakRef?.deref();
1066
+ if (model &&
1067
+ typeof model.hasObservedCollections === 'function' &&
1068
+ model.hasObservedCollections()) {
1069
+ continue;
1070
+ }
1071
+ toEvict.push(id);
1025
1072
  }
1026
1073
  }
1027
- for (const candidate of oldest) {
1028
- this.remove(candidate.id);
1074
+ for (const id of toEvict) {
1075
+ this.remove(id);
1029
1076
  this.metrics.evictions++;
1030
1077
  }
1031
1078
  });
1032
1079
  }
1080
+ /**
1081
+ * Data-field keys to probe per model, resolved once per model name. Only
1082
+ * stored-value fields can carry bulk; walking the whole instance also
1083
+ * visited the base class's bookkeeping (`_mobxProperties`,
1084
+ * `modifiedProperties`, `validationRules`, …) and JSON-stringified each of
1085
+ * those empty containers on EVERY add — a first-order term of wire-ingest
1086
+ * create apply. Reference/computed keys are deliberately excluded: reading
1087
+ * them would execute their getters.
1088
+ */
1089
+ sizeProbeKeys = new Map();
1033
1090
  isLargeModel(model) {
1034
1091
  try {
1035
- // Most synchronized rows contain only short scalars. Serializing the
1036
- // entire model merely to discover that fact doubled wire-ingest object
1037
- // walking. Accumulate cheap scalar sizes and serialize only nested
1038
- // payloads that can plausibly cross the WeakRef threshold.
1092
+ const modelName = model.getModelName();
1093
+ let keys = this.sizeProbeKeys.get(modelName);
1094
+ if (!keys) {
1095
+ const props = this.registry.getProperties(modelName);
1096
+ keys = [...props.entries()]
1097
+ .filter(([, meta]) => meta.type === PropertyType.property || meta.type === PropertyType.ephemeralProperty)
1098
+ .map(([key]) => key);
1099
+ this.sizeProbeKeys.set(modelName, keys);
1100
+ }
1101
+ // A model with no registered stored fields (e.g. a custom entity) keeps
1102
+ // the whole-instance walk, since its payload lives outside the schema.
1103
+ const values = keys.length > 0
1104
+ ? keys.map((key) => Reflect.get(model, key))
1105
+ : Object.values(model);
1106
+ // Most synchronized rows contain only short scalars. Accumulate cheap
1107
+ // scalar sizes and serialize only nested payloads that can plausibly
1108
+ // cross the WeakRef threshold.
1039
1109
  let size = 0;
1040
- for (const value of Object.values(model)) {
1110
+ for (const value of values) {
1041
1111
  if (typeof value === 'string') {
1042
1112
  size += value.length;
1043
1113
  }
@@ -1134,6 +1204,9 @@ export class InstanceCache {
1134
1204
  }
1135
1205
  const model = this.resolveModel(entry, id);
1136
1206
  if (model && !model.disposed) {
1207
+ // Consumer boundary — activate, as in getByType. `hasMany` reads
1208
+ // (`section.blocks`) observe fields off these rows directly.
1209
+ model.ensureObservable();
1137
1210
  result.push(model);
1138
1211
  }
1139
1212
  else if (model?.disposed) {
@@ -81,6 +81,16 @@ export declare abstract class Model {
81
81
  clientId: string;
82
82
  /** MobX observable properties storage */
83
83
  _mobxProperties: ModelData;
84
+ /**
85
+ * True while {@link updateFromData} assigns inbound wire data. The
86
+ * `observe()` bridge (`mobxSetup.M1`) checks it and skips the forward to
87
+ * {@link propertyChanged} entirely: hydration writes are not user edits, and
88
+ * the bridge's work per changed field — a nested action, map bookkeeping,
89
+ * and a fabricated `updatedAt` stamp — was already being discarded by the
90
+ * `modifiedProperties` swap in `updateFromData`. The swap stays as the
91
+ * correctness backstop; this flag removes the cost.
92
+ */
93
+ _isHydrating: boolean;
84
94
  /** Referenced models cache */
85
95
  _referencedModels: Record<string, Model | null>;
86
96
  /** Track property changes */
@@ -89,6 +99,17 @@ export declare abstract class Model {
89
99
  private _isNew;
90
100
  /** Original data snapshot */
91
101
  private _originalData?;
102
+ /**
103
+ * Whether the persisted baseline needs recapturing. Hydration and ack land
104
+ * far more often than anything reads the baseline — an observer-only client
105
+ * reads it never — so each of those sites marks the snapshot stale and
106
+ * {@link getOriginalSnapshot} materializes it on first read. Correctness
107
+ * rests on the tracking invariant: every non-hydration write is recorded in
108
+ * `modifiedProperties` (first-old-wins), so between the stale mark and the
109
+ * read, untracked fields still hold exactly the values an eager capture
110
+ * would have recorded.
111
+ */
112
+ private _originalDataStale;
92
113
  /** Sync status */
93
114
  syncStatus: 'pending' | 'syncing' | 'synced';
94
115
  /** Timestamps */
@@ -279,6 +300,13 @@ export declare abstract class Model {
279
300
  * leaves that baseline intact.
280
301
  */
281
302
  private assignFieldsFromData;
303
+ /**
304
+ * The uncached decision for one key: existence on instance or prototype,
305
+ * never a MobX computed (its setter may throw), and writability resolved
306
+ * through the descriptor chain — a data descriptor's `writable` or an
307
+ * accessor's setter.
308
+ */
309
+ private resolveFieldDisposition;
282
310
  /**
283
311
  * Update from raw data (hydration)
284
312
  *
@@ -31,6 +31,7 @@ export class ValidationError extends Error {
31
31
  }
32
32
  /** Shared frozen default for {@link Model.getDerivedGetterNames}. */
33
33
  const EMPTY_DERIVED_GETTERS = Object.freeze([]);
34
+ const fieldWritePlans = new WeakMap();
34
35
  /**
35
36
  * The abstract base class every domain model extends. It holds the model's id
36
37
  * and timestamps, tracks in-place property changes for change detection and
@@ -46,6 +47,16 @@ export class Model {
46
47
  clientId;
47
48
  /** MobX observable properties storage */
48
49
  _mobxProperties = {};
50
+ /**
51
+ * True while {@link updateFromData} assigns inbound wire data. The
52
+ * `observe()` bridge (`mobxSetup.M1`) checks it and skips the forward to
53
+ * {@link propertyChanged} entirely: hydration writes are not user edits, and
54
+ * the bridge's work per changed field — a nested action, map bookkeeping,
55
+ * and a fabricated `updatedAt` stamp — was already being discarded by the
56
+ * `modifiedProperties` swap in `updateFromData`. The swap stays as the
57
+ * correctness backstop; this flag removes the cost.
58
+ */
59
+ _isHydrating = false;
49
60
  /** Referenced models cache */
50
61
  _referencedModels = {};
51
62
  /** Track property changes */
@@ -54,6 +65,17 @@ export class Model {
54
65
  _isNew = true;
55
66
  /** Original data snapshot */
56
67
  _originalData;
68
+ /**
69
+ * Whether the persisted baseline needs recapturing. Hydration and ack land
70
+ * far more often than anything reads the baseline — an observer-only client
71
+ * reads it never — so each of those sites marks the snapshot stale and
72
+ * {@link getOriginalSnapshot} materializes it on first read. Correctness
73
+ * rests on the tracking invariant: every non-hydration write is recorded in
74
+ * `modifiedProperties` (first-old-wins), so between the stale mark and the
75
+ * read, untracked fields still hold exactly the values an eager capture
76
+ * would have recorded.
77
+ */
78
+ _originalDataStale = false;
57
79
  /** Sync status */
58
80
  syncStatus = 'pending';
59
81
  /** Timestamps */
@@ -208,7 +230,8 @@ export class Model {
208
230
  */
209
231
  markAsPersisted() {
210
232
  this._isNew = false;
211
- this._originalData = this.captureSnapshot();
233
+ this._originalData = undefined;
234
+ this._originalDataStale = true;
212
235
  }
213
236
  /**
214
237
  * Check if this is a new model
@@ -230,6 +253,10 @@ export class Model {
230
253
  * last acknowledged state would already be the authoritative baseline.
231
254
  */
232
255
  getOriginalSnapshot() {
256
+ if (this._originalDataStale) {
257
+ this._originalData = this.captureSnapshot();
258
+ this._originalDataStale = false;
259
+ }
233
260
  return this._originalData;
234
261
  }
235
262
  /**
@@ -238,7 +265,8 @@ export class Model {
238
265
  clearChanges() {
239
266
  runInAction(() => {
240
267
  this.modifiedProperties.clear();
241
- this._originalData = this.captureSnapshot();
268
+ this._originalData = undefined;
269
+ this._originalDataStale = true;
242
270
  });
243
271
  }
244
272
  /**
@@ -471,43 +499,39 @@ export class Model {
471
499
  * leaves that baseline intact.
472
500
  */
473
501
  assignFieldsFromData(data, onWrite) {
474
- // Update properties with safety checks for read-only/computed accessors
475
- for (const [key, raw] of Object.entries(data)) {
502
+ let plan = fieldWritePlans.get(this.constructor);
503
+ if (!plan) {
504
+ plan = new Map();
505
+ fieldWritePlans.set(this.constructor, plan);
506
+ }
507
+ for (const key in data) {
476
508
  if (key === 'id')
477
509
  continue;
478
- // Only attempt to set if the property exists on instance or prototype
479
- if (!(this.hasOwnProperty(key) || key in this))
510
+ if (!Object.prototype.hasOwnProperty.call(data, key))
480
511
  continue;
481
- // Never assign to MobX computed properties (they may expose a setter that throws)
482
- try {
483
- if (isComputedProp(this, key)) {
484
- continue;
485
- }
512
+ const raw = data[key];
513
+ let disposition = plan.get(key);
514
+ if (disposition === undefined) {
515
+ disposition = this.resolveFieldDisposition(key);
516
+ plan.set(key, disposition);
486
517
  }
487
- catch {
488
- // If MobX internals are unavailable for some reason, fall back to descriptor checks below
489
- }
490
- // Resolve property descriptor from own or prototype chain
491
- const ownDesc = Object.getOwnPropertyDescriptor(this, key);
492
- let desc = ownDesc;
493
- if (!desc) {
494
- let proto = Object.getPrototypeOf(this);
495
- while (proto && proto !== Object.prototype && !desc) {
496
- desc = Object.getOwnPropertyDescriptor(proto, key);
497
- proto = Object.getPrototypeOf(proto);
498
- }
518
+ if (disposition === 'skip-readonly')
519
+ continue;
520
+ if (disposition === 'skip-absent') {
521
+ // The plan was built from an instance that lacked this key. Re-resolve
522
+ // for this instance without touching the cache, so heterogeneous
523
+ // shapes stay correct at the cost of the slow path.
524
+ if (!(key in this))
525
+ continue;
526
+ disposition = this.resolveFieldDisposition(key);
527
+ if (disposition === 'skip-readonly' || disposition === 'skip-absent')
528
+ continue;
499
529
  }
500
- // Determine writability: allow if data descriptor writable, or accessor with setter
501
- const writable = desc
502
- ? ('writable' in desc && !!desc.writable) ||
503
- ('set' in desc && typeof desc.set === 'function')
504
- : true;
505
- if (!writable) {
506
- // Skip read-only accessor properties (getter-only)
530
+ else if (!(key in this)) {
507
531
  continue;
508
532
  }
509
533
  // Handle date conversions
510
- const value = (key === 'createdAt' || key === 'updatedAt' || key === 'archivedAt') && raw
534
+ const value = disposition === 'write-date' && raw
511
535
  ? new Date(raw)
512
536
  : raw;
513
537
  // Capture the pre-write value BEFORE assignment so trackers
@@ -518,6 +542,42 @@ export class Model {
518
542
  onWrite?.(key, oldValue, value);
519
543
  }
520
544
  }
545
+ /**
546
+ * The uncached decision for one key: existence on instance or prototype,
547
+ * never a MobX computed (its setter may throw), and writability resolved
548
+ * through the descriptor chain — a data descriptor's `writable` or an
549
+ * accessor's setter.
550
+ */
551
+ resolveFieldDisposition(key) {
552
+ if (!(key in this))
553
+ return 'skip-absent';
554
+ try {
555
+ if (isComputedProp(this, key)) {
556
+ return 'skip-readonly';
557
+ }
558
+ }
559
+ catch {
560
+ // If MobX internals are unavailable for some reason, fall back to descriptor checks below
561
+ }
562
+ const ownDesc = Object.getOwnPropertyDescriptor(this, key);
563
+ let desc = ownDesc;
564
+ if (!desc) {
565
+ let proto = Object.getPrototypeOf(this);
566
+ while (proto && proto !== Object.prototype && !desc) {
567
+ desc = Object.getOwnPropertyDescriptor(proto, key);
568
+ proto = Object.getPrototypeOf(proto);
569
+ }
570
+ }
571
+ const writable = desc
572
+ ? ('writable' in desc && !!desc.writable) ||
573
+ ('set' in desc && typeof desc.set === 'function')
574
+ : true;
575
+ if (!writable)
576
+ return 'skip-readonly';
577
+ return key === 'createdAt' || key === 'updatedAt' || key === 'archivedAt'
578
+ ? 'write-date'
579
+ : 'write';
580
+ }
521
581
  /**
522
582
  * Update from raw data (hydration)
523
583
  *
@@ -544,13 +604,20 @@ export class Model {
544
604
  runInAction(() => {
545
605
  const originalTracking = this.modifiedProperties;
546
606
  this.modifiedProperties = new Map();
547
- // No `onWrite` → this call records nothing itself.
548
- this.assignFieldsFromData(data);
549
- this.modifiedProperties = originalTracking;
607
+ this._isHydrating = true;
608
+ try {
609
+ // No `onWrite` → this call records nothing itself.
610
+ this.assignFieldsFromData(data);
611
+ }
612
+ finally {
613
+ this._isHydrating = false;
614
+ this.modifiedProperties = originalTracking;
615
+ }
550
616
  });
551
617
  // Mark as persisted if updating existing model
552
618
  if (!this._isNew) {
553
- this._originalData = this.captureSnapshot();
619
+ this._originalData = undefined;
620
+ this._originalDataStale = true;
554
621
  }
555
622
  this.didUpdate();
556
623
  }