@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.
- package/dist/core.d.ts +1 -0
- package/dist/core.js +4 -0
- package/dist/local/BaseSyncedStore.d.ts +4 -2
- 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/createModelProxy.js +14 -12
- package/dist/local/client/options.d.ts +7 -0
- package/dist/local/client/reactiveEngine.js +23 -3
- package/dist/local/client/storeLifecycle.js +6 -3
- package/dist/local/stores/DatabaseManager.d.ts +2 -2
- package/dist/local/stores/DatabaseManager.js +2 -2
- package/dist/local/stores/persistenceIdentity.d.ts +7 -8
- package/dist/local/stores/persistenceIdentity.js +4 -5
- package/dist/local/sync/SyncWebSocket.d.ts +7 -0
- package/dist/local/sync/SyncWebSocket.js +21 -6
- package/dist/local/sync/deltaPipeline.js +31 -13
- package/dist/local/sync/drainProfile.d.ts +104 -0
- package/dist/local/sync/drainProfile.js +182 -0
- package/dist/local/sync/initialize.js +2 -2
- package/dist/local/transactions/mutations/MutationQueue.js +32 -12
- package/dist/local/transactions/mutations/pendingDrain.d.ts +1 -1
- package/dist/local/transactions/mutations/pendingDrain.js +2 -1
- package/package.json +2 -2
- package/src/core.ts +15 -0
- package/src/local/BaseSyncedStore.ts +11 -3
- 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/createModelProxy.ts +14 -12
- package/src/local/client/options.ts +9 -0
- package/src/local/client/reactiveEngine.ts +23 -2
- package/src/local/client/storeLifecycle.ts +10 -4
- package/src/local/stores/DatabaseManager.ts +4 -4
- package/src/local/stores/persistenceIdentity.ts +10 -12
- package/src/local/sync/SyncWebSocket.ts +20 -6
- package/src/local/sync/deltaPipeline.ts +51 -21
- package/src/local/sync/drainProfile.ts +257 -0
- package/src/local/sync/initialize.ts +2 -2
- package/src/local/transactions/mutations/MutationQueue.ts +31 -12
- package/src/local/transactions/mutations/pendingDrain.ts +7 -2
package/src/local/Database.ts
CHANGED
|
@@ -391,48 +391,85 @@ export class Database {
|
|
|
391
391
|
|
|
392
392
|
const out: ModelData = {};
|
|
393
393
|
|
|
394
|
-
for (const
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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
|
-
|
|
401
|
-
|
|
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
|
-
|
|
407
|
-
|
|
408
|
-
out[key] = value;
|
|
409
|
-
continue;
|
|
410
|
-
}
|
|
402
|
+
return out;
|
|
403
|
+
}
|
|
411
404
|
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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
|
-
|
|
426
|
-
if (Object.keys(value).length === 0) continue;
|
|
433
|
+
if (value instanceof Date) {
|
|
427
434
|
out[key] = value;
|
|
428
|
-
|
|
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
|
-
|
|
435
|
-
|
|
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
|
|
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] =
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
*
|
|
1189
|
-
*
|
|
1190
|
-
*
|
|
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
|
-
|
|
1200
|
-
const
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
if (
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
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
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
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
|
|
1263
|
-
this.remove(
|
|
1268
|
+
for (const id of toEvict) {
|
|
1269
|
+
this.remove(id);
|
|
1264
1270
|
this.metrics.evictions++;
|
|
1265
1271
|
}
|
|
1266
1272
|
});
|
package/src/local/Model.ts
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
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
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
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
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
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
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
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
|
-
|
|
623
|
-
|
|
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 =
|
|
733
|
+
this._originalData = undefined;
|
|
734
|
+
this._originalDataStale = true;
|
|
674
735
|
}
|
|
675
736
|
|
|
676
737
|
this.didUpdate();
|