@abloatai/humans 0.38.0 → 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.
- package/dist/core.d.ts +1 -1
- package/dist/core.js +1 -1
- package/dist/local/BaseSyncedStore.js +7 -1
- package/dist/local/Database.d.ts +20 -0
- package/dist/local/Database.js +83 -49
- package/dist/local/InstanceCache.d.ts +18 -8
- package/dist/local/InstanceCache.js +74 -74
- package/dist/local/Model.d.ts +18 -0
- package/dist/local/Model.js +83 -32
- package/dist/local/SyncClient.d.ts +1 -4
- package/dist/local/SyncClient.js +55 -60
- package/dist/local/client/reactiveEngine.js +18 -0
- package/dist/local/sync/SyncWebSocket.js +3 -6
- package/dist/local/sync/deltaPipeline.js +11 -1
- package/dist/local/sync/drainProfile.d.ts +45 -0
- package/dist/local/sync/drainProfile.js +55 -0
- package/package.json +2 -2
- package/src/core.ts +3 -0
- package/src/local/BaseSyncedStore.ts +7 -1
- package/src/local/Database.ts +87 -50
- package/src/local/InstanceCache.ts +77 -71
- package/src/local/Model.ts +98 -37
- package/src/local/SyncClient.ts +57 -61
- package/src/local/client/reactiveEngine.ts +18 -0
- package/src/local/sync/SyncWebSocket.ts +3 -6
- package/src/local/sync/deltaPipeline.ts +21 -1
- package/src/local/sync/drainProfile.ts +93 -0
package/dist/core.d.ts
CHANGED
|
@@ -33,4 +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, type DrainProfile, type DrainStage, type DrainStageTotals, } from './local/sync/drainProfile.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
|
@@ -49,4 +49,4 @@ export { LoadStrategy } from '@abloatai/transaction/types';
|
|
|
49
49
|
// Stage timings for the delta drain, so a benchmark harness can report where
|
|
50
50
|
// an observer's catch-up time went instead of inferring it. Inert unless
|
|
51
51
|
// `ABLO_PROFILE_DRAIN=true`, and read-only: the pipeline does the recording.
|
|
52
|
-
export { drainProfileSnapshot, resetDrainProfile, drainProfilingEnabled, } from './local/sync/drainProfile.js';
|
|
52
|
+
export { drainProfileSnapshot, resetDrainProfile, drainProfilingEnabled, drainAcknowledgeStamps, } from './local/sync/drainProfile.js';
|
|
@@ -370,7 +370,13 @@ export class BaseSyncedStore {
|
|
|
370
370
|
this.smartSyncOptions = {
|
|
371
371
|
maxDeltasBeforeBootstrap: 1000,
|
|
372
372
|
maxBootstrapSize: 10 * 1024 * 1024,
|
|
373
|
-
|
|
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
|
package/dist/local/Database.d.ts
CHANGED
|
@@ -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
|
package/dist/local/Database.js
CHANGED
|
@@ -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
|
|
235
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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] =
|
|
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
|
|
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
|
-
*
|
|
211
|
-
*
|
|
212
|
-
*
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
*
|
|
955
|
-
*
|
|
956
|
-
*
|
|
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
|
|
967
|
-
const
|
|
968
|
-
|
|
969
|
-
if (
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
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
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
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
|
|
1028
|
-
this.remove(
|
|
1027
|
+
for (const id of toEvict) {
|
|
1028
|
+
this.remove(id);
|
|
1029
1029
|
this.metrics.evictions++;
|
|
1030
1030
|
}
|
|
1031
1031
|
});
|
package/dist/local/Model.d.ts
CHANGED
|
@@ -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
|
*
|