@abloatai/ablo 0.3.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/CHANGELOG.md +208 -0
- package/LICENSE +201 -0
- package/NOTICE +12 -0
- package/README.md +230 -0
- package/dist/BaseSyncedStore.d.ts +709 -0
- package/dist/BaseSyncedStore.js +1843 -0
- package/dist/Database.d.ts +344 -0
- package/dist/Database.js +1259 -0
- package/dist/LazyReferenceCollection.d.ts +181 -0
- package/dist/LazyReferenceCollection.js +460 -0
- package/dist/Model.d.ts +339 -0
- package/dist/Model.js +715 -0
- package/dist/ModelRegistry.d.ts +200 -0
- package/dist/ModelRegistry.js +535 -0
- package/dist/NetworkMonitor.d.ts +27 -0
- package/dist/NetworkMonitor.js +73 -0
- package/dist/ObjectPool.d.ts +202 -0
- package/dist/ObjectPool.js +1106 -0
- package/dist/SyncClient.d.ts +489 -0
- package/dist/SyncClient.js +1555 -0
- package/dist/SyncEngineContext.d.ts +46 -0
- package/dist/SyncEngineContext.js +74 -0
- package/dist/adapters/alwaysOnline.d.ts +16 -0
- package/dist/adapters/alwaysOnline.js +19 -0
- package/dist/adapters/inMemoryStorage.d.ts +30 -0
- package/dist/adapters/inMemoryStorage.js +94 -0
- package/dist/agent/Agent.d.ts +358 -0
- package/dist/agent/Agent.js +500 -0
- package/dist/agent/index.d.ts +115 -0
- package/dist/agent/index.js +128 -0
- package/dist/agent/session.d.ts +90 -0
- package/dist/agent/session.js +156 -0
- package/dist/agent/types.d.ts +73 -0
- package/dist/agent/types.js +10 -0
- package/dist/ai-sdk/coordination-context.d.ts +51 -0
- package/dist/ai-sdk/coordination-context.js +107 -0
- package/dist/ai-sdk/index.d.ts +68 -0
- package/dist/ai-sdk/index.js +68 -0
- package/dist/ai-sdk/intent-broadcast.d.ts +77 -0
- package/dist/ai-sdk/intent-broadcast.js +72 -0
- package/dist/ai-sdk/wrap.d.ts +67 -0
- package/dist/ai-sdk/wrap.js +45 -0
- package/dist/api/index.d.ts +10 -0
- package/dist/api/index.js +9 -0
- package/dist/auth/index.d.ts +137 -0
- package/dist/auth/index.js +246 -0
- package/dist/client/Ablo.d.ts +835 -0
- package/dist/client/Ablo.js +1440 -0
- package/dist/client/ApiClient.d.ts +200 -0
- package/dist/client/ApiClient.js +659 -0
- package/dist/client/auth.d.ts +79 -0
- package/dist/client/auth.js +81 -0
- package/dist/client/createInternalComponents.d.ts +44 -0
- package/dist/client/createInternalComponents.js +88 -0
- package/dist/client/createModelProxy.d.ts +152 -0
- package/dist/client/createModelProxy.js +199 -0
- package/dist/client/identity.d.ts +63 -0
- package/dist/client/identity.js +156 -0
- package/dist/client/index.d.ts +36 -0
- package/dist/client/index.js +33 -0
- package/dist/client/persistence.d.ts +7 -0
- package/dist/client/persistence.js +11 -0
- package/dist/client/validateAbloOptions.d.ts +42 -0
- package/dist/client/validateAbloOptions.js +43 -0
- package/dist/config/index.d.ts +10 -0
- package/dist/config/index.js +12 -0
- package/dist/context.d.ts +27 -0
- package/dist/context.js +58 -0
- package/dist/core/DatabaseManager.d.ts +108 -0
- package/dist/core/DatabaseManager.js +361 -0
- package/dist/core/QueryProcessor.d.ts +77 -0
- package/dist/core/QueryProcessor.js +262 -0
- package/dist/core/QueryView.d.ts +64 -0
- package/dist/core/QueryView.js +219 -0
- package/dist/core/StoreManager.d.ts +131 -0
- package/dist/core/StoreManager.js +334 -0
- package/dist/core/ViewRegistry.d.ts +20 -0
- package/dist/core/ViewRegistry.js +55 -0
- package/dist/core/index.d.ts +34 -0
- package/dist/core/index.js +59 -0
- package/dist/core/openIDBWithTimeout.d.ts +27 -0
- package/dist/core/openIDBWithTimeout.js +63 -0
- package/dist/core/query-utils.d.ts +37 -0
- package/dist/core/query-utils.js +60 -0
- package/dist/errors.d.ts +235 -0
- package/dist/errors.js +243 -0
- package/dist/index.d.ts +41 -0
- package/dist/index.js +82 -0
- package/dist/interfaces/headless.d.ts +95 -0
- package/dist/interfaces/headless.js +41 -0
- package/dist/interfaces/index.d.ts +321 -0
- package/dist/interfaces/index.js +8 -0
- package/dist/mutators/RecordingTransaction.d.ts +36 -0
- package/dist/mutators/RecordingTransaction.js +216 -0
- package/dist/mutators/Transaction.d.ts +48 -0
- package/dist/mutators/Transaction.js +64 -0
- package/dist/mutators/UndoManager.d.ts +114 -0
- package/dist/mutators/UndoManager.js +143 -0
- package/dist/mutators/defineMutators.d.ts +55 -0
- package/dist/mutators/defineMutators.js +28 -0
- package/dist/policy/index.d.ts +19 -0
- package/dist/policy/index.js +18 -0
- package/dist/policy/types.d.ts +74 -0
- package/dist/policy/types.js +17 -0
- package/dist/principal.d.ts +44 -0
- package/dist/principal.js +49 -0
- package/dist/query/client.d.ts +43 -0
- package/dist/query/client.js +84 -0
- package/dist/query/index.d.ts +6 -0
- package/dist/query/index.js +5 -0
- package/dist/query/types.d.ts +143 -0
- package/dist/query/types.js +36 -0
- package/dist/react/AbloProvider.d.ts +205 -0
- package/dist/react/AbloProvider.js +398 -0
- package/dist/react/ClientSideSuspense.d.ts +36 -0
- package/dist/react/ClientSideSuspense.js +17 -0
- package/dist/react/DefaultFallback.d.ts +24 -0
- package/dist/react/DefaultFallback.js +43 -0
- package/dist/react/SyncGroupProvider.d.ts +19 -0
- package/dist/react/SyncGroupProvider.js +44 -0
- package/dist/react/context.d.ts +161 -0
- package/dist/react/context.js +35 -0
- package/dist/react/index.d.ts +64 -0
- package/dist/react/index.js +73 -0
- package/dist/react/internalContext.d.ts +35 -0
- package/dist/react/internalContext.js +3 -0
- package/dist/react/useAblo.d.ts +72 -0
- package/dist/react/useAblo.js +63 -0
- package/dist/react/useCurrentUserId.d.ts +21 -0
- package/dist/react/useCurrentUserId.js +33 -0
- package/dist/react/useErrorListener.d.ts +20 -0
- package/dist/react/useErrorListener.js +39 -0
- package/dist/react/useIntent.d.ts +29 -0
- package/dist/react/useIntent.js +42 -0
- package/dist/react/useMutate.d.ts +83 -0
- package/dist/react/useMutate.js +122 -0
- package/dist/react/useMutationFailureListener.d.ts +26 -0
- package/dist/react/useMutationFailureListener.js +38 -0
- package/dist/react/useMutators.d.ts +56 -0
- package/dist/react/useMutators.js +66 -0
- package/dist/react/usePresence.d.ts +32 -0
- package/dist/react/usePresence.js +41 -0
- package/dist/react/useQuery.d.ts +123 -0
- package/dist/react/useQuery.js +145 -0
- package/dist/react/useReactive.d.ts +35 -0
- package/dist/react/useReactive.js +111 -0
- package/dist/react/useReader.d.ts +69 -0
- package/dist/react/useReader.js +73 -0
- package/dist/react/useSyncStatus.d.ts +61 -0
- package/dist/react/useSyncStatus.js +76 -0
- package/dist/react/useUndoScope.d.ts +36 -0
- package/dist/react/useUndoScope.js +73 -0
- package/dist/realtime/index.d.ts +10 -0
- package/dist/realtime/index.js +9 -0
- package/dist/schema/field.d.ts +134 -0
- package/dist/schema/field.js +264 -0
- package/dist/schema/index.d.ts +29 -0
- package/dist/schema/index.js +38 -0
- package/dist/schema/model.d.ts +326 -0
- package/dist/schema/model.js +89 -0
- package/dist/schema/queries.d.ts +203 -0
- package/dist/schema/queries.js +145 -0
- package/dist/schema/relation.d.ts +172 -0
- package/dist/schema/relation.js +104 -0
- package/dist/schema/schema.d.ts +259 -0
- package/dist/schema/schema.js +188 -0
- package/dist/schema/sugar.d.ts +129 -0
- package/dist/schema/sugar.js +94 -0
- package/dist/source/index.d.ts +423 -0
- package/dist/source/index.js +320 -0
- package/dist/source/pushQueue.d.ts +112 -0
- package/dist/source/pushQueue.js +249 -0
- package/dist/stores/ObjectStore.d.ts +103 -0
- package/dist/stores/ObjectStore.js +371 -0
- package/dist/stores/ObjectStoreContract.d.ts +39 -0
- package/dist/stores/ObjectStoreContract.js +1 -0
- package/dist/stores/SyncActionStore.d.ts +101 -0
- package/dist/stores/SyncActionStore.js +481 -0
- package/dist/sync/BootstrapHelper.d.ts +127 -0
- package/dist/sync/BootstrapHelper.js +434 -0
- package/dist/sync/ConnectionManager.d.ts +136 -0
- package/dist/sync/ConnectionManager.js +465 -0
- package/dist/sync/HydrationCoordinator.d.ts +137 -0
- package/dist/sync/HydrationCoordinator.js +468 -0
- package/dist/sync/NetworkProbe.d.ts +43 -0
- package/dist/sync/NetworkProbe.js +113 -0
- package/dist/sync/OfflineFlush.d.ts +9 -0
- package/dist/sync/OfflineFlush.js +22 -0
- package/dist/sync/OfflineTransactionStore.d.ts +37 -0
- package/dist/sync/OfflineTransactionStore.js +263 -0
- package/dist/sync/SyncWebSocket.d.ts +663 -0
- package/dist/sync/SyncWebSocket.js +1336 -0
- package/dist/sync/createIntentStream.d.ts +33 -0
- package/dist/sync/createIntentStream.js +243 -0
- package/dist/sync/createPresenceStream.d.ts +46 -0
- package/dist/sync/createPresenceStream.js +192 -0
- package/dist/sync/createSnapshot.d.ts +33 -0
- package/dist/sync/createSnapshot.js +124 -0
- package/dist/sync/participants.d.ts +114 -0
- package/dist/sync/participants.js +336 -0
- package/dist/sync/schemas.d.ts +79 -0
- package/dist/sync/schemas.js +78 -0
- package/dist/testing/fixtures/bootstrap.d.ts +45 -0
- package/dist/testing/fixtures/bootstrap.js +53 -0
- package/dist/testing/fixtures/deltas.d.ts +86 -0
- package/dist/testing/fixtures/deltas.js +139 -0
- package/dist/testing/fixtures/models.d.ts +82 -0
- package/dist/testing/fixtures/models.js +270 -0
- package/dist/testing/helpers/react-wrapper.d.ts +66 -0
- package/dist/testing/helpers/react-wrapper.js +64 -0
- package/dist/testing/helpers/sync-engine-harness.d.ts +55 -0
- package/dist/testing/helpers/sync-engine-harness.js +70 -0
- package/dist/testing/helpers/wait.d.ts +25 -0
- package/dist/testing/helpers/wait.js +44 -0
- package/dist/testing/index.d.ts +21 -0
- package/dist/testing/index.js +32 -0
- package/dist/testing/mocks/MockMutationExecutor.d.ts +65 -0
- package/dist/testing/mocks/MockMutationExecutor.js +139 -0
- package/dist/testing/mocks/MockNetworkMonitor.d.ts +20 -0
- package/dist/testing/mocks/MockNetworkMonitor.js +46 -0
- package/dist/testing/mocks/MockSyncContext.d.ts +64 -0
- package/dist/testing/mocks/MockSyncContext.js +100 -0
- package/dist/testing/mocks/MockSyncStore.d.ts +88 -0
- package/dist/testing/mocks/MockSyncStore.js +171 -0
- package/dist/testing/mocks/MockWebSocket.d.ts +66 -0
- package/dist/testing/mocks/MockWebSocket.js +117 -0
- package/dist/transactions/OptimisticEchoTracker.d.ts +82 -0
- package/dist/transactions/OptimisticEchoTracker.js +104 -0
- package/dist/transactions/TransactionQueue.d.ts +499 -0
- package/dist/transactions/TransactionQueue.js +1895 -0
- package/dist/transactions/index.d.ts +16 -0
- package/dist/transactions/index.js +7 -0
- package/dist/transactions/mutation-error-handler.d.ts +5 -0
- package/dist/transactions/mutation-error-handler.js +39 -0
- package/dist/types/global.d.ts +107 -0
- package/dist/types/global.js +38 -0
- package/dist/types/index.d.ts +241 -0
- package/dist/types/index.js +70 -0
- package/dist/types/streams.d.ts +495 -0
- package/dist/types/streams.js +11 -0
- package/dist/utils/asyncIterator.d.ts +41 -0
- package/dist/utils/asyncIterator.js +142 -0
- package/dist/utils/duration.d.ts +28 -0
- package/dist/utils/duration.js +47 -0
- package/dist/utils/mobx-setup.d.ts +42 -0
- package/dist/utils/mobx-setup.js +381 -0
- package/docs/api-keys.md +24 -0
- package/docs/api.md +230 -0
- package/docs/audit.md +81 -0
- package/docs/capabilities.md +163 -0
- package/docs/client-behavior.md +202 -0
- package/docs/data-sources.md +214 -0
- package/docs/examples/agent-human.md +84 -0
- package/docs/examples/ai-sdk-tool.md +92 -0
- package/docs/examples/existing-python-backend.md +249 -0
- package/docs/examples/nextjs.md +88 -0
- package/docs/examples/server-agent.md +86 -0
- package/docs/guarantees.md +148 -0
- package/docs/index.md +97 -0
- package/docs/integration-guide.md +493 -0
- package/docs/interaction-model.md +140 -0
- package/docs/mcp/claude-code.md +43 -0
- package/docs/mcp/cursor.md +53 -0
- package/docs/mcp/windsurf.md +46 -0
- package/docs/mcp.md +59 -0
- package/docs/quickstart.md +152 -0
- package/docs/react.md +115 -0
- package/docs/roadmap.md +45 -0
- package/examples/README.md +54 -0
- package/examples/data-source/README.md +102 -0
- package/examples/data-source/ablo-driver.ts +89 -0
- package/examples/data-source/customer-server.ts +208 -0
- package/examples/data-source/run.ts +101 -0
- package/examples/data-source/schema.ts +25 -0
- package/examples/quickstart.ts +54 -0
- package/examples/tsconfig.json +16 -0
- package/llms.txt +143 -0
- package/package.json +147 -0
|
@@ -0,0 +1,1106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectPool - In-memory model cache with deduplication
|
|
3
|
+
*
|
|
4
|
+
* Pure memory management without database or registry dependencies.
|
|
5
|
+
* Uses static ModelRegistry for model class lookup only.
|
|
6
|
+
*/
|
|
7
|
+
import { makeObservable, observable, action, computed, runInAction } from 'mobx';
|
|
8
|
+
import { getContext } from './context.js';
|
|
9
|
+
import { AbloValidationError } from './errors.js';
|
|
10
|
+
import { ModelScope } from './types/index.js';
|
|
11
|
+
import { ViewRegistry } from './core/ViewRegistry.js';
|
|
12
|
+
import { QueryView } from './core/QueryView.js';
|
|
13
|
+
// Re-export so existing `import { ModelScope } from './ObjectPool.js'` still resolves
|
|
14
|
+
export { ModelScope };
|
|
15
|
+
/**
|
|
16
|
+
* ObjectPool - Pure in-memory model cache with deduplication
|
|
17
|
+
*/
|
|
18
|
+
export class ObjectPool {
|
|
19
|
+
// Single source of truth for all models (observable for reactivity)
|
|
20
|
+
entries = observable.map();
|
|
21
|
+
typeIndex = observable.map();
|
|
22
|
+
// Non-observable access time tracking — kept outside observable.map so that
|
|
23
|
+
// updating timestamps in get() during React render does NOT trigger MobX
|
|
24
|
+
// reactions (which would cause infinite re-render loops).
|
|
25
|
+
accessTimes = new Map();
|
|
26
|
+
// Deduplication tracking
|
|
27
|
+
recentAdditions = new Map(); // "modelType:modelId" -> timestamp
|
|
28
|
+
deltaHistory = new Map();
|
|
29
|
+
// No intermediate cache layer — getByType() reads typeIndex + entries directly.
|
|
30
|
+
// This follows Linear's sync engine pattern: observable data structures ARE the
|
|
31
|
+
// reactivity source. No computed getters with conditional cache invalidation.
|
|
32
|
+
// Foreign key indexes: Map<"ModelType:fieldName", Map<fieldValue, ObservableSet<modelId>>>
|
|
33
|
+
// Enables O(1) lookups like "all SlideLayer models where slideId = X"
|
|
34
|
+
// instead of scanning all models of a type and filtering.
|
|
35
|
+
foreignKeyIndexes = new Map();
|
|
36
|
+
// Registry of which fields to index: Map<modelName, fieldName[]>
|
|
37
|
+
foreignKeyConfig = new Map();
|
|
38
|
+
// Performance tracking
|
|
39
|
+
metrics = {
|
|
40
|
+
hits: 0,
|
|
41
|
+
misses: 0,
|
|
42
|
+
evictions: 0,
|
|
43
|
+
additions: 0,
|
|
44
|
+
duplicatesSkipped: 0,
|
|
45
|
+
};
|
|
46
|
+
// Configuration
|
|
47
|
+
config;
|
|
48
|
+
gcTimer;
|
|
49
|
+
// ModelRegistry instance — single source of truth for model metadata
|
|
50
|
+
registry;
|
|
51
|
+
// ViewRegistry — tracks active QueryViews for incremental view maintenance
|
|
52
|
+
viewRegistry = new ViewRegistry();
|
|
53
|
+
// Subscription registry
|
|
54
|
+
subscriptions = new Map();
|
|
55
|
+
/**
|
|
56
|
+
* Subscribe to updates for a specific model type.
|
|
57
|
+
*/
|
|
58
|
+
subscribe(modelClass, callback) {
|
|
59
|
+
const modelName = this.registry.getModelNameFromConstructor(modelClass);
|
|
60
|
+
if (!modelName) {
|
|
61
|
+
throw new AbloValidationError(`Model class not registered: ${modelClass.name}`, {
|
|
62
|
+
code: 'pool_subscribe_unregistered',
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
if (!this.subscriptions.has(modelName)) {
|
|
66
|
+
this.subscriptions.set(modelName, new Set());
|
|
67
|
+
}
|
|
68
|
+
const subs = this.subscriptions.get(modelName);
|
|
69
|
+
const erased = callback;
|
|
70
|
+
subs.add(erased);
|
|
71
|
+
return () => subs.delete(erased);
|
|
72
|
+
}
|
|
73
|
+
notifySubscribers(model) {
|
|
74
|
+
const modelName = model.getModelName();
|
|
75
|
+
const subs = this.subscriptions.get(modelName);
|
|
76
|
+
if (subs) {
|
|
77
|
+
for (const callback of subs) {
|
|
78
|
+
callback(model);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
constructor(config = {}, modelRegistry) {
|
|
83
|
+
this.config = {
|
|
84
|
+
maxSize: config.maxSize ?? 10000,
|
|
85
|
+
// Idle-eviction disabled by default. The 5-minute default used to
|
|
86
|
+
// live here, but with schema-driven dynamic classes not
|
|
87
|
+
// registering `LazyReferenceCollection`s, the
|
|
88
|
+
// `hasObservedCollections()` guard in gc() didn't fire for most
|
|
89
|
+
// actively-rendered models — and they'd evict out from under a
|
|
90
|
+
// user whose tab sat for 10 minutes. Memory pressure relief is
|
|
91
|
+
// handled by the `maxSize` LRU cap (see `evictOldest`), which is
|
|
92
|
+
// the bound that actually matches usage: "keep the most recent N
|
|
93
|
+
// entities, not the entities touched in the last N minutes."
|
|
94
|
+
//
|
|
95
|
+
// Callers who genuinely want time-based eviction can pass an
|
|
96
|
+
// explicit `maxAge`. Leaving the default at Infinity keeps
|
|
97
|
+
// correctness as the default and makes aggressive GC an opt-in.
|
|
98
|
+
maxAge: config.maxAge ?? Number.POSITIVE_INFINITY,
|
|
99
|
+
gcInterval: config.gcInterval ?? 60000, // 1 minute
|
|
100
|
+
useWeakRefs: config.useWeakRefs ?? true,
|
|
101
|
+
};
|
|
102
|
+
// 🔧 PROPER FIX: Store model registry reference
|
|
103
|
+
if (!modelRegistry) {
|
|
104
|
+
throw new AbloValidationError('ObjectPool requires ModelRegistry for production-safe model name lookup', { code: 'pool_registry_missing' });
|
|
105
|
+
}
|
|
106
|
+
this.registry = modelRegistry;
|
|
107
|
+
// 🔧 PRODUCTION FIX: Defer type index initialization until first use
|
|
108
|
+
// This allows models to be registered after ObjectPool creation
|
|
109
|
+
// Type indexes will be initialized on first getByType call
|
|
110
|
+
// Linear-style: no computed cache layer. entries + typeIndex are both observable.
|
|
111
|
+
// getByType() reads them directly, so MobX always tracks the dependency.
|
|
112
|
+
makeObservable(this, {
|
|
113
|
+
add: action,
|
|
114
|
+
addBatch: action,
|
|
115
|
+
upsertBatch: action,
|
|
116
|
+
removeBatch: action,
|
|
117
|
+
addToArchive: action,
|
|
118
|
+
remove: action,
|
|
119
|
+
removeFromArchive: action,
|
|
120
|
+
clear: action,
|
|
121
|
+
updateScope: action,
|
|
122
|
+
size: computed,
|
|
123
|
+
});
|
|
124
|
+
this.startGC();
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* 🔧 PRODUCTION FIX: Initialize type indexes for all registered models
|
|
128
|
+
* This prevents the "No type index found" error in production where constructor
|
|
129
|
+
* references are lost due to minification.
|
|
130
|
+
*/
|
|
131
|
+
initializeTypeIndexes() {
|
|
132
|
+
const names = this.registry.getRegisteredModelNames();
|
|
133
|
+
for (const modelName of names) {
|
|
134
|
+
if (!this.typeIndex.has(modelName)) {
|
|
135
|
+
this.typeIndex.set(modelName, observable.set());
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
// No computed getters — getByType() reads typeIndex + entries directly.
|
|
140
|
+
// This eliminates the conditional dependency bug where MobX lost tracking
|
|
141
|
+
// because _cacheInvalid (non-observable) gated whether entries was read.
|
|
142
|
+
// _rebuildCaches and _invalidateCache removed — no cache layer to manage.
|
|
143
|
+
// typeIndex + entries are observable and read directly by getByType().
|
|
144
|
+
resolveModel(entry, id) {
|
|
145
|
+
if (entry.model)
|
|
146
|
+
return entry.model;
|
|
147
|
+
if (entry.weakRef) {
|
|
148
|
+
const model = entry.weakRef.deref();
|
|
149
|
+
if (model) {
|
|
150
|
+
entry.model = model;
|
|
151
|
+
if (id)
|
|
152
|
+
this.accessTimes.set(id, Date.now());
|
|
153
|
+
return model;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
|
158
|
+
get(id) {
|
|
159
|
+
const entry = this.entries.get(id);
|
|
160
|
+
if (!entry) {
|
|
161
|
+
runInAction(() => {
|
|
162
|
+
this.metrics.misses++;
|
|
163
|
+
});
|
|
164
|
+
return undefined;
|
|
165
|
+
}
|
|
166
|
+
let model = entry.model;
|
|
167
|
+
if (!model && entry.weakRef) {
|
|
168
|
+
const restoredModel = entry.weakRef.deref();
|
|
169
|
+
if (!restoredModel) {
|
|
170
|
+
runInAction(() => {
|
|
171
|
+
this.entries.delete(id);
|
|
172
|
+
this.removeFromTypeIndex(id, entry.model?.getModelName());
|
|
173
|
+
this.metrics.misses++;
|
|
174
|
+
});
|
|
175
|
+
return undefined;
|
|
176
|
+
}
|
|
177
|
+
model = restoredModel;
|
|
178
|
+
runInAction(() => {
|
|
179
|
+
entry.model = restoredModel;
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
// Never return disposed models — they are logically removed and may have
|
|
183
|
+
// torn-down internal state. Callers (e.g. flushPendingDeltas) must not
|
|
184
|
+
// receive a disposed reference that will throw on updateFromData().
|
|
185
|
+
if (model?.disposed) {
|
|
186
|
+
return undefined;
|
|
187
|
+
}
|
|
188
|
+
// Update access time in non-observable map — prevents MobX reactions during render
|
|
189
|
+
this.accessTimes.set(id, Date.now());
|
|
190
|
+
this.metrics.hits++;
|
|
191
|
+
return model ?? undefined;
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Add model with deduplication support
|
|
195
|
+
*/
|
|
196
|
+
add(model, scope = ModelScope.live, deltaInfo) {
|
|
197
|
+
const id = model.id;
|
|
198
|
+
const modelType = model.getModelName();
|
|
199
|
+
const addKey = `${modelType}:${id}`;
|
|
200
|
+
// Ensure type index exists for this model type
|
|
201
|
+
if (!this.typeIndex.has(modelType)) {
|
|
202
|
+
this.typeIndex.set(modelType, observable.set());
|
|
203
|
+
}
|
|
204
|
+
// Check if model already exists to prevent duplicates
|
|
205
|
+
const existingEntry = this.entries.get(id);
|
|
206
|
+
if (existingEntry && existingEntry.model && !existingEntry.model.disposed) {
|
|
207
|
+
// Model already exists and is valid, update its scope if needed
|
|
208
|
+
if (existingEntry.scope !== scope) {
|
|
209
|
+
runInAction(() => {
|
|
210
|
+
this.entries.set(id, { ...existingEntry, scope });
|
|
211
|
+
});
|
|
212
|
+
this.accessTimes.set(id, Date.now());
|
|
213
|
+
}
|
|
214
|
+
this.metrics.duplicatesSkipped++;
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
// Check rapid additions (within 50ms) for better deduplication
|
|
218
|
+
const lastAdded = this.recentAdditions.get(addKey);
|
|
219
|
+
if (lastAdded && Date.now() - lastAdded < 50) {
|
|
220
|
+
this.metrics.duplicatesSkipped++;
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
// Check delta history for duplicate processing
|
|
224
|
+
if (deltaInfo?.syncId) {
|
|
225
|
+
const history = this.deltaHistory.get(addKey);
|
|
226
|
+
if (history) {
|
|
227
|
+
// Skip if we've already processed a newer or equal sync ID
|
|
228
|
+
if (history.lastSyncId >= deltaInfo.syncId) {
|
|
229
|
+
this.metrics.duplicatesSkipped++;
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
// Warn about suspicious patterns
|
|
233
|
+
if (deltaInfo.action === 'I' &&
|
|
234
|
+
(history.lastAction === 'U' || history.lastAction === 'D')) {
|
|
235
|
+
getContext().logger.warn(`ObjectPool.add() SUSPICIOUS: INSERT after ${history.lastAction}`, { modelType, id, syncId: deltaInfo.syncId });
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
// Update delta history
|
|
239
|
+
this.deltaHistory.set(addKey, {
|
|
240
|
+
lastAction: deltaInfo.action || 'U',
|
|
241
|
+
lastSyncId: deltaInfo.syncId,
|
|
242
|
+
timestamp: Date.now(),
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
// Track this addition
|
|
246
|
+
this.recentAdditions.set(addKey, Date.now());
|
|
247
|
+
// Clean old tracking entries periodically
|
|
248
|
+
if (this.recentAdditions.size > 100) {
|
|
249
|
+
this.cleanupTracking();
|
|
250
|
+
}
|
|
251
|
+
// Note: existingEntry check is now done earlier for better deduplication
|
|
252
|
+
if (this.entries.size >= this.config.maxSize) {
|
|
253
|
+
this.evictOldest();
|
|
254
|
+
}
|
|
255
|
+
const entry = {
|
|
256
|
+
model,
|
|
257
|
+
scope,
|
|
258
|
+
};
|
|
259
|
+
if (this.config.useWeakRefs && this.isLargeModel(model)) {
|
|
260
|
+
entry.weakRef = new WeakRef(model);
|
|
261
|
+
}
|
|
262
|
+
this.accessTimes.set(id, Date.now());
|
|
263
|
+
runInAction(() => {
|
|
264
|
+
this.entries.set(id, entry);
|
|
265
|
+
this.addToTypeIndex(id, model.getModelName());
|
|
266
|
+
this.metrics.additions++;
|
|
267
|
+
});
|
|
268
|
+
// No cache to invalidate — typeIndex + entries are directly observable
|
|
269
|
+
// Notify views of the addition
|
|
270
|
+
this.notifySubscribers(model);
|
|
271
|
+
this.viewRegistry.notifyAdded(modelType, model);
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Upsert a model - INSERT if new, UPDATE if exists
|
|
275
|
+
*/
|
|
276
|
+
upsert(model, scope = ModelScope.live) {
|
|
277
|
+
const id = model.id;
|
|
278
|
+
const existingEntry = this.entries.get(id);
|
|
279
|
+
if (existingEntry?.model && !existingEntry.model.disposed) {
|
|
280
|
+
// Model exists - update it in-place
|
|
281
|
+
const existingModel = existingEntry.model;
|
|
282
|
+
// Skip updateFromData if same instance - preserves _local changes for client mutations
|
|
283
|
+
if (model !== existingModel) {
|
|
284
|
+
existingModel.updateFromData(model.toJSON());
|
|
285
|
+
}
|
|
286
|
+
// Update scope if different
|
|
287
|
+
if (existingEntry.scope !== scope) {
|
|
288
|
+
runInAction(() => {
|
|
289
|
+
this.entries.set(id, { ...existingEntry, scope });
|
|
290
|
+
});
|
|
291
|
+
this.accessTimes.set(id, Date.now());
|
|
292
|
+
}
|
|
293
|
+
this.notifySubscribers(existingModel);
|
|
294
|
+
// Notify views of the update
|
|
295
|
+
this.viewRegistry.notifyUpdated(existingModel.getModelName(), existingModel);
|
|
296
|
+
}
|
|
297
|
+
else {
|
|
298
|
+
// Model doesn't exist - add it (add() already notifies views)
|
|
299
|
+
this.add(model, scope);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Batch add models - optimized for hydration
|
|
304
|
+
* All models are added in a single MobX action to minimize reactivity overhead
|
|
305
|
+
*/
|
|
306
|
+
addBatch(models, scope = ModelScope.live) {
|
|
307
|
+
if (models.length === 0)
|
|
308
|
+
return 0;
|
|
309
|
+
let addedCount = 0;
|
|
310
|
+
const now = Date.now();
|
|
311
|
+
// Process all models in a single action to avoid per-item reaction cycles
|
|
312
|
+
for (const model of models) {
|
|
313
|
+
const id = model.id;
|
|
314
|
+
const modelType = model.getModelName();
|
|
315
|
+
// Ensure type index exists
|
|
316
|
+
if (!this.typeIndex.has(modelType)) {
|
|
317
|
+
this.typeIndex.set(modelType, observable.set());
|
|
318
|
+
}
|
|
319
|
+
// Skip if model already exists and is valid
|
|
320
|
+
const existingEntry = this.entries.get(id);
|
|
321
|
+
if (existingEntry && existingEntry.model && !existingEntry.model.disposed) {
|
|
322
|
+
if (existingEntry.scope !== scope) {
|
|
323
|
+
this.entries.set(id, { ...existingEntry, scope });
|
|
324
|
+
this.accessTimes.set(id, now);
|
|
325
|
+
}
|
|
326
|
+
this.metrics.duplicatesSkipped++;
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
// Evict if at capacity
|
|
330
|
+
if (this.entries.size >= this.config.maxSize) {
|
|
331
|
+
this.evictOldest();
|
|
332
|
+
}
|
|
333
|
+
const entry = {
|
|
334
|
+
model,
|
|
335
|
+
scope,
|
|
336
|
+
};
|
|
337
|
+
this.accessTimes.set(id, now);
|
|
338
|
+
if (this.config.useWeakRefs && this.isLargeModel(model)) {
|
|
339
|
+
entry.weakRef = new WeakRef(model);
|
|
340
|
+
}
|
|
341
|
+
this.entries.set(id, entry);
|
|
342
|
+
this.addToTypeIndex(id, modelType);
|
|
343
|
+
// Populate the foreign-key indexes. The single-item `add()` path
|
|
344
|
+
// does this; `addBatch()` used to skip it, which meant every
|
|
345
|
+
// layer / sheet cell / message that came in through a bulk
|
|
346
|
+
// loader (`ensureDeckLayers`, `prefetchSlideLayers`, bootstrap
|
|
347
|
+
// hydration) was in the pool but invisible to `hasMany` lookups
|
|
348
|
+
// — `slide.layers` returned `[]` until the user clicked a layer
|
|
349
|
+
// and SOMETHING else ran a non-batch `add` that happened to
|
|
350
|
+
// populate the FK index as a side effect. The UX symptom was
|
|
351
|
+
// "slides show empty until you click on one." Adding this one
|
|
352
|
+
// line closes the gap.
|
|
353
|
+
this.addToForeignKeyIndex(id, model, modelType);
|
|
354
|
+
this.metrics.additions++;
|
|
355
|
+
addedCount++;
|
|
356
|
+
this.notifySubscribers(model);
|
|
357
|
+
// Notify views of the addition
|
|
358
|
+
this.viewRegistry.notifyAdded(modelType, model);
|
|
359
|
+
}
|
|
360
|
+
// No cache to invalidate — typeIndex + entries are directly observable
|
|
361
|
+
return addedCount;
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* Batch upsert models - optimized for delta processing.
|
|
365
|
+
* All upserts happen in a single MobX action to minimize reactivity overhead.
|
|
366
|
+
*/
|
|
367
|
+
upsertBatch(models, scope = ModelScope.live) {
|
|
368
|
+
if (models.length === 0)
|
|
369
|
+
return;
|
|
370
|
+
for (const model of models) {
|
|
371
|
+
const id = model.id;
|
|
372
|
+
const existingEntry = this.entries.get(id);
|
|
373
|
+
if (existingEntry?.model && !existingEntry.model.disposed) {
|
|
374
|
+
if (model !== existingEntry.model) {
|
|
375
|
+
existingEntry.model.updateFromData(model.toJSON());
|
|
376
|
+
}
|
|
377
|
+
if (existingEntry.scope !== scope) {
|
|
378
|
+
this.entries.set(id, { ...existingEntry, scope });
|
|
379
|
+
this.accessTimes.set(id, Date.now());
|
|
380
|
+
}
|
|
381
|
+
this.notifySubscribers(existingEntry.model);
|
|
382
|
+
// Notify views of the update
|
|
383
|
+
this.viewRegistry.notifyUpdated(existingEntry.model.getModelName(), existingEntry.model);
|
|
384
|
+
}
|
|
385
|
+
else {
|
|
386
|
+
// Delegate to inline add logic (same as addBatch internals)
|
|
387
|
+
const modelType = model.getModelName();
|
|
388
|
+
if (!this.typeIndex.has(modelType)) {
|
|
389
|
+
this.typeIndex.set(modelType, observable.set());
|
|
390
|
+
}
|
|
391
|
+
if (this.entries.size >= this.config.maxSize) {
|
|
392
|
+
this.evictOldest();
|
|
393
|
+
}
|
|
394
|
+
const entry = { model, scope };
|
|
395
|
+
this.accessTimes.set(id, Date.now());
|
|
396
|
+
if (this.config.useWeakRefs && this.isLargeModel(model)) {
|
|
397
|
+
entry.weakRef = new WeakRef(model);
|
|
398
|
+
}
|
|
399
|
+
this.entries.set(id, entry);
|
|
400
|
+
this.addToTypeIndex(id, modelType);
|
|
401
|
+
this.metrics.additions++;
|
|
402
|
+
this.notifySubscribers(model);
|
|
403
|
+
// Notify views of the addition
|
|
404
|
+
this.viewRegistry.notifyAdded(modelType, model);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
// No cache to invalidate — typeIndex + entries are directly observable
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* Batch remove models by ID - optimized for delta processing.
|
|
411
|
+
* All removals happen in a single MobX action to minimize reactivity overhead.
|
|
412
|
+
* Returns the number of models actually removed.
|
|
413
|
+
*/
|
|
414
|
+
removeBatch(ids) {
|
|
415
|
+
if (ids.length === 0)
|
|
416
|
+
return 0;
|
|
417
|
+
let removedCount = 0;
|
|
418
|
+
for (const id of ids) {
|
|
419
|
+
const entry = this.entries.get(id);
|
|
420
|
+
if (!entry)
|
|
421
|
+
continue;
|
|
422
|
+
const modelName = entry.model?.getModelName() || entry.weakRef?.deref()?.getModelName();
|
|
423
|
+
// FK/type cleanup must run BEFORE entries.delete — see `remove()`
|
|
424
|
+
// for the full explanation. Same bug, same fix.
|
|
425
|
+
this.removeFromTypeIndex(id, modelName);
|
|
426
|
+
this.entries.delete(id);
|
|
427
|
+
// Notify views of the removal before disposing
|
|
428
|
+
if (modelName) {
|
|
429
|
+
this.viewRegistry.notifyRemoved(modelName, id);
|
|
430
|
+
}
|
|
431
|
+
const model = entry.model || entry.weakRef?.deref();
|
|
432
|
+
model?.dispose?.();
|
|
433
|
+
const addKey = modelName ? `${modelName}:${id}` : id;
|
|
434
|
+
this.recentAdditions.delete(addKey);
|
|
435
|
+
this.deltaHistory.delete(addKey);
|
|
436
|
+
this.accessTimes.delete(id);
|
|
437
|
+
removedCount++;
|
|
438
|
+
}
|
|
439
|
+
// No cache to invalidate — typeIndex + entries are directly observable
|
|
440
|
+
return removedCount;
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* Read-only accessor for entity IDs by model type.
|
|
444
|
+
* Used by applyBootstrapToPool() and rehydrateFromDatabase() for ghost detection.
|
|
445
|
+
*/
|
|
446
|
+
getIdsByModelType(modelType) {
|
|
447
|
+
return this.typeIndex.get(modelType);
|
|
448
|
+
}
|
|
449
|
+
addToArchive(model) {
|
|
450
|
+
this.add(model, ModelScope.archived);
|
|
451
|
+
}
|
|
452
|
+
remove(id) {
|
|
453
|
+
const entry = this.entries.get(id);
|
|
454
|
+
if (!entry)
|
|
455
|
+
return false;
|
|
456
|
+
const modelName = entry.model?.getModelName() || entry.weakRef?.deref()?.getModelName();
|
|
457
|
+
// Order matters here: `removeFromTypeIndex` → `removeFromForeignKeyIndex`
|
|
458
|
+
// reads the FK field values off the model via `this.entries.get(id)`.
|
|
459
|
+
// If we `this.entries.delete(id)` first, the model is gone and the
|
|
460
|
+
// FK cleanup silently no-ops — leaving ghost ids in the FK index.
|
|
461
|
+
// That causes `getByForeignKey(..., parentId)` to report
|
|
462
|
+
// `matched > returned` (dropped-no-entry) and, on the UI, keeps the
|
|
463
|
+
// stale layer visible until the next reload rebuilds the index
|
|
464
|
+
// from fresh data. Do the FK/type cleanup FIRST, then delete the
|
|
465
|
+
// entry.
|
|
466
|
+
runInAction(() => {
|
|
467
|
+
this.removeFromTypeIndex(id, modelName);
|
|
468
|
+
this.entries.delete(id);
|
|
469
|
+
});
|
|
470
|
+
// No cache to invalidate — typeIndex + entries are directly observable
|
|
471
|
+
// Notify views of the removal before disposing
|
|
472
|
+
if (modelName) {
|
|
473
|
+
this.viewRegistry.notifyRemoved(modelName, id);
|
|
474
|
+
}
|
|
475
|
+
const model = entry.model || entry.weakRef?.deref();
|
|
476
|
+
model?.dispose?.();
|
|
477
|
+
// Clean tracking
|
|
478
|
+
const addKey = modelName ? `${modelName}:${id}` : id;
|
|
479
|
+
this.recentAdditions.delete(addKey);
|
|
480
|
+
this.deltaHistory.delete(addKey);
|
|
481
|
+
this.accessTimes.delete(id);
|
|
482
|
+
return true;
|
|
483
|
+
}
|
|
484
|
+
removeFromArchive(id) {
|
|
485
|
+
const entry = this.entries.get(id);
|
|
486
|
+
if (!entry || entry.scope !== ModelScope.archived) {
|
|
487
|
+
return false;
|
|
488
|
+
}
|
|
489
|
+
return this.remove(id);
|
|
490
|
+
}
|
|
491
|
+
getByType(modelClass, scope = ModelScope.all) {
|
|
492
|
+
// Linear-style: read typeIndex + entries directly. Both are observable maps,
|
|
493
|
+
// so MobX always tracks the dependency — no conditional cache path.
|
|
494
|
+
let actualModelName = this.registry.getModelNameFromConstructor(modelClass);
|
|
495
|
+
if (!actualModelName) {
|
|
496
|
+
actualModelName = this.registry.getModelNameFromConstructor(modelClass);
|
|
497
|
+
if (!actualModelName) {
|
|
498
|
+
try {
|
|
499
|
+
const ConcreteClass = modelClass;
|
|
500
|
+
const tempInstance = new ConcreteClass({});
|
|
501
|
+
actualModelName = tempInstance.getModelName();
|
|
502
|
+
// Fallback resolved — hand-coded class not in registry but name matches.
|
|
503
|
+
// This is expected during migration from hand-coded → dynamic models.
|
|
504
|
+
}
|
|
505
|
+
catch (e) {
|
|
506
|
+
getContext().observability.breadcrumb(`Failed to create fallback instance for ${modelClass.name}`, 'sync.database', 'error', {
|
|
507
|
+
error: e instanceof Error ? e.message : String(e),
|
|
508
|
+
});
|
|
509
|
+
return [];
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
// Read from typeIndex (observable) to get IDs for this model type
|
|
514
|
+
const ids = this.typeIndex.get(actualModelName || '');
|
|
515
|
+
if (!ids || ids.size === 0) {
|
|
516
|
+
return [];
|
|
517
|
+
}
|
|
518
|
+
// Resolve each ID from entries (observable) with scope filtering.
|
|
519
|
+
// Note: we do NOT check `instanceof modelClass` because schema-generated
|
|
520
|
+
// dynamic classes and hand-coded classes are different constructors that
|
|
521
|
+
// both represent the same model type. The typeIndex lookup by name is
|
|
522
|
+
// authoritative — if the name matched, the model belongs to this type.
|
|
523
|
+
const result = [];
|
|
524
|
+
for (const id of ids) {
|
|
525
|
+
const entry = this.entries.get(id);
|
|
526
|
+
if (!entry)
|
|
527
|
+
continue;
|
|
528
|
+
if (!this.matchesScope(entry.scope, scope))
|
|
529
|
+
continue;
|
|
530
|
+
const model = this.resolveModel(entry, id);
|
|
531
|
+
if (model && !model.disposed) {
|
|
532
|
+
result.push(model);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
return result;
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* Get all models of a given type by string name.
|
|
539
|
+
* Used for custom entity types where multiple entity type names share
|
|
540
|
+
* the same CustomEntityModel constructor (getByType can't disambiguate).
|
|
541
|
+
* Reads from the same typeIndex as getByType — MobX tracks the dependency.
|
|
542
|
+
*/
|
|
543
|
+
getByTypeName(modelName, scope = ModelScope.all) {
|
|
544
|
+
const ids = this.typeIndex.get(modelName);
|
|
545
|
+
if (!ids || ids.size === 0) {
|
|
546
|
+
return [];
|
|
547
|
+
}
|
|
548
|
+
const result = [];
|
|
549
|
+
for (const id of ids) {
|
|
550
|
+
const entry = this.entries.get(id);
|
|
551
|
+
if (!entry)
|
|
552
|
+
continue;
|
|
553
|
+
if (!this.matchesScope(entry.scope, scope))
|
|
554
|
+
continue;
|
|
555
|
+
const model = this.resolveModel(entry, id);
|
|
556
|
+
if (model && !model.disposed) {
|
|
557
|
+
result.push(model);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
return result;
|
|
561
|
+
}
|
|
562
|
+
*iterateByType(modelClass, scope = ModelScope.all) {
|
|
563
|
+
const actualModelName = this.registry.getModelNameFromConstructor(modelClass);
|
|
564
|
+
if (!actualModelName) {
|
|
565
|
+
throw new AbloValidationError(`Model class ${modelClass.name} not registered in ModelRegistry`, { code: 'pool_model_class_not_registered' });
|
|
566
|
+
}
|
|
567
|
+
const ids = this.typeIndex.get(actualModelName);
|
|
568
|
+
if (!ids)
|
|
569
|
+
return;
|
|
570
|
+
for (const id of ids) {
|
|
571
|
+
const entry = this.entries.get(id);
|
|
572
|
+
if (!entry)
|
|
573
|
+
continue;
|
|
574
|
+
if (!this.matchesScope(entry.scope, scope))
|
|
575
|
+
continue;
|
|
576
|
+
const model = this.get(id);
|
|
577
|
+
if (model && model instanceof modelClass) {
|
|
578
|
+
yield model;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
updateScope(id, scope) {
|
|
583
|
+
const entry = this.entries.get(id);
|
|
584
|
+
if (entry && entry.scope !== scope) {
|
|
585
|
+
// Re-set the entry so ObservableMap notifies observers of the change.
|
|
586
|
+
// Mutating entry.scope in-place wouldn't trigger MobX (plain object property).
|
|
587
|
+
runInAction(() => {
|
|
588
|
+
this.entries.set(id, { ...entry, scope });
|
|
589
|
+
});
|
|
590
|
+
this.accessTimes.set(id, Date.now());
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
/**
|
|
594
|
+
* Create (or update) a model instance locally, given a typename and raw
|
|
595
|
+
* data. Cleaner than `createFromData({ __typename, ...data })` — the
|
|
596
|
+
* typename lives in the arg list, not hidden inside the data object.
|
|
597
|
+
*
|
|
598
|
+
* Used for optimistic local writes: `pool.create('Slide', { id, deckId, ... })`.
|
|
599
|
+
* For hydration from server deltas (where `__typename` already rides on
|
|
600
|
+
* the payload), use `createFromData(data)` directly — that path is kept
|
|
601
|
+
* because the wire format attaches the discriminator to the data itself.
|
|
602
|
+
*/
|
|
603
|
+
create(typename, data) {
|
|
604
|
+
return this.createFromData({ ...data, __typename: typename });
|
|
605
|
+
}
|
|
606
|
+
createFromData(data, ModelClass) {
|
|
607
|
+
// Support multiple model identifier fields for backwards compatibility
|
|
608
|
+
const modelName = data.__typename ?? data.__class ?? data.modelName ?? 'Unknown';
|
|
609
|
+
const Constructor = ModelClass || this.registry.getModelByName(modelName);
|
|
610
|
+
if (!Constructor) {
|
|
611
|
+
if (!ModelClass && modelName === 'Unknown') {
|
|
612
|
+
getContext().logger.warn('ObjectPool.createFromData: No model identifier found', { data });
|
|
613
|
+
getContext().modelDebugLogger?.logError('Unknown', 'CREATE', 'No model identifier found', data);
|
|
614
|
+
return null;
|
|
615
|
+
}
|
|
616
|
+
getContext().logger.warn(`ObjectPool.createFromData: No constructor found for model "${modelName}"`, { data });
|
|
617
|
+
getContext().modelDebugLogger?.logError(modelName, 'CREATE', `No constructor found for model "${modelName}"`, data);
|
|
618
|
+
return null;
|
|
619
|
+
}
|
|
620
|
+
// Check if model already exists and UPDATE it instead of creating duplicate
|
|
621
|
+
// LINEAR PATTERN: Keep existing model instances alive, just update their data
|
|
622
|
+
// This preserves React's references and MobX observation tracking
|
|
623
|
+
if (data.id && this.entries.has(data.id)) {
|
|
624
|
+
const existing = this.get(data.id);
|
|
625
|
+
if (existing && existing.getModelName() === modelName) {
|
|
626
|
+
// Same ID and same type - update existing model with new data and return it
|
|
627
|
+
existing.updateFromData(data);
|
|
628
|
+
return existing;
|
|
629
|
+
}
|
|
630
|
+
// Different type with same ID - this is a shared PK scenario (e.g., Project/Dataroom)
|
|
631
|
+
// Don't return existing, create new model (will use composite key for storage)
|
|
632
|
+
}
|
|
633
|
+
// Log model creation attempt
|
|
634
|
+
getContext().modelDebugLogger?.logCreation(modelName, data, Constructor);
|
|
635
|
+
try {
|
|
636
|
+
// Pass data directly to constructor for Prisma-first models
|
|
637
|
+
const model = new Constructor(data);
|
|
638
|
+
return model;
|
|
639
|
+
}
|
|
640
|
+
catch (error) {
|
|
641
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
642
|
+
getContext().logger.warn(`[ObjectPool.createFromData] FAILED ${modelName}`, { errorMessage, stack: error instanceof Error ? error.stack : undefined });
|
|
643
|
+
getContext().observability.captureTransactionFailure({
|
|
644
|
+
context: 'createFromData',
|
|
645
|
+
modelName,
|
|
646
|
+
modelId: data.id,
|
|
647
|
+
error: errorMessage,
|
|
648
|
+
});
|
|
649
|
+
getContext().modelDebugLogger?.logError(modelName, 'CREATE', errorMessage, {
|
|
650
|
+
data,
|
|
651
|
+
constructor: Constructor.name,
|
|
652
|
+
});
|
|
653
|
+
return null;
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
/**
|
|
657
|
+
* Clear the object pool
|
|
658
|
+
* @param options.preserveObserved - If true, keep models that are being observed by React
|
|
659
|
+
* This prevents React components from holding stale references
|
|
660
|
+
* after bootstrap/rehydration
|
|
661
|
+
*/
|
|
662
|
+
clear(options = {}) {
|
|
663
|
+
const preserveObserved = options.preserveObserved ?? false;
|
|
664
|
+
const preservedIds = [];
|
|
665
|
+
const preservedEntries = [];
|
|
666
|
+
let disposedCount = 0;
|
|
667
|
+
let checkedCount = 0;
|
|
668
|
+
for (const [id, entry] of this.entries) {
|
|
669
|
+
const model = entry.model || entry.weakRef?.deref();
|
|
670
|
+
checkedCount++;
|
|
671
|
+
// Check if this model should be preserved (has active React observers)
|
|
672
|
+
if (preserveObserved &&
|
|
673
|
+
model &&
|
|
674
|
+
typeof model.hasObservedCollections === 'function' &&
|
|
675
|
+
model.hasObservedCollections()) {
|
|
676
|
+
// Keep this model alive - React is still using it
|
|
677
|
+
preservedIds.push(id);
|
|
678
|
+
preservedEntries.push([id, entry]);
|
|
679
|
+
continue;
|
|
680
|
+
}
|
|
681
|
+
model?.dispose?.();
|
|
682
|
+
disposedCount++;
|
|
683
|
+
}
|
|
684
|
+
// Save access times for preserved entries before clearing
|
|
685
|
+
const preservedAccessTimes = new Map();
|
|
686
|
+
for (const [id] of preservedEntries) {
|
|
687
|
+
const time = this.accessTimes.get(id);
|
|
688
|
+
if (time)
|
|
689
|
+
preservedAccessTimes.set(id, time);
|
|
690
|
+
}
|
|
691
|
+
runInAction(() => {
|
|
692
|
+
this.entries.clear();
|
|
693
|
+
this.typeIndex.clear();
|
|
694
|
+
// Clear foreign key index data (preserves config/structure, just empties the value maps)
|
|
695
|
+
for (const index of this.foreignKeyIndexes.values()) {
|
|
696
|
+
index.clear();
|
|
697
|
+
}
|
|
698
|
+
this.recentAdditions.clear();
|
|
699
|
+
this.deltaHistory.clear();
|
|
700
|
+
this.metrics = {
|
|
701
|
+
hits: 0,
|
|
702
|
+
misses: 0,
|
|
703
|
+
evictions: 0,
|
|
704
|
+
additions: 0,
|
|
705
|
+
duplicatesSkipped: 0,
|
|
706
|
+
};
|
|
707
|
+
// Re-add preserved entries (also rebuilds foreign key indexes via addToTypeIndex)
|
|
708
|
+
for (const [id, entry] of preservedEntries) {
|
|
709
|
+
this.entries.set(id, entry);
|
|
710
|
+
const model = entry.model || entry.weakRef?.deref();
|
|
711
|
+
if (model) {
|
|
712
|
+
this.addToTypeIndex(id, model.getModelName());
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
});
|
|
716
|
+
// Restore access times: clear then re-add preserved
|
|
717
|
+
this.accessTimes.clear();
|
|
718
|
+
for (const [id, time] of preservedAccessTimes) {
|
|
719
|
+
this.accessTimes.set(id, time);
|
|
720
|
+
}
|
|
721
|
+
// No cache to invalidate — typeIndex + entries are directly observable
|
|
722
|
+
}
|
|
723
|
+
has(id) {
|
|
724
|
+
return this.entries.has(id);
|
|
725
|
+
}
|
|
726
|
+
/**
|
|
727
|
+
* Touch a model to update its access time (prevents premature GC)
|
|
728
|
+
* Used by LazyReferenceCollection to keep parent models alive during active usage
|
|
729
|
+
*/
|
|
730
|
+
touch(id) {
|
|
731
|
+
const entry = this.entries.get(id);
|
|
732
|
+
if (!entry) {
|
|
733
|
+
return false;
|
|
734
|
+
}
|
|
735
|
+
this.accessTimes.set(id, Date.now());
|
|
736
|
+
return true;
|
|
737
|
+
}
|
|
738
|
+
getAllIds() {
|
|
739
|
+
return Array.from(this.entries.keys());
|
|
740
|
+
}
|
|
741
|
+
getAllModels() {
|
|
742
|
+
const results = [];
|
|
743
|
+
for (const [id] of this.entries) {
|
|
744
|
+
const model = this.get(id);
|
|
745
|
+
if (model) {
|
|
746
|
+
results.push(model);
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
return results;
|
|
750
|
+
}
|
|
751
|
+
get size() {
|
|
752
|
+
return this.entries.size;
|
|
753
|
+
}
|
|
754
|
+
get hitRate() {
|
|
755
|
+
const total = this.metrics.hits + this.metrics.misses;
|
|
756
|
+
return total > 0 ? (this.metrics.hits / total) * 100 : 0;
|
|
757
|
+
}
|
|
758
|
+
getStats() {
|
|
759
|
+
const scopeCounts = { live: 0, archived: 0 };
|
|
760
|
+
const typeCounts = new Map();
|
|
761
|
+
for (const [, entry] of this.entries) {
|
|
762
|
+
if (entry.scope === ModelScope.live)
|
|
763
|
+
scopeCounts.live++;
|
|
764
|
+
else if (entry.scope === ModelScope.archived)
|
|
765
|
+
scopeCounts.archived++;
|
|
766
|
+
const modelName = entry.model?.getModelName() || entry.weakRef?.deref()?.getModelName();
|
|
767
|
+
if (modelName) {
|
|
768
|
+
typeCounts.set(modelName, (typeCounts.get(modelName) || 0) + 1);
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
return {
|
|
772
|
+
size: this.size,
|
|
773
|
+
hitRate: this.hitRate,
|
|
774
|
+
metrics: { ...this.metrics },
|
|
775
|
+
scopeCounts,
|
|
776
|
+
typeCounts: Object.fromEntries(typeCounts),
|
|
777
|
+
deltaHistorySize: this.deltaHistory.size,
|
|
778
|
+
recentAdditionsSize: this.recentAdditions.size,
|
|
779
|
+
config: { ...this.config },
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
clearDeltaHistory(olderThanMs = 3600000) {
|
|
783
|
+
const now = Date.now();
|
|
784
|
+
const toDelete = [];
|
|
785
|
+
for (const [key, history] of this.deltaHistory) {
|
|
786
|
+
if (now - history.timestamp > olderThanMs) {
|
|
787
|
+
toDelete.push(key);
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
toDelete.forEach((key) => this.deltaHistory.delete(key));
|
|
791
|
+
// Delta history entries cleared silently
|
|
792
|
+
}
|
|
793
|
+
cleanupTracking() {
|
|
794
|
+
const now = Date.now();
|
|
795
|
+
for (const [key, time] of this.recentAdditions) {
|
|
796
|
+
if (now - time > 1000) {
|
|
797
|
+
this.recentAdditions.delete(key);
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
gc() {
|
|
802
|
+
return runInAction(() => {
|
|
803
|
+
const now = Date.now();
|
|
804
|
+
const toRemove = [];
|
|
805
|
+
let evicted = 0;
|
|
806
|
+
let skippedObserved = 0;
|
|
807
|
+
for (const [id, entry] of this.entries) {
|
|
808
|
+
// Check if model has expired based on last access time
|
|
809
|
+
const lastAccessed = this.accessTimes.get(id) || 0;
|
|
810
|
+
if (now - lastAccessed > this.config.maxAge) {
|
|
811
|
+
// CRITICAL: Check if model has observed collections before GC
|
|
812
|
+
// Following MobX best practice: don't dispose models being observed by React
|
|
813
|
+
// See: https://mobx.js.org/lazy-observables.html
|
|
814
|
+
const model = entry.model || entry.weakRef?.deref();
|
|
815
|
+
if (model &&
|
|
816
|
+
typeof model.hasObservedCollections === 'function' &&
|
|
817
|
+
model.hasObservedCollections()) {
|
|
818
|
+
// Model has active React observers - refresh access time and skip GC
|
|
819
|
+
this.accessTimes.set(id, now);
|
|
820
|
+
skippedObserved++;
|
|
821
|
+
continue;
|
|
822
|
+
}
|
|
823
|
+
toRemove.push(id);
|
|
824
|
+
continue;
|
|
825
|
+
}
|
|
826
|
+
// Strong-to-weak-ref demotion at `maxAge / 2` used to live here,
|
|
827
|
+
// in service of memory-pressure relief: idle entries would lose
|
|
828
|
+
// their strong reference, V8 would collect them, and the next
|
|
829
|
+
// access would re-hydrate from IDB/network. In practice it
|
|
830
|
+
// caused silent data loss — any model actively being rendered
|
|
831
|
+
// through a schema-driven dynamic class (i.e., most of them)
|
|
832
|
+
// would be demoted, collected, and the next render's
|
|
833
|
+
// `weakRef.deref()` returned undefined, so layers / cells /
|
|
834
|
+
// messages "disappeared" after ~10 min of idle.
|
|
835
|
+
//
|
|
836
|
+
// The `hasObservedCollections()` guard used by the eviction
|
|
837
|
+
// branch above only protects models that explicitly register a
|
|
838
|
+
// LazyReferenceCollection; plain observer() components reading
|
|
839
|
+
// properties don't register, so for typical UI usage the guard
|
|
840
|
+
// didn't apply. Rather than try to make React-observation
|
|
841
|
+
// globally visible to the pool, we drop the demotion phase
|
|
842
|
+
// entirely — hard eviction at `maxAge` (with its own guard) is
|
|
843
|
+
// the only automated removal now. If memory-pressure relief is
|
|
844
|
+
// needed later, gate it on an explicit policy (e.g.,
|
|
845
|
+
// `documenthidden` + `performance.memory.usedJSHeapSize`) rather
|
|
846
|
+
// than a time-based tick.
|
|
847
|
+
}
|
|
848
|
+
for (const id of toRemove) {
|
|
849
|
+
if (this.remove(id)) {
|
|
850
|
+
evicted++;
|
|
851
|
+
this.metrics.evictions++;
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
if (skippedObserved > 0) {
|
|
855
|
+
getContext().logger.debug(`[ObjectPool GC] Skipped ${skippedObserved} models with active React observers`);
|
|
856
|
+
}
|
|
857
|
+
// Also clean up old tracking data
|
|
858
|
+
this.clearDeltaHistory();
|
|
859
|
+
this.cleanupTracking();
|
|
860
|
+
return evicted;
|
|
861
|
+
});
|
|
862
|
+
}
|
|
863
|
+
startGC() {
|
|
864
|
+
if (this.gcTimer)
|
|
865
|
+
return;
|
|
866
|
+
this.gcTimer = setInterval(() => this.gc(), this.config.gcInterval);
|
|
867
|
+
}
|
|
868
|
+
stopGC() {
|
|
869
|
+
if (this.gcTimer) {
|
|
870
|
+
clearInterval(this.gcTimer);
|
|
871
|
+
this.gcTimer = undefined;
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
evictOldest() {
|
|
875
|
+
runInAction(() => {
|
|
876
|
+
let oldest;
|
|
877
|
+
let oldestTime = Date.now();
|
|
878
|
+
for (const [id, entry] of this.entries) {
|
|
879
|
+
// Skip models that are being observed by React - they must stay alive
|
|
880
|
+
const model = entry.model || entry.weakRef?.deref();
|
|
881
|
+
if (model &&
|
|
882
|
+
typeof model.hasObservedCollections === 'function' &&
|
|
883
|
+
model.hasObservedCollections()) {
|
|
884
|
+
continue;
|
|
885
|
+
}
|
|
886
|
+
const entryAccessTime = this.accessTimes.get(id) || 0;
|
|
887
|
+
if (entryAccessTime < oldestTime) {
|
|
888
|
+
oldest = [id, entry];
|
|
889
|
+
oldestTime = entryAccessTime;
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
if (oldest) {
|
|
893
|
+
this.remove(oldest[0]);
|
|
894
|
+
this.metrics.evictions++;
|
|
895
|
+
}
|
|
896
|
+
});
|
|
897
|
+
}
|
|
898
|
+
isLargeModel(model) {
|
|
899
|
+
try {
|
|
900
|
+
const size = JSON.stringify(model).length;
|
|
901
|
+
return size > 10240;
|
|
902
|
+
}
|
|
903
|
+
catch {
|
|
904
|
+
return false;
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
// ========== FOREIGN KEY INDEX ==========
|
|
908
|
+
/**
|
|
909
|
+
* Register a foreign key field for indexing on a model type.
|
|
910
|
+
* Call once during app initialization (e.g., after model registration).
|
|
911
|
+
*
|
|
912
|
+
* Example: registerForeignKey('SlideLayer', 'slideId')
|
|
913
|
+
* This enables getByForeignKey('SlideLayer', 'slideId', someSlideId) → O(1) lookup
|
|
914
|
+
*/
|
|
915
|
+
registerForeignKey(modelName, fieldName) {
|
|
916
|
+
const fields = this.foreignKeyConfig.get(modelName) ?? [];
|
|
917
|
+
if (!fields.includes(fieldName)) {
|
|
918
|
+
fields.push(fieldName);
|
|
919
|
+
this.foreignKeyConfig.set(modelName, fields);
|
|
920
|
+
}
|
|
921
|
+
// Initialize the index map
|
|
922
|
+
const indexKey = `${modelName}:${fieldName}`;
|
|
923
|
+
if (!this.foreignKeyIndexes.has(indexKey)) {
|
|
924
|
+
this.foreignKeyIndexes.set(indexKey, observable.map());
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
/**
|
|
928
|
+
* Check whether a foreign key index exists for a given typename + field.
|
|
929
|
+
* Used by QueryView to decide whether to use FK-index for initial scan.
|
|
930
|
+
*/
|
|
931
|
+
hasForeignKeyIndex(typename, fieldName) {
|
|
932
|
+
const indexKey = `${typename}:${fieldName}`;
|
|
933
|
+
return this.foreignKeyIndexes.has(indexKey);
|
|
934
|
+
}
|
|
935
|
+
/**
|
|
936
|
+
* Create a QueryView — an incrementally maintained materialized view.
|
|
937
|
+
* The view registers itself with the ViewRegistry and receives
|
|
938
|
+
* incremental updates when models of the given typename change.
|
|
939
|
+
*/
|
|
940
|
+
createView(typename, options) {
|
|
941
|
+
return new QueryView(typename, this, this.viewRegistry, options);
|
|
942
|
+
}
|
|
943
|
+
/**
|
|
944
|
+
* O(1) lookup of models by foreign key value.
|
|
945
|
+
* Returns model instances, filtered to live scope by default.
|
|
946
|
+
*/
|
|
947
|
+
getByForeignKey(modelName, fieldName, fieldValue) {
|
|
948
|
+
const indexKey = `${modelName}:${fieldName}`;
|
|
949
|
+
const index = this.foreignKeyIndexes.get(indexKey);
|
|
950
|
+
// Both empty-path early-returns below are NORMAL states, not errors:
|
|
951
|
+
// a model with no FK index yet (not populated), or an index with no
|
|
952
|
+
// entry for this specific parent id (entity genuinely has no
|
|
953
|
+
// children). These used to `console.warn` diagnostic dumps on every
|
|
954
|
+
// call, which turned into hundreds of log lines per second during
|
|
955
|
+
// cursor hover / rapid re-renders on the deck page. If a caller
|
|
956
|
+
// needs visibility into "why is this empty," wire an opt-in
|
|
957
|
+
// `logger.debug` at the specific call site rather than re-adding
|
|
958
|
+
// a blanket warn here.
|
|
959
|
+
if (!index)
|
|
960
|
+
return [];
|
|
961
|
+
const ids = index.get(fieldValue);
|
|
962
|
+
if (!ids || ids.size === 0)
|
|
963
|
+
return [];
|
|
964
|
+
const result = [];
|
|
965
|
+
let droppedNoEntry = 0;
|
|
966
|
+
let droppedScope = 0;
|
|
967
|
+
let droppedDisposed = 0;
|
|
968
|
+
for (const id of ids) {
|
|
969
|
+
const entry = this.entries.get(id);
|
|
970
|
+
if (!entry) {
|
|
971
|
+
droppedNoEntry++;
|
|
972
|
+
continue;
|
|
973
|
+
}
|
|
974
|
+
if (!this.matchesScope(entry.scope, ModelScope.live)) {
|
|
975
|
+
droppedScope++;
|
|
976
|
+
continue;
|
|
977
|
+
}
|
|
978
|
+
const model = this.resolveModel(entry, id);
|
|
979
|
+
if (model && !model.disposed) {
|
|
980
|
+
result.push(model);
|
|
981
|
+
}
|
|
982
|
+
else if (model?.disposed) {
|
|
983
|
+
droppedDisposed++;
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
if (droppedNoEntry || droppedScope || droppedDisposed) {
|
|
987
|
+
// Debug-level: happens on every render when a foreign-key index
|
|
988
|
+
// has dangling refs (legacy orphan deltas, pending CREATE
|
|
989
|
+
// transactions, etc.). Noisy at warn level, useful during
|
|
990
|
+
// investigation.
|
|
991
|
+
getContext().logger.debug('[ObjectPool.getByForeignKey] ROWS DROPPED', {
|
|
992
|
+
modelName,
|
|
993
|
+
fieldName,
|
|
994
|
+
fieldValue,
|
|
995
|
+
matched: ids.size,
|
|
996
|
+
returned: result.length,
|
|
997
|
+
droppedNoEntry,
|
|
998
|
+
droppedScope,
|
|
999
|
+
droppedDisposed,
|
|
1000
|
+
});
|
|
1001
|
+
}
|
|
1002
|
+
return result;
|
|
1003
|
+
}
|
|
1004
|
+
/**
|
|
1005
|
+
* Add a model to foreign key indexes (called from addToTypeIndex path)
|
|
1006
|
+
*/
|
|
1007
|
+
addToForeignKeyIndex(id, model, modelName) {
|
|
1008
|
+
// Silent no-ops for "no config / non-string value / missing index"
|
|
1009
|
+
// — all three are legitimate states (non-indexed model, optional
|
|
1010
|
+
// nullable FK, index not yet registered because the batch ran
|
|
1011
|
+
// before schema registration completed). Diagnostic warns that
|
|
1012
|
+
// used to live here spammed the console on every hot-path load.
|
|
1013
|
+
const fields = this.foreignKeyConfig.get(modelName);
|
|
1014
|
+
if (!fields)
|
|
1015
|
+
return;
|
|
1016
|
+
for (const fieldName of fields) {
|
|
1017
|
+
const fieldValue = Reflect.get(model, fieldName);
|
|
1018
|
+
if (typeof fieldValue !== 'string')
|
|
1019
|
+
continue;
|
|
1020
|
+
const indexKey = `${modelName}:${fieldName}`;
|
|
1021
|
+
const index = this.foreignKeyIndexes.get(indexKey);
|
|
1022
|
+
if (!index)
|
|
1023
|
+
continue;
|
|
1024
|
+
let ids = index.get(fieldValue);
|
|
1025
|
+
if (!ids) {
|
|
1026
|
+
ids = observable.set();
|
|
1027
|
+
index.set(fieldValue, ids);
|
|
1028
|
+
}
|
|
1029
|
+
ids.add(id);
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
/**
|
|
1033
|
+
* Remove a model from foreign key indexes (called from removeFromTypeIndex path)
|
|
1034
|
+
*/
|
|
1035
|
+
removeFromForeignKeyIndex(id, modelName) {
|
|
1036
|
+
if (!modelName)
|
|
1037
|
+
return;
|
|
1038
|
+
const fields = this.foreignKeyConfig.get(modelName);
|
|
1039
|
+
if (!fields)
|
|
1040
|
+
return;
|
|
1041
|
+
// We need the model to read the foreign key value
|
|
1042
|
+
const entry = this.entries.get(id);
|
|
1043
|
+
const model = entry?.model ?? entry?.weakRef?.deref();
|
|
1044
|
+
if (!model)
|
|
1045
|
+
return;
|
|
1046
|
+
for (const fieldName of fields) {
|
|
1047
|
+
const fieldValue = Reflect.get(model, fieldName);
|
|
1048
|
+
if (typeof fieldValue !== 'string')
|
|
1049
|
+
continue;
|
|
1050
|
+
const indexKey = `${modelName}:${fieldName}`;
|
|
1051
|
+
const index = this.foreignKeyIndexes.get(indexKey);
|
|
1052
|
+
if (!index)
|
|
1053
|
+
continue;
|
|
1054
|
+
const ids = index.get(fieldValue);
|
|
1055
|
+
if (ids) {
|
|
1056
|
+
ids.delete(id);
|
|
1057
|
+
if (ids.size === 0) {
|
|
1058
|
+
index.delete(fieldValue);
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
addToTypeIndex(id, modelName) {
|
|
1064
|
+
if (!modelName)
|
|
1065
|
+
return;
|
|
1066
|
+
let ids = this.typeIndex.get(modelName);
|
|
1067
|
+
if (!ids) {
|
|
1068
|
+
ids = observable.set();
|
|
1069
|
+
this.typeIndex.set(modelName, ids);
|
|
1070
|
+
}
|
|
1071
|
+
ids.add(id);
|
|
1072
|
+
// Update foreign key indexes. If we can't reach the model object,
|
|
1073
|
+
// the FK index will be (re)populated on the first lookup that
|
|
1074
|
+
// resolves the entry — no need to warn here.
|
|
1075
|
+
const entry = this.entries.get(id);
|
|
1076
|
+
const model = entry?.model ?? entry?.weakRef?.deref();
|
|
1077
|
+
if (model) {
|
|
1078
|
+
this.addToForeignKeyIndex(id, model, modelName);
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
removeFromTypeIndex(id, modelName) {
|
|
1082
|
+
if (!modelName)
|
|
1083
|
+
return;
|
|
1084
|
+
// Remove from foreign key indexes BEFORE removing from entries
|
|
1085
|
+
this.removeFromForeignKeyIndex(id, modelName);
|
|
1086
|
+
const ids = this.typeIndex.get(modelName);
|
|
1087
|
+
if (ids) {
|
|
1088
|
+
ids.delete(id);
|
|
1089
|
+
if (ids.size === 0) {
|
|
1090
|
+
this.typeIndex.delete(modelName);
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
matchesScope(entryScope, queryScope) {
|
|
1095
|
+
switch (queryScope) {
|
|
1096
|
+
case ModelScope.all:
|
|
1097
|
+
return true;
|
|
1098
|
+
case ModelScope.live:
|
|
1099
|
+
return entryScope === ModelScope.live;
|
|
1100
|
+
case ModelScope.archived:
|
|
1101
|
+
return entryScope === ModelScope.archived;
|
|
1102
|
+
default:
|
|
1103
|
+
return entryScope === queryScope;
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
}
|