@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
package/dist/core.d.ts CHANGED
@@ -33,3 +33,4 @@ export { createClaimStream, type AttachableClaimStream, type ClaimStreamConfig,
33
33
  export { awaitClaimGrant, type GrantTransport, } from '@abloatai/transaction/coordination/awaitClaimGrant';
34
34
  export { LoadStrategy } from '@abloatai/transaction/types';
35
35
  export type { InternalAbloOptions } from './local/client/options.js';
36
+ export { drainProfileSnapshot, resetDrainProfile, drainProfilingEnabled, drainAcknowledgeStamps, type AcknowledgeStamp, type DrainProfile, type DrainBatchRow, type DrainStage, type DrainStageTotals, } from './local/sync/drainProfile.js';
package/dist/core.js CHANGED
@@ -46,3 +46,7 @@ export { awaitClaimGrant, } from '@abloatai/transaction/coordination/awaitClaimG
46
46
  // An enum naming the strategies for loading a model's data. Referenced when
47
47
  // registering models in extension code.
48
48
  export { LoadStrategy } from '@abloatai/transaction/types';
49
+ // Stage timings for the delta drain, so a benchmark harness can report where
50
+ // an observer's catch-up time went instead of inferring it. Inert unless
51
+ // `ABLO_PROFILE_DRAIN=true`, and read-only: the pipeline does the recording.
52
+ export { drainProfileSnapshot, resetDrainProfile, drainProfilingEnabled, drainAcknowledgeStamps, } from './local/sync/drainProfile.js';
@@ -86,8 +86,10 @@ export interface UserContext {
86
86
  organizationId: string;
87
87
  /** Authenticated data-plane coordinates used to isolate local persistence. */
88
88
  projectId?: string | null;
89
- environment?: 'sandbox' | 'production' | null;
90
- sandboxId?: string | null;
89
+ /** Immutable branch target. Authoritative whenever present. */
90
+ branchId: string;
91
+ /** True only when branchId is the project's production root. */
92
+ branchRoot?: boolean;
91
93
  role?: string;
92
94
  teamIds?: string[];
93
95
  /** Participant kind on the wire. Default 'user' for browser
@@ -370,7 +370,13 @@ export class BaseSyncedStore {
370
370
  this.smartSyncOptions = {
371
371
  maxDeltasBeforeBootstrap: 1000,
372
372
  maxBootstrapSize: 10 * 1024 * 1024,
373
- batchingDelay: 100,
373
+ // The inbound-delta flush debounce. Under sustained traffic the
374
+ // `maxBatchSize` force-flush governs batching, so this timer decides
375
+ // exactly one thing: how long the FINAL partial batch of a burst sits
376
+ // before it materializes. At 100 ms it was the largest single term in
377
+ // the observer's drain tail on the throughput bench; 10 ms coalesces a
378
+ // trickle just as well and keeps burst tails inside the drain budget.
379
+ batchingDelay: 10,
374
380
  maxBatchSize: 50,
375
381
  };
376
382
  // Create internal helpers
@@ -151,6 +151,26 @@ export declare class Database {
151
151
  * and bootstrap data is plain JSON from the server.
152
152
  */
153
153
  private compactRecord;
154
+ /**
155
+ * The one definition of the per-key compaction rule: drops the redundant
156
+ * markers `__typename`, `__class`, `clientId`, and `syncStatus`, drops
157
+ * `undefined`, empty arrays, and empty plain objects, and preserves
158
+ * explicit `null` (a meaningful "clear this field" for a nullable column)
159
+ * and `Date` instances (IndexedDB can clone these). `compactRecord` applies
160
+ * it into a fresh object; the batched in-memory delta path applies it
161
+ * directly onto the merge target so a delta costs one object, not four.
162
+ */
163
+ private compactAssign;
164
+ /**
165
+ * Compact a wire delta's payload in one pass, mirroring
166
+ * `compactRecord({ id: modelId, ...data })` exactly: the id key is
167
+ * processed first with the payload's own `id` winning over the envelope's,
168
+ * then each payload key in order. Passing an existing record as `out`
169
+ * makes this the update merge — compacted keys override, dropped keys
170
+ * leave the existing values untouched — without the intermediate
171
+ * id-injected and compacted copies the spread form allocates.
172
+ */
173
+ private compactDeltaRecord;
154
174
  /**
155
175
  * Mark that the next bootstrap must be a full bootstrap.
156
176
  * Called when a sync group change ("G" delta) is received — the client must
@@ -231,44 +231,77 @@ export class Database {
231
231
  if (!data || typeof data !== 'object')
232
232
  return data;
233
233
  const out = {};
234
- for (const [key, value] of Object.entries(data)) {
235
- // Drop redundant or ephemeral markers
236
- if (key === '__typename' || key === '__class' || key === 'clientId' || key === 'syncStatus') {
237
- continue;
238
- }
239
- // Skip only `undefined`; preserve explicit `null`, which is a
240
- // meaningful value for a nullable column.
241
- if (value === undefined) {
242
- continue;
243
- }
244
- if (Array.isArray(value)) {
245
- if (value.length === 0)
246
- continue;
247
- out[key] = value;
234
+ for (const key in data) {
235
+ if (!Object.prototype.hasOwnProperty.call(data, key))
248
236
  continue;
237
+ this.compactAssign(out, key, data[key]);
238
+ }
239
+ // Always ensure id is present
240
+ if (!out.id && data.id)
241
+ out.id = data.id;
242
+ return out;
243
+ }
244
+ /**
245
+ * The one definition of the per-key compaction rule: drops the redundant
246
+ * markers `__typename`, `__class`, `clientId`, and `syncStatus`, drops
247
+ * `undefined`, empty arrays, and empty plain objects, and preserves
248
+ * explicit `null` (a meaningful "clear this field" for a nullable column)
249
+ * and `Date` instances (IndexedDB can clone these). `compactRecord` applies
250
+ * it into a fresh object; the batched in-memory delta path applies it
251
+ * directly onto the merge target so a delta costs one object, not four.
252
+ */
253
+ compactAssign(out, key, value) {
254
+ if (key === '__typename' || key === '__class' || key === 'clientId' || key === 'syncStatus') {
255
+ return;
256
+ }
257
+ if (value === undefined)
258
+ return;
259
+ if (Array.isArray(value)) {
260
+ if (value.length === 0)
261
+ return;
262
+ out[key] = value;
263
+ return;
264
+ }
265
+ if (typeof value === 'object') {
266
+ if (value === null) {
267
+ out[key] = null;
268
+ return;
249
269
  }
250
- if (typeof value === 'object') {
251
- // Preserve explicit null values
252
- if (value === null) {
253
- out[key] = null;
254
- continue;
255
- }
256
- // Preserve Date objects (IndexedDB can clone these)
257
- if (value instanceof Date) {
258
- out[key] = value;
259
- continue;
260
- }
261
- // For plain objects, drop if empty
262
- if (Object.keys(value).length === 0)
263
- continue;
270
+ if (value instanceof Date) {
264
271
  out[key] = value;
265
- continue;
272
+ return;
266
273
  }
274
+ if (Object.keys(value).length === 0)
275
+ return;
267
276
  out[key] = value;
277
+ return;
278
+ }
279
+ out[key] = value;
280
+ }
281
+ /**
282
+ * Compact a wire delta's payload in one pass, mirroring
283
+ * `compactRecord({ id: modelId, ...data })` exactly: the id key is
284
+ * processed first with the payload's own `id` winning over the envelope's,
285
+ * then each payload key in order. Passing an existing record as `out`
286
+ * makes this the update merge — compacted keys override, dropped keys
287
+ * leave the existing values untouched — without the intermediate
288
+ * id-injected and compacted copies the spread form allocates.
289
+ */
290
+ compactDeltaRecord(modelId, data, out = {}) {
291
+ const hasOwnId = Object.prototype.hasOwnProperty.call(data, 'id');
292
+ this.compactAssign(out, 'id', hasOwnId ? data.id : modelId);
293
+ for (const key in data) {
294
+ if (key === 'id')
295
+ continue;
296
+ if (!Object.prototype.hasOwnProperty.call(data, key))
297
+ continue;
298
+ this.compactAssign(out, key, data[key]);
299
+ }
300
+ if (!out.id) {
301
+ const idValue = hasOwnId ? data.id : modelId;
302
+ if (idValue)
303
+ out.id = idValue;
268
304
  }
269
- // Always ensure id is present
270
- if (!out.id && data.id)
271
- out.id = data.id;
272
305
  return out;
273
306
  }
274
307
  /**
@@ -778,43 +811,44 @@ export class Database {
778
811
  // catch-up frame. Apply the already-ordered batch directly, matching the
779
812
  // synchronous request scheduling used by the IndexedDB transaction path.
780
813
  for (const [index, delta] of deltas.entries()) {
781
- const { actionType, modelName, modelId, data, syncId } = delta;
814
+ const { actionType, modelName, modelId, data, syncId, transactionId } = delta;
782
815
  const store = this.getStore(modelName, 'processDeltaBatch');
783
816
  let single;
784
817
  if (!store || (typeof syncId === 'number' && syncId <= lastApplied)) {
785
- single = { action: 'verify', modelName, modelId };
818
+ single = { action: 'verify', modelName, modelId, transactionId };
786
819
  }
787
820
  else {
788
821
  const memoryStore = store;
789
- const dataWithId = data && typeof data === 'object'
790
- ? { id: modelId, ...data }
791
- : data;
792
- const compacted = dataWithId && typeof dataWithId === 'object'
793
- ? this.compactRecord(modelName, dataWithId)
794
- : dataWithId;
795
822
  switch (actionType) {
796
823
  case 'C':
797
- case 'I':
824
+ case 'I': {
825
+ const compacted = data && typeof data === 'object'
826
+ ? this.compactDeltaRecord(modelId, data)
827
+ : data;
798
828
  if (compacted && typeof compacted === 'object') {
799
829
  memoryStore.putSync(compacted);
800
830
  }
801
- single = { action: 'add', modelName, modelId, data: compacted };
831
+ single = { action: 'add', modelName, modelId, data: compacted, transactionId };
802
832
  break;
833
+ }
803
834
  case 'U': {
804
835
  const existing = memoryStore.getSync(modelId);
805
836
  if (!existing) {
806
- single = { action: 'verify', modelName, modelId, data: null };
837
+ single = { action: 'verify', modelName, modelId, data: null, transactionId };
807
838
  }
808
839
  else {
809
- const merged = { ...existing, ...compacted };
840
+ const merged = { ...existing };
841
+ if (data && typeof data === 'object') {
842
+ this.compactDeltaRecord(modelId, data, merged);
843
+ }
810
844
  memoryStore.putSync(merged);
811
- single = { action: 'update', modelName, modelId, data: merged };
845
+ single = { action: 'update', modelName, modelId, data: merged, transactionId };
812
846
  }
813
847
  break;
814
848
  }
815
849
  case 'D':
816
850
  memoryStore.deleteSync(modelId);
817
- single = { action: 'remove', modelName, modelId };
851
+ single = { action: 'remove', modelName, modelId, transactionId };
818
852
  break;
819
853
  case 'A': {
820
854
  const archivedData = this.compactRecord(modelName, {
@@ -823,17 +857,17 @@ export class Database {
823
857
  archivedAt: new Date(),
824
858
  });
825
859
  memoryStore.putSync(archivedData);
826
- single = { action: 'archive', modelName, modelId, data: archivedData };
860
+ single = { action: 'archive', modelName, modelId, data: archivedData, transactionId };
827
861
  break;
828
862
  }
829
863
  case 'V':
830
864
  case 'G':
831
865
  case 'S':
832
- single = { action: 'verify', modelName, modelId, data };
866
+ single = { action: 'verify', modelName, modelId, data, transactionId };
833
867
  break;
834
868
  }
835
869
  }
836
- inMemResults[index] = { ...single, transactionId: delta.transactionId };
870
+ inMemResults[index] = single;
837
871
  if (single.action !== 'verify' &&
838
872
  typeof syncId === 'number' &&
839
873
  syncId > inMemPersistedSyncId) {
@@ -35,6 +35,12 @@ export declare class InstanceCache {
35
35
  private entries;
36
36
  private typeIndex;
37
37
  private accessTimes;
38
+ /**
39
+ * Record an access. The delete-then-set moves an existing key to the back
40
+ * of the map's insertion order, which is what keeps `accessTimes` iterable
41
+ * oldest-first for eviction.
42
+ */
43
+ private touchAccess;
38
44
  private recentAdditions;
39
45
  private deltaHistory;
40
46
  private foreignKeyIndexes;
@@ -205,15 +211,19 @@ export declare class InstanceCache {
205
211
  stopGC(): void;
206
212
  private evictOldest;
207
213
  /**
208
- * Free capacity for a whole incoming frame in one scan.
214
+ * Free capacity for a whole incoming frame by taking the first eligible
215
+ * keys of the recency-ordered `accessTimes` map.
216
+ *
217
+ * Every access moves its key to the back of that map (`touchAccess`), so
218
+ * iterating from the front visits entries oldest-first — the eviction
219
+ * order — at O(count) for a sustained publication frame. The previous
220
+ * shape kept a bounded max-heap but still walked EVERY cache entry per
221
+ * incoming frame, which at a 10k cap and ~1.3k-delta frames made the scan
222
+ * itself a first-order term of the observer's apply cost.
209
223
  *
210
- * Calling evictOldest once per model made a full 10k-entry cache scan
211
- * `incomingCount` times. A 400-row delta frame therefore performed four
212
- * million entry visits before constructing models. Keep only the `count`
213
- * oldest candidates in a bounded max-heap: a full sort paid
214
- * O(cache log cache) for every sustained publication frame even though it
215
- * consumed only the first few hundred entries. The heap preserves the same
216
- * LRU/observed-model contract at O(cache log count) time and O(count) space.
224
+ * The observed-model contract is unchanged: a model React is observing is
225
+ * skipped and stays in place, so it is reconsidered (and skipped again)
226
+ * on later evictions until it is no longer observed.
217
227
  */
218
228
  private evictOldestBatch;
219
229
  private isLargeModel;
@@ -27,7 +27,21 @@ export class InstanceCache {
27
27
  // Non-observable access time tracking — kept outside observable.map so that
28
28
  // updating timestamps in get() during React render does NOT trigger MobX
29
29
  // reactions (which would cause infinite re-render loops).
30
+ //
31
+ // Every write goes through `touchAccess`, which moves the key to the map's
32
+ // back, so iteration order IS recency order (oldest first). Eviction relies
33
+ // on that: `evictOldestBatch` takes the first eligible keys instead of
34
+ // scanning every entry.
30
35
  accessTimes = new Map();
36
+ /**
37
+ * Record an access. The delete-then-set moves an existing key to the back
38
+ * of the map's insertion order, which is what keeps `accessTimes` iterable
39
+ * oldest-first for eviction.
40
+ */
41
+ touchAccess(id, at) {
42
+ this.accessTimes.delete(id);
43
+ this.accessTimes.set(id, at);
44
+ }
31
45
  // Deduplication tracking
32
46
  recentAdditions = new Map(); // "modelType:modelId" -> timestamp
33
47
  deltaHistory = new Map();
@@ -145,7 +159,7 @@ export class InstanceCache {
145
159
  if (model) {
146
160
  entry.model = model;
147
161
  if (id)
148
- this.accessTimes.set(id, Date.now());
162
+ this.touchAccess(id, Date.now());
149
163
  return model;
150
164
  }
151
165
  }
@@ -186,7 +200,7 @@ export class InstanceCache {
186
200
  return undefined;
187
201
  }
188
202
  // Update access time in non-observable map — prevents MobX reactions during render
189
- this.accessTimes.set(id, Date.now());
203
+ this.touchAccess(id, Date.now());
190
204
  this.metrics.hits++;
191
205
  model?.ensureObservable();
192
206
  return model ?? undefined;
@@ -243,7 +257,7 @@ export class InstanceCache {
243
257
  runInAction(() => {
244
258
  this.entries.set(id, { ...existingEntry, scope });
245
259
  });
246
- this.accessTimes.set(id, Date.now());
260
+ this.touchAccess(id, Date.now());
247
261
  }
248
262
  this.metrics.duplicatesSkipped++;
249
263
  return;
@@ -295,7 +309,7 @@ export class InstanceCache {
295
309
  if (this.config.useWeakRefs && this.isLargeModel(model)) {
296
310
  entry.weakRef = new WeakRef(model);
297
311
  }
298
- this.accessTimes.set(id, Date.now());
312
+ this.touchAccess(id, Date.now());
299
313
  runInAction(() => {
300
314
  this.entries.set(id, entry);
301
315
  this.addToTypeIndex(id, model.getModelName());
@@ -324,7 +338,7 @@ export class InstanceCache {
324
338
  runInAction(() => {
325
339
  this.entries.set(id, { ...existingEntry, scope });
326
340
  });
327
- this.accessTimes.set(id, Date.now());
341
+ this.touchAccess(id, Date.now());
328
342
  }
329
343
  this.notifySubscribers(existingModel);
330
344
  // Notify views of the update
@@ -366,7 +380,7 @@ export class InstanceCache {
366
380
  if (existingEntry?.model && !existingEntry.model.disposed) {
367
381
  if (existingEntry.scope !== scope) {
368
382
  this.entries.set(id, { ...existingEntry, scope });
369
- this.accessTimes.set(id, now);
383
+ this.touchAccess(id, now);
370
384
  }
371
385
  this.metrics.duplicatesSkipped++;
372
386
  continue;
@@ -375,7 +389,7 @@ export class InstanceCache {
375
389
  model,
376
390
  scope,
377
391
  };
378
- this.accessTimes.set(id, now);
392
+ this.touchAccess(id, now);
379
393
  if (this.config.useWeakRefs && this.isLargeModel(model)) {
380
394
  entry.weakRef = new WeakRef(model);
381
395
  }
@@ -417,7 +431,7 @@ export class InstanceCache {
417
431
  }
418
432
  if (existingEntry.scope !== scope) {
419
433
  this.entries.set(id, { ...existingEntry, scope });
420
- this.accessTimes.set(id, Date.now());
434
+ this.touchAccess(id, Date.now());
421
435
  }
422
436
  this.notifySubscribers(existingEntry.model);
423
437
  // Notify views of the update
@@ -433,7 +447,7 @@ export class InstanceCache {
433
447
  this.evictOldest();
434
448
  }
435
449
  const entry = { model, scope };
436
- this.accessTimes.set(id, Date.now());
450
+ this.touchAccess(id, Date.now());
437
451
  if (this.config.useWeakRefs && this.isLargeModel(model)) {
438
452
  entry.weakRef = new WeakRef(model);
439
453
  }
@@ -632,7 +646,7 @@ export class InstanceCache {
632
646
  runInAction(() => {
633
647
  this.entries.set(id, { ...entry, scope });
634
648
  });
635
- this.accessTimes.set(id, Date.now());
649
+ this.touchAccess(id, Date.now());
636
650
  }
637
651
  }
638
652
  /**
@@ -784,7 +798,7 @@ export class InstanceCache {
784
798
  // Restore access times: clear then re-add preserved
785
799
  this.accessTimes.clear();
786
800
  for (const [id, time] of preservedAccessTimes) {
787
- this.accessTimes.set(id, time);
801
+ this.touchAccess(id, time);
788
802
  }
789
803
  // No cache to invalidate — typeIndex + entries are directly observable
790
804
  }
@@ -800,7 +814,7 @@ export class InstanceCache {
800
814
  if (!entry) {
801
815
  return false;
802
816
  }
803
- this.accessTimes.set(id, Date.now());
817
+ this.touchAccess(id, Date.now());
804
818
  return true;
805
819
  }
806
820
  getAllIds() {
@@ -884,7 +898,7 @@ export class InstanceCache {
884
898
  typeof model.hasObservedCollections === 'function' &&
885
899
  model.hasObservedCollections()) {
886
900
  // Model has active React observers - refresh access time and skip GC
887
- this.accessTimes.set(id, now);
901
+ this.touchAccess(id, now);
888
902
  skippedObserved++;
889
903
  continue;
890
904
  }
@@ -949,61 +963,36 @@ export class InstanceCache {
949
963
  this.evictOldestBatch(1);
950
964
  }
951
965
  /**
952
- * Free capacity for a whole incoming frame in one scan.
966
+ * Free capacity for a whole incoming frame by taking the first eligible
967
+ * keys of the recency-ordered `accessTimes` map.
968
+ *
969
+ * Every access moves its key to the back of that map (`touchAccess`), so
970
+ * iterating from the front visits entries oldest-first — the eviction
971
+ * order — at O(count) for a sustained publication frame. The previous
972
+ * shape kept a bounded max-heap but still walked EVERY cache entry per
973
+ * incoming frame, which at a 10k cap and ~1.3k-delta frames made the scan
974
+ * itself a first-order term of the observer's apply cost.
953
975
  *
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.
976
+ * The observed-model contract is unchanged: a model React is observing is
977
+ * skipped and stays in place, so it is reconsidered (and skipped again)
978
+ * on later evictions until it is no longer observed.
961
979
  */
962
980
  evictOldestBatch(count) {
963
981
  if (count <= 0)
964
982
  return;
965
983
  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;
984
+ const toEvict = [];
985
+ const staleAccessKeys = [];
986
+ for (const id of this.accessTimes.keys()) {
987
+ if (toEvict.length >= count)
988
+ break;
989
+ const entry = this.entries.get(id);
990
+ if (!entry) {
991
+ // remove()/clear() delete from both maps, so a stale key means a
992
+ // divergence clean it up rather than let it linger.
993
+ staleAccessKeys.push(id);
994
+ continue;
1004
995
  }
1005
- };
1006
- for (const [id, entry] of this.entries) {
1007
996
  // Skip models that are being observed by React - they must stay alive
1008
997
  const model = entry.model ?? entry.weakRef?.deref();
1009
998
  if (model &&
@@ -1011,21 +1000,32 @@ export class InstanceCache {
1011
1000
  model.hasObservedCollections()) {
1012
1001
  continue;
1013
1002
  }
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();
1003
+ toEvict.push(id);
1004
+ }
1005
+ for (const id of staleAccessKeys)
1006
+ this.accessTimes.delete(id);
1007
+ // Safety net for an entry that never received an access stamp: it is
1008
+ // invisible to the recency map, so fall back to the entry scan the old
1009
+ // implementation always paid. Every add path stamps `accessTimes`, so
1010
+ // this loop finds nothing and costs nothing in the normal case (it only
1011
+ // runs at all when the recency map came up short).
1012
+ if (toEvict.length < count) {
1013
+ for (const [id, entry] of this.entries) {
1014
+ if (toEvict.length >= count)
1015
+ break;
1016
+ if (this.accessTimes.has(id))
1017
+ continue;
1018
+ const model = entry.model ?? entry.weakRef?.deref();
1019
+ if (model &&
1020
+ typeof model.hasObservedCollections === 'function' &&
1021
+ model.hasObservedCollections()) {
1022
+ continue;
1023
+ }
1024
+ toEvict.push(id);
1025
1025
  }
1026
1026
  }
1027
- for (const candidate of oldest) {
1028
- this.remove(candidate.id);
1027
+ for (const id of toEvict) {
1028
+ this.remove(id);
1029
1029
  this.metrics.evictions++;
1030
1030
  }
1031
1031
  });
@@ -89,6 +89,17 @@ export declare abstract class Model {
89
89
  private _isNew;
90
90
  /** Original data snapshot */
91
91
  private _originalData?;
92
+ /**
93
+ * Whether the persisted baseline needs recapturing. Hydration and ack land
94
+ * far more often than anything reads the baseline — an observer-only client
95
+ * reads it never — so each of those sites marks the snapshot stale and
96
+ * {@link getOriginalSnapshot} materializes it on first read. Correctness
97
+ * rests on the tracking invariant: every non-hydration write is recorded in
98
+ * `modifiedProperties` (first-old-wins), so between the stale mark and the
99
+ * read, untracked fields still hold exactly the values an eager capture
100
+ * would have recorded.
101
+ */
102
+ private _originalDataStale;
92
103
  /** Sync status */
93
104
  syncStatus: 'pending' | 'syncing' | 'synced';
94
105
  /** Timestamps */
@@ -279,6 +290,13 @@ export declare abstract class Model {
279
290
  * leaves that baseline intact.
280
291
  */
281
292
  private assignFieldsFromData;
293
+ /**
294
+ * The uncached decision for one key: existence on instance or prototype,
295
+ * never a MobX computed (its setter may throw), and writability resolved
296
+ * through the descriptor chain — a data descriptor's `writable` or an
297
+ * accessor's setter.
298
+ */
299
+ private resolveFieldDisposition;
282
300
  /**
283
301
  * Update from raw data (hydration)
284
302
  *