@abloatai/humans 0.37.1 → 0.39.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 (48) hide show
  1. package/dist/core.d.ts +1 -0
  2. package/dist/core.js +4 -0
  3. package/dist/local/BaseSyncedStore.d.ts +4 -2
  4. package/dist/local/BaseSyncedStore.js +7 -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 +18 -8
  8. package/dist/local/InstanceCache.js +74 -74
  9. package/dist/local/Model.d.ts +18 -0
  10. package/dist/local/Model.js +83 -32
  11. package/dist/local/SyncClient.d.ts +1 -4
  12. package/dist/local/SyncClient.js +55 -60
  13. package/dist/local/client/createModelProxy.js +14 -12
  14. package/dist/local/client/options.d.ts +7 -0
  15. package/dist/local/client/reactiveEngine.js +23 -3
  16. package/dist/local/client/storeLifecycle.js +6 -3
  17. package/dist/local/stores/DatabaseManager.d.ts +2 -2
  18. package/dist/local/stores/DatabaseManager.js +2 -2
  19. package/dist/local/stores/persistenceIdentity.d.ts +7 -8
  20. package/dist/local/stores/persistenceIdentity.js +4 -5
  21. package/dist/local/sync/SyncWebSocket.d.ts +7 -0
  22. package/dist/local/sync/SyncWebSocket.js +21 -6
  23. package/dist/local/sync/deltaPipeline.js +31 -13
  24. package/dist/local/sync/drainProfile.d.ts +104 -0
  25. package/dist/local/sync/drainProfile.js +182 -0
  26. package/dist/local/sync/initialize.js +2 -2
  27. package/dist/local/transactions/mutations/MutationQueue.js +32 -12
  28. package/dist/local/transactions/mutations/pendingDrain.d.ts +1 -1
  29. package/dist/local/transactions/mutations/pendingDrain.js +2 -1
  30. package/package.json +2 -2
  31. package/src/core.ts +15 -0
  32. package/src/local/BaseSyncedStore.ts +11 -3
  33. package/src/local/Database.ts +87 -50
  34. package/src/local/InstanceCache.ts +77 -71
  35. package/src/local/Model.ts +98 -37
  36. package/src/local/SyncClient.ts +57 -61
  37. package/src/local/client/createModelProxy.ts +14 -12
  38. package/src/local/client/options.ts +9 -0
  39. package/src/local/client/reactiveEngine.ts +23 -2
  40. package/src/local/client/storeLifecycle.ts +10 -4
  41. package/src/local/stores/DatabaseManager.ts +4 -4
  42. package/src/local/stores/persistenceIdentity.ts +10 -12
  43. package/src/local/sync/SyncWebSocket.ts +20 -6
  44. package/src/local/sync/deltaPipeline.ts +51 -21
  45. package/src/local/sync/drainProfile.ts +257 -0
  46. package/src/local/sync/initialize.ts +2 -2
  47. package/src/local/transactions/mutations/MutationQueue.ts +31 -12
  48. package/src/local/transactions/mutations/pendingDrain.ts +7 -2
