@expo/entity 0.42.0 → 0.44.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/build/AuthorizationResultBasedEntityMutator.d.ts +87 -2
- package/build/AuthorizationResultBasedEntityMutator.js +122 -8
- package/build/AuthorizationResultBasedEntityMutator.js.map +1 -1
- package/build/EntityFieldDefinition.d.ts +1 -1
- package/build/EntityFieldDefinition.js +1 -1
- package/build/EntityFieldDefinition.js.map +1 -1
- package/build/EntityLoaderUtils.d.ts +15 -3
- package/build/EntityLoaderUtils.js +25 -10
- package/build/EntityLoaderUtils.js.map +1 -1
- package/build/EntityQueryContext.d.ts +53 -7
- package/build/EntityQueryContext.js +65 -10
- package/build/EntityQueryContext.js.map +1 -1
- package/build/EntityQueryContextProvider.d.ts +5 -1
- package/build/EntityQueryContextProvider.js +11 -4
- package/build/EntityQueryContextProvider.js.map +1 -1
- package/build/IEntityGenericCacher.d.ts +2 -2
- package/build/internal/CompositeFieldHolder.d.ts +13 -0
- package/build/internal/CompositeFieldHolder.js +7 -0
- package/build/internal/CompositeFieldHolder.js.map +1 -1
- package/build/internal/CompositeFieldValueMap.d.ts +3 -0
- package/build/internal/CompositeFieldValueMap.js +3 -0
- package/build/internal/CompositeFieldValueMap.js.map +1 -1
- package/build/internal/EntityDataManager.d.ts +22 -3
- package/build/internal/EntityDataManager.js +99 -11
- package/build/internal/EntityDataManager.js.map +1 -1
- package/build/internal/EntityFieldTransformationUtils.d.ts +20 -0
- package/build/internal/EntityFieldTransformationUtils.js +15 -0
- package/build/internal/EntityFieldTransformationUtils.js.map +1 -1
- package/build/internal/EntityLoadInterfaces.d.ts +8 -0
- package/build/internal/EntityLoadInterfaces.js +2 -0
- package/build/internal/EntityLoadInterfaces.js.map +1 -1
- package/build/internal/EntityTableDataCoordinator.d.ts +2 -0
- package/build/internal/EntityTableDataCoordinator.js +2 -0
- package/build/internal/EntityTableDataCoordinator.js.map +1 -1
- package/build/internal/ReadThroughEntityCache.d.ts +8 -0
- package/build/internal/ReadThroughEntityCache.js +5 -0
- package/build/internal/ReadThroughEntityCache.js.map +1 -1
- package/build/internal/SingleFieldHolder.d.ts +7 -0
- package/build/internal/SingleFieldHolder.js +7 -0
- package/build/internal/SingleFieldHolder.js.map +1 -1
- package/build/metrics/EntityMetricsUtils.d.ts +4 -3
- package/build/metrics/EntityMetricsUtils.js +6 -3
- package/build/metrics/EntityMetricsUtils.js.map +1 -1
- package/build/metrics/IEntityMetricsAdapter.d.ts +21 -0
- package/build/metrics/IEntityMetricsAdapter.js.map +1 -1
- package/package.json +13 -13
- package/src/AuthorizationResultBasedEntityMutator.ts +133 -15
- package/src/EntityFieldDefinition.ts +1 -1
- package/src/EntityLoaderUtils.ts +43 -12
- package/src/EntityQueryContext.ts +68 -13
- package/src/EntityQueryContextProvider.ts +20 -3
- package/src/IEntityGenericCacher.ts +2 -2
- package/src/__tests__/AuthorizationResultBasedEntityLoader-test.ts +98 -0
- package/src/__tests__/EntityQueryContext-test.ts +141 -26
- package/src/internal/CompositeFieldHolder.ts +15 -0
- package/src/internal/CompositeFieldValueMap.ts +3 -0
- package/src/internal/EntityDataManager.ts +170 -10
- package/src/internal/EntityFieldTransformationUtils.ts +20 -0
- package/src/internal/EntityLoadInterfaces.ts +8 -0
- package/src/internal/EntityTableDataCoordinator.ts +2 -0
- package/src/internal/ReadThroughEntityCache.ts +8 -0
- package/src/internal/SingleFieldHolder.ts +7 -0
- package/src/internal/__tests__/EntityDataManager-test.ts +708 -186
- package/src/metrics/EntityMetricsUtils.ts +7 -0
- package/src/metrics/IEntityMetricsAdapter.ts +27 -0
- package/src/utils/__testfixtures__/StubDatabaseAdapter.ts +13 -1
|
@@ -27,7 +27,93 @@ import { timeAndLogMutationEventAsync } from './metrics/EntityMetricsUtils';
|
|
|
27
27
|
import IEntityMetricsAdapter, { EntityMetricsMutationType } from './metrics/IEntityMetricsAdapter';
|
|
28
28
|
import { mapMapAsync } from './utils/collections/maps';
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
/**
|
|
31
|
+
* Base class for entity mutators. Mutators are builder-like class instances that are
|
|
32
|
+
* responsible for creating, updating, and deleting entities, and for calling out to
|
|
33
|
+
* the loader at appropriate times to invalidate the cache(s). The loader is responsible
|
|
34
|
+
* for deciding which cache entries to invalidate for the entity being mutated.
|
|
35
|
+
*
|
|
36
|
+
* ## Notes on invalidation
|
|
37
|
+
*
|
|
38
|
+
* The primary goal of invalidation is to ensure that at any point in time, a load
|
|
39
|
+
* for an entity through a cache or layers of caches will return the most up-to-date
|
|
40
|
+
* value for that entity according to the source of truth stored in the database, and thus
|
|
41
|
+
* the read-through cache must be kept consistent (only current values are stored, others are invalidated).
|
|
42
|
+
*
|
|
43
|
+
* This is done by invalidating the cache for the entity being mutated at the end of the transaction
|
|
44
|
+
* in which the mutation is performed. This ensures that the cache is invalidated as close as possible
|
|
45
|
+
* to when the source-of-truth is updated in the database, as to reduce the likelihood of
|
|
46
|
+
* collisions with loads done at the same time on other machines.
|
|
47
|
+
*
|
|
48
|
+
* <blockquote>
|
|
49
|
+
* The easiest way to demonstrate this reasoning is via some counter-examples.
|
|
50
|
+
* For sake of demonstration, let's say we did invalidation immediately after the mutation instead of
|
|
51
|
+
* at the end of the transaction.
|
|
52
|
+
*
|
|
53
|
+
* Example 1:
|
|
54
|
+
* - t=0. A transaction is started on machine A and within this transaction a new entity is created.
|
|
55
|
+
* The cache for the entity is invalidated.
|
|
56
|
+
* - t=1. Machine B tries to load the same entity outside of a transaction. It does not yet exist
|
|
57
|
+
* so it negatively caches the entity.
|
|
58
|
+
* - t=2. Machine A commits the transaction.
|
|
59
|
+
* - t=3. Machine C tries to load the same entity outside of a transaction. It is negatively cached
|
|
60
|
+
* so it returns null, even though it exists in the database.
|
|
61
|
+
*
|
|
62
|
+
* One can see that it's strictly better to invalidate the transaction at t=2 as it would remove the
|
|
63
|
+
* negative cache entry for the entity, thus leaving the cache consistent with the database.
|
|
64
|
+
*
|
|
65
|
+
* Example 2:
|
|
66
|
+
* - t=0. Entity A is created and read into the cache (everthing is consistent at this point in time).
|
|
67
|
+
* - t=1. Machine A starts a transaction, reads entity A, updates it, and invalidates the cache.
|
|
68
|
+
* - t=2. Machine B reads entity A outside of a transaction. Since the transaction from the step above
|
|
69
|
+
* has not yet been committed, the changes within that transaction are not yet visible. It stores
|
|
70
|
+
* the entity in the cache.
|
|
71
|
+
* - t=3. Machine A commits the transaction.
|
|
72
|
+
* - t=4. Machine C reads entity A outside of a transaction. It returns the entity from the cache which
|
|
73
|
+
* is now inconsistent with the database.
|
|
74
|
+
*
|
|
75
|
+
* Again, one can see that it's strictly better to invalidate the transaction at t=3 as it would remove the
|
|
76
|
+
* stale cache entry for the entity, thus leaving the cache consistent with the database.
|
|
77
|
+
*
|
|
78
|
+
* For deletions, one can imagine a similar series of events occurring.
|
|
79
|
+
* </blockquote>
|
|
80
|
+
*
|
|
81
|
+
* #### Invalidation as it pertains to transactions and nested transactions
|
|
82
|
+
*
|
|
83
|
+
* Invalidation becomes slightly more complex when nested transactions are considered. The general
|
|
84
|
+
* guiding principle here is that over-invalidation is strictly better than under-invalidation
|
|
85
|
+
* as far as consistency goes. This is because the database is the source of truth.
|
|
86
|
+
*
|
|
87
|
+
* For the visible-to-the-outside-world caches (cache adapters), the invalidations are done at the
|
|
88
|
+
* end of the outermost transaction (as discussed above), plus at the end of each nested transaction.
|
|
89
|
+
* While only the outermost transaction is strictly necessary for these cache adapter invalidations,
|
|
90
|
+
* the mental model of doing it at the end of each transaction, nested or otherwise, is easier to reason about.
|
|
91
|
+
*
|
|
92
|
+
* For the dataloader caches (per-transaction local caches), the invalidation is done multiple times
|
|
93
|
+
* (over-invalidation) to better ensure that the caches are always consistent with the database as read within
|
|
94
|
+
* the transaction or nested transaction.
|
|
95
|
+
* 1. Immediately after the mutation is performed (but before the transaction or nested transaction is committed).
|
|
96
|
+
* 2. At the end of the transaction (or nested transaction) itself.
|
|
97
|
+
* 3. At the end of the outermost transaction (if this is a nested transaction) and all of that transactions's nested transactions recursively.
|
|
98
|
+
*
|
|
99
|
+
* This over-invalidation is done because transaction isolation semantics are not consistent across all
|
|
100
|
+
* databases (some databases don't even have true nested transactions at all), meaning that whether
|
|
101
|
+
* a change made in a nested transaction is visible to the parent transaction(s) is not necessarily known.
|
|
102
|
+
* This means that the only way to ensure that the dataloader caches are consistent
|
|
103
|
+
* with the database is to invalidate them often, thus delegating consistency to the database. Invalidation
|
|
104
|
+
* of local caches is synchronous and immediate, so the performance impact of over-invalidation is negligible.
|
|
105
|
+
*
|
|
106
|
+
* #### Invalidation pitfalls
|
|
107
|
+
*
|
|
108
|
+
* One may have noticed that the above invalidation strategy still isn't perfect. Cache invalidation is hard.
|
|
109
|
+
* There still exists a very short moment in time between when invalidation occurs and when the transaction is committed,
|
|
110
|
+
* so dirty cache writes are still possible, especially in systems reading an object frequently and writing to the same object.
|
|
111
|
+
* For now, the entity framework does not attempt to provide a further solution to this problem since it is likely
|
|
112
|
+
* solutions will be case-specific. Some fun reads on the topic:
|
|
113
|
+
* - https://engineering.fb.com/2013/06/25/core-infra/tao-the-power-of-the-graph/
|
|
114
|
+
* - https://hazelcast.com/blog/a-hitchhikers-guide-to-caching-patterns/
|
|
115
|
+
*/
|
|
116
|
+
export abstract class AuthorizationResultBasedBaseMutator<
|
|
31
117
|
TFields extends Record<string, any>,
|
|
32
118
|
TIDField extends keyof NonNullable<Pick<TFields, TSelectedFields>>,
|
|
33
119
|
TViewerContext extends ViewerContext,
|
|
@@ -224,6 +310,7 @@ export class AuthorizationResultBasedCreateMutator<
|
|
|
224
310
|
this.metricsAdapter,
|
|
225
311
|
EntityMetricsMutationType.CREATE,
|
|
226
312
|
this.entityClass.name,
|
|
313
|
+
this.queryContext,
|
|
227
314
|
)(this.createInTransactionAsync());
|
|
228
315
|
}
|
|
229
316
|
|
|
@@ -282,9 +369,14 @@ export class AuthorizationResultBasedCreateMutator<
|
|
|
282
369
|
|
|
283
370
|
const insertResult = await this.databaseAdapter.insertAsync(queryContext, this.fieldsForEntity);
|
|
284
371
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
)
|
|
372
|
+
// Invalidate all caches for the new entity so that any previously-negatively-cached loads
|
|
373
|
+
// are removed from the caches.
|
|
374
|
+
queryContext.appendPostCommitInvalidationCallback(async () => {
|
|
375
|
+
entityLoader.utils.invalidateFieldsForTransaction(queryContext, insertResult);
|
|
376
|
+
await entityLoader.utils.invalidateFieldsAsync(insertResult);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
entityLoader.utils.invalidateFieldsForTransaction(queryContext, insertResult);
|
|
288
380
|
|
|
289
381
|
const unauthorizedEntityAfterInsert = entityLoader.utils.constructEntity(insertResult);
|
|
290
382
|
const newEntity = await enforceAsyncResult(
|
|
@@ -423,6 +515,7 @@ export class AuthorizationResultBasedUpdateMutator<
|
|
|
423
515
|
this.metricsAdapter,
|
|
424
516
|
EntityMetricsMutationType.UPDATE,
|
|
425
517
|
this.entityClass.name,
|
|
518
|
+
this.queryContext,
|
|
426
519
|
)(this.updateInTransactionAsync(false, null));
|
|
427
520
|
}
|
|
428
521
|
|
|
@@ -499,15 +592,31 @@ export class AuthorizationResultBasedUpdateMutator<
|
|
|
499
592
|
);
|
|
500
593
|
}
|
|
501
594
|
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
595
|
+
// Invalidate all caches for the entity being updated so that any previously-cached loads
|
|
596
|
+
// are consistent. This means:
|
|
597
|
+
// - any query that returned this entity (pre-update) in the past should no longer have that entity in cache for that query.
|
|
598
|
+
// - any query that will return this entity (post-update) that would not have returned the entity in the past should not
|
|
599
|
+
// be negatively cached for the entity.
|
|
600
|
+
// To do this we simply invalidate all of the entity's caches for both the previous version of the entity and the upcoming
|
|
601
|
+
// version of the entity.
|
|
602
|
+
|
|
603
|
+
queryContext.appendPostCommitInvalidationCallback(async () => {
|
|
604
|
+
entityLoader.utils.invalidateFieldsForTransaction(
|
|
605
|
+
queryContext,
|
|
505
606
|
this.originalEntity.getAllDatabaseFields(),
|
|
506
|
-
)
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
607
|
+
);
|
|
608
|
+
entityLoader.utils.invalidateFieldsForTransaction(queryContext, this.fieldsForEntity);
|
|
609
|
+
await Promise.all([
|
|
610
|
+
entityLoader.utils.invalidateFieldsAsync(this.originalEntity.getAllDatabaseFields()),
|
|
611
|
+
entityLoader.utils.invalidateFieldsAsync(this.fieldsForEntity),
|
|
612
|
+
]);
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
entityLoader.utils.invalidateFieldsForTransaction(
|
|
616
|
+
queryContext,
|
|
617
|
+
this.originalEntity.getAllDatabaseFields(),
|
|
510
618
|
);
|
|
619
|
+
entityLoader.utils.invalidateFieldsForTransaction(queryContext, this.fieldsForEntity);
|
|
511
620
|
|
|
512
621
|
const updatedEntity = await enforceAsyncResult(
|
|
513
622
|
entityLoader.loadByIDAsync(entityAboutToBeUpdated.getID()),
|
|
@@ -639,6 +748,7 @@ export class AuthorizationResultBasedDeleteMutator<
|
|
|
639
748
|
this.metricsAdapter,
|
|
640
749
|
EntityMetricsMutationType.DELETE,
|
|
641
750
|
this.entityClass.name,
|
|
751
|
+
this.queryContext,
|
|
642
752
|
)(this.deleteInTransactionAsync(new Set(), false, null));
|
|
643
753
|
}
|
|
644
754
|
|
|
@@ -708,11 +818,19 @@ export class AuthorizationResultBasedDeleteMutator<
|
|
|
708
818
|
previousValue: null,
|
|
709
819
|
cascadingDeleteCause,
|
|
710
820
|
});
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
821
|
+
|
|
822
|
+
// Invalidate all caches for the entity so that any previously-cached loads
|
|
823
|
+
// are removed from the caches.
|
|
824
|
+
queryContext.appendPostCommitInvalidationCallback(async () => {
|
|
825
|
+
entityLoader.utils.invalidateFieldsForTransaction(
|
|
826
|
+
queryContext,
|
|
714
827
|
this.entity.getAllDatabaseFields(),
|
|
715
|
-
)
|
|
828
|
+
);
|
|
829
|
+
await entityLoader.utils.invalidateFieldsAsync(this.entity.getAllDatabaseFields());
|
|
830
|
+
});
|
|
831
|
+
entityLoader.utils.invalidateFieldsForTransaction(
|
|
832
|
+
queryContext,
|
|
833
|
+
this.entity.getAllDatabaseFields(),
|
|
716
834
|
);
|
|
717
835
|
|
|
718
836
|
await this.executeMutationTriggersAsync(
|
|
@@ -177,7 +177,7 @@ export abstract class EntityFieldDefinition<T, TRequireExplicitCache extends boo
|
|
|
177
177
|
|
|
178
178
|
// @ts-expect-error this is to ensure that different constructor requirements produce incompatible
|
|
179
179
|
// objects in the eyes of the type system
|
|
180
|
-
|
|
180
|
+
protected readonly _cacheRawSentinel: TRequireExplicitCache;
|
|
181
181
|
|
|
182
182
|
/**
|
|
183
183
|
* @param options - options for this field definition
|
package/src/EntityLoaderUtils.ts
CHANGED
|
@@ -4,11 +4,12 @@ import nullthrows from 'nullthrows';
|
|
|
4
4
|
import { IEntityClass } from './Entity';
|
|
5
5
|
import EntityConfiguration from './EntityConfiguration';
|
|
6
6
|
import EntityPrivacyPolicy, { EntityPrivacyPolicyEvaluationContext } from './EntityPrivacyPolicy';
|
|
7
|
-
import { EntityQueryContext } from './EntityQueryContext';
|
|
7
|
+
import { EntityQueryContext, EntityTransactionalQueryContext } from './EntityQueryContext';
|
|
8
8
|
import ReadonlyEntity from './ReadonlyEntity';
|
|
9
9
|
import ViewerContext from './ViewerContext';
|
|
10
10
|
import { pick } from './entityUtils';
|
|
11
11
|
import EntityDataManager from './internal/EntityDataManager';
|
|
12
|
+
import { LoadPair } from './internal/EntityLoadInterfaces';
|
|
12
13
|
import { SingleFieldHolder, SingleFieldValueHolder } from './internal/SingleFieldHolder';
|
|
13
14
|
import IEntityMetricsAdapter from './metrics/IEntityMetricsAdapter';
|
|
14
15
|
import { mapMapAsync } from './utils/collections/maps';
|
|
@@ -56,11 +57,9 @@ export default class EntityLoaderUtils<
|
|
|
56
57
|
protected readonly metricsAdapter: IEntityMetricsAdapter,
|
|
57
58
|
) {}
|
|
58
59
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
*/
|
|
63
|
-
async invalidateFieldsAsync(objectFields: Readonly<TFields>): Promise<void> {
|
|
60
|
+
private getKeyValuePairsFromObjectFields(
|
|
61
|
+
objectFields: Readonly<TFields>,
|
|
62
|
+
): readonly LoadPair<TFields, TIDField, any, any, any>[] {
|
|
64
63
|
const keys = Object.keys(objectFields) as (keyof TFields)[];
|
|
65
64
|
const singleFieldKeyValues = keys
|
|
66
65
|
.map((fieldName: keyof TFields) => {
|
|
@@ -85,22 +84,54 @@ export default class EntityLoaderUtils<
|
|
|
85
84
|
: null;
|
|
86
85
|
})
|
|
87
86
|
.filter((kv) => kv !== null);
|
|
87
|
+
return [...singleFieldKeyValues, ...compositeFieldKeyValues];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Invalidate all caches and local dataloaders for an entity's fields. Exposed primarily for internal use by EntityMutator.
|
|
92
|
+
* @param objectFields - entity data object to be invalidated
|
|
93
|
+
*/
|
|
94
|
+
public async invalidateFieldsAsync(objectFields: Readonly<TFields>): Promise<void> {
|
|
95
|
+
await this.dataManager.invalidateKeyValuePairsAsync(
|
|
96
|
+
this.getKeyValuePairsFromObjectFields(objectFields),
|
|
97
|
+
);
|
|
98
|
+
}
|
|
88
99
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
100
|
+
/**
|
|
101
|
+
* Invalidate all local dataloaders specific to a transaction for an entity's fields. Exposed primarily for internal use by EntityMutator.
|
|
102
|
+
* @param objectFields - entity data object to be invalidated
|
|
103
|
+
*/
|
|
104
|
+
public invalidateFieldsForTransaction(
|
|
105
|
+
queryContext: EntityTransactionalQueryContext,
|
|
106
|
+
objectFields: Readonly<TFields>,
|
|
107
|
+
): void {
|
|
108
|
+
this.dataManager.invalidateKeyValuePairsForTransaction(
|
|
109
|
+
queryContext,
|
|
110
|
+
this.getKeyValuePairsFromObjectFields(objectFields),
|
|
111
|
+
);
|
|
93
112
|
}
|
|
94
113
|
|
|
95
114
|
/**
|
|
96
|
-
* Invalidate all caches for an entity. One potential use case would be to keep the entity
|
|
115
|
+
* Invalidate all caches and local dataloaders for an entity. One potential use case would be to keep the entity
|
|
97
116
|
* framework in sync with changes made to data outside of the framework.
|
|
98
117
|
* @param entity - entity to be invalidated
|
|
99
118
|
*/
|
|
100
|
-
async invalidateEntityAsync(entity: TEntity): Promise<void> {
|
|
119
|
+
public async invalidateEntityAsync(entity: TEntity): Promise<void> {
|
|
101
120
|
await this.invalidateFieldsAsync(entity.getAllDatabaseFields());
|
|
102
121
|
}
|
|
103
122
|
|
|
123
|
+
/**
|
|
124
|
+
* Invalidate all local dataloaders specific to a transaction for an entity. One potential use case would be to keep the entity
|
|
125
|
+
* framework in sync with changes made to data outside of the framework.
|
|
126
|
+
* @param entity - entity to be invalidated
|
|
127
|
+
*/
|
|
128
|
+
public invalidateEntityForTransaction(
|
|
129
|
+
queryContext: EntityTransactionalQueryContext,
|
|
130
|
+
entity: TEntity,
|
|
131
|
+
): void {
|
|
132
|
+
this.invalidateFieldsForTransaction(queryContext, entity.getAllDatabaseFields());
|
|
133
|
+
}
|
|
134
|
+
|
|
104
135
|
/**
|
|
105
136
|
* Construct an entity from a fields object (applying field selection if applicable),
|
|
106
137
|
* checking that the ID field is specified.
|
|
@@ -14,8 +14,15 @@ export enum TransactionIsolationLevel {
|
|
|
14
14
|
SERIALIZABLE = 'SERIALIZABLE',
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
export enum TransactionalDataLoaderMode {
|
|
18
|
+
ENABLED = 'ENABLED',
|
|
19
|
+
ENABLED_BATCH_ONLY = 'ENABLED_BATCH_ONLY',
|
|
20
|
+
DISABLED = 'DISABLED',
|
|
21
|
+
}
|
|
22
|
+
|
|
17
23
|
export type TransactionConfig = {
|
|
18
24
|
isolationLevel?: TransactionIsolationLevel;
|
|
25
|
+
transactionalDataLoaderMode?: TransactionalDataLoaderMode;
|
|
19
26
|
};
|
|
20
27
|
|
|
21
28
|
/**
|
|
@@ -28,7 +35,7 @@ export type TransactionConfig = {
|
|
|
28
35
|
export abstract class EntityQueryContext {
|
|
29
36
|
constructor(private readonly queryInterface: any) {}
|
|
30
37
|
|
|
31
|
-
abstract isInTransaction():
|
|
38
|
+
abstract isInTransaction(): this is EntityTransactionalQueryContext;
|
|
32
39
|
|
|
33
40
|
getQueryInterface(): any {
|
|
34
41
|
return this.queryInterface;
|
|
@@ -54,7 +61,7 @@ export class EntityNonTransactionalQueryContext extends EntityQueryContext {
|
|
|
54
61
|
super(queryInterface);
|
|
55
62
|
}
|
|
56
63
|
|
|
57
|
-
isInTransaction():
|
|
64
|
+
override isInTransaction(): this is EntityTransactionalQueryContext {
|
|
58
65
|
return false;
|
|
59
66
|
}
|
|
60
67
|
|
|
@@ -75,6 +82,11 @@ export class EntityNonTransactionalQueryContext extends EntityQueryContext {
|
|
|
75
82
|
* dependent triggers and validators will run within the transaction.
|
|
76
83
|
*/
|
|
77
84
|
export class EntityTransactionalQueryContext extends EntityQueryContext {
|
|
85
|
+
/**
|
|
86
|
+
* @internal
|
|
87
|
+
*/
|
|
88
|
+
public readonly childQueryContexts: EntityNestedTransactionalQueryContext[] = [];
|
|
89
|
+
|
|
78
90
|
private readonly postCommitInvalidationCallbacks: PostCommitCallback[] = [];
|
|
79
91
|
private readonly postCommitCallbacks: PostCommitCallback[] = [];
|
|
80
92
|
|
|
@@ -83,6 +95,11 @@ export class EntityTransactionalQueryContext extends EntityQueryContext {
|
|
|
83
95
|
constructor(
|
|
84
96
|
queryInterface: any,
|
|
85
97
|
private readonly entityQueryContextProvider: EntityQueryContextProvider,
|
|
98
|
+
/**
|
|
99
|
+
* @internal
|
|
100
|
+
*/
|
|
101
|
+
readonly transactionId: string,
|
|
102
|
+
public readonly transactionalDataLoaderMode: TransactionalDataLoaderMode,
|
|
86
103
|
) {
|
|
87
104
|
super(queryInterface);
|
|
88
105
|
}
|
|
@@ -121,6 +138,9 @@ export class EntityTransactionalQueryContext extends EntityQueryContext {
|
|
|
121
138
|
this.postCommitCallbacks.push(callback);
|
|
122
139
|
}
|
|
123
140
|
|
|
141
|
+
/**
|
|
142
|
+
* @internal
|
|
143
|
+
*/
|
|
124
144
|
public async runPreCommitCallbacksAsync(): Promise<void> {
|
|
125
145
|
const callbacks = [...this.preCommitCallbacks]
|
|
126
146
|
.sort((a, b) => a.order - b.order)
|
|
@@ -132,6 +152,9 @@ export class EntityTransactionalQueryContext extends EntityQueryContext {
|
|
|
132
152
|
}
|
|
133
153
|
}
|
|
134
154
|
|
|
155
|
+
/**
|
|
156
|
+
* @internal
|
|
157
|
+
*/
|
|
135
158
|
public async runPostCommitCallbacksAsync(): Promise<void> {
|
|
136
159
|
const invalidationCallbacks = [...this.postCommitInvalidationCallbacks];
|
|
137
160
|
this.postCommitInvalidationCallbacks.length = 0;
|
|
@@ -142,10 +165,14 @@ export class EntityTransactionalQueryContext extends EntityQueryContext {
|
|
|
142
165
|
await Promise.all(callbacks.map((callback) => callback()));
|
|
143
166
|
}
|
|
144
167
|
|
|
145
|
-
isInTransaction():
|
|
168
|
+
override isInTransaction(): this is EntityTransactionalQueryContext {
|
|
146
169
|
return true;
|
|
147
170
|
}
|
|
148
171
|
|
|
172
|
+
isInNestedTransaction(): this is EntityNestedTransactionalQueryContext {
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
|
|
149
176
|
async runInTransactionIfNotInTransactionAsync<T>(
|
|
150
177
|
transactionScope: (queryContext: EntityTransactionalQueryContext) => Promise<T>,
|
|
151
178
|
transactionConfig?: TransactionConfig,
|
|
@@ -181,31 +208,59 @@ export class EntityNestedTransactionalQueryContext extends EntityTransactionalQu
|
|
|
181
208
|
|
|
182
209
|
constructor(
|
|
183
210
|
queryInterface: any,
|
|
184
|
-
|
|
211
|
+
/**
|
|
212
|
+
* @internal
|
|
213
|
+
*/
|
|
214
|
+
readonly parentQueryContext: EntityTransactionalQueryContext,
|
|
185
215
|
entityQueryContextProvider: EntityQueryContextProvider,
|
|
216
|
+
transactionId: string,
|
|
217
|
+
transactionalDataLoaderMode: TransactionalDataLoaderMode,
|
|
186
218
|
) {
|
|
187
|
-
super(queryInterface, entityQueryContextProvider);
|
|
219
|
+
super(queryInterface, entityQueryContextProvider, transactionId, transactionalDataLoaderMode);
|
|
220
|
+
parentQueryContext.childQueryContexts.push(this);
|
|
188
221
|
}
|
|
189
222
|
|
|
190
|
-
|
|
191
|
-
|
|
223
|
+
override isInNestedTransaction(): this is EntityNestedTransactionalQueryContext {
|
|
224
|
+
return true;
|
|
192
225
|
}
|
|
193
226
|
|
|
194
|
-
public override
|
|
227
|
+
public override appendPostCommitCallback(callback: PostCommitCallback): void {
|
|
228
|
+
// explicitly do not add to the super-class's post-commit callbacks
|
|
229
|
+
// instead, we will add them to the parent transaction's post-commit callbacks
|
|
230
|
+
// after the nested transaction has been committed
|
|
195
231
|
this.postCommitCallbacksToTransfer.push(callback);
|
|
196
232
|
}
|
|
197
233
|
|
|
198
|
-
public override
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
);
|
|
234
|
+
public override appendPostCommitInvalidationCallback(callback: PostCommitCallback): void {
|
|
235
|
+
super.appendPostCommitInvalidationCallback(callback);
|
|
236
|
+
this.postCommitInvalidationCallbacksToTransfer.push(callback);
|
|
202
237
|
}
|
|
203
238
|
|
|
204
|
-
|
|
239
|
+
/**
|
|
240
|
+
* The behavior of callbacks for nested transactions are a bit different than for normal
|
|
241
|
+
* transactions.
|
|
242
|
+
* - Post-commit (non-invalidation) callbacks are run at the end of the outermost transaction
|
|
243
|
+
* since they often contain side-effects that only should run if the transaction doesn't roll back.
|
|
244
|
+
* The outermost transaction has the final say on the commit state of itself and all sub-transactions.
|
|
245
|
+
* - Invalidation callbacks are run at the end of both the nested transaction iteself but also transferred
|
|
246
|
+
* to the parent transaction to be run at the end of it (and recurse upwards, accumulating invalations).
|
|
247
|
+
* This is to ensure the dataloader cache is never stale no matter the DBMS transaction isolation
|
|
248
|
+
* semantics. See the note in `AuthorizationResultBasedBaseMutator` for more details.
|
|
249
|
+
*
|
|
250
|
+
* @internal
|
|
251
|
+
*/
|
|
252
|
+
public override async runPostCommitCallbacksAsync(): Promise<void> {
|
|
253
|
+
// run the post-commit callbacks for the nested transaction now
|
|
254
|
+
// (this technically also would run regular post-commit callbacks, but they are empty)
|
|
255
|
+
await super.runPostCommitCallbacksAsync();
|
|
256
|
+
|
|
257
|
+
// transfer a copy of the post-commit invalidation callbacks to the parent transaction
|
|
258
|
+
// to also be run at the end of it (or recurse in the case of the parent transaction being nested as well)
|
|
205
259
|
for (const callback of this.postCommitInvalidationCallbacksToTransfer) {
|
|
206
260
|
this.parentQueryContext.appendPostCommitInvalidationCallback(callback);
|
|
207
261
|
}
|
|
208
262
|
|
|
263
|
+
// transfer post-commit callbacks to patent
|
|
209
264
|
for (const callback of this.postCommitCallbacksToTransfer) {
|
|
210
265
|
this.parentQueryContext.appendPostCommitCallback(callback);
|
|
211
266
|
}
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
|
|
1
3
|
import {
|
|
2
4
|
EntityTransactionalQueryContext,
|
|
3
5
|
EntityNonTransactionalQueryContext,
|
|
4
6
|
EntityNestedTransactionalQueryContext,
|
|
5
7
|
TransactionConfig,
|
|
8
|
+
TransactionalDataLoaderMode,
|
|
6
9
|
} from './EntityQueryContext';
|
|
7
10
|
|
|
8
11
|
/**
|
|
@@ -21,6 +24,13 @@ export default abstract class EntityQueryContextProvider {
|
|
|
21
24
|
*/
|
|
22
25
|
protected abstract getQueryInterface(): any;
|
|
23
26
|
|
|
27
|
+
/**
|
|
28
|
+
* @returns true if the transactional dataloader should be disabled for all transactions.
|
|
29
|
+
*/
|
|
30
|
+
protected defaultTransactionalDataLoaderMode(): TransactionalDataLoaderMode {
|
|
31
|
+
return TransactionalDataLoaderMode.ENABLED;
|
|
32
|
+
}
|
|
33
|
+
|
|
24
34
|
/**
|
|
25
35
|
* Vend a transaction runner for use in runInTransactionAsync.
|
|
26
36
|
*/
|
|
@@ -43,7 +53,12 @@ export default abstract class EntityQueryContextProvider {
|
|
|
43
53
|
const [returnedValue, queryContext] = await this.createTransactionRunner<
|
|
44
54
|
[T, EntityTransactionalQueryContext]
|
|
45
55
|
>(transactionConfig)(async (queryInterface) => {
|
|
46
|
-
const queryContext = new EntityTransactionalQueryContext(
|
|
56
|
+
const queryContext = new EntityTransactionalQueryContext(
|
|
57
|
+
queryInterface,
|
|
58
|
+
this,
|
|
59
|
+
randomUUID(),
|
|
60
|
+
transactionConfig?.transactionalDataLoaderMode ?? this.defaultTransactionalDataLoaderMode(),
|
|
61
|
+
);
|
|
47
62
|
const result = await transactionScope(queryContext);
|
|
48
63
|
await queryContext.runPreCommitCallbacksAsync();
|
|
49
64
|
return [result, queryContext];
|
|
@@ -69,13 +84,15 @@ export default abstract class EntityQueryContextProvider {
|
|
|
69
84
|
innerQueryInterface,
|
|
70
85
|
outerQueryContext,
|
|
71
86
|
this,
|
|
87
|
+
randomUUID(),
|
|
88
|
+
outerQueryContext.transactionalDataLoaderMode,
|
|
72
89
|
);
|
|
73
90
|
const result = await transactionScope(innerQueryContext);
|
|
74
91
|
await innerQueryContext.runPreCommitCallbacksAsync();
|
|
75
92
|
return [result, innerQueryContext];
|
|
76
93
|
});
|
|
77
|
-
//
|
|
78
|
-
innerQueryContext.
|
|
94
|
+
// behavior of this call differs for nested transaction query contexts from regular transaction query contexts
|
|
95
|
+
await innerQueryContext.runPostCommitCallbacksAsync();
|
|
79
96
|
return returnedValue;
|
|
80
97
|
}
|
|
81
98
|
}
|
|
@@ -59,8 +59,8 @@ export default interface IEntityGenericCacher<
|
|
|
59
59
|
* from makeCacheKeyForStorage because invalidation can optionally be configured to invalidate a larger set of keys than
|
|
60
60
|
* the one for just the current cache version, which can be useful for things like push safety.
|
|
61
61
|
*
|
|
62
|
-
* @param key - load key
|
|
63
|
-
* @param
|
|
62
|
+
* @param key - load key for the cache keys
|
|
63
|
+
* @param value - load value for the cache keys
|
|
64
64
|
*/
|
|
65
65
|
makeCacheKeysForInvalidation<
|
|
66
66
|
TLoadKey extends IEntityLoadKey<TFields, TIDField, TSerializedLoadValue, TLoadValue>,
|
|
@@ -890,6 +890,104 @@ describe(AuthorizationResultBasedEntityLoader, () => {
|
|
|
890
890
|
).once();
|
|
891
891
|
});
|
|
892
892
|
|
|
893
|
+
it('invalidates upon invalidate by entity within transaction', async () => {
|
|
894
|
+
const viewerContext = instance(mock(ViewerContext));
|
|
895
|
+
const privacyPolicyEvaluationContext =
|
|
896
|
+
instance(
|
|
897
|
+
mock<
|
|
898
|
+
EntityPrivacyPolicyEvaluationContext<
|
|
899
|
+
TestFields,
|
|
900
|
+
'customIdField',
|
|
901
|
+
ViewerContext,
|
|
902
|
+
TestEntity
|
|
903
|
+
>
|
|
904
|
+
>(),
|
|
905
|
+
);
|
|
906
|
+
const metricsAdapter = instance(mock<IEntityMetricsAdapter>());
|
|
907
|
+
|
|
908
|
+
const privacyPolicy = instance(mock(TestEntityPrivacyPolicy));
|
|
909
|
+
const dataManagerMock = mock<EntityDataManager<TestFields, 'customIdField'>>();
|
|
910
|
+
const dataManagerInstance = instance(dataManagerMock);
|
|
911
|
+
|
|
912
|
+
const id1 = uuidv4();
|
|
913
|
+
const entityMock = mock(TestEntity);
|
|
914
|
+
const date = new Date();
|
|
915
|
+
|
|
916
|
+
when(entityMock.getAllDatabaseFields()).thenReturn({
|
|
917
|
+
customIdField: id1,
|
|
918
|
+
testIndexedField: 'h1',
|
|
919
|
+
intField: 5,
|
|
920
|
+
stringField: 'huh',
|
|
921
|
+
dateField: date,
|
|
922
|
+
nullableField: null,
|
|
923
|
+
});
|
|
924
|
+
const entityInstance = instance(entityMock);
|
|
925
|
+
|
|
926
|
+
await new StubQueryContextProvider().runInTransactionAsync(async (queryContext) => {
|
|
927
|
+
const utils = new EntityLoaderUtils(
|
|
928
|
+
viewerContext,
|
|
929
|
+
queryContext,
|
|
930
|
+
privacyPolicyEvaluationContext,
|
|
931
|
+
testEntityConfiguration,
|
|
932
|
+
TestEntity,
|
|
933
|
+
/* entitySelectedFields */ undefined,
|
|
934
|
+
privacyPolicy,
|
|
935
|
+
dataManagerInstance,
|
|
936
|
+
metricsAdapter,
|
|
937
|
+
);
|
|
938
|
+
const entityLoader = new AuthorizationResultBasedEntityLoader(
|
|
939
|
+
queryContext,
|
|
940
|
+
testEntityConfiguration,
|
|
941
|
+
TestEntity,
|
|
942
|
+
dataManagerInstance,
|
|
943
|
+
metricsAdapter,
|
|
944
|
+
utils,
|
|
945
|
+
);
|
|
946
|
+
entityLoader.utils.invalidateEntityForTransaction(queryContext, entityInstance);
|
|
947
|
+
|
|
948
|
+
verify(
|
|
949
|
+
dataManagerMock.invalidateKeyValuePairsForTransaction(queryContext, anything()),
|
|
950
|
+
).once();
|
|
951
|
+
verify(
|
|
952
|
+
dataManagerMock.invalidateKeyValuePairsForTransaction(
|
|
953
|
+
queryContext,
|
|
954
|
+
deepEqualEntityAware([
|
|
955
|
+
[
|
|
956
|
+
new SingleFieldHolder<TestFields, 'customIdField', 'customIdField'>('customIdField'),
|
|
957
|
+
new SingleFieldValueHolder<TestFields, 'customIdField'>(id1),
|
|
958
|
+
],
|
|
959
|
+
[
|
|
960
|
+
new SingleFieldHolder<TestFields, 'customIdField', 'testIndexedField'>(
|
|
961
|
+
'testIndexedField',
|
|
962
|
+
),
|
|
963
|
+
new SingleFieldValueHolder<TestFields, 'testIndexedField'>('h1'),
|
|
964
|
+
],
|
|
965
|
+
[
|
|
966
|
+
new SingleFieldHolder<TestFields, 'customIdField', 'intField'>('intField'),
|
|
967
|
+
new SingleFieldValueHolder<TestFields, 'intField'>(5),
|
|
968
|
+
],
|
|
969
|
+
[
|
|
970
|
+
new SingleFieldHolder<TestFields, 'customIdField', 'stringField'>('stringField'),
|
|
971
|
+
new SingleFieldValueHolder<TestFields, 'stringField'>('huh'),
|
|
972
|
+
],
|
|
973
|
+
[
|
|
974
|
+
new SingleFieldHolder<TestFields, 'customIdField', 'dateField'>('dateField'),
|
|
975
|
+
new SingleFieldValueHolder<TestFields, 'dateField'>(date),
|
|
976
|
+
],
|
|
977
|
+
[
|
|
978
|
+
new CompositeFieldHolder(['stringField', 'intField']),
|
|
979
|
+
new CompositeFieldValueHolder({ stringField: 'huh', intField: 5 }),
|
|
980
|
+
],
|
|
981
|
+
[
|
|
982
|
+
new CompositeFieldHolder(['stringField', 'testIndexedField']),
|
|
983
|
+
new CompositeFieldValueHolder({ stringField: 'huh', testIndexedField: 'h1' }),
|
|
984
|
+
],
|
|
985
|
+
]),
|
|
986
|
+
),
|
|
987
|
+
).once();
|
|
988
|
+
});
|
|
989
|
+
});
|
|
990
|
+
|
|
893
991
|
it('returns error result when not allowed', async () => {
|
|
894
992
|
const viewerContext = instance(mock(ViewerContext));
|
|
895
993
|
const privacyPolicyEvaluationContext =
|