@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
@@ -13,7 +13,7 @@ import { ModelRegistry } from './ModelRegistry.js';
13
13
  import { globalRuntime } from './context.js';
14
14
  import type { RuntimeContext } from './RuntimeContext.js';
15
15
  import { AbloValidationError } from '@abloatai/transaction/errors';
16
- import { ModelScope } from '@abloatai/transaction/types';
16
+ import { ModelScope, PropertyType } from '@abloatai/transaction/types';
17
17
  import { ViewRegistry } from './views/ViewRegistry.js';
18
18
  import { QueryView, type QueryViewOptions } from './views/QueryView.js';
19
19
 
@@ -48,15 +48,39 @@ interface DeltaInfo {
48
48
  * entity resolves to a single instance.
49
49
  */
50
50
  export class InstanceCache {
51
- // Single source of truth for all models (observable for reactivity)
52
- private entries = observable.map<string, ModelEntry>();
53
- private typeIndex = observable.map<string, Set<string>>();
51
+ // Single source of truth for all models (observable for reactivity).
52
+ // Shallow on purpose, and the reactivity contract depends on it: readers
53
+ // track MEMBERSHIP (keys), and every entry-level change re-sets the whole
54
+ // entry (see `updateScope`) — in-place field writes on an entry do not
55
+ // notify. The deep default additionally converted every stored ModelEntry
56
+ // into an observable object, a per-add extendObservable/defineProperty
57
+ // pass that profiled as the single largest term of create-apply on the
58
+ // wire-ingestion path, bought nothing the contract uses, and meant
59
+ // `entries.get()` returned a converted wrapper rather than the object
60
+ // the insert stored.
61
+ private entries = observable.map<string, ModelEntry>({}, { deep: false });
62
+ private typeIndex = observable.map<string, Set<string>>({}, { deep: false });
54
63
 
55
64
  // Non-observable access time tracking — kept outside observable.map so that
56
65
  // updating timestamps in get() during React render does NOT trigger MobX
57
66
  // reactions (which would cause infinite re-render loops).
67
+ //
68
+ // Every write goes through `touchAccess`, which moves the key to the map's
69
+ // back, so iteration order IS recency order (oldest first). Eviction relies
70
+ // on that: `evictOldestBatch` takes the first eligible keys instead of
71
+ // scanning every entry.
58
72
  private accessTimes = new Map<string, number>();
59
73
 
74
+ /**
75
+ * Record an access. The delete-then-set moves an existing key to the back
76
+ * of the map's insertion order, which is what keeps `accessTimes` iterable
77
+ * oldest-first for eviction.
78
+ */
79
+ private touchAccess(id: string, at: number): void {
80
+ this.accessTimes.delete(id);
81
+ this.accessTimes.set(id, at);
82
+ }
83
+
60
84
  // Deduplication tracking
61
85
  private recentAdditions = new Map<string, number>(); // "modelType:modelId" -> timestamp
62
86
  private deltaHistory = new Map<
@@ -203,7 +227,7 @@ export class InstanceCache {
203
227
  const model = entry.weakRef.deref();
204
228
  if (model) {
205
229
  entry.model = model;
206
- if (id) this.accessTimes.set(id, Date.now());
230
+ if (id) this.touchAccess(id, Date.now());
207
231
  return model;
208
232
  }
209
233
  }
@@ -250,13 +274,45 @@ export class InstanceCache {
250
274
  }
251
275
 
252
276
  // Update access time in non-observable map — prevents MobX reactions during render
253
- this.accessTimes.set(id, Date.now());
277
+ this.touchAccess(id, Date.now());
254
278
  this.metrics.hits++;
255
279
 
256
280
  model?.ensureObservable();
257
281
  return model ?? undefined;
258
282
  }
259
283
 
284
+ /**
285
+ * Ingestion-side lookup: resolve a row WITHOUT crossing the consumer
286
+ * boundary. Same resolution semantics as {@link get} — weak-ref revival,
287
+ * disposed rows filtered, access recency stamped — but the model is NOT
288
+ * activated. The delta-apply loop reads every row it updates, and reading
289
+ * through `get()` made the stream itself install per-field MobX
290
+ * instrumentation on rows no consumer ever observes; that activation is
291
+ * the dominant term of apply cost (measured 12.9 vs 2.9 µs/delta in
292
+ * `applyPool.bench.test.ts`). Consumer reads must keep using `get()`,
293
+ * which is what activates a deferred model.
294
+ */
295
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters
296
+ peek<T extends Model = Model>(id: string): T | undefined {
297
+ const entry = this.entries.get(id);
298
+ if (!entry) return undefined;
299
+
300
+ let model = entry.model as T | undefined;
301
+ if (!model && entry.weakRef) {
302
+ const restoredModel = entry.weakRef.deref();
303
+ if (!restoredModel) return undefined;
304
+ model = restoredModel as T;
305
+ runInAction(() => {
306
+ entry.model = restoredModel;
307
+ });
308
+ }
309
+
310
+ if (model?.disposed) return undefined;
311
+
312
+ this.touchAccess(id, Date.now());
313
+ return model ?? undefined;
314
+ }
315
+
260
316
  /**
261
317
  * Look a row up **within one model**.
262
318
  *
@@ -312,7 +368,7 @@ export class InstanceCache {
312
368
  runInAction(() => {
313
369
  this.entries.set(id, { ...existingEntry, scope });
314
370
  });
315
- this.accessTimes.set(id, Date.now());
371
+ this.touchAccess(id, Date.now());
316
372
  }
317
373
  this.metrics.duplicatesSkipped++;
318
374
  return;
@@ -381,7 +437,7 @@ export class InstanceCache {
381
437
  entry.weakRef = new WeakRef(model);
382
438
  }
383
439
 
384
- this.accessTimes.set(id, Date.now());
440
+ this.touchAccess(id, Date.now());
385
441
  runInAction(() => {
386
442
  this.entries.set(id, entry);
387
443
  this.addToTypeIndex(id, model.getModelName());
@@ -415,7 +471,7 @@ export class InstanceCache {
415
471
  runInAction(() => {
416
472
  this.entries.set(id, { ...existingEntry, scope });
417
473
  });
418
- this.accessTimes.set(id, Date.now());
474
+ this.touchAccess(id, Date.now());
419
475
  }
420
476
 
421
477
  this.notifySubscribers(existingModel);
@@ -462,7 +518,7 @@ export class InstanceCache {
462
518
  if (existingEntry?.model && !existingEntry.model.disposed) {
463
519
  if (existingEntry.scope !== scope) {
464
520
  this.entries.set(id, { ...existingEntry, scope });
465
- this.accessTimes.set(id, now);
521
+ this.touchAccess(id, now);
466
522
  }
467
523
  this.metrics.duplicatesSkipped++;
468
524
  continue;
@@ -472,7 +528,7 @@ export class InstanceCache {
472
528
  model,
473
529
  scope,
474
530
  };
475
- this.accessTimes.set(id, now);
531
+ this.touchAccess(id, now);
476
532
 
477
533
  if (this.config.useWeakRefs && this.isLargeModel(model)) {
478
534
  entry.weakRef = new WeakRef(model);
@@ -521,7 +577,7 @@ export class InstanceCache {
521
577
  }
522
578
  if (existingEntry.scope !== scope) {
523
579
  this.entries.set(id, { ...existingEntry, scope });
524
- this.accessTimes.set(id, Date.now());
580
+ this.touchAccess(id, Date.now());
525
581
  }
526
582
  this.notifySubscribers(existingEntry.model);
527
583
  // Notify views of the update
@@ -536,7 +592,7 @@ export class InstanceCache {
536
592
  this.evictOldest();
537
593
  }
538
594
  const entry: ModelEntry = { model, scope };
539
- this.accessTimes.set(id, Date.now());
595
+ this.touchAccess(id, Date.now());
540
596
  if (this.config.useWeakRefs && this.isLargeModel(model)) {
541
597
  entry.weakRef = new WeakRef(model);
542
598
  }
@@ -705,6 +761,11 @@ export class InstanceCache {
705
761
 
706
762
  const model = this.resolveModel(entry, id);
707
763
  if (model && !model.disposed) {
764
+ // Bulk reads are a consumer boundary: rows land cold from the wire
765
+ // (delta ingestion applies with `peek` and never activates), so
766
+ // every read that hands models out must activate them, exactly as
767
+ // `get()` does. Idempotent — an active row pays one boolean check.
768
+ model.ensureObservable();
708
769
  result.push(model);
709
770
  }
710
771
  }
@@ -732,6 +793,8 @@ export class InstanceCache {
732
793
 
733
794
  const model = this.resolveModel(entry, id);
734
795
  if (model && !model.disposed) {
796
+ // Consumer boundary — activate, as in getByType.
797
+ model.ensureObservable();
735
798
  result.push(model);
736
799
  }
737
800
  }
@@ -773,7 +836,7 @@ export class InstanceCache {
773
836
  runInAction(() => {
774
837
  this.entries.set(id, { ...entry, scope });
775
838
  });
776
- this.accessTimes.set(id, Date.now());
839
+ this.touchAccess(id, Date.now());
777
840
  }
778
841
  }
779
842
 
@@ -989,7 +1052,7 @@ export class InstanceCache {
989
1052
  // Restore access times: clear then re-add preserved
990
1053
  this.accessTimes.clear();
991
1054
  for (const [id, time] of preservedAccessTimes) {
992
- this.accessTimes.set(id, time);
1055
+ this.touchAccess(id, time);
993
1056
  }
994
1057
  // No cache to invalidate — typeIndex + entries are directly observable
995
1058
  }
@@ -1008,7 +1071,7 @@ export class InstanceCache {
1008
1071
  return false;
1009
1072
  }
1010
1073
 
1011
- this.accessTimes.set(id, Date.now());
1074
+ this.touchAccess(id, Date.now());
1012
1075
  return true;
1013
1076
  }
1014
1077
 
@@ -1107,7 +1170,7 @@ export class InstanceCache {
1107
1170
  model.hasObservedCollections()
1108
1171
  ) {
1109
1172
  // Model has active React observers - refresh access time and skip GC
1110
- this.accessTimes.set(id, now);
1173
+ this.touchAccess(id, now);
1111
1174
  skippedObserved++;
1112
1175
  continue;
1113
1176
  }
@@ -1183,59 +1246,37 @@ export class InstanceCache {
1183
1246
  }
1184
1247
 
1185
1248
  /**
1186
- * Free capacity for a whole incoming frame in one scan.
1249
+ * Free capacity for a whole incoming frame by taking the first eligible
1250
+ * keys of the recency-ordered `accessTimes` map.
1251
+ *
1252
+ * Every access moves its key to the back of that map (`touchAccess`), so
1253
+ * iterating from the front visits entries oldest-first — the eviction
1254
+ * order — at O(count) for a sustained publication frame. The previous
1255
+ * shape kept a bounded max-heap but still walked EVERY cache entry per
1256
+ * incoming frame, which at a 10k cap and ~1.3k-delta frames made the scan
1257
+ * itself a first-order term of the observer's apply cost.
1187
1258
  *
1188
- * Calling evictOldest once per model made a full 10k-entry cache scan
1189
- * `incomingCount` times. A 400-row delta frame therefore performed four
1190
- * million entry visits before constructing models. Keep only the `count`
1191
- * oldest candidates in a bounded max-heap: a full sort paid
1192
- * O(cache log cache) for every sustained publication frame even though it
1193
- * consumed only the first few hundred entries. The heap preserves the same
1194
- * LRU/observed-model contract at O(cache log count) time and O(count) space.
1259
+ * The observed-model contract is unchanged: a model React is observing is
1260
+ * skipped and stays in place, so it is reconsidered (and skipped again)
1261
+ * on later evictions until it is no longer observed.
1195
1262
  */
1196
1263
  private evictOldestBatch(count: number): void {
1197
1264
  if (count <= 0) return;
1198
1265
  runInAction(() => {
1199
- interface Candidate { id: string; accessedAt: number }
1200
- const oldest: Candidate[] = [];
1201
- const candidateAt = (index: number): Candidate => {
1202
- const candidate = oldest[index];
1203
- if (!candidate) throw new Error(`Missing eviction candidate at index ${index}`);
1204
- return candidate;
1205
- };
1206
- const swap = (left: number, right: number): void => {
1207
- const leftValue = candidateAt(left);
1208
- const rightValue = candidateAt(right);
1209
- oldest[left] = rightValue;
1210
- oldest[right] = leftValue;
1211
- };
1212
- const siftUp = (start: number): void => {
1213
- let index = start;
1214
- while (index > 0) {
1215
- const parent = Math.floor((index - 1) / 2);
1216
- if (candidateAt(parent).accessedAt >= candidateAt(index).accessedAt) break;
1217
- swap(parent, index);
1218
- index = parent;
1219
- }
1220
- };
1221
- const siftDown = (): void => {
1222
- let index = 0;
1223
- for (;;) {
1224
- const left = index * 2 + 1;
1225
- if (left >= oldest.length) return;
1226
- const right = left + 1;
1227
- const larger =
1228
- right < oldest.length &&
1229
- candidateAt(right).accessedAt > candidateAt(left).accessedAt
1230
- ? right
1231
- : left;
1232
- if (candidateAt(index).accessedAt >= candidateAt(larger).accessedAt) return;
1233
- swap(index, larger);
1234
- index = larger;
1266
+ const toEvict: string[] = [];
1267
+ const staleAccessKeys: string[] = [];
1268
+
1269
+ for (const id of this.accessTimes.keys()) {
1270
+ if (toEvict.length >= count) break;
1271
+
1272
+ const entry = this.entries.get(id);
1273
+ if (!entry) {
1274
+ // remove()/clear() delete from both maps, so a stale key means a
1275
+ // divergence clean it up rather than let it linger.
1276
+ staleAccessKeys.push(id);
1277
+ continue;
1235
1278
  }
1236
- };
1237
1279
 
1238
- for (const [id, entry] of this.entries) {
1239
1280
  // Skip models that are being observed by React - they must stay alive
1240
1281
  const model = entry.model ?? entry.weakRef?.deref();
1241
1282
  if (
@@ -1246,34 +1287,72 @@ export class InstanceCache {
1246
1287
  continue;
1247
1288
  }
1248
1289
 
1249
- const candidate = {
1250
- id,
1251
- accessedAt: this.accessTimes.get(id) ?? 0,
1252
- };
1253
- if (oldest.length < count) {
1254
- oldest.push(candidate);
1255
- siftUp(oldest.length - 1);
1256
- } else if (candidate.accessedAt < candidateAt(0).accessedAt) {
1257
- oldest[0] = candidate;
1258
- siftDown();
1290
+ toEvict.push(id);
1291
+ }
1292
+
1293
+ for (const id of staleAccessKeys) this.accessTimes.delete(id);
1294
+
1295
+ // Safety net for an entry that never received an access stamp: it is
1296
+ // invisible to the recency map, so fall back to the entry scan the old
1297
+ // implementation always paid. Every add path stamps `accessTimes`, so
1298
+ // this loop finds nothing and costs nothing in the normal case (it only
1299
+ // runs at all when the recency map came up short).
1300
+ if (toEvict.length < count) {
1301
+ for (const [id, entry] of this.entries) {
1302
+ if (toEvict.length >= count) break;
1303
+ if (this.accessTimes.has(id)) continue;
1304
+ const model = entry.model ?? entry.weakRef?.deref();
1305
+ if (
1306
+ model &&
1307
+ typeof model.hasObservedCollections === 'function' &&
1308
+ model.hasObservedCollections()
1309
+ ) {
1310
+ continue;
1311
+ }
1312
+ toEvict.push(id);
1259
1313
  }
1260
1314
  }
1261
1315
 
1262
- for (const candidate of oldest) {
1263
- this.remove(candidate.id);
1316
+ for (const id of toEvict) {
1317
+ this.remove(id);
1264
1318
  this.metrics.evictions++;
1265
1319
  }
1266
1320
  });
1267
1321
  }
1268
1322
 
1323
+ /**
1324
+ * Data-field keys to probe per model, resolved once per model name. Only
1325
+ * stored-value fields can carry bulk; walking the whole instance also
1326
+ * visited the base class's bookkeeping (`_mobxProperties`,
1327
+ * `modifiedProperties`, `validationRules`, …) and JSON-stringified each of
1328
+ * those empty containers on EVERY add — a first-order term of wire-ingest
1329
+ * create apply. Reference/computed keys are deliberately excluded: reading
1330
+ * them would execute their getters.
1331
+ */
1332
+ private sizeProbeKeys = new Map<string, readonly string[]>();
1333
+
1269
1334
  private isLargeModel(model: Model): boolean {
1270
1335
  try {
1271
- // Most synchronized rows contain only short scalars. Serializing the
1272
- // entire model merely to discover that fact doubled wire-ingest object
1273
- // walking. Accumulate cheap scalar sizes and serialize only nested
1274
- // payloads that can plausibly cross the WeakRef threshold.
1336
+ const modelName = model.getModelName();
1337
+ let keys = this.sizeProbeKeys.get(modelName);
1338
+ if (!keys) {
1339
+ const props = this.registry.getProperties(modelName);
1340
+ keys = [...props.entries()]
1341
+ .filter(([, meta]) => meta.type === PropertyType.property || meta.type === PropertyType.ephemeralProperty)
1342
+ .map(([key]) => key);
1343
+ this.sizeProbeKeys.set(modelName, keys);
1344
+ }
1345
+ // A model with no registered stored fields (e.g. a custom entity) keeps
1346
+ // the whole-instance walk, since its payload lives outside the schema.
1347
+ const values: Iterable<unknown> = keys.length > 0
1348
+ ? keys.map((key): unknown => Reflect.get(model, key))
1349
+ : Object.values(model);
1350
+
1351
+ // Most synchronized rows contain only short scalars. Accumulate cheap
1352
+ // scalar sizes and serialize only nested payloads that can plausibly
1353
+ // cross the WeakRef threshold.
1275
1354
  let size = 0;
1276
- for (const value of Object.values(model)) {
1355
+ for (const value of values) {
1277
1356
  if (typeof value === 'string') {
1278
1357
  size += value.length;
1279
1358
  } else if (
@@ -1369,6 +1448,9 @@ export class InstanceCache {
1369
1448
  if (!this.matchesScope(entry.scope, ModelScope.live)) { droppedScope++; continue; }
1370
1449
  const model = this.resolveModel(entry, id);
1371
1450
  if (model && !model.disposed) {
1451
+ // Consumer boundary — activate, as in getByType. `hasMany` reads
1452
+ // (`section.blocks`) observe fields off these rows directly.
1453
+ model.ensureObservable();
1372
1454
  result.push(model);
1373
1455
  } else if (model?.disposed) {
1374
1456
  droppedDisposed++;
@@ -89,6 +89,19 @@ export interface ModelChanges {
89
89
  /** Shared frozen default for {@link Model.getDerivedGetterNames}. */
90
90
  const EMPTY_DERIVED_GETTERS: readonly string[] = Object.freeze([]);
91
91
 
92
+ /**
93
+ * How {@link Model.assignFieldsFromData} treats one key on instances of one
94
+ * model class. Resolved once per (class, key) and cached: the decision depends
95
+ * only on class shape — prototype getters, registry annotations, declared
96
+ * fields — which is identical across instances of a generated model class.
97
+ * Every write disposition is still guarded per instance by a `key in this`
98
+ * check, so an instance that genuinely lacks the key falls back to the full
99
+ * resolution instead of trusting the cache.
100
+ */
101
+ type FieldDisposition = 'write' | 'write-date' | 'skip-absent' | 'skip-readonly';
102
+
103
+ const fieldWritePlans = new WeakMap<object, Map<string, FieldDisposition>>();
104
+
92
105
  /**
93
106
  * The abstract base class every domain model extends. It holds the model's id
94
107
  * and timestamps, tracks in-place property changes for change detection and
@@ -108,6 +121,17 @@ export abstract class Model {
108
121
  /** MobX observable properties storage */
109
122
  _mobxProperties: ModelData = {};
110
123
 
124
+ /**
125
+ * True while {@link updateFromData} assigns inbound wire data. The
126
+ * `observe()` bridge (`mobxSetup.M1`) checks it and skips the forward to
127
+ * {@link propertyChanged} entirely: hydration writes are not user edits, and
128
+ * the bridge's work per changed field — a nested action, map bookkeeping,
129
+ * and a fabricated `updatedAt` stamp — was already being discarded by the
130
+ * `modifiedProperties` swap in `updateFromData`. The swap stays as the
131
+ * correctness backstop; this flag removes the cost.
132
+ */
133
+ _isHydrating = false;
134
+
111
135
  /** Referenced models cache */
112
136
  _referencedModels: Record<string, Model | null> = {};
113
137
 
@@ -120,6 +144,18 @@ export abstract class Model {
120
144
  /** Original data snapshot */
121
145
  private _originalData?: ModelData;
122
146
 
147
+ /**
148
+ * Whether the persisted baseline needs recapturing. Hydration and ack land
149
+ * far more often than anything reads the baseline — an observer-only client
150
+ * reads it never — so each of those sites marks the snapshot stale and
151
+ * {@link getOriginalSnapshot} materializes it on first read. Correctness
152
+ * rests on the tracking invariant: every non-hydration write is recorded in
153
+ * `modifiedProperties` (first-old-wins), so between the stale mark and the
154
+ * read, untracked fields still hold exactly the values an eager capture
155
+ * would have recorded.
156
+ */
157
+ private _originalDataStale = false;
158
+
123
159
  /** Sync status */
124
160
  syncStatus: 'pending' | 'syncing' | 'synced' = 'pending';
125
161
 
@@ -291,7 +327,8 @@ export abstract class Model {
291
327
  */
292
328
  markAsPersisted(): void {
293
329
  this._isNew = false;
294
- this._originalData = this.captureSnapshot();
330
+ this._originalData = undefined;
331
+ this._originalDataStale = true;
295
332
  }
296
333
 
297
334
  /**
@@ -315,6 +352,10 @@ export abstract class Model {
315
352
  * last acknowledged state would already be the authoritative baseline.
316
353
  */
317
354
  getOriginalSnapshot(): Readonly<ModelData> | undefined {
355
+ if (this._originalDataStale) {
356
+ this._originalData = this.captureSnapshot();
357
+ this._originalDataStale = false;
358
+ }
318
359
  return this._originalData;
319
360
  }
320
361
 
@@ -324,7 +365,8 @@ export abstract class Model {
324
365
  clearChanges(): void {
325
366
  runInAction(() => {
326
367
  this.modifiedProperties.clear();
327
- this._originalData = this.captureSnapshot();
368
+ this._originalData = undefined;
369
+ this._originalDataStale = true;
328
370
  });
329
371
  }
330
372
 
@@ -580,48 +622,39 @@ export abstract class Model {
580
622
  data: ModelData,
581
623
  onWrite?: (key: string, oldValue: unknown, newValue: unknown) => void,
582
624
  ): void {
583
- // Update properties with safety checks for read-only/computed accessors
584
- for (const [key, raw] of Object.entries(data)) {
585
- if (key === 'id') continue;
586
-
587
- // Only attempt to set if the property exists on instance or prototype
588
- if (!(this.hasOwnProperty(key) || key in this)) continue;
625
+ let plan = fieldWritePlans.get(this.constructor);
626
+ if (!plan) {
627
+ plan = new Map();
628
+ fieldWritePlans.set(this.constructor, plan);
629
+ }
589
630
 
590
- // Never assign to MobX computed properties (they may expose a setter that throws)
591
- try {
592
- if (isComputedProp(this, key)) {
593
- continue;
594
- }
595
- } catch {
596
- // If MobX internals are unavailable for some reason, fall back to descriptor checks below
597
- }
631
+ for (const key in data) {
632
+ if (key === 'id') continue;
633
+ if (!Object.prototype.hasOwnProperty.call(data, key)) continue;
634
+ const raw = data[key];
598
635
 
599
- // Resolve property descriptor from own or prototype chain
600
- const ownDesc = Object.getOwnPropertyDescriptor(this, key);
601
- let desc = ownDesc;
602
- if (!desc) {
603
- let proto = Object.getPrototypeOf(this) as object | null;
604
- while (proto && proto !== Object.prototype && !desc) {
605
- desc = Object.getOwnPropertyDescriptor(proto, key);
606
- proto = Object.getPrototypeOf(proto) as object | null;
607
- }
636
+ let disposition = plan.get(key);
637
+ if (disposition === undefined) {
638
+ disposition = this.resolveFieldDisposition(key);
639
+ plan.set(key, disposition);
608
640
  }
609
641
 
610
- // Determine writability: allow if data descriptor writable, or accessor with setter
611
- const writable = desc
612
- ? ('writable' in desc && !!desc.writable) ||
613
- ('set' in desc && typeof desc.set === 'function')
614
- : true;
615
- if (!writable) {
616
- // Skip read-only accessor properties (getter-only)
642
+ if (disposition === 'skip-readonly') continue;
643
+ if (disposition === 'skip-absent') {
644
+ // The plan was built from an instance that lacked this key. Re-resolve
645
+ // for this instance without touching the cache, so heterogeneous
646
+ // shapes stay correct at the cost of the slow path.
647
+ if (!(key in this)) continue;
648
+ disposition = this.resolveFieldDisposition(key);
649
+ if (disposition === 'skip-readonly' || disposition === 'skip-absent') continue;
650
+ } else if (!(key in this)) {
617
651
  continue;
618
652
  }
619
653
 
620
654
  // Handle date conversions
621
- const value =
622
- (key === 'createdAt' || key === 'updatedAt' || key === 'archivedAt') && raw
623
- ? new Date(raw as string | number)
624
- : raw;
655
+ const value = disposition === 'write-date' && raw
656
+ ? new Date(raw as string | number)
657
+ : raw;
625
658
 
626
659
  // Capture the pre-write value BEFORE assignment so trackers
627
660
  // (undo inverse, getChanges) see the true previous value.
@@ -634,6 +667,44 @@ export abstract class Model {
634
667
  }
635
668
  }
636
669
 
670
+ /**
671
+ * The uncached decision for one key: existence on instance or prototype,
672
+ * never a MobX computed (its setter may throw), and writability resolved
673
+ * through the descriptor chain — a data descriptor's `writable` or an
674
+ * accessor's setter.
675
+ */
676
+ private resolveFieldDisposition(key: string): FieldDisposition {
677
+ if (!(key in this)) return 'skip-absent';
678
+
679
+ try {
680
+ if (isComputedProp(this, key)) {
681
+ return 'skip-readonly';
682
+ }
683
+ } catch {
684
+ // If MobX internals are unavailable for some reason, fall back to descriptor checks below
685
+ }
686
+
687
+ const ownDesc = Object.getOwnPropertyDescriptor(this, key);
688
+ let desc = ownDesc;
689
+ if (!desc) {
690
+ let proto = Object.getPrototypeOf(this) as object | null;
691
+ while (proto && proto !== Object.prototype && !desc) {
692
+ desc = Object.getOwnPropertyDescriptor(proto, key);
693
+ proto = Object.getPrototypeOf(proto) as object | null;
694
+ }
695
+ }
696
+
697
+ const writable = desc
698
+ ? ('writable' in desc && !!desc.writable) ||
699
+ ('set' in desc && typeof desc.set === 'function')
700
+ : true;
701
+ if (!writable) return 'skip-readonly';
702
+
703
+ return key === 'createdAt' || key === 'updatedAt' || key === 'archivedAt'
704
+ ? 'write-date'
705
+ : 'write';
706
+ }
707
+
637
708
  /**
638
709
  * Update from raw data (hydration)
639
710
  *
@@ -661,16 +732,20 @@ export abstract class Model {
661
732
  runInAction(() => {
662
733
  const originalTracking = this.modifiedProperties;
663
734
  this.modifiedProperties = new Map();
664
-
665
- // No `onWrite` → this call records nothing itself.
666
- this.assignFieldsFromData(data);
667
-
668
- this.modifiedProperties = originalTracking;
735
+ this._isHydrating = true;
736
+ try {
737
+ // No `onWrite` → this call records nothing itself.
738
+ this.assignFieldsFromData(data);
739
+ } finally {
740
+ this._isHydrating = false;
741
+ this.modifiedProperties = originalTracking;
742
+ }
669
743
  });
670
744
 
671
745
  // Mark as persisted if updating existing model
672
746
  if (!this._isNew) {
673
- this._originalData = this.captureSnapshot();
747
+ this._originalData = undefined;
748
+ this._originalDataStale = true;
674
749
  }
675
750
 
676
751
  this.didUpdate();