@@ -391,48 +391,85 @@ export class Database {
391
391
 
392
392
  const out: ModelData = {};
393
393
 
394
- for (const [key, value] of Object.entries(data)) {
395
- // Drop redundant or ephemeral markers
396
- if (key === '__typename' || key === '__class' || key === 'clientId' || key === 'syncStatus') {
397
- continue;
398
- }
394
+ for (const key in data) {
395
+ if (!Object.prototype.hasOwnProperty.call(data, key)) continue;
396
+ this.compactAssign(out, key, data[key]);
397
+ }
399
398
 
400
- // Skip only `undefined`; preserve explicit `null`, which is a
401
- // meaningful value for a nullable column.
402
- if (value === undefined) {
403
- continue;
404
- }
399
+ // Always ensure id is present
400
+ if (!out.id && data.id) out.id = data.id;
405
401
 
406
- if (Array.isArray(value)) {
407
- if (value.length === 0) continue;
408
- out[key] = value;
409
- continue;
410
- }
402
+ return out;
403
+ }
411
404
 
412
- if (typeof value === 'object') {
413
- // Preserve explicit null values
414
- if (value === null) {
415
- out[key] = null;
416
- continue;
417
- }
405
+ /**
406
+ * The one definition of the per-key compaction rule: drops the redundant
407
+ * markers `__typename`, `__class`, `clientId`, and `syncStatus`, drops
408
+ * `undefined`, empty arrays, and empty plain objects, and preserves
409
+ * explicit `null` (a meaningful "clear this field" for a nullable column)
410
+ * and `Date` instances (IndexedDB can clone these). `compactRecord` applies
411
+ * it into a fresh object; the batched in-memory delta path applies it
412
+ * directly onto the merge target so a delta costs one object, not four.
413
+ */
414
+ private compactAssign(out: ModelData, key: string, value: unknown): void {
415
+ if (key === '__typename' || key === '__class' || key === 'clientId' || key === 'syncStatus') {
416
+ return;
417
+ }
418
418
 
419
- // Preserve Date objects (IndexedDB can clone these)
420
- if (value instanceof Date) {
421
- out[key] = value;
422
- continue;
423
- }
419
+ if (value === undefined) return;
420
+
421
+ if (Array.isArray(value)) {
422
+ if (value.length === 0) return;
423
+ out[key] = value;
424
+ return;
425
+ }
426
+
427
+ if (typeof value === 'object') {
428
+ if (value === null) {
429
+ out[key] = null;
430
+ return;
431
+ }
424
432
 
425
- // For plain objects, drop if empty
426
- if (Object.keys(value).length === 0) continue;
433
+ if (value instanceof Date) {
427
434
  out[key] = value;
428
- continue;
435
+ return;
429
436
  }
430
437
 
438
+ if (Object.keys(value).length === 0) return;
431
439
  out[key] = value;
440
+ return;
432
441
  }
433
442
 
434
- // Always ensure id is present
435
- if (!out.id && data.id) out.id = data.id;
443
+ out[key] = value;
444
+ }
445
+
446
+ /**
447
+ * Compact a wire delta's payload in one pass, mirroring
448
+ * `compactRecord({ id: modelId, ...data })` exactly: the id key is
449
+ * processed first with the payload's own `id` winning over the envelope's,
450
+ * then each payload key in order. Passing an existing record as `out`
451
+ * makes this the update merge — compacted keys override, dropped keys
452
+ * leave the existing values untouched — without the intermediate
453
+ * id-injected and compacted copies the spread form allocates.
454
+ */
455
+ private compactDeltaRecord(
456
+ modelId: string,
457
+ data: Record<string, unknown>,
458
+ out: ModelData = {},
459
+ ): ModelData {
460
+ const hasOwnId = Object.prototype.hasOwnProperty.call(data, 'id');
461
+ this.compactAssign(out, 'id', hasOwnId ? data.id : modelId);
462
+
463
+ for (const key in data) {
464
+ if (key === 'id') continue;
465
+ if (!Object.prototype.hasOwnProperty.call(data, key)) continue;
466
+ this.compactAssign(out, key, data[key]);
467
+ }
468
+
469
+ if (!out.id) {
470
+ const idValue = hasOwnId ? data.id : modelId;
471
+ if (idValue) out.id = idValue;
472
+ }
436
473
 
437
474
  return out;
438
475
  }
@@ -1109,45 +1146,45 @@ export class Database {
1109
1146
  // catch-up frame. Apply the already-ordered batch directly, matching the
1110
1147
  // synchronous request scheduling used by the IndexedDB transaction path.
1111
1148
  for (const [index, delta] of deltas.entries()) {
1112
- const { actionType, modelName, modelId, data, syncId } = delta;
1149
+ const { actionType, modelName, modelId, data, syncId, transactionId } = delta;
1113
1150
  const store = this.getStore(modelName, 'processDeltaBatch');
1114
1151
  let single: AppliedChange;
1115
1152
 
1116
1153
  if (!store || (typeof syncId === 'number' && syncId <= lastApplied)) {
1117
- single = { action: 'verify', modelName, modelId };
1154
+ single = { action: 'verify', modelName, modelId, transactionId };
1118
1155
  } else {
1119
1156
  const memoryStore = store as InMemoryObjectStore;
1120
- const dataWithId =
1121
- data && typeof data === 'object'
1122
- ? { id: modelId, ...(data as Record<string, unknown>) }
1123
- : data;
1124
- const compacted =
1125
- dataWithId && typeof dataWithId === 'object'
1126
- ? this.compactRecord(modelName, dataWithId)
1127
- : dataWithId;
1128
1157
 
1129
1158
  switch (actionType) {
1130
1159
  case 'C':
1131
- case 'I':
1160
+ case 'I': {
1161
+ const compacted =
1162
+ data && typeof data === 'object'
1163
+ ? this.compactDeltaRecord(modelId, data)
1164
+ : data;
1132
1165
  if (compacted && typeof compacted === 'object') {
1133
1166
  memoryStore.putSync(compacted);
1134
1167
  }
1135
- single = { action: 'add', modelName, modelId, data: compacted };
1168
+ single = { action: 'add', modelName, modelId, data: compacted, transactionId };
1136
1169
  break;
1170
+ }
1137
1171
  case 'U': {
1138
1172
  const existing = memoryStore.getSync(modelId);
1139
1173
  if (!existing) {
1140
- single = { action: 'verify', modelName, modelId, data: null };
1174
+ single = { action: 'verify', modelName, modelId, data: null, transactionId };
1141
1175
  } else {
1142
- const merged = { ...existing, ...compacted };
1176
+ const merged: ModelData = { ...existing };
1177
+ if (data && typeof data === 'object') {
1178
+ this.compactDeltaRecord(modelId, data, merged);
1179
+ }
1143
1180
  memoryStore.putSync(merged);
1144
- single = { action: 'update', modelName, modelId, data: merged };
1181
+ single = { action: 'update', modelName, modelId, data: merged, transactionId };
1145
1182
  }
1146
1183
  break;
1147
1184
  }
1148
1185
  case 'D':
1149
1186
  memoryStore.deleteSync(modelId);
1150
- single = { action: 'remove', modelName, modelId };
1187
+ single = { action: 'remove', modelName, modelId, transactionId };
1151
1188
  break;
1152
1189
  case 'A': {
1153
1190
  const archivedData = this.compactRecord(modelName, {
@@ -1156,18 +1193,18 @@ export class Database {
1156
1193
  archivedAt: new Date(),
1157
1194
  });
1158
1195
  memoryStore.putSync(archivedData);
1159
- single = { action: 'archive', modelName, modelId, data: archivedData };
1196
+ single = { action: 'archive', modelName, modelId, data: archivedData, transactionId };
1160
1197
  break;
1161
1198
  }
1162
1199
  case 'V':
1163
1200
  case 'G':
1164
1201
  case 'S':
1165
- single = { action: 'verify', modelName, modelId, data };
1202
+ single = { action: 'verify', modelName, modelId, data, transactionId };
1166
1203
  break;
1167
1204
  }
1168
1205
  }
1169
1206
 
1170
- inMemResults[index] = { ...single, transactionId: delta.transactionId };
1207
+ inMemResults[index] = single;
1171
1208
  if (
1172
1209
  single.action !== 'verify' &&
1173
1210
  typeof syncId === 'number' &&
@@ -55,8 +55,23 @@ export class InstanceCache {
55
55
  // Non-observable access time tracking — kept outside observable.map so that
56
56
  // updating timestamps in get() during React render does NOT trigger MobX
57
57
  // reactions (which would cause infinite re-render loops).
58
+ //
59
+ // Every write goes through `touchAccess`, which moves the key to the map's
60
+ // back, so iteration order IS recency order (oldest first). Eviction relies
61
+ // on that: `evictOldestBatch` takes the first eligible keys instead of
62
+ // scanning every entry.
58
63
  private accessTimes = new Map<string, number>();
59
64
 
65
+ /**
66
+ * Record an access. The delete-then-set moves an existing key to the back
67
+ * of the map's insertion order, which is what keeps `accessTimes` iterable
68
+ * oldest-first for eviction.
69
+ */
70
+ private touchAccess(id: string, at: number): void {
71
+ this.accessTimes.delete(id);
72
+ this.accessTimes.set(id, at);
73
+ }
74
+
60
75
  // Deduplication tracking
61
76
  private recentAdditions = new Map<string, number>(); // "modelType:modelId" -> timestamp
62
77
  private deltaHistory = new Map<
@@ -203,7 +218,7 @@ export class InstanceCache {
203
218
  const model = entry.weakRef.deref();
204
219
  if (model) {
205
220
  entry.model = model;
206
- if (id) this.accessTimes.set(id, Date.now());
221
+ if (id) this.touchAccess(id, Date.now());
207
222
  return model;
208
223
  }
209
224
  }
@@ -250,7 +265,7 @@ export class InstanceCache {
250
265
  }
251
266
 
252
267
  // Update access time in non-observable map — prevents MobX reactions during render
253
- this.accessTimes.set(id, Date.now());
268
+ this.touchAccess(id, Date.now());
254
269
  this.metrics.hits++;
255
270
 
256
271
  model?.ensureObservable();
@@ -312,7 +327,7 @@ export class InstanceCache {
312
327
  runInAction(() => {
313
328
  this.entries.set(id, { ...existingEntry, scope });
314
329
  });
315
- this.accessTimes.set(id, Date.now());
330
+ this.touchAccess(id, Date.now());
316
331
  }
317
332
  this.metrics.duplicatesSkipped++;
318
333
  return;
@@ -381,7 +396,7 @@ export class InstanceCache {
381
396
  entry.weakRef = new WeakRef(model);
382
397
  }
383
398
 
384
- this.accessTimes.set(id, Date.now());
399
+ this.touchAccess(id, Date.now());
385
400
  runInAction(() => {
386
401
  this.entries.set(id, entry);
387
402
  this.addToTypeIndex(id, model.getModelName());
@@ -415,7 +430,7 @@ export class InstanceCache {
415
430
  runInAction(() => {
416
431
  this.entries.set(id, { ...existingEntry, scope });
417
432
  });
418
- this.accessTimes.set(id, Date.now());
433
+ this.touchAccess(id, Date.now());
419
434
  }
420
435
 
421
436
  this.notifySubscribers(existingModel);
@@ -462,7 +477,7 @@ export class InstanceCache {
462
477
  if (existingEntry?.model && !existingEntry.model.disposed) {
463
478
  if (existingEntry.scope !== scope) {
464
479
  this.entries.set(id, { ...existingEntry, scope });
465
- this.accessTimes.set(id, now);
480
+ this.touchAccess(id, now);
466
481
  }
467
482
  this.metrics.duplicatesSkipped++;
468
483
  continue;
@@ -472,7 +487,7 @@ export class InstanceCache {
472
487
  model,
473
488
  scope,
474
489
  };
475
- this.accessTimes.set(id, now);
490
+ this.touchAccess(id, now);
476
491
 
477
492
  if (this.config.useWeakRefs && this.isLargeModel(model)) {
478
493
  entry.weakRef = new WeakRef(model);
@@ -521,7 +536,7 @@ export class InstanceCache {
521
536
  }
522
537
  if (existingEntry.scope !== scope) {
523
538
  this.entries.set(id, { ...existingEntry, scope });
524
- this.accessTimes.set(id, Date.now());
539
+ this.touchAccess(id, Date.now());
525
540
  }
526
541
  this.notifySubscribers(existingEntry.model);
527
542
  // Notify views of the update
@@ -536,7 +551,7 @@ export class InstanceCache {
536
551
  this.evictOldest();
537
552
  }
538
553
  const entry: ModelEntry = { model, scope };
539
- this.accessTimes.set(id, Date.now());
554
+ this.touchAccess(id, Date.now());
540
555
  if (this.config.useWeakRefs && this.isLargeModel(model)) {
541
556
  entry.weakRef = new WeakRef(model);
542
557
  }
@@ -773,7 +788,7 @@ export class InstanceCache {
773
788
  runInAction(() => {
774
789
  this.entries.set(id, { ...entry, scope });
775
790
  });
776
- this.accessTimes.set(id, Date.now());
791
+ this.touchAccess(id, Date.now());
777
792
  }
778
793
  }
779
794
 
@@ -989,7 +1004,7 @@ export class InstanceCache {
989
1004
  // Restore access times: clear then re-add preserved
990
1005
  this.accessTimes.clear();
991
1006
  for (const [id, time] of preservedAccessTimes) {
992
- this.accessTimes.set(id, time);
1007
+ this.touchAccess(id, time);
993
1008
  }
994
1009
  // No cache to invalidate — typeIndex + entries are directly observable
995
1010
  }
@@ -1008,7 +1023,7 @@ export class InstanceCache {
1008
1023
  return false;
1009
1024
  }
1010
1025
 
1011
- this.accessTimes.set(id, Date.now());
1026
+ this.touchAccess(id, Date.now());
1012
1027
  return true;
1013
1028
  }
1014
1029
 
@@ -1107,7 +1122,7 @@ export class InstanceCache {
1107
1122
  model.hasObservedCollections()
1108
1123
  ) {
1109
1124
  // Model has active React observers - refresh access time and skip GC
1110
- this.accessTimes.set(id, now);
1125
+ this.touchAccess(id, now);
1111
1126
  skippedObserved++;
1112
1127
  continue;
1113
1128
  }
@@ -1183,59 +1198,37 @@ export class InstanceCache {
1183
1198
  }
1184
1199
 
1185
1200
  /**
1186
- * Free capacity for a whole incoming frame in one scan.
1201
+ * Free capacity for a whole incoming frame by taking the first eligible
1202
+ * keys of the recency-ordered `accessTimes` map.
1203
+ *
1204
+ * Every access moves its key to the back of that map (`touchAccess`), so
1205
+ * iterating from the front visits entries oldest-first — the eviction
1206
+ * order — at O(count) for a sustained publication frame. The previous
1207
+ * shape kept a bounded max-heap but still walked EVERY cache entry per
1208
+ * incoming frame, which at a 10k cap and ~1.3k-delta frames made the scan
1209
+ * itself a first-order term of the observer's apply cost.
1187
1210
  *
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.
1211
+ * The observed-model contract is unchanged: a model React is observing is
1212
+ * skipped and stays in place, so it is reconsidered (and skipped again)
1213
+ * on later evictions until it is no longer observed.
1195
1214
  */
1196
1215
  private evictOldestBatch(count: number): void {
1197
1216
  if (count <= 0) return;
1198
1217
  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;
1218
+ const toEvict: string[] = [];
1219
+ const staleAccessKeys: string[] = [];
1220
+
1221
+ for (const id of this.accessTimes.keys()) {
1222
+ if (toEvict.length >= count) break;
1223
+
1224
+ const entry = this.entries.get(id);
1225
+ if (!entry) {
1226
+ // remove()/clear() delete from both maps, so a stale key means a
1227
+ // divergence clean it up rather than let it linger.
1228
+ staleAccessKeys.push(id);
1229
+ continue;
1235
1230
  }
1236
- };
1237
1231
 
1238
- for (const [id, entry] of this.entries) {
1239
1232
  // Skip models that are being observed by React - they must stay alive
1240
1233
  const model = entry.model ?? entry.weakRef?.deref();
1241
1234
  if (
@@ -1246,21 +1239,34 @@ export class InstanceCache {
1246
1239
  continue;
1247
1240
  }
1248
1241
 
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();
1242
+ toEvict.push(id);
1243
+ }
1244
+
1245
+ for (const id of staleAccessKeys) this.accessTimes.delete(id);
1246
+
1247
+ // Safety net for an entry that never received an access stamp: it is
1248
+ // invisible to the recency map, so fall back to the entry scan the old
1249
+ // implementation always paid. Every add path stamps `accessTimes`, so
1250
+ // this loop finds nothing and costs nothing in the normal case (it only
1251
+ // runs at all when the recency map came up short).
1252
+ if (toEvict.length < count) {
1253
+ for (const [id, entry] of this.entries) {
1254
+ if (toEvict.length >= count) break;
1255
+ if (this.accessTimes.has(id)) continue;
1256
+ const model = entry.model ?? entry.weakRef?.deref();
1257
+ if (
1258
+ model &&
1259
+ typeof model.hasObservedCollections === 'function' &&
1260
+ model.hasObservedCollections()
1261
+ ) {
1262
+ continue;
1263
+ }
1264
+ toEvict.push(id);
1259
1265
  }
1260
1266
  }
1261
1267
 
1262
- for (const candidate of oldest) {
1263
- this.remove(candidate.id);
1268
+ for (const id of toEvict) {
1269
+ this.remove(id);
1264
1270
  this.metrics.evictions++;
1265
1271
  }
1266
1272
  });
@@ -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
@@ -120,6 +133,18 @@ export abstract class Model {
120
133
  /** Original data snapshot */
121
134
  private _originalData?: ModelData;
122
135
 
136
+ /**
137
+ * Whether the persisted baseline needs recapturing. Hydration and ack land
138
+ * far more often than anything reads the baseline — an observer-only client
139
+ * reads it never — so each of those sites marks the snapshot stale and
140
+ * {@link getOriginalSnapshot} materializes it on first read. Correctness
141
+ * rests on the tracking invariant: every non-hydration write is recorded in
142
+ * `modifiedProperties` (first-old-wins), so between the stale mark and the
143
+ * read, untracked fields still hold exactly the values an eager capture
144
+ * would have recorded.
145
+ */
146
+ private _originalDataStale = false;
147
+
123
148
  /** Sync status */
124
149
  syncStatus: 'pending' | 'syncing' | 'synced' = 'pending';
125
150
 
@@ -291,7 +316,8 @@ export abstract class Model {
291
316
  */
292
317
  markAsPersisted(): void {
293
318
  this._isNew = false;
294
- this._originalData = this.captureSnapshot();
319
+ this._originalData = undefined;
320
+ this._originalDataStale = true;
295
321
  }
296
322
 
297
323
  /**
@@ -315,6 +341,10 @@ export abstract class Model {
315
341
  * last acknowledged state would already be the authoritative baseline.
316
342
  */
317
343
  getOriginalSnapshot(): Readonly<ModelData> | undefined {
344
+ if (this._originalDataStale) {
345
+ this._originalData = this.captureSnapshot();
346
+ this._originalDataStale = false;
347
+ }
318
348
  return this._originalData;
319
349
  }
320
350
 
@@ -324,7 +354,8 @@ export abstract class Model {
324
354
  clearChanges(): void {
325
355
  runInAction(() => {
326
356
  this.modifiedProperties.clear();
327
- this._originalData = this.captureSnapshot();
357
+ this._originalData = undefined;
358
+ this._originalDataStale = true;
328
359
  });
329
360
  }
330
361
 
@@ -580,48 +611,39 @@ export abstract class Model {
580
611
  data: ModelData,
581
612
  onWrite?: (key: string, oldValue: unknown, newValue: unknown) => void,
582
613
  ): 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;
614
+ let plan = fieldWritePlans.get(this.constructor);
615
+ if (!plan) {
616
+ plan = new Map();
617
+ fieldWritePlans.set(this.constructor, plan);
618
+ }
589
619
 
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
- }
620
+ for (const key in data) {
621
+ if (key === 'id') continue;
622
+ if (!Object.prototype.hasOwnProperty.call(data, key)) continue;
623
+ const raw = data[key];
598
624
 
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
- }
625
+ let disposition = plan.get(key);
626
+ if (disposition === undefined) {
627
+ disposition = this.resolveFieldDisposition(key);
628
+ plan.set(key, disposition);
608
629
  }
609
630
 
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)
631
+ if (disposition === 'skip-readonly') continue;
632
+ if (disposition === 'skip-absent') {
633
+ // The plan was built from an instance that lacked this key. Re-resolve
634
+ // for this instance without touching the cache, so heterogeneous
635
+ // shapes stay correct at the cost of the slow path.
636
+ if (!(key in this)) continue;
637
+ disposition = this.resolveFieldDisposition(key);
638
+ if (disposition === 'skip-readonly' || disposition === 'skip-absent') continue;
639
+ } else if (!(key in this)) {
617
640
  continue;
618
641
  }
619
642
 
620
643
  // Handle date conversions
621
- const value =
622
- (key === 'createdAt' || key === 'updatedAt' || key === 'archivedAt') && raw
623
- ? new Date(raw as string | number)
624
- : raw;
644
+ const value = disposition === 'write-date' && raw
645
+ ? new Date(raw as string | number)
646
+ : raw;
625
647
 
626
648
  // Capture the pre-write value BEFORE assignment so trackers
627
649
  // (undo inverse, getChanges) see the true previous value.
@@ -634,6 +656,44 @@ export abstract class Model {
634
656
  }
635
657
  }
636
658
 
659
+ /**
660
+ * The uncached decision for one key: existence on instance or prototype,
661
+ * never a MobX computed (its setter may throw), and writability resolved
662
+ * through the descriptor chain — a data descriptor's `writable` or an
663
+ * accessor's setter.
664
+ */
665
+ private resolveFieldDisposition(key: string): FieldDisposition {
666
+ if (!(key in this)) return 'skip-absent';
667
+
668
+ try {
669
+ if (isComputedProp(this, key)) {
670
+ return 'skip-readonly';
671
+ }
672
+ } catch {
673
+ // If MobX internals are unavailable for some reason, fall back to descriptor checks below
674
+ }
675
+
676
+ const ownDesc = Object.getOwnPropertyDescriptor(this, key);
677
+ let desc = ownDesc;
678
+ if (!desc) {
679
+ let proto = Object.getPrototypeOf(this) as object | null;
680
+ while (proto && proto !== Object.prototype && !desc) {
681
+ desc = Object.getOwnPropertyDescriptor(proto, key);
682
+ proto = Object.getPrototypeOf(proto) as object | null;
683
+ }
684
+ }
685
+
686
+ const writable = desc
687
+ ? ('writable' in desc && !!desc.writable) ||
688
+ ('set' in desc && typeof desc.set === 'function')
689
+ : true;
690
+ if (!writable) return 'skip-readonly';
691
+
692
+ return key === 'createdAt' || key === 'updatedAt' || key === 'archivedAt'
693
+ ? 'write-date'
694
+ : 'write';
695
+ }
696
+
637
697
  /**
638
698
  * Update from raw data (hydration)
639
699
  *
@@ -670,7 +730,8 @@ export abstract class Model {
670
730
 
671
731
  // Mark as persisted if updating existing model
672
732
  if (!this._isNew) {
673
- this._originalData = this.captureSnapshot();
733
+ this._originalData = undefined;
734
+ this._originalDataStale = true;
674
735
  }
675
736
 
676
737
  this.didUpdate();