@abloatai/humans 0.38.0 → 0.40.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core.d.ts +1 -1
- package/dist/core.js +1 -1
- package/dist/local/BaseSyncedStore.d.ts +9 -0
- package/dist/local/BaseSyncedStore.js +28 -1
- package/dist/local/Database.d.ts +20 -0
- package/dist/local/Database.js +83 -49
- package/dist/local/InstanceCache.d.ts +40 -8
- package/dist/local/InstanceCache.js +156 -83
- package/dist/local/Model.d.ts +28 -0
- package/dist/local/Model.js +102 -35
- package/dist/local/SyncClient.d.ts +1 -4
- package/dist/local/SyncClient.js +63 -62
- package/dist/local/client/reactiveEngine.js +18 -0
- package/dist/local/sync/SyncWebSocket.js +3 -6
- package/dist/local/sync/deltaPipeline.d.ts +32 -1
- package/dist/local/sync/deltaPipeline.js +116 -8
- package/dist/local/sync/drainProfile.d.ts +45 -0
- package/dist/local/sync/drainProfile.js +55 -0
- package/dist/local/transactions/mutations/MutationQueue.js +11 -3
- package/dist/local/utils/mobxSetup.d.ts +1 -0
- package/dist/local/utils/mobxSetup.js +5 -0
- package/package.json +2 -2
- package/src/core.ts +3 -0
- package/src/local/BaseSyncedStore.ts +37 -1
- package/src/local/Database.ts +87 -50
- package/src/local/InstanceCache.ts +162 -80
- package/src/local/Model.ts +117 -42
- package/src/local/SyncClient.ts +66 -63
- package/src/local/client/reactiveEngine.ts +18 -0
- package/src/local/sync/SyncWebSocket.ts +3 -6
- package/src/local/sync/deltaPipeline.ts +135 -9
- package/src/local/sync/drainProfile.ts +93 -0
- package/src/local/transactions/mutations/MutationQueue.ts +14 -4
- package/src/local/utils/mobxSetup.ts +5 -0
|
@@ -11,7 +11,7 @@ import { DEFER_MODEL_OBSERVABILITY, Model } from './Model.js';
|
|
|
11
11
|
import { ModelRegistry } from './ModelRegistry.js';
|
|
12
12
|
import { globalRuntime } from './context.js';
|
|
13
13
|
import { AbloValidationError } from '@abloatai/transaction/errors';
|
|
14
|
-
import { ModelScope } from '@abloatai/transaction/types';
|
|
14
|
+
import { ModelScope, PropertyType } from '@abloatai/transaction/types';
|
|
15
15
|
import { ViewRegistry } from './views/ViewRegistry.js';
|
|
16
16
|
import { QueryView } from './views/QueryView.js';
|
|
17
17
|
// Re-exported so `import { ModelScope } from './InstanceCache.js'` resolves
|
|
@@ -21,13 +21,36 @@ export { ModelScope };
|
|
|
21
21
|
* entity resolves to a single instance.
|
|
22
22
|
*/
|
|
23
23
|
export class InstanceCache {
|
|
24
|
-
// Single source of truth for all models (observable for reactivity)
|
|
25
|
-
|
|
26
|
-
|
|
24
|
+
// Single source of truth for all models (observable for reactivity).
|
|
25
|
+
// Shallow on purpose, and the reactivity contract depends on it: readers
|
|
26
|
+
// track MEMBERSHIP (keys), and every entry-level change re-sets the whole
|
|
27
|
+
// entry (see `updateScope`) — in-place field writes on an entry do not
|
|
28
|
+
// notify. The deep default additionally converted every stored ModelEntry
|
|
29
|
+
// into an observable object, a per-add extendObservable/defineProperty
|
|
30
|
+
// pass that profiled as the single largest term of create-apply on the
|
|
31
|
+
// wire-ingestion path, bought nothing the contract uses, and meant
|
|
32
|
+
// `entries.get()` returned a converted wrapper rather than the object
|
|
33
|
+
// the insert stored.
|
|
34
|
+
entries = observable.map({}, { deep: false });
|
|
35
|
+
typeIndex = observable.map({}, { deep: false });
|
|
27
36
|
// Non-observable access time tracking — kept outside observable.map so that
|
|
28
37
|
// updating timestamps in get() during React render does NOT trigger MobX
|
|
29
38
|
// reactions (which would cause infinite re-render loops).
|
|
39
|
+
//
|
|
40
|
+
// Every write goes through `touchAccess`, which moves the key to the map's
|
|
41
|
+
// back, so iteration order IS recency order (oldest first). Eviction relies
|
|
42
|
+
// on that: `evictOldestBatch` takes the first eligible keys instead of
|
|
43
|
+
// scanning every entry.
|
|
30
44
|
accessTimes = new Map();
|
|
45
|
+
/**
|
|
46
|
+
* Record an access. The delete-then-set moves an existing key to the back
|
|
47
|
+
* of the map's insertion order, which is what keeps `accessTimes` iterable
|
|
48
|
+
* oldest-first for eviction.
|
|
49
|
+
*/
|
|
50
|
+
touchAccess(id, at) {
|
|
51
|
+
this.accessTimes.delete(id);
|
|
52
|
+
this.accessTimes.set(id, at);
|
|
53
|
+
}
|
|
31
54
|
// Deduplication tracking
|
|
32
55
|
recentAdditions = new Map(); // "modelType:modelId" -> timestamp
|
|
33
56
|
deltaHistory = new Map();
|
|
@@ -145,7 +168,7 @@ export class InstanceCache {
|
|
|
145
168
|
if (model) {
|
|
146
169
|
entry.model = model;
|
|
147
170
|
if (id)
|
|
148
|
-
this.
|
|
171
|
+
this.touchAccess(id, Date.now());
|
|
149
172
|
return model;
|
|
150
173
|
}
|
|
151
174
|
}
|
|
@@ -186,11 +209,42 @@ export class InstanceCache {
|
|
|
186
209
|
return undefined;
|
|
187
210
|
}
|
|
188
211
|
// Update access time in non-observable map — prevents MobX reactions during render
|
|
189
|
-
this.
|
|
212
|
+
this.touchAccess(id, Date.now());
|
|
190
213
|
this.metrics.hits++;
|
|
191
214
|
model?.ensureObservable();
|
|
192
215
|
return model ?? undefined;
|
|
193
216
|
}
|
|
217
|
+
/**
|
|
218
|
+
* Ingestion-side lookup: resolve a row WITHOUT crossing the consumer
|
|
219
|
+
* boundary. Same resolution semantics as {@link get} — weak-ref revival,
|
|
220
|
+
* disposed rows filtered, access recency stamped — but the model is NOT
|
|
221
|
+
* activated. The delta-apply loop reads every row it updates, and reading
|
|
222
|
+
* through `get()` made the stream itself install per-field MobX
|
|
223
|
+
* instrumentation on rows no consumer ever observes; that activation is
|
|
224
|
+
* the dominant term of apply cost (measured 12.9 vs 2.9 µs/delta in
|
|
225
|
+
* `applyPool.bench.test.ts`). Consumer reads must keep using `get()`,
|
|
226
|
+
* which is what activates a deferred model.
|
|
227
|
+
*/
|
|
228
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters
|
|
229
|
+
peek(id) {
|
|
230
|
+
const entry = this.entries.get(id);
|
|
231
|
+
if (!entry)
|
|
232
|
+
return undefined;
|
|
233
|
+
let model = entry.model;
|
|
234
|
+
if (!model && entry.weakRef) {
|
|
235
|
+
const restoredModel = entry.weakRef.deref();
|
|
236
|
+
if (!restoredModel)
|
|
237
|
+
return undefined;
|
|
238
|
+
model = restoredModel;
|
|
239
|
+
runInAction(() => {
|
|
240
|
+
entry.model = restoredModel;
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
if (model?.disposed)
|
|
244
|
+
return undefined;
|
|
245
|
+
this.touchAccess(id, Date.now());
|
|
246
|
+
return model ?? undefined;
|
|
247
|
+
}
|
|
194
248
|
/**
|
|
195
249
|
* Look a row up **within one model**.
|
|
196
250
|
*
|
|
@@ -243,7 +297,7 @@ export class InstanceCache {
|
|
|
243
297
|
runInAction(() => {
|
|
244
298
|
this.entries.set(id, { ...existingEntry, scope });
|
|
245
299
|
});
|
|
246
|
-
this.
|
|
300
|
+
this.touchAccess(id, Date.now());
|
|
247
301
|
}
|
|
248
302
|
this.metrics.duplicatesSkipped++;
|
|
249
303
|
return;
|
|
@@ -295,7 +349,7 @@ export class InstanceCache {
|
|
|
295
349
|
if (this.config.useWeakRefs && this.isLargeModel(model)) {
|
|
296
350
|
entry.weakRef = new WeakRef(model);
|
|
297
351
|
}
|
|
298
|
-
this.
|
|
352
|
+
this.touchAccess(id, Date.now());
|
|
299
353
|
runInAction(() => {
|
|
300
354
|
this.entries.set(id, entry);
|
|
301
355
|
this.addToTypeIndex(id, model.getModelName());
|
|
@@ -324,7 +378,7 @@ export class InstanceCache {
|
|
|
324
378
|
runInAction(() => {
|
|
325
379
|
this.entries.set(id, { ...existingEntry, scope });
|
|
326
380
|
});
|
|
327
|
-
this.
|
|
381
|
+
this.touchAccess(id, Date.now());
|
|
328
382
|
}
|
|
329
383
|
this.notifySubscribers(existingModel);
|
|
330
384
|
// Notify views of the update
|
|
@@ -366,7 +420,7 @@ export class InstanceCache {
|
|
|
366
420
|
if (existingEntry?.model && !existingEntry.model.disposed) {
|
|
367
421
|
if (existingEntry.scope !== scope) {
|
|
368
422
|
this.entries.set(id, { ...existingEntry, scope });
|
|
369
|
-
this.
|
|
423
|
+
this.touchAccess(id, now);
|
|
370
424
|
}
|
|
371
425
|
this.metrics.duplicatesSkipped++;
|
|
372
426
|
continue;
|
|
@@ -375,7 +429,7 @@ export class InstanceCache {
|
|
|
375
429
|
model,
|
|
376
430
|
scope,
|
|
377
431
|
};
|
|
378
|
-
this.
|
|
432
|
+
this.touchAccess(id, now);
|
|
379
433
|
if (this.config.useWeakRefs && this.isLargeModel(model)) {
|
|
380
434
|
entry.weakRef = new WeakRef(model);
|
|
381
435
|
}
|
|
@@ -417,7 +471,7 @@ export class InstanceCache {
|
|
|
417
471
|
}
|
|
418
472
|
if (existingEntry.scope !== scope) {
|
|
419
473
|
this.entries.set(id, { ...existingEntry, scope });
|
|
420
|
-
this.
|
|
474
|
+
this.touchAccess(id, Date.now());
|
|
421
475
|
}
|
|
422
476
|
this.notifySubscribers(existingEntry.model);
|
|
423
477
|
// Notify views of the update
|
|
@@ -433,7 +487,7 @@ export class InstanceCache {
|
|
|
433
487
|
this.evictOldest();
|
|
434
488
|
}
|
|
435
489
|
const entry = { model, scope };
|
|
436
|
-
this.
|
|
490
|
+
this.touchAccess(id, Date.now());
|
|
437
491
|
if (this.config.useWeakRefs && this.isLargeModel(model)) {
|
|
438
492
|
entry.weakRef = new WeakRef(model);
|
|
439
493
|
}
|
|
@@ -574,6 +628,11 @@ export class InstanceCache {
|
|
|
574
628
|
continue;
|
|
575
629
|
const model = this.resolveModel(entry, id);
|
|
576
630
|
if (model && !model.disposed) {
|
|
631
|
+
// Bulk reads are a consumer boundary: rows land cold from the wire
|
|
632
|
+
// (delta ingestion applies with `peek` and never activates), so
|
|
633
|
+
// every read that hands models out must activate them, exactly as
|
|
634
|
+
// `get()` does. Idempotent — an active row pays one boolean check.
|
|
635
|
+
model.ensureObservable();
|
|
577
636
|
result.push(model);
|
|
578
637
|
}
|
|
579
638
|
}
|
|
@@ -599,6 +658,8 @@ export class InstanceCache {
|
|
|
599
658
|
continue;
|
|
600
659
|
const model = this.resolveModel(entry, id);
|
|
601
660
|
if (model && !model.disposed) {
|
|
661
|
+
// Consumer boundary — activate, as in getByType.
|
|
662
|
+
model.ensureObservable();
|
|
602
663
|
result.push(model);
|
|
603
664
|
}
|
|
604
665
|
}
|
|
@@ -632,7 +693,7 @@ export class InstanceCache {
|
|
|
632
693
|
runInAction(() => {
|
|
633
694
|
this.entries.set(id, { ...entry, scope });
|
|
634
695
|
});
|
|
635
|
-
this.
|
|
696
|
+
this.touchAccess(id, Date.now());
|
|
636
697
|
}
|
|
637
698
|
}
|
|
638
699
|
/**
|
|
@@ -784,7 +845,7 @@ export class InstanceCache {
|
|
|
784
845
|
// Restore access times: clear then re-add preserved
|
|
785
846
|
this.accessTimes.clear();
|
|
786
847
|
for (const [id, time] of preservedAccessTimes) {
|
|
787
|
-
this.
|
|
848
|
+
this.touchAccess(id, time);
|
|
788
849
|
}
|
|
789
850
|
// No cache to invalidate — typeIndex + entries are directly observable
|
|
790
851
|
}
|
|
@@ -800,7 +861,7 @@ export class InstanceCache {
|
|
|
800
861
|
if (!entry) {
|
|
801
862
|
return false;
|
|
802
863
|
}
|
|
803
|
-
this.
|
|
864
|
+
this.touchAccess(id, Date.now());
|
|
804
865
|
return true;
|
|
805
866
|
}
|
|
806
867
|
getAllIds() {
|
|
@@ -884,7 +945,7 @@ export class InstanceCache {
|
|
|
884
945
|
typeof model.hasObservedCollections === 'function' &&
|
|
885
946
|
model.hasObservedCollections()) {
|
|
886
947
|
// Model has active React observers - refresh access time and skip GC
|
|
887
|
-
this.
|
|
948
|
+
this.touchAccess(id, now);
|
|
888
949
|
skippedObserved++;
|
|
889
950
|
continue;
|
|
890
951
|
}
|
|
@@ -949,61 +1010,36 @@ export class InstanceCache {
|
|
|
949
1010
|
this.evictOldestBatch(1);
|
|
950
1011
|
}
|
|
951
1012
|
/**
|
|
952
|
-
* Free capacity for a whole incoming frame
|
|
1013
|
+
* Free capacity for a whole incoming frame by taking the first eligible
|
|
1014
|
+
* keys of the recency-ordered `accessTimes` map.
|
|
953
1015
|
*
|
|
954
|
-
*
|
|
955
|
-
*
|
|
956
|
-
*
|
|
957
|
-
*
|
|
958
|
-
*
|
|
959
|
-
*
|
|
960
|
-
*
|
|
1016
|
+
* Every access moves its key to the back of that map (`touchAccess`), so
|
|
1017
|
+
* iterating from the front visits entries oldest-first — the eviction
|
|
1018
|
+
* order — at O(count) for a sustained publication frame. The previous
|
|
1019
|
+
* shape kept a bounded max-heap but still walked EVERY cache entry per
|
|
1020
|
+
* incoming frame, which at a 10k cap and ~1.3k-delta frames made the scan
|
|
1021
|
+
* itself a first-order term of the observer's apply cost.
|
|
1022
|
+
*
|
|
1023
|
+
* The observed-model contract is unchanged: a model React is observing is
|
|
1024
|
+
* skipped and stays in place, so it is reconsidered (and skipped again)
|
|
1025
|
+
* on later evictions until it is no longer observed.
|
|
961
1026
|
*/
|
|
962
1027
|
evictOldestBatch(count) {
|
|
963
1028
|
if (count <= 0)
|
|
964
1029
|
return;
|
|
965
1030
|
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;
|
|
1031
|
+
const toEvict = [];
|
|
1032
|
+
const staleAccessKeys = [];
|
|
1033
|
+
for (const id of this.accessTimes.keys()) {
|
|
1034
|
+
if (toEvict.length >= count)
|
|
1035
|
+
break;
|
|
1036
|
+
const entry = this.entries.get(id);
|
|
1037
|
+
if (!entry) {
|
|
1038
|
+
// remove()/clear() delete from both maps, so a stale key means a
|
|
1039
|
+
// divergence — clean it up rather than let it linger.
|
|
1040
|
+
staleAccessKeys.push(id);
|
|
1041
|
+
continue;
|
|
1004
1042
|
}
|
|
1005
|
-
};
|
|
1006
|
-
for (const [id, entry] of this.entries) {
|
|
1007
1043
|
// Skip models that are being observed by React - they must stay alive
|
|
1008
1044
|
const model = entry.model ?? entry.weakRef?.deref();
|
|
1009
1045
|
if (model &&
|
|
@@ -1011,33 +1047,67 @@ export class InstanceCache {
|
|
|
1011
1047
|
model.hasObservedCollections()) {
|
|
1012
1048
|
continue;
|
|
1013
1049
|
}
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1050
|
+
toEvict.push(id);
|
|
1051
|
+
}
|
|
1052
|
+
for (const id of staleAccessKeys)
|
|
1053
|
+
this.accessTimes.delete(id);
|
|
1054
|
+
// Safety net for an entry that never received an access stamp: it is
|
|
1055
|
+
// invisible to the recency map, so fall back to the entry scan the old
|
|
1056
|
+
// implementation always paid. Every add path stamps `accessTimes`, so
|
|
1057
|
+
// this loop finds nothing and costs nothing in the normal case (it only
|
|
1058
|
+
// runs at all when the recency map came up short).
|
|
1059
|
+
if (toEvict.length < count) {
|
|
1060
|
+
for (const [id, entry] of this.entries) {
|
|
1061
|
+
if (toEvict.length >= count)
|
|
1062
|
+
break;
|
|
1063
|
+
if (this.accessTimes.has(id))
|
|
1064
|
+
continue;
|
|
1065
|
+
const model = entry.model ?? entry.weakRef?.deref();
|
|
1066
|
+
if (model &&
|
|
1067
|
+
typeof model.hasObservedCollections === 'function' &&
|
|
1068
|
+
model.hasObservedCollections()) {
|
|
1069
|
+
continue;
|
|
1070
|
+
}
|
|
1071
|
+
toEvict.push(id);
|
|
1025
1072
|
}
|
|
1026
1073
|
}
|
|
1027
|
-
for (const
|
|
1028
|
-
this.remove(
|
|
1074
|
+
for (const id of toEvict) {
|
|
1075
|
+
this.remove(id);
|
|
1029
1076
|
this.metrics.evictions++;
|
|
1030
1077
|
}
|
|
1031
1078
|
});
|
|
1032
1079
|
}
|
|
1080
|
+
/**
|
|
1081
|
+
* Data-field keys to probe per model, resolved once per model name. Only
|
|
1082
|
+
* stored-value fields can carry bulk; walking the whole instance also
|
|
1083
|
+
* visited the base class's bookkeeping (`_mobxProperties`,
|
|
1084
|
+
* `modifiedProperties`, `validationRules`, …) and JSON-stringified each of
|
|
1085
|
+
* those empty containers on EVERY add — a first-order term of wire-ingest
|
|
1086
|
+
* create apply. Reference/computed keys are deliberately excluded: reading
|
|
1087
|
+
* them would execute their getters.
|
|
1088
|
+
*/
|
|
1089
|
+
sizeProbeKeys = new Map();
|
|
1033
1090
|
isLargeModel(model) {
|
|
1034
1091
|
try {
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1092
|
+
const modelName = model.getModelName();
|
|
1093
|
+
let keys = this.sizeProbeKeys.get(modelName);
|
|
1094
|
+
if (!keys) {
|
|
1095
|
+
const props = this.registry.getProperties(modelName);
|
|
1096
|
+
keys = [...props.entries()]
|
|
1097
|
+
.filter(([, meta]) => meta.type === PropertyType.property || meta.type === PropertyType.ephemeralProperty)
|
|
1098
|
+
.map(([key]) => key);
|
|
1099
|
+
this.sizeProbeKeys.set(modelName, keys);
|
|
1100
|
+
}
|
|
1101
|
+
// A model with no registered stored fields (e.g. a custom entity) keeps
|
|
1102
|
+
// the whole-instance walk, since its payload lives outside the schema.
|
|
1103
|
+
const values = keys.length > 0
|
|
1104
|
+
? keys.map((key) => Reflect.get(model, key))
|
|
1105
|
+
: Object.values(model);
|
|
1106
|
+
// Most synchronized rows contain only short scalars. Accumulate cheap
|
|
1107
|
+
// scalar sizes and serialize only nested payloads that can plausibly
|
|
1108
|
+
// cross the WeakRef threshold.
|
|
1039
1109
|
let size = 0;
|
|
1040
|
-
for (const value of
|
|
1110
|
+
for (const value of values) {
|
|
1041
1111
|
if (typeof value === 'string') {
|
|
1042
1112
|
size += value.length;
|
|
1043
1113
|
}
|
|
@@ -1134,6 +1204,9 @@ export class InstanceCache {
|
|
|
1134
1204
|
}
|
|
1135
1205
|
const model = this.resolveModel(entry, id);
|
|
1136
1206
|
if (model && !model.disposed) {
|
|
1207
|
+
// Consumer boundary — activate, as in getByType. `hasMany` reads
|
|
1208
|
+
// (`section.blocks`) observe fields off these rows directly.
|
|
1209
|
+
model.ensureObservable();
|
|
1137
1210
|
result.push(model);
|
|
1138
1211
|
}
|
|
1139
1212
|
else if (model?.disposed) {
|
package/dist/local/Model.d.ts
CHANGED
|
@@ -81,6 +81,16 @@ export declare abstract class Model {
|
|
|
81
81
|
clientId: string;
|
|
82
82
|
/** MobX observable properties storage */
|
|
83
83
|
_mobxProperties: ModelData;
|
|
84
|
+
/**
|
|
85
|
+
* True while {@link updateFromData} assigns inbound wire data. The
|
|
86
|
+
* `observe()` bridge (`mobxSetup.M1`) checks it and skips the forward to
|
|
87
|
+
* {@link propertyChanged} entirely: hydration writes are not user edits, and
|
|
88
|
+
* the bridge's work per changed field — a nested action, map bookkeeping,
|
|
89
|
+
* and a fabricated `updatedAt` stamp — was already being discarded by the
|
|
90
|
+
* `modifiedProperties` swap in `updateFromData`. The swap stays as the
|
|
91
|
+
* correctness backstop; this flag removes the cost.
|
|
92
|
+
*/
|
|
93
|
+
_isHydrating: boolean;
|
|
84
94
|
/** Referenced models cache */
|
|
85
95
|
_referencedModels: Record<string, Model | null>;
|
|
86
96
|
/** Track property changes */
|
|
@@ -89,6 +99,17 @@ export declare abstract class Model {
|
|
|
89
99
|
private _isNew;
|
|
90
100
|
/** Original data snapshot */
|
|
91
101
|
private _originalData?;
|
|
102
|
+
/**
|
|
103
|
+
* Whether the persisted baseline needs recapturing. Hydration and ack land
|
|
104
|
+
* far more often than anything reads the baseline — an observer-only client
|
|
105
|
+
* reads it never — so each of those sites marks the snapshot stale and
|
|
106
|
+
* {@link getOriginalSnapshot} materializes it on first read. Correctness
|
|
107
|
+
* rests on the tracking invariant: every non-hydration write is recorded in
|
|
108
|
+
* `modifiedProperties` (first-old-wins), so between the stale mark and the
|
|
109
|
+
* read, untracked fields still hold exactly the values an eager capture
|
|
110
|
+
* would have recorded.
|
|
111
|
+
*/
|
|
112
|
+
private _originalDataStale;
|
|
92
113
|
/** Sync status */
|
|
93
114
|
syncStatus: 'pending' | 'syncing' | 'synced';
|
|
94
115
|
/** Timestamps */
|
|
@@ -279,6 +300,13 @@ export declare abstract class Model {
|
|
|
279
300
|
* leaves that baseline intact.
|
|
280
301
|
*/
|
|
281
302
|
private assignFieldsFromData;
|
|
303
|
+
/**
|
|
304
|
+
* The uncached decision for one key: existence on instance or prototype,
|
|
305
|
+
* never a MobX computed (its setter may throw), and writability resolved
|
|
306
|
+
* through the descriptor chain — a data descriptor's `writable` or an
|
|
307
|
+
* accessor's setter.
|
|
308
|
+
*/
|
|
309
|
+
private resolveFieldDisposition;
|
|
282
310
|
/**
|
|
283
311
|
* Update from raw data (hydration)
|
|
284
312
|
*
|
package/dist/local/Model.js
CHANGED
|
@@ -31,6 +31,7 @@ export class ValidationError extends Error {
|
|
|
31
31
|
}
|
|
32
32
|
/** Shared frozen default for {@link Model.getDerivedGetterNames}. */
|
|
33
33
|
const EMPTY_DERIVED_GETTERS = Object.freeze([]);
|
|
34
|
+
const fieldWritePlans = new WeakMap();
|
|
34
35
|
/**
|
|
35
36
|
* The abstract base class every domain model extends. It holds the model's id
|
|
36
37
|
* and timestamps, tracks in-place property changes for change detection and
|
|
@@ -46,6 +47,16 @@ export class Model {
|
|
|
46
47
|
clientId;
|
|
47
48
|
/** MobX observable properties storage */
|
|
48
49
|
_mobxProperties = {};
|
|
50
|
+
/**
|
|
51
|
+
* True while {@link updateFromData} assigns inbound wire data. The
|
|
52
|
+
* `observe()` bridge (`mobxSetup.M1`) checks it and skips the forward to
|
|
53
|
+
* {@link propertyChanged} entirely: hydration writes are not user edits, and
|
|
54
|
+
* the bridge's work per changed field — a nested action, map bookkeeping,
|
|
55
|
+
* and a fabricated `updatedAt` stamp — was already being discarded by the
|
|
56
|
+
* `modifiedProperties` swap in `updateFromData`. The swap stays as the
|
|
57
|
+
* correctness backstop; this flag removes the cost.
|
|
58
|
+
*/
|
|
59
|
+
_isHydrating = false;
|
|
49
60
|
/** Referenced models cache */
|
|
50
61
|
_referencedModels = {};
|
|
51
62
|
/** Track property changes */
|
|
@@ -54,6 +65,17 @@ export class Model {
|
|
|
54
65
|
_isNew = true;
|
|
55
66
|
/** Original data snapshot */
|
|
56
67
|
_originalData;
|
|
68
|
+
/**
|
|
69
|
+
* Whether the persisted baseline needs recapturing. Hydration and ack land
|
|
70
|
+
* far more often than anything reads the baseline — an observer-only client
|
|
71
|
+
* reads it never — so each of those sites marks the snapshot stale and
|
|
72
|
+
* {@link getOriginalSnapshot} materializes it on first read. Correctness
|
|
73
|
+
* rests on the tracking invariant: every non-hydration write is recorded in
|
|
74
|
+
* `modifiedProperties` (first-old-wins), so between the stale mark and the
|
|
75
|
+
* read, untracked fields still hold exactly the values an eager capture
|
|
76
|
+
* would have recorded.
|
|
77
|
+
*/
|
|
78
|
+
_originalDataStale = false;
|
|
57
79
|
/** Sync status */
|
|
58
80
|
syncStatus = 'pending';
|
|
59
81
|
/** Timestamps */
|
|
@@ -208,7 +230,8 @@ export class Model {
|
|
|
208
230
|
*/
|
|
209
231
|
markAsPersisted() {
|
|
210
232
|
this._isNew = false;
|
|
211
|
-
this._originalData =
|
|
233
|
+
this._originalData = undefined;
|
|
234
|
+
this._originalDataStale = true;
|
|
212
235
|
}
|
|
213
236
|
/**
|
|
214
237
|
* Check if this is a new model
|
|
@@ -230,6 +253,10 @@ export class Model {
|
|
|
230
253
|
* last acknowledged state would already be the authoritative baseline.
|
|
231
254
|
*/
|
|
232
255
|
getOriginalSnapshot() {
|
|
256
|
+
if (this._originalDataStale) {
|
|
257
|
+
this._originalData = this.captureSnapshot();
|
|
258
|
+
this._originalDataStale = false;
|
|
259
|
+
}
|
|
233
260
|
return this._originalData;
|
|
234
261
|
}
|
|
235
262
|
/**
|
|
@@ -238,7 +265,8 @@ export class Model {
|
|
|
238
265
|
clearChanges() {
|
|
239
266
|
runInAction(() => {
|
|
240
267
|
this.modifiedProperties.clear();
|
|
241
|
-
this._originalData =
|
|
268
|
+
this._originalData = undefined;
|
|
269
|
+
this._originalDataStale = true;
|
|
242
270
|
});
|
|
243
271
|
}
|
|
244
272
|
/**
|
|
@@ -471,43 +499,39 @@ export class Model {
|
|
|
471
499
|
* leaves that baseline intact.
|
|
472
500
|
*/
|
|
473
501
|
assignFieldsFromData(data, onWrite) {
|
|
474
|
-
|
|
475
|
-
|
|
502
|
+
let plan = fieldWritePlans.get(this.constructor);
|
|
503
|
+
if (!plan) {
|
|
504
|
+
plan = new Map();
|
|
505
|
+
fieldWritePlans.set(this.constructor, plan);
|
|
506
|
+
}
|
|
507
|
+
for (const key in data) {
|
|
476
508
|
if (key === 'id')
|
|
477
509
|
continue;
|
|
478
|
-
|
|
479
|
-
if (!(this.hasOwnProperty(key) || key in this))
|
|
510
|
+
if (!Object.prototype.hasOwnProperty.call(data, key))
|
|
480
511
|
continue;
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
512
|
+
const raw = data[key];
|
|
513
|
+
let disposition = plan.get(key);
|
|
514
|
+
if (disposition === undefined) {
|
|
515
|
+
disposition = this.resolveFieldDisposition(key);
|
|
516
|
+
plan.set(key, disposition);
|
|
486
517
|
}
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
}
|
|
518
|
+
if (disposition === 'skip-readonly')
|
|
519
|
+
continue;
|
|
520
|
+
if (disposition === 'skip-absent') {
|
|
521
|
+
// The plan was built from an instance that lacked this key. Re-resolve
|
|
522
|
+
// for this instance without touching the cache, so heterogeneous
|
|
523
|
+
// shapes stay correct at the cost of the slow path.
|
|
524
|
+
if (!(key in this))
|
|
525
|
+
continue;
|
|
526
|
+
disposition = this.resolveFieldDisposition(key);
|
|
527
|
+
if (disposition === 'skip-readonly' || disposition === 'skip-absent')
|
|
528
|
+
continue;
|
|
499
529
|
}
|
|
500
|
-
|
|
501
|
-
const writable = desc
|
|
502
|
-
? ('writable' in desc && !!desc.writable) ||
|
|
503
|
-
('set' in desc && typeof desc.set === 'function')
|
|
504
|
-
: true;
|
|
505
|
-
if (!writable) {
|
|
506
|
-
// Skip read-only accessor properties (getter-only)
|
|
530
|
+
else if (!(key in this)) {
|
|
507
531
|
continue;
|
|
508
532
|
}
|
|
509
533
|
// Handle date conversions
|
|
510
|
-
const value =
|
|
534
|
+
const value = disposition === 'write-date' && raw
|
|
511
535
|
? new Date(raw)
|
|
512
536
|
: raw;
|
|
513
537
|
// Capture the pre-write value BEFORE assignment so trackers
|
|
@@ -518,6 +542,42 @@ export class Model {
|
|
|
518
542
|
onWrite?.(key, oldValue, value);
|
|
519
543
|
}
|
|
520
544
|
}
|
|
545
|
+
/**
|
|
546
|
+
* The uncached decision for one key: existence on instance or prototype,
|
|
547
|
+
* never a MobX computed (its setter may throw), and writability resolved
|
|
548
|
+
* through the descriptor chain — a data descriptor's `writable` or an
|
|
549
|
+
* accessor's setter.
|
|
550
|
+
*/
|
|
551
|
+
resolveFieldDisposition(key) {
|
|
552
|
+
if (!(key in this))
|
|
553
|
+
return 'skip-absent';
|
|
554
|
+
try {
|
|
555
|
+
if (isComputedProp(this, key)) {
|
|
556
|
+
return 'skip-readonly';
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
catch {
|
|
560
|
+
// If MobX internals are unavailable for some reason, fall back to descriptor checks below
|
|
561
|
+
}
|
|
562
|
+
const ownDesc = Object.getOwnPropertyDescriptor(this, key);
|
|
563
|
+
let desc = ownDesc;
|
|
564
|
+
if (!desc) {
|
|
565
|
+
let proto = Object.getPrototypeOf(this);
|
|
566
|
+
while (proto && proto !== Object.prototype && !desc) {
|
|
567
|
+
desc = Object.getOwnPropertyDescriptor(proto, key);
|
|
568
|
+
proto = Object.getPrototypeOf(proto);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
const writable = desc
|
|
572
|
+
? ('writable' in desc && !!desc.writable) ||
|
|
573
|
+
('set' in desc && typeof desc.set === 'function')
|
|
574
|
+
: true;
|
|
575
|
+
if (!writable)
|
|
576
|
+
return 'skip-readonly';
|
|
577
|
+
return key === 'createdAt' || key === 'updatedAt' || key === 'archivedAt'
|
|
578
|
+
? 'write-date'
|
|
579
|
+
: 'write';
|
|
580
|
+
}
|
|
521
581
|
/**
|
|
522
582
|
* Update from raw data (hydration)
|
|
523
583
|
*
|
|
@@ -544,13 +604,20 @@ export class Model {
|
|
|
544
604
|
runInAction(() => {
|
|
545
605
|
const originalTracking = this.modifiedProperties;
|
|
546
606
|
this.modifiedProperties = new Map();
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
607
|
+
this._isHydrating = true;
|
|
608
|
+
try {
|
|
609
|
+
// No `onWrite` → this call records nothing itself.
|
|
610
|
+
this.assignFieldsFromData(data);
|
|
611
|
+
}
|
|
612
|
+
finally {
|
|
613
|
+
this._isHydrating = false;
|
|
614
|
+
this.modifiedProperties = originalTracking;
|
|
615
|
+
}
|
|
550
616
|
});
|
|
551
617
|
// Mark as persisted if updating existing model
|
|
552
618
|
if (!this._isNew) {
|
|
553
|
-
this._originalData =
|
|
619
|
+
this._originalData = undefined;
|
|
620
|
+
this._originalDataStale = true;
|
|
554
621
|
}
|
|
555
622
|
this.didUpdate();
|
|
556
623
|
}
|