-
-
Notifications
You must be signed in to change notification settings - Fork 1.3k
/
instance-cache.ts
485 lines (420 loc) · 17.9 KB
/
instance-cache.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
import { assert, warn } from '@ember/debug';
import { importSync } from '@embroider/macros';
import { LOG_INSTANCE_CACHE } from '@ember-data/debugging';
import { DEBUG } from '@ember-data/env';
import type { Graph } from '@ember-data/graph/-private/graph/graph';
import type { peekGraph } from '@ember-data/graph/-private/graph/index';
import { HAS_GRAPH_PACKAGE } from '@ember-data/packages';
import type { Cache } from '@ember-data/types/q/cache';
import type { CacheStoreWrapper as StoreWrapper } from '@ember-data/types/q/cache-store-wrapper';
import type {
ExistingResourceIdentifierObject,
NewResourceIdentifierObject,
} from '@ember-data/types/q/ember-data-json-api';
import type { RecordIdentifier, StableRecordIdentifier } from '@ember-data/types/q/identifier';
import type { JsonApiRelationship, JsonApiResource } from '@ember-data/types/q/record-data-json-api';
import type { RelationshipSchema } from '@ember-data/types/q/record-data-schemas';
import type { RecordInstance } from '@ember-data/types/q/record-instance';
import RecordReference from '../legacy-model-support/record-reference';
import { CacheManager } from '../managers/cache-manager';
import { CacheStoreWrapper } from '../managers/cache-store-wrapper';
import type { CreateRecordProperties } from '../store-service';
import type Store from '../store-service';
import { CacheForIdentifierCache, removeRecordDataFor, setCacheFor } from './cache-utils';
let _peekGraph: peekGraph;
if (HAS_GRAPH_PACKAGE) {
let __peekGraph: peekGraph;
_peekGraph = (wrapper: Store | StoreWrapper): Graph | undefined => {
let a = (importSync('@ember-data/graph/-private') as { peekGraph: peekGraph }).peekGraph;
__peekGraph = __peekGraph || a;
return __peekGraph(wrapper);
};
}
/**
@module @ember-data/store
*/
const RecordCache = new Map<RecordInstance, StableRecordIdentifier>();
export function peekRecordIdentifier(record: RecordInstance): StableRecordIdentifier | undefined {
return RecordCache.get(record);
}
/**
Retrieves the unique referentially-stable [RecordIdentifier](/ember-data/release/classes/StableRecordIdentifier)
assigned to the given record instance.
```js
import { recordIdentifierFor } from "@ember-data/store";
// ... gain access to a record, for instance with peekRecord or findRecord
const record = store.peekRecord("user", "1");
// get the identifier for the record (see docs for StableRecordIdentifier)
const identifier = recordIdentifierFor(record);
// access the identifier's properties.
const { id, type, lid } = identifier;
```
@method recordIdentifierFor
@public
@static
@for @ember-data/store
@param {Object} record a record instance previously obstained from the store.
@returns {StableRecordIdentifier}
*/
export function recordIdentifierFor(record: RecordInstance): StableRecordIdentifier {
assert(`${String(record)} is not a record instantiated by @ember-data/store`, RecordCache.has(record));
return RecordCache.get(record)!;
}
export function setRecordIdentifier(record: RecordInstance, identifier: StableRecordIdentifier): void {
if (DEBUG) {
if (RecordCache.has(record) && RecordCache.get(record) !== identifier) {
throw new Error(`${String(record)} was already assigned an identifier`);
}
}
/*
It would be nice to do a reverse check here that an identifier has not
previously been assigned a record; however, unload + rematerialization
prevents us from having a great way of doing so when CustomRecordClasses
don't necessarily give us access to a `isDestroyed` for dematerialized
instance.
*/
RecordCache.set(record, identifier);
}
export const StoreMap = new Map<RecordInstance, Store>();
export function storeFor(record: RecordInstance): Store | undefined {
const store = StoreMap.get(record);
assert(
`A record in a disconnected state cannot utilize the store. This typically means the record has been destroyed, most commonly by unloading it.`,
store
);
return store;
}
type Caches = {
record: Map<StableRecordIdentifier, RecordInstance>;
reference: WeakMap<StableRecordIdentifier, RecordReference>;
};
export class InstanceCache {
declare store: Store;
declare cache: Cache;
declare _storeWrapper: CacheStoreWrapper;
declare __cacheFor: (resource: RecordIdentifier) => Cache;
declare __cacheManager: CacheManager;
__instances: Caches = {
record: new Map<StableRecordIdentifier, RecordInstance>(),
reference: new WeakMap<StableRecordIdentifier, RecordReference>(),
};
constructor(store: Store) {
this.store = store;
this._storeWrapper = new CacheStoreWrapper(this.store);
store.identifierCache.__configureMerge(
(identifier: StableRecordIdentifier, matchedIdentifier: StableRecordIdentifier, resourceData) => {
let keptIdentifier = identifier;
if (identifier.id !== matchedIdentifier.id) {
keptIdentifier = 'id' in resourceData && identifier.id === resourceData.id ? identifier : matchedIdentifier;
} else if (identifier.type !== matchedIdentifier.type) {
keptIdentifier =
'type' in resourceData && identifier.type === resourceData.type ? identifier : matchedIdentifier;
}
let staleIdentifier = identifier === keptIdentifier ? matchedIdentifier : identifier;
// check for duplicate entities
let keptHasRecord = this.__instances.record.has(keptIdentifier);
let staleHasRecord = this.__instances.record.has(staleIdentifier);
// we cannot merge entities when both have records
// (this may not be strictly true, we could probably swap the cache data the record points at)
if (keptHasRecord && staleHasRecord) {
// TODO we probably don't need to throw these errors anymore
// we can probably just "swap" what data source the abandoned
// record points at so long as
// it itself is not retained by the store in any way.
if ('id' in resourceData) {
throw new Error(
`Failed to update the 'id' for the RecordIdentifier '${identifier.type}:${String(identifier.id)} (${
identifier.lid
})' to '${String(resourceData.id)}', because that id is already in use by '${
matchedIdentifier.type
}:${String(matchedIdentifier.id)} (${matchedIdentifier.lid})'`
);
}
assert(
`Failed to update the RecordIdentifier '${identifier.type}:${String(identifier.id)} (${
identifier.lid
})' to merge with the detected duplicate identifier '${matchedIdentifier.type}:${String(
matchedIdentifier.id
)} (${String(matchedIdentifier.lid)})'`
);
}
this.store.cache.patch({
op: 'mergeIdentifiers',
record: staleIdentifier,
value: keptIdentifier,
});
/*
TODO @runspired consider adding this to make polymorphism even nicer
if (HAS_GRAPH_PACKAGE) {
if (identifier.type !== matchedIdentifier.type) {
const graphFor = importSync('@ember-data/graph/-private').graphFor;
graphFor(this).registerPolymorphicType(identifier.type, matchedIdentifier.type);
}
}
*/
this.unloadRecord(staleIdentifier);
return keptIdentifier;
}
);
}
peek(identifier: StableRecordIdentifier): Cache | RecordInstance | undefined {
return this.__instances.record.get(identifier);
}
getRecord(identifier: StableRecordIdentifier, properties?: CreateRecordProperties): RecordInstance {
let record = this.__instances.record.get(identifier);
if (!record) {
assert(
`Cannot create a new record instance while the store is being destroyed`,
!this.store.isDestroying && !this.store.isDestroyed
);
const cache = this.store.cache;
setCacheFor(identifier, cache);
record = this.store.instantiateRecord(identifier, properties || {});
setRecordIdentifier(record, identifier);
setCacheFor(record, cache);
StoreMap.set(record, this.store);
this.__instances.record.set(identifier, record);
if (LOG_INSTANCE_CACHE) {
// eslint-disable-next-line no-console
console.log(`InstanceCache: created Record for ${String(identifier)}`, properties);
}
}
return record;
}
getReference(identifier: StableRecordIdentifier) {
let cache = this.__instances.reference;
let reference = cache.get(identifier);
if (!reference) {
reference = new RecordReference(this.store, identifier);
cache.set(identifier, reference);
}
return reference;
}
recordIsLoaded(identifier: StableRecordIdentifier, filterDeleted: boolean = false) {
const cache = this.cache;
if (!cache) {
return false;
}
const isNew = cache.isNew(identifier);
const isEmpty = cache.isEmpty(identifier);
// if we are new we must consider ourselves loaded
if (isNew) {
return !cache.isDeleted(identifier);
}
// even if we have a past request, if we are now empty we are not loaded
// typically this is true after an unloadRecord call
// if we are not empty, not new && we have a fulfilled request then we are loaded
// we should consider allowing for something to be loaded that is simply "not empty".
// which is how RecordState currently handles this case; however, RecordState is buggy
// in that it does not account for unloading.
return filterDeleted && cache.isDeletionCommitted(identifier) ? false : !isEmpty;
}
disconnect(identifier: StableRecordIdentifier) {
const record = this.__instances.record.get(identifier);
assert(
'Cannot destroy record while it is still materialized',
!record || record.isDestroyed || record.isDestroying
);
if (HAS_GRAPH_PACKAGE) {
let graph = _peekGraph(this.store);
if (graph) {
graph.remove(identifier);
}
}
this.store.identifierCache.forgetRecordIdentifier(identifier);
removeRecordDataFor(identifier);
this.store._requestCache._clearEntries(identifier);
if (LOG_INSTANCE_CACHE) {
// eslint-disable-next-line no-console
console.log(`InstanceCache: disconnected ${String(identifier)}`);
}
}
unloadRecord(identifier: StableRecordIdentifier) {
if (DEBUG) {
const requests = this.store.getRequestStateService().getPendingRequestsForRecord(identifier);
if (
requests.some((req) => {
return req.type === 'mutation';
})
) {
assert(`You can only unload a record which is not inFlight. '${String(identifier)}'`);
}
}
if (LOG_INSTANCE_CACHE) {
// eslint-disable-next-line no-console
console.groupCollapsed(`InstanceCache: unloading record for ${String(identifier)}`);
}
// TODO is this join still necessary?
this.store._join(() => {
const record = this.__instances.record.get(identifier);
const cache = this.cache;
if (record) {
this.store.teardownRecord(record);
this.__instances.record.delete(identifier);
StoreMap.delete(record);
RecordCache.delete(record);
removeRecordDataFor(record);
if (LOG_INSTANCE_CACHE) {
// eslint-disable-next-line no-console
console.log(`InstanceCache: destroyed record for ${String(identifier)}`);
}
}
if (cache) {
cache.unloadRecord(identifier);
removeRecordDataFor(identifier);
if (LOG_INSTANCE_CACHE) {
// eslint-disable-next-line no-console
console.log(`InstanceCache: destroyed cache for ${String(identifier)}`);
}
} else {
this.disconnect(identifier);
}
this.store._requestCache._clearEntries(identifier);
if (LOG_INSTANCE_CACHE) {
// eslint-disable-next-line no-console
console.log(`InstanceCache: unloaded RecordData for ${String(identifier)}`);
// eslint-disable-next-line no-console
console.groupEnd();
}
});
}
clear(type?: string) {
const cache = this.store.identifierCache._cache;
if (type === undefined) {
// it would be cool if we could just de-ref cache here
// but probably would require WeakRef models to do so.
cache.lids.forEach((identifier) => {
this.unloadRecord(identifier);
});
} else {
const typeCache = cache.types;
let identifiers = typeCache[type]?.lid;
if (identifiers) {
identifiers.forEach((identifier) => {
// if (rds.has(identifier)) {
this.unloadRecord(identifier);
// }
// TODO we don't remove the identifier, should we?
});
}
}
}
// TODO this should move into something coordinating operations
setRecordId(identifier: StableRecordIdentifier, id: string) {
const { type, lid } = identifier;
let oldId = identifier.id;
// ID absolutely can't be missing if the oldID is empty (missing Id in response for a new record)
assert(
`'${type}' was saved to the server, but the response does not have an id and your record does not either.`,
!(id === null && oldId === null)
);
// ID absolutely can't be different than oldID if oldID is not null
// TODO this assertion and restriction may not strictly be needed in the identifiers world
assert(
`Cannot update the id for '${type}:${lid}' from '${String(oldId)}' to '${id}'.`,
!(oldId !== null && id !== oldId)
);
// ID can be null if oldID is not null (altered ID in response for a record)
// however, this is more than likely a developer error.
if (oldId !== null && id === null) {
warn(
`Your ${type} record was saved to the server, but the response does not have an id.`,
!(oldId !== null && id === null)
);
return;
}
if (LOG_INSTANCE_CACHE) {
// eslint-disable-next-line no-console
console.log(`InstanceCache: updating id to '${id}' for record ${String(identifier)}`);
}
let existingIdentifier = this.store.identifierCache.peekRecordIdentifier({ type, id });
assert(
`'${type}' was saved to the server, but the response returned the new id '${id}', which has already been used with another record.'`,
!existingIdentifier || existingIdentifier === identifier
);
if (identifier.id === null) {
// TODO potentially this needs to handle merged result
this.store.identifierCache.updateRecordIdentifier(identifier, { type, id });
}
// TODO update resource cache if needed ?
// TODO handle consequences of identifier merge for notifications
this.store.notifications.notify(identifier, 'identity');
}
}
function _resourceIsFullDeleted(identifier: StableRecordIdentifier, cache: Cache): boolean {
return cache.isDeletionCommitted(identifier) || (cache.isNew(identifier) && cache.isDeleted(identifier));
}
export function resourceIsFullyDeleted(instanceCache: InstanceCache, identifier: StableRecordIdentifier): boolean {
const cache = instanceCache.cache;
return !cache || _resourceIsFullDeleted(identifier, cache);
}
/*
When a find request is triggered on the store, the user can optionally pass in
attributes and relationships to be preloaded. These are meant to behave as if they
came back from the server, except the user obtained them out of band and is informing
the store of their existence. The most common use case is for supporting client side
nested URLs, such as `/posts/1/comments/2` so the user can do
`store.findRecord('comment', 2, { preload: { post: 1 } })` without having to fetch the post.
Preloaded data can be attributes and relationships passed in either as IDs or as actual
models.
*/
type PreloadRelationshipValue = RecordInstance | string;
export function preloadData(store: Store, identifier: StableRecordIdentifier, preload: Record<string, unknown>) {
let jsonPayload: JsonApiResource = {};
//TODO(Igor) consider the polymorphic case
const schemas = store.getSchemaDefinitionService();
const relationships = schemas.relationshipsDefinitionFor(identifier);
Object.keys(preload).forEach((key) => {
let preloadValue = preload[key];
let relationshipMeta = relationships[key];
if (relationshipMeta) {
if (!jsonPayload.relationships) {
jsonPayload.relationships = {};
}
jsonPayload.relationships[key] = preloadRelationship(
relationshipMeta,
preloadValue as PreloadRelationshipValue | null | Array<PreloadRelationshipValue>
);
} else {
if (!jsonPayload.attributes) {
jsonPayload.attributes = {};
}
jsonPayload.attributes[key] = preloadValue;
}
});
const cache = store.cache;
const hasRecord = Boolean(store._instanceCache.peek(identifier));
cache.upsert(identifier, jsonPayload, hasRecord);
}
function preloadRelationship(
schema: RelationshipSchema,
preloadValue: PreloadRelationshipValue | null | Array<PreloadRelationshipValue>
): JsonApiRelationship {
const relatedType = schema.type;
if (schema.kind === 'hasMany') {
assert('You need to pass in an array to set a hasMany property on a record', Array.isArray(preloadValue));
return { data: preloadValue.map((value) => _convertPreloadRelationshipToJSON(value, relatedType)) };
}
assert('You should not pass in an array to set a belongsTo property on a record', !Array.isArray(preloadValue));
return { data: preloadValue ? _convertPreloadRelationshipToJSON(preloadValue, relatedType) : null };
}
/*
findRecord('user', '1', { preload: { friends: ['1'] }});
findRecord('user', '1', { preload: { friends: [record] }});
*/
function _convertPreloadRelationshipToJSON(
value: RecordInstance | string,
type: string
): ExistingResourceIdentifierObject | NewResourceIdentifierObject {
if (typeof value === 'string' || typeof value === 'number') {
return { type, id: value };
}
// TODO if not a record instance assert it's an identifier
// and allow identifiers to be used
return recordIdentifierFor(value);
}
export function _clearCaches() {
RecordCache.clear();
StoreMap.clear();
CacheForIdentifierCache.clear();
}