@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
|
@@ -13,7 +13,7 @@ import { ModelRegistry } from './ModelRegistry.js';
|
|
|
13
13
|
import { globalRuntime } from './context.js';
|
|
14
14
|
import type { RuntimeContext } from './RuntimeContext.js';
|
|
15
15
|
import { AbloValidationError } from '@abloatai/transaction/errors';
|
|
16
|
-
import { ModelScope } from '@abloatai/transaction/types';
|
|
16
|
+
import { ModelScope, PropertyType } from '@abloatai/transaction/types';
|
|
17
17
|
import { ViewRegistry } from './views/ViewRegistry.js';
|
|
18
18
|
import { QueryView, type QueryViewOptions } from './views/QueryView.js';
|
|
19
19
|
|
|
@@ -48,15 +48,39 @@ interface DeltaInfo {
|
|
|
48
48
|
* entity resolves to a single instance.
|
|
49
49
|
*/
|
|
50
50
|
export class InstanceCache {
|
|
51
|
-
// Single source of truth for all models (observable for reactivity)
|
|
52
|
-
|
|
53
|
-
|
|
51
|
+
// Single source of truth for all models (observable for reactivity).
|
|
52
|
+
// Shallow on purpose, and the reactivity contract depends on it: readers
|
|
53
|
+
// track MEMBERSHIP (keys), and every entry-level change re-sets the whole
|
|
54
|
+
// entry (see `updateScope`) — in-place field writes on an entry do not
|
|
55
|
+
// notify. The deep default additionally converted every stored ModelEntry
|
|
56
|
+
// into an observable object, a per-add extendObservable/defineProperty
|
|
57
|
+
// pass that profiled as the single largest term of create-apply on the
|
|
58
|
+
// wire-ingestion path, bought nothing the contract uses, and meant
|
|
59
|
+
// `entries.get()` returned a converted wrapper rather than the object
|
|
60
|
+
// the insert stored.
|
|
61
|
+
private entries = observable.map<string, ModelEntry>({}, { deep: false });
|
|
62
|
+
private typeIndex = observable.map<string, Set<string>>({}, { deep: false });
|
|
54
63
|
|
|
55
64
|
// Non-observable access time tracking — kept outside observable.map so that
|
|
56
65
|
// updating timestamps in get() during React render does NOT trigger MobX
|
|
57
66
|
// reactions (which would cause infinite re-render loops).
|
|
67
|
+
//
|
|
68
|
+
// Every write goes through `touchAccess`, which moves the key to the map's
|
|
69
|
+
// back, so iteration order IS recency order (oldest first). Eviction relies
|
|
70
|
+
// on that: `evictOldestBatch` takes the first eligible keys instead of
|
|
71
|
+
// scanning every entry.
|
|
58
72
|
private accessTimes = new Map<string, number>();
|
|
59
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Record an access. The delete-then-set moves an existing key to the back
|
|
76
|
+
* of the map's insertion order, which is what keeps `accessTimes` iterable
|
|
77
|
+
* oldest-first for eviction.
|
|
78
|
+
*/
|
|
79
|
+
private touchAccess(id: string, at: number): void {
|
|
80
|
+
this.accessTimes.delete(id);
|
|
81
|
+
this.accessTimes.set(id, at);
|
|
82
|
+
}
|
|
83
|
+
|
|
60
84
|
// Deduplication tracking
|
|
61
85
|
private recentAdditions = new Map<string, number>(); // "modelType:modelId" -> timestamp
|
|
62
86
|
private deltaHistory = new Map<
|
|
@@ -203,7 +227,7 @@ export class InstanceCache {
|
|
|
203
227
|
const model = entry.weakRef.deref();
|
|
204
228
|
if (model) {
|
|
205
229
|
entry.model = model;
|
|
206
|
-
if (id) this.
|
|
230
|
+
if (id) this.touchAccess(id, Date.now());
|
|
207
231
|
return model;
|
|
208
232
|
}
|
|
209
233
|
}
|
|
@@ -250,13 +274,45 @@ export class InstanceCache {
|
|
|
250
274
|
}
|
|
251
275
|
|
|
252
276
|
// Update access time in non-observable map — prevents MobX reactions during render
|
|
253
|
-
this.
|
|
277
|
+
this.touchAccess(id, Date.now());
|
|
254
278
|
this.metrics.hits++;
|
|
255
279
|
|
|
256
280
|
model?.ensureObservable();
|
|
257
281
|
return model ?? undefined;
|
|
258
282
|
}
|
|
259
283
|
|
|
284
|
+
/**
|
|
285
|
+
* Ingestion-side lookup: resolve a row WITHOUT crossing the consumer
|
|
286
|
+
* boundary. Same resolution semantics as {@link get} — weak-ref revival,
|
|
287
|
+
* disposed rows filtered, access recency stamped — but the model is NOT
|
|
288
|
+
* activated. The delta-apply loop reads every row it updates, and reading
|
|
289
|
+
* through `get()` made the stream itself install per-field MobX
|
|
290
|
+
* instrumentation on rows no consumer ever observes; that activation is
|
|
291
|
+
* the dominant term of apply cost (measured 12.9 vs 2.9 µs/delta in
|
|
292
|
+
* `applyPool.bench.test.ts`). Consumer reads must keep using `get()`,
|
|
293
|
+
* which is what activates a deferred model.
|
|
294
|
+
*/
|
|
295
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters
|
|
296
|
+
peek<T extends Model = Model>(id: string): T | undefined {
|
|
297
|
+
const entry = this.entries.get(id);
|
|
298
|
+
if (!entry) return undefined;
|
|
299
|
+
|
|
300
|
+
let model = entry.model as T | undefined;
|
|
301
|
+
if (!model && entry.weakRef) {
|
|
302
|
+
const restoredModel = entry.weakRef.deref();
|
|
303
|
+
if (!restoredModel) return undefined;
|
|
304
|
+
model = restoredModel as T;
|
|
305
|
+
runInAction(() => {
|
|
306
|
+
entry.model = restoredModel;
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (model?.disposed) return undefined;
|
|
311
|
+
|
|
312
|
+
this.touchAccess(id, Date.now());
|
|
313
|
+
return model ?? undefined;
|
|
314
|
+
}
|
|
315
|
+
|
|
260
316
|
/**
|
|
261
317
|
* Look a row up **within one model**.
|
|
262
318
|
*
|
|
@@ -312,7 +368,7 @@ export class InstanceCache {
|
|
|
312
368
|
runInAction(() => {
|
|
313
369
|
this.entries.set(id, { ...existingEntry, scope });
|
|
314
370
|
});
|
|
315
|
-
this.
|
|
371
|
+
this.touchAccess(id, Date.now());
|
|
316
372
|
}
|
|
317
373
|
this.metrics.duplicatesSkipped++;
|
|
318
374
|
return;
|
|
@@ -381,7 +437,7 @@ export class InstanceCache {
|
|
|
381
437
|
entry.weakRef = new WeakRef(model);
|
|
382
438
|
}
|
|
383
439
|
|
|
384
|
-
this.
|
|
440
|
+
this.touchAccess(id, Date.now());
|
|
385
441
|
runInAction(() => {
|
|
386
442
|
this.entries.set(id, entry);
|
|
387
443
|
this.addToTypeIndex(id, model.getModelName());
|
|
@@ -415,7 +471,7 @@ export class InstanceCache {
|
|
|
415
471
|
runInAction(() => {
|
|
416
472
|
this.entries.set(id, { ...existingEntry, scope });
|
|
417
473
|
});
|
|
418
|
-
this.
|
|
474
|
+
this.touchAccess(id, Date.now());
|
|
419
475
|
}
|
|
420
476
|
|
|
421
477
|
this.notifySubscribers(existingModel);
|
|
@@ -462,7 +518,7 @@ export class InstanceCache {
|
|
|
462
518
|
if (existingEntry?.model && !existingEntry.model.disposed) {
|
|
463
519
|
if (existingEntry.scope !== scope) {
|
|
464
520
|
this.entries.set(id, { ...existingEntry, scope });
|
|
465
|
-
this.
|
|
521
|
+
this.touchAccess(id, now);
|
|
466
522
|
}
|
|
467
523
|
this.metrics.duplicatesSkipped++;
|
|
468
524
|
continue;
|
|
@@ -472,7 +528,7 @@ export class InstanceCache {
|
|
|
472
528
|
model,
|
|
473
529
|
scope,
|
|
474
530
|
};
|
|
475
|
-
this.
|
|
531
|
+
this.touchAccess(id, now);
|
|
476
532
|
|
|
477
533
|
if (this.config.useWeakRefs && this.isLargeModel(model)) {
|
|
478
534
|
entry.weakRef = new WeakRef(model);
|
|
@@ -521,7 +577,7 @@ export class InstanceCache {
|
|
|
521
577
|
}
|
|
522
578
|
if (existingEntry.scope !== scope) {
|
|
523
579
|
this.entries.set(id, { ...existingEntry, scope });
|
|
524
|
-
this.
|
|
580
|
+
this.touchAccess(id, Date.now());
|
|
525
581
|
}
|
|
526
582
|
this.notifySubscribers(existingEntry.model);
|
|
527
583
|
// Notify views of the update
|
|
@@ -536,7 +592,7 @@ export class InstanceCache {
|
|
|
536
592
|
this.evictOldest();
|
|
537
593
|
}
|
|
538
594
|
const entry: ModelEntry = { model, scope };
|
|
539
|
-
this.
|
|
595
|
+
this.touchAccess(id, Date.now());
|
|
540
596
|
if (this.config.useWeakRefs && this.isLargeModel(model)) {
|
|
541
597
|
entry.weakRef = new WeakRef(model);
|
|
542
598
|
}
|
|
@@ -705,6 +761,11 @@ export class InstanceCache {
|
|
|
705
761
|
|
|
706
762
|
const model = this.resolveModel(entry, id);
|
|
707
763
|
if (model && !model.disposed) {
|
|
764
|
+
// Bulk reads are a consumer boundary: rows land cold from the wire
|
|
765
|
+
// (delta ingestion applies with `peek` and never activates), so
|
|
766
|
+
// every read that hands models out must activate them, exactly as
|
|
767
|
+
// `get()` does. Idempotent — an active row pays one boolean check.
|
|
768
|
+
model.ensureObservable();
|
|
708
769
|
result.push(model);
|
|
709
770
|
}
|
|
710
771
|
}
|
|
@@ -732,6 +793,8 @@ export class InstanceCache {
|
|
|
732
793
|
|
|
733
794
|
const model = this.resolveModel(entry, id);
|
|
734
795
|
if (model && !model.disposed) {
|
|
796
|
+
// Consumer boundary — activate, as in getByType.
|
|
797
|
+
model.ensureObservable();
|
|
735
798
|
result.push(model);
|
|
736
799
|
}
|
|
737
800
|
}
|
|
@@ -773,7 +836,7 @@ export class InstanceCache {
|
|
|
773
836
|
runInAction(() => {
|
|
774
837
|
this.entries.set(id, { ...entry, scope });
|
|
775
838
|
});
|
|
776
|
-
this.
|
|
839
|
+
this.touchAccess(id, Date.now());
|
|
777
840
|
}
|
|
778
841
|
}
|
|
779
842
|
|
|
@@ -989,7 +1052,7 @@ export class InstanceCache {
|
|
|
989
1052
|
// Restore access times: clear then re-add preserved
|
|
990
1053
|
this.accessTimes.clear();
|
|
991
1054
|
for (const [id, time] of preservedAccessTimes) {
|
|
992
|
-
this.
|
|
1055
|
+
this.touchAccess(id, time);
|
|
993
1056
|
}
|
|
994
1057
|
// No cache to invalidate — typeIndex + entries are directly observable
|
|
995
1058
|
}
|
|
@@ -1008,7 +1071,7 @@ export class InstanceCache {
|
|
|
1008
1071
|
return false;
|
|
1009
1072
|
}
|
|
1010
1073
|
|
|
1011
|
-
this.
|
|
1074
|
+
this.touchAccess(id, Date.now());
|
|
1012
1075
|
return true;
|
|
1013
1076
|
}
|
|
1014
1077
|
|
|
@@ -1107,7 +1170,7 @@ export class InstanceCache {
|
|
|
1107
1170
|
model.hasObservedCollections()
|
|
1108
1171
|
) {
|
|
1109
1172
|
// Model has active React observers - refresh access time and skip GC
|
|
1110
|
-
this.
|
|
1173
|
+
this.touchAccess(id, now);
|
|
1111
1174
|
skippedObserved++;
|
|
1112
1175
|
continue;
|
|
1113
1176
|
}
|
|
@@ -1183,59 +1246,37 @@ export class InstanceCache {
|
|
|
1183
1246
|
}
|
|
1184
1247
|
|
|
1185
1248
|
/**
|
|
1186
|
-
* Free capacity for a whole incoming frame
|
|
1249
|
+
* Free capacity for a whole incoming frame by taking the first eligible
|
|
1250
|
+
* keys of the recency-ordered `accessTimes` map.
|
|
1251
|
+
*
|
|
1252
|
+
* Every access moves its key to the back of that map (`touchAccess`), so
|
|
1253
|
+
* iterating from the front visits entries oldest-first — the eviction
|
|
1254
|
+
* order — at O(count) for a sustained publication frame. The previous
|
|
1255
|
+
* shape kept a bounded max-heap but still walked EVERY cache entry per
|
|
1256
|
+
* incoming frame, which at a 10k cap and ~1.3k-delta frames made the scan
|
|
1257
|
+
* itself a first-order term of the observer's apply cost.
|
|
1187
1258
|
*
|
|
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.
|
|
1259
|
+
* The observed-model contract is unchanged: a model React is observing is
|
|
1260
|
+
* skipped and stays in place, so it is reconsidered (and skipped again)
|
|
1261
|
+
* on later evictions until it is no longer observed.
|
|
1195
1262
|
*/
|
|
1196
1263
|
private evictOldestBatch(count: number): void {
|
|
1197
1264
|
if (count <= 0) return;
|
|
1198
1265
|
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;
|
|
1266
|
+
const toEvict: string[] = [];
|
|
1267
|
+
const staleAccessKeys: string[] = [];
|
|
1268
|
+
|
|
1269
|
+
for (const id of this.accessTimes.keys()) {
|
|
1270
|
+
if (toEvict.length >= count) break;
|
|
1271
|
+
|
|
1272
|
+
const entry = this.entries.get(id);
|
|
1273
|
+
if (!entry) {
|
|
1274
|
+
// remove()/clear() delete from both maps, so a stale key means a
|
|
1275
|
+
// divergence — clean it up rather than let it linger.
|
|
1276
|
+
staleAccessKeys.push(id);
|
|
1277
|
+
continue;
|
|
1235
1278
|
}
|
|
1236
|
-
};
|
|
1237
1279
|
|
|
1238
|
-
for (const [id, entry] of this.entries) {
|
|
1239
1280
|
// Skip models that are being observed by React - they must stay alive
|
|
1240
1281
|
const model = entry.model ?? entry.weakRef?.deref();
|
|
1241
1282
|
if (
|
|
@@ -1246,34 +1287,72 @@ export class InstanceCache {
|
|
|
1246
1287
|
continue;
|
|
1247
1288
|
}
|
|
1248
1289
|
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1290
|
+
toEvict.push(id);
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
for (const id of staleAccessKeys) this.accessTimes.delete(id);
|
|
1294
|
+
|
|
1295
|
+
// Safety net for an entry that never received an access stamp: it is
|
|
1296
|
+
// invisible to the recency map, so fall back to the entry scan the old
|
|
1297
|
+
// implementation always paid. Every add path stamps `accessTimes`, so
|
|
1298
|
+
// this loop finds nothing and costs nothing in the normal case (it only
|
|
1299
|
+
// runs at all when the recency map came up short).
|
|
1300
|
+
if (toEvict.length < count) {
|
|
1301
|
+
for (const [id, entry] of this.entries) {
|
|
1302
|
+
if (toEvict.length >= count) break;
|
|
1303
|
+
if (this.accessTimes.has(id)) continue;
|
|
1304
|
+
const model = entry.model ?? entry.weakRef?.deref();
|
|
1305
|
+
if (
|
|
1306
|
+
model &&
|
|
1307
|
+
typeof model.hasObservedCollections === 'function' &&
|
|
1308
|
+
model.hasObservedCollections()
|
|
1309
|
+
) {
|
|
1310
|
+
continue;
|
|
1311
|
+
}
|
|
1312
|
+
toEvict.push(id);
|
|
1259
1313
|
}
|
|
1260
1314
|
}
|
|
1261
1315
|
|
|
1262
|
-
for (const
|
|
1263
|
-
this.remove(
|
|
1316
|
+
for (const id of toEvict) {
|
|
1317
|
+
this.remove(id);
|
|
1264
1318
|
this.metrics.evictions++;
|
|
1265
1319
|
}
|
|
1266
1320
|
});
|
|
1267
1321
|
}
|
|
1268
1322
|
|
|
1323
|
+
/**
|
|
1324
|
+
* Data-field keys to probe per model, resolved once per model name. Only
|
|
1325
|
+
* stored-value fields can carry bulk; walking the whole instance also
|
|
1326
|
+
* visited the base class's bookkeeping (`_mobxProperties`,
|
|
1327
|
+
* `modifiedProperties`, `validationRules`, …) and JSON-stringified each of
|
|
1328
|
+
* those empty containers on EVERY add — a first-order term of wire-ingest
|
|
1329
|
+
* create apply. Reference/computed keys are deliberately excluded: reading
|
|
1330
|
+
* them would execute their getters.
|
|
1331
|
+
*/
|
|
1332
|
+
private sizeProbeKeys = new Map<string, readonly string[]>();
|
|
1333
|
+
|
|
1269
1334
|
private isLargeModel(model: Model): boolean {
|
|
1270
1335
|
try {
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1336
|
+
const modelName = model.getModelName();
|
|
1337
|
+
let keys = this.sizeProbeKeys.get(modelName);
|
|
1338
|
+
if (!keys) {
|
|
1339
|
+
const props = this.registry.getProperties(modelName);
|
|
1340
|
+
keys = [...props.entries()]
|
|
1341
|
+
.filter(([, meta]) => meta.type === PropertyType.property || meta.type === PropertyType.ephemeralProperty)
|
|
1342
|
+
.map(([key]) => key);
|
|
1343
|
+
this.sizeProbeKeys.set(modelName, keys);
|
|
1344
|
+
}
|
|
1345
|
+
// A model with no registered stored fields (e.g. a custom entity) keeps
|
|
1346
|
+
// the whole-instance walk, since its payload lives outside the schema.
|
|
1347
|
+
const values: Iterable<unknown> = keys.length > 0
|
|
1348
|
+
? keys.map((key): unknown => Reflect.get(model, key))
|
|
1349
|
+
: Object.values(model);
|
|
1350
|
+
|
|
1351
|
+
// Most synchronized rows contain only short scalars. Accumulate cheap
|
|
1352
|
+
// scalar sizes and serialize only nested payloads that can plausibly
|
|
1353
|
+
// cross the WeakRef threshold.
|
|
1275
1354
|
let size = 0;
|
|
1276
|
-
for (const value of
|
|
1355
|
+
for (const value of values) {
|
|
1277
1356
|
if (typeof value === 'string') {
|
|
1278
1357
|
size += value.length;
|
|
1279
1358
|
} else if (
|
|
@@ -1369,6 +1448,9 @@ export class InstanceCache {
|
|
|
1369
1448
|
if (!this.matchesScope(entry.scope, ModelScope.live)) { droppedScope++; continue; }
|
|
1370
1449
|
const model = this.resolveModel(entry, id);
|
|
1371
1450
|
if (model && !model.disposed) {
|
|
1451
|
+
// Consumer boundary — activate, as in getByType. `hasMany` reads
|
|
1452
|
+
// (`section.blocks`) observe fields off these rows directly.
|
|
1453
|
+
model.ensureObservable();
|
|
1372
1454
|
result.push(model);
|
|
1373
1455
|
} else if (model?.disposed) {
|
|
1374
1456
|
droppedDisposed++;
|
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
|
|
@@ -108,6 +121,17 @@ export abstract class Model {
|
|
|
108
121
|
/** MobX observable properties storage */
|
|
109
122
|
_mobxProperties: ModelData = {};
|
|
110
123
|
|
|
124
|
+
/**
|
|
125
|
+
* True while {@link updateFromData} assigns inbound wire data. The
|
|
126
|
+
* `observe()` bridge (`mobxSetup.M1`) checks it and skips the forward to
|
|
127
|
+
* {@link propertyChanged} entirely: hydration writes are not user edits, and
|
|
128
|
+
* the bridge's work per changed field — a nested action, map bookkeeping,
|
|
129
|
+
* and a fabricated `updatedAt` stamp — was already being discarded by the
|
|
130
|
+
* `modifiedProperties` swap in `updateFromData`. The swap stays as the
|
|
131
|
+
* correctness backstop; this flag removes the cost.
|
|
132
|
+
*/
|
|
133
|
+
_isHydrating = false;
|
|
134
|
+
|
|
111
135
|
/** Referenced models cache */
|
|
112
136
|
_referencedModels: Record<string, Model | null> = {};
|
|
113
137
|
|
|
@@ -120,6 +144,18 @@ export abstract class Model {
|
|
|
120
144
|
/** Original data snapshot */
|
|
121
145
|
private _originalData?: ModelData;
|
|
122
146
|
|
|
147
|
+
/**
|
|
148
|
+
* Whether the persisted baseline needs recapturing. Hydration and ack land
|
|
149
|
+
* far more often than anything reads the baseline — an observer-only client
|
|
150
|
+
* reads it never — so each of those sites marks the snapshot stale and
|
|
151
|
+
* {@link getOriginalSnapshot} materializes it on first read. Correctness
|
|
152
|
+
* rests on the tracking invariant: every non-hydration write is recorded in
|
|
153
|
+
* `modifiedProperties` (first-old-wins), so between the stale mark and the
|
|
154
|
+
* read, untracked fields still hold exactly the values an eager capture
|
|
155
|
+
* would have recorded.
|
|
156
|
+
*/
|
|
157
|
+
private _originalDataStale = false;
|
|
158
|
+
|
|
123
159
|
/** Sync status */
|
|
124
160
|
syncStatus: 'pending' | 'syncing' | 'synced' = 'pending';
|
|
125
161
|
|
|
@@ -291,7 +327,8 @@ export abstract class Model {
|
|
|
291
327
|
*/
|
|
292
328
|
markAsPersisted(): void {
|
|
293
329
|
this._isNew = false;
|
|
294
|
-
this._originalData =
|
|
330
|
+
this._originalData = undefined;
|
|
331
|
+
this._originalDataStale = true;
|
|
295
332
|
}
|
|
296
333
|
|
|
297
334
|
/**
|
|
@@ -315,6 +352,10 @@ export abstract class Model {
|
|
|
315
352
|
* last acknowledged state would already be the authoritative baseline.
|
|
316
353
|
*/
|
|
317
354
|
getOriginalSnapshot(): Readonly<ModelData> | undefined {
|
|
355
|
+
if (this._originalDataStale) {
|
|
356
|
+
this._originalData = this.captureSnapshot();
|
|
357
|
+
this._originalDataStale = false;
|
|
358
|
+
}
|
|
318
359
|
return this._originalData;
|
|
319
360
|
}
|
|
320
361
|
|
|
@@ -324,7 +365,8 @@ export abstract class Model {
|
|
|
324
365
|
clearChanges(): void {
|
|
325
366
|
runInAction(() => {
|
|
326
367
|
this.modifiedProperties.clear();
|
|
327
|
-
this._originalData =
|
|
368
|
+
this._originalData = undefined;
|
|
369
|
+
this._originalDataStale = true;
|
|
328
370
|
});
|
|
329
371
|
}
|
|
330
372
|
|
|
@@ -580,48 +622,39 @@ export abstract class Model {
|
|
|
580
622
|
data: ModelData,
|
|
581
623
|
onWrite?: (key: string, oldValue: unknown, newValue: unknown) => void,
|
|
582
624
|
): void {
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
if (!(this.hasOwnProperty(key) || key in this)) continue;
|
|
625
|
+
let plan = fieldWritePlans.get(this.constructor);
|
|
626
|
+
if (!plan) {
|
|
627
|
+
plan = new Map();
|
|
628
|
+
fieldWritePlans.set(this.constructor, plan);
|
|
629
|
+
}
|
|
589
630
|
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
}
|
|
595
|
-
} catch {
|
|
596
|
-
// If MobX internals are unavailable for some reason, fall back to descriptor checks below
|
|
597
|
-
}
|
|
631
|
+
for (const key in data) {
|
|
632
|
+
if (key === 'id') continue;
|
|
633
|
+
if (!Object.prototype.hasOwnProperty.call(data, key)) continue;
|
|
634
|
+
const raw = data[key];
|
|
598
635
|
|
|
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
|
-
}
|
|
636
|
+
let disposition = plan.get(key);
|
|
637
|
+
if (disposition === undefined) {
|
|
638
|
+
disposition = this.resolveFieldDisposition(key);
|
|
639
|
+
plan.set(key, disposition);
|
|
608
640
|
}
|
|
609
641
|
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
642
|
+
if (disposition === 'skip-readonly') continue;
|
|
643
|
+
if (disposition === 'skip-absent') {
|
|
644
|
+
// The plan was built from an instance that lacked this key. Re-resolve
|
|
645
|
+
// for this instance without touching the cache, so heterogeneous
|
|
646
|
+
// shapes stay correct at the cost of the slow path.
|
|
647
|
+
if (!(key in this)) continue;
|
|
648
|
+
disposition = this.resolveFieldDisposition(key);
|
|
649
|
+
if (disposition === 'skip-readonly' || disposition === 'skip-absent') continue;
|
|
650
|
+
} else if (!(key in this)) {
|
|
617
651
|
continue;
|
|
618
652
|
}
|
|
619
653
|
|
|
620
654
|
// Handle date conversions
|
|
621
|
-
const value =
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
: raw;
|
|
655
|
+
const value = disposition === 'write-date' && raw
|
|
656
|
+
? new Date(raw as string | number)
|
|
657
|
+
: raw;
|
|
625
658
|
|
|
626
659
|
// Capture the pre-write value BEFORE assignment so trackers
|
|
627
660
|
// (undo inverse, getChanges) see the true previous value.
|
|
@@ -634,6 +667,44 @@ export abstract class Model {
|
|
|
634
667
|
}
|
|
635
668
|
}
|
|
636
669
|
|
|
670
|
+
/**
|
|
671
|
+
* The uncached decision for one key: existence on instance or prototype,
|
|
672
|
+
* never a MobX computed (its setter may throw), and writability resolved
|
|
673
|
+
* through the descriptor chain — a data descriptor's `writable` or an
|
|
674
|
+
* accessor's setter.
|
|
675
|
+
*/
|
|
676
|
+
private resolveFieldDisposition(key: string): FieldDisposition {
|
|
677
|
+
if (!(key in this)) return 'skip-absent';
|
|
678
|
+
|
|
679
|
+
try {
|
|
680
|
+
if (isComputedProp(this, key)) {
|
|
681
|
+
return 'skip-readonly';
|
|
682
|
+
}
|
|
683
|
+
} catch {
|
|
684
|
+
// If MobX internals are unavailable for some reason, fall back to descriptor checks below
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
const ownDesc = Object.getOwnPropertyDescriptor(this, key);
|
|
688
|
+
let desc = ownDesc;
|
|
689
|
+
if (!desc) {
|
|
690
|
+
let proto = Object.getPrototypeOf(this) as object | null;
|
|
691
|
+
while (proto && proto !== Object.prototype && !desc) {
|
|
692
|
+
desc = Object.getOwnPropertyDescriptor(proto, key);
|
|
693
|
+
proto = Object.getPrototypeOf(proto) as object | null;
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
const writable = desc
|
|
698
|
+
? ('writable' in desc && !!desc.writable) ||
|
|
699
|
+
('set' in desc && typeof desc.set === 'function')
|
|
700
|
+
: true;
|
|
701
|
+
if (!writable) return 'skip-readonly';
|
|
702
|
+
|
|
703
|
+
return key === 'createdAt' || key === 'updatedAt' || key === 'archivedAt'
|
|
704
|
+
? 'write-date'
|
|
705
|
+
: 'write';
|
|
706
|
+
}
|
|
707
|
+
|
|
637
708
|
/**
|
|
638
709
|
* Update from raw data (hydration)
|
|
639
710
|
*
|
|
@@ -661,16 +732,20 @@ export abstract class Model {
|
|
|
661
732
|
runInAction(() => {
|
|
662
733
|
const originalTracking = this.modifiedProperties;
|
|
663
734
|
this.modifiedProperties = new Map();
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
735
|
+
this._isHydrating = true;
|
|
736
|
+
try {
|
|
737
|
+
// No `onWrite` → this call records nothing itself.
|
|
738
|
+
this.assignFieldsFromData(data);
|
|
739
|
+
} finally {
|
|
740
|
+
this._isHydrating = false;
|
|
741
|
+
this.modifiedProperties = originalTracking;
|
|
742
|
+
}
|
|
669
743
|
});
|
|
670
744
|
|
|
671
745
|
// Mark as persisted if updating existing model
|
|
672
746
|
if (!this._isNew) {
|
|
673
|
-
this._originalData =
|
|
747
|
+
this._originalData = undefined;
|
|
748
|
+
this._originalDataStale = true;
|
|
674
749
|
}
|
|
675
750
|
|
|
676
751
|
this.didUpdate();
|